Merge remote-tracking branch 'origin/dev' into Anush008/main

Signed-off-by: Anush008 <anushshetty90@gmail.com>
This commit is contained in:
Anush008 2025-07-04 12:22:08 +05:30
commit 7c734d3fea
No known key found for this signature in database
216 changed files with 2166 additions and 1816 deletions

View file

@ -19,6 +19,7 @@ from open_webui.env import (
DATABASE_URL, DATABASE_URL,
ENV, ENV,
REDIS_URL, REDIS_URL,
REDIS_KEY_PREFIX,
REDIS_SENTINEL_HOSTS, REDIS_SENTINEL_HOSTS,
REDIS_SENTINEL_PORT, REDIS_SENTINEL_PORT,
FRONTEND_BUILD_DIR, FRONTEND_BUILD_DIR,
@ -211,11 +212,16 @@ class PersistentConfig(Generic[T]):
class AppConfig: class AppConfig:
_state: dict[str, PersistentConfig] _state: dict[str, PersistentConfig]
_redis: Optional[redis.Redis] = None _redis: Optional[redis.Redis] = None
_redis_key_prefix: str
def __init__( def __init__(
self, redis_url: Optional[str] = None, redis_sentinels: Optional[list] = [] self,
redis_url: Optional[str] = None,
redis_sentinels: Optional[list] = [],
redis_key_prefix: str = "open-webui",
): ):
super().__setattr__("_state", {}) super().__setattr__("_state", {})
super().__setattr__("_redis_key_prefix", redis_key_prefix)
if redis_url: if redis_url:
super().__setattr__( super().__setattr__(
"_redis", "_redis",
@ -230,7 +236,7 @@ class AppConfig:
self._state[key].save() self._state[key].save()
if self._redis: if self._redis:
redis_key = f"open-webui:config:{key}" redis_key = f"{self._redis_key_prefix}:config:{key}"
self._redis.set(redis_key, json.dumps(self._state[key].value)) self._redis.set(redis_key, json.dumps(self._state[key].value))
def __getattr__(self, key): def __getattr__(self, key):
@ -239,7 +245,7 @@ class AppConfig:
# If Redis is available, check for an updated value # If Redis is available, check for an updated value
if self._redis: if self._redis:
redis_key = f"open-webui:config:{key}" redis_key = f"{self._redis_key_prefix}:config:{key}"
redis_value = self._redis.get(redis_key) redis_value = self._redis.get(redis_key)
if redis_value is not None: if redis_value is not None:
@ -431,6 +437,12 @@ OAUTH_SCOPES = PersistentConfig(
os.environ.get("OAUTH_SCOPES", "openid email profile"), os.environ.get("OAUTH_SCOPES", "openid email profile"),
) )
OAUTH_TIMEOUT = PersistentConfig(
"OAUTH_TIMEOUT",
"oauth.oidc.oauth_timeout",
os.environ.get("OAUTH_TIMEOUT", ""),
)
OAUTH_CODE_CHALLENGE_METHOD = PersistentConfig( OAUTH_CODE_CHALLENGE_METHOD = PersistentConfig(
"OAUTH_CODE_CHALLENGE_METHOD", "OAUTH_CODE_CHALLENGE_METHOD",
"oauth.oidc.code_challenge_method", "oauth.oidc.code_challenge_method",
@ -540,7 +552,14 @@ def load_oauth_providers():
client_id=GOOGLE_CLIENT_ID.value, client_id=GOOGLE_CLIENT_ID.value,
client_secret=GOOGLE_CLIENT_SECRET.value, client_secret=GOOGLE_CLIENT_SECRET.value,
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration", server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
client_kwargs={"scope": GOOGLE_OAUTH_SCOPE.value}, client_kwargs={
"scope": GOOGLE_OAUTH_SCOPE.value,
**(
{"timeout": int(OAUTH_TIMEOUT.value)}
if OAUTH_TIMEOUT.value
else {}
),
},
redirect_uri=GOOGLE_REDIRECT_URI.value, redirect_uri=GOOGLE_REDIRECT_URI.value,
) )
@ -563,6 +582,11 @@ def load_oauth_providers():
server_metadata_url=f"{MICROSOFT_CLIENT_LOGIN_BASE_URL.value}/{MICROSOFT_CLIENT_TENANT_ID.value}/v2.0/.well-known/openid-configuration?appid={MICROSOFT_CLIENT_ID.value}", server_metadata_url=f"{MICROSOFT_CLIENT_LOGIN_BASE_URL.value}/{MICROSOFT_CLIENT_TENANT_ID.value}/v2.0/.well-known/openid-configuration?appid={MICROSOFT_CLIENT_ID.value}",
client_kwargs={ client_kwargs={
"scope": MICROSOFT_OAUTH_SCOPE.value, "scope": MICROSOFT_OAUTH_SCOPE.value,
**(
{"timeout": int(OAUTH_TIMEOUT.value)}
if OAUTH_TIMEOUT.value
else {}
),
}, },
redirect_uri=MICROSOFT_REDIRECT_URI.value, redirect_uri=MICROSOFT_REDIRECT_URI.value,
) )
@ -584,7 +608,14 @@ def load_oauth_providers():
authorize_url="https://github.com/login/oauth/authorize", authorize_url="https://github.com/login/oauth/authorize",
api_base_url="https://api.github.com", api_base_url="https://api.github.com",
userinfo_endpoint="https://api.github.com/user", userinfo_endpoint="https://api.github.com/user",
client_kwargs={"scope": GITHUB_CLIENT_SCOPE.value}, client_kwargs={
"scope": GITHUB_CLIENT_SCOPE.value,
**(
{"timeout": int(OAUTH_TIMEOUT.value)}
if OAUTH_TIMEOUT.value
else {}
),
},
redirect_uri=GITHUB_CLIENT_REDIRECT_URI.value, redirect_uri=GITHUB_CLIENT_REDIRECT_URI.value,
) )
@ -603,6 +634,9 @@ def load_oauth_providers():
def oidc_oauth_register(client): def oidc_oauth_register(client):
client_kwargs = { client_kwargs = {
"scope": OAUTH_SCOPES.value, "scope": OAUTH_SCOPES.value,
**(
{"timeout": int(OAUTH_TIMEOUT.value)} if OAUTH_TIMEOUT.value else {}
),
} }
if ( if (
@ -895,6 +929,18 @@ except Exception:
pass pass
OPENAI_API_BASE_URL = "https://api.openai.com/v1" OPENAI_API_BASE_URL = "https://api.openai.com/v1"
####################################
# MODELS
####################################
ENABLE_BASE_MODELS_CACHE = PersistentConfig(
"ENABLE_BASE_MODELS_CACHE",
"models.base_models_cache",
os.environ.get("ENABLE_BASE_MODELS_CACHE", "False").lower() == "true",
)
#################################### ####################################
# TOOL_SERVERS # TOOL_SERVERS
#################################### ####################################
@ -1799,6 +1845,7 @@ QDRANT_GRPC_PORT = int(os.environ.get("QDRANT_GRPC_PORT", "6334"))
ENABLE_QDRANT_MULTITENANCY_MODE = ( ENABLE_QDRANT_MULTITENANCY_MODE = (
os.environ.get("ENABLE_QDRANT_MULTITENANCY_MODE", "true").lower() == "true" os.environ.get("ENABLE_QDRANT_MULTITENANCY_MODE", "true").lower() == "true"
) )
QDRANT_COLLECTION_PREFIX = os.environ.get("QDRANT_COLLECTION_PREFIX", "open-webui")
# OpenSearch # OpenSearch
OPENSEARCH_URI = os.environ.get("OPENSEARCH_URI", "https://localhost:9200") OPENSEARCH_URI = os.environ.get("OPENSEARCH_URI", "https://localhost:9200")

View file

@ -267,6 +267,30 @@ else:
DATABASE_URL = os.environ.get("DATABASE_URL", f"sqlite:///{DATA_DIR}/webui.db") DATABASE_URL = os.environ.get("DATABASE_URL", f"sqlite:///{DATA_DIR}/webui.db")
DATABASE_TYPE = os.environ.get("DATABASE_TYPE")
DATABASE_USER = os.environ.get("DATABASE_USER")
DATABASE_PASSWORD = os.environ.get("DATABASE_PASSWORD")
DATABASE_CRED = ""
if DATABASE_USER:
DATABASE_CRED += f"{DATABASE_USER}"
if DATABASE_PASSWORD:
DATABASE_CRED += f":{DATABASE_PASSWORD}"
if DATABASE_CRED:
DATABASE_CRED += "@"
DB_VARS = {
"db_type": DATABASE_TYPE,
"db_cred": DATABASE_CRED,
"db_host": os.environ.get("DATABASE_HOST"),
"db_port": os.environ.get("DATABASE_PORT"),
"db_name": os.environ.get("DATABASE_NAME"),
}
if all(DB_VARS.values()):
DATABASE_URL = f"{DB_VARS['db_type']}://{DB_VARS['db_cred']}@{DB_VARS['db_host']}:{DB_VARS['db_port']}/{DB_VARS['db_name']}"
# Replace the postgres:// with postgresql:// # Replace the postgres:// with postgresql://
if "postgres://" in DATABASE_URL: if "postgres://" in DATABASE_URL:
DATABASE_URL = DATABASE_URL.replace("postgres://", "postgresql://") DATABASE_URL = DATABASE_URL.replace("postgres://", "postgresql://")
@ -324,6 +348,7 @@ ENABLE_REALTIME_CHAT_SAVE = (
#################################### ####################################
REDIS_URL = os.environ.get("REDIS_URL", "") REDIS_URL = os.environ.get("REDIS_URL", "")
REDIS_KEY_PREFIX = os.environ.get("REDIS_KEY_PREFIX", "open-webui")
REDIS_SENTINEL_HOSTS = os.environ.get("REDIS_SENTINEL_HOSTS", "") REDIS_SENTINEL_HOSTS = os.environ.get("REDIS_SENTINEL_HOSTS", "")
REDIS_SENTINEL_PORT = os.environ.get("REDIS_SENTINEL_PORT", "26379") REDIS_SENTINEL_PORT = os.environ.get("REDIS_SENTINEL_PORT", "26379")
@ -399,10 +424,29 @@ ENABLE_COMPRESSION_MIDDLEWARE = (
os.environ.get("ENABLE_COMPRESSION_MIDDLEWARE", "True").lower() == "true" os.environ.get("ENABLE_COMPRESSION_MIDDLEWARE", "True").lower() == "true"
) )
####################################
# MODELS
####################################
MODELS_CACHE_TTL = os.environ.get("MODELS_CACHE_TTL", "1")
if MODELS_CACHE_TTL == "":
MODELS_CACHE_TTL = None
else:
try:
MODELS_CACHE_TTL = int(MODELS_CACHE_TTL)
except Exception:
MODELS_CACHE_TTL = 1
####################################
# WEBSOCKET SUPPORT
####################################
ENABLE_WEBSOCKET_SUPPORT = ( ENABLE_WEBSOCKET_SUPPORT = (
os.environ.get("ENABLE_WEBSOCKET_SUPPORT", "True").lower() == "true" os.environ.get("ENABLE_WEBSOCKET_SUPPORT", "True").lower() == "true"
) )
WEBSOCKET_MANAGER = os.environ.get("WEBSOCKET_MANAGER", "") WEBSOCKET_MANAGER = os.environ.get("WEBSOCKET_MANAGER", "")
WEBSOCKET_REDIS_URL = os.environ.get("WEBSOCKET_REDIS_URL", REDIS_URL) WEBSOCKET_REDIS_URL = os.environ.get("WEBSOCKET_REDIS_URL", REDIS_URL)
@ -509,11 +553,14 @@ else:
# OFFLINE_MODE # OFFLINE_MODE
#################################### ####################################
ENABLE_VERSION_UPDATE_CHECK = (
os.environ.get("ENABLE_VERSION_UPDATE_CHECK", "true").lower() == "true"
)
OFFLINE_MODE = os.environ.get("OFFLINE_MODE", "false").lower() == "true" OFFLINE_MODE = os.environ.get("OFFLINE_MODE", "false").lower() == "true"
if OFFLINE_MODE: if OFFLINE_MODE:
os.environ["HF_HUB_OFFLINE"] = "1" os.environ["HF_HUB_OFFLINE"] = "1"
ENABLE_VERSION_UPDATE_CHECK = False
#################################### ####################################
# AUDIT LOGGING # AUDIT LOGGING
@ -522,6 +569,14 @@ if OFFLINE_MODE:
AUDIT_LOGS_FILE_PATH = f"{DATA_DIR}/audit.log" AUDIT_LOGS_FILE_PATH = f"{DATA_DIR}/audit.log"
# Maximum size of a file before rotating into a new log file # Maximum size of a file before rotating into a new log file
AUDIT_LOG_FILE_ROTATION_SIZE = os.getenv("AUDIT_LOG_FILE_ROTATION_SIZE", "10MB") AUDIT_LOG_FILE_ROTATION_SIZE = os.getenv("AUDIT_LOG_FILE_ROTATION_SIZE", "10MB")
# Comma separated list of logger names to use for audit logging
# Default is "uvicorn.access" which is the access log for Uvicorn
# You can add more logger names to this list if you want to capture more logs
AUDIT_UVICORN_LOGGER_NAMES = os.getenv(
"AUDIT_UVICORN_LOGGER_NAMES", "uvicorn.access"
).split(",")
# METADATA | REQUEST | REQUEST_RESPONSE # METADATA | REQUEST | REQUEST_RESPONSE
AUDIT_LOG_LEVEL = os.getenv("AUDIT_LOG_LEVEL", "NONE").upper() AUDIT_LOG_LEVEL = os.getenv("AUDIT_LOG_LEVEL", "NONE").upper()
try: try:
@ -559,6 +614,12 @@ OTEL_TRACES_SAMPLER = os.environ.get(
OTEL_BASIC_AUTH_USERNAME = os.environ.get("OTEL_BASIC_AUTH_USERNAME", "") OTEL_BASIC_AUTH_USERNAME = os.environ.get("OTEL_BASIC_AUTH_USERNAME", "")
OTEL_BASIC_AUTH_PASSWORD = os.environ.get("OTEL_BASIC_AUTH_PASSWORD", "") OTEL_BASIC_AUTH_PASSWORD = os.environ.get("OTEL_BASIC_AUTH_PASSWORD", "")
OTEL_OTLP_SPAN_EXPORTER = os.environ.get(
"OTEL_OTLP_SPAN_EXPORTER", "grpc"
).lower() # grpc or http
#################################### ####################################
# TOOLS/FUNCTIONS PIP OPTIONS # TOOLS/FUNCTIONS PIP OPTIONS
#################################### ####################################

View file

@ -36,7 +36,6 @@ from fastapi import (
applications, applications,
BackgroundTasks, BackgroundTasks,
) )
from fastapi.openapi.docs import get_swagger_ui_html from fastapi.openapi.docs import get_swagger_ui_html
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
@ -49,6 +48,7 @@ from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
from starlette.responses import Response, StreamingResponse from starlette.responses import Response, StreamingResponse
from starlette.datastructures import Headers
from open_webui.utils import logger from open_webui.utils import logger
@ -116,6 +116,8 @@ from open_webui.config import (
OPENAI_API_CONFIGS, OPENAI_API_CONFIGS,
# Direct Connections # Direct Connections
ENABLE_DIRECT_CONNECTIONS, ENABLE_DIRECT_CONNECTIONS,
# Model list
ENABLE_BASE_MODELS_CACHE,
# Thread pool size for FastAPI/AnyIO # Thread pool size for FastAPI/AnyIO
THREAD_POOL_SIZE, THREAD_POOL_SIZE,
# Tool Server Configs # Tool Server Configs
@ -396,6 +398,7 @@ from open_webui.env import (
AUDIT_LOG_LEVEL, AUDIT_LOG_LEVEL,
CHANGELOG, CHANGELOG,
REDIS_URL, REDIS_URL,
REDIS_KEY_PREFIX,
REDIS_SENTINEL_HOSTS, REDIS_SENTINEL_HOSTS,
REDIS_SENTINEL_PORT, REDIS_SENTINEL_PORT,
GLOBAL_LOG_LEVEL, GLOBAL_LOG_LEVEL,
@ -415,7 +418,7 @@ from open_webui.env import (
ENABLE_WEBSOCKET_SUPPORT, ENABLE_WEBSOCKET_SUPPORT,
BYPASS_MODEL_ACCESS_CONTROL, BYPASS_MODEL_ACCESS_CONTROL,
RESET_CONFIG_ON_START, RESET_CONFIG_ON_START,
OFFLINE_MODE, ENABLE_VERSION_UPDATE_CHECK,
ENABLE_OTEL, ENABLE_OTEL,
EXTERNAL_PWA_MANIFEST_URL, EXTERNAL_PWA_MANIFEST_URL,
AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_SESSION_SSL,
@ -534,6 +537,27 @@ async def lifespan(app: FastAPI):
asyncio.create_task(periodic_usage_pool_cleanup()) asyncio.create_task(periodic_usage_pool_cleanup())
if app.state.config.ENABLE_BASE_MODELS_CACHE:
await get_all_models(
Request(
# Creating a mock request object to pass to get_all_models
{
"type": "http",
"asgi.version": "3.0",
"asgi.spec_version": "2.0",
"method": "GET",
"path": "/internal",
"query_string": b"",
"headers": Headers({}).raw,
"client": ("127.0.0.1", 12345),
"server": ("127.0.0.1", 80),
"scheme": "http",
"app": app,
}
),
None,
)
yield yield
if hasattr(app.state, "redis_task_command_listener"): if hasattr(app.state, "redis_task_command_listener"):
@ -554,6 +578,7 @@ app.state.instance_id = None
app.state.config = AppConfig( app.state.config = AppConfig(
redis_url=REDIS_URL, redis_url=REDIS_URL,
redis_sentinels=get_sentinels_from_env(REDIS_SENTINEL_HOSTS, REDIS_SENTINEL_PORT), redis_sentinels=get_sentinels_from_env(REDIS_SENTINEL_HOSTS, REDIS_SENTINEL_PORT),
redis_key_prefix=REDIS_KEY_PREFIX,
) )
app.state.redis = None app.state.redis = None
@ -616,6 +641,15 @@ app.state.TOOL_SERVERS = []
app.state.config.ENABLE_DIRECT_CONNECTIONS = ENABLE_DIRECT_CONNECTIONS app.state.config.ENABLE_DIRECT_CONNECTIONS = ENABLE_DIRECT_CONNECTIONS
########################################
#
# MODELS
#
########################################
app.state.config.ENABLE_BASE_MODELS_CACHE = ENABLE_BASE_MODELS_CACHE
app.state.BASE_MODELS = []
######################################## ########################################
# #
# WEBUI # WEBUI
@ -1191,7 +1225,9 @@ if audit_level != AuditLevel.NONE:
@app.get("/api/models") @app.get("/api/models")
async def get_models(request: Request, user=Depends(get_verified_user)): async def get_models(
request: Request, refresh: bool = False, user=Depends(get_verified_user)
):
def get_filtered_models(models, user): def get_filtered_models(models, user):
filtered_models = [] filtered_models = []
for model in models: for model in models:
@ -1215,7 +1251,7 @@ async def get_models(request: Request, user=Depends(get_verified_user)):
return filtered_models return filtered_models
all_models = await get_all_models(request, user=user) all_models = await get_all_models(request, refresh=refresh, user=user)
models = [] models = []
for model in all_models: for model in all_models:
@ -1471,7 +1507,7 @@ async def list_tasks_by_chat_id_endpoint(
task_ids = await list_task_ids_by_chat_id(request, chat_id) task_ids = await list_task_ids_by_chat_id(request, chat_id)
print(f"Task IDs for chat {chat_id}: {task_ids}") log.debug(f"Task IDs for chat {chat_id}: {task_ids}")
return {"task_ids": task_ids} return {"task_ids": task_ids}
@ -1524,6 +1560,7 @@ async def get_app_config(request: Request):
"enable_signup": app.state.config.ENABLE_SIGNUP, "enable_signup": app.state.config.ENABLE_SIGNUP,
"enable_login_form": app.state.config.ENABLE_LOGIN_FORM, "enable_login_form": app.state.config.ENABLE_LOGIN_FORM,
"enable_websocket": ENABLE_WEBSOCKET_SUPPORT, "enable_websocket": ENABLE_WEBSOCKET_SUPPORT,
"enable_version_update_check": ENABLE_VERSION_UPDATE_CHECK,
**( **(
{ {
"enable_direct_connections": app.state.config.ENABLE_DIRECT_CONNECTIONS, "enable_direct_connections": app.state.config.ENABLE_DIRECT_CONNECTIONS,
@ -1629,9 +1666,9 @@ async def get_app_version():
@app.get("/api/version/updates") @app.get("/api/version/updates")
async def get_app_latest_release_version(user=Depends(get_verified_user)): async def get_app_latest_release_version(user=Depends(get_verified_user)):
if OFFLINE_MODE: if not ENABLE_VERSION_UPDATE_CHECK:
log.debug( log.debug(
f"Offline mode is enabled, returning current version as latest version" f"Version update check is disabled, returning current version as latest version"
) )
return {"current": VERSION, "latest": VERSION} return {"current": VERSION, "latest": VERSION}
try: try:

View file

@ -12,6 +12,7 @@ from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON
from sqlalchemy import or_, func, select, and_, text from sqlalchemy import or_, func, select, and_, text
from sqlalchemy.sql import exists from sqlalchemy.sql import exists
from sqlalchemy.sql.expression import bindparam
#################### ####################
# Chat DB Schema # Chat DB Schema
@ -72,6 +73,8 @@ class ChatImportForm(ChatForm):
meta: Optional[dict] = {} meta: Optional[dict] = {}
pinned: Optional[bool] = False pinned: Optional[bool] = False
folder_id: Optional[str] = None folder_id: Optional[str] = None
created_at: Optional[int] = None
updated_at: Optional[int] = None
class ChatTitleMessagesForm(BaseModel): class ChatTitleMessagesForm(BaseModel):
@ -147,8 +150,16 @@ class ChatTable:
"meta": form_data.meta, "meta": form_data.meta,
"pinned": form_data.pinned, "pinned": form_data.pinned,
"folder_id": form_data.folder_id, "folder_id": form_data.folder_id,
"created_at": int(time.time()), "created_at": (
"updated_at": int(time.time()), form_data.created_at
if form_data.created_at
else int(time.time())
),
"updated_at": (
form_data.updated_at
if form_data.updated_at
else int(time.time())
),
} }
) )
@ -232,6 +243,10 @@ class ChatTable:
if chat is None: if chat is None:
return None return None
# Sanitize message content for null characters before upserting
if isinstance(message.get("content"), str):
message["content"] = message["content"].replace("\x00", "")
chat = chat.chat chat = chat.chat
history = chat.get("history", {}) history = chat.get("history", {})
@ -580,7 +595,7 @@ class ChatTable:
""" """
Filters chats based on a search query using Python, allowing pagination using skip and limit. Filters chats based on a search query using Python, allowing pagination using skip and limit.
""" """
search_text = search_text.lower().strip() search_text = search_text.replace("\u0000", "").lower().strip()
if not search_text: if not search_text:
return self.get_chat_list_by_user_id( return self.get_chat_list_by_user_id(
@ -614,21 +629,19 @@ class ChatTable:
dialect_name = db.bind.dialect.name dialect_name = db.bind.dialect.name
if dialect_name == "sqlite": if dialect_name == "sqlite":
# SQLite case: using JSON1 extension for JSON searching # SQLite case: using JSON1 extension for JSON searching
sqlite_content_sql = (
"EXISTS ("
" SELECT 1 "
" FROM json_each(Chat.chat, '$.messages') AS message "
" WHERE LOWER(message.value->>'content') LIKE '%' || :content_key || '%'"
")"
)
sqlite_content_clause = text(sqlite_content_sql)
query = query.filter( query = query.filter(
( or_(
Chat.title.ilike( Chat.title.ilike(bindparam('title_key')),
f"%{search_text}%" sqlite_content_clause
) # Case-insensitive search in title ).params(title_key=f"%{search_text}%", content_key=search_text)
| text(
"""
EXISTS (
SELECT 1
FROM json_each(Chat.chat, '$.messages') AS message
WHERE LOWER(message.value->>'content') LIKE '%' || :search_text || '%'
)
"""
)
).params(search_text=search_text)
) )
# Check if there are any tags to filter, it should have all the tags # Check if there are any tags to filter, it should have all the tags
@ -663,21 +676,19 @@ class ChatTable:
elif dialect_name == "postgresql": elif dialect_name == "postgresql":
# PostgreSQL relies on proper JSON query for search # PostgreSQL relies on proper JSON query for search
postgres_content_sql = (
"EXISTS ("
" SELECT 1 "
" FROM json_array_elements(Chat.chat->'messages') AS message "
" WHERE LOWER(message->>'content') LIKE '%' || :content_key || '%'"
")"
)
postgres_content_clause = text(postgres_content_sql)
query = query.filter( query = query.filter(
( or_(
Chat.title.ilike( Chat.title.ilike(bindparam('title_key')),
f"%{search_text}%" postgres_content_clause
) # Case-insensitive search in title ).params(title_key=f"%{search_text}%", content_key=search_text)
| text(
"""
EXISTS (
SELECT 1
FROM json_array_elements(Chat.chat->'messages') AS message
WHERE LOWER(message->>'content') LIKE '%' || :search_text || '%'
)
"""
)
).params(search_text=search_text)
) )
# Check if there are any tags to filter, it should have all the tags # Check if there are any tags to filter, it should have all the tags

View file

@ -507,6 +507,7 @@ class MistralLoader:
timeout=timeout, timeout=timeout,
headers={"User-Agent": "OpenWebUI-MistralLoader/2.0"}, headers={"User-Agent": "OpenWebUI-MistralLoader/2.0"},
raise_for_status=False, # We handle status codes manually raise_for_status=False, # We handle status codes manually
trust_env=True,
) as session: ) as session:
yield session yield session

View file

@ -460,20 +460,19 @@ def get_sources_from_files(
) )
extracted_collections = [] extracted_collections = []
relevant_contexts = [] query_results = []
for file in files: for file in files:
query_result = None
context = None
if file.get("docs"): if file.get("docs"):
# BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL # BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL
context = { query_result = {
"documents": [[doc.get("content") for doc in file.get("docs")]], "documents": [[doc.get("content") for doc in file.get("docs")]],
"metadatas": [[doc.get("metadata") for doc in file.get("docs")]], "metadatas": [[doc.get("metadata") for doc in file.get("docs")]],
} }
elif file.get("context") == "full": elif file.get("context") == "full":
# Manual Full Mode Toggle # Manual Full Mode Toggle
context = { query_result = {
"documents": [[file.get("file").get("data", {}).get("content")]], "documents": [[file.get("file").get("data", {}).get("content")]],
"metadatas": [[{"file_id": file.get("id"), "name": file.get("name")}]], "metadatas": [[{"file_id": file.get("id"), "name": file.get("name")}]],
} }
@ -500,7 +499,7 @@ def get_sources_from_files(
} }
) )
context = { query_result = {
"documents": [documents], "documents": [documents],
"metadatas": [metadatas], "metadatas": [metadatas],
} }
@ -508,7 +507,7 @@ def get_sources_from_files(
elif file.get("id"): elif file.get("id"):
file_object = Files.get_file_by_id(file.get("id")) file_object = Files.get_file_by_id(file.get("id"))
if file_object: if file_object:
context = { query_result = {
"documents": [[file_object.data.get("content", "")]], "documents": [[file_object.data.get("content", "")]],
"metadatas": [ "metadatas": [
[ [
@ -521,7 +520,7 @@ def get_sources_from_files(
], ],
} }
elif file.get("file").get("data"): elif file.get("file").get("data"):
context = { query_result = {
"documents": [[file.get("file").get("data", {}).get("content")]], "documents": [[file.get("file").get("data", {}).get("content")]],
"metadatas": [ "metadatas": [
[file.get("file").get("data", {}).get("metadata", {})] [file.get("file").get("data", {}).get("metadata", {})]
@ -549,19 +548,27 @@ def get_sources_from_files(
if full_context: if full_context:
try: try:
context = get_all_items_from_collections(collection_names) query_result = get_all_items_from_collections(collection_names)
except Exception as e: except Exception as e:
log.exception(e) log.exception(e)
else: else:
try: try:
context = None query_result = None
if file.get("type") == "text": if file.get("type") == "text":
context = file["content"] # Not sure when this is used, but it seems to be a fallback
query_result = {
"documents": [
[file.get("file").get("data", {}).get("content")]
],
"metadatas": [
[file.get("file").get("data", {}).get("meta", {})]
],
}
else: else:
if hybrid_search: if hybrid_search:
try: try:
context = query_collection_with_hybrid_search( query_result = query_collection_with_hybrid_search(
collection_names=collection_names, collection_names=collection_names,
queries=queries, queries=queries,
embedding_function=embedding_function, embedding_function=embedding_function,
@ -577,8 +584,8 @@ def get_sources_from_files(
" non hybrid search as fallback." " non hybrid search as fallback."
) )
if (not hybrid_search) or (context is None): if (not hybrid_search) or (query_result is None):
context = query_collection( query_result = query_collection(
collection_names=collection_names, collection_names=collection_names,
queries=queries, queries=queries,
embedding_function=embedding_function, embedding_function=embedding_function,
@ -589,24 +596,24 @@ def get_sources_from_files(
extracted_collections.extend(collection_names) extracted_collections.extend(collection_names)
if context: if query_result:
if "data" in file: if "data" in file:
del file["data"] del file["data"]
relevant_contexts.append({**context, "file": file}) query_results.append({**query_result, "file": file})
sources = [] sources = []
for context in relevant_contexts: for query_result in query_results:
try: try:
if "documents" in context: if "documents" in query_result:
if "metadatas" in context: if "metadatas" in query_result:
source = { source = {
"source": context["file"], "source": query_result["file"],
"document": context["documents"][0], "document": query_result["documents"][0],
"metadata": context["metadatas"][0], "metadata": query_result["metadatas"][0],
} }
if "distances" in context and context["distances"]: if "distances" in query_result and query_result["distances"]:
source["distances"] = context["distances"][0] source["distances"] = query_result["distances"][0]
sources.append(source) sources.append(source)
except Exception as e: except Exception as e:

View file

@ -157,10 +157,10 @@ class OpenSearchClient(VectorDBBase):
for field, value in filter.items(): for field, value in filter.items():
query_body["query"]["bool"]["filter"].append( query_body["query"]["bool"]["filter"].append(
{"match": {"metadata." + str(field): value}} {"term": {"metadata." + str(field) + ".keyword": value}}
) )
size = limit if limit else 10 size = limit if limit else 10000
try: try:
result = self.client.search( result = self.client.search(
@ -206,6 +206,7 @@ class OpenSearchClient(VectorDBBase):
for item in batch for item in batch
] ]
bulk(self.client, actions) bulk(self.client, actions)
self.client.indices.refresh(self._get_index_name(collection_name))
def upsert(self, collection_name: str, items: list[VectorItem]): def upsert(self, collection_name: str, items: list[VectorItem]):
self._create_index_if_not_exists( self._create_index_if_not_exists(
@ -228,6 +229,7 @@ class OpenSearchClient(VectorDBBase):
for item in batch for item in batch
] ]
bulk(self.client, actions) bulk(self.client, actions)
self.client.indices.refresh(self._get_index_name(collection_name))
def delete( def delete(
self, self,
@ -251,11 +253,12 @@ class OpenSearchClient(VectorDBBase):
} }
for field, value in filter.items(): for field, value in filter.items():
query_body["query"]["bool"]["filter"].append( query_body["query"]["bool"]["filter"].append(
{"match": {"metadata." + str(field): value}} {"term": {"metadata." + str(field) + ".keyword": value}}
) )
self.client.delete_by_query( self.client.delete_by_query(
index=self._get_index_name(collection_name), body=query_body index=self._get_index_name(collection_name), body=query_body
) )
self.client.indices.refresh(self._get_index_name(collection_name))
def reset(self): def reset(self):
indices = self.client.indices.get(index=f"{self.index_prefix}_*") indices = self.client.indices.get(index=f"{self.index_prefix}_*")

View file

@ -18,6 +18,7 @@ from open_webui.config import (
QDRANT_ON_DISK, QDRANT_ON_DISK,
QDRANT_GRPC_PORT, QDRANT_GRPC_PORT,
QDRANT_PREFER_GRPC, QDRANT_PREFER_GRPC,
QDRANT_COLLECTION_PREFIX,
) )
from open_webui.env import SRC_LOG_LEVELS from open_webui.env import SRC_LOG_LEVELS
@ -29,7 +30,7 @@ log.setLevel(SRC_LOG_LEVELS["RAG"])
class QdrantClient(VectorDBBase): class QdrantClient(VectorDBBase):
def __init__(self): def __init__(self):
self.collection_prefix = "open-webui" self.collection_prefix = QDRANT_COLLECTION_PREFIX
self.QDRANT_URI = QDRANT_URI self.QDRANT_URI = QDRANT_URI
self.QDRANT_API_KEY = QDRANT_API_KEY self.QDRANT_API_KEY = QDRANT_API_KEY
self.QDRANT_ON_DISK = QDRANT_ON_DISK self.QDRANT_ON_DISK = QDRANT_ON_DISK
@ -86,6 +87,25 @@ class QdrantClient(VectorDBBase):
), ),
) )
# Create payload indexes for efficient filtering
self.client.create_payload_index(
collection_name=collection_name_with_prefix,
field_name="metadata.hash",
field_schema=models.KeywordIndexParams(
type=models.KeywordIndexType.KEYWORD,
is_tenant=False,
on_disk=self.QDRANT_ON_DISK,
),
)
self.client.create_payload_index(
collection_name=collection_name_with_prefix,
field_name="metadata.file_id",
field_schema=models.KeywordIndexParams(
type=models.KeywordIndexType.KEYWORD,
is_tenant=False,
on_disk=self.QDRANT_ON_DISK,
),
)
log.info(f"collection {collection_name_with_prefix} successfully created!") log.info(f"collection {collection_name_with_prefix} successfully created!")
def _create_collection_if_not_exists(self, collection_name, dimension): def _create_collection_if_not_exists(self, collection_name, dimension):

View file

@ -9,6 +9,7 @@ from open_webui.config import (
QDRANT_ON_DISK, QDRANT_ON_DISK,
QDRANT_PREFER_GRPC, QDRANT_PREFER_GRPC,
QDRANT_URI, QDRANT_URI,
QDRANT_COLLECTION_PREFIX,
) )
from open_webui.env import SRC_LOG_LEVELS from open_webui.env import SRC_LOG_LEVELS
from open_webui.retrieval.vector.main import ( from open_webui.retrieval.vector.main import (
@ -31,7 +32,7 @@ log.setLevel(SRC_LOG_LEVELS["RAG"])
class QdrantClient(VectorDBBase): class QdrantClient(VectorDBBase):
def __init__(self): def __init__(self):
self.collection_prefix = "open-webui" self.collection_prefix = QDRANT_COLLECTION_PREFIX
self.QDRANT_URI = QDRANT_URI self.QDRANT_URI = QDRANT_URI
self.QDRANT_API_KEY = QDRANT_API_KEY self.QDRANT_API_KEY = QDRANT_API_KEY
self.QDRANT_ON_DISK = QDRANT_ON_DISK self.QDRANT_ON_DISK = QDRANT_ON_DISK

View file

@ -36,7 +36,9 @@ def search_brave(
return [ return [
SearchResult( SearchResult(
link=result["url"], title=result.get("title"), snippet=result.get("snippet") link=result["url"],
title=result.get("title"),
snippet=result.get("description"),
) )
for result in results[:count] for result in results[:count]
] ]

View file

@ -675,7 +675,7 @@ async def signout(request: Request, response: Response):
oauth_id_token = request.cookies.get("oauth_id_token") oauth_id_token = request.cookies.get("oauth_id_token")
if oauth_id_token: if oauth_id_token:
try: try:
async with ClientSession() as session: async with ClientSession(trust_env=True) as session:
async with session.get(OPENID_PROVIDER_URL.value) as resp: async with session.get(OPENID_PROVIDER_URL.value) as resp:
if resp.status == 200: if resp.status == 200:
openid_data = await resp.json() openid_data = await resp.json()
@ -687,7 +687,7 @@ async def signout(request: Request, response: Response):
status_code=200, status_code=200,
content={ content={
"status": True, "status": True,
"redirect_url": f"{logout_url}?id_token_hint={oauth_id_token}", "redirect_url": f"{logout_url}?id_token_hint={oauth_id_token}" + (f"&post_logout_redirect_uri={WEBUI_AUTH_SIGNOUT_REDIRECT_URL}" if WEBUI_AUTH_SIGNOUT_REDIRECT_URL else ""),
}, },
headers=response.headers, headers=response.headers,
) )

View file

@ -684,8 +684,10 @@ async def archive_chat_by_id(id: str, user=Depends(get_verified_user)):
@router.post("/{id}/share", response_model=Optional[ChatResponse]) @router.post("/{id}/share", response_model=Optional[ChatResponse])
async def share_chat_by_id(request: Request, id: str, user=Depends(get_verified_user)): async def share_chat_by_id(request: Request, id: str, user=Depends(get_verified_user)):
if not has_permission( if (user.role != "admin") and (
user.id, "chat.share", request.app.state.config.USER_PERMISSIONS not has_permission(
user.id, "chat.share", request.app.state.config.USER_PERMISSIONS
)
): ):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,

View file

@ -39,32 +39,39 @@ async def export_config(user=Depends(get_admin_user)):
############################ ############################
# Direct Connections Config # Connections Config
############################ ############################
class DirectConnectionsConfigForm(BaseModel): class ConnectionsConfigForm(BaseModel):
ENABLE_DIRECT_CONNECTIONS: bool ENABLE_DIRECT_CONNECTIONS: bool
ENABLE_BASE_MODELS_CACHE: bool
@router.get("/direct_connections", response_model=DirectConnectionsConfigForm) @router.get("/connections", response_model=ConnectionsConfigForm)
async def get_direct_connections_config(request: Request, user=Depends(get_admin_user)): async def get_connections_config(request: Request, user=Depends(get_admin_user)):
return { return {
"ENABLE_DIRECT_CONNECTIONS": request.app.state.config.ENABLE_DIRECT_CONNECTIONS, "ENABLE_DIRECT_CONNECTIONS": request.app.state.config.ENABLE_DIRECT_CONNECTIONS,
"ENABLE_BASE_MODELS_CACHE": request.app.state.config.ENABLE_BASE_MODELS_CACHE,
} }
@router.post("/direct_connections", response_model=DirectConnectionsConfigForm) @router.post("/connections", response_model=ConnectionsConfigForm)
async def set_direct_connections_config( async def set_connections_config(
request: Request, request: Request,
form_data: DirectConnectionsConfigForm, form_data: ConnectionsConfigForm,
user=Depends(get_admin_user), user=Depends(get_admin_user),
): ):
request.app.state.config.ENABLE_DIRECT_CONNECTIONS = ( request.app.state.config.ENABLE_DIRECT_CONNECTIONS = (
form_data.ENABLE_DIRECT_CONNECTIONS form_data.ENABLE_DIRECT_CONNECTIONS
) )
request.app.state.config.ENABLE_BASE_MODELS_CACHE = (
form_data.ENABLE_BASE_MODELS_CACHE
)
return { return {
"ENABLE_DIRECT_CONNECTIONS": request.app.state.config.ENABLE_DIRECT_CONNECTIONS, "ENABLE_DIRECT_CONNECTIONS": request.app.state.config.ENABLE_DIRECT_CONNECTIONS,
"ENABLE_BASE_MODELS_CACHE": request.app.state.config.ENABLE_BASE_MODELS_CACHE,
} }

View file

@ -105,7 +105,7 @@ async def load_function_from_url(
) )
try: try:
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession(trust_env=True) as session:
async with session.get( async with session.get(
url, headers={"Content-Type": "application/json"} url, headers={"Content-Type": "application/json"}
) as resp: ) as resp:

View file

@ -303,10 +303,12 @@ async def update_image_config(
): ):
set_image_model(request, form_data.MODEL) set_image_model(request, form_data.MODEL)
if (form_data.IMAGE_SIZE == "auto" and form_data.MODEL != 'gpt-image-1'): if form_data.IMAGE_SIZE == "auto" and form_data.MODEL != "gpt-image-1":
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail=ERROR_MESSAGES.INCORRECT_FORMAT(" (auto is only allowed with gpt-image-1).") detail=ERROR_MESSAGES.INCORRECT_FORMAT(
" (auto is only allowed with gpt-image-1)."
),
) )
pattern = r"^\d+x\d+$" pattern = r"^\d+x\d+$"
@ -483,7 +485,7 @@ async def image_generations(
# image model other than gpt-image-1, which is warned about on settings save # image model other than gpt-image-1, which is warned about on settings save
width, height = ( width, height = (
tuple(map(int, request.app.state.config.IMAGE_SIZE.split("x"))) tuple(map(int, request.app.state.config.IMAGE_SIZE.split("x")))
if 'x' in request.app.state.config.IMAGE_SIZE if "x" in request.app.state.config.IMAGE_SIZE
else (512, 512) else (512, 512)
) )

View file

@ -59,6 +59,7 @@ from open_webui.config import (
from open_webui.env import ( from open_webui.env import (
ENV, ENV,
SRC_LOG_LEVELS, SRC_LOG_LEVELS,
MODELS_CACHE_TTL,
AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_SESSION_SSL,
AIOHTTP_CLIENT_TIMEOUT, AIOHTTP_CLIENT_TIMEOUT,
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST, AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
@ -330,7 +331,7 @@ def merge_ollama_models_lists(model_lists):
return list(merged_models.values()) return list(merged_models.values())
@cached(ttl=1) @cached(ttl=MODELS_CACHE_TTL)
async def get_all_models(request: Request, user: UserModel = None): async def get_all_models(request: Request, user: UserModel = None):
log.info("get_all_models()") log.info("get_all_models()")
if request.app.state.config.ENABLE_OLLAMA_API: if request.app.state.config.ENABLE_OLLAMA_API:

View file

@ -21,6 +21,7 @@ from open_webui.config import (
CACHE_DIR, CACHE_DIR,
) )
from open_webui.env import ( from open_webui.env import (
MODELS_CACHE_TTL,
AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_SESSION_SSL,
AIOHTTP_CLIENT_TIMEOUT, AIOHTTP_CLIENT_TIMEOUT,
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST, AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
@ -386,7 +387,7 @@ async def get_filtered_models(models, user):
return filtered_models return filtered_models
@cached(ttl=1) @cached(ttl=MODELS_CACHE_TTL)
async def get_all_models(request: Request, user: UserModel) -> dict[str, list]: async def get_all_models(request: Request, user: UserModel) -> dict[str, list]:
log.info("get_all_models()") log.info("get_all_models()")

View file

@ -1794,6 +1794,13 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]:
request.app.state.config.WEB_SEARCH_RESULT_COUNT, request.app.state.config.WEB_SEARCH_RESULT_COUNT,
request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
) )
elif engine == "exa":
return search_exa(
request.app.state.config.EXA_API_KEY,
query,
request.app.state.config.WEB_SEARCH_RESULT_COUNT,
request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
)
elif engine == "perplexity": elif engine == "perplexity":
return search_perplexity( return search_perplexity(
request.app.state.config.PERPLEXITY_API_KEY, request.app.state.config.PERPLEXITY_API_KEY,

View file

@ -153,7 +153,7 @@ async def load_tool_from_url(
) )
try: try:
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession(trust_env=True) as session:
async with session.get( async with session.get(
url, headers={"Content-Type": "application/json"} url, headers={"Content-Type": "application/json"}
) as resp: ) as resp:

View file

@ -1,4 +1,6 @@
import asyncio import asyncio
import random
import socketio import socketio
import logging import logging
import sys import sys
@ -105,10 +107,26 @@ else:
async def periodic_usage_pool_cleanup(): async def periodic_usage_pool_cleanup():
if not aquire_func(): max_retries = 2
log.debug("Usage pool cleanup lock already exists. Not running it.") retry_delay = random.uniform(
return WEBSOCKET_REDIS_LOCK_TIMEOUT / 2, WEBSOCKET_REDIS_LOCK_TIMEOUT
log.debug("Running periodic_usage_pool_cleanup") )
for attempt in range(max_retries + 1):
if aquire_func():
break
else:
if attempt < max_retries:
log.debug(
f"Cleanup lock already exists. Retry {attempt + 1} after {retry_delay}s..."
)
await asyncio.sleep(retry_delay)
else:
log.warning(
"Failed to acquire cleanup lock after retries. Skipping cleanup."
)
return
log.debug("Running periodic_cleanup")
try: try:
while True: while True:
if not renew_func(): if not renew_func():

View file

@ -3,10 +3,17 @@ import asyncio
from typing import Dict from typing import Dict
from uuid import uuid4 from uuid import uuid4
import json import json
import logging
from redis.asyncio import Redis from redis.asyncio import Redis
from fastapi import Request from fastapi import Request
from typing import Dict, List, Optional from typing import Dict, List, Optional
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MAIN"])
# A dictionary to keep track of active tasks # A dictionary to keep track of active tasks
tasks: Dict[str, asyncio.Task] = {} tasks: Dict[str, asyncio.Task] = {}
chat_tasks = {} chat_tasks = {}
@ -38,7 +45,7 @@ async def redis_task_command_listener(app):
if local_task: if local_task:
local_task.cancel() local_task.cancel()
except Exception as e: except Exception as e:
print(f"Error handling distributed task command: {e}") log.exception(f"Error handling distributed task command: {e}")
### ------------------------------ ### ------------------------------

View file

@ -5,7 +5,9 @@ from typing import TYPE_CHECKING
from loguru import logger from loguru import logger
from open_webui.env import ( from open_webui.env import (
AUDIT_UVICORN_LOGGER_NAMES,
AUDIT_LOG_FILE_ROTATION_SIZE, AUDIT_LOG_FILE_ROTATION_SIZE,
AUDIT_LOG_LEVEL, AUDIT_LOG_LEVEL,
AUDIT_LOGS_FILE_PATH, AUDIT_LOGS_FILE_PATH,
@ -128,11 +130,13 @@ def start_logger():
logging.basicConfig( logging.basicConfig(
handlers=[InterceptHandler()], level=GLOBAL_LOG_LEVEL, force=True handlers=[InterceptHandler()], level=GLOBAL_LOG_LEVEL, force=True
) )
for uvicorn_logger_name in ["uvicorn", "uvicorn.error"]: for uvicorn_logger_name in ["uvicorn", "uvicorn.error"]:
uvicorn_logger = logging.getLogger(uvicorn_logger_name) uvicorn_logger = logging.getLogger(uvicorn_logger_name)
uvicorn_logger.setLevel(GLOBAL_LOG_LEVEL) uvicorn_logger.setLevel(GLOBAL_LOG_LEVEL)
uvicorn_logger.handlers = [] uvicorn_logger.handlers = []
for uvicorn_logger_name in ["uvicorn.access"]:
for uvicorn_logger_name in AUDIT_UVICORN_LOGGER_NAMES:
uvicorn_logger = logging.getLogger(uvicorn_logger_name) uvicorn_logger = logging.getLogger(uvicorn_logger_name)
uvicorn_logger.setLevel(GLOBAL_LOG_LEVEL) uvicorn_logger.setLevel(GLOBAL_LOG_LEVEL)
uvicorn_logger.handlers = [InterceptHandler()] uvicorn_logger.handlers = [InterceptHandler()]

View file

@ -248,6 +248,7 @@ async def chat_completion_tools_handler(
if tool_id if tool_id
else f"{tool_function_name}" else f"{tool_function_name}"
) )
if tool.get("metadata", {}).get("citation", False) or tool.get( if tool.get("metadata", {}).get("citation", False) or tool.get(
"direct", False "direct", False
): ):
@ -718,6 +719,10 @@ def apply_params_to_form_data(form_data, model):
async def process_chat_payload(request, form_data, user, metadata, model): async def process_chat_payload(request, form_data, user, metadata, model):
# Pipeline Inlet -> Filter Inlet -> Chat Memory -> Chat Web Search -> Chat Image Generation
# -> Chat Code Interpreter (Form Data Update) -> (Default) Chat Tools Function Calling
# -> Chat Files
form_data = apply_params_to_form_data(form_data, model) form_data = apply_params_to_form_data(form_data, model)
log.debug(f"form_data: {form_data}") log.debug(f"form_data: {form_data}")
@ -911,7 +916,6 @@ async def process_chat_payload(request, form_data, user, metadata, model):
request, form_data, extra_params, user, models, tools_dict request, form_data, extra_params, user, models, tools_dict
) )
sources.extend(flags.get("sources", [])) sources.extend(flags.get("sources", []))
except Exception as e: except Exception as e:
log.exception(e) log.exception(e)
@ -924,24 +928,27 @@ async def process_chat_payload(request, form_data, user, metadata, model):
# If context is not empty, insert it into the messages # If context is not empty, insert it into the messages
if len(sources) > 0: if len(sources) > 0:
context_string = "" context_string = ""
citation_idx = {} citation_idx_map = {}
for source in sources: for source in sources:
if "document" in source: if "document" in source:
for doc_context, doc_meta in zip( for document_text, document_metadata in zip(
source["document"], source["metadata"] source["document"], source["metadata"]
): ):
source_name = source.get("source", {}).get("name", None) source_name = source.get("source", {}).get("name", None)
citation_id = ( source_id = (
doc_meta.get("source", None) document_metadata.get("source", None)
or source.get("source", {}).get("id", None) or source.get("source", {}).get("id", None)
or "N/A" or "N/A"
) )
if citation_id not in citation_idx:
citation_idx[citation_id] = len(citation_idx) + 1 if source_id not in citation_idx_map:
citation_idx_map[source_id] = len(citation_idx_map) + 1
context_string += ( context_string += (
f'<source id="{citation_idx[citation_id]}"' f'<source id="{citation_idx_map[source_id]}"'
+ (f' name="{source_name}"' if source_name else "") + (f' name="{source_name}"' if source_name else "")
+ f">{doc_context}</source>\n" + f">{document_text}</source>\n"
) )
context_string = context_string.strip() context_string = context_string.strip()
@ -1369,7 +1376,7 @@ async def process_chat_response(
return len(backtick_segments) > 1 and len(backtick_segments) % 2 == 0 return len(backtick_segments) > 1 and len(backtick_segments) % 2 == 0
# Handle as a background task # Handle as a background task
async def post_response_handler(response, events): async def response_handler(response, events):
def serialize_content_blocks(content_blocks, raw=False): def serialize_content_blocks(content_blocks, raw=False):
content = "" content = ""
@ -2428,9 +2435,9 @@ async def process_chat_response(
if response.background is not None: if response.background is not None:
await response.background() await response.background()
# background_tasks.add_task(post_response_handler, response, events) # background_tasks.add_task(response_handler, response, events)
task_id, _ = await create_task( task_id, _ = await create_task(
request, post_response_handler(response, events), id=metadata["chat_id"] request, response_handler(response, events), id=metadata["chat_id"]
) )
return {"status": True, "task_id": task_id} return {"status": True, "task_id": task_id}

View file

@ -76,8 +76,16 @@ async def get_all_base_models(request: Request, user: UserModel = None):
return function_models + openai_models + ollama_models return function_models + openai_models + ollama_models
async def get_all_models(request, user: UserModel = None): async def get_all_models(request, refresh: bool = False, user: UserModel = None):
models = await get_all_base_models(request, user=user) if (
request.app.state.MODELS
and request.app.state.BASE_MODELS
and (request.app.state.config.ENABLE_BASE_MODELS_CACHE and not refresh)
):
models = request.app.state.BASE_MODELS
else:
models = await get_all_base_models(request, user=user)
request.app.state.BASE_MODELS = models
# If there are no models, return an empty list # If there are no models, return an empty list
if len(models) == 0: if len(models) == 0:

View file

@ -1,6 +1,9 @@
from fastapi import FastAPI from fastapi import FastAPI
from opentelemetry import trace from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
OTLPSpanExporter as HttpOTLPSpanExporter,
)
from opentelemetry.sdk.resources import SERVICE_NAME, Resource from opentelemetry.sdk.resources import SERVICE_NAME, Resource
from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace import TracerProvider
from sqlalchemy import Engine from sqlalchemy import Engine
@ -16,6 +19,7 @@ from open_webui.env import (
ENABLE_OTEL_METRICS, ENABLE_OTEL_METRICS,
OTEL_BASIC_AUTH_USERNAME, OTEL_BASIC_AUTH_USERNAME,
OTEL_BASIC_AUTH_PASSWORD, OTEL_BASIC_AUTH_PASSWORD,
OTEL_OTLP_SPAN_EXPORTER,
) )
@ -35,11 +39,18 @@ def setup(app: FastAPI, db_engine: Engine):
headers = [("authorization", f"Basic {auth_header}")] headers = [("authorization", f"Basic {auth_header}")]
# otlp export # otlp export
exporter = OTLPSpanExporter( if OTEL_OTLP_SPAN_EXPORTER == "http":
endpoint=OTEL_EXPORTER_OTLP_ENDPOINT, exporter = HttpOTLPSpanExporter(
insecure=OTEL_EXPORTER_OTLP_INSECURE, endpoint=OTEL_EXPORTER_OTLP_ENDPOINT,
headers=headers, insecure=OTEL_EXPORTER_OTLP_INSECURE,
) headers=headers,
)
else:
exporter = OTLPSpanExporter(
endpoint=OTEL_EXPORTER_OTLP_ENDPOINT,
insecure=OTEL_EXPORTER_OTLP_INSECURE,
headers=headers,
)
trace.get_tracer_provider().add_span_processor(LazyBatchSpanProcessor(exporter)) trace.get_tracer_provider().add_span_processor(LazyBatchSpanProcessor(exporter))
Instrumentor(app=app, db_engine=db_engine).instrument() Instrumentor(app=app, db_engine=db_engine).instrument()

View file

@ -1,6 +1,6 @@
fastapi==0.115.7 fastapi==0.115.7
uvicorn[standard]==0.34.2 uvicorn[standard]==0.35.0
pydantic==2.10.6 pydantic==2.11.7
python-multipart==0.0.20 python-multipart==0.0.20
python-socketio==5.13.0 python-socketio==5.13.0
@ -42,8 +42,8 @@ google-genai==1.15.0
google-generativeai==0.8.5 google-generativeai==0.8.5
tiktoken tiktoken
langchain==0.3.24 langchain==0.3.26
langchain-community==0.3.23 langchain-community==0.3.26
fake-useragent==2.1.0 fake-useragent==2.1.0
chromadb==0.6.3 chromadb==0.6.3
@ -98,7 +98,6 @@ langfuse==2.44.0
youtube-transcript-api==1.1.0 youtube-transcript-api==1.1.0
pytube==15.0.0 pytube==15.0.0
extract_msg
pydub pydub
duckduckgo-search==8.0.2 duckduckgo-search==8.0.2
@ -115,7 +114,7 @@ pytest-docker~=3.1.1
googleapis-common-protos==1.63.2 googleapis-common-protos==1.63.2
google-cloud-storage==2.19.0 google-cloud-storage==2.19.0
azure-identity==1.21.0 azure-identity==1.23.0
azure-storage-blob==12.24.1 azure-storage-blob==12.24.1

View file

@ -28,7 +28,7 @@ SET "WEBUI_SECRET_KEY=%WEBUI_SECRET_KEY%"
SET "WEBUI_JWT_SECRET_KEY=%WEBUI_JWT_SECRET_KEY%" SET "WEBUI_JWT_SECRET_KEY=%WEBUI_JWT_SECRET_KEY%"
:: Check if WEBUI_SECRET_KEY and WEBUI_JWT_SECRET_KEY are not set :: Check if WEBUI_SECRET_KEY and WEBUI_JWT_SECRET_KEY are not set
IF "%WEBUI_SECRET_KEY%%WEBUI_JWT_SECRET_KEY%" == " " ( IF "%WEBUI_SECRET_KEY% %WEBUI_JWT_SECRET_KEY%" == " " (
echo Loading WEBUI_SECRET_KEY from file, not provided as an environment variable. echo Loading WEBUI_SECRET_KEY from file, not provided as an environment variable.
IF NOT EXIST "%KEY_FILE%" ( IF NOT EXIST "%KEY_FILE%" (

24
docker-compose.otel.yaml Normal file
View file

@ -0,0 +1,24 @@
services:
grafana:
image: grafana/otel-lgtm:latest
container_name: lgtm
ports:
- "3000:3000" # Grafana UI
- "4317:4317" # OTLP/gRPC
- "4318:4318" # OTLP/HTTP
restart: unless-stopped
open-webui:
image: ghcr.io/open-webui/open-webui:main
container_name: open-webui
depends_on: [grafana]
environment:
- ENABLE_OTEL=true
- OTEL_EXPORTER_OTLP_ENDPOINT=http://grafana:4317
- OTEL_SERVICE_NAME=open-webui
ports:
- "8088:8080"
networks: [default]
networks:
default:

50
package-lock.json generated
View file

@ -32,6 +32,7 @@
"@xyflow/svelte": "^0.1.19", "@xyflow/svelte": "^0.1.19",
"async": "^3.2.5", "async": "^3.2.5",
"bits-ui": "^0.21.15", "bits-ui": "^0.21.15",
"chart.js": "^4.5.0",
"codemirror": "^6.0.1", "codemirror": "^6.0.1",
"codemirror-lang-elixir": "^4.0.0", "codemirror-lang-elixir": "^4.0.0",
"codemirror-lang-hcl": "^0.1.0", "codemirror-lang-hcl": "^0.1.0",
@ -42,9 +43,10 @@
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"focus-trap": "^7.6.4", "focus-trap": "^7.6.4",
"fuse.js": "^7.0.0", "fuse.js": "^7.0.0",
"heic2any": "^0.0.4",
"highlight.js": "^11.9.0", "highlight.js": "^11.9.0",
"html-entities": "^2.5.3", "html-entities": "^2.5.3",
"html2canvas-pro": "^1.5.8", "html2canvas-pro": "^1.5.11",
"i18next": "^23.10.0", "i18next": "^23.10.0",
"i18next-browser-languagedetector": "^7.2.0", "i18next-browser-languagedetector": "^7.2.0",
"i18next-resources-to-backend": "^1.2.0", "i18next-resources-to-backend": "^1.2.0",
@ -53,6 +55,7 @@
"jspdf": "^3.0.0", "jspdf": "^3.0.0",
"katex": "^0.16.22", "katex": "^0.16.22",
"kokoro-js": "^1.1.1", "kokoro-js": "^1.1.1",
"leaflet": "^1.9.4",
"marked": "^9.1.0", "marked": "^9.1.0",
"mermaid": "^11.6.0", "mermaid": "^11.6.0",
"paneforge": "^0.0.6", "paneforge": "^0.0.6",
@ -70,7 +73,7 @@
"prosemirror-view": "^1.34.3", "prosemirror-view": "^1.34.3",
"pyodide": "^0.27.3", "pyodide": "^0.27.3",
"socket.io-client": "^4.2.0", "socket.io-client": "^4.2.0",
"sortablejs": "^1.15.2", "sortablejs": "^1.15.6",
"svelte-sonner": "^0.3.19", "svelte-sonner": "^0.3.19",
"tippy.js": "^6.3.7", "tippy.js": "^6.3.7",
"turndown": "^7.2.0", "turndown": "^7.2.0",
@ -1870,6 +1873,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@lezer/common": { "node_modules/@lezer/common": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.1.tgz", "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.1.tgz",
@ -4723,6 +4732,18 @@
"url": "https://github.com/chalk/chalk?sponsor=1" "url": "https://github.com/chalk/chalk?sponsor=1"
} }
}, },
"node_modules/chart.js": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz",
"integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==",
"license": "MIT",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/check-error": { "node_modules/check-error": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz",
@ -7295,6 +7316,12 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/heic2any": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/heic2any/-/heic2any-0.0.4.tgz",
"integrity": "sha512-3lLnZiDELfabVH87htnRolZ2iehX9zwpRyGNz22GKXIu0fznlblf0/ftppXKNqS26dqFSeqfIBhAmAj/uSp0cA==",
"license": "MIT"
},
"node_modules/heimdalljs": { "node_modules/heimdalljs": {
"version": "0.2.6", "version": "0.2.6",
"resolved": "https://registry.npmjs.org/heimdalljs/-/heimdalljs-0.2.6.tgz", "resolved": "https://registry.npmjs.org/heimdalljs/-/heimdalljs-0.2.6.tgz",
@ -7379,9 +7406,9 @@
} }
}, },
"node_modules/html2canvas-pro": { "node_modules/html2canvas-pro": {
"version": "1.5.8", "version": "1.5.11",
"resolved": "https://registry.npmjs.org/html2canvas-pro/-/html2canvas-pro-1.5.8.tgz", "resolved": "https://registry.npmjs.org/html2canvas-pro/-/html2canvas-pro-1.5.11.tgz",
"integrity": "sha512-bVGAU7IvhBwBlRAmX6QhekX8lsaxmYoF6zIwf/HNlHscjx+KN8jw/U4PQRYqeEVm9+m13hcS1l5ChJB9/e29Lw==", "integrity": "sha512-W4pEeKLG8+9a54RDOSiEKq7gRXXDzt0ORMaLXX+l6a3urSKbmnkmyzcRDCtgTOzmHLaZTLG2wiTQMJqKLlSh3w==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"css-line-break": "^2.1.0", "css-line-break": "^2.1.0",
@ -8046,6 +8073,12 @@
"node": ">=10.13.0" "node": ">=10.13.0"
} }
}, },
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
},
"node_modules/levn": { "node_modules/levn": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@ -11138,9 +11171,10 @@
} }
}, },
"node_modules/sortablejs": { "node_modules/sortablejs": {
"version": "1.15.2", "version": "1.15.6",
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.2.tgz", "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.6.tgz",
"integrity": "sha512-FJF5jgdfvoKn1MAKSdGs33bIqLi3LmsgVTliuX6iITj834F+JRQZN90Z93yql8h0K2t0RwDPBmxwlbZfDcxNZA==" "integrity": "sha512-aNfiuwMEpfBM/CN6LY0ibyhxPfPbyFeBTYJKCvzkJ2GkUpazIt3H+QIPAMHwqQ7tMKaHz1Qj+rJJCqljnf4p3A==",
"license": "MIT"
}, },
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",

View file

@ -76,6 +76,7 @@
"@xyflow/svelte": "^0.1.19", "@xyflow/svelte": "^0.1.19",
"async": "^3.2.5", "async": "^3.2.5",
"bits-ui": "^0.21.15", "bits-ui": "^0.21.15",
"chart.js": "^4.5.0",
"codemirror": "^6.0.1", "codemirror": "^6.0.1",
"codemirror-lang-elixir": "^4.0.0", "codemirror-lang-elixir": "^4.0.0",
"codemirror-lang-hcl": "^0.1.0", "codemirror-lang-hcl": "^0.1.0",
@ -86,9 +87,10 @@
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"focus-trap": "^7.6.4", "focus-trap": "^7.6.4",
"fuse.js": "^7.0.0", "fuse.js": "^7.0.0",
"heic2any": "^0.0.4",
"highlight.js": "^11.9.0", "highlight.js": "^11.9.0",
"html-entities": "^2.5.3", "html-entities": "^2.5.3",
"html2canvas-pro": "^1.5.8", "html2canvas-pro": "^1.5.11",
"i18next": "^23.10.0", "i18next": "^23.10.0",
"i18next-browser-languagedetector": "^7.2.0", "i18next-browser-languagedetector": "^7.2.0",
"i18next-resources-to-backend": "^1.2.0", "i18next-resources-to-backend": "^1.2.0",
@ -97,6 +99,7 @@
"jspdf": "^3.0.0", "jspdf": "^3.0.0",
"katex": "^0.16.22", "katex": "^0.16.22",
"kokoro-js": "^1.1.1", "kokoro-js": "^1.1.1",
"leaflet": "^1.9.4",
"marked": "^9.1.0", "marked": "^9.1.0",
"mermaid": "^11.6.0", "mermaid": "^11.6.0",
"paneforge": "^0.0.6", "paneforge": "^0.0.6",
@ -114,7 +117,7 @@
"prosemirror-view": "^1.34.3", "prosemirror-view": "^1.34.3",
"pyodide": "^0.27.3", "pyodide": "^0.27.3",
"socket.io-client": "^4.2.0", "socket.io-client": "^4.2.0",
"sortablejs": "^1.15.2", "sortablejs": "^1.15.6",
"svelte-sonner": "^0.3.19", "svelte-sonner": "^0.3.19",
"tippy.js": "^6.3.7", "tippy.js": "^6.3.7",
"turndown": "^7.2.0", "turndown": "^7.2.0",

View file

@ -8,7 +8,7 @@ license = { file = "LICENSE" }
dependencies = [ dependencies = [
"fastapi==0.115.7", "fastapi==0.115.7",
"uvicorn[standard]==0.34.2", "uvicorn[standard]==0.34.2",
"pydantic==2.10.6", "pydantic==2.11.7",
"python-multipart==0.0.20", "python-multipart==0.0.20",
"python-socketio==5.13.0", "python-socketio==5.13.0",
@ -50,8 +50,8 @@ dependencies = [
"google-generativeai==0.8.5", "google-generativeai==0.8.5",
"tiktoken", "tiktoken",
"langchain==0.3.24", "langchain==0.3.26",
"langchain-community==0.3.23", "langchain-community==0.3.26",
"fake-useragent==2.1.0", "fake-useragent==2.1.0",
"chromadb==0.6.3", "chromadb==0.6.3",
@ -105,7 +105,6 @@ dependencies = [
"youtube-transcript-api==1.1.0", "youtube-transcript-api==1.1.0",
"pytube==15.0.0", "pytube==15.0.0",
"extract_msg",
"pydub", "pydub",
"duckduckgo-search==8.0.2", "duckduckgo-search==8.0.2",

View file

@ -74,8 +74,8 @@ async function downloadPackages() {
console.log('Pyodide version mismatch, removing static/pyodide directory'); console.log('Pyodide version mismatch, removing static/pyodide directory');
await rmdir('static/pyodide', { recursive: true }); await rmdir('static/pyodide', { recursive: true });
} }
} catch (e) { } catch (err) {
console.log('Pyodide package not found, proceeding with download.'); console.log('Pyodide package not found, proceeding with download.', err);
} }
try { try {

View file

@ -77,28 +77,18 @@
} }
} }
}); });
const isDarkMode = document.documentElement.classList.contains('dark');
function setSplashImage() { const logo = document.createElement('img');
const logo = document.getElementById('logo'); logo.id = 'logo';
const isDarkMode = document.documentElement.classList.contains('dark'); logo.style =
'position: absolute; width: auto; height: 6rem; top: 44%; left: 50%; transform: translateX(-50%); display:block;';
logo.src = isDarkMode ? '/static/splash-dark.png' : '/static/splash.png';
if (isDarkMode) { document.addEventListener('DOMContentLoaded', function () {
const darkImage = new Image(); const splash = document.getElementById('splash-screen');
darkImage.src = '/static/splash-dark.png'; if (splash) splash.prepend(logo);
});
darkImage.onload = () => {
logo.src = '/static/splash-dark.png';
logo.style.filter = ''; // Ensure no inversion is applied if splash-dark.png exists
};
darkImage.onerror = () => {
logo.style.filter = 'invert(1)'; // Invert image if splash-dark.png is missing
};
}
}
// Runs after classes are assigned
window.onload = setSplashImage;
})(); })();
</script> </script>
@ -120,19 +110,6 @@
} }
</style> </style>
<img
id="logo"
style="
position: absolute;
width: auto;
height: 6rem;
top: 44%;
left: 50%;
transform: translateX(-50%);
"
src="/static/splash.png"
/>
<div <div
style=" style="
position: absolute; position: absolute;

