Merge pull request #1 from open-webui/main

Upgrade to v0.6.11
This commit is contained in:
YuQX 2025-05-28 22:24:33 +08:00 committed by GitHub
commit 24be96a6d4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
159 changed files with 6188 additions and 3564 deletions

View File

@ -89,9 +89,20 @@ body:
required: true required: true
- label: I have included the Docker container logs. - label: I have included the Docker container logs.
required: true required: true
- label: I have listed steps to reproduce the bug in detail. - label: I have **provided every relevant configuration, setting, and environment variable used in my setup.**
required: true
- label: I have clearly **listed every relevant configuration, custom setting, environment variable, and command-line option that influences my setup** (such as Docker Compose overrides, .env values, browser settings, authentication configurations, etc).
required: true
- label: |
I have documented **step-by-step reproduction instructions that are precise, sequential, and leave nothing to interpretation**. My steps:
- Start with the initial platform/version/OS and dependencies used,
- Specify exact install/launch/configure commands,
- List URLs visited, user input (incl. example values/emails/passwords if needed),
- Describe all options and toggles enabled or changed,
- Include any files or environmental changes,
- Identify the expected and actual result at each stage,
- Ensure any reasonably skilled user can follow and hit the same issue.
required: true required: true
- type: textarea - type: textarea
id: expected-behavior id: expected-behavior
attributes: attributes:
@ -112,15 +123,25 @@ body:
id: reproduction-steps id: reproduction-steps
attributes: attributes:
label: Steps to Reproduce label: Steps to Reproduce
description: Providing clear, step-by-step instructions helps us reproduce and fix the issue faster. If we can't reproduce it, we can't fix it. description: |
Please provide a **very detailed, step-by-step guide** to reproduce the issue. Your instructions should be so clear and precise that anyone can follow them without guesswork. Include every relevant detail—settings, configuration options, exact commands used, values entered, and any prerequisites or environment variables.
**If full reproduction steps and all relevant settings are not provided, your issue may not be addressed.**
placeholder: | placeholder: |
1. Go to '...' Example (include every detail):
2. Click on '...' 1. Start with a clean Ubuntu 22.04 install.
3. Scroll down to '...' 2. Install Docker v24.0.5 and start the service.
4. See the error message '...' 3. Clone the Open WebUI repo (git clone ...).
4. Use the Docker Compose file without modifications.
5. Open browser Chrome 115.0 in incognito mode.
6. Go to http://localhost:8080 and log in with user "test@example.com".
7. Set the language to "English" and theme to "Dark".
8. Attempt to connect to Ollama at "http://localhost:11434".
9. Observe that the error message "Connection refused" appears at the top right.
Please list each step carefully and include all relevant configuration, settings, and options.
validations: validations:
required: true required: true
- type: textarea - type: textarea
id: logs-screenshots id: logs-screenshots
attributes: attributes:

View File

@ -5,6 +5,54 @@ 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.11] - 2025-05-27
### Added
- 🟢 **Ollama Model Status Indicator in Model Selector**: Instantly see which Ollama models are currently loaded with a clear indicator in the model selector, helping you stay organized and optimize local model usage.
- 🗑️ **Unload Ollama Model Directly from Model Selector**: Easily release memory and resources by unloading any loaded Ollama model right in the model selector—streamline hardware management without switching pages.
- 🗣️ **User-Configurable Speech-to-Text Language Setting**: Improve transcription accuracy by letting individual users explicitly set their preferred STT language in their settings—ideal for multilingual teams and clear audio capture.
- ⚡ **Granular Audio Playback Speed Control**: Instead of just presets, you can now choose granular audio speed using a numeric input, giving you complete control over playback pace in transcriptions and media reviews.
- 📦 **GZip, Brotli, ZStd Compression Middleware**: Enjoy significantly faster page loads and reduced bandwidth usage with new server-side compression—giving users a snappier, more efficient experience.
- 🏷️ **Configurable Weight for BM25 in Hybrid Search**: Fine-tune search relevance by adjusting the weight for BM25 inside hybrid search from the UI, letting you tailor knowledge search results to your workflow.
- 🧪 **Bypass File Creation with CTRL + SHIFT + V**: When “Paste Large Text as File” is enabled, use CTRL + SHIFT + V to skip the file creation dialog and instantly upload text as a file—perfect for rapid document prep.
- 🌐 **Bypass Web Loader in Web Search**: Choose to bypass web content loading and use snippets directly in web search for faster, more reliable results when page loads are slow or blocked.
- 🚀 **Environment Variable: WEBUI_AUTH_TRUSTED_GROUPS_HEADER**: Now sync and manage user groups directly via trusted HTTP header, unlocking smoother single sign-on and identity integrations for organizations.
- 🏢 **Workspace Models Visibility Controls**: You can now hide workspace-level models from both the model selector and shared environments—keep your team focused and reduce clutter from rarely-used endpoints.
- 🛡️ **Copy Model Link**: You can now copy a direct link to any model—including those hidden from the selector—making sharing and onboarding others more seamless.
- 🔗 **Load Function Directly from URL**: Simplify custom function management—just paste any GitHub function URL into Open WebUI and import new functions in seconds.
- ⚙️ **Custom Name/Description for External Tool Servers**: Personalize and clarify external tool servers by assigning custom names and descriptions, making it easier to manage integrations in large-scale workspaces.
- 🌍 **Custom OpenAPI JSON URL Support for Tool Servers**: Supports specifying any custom OpenAPI JSON URL, unlocking more flexible integration with any backend for tool calls.
- 📊 **Source Field Now Displays in Non-Streaming Responses with Attachments**: When files or knowledge are attached, the "source" field now appears for all responses, even in non-streaming mode—enabling improved citation workflow.
- 🎛 **Pinned Chats**: Reduced payload size on pinned chat requests—leading to faster load times and less data usage, especially on busy warehouses.
- 🛠 **Import/Export Default Prompt Suggestions**: Enjoy one-click import/export of prompt suggestions, making it much easier to share, reuse, and manage best practices across teams or deployments.
- 🍰 **Banners Now Sortable from Admin Settings**: Quickly re-order or prioritize banners, letting you highlight the most critical info for your team.
- 🛠 **Advanced Chat Parameters—Clearer Ollama Support Labels**: Parameters and advanced settings now explicitly indicate if they are Ollama-specific, reducing confusion and improving setup accuracy.
- 🤏 **Scroll Bar Thumb Improved for Better Visibility**: Enhanced scrollbar styling makes navigation more accessible and visually intuitive.
- 🗄️ **Modal Redesign for Archived and User Chat Listings**: Clean, modern modal interface for browsing archived and user-specific chats makes locating conversations faster and more pleasant.
- 📝 **Add/Edit Memory Modal UX**: Memory modals are now larger and have resizable input fields, supporting easier editing of long or complex memory content.
- 🏆 **Translation & Localization Enhancements**: Major upgrades to Chinese (Simplified & Traditional), Korean, Russian, German, Danish, Finnish—not just fixing typos, but consistency, tone, and terminology for a more natural native-language experience.
- ⚡ **General Backend Stability & Security Enhancements**: Various backend refinements ensure a more resilient, reliable, and secure platform for smoother operation and peace of mind.
### Fixed
- 🖼️ **Image Generation with Allowed File Extensions Now Works Reliably**: Ensure seamless image generation even when strict file extension rules are set—no more blocked creative workflows due to technical hiccups.
- 🗂 **Remove Leading Dot for File Extension Check**: Fixed an issue where file validation failed because of a leading dot, making file uploads and knowledge management more robust.
- 🏷️ **Correct Local/External Model Classification**: The platform now accurately distinguishes between local and external models—preventing local models from showing up as external (and vice versa)—ensuring seamless setup, clarity, and management of your AI model endpoints.
- 📄 **External Document Loader Now Functions as Intended**: External document loaders are reliably invoked, ensuring smoother knowledge ingestion from external sources—expanding your RAG and knowledge workflows.
- 🎯 **Correct Handling of Toggle Filters**: Toggle filters are now robustly managed, preventing accidental auto-activation and ensuring user preferences are always respected.
- 🗃 **S3 Tagging Character Restrictions Fixed**: Tags for files in S3 now automatically meet Amazons allowed character set, avoiding upload errors and ensuring cross-cloud compatibility.
- 🛡️ **Authentication Now Uses Password Hash When Duplicate Emails Exist**: Ensures account security and prevents access issues if duplicate emails are present in your system.
### Changed
- 🧩 **Admin Settings: OAuth Redirects Now Use WEBUI_URL**: The OAuth redirect URL is now based on the explicitly set WEBUI_URL, ensuring single sign-on and identity provider integrations always send users to the correct frontend.
### Removed
- 💡 **Duplicate/Typo Component Removals**: Obsolete components have been cleaned up, reducing confusion and improving overall code quality for the team.
- 🚫 **Streaming Upsert in Pinecone Removed**: Removed streaming upsert references for better compatibility and future-proofing with latest Pinecone SDK updates.
## [0.6.10] - 2025-05-19 ## [0.6.10] - 2025-05-19
### Added ### Added

View File

@ -1928,6 +1928,11 @@ RAG_RELEVANCE_THRESHOLD = PersistentConfig(
"rag.relevance_threshold", "rag.relevance_threshold",
float(os.environ.get("RAG_RELEVANCE_THRESHOLD", "0.0")), float(os.environ.get("RAG_RELEVANCE_THRESHOLD", "0.0")),
) )
RAG_HYBRID_BM25_WEIGHT = PersistentConfig(
"RAG_HYBRID_BM25_WEIGHT",
"rag.hybrid_bm25_weight",
float(os.environ.get("RAG_HYBRID_BM25_WEIGHT", "0.5")),
)
ENABLE_RAG_HYBRID_SEARCH = PersistentConfig( ENABLE_RAG_HYBRID_SEARCH = PersistentConfig(
"ENABLE_RAG_HYBRID_SEARCH", "ENABLE_RAG_HYBRID_SEARCH",
@ -2177,6 +2182,12 @@ BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL = PersistentConfig(
) )
BYPASS_WEB_SEARCH_WEB_LOADER = PersistentConfig(
"BYPASS_WEB_SEARCH_WEB_LOADER",
"rag.web.search.bypass_web_loader",
os.getenv("BYPASS_WEB_SEARCH_WEB_LOADER", "False").lower() == "true",
)
WEB_SEARCH_RESULT_COUNT = PersistentConfig( WEB_SEARCH_RESULT_COUNT = PersistentConfig(
"WEB_SEARCH_RESULT_COUNT", "WEB_SEARCH_RESULT_COUNT",
"rag.web.search.result_count", "rag.web.search.result_count",
@ -2202,6 +2213,7 @@ WEB_SEARCH_CONCURRENT_REQUESTS = PersistentConfig(
int(os.getenv("WEB_SEARCH_CONCURRENT_REQUESTS", "10")), int(os.getenv("WEB_SEARCH_CONCURRENT_REQUESTS", "10")),
) )
WEB_LOADER_ENGINE = PersistentConfig( WEB_LOADER_ENGINE = PersistentConfig(
"WEB_LOADER_ENGINE", "WEB_LOADER_ENGINE",
"rag.web.loader.engine", "rag.web.loader.engine",

View File

@ -349,6 +349,10 @@ WEBUI_AUTH_TRUSTED_EMAIL_HEADER = os.environ.get(
"WEBUI_AUTH_TRUSTED_EMAIL_HEADER", None "WEBUI_AUTH_TRUSTED_EMAIL_HEADER", None
) )
WEBUI_AUTH_TRUSTED_NAME_HEADER = os.environ.get("WEBUI_AUTH_TRUSTED_NAME_HEADER", None) WEBUI_AUTH_TRUSTED_NAME_HEADER = os.environ.get("WEBUI_AUTH_TRUSTED_NAME_HEADER", None)
WEBUI_AUTH_TRUSTED_GROUPS_HEADER = os.environ.get(
"WEBUI_AUTH_TRUSTED_GROUPS_HEADER", None
)
BYPASS_MODEL_ACCESS_CONTROL = ( BYPASS_MODEL_ACCESS_CONTROL = (
os.environ.get("BYPASS_MODEL_ACCESS_CONTROL", "False").lower() == "true" os.environ.get("BYPASS_MODEL_ACCESS_CONTROL", "False").lower() == "true"

View File

@ -54,11 +54,8 @@ log.setLevel(SRC_LOG_LEVELS["MAIN"])
def get_function_module_by_id(request: Request, pipe_id: str): def get_function_module_by_id(request: Request, pipe_id: str):
# Check if function is already loaded # Check if function is already loaded
if pipe_id not in request.app.state.FUNCTIONS: function_module, _, _ = load_function_module_by_id(pipe_id)
function_module, _, _ = load_function_module_by_id(pipe_id) request.app.state.FUNCTIONS[pipe_id] = function_module
request.app.state.FUNCTIONS[pipe_id] = function_module
else:
function_module = request.app.state.FUNCTIONS[pipe_id]
if hasattr(function_module, "valves") and hasattr(function_module, "Valves"): if hasattr(function_module, "valves") and hasattr(function_module, "Valves"):
valves = Functions.get_function_valves_by_id(pipe_id) valves = Functions.get_function_valves_by_id(pipe_id)

View File

@ -43,7 +43,7 @@ class ReconnectingPostgresqlDatabase(CustomReconnectMixin, PostgresqlDatabase):
def register_connection(db_url): def register_connection(db_url):
db = connect(db_url, unquote_password=True) db = connect(db_url, unquote_user=True, unquote_password=True)
if isinstance(db, PostgresqlDatabase): if isinstance(db, PostgresqlDatabase):
# Enable autoconnect for SQLite databases, managed by Peewee # Enable autoconnect for SQLite databases, managed by Peewee
db.autoconnect = True db.autoconnect = True
@ -51,7 +51,7 @@ def register_connection(db_url):
log.info("Connected to PostgreSQL database") log.info("Connected to PostgreSQL database")
# Get the connection details # Get the connection details
connection = parse(db_url, unquote_password=True) connection = parse(db_url, unquote_user=True, unquote_password=True)
# Use our custom database class that supports reconnection # Use our custom database class that supports reconnection
db = ReconnectingPostgresqlDatabase(**connection) db = ReconnectingPostgresqlDatabase(**connection)

View File

@ -40,6 +40,8 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, RedirectResponse from fastapi.responses import JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from starlette_compress import CompressMiddleware
from starlette.exceptions import HTTPException as StarletteHTTPException 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
@ -196,7 +198,10 @@ from open_webui.config import (
RAG_RERANKING_MODEL_TRUST_REMOTE_CODE, RAG_RERANKING_MODEL_TRUST_REMOTE_CODE,
RAG_EMBEDDING_ENGINE, RAG_EMBEDDING_ENGINE,
RAG_EMBEDDING_BATCH_SIZE, RAG_EMBEDDING_BATCH_SIZE,
RAG_TOP_K,
RAG_TOP_K_RERANKER,
RAG_RELEVANCE_THRESHOLD, RAG_RELEVANCE_THRESHOLD,
RAG_HYBRID_BM25_WEIGHT,
RAG_ALLOWED_FILE_EXTENSIONS, RAG_ALLOWED_FILE_EXTENSIONS,
RAG_FILE_MAX_COUNT, RAG_FILE_MAX_COUNT,
RAG_FILE_MAX_SIZE, RAG_FILE_MAX_SIZE,
@ -217,8 +222,6 @@ from open_webui.config import (
DOCUMENT_INTELLIGENCE_ENDPOINT, DOCUMENT_INTELLIGENCE_ENDPOINT,
DOCUMENT_INTELLIGENCE_KEY, DOCUMENT_INTELLIGENCE_KEY,
MISTRAL_OCR_API_KEY, MISTRAL_OCR_API_KEY,
RAG_TOP_K,
RAG_TOP_K_RERANKER,
RAG_TEXT_SPLITTER, RAG_TEXT_SPLITTER,
TIKTOKEN_ENCODING_NAME, TIKTOKEN_ENCODING_NAME,
PDF_EXTRACT_IMAGES, PDF_EXTRACT_IMAGES,
@ -228,6 +231,7 @@ from open_webui.config import (
ENABLE_WEB_SEARCH, ENABLE_WEB_SEARCH,
WEB_SEARCH_ENGINE, WEB_SEARCH_ENGINE,
BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL, BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL,
BYPASS_WEB_SEARCH_WEB_LOADER,
WEB_SEARCH_RESULT_COUNT, WEB_SEARCH_RESULT_COUNT,
WEB_SEARCH_CONCURRENT_REQUESTS, WEB_SEARCH_CONCURRENT_REQUESTS,
WEB_SEARCH_TRUST_ENV, WEB_SEARCH_TRUST_ENV,
@ -646,6 +650,7 @@ app.state.FUNCTIONS = {}
app.state.config.TOP_K = RAG_TOP_K app.state.config.TOP_K = RAG_TOP_K
app.state.config.TOP_K_RERANKER = RAG_TOP_K_RERANKER app.state.config.TOP_K_RERANKER = RAG_TOP_K_RERANKER
app.state.config.RELEVANCE_THRESHOLD = RAG_RELEVANCE_THRESHOLD app.state.config.RELEVANCE_THRESHOLD = RAG_RELEVANCE_THRESHOLD
app.state.config.HYBRID_BM25_WEIGHT = RAG_HYBRID_BM25_WEIGHT
app.state.config.ALLOWED_FILE_EXTENSIONS = RAG_ALLOWED_FILE_EXTENSIONS app.state.config.ALLOWED_FILE_EXTENSIONS = RAG_ALLOWED_FILE_EXTENSIONS
app.state.config.FILE_MAX_SIZE = RAG_FILE_MAX_SIZE app.state.config.FILE_MAX_SIZE = RAG_FILE_MAX_SIZE
app.state.config.FILE_MAX_COUNT = RAG_FILE_MAX_COUNT app.state.config.FILE_MAX_COUNT = RAG_FILE_MAX_COUNT
@ -707,6 +712,7 @@ app.state.config.WEB_SEARCH_TRUST_ENV = WEB_SEARCH_TRUST_ENV
app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL = ( app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL = (
BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL
) )
app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER = BYPASS_WEB_SEARCH_WEB_LOADER
app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION = ENABLE_GOOGLE_DRIVE_INTEGRATION app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION = ENABLE_GOOGLE_DRIVE_INTEGRATION
app.state.config.ENABLE_ONEDRIVE_INTEGRATION = ENABLE_ONEDRIVE_INTEGRATION app.state.config.ENABLE_ONEDRIVE_INTEGRATION = ENABLE_ONEDRIVE_INTEGRATION
@ -959,6 +965,7 @@ class RedirectMiddleware(BaseHTTPMiddleware):
# Add the middleware to the app # Add the middleware to the app
app.add_middleware(CompressMiddleware)
app.add_middleware(RedirectMiddleware) app.add_middleware(RedirectMiddleware)
app.add_middleware(SecurityHeadersMiddleware) app.add_middleware(SecurityHeadersMiddleware)

View File

@ -129,12 +129,16 @@ class AuthsTable:
def authenticate_user(self, email: str, password: str) -> Optional[UserModel]: def authenticate_user(self, email: str, password: str) -> Optional[UserModel]:
log.info(f"authenticate_user: {email}") log.info(f"authenticate_user: {email}")
user = Users.get_user_by_email(email)
if not user:
return None
try: try:
with get_db() as db: with get_db() as db:
auth = db.query(Auth).filter_by(email=email, active=True).first() auth = db.query(Auth).filter_by(id=user.id, active=True).first()
if auth: if auth:
if verify_password(password, auth.password): if verify_password(password, auth.password):
user = Users.get_user_by_id(auth.id)
return user return user
else: else:
return None return None
@ -155,8 +159,8 @@ class AuthsTable:
except Exception: except Exception:
return False return False
def authenticate_user_by_trusted_header(self, email: str) -> Optional[UserModel]: def authenticate_user_by_email(self, email: str) -> Optional[UserModel]:
log.info(f"authenticate_user_by_trusted_header: {email}") log.info(f"authenticate_user_by_email: {email}")
try: try:
with get_db() as db: with get_db() as db:
auth = db.query(Auth).filter_by(email=email, active=True).first() auth = db.query(Auth).filter_by(email=email, active=True).first()

View File

@ -377,22 +377,47 @@ class ChatTable:
return False return False
def get_archived_chat_list_by_user_id( def get_archived_chat_list_by_user_id(
self, user_id: str, skip: int = 0, limit: int = 50 self,
user_id: str,
filter: Optional[dict] = None,
skip: int = 0,
limit: int = 50,
) -> list[ChatModel]: ) -> list[ChatModel]:
with get_db() as db: with get_db() as db:
all_chats = ( query = db.query(Chat).filter_by(user_id=user_id, archived=True)
db.query(Chat)
.filter_by(user_id=user_id, archived=True) if filter:
.order_by(Chat.updated_at.desc()) query_key = filter.get("query")
# .limit(limit).offset(skip) if query_key:
.all() query = query.filter(Chat.title.ilike(f"%{query_key}%"))
)
order_by = filter.get("order_by")
direction = filter.get("direction")
if order_by and direction and getattr(Chat, order_by):
if direction.lower() == "asc":
query = query.order_by(getattr(Chat, order_by).asc())
elif direction.lower() == "desc":
query = query.order_by(getattr(Chat, order_by).desc())
else:
raise ValueError("Invalid direction for ordering")
else:
query = query.order_by(Chat.updated_at.desc())
if skip:
query = query.offset(skip)
if limit:
query = query.limit(limit)
all_chats = query.all()
return [ChatModel.model_validate(chat) for chat in all_chats] return [ChatModel.model_validate(chat) for chat in all_chats]
def get_chat_list_by_user_id( def get_chat_list_by_user_id(
self, self,
user_id: str, user_id: str,
include_archived: bool = False, include_archived: bool = False,
filter: Optional[dict] = None,
skip: int = 0, skip: int = 0,
limit: int = 50, limit: int = 50,
) -> list[ChatModel]: ) -> list[ChatModel]:
@ -401,7 +426,23 @@ class ChatTable:
if not include_archived: if not include_archived:
query = query.filter_by(archived=False) query = query.filter_by(archived=False)
query = query.order_by(Chat.updated_at.desc()) if filter:
query_key = filter.get("query")
if query_key:
query = query.filter(Chat.title.ilike(f"%{query_key}%"))
order_by = filter.get("order_by")
direction = filter.get("direction")
if order_by and direction and getattr(Chat, order_by):
if direction.lower() == "asc":
query = query.order_by(getattr(Chat, order_by).asc())
elif direction.lower() == "desc":
query = query.order_by(getattr(Chat, order_by).desc())
else:
raise ValueError("Invalid direction for ordering")
else:
query = query.order_by(Chat.updated_at.desc())
if skip: if skip:
query = query.offset(skip) query = query.offset(skip)
@ -542,7 +583,9 @@ class ChatTable:
search_text = search_text.lower().strip() search_text = search_text.lower().strip()
if not search_text: if not search_text:
return self.get_chat_list_by_user_id(user_id, include_archived, skip, limit) return self.get_chat_list_by_user_id(
user_id, include_archived, filter={}, skip=skip, limit=limit
)
search_text_words = search_text.split(" ") search_text_words = search_text.split(" ")

View File

@ -108,6 +108,54 @@ class FunctionsTable:
log.exception(f"Error creating a new function: {e}") log.exception(f"Error creating a new function: {e}")
return None return None
def sync_functions(
self, user_id: str, functions: list[FunctionModel]
) -> list[FunctionModel]:
# Synchronize functions for a user by updating existing ones, inserting new ones, and removing those that are no longer present.
try:
with get_db() as db:
# Get existing functions
existing_functions = db.query(Function).all()
existing_ids = {func.id for func in existing_functions}
# Prepare a set of new function IDs
new_function_ids = {func.id for func in functions}
# Update or insert functions
for func in functions:
if func.id in existing_ids:
db.query(Function).filter_by(id=func.id).update(
{
**func.model_dump(),
"user_id": user_id,
"updated_at": int(time.time()),
}
)
else:
new_func = Function(
**{
**func.model_dump(),
"user_id": user_id,
"updated_at": int(time.time()),
}
)
db.add(new_func)
# Remove functions that are no longer present
for func in existing_functions:
if func.id not in new_function_ids:
db.delete(func)
db.commit()
return [
FunctionModel.model_validate(func)
for func in db.query(Function).all()
]
except Exception as e:
log.exception(f"Error syncing functions for user {user_id}: {e}")
return []
def get_function_by_id(self, id: str) -> Optional[FunctionModel]: def get_function_by_id(self, id: str) -> Optional[FunctionModel]:
try: try:
with get_db() as db: with get_db() as db:

View File

@ -207,5 +207,43 @@ class GroupTable:
except Exception: except Exception:
return False return False
def sync_user_groups_by_group_names(
self, user_id: str, group_names: list[str]
) -> bool:
with get_db() as db:
try:
groups = db.query(Group).filter(Group.name.in_(group_names)).all()
group_ids = [group.id for group in groups]
# Remove user from groups not in the new list
existing_groups = self.get_groups_by_member_id(user_id)
for group in existing_groups:
if group.id not in group_ids:
group.user_ids.remove(user_id)
db.query(Group).filter_by(id=group.id).update(
{
"user_ids": group.user_ids,
"updated_at": int(time.time()),
}
)
# Add user to new groups
for group in groups:
if user_id not in group.user_ids:
group.user_ids.append(user_id)
db.query(Group).filter_by(id=group.id).update(
{
"user_ids": group.user_ids,
"updated_at": int(time.time()),
}
)
db.commit()
return True
except Exception as e:
log.exception(e)
return False
Groups = GroupTable() Groups = GroupTable()

View File

@ -226,7 +226,7 @@ class Loader:
api_key=self.kwargs.get("EXTERNAL_DOCUMENT_LOADER_API_KEY"), api_key=self.kwargs.get("EXTERNAL_DOCUMENT_LOADER_API_KEY"),
mime_type=file_content_type, mime_type=file_content_type,
) )
if self.engine == "tika" and self.kwargs.get("TIKA_SERVER_URL"): elif self.engine == "tika" and self.kwargs.get("TIKA_SERVER_URL"):
if self._is_text_file(file_ext, file_content_type): if 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:

View File

@ -1,8 +1,12 @@
import requests import requests
import aiohttp
import asyncio
import logging import logging
import os import os
import sys import sys
import time
from typing import List, Dict, Any from typing import List, Dict, Any
from contextlib import asynccontextmanager
from langchain_core.documents import Document from langchain_core.documents import Document
from open_webui.env import SRC_LOG_LEVELS, GLOBAL_LOG_LEVEL from open_webui.env import SRC_LOG_LEVELS, GLOBAL_LOG_LEVEL
@ -14,18 +18,29 @@ log.setLevel(SRC_LOG_LEVELS["RAG"])
class MistralLoader: class MistralLoader:
""" """
Enhanced Mistral OCR loader with both sync and async support.
Loads documents by processing them through the Mistral OCR API. Loads documents by processing them through the Mistral OCR API.
""" """
BASE_API_URL = "https://api.mistral.ai/v1" BASE_API_URL = "https://api.mistral.ai/v1"
def __init__(self, api_key: str, file_path: str): def __init__(
self,
api_key: str,
file_path: str,
timeout: int = 300, # 5 minutes default
max_retries: int = 3,
enable_debug_logging: bool = False,
):
""" """
Initializes the loader. Initializes the loader with enhanced features.
Args: Args:
api_key: Your Mistral API key. api_key: Your Mistral API key.
file_path: The local path to the PDF file to process. file_path: The local path to the PDF file to process.
timeout: Request timeout in seconds.
max_retries: Maximum number of retry attempts.
enable_debug_logging: Enable detailed debug logs.
""" """
if not api_key: if not api_key:
raise ValueError("API key cannot be empty.") raise ValueError("API key cannot be empty.")
@ -34,7 +49,23 @@ class MistralLoader:
self.api_key = api_key self.api_key = api_key
self.file_path = file_path self.file_path = file_path
self.headers = {"Authorization": f"Bearer {self.api_key}"} self.timeout = timeout
self.max_retries = max_retries
self.debug = enable_debug_logging
# Pre-compute file info for performance
self.file_name = os.path.basename(file_path)
self.file_size = os.path.getsize(file_path)
self.headers = {
"Authorization": f"Bearer {self.api_key}",
"User-Agent": "OpenWebUI-MistralLoader/2.0",
}
def _debug_log(self, message: str, *args) -> None:
"""Conditional debug logging for performance."""
if self.debug:
log.debug(message, *args)
def _handle_response(self, response: requests.Response) -> Dict[str, Any]: def _handle_response(self, response: requests.Response) -> Dict[str, Any]:
"""Checks response status and returns JSON content.""" """Checks response status and returns JSON content."""
@ -54,24 +85,89 @@ class MistralLoader:
log.error(f"JSON decode error: {json_err} - Response: {response.text}") log.error(f"JSON decode error: {json_err} - Response: {response.text}")
raise # Re-raise after logging raise # Re-raise after logging
async def _handle_response_async(
self, response: aiohttp.ClientResponse
) -> Dict[str, Any]:
"""Async version of response handling with better error info."""
try:
response.raise_for_status()
# Check content type
content_type = response.headers.get("content-type", "")
if "application/json" not in content_type:
if response.status == 204:
return {}
text = await response.text()
raise ValueError(
f"Unexpected content type: {content_type}, body: {text[:200]}..."
)
return await response.json()
except aiohttp.ClientResponseError as e:
error_text = await response.text() if response else "No response"
log.error(f"HTTP {e.status}: {e.message} - Response: {error_text[:500]}")
raise
except aiohttp.ClientError as e:
log.error(f"Client error: {e}")
raise
except Exception as e:
log.error(f"Unexpected error processing response: {e}")
raise
def _retry_request_sync(self, request_func, *args, **kwargs):
"""Synchronous retry logic with exponential backoff."""
for attempt in range(self.max_retries):
try:
return request_func(*args, **kwargs)
except (requests.exceptions.RequestException, Exception) as e:
if attempt == self.max_retries - 1:
raise
wait_time = (2**attempt) + 0.5
log.warning(
f"Request failed (attempt {attempt + 1}/{self.max_retries}): {e}. Retrying in {wait_time}s..."
)
time.sleep(wait_time)
async def _retry_request_async(self, request_func, *args, **kwargs):
"""Async retry logic with exponential backoff."""
for attempt in range(self.max_retries):
try:
return await request_func(*args, **kwargs)
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
if attempt == self.max_retries - 1:
raise
wait_time = (2**attempt) + 0.5
log.warning(
f"Request failed (attempt {attempt + 1}/{self.max_retries}): {e}. Retrying in {wait_time}s..."
)
await asyncio.sleep(wait_time)
def _upload_file(self) -> str: def _upload_file(self) -> str:
"""Uploads the file to Mistral for OCR processing.""" """Uploads the file to Mistral for OCR processing (sync version)."""
log.info("Uploading file to Mistral API") log.info("Uploading file to Mistral API")
url = f"{self.BASE_API_URL}/files" url = f"{self.BASE_API_URL}/files"
file_name = os.path.basename(self.file_path) file_name = os.path.basename(self.file_path)
try: def upload_request():
with open(self.file_path, "rb") as f: with open(self.file_path, "rb") as f:
files = {"file": (file_name, f, "application/pdf")} files = {"file": (file_name, f, "application/pdf")}
data = {"purpose": "ocr"} data = {"purpose": "ocr"}
upload_headers = self.headers.copy() # Avoid modifying self.headers
response = requests.post( response = requests.post(
url, headers=upload_headers, files=files, data=data url,
headers=self.headers,
files=files,
data=data,
timeout=self.timeout,
) )
response_data = self._handle_response(response) return self._handle_response(response)
try:
response_data = self._retry_request_sync(upload_request)
file_id = response_data.get("id") file_id = response_data.get("id")
if not file_id: if not file_id:
raise ValueError("File ID not found in upload response.") raise ValueError("File ID not found in upload response.")
@ -81,16 +177,66 @@ class MistralLoader:
log.error(f"Failed to upload file: {e}") log.error(f"Failed to upload file: {e}")
raise raise
async def _upload_file_async(self, session: aiohttp.ClientSession) -> str:
"""Async file upload with streaming for better memory efficiency."""
url = f"{self.BASE_API_URL}/files"
async def upload_request():
# Create multipart writer for streaming upload
writer = aiohttp.MultipartWriter("form-data")
# Add purpose field
purpose_part = writer.append("ocr")
purpose_part.set_content_disposition("form-data", name="purpose")
# Add file part with streaming
file_part = writer.append_payload(
aiohttp.streams.FilePayload(
self.file_path,
filename=self.file_name,
content_type="application/pdf",
)
)
file_part.set_content_disposition(
"form-data", name="file", filename=self.file_name
)
self._debug_log(
f"Uploading file: {self.file_name} ({self.file_size:,} bytes)"
)
async with session.post(
url,
data=writer,
headers=self.headers,
timeout=aiohttp.ClientTimeout(total=self.timeout),
) as response:
return await self._handle_response_async(response)
response_data = await self._retry_request_async(upload_request)
file_id = response_data.get("id")
if not file_id:
raise ValueError("File ID not found in upload response.")
log.info(f"File uploaded successfully. File ID: {file_id}")
return file_id
def _get_signed_url(self, file_id: str) -> str: def _get_signed_url(self, file_id: str) -> str:
"""Retrieves a temporary signed URL for the uploaded file.""" """Retrieves a temporary signed URL for the uploaded file (sync version)."""
log.info(f"Getting signed URL for file ID: {file_id}") log.info(f"Getting signed URL for file ID: {file_id}")
url = f"{self.BASE_API_URL}/files/{file_id}/url" url = f"{self.BASE_API_URL}/files/{file_id}/url"
params = {"expiry": 1} params = {"expiry": 1}
signed_url_headers = {**self.headers, "Accept": "application/json"} signed_url_headers = {**self.headers, "Accept": "application/json"}
def url_request():
response = requests.get(
url, headers=signed_url_headers, params=params, timeout=self.timeout
)
return self._handle_response(response)
try: try:
response = requests.get(url, headers=signed_url_headers, params=params) response_data = self._retry_request_sync(url_request)
response_data = self._handle_response(response)
signed_url = response_data.get("url") signed_url = response_data.get("url")
if not signed_url: if not signed_url:
raise ValueError("Signed URL not found in response.") raise ValueError("Signed URL not found in response.")
@ -100,8 +246,36 @@ class MistralLoader:
log.error(f"Failed to get signed URL: {e}") log.error(f"Failed to get signed URL: {e}")
raise raise
async def _get_signed_url_async(
self, session: aiohttp.ClientSession, file_id: str
) -> str:
"""Async signed URL retrieval."""
url = f"{self.BASE_API_URL}/files/{file_id}/url"
params = {"expiry": 1}
headers = {**self.headers, "Accept": "application/json"}
async def url_request():
self._debug_log(f"Getting signed URL for file ID: {file_id}")
async with session.get(
url,
headers=headers,
params=params,
timeout=aiohttp.ClientTimeout(total=self.timeout),
) as response:
return await self._handle_response_async(response)
response_data = await self._retry_request_async(url_request)
signed_url = response_data.get("url")
if not signed_url:
raise ValueError("Signed URL not found in response.")
self._debug_log("Signed URL received successfully")
return signed_url
def _process_ocr(self, signed_url: str) -> Dict[str, Any]: def _process_ocr(self, signed_url: str) -> Dict[str, Any]:
"""Sends the signed URL to the OCR endpoint for processing.""" """Sends the signed URL to the OCR endpoint for processing (sync version)."""
log.info("Processing OCR via Mistral API") log.info("Processing OCR via Mistral API")
url = f"{self.BASE_API_URL}/ocr" url = f"{self.BASE_API_URL}/ocr"
ocr_headers = { ocr_headers = {
@ -118,43 +292,198 @@ class MistralLoader:
"include_image_base64": False, "include_image_base64": False,
} }
def ocr_request():
response = requests.post(
url, headers=ocr_headers, json=payload, timeout=self.timeout
)
return self._handle_response(response)
try: try:
response = requests.post(url, headers=ocr_headers, json=payload) ocr_response = self._retry_request_sync(ocr_request)
ocr_response = self._handle_response(response)
log.info("OCR processing done.") log.info("OCR processing done.")
log.debug("OCR response: %s", ocr_response) self._debug_log("OCR response: %s", ocr_response)
return ocr_response return ocr_response
except Exception as e: except Exception as e:
log.error(f"Failed during OCR processing: {e}") log.error(f"Failed during OCR processing: {e}")
raise raise
async def _process_ocr_async(
self, session: aiohttp.ClientSession, signed_url: str
) -> Dict[str, Any]:
"""Async OCR processing with timing metrics."""
url = f"{self.BASE_API_URL}/ocr"
headers = {
**self.headers,
"Content-Type": "application/json",
"Accept": "application/json",
}
payload = {
"model": "mistral-ocr-latest",
"document": {
"type": "document_url",
"document_url": signed_url,
},
"include_image_base64": False,
}
async def ocr_request():
log.info("Starting OCR processing via Mistral API")
start_time = time.time()
async with session.post(
url,
json=payload,
headers=headers,
timeout=aiohttp.ClientTimeout(total=self.timeout),
) as response:
ocr_response = await self._handle_response_async(response)
processing_time = time.time() - start_time
log.info(f"OCR processing completed in {processing_time:.2f}s")
return ocr_response
return await self._retry_request_async(ocr_request)
def _delete_file(self, file_id: str) -> None: def _delete_file(self, file_id: str) -> None:
"""Deletes the file from Mistral storage.""" """Deletes the file from Mistral storage (sync version)."""
log.info(f"Deleting uploaded file ID: {file_id}") log.info(f"Deleting uploaded file ID: {file_id}")
url = f"{self.BASE_API_URL}/files/{file_id}" url = f"{self.BASE_API_URL}/files/{file_id}"
# No specific Accept header needed, default or Authorization is usually sufficient
try: try:
response = requests.delete(url, headers=self.headers) response = requests.delete(url, headers=self.headers, timeout=30)
delete_response = self._handle_response( delete_response = self._handle_response(response)
response log.info(f"File deleted successfully: {delete_response}")
) # Check status, ignore response body unless needed
log.info(
f"File deleted successfully: {delete_response}"
) # Log the response if available
except Exception as e: except Exception as e:
# Log error but don't necessarily halt execution if deletion fails # Log error but don't necessarily halt execution if deletion fails
log.error(f"Failed to delete file ID {file_id}: {e}") log.error(f"Failed to delete file ID {file_id}: {e}")
# Depending on requirements, you might choose to raise the error here
async def _delete_file_async(
self, session: aiohttp.ClientSession, file_id: str
) -> None:
"""Async file deletion with error tolerance."""
try:
async def delete_request():
self._debug_log(f"Deleting file ID: {file_id}")
async with session.delete(
url=f"{self.BASE_API_URL}/files/{file_id}",
headers=self.headers,
timeout=aiohttp.ClientTimeout(
total=30
), # Shorter timeout for cleanup
) as response:
return await self._handle_response_async(response)
await self._retry_request_async(delete_request)
self._debug_log(f"File {file_id} deleted successfully")
except Exception as e:
# Don't fail the entire process if cleanup fails
log.warning(f"Failed to delete file ID {file_id}: {e}")
@asynccontextmanager
async def _get_session(self):
"""Context manager for HTTP session with optimized settings."""
connector = aiohttp.TCPConnector(
limit=10, # Total connection limit
limit_per_host=5, # Per-host connection limit
ttl_dns_cache=300, # DNS cache TTL
use_dns_cache=True,
keepalive_timeout=30,
enable_cleanup_closed=True,
)
async with aiohttp.ClientSession(
connector=connector,
timeout=aiohttp.ClientTimeout(total=self.timeout),
headers={"User-Agent": "OpenWebUI-MistralLoader/2.0"},
) as session:
yield session
def _process_results(self, ocr_response: Dict[str, Any]) -> List[Document]:
"""Process OCR results into Document objects with enhanced metadata."""
pages_data = ocr_response.get("pages")
if not pages_data:
log.warning("No pages found in OCR response.")
return [
Document(
page_content="No text content found", metadata={"error": "no_pages"}
)
]
documents = []
total_pages = len(pages_data)
skipped_pages = 0
for page_data in pages_data:
page_content = page_data.get("markdown")
page_index = page_data.get("index") # API uses 0-based index
if page_content is not None and page_index is not None:
# Clean up content efficiently
cleaned_content = (
page_content.strip()
if isinstance(page_content, str)
else str(page_content)
)
if cleaned_content: # Only add non-empty pages
documents.append(
Document(
page_content=cleaned_content,
metadata={
"page": page_index, # 0-based index from API
"page_label": page_index
+ 1, # 1-based label for convenience
"total_pages": total_pages,
"file_name": self.file_name,
"file_size": self.file_size,
"processing_engine": "mistral-ocr",
},
)
)
else:
skipped_pages += 1
self._debug_log(f"Skipping empty page {page_index}")
else:
skipped_pages += 1
self._debug_log(
f"Skipping page due to missing 'markdown' or 'index'. Data: {page_data}"
)
if skipped_pages > 0:
log.info(
f"Processed {len(documents)} pages, skipped {skipped_pages} empty/invalid pages"
)
if not documents:
# Case where pages existed but none had valid markdown/index
log.warning(
"OCR response contained pages, but none had valid content/index."
)
return [
Document(
page_content="No valid text content found in document",
metadata={"error": "no_valid_pages", "total_pages": total_pages},
)
]
return documents
def load(self) -> List[Document]: def load(self) -> List[Document]:
""" """
Executes the full OCR workflow: upload, get URL, process OCR, delete file. Executes the full OCR workflow: upload, get URL, process OCR, delete file.
Synchronous version for backward compatibility.
Returns: Returns:
A list of Document objects, one for each page processed. A list of Document objects, one for each page processed.
""" """
file_id = None file_id = None
start_time = time.time()
try: try:
# 1. Upload file # 1. Upload file
file_id = self._upload_file() file_id = self._upload_file()
@ -166,53 +495,30 @@ class MistralLoader:
ocr_response = self._process_ocr(signed_url) ocr_response = self._process_ocr(signed_url)
# 4. Process results # 4. Process results
pages_data = ocr_response.get("pages") documents = self._process_results(ocr_response)
if not pages_data:
log.warning("No pages found in OCR response.")
return [Document(page_content="No text content found", metadata={})]
documents = [] total_time = time.time() - start_time
total_pages = len(pages_data) log.info(
for page_data in pages_data: f"Sync OCR workflow completed in {total_time:.2f}s, produced {len(documents)} documents"
page_content = page_data.get("markdown") )
page_index = page_data.get("index") # API uses 0-based index
if page_content is not None and page_index is not None:
documents.append(
Document(
page_content=page_content,
metadata={
"page": page_index, # 0-based index from API
"page_label": page_index
+ 1, # 1-based label for convenience
"total_pages": total_pages,
# Add other relevant metadata from page_data if available/needed
# e.g., page_data.get('width'), page_data.get('height')
},
)
)
else:
log.warning(
f"Skipping page due to missing 'markdown' or 'index'. Data: {page_data}"
)
if not documents:
# Case where pages existed but none had valid markdown/index
log.warning(
"OCR response contained pages, but none had valid content/index."
)
return [
Document(
page_content="No text content found in valid pages", metadata={}
)
]
return documents return documents
except Exception as e: except Exception as e:
log.error(f"An error occurred during the loading process: {e}") total_time = time.time() - start_time
# Return an empty list or a specific error document on failure log.error(
return [Document(page_content=f"Error during processing: {e}", metadata={})] f"An error occurred during the loading process after {total_time:.2f}s: {e}"
)
# Return an error document on failure
return [
Document(
page_content=f"Error during processing: {e}",
metadata={
"error": "processing_failed",
"file_name": self.file_name,
},
)
]
finally: finally:
# 5. Delete file (attempt even if prior steps failed after upload) # 5. Delete file (attempt even if prior steps failed after upload)
if file_id: if file_id:
@ -223,3 +529,105 @@ class MistralLoader:
log.error( log.error(
f"Cleanup error: Could not delete file ID {file_id}. Reason: {del_e}" f"Cleanup error: Could not delete file ID {file_id}. Reason: {del_e}"
) )
async def load_async(self) -> List[Document]:
"""
Asynchronous OCR workflow execution with optimized performance.
Returns:
A list of Document objects, one for each page processed.
"""
file_id = None
start_time = time.time()
try:
async with self._get_session() as session:
# 1. Upload file with streaming
file_id = await self._upload_file_async(session)
# 2. Get signed URL
signed_url = await self._get_signed_url_async(session, file_id)
# 3. Process OCR
ocr_response = await self._process_ocr_async(session, signed_url)
# 4. Process results
documents = self._process_results(ocr_response)
total_time = time.time() - start_time
log.info(
f"Async OCR workflow completed in {total_time:.2f}s, produced {len(documents)} documents"
)
return documents
except Exception as e:
total_time = time.time() - start_time
log.error(f"Async OCR workflow failed after {total_time:.2f}s: {e}")
return [
Document(
page_content=f"Error during OCR processing: {e}",
metadata={
"error": "processing_failed",
"file_name": self.file_name,
},
)
]
finally:
# 5. Cleanup - always attempt file deletion
if file_id:
try:
async with self._get_session() as session:
await self._delete_file_async(session, file_id)
except Exception as cleanup_error:
log.error(f"Cleanup failed for file ID {file_id}: {cleanup_error}")
@staticmethod
async def load_multiple_async(
loaders: List["MistralLoader"],
) -> List[List[Document]]:
"""
Process multiple files concurrently for maximum performance.
Args:
loaders: List of MistralLoader instances
Returns:
List of document lists, one for each loader
"""
if not loaders:
return []
log.info(f"Starting concurrent processing of {len(loaders)} files")
start_time = time.time()
# Process all files concurrently
tasks = [loader.load_async() for loader in loaders]
results = await asyncio.gather(*tasks, return_exceptions=True)
# Handle any exceptions in results
processed_results = []
for i, result in enumerate(results):
if isinstance(result, Exception):
log.error(f"File {i} failed: {result}")
processed_results.append(
[
Document(
page_content=f"Error processing file: {result}",
metadata={
"error": "batch_processing_failed",
"file_index": i,
},
)
]
)
else:
processed_results.append(result)
total_time = time.time() - start_time
total_docs = sum(len(docs) for docs in processed_results)
log.info(
f"Batch processing completed in {total_time:.2f}s, produced {total_docs} total documents"
)
return processed_results

View File

@ -0,0 +1,8 @@
from abc import ABC, abstractmethod
from typing import Optional, List, Tuple
class BaseReranker(ABC):
@abstractmethod
def predict(self, sentences: List[Tuple[str, str]]) -> Optional[List[float]]:
pass

View File

@ -7,11 +7,13 @@ from colbert.modeling.checkpoint import Checkpoint
from open_webui.env import SRC_LOG_LEVELS from open_webui.env import SRC_LOG_LEVELS
from open_webui.retrieval.models.base_reranker import BaseReranker
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"]) log.setLevel(SRC_LOG_LEVELS["RAG"])
class ColBERT: class ColBERT(BaseReranker):
def __init__(self, name, **kwargs) -> None: def __init__(self, name, **kwargs) -> None:
log.info("ColBERT: Loading model", name) log.info("ColBERT: Loading model", name)
self.device = "cuda" if torch.cuda.is_available() else "cpu" self.device = "cuda" if torch.cuda.is_available() else "cpu"

View File

@ -3,12 +3,14 @@ import requests
from typing import Optional, List, Tuple from typing import Optional, List, Tuple
from open_webui.env import SRC_LOG_LEVELS from open_webui.env import SRC_LOG_LEVELS
from open_webui.retrieval.models.base_reranker import BaseReranker
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"]) log.setLevel(SRC_LOG_LEVELS["RAG"])
class ExternalReranker: class ExternalReranker(BaseReranker):
def __init__( def __init__(
self, self,
api_key: str, api_key: str,

View File

@ -116,6 +116,7 @@ def query_doc_with_hybrid_search(
reranking_function, reranking_function,
k_reranker: int, k_reranker: int,
r: float, r: float,
hybrid_bm25_weight: float,
) -> dict: ) -> dict:
try: try:
log.debug(f"query_doc_with_hybrid_search:doc {collection_name}") log.debug(f"query_doc_with_hybrid_search:doc {collection_name}")
@ -131,9 +132,20 @@ def query_doc_with_hybrid_search(
top_k=k, top_k=k,
) )
ensemble_retriever = EnsembleRetriever( if hybrid_bm25_weight <= 0:
retrievers=[bm25_retriever, vector_search_retriever], weights=[0.5, 0.5] ensemble_retriever = EnsembleRetriever(
) retrievers=[vector_search_retriever], weights=[1.0]
)
elif hybrid_bm25_weight >= 1:
ensemble_retriever = EnsembleRetriever(
retrievers=[bm25_retriever], weights=[1.0]
)
else:
ensemble_retriever = EnsembleRetriever(
retrievers=[bm25_retriever, vector_search_retriever],
weights=[hybrid_bm25_weight, 1.0 - hybrid_bm25_weight],
)
compressor = RerankCompressor( compressor = RerankCompressor(
embedding_function=embedding_function, embedding_function=embedding_function,
top_n=k_reranker, top_n=k_reranker,
@ -313,6 +325,7 @@ def query_collection_with_hybrid_search(
reranking_function, reranking_function,
k_reranker: int, k_reranker: int,
r: float, r: float,
hybrid_bm25_weight: float,
) -> dict: ) -> dict:
results = [] results = []
error = False error = False
@ -346,6 +359,7 @@ def query_collection_with_hybrid_search(
reranking_function=reranking_function, reranking_function=reranking_function,
k_reranker=k_reranker, k_reranker=k_reranker,
r=r, r=r,
hybrid_bm25_weight=hybrid_bm25_weight,
) )
return result, None return result, None
except Exception as e: except Exception as e:
@ -433,6 +447,7 @@ def get_sources_from_files(
reranking_function, reranking_function,
k_reranker, k_reranker,
r, r,
hybrid_bm25_weight,
hybrid_search, hybrid_search,
full_context=False, full_context=False,
): ):
@ -550,6 +565,7 @@ def get_sources_from_files(
reranking_function=reranking_function, reranking_function=reranking_function,
k_reranker=k_reranker, k_reranker=k_reranker,
r=r, r=r,
hybrid_bm25_weight=hybrid_bm25_weight,
) )
except Exception as e: except Exception as e:
log.debug( log.debug(

View File

@ -1,13 +1,12 @@
from typing import Optional, List, Dict, Any, Union from typing import Optional, List, Dict, Any, Union
import logging import logging
import time # for measuring elapsed time import time # for measuring elapsed time
from pinecone import ServerlessSpec from pinecone import Pinecone, ServerlessSpec
import asyncio # for async upserts import asyncio # for async upserts
import functools # for partial binding in async tasks import functools # for partial binding in async tasks
import concurrent.futures # for parallel batch upserts import concurrent.futures # for parallel batch upserts
from pinecone.grpc import PineconeGRPC # use gRPC client for faster upserts
from open_webui.retrieval.vector.main import ( from open_webui.retrieval.vector.main import (
VectorDBBase, VectorDBBase,
@ -47,10 +46,8 @@ class PineconeClient(VectorDBBase):
self.metric = PINECONE_METRIC self.metric = PINECONE_METRIC
self.cloud = PINECONE_CLOUD self.cloud = PINECONE_CLOUD
# Initialize Pinecone gRPC client for improved performance # Initialize Pinecone client for improved performance
self.client = PineconeGRPC( self.client = Pinecone(api_key=self.api_key)
api_key=self.api_key, environment=self.environment, cloud=self.cloud
)
# Persistent executor for batch operations # Persistent executor for batch operations
self._executor = concurrent.futures.ThreadPoolExecutor(max_workers=5) self._executor = concurrent.futures.ThreadPoolExecutor(max_workers=5)
@ -147,8 +144,8 @@ class PineconeClient(VectorDBBase):
metadatas = [] metadatas = []
for match in matches: for match in matches:
metadata = match.get("metadata", {}) metadata = getattr(match, "metadata", {}) or {}
ids.append(match["id"]) ids.append(match.id if hasattr(match, "id") else match["id"])
documents.append(metadata.get("text", "")) documents.append(metadata.get("text", ""))
metadatas.append(metadata) metadatas.append(metadata)
@ -174,7 +171,8 @@ class PineconeClient(VectorDBBase):
filter={"collection_name": collection_name_with_prefix}, filter={"collection_name": collection_name_with_prefix},
include_metadata=False, include_metadata=False,
) )
return len(response.matches) > 0 matches = getattr(response, "matches", []) or []
return len(matches) > 0
except Exception as e: except Exception as e:
log.exception( log.exception(
f"Error checking collection '{collection_name_with_prefix}': {e}" f"Error checking collection '{collection_name_with_prefix}': {e}"
@ -321,32 +319,6 @@ class PineconeClient(VectorDBBase):
f"Successfully async upserted {len(points)} vectors in batches into '{collection_name_with_prefix}'" f"Successfully async upserted {len(points)} vectors in batches into '{collection_name_with_prefix}'"
) )
def streaming_upsert(self, collection_name: str, items: List[VectorItem]) -> None:
"""Perform a streaming upsert over gRPC for performance testing."""
if not items:
log.warning("No items to upsert via streaming")
return
collection_name_with_prefix = self._get_collection_name_with_prefix(
collection_name
)
points = self._create_points(items, collection_name_with_prefix)
# Open a streaming upsert channel
stream = self.index.streaming_upsert()
try:
for point in points:
# send each point over the stream
stream.send(point)
# close the stream to finalize
stream.close()
log.info(
f"Successfully streamed upsert of {len(points)} vectors into '{collection_name_with_prefix}'"
)
except Exception as e:
log.error(f"Error during streaming upsert: {e}")
raise
def search( def search(
self, collection_name: str, vectors: List[List[Union[float, int]]], limit: int self, collection_name: str, vectors: List[List[Union[float, int]]], limit: int
) -> Optional[SearchResult]: ) -> Optional[SearchResult]:
@ -374,7 +346,8 @@ class PineconeClient(VectorDBBase):
filter={"collection_name": collection_name_with_prefix}, filter={"collection_name": collection_name_with_prefix},
) )
if not query_response.matches: matches = getattr(query_response, "matches", []) or []
if not matches:
# Return empty result if no matches # Return empty result if no matches
return SearchResult( return SearchResult(
ids=[[]], ids=[[]],
@ -384,13 +357,13 @@ class PineconeClient(VectorDBBase):
) )
# Convert to GetResult format # Convert to GetResult format
get_result = self._result_to_get_result(query_response.matches) get_result = self._result_to_get_result(matches)
# Calculate normalized distances based on metric # Calculate normalized distances based on metric
distances = [ distances = [
[ [
self._normalize_distance(match.score) self._normalize_distance(getattr(match, "score", 0.0))
for match in query_response.matches for match in matches
] ]
] ]
@ -432,7 +405,8 @@ class PineconeClient(VectorDBBase):
include_metadata=True, include_metadata=True,
) )
return self._result_to_get_result(query_response.matches) matches = getattr(query_response, "matches", []) or []
return self._result_to_get_result(matches)
except Exception as e: except Exception as e:
log.error(f"Error querying collection '{collection_name}': {e}") log.error(f"Error querying collection '{collection_name}': {e}")
@ -456,7 +430,8 @@ class PineconeClient(VectorDBBase):
filter={"collection_name": collection_name_with_prefix}, filter={"collection_name": collection_name_with_prefix},
) )
return self._result_to_get_result(query_response.matches) matches = getattr(query_response, "matches", []) or []
return self._result_to_get_result(matches)
except Exception as e: except Exception as e:
log.error(f"Error getting collection '{collection_name}': {e}") log.error(f"Error getting collection '{collection_name}': {e}")
@ -516,12 +491,12 @@ class PineconeClient(VectorDBBase):
raise raise
def close(self): def close(self):
"""Shut down the gRPC channel and thread pool.""" """Shut down resources."""
try: try:
self.client.close() # The new Pinecone client doesn't need explicit closing
log.info("Pinecone gRPC channel closed.") pass
except Exception as e: except Exception as e:
log.warning(f"Failed to close Pinecone gRPC channel: {e}") log.warning(f"Failed to clean up Pinecone resources: {e}")
self._executor.shutdown(wait=True) self._executor.shutdown(wait=True)
def __enter__(self): def __enter__(self):

