Merge pull request #17420 from open-webui/dev

0.6.29
This commit is contained in:
Tim Jaeryang Baek 2025-09-17 11:32:59 -05:00 committed by GitHub
commit dddd1e44f3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
270 changed files with 8725 additions and 4566 deletions

View File

@ -5,6 +5,66 @@ 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.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

@ -1,3 +1,3 @@
export CORS_ALLOW_ORIGIN="http://localhost:5173" export CORS_ALLOW_ORIGIN="http://localhost:5173;http://localhost:8080"
PORT="${PORT:-8080}" PORT="${PORT:-8080}"
uvicorn open_webui.main:app --port $PORT --host 0.0.0.0 --forwarded-allow-ips '*' --reload uvicorn open_webui.main:app --port $PORT --host 0.0.0.0 --forwarded-allow-ips '*' --reload

View File

@ -513,6 +513,30 @@ OAUTH_GROUPS_CLAIM = PersistentConfig(
os.environ.get("OAUTH_GROUPS_CLAIM", os.environ.get("OAUTH_GROUP_CLAIM", "groups")), os.environ.get("OAUTH_GROUPS_CLAIM", os.environ.get("OAUTH_GROUP_CLAIM", "groups")),
) )
FEISHU_CLIENT_ID = PersistentConfig(
"FEISHU_CLIENT_ID",
"oauth.feishu.client_id",
os.environ.get("FEISHU_CLIENT_ID", ""),
)
FEISHU_CLIENT_SECRET = PersistentConfig(
"FEISHU_CLIENT_SECRET",
"oauth.feishu.client_secret",
os.environ.get("FEISHU_CLIENT_SECRET", ""),
)
FEISHU_OAUTH_SCOPE = PersistentConfig(
"FEISHU_OAUTH_SCOPE",
"oauth.feishu.scope",
os.environ.get("FEISHU_OAUTH_SCOPE", "contact:user.base:readonly"),
)
FEISHU_REDIRECT_URI = PersistentConfig(
"FEISHU_REDIRECT_URI",
"oauth.feishu.redirect_uri",
os.environ.get("FEISHU_REDIRECT_URI", ""),
)
ENABLE_OAUTH_ROLE_MANAGEMENT = PersistentConfig( ENABLE_OAUTH_ROLE_MANAGEMENT = PersistentConfig(
"ENABLE_OAUTH_ROLE_MANAGEMENT", "ENABLE_OAUTH_ROLE_MANAGEMENT",
"oauth.enable_role_mapping", "oauth.enable_role_mapping",
@ -705,6 +729,33 @@ def load_oauth_providers():
"register": oidc_oauth_register, "register": oidc_oauth_register,
} }
if FEISHU_CLIENT_ID.value and FEISHU_CLIENT_SECRET.value:
def feishu_oauth_register(client: OAuth):
client.register(
name="feishu",
client_id=FEISHU_CLIENT_ID.value,
client_secret=FEISHU_CLIENT_SECRET.value,
access_token_url="https://open.feishu.cn/open-apis/authen/v2/oauth/token",
authorize_url="https://accounts.feishu.cn/open-apis/authen/v1/authorize",
api_base_url="https://open.feishu.cn/open-apis",
userinfo_endpoint="https://open.feishu.cn/open-apis/authen/v1/user_info",
client_kwargs={
"scope": FEISHU_OAUTH_SCOPE.value,
**(
{"timeout": int(OAUTH_TIMEOUT.value)}
if OAUTH_TIMEOUT.value
else {}
),
},
redirect_uri=FEISHU_REDIRECT_URI.value,
)
OAUTH_PROVIDERS["feishu"] = {
"register": feishu_oauth_register,
"sub_claim": "user_id",
}
configured_providers = [] configured_providers = []
if GOOGLE_CLIENT_ID.value: if GOOGLE_CLIENT_ID.value:
configured_providers.append("Google") configured_providers.append("Google")
@ -712,6 +763,8 @@ def load_oauth_providers():
configured_providers.append("Microsoft") configured_providers.append("Microsoft")
if GITHUB_CLIENT_ID.value: if GITHUB_CLIENT_ID.value:
configured_providers.append("GitHub") configured_providers.append("GitHub")
if FEISHU_CLIENT_ID.value:
configured_providers.append("Feishu")
if configured_providers and not OPENID_PROVIDER_URL.value: if configured_providers and not OPENID_PROVIDER_URL.value:
provider_list = ", ".join(configured_providers) provider_list = ", ".join(configured_providers)
@ -2115,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,65 +83,69 @@ async def get_function_models(request):
pipe_models = [] pipe_models = []
for pipe in pipes: for pipe in pipes:
function_module = get_function_module_by_id(request, pipe.id) try:
function_module = get_function_module_by_id(request, pipe.id)
# Check if function is a manifold # Check if function is a manifold
if hasattr(function_module, "pipes"): if hasattr(function_module, "pipes"):
sub_pipes = []
# Handle pipes being a list, sync function, or async function
try:
if callable(function_module.pipes):
if asyncio.iscoroutinefunction(function_module.pipes):
sub_pipes = await function_module.pipes()
else:
sub_pipes = function_module.pipes()
else:
sub_pipes = function_module.pipes
except Exception as e:
log.exception(e)
sub_pipes = [] sub_pipes = []
log.debug( # Handle pipes being a list, sync function, or async function
f"get_function_models: function '{pipe.id}' is a manifold of {sub_pipes}" try:
) if callable(function_module.pipes):
if asyncio.iscoroutinefunction(function_module.pipes):
sub_pipes = await function_module.pipes()
else:
sub_pipes = function_module.pipes()
else:
sub_pipes = function_module.pipes
except Exception as e:
log.exception(e)
sub_pipes = []
for p in sub_pipes: log.debug(
sub_pipe_id = f'{pipe.id}.{p["id"]}' f"get_function_models: function '{pipe.id}' is a manifold of {sub_pipes}"
sub_pipe_name = p["name"] )
if hasattr(function_module, "name"): for p in sub_pipes:
sub_pipe_name = f"{function_module.name}{sub_pipe_name}" sub_pipe_id = f'{pipe.id}.{p["id"]}'
sub_pipe_name = p["name"]
pipe_flag = {"type": pipe.type} if hasattr(function_module, "name"):
sub_pipe_name = f"{function_module.name}{sub_pipe_name}"
pipe_flag = {"type": pipe.type}
pipe_models.append(
{
"id": sub_pipe_id,
"name": sub_pipe_name,
"object": "model",
"created": pipe.created_at,
"owned_by": "openai",
"pipe": pipe_flag,
}
)
else:
pipe_flag = {"type": "pipe"}
log.debug(
f"get_function_models: function '{pipe.id}' is a single pipe {{ 'id': {pipe.id}, 'name': {pipe.name} }}"
)
pipe_models.append( pipe_models.append(
{ {
"id": sub_pipe_id, "id": pipe.id,
"name": sub_pipe_name, "name": pipe.name,
"object": "model", "object": "model",
"created": pipe.created_at, "created": pipe.created_at,
"owned_by": "openai", "owned_by": "openai",
"pipe": pipe_flag, "pipe": pipe_flag,
} }
) )
else: except Exception as e:
pipe_flag = {"type": "pipe"} log.exception(e)
continue
log.debug(
f"get_function_models: function '{pipe.id}' is a single pipe {{ 'id': {pipe.id}, 'name': {pipe.name} }}"
)
pipe_models.append(
{
"id": pipe.id,
"name": pipe.name,
"object": "model",
"created": pipe.created_at,
"owned_by": "openai",
"pipe": pipe_flag,
}
)
return pipe_models return pipe_models
@ -221,10 +238,11 @@ async def generate_function_chat_completion(
oauth_token = None oauth_token = None
try: try:
oauth_token = request.app.state.oauth_manager.get_oauth_token( if request.cookies.get("oauth_session_id", None):
user.id, oauth_token = request.app.state.oauth_manager.get_oauth_token(
request.cookies.get("oauth_session_id", None), user.id,
) request.cookies.get("oauth_session_id", None),
)
except Exception as e: except Exception as e:
log.error(f"Error getting OAuth token: {e}") log.error(f"Error getting OAuth token: {e}")

View File

@ -448,6 +448,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 +1292,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,12 +1327,7 @@ async def get_models(
) )
) )
# Filter out models that the user does not have access to models = get_filtered_models(models, user)
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)
log.debug( log.debug(
f"/api/models returned filtered models accessible to the user: {json.dumps([model.get('id') for model in models])}" f"/api/models returned filtered models accessible to the user: {json.dumps([model.get('id') for model in models])}"
@ -1418,14 +1387,6 @@ async def chat_completion(
model_item = form_data.pop("model_item", {}) model_item = form_data.pop("model_item", {})
tasks = form_data.pop("background_tasks", None) tasks = form_data.pop("background_tasks", None)
oauth_token = None
try:
oauth_token = request.app.state.oauth_manager.get_oauth_token(
user.id, request.cookies.get("oauth_session_id", None)
)
except Exception as e:
log.error(f"Error getting OAuth token: {e}")
metadata = {} metadata = {}
try: try:
if not model_item.get("direct", False): if not model_item.get("direct", False):
@ -1738,6 +1699,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": app.state.config.ENABLE_ONEDRIVE_PERSONAL,
"enable_onedrive_business": app.state.config.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

@ -236,7 +236,7 @@ class ChatTable:
return chat.chat.get("title", "New Chat") return chat.chat.get("title", "New Chat")
def get_messages_by_chat_id(self, id: str) -> Optional[dict]: def get_messages_map_by_chat_id(self, id: str) -> Optional[dict]:
chat = self.get_chat_by_id(id) chat = self.get_chat_by_id(id)
if chat is None: if chat is None:
return None return None

View File

@ -37,6 +37,7 @@ class Function(Base):
class FunctionMeta(BaseModel): class FunctionMeta(BaseModel):
description: Optional[str] = None description: Optional[str] = None
manifest: Optional[dict] = {} manifest: Optional[dict] = {}
model_config = ConfigDict(extra="allow")
class FunctionModel(BaseModel): class FunctionModel(BaseModel):
@ -260,6 +261,29 @@ class FunctionsTable:
except Exception: except Exception:
return None return None
def update_function_metadata_by_id(
self, id: str, metadata: dict
) -> Optional[FunctionModel]:
with get_db() as db:
try:
function = db.get(Function, id)
if function:
if function.meta:
function.meta = {**function.meta, **metadata}
else:
function.meta = metadata
function.updated_at = int(time.time())
db.commit()
db.refresh(function)
return self.get_function_by_id(id)
else:
return None
except Exception as e:
log.exception(f"Error updating function metadata by id {id}: {e}")
return None
def get_user_valves_by_id_and_user_id( def get_user_valves_by_id_and_user_id(
self, id: str, user_id: str self, id: str, user_id: str
) -> Optional[dict]: ) -> Optional[dict]:

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

@ -97,15 +97,26 @@ class NoteTable:
db.commit() db.commit()
return note return note
def get_notes(self) -> list[NoteModel]: def get_notes(
self, skip: Optional[int] = None, limit: Optional[int] = None
) -> list[NoteModel]:
with get_db() as db: with get_db() as db:
notes = db.query(Note).order_by(Note.updated_at.desc()).all() query = db.query(Note).order_by(Note.updated_at.desc())
if skip is not None:
query = query.offset(skip)
if limit is not None:
query = query.limit(limit)
notes = query.all()
return [NoteModel.model_validate(note) for note in notes] return [NoteModel.model_validate(note) for note in notes]
def get_notes_by_user_id( def get_notes_by_user_id(
self, user_id: str, permission: str = "write" self,
user_id: str,
permission: str = "write",
skip: Optional[int] = None,
limit: Optional[int] = None,
) -> list[NoteModel]: ) -> list[NoteModel]:
notes = self.get_notes() notes = self.get_notes(skip=skip, limit=limit)
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)} user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)}
return [ return [
note note

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

@ -19,10 +19,13 @@ from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
from open_webui.models.users import UserModel from open_webui.models.users import UserModel
from open_webui.models.files import Files from open_webui.models.files import Files
from open_webui.models.knowledge import Knowledges from open_webui.models.knowledge import Knowledges
from open_webui.models.chats import Chats
from open_webui.models.notes import Notes from open_webui.models.notes import Notes
from open_webui.retrieval.vector.main import GetResult from open_webui.retrieval.vector.main import GetResult
from open_webui.utils.access_control import has_access from open_webui.utils.access_control import has_access
from open_webui.utils.misc import get_message_list
from open_webui.env import ( from open_webui.env import (
@ -432,13 +435,14 @@ def get_embedding_function(
if isinstance(query, list): if isinstance(query, list):
embeddings = [] embeddings = []
for i in range(0, len(query), embedding_batch_size): for i in range(0, len(query), embedding_batch_size):
embeddings.extend( batch_embeddings = func(
func( query[i : i + embedding_batch_size],
query[i : i + embedding_batch_size], prefix=prefix,
prefix=prefix, user=user,
user=user,
)
) )
if isinstance(batch_embeddings, list):
embeddings.extend(batch_embeddings)
return embeddings return embeddings
else: else:
return func(query, prefix, user) return func(query, prefix, user)
@ -490,25 +494,37 @@ def get_sources_from_items(
# Raw Text # Raw Text
# Used during temporary chat file uploads or web page & youtube attachements # Used during temporary chat file uploads or web page & youtube attachements
if item.get("collection_name"): if item.get("context") == "full":
# If item has a collection name, use it if item.get("file"):
collection_names.append(item.get("collection_name")) # if item has file data, use it
elif item.get("file"): query_result = {
# if item has file data, use it "documents": [
query_result = { [item.get("file", {}).get("data", {}).get("content")]
"documents": [ ],
[item.get("file", {}).get("data", {}).get("content")] "metadatas": [[item.get("file", {}).get("meta", {})]],
], }
"metadatas": [[item.get("file", {}).get("meta", {})]],
} if query_result is None:
else: # Fallback
# Fallback to item content if item.get("collection_name"):
query_result = { # If item has a collection name, use it
"documents": [[item.get("content")]], collection_names.append(item.get("collection_name"))
"metadatas": [ elif item.get("file"):
[{"file_id": item.get("id"), "name": item.get("name")}] # If item has file data, use it
], query_result = {
} "documents": [
[item.get("file", {}).get("data", {}).get("content")]
],
"metadatas": [[item.get("file", {}).get("meta", {})]],
}
else:
# Fallback to item content
query_result = {
"documents": [[item.get("content")]],
"metadatas": [
[{"file_id": item.get("id"), "name": item.get("name")}]
],
}
elif item.get("type") == "note": elif item.get("type") == "note":
# Note Attached # Note Attached
@ -525,6 +541,30 @@ def get_sources_from_items(
"metadatas": [[{"file_id": note.id, "name": note.title}]], "metadatas": [[{"file_id": note.id, "name": note.title}]],
} }
elif item.get("type") == "chat":
# Chat Attached
chat = Chats.get_chat_by_id(item.get("id"))
if chat and (user.role == "admin" or chat.user_id == user.id):
messages_map = chat.chat.get("history", {}).get("messages", {})
message_id = chat.chat.get("history", {}).get("currentId")
if messages_map and message_id:
# Reconstruct the message list in order
message_list = get_message_list(messages_map, message_id)
message_history = "\n".join(
[
f"#### {m.get('role', 'user').capitalize()}\n{m.get('content')}\n"
for m in message_list
]
)
# User has access to the chat
query_result = {
"documents": [[message_history]],
"metadatas": [[{"file_id": chat.id, "name": chat.title}]],
}
elif item.get("type") == "file": elif item.get("type") == "file":
if ( if (
item.get("context") == "full" item.get("context") == "full"
@ -581,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()
)
active_user_ids = get_user_ids_from_room(f"channel:{channel.id}")
background_tasks.add_task( @router.post("/{id}/messages/post", response_model=Optional[MessageModel])
send_notification, 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}")
async def background_handler():
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}")
@ -192,6 +204,9 @@ async def create_new_function(
function_cache_dir = CACHE_DIR / "functions" / form_data.id function_cache_dir = CACHE_DIR / "functions" / form_data.id
function_cache_dir.mkdir(parents=True, exist_ok=True) function_cache_dir.mkdir(parents=True, exist_ok=True)
if function_type == "filter" and getattr(function_module, "toggle", None):
Functions.update_function_metadata_by_id(id, {"toggle": True})
if function: if function:
return function return function
else: else:
@ -308,6 +323,9 @@ async def update_function_by_id(
function = Functions.update_function_by_id(id, updated) function = Functions.update_function_by_id(id, updated)
if function_type == "filter" and getattr(function_module, "toggle", None):
Functions.update_function_metadata_by_id(id, {"toggle": True})
if function: if function:
return function return function
else: else:

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

@ -62,8 +62,9 @@ class NoteTitleIdResponse(BaseModel):
@router.get("/list", response_model=list[NoteTitleIdResponse]) @router.get("/list", response_model=list[NoteTitleIdResponse])
async def get_note_list(request: Request, user=Depends(get_verified_user)): async def get_note_list(
request: Request, page: Optional[int] = None, user=Depends(get_verified_user)
):
if user.role != "admin" and not has_permission( if user.role != "admin" and not has_permission(
user.id, "features.notes", request.app.state.config.USER_PERMISSIONS user.id, "features.notes", request.app.state.config.USER_PERMISSIONS
): ):
@ -72,9 +73,15 @@ async def get_note_list(request: Request, user=Depends(get_verified_user)):
detail=ERROR_MESSAGES.UNAUTHORIZED, detail=ERROR_MESSAGES.UNAUTHORIZED,
) )
limit = None
skip = None
if page is not None:
limit = 60
skip = (page - 1) * limit
notes = [ notes = [
NoteTitleIdResponse(**note.model_dump()) NoteTitleIdResponse(**note.model_dump())
for note in Notes.get_notes_by_user_id(user.id, "write") for note in Notes.get_notes_by_user_id(user.id, "write", skip=skip, limit=limit)
] ]
return notes return notes

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,
@ -171,22 +173,41 @@ def get_headers_and_cookies(
oauth_token = None oauth_token = None
try: try:
oauth_token = request.app.state.oauth_manager.get_oauth_token( if request.cookies.get("oauth_session_id", None):
user.id, oauth_token = request.app.state.oauth_manager.get_oauth_token(
request.cookies.get("oauth_session_id", None), user.id,
) request.cookies.get("oauth_session_id", None),
)
except Exception as e: except Exception as e:
log.error(f"Error getting OAuth token: {e}") log.error(f"Error getting OAuth token: {e}")
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
@ -640,9 +661,12 @@ async def verify_connection(
) )
if api_config.get("azure", False): if api_config.get("azure", False):
headers["api-key"] = key # Only set api-key header if not using Azure Entra ID authentication
api_version = api_config.get("api_version", "") or "2023-03-15-preview" auth_type = api_config.get("auth_type", "bearer")
if auth_type not in ("azure_ad", "microsoft_entra_id"):
headers["api-key"] = key
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,
@ -884,7 +908,12 @@ async def generate_chat_completion(
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)
headers["api-key"] = key
# 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-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:
@ -1057,7 +1086,12 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_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")
headers["api-key"] = key
# 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-version"] = api_version headers["api-version"] = api_version
payload = json.loads(body) payload = json.loads(body)

View File

@ -1334,7 +1334,7 @@ def save_docs_to_vector_db(
) )
return True return True
log.info(f"adding to collection {collection_name}") log.info(f"generating embeddings for {collection_name}")
embedding_function = get_embedding_function( embedding_function = get_embedding_function(
request.app.state.config.RAG_EMBEDDING_ENGINE, request.app.state.config.RAG_EMBEDDING_ENGINE,
request.app.state.config.RAG_EMBEDDING_MODEL, request.app.state.config.RAG_EMBEDDING_MODEL,
@ -1370,6 +1370,7 @@ def save_docs_to_vector_db(
prefix=RAG_EMBEDDING_CONTENT_PREFIX, prefix=RAG_EMBEDDING_CONTENT_PREFIX,
user=user, user=user,
) )
log.info(f"embeddings generated {len(embeddings)} for {len(texts)} items")
items = [ items = [
{ {
@ -1381,11 +1382,13 @@ def save_docs_to_vector_db(
for idx, text in enumerate(texts) for idx, text in enumerate(texts)
] ]
log.info(f"adding to collection {collection_name}")
VECTOR_DB_CLIENT.insert( VECTOR_DB_CLIENT.insert(
collection_name=collection_name, collection_name=collection_name,
items=items, items=items,
) )
log.info(f"added {len(items)} items to collection {collection_name}")
return True return True
except Exception as e: except Exception as e:
log.exception(e) log.exception(e)
@ -1544,13 +1547,20 @@ def process_file(
log.debug(f"text_content: {text_content}") log.debug(f"text_content: {text_content}")
Files.update_file_data_by_id( Files.update_file_data_by_id(
file.id, file.id,
{"status": "completed", "content": text_content}, {"content": text_content},
) )
hash = calculate_sha256_string(text_content) hash = calculate_sha256_string(text_content)
Files.update_file_hash_by_id(file.id, hash) Files.update_file_hash_by_id(file.id, hash)
if not request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL: if request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL:
Files.update_file_data_by_id(file.id, {"status": "completed"})
return {
"status": True,
"collection_name": None,
"filename": file.filename,
"content": text_content,
}
else:
try: try:
result = save_docs_to_vector_db( result = save_docs_to_vector_db(
request, request,
@ -1564,6 +1574,7 @@ def process_file(
add=(True if form_data.collection_name else False), add=(True if form_data.collection_name else False),
user=user, user=user,
) )
log.info(f"added {len(docs)} items to collection {collection_name}")
if result: if result:
Files.update_file_metadata_by_id( Files.update_file_metadata_by_id(
@ -1573,21 +1584,21 @@ 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
else:
return {
"status": True,
"collection_name": None,
"filename": file.filename,
"content": text_content,
}
except Exception as e: except Exception as e:
log.exception(e) log.exception(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

@ -127,8 +127,10 @@ async def process_filter_functions(
raise e raise e
# Handle file cleanup for inlet # Handle file cleanup for inlet
if skip_files and "files" in form_data.get("metadata", {}): if skip_files:
del form_data["files"] if "files" in form_data.get("metadata", {}):
del form_data["metadata"]["files"] del form_data["metadata"]["files"]
if "files" in form_data:
del form_data["files"]
return form_data, {} return form_data, {}

View File

@ -817,10 +817,11 @@ async def process_chat_payload(request, form_data, user, metadata, model):
oauth_token = None oauth_token = None
try: try:
oauth_token = request.app.state.oauth_manager.get_oauth_token( if request.cookies.get("oauth_session_id", None):
user.id, oauth_token = request.app.state.oauth_manager.get_oauth_token(
request.cookies.get("oauth_session_id", None), user.id,
) request.cookies.get("oauth_session_id", None),
)
except Exception as e: except Exception as e:
log.error(f"Error getting OAuth token: {e}") log.error(f"Error getting OAuth token: {e}")
@ -1130,11 +1131,11 @@ async def process_chat_response(
request, response, form_data, user, metadata, model, events, tasks request, response, form_data, user, metadata, model, events, tasks
): ):
async def background_tasks_handler(): async def background_tasks_handler():
message_map = Chats.get_messages_by_chat_id(metadata["chat_id"]) messages_map = Chats.get_messages_map_by_chat_id(metadata["chat_id"])
message = message_map.get(metadata["message_id"]) if message_map else None message = messages_map.get(metadata["message_id"]) if messages_map else None
if message: if message:
message_list = get_message_list(message_map, metadata["message_id"]) message_list = get_message_list(messages_map, metadata["message_id"])
# Remove details tags and files from the messages. # Remove details tags and files from the messages.
# as get_message_list creates a new list, it does not affect # as get_message_list creates a new list, it does not affect
@ -1496,10 +1497,11 @@ async def process_chat_response(
oauth_token = None oauth_token = None
try: try:
oauth_token = request.app.state.oauth_manager.get_oauth_token( if request.cookies.get("oauth_session_id", None):
user.id, oauth_token = request.app.state.oauth_manager.get_oauth_token(
request.cookies.get("oauth_session_id", None), user.id,
) request.cookies.get("oauth_session_id", None),
)
except Exception as e: except Exception as e:
log.error(f"Error getting OAuth token: {e}") log.error(f"Error getting OAuth token: {e}")
@ -2029,6 +2031,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:
@ -2040,20 +2056,6 @@ async def process_chat_response(
}, },
} }
) )
usage = data.get("usage", {})
usage.update(
data.get("timing", {})
) # 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", {})

View File

@ -26,7 +26,7 @@ def deep_update(d, u):
return d return d
def get_message_list(messages, message_id): def get_message_list(messages_map, message_id):
""" """
Reconstructs a list of messages in order up to the specified message_id. Reconstructs a list of messages in order up to the specified message_id.
@ -36,11 +36,11 @@ def get_message_list(messages, message_id):
""" """
# Handle case where messages is None # Handle case where messages is None
if not messages: if not messages_map:
return [] # Return empty list instead of None to prevent iteration errors return [] # Return empty list instead of None to prevent iteration errors
# Find the message by its id # Find the message by its id
current_message = messages.get(message_id) current_message = messages_map.get(message_id)
if not current_message: if not current_message:
return [] # Return empty list instead of None to prevent iteration errors return [] # Return empty list instead of None to prevent iteration errors
@ -53,7 +53,7 @@ def get_message_list(messages, message_id):
0, current_message 0, current_message
) # Insert the message at the beginning of the list ) # Insert the message at the beginning of the list
parent_id = current_message.get("parentId") # Use .get() for safety parent_id = current_message.get("parentId") # Use .get() for safety
current_message = messages.get(parent_id) if parent_id else None current_message = messages_map.get(parent_id) if parent_id else None
return message_list return message_list

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

@ -602,6 +602,12 @@ 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
):
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}")
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)

View File

@ -163,20 +163,27 @@ 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()
response = await call_next(request)
elapsed_ms = (time.perf_counter() - start_time) * 1000.0
# Route template e.g. "/items/{item_id}" instead of real path. status_code = None
route = request.scope.get("route") try:
route_path = getattr(route, "path", request.url.path) 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
attrs: Dict[str, str | int] = { # Route template e.g. "/items/{item_id}" instead of real path.
"http.method": request.method, route = request.scope.get("route")
"http.route": route_path, route_path = getattr(route, "path", request.url.path)
"http.status_code": response.status_code,
}
request_counter.add(1, attrs) attrs: Dict[str, str | int] = {
duration_histogram.record(elapsed_ms, attrs) "http.method": request.method,
"http.route": route_path,
"http.status_code": status_code,
}
return response request_counter.add(1, attrs)
duration_histogram.record(elapsed_ms, attrs)

37
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "open-webui", "name": "open-webui",
"version": "0.6.28", "version": "0.6.29",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "open-webui", "name": "open-webui",
"version": "0.6.28", "version": "0.6.29",
"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",
@ -37,6 +37,7 @@
"@tiptap/extensions": "^3.0.7", "@tiptap/extensions": "^3.0.7",
"@tiptap/pm": "^3.0.7", "@tiptap/pm": "^3.0.7",
"@tiptap/starter-kit": "^3.0.7", "@tiptap/starter-kit": "^3.0.7",
"@tiptap/suggestion": "^3.4.2",
"@xyflow/svelte": "^0.1.19", "@xyflow/svelte": "^0.1.19",
"async": "^3.2.5", "async": "^3.2.5",
"bits-ui": "^0.21.15", "bits-ui": "^0.21.15",
@ -86,7 +87,6 @@
"socket.io-client": "^4.2.0", "socket.io-client": "^4.2.0",
"sortablejs": "^1.15.6", "sortablejs": "^1.15.6",
"svelte-sonner": "^0.3.19", "svelte-sonner": "^0.3.19",
"svelte-tiptap": "^3.0.0",
"tippy.js": "^6.3.7", "tippy.js": "^6.3.7",
"turndown": "^7.2.0", "turndown": "^7.2.0",
"turndown-plugin-gfm": "^1.0.2", "turndown-plugin-gfm": "^1.0.2",
@ -3856,18 +3856,17 @@
} }
}, },
"node_modules/@tiptap/suggestion": { "node_modules/@tiptap/suggestion": {
"version": "3.0.9", "version": "3.4.2",
"resolved": "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-3.0.9.tgz", "resolved": "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-3.4.2.tgz",
"integrity": "sha512-irthqfUybezo3IwR6AXvyyTOtkzwfvvst58VXZtTnR1nN6NEcrs3TQoY3bGKGbN83bdiquKh6aU2nLnZfAhoXg==", "integrity": "sha512-sljtfiDtdAsbPOwrXrFGf64D6sXUjeU3Iz5v3TvN7TVJKozkZ/gaMkPRl+WC1CGwC6BnzQVDBEEa1e+aApV0mA==",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/ueberdosis" "url": "https://github.com/sponsors/ueberdosis"
}, },
"peerDependencies": { "peerDependencies": {
"@tiptap/core": "^3.0.9", "@tiptap/core": "^3.4.2",
"@tiptap/pm": "^3.0.9" "@tiptap/pm": "^3.4.2"
} }
}, },
"node_modules/@tiptap/y-tiptap": { "node_modules/@tiptap/y-tiptap": {
@ -12503,26 +12502,6 @@
"svelte": "^3.0.0 || ^4.0.0 || ^5.0.0-next.1" "svelte": "^3.0.0 || ^4.0.0 || ^5.0.0-next.1"
} }
}, },
"node_modules/svelte-tiptap": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/svelte-tiptap/-/svelte-tiptap-3.0.0.tgz",
"integrity": "sha512-digFHOJe16RX0HIU+u8hOaCS9sIgktTpYHSF9yJ6dgxPv/JWJdYCdwoX65lcHitFhhCG7xnolJng6PJa9M9h3w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/sibiraj-s"
}
],
"license": "MIT",
"peerDependencies": {
"@floating-ui/dom": "^1.0.0",
"@tiptap/core": "^3.0.0",
"@tiptap/extension-bubble-menu": "^3.0.0",
"@tiptap/extension-floating-menu": "^3.0.0",
"@tiptap/pm": "^3.0.0",
"svelte": "^5.0.0"
}
},
"node_modules/svelte/node_modules/estree-walker": { "node_modules/svelte/node_modules/estree-walker": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",

View File

@ -1,6 +1,6 @@
{ {
"name": "open-webui", "name": "open-webui",
"version": "0.6.28", "version": "0.6.29",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "npm run pyodide:fetch && vite dev --host", "dev": "npm run pyodide:fetch && vite dev --host",
@ -81,6 +81,7 @@
"@tiptap/extensions": "^3.0.7", "@tiptap/extensions": "^3.0.7",
"@tiptap/pm": "^3.0.7", "@tiptap/pm": "^3.0.7",
"@tiptap/starter-kit": "^3.0.7", "@tiptap/starter-kit": "^3.0.7",
"@tiptap/suggestion": "^3.4.2",
"@xyflow/svelte": "^0.1.19", "@xyflow/svelte": "^0.1.19",
"async": "^3.2.5", "async": "^3.2.5",
"bits-ui": "^0.21.15", "bits-ui": "^0.21.15",
@ -130,7 +131,6 @@
"socket.io-client": "^4.2.0", "socket.io-client": "^4.2.0",
"sortablejs": "^1.15.6", "sortablejs": "^1.15.6",
"svelte-sonner": "^0.3.19", "svelte-sonner": "^0.3.19",
"svelte-tiptap": "^3.0.0",
"tippy.js": "^6.3.7", "tippy.js": "^6.3.7",
"turndown": "^7.2.0", "turndown": "^7.2.0",
"turndown-plugin-gfm": "^1.0.2", "turndown-plugin-gfm": "^1.0.2",

View File

@ -56,18 +56,11 @@ dependencies = [
"fake-useragent==2.2.0", "fake-useragent==2.2.0",
"chromadb==1.0.20", "chromadb==1.0.20",
"pymilvus==2.5.0",
"qdrant-client==1.14.3",
"opensearch-py==2.8.0", "opensearch-py==2.8.0",
"playwright==1.49.1",
"elasticsearch==9.1.0",
"pinecone==6.0.2",
"oracledb==3.2.0",
"transformers", "transformers",
"sentence-transformers==4.1.0", "sentence-transformers==4.1.0",
"accelerate", "accelerate",
"colbert-ai==0.2.21",
"pyarrow==20.0.0", "pyarrow==20.0.0",
"einops==0.8.1", "einops==0.8.1",
@ -154,6 +147,15 @@ all = [
"docker~=7.1.0", "docker~=7.1.0",
"pytest~=8.3.2", "pytest~=8.3.2",
"pytest-docker~=3.1.1", "pytest-docker~=3.1.1",
"playwright==1.49.1",
"elasticsearch==9.1.0",
"qdrant-client==1.14.3",
"pymilvus==2.5.0",
"pinecone==6.0.2",
"oracledb==3.2.0",
"colbert-ai==0.2.21",
] ]
[project.scripts] [project.scripts]

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 {
@ -409,17 +409,33 @@ input[type='number'] {
} }
} }
.tiptap .mention { .mention {
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;
} }
.tiptap .mention::after { .mention::after {
content: '\200B'; content: '\200B';
} }
.tiptap .suggestion {
border-radius: 0.4rem;
box-decoration-break: clone;
padding: 0.1rem 0.3rem;
@apply text-sky-800 dark:text-sky-200 bg-sky-300/15 dark:bg-sky-500/15;
}
.tiptap .suggestion::after {
content: '\200B';
}
.tiptap .suggestion.is-empty::after {
content: '\00A0';
border-bottom: 1px dotted rgba(31, 41, 55, 0.12);
}
.input-prose .tiptap ul[data-type='taskList'] { .input-prose .tiptap ul[data-type='taskList'] {
list-style: none; list-style: none;
margin-left: 0; margin-left: 0;

View File

@ -23,8 +23,6 @@
href="/static/apple-touch-icon.png" href="/static/apple-touch-icon.png"
crossorigin="use-credentials" crossorigin="use-credentials"
/> />
<meta name="apple-mobile-web-app-title" content="Open WebUI" />
<link <link
rel="manifest" rel="manifest"
href="/manifest.json" href="/manifest.json"
@ -37,14 +35,7 @@
/> />
<meta name="theme-color" content="#171717" /> <meta name="theme-color" content="#171717" />
<meta name="robots" content="noindex,nofollow" /> <meta name="robots" content="noindex,nofollow" />
<meta name="description" content="Open WebUI" />
<link
rel="search"
type="application/opensearchdescription+xml"
title="Open WebUI"
href="/opensearch.xml"
crossorigin="use-credentials"
/>
<script src="/static/loader.js" defer crossorigin="use-credentials"></script> <script src="/static/loader.js" defer crossorigin="use-credentials"></script>
<link rel="stylesheet" href="/static/custom.css" crossorigin="use-credentials" /> <link rel="stylesheet" href="/static/custom.css" crossorigin="use-credentials" />

View File

@ -91,10 +91,15 @@ export const getNotes = async (token: string = '', raw: boolean = false) => {
return grouped; return grouped;
}; };
export const getNoteList = async (token: string = '') => { export const getNoteList = async (token: string = '', page: number | null = null) => {
let error = null; let error = null;
const searchParams = new URLSearchParams();
const res = await fetch(`${WEBUI_API_BASE_URL}/notes/list`, { if (page !== null) {
searchParams.append('page', `${page}`);
}
const res = await fetch(`${WEBUI_API_BASE_URL}/notes/list?${searchParams.toString()}`, {
method: 'GET', method: 'GET',
headers: { headers: {
Accept: 'application/json', Accept: 'application/json',

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

@ -7,8 +7,7 @@
</script> </script>
<div class="px-3"> <div class="px-3">
<div class="text-center text-6xl mb-3">📄</div> <div class="text-center dark:text-white text-2xl font-medium z-50">
<div class="text-center dark:text-white text-xl font-semibold z-50">
{#if title} {#if title}
{title} {title}
{:else} {:else}
@ -17,7 +16,7 @@
</div> </div>
<slot <slot
><div class="px-2 mt-2 text-center text-sm dark:text-gray-200 w-full"> ><div class="px-2 mt-2 text-center text-gray-700 dark:text-gray-200 w-full">
{#if content} {#if content}
{content} {content}
{:else} {:else}

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,20 +366,22 @@ 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">
<CodeEditor {#await import('$lib/components/common/CodeEditor.svelte') then { default: CodeEditor }}
bind:this={codeEditor} <CodeEditor
value={content} bind:this={codeEditor}
lang="python" value={content}
{boilerplate} lang="python"
onChange={(e) => { {boilerplate}
_content = e; onChange={(e) => {
}} _content = e;
onSave={async () => { }}
if (formElement) { onSave={async () => {
formElement.requestSubmit(); if (formElement) {
} formElement.requestSubmit();
}} }
/> }}
/>
{/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-300/30 dark:border-gray-700/50 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"
@ -50,7 +50,7 @@
> >
{#if ['filter', 'action'].includes(func.type)} {#if ['filter', 'action'].includes(func.type)}
<div <div
class="flex gap-2 justify-between items-center px-3 py-2 text-sm font-medium cursor-pointerrounded-md" class="flex gap-2 justify-between items-center px-3 py-1.5 text-sm font-medium cursor-pointerrounded-md"
> >
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
<GlobeAlt /> <GlobeAlt />
@ -63,11 +63,11 @@
</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
class="flex gap-2 items-center px-3 py-2 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"
on:click={() => { on:click={() => {
editHandler(); editHandler();
}} }}
@ -91,7 +91,7 @@
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item <DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 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"
on:click={() => { on:click={() => {
shareHandler(); shareHandler();
}} }}
@ -101,7 +101,7 @@
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item <DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 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"
on:click={() => { on:click={() => {
cloneHandler(); cloneHandler();
}} }}
@ -112,20 +112,20 @@
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item <DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 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"
on:click={() => { on:click={() => {
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-2 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"
on:click={() => { on:click={() => {
deleteHandler(); deleteHandler();
}} }}

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">
@ -233,14 +233,4 @@
{/if} {/if}
</div> </div>
</div> </div>
<!-- <div class="flex justify-end pt-3 text-sm font-medium">
<button
class=" px-4 py-2 bg-emerald-700 hover:bg-emerald-800 text-gray-100 transition rounded-lg"
type="submit"
>
{$i18n.t('Save')}
</button>
</div> -->
</form> </form>

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,14 +45,14 @@
<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-300/30 dark:border-gray-700/50 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"
transition={flyAndScale} transition={flyAndScale}
> >
<DropdownMenu.Item <DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 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"
on:click={() => { on:click={() => {
hideHandler(); hideHandler();
}} }}
@ -104,7 +104,7 @@
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item <DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 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"
on:click={() => { on:click={() => {
copyLinkHandler(); copyLinkHandler();
}} }}
@ -115,12 +115,12 @@
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item <DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 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"
on:click={() => { on:click={() => {
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-2 border-gray-100 dark:border-gray-800">
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

@ -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">

File diff suppressed because it is too large Load Diff

View File

@ -13,6 +13,8 @@
import GlobeAltSolid from '$lib/components/icons/GlobeAltSolid.svelte'; import GlobeAltSolid from '$lib/components/icons/GlobeAltSolid.svelte';
import WrenchSolid from '$lib/components/icons/WrenchSolid.svelte'; import WrenchSolid from '$lib/components/icons/WrenchSolid.svelte';
import CameraSolid from '$lib/components/icons/CameraSolid.svelte'; import CameraSolid from '$lib/components/icons/CameraSolid.svelte';
import Camera from '$lib/components/icons/Camera.svelte';
import Clip from '$lib/components/icons/Clip.svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
@ -44,34 +46,32 @@
<div slot="content"> <div slot="content">
<DropdownMenu.Content <DropdownMenu.Content
class="w-full max-w-[200px] rounded-xl px-1 py-1 border-gray-300/30 dark:border-gray-700/50 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-sm" 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={15} sideOffset={4}
alignOffset={-8} alignOffset={-6}
side="top" side="bottom"
align="start" align="start"
transition={flyAndScale} transition={flyAndScale}
> >
{#if !$mobile}
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
on:click={() => {
screenCaptureHandler();
}}
>
<CameraSolid />
<div class=" line-clamp-1">{$i18n.t('Capture')}</div>
</DropdownMenu.Item>
{/if}
<DropdownMenu.Item <DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 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 rounded-xl"
on:click={() => { on:click={() => {
uploadFilesHandler(); uploadFilesHandler();
}} }}
> >
<DocumentArrowUpSolid /> <Clip />
<div class="line-clamp-1">{$i18n.t('Upload Files')}</div> <div class="line-clamp-1">{$i18n.t('Upload Files')}</div>
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
on:click={() => {
screenCaptureHandler();
}}
>
<Camera />
<div class=" line-clamp-1">{$i18n.t('Capture')}</div>
</DropdownMenu.Item>
</DropdownMenu.Content> </DropdownMenu.Content>
</div> </div>
</Dropdown> </Dropdown>

View File

@ -0,0 +1,205 @@
<script lang="ts">
import { getContext, onDestroy, onMount } from 'svelte';
const i18n = getContext('i18n');
import { channels, models, user } from '$lib/stores';
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 command: (payload: { id: string; label: string }) => void;
export let selectedIndex = 0;
export let label = '';
export let triggerChar = '@';
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 item = filteredItems[index];
if (!item) return;
// 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) => {
if (!['ArrowUp', 'ArrowDown', 'Enter', 'Tab', 'Escape'].includes(event.key)) return false;
if (event.key === 'ArrowUp') {
selectedIndex = Math.max(0, selectedIndex - 1);
const item = document.querySelector(`[data-selected="true"]`);
item?.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' });
return true;
}
if (event.key === 'ArrowDown') {
selectedIndex = Math.min(selectedIndex + 1, filteredItems.length - 1);
const item = document.querySelector(`[data-selected="true"]`);
item?.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' });
return true;
}
if (event.key === 'Enter' || event.key === 'Tab') {
select(selectedIndex);
if (event.key === 'Enter') {
event.preventDefault();
}
return true;
}
if (event.key === 'Escape') {
// tell tiptap we handled it (it will close)
return true;
}
return false;
};
// This method will be called from the suggestion renderer
// @ts-ignore
export function _onKeyDown(event: KeyboardEvent) {
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>
{#if filteredItems.length}
<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-72 p-1"
id="suggestions-container"
>
<div class="overflow-y-auto scrollbar-thin max-h-60">
{#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">
<button
type="button"
on:click={() => select(i)}
on:mousemove={() => {
selectedIndex = i;
}}
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'
: ''}"
data-selected={i === selectedIndex}
>
{#if item.type === 'channel'}
<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>
</button>
</Tooltip>
{/each}
</div>
</div>
{/if}

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
@ -138,19 +138,22 @@
id="message-{message.id}" id="message-{message.id}"
dir={$settings.chatDirection} dir={$settings.chatDirection}
> >
<div <div class={`shrink-0 mr-3 w-9`}>
class={`shrink-0 ${($settings?.chatDirection ?? 'LTR') === 'LTR' ? 'mr-3' : 'ml-3'} w-9`}
>
{#if showUserProfile} {#if showUserProfile}
<ProfilePreview user={message.user}> {#if message?.meta?.model_id}
<ProfileImage <img
src={message.user?.profile_image_url ?? src={`${WEBUI_API_BASE_URL}/models/model/profile/image?id=${message.meta.model_id}`}
($i18n.language === 'dg-DG' alt={message.meta.model_name ?? message.meta.model_id}
? `${WEBUI_BASE_URL}/doge.png` class="size-8 translate-y-1 ml-0.5 object-cover rounded-full"
: `${WEBUI_BASE_URL}/static/favicon.png`)}
className={'size-8 translate-y-1 ml-0.5'}
/> />
</ProfilePreview> {:else}
<ProfilePreview user={message.user}>
<ProfileImage
src={message.user?.profile_image_url ?? `${WEBUI_BASE_URL}/static/favicon.png`}
className={'size-8 translate-y-1 ml-0.5'}
/>
</ProfilePreview>
{/if}
{:else} {:else}
<!-- <div class="w-7 h-7 rounded-full bg-transparent" /> --> <!-- <div class="w-7 h-7 rounded-full bg-transparent" /> -->
@ -170,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">
{message?.user?.name} {#if message?.meta?.model_id}
{message?.meta?.model_name ?? message?.meta?.model_id}
{:else}
{message?.user?.name}
{/if}
</div> </div>
{#if message.created_at} {#if message.created_at}
@ -178,7 +185,12 @@
class=" self-center text-xs invisible group-hover:visible text-gray-400 font-medium first-letter:capitalize ml-0.5 translate-y-[1px]" class=" self-center text-xs invisible group-hover:visible text-gray-400 font-medium first-letter:capitalize ml-0.5 translate-y-[1px]"
> >
<Tooltip content={dayjs(message.created_at / 1000000).format('LLLL')}> <Tooltip content={dayjs(message.created_at / 1000000).format('LLLL')}>
<span class="line-clamp-1">{formatDate(message.created_at / 1000000)}</span> <span class="line-clamp-1">
{$i18n.t(formatDate(message.created_at / 1000000), {
LOCALIZED_TIME: dayjs(message.created_at / 1000000).format('LT'),
LOCALIZED_DATE: dayjs(message.created_at / 1000000).format('L')
})}
</span>
</Tooltip> </Tooltip>
</div> </div>
{/if} {/if}
@ -198,7 +210,7 @@
name={file.name} name={file.name}
type={file.type} type={file.type}
size={file?.size} size={file?.size}
colorClassName="bg-white dark:bg-gray-850 " small={true}
/> />
{/if} {/if}
</div> </div>
@ -228,7 +240,7 @@
<div class="flex space-x-1.5"> <div class="flex space-x-1.5">
<button <button
id="close-edit-message-button" id="close-edit-message-button"
class="px-4 py-2 bg-white dark:bg-gray-900 hover:bg-gray-100 text-gray-800 dark:text-gray-100 transition rounded-3xl" class="px-3.5 py-1.5 bg-white dark:bg-gray-900 hover:bg-gray-100 text-gray-800 dark:text-gray-100 transition rounded-3xl"
on:click={() => { on:click={() => {
edit = false; edit = false;
editedContent = null; editedContent = null;
@ -239,7 +251,7 @@
<button <button
id="confirm-edit-message-button" id="confirm-edit-message-button"
class=" px-4 py-2 bg-gray-900 dark:bg-white hover:bg-gray-850 text-gray-100 dark:text-gray-800 transition rounded-3xl" class="px-3.5 py-1.5 bg-gray-900 dark:bg-white hover:bg-gray-850 text-gray-100 dark:text-gray-800 transition rounded-3xl"
on:click={async () => { on:click={async () => {
onEdit(editedContent); onEdit(editedContent);
edit = false; edit = false;
@ -253,12 +265,16 @@
</div> </div>
{:else} {:else}
<div class=" min-w-full markdown-prose"> <div class=" min-w-full markdown-prose">
<Markdown {#if (message?.content ?? '').trim() === '' && message?.meta?.model_id}
id={message.id} <Skeleton />
content={message.content} {:else}
/>{#if message.created_at !== message.updated_at}<span class="text-gray-500 text-[10px]" <Markdown
>(edited)</span id={message.id}
>{/if} content={message.content}
/>{#if message.created_at !== message.updated_at && (message?.meta?.model_id ?? null) === null}<span
class="text-gray-500 text-[10px]">({$i18n.t('edited')})</span
>{/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';
@ -37,6 +36,7 @@
showArtifacts, showArtifacts,
tools, tools,
toolServers, toolServers,
functions,
selectedFolder, selectedFolder,
pinnedChats pinnedChats
} from '$lib/stores'; } from '$lib/stores';
@ -88,6 +88,8 @@
import Spinner from '../common/Spinner.svelte'; import Spinner from '../common/Spinner.svelte';
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 Image from '../common/Image.svelte';
export let chatIdProp = ''; export let chatIdProp = '';
@ -236,33 +238,58 @@
}; };
const resetInput = () => { const resetInput = () => {
console.debug('resetInput'); selectedToolIds = [];
setToolIds();
selectedFilterIds = []; selectedFilterIds = [];
webSearchEnabled = false; webSearchEnabled = false;
imageGenerationEnabled = false; imageGenerationEnabled = false;
codeInterpreterEnabled = false; codeInterpreterEnabled = false;
setDefaults();
}; };
const setToolIds = async () => { const setDefaults = async () => {
if (!$tools) { if (!$tools) {
tools.set(await getTools(localStorage.token)); tools.set(await getTools(localStorage.token));
} }
if (!$functions) {
functions.set(await getFunctions(localStorage.token));
}
if (selectedModels.length !== 1 && !atSelectedModel) { if (selectedModels.length !== 1 && !atSelectedModel) {
return; return;
} }
const model = atSelectedModel ?? $models.find((m) => m.id === selectedModels[0]); const model = atSelectedModel ?? $models.find((m) => m.id === selectedModels[0]);
if (model && model?.info?.meta?.toolIds) { if (model) {
selectedToolIds = [ // Set Default Tools
...new Set( if (model?.info?.meta?.toolIds) {
[...(model?.info?.meta?.toolIds ?? [])].filter((id) => $tools.find((t) => t.id === id)) selectedToolIds = [
) ...new Set(
]; [...(model?.info?.meta?.toolIds ?? [])].filter((id) => $tools.find((t) => t.id === id))
} else { )
selectedToolIds = []; ];
}
// Set Default Filters (Toggleable only)
if (model?.info?.meta?.defaultFilterIds) {
selectedFilterIds = model.info.meta.defaultFilterIds.filter((id) =>
model?.filters?.find((f) => f.id === id)
);
}
// Set Default Features
if (model?.info?.meta?.defaultFeatureIds) {
if (model.info?.meta?.capabilities?.['image_generation']) {
imageGenerationEnabled = model.info.meta.defaultFeatureIds.includes('image_generation');
}
if (model.info?.meta?.capabilities?.['web_search']) {
webSearchEnabled = model.info.meta.defaultFeatureIds.includes('web_search');
}
if (model.info?.meta?.capabilities?.['code_interpreter']) {
codeInterpreterEnabled = model.info.meta.defaultFeatureIds.includes('code_interpreter');
}
}
} }
}; };
@ -1464,19 +1491,11 @@
prompt = ''; prompt = '';
const messages = createMessagesList(history, history.currentId); const messages = createMessagesList(history, history.currentId);
// Reset chat input textarea
if (!($settings?.richTextInput ?? true)) {
const chatInputElement = document.getElementById('chat-input');
if (chatInputElement) {
await tick();
chatInputElement.style.height = '';
}
}
const _files = JSON.parse(JSON.stringify(files)); const _files = JSON.parse(JSON.stringify(files));
chatFiles.push(..._files.filter((item) => ['doc', 'file', 'collection'].includes(item.type)));
chatFiles.push(
..._files.filter((item) => ['doc', 'text', 'file', 'collection'].includes(item.type))
);
chatFiles = chatFiles.filter( chatFiles = chatFiles.filter(
// Remove duplicates // Remove duplicates
(item, index, array) => (item, index, array) =>
@ -1667,7 +1686,7 @@
(model) => $models.find((m) => m.id === model)?.info?.meta?.capabilities?.web_search ?? true (model) => $models.find((m) => m.id === model)?.info?.meta?.capabilities?.web_search ?? true
).length === currentModels.length ).length === currentModels.length
) { ) {
if (($settings?.webSearch ?? false) === 'always') { if ($config?.features?.enable_web_search && ($settings?.webSearch ?? false) === 'always') {
features = { ...features, web_search: true }; features = { ...features, web_search: true };
} }
} }
@ -1696,7 +1715,7 @@
let files = JSON.parse(JSON.stringify(chatFiles)); let files = JSON.parse(JSON.stringify(chatFiles));
files.push( files.push(
...(userMessage?.files ?? []).filter((item) => ...(userMessage?.files ?? []).filter((item) =>
['doc', 'text', 'file', 'note', 'collection'].includes(item.type) ['doc', 'text', 'file', 'note', 'chat', 'collection'].includes(item.type)
) )
); );
// Remove duplicates // Remove duplicates
@ -1769,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,
{ {
@ -1789,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)
@ -2225,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]'
@ -2240,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={{
@ -2259,7 +2308,6 @@
bind:selectedModels bind:selectedModels
shareEnabled={!!history.currentId} shareEnabled={!!history.currentId}
{initNewChat} {initNewChat}
showBanners={!showCommands}
archiveChatHandler={() => {}} archiveChatHandler={() => {}}
{moveChatHandler} {moveChatHandler}
onSaveTempChat={async () => { onSaveTempChat={async () => {
@ -2379,11 +2427,7 @@
if (e.detail || files.length > 0) { if (e.detail || files.length > 0) {
await tick(); await tick();
submitPrompt( submitPrompt(e.detail.replaceAll('\n\n', '\n'));
($settings?.richTextInput ?? true)
? e.detail.replaceAll('\n\n', '\n')
: e.detail
);
} }
}} }}
/> />
@ -2432,11 +2476,7 @@
clearDraft(); clearDraft();
if (e.detail || files.length > 0) { if (e.detail || files.length > 0) {
await tick(); await tick();
submitPrompt( submitPrompt(e.detail.replaceAll('\n\n', '\n'));
($settings?.richTextInput ?? true)
? e.detail.replaceAll('\n\n', '\n')
: e.detail
);
} }
}} }}
/> />

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

@ -45,6 +45,7 @@
type={file.type} type={file.type}
size={file?.size} size={file?.size}
dismissible={true} dismissible={true}
small={true}
on:dismiss={() => { on:dismiss={() => {
// Remove the file from the chatFiles array // Remove the file from the chatFiles array

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'}

File diff suppressed because it is too large Load Diff

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-300/30 dark:border-gray-700/50 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"
@ -34,7 +34,7 @@
> >
{#each devices as device} {#each devices as device}
<DropdownMenu.Item <DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm 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 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => { on:click={() => {
dispatch('change', device.deviceId); dispatch('change', device.deviceId);
}} }}

View File

@ -0,0 +1,159 @@
<script lang="ts">
import { knowledge, prompts } from '$lib/stores';
import { getPrompts } from '$lib/apis/prompts';
import { getKnowledgeBases } from '$lib/apis/knowledge';
import Prompts from './Commands/Prompts.svelte';
import Knowledge from './Commands/Knowledge.svelte';
import Models from './Commands/Models.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import { onMount } from 'svelte';
export let char = '';
export let query = '';
export let command: (payload: { id: string; label: string }) => void;
export let onSelect = (e) => {};
export let onUpload = (e) => {};
export let insertTextHandler = (text) => {};
let suggestionElement = null;
let loading = false;
let filteredItems = [];
const init = async () => {
loading = true;
await Promise.all([
(async () => {
prompts.set(await getPrompts(localStorage.token));
})(),
(async () => {
knowledge.set(await getKnowledgeBases(localStorage.token));
})()
]);
loading = false;
};
onMount(() => {
init();
});
const onKeyDown = (event: KeyboardEvent) => {
if (!['ArrowUp', 'ArrowDown', 'Enter', 'Tab', 'Escape'].includes(event.key)) return false;
if (event.key === 'ArrowUp') {
suggestionElement?.selectUp();
const item = document.querySelector(`[data-selected="true"]`);
item?.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' });
return true;
}
if (event.key === 'ArrowDown') {
suggestionElement?.selectDown();
const item = document.querySelector(`[data-selected="true"]`);
item?.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' });
return true;
}
if (event.key === 'Enter' || event.key === 'Tab') {
suggestionElement?.select();
if (event.key === 'Enter') {
event.preventDefault();
}
return true;
}
if (event.key === 'Escape') {
return true;
}
return false;
};
// This method will be called from the suggestion renderer
// @ts-ignore
export function _onKeyDown(event: KeyboardEvent) {
return onKeyDown(event);
}
</script>
<div
class="{(filteredItems ?? []).length > 0
? ''
: 'hidden'} 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"
>
<div class="overflow-y-auto scrollbar-thin max-h-60">
{#if !loading}
{#if char === '/'}
<Prompts
bind:this={suggestionElement}
{query}
bind:filteredItems
prompts={$prompts ?? []}
onSelect={(e) => {
const { type, data } = e;
if (type === 'prompt') {
insertTextHandler(data.content);
}
}}
/>
{:else if char === '#'}
<Knowledge
bind:this={suggestionElement}
{query}
bind:filteredItems
knowledge={$knowledge ?? []}
onSelect={(e) => {
const { type, data } = e;
if (type === 'knowledge') {
insertTextHandler('');
onUpload({
type: 'file',
data: data
});
} else if (type === 'youtube') {
insertTextHandler('');
onUpload({
type: 'youtube',
data: data
});
} else if (type === 'web') {
insertTextHandler('');
onUpload({
type: 'web',
data: data
});
}
}}
/>
{:else if char === '@'}
<Models
bind:this={suggestionElement}
{query}
bind:filteredItems
onSelect={(e) => {
const { type, data } = e;
if (type === 'model') {
insertTextHandler('');
onSelect({
type: 'model',
data: data
});
}
}}
/>
{/if}
{:else}
<div class="py-4 flex flex-col w-full rounded-xl text-gray-700 dark:text-gray-300">
<Spinner />
</div>
{/if}
</div>
</div>

View File

@ -1,129 +0,0 @@
<script>
import { knowledge, prompts } from '$lib/stores';
import { removeLastWordFromString } from '$lib/utils';
import { getPrompts } from '$lib/apis/prompts';
import { getKnowledgeBases } from '$lib/apis/knowledge';
import Prompts from './Commands/Prompts.svelte';
import Knowledge from './Commands/Knowledge.svelte';
import Models from './Commands/Models.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
export let show = false;
export let files = [];
export let command = '';
export let onSelect = (e) => {};
export let onUpload = (e) => {};
export let insertTextHandler = (text) => {};
let loading = false;
let commandElement = null;
export const selectUp = () => {
commandElement?.selectUp();
};
export const selectDown = () => {
commandElement?.selectDown();
};
$: if (show) {
init();
}
const init = async () => {
loading = true;
await Promise.all([
(async () => {
prompts.set(await getPrompts(localStorage.token));
})(),
(async () => {
knowledge.set(await getKnowledgeBases(localStorage.token));
})()
]);
loading = false;
};
</script>
{#if show}
{#if !loading}
{#if command?.charAt(0) === '/'}
<Prompts
bind:this={commandElement}
{command}
onSelect={(e) => {
const { type, data } = e;
if (type === 'prompt') {
insertTextHandler(data.content);
}
}}
/>
{:else if (command?.charAt(0) === '#' && command.startsWith('#') && !command.includes('# ')) || ('\\#' === command.slice(0, 2) && command.startsWith('#') && !command.includes('# '))}
<Knowledge
bind:this={commandElement}
command={command.includes('\\#') ? command.slice(2) : command}
onSelect={(e) => {
const { type, data } = e;
if (type === 'knowledge') {
insertTextHandler('');
onUpload({
type: 'file',
data: data
});
} else if (type === 'youtube') {
insertTextHandler('');
onUpload({
type: 'youtube',
data: data
});
} else if (type === 'web') {
insertTextHandler('');
onUpload({
type: 'web',
data: data
});
}
}}
/>
{:else if command?.charAt(0) === '@'}
<Models
bind:this={commandElement}
{command}
onSelect={(e) => {
const { type, data } = e;
if (type === 'model') {
insertTextHandler('');
onSelect({
type: 'model',
data: data
});
}
}}
/>
{/if}
{:else}
<div
id="commands-container"
class="px-2 mb-2 text-left w-full absolute bottom-0 left-0 right-0 z-10"
>
<div class="flex w-full rounded-xl border border-gray-100 dark:border-gray-850">
<div
class="max-h-60 flex flex-col w-full rounded-xl bg-white dark:bg-gray-900 dark:text-gray-100"
>
<Spinner />
</div>
</div>
</div>
{/if}
{/if}

View File

@ -8,29 +8,48 @@
import { tick, getContext, onMount, onDestroy } from 'svelte'; import { tick, getContext, onMount, onDestroy } from 'svelte';
import { removeLastWordFromString, isValidHttpUrl } from '$lib/utils'; import { removeLastWordFromString, isValidHttpUrl } from '$lib/utils';
import { knowledge } from '$lib/stores'; import Tooltip from '$lib/components/common/Tooltip.svelte';
import { getNoteList, getNotes } from '$lib/apis/notes'; import DocumentPage from '$lib/components/icons/DocumentPage.svelte';
import Database from '$lib/components/icons/Database.svelte';
import GlobeAlt from '$lib/components/icons/GlobeAlt.svelte';
import Youtube from '$lib/components/icons/Youtube.svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
export let command = ''; export let query = '';
export let onSelect = (e) => {}; export let onSelect = (e) => {};
export let knowledge = [];
let selectedIdx = 0; let selectedIdx = 0;
let items = []; let items = [];
let fuse = null; let fuse = null;
let filteredItems = []; export let filteredItems = [];
$: if (fuse) { $: if (fuse) {
filteredItems = command.slice(1) filteredItems = [
? fuse.search(command).map((e) => { ...(query
return e.item; ? fuse.search(query).map((e) => {
}) return e.item;
: items; })
: items),
...(query.startsWith('http')
? query.startsWith('https://www.youtube.com') || query.startsWith('https://youtu.be')
? [{ type: 'youtube', name: query, description: query }]
: [
{
type: 'web',
name: query,
description: query
}
]
: [])
];
} }
$: if (command) { $: if (query) {
selectedIdx = 0; selectedIdx = 0;
} }
@ -42,32 +61,14 @@
selectedIdx = Math.min(selectedIdx + 1, filteredItems.length - 1); selectedIdx = Math.min(selectedIdx + 1, filteredItems.length - 1);
}; };
let container; export const select = async () => {
let adjustHeightDebounce; // find item with data-selected=true
const item = document.querySelector(`[data-selected="true"]`);
const adjustHeight = () => { if (item) {
if (container) { // click the item
if (adjustHeightDebounce) { item.click();
clearTimeout(adjustHeightDebounce);
}
adjustHeightDebounce = setTimeout(() => {
if (!container) return;
// Ensure the container is visible before adjusting height
const rect = container.getBoundingClientRect();
container.style.maxHeight = Math.max(Math.min(240, rect.bottom - 100), 100) + 'px';
}, 100);
} }
}; };
const confirmSelect = async (type, data) => {
onSelect({
type: type,
data: data
});
};
const decodeString = (str: string) => { const decodeString = (str: string) => {
try { try {
return decodeURIComponent(str); return decodeURIComponent(str);
@ -77,22 +78,7 @@
}; };
onMount(async () => { onMount(async () => {
window.addEventListener('resize', adjustHeight); let legacy_documents = knowledge
let notes = await getNoteList(localStorage.token).catch(() => {
return [];
});
notes = notes.map((note) => {
return {
...note,
type: 'note',
name: note.title,
description: dayjs(note.updated_at / 1000000).fromNow()
};
});
let legacy_documents = $knowledge
.filter((item) => item?.meta?.document) .filter((item) => item?.meta?.document)
.map((item) => ({ .map((item) => ({
...item, ...item,
@ -127,16 +113,16 @@
] ]
: []; : [];
let collections = $knowledge let collections = knowledge
.filter((item) => !item?.meta?.document) .filter((item) => !item?.meta?.document)
.map((item) => ({ .map((item) => ({
...item, ...item,
type: 'collection' type: 'collection'
})); }));
let collection_files = let collection_files =
$knowledge.length > 0 knowledge.length > 0
? [ ? [
...$knowledge ...knowledge
.reduce((a, item) => { .reduce((a, item) => {
return [ return [
...new Set([ ...new Set([
@ -158,196 +144,145 @@
] ]
: []; : [];
items = [ items = [...collections, ...collection_files, ...legacy_collections, ...legacy_documents].map(
...notes, (item) => {
...collections, return {
...collection_files, ...item,
...legacy_collections, ...(item?.legacy || item?.meta?.legacy || item?.meta?.document ? { legacy: true } : {})
...legacy_documents };
].map((item) => { }
return { );
...item,
...(item?.legacy || item?.meta?.legacy || item?.meta?.document ? { legacy: true } : {})
};
});
fuse = new Fuse(items, { fuse = new Fuse(items, {
keys: ['name', 'description'] keys: ['name', 'description']
}); });
await tick(); await tick();
adjustHeight(); });
const onKeyDown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
select();
}
};
onMount(() => {
window.addEventListener('keydown', onKeyDown);
}); });
onDestroy(() => { onDestroy(() => {
window.removeEventListener('resize', adjustHeight); window.removeEventListener('keydown', onKeyDown);
}); });
</script> </script>
{#if filteredItems.length > 0 || command?.substring(1).startsWith('http')} <div class="px-2 text-xs text-gray-500 py-1">
<div {$i18n.t('Knowledge')}
id="commands-container" </div>
class="px-2 mb-2 text-left w-full absolute bottom-0 left-0 right-0 z-10"
>
<div class="flex w-full rounded-xl border border-gray-100 dark:border-gray-850">
<div class="flex flex-col w-full rounded-xl bg-white dark:bg-gray-900 dark:text-gray-100">
<div
class="m-1 overflow-y-auto p-1 rounded-r-xl space-y-0.5 scrollbar-hidden max-h-60"
id="command-options-container"
bind:this={container}
>
{#each filteredItems as item, idx}
<button
class=" px-3 py-1.5 rounded-xl w-full text-left flex justify-between items-center {idx ===
selectedIdx
? ' bg-gray-50 dark:bg-gray-850 dark:text-gray-100 selected-command-option-button'
: ''}"
type="button"
on:click={() => {
console.log(item);
confirmSelect('knowledge', item);
}}
on:mousemove={() => {
selectedIdx = idx;
}}
>
<div>
<div class=" font-medium text-black dark:text-gray-100 flex items-center gap-1">
{#if item.legacy}
<div
class="bg-gray-500/20 text-gray-700 dark:text-gray-200 rounded-sm uppercase text-xs font-bold px-1 shrink-0"
>
Legacy
</div>
{:else if item?.meta?.document}
<div
class="bg-gray-500/20 text-gray-700 dark:text-gray-200 rounded-sm uppercase text-xs font-bold px-1 shrink-0"
>
Document
</div>
{:else if item?.type === 'file'}
<div
class="bg-gray-500/20 text-gray-700 dark:text-gray-200 rounded-sm uppercase text-xs font-bold px-1 shrink-0"
>
File
</div>
{:else if item?.type === 'note'}
<div
class="bg-blue-500/20 text-blue-700 dark:text-blue-200 rounded-sm uppercase text-xs font-bold px-1 shrink-0"
>
Note
</div>
{:else}
<div
class="bg-green-500/20 text-green-700 dark:text-green-200 rounded-sm uppercase text-xs font-bold px-1 shrink-0"
>
Collection
</div>
{/if}
<div class="line-clamp-1"> {#if filteredItems.length > 0 || query.startsWith('http')}
{decodeString(item?.name)} {#each filteredItems as item, idx}
</div> {#if !['youtube', 'web'].includes(item.type)}
</div> <button
class=" px-2 py-1 rounded-xl w-full text-left flex justify-between items-center {idx ===
selectedIdx
? ' bg-gray-50 dark:bg-gray-800 dark:text-gray-100 selected-command-option-button'
: ''}"
type="button"
on:click={() => {
console.log(item);
onSelect({
type: 'knowledge',
data: item
});
}}
on:mousemove={() => {
selectedIdx = idx;
}}
data-selected={idx === selectedIdx}
>
<div class=" text-black dark:text-gray-100 flex items-center gap-1">
<Tooltip
content={item?.legacy
? $i18n.t('Legacy')
: item?.type === 'file'
? $i18n.t('File')
: item?.type === 'collection'
? $i18n.t('Collection')
: ''}
placement="top"
>
{#if item?.type === 'collection'}
<Database className="size-4" />
{:else}
<DocumentPage className="size-4" />
{/if}
</Tooltip>
<div class=" text-xs text-gray-600 dark:text-gray-100 line-clamp-1"> <Tooltip content={item.description || decodeString(item?.name)} placement="top-start">
{item?.description} <div class="line-clamp-1 flex-1">
</div> {decodeString(item?.name)}
</div> </div>
</button> </Tooltip>
</div>
</button>
{/if}
{/each}
<!-- <div slot="content" class=" pl-2 pt-1 flex flex-col gap-0.5"> {#if query.startsWith('https://www.youtube.com') || query.startsWith('https://youtu.be')}
{#if !item.legacy && (item?.files ?? []).length > 0} <button
{#each item?.files ?? [] as file, fileIdx} class="px-2 py-1 rounded-xl w-full text-left bg-gray-50 dark:bg-gray-800 dark:text-gray-100 selected-command-option-button"
<button type="button"
class=" px-3 py-1.5 rounded-xl w-full text-left flex justify-between items-center hover:bg-gray-50 dark:hover:bg-gray-850 dark:hover:text-gray-100 selected-command-option-button" data-selected={true}
type="button" on:click={() => {
on:click={() => { if (isValidHttpUrl(query)) {
console.log(file); onSelect({
}} type: 'youtube',
on:mousemove={() => { data: query
selectedIdx = idx; });
}} } else {
> toast.error(
<div> $i18n.t('Oops! Looks like the URL is invalid. Please double-check and try again.')
<div );
class=" font-medium text-black dark:text-gray-100 flex items-center gap-1" }
> }}
<div >
class="bg-gray-500/20 text-gray-700 dark:text-gray-200 rounded-sm uppercase text-xs font-bold px-1 shrink-0" <div class=" text-black dark:text-gray-100 line-clamp-1 flex items-center gap-1">
> <Tooltip content={$i18n.t('YouTube')} placement="top">
File <Youtube className="size-4" />
</div> </Tooltip>
<div class="line-clamp-1"> <div class="truncate flex-1">
{file?.meta?.name} {query}
</div>
</div>
<div class=" text-xs text-gray-600 dark:text-gray-100 line-clamp-1">
{$i18n.t('Updated')}
{dayjs(file.updated_at * 1000).fromNow()}
</div>
</div>
</button>
{/each}
{:else}
<div class=" text-gray-500 text-xs mt-1 mb-2">
{$i18n.t('File not found.')}
</div>
{/if}
</div> -->
{/each}
{#if command.substring(1).startsWith('https://www.youtube.com') || command
.substring(1)
.startsWith('https://youtu.be')}
<button
class="px-3 py-1.5 rounded-xl w-full text-left bg-gray-50 dark:bg-gray-850 dark:text-gray-100 selected-command-option-button"
type="button"
on:click={() => {
if (isValidHttpUrl(command.substring(1))) {
confirmSelect('youtube', command.substring(1));
} else {
toast.error(
$i18n.t(
'Oops! Looks like the URL is invalid. Please double-check and try again.'
)
);
}
}}
>
<div class=" font-medium text-black dark:text-gray-100 line-clamp-1">
{command.substring(1)}
</div>
<div class=" text-xs text-gray-600 line-clamp-1">{$i18n.t('Youtube')}</div>
</button>
{:else if command.substring(1).startsWith('http')}
<button
class="px-3 py-1.5 rounded-xl w-full text-left bg-gray-50 dark:bg-gray-850 dark:text-gray-100 selected-command-option-button"
type="button"
on:click={() => {
if (isValidHttpUrl(command.substring(1))) {
confirmSelect('web', command.substring(1));
} else {
toast.error(
$i18n.t(
'Oops! Looks like the URL is invalid. Please double-check and try again.'
)
);
}
}}
>
<div class=" font-medium text-black dark:text-gray-100 line-clamp-1">
{command}
</div>
<div class=" text-xs text-gray-600 line-clamp-1">{$i18n.t('Web')}</div>
</button>
{/if}
</div> </div>
</div> </div>
</div> </button>
</div> {:else if query.startsWith('http')}
<button
class="px-2 py-1 rounded-xl w-full text-left bg-gray-50 dark:bg-gray-800 dark:text-gray-100 selected-command-option-button"
type="button"
data-selected={true}
on:click={() => {
if (isValidHttpUrl(query)) {
onSelect({
type: 'web',
data: query
});
} else {
toast.error(
$i18n.t('Oops! Looks like the URL is invalid. Please double-check and try again.')
);
}
}}
>
<div class=" text-black dark:text-gray-100 line-clamp-1 flex items-center gap-1">
<Tooltip content={$i18n.t('Web')} placement="top">
<GlobeAlt className="size-4" />
</Tooltip>
<div class="truncate flex-1">
{query}
</div>
</div>
</button>
{/if}
{/if} {/if}

View File

@ -6,14 +6,15 @@
import { models } from '$lib/stores'; import { models } from '$lib/stores';
import { WEBUI_BASE_URL } from '$lib/constants'; import { WEBUI_BASE_URL } from '$lib/constants';
import Tooltip from '$lib/components/common/Tooltip.svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
export let command = ''; export let query = '';
export let onSelect = (e) => {}; export let onSelect = (e) => {};
let selectedIdx = 0; let selectedIdx = 0;
let filteredItems = []; export let filteredItems = [];
let fuse = new Fuse( let fuse = new Fuse(
$models $models
@ -33,13 +34,13 @@
} }
); );
$: filteredItems = command.slice(1) $: filteredItems = query
? fuse.search(command.slice(1)).map((e) => { ? fuse.search(query).map((e) => {
return e.item; return e.item;
}) })
: $models.filter((model) => !model?.info?.meta?.hidden); : $models.filter((model) => !model?.info?.meta?.hidden);
$: if (command) { $: if (query) {
selectedIdx = 0; selectedIdx = 0;
} }
@ -51,85 +52,46 @@
selectedIdx = Math.min(selectedIdx + 1, filteredItems.length - 1); selectedIdx = Math.min(selectedIdx + 1, filteredItems.length - 1);
}; };
let container; export const select = async () => {
let adjustHeightDebounce; const model = filteredItems[selectedIdx];
if (model) {
const adjustHeight = () => { onSelect({ type: 'model', data: model });
if (container) {
if (adjustHeightDebounce) {
clearTimeout(adjustHeightDebounce);
}
adjustHeightDebounce = setTimeout(() => {
if (!container) return;
// Ensure the container is visible before adjusting height
const rect = container.getBoundingClientRect();
container.style.maxHeight = Math.max(Math.min(240, rect.bottom - 100), 100) + 'px';
}, 100);
} }
}; };
const confirmSelect = async (model) => {
onSelect({ type: 'model', data: model });
};
onMount(async () => {
window.addEventListener('resize', adjustHeight);
await tick();
const chatInputElement = document.getElementById('chat-input');
await tick();
chatInputElement?.focus();
await tick();
adjustHeight();
});
onDestroy(() => {
window.removeEventListener('resize', adjustHeight);
});
</script> </script>
<div class="px-2 text-xs text-gray-500 py-1">
{$i18n.t('Models')}
</div>
{#if filteredItems.length > 0} {#if filteredItems.length > 0}
<div {#each filteredItems as model, modelIdx}
id="commands-container" <Tooltip content={model.id} placement="top-start">
class="px-2 mb-2 text-left w-full absolute bottom-0 left-0 right-0 z-10" <button
> class="px-2.5 py-1.5 rounded-xl w-full text-left {modelIdx === selectedIdx
<div class="flex w-full rounded-xl border border-gray-100 dark:border-gray-850"> ? 'bg-gray-50 dark:bg-gray-800 selected-command-option-button'
<div class="flex flex-col w-full rounded-xl bg-white dark:bg-gray-900 dark:text-gray-100"> : ''}"
<div type="button"
class="m-1 overflow-y-auto p-1 rounded-r-lg space-y-0.5 scrollbar-hidden max-h-60" on:click={() => {
id="command-options-container" onSelect({ type: 'model', data: model });
bind:this={container} }}
> on:mousemove={() => {
{#each filteredItems as model, modelIdx} selectedIdx = modelIdx;
<button }}
class="px-3 py-1.5 rounded-xl w-full text-left {modelIdx === selectedIdx on:focus={() => {}}
? 'bg-gray-50 dark:bg-gray-850 selected-command-option-button' data-selected={modelIdx === selectedIdx}
: ''}" >
type="button" <div class="flex text-black dark:text-gray-100 line-clamp-1">
on:click={() => { <img
confirmSelect(model); src={model?.info?.meta?.profile_image_url ?? `${WEBUI_BASE_URL}/static/favicon.png`}
}} alt={model?.name ?? model.id}
on:mousemove={() => { class="rounded-full size-5 items-center mr-2"
selectedIdx = modelIdx; />
}} <div class="truncate">
on:focus={() => {}} {model.name}
> </div>
<div class="flex font-medium text-black dark:text-gray-100 line-clamp-1">
<img
src={model?.info?.meta?.profile_image_url ??
`${WEBUI_BASE_URL}/static/favicon.png`}
alt={model?.name ?? model.id}
class="rounded-full size-6 items-center mr-2"
/>
{model.name}
</div>
</button>
{/each}
</div> </div>
</div> </button>
</div> </Tooltip>
</div> {/each}
{/if} {/if}

View File

@ -1,140 +1,71 @@
<script lang="ts"> <script lang="ts">
import { prompts, settings, user } from '$lib/stores'; import Tooltip from '$lib/components/common/Tooltip.svelte';
import {
extractCurlyBraceWords,
getUserPosition,
getFormattedDate,
getFormattedTime,
getCurrentDateTime,
getUserTimezone,
getWeekday
} from '$lib/utils';
import { tick, getContext, onMount, onDestroy } from 'svelte'; import { tick, getContext, onMount, onDestroy } from 'svelte';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
export let command = ''; export let query = '';
export let prompts = [];
export let onSelect = (e) => {}; export let onSelect = (e) => {};
let selectedPromptIdx = 0; let selectedPromptIdx = 0;
let filteredPrompts = []; export let filteredItems = [];
$: filteredPrompts = $prompts $: filteredItems = prompts
.filter((p) => p.command.toLowerCase().includes(command.toLowerCase())) .filter((p) => p.command.toLowerCase().includes(query.toLowerCase()))
.sort((a, b) => a.title.localeCompare(b.title)); .sort((a, b) => a.title.localeCompare(b.title));
$: if (command) { $: if (query) {
selectedPromptIdx = 0; selectedPromptIdx = 0;
} }
export const selectUp = () => { export const selectUp = () => {
selectedPromptIdx = Math.max(0, selectedPromptIdx - 1); selectedPromptIdx = Math.max(0, selectedPromptIdx - 1);
}; };
export const selectDown = () => { export const selectDown = () => {
selectedPromptIdx = Math.min(selectedPromptIdx + 1, filteredPrompts.length - 1); selectedPromptIdx = Math.min(selectedPromptIdx + 1, filteredItems.length - 1);
}; };
let container; export const select = async () => {
let adjustHeightDebounce; const command = filteredItems[selectedPromptIdx];
if (command) {
const adjustHeight = () => { onSelect({ type: 'prompt', data: command });
if (container) {
if (adjustHeightDebounce) {
clearTimeout(adjustHeightDebounce);
}
adjustHeightDebounce = setTimeout(() => {
if (!container) return;
// Ensure the container is visible before adjusting height
const rect = container.getBoundingClientRect();
container.style.maxHeight = Math.max(Math.min(240, rect.bottom - 80), 100) + 'px';
}, 100);
} }
}; };
const confirmPrompt = async (command) => {
onSelect({ type: 'prompt', data: command });
};
onMount(async () => {
window.addEventListener('resize', adjustHeight);
await tick();
adjustHeight();
});
onDestroy(() => {
window.removeEventListener('resize', adjustHeight);
});
</script> </script>
{#if filteredPrompts.length > 0} <div class="px-2 text-xs text-gray-500 py-1">
<div {$i18n.t('Prompts')}
id="commands-container" </div>
class="px-2 mb-2 text-left w-full absolute bottom-0 left-0 right-0 z-10"
> {#if filteredItems.length > 0}
<div class="flex w-full rounded-xl border border-gray-100 dark:border-gray-850"> <div class=" space-y-0.5 scrollbar-hidden">
<div class="flex flex-col w-full rounded-xl bg-white dark:bg-gray-900 dark:text-gray-100"> {#each filteredItems as promptItem, promptIdx}
<div <Tooltip content={promptItem.title} placement="top-start">
class="m-1 overflow-y-auto p-1 space-y-0.5 scrollbar-hidden max-h-60" <button
id="command-options-container" class=" px-3 py-1 rounded-xl w-full text-left {promptIdx === selectedPromptIdx
bind:this={container} ? ' bg-gray-50 dark:bg-gray-800 selected-command-option-button'
: ''} truncate"
type="button"
on:click={() => {
onSelect({ type: 'prompt', data: promptItem });
}}
on:mousemove={() => {
selectedPromptIdx = promptIdx;
}}
on:focus={() => {}}
data-selected={promptIdx === selectedPromptIdx}
> >
{#each filteredPrompts as promptItem, promptIdx} <span class=" font-medium text-black dark:text-gray-100">
<button {promptItem.command}
class=" px-3 py-1.5 rounded-xl w-full text-left {promptIdx === selectedPromptIdx </span>
? ' bg-gray-50 dark:bg-gray-850 selected-command-option-button'
: ''}"
type="button"
on:click={() => {
confirmPrompt(promptItem);
}}
on:mousemove={() => {
selectedPromptIdx = promptIdx;
}}
on:focus={() => {}}
>
<div class=" font-medium text-black dark:text-gray-100">
{promptItem.command}
</div>
<div class=" text-xs text-gray-600 dark:text-gray-100"> <span class=" text-xs text-gray-600 dark:text-gray-100">
{promptItem.title} {promptItem.title}
</div> </span>
</button> </button>
{/each} </Tooltip>
</div> {/each}
<div
class=" px-2 pt-0.5 pb-1 text-xs text-gray-600 dark:text-gray-100 bg-white dark:bg-gray-900 rounded-b-xl flex items-center space-x-1"
>
<div>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-3 h-3"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z"
/>
</svg>
</div>
<div class="line-clamp-1">
{$i18n.t(
'Tip: Update multiple variable slots consecutively by pressing the tab key in the chat input after each replacement.'
)}
</div>
</div>
</div>
</div>
</div> </div>
{/if} {/if}

View File

@ -24,8 +24,10 @@
role="region" role="region"
aria-label="Drag and Drop Container" aria-label="Drag and Drop Container"
> >
<div class="absolute w-full h-full backdrop-blur-sm bg-gray-800/40 flex justify-center"> <div
<div class="m-auto pt-64 flex flex-col justify-center"> class="absolute w-full h-full backdrop-blur-sm bg-gray-100/50 dark:bg-gray-900/80 flex justify-center"
>
<div class="m-auto flex flex-col justify-center">
<div class="max-w-md"> <div class="max-w-md">
<AddFilesPlaceholder /> <AddFilesPlaceholder />
</div> </div>

View File

@ -1,27 +1,33 @@
<script lang="ts"> <script lang="ts">
import { DropdownMenu } from 'bits-ui'; import { DropdownMenu } from 'bits-ui';
import { flyAndScale } from '$lib/utils/transitions';
import { getContext, onMount, tick } from 'svelte'; import { getContext, onMount, tick } from 'svelte';
import { fly } from 'svelte/transition';
import { flyAndScale } from '$lib/utils/transitions';
import { config, user, tools as _tools, mobile } from '$lib/stores'; import { config, user, tools as _tools, mobile, knowledge, chats } from '$lib/stores';
import { createPicker } from '$lib/utils/google-drive-picker'; import { createPicker } from '$lib/utils/google-drive-picker';
import { getTools } from '$lib/apis/tools';
import Dropdown from '$lib/components/common/Dropdown.svelte'; import Dropdown from '$lib/components/common/Dropdown.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte';
import DocumentArrowUpSolid from '$lib/components/icons/DocumentArrowUpSolid.svelte'; import DocumentArrowUp from '$lib/components/icons/DocumentArrowUp.svelte';
import Switch from '$lib/components/common/Switch.svelte'; import Camera from '$lib/components/icons/Camera.svelte';
import GlobeAltSolid from '$lib/components/icons/GlobeAltSolid.svelte'; import Note from '$lib/components/icons/Note.svelte';
import WrenchSolid from '$lib/components/icons/WrenchSolid.svelte'; import Clip from '$lib/components/icons/Clip.svelte';
import CameraSolid from '$lib/components/icons/CameraSolid.svelte'; import ChatBubbleOval from '$lib/components/icons/ChatBubbleOval.svelte';
import PhotoSolid from '$lib/components/icons/PhotoSolid.svelte'; import Refresh from '$lib/components/icons/Refresh.svelte';
import CommandLineSolid from '$lib/components/icons/CommandLineSolid.svelte'; import Agile from '$lib/components/icons/Agile.svelte';
import Spinner from '$lib/components/common/Spinner.svelte'; import ClockRotateRight from '$lib/components/icons/ClockRotateRight.svelte';
import Database from '$lib/components/icons/Database.svelte';
import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
import ChevronLeft from '$lib/components/icons/ChevronLeft.svelte';
import PageEdit from '$lib/components/icons/PageEdit.svelte';
import Chats from './InputMenu/Chats.svelte';
import Notes from './InputMenu/Notes.svelte';
import Knowledge from './InputMenu/Knowledge.svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
export let selectedToolIds: string[] = []; export let files = [];
export let selectedModels: string[] = []; export let selectedModels: string[] = [];
export let fileUploadCapableModels: string[] = []; export let fileUploadCapableModels: string[] = [];
@ -35,46 +41,41 @@
export let onClose: Function; export let onClose: Function;
let tools = null;
let show = false; let show = false;
let showAllTools = false; let tab = '';
$: if (show) {
init();
}
let fileUploadEnabled = true; let fileUploadEnabled = true;
$: fileUploadEnabled = $: fileUploadEnabled =
fileUploadCapableModels.length === selectedModels.length && fileUploadCapableModels.length === selectedModels.length &&
($user?.role === 'admin' || $user?.permissions?.chat?.file_upload); ($user?.role === 'admin' || $user?.permissions?.chat?.file_upload);
const init = async () => {
await _tools.set(await getTools(localStorage.token));
if ($_tools) {
tools = $_tools.reduce((a, tool, i, arr) => {
a[tool.id] = {
name: tool.name,
description: tool.meta.description,
enabled: selectedToolIds.includes(tool.id)
};
return a;
}, {});
selectedToolIds = selectedToolIds.filter((id) => $_tools?.some((tool) => tool.id === id));
}
};
const detectMobile = () => { const detectMobile = () => {
const userAgent = navigator.userAgent || navigator.vendor || window.opera; const userAgent = navigator.userAgent || navigator.vendor || window.opera;
return /android|iphone|ipad|ipod|windows phone/i.test(userAgent); return /android|iphone|ipad|ipod|windows phone/i.test(userAgent);
}; };
function handleFileChange(event) { const handleFileChange = (event) => {
const inputFiles = Array.from(event.target?.files); const inputFiles = Array.from(event.target?.files);
if (inputFiles && inputFiles.length > 0) { if (inputFiles && inputFiles.length > 0) {
console.log(inputFiles); console.log(inputFiles);
inputFilesHandler(inputFiles); inputFilesHandler(inputFiles);
} }
} };
const onSelect = (item) => {
if (files.find((f) => f.id === item.id)) {
return;
}
files = [
...files,
{
...item,
status: 'processed'
}
];
show = false;
};
</script> </script>
<!-- Hidden file input used to open the camera on mobile --> <!-- Hidden file input used to open the camera on mobile -->
@ -101,299 +102,390 @@
<div slot="content"> <div slot="content">
<DropdownMenu.Content <DropdownMenu.Content
class="w-full max-w-[240px] rounded-xl px-1 py-1 border border-gray-300/30 dark:border-gray-700/50 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-sm" class="w-full max-w-70 rounded-2xl px-1 py-1 border border-gray-100 dark:border-gray-800 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg max-h-72 overflow-y-auto overflow-x-hidden scrollbar-thin transition"
sideOffset={10} sideOffset={4}
alignOffset={-8} alignOffset={-6}
side="top" side="bottom"
align="start" align="start"
transition={flyAndScale} transition={flyAndScale}
> >
{#if tools} {#if tab === ''}
{#if Object.keys(tools).length > 0} <div in:fly={{ x: -20, duration: 150 }}>
<div class="{showAllTools ? ' max-h-96' : 'max-h-28'} overflow-y-auto scrollbar-thin"> <Tooltip
{#each Object.keys(tools) as toolId} content={fileUploadCapableModels.length !== selectedModels.length
? $i18n.t('Model(s) do not support file upload')
: !fileUploadEnabled
? $i18n.t('You do not have permission to upload files.')
: ''}
className="w-full"
>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl {!fileUploadEnabled
? 'opacity-50'
: ''}"
on:click={() => {
if (fileUploadEnabled) {
uploadFilesHandler();
}
}}
>
<Clip />
<div class="line-clamp-1">{$i18n.t('Upload Files')}</div>
</DropdownMenu.Item>
</Tooltip>
<Tooltip
content={fileUploadCapableModels.length !== selectedModels.length
? $i18n.t('Model(s) do not support file upload')
: !fileUploadEnabled
? $i18n.t('You do not have permission to upload files.')
: ''}
className="w-full"
>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl {!fileUploadEnabled
? 'opacity-50'
: ''}"
on:click={() => {
if (fileUploadEnabled) {
if (!detectMobile()) {
screenCaptureHandler();
} else {
const cameraInputElement = document.getElementById('camera-input');
if (cameraInputElement) {
cameraInputElement.click();
}
}
}
}}
>
<Camera />
<div class=" line-clamp-1">{$i18n.t('Capture')}</div>
</DropdownMenu.Item>
</Tooltip>
{#if $config?.features?.enable_notes ?? false}
<Tooltip
content={fileUploadCapableModels.length !== selectedModels.length
? $i18n.t('Model(s) do not support file upload')
: !fileUploadEnabled
? $i18n.t('You do not have permission to upload files.')
: ''}
className="w-full"
>
<button <button
class="flex w-full justify-between gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer rounded-xl" class="flex gap-2 w-full items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl {!fileUploadEnabled
? 'opacity-50'
: ''}"
on:click={() => { on:click={() => {
tools[toolId].enabled = !tools[toolId].enabled; tab = 'notes';
}} }}
> >
<div class="flex-1 truncate"> <PageEdit />
<Tooltip
content={tools[toolId]?.description ?? ''}
placement="top-start"
className="flex flex-1 gap-2 items-center"
>
<div class="shrink-0">
<WrenchSolid />
</div>
<div class=" truncate">{tools[toolId].name}</div> <div class="flex items-center w-full justify-between">
</Tooltip> <div class=" line-clamp-1">
</div> {$i18n.t('Attach Notes')}
</div>
<div class=" shrink-0"> <div class="text-gray-500">
<Switch <ChevronRight />
state={tools[toolId].enabled} </div>
on:change={async (e) => {
const state = e.detail;
await tick();
if (state) {
selectedToolIds = [...selectedToolIds, toolId];
} else {
selectedToolIds = selectedToolIds.filter((id) => id !== toolId);
}
}}
/>
</div> </div>
</button> </button>
{/each} </Tooltip>
</div> {/if}
{#if Object.keys(tools).length > 3}
<button <Tooltip
class="flex w-full justify-center items-center text-sm font-medium cursor-pointer rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800" content={fileUploadCapableModels.length !== selectedModels.length
on:click={() => { ? $i18n.t('Model(s) do not support file upload')
showAllTools = !showAllTools; : !fileUploadEnabled
}} ? $i18n.t('You do not have permission to upload files.')
title={showAllTools ? $i18n.t('Show Less') : $i18n.t('Show All')} : ''}
> className="w-full"
<svg >
xmlns="http://www.w3.org/2000/svg" <button
fill="none" 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
viewBox="0 0 24 24" ? 'opacity-50'
stroke-width="2.5" : ''}"
stroke="currentColor" on:click={() => {
class="size-3 transition-transform duration-200 {showAllTools tab = 'knowledge';
? 'rotate-180' }}
: ''} text-gray-300 dark:text-gray-600" >
> <Database />
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5"
></path> <div class="flex items-center w-full justify-between">
</svg> <div class=" line-clamp-1">
</button> {$i18n.t('Attach Knowledge')}
</div>
<div class="text-gray-500">
<ChevronRight />
</div>
</div>
</button>
</Tooltip>
{#if ($chats ?? []).length > 0}
<Tooltip
content={fileUploadCapableModels.length !== selectedModels.length
? $i18n.t('Model(s) do not support file upload')
: !fileUploadEnabled
? $i18n.t('You do not have permission to upload files.')
: ''}
className="w-full"
>
<button
class="flex gap-2 w-full items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl {!fileUploadEnabled
? 'opacity-50'
: ''}"
on:click={() => {
tab = 'chats';
}}
>
<ClockRotateRight />
<div class="flex items-center w-full justify-between">
<div class=" line-clamp-1">
{$i18n.t('Reference Chats')}
</div>
<div class="text-gray-500">
<ChevronRight />
</div>
</div>
</button>
</Tooltip>
{/if}
{#if fileUploadEnabled}
{#if $config?.features?.enable_google_drive_integration}
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
on:click={() => {
uploadGoogleDriveHandler();
}}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 87.3 78" class="w-5 h-5">
<path
d="m6.6 66.85 3.85 6.65c.8 1.4 1.95 2.5 3.3 3.3l13.75-23.8h-27.5c0 1.55.4 3.1 1.2 4.5z"
fill="#0066da"
/>
<path
d="m43.65 25-13.75-23.8c-1.35.8-2.5 1.9-3.3 3.3l-25.4 44a9.06 9.06 0 0 0 -1.2 4.5h27.5z"
fill="#00ac47"
/>
<path
d="m73.55 76.8c1.35-.8 2.5-1.9 3.3-3.3l1.6-2.75 7.65-13.25c.8-1.4 1.2-2.95 1.2-4.5h-27.502l5.852 11.5z"
fill="#ea4335"
/>
<path
d="m43.65 25 13.75-23.8c-1.35-.8-2.9-1.2-4.5-1.2h-18.5c-1.6 0-3.15.45-4.5 1.2z"
fill="#00832d"
/>
<path
d="m59.8 53h-32.3l-13.75 23.8c1.35.8 2.9 1.2 4.5 1.2h50.8c1.6 0 3.15-.45 4.5-1.2z"
fill="#2684fc"
/>
<path
d="m73.4 26.5-12.7-22c-.8-1.4-1.95-2.5-3.3-3.3l-13.75 23.8 16.15 28h27.45c0-1.55-.4-3.1-1.2-4.5z"
fill="#ffba00"
/>
</svg>
<div class="line-clamp-1">{$i18n.t('Google Drive')}</div>
</DropdownMenu.Item>
{/if}
{#if $config?.features?.enable_onedrive_integration && ($config?.features?.enable_onedrive_personal || $config?.features?.enable_onedrive_business)}
<DropdownMenu.Sub>
<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"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 32 32"
class="w-5 h-5"
fill="none"
>
<mask
id="mask0_87_7796"
style="mask-type:alpha"
maskUnits="userSpaceOnUse"
x="0"
y="6"
width="32"
height="20"
>
<path
d="M7.82979 26C3.50549 26 0 22.5675 0 18.3333C0 14.1921 3.35322 10.8179 7.54613 10.6716C9.27535 7.87166 12.4144 6 16 6C20.6308 6 24.5169 9.12183 25.5829 13.3335C29.1316 13.3603 32 16.1855 32 19.6667C32 23.0527 29 26 25.8723 25.9914L7.82979 26Z"
fill="#C4C4C4"
/>
</mask>
<g mask="url(#mask0_87_7796)">
<path
d="M7.83017 26.0001C5.37824 26.0001 3.18957 24.8966 1.75391 23.1691L18.0429 16.3335L30.7089 23.4647C29.5926 24.9211 27.9066 26.0001 26.0004 25.9915C23.1254 26.0001 12.0629 26.0001 7.83017 26.0001Z"
fill="url(#paint0_linear_87_7796)"
/>
<path
d="M25.5785 13.3149L18.043 16.3334L30.709 23.4647C31.5199 22.4065 32.0004 21.0916 32.0004 19.6669C32.0004 16.1857 29.1321 13.3605 25.5833 13.3337C25.5817 13.3274 25.5801 13.3212 25.5785 13.3149Z"
fill="url(#paint1_linear_87_7796)"
/>
<path
d="M7.06445 10.7028L18.0423 16.3333L25.5779 13.3148C24.5051 9.11261 20.6237 6 15.9997 6C12.4141 6 9.27508 7.87166 7.54586 10.6716C7.3841 10.6773 7.22358 10.6877 7.06445 10.7028Z"
fill="url(#paint2_linear_87_7796)"
/>
<path
d="M1.7535 23.1687L18.0425 16.3331L7.06471 10.7026C3.09947 11.0792 0 14.3517 0 18.3331C0 20.1665 0.657197 21.8495 1.7535 23.1687Z"
fill="url(#paint3_linear_87_7796)"
/>
</g>
<defs>
<linearGradient
id="paint0_linear_87_7796"
x1="4.42591"
y1="24.6668"
x2="27.2309"
y2="23.2764"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#2086B8" />
<stop offset="1" stop-color="#46D3F6" />
</linearGradient>
<linearGradient
id="paint1_linear_87_7796"
x1="23.8302"
y1="19.6668"
x2="30.2108"
y2="15.2082"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#1694DB" />
<stop offset="1" stop-color="#62C3FE" />
</linearGradient>
<linearGradient
id="paint2_linear_87_7796"
x1="8.51037"
y1="7.33333"
x2="23.3335"
y2="15.9348"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#0D3D78" />
<stop offset="1" stop-color="#063B83" />
</linearGradient>
<linearGradient
id="paint3_linear_87_7796"
x1="-0.340429"
y1="19.9998"
x2="14.5634"
y2="14.4649"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#16589B" />
<stop offset="1" stop-color="#1464B7" />
</linearGradient>
</defs>
</svg>
<div class="line-clamp-1">{$i18n.t('Microsoft OneDrive')}</div>
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
class="w-[calc(100vw-2rem)] max-w-[280px] rounded-xl px-1 py-1 border border-gray-100 dark:border-gray-800 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-sm"
side={$mobile ? 'bottom' : 'right'}
sideOffset={$mobile ? 5 : 0}
alignOffset={$mobile ? 0 : -8}
>
{#if $config?.features?.enable_onedrive_personal}
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
on:click={() => {
uploadOneDriveHandler('personal');
}}
>
<div class="flex flex-col">
<div class="line-clamp-1">{$i18n.t('Microsoft OneDrive (personal)')}</div>
</div>
</DropdownMenu.Item>
{/if}
{#if $config?.features?.enable_onedrive_business}
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
on:click={() => {
uploadOneDriveHandler('organizations');
}}
>
<div class="flex flex-col">
<div class="line-clamp-1">
{$i18n.t('Microsoft OneDrive (work/school)')}
</div>
<div class="text-xs text-gray-500">{$i18n.t('Includes SharePoint')}</div>
</div>
</DropdownMenu.Item>
{/if}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
{/if}
{/if} {/if}
<hr class="border-black/5 dark:border-white/5 my-1" />
{/if}
{:else}
<div class="py-4">
<Spinner />
</div> </div>
{:else if tab === 'knowledge'}
<hr class="border-black/5 dark:border-white/5 my-1" /> <div in:fly={{ x: 20, duration: 150 }}>
{/if} <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"
<Tooltip
content={fileUploadCapableModels.length !== selectedModels.length
? $i18n.t('Model(s) do not support file upload')
: !fileUploadEnabled
? $i18n.t('You do not have permission to upload files.')
: ''}
className="w-full"
>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl {!fileUploadEnabled
? 'opacity-50'
: ''}"
on:click={() => {
if (fileUploadEnabled) {
if (!detectMobile()) {
screenCaptureHandler();
} else {
const cameraInputElement = document.getElementById('camera-input');
if (cameraInputElement) {
cameraInputElement.click();
}
}
}
}}
>
<CameraSolid />
<div class=" line-clamp-1">{$i18n.t('Capture')}</div>
</DropdownMenu.Item>
</Tooltip>
<Tooltip
content={fileUploadCapableModels.length !== selectedModels.length
? $i18n.t('Model(s) do not support file upload')
: !fileUploadEnabled
? $i18n.t('You do not have permission to upload files.')
: ''}
className="w-full"
>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl {!fileUploadEnabled
? 'opacity-50'
: ''}"
on:click={() => {
if (fileUploadEnabled) {
uploadFilesHandler();
}
}}
>
<DocumentArrowUpSolid />
<div class="line-clamp-1">{$i18n.t('Upload Files')}</div>
</DropdownMenu.Item>
</Tooltip>
{#if fileUploadEnabled}
{#if $config?.features?.enable_google_drive_integration}
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
on:click={() => { on:click={() => {
uploadGoogleDriveHandler(); tab = '';
}} }}
> >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 87.3 78" class="w-5 h-5"> <ChevronLeft />
<path
d="m6.6 66.85 3.85 6.65c.8 1.4 1.95 2.5 3.3 3.3l13.75-23.8h-27.5c0 1.55.4 3.1 1.2 4.5z"
fill="#0066da"
/>
<path
d="m43.65 25-13.75-23.8c-1.35.8-2.5 1.9-3.3 3.3l-25.4 44a9.06 9.06 0 0 0 -1.2 4.5h27.5z"
fill="#00ac47"
/>
<path
d="m73.55 76.8c1.35-.8 2.5-1.9 3.3-3.3l1.6-2.75 7.65-13.25c.8-1.4 1.2-2.95 1.2-4.5h-27.502l5.852 11.5z"
fill="#ea4335"
/>
<path
d="m43.65 25 13.75-23.8c-1.35-.8-2.9-1.2-4.5-1.2h-18.5c-1.6 0-3.15.45-4.5 1.2z"
fill="#00832d"
/>
<path
d="m59.8 53h-32.3l-13.75 23.8c1.35.8 2.9 1.2 4.5 1.2h50.8c1.6 0 3.15-.45 4.5-1.2z"
fill="#2684fc"
/>
<path
d="m73.4 26.5-12.7-22c-.8-1.4-1.95-2.5-3.3-3.3l-13.75 23.8 16.15 28h27.45c0-1.55-.4-3.1-1.2-4.5z"
fill="#ffba00"
/>
</svg>
<div class="line-clamp-1">{$i18n.t('Google Drive')}</div>
</DropdownMenu.Item>
{/if}
{#if $config?.features?.enable_onedrive_integration} <div class="flex items-center w-full justify-between">
<DropdownMenu.Sub> <div>
<DropdownMenu.SubTrigger {$i18n.t('Knowledge')}
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl w-full" </div>
> </div>
<svg </button>
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 32 32" <Knowledge {onSelect} />
class="w-5 h-5" </div>
fill="none" {:else if tab === 'notes'}
> <div in:fly={{ x: 20, duration: 150 }}>
<mask <button
id="mask0_87_7796" 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"
style="mask-type:alpha" on:click={() => {
maskUnits="userSpaceOnUse" tab = '';
x="0" }}
y="6" >
width="32" <ChevronLeft />
height="20"
> <div class="flex items-center w-full justify-between">
<path <div>
d="M7.82979 26C3.50549 26 0 22.5675 0 18.3333C0 14.1921 3.35322 10.8179 7.54613 10.6716C9.27535 7.87166 12.4144 6 16 6C20.6308 6 24.5169 9.12183 25.5829 13.3335C29.1316 13.3603 32 16.1855 32 19.6667C32 23.0527 29 26 25.8723 25.9914L7.82979 26Z" {$i18n.t('Notes')}
fill="#C4C4C4" </div>
/> </div>
</mask> </button>
<g mask="url(#mask0_87_7796)">
<path <Notes {onSelect} />
d="M7.83017 26.0001C5.37824 26.0001 3.18957 24.8966 1.75391 23.1691L18.0429 16.3335L30.7089 23.4647C29.5926 24.9211 27.9066 26.0001 26.0004 25.9915C23.1254 26.0001 12.0629 26.0001 7.83017 26.0001Z" </div>
fill="url(#paint0_linear_87_7796)" {:else if tab === 'chats'}
/> <div in:fly={{ x: 20, duration: 150 }}>
<path <button
d="M25.5785 13.3149L18.043 16.3334L30.709 23.4647C31.5199 22.4065 32.0004 21.0916 32.0004 19.6669C32.0004 16.1857 29.1321 13.3605 25.5833 13.3337C25.5817 13.3274 25.5801 13.3212 25.5785 13.3149Z" 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"
fill="url(#paint1_linear_87_7796)" on:click={() => {
/> tab = '';
<path }}
d="M7.06445 10.7028L18.0423 16.3333L25.5779 13.3148C24.5051 9.11261 20.6237 6 15.9997 6C12.4141 6 9.27508 7.87166 7.54586 10.6716C7.3841 10.6773 7.22358 10.6877 7.06445 10.7028Z" >
fill="url(#paint2_linear_87_7796)" <ChevronLeft />
/>
<path <div class="flex items-center w-full justify-between">
d="M1.7535 23.1687L18.0425 16.3331L7.06471 10.7026C3.09947 11.0792 0 14.3517 0 18.3331C0 20.1665 0.657197 21.8495 1.7535 23.1687Z" <div>
fill="url(#paint3_linear_87_7796)" {$i18n.t('Chats')}
/> </div>
</g> </div>
<defs> </button>
<linearGradient
id="paint0_linear_87_7796" <Chats {onSelect} />
x1="4.42591" </div>
y1="24.6668"
x2="27.2309"
y2="23.2764"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#2086B8" />
<stop offset="1" stop-color="#46D3F6" />
</linearGradient>
<linearGradient
id="paint1_linear_87_7796"
x1="23.8302"
y1="19.6668"
x2="30.2108"
y2="15.2082"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#1694DB" />
<stop offset="1" stop-color="#62C3FE" />
</linearGradient>
<linearGradient
id="paint2_linear_87_7796"
x1="8.51037"
y1="7.33333"
x2="23.3335"
y2="15.9348"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#0D3D78" />
<stop offset="1" stop-color="#063B83" />
</linearGradient>
<linearGradient
id="paint3_linear_87_7796"
x1="-0.340429"
y1="19.9998"
x2="14.5634"
y2="14.4649"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#16589B" />
<stop offset="1" stop-color="#1464B7" />
</linearGradient>
</defs>
</svg>
<div class="line-clamp-1">{$i18n.t('Microsoft OneDrive')}</div>
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
class="w-[calc(100vw-2rem)] max-w-[280px] rounded-xl px-1 py-1 border border-gray-300/30 dark:border-gray-700/50 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-sm"
side={$mobile ? 'bottom' : 'right'}
sideOffset={$mobile ? 5 : 0}
alignOffset={$mobile ? 0 : -8}
>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
on:click={() => {
uploadOneDriveHandler('personal');
}}
>
<div class="line-clamp-1">{$i18n.t('Microsoft OneDrive (personal)')}</div>
</DropdownMenu.Item>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
on:click={() => {
uploadOneDriveHandler('organizations');
}}
>
<div class="flex flex-col">
<div class="line-clamp-1">{$i18n.t('Microsoft OneDrive (work/school)')}</div>
<div class="text-xs text-gray-500">{$i18n.t('Includes SharePoint')}</div>
</div>
</DropdownMenu.Item>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
{/if}
{/if} {/if}
</DropdownMenu.Content> </DropdownMenu.Content>
</div> </div>

View File

@ -0,0 +1,125 @@
<script lang="ts">
import dayjs from 'dayjs';
import { onMount, tick, getContext } from 'svelte';
import { decodeString } from '$lib/utils';
import { getChatList } from '$lib/apis/chats';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import Loader from '$lib/components/common/Loader.svelte';
import { chatId } from '$lib/stores';
const i18n = getContext('i18n');
export let onSelect = (e) => {};
let loaded = false;
let items = [];
let selectedIdx = 0;
let page = 1;
let itemsLoading = false;
let allItemsLoaded = false;
const loadMoreItems = async () => {
if (allItemsLoaded) return;
page += 1;
await getItemsPage();
};
const getItemsPage = async () => {
itemsLoading = true;
let res = await getChatList(localStorage.token, page).catch(() => {
return [];
});
if ((res ?? []).length === 0) {
allItemsLoaded = true;
} else {
allItemsLoaded = false;
}
items = [
...items,
...res
.filter((item) => item?.id !== $chatId)
.map((item) => {
return {
...item,
type: 'chat',
name: item.title,
description: dayjs(item.updated_at * 1000).fromNow()
};
})
];
itemsLoading = false;
return res;
};
onMount(async () => {
await getItemsPage();
await tick();
loaded = true;
});
</script>
{#if loaded}
{#if items.length === 0}
<div class="text-center text-xs text-gray-500 py-3">{$i18n.t('No chats found')}</div>
{:else}
<div class="flex flex-col gap-0.5">
{#each items as item, idx}
<button
class=" px-2.5 py-1 rounded-xl w-full text-left flex justify-between items-center text-sm {idx ===
selectedIdx
? ' bg-gray-50 dark:bg-gray-800 dark:text-gray-100 selected-command-option-button'
: ''}"
type="button"
on:click={() => {
onSelect(item);
}}
on:mousemove={() => {
selectedIdx = idx;
}}
on:mouseleave={() => {
if (idx === 0) {
selectedIdx = -1;
}
}}
data-selected={idx === selectedIdx}
>
<div class="text-black dark:text-gray-100 flex items-center gap-1.5">
<Tooltip content={item.description || decodeString(item?.name)} placement="top-start">
<div class="line-clamp-1 flex-1">
{decodeString(item?.name)}
</div>
</Tooltip>
</div>
</button>
{/each}
{#if !allItemsLoaded}
<Loader
on:visible={(e) => {
if (!itemsLoading) {
loadMoreItems();
}
}}
>
<div class="w-full flex justify-center py-4 text-xs animate-pulse items-center gap-2">
<Spinner className=" size-4" />
<div class=" ">{$i18n.t('Loading...')}</div>
</div>
</Loader>
{/if}
</div>
{/if}
{:else}
<div class="py-4.5">
<Spinner />
</div>
{/if}

View File

@ -0,0 +1,163 @@
<script lang="ts">
import { onMount, tick, getContext } from 'svelte';
import { decodeString } from '$lib/utils';
import { knowledge } from '$lib/stores';
import { getKnowledgeBases } from '$lib/apis/knowledge';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Database from '$lib/components/icons/Database.svelte';
import DocumentPage from '$lib/components/icons/DocumentPage.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
const i18n = getContext('i18n');
export let onSelect = (e) => {};
let loaded = false;
let items = [];
let selectedIdx = 0;
onMount(async () => {
if ($knowledge === null) {
await knowledge.set(await getKnowledgeBases(localStorage.token));
}
let legacy_documents = $knowledge
.filter((item) => item?.meta?.document)
.map((item) => ({
...item,
type: 'file'
}));
let legacy_collections =
legacy_documents.length > 0
? [
{
name: 'All Documents',
legacy: true,
type: 'collection',
description: 'Deprecated (legacy collection), please create a new knowledge base.',
title: $i18n.t('All Documents'),
collection_names: legacy_documents.map((item) => item.id)
},
...legacy_documents
.reduce((a, item) => {
return [...new Set([...a, ...(item?.meta?.tags ?? []).map((tag) => tag.name)])];
}, [])
.map((tag) => ({
name: tag,
legacy: true,
type: 'collection',
description: 'Deprecated (legacy collection), please create a new knowledge base.',
collection_names: legacy_documents
.filter((item) => (item?.meta?.tags ?? []).map((tag) => tag.name).includes(tag))
.map((item) => item.id)
}))
]
: [];
let collections = $knowledge
.filter((item) => !item?.meta?.document)
.map((item) => ({
...item,
type: 'collection'
}));
``;
let collection_files =
$knowledge.length > 0
? [
...$knowledge
.reduce((a, item) => {
return [
...new Set([
...a,
...(item?.files ?? []).map((file) => ({
...file,
collection: { name: item.name, description: item.description } // DO NOT REMOVE, USED IN FILE DESCRIPTION/ATTACHMENT
}))
])
];
}, [])
.map((file) => ({
...file,
name: file?.meta?.name,
description: `${file?.collection?.name} - ${file?.collection?.description}`,
knowledge: true, // DO NOT REMOVE, USED TO INDICATE KNOWLEDGE BASE FILE
type: 'file'
}))
]
: [];
items = [...collections, ...collection_files, ...legacy_collections, ...legacy_documents].map(
(item) => {
return {
...item,
...(item?.legacy || item?.meta?.legacy || item?.meta?.document ? { legacy: true } : {})
};
}
);
await tick();
loaded = true;
});
</script>
{#if loaded}
<div class="flex flex-col gap-0.5">
{#each items as item, idx}
<button
class=" px-2.5 py-1 rounded-xl w-full text-left flex justify-between items-center text-sm {idx ===
selectedIdx
? ' bg-gray-50 dark:bg-gray-800 dark:text-gray-100 selected-command-option-button'
: ''}"
type="button"
on:click={() => {
console.log(item);
onSelect(item);
}}
on:mousemove={() => {
selectedIdx = idx;
}}
on:mouseleave={() => {
if (idx === 0) {
selectedIdx = -1;
}
}}
data-selected={idx === selectedIdx}
>
<div class=" text-black dark:text-gray-100 flex items-center gap-1">
<Tooltip
content={item?.legacy
? $i18n.t('Legacy')
: item?.type === 'file'
? $i18n.t('File')
: item?.type === 'collection'
? $i18n.t('Collection')
: ''}
placement="top"
>
{#if item?.type === 'collection'}
<Database className="size-4" />
{:else}
<DocumentPage className="size-4" />
{/if}
</Tooltip>
<Tooltip content={item.description || decodeString(item?.name)} placement="top-start">
<div class="line-clamp-1 flex-1">
{decodeString(item?.name)}
</div>
</Tooltip>
</div>
</button>
{/each}
</div>
{:else}
<div class="py-4.5">
<Spinner />
</div>
{/if}

View File

@ -0,0 +1,128 @@
<script lang="ts">
import dayjs from 'dayjs';
import { onMount, tick, getContext } from 'svelte';
import { decodeString } from '$lib/utils';
import { getNoteList } from '$lib/apis/notes';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import PageEdit from '$lib/components/icons/PageEdit.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import Loader from '$lib/components/common/Loader.svelte';
const i18n = getContext('i18n');
export let onSelect = (e) => {};
let loaded = false;
let items = [];
let selectedIdx = 0;
let page = 1;
let itemsLoading = false;
let allItemsLoaded = false;
const loadMoreItems = async () => {
if (allItemsLoaded) return;
page += 1;
await getItemsPage();
};
const getItemsPage = async () => {
itemsLoading = true;
let res = await getNoteList(localStorage.token, page).catch(() => {
return [];
});
if ((res ?? []).length === 0) {
allItemsLoaded = true;
} else {
allItemsLoaded = false;
}
items = [
...items,
...res.map((note) => {
return {
...note,
type: 'note',
name: note.title,
description: dayjs(note.updated_at / 1000000).fromNow()
};
})
];
itemsLoading = false;
return res;
};
onMount(async () => {
await getItemsPage();
await tick();
loaded = true;
});
</script>
{#if loaded}
{#if items.length === 0}
<div class="text-center text-xs text-gray-500 py-3">{$i18n.t('No notes found')}</div>
{:else}
<div class="flex flex-col gap-0.5">
{#each items as item, idx}
<button
class=" px-2.5 py-1 rounded-xl w-full text-left flex justify-between items-center text-sm {idx ===
selectedIdx
? ' bg-gray-50 dark:bg-gray-800 dark:text-gray-100 selected-command-option-button'
: ''}"
type="button"
on:click={() => {
onSelect(item);
}}
on:mousemove={() => {
selectedIdx = idx;
}}
on:mouseleave={() => {
if (idx === 0) {
selectedIdx = -1;
}
}}
data-selected={idx === selectedIdx}
>
<div class="text-black dark:text-gray-100 flex items-center gap-1.5">
<Tooltip content={$i18n.t('Note')} placement="top">
<PageEdit className="size-4" />
</Tooltip>
<Tooltip content={item.description || decodeString(item?.name)} placement="top-start">
<div class="line-clamp-1 flex-1">
{decodeString(item?.name)}
</div>
</Tooltip>
</div>
</button>
{/each}
{#if !allItemsLoaded}
<Loader
on:visible={(e) => {
if (!itemsLoading) {
loadMoreItems();
}
}}
>
<div class="w-full flex justify-center py-4 text-xs animate-pulse items-center gap-2">
<Spinner className=" size-4" />
<div class=" ">{$i18n.t('Loading...')}</div>
</div>
</Loader>
{/if}
</div>
{/if}
{:else}
<div class="py-4.5">
<Spinner />
</div>
{/if}

View File

@ -84,8 +84,8 @@
<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">*required</span> <span class=" text-gray-500">*{$i18n.t('required')}</span>
{/if} {/if}
</div> </div>
</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

@ -0,0 +1,345 @@
<script lang="ts">
import { DropdownMenu } from 'bits-ui';
import { getContext, onMount, tick } from 'svelte';
import { fly } from 'svelte/transition';
import { flyAndScale } from '$lib/utils/transitions';
import { config, user, tools as _tools, mobile, settings, toolServers } from '$lib/stores';
import { getTools } from '$lib/apis/tools';
import Dropdown from '$lib/components/common/Dropdown.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Switch from '$lib/components/common/Switch.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import Wrench from '$lib/components/icons/Wrench.svelte';
import Sparkles from '$lib/components/icons/Sparkles.svelte';
import GlobeAlt from '$lib/components/icons/GlobeAlt.svelte';
import Photo from '$lib/components/icons/Photo.svelte';
import Terminal from '$lib/components/icons/Terminal.svelte';
import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
import ChevronLeft from '$lib/components/icons/ChevronLeft.svelte';
const i18n = getContext('i18n');
export let selectedToolIds: string[] = [];
export let selectedModels: string[] = [];
export let fileUploadCapableModels: string[] = [];
export let toggleFilters: { id: string; name: string; description?: string; icon?: string }[] =
[];
export let selectedFilterIds: string[] = [];
export let showWebSearchButton = false;
export let webSearchEnabled = false;
export let showImageGenerationButton = false;
export let imageGenerationEnabled = false;
export let showCodeInterpreterButton = false;
export let codeInterpreterEnabled = false;
export let onClose: Function;
let show = false;
let tab = '';
let tools = null;
$: if (show) {
init();
}
let fileUploadEnabled = true;
$: fileUploadEnabled =
fileUploadCapableModels.length === selectedModels.length &&
($user?.role === 'admin' || $user?.permissions?.chat?.file_upload);
const init = async () => {
if ($_tools === null) {
await _tools.set(await getTools(localStorage.token));
}
if ($_tools) {
tools = $_tools.reduce((a, tool, i, arr) => {
a[tool.id] = {
name: tool.name,
description: tool.meta.description,
enabled: selectedToolIds.includes(tool.id)
};
return a;
}, {});
}
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>
<Dropdown
bind:show
on:change={(e) => {
if (e.detail === false) {
onClose();
}
}}
>
<Tooltip content={$i18n.t('Integrations')} placement="top">
<slot />
</Tooltip>
<div slot="content">
<DropdownMenu.Content
class="w-full max-w-70 rounded-2xl px-1 py-1 border border-gray-100 dark:border-gray-800 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg max-h-72 overflow-y-auto overflow-x-hidden scrollbar-thin"
sideOffset={4}
alignOffset={-6}
side="bottom"
align="start"
transition={flyAndScale}
>
{#if tab === ''}
<div in:fly={{ x: -20, duration: 150 }}>
{#if tools}
{#if Object.keys(tools).length > 0}
<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"
on:click={() => {
tab = 'tools';
}}
>
<Wrench />
<div class="flex items-center w-full justify-between">
<div class=" line-clamp-1">
{$i18n.t('Tools')}
<span class="ml-0.5 text-gray-500">{Object.keys(tools).length}</span>
</div>
<div class="text-gray-500">
<ChevronRight />
</div>
</div>
</button>
{/if}
{:else}
<div class="py-4">
<Spinner />
</div>
{/if}
{#if toggleFilters && toggleFilters.length > 0}
{#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">
<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"
on:click={() => {
if (selectedFilterIds.includes(filter.id)) {
selectedFilterIds = selectedFilterIds.filter((id) => id !== filter.id);
} else {
selectedFilterIds = [...selectedFilterIds, filter.id];
}
}}
>
<div class="flex-1 truncate">
<div class="flex flex-1 gap-2 items-center">
<div class="shrink-0">
{#if filter?.icon}
<div class="size-4 items-center flex justify-center">
<img
src={filter.icon}
class="size-3.5 {filter.icon.includes('svg')
? 'dark:invert-[80%]'
: ''}"
style="fill: currentColor;"
alt={filter.name}
/>
</div>
{:else}
<Sparkles className="size-4" strokeWidth="1.75" />
{/if}
</div>
<div class=" truncate">{filter?.name}</div>
</div>
</div>
<div class=" shrink-0">
<Switch
state={selectedFilterIds.includes(filter.id)}
on:change={async (e) => {
const state = e.detail;
await tick();
}}
/>
</div>
</button>
</Tooltip>
{/each}
{/if}
{#if showWebSearchButton}
<Tooltip content={$i18n.t('Search the internet')} placement="top-start">
<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"
on:click={() => {
webSearchEnabled = !webSearchEnabled;
}}
>
<div class="flex-1 truncate">
<div class="flex flex-1 gap-2 items-center">
<div class="shrink-0">
<GlobeAlt />
</div>
<div class=" truncate">{$i18n.t('Web Search')}</div>
</div>
</div>
<div class=" shrink-0">
<Switch
state={webSearchEnabled}
on:change={async (e) => {
const state = e.detail;
await tick();
}}
/>
</div>
</button>
</Tooltip>
{/if}
{#if showImageGenerationButton}
<Tooltip content={$i18n.t('Generate an image')} placement="top-start">
<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"
on:click={() => {
imageGenerationEnabled = !imageGenerationEnabled;
}}
>
<div class="flex-1 truncate">
<div class="flex flex-1 gap-2 items-center">
<div class="shrink-0">
<Photo className="size-4" strokeWidth="1.5" />
</div>
<div class=" truncate">{$i18n.t('Image')}</div>
</div>
</div>
<div class=" shrink-0">
<Switch
state={imageGenerationEnabled}
on:change={async (e) => {
const state = e.detail;
await tick();
}}
/>
</div>
</button>
</Tooltip>
{/if}
{#if showCodeInterpreterButton}
<Tooltip content={$i18n.t('Execute code for analysis')} placement="top-start">
<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"
aria-pressed={codeInterpreterEnabled}
aria-label={codeInterpreterEnabled
? $i18n.t('Disable Code Interpreter')
: $i18n.t('Enable Code Interpreter')}
on:click={() => {
codeInterpreterEnabled = !codeInterpreterEnabled;
}}
>
<div class="flex-1 truncate">
<div class="flex flex-1 gap-2 items-center">
<div class="shrink-0">
<Terminal className="size-3.5" strokeWidth="1.75" />
</div>
<div class=" truncate">{$i18n.t('Code Interpreter')}</div>
</div>
</div>
<div class=" shrink-0">
<Switch
state={codeInterpreterEnabled}
on:change={async (e) => {
const state = e.detail;
await tick();
}}
/>
</div>
</button>
</Tooltip>
{/if}
</div>
{:else if tab === 'tools' && tools}
<div in:fly={{ x: 20, duration: 150 }}>
<button
class="flex w-full justify-between gap-2 items-center px-3 py-1.5 text-sm cursor-pointer rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800"
on:click={() => {
tab = '';
}}
>
<ChevronLeft />
<div class="flex items-center w-full justify-between">
<div>
{$i18n.t('Tools')}
<span class="ml-0.5 text-gray-500">{Object.keys(tools).length}</span>
</div>
</div>
</button>
{#each Object.keys(tools) as toolId}
<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"
on:click={() => {
tools[toolId].enabled = !tools[toolId].enabled;
}}
>
<div class="flex-1 truncate">
<div class="flex flex-1 gap-2 items-center">
<Tooltip content={tools[toolId]?.name ?? ''} placement="top">
<div class="shrink-0">
<Wrench />
</div>
</Tooltip>
<Tooltip content={tools[toolId]?.description ?? ''} placement="top-start">
<div class=" truncate">{tools[toolId].name}</div>
</Tooltip>
</div>
</div>
<div class=" shrink-0">
<Switch
state={tools[toolId].enabled}
on:change={async (e) => {
const state = e.detail;
await tick();
if (state) {
selectedToolIds = [...selectedToolIds, toolId];
} else {
selectedToolIds = selectedToolIds.filter((id) => id !== toolId);
}
}}
/>
</div>
</button>
{/each}
</div>
{/if}
</DropdownMenu.Content>
</div>
</Dropdown>

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

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { getContext } from 'svelte'; import { getContext } from 'svelte';
import CitationsModal from '$lib/components/chat/Messages/Citations/CitationsModal.svelte'; import CitationModal from './Citations/CitationModal.svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
@ -12,15 +12,17 @@
let showRelevance = true; let showRelevance = true;
let citationModal = null; let citationModal = null;
let showCitations = false;
let showCitationModal = false; let showCitationModal = false;
let selectedCitation: any = null; let selectedCitation: any = null;
let isCollapsibleOpen = false;
export const showSourceModal = (sourceIdx) => { export const showSourceModal = (sourceIdx) => {
if (citations[sourceIdx]) { if (citations[sourceIdx]) {
console.log('Showing citation modal for:', citations[sourceIdx]); console.log('Showing citation modal for:', citations[sourceIdx]);
citationModal?.showCitation(citations[sourceIdx]); selectedCitation = citations[sourceIdx];
// showCitationModal = true; showCitationModal = true;
} }
}; };
@ -94,13 +96,19 @@
showRelevance = calculateShowRelevance(citations); showRelevance = calculateShowRelevance(citations);
showPercentage = shouldShowPercentage(citations); showPercentage = shouldShowPercentage(citations);
} }
const decodeString = (str: string) => {
try {
return decodeURIComponent(str);
} catch (e) {
return str;
}
};
</script> </script>
<CitationsModal <CitationModal
bind:this={citationModal}
bind:show={showCitationModal} bind:show={showCitationModal}
{id} citation={selectedCitation}
{citations}
{showPercentage} {showPercentage}
{showRelevance} {showRelevance}
/> />
@ -111,7 +119,7 @@
<button <button
class="text-xs font-medium text-gray-600 dark:text-gray-300 px-3.5 h-8 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition flex items-center gap-1 border border-gray-50 dark:border-gray-850" class="text-xs font-medium text-gray-600 dark:text-gray-300 px-3.5 h-8 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition flex items-center gap-1 border border-gray-50 dark:border-gray-850"
on:click={() => { on:click={() => {
showCitationModal = true; showCitations = !showCitations;
}} }}
> >
{#if urlCitations.length > 0} {#if urlCitations.length > 0}
@ -137,3 +145,29 @@
</button> </button>
</div> </div>
{/if} {/if}
{#if showCitations}
<div class="py-1.5">
<div class="text-xs gap-2 flex flex-col">
{#each citations as citation, idx}
<button
id={`source-${id}-${idx + 1}`}
class="no-toggle outline-hidden flex dark:text-gray-300 bg-transparent text-gray-600 rounded-xl gap-1.5 items-center"
on:click={() => {
showCitationModal = true;
selectedCitation = citation;
}}
>
<div class=" font-medium bg-gray-50 dark:bg-gray-850 rounded-md px-1">
{idx + 1}
</div>
<div
class="flex-1 truncate hover:text-black dark:text-white/60 dark:hover:text-white transition text-left"
>
{decodeString(citation.source.name)}
</div>
</button>
{/each}
</div>
</div>
{/if}

View File

@ -60,19 +60,21 @@
<Modal size="lg" bind:show> <Modal size="lg" bind:show>
<div> <div>
<div class=" flex justify-between dark:text-gray-300 px-5 pt-4 pb-2"> <div class=" flex justify-between dark:text-gray-300 px-4.5 pt-3 pb-2">
<div class=" text-lg font-medium self-center"> <div class=" text-lg font-medium self-center flex items-center">
{#if citation?.source?.name} {#if citation?.source?.name}
{@const document = mergedDocuments?.[0]} {@const document = mergedDocuments?.[0]}
{#if document?.metadata?.file_id || document.source?.url?.includes('http')} {#if document?.metadata?.file_id || document.source?.url?.includes('http')}
<Tooltip <Tooltip
className="w-fit" className="w-fit"
content={$i18n.t('Open file')} content={document.source?.url?.includes('http')
? $i18n.t('Open link')
: $i18n.t('Open file')}
placement="top-start" placement="top-start"
tippyOptions={{ duration: [500, 0] }} tippyOptions={{ duration: [500, 0] }}
> >
<a <a
class="hover:text-gray-500 dark:hover:text-gray-100 underline grow" class="hover:text-gray-500 dark:hover:text-gray-100 underline grow line-clamp-1"
href={document?.metadata?.file_id href={document?.metadata?.file_id
? `${WEBUI_API_BASE_URL}/files/${document?.metadata?.file_id}/content${document?.metadata?.page !== undefined ? `#page=${document.metadata.page + 1}` : ''}` ? `${WEBUI_API_BASE_URL}/files/${document?.metadata?.file_id}/content${document?.metadata?.page !== undefined ? `#page=${document.metadata.page + 1}` : ''}`
: document.source?.url?.includes('http') : document.source?.url?.includes('http')
@ -100,9 +102,9 @@
</button> </button>
</div> </div>
<div class="flex flex-col md:flex-row w-full px-6 pb-5 md:space-x-4"> <div class="flex flex-col md:flex-row w-full px-5 pb-5 md:space-x-4">
<div <div
class="flex flex-col w-full dark:text-gray-200 overflow-y-scroll max-h-[22rem] scrollbar-hidden gap-1" class="flex flex-col w-full dark:text-gray-200 overflow-y-scroll max-h-[22rem] scrollbar-thin gap-1"
> >
{#each mergedDocuments as document, documentIdx} {#each mergedDocuments as document, documentIdx}
<div class="flex flex-col w-full gap-2"> <div class="flex flex-col w-full gap-2">

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,34 +452,47 @@
{/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}
<CodeEditor {#await import('$lib/components/common/CodeEditor.svelte') then { default: CodeEditor }}
value={code} <CodeEditor
{id} value={code}
{lang} {id}
onSave={() => { {lang}
saveCode(); onSave={() => {
}} saveCode();
onChange={(value) => { }}
_code = value; onChange={(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

@ -5,6 +5,7 @@
import markedExtension from '$lib/utils/marked/extension'; import markedExtension from '$lib/utils/marked/extension';
import markedKatexExtension from '$lib/utils/marked/katex-extension'; import markedKatexExtension from '$lib/utils/marked/katex-extension';
import { mentionExtension } from '$lib/utils/marked/mention-extension';
import MarkdownTokens from './Markdown/MarkdownTokens.svelte'; import MarkdownTokens from './Markdown/MarkdownTokens.svelte';
@ -37,6 +38,9 @@
marked.use(markedKatexExtension(options)); marked.use(markedKatexExtension(options));
marked.use(markedExtension(options)); marked.use(markedExtension(options));
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

@ -16,6 +16,7 @@
import HtmlToken from './HTMLToken.svelte'; import HtmlToken from './HTMLToken.svelte';
import TextToken from './MarkdownInlineTokens/TextToken.svelte'; import TextToken from './MarkdownInlineTokens/TextToken.svelte';
import CodespanToken from './MarkdownInlineTokens/CodespanToken.svelte'; import CodespanToken from './MarkdownInlineTokens/CodespanToken.svelte';
import MentionToken from './MarkdownInlineTokens/MentionToken.svelte';
export let id: string; export let id: string;
export let done = true; export let done = true;
@ -60,6 +61,8 @@
frameborder="0" frameborder="0"
onload="this.style.height=(this.contentWindow.document.body.scrollHeight+20)+'px';" onload="this.style.height=(this.contentWindow.document.body.scrollHeight+20)+'px';"
></iframe> ></iframe>
{:else if token.type === 'mention'}
<MentionToken {token} />
{:else if token.type === 'text'} {:else if token.type === 'text'}
<TextToken {token} {done} /> <TextToken {token} {done} />
{/if} {/if}

View File

@ -0,0 +1,108 @@
<script lang="ts">
import type { Token } from 'marked';
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;
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>
<LinkPreview.Root openDelay={0} closeDelay={0}>
<LinkPreview.Trigger class=" cursor-pointer no-underline! font-normal! ">
<!-- 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

@ -634,7 +634,12 @@
: 'invisible group-hover:visible transition text-gray-400'}" : 'invisible group-hover:visible transition text-gray-400'}"
> >
<Tooltip content={dayjs(message.timestamp * 1000).format('LLLL')}> <Tooltip content={dayjs(message.timestamp * 1000).format('LLLL')}>
<span class="line-clamp-1">{formatDate(message.timestamp * 1000)}</span> <span class="line-clamp-1"
>{$i18n.t(formatDate(message.timestamp * 1000), {
LOCALIZED_TIME: dayjs(message.timestamp * 1000).format('LT'),
LOCALIZED_DATE: dayjs(message.timestamp * 1000).format('L')
})}</span
>
</Tooltip> </Tooltip>
</div> </div>
{/if} {/if}
@ -663,7 +668,7 @@
name={file.name} name={file.name}
type={file.type} type={file.type}
size={file?.size} size={file?.size}
colorClassName="bg-white dark:bg-gray-850 " small={true}
/> />
{/if} {/if}
</div> </div>
@ -700,7 +705,7 @@
<div> <div>
<button <button
id="save-new-message-button" id="save-new-message-button"
class=" px-4 py-2 bg-gray-50 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 border border-gray-100 dark:border-gray-700 text-gray-700 dark:text-gray-200 transition rounded-3xl" class="px-3.5 py-1.5 bg-gray-50 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 border border-gray-100 dark:border-gray-700 text-gray-700 dark:text-gray-200 transition rounded-3xl"
on:click={() => { on:click={() => {
saveAsCopyHandler(); saveAsCopyHandler();
}} }}
@ -712,7 +717,7 @@
<div class="flex space-x-1.5"> <div class="flex space-x-1.5">
<button <button
id="close-edit-message-button" id="close-edit-message-button"
class="px-4 py-2 bg-white dark:bg-gray-900 hover:bg-gray-100 text-gray-800 dark:text-gray-100 transition rounded-3xl" class="px-3.5 py-1.5 bg-white dark:bg-gray-900 hover:bg-gray-100 text-gray-800 dark:text-gray-100 transition rounded-3xl"
on:click={() => { on:click={() => {
cancelEditMessage(); cancelEditMessage();
}} }}
@ -722,7 +727,7 @@
<button <button
id="confirm-edit-message-button" id="confirm-edit-message-button"
class=" px-4 py-2 bg-gray-900 dark:bg-white hover:bg-gray-850 text-gray-100 dark:text-gray-800 transition rounded-3xl" class="px-3.5 py-1.5 bg-gray-900 dark:bg-white hover:bg-gray-850 text-gray-100 dark:text-gray-800 transition rounded-3xl"
on:click={() => { on:click={() => {
editMessageConfirmHandler(); editMessageConfirmHandler();
}} }}

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

@ -32,19 +32,26 @@
{#if showHistory} {#if showHistory}
<div class="flex flex-row"> <div class="flex flex-row">
{#if history.length > 1} {#if history.length > 1}
<div class="w-1 border-r border-gray-50 dark:border-gray-800 mt-3 -mb-2.5" /> <div class="w-full">
<div class="w-full -translate-x-[7.5px]">
{#each history as status, idx} {#each history as status, idx}
{#if idx !== history.length - 1} {#if idx !== history.length - 1}
<div class="flex items-start gap-2 mb-1"> <div class="flex items-stretch gap-2 mb-1">
<div class="pt-3 px-1"> <div class=" ">
<span class="relative flex size-2"> <div class="pt-3 px-1 mb-1.5">
<span <span
class="relative inline-flex size-1.5 rounded-full bg-gray-200 dark:bg-gray-700" class="relative flex size-1.5 rounded-full justify-center items-center"
></span> >
</span> <span
class="relative inline-flex size-1.5 rounded-full bg-gray-500 dark:bg-gray-300"
></span>
</span>
</div>
<div
class="w-[0.5px] ml-[6.5px] h-[calc(100%-14px)] bg-gray-300 dark:bg-gray-700"
/>
</div> </div>
<StatusItem {status} done={true} /> <StatusItem {status} done={true} />
</div> </div>
{/if} {/if}
@ -55,20 +62,20 @@
{/if} {/if}
<button <button
class="w-full -translate-x-[3.5px]" class="w-full"
on:click={() => { on:click={() => {
showHistory = !showHistory; showHistory = !showHistory;
}} }}
> >
<div class="flex items-start gap-2"> <div class="flex items-start gap-2">
<div class="pt-3 px-1"> <div class="pt-3 px-1">
<span class="relative flex size-2"> <span class="relative flex size-1.5 rounded-full justify-center items-center">
{#if status?.done === false} {#if status?.done === false}
<span <span
class="absolute inline-flex h-full w-full animate-ping rounded-full bg-gray-400 dark:bg-gray-700 opacity-75" class="absolute inline-flex h-full w-full animate-ping rounded-full bg-gray-500 dark:bg-gray-300 opacity-75"
></span> ></span>
{/if} {/if}
<span class="relative inline-flex size-1.5 rounded-full bg-gray-200 dark:bg-gray-700" <span class="relative inline-flex size-1.5 rounded-full bg-gray-500 dark:bg-gray-300"
></span> ></span>
</span> </span>
</div> </div>

View File

@ -22,7 +22,7 @@
<!-- $i18n.t("Generating search query") --> <!-- $i18n.t("Generating search query") -->
<!-- $i18n.t("No search query generated") --> <!-- $i18n.t("No search query generated") -->
<!-- $i18n.t('Searched {{count}} sites') --> <!-- $i18n.t('Searched {{count}} sites') -->
{#if status?.description.includes('{{count}}')} {#if status?.description?.includes('{{count}}')}
{$i18n.t(status?.description, { {$i18n.t(status?.description, {
count: (status?.urls || status?.items).length count: (status?.urls || status?.items).length
})} })}

View File

@ -2,14 +2,22 @@
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-2'} rounded-full bg-black dark:bg-white animate-size" : size === 'xs'
? 'size-1.5'
: 'size-2'} rounded-full bg-black dark:bg-white animate-size"
></span> ></span>
</span> </span>

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