View file

@ -37,7 +37,9 @@ export const importChat = async (
chat: object, chat: object,
meta: object | null, meta: object | null,
pinned?: boolean, pinned?: boolean,
folderId?: string | null folderId?: string | null,
createdAt: number | null = null,
updatedAt: number | null = null
) => { ) => {
let error = null; let error = null;
@ -52,7 +54,9 @@ export const importChat = async (
chat: chat, chat: chat,
meta: meta ?? {}, meta: meta ?? {},
pinned: pinned, pinned: pinned,
folder_id: folderId folder_id: folderId,
created_at: createdAt ?? null,
updated_at: updatedAt ?? null
}) })
}) })
.then(async (res) => { .then(async (res) => {

View file

@ -58,10 +58,10 @@ export const exportConfig = async (token: string) => {
return res; return res;
}; };
export const getDirectConnectionsConfig = async (token: string) => { export const getConnectionsConfig = async (token: string) => {
let error = null; let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/configs/direct_connections`, { const res = await fetch(`${WEBUI_API_BASE_URL}/configs/connections`, {
method: 'GET', method: 'GET',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@ -85,10 +85,10 @@ export const getDirectConnectionsConfig = async (token: string) => {
return res; return res;
}; };
export const setDirectConnectionsConfig = async (token: string, config: object) => { export const setConnectionsConfig = async (token: string, config: object) => {
let error = null; let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/configs/direct_connections`, { const res = await fetch(`${WEBUI_API_BASE_URL}/configs/connections`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',

View file

@ -8,17 +8,26 @@ import { toast } from 'svelte-sonner';
export const getModels = async ( export const getModels = async (
token: string = '', token: string = '',
connections: object | null = null, connections: object | null = null,
base: boolean = false base: boolean = false,
refresh: boolean = false
) => { ) => {
const searchParams = new URLSearchParams();
if (refresh) {
searchParams.append('refresh', 'true');
}
let error = null; let error = null;
const res = await fetch(`${WEBUI_BASE_URL}/api/models${base ? '/base' : ''}`, { const res = await fetch(
method: 'GET', `${WEBUI_BASE_URL}/api/models${base ? '/base' : ''}?${searchParams.toString()}`,
headers: { {
Accept: 'application/json', method: 'GET',
'Content-Type': 'application/json', headers: {
...(token && { authorization: `Bearer ${token}` }) Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
}
} }
}) )
.then(async (res) => { .then(async (res) => {
if (!res.ok) throw await res.json(); if (!res.ok) throw await res.json();
return res.json(); return res.json();

View file

@ -403,6 +403,7 @@ export const deleteUserById = async (token: string, userId: string) => {
}; };
type UserUpdateForm = { type UserUpdateForm = {
role: string;
profile_image_url: string; profile_image_url: string;
email: string; email: string;
name: string; name: string;

View file

@ -15,6 +15,8 @@
import Tooltip from '$lib/components/common/Tooltip.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte';
import Switch from '$lib/components/common/Switch.svelte'; import Switch from '$lib/components/common/Switch.svelte';
import Tags from './common/Tags.svelte'; import Tags from './common/Tags.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
export let onSubmit: Function = () => {}; export let onSubmit: Function = () => {};
export let onDelete: Function = () => {}; export let onDelete: Function = () => {};
@ -208,17 +210,7 @@
show = false; show = false;
}} }}
> >
<svg <XMark className={'size-5'} />
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
class="w-5 h-5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
</button> </button>
</div> </div>
@ -524,29 +516,7 @@
{#if loading} {#if loading}
<div class="ml-2 self-center"> <div class="ml-2 self-center">
<svg <Spinner />
class=" w-4 h-4"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
><style>
.spinner_ajPY {
transform-origin: center;
animation: spinner_AtaB 0.75s infinite linear;
}
@keyframes spinner_AtaB {
100% {
transform: rotate(360deg);
}
}
</style><path
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
opacity=".25"
/><path
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
class="spinner_ajPY"
/></svg
>
</div> </div>
{/if} {/if}
</button> </button>

View file

@ -15,6 +15,8 @@
import { getToolServerData } from '$lib/apis'; import { getToolServerData } from '$lib/apis';
import { verifyToolServerConnection } from '$lib/apis/configs'; import { verifyToolServerConnection } from '$lib/apis/configs';
import AccessControl from './workspace/common/AccessControl.svelte'; import AccessControl from './workspace/common/AccessControl.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
export let onSubmit: Function = () => {}; export let onSubmit: Function = () => {};
export let onDelete: Function = () => {}; export let onDelete: Function = () => {};
@ -168,17 +170,7 @@
show = false; show = false;
}} }}
> >
<svg <XMark className={'size-5'} />
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
class="w-5 h-5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
</button> </button>
</div> </div>
@ -389,30 +381,7 @@
{#if loading} {#if loading}
<div class="ml-2 self-center"> <div class="ml-2 self-center">
<svg <Spinner />
aria-hidden="true"
class=" w-4 h-4"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
><style>
.spinner_ajPY {
transform-origin: center;
animation: spinner_AtaB 0.75s infinite linear;
}
@keyframes spinner_AtaB {
100% {
transform: rotate(360deg);
}
}
</style><path
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
opacity=".25"
/><path
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
class="spinner_ajPY"
/></svg
>
</div> </div>
{/if} {/if}
</button> </button>

View file

@ -9,6 +9,7 @@
import Modal from './common/Modal.svelte'; import Modal from './common/Modal.svelte';
import { updateUserSettings } from '$lib/apis/users'; import { updateUserSettings } from '$lib/apis/users';
import XMark from '$lib/components/icons/XMark.svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
@ -36,18 +37,11 @@
localStorage.version = $config.version; localStorage.version = $config.version;
show = false; show = false;
}} }}
aria-label={$i18n.t('Close')}
> >
<svg <XMark className={'size-5'}>
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<p class="sr-only">{$i18n.t('Close')}</p> <p class="sr-only">{$i18n.t('Close')}</p>
<path </XMark>
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
</button> </button>
</div> </div>
<div class="flex items-center mt-1"> <div class="flex items-center mt-1">

View file

@ -3,7 +3,9 @@
import { getContext, onMount } from 'svelte'; import { getContext, onMount } from 'svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
import Spinner from '$lib/components/common/Spinner.svelte';
import Modal from '$lib/components/common/Modal.svelte'; import Modal from '$lib/components/common/Modal.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import { extractFrontmatter } from '$lib/utils'; import { extractFrontmatter } from '$lib/utils';
export let show = false; export let show = false;
@ -69,16 +71,7 @@
show = false; show = false;
}} }}
> >
<svg <XMark className={'size-5'} />
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
</button> </button>
</div> </div>
@ -120,29 +113,7 @@
{#if loading} {#if loading}
<div class="ml-2 self-center"> <div class="ml-2 self-center">
<svg <Spinner />
class=" w-4 h-4"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
><style>
.spinner_ajPY {
transform-origin: center;
animation: spinner_AtaB 0.75s infinite linear;
}
@keyframes spinner_AtaB {
100% {
transform: rotate(360deg);
}
}
</style><path
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
opacity=".25"
/><path
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
class="spinner_ajPY"
/></svg
>
</div> </div>
{/if} {/if}
</button> </button>

View file

@ -2,16 +2,42 @@
import Modal from '$lib/components/common/Modal.svelte'; import Modal from '$lib/components/common/Modal.svelte';
import { getContext } from 'svelte'; import { getContext } from 'svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
import XMark from '$lib/components/icons/XMark.svelte';
import { getFeedbackById } from '$lib/apis/evaluations';
import { toast } from 'svelte-sonner';
import Spinner from '$lib/components/common/Spinner.svelte';
export let show = false; export let show = false;
export let selectedFeedback = null; export let selectedFeedback = null;
export let onClose: () => void = () => {}; export let onClose: () => void = () => {};
let loaded = false;
let feedbackData = null;
const close = () => { const close = () => {
show = false; show = false;
onClose(); onClose();
}; };
const init = async () => {
loaded = false;
feedbackData = null;
if (selectedFeedback) {
feedbackData = await getFeedbackById(localStorage.token, selectedFeedback.id).catch((err) => {
toast.error(err);
return null;
});
console.log('Feedback Data:', selectedFeedback, feedbackData);
}
loaded = true;
};
$: if (show) {
init();
}
</script> </script>
<Modal size="sm" bind:show> <Modal size="sm" bind:show>
@ -22,60 +48,89 @@
{$i18n.t('Feedback Details')} {$i18n.t('Feedback Details')}
</div> </div>
<button class="self-center" on:click={close} aria-label="Close"> <button class="self-center" on:click={close} aria-label="Close">
<svg <XMark className={'size-5'} />
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
</button> </button>
</div> </div>
<div class="flex flex-col md:flex-row w-full px-5 pb-4 md:space-x-4 dark:text-gray-200"> <div class="flex flex-col md:flex-row w-full px-5 pb-4 md:space-x-4 dark:text-gray-200">
<div class="flex flex-col w-full"> {#if loaded}
<div class="mb-2 -mx-1"> <div class="flex flex-col w-full">
{#if selectedFeedback?.data?.tags && selectedFeedback?.data?.tags.length} {#if feedbackData}
<div class="flex flex-wrap gap-1 mt-1"> {@const messageId = feedbackData?.meta?.message_id}
{#each selectedFeedback?.data?.tags as tag} {@const messages = feedbackData?.snapshot?.chat?.chat?.history.messages}
<span class="px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-850 text-xs"
>{tag}</span {#if messages[messages[messageId]?.parentId]}
<div class="flex flex-col w-full mb-2">
<div class="mb-1 text-xs text-gray-500">{$i18n.t('Prompt')}</div>
<div class="flex-1 text-xs whitespace-pre-line break-words">
<span>{messages[messages[messageId]?.parentId]?.content || '-'}</span>
</div>
</div>
{/if}
{#if messages[messageId]}
<div class="flex flex-col w-full mb-2">
<div class="mb-1 text-xs text-gray-500">{$i18n.t('Response')}</div>
<div
class="flex-1 text-xs whitespace-pre-line break-words max-h-32 overflow-y-auto"
> >
{/each} <span>{messages[messageId]?.content || '-'}</span>
</div> </div>
{:else} </div>
<span>-</span> {/if}
{/if} {/if}
</div>
<div class="flex flex-col w-full mb-2"> <div class="flex flex-col w-full mb-2">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Rating')}</div> <div class=" mb-1 text-xs text-gray-500">{$i18n.t('Rating')}</div>
<div class="flex-1 text-xs"> <div class="flex-1 text-xs">
<span>{selectedFeedback?.data?.details?.rating ?? '-'}</span> <span>{selectedFeedback?.data?.details?.rating ?? '-'}</span>
</div>
</div>
<div class="flex flex-col w-full mb-2">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Reason')}</div>
<div class="flex-1 text-xs">
<span>{selectedFeedback?.data?.reason || '-'}</span>
</div>
</div>
<div class="flex flex-col w-full mb-2">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Comment')}</div>
<div class="flex-1 text-xs">
<span>{selectedFeedback?.data?.comment || '-'}</span>
</div>
</div>
{#if selectedFeedback?.data?.tags && selectedFeedback?.data?.tags.length}
<div class="mb-2 -mx-1">
<div class="flex flex-wrap gap-1 mt-1">
{#each selectedFeedback?.data?.tags as tag}
<span class="px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-850 text-[9px]"
>{tag}</span
>
{/each}
</div>
</div>
{/if}
<div class="flex justify-end pt-2">
<button
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
type="button"
on:click={close}
>
{$i18n.t('Close')}
</button>
</div> </div>
</div> </div>
<div class="flex flex-col w-full mb-2"> {:else}
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Reason')}</div> <div class="flex items-center justify-center w-full h-32">
<Spinner className={'size-5'} />
<div class="flex-1 text-xs">
<span>{selectedFeedback?.data?.reason || '-'}</span>
</div>
</div> </div>
{/if}
<div class="flex justify-end pt-2">
<button
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
type="button"
on:click={close}
>
{$i18n.t('Close')}
</button>
</div>
</div>
</div> </div>
</div> </div>
{/if} {/if}

View file

@ -369,7 +369,7 @@
{dayjs(feedback.updated_at * 1000).fromNow()} {dayjs(feedback.updated_at * 1000).fromNow()}
</td> </td>
<td class=" px-3 py-1 text-right font-semibold"> <td class=" px-3 py-1 text-right font-semibold" on:click={(e) => e.stopPropagation()}>
<FeedbackMenu <FeedbackMenu
on:delete={(e) => { on:delete={(e) => {
deleteFeedbackHandler(feedback.id); deleteFeedbackHandler(feedback.id);

View file

@ -11,7 +11,7 @@
import Spinner from '$lib/components/common/Spinner.svelte'; import Spinner from '$lib/components/common/Spinner.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte';
import MagnifyingGlass from '$lib/components/icons/MagnifyingGlass.svelte'; import Search from '$lib/components/icons/Search.svelte';
import ChevronUp from '$lib/components/icons/ChevronUp.svelte'; import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte'; import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
@ -77,7 +77,7 @@
let showLeaderboardModal = false; let showLeaderboardModal = false;
let selectedModel = null; let selectedModel = null;
const openFeedbackModal = (model) => { const openLeaderboardModelModal = (model) => {
showLeaderboardModal = true; showLeaderboardModal = true;
selectedModel = model; selectedModel = model;
}; };
@ -350,7 +350,7 @@
<Tooltip content={$i18n.t('Re-rank models by topic similarity')}> <Tooltip content={$i18n.t('Re-rank models by topic similarity')}>
<div class="flex flex-1"> <div class="flex flex-1">
<div class=" self-center ml-1 mr-3"> <div class=" self-center ml-1 mr-3">
<MagnifyingGlass className="size-3" /> <Search className="size-3" />
</div> </div>
<input <input
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent" class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
@ -371,7 +371,7 @@
{#if loadingLeaderboard} {#if loadingLeaderboard}
<div class=" absolute top-0 bottom-0 left-0 right-0 flex"> <div class=" absolute top-0 bottom-0 left-0 right-0 flex">
<div class="m-auto"> <div class="m-auto">
<Spinner /> <Spinner className="size-5" />
</div> </div>
</div> </div>
{/if} {/if}
@ -505,7 +505,7 @@
{#each sortedModels as model, modelIdx (model.id)} {#each sortedModels as model, modelIdx (model.id)}
<tr <tr
class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs group cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-850/50 transition" class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs group cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-850/50 transition"
on:click={() => openFeedbackModal(model)} on:click={() => openLeaderboardModelModal(model)}
> >
<td class="px-3 py-1.5 text-left font-medium text-gray-900 dark:text-white w-fit"> <td class="px-3 py-1.5 text-left font-medium text-gray-900 dark:text-white w-fit">
<div class=" line-clamp-1"> <div class=" line-clamp-1">

View file

@ -6,6 +6,7 @@
export let feedbacks = []; export let feedbacks = [];
export let onClose: () => void = () => {}; export let onClose: () => void = () => {};
const i18n = getContext('i18n'); const i18n = getContext('i18n');
import XMark from '$lib/components/icons/XMark.svelte';
const close = () => { const close = () => {
show = false; show = false;
@ -37,16 +38,7 @@
{model.name} {model.name}
</div> </div>
<button class="self-center" on:click={close} aria-label="Close"> <button class="self-center" on:click={close} aria-label="Close">
<svg <XMark className={'size-5'} />
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
</button> </button>
</div> </div>
<div class="px-5 pb-4 dark:text-gray-200"> <div class="px-5 pb-4 dark:text-gray-200">

View file

@ -49,6 +49,8 @@
let showConfirm = false; let showConfirm = false;
let query = ''; let query = '';
let selectedType = 'all';
let showManifestModal = false; let showManifestModal = false;
let showValvesModal = false; let showValvesModal = false;
let selectedFunction = null; let selectedFunction = null;
@ -59,9 +61,10 @@
$: filteredItems = $functions $: filteredItems = $functions
.filter( .filter(
(f) => (f) =>
query === '' || (selectedType !== 'all' ? f.type === selectedType : true) &&
f.name.toLowerCase().includes(query.toLowerCase()) || (query === '' ||
f.id.toLowerCase().includes(query.toLowerCase()) f.name.toLowerCase().includes(query.toLowerCase()) ||
f.id.toLowerCase().includes(query.toLowerCase()))
) )
.sort((a, b) => a.type.localeCompare(b.type) || a.name.localeCompare(b.name)); .sort((a, b) => a.type.localeCompare(b.type) || a.name.localeCompare(b.name));
@ -135,7 +138,9 @@
models.set( models.set(
await getModels( await getModels(
localStorage.token, localStorage.token,
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null) $config?.features?.enable_direct_connections && ($settings?.directConnections ?? null),
false,
true
) )
); );
} }
@ -161,7 +166,9 @@
models.set( models.set(
await getModels( await getModels(
localStorage.token, localStorage.token,
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null) $config?.features?.enable_direct_connections && ($settings?.directConnections ?? null),
false,
true
) )
); );
} }
@ -215,8 +222,8 @@
}} }}
/> />
<div class="flex flex-col gap-1 mt-1.5 mb-2"> <div class="flex flex-col mt-1.5 mb-0.5">
<div class="flex justify-between items-center"> <div class="flex justify-between items-center mb-1">
<div class="flex md:self-center text-xl items-center font-medium px-0.5"> <div class="flex md:self-center text-xl items-center font-medium px-0.5">
{$i18n.t('Functions')} {$i18n.t('Functions')}
<div class="flex self-center w-[1px] h-6 mx-2.5 bg-gray-50 dark:bg-gray-850" /> <div class="flex self-center w-[1px] h-6 mx-2.5 bg-gray-50 dark:bg-gray-850" />
@ -266,12 +273,54 @@
</AddFunctionMenu> </AddFunctionMenu>
</div> </div>
</div> </div>
<div class=" flex w-full">
<div
class="flex gap-1 scrollbar-none overflow-x-auto w-fit text-center text-sm font-medium rounded-full bg-transparent"
>
<button
class="min-w-fit p-1.5 {selectedType === 'all'
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
on:click={() => {
selectedType = 'all';
}}>{$i18n.t('All')}</button
>
<button
class="min-w-fit p-1.5 {selectedType === 'pipe'
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
on:click={() => {
selectedType = 'pipe';
}}>{$i18n.t('Pipe')}</button
>
<button
class="min-w-fit p-1.5 {selectedType === 'filter'
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
on:click={() => {
selectedType = 'filter';
}}>{$i18n.t('Filter')}</button
>
<button
class="min-w-fit p-1.5 {selectedType === 'action'
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
on:click={() => {
selectedType = 'action';
}}>{$i18n.t('Action')}</button
>
</div>
</div>
</div> </div>
<div class="mb-5"> <div class="mb-5">
{#each filteredItems as func (func.id)} {#each filteredItems as func (func.id)}
<div <div
class=" flex space-x-4 cursor-pointer w-full px-3 py-2 dark:hover:bg-white/5 hover:bg-black/5 rounded-xl" class=" flex space-x-4 cursor-pointer w-full px-2 py-2 dark:hover:bg-white/5 hover:bg-black/5 rounded-xl"
> >
<a <a
class=" flex flex-1 space-x-3.5 cursor-pointer w-full" class=" flex flex-1 space-x-3.5 cursor-pointer w-full"
@ -413,7 +462,9 @@
await getModels( await getModels(
localStorage.token, localStorage.token,
$config?.features?.enable_direct_connections && $config?.features?.enable_direct_connections &&
($settings?.directConnections ?? null) ($settings?.directConnections ?? null),
false,
true
) )
); );
}} }}
@ -559,7 +610,9 @@
models.set( models.set(
await getModels( await getModels(
localStorage.token, localStorage.token,
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null) $config?.features?.enable_direct_connections && ($settings?.directConnections ?? null),
false,
true
) )
); );
}} }}
@ -585,7 +638,9 @@
models.set( models.set(
await getModels( await getModels(
localStorage.token, localStorage.token,
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null) $config?.features?.enable_direct_connections && ($settings?.directConnections ?? null),
false,
true
) )
); );
}; };