View File

@ -42,7 +42,9 @@ def search_searchapi(
results = get_filtered_results(results, filter_list) results = get_filtered_results(results, filter_list)
return [ return [
SearchResult( SearchResult(
link=result["link"], title=result["title"], snippet=result["snippet"] link=result["link"],
title=result.get("title"),
snippet=result.get("snippet"),
) )
for result in results[:count] for result in results[:count]
] ]

View File

@ -42,7 +42,9 @@ def search_serpapi(
results = get_filtered_results(results, filter_list) results = get_filtered_results(results, filter_list)
return [ return [
SearchResult( SearchResult(
link=result["link"], title=result["title"], snippet=result["snippet"] link=result["link"],
title=result.get("title"),
snippet=result.get("snippet"),
) )
for result in results[:count] for result in results[:count]
] ]

View File

@ -517,7 +517,6 @@ class SafeWebBaseLoader(WebBaseLoader):
async with session.get( async with session.get(
url, url,
**(self.requests_kwargs | kwargs), **(self.requests_kwargs | kwargs),
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as response: ) as response:
if self.raise_for_status: if self.raise_for_status:
response.raise_for_status() response.raise_for_status()

View File

@ -8,6 +8,8 @@ from pathlib import Path
from pydub import AudioSegment from pydub import AudioSegment
from pydub.silence import split_on_silence from pydub.silence import split_on_silence
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from typing import Optional
import aiohttp import aiohttp
import aiofiles import aiofiles
@ -18,6 +20,7 @@ from fastapi import (
Depends, Depends,
FastAPI, FastAPI,
File, File,
Form,
HTTPException, HTTPException,
Request, Request,
UploadFile, UploadFile,
@ -527,11 +530,13 @@ async def speech(request: Request, user=Depends(get_verified_user)):
return FileResponse(file_path) return FileResponse(file_path)
def transcription_handler(request, file_path): def transcription_handler(request, file_path, metadata):
filename = os.path.basename(file_path) filename = os.path.basename(file_path)
file_dir = os.path.dirname(file_path) file_dir = os.path.dirname(file_path)
id = filename.split(".")[0] id = filename.split(".")[0]
metadata = metadata or {}
if request.app.state.config.STT_ENGINE == "": if request.app.state.config.STT_ENGINE == "":
if request.app.state.faster_whisper_model is None: if request.app.state.faster_whisper_model is None:
request.app.state.faster_whisper_model = set_faster_whisper_model( request.app.state.faster_whisper_model = set_faster_whisper_model(
@ -543,7 +548,7 @@ def transcription_handler(request, file_path):
file_path, file_path,
beam_size=5, beam_size=5,
vad_filter=request.app.state.config.WHISPER_VAD_FILTER, vad_filter=request.app.state.config.WHISPER_VAD_FILTER,
language=WHISPER_LANGUAGE, language=metadata.get("language") or WHISPER_LANGUAGE,
) )
log.info( log.info(
"Detected language '%s' with probability %f" "Detected language '%s' with probability %f"
@ -569,7 +574,14 @@ def transcription_handler(request, file_path):
"Authorization": f"Bearer {request.app.state.config.STT_OPENAI_API_KEY}" "Authorization": f"Bearer {request.app.state.config.STT_OPENAI_API_KEY}"
}, },
files={"file": (filename, open(file_path, "rb"))}, files={"file": (filename, open(file_path, "rb"))},
data={"model": request.app.state.config.STT_MODEL}, data={
"model": request.app.state.config.STT_MODEL,
**(
{"language": metadata.get("language")}
if metadata.get("language")
else {}
),
},
) )
r.raise_for_status() r.raise_for_status()
@ -777,8 +789,8 @@ def transcription_handler(request, file_path):
) )
def transcribe(request: Request, file_path): def transcribe(request: Request, file_path: str, metadata: Optional[dict] = None):
log.info(f"transcribe: {file_path}") log.info(f"transcribe: {file_path} {metadata}")
if is_audio_conversion_required(file_path): if is_audio_conversion_required(file_path):
file_path = convert_audio_to_mp3(file_path) file_path = convert_audio_to_mp3(file_path)
@ -804,7 +816,7 @@ def transcribe(request: Request, file_path):
with ThreadPoolExecutor() as executor: with ThreadPoolExecutor() as executor:
# Submit tasks for each chunk_path # Submit tasks for each chunk_path
futures = [ futures = [
executor.submit(transcription_handler, request, chunk_path) executor.submit(transcription_handler, request, chunk_path, metadata)
for chunk_path in chunk_paths for chunk_path in chunk_paths
] ]
# Gather results as they complete # Gather results as they complete
@ -812,10 +824,9 @@ def transcribe(request: Request, file_path):
try: try:
results.append(future.result()) results.append(future.result())
except Exception as transcribe_exc: except Exception as transcribe_exc:
log.exception(f"Error transcribing chunk: {transcribe_exc}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Error during transcription.", detail=f"Error transcribing chunk: {transcribe_exc}",
) )
finally: finally:
# Clean up only the temporary chunks, never the original file # Clean up only the temporary chunks, never the original file
@ -897,6 +908,7 @@ def split_audio(file_path, max_bytes, format="mp3", bitrate="32k"):
def transcription( def transcription(
request: Request, request: Request,
file: UploadFile = File(...), file: UploadFile = File(...),
language: Optional[str] = Form(None),
user=Depends(get_verified_user), user=Depends(get_verified_user),
): ):
log.info(f"file.content_type: {file.content_type}") log.info(f"file.content_type: {file.content_type}")
@ -926,7 +938,12 @@ def transcription(
f.write(contents) f.write(contents)
try: try:
result = transcribe(request, file_path) metadata = None
if language:
metadata = {"language": language}
result = transcribe(request, file_path, metadata)
return { return {
**result, **result,

View File

@ -19,12 +19,14 @@ from open_webui.models.auths import (
UserResponse, UserResponse,
) )
from open_webui.models.users import Users from open_webui.models.users import Users
from open_webui.models.groups import Groups
from open_webui.constants import ERROR_MESSAGES, WEBHOOK_MESSAGES from open_webui.constants import ERROR_MESSAGES, WEBHOOK_MESSAGES
from open_webui.env import ( from open_webui.env import (
WEBUI_AUTH, WEBUI_AUTH,
WEBUI_AUTH_TRUSTED_EMAIL_HEADER, WEBUI_AUTH_TRUSTED_EMAIL_HEADER,
WEBUI_AUTH_TRUSTED_NAME_HEADER, WEBUI_AUTH_TRUSTED_NAME_HEADER,
WEBUI_AUTH_TRUSTED_GROUPS_HEADER,
WEBUI_AUTH_COOKIE_SAME_SITE, WEBUI_AUTH_COOKIE_SAME_SITE,
WEBUI_AUTH_COOKIE_SECURE, WEBUI_AUTH_COOKIE_SECURE,
WEBUI_AUTH_SIGNOUT_REDIRECT_URL, WEBUI_AUTH_SIGNOUT_REDIRECT_URL,
@ -299,7 +301,7 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
500, detail="Internal error occurred during LDAP user creation." 500, detail="Internal error occurred during LDAP user creation."
) )
user = Auths.authenticate_user_by_trusted_header(email) user = Auths.authenticate_user_by_email(email)
if user: if user:
expires_delta = parse_duration(request.app.state.config.JWT_EXPIRES_IN) expires_delta = parse_duration(request.app.state.config.JWT_EXPIRES_IN)
@ -363,21 +365,29 @@ async def signin(request: Request, response: Response, form_data: SigninForm):
if WEBUI_AUTH_TRUSTED_EMAIL_HEADER not in request.headers: if WEBUI_AUTH_TRUSTED_EMAIL_HEADER not in request.headers:
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_TRUSTED_HEADER) raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_TRUSTED_HEADER)
trusted_email = request.headers[WEBUI_AUTH_TRUSTED_EMAIL_HEADER].lower() email = request.headers[WEBUI_AUTH_TRUSTED_EMAIL_HEADER].lower()
trusted_name = trusted_email name = email
if WEBUI_AUTH_TRUSTED_NAME_HEADER: if WEBUI_AUTH_TRUSTED_NAME_HEADER:
trusted_name = request.headers.get( name = request.headers.get(WEBUI_AUTH_TRUSTED_NAME_HEADER, email)
WEBUI_AUTH_TRUSTED_NAME_HEADER, trusted_email
) if not Users.get_user_by_email(email.lower()):
if not Users.get_user_by_email(trusted_email.lower()):
await signup( await signup(
request, request,
response, response,
SignupForm( SignupForm(email=email, password=str(uuid.uuid4()), name=name),
email=trusted_email, password=str(uuid.uuid4()), name=trusted_name
),
) )
user = Auths.authenticate_user_by_trusted_header(trusted_email)
user = Auths.authenticate_user_by_email(email)
if WEBUI_AUTH_TRUSTED_GROUPS_HEADER and user and user.role != "admin":
group_names = request.headers.get(
WEBUI_AUTH_TRUSTED_GROUPS_HEADER, ""
).split(",")
group_names = [name.strip() for name in group_names if name.strip()]
if group_names:
Groups.sync_user_groups_by_group_names(user.id, group_names)
elif WEBUI_AUTH == False: elif WEBUI_AUTH == False:
admin_email = "admin@localhost" admin_email = "admin@localhost"
admin_password = "admin" admin_password = "admin"

View File

