Merge branch 'dev' into update-ga-strings-june

This commit is contained in:
Aindriú Mac Giolla Eoin 2025-07-03 13:07:38 +01:00 committed by GitHub
commit 34ec073b3d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
226 changed files with 5101 additions and 3210 deletions

View file

@ -19,6 +19,7 @@ from open_webui.env import (
DATABASE_URL,
ENV,
REDIS_URL,
REDIS_KEY_PREFIX,
REDIS_SENTINEL_HOSTS,
REDIS_SENTINEL_PORT,
FRONTEND_BUILD_DIR,
@ -211,11 +212,16 @@ class PersistentConfig(Generic[T]):
class AppConfig:
_state: dict[str, PersistentConfig]
_redis: Optional[redis.Redis] = None
_redis_key_prefix: str
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__("_redis_key_prefix", redis_key_prefix)
if redis_url:
super().__setattr__(
"_redis",
@ -230,7 +236,7 @@ class AppConfig:
self._state[key].save()
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))
def __getattr__(self, key):
@ -239,7 +245,7 @@ class AppConfig:
# If Redis is available, check for an updated value
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)
if redis_value is not None:
@ -431,6 +437,12 @@ OAUTH_SCOPES = PersistentConfig(
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",
"oauth.oidc.code_challenge_method",
@ -540,7 +552,14 @@ def load_oauth_providers():
client_id=GOOGLE_CLIENT_ID.value,
client_secret=GOOGLE_CLIENT_SECRET.value,
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,
)
@ -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}",
client_kwargs={
"scope": MICROSOFT_OAUTH_SCOPE.value,
**(
{"timeout": int(OAUTH_TIMEOUT.value)}
if OAUTH_TIMEOUT.value
else {}
),
},
redirect_uri=MICROSOFT_REDIRECT_URI.value,
)
@ -584,7 +608,14 @@ def load_oauth_providers():
authorize_url="https://github.com/login/oauth/authorize",
api_base_url="https://api.github.com",
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,
)
@ -603,6 +634,9 @@ def load_oauth_providers():
def oidc_oauth_register(client):
client_kwargs = {
"scope": OAUTH_SCOPES.value,
**(
{"timeout": int(OAUTH_TIMEOUT.value)} if OAUTH_TIMEOUT.value else {}
),
}
if (
@ -895,6 +929,18 @@ except Exception:
pass
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
####################################
@ -1799,6 +1845,7 @@ QDRANT_GRPC_PORT = int(os.environ.get("QDRANT_GRPC_PORT", "6334"))
ENABLE_QDRANT_MULTITENANCY_MODE = (
os.environ.get("ENABLE_QDRANT_MULTITENANCY_MODE", "false").lower() == "true"
)
QDRANT_COLLECTION_PREFIX = os.environ.get("QDRANT_COLLECTION_PREFIX", "open-webui")
# OpenSearch
OPENSEARCH_URI = os.environ.get("OPENSEARCH_URI", "https://localhost:9200")

View file

@ -199,6 +199,7 @@ CHANGELOG = changelog_json
SAFE_MODE = os.environ.get("SAFE_MODE", "false").lower() == "true"
####################################
# ENABLE_FORWARD_USER_INFO_HEADERS
####################################
@ -272,15 +273,13 @@ if "postgres://" in DATABASE_URL:
DATABASE_SCHEMA = os.environ.get("DATABASE_SCHEMA", None)
DATABASE_POOL_SIZE = os.environ.get("DATABASE_POOL_SIZE", 0)
DATABASE_POOL_SIZE = os.environ.get("DATABASE_POOL_SIZE", None)
if DATABASE_POOL_SIZE == "":
DATABASE_POOL_SIZE = 0
else:
if DATABASE_POOL_SIZE != None:
try:
DATABASE_POOL_SIZE = int(DATABASE_POOL_SIZE)
except Exception:
DATABASE_POOL_SIZE = 0
DATABASE_POOL_SIZE = None
DATABASE_POOL_MAX_OVERFLOW = os.environ.get("DATABASE_POOL_MAX_OVERFLOW", 0)
@ -325,6 +324,7 @@ ENABLE_REALTIME_CHAT_SAVE = (
####################################
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_PORT = os.environ.get("REDIS_SENTINEL_PORT", "26379")
@ -396,10 +396,33 @@ WEBUI_AUTH_COOKIE_SECURE = (
if WEBUI_AUTH and WEBUI_SECRET_KEY == "":
raise ValueError(ERROR_MESSAGES.ENV_VAR_NOT_FOUND)
ENABLE_COMPRESSION_MIDDLEWARE = (
os.environ.get("ENABLE_COMPRESSION_MIDDLEWARE", "True").lower() == "true"
)
####################################
# MODELS
####################################
MODELS_CACHE_TTL = os.environ.get("MODELS_CACHE_TTL", "1")
if MODELS_CACHE_TTL == "":
MODELS_CACHE_TTL = None
else:
try:
MODELS_CACHE_TTL = int(MODELS_CACHE_TTL)
except Exception:
MODELS_CACHE_TTL = 1
####################################
# WEBSOCKET SUPPORT
####################################
ENABLE_WEBSOCKET_SUPPORT = (
os.environ.get("ENABLE_WEBSOCKET_SUPPORT", "True").lower() == "true"
)
WEBSOCKET_MANAGER = os.environ.get("WEBSOCKET_MANAGER", "")
WEBSOCKET_REDIS_URL = os.environ.get("WEBSOCKET_REDIS_URL", REDIS_URL)
@ -543,6 +566,9 @@ ENABLE_OTEL_METRICS = os.environ.get("ENABLE_OTEL_METRICS", "False").lower() ==
OTEL_EXPORTER_OTLP_ENDPOINT = os.environ.get(
"OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317"
)
OTEL_EXPORTER_OTLP_INSECURE = (
os.environ.get("OTEL_EXPORTER_OTLP_INSECURE", "False").lower() == "true"
)
OTEL_SERVICE_NAME = os.environ.get("OTEL_SERVICE_NAME", "open-webui")
OTEL_RESOURCE_ATTRIBUTES = os.environ.get(
"OTEL_RESOURCE_ATTRIBUTES", ""
@ -550,6 +576,14 @@ OTEL_RESOURCE_ATTRIBUTES = os.environ.get(
OTEL_TRACES_SAMPLER = os.environ.get(
"OTEL_TRACES_SAMPLER", "parentbased_always_on"
).lower()
OTEL_BASIC_AUTH_USERNAME = os.environ.get("OTEL_BASIC_AUTH_USERNAME", "")
OTEL_BASIC_AUTH_PASSWORD = os.environ.get("OTEL_BASIC_AUTH_PASSWORD", "")
OTEL_OTLP_SPAN_EXPORTER = os.environ.get(
"OTEL_OTLP_SPAN_EXPORTER", "grpc"
).lower() # grpc or http
####################################
# TOOLS/FUNCTIONS PIP OPTIONS

View file

@ -62,6 +62,9 @@ def handle_peewee_migration(DATABASE_URL):
except Exception as e:
log.error(f"Failed to initialize the database connection: {e}")
log.warning(
"Hint: If your database password contains special characters, you may need to URL-encode it."
)
raise
finally:
# Properly closing the database connection
@ -81,6 +84,7 @@ if "sqlite" in SQLALCHEMY_DATABASE_URL:
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
)
else:
if isinstance(DATABASE_POOL_SIZE, int):
if DATABASE_POOL_SIZE > 0:
engine = create_engine(
SQLALCHEMY_DATABASE_URL,
@ -95,6 +99,8 @@ else:
engine = create_engine(
SQLALCHEMY_DATABASE_URL, pool_pre_ping=True, poolclass=NullPool
)
else:
engine = create_engine(SQLALCHEMY_DATABASE_URL, pool_pre_ping=True)
SessionLocal = sessionmaker(

View file

@ -36,7 +36,6 @@ from fastapi import (
applications,
BackgroundTasks,
)
from fastapi.openapi.docs import get_swagger_ui_html
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.sessions import SessionMiddleware
from starlette.responses import Response, StreamingResponse
from starlette.datastructures import Headers
from open_webui.utils import logger
@ -116,6 +116,8 @@ from open_webui.config import (
OPENAI_API_CONFIGS,
# Direct Connections
ENABLE_DIRECT_CONNECTIONS,
# Model list
ENABLE_BASE_MODELS_CACHE,
# Thread pool size for FastAPI/AnyIO
THREAD_POOL_SIZE,
# Tool Server Configs
@ -396,6 +398,7 @@ from open_webui.env import (
AUDIT_LOG_LEVEL,
CHANGELOG,
REDIS_URL,
REDIS_KEY_PREFIX,
REDIS_SENTINEL_HOSTS,
REDIS_SENTINEL_PORT,
GLOBAL_LOG_LEVEL,
@ -411,6 +414,7 @@ from open_webui.env import (
WEBUI_AUTH_TRUSTED_EMAIL_HEADER,
WEBUI_AUTH_TRUSTED_NAME_HEADER,
WEBUI_AUTH_SIGNOUT_REDIRECT_URL,
ENABLE_COMPRESSION_MIDDLEWARE,
ENABLE_WEBSOCKET_SUPPORT,
BYPASS_MODEL_ACCESS_CONTROL,
RESET_CONFIG_ON_START,
@ -533,6 +537,27 @@ async def lifespan(app: FastAPI):
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
if hasattr(app.state, "redis_task_command_listener"):
@ -553,6 +578,7 @@ app.state.instance_id = None
app.state.config = AppConfig(
redis_url=REDIS_URL,
redis_sentinels=get_sentinels_from_env(REDIS_SENTINEL_HOSTS, REDIS_SENTINEL_PORT),
redis_key_prefix=REDIS_KEY_PREFIX,
)
app.state.redis = None
@ -615,6 +641,15 @@ app.state.TOOL_SERVERS = []
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
@ -1072,7 +1107,9 @@ class RedirectMiddleware(BaseHTTPMiddleware):
# Add the middleware to the app
app.add_middleware(CompressMiddleware)
if ENABLE_COMPRESSION_MIDDLEWARE:
app.add_middleware(CompressMiddleware)
app.add_middleware(RedirectMiddleware)
app.add_middleware(SecurityHeadersMiddleware)
@ -1188,7 +1225,9 @@ if audit_level != AuditLevel.NONE:
@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):
filtered_models = []
for model in models:
@ -1212,7 +1251,7 @@ async def get_models(request: Request, user=Depends(get_verified_user)):
return filtered_models
all_models = await get_all_models(request, user=user)
all_models = await get_all_models(request, refresh=refresh, user=user)
models = []
for model in all_models:
@ -1507,6 +1546,7 @@ async def get_app_config(request: Request):
"name": app.state.WEBUI_NAME,
"version": VERSION,
"default_locale": str(DEFAULT_LOCALE),
"offline_mode": OFFLINE_MODE,
"oauth": {
"providers": {
name: config.get("name", name)

View file

@ -72,6 +72,8 @@ class ChatImportForm(ChatForm):
meta: Optional[dict] = {}
pinned: Optional[bool] = False
folder_id: Optional[str] = None
created_at: Optional[int] = None
updated_at: Optional[int] = None
class ChatTitleMessagesForm(BaseModel):
@ -147,8 +149,16 @@ class ChatTable:
"meta": form_data.meta,
"pinned": form_data.pinned,
"folder_id": form_data.folder_id,
"created_at": int(time.time()),
"updated_at": int(time.time()),
"created_at": (
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())
),
}
)

View file

@ -14,7 +14,7 @@ from langchain_community.document_loaders import (
TextLoader,
UnstructuredEPubLoader,
UnstructuredExcelLoader,
UnstructuredMarkdownLoader,
UnstructuredODTLoader,
UnstructuredPowerPointLoader,
UnstructuredRSTLoader,
UnstructuredXMLLoader,
@ -389,6 +389,8 @@ class Loader:
loader = UnstructuredPowerPointLoader(file_path)
elif file_ext == "msg":
loader = OutlookMessageLoader(file_path)
elif file_ext == "odt":
loader = UnstructuredODTLoader(file_path)
elif self._is_text_file(file_ext, file_content_type):
loader = TextLoader(file_path, autodetect_encoding=True)
else:

View file

@ -7,6 +7,7 @@ import hashlib
from concurrent.futures import ThreadPoolExecutor
import time
from urllib.parse import quote
from huggingface_hub import snapshot_download
from langchain.retrievers import ContextualCompressionRetriever, EnsembleRetriever
from langchain_community.retrievers import BM25Retriever
@ -459,20 +460,19 @@ def get_sources_from_files(
)
extracted_collections = []
relevant_contexts = []
query_results = []
for file in files:
context = None
query_result = None
if file.get("docs"):
# BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL
context = {
query_result = {
"documents": [[doc.get("content") for doc in file.get("docs")]],
"metadatas": [[doc.get("metadata") for doc in file.get("docs")]],
}
elif file.get("context") == "full":
# Manual Full Mode Toggle
context = {
query_result = {
"documents": [[file.get("file").get("data", {}).get("content")]],
"metadatas": [[{"file_id": file.get("id"), "name": file.get("name")}]],
}
@ -499,7 +499,7 @@ def get_sources_from_files(
}
)
context = {
query_result = {
"documents": [documents],
"metadatas": [metadatas],
}
@ -507,7 +507,7 @@ def get_sources_from_files(
elif file.get("id"):
file_object = Files.get_file_by_id(file.get("id"))
if file_object:
context = {
query_result = {
"documents": [[file_object.data.get("content", "")]],
"metadatas": [
[
@ -520,7 +520,7 @@ def get_sources_from_files(
],
}
elif file.get("file").get("data"):
context = {
query_result = {
"documents": [[file.get("file").get("data", {}).get("content")]],
"metadatas": [
[file.get("file").get("data", {}).get("metadata", {})]
@ -548,19 +548,27 @@ def get_sources_from_files(
if full_context:
try:
context = get_all_items_from_collections(collection_names)
query_result = get_all_items_from_collections(collection_names)
except Exception as e:
log.exception(e)
else:
try:
context = None
query_result = None
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:
if hybrid_search:
try:
context = query_collection_with_hybrid_search(
query_result = query_collection_with_hybrid_search(
collection_names=collection_names,
queries=queries,
embedding_function=embedding_function,
@ -576,8 +584,8 @@ def get_sources_from_files(
" non hybrid search as fallback."
)
if (not hybrid_search) or (context is None):
context = query_collection(
if (not hybrid_search) or (query_result is None):
query_result = query_collection(
collection_names=collection_names,
queries=queries,
embedding_function=embedding_function,
@ -588,24 +596,24 @@ def get_sources_from_files(
extracted_collections.extend(collection_names)
if context:
if query_result:
if "data" in file:
del file["data"]
relevant_contexts.append({**context, "file": file})
query_results.append({**query_result, "file": file})
sources = []
for context in relevant_contexts:
for query_result in query_results:
try:
if "documents" in context:
if "metadatas" in context:
if "documents" in query_result:
if "metadatas" in query_result:
source = {
"source": context["file"],
"document": context["documents"][0],
"metadata": context["metadatas"][0],
"source": query_result["file"],
"document": query_result["documents"][0],
"metadata": query_result["metadatas"][0],
}
if "distances" in context and context["distances"]:
source["distances"] = context["distances"][0]
if "distances" in query_result and query_result["distances"]:
source["distances"] = query_result["distances"][0]
sources.append(source)
except Exception as e:
@ -678,10 +686,10 @@ def generate_openai_batch_embeddings(
"Authorization": f"Bearer {key}",
**(
{
"X-OpenWebUI-User-Name": user.name,
"X-OpenWebUI-User-Id": user.id,
"X-OpenWebUI-User-Email": user.email,
"X-OpenWebUI-User-Role": user.role,
"X-OpenWebUI-User-Name": quote(user.name),
"X-OpenWebUI-User-Id": quote(user.id),
"X-OpenWebUI-User-Email": quote(user.email),
"X-OpenWebUI-User-Role": quote(user.role),
}
if ENABLE_FORWARD_USER_INFO_HEADERS and user
else {}
@ -727,10 +735,10 @@ def generate_azure_openai_batch_embeddings(
"api-key": key,
**(
{
"X-OpenWebUI-User-Name": user.name,
"X-OpenWebUI-User-Id": user.id,
"X-OpenWebUI-User-Email": user.email,
"X-OpenWebUI-User-Role": user.role,
"X-OpenWebUI-User-Name": quote(user.name),
"X-OpenWebUI-User-Id": quote(user.id),
"X-OpenWebUI-User-Email": quote(user.email),
"X-OpenWebUI-User-Role": quote(user.role),
}
if ENABLE_FORWARD_USER_INFO_HEADERS and user
else {}
@ -777,10 +785,10 @@ def generate_ollama_batch_embeddings(
"Authorization": f"Bearer {key}",
**(
{
"X-OpenWebUI-User-Name": user.name,
"X-OpenWebUI-User-Id": user.id,
"X-OpenWebUI-User-Email": user.email,
"X-OpenWebUI-User-Role": user.role,
"X-OpenWebUI-User-Name": quote(user.name),
"X-OpenWebUI-User-Id": quote(user.id),
"X-OpenWebUI-User-Email": quote(user.email),
"X-OpenWebUI-User-Role": quote(user.role),
}
if ENABLE_FORWARD_USER_INFO_HEADERS
else {}

View file

@ -157,10 +157,10 @@ class OpenSearchClient(VectorDBBase):
for field, value in filter.items():
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:
result = self.client.search(
@ -206,6 +206,7 @@ class OpenSearchClient(VectorDBBase):
for item in batch
]
bulk(self.client, actions)
self.client.indices.refresh(self._get_index_name(collection_name))
def upsert(self, collection_name: str, items: list[VectorItem]):
self._create_index_if_not_exists(
@ -228,6 +229,7 @@ class OpenSearchClient(VectorDBBase):
for item in batch
]
bulk(self.client, actions)
self.client.indices.refresh(self._get_index_name(collection_name))
def delete(
self,
@ -251,11 +253,12 @@ class OpenSearchClient(VectorDBBase):
}
for field, value in filter.items():
query_body["query"]["bool"]["filter"].append(
{"match": {"metadata." + str(field): value}}
{"term": {"metadata." + str(field) + ".keyword": value}}
)
self.client.delete_by_query(
index=self._get_index_name(collection_name), body=query_body
)
self.client.indices.refresh(self._get_index_name(collection_name))
def reset(self):
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_GRPC_PORT,
QDRANT_PREFER_GRPC,
QDRANT_COLLECTION_PREFIX,
)
from open_webui.env import SRC_LOG_LEVELS
@ -29,7 +30,7 @@ log.setLevel(SRC_LOG_LEVELS["RAG"])
class QdrantClient(VectorDBBase):
def __init__(self):
self.collection_prefix = "open-webui"
self.collection_prefix = QDRANT_COLLECTION_PREFIX
self.QDRANT_URI = QDRANT_URI
self.QDRANT_API_KEY = QDRANT_API_KEY
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!")
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_PREFER_GRPC,
QDRANT_URI,
QDRANT_COLLECTION_PREFIX,
)
from open_webui.env import SRC_LOG_LEVELS
from open_webui.retrieval.vector.main import (
@ -30,7 +31,7 @@ log.setLevel(SRC_LOG_LEVELS["RAG"])
class QdrantClient(VectorDBBase):
def __init__(self):
self.collection_prefix = "open-webui"
self.collection_prefix = QDRANT_COLLECTION_PREFIX
self.QDRANT_URI = QDRANT_URI
self.QDRANT_API_KEY = QDRANT_API_KEY
self.QDRANT_ON_DISK = QDRANT_ON_DISK
@ -228,6 +229,25 @@ class QdrantClient(VectorDBBase):
),
wait=True,
)
# Create payload indexes for efficient filtering on metadata.hash and metadata.file_id
self.client.create_payload_index(
collection_name=mt_collection_name,
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=mt_collection_name,
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"Multi-tenant collection {mt_collection_name} created with dimension {dimension}!"

View file

@ -36,7 +36,9 @@ def search_brave(
return [
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]
]

View file

@ -15,6 +15,7 @@ import aiohttp
import aiofiles
import requests
import mimetypes
from urllib.parse import quote
from fastapi import (
Depends,
@ -343,10 +344,10 @@ async def speech(request: Request, user=Depends(get_verified_user)):
"Authorization": f"Bearer {request.app.state.config.TTS_OPENAI_API_KEY}",
**(
{
"X-OpenWebUI-User-Name": user.name,
"X-OpenWebUI-User-Id": user.id,
"X-OpenWebUI-User-Email": user.email,
"X-OpenWebUI-User-Role": user.role,
"X-OpenWebUI-User-Name": quote(user.name),
"X-OpenWebUI-User-Id": quote(user.id),
"X-OpenWebUI-User-Email": quote(user.email),
"X-OpenWebUI-User-Role": quote(user.role),
}
if ENABLE_FORWARD_USER_INFO_HEADERS
else {}
@ -919,14 +920,18 @@ def transcription(
):
log.info(f"file.content_type: {file.content_type}")
supported_content_types = request.app.state.config.STT_SUPPORTED_CONTENT_TYPES or [
"audio/*",
"video/webm",
]
stt_supported_content_types = getattr(
request.app.state.config, "STT_SUPPORTED_CONTENT_TYPES", []
)
if not any(
fnmatch(file.content_type, content_type)
for content_type in supported_content_types
for content_type in (
stt_supported_content_types
if stt_supported_content_types
and any(t.strip() for t in stt_supported_content_types)
else ["audio/*", "video/webm"]
)
):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,

View file

@ -669,6 +669,7 @@ async def signup(request: Request, response: Response, form_data: SignupForm):
@router.get("/signout")
async def signout(request: Request, response: Response):
response.delete_cookie("token")
response.delete_cookie("oui-session")
if ENABLE_OAUTH_SIGNUP.value:
oauth_id_token = request.cookies.get("oauth_id_token")

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])
async def share_chat_by_id(request: Request, id: str, user=Depends(get_verified_user)):
if not has_permission(
if (user.role != "admin") and (
not has_permission(
user.id, "chat.share", request.app.state.config.USER_PERMISSIONS
)
):
raise HTTPException(
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_BASE_MODELS_CACHE: bool
@router.get("/direct_connections", response_model=DirectConnectionsConfigForm)
async def get_direct_connections_config(request: Request, user=Depends(get_admin_user)):
@router.get("/connections", response_model=ConnectionsConfigForm)
async def get_connections_config(request: Request, user=Depends(get_admin_user)):
return {
"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)
async def set_direct_connections_config(
@router.post("/connections", response_model=ConnectionsConfigForm)
async def set_connections_config(
request: Request,
form_data: DirectConnectionsConfigForm,
form_data: ConnectionsConfigForm,
user=Depends(get_admin_user),
):
request.app.state.config.ENABLE_DIRECT_CONNECTIONS = (
form_data.ENABLE_DIRECT_CONNECTIONS
)
request.app.state.config.ENABLE_BASE_MODELS_CACHE = (
form_data.ENABLE_BASE_MODELS_CACHE
)
return {
"ENABLE_DIRECT_CONNECTIONS": request.app.state.config.ENABLE_DIRECT_CONNECTIONS,
"ENABLE_BASE_MODELS_CACHE": request.app.state.config.ENABLE_BASE_MODELS_CACHE,
}

View file

@ -155,17 +155,18 @@ def upload_file(
if process:
try:
if file.content_type:
stt_supported_content_types = (
request.app.state.config.STT_SUPPORTED_CONTENT_TYPES
or [
"audio/*",
"video/webm",
]
stt_supported_content_types = getattr(
request.app.state.config, "STT_SUPPORTED_CONTENT_TYPES", []
)
if any(
fnmatch(file.content_type, content_type)
for content_type in stt_supported_content_types
for content_type in (
stt_supported_content_types
if stt_supported_content_types
and any(t.strip() for t in stt_supported_content_types)
else ["audio/*", "video/webm"]
)
):
file_path = Storage.get_file(file_path)
result = transcribe(request, file_path, file_metadata)

View file

@ -8,6 +8,7 @@ import re
from pathlib import Path
from typing import Optional
from urllib.parse import quote
import requests
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile
from open_webui.config import CACHE_DIR
@ -302,8 +303,16 @@ async def update_image_config(
):
set_image_model(request, form_data.MODEL)
if form_data.IMAGE_SIZE == "auto" and form_data.MODEL != "gpt-image-1":
raise HTTPException(
status_code=400,
detail=ERROR_MESSAGES.INCORRECT_FORMAT(
" (auto is only allowed with gpt-image-1)."
),
)
pattern = r"^\d+x\d+$"
if re.match(pattern, form_data.IMAGE_SIZE):
if form_data.IMAGE_SIZE == "auto" or re.match(pattern, form_data.IMAGE_SIZE):
request.app.state.config.IMAGE_SIZE = form_data.IMAGE_SIZE
else:
raise HTTPException(
@ -471,7 +480,14 @@ async def image_generations(
form_data: GenerateImageForm,
user=Depends(get_verified_user),
):
width, height = tuple(map(int, request.app.state.config.IMAGE_SIZE.split("x")))
# if IMAGE_SIZE = 'auto', default WidthxHeight to the 512x512 default
# This is only relevant when the user has set IMAGE_SIZE to 'auto' with an
# image model other than gpt-image-1, which is warned about on settings save
width, height = (
tuple(map(int, request.app.state.config.IMAGE_SIZE.split("x")))
if "x" in request.app.state.config.IMAGE_SIZE
else (512, 512)
)
r = None
try:
@ -483,10 +499,10 @@ async def image_generations(
headers["Content-Type"] = "application/json"
if ENABLE_FORWARD_USER_INFO_HEADERS:
headers["X-OpenWebUI-User-Name"] = user.name
headers["X-OpenWebUI-User-Id"] = user.id
headers["X-OpenWebUI-User-Email"] = user.email
headers["X-OpenWebUI-User-Role"] = user.role
headers["X-OpenWebUI-User-Name"] = quote(user.name)
headers["X-OpenWebUI-User-Id"] = quote(user.id)
headers["X-OpenWebUI-User-Email"] = quote(user.email)
headers["X-OpenWebUI-User-Role"] = quote(user.role)
data = {
"model": (

View file

@ -16,6 +16,7 @@ from urllib.parse import urlparse
import aiohttp
from aiocache import cached
import requests
from urllib.parse import quote
from open_webui.models.chats import Chats
from open_webui.models.users import UserModel
@ -58,6 +59,7 @@ from open_webui.config import (
from open_webui.env import (
ENV,
SRC_LOG_LEVELS,
MODELS_CACHE_TTL,
AIOHTTP_CLIENT_SESSION_SSL,
AIOHTTP_CLIENT_TIMEOUT,
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
@ -87,10 +89,10 @@ async def send_get_request(url, key=None, user: UserModel = None):
**({"Authorization": f"Bearer {key}"} if key else {}),
**(
{
"X-OpenWebUI-User-Name": user.name,
"X-OpenWebUI-User-Id": user.id,
"X-OpenWebUI-User-Email": user.email,
"X-OpenWebUI-User-Role": user.role,
"X-OpenWebUI-User-Name": quote(user.name),
"X-OpenWebUI-User-Id": quote(user.id),
"X-OpenWebUI-User-Email": quote(user.email),
"X-OpenWebUI-User-Role": quote(user.role),
}
if ENABLE_FORWARD_USER_INFO_HEADERS and user
else {}
@ -138,10 +140,10 @@ async def send_post_request(
**({"Authorization": f"Bearer {key}"} if key else {}),
**(
{
"X-OpenWebUI-User-Name": user.name,
"X-OpenWebUI-User-Id": user.id,
"X-OpenWebUI-User-Email": user.email,
"X-OpenWebUI-User-Role": user.role,
"X-OpenWebUI-User-Name": quote(user.name),
"X-OpenWebUI-User-Id": quote(user.id),
"X-OpenWebUI-User-Email": quote(user.email),
"X-OpenWebUI-User-Role": quote(user.role),
}
if ENABLE_FORWARD_USER_INFO_HEADERS and user
else {}
@ -242,10 +244,10 @@ async def verify_connection(
**({"Authorization": f"Bearer {key}"} if key else {}),
**(
{
"X-OpenWebUI-User-Name": user.name,
"X-OpenWebUI-User-Id": user.id,
"X-OpenWebUI-User-Email": user.email,
"X-OpenWebUI-User-Role": user.role,
"X-OpenWebUI-User-Name": quote(user.name),
"X-OpenWebUI-User-Id": quote(user.id),
"X-OpenWebUI-User-Email": quote(user.email),
"X-OpenWebUI-User-Role": quote(user.role),
}
if ENABLE_FORWARD_USER_INFO_HEADERS and user
else {}
@ -329,7 +331,7 @@ def merge_ollama_models_lists(model_lists):
return list(merged_models.values())
@cached(ttl=1)
@cached(ttl=MODELS_CACHE_TTL)
async def get_all_models(request: Request, user: UserModel = None):
log.info("get_all_models()")
if request.app.state.config.ENABLE_OLLAMA_API:
@ -462,10 +464,10 @@ async def get_ollama_tags(
**({"Authorization": f"Bearer {key}"} if key else {}),
**(
{
"X-OpenWebUI-User-Name": user.name,
"X-OpenWebUI-User-Id": user.id,
"X-OpenWebUI-User-Email": user.email,
"X-OpenWebUI-User-Role": user.role,
"X-OpenWebUI-User-Name": quote(user.name),
"X-OpenWebUI-User-Id": quote(user.id),
"X-OpenWebUI-User-Email": quote(user.email),
"X-OpenWebUI-User-Role": quote(user.role),
}
if ENABLE_FORWARD_USER_INFO_HEADERS and user
else {}
@ -824,10 +826,10 @@ async def copy_model(
**({"Authorization": f"Bearer {key}"} if key else {}),
**(
{
"X-OpenWebUI-User-Name": user.name,
"X-OpenWebUI-User-Id": user.id,
"X-OpenWebUI-User-Email": user.email,
"X-OpenWebUI-User-Role": user.role,
"X-OpenWebUI-User-Name": quote(user.name),
"X-OpenWebUI-User-Id": quote(user.id),
"X-OpenWebUI-User-Email": quote(user.email),
"X-OpenWebUI-User-Role": quote(user.role),
}
if ENABLE_FORWARD_USER_INFO_HEADERS and user
else {}
@ -890,10 +892,10 @@ async def delete_model(
**({"Authorization": f"Bearer {key}"} if key else {}),
**(
{
"X-OpenWebUI-User-Name": user.name,
"X-OpenWebUI-User-Id": user.id,
"X-OpenWebUI-User-Email": user.email,
"X-OpenWebUI-User-Role": user.role,
"X-OpenWebUI-User-Name": quote(user.name),
"X-OpenWebUI-User-Id": quote(user.id),
"X-OpenWebUI-User-Email": quote(user.email),
"X-OpenWebUI-User-Role": quote(user.role),
}
if ENABLE_FORWARD_USER_INFO_HEADERS and user
else {}
@ -949,10 +951,10 @@ async def show_model_info(
**({"Authorization": f"Bearer {key}"} if key else {}),
**(
{
"X-OpenWebUI-User-Name": user.name,
"X-OpenWebUI-User-Id": user.id,
"X-OpenWebUI-User-Email": user.email,
"X-OpenWebUI-User-Role": user.role,
"X-OpenWebUI-User-Name": quote(user.name),
"X-OpenWebUI-User-Id": quote(user.id),
"X-OpenWebUI-User-Email": quote(user.email),
"X-OpenWebUI-User-Role": quote(user.role),
}
if ENABLE_FORWARD_USER_INFO_HEADERS and user
else {}
@ -1036,10 +1038,10 @@ async def embed(
**({"Authorization": f"Bearer {key}"} if key else {}),
**(
{
"X-OpenWebUI-User-Name": user.name,
"X-OpenWebUI-User-Id": user.id,
"X-OpenWebUI-User-Email": user.email,
"X-OpenWebUI-User-Role": user.role,
"X-OpenWebUI-User-Name": quote(user.name),
"X-OpenWebUI-User-Id": quote(user.id),
"X-OpenWebUI-User-Email": quote(user.email),
"X-OpenWebUI-User-Role": quote(user.role),
}
if ENABLE_FORWARD_USER_INFO_HEADERS and user
else {}
@ -1123,10 +1125,10 @@ async def embeddings(
**({"Authorization": f"Bearer {key}"} if key else {}),
**(
{
"X-OpenWebUI-User-Name": user.name,
"X-OpenWebUI-User-Id": user.id,
"X-OpenWebUI-User-Email": user.email,
"X-OpenWebUI-User-Role": user.role,
"X-OpenWebUI-User-Name": quote(user.name),
"X-OpenWebUI-User-Id": quote(user.id),
"X-OpenWebUI-User-Email": quote(user.email),
"X-OpenWebUI-User-Role": quote(user.role),
}
if ENABLE_FORWARD_USER_INFO_HEADERS and user
else {}

View file

@ -8,7 +8,7 @@ from typing import Literal, Optional, overload
import aiohttp
from aiocache import cached
import requests
from urllib.parse import quote
from fastapi import Depends, FastAPI, HTTPException, Request, APIRouter
from fastapi.middleware.cors import CORSMiddleware
@ -21,6 +21,7 @@ from open_webui.config import (
CACHE_DIR,
)
from open_webui.env import (
MODELS_CACHE_TTL,
AIOHTTP_CLIENT_SESSION_SSL,
AIOHTTP_CLIENT_TIMEOUT,
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
@ -66,10 +67,10 @@ async def send_get_request(url, key=None, user: UserModel = None):
**({"Authorization": f"Bearer {key}"} if key else {}),
**(
{
"X-OpenWebUI-User-Name": user.name,
"X-OpenWebUI-User-Id": user.id,
"X-OpenWebUI-User-Email": user.email,
"X-OpenWebUI-User-Role": user.role,
"X-OpenWebUI-User-Name": quote(user.name),
"X-OpenWebUI-User-Id": quote(user.id),
"X-OpenWebUI-User-Email": quote(user.email),
"X-OpenWebUI-User-Role": quote(user.role),
}
if ENABLE_FORWARD_USER_INFO_HEADERS and user
else {}
@ -225,10 +226,10 @@ async def speech(request: Request, user=Depends(get_verified_user)):
),
**(
{
"X-OpenWebUI-User-Name": user.name,
"X-OpenWebUI-User-Id": user.id,
"X-OpenWebUI-User-Email": user.email,
"X-OpenWebUI-User-Role": user.role,
"X-OpenWebUI-User-Name": quote(user.name),
"X-OpenWebUI-User-Id": quote(user.id),
"X-OpenWebUI-User-Email": quote(user.email),
"X-OpenWebUI-User-Role": quote(user.role),
}
if ENABLE_FORWARD_USER_INFO_HEADERS
else {}
@ -386,7 +387,7 @@ async def get_filtered_models(models, user):
return filtered_models
@cached(ttl=1)
@cached(ttl=MODELS_CACHE_TTL)
async def get_all_models(request: Request, user: UserModel) -> dict[str, list]:
log.info("get_all_models()")
@ -478,10 +479,10 @@ async def get_models(
"Content-Type": "application/json",
**(
{
"X-OpenWebUI-User-Name": user.name,
"X-OpenWebUI-User-Id": user.id,
"X-OpenWebUI-User-Email": user.email,
"X-OpenWebUI-User-Role": user.role,
"X-OpenWebUI-User-Name": quote(user.name),
"X-OpenWebUI-User-Id": quote(user.id),
"X-OpenWebUI-User-Email": quote(user.email),
"X-OpenWebUI-User-Role": quote(user.role),
}
if ENABLE_FORWARD_USER_INFO_HEADERS
else {}
@ -573,10 +574,10 @@ async def verify_connection(
"Content-Type": "application/json",
**(
{
"X-OpenWebUI-User-Name": user.name,
"X-OpenWebUI-User-Id": user.id,
"X-OpenWebUI-User-Email": user.email,
"X-OpenWebUI-User-Role": user.role,
"X-OpenWebUI-User-Name": quote(user.name),
"X-OpenWebUI-User-Id": quote(user.id),
"X-OpenWebUI-User-Email": quote(user.email),
"X-OpenWebUI-User-Role": quote(user.role),
}
if ENABLE_FORWARD_USER_INFO_HEADERS
else {}
@ -633,13 +634,7 @@ async def verify_connection(
raise HTTPException(status_code=500, detail=error_detail)
def convert_to_azure_payload(
url,
payload: dict,
):
model = payload.get("model", "")
# Filter allowed parameters based on Azure OpenAI API
def get_azure_allowed_params(api_version: str) -> set[str]:
allowed_params = {
"messages",
"temperature",
@ -669,6 +664,23 @@ def convert_to_azure_payload(
"max_completion_tokens",
}
try:
if api_version >= "2024-09-01-preview":
allowed_params.add("stream_options")
except ValueError:
log.debug(
f"Invalid API version {api_version} for Azure OpenAI. Defaulting to allowed parameters."
)
return allowed_params
def convert_to_azure_payload(url, payload: dict, api_version: str):
model = payload.get("model", "")
# Filter allowed parameters based on Azure OpenAI API
allowed_params = get_azure_allowed_params(api_version)
# Special handling for o-series models
if model.startswith("o") and model.endswith("-mini"):
# Convert max_tokens to max_completion_tokens for o-series models
@ -806,10 +818,10 @@ async def generate_chat_completion(
),
**(
{
"X-OpenWebUI-User-Name": user.name,
"X-OpenWebUI-User-Id": user.id,
"X-OpenWebUI-User-Email": user.email,
"X-OpenWebUI-User-Role": user.role,
"X-OpenWebUI-User-Name": quote(user.name),
"X-OpenWebUI-User-Id": quote(user.id),
"X-OpenWebUI-User-Email": quote(user.email),
"X-OpenWebUI-User-Role": quote(user.role),
}
if ENABLE_FORWARD_USER_INFO_HEADERS
else {}
@ -817,8 +829,8 @@ async def generate_chat_completion(
}
if api_config.get("azure", False):
request_url, payload = convert_to_azure_payload(url, payload)
api_version = api_config.get("api_version", "") or "2023-03-15-preview"
api_version = api_config.get("api_version", "2023-03-15-preview")
request_url, payload = convert_to_azure_payload(url, payload, api_version)
headers["api-key"] = key
headers["api-version"] = api_version
request_url = f"{request_url}/chat/completions?api-version={api_version}"
@ -924,10 +936,10 @@ async def embeddings(request: Request, form_data: dict, user):
"Content-Type": "application/json",
**(
{
"X-OpenWebUI-User-Name": user.name,
"X-OpenWebUI-User-Id": user.id,
"X-OpenWebUI-User-Email": user.email,
"X-OpenWebUI-User-Role": user.role,
"X-OpenWebUI-User-Name": quote(user.name),
"X-OpenWebUI-User-Id": quote(user.id),
"X-OpenWebUI-User-Email": quote(user.email),
"X-OpenWebUI-User-Role": quote(user.role),
}
if ENABLE_FORWARD_USER_INFO_HEADERS and user
else {}
@ -996,10 +1008,10 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
"Content-Type": "application/json",
**(
{
"X-OpenWebUI-User-Name": user.name,
"X-OpenWebUI-User-Id": user.id,
"X-OpenWebUI-User-Email": user.email,
"X-OpenWebUI-User-Role": user.role,
"X-OpenWebUI-User-Name": quote(user.name),
"X-OpenWebUI-User-Id": quote(user.id),
"X-OpenWebUI-User-Email": quote(user.email),
"X-OpenWebUI-User-Role": quote(user.role),
}
if ENABLE_FORWARD_USER_INFO_HEADERS
else {}
@ -1007,16 +1019,15 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
}
if api_config.get("azure", False):
api_version = api_config.get("api_version", "2023-03-15-preview")
headers["api-key"] = key
headers["api-version"] = (
api_config.get("api_version", "") or "2023-03-15-preview"
)
headers["api-version"] = api_version
payload = json.loads(body)
url, payload = convert_to_azure_payload(url, payload)
url, payload = convert_to_azure_payload(url, payload, api_version)
body = json.dumps(payload).encode()
request_url = f"{url}/{path}?api-version={api_config.get('api_version', '2023-03-15-preview')}"
request_url = f"{url}/{path}?api-version={api_version}"
else:
headers["Authorization"] = f"Bearer {key}"
request_url = f"{url}/{path}"

View file

@ -1747,6 +1747,16 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]:
)
else:
raise Exception("No TAVILY_API_KEY found in environment variables")
elif engine == "exa":
if request.app.state.config.EXA_API_KEY:
return search_exa(
request.app.state.config.EXA_API_KEY,
query,
request.app.state.config.WEB_SEARCH_RESULT_COUNT,
request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
)
else:
raise Exception("No EXA_API_KEY found in environment variables")
elif engine == "searchapi":
if request.app.state.config.SEARCHAPI_API_KEY:
return search_searchapi(
@ -1784,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_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":
return search_perplexity(
request.app.state.config.PERPLEXITY_API_KEY,

View file

@ -1,4 +1,6 @@
import asyncio
import random
import socketio
import logging
import sys
@ -105,10 +107,26 @@ else:
async def periodic_usage_pool_cleanup():
if not aquire_func():
log.debug("Usage pool cleanup lock already exists. Not running it.")
max_retries = 2
retry_delay = random.uniform(
WEBSOCKET_REDIS_LOCK_TIMEOUT / 2, WEBSOCKET_REDIS_LOCK_TIMEOUT
)
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_usage_pool_cleanup")
log.debug("Running periodic_cleanup")
try:
while True:
if not renew_func():

View file

@ -419,7 +419,7 @@ async def chat_action(request: Request, action_id: str, form_data: dict, user: A
params[key] = value
if "__user__" in sig.parameters:
__user__ = (user.model_dump() if isinstance(user, UserModel) else {},)
__user__ = user.model_dump() if isinstance(user, UserModel) else {}
try:
if hasattr(function_module, "UserValves"):

View file

@ -248,6 +248,7 @@ async def chat_completion_tools_handler(
if tool_id
else f"{tool_function_name}"
)
if tool.get("metadata", {}).get("citation", False) or tool.get(
"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):
# 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)
log.debug(f"form_data: {form_data}")
@ -804,7 +809,6 @@ async def process_chat_payload(request, form_data, user, metadata, model):
raise e
try:
filter_functions = [
Functions.get_function_by_id(filter_id)
for filter_id in get_sorted_filter_ids(
@ -912,7 +916,6 @@ async def process_chat_payload(request, form_data, user, metadata, model):
request, form_data, extra_params, user, models, tools_dict
)
sources.extend(flags.get("sources", []))
except Exception as e:
log.exception(e)
@ -925,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 len(sources) > 0:
context_string = ""
citation_idx = {}
citation_idx_map = {}
for source in sources:
if "document" in source:
for doc_context, doc_meta in zip(
for document_text, document_metadata in zip(
source["document"], source["metadata"]
):
source_name = source.get("source", {}).get("name", None)
citation_id = (
doc_meta.get("source", None)
source_id = (
document_metadata.get("source", None)
or source.get("source", {}).get("id", None)
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 += (
f'<source id="{citation_idx[citation_id]}"'
f'<source id="{citation_idx_map[source_id]}"'
+ (f' name="{source_name}"' if source_name else "")
+ f">{doc_context}</source>\n"
+ f">{document_text}</source>\n"
)
context_string = context_string.strip()
@ -1370,7 +1376,7 @@ async def process_chat_response(
return len(backtick_segments) > 1 and len(backtick_segments) % 2 == 0
# 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):
content = ""
@ -1741,7 +1747,7 @@ async def process_chat_response(
},
)
async def stream_body_handler(response):
async def stream_body_handler(response, form_data):
nonlocal content
nonlocal content_blocks
@ -1770,7 +1776,7 @@ async def process_chat_response(
filter_functions=filter_functions,
filter_type="stream",
form_data=data,
extra_params=extra_params,
extra_params={"__body__": form_data, **extra_params},
)
if data:
@ -2032,7 +2038,7 @@ async def process_chat_response(
if response.background:
await response.background()
await stream_body_handler(response)
await stream_body_handler(response, form_data)
MAX_TOOL_CALL_RETRIES = 10
tool_call_retries = 0
@ -2181,9 +2187,7 @@ async def process_chat_response(
)
try:
res = await generate_chat_completion(
request,
{
new_form_data = {
"model": model_id,
"stream": True,
"tools": form_data["tools"],
@ -2191,12 +2195,16 @@ async def process_chat_response(
*form_data["messages"],
*convert_content_blocks_to_messages(content_blocks),
],
},
}
res = await generate_chat_completion(
request,
new_form_data,
user,
)
if isinstance(res, StreamingResponse):
await stream_body_handler(res)
await stream_body_handler(res, new_form_data)
else:
break
except Exception as e:
@ -2427,9 +2435,9 @@ async def process_chat_response(
if response.background is not None:
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(
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}

View file

@ -76,8 +76,16 @@ async def get_all_base_models(request: Request, user: UserModel = None):
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):
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 len(models) == 0:

View file

@ -1,9 +1,13 @@
from fastapi import FastAPI
from opentelemetry import trace
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.trace import TracerProvider
from sqlalchemy import Engine
from base64 import b64encode
from open_webui.utils.telemetry.exporters import LazyBatchSpanProcessor
from open_webui.utils.telemetry.instrumentors import Instrumentor
@ -11,7 +15,11 @@ from open_webui.utils.telemetry.metrics import setup_metrics
from open_webui.env import (
OTEL_SERVICE_NAME,
OTEL_EXPORTER_OTLP_ENDPOINT,
OTEL_EXPORTER_OTLP_INSECURE,
ENABLE_OTEL_METRICS,
OTEL_BASIC_AUTH_USERNAME,
OTEL_BASIC_AUTH_PASSWORD,
OTEL_OTLP_SPAN_EXPORTER,
)
@ -22,8 +30,27 @@ def setup(app: FastAPI, db_engine: Engine):
resource=Resource.create(attributes={SERVICE_NAME: OTEL_SERVICE_NAME})
)
)
# Add basic auth header only if both username and password are not empty
headers = []
if OTEL_BASIC_AUTH_USERNAME and OTEL_BASIC_AUTH_PASSWORD:
auth_string = f"{OTEL_BASIC_AUTH_USERNAME}:{OTEL_BASIC_AUTH_PASSWORD}"
auth_header = b64encode(auth_string.encode()).decode()
headers = [("authorization", f"Basic {auth_header}")]
# otlp export
exporter = OTLPSpanExporter(endpoint=OTEL_EXPORTER_OTLP_ENDPOINT)
if OTEL_OTLP_SPAN_EXPORTER == "http":
exporter = HttpOTLPSpanExporter(
endpoint=OTEL_EXPORTER_OTLP_ENDPOINT,
insecure=OTEL_EXPORTER_OTLP_INSECURE,
headers=headers,
)
else:
exporter = OTLPSpanExporter(
endpoint=OTEL_EXPORTER_OTLP_ENDPOINT,
insecure=OTEL_EXPORTER_OTLP_INSECURE,
headers=headers,
)
trace.get_tracer_provider().add_span_processor(LazyBatchSpanProcessor(exporter))
Instrumentor(app=app, db_engine=db_engine).instrument()

View file

@ -101,9 +101,6 @@ def get_tools(
def make_tool_function(function_name, token, tool_server_data):
async def tool_function(**kwargs):
print(
f"Executing tool function {function_name} with params: {kwargs}"
)
return await execute_tool_server(
token=token,
url=tool_server_data["url"],

View file

@ -1,6 +1,6 @@
fastapi==0.115.7
uvicorn[standard]==0.34.2
pydantic==2.10.6
uvicorn[standard]==0.35.0
pydantic==2.11.7
python-multipart==0.0.20
python-socketio==5.13.0
@ -42,8 +42,8 @@ google-genai==1.15.0
google-generativeai==0.8.5
tiktoken
langchain==0.3.24
langchain-community==0.3.23
langchain==0.3.26
langchain-community==0.3.26
fake-useragent==2.1.0
chromadb==0.6.3
@ -114,7 +114,7 @@ pytest-docker~=3.1.1
googleapis-common-protos==1.63.2
google-cloud-storage==2.19.0
azure-identity==1.21.0
azure-identity==1.23.0
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%"
:: 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.
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",
"async": "^3.2.5",
"bits-ui": "^0.21.15",
"chart.js": "^4.5.0",
"codemirror": "^6.0.1",
"codemirror-lang-elixir": "^4.0.0",
"codemirror-lang-hcl": "^0.1.0",
@ -42,9 +43,10 @@
"file-saver": "^2.0.5",
"focus-trap": "^7.6.4",
"fuse.js": "^7.0.0",
"heic2any": "^0.0.4",
"highlight.js": "^11.9.0",
"html-entities": "^2.5.3",
"html2canvas-pro": "^1.5.8",
"html2canvas-pro": "^1.5.11",
"i18next": "^23.10.0",
"i18next-browser-languagedetector": "^7.2.0",
"i18next-resources-to-backend": "^1.2.0",
@ -53,6 +55,7 @@
"jspdf": "^3.0.0",
"katex": "^0.16.22",
"kokoro-js": "^1.1.1",
"leaflet": "^1.9.4",
"marked": "^9.1.0",
"mermaid": "^11.6.0",
"paneforge": "^0.0.6",
@ -70,7 +73,7 @@
"prosemirror-view": "^1.34.3",
"pyodide": "^0.27.3",
"socket.io-client": "^4.2.0",
"sortablejs": "^1.15.2",
"sortablejs": "^1.15.6",
"svelte-sonner": "^0.3.19",
"tippy.js": "^6.3.7",
"turndown": "^7.2.0",
@ -1870,6 +1873,12 @@
"@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": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.1.tgz",
@ -4723,6 +4732,18 @@
"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": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz",
@ -7295,6 +7316,12 @@
"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": {
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/heimdalljs/-/heimdalljs-0.2.6.tgz",
@ -7379,9 +7406,9 @@
}
},
"node_modules/html2canvas-pro": {
"version": "1.5.8",
"resolved": "https://registry.npmjs.org/html2canvas-pro/-/html2canvas-pro-1.5.8.tgz",
"integrity": "sha512-bVGAU7IvhBwBlRAmX6QhekX8lsaxmYoF6zIwf/HNlHscjx+KN8jw/U4PQRYqeEVm9+m13hcS1l5ChJB9/e29Lw==",
"version": "1.5.11",
"resolved": "https://registry.npmjs.org/html2canvas-pro/-/html2canvas-pro-1.5.11.tgz",
"integrity": "sha512-W4pEeKLG8+9a54RDOSiEKq7gRXXDzt0ORMaLXX+l6a3urSKbmnkmyzcRDCtgTOzmHLaZTLG2wiTQMJqKLlSh3w==",
"license": "MIT",
"dependencies": {
"css-line-break": "^2.1.0",
@ -8046,6 +8073,12 @@
"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": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@ -11138,9 +11171,10 @@
}
},
"node_modules/sortablejs": {
"version": "1.15.2",
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.2.tgz",
"integrity": "sha512-FJF5jgdfvoKn1MAKSdGs33bIqLi3LmsgVTliuX6iITj834F+JRQZN90Z93yql8h0K2t0RwDPBmxwlbZfDcxNZA=="
"version": "1.15.6",
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.6.tgz",
"integrity": "sha512-aNfiuwMEpfBM/CN6LY0ibyhxPfPbyFeBTYJKCvzkJ2GkUpazIt3H+QIPAMHwqQ7tMKaHz1Qj+rJJCqljnf4p3A==",
"license": "MIT"
},
"node_modules/source-map-js": {
"version": "1.2.1",

View file

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

View file

@ -8,7 +8,7 @@ license = { file = "LICENSE" }
dependencies = [
"fastapi==0.115.7",
"uvicorn[standard]==0.34.2",
"pydantic==2.10.6",
"pydantic==2.11.7",
"python-multipart==0.0.20",
"python-socketio==5.13.0",
@ -50,8 +50,8 @@ dependencies = [
"google-generativeai==0.8.5",
"tiktoken",
"langchain==0.3.24",
"langchain-community==0.3.23",
"langchain==0.3.26",
"langchain-community==0.3.26",
"fake-useragent==2.1.0",
"chromadb==0.6.3",
@ -138,7 +138,7 @@ requires-python = ">= 3.11, < 3.13.0a1"
dynamic = ["version"]
classifiers = [
"Development Status :: 4 - Beta",
"License :: OSI Approved :: MIT License",
"License :: Other/Proprietary License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",

View file

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

View file

@ -33,6 +33,7 @@
</script>
<script>
// On page load or when changing themes, best to add inline in `head` to avoid FOUC
(() => {
const metaThemeColorTag = document.querySelector('meta[name="theme-color"]');
@ -77,28 +78,17 @@
}
}
});
function setSplashImage() {
const logo = document.getElementById('logo');
const isDarkMode = document.documentElement.classList.contains('dark');
if (isDarkMode) {
const darkImage = new Image();
darkImage.src = '/static/splash-dark.png';
const logo = document.createElement('img');
logo.id = 'logo';
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';
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;
document.addEventListener('DOMContentLoaded', function() {
const splash = document.getElementById('splash-screen');
if (splash) splash.prepend(logo);
});
})();
</script>
@ -120,18 +110,6 @@
}
</style>
<img
id="logo"
style="
position: absolute;
width: auto;
height: 6rem;
top: 44%;
left: 50%;
transform: translateX(-50%);
"
src="/static/splash.png"
/>
<div
style="

View file

@ -347,6 +347,8 @@ export const userSignOut = async () => {
if (error) {
throw error;
}
sessionStorage.clear();
return res;
};

View file

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

View file

@ -58,10 +58,10 @@ export const exportConfig = async (token: string) => {
return res;
};
export const getDirectConnectionsConfig = async (token: string) => {
export const getConnectionsConfig = async (token: string) => {
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',
headers: {
'Content-Type': 'application/json',
@ -85,10 +85,10 @@ export const getDirectConnectionsConfig = async (token: string) => {
return res;
};
export const setDirectConnectionsConfig = async (token: string, config: object) => {
export const setConnectionsConfig = async (token: string, config: object) => {
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',
headers: {
'Content-Type': 'application/json',

View file

@ -8,17 +8,26 @@ import { toast } from 'svelte-sonner';
export const getModels = async (
token: string = '',
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;
const res = await fetch(`${WEBUI_BASE_URL}/api/models${base ? '/base' : ''}`, {
const res = await fetch(
`${WEBUI_BASE_URL}/api/models${base ? '/base' : ''}?${searchParams.toString()}`,
{
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
}
})
}
)
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
@ -1587,6 +1596,7 @@ export interface ModelConfig {
}
export interface ModelMeta {
toolIds: never[];
description?: string;
capabilities?: object;
profile_image_url?: string;

View file

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

View file

@ -15,6 +15,8 @@
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Switch from '$lib/components/common/Switch.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 onDelete: Function = () => {};
@ -208,17 +210,7 @@
show = false;
}}
>
<svg
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>
<XMark className={'size-5'} />
</button>
</div>
@ -524,29 +516,7 @@
{#if loading}
<div class="ml-2 self-center">
<svg
class=" w-4 h-4"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
><style>
.spinner_ajPY {
transform-origin: center;
animation: spinner_AtaB 0.75s infinite linear;
}
@keyframes spinner_AtaB {
100% {
transform: rotate(360deg);
}
}
</style><path
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
opacity=".25"
/><path
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
class="spinner_ajPY"
/></svg
>
<Spinner />
</div>
{/if}
</button>

View file

@ -3,6 +3,7 @@
import { getContext, onMount } from 'svelte';
const i18n = getContext('i18n');
import { settings } from '$lib/stores';
import Modal from '$lib/components/common/Modal.svelte';
import Plus from '$lib/components/icons/Plus.svelte';
import Minus from '$lib/components/icons/Minus.svelte';
@ -14,6 +15,8 @@
import { getToolServerData } from '$lib/apis';
import { verifyToolServerConnection } from '$lib/apis/configs';
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 onDelete: Function = () => {};
@ -153,29 +156,21 @@
<Modal size="sm" bind:show>
<div>
<div class=" flex justify-between dark:text-gray-100 px-5 pt-4 pb-2">
<div class=" text-lg font-medium self-center font-primary">
<h1 class=" text-lg font-medium self-center font-primary">
{#if edit}
{$i18n.t('Edit Connection')}
{:else}
{$i18n.t('Add Connection')}
{/if}
</div>
</h1>
<button
class="self-center"
aria-label={$i18n.t('Close Configure Connection Modal')}
on:click={() => {
show = false;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
<XMark className={'size-5'} />
</button>
</div>
@ -192,12 +187,17 @@
<div class="flex gap-2">
<div class="flex flex-col w-full">
<div class="flex justify-between mb-0.5">
<div class=" text-xs text-gray-500">{$i18n.t('URL')}</div>
<label
for="api-base-url"
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>{$i18n.t('URL')}</label
>
</div>
<div class="flex flex-1 items-center">
<input
class="w-full flex-1 text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
id="api-base-url"
class={`w-full flex-1 text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
type="text"
bind:value={url}
placeholder={$i18n.t('API Base URL')}
@ -214,6 +214,7 @@
on:click={() => {
verifyHandler();
}}
aria-label={$i18n.t('Verify Connection')}
type="button"
>
<svg
@ -221,6 +222,7 @@
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
aria-hidden="true"
>
<path
fill-rule="evenodd"
@ -237,9 +239,13 @@
</div>
<div class="flex-1 flex items-center">
<label for="url-or-path" class="sr-only"
>{$i18n.t('openapi.json URL or Path')}</label
>
<input
class="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
type="text"
id="url-or-path"
bind:value={path}
placeholder={$i18n.t('openapi.json URL or Path')}
autocomplete="off"
@ -249,7 +255,9 @@
</div>
</div>
<div class="text-xs text-gray-500 mt-1">
<div
class={`text-xs mt-1 ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t(`WebUI will make requests to "{{url}}"`, {
url: path.includes('://') ? path : `${url}${path.startsWith('/') ? '' : '/'}${path}`
})}
@ -257,12 +265,17 @@
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<div class=" text-xs text-gray-500">{$i18n.t('Auth')}</div>
<label
for="select-bearer-or-session"
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>{$i18n.t('Auth')}</label
>
<div class="flex gap-2">
<div class="flex-shrink-0 self-start">
<select
class="w-full text-sm bg-transparent dark:bg-gray-900 placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden pr-5"
id="select-bearer-or-session"
class={`w-full text-sm bg-transparent pr-5 ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
bind:value={auth_type}
>
<option value="bearer">Bearer</option>
@ -273,13 +286,14 @@
<div class="flex flex-1 items-center">
{#if auth_type === 'bearer'}
<SensitiveInput
className="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
bind:value={key}
placeholder={$i18n.t('API Key')}
required={false}
/>
{:else if auth_type === 'session'}
<div class="text-xs text-gray-500 self-center translate-y-[1px]">
<div
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t('Forwards system user session credentials to authenticate')}
</div>
{/if}
@ -293,11 +307,16 @@
<div class="flex gap-2">
<div class="flex flex-col w-full">
<div class=" mb-0.5 text-xs text-gray-500">{$i18n.t('Name')}</div>
<label
for="enter-name"
class={`mb-0.5 text-xs" ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>{$i18n.t('Name')}</label
>
<div class="flex-1">
<input
class="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
id="enter-name"
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
type="text"
bind:value={name}
placeholder={$i18n.t('Enter name')}
@ -309,11 +328,16 @@
</div>
<div class="flex flex-col w-full mt-2">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Description')}</div>
<label
for="description"
class={`mb-1 text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100 placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700 text-gray-500'}`}
>{$i18n.t('Description')}</label
>
<div class="flex-1">
<input
class="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
id="description"
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
type="text"
bind:value={description}
placeholder={$i18n.t('Enter description')}
@ -357,29 +381,7 @@
{#if loading}
<div class="ml-2 self-center">
<svg
class=" w-4 h-4"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
><style>
.spinner_ajPY {
transform-origin: center;
animation: spinner_AtaB 0.75s infinite linear;
}
@keyframes spinner_AtaB {
100% {
transform: rotate(360deg);
}
}
</style><path
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
opacity=".25"
/><path
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
class="spinner_ajPY"
/></svg
>
<Spinner />
</div>
{/if}
</button>

View file

@ -9,6 +9,7 @@
import Modal from './common/Modal.svelte';
import { updateUserSettings } from '$lib/apis/users';
import XMark from '$lib/components/icons/XMark.svelte';
const i18n = getContext('i18n');
@ -36,18 +37,11 @@
localStorage.version = $config.version;
show = false;
}}
aria-label={$i18n.t('Close')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<XMark className={'size-5'}>
<p class="sr-only">{$i18n.t('Close')}</p>
<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>
</XMark>
</button>
</div>
<div class="flex items-center mt-1">

View file

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

View file

@ -2,16 +2,42 @@
import Modal from '$lib/components/common/Modal.svelte';
import { getContext } from 'svelte';
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 selectedFeedback = null;
export let onClose: () => void = () => {};
let loaded = false;
let feedbackData = null;
const close = () => {
show = false;
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>
<Modal size="sm" bind:show>
@ -22,49 +48,75 @@
{$i18n.t('Feedback Details')}
</div>
<button class="self-center" on:click={close} aria-label="Close">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
<XMark className={'size-5'} />
</button>
</div>
<div class="flex flex-col md:flex-row w-full px-5 pb-4 md:space-x-4 dark:text-gray-200">
{#if loaded}
<div class="flex flex-col w-full">
{#if feedbackData}
{@const messageId = feedbackData?.meta?.message_id}
{@const messages = feedbackData?.snapshot?.chat?.chat?.history.messages}
{#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"
>
<span>{messages[messageId]?.content || '-'}</span>
</div>
</div>
{/if}
{/if}
<div class="flex flex-col w-full mb-2">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Rating')}</div>
<div class="flex-1">
<div class="flex-1 text-xs">
<span>{selectedFeedback?.data?.details?.rating ?? '-'}</span>
</div>
</div>
<div class="flex flex-col w-full mb-2">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Reason')}</div>
<div class="flex-1">
<div class="flex-1 text-xs">
<span>{selectedFeedback?.data?.reason || '-'}</span>
</div>
</div>
<div class="mb-2">
<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 bg-gray-100 dark:bg-gray-800 text-xs">{tag}</span
<span class="px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-850 text-[9px]"
>{tag}</span
>
{/each}
</div>
{:else}
<span>-</span>
{/if}
</div>
<div class="flex justify-end pt-3">
{/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"
@ -74,6 +126,11 @@
</button>
</div>
</div>
{:else}
<div class="flex items-center justify-center w-full h-32">
<Spinner className={'size-5'} />
</div>
{/if}
</div>
</div>
{/if}

View file

@ -305,7 +305,7 @@
<tbody class="">
{#each paginatedFeedbacks as feedback (feedback.id)}
<tr
class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 transition"
class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-850/50 transition"
on:click={() => openFeedbackModal(feedback)}
>
<td class=" py-0.5 text-right font-semibold">
@ -369,7 +369,7 @@
{dayjs(feedback.updated_at * 1000).fromNow()}
</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
on:delete={(e) => {
deleteFeedbackHandler(feedback.id);

View file

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

View file

@ -6,6 +6,7 @@
export let feedbacks = [];
export let onClose: () => void = () => {};
const i18n = getContext('i18n');
import XMark from '$lib/components/icons/XMark.svelte';
const close = () => {
show = false;
@ -37,25 +38,16 @@
{model.name}
</div>
<button class="self-center" on:click={close} aria-label="Close">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
<XMark className={'size-5'} />
</button>
</div>
<div class="px-5 pb-4 dark:text-gray-200">
<div class="mb-2">
{#if topTags.length}
<div class="flex flex-wrap gap-1 mt-1">
<div class="flex flex-wrap gap-1 mt-1 -mx-1">
{#each topTags as tagInfo}
<span class="px-2 py-0.5 rounded bg-gray-100 dark:bg-gray-800 text-xs">
{tagInfo.tag} <span class="text-gray-500">({tagInfo.count})</span>
<span class="px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-850 text-xs">
{tagInfo.tag} <span class="text-gray-500 font-medium">{tagInfo.count}</span>
</span>
{/each}
</div>
@ -63,7 +55,7 @@
<span>-</span>
{/if}
</div>
<div class="flex justify-end pt-3">
<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"

View file

@ -135,7 +135,9 @@
models.set(
await getModels(
localStorage.token,
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null),
false,
true
)
);
}
@ -161,7 +163,9 @@
models.set(
await getModels(
localStorage.token,
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null),
false,
true
)
);
}
@ -413,7 +417,9 @@
await getModels(
localStorage.token,
$config?.features?.enable_direct_connections &&
($settings?.directConnections ?? null)
($settings?.directConnections ?? null),
false,
true
)
);
}}
@ -559,7 +565,9 @@
models.set(
await getModels(
localStorage.token,
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null),
false,
true
)
);
}}
@ -585,7 +593,9 @@
models.set(
await getModels(
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';
import { config, settings } from '$lib/stores';
import Spinner from '$lib/components/common/Spinner.svelte';
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
import { TTS_RESPONSE_SPLIT } from '$lib/types';
@ -373,33 +374,7 @@
>
{#if STT_WHISPER_MODEL_LOADING}
<div class="self-center">
<svg
class=" w-4 h-4"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<style>
.spinner_ajPY {
transform-origin: center;
animation: spinner_AtaB 0.75s infinite linear;
}
@keyframes spinner_AtaB {
100% {
transform: rotate(360deg);
}
}
</style>
<path
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
opacity=".25"
/>
<path
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
class="spinner_ajPY"
/>
</svg>
<Spinner />
</div>
{:else}
<svg

View file

@ -7,7 +7,7 @@
import { getOllamaConfig, updateOllamaConfig } from '$lib/apis/ollama';
import { getOpenAIConfig, updateOpenAIConfig, getOpenAIModels } from '$lib/apis/openai';
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';
@ -25,7 +25,9 @@
const getModels = async () => {
const models = await _getModels(
localStorage.token,
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null),
false,
true
);
return models;
};
@ -41,7 +43,7 @@
let ENABLE_OPENAI_API: null | boolean = null;
let ENABLE_OLLAMA_API: null | boolean = null;
let directConnectionsConfig = null;
let connectionsConfig = null;
let pipelineUrls = {};
let showAddOpenAIConnectionModal = false;
@ -104,15 +106,13 @@
}
};
const updateDirectConnectionsHandler = async () => {
const res = await setDirectConnectionsConfig(localStorage.token, directConnectionsConfig).catch(
(error) => {
const updateConnectionsHandler = async () => {
const res = await setConnectionsConfig(localStorage.token, connectionsConfig).catch((error) => {
toast.error(`${error}`);
}
);
});
if (res) {
toast.success($i18n.t('Direct Connections settings updated'));
toast.success($i18n.t('Connections settings updated'));
await models.set(await getModels());
}
};
@ -148,7 +148,7 @@
openaiConfig = await getOpenAIConfig(localStorage.token);
})(),
(async () => {
directConnectionsConfig = await getDirectConnectionsConfig(localStorage.token);
connectionsConfig = await getConnectionsConfig(localStorage.token);
})()
]);
@ -215,9 +215,14 @@
<form class="flex flex-col h-full justify-between text-sm" on:submit|preventDefault={submitHandler}>
<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="mb-3.5">
<div class=" mb-2.5 text-base font-medium">{$i18n.t('General')}</div>
<hr class=" border-gray-100 dark:border-gray-850 my-2" />
<div class="my-2">
<div class="mt-2 space-y-2 pr-1.5">
<div class="mt-2 space-y-2">
<div class="flex justify-between items-center text-sm">
<div class=" font-medium">{$i18n.t('OpenAI API')}</div>
@ -234,11 +239,9 @@
</div>
{#if ENABLE_OPENAI_API}
<hr class=" border-gray-100 dark:border-gray-850" />
<div class="">
<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 OpenAI API Connections')}</div>
<Tooltip content={$i18n.t(`Add Connection`)}>
<button
@ -271,7 +274,8 @@
let newConfig = {};
OPENAI_API_BASE_URLS.forEach((url, newIdx) => {
newConfig[newIdx] = OPENAI_API_CONFIGS[newIdx < idx ? newIdx : newIdx + 1];
newConfig[newIdx] =
OPENAI_API_CONFIGS[newIdx < idx ? newIdx : newIdx + 1];
});
OPENAI_API_CONFIGS = newConfig;
updateOpenAIHandler();
@ -284,9 +288,7 @@
</div>
</div>
<hr class=" border-gray-100 dark:border-gray-850" />
<div class="pr-1.5 my-2">
<div class=" my-2">
<div class="flex justify-between items-center text-sm mb-2">
<div class=" font-medium">{$i18n.t('Ollama API')}</div>
@ -301,11 +303,9 @@
</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>
<div class="font-medium text-xs">{$i18n.t('Manage Ollama API Connections')}</div>
<Tooltip content={$i18n.t(`Add Connection`)}>
<button
@ -335,7 +335,8 @@
let newConfig = {};
OLLAMA_BASE_URLS.forEach((url, newIdx) => {
newConfig[newIdx] = OLLAMA_API_CONFIGS[newIdx < idx ? newIdx : newIdx + 1];
newConfig[newIdx] =
OLLAMA_API_CONFIGS[newIdx < idx ? newIdx : newIdx + 1];
});
OLLAMA_API_CONFIGS = newConfig;
}}
@ -358,31 +359,53 @@
{/if}
</div>
<hr class=" border-gray-100 dark:border-gray-850" />
<div class="pr-1.5 my-2">
<div class="my-2">
<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}
bind:state={connectionsConfig.ENABLE_DIRECT_CONNECTIONS}
on:change={async () => {
updateDirectConnectionsHandler();
updateConnectionsHandler();
}}
/>
</div>
</div>
</div>
<div class="mt-1.5">
<div class="text-xs text-gray-500">
<div class="mt-1 text-xs text-gray-400 dark:text-gray-500">
{$i18n.t(
'Direct Connections allow users to connect to their own OpenAI compatible API endpoints.'
)}
</div>
</div>
<hr class=" border-gray-100 dark:border-gray-850 my-2" />
<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(
'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>
{:else}
<div class="flex h-full justify-center">

View file

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

View file

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

View file

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

View file

@ -90,7 +90,9 @@
};
onMount(async () => {
if (!$config?.offline_mode) {
checkForVersionUpdates();
}
await Promise.all([
(async () => {
@ -160,6 +162,7 @@
</button>
</div>
{#if !$config?.offline_mode}
<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"
type="button"
@ -169,6 +172,7 @@
>
{$i18n.t('Check for updates')}
</button>
{/if}
</div>
</div>

View file

@ -13,9 +13,11 @@
updateConfig,
verifyConfigUrl
} from '$lib/apis/images';
import Spinner from '$lib/components/common/Spinner.svelte';
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
import Switch from '$lib/components/common/Switch.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Textarea from '$lib/components/common/Textarea.svelte';
const dispatch = createEventDispatcher();
const i18n = getContext('i18n');
@ -504,7 +506,7 @@
<div class=" mb-2 text-sm font-medium">{$i18n.t('ComfyUI Workflow')}</div>
{#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"
rows="10"
bind:value={config.comfyui.COMFYUI_WORKFLOW}
@ -533,7 +535,7 @@
/>
<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"
on:click={() => {
document.getElementById('upload-comfyui-workflow-input')?.click();
@ -555,10 +557,10 @@
<div class="text-xs flex flex-col gap-1.5">
{#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=" 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' ? '*' : ''}
</div>
@ -566,7 +568,7 @@
<div class="">
<Tooltip content="Input Key (e.g. text, unet_name, steps)">
<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"
bind:value={node.key}
required
@ -580,7 +582,7 @@
placement="top-start"
>
<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"
bind:value={node.node_ids}
/>
@ -711,29 +713,7 @@
{#if loading}
<div class="ml-2 self-center">
<svg
class=" w-4 h-4"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
><style>
.spinner_ajPY {
transform-origin: center;
animation: spinner_AtaB 0.75s infinite linear;
}
@keyframes spinner_AtaB {
100% {
transform: rotate(360deg);
}
}
</style><path
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
opacity=".25"
/><path
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
class="spinner_ajPY"
/></svg
>
<Spinner />
</div>
{/if}
</button>

View file

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

View file

@ -1,7 +1,9 @@
<script lang="ts">
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 EllipsisVertical from '$lib/components/icons/EllipsisVertical.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import Sortable from 'sortablejs';
import { getContext } from 'svelte';
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) {
init();
}
@ -44,14 +53,14 @@
};
</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)}
<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" />
<div class="flex flex-row flex-1 gap-2 items-center">
<div class="flex flex-row flex-1 gap-2 items-start">
<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}
required
>
@ -64,10 +73,11 @@
<option value="success" class="text-gray-900">{$i18n.t('Success')}</option>
</select>
<input
class="pr-5 py-1.5 text-xs w-full bg-transparent outline-hidden"
<Textarea
className="mr-2 text-xs w-full bg-transparent outline-hidden resize-none"
placeholder={$i18n.t('Content')}
bind:value={banner.content}
maxSize={100}
/>
<div class="relative -left-2">
@ -85,16 +95,7 @@
banners = banners;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
<XMark className={'size-4'} />
</button>
</div>
{/each}

View file

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

View file

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

View file

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

View file

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

View file

@ -141,6 +141,7 @@
placeholder={$i18n.t('Enter Searxng Query URL')}
bind:value={webConfig.SEARXNG_QUERY_URL}
autocomplete="off"
required
/>
</div>
</div>
@ -248,7 +249,6 @@
bind:value={webConfig.KAGI_SEARCH_API_KEY}
/>
</div>
.
</div>
{:else if webConfig.WEB_SEARCH_ENGINE === 'mojeek'}
<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 ChevronRight from '$lib/components/icons/ChevronRight.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 UserCircleSolid from '$lib/components/icons/UserCircleSolid.svelte';
import GroupModal from './Groups/EditGroupModal.svelte';
@ -159,18 +160,7 @@
<div class=" flex w-full space-x-2">
<div class="flex flex-1">
<div class=" self-center ml-1 mr-3">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z"
clip-rule="evenodd"
/>
</svg>
<Search />
</div>
<input
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';
const i18n = getContext('i18n');
import Spinner from '$lib/components/common/Spinner.svelte';
import Modal from '$lib/components/common/Modal.svelte';
import Textarea from '$lib/components/common/Textarea.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
export let onSubmit: Function = () => {};
export let show = false;
@ -45,16 +47,7 @@
show = false;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
<XMark className={'size-5'} />
</button>
</div>
@ -111,29 +104,7 @@
{#if loading}
<div class="ml-2 self-center">
<svg
class=" w-4 h-4"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
><style>
.spinner_ajPY {
transform-origin: center;
animation: spinner_AtaB 0.75s infinite linear;
}
@keyframes spinner_AtaB {
100% {
transform: rotate(360deg);
}
}
</style><path
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
opacity=".25"
/><path
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
class="spinner_ajPY"
/></svg
>
<Spinner />
</div>
{/if}
</button>

View file

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

View file

@ -7,6 +7,7 @@
import { WEBUI_BASE_URL } from '$lib/constants';
import Checkbox from '$lib/components/common/Checkbox.svelte';
import Badge from '$lib/components/common/Badge.svelte';
import Search from '$lib/components/icons/Search.svelte';
export let users = [];
export let userIds = [];
@ -50,18 +51,7 @@
<div class="flex w-full">
<div class="flex flex-1">
<div class=" self-center mr-3">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z"
clip-rule="evenodd"
/>
</svg>
<Search />
</div>
<input
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}
<div class="my-10">
<Spinner />
<Spinner className="size-5" />
</div>
{:else}
<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 Spinner from '$lib/components/common/Spinner.svelte';
import Modal from '$lib/components/common/Modal.svelte';
import { generateInitialsImage } from '$lib/utils';
import XMark from '$lib/components/icons/XMark.svelte';
const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
@ -132,16 +134,7 @@
show = false;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
<XMark className={'size-5'} />
</button>
</div>
@ -293,29 +286,7 @@
{#if loading}
<div class="ml-2 self-center">
<svg
class=" w-4 h-4"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
><style>
.spinner_ajPY {
transform-origin: center;
animation: spinner_AtaB 0.75s infinite linear;
}
@keyframes spinner_AtaB {
100% {
transform: rotate(360deg);
}
}
</style><path
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
opacity=".25"
/><path
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
class="spinner_ajPY"
/></svg
>
<Spinner />
</div>
{/if}
</button>

View file

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

View file

@ -1,6 +1,7 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { v4 as uuidv4 } from 'uuid';
import heic2any from 'heic2any';
import { tick, getContext, onMount, onDestroy } from 'svelte';
@ -78,7 +79,7 @@
};
const inputFilesHandler = async (inputFiles) => {
inputFiles.forEach((file) => {
inputFiles.forEach(async (file) => {
console.info('Processing file:', {
name: file.name,
type: file.type,
@ -102,43 +103,50 @@
return;
}
if (
['image/gif', 'image/webp', 'image/jpeg', 'image/png', 'image/avif'].includes(file['type'])
) {
if (file['type'].startsWith('image/')) {
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();
reader.onload = async (event) => {
let imageUrl = event.target.result;
if (
($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);
}
}
// Compress the image if settings or config require it
imageUrl = await compressImageHandler(imageUrl, $settings, $config);
files = [
...files,
@ -149,7 +157,11 @@
];
};
reader.readAsDataURL(file);
reader.readAsDataURL(
file['type'] === 'image/heic'
? await heic2any({ blob: file, toType: 'image/jpeg' })
: file
);
} else {
uploadFileHandler(file);
}

View file

@ -55,10 +55,7 @@
import { generateChatCompletion } from '$lib/apis/ollama';
import {
addTagById,
createNewChat,
deleteTagById,
deleteTagsById,
getAllTags,
getChatById,
getChatList,
@ -153,10 +150,10 @@
webSearchEnabled = false;
imageGenerationEnabled = false;
if (localStorage.getItem(`chat-input${chatIdProp ? `-${chatIdProp}` : ''}`)) {
if (sessionStorage.getItem(`chat-input${chatIdProp ? `-${chatIdProp}` : ''}`)) {
try {
const input = JSON.parse(
localStorage.getItem(`chat-input${chatIdProp ? `-${chatIdProp}` : ''}`)
sessionStorage.getItem(`chat-input${chatIdProp ? `-${chatIdProp}` : ''}`)
);
if (!$temporaryChatEnabled) {
@ -446,7 +443,7 @@
}
});
if (localStorage.getItem(`chat-input${chatIdProp ? `-${chatIdProp}` : ''}`)) {
if (sessionStorage.getItem(`chat-input${chatIdProp ? `-${chatIdProp}` : ''}`)) {
prompt = '';
files = [];
selectedToolIds = [];
@ -457,7 +454,7 @@
try {
const input = JSON.parse(
localStorage.getItem(`chat-input${chatIdProp ? `-${chatIdProp}` : ''}`)
sessionStorage.getItem(`chat-input${chatIdProp ? `-${chatIdProp}` : ''}`)
);
if (!$temporaryChatEnabled) {
@ -708,6 +705,10 @@
//////////////////////////
const initNewChat = async () => {
if ($user?.role !== 'admin' && $user?.permissions?.chat?.temporary_enforced) {
await temporaryChatEnabled.set(true);
}
const availableModels = $models
.filter((m) => !(m?.info?.meta?.hidden ?? false))
.map((m) => m.id);
@ -2120,12 +2121,12 @@
onChange={(input) => {
if (!$temporaryChatEnabled) {
if (input.prompt !== null) {
localStorage.setItem(
sessionStorage.setItem(
`chat-input${$chatId ? `-${$chatId}` : ''}`,
JSON.stringify(input)
);
} else {
localStorage.removeItem(`chat-input${$chatId ? `-${$chatId}` : ''}`);
sessionStorage.removeItem(`chat-input${$chatId ? `-${$chatId}` : ''}`);
}
}
}}
@ -2227,7 +2228,7 @@
{:else if loading}
<div class=" flex items-center justify-center h-full w-full">
<div class="m-auto">
<Spinner />
<Spinner className="size-5" />
</div>
</div>
{/if}

View file

@ -46,7 +46,9 @@
>
<Tooltip
content={marked.parse(
sanitizeResponseContent(models[selectedModelIdx]?.info?.meta?.description ?? '')
sanitizeResponseContent(
models[selectedModelIdx]?.info?.meta?.description ?? ''
).replaceAll('\n', '<br>')
)}
placement="right"
>
@ -68,7 +70,7 @@
{#if $temporaryChatEnabled}
<Tooltip
content={$i18n.t('This chat wont appear in history and your messages will not be saved.')}
content={$i18n.t("This chat won't appear in history and your messages will not be saved.")}
className="w-full flex justify-start mb-0.5"
placement="top"
>
@ -96,7 +98,9 @@
class="mt-0.5 text-base font-normal text-gray-500 dark:text-gray-400 line-clamp-3 markdown"
>
{@html marked.parse(
sanitizeResponseContent(models[selectedModelIdx]?.info?.meta?.description)
sanitizeResponseContent(
models[selectedModelIdx]?.info?.meta?.description
).replaceAll('\n', '<br>')
)}
</div>
{#if models[selectedModelIdx]?.info?.meta?.user}

View file

@ -9,7 +9,7 @@
import FileItem from '$lib/components/common/FileItem.svelte';
import Collapsible from '$lib/components/common/Collapsible.svelte';
import { user } from '$lib/stores';
import { user, settings } from '$lib/stores';
export let models = [];
export let chatFiles = [];
export let params = {};
@ -74,7 +74,9 @@
<div class="" slot="content">
<textarea
bind:value={params.system}
class="w-full text-xs py-1.5 bg-transparent outline-hidden resize-none"
class="w-full text-xs outline-hidden resize-vertical {$settings.highContrastMode
? 'border-2 border-gray-300 dark:border-gray-700 rounded-lg bg-gray-50 dark:bg-gray-800 p-2.5'
: 'py-1.5 bg-transparent'}"
rows="4"
placeholder={$i18n.t('Enter system prompt')}
/>

View file

@ -1,6 +1,7 @@
<script lang="ts">
import DOMPurify from 'dompurify';
import { marked } from 'marked';
import heic2any from 'heic2any';
import { toast } from 'svelte-sonner';
@ -320,7 +321,7 @@
return;
}
inputFiles.forEach((file) => {
inputFiles.forEach(async (file) => {
console.log('Processing file:', {
name: file.name,
type: file.type,
@ -344,46 +345,53 @@
return;
}
if (
['image/gif', 'image/webp', 'image/jpeg', 'image/png', 'image/avif'].includes(file['type'])
) {
if (file['type'].startsWith('image/')) {
if (visionCapableModels.length === 0) {
toast.error($i18n.t('Selected model(s) do not support image inputs'));
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();
reader.onload = async (event) => {
let imageUrl = event.target.result;
if (
($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);
}
}
imageUrl = await compressImageHandler(imageUrl, $settings, $config);
files = [
...files,
@ -393,7 +401,11 @@
}
];
};
reader.readAsDataURL(file);
reader.readAsDataURL(
file['type'] === 'image/heic'
? await heic2any({ blob: file, toType: 'image/jpeg' })
: file
);
} else {
uploadFileHandler(file);
}
@ -659,7 +671,7 @@
<div class="relative flex items-center">
<Image
src={file.url}
alt="input"
alt=""
imageClassName=" size-14 rounded-xl object-cover"
/>
{#if atSelectedModel ? visionCapableModels.length === 0 : selectedModels.length !== visionCapableModels.length}
@ -677,6 +689,7 @@
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
class="size-4 fill-yellow-300"
>
<path
@ -690,8 +703,12 @@
</div>
<div class=" absolute -top-1 -right-1">
<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"
aria-label={$i18n.t('Remove file')}
on:click={() => {
files.splice(fileIdx, 1);
files = files;
@ -701,6 +718,7 @@
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
class="size-4"
>
<path
@ -1253,11 +1271,12 @@
<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"
type="button"
aria-label="More"
aria-label={$i18n.t('More Available Tools')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
aria-hidden="true"
fill="currentColor"
class="size-5"
>
@ -1379,6 +1398,10 @@
{#if showCodeInterpreterButton}
<Tooltip content={$i18n.t('Execute code for analysis')} placement="top">
<button
aria-label={codeInterpreterEnabled
? $i18n.t('Disable Code Interpreter')
: $i18n.t('Enable Code Interpreter')}
aria-pressed={codeInterpreterEnabled}
on:click|preventDefault={() =>
(codeInterpreterEnabled = !codeInterpreterEnabled)}
type="button"
@ -1530,7 +1553,7 @@
);
}
}}
aria-label="Call"
aria-label={$i18n.t('Voice mode')}
>
<Headphone className="size-5" />
</button>

View file

@ -854,6 +854,7 @@
</button>
{:else}
<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
id="camera-feed"
autoplay

View file

@ -6,7 +6,7 @@
import relativeTime from 'dayjs/plugin/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 { knowledge } from '$lib/stores';
@ -42,6 +42,24 @@
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) => {
dispatch('select', item);
@ -75,7 +93,18 @@
await tick();
};
const decodeString = (str: string) => {
try {
return decodeURIComponent(str);
} catch (e) {
return str;
}
};
onMount(() => {
window.addEventListener('resize', adjustHeight);
adjustHeight();
let legacy_documents = $knowledge
.filter((item) => item?.meta?.document)
.map((item) => ({
@ -155,13 +184,9 @@
});
});
const decodeString = (str: string) => {
try {
return decodeURIComponent(str);
} catch (e) {
return str;
}
};
onDestroy(() => {
window.removeEventListener('resize', adjustHeight);
});
</script>
{#if filteredItems.length > 0 || prompt.split(' ')?.at(0)?.substring(1).startsWith('http')}
@ -174,6 +199,7 @@
<div
class="m-1 overflow-y-auto p-1 rounded-r-xl space-y-0.5 scrollbar-hidden max-h-60"
id="command-options-container"
bind:this={container}
>
{#each filteredItems as item, idx}
<button

View file

@ -1,7 +1,7 @@
<script lang="ts">
import Fuse from 'fuse.js';
import { createEventDispatcher, onMount } from 'svelte';
import { createEventDispatcher, onDestroy, onMount } from 'svelte';
import { tick, getContext } from 'svelte';
import { models } from '$lib/stores';
@ -51,18 +51,44 @@
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) => {
command = '';
dispatch('select', model);
};
onMount(async () => {
window.addEventListener('resize', adjustHeight);
adjustHeight();
await tick();
const chatInputElement = document.getElementById('chat-input');
await tick();
chatInputElement?.focus();
await tick();
});
onDestroy(() => {
window.removeEventListener('resize', adjustHeight);
});
</script>
{#if filteredItems.length > 0}
@ -75,6 +101,7 @@
<div
class="m-1 overflow-y-auto p-1 rounded-r-lg space-y-0.5 scrollbar-hidden max-h-60"
id="command-options-container"
bind:this={container}
>
{#each filteredItems as model, modelIdx}
<button

View file

@ -9,7 +9,7 @@
getUserTimezone,
getWeekday
} from '$lib/utils';
import { tick, getContext } from 'svelte';
import { tick, getContext, onMount, onDestroy } from 'svelte';
import { toast } from 'svelte-sonner';
const i18n = getContext('i18n');
@ -38,6 +38,25 @@
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) => {
let text = command.content;
@ -172,6 +191,15 @@
}
}
};
onMount(() => {
window.addEventListener('resize', adjustHeight);
adjustHeight();
});
onDestroy(() => {
window.removeEventListener('resize', adjustHeight);
});
</script>
{#if filteredPrompts.length > 0}
@ -184,6 +212,7 @@
<div
class="m-1 overflow-y-auto p-1 space-y-0.5 scrollbar-hidden max-h-60"
id="command-options-container"
bind:this={container}
>
{#each filteredPrompts as prompt, promptIdx}
<button

View file

@ -5,6 +5,7 @@
import { blobToFile, calculateSHA256, extractCurlyBraceWords } from '$lib/utils';
import { transcribeAudio } from '$lib/apis/audio';
import XMark from '$lib/components/icons/XMark.svelte';
import dayjs from 'dayjs';
import LocalizedFormat from 'dayjs/plugin/localizedFormat';
@ -406,16 +407,7 @@
onCancel();
}}
>
<svg
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>
<XMark className={'size-4'} />
</button>
</div>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -27,7 +27,8 @@
let tokens = [];
const options = {
throwOnError: false
throwOnError: false,
breaks: true
};
marked.use(markedKatexExtension(options));

View file

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

View file

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

View file

@ -4,6 +4,7 @@
import { createEventDispatcher, onMount, getContext } from 'svelte';
import { config, models } from '$lib/stores';
import Tags from '$lib/components/common/Tags.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
const i18n = getContext('i18n');
@ -123,16 +124,7 @@
show = false;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-4"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
<XMark className={'size-4'} />
</button>
</div>

View file

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

View file

@ -1,7 +1,7 @@
<script lang="ts">
import ChevronDown from '$lib/components/icons/ChevronDown.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';
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"
>
<div class="flex gap-2 items-center">
<MagnifyingGlass />
<Search />
<div class=" line-clamp-1">
{status.query}

View file

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

View file

@ -113,7 +113,7 @@
id="message-{message.id}"
>
{#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
src={message.user
? ($models.find((m) => m.id === message.user)?.info?.meta?.profile_image_url ??

View file

@ -49,7 +49,7 @@
{#each selectedModels as selectedModel, selectedModelIdx}
<div class="flex w-full max-w-fit">
<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
id={`${selectedModelIdx}`}
placeholder={$i18n.t('Select a model')}

View file

@ -7,6 +7,7 @@
import relativeTime from 'dayjs/plugin/relativeTime';
dayjs.extend(relativeTime);
import Spinner from '$lib/components/common/Spinner.svelte';
import { flyAndScale } from '$lib/utils/transitions';
import { createEventDispatcher, onMount, getContext, tick } from 'svelte';
import { goto } from '$app/navigation';
@ -345,12 +346,17 @@
closeFocus={false}
>
<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}
id="model-selector-{id}-button"
>
<button
class="flex w-full text-left px-0.5 outline-hidden bg-transparent truncate {triggerClassName} justify-between font-medium placeholder-gray-400 focus:outline-hidden"
<div
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 () => {
models.set(
await getModels(
@ -359,7 +365,6 @@
)
);
}}
type="button"
>
{#if selectedModel}
{selectedModel.label}
@ -367,7 +372,7 @@
{placeholder}
{/if}
<ChevronDown className=" self-center ml-2 size-3" strokeWidth="2.5" />
</button>
</div>
</DropdownMenu.Trigger>
<DropdownMenu.Content
@ -550,29 +555,7 @@
>
<div class="flex">
<div class="-ml-2 mr-2.5 translate-y-0.5">
<svg
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
>
<Spinner />
</div>
<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"
aria-label="User Menu"
>
<div class=" self-center">
<span class="sr-only">{$i18n.t('User menu')}</span>
<img
src={$user?.profile_image_url}
class="size-6 object-cover rounded-full"
alt="User profile"
alt=""
draggable="false"
/>
</div>
</button>
</div>
</UserMenu>
{/if}
</div>

View file

@ -118,6 +118,8 @@
placement="top"
>
<button
aria-hidden={models.length <= 1}
aria-label={$i18n.t('Get information on {{name}} in the UI', { name: models[modelIdx]?.name})}
on:click={() => {
selectedModelIdx = modelIdx;
}}
@ -129,7 +131,7 @@
? `/doge.png`
: `${WEBUI_BASE_URL}/static/favicon.png`)}
class=" size-9 @sm:size-10 rounded-full border-[1px] border-gray-100 dark:border-none"
alt="logo"
aria-hidden="true"
draggable="false"
/>
</button>
@ -164,7 +166,9 @@
<Tooltip
className=" w-fit"
content={marked.parse(
sanitizeResponseContent(models[selectedModelIdx]?.info?.meta?.description ?? '')
sanitizeResponseContent(
models[selectedModelIdx]?.info?.meta?.description ?? ''
).replaceAll('\n', '<br>')
)}
placement="top"
>
@ -172,7 +176,9 @@
class="mt-0.5 px-2 text-sm font-normal text-gray-500 dark:text-gray-400 line-clamp-2 max-w-xl markdown"
>
{@html marked.parse(
sanitizeResponseContent(models[selectedModelIdx]?.info?.meta?.description)
sanitizeResponseContent(
models[selectedModelIdx]?.info?.meta?.description ?? ''
).replaceAll('\n', '<br>')
)}
</div>
</Tooltip>
@ -218,9 +224,9 @@
onChange={(input) => {
if (!$temporaryChatEnabled) {
if (input.prompt !== null) {
localStorage.setItem(`chat-input`, JSON.stringify(input));
sessionStorage.setItem(`chat-input`, JSON.stringify(input));
} else {
localStorage.removeItem(`chat-input`);
sessionStorage.removeItem(`chat-input`);
}
}
}}

View file

@ -38,7 +38,9 @@
return '';
});
if (!$config?.offline_mode) {
checkForVersionUpdates();
}
});
</script>
@ -80,6 +82,7 @@
</button>
</div>
{#if $config?.offline_mode}
<button
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"
on:click={() => {
@ -88,6 +91,7 @@
>
{$i18n.t('Check for updates')}
</button>
{/if}
</div>
</div>

View file

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

View file

@ -274,7 +274,10 @@
<div class=" my-2.5 text-sm font-medium">{$i18n.t('System Prompt')}</div>
<Textarea
bind:value={system}
className="w-full text-sm bg-white dark:text-gray-300 dark:bg-gray-900 outline-hidden resize-none"
className={'w-full text-sm outline-hidden resize-vertical' +
($settings.highContrastMode
? ' p-2.5 border-2 border-gray-300 dark:border-gray-700 rounded-lg bg-gray-50 dark:bg-gray-850 text-gray-900 dark:text-gray-100 focus:ring-1 focus:ring-blue-500 focus:border-blue-500 overflow-y-hidden'
: ' bg-white dark:text-gray-300 dark:bg-gray-900')}
rows="4"
placeholder={$i18n.t('Enter system prompt here')}
/>

View file

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

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