View file

@ -12,6 +12,7 @@
} from '$lib/apis/audio'; } from '$lib/apis/audio';
import { config, settings } from '$lib/stores'; import { config, settings } from '$lib/stores';
import Spinner from '$lib/components/common/Spinner.svelte';
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte'; import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
import { TTS_RESPONSE_SPLIT } from '$lib/types'; import { TTS_RESPONSE_SPLIT } from '$lib/types';
@ -373,33 +374,7 @@
> >
{#if STT_WHISPER_MODEL_LOADING} {#if STT_WHISPER_MODEL_LOADING}
<div class="self-center"> <div class="self-center">
<svg <Spinner />
class=" w-4 h-4"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<style>
.spinner_ajPY {
transform-origin: center;
animation: spinner_AtaB 0.75s infinite linear;
}
@keyframes spinner_AtaB {
100% {
transform: rotate(360deg);
}
}
</style>
<path
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
opacity=".25"
/>
<path
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
class="spinner_ajPY"
/>
</svg>
</div> </div>
{:else} {:else}
<svg <svg

View file

@ -7,7 +7,7 @@
import { getOllamaConfig, updateOllamaConfig } from '$lib/apis/ollama'; import { getOllamaConfig, updateOllamaConfig } from '$lib/apis/ollama';
import { getOpenAIConfig, updateOpenAIConfig, getOpenAIModels } from '$lib/apis/openai'; import { getOpenAIConfig, updateOpenAIConfig, getOpenAIModels } from '$lib/apis/openai';
import { getModels as _getModels } from '$lib/apis'; import { getModels as _getModels } from '$lib/apis';
import { getDirectConnectionsConfig, setDirectConnectionsConfig } from '$lib/apis/configs'; import { getConnectionsConfig, setConnectionsConfig } from '$lib/apis/configs';
import { config, models, settings, user } from '$lib/stores'; import { config, models, settings, user } from '$lib/stores';
@ -25,7 +25,9 @@
const getModels = async () => { const getModels = async () => {
const models = await _getModels( const models = await _getModels(
localStorage.token, localStorage.token,
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null) $config?.features?.enable_direct_connections && ($settings?.directConnections ?? null),
false,
true
); );
return models; return models;
}; };
@ -41,7 +43,7 @@
let ENABLE_OPENAI_API: null | boolean = null; let ENABLE_OPENAI_API: null | boolean = null;
let ENABLE_OLLAMA_API: null | boolean = null; let ENABLE_OLLAMA_API: null | boolean = null;
let directConnectionsConfig = null; let connectionsConfig = null;
let pipelineUrls = {}; let pipelineUrls = {};
let showAddOpenAIConnectionModal = false; let showAddOpenAIConnectionModal = false;
@ -104,15 +106,13 @@
} }
}; };
const updateDirectConnectionsHandler = async () => { const updateConnectionsHandler = async () => {
const res = await setDirectConnectionsConfig(localStorage.token, directConnectionsConfig).catch( const res = await setConnectionsConfig(localStorage.token, connectionsConfig).catch((error) => {
(error) => { toast.error(`${error}`);
toast.error(`${error}`); });
}
);
if (res) { if (res) {
toast.success($i18n.t('Direct Connections settings updated')); toast.success($i18n.t('Connections settings updated'));
await models.set(await getModels()); await models.set(await getModels());
} }
}; };
@ -148,7 +148,7 @@
openaiConfig = await getOpenAIConfig(localStorage.token); openaiConfig = await getOpenAIConfig(localStorage.token);
})(), })(),
(async () => { (async () => {
directConnectionsConfig = await getDirectConnectionsConfig(localStorage.token); connectionsConfig = await getConnectionsConfig(localStorage.token);
})() })()
]); ]);
@ -215,36 +215,103 @@
<form class="flex flex-col h-full justify-between text-sm" on:submit|preventDefault={submitHandler}> <form class="flex flex-col h-full justify-between text-sm" on:submit|preventDefault={submitHandler}>
<div class=" overflow-y-scroll scrollbar-hidden h-full"> <div class=" overflow-y-scroll scrollbar-hidden h-full">
{#if ENABLE_OPENAI_API !== null && ENABLE_OLLAMA_API !== null && directConnectionsConfig !== null} {#if ENABLE_OPENAI_API !== null && ENABLE_OLLAMA_API !== null && connectionsConfig !== null}
<div class="my-2"> <div class="mb-3.5">
<div class="mt-2 space-y-2 pr-1.5"> <div class=" mb-2.5 text-base font-medium">{$i18n.t('General')}</div>
<div class="flex justify-between items-center text-sm">
<div class=" font-medium">{$i18n.t('OpenAI API')}</div>
<div class="flex items-center"> <hr class=" border-gray-100 dark:border-gray-850 my-2" />
<div class="">
<Switch <div class="my-2">
bind:state={ENABLE_OPENAI_API} <div class="mt-2 space-y-2">
on:change={async () => { <div class="flex justify-between items-center text-sm">
updateOpenAIHandler(); <div class=" font-medium">{$i18n.t('OpenAI API')}</div>
}}
/> <div class="flex items-center">
<div class="">
<Switch
bind:state={ENABLE_OPENAI_API}
on:change={async () => {
updateOpenAIHandler();
}}
/>
</div>
</div> </div>
</div> </div>
{#if ENABLE_OPENAI_API}
<div class="">
<div class="flex justify-between items-center">
<div class="font-medium text-xs">{$i18n.t('Manage OpenAI API Connections')}</div>
<Tooltip content={$i18n.t(`Add Connection`)}>
<button
class="px-1"
on:click={() => {
showAddOpenAIConnectionModal = true;
}}
type="button"
>
<Plus />
</button>
</Tooltip>
</div>
<div class="flex flex-col gap-1.5 mt-1.5">
{#each OPENAI_API_BASE_URLS as url, idx}
<OpenAIConnection
pipeline={pipelineUrls[url] ? true : false}
bind:url
bind:key={OPENAI_API_KEYS[idx]}
bind:config={OPENAI_API_CONFIGS[idx]}
onSubmit={() => {
updateOpenAIHandler();
}}
onDelete={() => {
OPENAI_API_BASE_URLS = OPENAI_API_BASE_URLS.filter(
(url, urlIdx) => idx !== urlIdx
);
OPENAI_API_KEYS = OPENAI_API_KEYS.filter((key, keyIdx) => idx !== keyIdx);
let newConfig = {};
OPENAI_API_BASE_URLS.forEach((url, newIdx) => {
newConfig[newIdx] =
OPENAI_API_CONFIGS[newIdx < idx ? newIdx : newIdx + 1];
});
OPENAI_API_CONFIGS = newConfig;
updateOpenAIHandler();
}}
/>
{/each}
</div>
</div>
{/if}
</div>
</div>
<div class=" my-2">
<div class="flex justify-between items-center text-sm mb-2">
<div class=" font-medium">{$i18n.t('Ollama API')}</div>
<div class="mt-1">
<Switch
bind:state={ENABLE_OLLAMA_API}
on:change={async () => {
updateOllamaHandler();
}}
/>
</div>
</div> </div>
{#if ENABLE_OPENAI_API} {#if ENABLE_OLLAMA_API}
<hr class=" border-gray-100 dark:border-gray-850" />
<div class=""> <div class="">
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<div class="font-medium">{$i18n.t('Manage OpenAI API Connections')}</div> <div class="font-medium text-xs">{$i18n.t('Manage Ollama API Connections')}</div>
<Tooltip content={$i18n.t(`Add Connection`)}> <Tooltip content={$i18n.t(`Add Connection`)}>
<button <button
class="px-1" class="px-1"
on:click={() => { on:click={() => {
showAddOpenAIConnectionModal = true; showAddOllamaConnectionModal = true;
}} }}
type="button" type="button"
> >
@ -253,133 +320,89 @@
</Tooltip> </Tooltip>
</div> </div>
<div class="flex flex-col gap-1.5 mt-1.5"> <div class="flex w-full gap-1.5">
{#each OPENAI_API_BASE_URLS as url, idx} <div class="flex-1 flex flex-col gap-1.5 mt-1.5">
<OpenAIConnection {#each OLLAMA_BASE_URLS as url, idx}
pipeline={pipelineUrls[url] ? true : false} <OllamaConnection
bind:url bind:url
bind:key={OPENAI_API_KEYS[idx]} bind:config={OLLAMA_API_CONFIGS[idx]}
bind:config={OPENAI_API_CONFIGS[idx]} {idx}
onSubmit={() => { onSubmit={() => {
updateOpenAIHandler(); updateOllamaHandler();
}} }}
onDelete={() => { onDelete={() => {
OPENAI_API_BASE_URLS = OPENAI_API_BASE_URLS.filter( OLLAMA_BASE_URLS = OLLAMA_BASE_URLS.filter((url, urlIdx) => idx !== urlIdx);
(url, urlIdx) => idx !== urlIdx
);
OPENAI_API_KEYS = OPENAI_API_KEYS.filter((key, keyIdx) => idx !== keyIdx);
let newConfig = {}; let newConfig = {};
OPENAI_API_BASE_URLS.forEach((url, newIdx) => { OLLAMA_BASE_URLS.forEach((url, newIdx) => {
newConfig[newIdx] = OPENAI_API_CONFIGS[newIdx < idx ? newIdx : newIdx + 1]; newConfig[newIdx] =
}); OLLAMA_API_CONFIGS[newIdx < idx ? newIdx : newIdx + 1];
OPENAI_API_CONFIGS = newConfig; });
updateOpenAIHandler(); OLLAMA_API_CONFIGS = newConfig;
}} }}
/> />
{/each} {/each}
</div>
</div>
<div class="mt-1 text-xs text-gray-400 dark:text-gray-500">
{$i18n.t('Trouble accessing Ollama?')}
<a
class=" text-gray-300 font-medium underline"
href="https://github.com/open-webui/open-webui#troubleshooting"
target="_blank"
>
{$i18n.t('Click here for help.')}
</a>
</div> </div>
</div> </div>
{/if} {/if}
</div> </div>
</div>
<hr class=" border-gray-100 dark:border-gray-850" /> <div class="my-2">
<div class="flex justify-between items-center text-sm">
<div class=" font-medium">{$i18n.t('Direct Connections')}</div>
<div class="pr-1.5 my-2"> <div class="flex items-center">
<div class="flex justify-between items-center text-sm mb-2"> <div class="">
<div class=" font-medium">{$i18n.t('Ollama API')}</div> <Switch
bind:state={connectionsConfig.ENABLE_DIRECT_CONNECTIONS}
<div class="mt-1"> on:change={async () => {
<Switch updateConnectionsHandler();
bind:state={ENABLE_OLLAMA_API}
on:change={async () => {
updateOllamaHandler();
}}
/>
</div>
</div>
{#if ENABLE_OLLAMA_API}
<hr class=" border-gray-100 dark:border-gray-850 my-2" />
<div class="">
<div class="flex justify-between items-center">
<div class="font-medium">{$i18n.t('Manage Ollama API Connections')}</div>
<Tooltip content={$i18n.t(`Add Connection`)}>
<button
class="px-1"
on:click={() => {
showAddOllamaConnectionModal = true;
}} }}
type="button" />
>
<Plus />
</button>
</Tooltip>
</div>
<div class="flex w-full gap-1.5">
<div class="flex-1 flex flex-col gap-1.5 mt-1.5">
{#each OLLAMA_BASE_URLS as url, idx}
<OllamaConnection
bind:url
bind:config={OLLAMA_API_CONFIGS[idx]}
{idx}
onSubmit={() => {
updateOllamaHandler();
}}
onDelete={() => {
OLLAMA_BASE_URLS = OLLAMA_BASE_URLS.filter((url, urlIdx) => idx !== urlIdx);
let newConfig = {};
OLLAMA_BASE_URLS.forEach((url, newIdx) => {
newConfig[newIdx] = OLLAMA_API_CONFIGS[newIdx < idx ? newIdx : newIdx + 1];
});
OLLAMA_API_CONFIGS = newConfig;
}}
/>
{/each}
</div> </div>
</div> </div>
<div class="mt-1 text-xs text-gray-400 dark:text-gray-500">
{$i18n.t('Trouble accessing Ollama?')}
<a
class=" text-gray-300 font-medium underline"
href="https://github.com/open-webui/open-webui#troubleshooting"
target="_blank"
>
{$i18n.t('Click here for help.')}
</a>
</div>
</div> </div>
{/if}
</div>
<hr class=" border-gray-100 dark:border-gray-850" /> <div class="mt-1 text-xs text-gray-400 dark:text-gray-500">
{$i18n.t(
<div class="pr-1.5 my-2"> 'Direct Connections allow users to connect to their own OpenAI compatible API endpoints.'
<div class="flex justify-between items-center text-sm"> )}
<div class=" font-medium">{$i18n.t('Direct Connections')}</div>
<div class="flex items-center">
<div class="">
<Switch
bind:state={directConnectionsConfig.ENABLE_DIRECT_CONNECTIONS}
on:change={async () => {
updateDirectConnectionsHandler();
}}
/>
</div>
</div> </div>
</div> </div>
<div class="mt-1.5"> <hr class=" border-gray-100 dark:border-gray-850 my-2" />
<div class="text-xs text-gray-500">
<div class="my-2">
<div class="flex justify-between items-center text-sm">
<div class=" text-xs font-medium">{$i18n.t('Cache Base Model List')}</div>
<div class="flex items-center">
<div class="">
<Switch
bind:state={connectionsConfig.ENABLE_BASE_MODELS_CACHE}
on:change={async () => {
updateConnectionsHandler();
}}
/>
</div>
</div>
</div>
<div class="mt-1 text-xs text-gray-400 dark:text-gray-500">
{$i18n.t( {$i18n.t(
'Direct Connections allow users to connect to their own OpenAI compatible API endpoints.' 'Base Model List Cache speeds up access by fetching base models only at startup or on settings save—faster, but may not show recent base model changes.'
)} )}
</div> </div>
</div> </div>

View file

@ -5,6 +5,7 @@
import Modal from '$lib/components/common/Modal.svelte'; import Modal from '$lib/components/common/Modal.svelte';
import ManageOllama from '../Models/Manage/ManageOllama.svelte'; import ManageOllama from '../Models/Manage/ManageOllama.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
export let show = false; export let show = false;
export let urlIdx: number | null = null; export let urlIdx: number | null = null;
@ -26,16 +27,7 @@
show = false; show = false;
}} }}
> >
<svg <XMark className={'size-5'} />
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
</button> </button>
</div> </div>

View file

@ -90,10 +90,6 @@
return; return;
} }
if (embeddingEngine === 'openai' && (OpenAIKey === '' || OpenAIUrl === '')) {
toast.error($i18n.t('OpenAI URL/Key required.'));
return;
}
if ( if (
embeddingEngine === 'azure_openai' && embeddingEngine === 'azure_openai' &&
(AzureOpenAIKey === '' || AzureOpenAIUrl === '' || AzureOpenAIVersion === '') (AzureOpenAIKey === '' || AzureOpenAIUrl === '' || AzureOpenAIVersion === '')
@ -731,7 +727,11 @@
required required
/> />
<SensitiveInput placeholder={$i18n.t('API Key')} bind:value={OpenAIKey} /> <SensitiveInput
placeholder={$i18n.t('API Key')}
bind:value={OpenAIKey}
required={false}
/>
</div> </div>
{:else if embeddingEngine === 'ollama'} {:else if embeddingEngine === 'ollama'}
<div class="my-0.5 flex gap-2 pr-2"> <div class="my-0.5 flex gap-2 pr-2">
@ -808,33 +808,7 @@
> >
{#if updateEmbeddingModelLoading} {#if updateEmbeddingModelLoading}
<div class="self-center"> <div class="self-center">
<svg <Spinner />
class=" w-4 h-4"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<style>
.spinner_ajPY {
transform-origin: center;
animation: spinner_AtaB 0.75s infinite linear;
}
@keyframes spinner_AtaB {
100% {
transform: rotate(360deg);
}
}
</style>
<path
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
opacity=".25"
/>
<path
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
class="spinner_ajPY"
/>
</svg>
</div> </div>
{:else} {:else}
<svg <svg
@ -1272,7 +1246,7 @@
</div> </div>
{:else} {:else}
<div class="flex items-center justify-center h-full"> <div class="flex items-center justify-center h-full">
<Spinner /> <Spinner className="size-5" />
</div> </div>
{/if} {/if}
</form> </form>

View file

@ -3,6 +3,7 @@
const i18n = getContext('i18n'); const i18n = getContext('i18n');
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
import Spinner from '$lib/components/common/Spinner.svelte';
import Modal from '$lib/components/common/Modal.svelte'; import Modal from '$lib/components/common/Modal.svelte';
import { models } from '$lib/stores'; import { models } from '$lib/stores';
import Plus from '$lib/components/icons/Plus.svelte'; import Plus from '$lib/components/icons/Plus.svelte';
@ -11,6 +12,7 @@
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import AccessControl from '$lib/components/workspace/common/AccessControl.svelte'; import AccessControl from '$lib/components/workspace/common/AccessControl.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte'; import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
export let show = false; export let show = false;
export let edit = false; export let edit = false;
@ -141,16 +143,7 @@
show = false; show = false;
}} }}
> >
<svg <XMark className={'size-5'} />
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
</button> </button>
</div> </div>
@ -406,29 +399,7 @@
{#if loading} {#if loading}
<div class="ml-2 self-center"> <div class="ml-2 self-center">
<svg <Spinner />
class=" w-4 h-4"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
><style>
.spinner_ajPY {
transform-origin: center;
animation: spinner_AtaB 0.75s infinite linear;
}
@keyframes spinner_AtaB {
100% {
transform: rotate(360deg);
}
}
</style><path
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
opacity=".25"
/><path
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
class="spinner_ajPY"
/></svg
>
</div> </div>
{/if} {/if}
</button> </button>

View file

@ -90,7 +90,9 @@
}; };
onMount(async () => { onMount(async () => {
checkForVersionUpdates(); if ($config?.features?.enable_version_update_check) {
checkForVersionUpdates();
}
await Promise.all([ await Promise.all([
(async () => { (async () => {
@ -137,16 +139,18 @@
v{WEBUI_VERSION} v{WEBUI_VERSION}
</Tooltip> </Tooltip>
<a {#if $config?.features?.enable_version_update_check}
href="https://github.com/open-webui/open-webui/releases/tag/v{version.latest}" <a
target="_blank" href="https://github.com/open-webui/open-webui/releases/tag/v{version.latest}"
> target="_blank"
{updateAvailable === null >
? $i18n.t('Checking for updates...') {updateAvailable === null
: updateAvailable ? $i18n.t('Checking for updates...')
? `(v${version.latest} ${$i18n.t('available!')})` : updateAvailable
: $i18n.t('(latest)')} ? `(v${version.latest} ${$i18n.t('available!')})`
</a> : $i18n.t('(latest)')}
</a>
{/if}
</div> </div>
<button <button
@ -160,15 +164,17 @@
</button> </button>
</div> </div>
<button {#if $config?.features?.enable_version_update_check}
class=" text-xs px-3 py-1.5 bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 transition rounded-lg font-medium" <button
type="button" class=" text-xs px-3 py-1.5 bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 transition rounded-lg font-medium"
on:click={() => { type="button"
checkForVersionUpdates(); on:click={() => {
}} checkForVersionUpdates();
> }}
{$i18n.t('Check for updates')} >
</button> {$i18n.t('Check for updates')}
</button>
{/if}
</div> </div>
</div> </div>

View file

@ -13,9 +13,11 @@
updateConfig, updateConfig,
verifyConfigUrl verifyConfigUrl
} from '$lib/apis/images'; } from '$lib/apis/images';
import Spinner from '$lib/components/common/Spinner.svelte';
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte'; import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
import Switch from '$lib/components/common/Switch.svelte'; import Switch from '$lib/components/common/Switch.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte';
import Textarea from '$lib/components/common/Textarea.svelte';
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
const i18n = getContext('i18n'); const i18n = getContext('i18n');
@ -504,7 +506,7 @@
<div class=" mb-2 text-sm font-medium">{$i18n.t('ComfyUI Workflow')}</div> <div class=" mb-2 text-sm font-medium">{$i18n.t('ComfyUI Workflow')}</div>
{#if config.comfyui.COMFYUI_WORKFLOW} {#if config.comfyui.COMFYUI_WORKFLOW}
<textarea <Textarea
class="w-full rounded-lg mb-1 py-2 px-4 text-xs bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden disabled:text-gray-600 resize-none" class="w-full rounded-lg mb-1 py-2 px-4 text-xs bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden disabled:text-gray-600 resize-none"
rows="10" rows="10"
bind:value={config.comfyui.COMFYUI_WORKFLOW} bind:value={config.comfyui.COMFYUI_WORKFLOW}
@ -533,7 +535,7 @@
/> />
<button <button
class="w-full text-sm font-medium py-2 bg-transparent hover:bg-gray-100 border border-dashed dark:border-gray-850 dark:hover:bg-gray-850 text-center rounded-xl" class="w-full text-sm font-medium py-2 bg-transparent hover:bg-gray-50 border border-dashed border-gray-50 dark:border-gray-850 dark:hover:bg-gray-850 text-center rounded-xl"
type="button" type="button"
on:click={() => { on:click={() => {
document.getElementById('upload-comfyui-workflow-input')?.click(); document.getElementById('upload-comfyui-workflow-input')?.click();
@ -555,10 +557,10 @@
<div class="text-xs flex flex-col gap-1.5"> <div class="text-xs flex flex-col gap-1.5">
{#each requiredWorkflowNodes as node} {#each requiredWorkflowNodes as node}
<div class="flex w-full items-center border dark:border-gray-850 rounded-lg"> <div class="flex w-full items-center">
<div class="shrink-0"> <div class="shrink-0">
<div <div
class=" capitalize line-clamp-1 font-medium px-3 py-1 w-20 text-center rounded-l-lg bg-green-500/10 text-green-700 dark:text-green-200" class=" capitalize line-clamp-1 font-medium px-3 py-1 w-20 text-center bg-green-500/10 text-green-700 dark:text-green-200"
> >
{node.type}{node.type === 'prompt' ? '*' : ''} {node.type}{node.type === 'prompt' ? '*' : ''}
</div> </div>
@ -566,7 +568,7 @@
<div class=""> <div class="">
<Tooltip content="Input Key (e.g. text, unet_name, steps)"> <Tooltip content="Input Key (e.g. text, unet_name, steps)">
<input <input
class="py-1 px-3 w-24 text-xs text-center bg-transparent outline-hidden border-r dark:border-gray-850" class="py-1 px-3 w-24 text-xs text-center bg-transparent outline-hidden border-r border-gray-50 dark:border-gray-850"
placeholder="Key" placeholder="Key"
bind:value={node.key} bind:value={node.key}
required required
@ -580,7 +582,7 @@
placement="top-start" placement="top-start"
> >
<input <input
class="w-full py-1 px-4 rounded-r-lg text-xs bg-transparent outline-hidden" class="w-full py-1 px-4 text-xs bg-transparent outline-hidden"
placeholder="Node Ids" placeholder="Node Ids"
bind:value={node.node_ids} bind:value={node.node_ids}
/> />
@ -711,29 +713,7 @@
{#if loading} {#if loading}
<div class="ml-2 self-center"> <div class="ml-2 self-center">
<svg <Spinner />
class=" w-4 h-4"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
><style>
.spinner_ajPY {
transform-origin: center;
animation: spinner_AtaB 0.75s infinite linear;
}
@keyframes spinner_AtaB {
100% {
transform: rotate(360deg);
}
}
</style><path
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
opacity=".25"
/><path
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
class="spinner_ajPY"
/></svg
>
</div> </div>
{/if} {/if}
</button> </button>

View file

@ -390,7 +390,7 @@
<div class="mb-2.5"> <div class="mb-2.5">
<div class="flex w-full justify-between"> <div class="flex w-full justify-between">
<div class=" self-center text-sm"> <div class=" self-center text-xs">
{$i18n.t('Banners')} {$i18n.t('Banners')}
</div> </div>
@ -432,7 +432,7 @@
{#if $user?.role === 'admin'} {#if $user?.role === 'admin'}
<div class=" space-y-3"> <div class=" space-y-3">
<div class="flex w-full justify-between mb-2"> <div class="flex w-full justify-between mb-2">
<div class=" self-center text-sm"> <div class=" self-center text-xs">
{$i18n.t('Default Prompt Suggestions')} {$i18n.t('Default Prompt Suggestions')}
</div> </div>
@ -636,6 +636,6 @@
</form> </form>
{:else} {:else}
<div class=" h-full w-full flex justify-center items-center"> <div class=" h-full w-full flex justify-center items-center">
<Spinner /> <Spinner className="size-5" />
</div> </div>
{/if} {/if}

View file

@ -1,7 +1,9 @@
<script lang="ts"> <script lang="ts">
import Switch from '$lib/components/common/Switch.svelte'; import Switch from '$lib/components/common/Switch.svelte';
import Textarea from '$lib/components/common/Textarea.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte';
import EllipsisVertical from '$lib/components/icons/EllipsisVertical.svelte'; import EllipsisVertical from '$lib/components/icons/EllipsisVertical.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import Sortable from 'sortablejs'; import Sortable from 'sortablejs';
import { getContext } from 'svelte'; import { getContext } from 'svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
@ -23,6 +25,13 @@
}); });
}; };
const classNames: Record<string, string> = {
info: 'bg-blue-500/20 text-blue-700 dark:text-blue-200 ',
success: 'bg-green-500/20 text-green-700 dark:text-green-200',
warning: 'bg-yellow-500/20 text-yellow-700 dark:text-yellow-200',
error: 'bg-red-500/20 text-red-700 dark:text-red-200'
};
$: if (banners) { $: if (banners) {
init(); init();
} }
@ -44,14 +53,14 @@
}; };
</script> </script>
<div class=" flex flex-col space-y-0.5" bind:this={bannerListElement}> <div class=" flex flex-col gap-3 {banners?.length > 0 ? 'mt-2' : ''}" bind:this={bannerListElement}>
{#each banners as banner, bannerIdx (banner.id)} {#each banners as banner, bannerIdx (banner.id)}
<div class=" flex justify-between items-center -ml-1" id="banner-item-{banner.id}"> <div class=" flex justify-between items-start -ml-1" id="banner-item-{banner.id}">
<EllipsisVertical className="size-4 cursor-move item-handle" /> <EllipsisVertical className="size-4 cursor-move item-handle" />
<div class="flex flex-row flex-1 gap-2 items-center"> <div class="flex flex-row flex-1 gap-2 items-start">
<select <select
class="w-fit capitalize rounded-xl text-xs bg-transparent outline-hidden text-left pl-1 pr-2" class="w-fit capitalize rounded-xl text-xs bg-transparent outline-hidden pl-1 pr-5"
bind:value={banner.type} bind:value={banner.type}
required required
> >
@ -64,10 +73,11 @@
<option value="success" class="text-gray-900">{$i18n.t('Success')}</option> <option value="success" class="text-gray-900">{$i18n.t('Success')}</option>
</select> </select>
<input <Textarea
class="pr-5 py-1.5 text-xs w-full bg-transparent outline-hidden" className="mr-2 text-xs w-full bg-transparent outline-hidden resize-none"
placeholder={$i18n.t('Content')} placeholder={$i18n.t('Content')}
bind:value={banner.content} bind:value={banner.content}
maxSize={100}
/> />
<div class="relative -left-2"> <div class="relative -left-2">
@ -85,16 +95,7 @@
banners = banners; banners = banners;
}} }}
> >
<svg <XMark className={'size-4'} />
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
</button> </button>
</div> </div>
{/each} {/each}

View file

@ -563,6 +563,6 @@
{/if} {/if}
{:else} {:else}
<div class=" h-full w-full flex justify-center items-center"> <div class=" h-full w-full flex justify-center items-center">
<Spinner /> <Spinner className="size-5" />
</div> </div>
{/if} {/if}

View file

@ -18,6 +18,7 @@
import Plus from '$lib/components/icons/Plus.svelte'; import Plus from '$lib/components/icons/Plus.svelte';
import ChevronUp from '$lib/components/icons/ChevronUp.svelte'; import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte'; import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
export let show = false; export let show = false;
export let initHandler = () => {}; export let initHandler = () => {};
@ -129,16 +130,7 @@
show = false; show = false;
}} }}
> >
<svg <XMark className={'size-5'} />
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
</button> </button>
</div> </div>
@ -278,29 +270,7 @@
{#if loading} {#if loading}
<div class="ml-2 self-center"> <div class="ml-2 self-center">
<svg <Spinner />
class=" w-4 h-4"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
><style>
.spinner_ajPY {
transform-origin: center;
animation: spinner_AtaB 0.75s infinite linear;
}
@keyframes spinner_AtaB {
100% {
transform: rotate(360deg);
}
}
</style><path
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
opacity=".25"
/><path
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
class="spinner_ajPY"
/></svg
>
</div> </div>
{/if} {/if}
</button> </button>
@ -308,7 +278,7 @@
</form> </form>
{:else} {:else}
<div> <div>
<Spinner /> <Spinner className="size-5" />
</div> </div>
{/if} {/if}
</div> </div>

View file

@ -1057,6 +1057,6 @@
</div> </div>
{:else} {:else}
<div class="flex justify-center items-center w-full h-full py-3"> <div class="flex justify-center items-center w-full h-full py-3">
<Spinner /> <Spinner className="size-5" />
</div> </div>
{/if} {/if}

View file

@ -7,6 +7,7 @@
import { user } from '$lib/stores'; import { user } from '$lib/stores';
import XMark from '$lib/components/icons/XMark.svelte';
import Modal from '$lib/components/common/Modal.svelte'; import Modal from '$lib/components/common/Modal.svelte';
import ManageOllama from './Manage/ManageOllama.svelte'; import ManageOllama from './Manage/ManageOllama.svelte';
import { getOllamaConfig } from '$lib/apis/ollama'; import { getOllamaConfig } from '$lib/apis/ollama';
@ -48,16 +49,7 @@
show = false; show = false;
}} }}
> >
<svg <XMark className={'size-5'} />
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
</button> </button>
</div> </div>
@ -75,7 +67,7 @@
class="flex gap-1 scrollbar-none overflow-x-auto w-fit text-center text-sm font-medium rounded-full bg-transparent dark:text-gray-200" class="flex gap-1 scrollbar-none overflow-x-auto w-fit text-center text-sm font-medium rounded-full bg-transparent dark:text-gray-200"
> >
<button <button
class="min-w-fit rounded-full p-1.5 {selected === 'ollama' class="min-w-fit p-1.5 {selected === 'ollama'
? '' ? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition" : 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
on:click={() => { on:click={() => {
@ -84,7 +76,7 @@
> >
<!-- <button <!-- <button
class="min-w-fit rounded-full p-1.5 {selected === 'llamacpp' class="min-w-fit p-1.5 {selected === 'llamacpp'
? '' ? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition" : 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
on:click={() => { on:click={() => {

View file

@ -141,6 +141,7 @@
placeholder={$i18n.t('Enter Searxng Query URL')} placeholder={$i18n.t('Enter Searxng Query URL')}
bind:value={webConfig.SEARXNG_QUERY_URL} bind:value={webConfig.SEARXNG_QUERY_URL}
autocomplete="off" autocomplete="off"
required
/> />
</div> </div>
</div> </div>
@ -248,7 +249,6 @@
bind:value={webConfig.KAGI_SEARCH_API_KEY} bind:value={webConfig.KAGI_SEARCH_API_KEY}
/> />
</div> </div>
.
</div> </div>
{:else if webConfig.WEB_SEARCH_ENGINE === 'mojeek'} {:else if webConfig.WEB_SEARCH_ENGINE === 'mojeek'}
<div class="mb-2.5 flex w-full flex-col"> <div class="mb-2.5 flex w-full flex-col">

View file

@ -16,6 +16,7 @@
import UsersSolid from '$lib/components/icons/UsersSolid.svelte'; import UsersSolid from '$lib/components/icons/UsersSolid.svelte';
import ChevronRight from '$lib/components/icons/ChevronRight.svelte'; import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte'; import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte';
import Search from '$lib/components/icons/Search.svelte';
import User from '$lib/components/icons/User.svelte'; import User from '$lib/components/icons/User.svelte';
import UserCircleSolid from '$lib/components/icons/UserCircleSolid.svelte'; import UserCircleSolid from '$lib/components/icons/UserCircleSolid.svelte';
import GroupModal from './Groups/EditGroupModal.svelte'; import GroupModal from './Groups/EditGroupModal.svelte';
@ -159,18 +160,7 @@
<div class=" flex w-full space-x-2"> <div class=" flex w-full space-x-2">
<div class="flex flex-1"> <div class="flex flex-1">
<div class=" self-center ml-1 mr-3"> <div class=" self-center ml-1 mr-3">
<svg <Search />
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z"
clip-rule="evenodd"
/>
</svg>
</div> </div>
<input <input
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent" class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"

View file

@ -3,8 +3,10 @@
import { getContext, onMount } from 'svelte'; import { getContext, onMount } from 'svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
import Spinner from '$lib/components/common/Spinner.svelte';
import Modal from '$lib/components/common/Modal.svelte'; import Modal from '$lib/components/common/Modal.svelte';
import Textarea from '$lib/components/common/Textarea.svelte'; import Textarea from '$lib/components/common/Textarea.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
export let onSubmit: Function = () => {}; export let onSubmit: Function = () => {};
export let show = false; export let show = false;
@ -45,16 +47,7 @@
show = false; show = false;
}} }}
> >
<svg <XMark className={'size-5'} />
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
</button> </button>
</div> </div>
@ -111,29 +104,7 @@
{#if loading} {#if loading}
<div class="ml-2 self-center"> <div class="ml-2 self-center">
<svg <Spinner />
class=" w-4 h-4"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
><style>
.spinner_ajPY {
transform-origin: center;
animation: spinner_AtaB 0.75s infinite linear;
}
@keyframes spinner_AtaB {
100% {
transform: rotate(360deg);
}
}
</style><path
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
opacity=".25"
/><path
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
class="spinner_ajPY"
/></svg
>
</div> </div>
{/if} {/if}
</button> </button>

View file

@ -3,6 +3,7 @@
import { getContext, onMount } from 'svelte'; import { getContext, onMount } from 'svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
import Spinner from '$lib/components/common/Spinner.svelte';
import Modal from '$lib/components/common/Modal.svelte'; import Modal from '$lib/components/common/Modal.svelte';
import Display from './Display.svelte'; import Display from './Display.svelte';
import Permissions from './Permissions.svelte'; import Permissions from './Permissions.svelte';
@ -10,6 +11,7 @@
import UserPlusSolid from '$lib/components/icons/UserPlusSolid.svelte'; import UserPlusSolid from '$lib/components/icons/UserPlusSolid.svelte';
import WrenchSolid from '$lib/components/icons/WrenchSolid.svelte'; import WrenchSolid from '$lib/components/icons/WrenchSolid.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte'; import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
export let onSubmit: Function = () => {}; export let onSubmit: Function = () => {};
export let onDelete: Function = () => {}; export let onDelete: Function = () => {};
@ -124,16 +126,7 @@
show = false; show = false;
}} }}
> >
<svg <XMark className={'size-5'} />
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
</button> </button>
</div> </div>
@ -305,29 +298,7 @@
{#if loading} {#if loading}
<div class="ml-2 self-center"> <div class="ml-2 self-center">
<svg <Spinner />
class=" w-4 h-4"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
><style>
.spinner_ajPY {
transform-origin: center;
animation: spinner_AtaB 0.75s infinite linear;
}
@keyframes spinner_AtaB {
100% {
transform: rotate(360deg);
}
}
</style><path
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
opacity=".25"
/><path
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
class="spinner_ajPY"
/></svg
>
</div> </div>
{/if} {/if}
</button> </button>

View file

@ -7,6 +7,7 @@
import { WEBUI_BASE_URL } from '$lib/constants'; import { WEBUI_BASE_URL } from '$lib/constants';
import Checkbox from '$lib/components/common/Checkbox.svelte'; import Checkbox from '$lib/components/common/Checkbox.svelte';
import Badge from '$lib/components/common/Badge.svelte'; import Badge from '$lib/components/common/Badge.svelte';
import Search from '$lib/components/icons/Search.svelte';
export let users = []; export let users = [];
export let userIds = []; export let userIds = [];
@ -50,18 +51,7 @@
<div class="flex w-full"> <div class="flex w-full">
<div class="flex flex-1"> <div class="flex flex-1">
<div class=" self-center mr-3"> <div class=" self-center mr-3">
<svg <Search />
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z"
clip-rule="evenodd"
/>
</svg>
</div> </div>
<input <input
class=" w-full text-sm pr-4 rounded-r-xl outline-hidden bg-transparent" class=" w-full text-sm pr-4 rounded-r-xl outline-hidden bg-transparent"

View file

@ -151,7 +151,7 @@
{#if users === null || total === null} {#if users === null || total === null}
<div class="my-10"> <div class="my-10">
<Spinner /> <Spinner className="size-5" />
</div> </div>
{:else} {:else}
<div class="mt-0.5 mb-2 gap-1 flex flex-col md:flex-row justify-between"> <div class="mt-0.5 mb-2 gap-1 flex flex-col md:flex-row justify-between">

View file

@ -6,8 +6,10 @@
import { WEBUI_BASE_URL } from '$lib/constants'; import { WEBUI_BASE_URL } from '$lib/constants';
import Spinner from '$lib/components/common/Spinner.svelte';
import Modal from '$lib/components/common/Modal.svelte'; import Modal from '$lib/components/common/Modal.svelte';
import { generateInitialsImage } from '$lib/utils'; import { generateInitialsImage } from '$lib/utils';
import XMark from '$lib/components/icons/XMark.svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
@ -132,16 +134,7 @@
show = false; show = false;
}} }}
> >
<svg <XMark className={'size-5'} />
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
</button> </button>
</div> </div>
@ -157,7 +150,7 @@
class="flex -mt-2 mb-1.5 gap-1 scrollbar-none overflow-x-auto w-fit text-center text-sm font-medium rounded-full bg-transparent dark:text-gray-200" class="flex -mt-2 mb-1.5 gap-1 scrollbar-none overflow-x-auto w-fit text-center text-sm font-medium rounded-full bg-transparent dark:text-gray-200"
> >
<button <button
class="min-w-fit rounded-full p-1.5 {tab === '' class="min-w-fit p-1.5 {tab === ''
? '' ? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition" : 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
type="button" type="button"
@ -167,7 +160,7 @@
> >
<button <button
class="min-w-fit rounded-full p-1.5 {tab === 'import' class="min-w-fit p-1.5 {tab === 'import'
? '' ? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition" : 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
type="button" type="button"
@ -293,29 +286,7 @@
{#if loading} {#if loading}
<div class="ml-2 self-center"> <div class="ml-2 self-center">
<svg <Spinner />
class=" w-4 h-4"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
><style>
.spinner_ajPY {
transform-origin: center;
animation: spinner_AtaB 0.75s infinite linear;
}
@keyframes spinner_AtaB {
100% {
transform: rotate(360deg);
}
}
</style><path
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
opacity=".25"
/><path
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
class="spinner_ajPY"
/></svg
>
</div> </div>
{/if} {/if}
</button> </button>

View file

@ -8,6 +8,7 @@
import Modal from '$lib/components/common/Modal.svelte'; import Modal from '$lib/components/common/Modal.svelte';
import localizedFormat from 'dayjs/plugin/localizedFormat'; import localizedFormat from 'dayjs/plugin/localizedFormat';
import XMark from '$lib/components/icons/XMark.svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
@ -54,16 +55,7 @@
show = false; show = false;
}} }}
> >
<svg <XMark className={'size-5'} />
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
</button> </button>
</div> </div>

View file

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import heic2any from 'heic2any';
import { tick, getContext, onMount, onDestroy } from 'svelte'; import { tick, getContext, onMount, onDestroy } from 'svelte';
@ -78,7 +79,7 @@
}; };
const inputFilesHandler = async (inputFiles) => { const inputFilesHandler = async (inputFiles) => {
inputFiles.forEach((file) => { inputFiles.forEach(async (file) => {
console.info('Processing file:', { console.info('Processing file:', {
name: file.name, name: file.name,
type: file.type, type: file.type,
@ -102,43 +103,50 @@
return; return;
} }
if ( if (file['type'].startsWith('image/')) {
['image/gif', 'image/webp', 'image/jpeg', 'image/png', 'image/avif'].includes(file['type']) const compressImageHandler = async (imageUrl, settings = {}, config = {}) => {
) { // Quick shortcut so we dont do unnecessary work.
const settingsCompression = settings?.imageCompression ?? false;
const configWidth = config?.file?.image_compression?.width ?? null;
const configHeight = config?.file?.image_compression?.height ?? null;
// If neither settings nor config wants compression, return original URL.
if (!settingsCompression && !configWidth && !configHeight) {
return imageUrl;
}
// Default to null (no compression unless set)
let width = null;
let height = null;
// If user/settings want compression, pick their preferred size.
if (settingsCompression) {
width = settings?.imageCompressionSize?.width ?? null;
height = settings?.imageCompressionSize?.height ?? null;
}
// Apply config limits as an upper bound if any
if (configWidth && (width === null || width > configWidth)) {
width = configWidth;
}
if (configHeight && (height === null || height > configHeight)) {
height = configHeight;
}
// Do the compression if required
if (width || height) {
return await compressImage(imageUrl, width, height);
}
return imageUrl;
};
let reader = new FileReader(); let reader = new FileReader();
reader.onload = async (event) => { reader.onload = async (event) => {
let imageUrl = event.target.result; let imageUrl = event.target.result;
if ( // Compress the image if settings or config require it
($settings?.imageCompression ?? false) || imageUrl = await compressImageHandler(imageUrl, $settings, $config);
($config?.file?.image_compression?.width ?? null) ||
($config?.file?.image_compression?.height ?? null)
) {
let width = null;
let height = null;
if ($settings?.imageCompression ?? false) {
width = $settings?.imageCompressionSize?.width ?? null;
height = $settings?.imageCompressionSize?.height ?? null;
}
if (
($config?.file?.image_compression?.width ?? null) ||
($config?.file?.image_compression?.height ?? null)
) {
if (width > ($config?.file?.image_compression?.width ?? null)) {
width = $config?.file?.image_compression?.width ?? null;
}
if (height > ($config?.file?.image_compression?.height ?? null)) {
height = $config?.file?.image_compression?.height ?? null;
}
}
if (width || height) {
imageUrl = await compressImage(imageUrl, width, height);
}
}
files = [ files = [
...files, ...files,
@ -149,7 +157,11 @@
]; ];
}; };
reader.readAsDataURL(file); reader.readAsDataURL(
file['type'] === 'image/heic'
? await heic2any({ blob: file, toType: 'image/jpeg' })
: file
);
} else { } else {
uploadFileHandler(file); uploadFileHandler(file);
} }

View file

@ -55,10 +55,7 @@
import { generateChatCompletion } from '$lib/apis/ollama'; import { generateChatCompletion } from '$lib/apis/ollama';
import { import {
addTagById,
createNewChat, createNewChat,
deleteTagById,
deleteTagsById,
getAllTags, getAllTags,
getChatById, getChatById,
getChatList, getChatList,
@ -708,6 +705,10 @@
////////////////////////// //////////////////////////
const initNewChat = async () => { const initNewChat = async () => {
if ($user?.role !== 'admin' && $user?.permissions?.chat?.temporary_enforced) {
await temporaryChatEnabled.set(true);
}
const availableModels = $models const availableModels = $models
.filter((m) => !(m?.info?.meta?.hidden ?? false)) .filter((m) => !(m?.info?.meta?.hidden ?? false))
.map((m) => m.id); .map((m) => m.id);
@ -835,8 +836,10 @@
prompt = $page.url.searchParams.get('q') ?? ''; prompt = $page.url.searchParams.get('q') ?? '';
if (prompt) { if (prompt) {
await tick(); if (($page.url.searchParams.get('submit') ?? 'true') === 'true') {
submitPrompt(prompt); await tick();
submitPrompt(prompt);
}
} }
} }
@ -2227,7 +2230,7 @@
{: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">
<Spinner /> <Spinner className="size-5" />
</div> </div>
</div> </div>
{/if} {/if}

View file

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import DOMPurify from 'dompurify'; import DOMPurify from 'dompurify';
import { marked } from 'marked'; import { marked } from 'marked';
import heic2any from 'heic2any';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
@ -320,7 +321,7 @@
return; return;
} }
inputFiles.forEach((file) => { inputFiles.forEach(async (file) => {
console.log('Processing file:', { console.log('Processing file:', {
name: file.name, name: file.name,
type: file.type, type: file.type,
@ -344,46 +345,53 @@
return; return;
} }
if ( if (file['type'].startsWith('image/')) {
['image/gif', 'image/webp', 'image/jpeg', 'image/png', 'image/avif'].includes(file['type'])
) {
if (visionCapableModels.length === 0) { if (visionCapableModels.length === 0) {
toast.error($i18n.t('Selected model(s) do not support image inputs')); toast.error($i18n.t('Selected model(s) do not support image inputs'));
return; return;
} }
const compressImageHandler = async (imageUrl, settings = {}, config = {}) => {
// Quick shortcut so we dont do unnecessary work.
const settingsCompression = settings?.imageCompression ?? false;
const configWidth = config?.file?.image_compression?.width ?? null;
const configHeight = config?.file?.image_compression?.height ?? null;
// If neither settings nor config wants compression, return original URL.
if (!settingsCompression && !configWidth && !configHeight) {
return imageUrl;
}
// Default to null (no compression unless set)
let width = null;
let height = null;
// If user/settings want compression, pick their preferred size.
if (settingsCompression) {
width = settings?.imageCompressionSize?.width ?? null;
height = settings?.imageCompressionSize?.height ?? null;
}
// Apply config limits as an upper bound if any
if (configWidth && (width === null || width > configWidth)) {
width = configWidth;
}
if (configHeight && (height === null || height > configHeight)) {
height = configHeight;
}
// Do the compression if required
if (width || height) {
return await compressImage(imageUrl, width, height);
}
return imageUrl;
};
let reader = new FileReader(); let reader = new FileReader();
reader.onload = async (event) => { reader.onload = async (event) => {
let imageUrl = event.target.result; let imageUrl = event.target.result;
if ( imageUrl = await compressImageHandler(imageUrl, $settings, $config);
($settings?.imageCompression ?? false) ||
($config?.file?.image_compression?.width ?? null) ||
($config?.file?.image_compression?.height ?? null)
) {
let width = null;
let height = null;
if ($settings?.imageCompression ?? false) {
width = $settings?.imageCompressionSize?.width ?? null;
height = $settings?.imageCompressionSize?.height ?? null;
}
if (
($config?.file?.image_compression?.width ?? null) ||
($config?.file?.image_compression?.height ?? null)
) {
if (width > ($config?.file?.image_compression?.width ?? null)) {
width = $config?.file?.image_compression?.width ?? null;
}
if (height > ($config?.file?.image_compression?.height ?? null)) {
height = $config?.file?.image_compression?.height ?? null;
}
}
if (width || height) {
imageUrl = await compressImage(imageUrl, width, height);
}
}
files = [ files = [
...files, ...files,
@ -393,7 +401,11 @@
} }
]; ];
}; };
reader.readAsDataURL(file); reader.readAsDataURL(
file['type'] === 'image/heic'
? await heic2any({ blob: file, toType: 'image/jpeg' })
: file
);
} else { } else {
uploadFileHandler(file); uploadFileHandler(file);
} }
@ -659,7 +671,7 @@
<div class="relative flex items-center"> <div class="relative flex items-center">
<Image <Image
src={file.url} src={file.url}
alt="input" alt=""
imageClassName=" size-14 rounded-xl object-cover" imageClassName=" size-14 rounded-xl object-cover"
/> />
{#if atSelectedModel ? visionCapableModels.length === 0 : selectedModels.length !== visionCapableModels.length} {#if atSelectedModel ? visionCapableModels.length === 0 : selectedModels.length !== visionCapableModels.length}
@ -677,6 +689,7 @@
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="currentColor" fill="currentColor"
aria-hidden="true"
class="size-4 fill-yellow-300" class="size-4 fill-yellow-300"
> >
<path <path
@ -690,8 +703,12 @@
</div> </div>
<div class=" absolute -top-1 -right-1"> <div class=" absolute -top-1 -right-1">
<button <button
class=" bg-white text-black border border-white rounded-full group-hover:visible invisible transition" class=" bg-white text-black border border-white rounded-full {($settings?.highContrastMode ??
false)
? ''
: 'outline-hidden focus:outline-hidden group-hover:visible invisible transition'}"
type="button" type="button"
aria-label={$i18n.t('Remove file')}
on:click={() => { on:click={() => {
files.splice(fileIdx, 1); files.splice(fileIdx, 1);
files = files; files = files;
@ -701,6 +718,7 @@
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20" viewBox="0 0 20 20"
fill="currentColor" fill="currentColor"
aria-hidden="true"
class="size-4" class="size-4"
> >
<path <path
@ -1253,11 +1271,12 @@
<button <button
class="bg-transparent hover:bg-gray-100 text-gray-800 dark:text-white dark:hover:bg-gray-800 transition rounded-full p-1.5 outline-hidden focus:outline-hidden" class="bg-transparent hover:bg-gray-100 text-gray-800 dark:text-white dark:hover:bg-gray-800 transition rounded-full p-1.5 outline-hidden focus:outline-hidden"
type="button" type="button"
aria-label="More" aria-label={$i18n.t('More Available Tools')}
> >
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20" viewBox="0 0 20 20"
aria-hidden="true"
fill="currentColor" fill="currentColor"
class="size-5" class="size-5"
> >
@ -1379,6 +1398,10 @@
{#if showCodeInterpreterButton} {#if showCodeInterpreterButton}
<Tooltip content={$i18n.t('Execute code for analysis')} placement="top"> <Tooltip content={$i18n.t('Execute code for analysis')} placement="top">
<button <button
aria-label={codeInterpreterEnabled
? $i18n.t('Disable Code Interpreter')
: $i18n.t('Enable Code Interpreter')}
aria-pressed={codeInterpreterEnabled}
on:click|preventDefault={() => on:click|preventDefault={() =>
(codeInterpreterEnabled = !codeInterpreterEnabled)} (codeInterpreterEnabled = !codeInterpreterEnabled)}
type="button" type="button"
@ -1530,7 +1553,7 @@
); );
} }
}} }}
aria-label="Call" aria-label={$i18n.t('Voice mode')}
> >
<Headphone className="size-5" /> <Headphone className="size-5" />
</button> </button>

View file

@ -854,6 +854,7 @@
</button> </button>
{:else} {:else}
<div class="relative flex video-container w-full max-h-full pt-2 pb-4 md:py-6 px-2 h-full"> <div class="relative flex video-container w-full max-h-full pt-2 pb-4 md:py-6 px-2 h-full">
<!-- svelte-ignore a11y-media-has-caption -->
<video <video
id="camera-feed" id="camera-feed"
autoplay autoplay

View file

@ -6,7 +6,7 @@
import relativeTime from 'dayjs/plugin/relativeTime'; import relativeTime from 'dayjs/plugin/relativeTime';
dayjs.extend(relativeTime); dayjs.extend(relativeTime);
import { createEventDispatcher, tick, getContext, onMount } from 'svelte'; import { createEventDispatcher, tick, getContext, onMount, onDestroy } from 'svelte';
import { removeLastWordFromString, isValidHttpUrl } from '$lib/utils'; import { removeLastWordFromString, isValidHttpUrl } from '$lib/utils';
import { knowledge } from '$lib/stores'; import { knowledge } from '$lib/stores';
@ -42,6 +42,24 @@
selectedIdx = Math.min(selectedIdx + 1, filteredItems.length - 1); selectedIdx = Math.min(selectedIdx + 1, filteredItems.length - 1);
}; };
let container;
let adjustHeightDebounce;
const adjustHeight = () => {
if (container) {
if (adjustHeightDebounce) {
clearTimeout(adjustHeightDebounce);
}
adjustHeightDebounce = setTimeout(() => {
if (!container) return;
// Ensure the container is visible before adjusting height
const rect = container.getBoundingClientRect();
container.style.maxHeight = Math.max(Math.min(240, rect.bottom - 100), 100) + 'px';
}, 100);
}
};
const confirmSelect = async (item) => { const confirmSelect = async (item) => {
dispatch('select', item); dispatch('select', item);
@ -75,7 +93,18 @@
await tick(); await tick();
}; };
const decodeString = (str: string) => {
try {
return decodeURIComponent(str);
} catch (e) {
return str;
}
};
onMount(() => { onMount(() => {
window.addEventListener('resize', adjustHeight);
adjustHeight();
let legacy_documents = $knowledge let legacy_documents = $knowledge
.filter((item) => item?.meta?.document) .filter((item) => item?.meta?.document)
.map((item) => ({ .map((item) => ({
@ -155,13 +184,9 @@
}); });
}); });
const decodeString = (str: string) => { onDestroy(() => {
try { window.removeEventListener('resize', adjustHeight);
return decodeURIComponent(str); });
} catch (e) {
return str;
}
};
</script> </script>
{#if filteredItems.length > 0 || prompt.split(' ')?.at(0)?.substring(1).startsWith('http')} {#if filteredItems.length > 0 || prompt.split(' ')?.at(0)?.substring(1).startsWith('http')}
@ -174,6 +199,7 @@
<div <div
class="m-1 overflow-y-auto p-1 rounded-r-xl space-y-0.5 scrollbar-hidden max-h-60" class="m-1 overflow-y-auto p-1 rounded-r-xl space-y-0.5 scrollbar-hidden max-h-60"
id="command-options-container" id="command-options-container"
bind:this={container}
> >
{#each filteredItems as item, idx} {#each filteredItems as item, idx}
<button <button

View file

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import Fuse from 'fuse.js'; import Fuse from 'fuse.js';
import { createEventDispatcher, onMount } from 'svelte'; import { createEventDispatcher, onDestroy, onMount } from 'svelte';
import { tick, getContext } from 'svelte'; import { tick, getContext } from 'svelte';
import { models } from '$lib/stores'; import { models } from '$lib/stores';
@ -51,18 +51,44 @@
selectedIdx = Math.min(selectedIdx + 1, filteredItems.length - 1); selectedIdx = Math.min(selectedIdx + 1, filteredItems.length - 1);
}; };
let container;
let adjustHeightDebounce;
const adjustHeight = () => {
if (container) {
if (adjustHeightDebounce) {
clearTimeout(adjustHeightDebounce);
}
adjustHeightDebounce = setTimeout(() => {
if (!container) return;
// Ensure the container is visible before adjusting height
const rect = container.getBoundingClientRect();
container.style.maxHeight = Math.max(Math.min(240, rect.bottom - 100), 100) + 'px';
}, 100);
}
};
const confirmSelect = async (model) => { const confirmSelect = async (model) => {
command = ''; command = '';
dispatch('select', model); dispatch('select', model);
}; };
onMount(async () => { onMount(async () => {
window.addEventListener('resize', adjustHeight);
adjustHeight();
await tick(); await tick();
const chatInputElement = document.getElementById('chat-input'); const chatInputElement = document.getElementById('chat-input');
await tick(); await tick();
chatInputElement?.focus(); chatInputElement?.focus();
await tick(); await tick();
}); });
onDestroy(() => {
window.removeEventListener('resize', adjustHeight);
});
</script> </script>
{#if filteredItems.length > 0} {#if filteredItems.length > 0}
@ -75,6 +101,7 @@
<div <div
class="m-1 overflow-y-auto p-1 rounded-r-lg space-y-0.5 scrollbar-hidden max-h-60" class="m-1 overflow-y-auto p-1 rounded-r-lg space-y-0.5 scrollbar-hidden max-h-60"
id="command-options-container" id="command-options-container"
bind:this={container}
> >
{#each filteredItems as model, modelIdx} {#each filteredItems as model, modelIdx}
<button <button

View file

@ -9,7 +9,7 @@
getUserTimezone, getUserTimezone,
getWeekday getWeekday
} from '$lib/utils'; } from '$lib/utils';
import { tick, getContext } from 'svelte'; import { tick, getContext, onMount, onDestroy } from 'svelte';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
@ -38,6 +38,25 @@
selectedPromptIdx = Math.min(selectedPromptIdx + 1, filteredPrompts.length - 1); selectedPromptIdx = Math.min(selectedPromptIdx + 1, filteredPrompts.length - 1);
}; };
let container;
let adjustHeightDebounce;
const adjustHeight = () => {
if (container) {
if (adjustHeightDebounce) {
clearTimeout(adjustHeightDebounce);
}
adjustHeightDebounce = setTimeout(() => {
if (!container) return;
// Ensure the container is visible before adjusting height
const rect = container.getBoundingClientRect();
container.style.maxHeight = Math.max(Math.min(240, rect.bottom - 100), 100) + 'px';
}, 100);
}
};
const confirmPrompt = async (command) => { const confirmPrompt = async (command) => {
let text = command.content; let text = command.content;
@ -156,22 +175,30 @@
if (words.length > 0) { if (words.length > 0) {
const word = words.at(0); const word = words.at(0);
const fullPrompt = prompt;
prompt = prompt.substring(0, word?.endIndex + 1);
await tick(); await tick();
chatInputElement.scrollTop = chatInputElement.scrollHeight; if (!($settings?.richTextInput ?? true)) {
chatInputElement.setSelectionRange(word.startIndex, word.endIndex + 1);
chatInputElement.focus();
prompt = fullPrompt; // This is a workaround to ensure the cursor is placed correctly
await tick(); // after the text is inserted, especially for multiline inputs.
chatInputElement.setSelectionRange(word.startIndex, word.endIndex + 1);
chatInputElement.setSelectionRange(word?.startIndex, word.endIndex + 1); }
} else { } else {
chatInputElement.scrollTop = chatInputElement.scrollHeight; chatInputElement.scrollTop = chatInputElement.scrollHeight;
} }
} }
}; };
onMount(() => {
window.addEventListener('resize', adjustHeight);
adjustHeight();
});
onDestroy(() => {
window.removeEventListener('resize', adjustHeight);
});
</script> </script>
{#if filteredPrompts.length > 0} {#if filteredPrompts.length > 0}
@ -184,6 +211,7 @@
<div <div
class="m-1 overflow-y-auto p-1 space-y-0.5 scrollbar-hidden max-h-60" class="m-1 overflow-y-auto p-1 space-y-0.5 scrollbar-hidden max-h-60"
id="command-options-container" id="command-options-container"
bind:this={container}
> >
{#each filteredPrompts as prompt, promptIdx} {#each filteredPrompts as prompt, promptIdx}
<button <button

View file

@ -5,6 +5,7 @@
import { blobToFile, calculateSHA256, extractCurlyBraceWords } from '$lib/utils'; import { blobToFile, calculateSHA256, extractCurlyBraceWords } from '$lib/utils';
import { transcribeAudio } from '$lib/apis/audio'; import { transcribeAudio } from '$lib/apis/audio';
import XMark from '$lib/components/icons/XMark.svelte';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import LocalizedFormat from 'dayjs/plugin/localizedFormat'; import LocalizedFormat from 'dayjs/plugin/localizedFormat';
@ -406,16 +407,7 @@
onCancel(); onCancel();
}} }}
> >
<svg <XMark className={'size-4'} />
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="3"
stroke="currentColor"
class="size-4"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button> </button>
</div> </div>

View file

@ -438,6 +438,7 @@
<Message <Message
{chatId} {chatId}
bind:history bind:history
{selectedModels}
messageId={message.id} messageId={message.id}
idx={messageIdx} idx={messageIdx}
{user} {user}

View file

@ -43,7 +43,6 @@
} }
$: { $: {
console.log('sources', sources);
citations = sources.reduce((acc, source) => { citations = sources.reduce((acc, source) => {
if (Object.keys(source).length === 0) { if (Object.keys(source).length === 0) {
return acc; return acc;

View file

@ -4,6 +4,8 @@
import Tooltip from '$lib/components/common/Tooltip.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte';
import { WEBUI_API_BASE_URL } from '$lib/constants'; import { WEBUI_API_BASE_URL } from '$lib/constants';
import XMark from '$lib/components/icons/XMark.svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
export let show = false; export let show = false;
@ -67,16 +69,7 @@
show = false; show = false;
}} }}
> >
<svg <XMark className={'size-5'} />
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
</button> </button>
</div> </div>

View file

@ -4,6 +4,7 @@
import Modal from '$lib/components/common/Modal.svelte'; import Modal from '$lib/components/common/Modal.svelte';
import Spinner from '$lib/components/common/Spinner.svelte'; import Spinner from '$lib/components/common/Spinner.svelte';
import Badge from '$lib/components/common/Badge.svelte'; import Badge from '$lib/components/common/Badge.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
export let show = false; export let show = false;
@ -49,16 +50,7 @@
codeExecution = null; codeExecution = null;
}} }}
> >
<svg <XMark className={'size-5'} />
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
</button> </button>
</div> </div>

View file

@ -18,6 +18,8 @@
export let id; export let id;
export let content; export let content;
export let history; export let history;
export let selectedModels = [];
export let model = null; export let model = null;
export let sources = null; export let sources = null;
@ -25,11 +27,10 @@
export let preview = false; export let preview = false;
export let floatingButtons = true; export let floatingButtons = true;
export let onSave = () => {}; export let onSave = (e) => {};
export let onSourceClick = () => {}; export let onSourceClick = (e) => {};
export let onTaskClick = () => {}; export let onTaskClick = (e) => {};
export let onAddMessages = (e) => {};
export let onAddMessages = () => {};
let contentContainerElement; let contentContainerElement;
@ -192,7 +193,11 @@
<FloatingButtons <FloatingButtons
bind:this={floatingButtonsElement} bind:this={floatingButtonsElement}
{id} {id}
model={model?.id} model={(selectedModels ?? []).includes(model?.id)
? model?.id
: (selectedModels ?? []).length > 0
? selectedModels.at(0)
: model?.id}
messages={createMessagesList(history, id)} messages={createMessagesList(history, id)}
onAdd={({ modelId, parentId, messages }) => { onAdd={({ modelId, parentId, messages }) => {
console.log(modelId, parentId, messages); console.log(modelId, parentId, messages);

View file

@ -13,6 +13,7 @@
import UserMessage from './UserMessage.svelte'; import UserMessage from './UserMessage.svelte';
export let chatId; export let chatId;
export let selectedModels = [];
export let idx = 0; export let idx = 0;
export let history; export let history;
@ -70,6 +71,7 @@
{chatId} {chatId}
{history} {history}
{messageId} {messageId}
{selectedModels}
isLastMessage={messageId === history.currentId} isLastMessage={messageId === history.currentId}
siblings={history.messages[history.messages[messageId].parentId]?.childrenIds ?? []} siblings={history.messages[history.messages[messageId].parentId]?.childrenIds ?? []}
{gotoMessage} {gotoMessage}
@ -92,6 +94,7 @@
bind:history bind:history
{chatId} {chatId}
{messageId} {messageId}
{selectedModels}
isLastMessage={messageId === history?.currentId} isLastMessage={messageId === history?.currentId}
{updateChat} {updateChat}
{editMessage} {editMessage}

View file

@ -23,6 +23,7 @@
export let chatId; export let chatId;
export let history; export let history;
export let messageId; export let messageId;
export let selectedModels = [];
export let isLastMessage; export let isLastMessage;
export let readOnly = false; export let readOnly = false;
@ -252,6 +253,7 @@
{chatId} {chatId}
{history} {history}
messageId={_messageId} messageId={_messageId}
{selectedModels}
isLastMessage={true} isLastMessage={true}
siblings={groupedMessageIds[modelIdx].messageIds} siblings={groupedMessageIds[modelIdx].messageIds}
gotoMessage={(message, messageIdx) => gotoMessage(modelIdx, messageIdx)} gotoMessage={(message, messageIdx) => gotoMessage(modelIdx, messageIdx)}

View file

@ -4,6 +4,7 @@
import { createEventDispatcher, onMount, getContext } from 'svelte'; import { createEventDispatcher, onMount, getContext } from 'svelte';
import { config, models } from '$lib/stores'; import { config, models } from '$lib/stores';
import Tags from '$lib/components/common/Tags.svelte'; import Tags from '$lib/components/common/Tags.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
@ -123,16 +124,7 @@
show = false; show = false;
}} }}
> >
<svg <XMark className={'size-4'} />
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="M6 18 18 6M6 6l12 12" />
</svg>
</button> </button>
</div> </div>

View file

@ -106,6 +106,7 @@
export let chatId = ''; export let chatId = '';
export let history; export let history;
export let messageId; export let messageId;
export let selectedModels = [];
let message: MessageType = JSON.parse(JSON.stringify(history.messages[messageId])); let message: MessageType = JSON.parse(JSON.stringify(history.messages[messageId]));
$: if (history.messages) { $: if (history.messages) {
@ -601,7 +602,7 @@
id="message-{message.id}" id="message-{message.id}"
dir={$settings.chatDirection} dir={$settings.chatDirection}
> >
<div class={`shrink-0 ltr:mr-3 rtl:ml-3 hidden @lg:flex `}> <div class={`shrink-0 ltr:mr-3 rtl:ml-3 hidden @lg:flex mt-1 `}>
<ProfileImage <ProfileImage
src={model?.info?.meta?.profile_image_url ?? src={model?.info?.meta?.profile_image_url ??
($i18n.language === 'dg-DG' ? `/doge.png` : `${WEBUI_BASE_URL}/static/favicon.png`)} ($i18n.language === 'dg-DG' ? `/doge.png` : `${WEBUI_BASE_URL}/static/favicon.png`)}
@ -609,7 +610,7 @@
/> />
</div> </div>
<div class="flex-auto w-0 pl-1 relative -translate-y-0.5"> <div class="flex-auto w-0 pl-1 relative">
<Name> <Name>
<Tooltip content={model?.name ?? message.model} placement="top-start"> <Tooltip content={model?.name ?? message.model} placement="top-start">
<span class="line-clamp-1 text-black dark:text-white"> <span class="line-clamp-1 text-black dark:text-white">
@ -795,6 +796,7 @@
<ContentRenderer <ContentRenderer
id={message.id} id={message.id}
{history} {history}
{selectedModels}
content={message.content} content={message.content}
sources={message.sources} sources={message.sources}
floatingButtons={message?.done && !readOnly} floatingButtons={message?.done && !readOnly}

View file

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import ChevronDown from '$lib/components/icons/ChevronDown.svelte'; import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import ChevronUp from '$lib/components/icons/ChevronUp.svelte'; import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
import MagnifyingGlass from '$lib/components/icons/MagnifyingGlass.svelte'; import Search from '$lib/components/icons/Search.svelte';
import Collapsible from '$lib/components/common/Collapsible.svelte'; import Collapsible from '$lib/components/common/Collapsible.svelte';
export let status = { urls: [], query: '' }; export let status = { urls: [], query: '' };
@ -31,7 +31,7 @@
class="flex w-full items-center p-3 px-4 border-b border-gray-300/30 dark:border-gray-700/50 group/item justify-between font-normal text-gray-800 dark:text-gray-300 no-underline" class="flex w-full items-center p-3 px-4 border-b border-gray-300/30 dark:border-gray-700/50 group/item justify-between font-normal text-gray-800 dark:text-gray-300 no-underline"
> >
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
<MagnifyingGlass /> <Search />
<div class=" line-clamp-1"> <div class=" line-clamp-1">
{status.query} {status.query}

View file

@ -2,44 +2,29 @@
export let size = 'md'; export let size = 'md';
</script> </script>
<div class="w-full mt-2 mb-2"> <span class="relative flex {size === 'md' ? 'size-3 my-2' : 'size-2 my-1'} mx-1">
<div class="animate-pulse flex w-full"> <span
<div class="{size === 'md' ? 'space-y-2' : 'space-y-1.5'} w-full"> class="absolute inline-flex h-full w-full animate-pulse rounded-full bg-gray-700 dark:bg-gray-200 opacity-75"
<div ></span>
class="{size === 'md' ? 'h-2' : 'h-1.5'} bg-gray-200 dark:bg-gray-600 rounded-sm mr-14" <span
/> class="relative inline-flex {size === 'md'
? 'size-3'
: 'size-2'} rounded-full bg-black dark:bg-white animate-size"
></span>
</span>
<div class="grid grid-cols-3 gap-4"> <style>
<div @keyframes size {
class="{size === 'md' 0%,
? 'h-2' 100% {
: 'h-1.5'} bg-gray-200 dark:bg-gray-600 rounded-sm col-span-2" transform: scale(1);
/> }
<div 50% {
class="{size === 'md' transform: scale(1.25);
? 'h-2' }
: 'h-1.5'} bg-gray-200 dark:bg-gray-600 rounded-sm col-span-1" }
/>
</div>
<div class="grid grid-cols-4 gap-4">
<div
class="{size === 'md'
? 'h-2'
: 'h-1.5'} bg-gray-200 dark:bg-gray-600 rounded-sm col-span-1"
/>
<div
class="{size === 'md'
? 'h-2'
: 'h-1.5'} bg-gray-200 dark:bg-gray-600 rounded-sm col-span-2"
/>
<div
class="{size === 'md'
? 'h-2'
: 'h-1.5'} bg-gray-200 dark:bg-gray-600 rounded-sm col-span-1 mr-4"
/>
</div>
<div class="{size === 'md' ? 'h-2' : 'h-1.5'} bg-gray-200 dark:bg-gray-600 rounded-sm" /> .animate-size {
</div> animation: size 1.5s ease-in-out infinite;
</div> }
</div> </style>

