mirror of
https://github.com/open-webui/open-webui.git
synced 2025-12-12 04:15:25 +00:00
commit
f966935d1d
311 changed files with 17008 additions and 7416 deletions
59
CHANGELOG.md
59
CHANGELOG.md
|
|
@ -5,6 +5,65 @@ All notable changes to this project will be documented in this file.
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [0.6.16] - 2025-07-14
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 🗂️ **Folders as Projects**: Organize your workflow with folder-based projects—set folder-level system prompts and associate custom knowledge, bringing seamless, context-rich management to teams and users handling multiple initiatives or clients.
|
||||||
|
- 📁 **Instant Folder-Based Chat Creation**: Start a new chat directly from any folder; just click and your new conversation is automatically embedded in the right project context—no more manual dragging or setup, saving time and eliminating mistakes.
|
||||||
|
- 🧩 **Prompt Variables with Automatic Input Modal**: Prompts containing variables now display a clean, auto-generated input modal that **autofocuses on the first field** for instant value entry—just select the prompt and fill in exactly what’s needed, reducing friction and guesswork.
|
||||||
|
- 🔡 **Variable Input Typing in Prompts**: Define input types for prompt variables (e.g., text, textarea, number, select, color, date, map and more), giving everyone a clearer and more precise prompt-building experience for advanced automation or workflows.
|
||||||
|
- 🚀 **Base Model List Caching**: Cache your base model list to speed up model selection and reduce repeated API calls; toggle this in Admin Settings > Connections for responsive model management even in large or multi-provider setups.
|
||||||
|
- ⏱️ **Configurable Model List Cache TTL**: Take control over model list caching with the new MODEL_LIST_CACHE_TTL environment variable. Set a custom cache duration in seconds to balance performance and freshness, reducing API requests in stable environments or ensuring rapid updates when models change frequently.
|
||||||
|
- 🔖 **Reference Notes as Knowledge or in Chats**: Use any note as knowledge for a model or folder, or reference it directly from chat—integrate living documentation into your Retrieval Augmented Generation workflows or discussions, bridging knowledge and action.
|
||||||
|
- 📝 **Chat Directly with Notes (Experimental)**: Ask questions about any note, and directly edit or update notes from within a chat—unlock direct AI-powered brainstorming, summarization, and cleanup, like having your own collaborative AI canvas.
|
||||||
|
- 🤝 **Collaborative Notes with Multi-User Editing**: Share notes with others and collaborate live—multiple users can edit a note in real-time, boosting cooperative knowledge building and workflow documentation.
|
||||||
|
- 🛡️ **Collaborative Note Permissions**: Control who can view or edit each note with robust sharing permissions, ensuring privacy or collaboration per your organizational needs.
|
||||||
|
- 🔗 **Copy Link to Notes**: Quickly copy and share direct links to notes for easier knowledge transfer within your team or external collaborators.
|
||||||
|
- 📋 **Task List Support in Notes**: Add, organize, and manage checklists or tasks inside your notes—plan projects, track to-dos, and keep everything actionable in a single space.
|
||||||
|
- 🧠 **AI-Generated Note Titles**: Instantly generate relevant and concise titles for your notes using AI—keep your knowledge library organized without tedious manual editing.
|
||||||
|
- 🔄 **Full Undo/Redo Support in Notes**: Effortlessly undo or redo your latest note changes—never fear mistakes or accidental edits while collaborating or writing.
|
||||||
|
- 📝 **Enhanced Note Word/Character Counter**: Always know the size of your notes with built-in counters, making it easier to adhere to length guidelines for shared or published content.
|
||||||
|
- 🖊️ **Floating & Bubble Formatting Menus in Note Editor**: Access text formatting tools through both a floating menu and an intuitive bubble menu directly in the note editor—making rich text editing faster, more discoverable, and easier than ever.
|
||||||
|
- ✍️ **Rich Text Prompt Insertion**: A new setting allows prompts to be inserted directly into the chat box as fully-formatted rich text, preserving Markdown elements like headings, lists, and bold text for a more intuitive and visually consistent editing experience.
|
||||||
|
- 🌐 **Configurable Database URL**: WebUI now supports more flexible database configuration via new environment variables—making deployment and scaling simpler across various infrastructure setups.
|
||||||
|
- 🎛️ **Completely Frontend-Handled File Upload in Temporary Chats**: When using temporary chats, file extraction now occurs fully in your browser with zero files sent to the backend, further strengthening privacy and giving you instant feedback.
|
||||||
|
- 🔄 **Enhanced Banner and Chat Command Visibility**: Banner handling and command feedback in chat are now clearer and more contextually visible, making alerts, suggestions, and automation easier to spot and interact with for all users.
|
||||||
|
- 📱 **Mobile Experience Polished**: The "new chat" button is back in mobile, plus core navigation and input controls have been smoothed out for better usability on phones and tablets.
|
||||||
|
- 📄 **OpenDocument Text (.odt) Support**: Seamlessly upload and process .odt files from open-source office suites like LibreOffice and OpenOffice, expanding your ability to build knowledge from a wider range of document formats.
|
||||||
|
- 📑 **Enhanced Markdown Document Splitting**: Improve knowledge retrieval from Markdown files with a new header-aware splitting strategy. This method intelligently chunks documents based on their header structure, preserving the original context and hierarchy for more accurate and relevant RAG results.
|
||||||
|
- 📚 **Full Context Mode for Knowledge Bases**: When adding a knowledge base to a folder or custom model, you can now toggle full context mode for the entire knowledge base. This bypasses the usual chunking and retrieval process, making it perfect for leaner knowledge bases.
|
||||||
|
- 🕰️ **Configurable OAuth Timeout**: Enhance login reliability by setting a custom timeout (OAUTH_TIMEOUT) for all OAuth providers (Google, Microsoft, GitHub, OIDC), preventing authentication failures on slow or restricted networks.
|
||||||
|
- 🎨 **Accessibility & High-Contrast Theme Enhancements**: Major accessibility overhaul with significant updates to the high-contrast theme. Improved focus visibility, ARIA labels, and semantic HTML ensure core components like the chat interface and model selector are fully compliant and readable for visually impaired users.
|
||||||
|
- ↕️ **Resizable System Prompt Fields**: Conveniently resize system prompt input fields to comfortably view and edit lengthy or complex instructions, improving the user experience for advanced model configuration.
|
||||||
|
- 🔧 **Granular Update Check Control**: Gain finer control over outbound connections with the new ENABLE_VERSION_UPDATE_CHECK flag. This allows administrators to disable version update checks independently of the full OFFLINE_MODE, perfect for environments with restricted internet access that still need to download embedding models.
|
||||||
|
- 🗃️ **Configurable Qdrant Collection Prefix**: Enhance scalability by setting a custom QDRANT_COLLECTION_PREFIX. This allows multiple Open WebUI instances to share a single Qdrant cluster safely, ensuring complete data isolation between separate deployments without conflicts.
|
||||||
|
- ⚙️ **Improved Default Database Performance**: Enhanced out-of-the-box performance by setting smarter database connection pooling defaults, reducing API response times for users on non-SQLite databases without requiring manual configuration.
|
||||||
|
- 🔧 **Configurable Redis Key Prefix**: Added support for the REDIS_KEY_PREFIX environment variable, allowing multiple Open WebUI instances to share a Redis cluster with isolated key namespaces for improved multi-tenancy.
|
||||||
|
- ➡️ **Forward User Context to Reranker**: For advanced RAG integrations, user information (ID, name, email, role) can now be forwarded as HTTP headers to external reranking services, enabling personalized results or per-user access control.
|
||||||
|
- ⚙️ **PGVector Connection Pooling**: Enhance performance and stability for PGVector-based RAG by enabling and configuring the database connection pool. New environment variables allow fine-tuning of pool size, timeout, and overflow settings to handle high-concurrency workloads efficiently.
|
||||||
|
- ⚙️ **General Backend Refactoring**: Extensive refactoring delivers a faster, more reliable, and robust backend experience—improving chat speed, model management, and day-to-day reliability.
|
||||||
|
- 🌍 **Expanded & Improved Translations**: Enjoy a more accessible and intuitive experience thanks to comprehensive updates and enhancements for Chinese (Simplified and Traditional), German, French, Catalan, Irish, and Spanish translations throughout the interface.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 🛠️ **Rich Text Input Stability and Performance**: Multiple improvements ensure faster, cleaner text editing and rendering with reduced glitches—especially supporting links, color picking, checkbox controls, and code blocks in notes and chats.
|
||||||
|
- 📷 **Seamless iPhone Image Uploads**: Effortlessly upload photos from iPhones and other devices using HEIC format—images are now correctly recognized and processed, eliminating compatibility issues.
|
||||||
|
- 🔄 **Audio MIME Type Registration**: Issues with audio file content types have been resolved, guaranteeing smoother, error-free uploads and playback for transcription or note attachments.
|
||||||
|
- 🖍️ **Input Commands Now Always Visible**: Input commands (like prompts or knowledge) dynamically adjust their height on small screens, ensuring nothing is cut off and every tool remains easily accessible.
|
||||||
|
- 🛑 **Tool Result Rendering**: Fixed display problems with tool results, providing fast, clear feedback when using external or internal tools.
|
||||||
|
- 🗂️ **Table Alignment in Markdown**: Markdown tables are now rendered and aligned as expected, keeping reports and documentation readable.
|
||||||
|
- 🖼️ **Thread Image Handling**: Fixed an issue where messages containing only images in threads weren’t displayed correctly.
|
||||||
|
- 🗝️ **Note Access Control Security**: Tightened access control logic for notes to guarantee that shared or collaborative notes respect all user permissions and privacy safeguards.
|
||||||
|
- 🧾 **Ollama API Compatibility**: Fixed model parameter naming in the API to ensure uninterrupted compatibility for all Ollama endpoints.
|
||||||
|
- 🛠️ **Detection for 'text/html' Files**: Files loaded with docling/tika are now reliably detected as the correct type, improving knowledge ingestion and document parsing.
|
||||||
|
- 🔐 **OAuth Login Stability**: Resolved a critical OAuth bug that caused login failures on subsequent attempts after logging out. The user session is now completely cleared on logout, ensuring reliable and secure authentication across all supported providers (Google, Microsoft, GitHub, OIDC).
|
||||||
|
- 🚪 **OAuth Logout and Redirect Reliability**: The OAuth logout process has been made more robust. Logout requests now correctly use proxy environment variables, ensuring they succeed in corporate networks. Additionally, the custom WEBUI_AUTH_SIGNOUT_REDIRECT_URL is now properly respected for all OAuth/OIDC configurations, ensuring a seamless sign-out experience.
|
||||||
|
- 📜 **Banner Newline Rendering**: Banners now correctly render newline characters, ensuring that multi-line announcements and messages are displayed with their intended formatting.
|
||||||
|
- ℹ️ **Consistent Model Description Rendering**: Model descriptions now render Markdown correctly in the main chat interface, matching the formatting seen in the model selection dropdown for a consistent user experience.
|
||||||
|
- 🔄 **Offline Mode Update Check Display**: Corrected a UI bug where the "Checking for Updates..." message would display indefinitely when the application was set to offline mode.
|
||||||
|
- 🛠️ **Tool Result Encoding**: Fixed a bug where tool calls returning non-ASCII characters would fail, ensuring robust handling of international text and special characters in tool outputs.
|
||||||
|
|
||||||
## [0.6.15] - 2025-06-16
|
## [0.6.15] - 2025-06-16
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,15 @@ from urllib.parse import urlparse
|
||||||
import requests
|
import requests
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import JSON, Column, DateTime, Integer, func
|
from sqlalchemy import JSON, Column, DateTime, Integer, func
|
||||||
|
from authlib.integrations.starlette_client import OAuth
|
||||||
|
|
||||||
|
|
||||||
from open_webui.env import (
|
from open_webui.env import (
|
||||||
DATA_DIR,
|
DATA_DIR,
|
||||||
DATABASE_URL,
|
DATABASE_URL,
|
||||||
ENV,
|
ENV,
|
||||||
REDIS_URL,
|
REDIS_URL,
|
||||||
|
REDIS_KEY_PREFIX,
|
||||||
REDIS_SENTINEL_HOSTS,
|
REDIS_SENTINEL_HOSTS,
|
||||||
REDIS_SENTINEL_PORT,
|
REDIS_SENTINEL_PORT,
|
||||||
FRONTEND_BUILD_DIR,
|
FRONTEND_BUILD_DIR,
|
||||||
|
|
@ -211,11 +214,16 @@ class PersistentConfig(Generic[T]):
|
||||||
class AppConfig:
|
class AppConfig:
|
||||||
_state: dict[str, PersistentConfig]
|
_state: dict[str, PersistentConfig]
|
||||||
_redis: Optional[redis.Redis] = None
|
_redis: Optional[redis.Redis] = None
|
||||||
|
_redis_key_prefix: str
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, redis_url: Optional[str] = None, redis_sentinels: Optional[list] = []
|
self,
|
||||||
|
redis_url: Optional[str] = None,
|
||||||
|
redis_sentinels: Optional[list] = [],
|
||||||
|
redis_key_prefix: str = "open-webui",
|
||||||
):
|
):
|
||||||
super().__setattr__("_state", {})
|
super().__setattr__("_state", {})
|
||||||
|
super().__setattr__("_redis_key_prefix", redis_key_prefix)
|
||||||
if redis_url:
|
if redis_url:
|
||||||
super().__setattr__(
|
super().__setattr__(
|
||||||
"_redis",
|
"_redis",
|
||||||
|
|
@ -230,7 +238,7 @@ class AppConfig:
|
||||||
self._state[key].save()
|
self._state[key].save()
|
||||||
|
|
||||||
if self._redis:
|
if self._redis:
|
||||||
redis_key = f"open-webui:config:{key}"
|
redis_key = f"{self._redis_key_prefix}:config:{key}"
|
||||||
self._redis.set(redis_key, json.dumps(self._state[key].value))
|
self._redis.set(redis_key, json.dumps(self._state[key].value))
|
||||||
|
|
||||||
def __getattr__(self, key):
|
def __getattr__(self, key):
|
||||||
|
|
@ -239,7 +247,7 @@ class AppConfig:
|
||||||
|
|
||||||
# If Redis is available, check for an updated value
|
# If Redis is available, check for an updated value
|
||||||
if self._redis:
|
if self._redis:
|
||||||
redis_key = f"open-webui:config:{key}"
|
redis_key = f"{self._redis_key_prefix}:config:{key}"
|
||||||
redis_value = self._redis.get(redis_key)
|
redis_value = self._redis.get(redis_key)
|
||||||
|
|
||||||
if redis_value is not None:
|
if redis_value is not None:
|
||||||
|
|
@ -431,6 +439,12 @@ OAUTH_SCOPES = PersistentConfig(
|
||||||
os.environ.get("OAUTH_SCOPES", "openid email profile"),
|
os.environ.get("OAUTH_SCOPES", "openid email profile"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
OAUTH_TIMEOUT = PersistentConfig(
|
||||||
|
"OAUTH_TIMEOUT",
|
||||||
|
"oauth.oidc.oauth_timeout",
|
||||||
|
os.environ.get("OAUTH_TIMEOUT", ""),
|
||||||
|
)
|
||||||
|
|
||||||
OAUTH_CODE_CHALLENGE_METHOD = PersistentConfig(
|
OAUTH_CODE_CHALLENGE_METHOD = PersistentConfig(
|
||||||
"OAUTH_CODE_CHALLENGE_METHOD",
|
"OAUTH_CODE_CHALLENGE_METHOD",
|
||||||
"oauth.oidc.code_challenge_method",
|
"oauth.oidc.code_challenge_method",
|
||||||
|
|
@ -534,13 +548,20 @@ def load_oauth_providers():
|
||||||
OAUTH_PROVIDERS.clear()
|
OAUTH_PROVIDERS.clear()
|
||||||
if GOOGLE_CLIENT_ID.value and GOOGLE_CLIENT_SECRET.value:
|
if GOOGLE_CLIENT_ID.value and GOOGLE_CLIENT_SECRET.value:
|
||||||
|
|
||||||
def google_oauth_register(client):
|
def google_oauth_register(client: OAuth):
|
||||||
client.register(
|
client.register(
|
||||||
name="google",
|
name="google",
|
||||||
client_id=GOOGLE_CLIENT_ID.value,
|
client_id=GOOGLE_CLIENT_ID.value,
|
||||||
client_secret=GOOGLE_CLIENT_SECRET.value,
|
client_secret=GOOGLE_CLIENT_SECRET.value,
|
||||||
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
|
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
|
||||||
client_kwargs={"scope": GOOGLE_OAUTH_SCOPE.value},
|
client_kwargs={
|
||||||
|
"scope": GOOGLE_OAUTH_SCOPE.value,
|
||||||
|
**(
|
||||||
|
{"timeout": int(OAUTH_TIMEOUT.value)}
|
||||||
|
if OAUTH_TIMEOUT.value
|
||||||
|
else {}
|
||||||
|
),
|
||||||
|
},
|
||||||
redirect_uri=GOOGLE_REDIRECT_URI.value,
|
redirect_uri=GOOGLE_REDIRECT_URI.value,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -555,7 +576,7 @@ def load_oauth_providers():
|
||||||
and MICROSOFT_CLIENT_TENANT_ID.value
|
and MICROSOFT_CLIENT_TENANT_ID.value
|
||||||
):
|
):
|
||||||
|
|
||||||
def microsoft_oauth_register(client):
|
def microsoft_oauth_register(client: OAuth):
|
||||||
client.register(
|
client.register(
|
||||||
name="microsoft",
|
name="microsoft",
|
||||||
client_id=MICROSOFT_CLIENT_ID.value,
|
client_id=MICROSOFT_CLIENT_ID.value,
|
||||||
|
|
@ -563,6 +584,11 @@ def load_oauth_providers():
|
||||||
server_metadata_url=f"{MICROSOFT_CLIENT_LOGIN_BASE_URL.value}/{MICROSOFT_CLIENT_TENANT_ID.value}/v2.0/.well-known/openid-configuration?appid={MICROSOFT_CLIENT_ID.value}",
|
server_metadata_url=f"{MICROSOFT_CLIENT_LOGIN_BASE_URL.value}/{MICROSOFT_CLIENT_TENANT_ID.value}/v2.0/.well-known/openid-configuration?appid={MICROSOFT_CLIENT_ID.value}",
|
||||||
client_kwargs={
|
client_kwargs={
|
||||||
"scope": MICROSOFT_OAUTH_SCOPE.value,
|
"scope": MICROSOFT_OAUTH_SCOPE.value,
|
||||||
|
**(
|
||||||
|
{"timeout": int(OAUTH_TIMEOUT.value)}
|
||||||
|
if OAUTH_TIMEOUT.value
|
||||||
|
else {}
|
||||||
|
),
|
||||||
},
|
},
|
||||||
redirect_uri=MICROSOFT_REDIRECT_URI.value,
|
redirect_uri=MICROSOFT_REDIRECT_URI.value,
|
||||||
)
|
)
|
||||||
|
|
@ -575,7 +601,7 @@ def load_oauth_providers():
|
||||||
|
|
||||||
if GITHUB_CLIENT_ID.value and GITHUB_CLIENT_SECRET.value:
|
if GITHUB_CLIENT_ID.value and GITHUB_CLIENT_SECRET.value:
|
||||||
|
|
||||||
def github_oauth_register(client):
|
def github_oauth_register(client: OAuth):
|
||||||
client.register(
|
client.register(
|
||||||
name="github",
|
name="github",
|
||||||
client_id=GITHUB_CLIENT_ID.value,
|
client_id=GITHUB_CLIENT_ID.value,
|
||||||
|
|
@ -584,7 +610,14 @@ def load_oauth_providers():
|
||||||
authorize_url="https://github.com/login/oauth/authorize",
|
authorize_url="https://github.com/login/oauth/authorize",
|
||||||
api_base_url="https://api.github.com",
|
api_base_url="https://api.github.com",
|
||||||
userinfo_endpoint="https://api.github.com/user",
|
userinfo_endpoint="https://api.github.com/user",
|
||||||
client_kwargs={"scope": GITHUB_CLIENT_SCOPE.value},
|
client_kwargs={
|
||||||
|
"scope": GITHUB_CLIENT_SCOPE.value,
|
||||||
|
**(
|
||||||
|
{"timeout": int(OAUTH_TIMEOUT.value)}
|
||||||
|
if OAUTH_TIMEOUT.value
|
||||||
|
else {}
|
||||||
|
),
|
||||||
|
},
|
||||||
redirect_uri=GITHUB_CLIENT_REDIRECT_URI.value,
|
redirect_uri=GITHUB_CLIENT_REDIRECT_URI.value,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -600,9 +633,12 @@ def load_oauth_providers():
|
||||||
and OPENID_PROVIDER_URL.value
|
and OPENID_PROVIDER_URL.value
|
||||||
):
|
):
|
||||||
|
|
||||||
def oidc_oauth_register(client):
|
def oidc_oauth_register(client: OAuth):
|
||||||
client_kwargs = {
|
client_kwargs = {
|
||||||
"scope": OAUTH_SCOPES.value,
|
"scope": OAUTH_SCOPES.value,
|
||||||
|
**(
|
||||||
|
{"timeout": int(OAUTH_TIMEOUT.value)} if OAUTH_TIMEOUT.value else {}
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|
@ -895,6 +931,18 @@ except Exception:
|
||||||
pass
|
pass
|
||||||
OPENAI_API_BASE_URL = "https://api.openai.com/v1"
|
OPENAI_API_BASE_URL = "https://api.openai.com/v1"
|
||||||
|
|
||||||
|
|
||||||
|
####################################
|
||||||
|
# MODELS
|
||||||
|
####################################
|
||||||
|
|
||||||
|
ENABLE_BASE_MODELS_CACHE = PersistentConfig(
|
||||||
|
"ENABLE_BASE_MODELS_CACHE",
|
||||||
|
"models.base_models_cache",
|
||||||
|
os.environ.get("ENABLE_BASE_MODELS_CACHE", "False").lower() == "true",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
####################################
|
####################################
|
||||||
# TOOL_SERVERS
|
# TOOL_SERVERS
|
||||||
####################################
|
####################################
|
||||||
|
|
@ -1794,11 +1842,12 @@ MILVUS_IVF_FLAT_NLIST = int(os.environ.get("MILVUS_IVF_FLAT_NLIST", "128"))
|
||||||
QDRANT_URI = os.environ.get("QDRANT_URI", None)
|
QDRANT_URI = os.environ.get("QDRANT_URI", None)
|
||||||
QDRANT_API_KEY = os.environ.get("QDRANT_API_KEY", None)
|
QDRANT_API_KEY = os.environ.get("QDRANT_API_KEY", None)
|
||||||
QDRANT_ON_DISK = os.environ.get("QDRANT_ON_DISK", "false").lower() == "true"
|
QDRANT_ON_DISK = os.environ.get("QDRANT_ON_DISK", "false").lower() == "true"
|
||||||
QDRANT_PREFER_GRPC = os.environ.get("QDRANT_PREFER_GRPC", "False").lower() == "true"
|
QDRANT_PREFER_GRPC = os.environ.get("QDRANT_PREFER_GRPC", "false").lower() == "true"
|
||||||
QDRANT_GRPC_PORT = int(os.environ.get("QDRANT_GRPC_PORT", "6334"))
|
QDRANT_GRPC_PORT = int(os.environ.get("QDRANT_GRPC_PORT", "6334"))
|
||||||
ENABLE_QDRANT_MULTITENANCY_MODE = (
|
ENABLE_QDRANT_MULTITENANCY_MODE = (
|
||||||
os.environ.get("ENABLE_QDRANT_MULTITENANCY_MODE", "false").lower() == "true"
|
os.environ.get("ENABLE_QDRANT_MULTITENANCY_MODE", "true").lower() == "true"
|
||||||
)
|
)
|
||||||
|
QDRANT_COLLECTION_PREFIX = os.environ.get("QDRANT_COLLECTION_PREFIX", "open-webui")
|
||||||
|
|
||||||
# OpenSearch
|
# OpenSearch
|
||||||
OPENSEARCH_URI = os.environ.get("OPENSEARCH_URI", "https://localhost:9200")
|
OPENSEARCH_URI = os.environ.get("OPENSEARCH_URI", "https://localhost:9200")
|
||||||
|
|
@ -1837,6 +1886,45 @@ if PGVECTOR_PGCRYPTO and not PGVECTOR_PGCRYPTO_KEY:
|
||||||
"PGVECTOR_PGCRYPTO is enabled but PGVECTOR_PGCRYPTO_KEY is not set. Please provide a valid key."
|
"PGVECTOR_PGCRYPTO is enabled but PGVECTOR_PGCRYPTO_KEY is not set. Please provide a valid key."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
PGVECTOR_POOL_SIZE = os.environ.get("PGVECTOR_POOL_SIZE", None)
|
||||||
|
|
||||||
|
if PGVECTOR_POOL_SIZE != None:
|
||||||
|
try:
|
||||||
|
PGVECTOR_POOL_SIZE = int(PGVECTOR_POOL_SIZE)
|
||||||
|
except Exception:
|
||||||
|
PGVECTOR_POOL_SIZE = None
|
||||||
|
|
||||||
|
PGVECTOR_POOL_MAX_OVERFLOW = os.environ.get("PGVECTOR_POOL_MAX_OVERFLOW", 0)
|
||||||
|
|
||||||
|
if PGVECTOR_POOL_MAX_OVERFLOW == "":
|
||||||
|
PGVECTOR_POOL_MAX_OVERFLOW = 0
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
PGVECTOR_POOL_MAX_OVERFLOW = int(PGVECTOR_POOL_MAX_OVERFLOW)
|
||||||
|
except Exception:
|
||||||
|
PGVECTOR_POOL_MAX_OVERFLOW = 0
|
||||||
|
|
||||||
|
PGVECTOR_POOL_TIMEOUT = os.environ.get("PGVECTOR_POOL_TIMEOUT", 30)
|
||||||
|
|
||||||
|
if PGVECTOR_POOL_TIMEOUT == "":
|
||||||
|
PGVECTOR_POOL_TIMEOUT = 30
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
PGVECTOR_POOL_TIMEOUT = int(PGVECTOR_POOL_TIMEOUT)
|
||||||
|
except Exception:
|
||||||
|
PGVECTOR_POOL_TIMEOUT = 30
|
||||||
|
|
||||||
|
PGVECTOR_POOL_RECYCLE = os.environ.get("PGVECTOR_POOL_RECYCLE", 3600)
|
||||||
|
|
||||||
|
if PGVECTOR_POOL_RECYCLE == "":
|
||||||
|
PGVECTOR_POOL_RECYCLE = 3600
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
PGVECTOR_POOL_RECYCLE = int(PGVECTOR_POOL_RECYCLE)
|
||||||
|
except Exception:
|
||||||
|
PGVECTOR_POOL_RECYCLE = 3600
|
||||||
|
|
||||||
# Pinecone
|
# Pinecone
|
||||||
PINECONE_API_KEY = os.environ.get("PINECONE_API_KEY", None)
|
PINECONE_API_KEY = os.environ.get("PINECONE_API_KEY", None)
|
||||||
PINECONE_ENVIRONMENT = os.environ.get("PINECONE_ENVIRONMENT", None)
|
PINECONE_ENVIRONMENT = os.environ.get("PINECONE_ENVIRONMENT", None)
|
||||||
|
|
|
||||||
|
|
@ -199,6 +199,7 @@ CHANGELOG = changelog_json
|
||||||
|
|
||||||
SAFE_MODE = os.environ.get("SAFE_MODE", "false").lower() == "true"
|
SAFE_MODE = os.environ.get("SAFE_MODE", "false").lower() == "true"
|
||||||
|
|
||||||
|
|
||||||
####################################
|
####################################
|
||||||
# ENABLE_FORWARD_USER_INFO_HEADERS
|
# ENABLE_FORWARD_USER_INFO_HEADERS
|
||||||
####################################
|
####################################
|
||||||
|
|
@ -266,21 +267,43 @@ else:
|
||||||
|
|
||||||
DATABASE_URL = os.environ.get("DATABASE_URL", f"sqlite:///{DATA_DIR}/webui.db")
|
DATABASE_URL = os.environ.get("DATABASE_URL", f"sqlite:///{DATA_DIR}/webui.db")
|
||||||
|
|
||||||
|
DATABASE_TYPE = os.environ.get("DATABASE_TYPE")
|
||||||
|
DATABASE_USER = os.environ.get("DATABASE_USER")
|
||||||
|
DATABASE_PASSWORD = os.environ.get("DATABASE_PASSWORD")
|
||||||
|
|
||||||
|
DATABASE_CRED = ""
|
||||||
|
if DATABASE_USER:
|
||||||
|
DATABASE_CRED += f"{DATABASE_USER}"
|
||||||
|
if DATABASE_PASSWORD:
|
||||||
|
DATABASE_CRED += f":{DATABASE_PASSWORD}"
|
||||||
|
if DATABASE_CRED:
|
||||||
|
DATABASE_CRED += "@"
|
||||||
|
|
||||||
|
|
||||||
|
DB_VARS = {
|
||||||
|
"db_type": DATABASE_TYPE,
|
||||||
|
"db_cred": DATABASE_CRED,
|
||||||
|
"db_host": os.environ.get("DATABASE_HOST"),
|
||||||
|
"db_port": os.environ.get("DATABASE_PORT"),
|
||||||
|
"db_name": os.environ.get("DATABASE_NAME"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if all(DB_VARS.values()):
|
||||||
|
DATABASE_URL = f"{DB_VARS['db_type']}://{DB_VARS['db_cred']}@{DB_VARS['db_host']}:{DB_VARS['db_port']}/{DB_VARS['db_name']}"
|
||||||
|
|
||||||
# Replace the postgres:// with postgresql://
|
# Replace the postgres:// with postgresql://
|
||||||
if "postgres://" in DATABASE_URL:
|
if "postgres://" in DATABASE_URL:
|
||||||
DATABASE_URL = DATABASE_URL.replace("postgres://", "postgresql://")
|
DATABASE_URL = DATABASE_URL.replace("postgres://", "postgresql://")
|
||||||
|
|
||||||
DATABASE_SCHEMA = os.environ.get("DATABASE_SCHEMA", None)
|
DATABASE_SCHEMA = os.environ.get("DATABASE_SCHEMA", None)
|
||||||
|
|
||||||
DATABASE_POOL_SIZE = os.environ.get("DATABASE_POOL_SIZE", 0)
|
DATABASE_POOL_SIZE = os.environ.get("DATABASE_POOL_SIZE", None)
|
||||||
|
|
||||||
if DATABASE_POOL_SIZE == "":
|
if DATABASE_POOL_SIZE != None:
|
||||||
DATABASE_POOL_SIZE = 0
|
|
||||||
else:
|
|
||||||
try:
|
try:
|
||||||
DATABASE_POOL_SIZE = int(DATABASE_POOL_SIZE)
|
DATABASE_POOL_SIZE = int(DATABASE_POOL_SIZE)
|
||||||
except Exception:
|
except Exception:
|
||||||
DATABASE_POOL_SIZE = 0
|
DATABASE_POOL_SIZE = None
|
||||||
|
|
||||||
DATABASE_POOL_MAX_OVERFLOW = os.environ.get("DATABASE_POOL_MAX_OVERFLOW", 0)
|
DATABASE_POOL_MAX_OVERFLOW = os.environ.get("DATABASE_POOL_MAX_OVERFLOW", 0)
|
||||||
|
|
||||||
|
|
@ -325,6 +348,7 @@ ENABLE_REALTIME_CHAT_SAVE = (
|
||||||
####################################
|
####################################
|
||||||
|
|
||||||
REDIS_URL = os.environ.get("REDIS_URL", "")
|
REDIS_URL = os.environ.get("REDIS_URL", "")
|
||||||
|
REDIS_KEY_PREFIX = os.environ.get("REDIS_KEY_PREFIX", "open-webui")
|
||||||
REDIS_SENTINEL_HOSTS = os.environ.get("REDIS_SENTINEL_HOSTS", "")
|
REDIS_SENTINEL_HOSTS = os.environ.get("REDIS_SENTINEL_HOSTS", "")
|
||||||
REDIS_SENTINEL_PORT = os.environ.get("REDIS_SENTINEL_PORT", "26379")
|
REDIS_SENTINEL_PORT = os.environ.get("REDIS_SENTINEL_PORT", "26379")
|
||||||
|
|
||||||
|
|
@ -396,10 +420,33 @@ WEBUI_AUTH_COOKIE_SECURE = (
|
||||||
if WEBUI_AUTH and WEBUI_SECRET_KEY == "":
|
if WEBUI_AUTH and WEBUI_SECRET_KEY == "":
|
||||||
raise ValueError(ERROR_MESSAGES.ENV_VAR_NOT_FOUND)
|
raise ValueError(ERROR_MESSAGES.ENV_VAR_NOT_FOUND)
|
||||||
|
|
||||||
|
ENABLE_COMPRESSION_MIDDLEWARE = (
|
||||||
|
os.environ.get("ENABLE_COMPRESSION_MIDDLEWARE", "True").lower() == "true"
|
||||||
|
)
|
||||||
|
|
||||||
|
####################################
|
||||||
|
# MODELS
|
||||||
|
####################################
|
||||||
|
|
||||||
|
MODELS_CACHE_TTL = os.environ.get("MODELS_CACHE_TTL", "1")
|
||||||
|
if MODELS_CACHE_TTL == "":
|
||||||
|
MODELS_CACHE_TTL = None
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
MODELS_CACHE_TTL = int(MODELS_CACHE_TTL)
|
||||||
|
except Exception:
|
||||||
|
MODELS_CACHE_TTL = 1
|
||||||
|
|
||||||
|
|
||||||
|
####################################
|
||||||
|
# WEBSOCKET SUPPORT
|
||||||
|
####################################
|
||||||
|
|
||||||
ENABLE_WEBSOCKET_SUPPORT = (
|
ENABLE_WEBSOCKET_SUPPORT = (
|
||||||
os.environ.get("ENABLE_WEBSOCKET_SUPPORT", "True").lower() == "true"
|
os.environ.get("ENABLE_WEBSOCKET_SUPPORT", "True").lower() == "true"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
WEBSOCKET_MANAGER = os.environ.get("WEBSOCKET_MANAGER", "")
|
WEBSOCKET_MANAGER = os.environ.get("WEBSOCKET_MANAGER", "")
|
||||||
|
|
||||||
WEBSOCKET_REDIS_URL = os.environ.get("WEBSOCKET_REDIS_URL", REDIS_URL)
|
WEBSOCKET_REDIS_URL = os.environ.get("WEBSOCKET_REDIS_URL", REDIS_URL)
|
||||||
|
|
@ -506,11 +553,14 @@ else:
|
||||||
# OFFLINE_MODE
|
# OFFLINE_MODE
|
||||||
####################################
|
####################################
|
||||||
|
|
||||||
|
ENABLE_VERSION_UPDATE_CHECK = (
|
||||||
|
os.environ.get("ENABLE_VERSION_UPDATE_CHECK", "true").lower() == "true"
|
||||||
|
)
|
||||||
OFFLINE_MODE = os.environ.get("OFFLINE_MODE", "false").lower() == "true"
|
OFFLINE_MODE = os.environ.get("OFFLINE_MODE", "false").lower() == "true"
|
||||||
|
|
||||||
if OFFLINE_MODE:
|
if OFFLINE_MODE:
|
||||||
os.environ["HF_HUB_OFFLINE"] = "1"
|
os.environ["HF_HUB_OFFLINE"] = "1"
|
||||||
|
ENABLE_VERSION_UPDATE_CHECK = False
|
||||||
|
|
||||||
####################################
|
####################################
|
||||||
# AUDIT LOGGING
|
# AUDIT LOGGING
|
||||||
|
|
@ -519,6 +569,14 @@ if OFFLINE_MODE:
|
||||||
AUDIT_LOGS_FILE_PATH = f"{DATA_DIR}/audit.log"
|
AUDIT_LOGS_FILE_PATH = f"{DATA_DIR}/audit.log"
|
||||||
# Maximum size of a file before rotating into a new log file
|
# Maximum size of a file before rotating into a new log file
|
||||||
AUDIT_LOG_FILE_ROTATION_SIZE = os.getenv("AUDIT_LOG_FILE_ROTATION_SIZE", "10MB")
|
AUDIT_LOG_FILE_ROTATION_SIZE = os.getenv("AUDIT_LOG_FILE_ROTATION_SIZE", "10MB")
|
||||||
|
|
||||||
|
# Comma separated list of logger names to use for audit logging
|
||||||
|
# Default is "uvicorn.access" which is the access log for Uvicorn
|
||||||
|
# You can add more logger names to this list if you want to capture more logs
|
||||||
|
AUDIT_UVICORN_LOGGER_NAMES = os.getenv(
|
||||||
|
"AUDIT_UVICORN_LOGGER_NAMES", "uvicorn.access"
|
||||||
|
).split(",")
|
||||||
|
|
||||||
# METADATA | REQUEST | REQUEST_RESPONSE
|
# METADATA | REQUEST | REQUEST_RESPONSE
|
||||||
AUDIT_LOG_LEVEL = os.getenv("AUDIT_LOG_LEVEL", "NONE").upper()
|
AUDIT_LOG_LEVEL = os.getenv("AUDIT_LOG_LEVEL", "NONE").upper()
|
||||||
try:
|
try:
|
||||||
|
|
@ -543,6 +601,9 @@ ENABLE_OTEL_METRICS = os.environ.get("ENABLE_OTEL_METRICS", "False").lower() ==
|
||||||
OTEL_EXPORTER_OTLP_ENDPOINT = os.environ.get(
|
OTEL_EXPORTER_OTLP_ENDPOINT = os.environ.get(
|
||||||
"OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317"
|
"OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317"
|
||||||
)
|
)
|
||||||
|
OTEL_EXPORTER_OTLP_INSECURE = (
|
||||||
|
os.environ.get("OTEL_EXPORTER_OTLP_INSECURE", "False").lower() == "true"
|
||||||
|
)
|
||||||
OTEL_SERVICE_NAME = os.environ.get("OTEL_SERVICE_NAME", "open-webui")
|
OTEL_SERVICE_NAME = os.environ.get("OTEL_SERVICE_NAME", "open-webui")
|
||||||
OTEL_RESOURCE_ATTRIBUTES = os.environ.get(
|
OTEL_RESOURCE_ATTRIBUTES = os.environ.get(
|
||||||
"OTEL_RESOURCE_ATTRIBUTES", ""
|
"OTEL_RESOURCE_ATTRIBUTES", ""
|
||||||
|
|
@ -550,6 +611,14 @@ OTEL_RESOURCE_ATTRIBUTES = os.environ.get(
|
||||||
OTEL_TRACES_SAMPLER = os.environ.get(
|
OTEL_TRACES_SAMPLER = os.environ.get(
|
||||||
"OTEL_TRACES_SAMPLER", "parentbased_always_on"
|
"OTEL_TRACES_SAMPLER", "parentbased_always_on"
|
||||||
).lower()
|
).lower()
|
||||||
|
OTEL_BASIC_AUTH_USERNAME = os.environ.get("OTEL_BASIC_AUTH_USERNAME", "")
|
||||||
|
OTEL_BASIC_AUTH_PASSWORD = os.environ.get("OTEL_BASIC_AUTH_PASSWORD", "")
|
||||||
|
|
||||||
|
|
||||||
|
OTEL_OTLP_SPAN_EXPORTER = os.environ.get(
|
||||||
|
"OTEL_OTLP_SPAN_EXPORTER", "grpc"
|
||||||
|
).lower() # grpc or http
|
||||||
|
|
||||||
|
|
||||||
####################################
|
####################################
|
||||||
# TOOLS/FUNCTIONS PIP OPTIONS
|
# TOOLS/FUNCTIONS PIP OPTIONS
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,9 @@ def handle_peewee_migration(DATABASE_URL):
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error(f"Failed to initialize the database connection: {e}")
|
log.error(f"Failed to initialize the database connection: {e}")
|
||||||
|
log.warning(
|
||||||
|
"Hint: If your database password contains special characters, you may need to URL-encode it."
|
||||||
|
)
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
# Properly closing the database connection
|
# Properly closing the database connection
|
||||||
|
|
@ -81,20 +84,23 @@ if "sqlite" in SQLALCHEMY_DATABASE_URL:
|
||||||
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
|
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
if DATABASE_POOL_SIZE > 0:
|
if isinstance(DATABASE_POOL_SIZE, int):
|
||||||
engine = create_engine(
|
if DATABASE_POOL_SIZE > 0:
|
||||||
SQLALCHEMY_DATABASE_URL,
|
engine = create_engine(
|
||||||
pool_size=DATABASE_POOL_SIZE,
|
SQLALCHEMY_DATABASE_URL,
|
||||||
max_overflow=DATABASE_POOL_MAX_OVERFLOW,
|
pool_size=DATABASE_POOL_SIZE,
|
||||||
pool_timeout=DATABASE_POOL_TIMEOUT,
|
max_overflow=DATABASE_POOL_MAX_OVERFLOW,
|
||||||
pool_recycle=DATABASE_POOL_RECYCLE,
|
pool_timeout=DATABASE_POOL_TIMEOUT,
|
||||||
pool_pre_ping=True,
|
pool_recycle=DATABASE_POOL_RECYCLE,
|
||||||
poolclass=QueuePool,
|
pool_pre_ping=True,
|
||||||
)
|
poolclass=QueuePool,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
engine = create_engine(
|
||||||
|
SQLALCHEMY_DATABASE_URL, pool_pre_ping=True, poolclass=NullPool
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
engine = create_engine(
|
engine = create_engine(SQLALCHEMY_DATABASE_URL, pool_pre_ping=True)
|
||||||
SQLALCHEMY_DATABASE_URL, pool_pre_ping=True, poolclass=NullPool
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
SessionLocal = sessionmaker(
|
SessionLocal = sessionmaker(
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,6 @@ from fastapi import (
|
||||||
applications,
|
applications,
|
||||||
BackgroundTasks,
|
BackgroundTasks,
|
||||||
)
|
)
|
||||||
|
|
||||||
from fastapi.openapi.docs import get_swagger_ui_html
|
from fastapi.openapi.docs import get_swagger_ui_html
|
||||||
|
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
@ -49,6 +48,7 @@ from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
from starlette.middleware.sessions import SessionMiddleware
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
from starlette.responses import Response, StreamingResponse
|
from starlette.responses import Response, StreamingResponse
|
||||||
|
from starlette.datastructures import Headers
|
||||||
|
|
||||||
|
|
||||||
from open_webui.utils import logger
|
from open_webui.utils import logger
|
||||||
|
|
@ -89,6 +89,7 @@ from open_webui.routers import (
|
||||||
|
|
||||||
from open_webui.routers.retrieval import (
|
from open_webui.routers.retrieval import (
|
||||||
get_embedding_function,
|
get_embedding_function,
|
||||||
|
get_reranking_function,
|
||||||
get_ef,
|
get_ef,
|
||||||
get_rf,
|
get_rf,
|
||||||
)
|
)
|
||||||
|
|
@ -116,6 +117,8 @@ from open_webui.config import (
|
||||||
OPENAI_API_CONFIGS,
|
OPENAI_API_CONFIGS,
|
||||||
# Direct Connections
|
# Direct Connections
|
||||||
ENABLE_DIRECT_CONNECTIONS,
|
ENABLE_DIRECT_CONNECTIONS,
|
||||||
|
# Model list
|
||||||
|
ENABLE_BASE_MODELS_CACHE,
|
||||||
# Thread pool size for FastAPI/AnyIO
|
# Thread pool size for FastAPI/AnyIO
|
||||||
THREAD_POOL_SIZE,
|
THREAD_POOL_SIZE,
|
||||||
# Tool Server Configs
|
# Tool Server Configs
|
||||||
|
|
@ -396,6 +399,7 @@ from open_webui.env import (
|
||||||
AUDIT_LOG_LEVEL,
|
AUDIT_LOG_LEVEL,
|
||||||
CHANGELOG,
|
CHANGELOG,
|
||||||
REDIS_URL,
|
REDIS_URL,
|
||||||
|
REDIS_KEY_PREFIX,
|
||||||
REDIS_SENTINEL_HOSTS,
|
REDIS_SENTINEL_HOSTS,
|
||||||
REDIS_SENTINEL_PORT,
|
REDIS_SENTINEL_PORT,
|
||||||
GLOBAL_LOG_LEVEL,
|
GLOBAL_LOG_LEVEL,
|
||||||
|
|
@ -411,10 +415,11 @@ from open_webui.env import (
|
||||||
WEBUI_AUTH_TRUSTED_EMAIL_HEADER,
|
WEBUI_AUTH_TRUSTED_EMAIL_HEADER,
|
||||||
WEBUI_AUTH_TRUSTED_NAME_HEADER,
|
WEBUI_AUTH_TRUSTED_NAME_HEADER,
|
||||||
WEBUI_AUTH_SIGNOUT_REDIRECT_URL,
|
WEBUI_AUTH_SIGNOUT_REDIRECT_URL,
|
||||||
|
ENABLE_COMPRESSION_MIDDLEWARE,
|
||||||
ENABLE_WEBSOCKET_SUPPORT,
|
ENABLE_WEBSOCKET_SUPPORT,
|
||||||
BYPASS_MODEL_ACCESS_CONTROL,
|
BYPASS_MODEL_ACCESS_CONTROL,
|
||||||
RESET_CONFIG_ON_START,
|
RESET_CONFIG_ON_START,
|
||||||
OFFLINE_MODE,
|
ENABLE_VERSION_UPDATE_CHECK,
|
||||||
ENABLE_OTEL,
|
ENABLE_OTEL,
|
||||||
EXTERNAL_PWA_MANIFEST_URL,
|
EXTERNAL_PWA_MANIFEST_URL,
|
||||||
AIOHTTP_CLIENT_SESSION_SSL,
|
AIOHTTP_CLIENT_SESSION_SSL,
|
||||||
|
|
@ -449,7 +454,7 @@ from open_webui.utils.redis import get_redis_connection
|
||||||
|
|
||||||
from open_webui.tasks import (
|
from open_webui.tasks import (
|
||||||
redis_task_command_listener,
|
redis_task_command_listener,
|
||||||
list_task_ids_by_chat_id,
|
list_task_ids_by_item_id,
|
||||||
stop_task,
|
stop_task,
|
||||||
list_tasks,
|
list_tasks,
|
||||||
) # Import from tasks.py
|
) # Import from tasks.py
|
||||||
|
|
@ -533,6 +538,27 @@ async def lifespan(app: FastAPI):
|
||||||
|
|
||||||
asyncio.create_task(periodic_usage_pool_cleanup())
|
asyncio.create_task(periodic_usage_pool_cleanup())
|
||||||
|
|
||||||
|
if app.state.config.ENABLE_BASE_MODELS_CACHE:
|
||||||
|
await get_all_models(
|
||||||
|
Request(
|
||||||
|
# Creating a mock request object to pass to get_all_models
|
||||||
|
{
|
||||||
|
"type": "http",
|
||||||
|
"asgi.version": "3.0",
|
||||||
|
"asgi.spec_version": "2.0",
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/internal",
|
||||||
|
"query_string": b"",
|
||||||
|
"headers": Headers({}).raw,
|
||||||
|
"client": ("127.0.0.1", 12345),
|
||||||
|
"server": ("127.0.0.1", 80),
|
||||||
|
"scheme": "http",
|
||||||
|
"app": app,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
if hasattr(app.state, "redis_task_command_listener"):
|
if hasattr(app.state, "redis_task_command_listener"):
|
||||||
|
|
@ -553,6 +579,7 @@ app.state.instance_id = None
|
||||||
app.state.config = AppConfig(
|
app.state.config = AppConfig(
|
||||||
redis_url=REDIS_URL,
|
redis_url=REDIS_URL,
|
||||||
redis_sentinels=get_sentinels_from_env(REDIS_SENTINEL_HOSTS, REDIS_SENTINEL_PORT),
|
redis_sentinels=get_sentinels_from_env(REDIS_SENTINEL_HOSTS, REDIS_SENTINEL_PORT),
|
||||||
|
redis_key_prefix=REDIS_KEY_PREFIX,
|
||||||
)
|
)
|
||||||
app.state.redis = None
|
app.state.redis = None
|
||||||
|
|
||||||
|
|
@ -615,6 +642,15 @@ app.state.TOOL_SERVERS = []
|
||||||
|
|
||||||
app.state.config.ENABLE_DIRECT_CONNECTIONS = ENABLE_DIRECT_CONNECTIONS
|
app.state.config.ENABLE_DIRECT_CONNECTIONS = ENABLE_DIRECT_CONNECTIONS
|
||||||
|
|
||||||
|
########################################
|
||||||
|
#
|
||||||
|
# MODELS
|
||||||
|
#
|
||||||
|
########################################
|
||||||
|
|
||||||
|
app.state.config.ENABLE_BASE_MODELS_CACHE = ENABLE_BASE_MODELS_CACHE
|
||||||
|
app.state.BASE_MODELS = []
|
||||||
|
|
||||||
########################################
|
########################################
|
||||||
#
|
#
|
||||||
# WEBUI
|
# WEBUI
|
||||||
|
|
@ -843,6 +879,7 @@ app.state.config.FIRECRAWL_API_KEY = FIRECRAWL_API_KEY
|
||||||
app.state.config.TAVILY_EXTRACT_DEPTH = TAVILY_EXTRACT_DEPTH
|
app.state.config.TAVILY_EXTRACT_DEPTH = TAVILY_EXTRACT_DEPTH
|
||||||
|
|
||||||
app.state.EMBEDDING_FUNCTION = None
|
app.state.EMBEDDING_FUNCTION = None
|
||||||
|
app.state.RERANKING_FUNCTION = None
|
||||||
app.state.ef = None
|
app.state.ef = None
|
||||||
app.state.rf = None
|
app.state.rf = None
|
||||||
|
|
||||||
|
|
@ -871,8 +908,8 @@ except Exception as e:
|
||||||
app.state.EMBEDDING_FUNCTION = get_embedding_function(
|
app.state.EMBEDDING_FUNCTION = get_embedding_function(
|
||||||
app.state.config.RAG_EMBEDDING_ENGINE,
|
app.state.config.RAG_EMBEDDING_ENGINE,
|
||||||
app.state.config.RAG_EMBEDDING_MODEL,
|
app.state.config.RAG_EMBEDDING_MODEL,
|
||||||
app.state.ef,
|
embedding_function=app.state.ef,
|
||||||
(
|
url=(
|
||||||
app.state.config.RAG_OPENAI_API_BASE_URL
|
app.state.config.RAG_OPENAI_API_BASE_URL
|
||||||
if app.state.config.RAG_EMBEDDING_ENGINE == "openai"
|
if app.state.config.RAG_EMBEDDING_ENGINE == "openai"
|
||||||
else (
|
else (
|
||||||
|
|
@ -881,7 +918,7 @@ app.state.EMBEDDING_FUNCTION = get_embedding_function(
|
||||||
else app.state.config.RAG_AZURE_OPENAI_BASE_URL
|
else app.state.config.RAG_AZURE_OPENAI_BASE_URL
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
(
|
key=(
|
||||||
app.state.config.RAG_OPENAI_API_KEY
|
app.state.config.RAG_OPENAI_API_KEY
|
||||||
if app.state.config.RAG_EMBEDDING_ENGINE == "openai"
|
if app.state.config.RAG_EMBEDDING_ENGINE == "openai"
|
||||||
else (
|
else (
|
||||||
|
|
@ -890,7 +927,7 @@ app.state.EMBEDDING_FUNCTION = get_embedding_function(
|
||||||
else app.state.config.RAG_AZURE_OPENAI_API_KEY
|
else app.state.config.RAG_AZURE_OPENAI_API_KEY
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
app.state.config.RAG_EMBEDDING_BATCH_SIZE,
|
embedding_batch_size=app.state.config.RAG_EMBEDDING_BATCH_SIZE,
|
||||||
azure_api_version=(
|
azure_api_version=(
|
||||||
app.state.config.RAG_AZURE_OPENAI_API_VERSION
|
app.state.config.RAG_AZURE_OPENAI_API_VERSION
|
||||||
if app.state.config.RAG_EMBEDDING_ENGINE == "azure_openai"
|
if app.state.config.RAG_EMBEDDING_ENGINE == "azure_openai"
|
||||||
|
|
@ -898,6 +935,12 @@ app.state.EMBEDDING_FUNCTION = get_embedding_function(
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
app.state.RERANKING_FUNCTION = get_reranking_function(
|
||||||
|
app.state.config.RAG_RERANKING_ENGINE,
|
||||||
|
app.state.config.RAG_RERANKING_MODEL,
|
||||||
|
reranking_function=app.state.rf,
|
||||||
|
)
|
||||||
|
|
||||||
########################################
|
########################################
|
||||||
#
|
#
|
||||||
# CODE EXECUTION
|
# CODE EXECUTION
|
||||||
|
|
@ -1072,7 +1115,9 @@ class RedirectMiddleware(BaseHTTPMiddleware):
|
||||||
|
|
||||||
|
|
||||||
# Add the middleware to the app
|
# Add the middleware to the app
|
||||||
app.add_middleware(CompressMiddleware)
|
if ENABLE_COMPRESSION_MIDDLEWARE:
|
||||||
|
app.add_middleware(CompressMiddleware)
|
||||||
|
|
||||||
app.add_middleware(RedirectMiddleware)
|
app.add_middleware(RedirectMiddleware)
|
||||||
app.add_middleware(SecurityHeadersMiddleware)
|
app.add_middleware(SecurityHeadersMiddleware)
|
||||||
|
|
||||||
|
|
@ -1188,7 +1233,9 @@ if audit_level != AuditLevel.NONE:
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/models")
|
@app.get("/api/models")
|
||||||
async def get_models(request: Request, user=Depends(get_verified_user)):
|
async def get_models(
|
||||||
|
request: Request, refresh: bool = False, user=Depends(get_verified_user)
|
||||||
|
):
|
||||||
def get_filtered_models(models, user):
|
def get_filtered_models(models, user):
|
||||||
filtered_models = []
|
filtered_models = []
|
||||||
for model in models:
|
for model in models:
|
||||||
|
|
@ -1212,7 +1259,7 @@ async def get_models(request: Request, user=Depends(get_verified_user)):
|
||||||
|
|
||||||
return filtered_models
|
return filtered_models
|
||||||
|
|
||||||
all_models = await get_all_models(request, user=user)
|
all_models = await get_all_models(request, refresh=refresh, user=user)
|
||||||
|
|
||||||
models = []
|
models = []
|
||||||
for model in all_models:
|
for model in all_models:
|
||||||
|
|
@ -1447,7 +1494,7 @@ async def stop_task_endpoint(
|
||||||
request: Request, task_id: str, user=Depends(get_verified_user)
|
request: Request, task_id: str, user=Depends(get_verified_user)
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
result = await stop_task(request, task_id)
|
result = await stop_task(request.app.state.redis, task_id)
|
||||||
return result
|
return result
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
|
||||||
|
|
@ -1455,7 +1502,7 @@ async def stop_task_endpoint(
|
||||||
|
|
||||||
@app.get("/api/tasks")
|
@app.get("/api/tasks")
|
||||||
async def list_tasks_endpoint(request: Request, user=Depends(get_verified_user)):
|
async def list_tasks_endpoint(request: Request, user=Depends(get_verified_user)):
|
||||||
return {"tasks": await list_tasks(request)}
|
return {"tasks": await list_tasks(request.app.state.redis)}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/tasks/chat/{chat_id}")
|
@app.get("/api/tasks/chat/{chat_id}")
|
||||||
|
|
@ -1466,9 +1513,9 @@ async def list_tasks_by_chat_id_endpoint(
|
||||||
if chat is None or chat.user_id != user.id:
|
if chat is None or chat.user_id != user.id:
|
||||||
return {"task_ids": []}
|
return {"task_ids": []}
|
||||||
|
|
||||||
task_ids = await list_task_ids_by_chat_id(request, chat_id)
|
task_ids = await list_task_ids_by_item_id(request.app.state.redis, chat_id)
|
||||||
|
|
||||||
print(f"Task IDs for chat {chat_id}: {task_ids}")
|
log.debug(f"Task IDs for chat {chat_id}: {task_ids}")
|
||||||
return {"task_ids": task_ids}
|
return {"task_ids": task_ids}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1521,6 +1568,7 @@ async def get_app_config(request: Request):
|
||||||
"enable_signup": app.state.config.ENABLE_SIGNUP,
|
"enable_signup": app.state.config.ENABLE_SIGNUP,
|
||||||
"enable_login_form": app.state.config.ENABLE_LOGIN_FORM,
|
"enable_login_form": app.state.config.ENABLE_LOGIN_FORM,
|
||||||
"enable_websocket": ENABLE_WEBSOCKET_SUPPORT,
|
"enable_websocket": ENABLE_WEBSOCKET_SUPPORT,
|
||||||
|
"enable_version_update_check": ENABLE_VERSION_UPDATE_CHECK,
|
||||||
**(
|
**(
|
||||||
{
|
{
|
||||||
"enable_direct_connections": app.state.config.ENABLE_DIRECT_CONNECTIONS,
|
"enable_direct_connections": app.state.config.ENABLE_DIRECT_CONNECTIONS,
|
||||||
|
|
@ -1594,7 +1642,19 @@ async def get_app_config(request: Request):
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
if user is not None
|
if user is not None
|
||||||
else {}
|
else {
|
||||||
|
**(
|
||||||
|
{
|
||||||
|
"metadata": {
|
||||||
|
"login_footer": app.state.LICENSE_METADATA.get(
|
||||||
|
"login_footer", ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if app.state.LICENSE_METADATA
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
}
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1626,9 +1686,9 @@ async def get_app_version():
|
||||||
|
|
||||||
@app.get("/api/version/updates")
|
@app.get("/api/version/updates")
|
||||||
async def get_app_latest_release_version(user=Depends(get_verified_user)):
|
async def get_app_latest_release_version(user=Depends(get_verified_user)):
|
||||||
if OFFLINE_MODE:
|
if not ENABLE_VERSION_UPDATE_CHECK:
|
||||||
log.debug(
|
log.debug(
|
||||||
f"Offline mode is enabled, returning current version as latest version"
|
f"Version update check is disabled, returning current version as latest version"
|
||||||
)
|
)
|
||||||
return {"current": VERSION, "latest": VERSION}
|
return {"current": VERSION, "latest": VERSION}
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
"""Update folder table data
|
||||||
|
|
||||||
|
Revision ID: d31026856c01
|
||||||
|
Revises: 9f0c9cd09105
|
||||||
|
Create Date: 2025-07-13 03:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "d31026856c01"
|
||||||
|
down_revision = "9f0c9cd09105"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.add_column("folder", sa.Column("data", sa.JSON(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_column("folder", "data")
|
||||||
|
|
@ -12,6 +12,7 @@ from pydantic import BaseModel, ConfigDict
|
||||||
from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON
|
from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON
|
||||||
from sqlalchemy import or_, func, select, and_, text
|
from sqlalchemy import or_, func, select, and_, text
|
||||||
from sqlalchemy.sql import exists
|
from sqlalchemy.sql import exists
|
||||||
|
from sqlalchemy.sql.expression import bindparam
|
||||||
|
|
||||||
####################
|
####################
|
||||||
# Chat DB Schema
|
# Chat DB Schema
|
||||||
|
|
@ -66,12 +67,14 @@ class ChatModel(BaseModel):
|
||||||
|
|
||||||
class ChatForm(BaseModel):
|
class ChatForm(BaseModel):
|
||||||
chat: dict
|
chat: dict
|
||||||
|
folder_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class ChatImportForm(ChatForm):
|
class ChatImportForm(ChatForm):
|
||||||
meta: Optional[dict] = {}
|
meta: Optional[dict] = {}
|
||||||
pinned: Optional[bool] = False
|
pinned: Optional[bool] = False
|
||||||
folder_id: Optional[str] = None
|
created_at: Optional[int] = None
|
||||||
|
updated_at: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
class ChatTitleMessagesForm(BaseModel):
|
class ChatTitleMessagesForm(BaseModel):
|
||||||
|
|
@ -118,6 +121,7 @@ class ChatTable:
|
||||||
else "New Chat"
|
else "New Chat"
|
||||||
),
|
),
|
||||||
"chat": form_data.chat,
|
"chat": form_data.chat,
|
||||||
|
"folder_id": form_data.folder_id,
|
||||||
"created_at": int(time.time()),
|
"created_at": int(time.time()),
|
||||||
"updated_at": int(time.time()),
|
"updated_at": int(time.time()),
|
||||||
}
|
}
|
||||||
|
|
@ -147,8 +151,16 @@ class ChatTable:
|
||||||
"meta": form_data.meta,
|
"meta": form_data.meta,
|
||||||
"pinned": form_data.pinned,
|
"pinned": form_data.pinned,
|
||||||
"folder_id": form_data.folder_id,
|
"folder_id": form_data.folder_id,
|
||||||
"created_at": int(time.time()),
|
"created_at": (
|
||||||
"updated_at": int(time.time()),
|
form_data.created_at
|
||||||
|
if form_data.created_at
|
||||||
|
else int(time.time())
|
||||||
|
),
|
||||||
|
"updated_at": (
|
||||||
|
form_data.updated_at
|
||||||
|
if form_data.updated_at
|
||||||
|
else int(time.time())
|
||||||
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -232,6 +244,10 @@ class ChatTable:
|
||||||
if chat is None:
|
if chat is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Sanitize message content for null characters before upserting
|
||||||
|
if isinstance(message.get("content"), str):
|
||||||
|
message["content"] = message["content"].replace("\x00", "")
|
||||||
|
|
||||||
chat = chat.chat
|
chat = chat.chat
|
||||||
history = chat.get("history", {})
|
history = chat.get("history", {})
|
||||||
|
|
||||||
|
|
@ -580,7 +596,7 @@ class ChatTable:
|
||||||
"""
|
"""
|
||||||
Filters chats based on a search query using Python, allowing pagination using skip and limit.
|
Filters chats based on a search query using Python, allowing pagination using skip and limit.
|
||||||
"""
|
"""
|
||||||
search_text = search_text.lower().strip()
|
search_text = search_text.replace("\u0000", "").lower().strip()
|
||||||
|
|
||||||
if not search_text:
|
if not search_text:
|
||||||
return self.get_chat_list_by_user_id(
|
return self.get_chat_list_by_user_id(
|
||||||
|
|
@ -614,21 +630,18 @@ class ChatTable:
|
||||||
dialect_name = db.bind.dialect.name
|
dialect_name = db.bind.dialect.name
|
||||||
if dialect_name == "sqlite":
|
if dialect_name == "sqlite":
|
||||||
# SQLite case: using JSON1 extension for JSON searching
|
# SQLite case: using JSON1 extension for JSON searching
|
||||||
|
sqlite_content_sql = (
|
||||||
|
"EXISTS ("
|
||||||
|
" SELECT 1 "
|
||||||
|
" FROM json_each(Chat.chat, '$.messages') AS message "
|
||||||
|
" WHERE LOWER(message.value->>'content') LIKE '%' || :content_key || '%'"
|
||||||
|
")"
|
||||||
|
)
|
||||||
|
sqlite_content_clause = text(sqlite_content_sql)
|
||||||
query = query.filter(
|
query = query.filter(
|
||||||
(
|
or_(
|
||||||
Chat.title.ilike(
|
Chat.title.ilike(bindparam("title_key")), sqlite_content_clause
|
||||||
f"%{search_text}%"
|
).params(title_key=f"%{search_text}%", content_key=search_text)
|
||||||
) # Case-insensitive search in title
|
|
||||||
| text(
|
|
||||||
"""
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM json_each(Chat.chat, '$.messages') AS message
|
|
||||||
WHERE LOWER(message.value->>'content') LIKE '%' || :search_text || '%'
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
).params(search_text=search_text)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if there are any tags to filter, it should have all the tags
|
# Check if there are any tags to filter, it should have all the tags
|
||||||
|
|
@ -663,21 +676,19 @@ class ChatTable:
|
||||||
|
|
||||||
elif dialect_name == "postgresql":
|
elif dialect_name == "postgresql":
|
||||||
# PostgreSQL relies on proper JSON query for search
|
# PostgreSQL relies on proper JSON query for search
|
||||||
|
postgres_content_sql = (
|
||||||
|
"EXISTS ("
|
||||||
|
" SELECT 1 "
|
||||||
|
" FROM json_array_elements(Chat.chat->'messages') AS message "
|
||||||
|
" WHERE LOWER(message->>'content') LIKE '%' || :content_key || '%'"
|
||||||
|
")"
|
||||||
|
)
|
||||||
|
postgres_content_clause = text(postgres_content_sql)
|
||||||
query = query.filter(
|
query = query.filter(
|
||||||
(
|
or_(
|
||||||
Chat.title.ilike(
|
Chat.title.ilike(bindparam("title_key")),
|
||||||
f"%{search_text}%"
|
postgres_content_clause,
|
||||||
) # Case-insensitive search in title
|
).params(title_key=f"%{search_text}%", content_key=search_text)
|
||||||
| text(
|
|
||||||
"""
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM json_array_elements(Chat.chat->'messages') AS message
|
|
||||||
WHERE LOWER(message->>'content') LIKE '%' || :search_text || '%'
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
).params(search_text=search_text)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if there are any tags to filter, it should have all the tags
|
# Check if there are any tags to filter, it should have all the tags
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ class Folder(Base):
|
||||||
name = Column(Text)
|
name = Column(Text)
|
||||||
items = Column(JSON, nullable=True)
|
items = Column(JSON, nullable=True)
|
||||||
meta = Column(JSON, nullable=True)
|
meta = Column(JSON, nullable=True)
|
||||||
|
data = Column(JSON, nullable=True)
|
||||||
is_expanded = Column(Boolean, default=False)
|
is_expanded = Column(Boolean, default=False)
|
||||||
created_at = Column(BigInteger)
|
created_at = Column(BigInteger)
|
||||||
updated_at = Column(BigInteger)
|
updated_at = Column(BigInteger)
|
||||||
|
|
@ -41,6 +42,7 @@ class FolderModel(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
items: Optional[dict] = None
|
items: Optional[dict] = None
|
||||||
meta: Optional[dict] = None
|
meta: Optional[dict] = None
|
||||||
|
data: Optional[dict] = None
|
||||||
is_expanded: bool = False
|
is_expanded: bool = False
|
||||||
created_at: int
|
created_at: int
|
||||||
updated_at: int
|
updated_at: int
|
||||||
|
|
@ -55,6 +57,7 @@ class FolderModel(BaseModel):
|
||||||
|
|
||||||
class FolderForm(BaseModel):
|
class FolderForm(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
|
data: Optional[dict] = None
|
||||||
model_config = ConfigDict(extra="allow")
|
model_config = ConfigDict(extra="allow")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -187,8 +190,8 @@ class FolderTable:
|
||||||
log.error(f"update_folder: {e}")
|
log.error(f"update_folder: {e}")
|
||||||
return
|
return
|
||||||
|
|
||||||
def update_folder_name_by_id_and_user_id(
|
def update_folder_by_id_and_user_id(
|
||||||
self, id: str, user_id: str, name: str
|
self, id: str, user_id: str, form_data: FolderForm
|
||||||
) -> Optional[FolderModel]:
|
) -> Optional[FolderModel]:
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
|
|
@ -197,16 +200,28 @@ class FolderTable:
|
||||||
if not folder:
|
if not folder:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
form_data = form_data.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
existing_folder = (
|
existing_folder = (
|
||||||
db.query(Folder)
|
db.query(Folder)
|
||||||
.filter_by(name=name, parent_id=folder.parent_id, user_id=user_id)
|
.filter_by(
|
||||||
|
name=form_data.get("name"),
|
||||||
|
parent_id=folder.parent_id,
|
||||||
|
user_id=user_id,
|
||||||
|
)
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
|
||||||
if existing_folder:
|
if existing_folder and existing_folder.id != id:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
folder.name = name
|
folder.name = form_data.get("name", folder.name)
|
||||||
|
if "data" in form_data:
|
||||||
|
folder.data = {
|
||||||
|
**(folder.data or {}),
|
||||||
|
**form_data["data"],
|
||||||
|
}
|
||||||
|
|
||||||
folder.updated_at = int(time.time())
|
folder.updated_at = int(time.time())
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,13 @@ class NoteForm(BaseModel):
|
||||||
access_control: Optional[dict] = None
|
access_control: Optional[dict] = None
|
||||||
|
|
||||||
|
|
||||||
|
class NoteUpdateForm(BaseModel):
|
||||||
|
title: Optional[str] = None
|
||||||
|
data: Optional[dict] = None
|
||||||
|
meta: Optional[dict] = None
|
||||||
|
access_control: Optional[dict] = None
|
||||||
|
|
||||||
|
|
||||||
class NoteUserResponse(NoteModel):
|
class NoteUserResponse(NoteModel):
|
||||||
user: Optional[UserResponse] = None
|
user: Optional[UserResponse] = None
|
||||||
|
|
||||||
|
|
@ -110,16 +117,26 @@ class NoteTable:
|
||||||
note = db.query(Note).filter(Note.id == id).first()
|
note = db.query(Note).filter(Note.id == id).first()
|
||||||
return NoteModel.model_validate(note) if note else None
|
return NoteModel.model_validate(note) if note else None
|
||||||
|
|
||||||
def update_note_by_id(self, id: str, form_data: NoteForm) -> Optional[NoteModel]:
|
def update_note_by_id(
|
||||||
|
self, id: str, form_data: NoteUpdateForm
|
||||||
|
) -> Optional[NoteModel]:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
note = db.query(Note).filter(Note.id == id).first()
|
note = db.query(Note).filter(Note.id == id).first()
|
||||||
if not note:
|
if not note:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
note.title = form_data.title
|
form_data = form_data.model_dump(exclude_unset=True)
|
||||||
note.data = form_data.data
|
|
||||||
note.meta = form_data.meta
|
if "title" in form_data:
|
||||||
note.access_control = form_data.access_control
|
note.title = form_data["title"]
|
||||||
|
if "data" in form_data:
|
||||||
|
note.data = {**note.data, **form_data["data"]}
|
||||||
|
if "meta" in form_data:
|
||||||
|
note.meta = {**note.meta, **form_data["meta"]}
|
||||||
|
|
||||||
|
if "access_control" in form_data:
|
||||||
|
note.access_control = form_data["access_control"]
|
||||||
|
|
||||||
note.updated_at = int(time.time_ns())
|
note.updated_at = int(time.time_ns())
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ from langchain_community.document_loaders import (
|
||||||
TextLoader,
|
TextLoader,
|
||||||
UnstructuredEPubLoader,
|
UnstructuredEPubLoader,
|
||||||
UnstructuredExcelLoader,
|
UnstructuredExcelLoader,
|
||||||
UnstructuredMarkdownLoader,
|
UnstructuredODTLoader,
|
||||||
UnstructuredPowerPointLoader,
|
UnstructuredPowerPointLoader,
|
||||||
UnstructuredRSTLoader,
|
UnstructuredRSTLoader,
|
||||||
UnstructuredXMLLoader,
|
UnstructuredXMLLoader,
|
||||||
|
|
@ -226,7 +226,10 @@ class Loader:
|
||||||
|
|
||||||
def _is_text_file(self, file_ext: str, file_content_type: str) -> bool:
|
def _is_text_file(self, file_ext: str, file_content_type: str) -> bool:
|
||||||
return file_ext in known_source_ext or (
|
return file_ext in known_source_ext or (
|
||||||
file_content_type and file_content_type.find("text/") >= 0
|
file_content_type
|
||||||
|
and file_content_type.find("text/") >= 0
|
||||||
|
# Avoid text/html files being detected as text
|
||||||
|
and not file_content_type.find("html") >= 0
|
||||||
)
|
)
|
||||||
|
|
||||||
def _get_loader(self, filename: str, file_content_type: str, file_path: str):
|
def _get_loader(self, filename: str, file_content_type: str, file_path: str):
|
||||||
|
|
@ -389,6 +392,8 @@ class Loader:
|
||||||
loader = UnstructuredPowerPointLoader(file_path)
|
loader = UnstructuredPowerPointLoader(file_path)
|
||||||
elif file_ext == "msg":
|
elif file_ext == "msg":
|
||||||
loader = OutlookMessageLoader(file_path)
|
loader = OutlookMessageLoader(file_path)
|
||||||
|
elif file_ext == "odt":
|
||||||
|
loader = UnstructuredODTLoader(file_path)
|
||||||
elif self._is_text_file(file_ext, file_content_type):
|
elif self._is_text_file(file_ext, file_content_type):
|
||||||
loader = TextLoader(file_path, autodetect_encoding=True)
|
loader = TextLoader(file_path, autodetect_encoding=True)
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -507,6 +507,7 @@ class MistralLoader:
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
headers={"User-Agent": "OpenWebUI-MistralLoader/2.0"},
|
headers={"User-Agent": "OpenWebUI-MistralLoader/2.0"},
|
||||||
raise_for_status=False, # We handle status codes manually
|
raise_for_status=False, # We handle status codes manually
|
||||||
|
trust_env=True,
|
||||||
) as session:
|
) as session:
|
||||||
yield session
|
yield session
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import logging
|
import logging
|
||||||
import requests
|
import requests
|
||||||
from typing import Optional, List, Tuple
|
from typing import Optional, List, Tuple
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
from open_webui.env import SRC_LOG_LEVELS
|
|
||||||
|
from open_webui.env import ENABLE_FORWARD_USER_INFO_HEADERS, SRC_LOG_LEVELS
|
||||||
from open_webui.retrieval.models.base_reranker import BaseReranker
|
from open_webui.retrieval.models.base_reranker import BaseReranker
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -21,7 +23,9 @@ class ExternalReranker(BaseReranker):
|
||||||
self.url = url
|
self.url = url
|
||||||
self.model = model
|
self.model = model
|
||||||
|
|
||||||
def predict(self, sentences: List[Tuple[str, str]]) -> Optional[List[float]]:
|
def predict(
|
||||||
|
self, sentences: List[Tuple[str, str]], user=None
|
||||||
|
) -> Optional[List[float]]:
|
||||||
query = sentences[0][0]
|
query = sentences[0][0]
|
||||||
docs = [i[1] for i in sentences]
|
docs = [i[1] for i in sentences]
|
||||||
|
|
||||||
|
|
@ -41,6 +45,16 @@ class ExternalReranker(BaseReranker):
|
||||||
headers={
|
headers={
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Authorization": f"Bearer {self.api_key}",
|
"Authorization": f"Bearer {self.api_key}",
|
||||||
|
**(
|
||||||
|
{
|
||||||
|
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||||
|
"X-OpenWebUI-User-Id": user.id,
|
||||||
|
"X-OpenWebUI-User-Email": user.email,
|
||||||
|
"X-OpenWebUI-User-Role": user.role,
|
||||||
|
}
|
||||||
|
if ENABLE_FORWARD_USER_INFO_HEADERS and user
|
||||||
|
else {}
|
||||||
|
),
|
||||||
},
|
},
|
||||||
json=payload,
|
json=payload,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import hashlib
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
from urllib.parse import quote
|
||||||
from huggingface_hub import snapshot_download
|
from huggingface_hub import snapshot_download
|
||||||
from langchain.retrievers import ContextualCompressionRetriever, EnsembleRetriever
|
from langchain.retrievers import ContextualCompressionRetriever, EnsembleRetriever
|
||||||
from langchain_community.retrievers import BM25Retriever
|
from langchain_community.retrievers import BM25Retriever
|
||||||
|
|
@ -17,8 +18,11 @@ from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
|
||||||
|
|
||||||
from open_webui.models.users import UserModel
|
from open_webui.models.users import UserModel
|
||||||
from open_webui.models.files import Files
|
from open_webui.models.files import Files
|
||||||
|
from open_webui.models.knowledge import Knowledges
|
||||||
|
from open_webui.models.notes import Notes
|
||||||
|
|
||||||
from open_webui.retrieval.vector.main import GetResult
|
from open_webui.retrieval.vector.main import GetResult
|
||||||
|
from open_webui.utils.access_control import has_access
|
||||||
|
|
||||||
|
|
||||||
from open_webui.env import (
|
from open_webui.env import (
|
||||||
|
|
@ -441,9 +445,20 @@ def get_embedding_function(
|
||||||
raise ValueError(f"Unknown embedding engine: {embedding_engine}")
|
raise ValueError(f"Unknown embedding engine: {embedding_engine}")
|
||||||
|
|
||||||
|
|
||||||
def get_sources_from_files(
|
def get_reranking_function(reranking_engine, reranking_model, reranking_function):
|
||||||
|
if reranking_function is None:
|
||||||
|
return None
|
||||||
|
if reranking_engine == "external":
|
||||||
|
return lambda sentences, user=None: reranking_function.predict(
|
||||||
|
sentences, user=user
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return lambda sentences, user=None: reranking_function.predict(sentences)
|
||||||
|
|
||||||
|
|
||||||
|
def get_sources_from_items(
|
||||||
request,
|
request,
|
||||||
files,
|
items,
|
||||||
queries,
|
queries,
|
||||||
embedding_function,
|
embedding_function,
|
||||||
k,
|
k,
|
||||||
|
|
@ -453,159 +468,210 @@ def get_sources_from_files(
|
||||||
hybrid_bm25_weight,
|
hybrid_bm25_weight,
|
||||||
hybrid_search,
|
hybrid_search,
|
||||||
full_context=False,
|
full_context=False,
|
||||||
|
user: Optional[UserModel] = None,
|
||||||
):
|
):
|
||||||
log.debug(
|
log.debug(
|
||||||
f"files: {files} {queries} {embedding_function} {reranking_function} {full_context}"
|
f"items: {items} {queries} {embedding_function} {reranking_function} {full_context}"
|
||||||
)
|
)
|
||||||
|
|
||||||
extracted_collections = []
|
extracted_collections = []
|
||||||
relevant_contexts = []
|
query_results = []
|
||||||
|
|
||||||
for file in files:
|
for item in items:
|
||||||
|
query_result = None
|
||||||
|
collection_names = []
|
||||||
|
|
||||||
context = None
|
if item.get("type") == "text":
|
||||||
if file.get("docs"):
|
# Raw Text
|
||||||
# BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL
|
# Used during temporary chat file uploads
|
||||||
context = {
|
|
||||||
"documents": [[doc.get("content") for doc in file.get("docs")]],
|
|
||||||
"metadatas": [[doc.get("metadata") for doc in file.get("docs")]],
|
|
||||||
}
|
|
||||||
elif file.get("context") == "full":
|
|
||||||
# Manual Full Mode Toggle
|
|
||||||
context = {
|
|
||||||
"documents": [[file.get("file").get("data", {}).get("content")]],
|
|
||||||
"metadatas": [[{"file_id": file.get("id"), "name": file.get("name")}]],
|
|
||||||
}
|
|
||||||
elif (
|
|
||||||
file.get("type") != "web_search"
|
|
||||||
and request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL
|
|
||||||
):
|
|
||||||
# BYPASS_EMBEDDING_AND_RETRIEVAL
|
|
||||||
if file.get("type") == "collection":
|
|
||||||
file_ids = file.get("data", {}).get("file_ids", [])
|
|
||||||
|
|
||||||
documents = []
|
if item.get("file"):
|
||||||
metadatas = []
|
# if item has file data, use it
|
||||||
for file_id in file_ids:
|
query_result = {
|
||||||
file_object = Files.get_file_by_id(file_id)
|
"documents": [
|
||||||
|
[item.get("file", {}).get("data", {}).get("content")]
|
||||||
if file_object:
|
],
|
||||||
documents.append(file_object.data.get("content", ""))
|
"metadatas": [
|
||||||
metadatas.append(
|
[item.get("file", {}).get("data", {}).get("meta", {})]
|
||||||
{
|
],
|
||||||
"file_id": file_id,
|
}
|
||||||
"name": file_object.filename,
|
else:
|
||||||
"source": file_object.filename,
|
# Fallback to item content
|
||||||
}
|
query_result = {
|
||||||
)
|
"documents": [[item.get("content")]],
|
||||||
|
"metadatas": [
|
||||||
context = {
|
[{"file_id": item.get("id"), "name": item.get("name")}]
|
||||||
"documents": [documents],
|
],
|
||||||
"metadatas": [metadatas],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
elif file.get("id"):
|
elif item.get("type") == "note":
|
||||||
file_object = Files.get_file_by_id(file.get("id"))
|
# Note Attached
|
||||||
if file_object:
|
note = Notes.get_note_by_id(item.get("id"))
|
||||||
context = {
|
|
||||||
"documents": [[file_object.data.get("content", "")]],
|
if user.role == "admin" or has_access(user.id, "read", note.access_control):
|
||||||
|
# User has access to the note
|
||||||
|
query_result = {
|
||||||
|
"documents": [[note.data.get("content", {}).get("md", "")]],
|
||||||
|
"metadatas": [[{"file_id": note.id, "name": note.title}]],
|
||||||
|
}
|
||||||
|
|
||||||
|
elif item.get("type") == "file":
|
||||||
|
if (
|
||||||
|
item.get("context") == "full"
|
||||||
|
or request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL
|
||||||
|
):
|
||||||
|
if item.get("file", {}).get("data", {}).get("content", ""):
|
||||||
|
# Manual Full Mode Toggle
|
||||||
|
# Used from chat file modal, we can assume that the file content will be available from item.get("file").get("data", {}).get("content")
|
||||||
|
query_result = {
|
||||||
|
"documents": [
|
||||||
|
[item.get("file", {}).get("data", {}).get("content", "")]
|
||||||
|
],
|
||||||
"metadatas": [
|
"metadatas": [
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"file_id": file.get("id"),
|
"file_id": item.get("id"),
|
||||||
"name": file_object.filename,
|
"name": item.get("name"),
|
||||||
"source": file_object.filename,
|
**item.get("file")
|
||||||
|
.get("data", {})
|
||||||
|
.get("metadata", {}),
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
elif file.get("file").get("data"):
|
elif item.get("id"):
|
||||||
context = {
|
file_object = Files.get_file_by_id(item.get("id"))
|
||||||
"documents": [[file.get("file").get("data", {}).get("content")]],
|
if file_object:
|
||||||
"metadatas": [
|
query_result = {
|
||||||
[file.get("file").get("data", {}).get("metadata", {})]
|
"documents": [[file_object.data.get("content", "")]],
|
||||||
],
|
"metadatas": [
|
||||||
}
|
[
|
||||||
else:
|
{
|
||||||
collection_names = []
|
"file_id": item.get("id"),
|
||||||
if file.get("type") == "collection":
|
"name": file_object.filename,
|
||||||
if file.get("legacy"):
|
"source": file_object.filename,
|
||||||
collection_names = file.get("collection_names", [])
|
}
|
||||||
|
]
|
||||||
|
],
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# Fallback to collection names
|
||||||
|
if item.get("legacy"):
|
||||||
|
collection_names.append(f"{item['id']}")
|
||||||
else:
|
else:
|
||||||
collection_names.append(file["id"])
|
collection_names.append(f"file-{item['id']}")
|
||||||
elif file.get("collection_name"):
|
|
||||||
collection_names.append(file["collection_name"])
|
|
||||||
elif file.get("id"):
|
|
||||||
if file.get("legacy"):
|
|
||||||
collection_names.append(f"{file['id']}")
|
|
||||||
else:
|
|
||||||
collection_names.append(f"file-{file['id']}")
|
|
||||||
|
|
||||||
|
elif item.get("type") == "collection":
|
||||||
|
if (
|
||||||
|
item.get("context") == "full"
|
||||||
|
or request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL
|
||||||
|
):
|
||||||
|
# Manual Full Mode Toggle for Collection
|
||||||
|
knowledge_base = Knowledges.get_knowledge_by_id(item.get("id"))
|
||||||
|
|
||||||
|
if knowledge_base and (
|
||||||
|
user.role == "admin"
|
||||||
|
or has_access(user.id, "read", knowledge_base.access_control)
|
||||||
|
):
|
||||||
|
|
||||||
|
file_ids = knowledge_base.data.get("file_ids", [])
|
||||||
|
|
||||||
|
documents = []
|
||||||
|
metadatas = []
|
||||||
|
for file_id in file_ids:
|
||||||
|
file_object = Files.get_file_by_id(file_id)
|
||||||
|
|
||||||
|
if file_object:
|
||||||
|
documents.append(file_object.data.get("content", ""))
|
||||||
|
metadatas.append(
|
||||||
|
{
|
||||||
|
"file_id": file_id,
|
||||||
|
"name": file_object.filename,
|
||||||
|
"source": file_object.filename,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
query_result = {
|
||||||
|
"documents": [documents],
|
||||||
|
"metadatas": [metadatas],
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# Fallback to collection names
|
||||||
|
if item.get("legacy"):
|
||||||
|
collection_names = item.get("collection_names", [])
|
||||||
|
else:
|
||||||
|
collection_names.append(item["id"])
|
||||||
|
|
||||||
|
elif item.get("docs"):
|
||||||
|
# BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL
|
||||||
|
query_result = {
|
||||||
|
"documents": [[doc.get("content") for doc in item.get("docs")]],
|
||||||
|
"metadatas": [[doc.get("metadata") for doc in item.get("docs")]],
|
||||||
|
}
|
||||||
|
elif item.get("collection_name"):
|
||||||
|
# Direct Collection Name
|
||||||
|
collection_names.append(item["collection_name"])
|
||||||
|
|
||||||
|
# If query_result is None
|
||||||
|
# Fallback to collection names and vector search the collections
|
||||||
|
if query_result is None and collection_names:
|
||||||
collection_names = set(collection_names).difference(extracted_collections)
|
collection_names = set(collection_names).difference(extracted_collections)
|
||||||
if not collection_names:
|
if not collection_names:
|
||||||
log.debug(f"skipping {file} as it has already been extracted")
|
log.debug(f"skipping {item} as it has already been extracted")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if full_context:
|
try:
|
||||||
try:
|
if full_context:
|
||||||
context = get_all_items_from_collections(collection_names)
|
query_result = get_all_items_from_collections(collection_names)
|
||||||
except Exception as e:
|
else:
|
||||||
log.exception(e)
|
query_result = None # Initialize to None
|
||||||
|
if hybrid_search:
|
||||||
else:
|
try:
|
||||||
try:
|
query_result = query_collection_with_hybrid_search(
|
||||||
context = None
|
|
||||||
if file.get("type") == "text":
|
|
||||||
context = file["content"]
|
|
||||||
else:
|
|
||||||
if hybrid_search:
|
|
||||||
try:
|
|
||||||
context = query_collection_with_hybrid_search(
|
|
||||||
collection_names=collection_names,
|
|
||||||
queries=queries,
|
|
||||||
embedding_function=embedding_function,
|
|
||||||
k=k,
|
|
||||||
reranking_function=reranking_function,
|
|
||||||
k_reranker=k_reranker,
|
|
||||||
r=r,
|
|
||||||
hybrid_bm25_weight=hybrid_bm25_weight,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
log.debug(
|
|
||||||
"Error when using hybrid search, using"
|
|
||||||
" non hybrid search as fallback."
|
|
||||||
)
|
|
||||||
|
|
||||||
if (not hybrid_search) or (context is None):
|
|
||||||
context = query_collection(
|
|
||||||
collection_names=collection_names,
|
collection_names=collection_names,
|
||||||
queries=queries,
|
queries=queries,
|
||||||
embedding_function=embedding_function,
|
embedding_function=embedding_function,
|
||||||
k=k,
|
k=k,
|
||||||
|
reranking_function=reranking_function,
|
||||||
|
k_reranker=k_reranker,
|
||||||
|
r=r,
|
||||||
|
hybrid_bm25_weight=hybrid_bm25_weight,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.exception(e)
|
log.debug(
|
||||||
|
"Error when using hybrid search, using non hybrid search as fallback."
|
||||||
|
)
|
||||||
|
|
||||||
|
# fallback to non-hybrid search
|
||||||
|
if not hybrid_search and query_result is None:
|
||||||
|
query_result = query_collection(
|
||||||
|
collection_names=collection_names,
|
||||||
|
queries=queries,
|
||||||
|
embedding_function=embedding_function,
|
||||||
|
k=k,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
log.exception(e)
|
||||||
|
|
||||||
extracted_collections.extend(collection_names)
|
extracted_collections.extend(collection_names)
|
||||||
|
|
||||||
if context:
|
if query_result:
|
||||||
if "data" in file:
|
if "data" in item:
|
||||||
del file["data"]
|
del item["data"]
|
||||||
|
query_results.append({**query_result, "file": item})
|
||||||
relevant_contexts.append({**context, "file": file})
|
|
||||||
|
|
||||||
sources = []
|
sources = []
|
||||||
for context in relevant_contexts:
|
for query_result in query_results:
|
||||||
try:
|
try:
|
||||||
if "documents" in context:
|
if "documents" in query_result:
|
||||||
if "metadatas" in context:
|
if "metadatas" in query_result:
|
||||||
source = {
|
source = {
|
||||||
"source": context["file"],
|
"source": query_result["file"],
|
||||||
"document": context["documents"][0],
|
"document": query_result["documents"][0],
|
||||||
"metadata": context["metadatas"][0],
|
"metadata": query_result["metadatas"][0],
|
||||||
}
|
}
|
||||||
if "distances" in context and context["distances"]:
|
if "distances" in query_result and query_result["distances"]:
|
||||||
source["distances"] = context["distances"][0]
|
source["distances"] = query_result["distances"][0]
|
||||||
|
|
||||||
sources.append(source)
|
sources.append(source)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -678,7 +744,7 @@ def generate_openai_batch_embeddings(
|
||||||
"Authorization": f"Bearer {key}",
|
"Authorization": f"Bearer {key}",
|
||||||
**(
|
**(
|
||||||
{
|
{
|
||||||
"X-OpenWebUI-User-Name": user.name,
|
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||||
"X-OpenWebUI-User-Id": user.id,
|
"X-OpenWebUI-User-Id": user.id,
|
||||||
"X-OpenWebUI-User-Email": user.email,
|
"X-OpenWebUI-User-Email": user.email,
|
||||||
"X-OpenWebUI-User-Role": user.role,
|
"X-OpenWebUI-User-Role": user.role,
|
||||||
|
|
@ -727,7 +793,7 @@ def generate_azure_openai_batch_embeddings(
|
||||||
"api-key": key,
|
"api-key": key,
|
||||||
**(
|
**(
|
||||||
{
|
{
|
||||||
"X-OpenWebUI-User-Name": user.name,
|
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||||
"X-OpenWebUI-User-Id": user.id,
|
"X-OpenWebUI-User-Id": user.id,
|
||||||
"X-OpenWebUI-User-Email": user.email,
|
"X-OpenWebUI-User-Email": user.email,
|
||||||
"X-OpenWebUI-User-Role": user.role,
|
"X-OpenWebUI-User-Role": user.role,
|
||||||
|
|
@ -777,7 +843,7 @@ def generate_ollama_batch_embeddings(
|
||||||
"Authorization": f"Bearer {key}",
|
"Authorization": f"Bearer {key}",
|
||||||
**(
|
**(
|
||||||
{
|
{
|
||||||
"X-OpenWebUI-User-Name": user.name,
|
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||||
"X-OpenWebUI-User-Id": user.id,
|
"X-OpenWebUI-User-Id": user.id,
|
||||||
"X-OpenWebUI-User-Email": user.email,
|
"X-OpenWebUI-User-Email": user.email,
|
||||||
"X-OpenWebUI-User-Role": user.role,
|
"X-OpenWebUI-User-Role": user.role,
|
||||||
|
|
@ -874,7 +940,7 @@ class RerankCompressor(BaseDocumentCompressor):
|
||||||
reranking = self.reranking_function is not None
|
reranking = self.reranking_function is not None
|
||||||
|
|
||||||
if reranking:
|
if reranking:
|
||||||
scores = self.reranking_function.predict(
|
scores = self.reranking_function(
|
||||||
[(query, doc.page_content) for doc in documents]
|
[(query, doc.page_content) for doc in documents]
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -157,10 +157,10 @@ class OpenSearchClient(VectorDBBase):
|
||||||
|
|
||||||
for field, value in filter.items():
|
for field, value in filter.items():
|
||||||
query_body["query"]["bool"]["filter"].append(
|
query_body["query"]["bool"]["filter"].append(
|
||||||
{"match": {"metadata." + str(field): value}}
|
{"term": {"metadata." + str(field) + ".keyword": value}}
|
||||||
)
|
)
|
||||||
|
|
||||||
size = limit if limit else 10
|
size = limit if limit else 10000
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = self.client.search(
|
result = self.client.search(
|
||||||
|
|
@ -206,6 +206,7 @@ class OpenSearchClient(VectorDBBase):
|
||||||
for item in batch
|
for item in batch
|
||||||
]
|
]
|
||||||
bulk(self.client, actions)
|
bulk(self.client, actions)
|
||||||
|
self.client.indices.refresh(self._get_index_name(collection_name))
|
||||||
|
|
||||||
def upsert(self, collection_name: str, items: list[VectorItem]):
|
def upsert(self, collection_name: str, items: list[VectorItem]):
|
||||||
self._create_index_if_not_exists(
|
self._create_index_if_not_exists(
|
||||||
|
|
@ -228,6 +229,7 @@ class OpenSearchClient(VectorDBBase):
|
||||||
for item in batch
|
for item in batch
|
||||||
]
|
]
|
||||||
bulk(self.client, actions)
|
bulk(self.client, actions)
|
||||||
|
self.client.indices.refresh(self._get_index_name(collection_name))
|
||||||
|
|
||||||
def delete(
|
def delete(
|
||||||
self,
|
self,
|
||||||
|
|
@ -251,11 +253,12 @@ class OpenSearchClient(VectorDBBase):
|
||||||
}
|
}
|
||||||
for field, value in filter.items():
|
for field, value in filter.items():
|
||||||
query_body["query"]["bool"]["filter"].append(
|
query_body["query"]["bool"]["filter"].append(
|
||||||
{"match": {"metadata." + str(field): value}}
|
{"term": {"metadata." + str(field) + ".keyword": value}}
|
||||||
)
|
)
|
||||||
self.client.delete_by_query(
|
self.client.delete_by_query(
|
||||||
index=self._get_index_name(collection_name), body=query_body
|
index=self._get_index_name(collection_name), body=query_body
|
||||||
)
|
)
|
||||||
|
self.client.indices.refresh(self._get_index_name(collection_name))
|
||||||
|
|
||||||
def reset(self):
|
def reset(self):
|
||||||
indices = self.client.indices.get(index=f"{self.index_prefix}_*")
|
indices = self.client.indices.get(index=f"{self.index_prefix}_*")
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ from sqlalchemy import (
|
||||||
values,
|
values,
|
||||||
)
|
)
|
||||||
from sqlalchemy.sql import true
|
from sqlalchemy.sql import true
|
||||||
from sqlalchemy.pool import NullPool
|
from sqlalchemy.pool import NullPool, QueuePool
|
||||||
|
|
||||||
from sqlalchemy.orm import declarative_base, scoped_session, sessionmaker
|
from sqlalchemy.orm import declarative_base, scoped_session, sessionmaker
|
||||||
from sqlalchemy.dialects.postgresql import JSONB, array
|
from sqlalchemy.dialects.postgresql import JSONB, array
|
||||||
|
|
@ -37,6 +37,10 @@ from open_webui.config import (
|
||||||
PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH,
|
PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH,
|
||||||
PGVECTOR_PGCRYPTO,
|
PGVECTOR_PGCRYPTO,
|
||||||
PGVECTOR_PGCRYPTO_KEY,
|
PGVECTOR_PGCRYPTO_KEY,
|
||||||
|
PGVECTOR_POOL_SIZE,
|
||||||
|
PGVECTOR_POOL_MAX_OVERFLOW,
|
||||||
|
PGVECTOR_POOL_TIMEOUT,
|
||||||
|
PGVECTOR_POOL_RECYCLE,
|
||||||
)
|
)
|
||||||
|
|
||||||
from open_webui.env import SRC_LOG_LEVELS
|
from open_webui.env import SRC_LOG_LEVELS
|
||||||
|
|
@ -80,9 +84,24 @@ class PgvectorClient(VectorDBBase):
|
||||||
|
|
||||||
self.session = Session
|
self.session = Session
|
||||||
else:
|
else:
|
||||||
engine = create_engine(
|
if isinstance(PGVECTOR_POOL_SIZE, int):
|
||||||
PGVECTOR_DB_URL, pool_pre_ping=True, poolclass=NullPool
|
if PGVECTOR_POOL_SIZE > 0:
|
||||||
)
|
engine = create_engine(
|
||||||
|
PGVECTOR_DB_URL,
|
||||||
|
pool_size=PGVECTOR_POOL_SIZE,
|
||||||
|
max_overflow=PGVECTOR_POOL_MAX_OVERFLOW,
|
||||||
|
pool_timeout=PGVECTOR_POOL_TIMEOUT,
|
||||||
|
pool_recycle=PGVECTOR_POOL_RECYCLE,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
poolclass=QueuePool,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
engine = create_engine(
|
||||||
|
PGVECTOR_DB_URL, pool_pre_ping=True, poolclass=NullPool
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
engine = create_engine(PGVECTOR_DB_URL, pool_pre_ping=True)
|
||||||
|
|
||||||
SessionLocal = sessionmaker(
|
SessionLocal = sessionmaker(
|
||||||
autocommit=False, autoflush=False, bind=engine, expire_on_commit=False
|
autocommit=False, autoflush=False, bind=engine, expire_on_commit=False
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ from open_webui.config import (
|
||||||
QDRANT_ON_DISK,
|
QDRANT_ON_DISK,
|
||||||
QDRANT_GRPC_PORT,
|
QDRANT_GRPC_PORT,
|
||||||
QDRANT_PREFER_GRPC,
|
QDRANT_PREFER_GRPC,
|
||||||
|
QDRANT_COLLECTION_PREFIX,
|
||||||
)
|
)
|
||||||
from open_webui.env import SRC_LOG_LEVELS
|
from open_webui.env import SRC_LOG_LEVELS
|
||||||
|
|
||||||
|
|
@ -29,7 +30,7 @@ log.setLevel(SRC_LOG_LEVELS["RAG"])
|
||||||
|
|
||||||
class QdrantClient(VectorDBBase):
|
class QdrantClient(VectorDBBase):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.collection_prefix = "open-webui"
|
self.collection_prefix = QDRANT_COLLECTION_PREFIX
|
||||||
self.QDRANT_URI = QDRANT_URI
|
self.QDRANT_URI = QDRANT_URI
|
||||||
self.QDRANT_API_KEY = QDRANT_API_KEY
|
self.QDRANT_API_KEY = QDRANT_API_KEY
|
||||||
self.QDRANT_ON_DISK = QDRANT_ON_DISK
|
self.QDRANT_ON_DISK = QDRANT_ON_DISK
|
||||||
|
|
@ -86,6 +87,25 @@ class QdrantClient(VectorDBBase):
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Create payload indexes for efficient filtering
|
||||||
|
self.client.create_payload_index(
|
||||||
|
collection_name=collection_name_with_prefix,
|
||||||
|
field_name="metadata.hash",
|
||||||
|
field_schema=models.KeywordIndexParams(
|
||||||
|
type=models.KeywordIndexType.KEYWORD,
|
||||||
|
is_tenant=False,
|
||||||
|
on_disk=self.QDRANT_ON_DISK,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.client.create_payload_index(
|
||||||
|
collection_name=collection_name_with_prefix,
|
||||||
|
field_name="metadata.file_id",
|
||||||
|
field_schema=models.KeywordIndexParams(
|
||||||
|
type=models.KeywordIndexType.KEYWORD,
|
||||||
|
is_tenant=False,
|
||||||
|
on_disk=self.QDRANT_ON_DISK,
|
||||||
|
),
|
||||||
|
)
|
||||||
log.info(f"collection {collection_name_with_prefix} successfully created!")
|
log.info(f"collection {collection_name_with_prefix} successfully created!")
|
||||||
|
|
||||||
def _create_collection_if_not_exists(self, collection_name, dimension):
|
def _create_collection_if_not_exists(self, collection_name, dimension):
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional, Tuple
|
from typing import Optional, Tuple, List, Dict, Any
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import grpc
|
import grpc
|
||||||
|
|
@ -9,6 +9,7 @@ from open_webui.config import (
|
||||||
QDRANT_ON_DISK,
|
QDRANT_ON_DISK,
|
||||||
QDRANT_PREFER_GRPC,
|
QDRANT_PREFER_GRPC,
|
||||||
QDRANT_URI,
|
QDRANT_URI,
|
||||||
|
QDRANT_COLLECTION_PREFIX,
|
||||||
)
|
)
|
||||||
from open_webui.env import SRC_LOG_LEVELS
|
from open_webui.env import SRC_LOG_LEVELS
|
||||||
from open_webui.retrieval.vector.main import (
|
from open_webui.retrieval.vector.main import (
|
||||||
|
|
@ -23,14 +24,28 @@ from qdrant_client.http.models import PointStruct
|
||||||
from qdrant_client.models import models
|
from qdrant_client.models import models
|
||||||
|
|
||||||
NO_LIMIT = 999999999
|
NO_LIMIT = 999999999
|
||||||
|
TENANT_ID_FIELD = "tenant_id"
|
||||||
|
DEFAULT_DIMENSION = 384
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
log.setLevel(SRC_LOG_LEVELS["RAG"])
|
log.setLevel(SRC_LOG_LEVELS["RAG"])
|
||||||
|
|
||||||
|
|
||||||
|
def _tenant_filter(tenant_id: str) -> models.FieldCondition:
|
||||||
|
return models.FieldCondition(
|
||||||
|
key=TENANT_ID_FIELD, match=models.MatchValue(value=tenant_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _metadata_filter(key: str, value: Any) -> models.FieldCondition:
|
||||||
|
return models.FieldCondition(
|
||||||
|
key=f"metadata.{key}", match=models.MatchValue(value=value)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class QdrantClient(VectorDBBase):
|
class QdrantClient(VectorDBBase):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.collection_prefix = "open-webui"
|
self.collection_prefix = QDRANT_COLLECTION_PREFIX
|
||||||
self.QDRANT_URI = QDRANT_URI
|
self.QDRANT_URI = QDRANT_URI
|
||||||
self.QDRANT_API_KEY = QDRANT_API_KEY
|
self.QDRANT_API_KEY = QDRANT_API_KEY
|
||||||
self.QDRANT_ON_DISK = QDRANT_ON_DISK
|
self.QDRANT_ON_DISK = QDRANT_ON_DISK
|
||||||
|
|
@ -38,24 +53,26 @@ class QdrantClient(VectorDBBase):
|
||||||
self.GRPC_PORT = QDRANT_GRPC_PORT
|
self.GRPC_PORT = QDRANT_GRPC_PORT
|
||||||
|
|
||||||
if not self.QDRANT_URI:
|
if not self.QDRANT_URI:
|
||||||
self.client = None
|
raise ValueError(
|
||||||
return
|
"QDRANT_URI is not set. Please configure it in the environment variables."
|
||||||
|
)
|
||||||
|
|
||||||
# Unified handling for either scheme
|
# Unified handling for either scheme
|
||||||
parsed = urlparse(self.QDRANT_URI)
|
parsed = urlparse(self.QDRANT_URI)
|
||||||
host = parsed.hostname or self.QDRANT_URI
|
host = parsed.hostname or self.QDRANT_URI
|
||||||
http_port = parsed.port or 6333 # default REST port
|
http_port = parsed.port or 6333 # default REST port
|
||||||
|
|
||||||
if self.PREFER_GRPC:
|
self.client = (
|
||||||
self.client = Qclient(
|
Qclient(
|
||||||
host=host,
|
host=host,
|
||||||
port=http_port,
|
port=http_port,
|
||||||
grpc_port=self.GRPC_PORT,
|
grpc_port=self.GRPC_PORT,
|
||||||
prefer_grpc=self.PREFER_GRPC,
|
prefer_grpc=self.PREFER_GRPC,
|
||||||
api_key=self.QDRANT_API_KEY,
|
api_key=self.QDRANT_API_KEY,
|
||||||
)
|
)
|
||||||
else:
|
if self.PREFER_GRPC
|
||||||
self.client = Qclient(url=self.QDRANT_URI, api_key=self.QDRANT_API_KEY)
|
else Qclient(url=self.QDRANT_URI, api_key=self.QDRANT_API_KEY)
|
||||||
|
)
|
||||||
|
|
||||||
# Main collection types for multi-tenancy
|
# Main collection types for multi-tenancy
|
||||||
self.MEMORY_COLLECTION = f"{self.collection_prefix}_memories"
|
self.MEMORY_COLLECTION = f"{self.collection_prefix}_memories"
|
||||||
|
|
@ -65,23 +82,13 @@ class QdrantClient(VectorDBBase):
|
||||||
self.HASH_BASED_COLLECTION = f"{self.collection_prefix}_hash-based"
|
self.HASH_BASED_COLLECTION = f"{self.collection_prefix}_hash-based"
|
||||||
|
|
||||||
def _result_to_get_result(self, points) -> GetResult:
|
def _result_to_get_result(self, points) -> GetResult:
|
||||||
ids = []
|
ids, documents, metadatas = [], [], []
|
||||||
documents = []
|
|
||||||
metadatas = []
|
|
||||||
|
|
||||||
for point in points:
|
for point in points:
|
||||||
payload = point.payload
|
payload = point.payload
|
||||||
ids.append(point.id)
|
ids.append(point.id)
|
||||||
documents.append(payload["text"])
|
documents.append(payload["text"])
|
||||||
metadatas.append(payload["metadata"])
|
metadatas.append(payload["metadata"])
|
||||||
|
return GetResult(ids=[ids], documents=[documents], metadatas=[metadatas])
|
||||||
return GetResult(
|
|
||||||
**{
|
|
||||||
"ids": [ids],
|
|
||||||
"documents": [documents],
|
|
||||||
"metadatas": [metadatas],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
def _get_collection_and_tenant_id(self, collection_name: str) -> Tuple[str, str]:
|
def _get_collection_and_tenant_id(self, collection_name: str) -> Tuple[str, str]:
|
||||||
"""
|
"""
|
||||||
|
|
@ -113,143 +120,47 @@ class QdrantClient(VectorDBBase):
|
||||||
else:
|
else:
|
||||||
return self.KNOWLEDGE_COLLECTION, tenant_id
|
return self.KNOWLEDGE_COLLECTION, tenant_id
|
||||||
|
|
||||||
def _extract_error_message(self, exception):
|
def _create_multi_tenant_collection(
|
||||||
"""
|
self, mt_collection_name: str, dimension: int = DEFAULT_DIMENSION
|
||||||
Extract error message from either HTTP or gRPC exceptions
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
tuple: (status_code, error_message)
|
|
||||||
"""
|
|
||||||
# Check if it's an HTTP exception
|
|
||||||
if isinstance(exception, UnexpectedResponse):
|
|
||||||
try:
|
|
||||||
error_data = exception.structured()
|
|
||||||
error_msg = error_data.get("status", {}).get("error", "")
|
|
||||||
return exception.status_code, error_msg
|
|
||||||
except Exception as inner_e:
|
|
||||||
log.error(f"Failed to parse HTTP error: {inner_e}")
|
|
||||||
return exception.status_code, str(exception)
|
|
||||||
|
|
||||||
# Check if it's a gRPC exception
|
|
||||||
elif isinstance(exception, grpc.RpcError):
|
|
||||||
# Extract status code from gRPC error
|
|
||||||
status_code = None
|
|
||||||
if hasattr(exception, "code") and callable(exception.code):
|
|
||||||
status_code = exception.code().value[0]
|
|
||||||
|
|
||||||
# Extract error message
|
|
||||||
error_msg = str(exception)
|
|
||||||
if "details =" in error_msg:
|
|
||||||
# Parse the details line which contains the actual error message
|
|
||||||
try:
|
|
||||||
details_line = [
|
|
||||||
line.strip()
|
|
||||||
for line in error_msg.split("\n")
|
|
||||||
if "details =" in line
|
|
||||||
][0]
|
|
||||||
error_msg = details_line.split("details =")[1].strip(' "')
|
|
||||||
except (IndexError, AttributeError):
|
|
||||||
# Fall back to full message if parsing fails
|
|
||||||
pass
|
|
||||||
|
|
||||||
return status_code, error_msg
|
|
||||||
|
|
||||||
# For any other type of exception
|
|
||||||
return None, str(exception)
|
|
||||||
|
|
||||||
def _is_collection_not_found_error(self, exception):
|
|
||||||
"""
|
|
||||||
Check if the exception is due to collection not found, supporting both HTTP and gRPC
|
|
||||||
"""
|
|
||||||
status_code, error_msg = self._extract_error_message(exception)
|
|
||||||
|
|
||||||
# HTTP error (404)
|
|
||||||
if (
|
|
||||||
status_code == 404
|
|
||||||
and "Collection" in error_msg
|
|
||||||
and "doesn't exist" in error_msg
|
|
||||||
):
|
|
||||||
return True
|
|
||||||
|
|
||||||
# gRPC error (NOT_FOUND status)
|
|
||||||
if (
|
|
||||||
isinstance(exception, grpc.RpcError)
|
|
||||||
and exception.code() == grpc.StatusCode.NOT_FOUND
|
|
||||||
):
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _is_dimension_mismatch_error(self, exception):
|
|
||||||
"""
|
|
||||||
Check if the exception is due to dimension mismatch, supporting both HTTP and gRPC
|
|
||||||
"""
|
|
||||||
status_code, error_msg = self._extract_error_message(exception)
|
|
||||||
|
|
||||||
# Common patterns in both HTTP and gRPC
|
|
||||||
return (
|
|
||||||
"Vector dimension error" in error_msg
|
|
||||||
or "dimensions mismatch" in error_msg
|
|
||||||
or "invalid vector size" in error_msg
|
|
||||||
)
|
|
||||||
|
|
||||||
def _create_multi_tenant_collection_if_not_exists(
|
|
||||||
self, mt_collection_name: str, dimension: int = 384
|
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Creates a collection with multi-tenancy configuration if it doesn't exist.
|
Creates a collection with multi-tenancy configuration and payload indexes for tenant_id and metadata fields.
|
||||||
Default dimension is set to 384 which corresponds to 'sentence-transformers/all-MiniLM-L6-v2'.
|
|
||||||
When creating collections dynamically (insert/upsert), the actual vector dimensions will be used.
|
|
||||||
"""
|
"""
|
||||||
try:
|
self.client.create_collection(
|
||||||
# Try to create the collection directly - will fail if it already exists
|
collection_name=mt_collection_name,
|
||||||
self.client.create_collection(
|
vectors_config=models.VectorParams(
|
||||||
collection_name=mt_collection_name,
|
size=dimension,
|
||||||
vectors_config=models.VectorParams(
|
distance=models.Distance.COSINE,
|
||||||
size=dimension,
|
on_disk=self.QDRANT_ON_DISK,
|
||||||
distance=models.Distance.COSINE,
|
),
|
||||||
on_disk=self.QDRANT_ON_DISK,
|
)
|
||||||
),
|
log.info(
|
||||||
hnsw_config=models.HnswConfigDiff(
|
f"Multi-tenant collection {mt_collection_name} created with dimension {dimension}!"
|
||||||
payload_m=16, # Enable per-tenant indexing
|
)
|
||||||
m=0,
|
|
||||||
on_disk=self.QDRANT_ON_DISK,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create tenant ID payload index
|
self.client.create_payload_index(
|
||||||
|
collection_name=mt_collection_name,
|
||||||
|
field_name=TENANT_ID_FIELD,
|
||||||
|
field_schema=models.KeywordIndexParams(
|
||||||
|
type=models.KeywordIndexType.KEYWORD,
|
||||||
|
is_tenant=True,
|
||||||
|
on_disk=self.QDRANT_ON_DISK,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
for field in ("metadata.hash", "metadata.file_id"):
|
||||||
self.client.create_payload_index(
|
self.client.create_payload_index(
|
||||||
collection_name=mt_collection_name,
|
collection_name=mt_collection_name,
|
||||||
field_name="tenant_id",
|
field_name=field,
|
||||||
field_schema=models.KeywordIndexParams(
|
field_schema=models.KeywordIndexParams(
|
||||||
type=models.KeywordIndexType.KEYWORD,
|
type=models.KeywordIndexType.KEYWORD,
|
||||||
is_tenant=True,
|
|
||||||
on_disk=self.QDRANT_ON_DISK,
|
on_disk=self.QDRANT_ON_DISK,
|
||||||
),
|
),
|
||||||
wait=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
log.info(
|
def _create_points(
|
||||||
f"Multi-tenant collection {mt_collection_name} created with dimension {dimension}!"
|
self, items: List[VectorItem], tenant_id: str
|
||||||
)
|
) -> List[PointStruct]:
|
||||||
except (UnexpectedResponse, grpc.RpcError) as e:
|
|
||||||
# Check for the specific error indicating collection already exists
|
|
||||||
status_code, error_msg = self._extract_error_message(e)
|
|
||||||
|
|
||||||
# HTTP status code 409 or gRPC ALREADY_EXISTS
|
|
||||||
if (isinstance(e, UnexpectedResponse) and status_code == 409) or (
|
|
||||||
isinstance(e, grpc.RpcError)
|
|
||||||
and e.code() == grpc.StatusCode.ALREADY_EXISTS
|
|
||||||
):
|
|
||||||
if "already exists" in error_msg:
|
|
||||||
log.debug(f"Collection {mt_collection_name} already exists")
|
|
||||||
return
|
|
||||||
# If it's not an already exists error, re-raise
|
|
||||||
raise e
|
|
||||||
except Exception as e:
|
|
||||||
raise e
|
|
||||||
|
|
||||||
def _create_points(self, items: list[VectorItem], tenant_id: str):
|
|
||||||
"""
|
"""
|
||||||
Create point structs from vector items with tenant ID.
|
Create point structs from vector items with tenant ID.
|
||||||
"""
|
"""
|
||||||
|
|
@ -260,56 +171,42 @@ class QdrantClient(VectorDBBase):
|
||||||
payload={
|
payload={
|
||||||
"text": item["text"],
|
"text": item["text"],
|
||||||
"metadata": item["metadata"],
|
"metadata": item["metadata"],
|
||||||
"tenant_id": tenant_id,
|
TENANT_ID_FIELD: tenant_id,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
for item in items
|
for item in items
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def _ensure_collection(
|
||||||
|
self, mt_collection_name: str, dimension: int = DEFAULT_DIMENSION
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Ensure the collection exists and payload indexes are created for tenant_id and metadata fields.
|
||||||
|
"""
|
||||||
|
if not self.client.collection_exists(collection_name=mt_collection_name):
|
||||||
|
self._create_multi_tenant_collection(mt_collection_name, dimension)
|
||||||
|
|
||||||
def has_collection(self, collection_name: str) -> bool:
|
def has_collection(self, collection_name: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if a logical collection exists by checking for any points with the tenant ID.
|
Check if a logical collection exists by checking for any points with the tenant ID.
|
||||||
"""
|
"""
|
||||||
if not self.client:
|
if not self.client:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Map to multi-tenant collection and tenant ID
|
|
||||||
mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name)
|
mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name)
|
||||||
|
if not self.client.collection_exists(collection_name=mt_collection):
|
||||||
# Create tenant filter
|
|
||||||
tenant_filter = models.FieldCondition(
|
|
||||||
key="tenant_id", match=models.MatchValue(value=tenant_id)
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Try directly querying - most of the time collection should exist
|
|
||||||
response = self.client.query_points(
|
|
||||||
collection_name=mt_collection,
|
|
||||||
query_filter=models.Filter(must=[tenant_filter]),
|
|
||||||
limit=1,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Collection exists with this tenant ID if there are points
|
|
||||||
return len(response.points) > 0
|
|
||||||
except (UnexpectedResponse, grpc.RpcError) as e:
|
|
||||||
if self._is_collection_not_found_error(e):
|
|
||||||
log.debug(f"Collection {mt_collection} doesn't exist")
|
|
||||||
return False
|
|
||||||
else:
|
|
||||||
# For other API errors, log and return False
|
|
||||||
_, error_msg = self._extract_error_message(e)
|
|
||||||
log.warning(f"Unexpected Qdrant error: {error_msg}")
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
|
||||||
# For any other errors, log and return False
|
|
||||||
log.debug(f"Error checking collection {mt_collection}: {e}")
|
|
||||||
return False
|
return False
|
||||||
|
tenant_filter = _tenant_filter(tenant_id)
|
||||||
|
count_result = self.client.count(
|
||||||
|
collection_name=mt_collection,
|
||||||
|
count_filter=models.Filter(must=[tenant_filter]),
|
||||||
|
)
|
||||||
|
return count_result.count > 0
|
||||||
|
|
||||||
def delete(
|
def delete(
|
||||||
self,
|
self,
|
||||||
collection_name: str,
|
collection_name: str,
|
||||||
ids: Optional[list[str]] = None,
|
ids: Optional[List[str]] = None,
|
||||||
filter: Optional[dict] = None,
|
filter: Optional[Dict[str, Any]] = None,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Delete vectors by ID or filter from a collection with tenant isolation.
|
Delete vectors by ID or filter from a collection with tenant isolation.
|
||||||
|
|
@ -317,189 +214,76 @@ class QdrantClient(VectorDBBase):
|
||||||
if not self.client:
|
if not self.client:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Map to multi-tenant collection and tenant ID
|
|
||||||
mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name)
|
mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name)
|
||||||
|
if not self.client.collection_exists(collection_name=mt_collection):
|
||||||
|
log.debug(f"Collection {mt_collection} doesn't exist, nothing to delete")
|
||||||
|
return None
|
||||||
|
|
||||||
# Create tenant filter
|
must_conditions = [_tenant_filter(tenant_id)]
|
||||||
tenant_filter = models.FieldCondition(
|
should_conditions = []
|
||||||
key="tenant_id", match=models.MatchValue(value=tenant_id)
|
if ids:
|
||||||
|
should_conditions = [_metadata_filter("id", id_value) for id_value in ids]
|
||||||
|
elif filter:
|
||||||
|
must_conditions += [_metadata_filter(k, v) for k, v in filter.items()]
|
||||||
|
|
||||||
|
return self.client.delete(
|
||||||
|
collection_name=mt_collection,
|
||||||
|
points_selector=models.FilterSelector(
|
||||||
|
filter=models.Filter(must=must_conditions, should=should_conditions)
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
must_conditions = [tenant_filter]
|
|
||||||
should_conditions = []
|
|
||||||
|
|
||||||
if ids:
|
|
||||||
for id_value in ids:
|
|
||||||
should_conditions.append(
|
|
||||||
models.FieldCondition(
|
|
||||||
key="metadata.id",
|
|
||||||
match=models.MatchValue(value=id_value),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
elif filter:
|
|
||||||
for key, value in filter.items():
|
|
||||||
must_conditions.append(
|
|
||||||
models.FieldCondition(
|
|
||||||
key=f"metadata.{key}",
|
|
||||||
match=models.MatchValue(value=value),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Try to delete directly - most of the time collection should exist
|
|
||||||
update_result = self.client.delete(
|
|
||||||
collection_name=mt_collection,
|
|
||||||
points_selector=models.FilterSelector(
|
|
||||||
filter=models.Filter(must=must_conditions, should=should_conditions)
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
return update_result
|
|
||||||
except (UnexpectedResponse, grpc.RpcError) as e:
|
|
||||||
if self._is_collection_not_found_error(e):
|
|
||||||
log.debug(
|
|
||||||
f"Collection {mt_collection} doesn't exist, nothing to delete"
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
else:
|
|
||||||
# For other API errors, log and re-raise
|
|
||||||
_, error_msg = self._extract_error_message(e)
|
|
||||||
log.warning(f"Unexpected Qdrant error: {error_msg}")
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
# For non-Qdrant exceptions, re-raise
|
|
||||||
raise
|
|
||||||
|
|
||||||
def search(
|
def search(
|
||||||
self, collection_name: str, vectors: list[list[float | int]], limit: int
|
self, collection_name: str, vectors: List[List[float | int]], limit: int
|
||||||
) -> Optional[SearchResult]:
|
) -> Optional[SearchResult]:
|
||||||
"""
|
"""
|
||||||
Search for the nearest neighbor items based on the vectors with tenant isolation.
|
Search for the nearest neighbor items based on the vectors with tenant isolation.
|
||||||
"""
|
"""
|
||||||
if not self.client:
|
if not self.client or not vectors:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Map to multi-tenant collection and tenant ID
|
|
||||||
mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name)
|
mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name)
|
||||||
|
if not self.client.collection_exists(collection_name=mt_collection):
|
||||||
# Get the vector dimension from the query vector
|
log.debug(f"Collection {mt_collection} doesn't exist, search returns None")
|
||||||
dimension = len(vectors[0]) if vectors and len(vectors) > 0 else None
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Try the search operation directly - most of the time collection should exist
|
|
||||||
|
|
||||||
# Create tenant filter
|
|
||||||
tenant_filter = models.FieldCondition(
|
|
||||||
key="tenant_id", match=models.MatchValue(value=tenant_id)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Ensure vector dimensions match the collection
|
|
||||||
collection_dim = self.client.get_collection(
|
|
||||||
mt_collection
|
|
||||||
).config.params.vectors.size
|
|
||||||
|
|
||||||
if collection_dim != dimension:
|
|
||||||
if collection_dim < dimension:
|
|
||||||
vectors = [vector[:collection_dim] for vector in vectors]
|
|
||||||
else:
|
|
||||||
vectors = [
|
|
||||||
vector + [0] * (collection_dim - dimension)
|
|
||||||
for vector in vectors
|
|
||||||
]
|
|
||||||
|
|
||||||
# Search with tenant filter
|
|
||||||
prefetch_query = models.Prefetch(
|
|
||||||
filter=models.Filter(must=[tenant_filter]),
|
|
||||||
limit=NO_LIMIT,
|
|
||||||
)
|
|
||||||
query_response = self.client.query_points(
|
|
||||||
collection_name=mt_collection,
|
|
||||||
query=vectors[0],
|
|
||||||
prefetch=prefetch_query,
|
|
||||||
limit=limit,
|
|
||||||
)
|
|
||||||
|
|
||||||
get_result = self._result_to_get_result(query_response.points)
|
|
||||||
return SearchResult(
|
|
||||||
ids=get_result.ids,
|
|
||||||
documents=get_result.documents,
|
|
||||||
metadatas=get_result.metadatas,
|
|
||||||
# qdrant distance is [-1, 1], normalize to [0, 1]
|
|
||||||
distances=[
|
|
||||||
[(point.score + 1.0) / 2.0 for point in query_response.points]
|
|
||||||
],
|
|
||||||
)
|
|
||||||
except (UnexpectedResponse, grpc.RpcError) as e:
|
|
||||||
if self._is_collection_not_found_error(e):
|
|
||||||
log.debug(
|
|
||||||
f"Collection {mt_collection} doesn't exist, search returns None"
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
else:
|
|
||||||
# For other API errors, log and re-raise
|
|
||||||
_, error_msg = self._extract_error_message(e)
|
|
||||||
log.warning(f"Unexpected Qdrant error during search: {error_msg}")
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
# For non-Qdrant exceptions, log and return None
|
|
||||||
log.exception(f"Error searching collection '{collection_name}': {e}")
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def query(self, collection_name: str, filter: dict, limit: Optional[int] = None):
|
tenant_filter = _tenant_filter(tenant_id)
|
||||||
|
query_response = self.client.query_points(
|
||||||
|
collection_name=mt_collection,
|
||||||
|
query=vectors[0],
|
||||||
|
limit=limit,
|
||||||
|
query_filter=models.Filter(must=[tenant_filter]),
|
||||||
|
)
|
||||||
|
get_result = self._result_to_get_result(query_response.points)
|
||||||
|
return SearchResult(
|
||||||
|
ids=get_result.ids,
|
||||||
|
documents=get_result.documents,
|
||||||
|
metadatas=get_result.metadatas,
|
||||||
|
distances=[[(point.score + 1.0) / 2.0 for point in query_response.points]],
|
||||||
|
)
|
||||||
|
|
||||||
|
def query(
|
||||||
|
self, collection_name: str, filter: Dict[str, Any], limit: Optional[int] = None
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Query points with filters and tenant isolation.
|
Query points with filters and tenant isolation.
|
||||||
"""
|
"""
|
||||||
if not self.client:
|
if not self.client:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Map to multi-tenant collection and tenant ID
|
|
||||||
mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name)
|
mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name)
|
||||||
|
if not self.client.collection_exists(collection_name=mt_collection):
|
||||||
# Set default limit if not provided
|
log.debug(f"Collection {mt_collection} doesn't exist, query returns None")
|
||||||
|
return None
|
||||||
if limit is None:
|
if limit is None:
|
||||||
limit = NO_LIMIT
|
limit = NO_LIMIT
|
||||||
|
tenant_filter = _tenant_filter(tenant_id)
|
||||||
# Create tenant filter
|
field_conditions = [_metadata_filter(k, v) for k, v in filter.items()]
|
||||||
tenant_filter = models.FieldCondition(
|
|
||||||
key="tenant_id", match=models.MatchValue(value=tenant_id)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create metadata filters
|
|
||||||
field_conditions = []
|
|
||||||
for key, value in filter.items():
|
|
||||||
field_conditions.append(
|
|
||||||
models.FieldCondition(
|
|
||||||
key=f"metadata.{key}", match=models.MatchValue(value=value)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Combine tenant filter with metadata filters
|
|
||||||
combined_filter = models.Filter(must=[tenant_filter, *field_conditions])
|
combined_filter = models.Filter(must=[tenant_filter, *field_conditions])
|
||||||
|
points = self.client.query_points(
|
||||||
try:
|
collection_name=mt_collection,
|
||||||
# Try the query directly - most of the time collection should exist
|
query_filter=combined_filter,
|
||||||
points = self.client.query_points(
|
limit=limit,
|
||||||
collection_name=mt_collection,
|
)
|
||||||
query_filter=combined_filter,
|
return self._result_to_get_result(points.points)
|
||||||
limit=limit,
|
|
||||||
)
|
|
||||||
|
|
||||||
return self._result_to_get_result(points.points)
|
|
||||||
except (UnexpectedResponse, grpc.RpcError) as e:
|
|
||||||
if self._is_collection_not_found_error(e):
|
|
||||||
log.debug(
|
|
||||||
f"Collection {mt_collection} doesn't exist, query returns None"
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
else:
|
|
||||||
# For other API errors, log and re-raise
|
|
||||||
_, error_msg = self._extract_error_message(e)
|
|
||||||
log.warning(f"Unexpected Qdrant error during query: {error_msg}")
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
# For non-Qdrant exceptions, log and re-raise
|
|
||||||
log.exception(f"Error querying collection '{collection_name}': {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get(self, collection_name: str) -> Optional[GetResult]:
|
def get(self, collection_name: str) -> Optional[GetResult]:
|
||||||
"""
|
"""
|
||||||
|
|
@ -507,169 +291,36 @@ class QdrantClient(VectorDBBase):
|
||||||
"""
|
"""
|
||||||
if not self.client:
|
if not self.client:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Map to multi-tenant collection and tenant ID
|
|
||||||
mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name)
|
mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name)
|
||||||
|
if not self.client.collection_exists(collection_name=mt_collection):
|
||||||
# Create tenant filter
|
log.debug(f"Collection {mt_collection} doesn't exist, get returns None")
|
||||||
tenant_filter = models.FieldCondition(
|
|
||||||
key="tenant_id", match=models.MatchValue(value=tenant_id)
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Try to get points directly - most of the time collection should exist
|
|
||||||
points = self.client.query_points(
|
|
||||||
collection_name=mt_collection,
|
|
||||||
query_filter=models.Filter(must=[tenant_filter]),
|
|
||||||
limit=NO_LIMIT,
|
|
||||||
)
|
|
||||||
|
|
||||||
return self._result_to_get_result(points.points)
|
|
||||||
except (UnexpectedResponse, grpc.RpcError) as e:
|
|
||||||
if self._is_collection_not_found_error(e):
|
|
||||||
log.debug(f"Collection {mt_collection} doesn't exist, get returns None")
|
|
||||||
return None
|
|
||||||
else:
|
|
||||||
# For other API errors, log and re-raise
|
|
||||||
_, error_msg = self._extract_error_message(e)
|
|
||||||
log.warning(f"Unexpected Qdrant error during get: {error_msg}")
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
# For non-Qdrant exceptions, log and return None
|
|
||||||
log.exception(f"Error getting collection '{collection_name}': {e}")
|
|
||||||
return None
|
return None
|
||||||
|
tenant_filter = _tenant_filter(tenant_id)
|
||||||
def _handle_operation_with_error_retry(
|
points = self.client.query_points(
|
||||||
self, operation_name, mt_collection, points, dimension
|
collection_name=mt_collection,
|
||||||
):
|
query_filter=models.Filter(must=[tenant_filter]),
|
||||||
"""
|
limit=NO_LIMIT,
|
||||||
Private helper to handle common error cases for insert and upsert operations.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
operation_name: 'insert' or 'upsert'
|
|
||||||
mt_collection: The multi-tenant collection name
|
|
||||||
points: The vector points to insert/upsert
|
|
||||||
dimension: The dimension of the vectors
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The operation result (for upsert) or None (for insert)
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
if operation_name == "insert":
|
|
||||||
self.client.upload_points(mt_collection, points)
|
|
||||||
return None
|
|
||||||
else: # upsert
|
|
||||||
return self.client.upsert(mt_collection, points)
|
|
||||||
except (UnexpectedResponse, grpc.RpcError) as e:
|
|
||||||
# Handle collection not found
|
|
||||||
if self._is_collection_not_found_error(e):
|
|
||||||
log.info(
|
|
||||||
f"Collection {mt_collection} doesn't exist. Creating it with dimension {dimension}."
|
|
||||||
)
|
|
||||||
# Create collection with correct dimensions from our vectors
|
|
||||||
self._create_multi_tenant_collection_if_not_exists(
|
|
||||||
mt_collection_name=mt_collection, dimension=dimension
|
|
||||||
)
|
|
||||||
# Try operation again - no need for dimension adjustment since we just created with correct dimensions
|
|
||||||
if operation_name == "insert":
|
|
||||||
self.client.upload_points(mt_collection, points)
|
|
||||||
return None
|
|
||||||
else: # upsert
|
|
||||||
return self.client.upsert(mt_collection, points)
|
|
||||||
|
|
||||||
# Handle dimension mismatch
|
|
||||||
elif self._is_dimension_mismatch_error(e):
|
|
||||||
# For dimension errors, the collection must exist, so get its configuration
|
|
||||||
mt_collection_info = self.client.get_collection(mt_collection)
|
|
||||||
existing_size = mt_collection_info.config.params.vectors.size
|
|
||||||
|
|
||||||
log.info(
|
|
||||||
f"Dimension mismatch: Collection {mt_collection} expects {existing_size}, got {dimension}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if existing_size < dimension:
|
|
||||||
# Truncate vectors to fit
|
|
||||||
log.info(
|
|
||||||
f"Truncating vectors from {dimension} to {existing_size} dimensions"
|
|
||||||
)
|
|
||||||
points = [
|
|
||||||
PointStruct(
|
|
||||||
id=point.id,
|
|
||||||
vector=point.vector[:existing_size],
|
|
||||||
payload=point.payload,
|
|
||||||
)
|
|
||||||
for point in points
|
|
||||||
]
|
|
||||||
elif existing_size > dimension:
|
|
||||||
# Pad vectors with zeros
|
|
||||||
log.info(
|
|
||||||
f"Padding vectors from {dimension} to {existing_size} dimensions with zeros"
|
|
||||||
)
|
|
||||||
points = [
|
|
||||||
PointStruct(
|
|
||||||
id=point.id,
|
|
||||||
vector=point.vector
|
|
||||||
+ [0] * (existing_size - len(point.vector)),
|
|
||||||
payload=point.payload,
|
|
||||||
)
|
|
||||||
for point in points
|
|
||||||
]
|
|
||||||
# Try operation again with adjusted dimensions
|
|
||||||
if operation_name == "insert":
|
|
||||||
self.client.upload_points(mt_collection, points)
|
|
||||||
return None
|
|
||||||
else: # upsert
|
|
||||||
return self.client.upsert(mt_collection, points)
|
|
||||||
else:
|
|
||||||
# Not a known error we can handle, log and re-raise
|
|
||||||
_, error_msg = self._extract_error_message(e)
|
|
||||||
log.warning(f"Unhandled Qdrant error: {error_msg}")
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
# For non-Qdrant exceptions, re-raise
|
|
||||||
raise
|
|
||||||
|
|
||||||
def insert(self, collection_name: str, items: list[VectorItem]):
|
|
||||||
"""
|
|
||||||
Insert items with tenant ID.
|
|
||||||
"""
|
|
||||||
if not self.client or not items:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Map to multi-tenant collection and tenant ID
|
|
||||||
mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name)
|
|
||||||
|
|
||||||
# Get dimensions from the actual vectors
|
|
||||||
dimension = len(items[0]["vector"]) if items else None
|
|
||||||
|
|
||||||
# Create points with tenant ID
|
|
||||||
points = self._create_points(items, tenant_id)
|
|
||||||
|
|
||||||
# Handle the operation with error retry
|
|
||||||
return self._handle_operation_with_error_retry(
|
|
||||||
"insert", mt_collection, points, dimension
|
|
||||||
)
|
)
|
||||||
|
return self._result_to_get_result(points.points)
|
||||||
|
|
||||||
def upsert(self, collection_name: str, items: list[VectorItem]):
|
def upsert(self, collection_name: str, items: List[VectorItem]):
|
||||||
"""
|
"""
|
||||||
Upsert items with tenant ID.
|
Upsert items with tenant ID.
|
||||||
"""
|
"""
|
||||||
if not self.client or not items:
|
if not self.client or not items:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Map to multi-tenant collection and tenant ID
|
|
||||||
mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name)
|
mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name)
|
||||||
|
dimension = len(items[0]["vector"])
|
||||||
# Get dimensions from the actual vectors
|
self._ensure_collection(mt_collection, dimension)
|
||||||
dimension = len(items[0]["vector"]) if items else None
|
|
||||||
|
|
||||||
# Create points with tenant ID
|
|
||||||
points = self._create_points(items, tenant_id)
|
points = self._create_points(items, tenant_id)
|
||||||
|
self.client.upload_points(mt_collection, points)
|
||||||
|
return None
|
||||||
|
|
||||||
# Handle the operation with error retry
|
def insert(self, collection_name: str, items: List[VectorItem]):
|
||||||
return self._handle_operation_with_error_retry(
|
"""
|
||||||
"upsert", mt_collection, points, dimension
|
Insert items with tenant ID.
|
||||||
)
|
"""
|
||||||
|
return self.upsert(collection_name, items)
|
||||||
|
|
||||||
def reset(self):
|
def reset(self):
|
||||||
"""
|
"""
|
||||||
|
|
@ -677,11 +328,9 @@ class QdrantClient(VectorDBBase):
|
||||||
"""
|
"""
|
||||||
if not self.client:
|
if not self.client:
|
||||||
return None
|
return None
|
||||||
|
for collection in self.client.get_collections().collections:
|
||||||
collection_names = self.client.get_collections().collections
|
if collection.name.startswith(self.collection_prefix):
|
||||||
for collection_name in collection_names:
|
self.client.delete_collection(collection_name=collection.name)
|
||||||
if collection_name.name.startswith(self.collection_prefix):
|
|
||||||
self.client.delete_collection(collection_name=collection_name.name)
|
|
||||||
|
|
||||||
def delete_collection(self, collection_name: str):
|
def delete_collection(self, collection_name: str):
|
||||||
"""
|
"""
|
||||||
|
|
@ -689,24 +338,13 @@ class QdrantClient(VectorDBBase):
|
||||||
"""
|
"""
|
||||||
if not self.client:
|
if not self.client:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Map to multi-tenant collection and tenant ID
|
|
||||||
mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name)
|
mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name)
|
||||||
|
if not self.client.collection_exists(collection_name=mt_collection):
|
||||||
tenant_filter = models.FieldCondition(
|
log.debug(f"Collection {mt_collection} doesn't exist, nothing to delete")
|
||||||
key="tenant_id", match=models.MatchValue(value=tenant_id)
|
return None
|
||||||
)
|
self.client.delete(
|
||||||
|
|
||||||
field_conditions = [tenant_filter]
|
|
||||||
|
|
||||||
update_result = self.client.delete(
|
|
||||||
collection_name=mt_collection,
|
collection_name=mt_collection,
|
||||||
points_selector=models.FilterSelector(
|
points_selector=models.FilterSelector(
|
||||||
filter=models.Filter(must=field_conditions)
|
filter=models.Filter(must=[_tenant_filter(tenant_id)])
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.client.get_collection(mt_collection).points_count == 0:
|
|
||||||
self.client.delete_collection(mt_collection)
|
|
||||||
|
|
||||||
return update_result
|
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,9 @@ def search_brave(
|
||||||
|
|
||||||
return [
|
return [
|
||||||
SearchResult(
|
SearchResult(
|
||||||
link=result["url"], title=result.get("title"), snippet=result.get("snippet")
|
link=result["url"],
|
||||||
|
title=result.get("title"),
|
||||||
|
snippet=result.get("description"),
|
||||||
)
|
)
|
||||||
for result in results[:count]
|
for result in results[:count]
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@ import logging
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
|
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
|
||||||
from duckduckgo_search import DDGS
|
from ddgs import DDGS
|
||||||
from duckduckgo_search.exceptions import RatelimitException
|
from ddgs.exceptions import RatelimitException
|
||||||
from open_webui.env import SRC_LOG_LEVELS
|
from open_webui.env import SRC_LOG_LEVELS
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import aiohttp
|
||||||
import aiofiles
|
import aiofiles
|
||||||
import requests
|
import requests
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
from fastapi import (
|
from fastapi import (
|
||||||
Depends,
|
Depends,
|
||||||
|
|
@ -327,6 +328,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
|
||||||
log.exception(e)
|
log.exception(e)
|
||||||
raise HTTPException(status_code=400, detail="Invalid JSON payload")
|
raise HTTPException(status_code=400, detail="Invalid JSON payload")
|
||||||
|
|
||||||
|
r = None
|
||||||
if request.app.state.config.TTS_ENGINE == "openai":
|
if request.app.state.config.TTS_ENGINE == "openai":
|
||||||
payload["model"] = request.app.state.config.TTS_MODEL
|
payload["model"] = request.app.state.config.TTS_MODEL
|
||||||
|
|
||||||
|
|
@ -335,7 +337,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
|
||||||
async with aiohttp.ClientSession(
|
async with aiohttp.ClientSession(
|
||||||
timeout=timeout, trust_env=True
|
timeout=timeout, trust_env=True
|
||||||
) as session:
|
) as session:
|
||||||
async with session.post(
|
r = await session.post(
|
||||||
url=f"{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/speech",
|
url=f"{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/speech",
|
||||||
json=payload,
|
json=payload,
|
||||||
headers={
|
headers={
|
||||||
|
|
@ -343,7 +345,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
|
||||||
"Authorization": f"Bearer {request.app.state.config.TTS_OPENAI_API_KEY}",
|
"Authorization": f"Bearer {request.app.state.config.TTS_OPENAI_API_KEY}",
|
||||||
**(
|
**(
|
||||||
{
|
{
|
||||||
"X-OpenWebUI-User-Name": user.name,
|
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||||
"X-OpenWebUI-User-Id": user.id,
|
"X-OpenWebUI-User-Id": user.id,
|
||||||
"X-OpenWebUI-User-Email": user.email,
|
"X-OpenWebUI-User-Email": user.email,
|
||||||
"X-OpenWebUI-User-Role": user.role,
|
"X-OpenWebUI-User-Role": user.role,
|
||||||
|
|
@ -353,14 +355,15 @@ async def speech(request: Request, user=Depends(get_verified_user)):
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||||
) as r:
|
)
|
||||||
r.raise_for_status()
|
|
||||||
|
|
||||||
async with aiofiles.open(file_path, "wb") as f:
|
r.raise_for_status()
|
||||||
await f.write(await r.read())
|
|
||||||
|
|
||||||
async with aiofiles.open(file_body_path, "w") as f:
|
async with aiofiles.open(file_path, "wb") as f:
|
||||||
await f.write(json.dumps(payload))
|
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)
|
return FileResponse(file_path)
|
||||||
|
|
||||||
|
|
@ -368,18 +371,18 @@ async def speech(request: Request, user=Depends(get_verified_user)):
|
||||||
log.exception(e)
|
log.exception(e)
|
||||||
detail = None
|
detail = None
|
||||||
|
|
||||||
try:
|
status_code = 500
|
||||||
if r.status != 200:
|
detail = f"Open WebUI: Server Connection Error"
|
||||||
res = await r.json()
|
|
||||||
|
|
||||||
if "error" in res:
|
if r is not None:
|
||||||
detail = f"External: {res['error'].get('message', '')}"
|
status_code = r.status
|
||||||
except Exception:
|
res = await r.json()
|
||||||
detail = f"External: {e}"
|
if "error" in res:
|
||||||
|
detail = f"External: {res['error'].get('message', '')}"
|
||||||
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=getattr(r, "status", 500) if r else 500,
|
status_code=status_code,
|
||||||
detail=detail if detail else "Open WebUI: Server Connection Error",
|
detail=detail,
|
||||||
)
|
)
|
||||||
|
|
||||||
elif request.app.state.config.TTS_ENGINE == "elevenlabs":
|
elif request.app.state.config.TTS_ENGINE == "elevenlabs":
|
||||||
|
|
@ -919,14 +922,18 @@ def transcription(
|
||||||
):
|
):
|
||||||
log.info(f"file.content_type: {file.content_type}")
|
log.info(f"file.content_type: {file.content_type}")
|
||||||
|
|
||||||
supported_content_types = request.app.state.config.STT_SUPPORTED_CONTENT_TYPES or [
|
stt_supported_content_types = getattr(
|
||||||
"audio/*",
|
request.app.state.config, "STT_SUPPORTED_CONTENT_TYPES", []
|
||||||
"video/webm",
|
)
|
||||||
]
|
|
||||||
|
|
||||||
if not any(
|
if not any(
|
||||||
fnmatch(file.content_type, content_type)
|
fnmatch(file.content_type, content_type)
|
||||||
for content_type in supported_content_types
|
for content_type in (
|
||||||
|
stt_supported_content_types
|
||||||
|
if stt_supported_content_types
|
||||||
|
and any(t.strip() for t in stt_supported_content_types)
|
||||||
|
else ["audio/*", "video/webm"]
|
||||||
|
)
|
||||||
):
|
):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
|
|
||||||
|
|
@ -669,12 +669,13 @@ async def signup(request: Request, response: Response, form_data: SignupForm):
|
||||||
@router.get("/signout")
|
@router.get("/signout")
|
||||||
async def signout(request: Request, response: Response):
|
async def signout(request: Request, response: Response):
|
||||||
response.delete_cookie("token")
|
response.delete_cookie("token")
|
||||||
|
response.delete_cookie("oui-session")
|
||||||
|
|
||||||
if ENABLE_OAUTH_SIGNUP.value:
|
if ENABLE_OAUTH_SIGNUP.value:
|
||||||
oauth_id_token = request.cookies.get("oauth_id_token")
|
oauth_id_token = request.cookies.get("oauth_id_token")
|
||||||
if oauth_id_token:
|
if oauth_id_token:
|
||||||
try:
|
try:
|
||||||
async with ClientSession() as session:
|
async with ClientSession(trust_env=True) as session:
|
||||||
async with session.get(OPENID_PROVIDER_URL.value) as resp:
|
async with session.get(OPENID_PROVIDER_URL.value) as resp:
|
||||||
if resp.status == 200:
|
if resp.status == 200:
|
||||||
openid_data = await resp.json()
|
openid_data = await resp.json()
|
||||||
|
|
@ -686,7 +687,12 @@ async def signout(request: Request, response: Response):
|
||||||
status_code=200,
|
status_code=200,
|
||||||
content={
|
content={
|
||||||
"status": True,
|
"status": True,
|
||||||
"redirect_url": f"{logout_url}?id_token_hint={oauth_id_token}",
|
"redirect_url": f"{logout_url}?id_token_hint={oauth_id_token}"
|
||||||
|
+ (
|
||||||
|
f"&post_logout_redirect_uri={WEBUI_AUTH_SIGNOUT_REDIRECT_URL}"
|
||||||
|
if WEBUI_AUTH_SIGNOUT_REDIRECT_URL
|
||||||
|
else ""
|
||||||
|
),
|
||||||
},
|
},
|
||||||
headers=response.headers,
|
headers=response.headers,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -40,10 +40,14 @@ router = APIRouter()
|
||||||
|
|
||||||
@router.get("/", response_model=list[ChannelModel])
|
@router.get("/", response_model=list[ChannelModel])
|
||||||
async def get_channels(user=Depends(get_verified_user)):
|
async def get_channels(user=Depends(get_verified_user)):
|
||||||
|
return Channels.get_channels_by_user_id(user.id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/list", response_model=list[ChannelModel])
|
||||||
|
async def get_all_channels(user=Depends(get_verified_user)):
|
||||||
if user.role == "admin":
|
if user.role == "admin":
|
||||||
return Channels.get_channels()
|
return Channels.get_channels()
|
||||||
else:
|
return Channels.get_channels_by_user_id(user.id)
|
||||||
return Channels.get_channels_by_user_id(user.id)
|
|
||||||
|
|
||||||
|
|
||||||
############################
|
############################
|
||||||
|
|
|
||||||
|
|
@ -684,8 +684,10 @@ async def archive_chat_by_id(id: str, user=Depends(get_verified_user)):
|
||||||
|
|
||||||
@router.post("/{id}/share", response_model=Optional[ChatResponse])
|
@router.post("/{id}/share", response_model=Optional[ChatResponse])
|
||||||
async def share_chat_by_id(request: Request, id: str, user=Depends(get_verified_user)):
|
async def share_chat_by_id(request: Request, id: str, user=Depends(get_verified_user)):
|
||||||
if not has_permission(
|
if (user.role != "admin") and (
|
||||||
user.id, "chat.share", request.app.state.config.USER_PERMISSIONS
|
not has_permission(
|
||||||
|
user.id, "chat.share", request.app.state.config.USER_PERMISSIONS
|
||||||
|
)
|
||||||
):
|
):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
|
|
||||||
|
|
@ -39,32 +39,39 @@ async def export_config(user=Depends(get_admin_user)):
|
||||||
|
|
||||||
|
|
||||||
############################
|
############################
|
||||||
# Direct Connections Config
|
# Connections Config
|
||||||
############################
|
############################
|
||||||
|
|
||||||
|
|
||||||
class DirectConnectionsConfigForm(BaseModel):
|
class ConnectionsConfigForm(BaseModel):
|
||||||
ENABLE_DIRECT_CONNECTIONS: bool
|
ENABLE_DIRECT_CONNECTIONS: bool
|
||||||
|
ENABLE_BASE_MODELS_CACHE: bool
|
||||||
|
|
||||||
|
|
||||||
@router.get("/direct_connections", response_model=DirectConnectionsConfigForm)
|
@router.get("/connections", response_model=ConnectionsConfigForm)
|
||||||
async def get_direct_connections_config(request: Request, user=Depends(get_admin_user)):
|
async def get_connections_config(request: Request, user=Depends(get_admin_user)):
|
||||||
return {
|
return {
|
||||||
"ENABLE_DIRECT_CONNECTIONS": request.app.state.config.ENABLE_DIRECT_CONNECTIONS,
|
"ENABLE_DIRECT_CONNECTIONS": request.app.state.config.ENABLE_DIRECT_CONNECTIONS,
|
||||||
|
"ENABLE_BASE_MODELS_CACHE": request.app.state.config.ENABLE_BASE_MODELS_CACHE,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/direct_connections", response_model=DirectConnectionsConfigForm)
|
@router.post("/connections", response_model=ConnectionsConfigForm)
|
||||||
async def set_direct_connections_config(
|
async def set_connections_config(
|
||||||
request: Request,
|
request: Request,
|
||||||
form_data: DirectConnectionsConfigForm,
|
form_data: ConnectionsConfigForm,
|
||||||
user=Depends(get_admin_user),
|
user=Depends(get_admin_user),
|
||||||
):
|
):
|
||||||
request.app.state.config.ENABLE_DIRECT_CONNECTIONS = (
|
request.app.state.config.ENABLE_DIRECT_CONNECTIONS = (
|
||||||
form_data.ENABLE_DIRECT_CONNECTIONS
|
form_data.ENABLE_DIRECT_CONNECTIONS
|
||||||
)
|
)
|
||||||
|
request.app.state.config.ENABLE_BASE_MODELS_CACHE = (
|
||||||
|
form_data.ENABLE_BASE_MODELS_CACHE
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"ENABLE_DIRECT_CONNECTIONS": request.app.state.config.ENABLE_DIRECT_CONNECTIONS,
|
"ENABLE_DIRECT_CONNECTIONS": request.app.state.config.ENABLE_DIRECT_CONNECTIONS,
|
||||||
|
"ENABLE_BASE_MODELS_CACHE": request.app.state.config.ENABLE_BASE_MODELS_CACHE,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -155,17 +155,18 @@ def upload_file(
|
||||||
if process:
|
if process:
|
||||||
try:
|
try:
|
||||||
if file.content_type:
|
if file.content_type:
|
||||||
stt_supported_content_types = (
|
stt_supported_content_types = getattr(
|
||||||
request.app.state.config.STT_SUPPORTED_CONTENT_TYPES
|
request.app.state.config, "STT_SUPPORTED_CONTENT_TYPES", []
|
||||||
or [
|
|
||||||
"audio/*",
|
|
||||||
"video/webm",
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if any(
|
if any(
|
||||||
fnmatch(file.content_type, content_type)
|
fnmatch(file.content_type, content_type)
|
||||||
for content_type in stt_supported_content_types
|
for content_type in (
|
||||||
|
stt_supported_content_types
|
||||||
|
if stt_supported_content_types
|
||||||
|
and any(t.strip() for t in stt_supported_content_types)
|
||||||
|
else ["audio/*", "video/webm"]
|
||||||
|
)
|
||||||
):
|
):
|
||||||
file_path = Storage.get_file(file_path)
|
file_path = Storage.get_file(file_path)
|
||||||
result = transcribe(request, file_path, file_metadata)
|
result = transcribe(request, file_path, file_metadata)
|
||||||
|
|
|
||||||
|
|
@ -120,16 +120,14 @@ async def update_folder_name_by_id(
|
||||||
existing_folder = Folders.get_folder_by_parent_id_and_user_id_and_name(
|
existing_folder = Folders.get_folder_by_parent_id_and_user_id_and_name(
|
||||||
folder.parent_id, user.id, form_data.name
|
folder.parent_id, user.id, form_data.name
|
||||||
)
|
)
|
||||||
if existing_folder:
|
if existing_folder and existing_folder.id != id:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail=ERROR_MESSAGES.DEFAULT("Folder already exists"),
|
detail=ERROR_MESSAGES.DEFAULT("Folder already exists"),
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
folder = Folders.update_folder_name_by_id_and_user_id(
|
folder = Folders.update_folder_by_id_and_user_id(id, user.id, form_data)
|
||||||
id, user.id, form_data.name
|
|
||||||
)
|
|
||||||
|
|
||||||
return folder
|
return folder
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,7 @@ async def load_function_from_url(
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||||
async with session.get(
|
async with session.get(
|
||||||
url, headers={"Content-Type": "application/json"}
|
url, headers={"Content-Type": "application/json"}
|
||||||
) as resp:
|
) as resp:
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from urllib.parse import quote
|
||||||
import requests
|
import requests
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile
|
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile
|
||||||
from open_webui.config import CACHE_DIR
|
from open_webui.config import CACHE_DIR
|
||||||
|
|
@ -302,8 +303,16 @@ async def update_image_config(
|
||||||
):
|
):
|
||||||
set_image_model(request, form_data.MODEL)
|
set_image_model(request, form_data.MODEL)
|
||||||
|
|
||||||
|
if form_data.IMAGE_SIZE == "auto" and form_data.MODEL != "gpt-image-1":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=ERROR_MESSAGES.INCORRECT_FORMAT(
|
||||||
|
" (auto is only allowed with gpt-image-1)."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
pattern = r"^\d+x\d+$"
|
pattern = r"^\d+x\d+$"
|
||||||
if re.match(pattern, form_data.IMAGE_SIZE):
|
if form_data.IMAGE_SIZE == "auto" or re.match(pattern, form_data.IMAGE_SIZE):
|
||||||
request.app.state.config.IMAGE_SIZE = form_data.IMAGE_SIZE
|
request.app.state.config.IMAGE_SIZE = form_data.IMAGE_SIZE
|
||||||
else:
|
else:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -471,7 +480,14 @@ async def image_generations(
|
||||||
form_data: GenerateImageForm,
|
form_data: GenerateImageForm,
|
||||||
user=Depends(get_verified_user),
|
user=Depends(get_verified_user),
|
||||||
):
|
):
|
||||||
width, height = tuple(map(int, request.app.state.config.IMAGE_SIZE.split("x")))
|
# if IMAGE_SIZE = 'auto', default WidthxHeight to the 512x512 default
|
||||||
|
# This is only relevant when the user has set IMAGE_SIZE to 'auto' with an
|
||||||
|
# image model other than gpt-image-1, which is warned about on settings save
|
||||||
|
width, height = (
|
||||||
|
tuple(map(int, request.app.state.config.IMAGE_SIZE.split("x")))
|
||||||
|
if "x" in request.app.state.config.IMAGE_SIZE
|
||||||
|
else (512, 512)
|
||||||
|
)
|
||||||
|
|
||||||
r = None
|
r = None
|
||||||
try:
|
try:
|
||||||
|
|
@ -483,7 +499,7 @@ async def image_generations(
|
||||||
headers["Content-Type"] = "application/json"
|
headers["Content-Type"] = "application/json"
|
||||||
|
|
||||||
if ENABLE_FORWARD_USER_INFO_HEADERS:
|
if ENABLE_FORWARD_USER_INFO_HEADERS:
|
||||||
headers["X-OpenWebUI-User-Name"] = user.name
|
headers["X-OpenWebUI-User-Name"] = quote(user.name, safe=" ")
|
||||||
headers["X-OpenWebUI-User-Id"] = user.id
|
headers["X-OpenWebUI-User-Id"] = user.id
|
||||||
headers["X-OpenWebUI-User-Email"] = user.email
|
headers["X-OpenWebUI-User-Email"] = user.email
|
||||||
headers["X-OpenWebUI-User-Role"] = user.role
|
headers["X-OpenWebUI-User-Role"] = user.role
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,14 @@ async def get_notes(request: Request, user=Depends(get_verified_user)):
|
||||||
return notes
|
return notes
|
||||||
|
|
||||||
|
|
||||||
@router.get("/list", response_model=list[NoteUserResponse])
|
class NoteTitleIdResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
title: str
|
||||||
|
updated_at: int
|
||||||
|
created_at: int
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/list", response_model=list[NoteTitleIdResponse])
|
||||||
async def get_note_list(request: Request, user=Depends(get_verified_user)):
|
async def get_note_list(request: Request, user=Depends(get_verified_user)):
|
||||||
|
|
||||||
if user.role != "admin" and not has_permission(
|
if user.role != "admin" and not has_permission(
|
||||||
|
|
@ -63,13 +70,8 @@ async def get_note_list(request: Request, user=Depends(get_verified_user)):
|
||||||
)
|
)
|
||||||
|
|
||||||
notes = [
|
notes = [
|
||||||
NoteUserResponse(
|
NoteTitleIdResponse(**note.model_dump())
|
||||||
**{
|
for note in Notes.get_notes_by_user_id(user.id, "write")
|
||||||
**note.model_dump(),
|
|
||||||
"user": UserResponse(**Users.get_user_by_id(note.user_id).model_dump()),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
for note in Notes.get_notes_by_user_id(user.id, "read")
|
|
||||||
]
|
]
|
||||||
|
|
||||||
return notes
|
return notes
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ from urllib.parse import urlparse
|
||||||
import aiohttp
|
import aiohttp
|
||||||
from aiocache import cached
|
from aiocache import cached
|
||||||
import requests
|
import requests
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
from open_webui.models.chats import Chats
|
from open_webui.models.chats import Chats
|
||||||
from open_webui.models.users import UserModel
|
from open_webui.models.users import UserModel
|
||||||
|
|
@ -58,6 +59,7 @@ from open_webui.config import (
|
||||||
from open_webui.env import (
|
from open_webui.env import (
|
||||||
ENV,
|
ENV,
|
||||||
SRC_LOG_LEVELS,
|
SRC_LOG_LEVELS,
|
||||||
|
MODELS_CACHE_TTL,
|
||||||
AIOHTTP_CLIENT_SESSION_SSL,
|
AIOHTTP_CLIENT_SESSION_SSL,
|
||||||
AIOHTTP_CLIENT_TIMEOUT,
|
AIOHTTP_CLIENT_TIMEOUT,
|
||||||
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
|
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
|
||||||
|
|
@ -87,7 +89,7 @@ async def send_get_request(url, key=None, user: UserModel = None):
|
||||||
**({"Authorization": f"Bearer {key}"} if key else {}),
|
**({"Authorization": f"Bearer {key}"} if key else {}),
|
||||||
**(
|
**(
|
||||||
{
|
{
|
||||||
"X-OpenWebUI-User-Name": user.name,
|
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||||
"X-OpenWebUI-User-Id": user.id,
|
"X-OpenWebUI-User-Id": user.id,
|
||||||
"X-OpenWebUI-User-Email": user.email,
|
"X-OpenWebUI-User-Email": user.email,
|
||||||
"X-OpenWebUI-User-Role": user.role,
|
"X-OpenWebUI-User-Role": user.role,
|
||||||
|
|
@ -138,7 +140,7 @@ async def send_post_request(
|
||||||
**({"Authorization": f"Bearer {key}"} if key else {}),
|
**({"Authorization": f"Bearer {key}"} if key else {}),
|
||||||
**(
|
**(
|
||||||
{
|
{
|
||||||
"X-OpenWebUI-User-Name": user.name,
|
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||||
"X-OpenWebUI-User-Id": user.id,
|
"X-OpenWebUI-User-Id": user.id,
|
||||||
"X-OpenWebUI-User-Email": user.email,
|
"X-OpenWebUI-User-Email": user.email,
|
||||||
"X-OpenWebUI-User-Role": user.role,
|
"X-OpenWebUI-User-Role": user.role,
|
||||||
|
|
@ -242,7 +244,7 @@ async def verify_connection(
|
||||||
**({"Authorization": f"Bearer {key}"} if key else {}),
|
**({"Authorization": f"Bearer {key}"} if key else {}),
|
||||||
**(
|
**(
|
||||||
{
|
{
|
||||||
"X-OpenWebUI-User-Name": user.name,
|
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||||
"X-OpenWebUI-User-Id": user.id,
|
"X-OpenWebUI-User-Id": user.id,
|
||||||
"X-OpenWebUI-User-Email": user.email,
|
"X-OpenWebUI-User-Email": user.email,
|
||||||
"X-OpenWebUI-User-Role": user.role,
|
"X-OpenWebUI-User-Role": user.role,
|
||||||
|
|
@ -329,7 +331,7 @@ def merge_ollama_models_lists(model_lists):
|
||||||
return list(merged_models.values())
|
return list(merged_models.values())
|
||||||
|
|
||||||
|
|
||||||
@cached(ttl=1)
|
@cached(ttl=MODELS_CACHE_TTL)
|
||||||
async def get_all_models(request: Request, user: UserModel = None):
|
async def get_all_models(request: Request, user: UserModel = None):
|
||||||
log.info("get_all_models()")
|
log.info("get_all_models()")
|
||||||
if request.app.state.config.ENABLE_OLLAMA_API:
|
if request.app.state.config.ENABLE_OLLAMA_API:
|
||||||
|
|
@ -462,7 +464,7 @@ async def get_ollama_tags(
|
||||||
**({"Authorization": f"Bearer {key}"} if key else {}),
|
**({"Authorization": f"Bearer {key}"} if key else {}),
|
||||||
**(
|
**(
|
||||||
{
|
{
|
||||||
"X-OpenWebUI-User-Name": user.name,
|
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||||
"X-OpenWebUI-User-Id": user.id,
|
"X-OpenWebUI-User-Id": user.id,
|
||||||
"X-OpenWebUI-User-Email": user.email,
|
"X-OpenWebUI-User-Email": user.email,
|
||||||
"X-OpenWebUI-User-Role": user.role,
|
"X-OpenWebUI-User-Role": user.role,
|
||||||
|
|
@ -634,7 +636,10 @@ async def get_ollama_versions(request: Request, url_idx: Optional[int] = None):
|
||||||
|
|
||||||
|
|
||||||
class ModelNameForm(BaseModel):
|
class ModelNameForm(BaseModel):
|
||||||
name: str
|
model: Optional[str] = None
|
||||||
|
model_config = ConfigDict(
|
||||||
|
extra="allow",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/unload")
|
@router.post("/api/unload")
|
||||||
|
|
@ -643,10 +648,12 @@ async def unload_model(
|
||||||
form_data: ModelNameForm,
|
form_data: ModelNameForm,
|
||||||
user=Depends(get_admin_user),
|
user=Depends(get_admin_user),
|
||||||
):
|
):
|
||||||
model_name = form_data.name
|
form_data = form_data.model_dump(exclude_none=True)
|
||||||
|
model_name = form_data.get("model", form_data.get("name"))
|
||||||
|
|
||||||
if not model_name:
|
if not model_name:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400, detail="Missing 'name' of model to unload."
|
status_code=400, detail="Missing name of the model to unload."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Refresh/load models if needed, get mapping from name to URLs
|
# Refresh/load models if needed, get mapping from name to URLs
|
||||||
|
|
@ -709,11 +716,14 @@ async def pull_model(
|
||||||
url_idx: int = 0,
|
url_idx: int = 0,
|
||||||
user=Depends(get_admin_user),
|
user=Depends(get_admin_user),
|
||||||
):
|
):
|
||||||
|
form_data = form_data.model_dump(exclude_none=True)
|
||||||
|
form_data["model"] = form_data.get("model", form_data.get("name"))
|
||||||
|
|
||||||
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
|
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
|
||||||
log.info(f"url: {url}")
|
log.info(f"url: {url}")
|
||||||
|
|
||||||
# Admin should be able to pull models from any source
|
# Admin should be able to pull models from any source
|
||||||
payload = {**form_data.model_dump(exclude_none=True), "insecure": True}
|
payload = {**form_data, "insecure": True}
|
||||||
|
|
||||||
return await send_post_request(
|
return await send_post_request(
|
||||||
url=f"{url}/api/pull",
|
url=f"{url}/api/pull",
|
||||||
|
|
@ -724,7 +734,7 @@ async def pull_model(
|
||||||
|
|
||||||
|
|
||||||
class PushModelForm(BaseModel):
|
class PushModelForm(BaseModel):
|
||||||
name: str
|
model: str
|
||||||
insecure: Optional[bool] = None
|
insecure: Optional[bool] = None
|
||||||
stream: Optional[bool] = None
|
stream: Optional[bool] = None
|
||||||
|
|
||||||
|
|
@ -741,12 +751,12 @@ async def push_model(
|
||||||
await get_all_models(request, user=user)
|
await get_all_models(request, user=user)
|
||||||
models = request.app.state.OLLAMA_MODELS
|
models = request.app.state.OLLAMA_MODELS
|
||||||
|
|
||||||
if form_data.name in models:
|
if form_data.model in models:
|
||||||
url_idx = models[form_data.name]["urls"][0]
|
url_idx = models[form_data.model]["urls"][0]
|
||||||
else:
|
else:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.name),
|
detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.model),
|
||||||
)
|
)
|
||||||
|
|
||||||
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
|
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
|
||||||
|
|
@ -824,7 +834,7 @@ async def copy_model(
|
||||||
**({"Authorization": f"Bearer {key}"} if key else {}),
|
**({"Authorization": f"Bearer {key}"} if key else {}),
|
||||||
**(
|
**(
|
||||||
{
|
{
|
||||||
"X-OpenWebUI-User-Name": user.name,
|
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||||
"X-OpenWebUI-User-Id": user.id,
|
"X-OpenWebUI-User-Id": user.id,
|
||||||
"X-OpenWebUI-User-Email": user.email,
|
"X-OpenWebUI-User-Email": user.email,
|
||||||
"X-OpenWebUI-User-Role": user.role,
|
"X-OpenWebUI-User-Role": user.role,
|
||||||
|
|
@ -865,16 +875,21 @@ async def delete_model(
|
||||||
url_idx: Optional[int] = None,
|
url_idx: Optional[int] = None,
|
||||||
user=Depends(get_admin_user),
|
user=Depends(get_admin_user),
|
||||||
):
|
):
|
||||||
|
form_data = form_data.model_dump(exclude_none=True)
|
||||||
|
form_data["model"] = form_data.get("model", form_data.get("name"))
|
||||||
|
|
||||||
|
model = form_data.get("model")
|
||||||
|
|
||||||
if url_idx is None:
|
if url_idx is None:
|
||||||
await get_all_models(request, user=user)
|
await get_all_models(request, user=user)
|
||||||
models = request.app.state.OLLAMA_MODELS
|
models = request.app.state.OLLAMA_MODELS
|
||||||
|
|
||||||
if form_data.name in models:
|
if model in models:
|
||||||
url_idx = models[form_data.name]["urls"][0]
|
url_idx = models[model]["urls"][0]
|
||||||
else:
|
else:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.name),
|
detail=ERROR_MESSAGES.MODEL_NOT_FOUND(model),
|
||||||
)
|
)
|
||||||
|
|
||||||
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
|
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
|
||||||
|
|
@ -884,13 +899,13 @@ async def delete_model(
|
||||||
r = requests.request(
|
r = requests.request(
|
||||||
method="DELETE",
|
method="DELETE",
|
||||||
url=f"{url}/api/delete",
|
url=f"{url}/api/delete",
|
||||||
data=form_data.model_dump_json(exclude_none=True).encode(),
|
data=json.dumps(form_data).encode(),
|
||||||
headers={
|
headers={
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
**({"Authorization": f"Bearer {key}"} if key else {}),
|
**({"Authorization": f"Bearer {key}"} if key else {}),
|
||||||
**(
|
**(
|
||||||
{
|
{
|
||||||
"X-OpenWebUI-User-Name": user.name,
|
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||||
"X-OpenWebUI-User-Id": user.id,
|
"X-OpenWebUI-User-Id": user.id,
|
||||||
"X-OpenWebUI-User-Email": user.email,
|
"X-OpenWebUI-User-Email": user.email,
|
||||||
"X-OpenWebUI-User-Role": user.role,
|
"X-OpenWebUI-User-Role": user.role,
|
||||||
|
|
@ -926,16 +941,21 @@ async def delete_model(
|
||||||
async def show_model_info(
|
async def show_model_info(
|
||||||
request: Request, form_data: ModelNameForm, user=Depends(get_verified_user)
|
request: Request, form_data: ModelNameForm, user=Depends(get_verified_user)
|
||||||
):
|
):
|
||||||
|
form_data = form_data.model_dump(exclude_none=True)
|
||||||
|
form_data["model"] = form_data.get("model", form_data.get("name"))
|
||||||
|
|
||||||
await get_all_models(request, user=user)
|
await get_all_models(request, user=user)
|
||||||
models = request.app.state.OLLAMA_MODELS
|
models = request.app.state.OLLAMA_MODELS
|
||||||
|
|
||||||
if form_data.name not in models:
|
model = form_data.get("model")
|
||||||
|
|
||||||
|
if model not in models:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.name),
|
detail=ERROR_MESSAGES.MODEL_NOT_FOUND(model),
|
||||||
)
|
)
|
||||||
|
|
||||||
url_idx = random.choice(models[form_data.name]["urls"])
|
url_idx = random.choice(models[model]["urls"])
|
||||||
|
|
||||||
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
|
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
|
||||||
key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS)
|
key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS)
|
||||||
|
|
@ -949,7 +969,7 @@ async def show_model_info(
|
||||||
**({"Authorization": f"Bearer {key}"} if key else {}),
|
**({"Authorization": f"Bearer {key}"} if key else {}),
|
||||||
**(
|
**(
|
||||||
{
|
{
|
||||||
"X-OpenWebUI-User-Name": user.name,
|
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||||
"X-OpenWebUI-User-Id": user.id,
|
"X-OpenWebUI-User-Id": user.id,
|
||||||
"X-OpenWebUI-User-Email": user.email,
|
"X-OpenWebUI-User-Email": user.email,
|
||||||
"X-OpenWebUI-User-Role": user.role,
|
"X-OpenWebUI-User-Role": user.role,
|
||||||
|
|
@ -958,7 +978,7 @@ async def show_model_info(
|
||||||
else {}
|
else {}
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
data=form_data.model_dump_json(exclude_none=True).encode(),
|
data=json.dumps(form_data).encode(),
|
||||||
)
|
)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
|
|
||||||
|
|
@ -1036,7 +1056,7 @@ async def embed(
|
||||||
**({"Authorization": f"Bearer {key}"} if key else {}),
|
**({"Authorization": f"Bearer {key}"} if key else {}),
|
||||||
**(
|
**(
|
||||||
{
|
{
|
||||||
"X-OpenWebUI-User-Name": user.name,
|
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||||
"X-OpenWebUI-User-Id": user.id,
|
"X-OpenWebUI-User-Id": user.id,
|
||||||
"X-OpenWebUI-User-Email": user.email,
|
"X-OpenWebUI-User-Email": user.email,
|
||||||
"X-OpenWebUI-User-Role": user.role,
|
"X-OpenWebUI-User-Role": user.role,
|
||||||
|
|
@ -1123,7 +1143,7 @@ async def embeddings(
|
||||||
**({"Authorization": f"Bearer {key}"} if key else {}),
|
**({"Authorization": f"Bearer {key}"} if key else {}),
|
||||||
**(
|
**(
|
||||||
{
|
{
|
||||||
"X-OpenWebUI-User-Name": user.name,
|
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||||
"X-OpenWebUI-User-Id": user.id,
|
"X-OpenWebUI-User-Id": user.id,
|
||||||
"X-OpenWebUI-User-Email": user.email,
|
"X-OpenWebUI-User-Email": user.email,
|
||||||
"X-OpenWebUI-User-Role": user.role,
|
"X-OpenWebUI-User-Role": user.role,
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ from typing import Literal, Optional, overload
|
||||||
import aiohttp
|
import aiohttp
|
||||||
from aiocache import cached
|
from aiocache import cached
|
||||||
import requests
|
import requests
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
from fastapi import Depends, FastAPI, HTTPException, Request, APIRouter
|
from fastapi import Depends, FastAPI, HTTPException, Request, APIRouter
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
@ -21,6 +21,7 @@ from open_webui.config import (
|
||||||
CACHE_DIR,
|
CACHE_DIR,
|
||||||
)
|
)
|
||||||
from open_webui.env import (
|
from open_webui.env import (
|
||||||
|
MODELS_CACHE_TTL,
|
||||||
AIOHTTP_CLIENT_SESSION_SSL,
|
AIOHTTP_CLIENT_SESSION_SSL,
|
||||||
AIOHTTP_CLIENT_TIMEOUT,
|
AIOHTTP_CLIENT_TIMEOUT,
|
||||||
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
|
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
|
||||||
|
|
@ -66,7 +67,7 @@ async def send_get_request(url, key=None, user: UserModel = None):
|
||||||
**({"Authorization": f"Bearer {key}"} if key else {}),
|
**({"Authorization": f"Bearer {key}"} if key else {}),
|
||||||
**(
|
**(
|
||||||
{
|
{
|
||||||
"X-OpenWebUI-User-Name": user.name,
|
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||||
"X-OpenWebUI-User-Id": user.id,
|
"X-OpenWebUI-User-Id": user.id,
|
||||||
"X-OpenWebUI-User-Email": user.email,
|
"X-OpenWebUI-User-Email": user.email,
|
||||||
"X-OpenWebUI-User-Role": user.role,
|
"X-OpenWebUI-User-Role": user.role,
|
||||||
|
|
@ -225,7 +226,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
|
||||||
),
|
),
|
||||||
**(
|
**(
|
||||||
{
|
{
|
||||||
"X-OpenWebUI-User-Name": user.name,
|
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||||
"X-OpenWebUI-User-Id": user.id,
|
"X-OpenWebUI-User-Id": user.id,
|
||||||
"X-OpenWebUI-User-Email": user.email,
|
"X-OpenWebUI-User-Email": user.email,
|
||||||
"X-OpenWebUI-User-Role": user.role,
|
"X-OpenWebUI-User-Role": user.role,
|
||||||
|
|
@ -386,7 +387,7 @@ async def get_filtered_models(models, user):
|
||||||
return filtered_models
|
return filtered_models
|
||||||
|
|
||||||
|
|
||||||
@cached(ttl=1)
|
@cached(ttl=MODELS_CACHE_TTL)
|
||||||
async def get_all_models(request: Request, user: UserModel) -> dict[str, list]:
|
async def get_all_models(request: Request, user: UserModel) -> dict[str, list]:
|
||||||
log.info("get_all_models()")
|
log.info("get_all_models()")
|
||||||
|
|
||||||
|
|
@ -478,7 +479,7 @@ async def get_models(
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
**(
|
**(
|
||||||
{
|
{
|
||||||
"X-OpenWebUI-User-Name": user.name,
|
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||||
"X-OpenWebUI-User-Id": user.id,
|
"X-OpenWebUI-User-Id": user.id,
|
||||||
"X-OpenWebUI-User-Email": user.email,
|
"X-OpenWebUI-User-Email": user.email,
|
||||||
"X-OpenWebUI-User-Role": user.role,
|
"X-OpenWebUI-User-Role": user.role,
|
||||||
|
|
@ -573,7 +574,7 @@ async def verify_connection(
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
**(
|
**(
|
||||||
{
|
{
|
||||||
"X-OpenWebUI-User-Name": user.name,
|
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||||
"X-OpenWebUI-User-Id": user.id,
|
"X-OpenWebUI-User-Id": user.id,
|
||||||
"X-OpenWebUI-User-Email": user.email,
|
"X-OpenWebUI-User-Email": user.email,
|
||||||
"X-OpenWebUI-User-Role": user.role,
|
"X-OpenWebUI-User-Role": user.role,
|
||||||
|
|
@ -633,13 +634,7 @@ async def verify_connection(
|
||||||
raise HTTPException(status_code=500, detail=error_detail)
|
raise HTTPException(status_code=500, detail=error_detail)
|
||||||
|
|
||||||
|
|
||||||
def convert_to_azure_payload(
|
def get_azure_allowed_params(api_version: str) -> set[str]:
|
||||||
url,
|
|
||||||
payload: dict,
|
|
||||||
):
|
|
||||||
model = payload.get("model", "")
|
|
||||||
|
|
||||||
# Filter allowed parameters based on Azure OpenAI API
|
|
||||||
allowed_params = {
|
allowed_params = {
|
||||||
"messages",
|
"messages",
|
||||||
"temperature",
|
"temperature",
|
||||||
|
|
@ -669,6 +664,23 @@ def convert_to_azure_payload(
|
||||||
"max_completion_tokens",
|
"max_completion_tokens",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
if api_version >= "2024-09-01-preview":
|
||||||
|
allowed_params.add("stream_options")
|
||||||
|
except ValueError:
|
||||||
|
log.debug(
|
||||||
|
f"Invalid API version {api_version} for Azure OpenAI. Defaulting to allowed parameters."
|
||||||
|
)
|
||||||
|
|
||||||
|
return allowed_params
|
||||||
|
|
||||||
|
|
||||||
|
def convert_to_azure_payload(url, payload: dict, api_version: str):
|
||||||
|
model = payload.get("model", "")
|
||||||
|
|
||||||
|
# Filter allowed parameters based on Azure OpenAI API
|
||||||
|
allowed_params = get_azure_allowed_params(api_version)
|
||||||
|
|
||||||
# Special handling for o-series models
|
# Special handling for o-series models
|
||||||
if model.startswith("o") and model.endswith("-mini"):
|
if model.startswith("o") and model.endswith("-mini"):
|
||||||
# Convert max_tokens to max_completion_tokens for o-series models
|
# Convert max_tokens to max_completion_tokens for o-series models
|
||||||
|
|
@ -806,7 +818,7 @@ async def generate_chat_completion(
|
||||||
),
|
),
|
||||||
**(
|
**(
|
||||||
{
|
{
|
||||||
"X-OpenWebUI-User-Name": user.name,
|
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||||
"X-OpenWebUI-User-Id": user.id,
|
"X-OpenWebUI-User-Id": user.id,
|
||||||
"X-OpenWebUI-User-Email": user.email,
|
"X-OpenWebUI-User-Email": user.email,
|
||||||
"X-OpenWebUI-User-Role": user.role,
|
"X-OpenWebUI-User-Role": user.role,
|
||||||
|
|
@ -817,8 +829,8 @@ async def generate_chat_completion(
|
||||||
}
|
}
|
||||||
|
|
||||||
if api_config.get("azure", False):
|
if api_config.get("azure", False):
|
||||||
request_url, payload = convert_to_azure_payload(url, payload)
|
api_version = api_config.get("api_version", "2023-03-15-preview")
|
||||||
api_version = api_config.get("api_version", "") or "2023-03-15-preview"
|
request_url, payload = convert_to_azure_payload(url, payload, api_version)
|
||||||
headers["api-key"] = key
|
headers["api-key"] = key
|
||||||
headers["api-version"] = api_version
|
headers["api-version"] = api_version
|
||||||
request_url = f"{request_url}/chat/completions?api-version={api_version}"
|
request_url = f"{request_url}/chat/completions?api-version={api_version}"
|
||||||
|
|
@ -924,7 +936,7 @@ async def embeddings(request: Request, form_data: dict, user):
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
**(
|
**(
|
||||||
{
|
{
|
||||||
"X-OpenWebUI-User-Name": user.name,
|
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||||
"X-OpenWebUI-User-Id": user.id,
|
"X-OpenWebUI-User-Id": user.id,
|
||||||
"X-OpenWebUI-User-Email": user.email,
|
"X-OpenWebUI-User-Email": user.email,
|
||||||
"X-OpenWebUI-User-Role": user.role,
|
"X-OpenWebUI-User-Role": user.role,
|
||||||
|
|
@ -996,7 +1008,7 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
**(
|
**(
|
||||||
{
|
{
|
||||||
"X-OpenWebUI-User-Name": user.name,
|
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||||
"X-OpenWebUI-User-Id": user.id,
|
"X-OpenWebUI-User-Id": user.id,
|
||||||
"X-OpenWebUI-User-Email": user.email,
|
"X-OpenWebUI-User-Email": user.email,
|
||||||
"X-OpenWebUI-User-Role": user.role,
|
"X-OpenWebUI-User-Role": user.role,
|
||||||
|
|
@ -1007,16 +1019,15 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
|
||||||
}
|
}
|
||||||
|
|
||||||
if api_config.get("azure", False):
|
if api_config.get("azure", False):
|
||||||
|
api_version = api_config.get("api_version", "2023-03-15-preview")
|
||||||
headers["api-key"] = key
|
headers["api-key"] = key
|
||||||
headers["api-version"] = (
|
headers["api-version"] = api_version
|
||||||
api_config.get("api_version", "") or "2023-03-15-preview"
|
|
||||||
)
|
|
||||||
|
|
||||||
payload = json.loads(body)
|
payload = json.loads(body)
|
||||||
url, payload = convert_to_azure_payload(url, payload)
|
url, payload = convert_to_azure_payload(url, payload, api_version)
|
||||||
body = json.dumps(payload).encode()
|
body = json.dumps(payload).encode()
|
||||||
|
|
||||||
request_url = f"{url}/{path}?api-version={api_config.get('api_version', '2023-03-15-preview')}"
|
request_url = f"{url}/{path}?api-version={api_version}"
|
||||||
else:
|
else:
|
||||||
headers["Authorization"] = f"Bearer {key}"
|
headers["Authorization"] = f"Bearer {key}"
|
||||||
request_url = f"{url}/{path}"
|
request_url = f"{url}/{path}"
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ import tiktoken
|
||||||
|
|
||||||
|
|
||||||
from langchain.text_splitter import RecursiveCharacterTextSplitter, TokenTextSplitter
|
from langchain.text_splitter import RecursiveCharacterTextSplitter, TokenTextSplitter
|
||||||
|
from langchain_text_splitters import MarkdownHeaderTextSplitter
|
||||||
from langchain_core.documents import Document
|
from langchain_core.documents import Document
|
||||||
|
|
||||||
from open_webui.models.files import FileModel, Files
|
from open_webui.models.files import FileModel, Files
|
||||||
|
|
@ -69,6 +70,7 @@ from open_webui.retrieval.web.external import search_external
|
||||||
|
|
||||||
from open_webui.retrieval.utils import (
|
from open_webui.retrieval.utils import (
|
||||||
get_embedding_function,
|
get_embedding_function,
|
||||||
|
get_reranking_function,
|
||||||
get_model_path,
|
get_model_path,
|
||||||
query_collection,
|
query_collection,
|
||||||
query_collection_with_hybrid_search,
|
query_collection_with_hybrid_search,
|
||||||
|
|
@ -823,6 +825,12 @@ async def update_rag_config(
|
||||||
request.app.state.config.RAG_EXTERNAL_RERANKER_API_KEY,
|
request.app.state.config.RAG_EXTERNAL_RERANKER_API_KEY,
|
||||||
True,
|
True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
request.app.state.RERANKING_FUNCTION = get_reranking_function(
|
||||||
|
request.app.state.config.RAG_RERANKING_ENGINE,
|
||||||
|
request.app.state.config.RAG_RERANKING_MODEL,
|
||||||
|
request.app.state.rf,
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error(f"Error loading reranking model: {e}")
|
log.error(f"Error loading reranking model: {e}")
|
||||||
request.app.state.config.ENABLE_RAG_HYBRID_SEARCH = False
|
request.app.state.config.ENABLE_RAG_HYBRID_SEARCH = False
|
||||||
|
|
@ -1146,6 +1154,7 @@ def save_docs_to_vector_db(
|
||||||
chunk_overlap=request.app.state.config.CHUNK_OVERLAP,
|
chunk_overlap=request.app.state.config.CHUNK_OVERLAP,
|
||||||
add_start_index=True,
|
add_start_index=True,
|
||||||
)
|
)
|
||||||
|
docs = text_splitter.split_documents(docs)
|
||||||
elif request.app.state.config.TEXT_SPLITTER == "token":
|
elif request.app.state.config.TEXT_SPLITTER == "token":
|
||||||
log.info(
|
log.info(
|
||||||
f"Using token text splitter: {request.app.state.config.TIKTOKEN_ENCODING_NAME}"
|
f"Using token text splitter: {request.app.state.config.TIKTOKEN_ENCODING_NAME}"
|
||||||
|
|
@ -1158,11 +1167,56 @@ def save_docs_to_vector_db(
|
||||||
chunk_overlap=request.app.state.config.CHUNK_OVERLAP,
|
chunk_overlap=request.app.state.config.CHUNK_OVERLAP,
|
||||||
add_start_index=True,
|
add_start_index=True,
|
||||||
)
|
)
|
||||||
|
docs = text_splitter.split_documents(docs)
|
||||||
|
elif request.app.state.config.TEXT_SPLITTER == "markdown_header":
|
||||||
|
log.info("Using markdown header text splitter")
|
||||||
|
|
||||||
|
# Define headers to split on - covering most common markdown header levels
|
||||||
|
headers_to_split_on = [
|
||||||
|
("#", "Header 1"),
|
||||||
|
("##", "Header 2"),
|
||||||
|
("###", "Header 3"),
|
||||||
|
("####", "Header 4"),
|
||||||
|
("#####", "Header 5"),
|
||||||
|
("######", "Header 6"),
|
||||||
|
]
|
||||||
|
|
||||||
|
markdown_splitter = MarkdownHeaderTextSplitter(
|
||||||
|
headers_to_split_on=headers_to_split_on,
|
||||||
|
strip_headers=False, # Keep headers in content for context
|
||||||
|
)
|
||||||
|
|
||||||
|
md_split_docs = []
|
||||||
|
for doc in docs:
|
||||||
|
md_header_splits = markdown_splitter.split_text(doc.page_content)
|
||||||
|
text_splitter = RecursiveCharacterTextSplitter(
|
||||||
|
chunk_size=request.app.state.config.CHUNK_SIZE,
|
||||||
|
chunk_overlap=request.app.state.config.CHUNK_OVERLAP,
|
||||||
|
add_start_index=True,
|
||||||
|
)
|
||||||
|
md_header_splits = text_splitter.split_documents(md_header_splits)
|
||||||
|
|
||||||
|
# Convert back to Document objects, preserving original metadata
|
||||||
|
for split_chunk in md_header_splits:
|
||||||
|
headings_list = []
|
||||||
|
# Extract header values in order based on headers_to_split_on
|
||||||
|
for _, header_meta_key_name in headers_to_split_on:
|
||||||
|
if header_meta_key_name in split_chunk.metadata:
|
||||||
|
headings_list.append(
|
||||||
|
split_chunk.metadata[header_meta_key_name]
|
||||||
|
)
|
||||||
|
|
||||||
|
md_split_docs.append(
|
||||||
|
Document(
|
||||||
|
page_content=split_chunk.page_content,
|
||||||
|
metadata={**doc.metadata, "headings": headings_list},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
docs = md_split_docs
|
||||||
else:
|
else:
|
||||||
raise ValueError(ERROR_MESSAGES.DEFAULT("Invalid text splitter"))
|
raise ValueError(ERROR_MESSAGES.DEFAULT("Invalid text splitter"))
|
||||||
|
|
||||||
docs = text_splitter.split_documents(docs)
|
|
||||||
|
|
||||||
if len(docs) == 0:
|
if len(docs) == 0:
|
||||||
raise ValueError(ERROR_MESSAGES.EMPTY_CONTENT)
|
raise ValueError(ERROR_MESSAGES.EMPTY_CONTENT)
|
||||||
|
|
||||||
|
|
@ -1747,6 +1801,16 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]:
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
raise Exception("No TAVILY_API_KEY found in environment variables")
|
raise Exception("No TAVILY_API_KEY found in environment variables")
|
||||||
|
elif engine == "exa":
|
||||||
|
if request.app.state.config.EXA_API_KEY:
|
||||||
|
return search_exa(
|
||||||
|
request.app.state.config.EXA_API_KEY,
|
||||||
|
query,
|
||||||
|
request.app.state.config.WEB_SEARCH_RESULT_COUNT,
|
||||||
|
request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise Exception("No EXA_API_KEY found in environment variables")
|
||||||
elif engine == "searchapi":
|
elif engine == "searchapi":
|
||||||
if request.app.state.config.SEARCHAPI_API_KEY:
|
if request.app.state.config.SEARCHAPI_API_KEY:
|
||||||
return search_searchapi(
|
return search_searchapi(
|
||||||
|
|
@ -1784,6 +1848,13 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]:
|
||||||
request.app.state.config.WEB_SEARCH_RESULT_COUNT,
|
request.app.state.config.WEB_SEARCH_RESULT_COUNT,
|
||||||
request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
|
request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
|
||||||
)
|
)
|
||||||
|
elif engine == "exa":
|
||||||
|
return search_exa(
|
||||||
|
request.app.state.config.EXA_API_KEY,
|
||||||
|
query,
|
||||||
|
request.app.state.config.WEB_SEARCH_RESULT_COUNT,
|
||||||
|
request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
|
||||||
|
)
|
||||||
elif engine == "perplexity":
|
elif engine == "perplexity":
|
||||||
return search_perplexity(
|
return search_perplexity(
|
||||||
request.app.state.config.PERPLEXITY_API_KEY,
|
request.app.state.config.PERPLEXITY_API_KEY,
|
||||||
|
|
@ -1978,7 +2049,13 @@ def query_doc_handler(
|
||||||
query, prefix=prefix, user=user
|
query, prefix=prefix, user=user
|
||||||
),
|
),
|
||||||
k=form_data.k if form_data.k else request.app.state.config.TOP_K,
|
k=form_data.k if form_data.k else request.app.state.config.TOP_K,
|
||||||
reranking_function=request.app.state.rf,
|
reranking_function=(
|
||||||
|
lambda sentences: (
|
||||||
|
request.app.state.RERANKING_FUNCTION(sentences, user=user)
|
||||||
|
if request.app.state.RERANKING_FUNCTION
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
),
|
||||||
k_reranker=form_data.k_reranker
|
k_reranker=form_data.k_reranker
|
||||||
or request.app.state.config.TOP_K_RERANKER,
|
or request.app.state.config.TOP_K_RERANKER,
|
||||||
r=(
|
r=(
|
||||||
|
|
@ -2035,7 +2112,9 @@ def query_collection_handler(
|
||||||
query, prefix=prefix, user=user
|
query, prefix=prefix, user=user
|
||||||
),
|
),
|
||||||
k=form_data.k if form_data.k else request.app.state.config.TOP_K,
|
k=form_data.k if form_data.k else request.app.state.config.TOP_K,
|
||||||
reranking_function=request.app.state.rf,
|
reranking_function=lambda sentences: request.app.state.RERANKING_FUNCTION(
|
||||||
|
sentences, user=user
|
||||||
|
),
|
||||||
k_reranker=form_data.k_reranker
|
k_reranker=form_data.k_reranker
|
||||||
or request.app.state.config.TOP_K_RERANKER,
|
or request.app.state.config.TOP_K_RERANKER,
|
||||||
r=(
|
r=(
|
||||||
|
|
|
||||||
|
|
@ -153,7 +153,7 @@ async def load_tool_from_url(
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||||
async with session.get(
|
async with session.get(
|
||||||
url, headers={"Content-Type": "application/json"}
|
url, headers={"Content-Type": "application/json"}
|
||||||
) as resp:
|
) as resp:
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,18 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import random
|
||||||
|
|
||||||
import socketio
|
import socketio
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
from typing import Dict, Set
|
||||||
from redis import asyncio as aioredis
|
from redis import asyncio as aioredis
|
||||||
|
import pycrdt as Y
|
||||||
|
|
||||||
from open_webui.models.users import Users, UserNameResponse
|
from open_webui.models.users import Users, UserNameResponse
|
||||||
from open_webui.models.channels import Channels
|
from open_webui.models.channels import Channels
|
||||||
from open_webui.models.chats import Chats
|
from open_webui.models.chats import Chats
|
||||||
|
from open_webui.models.notes import Notes, NoteUpdateForm
|
||||||
from open_webui.utils.redis import (
|
from open_webui.utils.redis import (
|
||||||
get_sentinels_from_env,
|
get_sentinels_from_env,
|
||||||
get_sentinel_url_from_env,
|
get_sentinel_url_from_env,
|
||||||
|
|
@ -22,7 +27,11 @@ from open_webui.env import (
|
||||||
WEBSOCKET_SENTINEL_HOSTS,
|
WEBSOCKET_SENTINEL_HOSTS,
|
||||||
)
|
)
|
||||||
from open_webui.utils.auth import decode_token
|
from open_webui.utils.auth import decode_token
|
||||||
from open_webui.socket.utils import RedisDict, RedisLock
|
from open_webui.socket.utils import RedisDict, RedisLock, YdocManager
|
||||||
|
from open_webui.tasks import create_task, stop_item_tasks
|
||||||
|
from open_webui.utils.redis import get_redis_connection
|
||||||
|
from open_webui.utils.access_control import has_access, get_users_with_access
|
||||||
|
|
||||||
|
|
||||||
from open_webui.env import (
|
from open_webui.env import (
|
||||||
GLOBAL_LOG_LEVEL,
|
GLOBAL_LOG_LEVEL,
|
||||||
|
|
@ -35,6 +44,8 @@ log = logging.getLogger(__name__)
|
||||||
log.setLevel(SRC_LOG_LEVELS["SOCKET"])
|
log.setLevel(SRC_LOG_LEVELS["SOCKET"])
|
||||||
|
|
||||||
|
|
||||||
|
REDIS = None
|
||||||
|
|
||||||
if WEBSOCKET_MANAGER == "redis":
|
if WEBSOCKET_MANAGER == "redis":
|
||||||
if WEBSOCKET_SENTINEL_HOSTS:
|
if WEBSOCKET_SENTINEL_HOSTS:
|
||||||
mgr = socketio.AsyncRedisManager(
|
mgr = socketio.AsyncRedisManager(
|
||||||
|
|
@ -69,6 +80,14 @@ TIMEOUT_DURATION = 3
|
||||||
|
|
||||||
if WEBSOCKET_MANAGER == "redis":
|
if WEBSOCKET_MANAGER == "redis":
|
||||||
log.debug("Using Redis to manage websockets.")
|
log.debug("Using Redis to manage websockets.")
|
||||||
|
REDIS = get_redis_connection(
|
||||||
|
redis_url=WEBSOCKET_REDIS_URL,
|
||||||
|
redis_sentinels=get_sentinels_from_env(
|
||||||
|
WEBSOCKET_SENTINEL_HOSTS, WEBSOCKET_SENTINEL_PORT
|
||||||
|
),
|
||||||
|
async_mode=True,
|
||||||
|
)
|
||||||
|
|
||||||
redis_sentinels = get_sentinels_from_env(
|
redis_sentinels = get_sentinels_from_env(
|
||||||
WEBSOCKET_SENTINEL_HOSTS, WEBSOCKET_SENTINEL_PORT
|
WEBSOCKET_SENTINEL_HOSTS, WEBSOCKET_SENTINEL_PORT
|
||||||
)
|
)
|
||||||
|
|
@ -101,14 +120,37 @@ else:
|
||||||
SESSION_POOL = {}
|
SESSION_POOL = {}
|
||||||
USER_POOL = {}
|
USER_POOL = {}
|
||||||
USAGE_POOL = {}
|
USAGE_POOL = {}
|
||||||
|
|
||||||
aquire_func = release_func = renew_func = lambda: True
|
aquire_func = release_func = renew_func = lambda: True
|
||||||
|
|
||||||
|
|
||||||
|
YDOC_MANAGER = YdocManager(
|
||||||
|
redis=REDIS,
|
||||||
|
redis_key_prefix="open-webui:ydoc:documents",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def periodic_usage_pool_cleanup():
|
async def periodic_usage_pool_cleanup():
|
||||||
if not aquire_func():
|
max_retries = 2
|
||||||
log.debug("Usage pool cleanup lock already exists. Not running it.")
|
retry_delay = random.uniform(
|
||||||
return
|
WEBSOCKET_REDIS_LOCK_TIMEOUT / 2, WEBSOCKET_REDIS_LOCK_TIMEOUT
|
||||||
log.debug("Running periodic_usage_pool_cleanup")
|
)
|
||||||
|
for attempt in range(max_retries + 1):
|
||||||
|
if aquire_func():
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
if attempt < max_retries:
|
||||||
|
log.debug(
|
||||||
|
f"Cleanup lock already exists. Retry {attempt + 1} after {retry_delay}s..."
|
||||||
|
)
|
||||||
|
await asyncio.sleep(retry_delay)
|
||||||
|
else:
|
||||||
|
log.warning(
|
||||||
|
"Failed to acquire cleanup lock after retries. Skipping cleanup."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
log.debug("Running periodic_cleanup")
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
if not renew_func():
|
if not renew_func():
|
||||||
|
|
@ -169,16 +211,20 @@ def get_user_id_from_session_pool(sid):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def get_user_ids_from_room(room):
|
def get_session_ids_from_room(room):
|
||||||
|
"""Get all session IDs from a specific room."""
|
||||||
active_session_ids = sio.manager.get_participants(
|
active_session_ids = sio.manager.get_participants(
|
||||||
namespace="/",
|
namespace="/",
|
||||||
room=room,
|
room=room,
|
||||||
)
|
)
|
||||||
|
return [session_id[0] for session_id in active_session_ids]
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_ids_from_room(room):
|
||||||
|
active_session_ids = get_session_ids_from_room(room)
|
||||||
|
|
||||||
active_user_ids = list(
|
active_user_ids = list(
|
||||||
set(
|
set([SESSION_POOL.get(session_id)["id"] for session_id in active_session_ids])
|
||||||
[SESSION_POOL.get(session_id[0])["id"] for session_id in active_session_ids]
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
return active_user_ids
|
return active_user_ids
|
||||||
|
|
||||||
|
|
@ -298,6 +344,241 @@ async def channel_events(sid, data):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@sio.on("ydoc:document:join")
|
||||||
|
async def ydoc_document_join(sid, data):
|
||||||
|
"""Handle user joining a document"""
|
||||||
|
user = SESSION_POOL.get(sid)
|
||||||
|
|
||||||
|
try:
|
||||||
|
document_id = data["document_id"]
|
||||||
|
|
||||||
|
if document_id.startswith("note:"):
|
||||||
|
note_id = document_id.split(":")[1]
|
||||||
|
note = Notes.get_note_by_id(note_id)
|
||||||
|
if not note:
|
||||||
|
log.error(f"Note {note_id} not found")
|
||||||
|
return
|
||||||
|
|
||||||
|
if (
|
||||||
|
user.get("role") != "admin"
|
||||||
|
and user.get("id") != note.user_id
|
||||||
|
and not has_access(
|
||||||
|
user.get("id"), type="read", access_control=note.access_control
|
||||||
|
)
|
||||||
|
):
|
||||||
|
log.error(
|
||||||
|
f"User {user.get('id')} does not have access to note {note_id}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
user_id = data.get("user_id", sid)
|
||||||
|
user_name = data.get("user_name", "Anonymous")
|
||||||
|
user_color = data.get("user_color", "#000000")
|
||||||
|
|
||||||
|
log.info(f"User {user_id} joining document {document_id}")
|
||||||
|
await YDOC_MANAGER.add_user(document_id=document_id, user_id=sid)
|
||||||
|
|
||||||
|
# Join Socket.IO room
|
||||||
|
await sio.enter_room(sid, f"doc_{document_id}")
|
||||||
|
|
||||||
|
active_session_ids = get_session_ids_from_room(f"doc_{document_id}")
|
||||||
|
|
||||||
|
# Get the Yjs document state
|
||||||
|
ydoc = Y.Doc()
|
||||||
|
updates = await YDOC_MANAGER.get_updates(document_id)
|
||||||
|
for update in updates:
|
||||||
|
ydoc.apply_update(bytes(update))
|
||||||
|
|
||||||
|
# Encode the entire document state as an update
|
||||||
|
state_update = ydoc.get_update()
|
||||||
|
await sio.emit(
|
||||||
|
"ydoc:document:state",
|
||||||
|
{
|
||||||
|
"document_id": document_id,
|
||||||
|
"state": list(state_update), # Convert bytes to list for JSON
|
||||||
|
"sessions": active_session_ids,
|
||||||
|
},
|
||||||
|
room=sid,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Notify other users about the new user
|
||||||
|
await sio.emit(
|
||||||
|
"ydoc:user:joined",
|
||||||
|
{
|
||||||
|
"document_id": document_id,
|
||||||
|
"user_id": user_id,
|
||||||
|
"user_name": user_name,
|
||||||
|
"user_color": user_color,
|
||||||
|
},
|
||||||
|
room=f"doc_{document_id}",
|
||||||
|
skip_sid=sid,
|
||||||
|
)
|
||||||
|
|
||||||
|
log.info(f"User {user_id} successfully joined document {document_id}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Error in yjs_document_join: {e}")
|
||||||
|
await sio.emit("error", {"message": "Failed to join document"}, room=sid)
|
||||||
|
|
||||||
|
|
||||||
|
async def document_save_handler(document_id, data, user):
|
||||||
|
if document_id.startswith("note:"):
|
||||||
|
note_id = document_id.split(":")[1]
|
||||||
|
note = Notes.get_note_by_id(note_id)
|
||||||
|
if not note:
|
||||||
|
log.error(f"Note {note_id} not found")
|
||||||
|
return
|
||||||
|
|
||||||
|
if (
|
||||||
|
user.get("role") != "admin"
|
||||||
|
and user.get("id") != note.user_id
|
||||||
|
and not has_access(
|
||||||
|
user.get("id"), type="read", access_control=note.access_control
|
||||||
|
)
|
||||||
|
):
|
||||||
|
log.error(f"User {user.get('id')} does not have access to note {note_id}")
|
||||||
|
return
|
||||||
|
|
||||||
|
Notes.update_note_by_id(note_id, NoteUpdateForm(data=data))
|
||||||
|
|
||||||
|
|
||||||
|
@sio.on("ydoc:document:state")
|
||||||
|
async def yjs_document_state(sid, data):
|
||||||
|
"""Send the current state of the Yjs document to the user"""
|
||||||
|
try:
|
||||||
|
document_id = data["document_id"]
|
||||||
|
room = f"doc_{document_id}"
|
||||||
|
|
||||||
|
active_session_ids = get_session_ids_from_room(room)
|
||||||
|
print(active_session_ids)
|
||||||
|
if sid not in active_session_ids:
|
||||||
|
log.warning(f"Session {sid} not in room {room}. Cannot send state.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not await YDOC_MANAGER.document_exists(document_id):
|
||||||
|
log.warning(f"Document {document_id} not found")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get the Yjs document state
|
||||||
|
ydoc = Y.Doc()
|
||||||
|
updates = await YDOC_MANAGER.get_updates(document_id)
|
||||||
|
for update in updates:
|
||||||
|
ydoc.apply_update(bytes(update))
|
||||||
|
|
||||||
|
# Encode the entire document state as an update
|
||||||
|
state_update = ydoc.get_update()
|
||||||
|
|
||||||
|
await sio.emit(
|
||||||
|
"ydoc:document:state",
|
||||||
|
{
|
||||||
|
"document_id": document_id,
|
||||||
|
"state": list(state_update), # Convert bytes to list for JSON
|
||||||
|
"sessions": active_session_ids,
|
||||||
|
},
|
||||||
|
room=sid,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Error in yjs_document_state: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@sio.on("ydoc:document:update")
|
||||||
|
async def yjs_document_update(sid, data):
|
||||||
|
"""Handle Yjs document updates"""
|
||||||
|
try:
|
||||||
|
document_id = data["document_id"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
await stop_item_tasks(REDIS, document_id)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
user_id = data.get("user_id", sid)
|
||||||
|
|
||||||
|
update = data["update"] # List of bytes from frontend
|
||||||
|
|
||||||
|
await YDOC_MANAGER.append_to_updates(
|
||||||
|
document_id=document_id,
|
||||||
|
update=update, # Convert list of bytes to bytes
|
||||||
|
)
|
||||||
|
|
||||||
|
# Broadcast update to all other users in the document
|
||||||
|
await sio.emit(
|
||||||
|
"ydoc:document:update",
|
||||||
|
{
|
||||||
|
"document_id": document_id,
|
||||||
|
"user_id": user_id,
|
||||||
|
"update": update,
|
||||||
|
"socket_id": sid, # Add socket_id to match frontend filtering
|
||||||
|
},
|
||||||
|
room=f"doc_{document_id}",
|
||||||
|
skip_sid=sid,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def debounced_save():
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
await document_save_handler(
|
||||||
|
document_id, data.get("data", {}), SESSION_POOL.get(sid)
|
||||||
|
)
|
||||||
|
|
||||||
|
await create_task(REDIS, debounced_save(), document_id)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Error in yjs_document_update: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@sio.on("ydoc:document:leave")
|
||||||
|
async def yjs_document_leave(sid, data):
|
||||||
|
"""Handle user leaving a document"""
|
||||||
|
try:
|
||||||
|
document_id = data["document_id"]
|
||||||
|
user_id = data.get("user_id", sid)
|
||||||
|
|
||||||
|
log.info(f"User {user_id} leaving document {document_id}")
|
||||||
|
|
||||||
|
# Remove user from the document
|
||||||
|
await YDOC_MANAGER.remove_user(document_id=document_id, user_id=sid)
|
||||||
|
|
||||||
|
# Leave Socket.IO room
|
||||||
|
await sio.leave_room(sid, f"doc_{document_id}")
|
||||||
|
|
||||||
|
# Notify other users
|
||||||
|
await sio.emit(
|
||||||
|
"ydoc:user:left",
|
||||||
|
{"document_id": document_id, "user_id": user_id},
|
||||||
|
room=f"doc_{document_id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
YDOC_MANAGER.document_exists(document_id)
|
||||||
|
and len(await YDOC_MANAGER.get_users(document_id)) == 0
|
||||||
|
):
|
||||||
|
log.info(f"Cleaning up document {document_id} as no users are left")
|
||||||
|
await YDOC_MANAGER.clear_document(document_id)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Error in yjs_document_leave: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@sio.on("ydoc:awareness:update")
|
||||||
|
async def yjs_awareness_update(sid, data):
|
||||||
|
"""Handle awareness updates (cursors, selections, etc.)"""
|
||||||
|
try:
|
||||||
|
document_id = data["document_id"]
|
||||||
|
user_id = data.get("user_id", sid)
|
||||||
|
update = data["update"]
|
||||||
|
|
||||||
|
# Broadcast awareness update to all other users in the document
|
||||||
|
await sio.emit(
|
||||||
|
"ydoc:awareness:update",
|
||||||
|
{"document_id": document_id, "user_id": user_id, "update": update},
|
||||||
|
room=f"doc_{document_id}",
|
||||||
|
skip_sid=sid,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Error in yjs_awareness_update: {e}")
|
||||||
|
|
||||||
|
|
||||||
@sio.event
|
@sio.event
|
||||||
async def disconnect(sid):
|
async def disconnect(sid):
|
||||||
if sid in SESSION_POOL:
|
if sid in SESSION_POOL:
|
||||||
|
|
@ -309,6 +590,8 @@ async def disconnect(sid):
|
||||||
|
|
||||||
if len(USER_POOL[user_id]) == 0:
|
if len(USER_POOL[user_id]) == 0:
|
||||||
del USER_POOL[user_id]
|
del USER_POOL[user_id]
|
||||||
|
|
||||||
|
await YDOC_MANAGER.remove_user_from_all_documents(sid)
|
||||||
else:
|
else:
|
||||||
pass
|
pass
|
||||||
# print(f"Unknown session ID {sid} disconnected")
|
# print(f"Unknown session ID {sid} disconnected")
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import json
|
import json
|
||||||
import uuid
|
import uuid
|
||||||
from open_webui.utils.redis import get_redis_connection
|
from open_webui.utils.redis import get_redis_connection
|
||||||
|
from typing import Optional, List, Tuple
|
||||||
|
import pycrdt as Y
|
||||||
|
|
||||||
|
|
||||||
class RedisLock:
|
class RedisLock:
|
||||||
|
|
@ -89,3 +91,109 @@ class RedisDict:
|
||||||
if key not in self:
|
if key not in self:
|
||||||
self[key] = default
|
self[key] = default
|
||||||
return self[key]
|
return self[key]
|
||||||
|
|
||||||
|
|
||||||
|
class YdocManager:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
redis=None,
|
||||||
|
redis_key_prefix: str = "open-webui:ydoc:documents",
|
||||||
|
):
|
||||||
|
self._updates = {}
|
||||||
|
self._users = {}
|
||||||
|
self._redis = redis
|
||||||
|
self._redis_key_prefix = redis_key_prefix
|
||||||
|
|
||||||
|
async def append_to_updates(self, document_id: str, update: bytes):
|
||||||
|
document_id = document_id.replace(":", "_")
|
||||||
|
if self._redis:
|
||||||
|
redis_key = f"{self._redis_key_prefix}:{document_id}:updates"
|
||||||
|
await self._redis.rpush(redis_key, json.dumps(list(update)))
|
||||||
|
else:
|
||||||
|
if document_id not in self._updates:
|
||||||
|
self._updates[document_id] = []
|
||||||
|
self._updates[document_id].append(update)
|
||||||
|
|
||||||
|
async def get_updates(self, document_id: str) -> List[bytes]:
|
||||||
|
document_id = document_id.replace(":", "_")
|
||||||
|
|
||||||
|
if self._redis:
|
||||||
|
redis_key = f"{self._redis_key_prefix}:{document_id}:updates"
|
||||||
|
updates = await self._redis.lrange(redis_key, 0, -1)
|
||||||
|
return [bytes(json.loads(update)) for update in updates]
|
||||||
|
else:
|
||||||
|
return self._updates.get(document_id, [])
|
||||||
|
|
||||||
|
async def document_exists(self, document_id: str) -> bool:
|
||||||
|
document_id = document_id.replace(":", "_")
|
||||||
|
|
||||||
|
if self._redis:
|
||||||
|
redis_key = f"{self._redis_key_prefix}:{document_id}:updates"
|
||||||
|
return await self._redis.exists(redis_key) > 0
|
||||||
|
else:
|
||||||
|
return document_id in self._updates
|
||||||
|
|
||||||
|
async def get_users(self, document_id: str) -> List[str]:
|
||||||
|
document_id = document_id.replace(":", "_")
|
||||||
|
|
||||||
|
if self._redis:
|
||||||
|
redis_key = f"{self._redis_key_prefix}:{document_id}:users"
|
||||||
|
users = await self._redis.smembers(redis_key)
|
||||||
|
return list(users)
|
||||||
|
else:
|
||||||
|
return self._users.get(document_id, [])
|
||||||
|
|
||||||
|
async def add_user(self, document_id: str, user_id: str):
|
||||||
|
document_id = document_id.replace(":", "_")
|
||||||
|
|
||||||
|
if self._redis:
|
||||||
|
redis_key = f"{self._redis_key_prefix}:{document_id}:users"
|
||||||
|
await self._redis.sadd(redis_key, user_id)
|
||||||
|
else:
|
||||||
|
if document_id not in self._users:
|
||||||
|
self._users[document_id] = set()
|
||||||
|
self._users[document_id].add(user_id)
|
||||||
|
|
||||||
|
async def remove_user(self, document_id: str, user_id: str):
|
||||||
|
document_id = document_id.replace(":", "_")
|
||||||
|
|
||||||
|
if self._redis:
|
||||||
|
redis_key = f"{self._redis_key_prefix}:{document_id}:users"
|
||||||
|
await self._redis.srem(redis_key, user_id)
|
||||||
|
else:
|
||||||
|
if document_id in self._users and user_id in self._users[document_id]:
|
||||||
|
self._users[document_id].remove(user_id)
|
||||||
|
|
||||||
|
async def remove_user_from_all_documents(self, user_id: str):
|
||||||
|
if self._redis:
|
||||||
|
keys = await self._redis.keys(f"{self._redis_key_prefix}:*")
|
||||||
|
for key in keys:
|
||||||
|
if key.endswith(":users"):
|
||||||
|
await self._redis.srem(key, user_id)
|
||||||
|
|
||||||
|
document_id = key.split(":")[-2]
|
||||||
|
if len(await self.get_users(document_id)) == 0:
|
||||||
|
await self.clear_document(document_id)
|
||||||
|
|
||||||
|
else:
|
||||||
|
for document_id in list(self._users.keys()):
|
||||||
|
if user_id in self._users[document_id]:
|
||||||
|
self._users[document_id].remove(user_id)
|
||||||
|
if not self._users[document_id]:
|
||||||
|
del self._users[document_id]
|
||||||
|
|
||||||
|
await self.clear_document(document_id)
|
||||||
|
|
||||||
|
async def clear_document(self, document_id: str):
|
||||||
|
document_id = document_id.replace(":", "_")
|
||||||
|
|
||||||
|
if self._redis:
|
||||||
|
redis_key = f"{self._redis_key_prefix}:{document_id}:updates"
|
||||||
|
await self._redis.delete(redis_key)
|
||||||
|
redis_users_key = f"{self._redis_key_prefix}:{document_id}:users"
|
||||||
|
await self._redis.delete(redis_users_key)
|
||||||
|
else:
|
||||||
|
if document_id in self._updates:
|
||||||
|
del self._updates[document_id]
|
||||||
|
if document_id in self._users:
|
||||||
|
del self._users[document_id]
|
||||||
|
|
|
||||||
|
|
@ -3,25 +3,27 @@ import asyncio
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
from redis.asyncio import Redis
|
from redis.asyncio import Redis
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
from open_webui.env import SRC_LOG_LEVELS
|
||||||
|
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
log.setLevel(SRC_LOG_LEVELS["MAIN"])
|
||||||
|
|
||||||
# A dictionary to keep track of active tasks
|
# A dictionary to keep track of active tasks
|
||||||
tasks: Dict[str, asyncio.Task] = {}
|
tasks: Dict[str, asyncio.Task] = {}
|
||||||
chat_tasks = {}
|
item_tasks = {}
|
||||||
|
|
||||||
|
|
||||||
REDIS_TASKS_KEY = "open-webui:tasks"
|
REDIS_TASKS_KEY = "open-webui:tasks"
|
||||||
REDIS_CHAT_TASKS_KEY = "open-webui:tasks:chat"
|
REDIS_ITEM_TASKS_KEY = "open-webui:tasks:item"
|
||||||
REDIS_PUBSUB_CHANNEL = "open-webui:tasks:commands"
|
REDIS_PUBSUB_CHANNEL = "open-webui:tasks:commands"
|
||||||
|
|
||||||
|
|
||||||
def is_redis(request: Request) -> bool:
|
|
||||||
# Called everywhere a request is available to check Redis
|
|
||||||
return hasattr(request.app.state, "redis") and (request.app.state.redis is not None)
|
|
||||||
|
|
||||||
|
|
||||||
async def redis_task_command_listener(app):
|
async def redis_task_command_listener(app):
|
||||||
redis: Redis = app.state.redis
|
redis: Redis = app.state.redis
|
||||||
pubsub = redis.pubsub()
|
pubsub = redis.pubsub()
|
||||||
|
|
@ -38,7 +40,7 @@ async def redis_task_command_listener(app):
|
||||||
if local_task:
|
if local_task:
|
||||||
local_task.cancel()
|
local_task.cancel()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error handling distributed task command: {e}")
|
log.exception(f"Error handling distributed task command: {e}")
|
||||||
|
|
||||||
|
|
||||||
### ------------------------------
|
### ------------------------------
|
||||||
|
|
@ -46,21 +48,21 @@ async def redis_task_command_listener(app):
|
||||||
### ------------------------------
|
### ------------------------------
|
||||||
|
|
||||||
|
|
||||||
async def redis_save_task(redis: Redis, task_id: str, chat_id: Optional[str]):
|
async def redis_save_task(redis: Redis, task_id: str, item_id: Optional[str]):
|
||||||
pipe = redis.pipeline()
|
pipe = redis.pipeline()
|
||||||
pipe.hset(REDIS_TASKS_KEY, task_id, chat_id or "")
|
pipe.hset(REDIS_TASKS_KEY, task_id, item_id or "")
|
||||||
if chat_id:
|
if item_id:
|
||||||
pipe.sadd(f"{REDIS_CHAT_TASKS_KEY}:{chat_id}", task_id)
|
pipe.sadd(f"{REDIS_ITEM_TASKS_KEY}:{item_id}", task_id)
|
||||||
await pipe.execute()
|
await pipe.execute()
|
||||||
|
|
||||||
|
|
||||||
async def redis_cleanup_task(redis: Redis, task_id: str, chat_id: Optional[str]):
|
async def redis_cleanup_task(redis: Redis, task_id: str, item_id: Optional[str]):
|
||||||
pipe = redis.pipeline()
|
pipe = redis.pipeline()
|
||||||
pipe.hdel(REDIS_TASKS_KEY, task_id)
|
pipe.hdel(REDIS_TASKS_KEY, task_id)
|
||||||
if chat_id:
|
if item_id:
|
||||||
pipe.srem(f"{REDIS_CHAT_TASKS_KEY}:{chat_id}", task_id)
|
pipe.srem(f"{REDIS_ITEM_TASKS_KEY}:{item_id}", task_id)
|
||||||
if (await pipe.scard(f"{REDIS_CHAT_TASKS_KEY}:{chat_id}").execute())[-1] == 0:
|
if (await pipe.scard(f"{REDIS_ITEM_TASKS_KEY}:{item_id}").execute())[-1] == 0:
|
||||||
pipe.delete(f"{REDIS_CHAT_TASKS_KEY}:{chat_id}") # Remove if empty set
|
pipe.delete(f"{REDIS_ITEM_TASKS_KEY}:{item_id}") # Remove if empty set
|
||||||
await pipe.execute()
|
await pipe.execute()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -68,31 +70,31 @@ async def redis_list_tasks(redis: Redis) -> List[str]:
|
||||||
return list(await redis.hkeys(REDIS_TASKS_KEY))
|
return list(await redis.hkeys(REDIS_TASKS_KEY))
|
||||||
|
|
||||||
|
|
||||||
async def redis_list_chat_tasks(redis: Redis, chat_id: str) -> List[str]:
|
async def redis_list_item_tasks(redis: Redis, item_id: str) -> List[str]:
|
||||||
return list(await redis.smembers(f"{REDIS_CHAT_TASKS_KEY}:{chat_id}"))
|
return list(await redis.smembers(f"{REDIS_ITEM_TASKS_KEY}:{item_id}"))
|
||||||
|
|
||||||
|
|
||||||
async def redis_send_command(redis: Redis, command: dict):
|
async def redis_send_command(redis: Redis, command: dict):
|
||||||
await redis.publish(REDIS_PUBSUB_CHANNEL, json.dumps(command))
|
await redis.publish(REDIS_PUBSUB_CHANNEL, json.dumps(command))
|
||||||
|
|
||||||
|
|
||||||
async def cleanup_task(request, task_id: str, id=None):
|
async def cleanup_task(redis, task_id: str, id=None):
|
||||||
"""
|
"""
|
||||||
Remove a completed or canceled task from the global `tasks` dictionary.
|
Remove a completed or canceled task from the global `tasks` dictionary.
|
||||||
"""
|
"""
|
||||||
if is_redis(request):
|
if redis:
|
||||||
await redis_cleanup_task(request.app.state.redis, task_id, id)
|
await redis_cleanup_task(redis, task_id, id)
|
||||||
|
|
||||||
tasks.pop(task_id, None) # Remove the task if it exists
|
tasks.pop(task_id, None) # Remove the task if it exists
|
||||||
|
|
||||||
# If an ID is provided, remove the task from the chat_tasks dictionary
|
# If an ID is provided, remove the task from the item_tasks dictionary
|
||||||
if id and task_id in chat_tasks.get(id, []):
|
if id and task_id in item_tasks.get(id, []):
|
||||||
chat_tasks[id].remove(task_id)
|
item_tasks[id].remove(task_id)
|
||||||
if not chat_tasks[id]: # If no tasks left for this ID, remove the entry
|
if not item_tasks[id]: # If no tasks left for this ID, remove the entry
|
||||||
chat_tasks.pop(id, None)
|
item_tasks.pop(id, None)
|
||||||
|
|
||||||
|
|
||||||
async def create_task(request, coroutine, id=None):
|
async def create_task(redis, coroutine, id=None):
|
||||||
"""
|
"""
|
||||||
Create a new asyncio task and add it to the global task dictionary.
|
Create a new asyncio task and add it to the global task dictionary.
|
||||||
"""
|
"""
|
||||||
|
|
@ -101,48 +103,48 @@ async def create_task(request, coroutine, id=None):
|
||||||
|
|
||||||
# Add a done callback for cleanup
|
# Add a done callback for cleanup
|
||||||
task.add_done_callback(
|
task.add_done_callback(
|
||||||
lambda t: asyncio.create_task(cleanup_task(request, task_id, id))
|
lambda t: asyncio.create_task(cleanup_task(redis, task_id, id))
|
||||||
)
|
)
|
||||||
tasks[task_id] = task
|
tasks[task_id] = task
|
||||||
|
|
||||||
# If an ID is provided, associate the task with that ID
|
# If an ID is provided, associate the task with that ID
|
||||||
if chat_tasks.get(id):
|
if item_tasks.get(id):
|
||||||
chat_tasks[id].append(task_id)
|
item_tasks[id].append(task_id)
|
||||||
else:
|
else:
|
||||||
chat_tasks[id] = [task_id]
|
item_tasks[id] = [task_id]
|
||||||
|
|
||||||
if is_redis(request):
|
if redis:
|
||||||
await redis_save_task(request.app.state.redis, task_id, id)
|
await redis_save_task(redis, task_id, id)
|
||||||
|
|
||||||
return task_id, task
|
return task_id, task
|
||||||
|
|
||||||
|
|
||||||
async def list_tasks(request):
|
async def list_tasks(redis):
|
||||||
"""
|
"""
|
||||||
List all currently active task IDs.
|
List all currently active task IDs.
|
||||||
"""
|
"""
|
||||||
if is_redis(request):
|
if redis:
|
||||||
return await redis_list_tasks(request.app.state.redis)
|
return await redis_list_tasks(redis)
|
||||||
return list(tasks.keys())
|
return list(tasks.keys())
|
||||||
|
|
||||||
|
|
||||||
async def list_task_ids_by_chat_id(request, id):
|
async def list_task_ids_by_item_id(redis, id):
|
||||||
"""
|
"""
|
||||||
List all tasks associated with a specific ID.
|
List all tasks associated with a specific ID.
|
||||||
"""
|
"""
|
||||||
if is_redis(request):
|
if redis:
|
||||||
return await redis_list_chat_tasks(request.app.state.redis, id)
|
return await redis_list_item_tasks(redis, id)
|
||||||
return chat_tasks.get(id, [])
|
return item_tasks.get(id, [])
|
||||||
|
|
||||||
|
|
||||||
async def stop_task(request, task_id: str):
|
async def stop_task(redis, task_id: str):
|
||||||
"""
|
"""
|
||||||
Cancel a running task and remove it from the global task list.
|
Cancel a running task and remove it from the global task list.
|
||||||
"""
|
"""
|
||||||
if is_redis(request):
|
if redis:
|
||||||
# PUBSUB: All instances check if they have this task, and stop if so.
|
# PUBSUB: All instances check if they have this task, and stop if so.
|
||||||
await redis_send_command(
|
await redis_send_command(
|
||||||
request.app.state.redis,
|
redis,
|
||||||
{
|
{
|
||||||
"action": "stop",
|
"action": "stop",
|
||||||
"task_id": task_id,
|
"task_id": task_id,
|
||||||
|
|
@ -151,7 +153,7 @@ async def stop_task(request, task_id: str):
|
||||||
# Optionally check if task_id still in Redis a few moments later for feedback?
|
# Optionally check if task_id still in Redis a few moments later for feedback?
|
||||||
return {"status": True, "message": f"Stop signal sent for {task_id}"}
|
return {"status": True, "message": f"Stop signal sent for {task_id}"}
|
||||||
|
|
||||||
task = tasks.get(task_id)
|
task = tasks.pop(task_id)
|
||||||
if not task:
|
if not task:
|
||||||
raise ValueError(f"Task with ID {task_id} not found.")
|
raise ValueError(f"Task with ID {task_id} not found.")
|
||||||
|
|
||||||
|
|
@ -160,7 +162,22 @@ async def stop_task(request, task_id: str):
|
||||||
await task # Wait for the task to handle the cancellation
|
await task # Wait for the task to handle the cancellation
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
# Task successfully canceled
|
# 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": True, "message": f"Task {task_id} successfully stopped."}
|
||||||
|
|
||||||
return {"status": False, "message": f"Failed to stop task {task_id}."}
|
return {"status": False, "message": f"Failed to stop task {task_id}."}
|
||||||
|
|
||||||
|
|
||||||
|
async def stop_item_tasks(redis: Redis, item_id: str):
|
||||||
|
"""
|
||||||
|
Stop all tasks associated with a specific item ID.
|
||||||
|
"""
|
||||||
|
task_ids = await list_task_ids_by_item_id(redis, item_id)
|
||||||
|
if not task_ids:
|
||||||
|
return {"status": True, "message": f"No tasks found for item {item_id}."}
|
||||||
|
|
||||||
|
for task_id in task_ids:
|
||||||
|
result = await stop_task(redis, task_id)
|
||||||
|
if not result["status"]:
|
||||||
|
return result # Return the first failure
|
||||||
|
|
||||||
|
return {"status": True, "message": f"All tasks for item {item_id} stopped."}
|
||||||
|
|
|
||||||
|
|
@ -74,31 +74,37 @@ def override_static(path: str, content: str):
|
||||||
|
|
||||||
|
|
||||||
def get_license_data(app, key):
|
def get_license_data(app, key):
|
||||||
if key:
|
def handler(u):
|
||||||
try:
|
res = requests.post(
|
||||||
res = requests.post(
|
f"{u}/api/v1/license/",
|
||||||
"https://api.openwebui.com/api/v1/license/",
|
json={"key": key, "version": "1"},
|
||||||
json={"key": key, "version": "1"},
|
timeout=5,
|
||||||
timeout=5,
|
)
|
||||||
|
|
||||||
|
if getattr(res, "ok", False):
|
||||||
|
payload = getattr(res, "json", lambda: {})()
|
||||||
|
for k, v in payload.items():
|
||||||
|
if k == "resources":
|
||||||
|
for p, c in v.items():
|
||||||
|
globals().get("override_static", lambda a, b: None)(p, c)
|
||||||
|
elif k == "count":
|
||||||
|
setattr(app.state, "USER_COUNT", v)
|
||||||
|
elif k == "name":
|
||||||
|
setattr(app.state, "WEBUI_NAME", v)
|
||||||
|
elif k == "metadata":
|
||||||
|
setattr(app.state, "LICENSE_METADATA", v)
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
log.error(
|
||||||
|
f"License: retrieval issue: {getattr(res, 'text', 'unknown error')}"
|
||||||
)
|
)
|
||||||
|
|
||||||
if getattr(res, "ok", False):
|
if key:
|
||||||
payload = getattr(res, "json", lambda: {})()
|
us = ["https://api.openwebui.com", "https://licenses.api.openwebui.com"]
|
||||||
for k, v in payload.items():
|
try:
|
||||||
if k == "resources":
|
for u in us:
|
||||||
for p, c in v.items():
|
if handler(u):
|
||||||
globals().get("override_static", lambda a, b: None)(p, c)
|
return True
|
||||||
elif k == "count":
|
|
||||||
setattr(app.state, "USER_COUNT", v)
|
|
||||||
elif k == "name":
|
|
||||||
setattr(app.state, "WEBUI_NAME", v)
|
|
||||||
elif k == "metadata":
|
|
||||||
setattr(app.state, "LICENSE_METADATA", v)
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
log.error(
|
|
||||||
f"License: retrieval issue: {getattr(res, 'text', 'unknown error')}"
|
|
||||||
)
|
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
log.exception(f"License: Uncaught Exception: {ex}")
|
log.exception(f"License: Uncaught Exception: {ex}")
|
||||||
return False
|
return False
|
||||||
|
|
|
||||||
|
|
@ -419,7 +419,7 @@ async def chat_action(request: Request, action_id: str, form_data: dict, user: A
|
||||||
params[key] = value
|
params[key] = value
|
||||||
|
|
||||||
if "__user__" in sig.parameters:
|
if "__user__" in sig.parameters:
|
||||||
__user__ = (user.model_dump() if isinstance(user, UserModel) else {},)
|
__user__ = user.model_dump() if isinstance(user, UserModel) else {}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if hasattr(function_module, "UserValves"):
|
if hasattr(function_module, "UserValves"):
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,9 @@ from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
|
||||||
from open_webui.env import (
|
from open_webui.env import (
|
||||||
|
AUDIT_UVICORN_LOGGER_NAMES,
|
||||||
AUDIT_LOG_FILE_ROTATION_SIZE,
|
AUDIT_LOG_FILE_ROTATION_SIZE,
|
||||||
AUDIT_LOG_LEVEL,
|
AUDIT_LOG_LEVEL,
|
||||||
AUDIT_LOGS_FILE_PATH,
|
AUDIT_LOGS_FILE_PATH,
|
||||||
|
|
@ -128,11 +130,13 @@ def start_logger():
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
handlers=[InterceptHandler()], level=GLOBAL_LOG_LEVEL, force=True
|
handlers=[InterceptHandler()], level=GLOBAL_LOG_LEVEL, force=True
|
||||||
)
|
)
|
||||||
|
|
||||||
for uvicorn_logger_name in ["uvicorn", "uvicorn.error"]:
|
for uvicorn_logger_name in ["uvicorn", "uvicorn.error"]:
|
||||||
uvicorn_logger = logging.getLogger(uvicorn_logger_name)
|
uvicorn_logger = logging.getLogger(uvicorn_logger_name)
|
||||||
uvicorn_logger.setLevel(GLOBAL_LOG_LEVEL)
|
uvicorn_logger.setLevel(GLOBAL_LOG_LEVEL)
|
||||||
uvicorn_logger.handlers = []
|
uvicorn_logger.handlers = []
|
||||||
for uvicorn_logger_name in ["uvicorn.access"]:
|
|
||||||
|
for uvicorn_logger_name in AUDIT_UVICORN_LOGGER_NAMES:
|
||||||
uvicorn_logger = logging.getLogger(uvicorn_logger_name)
|
uvicorn_logger = logging.getLogger(uvicorn_logger_name)
|
||||||
uvicorn_logger.setLevel(GLOBAL_LOG_LEVEL)
|
uvicorn_logger.setLevel(GLOBAL_LOG_LEVEL)
|
||||||
uvicorn_logger.handlers = [InterceptHandler()]
|
uvicorn_logger.handlers = [InterceptHandler()]
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ from starlette.responses import Response, StreamingResponse
|
||||||
|
|
||||||
|
|
||||||
from open_webui.models.chats import Chats
|
from open_webui.models.chats import Chats
|
||||||
|
from open_webui.models.folders import Folders
|
||||||
from open_webui.models.users import Users
|
from open_webui.models.users import Users
|
||||||
from open_webui.socket.main import (
|
from open_webui.socket.main import (
|
||||||
get_event_call,
|
get_event_call,
|
||||||
|
|
@ -56,7 +57,7 @@ from open_webui.models.users import UserModel
|
||||||
from open_webui.models.functions import Functions
|
from open_webui.models.functions import Functions
|
||||||
from open_webui.models.models import Models
|
from open_webui.models.models import Models
|
||||||
|
|
||||||
from open_webui.retrieval.utils import get_sources_from_files
|
from open_webui.retrieval.utils import get_sources_from_items
|
||||||
|
|
||||||
|
|
||||||
from open_webui.utils.chat import generate_chat_completion
|
from open_webui.utils.chat import generate_chat_completion
|
||||||
|
|
@ -248,30 +249,28 @@ async def chat_completion_tools_handler(
|
||||||
if tool_id
|
if tool_id
|
||||||
else f"{tool_function_name}"
|
else f"{tool_function_name}"
|
||||||
)
|
)
|
||||||
if tool.get("metadata", {}).get("citation", False) or tool.get(
|
|
||||||
"direct", False
|
# Citation is enabled for this tool
|
||||||
):
|
sources.append(
|
||||||
# Citation is enabled for this tool
|
{
|
||||||
sources.append(
|
"source": {
|
||||||
{
|
"name": (f"TOOL:{tool_name}"),
|
||||||
"source": {
|
},
|
||||||
"name": (f"TOOL:{tool_name}"),
|
"document": [tool_result],
|
||||||
},
|
"metadata": [
|
||||||
"document": [tool_result],
|
{
|
||||||
"metadata": [
|
"source": (f"TOOL:{tool_name}"),
|
||||||
{
|
"parameters": tool_function_params,
|
||||||
"source": (f"TOOL:{tool_name}"),
|
}
|
||||||
"parameters": tool_function_params,
|
],
|
||||||
}
|
"tool_result": True,
|
||||||
],
|
}
|
||||||
}
|
)
|
||||||
)
|
# Citation is not enabled for this tool
|
||||||
else:
|
body["messages"] = add_or_update_user_message(
|
||||||
# Citation is not enabled for this tool
|
f"\nTool `{tool_name}` Output: {tool_result}",
|
||||||
body["messages"] = add_or_update_user_message(
|
body["messages"],
|
||||||
f"\nTool `{tool_name}` Output: {tool_result}",
|
)
|
||||||
body["messages"],
|
|
||||||
)
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
tools[tool_function_name]
|
tools[tool_function_name]
|
||||||
|
|
@ -640,25 +639,34 @@ async def chat_completion_files_handler(
|
||||||
queries = [get_last_user_message(body["messages"])]
|
queries = [get_last_user_message(body["messages"])]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Offload get_sources_from_files to a separate thread
|
# Offload get_sources_from_items to a separate thread
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
with ThreadPoolExecutor() as executor:
|
with ThreadPoolExecutor() as executor:
|
||||||
sources = await loop.run_in_executor(
|
sources = await loop.run_in_executor(
|
||||||
executor,
|
executor,
|
||||||
lambda: get_sources_from_files(
|
lambda: get_sources_from_items(
|
||||||
request=request,
|
request=request,
|
||||||
files=files,
|
items=files,
|
||||||
queries=queries,
|
queries=queries,
|
||||||
embedding_function=lambda query, prefix: request.app.state.EMBEDDING_FUNCTION(
|
embedding_function=lambda query, prefix: request.app.state.EMBEDDING_FUNCTION(
|
||||||
query, prefix=prefix, user=user
|
query, prefix=prefix, user=user
|
||||||
),
|
),
|
||||||
k=request.app.state.config.TOP_K,
|
k=request.app.state.config.TOP_K,
|
||||||
reranking_function=request.app.state.rf,
|
reranking_function=(
|
||||||
|
lambda sentences: (
|
||||||
|
request.app.state.RERANKING_FUNCTION(
|
||||||
|
sentences, user=user
|
||||||
|
)
|
||||||
|
if request.app.state.RERANKING_FUNCTION
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
),
|
||||||
k_reranker=request.app.state.config.TOP_K_RERANKER,
|
k_reranker=request.app.state.config.TOP_K_RERANKER,
|
||||||
r=request.app.state.config.RELEVANCE_THRESHOLD,
|
r=request.app.state.config.RELEVANCE_THRESHOLD,
|
||||||
hybrid_bm25_weight=request.app.state.config.HYBRID_BM25_WEIGHT,
|
hybrid_bm25_weight=request.app.state.config.HYBRID_BM25_WEIGHT,
|
||||||
hybrid_search=request.app.state.config.ENABLE_RAG_HYBRID_SEARCH,
|
hybrid_search=request.app.state.config.ENABLE_RAG_HYBRID_SEARCH,
|
||||||
full_context=request.app.state.config.RAG_FULL_CONTEXT,
|
full_context=request.app.state.config.RAG_FULL_CONTEXT,
|
||||||
|
user=user,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -718,6 +726,10 @@ def apply_params_to_form_data(form_data, model):
|
||||||
|
|
||||||
|
|
||||||
async def process_chat_payload(request, form_data, user, metadata, model):
|
async def process_chat_payload(request, form_data, user, metadata, model):
|
||||||
|
# Pipeline Inlet -> Filter Inlet -> Chat Memory -> Chat Web Search -> Chat Image Generation
|
||||||
|
# -> Chat Code Interpreter (Form Data Update) -> (Default) Chat Tools Function Calling
|
||||||
|
# -> Chat Files
|
||||||
|
|
||||||
form_data = apply_params_to_form_data(form_data, model)
|
form_data = apply_params_to_form_data(form_data, model)
|
||||||
log.debug(f"form_data: {form_data}")
|
log.debug(f"form_data: {form_data}")
|
||||||
|
|
||||||
|
|
@ -752,6 +764,26 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
||||||
events = []
|
events = []
|
||||||
sources = []
|
sources = []
|
||||||
|
|
||||||
|
# Folder "Project" handling
|
||||||
|
# Check if the request has chat_id and is inside of a folder
|
||||||
|
chat_id = metadata.get("chat_id", None)
|
||||||
|
if chat_id and user:
|
||||||
|
chat = Chats.get_chat_by_id_and_user_id(chat_id, user.id)
|
||||||
|
if chat and chat.folder_id:
|
||||||
|
folder = Folders.get_folder_by_id_and_user_id(chat.folder_id, user.id)
|
||||||
|
|
||||||
|
if folder and folder.data:
|
||||||
|
if "system_prompt" in folder.data:
|
||||||
|
form_data["messages"] = add_or_update_system_message(
|
||||||
|
folder.data["system_prompt"], form_data["messages"]
|
||||||
|
)
|
||||||
|
if "files" in folder.data:
|
||||||
|
form_data["files"] = [
|
||||||
|
*folder.data["files"],
|
||||||
|
*form_data.get("files", []),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Model "Knowledge" handling
|
||||||
user_message = get_last_user_message(form_data["messages"])
|
user_message = get_last_user_message(form_data["messages"])
|
||||||
model_knowledge = model.get("info", {}).get("meta", {}).get("knowledge", False)
|
model_knowledge = model.get("info", {}).get("meta", {}).get("knowledge", False)
|
||||||
|
|
||||||
|
|
@ -804,7 +836,6 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
||||||
filter_functions = [
|
filter_functions = [
|
||||||
Functions.get_function_by_id(filter_id)
|
Functions.get_function_by_id(filter_id)
|
||||||
for filter_id in get_sorted_filter_ids(
|
for filter_id in get_sorted_filter_ids(
|
||||||
|
|
@ -912,7 +943,6 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
||||||
request, form_data, extra_params, user, models, tools_dict
|
request, form_data, extra_params, user, models, tools_dict
|
||||||
)
|
)
|
||||||
sources.extend(flags.get("sources", []))
|
sources.extend(flags.get("sources", []))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.exception(e)
|
log.exception(e)
|
||||||
|
|
||||||
|
|
@ -925,55 +955,59 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
||||||
# If context is not empty, insert it into the messages
|
# If context is not empty, insert it into the messages
|
||||||
if len(sources) > 0:
|
if len(sources) > 0:
|
||||||
context_string = ""
|
context_string = ""
|
||||||
citation_idx = {}
|
citation_idx_map = {}
|
||||||
|
|
||||||
for source in sources:
|
for source in sources:
|
||||||
if "document" in source:
|
is_tool_result = source.get("tool_result", False)
|
||||||
for doc_context, doc_meta in zip(
|
|
||||||
|
if "document" in source and not is_tool_result:
|
||||||
|
for document_text, document_metadata in zip(
|
||||||
source["document"], source["metadata"]
|
source["document"], source["metadata"]
|
||||||
):
|
):
|
||||||
source_name = source.get("source", {}).get("name", None)
|
source_name = source.get("source", {}).get("name", None)
|
||||||
citation_id = (
|
source_id = (
|
||||||
doc_meta.get("source", None)
|
document_metadata.get("source", None)
|
||||||
or source.get("source", {}).get("id", None)
|
or source.get("source", {}).get("id", None)
|
||||||
or "N/A"
|
or "N/A"
|
||||||
)
|
)
|
||||||
if citation_id not in citation_idx:
|
|
||||||
citation_idx[citation_id] = len(citation_idx) + 1
|
if source_id not in citation_idx_map:
|
||||||
|
citation_idx_map[source_id] = len(citation_idx_map) + 1
|
||||||
|
|
||||||
context_string += (
|
context_string += (
|
||||||
f'<source id="{citation_idx[citation_id]}"'
|
f'<source id="{citation_idx_map[source_id]}"'
|
||||||
+ (f' name="{source_name}"' if source_name else "")
|
+ (f' name="{source_name}"' if source_name else "")
|
||||||
+ f">{doc_context}</source>\n"
|
+ f">{document_text}</source>\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
context_string = context_string.strip()
|
context_string = context_string.strip()
|
||||||
prompt = get_last_user_message(form_data["messages"])
|
|
||||||
|
|
||||||
|
prompt = get_last_user_message(form_data["messages"])
|
||||||
if prompt is None:
|
if prompt is None:
|
||||||
raise Exception("No user message found")
|
raise Exception("No user message found")
|
||||||
if (
|
|
||||||
request.app.state.config.RELEVANCE_THRESHOLD == 0
|
|
||||||
and context_string.strip() == ""
|
|
||||||
):
|
|
||||||
log.debug(
|
|
||||||
f"With a 0 relevancy threshold for RAG, the context cannot be empty"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Workaround for Ollama 2.0+ system prompt issue
|
if context_string == "":
|
||||||
# TODO: replace with add_or_update_system_message
|
if request.app.state.config.RELEVANCE_THRESHOLD == 0:
|
||||||
if model.get("owned_by") == "ollama":
|
log.debug(
|
||||||
form_data["messages"] = prepend_to_first_user_message_content(
|
f"With a 0 relevancy threshold for RAG, the context cannot be empty"
|
||||||
rag_template(
|
)
|
||||||
request.app.state.config.RAG_TEMPLATE, context_string, prompt
|
|
||||||
),
|
|
||||||
form_data["messages"],
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
form_data["messages"] = add_or_update_system_message(
|
# Workaround for Ollama 2.0+ system prompt issue
|
||||||
rag_template(
|
# TODO: replace with add_or_update_system_message
|
||||||
request.app.state.config.RAG_TEMPLATE, context_string, prompt
|
if model.get("owned_by") == "ollama":
|
||||||
),
|
form_data["messages"] = prepend_to_first_user_message_content(
|
||||||
form_data["messages"],
|
rag_template(
|
||||||
)
|
request.app.state.config.RAG_TEMPLATE, context_string, prompt
|
||||||
|
),
|
||||||
|
form_data["messages"],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
form_data["messages"] = add_or_update_system_message(
|
||||||
|
rag_template(
|
||||||
|
request.app.state.config.RAG_TEMPLATE, context_string, prompt
|
||||||
|
),
|
||||||
|
form_data["messages"],
|
||||||
|
)
|
||||||
|
|
||||||
# If there are citations, add them to the data_items
|
# If there are citations, add them to the data_items
|
||||||
sources = [
|
sources = [
|
||||||
|
|
@ -1370,7 +1404,7 @@ async def process_chat_response(
|
||||||
return len(backtick_segments) > 1 and len(backtick_segments) % 2 == 0
|
return len(backtick_segments) > 1 and len(backtick_segments) % 2 == 0
|
||||||
|
|
||||||
# Handle as a background task
|
# Handle as a background task
|
||||||
async def post_response_handler(response, events):
|
async def response_handler(response, events):
|
||||||
def serialize_content_blocks(content_blocks, raw=False):
|
def serialize_content_blocks(content_blocks, raw=False):
|
||||||
content = ""
|
content = ""
|
||||||
|
|
||||||
|
|
@ -1405,7 +1439,7 @@ async def process_chat_response(
|
||||||
break
|
break
|
||||||
|
|
||||||
if tool_result:
|
if tool_result:
|
||||||
tool_calls_display_content = f'{tool_calls_display_content}\n<details type="tool_calls" done="true" id="{tool_call_id}" name="{tool_name}" arguments="{html.escape(json.dumps(tool_arguments))}" result="{html.escape(json.dumps(tool_result))}" files="{html.escape(json.dumps(tool_result_files)) if tool_result_files else ""}">\n<summary>Tool Executed</summary>\n</details>\n'
|
tool_calls_display_content = f'{tool_calls_display_content}\n<details type="tool_calls" done="true" id="{tool_call_id}" name="{tool_name}" arguments="{html.escape(json.dumps(tool_arguments))}" result="{html.escape(json.dumps(tool_result, ensure_ascii=False))}" files="{html.escape(json.dumps(tool_result_files)) if tool_result_files else ""}">\n<summary>Tool Executed</summary>\n</details>\n'
|
||||||
else:
|
else:
|
||||||
tool_calls_display_content = f'{tool_calls_display_content}\n<details type="tool_calls" done="false" id="{tool_call_id}" name="{tool_name}" arguments="{html.escape(json.dumps(tool_arguments))}">\n<summary>Executing...</summary>\n</details>'
|
tool_calls_display_content = f'{tool_calls_display_content}\n<details type="tool_calls" done="false" id="{tool_call_id}" name="{tool_name}" arguments="{html.escape(json.dumps(tool_arguments))}">\n<summary>Executing...</summary>\n</details>'
|
||||||
|
|
||||||
|
|
@ -1741,7 +1775,7 @@ async def process_chat_response(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
async def stream_body_handler(response):
|
async def stream_body_handler(response, form_data):
|
||||||
nonlocal content
|
nonlocal content
|
||||||
nonlocal content_blocks
|
nonlocal content_blocks
|
||||||
|
|
||||||
|
|
@ -1770,7 +1804,7 @@ async def process_chat_response(
|
||||||
filter_functions=filter_functions,
|
filter_functions=filter_functions,
|
||||||
filter_type="stream",
|
filter_type="stream",
|
||||||
form_data=data,
|
form_data=data,
|
||||||
extra_params=extra_params,
|
extra_params={"__body__": form_data, **extra_params},
|
||||||
)
|
)
|
||||||
|
|
||||||
if data:
|
if data:
|
||||||
|
|
@ -2032,7 +2066,7 @@ async def process_chat_response(
|
||||||
if response.background:
|
if response.background:
|
||||||
await response.background()
|
await response.background()
|
||||||
|
|
||||||
await stream_body_handler(response)
|
await stream_body_handler(response, form_data)
|
||||||
|
|
||||||
MAX_TOOL_CALL_RETRIES = 10
|
MAX_TOOL_CALL_RETRIES = 10
|
||||||
tool_call_retries = 0
|
tool_call_retries = 0
|
||||||
|
|
@ -2148,7 +2182,9 @@ async def process_chat_response(
|
||||||
if isinstance(tool_result, dict) or isinstance(
|
if isinstance(tool_result, dict) or isinstance(
|
||||||
tool_result, list
|
tool_result, list
|
||||||
):
|
):
|
||||||
tool_result = json.dumps(tool_result, indent=2)
|
tool_result = json.dumps(
|
||||||
|
tool_result, indent=2, ensure_ascii=False
|
||||||
|
)
|
||||||
|
|
||||||
results.append(
|
results.append(
|
||||||
{
|
{
|
||||||
|
|
@ -2181,22 +2217,24 @@ async def process_chat_response(
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
new_form_data = {
|
||||||
|
"model": model_id,
|
||||||
|
"stream": True,
|
||||||
|
"tools": form_data["tools"],
|
||||||
|
"messages": [
|
||||||
|
*form_data["messages"],
|
||||||
|
*convert_content_blocks_to_messages(content_blocks),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
res = await generate_chat_completion(
|
res = await generate_chat_completion(
|
||||||
request,
|
request,
|
||||||
{
|
new_form_data,
|
||||||
"model": model_id,
|
|
||||||
"stream": True,
|
|
||||||
"tools": form_data["tools"],
|
|
||||||
"messages": [
|
|
||||||
*form_data["messages"],
|
|
||||||
*convert_content_blocks_to_messages(content_blocks),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
user,
|
user,
|
||||||
)
|
)
|
||||||
|
|
||||||
if isinstance(res, StreamingResponse):
|
if isinstance(res, StreamingResponse):
|
||||||
await stream_body_handler(res)
|
await stream_body_handler(res, new_form_data)
|
||||||
else:
|
else:
|
||||||
break
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -2211,6 +2249,7 @@ async def process_chat_response(
|
||||||
content_blocks[-1]["type"] == "code_interpreter"
|
content_blocks[-1]["type"] == "code_interpreter"
|
||||||
and retries < MAX_RETRIES
|
and retries < MAX_RETRIES
|
||||||
):
|
):
|
||||||
|
|
||||||
await event_emitter(
|
await event_emitter(
|
||||||
{
|
{
|
||||||
"type": "chat:completion",
|
"type": "chat:completion",
|
||||||
|
|
@ -2343,26 +2382,28 @@ async def process_chat_response(
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
new_form_data = {
|
||||||
|
"model": model_id,
|
||||||
|
"stream": True,
|
||||||
|
"messages": [
|
||||||
|
*form_data["messages"],
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": serialize_content_blocks(
|
||||||
|
content_blocks, raw=True
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
res = await generate_chat_completion(
|
res = await generate_chat_completion(
|
||||||
request,
|
request,
|
||||||
{
|
new_form_data,
|
||||||
"model": model_id,
|
|
||||||
"stream": True,
|
|
||||||
"messages": [
|
|
||||||
*form_data["messages"],
|
|
||||||
{
|
|
||||||
"role": "assistant",
|
|
||||||
"content": serialize_content_blocks(
|
|
||||||
content_blocks, raw=True
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
user,
|
user,
|
||||||
)
|
)
|
||||||
|
|
||||||
if isinstance(res, StreamingResponse):
|
if isinstance(res, StreamingResponse):
|
||||||
await stream_body_handler(res)
|
await stream_body_handler(res, new_form_data)
|
||||||
else:
|
else:
|
||||||
break
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -2427,9 +2468,11 @@ async def process_chat_response(
|
||||||
if response.background is not None:
|
if response.background is not None:
|
||||||
await response.background()
|
await response.background()
|
||||||
|
|
||||||
# background_tasks.add_task(post_response_handler, response, events)
|
# background_tasks.add_task(response_handler, response, events)
|
||||||
task_id, _ = await create_task(
|
task_id, _ = await create_task(
|
||||||
request, post_response_handler(response, events), id=metadata["chat_id"]
|
request.app.state.redis,
|
||||||
|
response_handler(response, events),
|
||||||
|
id=metadata["chat_id"],
|
||||||
)
|
)
|
||||||
return {"status": True, "task_id": task_id}
|
return {"status": True, "task_id": task_id}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -76,8 +76,19 @@ async def get_all_base_models(request: Request, user: UserModel = None):
|
||||||
return function_models + openai_models + ollama_models
|
return function_models + openai_models + ollama_models
|
||||||
|
|
||||||
|
|
||||||
async def get_all_models(request, user: UserModel = None):
|
async def get_all_models(request, refresh: bool = False, user: UserModel = None):
|
||||||
models = await get_all_base_models(request, user=user)
|
if (
|
||||||
|
request.app.state.MODELS
|
||||||
|
and request.app.state.BASE_MODELS
|
||||||
|
and (request.app.state.config.ENABLE_BASE_MODELS_CACHE and not refresh)
|
||||||
|
):
|
||||||
|
base_models = request.app.state.BASE_MODELS
|
||||||
|
else:
|
||||||
|
base_models = await get_all_base_models(request, user=user)
|
||||||
|
request.app.state.BASE_MODELS = base_models
|
||||||
|
|
||||||
|
# deep copy the base models to avoid modifying the original list
|
||||||
|
models = [model.copy() for model in base_models]
|
||||||
|
|
||||||
# If there are no models, return an empty list
|
# If there are no models, return an empty list
|
||||||
if len(models) == 0:
|
if len(models) == 0:
|
||||||
|
|
@ -137,6 +148,7 @@ async def get_all_models(request, user: UserModel = None):
|
||||||
custom_models = Models.get_all_models()
|
custom_models = Models.get_all_models()
|
||||||
for custom_model in custom_models:
|
for custom_model in custom_models:
|
||||||
if custom_model.base_model_id is None:
|
if custom_model.base_model_id is None:
|
||||||
|
# Applied directly to a base model
|
||||||
for model in models:
|
for model in models:
|
||||||
if custom_model.id == model["id"] or (
|
if custom_model.id == model["id"] or (
|
||||||
model.get("owned_by") == "ollama"
|
model.get("owned_by") == "ollama"
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,13 @@
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from opentelemetry import trace
|
from opentelemetry import trace
|
||||||
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
|
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
|
||||||
|
from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
|
||||||
|
OTLPSpanExporter as HttpOTLPSpanExporter,
|
||||||
|
)
|
||||||
from opentelemetry.sdk.resources import SERVICE_NAME, Resource
|
from opentelemetry.sdk.resources import SERVICE_NAME, Resource
|
||||||
from opentelemetry.sdk.trace import TracerProvider
|
from opentelemetry.sdk.trace import TracerProvider
|
||||||
from sqlalchemy import Engine
|
from sqlalchemy import Engine
|
||||||
|
from base64 import b64encode
|
||||||
|
|
||||||
from open_webui.utils.telemetry.exporters import LazyBatchSpanProcessor
|
from open_webui.utils.telemetry.exporters import LazyBatchSpanProcessor
|
||||||
from open_webui.utils.telemetry.instrumentors import Instrumentor
|
from open_webui.utils.telemetry.instrumentors import Instrumentor
|
||||||
|
|
@ -11,7 +15,11 @@ from open_webui.utils.telemetry.metrics import setup_metrics
|
||||||
from open_webui.env import (
|
from open_webui.env import (
|
||||||
OTEL_SERVICE_NAME,
|
OTEL_SERVICE_NAME,
|
||||||
OTEL_EXPORTER_OTLP_ENDPOINT,
|
OTEL_EXPORTER_OTLP_ENDPOINT,
|
||||||
|
OTEL_EXPORTER_OTLP_INSECURE,
|
||||||
ENABLE_OTEL_METRICS,
|
ENABLE_OTEL_METRICS,
|
||||||
|
OTEL_BASIC_AUTH_USERNAME,
|
||||||
|
OTEL_BASIC_AUTH_PASSWORD,
|
||||||
|
OTEL_OTLP_SPAN_EXPORTER,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -22,8 +30,27 @@ def setup(app: FastAPI, db_engine: Engine):
|
||||||
resource=Resource.create(attributes={SERVICE_NAME: OTEL_SERVICE_NAME})
|
resource=Resource.create(attributes={SERVICE_NAME: OTEL_SERVICE_NAME})
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Add basic auth header only if both username and password are not empty
|
||||||
|
headers = []
|
||||||
|
if OTEL_BASIC_AUTH_USERNAME and OTEL_BASIC_AUTH_PASSWORD:
|
||||||
|
auth_string = f"{OTEL_BASIC_AUTH_USERNAME}:{OTEL_BASIC_AUTH_PASSWORD}"
|
||||||
|
auth_header = b64encode(auth_string.encode()).decode()
|
||||||
|
headers = [("authorization", f"Basic {auth_header}")]
|
||||||
|
|
||||||
# otlp export
|
# otlp export
|
||||||
exporter = OTLPSpanExporter(endpoint=OTEL_EXPORTER_OTLP_ENDPOINT)
|
if OTEL_OTLP_SPAN_EXPORTER == "http":
|
||||||
|
exporter = HttpOTLPSpanExporter(
|
||||||
|
endpoint=OTEL_EXPORTER_OTLP_ENDPOINT,
|
||||||
|
insecure=OTEL_EXPORTER_OTLP_INSECURE,
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
exporter = OTLPSpanExporter(
|
||||||
|
endpoint=OTEL_EXPORTER_OTLP_ENDPOINT,
|
||||||
|
insecure=OTEL_EXPORTER_OTLP_INSECURE,
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
trace.get_tracer_provider().add_span_processor(LazyBatchSpanProcessor(exporter))
|
trace.get_tracer_provider().add_span_processor(LazyBatchSpanProcessor(exporter))
|
||||||
Instrumentor(app=app, db_engine=db_engine).instrument()
|
Instrumentor(app=app, db_engine=db_engine).instrument()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -101,9 +101,6 @@ def get_tools(
|
||||||
|
|
||||||
def make_tool_function(function_name, token, tool_server_data):
|
def make_tool_function(function_name, token, tool_server_data):
|
||||||
async def tool_function(**kwargs):
|
async def tool_function(**kwargs):
|
||||||
print(
|
|
||||||
f"Executing tool function {function_name} with params: {kwargs}"
|
|
||||||
)
|
|
||||||
return await execute_tool_server(
|
return await execute_tool_server(
|
||||||
token=token,
|
token=token,
|
||||||
url=tool_server_data["url"],
|
url=tool_server_data["url"],
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
fastapi==0.115.7
|
fastapi==0.115.7
|
||||||
uvicorn[standard]==0.34.2
|
uvicorn[standard]==0.35.0
|
||||||
pydantic==2.10.6
|
pydantic==2.11.7
|
||||||
python-multipart==0.0.20
|
python-multipart==0.0.20
|
||||||
|
|
||||||
python-socketio==5.13.0
|
python-socketio==5.13.0
|
||||||
python-jose==3.4.0
|
python-jose==3.4.0
|
||||||
passlib[bcrypt]==1.7.4
|
passlib[bcrypt]==1.7.4
|
||||||
|
cryptography
|
||||||
|
|
||||||
requests==2.32.4
|
requests==2.32.4
|
||||||
aiohttp==3.11.11
|
aiohttp==3.11.11
|
||||||
|
|
@ -13,6 +14,7 @@ async-timeout
|
||||||
aiocache
|
aiocache
|
||||||
aiofiles
|
aiofiles
|
||||||
starlette-compress==1.6.0
|
starlette-compress==1.6.0
|
||||||
|
httpx[socks,http2,zstd,cli,brotli]==0.28.1
|
||||||
|
|
||||||
sqlalchemy==2.0.38
|
sqlalchemy==2.0.38
|
||||||
alembic==1.14.0
|
alembic==1.14.0
|
||||||
|
|
@ -30,6 +32,8 @@ boto3==1.35.53
|
||||||
argon2-cffi==23.1.0
|
argon2-cffi==23.1.0
|
||||||
APScheduler==3.10.4
|
APScheduler==3.10.4
|
||||||
|
|
||||||
|
pycrdt==0.12.25
|
||||||
|
|
||||||
RestrictedPython==8.0
|
RestrictedPython==8.0
|
||||||
|
|
||||||
loguru==0.7.3
|
loguru==0.7.3
|
||||||
|
|
@ -42,13 +46,13 @@ google-genai==1.15.0
|
||||||
google-generativeai==0.8.5
|
google-generativeai==0.8.5
|
||||||
tiktoken
|
tiktoken
|
||||||
|
|
||||||
langchain==0.3.24
|
langchain==0.3.26
|
||||||
langchain-community==0.3.23
|
langchain-community==0.3.26
|
||||||
|
|
||||||
fake-useragent==2.1.0
|
fake-useragent==2.1.0
|
||||||
chromadb==0.6.3
|
chromadb==0.6.3
|
||||||
pymilvus==2.5.0
|
pymilvus==2.5.0
|
||||||
qdrant-client~=1.12.0
|
qdrant-client==1.14.3
|
||||||
opensearch-py==2.8.0
|
opensearch-py==2.8.0
|
||||||
playwright==1.49.1 # Caution: version must match docker-compose.playwright.yaml
|
playwright==1.49.1 # Caution: version must match docker-compose.playwright.yaml
|
||||||
elasticsearch==9.0.1
|
elasticsearch==9.0.1
|
||||||
|
|
@ -99,7 +103,7 @@ youtube-transcript-api==1.1.0
|
||||||
pytube==15.0.0
|
pytube==15.0.0
|
||||||
|
|
||||||
pydub
|
pydub
|
||||||
duckduckgo-search==8.0.2
|
ddgs==9.0.0
|
||||||
|
|
||||||
## Google Drive
|
## Google Drive
|
||||||
google-api-python-client
|
google-api-python-client
|
||||||
|
|
@ -114,7 +118,7 @@ pytest-docker~=3.1.1
|
||||||
googleapis-common-protos==1.63.2
|
googleapis-common-protos==1.63.2
|
||||||
google-cloud-storage==2.19.0
|
google-cloud-storage==2.19.0
|
||||||
|
|
||||||
azure-identity==1.21.0
|
azure-identity==1.23.0
|
||||||
azure-storage-blob==12.24.1
|
azure-storage-blob==12.24.1
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ SET "WEBUI_SECRET_KEY=%WEBUI_SECRET_KEY%"
|
||||||
SET "WEBUI_JWT_SECRET_KEY=%WEBUI_JWT_SECRET_KEY%"
|
SET "WEBUI_JWT_SECRET_KEY=%WEBUI_JWT_SECRET_KEY%"
|
||||||
|
|
||||||
:: Check if WEBUI_SECRET_KEY and WEBUI_JWT_SECRET_KEY are not set
|
:: Check if WEBUI_SECRET_KEY and WEBUI_JWT_SECRET_KEY are not set
|
||||||
IF "%WEBUI_SECRET_KEY%%WEBUI_JWT_SECRET_KEY%" == " " (
|
IF "%WEBUI_SECRET_KEY% %WEBUI_JWT_SECRET_KEY%" == " " (
|
||||||
echo Loading WEBUI_SECRET_KEY from file, not provided as an environment variable.
|
echo Loading WEBUI_SECRET_KEY from file, not provided as an environment variable.
|
||||||
|
|
||||||
IF NOT EXIST "%KEY_FILE%" (
|
IF NOT EXIST "%KEY_FILE%" (
|
||||||
|
|
|
||||||
|
|
@ -21,14 +21,14 @@ describe('Settings', () => {
|
||||||
// Click on the model selector
|
// Click on the model selector
|
||||||
cy.get('button[aria-label="Select a model"]').click();
|
cy.get('button[aria-label="Select a model"]').click();
|
||||||
// Select the first model
|
// Select the first model
|
||||||
cy.get('button[aria-label="model-item"]').first().click();
|
cy.get('button[aria-roledescription="model-item"]').first().click();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('user can perform text chat', () => {
|
it('user can perform text chat', () => {
|
||||||
// Click on the model selector
|
// Click on the model selector
|
||||||
cy.get('button[aria-label="Select a model"]').click();
|
cy.get('button[aria-label="Select a model"]').click();
|
||||||
// Select the first model
|
// Select the first model
|
||||||
cy.get('button[aria-label="model-item"]').first().click();
|
cy.get('button[aria-roledescription="model-item"]').first().click();
|
||||||
// Type a message
|
// Type a message
|
||||||
cy.get('#chat-input').type('Hi, what can you do? A single sentence only please.', {
|
cy.get('#chat-input').type('Hi, what can you do? A single sentence only please.', {
|
||||||
force: true
|
force: true
|
||||||
|
|
@ -48,7 +48,7 @@ describe('Settings', () => {
|
||||||
// Click on the model selector
|
// Click on the model selector
|
||||||
cy.get('button[aria-label="Select a model"]').click();
|
cy.get('button[aria-label="Select a model"]').click();
|
||||||
// Select the first model
|
// Select the first model
|
||||||
cy.get('button[aria-label="model-item"]').first().click();
|
cy.get('button[aria-roledescription="model-item"]').first().click();
|
||||||
// Type a message
|
// Type a message
|
||||||
cy.get('#chat-input').type('Hi, what can you do? A single sentence only please.', {
|
cy.get('#chat-input').type('Hi, what can you do? A single sentence only please.', {
|
||||||
force: true
|
force: true
|
||||||
|
|
@ -83,7 +83,7 @@ describe('Settings', () => {
|
||||||
// Click on the model selector
|
// Click on the model selector
|
||||||
cy.get('button[aria-label="Select a model"]').click();
|
cy.get('button[aria-label="Select a model"]').click();
|
||||||
// Select the first model
|
// Select the first model
|
||||||
cy.get('button[aria-label="model-item"]').first().click();
|
cy.get('button[aria-roledescription="model-item"]').first().click();
|
||||||
// Type a message
|
// Type a message
|
||||||
cy.get('#chat-input').type('Hi, what can you do? A single sentence only please.', {
|
cy.get('#chat-input').type('Hi, what can you do? A single sentence only please.', {
|
||||||
force: true
|
force: true
|
||||||
|
|
|
||||||
24
docker-compose.otel.yaml
Normal file
24
docker-compose.otel.yaml
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
services:
|
||||||
|
grafana:
|
||||||
|
image: grafana/otel-lgtm:latest
|
||||||
|
container_name: lgtm
|
||||||
|
ports:
|
||||||
|
- "3000:3000" # Grafana UI
|
||||||
|
- "4317:4317" # OTLP/gRPC
|
||||||
|
- "4318:4318" # OTLP/HTTP
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
open-webui:
|
||||||
|
image: ghcr.io/open-webui/open-webui:main
|
||||||
|
container_name: open-webui
|
||||||
|
depends_on: [grafana]
|
||||||
|
environment:
|
||||||
|
- ENABLE_OTEL=true
|
||||||
|
- OTEL_EXPORTER_OTLP_ENDPOINT=http://grafana:4317
|
||||||
|
- OTEL_SERVICE_NAME=open-webui
|
||||||
|
ports:
|
||||||
|
- "8088:8080"
|
||||||
|
networks: [default]
|
||||||
|
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
483
package-lock.json
generated
483
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "open-webui",
|
"name": "open-webui",
|
||||||
"version": "0.6.15",
|
"version": "0.6.16",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "open-webui",
|
"name": "open-webui",
|
||||||
"version": "0.6.15",
|
"version": "0.6.16",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@azure/msal-browser": "^4.5.0",
|
"@azure/msal-browser": "^4.5.0",
|
||||||
"@codemirror/lang-javascript": "^6.2.2",
|
"@codemirror/lang-javascript": "^6.2.2",
|
||||||
|
|
@ -19,19 +19,28 @@
|
||||||
"@sveltejs/adapter-node": "^2.0.0",
|
"@sveltejs/adapter-node": "^2.0.0",
|
||||||
"@sveltejs/svelte-virtual-list": "^3.0.1",
|
"@sveltejs/svelte-virtual-list": "^3.0.1",
|
||||||
"@tiptap/core": "^2.11.9",
|
"@tiptap/core": "^2.11.9",
|
||||||
|
"@tiptap/extension-bubble-menu": "^2.25.0",
|
||||||
|
"@tiptap/extension-character-count": "^2.25.0",
|
||||||
"@tiptap/extension-code-block-lowlight": "^2.11.9",
|
"@tiptap/extension-code-block-lowlight": "^2.11.9",
|
||||||
|
"@tiptap/extension-floating-menu": "^2.25.0",
|
||||||
"@tiptap/extension-highlight": "^2.10.0",
|
"@tiptap/extension-highlight": "^2.10.0",
|
||||||
|
"@tiptap/extension-history": "^2.25.1",
|
||||||
|
"@tiptap/extension-link": "^2.25.0",
|
||||||
"@tiptap/extension-placeholder": "^2.10.0",
|
"@tiptap/extension-placeholder": "^2.10.0",
|
||||||
"@tiptap/extension-table": "^2.12.0",
|
"@tiptap/extension-table": "^2.12.0",
|
||||||
"@tiptap/extension-table-cell": "^2.12.0",
|
"@tiptap/extension-table-cell": "^2.12.0",
|
||||||
"@tiptap/extension-table-header": "^2.12.0",
|
"@tiptap/extension-table-header": "^2.12.0",
|
||||||
"@tiptap/extension-table-row": "^2.12.0",
|
"@tiptap/extension-table-row": "^2.12.0",
|
||||||
|
"@tiptap/extension-task-item": "^2.25.0",
|
||||||
|
"@tiptap/extension-task-list": "^2.25.0",
|
||||||
"@tiptap/extension-typography": "^2.10.0",
|
"@tiptap/extension-typography": "^2.10.0",
|
||||||
|
"@tiptap/extension-underline": "^2.25.0",
|
||||||
"@tiptap/pm": "^2.11.7",
|
"@tiptap/pm": "^2.11.7",
|
||||||
"@tiptap/starter-kit": "^2.10.0",
|
"@tiptap/starter-kit": "^2.10.0",
|
||||||
"@xyflow/svelte": "^0.1.19",
|
"@xyflow/svelte": "^0.1.19",
|
||||||
"async": "^3.2.5",
|
"async": "^3.2.5",
|
||||||
"bits-ui": "^0.21.15",
|
"bits-ui": "^0.21.15",
|
||||||
|
"chart.js": "^4.5.0",
|
||||||
"codemirror": "^6.0.1",
|
"codemirror": "^6.0.1",
|
||||||
"codemirror-lang-elixir": "^4.0.0",
|
"codemirror-lang-elixir": "^4.0.0",
|
||||||
"codemirror-lang-hcl": "^0.1.0",
|
"codemirror-lang-hcl": "^0.1.0",
|
||||||
|
|
@ -42,9 +51,10 @@
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"focus-trap": "^7.6.4",
|
"focus-trap": "^7.6.4",
|
||||||
"fuse.js": "^7.0.0",
|
"fuse.js": "^7.0.0",
|
||||||
|
"heic2any": "^0.0.4",
|
||||||
"highlight.js": "^11.9.0",
|
"highlight.js": "^11.9.0",
|
||||||
"html-entities": "^2.5.3",
|
"html-entities": "^2.5.3",
|
||||||
"html2canvas-pro": "^1.5.8",
|
"html2canvas-pro": "^1.5.11",
|
||||||
"i18next": "^23.10.0",
|
"i18next": "^23.10.0",
|
||||||
"i18next-browser-languagedetector": "^7.2.0",
|
"i18next-browser-languagedetector": "^7.2.0",
|
||||||
"i18next-resources-to-backend": "^1.2.0",
|
"i18next-resources-to-backend": "^1.2.0",
|
||||||
|
|
@ -53,10 +63,13 @@
|
||||||
"jspdf": "^3.0.0",
|
"jspdf": "^3.0.0",
|
||||||
"katex": "^0.16.22",
|
"katex": "^0.16.22",
|
||||||
"kokoro-js": "^1.1.1",
|
"kokoro-js": "^1.1.1",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"marked": "^9.1.0",
|
"marked": "^9.1.0",
|
||||||
"mermaid": "^11.6.0",
|
"mermaid": "^11.6.0",
|
||||||
"paneforge": "^0.0.6",
|
"paneforge": "^0.0.6",
|
||||||
"panzoom": "^9.4.3",
|
"panzoom": "^9.4.3",
|
||||||
|
"pdfjs-dist": "^5.3.93",
|
||||||
|
"prosemirror-collab": "^1.3.1",
|
||||||
"prosemirror-commands": "^1.6.0",
|
"prosemirror-commands": "^1.6.0",
|
||||||
"prosemirror-example-setup": "^1.2.3",
|
"prosemirror-example-setup": "^1.2.3",
|
||||||
"prosemirror-history": "^1.4.1",
|
"prosemirror-history": "^1.4.1",
|
||||||
|
|
@ -70,7 +83,7 @@
|
||||||
"prosemirror-view": "^1.34.3",
|
"prosemirror-view": "^1.34.3",
|
||||||
"pyodide": "^0.27.3",
|
"pyodide": "^0.27.3",
|
||||||
"socket.io-client": "^4.2.0",
|
"socket.io-client": "^4.2.0",
|
||||||
"sortablejs": "^1.15.2",
|
"sortablejs": "^1.15.6",
|
||||||
"svelte-sonner": "^0.3.19",
|
"svelte-sonner": "^0.3.19",
|
||||||
"tippy.js": "^6.3.7",
|
"tippy.js": "^6.3.7",
|
||||||
"turndown": "^7.2.0",
|
"turndown": "^7.2.0",
|
||||||
|
|
@ -78,7 +91,9 @@
|
||||||
"undici": "^7.3.0",
|
"undici": "^7.3.0",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
"vite-plugin-static-copy": "^2.2.0",
|
"vite-plugin-static-copy": "^2.2.0",
|
||||||
"yaml": "^2.7.1"
|
"y-prosemirror": "^1.3.7",
|
||||||
|
"yaml": "^2.7.1",
|
||||||
|
"yjs": "^13.6.27"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-auto": "3.2.2",
|
"@sveltejs/adapter-auto": "3.2.2",
|
||||||
|
|
@ -1870,6 +1885,12 @@
|
||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@kurkle/color": {
|
||||||
|
"version": "0.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||||
|
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@lezer/common": {
|
"node_modules/@lezer/common": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.1.tgz",
|
||||||
|
|
@ -2066,6 +2087,191 @@
|
||||||
"resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz",
|
||||||
"integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="
|
"integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="
|
||||||
},
|
},
|
||||||
|
"node_modules/@napi-rs/canvas": {
|
||||||
|
"version": "0.1.73",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.73.tgz",
|
||||||
|
"integrity": "sha512-9iwPZrNlCK4rG+vWyDvyvGeYjck9MoP0NVQP6N60gqJNFA1GsN0imG05pzNsqfCvFxUxgiTYlR8ff0HC1HXJiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"workspaces": [
|
||||||
|
"e2e/*"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@napi-rs/canvas-android-arm64": "0.1.73",
|
||||||
|
"@napi-rs/canvas-darwin-arm64": "0.1.73",
|
||||||
|
"@napi-rs/canvas-darwin-x64": "0.1.73",
|
||||||
|
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.73",
|
||||||
|
"@napi-rs/canvas-linux-arm64-gnu": "0.1.73",
|
||||||
|
"@napi-rs/canvas-linux-arm64-musl": "0.1.73",
|
||||||
|
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.73",
|
||||||
|
"@napi-rs/canvas-linux-x64-gnu": "0.1.73",
|
||||||
|
"@napi-rs/canvas-linux-x64-musl": "0.1.73",
|
||||||
|
"@napi-rs/canvas-win32-x64-msvc": "0.1.73"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-android-arm64": {
|
||||||
|
"version": "0.1.73",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.73.tgz",
|
||||||
|
"integrity": "sha512-s8dMhfYIHVv7gz8BXg3Nb6cFi950Y0xH5R/sotNZzUVvU9EVqHfkqiGJ4UIqu+15UhqguT6mI3Bv1mhpRkmMQw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-darwin-arm64": {
|
||||||
|
"version": "0.1.73",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.73.tgz",
|
||||||
|
"integrity": "sha512-bLPCq8Yyq1vMdVdIpQAqmgf6VGUknk8e7NdSZXJJFOA9gxkJ1RGcHOwoXo7h0gzhHxSorg71hIxyxtwXpq10Rw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-darwin-x64": {
|
||||||
|
"version": "0.1.73",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.73.tgz",
|
||||||
|
"integrity": "sha512-GR1CcehDjdNYXN3bj8PIXcXfYLUUOQANjQpM+KNnmpRo7ojsuqPjT7ZVH+6zoG/aqRJWhiSo+ChQMRazZlRU9g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
|
||||||
|
"version": "0.1.73",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.73.tgz",
|
||||||
|
"integrity": "sha512-cM7F0kBJVFio0+U2iKSW4fWSfYQ8CPg4/DRZodSum/GcIyfB8+UPJSRM1BvvlcWinKLfX1zUYOwonZX9IFRRcw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
|
||||||
|
"version": "0.1.73",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.73.tgz",
|
||||||
|
"integrity": "sha512-PMWNrMON9uz9klz1B8ZY/RXepQSC5dxxHQTowfw93Tb3fLtWO5oNX2k9utw7OM4ypT9BUZUWJnDQ5bfuXc/EUQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
|
||||||
|
"version": "0.1.73",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.73.tgz",
|
||||||
|
"integrity": "sha512-lX0z2bNmnk1PGZ+0a9OZwI2lPPvWjRYzPqvEitXX7lspyLFrOzh2kcQiLL7bhyODN23QvfriqwYqp5GreSzVvA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
|
||||||
|
"version": "0.1.73",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.73.tgz",
|
||||||
|
"integrity": "sha512-QDQgMElwxAoADsSR3UYvdTTQk5XOyD9J5kq15Z8XpGwpZOZsSE0zZ/X1JaOtS2x+HEZL6z1S6MF/1uhZFZb5ig==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
|
||||||
|
"version": "0.1.73",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.73.tgz",
|
||||||
|
"integrity": "sha512-wbzLJrTalQrpyrU1YRrO6w6pdr5vcebbJa+Aut5QfTaW9eEmMb1WFG6l1V+cCa5LdHmRr8bsvl0nJDU/IYDsmw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-x64-musl": {
|
||||||
|
"version": "0.1.73",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.73.tgz",
|
||||||
|
"integrity": "sha512-xbfhYrUufoTAKvsEx2ZUN4jvACabIF0h1F5Ik1Rk4e/kQq6c+Dwa5QF0bGrfLhceLpzHT0pCMGMDeQKQrcUIyA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
|
||||||
|
"version": "0.1.73",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.73.tgz",
|
||||||
|
"integrity": "sha512-YQmHXBufFBdWqhx+ympeTPkMfs3RNxaOgWm59vyjpsub7Us07BwCcmu1N5kildhO8Fm0syoI2kHnzGkJBLSvsg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@nodelib/fs.scandir": {
|
"node_modules/@nodelib/fs.scandir": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||||
|
|
@ -2941,6 +3147,23 @@
|
||||||
"@tiptap/core": "^2.7.0"
|
"@tiptap/core": "^2.7.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tiptap/extension-bubble-menu": {
|
||||||
|
"version": "2.25.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.25.0.tgz",
|
||||||
|
"integrity": "sha512-BnbfQWRXJDDy9/x/0Atu2Nka5ZAMyXLDFqzSLMAXqXSQcG6CZRTSNRgOCnjpda6Hq2yCtq7l/YEoXkbHT1ZZdQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tippy.js": "^6.3.7"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0",
|
||||||
|
"@tiptap/pm": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tiptap/extension-bullet-list": {
|
"node_modules/@tiptap/extension-bullet-list": {
|
||||||
"version": "2.10.0",
|
"version": "2.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-2.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-2.10.0.tgz",
|
||||||
|
|
@ -2954,6 +3177,20 @@
|
||||||
"@tiptap/core": "^2.7.0"
|
"@tiptap/core": "^2.7.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tiptap/extension-character-count": {
|
||||||
|
"version": "2.25.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-character-count/-/extension-character-count-2.25.0.tgz",
|
||||||
|
"integrity": "sha512-F+4DxJFptbX3oioqNwS38zOTi6gH9CumV/ISeOIvr4ao7Iija3tNonGDsHhxD05njjbYNIp1OKsxtnzbWukgMA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0",
|
||||||
|
"@tiptap/pm": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tiptap/extension-code": {
|
"node_modules/@tiptap/extension-code": {
|
||||||
"version": "2.10.0",
|
"version": "2.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-2.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-2.10.0.tgz",
|
||||||
|
|
@ -3025,6 +3262,23 @@
|
||||||
"@tiptap/pm": "^2.7.0"
|
"@tiptap/pm": "^2.7.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tiptap/extension-floating-menu": {
|
||||||
|
"version": "2.25.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-2.25.0.tgz",
|
||||||
|
"integrity": "sha512-hPZ5SNpI14smTz4GpWQXTnxmeICINYiABSgXcsU5V66tik9OtxKwoCSR/gpU35esaAFUVRdjW7+sGkACLZD5AQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tippy.js": "^6.3.7"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0",
|
||||||
|
"@tiptap/pm": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tiptap/extension-gapcursor": {
|
"node_modules/@tiptap/extension-gapcursor": {
|
||||||
"version": "2.10.0",
|
"version": "2.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-2.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-2.10.0.tgz",
|
||||||
|
|
@ -3079,9 +3333,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tiptap/extension-history": {
|
"node_modules/@tiptap/extension-history": {
|
||||||
"version": "2.10.0",
|
"version": "2.25.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.25.1.tgz",
|
||||||
"integrity": "sha512-5aYOmxqaCnw7e7wmWqFZmkpYCxxDjEzFbgVI6WknqNwqeOizR4+YJf3aAt/lTbksLJe47XF+NBX51gOm/ZBCiw==",
|
"integrity": "sha512-ZoxxOAObk1U8H3d+XEG0MjccJN0ViGIKEZqnLUSswmVweYPdkJG2WF2pEif9hpwJONslvLTKa+f8jwK5LEnJLQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
|
|
@ -3119,6 +3373,23 @@
|
||||||
"@tiptap/core": "^2.7.0"
|
"@tiptap/core": "^2.7.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tiptap/extension-link": {
|
||||||
|
"version": "2.25.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-2.25.0.tgz",
|
||||||
|
"integrity": "sha512-jNd+1Fd7wiIbxlS51weBzyDtBEBSVzW0cgzdwOzBYQtPJueRyXNNVERksyinDuVgcfvEWgmNZUylgzu7mehnEg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"linkifyjs": "^4.2.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0",
|
||||||
|
"@tiptap/pm": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tiptap/extension-list-item": {
|
"node_modules/@tiptap/extension-list-item": {
|
||||||
"version": "2.10.0",
|
"version": "2.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.10.0.tgz",
|
||||||
|
|
@ -3238,6 +3509,33 @@
|
||||||
"@tiptap/core": "^2.7.0"
|
"@tiptap/core": "^2.7.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tiptap/extension-task-item": {
|
||||||
|
"version": "2.25.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-task-item/-/extension-task-item-2.25.0.tgz",
|
||||||
|
"integrity": "sha512-8F7Z7jbsyGrPLHQCn+n39zdqIgxwR1kJ1nL5ZwhEW3ZhJgkFF0WMJSv36mwIJwL08p8um/c6g72AYB/e8CD7eA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0",
|
||||||
|
"@tiptap/pm": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-task-list": {
|
||||||
|
"version": "2.25.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-task-list/-/extension-task-list-2.25.0.tgz",
|
||||||
|
"integrity": "sha512-2mASqp8MJ0dyc1OK6c8P7m/zwoVDv8PV+XsRR9O3tpIz/zjUVrOl0W4IndjUPBMa7cpJX8fGj8iC3DaRNpSMcg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tiptap/extension-text": {
|
"node_modules/@tiptap/extension-text": {
|
||||||
"version": "2.10.0",
|
"version": "2.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.10.0.tgz",
|
||||||
|
|
@ -3277,6 +3575,19 @@
|
||||||
"@tiptap/core": "^2.7.0"
|
"@tiptap/core": "^2.7.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tiptap/extension-underline": {
|
||||||
|
"version": "2.25.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-2.25.0.tgz",
|
||||||
|
"integrity": "sha512-RqXkWSMJyllfsDukugDzWEZfWRUOgcqzuMWC40BnuDUs4KgdRA0nhVUWJbLfUEmXI0UVqN5OwYTTAdhaiF7kjQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tiptap/pm": {
|
"node_modules/@tiptap/pm": {
|
||||||
"version": "2.11.7",
|
"version": "2.11.7",
|
||||||
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.11.7.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.11.7.tgz",
|
||||||
|
|
@ -4723,6 +5034,18 @@
|
||||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/chart.js": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@kurkle/color": "^0.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"pnpm": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/check-error": {
|
"node_modules/check-error": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz",
|
||||||
|
|
@ -7295,6 +7618,12 @@
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/heic2any": {
|
||||||
|
"version": "0.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/heic2any/-/heic2any-0.0.4.tgz",
|
||||||
|
"integrity": "sha512-3lLnZiDELfabVH87htnRolZ2iehX9zwpRyGNz22GKXIu0fznlblf0/ftppXKNqS26dqFSeqfIBhAmAj/uSp0cA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/heimdalljs": {
|
"node_modules/heimdalljs": {
|
||||||
"version": "0.2.6",
|
"version": "0.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/heimdalljs/-/heimdalljs-0.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/heimdalljs/-/heimdalljs-0.2.6.tgz",
|
||||||
|
|
@ -7379,9 +7708,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/html2canvas-pro": {
|
"node_modules/html2canvas-pro": {
|
||||||
"version": "1.5.8",
|
"version": "1.5.11",
|
||||||
"resolved": "https://registry.npmjs.org/html2canvas-pro/-/html2canvas-pro-1.5.8.tgz",
|
"resolved": "https://registry.npmjs.org/html2canvas-pro/-/html2canvas-pro-1.5.11.tgz",
|
||||||
"integrity": "sha512-bVGAU7IvhBwBlRAmX6QhekX8lsaxmYoF6zIwf/HNlHscjx+KN8jw/U4PQRYqeEVm9+m13hcS1l5ChJB9/e29Lw==",
|
"integrity": "sha512-W4pEeKLG8+9a54RDOSiEKq7gRXXDzt0ORMaLXX+l6a3urSKbmnkmyzcRDCtgTOzmHLaZTLG2wiTQMJqKLlSh3w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"css-line-break": "^2.1.0",
|
"css-line-break": "^2.1.0",
|
||||||
|
|
@ -7830,6 +8159,16 @@
|
||||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="
|
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="
|
||||||
},
|
},
|
||||||
|
"node_modules/isomorphic.js": {
|
||||||
|
"version": "0.2.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz",
|
||||||
|
"integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "GitHub Sponsors ❤",
|
||||||
|
"url": "https://github.com/sponsors/dmonad"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/isstream": {
|
"node_modules/isstream": {
|
||||||
"version": "0.1.2",
|
"version": "0.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz",
|
||||||
|
|
@ -8046,6 +8385,12 @@
|
||||||
"node": ">=10.13.0"
|
"node": ">=10.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/leaflet": {
|
||||||
|
"version": "1.9.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||||
|
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
"node_modules/levn": {
|
"node_modules/levn": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||||
|
|
@ -8068,6 +8413,27 @@
|
||||||
"@lezer/lr": "^1.3.0"
|
"@lezer/lr": "^1.3.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lib0": {
|
||||||
|
"version": "0.2.109",
|
||||||
|
"resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.109.tgz",
|
||||||
|
"integrity": "sha512-jP0gbnyW0kwlx1Atc4dcHkBbrVAkdHjuyHxtClUPYla7qCmwIif1qZ6vQeJdR5FrOVdn26HvQT0ko01rgW7/Xw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"isomorphic.js": "^0.2.4"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js",
|
||||||
|
"0gentesthtml": "bin/gentesthtml.js",
|
||||||
|
"0serve": "bin/0serve.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "GitHub Sponsors ❤",
|
||||||
|
"url": "https://github.com/sponsors/dmonad"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lightningcss": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.29.1",
|
"version": "1.29.1",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.29.1.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.29.1.tgz",
|
||||||
|
|
@ -8331,6 +8697,12 @@
|
||||||
"uc.micro": "^2.0.0"
|
"uc.micro": "^2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/linkifyjs": {
|
||||||
|
"version": "4.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.1.tgz",
|
||||||
|
"integrity": "sha512-DRSlB9DKVW04c4SUdGvKK5FR6be45lTU9M76JnngqPeeGDqPwYc0zdUErtsNVMtxPXgUWV4HbXbnC4sNyBxkYg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/listr2": {
|
"node_modules/listr2": {
|
||||||
"version": "3.14.0",
|
"version": "3.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/listr2/-/listr2-3.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/listr2/-/listr2-3.14.0.tgz",
|
||||||
|
|
@ -9352,6 +9724,18 @@
|
||||||
"node": "*"
|
"node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pdfjs-dist": {
|
||||||
|
"version": "5.3.93",
|
||||||
|
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.3.93.tgz",
|
||||||
|
"integrity": "sha512-w3fQKVL1oGn8FRyx5JUG5tnbblggDqyx2XzA5brsJ5hSuS+I0NdnJANhmeWKLjotdbPQucLBug5t0MeWr0AAdg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.16.0 || >=22.3.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@napi-rs/canvas": "^0.1.71"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pend": {
|
"node_modules/pend": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
|
||||||
|
|
@ -9994,10 +10378,10 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/pyodide": {
|
"node_modules/pyodide": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/pyodide/-/pyodide-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/pyodide/-/pyodide-0.27.7.tgz",
|
||||||
"integrity": "sha512-6NwKEbPk0M3Wic2T1TCZijgZH9VE4RkHp1VGljS1sou0NjGdsmY2R/fG5oLmdDkjTRMI1iW7WYaY9pofX8gg1g==",
|
"integrity": "sha512-RUSVJlhQdfWfgO9hVHCiXoG+nVZQRS5D9FzgpLJ/VcgGBLSAKoPL8kTiOikxbHQm1kRISeWUBdulEgO26qpSRA==",
|
||||||
"license": "Apache-2.0",
|
"license": "MPL-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ws": "^8.5.0"
|
"ws": "^8.5.0"
|
||||||
},
|
},
|
||||||
|
|
@ -11138,9 +11522,10 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sortablejs": {
|
"node_modules/sortablejs": {
|
||||||
"version": "1.15.2",
|
"version": "1.15.6",
|
||||||
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.2.tgz",
|
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.6.tgz",
|
||||||
"integrity": "sha512-FJF5jgdfvoKn1MAKSdGs33bIqLi3LmsgVTliuX6iITj834F+JRQZN90Z93yql8h0K2t0RwDPBmxwlbZfDcxNZA=="
|
"integrity": "sha512-aNfiuwMEpfBM/CN6LY0ibyhxPfPbyFeBTYJKCvzkJ2GkUpazIt3H+QIPAMHwqQ7tMKaHz1Qj+rJJCqljnf4p3A==",
|
||||||
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
|
|
@ -13111,6 +13496,51 @@
|
||||||
"node": ">=0.4"
|
"node": ">=0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/y-prosemirror": {
|
||||||
|
"version": "1.3.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/y-prosemirror/-/y-prosemirror-1.3.7.tgz",
|
||||||
|
"integrity": "sha512-NpM99WSdD4Fx4if5xOMDpPtU3oAmTSjlzh5U4353ABbRHl1HtAFUx6HlebLZfyFxXN9jzKMDkVbcRjqOZVkYQg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"lib0": "^0.2.109"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.0.0",
|
||||||
|
"npm": ">=8.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "GitHub Sponsors ❤",
|
||||||
|
"url": "https://github.com/sponsors/dmonad"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"prosemirror-model": "^1.7.1",
|
||||||
|
"prosemirror-state": "^1.2.3",
|
||||||
|
"prosemirror-view": "^1.9.10",
|
||||||
|
"y-protocols": "^1.0.1",
|
||||||
|
"yjs": "^13.5.38"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/y-protocols": {
|
||||||
|
"version": "1.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.6.tgz",
|
||||||
|
"integrity": "sha512-vHRF2L6iT3rwj1jub/K5tYcTT/mEYDUppgNPXwp8fmLpui9f7Yeq3OEtTLVF012j39QnV+KEQpNqoN7CWU7Y9Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"lib0": "^0.2.85"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.0.0",
|
||||||
|
"npm": ">=8.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "GitHub Sponsors ❤",
|
||||||
|
"url": "https://github.com/sponsors/dmonad"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"yjs": "^13.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
|
||||||
|
|
@ -13142,6 +13572,23 @@
|
||||||
"fd-slicer": "~1.1.0"
|
"fd-slicer": "~1.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/yjs": {
|
||||||
|
"version": "13.6.27",
|
||||||
|
"resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.27.tgz",
|
||||||
|
"integrity": "sha512-OIDwaflOaq4wC6YlPBy2L6ceKeKuF7DeTxx+jPzv1FHn9tCZ0ZwSRnUBxD05E3yed46fv/FWJbvR+Ud7x0L7zw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"lib0": "^0.2.99"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.0.0",
|
||||||
|
"npm": ">=8.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "GitHub Sponsors ❤",
|
||||||
|
"url": "https://github.com/sponsors/dmonad"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yocto-queue": {
|
"node_modules/yocto-queue": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||||
|
|
|
||||||
23
package.json
23
package.json
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "open-webui",
|
"name": "open-webui",
|
||||||
"version": "0.6.15",
|
"version": "0.6.16",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "npm run pyodide:fetch && vite dev --host",
|
"dev": "npm run pyodide:fetch && vite dev --host",
|
||||||
|
|
@ -63,19 +63,28 @@
|
||||||
"@sveltejs/adapter-node": "^2.0.0",
|
"@sveltejs/adapter-node": "^2.0.0",
|
||||||
"@sveltejs/svelte-virtual-list": "^3.0.1",
|
"@sveltejs/svelte-virtual-list": "^3.0.1",
|
||||||
"@tiptap/core": "^2.11.9",
|
"@tiptap/core": "^2.11.9",
|
||||||
|
"@tiptap/extension-bubble-menu": "^2.25.0",
|
||||||
|
"@tiptap/extension-character-count": "^2.25.0",
|
||||||
"@tiptap/extension-code-block-lowlight": "^2.11.9",
|
"@tiptap/extension-code-block-lowlight": "^2.11.9",
|
||||||
|
"@tiptap/extension-floating-menu": "^2.25.0",
|
||||||
"@tiptap/extension-highlight": "^2.10.0",
|
"@tiptap/extension-highlight": "^2.10.0",
|
||||||
|
"@tiptap/extension-history": "^2.25.1",
|
||||||
|
"@tiptap/extension-link": "^2.25.0",
|
||||||
"@tiptap/extension-placeholder": "^2.10.0",
|
"@tiptap/extension-placeholder": "^2.10.0",
|
||||||
"@tiptap/extension-table": "^2.12.0",
|
"@tiptap/extension-table": "^2.12.0",
|
||||||
"@tiptap/extension-table-cell": "^2.12.0",
|
"@tiptap/extension-table-cell": "^2.12.0",
|
||||||
"@tiptap/extension-table-header": "^2.12.0",
|
"@tiptap/extension-table-header": "^2.12.0",
|
||||||
"@tiptap/extension-table-row": "^2.12.0",
|
"@tiptap/extension-table-row": "^2.12.0",
|
||||||
|
"@tiptap/extension-task-item": "^2.25.0",
|
||||||
|
"@tiptap/extension-task-list": "^2.25.0",
|
||||||
"@tiptap/extension-typography": "^2.10.0",
|
"@tiptap/extension-typography": "^2.10.0",
|
||||||
|
"@tiptap/extension-underline": "^2.25.0",
|
||||||
"@tiptap/pm": "^2.11.7",
|
"@tiptap/pm": "^2.11.7",
|
||||||
"@tiptap/starter-kit": "^2.10.0",
|
"@tiptap/starter-kit": "^2.10.0",
|
||||||
"@xyflow/svelte": "^0.1.19",
|
"@xyflow/svelte": "^0.1.19",
|
||||||
"async": "^3.2.5",
|
"async": "^3.2.5",
|
||||||
"bits-ui": "^0.21.15",
|
"bits-ui": "^0.21.15",
|
||||||
|
"chart.js": "^4.5.0",
|
||||||
"codemirror": "^6.0.1",
|
"codemirror": "^6.0.1",
|
||||||
"codemirror-lang-elixir": "^4.0.0",
|
"codemirror-lang-elixir": "^4.0.0",
|
||||||
"codemirror-lang-hcl": "^0.1.0",
|
"codemirror-lang-hcl": "^0.1.0",
|
||||||
|
|
@ -86,9 +95,10 @@
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"focus-trap": "^7.6.4",
|
"focus-trap": "^7.6.4",
|
||||||
"fuse.js": "^7.0.0",
|
"fuse.js": "^7.0.0",
|
||||||
|
"heic2any": "^0.0.4",
|
||||||
"highlight.js": "^11.9.0",
|
"highlight.js": "^11.9.0",
|
||||||
"html-entities": "^2.5.3",
|
"html-entities": "^2.5.3",
|
||||||
"html2canvas-pro": "^1.5.8",
|
"html2canvas-pro": "^1.5.11",
|
||||||
"i18next": "^23.10.0",
|
"i18next": "^23.10.0",
|
||||||
"i18next-browser-languagedetector": "^7.2.0",
|
"i18next-browser-languagedetector": "^7.2.0",
|
||||||
"i18next-resources-to-backend": "^1.2.0",
|
"i18next-resources-to-backend": "^1.2.0",
|
||||||
|
|
@ -97,10 +107,13 @@
|
||||||
"jspdf": "^3.0.0",
|
"jspdf": "^3.0.0",
|
||||||
"katex": "^0.16.22",
|
"katex": "^0.16.22",
|
||||||
"kokoro-js": "^1.1.1",
|
"kokoro-js": "^1.1.1",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"marked": "^9.1.0",
|
"marked": "^9.1.0",
|
||||||
"mermaid": "^11.6.0",
|
"mermaid": "^11.6.0",
|
||||||
"paneforge": "^0.0.6",
|
"paneforge": "^0.0.6",
|
||||||
"panzoom": "^9.4.3",
|
"panzoom": "^9.4.3",
|
||||||
|
"pdfjs-dist": "^5.3.93",
|
||||||
|
"prosemirror-collab": "^1.3.1",
|
||||||
"prosemirror-commands": "^1.6.0",
|
"prosemirror-commands": "^1.6.0",
|
||||||
"prosemirror-example-setup": "^1.2.3",
|
"prosemirror-example-setup": "^1.2.3",
|
||||||
"prosemirror-history": "^1.4.1",
|
"prosemirror-history": "^1.4.1",
|
||||||
|
|
@ -114,7 +127,7 @@
|
||||||
"prosemirror-view": "^1.34.3",
|
"prosemirror-view": "^1.34.3",
|
||||||
"pyodide": "^0.27.3",
|
"pyodide": "^0.27.3",
|
||||||
"socket.io-client": "^4.2.0",
|
"socket.io-client": "^4.2.0",
|
||||||
"sortablejs": "^1.15.2",
|
"sortablejs": "^1.15.6",
|
||||||
"svelte-sonner": "^0.3.19",
|
"svelte-sonner": "^0.3.19",
|
||||||
"tippy.js": "^6.3.7",
|
"tippy.js": "^6.3.7",
|
||||||
"turndown": "^7.2.0",
|
"turndown": "^7.2.0",
|
||||||
|
|
@ -122,7 +135,9 @@
|
||||||
"undici": "^7.3.0",
|
"undici": "^7.3.0",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
"vite-plugin-static-copy": "^2.2.0",
|
"vite-plugin-static-copy": "^2.2.0",
|
||||||
"yaml": "^2.7.1"
|
"y-prosemirror": "^1.3.7",
|
||||||
|
"yaml": "^2.7.1",
|
||||||
|
"yjs": "^13.6.27"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.13.0 <=22.x.x",
|
"node": ">=18.13.0 <=22.x.x",
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,13 @@ license = { file = "LICENSE" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastapi==0.115.7",
|
"fastapi==0.115.7",
|
||||||
"uvicorn[standard]==0.34.2",
|
"uvicorn[standard]==0.34.2",
|
||||||
"pydantic==2.10.6",
|
"pydantic==2.11.7",
|
||||||
"python-multipart==0.0.20",
|
"python-multipart==0.0.20",
|
||||||
|
|
||||||
"python-socketio==5.13.0",
|
"python-socketio==5.13.0",
|
||||||
"python-jose==3.4.0",
|
"python-jose==3.4.0",
|
||||||
"passlib[bcrypt]==1.7.4",
|
"passlib[bcrypt]==1.7.4",
|
||||||
|
"cryptography",
|
||||||
|
|
||||||
"requests==2.32.4",
|
"requests==2.32.4",
|
||||||
"aiohttp==3.11.11",
|
"aiohttp==3.11.11",
|
||||||
|
|
@ -21,6 +22,7 @@ dependencies = [
|
||||||
"aiocache",
|
"aiocache",
|
||||||
"aiofiles",
|
"aiofiles",
|
||||||
"starlette-compress==1.6.0",
|
"starlette-compress==1.6.0",
|
||||||
|
"httpx[socks,http2,zstd,cli,brotli]==0.28.1",
|
||||||
|
|
||||||
"sqlalchemy==2.0.38",
|
"sqlalchemy==2.0.38",
|
||||||
"alembic==1.14.0",
|
"alembic==1.14.0",
|
||||||
|
|
@ -38,6 +40,8 @@ dependencies = [
|
||||||
"argon2-cffi==23.1.0",
|
"argon2-cffi==23.1.0",
|
||||||
"APScheduler==3.10.4",
|
"APScheduler==3.10.4",
|
||||||
|
|
||||||
|
"pycrdt==0.12.25",
|
||||||
|
|
||||||
|
|
||||||
"RestrictedPython==8.0",
|
"RestrictedPython==8.0",
|
||||||
|
|
||||||
|
|
@ -50,13 +54,13 @@ dependencies = [
|
||||||
"google-generativeai==0.8.5",
|
"google-generativeai==0.8.5",
|
||||||
"tiktoken",
|
"tiktoken",
|
||||||
|
|
||||||
"langchain==0.3.24",
|
"langchain==0.3.26",
|
||||||
"langchain-community==0.3.23",
|
"langchain-community==0.3.26",
|
||||||
|
|
||||||
"fake-useragent==2.1.0",
|
"fake-useragent==2.1.0",
|
||||||
"chromadb==0.6.3",
|
"chromadb==0.6.3",
|
||||||
"pymilvus==2.5.0",
|
"pymilvus==2.5.0",
|
||||||
"qdrant-client~=1.12.0",
|
"qdrant-client==1.14.3",
|
||||||
"opensearch-py==2.8.0",
|
"opensearch-py==2.8.0",
|
||||||
"playwright==1.49.1",
|
"playwright==1.49.1",
|
||||||
"elasticsearch==9.0.1",
|
"elasticsearch==9.0.1",
|
||||||
|
|
@ -106,7 +110,7 @@ dependencies = [
|
||||||
"pytube==15.0.0",
|
"pytube==15.0.0",
|
||||||
|
|
||||||
"pydub",
|
"pydub",
|
||||||
"duckduckgo-search==8.0.2",
|
"ddgs==9.0.0",
|
||||||
|
|
||||||
"google-api-python-client",
|
"google-api-python-client",
|
||||||
"google-auth-httplib2",
|
"google-auth-httplib2",
|
||||||
|
|
@ -138,7 +142,7 @@ requires-python = ">= 3.11, < 3.13.0a1"
|
||||||
dynamic = ["version"]
|
dynamic = ["version"]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Development Status :: 4 - Beta",
|
"Development Status :: 4 - Beta",
|
||||||
"License :: OSI Approved :: MIT License",
|
"License :: Other/Proprietary License",
|
||||||
"Programming Language :: Python :: 3",
|
"Programming Language :: Python :: 3",
|
||||||
"Programming Language :: Python :: 3.11",
|
"Programming Language :: Python :: 3.11",
|
||||||
"Programming Language :: Python :: 3.12",
|
"Programming Language :: Python :: 3.12",
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,8 @@ const packages = [
|
||||||
'tiktoken',
|
'tiktoken',
|
||||||
'seaborn',
|
'seaborn',
|
||||||
'pytz',
|
'pytz',
|
||||||
'black'
|
'black',
|
||||||
|
'openai'
|
||||||
];
|
];
|
||||||
|
|
||||||
import { loadPyodide } from 'pyodide';
|
import { loadPyodide } from 'pyodide';
|
||||||
|
|
@ -74,8 +75,8 @@ async function downloadPackages() {
|
||||||
console.log('Pyodide version mismatch, removing static/pyodide directory');
|
console.log('Pyodide version mismatch, removing static/pyodide directory');
|
||||||
await rmdir('static/pyodide', { recursive: true });
|
await rmdir('static/pyodide', { recursive: true });
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (err) {
|
||||||
console.log('Pyodide package not found, proceeding with download.');
|
console.log('Pyodide package not found, proceeding with download.', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
185
src/app.css
185
src/app.css
|
|
@ -65,19 +65,23 @@ textarea::placeholder {
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-prose {
|
.input-prose {
|
||||||
@apply prose dark:prose-invert prose-headings:font-semibold prose-hr:my-4 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-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-1 prose-img:my-1 prose-headings:my-2 prose-pre:my-0 prose-table:my-1 prose-blockquote:my-0 prose-ul:my-1 prose-ol:my-1 prose-li:my-0.5 whitespace-pre-line;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-prose-sm {
|
.input-prose-sm {
|
||||||
@apply prose dark:prose-invert prose-headings:font-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 text-sm;
|
@apply prose dark:prose-invert prose-headings:font-medium prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg prose-hr:my-4 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-1 prose-img:my-1 prose-headings:my-2 prose-pre:my-0 prose-table:my-1 prose-blockquote:my-0 prose-ul:my-1 prose-ol:my-1 prose-li:my-1 whitespace-pre-line text-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-prose {
|
.markdown-prose {
|
||||||
@apply prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-semibold prose-hr:my-4 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
|
@apply prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-semibold prose-hr:my-4 prose-hr:border-gray-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-sm {
|
||||||
|
@apply text-sm prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-semibold prose-hr:my-2 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
.markdown-prose-xs {
|
.markdown-prose-xs {
|
||||||
@apply text-xs prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-semibold prose-hr:my-0 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
|
@apply text-xs prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-semibold prose-hr:my-0.5 prose-hr:border-gray-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 {
|
.markdown a {
|
||||||
|
|
@ -326,6 +330,138 @@ input[type='number'] {
|
||||||
@apply line-clamp-1 absolute;
|
@apply line-clamp-1 absolute;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tiptap ul[data-type='taskList'] {
|
||||||
|
list-style: none;
|
||||||
|
margin-left: 0;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
li {
|
||||||
|
align-items: start;
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
> label {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
user-select: none;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
> div {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* checked data-checked="true" */
|
||||||
|
|
||||||
|
li[data-checked='true'] {
|
||||||
|
> div {
|
||||||
|
opacity: 0.5;
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='checkbox'] {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul[data-type='taskList'] {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reset nested regular ul elements to default styling */
|
||||||
|
ul:not([data-type='taskList']) {
|
||||||
|
list-style: disc;
|
||||||
|
padding-left: 1rem;
|
||||||
|
|
||||||
|
li {
|
||||||
|
align-items: initial;
|
||||||
|
display: list-item;
|
||||||
|
|
||||||
|
label {
|
||||||
|
flex: initial;
|
||||||
|
margin-right: initial;
|
||||||
|
margin-top: initial;
|
||||||
|
user-select: initial;
|
||||||
|
display: initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
div {
|
||||||
|
flex: initial;
|
||||||
|
align-items: initial;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-prose .tiptap ul[data-type='taskList'] {
|
||||||
|
list-style: none;
|
||||||
|
margin-left: 0;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
li {
|
||||||
|
align-items: start;
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
> label {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
user-select: none;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
> div {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* checked data-checked="true" */
|
||||||
|
|
||||||
|
li[data-checked='true'] {
|
||||||
|
> div {
|
||||||
|
opacity: 0.5;
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='checkbox'] {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul[data-type='taskList'] {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reset nested regular ul elements to default styling */
|
||||||
|
ul:not([data-type='taskList']) {
|
||||||
|
list-style: disc;
|
||||||
|
padding-left: 1rem;
|
||||||
|
|
||||||
|
li {
|
||||||
|
align-items: initial;
|
||||||
|
display: list-item;
|
||||||
|
|
||||||
|
label {
|
||||||
|
flex: initial;
|
||||||
|
margin-right: initial;
|
||||||
|
margin-top: initial;
|
||||||
|
user-select: initial;
|
||||||
|
display: initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
div {
|
||||||
|
flex: initial;
|
||||||
|
align-items: initial;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
.ProseMirror p.is-editor-empty:first-child::before {
|
.ProseMirror p.is-editor-empty:first-child::before {
|
||||||
color: #757575;
|
color: #757575;
|
||||||
|
|
@ -339,21 +475,21 @@ input[type='number'] {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiptap > pre > code {
|
.tiptap pre > code {
|
||||||
border-radius: 0.4rem;
|
border-radius: 0.4rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
padding: 0.25em 0.3em;
|
padding: 0.25em 0.3em;
|
||||||
|
|
||||||
@apply dark:bg-gray-800 bg-gray-100;
|
@apply dark:bg-gray-800 bg-gray-50;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiptap > pre {
|
.tiptap pre {
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
font-family: 'JetBrainsMono', monospace;
|
font-family: 'JetBrainsMono', monospace;
|
||||||
margin: 1.5rem 0;
|
margin: 1.5rem 0;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
|
|
||||||
@apply dark:bg-gray-800 bg-gray-100;
|
@apply dark:bg-gray-800 bg-gray-50;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiptap p code {
|
.tiptap p code {
|
||||||
|
|
@ -362,7 +498,7 @@ input[type='number'] {
|
||||||
padding: 3px 8px;
|
padding: 3px 8px;
|
||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@apply rounded-md dark:bg-gray-800 bg-gray-100 mx-0.5;
|
@apply rounded-md dark:bg-gray-800 bg-gray-50 mx-0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Code styling */
|
/* Code styling */
|
||||||
|
|
@ -442,3 +578,36 @@ input[type='number'] {
|
||||||
.tiptap tr {
|
.tiptap tr {
|
||||||
@apply bg-white dark:bg-gray-900 dark:border-gray-850 text-xs;
|
@apply bg-white dark:bg-gray-900 dark:border-gray-850 text-xs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tippy-box[data-theme~='transparent'] {
|
||||||
|
@apply bg-transparent p-0 m-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* this is a rough fix for the first cursor position when the first paragraph is empty */
|
||||||
|
.ProseMirror > .ProseMirror-yjs-cursor:first-child {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
/* This gives the remote user caret. The colors are automatically overwritten*/
|
||||||
|
.ProseMirror-yjs-cursor {
|
||||||
|
position: relative;
|
||||||
|
margin-left: -1px;
|
||||||
|
margin-right: -1px;
|
||||||
|
border-left: 1px solid black;
|
||||||
|
border-right: 1px solid black;
|
||||||
|
border-color: orange;
|
||||||
|
word-break: normal;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
/* This renders the username above the caret */
|
||||||
|
.ProseMirror-yjs-cursor > div {
|
||||||
|
position: absolute;
|
||||||
|
top: -1.05em;
|
||||||
|
left: -1px;
|
||||||
|
font-size: 13px;
|
||||||
|
background-color: rgb(250, 129, 0);
|
||||||
|
user-select: none;
|
||||||
|
color: white;
|
||||||
|
padding-left: 2px;
|
||||||
|
padding-right: 2px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
|
||||||
43
src/app.html
43
src/app.html
|
|
@ -77,28 +77,18 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
const isDarkMode = document.documentElement.classList.contains('dark');
|
||||||
|
|
||||||
function setSplashImage() {
|
const logo = document.createElement('img');
|
||||||
const logo = document.getElementById('logo');
|
logo.id = 'logo';
|
||||||
const isDarkMode = document.documentElement.classList.contains('dark');
|
logo.style =
|
||||||
|
'position: absolute; width: auto; height: 6rem; top: 44%; left: 50%; transform: translateX(-50%); display:block;';
|
||||||
|
logo.src = isDarkMode ? '/static/splash-dark.png' : '/static/splash.png';
|
||||||
|
|
||||||
if (isDarkMode) {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
const darkImage = new Image();
|
const splash = document.getElementById('splash-screen');
|
||||||
darkImage.src = '/static/splash-dark.png';
|
if (splash) splash.prepend(logo);
|
||||||
|
});
|
||||||
darkImage.onload = () => {
|
|
||||||
logo.src = '/static/splash-dark.png';
|
|
||||||
logo.style.filter = ''; // Ensure no inversion is applied if splash-dark.png exists
|
|
||||||
};
|
|
||||||
|
|
||||||
darkImage.onerror = () => {
|
|
||||||
logo.style.filter = 'invert(1)'; // Invert image if splash-dark.png is missing
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Runs after classes are assigned
|
|
||||||
window.onload = setSplashImage;
|
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -120,19 +110,6 @@
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<img
|
|
||||||
id="logo"
|
|
||||||
style="
|
|
||||||
position: absolute;
|
|
||||||
width: auto;
|
|
||||||
height: 6rem;
|
|
||||||
top: 44%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
"
|
|
||||||
src="/static/splash.png"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
style="
|
style="
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
|
||||||
|
|
@ -347,6 +347,8 @@ export const userSignOut = async () => {
|
||||||
if (error) {
|
if (error) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sessionStorage.clear();
|
||||||
return res;
|
return res;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { WEBUI_API_BASE_URL } from '$lib/constants';
|
import { WEBUI_API_BASE_URL } from '$lib/constants';
|
||||||
import { getTimeRange } from '$lib/utils';
|
import { getTimeRange } from '$lib/utils';
|
||||||
|
|
||||||
export const createNewChat = async (token: string, chat: object) => {
|
export const createNewChat = async (token: string, chat: object, folderId: string | null) => {
|
||||||
let error = null;
|
let error = null;
|
||||||
|
|
||||||
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/new`, {
|
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/new`, {
|
||||||
|
|
@ -12,7 +12,8 @@ export const createNewChat = async (token: string, chat: object) => {
|
||||||
authorization: `Bearer ${token}`
|
authorization: `Bearer ${token}`
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
chat: chat
|
chat: chat,
|
||||||
|
folder_id: folderId ?? null
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.then(async (res) => {
|
.then(async (res) => {
|
||||||
|
|
@ -37,7 +38,9 @@ export const importChat = async (
|
||||||
chat: object,
|
chat: object,
|
||||||
meta: object | null,
|
meta: object | null,
|
||||||
pinned?: boolean,
|
pinned?: boolean,
|
||||||
folderId?: string | null
|
folderId?: string | null,
|
||||||
|
createdAt: number | null = null,
|
||||||
|
updatedAt: number | null = null
|
||||||
) => {
|
) => {
|
||||||
let error = null;
|
let error = null;
|
||||||
|
|
||||||
|
|
@ -52,7 +55,9 @@ export const importChat = async (
|
||||||
chat: chat,
|
chat: chat,
|
||||||
meta: meta ?? {},
|
meta: meta ?? {},
|
||||||
pinned: pinned,
|
pinned: pinned,
|
||||||
folder_id: folderId
|
folder_id: folderId,
|
||||||
|
created_at: createdAt ?? null,
|
||||||
|
updated_at: updatedAt ?? null
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.then(async (res) => {
|
.then(async (res) => {
|
||||||
|
|
|
||||||
|
|
@ -58,10 +58,10 @@ export const exportConfig = async (token: string) => {
|
||||||
return res;
|
return res;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getDirectConnectionsConfig = async (token: string) => {
|
export const getConnectionsConfig = async (token: string) => {
|
||||||
let error = null;
|
let error = null;
|
||||||
|
|
||||||
const res = await fetch(`${WEBUI_API_BASE_URL}/configs/direct_connections`, {
|
const res = await fetch(`${WEBUI_API_BASE_URL}/configs/connections`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|
@ -85,10 +85,10 @@ export const getDirectConnectionsConfig = async (token: string) => {
|
||||||
return res;
|
return res;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const setDirectConnectionsConfig = async (token: string, config: object) => {
|
export const setConnectionsConfig = async (token: string, config: object) => {
|
||||||
let error = null;
|
let error = null;
|
||||||
|
|
||||||
const res = await fetch(`${WEBUI_API_BASE_URL}/configs/direct_connections`, {
|
const res = await fetch(`${WEBUI_API_BASE_URL}/configs/connections`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,12 @@ export const getFolderById = async (token: string, id: string) => {
|
||||||
return res;
|
return res;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateFolderNameById = async (token: string, id: string, name: string) => {
|
type FolderForm = {
|
||||||
|
name: string;
|
||||||
|
data?: Record<string, any>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateFolderById = async (token: string, id: string, folderForm: FolderForm) => {
|
||||||
let error = null;
|
let error = null;
|
||||||
|
|
||||||
const res = await fetch(`${WEBUI_API_BASE_URL}/folders/${id}/update`, {
|
const res = await fetch(`${WEBUI_API_BASE_URL}/folders/${id}/update`, {
|
||||||
|
|
@ -102,9 +107,7 @@ export const updateFolderNameById = async (token: string, id: string, name: stri
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
authorization: `Bearer ${token}`
|
authorization: `Bearer ${token}`
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(folderForm)
|
||||||
name: name
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
.then(async (res) => {
|
.then(async (res) => {
|
||||||
if (!res.ok) throw await res.json();
|
if (!res.ok) throw await res.json();
|
||||||
|
|
|
||||||
|
|
@ -8,17 +8,26 @@ import { toast } from 'svelte-sonner';
|
||||||
export const getModels = async (
|
export const getModels = async (
|
||||||
token: string = '',
|
token: string = '',
|
||||||
connections: object | null = null,
|
connections: object | null = null,
|
||||||
base: boolean = false
|
base: boolean = false,
|
||||||
|
refresh: boolean = false
|
||||||
) => {
|
) => {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
if (refresh) {
|
||||||
|
searchParams.append('refresh', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
let error = null;
|
let error = null;
|
||||||
const res = await fetch(`${WEBUI_BASE_URL}/api/models${base ? '/base' : ''}`, {
|
const res = await fetch(
|
||||||
method: 'GET',
|
`${WEBUI_BASE_URL}/api/models${base ? '/base' : ''}?${searchParams.toString()}`,
|
||||||
headers: {
|
{
|
||||||
Accept: 'application/json',
|
method: 'GET',
|
||||||
'Content-Type': 'application/json',
|
headers: {
|
||||||
...(token && { authorization: `Bearer ${token}` })
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token && { authorization: `Bearer ${token}` })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
.then(async (res) => {
|
.then(async (res) => {
|
||||||
if (!res.ok) throw await res.json();
|
if (!res.ok) throw await res.json();
|
||||||
return res.json();
|
return res.json();
|
||||||
|
|
@ -1587,6 +1596,7 @@ export interface ModelConfig {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModelMeta {
|
export interface ModelMeta {
|
||||||
|
toolIds: never[];
|
||||||
description?: string;
|
description?: string;
|
||||||
capabilities?: object;
|
capabilities?: object;
|
||||||
profile_image_url?: string;
|
profile_image_url?: string;
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ export const createNewNote = async (token: string, note: NoteItem) => {
|
||||||
return res;
|
return res;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getNotes = async (token: string = '') => {
|
export const getNotes = async (token: string = '', raw: boolean = false) => {
|
||||||
let error = null;
|
let error = null;
|
||||||
|
|
||||||
const res = await fetch(`${WEBUI_API_BASE_URL}/notes/`, {
|
const res = await fetch(`${WEBUI_API_BASE_URL}/notes/`, {
|
||||||
|
|
@ -67,6 +67,10 @@ export const getNotes = async (token: string = '') => {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (raw) {
|
||||||
|
return res; // Return raw response if requested
|
||||||
|
}
|
||||||
|
|
||||||
if (!Array.isArray(res)) {
|
if (!Array.isArray(res)) {
|
||||||
return {}; // or throw new Error("Notes response is not an array")
|
return {}; // or throw new Error("Notes response is not an array")
|
||||||
}
|
}
|
||||||
|
|
@ -87,6 +91,37 @@ export const getNotes = async (token: string = '') => {
|
||||||
return grouped;
|
return grouped;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getNoteList = async (token: string = '') => {
|
||||||
|
let error = null;
|
||||||
|
|
||||||
|
const res = await fetch(`${WEBUI_API_BASE_URL}/notes/list`, {
|
||||||
|
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.error(err);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
export const getNoteById = async (token: string, id: string) => {
|
export const getNoteById = async (token: string, id: string) => {
|
||||||
let error = null;
|
let error = null;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -366,7 +366,7 @@ export const unloadModel = async (token: string, tagName: string) => {
|
||||||
Authorization: `Bearer ${token}`
|
Authorization: `Bearer ${token}`
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name: tagName
|
model: tagName
|
||||||
})
|
})
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
error = err;
|
error = err;
|
||||||
|
|
@ -419,7 +419,7 @@ export const deleteModel = async (token: string, tagName: string, urlIdx: string
|
||||||
Authorization: `Bearer ${token}`
|
Authorization: `Bearer ${token}`
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name: tagName
|
model: tagName
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -403,6 +403,7 @@ export const deleteUserById = async (token: string, userId: string) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
type UserUpdateForm = {
|
type UserUpdateForm = {
|
||||||
|
role: string;
|
||||||
profile_image_url: string;
|
profile_image_url: string;
|
||||||
email: string;
|
email: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@
|
||||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||||
import Switch from '$lib/components/common/Switch.svelte';
|
import Switch from '$lib/components/common/Switch.svelte';
|
||||||
import Tags from './common/Tags.svelte';
|
import Tags from './common/Tags.svelte';
|
||||||
|
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||||
|
import XMark from '$lib/components/icons/XMark.svelte';
|
||||||
|
|
||||||
export let onSubmit: Function = () => {};
|
export let onSubmit: Function = () => {};
|
||||||
export let onDelete: Function = () => {};
|
export let onDelete: Function = () => {};
|
||||||
|
|
@ -208,17 +210,7 @@
|
||||||
show = false;
|
show = false;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg
|
<XMark className={'size-5'} />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="currentColor"
|
|
||||||
aria-hidden="true"
|
|
||||||
class="w-5 h-5"
|
|
||||||
>
|
|
||||||
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -524,29 +516,7 @@
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="ml-2 self-center">
|
<div class="ml-2 self-center">
|
||||||
<svg
|
<Spinner />
|
||||||
class=" w-4 h-4"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="currentColor"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
><style>
|
|
||||||
.spinner_ajPY {
|
|
||||||
transform-origin: center;
|
|
||||||
animation: spinner_AtaB 0.75s infinite linear;
|
|
||||||
}
|
|
||||||
@keyframes spinner_AtaB {
|
|
||||||
100% {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style><path
|
|
||||||
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
|
|
||||||
opacity=".25"
|
|
||||||
/><path
|
|
||||||
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
|
|
||||||
class="spinner_ajPY"
|
|
||||||
/></svg
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { getContext, onMount } from 'svelte';
|
import { getContext, onMount } from 'svelte';
|
||||||
const i18n = getContext('i18n');
|
const i18n = getContext('i18n');
|
||||||
|
|
||||||
|
import { settings } from '$lib/stores';
|
||||||
import Modal from '$lib/components/common/Modal.svelte';
|
import Modal from '$lib/components/common/Modal.svelte';
|
||||||
import Plus from '$lib/components/icons/Plus.svelte';
|
import Plus from '$lib/components/icons/Plus.svelte';
|
||||||
import Minus from '$lib/components/icons/Minus.svelte';
|
import Minus from '$lib/components/icons/Minus.svelte';
|
||||||
|
|
@ -14,6 +15,8 @@
|
||||||
import { getToolServerData } from '$lib/apis';
|
import { getToolServerData } from '$lib/apis';
|
||||||
import { verifyToolServerConnection } from '$lib/apis/configs';
|
import { verifyToolServerConnection } from '$lib/apis/configs';
|
||||||
import AccessControl from './workspace/common/AccessControl.svelte';
|
import AccessControl from './workspace/common/AccessControl.svelte';
|
||||||
|
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||||
|
import XMark from '$lib/components/icons/XMark.svelte';
|
||||||
|
|
||||||
export let onSubmit: Function = () => {};
|
export let onSubmit: Function = () => {};
|
||||||
export let onDelete: Function = () => {};
|
export let onDelete: Function = () => {};
|
||||||
|
|
@ -153,29 +156,21 @@
|
||||||
<Modal size="sm" bind:show>
|
<Modal size="sm" bind:show>
|
||||||
<div>
|
<div>
|
||||||
<div class=" flex justify-between dark:text-gray-100 px-5 pt-4 pb-2">
|
<div class=" flex justify-between dark:text-gray-100 px-5 pt-4 pb-2">
|
||||||
<div class=" text-lg font-medium self-center font-primary">
|
<h1 class=" text-lg font-medium self-center font-primary">
|
||||||
{#if edit}
|
{#if edit}
|
||||||
{$i18n.t('Edit Connection')}
|
{$i18n.t('Edit Connection')}
|
||||||
{:else}
|
{:else}
|
||||||
{$i18n.t('Add Connection')}
|
{$i18n.t('Add Connection')}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</h1>
|
||||||
<button
|
<button
|
||||||
class="self-center"
|
class="self-center"
|
||||||
|
aria-label={$i18n.t('Close Configure Connection Modal')}
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
show = false;
|
show = false;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg
|
<XMark className={'size-5'} />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="currentColor"
|
|
||||||
class="w-5 h-5"
|
|
||||||
>
|
|
||||||
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -192,12 +187,17 @@
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<div class="flex flex-col w-full">
|
<div class="flex flex-col w-full">
|
||||||
<div class="flex justify-between mb-0.5">
|
<div class="flex justify-between mb-0.5">
|
||||||
<div class=" text-xs text-gray-500">{$i18n.t('URL')}</div>
|
<label
|
||||||
|
for="api-base-url"
|
||||||
|
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||||
|
>{$i18n.t('URL')}</label
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-1 items-center">
|
<div class="flex flex-1 items-center">
|
||||||
<input
|
<input
|
||||||
class="w-full flex-1 text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
|
id="api-base-url"
|
||||||
|
class={`w-full flex-1 text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={url}
|
bind:value={url}
|
||||||
placeholder={$i18n.t('API Base URL')}
|
placeholder={$i18n.t('API Base URL')}
|
||||||
|
|
@ -214,6 +214,7 @@
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
verifyHandler();
|
verifyHandler();
|
||||||
}}
|
}}
|
||||||
|
aria-label={$i18n.t('Verify Connection')}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
|
|
@ -221,6 +222,7 @@
|
||||||
viewBox="0 0 20 20"
|
viewBox="0 0 20 20"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
class="w-4 h-4"
|
class="w-4 h-4"
|
||||||
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
fill-rule="evenodd"
|
fill-rule="evenodd"
|
||||||
|
|
@ -237,9 +239,13 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 flex items-center">
|
<div class="flex-1 flex items-center">
|
||||||
|
<label for="url-or-path" class="sr-only"
|
||||||
|
>{$i18n.t('openapi.json URL or Path')}</label
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
class="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
|
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||||
type="text"
|
type="text"
|
||||||
|
id="url-or-path"
|
||||||
bind:value={path}
|
bind:value={path}
|
||||||
placeholder={$i18n.t('openapi.json URL or Path')}
|
placeholder={$i18n.t('openapi.json URL or Path')}
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
|
|
@ -249,7 +255,9 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-xs text-gray-500 mt-1">
|
<div
|
||||||
|
class={`text-xs mt-1 ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||||
|
>
|
||||||
{$i18n.t(`WebUI will make requests to "{{url}}"`, {
|
{$i18n.t(`WebUI will make requests to "{{url}}"`, {
|
||||||
url: path.includes('://') ? path : `${url}${path.startsWith('/') ? '' : '/'}${path}`
|
url: path.includes('://') ? path : `${url}${path.startsWith('/') ? '' : '/'}${path}`
|
||||||
})}
|
})}
|
||||||
|
|
@ -257,12 +265,17 @@
|
||||||
|
|
||||||
<div class="flex gap-2 mt-2">
|
<div class="flex gap-2 mt-2">
|
||||||
<div class="flex flex-col w-full">
|
<div class="flex flex-col w-full">
|
||||||
<div class=" text-xs text-gray-500">{$i18n.t('Auth')}</div>
|
<label
|
||||||
|
for="select-bearer-or-session"
|
||||||
|
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||||
|
>{$i18n.t('Auth')}</label
|
||||||
|
>
|
||||||
|
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<div class="flex-shrink-0 self-start">
|
<div class="flex-shrink-0 self-start">
|
||||||
<select
|
<select
|
||||||
class="w-full text-sm bg-transparent dark:bg-gray-900 placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden pr-5"
|
id="select-bearer-or-session"
|
||||||
|
class={`w-full text-sm bg-transparent pr-5 ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||||
bind:value={auth_type}
|
bind:value={auth_type}
|
||||||
>
|
>
|
||||||
<option value="bearer">Bearer</option>
|
<option value="bearer">Bearer</option>
|
||||||
|
|
@ -273,13 +286,14 @@
|
||||||
<div class="flex flex-1 items-center">
|
<div class="flex flex-1 items-center">
|
||||||
{#if auth_type === 'bearer'}
|
{#if auth_type === 'bearer'}
|
||||||
<SensitiveInput
|
<SensitiveInput
|
||||||
className="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
|
|
||||||
bind:value={key}
|
bind:value={key}
|
||||||
placeholder={$i18n.t('API Key')}
|
placeholder={$i18n.t('API Key')}
|
||||||
required={false}
|
required={false}
|
||||||
/>
|
/>
|
||||||
{:else if auth_type === 'session'}
|
{:else if auth_type === 'session'}
|
||||||
<div class="text-xs text-gray-500 self-center translate-y-[1px]">
|
<div
|
||||||
|
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||||
|
>
|
||||||
{$i18n.t('Forwards system user session credentials to authenticate')}
|
{$i18n.t('Forwards system user session credentials to authenticate')}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -293,11 +307,16 @@
|
||||||
|
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<div class="flex flex-col w-full">
|
<div class="flex flex-col w-full">
|
||||||
<div class=" mb-0.5 text-xs text-gray-500">{$i18n.t('Name')}</div>
|
<label
|
||||||
|
for="enter-name"
|
||||||
|
class={`mb-0.5 text-xs" ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||||
|
>{$i18n.t('Name')}</label
|
||||||
|
>
|
||||||
|
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<input
|
<input
|
||||||
class="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
|
id="enter-name"
|
||||||
|
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={name}
|
bind:value={name}
|
||||||
placeholder={$i18n.t('Enter name')}
|
placeholder={$i18n.t('Enter name')}
|
||||||
|
|
@ -309,11 +328,16 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col w-full mt-2">
|
<div class="flex flex-col w-full mt-2">
|
||||||
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Description')}</div>
|
<label
|
||||||
|
for="description"
|
||||||
|
class={`mb-1 text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100 placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700 text-gray-500'}`}
|
||||||
|
>{$i18n.t('Description')}</label
|
||||||
|
>
|
||||||
|
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<input
|
<input
|
||||||
class="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
|
id="description"
|
||||||
|
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={description}
|
bind:value={description}
|
||||||
placeholder={$i18n.t('Enter description')}
|
placeholder={$i18n.t('Enter description')}
|
||||||
|
|
@ -357,29 +381,7 @@
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="ml-2 self-center">
|
<div class="ml-2 self-center">
|
||||||
<svg
|
<Spinner />
|
||||||
class=" w-4 h-4"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="currentColor"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
><style>
|
|
||||||
.spinner_ajPY {
|
|
||||||
transform-origin: center;
|
|
||||||
animation: spinner_AtaB 0.75s infinite linear;
|
|
||||||
}
|
|
||||||
@keyframes spinner_AtaB {
|
|
||||||
100% {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style><path
|
|
||||||
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
|
|
||||||
opacity=".25"
|
|
||||||
/><path
|
|
||||||
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
|
|
||||||
class="spinner_ajPY"
|
|
||||||
/></svg
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
|
|
||||||
import Modal from './common/Modal.svelte';
|
import Modal from './common/Modal.svelte';
|
||||||
import { updateUserSettings } from '$lib/apis/users';
|
import { updateUserSettings } from '$lib/apis/users';
|
||||||
|
import XMark from '$lib/components/icons/XMark.svelte';
|
||||||
|
|
||||||
const i18n = getContext('i18n');
|
const i18n = getContext('i18n');
|
||||||
|
|
||||||
|
|
@ -36,18 +37,11 @@
|
||||||
localStorage.version = $config.version;
|
localStorage.version = $config.version;
|
||||||
show = false;
|
show = false;
|
||||||
}}
|
}}
|
||||||
|
aria-label={$i18n.t('Close')}
|
||||||
>
|
>
|
||||||
<svg
|
<XMark className={'size-5'}>
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="currentColor"
|
|
||||||
class="w-5 h-5"
|
|
||||||
>
|
|
||||||
<p class="sr-only">{$i18n.t('Close')}</p>
|
<p class="sr-only">{$i18n.t('Close')}</p>
|
||||||
<path
|
</XMark>
|
||||||
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center mt-1">
|
<div class="flex items-center mt-1">
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,9 @@
|
||||||
import { getContext, onMount } from 'svelte';
|
import { getContext, onMount } from 'svelte';
|
||||||
const i18n = getContext('i18n');
|
const i18n = getContext('i18n');
|
||||||
|
|
||||||
|
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||||
import Modal from '$lib/components/common/Modal.svelte';
|
import Modal from '$lib/components/common/Modal.svelte';
|
||||||
|
import XMark from '$lib/components/icons/XMark.svelte';
|
||||||
import { extractFrontmatter } from '$lib/utils';
|
import { extractFrontmatter } from '$lib/utils';
|
||||||
|
|
||||||
export let show = false;
|
export let show = false;
|
||||||
|
|
@ -69,16 +71,7 @@
|
||||||
show = false;
|
show = false;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg
|
<XMark className={'size-5'} />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="currentColor"
|
|
||||||
class="w-5 h-5"
|
|
||||||
>
|
|
||||||
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -120,29 +113,7 @@
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="ml-2 self-center">
|
<div class="ml-2 self-center">
|
||||||
<svg
|
<Spinner />
|
||||||
class=" w-4 h-4"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="currentColor"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
><style>
|
|
||||||
.spinner_ajPY {
|
|
||||||
transform-origin: center;
|
|
||||||
animation: spinner_AtaB 0.75s infinite linear;
|
|
||||||
}
|
|
||||||
@keyframes spinner_AtaB {
|
|
||||||
100% {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style><path
|
|
||||||
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
|
|
||||||
opacity=".25"
|
|
||||||
/><path
|
|
||||||
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
|
|
||||||
class="spinner_ajPY"
|
|
||||||
/></svg
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { WEBUI_BASE_URL } from '$lib/constants';
|
||||||
import { settings, playingNotificationSound, isLastActiveTab } from '$lib/stores';
|
import { settings, playingNotificationSound, isLastActiveTab } from '$lib/stores';
|
||||||
import DOMPurify from 'dompurify';
|
import DOMPurify from 'dompurify';
|
||||||
|
|
||||||
|
|
@ -38,7 +39,7 @@
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="shrink-0 self-top -translate-y-0.5">
|
<div class="shrink-0 self-top -translate-y-0.5">
|
||||||
<img src={'/static/favicon.png'} alt="favicon" class="size-7 rounded-full" />
|
<img src="{WEBUI_BASE_URL}/static/favicon.png" alt="favicon" class="size-7 rounded-full" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -19,10 +19,10 @@
|
||||||
|
|
||||||
if (isDarkMode) {
|
if (isDarkMode) {
|
||||||
const darkImage = new Image();
|
const darkImage = new Image();
|
||||||
darkImage.src = '/static/favicon-dark.png';
|
darkImage.src = `${WEBUI_BASE_URL}/static/favicon-dark.png`;
|
||||||
|
|
||||||
darkImage.onload = () => {
|
darkImage.onload = () => {
|
||||||
logo.src = '/static/favicon-dark.png';
|
logo.src = `${WEBUI_BASE_URL}/static/favicon-dark.png`;
|
||||||
logo.style.filter = ''; // Ensure no inversion is applied if splash-dark.png exists
|
logo.style.filter = ''; // Ensure no inversion is applied if splash-dark.png exists
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,41 @@
|
||||||
import Modal from '$lib/components/common/Modal.svelte';
|
import Modal from '$lib/components/common/Modal.svelte';
|
||||||
import { getContext } from 'svelte';
|
import { getContext } from 'svelte';
|
||||||
const i18n = getContext('i18n');
|
const i18n = getContext('i18n');
|
||||||
|
import XMark from '$lib/components/icons/XMark.svelte';
|
||||||
|
import { getFeedbackById } from '$lib/apis/evaluations';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||||
|
|
||||||
export let show = false;
|
export let show = false;
|
||||||
export let selectedFeedback = null;
|
export let selectedFeedback = null;
|
||||||
|
|
||||||
export let onClose: () => void = () => {};
|
export let onClose: () => void = () => {};
|
||||||
|
|
||||||
|
let loaded = false;
|
||||||
|
|
||||||
|
let feedbackData = null;
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
show = false;
|
show = false;
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const init = async () => {
|
||||||
|
loaded = false;
|
||||||
|
feedbackData = null;
|
||||||
|
if (selectedFeedback) {
|
||||||
|
feedbackData = await getFeedbackById(localStorage.token, selectedFeedback.id).catch((err) => {
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Feedback Data:', selectedFeedback, feedbackData);
|
||||||
|
}
|
||||||
|
loaded = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
$: if (show) {
|
||||||
|
init();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Modal size="sm" bind:show>
|
<Modal size="sm" bind:show>
|
||||||
|
|
@ -22,58 +47,89 @@
|
||||||
{$i18n.t('Feedback Details')}
|
{$i18n.t('Feedback Details')}
|
||||||
</div>
|
</div>
|
||||||
<button class="self-center" on:click={close} aria-label="Close">
|
<button class="self-center" on:click={close} aria-label="Close">
|
||||||
<svg
|
<XMark className={'size-5'} />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="currentColor"
|
|
||||||
class="w-5 h-5"
|
|
||||||
>
|
|
||||||
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col md:flex-row w-full px-5 pb-4 md:space-x-4 dark:text-gray-200">
|
<div class="flex flex-col md:flex-row w-full px-5 pb-4 md:space-x-4 dark:text-gray-200">
|
||||||
<div class="flex flex-col w-full">
|
{#if loaded}
|
||||||
<div class="flex flex-col w-full mb-2">
|
<div class="flex flex-col w-full">
|
||||||
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Rating')}</div>
|
{#if feedbackData}
|
||||||
|
{@const messageId = feedbackData?.meta?.message_id}
|
||||||
|
{@const messages = feedbackData?.snapshot?.chat?.chat?.history.messages}
|
||||||
|
|
||||||
<div class="flex-1">
|
{#if messages[messages[messageId]?.parentId]}
|
||||||
<span>{selectedFeedback?.data?.details?.rating ?? '-'}</span>
|
<div class="flex flex-col w-full mb-2">
|
||||||
</div>
|
<div class="mb-1 text-xs text-gray-500">{$i18n.t('Prompt')}</div>
|
||||||
</div>
|
|
||||||
<div class="flex flex-col w-full mb-2">
|
|
||||||
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Reason')}</div>
|
|
||||||
|
|
||||||
<div class="flex-1">
|
<div class="flex-1 text-xs whitespace-pre-line break-words">
|
||||||
<span>{selectedFeedback?.data?.reason || '-'}</span>
|
<span>{messages[messages[messageId]?.parentId]?.content || '-'}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="mb-2">
|
{#if messages[messageId]}
|
||||||
{#if selectedFeedback?.data?.tags && selectedFeedback?.data?.tags.length}
|
<div class="flex flex-col w-full mb-2">
|
||||||
<div class="flex flex-wrap gap-1 mt-1">
|
<div class="mb-1 text-xs text-gray-500">{$i18n.t('Response')}</div>
|
||||||
{#each selectedFeedback?.data?.tags as tag}
|
<div
|
||||||
<span class="px-2 py-0.5 rounded bg-gray-100 dark:bg-gray-800 text-xs">{tag}</span
|
class="flex-1 text-xs whitespace-pre-line break-words max-h-32 overflow-y-auto"
|
||||||
>
|
>
|
||||||
{/each}
|
<span>{messages[messageId]?.content || '-'}</span>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
</div>
|
||||||
<span>-</span>
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex flex-col w-full mb-2">
|
||||||
|
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Rating')}</div>
|
||||||
|
|
||||||
|
<div class="flex-1 text-xs">
|
||||||
|
<span>{selectedFeedback?.data?.details?.rating ?? '-'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col w-full mb-2">
|
||||||
|
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Reason')}</div>
|
||||||
|
|
||||||
|
<div class="flex-1 text-xs">
|
||||||
|
<span>{selectedFeedback?.data?.reason || '-'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col w-full mb-2">
|
||||||
|
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Comment')}</div>
|
||||||
|
|
||||||
|
<div class="flex-1 text-xs">
|
||||||
|
<span>{selectedFeedback?.data?.comment || '-'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if selectedFeedback?.data?.tags && selectedFeedback?.data?.tags.length}
|
||||||
|
<div class="mb-2 -mx-1">
|
||||||
|
<div class="flex flex-wrap gap-1 mt-1">
|
||||||
|
{#each selectedFeedback?.data?.tags as tag}
|
||||||
|
<span class="px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-850 text-[9px]"
|
||||||
|
>{tag}</span
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex justify-end pt-2">
|
||||||
|
<button
|
||||||
|
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
|
||||||
|
type="button"
|
||||||
|
on:click={close}
|
||||||
|
>
|
||||||
|
{$i18n.t('Close')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end pt-3">
|
{:else}
|
||||||
<button
|
<div class="flex items-center justify-center w-full h-32">
|
||||||
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"
|
<Spinner className={'size-5'} />
|
||||||
type="button"
|
|
||||||
on:click={close}
|
|
||||||
>
|
|
||||||
{$i18n.t('Close')}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
|
|
||||||
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
|
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
|
||||||
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
|
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
|
||||||
|
import { WEBUI_BASE_URL } from '$lib/constants';
|
||||||
|
|
||||||
export let feedbacks = [];
|
export let feedbacks = [];
|
||||||
|
|
||||||
|
|
@ -305,7 +306,7 @@
|
||||||
<tbody class="">
|
<tbody class="">
|
||||||
{#each paginatedFeedbacks as feedback (feedback.id)}
|
{#each paginatedFeedbacks as feedback (feedback.id)}
|
||||||
<tr
|
<tr
|
||||||
class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 transition"
|
class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-850/50 transition"
|
||||||
on:click={() => openFeedbackModal(feedback)}
|
on:click={() => openFeedbackModal(feedback)}
|
||||||
>
|
>
|
||||||
<td class=" py-0.5 text-right font-semibold">
|
<td class=" py-0.5 text-right font-semibold">
|
||||||
|
|
@ -313,7 +314,7 @@
|
||||||
<Tooltip content={feedback?.user?.name}>
|
<Tooltip content={feedback?.user?.name}>
|
||||||
<div class="shrink-0">
|
<div class="shrink-0">
|
||||||
<img
|
<img
|
||||||
src={feedback?.user?.profile_image_url ?? '/user.png'}
|
src={feedback?.user?.profile_image_url ?? `${WEBUI_BASE_URL}/user.png`}
|
||||||
alt={feedback?.user?.name}
|
alt={feedback?.user?.name}
|
||||||
class="size-5 rounded-full object-cover shrink-0"
|
class="size-5 rounded-full object-cover shrink-0"
|
||||||
/>
|
/>
|
||||||
|
|
@ -369,7 +370,7 @@
|
||||||
{dayjs(feedback.updated_at * 1000).fromNow()}
|
{dayjs(feedback.updated_at * 1000).fromNow()}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td class=" px-3 py-1 text-right font-semibold">
|
<td class=" px-3 py-1 text-right font-semibold" on:click={(e) => e.stopPropagation()}>
|
||||||
<FeedbackMenu
|
<FeedbackMenu
|
||||||
on:delete={(e) => {
|
on:delete={(e) => {
|
||||||
deleteFeedbackHandler(feedback.id);
|
deleteFeedbackHandler(feedback.id);
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,11 @@
|
||||||
|
|
||||||
import Spinner from '$lib/components/common/Spinner.svelte';
|
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||||
import MagnifyingGlass from '$lib/components/icons/MagnifyingGlass.svelte';
|
import Search from '$lib/components/icons/Search.svelte';
|
||||||
|
|
||||||
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
|
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
|
||||||
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
|
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
|
||||||
|
import { WEBUI_BASE_URL } from '$lib/constants';
|
||||||
|
|
||||||
const i18n = getContext('i18n');
|
const i18n = getContext('i18n');
|
||||||
|
|
||||||
|
|
@ -77,7 +78,7 @@
|
||||||
let showLeaderboardModal = false;
|
let showLeaderboardModal = false;
|
||||||
let selectedModel = null;
|
let selectedModel = null;
|
||||||
|
|
||||||
const openFeedbackModal = (model) => {
|
const openLeaderboardModelModal = (model) => {
|
||||||
showLeaderboardModal = true;
|
showLeaderboardModal = true;
|
||||||
selectedModel = model;
|
selectedModel = model;
|
||||||
};
|
};
|
||||||
|
|
@ -350,7 +351,7 @@
|
||||||
<Tooltip content={$i18n.t('Re-rank models by topic similarity')}>
|
<Tooltip content={$i18n.t('Re-rank models by topic similarity')}>
|
||||||
<div class="flex flex-1">
|
<div class="flex flex-1">
|
||||||
<div class=" self-center ml-1 mr-3">
|
<div class=" self-center ml-1 mr-3">
|
||||||
<MagnifyingGlass className="size-3" />
|
<Search className="size-3" />
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
|
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
|
||||||
|
|
@ -371,7 +372,7 @@
|
||||||
{#if loadingLeaderboard}
|
{#if loadingLeaderboard}
|
||||||
<div class=" absolute top-0 bottom-0 left-0 right-0 flex">
|
<div class=" absolute top-0 bottom-0 left-0 right-0 flex">
|
||||||
<div class="m-auto">
|
<div class="m-auto">
|
||||||
<Spinner />
|
<Spinner className="size-5" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -504,8 +505,8 @@
|
||||||
<tbody class="">
|
<tbody class="">
|
||||||
{#each sortedModels as model, modelIdx (model.id)}
|
{#each sortedModels as model, modelIdx (model.id)}
|
||||||
<tr
|
<tr
|
||||||
class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs group cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 transition"
|
class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs group cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-850/50 transition"
|
||||||
on:click={() => openFeedbackModal(model)}
|
on:click={() => openLeaderboardModelModal(model)}
|
||||||
>
|
>
|
||||||
<td class="px-3 py-1.5 text-left font-medium text-gray-900 dark:text-white w-fit">
|
<td class="px-3 py-1.5 text-left font-medium text-gray-900 dark:text-white w-fit">
|
||||||
<div class=" line-clamp-1">
|
<div class=" line-clamp-1">
|
||||||
|
|
@ -516,7 +517,7 @@
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div class="shrink-0">
|
<div class="shrink-0">
|
||||||
<img
|
<img
|
||||||
src={model?.info?.meta?.profile_image_url ?? '/favicon.png'}
|
src={model?.info?.meta?.profile_image_url ?? `${WEBUI_BASE_URL}/favicon.png`}
|
||||||
alt={model.name}
|
alt={model.name}
|
||||||
class="size-5 rounded-full object-cover shrink-0"
|
class="size-5 rounded-full object-cover shrink-0"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
export let feedbacks = [];
|
export let feedbacks = [];
|
||||||
export let onClose: () => void = () => {};
|
export let onClose: () => void = () => {};
|
||||||
const i18n = getContext('i18n');
|
const i18n = getContext('i18n');
|
||||||
|
import XMark from '$lib/components/icons/XMark.svelte';
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
show = false;
|
show = false;
|
||||||
|
|
@ -37,25 +38,16 @@
|
||||||
{model.name}
|
{model.name}
|
||||||
</div>
|
</div>
|
||||||
<button class="self-center" on:click={close} aria-label="Close">
|
<button class="self-center" on:click={close} aria-label="Close">
|
||||||
<svg
|
<XMark className={'size-5'} />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="currentColor"
|
|
||||||
class="w-5 h-5"
|
|
||||||
>
|
|
||||||
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="px-5 pb-4 dark:text-gray-200">
|
<div class="px-5 pb-4 dark:text-gray-200">
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
{#if topTags.length}
|
{#if topTags.length}
|
||||||
<div class="flex flex-wrap gap-1 mt-1">
|
<div class="flex flex-wrap gap-1 mt-1 -mx-1">
|
||||||
{#each topTags as tagInfo}
|
{#each topTags as tagInfo}
|
||||||
<span class="px-2 py-0.5 rounded bg-gray-100 dark:bg-gray-800 text-xs">
|
<span class="px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-850 text-xs">
|
||||||
{tagInfo.tag} <span class="text-gray-500">({tagInfo.count})</span>
|
{tagInfo.tag} <span class="text-gray-500 font-medium">{tagInfo.count}</span>
|
||||||
</span>
|
</span>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -63,7 +55,7 @@
|
||||||
<span>-</span>
|
<span>-</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end pt-3">
|
<div class="flex justify-end pt-2">
|
||||||
<button
|
<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"
|
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,8 @@
|
||||||
let showConfirm = false;
|
let showConfirm = false;
|
||||||
let query = '';
|
let query = '';
|
||||||
|
|
||||||
|
let selectedType = 'all';
|
||||||
|
|
||||||
let showManifestModal = false;
|
let showManifestModal = false;
|
||||||
let showValvesModal = false;
|
let showValvesModal = false;
|
||||||
let selectedFunction = null;
|
let selectedFunction = null;
|
||||||
|
|
@ -59,9 +61,10 @@
|
||||||
$: filteredItems = $functions
|
$: filteredItems = $functions
|
||||||
.filter(
|
.filter(
|
||||||
(f) =>
|
(f) =>
|
||||||
query === '' ||
|
(selectedType !== 'all' ? f.type === selectedType : true) &&
|
||||||
f.name.toLowerCase().includes(query.toLowerCase()) ||
|
(query === '' ||
|
||||||
f.id.toLowerCase().includes(query.toLowerCase())
|
f.name.toLowerCase().includes(query.toLowerCase()) ||
|
||||||
|
f.id.toLowerCase().includes(query.toLowerCase()))
|
||||||
)
|
)
|
||||||
.sort((a, b) => a.type.localeCompare(b.type) || a.name.localeCompare(b.name));
|
.sort((a, b) => a.type.localeCompare(b.type) || a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
|
@ -135,7 +138,9 @@
|
||||||
models.set(
|
models.set(
|
||||||
await getModels(
|
await getModels(
|
||||||
localStorage.token,
|
localStorage.token,
|
||||||
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
|
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null),
|
||||||
|
false,
|
||||||
|
true
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -161,7 +166,9 @@
|
||||||
models.set(
|
models.set(
|
||||||
await getModels(
|
await getModels(
|
||||||
localStorage.token,
|
localStorage.token,
|
||||||
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
|
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null),
|
||||||
|
false,
|
||||||
|
true
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -215,8 +222,8 @@
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="flex flex-col gap-1 mt-1.5 mb-2">
|
<div class="flex flex-col mt-1.5 mb-0.5">
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center mb-1">
|
||||||
<div class="flex md:self-center text-xl items-center font-medium px-0.5">
|
<div class="flex md:self-center text-xl items-center font-medium px-0.5">
|
||||||
{$i18n.t('Functions')}
|
{$i18n.t('Functions')}
|
||||||
<div class="flex self-center w-[1px] h-6 mx-2.5 bg-gray-50 dark:bg-gray-850" />
|
<div class="flex self-center w-[1px] h-6 mx-2.5 bg-gray-50 dark:bg-gray-850" />
|
||||||
|
|
@ -266,12 +273,54 @@
|
||||||
</AddFunctionMenu>
|
</AddFunctionMenu>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class=" flex w-full">
|
||||||
|
<div
|
||||||
|
class="flex gap-1 scrollbar-none overflow-x-auto w-fit text-center text-sm font-medium rounded-full bg-transparent"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="min-w-fit p-1.5 {selectedType === 'all'
|
||||||
|
? ''
|
||||||
|
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
|
||||||
|
on:click={() => {
|
||||||
|
selectedType = 'all';
|
||||||
|
}}>{$i18n.t('All')}</button
|
||||||
|
>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="min-w-fit p-1.5 {selectedType === 'pipe'
|
||||||
|
? ''
|
||||||
|
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
|
||||||
|
on:click={() => {
|
||||||
|
selectedType = 'pipe';
|
||||||
|
}}>{$i18n.t('Pipe')}</button
|
||||||
|
>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="min-w-fit p-1.5 {selectedType === 'filter'
|
||||||
|
? ''
|
||||||
|
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
|
||||||
|
on:click={() => {
|
||||||
|
selectedType = 'filter';
|
||||||
|
}}>{$i18n.t('Filter')}</button
|
||||||
|
>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="min-w-fit p-1.5 {selectedType === 'action'
|
||||||
|
? ''
|
||||||
|
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
|
||||||
|
on:click={() => {
|
||||||
|
selectedType = 'action';
|
||||||
|
}}>{$i18n.t('Action')}</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-5">
|
<div class="mb-5">
|
||||||
{#each filteredItems as func (func.id)}
|
{#each filteredItems as func (func.id)}
|
||||||
<div
|
<div
|
||||||
class=" flex space-x-4 cursor-pointer w-full px-3 py-2 dark:hover:bg-white/5 hover:bg-black/5 rounded-xl"
|
class=" flex space-x-4 cursor-pointer w-full px-2 py-2 dark:hover:bg-white/5 hover:bg-black/5 rounded-xl"
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
class=" flex flex-1 space-x-3.5 cursor-pointer w-full"
|
class=" flex flex-1 space-x-3.5 cursor-pointer w-full"
|
||||||
|
|
@ -413,7 +462,9 @@
|
||||||
await getModels(
|
await getModels(
|
||||||
localStorage.token,
|
localStorage.token,
|
||||||
$config?.features?.enable_direct_connections &&
|
$config?.features?.enable_direct_connections &&
|
||||||
($settings?.directConnections ?? null)
|
($settings?.directConnections ?? null),
|
||||||
|
false,
|
||||||
|
true
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
|
@ -559,7 +610,9 @@
|
||||||
models.set(
|
models.set(
|
||||||
await getModels(
|
await getModels(
|
||||||
localStorage.token,
|
localStorage.token,
|
||||||
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
|
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null),
|
||||||
|
false,
|
||||||
|
true
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
|
@ -585,7 +638,9 @@
|
||||||
models.set(
|
models.set(
|
||||||
await getModels(
|
await getModels(
|
||||||
localStorage.token,
|
localStorage.token,
|
||||||
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
|
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null),
|
||||||
|
false,
|
||||||
|
true
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
} from '$lib/apis/audio';
|
} from '$lib/apis/audio';
|
||||||
import { config, settings } from '$lib/stores';
|
import { config, settings } from '$lib/stores';
|
||||||
|
|
||||||
|
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||||
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
|
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
|
||||||
|
|
||||||
import { TTS_RESPONSE_SPLIT } from '$lib/types';
|
import { TTS_RESPONSE_SPLIT } from '$lib/types';
|
||||||
|
|
@ -199,7 +200,9 @@
|
||||||
<input
|
<input
|
||||||
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
|
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
|
||||||
bind:value={STT_SUPPORTED_CONTENT_TYPES}
|
bind:value={STT_SUPPORTED_CONTENT_TYPES}
|
||||||
placeholder={$i18n.t('e.g., audio/wav,audio/mpeg (leave blank for defaults)')}
|
placeholder={$i18n.t(
|
||||||
|
'e.g., audio/wav,audio/mpeg,video/* (leave blank for defaults, * for all)'
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -373,33 +376,7 @@
|
||||||
>
|
>
|
||||||
{#if STT_WHISPER_MODEL_LOADING}
|
{#if STT_WHISPER_MODEL_LOADING}
|
||||||
<div class="self-center">
|
<div class="self-center">
|
||||||
<svg
|
<Spinner />
|
||||||
class=" w-4 h-4"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="currentColor"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<style>
|
|
||||||
.spinner_ajPY {
|
|
||||||
transform-origin: center;
|
|
||||||
animation: spinner_AtaB 0.75s infinite linear;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spinner_AtaB {
|
|
||||||
100% {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<path
|
|
||||||
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
|
|
||||||
opacity=".25"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
|
|
||||||
class="spinner_ajPY"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<svg
|
<svg
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
import { getOllamaConfig, updateOllamaConfig } from '$lib/apis/ollama';
|
import { getOllamaConfig, updateOllamaConfig } from '$lib/apis/ollama';
|
||||||
import { getOpenAIConfig, updateOpenAIConfig, getOpenAIModels } from '$lib/apis/openai';
|
import { getOpenAIConfig, updateOpenAIConfig, getOpenAIModels } from '$lib/apis/openai';
|
||||||
import { getModels as _getModels } from '$lib/apis';
|
import { getModels as _getModels } from '$lib/apis';
|
||||||
import { getDirectConnectionsConfig, setDirectConnectionsConfig } from '$lib/apis/configs';
|
import { getConnectionsConfig, setConnectionsConfig } from '$lib/apis/configs';
|
||||||
|
|
||||||
import { config, models, settings, user } from '$lib/stores';
|
import { config, models, settings, user } from '$lib/stores';
|
||||||
|
|
||||||
|
|
@ -25,7 +25,9 @@
|
||||||
const getModels = async () => {
|
const getModels = async () => {
|
||||||
const models = await _getModels(
|
const models = await _getModels(
|
||||||
localStorage.token,
|
localStorage.token,
|
||||||
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
|
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null),
|
||||||
|
false,
|
||||||
|
true
|
||||||
);
|
);
|
||||||
return models;
|
return models;
|
||||||
};
|
};
|
||||||
|
|
@ -41,7 +43,7 @@
|
||||||
let ENABLE_OPENAI_API: null | boolean = null;
|
let ENABLE_OPENAI_API: null | boolean = null;
|
||||||
let ENABLE_OLLAMA_API: null | boolean = null;
|
let ENABLE_OLLAMA_API: null | boolean = null;
|
||||||
|
|
||||||
let directConnectionsConfig = null;
|
let connectionsConfig = null;
|
||||||
|
|
||||||
let pipelineUrls = {};
|
let pipelineUrls = {};
|
||||||
let showAddOpenAIConnectionModal = false;
|
let showAddOpenAIConnectionModal = false;
|
||||||
|
|
@ -104,15 +106,13 @@
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateDirectConnectionsHandler = async () => {
|
const updateConnectionsHandler = async () => {
|
||||||
const res = await setDirectConnectionsConfig(localStorage.token, directConnectionsConfig).catch(
|
const res = await setConnectionsConfig(localStorage.token, connectionsConfig).catch((error) => {
|
||||||
(error) => {
|
toast.error(`${error}`);
|
||||||
toast.error(`${error}`);
|
});
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (res) {
|
if (res) {
|
||||||
toast.success($i18n.t('Direct Connections settings updated'));
|
toast.success($i18n.t('Connections settings updated'));
|
||||||
await models.set(await getModels());
|
await models.set(await getModels());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -148,7 +148,7 @@
|
||||||
openaiConfig = await getOpenAIConfig(localStorage.token);
|
openaiConfig = await getOpenAIConfig(localStorage.token);
|
||||||
})(),
|
})(),
|
||||||
(async () => {
|
(async () => {
|
||||||
directConnectionsConfig = await getDirectConnectionsConfig(localStorage.token);
|
connectionsConfig = await getConnectionsConfig(localStorage.token);
|
||||||
})()
|
})()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -215,36 +215,103 @@
|
||||||
|
|
||||||
<form class="flex flex-col h-full justify-between text-sm" on:submit|preventDefault={submitHandler}>
|
<form class="flex flex-col h-full justify-between text-sm" on:submit|preventDefault={submitHandler}>
|
||||||
<div class=" overflow-y-scroll scrollbar-hidden h-full">
|
<div class=" overflow-y-scroll scrollbar-hidden h-full">
|
||||||
{#if ENABLE_OPENAI_API !== null && ENABLE_OLLAMA_API !== null && directConnectionsConfig !== null}
|
{#if ENABLE_OPENAI_API !== null && ENABLE_OLLAMA_API !== null && connectionsConfig !== null}
|
||||||
<div class="my-2">
|
<div class="mb-3.5">
|
||||||
<div class="mt-2 space-y-2 pr-1.5">
|
<div class=" mb-2.5 text-base font-medium">{$i18n.t('General')}</div>
|
||||||
<div class="flex justify-between items-center text-sm">
|
|
||||||
<div class=" font-medium">{$i18n.t('OpenAI API')}</div>
|
|
||||||
|
|
||||||
<div class="flex items-center">
|
<hr class=" border-gray-100 dark:border-gray-850 my-2" />
|
||||||
<div class="">
|
|
||||||
<Switch
|
<div class="my-2">
|
||||||
bind:state={ENABLE_OPENAI_API}
|
<div class="mt-2 space-y-2">
|
||||||
on:change={async () => {
|
<div class="flex justify-between items-center text-sm">
|
||||||
updateOpenAIHandler();
|
<div class=" font-medium">{$i18n.t('OpenAI API')}</div>
|
||||||
}}
|
|
||||||
/>
|
<div class="flex items-center">
|
||||||
|
<div class="">
|
||||||
|
<Switch
|
||||||
|
bind:state={ENABLE_OPENAI_API}
|
||||||
|
on:change={async () => {
|
||||||
|
updateOpenAIHandler();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if ENABLE_OPENAI_API}
|
||||||
|
<div class="">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<div class="font-medium text-xs">{$i18n.t('Manage OpenAI API Connections')}</div>
|
||||||
|
|
||||||
|
<Tooltip content={$i18n.t(`Add Connection`)}>
|
||||||
|
<button
|
||||||
|
class="px-1"
|
||||||
|
on:click={() => {
|
||||||
|
showAddOpenAIConnectionModal = true;
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Plus />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1.5 mt-1.5">
|
||||||
|
{#each OPENAI_API_BASE_URLS as url, idx}
|
||||||
|
<OpenAIConnection
|
||||||
|
pipeline={pipelineUrls[url] ? true : false}
|
||||||
|
bind:url
|
||||||
|
bind:key={OPENAI_API_KEYS[idx]}
|
||||||
|
bind:config={OPENAI_API_CONFIGS[idx]}
|
||||||
|
onSubmit={() => {
|
||||||
|
updateOpenAIHandler();
|
||||||
|
}}
|
||||||
|
onDelete={() => {
|
||||||
|
OPENAI_API_BASE_URLS = OPENAI_API_BASE_URLS.filter(
|
||||||
|
(url, urlIdx) => idx !== urlIdx
|
||||||
|
);
|
||||||
|
OPENAI_API_KEYS = OPENAI_API_KEYS.filter((key, keyIdx) => idx !== keyIdx);
|
||||||
|
|
||||||
|
let newConfig = {};
|
||||||
|
OPENAI_API_BASE_URLS.forEach((url, newIdx) => {
|
||||||
|
newConfig[newIdx] =
|
||||||
|
OPENAI_API_CONFIGS[newIdx < idx ? newIdx : newIdx + 1];
|
||||||
|
});
|
||||||
|
OPENAI_API_CONFIGS = newConfig;
|
||||||
|
updateOpenAIHandler();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class=" my-2">
|
||||||
|
<div class="flex justify-between items-center text-sm mb-2">
|
||||||
|
<div class=" font-medium">{$i18n.t('Ollama API')}</div>
|
||||||
|
|
||||||
|
<div class="mt-1">
|
||||||
|
<Switch
|
||||||
|
bind:state={ENABLE_OLLAMA_API}
|
||||||
|
on:change={async () => {
|
||||||
|
updateOllamaHandler();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if ENABLE_OPENAI_API}
|
{#if ENABLE_OLLAMA_API}
|
||||||
<hr class=" border-gray-100 dark:border-gray-850" />
|
|
||||||
|
|
||||||
<div class="">
|
<div class="">
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
<div class="font-medium">{$i18n.t('Manage OpenAI API Connections')}</div>
|
<div class="font-medium text-xs">{$i18n.t('Manage Ollama API Connections')}</div>
|
||||||
|
|
||||||
<Tooltip content={$i18n.t(`Add Connection`)}>
|
<Tooltip content={$i18n.t(`Add Connection`)}>
|
||||||
<button
|
<button
|
||||||
class="px-1"
|
class="px-1"
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
showAddOpenAIConnectionModal = true;
|
showAddOllamaConnectionModal = true;
|
||||||
}}
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
|
|
@ -253,133 +320,89 @@
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-1.5 mt-1.5">
|
<div class="flex w-full gap-1.5">
|
||||||
{#each OPENAI_API_BASE_URLS as url, idx}
|
<div class="flex-1 flex flex-col gap-1.5 mt-1.5">
|
||||||
<OpenAIConnection
|
{#each OLLAMA_BASE_URLS as url, idx}
|
||||||
pipeline={pipelineUrls[url] ? true : false}
|
<OllamaConnection
|
||||||
bind:url
|
bind:url
|
||||||
bind:key={OPENAI_API_KEYS[idx]}
|
bind:config={OLLAMA_API_CONFIGS[idx]}
|
||||||
bind:config={OPENAI_API_CONFIGS[idx]}
|
{idx}
|
||||||
onSubmit={() => {
|
onSubmit={() => {
|
||||||
updateOpenAIHandler();
|
updateOllamaHandler();
|
||||||
}}
|
}}
|
||||||
onDelete={() => {
|
onDelete={() => {
|
||||||
OPENAI_API_BASE_URLS = OPENAI_API_BASE_URLS.filter(
|
OLLAMA_BASE_URLS = OLLAMA_BASE_URLS.filter((url, urlIdx) => idx !== urlIdx);
|
||||||
(url, urlIdx) => idx !== urlIdx
|
|
||||||
);
|
|
||||||
OPENAI_API_KEYS = OPENAI_API_KEYS.filter((key, keyIdx) => idx !== keyIdx);
|
|
||||||
|
|
||||||
let newConfig = {};
|
let newConfig = {};
|
||||||
OPENAI_API_BASE_URLS.forEach((url, newIdx) => {
|
OLLAMA_BASE_URLS.forEach((url, newIdx) => {
|
||||||
newConfig[newIdx] = OPENAI_API_CONFIGS[newIdx < idx ? newIdx : newIdx + 1];
|
newConfig[newIdx] =
|
||||||
});
|
OLLAMA_API_CONFIGS[newIdx < idx ? newIdx : newIdx + 1];
|
||||||
OPENAI_API_CONFIGS = newConfig;
|
});
|
||||||
updateOpenAIHandler();
|
OLLAMA_API_CONFIGS = newConfig;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
{$i18n.t('Trouble accessing Ollama?')}
|
||||||
|
<a
|
||||||
|
class=" text-gray-300 font-medium underline"
|
||||||
|
href="https://github.com/open-webui/open-webui#troubleshooting"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{$i18n.t('Click here for help.')}
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr class=" border-gray-100 dark:border-gray-850" />
|
<div class="my-2">
|
||||||
|
<div class="flex justify-between items-center text-sm">
|
||||||
|
<div class=" font-medium">{$i18n.t('Direct Connections')}</div>
|
||||||
|
|
||||||
<div class="pr-1.5 my-2">
|
<div class="flex items-center">
|
||||||
<div class="flex justify-between items-center text-sm mb-2">
|
<div class="">
|
||||||
<div class=" font-medium">{$i18n.t('Ollama API')}</div>
|
<Switch
|
||||||
|
bind:state={connectionsConfig.ENABLE_DIRECT_CONNECTIONS}
|
||||||
<div class="mt-1">
|
on:change={async () => {
|
||||||
<Switch
|
updateConnectionsHandler();
|
||||||
bind:state={ENABLE_OLLAMA_API}
|
|
||||||
on:change={async () => {
|
|
||||||
updateOllamaHandler();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if ENABLE_OLLAMA_API}
|
|
||||||
<hr class=" border-gray-100 dark:border-gray-850 my-2" />
|
|
||||||
|
|
||||||
<div class="">
|
|
||||||
<div class="flex justify-between items-center">
|
|
||||||
<div class="font-medium">{$i18n.t('Manage Ollama API Connections')}</div>
|
|
||||||
|
|
||||||
<Tooltip content={$i18n.t(`Add Connection`)}>
|
|
||||||
<button
|
|
||||||
class="px-1"
|
|
||||||
on:click={() => {
|
|
||||||
showAddOllamaConnectionModal = true;
|
|
||||||
}}
|
}}
|
||||||
type="button"
|
/>
|
||||||
>
|
|
||||||
<Plus />
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex w-full gap-1.5">
|
|
||||||
<div class="flex-1 flex flex-col gap-1.5 mt-1.5">
|
|
||||||
{#each OLLAMA_BASE_URLS as url, idx}
|
|
||||||
<OllamaConnection
|
|
||||||
bind:url
|
|
||||||
bind:config={OLLAMA_API_CONFIGS[idx]}
|
|
||||||
{idx}
|
|
||||||
onSubmit={() => {
|
|
||||||
updateOllamaHandler();
|
|
||||||
}}
|
|
||||||
onDelete={() => {
|
|
||||||
OLLAMA_BASE_URLS = OLLAMA_BASE_URLS.filter((url, urlIdx) => idx !== urlIdx);
|
|
||||||
|
|
||||||
let newConfig = {};
|
|
||||||
OLLAMA_BASE_URLS.forEach((url, newIdx) => {
|
|
||||||
newConfig[newIdx] = OLLAMA_API_CONFIGS[newIdx < idx ? newIdx : newIdx + 1];
|
|
||||||
});
|
|
||||||
OLLAMA_API_CONFIGS = newConfig;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
|
||||||
{$i18n.t('Trouble accessing Ollama?')}
|
|
||||||
<a
|
|
||||||
class=" text-gray-300 font-medium underline"
|
|
||||||
href="https://github.com/open-webui/open-webui#troubleshooting"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
{$i18n.t('Click here for help.')}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr class=" border-gray-100 dark:border-gray-850" />
|
<div class="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
{$i18n.t(
|
||||||
<div class="pr-1.5 my-2">
|
'Direct Connections allow users to connect to their own OpenAI compatible API endpoints.'
|
||||||
<div class="flex justify-between items-center text-sm">
|
)}
|
||||||
<div class=" font-medium">{$i18n.t('Direct Connections')}</div>
|
|
||||||
|
|
||||||
<div class="flex items-center">
|
|
||||||
<div class="">
|
|
||||||
<Switch
|
|
||||||
bind:state={directConnectionsConfig.ENABLE_DIRECT_CONNECTIONS}
|
|
||||||
on:change={async () => {
|
|
||||||
updateDirectConnectionsHandler();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-1.5">
|
<hr class=" border-gray-100 dark:border-gray-850 my-2" />
|
||||||
<div class="text-xs text-gray-500">
|
|
||||||
|
<div class="my-2">
|
||||||
|
<div class="flex justify-between items-center text-sm">
|
||||||
|
<div class=" text-xs font-medium">{$i18n.t('Cache Base Model List')}</div>
|
||||||
|
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="">
|
||||||
|
<Switch
|
||||||
|
bind:state={connectionsConfig.ENABLE_BASE_MODELS_CACHE}
|
||||||
|
on:change={async () => {
|
||||||
|
updateConnectionsHandler();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||||
{$i18n.t(
|
{$i18n.t(
|
||||||
'Direct Connections allow users to connect to their own OpenAI compatible API endpoints.'
|
'Base Model List Cache speeds up access by fetching base models only at startup or on settings save—faster, but may not show recent base model changes.'
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
|
|
||||||
import Modal from '$lib/components/common/Modal.svelte';
|
import Modal from '$lib/components/common/Modal.svelte';
|
||||||
import ManageOllama from '../Models/Manage/ManageOllama.svelte';
|
import ManageOllama from '../Models/Manage/ManageOllama.svelte';
|
||||||
|
import XMark from '$lib/components/icons/XMark.svelte';
|
||||||
|
|
||||||
export let show = false;
|
export let show = false;
|
||||||
export let urlIdx: number | null = null;
|
export let urlIdx: number | null = null;
|
||||||
|
|
@ -26,16 +27,7 @@
|
||||||
show = false;
|
show = false;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg
|
<XMark className={'size-5'} />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="currentColor"
|
|
||||||
class="w-5 h-5"
|
|
||||||
>
|
|
||||||
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -90,10 +90,6 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (embeddingEngine === 'openai' && (OpenAIKey === '' || OpenAIUrl === '')) {
|
|
||||||
toast.error($i18n.t('OpenAI URL/Key required.'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
if (
|
||||||
embeddingEngine === 'azure_openai' &&
|
embeddingEngine === 'azure_openai' &&
|
||||||
(AzureOpenAIKey === '' || AzureOpenAIUrl === '' || AzureOpenAIVersion === '')
|
(AzureOpenAIKey === '' || AzureOpenAIUrl === '' || AzureOpenAIVersion === '')
|
||||||
|
|
@ -643,6 +639,7 @@
|
||||||
>
|
>
|
||||||
<option value="">{$i18n.t('Default')} ({$i18n.t('Character')})</option>
|
<option value="">{$i18n.t('Default')} ({$i18n.t('Character')})</option>
|
||||||
<option value="token">{$i18n.t('Token')} ({$i18n.t('Tiktoken')})</option>
|
<option value="token">{$i18n.t('Token')} ({$i18n.t('Tiktoken')})</option>
|
||||||
|
<option value="markdown_header">{$i18n.t('Markdown (Header)')}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -731,7 +728,11 @@
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SensitiveInput placeholder={$i18n.t('API Key')} bind:value={OpenAIKey} />
|
<SensitiveInput
|
||||||
|
placeholder={$i18n.t('API Key')}
|
||||||
|
bind:value={OpenAIKey}
|
||||||
|
required={false}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{:else if embeddingEngine === 'ollama'}
|
{:else if embeddingEngine === 'ollama'}
|
||||||
<div class="my-0.5 flex gap-2 pr-2">
|
<div class="my-0.5 flex gap-2 pr-2">
|
||||||
|
|
@ -808,33 +809,7 @@
|
||||||
>
|
>
|
||||||
{#if updateEmbeddingModelLoading}
|
{#if updateEmbeddingModelLoading}
|
||||||
<div class="self-center">
|
<div class="self-center">
|
||||||
<svg
|
<Spinner />
|
||||||
class=" w-4 h-4"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="currentColor"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<style>
|
|
||||||
.spinner_ajPY {
|
|
||||||
transform-origin: center;
|
|
||||||
animation: spinner_AtaB 0.75s infinite linear;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spinner_AtaB {
|
|
||||||
100% {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<path
|
|
||||||
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
|
|
||||||
opacity=".25"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
|
|
||||||
class="spinner_ajPY"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<svg
|
<svg
|
||||||
|
|
@ -1272,7 +1247,7 @@
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex items-center justify-center h-full">
|
<div class="flex items-center justify-center h-full">
|
||||||
<Spinner />
|
<Spinner className="size-5" />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
const i18n = getContext('i18n');
|
const i18n = getContext('i18n');
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||||
import Modal from '$lib/components/common/Modal.svelte';
|
import Modal from '$lib/components/common/Modal.svelte';
|
||||||
import { models } from '$lib/stores';
|
import { models } from '$lib/stores';
|
||||||
import Plus from '$lib/components/icons/Plus.svelte';
|
import Plus from '$lib/components/icons/Plus.svelte';
|
||||||
|
|
@ -11,6 +12,8 @@
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import AccessControl from '$lib/components/workspace/common/AccessControl.svelte';
|
import AccessControl from '$lib/components/workspace/common/AccessControl.svelte';
|
||||||
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
||||||
|
import XMark from '$lib/components/icons/XMark.svelte';
|
||||||
|
import { WEBUI_BASE_URL } from '$lib/constants';
|
||||||
|
|
||||||
export let show = false;
|
export let show = false;
|
||||||
export let edit = false;
|
export let edit = false;
|
||||||
|
|
@ -34,7 +37,7 @@
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let profileImageUrl = '/favicon.png';
|
let profileImageUrl = `${WEBUI_BASE_URL}/favicon.png`;
|
||||||
let description = '';
|
let description = '';
|
||||||
|
|
||||||
let selectedModelId = '';
|
let selectedModelId = '';
|
||||||
|
|
@ -90,7 +93,7 @@
|
||||||
|
|
||||||
name = '';
|
name = '';
|
||||||
id = '';
|
id = '';
|
||||||
profileImageUrl = '/favicon.png';
|
profileImageUrl = `${WEBUI_BASE_URL}/favicon.png`;
|
||||||
description = '';
|
description = '';
|
||||||
modelIds = [];
|
modelIds = [];
|
||||||
selectedModelId = '';
|
selectedModelId = '';
|
||||||
|
|
@ -141,16 +144,7 @@
|
||||||
show = false;
|
show = false;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg
|
<XMark className={'size-5'} />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="currentColor"
|
|
||||||
class="w-5 h-5"
|
|
||||||
>
|
|
||||||
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -406,29 +400,7 @@
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="ml-2 self-center">
|
<div class="ml-2 self-center">
|
||||||
<svg
|
<Spinner />
|
||||||
class=" w-4 h-4"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="currentColor"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
><style>
|
|
||||||
.spinner_ajPY {
|
|
||||||
transform-origin: center;
|
|
||||||
animation: spinner_AtaB 0.75s infinite linear;
|
|
||||||
}
|
|
||||||
@keyframes spinner_AtaB {
|
|
||||||
100% {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style><path
|
|
||||||
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
|
|
||||||
opacity=".25"
|
|
||||||
/><path
|
|
||||||
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
|
|
||||||
class="spinner_ajPY"
|
|
||||||
/></svg
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,9 @@
|
||||||
};
|
};
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
checkForVersionUpdates();
|
if ($config?.features?.enable_version_update_check) {
|
||||||
|
checkForVersionUpdates();
|
||||||
|
}
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
(async () => {
|
(async () => {
|
||||||
|
|
@ -137,16 +139,18 @@
|
||||||
v{WEBUI_VERSION}
|
v{WEBUI_VERSION}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<a
|
{#if $config?.features?.enable_version_update_check}
|
||||||
href="https://github.com/open-webui/open-webui/releases/tag/v{version.latest}"
|
<a
|
||||||
target="_blank"
|
href="https://github.com/open-webui/open-webui/releases/tag/v{version.latest}"
|
||||||
>
|
target="_blank"
|
||||||
{updateAvailable === null
|
>
|
||||||
? $i18n.t('Checking for updates...')
|
{updateAvailable === null
|
||||||
: updateAvailable
|
? $i18n.t('Checking for updates...')
|
||||||
? `(v${version.latest} ${$i18n.t('available!')})`
|
: updateAvailable
|
||||||
: $i18n.t('(latest)')}
|
? `(v${version.latest} ${$i18n.t('available!')})`
|
||||||
</a>
|
: $i18n.t('(latest)')}
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|
@ -160,15 +164,17 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
{#if $config?.features?.enable_version_update_check}
|
||||||
class=" text-xs px-3 py-1.5 bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 transition rounded-lg font-medium"
|
<button
|
||||||
type="button"
|
class=" text-xs px-3 py-1.5 bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 transition rounded-lg font-medium"
|
||||||
on:click={() => {
|
type="button"
|
||||||
checkForVersionUpdates();
|
on:click={() => {
|
||||||
}}
|
checkForVersionUpdates();
|
||||||
>
|
}}
|
||||||
{$i18n.t('Check for updates')}
|
>
|
||||||
</button>
|
{$i18n.t('Check for updates')}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,11 @@
|
||||||
updateConfig,
|
updateConfig,
|
||||||
verifyConfigUrl
|
verifyConfigUrl
|
||||||
} from '$lib/apis/images';
|
} from '$lib/apis/images';
|
||||||
|
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||||
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
|
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
|
||||||
import Switch from '$lib/components/common/Switch.svelte';
|
import Switch from '$lib/components/common/Switch.svelte';
|
||||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||||
|
import Textarea from '$lib/components/common/Textarea.svelte';
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
const i18n = getContext('i18n');
|
const i18n = getContext('i18n');
|
||||||
|
|
@ -504,7 +506,7 @@
|
||||||
<div class=" mb-2 text-sm font-medium">{$i18n.t('ComfyUI Workflow')}</div>
|
<div class=" mb-2 text-sm font-medium">{$i18n.t('ComfyUI Workflow')}</div>
|
||||||
|
|
||||||
{#if config.comfyui.COMFYUI_WORKFLOW}
|
{#if config.comfyui.COMFYUI_WORKFLOW}
|
||||||
<textarea
|
<Textarea
|
||||||
class="w-full rounded-lg mb-1 py-2 px-4 text-xs bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden disabled:text-gray-600 resize-none"
|
class="w-full rounded-lg mb-1 py-2 px-4 text-xs bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden disabled:text-gray-600 resize-none"
|
||||||
rows="10"
|
rows="10"
|
||||||
bind:value={config.comfyui.COMFYUI_WORKFLOW}
|
bind:value={config.comfyui.COMFYUI_WORKFLOW}
|
||||||
|
|
@ -533,7 +535,7 @@
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="w-full text-sm font-medium py-2 bg-transparent hover:bg-gray-100 border border-dashed dark:border-gray-850 dark:hover:bg-gray-850 text-center rounded-xl"
|
class="w-full text-sm font-medium py-2 bg-transparent hover:bg-gray-50 border border-dashed border-gray-50 dark:border-gray-850 dark:hover:bg-gray-850 text-center rounded-xl"
|
||||||
type="button"
|
type="button"
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
document.getElementById('upload-comfyui-workflow-input')?.click();
|
document.getElementById('upload-comfyui-workflow-input')?.click();
|
||||||
|
|
@ -555,10 +557,10 @@
|
||||||
|
|
||||||
<div class="text-xs flex flex-col gap-1.5">
|
<div class="text-xs flex flex-col gap-1.5">
|
||||||
{#each requiredWorkflowNodes as node}
|
{#each requiredWorkflowNodes as node}
|
||||||
<div class="flex w-full items-center border dark:border-gray-850 rounded-lg">
|
<div class="flex w-full items-center">
|
||||||
<div class="shrink-0">
|
<div class="shrink-0">
|
||||||
<div
|
<div
|
||||||
class=" capitalize line-clamp-1 font-medium px-3 py-1 w-20 text-center rounded-l-lg bg-green-500/10 text-green-700 dark:text-green-200"
|
class=" capitalize line-clamp-1 font-medium px-3 py-1 w-20 text-center bg-green-500/10 text-green-700 dark:text-green-200"
|
||||||
>
|
>
|
||||||
{node.type}{node.type === 'prompt' ? '*' : ''}
|
{node.type}{node.type === 'prompt' ? '*' : ''}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -566,7 +568,7 @@
|
||||||
<div class="">
|
<div class="">
|
||||||
<Tooltip content="Input Key (e.g. text, unet_name, steps)">
|
<Tooltip content="Input Key (e.g. text, unet_name, steps)">
|
||||||
<input
|
<input
|
||||||
class="py-1 px-3 w-24 text-xs text-center bg-transparent outline-hidden border-r dark:border-gray-850"
|
class="py-1 px-3 w-24 text-xs text-center bg-transparent outline-hidden border-r border-gray-50 dark:border-gray-850"
|
||||||
placeholder="Key"
|
placeholder="Key"
|
||||||
bind:value={node.key}
|
bind:value={node.key}
|
||||||
required
|
required
|
||||||
|
|
@ -580,7 +582,7 @@
|
||||||
placement="top-start"
|
placement="top-start"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
class="w-full py-1 px-4 rounded-r-lg text-xs bg-transparent outline-hidden"
|
class="w-full py-1 px-4 text-xs bg-transparent outline-hidden"
|
||||||
placeholder="Node Ids"
|
placeholder="Node Ids"
|
||||||
bind:value={node.node_ids}
|
bind:value={node.node_ids}
|
||||||
/>
|
/>
|
||||||
|
|
@ -711,29 +713,7 @@
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="ml-2 self-center">
|
<div class="ml-2 self-center">
|
||||||
<svg
|
<Spinner />
|
||||||
class=" w-4 h-4"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="currentColor"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
><style>
|
|
||||||
.spinner_ajPY {
|
|
||||||
transform-origin: center;
|
|
||||||
animation: spinner_AtaB 0.75s infinite linear;
|
|
||||||
}
|
|
||||||
@keyframes spinner_AtaB {
|
|
||||||
100% {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style><path
|
|
||||||
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
|
|
||||||
opacity=".25"
|
|
||||||
/><path
|
|
||||||
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
|
|
||||||
class="spinner_ajPY"
|
|
||||||
/></svg
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -390,7 +390,7 @@
|
||||||
|
|
||||||
<div class="mb-2.5">
|
<div class="mb-2.5">
|
||||||
<div class="flex w-full justify-between">
|
<div class="flex w-full justify-between">
|
||||||
<div class=" self-center text-sm">
|
<div class=" self-center text-xs">
|
||||||
{$i18n.t('Banners')}
|
{$i18n.t('Banners')}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -432,7 +432,7 @@
|
||||||
{#if $user?.role === 'admin'}
|
{#if $user?.role === 'admin'}
|
||||||
<div class=" space-y-3">
|
<div class=" space-y-3">
|
||||||
<div class="flex w-full justify-between mb-2">
|
<div class="flex w-full justify-between mb-2">
|
||||||
<div class=" self-center text-sm">
|
<div class=" self-center text-xs">
|
||||||
{$i18n.t('Default Prompt Suggestions')}
|
{$i18n.t('Default Prompt Suggestions')}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -636,6 +636,6 @@
|
||||||
</form>
|
</form>
|
||||||
{:else}
|
{:else}
|
||||||
<div class=" h-full w-full flex justify-center items-center">
|
<div class=" h-full w-full flex justify-center items-center">
|
||||||
<Spinner />
|
<Spinner className="size-5" />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Switch from '$lib/components/common/Switch.svelte';
|
import Switch from '$lib/components/common/Switch.svelte';
|
||||||
|
import Textarea from '$lib/components/common/Textarea.svelte';
|
||||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||||
import EllipsisVertical from '$lib/components/icons/EllipsisVertical.svelte';
|
import EllipsisVertical from '$lib/components/icons/EllipsisVertical.svelte';
|
||||||
|
import XMark from '$lib/components/icons/XMark.svelte';
|
||||||
import Sortable from 'sortablejs';
|
import Sortable from 'sortablejs';
|
||||||
import { getContext } from 'svelte';
|
import { getContext } from 'svelte';
|
||||||
const i18n = getContext('i18n');
|
const i18n = getContext('i18n');
|
||||||
|
|
@ -23,6 +25,13 @@
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const classNames: Record<string, string> = {
|
||||||
|
info: 'bg-blue-500/20 text-blue-700 dark:text-blue-200 ',
|
||||||
|
success: 'bg-green-500/20 text-green-700 dark:text-green-200',
|
||||||
|
warning: 'bg-yellow-500/20 text-yellow-700 dark:text-yellow-200',
|
||||||
|
error: 'bg-red-500/20 text-red-700 dark:text-red-200'
|
||||||
|
};
|
||||||
|
|
||||||
$: if (banners) {
|
$: if (banners) {
|
||||||
init();
|
init();
|
||||||
}
|
}
|
||||||
|
|
@ -44,14 +53,14 @@
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class=" flex flex-col space-y-0.5" bind:this={bannerListElement}>
|
<div class=" flex flex-col gap-3 {banners?.length > 0 ? 'mt-2' : ''}" bind:this={bannerListElement}>
|
||||||
{#each banners as banner, bannerIdx (banner.id)}
|
{#each banners as banner, bannerIdx (banner.id)}
|
||||||
<div class=" flex justify-between items-center -ml-1" id="banner-item-{banner.id}">
|
<div class=" flex justify-between items-start -ml-1" id="banner-item-{banner.id}">
|
||||||
<EllipsisVertical className="size-4 cursor-move item-handle" />
|
<EllipsisVertical className="size-4 cursor-move item-handle" />
|
||||||
|
|
||||||
<div class="flex flex-row flex-1 gap-2 items-center">
|
<div class="flex flex-row flex-1 gap-2 items-start">
|
||||||
<select
|
<select
|
||||||
class="w-fit capitalize rounded-xl text-xs bg-transparent outline-hidden text-left pl-1 pr-2"
|
class="w-fit capitalize rounded-xl text-xs bg-transparent outline-hidden pl-1 pr-5"
|
||||||
bind:value={banner.type}
|
bind:value={banner.type}
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
|
|
@ -64,14 +73,15 @@
|
||||||
<option value="success" class="text-gray-900">{$i18n.t('Success')}</option>
|
<option value="success" class="text-gray-900">{$i18n.t('Success')}</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<input
|
<Textarea
|
||||||
class="pr-5 py-1.5 text-xs w-full bg-transparent outline-hidden"
|
className="mr-2 text-xs w-full bg-transparent outline-hidden resize-none"
|
||||||
placeholder={$i18n.t('Content')}
|
placeholder={$i18n.t('Content')}
|
||||||
bind:value={banner.content}
|
bind:value={banner.content}
|
||||||
|
maxSize={100}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="relative -left-2">
|
<div class="relative -left-2">
|
||||||
<Tooltip content={$i18n.t('Dismissible')} className="flex h-fit items-center">
|
<Tooltip content={$i18n.t('Remember Dismissal')} className="flex h-fit items-center">
|
||||||
<Switch bind:state={banner.dismissible} />
|
<Switch bind:state={banner.dismissible} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -85,16 +95,7 @@
|
||||||
banners = banners;
|
banners = banners;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg
|
<XMark className={'size-4'} />
|
||||||
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@
|
||||||
toggleModelById,
|
toggleModelById,
|
||||||
updateModelById
|
updateModelById
|
||||||
} from '$lib/apis/models';
|
} from '$lib/apis/models';
|
||||||
|
import { copyToClipboard } from '$lib/utils';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
|
||||||
import { getModels } from '$lib/apis';
|
import { getModels } from '$lib/apis';
|
||||||
import Search from '$lib/components/icons/Search.svelte';
|
import Search from '$lib/components/icons/Search.svelte';
|
||||||
|
|
@ -34,7 +36,7 @@
|
||||||
import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte';
|
import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte';
|
||||||
import EyeSlash from '$lib/components/icons/EyeSlash.svelte';
|
import EyeSlash from '$lib/components/icons/EyeSlash.svelte';
|
||||||
import Eye from '$lib/components/icons/Eye.svelte';
|
import Eye from '$lib/components/icons/Eye.svelte';
|
||||||
import { copyToClipboard } from '$lib/utils';
|
import { WEBUI_BASE_URL } from '$lib/constants';
|
||||||
|
|
||||||
let shiftKey = false;
|
let shiftKey = false;
|
||||||
|
|
||||||
|
|
@ -205,6 +207,11 @@
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
await init();
|
await init();
|
||||||
|
const id = $page.url.searchParams.get('id');
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
selectedModelId = id;
|
||||||
|
}
|
||||||
|
|
||||||
const onKeyDown = (event) => {
|
const onKeyDown = (event) => {
|
||||||
if (event.key === 'Shift') {
|
if (event.key === 'Shift') {
|
||||||
|
|
@ -326,7 +333,7 @@
|
||||||
: 'opacity-50 dark:opacity-50'} "
|
: 'opacity-50 dark:opacity-50'} "
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={model?.meta?.profile_image_url ?? '/static/favicon.png'}
|
src={model?.meta?.profile_image_url ?? `${WEBUI_BASE_URL}/static/favicon.png`}
|
||||||
alt="modelfile profile"
|
alt="modelfile profile"
|
||||||
class=" rounded-full w-full h-auto object-cover"
|
class=" rounded-full w-full h-auto object-cover"
|
||||||
/>
|
/>
|
||||||
|
|
@ -563,6 +570,6 @@
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<div class=" h-full w-full flex justify-center items-center">
|
<div class=" h-full w-full flex justify-center items-center">
|
||||||
<Spinner />
|
<Spinner className="size-5" />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
import Plus from '$lib/components/icons/Plus.svelte';
|
import Plus from '$lib/components/icons/Plus.svelte';
|
||||||
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
|
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
|
||||||
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
|
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
|
||||||
|
import XMark from '$lib/components/icons/XMark.svelte';
|
||||||
|
|
||||||
export let show = false;
|
export let show = false;
|
||||||
export let initHandler = () => {};
|
export let initHandler = () => {};
|
||||||
|
|
@ -129,16 +130,7 @@
|
||||||
show = false;
|
show = false;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg
|
<XMark className={'size-5'} />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="currentColor"
|
|
||||||
class="w-5 h-5"
|
|
||||||
>
|
|
||||||
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -278,29 +270,7 @@
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="ml-2 self-center">
|
<div class="ml-2 self-center">
|
||||||
<svg
|
<Spinner />
|
||||||
class=" w-4 h-4"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="currentColor"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
><style>
|
|
||||||
.spinner_ajPY {
|
|
||||||
transform-origin: center;
|
|
||||||
animation: spinner_AtaB 0.75s infinite linear;
|
|
||||||
}
|
|
||||||
@keyframes spinner_AtaB {
|
|
||||||
100% {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style><path
|
|
||||||
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
|
|
||||||
opacity=".25"
|
|
||||||
/><path
|
|
||||||
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
|
|
||||||
class="spinner_ajPY"
|
|
||||||
/></svg
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -308,7 +278,7 @@
|
||||||
</form>
|
</form>
|
||||||
{:else}
|
{:else}
|
||||||
<div>
|
<div>
|
||||||
<Spinner />
|
<Spinner className="size-5" />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1057,6 +1057,6 @@
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex justify-center items-center w-full h-full py-3">
|
<div class="flex justify-center items-center w-full h-full py-3">
|
||||||
<Spinner />
|
<Spinner className="size-5" />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
|
|
||||||
import { user } from '$lib/stores';
|
import { user } from '$lib/stores';
|
||||||
|
|
||||||
|
import XMark from '$lib/components/icons/XMark.svelte';
|
||||||
import Modal from '$lib/components/common/Modal.svelte';
|
import Modal from '$lib/components/common/Modal.svelte';
|
||||||
import ManageOllama from './Manage/ManageOllama.svelte';
|
import ManageOllama from './Manage/ManageOllama.svelte';
|
||||||
import { getOllamaConfig } from '$lib/apis/ollama';
|
import { getOllamaConfig } from '$lib/apis/ollama';
|
||||||
|
|
@ -48,16 +49,7 @@
|
||||||
show = false;
|
show = false;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg
|
<XMark className={'size-5'} />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="currentColor"
|
|
||||||
class="w-5 h-5"
|
|
||||||
>
|
|
||||||
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -75,7 +67,7 @@
|
||||||
class="flex gap-1 scrollbar-none overflow-x-auto w-fit text-center text-sm font-medium rounded-full bg-transparent dark:text-gray-200"
|
class="flex gap-1 scrollbar-none overflow-x-auto w-fit text-center text-sm font-medium rounded-full bg-transparent dark:text-gray-200"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
class="min-w-fit rounded-full p-1.5 {selected === 'ollama'
|
class="min-w-fit p-1.5 {selected === 'ollama'
|
||||||
? ''
|
? ''
|
||||||
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
|
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
|
|
@ -84,7 +76,7 @@
|
||||||
>
|
>
|
||||||
|
|
||||||
<!-- <button
|
<!-- <button
|
||||||
class="min-w-fit rounded-full p-1.5 {selected === 'llamacpp'
|
class="min-w-fit p-1.5 {selected === 'llamacpp'
|
||||||
? ''
|
? ''
|
||||||
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
|
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
|
|
|
||||||
|
|
@ -141,6 +141,7 @@
|
||||||
placeholder={$i18n.t('Enter Searxng Query URL')}
|
placeholder={$i18n.t('Enter Searxng Query URL')}
|
||||||
bind:value={webConfig.SEARXNG_QUERY_URL}
|
bind:value={webConfig.SEARXNG_QUERY_URL}
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -248,7 +249,6 @@
|
||||||
bind:value={webConfig.KAGI_SEARCH_API_KEY}
|
bind:value={webConfig.KAGI_SEARCH_API_KEY}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
.
|
|
||||||
</div>
|
</div>
|
||||||
{:else if webConfig.WEB_SEARCH_ENGINE === 'mojeek'}
|
{:else if webConfig.WEB_SEARCH_ENGINE === 'mojeek'}
|
||||||
<div class="mb-2.5 flex w-full flex-col">
|
<div class="mb-2.5 flex w-full flex-col">
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
import UsersSolid from '$lib/components/icons/UsersSolid.svelte';
|
import UsersSolid from '$lib/components/icons/UsersSolid.svelte';
|
||||||
import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
|
import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
|
||||||
import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte';
|
import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte';
|
||||||
|
import Search from '$lib/components/icons/Search.svelte';
|
||||||
import User from '$lib/components/icons/User.svelte';
|
import User from '$lib/components/icons/User.svelte';
|
||||||
import UserCircleSolid from '$lib/components/icons/UserCircleSolid.svelte';
|
import UserCircleSolid from '$lib/components/icons/UserCircleSolid.svelte';
|
||||||
import GroupModal from './Groups/EditGroupModal.svelte';
|
import GroupModal from './Groups/EditGroupModal.svelte';
|
||||||
|
|
@ -159,18 +160,7 @@
|
||||||
<div class=" flex w-full space-x-2">
|
<div class=" flex w-full space-x-2">
|
||||||
<div class="flex flex-1">
|
<div class="flex flex-1">
|
||||||
<div class=" self-center ml-1 mr-3">
|
<div class=" self-center ml-1 mr-3">
|
||||||
<svg
|
<Search />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="currentColor"
|
|
||||||
class="w-4 h-4"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fill-rule="evenodd"
|
|
||||||
d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z"
|
|
||||||
clip-rule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
|
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
|
||||||
|
|
@ -221,11 +211,9 @@
|
||||||
{:else}
|
{:else}
|
||||||
<div>
|
<div>
|
||||||
<div class=" flex items-center gap-3 justify-between text-xs uppercase px-1 font-bold">
|
<div class=" flex items-center gap-3 justify-between text-xs uppercase px-1 font-bold">
|
||||||
<div class="w-full">Group</div>
|
<div class="w-full basis-3/5">Group</div>
|
||||||
|
|
||||||
<div class="w-full">Users</div>
|
<div class="w-full basis-2/5 text-right">Users</div>
|
||||||
|
|
||||||
<div class="w-full"></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr class="mt-1.5 border-gray-100 dark:border-gray-850" />
|
<hr class="mt-1.5 border-gray-100 dark:border-gray-850" />
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,10 @@
|
||||||
import { getContext, onMount } from 'svelte';
|
import { getContext, onMount } from 'svelte';
|
||||||
const i18n = getContext('i18n');
|
const i18n = getContext('i18n');
|
||||||
|
|
||||||
|
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||||
import Modal from '$lib/components/common/Modal.svelte';
|
import Modal from '$lib/components/common/Modal.svelte';
|
||||||
import Textarea from '$lib/components/common/Textarea.svelte';
|
import Textarea from '$lib/components/common/Textarea.svelte';
|
||||||
|
import XMark from '$lib/components/icons/XMark.svelte';
|
||||||
export let onSubmit: Function = () => {};
|
export let onSubmit: Function = () => {};
|
||||||
export let show = false;
|
export let show = false;
|
||||||
|
|
||||||
|
|
@ -45,16 +47,7 @@
|
||||||
show = false;
|
show = false;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg
|
<XMark className={'size-5'} />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="currentColor"
|
|
||||||
class="w-5 h-5"
|
|
||||||
>
|
|
||||||
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -111,29 +104,7 @@
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="ml-2 self-center">
|
<div class="ml-2 self-center">
|
||||||
<svg
|
<Spinner />
|
||||||
class=" w-4 h-4"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="currentColor"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
><style>
|
|
||||||
.spinner_ajPY {
|
|
||||||
transform-origin: center;
|
|
||||||
animation: spinner_AtaB 0.75s infinite linear;
|
|
||||||
}
|
|
||||||
@keyframes spinner_AtaB {
|
|
||||||
100% {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style><path
|
|
||||||
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
|
|
||||||
opacity=".25"
|
|
||||||
/><path
|
|
||||||
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
|
|
||||||
class="spinner_ajPY"
|
|
||||||
/></svg
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { getContext, onMount } from 'svelte';
|
import { getContext, onMount } from 'svelte';
|
||||||
const i18n = getContext('i18n');
|
const i18n = getContext('i18n');
|
||||||
|
|
||||||
|
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||||
import Modal from '$lib/components/common/Modal.svelte';
|
import Modal from '$lib/components/common/Modal.svelte';
|
||||||
import Display from './Display.svelte';
|
import Display from './Display.svelte';
|
||||||
import Permissions from './Permissions.svelte';
|
import Permissions from './Permissions.svelte';
|
||||||
|
|
@ -10,6 +11,7 @@
|
||||||
import UserPlusSolid from '$lib/components/icons/UserPlusSolid.svelte';
|
import UserPlusSolid from '$lib/components/icons/UserPlusSolid.svelte';
|
||||||
import WrenchSolid from '$lib/components/icons/WrenchSolid.svelte';
|
import WrenchSolid from '$lib/components/icons/WrenchSolid.svelte';
|
||||||
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
||||||
|
import XMark from '$lib/components/icons/XMark.svelte';
|
||||||
|
|
||||||
export let onSubmit: Function = () => {};
|
export let onSubmit: Function = () => {};
|
||||||
export let onDelete: Function = () => {};
|
export let onDelete: Function = () => {};
|
||||||
|
|
@ -124,16 +126,7 @@
|
||||||
show = false;
|
show = false;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg
|
<XMark className={'size-5'} />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="currentColor"
|
|
||||||
class="w-5 h-5"
|
|
||||||
>
|
|
||||||
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -305,29 +298,7 @@
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="ml-2 self-center">
|
<div class="ml-2 self-center">
|
||||||
<svg
|
<Spinner />
|
||||||
class=" w-4 h-4"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="currentColor"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
><style>
|
|
||||||
.spinner_ajPY {
|
|
||||||
transform-origin: center;
|
|
||||||
animation: spinner_AtaB 0.75s infinite linear;
|
|
||||||
}
|
|
||||||
@keyframes spinner_AtaB {
|
|
||||||
100% {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style><path
|
|
||||||
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
|
|
||||||
opacity=".25"
|
|
||||||
/><path
|
|
||||||
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
|
|
||||||
class="spinner_ajPY"
|
|
||||||
/></svg
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -61,22 +61,22 @@
|
||||||
showEdit = true;
|
showEdit = true;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-1.5 w-full font-medium">
|
<div class="flex items-center gap-1.5 w-full font-medium flex-1">
|
||||||
<div>
|
<div>
|
||||||
<UserCircleSolid className="size-4" />
|
<UserCircleSolid className="size-4" />
|
||||||
</div>
|
</div>
|
||||||
{group.name}
|
<div class="line-clamp-1">
|
||||||
|
{group.name}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-1.5 w-full font-medium">
|
<div class="flex items-center gap-1.5 w-fit font-medium text-right justify-end">
|
||||||
{group.user_ids.length}
|
{group.user_ids.length}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<User className="size-3.5" />
|
<User className="size-3.5" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="w-full flex justify-end">
|
|
||||||
<div class=" rounded-lg p-1 hover:bg-gray-100 dark:hover:bg-gray-850 transition">
|
<div class=" rounded-lg p-1 hover:bg-gray-100 dark:hover:bg-gray-850 transition">
|
||||||
<Pencil className="size-3.5" />
|
<Pencil className="size-3.5" />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
import { WEBUI_BASE_URL } from '$lib/constants';
|
import { WEBUI_BASE_URL } from '$lib/constants';
|
||||||
import Checkbox from '$lib/components/common/Checkbox.svelte';
|
import Checkbox from '$lib/components/common/Checkbox.svelte';
|
||||||
import Badge from '$lib/components/common/Badge.svelte';
|
import Badge from '$lib/components/common/Badge.svelte';
|
||||||
|
import Search from '$lib/components/icons/Search.svelte';
|
||||||
|
|
||||||
export let users = [];
|
export let users = [];
|
||||||
export let userIds = [];
|
export let userIds = [];
|
||||||
|
|
@ -15,10 +16,6 @@
|
||||||
|
|
||||||
$: filteredUsers = users
|
$: filteredUsers = users
|
||||||
.filter((user) => {
|
.filter((user) => {
|
||||||
if (user?.role === 'admin') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (query === '') {
|
if (query === '') {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -50,18 +47,7 @@
|
||||||
<div class="flex w-full">
|
<div class="flex w-full">
|
||||||
<div class="flex flex-1">
|
<div class="flex flex-1">
|
||||||
<div class=" self-center mr-3">
|
<div class=" self-center mr-3">
|
||||||
<svg
|
<Search />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="currentColor"
|
|
||||||
class="w-4 h-4"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fill-rule="evenodd"
|
|
||||||
d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z"
|
|
||||||
clip-rule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
class=" w-full text-sm pr-4 rounded-r-xl outline-hidden bg-transparent"
|
class=" w-full text-sm pr-4 rounded-r-xl outline-hidden bg-transparent"
|
||||||
|
|
@ -98,7 +84,7 @@
|
||||||
user.profile_image_url.startsWith('https://www.gravatar.com/avatar/') ||
|
user.profile_image_url.startsWith('https://www.gravatar.com/avatar/') ||
|
||||||
user.profile_image_url.startsWith('data:')
|
user.profile_image_url.startsWith('data:')
|
||||||
? user.profile_image_url
|
? user.profile_image_url
|
||||||
: `/user.png`}
|
: `${WEBUI_BASE_URL}/user.png`}
|
||||||
alt="user"
|
alt="user"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -151,7 +151,7 @@
|
||||||
|
|
||||||
{#if users === null || total === null}
|
{#if users === null || total === null}
|
||||||
<div class="my-10">
|
<div class="my-10">
|
||||||
<Spinner />
|
<Spinner className="size-5" />
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="mt-0.5 mb-2 gap-1 flex flex-col md:flex-row justify-between">
|
<div class="mt-0.5 mb-2 gap-1 flex flex-col md:flex-row justify-between">
|
||||||
|
|
@ -396,7 +396,7 @@
|
||||||
user.profile_image_url.startsWith('https://www.gravatar.com/avatar/') ||
|
user.profile_image_url.startsWith('https://www.gravatar.com/avatar/') ||
|
||||||
user.profile_image_url.startsWith('data:')
|
user.profile_image_url.startsWith('data:')
|
||||||
? user.profile_image_url
|
? user.profile_image_url
|
||||||
: `/user.png`}
|
: `${WEBUI_BASE_URL}/user.png`}
|
||||||
alt="user"
|
alt="user"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,10 @@
|
||||||
|
|
||||||
import { WEBUI_BASE_URL } from '$lib/constants';
|
import { WEBUI_BASE_URL } from '$lib/constants';
|
||||||
|
|
||||||
|
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||||
import Modal from '$lib/components/common/Modal.svelte';
|
import Modal from '$lib/components/common/Modal.svelte';
|
||||||
import { generateInitialsImage } from '$lib/utils';
|
import { generateInitialsImage } from '$lib/utils';
|
||||||
|
import XMark from '$lib/components/icons/XMark.svelte';
|
||||||
|
|
||||||
const i18n = getContext('i18n');
|
const i18n = getContext('i18n');
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
|
|
@ -132,16 +134,7 @@
|
||||||
show = false;
|
show = false;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg
|
<XMark className={'size-5'} />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="currentColor"
|
|
||||||
class="w-5 h-5"
|
|
||||||
>
|
|
||||||
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -157,7 +150,7 @@
|
||||||
class="flex -mt-2 mb-1.5 gap-1 scrollbar-none overflow-x-auto w-fit text-center text-sm font-medium rounded-full bg-transparent dark:text-gray-200"
|
class="flex -mt-2 mb-1.5 gap-1 scrollbar-none overflow-x-auto w-fit text-center text-sm font-medium rounded-full bg-transparent dark:text-gray-200"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
class="min-w-fit rounded-full p-1.5 {tab === ''
|
class="min-w-fit p-1.5 {tab === ''
|
||||||
? ''
|
? ''
|
||||||
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
|
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -167,7 +160,7 @@
|
||||||
>
|
>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="min-w-fit rounded-full p-1.5 {tab === 'import'
|
class="min-w-fit p-1.5 {tab === 'import'
|
||||||
? ''
|
? ''
|
||||||
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
|
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -293,29 +286,7 @@
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="ml-2 self-center">
|
<div class="ml-2 self-center">
|
||||||
<svg
|
<Spinner />
|
||||||
class=" w-4 h-4"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="currentColor"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
><style>
|
|
||||||
.spinner_ajPY {
|
|
||||||
transform-origin: center;
|
|
||||||
animation: spinner_AtaB 0.75s infinite linear;
|
|
||||||
}
|
|
||||||
@keyframes spinner_AtaB {
|
|
||||||
100% {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style><path
|
|
||||||
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
|
|
||||||
opacity=".25"
|
|
||||||
/><path
|
|
||||||
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
|
|
||||||
class="spinner_ajPY"
|
|
||||||
/></svg
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
|
|
||||||
import Modal from '$lib/components/common/Modal.svelte';
|
import Modal from '$lib/components/common/Modal.svelte';
|
||||||
import localizedFormat from 'dayjs/plugin/localizedFormat';
|
import localizedFormat from 'dayjs/plugin/localizedFormat';
|
||||||
|
import XMark from '$lib/components/icons/XMark.svelte';
|
||||||
|
|
||||||
const i18n = getContext('i18n');
|
const i18n = getContext('i18n');
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
|
|
@ -54,16 +55,7 @@
|
||||||
show = false;
|
show = false;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg
|
<XMark className={'size-5'} />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="currentColor"
|
|
||||||
class="w-5 h-5"
|
|
||||||
>
|
|
||||||
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||||
import Plus from '$lib/components/icons/Plus.svelte';
|
import Plus from '$lib/components/icons/Plus.svelte';
|
||||||
|
import { WEBUI_BASE_URL } from '$lib/constants';
|
||||||
|
|
||||||
let selected = '';
|
let selected = '';
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -25,7 +26,7 @@
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src="/static/splash.png"
|
src="{WEBUI_BASE_URL}/static/splash.png"
|
||||||
class="size-11 dark:invert p-0.5"
|
class="size-11 dark:invert p-0.5"
|
||||||
alt="logo"
|
alt="logo"
|
||||||
draggable="false"
|
draggable="false"
|
||||||
|
|
@ -49,7 +50,7 @@
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src="/static/favicon.png"
|
src="{WEBUI_BASE_URL}/static/favicon.png"
|
||||||
class="size-10 {selected === '' ? 'rounded-2xl' : 'rounded-full'}"
|
class="size-10 {selected === '' ? 'rounded-2xl' : 'rounded-full'}"
|
||||||
alt="logo"
|
alt="logo"
|
||||||
draggable="false"
|
draggable="false"
|
||||||
|
|
|
||||||
|
|
@ -246,7 +246,7 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class=" pb-[1rem]">
|
<div class=" pb-[1rem] px-2.5">
|
||||||
<MessageInput
|
<MessageInput
|
||||||
id="root"
|
id="root"
|
||||||
{typingUsers}
|
{typingUsers}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,24 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import heic2any from 'heic2any';
|
||||||
|
|
||||||
import { tick, getContext, onMount, onDestroy } from 'svelte';
|
import { tick, getContext, onMount, onDestroy } from 'svelte';
|
||||||
|
|
||||||
const i18n = getContext('i18n');
|
const i18n = getContext('i18n');
|
||||||
|
|
||||||
import { config, mobile, settings, socket } from '$lib/stores';
|
import { config, mobile, settings, socket, user } from '$lib/stores';
|
||||||
import { blobToFile, compressImage } from '$lib/utils';
|
import {
|
||||||
|
blobToFile,
|
||||||
|
compressImage,
|
||||||
|
extractInputVariables,
|
||||||
|
getCurrentDateTime,
|
||||||
|
getFormattedDate,
|
||||||
|
getFormattedTime,
|
||||||
|
getUserPosition,
|
||||||
|
getUserTimezone,
|
||||||
|
getWeekday
|
||||||
|
} from '$lib/utils';
|
||||||
|
|
||||||
import Tooltip from '../common/Tooltip.svelte';
|
import Tooltip from '../common/Tooltip.svelte';
|
||||||
import RichTextInput from '../common/RichTextInput.svelte';
|
import RichTextInput from '../common/RichTextInput.svelte';
|
||||||
|
|
@ -18,6 +29,8 @@
|
||||||
import FileItem from '../common/FileItem.svelte';
|
import FileItem from '../common/FileItem.svelte';
|
||||||
import Image from '../common/Image.svelte';
|
import Image from '../common/Image.svelte';
|
||||||
import FilesOverlay from '../chat/MessageInput/FilesOverlay.svelte';
|
import FilesOverlay from '../chat/MessageInput/FilesOverlay.svelte';
|
||||||
|
import Commands from '../chat/MessageInput/Commands.svelte';
|
||||||
|
import InputVariablesModal from '../chat/MessageInput/InputVariablesModal.svelte';
|
||||||
|
|
||||||
export let placeholder = $i18n.t('Send a Message');
|
export let placeholder = $i18n.t('Send a Message');
|
||||||
export let transparentBackground = false;
|
export let transparentBackground = false;
|
||||||
|
|
@ -30,16 +43,185 @@
|
||||||
let content = '';
|
let content = '';
|
||||||
let files = [];
|
let files = [];
|
||||||
|
|
||||||
|
export let chatInputElement;
|
||||||
|
|
||||||
|
let commandsElement;
|
||||||
let filesInputElement;
|
let filesInputElement;
|
||||||
let inputFiles;
|
let inputFiles;
|
||||||
|
|
||||||
export let typingUsers = [];
|
export let typingUsers = [];
|
||||||
|
export let inputLoading = false;
|
||||||
|
|
||||||
|
export let onSubmit: Function = (e) => {};
|
||||||
|
export let onChange: Function = (e) => {};
|
||||||
|
export let onStop: Function = (e) => {};
|
||||||
|
|
||||||
export let onSubmit: Function;
|
|
||||||
export let onChange: Function;
|
|
||||||
export let scrollEnd = true;
|
export let scrollEnd = true;
|
||||||
export let scrollToBottom: Function = () => {};
|
export let scrollToBottom: Function = () => {};
|
||||||
|
|
||||||
|
export let acceptFiles = true;
|
||||||
|
export let showFormattingButtons = true;
|
||||||
|
|
||||||
|
let showInputVariablesModal = false;
|
||||||
|
let inputVariables: Record<string, any> = {};
|
||||||
|
let inputVariableValues = {};
|
||||||
|
|
||||||
|
const inputVariableHandler = async (text: string) => {
|
||||||
|
inputVariables = extractInputVariables(text);
|
||||||
|
if (Object.keys(inputVariables).length > 0) {
|
||||||
|
showInputVariablesModal = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const textVariableHandler = async (text: string) => {
|
||||||
|
if (text.includes('{{CLIPBOARD}}')) {
|
||||||
|
const clipboardText = await navigator.clipboard.readText().catch((err) => {
|
||||||
|
toast.error($i18n.t('Failed to read clipboard contents'));
|
||||||
|
return '{{CLIPBOARD}}';
|
||||||
|
});
|
||||||
|
|
||||||
|
const clipboardItems = await navigator.clipboard.read();
|
||||||
|
|
||||||
|
let imageUrl = null;
|
||||||
|
for (const item of clipboardItems) {
|
||||||
|
// Check for known image types
|
||||||
|
for (const type of item.types) {
|
||||||
|
if (type.startsWith('image/')) {
|
||||||
|
const blob = await item.getType(type);
|
||||||
|
imageUrl = URL.createObjectURL(blob);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageUrl) {
|
||||||
|
files = [
|
||||||
|
...files,
|
||||||
|
{
|
||||||
|
type: 'image',
|
||||||
|
url: imageUrl
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
text = text.replaceAll('{{CLIPBOARD}}', clipboardText);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.includes('{{USER_LOCATION}}')) {
|
||||||
|
let location;
|
||||||
|
try {
|
||||||
|
location = await getUserPosition();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error($i18n.t('Location access not allowed'));
|
||||||
|
location = 'LOCATION_UNKNOWN';
|
||||||
|
}
|
||||||
|
text = text.replaceAll('{{USER_LOCATION}}', String(location));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.includes('{{USER_NAME}}')) {
|
||||||
|
const name = $user?.name || 'User';
|
||||||
|
text = text.replaceAll('{{USER_NAME}}', name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.includes('{{USER_LANGUAGE}}')) {
|
||||||
|
const language = localStorage.getItem('locale') || 'en-US';
|
||||||
|
text = text.replaceAll('{{USER_LANGUAGE}}', language);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.includes('{{CURRENT_DATE}}')) {
|
||||||
|
const date = getFormattedDate();
|
||||||
|
text = text.replaceAll('{{CURRENT_DATE}}', date);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.includes('{{CURRENT_TIME}}')) {
|
||||||
|
const time = getFormattedTime();
|
||||||
|
text = text.replaceAll('{{CURRENT_TIME}}', time);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.includes('{{CURRENT_DATETIME}}')) {
|
||||||
|
const dateTime = getCurrentDateTime();
|
||||||
|
text = text.replaceAll('{{CURRENT_DATETIME}}', dateTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.includes('{{CURRENT_TIMEZONE}}')) {
|
||||||
|
const timezone = getUserTimezone();
|
||||||
|
text = text.replaceAll('{{CURRENT_TIMEZONE}}', timezone);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.includes('{{CURRENT_WEEKDAY}}')) {
|
||||||
|
const weekday = getWeekday();
|
||||||
|
text = text.replaceAll('{{CURRENT_WEEKDAY}}', weekday);
|
||||||
|
}
|
||||||
|
|
||||||
|
inputVariableHandler(text);
|
||||||
|
return text;
|
||||||
|
};
|
||||||
|
|
||||||
|
const replaceVariables = (variables: Record<string, any>) => {
|
||||||
|
if (!chatInputElement) return;
|
||||||
|
console.log('Replacing variables:', variables);
|
||||||
|
|
||||||
|
chatInputElement.replaceVariables(variables);
|
||||||
|
chatInputElement.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setText = async (text?: string) => {
|
||||||
|
if (!chatInputElement) return;
|
||||||
|
|
||||||
|
text = await textVariableHandler(text || '');
|
||||||
|
|
||||||
|
chatInputElement?.setText(text);
|
||||||
|
chatInputElement?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCommand = () => {
|
||||||
|
if (!chatInputElement) return;
|
||||||
|
|
||||||
|
let word = '';
|
||||||
|
word = chatInputElement?.getWordAtDocPos();
|
||||||
|
|
||||||
|
return word;
|
||||||
|
};
|
||||||
|
|
||||||
|
const replaceCommandWithText = (text) => {
|
||||||
|
if (!chatInputElement) return;
|
||||||
|
|
||||||
|
chatInputElement?.replaceCommandWithText(text);
|
||||||
|
};
|
||||||
|
|
||||||
|
const insertTextAtCursor = async (text: string) => {
|
||||||
|
text = await textVariableHandler(text);
|
||||||
|
|
||||||
|
if (command) {
|
||||||
|
replaceCommandWithText(text);
|
||||||
|
} else {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (selection && selection.rangeCount > 0) {
|
||||||
|
const range = selection.getRangeAt(0);
|
||||||
|
range.deleteContents();
|
||||||
|
range.insertNode(document.createTextNode(text));
|
||||||
|
range.collapse(false);
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await tick();
|
||||||
|
const chatInputContainer = document.getElementById('chat-input-container');
|
||||||
|
if (chatInputContainer) {
|
||||||
|
chatInputContainer.scrollTop = chatInputContainer.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
await tick();
|
||||||
|
if (chatInputElement) {
|
||||||
|
chatInputElement.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let command = '';
|
||||||
|
|
||||||
|
export let showCommands = false;
|
||||||
|
$: showCommands = ['/'].includes(command?.charAt(0));
|
||||||
|
|
||||||
const screenCaptureHandler = async () => {
|
const screenCaptureHandler = async () => {
|
||||||
try {
|
try {
|
||||||
// Request screen media
|
// Request screen media
|
||||||
|
|
@ -78,7 +260,7 @@
|
||||||
};
|
};
|
||||||
|
|
||||||
const inputFilesHandler = async (inputFiles) => {
|
const inputFilesHandler = async (inputFiles) => {
|
||||||
inputFiles.forEach((file) => {
|
inputFiles.forEach(async (file) => {
|
||||||
console.info('Processing file:', {
|
console.info('Processing file:', {
|
||||||
name: file.name,
|
name: file.name,
|
||||||
type: file.type,
|
type: file.type,
|
||||||
|
|
@ -102,43 +284,50 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (file['type'].startsWith('image/')) {
|
||||||
['image/gif', 'image/webp', 'image/jpeg', 'image/png', 'image/avif'].includes(file['type'])
|
const compressImageHandler = async (imageUrl, settings = {}, config = {}) => {
|
||||||
) {
|
// Quick shortcut so we don’t do unnecessary work.
|
||||||
|
const settingsCompression = settings?.imageCompression ?? false;
|
||||||
|
const configWidth = config?.file?.image_compression?.width ?? null;
|
||||||
|
const configHeight = config?.file?.image_compression?.height ?? null;
|
||||||
|
|
||||||
|
// If neither settings nor config wants compression, return original URL.
|
||||||
|
if (!settingsCompression && !configWidth && !configHeight) {
|
||||||
|
return imageUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to null (no compression unless set)
|
||||||
|
let width = null;
|
||||||
|
let height = null;
|
||||||
|
|
||||||
|
// If user/settings want compression, pick their preferred size.
|
||||||
|
if (settingsCompression) {
|
||||||
|
width = settings?.imageCompressionSize?.width ?? null;
|
||||||
|
height = settings?.imageCompressionSize?.height ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply config limits as an upper bound if any
|
||||||
|
if (configWidth && (width === null || width > configWidth)) {
|
||||||
|
width = configWidth;
|
||||||
|
}
|
||||||
|
if (configHeight && (height === null || height > configHeight)) {
|
||||||
|
height = configHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do the compression if required
|
||||||
|
if (width || height) {
|
||||||
|
return await compressImage(imageUrl, width, height);
|
||||||
|
}
|
||||||
|
return imageUrl;
|
||||||
|
};
|
||||||
|
|
||||||
let reader = new FileReader();
|
let reader = new FileReader();
|
||||||
|
|
||||||
reader.onload = async (event) => {
|
reader.onload = async (event) => {
|
||||||
let imageUrl = event.target.result;
|
let imageUrl = event.target.result;
|
||||||
|
|
||||||
if (
|
// Compress the image if settings or config require it
|
||||||
($settings?.imageCompression ?? false) ||
|
imageUrl = await compressImageHandler(imageUrl, $settings, $config);
|
||||||
($config?.file?.image_compression?.width ?? null) ||
|
|
||||||
($config?.file?.image_compression?.height ?? null)
|
|
||||||
) {
|
|
||||||
let width = null;
|
|
||||||
let height = null;
|
|
||||||
|
|
||||||
if ($settings?.imageCompression ?? false) {
|
|
||||||
width = $settings?.imageCompressionSize?.width ?? null;
|
|
||||||
height = $settings?.imageCompressionSize?.height ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
($config?.file?.image_compression?.width ?? null) ||
|
|
||||||
($config?.file?.image_compression?.height ?? null)
|
|
||||||
) {
|
|
||||||
if (width > ($config?.file?.image_compression?.width ?? null)) {
|
|
||||||
width = $config?.file?.image_compression?.width ?? null;
|
|
||||||
}
|
|
||||||
if (height > ($config?.file?.image_compression?.height ?? null)) {
|
|
||||||
height = $config?.file?.image_compression?.height ?? null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (width || height) {
|
|
||||||
imageUrl = await compressImage(imageUrl, width, height);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
files = [
|
files = [
|
||||||
...files,
|
...files,
|
||||||
|
|
@ -149,7 +338,11 @@
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(
|
||||||
|
file['type'] === 'image/heic'
|
||||||
|
? await heic2any({ blob: file, toType: 'image/jpeg' })
|
||||||
|
: file
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
uploadFileHandler(file);
|
uploadFileHandler(file);
|
||||||
}
|
}
|
||||||
|
|
@ -247,7 +440,7 @@
|
||||||
const onDrop = async (e) => {
|
const onDrop = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (e.dataTransfer?.files) {
|
if (e.dataTransfer?.files && acceptFiles) {
|
||||||
const inputFiles = Array.from(e.dataTransfer?.files);
|
const inputFiles = Array.from(e.dataTransfer?.files);
|
||||||
if (inputFiles && inputFiles.length > 0) {
|
if (inputFiles && inputFiles.length > 0) {
|
||||||
console.log(inputFiles);
|
console.log(inputFiles);
|
||||||
|
|
@ -273,10 +466,13 @@
|
||||||
content = '';
|
content = '';
|
||||||
files = [];
|
files = [];
|
||||||
|
|
||||||
await tick();
|
if (chatInputElement) {
|
||||||
|
chatInputElement?.setText('');
|
||||||
|
|
||||||
const chatInputElement = document.getElementById(`chat-input-${id}`);
|
await tick();
|
||||||
chatInputElement?.focus();
|
|
||||||
|
chatInputElement.focus();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
$: if (content) {
|
$: if (content) {
|
||||||
|
|
@ -285,9 +481,10 @@
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
const chatInput = document.getElementById(`chat-input-${id}`);
|
if (chatInputElement) {
|
||||||
chatInput?.focus();
|
chatInputElement.focus();
|
||||||
}, 0);
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
await tick();
|
await tick();
|
||||||
|
|
@ -314,27 +511,39 @@
|
||||||
|
|
||||||
<FilesOverlay show={draggedOver} />
|
<FilesOverlay show={draggedOver} />
|
||||||
|
|
||||||
<input
|
{#if acceptFiles}
|
||||||
bind:this={filesInputElement}
|
<input
|
||||||
bind:files={inputFiles}
|
bind:this={filesInputElement}
|
||||||
type="file"
|
bind:files={inputFiles}
|
||||||
hidden
|
type="file"
|
||||||
multiple
|
hidden
|
||||||
on:change={async () => {
|
multiple
|
||||||
if (inputFiles && inputFiles.length > 0) {
|
on:change={async () => {
|
||||||
inputFilesHandler(Array.from(inputFiles));
|
if (inputFiles && inputFiles.length > 0) {
|
||||||
} else {
|
inputFilesHandler(Array.from(inputFiles));
|
||||||
toast.error($i18n.t(`File not found.`));
|
} else {
|
||||||
}
|
toast.error($i18n.t(`File not found.`));
|
||||||
|
}
|
||||||
|
|
||||||
filesInputElement.value = '';
|
filesInputElement.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<InputVariablesModal
|
||||||
|
bind:show={showInputVariablesModal}
|
||||||
|
variables={inputVariables}
|
||||||
|
onSave={(variableValues) => {
|
||||||
|
inputVariableValues = { ...inputVariableValues, ...variableValues };
|
||||||
|
replaceVariables(inputVariableValues);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="bg-transparent">
|
<div class="bg-transparent">
|
||||||
<div
|
<div
|
||||||
class="{($settings?.widescreenMode ?? null)
|
class="{($settings?.widescreenMode ?? null)
|
||||||
? 'max-w-full'
|
? 'max-w-full'
|
||||||
: 'max-w-6xl'} px-2.5 mx-auto inset-x-0 relative"
|
: 'max-w-6xl'} 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="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="flex flex-col px-3 w-full">
|
||||||
|
|
@ -378,6 +587,13 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Commands
|
||||||
|
bind:this={commandsElement}
|
||||||
|
show={showCommands}
|
||||||
|
{command}
|
||||||
|
insertTextHandler={insertTextAtCursor}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -390,15 +606,23 @@
|
||||||
recording = false;
|
recording = false;
|
||||||
|
|
||||||
await tick();
|
await tick();
|
||||||
document.getElementById(`chat-input-${id}`)?.focus();
|
|
||||||
|
if (chatInputElement) {
|
||||||
|
chatInputElement.focus();
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
onConfirm={async (data) => {
|
onConfirm={async (data) => {
|
||||||
const { text, filename } = data;
|
const { text, filename } = data;
|
||||||
content = `${content}${text} `;
|
|
||||||
recording = false;
|
recording = false;
|
||||||
|
|
||||||
await tick();
|
await tick();
|
||||||
document.getElementById(`chat-input-${id}`)?.focus();
|
insertTextAtCursor(text);
|
||||||
|
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
if (chatInputElement) {
|
||||||
|
chatInputElement.focus();
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
|
|
@ -470,40 +694,96 @@
|
||||||
|
|
||||||
<div class="px-2.5">
|
<div class="px-2.5">
|
||||||
<div
|
<div
|
||||||
class="scrollbar-hidden font-primary text-left bg-transparent dark:text-gray-100 outline-hidden w-full pt-3 px-1 rounded-xl resize-none h-fit max-h-80 overflow-auto"
|
class="scrollbar-hidden font-primary text-left bg-transparent dark:text-gray-100 outline-hidden w-full pt-3 px-1 resize-none h-fit max-h-80 overflow-auto"
|
||||||
>
|
>
|
||||||
<RichTextInput
|
<RichTextInput
|
||||||
bind:value={content}
|
bind:this={chatInputElement}
|
||||||
id={`chat-input-${id}`}
|
json={true}
|
||||||
messageInput={true}
|
messageInput={true}
|
||||||
shiftEnter={!$mobile ||
|
{showFormattingButtons}
|
||||||
!(
|
shiftEnter={!($settings?.ctrlEnterToSend ?? false) &&
|
||||||
'ontouchstart' in window ||
|
(!$mobile ||
|
||||||
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 ||
|
'ontouchstart' in window ||
|
||||||
navigator.maxTouchPoints > 0 ||
|
navigator.maxTouchPoints > 0 ||
|
||||||
navigator.msMaxTouchPoints > 0
|
navigator.msMaxTouchPoints > 0
|
||||||
)
|
))}
|
||||||
) {
|
largeTextAsFile={$settings?.largeTextAsFile ?? false}
|
||||||
// Prevent Enter key from creating a new line
|
floatingMenuPlacement={'top-start'}
|
||||||
// Uses keyCode '13' for Enter key for chinese/japanese keyboards
|
onChange={(e) => {
|
||||||
if (e.keyCode === 13 && !e.shiftKey) {
|
const { md } = e;
|
||||||
|
content = md;
|
||||||
|
command = getCommand();
|
||||||
|
}}
|
||||||
|
on:keydown={async (e) => {
|
||||||
|
e = e.detail.event;
|
||||||
|
const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac
|
||||||
|
|
||||||
|
const commandsContainerElement = document.getElementById('commands-container');
|
||||||
|
|
||||||
|
if (commandsContainerElement) {
|
||||||
|
if (commandsContainerElement && e.key === 'ArrowUp') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
commandsElement.selectUp();
|
||||||
|
|
||||||
|
const commandOptionButton = [
|
||||||
|
...document.getElementsByClassName('selected-command-option-button')
|
||||||
|
]?.at(-1);
|
||||||
|
commandOptionButton.scrollIntoView({ block: 'center' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Submit the content when Enter key is pressed
|
if (commandsContainerElement && e.key === 'ArrowDown') {
|
||||||
if (content !== '' && e.keyCode === 13 && !e.shiftKey) {
|
e.preventDefault();
|
||||||
submitHandler();
|
commandsElement.selectDown();
|
||||||
|
|
||||||
|
const commandOptionButton = [
|
||||||
|
...document.getElementsByClassName('selected-command-option-button')
|
||||||
|
]?.at(-1);
|
||||||
|
commandOptionButton.scrollIntoView({ block: 'center' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (commandsContainerElement && e.key === 'Tab') {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const commandOptionButton = [
|
||||||
|
...document.getElementsByClassName('selected-command-option-button')
|
||||||
|
]?.at(-1);
|
||||||
|
|
||||||
|
commandOptionButton?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (commandsContainerElement && e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const commandOptionButton = [
|
||||||
|
...document.getElementsByClassName('selected-command-option-button')
|
||||||
|
]?.at(-1);
|
||||||
|
|
||||||
|
if (commandOptionButton) {
|
||||||
|
commandOptionButton?.click();
|
||||||
|
} else {
|
||||||
|
document.getElementById('send-message-button')?.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -520,30 +800,34 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class=" flex justify-between mb-2.5 mt-1.5 mx-0.5">
|
<div class=" flex justify-between mb-2.5 mt-1.5 mx-0.5">
|
||||||
<div class="ml-1 self-end flex space-x-1">
|
<div class="ml-1 self-end flex space-x-1 flex-1">
|
||||||
<InputMenu
|
<slot name="menu">
|
||||||
{screenCaptureHandler}
|
{#if acceptFiles}
|
||||||
uploadFilesHandler={() => {
|
<InputMenu
|
||||||
filesInputElement.click();
|
{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-1.5 outline-hidden focus:outline-hidden"
|
|
||||||
type="button"
|
|
||||||
aria-label="More"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="currentColor"
|
|
||||||
class="size-5"
|
|
||||||
>
|
>
|
||||||
<path
|
<button
|
||||||
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"
|
class="bg-transparent hover:bg-white/80 text-gray-800 dark:text-white dark:hover:bg-gray-800 transition rounded-full p-1.5 outline-hidden focus:outline-hidden"
|
||||||
/>
|
type="button"
|
||||||
</svg>
|
aria-label="More"
|
||||||
</button>
|
>
|
||||||
</InputMenu>
|
<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>
|
||||||
|
{/if}
|
||||||
|
</slot>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="self-end flex space-x-1 mr-1">
|
<div class="self-end flex space-x-1 mr-1">
|
||||||
|
|
@ -594,31 +878,57 @@
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class=" flex items-center">
|
<div class=" flex items-center">
|
||||||
<div class=" flex items-center">
|
{#if inputLoading && onStop}
|
||||||
<Tooltip content={$i18n.t('Send message')}>
|
<div class=" flex items-center">
|
||||||
<button
|
<Tooltip content={$i18n.t('Stop')}>
|
||||||
id="send-message-button"
|
<button
|
||||||
class="{content !== '' || files.length !== 0
|
class="bg-white hover:bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-800 transition rounded-full p-1.5"
|
||||||
? 'bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100 '
|
on:click={() => {
|
||||||
: 'text-white bg-gray-200 dark:text-gray-900 dark:bg-gray-700 disabled'} transition rounded-full p-1.5 self-center"
|
onStop();
|
||||||
type="submit"
|
}}
|
||||||
disabled={content === '' && files.length === 0}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
fill="currentColor"
|
|
||||||
class="size-5"
|
|
||||||
>
|
>
|
||||||
<path
|
<svg
|
||||||
fill-rule="evenodd"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
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"
|
viewBox="0 0 24 24"
|
||||||
clip-rule="evenodd"
|
fill="currentColor"
|
||||||
/>
|
class="size-5"
|
||||||
</svg>
|
>
|
||||||
</button>
|
<path
|
||||||
</Tooltip>
|
fill-rule="evenodd"
|
||||||
</div>
|
d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm6-2.438c0-.724.588-1.312 1.313-1.312h4.874c.725 0 1.313.588 1.313 1.313v4.874c0 .725-.588 1.313-1.313 1.313H9.564a1.312 1.312 0 01-1.313-1.313V9.564z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<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-5"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -144,7 +144,9 @@
|
||||||
<ProfilePreview user={message.user}>
|
<ProfilePreview user={message.user}>
|
||||||
<ProfileImage
|
<ProfileImage
|
||||||
src={message.user?.profile_image_url ??
|
src={message.user?.profile_image_url ??
|
||||||
($i18n.language === 'dg-DG' ? `/doge.png` : `${WEBUI_BASE_URL}/static/favicon.png`)}
|
($i18n.language === 'dg-DG'
|
||||||
|
? `${WEBUI_BASE_URL}/doge.png`
|
||||||
|
: `${WEBUI_BASE_URL}/static/favicon.png`)}
|
||||||
className={'size-8 translate-y-1 ml-0.5'}
|
className={'size-8 translate-y-1 ml-0.5'}
|
||||||
/>
|
/>
|
||||||
</ProfilePreview>
|
</ProfilePreview>
|
||||||
|
|
@ -275,7 +277,7 @@
|
||||||
>
|
>
|
||||||
{#if $shortCodesToEmojis[reaction.name]}
|
{#if $shortCodesToEmojis[reaction.name]}
|
||||||
<img
|
<img
|
||||||
src="/assets/emojis/{$shortCodesToEmojis[
|
src="{WEBUI_BASE_URL}/assets/emojis/{$shortCodesToEmojis[
|
||||||
reaction.name
|
reaction.name
|
||||||
].toLowerCase()}.svg"
|
].toLowerCase()}.svg"
|
||||||
alt={reaction.name}
|
alt={reaction.name}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
import emojiShortCodes from '$lib/emoji-shortcodes.json';
|
import emojiShortCodes from '$lib/emoji-shortcodes.json';
|
||||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||||
import VirtualList from '@sveltejs/svelte-virtual-list';
|
import VirtualList from '@sveltejs/svelte-virtual-list';
|
||||||
|
import { WEBUI_BASE_URL } from '$lib/constants';
|
||||||
|
|
||||||
export let onClose = () => {};
|
export let onClose = () => {};
|
||||||
export let onSubmit = (name) => {};
|
export let onSubmit = (name) => {};
|
||||||
|
|
@ -147,7 +148,7 @@
|
||||||
on:click={() => selectEmoji(emojiItem)}
|
on:click={() => selectEmoji(emojiItem)}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src="/assets/emojis/{emojiItem.name.toLowerCase()}.svg"
|
src="{WEBUI_BASE_URL}/assets/emojis/{emojiItem.name.toLowerCase()}.svg"
|
||||||
alt={emojiItem.name}
|
alt={emojiItem.name}
|
||||||
class="size-5"
|
class="size-5"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
|
|
||||||
|
|
@ -119,7 +119,7 @@
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitHandler = async ({ content, data }) => {
|
const submitHandler = async ({ content, data }) => {
|
||||||
if (!content) {
|
if (!content && (data?.files ?? []).length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -196,7 +196,7 @@
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class=" pb-[1rem]">
|
<div class=" pb-[1rem] px-2.5">
|
||||||
<MessageInput id={threadId} {typingUsers} {onChange} onSubmit={submitHandler} />
|
<MessageInput id={threadId} {typingUsers} {onChange} onSubmit={submitHandler} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue