Merge branch 'open-webui:main' into main

This commit is contained in:
MadsLang 2025-01-13 08:28:13 +01:00 committed by GitHub
commit d4a26f8031
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4022 changed files with 103589 additions and 2083 deletions

25
.github/workflows/codespell.disabled vendored Normal file
View File

@ -0,0 +1,25 @@
# Codespell configuration is within pyproject.toml
---
name: Codespell
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
jobs:
codespell:
name: Check for spelling errors
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Annotate locations with typos
uses: codespell-project/codespell-problem-matcher@v1
- name: Codespell
uses: codespell-project/actions-codespell@v2

View File

@ -5,8 +5,112 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.5.4] - 2024-01-05
### Added
- **🌐 Enhanced Translations**: Added Slovak language, improved Czech language.
- **🔄 Clone Shared Chats**: Effortlessly clone shared chats to save time and streamline collaboration, perfect for reusing insightful discussions or custom setups.
- **📣 Native Notifications for Channel Messages**: Stay informed with integrated desktop notifications for channel messages, ensuring you never miss important updates while multitasking.
- **🔥 Torch MPS Support**: MPS support for Mac users when Open WebUI is installed directly, offering better performance and compatibility for AI workloads.
- **🌍 Enhanced Translations**: Small improvements to various translations, ensuring a smoother global user experience.
### Fixed
- **🖼️ Image-Only Messages in Channels**: You can now send images without accompanying text or content in channels.
- **❌ Proper Exception Handling**: Enhanced error feedback by ensuring exceptions are raised clearly, reducing confusion and promoting smoother debugging.
- **🔍 RAG Query Generation Restored**: Fixed query generation issues for Retrieval-Augmented Generation, improving retrieval accuracy and ensuring seamless functionality.
- **📩 MOA Response Functionality Fixed**: Addressed an error with the MOA response generation feature.
- **💬 Channel Thread Loading with 50+ Messages**: Resolved an issue where channel threads stalled when exceeding 50 messages, ensuring smooth navigation in active discussions.
- **🔑 API Endpoint Restrictions Resolution**: Fixed a critical bug where the 'API_KEY_ALLOWED_ENDPOINTS' setting was not functioning as intended, ensuring API access is limited to specified endpoints for enhanced security.
- **🛠️ Action Functions Restored**: Corrected an issue preventing action functions from working, restoring their utility for customized automations and workflows.
- **📂 Temporary Chat JSON Export Fix**: Resolved a bug blocking temporary chats from being exported in JSON format, ensuring seamless data portability.
### Changed
- **🎛️ Sidebar UI Tweaks**: Chat folders, including pinned folders, now display below the Chats section for better organization; the "New Folder" button has been relocated to the Chats section for a more intuitive workflow.
- **🏗️ Real-Time Save Disabled by Default**: The 'ENABLE_REALTIME_CHAT_SAVE' setting is now off by default, boosting response speed for users who prioritize performance in high-paced workflows or less critical scenarios.
- **🎤 Audio Input Echo Cancellation**: Audio input now features echo cancellation enabled by default, reducing audio feedback for improved clarity during conversations or voice-based interactions.
- **🔧 General Reliability Improvements**: Numerous under-the-hood enhancements have been made to improve platform stability, boost overall performance, and ensure a more seamless, dependable experience across workflows.
## [0.5.3] - 2024-12-31
### Added
- **💬 Channel Reactions with Built-In Emoji Picker**: Easily express yourself in channel threads and messages with reactions, featuring an intuitive built-in emoji picker for seamless selection.
- **🧵 Threads for Channels**: Organize discussions within channels by creating threads, improving clarity and fostering focused conversations.
- **🔄 Reset Button for SVG Pan/Zoom**: Added a handy reset button to SVG Pan/Zoom, allowing users to quickly return diagrams or visuals to their default state without hassle.
- **⚡ Realtime Chat Save Environment Variable**: Introduced the ENABLE_REALTIME_CHAT_SAVE environment variable. Choose between faster responses by disabling realtime chat saving or ensuring chunk-by-chunk data persistency for critical operations.
- **🌍 Translation Enhancements**: Updated and refined translations across multiple languages, providing a smoother experience for international users.
- **📚 Improved Documentation**: Expanded documentation on functions, including clearer guidance on function plugins and detailed instructions for migrating to v0.5. This ensures users can adapt and harness new updates more effectively. (https://docs.openwebui.com/features/plugin/)
### Fixed
- **🛠️ Ollama Parameters Respected**: Resolved an issue where input parameters for Ollama were being ignored, ensuring precise and consistent model behavior.
- **🔧 Function Plugin Outlet Hook Reliability**: Fixed a bug causing issues with 'event_emitter' and outlet hooks in filter function plugins, guaranteeing smoother operation within custom extensions.
- **🖋️ Weird Custom Status Descriptions**: Adjusted the formatting and functionality for custom user statuses, ensuring they display correctly and intuitively.
- **🔗 Restored API Functionality**: Fixed a critical issue where APIs were not operational for certain configurations, ensuring uninterrupted access.
- **⏳ Custom Pipe Function Completion**: Resolved an issue where chats using specific custom pipe function plugins werent finishing properly, restoring consistent chat workflows.
- **✅ General Stability Enhancements**: Implemented various under-the-hood improvements to boost overall reliability, ensuring smoother and more consistent performance across the WebUI.
## [0.5.2] - 2024-12-26
### Added
- **🖊️ Typing Indicators in Channels**: Know exactly whos typing in real-time within your channels, enhancing collaboration and keeping everyone engaged.
- **👤 User Status Indicators**: Quickly view a users status by clicking their profile image in channels for better coordination and availability insights.
- **🔒 Configurable API Key Authentication Restrictions**: Flexibly configure endpoint restrictions for API key authentication, now off by default for a smoother setup in trusted environments.
### Fixed
- **🔧 Playground Functionality Restored**: Resolved a critical issue where the playground wasnt working, ensuring seamless experimentation and troubleshooting workflows.
- **📊 Corrected Ollama Usage Statistics**: Fixed a calculation error in Ollamas usage statistics, providing more accurate tracking and insights for better resource management.
- **🔗 Pipelines Outlet Hook Registration**: Addressed an issue where outlet hooks for pipelines werent registered, restoring functionality and consistency in pipeline workflows.
- **🎨 Image Generation Error**: Resolved a persistent issue causing errors with 'get_automatic1111_api_auth()' to ensure smooth image generation workflows.
- **🎙️ Text-to-Speech Error**: Fixed the missing argument in Eleven Labs 'get_available_voices()', restoring full text-to-speech capabilities for uninterrupted voice interactions.
- **🖋️ Title Generation Issue**: Fixed a bug where title generation was not working in certain cases, ensuring consistent and reliable chat organization.
## [0.5.1] - 2024-12-25
### Added
- **🔕 Notification Sound Toggle**: Added a new setting under Settings > Interface to disable notification sounds, giving you greater control over your workspace environment and focus.
### Fixed
- **🔄 Non-Streaming Response Visibility**: Resolved an issue where non-streaming responses were not displayed, ensuring all responses are now reliably shown in your conversations.
- **🖋️ Title Generation with OpenAI APIs**: Fixed a bug preventing title generation when using OpenAI APIs, restoring the ability to automatically generate chat titles for smoother organization.
- **👥 Admin Panel User List**: Addressed the issue where only 50 users were visible in the admin panel. You can now manage and view all users without restrictions.
- **🖼️ Image Generation Error**: Fixed the issue causing 'get_automatic1111_api_auth()' errors in image generation, ensuring seamless creative workflows.
- **⚙️ Pipeline Settings Loading Issue**: Resolved a problem where pipeline settings were stuck at the loading screen, restoring full configurability in the admin panel.
## [0.5.0] - 2024-12-25
### Added
- **💬 True Asynchronous Chat Support**: Create chats, navigate away, and return anytime with responses ready. Ideal for reasoning models and multi-agent workflows, enhancing multitasking like never before.
- **🔔 Chat Completion Notifications**: Never miss a completed response. Receive instant in-UI notifications when a chat finishes in a non-active tab, keeping you updated while you work elsewhere.
- **🌐 Notification Webhook Integration**: Get alerts via webhooks even when your tab is closed! Configure your webhook URL in Settings > Account and receive timely updates for long-running chats or external integration needs.
- **📚 Channels (Beta)**: Explore Discord/Slack-style chat rooms designed for real-time collaboration between users and AIs. Build bots for channels and unlock asynchronous communication for proactive multi-agent workflows. Opt-in via Admin Settings > General. A Comprehensive Bot SDK tutorial (https://github.com/open-webui/bot) is incoming, so stay tuned!
- **🖼️ Client-Side Image Compression**: Now compress images before upload (Settings > Interface), saving bandwidth and improving performance seamlessly.
- **🛠️ OAuth Management for User Groups**: Enable group-level management via OAuth integration for enhanced control and scalability in collaborative environments.
- **✅ Structured Output for Ollama**: Pass structured data output directly to Ollama, unlocking new possibilities for streamlined automation and precise data handling.
- **📜 Offline Swagger Documentation**: Developer-friendly Swagger API docs are now available offline, ensuring full accessibility wherever you are.
- **📸 Quick Screen Capture Button**: Effortlessly capture your screen with a single click from the message input menu.
- **🌍 i18n Updates**: Improved and refined translations across several languages, including Ukrainian, German, Brazilian Portuguese, Catalan, and more, ensuring a seamless global user experience.
### Fixed
- **📋 Table Export to CSV**: Resolved issues with CSV export where headers were missing or errors occurred due to values with commas, ensuring smooth and reliable data handling.
- **🔓 BYPASS_MODEL_ACCESS_CONTROL**: Fixed an issue where users could see models but couldnt use them with 'BYPASS_MODEL_ACCESS_CONTROL=True', restoring proper functionality for environments leveraging this setting.
### Changed
- **💡 API Key Authentication Restriction**: Narrowed API key auth permissions to '/api/models' and '/api/chat/completions' for enhanced security and better API governance.
- **⚙️ Backend Overhaul for Performance**: Major backend restructuring; a heads-up that some "Functions" using internal variables may face compatibility issues. Moving forward, websocket support is mandatory to ensure Open WebUI operates seamlessly.
### Removed
- **⚠️ Legacy Functionality Clean-Up**: Deprecated outdated backend systems that were non-essential or overlapped with newer implementations, allowing for a leaner, more efficient platform.
## [0.4.8] - 2024-12-07

View File

@ -2,7 +2,7 @@
## Our Pledge
As members, contributors, and leaders of this community, we pledge to make participation in our open-source project a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
As members, contributors, and leaders of this community, we pledge to make participation in our open-source project a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socioeconomic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
We are committed to creating and maintaining an open, respectful, and professional environment where positive contributions and meaningful discussions can flourish. By participating in this project, you agree to uphold these values and align your behavior to the standards outlined in this Code of Conduct.

40
LICENSE
View File

@ -1,21 +1,27 @@
MIT License
Copyright (c) 2023-2025 Timothy Jaeryang Baek
All rights reserved.
Copyright (c) 2023 Timothy Jaeryang Baek
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View File

@ -11,7 +11,9 @@
[![Discord](https://img.shields.io/badge/Discord-Open_WebUI-blue?logo=discord&logoColor=white)](https://discord.gg/5rJgQTnV4s)
[![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/tjbck)
Open WebUI is an [extensible](https://github.com/open-webui/pipelines), feature-rich, and user-friendly self-hosted WebUI designed to operate entirely offline. It supports various LLM runners, including Ollama and OpenAI-compatible APIs. For more information, be sure to check out our [Open WebUI Documentation](https://docs.openwebui.com/).
**Open WebUI is an [extensible](https://docs.openwebui.com/features/plugin/), feature-rich, and user-friendly self-hosted AI platform designed to operate entirely offline.** It supports various LLM runners like **Ollama** and **OpenAI-compatible APIs**, with **built-in inference engine** for RAG, making it a **powerful AI deployment solution**.
For more information, be sure to check out our [Open WebUI Documentation](https://docs.openwebui.com/).
![Open WebUI Demo](./demo.gif)
@ -185,13 +187,21 @@ If you want to try out the latest bleeding-edge features and are okay with occas
docker run -d -p 3000:8080 -v open-webui:/app/backend/data --name open-webui --add-host=host.docker.internal:host-gateway --restart always ghcr.io/open-webui/open-webui:dev
```
### Offline Mode
If you are running Open WebUI in an offline environment, you can set the `HF_HUB_OFFLINE` environment variable to `1` to prevent attempts to download models from the internet.
```bash
export HF_HUB_OFFLINE=1
```
## What's Next? 🌟
Discover upcoming features on our roadmap in the [Open WebUI Documentation](https://docs.openwebui.com/roadmap/).
## License 📜
This project is licensed under the [MIT License](LICENSE) - see the [LICENSE](LICENSE) file for details. 📄
This project is licensed under the [BSD-3-Clause License](LICENSE) - see the [LICENSE](LICENSE) file for details. 📄
## Support 💬

View File

@ -21,7 +21,7 @@ from open_webui.env import (
WEBUI_NAME,
log,
DATABASE_URL,
OFFLINE_MODE
OFFLINE_MODE,
)
from pydantic import BaseModel
from sqlalchemy import JSON, Column, DateTime, Integer, func
@ -272,6 +272,18 @@ ENABLE_API_KEY = PersistentConfig(
os.environ.get("ENABLE_API_KEY", "True").lower() == "true",
)
ENABLE_API_KEY_ENDPOINT_RESTRICTIONS = PersistentConfig(
"ENABLE_API_KEY_ENDPOINT_RESTRICTIONS",
"auth.api_key.endpoint_restrictions",
os.environ.get("ENABLE_API_KEY_ENDPOINT_RESTRICTIONS", "False").lower() == "true",
)
API_KEY_ALLOWED_ENDPOINTS = PersistentConfig(
"API_KEY_ALLOWED_ENDPOINTS",
"auth.api_key.allowed_endpoints",
os.environ.get("API_KEY_ALLOWED_ENDPOINTS", ""),
)
JWT_EXPIRES_IN = PersistentConfig(
"JWT_EXPIRES_IN", "auth.jwt_expiry", os.environ.get("JWT_EXPIRES_IN", "-1")
@ -307,6 +319,7 @@ GOOGLE_CLIENT_SECRET = PersistentConfig(
os.environ.get("GOOGLE_CLIENT_SECRET", ""),
)
GOOGLE_OAUTH_SCOPE = PersistentConfig(
"GOOGLE_OAUTH_SCOPE",
"oauth.google.scope",
@ -403,12 +416,24 @@ OAUTH_EMAIL_CLAIM = PersistentConfig(
os.environ.get("OAUTH_EMAIL_CLAIM", "email"),
)
OAUTH_GROUPS_CLAIM = PersistentConfig(
"OAUTH_GROUPS_CLAIM",
"oauth.oidc.group_claim",
os.environ.get("OAUTH_GROUP_CLAIM", "groups"),
)
ENABLE_OAUTH_ROLE_MANAGEMENT = PersistentConfig(
"ENABLE_OAUTH_ROLE_MANAGEMENT",
"oauth.enable_role_mapping",
os.environ.get("ENABLE_OAUTH_ROLE_MANAGEMENT", "False").lower() == "true",
)
ENABLE_OAUTH_GROUP_MANAGEMENT = PersistentConfig(
"ENABLE_OAUTH_GROUP_MANAGEMENT",
"oauth.enable_group_mapping",
os.environ.get("ENABLE_OAUTH_GROUP_MANAGEMENT", "False").lower() == "true",
)
OAUTH_ROLES_CLAIM = PersistentConfig(
"OAUTH_ROLES_CLAIM",
"oauth.roles_claim",
@ -696,6 +721,12 @@ OPENAI_API_BASE_URL = "https://api.openai.com/v1"
# WEBUI
####################################
WEBUI_URL = PersistentConfig(
"WEBUI_URL", "webui.url", os.environ.get("WEBUI_URL", "http://localhost:3000")
)
ENABLE_SIGNUP = PersistentConfig(
"ENABLE_SIGNUP",
"ui.enable_signup",
@ -823,6 +854,12 @@ USER_PERMISSIONS = PersistentConfig(
},
)
ENABLE_CHANNELS = PersistentConfig(
"ENABLE_CHANNELS",
"channels.enable",
os.environ.get("ENABLE_CHANNELS", "False").lower() == "true",
)
ENABLE_EVALUATION_ARENA_MODELS = PersistentConfig(
"ENABLE_EVALUATION_ARENA_MODELS",
@ -1174,11 +1211,34 @@ if VECTOR_DB == "pgvector" and not PGVECTOR_DB_URL.startswith("postgres"):
raise ValueError(
"Pgvector requires setting PGVECTOR_DB_URL or using Postgres with vector extension as the primary database."
)
PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH = int(
os.environ.get("PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH", "1536")
)
####################################
# Information Retrieval (RAG)
####################################
# If configured, Google Drive will be available as an upload option.
ENABLE_GOOGLE_DRIVE_INTEGRATION = PersistentConfig(
"ENABLE_GOOGLE_DRIVE_INTEGRATION",
"google_drive.enable",
os.getenv("ENABLE_GOOGLE_DRIVE_INTEGRATION", "False").lower() == "true",
)
GOOGLE_DRIVE_CLIENT_ID = PersistentConfig(
"GOOGLE_DRIVE_CLIENT_ID",
"google_drive.client_id",
os.environ.get("GOOGLE_DRIVE_CLIENT_ID", ""),
)
GOOGLE_DRIVE_API_KEY = PersistentConfig(
"GOOGLE_DRIVE_API_KEY",
"google_drive.api_key",
os.environ.get("GOOGLE_DRIVE_API_KEY", ""),
)
# RAG Content Extraction
CONTENT_EXTRACTION_ENGINE = PersistentConfig(
"CONTENT_EXTRACTION_ENGINE",
@ -1253,7 +1313,8 @@ RAG_EMBEDDING_MODEL = PersistentConfig(
log.info(f"Embedding model set: {RAG_EMBEDDING_MODEL.value}")
RAG_EMBEDDING_MODEL_AUTO_UPDATE = (
not OFFLINE_MODE and os.environ.get("RAG_EMBEDDING_MODEL_AUTO_UPDATE", "True").lower() == "true"
not OFFLINE_MODE
and os.environ.get("RAG_EMBEDDING_MODEL_AUTO_UPDATE", "True").lower() == "true"
)
RAG_EMBEDDING_MODEL_TRUST_REMOTE_CODE = (
@ -1278,7 +1339,8 @@ if RAG_RERANKING_MODEL.value != "":
log.info(f"Reranking model set: {RAG_RERANKING_MODEL.value}")
RAG_RERANKING_MODEL_AUTO_UPDATE = (
not OFFLINE_MODE and os.environ.get("RAG_RERANKING_MODEL_AUTO_UPDATE", "True").lower() == "true"
not OFFLINE_MODE
and os.environ.get("RAG_RERANKING_MODEL_AUTO_UPDATE", "True").lower() == "true"
)
RAG_RERANKING_MODEL_TRUST_REMOTE_CODE = (
@ -1412,6 +1474,7 @@ RAG_WEB_SEARCH_DOMAIN_FILTER_LIST = PersistentConfig(
],
)
SEARXNG_QUERY_URL = PersistentConfig(
"SEARXNG_QUERY_URL",
"rag.web.search.searxng_query_url",
@ -1587,6 +1650,12 @@ COMFYUI_BASE_URL = PersistentConfig(
os.getenv("COMFYUI_BASE_URL", ""),
)
COMFYUI_API_KEY = PersistentConfig(
"COMFYUI_API_KEY",
"image_generation.comfyui.api_key",
os.getenv("COMFYUI_API_KEY", ""),
)
COMFYUI_DEFAULT_WORKFLOW = """
{
"3": {
@ -1748,7 +1817,8 @@ WHISPER_MODEL = PersistentConfig(
WHISPER_MODEL_DIR = os.getenv("WHISPER_MODEL_DIR", f"{CACHE_DIR}/whisper/models")
WHISPER_MODEL_AUTO_UPDATE = (
not OFFLINE_MODE and os.environ.get("WHISPER_MODEL_AUTO_UPDATE", "").lower() == "true"
not OFFLINE_MODE
and os.environ.get("WHISPER_MODEL_AUTO_UPDATE", "").lower() == "true"
)

View File

@ -53,6 +53,11 @@ if USE_CUDA.lower() == "true":
else:
DEVICE_TYPE = "cpu"
try:
if torch.backends.mps.is_available() and torch.backends.mps.is_built():
DEVICE_TYPE = "mps"
except Exception:
pass
####################################
# LOGGING
@ -103,8 +108,6 @@ WEBUI_NAME = os.environ.get("WEBUI_NAME", "Open WebUI")
if WEBUI_NAME != "Open WebUI":
WEBUI_NAME += " (Open WebUI)"
WEBUI_URL = os.environ.get("WEBUI_URL", "http://localhost:3000")
WEBUI_FAVICON_URL = "https://openwebui.com/favicon.png"
@ -315,6 +318,11 @@ RESET_CONFIG_ON_START = (
os.environ.get("RESET_CONFIG_ON_START", "False").lower() == "true"
)
ENABLE_REALTIME_CHAT_SAVE = (
os.environ.get("ENABLE_REALTIME_CHAT_SAVE", "False").lower() == "true"
)
####################################
# REDIS
####################################
@ -396,3 +404,6 @@ else:
####################################
OFFLINE_MODE = os.environ.get("OFFLINE_MODE", "false").lower() == "true"
if OFFLINE_MODE:
os.environ["HF_HUB_OFFLINE"] = "1"

View File

@ -55,7 +55,7 @@ def handle_peewee_migration(DATABASE_URL):
try:
# Replace the postgresql:// with postgres:// to handle the peewee migration
db = register_connection(DATABASE_URL.replace("postgresql://", "postgres://"))
migrate_dir = OPEN_WEBUI_DIR / "apps" / "webui" / "internal" / "migrations"
migrate_dir = OPEN_WEBUI_DIR / "internal" / "migrations"
router = Router(db, logger=log, migrate_dir=migrate_dir)
router.run()
db.close()

View File

@ -18,6 +18,8 @@ from typing import Optional
from aiocache import cached
import aiohttp
import requests
from fastapi import (
Depends,
FastAPI,
@ -27,7 +29,12 @@ from fastapi import (
Request,
UploadFile,
status,
applications,
BackgroundTasks,
)
from fastapi.openapi.docs import get_swagger_ui_html
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
@ -51,6 +58,7 @@ from open_webui.routers import (
pipelines,
tasks,
auths,
channels,
chats,
folders,
configs,
@ -96,6 +104,7 @@ from open_webui.config import (
AUTOMATIC1111_SAMPLER,
AUTOMATIC1111_SCHEDULER,
COMFYUI_BASE_URL,
COMFYUI_API_KEY,
COMFYUI_WORKFLOW,
COMFYUI_WORKFLOW_NODES,
ENABLE_IMAGE_GENERATION,
@ -171,10 +180,13 @@ from open_webui.config import (
MOJEEK_SEARCH_API_KEY,
GOOGLE_PSE_API_KEY,
GOOGLE_PSE_ENGINE_ID,
GOOGLE_DRIVE_CLIENT_ID,
GOOGLE_DRIVE_API_KEY,
ENABLE_RAG_HYBRID_SEARCH,
ENABLE_RAG_LOCAL_WEB_FETCH,
ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION,
ENABLE_RAG_WEB_SEARCH,
ENABLE_GOOGLE_DRIVE_INTEGRATION,
UPLOAD_DIR,
# WebUI
WEBUI_AUTH,
@ -187,6 +199,9 @@ from open_webui.config import (
ENABLE_SIGNUP,
ENABLE_LOGIN_FORM,
ENABLE_API_KEY,
ENABLE_API_KEY_ENDPOINT_RESTRICTIONS,
API_KEY_ALLOWED_ENDPOINTS,
ENABLE_CHANNELS,
ENABLE_COMMUNITY_SHARING,
ENABLE_MESSAGE_RATING,
ENABLE_EVALUATION_ARENA_MODELS,
@ -226,6 +241,7 @@ from open_webui.config import (
CORS_ALLOW_ORIGIN,
DEFAULT_LOCALE,
OAUTH_PROVIDERS,
WEBUI_URL,
# Admin
ENABLE_ADMIN_CHAT_ACCESS,
ENABLE_ADMIN_EXPORT,
@ -251,13 +267,13 @@ from open_webui.env import (
SAFE_MODE,
SRC_LOG_LEVELS,
VERSION,
WEBUI_URL,
WEBUI_BUILD_HASH,
WEBUI_SECRET_KEY,
WEBUI_SESSION_COOKIE_SAME_SITE,
WEBUI_SESSION_COOKIE_SECURE,
WEBUI_AUTH_TRUSTED_EMAIL_HEADER,
WEBUI_AUTH_TRUSTED_NAME_HEADER,
ENABLE_WEBSOCKET_SUPPORT,
BYPASS_MODEL_ACCESS_CONTROL,
RESET_CONFIG_ON_START,
OFFLINE_MODE,
@ -285,6 +301,7 @@ from open_webui.utils.auth import (
from open_webui.utils.oauth import oauth_manager
from open_webui.utils.security_headers import SecurityHeadersMiddleware
from open_webui.tasks import stop_task, list_tasks # Import from tasks.py
if SAFE_MODE:
print("SAFE MODE ENABLED")
@ -374,9 +391,15 @@ app.state.OPENAI_MODELS = {}
#
########################################
app.state.config.WEBUI_URL = WEBUI_URL
app.state.config.ENABLE_SIGNUP = ENABLE_SIGNUP
app.state.config.ENABLE_LOGIN_FORM = ENABLE_LOGIN_FORM
app.state.config.ENABLE_API_KEY = ENABLE_API_KEY
app.state.config.ENABLE_API_KEY_ENDPOINT_RESTRICTIONS = (
ENABLE_API_KEY_ENDPOINT_RESTRICTIONS
)
app.state.config.API_KEY_ALLOWED_ENDPOINTS = API_KEY_ALLOWED_ENDPOINTS
app.state.config.JWT_EXPIRES_IN = JWT_EXPIRES_IN
@ -393,6 +416,8 @@ app.state.config.WEBHOOK_URL = WEBHOOK_URL
app.state.config.BANNERS = WEBUI_BANNERS
app.state.config.MODEL_ORDER_LIST = MODEL_ORDER_LIST
app.state.config.ENABLE_CHANNELS = ENABLE_CHANNELS
app.state.config.ENABLE_COMMUNITY_SHARING = ENABLE_COMMUNITY_SHARING
app.state.config.ENABLE_MESSAGE_RATING = ENABLE_MESSAGE_RATING
@ -477,6 +502,7 @@ app.state.config.ENABLE_RAG_WEB_SEARCH = ENABLE_RAG_WEB_SEARCH
app.state.config.RAG_WEB_SEARCH_ENGINE = RAG_WEB_SEARCH_ENGINE
app.state.config.RAG_WEB_SEARCH_DOMAIN_FILTER_LIST = RAG_WEB_SEARCH_DOMAIN_FILTER_LIST
app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION = ENABLE_GOOGLE_DRIVE_INTEGRATION
app.state.config.SEARXNG_QUERY_URL = SEARXNG_QUERY_URL
app.state.config.GOOGLE_PSE_API_KEY = GOOGLE_PSE_API_KEY
app.state.config.GOOGLE_PSE_ENGINE_ID = GOOGLE_PSE_ENGINE_ID
@ -504,6 +530,22 @@ app.state.rf = None
app.state.YOUTUBE_LOADER_TRANSLATION = None
try:
app.state.ef = get_ef(
app.state.config.RAG_EMBEDDING_ENGINE,
app.state.config.RAG_EMBEDDING_MODEL,
RAG_EMBEDDING_MODEL_AUTO_UPDATE,
)
app.state.rf = get_rf(
app.state.config.RAG_RERANKING_MODEL,
RAG_RERANKING_MODEL_AUTO_UPDATE,
)
except Exception as e:
log.error(f"Error updating models: {e}")
pass
app.state.EMBEDDING_FUNCTION = get_embedding_function(
app.state.config.RAG_EMBEDDING_ENGINE,
app.state.config.RAG_EMBEDDING_MODEL,
@ -521,21 +563,6 @@ app.state.EMBEDDING_FUNCTION = get_embedding_function(
app.state.config.RAG_EMBEDDING_BATCH_SIZE,
)
try:
app.state.ef = get_ef(
app.state.config.RAG_EMBEDDING_ENGINE,
app.state.config.RAG_EMBEDDING_MODEL,
RAG_EMBEDDING_MODEL_AUTO_UPDATE,
)
app.state.rf = get_rf(
app.state.config.RAG_RERANKING_MODEL,
RAG_RERANKING_MODEL_AUTO_UPDATE,
)
except Exception as e:
log.error(f"Error updating models: {e}")
pass
########################################
#
@ -557,6 +584,7 @@ app.state.config.AUTOMATIC1111_CFG_SCALE = AUTOMATIC1111_CFG_SCALE
app.state.config.AUTOMATIC1111_SAMPLER = AUTOMATIC1111_SAMPLER
app.state.config.AUTOMATIC1111_SCHEDULER = AUTOMATIC1111_SCHEDULER
app.state.config.COMFYUI_BASE_URL = COMFYUI_BASE_URL
app.state.config.COMFYUI_API_KEY = COMFYUI_API_KEY
app.state.config.COMFYUI_WORKFLOW = COMFYUI_WORKFLOW
app.state.config.COMFYUI_WORKFLOW_NODES = COMFYUI_WORKFLOW_NODES
@ -722,6 +750,8 @@ app.include_router(configs.router, prefix="/api/v1/configs", tags=["configs"])
app.include_router(auths.router, prefix="/api/v1/auths", tags=["auths"])
app.include_router(users.router, prefix="/api/v1/users", tags=["users"])
app.include_router(channels.router, prefix="/api/v1/channels", tags=["channels"])
app.include_router(chats.router, prefix="/api/v1/chats", tags=["chats"])
app.include_router(models.router, prefix="/api/v1/models", tags=["models"])
@ -810,11 +840,11 @@ async def chat_completion(
request: Request,
form_data: dict,
user=Depends(get_verified_user),
bypass_filter: bool = False,
):
if not request.app.state.MODELS:
await get_all_models(request)
tasks = form_data.pop("background_tasks", None)
try:
model_id = form_data.get("model", None)
if model_id not in request.app.state.MODELS:
@ -822,13 +852,26 @@ async def chat_completion(
model = request.app.state.MODELS[model_id]
# Check if user has access to the model
if not bypass_filter and user.role == "user":
if not BYPASS_MODEL_ACCESS_CONTROL and user.role == "user":
try:
check_model_access(user, model)
except Exception as e:
raise e
form_data, events = await process_chat_payload(request, form_data, user, model)
metadata = {
"user_id": user.id,
"chat_id": form_data.pop("chat_id", None),
"message_id": form_data.pop("id", None),
"session_id": form_data.pop("session_id", None),
"tool_ids": form_data.get("tool_ids", None),
"files": form_data.get("files", None),
"features": form_data.get("features", None),
}
form_data["metadata"] = metadata
form_data, events = await process_chat_payload(
request, form_data, metadata, user, model
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@ -836,10 +879,10 @@ async def chat_completion(
)
try:
response = await chat_completion_handler(
request, form_data, user, bypass_filter
response = await chat_completion_handler(request, form_data, user)
return await process_chat_response(
request, response, form_data, user, events, metadata, tasks
)
return await process_chat_response(response, events)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@ -878,6 +921,20 @@ async def chat_action(
)
@app.post("/api/tasks/stop/{task_id}")
async def stop_task_endpoint(task_id: str, user=Depends(get_verified_user)):
try:
result = await stop_task(task_id) # Use the function from tasks.py
return result
except ValueError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
@app.get("/api/tasks")
async def list_tasks_endpoint(user=Depends(get_verified_user)):
return {"tasks": list_tasks()} # Use the function from tasks.py
##################################
#
# Config Endpoints
@ -925,9 +982,12 @@ async def get_app_config(request: Request):
"enable_api_key": app.state.config.ENABLE_API_KEY,
"enable_signup": app.state.config.ENABLE_SIGNUP,
"enable_login_form": app.state.config.ENABLE_LOGIN_FORM,
"enable_websocket": ENABLE_WEBSOCKET_SUPPORT,
**(
{
"enable_channels": app.state.config.ENABLE_CHANNELS,
"enable_web_search": app.state.config.ENABLE_RAG_WEB_SEARCH,
"enable_google_drive_integration": app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION,
"enable_image_generation": app.state.config.ENABLE_IMAGE_GENERATION,
"enable_community_sharing": app.state.config.ENABLE_COMMUNITY_SHARING,
"enable_message_rating": app.state.config.ENABLE_MESSAGE_RATING,
@ -938,6 +998,10 @@ async def get_app_config(request: Request):
else {}
),
},
"google_drive": {
"client_id": GOOGLE_DRIVE_CLIENT_ID.value,
"api_key": GOOGLE_DRIVE_API_KEY.value,
},
**(
{
"default_models": app.state.config.DEFAULT_MODELS,
@ -1082,9 +1146,9 @@ async def get_opensearch_xml():
<ShortName>{WEBUI_NAME}</ShortName>
<Description>Search {WEBUI_NAME}</Description>
<InputEncoding>UTF-8</InputEncoding>
<Image width="16" height="16" type="image/x-icon">{WEBUI_URL}/static/favicon.png</Image>
<Url type="text/html" method="get" template="{WEBUI_URL}/?q={"{searchTerms}"}"/>
<moz:SearchForm>{WEBUI_URL}</moz:SearchForm>
<Image width="16" height="16" type="image/x-icon">{app.state.config.WEBUI_URL}/static/favicon.png</Image>
<Url type="text/html" method="get" template="{app.state.config.WEBUI_URL}/?q={"{searchTerms}"}"/>
<moz:SearchForm>{app.state.config.WEBUI_URL}</moz:SearchForm>
</OpenSearchDescription>
"""
return Response(content=xml_content, media_type="application/xml")
@ -1104,6 +1168,19 @@ async def healthcheck_with_db():
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
app.mount("/cache", StaticFiles(directory=CACHE_DIR), name="cache")
def swagger_ui_html(*args, **kwargs):
return get_swagger_ui_html(
*args,
**kwargs,
swagger_js_url="/static/swagger-ui/swagger-ui-bundle.js",
swagger_css_url="/static/swagger-ui/swagger-ui.css",
swagger_favicon_url="/static/swagger-ui/favicon.png",
)
applications.get_swagger_ui_html = swagger_ui_html
if os.path.exists(FRONTEND_BUILD_DIR):
mimetypes.add_type("text/javascript", ".js")
app.mount(

View File

@ -0,0 +1,70 @@
"""Update message & channel tables
Revision ID: 3781e22d8b01
Revises: 7826ab40b532
Create Date: 2024-12-30 03:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
revision = "3781e22d8b01"
down_revision = "7826ab40b532"
branch_labels = None
depends_on = None
def upgrade():
# Add 'type' column to the 'channel' table
op.add_column(
"channel",
sa.Column(
"type",
sa.Text(),
nullable=True,
),
)
# Add 'parent_id' column to the 'message' table for threads
op.add_column(
"message",
sa.Column("parent_id", sa.Text(), nullable=True),
)
op.create_table(
"message_reaction",
sa.Column(
"id", sa.Text(), nullable=False, primary_key=True, unique=True
), # Unique reaction ID
sa.Column("user_id", sa.Text(), nullable=False), # User who reacted
sa.Column(
"message_id", sa.Text(), nullable=False
), # Message that was reacted to
sa.Column(
"name", sa.Text(), nullable=False
), # Reaction name (e.g. "thumbs_up")
sa.Column(
"created_at", sa.BigInteger(), nullable=True
), # Timestamp of when the reaction was added
)
op.create_table(
"channel_member",
sa.Column(
"id", sa.Text(), nullable=False, primary_key=True, unique=True
), # Record ID for the membership row
sa.Column("channel_id", sa.Text(), nullable=False), # Associated channel
sa.Column("user_id", sa.Text(), nullable=False), # Associated user
sa.Column(
"created_at", sa.BigInteger(), nullable=True
), # Timestamp of when the user joined the channel
)
def downgrade():
# Revert 'type' column addition to the 'channel' table
op.drop_column("channel", "type")
op.drop_column("message", "parent_id")
op.drop_table("message_reaction")
op.drop_table("channel_member")

View File

@ -0,0 +1,48 @@
"""Add channel table
Revision ID: 57c599a3cb57
Revises: 922e7a387820
Create Date: 2024-12-22 03:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
revision = "57c599a3cb57"
down_revision = "922e7a387820"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"channel",
sa.Column("id", sa.Text(), nullable=False, primary_key=True, unique=True),
sa.Column("user_id", sa.Text()),
sa.Column("name", sa.Text()),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("data", sa.JSON(), nullable=True),
sa.Column("meta", sa.JSON(), nullable=True),
sa.Column("access_control", sa.JSON(), nullable=True),
sa.Column("created_at", sa.BigInteger(), nullable=True),
sa.Column("updated_at", sa.BigInteger(), nullable=True),
)
op.create_table(
"message",
sa.Column("id", sa.Text(), nullable=False, primary_key=True, unique=True),
sa.Column("user_id", sa.Text()),
sa.Column("channel_id", sa.Text(), nullable=True),
sa.Column("content", sa.Text()),
sa.Column("data", sa.JSON(), nullable=True),
sa.Column("meta", sa.JSON(), nullable=True),
sa.Column("created_at", sa.BigInteger(), nullable=True),
sa.Column("updated_at", sa.BigInteger(), nullable=True),
)
def downgrade():
op.drop_table("channel")
op.drop_table("message")

View File

@ -0,0 +1,26 @@
"""Update file table
Revision ID: 7826ab40b532
Revises: 57c599a3cb57
Create Date: 2024-12-23 03:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
revision = "7826ab40b532"
down_revision = "57c599a3cb57"
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
"file",
sa.Column("access_control", sa.JSON(), nullable=True),
)
def downgrade():
op.drop_column("file", "access_control")

View File

@ -0,0 +1,136 @@
import json
import time
import uuid
from typing import Optional
from open_webui.internal.db import Base, get_db
from open_webui.utils.access_control import has_access
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON
from sqlalchemy import or_, func, select, and_, text
from sqlalchemy.sql import exists
####################
# Channel DB Schema
####################
class Channel(Base):
__tablename__ = "channel"
id = Column(Text, primary_key=True)
user_id = Column(Text)
type = Column(Text, nullable=True)
name = Column(Text)
description = Column(Text, nullable=True)
data = Column(JSON, nullable=True)
meta = Column(JSON, nullable=True)
access_control = Column(JSON, nullable=True)
created_at = Column(BigInteger)
updated_at = Column(BigInteger)
class ChannelModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
user_id: str
type: Optional[str] = None
name: str
description: Optional[str] = None
data: Optional[dict] = None
meta: Optional[dict] = None
access_control: Optional[dict] = None
created_at: int # timestamp in epoch
updated_at: int # timestamp in epoch
####################
# Forms
####################
class ChannelForm(BaseModel):
name: str
description: Optional[str] = None
data: Optional[dict] = None
meta: Optional[dict] = None
access_control: Optional[dict] = None
class ChannelTable:
def insert_new_channel(
self, type: Optional[str], form_data: ChannelForm, user_id: str
) -> Optional[ChannelModel]:
with get_db() as db:
channel = ChannelModel(
**{
**form_data.model_dump(),
"type": type,
"name": form_data.name.lower(),
"id": str(uuid.uuid4()),
"user_id": user_id,
"created_at": int(time.time_ns()),
"updated_at": int(time.time_ns()),
}
)
new_channel = Channel(**channel.model_dump())
db.add(new_channel)
db.commit()
return channel
def get_channels(self) -> list[ChannelModel]:
with get_db() as db:
channels = db.query(Channel).all()
return [ChannelModel.model_validate(channel) for channel in channels]
def get_channels_by_user_id(
self, user_id: str, permission: str = "read"
) -> list[ChannelModel]:
channels = self.get_channels()
return [
channel
for channel in channels
if channel.user_id == user_id
or has_access(user_id, permission, channel.access_control)
]
def get_channel_by_id(self, id: str) -> Optional[ChannelModel]:
with get_db() as db:
channel = db.query(Channel).filter(Channel.id == id).first()
return ChannelModel.model_validate(channel) if channel else None
def update_channel_by_id(
self, id: str, form_data: ChannelForm
) -> Optional[ChannelModel]:
with get_db() as db:
channel = db.query(Channel).filter(Channel.id == id).first()
if not channel:
return None
channel.name = form_data.name
channel.data = form_data.data
channel.meta = form_data.meta
channel.access_control = form_data.access_control
channel.updated_at = int(time.time_ns())
db.commit()
return ChannelModel.model_validate(channel) if channel else None
def delete_channel_by_id(self, id: str):
with get_db() as db:
db.query(Channel).filter(Channel.id == id).delete()
db.commit()
return True
Channels = ChannelTable()

View File

@ -168,6 +168,100 @@ class ChatTable:
except Exception:
return None
def update_chat_title_by_id(self, id: str, title: str) -> Optional[ChatModel]:
chat = self.get_chat_by_id(id)
if chat is None:
return None
chat = chat.chat
chat["title"] = title
return self.update_chat_by_id(id, chat)
def update_chat_tags_by_id(
self, id: str, tags: list[str], user
) -> Optional[ChatModel]:
chat = self.get_chat_by_id(id)
if chat is None:
return None
self.delete_all_tags_by_id_and_user_id(id, user.id)
for tag in chat.meta.get("tags", []):
if self.count_chats_by_tag_name_and_user_id(tag, user.id) == 0:
Tags.delete_tag_by_name_and_user_id(tag, user.id)
for tag_name in tags:
if tag_name.lower() == "none":
continue
self.add_chat_tag_by_id_and_user_id_and_tag_name(id, user.id, tag_name)
return self.get_chat_by_id(id)
def get_chat_title_by_id(self, id: str) -> Optional[str]:
chat = self.get_chat_by_id(id)
if chat is None:
return None
return chat.chat.get("title", "New Chat")
def get_messages_by_chat_id(self, id: str) -> Optional[dict]:
chat = self.get_chat_by_id(id)
if chat is None:
return None
return chat.chat.get("history", {}).get("messages", {}) or {}
def get_message_by_id_and_message_id(
self, id: str, message_id: str
) -> Optional[dict]:
chat = self.get_chat_by_id(id)
if chat is None:
return None
return chat.chat.get("history", {}).get("messages", {}).get(message_id, {})
def upsert_message_to_chat_by_id_and_message_id(
self, id: str, message_id: str, message: dict
) -> Optional[ChatModel]:
chat = self.get_chat_by_id(id)
if chat is None:
return None
chat = chat.chat
history = chat.get("history", {})
if message_id in history.get("messages", {}):
history["messages"][message_id] = {
**history["messages"][message_id],
**message,
}
else:
history["messages"][message_id] = message
history["currentId"] = message_id
chat["history"] = history
return self.update_chat_by_id(id, chat)
def add_message_status_to_chat_by_id_and_message_id(
self, id: str, message_id: str, status: dict
) -> Optional[ChatModel]:
chat = self.get_chat_by_id(id)
if chat is None:
return None
chat = chat.chat
history = chat.get("history", {})
if message_id in history.get("messages", {}):
status_history = history["messages"][message_id].get("statusHistory", [])
status_history.append(status)
history["messages"][message_id]["statusHistory"] = status_history
chat["history"] = history
return self.update_chat_by_id(id, chat)
def insert_shared_chat_by_chat_id(self, chat_id: str) -> Optional[ChatModel]:
with get_db() as db:
# Get the existing chat to share
@ -375,6 +469,8 @@ class ChatTable:
def get_chat_by_share_id(self, id: str) -> Optional[ChatModel]:
try:
with get_db() as db:
# it is possible that the shared link was deleted. hence,
# we check if the chat is still shared by checkng if a chat with the share_id exists
chat = db.query(Chat).filter_by(share_id=id).first()
if chat:

View File

@ -27,6 +27,8 @@ class File(Base):
data = Column(JSON, nullable=True)
meta = Column(JSON, nullable=True)
access_control = Column(JSON, nullable=True)
created_at = Column(BigInteger)
updated_at = Column(BigInteger)
@ -44,6 +46,8 @@ class FileModel(BaseModel):
data: Optional[dict] = None
meta: Optional[dict] = None
access_control: Optional[dict] = None
created_at: Optional[int] # timestamp in epoch
updated_at: Optional[int] # timestamp in epoch
@ -90,6 +94,7 @@ class FileForm(BaseModel):
path: str
data: dict = {}
meta: dict = {}
access_control: Optional[dict] = None
class FilesTable:

View File

@ -146,6 +146,13 @@ class GroupTable:
except Exception:
return None
def get_group_user_ids_by_id(self, id: str) -> Optional[str]:
group = self.get_group_by_id(id)
if group:
return group.user_ids
else:
return None
def update_group_by_id(
self, id: str, form_data: GroupUpdateForm, overwrite: bool = False
) -> Optional[GroupModel]:

View File

@ -0,0 +1,279 @@
import json
import time
import uuid
from typing import Optional
from open_webui.internal.db import Base, get_db
from open_webui.models.tags import TagModel, Tag, Tags
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON
from sqlalchemy import or_, func, select, and_, text
from sqlalchemy.sql import exists
####################
# Message DB Schema
####################
class MessageReaction(Base):
__tablename__ = "message_reaction"
id = Column(Text, primary_key=True)
user_id = Column(Text)
message_id = Column(Text)
name = Column(Text)
created_at = Column(BigInteger)
class MessageReactionModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
user_id: str
message_id: str
name: str
created_at: int # timestamp in epoch
class Message(Base):
__tablename__ = "message"
id = Column(Text, primary_key=True)
user_id = Column(Text)
channel_id = Column(Text, nullable=True)
parent_id = Column(Text, nullable=True)
content = Column(Text)
data = Column(JSON, nullable=True)
meta = Column(JSON, nullable=True)
created_at = Column(BigInteger) # time_ns
updated_at = Column(BigInteger) # time_ns
class MessageModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
user_id: str
channel_id: Optional[str] = None
parent_id: Optional[str] = None
content: str
data: Optional[dict] = None
meta: Optional[dict] = None
created_at: int # timestamp in epoch
updated_at: int # timestamp in epoch
####################
# Forms
####################
class MessageForm(BaseModel):
content: str
parent_id: Optional[str] = None
data: Optional[dict] = None
meta: Optional[dict] = None
class Reactions(BaseModel):
name: str
user_ids: list[str]
count: int
class MessageResponse(MessageModel):
latest_reply_at: Optional[int]
reply_count: int
reactions: list[Reactions]
class MessageTable:
def insert_new_message(
self, form_data: MessageForm, channel_id: str, user_id: str
) -> Optional[MessageModel]:
with get_db() as db:
id = str(uuid.uuid4())
ts = int(time.time_ns())
message = MessageModel(
**{
"id": id,
"user_id": user_id,
"channel_id": channel_id,
"parent_id": form_data.parent_id,
"content": form_data.content,
"data": form_data.data,
"meta": form_data.meta,
"created_at": ts,
"updated_at": ts,
}
)
result = Message(**message.model_dump())
db.add(result)
db.commit()
db.refresh(result)
return MessageModel.model_validate(result) if result else None
def get_message_by_id(self, id: str) -> Optional[MessageResponse]:
with get_db() as db:
message = db.get(Message, id)
if not message:
return None
reactions = self.get_reactions_by_message_id(id)
replies = self.get_replies_by_message_id(id)
return MessageResponse(
**{
**MessageModel.model_validate(message).model_dump(),
"latest_reply_at": replies[0].created_at if replies else None,
"reply_count": len(replies),
"reactions": reactions,
}
)
def get_replies_by_message_id(self, id: str) -> list[MessageModel]:
with get_db() as db:
all_messages = (
db.query(Message)
.filter_by(parent_id=id)
.order_by(Message.created_at.desc())
.all()
)
return [MessageModel.model_validate(message) for message in all_messages]
def get_reply_user_ids_by_message_id(self, id: str) -> list[str]:
with get_db() as db:
return [
message.user_id
for message in db.query(Message).filter_by(parent_id=id).all()
]
def get_messages_by_channel_id(
self, channel_id: str, skip: int = 0, limit: int = 50
) -> list[MessageModel]:
with get_db() as db:
all_messages = (
db.query(Message)
.filter_by(channel_id=channel_id, parent_id=None)
.order_by(Message.created_at.desc())
.offset(skip)
.limit(limit)
.all()
)
return [MessageModel.model_validate(message) for message in all_messages]
def get_messages_by_parent_id(
self, channel_id: str, parent_id: str, skip: int = 0, limit: int = 50
) -> list[MessageModel]:
with get_db() as db:
message = db.get(Message, parent_id)
if not message:
return []
all_messages = (
db.query(Message)
.filter_by(channel_id=channel_id, parent_id=parent_id)
.order_by(Message.created_at.desc())
.offset(skip)
.limit(limit)
.all()
)
# If length of all_messages is less than limit, then add the parent message
if len(all_messages) < limit:
all_messages.append(message)
return [MessageModel.model_validate(message) for message in all_messages]
def update_message_by_id(
self, id: str, form_data: MessageForm
) -> Optional[MessageModel]:
with get_db() as db:
message = db.get(Message, id)
message.content = form_data.content
message.data = form_data.data
message.meta = form_data.meta
message.updated_at = int(time.time_ns())
db.commit()
db.refresh(message)
return MessageModel.model_validate(message) if message else None
def add_reaction_to_message(
self, id: str, user_id: str, name: str
) -> Optional[MessageReactionModel]:
with get_db() as db:
reaction_id = str(uuid.uuid4())
reaction = MessageReactionModel(
id=reaction_id,
user_id=user_id,
message_id=id,
name=name,
created_at=int(time.time_ns()),
)
result = MessageReaction(**reaction.model_dump())
db.add(result)
db.commit()
db.refresh(result)
return MessageReactionModel.model_validate(result) if result else None
def get_reactions_by_message_id(self, id: str) -> list[Reactions]:
with get_db() as db:
all_reactions = db.query(MessageReaction).filter_by(message_id=id).all()
reactions = {}
for reaction in all_reactions:
if reaction.name not in reactions:
reactions[reaction.name] = {
"name": reaction.name,
"user_ids": [],
"count": 0,
}
reactions[reaction.name]["user_ids"].append(reaction.user_id)
reactions[reaction.name]["count"] += 1
return [Reactions(**reaction) for reaction in reactions.values()]
def remove_reaction_by_id_and_user_id_and_name(
self, id: str, user_id: str, name: str
) -> bool:
with get_db() as db:
db.query(MessageReaction).filter_by(
message_id=id, user_id=user_id, name=name
).delete()
db.commit()
return True
def delete_reactions_by_id(self, id: str) -> bool:
with get_db() as db:
db.query(MessageReaction).filter_by(message_id=id).delete()
db.commit()
return True
def delete_replies_by_id(self, id: str) -> bool:
with get_db() as db:
db.query(Message).filter_by(parent_id=id).delete()
db.commit()
return True
def delete_message_by_id(self, id: str) -> bool:
with get_db() as db:
db.query(Message).filter_by(id=id).delete()
# Delete all reactions to this message
db.query(MessageReaction).filter_by(message_id=id).delete()
db.commit()
return True
Messages = MessageTable()

View File

@ -70,6 +70,13 @@ class UserResponse(BaseModel):
profile_image_url: str
class UserNameResponse(BaseModel):
id: str
name: str
role: str
profile_image_url: str
class UserRoleUpdateForm(BaseModel):
id: str
role: str
@ -147,13 +154,25 @@ class UsersTable:
except Exception:
return None
def get_users(self, skip: int = 0, limit: int = 50) -> list[UserModel]:
def get_users(
self, skip: Optional[int] = None, limit: Optional[int] = None
) -> list[UserModel]:
with get_db() as db:
users = (
db.query(User)
# .offset(skip).limit(limit)
.all()
)
query = db.query(User).order_by(User.created_at.desc())
if skip:
query = query.offset(skip)
if limit:
query = query.limit(limit)
users = query.all()
return [UserModel.model_validate(user) for user in users]
def get_users_by_user_ids(self, user_ids: list[str]) -> list[UserModel]:
with get_db() as db:
users = db.query(User).filter(User.id.in_(user_ids)).all()
return [UserModel.model_validate(user) for user in users]
def get_num_users(self) -> Optional[int]:
@ -168,6 +187,22 @@ class UsersTable:
except Exception:
return None
def get_user_webhook_url_by_id(self, id: str) -> Optional[str]:
try:
with get_db() as db:
user = db.query(User).filter_by(id=id).first()
if user.settings is None:
return None
else:
return (
user.settings.get("ui", {})
.get("notifications", {})
.get("webhook_url", None)
)
except Exception:
return None
def update_user_role_by_id(self, id: str, role: str) -> Optional[UserModel]:
try:
with get_db() as db:

View File

@ -14,7 +14,7 @@ from langchain_core.documents import Document
from open_webui.retrieval.vector.connector import VECTOR_DB_CLIENT
from open_webui.utils.misc import get_last_user_message
from open_webui.env import SRC_LOG_LEVELS
from open_webui.env import SRC_LOG_LEVELS, OFFLINE_MODE
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
@ -70,7 +70,9 @@ def query_doc(
limit=k,
)
log.info(f"query_doc:result {result.ids} {result.metadatas}")
if result:
log.info(f"query_doc:result {result.ids} {result.metadatas}")
return result
except Exception as e:
print(e)
@ -373,6 +375,9 @@ def get_model_path(model: str, update_model: bool = False):
local_files_only = not update_model
if OFFLINE_MODE:
local_files_only = True
snapshot_kwargs = {
"cache_dir": cache_dir,
"local_files_only": local_files_only,

View File

@ -5,6 +5,7 @@ from sqlalchemy import (
create_engine,
Column,
Integer,
MetaData,
select,
text,
Text,
@ -19,9 +20,9 @@ from pgvector.sqlalchemy import Vector
from sqlalchemy.ext.mutable import MutableDict
from open_webui.retrieval.vector.main import VectorItem, SearchResult, GetResult
from open_webui.config import PGVECTOR_DB_URL
from open_webui.config import PGVECTOR_DB_URL, PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH
VECTOR_LENGTH = 1536
VECTOR_LENGTH = PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH
Base = declarative_base()
@ -56,6 +57,9 @@ class PgvectorClient:
# Ensure the pgvector extension is available
self.session.execute(text("CREATE EXTENSION IF NOT EXISTS vector;"))
# Check vector length consistency
self.check_vector_length()
# Create the tables if they do not exist
# Base.metadata.create_all requires a bind (engine or connection)
# Get the connection from the session
@ -82,6 +86,38 @@ class PgvectorClient:
print(f"Error during initialization: {e}")
raise
def check_vector_length(self) -> None:
"""
Check if the VECTOR_LENGTH matches the existing vector column dimension in the database.
Raises an exception if there is a mismatch.
"""
metadata = MetaData()
metadata.reflect(bind=self.session.bind, only=["document_chunk"])
if "document_chunk" in metadata.tables:
document_chunk_table = metadata.tables["document_chunk"]
if "vector" in document_chunk_table.columns:
vector_column = document_chunk_table.columns["vector"]
vector_type = vector_column.type
if isinstance(vector_type, Vector):
db_vector_length = vector_type.dim
if db_vector_length != VECTOR_LENGTH:
raise Exception(
f"VECTOR_LENGTH {VECTOR_LENGTH} does not match existing vector column dimension {db_vector_length}. "
"Cannot change vector size after initialization without migrating the data."
)
else:
raise Exception(
"The 'vector' column exists but is not of type 'Vector'."
)
else:
raise Exception(
"The 'vector' column does not exist in the 'document_chunk' table."
)
else:
# Table does not exist yet; no action needed
pass
def adjust_vector_length(self, vector: List[float]) -> List[float]:
# Adjust vector to have length VECTOR_LENGTH
current_length = len(vector)

View File

@ -683,7 +683,7 @@
"age": "October 29, 2022",
"extra_snippets": [
"You can pass many options to the configure script; run ./configure --help to find out more. On macOS case-insensitive file systems and on Cygwin, the executable is called python.exe; elsewhere it's just python.",
"Building a complete Python installation requires the use of various additional third-party libraries, depending on your build platform and configure options. Not all standard library modules are buildable or useable on all platforms. Refer to the Install dependencies section of the Developer Guide for current detailed information on dependencies for various Linux distributions and macOS.",
"Building a complete Python installation requires the use of various additional third-party libraries, depending on your build platform and configure options. Not all standard library modules are buildable or usable on all platforms. Refer to the Install dependencies section of the Developer Guide for current detailed information on dependencies for various Linux distributions and macOS.",
"To get an optimized build of Python, configure --enable-optimizations before you run make. This sets the default make targets up to enable Profile Guided Optimization (PGO) and may be used to auto-enable Link Time Optimization (LTO) on some platforms. For more details, see the sections below.",
"Copyright © 2001-2024 Python Software Foundation. All rights reserved."
]

View File

@ -82,15 +82,15 @@ class SafeWebBaseLoader(WebBaseLoader):
def get_web_loader(
url: Union[str, Sequence[str]],
urls: Union[str, Sequence[str]],
verify_ssl: bool = True,
requests_per_second: int = 2,
):
# Check if the URL is valid
if not validate_url(url):
if not validate_url(urls):
raise ValueError(ERROR_MESSAGES.INVALID_URL)
return SafeWebBaseLoader(
url,
urls,
verify_ssl=verify_ssl,
requests_per_second=requests_per_second,
continue_on_failure=True,

View File

@ -218,7 +218,7 @@ async def update_audio_config(
}
def load_speech_pipeline():
def load_speech_pipeline(request):
from transformers import pipeline
from datasets import load_dataset
@ -236,7 +236,11 @@ def load_speech_pipeline():
@router.post("/speech")
async def speech(request: Request, user=Depends(get_verified_user)):
body = await request.body()
name = hashlib.sha256(body).hexdigest()
name = hashlib.sha256(
body
+ str(request.app.state.config.TTS_ENGINE).encode("utf-8")
+ str(request.app.state.config.TTS_MODEL).encode("utf-8")
).hexdigest()
file_path = SPEECH_CACHE_DIR.joinpath(f"{name}.mp3")
file_body_path = SPEECH_CACHE_DIR.joinpath(f"{name}.json")
@ -256,10 +260,11 @@ async def speech(request: Request, user=Depends(get_verified_user)):
payload["model"] = request.app.state.config.TTS_MODEL
try:
# print(payload)
async with aiohttp.ClientSession() as session:
async with session.post(
url=f"{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/speech",
data=payload,
json=payload,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {request.app.state.config.TTS_OPENAI_API_KEY}",
@ -281,7 +286,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
await f.write(await r.read())
async with aiofiles.open(file_body_path, "w") as f:
await f.write(json.dumps(json.loads(body.decode("utf-8"))))
await f.write(json.dumps(payload))
return FileResponse(file_path)
@ -292,6 +297,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
try:
if r.status != 200:
res = await r.json()
if "error" in res:
detail = f"External: {res['error'].get('message', '')}"
except Exception:
@ -305,7 +311,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
elif request.app.state.config.TTS_ENGINE == "elevenlabs":
voice_id = payload.get("voice", "")
if voice_id not in get_available_voices():
if voice_id not in get_available_voices(request):
raise HTTPException(
status_code=400,
detail="Invalid voice id",
@ -332,7 +338,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
await f.write(await r.read())
async with aiofiles.open(file_body_path, "w") as f:
await f.write(json.dumps(json.loads(body.decode("utf-8"))))
await f.write(json.dumps(payload))
return FileResponse(file_path)
@ -384,6 +390,9 @@ async def speech(request: Request, user=Depends(get_verified_user)):
async with aiofiles.open(file_path, "wb") as f:
await f.write(await r.read())
async with aiofiles.open(file_body_path, "w") as f:
await f.write(json.dumps(payload))
return FileResponse(file_path)
except Exception as e:
@ -414,7 +423,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
import torch
import soundfile as sf
load_speech_pipeline()
load_speech_pipeline(request)
embeddings_dataset = request.app.state.speech_speaker_embeddings_dataset
@ -436,8 +445,9 @@ async def speech(request: Request, user=Depends(get_verified_user)):
)
sf.write(file_path, speech["audio"], samplerate=speech["sampling_rate"])
with open(file_body_path, "w") as f:
json.dump(json.loads(body.decode("utf-8")), f)
async with aiofiles.open(file_body_path, "w") as f:
await f.write(json.dumps(payload))
return FileResponse(file_path)

View File

@ -547,7 +547,6 @@ async def add_user(form_data: AddUserForm, user=Depends(get_admin_user)):
raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN)
try:
print(form_data)
hashed = get_password_hash(form_data.password)
user = Auths.insert_new_auth(
form_data.email.lower(),
@ -614,8 +613,12 @@ async def get_admin_details(request: Request, user=Depends(get_current_user)):
async def get_admin_config(request: Request, user=Depends(get_admin_user)):
return {
"SHOW_ADMIN_DETAILS": request.app.state.config.SHOW_ADMIN_DETAILS,
"WEBUI_URL": request.app.state.config.WEBUI_URL,
"ENABLE_SIGNUP": request.app.state.config.ENABLE_SIGNUP,
"ENABLE_API_KEY": request.app.state.config.ENABLE_API_KEY,
"ENABLE_API_KEY_ENDPOINT_RESTRICTIONS": request.app.state.config.ENABLE_API_KEY_ENDPOINT_RESTRICTIONS,
"API_KEY_ALLOWED_ENDPOINTS": request.app.state.config.API_KEY_ALLOWED_ENDPOINTS,
"ENABLE_CHANNELS": request.app.state.config.ENABLE_CHANNELS,
"DEFAULT_USER_ROLE": request.app.state.config.DEFAULT_USER_ROLE,
"JWT_EXPIRES_IN": request.app.state.config.JWT_EXPIRES_IN,
"ENABLE_COMMUNITY_SHARING": request.app.state.config.ENABLE_COMMUNITY_SHARING,
@ -625,8 +628,12 @@ async def get_admin_config(request: Request, user=Depends(get_admin_user)):
class AdminConfig(BaseModel):
SHOW_ADMIN_DETAILS: bool
WEBUI_URL: str
ENABLE_SIGNUP: bool
ENABLE_API_KEY: bool
ENABLE_API_KEY_ENDPOINT_RESTRICTIONS: bool
API_KEY_ALLOWED_ENDPOINTS: str
ENABLE_CHANNELS: bool
DEFAULT_USER_ROLE: str
JWT_EXPIRES_IN: str
ENABLE_COMMUNITY_SHARING: bool
@ -638,8 +645,18 @@ async def update_admin_config(
request: Request, form_data: AdminConfig, user=Depends(get_admin_user)
):
request.app.state.config.SHOW_ADMIN_DETAILS = form_data.SHOW_ADMIN_DETAILS
request.app.state.config.WEBUI_URL = form_data.WEBUI_URL
request.app.state.config.ENABLE_SIGNUP = form_data.ENABLE_SIGNUP
request.app.state.config.ENABLE_API_KEY = form_data.ENABLE_API_KEY
request.app.state.config.ENABLE_API_KEY_ENDPOINT_RESTRICTIONS = (
form_data.ENABLE_API_KEY_ENDPOINT_RESTRICTIONS
)
request.app.state.config.API_KEY_ALLOWED_ENDPOINTS = (
form_data.API_KEY_ALLOWED_ENDPOINTS
)
request.app.state.config.ENABLE_CHANNELS = form_data.ENABLE_CHANNELS
if form_data.DEFAULT_USER_ROLE in ["pending", "user", "admin"]:
request.app.state.config.DEFAULT_USER_ROLE = form_data.DEFAULT_USER_ROLE
@ -657,8 +674,12 @@ async def update_admin_config(
return {
"SHOW_ADMIN_DETAILS": request.app.state.config.SHOW_ADMIN_DETAILS,
"WEBUI_URL": request.app.state.config.WEBUI_URL,
"ENABLE_SIGNUP": request.app.state.config.ENABLE_SIGNUP,
"ENABLE_API_KEY": request.app.state.config.ENABLE_API_KEY,
"ENABLE_API_KEY_ENDPOINT_RESTRICTIONS": request.app.state.config.ENABLE_API_KEY_ENDPOINT_RESTRICTIONS,
"API_KEY_ALLOWED_ENDPOINTS": request.app.state.config.API_KEY_ALLOWED_ENDPOINTS,
"ENABLE_CHANNELS": request.app.state.config.ENABLE_CHANNELS,
"DEFAULT_USER_ROLE": request.app.state.config.DEFAULT_USER_ROLE,
"JWT_EXPIRES_IN": request.app.state.config.JWT_EXPIRES_IN,
"ENABLE_COMMUNITY_SHARING": request.app.state.config.ENABLE_COMMUNITY_SHARING,

View File

@ -0,0 +1,710 @@
import json
import logging
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request, status, BackgroundTasks
from pydantic import BaseModel
from open_webui.socket.main import sio, get_user_ids_from_room
from open_webui.models.users import Users, UserNameResponse
from open_webui.models.channels import Channels, ChannelModel, ChannelForm
from open_webui.models.messages import (
Messages,
MessageModel,
MessageResponse,
MessageForm,
)
from open_webui.config import ENABLE_ADMIN_CHAT_ACCESS, ENABLE_ADMIN_EXPORT
from open_webui.constants import ERROR_MESSAGES
from open_webui.env import SRC_LOG_LEVELS
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.webhook import post_webhook
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
router = APIRouter()
############################
# GetChatList
############################
@router.get("/", response_model=list[ChannelModel])
async def get_channels(user=Depends(get_verified_user)):
if user.role == "admin":
return Channels.get_channels()
else:
return Channels.get_channels_by_user_id(user.id)
############################
# CreateNewChannel
############################
@router.post("/create", response_model=Optional[ChannelModel])
async def create_new_channel(form_data: ChannelForm, user=Depends(get_admin_user)):
try:
channel = Channels.insert_new_channel(None, form_data, user.id)
return ChannelModel(**channel.model_dump())
except Exception as e:
log.exception(e)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
)
############################
# GetChannelById
############################
@router.get("/{id}", response_model=Optional[ChannelModel])
async def get_channel_by_id(id: str, user=Depends(get_verified_user)):
channel = Channels.get_channel_by_id(id)
if not channel:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
if user.role != "admin" and not has_access(
user.id, type="read", access_control=channel.access_control
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
return ChannelModel(**channel.model_dump())
############################
# UpdateChannelById
############################
@router.post("/{id}/update", response_model=Optional[ChannelModel])
async def update_channel_by_id(
id: str, form_data: ChannelForm, user=Depends(get_admin_user)
):
channel = Channels.get_channel_by_id(id)
if not channel:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
try:
channel = Channels.update_channel_by_id(id, form_data)
return ChannelModel(**channel.model_dump())
except Exception as e:
log.exception(e)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
)
############################
# DeleteChannelById
############################
@router.delete("/{id}/delete", response_model=bool)
async def delete_channel_by_id(id: str, user=Depends(get_admin_user)):
channel = Channels.get_channel_by_id(id)
if not channel:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
try:
Channels.delete_channel_by_id(id)
return True
except Exception as e:
log.exception(e)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
)
############################
# GetChannelMessages
############################
class MessageUserResponse(MessageResponse):
user: UserNameResponse
@router.get("/{id}/messages", response_model=list[MessageUserResponse])
async def get_channel_messages(
id: str, skip: int = 0, limit: int = 50, user=Depends(get_verified_user)
):
channel = Channels.get_channel_by_id(id)
if not channel:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
if user.role != "admin" and not has_access(
user.id, type="read", access_control=channel.access_control
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
message_list = Messages.get_messages_by_channel_id(id, skip, limit)
users = {}
messages = []
for message in message_list:
if message.user_id not in users:
user = Users.get_user_by_id(message.user_id)
users[message.user_id] = user
replies = Messages.get_replies_by_message_id(message.id)
latest_reply_at = replies[0].created_at if replies else None
messages.append(
MessageUserResponse(
**{
**message.model_dump(),
"reply_count": len(replies),
"latest_reply_at": latest_reply_at,
"reactions": Messages.get_reactions_by_message_id(message.id),
"user": UserNameResponse(**users[message.user_id].model_dump()),
}
)
)
return messages
############################
# PostNewMessage
############################
async def send_notification(webui_url, channel, message, active_user_ids):
users = get_users_with_access("read", channel.access_control)
for user in users:
if user.id in active_user_ids:
continue
else:
if user.settings:
webhook_url = user.settings.ui.get("notifications", {}).get(
"webhook_url", None
)
if webhook_url:
post_webhook(
webhook_url,
f"#{channel.name} - {webui_url}/channels/{channel.id}\n\n{message.content}",
{
"action": "channel",
"message": message.content,
"title": channel.name,
"url": f"{webui_url}/channels/{channel.id}",
},
)
@router.post("/{id}/messages/post", response_model=Optional[MessageModel])
async def post_new_message(
request: Request,
id: str,
form_data: MessageForm,
background_tasks: BackgroundTasks,
user=Depends(get_verified_user),
):
channel = Channels.get_channel_by_id(id)
if not channel:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
if user.role != "admin" and not has_access(
user.id, type="read", access_control=channel.access_control
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
try:
message = Messages.insert_new_message(form_data, channel.id, user.id)
if message:
event_data = {
"channel_id": channel.id,
"message_id": message.id,
"data": {
"type": "message",
"data": MessageUserResponse(
**{
**message.model_dump(),
"reply_count": 0,
"latest_reply_at": None,
"reactions": Messages.get_reactions_by_message_id(
message.id
),
"user": UserNameResponse(**user.model_dump()),
}
).model_dump(),
},
"user": UserNameResponse(**user.model_dump()).model_dump(),
"channel": channel.model_dump(),
}
await sio.emit(
"channel-events",
event_data,
to=f"channel:{channel.id}",
)
if message.parent_id:
# If this message is a reply, emit to the parent message as well
parent_message = Messages.get_message_by_id(message.parent_id)
if parent_message:
await sio.emit(
"channel-events",
{
"channel_id": channel.id,
"message_id": parent_message.id,
"data": {
"type": "message:reply",
"data": MessageUserResponse(
**{
**parent_message.model_dump(),
"user": UserNameResponse(
**Users.get_user_by_id(
parent_message.user_id
).model_dump()
),
}
).model_dump(),
},
"user": UserNameResponse(**user.model_dump()).model_dump(),
"channel": channel.model_dump(),
},
to=f"channel:{channel.id}",
)
active_user_ids = get_user_ids_from_room(f"channel:{channel.id}")
background_tasks.add_task(
send_notification,
request.app.state.config.WEBUI_URL,
channel,
message,
active_user_ids,
)
return MessageModel(**message.model_dump())
except Exception as e:
log.exception(e)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
)
############################
# GetChannelMessage
############################
@router.get("/{id}/messages/{message_id}", response_model=Optional[MessageUserResponse])
async def get_channel_message(
id: str, message_id: str, user=Depends(get_verified_user)
):
channel = Channels.get_channel_by_id(id)
if not channel:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
if user.role != "admin" and not has_access(
user.id, type="read", access_control=channel.access_control
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
message = Messages.get_message_by_id(message_id)
if not message:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
if message.channel_id != id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
)
return MessageUserResponse(
**{
**message.model_dump(),
"user": UserNameResponse(
**Users.get_user_by_id(message.user_id).model_dump()
),
}
)
############################
# GetChannelThreadMessages
############################
@router.get(
"/{id}/messages/{message_id}/thread", response_model=list[MessageUserResponse]
)
async def get_channel_thread_messages(
id: str,
message_id: str,
skip: int = 0,
limit: int = 50,
user=Depends(get_verified_user),
):
channel = Channels.get_channel_by_id(id)
if not channel:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
if user.role != "admin" and not has_access(
user.id, type="read", access_control=channel.access_control
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
message_list = Messages.get_messages_by_parent_id(id, message_id, skip, limit)
users = {}
messages = []
for message in message_list:
if message.user_id not in users:
user = Users.get_user_by_id(message.user_id)
users[message.user_id] = user
messages.append(
MessageUserResponse(
**{
**message.model_dump(),
"reply_count": 0,
"latest_reply_at": None,
"reactions": Messages.get_reactions_by_message_id(message.id),
"user": UserNameResponse(**users[message.user_id].model_dump()),
}
)
)
return messages
############################
# UpdateMessageById
############################
@router.post(
"/{id}/messages/{message_id}/update", response_model=Optional[MessageModel]
)
async def update_message_by_id(
id: str, message_id: str, form_data: MessageForm, user=Depends(get_verified_user)
):
channel = Channels.get_channel_by_id(id)
if not channel:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
if user.role != "admin" and not has_access(
user.id, type="read", access_control=channel.access_control
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
message = Messages.get_message_by_id(message_id)
if not message:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
if message.channel_id != id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
)
try:
message = Messages.update_message_by_id(message_id, form_data)
message = Messages.get_message_by_id(message_id)
if message:
await sio.emit(
"channel-events",
{
"channel_id": channel.id,
"message_id": message.id,
"data": {
"type": "message:update",
"data": MessageUserResponse(
**{
**message.model_dump(),
"user": UserNameResponse(
**user.model_dump()
).model_dump(),
}
).model_dump(),
},
"user": UserNameResponse(**user.model_dump()).model_dump(),
"channel": channel.model_dump(),
},
to=f"channel:{channel.id}",
)
return MessageModel(**message.model_dump())
except Exception as e:
log.exception(e)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
)
############################
# AddReactionToMessage
############################
class ReactionForm(BaseModel):
name: str
@router.post("/{id}/messages/{message_id}/reactions/add", response_model=bool)
async def add_reaction_to_message(
id: str, message_id: str, form_data: ReactionForm, user=Depends(get_verified_user)
):
channel = Channels.get_channel_by_id(id)
if not channel:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
if user.role != "admin" and not has_access(
user.id, type="read", access_control=channel.access_control
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
message = Messages.get_message_by_id(message_id)
if not message:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
if message.channel_id != id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
)
try:
Messages.add_reaction_to_message(message_id, user.id, form_data.name)
message = Messages.get_message_by_id(message_id)
await sio.emit(
"channel-events",
{
"channel_id": channel.id,
"message_id": message.id,
"data": {
"type": "message:reaction:add",
"data": {
**message.model_dump(),
"user": UserNameResponse(
**Users.get_user_by_id(message.user_id).model_dump()
).model_dump(),
"name": form_data.name,
},
},
"user": UserNameResponse(**user.model_dump()).model_dump(),
"channel": channel.model_dump(),
},
to=f"channel:{channel.id}",
)
return True
except Exception as e:
log.exception(e)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
)
############################
# RemoveReactionById
############################
@router.post("/{id}/messages/{message_id}/reactions/remove", response_model=bool)
async def remove_reaction_by_id_and_user_id_and_name(
id: str, message_id: str, form_data: ReactionForm, user=Depends(get_verified_user)
):
channel = Channels.get_channel_by_id(id)
if not channel:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
if user.role != "admin" and not has_access(
user.id, type="read", access_control=channel.access_control
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
message = Messages.get_message_by_id(message_id)
if not message:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
if message.channel_id != id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
)
try:
Messages.remove_reaction_by_id_and_user_id_and_name(
message_id, user.id, form_data.name
)
message = Messages.get_message_by_id(message_id)
await sio.emit(
"channel-events",
{
"channel_id": channel.id,
"message_id": message.id,
"data": {
"type": "message:reaction:remove",
"data": {
**message.model_dump(),
"user": UserNameResponse(
**Users.get_user_by_id(message.user_id).model_dump()
).model_dump(),
"name": form_data.name,
},
},
"user": UserNameResponse(**user.model_dump()).model_dump(),
"channel": channel.model_dump(),
},
to=f"channel:{channel.id}",
)
return True
except Exception as e:
log.exception(e)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
)
############################
# DeleteMessageById
############################
@router.delete("/{id}/messages/{message_id}/delete", response_model=bool)
async def delete_message_by_id(
id: str, message_id: str, user=Depends(get_verified_user)
):
channel = Channels.get_channel_by_id(id)
if not channel:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
if user.role != "admin" and not has_access(
user.id, type="read", access_control=channel.access_control
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
message = Messages.get_message_by_id(message_id)
if not message:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
if message.channel_id != id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
)
try:
Messages.delete_message_by_id(message_id)
await sio.emit(
"channel-events",
{
"channel_id": channel.id,
"message_id": message.id,
"data": {
"type": "message:delete",
"data": {
**message.model_dump(),
"user": UserNameResponse(**user.model_dump()).model_dump(),
},
},
"user": UserNameResponse(**user.model_dump()).model_dump(),
"channel": channel.model_dump(),
},
to=f"channel:{channel.id}",
)
if message.parent_id:
# If this message is a reply, emit to the parent message as well
parent_message = Messages.get_message_by_id(message.parent_id)
if parent_message:
await sio.emit(
"channel-events",
{
"channel_id": channel.id,
"message_id": parent_message.id,
"data": {
"type": "message:reply",
"data": MessageUserResponse(
**{
**parent_message.model_dump(),
"user": UserNameResponse(
**Users.get_user_by_id(
parent_message.user_id
).model_dump()
),
}
).model_dump(),
},
"user": UserNameResponse(**user.model_dump()).model_dump(),
"channel": channel.model_dump(),
},
to=f"channel:{channel.id}",
)
return True
except Exception as e:
log.exception(e)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
)

View File

@ -463,6 +463,30 @@ async def clone_chat_by_id(id: str, user=Depends(get_verified_user)):
)
############################
# CloneSharedChatById
############################
@router.post("/{id}/clone/shared", response_model=Optional[ChatResponse])
async def clone_shared_chat_by_id(id: str, user=Depends(get_verified_user)):
chat = Chats.get_chat_by_share_id(id)
if chat:
updated_chat = {
**chat.chat,
"originalChatId": chat.id,
"branchPointMessageId": chat.chat["history"]["currentId"],
"title": f"Clone of {chat.title}",
}
chat = Chats.insert_new_chat(user.id, ChatForm(**{"chat": updated_chat}))
return ChatResponse(**chat.model_dump())
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT()
)
############################
# ArchiveChat
############################

View File

@ -226,9 +226,16 @@ async def get_file_content_by_id(id: str, user=Depends(get_verified_user)):
# Handle Unicode filenames
filename = file.meta.get("name", file.filename)
encoded_filename = quote(filename) # RFC5987 encoding
headers = {
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"
}
headers = {}
if file.meta.get("content_type") not in [
"application/pdf",
"text/plain",
]:
headers = {
**headers,
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}",
}
return FileResponse(file_path, headers=headers)
@ -341,7 +348,7 @@ async def delete_file_by_id(id: str, user=Depends(get_verified_user)):
result = Files.delete_file_by_id(id)
if result:
try:
Storage.delete_file(file.filename)
Storage.delete_file(file.path)
except Exception as e:
log.exception(e)
log.error(f"Error deleting files")

View File

@ -56,6 +56,7 @@ async def get_config(request: Request, user=Depends(get_admin_user)):
},
"comfyui": {
"COMFYUI_BASE_URL": request.app.state.config.COMFYUI_BASE_URL,
"COMFYUI_API_KEY": request.app.state.config.COMFYUI_API_KEY,
"COMFYUI_WORKFLOW": request.app.state.config.COMFYUI_WORKFLOW,
"COMFYUI_WORKFLOW_NODES": request.app.state.config.COMFYUI_WORKFLOW_NODES,
},
@ -77,6 +78,7 @@ class Automatic1111ConfigForm(BaseModel):
class ComfyUIConfigForm(BaseModel):
COMFYUI_BASE_URL: str
COMFYUI_API_KEY: str
COMFYUI_WORKFLOW: str
COMFYUI_WORKFLOW_NODES: list[dict]
@ -148,6 +150,7 @@ async def update_config(
},
"comfyui": {
"COMFYUI_BASE_URL": request.app.state.config.COMFYUI_BASE_URL,
"COMFYUI_API_KEY": request.app.state.config.COMFYUI_API_KEY,
"COMFYUI_WORKFLOW": request.app.state.config.COMFYUI_WORKFLOW,
"COMFYUI_WORKFLOW_NODES": request.app.state.config.COMFYUI_WORKFLOW_NODES,
},
@ -197,7 +200,7 @@ def set_image_model(request: Request, model: str):
log.info(f"Setting image model to {model}")
request.app.state.config.IMAGE_GENERATION_MODEL = model
if request.app.state.config.IMAGE_GENERATION_ENGINE in ["", "automatic1111"]:
api_auth = get_automatic1111_api_auth()
api_auth = get_automatic1111_api_auth(request)
r = requests.get(
url=f"{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options",
headers={"authorization": api_auth},
@ -233,7 +236,7 @@ def get_image_model(request):
try:
r = requests.get(
url=f"{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options",
headers={"authorization": get_automatic1111_api_auth()},
headers={"authorization": get_automatic1111_api_auth(request)},
)
options = r.json()
return options["sd_model_checkpoint"]
@ -298,8 +301,12 @@ def get_models(request: Request, user=Depends(get_verified_user)):
]
elif request.app.state.config.IMAGE_GENERATION_ENGINE == "comfyui":
# TODO - get models from comfyui
headers = {
"Authorization": f"Bearer {request.app.state.config.COMFYUI_API_KEY}"
}
r = requests.get(
url=f"{request.app.state.config.COMFYUI_BASE_URL}/object_info"
url=f"{request.app.state.config.COMFYUI_BASE_URL}/object_info",
headers=headers,
)
info = r.json()
@ -347,7 +354,7 @@ def get_models(request: Request, user=Depends(get_verified_user)):
):
r = requests.get(
url=f"{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/sd-models",
headers={"authorization": get_automatic1111_api_auth()},
headers={"authorization": get_automatic1111_api_auth(request)},
)
models = r.json()
return list(
@ -521,6 +528,7 @@ async def image_generations(
form_data,
user.id,
request.app.state.config.COMFYUI_BASE_URL,
request.app.state.config.COMFYUI_API_KEY,
)
log.debug(f"res: {res}")
@ -570,7 +578,7 @@ async def image_generations(
requests.post,
url=f"{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/txt2img",
json=data,
headers={"authorization": get_automatic1111_api_auth()},
headers={"authorization": get_automatic1111_api_auth(request)},
)
res = r.json()

View File

@ -1,5 +1,4 @@
import json
from typing import Optional, Union
from typing import List, Optional
from pydantic import BaseModel
from fastapi import APIRouter, Depends, HTTPException, status, Request
import logging
@ -12,11 +11,16 @@ from open_webui.models.knowledge import (
)
from open_webui.models.files import Files, FileModel
from open_webui.retrieval.vector.connector import VECTOR_DB_CLIENT
from open_webui.routers.retrieval import process_file, ProcessFileForm
from open_webui.routers.retrieval import (
process_file,
ProcessFileForm,
process_files_batch,
BatchProcessFilesForm,
)
from open_webui.constants import ERROR_MESSAGES
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.auth import get_verified_user
from open_webui.utils.access_control import has_access, has_permission
@ -415,13 +419,6 @@ def remove_file_from_knowledge_by_id(
collection_name=knowledge.id, filter={"file_id": form_data.file_id}
)
result = VECTOR_DB_CLIENT.query(
collection_name=knowledge.id,
filter={"file_id": form_data.file_id},
)
Files.delete_file_by_id(form_data.file_id)
if knowledge:
data = knowledge.data or {}
file_ids = data.get("file_ids", [])
@ -514,3 +511,86 @@ async def reset_knowledge_by_id(id: str, user=Depends(get_verified_user)):
knowledge = Knowledges.update_knowledge_data_by_id(id=id, data={"file_ids": []})
return knowledge
############################
# AddFilesToKnowledge
############################
@router.post("/{id}/files/batch/add", response_model=Optional[KnowledgeFilesResponse])
def add_files_to_knowledge_batch(
request: Request,
id: str,
form_data: list[KnowledgeFileIdForm],
user=Depends(get_verified_user),
):
"""
Add multiple files to a knowledge base
"""
knowledge = Knowledges.get_knowledge_by_id(id=id)
if not knowledge:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if knowledge.user_id != user.id and user.role != "admin":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
# Get files content
print(f"files/batch/add - {len(form_data)} files")
files: List[FileModel] = []
for form in form_data:
file = Files.get_file_by_id(form.file_id)
if not file:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File {form.file_id} not found",
)
files.append(file)
# Process files
try:
result = process_files_batch(
request=request,
form_data=BatchProcessFilesForm(files=files, collection_name=id),
user=user,
)
except Exception as e:
log.error(
f"add_files_to_knowledge_batch: Exception occurred: {e}", exc_info=True
)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# Add successful files to knowledge base
data = knowledge.data or {}
existing_file_ids = data.get("file_ids", [])
# Only add files that were successfully processed
successful_file_ids = [r.file_id for r in result.results if r.status == "completed"]
for file_id in successful_file_ids:
if file_id not in existing_file_ids:
existing_file_ids.append(file_id)
data["file_ids"] = existing_file_ids
knowledge = Knowledges.update_knowledge_data_by_id(id=id, data=data)
# If there were any errors, include them in the response
if result.errors:
error_details = [f"{err.file_id}: {err.error}" for err in result.errors]
return KnowledgeFilesResponse(
**knowledge.model_dump(),
files=Files.get_files_by_ids(existing_file_ids),
warnings={
"message": "Some files failed to process",
"errors": error_details,
},
)
return KnowledgeFilesResponse(
**knowledge.model_dump(), files=Files.get_files_by_ids(existing_file_ids)
)

View File

@ -82,6 +82,16 @@ async def send_get_request(url, key=None):
return None
async def cleanup_response(
response: Optional[aiohttp.ClientResponse],
session: Optional[aiohttp.ClientSession],
):
if response:
response.close()
if session:
await session.close()
async def send_post_request(
url: str,
payload: Union[str, bytes],
@ -89,14 +99,6 @@ async def send_post_request(
key: Optional[str] = None,
content_type: Optional[str] = None,
):
async def cleanup_response(
response: Optional[aiohttp.ClientResponse],
session: Optional[aiohttp.ClientSession],
):
if response:
response.close()
if session:
await session.close()
r = None
try:
@ -917,7 +919,7 @@ class ChatMessage(BaseModel):
class GenerateChatCompletionForm(BaseModel):
model: str
messages: list[ChatMessage]
format: Optional[str] = None
format: Optional[dict] = None
options: Optional[dict] = None
template: Optional[str] = None
stream: Optional[bool] = True

View File

@ -533,6 +533,9 @@ async def generate_chat_completion(
user=Depends(get_verified_user),
bypass_filter: Optional[bool] = False,
):
if BYPASS_MODEL_ACCESS_CONTROL:
bypass_filter = True
idx = 0
payload = {**form_data}
if "metadata" in payload:
@ -545,6 +548,7 @@ async def generate_chat_completion(
if model_info:
if model_info.base_model_id:
payload["model"] = model_info.base_model_id
model_id = model_info.base_model_id
params = model_info.params.model_dump()
payload = apply_model_params_to_body_openai(params, payload)
@ -604,14 +608,13 @@ async def generate_chat_completion(
if is_o1:
payload = openai_o1_handler(payload)
elif "api.openai.com" not in url:
# Remove "max_tokens" from the payload for backward compatibility
if "max_tokens" in payload:
payload["max_completion_tokens"] = payload["max_tokens"]
del payload["max_tokens"]
# Remove "max_completion_tokens" from the payload for backward compatibility
if "max_completion_tokens" in payload:
payload["max_tokens"] = payload["max_completion_tokens"]
del payload["max_completion_tokens"]
# TODO: check if below is needed
# if "max_tokens" in payload and "max_completion_tokens" in payload:
# del payload["max_tokens"]
if "max_tokens" in payload and "max_completion_tokens" in payload:
del payload["max_tokens"]
# Convert the modified body back to JSON
payload = json.dumps(payload)

View File

@ -124,18 +124,14 @@ def process_pipeline_outlet_filter(request, payload, user, models):
f"{url}/{filter['id']}/filter/outlet",
headers={"Authorization": f"Bearer {key}"},
json={
"user": {
"id": user.id,
"name": user.name,
"email": user.email,
"role": user.role,
},
"body": data,
"user": user,
"body": payload,
},
)
r.raise_for_status()
data = r.json()
payload = data
except Exception as e:
# Handle connection error here
print(f"Connection error: {e}")

View File

@ -7,7 +7,7 @@ import shutil
import uuid
from datetime import datetime
from pathlib import Path
from typing import Iterator, Optional, Sequence, Union
from typing import Iterator, List, Optional, Sequence, Union
from fastapi import (
Depends,
@ -28,7 +28,7 @@ import tiktoken
from langchain.text_splitter import RecursiveCharacterTextSplitter, TokenTextSplitter
from langchain_core.documents import Document
from open_webui.models.files import Files
from open_webui.models.files import FileModel, Files
from open_webui.models.knowledge import Knowledges
from open_webui.storage.provider import Storage
@ -347,6 +347,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
return {
"status": True,
"pdf_extract_images": request.app.state.config.PDF_EXTRACT_IMAGES,
"enable_google_drive_integration": request.app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION,
"content_extraction": {
"engine": request.app.state.config.CONTENT_EXTRACTION_ENGINE,
"tika_server_url": request.app.state.config.TIKA_SERVER_URL,
@ -369,6 +370,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
"web_loader_ssl_verification": request.app.state.config.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION,
"search": {
"enabled": request.app.state.config.ENABLE_RAG_WEB_SEARCH,
"drive": request.app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION,
"engine": request.app.state.config.RAG_WEB_SEARCH_ENGINE,
"searxng_query_url": request.app.state.config.SEARXNG_QUERY_URL,
"google_pse_api_key": request.app.state.config.GOOGLE_PSE_API_KEY,
@ -445,6 +447,7 @@ class WebConfig(BaseModel):
class ConfigUpdateForm(BaseModel):
pdf_extract_images: Optional[bool] = None
enable_google_drive_integration: Optional[bool] = None
file: Optional[FileConfig] = None
content_extraction: Optional[ContentExtractionConfig] = None
chunk: Optional[ChunkParamUpdateForm] = None
@ -462,6 +465,12 @@ async def update_rag_config(
else request.app.state.config.PDF_EXTRACT_IMAGES
)
request.app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION = (
form_data.enable_google_drive_integration
if form_data.enable_google_drive_integration is not None
else request.app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION
)
if form_data.file is not None:
request.app.state.config.FILE_MAX_SIZE = form_data.file.max_size
request.app.state.config.FILE_MAX_COUNT = form_data.file.max_count
@ -1247,21 +1256,22 @@ def process_web_search(
detail=ERROR_MESSAGES.WEB_SEARCH_ERROR(e),
)
log.debug(f"web_results: {web_results}")
try:
collection_name = form_data.collection_name
if collection_name == "":
if collection_name == "" or collection_name is None:
collection_name = f"web-search-{calculate_sha256_string(form_data.query)}"[
:63
]
urls = [result.link for result in web_results]
loader = get_web_loader(
urls=urls,
urls,
verify_ssl=request.app.state.config.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION,
requests_per_second=request.app.state.config.RAG_WEB_SEARCH_CONCURRENT_REQUESTS,
)
docs = loader.aload()
docs = loader.load()
save_docs_to_vector_db(request, docs, collection_name, overwrite=True)
return {
@ -1428,3 +1438,94 @@ if ENV == "dev":
@router.get("/ef/{text}")
async def get_embeddings(request: Request, text: Optional[str] = "Hello World!"):
return {"result": request.app.state.EMBEDDING_FUNCTION(text)}
class BatchProcessFilesForm(BaseModel):
files: List[FileModel]
collection_name: str
class BatchProcessFilesResult(BaseModel):
file_id: str
status: str
error: Optional[str] = None
class BatchProcessFilesResponse(BaseModel):
results: List[BatchProcessFilesResult]
errors: List[BatchProcessFilesResult]
@router.post("/process/files/batch")
def process_files_batch(
request: Request,
form_data: BatchProcessFilesForm,
user=Depends(get_verified_user),
) -> BatchProcessFilesResponse:
"""
Process a batch of files and save them to the vector database.
"""
results: List[BatchProcessFilesResult] = []
errors: List[BatchProcessFilesResult] = []
collection_name = form_data.collection_name
# Prepare all documents first
all_docs: List[Document] = []
for file in form_data.files:
try:
text_content = file.data.get("content", "")
docs: List[Document] = [
Document(
page_content=text_content.replace("<br/>", "\n"),
metadata={
**file.meta,
"name": file.filename,
"created_by": file.user_id,
"file_id": file.id,
"source": file.filename,
},
)
]
hash = calculate_sha256_string(text_content)
Files.update_file_hash_by_id(file.id, hash)
Files.update_file_data_by_id(file.id, {"content": text_content})
all_docs.extend(docs)
results.append(BatchProcessFilesResult(file_id=file.id, status="prepared"))
except Exception as e:
log.error(f"process_files_batch: Error processing file {file.id}: {str(e)}")
errors.append(
BatchProcessFilesResult(file_id=file.id, status="failed", error=str(e))
)
# Save all documents in one batch
if all_docs:
try:
save_docs_to_vector_db(
request=request,
docs=all_docs,
collection_name=collection_name,
add=True,
)
# Update all files with collection name
for result in results:
Files.update_file_metadata_by_id(
result.file_id, {"collection_name": collection_name}
)
result.status = "completed"
except Exception as e:
log.error(
f"process_files_batch: Error saving documents to vector DB: {str(e)}"
)
for result in results:
result.status = "failed"
errors.append(
BatchProcessFilesResult(file_id=result.file_id, error=str(e))
)
return BatchProcessFilesResponse(results=results, errors=errors)

View File

@ -186,9 +186,10 @@ async def generate_title(
try:
return await generate_chat_completion(request, form_data=payload, user=user)
except Exception as e:
log.error("Exception occurred", exc_info=True)
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={"detail": str(e)},
content={"detail": "An internal error has occurred."},
)
@ -248,9 +249,10 @@ async def generate_chat_tags(
try:
return await generate_chat_completion(request, form_data=payload, user=user)
except Exception as e:
log.error(f"Error generating chat completion: {e}")
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={"detail": str(e)},
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={"detail": "An internal error has occurred."},
)
@ -393,9 +395,10 @@ async def generate_autocompletion(
try:
return await generate_chat_completion(request, form_data=payload, user=user)
except Exception as e:
log.error(f"Error generating chat completion: {e}")
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={"detail": str(e)},
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={"detail": "An internal error has occurred."},
)
@ -496,8 +499,8 @@ async def generate_moa_response(
"model": task_model_id,
"messages": [{"role": "user", "content": content}],
"stream": form_data.get("stream", False),
"chat_id": form_data.get("chat_id", None),
"metadata": {
"chat_id": form_data.get("chat_id", None),
"task": str(TASKS.MOA_RESPONSE_GENERATION),
"task_body": form_data,
},

View File

@ -10,6 +10,9 @@ from open_webui.models.users import (
UserSettings,
UserUpdateForm,
)
from open_webui.socket.main import get_active_status_by_user_id
from open_webui.constants import ERROR_MESSAGES
from open_webui.env import SRC_LOG_LEVELS
from fastapi import APIRouter, Depends, HTTPException, Request, status
@ -27,7 +30,11 @@ router = APIRouter()
@router.get("/", response_model=list[UserModel])
async def get_users(skip: int = 0, limit: int = 50, user=Depends(get_admin_user)):
async def get_users(
skip: Optional[int] = None,
limit: Optional[int] = None,
user=Depends(get_admin_user),
):
return Users.get_users(skip, limit)
@ -192,6 +199,7 @@ async def update_user_info_by_session_user(
class UserResponse(BaseModel):
name: str
profile_image_url: str
active: Optional[bool] = None
@router.get("/{user_id}", response_model=UserResponse)
@ -212,7 +220,13 @@ async def get_user_by_id(user_id: str, user=Depends(get_verified_user)):
user = Users.get_user_by_id(user_id)
if user:
return UserResponse(name=user.name, profile_image_url=user.profile_image_url)
return UserResponse(
**{
"name": user.name,
"profile_image_url": user.profile_image_url,
"active": get_active_status_by_user_id(user_id),
}
)
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,

View File

@ -4,14 +4,17 @@ import logging
import sys
import time
from open_webui.models.users import Users
from open_webui.models.users import Users, UserNameResponse
from open_webui.models.channels import Channels
from open_webui.models.chats import Chats
from open_webui.env import (
ENABLE_WEBSOCKET_SUPPORT,
WEBSOCKET_MANAGER,
WEBSOCKET_REDIS_URL,
)
from open_webui.utils.auth import decode_token
from open_webui.socket.utils import RedisDict
from open_webui.socket.utils import RedisDict, RedisLock
from open_webui.env import (
GLOBAL_LOG_LEVEL,
@ -29,9 +32,7 @@ if WEBSOCKET_MANAGER == "redis":
sio = socketio.AsyncServer(
cors_allowed_origins=[],
async_mode="asgi",
transports=(
["polling", "websocket"] if ENABLE_WEBSOCKET_SUPPORT else ["polling"]
),
transports=(["websocket"] if ENABLE_WEBSOCKET_SUPPORT else ["polling"]),
allow_upgrades=ENABLE_WEBSOCKET_SUPPORT,
always_connect=True,
client_manager=mgr,
@ -40,54 +41,77 @@ else:
sio = socketio.AsyncServer(
cors_allowed_origins=[],
async_mode="asgi",
transports=(
["polling", "websocket"] if ENABLE_WEBSOCKET_SUPPORT else ["polling"]
),
transports=(["websocket"] if ENABLE_WEBSOCKET_SUPPORT else ["polling"]),
allow_upgrades=ENABLE_WEBSOCKET_SUPPORT,
always_connect=True,
)
# Timeout duration in seconds
TIMEOUT_DURATION = 3
# Dictionary to maintain the user pool
if WEBSOCKET_MANAGER == "redis":
log.debug("Using Redis to manage websockets.")
SESSION_POOL = RedisDict("open-webui:session_pool", redis_url=WEBSOCKET_REDIS_URL)
USER_POOL = RedisDict("open-webui:user_pool", redis_url=WEBSOCKET_REDIS_URL)
USAGE_POOL = RedisDict("open-webui:usage_pool", redis_url=WEBSOCKET_REDIS_URL)
clean_up_lock = RedisLock(
redis_url=WEBSOCKET_REDIS_URL,
lock_name="usage_cleanup_lock",
timeout_secs=TIMEOUT_DURATION * 2,
)
aquire_func = clean_up_lock.aquire_lock
renew_func = clean_up_lock.renew_lock
release_func = clean_up_lock.release_lock
else:
SESSION_POOL = {}
USER_POOL = {}
USAGE_POOL = {}
# Timeout duration in seconds
TIMEOUT_DURATION = 3
aquire_func = release_func = renew_func = lambda: True
async def periodic_usage_pool_cleanup():
while True:
now = int(time.time())
for model_id, connections in list(USAGE_POOL.items()):
# Creating a list of sids to remove if they have timed out
expired_sids = [
sid
for sid, details in connections.items()
if now - details["updated_at"] > TIMEOUT_DURATION
]
if not aquire_func():
log.debug("Usage pool cleanup lock already exists. Not running it.")
return
log.debug("Running periodic_usage_pool_cleanup")
try:
while True:
if not renew_func():
log.error(f"Unable to renew cleanup lock. Exiting usage pool cleanup.")
raise Exception("Unable to renew usage pool cleanup lock.")
for sid in expired_sids:
del connections[sid]
now = int(time.time())
send_usage = False
for model_id, connections in list(USAGE_POOL.items()):
# Creating a list of sids to remove if they have timed out
expired_sids = [
sid
for sid, details in connections.items()
if now - details["updated_at"] > TIMEOUT_DURATION
]
if not connections:
log.debug(f"Cleaning up model {model_id} from usage pool")
del USAGE_POOL[model_id]
else:
USAGE_POOL[model_id] = connections
for sid in expired_sids:
del connections[sid]
# Emit updated usage information after cleaning
await sio.emit("usage", {"models": get_models_in_use()})
if not connections:
log.debug(f"Cleaning up model {model_id} from usage pool")
del USAGE_POOL[model_id]
else:
USAGE_POOL[model_id] = connections
await asyncio.sleep(TIMEOUT_DURATION)
send_usage = True
if send_usage:
# Emit updated usage information after cleaning
await sio.emit("usage", {"models": get_models_in_use()})
await asyncio.sleep(TIMEOUT_DURATION)
finally:
release_func()
app = socketio.ASGIApp(
@ -128,20 +152,19 @@ async def connect(sid, environ, auth):
user = Users.get_user_by_id(data["id"])
if user:
SESSION_POOL[sid] = user.id
SESSION_POOL[sid] = user.model_dump()
if user.id in USER_POOL:
USER_POOL[user.id].append(sid)
USER_POOL[user.id] = USER_POOL[user.id] + [sid]
else:
USER_POOL[user.id] = [sid]
# print(f"user {user.name}({user.id}) connected with session ID {sid}")
await sio.emit("user-count", {"count": len(USER_POOL.items())})
await sio.emit("user-list", {"user_ids": list(USER_POOL.keys())})
await sio.emit("usage", {"models": get_models_in_use()})
@sio.on("user-join")
async def user_join(sid, data):
# print("user-join", sid, data)
auth = data["auth"] if "auth" in data else None
if not auth or "token" not in auth:
@ -155,39 +178,91 @@ async def user_join(sid, data):
if not user:
return
SESSION_POOL[sid] = user.id
SESSION_POOL[sid] = user.model_dump()
if user.id in USER_POOL:
USER_POOL[user.id].append(sid)
USER_POOL[user.id] = USER_POOL[user.id] + [sid]
else:
USER_POOL[user.id] = [sid]
# Join all the channels
channels = Channels.get_channels_by_user_id(user.id)
log.debug(f"{channels=}")
for channel in channels:
await sio.enter_room(sid, f"channel:{channel.id}")
# print(f"user {user.name}({user.id}) connected with session ID {sid}")
await sio.emit("user-count", {"count": len(USER_POOL.items())})
await sio.emit("user-list", {"user_ids": list(USER_POOL.keys())})
return {"id": user.id, "name": user.name}
@sio.on("user-count")
async def user_count(sid):
await sio.emit("user-count", {"count": len(USER_POOL.items())})
@sio.on("join-channels")
async def join_channel(sid, data):
auth = data["auth"] if "auth" in data else None
if not auth or "token" not in auth:
return
data = decode_token(auth["token"])
if data is None or "id" not in data:
return
user = Users.get_user_by_id(data["id"])
if not user:
return
# Join all the channels
channels = Channels.get_channels_by_user_id(user.id)
log.debug(f"{channels=}")
for channel in channels:
await sio.enter_room(sid, f"channel:{channel.id}")
@sio.on("chat")
async def chat(sid, data):
print("chat", sid, SESSION_POOL[sid], data)
@sio.on("channel-events")
async def channel_events(sid, data):
room = f"channel:{data['channel_id']}"
participants = sio.manager.get_participants(
namespace="/",
room=room,
)
sids = [sid for sid, _ in participants]
if sid not in sids:
return
event_data = data["data"]
event_type = event_data["type"]
if event_type == "typing":
await sio.emit(
"channel-events",
{
"channel_id": data["channel_id"],
"message_id": data.get("message_id", None),
"data": event_data,
"user": UserNameResponse(**SESSION_POOL[sid]).model_dump(),
},
room=room,
)
@sio.on("user-list")
async def user_list(sid):
await sio.emit("user-list", {"user_ids": list(USER_POOL.keys())})
@sio.event
async def disconnect(sid):
if sid in SESSION_POOL:
user_id = SESSION_POOL[sid]
user = SESSION_POOL[sid]
del SESSION_POOL[sid]
user_id = user["id"]
USER_POOL[user_id] = [_sid for _sid in USER_POOL[user_id] if _sid != sid]
if len(USER_POOL[user_id]) == 0:
del USER_POOL[user_id]
await sio.emit("user-count", {"count": len(USER_POOL)})
await sio.emit("user-list", {"user_ids": list(USER_POOL.keys())})
else:
pass
# print(f"Unknown session ID {sid} disconnected")
@ -195,16 +270,57 @@ async def disconnect(sid):
def get_event_emitter(request_info):
async def __event_emitter__(event_data):
await sio.emit(
"chat-events",
{
"chat_id": request_info["chat_id"],
"message_id": request_info["message_id"],
"data": event_data,
},
to=request_info["session_id"],
user_id = request_info["user_id"]
session_ids = list(
set(USER_POOL.get(user_id, []) + [request_info["session_id"]])
)
for session_id in session_ids:
await sio.emit(
"chat-events",
{
"chat_id": request_info["chat_id"],
"message_id": request_info["message_id"],
"data": event_data,
},
to=session_id,
)
if "type" in event_data and event_data["type"] == "status":
Chats.add_message_status_to_chat_by_id_and_message_id(
request_info["chat_id"],
request_info["message_id"],
event_data.get("data", {}),
)
if "type" in event_data and event_data["type"] == "message":
message = Chats.get_message_by_id_and_message_id(
request_info["chat_id"],
request_info["message_id"],
)
content = message.get("content", "")
content += event_data.get("data", {}).get("content", "")
Chats.upsert_message_to_chat_by_id_and_message_id(
request_info["chat_id"],
request_info["message_id"],
{
"content": content,
},
)
if "type" in event_data and event_data["type"] == "replace":
content = event_data.get("data", {}).get("content", "")
Chats.upsert_message_to_chat_by_id_and_message_id(
request_info["chat_id"],
request_info["message_id"],
{
"content": content,
},
)
return __event_emitter__
@ -222,3 +338,30 @@ def get_event_call(request_info):
return response
return __event_call__
def get_user_id_from_session_pool(sid):
user = SESSION_POOL.get(sid)
if user:
return user["id"]
return None
def get_user_ids_from_room(room):
active_session_ids = sio.manager.get_participants(
namespace="/",
room=room,
)
active_user_ids = list(
set(
[SESSION_POOL.get(session_id[0])["id"] for session_id in active_session_ids]
)
)
return active_user_ids
def get_active_status_by_user_id(user_id):
if user_id in USER_POOL:
return True
return False

View File

@ -1,5 +1,33 @@
import json
import redis
import uuid
class RedisLock:
def __init__(self, redis_url, lock_name, timeout_secs):
self.lock_name = lock_name
self.lock_id = str(uuid.uuid4())
self.timeout_secs = timeout_secs
self.lock_obtained = False
self.redis = redis.Redis.from_url(redis_url, decode_responses=True)
def aquire_lock(self):
# nx=True will only set this key if it _hasn't_ already been set
self.lock_obtained = self.redis.set(
self.lock_name, self.lock_id, nx=True, ex=self.timeout_secs
)
return self.lock_obtained
def renew_lock(self):
# xx=True will only set this key if it _has_ already been set
return self.redis.set(
self.lock_name, self.lock_id, xx=True, ex=self.timeout_secs
)
def release_lock(self):
lock_value = self.redis.get(self.lock_name)
if lock_value and lock_value.decode("utf-8") == self.lock_id:
self.redis.delete(self.lock_name)
class RedisDict:

View File

@ -26,8 +26,8 @@
html {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'NotoSans', 'NotoSansJP', 'NotoSansKR',
'NotoSansSC', 'Twemoji', 'STSong-Light', 'MSung-Light', 'HeiseiMin-W3', 'HYSMyeongJo-Medium', Roboto,
'Helvetica Neue', Arial, sans-serif;
'NotoSansSC', 'Twemoji', 'STSong-Light', 'MSung-Light', 'HeiseiMin-W3', 'HYSMyeongJo-Medium',
Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: 14px; /* Default font size */
line-height: 1.5;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@ -147,8 +147,10 @@ class StorageProvider:
return self._get_file_from_s3(file_path)
return self._get_file_from_local(file_path)
def delete_file(self, filename: str) -> None:
def delete_file(self, file_path: str) -> None:
"""Deletes a file either from S3 or the local file system."""
filename = file_path.split("/")[-1]
if self.storage_provider == "s3":
self._delete_from_s3(filename)

View File

@ -0,0 +1,61 @@
# tasks.py
import asyncio
from typing import Dict
from uuid import uuid4
# A dictionary to keep track of active tasks
tasks: Dict[str, asyncio.Task] = {}
def cleanup_task(task_id: str):
"""
Remove a completed or canceled task from the global `tasks` dictionary.
"""
tasks.pop(task_id, None) # Remove the task if it exists
def create_task(coroutine):
"""
Create a new asyncio task and add it to the global task dictionary.
"""
task_id = str(uuid4()) # Generate a unique ID for the task
task = asyncio.create_task(coroutine) # Create the task
# Add a done callback for cleanup
task.add_done_callback(lambda t: cleanup_task(task_id))
tasks[task_id] = task
return task_id, task
def get_task(task_id: str):
"""
Retrieve a task by its task ID.
"""
return tasks.get(task_id)
def list_tasks():
"""
List all currently active task IDs.
"""
return list(tasks.keys())
async def stop_task(task_id: str):
"""
Cancel a running task and remove it from the global task list.
"""
task = tasks.get(task_id)
if not task:
raise ValueError(f"Task with ID {task_id} not found.")
task.cancel() # Request task cancellation
try:
await task # Wait for the task to handle the cancellation
except asyncio.CancelledError:
# Task successfully canceled
tasks.pop(task_id, None) # Remove it from the dictionary
return {"status": True, "message": f"Task {task_id} successfully stopped."}
return {"status": False, "message": f"Failed to stop task {task_id}."}

View File

@ -1,4 +1,5 @@
from typing import Optional, Union, List, Dict, Any
from open_webui.models.users import Users, UserModel
from open_webui.models.groups import Groups
import json
@ -93,3 +94,24 @@ def has_access(
return user_id in permitted_user_ids or any(
group_id in permitted_group_ids for group_id in user_group_ids
)
# Get all users with access to a resource
def get_users_with_access(
type: str = "write", access_control: Optional[dict] = None
) -> List[UserModel]:
if access_control is None:
return Users.get_users()
permission_access = access_control.get(type, {})
permitted_group_ids = permission_access.get("group_ids", [])
permitted_user_ids = permission_access.get("user_ids", [])
user_ids_with_access = set(permitted_user_ids)
for group_id in permitted_group_ids:
group_user_ids = Groups.get_group_user_ids_by_id(group_id)
if group_user_ids:
user_ids_with_access.update(group_user_ids)
return Users.get_users_by_user_ids(list(user_ids_with_access))

View File

@ -95,6 +95,20 @@ def get_current_user(
raise HTTPException(
status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.API_KEY_NOT_ALLOWED
)
if request.app.state.config.ENABLE_API_KEY_ENDPOINT_RESTRICTIONS:
allowed_paths = [
path.strip()
for path in str(
request.app.state.config.API_KEY_ALLOWED_ENDPOINTS
).split(",")
]
if request.url.path not in allowed_paths:
raise HTTPException(
status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.API_KEY_NOT_ALLOWED
)
return get_current_user_by_api_key(token)
# auth by jwt token

View File

@ -89,7 +89,7 @@ async def generate_chat_completion(
if model_ids and filter_mode == "exclude":
model_ids = [
model["id"]
for model in await get_all_models(request)
for model in list(request.app.state.MODELS.values())
if model.get("owned_by") != "arena" and model["id"] not in model_ids
]
@ -99,7 +99,7 @@ async def generate_chat_completion(
else:
model_ids = [
model["id"]
for model in await get_all_models(request)
for model in list(request.app.state.MODELS.values())
if model.get("owned_by") != "arena"
]
selected_model_id = random.choice(model_ids)
@ -114,21 +114,27 @@ async def generate_chat_completion(
yield chunk
response = await generate_chat_completion(
form_data, user, bypass_filter=True
request, form_data, user, bypass_filter=True
)
return StreamingResponse(
stream_wrapper(response.body_iterator), media_type="text/event-stream"
stream_wrapper(response.body_iterator),
media_type="text/event-stream",
background=response.background,
)
else:
return {
**(await generate_chat_completion(form_data, user, bypass_filter=True)),
**(
await generate_chat_completion(
request, form_data, user, bypass_filter=True
)
),
"selected_model_id": selected_model_id,
}
if model.get("pipe"):
# Below does not require bypass_filter because this is the only route the uses this function and it is already bypassing the filter
return await generate_function_chat_completion(
form_data, user=user, models=models
request, form_data, user=user, models=models
)
if model["owned_by"] == "ollama":
# Using /ollama/api/chat endpoint
@ -141,6 +147,7 @@ async def generate_chat_completion(
return StreamingResponse(
convert_streaming_response_ollama_to_openai(response),
headers=dict(response.headers),
background=response.background,
)
else:
return convert_response_ollama_to_openai(response)
@ -150,8 +157,12 @@ async def generate_chat_completion(
)
chat_completion = generate_chat_completion
async def chat_completed(request: Request, form_data: dict, user: Any):
await get_all_models(request)
if not request.app.state.MODELS:
await get_all_models(request)
models = request.app.state.MODELS
data = form_data
@ -171,6 +182,7 @@ async def chat_completed(request: Request, form_data: dict, user: Any):
"chat_id": data["chat_id"],
"message_id": data["id"],
"session_id": data["session_id"],
"user_id": user.id,
}
)
@ -179,6 +191,7 @@ async def chat_completed(request: Request, form_data: dict, user: Any):
"chat_id": data["chat_id"],
"message_id": data["id"],
"session_id": data["session_id"],
"user_id": user.id,
}
)
@ -286,7 +299,8 @@ async def chat_action(request: Request, action_id: str, form_data: dict, user: A
if not action:
raise Exception(f"Action not found: {action_id}")
await get_all_models(request)
if not request.app.state.MODELS:
await get_all_models(request)
models = request.app.state.MODELS
data = form_data
@ -301,6 +315,7 @@ async def chat_action(request: Request, action_id: str, form_data: dict, user: A
"chat_id": data["chat_id"],
"message_id": data["id"],
"session_id": data["session_id"],
"user_id": user.id,
}
)
__event_call__ = get_event_call(
@ -308,6 +323,7 @@ async def chat_action(request: Request, action_id: str, form_data: dict, user: A
"chat_id": data["chat_id"],
"message_id": data["id"],
"session_id": data["session_id"],
"user_id": user.id,
}
)

View File

@ -16,14 +16,16 @@ log.setLevel(SRC_LOG_LEVELS["COMFYUI"])
default_headers = {"User-Agent": "Mozilla/5.0"}
def queue_prompt(prompt, client_id, base_url):
def queue_prompt(prompt, client_id, base_url, api_key):
log.info("queue_prompt")
p = {"prompt": prompt, "client_id": client_id}
data = json.dumps(p).encode("utf-8")
log.debug(f"queue_prompt data: {data}")
try:
req = urllib.request.Request(
f"{base_url}/prompt", data=data, headers=default_headers
f"{base_url}/prompt",
data=data,
headers={**default_headers, "Authorization": f"Bearer {api_key}"},
)
response = urllib.request.urlopen(req).read()
return json.loads(response)
@ -32,12 +34,13 @@ def queue_prompt(prompt, client_id, base_url):
raise e
def get_image(filename, subfolder, folder_type, base_url):
def get_image(filename, subfolder, folder_type, base_url, api_key):
log.info("get_image")
data = {"filename": filename, "subfolder": subfolder, "type": folder_type}
url_values = urllib.parse.urlencode(data)
req = urllib.request.Request(
f"{base_url}/view?{url_values}", headers=default_headers
f"{base_url}/view?{url_values}",
headers={**default_headers, "Authorization": f"Bearer {api_key}"},
)
with urllib.request.urlopen(req) as response:
return response.read()
@ -50,18 +53,19 @@ def get_image_url(filename, subfolder, folder_type, base_url):
return f"{base_url}/view?{url_values}"
def get_history(prompt_id, base_url):
def get_history(prompt_id, base_url, api_key):
log.info("get_history")
req = urllib.request.Request(
f"{base_url}/history/{prompt_id}", headers=default_headers
f"{base_url}/history/{prompt_id}",
headers={**default_headers, "Authorization": f"Bearer {api_key}"},
)
with urllib.request.urlopen(req) as response:
return json.loads(response.read())
def get_images(ws, prompt, client_id, base_url):
prompt_id = queue_prompt(prompt, client_id, base_url)["prompt_id"]
def get_images(ws, prompt, client_id, base_url, api_key):
prompt_id = queue_prompt(prompt, client_id, base_url, api_key)["prompt_id"]
output_images = []
while True:
out = ws.recv()
@ -74,7 +78,7 @@ def get_images(ws, prompt, client_id, base_url):
else:
continue # previews are binary data
history = get_history(prompt_id, base_url)[prompt_id]
history = get_history(prompt_id, base_url, api_key)[prompt_id]
for o in history["outputs"]:
for node_id in history["outputs"]:
node_output = history["outputs"][node_id]
@ -113,7 +117,7 @@ class ComfyUIGenerateImageForm(BaseModel):
async def comfyui_generate_image(
model: str, payload: ComfyUIGenerateImageForm, client_id, base_url
model: str, payload: ComfyUIGenerateImageForm, client_id, base_url, api_key
):
ws_url = base_url.replace("http://", "ws://").replace("https://", "wss://")
workflow = json.loads(payload.workflow.workflow)
@ -167,7 +171,8 @@ async def comfyui_generate_image(
try:
ws = websocket.WebSocket()
ws.connect(f"{ws_url}/ws?clientId={client_id}")
headers = {"Authorization": f"Bearer {api_key}"}
ws.connect(f"{ws_url}/ws?clientId={client_id}", header=headers)
log.info("WebSocket connection established.")
except Exception as e:
log.exception(f"Failed to connect to WebSocket server: {e}")
@ -176,7 +181,9 @@ async def comfyui_generate_image(
try:
log.info("Sending workflow to WebSocket server.")
log.info(f"Workflow: {workflow}")
images = await asyncio.to_thread(get_images, ws, workflow, client_id, base_url)
images = await asyncio.to_thread(
get_images, ws, workflow, client_id, base_url, api_key
)
except Exception as e:
log.exception(f"Error while receiving images: {e}")
images = None

View File

@ -2,21 +2,36 @@ import time
import logging
import sys
import asyncio
from aiocache import cached
from typing import Any, Optional
import random
import json
import inspect
from uuid import uuid4
from concurrent.futures import ThreadPoolExecutor
from fastapi import Request
from fastapi import BackgroundTasks
from starlette.responses import Response, StreamingResponse
from open_webui.models.chats import Chats
from open_webui.models.users import Users
from open_webui.socket.main import (
get_event_call,
get_event_emitter,
get_active_status_by_user_id,
)
from open_webui.routers.tasks import generate_queries
from open_webui.routers.tasks import (
generate_queries,
generate_title,
generate_chat_tags,
)
from open_webui.routers.retrieval import process_web_search, SearchForm
from open_webui.utils.webhook import post_webhook
from open_webui.models.users import UserModel
@ -33,16 +48,25 @@ from open_webui.utils.task import (
tools_function_calling_generation_template,
)
from open_webui.utils.misc import (
get_message_list,
add_or_update_system_message,
get_last_user_message,
get_last_assistant_message,
prepend_to_first_user_message_content,
)
from open_webui.utils.tools import get_tools
from open_webui.utils.plugin import load_function_module_by_id
from open_webui.tasks import create_task
from open_webui.config import DEFAULT_TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE
from open_webui.env import SRC_LOG_LEVELS, GLOBAL_LOG_LEVEL, BYPASS_MODEL_ACCESS_CONTROL
from open_webui.env import (
SRC_LOG_LEVELS,
GLOBAL_LOG_LEVEL,
BYPASS_MODEL_ACCESS_CONTROL,
ENABLE_REALTIME_CHAT_SAVE,
)
from open_webui.constants import TASKS
@ -312,6 +336,156 @@ async def chat_completion_tools_handler(
return body, {"sources": sources}
async def chat_web_search_handler(
request: Request, form_data: dict, extra_params: dict, user
):
event_emitter = extra_params["__event_emitter__"]
await event_emitter(
{
"type": "status",
"data": {
"action": "web_search",
"description": "Generating search query",
"done": False,
},
}
)
messages = form_data["messages"]
user_message = get_last_user_message(messages)
queries = []
try:
res = await generate_queries(
request,
{
"model": form_data["model"],
"messages": messages,
"prompt": user_message,
"type": "web_search",
},
user,
)
response = res["choices"][0]["message"]["content"]
try:
bracket_start = response.find("{")
bracket_end = response.rfind("}") + 1
if bracket_start == -1 or bracket_end == -1:
raise Exception("No JSON object found in the response")
response = response[bracket_start:bracket_end]
queries = json.loads(response)
queries = queries.get("queries", [])
except Exception as e:
queries = [response]
except Exception as e:
log.exception(e)
queries = [user_message]
if len(queries) == 0:
await event_emitter(
{
"type": "status",
"data": {
"action": "web_search",
"description": "No search query generated",
"done": True,
},
}
)
return
searchQuery = queries[0]
await event_emitter(
{
"type": "status",
"data": {
"action": "web_search",
"description": 'Searching "{{searchQuery}}"',
"query": searchQuery,
"done": False,
},
}
)
try:
# Offload process_web_search to a separate thread
loop = asyncio.get_running_loop()
with ThreadPoolExecutor() as executor:
results = await loop.run_in_executor(
executor,
lambda: process_web_search(
request,
SearchForm(
**{
"query": searchQuery,
}
),
user,
),
)
if results:
await event_emitter(
{
"type": "status",
"data": {
"action": "web_search",
"description": "Searched {{count}} sites",
"query": searchQuery,
"urls": results["filenames"],
"done": True,
},
}
)
files = form_data.get("files", [])
files.append(
{
"collection_name": results["collection_name"],
"name": searchQuery,
"type": "web_search_results",
"urls": results["filenames"],
}
)
form_data["files"] = files
else:
await event_emitter(
{
"type": "status",
"data": {
"action": "web_search",
"description": "No search results found",
"query": searchQuery,
"done": True,
"error": True,
},
}
)
except Exception as e:
log.exception(e)
await event_emitter(
{
"type": "status",
"data": {
"action": "web_search",
"description": 'Error searching "{{searchQuery}}"',
"query": searchQuery,
"done": True,
"error": True,
},
}
)
return form_data
async def chat_completion_files_handler(
request: Request, body: dict, user: UserModel
) -> tuple[dict, dict[str, list]]:
@ -320,6 +494,7 @@ async def chat_completion_files_handler(
if files := body.get("metadata", {}).get("files", None):
try:
queries_response = await generate_queries(
request,
{
"model": body["model"],
"messages": body["messages"],
@ -362,19 +537,44 @@ async def chat_completion_files_handler(
return body, {"sources": sources}
async def process_chat_payload(request, form_data, user, model):
metadata = {
"chat_id": form_data.pop("chat_id", None),
"message_id": form_data.pop("id", None),
"session_id": form_data.pop("session_id", None),
"tool_ids": form_data.get("tool_ids", None),
"files": form_data.get("files", None),
}
form_data["metadata"] = metadata
def apply_params_to_form_data(form_data, model):
params = form_data.pop("params", {})
if model.get("ollama"):
form_data["options"] = params
if "format" in params:
form_data["format"] = params["format"]
if "keep_alive" in params:
form_data["keep_alive"] = params["keep_alive"]
else:
if "seed" in params:
form_data["seed"] = params["seed"]
if "stop" in params:
form_data["stop"] = params["stop"]
if "temperature" in params:
form_data["temperature"] = params["temperature"]
if "top_p" in params:
form_data["top_p"] = params["top_p"]
if "frequency_penalty" in params:
form_data["frequency_penalty"] = params["frequency_penalty"]
return form_data
async def process_chat_payload(request, form_data, metadata, user, model):
form_data = apply_params_to_form_data(form_data, model)
log.debug(f"form_data: {form_data}")
event_emitter = get_event_emitter(metadata)
event_call = get_event_call(metadata)
extra_params = {
"__event_emitter__": get_event_emitter(metadata),
"__event_call__": get_event_call(metadata),
"__event_emitter__": event_emitter,
"__event_call__": event_call,
"__user__": {
"id": user.id,
"email": user.email,
@ -388,18 +588,70 @@ async def process_chat_payload(request, form_data, user, model):
# Initialize events to store additional event to be sent to the client
# Initialize contexts and citation
models = request.app.state.MODELS
events = []
sources = []
user_message = get_last_user_message(form_data["messages"])
model_knowledge = model.get("info", {}).get("meta", {}).get("knowledge", False)
if model_knowledge:
await event_emitter(
{
"type": "status",
"data": {
"action": "knowledge_search",
"query": user_message,
"done": False,
},
}
)
knowledge_files = []
for item in model_knowledge:
if item.get("collection_name"):
knowledge_files.append(
{
"id": item.get("collection_name"),
"name": item.get("name"),
"legacy": True,
}
)
elif item.get("collection_names"):
knowledge_files.append(
{
"name": item.get("name"),
"type": "collection",
"collection_names": item.get("collection_names"),
"legacy": True,
}
)
else:
knowledge_files.append(item)
files = form_data.get("files", [])
files.extend(knowledge_files)
form_data["files"] = files
features = form_data.pop("features", None)
if features:
if "web_search" in features and features["web_search"]:
form_data = await chat_web_search_handler(
request, form_data, extra_params, user
)
try:
form_data, flags = await chat_completion_filter_functions_handler(
request, form_data, model, extra_params
)
except Exception as e:
return Exception(f"Error: {e}")
raise Exception(f"Error: {e}")
tool_ids = form_data.pop("tool_ids", None)
files = form_data.pop("files", None)
# Remove files duplicates
if files:
files = list({json.dumps(f, sort_keys=True): f for f in files}.values())
metadata = {
**metadata,
@ -478,31 +730,366 @@ async def process_chat_payload(request, form_data, user, model):
if len(sources) > 0:
events.append({"sources": sources})
if model_knowledge:
await event_emitter(
{
"type": "status",
"data": {
"action": "knowledge_search",
"query": user_message,
"done": True,
"hidden": True,
},
}
)
return form_data, events
async def process_chat_response(response, events):
async def process_chat_response(
request, response, form_data, user, events, metadata, tasks
):
async def background_tasks_handler():
message_map = Chats.get_messages_by_chat_id(metadata["chat_id"])
message = message_map.get(metadata["message_id"]) if message_map else None
if message:
messages = get_message_list(message_map, message.get("id"))
if tasks:
if TASKS.TITLE_GENERATION in tasks:
if tasks[TASKS.TITLE_GENERATION]:
res = await generate_title(
request,
{
"model": message["model"],
"messages": messages,
"chat_id": metadata["chat_id"],
},
user,
)
if res and isinstance(res, dict):
title = (
res.get("choices", [])[0]
.get("message", {})
.get(
"content",
message.get("content", "New Chat"),
)
).strip()
if not title:
title = messages[0].get("content", "New Chat")
Chats.update_chat_title_by_id(metadata["chat_id"], title)
await event_emitter(
{
"type": "chat:title",
"data": title,
}
)
elif len(messages) == 2:
title = messages[0].get("content", "New Chat")
Chats.update_chat_title_by_id(metadata["chat_id"], title)
await event_emitter(
{
"type": "chat:title",
"data": message.get("content", "New Chat"),
}
)
if TASKS.TAGS_GENERATION in tasks and tasks[TASKS.TAGS_GENERATION]:
res = await generate_chat_tags(
request,
{
"model": message["model"],
"messages": messages,
"chat_id": metadata["chat_id"],
},
user,
)
if res and isinstance(res, dict):
tags_string = (
res.get("choices", [])[0]
.get("message", {})
.get("content", "")
)
tags_string = tags_string[
tags_string.find("{") : tags_string.rfind("}") + 1
]
try:
tags = json.loads(tags_string).get("tags", [])
Chats.update_chat_tags_by_id(
metadata["chat_id"], tags, user
)
await event_emitter(
{
"type": "chat:tags",
"data": tags,
}
)
except Exception as e:
print(f"Error: {e}")
event_emitter = None
if (
"session_id" in metadata
and metadata["session_id"]
and "chat_id" in metadata
and metadata["chat_id"]
and "message_id" in metadata
and metadata["message_id"]
):
event_emitter = get_event_emitter(metadata)
if not isinstance(response, StreamingResponse):
if event_emitter:
if "selected_model_id" in response:
Chats.upsert_message_to_chat_by_id_and_message_id(
metadata["chat_id"],
metadata["message_id"],
{
"selectedModelId": response["selected_model_id"],
},
)
if response.get("choices", [])[0].get("message", {}).get("content"):
content = response["choices"][0]["message"]["content"]
if content:
await event_emitter(
{
"type": "chat:completion",
"data": response,
}
)
title = Chats.get_chat_title_by_id(metadata["chat_id"])
await event_emitter(
{
"type": "chat:completion",
"data": {
"done": True,
"content": content,
"title": title,
},
}
)
# Save message in the database
Chats.upsert_message_to_chat_by_id_and_message_id(
metadata["chat_id"],
metadata["message_id"],
{
"content": content,
},
)
# Send a webhook notification if the user is not active
if get_active_status_by_user_id(user.id) is None:
webhook_url = Users.get_user_webhook_url_by_id(user.id)
if webhook_url:
post_webhook(
webhook_url,
f"{title} - {request.app.state.config.WEBUI_URL}/c/{metadata['chat_id']}\n\n{content}",
{
"action": "chat",
"message": content,
"title": title,
"url": f"{request.app.state.config.WEBUI_URL}/c/{metadata['chat_id']}",
},
)
await background_tasks_handler()
return response
else:
return response
if not any(
content_type in response.headers["Content-Type"]
for content_type in ["text/event-stream", "application/x-ndjson"]
):
return response
content_type = response.headers["Content-Type"]
is_openai = "text/event-stream" in content_type
is_ollama = "application/x-ndjson" in content_type
if event_emitter:
if not is_openai and not is_ollama:
return response
task_id = str(uuid4()) # Create a unique task ID.
async def stream_wrapper(original_generator, events):
def wrap_item(item):
return f"data: {item}\n\n" if is_openai else f"{item}\n"
# Handle as a background task
async def post_response_handler(response, events):
message = Chats.get_message_by_id_and_message_id(
metadata["chat_id"], metadata["message_id"]
)
content = message.get("content", "") if message else ""
for event in events:
yield wrap_item(json.dumps(event))
try:
for event in events:
await event_emitter(
{
"type": "chat:completion",
"data": event,
}
)
async for data in original_generator:
yield data
# Save message in the database
Chats.upsert_message_to_chat_by_id_and_message_id(
metadata["chat_id"],
metadata["message_id"],
{
**event,
},
)
return StreamingResponse(
stream_wrapper(response.body_iterator, events),
headers=dict(response.headers),
)
async for line in response.body_iterator:
line = line.decode("utf-8") if isinstance(line, bytes) else line
data = line
# Skip empty lines
if not data.strip():
continue
# "data: " is the prefix for each event
if not data.startswith("data: "):
continue
# Remove the prefix
data = data[len("data: ") :]
try:
data = json.loads(data)
if "selected_model_id" in data:
Chats.upsert_message_to_chat_by_id_and_message_id(
metadata["chat_id"],
metadata["message_id"],
{
"selectedModelId": data["selected_model_id"],
},
)
else:
value = (
data.get("choices", [])[0]
.get("delta", {})
.get("content")
)
if value:
content = f"{content}{value}"
if ENABLE_REALTIME_CHAT_SAVE:
# Save message in the database
Chats.upsert_message_to_chat_by_id_and_message_id(
metadata["chat_id"],
metadata["message_id"],
{
"content": content,
},
)
else:
data = {
"content": content,
}
await event_emitter(
{
"type": "chat:completion",
"data": data,
}
)
except Exception as e:
done = "data: [DONE]" in line
if done:
pass
else:
continue
title = Chats.get_chat_title_by_id(metadata["chat_id"])
data = {"done": True, "content": content, "title": title}
if not ENABLE_REALTIME_CHAT_SAVE:
# Save message in the database
Chats.upsert_message_to_chat_by_id_and_message_id(
metadata["chat_id"],
metadata["message_id"],
{
"content": content,
},
)
# Send a webhook notification if the user is not active
if get_active_status_by_user_id(user.id) is None:
webhook_url = Users.get_user_webhook_url_by_id(user.id)
if webhook_url:
post_webhook(
webhook_url,
f"{title} - {request.app.state.config.WEBUI_URL}/c/{metadata['chat_id']}\n\n{content}",
{
"action": "chat",
"message": content,
"title": title,
"url": f"{request.app.state.config.WEBUI_URL}/c/{metadata['chat_id']}",
},
)
await event_emitter(
{
"type": "chat:completion",
"data": data,
}
)
await background_tasks_handler()
except asyncio.CancelledError:
print("Task was cancelled!")
await event_emitter({"type": "task-cancelled"})
if not ENABLE_REALTIME_CHAT_SAVE:
# Save message in the database
Chats.upsert_message_to_chat_by_id_and_message_id(
metadata["chat_id"],
metadata["message_id"],
{
"content": content,
},
)
if response.background is not None:
await response.background()
# background_tasks.add_task(post_response_handler, response, events)
task_id, _ = create_task(post_response_handler(response, events))
return {"status": True, "task_id": task_id}
else:
# Fallback to the original response
async def stream_wrapper(original_generator, events):
def wrap_item(item):
return f"data: {item}\n\n"
for event in events:
yield wrap_item(json.dumps(event))
async for data in original_generator:
yield data
return StreamingResponse(
stream_wrapper(response.body_iterator, events),
headers=dict(response.headers),
background=response.background,
)

View File

@ -7,6 +7,34 @@ from pathlib import Path
from typing import Callable, Optional
def get_message_list(messages, message_id):
"""
Reconstructs a list of messages in order up to the specified message_id.
:param message_id: ID of the message to reconstruct the chain
:param messages: Message history dict containing all messages
:return: List of ordered messages starting from the root to the given message
"""
# Find the message by its id
current_message = messages.get(message_id)
if not current_message:
return f"Message ID {message_id} not found in the history."
# Reconstruct the chain by following the parentId links
message_list = []
while current_message:
message_list.insert(
0, current_message
) # Insert the message at the beginning of the list
parent_id = current_message["parentId"]
current_message = messages.get(parent_id) if parent_id else None
return message_list
def get_messages_content(messages: list[dict]) -> str:
return "\n".join(
[
@ -40,6 +68,13 @@ def get_last_user_message(messages: list[dict]) -> Optional[str]:
return get_content_from_message(message)
def get_last_assistant_message_item(messages: list[dict]) -> Optional[dict]:
for message in reversed(messages):
if message["role"] == "assistant":
return message
return None
def get_last_assistant_message(messages: list[dict]) -> Optional[str]:
for message in reversed(messages):
if message["role"] == "assistant":

View File

@ -58,7 +58,6 @@ async def get_all_base_models(request: Request):
return models
@cached(ttl=3)
async def get_all_models(request):
models = await get_all_base_models(request)

View File

@ -14,13 +14,16 @@ from starlette.responses import RedirectResponse
from open_webui.models.auths import Auths
from open_webui.models.users import Users
from open_webui.models.groups import Groups, GroupModel, GroupUpdateForm
from open_webui.config import (
DEFAULT_USER_ROLE,
ENABLE_OAUTH_SIGNUP,
OAUTH_MERGE_ACCOUNTS_BY_EMAIL,
OAUTH_PROVIDERS,
ENABLE_OAUTH_ROLE_MANAGEMENT,
ENABLE_OAUTH_GROUP_MANAGEMENT,
OAUTH_ROLES_CLAIM,
OAUTH_GROUPS_CLAIM,
OAUTH_EMAIL_CLAIM,
OAUTH_PICTURE_CLAIM,
OAUTH_USERNAME_CLAIM,
@ -44,7 +47,9 @@ auth_manager_config.DEFAULT_USER_ROLE = DEFAULT_USER_ROLE
auth_manager_config.ENABLE_OAUTH_SIGNUP = ENABLE_OAUTH_SIGNUP
auth_manager_config.OAUTH_MERGE_ACCOUNTS_BY_EMAIL = OAUTH_MERGE_ACCOUNTS_BY_EMAIL
auth_manager_config.ENABLE_OAUTH_ROLE_MANAGEMENT = ENABLE_OAUTH_ROLE_MANAGEMENT
auth_manager_config.ENABLE_OAUTH_GROUP_MANAGEMENT = ENABLE_OAUTH_GROUP_MANAGEMENT
auth_manager_config.OAUTH_ROLES_CLAIM = OAUTH_ROLES_CLAIM
auth_manager_config.OAUTH_GROUPS_CLAIM = OAUTH_GROUPS_CLAIM
auth_manager_config.OAUTH_EMAIL_CLAIM = OAUTH_EMAIL_CLAIM
auth_manager_config.OAUTH_PICTURE_CLAIM = OAUTH_PICTURE_CLAIM
auth_manager_config.OAUTH_USERNAME_CLAIM = OAUTH_USERNAME_CLAIM
@ -119,6 +124,61 @@ class OAuthManager:
return role
def update_user_groups(self, user, user_data, default_permissions):
oauth_claim = auth_manager_config.OAUTH_GROUPS_CLAIM
user_oauth_groups: list[str] = user_data.get(oauth_claim, list())
user_current_groups: list[GroupModel] = Groups.get_groups_by_member_id(user.id)
all_available_groups: list[GroupModel] = Groups.get_groups()
# Remove groups that user is no longer a part of
for group_model in user_current_groups:
if group_model.name not in user_oauth_groups:
# Remove group from user
user_ids = group_model.user_ids
user_ids = [i for i in user_ids if i != user.id]
# In case a group is created, but perms are never assigned to the group by hitting "save"
group_permissions = group_model.permissions
if not group_permissions:
group_permissions = default_permissions
update_form = GroupUpdateForm(
name=group_model.name,
description=group_model.description,
permissions=group_permissions,
user_ids=user_ids,
)
Groups.update_group_by_id(
id=group_model.id, form_data=update_form, overwrite=False
)
# Add user to new groups
for group_model in all_available_groups:
if group_model.name in user_oauth_groups and not any(
gm.name == group_model.name for gm in user_current_groups
):
# Add user to group
user_ids = group_model.user_ids
user_ids.append(user.id)
# In case a group is created, but perms are never assigned to the group by hitting "save"
group_permissions = group_model.permissions
if not group_permissions:
group_permissions = default_permissions
update_form = GroupUpdateForm(
name=group_model.name,
description=group_model.description,
permissions=group_permissions,
user_ids=user_ids,
)
Groups.update_group_by_id(
id=group_model.id, form_data=update_form, overwrite=False
)
async def handle_login(self, provider, request):
if provider not in OAUTH_PROVIDERS:
raise HTTPException(404)
@ -254,6 +314,13 @@ class OAuthManager:
expires_delta=parse_duration(auth_manager_config.JWT_EXPIRES_IN),
)
if auth_manager_config.ENABLE_OAUTH_GROUP_MANAGEMENT:
self.update_user_groups(
user=user,
user_data=user_data,
default_permissions=request.app.state.config.USER_PERMISSIONS,
)
# Set the cookie token
response.set_cookie(
key="token",

View File

@ -154,9 +154,16 @@ def convert_payload_openai_to_ollama(openai_payload: dict) -> dict:
)
ollama_payload["stream"] = openai_payload.get("stream", False)
if "format" in openai_payload:
ollama_payload["format"] = openai_payload["format"]
# If there are advanced parameters in the payload, format them in Ollama's options field
ollama_options = {}
if openai_payload.get("options"):
ollama_payload["options"] = openai_payload["options"]
ollama_options = openai_payload["options"]
# Handle parameters which map directly
for param in ["temperature", "top_p", "seed"]:
if param in openai_payload:

View File

@ -29,7 +29,7 @@ async def convert_streaming_response_ollama_to_openai(ollama_streaming_response)
(
(
data.get("eval_count", 0)
/ ((data.get("eval_duration", 0) / 1_000_000_000))
/ ((data.get("eval_duration", 0) / 10_000_000))
)
* 100
),
@ -43,12 +43,7 @@ async def convert_streaming_response_ollama_to_openai(ollama_streaming_response)
(
(
data.get("prompt_eval_count", 0)
/ (
(
data.get("prompt_eval_duration", 0)
/ 1_000_000_000
)
)
/ ((data.get("prompt_eval_duration", 0) / 10_000_000))
)
* 100
),
@ -57,20 +52,12 @@ async def convert_streaming_response_ollama_to_openai(ollama_streaming_response)
if data.get("prompt_eval_duration", 0) > 0
else "N/A"
),
"total_duration": round(
((data.get("total_duration", 0) / 1_000_000) * 100), 2
),
"load_duration": round(
((data.get("load_duration", 0) / 1_000_000) * 100), 2
),
"total_duration": data.get("total_duration", 0),
"load_duration": data.get("load_duration", 0),
"prompt_eval_count": data.get("prompt_eval_count", 0),
"prompt_eval_duration": round(
((data.get("prompt_eval_duration", 0) / 1_000_000) * 100), 2
),
"prompt_eval_duration": data.get("prompt_eval_duration", 0),
"eval_count": data.get("eval_count", 0),
"eval_duration": round(
((data.get("eval_duration", 0) / 1_000_000) * 100), 2
),
"eval_duration": data.get("eval_duration", 0),
"approximate_total": (
lambda s: f"{s // 3600}h{(s % 3600) // 60}m{s % 60}s"
)((data.get("total_duration", 0) or 0) // 1_000_000_000),

View File

@ -11,6 +11,7 @@ log.setLevel(SRC_LOG_LEVELS["WEBHOOK"])
def post_webhook(url: str, message: str, event_data: dict) -> bool:
try:
log.debug(f"post_webhook: {url}, {message}, {event_data}")
payload = {}
# Slack and Google Chat Webhooks
@ -18,7 +19,11 @@ def post_webhook(url: str, message: str, event_data: dict) -> bool:
payload["text"] = message
# Discord Webhooks
elif "https://discord.com/api/webhooks" in url:
payload["content"] = message
payload["content"] = (
message
if len(message) < 2000
else f"{message[: 2000 - 20]}... (truncated)"
)
# Microsoft Teams Webhooks
elif "webhook.office.com" in url:
action = event_data.get("action", "undefined")

View File

@ -3,7 +3,7 @@ uvicorn[standard]==0.30.6
pydantic==2.9.2
python-multipart==0.0.18
Flask==3.0.3
Flask==3.1.0
Flask-Cors==5.0.0
python-socketio==5.11.3
@ -18,7 +18,7 @@ aiofiles
sqlalchemy==2.0.32
alembic==1.14.0
peewee==3.17.6
peewee==3.17.8
peewee-migrate==1.12.2
psycopg2-binary==2.9.9
pgvector==0.3.5
@ -55,7 +55,7 @@ einops==0.8.0
ftfy==6.2.3
pypdf==4.3.1
fpdf2==2.7.9
fpdf2==2.8.2
pymdown-extensions==10.11.2
docx2txt==0.8
python-pptx==1.0.0
@ -67,7 +67,7 @@ pandas==2.2.3
openpyxl==3.1.5
pyxlsb==1.0.10
xlrd==2.0.1
validators==0.33.0
validators==0.34.0
psutil
sentencepiece
soundfile==0.12.1
@ -78,7 +78,7 @@ rank-bm25==0.2.2
faster-whisper==1.0.3
PyJWT[crypto]==2.9.0
PyJWT[crypto]==2.10.1
authlib==1.3.2
black==24.8.0
@ -90,6 +90,11 @@ extract_msg
pydub
duckduckgo-search~=6.3.5
## Google Drive
google-api-python-client
google-auth-httplib2
google-auth-oauthlib
## Tests
docker~=7.1.0
pytest~=8.3.2

31
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "open-webui",
"version": "0.4.8",
"version": "0.5.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "open-webui",
"version": "0.4.8",
"version": "0.5.4",
"dependencies": {
"@codemirror/lang-javascript": "^6.2.2",
"@codemirror/lang-python": "^6.1.6",
@ -16,6 +16,7 @@
"@mediapipe/tasks-vision": "^0.10.17",
"@pyscript/core": "^0.4.32",
"@sveltejs/adapter-node": "^2.0.0",
"@sveltejs/svelte-virtual-list": "^3.0.1",
"@tiptap/core": "^2.10.0",
"@tiptap/extension-code-block-lowlight": "^2.10.0",
"@tiptap/extension-highlight": "^2.10.0",
@ -2260,9 +2261,9 @@
}
},
"node_modules/@sveltejs/kit": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.9.0.tgz",
"integrity": "sha512-W3E7ed3ChB6kPqRs2H7tcHp+Z7oiTFC6m+lLyAQQuyXeqw6LdNuuwEUla+5VM0OGgqQD+cYD6+7Xq80vVm17Vg==",
"version": "2.12.1",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.12.1.tgz",
"integrity": "sha512-M3rPijGImeOkI0DBJSwjqz+YFX2DyOf6NzWgHVk3mqpT06dlYCpcv5xh1q4rYEqB58yQlk4QA1Y35PUqnUiFKw==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@ -2291,6 +2292,12 @@
"vite": "^5.0.3 || ^6.0.0"
}
},
"node_modules/@sveltejs/svelte-virtual-list": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@sveltejs/svelte-virtual-list/-/svelte-virtual-list-3.0.1.tgz",
"integrity": "sha512-aF9TptS7NKKS7/TqpsxQBSDJ9Q0XBYzBehCeIC5DzdMEgrJZpIYao9LRLnyyo6SVodpapm2B7FE/Lj+FSA5/SQ==",
"license": "LIL"
},
"node_modules/@sveltejs/vite-plugin-svelte": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-3.1.1.tgz",
@ -8267,15 +8274,16 @@
}
},
"node_modules/nanoid": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.6.tgz",
"integrity": "sha512-rRq0eMHoGZxlvaFOUdK1Ev83Bd1IgzzR+WJ3IbDJ7QOSdAxYjlurSPqFs9s4lJg29RT6nPwizFtJhQS6V5xgiA==",
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.9.tgz",
"integrity": "sha512-Aooyr6MXU6HpvvWXKoVoXwKMs/KyVakWwg7xQfv5/S/RIgJMy0Ifa45H9qqYy7pTCszrHzP21Uk4PZq2HpEM8Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.js"
},
@ -8976,15 +8984,16 @@
"dev": true
},
"node_modules/postcss/node_modules/nanoid": {
"version": "3.3.7",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
"version": "3.3.8",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz",
"integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},

View File

@ -1,6 +1,6 @@
{
"name": "open-webui",
"version": "0.4.8",
"version": "0.5.4",
"private": true,
"scripts": {
"dev": "npm run pyodide:fetch && vite dev --host",
@ -50,7 +50,6 @@
"type": "module",
"dependencies": {
"@codemirror/lang-javascript": "^6.2.2",
"codemirror-lang-hcl": "^0.0.0-beta.2",
"@codemirror/lang-python": "^6.1.6",
"@codemirror/language-data": "^6.5.1",
"@codemirror/theme-one-dark": "^6.1.2",
@ -58,6 +57,7 @@
"@mediapipe/tasks-vision": "^0.10.17",
"@pyscript/core": "^0.4.32",
"@sveltejs/adapter-node": "^2.0.0",
"@sveltejs/svelte-virtual-list": "^3.0.1",
"@tiptap/core": "^2.10.0",
"@tiptap/extension-code-block-lowlight": "^2.10.0",
"@tiptap/extension-highlight": "^2.10.0",
@ -69,6 +69,7 @@
"async": "^3.2.5",
"bits-ui": "^0.19.7",
"codemirror": "^6.0.1",
"codemirror-lang-hcl": "^0.0.0-beta.2",
"crc-32": "^1.2.2",
"dayjs": "^1.11.10",
"dompurify": "^3.1.6",

View File

@ -11,7 +11,7 @@ dependencies = [
"pydantic==2.9.2",
"python-multipart==0.0.18",
"Flask==3.0.3",
"Flask==3.1.0",
"Flask-Cors==5.0.0",
"python-socketio==5.11.3",
@ -26,7 +26,7 @@ dependencies = [
"sqlalchemy==2.0.32",
"alembic==1.14.0",
"peewee==3.17.6",
"peewee==3.17.8",
"peewee-migrate==1.12.2",
"psycopg2-binary==2.9.9",
"pgvector==0.3.5",
@ -61,7 +61,7 @@ dependencies = [
"ftfy==6.2.3",
"pypdf==4.3.1",
"fpdf2==2.7.9",
"fpdf2==2.8.2",
"pymdown-extensions==10.11.2",
"docx2txt==0.8",
"python-pptx==1.0.0",
@ -73,7 +73,7 @@ dependencies = [
"openpyxl==3.1.5",
"pyxlsb==1.0.10",
"xlrd==2.0.1",
"validators==0.33.0",
"validators==0.34.0",
"psutil",
"sentencepiece",
"soundfile==0.12.1",
@ -84,7 +84,7 @@ dependencies = [
"faster-whisper==1.0.3",
"PyJWT[crypto]==2.9.0",
"PyJWT[crypto]==2.10.1",
"authlib==1.3.2",
"black==24.8.0",
@ -151,3 +151,10 @@ exclude = [
"chroma.sqlite3",
]
force-include = { "CHANGELOG.md" = "open_webui/CHANGELOG.md", build = "open_webui/frontend" }
[tool.codespell]
# Ref: https://github.com/codespell-project/codespell#using-a-config-file
skip = '.git*,*.svg,package-lock.json,i18n,*.lock,*.css,*-bundle.js,locales,example-doc.txt,emoji-shortcodes.json'
check-hidden = true
# ignore-regex = ''
ignore-words-list = 'ans'

View File

@ -53,7 +53,11 @@ math {
}
.markdown-prose {
@apply prose dark:prose-invert prose-headings:font-semibold prose-hr:my-4 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-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;
}
.markdown-prose-xs {
@apply text-xs prose dark:prose-invert prose-headings:font-semibold prose-hr:my-0 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;
}
.markdown a {

View File

@ -0,0 +1,442 @@
import { WEBUI_API_BASE_URL } from '$lib/constants';
import { t } from 'i18next';
type ChannelForm = {
name: string;
data?: object;
meta?: object;
access_control?: object;
};
export const createNewChannel = async (token: string = '', channel: ChannelForm) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/channels/create`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({ ...channel })
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getChannels = async (token: string = '') => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/channels/`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getChannelById = async (token: string = '', channel_id: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/channels/${channel_id}`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const updateChannelById = async (
token: string = '',
channel_id: string,
channel: ChannelForm
) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/channels/${channel_id}/update`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({ ...channel })
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const deleteChannelById = async (token: string = '', channel_id: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/channels/${channel_id}/delete`, {
method: 'DELETE',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getChannelMessages = async (
token: string = '',
channel_id: string,
skip: number = 0,
limit: number = 50
) => {
let error = null;
const res = await fetch(
`${WEBUI_API_BASE_URL}/channels/${channel_id}/messages?skip=${skip}&limit=${limit}`,
{
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
}
)
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getChannelThreadMessages = async (
token: string = '',
channel_id: string,
message_id: string,
skip: number = 0,
limit: number = 50
) => {
let error = null;
const res = await fetch(
`${WEBUI_API_BASE_URL}/channels/${channel_id}/messages/${message_id}/thread?skip=${skip}&limit=${limit}`,
{
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
}
)
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
type MessageForm = {
parent_id?: string;
content: string;
data?: object;
meta?: object;
};
export const sendMessage = async (token: string = '', channel_id: string, message: MessageForm) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/channels/${channel_id}/messages/post`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({ ...message })
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const updateMessage = async (
token: string = '',
channel_id: string,
message_id: string,
message: MessageForm
) => {
let error = null;
const res = await fetch(
`${WEBUI_API_BASE_URL}/channels/${channel_id}/messages/${message_id}/update`,
{
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({ ...message })
}
)
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const addReaction = async (
token: string = '',
channel_id: string,
message_id: string,
name: string
) => {
let error = null;
const res = await fetch(
`${WEBUI_API_BASE_URL}/channels/${channel_id}/messages/${message_id}/reactions/add`,
{
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({ name })
}
)
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const removeReaction = async (
token: string = '',
channel_id: string,
message_id: string,
name: string
) => {
let error = null;
const res = await fetch(
`${WEBUI_API_BASE_URL}/channels/${channel_id}/messages/${message_id}/reactions/remove`,
{
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({ name })
}
)
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const deleteMessage = async (token: string = '', channel_id: string, message_id: string) => {
let error = null;
const res = await fetch(
`${WEBUI_API_BASE_URL}/channels/${channel_id}/messages/${message_id}/delete`,
{
method: 'DELETE',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
}
)
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};

View File

@ -618,6 +618,44 @@ export const cloneChatById = async (token: string, id: string) => {
return res;
};
export const cloneSharedChatById = async (token: string, id: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/${id}/clone/shared`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err;
if ('detail' in err) {
error = err.detail;
} else {
error = err;
}
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const shareChatById = async (token: string, id: string) => {
let error = null;

View File

@ -107,6 +107,38 @@ export const chatAction = async (token: string, action_id: string, body: ChatAct
return res;
};
export const stopTask = async (token: string, id: string) => {
let error = null;
const res = await fetch(`${WEBUI_BASE_URL}/api/tasks/stop/${id}`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
if ('detail' in err) {
error = err.detail;
} else {
error = err;
}
return null;
});
if (error) {
throw error;
}
return res;
};
export const getTaskConfig = async (token: string = '') => {
let error = null;
@ -650,7 +682,7 @@ export const getPipelines = async (token: string, urlIdx?: string) => {
searchParams.append('urlIdx', urlIdx);
}
const res = await fetch(`${WEBUI_BASE_URL}/api/v1/pipelines?${searchParams.toString()}`, {
const res = await fetch(`${WEBUI_BASE_URL}/api/v1/pipelines/?${searchParams.toString()}`, {
method: 'GET',
headers: {
Accept: 'application/json',

View File

@ -1,4 +1,4 @@
import { OPENAI_API_BASE_URL } from '$lib/constants';
import { OPENAI_API_BASE_URL, WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
export const getOpenAIConfig = async (token: string = '') => {
let error = null;
@ -273,10 +273,10 @@ export const verifyOpenAIConnection = async (
return res;
};
export const generateOpenAIChatCompletion = async (
export const chatCompletion = async (
token: string = '',
body: object,
url: string = OPENAI_API_BASE_URL
url: string = `${WEBUI_BASE_URL}/api`
): Promise<[Response | null, AbortController]> => {
const controller = new AbortController();
let error = null;
@ -302,6 +302,37 @@ export const generateOpenAIChatCompletion = async (
return [res, controller];
};
export const generateOpenAIChatCompletion = async (
token: string = '',
body: object,
url: string = `${WEBUI_BASE_URL}/api`
) => {
let error = null;
const res = await fetch(`${url}/chat/completions`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = `${err?.detail ?? 'Network Problem'}`;
return null;
});
if (error) {
throw error;
}
return res;
};
export const synthesizeOpenAISpeech = async (
token: string = '',
speaker: string = 'alloy',

View File

@ -4,7 +4,7 @@ type PromptItem = {
command: string;
title: string;
content: string;
access_control: null | object;
access_control?: null | object;
};
export const createNewPrompt = async (token: string, prompt: PromptItem) => {

View File

@ -45,6 +45,7 @@ type YoutubeConfigForm = {
type RAGConfigForm = {
pdf_extract_images?: boolean;
enable_google_drive_integration?: boolean;
chunk?: ChunkConfigForm;
content_extraction?: ContentExtractConfigForm;
web_loader_ssl_verification?: boolean;

View File

@ -84,7 +84,7 @@ async function* openAIStreamToIterator(
yield {
done: false,
value: parsedData.choices?.[0]?.delta?.content ?? '',
value: parsedData.choices?.[0]?.delta?.content ?? ''
};
} catch (e) {
console.error('Error extracting delta from SSE event:', e);
@ -120,8 +120,6 @@ async function* streamLargeDeltasAsRandomChunks(
continue;
}
let content = textStreamUpdate.value;
if (content.length < 5) {
yield { done: false, value: content };

View File

@ -0,0 +1,53 @@
<script lang="ts">
import { settings, playingNotificationSound, isLastActiveTab } from '$lib/stores';
import DOMPurify from 'dompurify';
import { marked } from 'marked';
import { createEventDispatcher, onMount } from 'svelte';
const dispatch = createEventDispatcher();
export let onClick: Function = () => {};
export let title: string = 'HI';
export let content: string;
onMount(() => {
if (!navigator.userActivation.hasBeenActive) {
return;
}
if ($settings?.notificationSound ?? true) {
if (!$playingNotificationSound && $isLastActiveTab) {
playingNotificationSound.set(true);
const audio = new Audio(`/audio/notification.mp3`);
audio.play().finally(() => {
// Ensure the global state is reset after the sound finishes
playingNotificationSound.set(false);
});
}
}
});
</script>
<button
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-50 dark:border-gray-800 rounded-xl px-3.5 py-3.5"
on:click={() => {
onClick();
dispatch('closeToast');
}}
>
<div class="flex-shrink-0 self-top -translate-y-0.5">
<img src={'/static/favicon.png'} alt="favicon" class="size-7 rounded-full" />
</div>
<div>
{#if title}
<div class=" text-[13px] font-medium mb-0.5 line-clamp-1 capitalize">{title}</div>
{/if}
<div class=" line-clamp-2 text-xs self-center dark:text-gray-300 font-normal">
{@html DOMPurify.sanitize(marked(content))}
</div>
</div>
</button>

View File

@ -56,6 +56,8 @@
let chunkOverlap = 0;
let pdfExtractImages = true;
let enableGoogleDriveIntegration = false;
let OpenAIUrl = '';
let OpenAIKey = '';
@ -175,6 +177,7 @@
}
const res = await updateRAGConfig(localStorage.token, {
pdf_extract_images: pdfExtractImages,
enable_google_drive_integration: enableGoogleDriveIntegration,
file: {
max_size: fileMaxSize === '' ? null : fileMaxSize,
max_count: fileMaxCount === '' ? null : fileMaxCount
@ -245,6 +248,8 @@
fileMaxSize = res?.file.max_size ?? '';
fileMaxCount = res?.file.max_count ?? '';
enableGoogleDriveIntegration = res.enable_google_drive_integration;
}
});
</script>
@ -586,6 +591,19 @@
<hr class=" dark:border-gray-850" />
<div class="text-sm font-medium mb-1">{$i18n.t('Google Drive')}</div>
<div class="">
<div class="flex justify-between items-center text-xs">
<div class="text-xs font-medium">{$i18n.t('Enable Google Drive')}</div>
<div>
<Switch bind:state={enableGoogleDriveIntegration} />
</div>
</div>
</div>
<hr class=" dark:border-gray-850" />
<div class=" ">
<div class=" text-sm font-medium mb-1">{$i18n.t('Query Params')}</div>
@ -669,7 +687,9 @@
<div class=" flex gap-1.5">
<div class=" w-full justify-between">
<div class="self-center text-xs font-medium min-w-fit mb-1">{$i18n.t('Chunk Size')}</div>
<div class="self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Chunk Size')}
</div>
<div class="self-center">
<input
class=" w-full rounded-lg py-1.5 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"

View File

@ -112,12 +112,48 @@
</div>
</div>
<div class=" flex w-full justify-between pr-2">
<div class=" self-center text-xs font-medium">{$i18n.t('Enable API Key Auth')}</div>
<div class=" flex w-full justify-between pr-2 my-3">
<div class=" self-center text-xs font-medium">{$i18n.t('Enable API Key')}</div>
<Switch bind:state={adminConfig.ENABLE_API_KEY} />
</div>
{#if adminConfig?.ENABLE_API_KEY}
<div class=" flex w-full justify-between pr-2 my-3">
<div class=" self-center text-xs font-medium">
{$i18n.t('API Key Endpoint Restrictions')}
</div>
<Switch bind:state={adminConfig.ENABLE_API_KEY_ENDPOINT_RESTRICTIONS} />
</div>
{#if adminConfig?.ENABLE_API_KEY_ENDPOINT_RESTRICTIONS}
<div class=" flex w-full flex-col pr-2">
<div class=" text-xs font-medium">
{$i18n.t('Allowed Endpoints')}
</div>
<input
class="w-full mt-1 rounded-lg text-sm dark:text-gray-300 bg-transparent outline-none"
type="text"
placeholder={`e.g.) /api/v1/messages, /api/v1/channels`}
bind:value={adminConfig.API_KEY_ALLOWED_ENDPOINTS}
/>
<div class="mt-2 text-xs text-gray-400 dark:text-gray-500">
<!-- https://docs.openwebui.com/getting-started/advanced-topics/api-endpoints -->
<a
href="https://docs.openwebui.com/getting-started/advanced-topics/api-endpoints"
target="_blank"
class=" text-gray-300 font-medium underline"
>
{$i18n.t('To learn more about available endpoints, visit our documentation.')}
</a>
</div>
</div>
{/if}
{/if}
<hr class=" border-gray-50 dark:border-gray-850 my-2" />
<div class="my-3 flex w-full items-center justify-between pr-2">
@ -142,6 +178,29 @@
<hr class=" border-gray-50 dark:border-gray-850 my-2" />
<div class=" w-full justify-between">
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">{$i18n.t('WebUI URL')}</div>
</div>
<div class="flex mt-2 space-x-2">
<input
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
type="text"
placeholder={`e.g.) "http://localhost:3000"`}
bind:value={adminConfig.WEBUI_URL}
/>
</div>
<div class="mt-2 text-xs text-gray-400 dark:text-gray-500">
{$i18n.t(
'Enter the public URL of your WebUI. This URL will be used to generate links in the notifications.'
)}
</div>
</div>
<hr class=" border-gray-50 dark:border-gray-850 my-2" />
<div class=" w-full justify-between">
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">{$i18n.t('JWT Expiration')}</div>
@ -180,6 +239,16 @@
/>
</div>
</div>
<hr class=" border-gray-50 dark:border-gray-850 my-2" />
<div class="pt-1 flex w-full justify-between pr-2">
<div class=" self-center text-sm font-medium">
{$i18n.t('Channels')} ({$i18n.t('Beta')})
</div>
<Switch bind:state={adminConfig.ENABLE_CHANNELS} />
</div>
</div>
{/if}

View File

@ -470,6 +470,19 @@
</div>
</div>
<div class="">
<div class=" mb-2 text-sm font-medium">{$i18n.t('ComfyUI API Key')}</div>
<div class="flex w-full">
<div class="flex-1 mr-2">
<SensitiveInput
placeholder={$i18n.t('sk-1234')}
bind:value={config.comfyui.COMFYUI_API_KEY}
required={false}
/>
</div>
</div>
</div>
<div class="">
<div class=" mb-2 text-sm font-medium">{$i18n.t('ComfyUI Workflow')}</div>

View File

@ -128,7 +128,7 @@
await toggleModelById(localStorage.token, model.id);
}
await init();
// await init();
_models.set(await getModels(localStorage.token));
};

View File

@ -0,0 +1,302 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { Pane, PaneGroup, PaneResizer } from 'paneforge';
import { onDestroy, onMount, tick } from 'svelte';
import { goto } from '$app/navigation';
import { chatId, showSidebar, socket, user } from '$lib/stores';
import { getChannelById, getChannelMessages, sendMessage } from '$lib/apis/channels';
import Messages from './Messages.svelte';
import MessageInput from './MessageInput.svelte';
import Navbar from './Navbar.svelte';
import Drawer from '../common/Drawer.svelte';
import EllipsisVertical from '../icons/EllipsisVertical.svelte';
import Thread from './Thread.svelte';
export let id = '';
let scrollEnd = true;
let messagesContainerElement = null;
let top = false;
let channel = null;
let messages = null;
let threadId = null;
let typingUsers = [];
let typingUsersTimeout = {};
$: if (id) {
initHandler();
}
const scrollToBottom = () => {
if (messagesContainerElement) {
messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight;
}
};
const initHandler = async () => {
top = false;
messages = null;
channel = null;
threadId = null;
typingUsers = [];
typingUsersTimeout = {};
channel = await getChannelById(localStorage.token, id).catch((error) => {
return null;
});
if (channel) {
messages = await getChannelMessages(localStorage.token, id, 0);
if (messages) {
scrollToBottom();
if (messages.length < 50) {
top = true;
}
}
} else {
goto('/');
}
};
const channelEventHandler = async (event) => {
if (event.channel_id === id) {
const type = event?.data?.type ?? null;
const data = event?.data?.data ?? null;
if (type === 'message') {
if ((data?.parent_id ?? null) === null) {
messages = [data, ...messages];
if (typingUsers.find((user) => user.id === event.user.id)) {
typingUsers = typingUsers.filter((user) => user.id !== event.user.id);
}
await tick();
if (scrollEnd) {
scrollToBottom();
}
}
} else if (type === 'message:update') {
const idx = messages.findIndex((message) => message.id === data.id);
if (idx !== -1) {
messages[idx] = data;
}
} else if (type === 'message:delete') {
messages = messages.filter((message) => message.id !== data.id);
} else if (type === 'message:reply') {
const idx = messages.findIndex((message) => message.id === data.id);
if (idx !== -1) {
messages[idx] = data;
}
} else if (type.includes('message:reaction')) {
const idx = messages.findIndex((message) => message.id === data.id);
if (idx !== -1) {
messages[idx] = data;
}
} else if (type === 'typing' && event.message_id === null) {
if (event.user.id === $user.id) {
return;
}
typingUsers = data.typing
? [
...typingUsers,
...(typingUsers.find((user) => user.id === event.user.id)
? []
: [
{
id: event.user.id,
name: event.user.name
}
])
]
: typingUsers.filter((user) => user.id !== event.user.id);
if (typingUsersTimeout[event.user.id]) {
clearTimeout(typingUsersTimeout[event.user.id]);
}
typingUsersTimeout[event.user.id] = setTimeout(() => {
typingUsers = typingUsers.filter((user) => user.id !== event.user.id);
}, 5000);
}
}
};
const submitHandler = async ({ content, data }) => {
if (!content && (data?.files ?? []).length === 0) {
return;
}
const res = await sendMessage(localStorage.token, id, { content: content, data: data }).catch(
(error) => {
toast.error(error);
return null;
}
);
if (res) {
messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight;
}
};
const onChange = async () => {
$socket?.emit('channel-events', {
channel_id: id,
message_id: null,
data: {
type: 'typing',
data: {
typing: true
}
}
});
};
let mediaQuery;
let largeScreen = false;
onMount(() => {
if ($chatId) {
chatId.set('');
}
$socket?.on('channel-events', channelEventHandler);
mediaQuery = window.matchMedia('(min-width: 1024px)');
const handleMediaQuery = async (e) => {
if (e.matches) {
largeScreen = true;
} else {
largeScreen = false;
}
};
mediaQuery.addEventListener('change', handleMediaQuery);
handleMediaQuery(mediaQuery);
});
onDestroy(() => {
$socket?.off('channel-events', channelEventHandler);
});
</script>
<svelte:head>
<title>#{channel?.name ?? 'Channel'} | Open WebUI</title>
</svelte:head>
<div
class="h-screen max-h-[100dvh] {$showSidebar
? 'md:max-w-[calc(100%-260px)]'
: ''} w-full max-w-full flex flex-col"
id="channel-container"
>
<PaneGroup direction="horizontal" class="w-full h-full">
<Pane defaultSize={50} minSize={50} class="h-full flex flex-col w-full relative">
<Navbar {channel} />
<div class="flex-1 overflow-y-auto">
{#if channel}
<div
class=" pb-2.5 max-w-full z-10 scrollbar-hidden w-full h-full pt-6 flex-1 flex flex-col-reverse overflow-auto"
id="messages-container"
bind:this={messagesContainerElement}
on:scroll={(e) => {
scrollEnd = Math.abs(messagesContainerElement.scrollTop) <= 50;
}}
>
{#key id}
<Messages
{channel}
{messages}
{top}
onThread={(id) => {
threadId = id;
}}
onLoad={async () => {
const newMessages = await getChannelMessages(
localStorage.token,
id,
messages.length
);
messages = [...messages, ...newMessages];
if (newMessages.length < 50) {
top = true;
return;
}
}}
/>
{/key}
</div>
{/if}
</div>
<div class=" pb-[1rem]">
<MessageInput
id="root"
{typingUsers}
{onChange}
onSubmit={submitHandler}
{scrollToBottom}
{scrollEnd}
/>
</div>
</Pane>
{#if !largeScreen}
{#if threadId !== null}
<Drawer
show={threadId !== null}
on:close={() => {
threadId = null;
}}
>
<div class=" {threadId !== null ? ' h-screen w-screen' : 'px-6 py-4'} h-full">
<Thread
{threadId}
{channel}
onClose={() => {
threadId = null;
}}
/>
</div>
</Drawer>
{/if}
{:else if threadId !== null}
<PaneResizer
class="relative flex w-[3px] items-center justify-center bg-background group bg-gray-50 dark:bg-gray-850"
>
<div class="z-10 flex h-7 w-5 items-center justify-center rounded-sm">
<EllipsisVertical className="size-4 invisible group-hover:visible" />
</div>
</PaneResizer>
<Pane defaultSize={50} minSize={30} class="h-full w-full">
<div class="h-full w-full shadow-xl">
<Thread
{threadId}
{channel}
onClose={() => {
threadId = null;
}}
/>
</div>
</Pane>
{/if}
</PaneGroup>
</div>

View File

@ -0,0 +1,613 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { v4 as uuidv4 } from 'uuid';
import { tick, getContext, onMount, onDestroy } from 'svelte';
const i18n = getContext('i18n');
import { config, mobile, settings, socket } from '$lib/stores';
import { blobToFile, compressImage } from '$lib/utils';
import Tooltip from '../common/Tooltip.svelte';
import RichTextInput from '../common/RichTextInput.svelte';
import VoiceRecording from '../chat/MessageInput/VoiceRecording.svelte';
import InputMenu from './MessageInput/InputMenu.svelte';
import { uploadFile } from '$lib/apis/files';
import { WEBUI_API_BASE_URL } from '$lib/constants';
import FileItem from '../common/FileItem.svelte';
import Image from '../common/Image.svelte';
import { transcribeAudio } from '$lib/apis/audio';
import FilesOverlay from '../chat/MessageInput/FilesOverlay.svelte';
export let placeholder = $i18n.t('Send a Message');
export let transparentBackground = false;
export let id = null;
let draggedOver = false;
let recording = false;
let content = '';
let files = [];
let filesInputElement;
let inputFiles;
export let typingUsers = [];
export let onSubmit: Function;
export let onChange: Function;
export let scrollEnd = true;
export let scrollToBottom: Function = () => {};
const screenCaptureHandler = async () => {
try {
// Request screen media
const mediaStream = await navigator.mediaDevices.getDisplayMedia({
video: { cursor: 'never' },
audio: false
});
// Once the user selects a screen, temporarily create a video element
const video = document.createElement('video');
video.srcObject = mediaStream;
// Ensure the video loads without affecting user experience or tab switching
await video.play();
// Set up the canvas to match the video dimensions
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
// Grab a single frame from the video stream using the canvas
const context = canvas.getContext('2d');
context.drawImage(video, 0, 0, canvas.width, canvas.height);
// Stop all video tracks (stop screen sharing) after capturing the image
mediaStream.getTracks().forEach((track) => track.stop());
// bring back focus to this current tab, so that the user can see the screen capture
window.focus();
// Convert the canvas to a Base64 image URL
const imageUrl = canvas.toDataURL('image/png');
// Add the captured image to the files array to render it
files = [...files, { type: 'image', url: imageUrl }];
// Clean memory: Clear video srcObject
video.srcObject = null;
} catch (error) {
// Handle any errors (e.g., user cancels screen sharing)
console.error('Error capturing screen:', error);
}
};
const inputFilesHandler = async (inputFiles) => {
inputFiles.forEach((file) => {
console.log('Processing file:', {
name: file.name,
type: file.type,
size: file.size,
extension: file.name.split('.').at(-1)
});
if (
($config?.file?.max_size ?? null) !== null &&
file.size > ($config?.file?.max_size ?? 0) * 1024 * 1024
) {
console.log('File exceeds max size limit:', {
fileSize: file.size,
maxSize: ($config?.file?.max_size ?? 0) * 1024 * 1024
});
toast.error(
$i18n.t(`File size should not exceed {{maxSize}} MB.`, {
maxSize: $config?.file?.max_size
})
);
return;
}
if (['image/gif', 'image/webp', 'image/jpeg', 'image/png'].includes(file['type'])) {
let reader = new FileReader();
reader.onload = async (event) => {
let imageUrl = event.target.result;
if ($settings?.imageCompression ?? false) {
const width = $settings?.imageCompressionSize?.width ?? null;
const height = $settings?.imageCompressionSize?.height ?? null;
if (width || height) {
imageUrl = await compressImage(imageUrl, width, height);
}
}
files = [
...files,
{
type: 'image',
url: `${imageUrl}`
}
];
};
reader.readAsDataURL(file);
} else {
uploadFileHandler(file);
}
});
};
const uploadFileHandler = async (file) => {
const tempItemId = uuidv4();
const fileItem = {
type: 'file',
file: '',
id: null,
url: '',
name: file.name,
collection_name: '',
status: 'uploading',
size: file.size,
error: '',
itemId: tempItemId
};
if (fileItem.size == 0) {
toast.error($i18n.t('You cannot upload an empty file.'));
return null;
}
files = [...files, fileItem];
// Check if the file is an audio file and transcribe/convert it to text file
if (['audio/mpeg', 'audio/wav', 'audio/ogg', 'audio/x-m4a'].includes(file['type'])) {
const res = await transcribeAudio(localStorage.token, file).catch((error) => {
toast.error(error);
return null;
});
if (res) {
console.log(res);
const blob = new Blob([res.text], { type: 'text/plain' });
file = blobToFile(blob, `${file.name}.txt`);
fileItem.name = file.name;
fileItem.size = file.size;
}
}
try {
// During the file upload, file content is automatically extracted.
const uploadedFile = await uploadFile(localStorage.token, file);
if (uploadedFile) {
console.log('File upload completed:', {
id: uploadedFile.id,
name: fileItem.name,
collection: uploadedFile?.meta?.collection_name
});
if (uploadedFile.error) {
console.warn('File upload warning:', uploadedFile.error);
toast.warning(uploadedFile.error);
}
fileItem.status = 'uploaded';
fileItem.file = uploadedFile;
fileItem.id = uploadedFile.id;
fileItem.collection_name =
uploadedFile?.meta?.collection_name || uploadedFile?.collection_name;
fileItem.url = `${WEBUI_API_BASE_URL}/files/${uploadedFile.id}`;
files = files;
} else {
files = files.filter((item) => item?.itemId !== tempItemId);
}
} catch (e) {
toast.error(e);
files = files.filter((item) => item?.itemId !== tempItemId);
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
console.log('Escape');
draggedOver = false;
}
};
const onDragOver = (e) => {
e.preventDefault();
// Check if a file is being draggedOver.
if (e.dataTransfer?.types?.includes('Files')) {
draggedOver = true;
} else {
draggedOver = false;
}
};
const onDragLeave = () => {
draggedOver = false;
};
const onDrop = async (e) => {
e.preventDefault();
console.log(e);
if (e.dataTransfer?.files) {
const inputFiles = Array.from(e.dataTransfer?.files);
if (inputFiles && inputFiles.length > 0) {
console.log(inputFiles);
inputFilesHandler(inputFiles);
}
}
draggedOver = false;
};
const submitHandler = async () => {
if (content === '' && files.length === 0) {
return;
}
onSubmit({
content,
data: {
files: files
}
});
content = '';
files = [];
await tick();
const chatInputElement = document.getElementById(`chat-input-${id}`);
chatInputElement?.focus();
};
$: if (content) {
onChange();
}
onMount(async () => {
window.setTimeout(() => {
const chatInput = document.getElementById(`chat-input-${id}`);
chatInput?.focus();
}, 0);
window.addEventListener('keydown', handleKeyDown);
await tick();
const dropzoneElement = document.getElementById('channel-container');
dropzoneElement?.addEventListener('dragover', onDragOver);
dropzoneElement?.addEventListener('drop', onDrop);
dropzoneElement?.addEventListener('dragleave', onDragLeave);
});
onDestroy(() => {
console.log('destroy');
window.removeEventListener('keydown', handleKeyDown);
const dropzoneElement = document.getElementById('channel-container');
if (dropzoneElement) {
dropzoneElement?.removeEventListener('dragover', onDragOver);
dropzoneElement?.removeEventListener('drop', onDrop);
dropzoneElement?.removeEventListener('dragleave', onDragLeave);
}
});
</script>
<FilesOverlay show={draggedOver} />
<input
bind:this={filesInputElement}
bind:files={inputFiles}
type="file"
hidden
multiple
on:change={async () => {
if (inputFiles && inputFiles.length > 0) {
inputFilesHandler(Array.from(inputFiles));
} else {
toast.error($i18n.t(`File not found.`));
}
filesInputElement.value = '';
}}
/>
<div class="bg-transparent">
<div
class="{($settings?.widescreenMode ?? null)
? 'max-w-full'
: 'max-w-6xl'} px-2.5 mx-auto inset-x-0 relative"
>
<div class="absolute top-0 left-0 right-0 mx-auto inset-x-0 bg-transparent flex justify-center">
<div class="flex flex-col px-3 w-full">
<div class="relative">
{#if scrollEnd === false}
<div
class=" absolute -top-12 left-0 right-0 flex justify-center z-30 pointer-events-none"
>
<button
class=" bg-white border border-gray-100 dark:border-none dark:bg-white/20 p-1.5 rounded-full pointer-events-auto"
on:click={() => {
scrollEnd = true;
scrollToBottom();
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
fill-rule="evenodd"
d="M10 3a.75.75 0 01.75.75v10.638l3.96-4.158a.75.75 0 111.08 1.04l-5.25 5.5a.75.75 0 01-1.08 0l-5.25-5.5a.75.75 0 111.08-1.04l3.96 4.158V3.75A.75.75 0 0110 3z"
clip-rule="evenodd"
/>
</svg>
</button>
</div>
{/if}
</div>
<div class="relative">
<div class=" -mt-5">
{#if typingUsers.length > 0}
<div class=" text-xs px-4 mb-1">
<span class=" font-normal text-black dark:text-white">
{typingUsers.map((user) => user.name).join(', ')}
</span>
{$i18n.t('is typing...')}
</div>
{/if}
</div>
</div>
</div>
</div>
<div class="">
{#if recording}
<VoiceRecording
bind:recording
on:cancel={async () => {
recording = false;
await tick();
document.getElementById(`chat-input-${id}`)?.focus();
}}
on:confirm={async (e) => {
const { text, filename } = e.detail;
content = `${content}${text} `;
recording = false;
await tick();
document.getElementById(`chat-input-${id}`)?.focus();
}}
/>
{:else}
<form
class="w-full flex gap-1.5"
on:submit|preventDefault={() => {
submitHandler();
}}
>
<div
class="flex-1 flex flex-col relative w-full rounded-3xl px-1 bg-gray-600/5 dark:bg-gray-400/5 dark:text-gray-100"
dir={$settings?.chatDirection ?? 'LTR'}
>
{#if files.length > 0}
<div class="mx-1 mt-2.5 mb-1 flex flex-wrap gap-2">
{#each files as file, fileIdx}
{#if file.type === 'image'}
<div class=" relative group">
<div class="relative">
<Image
src={file.url}
alt="input"
imageClassName=" h-16 w-16 rounded-xl object-cover"
/>
</div>
<div class=" absolute -top-1 -right-1">
<button
class=" bg-gray-400 text-white border border-white rounded-full group-hover:visible invisible transition"
type="button"
on:click={() => {
files.splice(fileIdx, 1);
files = files;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
</button>
</div>
</div>
{:else}
<FileItem
item={file}
name={file.name}
type={file.type}
size={file?.size}
loading={file.status === 'uploading'}
dismissible={true}
edit={true}
on:dismiss={() => {
files.splice(fileIdx, 1);
files = files;
}}
on:click={() => {
console.log(file);
}}
/>
{/if}
{/each}
</div>
{/if}
<div class=" flex">
<div class="ml-1 self-end mb-1.5 flex space-x-1">
<InputMenu
{screenCaptureHandler}
uploadFilesHandler={() => {
filesInputElement.click();
}}
>
<button
class="bg-transparent hover:bg-white/80 text-gray-800 dark:text-white dark:hover:bg-gray-800 transition rounded-full p-2 outline-none focus:outline-none"
type="button"
aria-label="More"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-5"
>
<path
d="M10.75 4.75a.75.75 0 0 0-1.5 0v4.5h-4.5a.75.75 0 0 0 0 1.5h4.5v4.5a.75.75 0 0 0 1.5 0v-4.5h4.5a.75.75 0 0 0 0-1.5h-4.5v-4.5Z"
/>
</svg>
</button>
</InputMenu>
</div>
<div
class="scrollbar-hidden text-left bg-transparent dark:text-gray-100 outline-none w-full py-2.5 px-1 rounded-xl resize-none h-fit max-h-80 overflow-auto"
>
<RichTextInput
bind:value={content}
id={`chat-input-${id}`}
messageInput={true}
shiftEnter={!$mobile ||
!(
'ontouchstart' in window ||
navigator.maxTouchPoints > 0 ||
navigator.msMaxTouchPoints > 0
)}
{placeholder}
largeTextAsFile={$settings?.largeTextAsFile ?? false}
on:keydown={async (e) => {
e = e.detail.event;
const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac
if (
!$mobile ||
!(
'ontouchstart' in window ||
navigator.maxTouchPoints > 0 ||
navigator.msMaxTouchPoints > 0
)
) {
// Prevent Enter key from creating a new line
// Uses keyCode '13' for Enter key for chinese/japanese keyboards
if (e.keyCode === 13 && !e.shiftKey) {
e.preventDefault();
}
// Submit the content when Enter key is pressed
if (content !== '' && e.keyCode === 13 && !e.shiftKey) {
submitHandler();
}
}
if (e.key === 'Escape') {
console.log('Escape');
}
}}
on:paste={async (e) => {
e = e.detail.event;
console.log(e);
}}
/>
</div>
<div class="self-end mb-1.5 flex space-x-1 mr-1">
{#if content === ''}
<Tooltip content={$i18n.t('Record voice')}>
<button
id="voice-input-button"
class=" text-gray-600 dark:text-gray-300 hover:text-gray-700 dark:hover:text-gray-200 transition rounded-full p-1.5 mr-0.5 self-center"
type="button"
on:click={async () => {
try {
let stream = await navigator.mediaDevices
.getUserMedia({ audio: true })
.catch(function (err) {
toast.error(
$i18n.t(`Permission denied when accessing microphone: {{error}}`, {
error: err
})
);
return null;
});
if (stream) {
recording = true;
const tracks = stream.getTracks();
tracks.forEach((track) => track.stop());
}
stream = null;
} catch {
toast.error($i18n.t('Permission denied when accessing microphone'));
}
}}
aria-label="Voice Input"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5 translate-y-[0.5px]"
>
<path d="M7 4a3 3 0 016 0v6a3 3 0 11-6 0V4z" />
<path
d="M5.5 9.643a.75.75 0 00-1.5 0V10c0 3.06 2.29 5.585 5.25 5.954V17.5h-1.5a.75.75 0 000 1.5h4.5a.75.75 0 000-1.5h-1.5v-1.546A6.001 6.001 0 0016 10v-.357a.75.75 0 00-1.5 0V10a4.5 4.5 0 01-9 0v-.357z"
/>
</svg>
</button>
</Tooltip>
{/if}
<div class=" flex items-center">
<div class=" flex items-center">
<Tooltip content={$i18n.t('Send message')}>
<button
id="send-message-button"
class="{content !== '' || files.length !== 0
? 'bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100 '
: 'text-white bg-gray-200 dark:text-gray-900 dark:bg-gray-700 disabled'} transition rounded-full p-1.5 self-center"
type="submit"
disabled={content === '' && files.length === 0}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="size-6"
>
<path
fill-rule="evenodd"
d="M8 14a.75.75 0 0 1-.75-.75V4.56L4.03 7.78a.75.75 0 0 1-1.06-1.06l4.5-4.5a.75.75 0 0 1 1.06 0l4.5 4.5a.75.75 0 0 1-1.06 1.06L8.75 4.56v8.69A.75.75 0 0 1 8 14Z"
clip-rule="evenodd"
/>
</svg>
</button>
</Tooltip>
</div>
</div>
</div>
</div>
</div>
</form>
{/if}
</div>
</div>
</div>

View File

@ -0,0 +1,77 @@
<script lang="ts">
import { DropdownMenu } from 'bits-ui';
import { flyAndScale } from '$lib/utils/transitions';
import { getContext, onMount, tick } from 'svelte';
import { config, user, tools as _tools, mobile } 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 DocumentArrowUpSolid from '$lib/components/icons/DocumentArrowUpSolid.svelte';
import Switch from '$lib/components/common/Switch.svelte';
import GlobeAltSolid from '$lib/components/icons/GlobeAltSolid.svelte';
import WrenchSolid from '$lib/components/icons/WrenchSolid.svelte';
import CameraSolid from '$lib/components/icons/CameraSolid.svelte';
const i18n = getContext('i18n');
export let screenCaptureHandler: Function;
export let uploadFilesHandler: Function;
export let onClose: Function = () => {};
let show = false;
$: if (show) {
init();
}
const init = async () => {};
</script>
<Dropdown
bind:show
on:change={(e) => {
if (e.detail === false) {
onClose();
}
}}
>
<Tooltip content={$i18n.t('More')}>
<slot />
</Tooltip>
<div slot="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"
sideOffset={15}
alignOffset={-8}
side="top"
align="start"
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
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={() => {
uploadFilesHandler();
}}
>
<DocumentArrowUpSolid />
<div class="line-clamp-1">{$i18n.t('Upload Files')}</div>
</DropdownMenu.Item>
</DropdownMenu.Content>
</div>
</Dropdown>

View File

@ -0,0 +1,194 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import isToday from 'dayjs/plugin/isToday';
import isYesterday from 'dayjs/plugin/isYesterday';
dayjs.extend(relativeTime);
dayjs.extend(isToday);
dayjs.extend(isYesterday);
import { tick, getContext, onMount, createEventDispatcher } from 'svelte';
import { settings, user } from '$lib/stores';
import Message from './Messages/Message.svelte';
import Loader from '../common/Loader.svelte';
import Spinner from '../common/Spinner.svelte';
import { addReaction, deleteMessage, removeReaction, updateMessage } from '$lib/apis/channels';
const i18n = getContext('i18n');
export let id = null;
export let channel = null;
export let messages = [];
export let top = false;
export let thread = false;
export let onLoad: Function = () => {};
export let onThread: Function = () => {};
let messagesLoading = false;
const loadMoreMessages = async () => {
// scroll slightly down to disable continuous loading
const element = document.getElementById('messages-container');
element.scrollTop = element.scrollTop + 100;
messagesLoading = true;
await onLoad();
await tick();
messagesLoading = false;
};
</script>
{#if messages}
{@const messageList = messages.slice().reverse()}
<div>
{#if !top}
<Loader
on:visible={(e) => {
console.log('visible');
if (!messagesLoading) {
loadMoreMessages();
}
}}
>
<div class="w-full flex justify-center py-1 text-xs animate-pulse items-center gap-2">
<Spinner className=" size-4" />
<div class=" ">Loading...</div>
</div>
</Loader>
{:else if !thread}
<div
class="px-5
{($settings?.widescreenMode ?? null) ? 'max-w-full' : 'max-w-5xl'} mx-auto"
>
{#if channel}
<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-gray-500">
This channel was created on {dayjs(channel.created_at / 1000000).format(
'MMMM D, YYYY'
)}. This is the very beginning of the {channel.name}
channel.
</div>
</div>
{:else}
<div class="flex justify-center text-xs items-center gap-2 py-5">
<div class=" ">Start of the channel</div>
</div>
{/if}
{#if messageList.length > 0}
<hr class=" border-gray-50 dark:border-gray-700/20 py-2.5 w-full" />
{/if}
</div>
{/if}
{#each messageList as message, messageIdx (id ? `${id}-${message.id}` : message.id)}
<Message
{message}
{thread}
showUserProfile={messageIdx === 0 ||
messageList.at(messageIdx - 1)?.user_id !== message.user_id}
onDelete={() => {
messages = messages.filter((m) => m.id !== message.id);
const res = deleteMessage(localStorage.token, message.channel_id, message.id).catch(
(error) => {
toast.error(error);
return null;
}
);
}}
onEdit={(content) => {
messages = messages.map((m) => {
if (m.id === message.id) {
m.content = content;
}
return m;
});
const res = updateMessage(localStorage.token, message.channel_id, message.id, {
content: content
}).catch((error) => {
toast.error(error);
return null;
});
}}
onThread={(id) => {
onThread(id);
}}
onReaction={(name) => {
if (
(message?.reactions ?? [])
.find((reaction) => reaction.name === name)
?.user_ids?.includes($user.id) ??
false
) {
messages = messages.map((m) => {
if (m.id === message.id) {
const reaction = m.reactions.find((reaction) => reaction.name === name);
if (reaction) {
reaction.user_ids = reaction.user_ids.filter((id) => id !== $user.id);
reaction.count = reaction.user_ids.length;
if (reaction.count === 0) {
m.reactions = m.reactions.filter((r) => r.name !== name);
}
}
}
return m;
});
const res = removeReaction(
localStorage.token,
message.channel_id,
message.id,
name
).catch((error) => {
toast.error(error);
return null;
});
} else {
messages = messages.map((m) => {
if (m.id === message.id) {
if (m.reactions) {
const reaction = m.reactions.find((reaction) => reaction.name === name);
if (reaction) {
reaction.user_ids.push($user.id);
reaction.count = reaction.user_ids.length;
} else {
m.reactions.push({
name: name,
user_ids: [$user.id],
count: 1
});
}
}
}
return m;
});
const res = addReaction(localStorage.token, message.channel_id, message.id, name).catch(
(error) => {
toast.error(error);
return null;
}
);
}
}}
/>
{/each}
<div class="pb-6" />
</div>
{/if}

View File

@ -0,0 +1,357 @@
<script lang="ts">
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import isToday from 'dayjs/plugin/isToday';
import isYesterday from 'dayjs/plugin/isYesterday';
dayjs.extend(relativeTime);
dayjs.extend(isToday);
dayjs.extend(isYesterday);
import { getContext, onMount } from 'svelte';
const i18n = getContext<Writable<i18nType>>('i18n');
import { settings, user, shortCodesToEmojis } from '$lib/stores';
import { WEBUI_BASE_URL } from '$lib/constants';
import Markdown from '$lib/components/chat/Messages/Markdown.svelte';
import ProfileImage from '$lib/components/chat/Messages/ProfileImage.svelte';
import Name from '$lib/components/chat/Messages/Name.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import GarbageBin from '$lib/components/icons/GarbageBin.svelte';
import Pencil from '$lib/components/icons/Pencil.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Textarea from '$lib/components/common/Textarea.svelte';
import Image from '$lib/components/common/Image.svelte';
import FileItem from '$lib/components/common/FileItem.svelte';
import ProfilePreview from './Message/ProfilePreview.svelte';
import ChatBubbleOvalEllipsis from '$lib/components/icons/ChatBubbleOvalEllipsis.svelte';
import FaceSmile from '$lib/components/icons/FaceSmile.svelte';
import ReactionPicker from './Message/ReactionPicker.svelte';
import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
export let message;
export let showUserProfile = true;
export let thread = false;
export let onDelete: Function = () => {};
export let onEdit: Function = () => {};
export let onThread: Function = () => {};
export let onReaction: Function = () => {};
let showButtons = false;
let edit = false;
let editedContent = null;
let showDeleteConfirmDialog = false;
const formatDate = (inputDate) => {
const date = dayjs(inputDate);
const now = dayjs();
if (date.isToday()) {
return `Today at ${date.format('HH:mm')}`;
} else if (date.isYesterday()) {
return `Yesterday at ${date.format('HH:mm')}`;
} else {
return `${date.format('DD/MM/YYYY')} at ${date.format('HH:mm')}`;
}
};
</script>
<ConfirmDialog
bind:show={showDeleteConfirmDialog}
title={$i18n.t('Delete Message')}
message={$i18n.t('Are you sure you want to delete this message?')}
onConfirm={async () => {
await onDelete();
}}
/>
{#if message}
<div
class="flex flex-col justify-between px-5 {showUserProfile
? 'pt-1.5 pb-0.5'
: ''} w-full {($settings?.widescreenMode ?? null)
? 'max-w-full'
: 'max-w-5xl'} mx-auto group hover:bg-gray-300/5 dark:hover:bg-gray-700/5 transition relative"
>
{#if !edit}
<div
class=" absolute {showButtons ? '' : 'invisible group-hover:visible'} right-1 -top-2 z-10"
>
<div
class="flex gap-1 rounded-lg bg-white dark:bg-gray-850 shadow-md p-0.5 border border-gray-100 dark:border-gray-800"
>
<ReactionPicker
onClose={() => (showButtons = false)}
onSubmit={(name) => {
showButtons = false;
onReaction(name);
}}
>
<Tooltip content={$i18n.t('Add Reaction')}>
<button
class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1"
on:click={() => {
showButtons = true;
}}
>
<FaceSmile />
</button>
</Tooltip>
</ReactionPicker>
{#if !thread}
<Tooltip content={$i18n.t('Reply in Thread')}>
<button
class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1"
on:click={() => {
onThread(message.id);
}}
>
<ChatBubbleOvalEllipsis />
</button>
</Tooltip>
{/if}
{#if message.user_id === $user.id || $user.role === 'admin'}
<Tooltip content={$i18n.t('Edit')}>
<button
class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1"
on:click={() => {
edit = true;
editedContent = message.content;
}}
>
<Pencil />
</button>
</Tooltip>
<Tooltip content={$i18n.t('Delete')}>
<button
class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1"
on:click={() => (showDeleteConfirmDialog = true)}
>
<GarbageBin />
</button>
</Tooltip>
{/if}
</div>
</div>
{/if}
<div
class=" flex w-full message-{message.id}"
id="message-{message.id}"
dir={$settings.chatDirection}
>
<div
class={`flex-shrink-0 ${($settings?.chatDirection ?? 'LTR') === 'LTR' ? 'mr-3' : 'ml-3'} w-9`}
>
{#if showUserProfile}
<ProfilePreview user={message.user}>
<ProfileImage
src={message.user?.profile_image_url ??
($i18n.language === 'dg-DG' ? `/doge.png` : `${WEBUI_BASE_URL}/static/favicon.png`)}
className={'size-8 translate-y-1 ml-0.5'}
/>
</ProfilePreview>
{:else}
<!-- <div class="w-7 h-7 rounded-full bg-transparent" /> -->
{#if message.created_at}
<div
class="mt-1.5 flex flex-shrink-0 items-center text-xs self-center invisible group-hover:visible text-gray-500 font-medium first-letter:capitalize"
>
<Tooltip
content={dayjs(message.created_at / 1000000).format('dddd, DD MMMM YYYY HH:mm')}
>
{dayjs(message.created_at / 1000000).format('HH:mm')}
</Tooltip>
</div>
{/if}
{/if}
</div>
<div class="flex-auto w-0 pl-1">
{#if showUserProfile}
<Name>
<div class=" self-end text-base shrink-0 font-medium truncate">
{message?.user?.name}
</div>
{#if message.created_at}
<div
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('dddd, DD MMMM YYYY HH:mm')}
>
<span class="line-clamp-1">{formatDate(message.created_at / 1000000)}</span>
</Tooltip>
</div>
{/if}
</Name>
{/if}
{#if (message?.data?.files ?? []).length > 0}
<div class="my-2.5 w-full flex overflow-x-auto gap-2 flex-wrap">
{#each message?.data?.files as file}
<div>
{#if file.type === 'image'}
<Image src={file.url} alt={file.name} imageClassName=" max-h-96 rounded-lg" />
{:else}
<FileItem
item={file}
url={file.url}
name={file.name}
type={file.type}
size={file?.size}
colorClassName="bg-white dark:bg-gray-850 "
/>
{/if}
</div>
{/each}
</div>
{/if}
{#if edit}
<div class="py-2">
<Textarea
className=" bg-transparent outline-none w-full resize-none"
bind:value={editedContent}
onKeydown={(e) => {
if (e.key === 'Escape') {
document.getElementById('close-edit-message-button')?.click();
}
const isCmdOrCtrlPressed = e.metaKey || e.ctrlKey;
const isEnterPressed = e.key === 'Enter';
if (isCmdOrCtrlPressed && isEnterPressed) {
document.getElementById('confirm-edit-message-button')?.click();
}
}}
/>
<div class=" mt-2 mb-1 flex justify-end text-sm font-medium">
<div class="flex space-x-1.5">
<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"
on:click={() => {
edit = false;
editedContent = null;
}}
>
{$i18n.t('Cancel')}
</button>
<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"
on:click={async () => {
onEdit(editedContent);
edit = false;
editedContent = null;
}}
>
{$i18n.t('Save')}
</button>
</div>
</div>
</div>
{:else}
<div class=" min-w-full markdown-prose">
<Markdown
id={message.id}
content={message.content}
/>{#if message.created_at !== message.updated_at}<span class="text-gray-500 text-[10px]"
>(edited)</span
>{/if}
</div>
{#if (message?.reactions ?? []).length > 0}
<div>
<div class="flex items-center flex-wrap gap-y-1.5 gap-1 mt-1 mb-2">
{#each message.reactions as reaction}
<Tooltip content={`:${reaction.name}:`}>
<button
class="flex items-center gap-1.5 transition rounded-xl px-2 py-1 cursor-pointer {reaction.user_ids.includes(
$user.id
)
? ' bg-blue-300/10 outline outline-blue-500/50 outline-1'
: 'bg-gray-300/10 dark:bg-gray-500/10 hover:outline hover:outline-gray-700/30 dark:hover:outline-gray-300/30 hover:outline-1'}"
on:click={() => {
onReaction(reaction.name);
}}
>
{#if $shortCodesToEmojis[reaction.name]}
<img
src="/assets/emojis/{$shortCodesToEmojis[
reaction.name
].toLowerCase()}.svg"
alt={reaction.name}
class=" size-4"
loading="lazy"
/>
{:else}
<div>
{reaction.name}
</div>
{/if}
{#if reaction.user_ids.length > 0}
<div class="text-xs font-medium text-gray-500 dark:text-gray-400">
{reaction.user_ids?.length}
</div>
{/if}
</button>
</Tooltip>
{/each}
<ReactionPicker
onSubmit={(name) => {
onReaction(name);
}}
>
<Tooltip content={$i18n.t('Add Reaction')}>
<div
class="flex items-center gap-1.5 bg-gray-500/10 hover:outline hover:outline-gray-700/30 dark:hover:outline-gray-300/30 hover:outline-1 transition rounded-xl px-1 py-1 cursor-pointer text-gray-500 dark:text-gray-400"
>
<FaceSmile />
</div>
</Tooltip>
</ReactionPicker>
</div>
</div>
{/if}
{#if !thread && message.reply_count > 0}
<div class="flex items-center gap-1.5 -mt-0.5 mb-1.5">
<button
class="flex items-center text-xs py-1 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 transition"
on:click={() => {
onThread(message.id);
}}
>
<span class="font-medium mr-1">
{$i18n.t('{{COUNT}} Replies', { COUNT: message.reply_count })}</span
><span>
{' - '}{$i18n.t('Last reply')}
{dayjs.unix(message.latest_reply_at / 1000000000).fromNow()}</span
>
<span class="ml-1">
<ChevronRight className="size-2.5" strokeWidth="3" />
</span>
<!-- {$i18n.t('View Replies')} -->
</button>
</div>
{/if}
{/if}
</div>
</div>
</div>
{/if}

View File

@ -0,0 +1,85 @@
<script lang="ts">
import { DropdownMenu } from 'bits-ui';
import { createEventDispatcher } from 'svelte';
import { flyAndScale } from '$lib/utils/transitions';
import { WEBUI_BASE_URL } from '$lib/constants';
import { activeUserIds } from '$lib/stores';
export let side = 'right';
export let align = 'top';
export let user = null;
let show = false;
const dispatch = createEventDispatcher();
</script>
<DropdownMenu.Root
bind:open={show}
closeFocus={false}
onOpenChange={(state) => {
dispatch('change', state);
}}
typeahead={false}
>
<DropdownMenu.Trigger>
<slot />
</DropdownMenu.Trigger>
<slot name="content">
<DropdownMenu.Content
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 $activeUserIds.includes(user.id)}
<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"> 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"> Away </span>
</div>
{/if}
</div>
</div>
</div>
{/if}
</DropdownMenu.Content>
</slot>
</DropdownMenu.Root>

View File

@ -0,0 +1,166 @@
<script lang="ts">
import { DropdownMenu } from 'bits-ui';
import { flyAndScale } from '$lib/utils/transitions';
import emojiGroups from '$lib/emoji-groups.json';
import emojiShortCodes from '$lib/emoji-shortcodes.json';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import VirtualList from '@sveltejs/svelte-virtual-list';
export let onClose = () => {};
export let onSubmit = (name) => {};
export let side = 'top';
export let align = 'start';
export let user = null;
let show = false;
let emojis = emojiShortCodes;
let search = '';
let flattenedEmojis = [];
let emojiRows = [];
// Reactive statement to filter the emojis based on search query
$: {
if (search) {
emojis = Object.keys(emojiShortCodes).reduce((acc, key) => {
if (key.includes(search)) {
acc[key] = emojiShortCodes[key];
} else {
if (Array.isArray(emojiShortCodes[key])) {
const filtered = emojiShortCodes[key].filter((emoji) => emoji.includes(search));
if (filtered.length) {
acc[key] = filtered;
}
} else {
if (emojiShortCodes[key].includes(search)) {
acc[key] = emojiShortCodes[key];
}
}
}
return acc;
}, {});
} else {
emojis = emojiShortCodes;
}
}
// Flatten emoji groups and group them into rows of 8 for virtual scrolling
$: {
flattenedEmojis = [];
Object.keys(emojiGroups).forEach((group) => {
const groupEmojis = emojiGroups[group].filter((emoji) => emojis[emoji]);
if (groupEmojis.length > 0) {
flattenedEmojis.push({ type: 'group', label: group });
flattenedEmojis.push(
...groupEmojis.map((emoji) => ({
type: 'emoji',
name: emoji,
shortCodes:
typeof emojiShortCodes[emoji] === 'string'
? [emojiShortCodes[emoji]]
: emojiShortCodes[emoji]
}))
);
}
});
// Group emojis into rows of 8
emojiRows = [];
let currentRow = [];
flattenedEmojis.forEach((item) => {
if (item.type === 'emoji') {
currentRow.push(item);
if (currentRow.length === 8) {
emojiRows.push(currentRow);
currentRow = [];
}
} else if (item.type === 'group') {
if (currentRow.length > 0) {
emojiRows.push(currentRow); // Push the remaining row
currentRow = [];
}
emojiRows.push([item]); // Add the group label as a separate row
}
});
if (currentRow.length > 0) {
emojiRows.push(currentRow); // Push the final row
}
}
const ROW_HEIGHT = 48; // Approximate height for a row with multiple emojis
// Handle emoji selection
function selectEmoji(emoji) {
const selectedCode = emoji.shortCodes[0];
onSubmit(selectedCode);
show = false;
}
</script>
<DropdownMenu.Root
bind:open={show}
closeFocus={false}
onOpenChange={(state) => {
if (!state) {
search = '';
onClose();
}
}}
typeahead={false}
>
<DropdownMenu.Trigger>
<slot />
</DropdownMenu.Trigger>
<DropdownMenu.Content
class="max-w-full w-80 bg-gray-50 dark:bg-gray-850 rounded-lg z-[9999] shadow-lg dark:text-white"
sideOffset={8}
{side}
{align}
transition={flyAndScale}
>
<div class="mb-1 px-3 pt-2 pb-2">
<input
type="text"
class="w-full text-sm bg-transparent outline-none"
placeholder="Search all emojis"
bind:value={search}
/>
</div>
<!-- Virtualized Emoji List -->
<div class="w-full flex justify-start h-96 overflow-y-auto px-3 pb-3 text-sm">
{#if emojiRows.length === 0}
<div class="text-center text-xs text-gray-500 dark:text-gray-400">No results</div>
{:else}
<div class="w-full flex ml-0.5">
<VirtualList rowHeight={ROW_HEIGHT} items={emojiRows} height={384} let:item>
<div class="w-full">
{#if item.length === 1 && item[0].type === 'group'}
<!-- Render group header -->
<div class="text-xs font-medium mb-2 text-gray-500 dark:text-gray-400">
{item[0].label}
</div>
{:else}
<!-- Render emojis in a row -->
<div class="flex items-center gap-1.5 w-full">
{#each item as emojiItem}
<Tooltip
content={emojiItem.shortCodes.map((code) => `:${code}:`).join(', ')}
placement="top"
>
<button
class="p-1.5 rounded-lg cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-700 transition"
on:click={() => selectEmoji(emojiItem)}
>
<img
src="/assets/emojis/{emojiItem.name.toLowerCase()}.svg"
alt={emojiItem.name}
class="size-5"
loading="lazy"
/>
</button>
</Tooltip>
{/each}
</div>
{/if}
</div>
</VirtualList>
</div>
{/if}
</div>
</DropdownMenu.Content>
</DropdownMenu.Root>

View File

@ -0,0 +1,86 @@
<script lang="ts">
import { getContext } from 'svelte';
import { toast } from 'svelte-sonner';
import { showArchivedChats, showSidebar, user } from '$lib/stores';
import { slide } from 'svelte/transition';
import { page } from '$app/stores';
import UserMenu from '$lib/components/layout/Sidebar/UserMenu.svelte';
import MenuLines from '../icons/MenuLines.svelte';
import PencilSquare from '../icons/PencilSquare.svelte';
const i18n = getContext('i18n');
export let channel;
</script>
<div class="sticky top-0 z-30 w-full px-1.5 py-1.5 -mb-8 flex items-center">
<div
class=" bg-gradient-to-b via-50% from-white via-white to-transparent dark:from-gray-900 dark:via-gray-900 dark:to-transparent pointer-events-none absolute inset-0 -bottom-7 z-[-1] blur"
></div>
<div class=" flex max-w-full w-full mx-auto px-1 pt-0.5 bg-transparent">
<div class="flex items-center w-full max-w-full">
<div
class="{$showSidebar
? 'md:hidden'
: ''} mr-1 self-start flex flex-none items-center text-gray-600 dark:text-gray-400"
>
<button
id="sidebar-toggle-button"
class="cursor-pointer px-2 py-2 flex rounded-xl hover:bg-gray-50 dark:hover:bg-gray-850 transition"
on:click={() => {
showSidebar.set(!$showSidebar);
}}
aria-label="Toggle Sidebar"
>
<div class=" m-auto self-center">
<MenuLines />
</div>
</button>
</div>
<div
class="flex-1 overflow-hidden max-w-full py-0.5
{$showSidebar ? 'ml-1' : ''}
"
>
{#if channel}
<div class="line-clamp-1 capitalize font-medium font-primary text-lg">
{channel.name}
</div>
{/if}
</div>
<div class="self-start flex flex-none items-center text-gray-600 dark:text-gray-400">
{#if $user !== undefined}
<UserMenu
className="max-w-[200px]"
role={$user.role}
on:show={(e) => {
if (e.detail === 'archived-chat') {
showArchivedChats.set(true);
}
}}
>
<button
class="select-none flex rounded-xl p-1.5 w-full hover:bg-gray-50 dark:hover:bg-gray-850 transition"
aria-label="User Menu"
>
<div class=" self-center">
<img
src={$user.profile_image_url}
class="size-6 object-cover rounded-full"
alt="User profile"
draggable="false"
/>
</div>
</button>
</UserMenu>
{/if}
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,204 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { socket, user } from '$lib/stores';
import { getChannelThreadMessages, sendMessage } from '$lib/apis/channels';
import XMark from '$lib/components/icons/XMark.svelte';
import MessageInput from './MessageInput.svelte';
import Messages from './Messages.svelte';
import { onDestroy, onMount, tick } from 'svelte';
import { toast } from 'svelte-sonner';
export let threadId = null;
export let channel = null;
export let onClose = () => {};
let messages = null;
let top = false;
let typingUsers = [];
let typingUsersTimeout = {};
let messagesContainerElement = null;
$: if (threadId) {
initHandler();
}
const scrollToBottom = () => {
messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight;
};
const initHandler = async () => {
messages = null;
top = false;
typingUsers = [];
typingUsersTimeout = {};
if (channel) {
messages = await getChannelThreadMessages(localStorage.token, channel.id, threadId);
if (messages.length < 50) {
top = true;
}
await tick();
scrollToBottom();
} else {
goto('/');
}
};
const channelEventHandler = async (event) => {
console.log(event);
if (event.channel_id === channel.id) {
const type = event?.data?.type ?? null;
const data = event?.data?.data ?? null;
if (type === 'message') {
if ((data?.parent_id ?? null) === threadId) {
if (messages) {
messages = [data, ...messages];
if (typingUsers.find((user) => user.id === event.user.id)) {
typingUsers = typingUsers.filter((user) => user.id !== event.user.id);
}
}
}
} else if (type === 'message:update') {
if (messages) {
const idx = messages.findIndex((message) => message.id === data.id);
if (idx !== -1) {
messages[idx] = data;
}
}
} else if (type === 'message:delete') {
if (messages) {
messages = messages.filter((message) => message.id !== data.id);
}
} else if (type.includes('message:reaction')) {
if (messages) {
const idx = messages.findIndex((message) => message.id === data.id);
if (idx !== -1) {
messages[idx] = data;
}
}
} else if (type === 'typing' && event.message_id === threadId) {
if (event.user.id === $user.id) {
return;
}
typingUsers = data.typing
? [
...typingUsers,
...(typingUsers.find((user) => user.id === event.user.id)
? []
: [
{
id: event.user.id,
name: event.user.name
}
])
]
: typingUsers.filter((user) => user.id !== event.user.id);
if (typingUsersTimeout[event.user.id]) {
clearTimeout(typingUsersTimeout[event.user.id]);
}
typingUsersTimeout[event.user.id] = setTimeout(() => {
typingUsers = typingUsers.filter((user) => user.id !== event.user.id);
}, 5000);
}
}
};
const submitHandler = async ({ content, data }) => {
if (!content) {
return;
}
const res = await sendMessage(localStorage.token, channel.id, {
parent_id: threadId,
content: content,
data: data
}).catch((error) => {
toast.error(error);
return null;
});
};
const onChange = async () => {
$socket?.emit('channel-events', {
channel_id: channel.id,
message_id: threadId,
data: {
type: 'typing',
data: {
typing: true
}
}
});
};
onMount(() => {
$socket?.on('channel-events', channelEventHandler);
});
onDestroy(() => {
$socket?.off('channel-events', channelEventHandler);
});
</script>
{#if channel}
<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=" font-medium text-lg">Thread</div>
<div>
<button
class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 p-2"
on:click={() => {
onClose();
}}
>
<XMark />
</button>
</div>
</div>
<div class=" max-h-full w-full overflow-y-auto pt-3" bind:this={messagesContainerElement}>
<Messages
id={threadId}
{channel}
{messages}
{top}
thread={true}
onLoad={async () => {
const newMessages = await getChannelThreadMessages(
localStorage.token,
channel.id,
threadId,
messages.length
);
messages = [...messages, ...newMessages];
if (newMessages.length < 50) {
top = true;
return;
}
}}
/>
<div class=" pb-[1rem]">
<MessageInput id={threadId} {typingUsers} {onChange} onSubmit={submitHandler} />
</div>
</div>
</div>
{/if}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,339 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import DOMPurify from 'dompurify';
import { marked } from 'marked';
import { getContext, tick } from 'svelte';
const i18n = getContext('i18n');
import { chatCompletion } from '$lib/apis/openai';
import ChatBubble from '$lib/components/icons/ChatBubble.svelte';
import LightBlub from '$lib/components/icons/LightBlub.svelte';
import Markdown from '../Messages/Markdown.svelte';
import Skeleton from '../Messages/Skeleton.svelte';
export let id = '';
export let model = null;
export let messages = [];
export let onAdd = () => {};
let floatingInput = false;
let selectedText = '';
let floatingInputValue = '';
let prompt = '';
let responseContent = null;
let responseDone = false;
const autoScroll = async () => {
// Scroll to bottom only if the scroll is at the bottom give 50px buffer
const responseContainer = document.getElementById('response-container');
if (
responseContainer.scrollHeight - responseContainer.clientHeight <=
responseContainer.scrollTop + 50
) {
responseContainer.scrollTop = responseContainer.scrollHeight;
}
};
const askHandler = async () => {
if (!model) {
toast.error('Model not selected');
return;
}
prompt = `${floatingInputValue}\n\`\`\`\n${selectedText}\n\`\`\``;
floatingInputValue = '';
responseContent = '';
const [res, controller] = await chatCompletion(localStorage.token, {
model: model,
messages: [
...messages,
{
role: 'user',
content: prompt
}
].map((message) => ({
role: message.role,
content: message.content
})),
stream: true // Enable streaming
});
if (res && res.ok) {
const reader = res.body.getReader();
const decoder = new TextDecoder();
const processStream = async () => {
while (true) {
// Read data chunks from the response stream
const { done, value } = await reader.read();
if (done) {
break;
}
// Decode the received chunk
const chunk = decoder.decode(value, { stream: true });
// Process lines within the chunk
const lines = chunk.split('\n').filter((line) => line.trim() !== '');
for (const line of lines) {
if (line.startsWith('data: ')) {
if (line.startsWith('data: [DONE]')) {
responseDone = true;
await tick();
autoScroll();
continue;
} else {
// Parse the JSON chunk
try {
const data = JSON.parse(line.slice(6));
// Append the `content` field from the "choices" object
if (data.choices && data.choices[0]?.delta?.content) {
responseContent += data.choices[0].delta.content;
autoScroll();
}
} catch (e) {
console.error(e);
}
}
}
}
}
};
// Process the stream in the background
await processStream();
} else {
toast.error('An error occurred while fetching the explanation');
}
};
const explainHandler = async () => {
if (!model) {
toast.error('Model not selected');
return;
}
prompt = `Explain this section to me in more detail\n\n\`\`\`\n${selectedText}\n\`\`\``;
responseContent = '';
const [res, controller] = await chatCompletion(localStorage.token, {
model: model,
messages: [
...messages,
{
role: 'user',
content: prompt
}
].map((message) => ({
role: message.role,
content: message.content
})),
stream: true // Enable streaming
});
if (res && res.ok) {
const reader = res.body.getReader();
const decoder = new TextDecoder();
const processStream = async () => {
while (true) {
// Read data chunks from the response stream
const { done, value } = await reader.read();
if (done) {
break;
}
// Decode the received chunk
const chunk = decoder.decode(value, { stream: true });
// Process lines within the chunk
const lines = chunk.split('\n').filter((line) => line.trim() !== '');
for (const line of lines) {
if (line.startsWith('data: ')) {
if (line.startsWith('data: [DONE]')) {
responseDone = true;
await tick();
autoScroll();
continue;
} else {
// Parse the JSON chunk
try {
const data = JSON.parse(line.slice(6));
// Append the `content` field from the "choices" object
if (data.choices && data.choices[0]?.delta?.content) {
responseContent += data.choices[0].delta.content;
autoScroll();
}
} catch (e) {
console.error(e);
}
}
}
}
}
};
// Process the stream in the background
await processStream();
} else {
toast.error('An error occurred while fetching the explanation');
}
};
const addHandler = async () => {
const messages = [
{
role: 'user',
content: prompt
},
{
role: 'assistant',
content: responseContent
}
];
onAdd({
modelId: model,
parentId: id,
messages: messages
});
};
export const closeHandler = () => {
responseContent = null;
responseDone = false;
floatingInput = false;
floatingInputValue = '';
};
</script>
<div
id={`floating-buttons-${id}`}
class="absolute rounded-lg mt-1 text-xs z-[9999]"
style="display: none"
>
{#if responseContent === null}
{#if !floatingInput}
<div
class="flex flex-row gap-0.5 shrink-0 p-1 bg-white dark:bg-gray-850 dark:text-gray-100 text-medium rounded-lg shadow-xl"
>
<button
class="px-1 hover:bg-gray-50 dark:hover:bg-gray-800 rounded flex items-center gap-1 min-w-fit"
on:click={async () => {
selectedText = window.getSelection().toString();
floatingInput = true;
await tick();
setTimeout(() => {
const input = document.getElementById('floating-message-input');
if (input) {
input.focus();
}
}, 0);
}}
>
<ChatBubble className="size-3 shrink-0" />
<div class="shrink-0">Ask</div>
</button>
<button
class="px-1 hover:bg-gray-50 dark:hover:bg-gray-800 rounded flex items-center gap-1 min-w-fit"
on:click={() => {
selectedText = window.getSelection().toString();
explainHandler();
}}
>
<LightBlub className="size-3 shrink-0" />
<div class="shrink-0">Explain</div>
</button>
</div>
{:else}
<div
class="py-1 flex dark:text-gray-100 bg-gray-50 dark:bg-gray-800 border dark:border-gray-800 w-72 rounded-full shadow-xl"
>
<input
type="text"
id="floating-message-input"
class="ml-5 bg-transparent outline-none w-full flex-1 text-sm"
placeholder={$i18n.t('Ask a question')}
bind:value={floatingInputValue}
on:keydown={(e) => {
if (e.key === 'Enter') {
askHandler();
}
}}
/>
<div class="ml-1 mr-2">
<button
class="{floatingInputValue !== ''
? 'bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100 '
: 'text-white bg-gray-200 dark:text-gray-900 dark:bg-gray-700 disabled'} transition rounded-full p-1.5 m-0.5 self-center"
on:click={() => {
askHandler();
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="size-4"
>
<path
fill-rule="evenodd"
d="M8 14a.75.75 0 0 1-.75-.75V4.56L4.03 7.78a.75.75 0 0 1-1.06-1.06l4.5-4.5a.75.75 0 0 1 1.06 0l4.5 4.5a.75.75 0 0 1-1.06 1.06L8.75 4.56v8.69A.75.75 0 0 1 8 14Z"
clip-rule="evenodd"
/>
</svg>
</button>
</div>
</div>
{/if}
{:else}
<div class="bg-white dark:bg-gray-850 dark:text-gray-100 rounded-xl shadow-xl w-80 max-w-full">
<div
class="bg-gray-50/50 dark:bg-gray-800 dark:text-gray-100 text-medium rounded-xl px-3.5 py-3 w-full"
>
<div class="font-medium">
<Markdown id={`${id}-float-prompt`} content={prompt} />
</div>
</div>
<div
class="bg-white dark:bg-gray-850 dark:text-gray-100 text-medium rounded-xl px-3.5 py-3 w-full"
>
<div class=" max-h-80 overflow-y-auto w-full markdown-prose-xs" id="response-container">
{#if responseContent.trim() === ''}
<Skeleton size="sm" />
{:else}
<Markdown id={`${id}-float-response`} content={responseContent} />
{/if}
{#if responseDone}
<div class="flex justify-end pt-3 text-sm font-medium">
<button
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
on:click={addHandler}
>
{$i18n.t('Add')}
</button>
</div>
{/if}
</div>
</div>
</div>
{/if}
</div>

View File

@ -1,6 +1,7 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { v4 as uuidv4 } from 'uuid';
import { createPicker, getAuthToken } from '$lib/utils/google-drive-picker';
import { onMount, tick, getContext, createEventDispatcher, onDestroy } from 'svelte';
const dispatch = createEventDispatcher();
@ -18,7 +19,7 @@
showControls
} from '$lib/stores';
import { blobToFile, createMessagesList, findWordIndices } from '$lib/utils';
import { blobToFile, compressImage, createMessagesList, findWordIndices } from '$lib/utils';
import { transcribeAudio } from '$lib/apis/audio';
import { uploadFile } from '$lib/apis/files';
import { getTools } from '$lib/apis/tools';
@ -36,17 +37,19 @@
import RichTextInput from '../common/RichTextInput.svelte';
import { generateAutoCompletion } from '$lib/apis';
import { error, text } from '@sveltejs/kit';
import Image from '../common/Image.svelte';
const i18n = getContext('i18n');
export let transparentBackground = false;
export let onChange: Function = () => {};
export let createMessagePair: Function;
export let stopResponse: Function;
export let autoScroll = false;
export let atSelectedModel: Model | undefined;
export let atSelectedModel: Model | undefined = undefined;
export let selectedModels: [''];
let selectedModelIds = [];
@ -60,6 +63,13 @@
export let selectedToolIds = [];
export let webSearchEnabled = false;
$: onChange({
prompt,
files,
selectedToolIds,
webSearchEnabled
});
let loaded = false;
let recording = false;
@ -88,14 +98,49 @@
});
};
const screenCaptureHandler = async () => {
try {
// Request screen media
const mediaStream = await navigator.mediaDevices.getDisplayMedia({
video: { cursor: 'never' },
audio: false
});
// Once the user selects a screen, temporarily create a video element
const video = document.createElement('video');
video.srcObject = mediaStream;
// Ensure the video loads without affecting user experience or tab switching
await video.play();
// Set up the canvas to match the video dimensions
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
// Grab a single frame from the video stream using the canvas
const context = canvas.getContext('2d');
context.drawImage(video, 0, 0, canvas.width, canvas.height);
// Stop all video tracks (stop screen sharing) after capturing the image
mediaStream.getTracks().forEach((track) => track.stop());
// bring back focus to this current tab, so that the user can see the screen capture
window.focus();
// Convert the canvas to a Base64 image URL
const imageUrl = canvas.toDataURL('image/png');
// Add the captured image to the files array to render it
files = [...files, { type: 'image', url: imageUrl }];
// Clean memory: Clear video srcObject
video.srcObject = null;
} catch (error) {
// Handle any errors (e.g., user cancels screen sharing)
console.error('Error capturing screen:', error);
}
};
const uploadFileHandler = async (file, fullContext: boolean = false) => {
if ($_user?.role !== 'admin' && !($_user?.permissions?.chat?.file_upload ?? true)) {
toast.error($i18n.t('You do not have permission to upload files.'));
return null;
}
console.log(file);
const tempItemId = uuidv4();
const fileItem = {
type: 'file',
@ -139,14 +184,22 @@
const uploadedFile = await uploadFile(localStorage.token, file);
if (uploadedFile) {
console.log('File upload completed:', {
id: uploadedFile.id,
name: fileItem.name,
collection: uploadedFile?.meta?.collection_name
});
if (uploadedFile.error) {
console.warn('File upload warning:', uploadedFile.error);
toast.warning(uploadedFile.error);
}
fileItem.status = 'uploaded';
fileItem.file = uploadedFile;
fileItem.id = uploadedFile.id;
fileItem.collection_name = uploadedFile?.meta?.collection_name;
fileItem.collection_name =
uploadedFile?.meta?.collection_name || uploadedFile?.collection_name;
fileItem.url = `${WEBUI_API_BASE_URL}/files/${uploadedFile.id}`;
files = files;
@ -160,13 +213,23 @@
};
const inputFilesHandler = async (inputFiles) => {
console.log('Input files handler called with:', inputFiles);
inputFiles.forEach((file) => {
console.log(file, file.name.split('.').at(-1));
console.log('Processing file:', {
name: file.name,
type: file.type,
size: file.size,
extension: file.name.split('.').at(-1)
});
if (
($config?.file?.max_size ?? null) !== null &&
file.size > ($config?.file?.max_size ?? 0) * 1024 * 1024
) {
console.log('File exceeds max size limit:', {
fileSize: file.size,
maxSize: ($config?.file?.max_size ?? 0) * 1024 * 1024
});
toast.error(
$i18n.t(`File size should not exceed {{maxSize}} MB.`, {
maxSize: $config?.file?.max_size
@ -181,12 +244,23 @@
return;
}
let reader = new FileReader();
reader.onload = (event) => {
reader.onload = async (event) => {
let imageUrl = event.target.result;
if ($settings?.imageCompression ?? false) {
const width = $settings?.imageCompressionSize?.width ?? null;
const height = $settings?.imageCompressionSize?.height ?? null;
if (width || height) {
imageUrl = await compressImage(imageUrl, width, height);
}
}
files = [
...files,
{
type: 'image',
url: `${event.target.result}`
url: `${imageUrl}`
}
];
};
@ -272,7 +346,11 @@
{#if loaded}
<div class="w-full font-primary">
<div class=" mx-auto inset-x-0 bg-transparent flex justify-center">
<div class="flex flex-col px-3 max-w-6xl w-full">
<div
class="flex flex-col px-3 {($settings?.widescreenMode ?? null)
? 'max-w-full'
: 'max-w-6xl'} w-full"
>
<div class="relative">
{#if autoScroll === false && history?.currentId}
<div
@ -410,7 +488,11 @@
</div>
<div class="{transparentBackground ? 'bg-transparent' : 'bg-white dark:bg-gray-900'} ">
<div class="max-w-6xl px-2.5 mx-auto inset-x-0">
<div
class="{($settings?.widescreenMode ?? null)
? 'max-w-full'
: 'max-w-6xl'} px-2.5 mx-auto inset-x-0"
>
<div class="">
<input
bind:this={filesInputElement}
@ -462,7 +544,7 @@
}}
>
<div
class="flex-1 flex flex-col relative w-full rounded-3xl px-1 bg-gray-50 dark:bg-gray-400/5 dark:text-gray-100"
class="flex-1 flex flex-col relative w-full rounded-3xl px-1 bg-gray-600/5 dark:bg-gray-400/5 dark:text-gray-100"
dir={$settings?.chatDirection ?? 'LTR'}
>
{#if files.length > 0}
@ -471,10 +553,10 @@
{#if file.type === 'image'}
<div class=" relative group">
<div class="relative">
<img
<Image
src={file.url}
alt="input"
class=" h-16 w-16 rounded-xl object-cover"
imageClassName=" h-16 w-16 rounded-xl object-cover"
/>
{#if atSelectedModel ? visionCapableModels.length === 0 : selectedModels.length !== visionCapableModels.length}
<Tooltip
@ -551,9 +633,30 @@
<InputMenu
bind:webSearchEnabled
bind:selectedToolIds
{screenCaptureHandler}
uploadFilesHandler={() => {
filesInputElement.click();
}}
uploadGoogleDriveHandler={async () => {
try {
const fileData = await createPicker();
if (fileData) {
const file = new File([fileData.blob], fileData.name, {
type: fileData.blob.type
});
await uploadFileHandler(file);
} else {
console.log('No file was selected from Google Drive');
}
} catch (error) {
console.error('Google Drive Error:', error);
toast.error(
$i18n.t('Error accessing Google Drive: {{error}}', {
error: error.message
})
);
}
}}
onClose={async () => {
await tick();
@ -626,6 +729,10 @@
const commandsContainerElement =
document.getElementById('commands-container');
if (e.key === 'Escape') {
stopResponse();
}
// Command/Ctrl + Shift + Enter to submit a message pair
if (isCtrlPressed && e.key === 'Enter' && e.shiftKey) {
e.preventDefault();
@ -651,14 +758,14 @@
...document.getElementsByClassName('user-message')
]?.at(-1);
const editButton = [
...document.getElementsByClassName('edit-user-message-button')
]?.at(-1);
if (userMessageElement) {
userMessageElement.scrollIntoView({ block: 'center' });
const editButton = [
...document.getElementsByClassName('edit-user-message-button')
]?.at(-1);
console.log(userMessageElement);
userMessageElement.scrollIntoView({ block: 'center' });
editButton?.click();
editButton?.click();
}
}
if (commandsContainerElement) {
@ -809,6 +916,9 @@
const commandsContainerElement =
document.getElementById('commands-container');
if (e.key === 'Escape') {
stopResponse();
}
// Command/Ctrl + Shift + Enter to submit a message pair
if (isCtrlPressed && e.key === 'Enter' && e.shiftKey) {
e.preventDefault();

View File

@ -217,7 +217,13 @@
const startRecording = async () => {
if ($showCallOverlay) {
if (!audioStream) {
audioStream = await navigator.mediaDevices.getUserMedia({ audio: true });
audioStream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
}
});
}
mediaRecorder = new MediaRecorder(audioStream);

View File

@ -3,7 +3,8 @@
import { flyAndScale } from '$lib/utils/transitions';
import { getContext, onMount, tick } from 'svelte';
import { config, user, tools as _tools } from '$lib/stores';
import { config, user, tools as _tools, mobile } from '$lib/stores';
import { createPicker } from '$lib/utils/google-drive-picker';
import { getTools } from '$lib/apis/tools';
import Dropdown from '$lib/components/common/Dropdown.svelte';
@ -12,10 +13,14 @@
import Switch from '$lib/components/common/Switch.svelte';
import GlobeAltSolid from '$lib/components/icons/GlobeAltSolid.svelte';
import WrenchSolid from '$lib/components/icons/WrenchSolid.svelte';
import CameraSolid from '$lib/components/icons/CameraSolid.svelte';
const i18n = getContext('i18n');
export let screenCaptureHandler: Function;
export let uploadFilesHandler: Function;
export let uploadGoogleDriveHandler: Function;
export let selectedToolIds: string[] = [];
export let webSearchEnabled: boolean;
@ -127,15 +132,64 @@
<hr class="border-black/5 dark:border-white/5 my-1" />
{/if}
{#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
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-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
on:click={() => {
uploadFilesHandler();
}}
>
<DocumentArrowUpSolid />
<div class=" line-clamp-1">{$i18n.t('Upload Files')}</div>
<div class="line-clamp-1">{$i18n.t('Upload Files')}</div>
</DropdownMenu.Item>
{#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={() => {
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}
</DropdownMenu.Content>
</div>
</Dropdown>

View File

@ -161,7 +161,13 @@
const startRecording = async () => {
startDurationCounter();
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
}
});
mediaRecorder = new MediaRecorder(stream);
mediaRecorder.onstart = () => {
console.log('Recording started');

View File

@ -16,6 +16,8 @@
const i18n = getContext('i18n');
export let className = 'h-full flex pt-8';
export let chatId = '';
export let user = $_user;
@ -33,6 +35,7 @@
export let chatActionHandler: Function;
export let showMessage: Function = () => {};
export let submitMessage: Function = () => {};
export let addMessages: Function = () => {};
export let readOnly = false;
@ -332,7 +335,7 @@
};
</script>
<div class="h-full flex pt-8">
<div class={className}>
{#if Object.keys(history?.messages ?? {}).length == 0}
<ChatPlaceholder
modelIds={selectedModels}
@ -404,6 +407,7 @@
{regenerateResponse}
{continueResponse}
{mergeResponses}
{addMessages}
{triggerScroll}
{readOnly}
/>

View File

@ -4,28 +4,28 @@
const dispatch = createEventDispatcher();
import Markdown from './Markdown.svelte';
import LightBlub from '$lib/components/icons/LightBlub.svelte';
import { chatId, mobile, showArtifacts, showControls, showOverview } from '$lib/stores';
import ChatBubble from '$lib/components/icons/ChatBubble.svelte';
import { stringify } from 'postcss';
import FloatingButtons from '../ContentRenderer/FloatingButtons.svelte';
import { createMessagesList } from '$lib/utils';
export let id;
export let content;
export let history;
export let model = null;
export let sources = null;
export let save = false;
export let floatingButtons = true;
export let onSourceClick = () => {};
export let onAddMessages = () => {};
let contentContainerElement;
let buttonsContainerElement;
let selectedText = '';
let floatingInput = false;
let floatingInputValue = '';
let floatingButtonsElement;
const updateButtonPosition = (event) => {
const buttonsContainerElement = document.getElementById(`floating-buttons-${id}`);
if (
!contentContainerElement?.contains(event.target) &&
!buttonsContainerElement?.contains(event.target)
@ -42,7 +42,6 @@
let selection = window.getSelection();
if (selection.toString().trim().length > 0) {
floatingInput = false;
const range = selection.getRangeAt(0);
const rect = range.getBoundingClientRect();
@ -56,11 +55,10 @@
buttonsContainerElement.style.display = 'block';
// Calculate space available on the right
const spaceOnRight = parentRect.width - (left + buttonsContainerElement.offsetWidth);
const spaceOnRight = parentRect.width - left;
let halfScreenWidth = $mobile ? window.innerWidth / 2 : window.innerWidth / 3;
let thirdScreenWidth = window.innerWidth / 3;
if (spaceOnRight < thirdScreenWidth) {
if (spaceOnRight < halfScreenWidth) {
const right = parentRect.right - rect.right;
buttonsContainerElement.style.right = `${right}px`;
buttonsContainerElement.style.left = 'auto'; // Reset left
@ -69,7 +67,6 @@
buttonsContainerElement.style.left = `${left}px`;
buttonsContainerElement.style.right = 'auto'; // Reset right
}
buttonsContainerElement.style.top = `${top + 5}px`; // +5 to add some spacing
}
} else {
@ -79,28 +76,14 @@
};
const closeFloatingButtons = () => {
const buttonsContainerElement = document.getElementById(`floating-buttons-${id}`);
if (buttonsContainerElement) {
buttonsContainerElement.style.display = 'none';
selectedText = '';
floatingInput = false;
floatingInputValue = '';
}
};
const selectAskHandler = () => {
dispatch('select', {
type: 'ask',
content: selectedText,
input: floatingInputValue
});
floatingInput = false;
floatingInputValue = '';
selectedText = '';
// Clear selection
window.getSelection().removeAllRanges();
buttonsContainerElement.style.display = 'none';
if (floatingButtonsElement) {
floatingButtonsElement.closeHandler();
}
};
const keydownHandler = (e) => {
@ -176,86 +159,16 @@
/>
</div>
{#if floatingButtons}
<div
bind:this={buttonsContainerElement}
class="absolute rounded-lg mt-1 text-xs z-[9999]"
style="display: none"
>
{#if !floatingInput}
<div
class="flex flex-row gap-0.5 shrink-0 p-1 bg-white dark:bg-gray-850 dark:text-gray-100 text-medium rounded-lg shadow-xl"
>
<button
class="px-1 hover:bg-gray-50 dark:hover:bg-gray-800 rounded flex items-center gap-1 min-w-fit"
on:click={() => {
selectedText = window.getSelection().toString();
floatingInput = true;
}}
>
<ChatBubble className="size-3 shrink-0" />
<div class="shrink-0">Ask</div>
</button>
<button
class="px-1 hover:bg-gray-50 dark:hover:bg-gray-800 rounded flex items-center gap-1 min-w-fit"
on:click={() => {
const selection = window.getSelection();
dispatch('select', {
type: 'explain',
content: selection.toString()
});
// Clear selection
selection.removeAllRanges();
buttonsContainerElement.style.display = 'none';
}}
>
<LightBlub className="size-3 shrink-0" />
<div class="shrink-0">Explain</div>
</button>
</div>
{:else}
<div
class="py-1 flex dark:text-gray-100 bg-gray-50 dark:bg-gray-800 border dark:border-gray-800 w-72 rounded-full shadow-xl"
>
<input
type="text"
class="ml-5 bg-transparent outline-none w-full flex-1 text-sm"
placeholder={$i18n.t('Ask a question')}
bind:value={floatingInputValue}
on:keydown={(e) => {
if (e.key === 'Enter') {
selectAskHandler();
}
}}
/>
<div class="ml-1 mr-2">
<button
class="{floatingInputValue !== ''
? 'bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100 '
: 'text-white bg-gray-200 dark:text-gray-900 dark:bg-gray-700 disabled'} transition rounded-full p-1.5 m-0.5 self-center"
on:click={() => {
selectAskHandler();
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="size-4"
>
<path
fill-rule="evenodd"
d="M8 14a.75.75 0 0 1-.75-.75V4.56L4.03 7.78a.75.75 0 0 1-1.06-1.06l4.5-4.5a.75.75 0 0 1 1.06 0l4.5 4.5a.75.75 0 0 1-1.06 1.06L8.75 4.56v8.69A.75.75 0 0 1 8 14Z"
clip-rule="evenodd"
/>
</svg>
</button>
</div>
</div>
{/if}
</div>
{#if floatingButtons && model}
<FloatingButtons
bind:this={floatingButtonsElement}
{id}
model={model?.id}
messages={createMessagesList(history, id)}
onAdd={({ modelId, parentId, messages }) => {
console.log(modelId, parentId, messages);
onAddMessages({ modelId, parentId, messages });
closeFloatingButtons();
}}
/>
{/if}

View File

@ -34,16 +34,27 @@
const exportTableToCSVHandler = (token, tokenIdx = 0) => {
console.log('Exporting table to CSV');
// Extract header row text and escape for CSV.
const header = token.header.map((headerCell) => `"${headerCell.text.replace(/"/g, '""')}"`);
// Create an array for rows that will hold the mapped cell text.
const rows = token.rows.map((row) =>
row.map((cell) => cell.tokens.map((token) => token.text).join(''))
row.map((cell) => {
// Map tokens into a single text
const cellContent = cell.tokens.map((token) => token.text).join('');
// Escape double quotes and wrap the content in double quotes
return `"${cellContent.replace(/"/g, '""')}"`;
})
);
// Combine header and rows
const csvData = [header, ...rows];
// Join the rows using commas (,) as the separator and rows using newline (\n).
const csvContent = rows.map((row) => row.join(',')).join('\n');
const csvContent = csvData.map((row) => row.join(',')).join('\n');
// Log rows and CSV content to ensure everything is correct.
console.log(rows);
console.log(csvData);
console.log(csvContent);
// To handle Unicode characters, you need to prefix the data with a BOM:
@ -100,7 +111,7 @@
{#each token.header as header, headerIdx}
<th
scope="col"
class="!px-3 !py-1.5 cursor-pointer select-none border border-gray-50 dark:border-gray-850"
class="!px-3 !py-1.5 cursor-pointer border border-gray-50 dark:border-gray-850"
style={token.align[headerIdx] ? '' : `text-align: ${token.align[headerIdx]}`}
>
<div class="flex flex-col gap-1.5 text-left">

View File

@ -35,6 +35,7 @@
export let continueResponse;
export let mergeResponses;
export let addMessages;
export let triggerScroll;
export let readOnly = false;
</script>
@ -79,6 +80,7 @@
{submitMessage}
{continueResponse}
{regenerateResponse}
{addMessages}
{readOnly}
/>
{:else}
@ -97,6 +99,7 @@
{regenerateResponse}
{mergeResponses}
{triggerScroll}
{addMessages}
{readOnly}
/>
{/if}

View File

@ -36,6 +36,8 @@
export let regenerateResponse: Function;
export let mergeResponses: Function;
export let addMessages: Function;
export let triggerScroll: Function;
const dispatch = createEventDispatcher();
@ -233,6 +235,7 @@
groupedMessageIdsIdx[modelIdx] =
groupedMessageIds[modelIdx].messageIds.length - 1;
}}
{addMessages}
{readOnly}
/>
{/if}

View File

@ -1,3 +1,3 @@
<div class=" self-center font-semibold mb-0.5 line-clamp-1 contents">
<div class=" self-center font-semibold mb-0.5 line-clamp-1 flex gap-1 items-center">
<slot />
</div>

View File

@ -52,12 +52,21 @@
}
const init = () => {
selectedReason = message?.annotation?.reason ?? '';
comment = message?.annotation?.comment ?? '';
if (!selectedReason) {
selectedReason = message?.annotation?.reason ?? '';
}
if (!comment) {
comment = message?.annotation?.comment ?? '';
}
tags = (message?.annotation?.tags ?? []).map((tag) => ({
name: tag
}));
detailedRating = message?.annotation?.details?.rating ?? null;
if (!detailedRating) {
detailedRating = message?.annotation?.details?.rating ?? null;
}
};
onMount(() => {

View File

@ -118,6 +118,8 @@
export let continueResponse: Function;
export let regenerateResponse: Function;
export let addMessages: Function;
export let isLastMessage = true;
export let readOnly = false;
@ -487,11 +489,15 @@
<div class="flex-auto w-0 pl-1">
<Name>
{model?.name ?? message.model}
<Tooltip content={model?.name ?? message.model} placement="top-start">
<span class="line-clamp-1">
{model?.name ?? message.model}
</span>
</Tooltip>
{#if message.timestamp}
<span
class=" self-center invisible group-hover:visible text-gray-400 text-xs font-medium uppercase ml-0.5 -mt-0.5"
class=" self-center shrink-0 translate-y-0.5 invisible group-hover:visible text-gray-400 text-xs font-medium uppercase ml-0.5 -mt-0.5"
>
{dayjs(message.timestamp * 1000).format($i18n.t('h:mm a'))}
</span>
@ -517,37 +523,76 @@
{@const status = (
message?.statusHistory ?? [...(message?.status ? [message?.status] : [])]
).at(-1)}
<div class="status-description flex items-center gap-2 py-0.5">
{#if status?.done === false}
<div class="">
<Spinner className="size-4" />
</div>
{/if}
{#if !status?.hidden}
<div class="status-description flex items-center gap-2 py-0.5">
{#if status?.done === false}
<div class="">
<Spinner className="size-4" />
</div>
{/if}
{#if status?.action === 'web_search' && status?.urls}
<WebSearchResults {status}>
{#if status?.action === 'web_search' && status?.urls}
<WebSearchResults {status}>
<div class="flex flex-col justify-center -space-y-0.5">
<div
class="{status?.done === false
? 'shimmer'
: ''} text-base line-clamp-1 text-wrap"
>
<!-- $i18n.t("Generating search query") -->
<!-- $i18n.t("No search query generated") -->
<!-- $i18n.t('Searched {{count}} sites') -->
{#if status?.description.includes('{{count}}')}
{$i18n.t(status?.description, {
count: status?.urls.length
})}
{:else if status?.description === 'No search query generated'}
{$i18n.t('No search query generated')}
{:else if status?.description === 'Generating search query'}
{$i18n.t('Generating search query')}
{:else}
{status?.description}
{/if}
</div>
</div>
</WebSearchResults>
{:else if status?.action === 'knowledge_search'}
<div class="flex flex-col justify-center -space-y-0.5">
<div
class="{status?.done === false
? 'shimmer'
: ''} text-base line-clamp-1 text-wrap"
: ''} text-gray-500 dark:text-gray-500 text-base line-clamp-1 text-wrap"
>
{status?.description}
{$i18n.t(`Searching Knowledge for "{{searchQuery}}"`, {
searchQuery: status.query
})}
</div>
</div>
</WebSearchResults>
{:else}
<div class="flex flex-col justify-center -space-y-0.5">
<div
class="{status?.done === false
? 'shimmer'
: ''} text-gray-500 dark:text-gray-500 text-base line-clamp-1 text-wrap"
>
{status?.description}
{:else}
<div class="flex flex-col justify-center -space-y-0.5">
<div
class="{status?.done === false
? 'shimmer'
: ''} text-gray-500 dark:text-gray-500 text-base line-clamp-1 text-wrap"
>
<!-- $i18n.t(`Searching "{{searchQuery}}"`) -->
{#if status?.description.includes('{{searchQuery}}')}
{$i18n.t(status?.description, {
searchQuery: status?.query
})}
{:else if status?.description === 'No search query generated'}
{$i18n.t('No search query generated')}
{:else if status?.description === 'Generating search query'}
{$i18n.t('Generating search query')}
{:else}
{status?.description}
{/if}
</div>
</div>
</div>
{/if}
</div>
{/if}
</div>
{/if}
{/if}
{#if edit === true}
@ -620,6 +665,7 @@
<!-- unless message.error === true which is legacy error handling, where the error message is stored in message.content -->
<ContentRenderer
id={message.id}
{history}
content={message.content}
sources={message.sources}
floatingButtons={message?.done}
@ -633,6 +679,9 @@
sourceButton.click();
}
}}
onAddMessages={({ modelId, parentId, messages }) => {
addMessages({ modelId, parentId, messages });
}}
on:update={(e) => {
const { raw, oldContent, newContent } = e.detail;

View File

@ -1,19 +1,35 @@
<script lang="ts">
export let size = 'md';
</script>
<div class="w-full mt-2 mb-2">
<div class="animate-pulse flex w-full">
<div class="space-y-2 w-full">
<div class="h-2 bg-gray-200 dark:bg-gray-600 rounded mr-14" />
<div class="{size === 'md' ? 'space-y-2' : 'space-y-1.5'} w-full">
<div class="{size === 'md' ? 'h-2' : 'h-1.5'} bg-gray-200 dark:bg-gray-600 rounded mr-14" />
<div class="grid grid-cols-3 gap-4">
<div class="h-2 bg-gray-200 dark:bg-gray-600 rounded col-span-2" />
<div class="h-2 bg-gray-200 dark:bg-gray-600 rounded col-span-1" />
<div
class="{size === 'md' ? 'h-2' : 'h-1.5'} bg-gray-200 dark:bg-gray-600 rounded col-span-2"
/>
<div
class="{size === 'md' ? 'h-2' : 'h-1.5'} bg-gray-200 dark:bg-gray-600 rounded col-span-1"
/>
</div>
<div class="grid grid-cols-4 gap-4">
<div class="h-2 bg-gray-200 dark:bg-gray-600 rounded col-span-1" />
<div class="h-2 bg-gray-200 dark:bg-gray-600 rounded col-span-2" />
<div class="h-2 bg-gray-200 dark:bg-gray-600 rounded col-span-1 mr-4" />
<div
class="{size === 'md' ? 'h-2' : 'h-1.5'} bg-gray-200 dark:bg-gray-600 rounded col-span-1"
/>
<div
class="{size === 'md' ? 'h-2' : 'h-1.5'} bg-gray-200 dark:bg-gray-600 rounded col-span-2"
/>
<div
class="{size === 'md'
? 'h-2'
: 'h-1.5'} bg-gray-200 dark:bg-gray-600 rounded col-span-1 mr-4"
/>
</div>
<div class="h-2 bg-gray-200 dark:bg-gray-600 rounded" />
<div class="{size === 'md' ? 'h-2' : 'h-1.5'} bg-gray-200 dark:bg-gray-600 rounded" />
</div>
</div>
</div>

View File

@ -0,0 +1,194 @@
<script lang="ts">
import { getContext } from 'svelte';
import { toast } from 'svelte-sonner';
import {
WEBUI_NAME,
chatId,
mobile,
settings,
showArchivedChats,
showControls,
showSidebar,
temporaryChatEnabled,
user
} from '$lib/stores';
import { slide } from 'svelte/transition';
import { page } from '$app/stores';
import ShareChatModal from '../chat/ShareChatModal.svelte';
import ModelSelector from '../chat/ModelSelector.svelte';
import Tooltip from '../common/Tooltip.svelte';
import Menu from '$lib/components/layout/Navbar/Menu.svelte';
import UserMenu from '$lib/components/layout/Sidebar/UserMenu.svelte';
import MenuLines from '../icons/MenuLines.svelte';
import AdjustmentsHorizontal from '../icons/AdjustmentsHorizontal.svelte';
import PencilSquare from '../icons/PencilSquare.svelte';
const i18n = getContext('i18n');
export let initNewChat: Function;
export let title: string = $WEBUI_NAME;
export let shareEnabled: boolean = false;
export let chat;
export let selectedModels;
export let showModelSelector = true;
let showShareChatModal = false;
let showDownloadChatModal = false;
</script>
<ShareChatModal bind:show={showShareChatModal} chatId={$chatId} />
<div class="sticky top-0 z-30 w-full px-1.5 py-1.5 -mb-8 flex items-center">
<div
class=" bg-gradient-to-b via-50% from-white via-white to-transparent dark:from-gray-900 dark:via-gray-900 dark:to-transparent pointer-events-none absolute inset-0 -bottom-7 z-[-1] blur"
></div>
<div class=" flex max-w-full w-full mx-auto px-1 pt-0.5 bg-transparent">
<div class="flex items-center w-full max-w-full">
<div
class="{$showSidebar
? 'md:hidden'
: ''} mr-1 self-start flex flex-none items-center text-gray-600 dark:text-gray-400"
>
<button
id="sidebar-toggle-button"
class="cursor-pointer px-2 py-2 flex rounded-xl hover:bg-gray-50 dark:hover:bg-gray-850 transition"
on:click={() => {
showSidebar.set(!$showSidebar);
}}
aria-label="Toggle Sidebar"
>
<div class=" m-auto self-center">
<MenuLines />
</div>
</button>
</div>
<div
class="flex-1 overflow-hidden max-w-full py-0.5
{$showSidebar ? 'ml-1' : ''}
"
>
{#if showModelSelector}
<ModelSelector bind:selectedModels showSetDefault={!shareEnabled} />
{/if}
</div>
<div class="self-start flex flex-none items-center text-gray-600 dark:text-gray-400">
<!-- <div class="md:hidden flex self-center w-[1px] h-5 mx-2 bg-gray-300 dark:bg-stone-700" /> -->
{#if shareEnabled && chat && (chat.id || $temporaryChatEnabled)}
<Menu
{chat}
{shareEnabled}
shareHandler={() => {
showShareChatModal = !showShareChatModal;
}}
downloadHandler={() => {
showDownloadChatModal = !showDownloadChatModal;
}}
>
<button
class="flex cursor-pointer px-2 py-2 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-850 transition"
id="chat-context-menu-button"
>
<div class=" m-auto self-center">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M6.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM12.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM18.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z"
/>
</svg>
</div>
</button>
</Menu>
{:else if $mobile}
<Tooltip content={$i18n.t('Controls')}>
<button
class=" flex cursor-pointer px-2 py-2 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-850 transition"
on:click={async () => {
await showControls.set(!$showControls);
}}
aria-label="Controls"
>
<div class=" m-auto self-center">
<AdjustmentsHorizontal className=" size-5" strokeWidth="0.5" />
</div>
</button>
</Tooltip>
{/if}
{#if !$mobile}
<Tooltip content={$i18n.t('Controls')}>
<button
class=" flex cursor-pointer px-2 py-2 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-850 transition"
on:click={async () => {
await showControls.set(!$showControls);
}}
aria-label="Controls"
>
<div class=" m-auto self-center">
<AdjustmentsHorizontal className=" size-5" strokeWidth="0.5" />
</div>
</button>
</Tooltip>
{/if}
<Tooltip content={$i18n.t('New Chat')}>
<button
id="new-chat-button"
class=" flex {$showSidebar
? 'md:hidden'
: ''} cursor-pointer px-2 py-2 rounded-xl text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-850 transition"
on:click={() => {
initNewChat();
}}
aria-label="New Chat"
>
<div class=" m-auto self-center">
<PencilSquare className=" size-5" strokeWidth="2" />
</div>
</button>
</Tooltip>
{#if $user !== undefined}
<UserMenu
className="max-w-[200px]"
role={$user.role}
on:show={(e) => {
if (e.detail === 'archived-chat') {
showArchivedChats.set(true);
}
}}
>
<button
class="select-none flex rounded-xl p-1.5 w-full hover:bg-gray-50 dark:hover:bg-gray-850 transition"
aria-label="User Menu"
>
<div class=" self-center">
<img
src={$user.profile_image_url}
class="size-6 object-cover rounded-full"
alt="User profile"
draggable="false"
/>
</div>
</button>
</UserMenu>
{/if}
</div>
</div>
</div>
</div>

View File

@ -106,6 +106,12 @@
<hr class=" dark:border-gray-850" />
<div class="mt-2 text-xs text-gray-400 dark:text-gray-500">
Emoji graphics provided by
<a href="https://github.com/jdecked/twemoji" target="_blank">Twemoji</a>, licensed under
<a href="https://creativecommons.org/licenses/by/4.0/" target="_blank">CC-BY 4.0</a>.
</div>
<div class="flex space-x-1">
<a href="https://discord.gg/5rJgQTnV4s" target="_blank">
<img
@ -129,6 +135,42 @@
</a>
</div>
<div>
<pre
class="text-xs text-gray-400 dark:text-gray-500">Copyright (c) {new Date().getFullYear()} <a
href="https://openwebui.com"
target="_blank"
class="underline">Open WebUI (Timothy Jaeryang Baek)</a
>
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
</pre>
</div>
<div class="mt-2 text-xs text-gray-400 dark:text-gray-500">
{#if !$WEBUI_NAME.includes('Open WebUI')}
<span class=" text-gray-500 dark:text-gray-300 font-medium">{$WEBUI_NAME}</span> -

View File

@ -2,7 +2,7 @@
import { toast } from 'svelte-sonner';
import { onMount, getContext } from 'svelte';
import { user, config } from '$lib/stores';
import { user, config, settings } from '$lib/stores';
import { updateUserProfile, createAPIKey, getAPIKey } from '$lib/apis/auths';
import UpdatePassword from './Account/UpdatePassword.svelte';
@ -16,10 +16,12 @@
const i18n = getContext('i18n');
export let saveHandler: Function;
export let saveSettings: Function;
let profileImageUrl = '';
let name = '';
let webhookUrl = '';
let showAPIKeys = false;
let JWTTokenCopied = false;
@ -35,6 +37,15 @@
}
}
if (webhookUrl !== $settings?.notifications?.webhook_url) {
saveSettings({
notifications: {
...$settings.notifications,
webhook_url: webhookUrl
}
});
}
const updatedUser = await updateUserProfile(localStorage.token, name, profileImageUrl).catch(
(error) => {
toast.error(error);
@ -60,6 +71,7 @@
onMount(async () => {
name = $user.name;
profileImageUrl = $user.profile_image_url;
webhookUrl = $settings?.notifications?.webhook_url ?? '';
APIKey = await getAPIKey(localStorage.token).catch((error) => {
console.log(error);
@ -226,6 +238,22 @@
</div>
</div>
</div>
<div class="pt-2">
<div class="flex flex-col w-full">
<div class=" mb-1 text-xs font-medium">{$i18n.t('Notification Webhook')}</div>
<div class="flex-1">
<input
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
type="url"
placeholder={$i18n.t('Enter your webhook URL')}
bind:value={webhookUrl}
required
/>
</div>
</div>
</div>
</div>
<div class="py-0.5">

View File

@ -60,9 +60,10 @@
<div class="flex-1">
<input
class="w-full rounded py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-none"
class="w-full bg-transparent dark:text-gray-300 outline-none placeholder:opacity-30"
type="password"
bind:value={currentPassword}
placeholder={$i18n.t('Enter your current password')}
autocomplete="current-password"
required
/>
@ -74,9 +75,10 @@
<div class="flex-1">
<input
class="w-full rounded py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-none"
class="w-full bg-transparent text-sm dark:text-gray-300 outline-none placeholder:opacity-30"
type="password"
bind:value={newPassword}
placeholder={$i18n.t('Enter your new password')}
autocomplete="new-password"
required
/>
@ -88,9 +90,10 @@
<div class="flex-1">
<input
class="w-full rounded py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-none"
class="w-full bg-transparent text-sm dark:text-gray-300 outline-none placeholder:opacity-30"
type="password"
bind:value={newPasswordConfirm}
placeholder={$i18n.t('Confirm your new password')}
autocomplete="off"
required
/>
@ -100,7 +103,7 @@
<div class="mt-3 flex justify-end">
<button
class=" px-4 py-2 text-xs bg-gray-800 hover:bg-gray-900 dark:bg-gray-700 dark:hover:bg-gray-800 text-gray-100 transition rounded-md font-medium"
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
>
{$i18n.t('Update password')}
</button>

View File

@ -32,11 +32,18 @@
let showUsername = false;
let richTextInput = true;
let largeTextAsFile = false;
let notificationSound = true;
let landingPageMode = '';
let chatBubble = true;
let chatDirection: 'LTR' | 'RTL' = 'LTR';
let imageCompression = false;
let imageCompressionSize = {
width: '',
height: ''
};
// Admin - Show Update Available Toast
let showUpdateToast = true;
let showChangelog = true;
@ -75,6 +82,11 @@
saveSettings({ showUpdateToast: showUpdateToast });
};
const toggleNotificationSound = async () => {
notificationSound = !notificationSound;
saveSettings({ notificationSound: notificationSound });
};
const toggleShowChangelog = async () => {
showChangelog = !showChangelog;
saveSettings({ showChangelog: showChangelog });
@ -95,6 +107,11 @@
saveSettings({ voiceInterruption: voiceInterruption });
};
const toggleImageCompression = async () => {
imageCompression = !imageCompression;
saveSettings({ imageCompression });
};
const toggleHapticFeedback = async () => {
hapticFeedback = !hapticFeedback;
saveSettings({ hapticFeedback: hapticFeedback });
@ -176,7 +193,8 @@
const updateInterfaceHandler = async () => {
saveSettings({
models: [defaultModelId]
models: [defaultModelId],
imageCompressionSize: imageCompressionSize
});
};
@ -204,8 +222,13 @@
chatDirection = $settings.chatDirection ?? 'LTR';
userLocation = $settings.userLocation ?? false;
notificationSound = $settings.notificationSound ?? true;
hapticFeedback = $settings.hapticFeedback ?? false;
imageCompression = $settings.imageCompression ?? false;
imageCompressionSize = $settings.imageCompressionSize ?? { width: '', height: '' };
defaultModelId = $settings?.models?.at(0) ?? '';
if ($config?.default_models) {
defaultModelId = $config.default_models.split(',')[0];
@ -356,6 +379,28 @@
</div>
</div>
<div>
<div class=" py-0.5 flex w-full justify-between">
<div class=" self-center text-xs">
{$i18n.t('Notification Sound')}
</div>
<button
class="p-1 px-3 text-xs flex rounded transition"
on:click={() => {
toggleNotificationSound();
}}
type="button"
>
{#if notificationSound === true}
<span class="ml-2 self-center">{$i18n.t('On')}</span>
{:else}
<span class="ml-2 self-center">{$i18n.t('Off')}</span>
{/if}
</button>
</div>
</div>
{#if $user.role === 'admin'}
<div>
<div class=" py-0.5 flex w-full justify-between">
@ -577,7 +622,7 @@
</div>
</div>
<div>
<!-- <div>
<div class=" py-0.5 flex w-full justify-between">
<div class=" self-center text-xs">
{$i18n.t('Fluidly stream large external response chunks')}
@ -597,7 +642,7 @@
{/if}
</button>
</div>
</div>
</div> -->
<div>
<div class=" py-0.5 flex w-full justify-between">
@ -662,6 +707,53 @@
</button>
</div>
</div>
<div class=" my-1.5 text-sm font-medium">{$i18n.t('File')}</div>
<div>
<div class=" py-0.5 flex w-full justify-between">
<div class=" self-center text-xs">{$i18n.t('Image Compression')}</div>
<button
class="p-1 px-3 text-xs flex rounded transition"
on:click={() => {
toggleImageCompression();
}}
type="button"
>
{#if imageCompression === true}
<span class="ml-2 self-center">{$i18n.t('On')}</span>
{:else}
<span class="ml-2 self-center">{$i18n.t('Off')}</span>
{/if}
</button>
</div>
</div>
{#if imageCompression}
<div>
<div class=" py-0.5 flex w-full justify-between text-xs">
<div class=" self-center text-xs">{$i18n.t('Image Max Compression Size')}</div>
<div>
<input
bind:value={imageCompressionSize.width}
type="number"
class="w-20 bg-transparent outline-none text-center"
min="0"
placeholder="Width"
/>x
<input
bind:value={imageCompressionSize.height}
type="number"
class="w-20 bg-transparent outline-none text-center"
min="0"
placeholder="Height"
/>
</div>
</div>
</div>
{/if}
</div>
</div>

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