View file

@ -113,7 +113,7 @@
id="message-{message.id}" id="message-{message.id}"
> >
{#if !($settings?.chatBubble ?? true)} {#if !($settings?.chatBubble ?? true)}
<div class={`shrink-0 ltr:mr-3 rtl:ml-3`}> <div class={`shrink-0 ltr:mr-3 rtl:ml-3 mt-1`}>
<ProfileImage <ProfileImage
src={message.user src={message.user
? ($models.find((m) => m.id === message.user)?.info?.meta?.profile_image_url ?? ? ($models.find((m) => m.id === message.user)?.info?.meta?.profile_image_url ??

View file

@ -49,7 +49,7 @@
{#each selectedModels as selectedModel, selectedModelIdx} {#each selectedModels as selectedModel, selectedModelIdx}
<div class="flex w-full max-w-fit"> <div class="flex w-full max-w-fit">
<div class="overflow-hidden w-full"> <div class="overflow-hidden w-full">
<div class="mr-1 max-w-full"> <div class="max-w-full {($settings?.highContrastMode ?? false) ? 'm-1' : 'mr-1'}">
<Selector <Selector
id={`${selectedModelIdx}`} id={`${selectedModelIdx}`}
placeholder={$i18n.t('Select a model')} placeholder={$i18n.t('Select a model')}

View file

@ -7,6 +7,7 @@
import relativeTime from 'dayjs/plugin/relativeTime'; import relativeTime from 'dayjs/plugin/relativeTime';
dayjs.extend(relativeTime); dayjs.extend(relativeTime);
import Spinner from '$lib/components/common/Spinner.svelte';
import { flyAndScale } from '$lib/utils/transitions'; import { flyAndScale } from '$lib/utils/transitions';
import { createEventDispatcher, onMount, getContext, tick } from 'svelte'; import { createEventDispatcher, onMount, getContext, tick } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
@ -345,12 +346,17 @@
closeFocus={false} closeFocus={false}
> >
<DropdownMenu.Trigger <DropdownMenu.Trigger
class="relative w-full font-primary" class="relative w-full font-primary {($settings?.highContrastMode ?? false)
? ''
: 'outline-hidden focus:outline-hidden'}"
aria-label={placeholder} aria-label={placeholder}
id="model-selector-{id}-button" id="model-selector-{id}-button"
> >
<button <div
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 bg-transparent truncate {triggerClassName} justify-between {($settings?.highContrastMode ??
false)
? 'dark:placeholder-gray-100 placeholder-gray-800'
: 'placeholder-gray-400'}"
on:mouseenter={async () => { on:mouseenter={async () => {
models.set( models.set(
await getModels( await getModels(
@ -359,7 +365,6 @@
) )
); );
}} }}
type="button"
> >
{#if selectedModel} {#if selectedModel}
{selectedModel.label} {selectedModel.label}
@ -367,7 +372,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" />
</button> </div>
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
<DropdownMenu.Content <DropdownMenu.Content
@ -550,29 +555,7 @@
> >
<div class="flex"> <div class="flex">
<div class="-ml-2 mr-2.5 translate-y-0.5"> <div class="-ml-2 mr-2.5 translate-y-0.5">
<svg <Spinner />
class="size-4"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
><style>
.spinner_ajPY {
transform-origin: center;
animation: spinner_AtaB 0.75s infinite linear;
}
@keyframes spinner_AtaB {
100% {
transform: rotate(360deg);
}
}
</style><path
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
opacity=".25"
/><path
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
class="spinner_ajPY"
/></svg
>
</div> </div>
<div class="flex flex-col self-start"> <div class="flex flex-col self-start">

View file

@ -173,19 +173,19 @@
} }
}} }}
> >
<button <div
class="select-none flex rounded-xl p-1.5 w-full hover:bg-gray-50 dark:hover:bg-gray-850 transition" class="select-none flex rounded-xl p-1.5 w-full hover:bg-gray-50 dark:hover:bg-gray-850 transition"
aria-label="User Menu"
> >
<div class=" self-center"> <div class=" self-center">
<span class="sr-only">{$i18n.t('User menu')}</span>
<img <img
src={$user?.profile_image_url} src={$user?.profile_image_url}
class="size-6 object-cover rounded-full" class="size-6 object-cover rounded-full"
alt="User profile" alt=""
draggable="false" draggable="false"
/> />
</div> </div>
</button> </div>
</UserMenu> </UserMenu>
{/if} {/if}
</div> </div>