@ -76,17 +76,34 @@ async def delete_all_user_chats(request: Request, user=Depends(get_verified_user
@router.get("/list/user/{user_id}", response_model=list[ChatTitleIdResponse]) @router.get("/list/user/{user_id}", response_model=list[ChatTitleIdResponse])
async def get_user_chat_list_by_user_id( async def get_user_chat_list_by_user_id(
user_id: str, user_id: str,
page: Optional[int] = None,
query: Optional[str] = None,
order_by: Optional[str] = None,
direction: Optional[str] = None,
user=Depends(get_admin_user), user=Depends(get_admin_user),
skip: int = 0,
limit: int = 50,
): ):
if not ENABLE_ADMIN_CHAT_ACCESS: if not ENABLE_ADMIN_CHAT_ACCESS:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED, detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
) )
if page is None:
page = 1
limit = 60
skip = (page - 1) * limit
filter = {}
if query:
filter["query"] = query
if order_by:
filter["order_by"] = order_by
if direction:
filter["direction"] = direction
return Chats.get_chat_list_by_user_id( return Chats.get_chat_list_by_user_id(
user_id, include_archived=True, skip=skip, limit=limit user_id, include_archived=True, filter=filter, skip=skip, limit=limit
) )
@ -194,10 +211,10 @@ async def get_chats_by_folder_id(folder_id: str, user=Depends(get_verified_user)
############################ ############################
@router.get("/pinned", response_model=list[ChatResponse]) @router.get("/pinned", response_model=list[ChatTitleIdResponse])
async def get_user_pinned_chats(user=Depends(get_verified_user)): async def get_user_pinned_chats(user=Depends(get_verified_user)):
return [ return [
ChatResponse(**chat.model_dump()) ChatTitleIdResponse(**chat.model_dump())
for chat in Chats.get_pinned_chats_by_user_id(user.id) for chat in Chats.get_pinned_chats_by_user_id(user.id)
] ]
@ -267,9 +284,37 @@ async def get_all_user_chats_in_db(user=Depends(get_admin_user)):
@router.get("/archived", response_model=list[ChatTitleIdResponse]) @router.get("/archived", response_model=list[ChatTitleIdResponse])
async def get_archived_session_user_chat_list( async def get_archived_session_user_chat_list(
user=Depends(get_verified_user), skip: int = 0, limit: int = 50 page: Optional[int] = None,
query: Optional[str] = None,
order_by: Optional[str] = None,
direction: Optional[str] = None,
user=Depends(get_verified_user),
): ):
return Chats.get_archived_chat_list_by_user_id(user.id, skip, limit) if page is None:
page = 1
limit = 60
skip = (page - 1) * limit
filter = {}
if query:
filter["query"] = query
if order_by:
filter["order_by"] = order_by
if direction:
filter["direction"] = direction
chat_list = [
ChatTitleIdResponse(**chat.model_dump())
for chat in Chats.get_archived_chat_list_by_user_id(
user.id,
filter=filter,
skip=skip,
limit=limit,
)
]
return chat_list
############################ ############################

View File

@ -1,6 +1,7 @@
import logging import logging
import os import os
import uuid import uuid
import json
from fnmatch import fnmatch from fnmatch import fnmatch
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@ -10,6 +11,7 @@ from fastapi import (
APIRouter, APIRouter,
Depends, Depends,
File, File,
Form,
HTTPException, HTTPException,
Request, Request,
UploadFile, UploadFile,
@ -84,19 +86,32 @@ def has_access_to_file(
def upload_file( def upload_file(
request: Request, request: Request,
file: UploadFile = File(...), file: UploadFile = File(...),
user=Depends(get_verified_user), metadata: Optional[dict | str] = Form(None),
file_metadata: dict = None,
process: bool = Query(True), process: bool = Query(True),
internal: bool = False,
user=Depends(get_verified_user),
): ):
log.info(f"file.content_type: {file.content_type}") log.info(f"file.content_type: {file.content_type}")
file_metadata = file_metadata if file_metadata else {} if isinstance(metadata, str):
try:
metadata = json.loads(metadata)
except json.JSONDecodeError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT("Invalid metadata format"),
)
file_metadata = metadata if metadata else {}
try: try:
unsanitized_filename = file.filename unsanitized_filename = file.filename
filename = os.path.basename(unsanitized_filename) filename = os.path.basename(unsanitized_filename)
file_extension = os.path.splitext(filename)[1] file_extension = os.path.splitext(filename)[1]
if request.app.state.config.ALLOWED_FILE_EXTENSIONS: # Remove the leading dot from the file extension
file_extension = file_extension[1:] if file_extension else ""
if (not internal) and request.app.state.config.ALLOWED_FILE_EXTENSIONS:
request.app.state.config.ALLOWED_FILE_EXTENSIONS = [ request.app.state.config.ALLOWED_FILE_EXTENSIONS = [
ext for ext in request.app.state.config.ALLOWED_FILE_EXTENSIONS if ext ext for ext in request.app.state.config.ALLOWED_FILE_EXTENSIONS if ext
] ]
@ -144,21 +159,16 @@ def upload_file(
"video/webm" "video/webm"
}: }:
file_path = Storage.get_file(file_path) file_path = Storage.get_file(file_path)
result = transcribe(request, file_path) result = transcribe(request, file_path, file_metadata)
process_file( process_file(
request, request,
ProcessFileForm(file_id=id, content=result.get("text", "")), ProcessFileForm(file_id=id, content=result.get("text", "")),
user=user, user=user,
) )
elif file.content_type not in [ elif (not file.content_type.startswith(("image/", "video/"))) or (
"image/png", request.app.state.config.CONTENT_EXTRACTION_ENGINE == "external"
"image/jpeg", ):
"image/gif",
"video/mp4",
"video/ogg",
"video/quicktime",
]:
process_file(request, ProcessFileForm(file_id=id), user=user) process_file(request, ProcessFileForm(file_id=id), user=user)
else: else:
log.info( log.info(
@ -189,7 +199,7 @@ def upload_file(
log.exception(e) log.exception(e)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT(e), detail=ERROR_MESSAGES.DEFAULT("Error uploading file"),
) )

View File

@ -1,5 +1,8 @@
import os import os
import re
import logging import logging
import aiohttp
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@ -15,6 +18,8 @@ from open_webui.constants import ERROR_MESSAGES
from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi import APIRouter, Depends, HTTPException, Request, status
from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.env import SRC_LOG_LEVELS from open_webui.env import SRC_LOG_LEVELS
from pydantic import BaseModel, HttpUrl
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MAIN"]) log.setLevel(SRC_LOG_LEVELS["MAIN"])
@ -42,6 +47,97 @@ async def get_functions(user=Depends(get_admin_user)):
return Functions.get_functions() return Functions.get_functions()
############################
# LoadFunctionFromLink
############################
class LoadUrlForm(BaseModel):
url: HttpUrl
def github_url_to_raw_url(url: str) -> str:
# Handle 'tree' (folder) URLs (add main.py at the end)
m1 = re.match(r"https://github\.com/([^/]+)/([^/]+)/tree/([^/]+)/(.*)", url)
if m1:
org, repo, branch, path = m1.groups()
return f"https://raw.githubusercontent.com/{org}/{repo}/refs/heads/{branch}/{path.rstrip('/')}/main.py"
# Handle 'blob' (file) URLs
m2 = re.match(r"https://github\.com/([^/]+)/([^/]+)/blob/([^/]+)/(.*)", url)
if m2:
org, repo, branch, path = m2.groups()
return (
f"https://raw.githubusercontent.com/{org}/{repo}/refs/heads/{branch}/{path}"
)
# No match; return as-is
return url
@router.post("/load/url", response_model=Optional[dict])
async def load_function_from_url(
request: Request, form_data: LoadUrlForm, user=Depends(get_admin_user)
):
# NOTE: This is NOT a SSRF vulnerability:
# This endpoint is admin-only (see get_admin_user), meant for *trusted* internal use,
# and does NOT accept untrusted user input. Access is enforced by authentication.
url = str(form_data.url)
if not url:
raise HTTPException(status_code=400, detail="Please enter a valid URL")
url = github_url_to_raw_url(url)
url_parts = url.rstrip("/").split("/")
file_name = url_parts[-1]
function_name = (
file_name[:-3]
if (
file_name.endswith(".py")
and (not file_name.startswith(("main.py", "index.py", "__init__.py")))
)
else url_parts[-2] if len(url_parts) > 1 else "function"
)
try:
async with aiohttp.ClientSession() as session:
async with session.get(
url, headers={"Content-Type": "application/json"}
) as resp:
if resp.status != 200:
raise HTTPException(
status_code=resp.status, detail="Failed to fetch the function"
)
data = await resp.text()
if not data:
raise HTTPException(
status_code=400, detail="No data received from the URL"
)
return {
"name": function_name,
"content": data,
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error importing function: {e}")
############################
# SyncFunctions
############################
class SyncFunctionsForm(FunctionForm):
functions: list[FunctionModel] = []
@router.post("/sync", response_model=Optional[FunctionModel])
async def sync_functions(
request: Request, form_data: SyncFunctionsForm, user=Depends(get_admin_user)
):
return Functions.sync_functions(user.id, form_data.functions)
############################ ############################
# CreateNewFunction # CreateNewFunction
############################ ############################
@ -262,11 +358,8 @@ async def get_function_valves_spec_by_id(
): ):
function = Functions.get_function_by_id(id) function = Functions.get_function_by_id(id)
if function: if function:
if id in request.app.state.FUNCTIONS: function_module, function_type, frontmatter = load_function_module_by_id(id)
function_module = request.app.state.FUNCTIONS[id] request.app.state.FUNCTIONS[id] = function_module
else:
function_module, function_type, frontmatter = load_function_module_by_id(id)
request.app.state.FUNCTIONS[id] = function_module
if hasattr(function_module, "Valves"): if hasattr(function_module, "Valves"):
Valves = function_module.Valves Valves = function_module.Valves
@ -290,11 +383,8 @@ async def update_function_valves_by_id(
): ):
function = Functions.get_function_by_id(id) function = Functions.get_function_by_id(id)
if function: if function:
if id in request.app.state.FUNCTIONS: function_module, function_type, frontmatter = load_function_module_by_id(id)
function_module = request.app.state.FUNCTIONS[id] request.app.state.FUNCTIONS[id] = function_module
else:
function_module, function_type, frontmatter = load_function_module_by_id(id)
request.app.state.FUNCTIONS[id] = function_module
if hasattr(function_module, "Valves"): if hasattr(function_module, "Valves"):
Valves = function_module.Valves Valves = function_module.Valves
@ -353,11 +443,8 @@ async def get_function_user_valves_spec_by_id(
): ):
function = Functions.get_function_by_id(id) function = Functions.get_function_by_id(id)
if function: if function:
if id in request.app.state.FUNCTIONS: function_module, function_type, frontmatter = load_function_module_by_id(id)
function_module = request.app.state.FUNCTIONS[id] request.app.state.FUNCTIONS[id] = function_module
else:
function_module, function_type, frontmatter = load_function_module_by_id(id)
request.app.state.FUNCTIONS[id] = function_module
if hasattr(function_module, "UserValves"): if hasattr(function_module, "UserValves"):
UserValves = function_module.UserValves UserValves = function_module.UserValves
@ -377,11 +464,8 @@ async def update_function_user_valves_by_id(
function = Functions.get_function_by_id(id) function = Functions.get_function_by_id(id)
if function: if function:
if id in request.app.state.FUNCTIONS: function_module, function_type, frontmatter = load_function_module_by_id(id)
function_module = request.app.state.FUNCTIONS[id] request.app.state.FUNCTIONS[id] = function_module
else:
function_module, function_type, frontmatter = load_function_module_by_id(id)
request.app.state.FUNCTIONS[id] = function_module
if hasattr(function_module, "UserValves"): if hasattr(function_module, "UserValves"):
UserValves = function_module.UserValves UserValves = function_module.UserValves

View File

@ -333,10 +333,11 @@ def get_models(request: Request, user=Depends(get_verified_user)):
return [ return [
{"id": "dall-e-2", "name": "DALL·E 2"}, {"id": "dall-e-2", "name": "DALL·E 2"},
{"id": "dall-e-3", "name": "DALL·E 3"}, {"id": "dall-e-3", "name": "DALL·E 3"},
{"id": "gpt-image-1", "name": "GPT-IMAGE 1"},
] ]
elif request.app.state.config.IMAGE_GENERATION_ENGINE == "gemini": elif request.app.state.config.IMAGE_GENERATION_ENGINE == "gemini":
return [ return [
{"id": "imagen-3-0-generate-002", "name": "imagen-3.0 generate-002"}, {"id": "imagen-3.0-generate-002", "name": "imagen-3.0 generate-002"},
] ]
elif request.app.state.config.IMAGE_GENERATION_ENGINE == "comfyui": elif request.app.state.config.IMAGE_GENERATION_ENGINE == "comfyui":
# TODO - get models from comfyui # TODO - get models from comfyui
@ -450,7 +451,7 @@ def load_url_image_data(url, headers=None):
return None return None
def upload_image(request, image_metadata, image_data, content_type, user): def upload_image(request, image_data, content_type, metadata, user):
image_format = mimetypes.guess_extension(content_type) image_format = mimetypes.guess_extension(content_type)
file = UploadFile( file = UploadFile(
file=io.BytesIO(image_data), file=io.BytesIO(image_data),
@ -459,7 +460,7 @@ def upload_image(request, image_metadata, image_data, content_type, user):
"content-type": content_type, "content-type": content_type,
}, },
) )
file_item = upload_file(request, file, user, file_metadata=image_metadata) file_item = upload_file(request, file, metadata=metadata, internal=True, user=user)
url = request.app.url_path_for("get_file_content_by_id", id=file_item.id) url = request.app.url_path_for("get_file_content_by_id", id=file_item.id)
return url return url
@ -526,7 +527,7 @@ async def image_generations(
else: else:
image_data, content_type = load_b64_image_data(image["b64_json"]) image_data, content_type = load_b64_image_data(image["b64_json"])
url = upload_image(request, data, image_data, content_type, user) url = upload_image(request, image_data, content_type, data, user)
images.append({"url": url}) images.append({"url": url})
return images return images
@ -560,7 +561,7 @@ async def image_generations(
image_data, content_type = load_b64_image_data( image_data, content_type = load_b64_image_data(
image["bytesBase64Encoded"] image["bytesBase64Encoded"]
) )
url = upload_image(request, data, image_data, content_type, user) url = upload_image(request, image_data, content_type, data, user)
images.append({"url": url}) images.append({"url": url})
return images return images
@ -611,9 +612,9 @@ async def image_generations(
image_data, content_type = load_url_image_data(image["url"], headers) image_data, content_type = load_url_image_data(image["url"], headers)
url = upload_image( url = upload_image(
request, request,
form_data.model_dump(exclude_none=True),
image_data, image_data,
content_type, content_type,
form_data.model_dump(exclude_none=True),
user, user,
) )
images.append({"url": url}) images.append({"url": url})
@ -664,9 +665,9 @@ async def image_generations(
image_data, content_type = load_b64_image_data(image) image_data, content_type = load_b64_image_data(image)
url = upload_image( url = upload_image(
request, request,
{**data, "info": res["info"]},
image_data, image_data,
content_type, content_type,
{**data, "info": res["info"]},
user, user,
) )
images.append({"url": url}) images.append({"url": url})

View File

@ -9,6 +9,8 @@ import os
import random import random
import re import re
import time import time
from datetime import datetime
from typing import Optional, Union from typing import Optional, Union
from urllib.parse import urlparse from urllib.parse import urlparse
import aiohttp import aiohttp
@ -300,6 +302,22 @@ async def update_config(
} }
def merge_ollama_models_lists(model_lists):
merged_models = {}
for idx, model_list in enumerate(model_lists):
if model_list is not None:
for model in model_list:
id = model["model"]
if id not in merged_models:
model["urls"] = [idx]
merged_models[id] = model
else:
merged_models[id]["urls"].append(idx)
return list(merged_models.values())
@cached(ttl=1) @cached(ttl=1)
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()")
@ -364,23 +382,8 @@ async def get_all_models(request: Request, user: UserModel = None):
if connection_type: if connection_type:
model["connection_type"] = connection_type model["connection_type"] = connection_type
def merge_models_lists(model_lists):
merged_models = {}
for idx, model_list in enumerate(model_lists):
if model_list is not None:
for model in model_list:
id = model["model"]
if id not in merged_models:
model["urls"] = [idx]
merged_models[id] = model
else:
merged_models[id]["urls"].append(idx)
return list(merged_models.values())
models = { models = {
"models": merge_models_lists( "models": merge_ollama_models_lists(
map( map(
lambda response: response.get("models", []) if response else None, lambda response: response.get("models", []) if response else None,
responses, responses,
@ -388,6 +391,22 @@ async def get_all_models(request: Request, user: UserModel = None):
) )
} }
try:
loaded_models = await get_ollama_loaded_models(request, user=user)
expires_map = {
m["name"]: m["expires_at"]
for m in loaded_models["models"]
if "expires_at" in m
}
for m in models["models"]:
if m["name"] in expires_map:
# Parse ISO8601 datetime with offset, get unix timestamp as int
dt = datetime.fromisoformat(expires_map[m["name"]])
m["expires_at"] = int(dt.timestamp())
except Exception as e:
log.debug(f"Failed to get loaded models: {e}")
else: else:
models = {"models": []} models = {"models": []}
@ -468,6 +487,68 @@ async def get_ollama_tags(
return models return models
@router.get("/api/ps")
async def get_ollama_loaded_models(request: Request, user=Depends(get_admin_user)):
"""
List models that are currently loaded into Ollama memory, and which node they are loaded on.
"""
if request.app.state.config.ENABLE_OLLAMA_API:
request_tasks = []
for idx, url in enumerate(request.app.state.config.OLLAMA_BASE_URLS):
if (str(idx) not in request.app.state.config.OLLAMA_API_CONFIGS) and (
url not in request.app.state.config.OLLAMA_API_CONFIGS # Legacy support
):
request_tasks.append(send_get_request(f"{url}/api/ps", user=user))
else:
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
str(idx),
request.app.state.config.OLLAMA_API_CONFIGS.get(
url, {}
), # Legacy support
)
enable = api_config.get("enable", True)
key = api_config.get("key", None)
if enable:
request_tasks.append(
send_get_request(f"{url}/api/ps", key, user=user)
)
else:
request_tasks.append(asyncio.ensure_future(asyncio.sleep(0, None)))
responses = await asyncio.gather(*request_tasks)
for idx, response in enumerate(responses):
if response:
url = request.app.state.config.OLLAMA_BASE_URLS[idx]
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
str(idx),
request.app.state.config.OLLAMA_API_CONFIGS.get(
url, {}
), # Legacy support
)
prefix_id = api_config.get("prefix_id", None)
for model in response.get("models", []):
if prefix_id:
model["model"] = f"{prefix_id}.{model['model']}"
models = {
"models": merge_ollama_models_lists(
map(
lambda response: response.get("models", []) if response else None,
responses,
)
)
}
else:
models = {"models": []}
return models
@router.get("/api/version") @router.get("/api/version")
@router.get("/api/version/{url_idx}") @router.get("/api/version/{url_idx}")
async def get_ollama_versions(request: Request, url_idx: Optional[int] = None): async def get_ollama_versions(request: Request, url_idx: Optional[int] = None):
@ -541,36 +622,74 @@ async def get_ollama_versions(request: Request, url_idx: Optional[int] = None):
return {"version": False} return {"version": False}
@router.get("/api/ps")
async def get_ollama_loaded_models(request: Request, user=Depends(get_verified_user)):
"""
List models that are currently loaded into Ollama memory, and which node they are loaded on.
"""
if request.app.state.config.ENABLE_OLLAMA_API:
request_tasks = [
send_get_request(
f"{url}/api/ps",
request.app.state.config.OLLAMA_API_CONFIGS.get(
str(idx),
request.app.state.config.OLLAMA_API_CONFIGS.get(
url, {}
), # Legacy support
).get("key", None),
user=user,
)
for idx, url in enumerate(request.app.state.config.OLLAMA_BASE_URLS)
]
responses = await asyncio.gather(*request_tasks)
return dict(zip(request.app.state.config.OLLAMA_BASE_URLS, responses))
else:
return {}
class ModelNameForm(BaseModel): class ModelNameForm(BaseModel):
name: str name: str
@router.post("/api/unload")
async def unload_model(
request: Request,
form_data: ModelNameForm,
user=Depends(get_admin_user),
):
model_name = form_data.name
if not model_name:
raise HTTPException(
status_code=400, detail="Missing 'name' of model to unload."
)
# Refresh/load models if needed, get mapping from name to URLs
await get_all_models(request, user=user)
models = request.app.state.OLLAMA_MODELS
# Canonicalize model name (if not supplied with version)
if ":" not in model_name:
model_name = f"{model_name}:latest"
if model_name not in models:
raise HTTPException(
status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(model_name)
)
url_indices = models[model_name]["urls"]
# Send unload to ALL url_indices
results = []
errors = []
for idx in url_indices:
url = request.app.state.config.OLLAMA_BASE_URLS[idx]
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
str(idx), request.app.state.config.OLLAMA_API_CONFIGS.get(url, {})
)
key = get_api_key(idx, url, request.app.state.config.OLLAMA_API_CONFIGS)
prefix_id = api_config.get("prefix_id", None)
if prefix_id and model_name.startswith(f"{prefix_id}."):
model_name = model_name[len(f"{prefix_id}.") :]
payload = {"model": model_name, "keep_alive": 0, "prompt": ""}
try:
res = await send_post_request(
url=f"{url}/api/generate",
payload=json.dumps(payload),
stream=False,
key=key,
user=user,
)
results.append({"url_idx": idx, "success": True, "response": res})
except Exception as e:
log.exception(f"Failed to unload model on node {idx}: {e}")
errors.append({"url_idx": idx, "success": False, "error": str(e)})
if len(errors) > 0:
raise HTTPException(
status_code=500,
detail=f"Failed to unload model on {len(errors)} nodes: {errors}",
)
return {"status": True}
@router.post("/api/pull") @router.post("/api/pull")
@router.post("/api/pull/{url_idx}") @router.post("/api/pull/{url_idx}")
async def pull_model( async def pull_model(

View File

@ -349,6 +349,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
"ENABLE_RAG_HYBRID_SEARCH": request.app.state.config.ENABLE_RAG_HYBRID_SEARCH, "ENABLE_RAG_HYBRID_SEARCH": request.app.state.config.ENABLE_RAG_HYBRID_SEARCH,
"TOP_K_RERANKER": request.app.state.config.TOP_K_RERANKER, "TOP_K_RERANKER": request.app.state.config.TOP_K_RERANKER,
"RELEVANCE_THRESHOLD": request.app.state.config.RELEVANCE_THRESHOLD, "RELEVANCE_THRESHOLD": request.app.state.config.RELEVANCE_THRESHOLD,
"HYBRID_BM25_WEIGHT": request.app.state.config.HYBRID_BM25_WEIGHT,
# Content extraction settings # Content extraction settings
"CONTENT_EXTRACTION_ENGINE": request.app.state.config.CONTENT_EXTRACTION_ENGINE, "CONTENT_EXTRACTION_ENGINE": request.app.state.config.CONTENT_EXTRACTION_ENGINE,
"PDF_EXTRACT_IMAGES": request.app.state.config.PDF_EXTRACT_IMAGES, "PDF_EXTRACT_IMAGES": request.app.state.config.PDF_EXTRACT_IMAGES,
@ -387,6 +388,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
"WEB_SEARCH_CONCURRENT_REQUESTS": request.app.state.config.WEB_SEARCH_CONCURRENT_REQUESTS, "WEB_SEARCH_CONCURRENT_REQUESTS": request.app.state.config.WEB_SEARCH_CONCURRENT_REQUESTS,
"WEB_SEARCH_DOMAIN_FILTER_LIST": request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, "WEB_SEARCH_DOMAIN_FILTER_LIST": request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
"BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL": request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL, "BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL": request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL,
"BYPASS_WEB_SEARCH_WEB_LOADER": request.app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER,
"SEARXNG_QUERY_URL": request.app.state.config.SEARXNG_QUERY_URL, "SEARXNG_QUERY_URL": request.app.state.config.SEARXNG_QUERY_URL,
"YACY_QUERY_URL": request.app.state.config.YACY_QUERY_URL, "YACY_QUERY_URL": request.app.state.config.YACY_QUERY_URL,
"YACY_USERNAME": request.app.state.config.YACY_USERNAME, "YACY_USERNAME": request.app.state.config.YACY_USERNAME,
@ -439,6 +441,7 @@ class WebConfig(BaseModel):
WEB_SEARCH_CONCURRENT_REQUESTS: Optional[int] = None WEB_SEARCH_CONCURRENT_REQUESTS: Optional[int] = None
WEB_SEARCH_DOMAIN_FILTER_LIST: Optional[List[str]] = [] WEB_SEARCH_DOMAIN_FILTER_LIST: Optional[List[str]] = []
BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL: Optional[bool] = None BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL: Optional[bool] = None
BYPASS_WEB_SEARCH_WEB_LOADER: Optional[bool] = None
SEARXNG_QUERY_URL: Optional[str] = None SEARXNG_QUERY_URL: Optional[str] = None
YACY_QUERY_URL: Optional[str] = None YACY_QUERY_URL: Optional[str] = None
YACY_USERNAME: Optional[str] = None YACY_USERNAME: Optional[str] = None
@ -492,6 +495,7 @@ class ConfigForm(BaseModel):
ENABLE_RAG_HYBRID_SEARCH: Optional[bool] = None ENABLE_RAG_HYBRID_SEARCH: Optional[bool] = None
TOP_K_RERANKER: Optional[int] = None TOP_K_RERANKER: Optional[int] = None
RELEVANCE_THRESHOLD: Optional[float] = None RELEVANCE_THRESHOLD: Optional[float] = None
HYBRID_BM25_WEIGHT: Optional[float] = None
# Content extraction settings # Content extraction settings
CONTENT_EXTRACTION_ENGINE: Optional[str] = None CONTENT_EXTRACTION_ENGINE: Optional[str] = None
@ -578,6 +582,11 @@ async def update_rag_config(
if form_data.RELEVANCE_THRESHOLD is not None if form_data.RELEVANCE_THRESHOLD is not None
else request.app.state.config.RELEVANCE_THRESHOLD else request.app.state.config.RELEVANCE_THRESHOLD
) )
request.app.state.config.HYBRID_BM25_WEIGHT = (
form_data.HYBRID_BM25_WEIGHT
if form_data.HYBRID_BM25_WEIGHT is not None
else request.app.state.config.HYBRID_BM25_WEIGHT
)
# Content extraction settings # Content extraction settings
request.app.state.config.CONTENT_EXTRACTION_ENGINE = ( request.app.state.config.CONTENT_EXTRACTION_ENGINE = (
@ -751,6 +760,9 @@ async def update_rag_config(
request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL = ( request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL = (
form_data.web.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL form_data.web.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL
) )
request.app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER = (
form_data.web.BYPASS_WEB_SEARCH_WEB_LOADER
)
request.app.state.config.SEARXNG_QUERY_URL = form_data.web.SEARXNG_QUERY_URL request.app.state.config.SEARXNG_QUERY_URL = form_data.web.SEARXNG_QUERY_URL
request.app.state.config.YACY_QUERY_URL = form_data.web.YACY_QUERY_URL request.app.state.config.YACY_QUERY_URL = form_data.web.YACY_QUERY_URL
request.app.state.config.YACY_USERNAME = form_data.web.YACY_USERNAME request.app.state.config.YACY_USERNAME = form_data.web.YACY_USERNAME
@ -837,6 +849,7 @@ async def update_rag_config(
"ENABLE_RAG_HYBRID_SEARCH": request.app.state.config.ENABLE_RAG_HYBRID_SEARCH, "ENABLE_RAG_HYBRID_SEARCH": request.app.state.config.ENABLE_RAG_HYBRID_SEARCH,
"TOP_K_RERANKER": request.app.state.config.TOP_K_RERANKER, "TOP_K_RERANKER": request.app.state.config.TOP_K_RERANKER,
"RELEVANCE_THRESHOLD": request.app.state.config.RELEVANCE_THRESHOLD, "RELEVANCE_THRESHOLD": request.app.state.config.RELEVANCE_THRESHOLD,
"HYBRID_BM25_WEIGHT": request.app.state.config.HYBRID_BM25_WEIGHT,
# Content extraction settings # Content extraction settings
"CONTENT_EXTRACTION_ENGINE": request.app.state.config.CONTENT_EXTRACTION_ENGINE, "CONTENT_EXTRACTION_ENGINE": request.app.state.config.CONTENT_EXTRACTION_ENGINE,
"PDF_EXTRACT_IMAGES": request.app.state.config.PDF_EXTRACT_IMAGES, "PDF_EXTRACT_IMAGES": request.app.state.config.PDF_EXTRACT_IMAGES,
@ -875,6 +888,7 @@ async def update_rag_config(
"WEB_SEARCH_CONCURRENT_REQUESTS": request.app.state.config.WEB_SEARCH_CONCURRENT_REQUESTS, "WEB_SEARCH_CONCURRENT_REQUESTS": request.app.state.config.WEB_SEARCH_CONCURRENT_REQUESTS,
"WEB_SEARCH_DOMAIN_FILTER_LIST": request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, "WEB_SEARCH_DOMAIN_FILTER_LIST": request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
"BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL": request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL, "BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL": request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL,
"BYPASS_WEB_SEARCH_WEB_LOADER": request.app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER,
"SEARXNG_QUERY_URL": request.app.state.config.SEARXNG_QUERY_URL, "SEARXNG_QUERY_URL": request.app.state.config.SEARXNG_QUERY_URL,
"YACY_QUERY_URL": request.app.state.config.YACY_QUERY_URL, "YACY_QUERY_URL": request.app.state.config.YACY_QUERY_URL,
"YACY_USERNAME": request.app.state.config.YACY_USERNAME, "YACY_USERNAME": request.app.state.config.YACY_USERNAME,
@ -1678,13 +1692,29 @@ async def process_web_search(
) )
try: try:
loader = get_web_loader( if request.app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER:
urls, docs = [
verify_ssl=request.app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION, Document(
requests_per_second=request.app.state.config.WEB_SEARCH_CONCURRENT_REQUESTS, page_content=result.snippet,
trust_env=request.app.state.config.WEB_SEARCH_TRUST_ENV, metadata={
) "source": result.link,
docs = await loader.aload() "title": result.title,
"snippet": result.snippet,
"link": result.link,
},
)
for result in search_results
if hasattr(result, "snippet")
]
else:
loader = get_web_loader(
urls,
verify_ssl=request.app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION,
requests_per_second=request.app.state.config.WEB_SEARCH_CONCURRENT_REQUESTS,
trust_env=request.app.state.config.WEB_SEARCH_TRUST_ENV,
)
docs = await loader.aload()
urls = [ urls = [
doc.metadata.get("source") for doc in docs if doc.metadata.get("source") doc.metadata.get("source") for doc in docs if doc.metadata.get("source")
] # only keep the urls returned by the loader ] # only keep the urls returned by the loader
@ -1774,6 +1804,11 @@ def query_doc_handler(
if form_data.r if form_data.r
else request.app.state.config.RELEVANCE_THRESHOLD else request.app.state.config.RELEVANCE_THRESHOLD
), ),
hybrid_bm25_weight=(
form_data.hybrid_bm25_weight
if form_data.hybrid_bm25_weight
else request.app.state.config.HYBRID_BM25_WEIGHT
),
user=user, user=user,
) )
else: else:
@ -1825,6 +1860,11 @@ def query_collection_handler(
if form_data.r if form_data.r
else request.app.state.config.RELEVANCE_THRESHOLD else request.app.state.config.RELEVANCE_THRESHOLD
), ),
hybrid_bm25_weight=(
form_data.hybrid_bm25_weight
if form_data.hybrid_bm25_weight
else request.app.state.config.HYBRID_BM25_WEIGHT
),
) )
else: else:
return query_collection( return query_collection(

View File

@ -51,11 +51,11 @@ async def get_tools(request: Request, user=Depends(get_verified_user)):
**{ **{
"id": f"server:{server['idx']}", "id": f"server:{server['idx']}",
"user_id": f"server:{server['idx']}", "user_id": f"server:{server['idx']}",
"name": server["openapi"] "name": server.get("openapi", {})
.get("info", {}) .get("info", {})
.get("title", "Tool Server"), .get("title", "Tool Server"),
"meta": { "meta": {
"description": server["openapi"] "description": server.get("openapi", {})
.get("info", {}) .get("info", {})
.get("description", ""), .get("description", ""),
}, },

View File

@ -2,6 +2,7 @@ import os
import shutil import shutil
import json import json
import logging import logging
import re
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import BinaryIO, Tuple, Dict from typing import BinaryIO, Tuple, Dict
@ -136,6 +137,11 @@ class S3StorageProvider(StorageProvider):
self.bucket_name = S3_BUCKET_NAME self.bucket_name = S3_BUCKET_NAME
self.key_prefix = S3_KEY_PREFIX if S3_KEY_PREFIX else "" self.key_prefix = S3_KEY_PREFIX if S3_KEY_PREFIX else ""
@staticmethod
def sanitize_tag_value(s: str) -> str:
"""Only include S3 allowed characters."""
return re.sub(r"[^a-zA-Z0-9 äöüÄÖÜß\+\-=\._:/@]", "", s)
def upload_file( def upload_file(
self, file: BinaryIO, filename: str, tags: Dict[str, str] self, file: BinaryIO, filename: str, tags: Dict[str, str]
) -> Tuple[bytes, str]: ) -> Tuple[bytes, str]:
@ -145,7 +151,15 @@ class S3StorageProvider(StorageProvider):
try: try:
self.s3_client.upload_file(file_path, self.bucket_name, s3_key) self.s3_client.upload_file(file_path, self.bucket_name, s3_key)
if S3_ENABLE_TAGGING and tags: if S3_ENABLE_TAGGING and tags:
tagging = {"TagSet": [{"Key": k, "Value": v} for k, v in tags.items()]} sanitized_tags = {
self.sanitize_tag_value(k): self.sanitize_tag_value(v)
for k, v in tags.items()
}
tagging = {
"TagSet": [
{"Key": k, "Value": v} for k, v in sanitized_tags.items()
]
}
self.s3_client.put_object_tagging( self.s3_client.put_object_tagging(
Bucket=self.bucket_name, Bucket=self.bucket_name,
Key=s3_key, Key=s3_key,

View File

@ -392,11 +392,8 @@ async def chat_action(request: Request, action_id: str, form_data: dict, user: A
} }
) )
if action_id in request.app.state.FUNCTIONS: function_module, _, _ = load_function_module_by_id(action_id)
function_module = request.app.state.FUNCTIONS[action_id] request.app.state.FUNCTIONS[action_id] = function_module
else:
function_module, _, _ = load_function_module_by_id(action_id)
request.app.state.FUNCTIONS[action_id] = function_module
if hasattr(function_module, "valves") and hasattr(function_module, "Valves"): if hasattr(function_module, "valves") and hasattr(function_module, "Valves"):
valves = Functions.get_function_valves_by_id(action_id) valves = Functions.get_function_valves_by_id(action_id)

View File

@ -13,11 +13,9 @@ def get_function_module(request, function_id):
""" """
Get the function module by its ID. Get the function module by its ID.
""" """
if function_id in request.app.state.FUNCTIONS:
function_module = request.app.state.FUNCTIONS[function_id] function_module, _, _ = load_function_module_by_id(function_id)
else: request.app.state.FUNCTIONS[function_id] = function_module
function_module, _, _ = load_function_module_by_id(function_id)
request.app.state.FUNCTIONS[function_id] = function_module
return function_module return function_module
@ -39,14 +37,17 @@ def get_sorted_filter_ids(request, model: dict, enabled_filter_ids: list = None)
for function in Functions.get_functions_by_type("filter", active_only=True) for function in Functions.get_functions_by_type("filter", active_only=True)
] ]
for filter_id in active_filter_ids: def get_active_status(filter_id):
function_module = get_function_module(request, filter_id) function_module = get_function_module(request, filter_id)
if getattr(function_module, "toggle", None) and ( if getattr(function_module, "toggle", None):
filter_id not in enabled_filter_ids return filter_id in (enabled_filter_ids or [])
):
active_filter_ids.remove(filter_id) return True
continue
active_filter_ids = [
filter_id for filter_id in active_filter_ids if get_active_status(filter_id)
]
filter_ids = [fid for fid in filter_ids if fid in active_filter_ids] filter_ids = [fid for fid in filter_ids if fid in active_filter_ids]
filter_ids.sort(key=get_priority) filter_ids.sort(key=get_priority)

View File

@ -41,6 +41,7 @@ from open_webui.routers.pipelines import (
process_pipeline_inlet_filter, process_pipeline_inlet_filter,
process_pipeline_outlet_filter, process_pipeline_outlet_filter,
) )
from open_webui.routers.memories import query_memory, QueryMemoryForm
from open_webui.utils.webhook import post_webhook from open_webui.utils.webhook import post_webhook
@ -251,7 +252,12 @@ async def chat_completion_tools_handler(
"name": (f"TOOL:{tool_name}"), "name": (f"TOOL:{tool_name}"),
}, },
"document": [tool_result], "document": [tool_result],
"metadata": [{"source": (f"TOOL:{tool_name}")}], "metadata": [
{
"source": (f"TOOL:{tool_name}"),
"parameters": tool_function_params,
}
],
} }
) )
else: else:
@ -290,6 +296,38 @@ async def chat_completion_tools_handler(
return body, {"sources": sources} return body, {"sources": sources}
async def chat_memory_handler(
request: Request, form_data: dict, extra_params: dict, user
):
results = await query_memory(
request,
QueryMemoryForm(
**{"content": get_last_user_message(form_data["messages"]), "k": 3}
),
user,
)
user_context = ""
if results and hasattr(results, "documents"):
if results.documents and len(results.documents) > 0:
for doc_idx, doc in enumerate(results.documents[0]):
created_at_date = "Unknown Date"
if results.metadatas[0][doc_idx].get("created_at"):
created_at_timestamp = results.metadatas[0][doc_idx]["created_at"]
created_at_date = time.strftime(
"%Y-%m-%d", time.localtime(created_at_timestamp)
)
user_context += f"{doc_idx + 1}. [{created_at_date}] {doc}\n"
form_data["messages"] = add_or_update_system_message(
f"User Context:\n{user_context}\n", form_data["messages"], append=True
)
return form_data
async def chat_web_search_handler( async def chat_web_search_handler(
request: Request, form_data: dict, extra_params: dict, user request: Request, form_data: dict, extra_params: dict, user
): ):
@ -389,6 +427,7 @@ async def chat_web_search_handler(
"name": ", ".join(queries), "name": ", ".join(queries),
"type": "web_search", "type": "web_search",
"urls": results["filenames"], "urls": results["filenames"],
"queries": queries,
} }
) )
elif results.get("docs"): elif results.get("docs"):
@ -400,6 +439,7 @@ async def chat_web_search_handler(
"name": ", ".join(queries), "name": ", ".join(queries),
"type": "web_search", "type": "web_search",
"urls": results["filenames"], "urls": results["filenames"],
"queries": queries,
} }
) )
@ -603,6 +643,7 @@ async def chat_completion_files_handler(
reranking_function=request.app.state.rf, reranking_function=request.app.state.rf,
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_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,
), ),
@ -774,6 +815,11 @@ async def process_chat_payload(request, form_data, user, metadata, model):
features = form_data.pop("features", None) features = form_data.pop("features", None)
if features: if features:
if "memory" in features and features["memory"]:
form_data = await chat_memory_handler(
request, form_data, extra_params, user
)
if "web_search" in features and features["web_search"]: if "web_search" in features and features["web_search"]:
form_data = await chat_web_search_handler( form_data = await chat_web_search_handler(
request, form_data, extra_params, user request, form_data, extra_params, user
@ -876,6 +922,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
for doc_context, doc_meta in zip( for doc_context, doc_meta in zip(
source["document"], source["metadata"] source["document"], source["metadata"]
): ):
source_name = source.get("source", {}).get("name", None)
citation_id = ( citation_id = (
doc_meta.get("source", None) doc_meta.get("source", None)
or source.get("source", {}).get("id", None) or source.get("source", {}).get("id", None)
@ -883,7 +930,11 @@ async def process_chat_payload(request, form_data, user, metadata, model):
) )
if citation_id not in citation_idx: if citation_id not in citation_idx:
citation_idx[citation_id] = len(citation_idx) + 1 citation_idx[citation_id] = len(citation_idx) + 1
context_string += f'<source id="{citation_idx[citation_id]}">{doc_context}</source>\n' context_string += (
f'<source id="{citation_idx[citation_id]}"'
+ (f' name="{source_name}"' if source_name else "")
+ f">{doc_context}</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"])
@ -950,7 +1001,7 @@ async def process_chat_response(
message = message_map.get(metadata["message_id"]) if message_map else None message = message_map.get(metadata["message_id"]) if message_map else None
if message: if message:
message_list = get_message_list(message_map, message.get("id")) message_list = get_message_list(message_map, metadata["message_id"])
# Remove details tags and files from the messages. # Remove details tags and files from the messages.
# as get_message_list creates a new list, it does not affect # as get_message_list creates a new list, it does not affect
@ -967,7 +1018,7 @@ async def process_chat_response(
if isinstance(content, str): if isinstance(content, str):
content = re.sub( content = re.sub(
r"<details\b[^>]*>.*?<\/details>", r"<details\b[^>]*>.*?<\/details>|!\[.*?\]\(.*?\)",
"", "",
content, content,
flags=re.S | re.I, flags=re.S | re.I,
@ -975,7 +1026,10 @@ async def process_chat_response(
messages.append( messages.append(
{ {
"role": message["role"], **message,
"role": message.get(
"role", "assistant"
), # Safe fallback for missing role
"content": content, "content": content,
} }
) )
@ -1143,6 +1197,7 @@ async def process_chat_response(
metadata["chat_id"], metadata["chat_id"],
metadata["message_id"], metadata["message_id"],
{ {
"role": "assistant",
"content": content, "content": content,
}, },
) )
@ -1165,8 +1220,34 @@ async def process_chat_response(
await background_tasks_handler() await background_tasks_handler()
if events and isinstance(events, list) and isinstance(response, dict):
extra_response = {}
for event in events:
if isinstance(event, dict):
extra_response.update(event)
else:
extra_response[event] = True
response = {
**extra_response,
**response,
}
return response return response
else: else:
if events and isinstance(events, list) and isinstance(response, dict):
extra_response = {}
for event in events:
if isinstance(event, dict):
extra_response.update(event)
else:
extra_response[event] = True
response = {
**extra_response,
**response,
}
return response return response
# Non standard response # Non standard response

View File

@ -34,11 +34,15 @@ def get_message_list(messages, message_id):
:return: List of ordered messages starting from the root to the given message :return: List of ordered messages starting from the root to the given message
""" """
# Handle case where messages is None
if not messages:
return [] # Return empty list instead of None to prevent iteration errors
# Find the message by its id # Find the message by its id
current_message = messages.get(message_id) current_message = messages.get(message_id)
if not current_message: if not current_message:
return None return [] # Return empty list instead of None to prevent iteration errors
# Reconstruct the chain by following the parentId links # Reconstruct the chain by following the parentId links
message_list = [] message_list = []
@ -47,7 +51,7 @@ def get_message_list(messages, message_id):
message_list.insert( message_list.insert(
0, current_message 0, current_message
) # Insert the message at the beginning of the list ) # Insert the message at the beginning of the list
parent_id = current_message["parentId"] parent_id = current_message.get("parentId") # Use .get() for safety
current_message = messages.get(parent_id) if parent_id else None current_message = messages.get(parent_id) if parent_id else None
return message_list return message_list
@ -130,7 +134,9 @@ def prepend_to_first_user_message_content(
return messages return messages
def add_or_update_system_message(content: str, messages: list[dict]): def add_or_update_system_message(
content: str, messages: list[dict], append: bool = False
):
""" """
Adds a new system message at the beginning of the messages list Adds a new system message at the beginning of the messages list
or updates the existing system message at the beginning. or updates the existing system message at the beginning.
@ -141,7 +147,10 @@ def add_or_update_system_message(content: str, messages: list[dict]):
""" """
if messages and messages[0].get("role") == "system": if messages and messages[0].get("role") == "system":
messages[0]["content"] = f"{content}\n{messages[0]['content']}" if append:
messages[0]["content"] = f"{messages[0]['content']}\n{content}"
else:
messages[0]["content"] = f"{content}\n{messages[0]['content']}"
else: else:
# Insert at the beginning # Insert at the beginning
messages.insert(0, {"role": "system", "content": content}) messages.insert(0, {"role": "system", "content": content})

View File

@ -239,11 +239,8 @@ async def get_all_models(request, user: UserModel = None):
] ]
def get_function_module_by_id(function_id): def get_function_module_by_id(function_id):
if function_id in request.app.state.FUNCTIONS: function_module, _, _ = load_function_module_by_id(function_id)
function_module = request.app.state.FUNCTIONS[function_id] request.app.state.FUNCTIONS[function_id] = function_module
else:
function_module, _, _ = load_function_module_by_id(function_id)
request.app.state.FUNCTIONS[function_id] = function_module
return function_module return function_module
for model in models: for model in models:

View File

@ -536,5 +536,10 @@ class OAuthManager:
secure=WEBUI_AUTH_COOKIE_SECURE, secure=WEBUI_AUTH_COOKIE_SECURE,
) )
# Redirect back to the frontend with the JWT token # Redirect back to the frontend with the JWT token
redirect_url = f"{request.base_url}auth#token={jwt_token}"
redirect_base_url = request.app.state.config.WEBUI_URL or request.base_url
if redirect_base_url.endswith("/"):
redirect_base_url = redirect_base_url[:-1]
redirect_url = f"{redirect_base_url}/auth#token={jwt_token}"
return RedirectResponse(url=redirect_url, headers=response.headers) return RedirectResponse(url=redirect_url, headers=response.headers)

View File

@ -57,6 +57,7 @@ def apply_model_params_to_body_openai(params: dict, form_data: dict) -> dict:
mappings = { mappings = {
"temperature": float, "temperature": float,
"top_p": float, "top_p": float,
"min_p": float,
"max_tokens": int, "max_tokens": int,
"frequency_penalty": float, "frequency_penalty": float,
"presence_penalty": float, "presence_penalty": float,

View File

@ -22,7 +22,7 @@ def get_task_model_id(
# Set the task model # Set the task model
task_model_id = default_model_id task_model_id = default_model_id
# Check if the user has a custom task model and use that model # Check if the user has a custom task model and use that model
if models[task_model_id].get("owned_by") == "ollama": if models[task_model_id].get("connection_type") == "local":
if task_model and task_model in models: if task_model and task_model in models:
task_model_id = task_model task_model_id = task_model
else: else:

View File

@ -160,7 +160,7 @@ def get_tools(
# TODO: Fix hack for OpenAI API # TODO: Fix hack for OpenAI API
# Some times breaks OpenAI but others don't. Leaving the comment # Some times breaks OpenAI but others don't. Leaving the comment
for val in spec.get("parameters", {}).get("properties", {}).values(): for val in spec.get("parameters", {}).get("properties", {}).values():
if val["type"] == "str": if val.get("type") == "str":
val["type"] = "string" val["type"] = "string"
# Remove internal reserved parameters (e.g. __id__, __user__) # Remove internal reserved parameters (e.g. __id__, __user__)
@ -490,8 +490,19 @@ async def get_tool_servers_data(
server_entries = [] server_entries = []
for idx, server in enumerate(servers): for idx, server in enumerate(servers):
if server.get("config", {}).get("enable"): if server.get("config", {}).get("enable"):
url_path = server.get("path", "openapi.json") # Path (to OpenAPI spec URL) can be either a full URL or a path to append to the base URL
full_url = f"{server.get('url')}/{url_path}" openapi_path = server.get("path", "openapi.json")
if "://" in openapi_path:
# If it contains "://", it's a full URL
full_url = openapi_path
else:
if not openapi_path.startswith("/"):
# Ensure the path starts with a slash
openapi_path = f"/{openapi_path}"
full_url = f"{server.get('url')}{openapi_path}"
info = server.get("info", {})
auth_type = server.get("auth_type", "bearer") auth_type = server.get("auth_type", "bearer")
token = None token = None
@ -500,26 +511,37 @@ async def get_tool_servers_data(
token = server.get("key", "") token = server.get("key", "")
elif auth_type == "session": elif auth_type == "session":
token = session_token token = session_token
server_entries.append((idx, server, full_url, token)) server_entries.append((idx, server, full_url, info, token))
# Create async tasks to fetch data # Create async tasks to fetch data
tasks = [get_tool_server_data(token, url) for (_, _, url, token) in server_entries] tasks = [
get_tool_server_data(token, url) for (_, _, url, _, token) in server_entries
]
# Execute tasks concurrently # Execute tasks concurrently
responses = await asyncio.gather(*tasks, return_exceptions=True) responses = await asyncio.gather(*tasks, return_exceptions=True)
# Build final results with index and server metadata # Build final results with index and server metadata
results = [] results = []
for (idx, server, url, _), response in zip(server_entries, responses): for (idx, server, url, info, _), response in zip(server_entries, responses):
if isinstance(response, Exception): if isinstance(response, Exception):
log.error(f"Failed to connect to {url} OpenAPI tool server") log.error(f"Failed to connect to {url} OpenAPI tool server")
continue continue
openapi_data = response.get("openapi", {})
if info and isinstance(openapi_data, dict):
if "name" in info:
openapi_data["info"]["title"] = info.get("name", "Tool Server")
if "description" in info:
openapi_data["info"]["description"] = info.get("description", "")
results.append( results.append(
{ {
"idx": idx, "idx": idx,
"url": server.get("url"), "url": server.get("url"),
"openapi": response.get("openapi"), "openapi": openapi_data,
"info": response.get("info"), "info": response.get("info"),
"specs": response.get("specs"), "specs": response.get("specs"),
} }

View File

@ -12,10 +12,12 @@ aiohttp==3.11.11
async-timeout async-timeout
aiocache aiocache
aiofiles aiofiles
starlette-compress==1.6.0
sqlalchemy==2.0.38 sqlalchemy==2.0.38
alembic==1.14.0 alembic==1.14.0
peewee==3.17.9 peewee==3.18.1
peewee-migrate==1.12.2 peewee-migrate==1.12.2
psycopg2-binary==2.9.9 psycopg2-binary==2.9.9
pgvector==0.4.0 pgvector==0.4.0

87
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "open-webui", "name": "open-webui",
"version": "0.6.10", "version": "0.6.11",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "open-webui", "name": "open-webui",
"version": "0.6.10", "version": "0.6.11",
"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",
@ -22,6 +22,10 @@
"@tiptap/extension-code-block-lowlight": "^2.11.9", "@tiptap/extension-code-block-lowlight": "^2.11.9",
"@tiptap/extension-highlight": "^2.10.0", "@tiptap/extension-highlight": "^2.10.0",
"@tiptap/extension-placeholder": "^2.10.0", "@tiptap/extension-placeholder": "^2.10.0",
"@tiptap/extension-table": "^2.12.0",
"@tiptap/extension-table-cell": "^2.12.0",
"@tiptap/extension-table-header": "^2.12.0",
"@tiptap/extension-table-row": "^2.12.0",
"@tiptap/extension-typography": "^2.10.0", "@tiptap/extension-typography": "^2.10.0",
"@tiptap/pm": "^2.11.7", "@tiptap/pm": "^2.11.7",
"@tiptap/starter-kit": "^2.10.0", "@tiptap/starter-kit": "^2.10.0",
@ -62,6 +66,7 @@
"prosemirror-schema-basic": "^1.2.3", "prosemirror-schema-basic": "^1.2.3",
"prosemirror-schema-list": "^1.5.1", "prosemirror-schema-list": "^1.5.1",
"prosemirror-state": "^1.4.3", "prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.7.1",
"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",
@ -69,6 +74,7 @@
"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",
"turndown-plugin-gfm": "^1.0.2",
"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",
@ -3173,6 +3179,59 @@
"@tiptap/core": "^2.7.0" "@tiptap/core": "^2.7.0"
} }
}, },
"node_modules/@tiptap/extension-table": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-2.12.0.tgz",
"integrity": "sha512-tT3IbbBal0vPQ1Bc/3Xl+tmqqZQCYWxnycBPl/WZBqhd57DWzfJqRPESwCGUIJgjOtTnipy/ulvj0FxHi1j9JA==",
"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-table-cell": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-cell/-/extension-table-cell-2.12.0.tgz",
"integrity": "sha512-8i35uCkmkSiQxMiZ+DLgT/wj24P5U/Zo3jr1e0tMAAMG7sRO1MljjLmkpV8WCdBo0xoRqzkz4J7Nkq+DtzZv9Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-table-header": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-header/-/extension-table-header-2.12.0.tgz",
"integrity": "sha512-gRKEsy13KKLpg9RxyPeUGqh4BRFSJ2Bc2KQP1ldhef6CPRYHCbGycxXCVQ5aAb7Mhpo54L+AAkmAv1iMHUTflw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-table-row": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-row/-/extension-table-row-2.12.0.tgz",
"integrity": "sha512-AEW/Zl9V0IoaYDBLMhF5lVl0xgoIJs3IuKCsIYxGDlxBfTVFC6PfQzvuy296CMjO5ZcZ0xalVipPV9ggsMRD+w==",
"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",
@ -9809,16 +9868,16 @@
} }
}, },
"node_modules/prosemirror-tables": { "node_modules/prosemirror-tables": {
"version": "1.6.4", "version": "1.7.1",
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.6.4.tgz", "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.7.1.tgz",
"integrity": "sha512-TkDY3Gw52gRFRfRn2f4wJv5WOgAOXLJA2CQJYIJ5+kdFbfj3acR4JUW6LX2e1hiEBiUwvEhzH5a3cZ5YSztpIA==", "integrity": "sha512-eRQ97Bf+i9Eby99QbyAiyov43iOKgWa7QCGly+lrDt7efZ1v8NWolhXiB43hSDGIXT1UXgbs4KJN3a06FGpr1Q==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"prosemirror-keymap": "^1.2.2", "prosemirror-keymap": "^1.2.2",
"prosemirror-model": "^1.24.1", "prosemirror-model": "^1.25.0",
"prosemirror-state": "^1.4.3", "prosemirror-state": "^1.4.3",
"prosemirror-transform": "^1.10.2", "prosemirror-transform": "^1.10.3",
"prosemirror-view": "^1.37.2" "prosemirror-view": "^1.39.1"
} }
}, },
"node_modules/prosemirror-trailing-node": { "node_modules/prosemirror-trailing-node": {
@ -9837,9 +9896,9 @@
} }
}, },
"node_modules/prosemirror-transform": { "node_modules/prosemirror-transform": {
"version": "1.10.2", "version": "1.10.4",
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.10.2.tgz", "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.10.4.tgz",
"integrity": "sha512-2iUq0wv2iRoJO/zj5mv8uDUriOHWzXRnOTVgCzSXnktS/2iQRa3UUQwVlkBlYZFtygw6Nh1+X4mGqoYBINn5KQ==", "integrity": "sha512-pwDy22nAnGqNR1feOQKHxoFkkUtepoFAd3r2hbEDsnf4wp57kKA36hXsB3njA9FtONBEwSDnDeCiJe+ItD+ykw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"prosemirror-model": "^1.21.0" "prosemirror-model": "^1.21.0"
@ -11808,6 +11867,12 @@
"@mixmark-io/domino": "^2.2.0" "@mixmark-io/domino": "^2.2.0"
} }
}, },
"node_modules/turndown-plugin-gfm": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/turndown-plugin-gfm/-/turndown-plugin-gfm-1.0.2.tgz",
"integrity": "sha512-vwz9tfvF7XN/jE0dGoBei3FXWuvll78ohzCZQuOb+ZjWrs3a0XhQVomJEb2Qh4VHTPNRO4GPZh0V7VRbiWwkRg==",
"license": "MIT"
},
"node_modules/tweetnacl": { "node_modules/tweetnacl": {
"version": "0.14.5", "version": "0.14.5",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",

View File

@ -1,6 +1,6 @@
{ {
"name": "open-webui", "name": "open-webui",
"version": "0.6.10", "version": "0.6.11",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "npm run pyodide:fetch && vite dev --host", "dev": "npm run pyodide:fetch && vite dev --host",
@ -66,6 +66,10 @@
"@tiptap/extension-code-block-lowlight": "^2.11.9", "@tiptap/extension-code-block-lowlight": "^2.11.9",
"@tiptap/extension-highlight": "^2.10.0", "@tiptap/extension-highlight": "^2.10.0",
"@tiptap/extension-placeholder": "^2.10.0", "@tiptap/extension-placeholder": "^2.10.0",
"@tiptap/extension-table": "^2.12.0",
"@tiptap/extension-table-cell": "^2.12.0",
"@tiptap/extension-table-header": "^2.12.0",
"@tiptap/extension-table-row": "^2.12.0",
"@tiptap/extension-typography": "^2.10.0", "@tiptap/extension-typography": "^2.10.0",
"@tiptap/pm": "^2.11.7", "@tiptap/pm": "^2.11.7",
"@tiptap/starter-kit": "^2.10.0", "@tiptap/starter-kit": "^2.10.0",
@ -106,6 +110,7 @@
"prosemirror-schema-basic": "^1.2.3", "prosemirror-schema-basic": "^1.2.3",
"prosemirror-schema-list": "^1.5.1", "prosemirror-schema-list": "^1.5.1",
"prosemirror-state": "^1.4.3", "prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.7.1",
"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",
@ -113,6 +118,7 @@
"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",
"turndown-plugin-gfm": "^1.0.2",
"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",

View File

@ -21,9 +21,11 @@ dependencies = [
"aiocache", "aiocache",
"aiofiles", "aiofiles",
"starlette-compress==1.6.0",
"sqlalchemy==2.0.38", "sqlalchemy==2.0.38",
"alembic==1.14.0", "alembic==1.14.0",
"peewee==3.17.9", "peewee==3.18.1",
"peewee-migrate==1.12.2", "peewee-migrate==1.12.2",
"psycopg2-binary==2.9.9", "psycopg2-binary==2.9.9",
"pgvector==0.4.0", "pgvector==0.4.0",

View File

@ -103,7 +103,7 @@ li p {
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
--tw-border-opacity: 1; --tw-border-opacity: 1;
background-color: rgba(236, 236, 236, 0.8); background-color: rgba(215, 215, 215, 0.8);
border-color: rgba(255, 255, 255, var(--tw-border-opacity)); border-color: rgba(255, 255, 255, var(--tw-border-opacity));
border-radius: 9999px; border-radius: 9999px;
border-width: 1px; border-width: 1px;
@ -111,12 +111,12 @@ li p {
/* Dark theme scrollbar styles */ /* Dark theme scrollbar styles */
.dark ::-webkit-scrollbar-thumb { .dark ::-webkit-scrollbar-thumb {
background-color: rgba(42, 42, 42, 0.8); /* Darker color for dark theme */ background-color: rgba(67, 67, 67, 0.8); /* Darker color for dark theme */
border-color: rgba(0, 0, 0, var(--tw-border-opacity)); border-color: rgba(0, 0, 0, var(--tw-border-opacity));
} }
::-webkit-scrollbar { ::-webkit-scrollbar {
height: 0.8rem; height: 0.6rem;
width: 0.4rem; width: 0.4rem;
} }
@ -412,3 +412,29 @@ input[type='number'] {
.hljs-strong { .hljs-strong {
font-weight: 700; font-weight: 700;
} }
/* Table styling for tiptap editors */
.tiptap table {
@apply w-full text-sm text-left text-gray-500 dark:text-gray-400 max-w-full;
}
.tiptap thead {
@apply text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-850 dark:text-gray-400 border-none;
}
.tiptap th,
.tiptap td {
@apply px-3 py-1.5 border border-gray-100 dark:border-gray-850;
}
.tiptap th {
@apply cursor-pointer text-left text-xs text-gray-700 dark:text-gray-400 font-semibold uppercase bg-gray-50 dark:bg-gray-850;
}
.tiptap td {
@apply text-gray-900 dark:text-white w-max;
}
.tiptap tr {
@apply bg-white dark:bg-gray-900 dark:border-gray-850 text-xs;
}

View File

@ -64,9 +64,12 @@ export const updateAudioConfig = async (token: string, payload: OpenAIConfigForm
return res; return res;
}; };
export const transcribeAudio = async (token: string, file: File) => { export const transcribeAudio = async (token: string, file: File, language?: string) => {
const data = new FormData(); const data = new FormData();
data.append('file', file); data.append('file', file);
if (language) {
data.append('language', language);
}
let error = null; let error = null;
const res = await fetch(`${AUDIO_API_BASE_URL}/transcriptions`, { const res = await fetch(`${AUDIO_API_BASE_URL}/transcriptions`, {

View File

@ -111,10 +111,79 @@ export const getChatList = async (token: string = '', page: number | null = null
})); }));
}; };
export const getChatListByUserId = async (token: string = '', userId: string) => { export const getChatListByUserId = async (
token: string = '',
userId: string,
page: number = 1,
filter?: object
) => {
let error = null; let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/list/user/${userId}`, { const searchParams = new URLSearchParams();
searchParams.append('page', `${page}`);
if (filter) {
Object.entries(filter).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
searchParams.append(key, value.toString());
}
});
}
const res = await fetch(
`${WEBUI_API_BASE_URL}/chats/list/user/${userId}?${searchParams.toString()}`,
{
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
}
}
)
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res.map((chat) => ({
...chat,
time_range: getTimeRange(chat.updated_at)
}));
};
export const getArchivedChatList = async (
token: string = '',
page: number = 1,
filter?: object
) => {
let error = null;
const searchParams = new URLSearchParams();
searchParams.append('page', `${page}`);
if (filter) {
Object.entries(filter).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
searchParams.append(key, value.toString());
}
});
}
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/archived?${searchParams.toString()}`, {
method: 'GET', method: 'GET',
headers: { headers: {
Accept: 'application/json', Accept: 'application/json',
@ -145,37 +214,6 @@ export const getChatListByUserId = async (token: string = '', userId: string) =>
})); }));
}; };
export const getArchivedChatList = async (token: string = '') => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/archived`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getAllChats = async (token: string) => { export const getAllChats = async (token: string) => {
let error = null; let error = null;

View File

@ -1,8 +1,12 @@
import { WEBUI_API_BASE_URL } from '$lib/constants'; import { WEBUI_API_BASE_URL } from '$lib/constants';
export const uploadFile = async (token: string, file: File) => { export const uploadFile = async (token: string, file: File, metadata?: object | null) => {
const data = new FormData(); const data = new FormData();
data.append('file', file); data.append('file', file);
if (metadata) {
data.append('metadata', JSON.stringify(metadata));
}
let error = null; let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/files/`, { const res = await fetch(`${WEBUI_API_BASE_URL}/files/`, {

View File

@ -62,6 +62,40 @@ export const getFunctions = async (token: string = '') => {
return res; return res;
}; };
export const loadFunctionByUrl = async (token: string = '', url: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/functions/load/url`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({
url
})
})
.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 exportFunctions = async (token: string = '') => { export const exportFunctions = async (token: string = '') => {
let error = null; let error = null;

View File

@ -346,11 +346,15 @@ export const getToolServersData = async (i18n, servers: object[]) => {
.map(async (server) => { .map(async (server) => {
const data = await getToolServerData( const data = await getToolServerData(
(server?.auth_type ?? 'bearer') === 'bearer' ? server?.key : localStorage.token, (server?.auth_type ?? 'bearer') === 'bearer' ? server?.key : localStorage.token,
server?.url + '/' + (server?.path ?? 'openapi.json') (server?.path ?? '').includes('://')
? server?.path
: `${server?.url}${(server?.path ?? '').startsWith('/') ? '' : '/'}${server?.path}`
).catch((err) => { ).catch((err) => {
toast.error( toast.error(
i18n.t(`Failed to connect to {{URL}} OpenAPI tool server`, { i18n.t(`Failed to connect to {{URL}} OpenAPI tool server`, {
URL: server?.url + '/' + (server?.path ?? 'openapi.json') URL: (server?.path ?? '').includes('://')
? server?.path
: `${server?.url}${(server?.path ?? '').startsWith('/') ? '' : '/'}${server?.path}`
}) })
); );
return null; return null;

View File

@ -355,6 +355,31 @@ export const generateChatCompletion = async (token: string = '', body: object) =
return [res, controller]; return [res, controller];
}; };
export const unloadModel = async (token: string, tagName: string) => {
let error = null;
const res = await fetch(`${OLLAMA_API_BASE_URL}/api/unload`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
name: tagName
})
}).catch((err) => {
error = err;
return null;
});
if (error) {
throw error;
}
return res;
};
export const createModel = async (token: string, payload: object, urlIdx: string | null = null) => { export const createModel = async (token: string, payload: object, urlIdx: string | null = null) => {
let error = null; let error = null;

View File

@ -22,7 +22,6 @@
export let edit = false; export let edit = false;
export let direct = false; export let direct = false;
export let connection = null; export let connection = null;
let url = ''; let url = '';
@ -33,6 +32,9 @@
let accessControl = {}; let accessControl = {};
let name = '';
let description = '';
let enable = true; let enable = true;
let loading = false; let loading = false;
@ -51,7 +53,7 @@
if (direct) { if (direct) {
const res = await getToolServerData( const res = await getToolServerData(
auth_type === 'bearer' ? key : localStorage.token, auth_type === 'bearer' ? key : localStorage.token,
`${url}/${path}` path.includes('://') ? path : `${url}${path.startsWith('/') ? '' : '/'}${path}`
).catch((err) => { ).catch((err) => {
toast.error($i18n.t('Connection failed')); toast.error($i18n.t('Connection failed'));
}); });
@ -69,6 +71,10 @@
config: { config: {
enable: enable, enable: enable,
access_control: accessControl access_control: accessControl
},
info: {
name,
description
} }
}).catch((err) => { }).catch((err) => {
toast.error($i18n.t('Connection failed')); toast.error($i18n.t('Connection failed'));
@ -95,6 +101,10 @@
config: { config: {
enable: enable, enable: enable,
access_control: accessControl access_control: accessControl
},
info: {
name: name,
description: description
} }
}; };
@ -108,6 +118,9 @@
key = ''; key = '';
auth_type = 'bearer'; auth_type = 'bearer';
name = '';
description = '';
enable = true; enable = true;
accessControl = null; accessControl = null;
}; };
@ -120,6 +133,9 @@
auth_type = connection?.auth_type ?? 'bearer'; auth_type = connection?.auth_type ?? 'bearer';
key = connection?.key ?? ''; key = connection?.key ?? '';
name = connection.info?.name ?? '';
description = connection.info?.description ?? '';
enable = connection.config?.enable ?? true; enable = connection.config?.enable ?? true;
accessControl = connection.config?.access_control ?? null; accessControl = connection.config?.access_control ?? null;
} }
@ -221,12 +237,11 @@
</div> </div>
<div class="flex-1 flex items-center"> <div class="flex-1 flex items-center">
<div class="text-sm">/</div>
<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 placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
type="text" type="text"
bind:value={path} bind:value={path}
placeholder={$i18n.t('openapi.json Path')} placeholder={$i18n.t('openapi.json URL or Path')}
autocomplete="off" autocomplete="off"
required required
/> />
@ -236,7 +251,7 @@
<div class="text-xs text-gray-500 mt-1"> <div class="text-xs text-gray-500 mt-1">
{$i18n.t(`WebUI will make requests to "{{url}}"`, { {$i18n.t(`WebUI will make requests to "{{url}}"`, {
url: `${url}/${path}` url: path.includes('://') ? path : `${url}${path.startsWith('/') ? '' : '/'}${path}`
})} })}
</div> </div>
@ -276,6 +291,39 @@
{#if !direct} {#if !direct}
<hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" /> <hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" />
<div class="flex gap-2">
<div class="flex flex-col w-full">
<div class=" mb-0.5 text-xs text-gray-500">{$i18n.t('Name')}</div>
<div class="flex-1">
<input
class="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
type="text"
bind:value={name}
placeholder={$i18n.t('Enter name')}
autocomplete="off"
required
/>
</div>
</div>
</div>
<div class="flex flex-col w-full mt-2">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Description')}</div>
<div class="flex-1">
<input
class="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
type="text"
bind:value={description}
placeholder={$i18n.t('Enter description')}
autocomplete="off"
/>
</div>
</div>
<hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" />
<div class="my-2 -mx-2"> <div class="my-2 -mx-2">
<div class="px-3 py-2 bg-gray-50 dark:bg-gray-950 rounded-lg"> <div class="px-3 py-2 bg-gray-50 dark:bg-gray-950 rounded-lg">
<AccessControl bind:accessControl /> <AccessControl bind:accessControl />

View File

@ -32,6 +32,9 @@
import Search from '../icons/Search.svelte'; import Search from '../icons/Search.svelte';
import Plus from '../icons/Plus.svelte'; import Plus from '../icons/Plus.svelte';
import ChevronRight from '../icons/ChevronRight.svelte'; import ChevronRight from '../icons/ChevronRight.svelte';
import XMark from '../icons/XMark.svelte';
import AddFunctionMenu from './Functions/AddFunctionMenu.svelte';
import ImportModal from './Functions/ImportModal.svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
@ -40,6 +43,8 @@
let functionsImportInputElement: HTMLInputElement; let functionsImportInputElement: HTMLInputElement;
let importFiles; let importFiles;
let showImportModal = false;
let showConfirm = false; let showConfirm = false;
let query = ''; let query = '';
@ -196,6 +201,16 @@
</title> </title>
</svelte:head> </svelte:head>
<ImportModal
bind:show={showImportModal}
onImport={(func) => {
sessionStorage.function = JSON.stringify({
...func
});
goto('/admin/functions/create');
}}
/>
<div class="flex flex-col gap-1 mt-1.5 mb-2"> <div class="flex flex-col gap-1 mt-1.5 mb-2">
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<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">
@ -215,15 +230,36 @@
bind:value={query} bind:value={query}
placeholder={$i18n.t('Search Functions')} placeholder={$i18n.t('Search Functions')}
/> />
{#if query}
<div class="self-center pl-1.5 translate-y-[0.5px] rounded-l-xl bg-transparent">
<button
class="p-0.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-900 transition"
on:click={() => {
query = '';
}}
>
<XMark className="size-3" strokeWidth="2" />
</button>
</div>
{/if}
</div> </div>
<div> <div>
<a <AddFunctionMenu
class=" px-2 py-2 rounded-xl hover:bg-gray-700/10 dark:hover:bg-gray-100/10 dark:text-gray-300 dark:hover:text-white transition font-medium text-sm flex items-center space-x-1" createHandler={() => {
href="/admin/functions/create" goto('/admin/functions/create');
}}
importFromLinkHandler={() => {
showImportModal = true;
}}
> >
<Plus className="size-3.5" /> <div
</a> class=" px-2 py-2 rounded-xl hover:bg-gray-700/10 dark:hover:bg-gray-100/10 dark:text-gray-300 dark:hover:text-white transition font-medium text-sm flex items-center space-x-1"
>
<Plus className="size-3.5" />
</div>
</AddFunctionMenu>
</div> </div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,77 @@
<script lang="ts">
import { DropdownMenu } from 'bits-ui';
import { flyAndScale } from '$lib/utils/transitions';
import { getContext } from 'svelte';
import Dropdown from '$lib/components/common/Dropdown.svelte';
import GarbageBin from '$lib/components/icons/GarbageBin.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Share from '$lib/components/icons/Share.svelte';
import DocumentDuplicate from '$lib/components/icons/DocumentDuplicate.svelte';
import ArrowDownTray from '$lib/components/icons/ArrowDownTray.svelte';
import Switch from '$lib/components/common/Switch.svelte';
import GlobeAlt from '$lib/components/icons/GlobeAlt.svelte';
import Github from '$lib/components/icons/Github.svelte';
import Plus from '$lib/components/icons/Plus.svelte';
import Pencil from '$lib/components/icons/Pencil.svelte';
import PencilSolid from '$lib/components/icons/PencilSolid.svelte';
import Link from '$lib/components/icons/Link.svelte';
const i18n = getContext('i18n');
export let createHandler: Function;
export let importFromLinkHandler: Function;
export let onClose: Function = () => {};
let show = false;
</script>
<Dropdown
bind:show
on:change={(e) => {
if (e.detail === false) {
onClose();
}
}}
>
<Tooltip content={$i18n.t('Create')}>
<slot />
</Tooltip>
<div slot="content">
<DropdownMenu.Content
class="w-full max-w-[190px] text-sm rounded-xl px-1 py-1.5 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg font-primary"
sideOffset={-2}
side="bottom"
align="start"
transition={flyAndScale}
>
<button
class="flex rounded-md py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition"
on:click={async () => {
createHandler();
show = false;
}}
>
<div class=" self-center mr-2">
<PencilSolid />
</div>
<div class=" self-center truncate">{$i18n.t('New Function')}</div>
</button>
<button
class="flex rounded-md py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition"
on:click={async () => {
importFromLinkHandler();
show = false;
}}
>
<div class=" self-center mr-2">
<Link />
</div>
<div class=" self-center truncate">{$i18n.t('Import From Link')}</div>
</button>
</DropdownMenu.Content>
</div>
</Dropdown>

View File

@ -0,0 +1,145 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { getContext, onMount } from 'svelte';
const i18n = getContext('i18n');
import Modal from '$lib/components/common/Modal.svelte';
import { loadFunctionByUrl } from '$lib/apis/functions';
import { extractFrontmatter } from '$lib/utils';
export let show = false;
export let onImport = (e) => {};
export let onClose = () => {};
let loading = false;
let url = '';
const submitHandler = async () => {
loading = true;
if (!url) {
toast.error($i18n.t('Please enter a valid URL'));
loading = false;
return;
}
const res = await loadFunctionByUrl(localStorage.token, url).catch((err) => {
toast.error(`${err}`);
return null;
});
if (res) {
toast.success($i18n.t('Function loaded successfully'));
let func = res;
func.id = func.id || func.name.replace(/\s+/g, '_').toLowerCase();
const frontmatter = extractFrontmatter(res.content); // Ensure frontmatter is extracted
if (frontmatter?.title) {
func.name = frontmatter.title;
}
func.meta = {
...(func.meta ?? {}),
description: frontmatter?.description ?? func.name
};
onImport(func);
show = false;
}
};
</script>
<Modal size="sm" bind:show>
<div>
<div class=" flex justify-between dark:text-gray-300 px-5 pt-4 pb-2">
<div class=" text-lg font-medium self-center">{$i18n.t('Import')}</div>
<button
class="self-center"
on:click={() => {
show = false;
}}
>
<svg
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>
</div>
<div class="flex flex-col md:flex-row w-full px-4 pb-3 md:space-x-4 dark:text-gray-200">
<div class=" flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
<form
class="flex flex-col w-full"
on:submit|preventDefault={() => {
submitHandler();
}}
>
<div class="px-1">
<div class="flex flex-col w-full">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('URL')}</div>
<div class="flex-1">
<input
class="w-full text-sm bg-transparent disabled:text-gray-500 dark:disabled:text-gray-500 outline-hidden"
type="url"
bind:value={url}
placeholder={$i18n.t('Enter the URL of the function to import')}
required
/>
</div>
</div>
</div>
<div class="flex justify-end pt-3 text-sm font-medium">
<button
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center {loading
? ' cursor-not-allowed'
: ''}"
type="submit"
disabled={loading}
>
{$i18n.t('Import')}
{#if loading}
<div class="ml-2 self-center">
<svg
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>
{/if}
</button>
</div>
</form>
</div>
</div>
</div>
</Modal>

View File

@ -771,6 +771,26 @@
</div> </div>
</div> </div>
{/if} {/if}
{#if RAGConfig.ENABLE_RAG_HYBRID_SEARCH === true}
<div class="mb-2.5 flex w-full justify-between">
<div class="self-center text-xs font-medium">
{$i18n.t('Weight of BM25 Retrieval')}
</div>
<div class="flex items-center relative">
<input
class="flex-1 w-full text-sm bg-transparent outline-hidden"
type="number"
step="0.01"
placeholder={$i18n.t('Enter BM25 Weight')}
bind:value={RAGConfig.HYBRID_BM25_WEIGHT}
autocomplete="off"
min="0.0"
max="1.0"
/>
</div>
</div>
{/if}
{/if} {/if}
<div class=" mb-2.5 flex flex-col w-full justify-between"> <div class=" mb-2.5 flex flex-col w-full justify-between">

View File

@ -84,7 +84,7 @@
if (res) { if (res) {
saveHandler(); saveHandler();
} else { } else {
toast.error(i18n.t('Failed to update settings')); toast.error($i18n.t('Failed to update settings'));
} }
}; };

View File

@ -1,4 +1,7 @@
<script lang="ts"> <script lang="ts">
import fileSaver from 'file-saver';
const { saveAs } = fileSaver;
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
@ -10,13 +13,14 @@
import { banners as _banners } from '$lib/stores'; import { banners as _banners } from '$lib/stores';
import type { Banner } from '$lib/types'; import type { Banner } from '$lib/types';
import { getBaseModels } from '$lib/apis/models';
import { getBanners, setBanners } from '$lib/apis/configs'; import { getBanners, setBanners } from '$lib/apis/configs';
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 Textarea from '$lib/components/common/Textarea.svelte'; import Textarea from '$lib/components/common/Textarea.svelte';
import Spinner from '$lib/components/common/Spinner.svelte'; import Spinner from '$lib/components/common/Spinner.svelte';
import { getBaseModels } from '$lib/apis/models'; import Banners from './Interface/Banners.svelte';
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
@ -44,6 +48,7 @@
const updateInterfaceHandler = async () => { const updateInterfaceHandler = async () => {
taskConfig = await updateTaskConfig(localStorage.token, taskConfig); taskConfig = await updateTaskConfig(localStorage.token, taskConfig);
promptSuggestions = promptSuggestions.filter((p) => p.content !== '');
promptSuggestions = await setDefaultPromptSuggestions(localStorage.token, promptSuggestions); promptSuggestions = await setDefaultPromptSuggestions(localStorage.token, promptSuggestions);
await updateBanners(); await updateBanners();
@ -355,9 +360,9 @@
<hr class=" border-gray-100 dark:border-gray-850 my-2" /> <hr class=" border-gray-100 dark:border-gray-850 my-2" />
<div class=" {banners.length > 0 ? ' mb-3' : ''}"> <div class="mb-2.5">
<div class="mb-2.5 flex w-full justify-between"> <div class="flex w-full justify-between">
<div class=" self-center text-sm font-semibold"> <div class=" self-center text-sm">
{$i18n.t('Banners')} {$i18n.t('Banners')}
</div> </div>
@ -393,69 +398,13 @@
</button> </button>
</div> </div>
<div class=" flex flex-col space-y-1"> <Banners bind:banners />
{#each banners as banner, bannerIdx}
<div class=" flex justify-between">
<div
class="flex flex-row flex-1 border rounded-xl border-gray-100 dark:border-gray-850"
>
<select
class="w-fit capitalize rounded-xl py-2 px-4 text-xs bg-transparent outline-hidden"
bind:value={banner.type}
required
>
{#if banner.type == ''}
<option value="" selected disabled class="text-gray-900"
>{$i18n.t('Type')}</option
>
{/if}
<option value="info" class="text-gray-900">{$i18n.t('Info')}</option>
<option value="warning" class="text-gray-900">{$i18n.t('Warning')}</option>
<option value="error" class="text-gray-900">{$i18n.t('Error')}</option>
<option value="success" class="text-gray-900">{$i18n.t('Success')}</option>
</select>
<input
class="pr-5 py-1.5 text-xs w-full bg-transparent outline-hidden"
placeholder={$i18n.t('Content')}
bind:value={banner.content}
/>
<div class="relative top-1.5 -left-2">
<Tooltip content={$i18n.t('Dismissible')} className="flex h-fit items-center">
<Switch bind:state={banner.dismissible} />
</Tooltip>
</div>
</div>
<button
class="px-2"
type="button"
on:click={() => {
banners.splice(bannerIdx, 1);
banners = banners;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
</button>
</div>
{/each}
</div>
</div> </div>
{#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 font-semibold"> <div class=" self-center text-sm">
{$i18n.t('Default Prompt Suggestions')} {$i18n.t('Default Prompt Suggestions')}
</div> </div>
@ -538,6 +487,111 @@
{$i18n.t('Adjusting these settings will apply changes universally to all users.')} {$i18n.t('Adjusting these settings will apply changes universally to all users.')}
</div> </div>
{/if} {/if}
<div class="flex items-center justify-end space-x-2 mt-2">
<input
id="prompt-suggestions-import-input"
type="file"
accept=".json"
hidden
on:change={(e) => {
const files = e.target.files;
if (!files || files.length === 0) {
return;
}
console.log(files);
let reader = new FileReader();
reader.onload = async (event) => {
try {
let suggestions = JSON.parse(event.target.result);
suggestions = suggestions.map((s) => {
if (typeof s.title === 'string') {
s.title = [s.title, ''];
} else if (!Array.isArray(s.title)) {
s.title = ['', ''];
}
return s;
});
promptSuggestions = [...promptSuggestions, ...suggestions];
} catch (error) {
toast.error($i18n.t('Invalid JSON file'));
return;
}
};
reader.readAsText(files[0]);
e.target.value = ''; // Reset the input value
}}
/>
<button
class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 dark:text-gray-200 transition"
type="button"
on:click={() => {
const input = document.getElementById('prompt-suggestions-import-input');
if (input) {
input.click();
}
}}
>
<div class=" self-center mr-2 font-medium line-clamp-1">
{$i18n.t('Import Prompt Suggestions')}
</div>
<div class=" self-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-3.5 h-3.5"
>
<path
fill-rule="evenodd"
d="M4 2a1.5 1.5 0 0 0-1.5 1.5v9A1.5 1.5 0 0 0 4 14h8a1.5 1.5 0 0 0 1.5-1.5V6.621a1.5 1.5 0 0 0-.44-1.06L9.94 2.439A1.5 1.5 0 0 0 8.878 2H4Zm4 9.5a.75.75 0 0 1-.75-.75V8.06l-.72.72a.75.75 0 0 1-1.06-1.06l2-2a.75.75 0 0 1 1.06 0l2 2a.75.75 0 1 1-1.06 1.06l-.72-.72v2.69a.75.75 0 0 1-.75.75Z"
clip-rule="evenodd"
/>
</svg>
</div>
</button>
{#if promptSuggestions.length}
<button
class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 dark:text-gray-200 transition"
type="button"
on:click={async () => {
let blob = new Blob([JSON.stringify(promptSuggestions)], {
type: 'application/json'
});
saveAs(blob, `prompt-suggestions-export-${Date.now()}.json`);
}}
>
<div class=" self-center mr-2 font-medium line-clamp-1">
{$i18n.t('Export Prompt Suggestions')} ({promptSuggestions.length})
</div>
<div class=" self-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-3.5 h-3.5"
>
<path
fill-rule="evenodd"
d="M4 2a1.5 1.5 0 0 0-1.5 1.5v9A1.5 1.5 0 0 0 4 14h8a1.5 1.5 0 0 0 1.5-1.5V6.621a1.5 1.5 0 0 0-.44-1.06L9.94 2.439A1.5 1.5 0 0 0 8.878 2H4Zm4 3.5a.75.75 0 0 1 .75.75v2.69l.72-.72a.75.75 0 1 1 1.06 1.06l-2 2a.75.75 0 0 1-1.06 0l-2-2a.75.75 0 0 1 1.06-1.06l.72.72V6.25A.75.75 0 0 1 8 5.5Z"
clip-rule="evenodd"
/>
</svg>
</div>
</button>
{/if}
</div>
</div> </div>
{/if} {/if}
</div> </div>

View File

@ -0,0 +1,101 @@
<script lang="ts">
import Switch from '$lib/components/common/Switch.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import EllipsisVertical from '$lib/components/icons/EllipsisVertical.svelte';
import Sortable from 'sortablejs';
import { getContext } from 'svelte';
const i18n = getContext('i18n');
export let banners = [];
let sortable = null;
let bannerListElement = null;
const positionChangeHandler = () => {
const bannerIdOrder = Array.from(bannerListElement.children).map((child) =>
child.id.replace('banner-item-', '')
);
// Sort the banners array based on the new order
banners = bannerIdOrder.map((id) => {
const index = banners.findIndex((banner) => banner.id === id);
return banners[index];
});
};
$: if (banners) {
init();
}
const init = () => {
if (sortable) {
sortable.destroy();
}
if (bannerListElement) {
sortable = Sortable.create(bannerListElement, {
animation: 150,
handle: '.item-handle',
onUpdate: async (event) => {
positionChangeHandler();
}
});
}
};
</script>
<div class=" flex flex-col space-y-0.5" bind:this={bannerListElement}>
{#each banners as banner, bannerIdx (banner.id)}
<div class=" flex justify-between items-center -ml-1" id="banner-item-{banner.id}">
<EllipsisVertical className="size-4 cursor-move item-handle" />
<div class="flex flex-row flex-1 gap-2 items-center">
<select
class="w-fit capitalize rounded-xl text-xs bg-transparent outline-hidden text-left pl-1 pr-2"
bind:value={banner.type}
required
>
{#if banner.type == ''}
<option value="" selected disabled class="text-gray-900">{$i18n.t('Type')}</option>
{/if}
<option value="info" class="text-gray-900">{$i18n.t('Info')}</option>
<option value="warning" class="text-gray-900">{$i18n.t('Warning')}</option>
<option value="error" class="text-gray-900">{$i18n.t('Error')}</option>
<option value="success" class="text-gray-900">{$i18n.t('Success')}</option>
</select>
<input
class="pr-5 py-1.5 text-xs w-full bg-transparent outline-hidden"
placeholder={$i18n.t('Content')}
bind:value={banner.content}
/>
<div class="relative -left-2">
<Tooltip content={$i18n.t('Dismissible')} className="flex h-fit items-center">
<Switch bind:state={banner.dismissible} />
</Tooltip>
</div>
</div>
<button
class="pr-3"
type="button"
on:click={() => {
banners.splice(bannerIdx, 1);
banners = banners;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
</button>
</div>
{/each}
</div>

View File

@ -20,6 +20,7 @@
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 Spinner from '$lib/components/common/Spinner.svelte'; import Spinner from '$lib/components/common/Spinner.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import ModelEditor from '$lib/components/workspace/Models/ModelEditor.svelte'; import ModelEditor from '$lib/components/workspace/Models/ModelEditor.svelte';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
@ -33,6 +34,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';
let shiftKey = false; let shiftKey = false;
@ -181,6 +183,17 @@
upsertModelHandler(model); upsertModelHandler(model);
}; };
const copyLinkHandler = async (model) => {
const baseUrl = window.location.origin;
const res = await copyToClipboard(`${baseUrl}/?model=${encodeURIComponent(model.id)}`);
if (res) {
toast.success($i18n.t('Copied link to clipboard'));
} else {
toast.error($i18n.t('Failed to copy link'));
}
};
const exportModelHandler = async (model) => { const exportModelHandler = async (model) => {
let blob = new Blob([JSON.stringify([model])], { let blob = new Blob([JSON.stringify([model])], {
type: 'application/json' type: 'application/json'
@ -271,6 +284,18 @@
bind:value={searchValue} bind:value={searchValue}
placeholder={$i18n.t('Search Models')} placeholder={$i18n.t('Search Models')}
/> />
{#if searchValue}
<div class="self-center pl-1.5 translate-y-[0.5px] rounded-l-xl bg-transparent">
<button
class="p-0.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-900 transition"
on:click={() => {
searchValue = '';
}}
>
<XMark className="size-3" strokeWidth="2" />
</button>
</div>
{/if}
</div> </div>
</div> </div>
</div> </div>
@ -381,6 +406,9 @@
hideHandler={() => { hideHandler={() => {
hideModelHandler(model); hideModelHandler(model);
}} }}
copyLinkHandler={() => {
copyLinkHandler(model);
}}
onClose={() => {}} onClose={() => {}}
> >
<button <button

View File

@ -15,6 +15,7 @@
import ArrowUpCircle from '$lib/components/icons/ArrowUpCircle.svelte'; import ArrowUpCircle from '$lib/components/icons/ArrowUpCircle.svelte';
import { config } from '$lib/stores'; import { config } from '$lib/stores';
import Link from '$lib/components/icons/Link.svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
@ -23,6 +24,7 @@
export let exportHandler: Function; export let exportHandler: Function;
export let hideHandler: Function; export let hideHandler: Function;
export let copyLinkHandler: Function;
export let onClose: Function; export let onClose: Function;
@ -101,6 +103,17 @@
</div> </div>
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
copyLinkHandler();
}}
>
<Link />
<div class="flex items-center">{$i18n.t('Copy Link')}</div>
</DropdownMenu.Item>
<DropdownMenu.Item <DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md" class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => { on:click={() => {

View File

@ -613,6 +613,19 @@
</div> </div>
</div> </div>
<div class=" mb-2.5 flex w-full justify-between">
<div class=" self-center text-xs font-medium">
<Tooltip content={$i18n.t('Bypass Web Loader')} placement="top-start">
{$i18n.t('Bypass Web Loader')}
</Tooltip>
</div>
<div class="flex items-center relative">
<Tooltip content={''}>
<Switch bind:state={webConfig.BYPASS_WEB_SEARCH_WEB_LOADER} />
</Tooltip>
</div>
</div>
<div class=" mb-2.5 flex w-full justify-between"> <div class=" mb-2.5 flex w-full justify-between">
<div class=" self-center text-xs font-medium"> <div class=" self-center text-xs font-medium">
{$i18n.t('Trust Proxy Environment')} {$i18n.t('Trust Proxy Environment')}

View File

@ -165,7 +165,10 @@
getUserList(); getUserList();
}} }}
/> />
<UserChatsModal bind:show={showUserChatsModal} user={selectedUser} />
{#if selectedUser}
<UserChatsModal bind:show={showUserChatsModal} user={selectedUser} />
{/if}
{#if ($config?.license_metadata?.seats ?? null) !== null && total && total > $config?.license_metadata?.seats} {#if ($config?.license_metadata?.seats ?? null) !== null && total && total > $config?.license_metadata?.seats}
<div class=" mt-1 mb-2 text-xs text-red-500"> <div class=" mt-1 mb-2 text-xs text-red-500">

View File

@ -1,10 +1,10 @@
<script lang="ts"> <script lang="ts">
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { getContext } from 'svelte';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { getContext, createEventDispatcher } from 'svelte';
import localizedFormat from 'dayjs/plugin/localizedFormat'; import localizedFormat from 'dayjs/plugin/localizedFormat';
const dispatch = createEventDispatcher();
dayjs.extend(localizedFormat); dayjs.extend(localizedFormat);
import { getChatListByUserId, deleteChatById, getArchivedChatList } from '$lib/apis/chats'; import { getChatListByUserId, deleteChatById, getArchivedChatList } from '$lib/apis/chats';
@ -12,191 +12,105 @@
import Modal from '$lib/components/common/Modal.svelte'; import Modal from '$lib/components/common/Modal.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte';
import Spinner from '$lib/components/common/Spinner.svelte'; import Spinner from '$lib/components/common/Spinner.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte'; import ChatsModal from '$lib/components/layout/ChatsModal.svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
export let show = false; export let show = false;
export let user; export let user;
let chats = null; let chatList = null;
let showDeleteConfirmDialog = false; let page = 1;
let chatToDelete = null;
const deleteChatHandler = async (chatId) => { let query = '';
const res = await deleteChatById(localStorage.token, chatId).catch((error) => { let orderBy = 'updated_at';
toast.error(`${error}`); let direction = 'desc';
});
chats = await getChatListByUserId(localStorage.token, user.id); let filter = {};
$: filter = {
...(query ? { query } : {}),
...(orderBy ? { order_by: orderBy } : {}),
...(direction ? { direction } : {})
};
$: if (filter !== null) {
searchHandler();
}
let allChatsLoaded = false;
let chatListLoading = false;
let searchDebounceTimeout;
const searchHandler = async () => {
if (searchDebounceTimeout) {
clearTimeout(searchDebounceTimeout);
}
page = 1;
chatList = null;
if (query === '') {
chatList = await getChatListByUserId(localStorage.token, user.id, page, filter);
} else {
searchDebounceTimeout = setTimeout(async () => {
chatList = await getChatListByUserId(localStorage.token, user.id, page, filter);
}, 500);
}
if ((chatList ?? []).length === 0) {
allChatsLoaded = true;
} else {
allChatsLoaded = false;
}
};
const loadMoreChats = async () => {
chatListLoading = true;
page += 1;
let newChatList = [];
newChatList = await getChatListByUserId(localStorage.token, user.id, page, filter);
// once the bottom of the list has been reached (no results) there is no need to continue querying
allChatsLoaded = newChatList.length === 0;
if (newChatList.length > 0) {
chatList = [...chatList, ...newChatList];
}
chatListLoading = false;
};
const init = async () => {
chatList = await getChatListByUserId(localStorage.token, user.id, page, filter);
}; };
$: if (show) { $: if (show) {
(async () => { init();
if (user.id) {
chats = await getChatListByUserId(localStorage.token, user.id);
}
})();
} else { } else {
chats = null; chatList = null;
} page = 1;
let sortKey = 'updated_at'; // default sort key allChatsLoaded = false;
let sortOrder = 'desc'; // default sort order chatListLoading = false;
function setSortKey(key) {
if (sortKey === key) {
sortOrder = sortOrder === 'asc' ? 'desc' : 'asc';
} else {
sortKey = key;
sortOrder = 'asc';
}
} }
</script> </script>
<ConfirmDialog <ChatsModal
bind:show={showDeleteConfirmDialog} bind:show
on:confirm={() => { bind:query
if (chatToDelete) { bind:orderBy
deleteChatHandler(chatToDelete); bind:direction
chatToDelete = null; title={$i18n.t("{{user}}'s Chats", { user: user.name })}
} emptyPlaceholder={$i18n.t('No chats found for this user.')}
shareUrl={true}
{chatList}
{allChatsLoaded}
{chatListLoading}
onUpdate={() => {
init();
}} }}
/> loadHandler={loadMoreChats}
></ChatsModal>
<Modal size="lg" bind:show>
<div class=" flex justify-between dark:text-gray-300 px-5 pt-4">
<div class=" text-lg font-medium self-center capitalize">
{$i18n.t("{{user}}'s Chats", { user: user.name })}
</div>
<button
class="self-center"
on:click={() => {
show = false;
}}
>
<svg
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>
</div>
<div class="flex flex-col md:flex-row w-full px-5 pt-2 pb-4 md:space-x-4 dark:text-gray-200">
<div class=" flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
{#if chats}
{#if chats.length > 0}
<div class="text-left text-sm w-full mb-4 max-h-[22rem] overflow-y-scroll">
<div class="relative overflow-x-auto">
<table class="w-full text-sm text-left text-gray-600 dark:text-gray-400 table-auto">
<thead
class="text-xs text-gray-700 uppercase bg-transparent dark:text-gray-200 border-b-2 dark:border-gray-850"
>
<tr>
<th
scope="col"
class="px-3 py-2 cursor-pointer select-none"
on:click={() => setSortKey('title')}
>
{$i18n.t('Title')}
{#if sortKey === 'title'}
{sortOrder === 'asc' ? '▲' : '▼'}
{:else}
<span class="invisible"></span>
{/if}
</th>
<th
scope="col"
class="px-3 py-2 hidden md:flex cursor-pointer select-none justify-end"
on:click={() => setSortKey('updated_at')}
>
{$i18n.t('Updated at')}
{#if sortKey === 'updated_at'}
{sortOrder === 'asc' ? '▲' : '▼'}
{:else}
<span class="invisible"></span>
{/if}
</th>
<th scope="col" class="px-3 py-2 text-right" />
</tr>
</thead>
<tbody>
{#each chats.sort((a, b) => {
if (a[sortKey] < b[sortKey]) return sortOrder === 'asc' ? -1 : 1;
if (a[sortKey] > b[sortKey]) return sortOrder === 'asc' ? 1 : -1;
return 0;
}) as chat, idx}
<tr
class="bg-transparent {idx !== chats.length - 1 &&
'border-b'} dark:bg-gray-900 dark:border-gray-850 text-xs"
>
<td class="px-3 py-1">
<a href="/s/{chat.id}" target="_blank">
<div class=" underline line-clamp-1 max-w-96">
{chat.title}
</div>
</a>
</td>
<td class=" px-3 py-1 hidden md:flex h-[2.5rem] justify-end">
<div class="my-auto shrink-0">
{dayjs(chat.updated_at * 1000).format('LLL')}
</div>
</td>
<td class="px-3 py-1 text-right">
<div class="flex justify-end w-full">
<Tooltip content={$i18n.t('Delete Chat')}>
<button
class="self-center w-fit text-sm px-2 py-2 hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
on:click={async () => {
chatToDelete = chat.id;
showDeleteConfirmDialog = true;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
/>
</svg>
</button>
</Tooltip>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<!-- {#each chats as chat}
<div>
{JSON.stringify(chat)}
</div>
{/each} -->
</div>
{:else}
<div class="text-left text-sm w-full mb-8">
{user.name}
{$i18n.t('has no conversations.')}
</div>
{/if}
{:else}
<Spinner />
{/if}
</div>
</div>
</Modal>

View File

@ -17,7 +17,6 @@
import { WEBUI_API_BASE_URL } from '$lib/constants'; import { WEBUI_API_BASE_URL } from '$lib/constants';
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 { transcribeAudio } from '$lib/apis/audio';
import FilesOverlay from '../chat/MessageInput/FilesOverlay.svelte'; import FilesOverlay from '../chat/MessageInput/FilesOverlay.svelte';
export let placeholder = $i18n.t('Send a Message'); export let placeholder = $i18n.t('Send a Message');
@ -160,7 +159,19 @@
try { try {
// During the file upload, file content is automatically extracted. // During the file upload, file content is automatically extracted.
const uploadedFile = await uploadFile(localStorage.token, file);
// If the file is an audio file, provide the language for STT.
let metadata = null;
if (
(file.type.startsWith('audio/') || file.type.startsWith('video/')) &&
$settings?.audio?.stt?.language
) {
metadata = {
language: $settings?.audio?.stt?.language
};
}
const uploadedFile = await uploadFile(localStorage.token, file, metadata);
if (uploadedFile) { if (uploadedFile) {
console.info('File upload completed:', { console.info('File upload completed:', {

View File

@ -57,8 +57,9 @@
<div class="self-start flex flex-none items-center text-gray-600 dark:text-gray-400"> <div class="self-start flex flex-none items-center text-gray-600 dark:text-gray-400">
{#if $user !== undefined} {#if $user !== undefined}
<UserMenu <UserMenu
className="max-w-[200px]" className="max-w-[240px]"
role={$user?.role} role={$user?.role}
help={true}
on:show={(e) => { on:show={(e) => {
if (e.detail === 'archived-chat') { if (e.detail === 'archived-chat') {
showArchivedChats.set(true); showArchivedChats.set(true);

View File

@ -49,7 +49,8 @@
sleep, sleep,
removeDetails, removeDetails,
getPromptVariables, getPromptVariables,
processDetails processDetails,
removeAllDetails
} from '$lib/utils'; } from '$lib/utils';
import { generateChatCompletion } from '$lib/apis/ollama'; import { generateChatCompletion } from '$lib/apis/ollama';
@ -88,6 +89,7 @@
import Placeholder from './Placeholder.svelte'; import Placeholder from './Placeholder.svelte';
import NotificationToast from '../NotificationToast.svelte'; import NotificationToast from '../NotificationToast.svelte';
import Spinner from '../common/Spinner.svelte'; import Spinner from '../common/Spinner.svelte';
import { fade } from 'svelte/transition';
export let chatIdProp = ''; export let chatIdProp = '';
@ -193,15 +195,27 @@
console.log('saveSessionSelectedModels', selectedModels, sessionStorage.selectedModels); console.log('saveSessionSelectedModels', selectedModels, sessionStorage.selectedModels);
}; };
$: if (selectedModels) { let oldSelectedModelIds = [''];
setToolIds(); $: if (JSON.stringify(selectedModelIds) !== JSON.stringify(oldSelectedModelIds)) {
setFilterIds(); onSelectedModelIdsChange();
} }
$: if (atSelectedModel || selectedModels) { const onSelectedModelIdsChange = () => {
if (oldSelectedModelIds.filter((id) => id).length > 0) {
resetInput();
}
oldSelectedModelIds = selectedModelIds;
};
const resetInput = () => {
console.debug('resetInput');
setToolIds(); setToolIds();
setFilterIds();
} selectedFilterIds = [];
webSearchEnabled = false;
imageGenerationEnabled = false;
codeInterpreterEnabled = false;
};
const setToolIds = async () => { const setToolIds = async () => {
if (!$tools) { if (!$tools) {
@ -213,20 +227,14 @@
} }
const model = atSelectedModel ?? $models.find((m) => m.id === selectedModels[0]); const model = atSelectedModel ?? $models.find((m) => m.id === selectedModels[0]);
if (model) { if (model && model?.info?.meta?.toolIds) {
selectedToolIds = [ selectedToolIds = [
...new Set( ...new Set(
[...selectedToolIds, ...(model?.info?.meta?.toolIds ?? [])].filter((id) => [...(model?.info?.meta?.toolIds ?? [])].filter((id) => $tools.find((t) => t.id === id))
$tools.find((t) => t.id === id)
)
) )
]; ];
} } else {
}; selectedToolIds = [];
const setFilterIds = async () => {
if (selectedModels.length !== 1 && !atSelectedModel) {
selectedFilterIds = [];
} }
}; };
@ -583,9 +591,20 @@
throw new Error('Created file is empty'); throw new Error('Created file is empty');
} }
// If the file is an audio file, provide the language for STT.
let metadata = null;
if (
(file.type.startsWith('audio/') || file.type.startsWith('video/')) &&
$settings?.audio?.stt?.language
) {
metadata = {
language: $settings?.audio?.stt?.language
};
}
// Upload file to server // Upload file to server
console.log('Uploading file to server...'); console.log('Uploading file to server...');
const uploadedFile = await uploadFile(localStorage.token, file); const uploadedFile = await uploadFile(localStorage.token, file, metadata);
if (!uploadedFile) { if (!uploadedFile) {
throw new Error('Server returned null response for file upload'); throw new Error('Server returned null response for file upload');
@ -844,6 +863,8 @@
(chatContent?.models ?? undefined) !== undefined (chatContent?.models ?? undefined) !== undefined
? chatContent.models ? chatContent.models
: [chatContent.models ?? '']; : [chatContent.models ?? ''];
oldSelectedModelIds = selectedModels;
history = history =
(chatContent?.history ?? undefined) !== undefined (chatContent?.history ?? undefined) !== undefined
? chatContent.history ? chatContent.history
@ -1171,7 +1192,7 @@
// Emit chat event for TTS // Emit chat event for TTS
const messageContentParts = getMessageContentParts( const messageContentParts = getMessageContentParts(
message.content, removeAllDetails(message.content),
$config?.audio?.tts?.split_on ?? 'punctuation' $config?.audio?.tts?.split_on ?? 'punctuation'
); );
messageContentParts.pop(); messageContentParts.pop();
@ -1205,7 +1226,7 @@
// Emit chat event for TTS // Emit chat event for TTS
const messageContentParts = getMessageContentParts( const messageContentParts = getMessageContentParts(
message.content, removeAllDetails(message.content),
$config?.audio?.tts?.split_on ?? 'punctuation' $config?.audio?.tts?.split_on ?? 'punctuation'
); );
messageContentParts.pop(); messageContentParts.pop();
@ -1252,9 +1273,10 @@
// Emit chat event for TTS // Emit chat event for TTS
let lastMessageContentPart = let lastMessageContentPart =
getMessageContentParts(message.content, $config?.audio?.tts?.split_on ?? 'punctuation')?.at( getMessageContentParts(
-1 removeAllDetails(message.content),
) ?? ''; $config?.audio?.tts?.split_on ?? 'punctuation'
)?.at(-1) ?? '';
if (lastMessageContentPart) { if (lastMessageContentPart) {
eventTarget.dispatchEvent( eventTarget.dispatchEvent(
new CustomEvent('chat', { new CustomEvent('chat', {
@ -1430,7 +1452,6 @@
model: model.id, model: model.id,
modelName: model.name ?? model.id, modelName: model.name ?? model.id,
modelIdx: modelIdx ? modelIdx : _modelIdx, modelIdx: modelIdx ? modelIdx : _modelIdx,
userContext: null,
timestamp: Math.floor(Date.now() / 1000) // Unix epoch timestamp: Math.floor(Date.now() / 1000) // Unix epoch
}; };
@ -1485,32 +1506,6 @@
let responseMessageId = let responseMessageId =
responseMessageIds[`${modelId}-${modelIdx ? modelIdx : _modelIdx}`]; responseMessageIds[`${modelId}-${modelIdx ? modelIdx : _modelIdx}`];
let responseMessage = _history.messages[responseMessageId];
let userContext = null;
if ($settings?.memory ?? false) {
if (userContext === null) {
const res = await queryMemory(localStorage.token, prompt).catch((error) => {
toast.error(`${error}`);
return null;
});
if (res) {
if (res.documents[0].length > 0) {
userContext = res.documents[0].reduce((acc, doc, index) => {
const createdAtTimestamp = res.metadatas[0][index].created_at;
const createdAtDate = new Date(createdAtTimestamp * 1000)
.toISOString()
.split('T')[0];
return `${acc}${index + 1}. [${createdAtDate}]. ${doc}\n`;
}, '');
}
console.log(userContext);
}
}
}
responseMessage.userContext = userContext;
const chatEventEmitter = await getChatEventEmitter(model.id, _chatId); const chatEventEmitter = await getChatEventEmitter(model.id, _chatId);
scrollToBottom(); scrollToBottom();
@ -1572,7 +1567,7 @@
true; true;
let messages = [ let messages = [
params?.system || $settings.system || (responseMessage?.userContext ?? null) params?.system || $settings.system
? { ? {
role: 'system', role: 'system',
content: `${promptTemplate( content: `${promptTemplate(
@ -1584,11 +1579,7 @@
return undefined; return undefined;
}) })
: undefined : undefined
)}${ )}`
(responseMessage?.userContext ?? null)
? `\n\nUser Context:\n${responseMessage?.userContext ?? ''}`
: ''
}`
} }
: undefined, : undefined,
...createMessagesList(_history, responseMessageId).map((message) => ({ ...createMessagesList(_history, responseMessageId).map((message) => ({
@ -1665,7 +1656,8 @@
$config?.features?.enable_web_search && $config?.features?.enable_web_search &&
($user?.role === 'admin' || $user?.permissions?.features?.web_search) ($user?.role === 'admin' || $user?.permissions?.features?.web_search)
? webSearchEnabled || ($settings?.webSearch ?? false) === 'always' ? webSearchEnabled || ($settings?.webSearch ?? false) === 'always'
: false : false,
memory: $settings?.memory ?? false
}, },
variables: { variables: {
...getPromptVariables( ...getPromptVariables(
@ -2011,196 +2003,198 @@
id="chat-container" id="chat-container"
> >
{#if !loading} {#if !loading}
{#if $settings?.backgroundImageUrl ?? null} <div in:fade={{ duration: 50 }} class="w-full h-full flex flex-col">
<div {#if $settings?.backgroundImageUrl ?? null}
class="absolute {$showSidebar <div
? 'md:max-w-[calc(100%-260px)] md:translate-x-[260px]' class="absolute {$showSidebar
: ''} top-0 left-0 w-full h-full bg-cover bg-center bg-no-repeat" ? 'md:max-w-[calc(100%-260px)] md:translate-x-[260px]'
style="background-image: url({$settings.backgroundImageUrl}) " : ''} top-0 left-0 w-full h-full bg-cover bg-center bg-no-repeat"
/> style="background-image: url({$settings.backgroundImageUrl}) "
<div
class="absolute top-0 left-0 w-full h-full bg-linear-to-t from-white to-white/85 dark:from-gray-900 dark:to-gray-900/90 z-0"
/>
{/if}
<PaneGroup direction="horizontal" class="w-full h-full">
<Pane defaultSize={50} class="h-full flex relative max-w-full flex-col">
<Navbar
bind:this={navbarElement}
chat={{
id: $chatId,
chat: {
title: $chatTitle,
models: selectedModels,
system: $settings.system ?? undefined,
params: params,
history: history,
timestamp: Date.now()
}
}}
{history}
title={$chatTitle}
bind:selectedModels
shareEnabled={!!history.currentId}
{initNewChat}
/> />
<div class="flex flex-col flex-auto z-10 w-full @container"> <div
{#if $settings?.landingPageMode === 'chat' || createMessagesList(history, history.currentId).length > 0} class="absolute top-0 left-0 w-full h-full bg-linear-to-t from-white to-white/85 dark:from-gray-900 dark:to-gray-900/90 z-0"
<div />
class=" pb-2.5 flex flex-col justify-between w-full flex-auto overflow-auto h-0 max-w-full z-10 scrollbar-hidden" {/if}
id="messages-container"
bind:this={messagesContainerElement} <PaneGroup direction="horizontal" class="w-full h-full">
on:scroll={(e) => { <Pane defaultSize={50} class="h-full flex relative max-w-full flex-col">
autoScroll = <Navbar
messagesContainerElement.scrollHeight - messagesContainerElement.scrollTop <= bind:this={navbarElement}
messagesContainerElement.clientHeight + 5; chat={{
}} id: $chatId,
> chat: {
<div class=" h-full w-full flex flex-col"> title: $chatTitle,
<Messages models: selectedModels,
chatId={$chatId} system: $settings.system ?? undefined,
bind:history params: params,
bind:autoScroll history: history,
bind:prompt timestamp: Date.now()
}
}}
{history}
title={$chatTitle}
bind:selectedModels
shareEnabled={!!history.currentId}
{initNewChat}
/>
<div class="flex flex-col flex-auto z-10 w-full @container">
{#if $settings?.landingPageMode === 'chat' || createMessagesList(history, history.currentId).length > 0}
<div
class=" pb-2.5 flex flex-col justify-between w-full flex-auto overflow-auto h-0 max-w-full z-10 scrollbar-hidden"
id="messages-container"
bind:this={messagesContainerElement}
on:scroll={(e) => {
autoScroll =
messagesContainerElement.scrollHeight - messagesContainerElement.scrollTop <=
messagesContainerElement.clientHeight + 5;
}}
>
<div class=" h-full w-full flex flex-col">
<Messages
chatId={$chatId}
bind:history
bind:autoScroll
bind:prompt
{selectedModels}
{atSelectedModel}
{sendPrompt}
{showMessage}
{submitMessage}
{continueResponse}
{regenerateResponse}
{mergeResponses}
{chatActionHandler}
{addMessages}
bottomPadding={files.length > 0}
/>
</div>
</div>
<div class=" pb-[1rem]">
<MessageInput
{history}
{taskIds}
{selectedModels} {selectedModels}
{atSelectedModel} bind:files
{sendPrompt} bind:prompt
{showMessage} bind:autoScroll
{submitMessage} bind:selectedToolIds
{continueResponse} bind:selectedFilterIds
{regenerateResponse} bind:imageGenerationEnabled
{mergeResponses} bind:codeInterpreterEnabled
{chatActionHandler} bind:webSearchEnabled
{addMessages} bind:atSelectedModel
bottomPadding={files.length > 0} toolServers={$toolServers}
transparentBackground={$settings?.backgroundImageUrl ?? false}
{stopResponse}
{createMessagePair}
onChange={(input) => {
if (input.prompt !== null) {
localStorage.setItem(
`chat-input${$chatId ? `-${$chatId}` : ''}`,
JSON.stringify(input)
);
} else {
localStorage.removeItem(`chat-input${$chatId ? `-${$chatId}` : ''}`);
}
}}
on:upload={async (e) => {
const { type, data } = e.detail;
if (type === 'web') {
await uploadWeb(data);
} else if (type === 'youtube') {
await uploadYoutubeTranscription(data);
} else if (type === 'google-drive') {
await uploadGoogleDriveFile(data);
}
}}
on:submit={async (e) => {
if (e.detail || files.length > 0) {
await tick();
submitPrompt(
($settings?.richTextInput ?? true)
? e.detail.replaceAll('\n\n', '\n')
: e.detail
);
}
}}
/>
<div
class="absolute bottom-1 text-xs text-gray-500 text-center line-clamp-1 right-0 left-0"
>
<!-- {$i18n.t('LLMs can make mistakes. Verify important information.')} -->
</div>
</div>
{:else}
<div class="overflow-auto w-full h-full flex items-center">
<Placeholder
{history}
{selectedModels}
bind:files
bind:prompt
bind:autoScroll
bind:selectedToolIds
bind:selectedFilterIds
bind:imageGenerationEnabled
bind:codeInterpreterEnabled
bind:webSearchEnabled
bind:atSelectedModel
transparentBackground={$settings?.backgroundImageUrl ?? false}
toolServers={$toolServers}
{stopResponse}
{createMessagePair}
on:upload={async (e) => {
const { type, data } = e.detail;
if (type === 'web') {
await uploadWeb(data);
} else if (type === 'youtube') {
await uploadYoutubeTranscription(data);
}
}}
on:submit={async (e) => {
if (e.detail || files.length > 0) {
await tick();
submitPrompt(
($settings?.richTextInput ?? true)
? e.detail.replaceAll('\n\n', '\n')
: e.detail
);
}
}}
/> />
</div> </div>
</div> {/if}
</div>
</Pane>
<div class=" pb-[1rem]"> <ChatControls
<MessageInput bind:this={controlPaneComponent}
{history} bind:history
{taskIds} bind:chatFiles
{selectedModels} bind:params
bind:files bind:files
bind:prompt bind:pane={controlPane}
bind:autoScroll chatId={$chatId}
bind:selectedToolIds modelId={selectedModelIds?.at(0) ?? null}
bind:selectedFilterIds models={selectedModelIds.reduce((a, e, i, arr) => {
bind:imageGenerationEnabled const model = $models.find((m) => m.id === e);
bind:codeInterpreterEnabled if (model) {
bind:webSearchEnabled return [...a, model];
bind:atSelectedModel }
toolServers={$toolServers} return a;
transparentBackground={$settings?.backgroundImageUrl ?? false} }, [])}
{stopResponse} {submitPrompt}
{createMessagePair} {stopResponse}
onChange={(input) => { {showMessage}
if (input.prompt !== null) { {eventTarget}
localStorage.setItem( />
`chat-input${$chatId ? `-${$chatId}` : ''}`, </PaneGroup>
JSON.stringify(input) </div>
);
} else {
localStorage.removeItem(`chat-input${$chatId ? `-${$chatId}` : ''}`);
}
}}
on:upload={async (e) => {
const { type, data } = e.detail;
if (type === 'web') {
await uploadWeb(data);
} else if (type === 'youtube') {
await uploadYoutubeTranscription(data);
} else if (type === 'google-drive') {
await uploadGoogleDriveFile(data);
}
}}
on:submit={async (e) => {
if (e.detail || files.length > 0) {
await tick();
submitPrompt(
($settings?.richTextInput ?? true)
? e.detail.replaceAll('\n\n', '\n')
: e.detail
);
}
}}
/>
<div
class="absolute bottom-1 text-xs text-gray-500 text-center line-clamp-1 right-0 left-0"
>
<!-- {$i18n.t('LLMs can make mistakes. Verify important information.')} -->
</div>
</div>
{:else}
<div class="overflow-auto w-full h-full flex items-center">
<Placeholder
{history}
{selectedModels}
bind:files
bind:prompt
bind:autoScroll
bind:selectedToolIds
bind:selectedFilterIds
bind:imageGenerationEnabled
bind:codeInterpreterEnabled
bind:webSearchEnabled
bind:atSelectedModel
transparentBackground={$settings?.backgroundImageUrl ?? false}
toolServers={$toolServers}
{stopResponse}
{createMessagePair}
on:upload={async (e) => {
const { type, data } = e.detail;
if (type === 'web') {
await uploadWeb(data);
} else if (type === 'youtube') {
await uploadYoutubeTranscription(data);
}
}}
on:submit={async (e) => {
if (e.detail || files.length > 0) {
await tick();
submitPrompt(
($settings?.richTextInput ?? true)
? e.detail.replaceAll('\n\n', '\n')
: e.detail
);
}
}}
/>
</div>
{/if}
</div>
</Pane>
<ChatControls
bind:this={controlPaneComponent}
bind:history
bind:chatFiles
bind:params
bind:files
bind:pane={controlPane}
chatId={$chatId}
modelId={selectedModelIds?.at(0) ?? null}
models={selectedModelIds.reduce((a, e, i, arr) => {
const model = $models.find((m) => m.id === e);
if (model) {
return [...a, model];
}
return a;
}, [])}
{submitPrompt}
{stopResponse}
{showMessage}
{eventTarget}
/>
</PaneGroup>
{:else if loading} {:else if loading}
<div class=" flex items-center justify-center h-full w-full"> <div class=" flex items-center justify-center h-full w-full">
<div class="m-auto"> <div class="m-auto">

View File

@ -10,7 +10,7 @@
import { chatCompletion } from '$lib/apis/openai'; import { chatCompletion } from '$lib/apis/openai';
import ChatBubble from '$lib/components/icons/ChatBubble.svelte'; import ChatBubble from '$lib/components/icons/ChatBubble.svelte';
import LightBlub from '$lib/components/icons/LightBlub.svelte'; import LightBulb from '$lib/components/icons/LightBulb.svelte';
import Markdown from '../Messages/Markdown.svelte'; import Markdown from '../Messages/Markdown.svelte';
import Skeleton from '../Messages/Skeleton.svelte'; import Skeleton from '../Messages/Skeleton.svelte';
@ -44,7 +44,13 @@
toast.error('Model not selected'); toast.error('Model not selected');
return; return;
} }
prompt = `${floatingInputValue}\n\`\`\`\n${selectedText}\n\`\`\``; prompt = [
// Blockquote each line of the selected text
...selectedText.split('\n').map((line) => `> ${line}`),
'',
// Then your question
floatingInputValue
].join('\n');
floatingInputValue = ''; floatingInputValue = '';
responseContent = ''; responseContent = '';
@ -121,8 +127,11 @@
toast.error('Model not selected'); toast.error('Model not selected');
return; return;
} }
const explainText = $i18n.t('Explain this section to me in more detail'); const quotedText = selectedText
prompt = `${explainText}\n\n\`\`\`\n${selectedText}\n\`\`\``; .split('\n')
.map((line) => `> ${line}`)
.join('\n');
prompt = `${quotedText}\n\nExplain`;
responseContent = ''; responseContent = '';
const [res, controller] = await chatCompletion(localStorage.token, { const [res, controller] = await chatCompletion(localStorage.token, {
@ -256,7 +265,7 @@
explainHandler(); explainHandler();
}} }}
> >
<LightBlub className="size-3 shrink-0" /> <LightBulb className="size-3 shrink-0" />
<div class="shrink-0">{$i18n.t('Explain')}</div> <div class="shrink-0">{$i18n.t('Explain')}</div>
</button> </button>

View File

@ -27,7 +27,6 @@
createMessagesList, createMessagesList,
extractCurlyBraceWords extractCurlyBraceWords
} from '$lib/utils'; } from '$lib/utils';
import { transcribeAudio } from '$lib/apis/audio';
import { uploadFile } from '$lib/apis/files'; import { uploadFile } from '$lib/apis/files';
import { generateAutoCompletion } from '$lib/apis'; import { generateAutoCompletion } from '$lib/apis';
import { deleteFileById } from '$lib/apis/files'; import { deleteFileById } from '$lib/apis/files';
@ -110,7 +109,9 @@
let commandsElement; let commandsElement;
let inputFiles; let inputFiles;
let dragged = false; let dragged = false;
let shiftKey = false;
let user = null; let user = null;
export let placeholder = ''; export let placeholder = '';
@ -151,6 +152,30 @@
.map((id) => ($models.find((model) => model.id === id) || {})?.filters ?? []) .map((id) => ($models.find((model) => model.id === id) || {})?.filters ?? [])
.reduce((acc, filters) => acc.filter((f1) => filters.some((f2) => f2.id === f1.id))); .reduce((acc, filters) => acc.filter((f1) => filters.some((f2) => f2.id === f1.id)));
let showToolsButton = false;
$: showToolsButton = toolServers.length + selectedToolIds.length > 0;
let showWebSearchButton = false;
$: showWebSearchButton =
(atSelectedModel?.id ? [atSelectedModel.id] : selectedModels).length ===
webSearchCapableModels.length &&
$config?.features?.enable_web_search &&
($_user.role === 'admin' || $_user?.permissions?.features?.web_search);
let showImageGenerationButton = false;
$: showImageGenerationButton =
(atSelectedModel?.id ? [atSelectedModel.id] : selectedModels).length ===
imageGenerationCapableModels.length &&
$config?.features?.enable_image_generation &&
($_user.role === 'admin' || $_user?.permissions?.features?.image_generation);
let showCodeInterpreterButton = false;
$: showCodeInterpreterButton =
(atSelectedModel?.id ? [atSelectedModel.id] : selectedModels).length ===
codeInterpreterCapableModels.length &&
$config?.features?.enable_code_interpreter &&
($_user.role === 'admin' || $_user?.permissions?.features?.code_interpreter);
const scrollToBottom = () => { const scrollToBottom = () => {
const element = document.getElementById('messages-container'); const element = document.getElementById('messages-container');
element.scrollTo({ element.scrollTo({
@ -225,8 +250,19 @@
files = [...files, fileItem]; files = [...files, fileItem];
try { try {
// If the file is an audio file, provide the language for STT.
let metadata = null;
if (
(file.type.startsWith('audio/') || file.type.startsWith('video/')) &&
$settings?.audio?.stt?.language
) {
metadata = {
language: $settings?.audio?.stt?.language
};
}
// During the file upload, file content is automatically extracted. // During the file upload, file content is automatically extracted.
const uploadedFile = await uploadFile(localStorage.token, file); const uploadedFile = await uploadFile(localStorage.token, file, metadata);
if (uploadedFile) { if (uploadedFile) {
console.log('File upload completed:', { console.log('File upload completed:', {
@ -318,13 +354,6 @@
}); });
}; };
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
console.log('Escape');
dragged = false;
}
};
const onDragOver = (e) => { const onDragOver = (e) => {
e.preventDefault(); e.preventDefault();
@ -355,6 +384,29 @@
dragged = false; dragged = false;
}; };
const onKeyDown = (e) => {
if (e.key === 'Shift') {
shiftKey = true;
}
if (e.key === 'Escape') {
console.log('Escape');
dragged = false;
}
};
const onKeyUp = (e) => {
if (e.key === 'Shift') {
shiftKey = false;
}
};
const onFocus = () => {};
const onBlur = () => {
shiftKey = false;
};
onMount(async () => { onMount(async () => {
loaded = true; loaded = true;
@ -363,7 +415,11 @@
chatInput?.focus(); chatInput?.focus();
}, 0); }, 0);
window.addEventListener('keydown', handleKeyDown); window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
window.addEventListener('focus', onFocus);
window.addEventListener('blur', onBlur);
await tick(); await tick();
@ -376,7 +432,11 @@
onDestroy(() => { onDestroy(() => {
console.log('destroy'); console.log('destroy');
window.removeEventListener('keydown', handleKeyDown); window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
window.removeEventListener('focus', onFocus);
window.removeEventListener('blur', onBlur);
const dropzoneElement = document.getElementById('chat-container'); const dropzoneElement = document.getElementById('chat-container');
@ -641,7 +701,7 @@
<div class="px-2.5"> <div class="px-2.5">
{#if $settings?.richTextInput ?? true} {#if $settings?.richTextInput ?? true}
<div <div
class="scrollbar-hidden rtl:text-right ltr: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" class="scrollbar-hidden rtl:text-right ltr:text-left bg-transparent dark:text-gray-100 outline-hidden w-full pt-2.5 pb-1 px-1 resize-none h-fit max-h-80 overflow-auto"
id="chat-input-container" id="chat-input-container"
> >
<RichTextInput <RichTextInput
@ -657,7 +717,7 @@
navigator.msMaxTouchPoints > 0 navigator.msMaxTouchPoints > 0
))} ))}
placeholder={placeholder ? placeholder : $i18n.t('Send a Message')} placeholder={placeholder ? placeholder : $i18n.t('Send a Message')}
largeTextAsFile={$settings?.largeTextAsFile ?? false} largeTextAsFile={($settings?.largeTextAsFile ?? false) && !shiftKey}
autocomplete={$config?.features?.enable_autocomplete_generation && autocomplete={$config?.features?.enable_autocomplete_generation &&
($settings?.promptAutocomplete ?? false)} ($settings?.promptAutocomplete ?? false)}
generateAutoCompletion={async (text) => { generateAutoCompletion={async (text) => {
@ -839,7 +899,7 @@
reader.readAsDataURL(blob); reader.readAsDataURL(blob);
} else if (item.type === 'text/plain') { } else if (item.type === 'text/plain') {
if ($settings?.largeTextAsFile ?? false) { if (($settings?.largeTextAsFile ?? false) && !shiftKey) {
const text = clipboardData.getData('text/plain'); const text = clipboardData.getData('text/plain');
if (text.length > PASTED_TEXT_CHARACTER_LIMIT) { if (text.length > PASTED_TEXT_CHARACTER_LIMIT) {
@ -1070,7 +1130,7 @@
reader.readAsDataURL(blob); reader.readAsDataURL(blob);
} else if (item.type === 'text/plain') { } else if (item.type === 'text/plain') {
if ($settings?.largeTextAsFile ?? false) { if (($settings?.largeTextAsFile ?? false) && !shiftKey) {
const text = clipboardData.getData('text/plain'); const text = clipboardData.getData('text/plain');
if (text.length > PASTED_TEXT_CHARACTER_LIMIT) { if (text.length > PASTED_TEXT_CHARACTER_LIMIT) {
@ -1091,8 +1151,8 @@
{/if} {/if}
</div> </div>
<div class=" flex justify-between mt-1 mb-2.5 mx-0.5 max-w-full" dir="ltr"> <div class=" flex justify-between mt-0.5 mb-2.5 mx-0.5 max-w-full" dir="ltr">
<div class="ml-1 self-end flex items-center flex-1 max-w-[80%] gap-0.5"> <div class="ml-1 self-end flex items-center flex-1 max-w-[80%]">
<InputMenu <InputMenu
bind:selectedToolIds bind:selectedToolIds
selectedModels={atSelectedModel ? [atSelectedModel.id] : selectedModels} selectedModels={atSelectedModel ? [atSelectedModel.id] : selectedModels}
@ -1162,31 +1222,35 @@
</button> </button>
</InputMenu> </InputMenu>
<div class="flex gap-1 items-center overflow-x-auto scrollbar-none flex-1"> {#if $_user && (showToolsButton || (toggleFilters && toggleFilters.length > 0) || showWebSearchButton || showImageGenerationButton || showCodeInterpreterButton)}
{#if toolServers.length + selectedToolIds.length > 0} <div
<Tooltip class="flex self-center w-[1px] h-4 mx-1.5 bg-gray-50 dark:bg-gray-800"
content={$i18n.t('{{COUNT}} Available Tools', { />
COUNT: toolServers.length + selectedToolIds.length
})} <div class="flex gap-1 items-center overflow-x-auto scrollbar-none flex-1">
> {#if showToolsButton}
<button <Tooltip
class="translate-y-[0.5px] flex gap-1 items-center text-gray-600 dark:text-gray-300 hover:text-gray-700 dark:hover:text-gray-200 rounded-lg p-1 self-center transition" content={$i18n.t('{{COUNT}} Available Tools', {
aria-label="Available Tools" COUNT: toolServers.length + selectedToolIds.length
type="button" })}
on:click={() => {
showTools = !showTools;
}}
> >
<Wrench className="size-4" strokeWidth="1.75" /> <button
class="translate-y-[0.5px] flex gap-1 items-center text-gray-600 dark:text-gray-300 hover:text-gray-700 dark:hover:text-gray-200 rounded-lg p-1 self-center transition"
aria-label="Available Tools"
type="button"
on:click={() => {
showTools = !showTools;
}}
>
<Wrench className="size-4" strokeWidth="1.75" />
<span class="text-sm font-medium text-gray-600 dark:text-gray-300"> <span class="text-sm font-medium text-gray-600 dark:text-gray-300">
{toolServers.length + selectedToolIds.length} {toolServers.length + selectedToolIds.length}
</span> </span>
</button> </button>
</Tooltip> </Tooltip>
{/if} {/if}
{#if $_user}
{#each toggleFilters as filter, filterIdx (filter.id)} {#each toggleFilters as filter, filterIdx (filter.id)}
<Tooltip content={filter?.description} placement="top"> <Tooltip content={filter?.description} placement="top">
<button <button
@ -1200,17 +1264,17 @@
} }
}} }}
type="button" type="button"
class="px-1.5 @xl:px-2.5 py-1.5 flex gap-1.5 items-center text-sm rounded-full font-medium transition-colors duration-300 focus:outline-hidden max-w-full overflow-hidden border {selectedFilterIds.includes( class="px-2 @xl:px-2.5 py-2 flex gap-1.5 items-center text-sm rounded-full transition-colors duration-300 focus:outline-hidden max-w-full overflow-hidden hover:bg-gray-50 dark:hover:bg-gray-800 {selectedFilterIds.includes(
filter.id filter.id
) )
? 'bg-gray-50 dark:bg-gray-400/10 border-gray-100 dark:border-gray-700 text-gray-600 dark:text-gray-400' ? 'text-sky-500 dark:text-sky-300 bg-sky-50 dark:bg-sky-200/5'
: 'bg-transparent border-transparent text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 '} capitalize" : 'bg-transparent text-gray-600 dark:text-gray-300 '} capitalize"
> >
{#if filter?.icon} {#if filter?.icon}
<div class="size-5 items-center flex justify-center"> <div class="size-4 items-center flex justify-center">
<img <img
src={filter.icon} src={filter.icon}
class="size-4.5 {filter.icon.includes('svg') class="size-3.5 {filter.icon.includes('svg')
? 'dark:invert-[80%]' ? 'dark:invert-[80%]'
: ''}" : ''}"
style="fill: currentColor;" style="fill: currentColor;"
@ -1218,79 +1282,80 @@
/> />
</div> </div>
{:else} {:else}
<Sparkles className="size-5" strokeWidth="1.75" /> <Sparkles className="size-4" strokeWidth="1.75" />
{/if} {/if}
<span <span
class="hidden @xl:block whitespace-nowrap overflow-hidden text-ellipsis translate-y-[0.5px]" class="hidden @xl:block whitespace-nowrap overflow-hidden text-ellipsis leading-none pr-0.5"
>{filter?.name}</span >{filter?.name}</span
> >
</button> </button>
</Tooltip> </Tooltip>
{/each} {/each}
{#if (atSelectedModel?.id ? [atSelectedModel.id] : selectedModels).length === webSearchCapableModels.length && $config?.features?.enable_web_search && ($_user.role === 'admin' || $_user?.permissions?.features?.web_search)} {#if showWebSearchButton}
<Tooltip content={$i18n.t('Search the internet')} placement="top"> <Tooltip content={$i18n.t('Search the internet')} placement="top">
<button <button
on:click|preventDefault={() => (webSearchEnabled = !webSearchEnabled)} on:click|preventDefault={() => (webSearchEnabled = !webSearchEnabled)}
type="button" type="button"
class="px-1.5 @xl:px-2.5 py-1.5 flex gap-1.5 items-center text-sm rounded-full font-medium transition-colors duration-300 focus:outline-hidden max-w-full overflow-hidden border {webSearchEnabled || class="px-2 @xl:px-2.5 py-2 flex gap-1.5 items-center text-sm rounded-full transition-colors duration-300 focus:outline-hidden max-w-full overflow-hidden hover:bg-gray-50 dark:hover:bg-gray-800 {webSearchEnabled ||
($settings?.webSearch ?? false) === 'always' ($settings?.webSearch ?? false) === 'always'
? 'bg-blue-100 dark:bg-blue-500/20 border-blue-400/20 text-blue-500 dark:text-blue-400' ? ' text-sky-500 dark:text-sky-300 bg-sky-50 dark:bg-sky-200/5'
: 'bg-transparent border-transparent text-gray-600 dark:text-gray-300 border-gray-200 hover:bg-gray-50 dark:hover:bg-gray-800'}" : 'bg-transparent text-gray-600 dark:text-gray-300 '}"
> >
<GlobeAlt className="size-5" strokeWidth="1.75" /> <GlobeAlt className="size-4" strokeWidth="1.75" />
<span <span
class="hidden @xl:block whitespace-nowrap overflow-hidden text-ellipsis translate-y-[0.5px]" class="hidden @xl:block whitespace-nowrap overflow-hidden text-ellipsis leading-none pr-0.5"
>{$i18n.t('Web Search')}</span >{$i18n.t('Web Search')}</span
> >
</button> </button>
</Tooltip> </Tooltip>
{/if} {/if}
{#if (atSelectedModel?.id ? [atSelectedModel.id] : selectedModels).length === imageGenerationCapableModels.length && $config?.features?.enable_image_generation && ($_user.role === 'admin' || $_user?.permissions?.features?.image_generation)} {#if showImageGenerationButton}
<Tooltip content={$i18n.t('Generate an image')} placement="top"> <Tooltip content={$i18n.t('Generate an image')} placement="top">
<button <button
on:click|preventDefault={() => on:click|preventDefault={() =>
(imageGenerationEnabled = !imageGenerationEnabled)} (imageGenerationEnabled = !imageGenerationEnabled)}
type="button" type="button"
class="px-1.5 @xl:px-2.5 py-1.5 flex gap-1.5 items-center text-sm rounded-full font-medium transition-colors duration-300 focus:outline-hidden max-w-full overflow-hidden border {imageGenerationEnabled class="px-2 @xl:px-2.5 py-2 flex gap-1.5 items-center text-sm rounded-full transition-colors duration-300 focus:outline-hidden max-w-full overflow-hidden hover:bg-gray-50 dark:hover:bg-gray-800 {imageGenerationEnabled
? 'bg-gray-50 dark:bg-gray-400/10 border-gray-100 dark:border-gray-700 text-gray-600 dark:text-gray-400' ? ' text-sky-500 dark:text-sky-300 bg-sky-50 dark:bg-sky-200/5'
: 'bg-transparent border-transparent text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 '}" : 'bg-transparent text-gray-600 dark:text-gray-300 '}"
> >
<Photo className="size-5" strokeWidth="1.75" /> <Photo className="size-4" strokeWidth="1.75" />
<span <span
class="hidden @xl:block whitespace-nowrap overflow-hidden text-ellipsis translate-y-[0.5px]" class="hidden @xl:block whitespace-nowrap overflow-hidden text-ellipsis leading-none pr-0.5"
>{$i18n.t('Image')}</span >{$i18n.t('Image')}</span
> >
</button> </button>
</Tooltip> </Tooltip>
{/if} {/if}
{#if (atSelectedModel?.id ? [atSelectedModel.id] : selectedModels).length === codeInterpreterCapableModels.length && $config?.features?.enable_code_interpreter && ($_user.role === 'admin' || $_user?.permissions?.features?.code_interpreter)} {#if showCodeInterpreterButton}
<Tooltip content={$i18n.t('Execute code for analysis')} placement="top"> <Tooltip content={$i18n.t('Execute code for analysis')} placement="top">
<button <button
on:click|preventDefault={() => on:click|preventDefault={() =>
(codeInterpreterEnabled = !codeInterpreterEnabled)} (codeInterpreterEnabled = !codeInterpreterEnabled)}
type="button" type="button"
class="px-1.5 @xl:px-2.5 py-1.5 flex gap-1.5 items-center text-sm rounded-full font-medium transition-colors duration-300 focus:outline-hidden max-w-full overflow-hidden border {codeInterpreterEnabled class="px-2 @xl:px-2.5 py-2 flex gap-1.5 items-center text-sm rounded-full transition-colors duration-300 focus:outline-hidden max-w-full overflow-hidden hover:bg-gray-50 dark:hover:bg-gray-800 {codeInterpreterEnabled
? 'bg-gray-50 dark:bg-gray-400/10 border-gray-100 dark:border-gray-700 text-gray-600 dark:text-gray-400 ' ? ' text-sky-500 dark:text-sky-300 bg-sky-50 dark:bg-sky-200/5'
: 'bg-transparent border-transparent text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 '}" : 'bg-transparent text-gray-600 dark:text-gray-300 '}"
> >
<CommandLine className="size-5" strokeWidth="1.75" /> <CommandLine className="size-4" strokeWidth="1.75" />
<span <span
class="hidden @xl:block whitespace-nowrap overflow-hidden text-ellipsis translate-y-[0.5px]" class="hidden @xl:block whitespace-nowrap overflow-hidden text-ellipsis leading-none pr-0.5"
>{$i18n.t('Code Interpreter')}</span >{$i18n.t('Code Interpreter')}</span
> >
</button> </button>
</Tooltip> </Tooltip>
{/if} {/if}
{/if} </div>
</div> {/if}
</div> </div>
<div class="self-end flex space-x-1 mr-1 shrink-0"> <div class="self-end flex space-x-1 mr-1 shrink-0">
{#if (!history?.currentId || history.messages[history.currentId]?.done == true) && ($_user?.role === 'admin' || ($_user?.permissions?.chat?.stt ?? true))} {#if (!history?.currentId || history.messages[history.currentId]?.done == true) && ($_user?.role === 'admin' || ($_user?.permissions?.chat?.stt ?? true))}
<Tooltip content={$i18n.t('Record voice')}> <!-- {$i18n.t('Record voice')} -->
<Tooltip content={$i18n.t('Dictate')}>
<button <button
id="voice-input-button" id="voice-input-button"
class=" text-gray-600 dark:text-gray-300 hover:text-gray-700 dark:hover:text-gray-200 transition rounded-full p-1.5 mr-0.5 self-center" class=" text-gray-600 dark:text-gray-300 hover:text-gray-700 dark:hover:text-gray-200 transition rounded-full p-1.5 mr-0.5 self-center"
@ -1364,7 +1429,8 @@
</div> </div>
{:else if prompt === '' && files.length === 0 && ($_user?.role === 'admin' || ($_user?.permissions?.chat?.call ?? true))} {:else if prompt === '' && files.length === 0 && ($_user?.role === 'admin' || ($_user?.permissions?.chat?.call ?? true))}
<div class=" flex items-center"> <div class=" flex items-center">
<Tooltip content={$i18n.t('Call')}> <!-- {$i18n.t('Call')} -->
<Tooltip content={$i18n.t('Voice mode')}>
<button <button
class=" bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full p-1.5 self-center" class=" bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full p-1.5 self-center"
type="button" type="button"

View File

@ -153,7 +153,11 @@
await tick(); await tick();
const file = blobToFile(audioBlob, 'recording.wav'); const file = blobToFile(audioBlob, 'recording.wav');
const res = await transcribeAudio(localStorage.token, file).catch((error) => { const res = await transcribeAudio(
localStorage.token,
file,
$settings?.audio?.stt?.language
).catch((error) => {
toast.error(`${error}`); toast.error(`${error}`);
return null; return null;
}); });

View File

@ -150,7 +150,11 @@
return; return;
} }
const res = await transcribeAudio(localStorage.token, file).catch((error) => { const res = await transcribeAudio(
localStorage.token,
file,
$settings?.audio?.stt?.language
).catch((error) => {
toast.error(`${error}`); toast.error(`${error}`);
return null; return null;
}); });

View File

@ -117,6 +117,17 @@
{/if} {/if}
</div> </div>
</Tooltip> </Tooltip>
{#if document.metadata?.parameters}
<div class="text-sm font-medium dark:text-gray-300 mt-2">
{$i18n.t('Parameters')}
</div>
<pre
class="text-sm dark:text-gray-400 bg-gray-50 dark:bg-gray-800 p-2 rounded-md overflow-auto max-h-40">{JSON.stringify(
document.metadata.parameters,
null,
2
)}</pre>
{/if}
{#if showRelevance} {#if showRelevance}
<div class="text-sm font-medium dark:text-gray-300 mt-2"> <div class="text-sm font-medium dark:text-gray-300 mt-2">
{$i18n.t('Relevance')} {$i18n.t('Relevance')}

View File

@ -24,7 +24,7 @@
TIP: { TIP: {
border: 'border-emerald-500', border: 'border-emerald-500',
text: 'text-emerald-500', text: 'text-emerald-500',
icon: LightBlub icon: LightBulb
}, },
IMPORTANT: { IMPORTANT: {
border: 'border-purple-500', border: 'border-purple-500',
@ -65,7 +65,7 @@
<script lang="ts"> <script lang="ts">
import Info from '$lib/components/icons/Info.svelte'; import Info from '$lib/components/icons/Info.svelte';
import Star from '$lib/components/icons/Star.svelte'; import Star from '$lib/components/icons/Star.svelte';
import LightBlub from '$lib/components/icons/LightBlub.svelte'; import LightBulb from '$lib/components/icons/LightBulb.svelte';
import Bolt from '$lib/components/icons/Bolt.svelte'; import Bolt from '$lib/components/icons/Bolt.svelte';
import ArrowRightCircle from '$lib/components/icons/ArrowRightCircle.svelte'; import ArrowRightCircle from '$lib/components/icons/ArrowRightCircle.svelte';
import MarkdownTokens from './MarkdownTokens.svelte'; import MarkdownTokens from './MarkdownTokens.svelte';

View File

@ -212,6 +212,8 @@
speaking = true; speaking = true;
const content = removeAllDetails(content);
if ($config.audio.tts.engine === '') { if ($config.audio.tts.engine === '') {
let voices = []; let voices = [];
const getVoicesLoop = setInterval(() => { const getVoicesLoop = setInterval(() => {
@ -228,7 +230,7 @@
console.log(voice); console.log(voice);
const speak = new SpeechSynthesisUtterance(message.content); const speak = new SpeechSynthesisUtterance(content);
speak.rate = $settings.audio?.tts?.playbackRate ?? 1; speak.rate = $settings.audio?.tts?.playbackRate ?? 1;
console.log(speak); console.log(speak);
@ -251,7 +253,7 @@
loadingSpeech = true; loadingSpeech = true;
const messageContentParts: string[] = getMessageContentParts( const messageContentParts: string[] = getMessageContentParts(
message.content, content,
$config?.audio?.tts?.split_on ?? 'punctuation' $config?.audio?.tts?.split_on ?? 'punctuation'
); );

View File

@ -10,7 +10,7 @@
import Check from '$lib/components/icons/Check.svelte'; import Check from '$lib/components/icons/Check.svelte';
import Search from '$lib/components/icons/Search.svelte'; import Search from '$lib/components/icons/Search.svelte';
import { deleteModel, getOllamaVersion, pullModel } from '$lib/apis/ollama'; import { deleteModel, getOllamaVersion, pullModel, unloadModel } from '$lib/apis/ollama';
import { import {
user, user,
@ -29,6 +29,10 @@
import Switch from '$lib/components/common/Switch.svelte'; import Switch from '$lib/components/common/Switch.svelte';
import ChatBubbleOval from '$lib/components/icons/ChatBubbleOval.svelte'; import ChatBubbleOval from '$lib/components/icons/ChatBubbleOval.svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import dayjs from '$lib/dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import ArrowUpTray from '$lib/components/icons/ArrowUpTray.svelte';
dayjs.extend(relativeTime);
const i18n = getContext('i18n'); const i18n = getContext('i18n');
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
@ -309,6 +313,22 @@
toast.success(`${model} download has been canceled`); toast.success(`${model} download has been canceled`);
} }
}; };
const unloadModelHandler = async (model: string) => {
const res = await unloadModel(localStorage.token, model).catch((error) => {
toast.error($i18n.t('Error unloading model: {{error}}', { error }));
});
if (res) {
toast.success($i18n.t('Model unloaded successfully'));
models.set(
await getModels(
localStorage.token,
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
)
);
}
};
</script> </script>
<DropdownMenu.Root <DropdownMenu.Root
@ -326,8 +346,17 @@
aria-label={placeholder} aria-label={placeholder}
id="model-selector-{id}-button" id="model-selector-{id}-button"
> >
<div <button
class="flex w-full text-left px-0.5 outline-hidden bg-transparent truncate {triggerClassName} justify-between font-medium placeholder-gray-400 focus:outline-hidden" class="flex w-full text-left px-0.5 outline-hidden bg-transparent truncate {triggerClassName} justify-between font-medium placeholder-gray-400 focus:outline-hidden"
on:mouseenter={async () => {
models.set(
await getModels(
localStorage.token,
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
)
);
}}
type="button"
> >
{#if selectedModel} {#if selectedModel}
{selectedModel.label} {selectedModel.label}
@ -335,7 +364,7 @@
{placeholder} {placeholder}
{/if} {/if}
<ChevronDown className=" self-center ml-2 size-3" strokeWidth="2.5" /> <ChevronDown className=" self-center ml-2 size-3" strokeWidth="2.5" />
</div> </button>
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
<DropdownMenu.Content <DropdownMenu.Content
@ -510,38 +539,59 @@
<div class="line-clamp-1"> <div class="line-clamp-1">
{item.label} {item.label}
</div> </div>
{#if item.model.owned_by === 'ollama' && (item.model.ollama?.details?.parameter_size ?? '') !== ''}
<div class="flex ml-1 items-center translate-y-[0.5px]">
<Tooltip
content={`${
item.model.ollama?.details?.quantization_level
? item.model.ollama?.details?.quantization_level + ' '
: ''
}${
item.model.ollama?.size
? `(${(item.model.ollama?.size / 1024 ** 3).toFixed(1)}GB)`
: ''
}`}
className="self-end"
>
<span
class=" text-xs font-medium text-gray-600 dark:text-gray-400 line-clamp-1"
>{item.model.ollama?.details?.parameter_size ?? ''}</span
>
</Tooltip>
</div>
{/if}
</div> </div>
</Tooltip> </Tooltip>
</div> </div>
</div> </div>
</div> </div>
{#if item.model.owned_by === 'ollama'}
{#if (item.model.ollama?.details?.parameter_size ?? '') !== ''}
<div class="flex items-center translate-y-[0.5px]">
<Tooltip
content={`${
item.model.ollama?.details?.quantization_level
? item.model.ollama?.details?.quantization_level + ' '
: ''
}${
item.model.ollama?.size
? `(${(item.model.ollama?.size / 1024 ** 3).toFixed(1)}GB)`
: ''
}`}
className="self-end"
>
<span
class=" text-xs font-medium text-gray-600 dark:text-gray-400 line-clamp-1"
>{item.model.ollama?.details?.parameter_size ?? ''}</span
>
</Tooltip>
</div>
{/if}
{#if item.model.ollama?.expires_at && new Date(item.model.ollama?.expires_at * 1000) > new Date()}
<div class="flex items-center translate-y-[0.5px] px-0.5">
<Tooltip
content={`${$i18n.t('Unloads {{FROM_NOW}}', {
FROM_NOW: dayjs(item.model.ollama?.expires_at * 1000).fromNow()
})}`}
className="self-end"
>
<div class=" flex items-center">
<span class="relative flex size-2">
<span
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"
/>
<span class="relative inline-flex rounded-full size-2 bg-green-500" />
</span>
</div>
</Tooltip>
</div>
{/if}
{/if}
<!-- {JSON.stringify(item.info)} --> <!-- {JSON.stringify(item.info)} -->
{#if item.model?.direct} {#if item.model?.direct}
<Tooltip content={`${'Direct'}`}> <Tooltip content={`${$i18n.t('Direct')}`}>
<div class="translate-y-[1px]"> <div class="translate-y-[1px]">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@ -557,8 +607,8 @@
</svg> </svg>
</div> </div>
</Tooltip> </Tooltip>
{:else if item.model.owned_by === 'openai'} {:else if item.model.connection_type === 'external'}
<Tooltip content={`${'External'}`}> <Tooltip content={`${$i18n.t('External')}`}>
<div class="translate-y-[1px]"> <div class="translate-y-[1px]">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@ -627,11 +677,26 @@
</div> </div>
</div> </div>
{#if value === item.value} <div class="ml-auto pl-2 pr-1 flex gap-1.5 items-center">
<div class="ml-auto pl-2 pr-2 md:pr-0"> {#if $user?.role === 'admin' && item.model.owned_by === 'ollama' && item.model.ollama?.expires_at && new Date(item.model.ollama?.expires_at * 1000) > new Date()}
<Check /> <Tooltip content={`${$i18n.t('Eject')}`} className="flex-shrink-0">
</div> <button
{/if} class="flex"
on:click={() => {
unloadModelHandler(item.value);
}}
>
<ArrowUpTray className="size-3" />
</button>
</Tooltip>
{/if}
{#if value === item.value}
<div>
<Check className="size-3" />
</div>
{/if}
</div>
</button> </button>
{:else} {:else}
<div class=""> <div class="">
@ -746,7 +811,7 @@
</div> </div>
{#if showTemporaryChatControl} {#if showTemporaryChatControl}
<div class="flex items-center mx-2 mb-2"> <div class="flex items-center mx-2 mt-1 mb-2">
<button <button
class="flex justify-between w-full font-medium line-clamp-1 select-none items-center rounded-button py-2 px-3 text-sm text-gray-700 dark:text-gray-100 outline-hidden transition-all duration-75 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg cursor-pointer data-highlighted:bg-muted" class="flex justify-between w-full font-medium line-clamp-1 select-none items-center rounded-button py-2 px-3 text-sm text-gray-700 dark:text-gray-100 outline-hidden transition-all duration-75 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg cursor-pointer data-highlighted:bg-muted"
on:click={async () => { on:click={async () => {

View File

@ -154,8 +154,9 @@
{#if $user !== undefined && $user !== null} {#if $user !== undefined && $user !== null}
<UserMenu <UserMenu
className="max-w-[200px]" className="max-w-[240px]"
role={$user?.role} role={$user?.role}
help={true}
on:show={(e) => { on:show={(e) => {
if (e.detail === 'archived-chat') { if (e.detail === 'archived-chat') {
showArchivedChats.set(true); showArchivedChats.set(true);

View File

@ -138,7 +138,7 @@
</div> </div>
</div> </div>
<div class=" text-3xl @sm:text-4xl line-clamp-1" in:fade={{ duration: 100 }}> <div class=" text-3xl @sm:text-3xl line-clamp-1" in:fade={{ duration: 100 }}>
{#if models[selectedModelIdx]?.name} {#if models[selectedModelIdx]?.name}
{models[selectedModelIdx]?.name} {models[selectedModelIdx]?.name}
{:else} {:else}
@ -221,7 +221,7 @@
</div> </div>
</div> </div>
</div> </div>
<div class="mx-auto max-w-2xl font-primary" in:fade={{ duration: 200, delay: 200 }}> <div class="mx-auto max-w-2xl font-primary mt-2" in:fade={{ duration: 200, delay: 200 }}>
<div class="mx-5"> <div class="mx-5">
<Suggestions <Suggestions
suggestionPrompts={atSelectedModel?.info?.meta?.suggestion_prompts ?? suggestionPrompts={atSelectedModel?.info?.meta?.suggestion_prompts ??

View File

@ -309,7 +309,7 @@
> >
<div class="flex w-full justify-between"> <div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium"> <div class=" self-center text-xs font-medium">
{$i18n.t('Logit Bias')} {'logit_bias'}
</div> </div>
<button <button
class="p-1 px-3 text-xs flex rounded-sm transition shrink-0 outline-hidden" class="p-1 px-3 text-xs flex rounded-sm transition shrink-0 outline-hidden"
@ -344,79 +344,27 @@
{/if} {/if}
</div> </div>
<div class=" py-0.5 w-full justify-between">
<Tooltip
content={$i18n.t('Enable Mirostat sampling for controlling perplexity.')}
placement="top-start"
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">
{$i18n.t('Mirostat')}
</div>
<button
class="p-1 px-3 text-xs flex rounded-sm transition shrink-0 outline-hidden"
type="button"
on:click={() => {
params.mirostat = (params?.mirostat ?? null) === null ? 0 : null;
}}
>
{#if (params?.mirostat ?? null) === null}
<span class="ml-2 self-center">{$i18n.t('Default')}</span>
{:else}
<span class="ml-2 self-center">{$i18n.t('Custom')}</span>
{/if}
</button>
</div>
</Tooltip>
{#if (params?.mirostat ?? null) !== null}
<div class="flex mt-0.5 space-x-2">
<div class=" flex-1">
<input
id="steps-range"
type="range"
min="0"
max="2"
step="1"
bind:value={params.mirostat}
class="w-full h-2 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
/>
</div>
<div>
<input
bind:value={params.mirostat}
type="number"
class=" bg-transparent text-center w-14"
min="0"
max="2"
step="1"
/>
</div>
</div>
{/if}
</div>
<div class=" py-0.5 w-full justify-between"> <div class=" py-0.5 w-full justify-between">
<Tooltip <Tooltip
content={$i18n.t( content={$i18n.t(
'Influences how quickly the algorithm responds to feedback from the generated text. A lower learning rate will result in slower adjustments, while a higher learning rate will make the algorithm more responsive.' 'This option sets the maximum number of tokens the model can generate in its response. Increasing this limit allows the model to provide longer answers, but it may also increase the likelihood of unhelpful or irrelevant content being generated.'
)} )}
placement="top-start" placement="top-start"
className="inline-tooltip" className="inline-tooltip"
> >
<div class="flex w-full justify-between"> <div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium"> <div class=" self-center text-xs font-medium">
{$i18n.t('Mirostat Eta')} {'max_tokens'}
</div> </div>
<button <button
class="p-1 px-3 text-xs flex rounded-sm transition shrink-0 outline-hidden" class="p-1 px-3 text-xs flex rounded-sm transition shrink-0 outline-hidden"
type="button" type="button"
on:click={() => { on:click={() => {
params.mirostat_eta = (params?.mirostat_eta ?? null) === null ? 0.1 : null; params.max_tokens = (params?.max_tokens ?? null) === null ? 128 : null;
}} }}
> >
{#if (params?.mirostat_eta ?? null) === null} {#if (params?.max_tokens ?? null) === null}
<span class="ml-2 self-center">{$i18n.t('Default')}</span> <span class="ml-2 self-center">{$i18n.t('Default')}</span>
{:else} {:else}
<span class="ml-2 self-center">{$i18n.t('Custom')}</span> <span class="ml-2 self-center">{$i18n.t('Custom')}</span>
@ -425,83 +373,26 @@
</div> </div>
</Tooltip> </Tooltip>
{#if (params?.mirostat_eta ?? null) !== null} {#if (params?.max_tokens ?? null) !== null}
<div class="flex mt-0.5 space-x-2"> <div class="flex mt-0.5 space-x-2">
<div class=" flex-1"> <div class=" flex-1">
<input <input
id="steps-range" id="steps-range"
type="range" type="range"
min="0" min="-2"
max="1" max="131072"
step="0.05" step="1"
bind:value={params.mirostat_eta} bind:value={params.max_tokens}
class="w-full h-2 rounded-lg appearance-none cursor-pointer dark:bg-gray-700" class="w-full h-2 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
/> />
</div> </div>
<div> <div>
<input <input
bind:value={params.mirostat_eta} bind:value={params.max_tokens}
type="number" type="number"
class=" bg-transparent text-center w-14" class=" bg-transparent text-center w-14"
min="0" min="-2"
max="1" step="1"
step="any"
/>
</div>
</div>
{/if}
</div>
<div class=" py-0.5 w-full justify-between">
<Tooltip
content={$i18n.t(
'Controls the balance between coherence and diversity of the output. A lower value will result in more focused and coherent text.'
)}
placement="top-start"
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">
{$i18n.t('Mirostat Tau')}
</div>
<button
class="p-1 px-3 text-xs flex rounded-sm transition shrink-0 outline-hidden"
type="button"
on:click={() => {
params.mirostat_tau = (params?.mirostat_tau ?? null) === null ? 5.0 : null;
}}
>
{#if (params?.mirostat_tau ?? null) === null}
<span class="ml-2 self-center">{$i18n.t('Default')}</span>
{:else}
<span class="ml-2 self-center">{$i18n.t('Custom')}</span>
{/if}
</button>
</div>
</Tooltip>
{#if (params?.mirostat_tau ?? null) !== null}
<div class="flex mt-0.5 space-x-2">
<div class=" flex-1">
<input
id="steps-range"
type="range"
min="0"
max="10"
step="0.5"
bind:value={params.mirostat_tau}
class="w-full h-2 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
/>
</div>
<div>
<input
bind:value={params.mirostat_tau}
type="number"
class=" bg-transparent text-center w-14"
min="0"
max="10"
step="any"
/> />
</div> </div>
</div> </div>
@ -518,7 +409,7 @@
> >
<div class="flex w-full justify-between"> <div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium"> <div class=" self-center text-xs font-medium">
{$i18n.t('Top K')} {'top_k'}
</div> </div>
<button <button
class="p-1 px-3 text-xs flex rounded-sm transition shrink-0 outline-hidden" class="p-1 px-3 text-xs flex rounded-sm transition shrink-0 outline-hidden"
@ -573,7 +464,7 @@
> >
<div class="flex w-full justify-between"> <div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium"> <div class=" self-center text-xs font-medium">
{$i18n.t('Top P')} {'top_p'}
</div> </div>
<button <button
@ -629,7 +520,7 @@
> >
<div class="flex w-full justify-between"> <div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium"> <div class=" self-center text-xs font-medium">
{$i18n.t('Min P')} {'min_p'}
</div> </div>
<button <button
class="p-1 px-3 text-xs flex rounded-sm transition shrink-0 outline-hidden" class="p-1 px-3 text-xs flex rounded-sm transition shrink-0 outline-hidden"
@ -684,7 +575,7 @@
> >
<div class="flex w-full justify-between"> <div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium"> <div class=" self-center text-xs font-medium">
{$i18n.t('Frequency Penalty')} {'frequency_penalty'}
</div> </div>
<button <button
@ -740,7 +631,7 @@
> >
<div class="flex w-full justify-between"> <div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium"> <div class=" self-center text-xs font-medium">
{$i18n.t('Presence Penalty')} {'presence_penalty'}
</div> </div>
<button <button
@ -786,6 +677,170 @@
{/if} {/if}
</div> </div>
<div class=" py-0.5 w-full justify-between">
<Tooltip
content={$i18n.t('Enable Mirostat sampling for controlling perplexity.')}
placement="top-start"
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">
{'mirostat'}
</div>
<button
class="p-1 px-3 text-xs flex rounded-sm transition shrink-0 outline-hidden"
type="button"
on:click={() => {
params.mirostat = (params?.mirostat ?? null) === null ? 0 : null;
}}
>
{#if (params?.mirostat ?? null) === null}
<span class="ml-2 self-center">{$i18n.t('Default')}</span>
{:else}
<span class="ml-2 self-center">{$i18n.t('Custom')}</span>
{/if}
</button>
</div>
</Tooltip>
{#if (params?.mirostat ?? null) !== null}
<div class="flex mt-0.5 space-x-2">
<div class=" flex-1">
<input
id="steps-range"
type="range"
min="0"
max="2"
step="1"
bind:value={params.mirostat}
class="w-full h-2 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
/>
</div>
<div>
<input
bind:value={params.mirostat}
type="number"
class=" bg-transparent text-center w-14"
min="0"
max="2"
step="1"
/>
</div>
</div>
{/if}
</div>
<div class=" py-0.5 w-full justify-between">
<Tooltip
content={$i18n.t(
'Influences how quickly the algorithm responds to feedback from the generated text. A lower learning rate will result in slower adjustments, while a higher learning rate will make the algorithm more responsive.'
)}
placement="top-start"
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">
{'mirostat_eta'}
</div>
<button
class="p-1 px-3 text-xs flex rounded-sm transition shrink-0 outline-hidden"
type="button"
on:click={() => {
params.mirostat_eta = (params?.mirostat_eta ?? null) === null ? 0.1 : null;
}}
>
{#if (params?.mirostat_eta ?? null) === null}
<span class="ml-2 self-center">{$i18n.t('Default')}</span>
{:else}
<span class="ml-2 self-center">{$i18n.t('Custom')}</span>
{/if}
</button>
</div>
</Tooltip>
{#if (params?.mirostat_eta ?? null) !== null}
<div class="flex mt-0.5 space-x-2">
<div class=" flex-1">
<input
id="steps-range"
type="range"
min="0"
max="1"
step="0.05"
bind:value={params.mirostat_eta}
class="w-full h-2 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
/>
</div>
<div>
<input
bind:value={params.mirostat_eta}
type="number"
class=" bg-transparent text-center w-14"
min="0"
max="1"
step="any"
/>
</div>
</div>
{/if}
</div>
<div class=" py-0.5 w-full justify-between">
<Tooltip
content={$i18n.t(
'Controls the balance between coherence and diversity of the output. A lower value will result in more focused and coherent text.'
)}
placement="top-start"
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">
{'mirostat_tau'}
</div>
<button
class="p-1 px-3 text-xs flex rounded-sm transition shrink-0 outline-hidden"
type="button"
on:click={() => {
params.mirostat_tau = (params?.mirostat_tau ?? null) === null ? 5.0 : null;
}}
>
{#if (params?.mirostat_tau ?? null) === null}
<span class="ml-2 self-center">{$i18n.t('Default')}</span>
{:else}
<span class="ml-2 self-center">{$i18n.t('Custom')}</span>
{/if}
</button>
</div>
</Tooltip>
{#if (params?.mirostat_tau ?? null) !== null}
<div class="flex mt-0.5 space-x-2">
<div class=" flex-1">
<input
id="steps-range"
type="range"
min="0"
max="10"
step="0.5"
bind:value={params.mirostat_tau}
class="w-full h-2 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
/>
</div>
<div>
<input
bind:value={params.mirostat_tau}
type="number"
class=" bg-transparent text-center w-14"
min="0"
max="10"
step="any"
/>
</div>
</div>
{/if}
</div>
<div class=" py-0.5 w-full justify-between"> <div class=" py-0.5 w-full justify-between">
<Tooltip <Tooltip
content={$i18n.t('Sets how far back for the model to look back to prevent repetition.')} content={$i18n.t('Sets how far back for the model to look back to prevent repetition.')}
@ -794,7 +849,7 @@
> >
<div class="flex w-full justify-between"> <div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium"> <div class=" self-center text-xs font-medium">
{$i18n.t('Repeat Last N')} {'repeat_last_n'}
</div> </div>
<button <button
@ -850,7 +905,7 @@
> >
<div class="flex w-full justify-between"> <div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium"> <div class=" self-center text-xs font-medium">
{$i18n.t('Tfs Z')} {'tfs_z'}
</div> </div>
<button <button
@ -896,6 +951,146 @@
{/if} {/if}
</div> </div>
<div class=" py-0.5 w-full justify-between">
<Tooltip
content={$i18n.t(
'Control the repetition of token sequences in the generated text. A higher value (e.g., 1.5) will penalize repetitions more strongly, while a lower value (e.g., 1.1) will be more lenient. At 1, it is disabled.'
)}
placement="top-start"
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">
{'repeat_penalty'}
</div>
<button
class="p-1 px-3 text-xs flex rounded transition flex-shrink-0 outline-none"
type="button"
on:click={() => {
params.repeat_penalty = (params?.repeat_penalty ?? null) === null ? 1.1 : null;
}}
>
{#if (params?.repeat_penalty ?? null) === null}
<span class="ml-2 self-center">{$i18n.t('Default')}</span>
{:else}
<span class="ml-2 self-center">{$i18n.t('Custom')}</span>
{/if}
</button>
</div>
</Tooltip>
{#if (params?.repeat_penalty ?? null) !== null}
<div class="flex mt-0.5 space-x-2">
<div class=" flex-1">
<input
id="steps-range"
type="range"
min="-2"
max="2"
step="0.05"
bind:value={params.repeat_penalty}
class="w-full h-2 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
/>
</div>
<div>
<input
bind:value={params.repeat_penalty}
type="number"
class=" bg-transparent text-center w-14"
min="-2"
max="2"
step="any"
/>
</div>
</div>
{/if}
</div>
{#if admin}
<div class=" py-0.5 w-full justify-between">
<Tooltip
content={$i18n.t(
'Enable Memory Mapping (mmap) to load model data. This option allows the system to use disk storage as an extension of RAM by treating disk files as if they were in RAM. This can improve model performance by allowing for faster data access. However, it may not work correctly with all systems and can consume a significant amount of disk space.'
)}
placement="top-start"
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">
{'use_mmap'}
</div>
<button
class="p-1 px-3 text-xs flex rounded-sm transition shrink-0 outline-hidden"
type="button"
on:click={() => {
params.use_mmap = (params?.use_mmap ?? null) === null ? true : null;
}}
>
{#if (params?.use_mmap ?? null) === null}
<span class="ml-2 self-center">{$i18n.t('Default')}</span>
{:else}
<span class="ml-2 self-center">{$i18n.t('Custom')}</span>
{/if}
</button>
</div>
</Tooltip>
{#if (params?.use_mmap ?? null) !== null}
<div class="flex justify-between items-center mt-1">
<div class="text-xs text-gray-500">
{params.use_mmap ? 'Enabled' : 'Disabled'}
</div>
<div class=" pr-2">
<Switch bind:state={params.use_mmap} />
</div>
</div>
{/if}
</div>
<div class=" py-0.5 w-full justify-between">
<Tooltip
content={$i18n.t(
"Enable Memory Locking (mlock) to prevent model data from being swapped out of RAM. This option locks the model's working set of pages into RAM, ensuring that they will not be swapped out to disk. This can help maintain performance by avoiding page faults and ensuring fast data access."
)}
placement="top-start"
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">
{'use_mlock'}
</div>
<button
class="p-1 px-3 text-xs flex rounded-sm transition shrink-0 outline-hidden"
type="button"
on:click={() => {
params.use_mlock = (params?.use_mlock ?? null) === null ? true : null;
}}
>
{#if (params?.use_mlock ?? null) === null}
<span class="ml-2 self-center">{$i18n.t('Default')}</span>
{:else}
<span class="ml-2 self-center">{$i18n.t('Custom')}</span>
{/if}
</button>
</div>
</Tooltip>
{#if (params?.use_mlock ?? null) !== null}
<div class="flex justify-between items-center mt-1">
<div class="text-xs text-gray-500">
{params.use_mlock ? 'Enabled' : 'Disabled'}
</div>
<div class=" pr-2">
<Switch bind:state={params.use_mlock} />
</div>
</div>
{/if}
</div>
{/if}
<div class=" py-0.5 w-full justify-between"> <div class=" py-0.5 w-full justify-between">
<Tooltip <Tooltip
content={$i18n.t( content={$i18n.t(
@ -906,7 +1101,7 @@
> >
<div class="flex w-full justify-between"> <div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium"> <div class=" self-center text-xs font-medium">
{$i18n.t('Tokens To Keep On Context Refresh (num_keep)')} {'num_keep'} ({$i18n.t('Ollama')})
</div> </div>
<button <button
@ -951,117 +1146,6 @@
{/if} {/if}
</div> </div>
<div class=" py-0.5 w-full justify-between">
<Tooltip
content={$i18n.t(
'This option sets the maximum number of tokens the model can generate in its response. Increasing this limit allows the model to provide longer answers, but it may also increase the likelihood of unhelpful or irrelevant content being generated.'
)}
placement="top-start"
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">
{$i18n.t('Max Tokens (num_predict)')}
</div>
<button
class="p-1 px-3 text-xs flex rounded-sm transition shrink-0 outline-hidden"
type="button"
on:click={() => {
params.max_tokens = (params?.max_tokens ?? null) === null ? 128 : null;
}}
>
{#if (params?.max_tokens ?? null) === null}
<span class="ml-2 self-center">{$i18n.t('Default')}</span>
{:else}
<span class="ml-2 self-center">{$i18n.t('Custom')}</span>
{/if}
</button>
</div>
</Tooltip>
{#if (params?.max_tokens ?? null) !== null}
<div class="flex mt-0.5 space-x-2">
<div class=" flex-1">
<input
id="steps-range"
type="range"
min="-2"
max="131072"
step="1"
bind:value={params.max_tokens}
class="w-full h-2 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
/>
</div>
<div>
<input
bind:value={params.max_tokens}
type="number"
class=" bg-transparent text-center w-14"
min="-2"
step="1"
/>
</div>
</div>
{/if}
</div>
<div class=" py-0.5 w-full justify-between">
<Tooltip
content={$i18n.t(
'Control the repetition of token sequences in the generated text. A higher value (e.g., 1.5) will penalize repetitions more strongly, while a lower value (e.g., 1.1) will be more lenient. At 1, it is disabled.'
)}
placement="top-start"
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">
{$i18n.t('Repeat Penalty (Ollama)')}
</div>
<button
class="p-1 px-3 text-xs flex rounded transition flex-shrink-0 outline-none"
type="button"
on:click={() => {
params.repeat_penalty = (params?.repeat_penalty ?? null) === null ? 1.1 : null;
}}
>
{#if (params?.repeat_penalty ?? null) === null}
<span class="ml-2 self-center">{$i18n.t('Default')}</span>
{:else}
<span class="ml-2 self-center">{$i18n.t('Custom')}</span>
{/if}
</button>
</div>
</Tooltip>
{#if (params?.repeat_penalty ?? null) !== null}
<div class="flex mt-0.5 space-x-2">
<div class=" flex-1">
<input
id="steps-range"
type="range"
min="-2"
max="2"
step="0.05"
bind:value={params.repeat_penalty}
class="w-full h-2 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
/>
</div>
<div>
<input
bind:value={params.repeat_penalty}
type="number"
class=" bg-transparent text-center w-14"
min="-2"
max="2"
step="any"
/>
</div>
</div>
{/if}
</div>
<div class=" py-0.5 w-full justify-between"> <div class=" py-0.5 w-full justify-between">
<Tooltip <Tooltip
content={$i18n.t('Sets the size of the context window used to generate the next token.')} content={$i18n.t('Sets the size of the context window used to generate the next token.')}
@ -1070,8 +1154,7 @@
> >
<div class="flex w-full justify-between"> <div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium"> <div class=" self-center text-xs font-medium">
{$i18n.t('Context Length')} {'num_ctx'} ({$i18n.t('Ollama')})
{$i18n.t('(Ollama)')}
</div> </div>
<button <button
@ -1126,7 +1209,7 @@
> >
<div class="flex w-full justify-between"> <div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium"> <div class=" self-center text-xs font-medium">
{$i18n.t('Batch Size (num_batch)')} {'num_batch'} ({$i18n.t('Ollama')})
</div> </div>
<button <button
@ -1172,88 +1255,6 @@
</div> </div>
{#if admin} {#if admin}
<div class=" py-0.5 w-full justify-between">
<Tooltip
content={$i18n.t(
'Enable Memory Mapping (mmap) to load model data. This option allows the system to use disk storage as an extension of RAM by treating disk files as if they were in RAM. This can improve model performance by allowing for faster data access. However, it may not work correctly with all systems and can consume a significant amount of disk space.'
)}
placement="top-start"
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">
{$i18n.t('use_mmap (Ollama)')}
</div>
<button
class="p-1 px-3 text-xs flex rounded-sm transition shrink-0 outline-hidden"
type="button"
on:click={() => {
params.use_mmap = (params?.use_mmap ?? null) === null ? true : null;
}}
>
{#if (params?.use_mmap ?? null) === null}
<span class="ml-2 self-center">{$i18n.t('Default')}</span>
{:else}
<span class="ml-2 self-center">{$i18n.t('Custom')}</span>
{/if}
</button>
</div>
</Tooltip>
{#if (params?.use_mmap ?? null) !== null}
<div class="flex justify-between items-center mt-1">
<div class="text-xs text-gray-500">
{params.use_mmap ? 'Enabled' : 'Disabled'}
</div>
<div class=" pr-2">
<Switch bind:state={params.use_mmap} />
</div>
</div>
{/if}
</div>
<div class=" py-0.5 w-full justify-between">
<Tooltip
content={$i18n.t(
"Enable Memory Locking (mlock) to prevent model data from being swapped out of RAM. This option locks the model's working set of pages into RAM, ensuring that they will not be swapped out to disk. This can help maintain performance by avoiding page faults and ensuring fast data access."
)}
placement="top-start"
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">
{$i18n.t('use_mlock (Ollama)')}
</div>
<button
class="p-1 px-3 text-xs flex rounded-sm transition shrink-0 outline-hidden"
type="button"
on:click={() => {
params.use_mlock = (params?.use_mlock ?? null) === null ? true : null;
}}
>
{#if (params?.use_mlock ?? null) === null}
<span class="ml-2 self-center">{$i18n.t('Default')}</span>
{:else}
<span class="ml-2 self-center">{$i18n.t('Custom')}</span>
{/if}
</button>
</div>
</Tooltip>
{#if (params?.use_mlock ?? null) !== null}
<div class="flex justify-between items-center mt-1">
<div class="text-xs text-gray-500">
{params.use_mlock ? 'Enabled' : 'Disabled'}
</div>
<div class=" pr-2">
<Switch bind:state={params.use_mlock} />
</div>
</div>
{/if}
</div>
<div class=" py-0.5 w-full justify-between"> <div class=" py-0.5 w-full justify-between">
<Tooltip <Tooltip
content={$i18n.t( content={$i18n.t(
@ -1264,7 +1265,7 @@
> >
<div class="flex w-full justify-between"> <div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium"> <div class=" self-center text-xs font-medium">
{$i18n.t('num_thread (Ollama)')} {'num_thread'} ({$i18n.t('Ollama')})
</div> </div>
<button <button
@ -1320,7 +1321,7 @@
> >
<div class="flex w-full justify-between"> <div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium"> <div class=" self-center text-xs font-medium">
{$i18n.t('num_gpu (Ollama)')} {'num_gpu'} ({$i18n.t('Ollama')})
</div> </div>
<button <button

View File

@ -9,6 +9,7 @@
import Switch from '$lib/components/common/Switch.svelte'; import Switch from '$lib/components/common/Switch.svelte';
import { round } from '@huggingface/transformers'; import { round } from '@huggingface/transformers';
import Spinner from '$lib/components/common/Spinner.svelte'; import Spinner from '$lib/components/common/Spinner.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
const i18n = getContext('i18n'); const i18n = getContext('i18n');
@ -22,6 +23,7 @@
let nonLocalVoices = false; let nonLocalVoices = false;
let STTEngine = ''; let STTEngine = '';
let STTLanguage = '';
let TTSEngine = ''; let TTSEngine = '';
let TTSEngineConfig = {}; let TTSEngineConfig = {};
@ -35,7 +37,6 @@
// Audio speed control // Audio speed control
let playbackRate = 1; let playbackRate = 1;
const speedOptions = [2, 1.75, 1.5, 1.25, 1, 0.75, 0.5];
const getVoices = async () => { const getVoices = async () => {
if (TTSEngine === 'browser-kokoro') { if (TTSEngine === 'browser-kokoro') {
@ -90,6 +91,7 @@
responseAutoPlayback = $settings.responseAutoPlayback ?? false; responseAutoPlayback = $settings.responseAutoPlayback ?? false;
STTEngine = $settings?.audio?.stt?.engine ?? ''; STTEngine = $settings?.audio?.stt?.engine ?? '';
STTLanguage = $settings?.audio?.stt?.language ?? '';
TTSEngine = $settings?.audio?.tts?.engine ?? ''; TTSEngine = $settings?.audio?.tts?.engine ?? '';
TTSEngineConfig = $settings?.audio?.tts?.engineConfig ?? {}; TTSEngineConfig = $settings?.audio?.tts?.engineConfig ?? {};
@ -157,7 +159,8 @@
saveSettings({ saveSettings({
audio: { audio: {
stt: { stt: {
engine: STTEngine !== '' ? STTEngine : undefined engine: STTEngine !== '' ? STTEngine : undefined,
language: STTLanguage !== '' ? STTLanguage : undefined
}, },
tts: { tts: {
engine: TTSEngine !== '' ? TTSEngine : undefined, engine: TTSEngine !== '' ? TTSEngine : undefined,
@ -190,6 +193,26 @@
</select> </select>
</div> </div>
</div> </div>
<div class=" py-0.5 flex w-full justify-between">
<div class=" self-center text-xs font-medium">{$i18n.t('Language')}</div>
<div class="flex items-center relative text-xs px-3">
<Tooltip
content={$i18n.t(
'The language of the input audio. Supplying the input language in ISO-639-1 (e.g. en) format will improve accuracy and latency. Leave blank to automatically detect the language.'
)}
placement="top"
>
<input
type="text"
bind:value={STTLanguage}
placeholder={$i18n.t('e.g. en')}
class=" text-sm text-right bg-transparent dark:text-gray-300 outline-hidden"
/>
</Tooltip>
</div>
</div>
{/if} {/if}
<div class=" py-0.5 flex w-full justify-between"> <div class=" py-0.5 flex w-full justify-between">
@ -270,15 +293,15 @@
<div class=" py-0.5 flex w-full justify-between"> <div class=" py-0.5 flex w-full justify-between">
<div class=" self-center text-xs font-medium">{$i18n.t('Speech Playback Speed')}</div> <div class=" self-center text-xs font-medium">{$i18n.t('Speech Playback Speed')}</div>
<div class="flex items-center relative"> <div class="flex items-center relative text-xs px-3">
<select <input
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right" type="number"
min="0"
step="0.01"
bind:value={playbackRate} bind:value={playbackRate}
> class=" text-sm text-right bg-transparent dark:text-gray-300 outline-hidden"
{#each speedOptions as option} />
<option value={option} selected={playbackRate === option}>{option}x</option> x
{/each}
</select>
</div> </div>
</div> </div>
</div> </div>
@ -293,7 +316,7 @@
<div class="flex-1"> <div class="flex-1">
<input <input
list="voice-list" list="voice-list"
class="w-full text-sm bg-white dark:text-gray-300 dark:bg-gray-850 outline-hidden" class="w-full text-sm bg-transparent dark:text-gray-300 outline-hidden"
bind:value={voice} bind:value={voice}
placeholder="Select a voice" placeholder="Select a voice"
/> />
@ -330,7 +353,7 @@
<div class="flex w-full"> <div class="flex w-full">
<div class="flex-1"> <div class="flex-1">
<select <select
class="w-full text-sm bg-white dark:text-gray-300 dark:bg-gray-850 outline-hidden" class="w-full text-sm bg-transparent dark:text-gray-300 outline-hidden"
bind:value={voice} bind:value={voice}
> >
<option value="" selected={voice !== ''}>{$i18n.t('Default')}</option> <option value="" selected={voice !== ''}>{$i18n.t('Default')}</option>
@ -361,7 +384,7 @@
<div class="flex-1"> <div class="flex-1">
<input <input
list="voice-list" list="voice-list"
class="w-full text-sm bg-white dark:text-gray-300 dark:bg-gray-850 outline-hidden" class="w-full text-sm bg-transparent dark:text-gray-300 outline-hidden"
bind:value={voice} bind:value={voice}
placeholder="Select a voice" placeholder="Select a voice"
/> />

View File

@ -16,7 +16,7 @@
import { onMount, getContext } from 'svelte'; import { onMount, getContext } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import ArchivedChatsModal from '$lib/components/layout/Sidebar/ArchivedChatsModal.svelte'; import ArchivedChatsModal from '$lib/components/layout/ArchivedChatsModal.svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
@ -105,7 +105,7 @@
}; };
</script> </script>
<ArchivedChatsModal bind:show={showArchivedChatsModal} on:change={handleArchivedChatsChange} /> <ArchivedChatsModal bind:show={showArchivedChatsModal} onUpdate={handleArchivedChatsChange} />
<div class="flex flex-col h-full justify-between space-y-3 text-sm"> <div class="flex flex-col h-full justify-between space-y-3 text-sm">
<div class=" space-y-2 overflow-y-scroll max-h-[28rem] lg:max-h-full"> <div class=" space-y-2 overflow-y-scroll max-h-[28rem] lg:max-h-full">

View File

@ -70,8 +70,9 @@
<div class=""> <div class="">
<textarea <textarea
bind:value={content} bind:value={content}
class=" bg-transparent w-full text-sm resize-none rounded-xl p-3 outline outline-1 outline-gray-100 dark:outline-gray-800" class=" bg-transparent w-full text-sm rounded-xl p-3 outline outline-1 outline-gray-100 dark:outline-gray-800"
rows="3" rows="6"
style="resize: vertical;"
placeholder={$i18n.t('Enter a detail about yourself for your LLMs to recall')} placeholder={$i18n.t('Enter a detail about yourself for your LLMs to recall')}
/> />

View File

@ -80,8 +80,9 @@
<div class=""> <div class="">
<textarea <textarea
bind:value={content} bind:value={content}
class=" bg-transparent w-full text-sm resize-none rounded-xl p-3 outline outline-1 outline-gray-100 dark:outline-gray-800" class=" bg-transparent w-full text-sm rounded-xl p-3 outline outline-1 outline-gray-100 dark:outline-gray-800"
rows="3" rows="6"
style="resize: vertical;"
placeholder={$i18n.t('Enter a detail about yourself for your LLMs to recall')} placeholder={$i18n.t('Enter a detail about yourself for your LLMs to recall')}
/> />

View File

@ -81,39 +81,41 @@
{/if} {/if}
</div> </div>
<div class="h-40 overflow-auto scrollbar-none {className} items-start"> <div class="h-40 w-full">
{#if filteredPrompts.length > 0} {#if filteredPrompts.length > 0}
{#each filteredPrompts as prompt, idx (prompt.id || prompt.content)} <div class="max-h-40 overflow-auto scrollbar-none items-start {className}">
<button {#each filteredPrompts as prompt, idx (prompt.id || prompt.content)}
class="waterfall flex flex-col flex-1 shrink-0 w-full justify-between <button
class="waterfall flex flex-col flex-1 shrink-0 w-full justify-between
px-3 py-2 rounded-xl bg-transparent hover:bg-black/5 px-3 py-2 rounded-xl bg-transparent hover:bg-black/5
dark:hover:bg-white/5 transition group" dark:hover:bg-white/5 transition group"
style="animation-delay: {idx * 60}ms" style="animation-delay: {idx * 60}ms"
on:click={() => dispatch('select', prompt.content)} on:click={() => dispatch('select', prompt.content)}
> >
<div class="flex flex-col text-left"> <div class="flex flex-col text-left">
{#if prompt.title && prompt.title[0] !== ''} {#if prompt.title && prompt.title[0] !== ''}
<div <div
class="font-medium dark:text-gray-300 dark:group-hover:text-gray-200 transition line-clamp-1" class="font-medium dark:text-gray-300 dark:group-hover:text-gray-200 transition line-clamp-1"
> >
{prompt.title[0]} {prompt.title[0]}
</div> </div>
<div class="text-xs text-gray-600 dark:text-gray-400 font-normal line-clamp-1"> <div class="text-xs text-gray-600 dark:text-gray-400 font-normal line-clamp-1">
{prompt.title[1]} {prompt.title[1]}
</div> </div>
{:else} {:else}
<div <div
class="font-medium dark:text-gray-300 dark:group-hover:text-gray-200 transition line-clamp-1" class="font-medium dark:text-gray-300 dark:group-hover:text-gray-200 transition line-clamp-1"
> >
{prompt.content} {prompt.content}
</div> </div>
<div class="text-xs text-gray-600 dark:text-gray-400 font-normal line-clamp-1"> <div class="text-xs text-gray-600 dark:text-gray-400 font-normal line-clamp-1">
{$i18n.t('Prompt')} {$i18n.t('Prompt')}
</div> </div>
{/if} {/if}
</div> </div>
</button> </button>
{/each} {/each}
</div>
{/if} {/if}
</div> </div>

View File

@ -1,29 +1,36 @@
<script lang="ts"> <script lang="ts">
import { marked } from 'marked'; import { marked } from 'marked';
import TurndownService from 'turndown'; import TurndownService from 'turndown';
import { gfm } from 'turndown-plugin-gfm';
const turndownService = new TurndownService({ const turndownService = new TurndownService({
codeBlockStyle: 'fenced', codeBlockStyle: 'fenced',
headingStyle: 'atx' headingStyle: 'atx'
}); });
turndownService.escape = (string) => string; turndownService.escape = (string) => string;
// Use turndown-plugin-gfm for proper GFM table support
turndownService.use(gfm);
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
const eventDispatch = createEventDispatcher(); const eventDispatch = createEventDispatcher();
import { EditorState, Plugin, PluginKey, TextSelection } from 'prosemirror-state'; import { EditorState, Plugin, PluginKey, TextSelection } from 'prosemirror-state';
import { Decoration, DecorationSet } from 'prosemirror-view'; import { Decoration, DecorationSet } from 'prosemirror-view';
import { Editor } from '@tiptap/core'; import { Editor } from '@tiptap/core';
import { AIAutocompletion } from './RichTextInput/AutoCompletion.js'; import { AIAutocompletion } from './RichTextInput/AutoCompletion.js';
import Table from '@tiptap/extension-table';
import TableRow from '@tiptap/extension-table-row';
import TableHeader from '@tiptap/extension-table-header';
import TableCell from '@tiptap/extension-table-cell';
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'; import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
import Placeholder from '@tiptap/extension-placeholder'; import Placeholder from '@tiptap/extension-placeholder';
import { all, createLowlight } from 'lowlight';
import StarterKit from '@tiptap/starter-kit';
import Highlight from '@tiptap/extension-highlight'; import Highlight from '@tiptap/extension-highlight';
import Typography from '@tiptap/extension-typography'; import Typography from '@tiptap/extension-typography';
import StarterKit from '@tiptap/starter-kit';
import { all, createLowlight } from 'lowlight';
import { PASTED_TEXT_CHARACTER_LIMIT } from '$lib/constants'; import { PASTED_TEXT_CHARACTER_LIMIT } from '$lib/constants';
@ -194,6 +201,10 @@
Highlight, Highlight,
Typography, Typography,
Placeholder.configure({ placeholder }), Placeholder.configure({ placeholder }),
Table.configure({ resizable: true }),
TableRow,
TableHeader,
TableCell,
...(autocomplete ...(autocomplete
? [ ? [
AIAutocompletion.configure({ AIAutocompletion.configure({

View File

@ -0,0 +1,19 @@
<script lang="ts">
export let className = 'size-4';
</script>
<svg
class={className}
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
fill-rule="evenodd"
d="M12.006 2a9.847 9.847 0 0 0-6.484 2.44 10.32 10.32 0 0 0-3.393 6.17 10.48 10.48 0 0 0 1.317 6.955 10.045 10.045 0 0 0 5.4 4.418c.504.095.683-.223.683-.494 0-.245-.01-1.052-.014-1.908-2.78.62-3.366-1.21-3.366-1.21a2.711 2.711 0 0 0-1.11-1.5c-.907-.637.07-.621.07-.621.317.044.62.163.885.346.266.183.487.426.647.71.135.253.318.476.538.655a2.079 2.079 0 0 0 2.37.196c.045-.52.27-1.006.635-1.37-2.219-.259-4.554-1.138-4.554-5.07a4.022 4.022 0 0 1 1.031-2.75 3.77 3.77 0 0 1 .096-2.713s.839-.275 2.749 1.05a9.26 9.26 0 0 1 5.004 0c1.906-1.325 2.74-1.05 2.74-1.05.37.858.406 1.828.101 2.713a4.017 4.017 0 0 1 1.029 2.75c0 3.939-2.339 4.805-4.564 5.058a2.471 2.471 0 0 1 .679 1.897c0 1.372-.012 2.477-.012 2.814 0 .272.18.592.687.492a10.05 10.05 0 0 0 5.388-4.421 10.473 10.473 0 0 0 1.313-6.948 10.32 10.32 0 0 0-3.39-6.165A9.847 9.847 0 0 0 12.007 2Z"
clip-rule="evenodd"
/>
</svg>

View File

@ -1,19 +0,0 @@
<script lang="ts">
export let className = 'w-4 h-4';
export let strokeWidth = '1.5';
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width={strokeWidth}
stroke="currentColor"
class={className}
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 18v-5.25m0 0a6.01 6.01 0 0 0 1.5-.189m-1.5.189a6.01 6.01 0 0 1-1.5-.189m3.75 7.478a12.06 12.06 0 0 1-4.5 0m3.75 2.383a14.406 14.406 0 0 1-3 0M14.25 18v-.192c0-.983.658-1.823 1.508-2.316a7.5 7.5 0 1 0-7.517 0c.85.493 1.509 1.333 1.509 2.316V18"
/>
</svg>

View File

@ -0,0 +1,168 @@
<script>
import fileSaver from 'file-saver';
const { saveAs } = fileSaver;
import { toast } from 'svelte-sonner';
import { getContext } from 'svelte';
import { archiveChatById, getAllArchivedChats, getArchivedChatList } from '$lib/apis/chats';
import ChatsModal from './ChatsModal.svelte';
import UnarchiveAllConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
const i18n = getContext('i18n');
export let show = false;
export let onUpdate = () => {};
let chatList = null;
let page = 1;
let query = '';
let orderBy = 'updated_at';
let direction = 'desc';
let allChatsLoaded = false;
let chatListLoading = false;
let searchDebounceTimeout;
let showUnarchiveAllConfirmDialog = false;
let filter = {};
$: filter = {
...(query ? { query } : {}),
...(orderBy ? { order_by: orderBy } : {}),
...(direction ? { direction } : {})
};
$: if (filter !== null) {
searchHandler();
}
const searchHandler = async () => {
if (searchDebounceTimeout) {
clearTimeout(searchDebounceTimeout);
}
page = 1;
chatList = null;
if (query === '') {
chatList = await getArchivedChatList(localStorage.token, page, filter);
} else {
searchDebounceTimeout = setTimeout(async () => {
chatList = await getArchivedChatList(localStorage.token, page, filter);
}, 500);
}
if ((chatList ?? []).length === 0) {
allChatsLoaded = true;
} else {
allChatsLoaded = false;
}
};
const loadMoreChats = async () => {
chatListLoading = true;
page += 1;
let newChatList = [];
if (query) {
newChatList = await getArchivedChatList(localStorage.token, page, filter);
} else {
newChatList = await getArchivedChatList(localStorage.token, page, filter);
}
// once the bottom of the list has been reached (no results) there is no need to continue querying
allChatsLoaded = newChatList.length === 0;
if (newChatList.length > 0) {
chatList = [...chatList, ...newChatList];
}
chatListLoading = false;
};
const exportChatsHandler = async () => {
const chats = await getAllArchivedChats(localStorage.token);
let blob = new Blob([JSON.stringify(chats)], {
type: 'application/json'
});
saveAs(blob, `${$i18n.t('archived-chat-export')}-${Date.now()}.json`);
};
const unarchiveHandler = async (chatId) => {
const res = await archiveChatById(localStorage.token, chatId).catch((error) => {
toast.error(`${error}`);
});
onUpdate();
init();
};
const unarchiveAllHandler = async () => {
const chats = await getAllArchivedChats(localStorage.token);
for (const chat of chats) {
await archiveChatById(localStorage.token, chat.id);
}
onUpdate();
init();
};
const init = async () => {
chatList = await getArchivedChatList(localStorage.token);
};
$: if (show) {
init();
}
</script>
<UnarchiveAllConfirmDialog
bind:show={showUnarchiveAllConfirmDialog}
message={$i18n.t('Are you sure you want to unarchive all archived chats?')}
confirmLabel={$i18n.t('Unarchive All')}
on:confirm={() => {
unarchiveAllHandler();
}}
/>
<ChatsModal
bind:show
bind:query
bind:orderBy
bind:direction
title={$i18n.t('Archived Chats')}
emptyPlaceholder={$i18n.t('You have no archived conversations.')}
{chatList}
{allChatsLoaded}
{chatListLoading}
onUpdate={() => {
init();
}}
loadHandler={loadMoreChats}
{unarchiveHandler}
>
<div slot="footer">
<div class="flex flex-wrap text-sm font-medium gap-1.5 mt-2 m-1 justify-end w-full">
<button
class=" px-3.5 py-1.5 font-medium hover:bg-black/5 dark:hover:bg-white/5 outline outline-1 outline-gray-100 dark:outline-gray-800 rounded-3xl"
on:click={() => {
showUnarchiveAllConfirmDialog = true;
}}
>
{$i18n.t('Unarchive All Archived Chats')}
</button>
<button
class="px-3.5 py-1.5 font-medium hover:bg-black/5 dark:hover:bg-white/5 outline outline-1 outline-gray-100 dark:outline-gray-800 rounded-3xl"
on:click={() => {
exportChatsHandler();
}}
>
{$i18n.t('Export All Archived Chats')}
</button>
</div>
</div>
</ChatsModal>

View File

@ -0,0 +1,450 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { getContext } from 'svelte';
import dayjs from 'dayjs';
import localizedFormat from 'dayjs/plugin/localizedFormat';
dayjs.extend(localizedFormat);
import { deleteChatById } from '$lib/apis/chats';
import Modal from '$lib/components/common/Modal.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import Spinner from '../common/Spinner.svelte';
import Loader from '../common/Loader.svelte';
import XMark from '../icons/XMark.svelte';
import ChevronUp from '../icons/ChevronUp.svelte';
import ChevronDown from '../icons/ChevronDown.svelte';
const i18n = getContext('i18n');
export let show = false;
export let title = 'Chats';
export let emptyPlaceholder = '';
export let shareUrl = false;
export let query = '';
export let orderBy = 'updated_at';
export let direction = 'desc'; // 'asc' or 'desc'
export let chatList = null;
export let allChatsLoaded = false;
export let chatListLoading = false;
let selectedChatId = null;
let selectedIdx = 0;
let showDeleteConfirmDialog = false;
export let onUpdate = () => {};
export let loadHandler: null | Function = null;
export let unarchiveHandler: null | Function = null;
const setSortKey = (key) => {
if (orderBy === key) {
direction = direction === 'asc' ? 'desc' : 'asc';
} else {
orderBy = key;
direction = 'asc';
}
};
const deleteHandler = async (chatId) => {
const res = await deleteChatById(localStorage.token, chatId).catch((error) => {
toast.error(`${error}`);
});
onUpdate();
};
</script>
<ConfirmDialog
bind:show={showDeleteConfirmDialog}
on:confirm={() => {
if (selectedChatId) {
deleteHandler(selectedChatId);
selectedChatId = null;
}
}}
/>
<Modal size="lg" bind:show>
<div>
<div class=" flex justify-between dark:text-gray-300 px-5 pt-4 pb-1">
<div class=" text-lg font-medium self-center">{title}</div>
<button
class="self-center"
on:click={() => {
show = false;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
fill-rule="evenodd"
d="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"
clip-rule="evenodd"
/>
</svg>
</button>
</div>
<div class="flex flex-col w-full px-5 pb-4 dark:text-gray-200">
<div class=" flex w-full space-x-2 mb-0.5">
<div class="flex flex-1">
<div class=" self-center ml-1 mr-3">
<svg
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>
<input
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
bind:value={query}
placeholder={$i18n.t('Search Chats')}
/>
{#if query}
<div class="self-center pl-1.5 pr-1 translate-y-[0.5px] rounded-l-xl bg-transparent">
<button
class="p-0.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-900 transition"
on:click={() => {
query = '';
selectedIdx = 0;
}}
>
<XMark className="size-3" strokeWidth="2" />
</button>
</div>
{/if}
</div>
</div>
<div class=" flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
{#if chatList}
<div class="w-full">
{#if chatList.length > 0}
<div class="flex text-xs font-medium mb-1.5">
<button
class="px-1.5 py-1 cursor-pointer select-none basis-3/5"
on:click={() => setSortKey('title')}
>
<div class="flex gap-1.5 items-center">
{$i18n.t('Title')}
{#if orderBy === 'title'}
<span class="font-normal"
>{#if direction === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
</span>
{:else}
<span class="invisible">
<ChevronUp className="size-2" />
</span>
{/if}
</div>
</button>
<button
class="px-1.5 py-1 cursor-pointer select-none hidden sm:flex sm:basis-2/5 justify-end"
on:click={() => setSortKey('updated_at')}
>
<div class="flex gap-1.5 items-center">
{$i18n.t('Updated at')}
{#if orderBy === 'updated_at'}
<span class="font-normal"
>{#if direction === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
</span>
{:else}
<span class="invisible">
<ChevronUp className="size-2" />
</span>
{/if}
</div>
</button>
</div>
{/if}
<div class="text-left text-sm w-full mb-3 max-h-[22rem] overflow-y-scroll">
{#if chatList.length === 0}
<div
class="text-xs text-gray-500 dark:text-gray-400 text-center px-5 min-h-20 w-full h-full flex justify-center items-center"
>
{$i18n.t('No results found')}
</div>
{/if}
{#each chatList as chat, idx (chat.id)}
{#if (idx === 0 || (idx > 0 && chat.time_range !== chatList[idx - 1].time_range)) && chat?.time_range}
<div
class="w-full text-xs text-gray-500 dark:text-gray-500 font-medium {idx === 0
? ''
: 'pt-5'} pb-2 px-2"
>
{$i18n.t(chat.time_range)}
<!-- localisation keys for time_range to be recognized from the i18next parser (so they don't get automatically removed):
{$i18n.t('Today')}
{$i18n.t('Yesterday')}
{$i18n.t('Previous 7 days')}
{$i18n.t('Previous 30 days')}
{$i18n.t('January')}
{$i18n.t('February')}
{$i18n.t('March')}
{$i18n.t('April')}
{$i18n.t('May')}
{$i18n.t('June')}
{$i18n.t('July')}
{$i18n.t('August')}
{$i18n.t('September')}
{$i18n.t('October')}
{$i18n.t('November')}
{$i18n.t('December')}
-->
</div>
{/if}
<div
class=" w-full flex justify-between items-center rounded-lg text-sm py-2 px-3 hover:bg-gray-50 dark:hover:bg-gray-850"
draggable="false"
>
<a
class=" basis-3/5"
href={shareUrl ? `/s/${chat.id}` : `/c/${chat.id}`}
on:click={() => (show = false)}
>
<div class="text-ellipsis line-clamp-1 w-full">
{chat?.title}
</div>
</a>
<div class="basis-2/5 flex items-center justify-end">
<div class="hidden sm:flex text-gray-500 dark:text-gray-400 text-xs">
{dayjs(chat?.updated_at * 1000).calendar()}
</div>
<div class="flex justify-end pl-2.5 text-gray-600 dark:text-gray-300">
{#if unarchiveHandler}
<Tooltip content={$i18n.t('Unarchive Chat')}>
<button
class="self-center w-fit px-1 text-sm rounded-xl"
on:click={async (e) => {
e.stopImmediatePropagation();
e.stopPropagation();
unarchiveHandler(chat.id);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 8.25H7.5a2.25 2.25 0 0 0-2.25 2.25v9a2.25 2.25 0 0 0 2.25 2.25h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25H15m0-3-3-3m0 0-3 3m3-3V15"
/>
</svg>
</button>
</Tooltip>
{/if}
<Tooltip content={$i18n.t('Delete Chat')}>
<button
class="self-center w-fit px-1 text-sm rounded-xl"
on:click={async (e) => {
e.stopImmediatePropagation();
e.stopPropagation();
selectedChatId = chat.id;
showDeleteConfirmDialog = true;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
/>
</svg>
</button>
</Tooltip>
</div>
</div>
</div>
{/each}
{#if !allChatsLoaded && loadHandler}
<Loader
on:visible={(e) => {
if (!chatListLoading) {
loadHandler();
}
}}
>
<div
class="w-full flex justify-center py-1 text-xs animate-pulse items-center gap-2"
>
<Spinner className=" size-4" />
<div class=" ">Loading...</div>
</div>
</Loader>
{/if}
</div>
{#if query === ''}
<slot name="footer"></slot>
{/if}
</div>
{:else}
<div class="w-full h-full flex justify-center items-center min-h-20">
<Spinner />
</div>
{/if}
<!-- {#if chats !== null}
{#if chats.length > 0}
<div class="w-full">
<div class="text-left text-sm w-full mb-3 max-h-[22rem] overflow-y-scroll">
<div class="relative overflow-x-auto">
<table
class="w-full text-sm text-left text-gray-600 dark:text-gray-400 table-auto"
>
<thead
class="text-xs text-gray-700 uppercase bg-transparent dark:text-gray-200 border-b-1 border-gray-50 dark:border-gray-850"
>
<tr>
<th scope="col" class="px-3 py-2"> {$i18n.t('Name')} </th>
<th scope="col" class="px-3 py-2 hidden md:flex">
{$i18n.t('Created At')}
</th>
<th scope="col" class="px-3 py-2 text-right" />
</tr>
</thead>
<tbody>
{#each chats as chat, idx}
<tr
class="bg-transparent {idx !== chats.length - 1 &&
'border-b'} dark:bg-gray-900 border-gray-50 dark:border-gray-850 text-xs"
>
<td class="px-3 py-1 w-2/3">
<a href="/c/{chat.id}" target="_blank">
<div class=" hover:underline line-clamp-1">
{chat.title}
</div>
</a>
</td>
<td class=" px-3 py-1 hidden md:flex h-[2.5rem]">
<div class="my-auto">
{dayjs(chat.created_at * 1000).format('LLL')}
</div>
</td>
<td class="px-3 py-1 text-right">
<div class="flex justify-end w-full">
{#if unarchiveHandler}
<Tooltip content={$i18n.t('Unarchive Chat')}>
<button
class="self-center w-fit text-sm px-2 py-2 hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
on:click={async () => {
unarchiveHandler(chat.id);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 8.25H7.5a2.25 2.25 0 0 0-2.25 2.25v9a2.25 2.25 0 0 0 2.25 2.25h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25H15m0-3-3-3m0 0-3 3m3-3V15"
/>
</svg>
</button>
</Tooltip>
{/if}
<Tooltip content={$i18n.t('Delete Chat')}>
<button
class="self-center w-fit text-sm px-2 py-2 hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
on:click={async () => {
deleteHandler(chat.id);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
/>
</svg>
</button>
</Tooltip>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
<slot name="footer"></slot>
</div>
{:else}
<div class="text-left text-sm w-full mb-8">
{emptyPlaceholder || $i18n.t('No chats found.')}
</div>
{/if}
{:else}
<div class="w-full h-full">
<Spinner />
</div>
{/if} -->
</div>
</div>
</div>
</Modal>

View File

@ -1,40 +0,0 @@
<script lang="ts">
import { onMount, tick, getContext } from 'svelte';
const i18n = getContext('i18n');
import ShortcutsModal from '../chat/ShortcutsModal.svelte';
import Tooltip from '../common/Tooltip.svelte';
import HelpMenu from './Help/HelpMenu.svelte';
let showShortcuts = false;
</script>
<div class=" hidden lg:flex fixed bottom-0 right-0 px-1 py-1 z-20">
<button
id="show-shortcuts-button"
class="hidden"
on:click={() => {
showShortcuts = !showShortcuts;
}}
/>
<HelpMenu
showDocsHandler={() => {
showShortcuts = !showShortcuts;
}}
showShortcutsHandler={() => {
showShortcuts = !showShortcuts;
}}
>
<Tooltip content={$i18n.t('Help')} placement="left">
<button
class="text-gray-600 dark:text-gray-300 bg-gray-300/20 size-4 flex items-center justify-center text-[0.7rem] rounded-full"
>
?
</button>
</Tooltip>
</HelpMenu>
</div>
<ShortcutsModal bind:show={showShortcuts} />

View File

@ -1,60 +0,0 @@
<script lang="ts">
import { DropdownMenu } from 'bits-ui';
import { getContext } from 'svelte';
import { showSettings } from '$lib/stores';
import { flyAndScale } from '$lib/utils/transitions';
import Dropdown from '$lib/components/common/Dropdown.svelte';
import QuestionMarkCircle from '$lib/components/icons/QuestionMarkCircle.svelte';
import Lifebuoy from '$lib/components/icons/Lifebuoy.svelte';
import Keyboard from '$lib/components/icons/Keyboard.svelte';
const i18n = getContext('i18n');
export let showDocsHandler: Function;
export let showShortcutsHandler: Function;
export let onClose: Function = () => {};
</script>
<Dropdown
on:change={(e) => {
if (e.detail === false) {
onClose();
}
}}
>
<slot />
<div slot="content">
<DropdownMenu.Content
class="w-full max-w-[200px] rounded-xl px-1 py-1.5 border border-gray-300/30 dark:border-gray-700/50 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg"
sideOffset={4}
side="top"
align="end"
transition={flyAndScale}
>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
id="chat-share-button"
on:click={() => {
window.open('https://docs.openwebui.com', '_blank');
}}
>
<QuestionMarkCircle className="size-5" />
<div class="flex items-center">{$i18n.t('Documentation')}</div>
</DropdownMenu.Item>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
id="chat-share-button"
on:click={() => {
showShortcutsHandler();
}}
>
<Keyboard className="size-5" />
<div class="flex items-center">{$i18n.t('Keyboard shortcuts')}</div>
</DropdownMenu.Item>
</DropdownMenu.Content>
</div>
</Dropdown>

View File

@ -166,8 +166,9 @@
{#if $user !== undefined} {#if $user !== undefined}
<UserMenu <UserMenu
className="max-w-[200px]" className="max-w-[240px]"
role={$user?.role} role={$user?.role}
help={true}
on:show={(e) => { on:show={(e) => {
if (e.detail === 'archived-chat') { if (e.detail === 'archived-chat') {
showArchivedChats.set(true); showArchivedChats.set(true);

View File

@ -26,9 +26,9 @@
let searchDebounceTimeout; let searchDebounceTimeout;
const searchHandler = async () => { let selectedIdx = 0;
console.log('search', query);
const searchHandler = async () => {
if (searchDebounceTimeout) { if (searchDebounceTimeout) {
clearTimeout(searchDebounceTimeout); clearTimeout(searchDebounceTimeout);
} }
@ -89,25 +89,47 @@
on:input={searchHandler} on:input={searchHandler}
placeholder={$i18n.t('Search')} placeholder={$i18n.t('Search')}
showClearButton={true} showClearButton={true}
onKeydown={(e) => {
console.log('e', e);
if (e.code === 'Enter' && (chatList ?? []).length > 0) {
const item = document.querySelector(`[data-arrow-selected="true"]`);
if (item) {
item?.click();
}
show = false;
return;
} else if (e.code === 'ArrowDown') {
selectedIdx = Math.min(selectedIdx + 1, (chatList ?? []).length - 1);
} else if (e.code === 'ArrowUp') {
selectedIdx = Math.max(selectedIdx - 1, 0);
} else {
selectedIdx = 0;
}
const item = document.querySelector(`[data-arrow-selected="true"]`);
item?.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' });
}}
/> />
</div> </div>
<!-- <hr class="border-gray-100 dark:border-gray-850 my-1" /> --> <!-- <hr class="border-gray-100 dark:border-gray-850 my-1" /> -->
<div class="flex flex-col overflow-y-auto h-80 scrollbar-hidden px-5 pb-1"> <div class="flex flex-col overflow-y-auto h-80 scrollbar-hidden px-3 pb-1">
{#if chatList} {#if chatList}
{#if chatList.length === 0} {#if chatList.length === 0}
<div class="text-xs text-gray-500 dark:text-gray-400 text-center"> <div class="text-xs text-gray-500 dark:text-gray-400 text-center px-5">
{$i18n.t('No results found')} {$i18n.t('No results found')}
</div> </div>
{/if} {/if}
{#each chatList as chat, idx} {#each chatList as chat, idx (chat.id)}
{#if idx === 0 || (idx > 0 && chat.time_range !== chatList[idx - 1].time_range)} {#if idx === 0 || (idx > 0 && chat.time_range !== chatList[idx - 1].time_range)}
<div <div
class="w-full text-xs text-gray-500 dark:text-gray-500 font-medium {idx === 0 class="w-full text-xs text-gray-500 dark:text-gray-500 font-medium {idx === 0
? '' ? ''
: 'pt-5'} pb-2" : 'pt-5'} pb-2 px-2"
> >
{$i18n.t(chat.time_range)} {$i18n.t(chat.time_range)}
<!-- localisation keys for time_range to be recognized from the i18next parser (so they don't get automatically removed): <!-- localisation keys for time_range to be recognized from the i18next parser (so they don't get automatically removed):
@ -132,9 +154,16 @@
{/if} {/if}
<a <a
class=" w-full flex justify-between items-center rounded-lg text-sm py-1 px-1 my-1" class=" w-full flex justify-between items-center rounded-lg text-sm py-2 px-3 hover:bg-gray-50 dark:hover:bg-gray-850 {selectedIdx ===
idx
? 'bg-gray-50 dark:bg-gray-850'
: ''}"
href="/c/{chat.id}" href="/c/{chat.id}"
draggable="false" draggable="false"
data-arrow-selected={selectedIdx === idx ? 'true' : undefined}
on:mouseenter={() => {
selectedIdx = idx;
}}
on:click={() => { on:click={() => {
show = false; show = false;
onClose(); onClose();

View File

@ -43,7 +43,7 @@
import { createNewFolder, getFolders, updateFolderParentIdById } from '$lib/apis/folders'; import { createNewFolder, getFolders, updateFolderParentIdById } from '$lib/apis/folders';
import { WEBUI_BASE_URL } from '$lib/constants'; import { WEBUI_BASE_URL } from '$lib/constants';
import ArchivedChatsModal from './Sidebar/ArchivedChatsModal.svelte'; import ArchivedChatsModal from './ArchivedChatsModal.svelte';
import UserMenu from './Sidebar/UserMenu.svelte'; import UserMenu from './Sidebar/UserMenu.svelte';
import ChatItem from './Sidebar/ChatItem.svelte'; import ChatItem from './Sidebar/ChatItem.svelte';
import Spinner from '../common/Spinner.svelte'; import Spinner from '../common/Spinner.svelte';
@ -366,7 +366,7 @@
window.addEventListener('touchend', onTouchEnd); window.addEventListener('touchend', onTouchEnd);
window.addEventListener('focus', onFocus); window.addEventListener('focus', onFocus);
window.addEventListener('blur-sm', onBlur); window.addEventListener('blur', onBlur);
const dropZone = document.getElementById('sidebar'); const dropZone = document.getElementById('sidebar');
@ -383,7 +383,7 @@
window.removeEventListener('touchend', onTouchEnd); window.removeEventListener('touchend', onTouchEnd);
window.removeEventListener('focus', onFocus); window.removeEventListener('focus', onFocus);
window.removeEventListener('blur-sm', onBlur); window.removeEventListener('blur', onBlur);
const dropZone = document.getElementById('sidebar'); const dropZone = document.getElementById('sidebar');
@ -395,7 +395,7 @@
<ArchivedChatsModal <ArchivedChatsModal
bind:show={$showArchivedChats} bind:show={$showArchivedChats}
on:change={async () => { onUpdate={async () => {
await initChatList(); await initChatList();
}} }}
/> />
@ -545,6 +545,24 @@
</div> </div>
{/if} --> {/if} -->
<div class="px-1.5 flex justify-center text-gray-800 dark:text-gray-200">
<button
class="grow flex items-center space-x-3 rounded-lg px-2 py-[7px] hover:bg-gray-100 dark:hover:bg-gray-900 transition outline-none"
on:click={() => {
showSearch.set(true);
}}
draggable="false"
>
<div class="self-center">
<MagnifyingGlass strokeWidth="2" className="size-[1.1rem]" />
</div>
<div class="flex self-center translate-y-[0.5px]">
<div class=" self-center font-medium text-sm font-primary">{$i18n.t('Search')}</div>
</div>
</button>
</div>
{#if ($config?.features?.enable_notes ?? false) && ($user?.role === 'admin' || ($user?.permissions?.features?.notes ?? true))} {#if ($config?.features?.enable_notes ?? false) && ($user?.role === 'admin' || ($user?.permissions?.features?.notes ?? true))}
<div class="px-1.5 flex justify-center text-gray-800 dark:text-gray-200"> <div class="px-1.5 flex justify-center text-gray-800 dark:text-gray-200">
<a <a
@ -626,24 +644,6 @@
</div> </div>
{/if} {/if}
<div class="px-1.5 flex justify-center text-gray-800 dark:text-gray-200">
<button
class="grow flex items-center space-x-3 rounded-lg px-2 py-[7px] hover:bg-gray-100 dark:hover:bg-gray-900 transition outline-none"
on:click={() => {
showSearch.set(true);
}}
draggable="false"
>
<div class="self-center">
<MagnifyingGlass strokeWidth="2" className="size-[1.1rem]" />
</div>
<div class="flex self-center translate-y-[0.5px]">
<div class=" self-center font-medium text-sm font-primary">{$i18n.t('Search')}</div>
</div>
</button>
</div>
<div <div
class="relative flex flex-col flex-1 overflow-y-auto overflow-x-hidden {$temporaryChatEnabled class="relative flex flex-col flex-1 overflow-y-auto overflow-x-hidden {$temporaryChatEnabled
? 'opacity-20' ? 'opacity-20'

View File

@ -1,255 +0,0 @@
<script lang="ts">
import fileSaver from 'file-saver';
const { saveAs } = fileSaver;
import { toast } from 'svelte-sonner';
import dayjs from 'dayjs';
import { getContext, createEventDispatcher } from 'svelte';
import localizedFormat from 'dayjs/plugin/localizedFormat';
dayjs.extend(localizedFormat);
const dispatch = createEventDispatcher();
import {
archiveChatById,
deleteChatById,
getAllArchivedChats,
getArchivedChatList
} from '$lib/apis/chats';
import Modal from '$lib/components/common/Modal.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import UnarchiveAllConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
const i18n = getContext('i18n');
export let show = false;
let chats = [];
let searchValue = '';
let showUnarchiveAllConfirmDialog = false;
const unarchiveChatHandler = async (chatId) => {
const res = await archiveChatById(localStorage.token, chatId).catch((error) => {
toast.error(`${error}`);
});
chats = await getArchivedChatList(localStorage.token);
dispatch('change');
};
const deleteChatHandler = async (chatId) => {
const res = await deleteChatById(localStorage.token, chatId).catch((error) => {
toast.error(`${error}`);
});
chats = await getArchivedChatList(localStorage.token);
};
const exportChatsHandler = async () => {
const chats = await getAllArchivedChats(localStorage.token);
let blob = new Blob([JSON.stringify(chats)], {
type: 'application/json'
});
saveAs(blob, `${$i18n.t('archived-chat-export')}-${Date.now()}.json`);
};
const unarchiveAllHandler = async () => {
for (const chat of chats) {
await archiveChatById(localStorage.token, chat.id);
}
chats = await getArchivedChatList(localStorage.token);
};
$: if (show) {
(async () => {
chats = await getArchivedChatList(localStorage.token);
})();
}
</script>
<UnarchiveAllConfirmDialog
bind:show={showUnarchiveAllConfirmDialog}
message={$i18n.t('Are you sure you want to unarchive all archived chats?')}
confirmLabel={$i18n.t('Unarchive All')}
on:confirm={() => {
unarchiveAllHandler();
}}
/>
<Modal size="lg" bind:show>
<div>
<div class=" flex justify-between dark:text-gray-300 px-5 pt-4 pb-1">
<div class=" text-lg font-medium self-center">{$i18n.t('Archived Chats')}</div>
<button
class="self-center"
on:click={() => {
show = false;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
fill-rule="evenodd"
d="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"
clip-rule="evenodd"
/>
</svg>
</button>
</div>
<div class="flex flex-col w-full px-5 pb-4 dark:text-gray-200">
<div class=" flex w-full mt-2 space-x-2">
<div class="flex flex-1">
<div class=" self-center ml-1 mr-3">
<svg
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>
<input
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
bind:value={searchValue}
placeholder={$i18n.t('Search Chats')}
/>
</div>
</div>
<hr class="border-gray-100 dark:border-gray-850 my-2" />
<div class=" flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
{#if chats.length > 0}
<div class="w-full">
<div class="text-left text-sm w-full mb-3 max-h-[22rem] overflow-y-scroll">
<div class="relative overflow-x-auto">
<table class="w-full text-sm text-left text-gray-600 dark:text-gray-400 table-auto">
<thead
class="text-xs text-gray-700 uppercase bg-transparent dark:text-gray-200 border-b-2 border-gray-50 dark:border-gray-850"
>
<tr>
<th scope="col" class="px-3 py-2"> {$i18n.t('Name')} </th>
<th scope="col" class="px-3 py-2 hidden md:flex">
{$i18n.t('Created At')}
</th>
<th scope="col" class="px-3 py-2 text-right" />
</tr>
</thead>
<tbody>
{#each chats.filter((c) => searchValue === '' || c.title
.toLowerCase()
.includes(searchValue.toLowerCase())) as chat, idx}
<tr
class="bg-transparent {idx !== chats.length - 1 &&
'border-b'} dark:bg-gray-900 border-gray-50 dark:border-gray-850 text-xs"
>
<td class="px-3 py-1 w-2/3">
<a href="/c/{chat.id}" target="_blank">
<div class=" underline line-clamp-1">
{chat.title}
</div>
</a>
</td>
<td class=" px-3 py-1 hidden md:flex h-[2.5rem]">
<div class="my-auto">
{dayjs(chat.created_at * 1000).format('LLL')}
</div>
</td>
<td class="px-3 py-1 text-right">
<div class="flex justify-end w-full">
<Tooltip content={$i18n.t('Unarchive Chat')}>
<button
class="self-center w-fit text-sm px-2 py-2 hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
on:click={async () => {
unarchiveChatHandler(chat.id);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 8.25H7.5a2.25 2.25 0 0 0-2.25 2.25v9a2.25 2.25 0 0 0 2.25 2.25h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25H15m0-3-3-3m0 0-3 3m3-3V15"
/>
</svg>
</button>
</Tooltip>
<Tooltip content={$i18n.t('Delete Chat')}>
<button
class="self-center w-fit text-sm px-2 py-2 hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
on:click={async () => {
deleteChatHandler(chat.id);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
/>
</svg>
</button>
</Tooltip>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
<div class="flex flex-wrap text-sm font-medium gap-1.5 mt-2 m-1 justify-end w-full">
<button
class=" px-3.5 py-1.5 font-medium hover:bg-black/5 dark:hover:bg-white/5 outline outline-1 outline-gray-300 dark:outline-gray-800 rounded-3xl"
on:click={() => {
showUnarchiveAllConfirmDialog = true;
}}
>
{$i18n.t('Unarchive All Archived Chats')}
</button>
<button
class="px-3.5 py-1.5 font-medium hover:bg-black/5 dark:hover:bg-white/5 outline outline-1 outline-gray-300 dark:outline-gray-800 rounded-3xl"
on:click={() => {
exportChatsHandler();
}}
>
{$i18n.t('Export All Archived Chats')}
</button>
</div>
</div>
{:else}
<div class="text-left text-sm w-full mb-8">
{$i18n.t('You have no archived conversations.')}
</div>
{/if}
</div>
</div>
</div>
</Modal>

View File

@ -11,6 +11,7 @@
export let placeholder = ''; export let placeholder = '';
export let value = ''; export let value = '';
export let showClearButton = false; export let showClearButton = false;
export let onKeydown = (e) => {};
let selectedIdx = 0; let selectedIdx = 0;
@ -145,6 +146,10 @@
// if the user types something, reset to the top selection. // if the user types something, reset to the top selection.
selectedIdx = 0; selectedIdx = 0;
} }
if (!document.getElementById('search-options-container')) {
onKeydown(e);
}
}} }}
/> />
@ -164,6 +169,7 @@
<!-- svelte-ignore a11y-no-static-element-interactions --> <!-- svelte-ignore a11y-no-static-element-interactions -->
<div <div
class="absolute top-0 mt-8 left-0 right-1 border border-gray-100 dark:border-gray-900 bg-gray-50 dark:bg-gray-950 rounded-lg z-10 shadow-lg" class="absolute top-0 mt-8 left-0 right-1 border border-gray-100 dark:border-gray-900 bg-gray-50 dark:bg-gray-950 rounded-lg z-10 shadow-lg"
id="search-options-container"
in:fade={{ duration: 50 }} in:fade={{ duration: 50 }}
on:mouseenter={() => { on:mouseenter={() => {
selectedIdx = null; selectedIdx = null;

View File

@ -9,16 +9,25 @@
import { fade, slide } from 'svelte/transition'; import { fade, slide } from 'svelte/transition';
import Tooltip from '$lib/components/common/Tooltip.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte';
import { userSignOut } from '$lib/apis/auths'; import { userSignOut } from '$lib/apis/auths';
import QuestionMarkCircle from '$lib/components/icons/QuestionMarkCircle.svelte';
import Map from '$lib/components/icons/Map.svelte';
import Keyboard from '$lib/components/icons/Keyboard.svelte';
import ShortcutsModal from '$lib/components/chat/ShortcutsModal.svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
export let show = false; export let show = false;
export let role = ''; export let role = '';
export let help = false;
export let className = 'max-w-[240px]'; export let className = 'max-w-[240px]';
let showShortcuts = false;
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
</script> </script>
<ShortcutsModal bind:show={showShortcuts} />
<DropdownMenu.Root <DropdownMenu.Root
bind:open={show} bind:open={show}
onOpenChange={(state) => { onOpenChange={(state) => {
@ -32,13 +41,13 @@
<slot name="content"> <slot name="content">
<DropdownMenu.Content <DropdownMenu.Content
class="w-full {className} text-sm rounded-xl px-1 py-1.5 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg font-primary" class="w-full {className} text-sm rounded-xl px-1 py-1.5 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg font-primary"
sideOffset={8} sideOffset={4}
side="bottom" side="bottom"
align="start" align="start"
transition={(e) => fade(e, { duration: 100 })} transition={(e) => fade(e, { duration: 100 })}
> >
<button <button
class="flex rounded-md py-2 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition" class="flex rounded-md py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition"
on:click={async () => { on:click={async () => {
await showSettings.set(true); await showSettings.set(true);
show = false; show = false;
@ -73,7 +82,7 @@
</button> </button>
<button <button
class="flex rounded-md py-2 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition" class="flex rounded-md py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition"
on:click={() => { on:click={() => {
dispatch('show', 'archived-chat'); dispatch('show', 'archived-chat');
show = false; show = false;
@ -91,7 +100,7 @@
{#if role === 'admin'} {#if role === 'admin'}
<a <a
class="flex rounded-md py-2 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition" class="flex rounded-md py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition"
href="/playground" href="/playground"
on:click={() => { on:click={() => {
show = false; show = false;
@ -121,7 +130,7 @@
</a> </a>
<a <a
class="flex rounded-md py-2 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition" class="flex rounded-md py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition"
href="/admin" href="/admin"
on:click={() => { on:click={() => {
show = false; show = false;
@ -151,10 +160,50 @@
</a> </a>
{/if} {/if}
<hr class=" border-gray-100 dark:border-gray-850 my-1 p-0" /> {#if help}
<hr class=" border-gray-100 dark:border-gray-800 my-1 p-0" />
<!-- {$i18n.t('Help')} -->
<DropdownMenu.Item
class="flex gap-2 items-center py-1.5 px-3 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
id="chat-share-button"
on:click={() => {
window.open('https://docs.openwebui.com', '_blank');
}}
>
<QuestionMarkCircle className="size-5" />
<div class="flex items-center">{$i18n.t('Documentation')}</div>
</DropdownMenu.Item>
<!-- Releases -->
<DropdownMenu.Item
class="flex gap-2 items-center py-1.5 px-3 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
id="menu-item-releases"
on:click={() => {
window.open('https://github.com/open-webui/open-webui/releases', '_blank');
}}
>
<Map className="size-5" />
<div class="flex items-center">{$i18n.t('Releases')}</div>
</DropdownMenu.Item>
<DropdownMenu.Item
class="flex gap-2 items-center py-1.5 px-3 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
id="chat-share-button"
on:click={() => {
showShortcuts = !showShortcuts;
show = false;
}}
>
<Keyboard className="size-5" />
<div class="flex items-center">{$i18n.t('Keyboard shortcuts')}</div>
</DropdownMenu.Item>
{/if}
<hr class=" border-gray-100 dark:border-gray-800 my-1 p-0" />
<button <button
class="flex rounded-md py-2 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition" class="flex rounded-md py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition"
on:click={async () => { on:click={async () => {
const res = await userSignOut(); const res = await userSignOut();
user.set(null); user.set(null);
@ -187,14 +236,14 @@
</button> </button>
{#if $activeUserIds?.length > 0} {#if $activeUserIds?.length > 0}
<hr class=" border-gray-100 dark:border-gray-850 my-1 p-0" /> <hr class=" border-gray-100 dark:border-gray-800 my-1 p-0" />
<Tooltip <Tooltip
content={$USAGE_POOL && $USAGE_POOL.length > 0 content={$USAGE_POOL && $USAGE_POOL.length > 0
? `${$i18n.t('Running')}: ${$USAGE_POOL.join(', ')} ✨` ? `${$i18n.t('Running')}: ${$USAGE_POOL.join(', ')} ✨`
: ''} : ''}
> >
<div class="flex rounded-md py-1.5 px-3 text-xs gap-2.5 items-center"> <div class="flex rounded-md py-1 px-3 text-xs gap-2.5 items-center">
<div class=" flex items-center"> <div class=" flex items-center">
<span class="relative flex size-2"> <span class="relative flex size-2">
<span <span
@ -216,7 +265,7 @@
</Tooltip> </Tooltip>
{/if} {/if}
<!-- <DropdownMenu.Item class="flex items-center px-3 py-2 text-sm "> <!-- <DropdownMenu.Item class="flex items-center py-1.5 px-3 text-sm ">
<div class="flex items-center">Profile</div> <div class="flex items-center">Profile</div>
</DropdownMenu.Item> --> </DropdownMenu.Item> -->
</DropdownMenu.Content> </DropdownMenu.Content>

View File

@ -276,8 +276,19 @@
files = [...files, fileItem]; files = [...files, fileItem];
try { try {
// If the file is an audio file, provide the language for STT.
let metadata = null;
if (
(file.type.startsWith('audio/') || file.type.startsWith('video/')) &&
$settings?.audio?.stt?.language
) {
metadata = {
language: $settings?.audio?.stt?.language
};
}
// During the file upload, file content is automatically extracted. // During the file upload, file content is automatically extracted.
const uploadedFile = await uploadFile(localStorage.token, file); const uploadedFile = await uploadFile(localStorage.token, file, metadata);
if (uploadedFile) { if (uploadedFile) {
console.log('File upload completed:', { console.log('File upload completed:', {

View File

@ -41,7 +41,7 @@
transition={(e) => fade(e, { duration: 100 })} transition={(e) => fade(e, { duration: 100 })}
> >
<button <button
class="flex rounded-md py-2 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition" class="flex rounded-md py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition"
on:click={async () => { on:click={async () => {
onRecord(); onRecord();
show = false; show = false;
@ -54,7 +54,7 @@
</button> </button>
<button <button
class="flex rounded-md py-2 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition" class="flex rounded-md py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition"
on:click={() => { on:click={() => {
onCaptureAudio(); onCaptureAudio();
show = false; show = false;
@ -67,7 +67,7 @@
</button> </button>
<button <button
class="flex rounded-md py-2 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition" class="flex rounded-md py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition"
on:click={() => { on:click={() => {
onUpload(); onUpload();
show = false; show = false;

View File

@ -147,7 +147,7 @@
<div class="mb-5 grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-2"> <div class="mb-5 grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-2">
{#each filteredItems as item} {#each filteredItems as item}
<button <button
class=" flex space-x-4 cursor-pointer text-left w-full px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-850 transition rounded-xl" class=" flex space-x-4 cursor-pointer text-left w-full px-3 py-2 hover:bg-black/5 dark:hover:bg-white/5 transition rounded-xl"
on:click={() => { on:click={() => {
if (item?.meta?.document) { if (item?.meta?.document) {
toast.error( toast.error(

View File

@ -9,7 +9,14 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { mobile, showSidebar, knowledge as _knowledge, config, user } from '$lib/stores'; import {
mobile,
showSidebar,
knowledge as _knowledge,
config,
user,
settings
} from '$lib/stores';
import { import {
updateFileDataContentById, updateFileDataContentById,
@ -26,10 +33,7 @@
updateFileFromKnowledgeById, updateFileFromKnowledgeById,
updateKnowledgeById updateKnowledgeById
} from '$lib/apis/knowledge'; } from '$lib/apis/knowledge';
import { transcribeAudio } from '$lib/apis/audio';
import { blobToFile } from '$lib/utils'; import { blobToFile } from '$lib/utils';
import { processFile } from '$lib/apis/retrieval';
import Spinner from '$lib/components/common/Spinner.svelte'; import Spinner from '$lib/components/common/Spinner.svelte';
import Files from './KnowledgeBase/Files.svelte'; import Files from './KnowledgeBase/Files.svelte';
@ -158,7 +162,18 @@
knowledge.files = [...(knowledge.files ?? []), fileItem]; knowledge.files = [...(knowledge.files ?? []), fileItem];
try { try {
const uploadedFile = await uploadFile(localStorage.token, file).catch((e) => { // If the file is an audio file, provide the language for STT.
let metadata = null;
if (
(file.type.startsWith('audio/') || file.type.startsWith('video/')) &&
$settings?.audio?.stt?.language
) {
metadata = {
language: $settings?.audio?.stt?.language
};
}
const uploadedFile = await uploadFile(localStorage.token, file, metadata).catch((e) => {
toast.error(`${e}`); toast.error(`${e}`);
return null; return null;
}); });

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