View file

@ -118,6 +118,10 @@
placement="top" placement="top"
> >
<button <button
aria-hidden={models.length <= 1}
aria-label={$i18n.t('Get information on {{name}} in the UI', {
name: models[modelIdx]?.name
})}
on:click={() => { on:click={() => {
selectedModelIdx = modelIdx; selectedModelIdx = modelIdx;
}} }}
@ -129,7 +133,7 @@
? `/doge.png` ? `/doge.png`
: `${WEBUI_BASE_URL}/static/favicon.png`)} : `${WEBUI_BASE_URL}/static/favicon.png`)}
class=" size-9 @sm:size-10 rounded-full border-[1px] border-gray-100 dark:border-none" class=" size-9 @sm:size-10 rounded-full border-[1px] border-gray-100 dark:border-none"
alt="logo" aria-hidden="true"
draggable="false" draggable="false"
/> />
</button> </button>

View file

@ -38,7 +38,9 @@
return ''; return '';
}); });
checkForVersionUpdates(); if ($config?.features?.enable_version_update_check) {
checkForVersionUpdates();
}
}); });
</script> </script>
@ -58,16 +60,18 @@
v{WEBUI_VERSION} v{WEBUI_VERSION}
</Tooltip> </Tooltip>
<a {#if $config?.features?.enable_version_update_check}
href="https://github.com/open-webui/open-webui/releases/tag/v{version.latest}" <a
target="_blank" href="https://github.com/open-webui/open-webui/releases/tag/v{version.latest}"
> target="_blank"
{updateAvailable === null >
? $i18n.t('Checking for updates...') {updateAvailable === null
: updateAvailable ? $i18n.t('Checking for updates...')
? `(v${version.latest} ${$i18n.t('available!')})` : updateAvailable
: $i18n.t('(latest)')} ? `(v${version.latest} ${$i18n.t('available!')})`
</a> : $i18n.t('(latest)')}
</a>
{/if}
</div> </div>
<button <button
@ -80,14 +84,16 @@
</button> </button>
</div> </div>
<button {#if $config?.features?.enable_version_update_check}
class=" text-xs px-3 py-1.5 bg-gray-100 hover:bg-gray-200 dark:bg-gray-850 dark:hover:bg-gray-800 transition rounded-lg font-medium" <button
on:click={() => { class=" text-xs px-3 py-1.5 bg-gray-100 hover:bg-gray-200 dark:bg-gray-850 dark:hover:bg-gray-800 transition rounded-lg font-medium"
checkForVersionUpdates(); on:click={() => {
}} checkForVersionUpdates();
> }}
{$i18n.t('Check for updates')} >
</button> {$i18n.t('Check for updates')}
</button>
{/if}
</div> </div>
</div> </div>

View file

@ -6,11 +6,10 @@
import { import {
archiveAllChats, archiveAllChats,
createNewChat,
deleteAllChats, deleteAllChats,
getAllChats, getAllChats,
getAllUserChats, getChatList,
getChatList importChat
} from '$lib/apis/chats'; } from '$lib/apis/chats';
import { getImportOrigin, convertOpenAIChats } from '$lib/utils'; import { getImportOrigin, convertOpenAIChats } from '$lib/utils';
import { onMount, getContext } from 'svelte'; import { onMount, getContext } from 'svelte';
@ -58,9 +57,18 @@
console.log(chat); console.log(chat);
if (chat.chat) { if (chat.chat) {
await createNewChat(localStorage.token, chat.chat); await importChat(
localStorage.token,
chat.chat,
chat.meta ?? {},
false,
null,
chat?.created_at ?? null,
chat?.updated_at ?? null
);
} else { } else {
await createNewChat(localStorage.token, chat); // Legacy format
await importChat(localStorage.token, chat, {}, false, null);
} }
} }
@ -101,6 +109,7 @@
const handleArchivedChatsChange = async () => { const handleArchivedChatsChange = async () => {
currentChatPage.set(1); currentChatPage.set(1);
await chats.set(await getChatList(localStorage.token, $currentChatPage)); await chats.set(await getChatList(localStorage.token, $currentChatPage));
scrollPaginationEnabled.set(true); scrollPaginationEnabled.set(true);
}; };
</script> </script>

View file

@ -4,6 +4,8 @@
import Modal from '$lib/components/common/Modal.svelte'; import Modal from '$lib/components/common/Modal.svelte';
import { addNewMemory, updateMemoryById } from '$lib/apis/memories'; import { addNewMemory, updateMemoryById } from '$lib/apis/memories';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import Spinner from '$lib/components/common/Spinner.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
@ -46,16 +48,7 @@
show = false; show = false;
}} }}
> >
<svg <XMark className={'size-5'} />
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
</button> </button>
</div> </div>
@ -93,29 +86,7 @@
{#if loading} {#if loading}
<div class="ml-2 self-center"> <div class="ml-2 self-center">
<svg <Spinner />
class=" w-4 h-4"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
><style>
.spinner_ajPY {
transform-origin: center;
animation: spinner_AtaB 0.75s infinite linear;
}
@keyframes spinner_AtaB {
100% {
transform: rotate(360deg);
}
}
</style><path
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
opacity=".25"
/><path
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
class="spinner_ajPY"
/></svg
>
</div> </div>
{/if} {/if}
</button> </button>

View file

@ -4,7 +4,9 @@
import { updateMemoryById } from '$lib/apis/memories'; import { updateMemoryById } from '$lib/apis/memories';
import Spinner from '$lib/components/common/Spinner.svelte';
import Modal from '$lib/components/common/Modal.svelte'; import Modal from '$lib/components/common/Modal.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
@ -56,16 +58,7 @@
show = false; show = false;
}} }}
> >
<svg <XMark className={'size-5'} />
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
</button> </button>
</div> </div>
@ -103,29 +96,7 @@
{#if loading} {#if loading}
<div class="ml-2 self-center"> <div class="ml-2 self-center">
<svg <Spinner />
class=" w-4 h-4"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
><style>
.spinner_ajPY {
transform-origin: center;
animation: spinner_AtaB 0.75s infinite linear;
}
@keyframes spinner_AtaB {
100% {
transform: rotate(360deg);
}
}
</style><path
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
opacity=".25"
/><path
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
class="spinner_ajPY"
/></svg
>
</div> </div>
{/if} {/if}
</button> </button>

View file

@ -8,6 +8,7 @@
import Modal from '../common/Modal.svelte'; import Modal from '../common/Modal.svelte';
import Link from '../icons/Link.svelte'; import Link from '../icons/Link.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
export let chatId; export let chatId;
@ -90,16 +91,7 @@
show = false; show = false;
}} }}
> >
<svg <XMark className={'size-5'} />
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
</button> </button>
</div> </div>

View file

@ -4,6 +4,7 @@
import Tooltip from '../common/Tooltip.svelte'; import Tooltip from '../common/Tooltip.svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
import XMark from '$lib/components/icons/XMark.svelte';
export let show = false; export let show = false;
</script> </script>
@ -18,16 +19,7 @@
show = false; show = false;
}} }}
> >
<svg <XMark className={'size-5'} />
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
</button> </button>
</div> </div>

View file

@ -83,9 +83,10 @@
<div class="h-40 w-full"> <div class="h-40 w-full">
{#if filteredPrompts.length > 0} {#if filteredPrompts.length > 0}
<div class="max-h-40 overflow-auto scrollbar-none items-start {className}"> <div role="list" class="max-h-40 overflow-auto scrollbar-none items-start {className}">
{#each filteredPrompts as prompt, idx (prompt.id || prompt.content)} {#each filteredPrompts as prompt, idx (prompt.id || prompt.content)}
<button <button
role="listitem"
class="waterfall flex flex-col flex-1 shrink-0 w-full justify-between 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"

View file

@ -9,6 +9,7 @@
import Modal from '../common/Modal.svelte'; import Modal from '../common/Modal.svelte';
import Link from '../icons/Link.svelte'; import Link from '../icons/Link.svelte';
import Collapsible from '../common/Collapsible.svelte'; import Collapsible from '../common/Collapsible.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
export let show = false; export let show = false;
export let selectedToolIds = []; export let selectedToolIds = [];
@ -30,16 +31,7 @@
show = false; show = false;
}} }}
> >
<svg <XMark className={'size-5'} />
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
</button> </button>
</div> </div>

View file

@ -36,6 +36,8 @@
onMount(() => { onMount(() => {
mounted = true; mounted = true;
console.log('Banner mounted:', banner);
}); });
</script> </script>
@ -82,9 +84,8 @@
</div> </div>
{/if} {/if}
</div> </div>
<div class="flex-1 text-xs text-gray-700 dark:text-white max-h-60 overflow-y-auto">
<div class="flex-1 text-xs text-gray-700 dark:text-white max-h-20 overflow-y-auto"> {@html marked.parse(DOMPurify.sanitize((banner?.content ?? '').replace(/\n/g, '<br>')))}
{@html marked.parse(DOMPurify.sanitize(banner.content))}
</div> </div>
</div> </div>

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