mirror of
https://github.com/open-webui/open-webui.git
synced 2025-12-12 04:15:25 +00:00
Merge branch 'dev' into feat/scim-2.0-support
This commit is contained in:
commit
41faec758b
302 changed files with 16478 additions and 7350 deletions
|
|
@ -13,12 +13,15 @@ from urllib.parse import urlparse
|
||||||
import requests
|
import requests
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import JSON, Column, DateTime, Integer, func
|
from sqlalchemy import JSON, Column, DateTime, Integer, func
|
||||||
|
from authlib.integrations.starlette_client import OAuth
|
||||||
|
|
||||||
|
|
||||||
from open_webui.env import (
|
from open_webui.env import (
|
||||||
DATA_DIR,
|
DATA_DIR,
|
||||||
DATABASE_URL,
|
DATABASE_URL,
|
||||||
ENV,
|
ENV,
|
||||||
REDIS_URL,
|
REDIS_URL,
|
||||||
|
REDIS_KEY_PREFIX,
|
||||||
REDIS_SENTINEL_HOSTS,
|
REDIS_SENTINEL_HOSTS,
|
||||||
REDIS_SENTINEL_PORT,
|
REDIS_SENTINEL_PORT,
|
||||||
FRONTEND_BUILD_DIR,
|
FRONTEND_BUILD_DIR,
|
||||||
|
|
@ -211,11 +214,16 @@ class PersistentConfig(Generic[T]):
|
||||||
class AppConfig:
|
class AppConfig:
|
||||||
_state: dict[str, PersistentConfig]
|
_state: dict[str, PersistentConfig]
|
||||||
_redis: Optional[redis.Redis] = None
|
_redis: Optional[redis.Redis] = None
|
||||||
|
_redis_key_prefix: str
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, redis_url: Optional[str] = None, redis_sentinels: Optional[list] = []
|
self,
|
||||||
|
redis_url: Optional[str] = None,
|
||||||
|
redis_sentinels: Optional[list] = [],
|
||||||
|
redis_key_prefix: str = "open-webui",
|
||||||
):
|
):
|
||||||
super().__setattr__("_state", {})
|
super().__setattr__("_state", {})
|
||||||
|
super().__setattr__("_redis_key_prefix", redis_key_prefix)
|
||||||
if redis_url:
|
if redis_url:
|
||||||
super().__setattr__(
|
super().__setattr__(
|
||||||
"_redis",
|
"_redis",
|
||||||
|
|
@ -230,7 +238,7 @@ class AppConfig:
|
||||||
self._state[key].save()
|
self._state[key].save()
|
||||||
|
|
||||||
if self._redis:
|
if self._redis:
|
||||||
redis_key = f"open-webui:config:{key}"
|
redis_key = f"{self._redis_key_prefix}:config:{key}"
|
||||||
self._redis.set(redis_key, json.dumps(self._state[key].value))
|
self._redis.set(redis_key, json.dumps(self._state[key].value))
|
||||||
|
|
||||||
def __getattr__(self, key):
|
def __getattr__(self, key):
|
||||||
|
|
@ -239,7 +247,7 @@ class AppConfig:
|
||||||
|
|
||||||
# If Redis is available, check for an updated value
|
# If Redis is available, check for an updated value
|
||||||
if self._redis:
|
if self._redis:
|
||||||
redis_key = f"open-webui:config:{key}"
|
redis_key = f"{self._redis_key_prefix}:config:{key}"
|
||||||
redis_value = self._redis.get(redis_key)
|
redis_value = self._redis.get(redis_key)
|
||||||
|
|
||||||
if redis_value is not None:
|
if redis_value is not None:
|
||||||
|
|
@ -431,6 +439,12 @@ OAUTH_SCOPES = PersistentConfig(
|
||||||
os.environ.get("OAUTH_SCOPES", "openid email profile"),
|
os.environ.get("OAUTH_SCOPES", "openid email profile"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
OAUTH_TIMEOUT = PersistentConfig(
|
||||||
|
"OAUTH_TIMEOUT",
|
||||||
|
"oauth.oidc.oauth_timeout",
|
||||||
|
os.environ.get("OAUTH_TIMEOUT", ""),
|
||||||
|
)
|
||||||
|
|
||||||
OAUTH_CODE_CHALLENGE_METHOD = PersistentConfig(
|
OAUTH_CODE_CHALLENGE_METHOD = PersistentConfig(
|
||||||
"OAUTH_CODE_CHALLENGE_METHOD",
|
"OAUTH_CODE_CHALLENGE_METHOD",
|
||||||
"oauth.oidc.code_challenge_method",
|
"oauth.oidc.code_challenge_method",
|
||||||
|
|
@ -534,13 +548,20 @@ def load_oauth_providers():
|
||||||
OAUTH_PROVIDERS.clear()
|
OAUTH_PROVIDERS.clear()
|
||||||
if GOOGLE_CLIENT_ID.value and GOOGLE_CLIENT_SECRET.value:
|
if GOOGLE_CLIENT_ID.value and GOOGLE_CLIENT_SECRET.value:
|
||||||
|
|
||||||
def google_oauth_register(client):
|
def google_oauth_register(client: OAuth):
|
||||||
client.register(
|
client.register(
|
||||||
name="google",
|
name="google",
|
||||||
client_id=GOOGLE_CLIENT_ID.value,
|
client_id=GOOGLE_CLIENT_ID.value,
|
||||||
client_secret=GOOGLE_CLIENT_SECRET.value,
|
client_secret=GOOGLE_CLIENT_SECRET.value,
|
||||||
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
|
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
|
||||||
client_kwargs={"scope": GOOGLE_OAUTH_SCOPE.value},
|
client_kwargs={
|
||||||
|
"scope": GOOGLE_OAUTH_SCOPE.value,
|
||||||
|
**(
|
||||||
|
{"timeout": int(OAUTH_TIMEOUT.value)}
|
||||||
|
if OAUTH_TIMEOUT.value
|
||||||
|
else {}
|
||||||
|
),
|
||||||
|
},
|
||||||
redirect_uri=GOOGLE_REDIRECT_URI.value,
|
redirect_uri=GOOGLE_REDIRECT_URI.value,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -555,7 +576,7 @@ def load_oauth_providers():
|
||||||
and MICROSOFT_CLIENT_TENANT_ID.value
|
and MICROSOFT_CLIENT_TENANT_ID.value
|
||||||
):
|
):
|
||||||
|
|
||||||
def microsoft_oauth_register(client):
|
def microsoft_oauth_register(client: OAuth):
|
||||||
client.register(
|
client.register(
|
||||||
name="microsoft",
|
name="microsoft",
|
||||||
client_id=MICROSOFT_CLIENT_ID.value,
|
client_id=MICROSOFT_CLIENT_ID.value,
|
||||||
|
|
@ -563,6 +584,11 @@ def load_oauth_providers():
|
||||||
server_metadata_url=f"{MICROSOFT_CLIENT_LOGIN_BASE_URL.value}/{MICROSOFT_CLIENT_TENANT_ID.value}/v2.0/.well-known/openid-configuration?appid={MICROSOFT_CLIENT_ID.value}",
|
server_metadata_url=f"{MICROSOFT_CLIENT_LOGIN_BASE_URL.value}/{MICROSOFT_CLIENT_TENANT_ID.value}/v2.0/.well-known/openid-configuration?appid={MICROSOFT_CLIENT_ID.value}",
|
||||||
client_kwargs={
|
client_kwargs={
|
||||||
"scope": MICROSOFT_OAUTH_SCOPE.value,
|
"scope": MICROSOFT_OAUTH_SCOPE.value,
|
||||||
|
**(
|
||||||
|
{"timeout": int(OAUTH_TIMEOUT.value)}
|
||||||
|
if OAUTH_TIMEOUT.value
|
||||||
|
else {}
|
||||||
|
),
|
||||||
},
|
},
|
||||||
redirect_uri=MICROSOFT_REDIRECT_URI.value,
|
redirect_uri=MICROSOFT_REDIRECT_URI.value,
|
||||||
)
|
)
|
||||||
|
|
@ -575,7 +601,7 @@ def load_oauth_providers():
|
||||||
|
|
||||||
if GITHUB_CLIENT_ID.value and GITHUB_CLIENT_SECRET.value:
|
if GITHUB_CLIENT_ID.value and GITHUB_CLIENT_SECRET.value:
|
||||||
|
|
||||||
def github_oauth_register(client):
|
def github_oauth_register(client: OAuth):
|
||||||
client.register(
|
client.register(
|
||||||
name="github",
|
name="github",
|
||||||
client_id=GITHUB_CLIENT_ID.value,
|
client_id=GITHUB_CLIENT_ID.value,
|
||||||
|
|
@ -584,7 +610,14 @@ def load_oauth_providers():
|
||||||
authorize_url="https://github.com/login/oauth/authorize",
|
authorize_url="https://github.com/login/oauth/authorize",
|
||||||
api_base_url="https://api.github.com",
|
api_base_url="https://api.github.com",
|
||||||
userinfo_endpoint="https://api.github.com/user",
|
userinfo_endpoint="https://api.github.com/user",
|
||||||
client_kwargs={"scope": GITHUB_CLIENT_SCOPE.value},
|
client_kwargs={
|
||||||
|
"scope": GITHUB_CLIENT_SCOPE.value,
|
||||||
|
**(
|
||||||
|
{"timeout": int(OAUTH_TIMEOUT.value)}
|
||||||
|
if OAUTH_TIMEOUT.value
|
||||||
|
else {}
|
||||||
|
),
|
||||||
|
},
|
||||||
redirect_uri=GITHUB_CLIENT_REDIRECT_URI.value,
|
redirect_uri=GITHUB_CLIENT_REDIRECT_URI.value,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -600,9 +633,12 @@ def load_oauth_providers():
|
||||||
and OPENID_PROVIDER_URL.value
|
and OPENID_PROVIDER_URL.value
|
||||||
):
|
):
|
||||||
|
|
||||||
def oidc_oauth_register(client):
|
def oidc_oauth_register(client: OAuth):
|
||||||
client_kwargs = {
|
client_kwargs = {
|
||||||
"scope": OAUTH_SCOPES.value,
|
"scope": OAUTH_SCOPES.value,
|
||||||
|
**(
|
||||||
|
{"timeout": int(OAUTH_TIMEOUT.value)} if OAUTH_TIMEOUT.value else {}
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|
@ -911,6 +947,18 @@ except Exception:
|
||||||
pass
|
pass
|
||||||
OPENAI_API_BASE_URL = "https://api.openai.com/v1"
|
OPENAI_API_BASE_URL = "https://api.openai.com/v1"
|
||||||
|
|
||||||
|
|
||||||
|
####################################
|
||||||
|
# MODELS
|
||||||
|
####################################
|
||||||
|
|
||||||
|
ENABLE_BASE_MODELS_CACHE = PersistentConfig(
|
||||||
|
"ENABLE_BASE_MODELS_CACHE",
|
||||||
|
"models.base_models_cache",
|
||||||
|
os.environ.get("ENABLE_BASE_MODELS_CACHE", "False").lower() == "true",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
####################################
|
####################################
|
||||||
# TOOL_SERVERS
|
# TOOL_SERVERS
|
||||||
####################################
|
####################################
|
||||||
|
|
@ -1810,11 +1858,12 @@ MILVUS_IVF_FLAT_NLIST = int(os.environ.get("MILVUS_IVF_FLAT_NLIST", "128"))
|
||||||
QDRANT_URI = os.environ.get("QDRANT_URI", None)
|
QDRANT_URI = os.environ.get("QDRANT_URI", None)
|
||||||
QDRANT_API_KEY = os.environ.get("QDRANT_API_KEY", None)
|
QDRANT_API_KEY = os.environ.get("QDRANT_API_KEY", None)
|
||||||
QDRANT_ON_DISK = os.environ.get("QDRANT_ON_DISK", "false").lower() == "true"
|
QDRANT_ON_DISK = os.environ.get("QDRANT_ON_DISK", "false").lower() == "true"
|
||||||
QDRANT_PREFER_GRPC = os.environ.get("QDRANT_PREFER_GRPC", "False").lower() == "true"
|
QDRANT_PREFER_GRPC = os.environ.get("QDRANT_PREFER_GRPC", "false").lower() == "true"
|
||||||
QDRANT_GRPC_PORT = int(os.environ.get("QDRANT_GRPC_PORT", "6334"))
|
QDRANT_GRPC_PORT = int(os.environ.get("QDRANT_GRPC_PORT", "6334"))
|
||||||
ENABLE_QDRANT_MULTITENANCY_MODE = (
|
ENABLE_QDRANT_MULTITENANCY_MODE = (
|
||||||
os.environ.get("ENABLE_QDRANT_MULTITENANCY_MODE", "false").lower() == "true"
|
os.environ.get("ENABLE_QDRANT_MULTITENANCY_MODE", "true").lower() == "true"
|
||||||
)
|
)
|
||||||
|
QDRANT_COLLECTION_PREFIX = os.environ.get("QDRANT_COLLECTION_PREFIX", "open-webui")
|
||||||
|
|
||||||
# OpenSearch
|
# OpenSearch
|
||||||
OPENSEARCH_URI = os.environ.get("OPENSEARCH_URI", "https://localhost:9200")
|
OPENSEARCH_URI = os.environ.get("OPENSEARCH_URI", "https://localhost:9200")
|
||||||
|
|
|
||||||
|
|
@ -199,6 +199,7 @@ CHANGELOG = changelog_json
|
||||||
|
|
||||||
SAFE_MODE = os.environ.get("SAFE_MODE", "false").lower() == "true"
|
SAFE_MODE = os.environ.get("SAFE_MODE", "false").lower() == "true"
|
||||||
|
|
||||||
|
|
||||||
####################################
|
####################################
|
||||||
# ENABLE_FORWARD_USER_INFO_HEADERS
|
# ENABLE_FORWARD_USER_INFO_HEADERS
|
||||||
####################################
|
####################################
|
||||||
|
|
@ -266,21 +267,43 @@ else:
|
||||||
|
|
||||||
DATABASE_URL = os.environ.get("DATABASE_URL", f"sqlite:///{DATA_DIR}/webui.db")
|
DATABASE_URL = os.environ.get("DATABASE_URL", f"sqlite:///{DATA_DIR}/webui.db")
|
||||||
|
|
||||||
|
DATABASE_TYPE = os.environ.get("DATABASE_TYPE")
|
||||||
|
DATABASE_USER = os.environ.get("DATABASE_USER")
|
||||||
|
DATABASE_PASSWORD = os.environ.get("DATABASE_PASSWORD")
|
||||||
|
|
||||||
|
DATABASE_CRED = ""
|
||||||
|
if DATABASE_USER:
|
||||||
|
DATABASE_CRED += f"{DATABASE_USER}"
|
||||||
|
if DATABASE_PASSWORD:
|
||||||
|
DATABASE_CRED += f":{DATABASE_PASSWORD}"
|
||||||
|
if DATABASE_CRED:
|
||||||
|
DATABASE_CRED += "@"
|
||||||
|
|
||||||
|
|
||||||
|
DB_VARS = {
|
||||||
|
"db_type": DATABASE_TYPE,
|
||||||
|
"db_cred": DATABASE_CRED,
|
||||||
|
"db_host": os.environ.get("DATABASE_HOST"),
|
||||||
|
"db_port": os.environ.get("DATABASE_PORT"),
|
||||||
|
"db_name": os.environ.get("DATABASE_NAME"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if all(DB_VARS.values()):
|
||||||
|
DATABASE_URL = f"{DB_VARS['db_type']}://{DB_VARS['db_cred']}@{DB_VARS['db_host']}:{DB_VARS['db_port']}/{DB_VARS['db_name']}"
|
||||||
|
|
||||||
# Replace the postgres:// with postgresql://
|
# Replace the postgres:// with postgresql://
|
||||||
if "postgres://" in DATABASE_URL:
|
if "postgres://" in DATABASE_URL:
|
||||||
DATABASE_URL = DATABASE_URL.replace("postgres://", "postgresql://")
|
DATABASE_URL = DATABASE_URL.replace("postgres://", "postgresql://")
|
||||||
|
|
||||||
DATABASE_SCHEMA = os.environ.get("DATABASE_SCHEMA", None)
|
DATABASE_SCHEMA = os.environ.get("DATABASE_SCHEMA", None)
|
||||||
|
|
||||||
DATABASE_POOL_SIZE = os.environ.get("DATABASE_POOL_SIZE", 0)
|
DATABASE_POOL_SIZE = os.environ.get("DATABASE_POOL_SIZE", None)
|
||||||
|
|
||||||
if DATABASE_POOL_SIZE == "":
|
if DATABASE_POOL_SIZE != None:
|
||||||
DATABASE_POOL_SIZE = 0
|
|
||||||
else:
|
|
||||||
try:
|
try:
|
||||||
DATABASE_POOL_SIZE = int(DATABASE_POOL_SIZE)
|
DATABASE_POOL_SIZE = int(DATABASE_POOL_SIZE)
|
||||||
except Exception:
|
except Exception:
|
||||||
DATABASE_POOL_SIZE = 0
|
DATABASE_POOL_SIZE = None
|
||||||
|
|
||||||
DATABASE_POOL_MAX_OVERFLOW = os.environ.get("DATABASE_POOL_MAX_OVERFLOW", 0)
|
DATABASE_POOL_MAX_OVERFLOW = os.environ.get("DATABASE_POOL_MAX_OVERFLOW", 0)
|
||||||
|
|
||||||
|
|
@ -325,6 +348,7 @@ ENABLE_REALTIME_CHAT_SAVE = (
|
||||||
####################################
|
####################################
|
||||||
|
|
||||||
REDIS_URL = os.environ.get("REDIS_URL", "")
|
REDIS_URL = os.environ.get("REDIS_URL", "")
|
||||||
|
REDIS_KEY_PREFIX = os.environ.get("REDIS_KEY_PREFIX", "open-webui")
|
||||||
REDIS_SENTINEL_HOSTS = os.environ.get("REDIS_SENTINEL_HOSTS", "")
|
REDIS_SENTINEL_HOSTS = os.environ.get("REDIS_SENTINEL_HOSTS", "")
|
||||||
REDIS_SENTINEL_PORT = os.environ.get("REDIS_SENTINEL_PORT", "26379")
|
REDIS_SENTINEL_PORT = os.environ.get("REDIS_SENTINEL_PORT", "26379")
|
||||||
|
|
||||||
|
|
@ -396,10 +420,33 @@ WEBUI_AUTH_COOKIE_SECURE = (
|
||||||
if WEBUI_AUTH and WEBUI_SECRET_KEY == "":
|
if WEBUI_AUTH and WEBUI_SECRET_KEY == "":
|
||||||
raise ValueError(ERROR_MESSAGES.ENV_VAR_NOT_FOUND)
|
raise ValueError(ERROR_MESSAGES.ENV_VAR_NOT_FOUND)
|
||||||
|
|
||||||
|
ENABLE_COMPRESSION_MIDDLEWARE = (
|
||||||
|
os.environ.get("ENABLE_COMPRESSION_MIDDLEWARE", "True").lower() == "true"
|
||||||
|
)
|
||||||
|
|
||||||
|
####################################
|
||||||
|
# MODELS
|
||||||
|
####################################
|
||||||
|
|
||||||
|
MODELS_CACHE_TTL = os.environ.get("MODELS_CACHE_TTL", "1")
|
||||||
|
if MODELS_CACHE_TTL == "":
|
||||||
|
MODELS_CACHE_TTL = None
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
MODELS_CACHE_TTL = int(MODELS_CACHE_TTL)
|
||||||
|
except Exception:
|
||||||
|
MODELS_CACHE_TTL = 1
|
||||||
|
|
||||||
|
|
||||||
|
####################################
|
||||||
|
# WEBSOCKET SUPPORT
|
||||||
|
####################################
|
||||||
|
|
||||||
ENABLE_WEBSOCKET_SUPPORT = (
|
ENABLE_WEBSOCKET_SUPPORT = (
|
||||||
os.environ.get("ENABLE_WEBSOCKET_SUPPORT", "True").lower() == "true"
|
os.environ.get("ENABLE_WEBSOCKET_SUPPORT", "True").lower() == "true"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
WEBSOCKET_MANAGER = os.environ.get("WEBSOCKET_MANAGER", "")
|
WEBSOCKET_MANAGER = os.environ.get("WEBSOCKET_MANAGER", "")
|
||||||
|
|
||||||
WEBSOCKET_REDIS_URL = os.environ.get("WEBSOCKET_REDIS_URL", REDIS_URL)
|
WEBSOCKET_REDIS_URL = os.environ.get("WEBSOCKET_REDIS_URL", REDIS_URL)
|
||||||
|
|
@ -506,11 +553,14 @@ else:
|
||||||
# OFFLINE_MODE
|
# OFFLINE_MODE
|
||||||
####################################
|
####################################
|
||||||
|
|
||||||
|
ENABLE_VERSION_UPDATE_CHECK = (
|
||||||
|
os.environ.get("ENABLE_VERSION_UPDATE_CHECK", "true").lower() == "true"
|
||||||
|
)
|
||||||
OFFLINE_MODE = os.environ.get("OFFLINE_MODE", "false").lower() == "true"
|
OFFLINE_MODE = os.environ.get("OFFLINE_MODE", "false").lower() == "true"
|
||||||
|
|
||||||
if OFFLINE_MODE:
|
if OFFLINE_MODE:
|
||||||
os.environ["HF_HUB_OFFLINE"] = "1"
|
os.environ["HF_HUB_OFFLINE"] = "1"
|
||||||
|
ENABLE_VERSION_UPDATE_CHECK = False
|
||||||
|
|
||||||
####################################
|
####################################
|
||||||
# AUDIT LOGGING
|
# AUDIT LOGGING
|
||||||
|
|
@ -519,6 +569,14 @@ if OFFLINE_MODE:
|
||||||
AUDIT_LOGS_FILE_PATH = f"{DATA_DIR}/audit.log"
|
AUDIT_LOGS_FILE_PATH = f"{DATA_DIR}/audit.log"
|
||||||
# Maximum size of a file before rotating into a new log file
|
# Maximum size of a file before rotating into a new log file
|
||||||
AUDIT_LOG_FILE_ROTATION_SIZE = os.getenv("AUDIT_LOG_FILE_ROTATION_SIZE", "10MB")
|
AUDIT_LOG_FILE_ROTATION_SIZE = os.getenv("AUDIT_LOG_FILE_ROTATION_SIZE", "10MB")
|
||||||
|
|
||||||
|
# Comma separated list of logger names to use for audit logging
|
||||||
|
# Default is "uvicorn.access" which is the access log for Uvicorn
|
||||||
|
# You can add more logger names to this list if you want to capture more logs
|
||||||
|
AUDIT_UVICORN_LOGGER_NAMES = os.getenv(
|
||||||
|
"AUDIT_UVICORN_LOGGER_NAMES", "uvicorn.access"
|
||||||
|
).split(",")
|
||||||
|
|
||||||
# METADATA | REQUEST | REQUEST_RESPONSE
|
# METADATA | REQUEST | REQUEST_RESPONSE
|
||||||
AUDIT_LOG_LEVEL = os.getenv("AUDIT_LOG_LEVEL", "NONE").upper()
|
AUDIT_LOG_LEVEL = os.getenv("AUDIT_LOG_LEVEL", "NONE").upper()
|
||||||
try:
|
try:
|
||||||
|
|
@ -543,6 +601,9 @@ ENABLE_OTEL_METRICS = os.environ.get("ENABLE_OTEL_METRICS", "False").lower() ==
|
||||||
OTEL_EXPORTER_OTLP_ENDPOINT = os.environ.get(
|
OTEL_EXPORTER_OTLP_ENDPOINT = os.environ.get(
|
||||||
"OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317"
|
"OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317"
|
||||||
)
|
)
|
||||||
|
OTEL_EXPORTER_OTLP_INSECURE = (
|
||||||
|
os.environ.get("OTEL_EXPORTER_OTLP_INSECURE", "False").lower() == "true"
|
||||||
|
)
|
||||||
OTEL_SERVICE_NAME = os.environ.get("OTEL_SERVICE_NAME", "open-webui")
|
OTEL_SERVICE_NAME = os.environ.get("OTEL_SERVICE_NAME", "open-webui")
|
||||||
OTEL_RESOURCE_ATTRIBUTES = os.environ.get(
|
OTEL_RESOURCE_ATTRIBUTES = os.environ.get(
|
||||||
"OTEL_RESOURCE_ATTRIBUTES", ""
|
"OTEL_RESOURCE_ATTRIBUTES", ""
|
||||||
|
|
@ -550,6 +611,14 @@ OTEL_RESOURCE_ATTRIBUTES = os.environ.get(
|
||||||
OTEL_TRACES_SAMPLER = os.environ.get(
|
OTEL_TRACES_SAMPLER = os.environ.get(
|
||||||
"OTEL_TRACES_SAMPLER", "parentbased_always_on"
|
"OTEL_TRACES_SAMPLER", "parentbased_always_on"
|
||||||
).lower()
|
).lower()
|
||||||
|
OTEL_BASIC_AUTH_USERNAME = os.environ.get("OTEL_BASIC_AUTH_USERNAME", "")
|
||||||
|
OTEL_BASIC_AUTH_PASSWORD = os.environ.get("OTEL_BASIC_AUTH_PASSWORD", "")
|
||||||
|
|
||||||
|
|
||||||
|
OTEL_OTLP_SPAN_EXPORTER = os.environ.get(
|
||||||
|
"OTEL_OTLP_SPAN_EXPORTER", "grpc"
|
||||||
|
).lower() # grpc or http
|
||||||
|
|
||||||
|
|
||||||
####################################
|
####################################
|
||||||
# TOOLS/FUNCTIONS PIP OPTIONS
|
# TOOLS/FUNCTIONS PIP OPTIONS
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,9 @@ def handle_peewee_migration(DATABASE_URL):
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error(f"Failed to initialize the database connection: {e}")
|
log.error(f"Failed to initialize the database connection: {e}")
|
||||||
|
log.warning(
|
||||||
|
"Hint: If your database password contains special characters, you may need to URL-encode it."
|
||||||
|
)
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
# Properly closing the database connection
|
# Properly closing the database connection
|
||||||
|
|
@ -81,20 +84,23 @@ if "sqlite" in SQLALCHEMY_DATABASE_URL:
|
||||||
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
|
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
if DATABASE_POOL_SIZE > 0:
|
if isinstance(DATABASE_POOL_SIZE, int):
|
||||||
engine = create_engine(
|
if DATABASE_POOL_SIZE > 0:
|
||||||
SQLALCHEMY_DATABASE_URL,
|
engine = create_engine(
|
||||||
pool_size=DATABASE_POOL_SIZE,
|
SQLALCHEMY_DATABASE_URL,
|
||||||
max_overflow=DATABASE_POOL_MAX_OVERFLOW,
|
pool_size=DATABASE_POOL_SIZE,
|
||||||
pool_timeout=DATABASE_POOL_TIMEOUT,
|
max_overflow=DATABASE_POOL_MAX_OVERFLOW,
|
||||||
pool_recycle=DATABASE_POOL_RECYCLE,
|
pool_timeout=DATABASE_POOL_TIMEOUT,
|
||||||
pool_pre_ping=True,
|
pool_recycle=DATABASE_POOL_RECYCLE,
|
||||||
poolclass=QueuePool,
|
pool_pre_ping=True,
|
||||||
)
|
poolclass=QueuePool,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
engine = create_engine(
|
||||||
|
SQLALCHEMY_DATABASE_URL, pool_pre_ping=True, poolclass=NullPool
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
engine = create_engine(
|
engine = create_engine(SQLALCHEMY_DATABASE_URL, pool_pre_ping=True)
|
||||||
SQLALCHEMY_DATABASE_URL, pool_pre_ping=True, poolclass=NullPool
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
SessionLocal = sessionmaker(
|
SessionLocal = sessionmaker(
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,6 @@ from fastapi import (
|
||||||
applications,
|
applications,
|
||||||
BackgroundTasks,
|
BackgroundTasks,
|
||||||
)
|
)
|
||||||
|
|
||||||
from fastapi.openapi.docs import get_swagger_ui_html
|
from fastapi.openapi.docs import get_swagger_ui_html
|
||||||
|
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
@ -49,6 +48,7 @@ from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
from starlette.middleware.sessions import SessionMiddleware
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
from starlette.responses import Response, StreamingResponse
|
from starlette.responses import Response, StreamingResponse
|
||||||
|
from starlette.datastructures import Headers
|
||||||
|
|
||||||
|
|
||||||
from open_webui.utils import logger
|
from open_webui.utils import logger
|
||||||
|
|
@ -117,9 +117,14 @@ from open_webui.config import (
|
||||||
OPENAI_API_CONFIGS,
|
OPENAI_API_CONFIGS,
|
||||||
# Direct Connections
|
# Direct Connections
|
||||||
ENABLE_DIRECT_CONNECTIONS,
|
ENABLE_DIRECT_CONNECTIONS,
|
||||||
|
|
||||||
# SCIM
|
# SCIM
|
||||||
SCIM_ENABLED,
|
SCIM_ENABLED,
|
||||||
SCIM_TOKEN,
|
SCIM_TOKEN,
|
||||||
|
|
||||||
|
# Model list
|
||||||
|
ENABLE_BASE_MODELS_CACHE,
|
||||||
|
|
||||||
# Thread pool size for FastAPI/AnyIO
|
# Thread pool size for FastAPI/AnyIO
|
||||||
THREAD_POOL_SIZE,
|
THREAD_POOL_SIZE,
|
||||||
# Tool Server Configs
|
# Tool Server Configs
|
||||||
|
|
@ -400,6 +405,7 @@ from open_webui.env import (
|
||||||
AUDIT_LOG_LEVEL,
|
AUDIT_LOG_LEVEL,
|
||||||
CHANGELOG,
|
CHANGELOG,
|
||||||
REDIS_URL,
|
REDIS_URL,
|
||||||
|
REDIS_KEY_PREFIX,
|
||||||
REDIS_SENTINEL_HOSTS,
|
REDIS_SENTINEL_HOSTS,
|
||||||
REDIS_SENTINEL_PORT,
|
REDIS_SENTINEL_PORT,
|
||||||
GLOBAL_LOG_LEVEL,
|
GLOBAL_LOG_LEVEL,
|
||||||
|
|
@ -415,10 +421,11 @@ from open_webui.env import (
|
||||||
WEBUI_AUTH_TRUSTED_EMAIL_HEADER,
|
WEBUI_AUTH_TRUSTED_EMAIL_HEADER,
|
||||||
WEBUI_AUTH_TRUSTED_NAME_HEADER,
|
WEBUI_AUTH_TRUSTED_NAME_HEADER,
|
||||||
WEBUI_AUTH_SIGNOUT_REDIRECT_URL,
|
WEBUI_AUTH_SIGNOUT_REDIRECT_URL,
|
||||||
|
ENABLE_COMPRESSION_MIDDLEWARE,
|
||||||
ENABLE_WEBSOCKET_SUPPORT,
|
ENABLE_WEBSOCKET_SUPPORT,
|
||||||
BYPASS_MODEL_ACCESS_CONTROL,
|
BYPASS_MODEL_ACCESS_CONTROL,
|
||||||
RESET_CONFIG_ON_START,
|
RESET_CONFIG_ON_START,
|
||||||
OFFLINE_MODE,
|
ENABLE_VERSION_UPDATE_CHECK,
|
||||||
ENABLE_OTEL,
|
ENABLE_OTEL,
|
||||||
EXTERNAL_PWA_MANIFEST_URL,
|
EXTERNAL_PWA_MANIFEST_URL,
|
||||||
AIOHTTP_CLIENT_SESSION_SSL,
|
AIOHTTP_CLIENT_SESSION_SSL,
|
||||||
|
|
@ -453,7 +460,7 @@ from open_webui.utils.redis import get_redis_connection
|
||||||
|
|
||||||
from open_webui.tasks import (
|
from open_webui.tasks import (
|
||||||
redis_task_command_listener,
|
redis_task_command_listener,
|
||||||
list_task_ids_by_chat_id,
|
list_task_ids_by_item_id,
|
||||||
stop_task,
|
stop_task,
|
||||||
list_tasks,
|
list_tasks,
|
||||||
) # Import from tasks.py
|
) # Import from tasks.py
|
||||||
|
|
@ -537,6 +544,27 @@ async def lifespan(app: FastAPI):
|
||||||
|
|
||||||
asyncio.create_task(periodic_usage_pool_cleanup())
|
asyncio.create_task(periodic_usage_pool_cleanup())
|
||||||
|
|
||||||
|
if app.state.config.ENABLE_BASE_MODELS_CACHE:
|
||||||
|
await get_all_models(
|
||||||
|
Request(
|
||||||
|
# Creating a mock request object to pass to get_all_models
|
||||||
|
{
|
||||||
|
"type": "http",
|
||||||
|
"asgi.version": "3.0",
|
||||||
|
"asgi.spec_version": "2.0",
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/internal",
|
||||||
|
"query_string": b"",
|
||||||
|
"headers": Headers({}).raw,
|
||||||
|
"client": ("127.0.0.1", 12345),
|
||||||
|
"server": ("127.0.0.1", 80),
|
||||||
|
"scheme": "http",
|
||||||
|
"app": app,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
if hasattr(app.state, "redis_task_command_listener"):
|
if hasattr(app.state, "redis_task_command_listener"):
|
||||||
|
|
@ -557,6 +585,7 @@ app.state.instance_id = None
|
||||||
app.state.config = AppConfig(
|
app.state.config = AppConfig(
|
||||||
redis_url=REDIS_URL,
|
redis_url=REDIS_URL,
|
||||||
redis_sentinels=get_sentinels_from_env(REDIS_SENTINEL_HOSTS, REDIS_SENTINEL_PORT),
|
redis_sentinels=get_sentinels_from_env(REDIS_SENTINEL_HOSTS, REDIS_SENTINEL_PORT),
|
||||||
|
redis_key_prefix=REDIS_KEY_PREFIX,
|
||||||
)
|
)
|
||||||
app.state.redis = None
|
app.state.redis = None
|
||||||
|
|
||||||
|
|
@ -628,6 +657,15 @@ app.state.config.ENABLE_DIRECT_CONNECTIONS = ENABLE_DIRECT_CONNECTIONS
|
||||||
app.state.config.SCIM_ENABLED = SCIM_ENABLED
|
app.state.config.SCIM_ENABLED = SCIM_ENABLED
|
||||||
app.state.config.SCIM_TOKEN = SCIM_TOKEN
|
app.state.config.SCIM_TOKEN = SCIM_TOKEN
|
||||||
|
|
||||||
|
########################################
|
||||||
|
#
|
||||||
|
# MODELS
|
||||||
|
#
|
||||||
|
########################################
|
||||||
|
|
||||||
|
app.state.config.ENABLE_BASE_MODELS_CACHE = ENABLE_BASE_MODELS_CACHE
|
||||||
|
app.state.BASE_MODELS = []
|
||||||
|
|
||||||
########################################
|
########################################
|
||||||
#
|
#
|
||||||
# WEBUI
|
# WEBUI
|
||||||
|
|
@ -1085,7 +1123,9 @@ class RedirectMiddleware(BaseHTTPMiddleware):
|
||||||
|
|
||||||
|
|
||||||
# Add the middleware to the app
|
# Add the middleware to the app
|
||||||
app.add_middleware(CompressMiddleware)
|
if ENABLE_COMPRESSION_MIDDLEWARE:
|
||||||
|
app.add_middleware(CompressMiddleware)
|
||||||
|
|
||||||
app.add_middleware(RedirectMiddleware)
|
app.add_middleware(RedirectMiddleware)
|
||||||
app.add_middleware(SecurityHeadersMiddleware)
|
app.add_middleware(SecurityHeadersMiddleware)
|
||||||
|
|
||||||
|
|
@ -1204,7 +1244,9 @@ if audit_level != AuditLevel.NONE:
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/models")
|
@app.get("/api/models")
|
||||||
async def get_models(request: Request, user=Depends(get_verified_user)):
|
async def get_models(
|
||||||
|
request: Request, refresh: bool = False, user=Depends(get_verified_user)
|
||||||
|
):
|
||||||
def get_filtered_models(models, user):
|
def get_filtered_models(models, user):
|
||||||
filtered_models = []
|
filtered_models = []
|
||||||
for model in models:
|
for model in models:
|
||||||
|
|
@ -1228,7 +1270,7 @@ async def get_models(request: Request, user=Depends(get_verified_user)):
|
||||||
|
|
||||||
return filtered_models
|
return filtered_models
|
||||||
|
|
||||||
all_models = await get_all_models(request, user=user)
|
all_models = await get_all_models(request, refresh=refresh, user=user)
|
||||||
|
|
||||||
models = []
|
models = []
|
||||||
for model in all_models:
|
for model in all_models:
|
||||||
|
|
@ -1463,7 +1505,7 @@ async def stop_task_endpoint(
|
||||||
request: Request, task_id: str, user=Depends(get_verified_user)
|
request: Request, task_id: str, user=Depends(get_verified_user)
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
result = await stop_task(request, task_id)
|
result = await stop_task(request.app.state.redis, task_id)
|
||||||
return result
|
return result
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
|
||||||
|
|
@ -1471,7 +1513,7 @@ async def stop_task_endpoint(
|
||||||
|
|
||||||
@app.get("/api/tasks")
|
@app.get("/api/tasks")
|
||||||
async def list_tasks_endpoint(request: Request, user=Depends(get_verified_user)):
|
async def list_tasks_endpoint(request: Request, user=Depends(get_verified_user)):
|
||||||
return {"tasks": await list_tasks(request)}
|
return {"tasks": await list_tasks(request.app.state.redis)}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/tasks/chat/{chat_id}")
|
@app.get("/api/tasks/chat/{chat_id}")
|
||||||
|
|
@ -1482,9 +1524,9 @@ async def list_tasks_by_chat_id_endpoint(
|
||||||
if chat is None or chat.user_id != user.id:
|
if chat is None or chat.user_id != user.id:
|
||||||
return {"task_ids": []}
|
return {"task_ids": []}
|
||||||
|
|
||||||
task_ids = await list_task_ids_by_chat_id(request, chat_id)
|
task_ids = await list_task_ids_by_item_id(request.app.state.redis, chat_id)
|
||||||
|
|
||||||
print(f"Task IDs for chat {chat_id}: {task_ids}")
|
log.debug(f"Task IDs for chat {chat_id}: {task_ids}")
|
||||||
return {"task_ids": task_ids}
|
return {"task_ids": task_ids}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1537,6 +1579,7 @@ async def get_app_config(request: Request):
|
||||||
"enable_signup": app.state.config.ENABLE_SIGNUP,
|
"enable_signup": app.state.config.ENABLE_SIGNUP,
|
||||||
"enable_login_form": app.state.config.ENABLE_LOGIN_FORM,
|
"enable_login_form": app.state.config.ENABLE_LOGIN_FORM,
|
||||||
"enable_websocket": ENABLE_WEBSOCKET_SUPPORT,
|
"enable_websocket": ENABLE_WEBSOCKET_SUPPORT,
|
||||||
|
"enable_version_update_check": ENABLE_VERSION_UPDATE_CHECK,
|
||||||
**(
|
**(
|
||||||
{
|
{
|
||||||
"enable_direct_connections": app.state.config.ENABLE_DIRECT_CONNECTIONS,
|
"enable_direct_connections": app.state.config.ENABLE_DIRECT_CONNECTIONS,
|
||||||
|
|
@ -1610,7 +1653,19 @@ async def get_app_config(request: Request):
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
if user is not None
|
if user is not None
|
||||||
else {}
|
else {
|
||||||
|
**(
|
||||||
|
{
|
||||||
|
"metadata": {
|
||||||
|
"login_footer": app.state.LICENSE_METADATA.get(
|
||||||
|
"login_footer", ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if app.state.LICENSE_METADATA
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
}
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1642,9 +1697,9 @@ async def get_app_version():
|
||||||
|
|
||||||
@app.get("/api/version/updates")
|
@app.get("/api/version/updates")
|
||||||
async def get_app_latest_release_version(user=Depends(get_verified_user)):
|
async def get_app_latest_release_version(user=Depends(get_verified_user)):
|
||||||
if OFFLINE_MODE:
|
if not ENABLE_VERSION_UPDATE_CHECK:
|
||||||
log.debug(
|
log.debug(
|
||||||
f"Offline mode is enabled, returning current version as latest version"
|
f"Version update check is disabled, returning current version as latest version"
|
||||||
)
|
)
|
||||||
return {"current": VERSION, "latest": VERSION}
|
return {"current": VERSION, "latest": VERSION}
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
"""Update folder table data
|
||||||
|
|
||||||
|
Revision ID: d31026856c01
|
||||||
|
Revises: 9f0c9cd09105
|
||||||
|
Create Date: 2025-07-13 03:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "d31026856c01"
|
||||||
|
down_revision = "9f0c9cd09105"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.add_column("folder", sa.Column("data", sa.JSON(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_column("folder", "data")
|
||||||
|
|
@ -12,6 +12,7 @@ from pydantic import BaseModel, ConfigDict
|
||||||
from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON
|
from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON
|
||||||
from sqlalchemy import or_, func, select, and_, text
|
from sqlalchemy import or_, func, select, and_, text
|
||||||
from sqlalchemy.sql import exists
|
from sqlalchemy.sql import exists
|
||||||
|
from sqlalchemy.sql.expression import bindparam
|
||||||
|
|
||||||
####################
|
####################
|
||||||
# Chat DB Schema
|
# Chat DB Schema
|
||||||
|
|
@ -66,12 +67,14 @@ class ChatModel(BaseModel):
|
||||||
|
|
||||||
class ChatForm(BaseModel):
|
class ChatForm(BaseModel):
|
||||||
chat: dict
|
chat: dict
|
||||||
|
folder_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class ChatImportForm(ChatForm):
|
class ChatImportForm(ChatForm):
|
||||||
meta: Optional[dict] = {}
|
meta: Optional[dict] = {}
|
||||||
pinned: Optional[bool] = False
|
pinned: Optional[bool] = False
|
||||||
folder_id: Optional[str] = None
|
created_at: Optional[int] = None
|
||||||
|
updated_at: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
class ChatTitleMessagesForm(BaseModel):
|
class ChatTitleMessagesForm(BaseModel):
|
||||||
|
|
@ -118,6 +121,7 @@ class ChatTable:
|
||||||
else "New Chat"
|
else "New Chat"
|
||||||
),
|
),
|
||||||
"chat": form_data.chat,
|
"chat": form_data.chat,
|
||||||
|
"folder_id": form_data.folder_id,
|
||||||
"created_at": int(time.time()),
|
"created_at": int(time.time()),
|
||||||
"updated_at": int(time.time()),
|
"updated_at": int(time.time()),
|
||||||
}
|
}
|
||||||
|
|
@ -147,8 +151,16 @@ class ChatTable:
|
||||||
"meta": form_data.meta,
|
"meta": form_data.meta,
|
||||||
"pinned": form_data.pinned,
|
"pinned": form_data.pinned,
|
||||||
"folder_id": form_data.folder_id,
|
"folder_id": form_data.folder_id,
|
||||||
"created_at": int(time.time()),
|
"created_at": (
|
||||||
"updated_at": int(time.time()),
|
form_data.created_at
|
||||||
|
if form_data.created_at
|
||||||
|
else int(time.time())
|
||||||
|
),
|
||||||
|
"updated_at": (
|
||||||
|
form_data.updated_at
|
||||||
|
if form_data.updated_at
|
||||||
|
else int(time.time())
|
||||||
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -232,6 +244,10 @@ class ChatTable:
|
||||||
if chat is None:
|
if chat is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Sanitize message content for null characters before upserting
|
||||||
|
if isinstance(message.get("content"), str):
|
||||||
|
message["content"] = message["content"].replace("\x00", "")
|
||||||
|
|
||||||
chat = chat.chat
|
chat = chat.chat
|
||||||
history = chat.get("history", {})
|
history = chat.get("history", {})
|
||||||
|
|
||||||
|
|
@ -580,7 +596,7 @@ class ChatTable:
|
||||||
"""
|
"""
|
||||||
Filters chats based on a search query using Python, allowing pagination using skip and limit.
|
Filters chats based on a search query using Python, allowing pagination using skip and limit.
|
||||||
"""
|
"""
|
||||||
search_text = search_text.lower().strip()
|
search_text = search_text.replace("\u0000", "").lower().strip()
|
||||||
|
|
||||||
if not search_text:
|
if not search_text:
|
||||||
return self.get_chat_list_by_user_id(
|
return self.get_chat_list_by_user_id(
|
||||||
|
|
@ -614,21 +630,18 @@ class ChatTable:
|
||||||
dialect_name = db.bind.dialect.name
|
dialect_name = db.bind.dialect.name
|
||||||
if dialect_name == "sqlite":
|
if dialect_name == "sqlite":
|
||||||
# SQLite case: using JSON1 extension for JSON searching
|
# SQLite case: using JSON1 extension for JSON searching
|
||||||
|
sqlite_content_sql = (
|
||||||
|
"EXISTS ("
|
||||||
|
" SELECT 1 "
|
||||||
|
" FROM json_each(Chat.chat, '$.messages') AS message "
|
||||||
|
" WHERE LOWER(message.value->>'content') LIKE '%' || :content_key || '%'"
|
||||||
|
")"
|
||||||
|
)
|
||||||
|
sqlite_content_clause = text(sqlite_content_sql)
|
||||||
query = query.filter(
|
query = query.filter(
|
||||||
(
|
or_(
|
||||||
Chat.title.ilike(
|
Chat.title.ilike(bindparam("title_key")), sqlite_content_clause
|
||||||
f"%{search_text}%"
|
).params(title_key=f"%{search_text}%", content_key=search_text)
|
||||||
) # Case-insensitive search in title
|
|
||||||
| text(
|
|
||||||
"""
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM json_each(Chat.chat, '$.messages') AS message
|
|
||||||
WHERE LOWER(message.value->>'content') LIKE '%' || :search_text || '%'
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
).params(search_text=search_text)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if there are any tags to filter, it should have all the tags
|
# Check if there are any tags to filter, it should have all the tags
|
||||||
|
|
@ -663,21 +676,19 @@ class ChatTable:
|
||||||
|
|
||||||
elif dialect_name == "postgresql":
|
elif dialect_name == "postgresql":
|
||||||
# PostgreSQL relies on proper JSON query for search
|
# PostgreSQL relies on proper JSON query for search
|
||||||
|
postgres_content_sql = (
|
||||||
|
"EXISTS ("
|
||||||
|
" SELECT 1 "
|
||||||
|
" FROM json_array_elements(Chat.chat->'messages') AS message "
|
||||||
|
" WHERE LOWER(message->>'content') LIKE '%' || :content_key || '%'"
|
||||||
|
")"
|
||||||
|
)
|
||||||
|
postgres_content_clause = text(postgres_content_sql)
|
||||||
query = query.filter(
|
query = query.filter(
|
||||||
(
|
or_(
|
||||||
Chat.title.ilike(
|
Chat.title.ilike(bindparam("title_key")),
|
||||||
f"%{search_text}%"
|
postgres_content_clause,
|
||||||
) # Case-insensitive search in title
|
).params(title_key=f"%{search_text}%", content_key=search_text)
|
||||||
| text(
|
|
||||||
"""
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM json_array_elements(Chat.chat->'messages') AS message
|
|
||||||
WHERE LOWER(message->>'content') LIKE '%' || :search_text || '%'
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
).params(search_text=search_text)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if there are any tags to filter, it should have all the tags
|
# Check if there are any tags to filter, it should have all the tags
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ class Folder(Base):
|
||||||
name = Column(Text)
|
name = Column(Text)
|
||||||
items = Column(JSON, nullable=True)
|
items = Column(JSON, nullable=True)
|
||||||
meta = Column(JSON, nullable=True)
|
meta = Column(JSON, nullable=True)
|
||||||
|
data = Column(JSON, nullable=True)
|
||||||
is_expanded = Column(Boolean, default=False)
|
is_expanded = Column(Boolean, default=False)
|
||||||
created_at = Column(BigInteger)
|
created_at = Column(BigInteger)
|
||||||
updated_at = Column(BigInteger)
|
updated_at = Column(BigInteger)
|
||||||
|
|
@ -41,6 +42,7 @@ class FolderModel(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
items: Optional[dict] = None
|
items: Optional[dict] = None
|
||||||
meta: Optional[dict] = None
|
meta: Optional[dict] = None
|
||||||
|
data: Optional[dict] = None
|
||||||
is_expanded: bool = False
|
is_expanded: bool = False
|
||||||
created_at: int
|
created_at: int
|
||||||
updated_at: int
|
updated_at: int
|
||||||
|
|
@ -55,6 +57,7 @@ class FolderModel(BaseModel):
|
||||||
|
|
||||||
class FolderForm(BaseModel):
|
class FolderForm(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
|
data: Optional[dict] = None
|
||||||
model_config = ConfigDict(extra="allow")
|
model_config = ConfigDict(extra="allow")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -187,8 +190,8 @@ class FolderTable:
|
||||||
log.error(f"update_folder: {e}")
|
log.error(f"update_folder: {e}")
|
||||||
return
|
return
|
||||||
|
|
||||||
def update_folder_name_by_id_and_user_id(
|
def update_folder_by_id_and_user_id(
|
||||||
self, id: str, user_id: str, name: str
|
self, id: str, user_id: str, form_data: FolderForm
|
||||||
) -> Optional[FolderModel]:
|
) -> Optional[FolderModel]:
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
|
|
@ -197,16 +200,28 @@ class FolderTable:
|
||||||
if not folder:
|
if not folder:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
form_data = form_data.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
existing_folder = (
|
existing_folder = (
|
||||||
db.query(Folder)
|
db.query(Folder)
|
||||||
.filter_by(name=name, parent_id=folder.parent_id, user_id=user_id)
|
.filter_by(
|
||||||
|
name=form_data.get("name"),
|
||||||
|
parent_id=folder.parent_id,
|
||||||
|
user_id=user_id,
|
||||||
|
)
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
|
||||||
if existing_folder:
|
if existing_folder and existing_folder.id != id:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
folder.name = name
|
folder.name = form_data.get("name", folder.name)
|
||||||
|
if "data" in form_data:
|
||||||
|
folder.data = {
|
||||||
|
**(folder.data or {}),
|
||||||
|
**form_data["data"],
|
||||||
|
}
|
||||||
|
|
||||||
folder.updated_at = int(time.time())
|
folder.updated_at = int(time.time())
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,13 @@ class NoteForm(BaseModel):
|
||||||
access_control: Optional[dict] = None
|
access_control: Optional[dict] = None
|
||||||
|
|
||||||
|
|
||||||
|
class NoteUpdateForm(BaseModel):
|
||||||
|
title: Optional[str] = None
|
||||||
|
data: Optional[dict] = None
|
||||||
|
meta: Optional[dict] = None
|
||||||
|
access_control: Optional[dict] = None
|
||||||
|
|
||||||
|
|
||||||
class NoteUserResponse(NoteModel):
|
class NoteUserResponse(NoteModel):
|
||||||
user: Optional[UserResponse] = None
|
user: Optional[UserResponse] = None
|
||||||
|
|
||||||
|
|
@ -110,16 +117,26 @@ class NoteTable:
|
||||||
note = db.query(Note).filter(Note.id == id).first()
|
note = db.query(Note).filter(Note.id == id).first()
|
||||||
return NoteModel.model_validate(note) if note else None
|
return NoteModel.model_validate(note) if note else None
|
||||||
|
|
||||||
def update_note_by_id(self, id: str, form_data: NoteForm) -> Optional[NoteModel]:
|
def update_note_by_id(
|
||||||
|
self, id: str, form_data: NoteUpdateForm
|
||||||
|
) -> Optional[NoteModel]:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
note = db.query(Note).filter(Note.id == id).first()
|
note = db.query(Note).filter(Note.id == id).first()
|
||||||
if not note:
|
if not note:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
note.title = form_data.title
|
form_data = form_data.model_dump(exclude_unset=True)
|
||||||
note.data = form_data.data
|
|
||||||
note.meta = form_data.meta
|
if "title" in form_data:
|
||||||
note.access_control = form_data.access_control
|
note.title = form_data["title"]
|
||||||
|
if "data" in form_data:
|
||||||
|
note.data = {**note.data, **form_data["data"]}
|
||||||
|
if "meta" in form_data:
|
||||||
|
note.meta = {**note.meta, **form_data["meta"]}
|
||||||
|
|
||||||
|
if "access_control" in form_data:
|
||||||
|
note.access_control = form_data["access_control"]
|
||||||
|
|
||||||
note.updated_at = int(time.time_ns())
|
note.updated_at = int(time.time_ns())
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ from langchain_community.document_loaders import (
|
||||||
TextLoader,
|
TextLoader,
|
||||||
UnstructuredEPubLoader,
|
UnstructuredEPubLoader,
|
||||||
UnstructuredExcelLoader,
|
UnstructuredExcelLoader,
|
||||||
UnstructuredMarkdownLoader,
|
UnstructuredODTLoader,
|
||||||
UnstructuredPowerPointLoader,
|
UnstructuredPowerPointLoader,
|
||||||
UnstructuredRSTLoader,
|
UnstructuredRSTLoader,
|
||||||
UnstructuredXMLLoader,
|
UnstructuredXMLLoader,
|
||||||
|
|
@ -226,7 +226,10 @@ class Loader:
|
||||||
|
|
||||||
def _is_text_file(self, file_ext: str, file_content_type: str) -> bool:
|
def _is_text_file(self, file_ext: str, file_content_type: str) -> bool:
|
||||||
return file_ext in known_source_ext or (
|
return file_ext in known_source_ext or (
|
||||||
file_content_type and file_content_type.find("text/") >= 0
|
file_content_type
|
||||||
|
and file_content_type.find("text/") >= 0
|
||||||
|
# Avoid text/html files being detected as text
|
||||||
|
and not file_content_type.find("html") >= 0
|
||||||
)
|
)
|
||||||
|
|
||||||
def _get_loader(self, filename: str, file_content_type: str, file_path: str):
|
def _get_loader(self, filename: str, file_content_type: str, file_path: str):
|
||||||
|
|
@ -389,6 +392,8 @@ class Loader:
|
||||||
loader = UnstructuredPowerPointLoader(file_path)
|
loader = UnstructuredPowerPointLoader(file_path)
|
||||||
elif file_ext == "msg":
|
elif file_ext == "msg":
|
||||||
loader = OutlookMessageLoader(file_path)
|
loader = OutlookMessageLoader(file_path)
|
||||||
|
elif file_ext == "odt":
|
||||||
|
loader = UnstructuredODTLoader(file_path)
|
||||||
elif self._is_text_file(file_ext, file_content_type):
|
elif self._is_text_file(file_ext, file_content_type):
|
||||||
loader = TextLoader(file_path, autodetect_encoding=True)
|
loader = TextLoader(file_path, autodetect_encoding=True)
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -507,6 +507,7 @@ class MistralLoader:
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
headers={"User-Agent": "OpenWebUI-MistralLoader/2.0"},
|
headers={"User-Agent": "OpenWebUI-MistralLoader/2.0"},
|
||||||
raise_for_status=False, # We handle status codes manually
|
raise_for_status=False, # We handle status codes manually
|
||||||
|
trust_env=True,
|
||||||
) as session:
|
) as session:
|
||||||
yield session
|
yield session
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import hashlib
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
from urllib.parse import quote
|
||||||
from huggingface_hub import snapshot_download
|
from huggingface_hub import snapshot_download
|
||||||
from langchain.retrievers import ContextualCompressionRetriever, EnsembleRetriever
|
from langchain.retrievers import ContextualCompressionRetriever, EnsembleRetriever
|
||||||
from langchain_community.retrievers import BM25Retriever
|
from langchain_community.retrievers import BM25Retriever
|
||||||
|
|
@ -17,8 +18,11 @@ from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
|
||||||
|
|
||||||
from open_webui.models.users import UserModel
|
from open_webui.models.users import UserModel
|
||||||
from open_webui.models.files import Files
|
from open_webui.models.files import Files
|
||||||
|
from open_webui.models.knowledge import Knowledges
|
||||||
|
from open_webui.models.notes import Notes
|
||||||
|
|
||||||
from open_webui.retrieval.vector.main import GetResult
|
from open_webui.retrieval.vector.main import GetResult
|
||||||
|
from open_webui.utils.access_control import has_access
|
||||||
|
|
||||||
|
|
||||||
from open_webui.env import (
|
from open_webui.env import (
|
||||||
|
|
@ -441,9 +445,9 @@ def get_embedding_function(
|
||||||
raise ValueError(f"Unknown embedding engine: {embedding_engine}")
|
raise ValueError(f"Unknown embedding engine: {embedding_engine}")
|
||||||
|
|
||||||
|
|
||||||
def get_sources_from_files(
|
def get_sources_from_items(
|
||||||
request,
|
request,
|
||||||
files,
|
items,
|
||||||
queries,
|
queries,
|
||||||
embedding_function,
|
embedding_function,
|
||||||
k,
|
k,
|
||||||
|
|
@ -453,159 +457,206 @@ def get_sources_from_files(
|
||||||
hybrid_bm25_weight,
|
hybrid_bm25_weight,
|
||||||
hybrid_search,
|
hybrid_search,
|
||||||
full_context=False,
|
full_context=False,
|
||||||
|
user: Optional[UserModel] = None,
|
||||||
):
|
):
|
||||||
log.debug(
|
log.debug(
|
||||||
f"files: {files} {queries} {embedding_function} {reranking_function} {full_context}"
|
f"items: {items} {queries} {embedding_function} {reranking_function} {full_context}"
|
||||||
)
|
)
|
||||||
|
|
||||||
extracted_collections = []
|
extracted_collections = []
|
||||||
relevant_contexts = []
|
query_results = []
|
||||||
|
|
||||||
for file in files:
|
for item in items:
|
||||||
|
query_result = None
|
||||||
|
collection_names = []
|
||||||
|
|
||||||
context = None
|
if item.get("type") == "text":
|
||||||
if file.get("docs"):
|
# Raw Text
|
||||||
# BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL
|
# Used during temporary chat file uploads
|
||||||
context = {
|
|
||||||
"documents": [[doc.get("content") for doc in file.get("docs")]],
|
|
||||||
"metadatas": [[doc.get("metadata") for doc in file.get("docs")]],
|
|
||||||
}
|
|
||||||
elif file.get("context") == "full":
|
|
||||||
# Manual Full Mode Toggle
|
|
||||||
context = {
|
|
||||||
"documents": [[file.get("file").get("data", {}).get("content")]],
|
|
||||||
"metadatas": [[{"file_id": file.get("id"), "name": file.get("name")}]],
|
|
||||||
}
|
|
||||||
elif (
|
|
||||||
file.get("type") != "web_search"
|
|
||||||
and request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL
|
|
||||||
):
|
|
||||||
# BYPASS_EMBEDDING_AND_RETRIEVAL
|
|
||||||
if file.get("type") == "collection":
|
|
||||||
file_ids = file.get("data", {}).get("file_ids", [])
|
|
||||||
|
|
||||||
documents = []
|
if item.get("file"):
|
||||||
metadatas = []
|
# if item has file data, use it
|
||||||
for file_id in file_ids:
|
query_result = {
|
||||||
file_object = Files.get_file_by_id(file_id)
|
"documents": [[item.get("file").get("data", {}).get("content")]],
|
||||||
|
"metadatas": [[item.get("file").get("data", {}).get("meta", {})]],
|
||||||
if file_object:
|
}
|
||||||
documents.append(file_object.data.get("content", ""))
|
else:
|
||||||
metadatas.append(
|
# Fallback to item content
|
||||||
{
|
query_result = {
|
||||||
"file_id": file_id,
|
"documents": [[item.get("content")]],
|
||||||
"name": file_object.filename,
|
"metadatas": [
|
||||||
"source": file_object.filename,
|
[{"file_id": item.get("id"), "name": item.get("name")}]
|
||||||
}
|
],
|
||||||
)
|
|
||||||
|
|
||||||
context = {
|
|
||||||
"documents": [documents],
|
|
||||||
"metadatas": [metadatas],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
elif file.get("id"):
|
elif item.get("type") == "note":
|
||||||
file_object = Files.get_file_by_id(file.get("id"))
|
# Note Attached
|
||||||
if file_object:
|
note = Notes.get_note_by_id(item.get("id"))
|
||||||
context = {
|
|
||||||
"documents": [[file_object.data.get("content", "")]],
|
if user.role == "admin" or has_access(user.id, "read", note.access_control):
|
||||||
|
# User has access to the note
|
||||||
|
query_result = {
|
||||||
|
"documents": [[note.data.get("content", {}).get("md", "")]],
|
||||||
|
"metadatas": [[{"file_id": note.id, "name": note.title}]],
|
||||||
|
}
|
||||||
|
|
||||||
|
elif item.get("type") == "file":
|
||||||
|
if (
|
||||||
|
item.get("context") == "full"
|
||||||
|
or request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL
|
||||||
|
):
|
||||||
|
if item.get("file").get("data", {}):
|
||||||
|
# Manual Full Mode Toggle
|
||||||
|
# Used from chat file modal, we can assume that the file content will be available from item.get("file").get("data", {}).get("content")
|
||||||
|
query_result = {
|
||||||
|
"documents": [
|
||||||
|
[item.get("file").get("data", {}).get("content", "")]
|
||||||
|
],
|
||||||
"metadatas": [
|
"metadatas": [
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"file_id": file.get("id"),
|
"file_id": item.get("id"),
|
||||||
"name": file_object.filename,
|
"name": item.get("name"),
|
||||||
"source": file_object.filename,
|
**item.get("file")
|
||||||
|
.get("data", {})
|
||||||
|
.get("metadata", {}),
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
elif file.get("file").get("data"):
|
elif item.get("id"):
|
||||||
context = {
|
file_object = Files.get_file_by_id(item.get("id"))
|
||||||
"documents": [[file.get("file").get("data", {}).get("content")]],
|
if file_object:
|
||||||
"metadatas": [
|
query_result = {
|
||||||
[file.get("file").get("data", {}).get("metadata", {})]
|
"documents": [[file_object.data.get("content", "")]],
|
||||||
],
|
"metadatas": [
|
||||||
}
|
[
|
||||||
else:
|
{
|
||||||
collection_names = []
|
"file_id": item.get("id"),
|
||||||
if file.get("type") == "collection":
|
"name": file_object.filename,
|
||||||
if file.get("legacy"):
|
"source": file_object.filename,
|
||||||
collection_names = file.get("collection_names", [])
|
}
|
||||||
|
]
|
||||||
|
],
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# Fallback to collection names
|
||||||
|
if item.get("legacy"):
|
||||||
|
collection_names.append(f"{item['id']}")
|
||||||
else:
|
else:
|
||||||
collection_names.append(file["id"])
|
collection_names.append(f"file-{item['id']}")
|
||||||
elif file.get("collection_name"):
|
|
||||||
collection_names.append(file["collection_name"])
|
|
||||||
elif file.get("id"):
|
|
||||||
if file.get("legacy"):
|
|
||||||
collection_names.append(f"{file['id']}")
|
|
||||||
else:
|
|
||||||
collection_names.append(f"file-{file['id']}")
|
|
||||||
|
|
||||||
|
elif item.get("type") == "collection":
|
||||||
|
if (
|
||||||
|
item.get("context") == "full"
|
||||||
|
or request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL
|
||||||
|
):
|
||||||
|
# Manual Full Mode Toggle for Collection
|
||||||
|
knowledge_base = Knowledges.get_knowledge_by_id(item.get("id"))
|
||||||
|
|
||||||
|
if knowledge_base and (
|
||||||
|
user.role == "admin"
|
||||||
|
or has_access(user.id, "read", knowledge_base.access_control)
|
||||||
|
):
|
||||||
|
|
||||||
|
file_ids = knowledge_base.data.get("file_ids", [])
|
||||||
|
|
||||||
|
documents = []
|
||||||
|
metadatas = []
|
||||||
|
for file_id in file_ids:
|
||||||
|
file_object = Files.get_file_by_id(file_id)
|
||||||
|
|
||||||
|
if file_object:
|
||||||
|
documents.append(file_object.data.get("content", ""))
|
||||||
|
metadatas.append(
|
||||||
|
{
|
||||||
|
"file_id": file_id,
|
||||||
|
"name": file_object.filename,
|
||||||
|
"source": file_object.filename,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
query_result = {
|
||||||
|
"documents": [documents],
|
||||||
|
"metadatas": [metadatas],
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# Fallback to collection names
|
||||||
|
if item.get("legacy"):
|
||||||
|
collection_names = item.get("collection_names", [])
|
||||||
|
else:
|
||||||
|
collection_names.append(item["id"])
|
||||||
|
|
||||||
|
elif item.get("docs"):
|
||||||
|
# BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL
|
||||||
|
query_result = {
|
||||||
|
"documents": [[doc.get("content") for doc in item.get("docs")]],
|
||||||
|
"metadatas": [[doc.get("metadata") for doc in item.get("docs")]],
|
||||||
|
}
|
||||||
|
elif item.get("collection_name"):
|
||||||
|
# Direct Collection Name
|
||||||
|
collection_names.append(item["collection_name"])
|
||||||
|
|
||||||
|
# If query_result is None
|
||||||
|
# Fallback to collection names and vector search the collections
|
||||||
|
if query_result is None and collection_names:
|
||||||
collection_names = set(collection_names).difference(extracted_collections)
|
collection_names = set(collection_names).difference(extracted_collections)
|
||||||
if not collection_names:
|
if not collection_names:
|
||||||
log.debug(f"skipping {file} as it has already been extracted")
|
log.debug(f"skipping {item} as it has already been extracted")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if full_context:
|
try:
|
||||||
try:
|
if full_context:
|
||||||
context = get_all_items_from_collections(collection_names)
|
query_result = get_all_items_from_collections(collection_names)
|
||||||
except Exception as e:
|
else:
|
||||||
log.exception(e)
|
query_result = None # Initialize to None
|
||||||
|
if hybrid_search:
|
||||||
else:
|
try:
|
||||||
try:
|
query_result = query_collection_with_hybrid_search(
|
||||||
context = None
|
|
||||||
if file.get("type") == "text":
|
|
||||||
context = file["content"]
|
|
||||||
else:
|
|
||||||
if hybrid_search:
|
|
||||||
try:
|
|
||||||
context = query_collection_with_hybrid_search(
|
|
||||||
collection_names=collection_names,
|
|
||||||
queries=queries,
|
|
||||||
embedding_function=embedding_function,
|
|
||||||
k=k,
|
|
||||||
reranking_function=reranking_function,
|
|
||||||
k_reranker=k_reranker,
|
|
||||||
r=r,
|
|
||||||
hybrid_bm25_weight=hybrid_bm25_weight,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
log.debug(
|
|
||||||
"Error when using hybrid search, using"
|
|
||||||
" non hybrid search as fallback."
|
|
||||||
)
|
|
||||||
|
|
||||||
if (not hybrid_search) or (context is None):
|
|
||||||
context = query_collection(
|
|
||||||
collection_names=collection_names,
|
collection_names=collection_names,
|
||||||
queries=queries,
|
queries=queries,
|
||||||
embedding_function=embedding_function,
|
embedding_function=embedding_function,
|
||||||
k=k,
|
k=k,
|
||||||
|
reranking_function=reranking_function,
|
||||||
|
k_reranker=k_reranker,
|
||||||
|
r=r,
|
||||||
|
hybrid_bm25_weight=hybrid_bm25_weight,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.exception(e)
|
log.debug(
|
||||||
|
"Error when using hybrid search, using non hybrid search as fallback."
|
||||||
|
)
|
||||||
|
|
||||||
|
# fallback to non-hybrid search
|
||||||
|
if not hybrid_search and query_result is None:
|
||||||
|
query_result = query_collection(
|
||||||
|
collection_names=collection_names,
|
||||||
|
queries=queries,
|
||||||
|
embedding_function=embedding_function,
|
||||||
|
k=k,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
log.exception(e)
|
||||||
|
|
||||||
extracted_collections.extend(collection_names)
|
extracted_collections.extend(collection_names)
|
||||||
|
|
||||||
if context:
|
if query_result:
|
||||||
if "data" in file:
|
if "data" in item:
|
||||||
del file["data"]
|
del item["data"]
|
||||||
|
query_results.append({**query_result, "file": item})
|
||||||
relevant_contexts.append({**context, "file": file})
|
|
||||||
|
|
||||||
sources = []
|
sources = []
|
||||||
for context in relevant_contexts:
|
for query_result in query_results:
|
||||||
try:
|
try:
|
||||||
if "documents" in context:
|
if "documents" in query_result:
|
||||||
if "metadatas" in context:
|
if "metadatas" in query_result:
|
||||||
source = {
|
source = {
|
||||||
"source": context["file"],
|
"source": query_result["file"],
|
||||||
"document": context["documents"][0],
|
"document": query_result["documents"][0],
|
||||||
"metadata": context["metadatas"][0],
|
"metadata": query_result["metadatas"][0],
|
||||||
}
|
}
|
||||||
if "distances" in context and context["distances"]:
|
if "distances" in query_result and query_result["distances"]:
|
||||||
source["distances"] = context["distances"][0]
|
source["distances"] = query_result["distances"][0]
|
||||||
|
|
||||||
sources.append(source)
|
sources.append(source)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -678,7 +729,7 @@ def generate_openai_batch_embeddings(
|
||||||
"Authorization": f"Bearer {key}",
|
"Authorization": f"Bearer {key}",
|
||||||
**(
|
**(
|
||||||
{
|
{
|
||||||
"X-OpenWebUI-User-Name": user.name,
|
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||||
"X-OpenWebUI-User-Id": user.id,
|
"X-OpenWebUI-User-Id": user.id,
|
||||||
"X-OpenWebUI-User-Email": user.email,
|
"X-OpenWebUI-User-Email": user.email,
|
||||||
"X-OpenWebUI-User-Role": user.role,
|
"X-OpenWebUI-User-Role": user.role,
|
||||||
|
|
@ -727,7 +778,7 @@ def generate_azure_openai_batch_embeddings(
|
||||||
"api-key": key,
|
"api-key": key,
|
||||||
**(
|
**(
|
||||||
{
|
{
|
||||||
"X-OpenWebUI-User-Name": user.name,
|
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||||
"X-OpenWebUI-User-Id": user.id,
|
"X-OpenWebUI-User-Id": user.id,
|
||||||
"X-OpenWebUI-User-Email": user.email,
|
"X-OpenWebUI-User-Email": user.email,
|
||||||
"X-OpenWebUI-User-Role": user.role,
|
"X-OpenWebUI-User-Role": user.role,
|
||||||
|
|
@ -777,7 +828,7 @@ def generate_ollama_batch_embeddings(
|
||||||
"Authorization": f"Bearer {key}",
|
"Authorization": f"Bearer {key}",
|
||||||
**(
|
**(
|
||||||
{
|
{
|
||||||
"X-OpenWebUI-User-Name": user.name,
|
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||||
"X-OpenWebUI-User-Id": user.id,
|
"X-OpenWebUI-User-Id": user.id,
|
||||||
"X-OpenWebUI-User-Email": user.email,
|
"X-OpenWebUI-User-Email": user.email,
|
||||||
"X-OpenWebUI-User-Role": user.role,
|
"X-OpenWebUI-User-Role": user.role,
|
||||||
|
|
|
||||||
|
|
@ -157,10 +157,10 @@ class OpenSearchClient(VectorDBBase):
|
||||||
|
|
||||||
for field, value in filter.items():
|
for field, value in filter.items():
|
||||||
query_body["query"]["bool"]["filter"].append(
|
query_body["query"]["bool"]["filter"].append(
|
||||||
{"match": {"metadata." + str(field): value}}
|
{"term": {"metadata." + str(field) + ".keyword": value}}
|
||||||
)
|
)
|
||||||
|
|
||||||
size = limit if limit else 10
|
size = limit if limit else 10000
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = self.client.search(
|
result = self.client.search(
|
||||||
|
|
@ -206,6 +206,7 @@ class OpenSearchClient(VectorDBBase):
|
||||||
for item in batch
|
for item in batch
|
||||||
]
|
]
|
||||||
bulk(self.client, actions)
|
bulk(self.client, actions)
|
||||||
|
self.client.indices.refresh(self._get_index_name(collection_name))
|
||||||
|
|
||||||
def upsert(self, collection_name: str, items: list[VectorItem]):
|
def upsert(self, collection_name: str, items: list[VectorItem]):
|
||||||
self._create_index_if_not_exists(
|
self._create_index_if_not_exists(
|
||||||
|
|
@ -228,6 +229,7 @@ class OpenSearchClient(VectorDBBase):
|
||||||
for item in batch
|
for item in batch
|
||||||
]
|
]
|
||||||
bulk(self.client, actions)
|
bulk(self.client, actions)
|
||||||
|
self.client.indices.refresh(self._get_index_name(collection_name))
|
||||||
|
|
||||||
def delete(
|
def delete(
|
||||||
self,
|
self,
|
||||||
|
|
@ -251,11 +253,12 @@ class OpenSearchClient(VectorDBBase):
|
||||||
}
|
}
|
||||||
for field, value in filter.items():
|
for field, value in filter.items():
|
||||||
query_body["query"]["bool"]["filter"].append(
|
query_body["query"]["bool"]["filter"].append(
|
||||||
{"match": {"metadata." + str(field): value}}
|
{"term": {"metadata." + str(field) + ".keyword": value}}
|
||||||
)
|
)
|
||||||
self.client.delete_by_query(
|
self.client.delete_by_query(
|
||||||
index=self._get_index_name(collection_name), body=query_body
|
index=self._get_index_name(collection_name), body=query_body
|
||||||
)
|
)
|
||||||
|
self.client.indices.refresh(self._get_index_name(collection_name))
|
||||||
|
|
||||||
def reset(self):
|
def reset(self):
|
||||||
indices = self.client.indices.get(index=f"{self.index_prefix}_*")
|
indices = self.client.indices.get(index=f"{self.index_prefix}_*")
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ from open_webui.config import (
|
||||||
QDRANT_ON_DISK,
|
QDRANT_ON_DISK,
|
||||||
QDRANT_GRPC_PORT,
|
QDRANT_GRPC_PORT,
|
||||||
QDRANT_PREFER_GRPC,
|
QDRANT_PREFER_GRPC,
|
||||||
|
QDRANT_COLLECTION_PREFIX,
|
||||||
)
|
)
|
||||||
from open_webui.env import SRC_LOG_LEVELS
|
from open_webui.env import SRC_LOG_LEVELS
|
||||||
|
|
||||||
|
|
@ -29,7 +30,7 @@ log.setLevel(SRC_LOG_LEVELS["RAG"])
|
||||||
|
|
||||||
class QdrantClient(VectorDBBase):
|
class QdrantClient(VectorDBBase):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.collection_prefix = "open-webui"
|
self.collection_prefix = QDRANT_COLLECTION_PREFIX
|
||||||
self.QDRANT_URI = QDRANT_URI
|
self.QDRANT_URI = QDRANT_URI
|
||||||
self.QDRANT_API_KEY = QDRANT_API_KEY
|
self.QDRANT_API_KEY = QDRANT_API_KEY
|
||||||
self.QDRANT_ON_DISK = QDRANT_ON_DISK
|
self.QDRANT_ON_DISK = QDRANT_ON_DISK
|
||||||
|
|
@ -86,6 +87,25 @@ class QdrantClient(VectorDBBase):
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Create payload indexes for efficient filtering
|
||||||
|
self.client.create_payload_index(
|
||||||
|
collection_name=collection_name_with_prefix,
|
||||||
|
field_name="metadata.hash",
|
||||||
|
field_schema=models.KeywordIndexParams(
|
||||||
|
type=models.KeywordIndexType.KEYWORD,
|
||||||
|
is_tenant=False,
|
||||||
|
on_disk=self.QDRANT_ON_DISK,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.client.create_payload_index(
|
||||||
|
collection_name=collection_name_with_prefix,
|
||||||
|
field_name="metadata.file_id",
|
||||||
|
field_schema=models.KeywordIndexParams(
|
||||||
|
type=models.KeywordIndexType.KEYWORD,
|
||||||
|
is_tenant=False,
|
||||||
|
on_disk=self.QDRANT_ON_DISK,
|
||||||
|
),
|
||||||
|
)
|
||||||
log.info(f"collection {collection_name_with_prefix} successfully created!")
|
log.info(f"collection {collection_name_with_prefix} successfully created!")
|
||||||
|
|
||||||
def _create_collection_if_not_exists(self, collection_name, dimension):
|
def _create_collection_if_not_exists(self, collection_name, dimension):
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional, Tuple
|
from typing import Optional, Tuple, List, Dict, Any
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import grpc
|
import grpc
|
||||||
|
|
@ -9,6 +9,7 @@ from open_webui.config import (
|
||||||
QDRANT_ON_DISK,
|
QDRANT_ON_DISK,
|
||||||
QDRANT_PREFER_GRPC,
|
QDRANT_PREFER_GRPC,
|
||||||
QDRANT_URI,
|
QDRANT_URI,
|
||||||
|
QDRANT_COLLECTION_PREFIX,
|
||||||
)
|
)
|
||||||
from open_webui.env import SRC_LOG_LEVELS
|
from open_webui.env import SRC_LOG_LEVELS
|
||||||
from open_webui.retrieval.vector.main import (
|
from open_webui.retrieval.vector.main import (
|
||||||
|
|
@ -23,14 +24,28 @@ from qdrant_client.http.models import PointStruct
|
||||||
from qdrant_client.models import models
|
from qdrant_client.models import models
|
||||||
|
|
||||||
NO_LIMIT = 999999999
|
NO_LIMIT = 999999999
|
||||||
|
TENANT_ID_FIELD = "tenant_id"
|
||||||
|
DEFAULT_DIMENSION = 384
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
log.setLevel(SRC_LOG_LEVELS["RAG"])
|
log.setLevel(SRC_LOG_LEVELS["RAG"])
|
||||||
|
|
||||||
|
|
||||||
|
def _tenant_filter(tenant_id: str) -> models.FieldCondition:
|
||||||
|
return models.FieldCondition(
|
||||||
|
key=TENANT_ID_FIELD, match=models.MatchValue(value=tenant_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _metadata_filter(key: str, value: Any) -> models.FieldCondition:
|
||||||
|
return models.FieldCondition(
|
||||||
|
key=f"metadata.{key}", match=models.MatchValue(value=value)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class QdrantClient(VectorDBBase):
|
class QdrantClient(VectorDBBase):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.collection_prefix = "open-webui"
|
self.collection_prefix = QDRANT_COLLECTION_PREFIX
|
||||||
self.QDRANT_URI = QDRANT_URI
|
self.QDRANT_URI = QDRANT_URI
|
||||||
self.QDRANT_API_KEY = QDRANT_API_KEY
|
self.QDRANT_API_KEY = QDRANT_API_KEY
|
||||||
self.QDRANT_ON_DISK = QDRANT_ON_DISK
|
self.QDRANT_ON_DISK = QDRANT_ON_DISK
|
||||||
|
|
@ -38,24 +53,26 @@ class QdrantClient(VectorDBBase):
|
||||||
self.GRPC_PORT = QDRANT_GRPC_PORT
|
self.GRPC_PORT = QDRANT_GRPC_PORT
|
||||||
|
|
||||||
if not self.QDRANT_URI:
|
if not self.QDRANT_URI:
|
||||||
self.client = None
|
raise ValueError(
|
||||||
return
|
"QDRANT_URI is not set. Please configure it in the environment variables."
|
||||||
|
)
|
||||||
|
|
||||||
# Unified handling for either scheme
|
# Unified handling for either scheme
|
||||||
parsed = urlparse(self.QDRANT_URI)
|
parsed = urlparse(self.QDRANT_URI)
|
||||||
host = parsed.hostname or self.QDRANT_URI
|
host = parsed.hostname or self.QDRANT_URI
|
||||||
http_port = parsed.port or 6333 # default REST port
|
http_port = parsed.port or 6333 # default REST port
|
||||||
|
|
||||||
if self.PREFER_GRPC:
|
self.client = (
|
||||||
self.client = Qclient(
|
Qclient(
|
||||||
host=host,
|
host=host,
|
||||||
port=http_port,
|
port=http_port,
|
||||||
grpc_port=self.GRPC_PORT,
|
grpc_port=self.GRPC_PORT,
|
||||||
prefer_grpc=self.PREFER_GRPC,
|
prefer_grpc=self.PREFER_GRPC,
|
||||||
api_key=self.QDRANT_API_KEY,
|
api_key=self.QDRANT_API_KEY,
|
||||||
)
|
)
|
||||||
else:
|
if self.PREFER_GRPC
|
||||||
self.client = Qclient(url=self.QDRANT_URI, api_key=self.QDRANT_API_KEY)
|
else Qclient(url=self.QDRANT_URI, api_key=self.QDRANT_API_KEY)
|
||||||
|
)
|
||||||
|
|
||||||
# Main collection types for multi-tenancy
|
# Main collection types for multi-tenancy
|
||||||
self.MEMORY_COLLECTION = f"{self.collection_prefix}_memories"
|
self.MEMORY_COLLECTION = f"{self.collection_prefix}_memories"
|
||||||
|
|
@ -65,23 +82,13 @@ class QdrantClient(VectorDBBase):
|
||||||
self.HASH_BASED_COLLECTION = f"{self.collection_prefix}_hash-based"
|
self.HASH_BASED_COLLECTION = f"{self.collection_prefix}_hash-based"
|
||||||
|
|
||||||
def _result_to_get_result(self, points) -> GetResult:
|
def _result_to_get_result(self, points) -> GetResult:
|
||||||
ids = []
|
ids, documents, metadatas = [], [], []
|
||||||
documents = []
|
|
||||||
metadatas = []
|
|
||||||
|
|
||||||
for point in points:
|
for point in points:
|
||||||
payload = point.payload
|
payload = point.payload
|
||||||
ids.append(point.id)
|
ids.append(point.id)
|
||||||
documents.append(payload["text"])
|
documents.append(payload["text"])
|
||||||
metadatas.append(payload["metadata"])
|
metadatas.append(payload["metadata"])
|
||||||
|
return GetResult(ids=[ids], documents=[documents], metadatas=[metadatas])
|
||||||
return GetResult(
|
|
||||||
**{
|
|
||||||
"ids": [ids],
|
|
||||||
"documents": [documents],
|
|
||||||
"metadatas": [metadatas],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
def _get_collection_and_tenant_id(self, collection_name: str) -> Tuple[str, str]:
|
def _get_collection_and_tenant_id(self, collection_name: str) -> Tuple[str, str]:
|
||||||
"""
|
"""
|
||||||
|
|
@ -113,143 +120,47 @@ class QdrantClient(VectorDBBase):
|
||||||
else:
|
else:
|
||||||
return self.KNOWLEDGE_COLLECTION, tenant_id
|
return self.KNOWLEDGE_COLLECTION, tenant_id
|
||||||
|
|
||||||
def _extract_error_message(self, exception):
|
def _create_multi_tenant_collection(
|
||||||
"""
|
self, mt_collection_name: str, dimension: int = DEFAULT_DIMENSION
|
||||||
Extract error message from either HTTP or gRPC exceptions
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
tuple: (status_code, error_message)
|
|
||||||
"""
|
|
||||||
# Check if it's an HTTP exception
|
|
||||||
if isinstance(exception, UnexpectedResponse):
|
|
||||||
try:
|
|
||||||
error_data = exception.structured()
|
|
||||||
error_msg = error_data.get("status", {}).get("error", "")
|
|
||||||
return exception.status_code, error_msg
|
|
||||||
except Exception as inner_e:
|
|
||||||
log.error(f"Failed to parse HTTP error: {inner_e}")
|
|
||||||
return exception.status_code, str(exception)
|
|
||||||
|
|
||||||
# Check if it's a gRPC exception
|
|
||||||
elif isinstance(exception, grpc.RpcError):
|
|
||||||
# Extract status code from gRPC error
|
|
||||||
status_code = None
|
|
||||||
if hasattr(exception, "code") and callable(exception.code):
|
|
||||||
status_code = exception.code().value[0]
|
|
||||||
|
|
||||||
# Extract error message
|
|
||||||
error_msg = str(exception)
|
|
||||||
if "details =" in error_msg:
|
|
||||||
# Parse the details line which contains the actual error message
|
|
||||||
try:
|
|
||||||
details_line = [
|
|
||||||
line.strip()
|
|
||||||
for line in error_msg.split("\n")
|
|
||||||
if "details =" in line
|
|
||||||
][0]
|
|
||||||
error_msg = details_line.split("details =")[1].strip(' "')
|
|
||||||
except (IndexError, AttributeError):
|
|
||||||
# Fall back to full message if parsing fails
|
|
||||||
pass
|
|
||||||
|
|
||||||
return status_code, error_msg
|
|
||||||
|
|
||||||
# For any other type of exception
|
|
||||||
return None, str(exception)
|
|
||||||
|
|
||||||
def _is_collection_not_found_error(self, exception):
|
|
||||||
"""
|
|
||||||
Check if the exception is due to collection not found, supporting both HTTP and gRPC
|
|
||||||
"""
|
|
||||||
status_code, error_msg = self._extract_error_message(exception)
|
|
||||||
|
|
||||||
# HTTP error (404)
|
|
||||||
if (
|
|
||||||
status_code == 404
|
|
||||||
and "Collection" in error_msg
|
|
||||||
and "doesn't exist" in error_msg
|
|
||||||
):
|
|
||||||
return True
|
|
||||||
|
|
||||||
# gRPC error (NOT_FOUND status)
|
|
||||||
if (
|
|
||||||
isinstance(exception, grpc.RpcError)
|
|
||||||
and exception.code() == grpc.StatusCode.NOT_FOUND
|
|
||||||
):
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _is_dimension_mismatch_error(self, exception):
|
|
||||||
"""
|
|
||||||
Check if the exception is due to dimension mismatch, supporting both HTTP and gRPC
|
|
||||||
"""
|
|
||||||
status_code, error_msg = self._extract_error_message(exception)
|
|
||||||
|
|
||||||
# Common patterns in both HTTP and gRPC
|
|
||||||
return (
|
|
||||||
"Vector dimension error" in error_msg
|
|
||||||
or "dimensions mismatch" in error_msg
|
|
||||||
or "invalid vector size" in error_msg
|
|
||||||
)
|
|
||||||
|
|
||||||
def _create_multi_tenant_collection_if_not_exists(
|
|
||||||
self, mt_collection_name: str, dimension: int = 384
|
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Creates a collection with multi-tenancy configuration if it doesn't exist.
|
Creates a collection with multi-tenancy configuration and payload indexes for tenant_id and metadata fields.
|
||||||
Default dimension is set to 384 which corresponds to 'sentence-transformers/all-MiniLM-L6-v2'.
|
|
||||||
When creating collections dynamically (insert/upsert), the actual vector dimensions will be used.
|
|
||||||
"""
|
"""
|
||||||
try:
|
self.client.create_collection(
|
||||||
# Try to create the collection directly - will fail if it already exists
|
collection_name=mt_collection_name,
|
||||||
self.client.create_collection(
|
vectors_config=models.VectorParams(
|
||||||
collection_name=mt_collection_name,
|
size=dimension,
|
||||||
vectors_config=models.VectorParams(
|
distance=models.Distance.COSINE,
|
||||||
size=dimension,
|
on_disk=self.QDRANT_ON_DISK,
|
||||||
distance=models.Distance.COSINE,
|
),
|
||||||
on_disk=self.QDRANT_ON_DISK,
|
)
|
||||||
),
|
log.info(
|
||||||
hnsw_config=models.HnswConfigDiff(
|
f"Multi-tenant collection {mt_collection_name} created with dimension {dimension}!"
|
||||||
payload_m=16, # Enable per-tenant indexing
|
)
|
||||||
m=0,
|
|
||||||
on_disk=self.QDRANT_ON_DISK,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create tenant ID payload index
|
self.client.create_payload_index(
|
||||||
|
collection_name=mt_collection_name,
|
||||||
|
field_name=TENANT_ID_FIELD,
|
||||||
|
field_schema=models.KeywordIndexParams(
|
||||||
|
type=models.KeywordIndexType.KEYWORD,
|
||||||
|
is_tenant=True,
|
||||||
|
on_disk=self.QDRANT_ON_DISK,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
for field in ("metadata.hash", "metadata.file_id"):
|
||||||
self.client.create_payload_index(
|
self.client.create_payload_index(
|
||||||
collection_name=mt_collection_name,
|
collection_name=mt_collection_name,
|
||||||
field_name="tenant_id",
|
field_name=field,
|
||||||
field_schema=models.KeywordIndexParams(
|
field_schema=models.KeywordIndexParams(
|
||||||
type=models.KeywordIndexType.KEYWORD,
|
type=models.KeywordIndexType.KEYWORD,
|
||||||
is_tenant=True,
|
|
||||||
on_disk=self.QDRANT_ON_DISK,
|
on_disk=self.QDRANT_ON_DISK,
|
||||||
),
|
),
|
||||||
wait=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
log.info(
|
def _create_points(
|
||||||
f"Multi-tenant collection {mt_collection_name} created with dimension {dimension}!"
|
self, items: List[VectorItem], tenant_id: str
|
||||||
)
|
) -> List[PointStruct]:
|
||||||
except (UnexpectedResponse, grpc.RpcError) as e:
|
|
||||||
# Check for the specific error indicating collection already exists
|
|
||||||
status_code, error_msg = self._extract_error_message(e)
|
|
||||||
|
|
||||||
# HTTP status code 409 or gRPC ALREADY_EXISTS
|
|
||||||
if (isinstance(e, UnexpectedResponse) and status_code == 409) or (
|
|
||||||
isinstance(e, grpc.RpcError)
|
|
||||||
and e.code() == grpc.StatusCode.ALREADY_EXISTS
|
|
||||||
):
|
|
||||||
if "already exists" in error_msg:
|
|
||||||
log.debug(f"Collection {mt_collection_name} already exists")
|
|
||||||
return
|
|
||||||
# If it's not an already exists error, re-raise
|
|
||||||
raise e
|
|
||||||
except Exception as e:
|
|
||||||
raise e
|
|
||||||
|
|
||||||
def _create_points(self, items: list[VectorItem], tenant_id: str):
|
|
||||||
"""
|
"""
|
||||||
Create point structs from vector items with tenant ID.
|
Create point structs from vector items with tenant ID.
|
||||||
"""
|
"""
|
||||||
|
|
@ -260,56 +171,42 @@ class QdrantClient(VectorDBBase):
|
||||||
payload={
|
payload={
|
||||||
"text": item["text"],
|
"text": item["text"],
|
||||||
"metadata": item["metadata"],
|
"metadata": item["metadata"],
|
||||||
"tenant_id": tenant_id,
|
TENANT_ID_FIELD: tenant_id,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
for item in items
|
for item in items
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def _ensure_collection(
|
||||||
|
self, mt_collection_name: str, dimension: int = DEFAULT_DIMENSION
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Ensure the collection exists and payload indexes are created for tenant_id and metadata fields.
|
||||||
|
"""
|
||||||
|
if not self.client.collection_exists(collection_name=mt_collection_name):
|
||||||
|
self._create_multi_tenant_collection(mt_collection_name, dimension)
|
||||||
|
|
||||||
def has_collection(self, collection_name: str) -> bool:
|
def has_collection(self, collection_name: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if a logical collection exists by checking for any points with the tenant ID.
|
Check if a logical collection exists by checking for any points with the tenant ID.
|
||||||
"""
|
"""
|
||||||
if not self.client:
|
if not self.client:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Map to multi-tenant collection and tenant ID
|
|
||||||
mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name)
|
mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name)
|
||||||
|
if not self.client.collection_exists(collection_name=mt_collection):
|
||||||
# Create tenant filter
|
|
||||||
tenant_filter = models.FieldCondition(
|
|
||||||
key="tenant_id", match=models.MatchValue(value=tenant_id)
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Try directly querying - most of the time collection should exist
|
|
||||||
response = self.client.query_points(
|
|
||||||
collection_name=mt_collection,
|
|
||||||
query_filter=models.Filter(must=[tenant_filter]),
|
|
||||||
limit=1,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Collection exists with this tenant ID if there are points
|
|
||||||
return len(response.points) > 0
|
|
||||||
except (UnexpectedResponse, grpc.RpcError) as e:
|
|
||||||
if self._is_collection_not_found_error(e):
|
|
||||||
log.debug(f"Collection {mt_collection} doesn't exist")
|
|
||||||
return False
|
|
||||||
else:
|
|
||||||
# For other API errors, log and return False
|
|
||||||
_, error_msg = self._extract_error_message(e)
|
|
||||||
log.warning(f"Unexpected Qdrant error: {error_msg}")
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
|
||||||
# For any other errors, log and return False
|
|
||||||
log.debug(f"Error checking collection {mt_collection}: {e}")
|
|
||||||
return False
|
return False
|
||||||
|
tenant_filter = _tenant_filter(tenant_id)
|
||||||
|
count_result = self.client.count(
|
||||||
|
collection_name=mt_collection,
|
||||||
|
count_filter=models.Filter(must=[tenant_filter]),
|
||||||
|
)
|
||||||
|
return count_result.count > 0
|
||||||
|
|
||||||
def delete(
|
def delete(
|
||||||
self,
|
self,
|
||||||
collection_name: str,
|
collection_name: str,
|
||||||
ids: Optional[list[str]] = None,
|
ids: Optional[List[str]] = None,
|
||||||
filter: Optional[dict] = None,
|
filter: Optional[Dict[str, Any]] = None,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Delete vectors by ID or filter from a collection with tenant isolation.
|
Delete vectors by ID or filter from a collection with tenant isolation.
|
||||||
|
|
@ -317,189 +214,76 @@ class QdrantClient(VectorDBBase):
|
||||||
if not self.client:
|
if not self.client:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Map to multi-tenant collection and tenant ID
|
|
||||||
mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name)
|
mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name)
|
||||||
|
if not self.client.collection_exists(collection_name=mt_collection):
|
||||||
|
log.debug(f"Collection {mt_collection} doesn't exist, nothing to delete")
|
||||||
|
return None
|
||||||
|
|
||||||
# Create tenant filter
|
must_conditions = [_tenant_filter(tenant_id)]
|
||||||
tenant_filter = models.FieldCondition(
|
should_conditions = []
|
||||||
key="tenant_id", match=models.MatchValue(value=tenant_id)
|
if ids:
|
||||||
|
should_conditions = [_metadata_filter("id", id_value) for id_value in ids]
|
||||||
|
elif filter:
|
||||||
|
must_conditions += [_metadata_filter(k, v) for k, v in filter.items()]
|
||||||
|
|
||||||
|
return self.client.delete(
|
||||||
|
collection_name=mt_collection,
|
||||||
|
points_selector=models.FilterSelector(
|
||||||
|
filter=models.Filter(must=must_conditions, should=should_conditions)
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
must_conditions = [tenant_filter]
|
|
||||||
should_conditions = []
|
|
||||||
|
|
||||||
if ids:
|
|
||||||
for id_value in ids:
|
|
||||||
should_conditions.append(
|
|
||||||
models.FieldCondition(
|
|
||||||
key="metadata.id",
|
|
||||||
match=models.MatchValue(value=id_value),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
elif filter:
|
|
||||||
for key, value in filter.items():
|
|
||||||
must_conditions.append(
|
|
||||||
models.FieldCondition(
|
|
||||||
key=f"metadata.{key}",
|
|
||||||
match=models.MatchValue(value=value),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Try to delete directly - most of the time collection should exist
|
|
||||||
update_result = self.client.delete(
|
|
||||||
collection_name=mt_collection,
|
|
||||||
points_selector=models.FilterSelector(
|
|
||||||
filter=models.Filter(must=must_conditions, should=should_conditions)
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
return update_result
|
|
||||||
except (UnexpectedResponse, grpc.RpcError) as e:
|
|
||||||
if self._is_collection_not_found_error(e):
|
|
||||||
log.debug(
|
|
||||||
f"Collection {mt_collection} doesn't exist, nothing to delete"
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
else:
|
|
||||||
# For other API errors, log and re-raise
|
|
||||||
_, error_msg = self._extract_error_message(e)
|
|
||||||
log.warning(f"Unexpected Qdrant error: {error_msg}")
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
# For non-Qdrant exceptions, re-raise
|
|
||||||
raise
|
|
||||||
|
|
||||||
def search(
|
def search(
|
||||||
self, collection_name: str, vectors: list[list[float | int]], limit: int
|
self, collection_name: str, vectors: List[List[float | int]], limit: int
|
||||||
) -> Optional[SearchResult]:
|
) -> Optional[SearchResult]:
|
||||||
"""
|
"""
|
||||||
Search for the nearest neighbor items based on the vectors with tenant isolation.
|
Search for the nearest neighbor items based on the vectors with tenant isolation.
|
||||||
"""
|
"""
|
||||||
if not self.client:
|
if not self.client or not vectors:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Map to multi-tenant collection and tenant ID
|
|
||||||
mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name)
|
mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name)
|
||||||
|
if not self.client.collection_exists(collection_name=mt_collection):
|
||||||
# Get the vector dimension from the query vector
|
log.debug(f"Collection {mt_collection} doesn't exist, search returns None")
|
||||||
dimension = len(vectors[0]) if vectors and len(vectors) > 0 else None
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Try the search operation directly - most of the time collection should exist
|
|
||||||
|
|
||||||
# Create tenant filter
|
|
||||||
tenant_filter = models.FieldCondition(
|
|
||||||
key="tenant_id", match=models.MatchValue(value=tenant_id)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Ensure vector dimensions match the collection
|
|
||||||
collection_dim = self.client.get_collection(
|
|
||||||
mt_collection
|
|
||||||
).config.params.vectors.size
|
|
||||||
|
|
||||||
if collection_dim != dimension:
|
|
||||||
if collection_dim < dimension:
|
|
||||||
vectors = [vector[:collection_dim] for vector in vectors]
|
|
||||||
else:
|
|
||||||
vectors = [
|
|
||||||
vector + [0] * (collection_dim - dimension)
|
|
||||||
for vector in vectors
|
|
||||||
]
|
|
||||||
|
|
||||||
# Search with tenant filter
|
|
||||||
prefetch_query = models.Prefetch(
|
|
||||||
filter=models.Filter(must=[tenant_filter]),
|
|
||||||
limit=NO_LIMIT,
|
|
||||||
)
|
|
||||||
query_response = self.client.query_points(
|
|
||||||
collection_name=mt_collection,
|
|
||||||
query=vectors[0],
|
|
||||||
prefetch=prefetch_query,
|
|
||||||
limit=limit,
|
|
||||||
)
|
|
||||||
|
|
||||||
get_result = self._result_to_get_result(query_response.points)
|
|
||||||
return SearchResult(
|
|
||||||
ids=get_result.ids,
|
|
||||||
documents=get_result.documents,
|
|
||||||
metadatas=get_result.metadatas,
|
|
||||||
# qdrant distance is [-1, 1], normalize to [0, 1]
|
|
||||||
distances=[
|
|
||||||
[(point.score + 1.0) / 2.0 for point in query_response.points]
|
|
||||||
],
|
|
||||||
)
|
|
||||||
except (UnexpectedResponse, grpc.RpcError) as e:
|
|
||||||
if self._is_collection_not_found_error(e):
|
|
||||||
log.debug(
|
|
||||||
f"Collection {mt_collection} doesn't exist, search returns None"
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
else:
|
|
||||||
# For other API errors, log and re-raise
|
|
||||||
_, error_msg = self._extract_error_message(e)
|
|
||||||
log.warning(f"Unexpected Qdrant error during search: {error_msg}")
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
# For non-Qdrant exceptions, log and return None
|
|
||||||
log.exception(f"Error searching collection '{collection_name}': {e}")
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def query(self, collection_name: str, filter: dict, limit: Optional[int] = None):
|
tenant_filter = _tenant_filter(tenant_id)
|
||||||
|
query_response = self.client.query_points(
|
||||||
|
collection_name=mt_collection,
|
||||||
|
query=vectors[0],
|
||||||
|
limit=limit,
|
||||||
|
query_filter=models.Filter(must=[tenant_filter]),
|
||||||
|
)
|
||||||
|
get_result = self._result_to_get_result(query_response.points)
|
||||||
|
return SearchResult(
|
||||||
|
ids=get_result.ids,
|
||||||
|
documents=get_result.documents,
|
||||||
|
metadatas=get_result.metadatas,
|
||||||
|
distances=[[(point.score + 1.0) / 2.0 for point in query_response.points]],
|
||||||
|
)
|
||||||
|
|
||||||
|
def query(
|
||||||
|
self, collection_name: str, filter: Dict[str, Any], limit: Optional[int] = None
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Query points with filters and tenant isolation.
|
Query points with filters and tenant isolation.
|
||||||
"""
|
"""
|
||||||
if not self.client:
|
if not self.client:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Map to multi-tenant collection and tenant ID
|
|
||||||
mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name)
|
mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name)
|
||||||
|
if not self.client.collection_exists(collection_name=mt_collection):
|
||||||
# Set default limit if not provided
|
log.debug(f"Collection {mt_collection} doesn't exist, query returns None")
|
||||||
|
return None
|
||||||
if limit is None:
|
if limit is None:
|
||||||
limit = NO_LIMIT
|
limit = NO_LIMIT
|
||||||
|
tenant_filter = _tenant_filter(tenant_id)
|
||||||
# Create tenant filter
|
field_conditions = [_metadata_filter(k, v) for k, v in filter.items()]
|
||||||
tenant_filter = models.FieldCondition(
|
|
||||||
key="tenant_id", match=models.MatchValue(value=tenant_id)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create metadata filters
|
|
||||||
field_conditions = []
|
|
||||||
for key, value in filter.items():
|
|
||||||
field_conditions.append(
|
|
||||||
models.FieldCondition(
|
|
||||||
key=f"metadata.{key}", match=models.MatchValue(value=value)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Combine tenant filter with metadata filters
|
|
||||||
combined_filter = models.Filter(must=[tenant_filter, *field_conditions])
|
combined_filter = models.Filter(must=[tenant_filter, *field_conditions])
|
||||||
|
points = self.client.query_points(
|
||||||
try:
|
collection_name=mt_collection,
|
||||||
# Try the query directly - most of the time collection should exist
|
query_filter=combined_filter,
|
||||||
points = self.client.query_points(
|
limit=limit,
|
||||||
collection_name=mt_collection,
|
)
|
||||||
query_filter=combined_filter,
|
return self._result_to_get_result(points.points)
|
||||||
limit=limit,
|
|
||||||
)
|
|
||||||
|
|
||||||
return self._result_to_get_result(points.points)
|
|
||||||
except (UnexpectedResponse, grpc.RpcError) as e:
|
|
||||||
if self._is_collection_not_found_error(e):
|
|
||||||
log.debug(
|
|
||||||
f"Collection {mt_collection} doesn't exist, query returns None"
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
else:
|
|
||||||
# For other API errors, log and re-raise
|
|
||||||
_, error_msg = self._extract_error_message(e)
|
|
||||||
log.warning(f"Unexpected Qdrant error during query: {error_msg}")
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
# For non-Qdrant exceptions, log and re-raise
|
|
||||||
log.exception(f"Error querying collection '{collection_name}': {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get(self, collection_name: str) -> Optional[GetResult]:
|
def get(self, collection_name: str) -> Optional[GetResult]:
|
||||||
"""
|
"""
|
||||||
|
|
@ -507,169 +291,36 @@ class QdrantClient(VectorDBBase):
|
||||||
"""
|
"""
|
||||||
if not self.client:
|
if not self.client:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Map to multi-tenant collection and tenant ID
|
|
||||||
mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name)
|
mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name)
|
||||||
|
if not self.client.collection_exists(collection_name=mt_collection):
|
||||||
# Create tenant filter
|
log.debug(f"Collection {mt_collection} doesn't exist, get returns None")
|
||||||
tenant_filter = models.FieldCondition(
|
|
||||||
key="tenant_id", match=models.MatchValue(value=tenant_id)
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Try to get points directly - most of the time collection should exist
|
|
||||||
points = self.client.query_points(
|
|
||||||
collection_name=mt_collection,
|
|
||||||
query_filter=models.Filter(must=[tenant_filter]),
|
|
||||||
limit=NO_LIMIT,
|
|
||||||
)
|
|
||||||
|
|
||||||
return self._result_to_get_result(points.points)
|
|
||||||
except (UnexpectedResponse, grpc.RpcError) as e:
|
|
||||||
if self._is_collection_not_found_error(e):
|
|
||||||
log.debug(f"Collection {mt_collection} doesn't exist, get returns None")
|
|
||||||
return None
|
|
||||||
else:
|
|
||||||
# For other API errors, log and re-raise
|
|
||||||
_, error_msg = self._extract_error_message(e)
|
|
||||||
log.warning(f"Unexpected Qdrant error during get: {error_msg}")
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
# For non-Qdrant exceptions, log and return None
|
|
||||||
log.exception(f"Error getting collection '{collection_name}': {e}")
|
|
||||||
return None
|
return None
|
||||||
|
tenant_filter = _tenant_filter(tenant_id)
|
||||||
def _handle_operation_with_error_retry(
|
points = self.client.query_points(
|
||||||
self, operation_name, mt_collection, points, dimension
|
collection_name=mt_collection,
|
||||||
):
|
query_filter=models.Filter(must=[tenant_filter]),
|
||||||
"""
|
limit=NO_LIMIT,
|
||||||
Private helper to handle common error cases for insert and upsert operations.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
operation_name: 'insert' or 'upsert'
|
|
||||||
mt_collection: The multi-tenant collection name
|
|
||||||
points: The vector points to insert/upsert
|
|
||||||
dimension: The dimension of the vectors
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The operation result (for upsert) or None (for insert)
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
if operation_name == "insert":
|
|
||||||
self.client.upload_points(mt_collection, points)
|
|
||||||
return None
|
|
||||||
else: # upsert
|
|
||||||
return self.client.upsert(mt_collection, points)
|
|
||||||
except (UnexpectedResponse, grpc.RpcError) as e:
|
|
||||||
# Handle collection not found
|
|
||||||
if self._is_collection_not_found_error(e):
|
|
||||||
log.info(
|
|
||||||
f"Collection {mt_collection} doesn't exist. Creating it with dimension {dimension}."
|
|
||||||
)
|
|
||||||
# Create collection with correct dimensions from our vectors
|
|
||||||
self._create_multi_tenant_collection_if_not_exists(
|
|
||||||
mt_collection_name=mt_collection, dimension=dimension
|
|
||||||
)
|
|
||||||
# Try operation again - no need for dimension adjustment since we just created with correct dimensions
|
|
||||||
if operation_name == "insert":
|
|
||||||
self.client.upload_points(mt_collection, points)
|
|
||||||
return None
|
|
||||||
else: # upsert
|
|
||||||
return self.client.upsert(mt_collection, points)
|
|
||||||
|
|
||||||
# Handle dimension mismatch
|
|
||||||
elif self._is_dimension_mismatch_error(e):
|
|
||||||
# For dimension errors, the collection must exist, so get its configuration
|
|
||||||
mt_collection_info = self.client.get_collection(mt_collection)
|
|
||||||
existing_size = mt_collection_info.config.params.vectors.size
|
|
||||||
|
|
||||||
log.info(
|
|
||||||
f"Dimension mismatch: Collection {mt_collection} expects {existing_size}, got {dimension}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if existing_size < dimension:
|
|
||||||
# Truncate vectors to fit
|
|
||||||
log.info(
|
|
||||||
f"Truncating vectors from {dimension} to {existing_size} dimensions"
|
|
||||||
)
|
|
||||||
points = [
|
|
||||||
PointStruct(
|
|
||||||
id=point.id,
|
|
||||||
vector=point.vector[:existing_size],
|
|
||||||
payload=point.payload,
|
|
||||||
)
|
|
||||||
for point in points
|
|
||||||
]
|
|
||||||
elif existing_size > dimension:
|
|
||||||
# Pad vectors with zeros
|
|
||||||
log.info(
|
|
||||||
f"Padding vectors from {dimension} to {existing_size} dimensions with zeros"
|
|
||||||
)
|
|
||||||
points = [
|
|
||||||
PointStruct(
|
|
||||||
id=point.id,
|
|
||||||
vector=point.vector
|
|
||||||
+ [0] * (existing_size - len(point.vector)),
|
|
||||||
payload=point.payload,
|
|
||||||
)
|
|
||||||
for point in points
|
|
||||||
]
|
|
||||||
# Try operation again with adjusted dimensions
|
|
||||||
if operation_name == "insert":
|
|
||||||
self.client.upload_points(mt_collection, points)
|
|
||||||
return None
|
|
||||||
else: # upsert
|
|
||||||
return self.client.upsert(mt_collection, points)
|
|
||||||
else:
|
|
||||||
# Not a known error we can handle, log and re-raise
|
|
||||||
_, error_msg = self._extract_error_message(e)
|
|
||||||
log.warning(f"Unhandled Qdrant error: {error_msg}")
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
# For non-Qdrant exceptions, re-raise
|
|
||||||
raise
|
|
||||||
|
|
||||||
def insert(self, collection_name: str, items: list[VectorItem]):
|
|
||||||
"""
|
|
||||||
Insert items with tenant ID.
|
|
||||||
"""
|
|
||||||
if not self.client or not items:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Map to multi-tenant collection and tenant ID
|
|
||||||
mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name)
|
|
||||||
|
|
||||||
# Get dimensions from the actual vectors
|
|
||||||
dimension = len(items[0]["vector"]) if items else None
|
|
||||||
|
|
||||||
# Create points with tenant ID
|
|
||||||
points = self._create_points(items, tenant_id)
|
|
||||||
|
|
||||||
# Handle the operation with error retry
|
|
||||||
return self._handle_operation_with_error_retry(
|
|
||||||
"insert", mt_collection, points, dimension
|
|
||||||
)
|
)
|
||||||
|
return self._result_to_get_result(points.points)
|
||||||
|
|
||||||
def upsert(self, collection_name: str, items: list[VectorItem]):
|
def upsert(self, collection_name: str, items: List[VectorItem]):
|
||||||
"""
|
"""
|
||||||
Upsert items with tenant ID.
|
Upsert items with tenant ID.
|
||||||
"""
|
"""
|
||||||
if not self.client or not items:
|
if not self.client or not items:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Map to multi-tenant collection and tenant ID
|
|
||||||
mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name)
|
mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name)
|
||||||
|
dimension = len(items[0]["vector"])
|
||||||
# Get dimensions from the actual vectors
|
self._ensure_collection(mt_collection, dimension)
|
||||||
dimension = len(items[0]["vector"]) if items else None
|
|
||||||
|
|
||||||
# Create points with tenant ID
|
|
||||||
points = self._create_points(items, tenant_id)
|
points = self._create_points(items, tenant_id)
|
||||||
|
self.client.upload_points(mt_collection, points)
|
||||||
|
return None
|
||||||
|
|
||||||
# Handle the operation with error retry
|
def insert(self, collection_name: str, items: List[VectorItem]):
|
||||||
return self._handle_operation_with_error_retry(
|
"""
|
||||||
"upsert", mt_collection, points, dimension
|
Insert items with tenant ID.
|
||||||
)
|
"""
|
||||||
|
return self.upsert(collection_name, items)
|
||||||
|
|
||||||
def reset(self):
|
def reset(self):
|
||||||
"""
|
"""
|
||||||
|
|
@ -677,11 +328,9 @@ class QdrantClient(VectorDBBase):
|
||||||
"""
|
"""
|
||||||
if not self.client:
|
if not self.client:
|
||||||
return None
|
return None
|
||||||
|
for collection in self.client.get_collections().collections:
|
||||||
collection_names = self.client.get_collections().collections
|
if collection.name.startswith(self.collection_prefix):
|
||||||
for collection_name in collection_names:
|
self.client.delete_collection(collection_name=collection.name)
|
||||||
if collection_name.name.startswith(self.collection_prefix):
|
|
||||||
self.client.delete_collection(collection_name=collection_name.name)
|
|
||||||
|
|
||||||
def delete_collection(self, collection_name: str):
|
def delete_collection(self, collection_name: str):
|
||||||
"""
|
"""
|
||||||
|
|
@ -689,24 +338,13 @@ class QdrantClient(VectorDBBase):
|
||||||
"""
|
"""
|
||||||
if not self.client:
|
if not self.client:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Map to multi-tenant collection and tenant ID
|
|
||||||
mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name)
|
mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name)
|
||||||
|
if not self.client.collection_exists(collection_name=mt_collection):
|
||||||
tenant_filter = models.FieldCondition(
|
log.debug(f"Collection {mt_collection} doesn't exist, nothing to delete")
|
||||||
key="tenant_id", match=models.MatchValue(value=tenant_id)
|
return None
|
||||||
)
|
self.client.delete(
|
||||||
|
|
||||||
field_conditions = [tenant_filter]
|
|
||||||
|
|
||||||
update_result = self.client.delete(
|
|
||||||
collection_name=mt_collection,
|
collection_name=mt_collection,
|
||||||
points_selector=models.FilterSelector(
|
points_selector=models.FilterSelector(
|
||||||
filter=models.Filter(must=field_conditions)
|
filter=models.Filter(must=[_tenant_filter(tenant_id)])
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.client.get_collection(mt_collection).points_count == 0:
|
|
||||||
self.client.delete_collection(mt_collection)
|
|
||||||
|
|
||||||
return update_result
|
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,9 @@ def search_brave(
|
||||||
|
|
||||||
return [
|
return [
|
||||||
SearchResult(
|
SearchResult(
|
||||||
link=result["url"], title=result.get("title"), snippet=result.get("snippet")
|
link=result["url"],
|
||||||
|
title=result.get("title"),
|
||||||
|
snippet=result.get("description"),
|
||||||
)
|
)
|
||||||
for result in results[:count]
|
for result in results[:count]
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@ import logging
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
|
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
|
||||||
from duckduckgo_search import DDGS
|
from ddgs import DDGS
|
||||||
from duckduckgo_search.exceptions import RatelimitException
|
from ddgs.exceptions import RatelimitException
|
||||||
from open_webui.env import SRC_LOG_LEVELS
|
from open_webui.env import SRC_LOG_LEVELS
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import aiohttp
|
||||||
import aiofiles
|
import aiofiles
|
||||||
import requests
|
import requests
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
from fastapi import (
|
from fastapi import (
|
||||||
Depends,
|
Depends,
|
||||||
|
|
@ -327,6 +328,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
|
||||||
log.exception(e)
|
log.exception(e)
|
||||||
raise HTTPException(status_code=400, detail="Invalid JSON payload")
|
raise HTTPException(status_code=400, detail="Invalid JSON payload")
|
||||||
|
|
||||||
|
r = None
|
||||||
if request.app.state.config.TTS_ENGINE == "openai":
|
if request.app.state.config.TTS_ENGINE == "openai":
|
||||||
payload["model"] = request.app.state.config.TTS_MODEL
|
payload["model"] = request.app.state.config.TTS_MODEL
|
||||||
|
|
||||||
|
|
@ -335,7 +337,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
|
||||||
async with aiohttp.ClientSession(
|
async with aiohttp.ClientSession(
|
||||||
timeout=timeout, trust_env=True
|
timeout=timeout, trust_env=True
|
||||||
) as session:
|
) as session:
|
||||||
async with session.post(
|
r = await session.post(
|
||||||
url=f"{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/speech",
|
url=f"{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/speech",
|
||||||
json=payload,
|
json=payload,
|
||||||
headers={
|
headers={
|
||||||
|
|
@ -343,7 +345,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
|
||||||
"Authorization": f"Bearer {request.app.state.config.TTS_OPENAI_API_KEY}",
|
"Authorization": f"Bearer {request.app.state.config.TTS_OPENAI_API_KEY}",
|
||||||
**(
|
**(
|
||||||
{
|
{
|
||||||
"X-OpenWebUI-User-Name": user.name,
|
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||||
"X-OpenWebUI-User-Id": user.id,
|
"X-OpenWebUI-User-Id": user.id,
|
||||||
"X-OpenWebUI-User-Email": user.email,
|
"X-OpenWebUI-User-Email": user.email,
|
||||||
"X-OpenWebUI-User-Role": user.role,
|
"X-OpenWebUI-User-Role": user.role,
|
||||||
|
|
@ -353,14 +355,15 @@ async def speech(request: Request, user=Depends(get_verified_user)):
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||||
) as r:
|
)
|
||||||
r.raise_for_status()
|
|
||||||
|
|
||||||
async with aiofiles.open(file_path, "wb") as f:
|
r.raise_for_status()
|
||||||
await f.write(await r.read())
|
|
||||||
|
|
||||||
async with aiofiles.open(file_body_path, "w") as f:
|
async with aiofiles.open(file_path, "wb") as f:
|
||||||
await f.write(json.dumps(payload))
|
await f.write(await r.read())
|
||||||
|
|
||||||
|
async with aiofiles.open(file_body_path, "w") as f:
|
||||||
|
await f.write(json.dumps(payload))
|
||||||
|
|
||||||
return FileResponse(file_path)
|
return FileResponse(file_path)
|
||||||
|
|
||||||
|
|
@ -368,18 +371,18 @@ async def speech(request: Request, user=Depends(get_verified_user)):
|
||||||
log.exception(e)
|
log.exception(e)
|
||||||
detail = None
|
detail = None
|
||||||
|
|
||||||
try:
|
status_code = 500
|
||||||
if r.status != 200:
|
detail = f"Open WebUI: Server Connection Error"
|
||||||
res = await r.json()
|
|
||||||
|
|
||||||
if "error" in res:
|
if r is not None:
|
||||||
detail = f"External: {res['error'].get('message', '')}"
|
status_code = r.status
|
||||||
except Exception:
|
res = await r.json()
|
||||||
detail = f"External: {e}"
|
if "error" in res:
|
||||||
|
detail = f"External: {res['error'].get('message', '')}"
|
||||||
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=getattr(r, "status", 500) if r else 500,
|
status_code=status_code,
|
||||||
detail=detail if detail else "Open WebUI: Server Connection Error",
|
detail=detail,
|
||||||
)
|
)
|
||||||
|
|
||||||
elif request.app.state.config.TTS_ENGINE == "elevenlabs":
|
elif request.app.state.config.TTS_ENGINE == "elevenlabs":
|
||||||
|
|
@ -919,14 +922,18 @@ def transcription(
|
||||||
):
|
):
|
||||||
log.info(f"file.content_type: {file.content_type}")
|
log.info(f"file.content_type: {file.content_type}")
|
||||||
|
|
||||||
supported_content_types = request.app.state.config.STT_SUPPORTED_CONTENT_TYPES or [
|
stt_supported_content_types = getattr(
|
||||||
"audio/*",
|
request.app.state.config, "STT_SUPPORTED_CONTENT_TYPES", []
|
||||||
"video/webm",
|
)
|
||||||
]
|
|
||||||
|
|
||||||
if not any(
|
if not any(
|
||||||
fnmatch(file.content_type, content_type)
|
fnmatch(file.content_type, content_type)
|
||||||
for content_type in supported_content_types
|
for content_type in (
|
||||||
|
stt_supported_content_types
|
||||||
|
if stt_supported_content_types
|
||||||
|
and any(t.strip() for t in stt_supported_content_types)
|
||||||
|
else ["audio/*", "video/webm"]
|
||||||
|
)
|
||||||
):
|
):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
|
|
||||||
|
|
@ -669,12 +669,13 @@ async def signup(request: Request, response: Response, form_data: SignupForm):
|
||||||
@router.get("/signout")
|
@router.get("/signout")
|
||||||
async def signout(request: Request, response: Response):
|
async def signout(request: Request, response: Response):
|
||||||
response.delete_cookie("token")
|
response.delete_cookie("token")
|
||||||
|
response.delete_cookie("oui-session")
|
||||||
|
|
||||||
if ENABLE_OAUTH_SIGNUP.value:
|
if ENABLE_OAUTH_SIGNUP.value:
|
||||||
oauth_id_token = request.cookies.get("oauth_id_token")
|
oauth_id_token = request.cookies.get("oauth_id_token")
|
||||||
if oauth_id_token:
|
if oauth_id_token:
|
||||||
try:
|
try:
|
||||||
async with ClientSession() as session:
|
async with ClientSession(trust_env=True) as session:
|
||||||
async with session.get(OPENID_PROVIDER_URL.value) as resp:
|
async with session.get(OPENID_PROVIDER_URL.value) as resp:
|
||||||
if resp.status == 200:
|
if resp.status == 200:
|
||||||
openid_data = await resp.json()
|
openid_data = await resp.json()
|
||||||
|
|
@ -686,7 +687,12 @@ async def signout(request: Request, response: Response):
|
||||||
status_code=200,
|
status_code=200,
|
||||||
content={
|
content={
|
||||||
"status": True,
|
"status": True,
|
||||||
"redirect_url": f"{logout_url}?id_token_hint={oauth_id_token}",
|
"redirect_url": f"{logout_url}?id_token_hint={oauth_id_token}"
|
||||||
|
+ (
|
||||||
|
f"&post_logout_redirect_uri={WEBUI_AUTH_SIGNOUT_REDIRECT_URL}"
|
||||||
|
if WEBUI_AUTH_SIGNOUT_REDIRECT_URL
|
||||||
|
else ""
|
||||||
|
),
|
||||||
},
|
},
|
||||||
headers=response.headers,
|
headers=response.headers,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -40,10 +40,14 @@ router = APIRouter()
|
||||||
|
|
||||||
@router.get("/", response_model=list[ChannelModel])
|
@router.get("/", response_model=list[ChannelModel])
|
||||||
async def get_channels(user=Depends(get_verified_user)):
|
async def get_channels(user=Depends(get_verified_user)):
|
||||||
|
return Channels.get_channels_by_user_id(user.id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/list", response_model=list[ChannelModel])
|
||||||
|
async def get_all_channels(user=Depends(get_verified_user)):
|
||||||
if user.role == "admin":
|
if user.role == "admin":
|
||||||
return Channels.get_channels()
|
return Channels.get_channels()
|
||||||
else:
|
return Channels.get_channels_by_user_id(user.id)
|
||||||
return Channels.get_channels_by_user_id(user.id)
|
|
||||||
|
|
||||||
|
|
||||||
############################
|
############################
|
||||||
|
|
|
||||||
|
|
@ -684,8 +684,10 @@ async def archive_chat_by_id(id: str, user=Depends(get_verified_user)):
|
||||||
|
|
||||||
@router.post("/{id}/share", response_model=Optional[ChatResponse])
|
@router.post("/{id}/share", response_model=Optional[ChatResponse])
|
||||||
async def share_chat_by_id(request: Request, id: str, user=Depends(get_verified_user)):
|
async def share_chat_by_id(request: Request, id: str, user=Depends(get_verified_user)):
|
||||||
if not has_permission(
|
if (user.role != "admin") and (
|
||||||
user.id, "chat.share", request.app.state.config.USER_PERMISSIONS
|
not has_permission(
|
||||||
|
user.id, "chat.share", request.app.state.config.USER_PERMISSIONS
|
||||||
|
)
|
||||||
):
|
):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
|
|
||||||
|
|
@ -39,32 +39,39 @@ async def export_config(user=Depends(get_admin_user)):
|
||||||
|
|
||||||
|
|
||||||
############################
|
############################
|
||||||
# Direct Connections Config
|
# Connections Config
|
||||||
############################
|
############################
|
||||||
|
|
||||||
|
|
||||||
class DirectConnectionsConfigForm(BaseModel):
|
class ConnectionsConfigForm(BaseModel):
|
||||||
ENABLE_DIRECT_CONNECTIONS: bool
|
ENABLE_DIRECT_CONNECTIONS: bool
|
||||||
|
ENABLE_BASE_MODELS_CACHE: bool
|
||||||
|
|
||||||
|
|
||||||
@router.get("/direct_connections", response_model=DirectConnectionsConfigForm)
|
@router.get("/connections", response_model=ConnectionsConfigForm)
|
||||||
async def get_direct_connections_config(request: Request, user=Depends(get_admin_user)):
|
async def get_connections_config(request: Request, user=Depends(get_admin_user)):
|
||||||
return {
|
return {
|
||||||
"ENABLE_DIRECT_CONNECTIONS": request.app.state.config.ENABLE_DIRECT_CONNECTIONS,
|
"ENABLE_DIRECT_CONNECTIONS": request.app.state.config.ENABLE_DIRECT_CONNECTIONS,
|
||||||
|
"ENABLE_BASE_MODELS_CACHE": request.app.state.config.ENABLE_BASE_MODELS_CACHE,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/direct_connections", response_model=DirectConnectionsConfigForm)
|
@router.post("/connections", response_model=ConnectionsConfigForm)
|
||||||
async def set_direct_connections_config(
|
async def set_connections_config(
|
||||||
request: Request,
|
request: Request,
|
||||||
form_data: DirectConnectionsConfigForm,
|
form_data: ConnectionsConfigForm,
|
||||||
user=Depends(get_admin_user),
|
user=Depends(get_admin_user),
|
||||||
):
|
):
|
||||||
request.app.state.config.ENABLE_DIRECT_CONNECTIONS = (
|
request.app.state.config.ENABLE_DIRECT_CONNECTIONS = (
|
||||||
form_data.ENABLE_DIRECT_CONNECTIONS
|
form_data.ENABLE_DIRECT_CONNECTIONS
|
||||||
)
|
)
|
||||||
|
request.app.state.config.ENABLE_BASE_MODELS_CACHE = (
|
||||||
|
form_data.ENABLE_BASE_MODELS_CACHE
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"ENABLE_DIRECT_CONNECTIONS": request.app.state.config.ENABLE_DIRECT_CONNECTIONS,
|
"ENABLE_DIRECT_CONNECTIONS": request.app.state.config.ENABLE_DIRECT_CONNECTIONS,
|
||||||
|
"ENABLE_BASE_MODELS_CACHE": request.app.state.config.ENABLE_BASE_MODELS_CACHE,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -155,17 +155,18 @@ def upload_file(
|
||||||
if process:
|
if process:
|
||||||
try:
|
try:
|
||||||
if file.content_type:
|
if file.content_type:
|
||||||
stt_supported_content_types = (
|
stt_supported_content_types = getattr(
|
||||||
request.app.state.config.STT_SUPPORTED_CONTENT_TYPES
|
request.app.state.config, "STT_SUPPORTED_CONTENT_TYPES", []
|
||||||
or [
|
|
||||||
"audio/*",
|
|
||||||
"video/webm",
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if any(
|
if any(
|
||||||
fnmatch(file.content_type, content_type)
|
fnmatch(file.content_type, content_type)
|
||||||
for content_type in stt_supported_content_types
|
for content_type in (
|
||||||
|
stt_supported_content_types
|
||||||
|
if stt_supported_content_types
|
||||||
|
and any(t.strip() for t in stt_supported_content_types)
|
||||||
|
else ["audio/*", "video/webm"]
|
||||||
|
)
|
||||||
):
|
):
|
||||||
file_path = Storage.get_file(file_path)
|
file_path = Storage.get_file(file_path)
|
||||||
result = transcribe(request, file_path, file_metadata)
|
result = transcribe(request, file_path, file_metadata)
|
||||||
|
|
|
||||||
|
|
@ -120,16 +120,14 @@ async def update_folder_name_by_id(
|
||||||
existing_folder = Folders.get_folder_by_parent_id_and_user_id_and_name(
|
existing_folder = Folders.get_folder_by_parent_id_and_user_id_and_name(
|
||||||
folder.parent_id, user.id, form_data.name
|
folder.parent_id, user.id, form_data.name
|
||||||
)
|
)
|
||||||
if existing_folder:
|
if existing_folder and existing_folder.id != id:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail=ERROR_MESSAGES.DEFAULT("Folder already exists"),
|
detail=ERROR_MESSAGES.DEFAULT("Folder already exists"),
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
folder = Folders.update_folder_name_by_id_and_user_id(
|
folder = Folders.update_folder_by_id_and_user_id(id, user.id, form_data)
|
||||||
id, user.id, form_data.name
|
|
||||||
)
|
|
||||||
|
|
||||||
return folder
|
return folder
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,7 @@ async def load_function_from_url(
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||||
async with session.get(
|
async with session.get(
|
||||||
url, headers={"Content-Type": "application/json"}
|
url, headers={"Content-Type": "application/json"}
|
||||||
) as resp:
|
) as resp:
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from urllib.parse import quote
|
||||||
import requests
|
import requests
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile
|
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile
|
||||||
from open_webui.config import CACHE_DIR
|
from open_webui.config import CACHE_DIR
|
||||||
|
|
@ -302,8 +303,16 @@ async def update_image_config(
|
||||||
):
|
):
|
||||||
set_image_model(request, form_data.MODEL)
|
set_image_model(request, form_data.MODEL)
|
||||||
|
|
||||||
|
if form_data.IMAGE_SIZE == "auto" and form_data.MODEL != "gpt-image-1":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=ERROR_MESSAGES.INCORRECT_FORMAT(
|
||||||
|
" (auto is only allowed with gpt-image-1)."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
pattern = r"^\d+x\d+$"
|
pattern = r"^\d+x\d+$"
|
||||||
if re.match(pattern, form_data.IMAGE_SIZE):
|
if form_data.IMAGE_SIZE == "auto" or re.match(pattern, form_data.IMAGE_SIZE):
|
||||||
request.app.state.config.IMAGE_SIZE = form_data.IMAGE_SIZE
|
request.app.state.config.IMAGE_SIZE = form_data.IMAGE_SIZE
|
||||||
else:
|
else:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -471,7 +480,14 @@ async def image_generations(
|
||||||
form_data: GenerateImageForm,
|
form_data: GenerateImageForm,
|
||||||
user=Depends(get_verified_user),
|
user=Depends(get_verified_user),
|
||||||
):
|
):
|
||||||
width, height = tuple(map(int, request.app.state.config.IMAGE_SIZE.split("x")))
|
# if IMAGE_SIZE = 'auto', default WidthxHeight to the 512x512 default
|
||||||
|
# This is only relevant when the user has set IMAGE_SIZE to 'auto' with an
|
||||||
|
# image model other than gpt-image-1, which is warned about on settings save
|
||||||
|
width, height = (
|
||||||
|
tuple(map(int, request.app.state.config.IMAGE_SIZE.split("x")))
|
||||||
|
if "x" in request.app.state.config.IMAGE_SIZE
|
||||||
|
else (512, 512)
|
||||||
|
)
|
||||||
|
|
||||||
r = None
|
r = None
|
||||||
try:
|
try:
|
||||||
|
|
@ -483,7 +499,7 @@ async def image_generations(
|
||||||
headers["Content-Type"] = "application/json"
|
headers["Content-Type"] = "application/json"
|
||||||
|
|
||||||
if ENABLE_FORWARD_USER_INFO_HEADERS:
|
if ENABLE_FORWARD_USER_INFO_HEADERS:
|
||||||
headers["X-OpenWebUI-User-Name"] = user.name
|
headers["X-OpenWebUI-User-Name"] = quote(user.name, safe=" ")
|
||||||
headers["X-OpenWebUI-User-Id"] = user.id
|
headers["X-OpenWebUI-User-Id"] = user.id
|
||||||
headers["X-OpenWebUI-User-Email"] = user.email
|
headers["X-OpenWebUI-User-Email"] = user.email
|
||||||
headers["X-OpenWebUI-User-Role"] = user.role
|
headers["X-OpenWebUI-User-Role"] = user.role
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,14 @@ async def get_notes(request: Request, user=Depends(get_verified_user)):
|
||||||
return notes
|
return notes
|
||||||
|
|
||||||
|
|
||||||
@router.get("/list", response_model=list[NoteUserResponse])
|
class NoteTitleIdResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
title: str
|
||||||
|
updated_at: int
|
||||||
|
created_at: int
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/list", response_model=list[NoteTitleIdResponse])
|
||||||
async def get_note_list(request: Request, user=Depends(get_verified_user)):
|
async def get_note_list(request: Request, user=Depends(get_verified_user)):
|
||||||
|
|
||||||
if user.role != "admin" and not has_permission(
|
if user.role != "admin" and not has_permission(
|
||||||
|
|
@ -63,13 +70,8 @@ async def get_note_list(request: Request, user=Depends(get_verified_user)):
|
||||||
)
|
)
|
||||||
|
|
||||||
notes = [
|
notes = [
|
||||||
NoteUserResponse(
|
NoteTitleIdResponse(**note.model_dump())
|
||||||
**{
|
for note in Notes.get_notes_by_user_id(user.id, "write")
|
||||||
**note.model_dump(),
|
|
||||||
"user": UserResponse(**Users.get_user_by_id(note.user_id).model_dump()),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
for note in Notes.get_notes_by_user_id(user.id, "read")
|
|
||||||
]
|
]
|
||||||
|
|
||||||
return notes
|
return notes
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ from urllib.parse import urlparse
|
||||||
import aiohttp
|
import aiohttp
|
||||||
from aiocache import cached
|
from aiocache import cached
|
||||||
import requests
|
import requests
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
from open_webui.models.chats import Chats
|
from open_webui.models.chats import Chats
|
||||||
from open_webui.models.users import UserModel
|
from open_webui.models.users import UserModel
|
||||||
|
|
@ -58,6 +59,7 @@ from open_webui.config import (
|
||||||
from open_webui.env import (
|
from open_webui.env import (
|
||||||
ENV,
|
ENV,
|
||||||
SRC_LOG_LEVELS,
|
SRC_LOG_LEVELS,
|
||||||
|
MODELS_CACHE_TTL,
|
||||||
AIOHTTP_CLIENT_SESSION_SSL,
|
AIOHTTP_CLIENT_SESSION_SSL,
|
||||||
AIOHTTP_CLIENT_TIMEOUT,
|
AIOHTTP_CLIENT_TIMEOUT,
|
||||||
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
|
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
|
||||||
|
|
@ -87,7 +89,7 @@ async def send_get_request(url, key=None, user: UserModel = None):
|
||||||
**({"Authorization": f"Bearer {key}"} if key else {}),
|
**({"Authorization": f"Bearer {key}"} if key else {}),
|
||||||
**(
|
**(
|
||||||
{
|
{
|
||||||
"X-OpenWebUI-User-Name": user.name,
|
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||||
"X-OpenWebUI-User-Id": user.id,
|
"X-OpenWebUI-User-Id": user.id,
|
||||||
"X-OpenWebUI-User-Email": user.email,
|
"X-OpenWebUI-User-Email": user.email,
|
||||||
"X-OpenWebUI-User-Role": user.role,
|
"X-OpenWebUI-User-Role": user.role,
|
||||||
|
|
@ -138,7 +140,7 @@ async def send_post_request(
|
||||||
**({"Authorization": f"Bearer {key}"} if key else {}),
|
**({"Authorization": f"Bearer {key}"} if key else {}),
|
||||||
**(
|
**(
|
||||||
{
|
{
|
||||||
"X-OpenWebUI-User-Name": user.name,
|
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||||
"X-OpenWebUI-User-Id": user.id,
|
"X-OpenWebUI-User-Id": user.id,
|
||||||
"X-OpenWebUI-User-Email": user.email,
|
"X-OpenWebUI-User-Email": user.email,
|
||||||
"X-OpenWebUI-User-Role": user.role,
|
"X-OpenWebUI-User-Role": user.role,
|
||||||
|
|
@ -242,7 +244,7 @@ async def verify_connection(
|
||||||
**({"Authorization": f"Bearer {key}"} if key else {}),
|
**({"Authorization": f"Bearer {key}"} if key else {}),
|
||||||
**(
|
**(
|
||||||
{
|
{
|
||||||
"X-OpenWebUI-User-Name": user.name,
|
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||||
"X-OpenWebUI-User-Id": user.id,
|
"X-OpenWebUI-User-Id": user.id,
|
||||||
"X-OpenWebUI-User-Email": user.email,
|
"X-OpenWebUI-User-Email": user.email,
|
||||||
"X-OpenWebUI-User-Role": user.role,
|
"X-OpenWebUI-User-Role": user.role,
|
||||||
|
|
@ -329,7 +331,7 @@ def merge_ollama_models_lists(model_lists):
|
||||||
return list(merged_models.values())
|
return list(merged_models.values())
|
||||||
|
|
||||||
|
|
||||||
@cached(ttl=1)
|
@cached(ttl=MODELS_CACHE_TTL)
|
||||||
async def get_all_models(request: Request, user: UserModel = None):
|
async def get_all_models(request: Request, user: UserModel = None):
|
||||||
log.info("get_all_models()")
|
log.info("get_all_models()")
|
||||||
if request.app.state.config.ENABLE_OLLAMA_API:
|
if request.app.state.config.ENABLE_OLLAMA_API:
|
||||||
|
|
@ -462,7 +464,7 @@ async def get_ollama_tags(
|
||||||
**({"Authorization": f"Bearer {key}"} if key else {}),
|
**({"Authorization": f"Bearer {key}"} if key else {}),
|
||||||
**(
|
**(
|
||||||
{
|
{
|
||||||
"X-OpenWebUI-User-Name": user.name,
|
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||||
"X-OpenWebUI-User-Id": user.id,
|
"X-OpenWebUI-User-Id": user.id,
|
||||||
"X-OpenWebUI-User-Email": user.email,
|
"X-OpenWebUI-User-Email": user.email,
|
||||||
"X-OpenWebUI-User-Role": user.role,
|
"X-OpenWebUI-User-Role": user.role,
|
||||||
|
|
@ -634,7 +636,10 @@ async def get_ollama_versions(request: Request, url_idx: Optional[int] = None):
|
||||||
|
|
||||||
|
|
||||||
class ModelNameForm(BaseModel):
|
class ModelNameForm(BaseModel):
|
||||||
name: str
|
model: Optional[str] = None
|
||||||
|
model_config = ConfigDict(
|
||||||
|
extra="allow",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/unload")
|
@router.post("/api/unload")
|
||||||
|
|
@ -643,10 +648,12 @@ async def unload_model(
|
||||||
form_data: ModelNameForm,
|
form_data: ModelNameForm,
|
||||||
user=Depends(get_admin_user),
|
user=Depends(get_admin_user),
|
||||||
):
|
):
|
||||||
model_name = form_data.name
|
form_data = form_data.model_dump(exclude_none=True)
|
||||||
|
model_name = form_data.get("model", form_data.get("name"))
|
||||||
|
|
||||||
if not model_name:
|
if not model_name:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400, detail="Missing 'name' of model to unload."
|
status_code=400, detail="Missing name of the model to unload."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Refresh/load models if needed, get mapping from name to URLs
|
# Refresh/load models if needed, get mapping from name to URLs
|
||||||
|
|
@ -709,11 +716,14 @@ async def pull_model(
|
||||||
url_idx: int = 0,
|
url_idx: int = 0,
|
||||||
user=Depends(get_admin_user),
|
user=Depends(get_admin_user),
|
||||||
):
|
):
|
||||||
|
form_data = form_data.model_dump(exclude_none=True)
|
||||||
|
form_data["model"] = form_data.get("model", form_data.get("name"))
|
||||||
|
|
||||||
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
|
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
|
||||||
log.info(f"url: {url}")
|
log.info(f"url: {url}")
|
||||||
|
|
||||||
# Admin should be able to pull models from any source
|
# Admin should be able to pull models from any source
|
||||||
payload = {**form_data.model_dump(exclude_none=True), "insecure": True}
|
payload = {**form_data, "insecure": True}
|
||||||
|
|
||||||
return await send_post_request(
|
return await send_post_request(
|
||||||
url=f"{url}/api/pull",
|
url=f"{url}/api/pull",
|
||||||
|
|
@ -724,7 +734,7 @@ async def pull_model(
|
||||||
|
|
||||||
|
|
||||||
class PushModelForm(BaseModel):
|
class PushModelForm(BaseModel):
|
||||||
name: str
|
model: str
|
||||||
insecure: Optional[bool] = None
|
insecure: Optional[bool] = None
|
||||||
stream: Optional[bool] = None
|
stream: Optional[bool] = None
|
||||||
|
|
||||||
|
|
@ -741,12 +751,12 @@ async def push_model(
|
||||||
await get_all_models(request, user=user)
|
await get_all_models(request, user=user)
|
||||||
models = request.app.state.OLLAMA_MODELS
|
models = request.app.state.OLLAMA_MODELS
|
||||||
|
|
||||||
if form_data.name in models:
|
if form_data.model in models:
|
||||||
url_idx = models[form_data.name]["urls"][0]
|
url_idx = models[form_data.model]["urls"][0]
|
||||||
else:
|
else:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.name),
|
detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.model),
|
||||||
)
|
)
|
||||||
|
|
||||||
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
|
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
|
||||||
|
|
@ -824,7 +834,7 @@ async def copy_model(
|
||||||
**({"Authorization": f"Bearer {key}"} if key else {}),
|
**({"Authorization": f"Bearer {key}"} if key else {}),
|
||||||
**(
|
**(
|
||||||
{
|
{
|
||||||
"X-OpenWebUI-User-Name": user.name,
|
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||||
"X-OpenWebUI-User-Id": user.id,
|
"X-OpenWebUI-User-Id": user.id,
|
||||||
"X-OpenWebUI-User-Email": user.email,
|
"X-OpenWebUI-User-Email": user.email,
|
||||||
"X-OpenWebUI-User-Role": user.role,
|
"X-OpenWebUI-User-Role": user.role,
|
||||||
|
|
@ -865,16 +875,21 @@ async def delete_model(
|
||||||
url_idx: Optional[int] = None,
|
url_idx: Optional[int] = None,
|
||||||
user=Depends(get_admin_user),
|
user=Depends(get_admin_user),
|
||||||
):
|
):
|
||||||
|
form_data = form_data.model_dump(exclude_none=True)
|
||||||
|
form_data["model"] = form_data.get("model", form_data.get("name"))
|
||||||
|
|
||||||
|
model = form_data.get("model")
|
||||||
|
|
||||||
if url_idx is None:
|
if url_idx is None:
|
||||||
await get_all_models(request, user=user)
|
await get_all_models(request, user=user)
|
||||||
models = request.app.state.OLLAMA_MODELS
|
models = request.app.state.OLLAMA_MODELS
|
||||||
|
|
||||||
if form_data.name in models:
|
if model in models:
|
||||||
url_idx = models[form_data.name]["urls"][0]
|
url_idx = models[model]["urls"][0]
|
||||||
else:
|
else:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.name),
|
detail=ERROR_MESSAGES.MODEL_NOT_FOUND(model),
|
||||||
)
|
)
|
||||||
|
|
||||||
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
|
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
|
||||||
|
|
@ -884,13 +899,13 @@ async def delete_model(
|
||||||
r = requests.request(
|
r = requests.request(
|
||||||
method="DELETE",
|
method="DELETE",
|
||||||
url=f"{url}/api/delete",
|
url=f"{url}/api/delete",
|
||||||
data=form_data.model_dump_json(exclude_none=True).encode(),
|
data=json.dumps(form_data).encode(),
|
||||||
headers={
|
headers={
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
**({"Authorization": f"Bearer {key}"} if key else {}),
|
**({"Authorization": f"Bearer {key}"} if key else {}),
|
||||||
**(
|
**(
|
||||||
{
|
{
|
||||||
"X-OpenWebUI-User-Name": user.name,
|
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||||
"X-OpenWebUI-User-Id": user.id,
|
"X-OpenWebUI-User-Id": user.id,
|
||||||
"X-OpenWebUI-User-Email": user.email,
|
"X-OpenWebUI-User-Email": user.email,
|
||||||
"X-OpenWebUI-User-Role": user.role,
|
"X-OpenWebUI-User-Role": user.role,
|
||||||
|
|
@ -926,16 +941,21 @@ async def delete_model(
|
||||||
async def show_model_info(
|
async def show_model_info(
|
||||||
request: Request, form_data: ModelNameForm, user=Depends(get_verified_user)
|
request: Request, form_data: ModelNameForm, user=Depends(get_verified_user)
|
||||||
):
|
):
|
||||||
|
form_data = form_data.model_dump(exclude_none=True)
|
||||||
|
form_data["model"] = form_data.get("model", form_data.get("name"))
|
||||||
|
|
||||||
await get_all_models(request, user=user)
|
await get_all_models(request, user=user)
|
||||||
models = request.app.state.OLLAMA_MODELS
|
models = request.app.state.OLLAMA_MODELS
|
||||||
|
|
||||||
if form_data.name not in models:
|
model = form_data.get("model")
|
||||||
|
|
||||||
|
if model not in models:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.name),
|
detail=ERROR_MESSAGES.MODEL_NOT_FOUND(model),
|
||||||
)
|
)
|
||||||
|
|
||||||
url_idx = random.choice(models[form_data.name]["urls"])
|
url_idx = random.choice(models[model]["urls"])
|
||||||
|
|
||||||
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
|
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
|
||||||
key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS)
|
key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS)
|
||||||
|
|
@ -949,7 +969,7 @@ async def show_model_info(
|
||||||
**({"Authorization": f"Bearer {key}"} if key else {}),
|
**({"Authorization": f"Bearer {key}"} if key else {}),
|
||||||
**(
|
**(
|
||||||
{
|
{
|
||||||
"X-OpenWebUI-User-Name": user.name,
|
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||||
"X-OpenWebUI-User-Id": user.id,
|
"X-OpenWebUI-User-Id": user.id,
|
||||||
"X-OpenWebUI-User-Email": user.email,
|
"X-OpenWebUI-User-Email": user.email,
|
||||||
"X-OpenWebUI-User-Role": user.role,
|
"X-OpenWebUI-User-Role": user.role,
|
||||||
|
|
@ -958,7 +978,7 @@ async def show_model_info(
|
||||||
else {}
|
else {}
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
data=form_data.model_dump_json(exclude_none=True).encode(),
|
data=json.dumps(form_data).encode(),
|
||||||
)
|
)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
|
|
||||||
|
|
@ -1036,7 +1056,7 @@ async def embed(
|
||||||
**({"Authorization": f"Bearer {key}"} if key else {}),
|
**({"Authorization": f"Bearer {key}"} if key else {}),
|
||||||
**(
|
**(
|
||||||
{
|
{
|
||||||
"X-OpenWebUI-User-Name": user.name,
|
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||||
"X-OpenWebUI-User-Id": user.id,
|
"X-OpenWebUI-User-Id": user.id,
|
||||||
"X-OpenWebUI-User-Email": user.email,
|
"X-OpenWebUI-User-Email": user.email,
|
||||||
"X-OpenWebUI-User-Role": user.role,
|
"X-OpenWebUI-User-Role": user.role,
|
||||||
|
|
@ -1123,7 +1143,7 @@ async def embeddings(
|
||||||
**({"Authorization": f"Bearer {key}"} if key else {}),
|
**({"Authorization": f"Bearer {key}"} if key else {}),
|
||||||
**(
|
**(
|
||||||
{
|
{
|
||||||
"X-OpenWebUI-User-Name": user.name,
|
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||||
"X-OpenWebUI-User-Id": user.id,
|
"X-OpenWebUI-User-Id": user.id,
|
||||||
"X-OpenWebUI-User-Email": user.email,
|
"X-OpenWebUI-User-Email": user.email,
|
||||||
"X-OpenWebUI-User-Role": user.role,
|
"X-OpenWebUI-User-Role": user.role,
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ from typing import Literal, Optional, overload
|
||||||
import aiohttp
|
import aiohttp
|
||||||
from aiocache import cached
|
from aiocache import cached
|
||||||
import requests
|
import requests
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
from fastapi import Depends, FastAPI, HTTPException, Request, APIRouter
|
from fastapi import Depends, FastAPI, HTTPException, Request, APIRouter
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
@ -21,6 +21,7 @@ from open_webui.config import (
|
||||||
CACHE_DIR,
|
CACHE_DIR,
|
||||||
)
|
)
|
||||||
from open_webui.env import (
|
from open_webui.env import (
|
||||||
|
MODELS_CACHE_TTL,
|
||||||
AIOHTTP_CLIENT_SESSION_SSL,
|
AIOHTTP_CLIENT_SESSION_SSL,
|
||||||
AIOHTTP_CLIENT_TIMEOUT,
|
AIOHTTP_CLIENT_TIMEOUT,
|
||||||
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
|
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
|
||||||
|
|
@ -66,7 +67,7 @@ async def send_get_request(url, key=None, user: UserModel = None):
|
||||||
**({"Authorization": f"Bearer {key}"} if key else {}),
|
**({"Authorization": f"Bearer {key}"} if key else {}),
|
||||||
**(
|
**(
|
||||||
{
|
{
|
||||||
"X-OpenWebUI-User-Name": user.name,
|
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||||
"X-OpenWebUI-User-Id": user.id,
|
"X-OpenWebUI-User-Id": user.id,
|
||||||
"X-OpenWebUI-User-Email": user.email,
|
"X-OpenWebUI-User-Email": user.email,
|
||||||
"X-OpenWebUI-User-Role": user.role,
|
"X-OpenWebUI-User-Role": user.role,
|
||||||
|
|
@ -225,7 +226,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
|
||||||
),
|
),
|
||||||
**(
|
**(
|
||||||
{
|
{
|
||||||
"X-OpenWebUI-User-Name": user.name,
|
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||||
"X-OpenWebUI-User-Id": user.id,
|
"X-OpenWebUI-User-Id": user.id,
|
||||||
"X-OpenWebUI-User-Email": user.email,
|
"X-OpenWebUI-User-Email": user.email,
|
||||||
"X-OpenWebUI-User-Role": user.role,
|
"X-OpenWebUI-User-Role": user.role,
|
||||||
|
|
@ -386,7 +387,7 @@ async def get_filtered_models(models, user):
|
||||||
return filtered_models
|
return filtered_models
|
||||||
|
|
||||||
|
|
||||||
@cached(ttl=1)
|
@cached(ttl=MODELS_CACHE_TTL)
|
||||||
async def get_all_models(request: Request, user: UserModel) -> dict[str, list]:
|
async def get_all_models(request: Request, user: UserModel) -> dict[str, list]:
|
||||||
log.info("get_all_models()")
|
log.info("get_all_models()")
|
||||||
|
|
||||||
|
|
@ -478,7 +479,7 @@ async def get_models(
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
**(
|
**(
|
||||||
{
|
{
|
||||||
"X-OpenWebUI-User-Name": user.name,
|
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||||
"X-OpenWebUI-User-Id": user.id,
|
"X-OpenWebUI-User-Id": user.id,
|
||||||
"X-OpenWebUI-User-Email": user.email,
|
"X-OpenWebUI-User-Email": user.email,
|
||||||
"X-OpenWebUI-User-Role": user.role,
|
"X-OpenWebUI-User-Role": user.role,
|
||||||
|
|
@ -573,7 +574,7 @@ async def verify_connection(
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
**(
|
**(
|
||||||
{
|
{
|
||||||
"X-OpenWebUI-User-Name": user.name,
|
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||||
"X-OpenWebUI-User-Id": user.id,
|
"X-OpenWebUI-User-Id": user.id,
|
||||||
"X-OpenWebUI-User-Email": user.email,
|
"X-OpenWebUI-User-Email": user.email,
|
||||||
"X-OpenWebUI-User-Role": user.role,
|
"X-OpenWebUI-User-Role": user.role,
|
||||||
|
|
@ -633,13 +634,7 @@ async def verify_connection(
|
||||||
raise HTTPException(status_code=500, detail=error_detail)
|
raise HTTPException(status_code=500, detail=error_detail)
|
||||||
|
|
||||||
|
|
||||||
def convert_to_azure_payload(
|
def get_azure_allowed_params(api_version: str) -> set[str]:
|
||||||
url,
|
|
||||||
payload: dict,
|
|
||||||
):
|
|
||||||
model = payload.get("model", "")
|
|
||||||
|
|
||||||
# Filter allowed parameters based on Azure OpenAI API
|
|
||||||
allowed_params = {
|
allowed_params = {
|
||||||
"messages",
|
"messages",
|
||||||
"temperature",
|
"temperature",
|
||||||
|
|
@ -669,6 +664,23 @@ def convert_to_azure_payload(
|
||||||
"max_completion_tokens",
|
"max_completion_tokens",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
if api_version >= "2024-09-01-preview":
|
||||||
|
allowed_params.add("stream_options")
|
||||||
|
except ValueError:
|
||||||
|
log.debug(
|
||||||
|
f"Invalid API version {api_version} for Azure OpenAI. Defaulting to allowed parameters."
|
||||||
|
)
|
||||||
|
|
||||||
|
return allowed_params
|
||||||
|
|
||||||
|
|
||||||
|
def convert_to_azure_payload(url, payload: dict, api_version: str):
|
||||||
|
model = payload.get("model", "")
|
||||||
|
|
||||||
|
# Filter allowed parameters based on Azure OpenAI API
|
||||||
|
allowed_params = get_azure_allowed_params(api_version)
|
||||||
|
|
||||||
# Special handling for o-series models
|
# Special handling for o-series models
|
||||||
if model.startswith("o") and model.endswith("-mini"):
|
if model.startswith("o") and model.endswith("-mini"):
|
||||||
# Convert max_tokens to max_completion_tokens for o-series models
|
# Convert max_tokens to max_completion_tokens for o-series models
|
||||||
|
|
@ -806,7 +818,7 @@ async def generate_chat_completion(
|
||||||
),
|
),
|
||||||
**(
|
**(
|
||||||
{
|
{
|
||||||
"X-OpenWebUI-User-Name": user.name,
|
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||||
"X-OpenWebUI-User-Id": user.id,
|
"X-OpenWebUI-User-Id": user.id,
|
||||||
"X-OpenWebUI-User-Email": user.email,
|
"X-OpenWebUI-User-Email": user.email,
|
||||||
"X-OpenWebUI-User-Role": user.role,
|
"X-OpenWebUI-User-Role": user.role,
|
||||||
|
|
@ -817,8 +829,8 @@ async def generate_chat_completion(
|
||||||
}
|
}
|
||||||
|
|
||||||
if api_config.get("azure", False):
|
if api_config.get("azure", False):
|
||||||
request_url, payload = convert_to_azure_payload(url, payload)
|
api_version = api_config.get("api_version", "2023-03-15-preview")
|
||||||
api_version = api_config.get("api_version", "") or "2023-03-15-preview"
|
request_url, payload = convert_to_azure_payload(url, payload, api_version)
|
||||||
headers["api-key"] = key
|
headers["api-key"] = key
|
||||||
headers["api-version"] = api_version
|
headers["api-version"] = api_version
|
||||||
request_url = f"{request_url}/chat/completions?api-version={api_version}"
|
request_url = f"{request_url}/chat/completions?api-version={api_version}"
|
||||||
|
|
@ -924,7 +936,7 @@ async def embeddings(request: Request, form_data: dict, user):
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
**(
|
**(
|
||||||
{
|
{
|
||||||
"X-OpenWebUI-User-Name": user.name,
|
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||||
"X-OpenWebUI-User-Id": user.id,
|
"X-OpenWebUI-User-Id": user.id,
|
||||||
"X-OpenWebUI-User-Email": user.email,
|
"X-OpenWebUI-User-Email": user.email,
|
||||||
"X-OpenWebUI-User-Role": user.role,
|
"X-OpenWebUI-User-Role": user.role,
|
||||||
|
|
@ -996,7 +1008,7 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
**(
|
**(
|
||||||
{
|
{
|
||||||
"X-OpenWebUI-User-Name": user.name,
|
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||||
"X-OpenWebUI-User-Id": user.id,
|
"X-OpenWebUI-User-Id": user.id,
|
||||||
"X-OpenWebUI-User-Email": user.email,
|
"X-OpenWebUI-User-Email": user.email,
|
||||||
"X-OpenWebUI-User-Role": user.role,
|
"X-OpenWebUI-User-Role": user.role,
|
||||||
|
|
@ -1007,16 +1019,15 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
|
||||||
}
|
}
|
||||||
|
|
||||||
if api_config.get("azure", False):
|
if api_config.get("azure", False):
|
||||||
|
api_version = api_config.get("api_version", "2023-03-15-preview")
|
||||||
headers["api-key"] = key
|
headers["api-key"] = key
|
||||||
headers["api-version"] = (
|
headers["api-version"] = api_version
|
||||||
api_config.get("api_version", "") or "2023-03-15-preview"
|
|
||||||
)
|
|
||||||
|
|
||||||
payload = json.loads(body)
|
payload = json.loads(body)
|
||||||
url, payload = convert_to_azure_payload(url, payload)
|
url, payload = convert_to_azure_payload(url, payload, api_version)
|
||||||
body = json.dumps(payload).encode()
|
body = json.dumps(payload).encode()
|
||||||
|
|
||||||
request_url = f"{url}/{path}?api-version={api_config.get('api_version', '2023-03-15-preview')}"
|
request_url = f"{url}/{path}?api-version={api_version}"
|
||||||
else:
|
else:
|
||||||
headers["Authorization"] = f"Bearer {key}"
|
headers["Authorization"] = f"Bearer {key}"
|
||||||
request_url = f"{url}/{path}"
|
request_url = f"{url}/{path}"
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ import tiktoken
|
||||||
|
|
||||||
|
|
||||||
from langchain.text_splitter import RecursiveCharacterTextSplitter, TokenTextSplitter
|
from langchain.text_splitter import RecursiveCharacterTextSplitter, TokenTextSplitter
|
||||||
|
from langchain_text_splitters import MarkdownHeaderTextSplitter
|
||||||
from langchain_core.documents import Document
|
from langchain_core.documents import Document
|
||||||
|
|
||||||
from open_webui.models.files import FileModel, Files
|
from open_webui.models.files import FileModel, Files
|
||||||
|
|
@ -1146,6 +1147,7 @@ def save_docs_to_vector_db(
|
||||||
chunk_overlap=request.app.state.config.CHUNK_OVERLAP,
|
chunk_overlap=request.app.state.config.CHUNK_OVERLAP,
|
||||||
add_start_index=True,
|
add_start_index=True,
|
||||||
)
|
)
|
||||||
|
docs = text_splitter.split_documents(docs)
|
||||||
elif request.app.state.config.TEXT_SPLITTER == "token":
|
elif request.app.state.config.TEXT_SPLITTER == "token":
|
||||||
log.info(
|
log.info(
|
||||||
f"Using token text splitter: {request.app.state.config.TIKTOKEN_ENCODING_NAME}"
|
f"Using token text splitter: {request.app.state.config.TIKTOKEN_ENCODING_NAME}"
|
||||||
|
|
@ -1158,11 +1160,56 @@ def save_docs_to_vector_db(
|
||||||
chunk_overlap=request.app.state.config.CHUNK_OVERLAP,
|
chunk_overlap=request.app.state.config.CHUNK_OVERLAP,
|
||||||
add_start_index=True,
|
add_start_index=True,
|
||||||
)
|
)
|
||||||
|
docs = text_splitter.split_documents(docs)
|
||||||
|
elif request.app.state.config.TEXT_SPLITTER == "markdown_header":
|
||||||
|
log.info("Using markdown header text splitter")
|
||||||
|
|
||||||
|
# Define headers to split on - covering most common markdown header levels
|
||||||
|
headers_to_split_on = [
|
||||||
|
("#", "Header 1"),
|
||||||
|
("##", "Header 2"),
|
||||||
|
("###", "Header 3"),
|
||||||
|
("####", "Header 4"),
|
||||||
|
("#####", "Header 5"),
|
||||||
|
("######", "Header 6"),
|
||||||
|
]
|
||||||
|
|
||||||
|
markdown_splitter = MarkdownHeaderTextSplitter(
|
||||||
|
headers_to_split_on=headers_to_split_on,
|
||||||
|
strip_headers=False, # Keep headers in content for context
|
||||||
|
)
|
||||||
|
|
||||||
|
md_split_docs = []
|
||||||
|
for doc in docs:
|
||||||
|
md_header_splits = markdown_splitter.split_text(doc.page_content)
|
||||||
|
text_splitter = RecursiveCharacterTextSplitter(
|
||||||
|
chunk_size=request.app.state.config.CHUNK_SIZE,
|
||||||
|
chunk_overlap=request.app.state.config.CHUNK_OVERLAP,
|
||||||
|
add_start_index=True,
|
||||||
|
)
|
||||||
|
md_header_splits = text_splitter.split_documents(md_header_splits)
|
||||||
|
|
||||||
|
# Convert back to Document objects, preserving original metadata
|
||||||
|
for split_chunk in md_header_splits:
|
||||||
|
headings_list = []
|
||||||
|
# Extract header values in order based on headers_to_split_on
|
||||||
|
for _, header_meta_key_name in headers_to_split_on:
|
||||||
|
if header_meta_key_name in split_chunk.metadata:
|
||||||
|
headings_list.append(
|
||||||
|
split_chunk.metadata[header_meta_key_name]
|
||||||
|
)
|
||||||
|
|
||||||
|
md_split_docs.append(
|
||||||
|
Document(
|
||||||
|
page_content=split_chunk.page_content,
|
||||||
|
metadata={**doc.metadata, "headings": headings_list},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
docs = md_split_docs
|
||||||
else:
|
else:
|
||||||
raise ValueError(ERROR_MESSAGES.DEFAULT("Invalid text splitter"))
|
raise ValueError(ERROR_MESSAGES.DEFAULT("Invalid text splitter"))
|
||||||
|
|
||||||
docs = text_splitter.split_documents(docs)
|
|
||||||
|
|
||||||
if len(docs) == 0:
|
if len(docs) == 0:
|
||||||
raise ValueError(ERROR_MESSAGES.EMPTY_CONTENT)
|
raise ValueError(ERROR_MESSAGES.EMPTY_CONTENT)
|
||||||
|
|
||||||
|
|
@ -1747,6 +1794,16 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]:
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
raise Exception("No TAVILY_API_KEY found in environment variables")
|
raise Exception("No TAVILY_API_KEY found in environment variables")
|
||||||
|
elif engine == "exa":
|
||||||
|
if request.app.state.config.EXA_API_KEY:
|
||||||
|
return search_exa(
|
||||||
|
request.app.state.config.EXA_API_KEY,
|
||||||
|
query,
|
||||||
|
request.app.state.config.WEB_SEARCH_RESULT_COUNT,
|
||||||
|
request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise Exception("No EXA_API_KEY found in environment variables")
|
||||||
elif engine == "searchapi":
|
elif engine == "searchapi":
|
||||||
if request.app.state.config.SEARCHAPI_API_KEY:
|
if request.app.state.config.SEARCHAPI_API_KEY:
|
||||||
return search_searchapi(
|
return search_searchapi(
|
||||||
|
|
@ -1784,6 +1841,13 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]:
|
||||||
request.app.state.config.WEB_SEARCH_RESULT_COUNT,
|
request.app.state.config.WEB_SEARCH_RESULT_COUNT,
|
||||||
request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
|
request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
|
||||||
)
|
)
|
||||||
|
elif engine == "exa":
|
||||||
|
return search_exa(
|
||||||
|
request.app.state.config.EXA_API_KEY,
|
||||||
|
query,
|
||||||
|
request.app.state.config.WEB_SEARCH_RESULT_COUNT,
|
||||||
|
request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
|
||||||
|
)
|
||||||
elif engine == "perplexity":
|
elif engine == "perplexity":
|
||||||
return search_perplexity(
|
return search_perplexity(
|
||||||
request.app.state.config.PERPLEXITY_API_KEY,
|
request.app.state.config.PERPLEXITY_API_KEY,
|
||||||
|
|
|
||||||
|
|
@ -153,7 +153,7 @@ async def load_tool_from_url(
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||||
async with session.get(
|
async with session.get(
|
||||||
url, headers={"Content-Type": "application/json"}
|
url, headers={"Content-Type": "application/json"}
|
||||||
) as resp:
|
) as resp:
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,18 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import random
|
||||||
|
|
||||||
import socketio
|
import socketio
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
from typing import Dict, Set
|
||||||
from redis import asyncio as aioredis
|
from redis import asyncio as aioredis
|
||||||
|
import pycrdt as Y
|
||||||
|
|
||||||
from open_webui.models.users import Users, UserNameResponse
|
from open_webui.models.users import Users, UserNameResponse
|
||||||
from open_webui.models.channels import Channels
|
from open_webui.models.channels import Channels
|
||||||
from open_webui.models.chats import Chats
|
from open_webui.models.chats import Chats
|
||||||
|
from open_webui.models.notes import Notes, NoteUpdateForm
|
||||||
from open_webui.utils.redis import (
|
from open_webui.utils.redis import (
|
||||||
get_sentinels_from_env,
|
get_sentinels_from_env,
|
||||||
get_sentinel_url_from_env,
|
get_sentinel_url_from_env,
|
||||||
|
|
@ -23,6 +28,10 @@ from open_webui.env import (
|
||||||
)
|
)
|
||||||
from open_webui.utils.auth import decode_token
|
from open_webui.utils.auth import decode_token
|
||||||
from open_webui.socket.utils import RedisDict, RedisLock
|
from open_webui.socket.utils import RedisDict, RedisLock
|
||||||
|
from open_webui.tasks import create_task, stop_item_tasks
|
||||||
|
from open_webui.utils.redis import get_redis_connection
|
||||||
|
from open_webui.utils.access_control import has_access, get_users_with_access
|
||||||
|
|
||||||
|
|
||||||
from open_webui.env import (
|
from open_webui.env import (
|
||||||
GLOBAL_LOG_LEVEL,
|
GLOBAL_LOG_LEVEL,
|
||||||
|
|
@ -35,6 +44,14 @@ log = logging.getLogger(__name__)
|
||||||
log.setLevel(SRC_LOG_LEVELS["SOCKET"])
|
log.setLevel(SRC_LOG_LEVELS["SOCKET"])
|
||||||
|
|
||||||
|
|
||||||
|
REDIS = get_redis_connection(
|
||||||
|
redis_url=WEBSOCKET_REDIS_URL,
|
||||||
|
redis_sentinels=get_sentinels_from_env(
|
||||||
|
WEBSOCKET_SENTINEL_HOSTS, WEBSOCKET_SENTINEL_PORT
|
||||||
|
),
|
||||||
|
async_mode=True,
|
||||||
|
)
|
||||||
|
|
||||||
if WEBSOCKET_MANAGER == "redis":
|
if WEBSOCKET_MANAGER == "redis":
|
||||||
if WEBSOCKET_SENTINEL_HOSTS:
|
if WEBSOCKET_SENTINEL_HOSTS:
|
||||||
mgr = socketio.AsyncRedisManager(
|
mgr = socketio.AsyncRedisManager(
|
||||||
|
|
@ -88,6 +105,10 @@ if WEBSOCKET_MANAGER == "redis":
|
||||||
redis_sentinels=redis_sentinels,
|
redis_sentinels=redis_sentinels,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# TODO: Implement Yjs document management with Redis
|
||||||
|
DOCUMENTS = {}
|
||||||
|
DOCUMENT_USERS = {}
|
||||||
|
|
||||||
clean_up_lock = RedisLock(
|
clean_up_lock = RedisLock(
|
||||||
redis_url=WEBSOCKET_REDIS_URL,
|
redis_url=WEBSOCKET_REDIS_URL,
|
||||||
lock_name="usage_cleanup_lock",
|
lock_name="usage_cleanup_lock",
|
||||||
|
|
@ -101,14 +122,33 @@ else:
|
||||||
SESSION_POOL = {}
|
SESSION_POOL = {}
|
||||||
USER_POOL = {}
|
USER_POOL = {}
|
||||||
USAGE_POOL = {}
|
USAGE_POOL = {}
|
||||||
|
|
||||||
|
DOCUMENTS = {} # document_id -> Y.YDoc instance
|
||||||
|
DOCUMENT_USERS = {} # document_id -> set of user sids
|
||||||
aquire_func = release_func = renew_func = lambda: True
|
aquire_func = release_func = renew_func = lambda: True
|
||||||
|
|
||||||
|
|
||||||
async def periodic_usage_pool_cleanup():
|
async def periodic_usage_pool_cleanup():
|
||||||
if not aquire_func():
|
max_retries = 2
|
||||||
log.debug("Usage pool cleanup lock already exists. Not running it.")
|
retry_delay = random.uniform(
|
||||||
return
|
WEBSOCKET_REDIS_LOCK_TIMEOUT / 2, WEBSOCKET_REDIS_LOCK_TIMEOUT
|
||||||
log.debug("Running periodic_usage_pool_cleanup")
|
)
|
||||||
|
for attempt in range(max_retries + 1):
|
||||||
|
if aquire_func():
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
if attempt < max_retries:
|
||||||
|
log.debug(
|
||||||
|
f"Cleanup lock already exists. Retry {attempt + 1} after {retry_delay}s..."
|
||||||
|
)
|
||||||
|
await asyncio.sleep(retry_delay)
|
||||||
|
else:
|
||||||
|
log.warning(
|
||||||
|
"Failed to acquire cleanup lock after retries. Skipping cleanup."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
log.debug("Running periodic_cleanup")
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
if not renew_func():
|
if not renew_func():
|
||||||
|
|
@ -298,6 +338,217 @@ async def channel_events(sid, data):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@sio.on("yjs:document:join")
|
||||||
|
async def yjs_document_join(sid, data):
|
||||||
|
"""Handle user joining a document"""
|
||||||
|
user = SESSION_POOL.get(sid)
|
||||||
|
|
||||||
|
try:
|
||||||
|
document_id = data["document_id"]
|
||||||
|
|
||||||
|
if document_id.startswith("note:"):
|
||||||
|
note_id = document_id.split(":")[1]
|
||||||
|
note = Notes.get_note_by_id(note_id)
|
||||||
|
if not note:
|
||||||
|
log.error(f"Note {note_id} not found")
|
||||||
|
return
|
||||||
|
|
||||||
|
if (
|
||||||
|
user.get("role") != "admin"
|
||||||
|
and user.get("id") != note.user_id
|
||||||
|
and not has_access(
|
||||||
|
user.get("id"), type="read", access_control=note.access_control
|
||||||
|
)
|
||||||
|
):
|
||||||
|
log.error(
|
||||||
|
f"User {user.get('id')} does not have access to note {note_id}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
user_id = data.get("user_id", sid)
|
||||||
|
user_name = data.get("user_name", "Anonymous")
|
||||||
|
user_color = data.get("user_color", "#000000")
|
||||||
|
|
||||||
|
log.info(f"User {user_id} joining document {document_id}")
|
||||||
|
|
||||||
|
# Initialize document if it doesn't exist
|
||||||
|
if document_id not in DOCUMENTS:
|
||||||
|
DOCUMENTS[document_id] = {
|
||||||
|
"ydoc": Y.Doc(), # Create actual Yjs document
|
||||||
|
"users": set(),
|
||||||
|
}
|
||||||
|
DOCUMENT_USERS[document_id] = set()
|
||||||
|
|
||||||
|
# Add user to document
|
||||||
|
DOCUMENTS[document_id]["users"].add(sid)
|
||||||
|
DOCUMENT_USERS[document_id].add(sid)
|
||||||
|
|
||||||
|
# Join Socket.IO room
|
||||||
|
await sio.enter_room(sid, f"doc_{document_id}")
|
||||||
|
|
||||||
|
# Send current document state as a proper Yjs update
|
||||||
|
ydoc = DOCUMENTS[document_id]["ydoc"]
|
||||||
|
|
||||||
|
# Encode the entire document state as an update
|
||||||
|
state_update = ydoc.get_update()
|
||||||
|
await sio.emit(
|
||||||
|
"yjs:document:state",
|
||||||
|
{
|
||||||
|
"document_id": document_id,
|
||||||
|
"state": list(state_update), # Convert bytes to list for JSON
|
||||||
|
},
|
||||||
|
room=sid,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Notify other users about the new user
|
||||||
|
await sio.emit(
|
||||||
|
"yjs:user:joined",
|
||||||
|
{
|
||||||
|
"document_id": document_id,
|
||||||
|
"user_id": user_id,
|
||||||
|
"user_name": user_name,
|
||||||
|
"user_color": user_color,
|
||||||
|
},
|
||||||
|
room=f"doc_{document_id}",
|
||||||
|
skip_sid=sid,
|
||||||
|
)
|
||||||
|
|
||||||
|
log.info(f"User {user_id} successfully joined document {document_id}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Error in yjs_document_join: {e}")
|
||||||
|
await sio.emit("error", {"message": "Failed to join document"}, room=sid)
|
||||||
|
|
||||||
|
|
||||||
|
async def document_save_handler(document_id, data, user):
|
||||||
|
if document_id.startswith("note:"):
|
||||||
|
note_id = document_id.split(":")[1]
|
||||||
|
note = Notes.get_note_by_id(note_id)
|
||||||
|
if not note:
|
||||||
|
log.error(f"Note {note_id} not found")
|
||||||
|
return
|
||||||
|
|
||||||
|
if (
|
||||||
|
user.get("role") != "admin"
|
||||||
|
and user.get("id") != note.user_id
|
||||||
|
and not has_access(
|
||||||
|
user.get("id"), type="read", access_control=note.access_control
|
||||||
|
)
|
||||||
|
):
|
||||||
|
log.error(f"User {user.get('id')} does not have access to note {note_id}")
|
||||||
|
return
|
||||||
|
|
||||||
|
Notes.update_note_by_id(note_id, NoteUpdateForm(data=data))
|
||||||
|
|
||||||
|
|
||||||
|
@sio.on("yjs:document:update")
|
||||||
|
async def yjs_document_update(sid, data):
|
||||||
|
"""Handle Yjs document updates"""
|
||||||
|
try:
|
||||||
|
document_id = data["document_id"]
|
||||||
|
try:
|
||||||
|
await stop_item_tasks(REDIS, document_id)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
user_id = data.get("user_id", sid)
|
||||||
|
|
||||||
|
update = data["update"] # List of bytes from frontend
|
||||||
|
|
||||||
|
if document_id not in DOCUMENTS:
|
||||||
|
log.warning(f"Document {document_id} not found")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Apply the update to the server's Yjs document
|
||||||
|
ydoc = DOCUMENTS[document_id]["ydoc"]
|
||||||
|
update_bytes = bytes(update)
|
||||||
|
|
||||||
|
try:
|
||||||
|
ydoc.apply_update(update_bytes)
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Failed to apply Yjs update: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Broadcast update to all other users in the document
|
||||||
|
await sio.emit(
|
||||||
|
"yjs:document:update",
|
||||||
|
{
|
||||||
|
"document_id": document_id,
|
||||||
|
"user_id": user_id,
|
||||||
|
"update": update,
|
||||||
|
"socket_id": sid, # Add socket_id to match frontend filtering
|
||||||
|
},
|
||||||
|
room=f"doc_{document_id}",
|
||||||
|
skip_sid=sid,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def debounced_save():
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
await document_save_handler(
|
||||||
|
document_id, data.get("data", {}), SESSION_POOL.get(sid)
|
||||||
|
)
|
||||||
|
|
||||||
|
await create_task(REDIS, debounced_save(), document_id)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Error in yjs_document_update: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@sio.on("yjs:document:leave")
|
||||||
|
async def yjs_document_leave(sid, data):
|
||||||
|
"""Handle user leaving a document"""
|
||||||
|
try:
|
||||||
|
document_id = data["document_id"]
|
||||||
|
user_id = data.get("user_id", sid)
|
||||||
|
|
||||||
|
log.info(f"User {user_id} leaving document {document_id}")
|
||||||
|
|
||||||
|
if document_id in DOCUMENTS:
|
||||||
|
DOCUMENTS[document_id]["users"].discard(sid)
|
||||||
|
|
||||||
|
if document_id in DOCUMENT_USERS:
|
||||||
|
DOCUMENT_USERS[document_id].discard(sid)
|
||||||
|
|
||||||
|
# Leave Socket.IO room
|
||||||
|
await sio.leave_room(sid, f"doc_{document_id}")
|
||||||
|
|
||||||
|
# Notify other users
|
||||||
|
await sio.emit(
|
||||||
|
"yjs:user:left",
|
||||||
|
{"document_id": document_id, "user_id": user_id},
|
||||||
|
room=f"doc_{document_id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
if document_id in DOCUMENTS and not DOCUMENTS[document_id]["users"]:
|
||||||
|
# If no users left, clean up the document
|
||||||
|
log.info(f"Cleaning up document {document_id} as no users are left")
|
||||||
|
del DOCUMENTS[document_id]
|
||||||
|
del DOCUMENT_USERS[document_id]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Error in yjs_document_leave: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@sio.on("yjs:awareness:update")
|
||||||
|
async def yjs_awareness_update(sid, data):
|
||||||
|
"""Handle awareness updates (cursors, selections, etc.)"""
|
||||||
|
try:
|
||||||
|
document_id = data["document_id"]
|
||||||
|
user_id = data.get("user_id", sid)
|
||||||
|
update = data["update"]
|
||||||
|
|
||||||
|
# Broadcast awareness update to all other users in the document
|
||||||
|
await sio.emit(
|
||||||
|
"yjs:awareness:update",
|
||||||
|
{"document_id": document_id, "user_id": user_id, "update": update},
|
||||||
|
room=f"doc_{document_id}",
|
||||||
|
skip_sid=sid,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Error in yjs_awareness_update: {e}")
|
||||||
|
|
||||||
|
|
||||||
@sio.event
|
@sio.event
|
||||||
async def disconnect(sid):
|
async def disconnect(sid):
|
||||||
if sid in SESSION_POOL:
|
if sid in SESSION_POOL:
|
||||||
|
|
|
||||||
|
|
@ -3,25 +3,27 @@ import asyncio
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
from redis.asyncio import Redis
|
from redis.asyncio import Redis
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
from open_webui.env import SRC_LOG_LEVELS
|
||||||
|
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
log.setLevel(SRC_LOG_LEVELS["MAIN"])
|
||||||
|
|
||||||
# A dictionary to keep track of active tasks
|
# A dictionary to keep track of active tasks
|
||||||
tasks: Dict[str, asyncio.Task] = {}
|
tasks: Dict[str, asyncio.Task] = {}
|
||||||
chat_tasks = {}
|
item_tasks = {}
|
||||||
|
|
||||||
|
|
||||||
REDIS_TASKS_KEY = "open-webui:tasks"
|
REDIS_TASKS_KEY = "open-webui:tasks"
|
||||||
REDIS_CHAT_TASKS_KEY = "open-webui:tasks:chat"
|
REDIS_ITEM_TASKS_KEY = "open-webui:tasks:item"
|
||||||
REDIS_PUBSUB_CHANNEL = "open-webui:tasks:commands"
|
REDIS_PUBSUB_CHANNEL = "open-webui:tasks:commands"
|
||||||
|
|
||||||
|
|
||||||
def is_redis(request: Request) -> bool:
|
|
||||||
# Called everywhere a request is available to check Redis
|
|
||||||
return hasattr(request.app.state, "redis") and (request.app.state.redis is not None)
|
|
||||||
|
|
||||||
|
|
||||||
async def redis_task_command_listener(app):
|
async def redis_task_command_listener(app):
|
||||||
redis: Redis = app.state.redis
|
redis: Redis = app.state.redis
|
||||||
pubsub = redis.pubsub()
|
pubsub = redis.pubsub()
|
||||||
|
|
@ -38,7 +40,7 @@ async def redis_task_command_listener(app):
|
||||||
if local_task:
|
if local_task:
|
||||||
local_task.cancel()
|
local_task.cancel()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error handling distributed task command: {e}")
|
log.exception(f"Error handling distributed task command: {e}")
|
||||||
|
|
||||||
|
|
||||||
### ------------------------------
|
### ------------------------------
|
||||||
|
|
@ -46,21 +48,21 @@ async def redis_task_command_listener(app):
|
||||||
### ------------------------------
|
### ------------------------------
|
||||||
|
|
||||||
|
|
||||||
async def redis_save_task(redis: Redis, task_id: str, chat_id: Optional[str]):
|
async def redis_save_task(redis: Redis, task_id: str, item_id: Optional[str]):
|
||||||
pipe = redis.pipeline()
|
pipe = redis.pipeline()
|
||||||
pipe.hset(REDIS_TASKS_KEY, task_id, chat_id or "")
|
pipe.hset(REDIS_TASKS_KEY, task_id, item_id or "")
|
||||||
if chat_id:
|
if item_id:
|
||||||
pipe.sadd(f"{REDIS_CHAT_TASKS_KEY}:{chat_id}", task_id)
|
pipe.sadd(f"{REDIS_ITEM_TASKS_KEY}:{item_id}", task_id)
|
||||||
await pipe.execute()
|
await pipe.execute()
|
||||||
|
|
||||||
|
|
||||||
async def redis_cleanup_task(redis: Redis, task_id: str, chat_id: Optional[str]):
|
async def redis_cleanup_task(redis: Redis, task_id: str, item_id: Optional[str]):
|
||||||
pipe = redis.pipeline()
|
pipe = redis.pipeline()
|
||||||
pipe.hdel(REDIS_TASKS_KEY, task_id)
|
pipe.hdel(REDIS_TASKS_KEY, task_id)
|
||||||
if chat_id:
|
if item_id:
|
||||||
pipe.srem(f"{REDIS_CHAT_TASKS_KEY}:{chat_id}", task_id)
|
pipe.srem(f"{REDIS_ITEM_TASKS_KEY}:{item_id}", task_id)
|
||||||
if (await pipe.scard(f"{REDIS_CHAT_TASKS_KEY}:{chat_id}").execute())[-1] == 0:
|
if (await pipe.scard(f"{REDIS_ITEM_TASKS_KEY}:{item_id}").execute())[-1] == 0:
|
||||||
pipe.delete(f"{REDIS_CHAT_TASKS_KEY}:{chat_id}") # Remove if empty set
|
pipe.delete(f"{REDIS_ITEM_TASKS_KEY}:{item_id}") # Remove if empty set
|
||||||
await pipe.execute()
|
await pipe.execute()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -68,31 +70,31 @@ async def redis_list_tasks(redis: Redis) -> List[str]:
|
||||||
return list(await redis.hkeys(REDIS_TASKS_KEY))
|
return list(await redis.hkeys(REDIS_TASKS_KEY))
|
||||||
|
|
||||||
|
|
||||||
async def redis_list_chat_tasks(redis: Redis, chat_id: str) -> List[str]:
|
async def redis_list_item_tasks(redis: Redis, item_id: str) -> List[str]:
|
||||||
return list(await redis.smembers(f"{REDIS_CHAT_TASKS_KEY}:{chat_id}"))
|
return list(await redis.smembers(f"{REDIS_ITEM_TASKS_KEY}:{item_id}"))
|
||||||
|
|
||||||
|
|
||||||
async def redis_send_command(redis: Redis, command: dict):
|
async def redis_send_command(redis: Redis, command: dict):
|
||||||
await redis.publish(REDIS_PUBSUB_CHANNEL, json.dumps(command))
|
await redis.publish(REDIS_PUBSUB_CHANNEL, json.dumps(command))
|
||||||
|
|
||||||
|
|
||||||
async def cleanup_task(request, task_id: str, id=None):
|
async def cleanup_task(redis, task_id: str, id=None):
|
||||||
"""
|
"""
|
||||||
Remove a completed or canceled task from the global `tasks` dictionary.
|
Remove a completed or canceled task from the global `tasks` dictionary.
|
||||||
"""
|
"""
|
||||||
if is_redis(request):
|
if redis:
|
||||||
await redis_cleanup_task(request.app.state.redis, task_id, id)
|
await redis_cleanup_task(redis, task_id, id)
|
||||||
|
|
||||||
tasks.pop(task_id, None) # Remove the task if it exists
|
tasks.pop(task_id, None) # Remove the task if it exists
|
||||||
|
|
||||||
# If an ID is provided, remove the task from the chat_tasks dictionary
|
# If an ID is provided, remove the task from the item_tasks dictionary
|
||||||
if id and task_id in chat_tasks.get(id, []):
|
if id and task_id in item_tasks.get(id, []):
|
||||||
chat_tasks[id].remove(task_id)
|
item_tasks[id].remove(task_id)
|
||||||
if not chat_tasks[id]: # If no tasks left for this ID, remove the entry
|
if not item_tasks[id]: # If no tasks left for this ID, remove the entry
|
||||||
chat_tasks.pop(id, None)
|
item_tasks.pop(id, None)
|
||||||
|
|
||||||
|
|
||||||
async def create_task(request, coroutine, id=None):
|
async def create_task(redis, coroutine, id=None):
|
||||||
"""
|
"""
|
||||||
Create a new asyncio task and add it to the global task dictionary.
|
Create a new asyncio task and add it to the global task dictionary.
|
||||||
"""
|
"""
|
||||||
|
|
@ -101,48 +103,48 @@ async def create_task(request, coroutine, id=None):
|
||||||
|
|
||||||
# Add a done callback for cleanup
|
# Add a done callback for cleanup
|
||||||
task.add_done_callback(
|
task.add_done_callback(
|
||||||
lambda t: asyncio.create_task(cleanup_task(request, task_id, id))
|
lambda t: asyncio.create_task(cleanup_task(redis, task_id, id))
|
||||||
)
|
)
|
||||||
tasks[task_id] = task
|
tasks[task_id] = task
|
||||||
|
|
||||||
# If an ID is provided, associate the task with that ID
|
# If an ID is provided, associate the task with that ID
|
||||||
if chat_tasks.get(id):
|
if item_tasks.get(id):
|
||||||
chat_tasks[id].append(task_id)
|
item_tasks[id].append(task_id)
|
||||||
else:
|
else:
|
||||||
chat_tasks[id] = [task_id]
|
item_tasks[id] = [task_id]
|
||||||
|
|
||||||
if is_redis(request):
|
if redis:
|
||||||
await redis_save_task(request.app.state.redis, task_id, id)
|
await redis_save_task(redis, task_id, id)
|
||||||
|
|
||||||
return task_id, task
|
return task_id, task
|
||||||
|
|
||||||
|
|
||||||
async def list_tasks(request):
|
async def list_tasks(redis):
|
||||||
"""
|
"""
|
||||||
List all currently active task IDs.
|
List all currently active task IDs.
|
||||||
"""
|
"""
|
||||||
if is_redis(request):
|
if redis:
|
||||||
return await redis_list_tasks(request.app.state.redis)
|
return await redis_list_tasks(redis)
|
||||||
return list(tasks.keys())
|
return list(tasks.keys())
|
||||||
|
|
||||||
|
|
||||||
async def list_task_ids_by_chat_id(request, id):
|
async def list_task_ids_by_item_id(redis, id):
|
||||||
"""
|
"""
|
||||||
List all tasks associated with a specific ID.
|
List all tasks associated with a specific ID.
|
||||||
"""
|
"""
|
||||||
if is_redis(request):
|
if redis:
|
||||||
return await redis_list_chat_tasks(request.app.state.redis, id)
|
return await redis_list_item_tasks(redis, id)
|
||||||
return chat_tasks.get(id, [])
|
return item_tasks.get(id, [])
|
||||||
|
|
||||||
|
|
||||||
async def stop_task(request, task_id: str):
|
async def stop_task(redis, task_id: str):
|
||||||
"""
|
"""
|
||||||
Cancel a running task and remove it from the global task list.
|
Cancel a running task and remove it from the global task list.
|
||||||
"""
|
"""
|
||||||
if is_redis(request):
|
if redis:
|
||||||
# PUBSUB: All instances check if they have this task, and stop if so.
|
# PUBSUB: All instances check if they have this task, and stop if so.
|
||||||
await redis_send_command(
|
await redis_send_command(
|
||||||
request.app.state.redis,
|
redis,
|
||||||
{
|
{
|
||||||
"action": "stop",
|
"action": "stop",
|
||||||
"task_id": task_id,
|
"task_id": task_id,
|
||||||
|
|
@ -151,7 +153,7 @@ async def stop_task(request, task_id: str):
|
||||||
# Optionally check if task_id still in Redis a few moments later for feedback?
|
# Optionally check if task_id still in Redis a few moments later for feedback?
|
||||||
return {"status": True, "message": f"Stop signal sent for {task_id}"}
|
return {"status": True, "message": f"Stop signal sent for {task_id}"}
|
||||||
|
|
||||||
task = tasks.get(task_id)
|
task = tasks.pop(task_id)
|
||||||
if not task:
|
if not task:
|
||||||
raise ValueError(f"Task with ID {task_id} not found.")
|
raise ValueError(f"Task with ID {task_id} not found.")
|
||||||
|
|
||||||
|
|
@ -160,7 +162,22 @@ async def stop_task(request, task_id: str):
|
||||||
await task # Wait for the task to handle the cancellation
|
await task # Wait for the task to handle the cancellation
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
# Task successfully canceled
|
# Task successfully canceled
|
||||||
tasks.pop(task_id, None) # Remove it from the dictionary
|
|
||||||
return {"status": True, "message": f"Task {task_id} successfully stopped."}
|
return {"status": True, "message": f"Task {task_id} successfully stopped."}
|
||||||
|
|
||||||
return {"status": False, "message": f"Failed to stop task {task_id}."}
|
return {"status": False, "message": f"Failed to stop task {task_id}."}
|
||||||
|
|
||||||
|
|
||||||
|
async def stop_item_tasks(redis: Redis, item_id: str):
|
||||||
|
"""
|
||||||
|
Stop all tasks associated with a specific item ID.
|
||||||
|
"""
|
||||||
|
task_ids = await list_task_ids_by_item_id(redis, item_id)
|
||||||
|
if not task_ids:
|
||||||
|
return {"status": True, "message": f"No tasks found for item {item_id}."}
|
||||||
|
|
||||||
|
for task_id in task_ids:
|
||||||
|
result = await stop_task(redis, task_id)
|
||||||
|
if not result["status"]:
|
||||||
|
return result # Return the first failure
|
||||||
|
|
||||||
|
return {"status": True, "message": f"All tasks for item {item_id} stopped."}
|
||||||
|
|
|
||||||
|
|
@ -74,31 +74,37 @@ def override_static(path: str, content: str):
|
||||||
|
|
||||||
|
|
||||||
def get_license_data(app, key):
|
def get_license_data(app, key):
|
||||||
if key:
|
def handler(u):
|
||||||
try:
|
res = requests.post(
|
||||||
res = requests.post(
|
f"{u}/api/v1/license/",
|
||||||
"https://api.openwebui.com/api/v1/license/",
|
json={"key": key, "version": "1"},
|
||||||
json={"key": key, "version": "1"},
|
timeout=5,
|
||||||
timeout=5,
|
)
|
||||||
|
|
||||||
|
if getattr(res, "ok", False):
|
||||||
|
payload = getattr(res, "json", lambda: {})()
|
||||||
|
for k, v in payload.items():
|
||||||
|
if k == "resources":
|
||||||
|
for p, c in v.items():
|
||||||
|
globals().get("override_static", lambda a, b: None)(p, c)
|
||||||
|
elif k == "count":
|
||||||
|
setattr(app.state, "USER_COUNT", v)
|
||||||
|
elif k == "name":
|
||||||
|
setattr(app.state, "WEBUI_NAME", v)
|
||||||
|
elif k == "metadata":
|
||||||
|
setattr(app.state, "LICENSE_METADATA", v)
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
log.error(
|
||||||
|
f"License: retrieval issue: {getattr(res, 'text', 'unknown error')}"
|
||||||
)
|
)
|
||||||
|
|
||||||
if getattr(res, "ok", False):
|
if key:
|
||||||
payload = getattr(res, "json", lambda: {})()
|
us = ["https://api.openwebui.com", "https://licenses.api.openwebui.com"]
|
||||||
for k, v in payload.items():
|
try:
|
||||||
if k == "resources":
|
for u in us:
|
||||||
for p, c in v.items():
|
if handler(u):
|
||||||
globals().get("override_static", lambda a, b: None)(p, c)
|
return True
|
||||||
elif k == "count":
|
|
||||||
setattr(app.state, "USER_COUNT", v)
|
|
||||||
elif k == "name":
|
|
||||||
setattr(app.state, "WEBUI_NAME", v)
|
|
||||||
elif k == "metadata":
|
|
||||||
setattr(app.state, "LICENSE_METADATA", v)
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
log.error(
|
|
||||||
f"License: retrieval issue: {getattr(res, 'text', 'unknown error')}"
|
|
||||||
)
|
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
log.exception(f"License: Uncaught Exception: {ex}")
|
log.exception(f"License: Uncaught Exception: {ex}")
|
||||||
return False
|
return False
|
||||||
|
|
|
||||||
|
|
@ -419,7 +419,7 @@ async def chat_action(request: Request, action_id: str, form_data: dict, user: A
|
||||||
params[key] = value
|
params[key] = value
|
||||||
|
|
||||||
if "__user__" in sig.parameters:
|
if "__user__" in sig.parameters:
|
||||||
__user__ = (user.model_dump() if isinstance(user, UserModel) else {},)
|
__user__ = user.model_dump() if isinstance(user, UserModel) else {}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if hasattr(function_module, "UserValves"):
|
if hasattr(function_module, "UserValves"):
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,9 @@ from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
|
||||||
from open_webui.env import (
|
from open_webui.env import (
|
||||||
|
AUDIT_UVICORN_LOGGER_NAMES,
|
||||||
AUDIT_LOG_FILE_ROTATION_SIZE,
|
AUDIT_LOG_FILE_ROTATION_SIZE,
|
||||||
AUDIT_LOG_LEVEL,
|
AUDIT_LOG_LEVEL,
|
||||||
AUDIT_LOGS_FILE_PATH,
|
AUDIT_LOGS_FILE_PATH,
|
||||||
|
|
@ -128,11 +130,13 @@ def start_logger():
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
handlers=[InterceptHandler()], level=GLOBAL_LOG_LEVEL, force=True
|
handlers=[InterceptHandler()], level=GLOBAL_LOG_LEVEL, force=True
|
||||||
)
|
)
|
||||||
|
|
||||||
for uvicorn_logger_name in ["uvicorn", "uvicorn.error"]:
|
for uvicorn_logger_name in ["uvicorn", "uvicorn.error"]:
|
||||||
uvicorn_logger = logging.getLogger(uvicorn_logger_name)
|
uvicorn_logger = logging.getLogger(uvicorn_logger_name)
|
||||||
uvicorn_logger.setLevel(GLOBAL_LOG_LEVEL)
|
uvicorn_logger.setLevel(GLOBAL_LOG_LEVEL)
|
||||||
uvicorn_logger.handlers = []
|
uvicorn_logger.handlers = []
|
||||||
for uvicorn_logger_name in ["uvicorn.access"]:
|
|
||||||
|
for uvicorn_logger_name in AUDIT_UVICORN_LOGGER_NAMES:
|
||||||
uvicorn_logger = logging.getLogger(uvicorn_logger_name)
|
uvicorn_logger = logging.getLogger(uvicorn_logger_name)
|
||||||
uvicorn_logger.setLevel(GLOBAL_LOG_LEVEL)
|
uvicorn_logger.setLevel(GLOBAL_LOG_LEVEL)
|
||||||
uvicorn_logger.handlers = [InterceptHandler()]
|
uvicorn_logger.handlers = [InterceptHandler()]
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ from starlette.responses import Response, StreamingResponse
|
||||||
|
|
||||||
|
|
||||||
from open_webui.models.chats import Chats
|
from open_webui.models.chats import Chats
|
||||||
|
from open_webui.models.folders import Folders
|
||||||
from open_webui.models.users import Users
|
from open_webui.models.users import Users
|
||||||
from open_webui.socket.main import (
|
from open_webui.socket.main import (
|
||||||
get_event_call,
|
get_event_call,
|
||||||
|
|
@ -56,7 +57,7 @@ from open_webui.models.users import UserModel
|
||||||
from open_webui.models.functions import Functions
|
from open_webui.models.functions import Functions
|
||||||
from open_webui.models.models import Models
|
from open_webui.models.models import Models
|
||||||
|
|
||||||
from open_webui.retrieval.utils import get_sources_from_files
|
from open_webui.retrieval.utils import get_sources_from_items
|
||||||
|
|
||||||
|
|
||||||
from open_webui.utils.chat import generate_chat_completion
|
from open_webui.utils.chat import generate_chat_completion
|
||||||
|
|
@ -248,30 +249,28 @@ async def chat_completion_tools_handler(
|
||||||
if tool_id
|
if tool_id
|
||||||
else f"{tool_function_name}"
|
else f"{tool_function_name}"
|
||||||
)
|
)
|
||||||
if tool.get("metadata", {}).get("citation", False) or tool.get(
|
|
||||||
"direct", False
|
# Citation is enabled for this tool
|
||||||
):
|
sources.append(
|
||||||
# Citation is enabled for this tool
|
{
|
||||||
sources.append(
|
"source": {
|
||||||
{
|
"name": (f"TOOL:{tool_name}"),
|
||||||
"source": {
|
},
|
||||||
"name": (f"TOOL:{tool_name}"),
|
"document": [tool_result],
|
||||||
},
|
"metadata": [
|
||||||
"document": [tool_result],
|
{
|
||||||
"metadata": [
|
"source": (f"TOOL:{tool_name}"),
|
||||||
{
|
"parameters": tool_function_params,
|
||||||
"source": (f"TOOL:{tool_name}"),
|
}
|
||||||
"parameters": tool_function_params,
|
],
|
||||||
}
|
"tool_result": True,
|
||||||
],
|
}
|
||||||
}
|
)
|
||||||
)
|
# Citation is not enabled for this tool
|
||||||
else:
|
body["messages"] = add_or_update_user_message(
|
||||||
# Citation is not enabled for this tool
|
f"\nTool `{tool_name}` Output: {tool_result}",
|
||||||
body["messages"] = add_or_update_user_message(
|
body["messages"],
|
||||||
f"\nTool `{tool_name}` Output: {tool_result}",
|
)
|
||||||
body["messages"],
|
|
||||||
)
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
tools[tool_function_name]
|
tools[tool_function_name]
|
||||||
|
|
@ -640,14 +639,14 @@ async def chat_completion_files_handler(
|
||||||
queries = [get_last_user_message(body["messages"])]
|
queries = [get_last_user_message(body["messages"])]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Offload get_sources_from_files to a separate thread
|
# Offload get_sources_from_items to a separate thread
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
with ThreadPoolExecutor() as executor:
|
with ThreadPoolExecutor() as executor:
|
||||||
sources = await loop.run_in_executor(
|
sources = await loop.run_in_executor(
|
||||||
executor,
|
executor,
|
||||||
lambda: get_sources_from_files(
|
lambda: get_sources_from_items(
|
||||||
request=request,
|
request=request,
|
||||||
files=files,
|
items=files,
|
||||||
queries=queries,
|
queries=queries,
|
||||||
embedding_function=lambda query, prefix: request.app.state.EMBEDDING_FUNCTION(
|
embedding_function=lambda query, prefix: request.app.state.EMBEDDING_FUNCTION(
|
||||||
query, prefix=prefix, user=user
|
query, prefix=prefix, user=user
|
||||||
|
|
@ -659,6 +658,7 @@ async def chat_completion_files_handler(
|
||||||
hybrid_bm25_weight=request.app.state.config.HYBRID_BM25_WEIGHT,
|
hybrid_bm25_weight=request.app.state.config.HYBRID_BM25_WEIGHT,
|
||||||
hybrid_search=request.app.state.config.ENABLE_RAG_HYBRID_SEARCH,
|
hybrid_search=request.app.state.config.ENABLE_RAG_HYBRID_SEARCH,
|
||||||
full_context=request.app.state.config.RAG_FULL_CONTEXT,
|
full_context=request.app.state.config.RAG_FULL_CONTEXT,
|
||||||
|
user=user,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -718,6 +718,10 @@ def apply_params_to_form_data(form_data, model):
|
||||||
|
|
||||||
|
|
||||||
async def process_chat_payload(request, form_data, user, metadata, model):
|
async def process_chat_payload(request, form_data, user, metadata, model):
|
||||||
|
# Pipeline Inlet -> Filter Inlet -> Chat Memory -> Chat Web Search -> Chat Image Generation
|
||||||
|
# -> Chat Code Interpreter (Form Data Update) -> (Default) Chat Tools Function Calling
|
||||||
|
# -> Chat Files
|
||||||
|
|
||||||
form_data = apply_params_to_form_data(form_data, model)
|
form_data = apply_params_to_form_data(form_data, model)
|
||||||
log.debug(f"form_data: {form_data}")
|
log.debug(f"form_data: {form_data}")
|
||||||
|
|
||||||
|
|
@ -752,6 +756,26 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
||||||
events = []
|
events = []
|
||||||
sources = []
|
sources = []
|
||||||
|
|
||||||
|
# Folder "Project" handling
|
||||||
|
# Check if the request has chat_id and is inside of a folder
|
||||||
|
chat_id = metadata.get("chat_id", None)
|
||||||
|
if chat_id and user:
|
||||||
|
chat = Chats.get_chat_by_id_and_user_id(chat_id, user.id)
|
||||||
|
if chat and chat.folder_id:
|
||||||
|
folder = Folders.get_folder_by_id_and_user_id(chat.folder_id, user.id)
|
||||||
|
|
||||||
|
if folder and folder.data:
|
||||||
|
if "system_prompt" in folder.data:
|
||||||
|
form_data["messages"] = add_or_update_system_message(
|
||||||
|
folder.data["system_prompt"], form_data["messages"]
|
||||||
|
)
|
||||||
|
if "files" in folder.data:
|
||||||
|
form_data["files"] = [
|
||||||
|
*folder.data["files"],
|
||||||
|
*form_data.get("files", []),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Model "Knowledge" handling
|
||||||
user_message = get_last_user_message(form_data["messages"])
|
user_message = get_last_user_message(form_data["messages"])
|
||||||
model_knowledge = model.get("info", {}).get("meta", {}).get("knowledge", False)
|
model_knowledge = model.get("info", {}).get("meta", {}).get("knowledge", False)
|
||||||
|
|
||||||
|
|
@ -804,7 +828,6 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
||||||
filter_functions = [
|
filter_functions = [
|
||||||
Functions.get_function_by_id(filter_id)
|
Functions.get_function_by_id(filter_id)
|
||||||
for filter_id in get_sorted_filter_ids(
|
for filter_id in get_sorted_filter_ids(
|
||||||
|
|
@ -912,7 +935,6 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
||||||
request, form_data, extra_params, user, models, tools_dict
|
request, form_data, extra_params, user, models, tools_dict
|
||||||
)
|
)
|
||||||
sources.extend(flags.get("sources", []))
|
sources.extend(flags.get("sources", []))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.exception(e)
|
log.exception(e)
|
||||||
|
|
||||||
|
|
@ -925,55 +947,59 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
||||||
# If context is not empty, insert it into the messages
|
# If context is not empty, insert it into the messages
|
||||||
if len(sources) > 0:
|
if len(sources) > 0:
|
||||||
context_string = ""
|
context_string = ""
|
||||||
citation_idx = {}
|
citation_idx_map = {}
|
||||||
|
|
||||||
for source in sources:
|
for source in sources:
|
||||||
if "document" in source:
|
is_tool_result = source.get("tool_result", False)
|
||||||
for doc_context, doc_meta in zip(
|
|
||||||
|
if "document" in source and not is_tool_result:
|
||||||
|
for document_text, document_metadata in zip(
|
||||||
source["document"], source["metadata"]
|
source["document"], source["metadata"]
|
||||||
):
|
):
|
||||||
source_name = source.get("source", {}).get("name", None)
|
source_name = source.get("source", {}).get("name", None)
|
||||||
citation_id = (
|
source_id = (
|
||||||
doc_meta.get("source", None)
|
document_metadata.get("source", None)
|
||||||
or source.get("source", {}).get("id", None)
|
or source.get("source", {}).get("id", None)
|
||||||
or "N/A"
|
or "N/A"
|
||||||
)
|
)
|
||||||
if citation_id not in citation_idx:
|
|
||||||
citation_idx[citation_id] = len(citation_idx) + 1
|
if source_id not in citation_idx_map:
|
||||||
|
citation_idx_map[source_id] = len(citation_idx_map) + 1
|
||||||
|
|
||||||
context_string += (
|
context_string += (
|
||||||
f'<source id="{citation_idx[citation_id]}"'
|
f'<source id="{citation_idx_map[source_id]}"'
|
||||||
+ (f' name="{source_name}"' if source_name else "")
|
+ (f' name="{source_name}"' if source_name else "")
|
||||||
+ f">{doc_context}</source>\n"
|
+ f">{document_text}</source>\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
context_string = context_string.strip()
|
context_string = context_string.strip()
|
||||||
prompt = get_last_user_message(form_data["messages"])
|
|
||||||
|
|
||||||
|
prompt = get_last_user_message(form_data["messages"])
|
||||||
if prompt is None:
|
if prompt is None:
|
||||||
raise Exception("No user message found")
|
raise Exception("No user message found")
|
||||||
if (
|
|
||||||
request.app.state.config.RELEVANCE_THRESHOLD == 0
|
|
||||||
and context_string.strip() == ""
|
|
||||||
):
|
|
||||||
log.debug(
|
|
||||||
f"With a 0 relevancy threshold for RAG, the context cannot be empty"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Workaround for Ollama 2.0+ system prompt issue
|
if context_string == "":
|
||||||
# TODO: replace with add_or_update_system_message
|
if request.app.state.config.RELEVANCE_THRESHOLD == 0:
|
||||||
if model.get("owned_by") == "ollama":
|
log.debug(
|
||||||
form_data["messages"] = prepend_to_first_user_message_content(
|
f"With a 0 relevancy threshold for RAG, the context cannot be empty"
|
||||||
rag_template(
|
)
|
||||||
request.app.state.config.RAG_TEMPLATE, context_string, prompt
|
|
||||||
),
|
|
||||||
form_data["messages"],
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
form_data["messages"] = add_or_update_system_message(
|
# Workaround for Ollama 2.0+ system prompt issue
|
||||||
rag_template(
|
# TODO: replace with add_or_update_system_message
|
||||||
request.app.state.config.RAG_TEMPLATE, context_string, prompt
|
if model.get("owned_by") == "ollama":
|
||||||
),
|
form_data["messages"] = prepend_to_first_user_message_content(
|
||||||
form_data["messages"],
|
rag_template(
|
||||||
)
|
request.app.state.config.RAG_TEMPLATE, context_string, prompt
|
||||||
|
),
|
||||||
|
form_data["messages"],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
form_data["messages"] = add_or_update_system_message(
|
||||||
|
rag_template(
|
||||||
|
request.app.state.config.RAG_TEMPLATE, context_string, prompt
|
||||||
|
),
|
||||||
|
form_data["messages"],
|
||||||
|
)
|
||||||
|
|
||||||
# If there are citations, add them to the data_items
|
# If there are citations, add them to the data_items
|
||||||
sources = [
|
sources = [
|
||||||
|
|
@ -1370,7 +1396,7 @@ async def process_chat_response(
|
||||||
return len(backtick_segments) > 1 and len(backtick_segments) % 2 == 0
|
return len(backtick_segments) > 1 and len(backtick_segments) % 2 == 0
|
||||||
|
|
||||||
# Handle as a background task
|
# Handle as a background task
|
||||||
async def post_response_handler(response, events):
|
async def response_handler(response, events):
|
||||||
def serialize_content_blocks(content_blocks, raw=False):
|
def serialize_content_blocks(content_blocks, raw=False):
|
||||||
content = ""
|
content = ""
|
||||||
|
|
||||||
|
|
@ -1405,7 +1431,7 @@ async def process_chat_response(
|
||||||
break
|
break
|
||||||
|
|
||||||
if tool_result:
|
if tool_result:
|
||||||
tool_calls_display_content = f'{tool_calls_display_content}\n<details type="tool_calls" done="true" id="{tool_call_id}" name="{tool_name}" arguments="{html.escape(json.dumps(tool_arguments))}" result="{html.escape(json.dumps(tool_result))}" files="{html.escape(json.dumps(tool_result_files)) if tool_result_files else ""}">\n<summary>Tool Executed</summary>\n</details>\n'
|
tool_calls_display_content = f'{tool_calls_display_content}\n<details type="tool_calls" done="true" id="{tool_call_id}" name="{tool_name}" arguments="{html.escape(json.dumps(tool_arguments))}" result="{html.escape(json.dumps(tool_result, ensure_ascii=False))}" files="{html.escape(json.dumps(tool_result_files)) if tool_result_files else ""}">\n<summary>Tool Executed</summary>\n</details>\n'
|
||||||
else:
|
else:
|
||||||
tool_calls_display_content = f'{tool_calls_display_content}\n<details type="tool_calls" done="false" id="{tool_call_id}" name="{tool_name}" arguments="{html.escape(json.dumps(tool_arguments))}">\n<summary>Executing...</summary>\n</details>'
|
tool_calls_display_content = f'{tool_calls_display_content}\n<details type="tool_calls" done="false" id="{tool_call_id}" name="{tool_name}" arguments="{html.escape(json.dumps(tool_arguments))}">\n<summary>Executing...</summary>\n</details>'
|
||||||
|
|
||||||
|
|
@ -1741,7 +1767,7 @@ async def process_chat_response(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
async def stream_body_handler(response):
|
async def stream_body_handler(response, form_data):
|
||||||
nonlocal content
|
nonlocal content
|
||||||
nonlocal content_blocks
|
nonlocal content_blocks
|
||||||
|
|
||||||
|
|
@ -1770,7 +1796,7 @@ async def process_chat_response(
|
||||||
filter_functions=filter_functions,
|
filter_functions=filter_functions,
|
||||||
filter_type="stream",
|
filter_type="stream",
|
||||||
form_data=data,
|
form_data=data,
|
||||||
extra_params=extra_params,
|
extra_params={"__body__": form_data, **extra_params},
|
||||||
)
|
)
|
||||||
|
|
||||||
if data:
|
if data:
|
||||||
|
|
@ -2032,7 +2058,7 @@ async def process_chat_response(
|
||||||
if response.background:
|
if response.background:
|
||||||
await response.background()
|
await response.background()
|
||||||
|
|
||||||
await stream_body_handler(response)
|
await stream_body_handler(response, form_data)
|
||||||
|
|
||||||
MAX_TOOL_CALL_RETRIES = 10
|
MAX_TOOL_CALL_RETRIES = 10
|
||||||
tool_call_retries = 0
|
tool_call_retries = 0
|
||||||
|
|
@ -2148,7 +2174,9 @@ async def process_chat_response(
|
||||||
if isinstance(tool_result, dict) or isinstance(
|
if isinstance(tool_result, dict) or isinstance(
|
||||||
tool_result, list
|
tool_result, list
|
||||||
):
|
):
|
||||||
tool_result = json.dumps(tool_result, indent=2)
|
tool_result = json.dumps(
|
||||||
|
tool_result, indent=2, ensure_ascii=False
|
||||||
|
)
|
||||||
|
|
||||||
results.append(
|
results.append(
|
||||||
{
|
{
|
||||||
|
|
@ -2181,22 +2209,24 @@ async def process_chat_response(
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
new_form_data = {
|
||||||
|
"model": model_id,
|
||||||
|
"stream": True,
|
||||||
|
"tools": form_data["tools"],
|
||||||
|
"messages": [
|
||||||
|
*form_data["messages"],
|
||||||
|
*convert_content_blocks_to_messages(content_blocks),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
res = await generate_chat_completion(
|
res = await generate_chat_completion(
|
||||||
request,
|
request,
|
||||||
{
|
new_form_data,
|
||||||
"model": model_id,
|
|
||||||
"stream": True,
|
|
||||||
"tools": form_data["tools"],
|
|
||||||
"messages": [
|
|
||||||
*form_data["messages"],
|
|
||||||
*convert_content_blocks_to_messages(content_blocks),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
user,
|
user,
|
||||||
)
|
)
|
||||||
|
|
||||||
if isinstance(res, StreamingResponse):
|
if isinstance(res, StreamingResponse):
|
||||||
await stream_body_handler(res)
|
await stream_body_handler(res, new_form_data)
|
||||||
else:
|
else:
|
||||||
break
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -2211,6 +2241,7 @@ async def process_chat_response(
|
||||||
content_blocks[-1]["type"] == "code_interpreter"
|
content_blocks[-1]["type"] == "code_interpreter"
|
||||||
and retries < MAX_RETRIES
|
and retries < MAX_RETRIES
|
||||||
):
|
):
|
||||||
|
|
||||||
await event_emitter(
|
await event_emitter(
|
||||||
{
|
{
|
||||||
"type": "chat:completion",
|
"type": "chat:completion",
|
||||||
|
|
@ -2343,26 +2374,28 @@ async def process_chat_response(
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
new_form_data = {
|
||||||
|
"model": model_id,
|
||||||
|
"stream": True,
|
||||||
|
"messages": [
|
||||||
|
*form_data["messages"],
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": serialize_content_blocks(
|
||||||
|
content_blocks, raw=True
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
res = await generate_chat_completion(
|
res = await generate_chat_completion(
|
||||||
request,
|
request,
|
||||||
{
|
new_form_data,
|
||||||
"model": model_id,
|
|
||||||
"stream": True,
|
|
||||||
"messages": [
|
|
||||||
*form_data["messages"],
|
|
||||||
{
|
|
||||||
"role": "assistant",
|
|
||||||
"content": serialize_content_blocks(
|
|
||||||
content_blocks, raw=True
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
user,
|
user,
|
||||||
)
|
)
|
||||||
|
|
||||||
if isinstance(res, StreamingResponse):
|
if isinstance(res, StreamingResponse):
|
||||||
await stream_body_handler(res)
|
await stream_body_handler(res, new_form_data)
|
||||||
else:
|
else:
|
||||||
break
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -2427,9 +2460,11 @@ async def process_chat_response(
|
||||||
if response.background is not None:
|
if response.background is not None:
|
||||||
await response.background()
|
await response.background()
|
||||||
|
|
||||||
# background_tasks.add_task(post_response_handler, response, events)
|
# background_tasks.add_task(response_handler, response, events)
|
||||||
task_id, _ = await create_task(
|
task_id, _ = await create_task(
|
||||||
request, post_response_handler(response, events), id=metadata["chat_id"]
|
request.app.state.redis,
|
||||||
|
response_handler(response, events),
|
||||||
|
id=metadata["chat_id"],
|
||||||
)
|
)
|
||||||
return {"status": True, "task_id": task_id}
|
return {"status": True, "task_id": task_id}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -76,8 +76,19 @@ async def get_all_base_models(request: Request, user: UserModel = None):
|
||||||
return function_models + openai_models + ollama_models
|
return function_models + openai_models + ollama_models
|
||||||
|
|
||||||
|
|
||||||
async def get_all_models(request, user: UserModel = None):
|
async def get_all_models(request, refresh: bool = False, user: UserModel = None):
|
||||||
models = await get_all_base_models(request, user=user)
|
if (
|
||||||
|
request.app.state.MODELS
|
||||||
|
and request.app.state.BASE_MODELS
|
||||||
|
and (request.app.state.config.ENABLE_BASE_MODELS_CACHE and not refresh)
|
||||||
|
):
|
||||||
|
base_models = request.app.state.BASE_MODELS
|
||||||
|
else:
|
||||||
|
base_models = await get_all_base_models(request, user=user)
|
||||||
|
request.app.state.BASE_MODELS = base_models
|
||||||
|
|
||||||
|
# deep copy the base models to avoid modifying the original list
|
||||||
|
models = [model.copy() for model in base_models]
|
||||||
|
|
||||||
# If there are no models, return an empty list
|
# If there are no models, return an empty list
|
||||||
if len(models) == 0:
|
if len(models) == 0:
|
||||||
|
|
@ -137,6 +148,7 @@ async def get_all_models(request, user: UserModel = None):
|
||||||
custom_models = Models.get_all_models()
|
custom_models = Models.get_all_models()
|
||||||
for custom_model in custom_models:
|
for custom_model in custom_models:
|
||||||
if custom_model.base_model_id is None:
|
if custom_model.base_model_id is None:
|
||||||
|
# Applied directly to a base model
|
||||||
for model in models:
|
for model in models:
|
||||||
if custom_model.id == model["id"] or (
|
if custom_model.id == model["id"] or (
|
||||||
model.get("owned_by") == "ollama"
|
model.get("owned_by") == "ollama"
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,13 @@
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from opentelemetry import trace
|
from opentelemetry import trace
|
||||||
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
|
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
|
||||||
|
from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
|
||||||
|
OTLPSpanExporter as HttpOTLPSpanExporter,
|
||||||
|
)
|
||||||
from opentelemetry.sdk.resources import SERVICE_NAME, Resource
|
from opentelemetry.sdk.resources import SERVICE_NAME, Resource
|
||||||
from opentelemetry.sdk.trace import TracerProvider
|
from opentelemetry.sdk.trace import TracerProvider
|
||||||
from sqlalchemy import Engine
|
from sqlalchemy import Engine
|
||||||
|
from base64 import b64encode
|
||||||
|
|
||||||
from open_webui.utils.telemetry.exporters import LazyBatchSpanProcessor
|
from open_webui.utils.telemetry.exporters import LazyBatchSpanProcessor
|
||||||
from open_webui.utils.telemetry.instrumentors import Instrumentor
|
from open_webui.utils.telemetry.instrumentors import Instrumentor
|
||||||
|
|
@ -11,7 +15,11 @@ from open_webui.utils.telemetry.metrics import setup_metrics
|
||||||
from open_webui.env import (
|
from open_webui.env import (
|
||||||
OTEL_SERVICE_NAME,
|
OTEL_SERVICE_NAME,
|
||||||
OTEL_EXPORTER_OTLP_ENDPOINT,
|
OTEL_EXPORTER_OTLP_ENDPOINT,
|
||||||
|
OTEL_EXPORTER_OTLP_INSECURE,
|
||||||
ENABLE_OTEL_METRICS,
|
ENABLE_OTEL_METRICS,
|
||||||
|
OTEL_BASIC_AUTH_USERNAME,
|
||||||
|
OTEL_BASIC_AUTH_PASSWORD,
|
||||||
|
OTEL_OTLP_SPAN_EXPORTER,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -22,8 +30,27 @@ def setup(app: FastAPI, db_engine: Engine):
|
||||||
resource=Resource.create(attributes={SERVICE_NAME: OTEL_SERVICE_NAME})
|
resource=Resource.create(attributes={SERVICE_NAME: OTEL_SERVICE_NAME})
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Add basic auth header only if both username and password are not empty
|
||||||
|
headers = []
|
||||||
|
if OTEL_BASIC_AUTH_USERNAME and OTEL_BASIC_AUTH_PASSWORD:
|
||||||
|
auth_string = f"{OTEL_BASIC_AUTH_USERNAME}:{OTEL_BASIC_AUTH_PASSWORD}"
|
||||||
|
auth_header = b64encode(auth_string.encode()).decode()
|
||||||
|
headers = [("authorization", f"Basic {auth_header}")]
|
||||||
|
|
||||||
# otlp export
|
# otlp export
|
||||||
exporter = OTLPSpanExporter(endpoint=OTEL_EXPORTER_OTLP_ENDPOINT)
|
if OTEL_OTLP_SPAN_EXPORTER == "http":
|
||||||
|
exporter = HttpOTLPSpanExporter(
|
||||||
|
endpoint=OTEL_EXPORTER_OTLP_ENDPOINT,
|
||||||
|
insecure=OTEL_EXPORTER_OTLP_INSECURE,
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
exporter = OTLPSpanExporter(
|
||||||
|
endpoint=OTEL_EXPORTER_OTLP_ENDPOINT,
|
||||||
|
insecure=OTEL_EXPORTER_OTLP_INSECURE,
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
trace.get_tracer_provider().add_span_processor(LazyBatchSpanProcessor(exporter))
|
trace.get_tracer_provider().add_span_processor(LazyBatchSpanProcessor(exporter))
|
||||||
Instrumentor(app=app, db_engine=db_engine).instrument()
|
Instrumentor(app=app, db_engine=db_engine).instrument()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -101,9 +101,6 @@ def get_tools(
|
||||||
|
|
||||||
def make_tool_function(function_name, token, tool_server_data):
|
def make_tool_function(function_name, token, tool_server_data):
|
||||||
async def tool_function(**kwargs):
|
async def tool_function(**kwargs):
|
||||||
print(
|
|
||||||
f"Executing tool function {function_name} with params: {kwargs}"
|
|
||||||
)
|
|
||||||
return await execute_tool_server(
|
return await execute_tool_server(
|
||||||
token=token,
|
token=token,
|
||||||
url=tool_server_data["url"],
|
url=tool_server_data["url"],
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
fastapi==0.115.7
|
fastapi==0.115.7
|
||||||
uvicorn[standard]==0.34.2
|
uvicorn[standard]==0.35.0
|
||||||
pydantic==2.10.6
|
pydantic==2.11.7
|
||||||
python-multipart==0.0.20
|
python-multipart==0.0.20
|
||||||
|
|
||||||
python-socketio==5.13.0
|
python-socketio==5.13.0
|
||||||
python-jose==3.4.0
|
python-jose==3.4.0
|
||||||
passlib[bcrypt]==1.7.4
|
passlib[bcrypt]==1.7.4
|
||||||
|
cryptography
|
||||||
|
|
||||||
requests==2.32.4
|
requests==2.32.4
|
||||||
aiohttp==3.11.11
|
aiohttp==3.11.11
|
||||||
|
|
@ -30,6 +31,8 @@ boto3==1.35.53
|
||||||
argon2-cffi==23.1.0
|
argon2-cffi==23.1.0
|
||||||
APScheduler==3.10.4
|
APScheduler==3.10.4
|
||||||
|
|
||||||
|
pycrdt==0.12.25
|
||||||
|
|
||||||
RestrictedPython==8.0
|
RestrictedPython==8.0
|
||||||
|
|
||||||
loguru==0.7.3
|
loguru==0.7.3
|
||||||
|
|
@ -42,13 +45,13 @@ google-genai==1.15.0
|
||||||
google-generativeai==0.8.5
|
google-generativeai==0.8.5
|
||||||
tiktoken
|
tiktoken
|
||||||
|
|
||||||
langchain==0.3.24
|
langchain==0.3.26
|
||||||
langchain-community==0.3.23
|
langchain-community==0.3.26
|
||||||
|
|
||||||
fake-useragent==2.1.0
|
fake-useragent==2.1.0
|
||||||
chromadb==0.6.3
|
chromadb==0.6.3
|
||||||
pymilvus==2.5.0
|
pymilvus==2.5.0
|
||||||
qdrant-client~=1.12.0
|
qdrant-client==1.14.3
|
||||||
opensearch-py==2.8.0
|
opensearch-py==2.8.0
|
||||||
playwright==1.49.1 # Caution: version must match docker-compose.playwright.yaml
|
playwright==1.49.1 # Caution: version must match docker-compose.playwright.yaml
|
||||||
elasticsearch==9.0.1
|
elasticsearch==9.0.1
|
||||||
|
|
@ -99,7 +102,7 @@ youtube-transcript-api==1.1.0
|
||||||
pytube==15.0.0
|
pytube==15.0.0
|
||||||
|
|
||||||
pydub
|
pydub
|
||||||
duckduckgo-search==8.0.2
|
ddgs==9.0.0
|
||||||
|
|
||||||
## Google Drive
|
## Google Drive
|
||||||
google-api-python-client
|
google-api-python-client
|
||||||
|
|
@ -114,7 +117,7 @@ pytest-docker~=3.1.1
|
||||||
googleapis-common-protos==1.63.2
|
googleapis-common-protos==1.63.2
|
||||||
google-cloud-storage==2.19.0
|
google-cloud-storage==2.19.0
|
||||||
|
|
||||||
azure-identity==1.21.0
|
azure-identity==1.23.0
|
||||||
azure-storage-blob==12.24.1
|
azure-storage-blob==12.24.1
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ SET "WEBUI_SECRET_KEY=%WEBUI_SECRET_KEY%"
|
||||||
SET "WEBUI_JWT_SECRET_KEY=%WEBUI_JWT_SECRET_KEY%"
|
SET "WEBUI_JWT_SECRET_KEY=%WEBUI_JWT_SECRET_KEY%"
|
||||||
|
|
||||||
:: Check if WEBUI_SECRET_KEY and WEBUI_JWT_SECRET_KEY are not set
|
:: Check if WEBUI_SECRET_KEY and WEBUI_JWT_SECRET_KEY are not set
|
||||||
IF "%WEBUI_SECRET_KEY%%WEBUI_JWT_SECRET_KEY%" == " " (
|
IF "%WEBUI_SECRET_KEY% %WEBUI_JWT_SECRET_KEY%" == " " (
|
||||||
echo Loading WEBUI_SECRET_KEY from file, not provided as an environment variable.
|
echo Loading WEBUI_SECRET_KEY from file, not provided as an environment variable.
|
||||||
|
|
||||||
IF NOT EXIST "%KEY_FILE%" (
|
IF NOT EXIST "%KEY_FILE%" (
|
||||||
|
|
|
||||||
24
docker-compose.otel.yaml
Normal file
24
docker-compose.otel.yaml
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
services:
|
||||||
|
grafana:
|
||||||
|
image: grafana/otel-lgtm:latest
|
||||||
|
container_name: lgtm
|
||||||
|
ports:
|
||||||
|
- "3000:3000" # Grafana UI
|
||||||
|
- "4317:4317" # OTLP/gRPC
|
||||||
|
- "4318:4318" # OTLP/HTTP
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
open-webui:
|
||||||
|
image: ghcr.io/open-webui/open-webui:main
|
||||||
|
container_name: open-webui
|
||||||
|
depends_on: [grafana]
|
||||||
|
environment:
|
||||||
|
- ENABLE_OTEL=true
|
||||||
|
- OTEL_EXPORTER_OTLP_ENDPOINT=http://grafana:4317
|
||||||
|
- OTEL_SERVICE_NAME=open-webui
|
||||||
|
ports:
|
||||||
|
- "8088:8080"
|
||||||
|
networks: [default]
|
||||||
|
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
483
package-lock.json
generated
483
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "open-webui",
|
"name": "open-webui",
|
||||||
"version": "0.6.15",
|
"version": "0.6.16",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "open-webui",
|
"name": "open-webui",
|
||||||
"version": "0.6.15",
|
"version": "0.6.16",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@azure/msal-browser": "^4.5.0",
|
"@azure/msal-browser": "^4.5.0",
|
||||||
"@codemirror/lang-javascript": "^6.2.2",
|
"@codemirror/lang-javascript": "^6.2.2",
|
||||||
|
|
@ -19,19 +19,28 @@
|
||||||
"@sveltejs/adapter-node": "^2.0.0",
|
"@sveltejs/adapter-node": "^2.0.0",
|
||||||
"@sveltejs/svelte-virtual-list": "^3.0.1",
|
"@sveltejs/svelte-virtual-list": "^3.0.1",
|
||||||
"@tiptap/core": "^2.11.9",
|
"@tiptap/core": "^2.11.9",
|
||||||
|
"@tiptap/extension-bubble-menu": "^2.25.0",
|
||||||
|
"@tiptap/extension-character-count": "^2.25.0",
|
||||||
"@tiptap/extension-code-block-lowlight": "^2.11.9",
|
"@tiptap/extension-code-block-lowlight": "^2.11.9",
|
||||||
|
"@tiptap/extension-floating-menu": "^2.25.0",
|
||||||
"@tiptap/extension-highlight": "^2.10.0",
|
"@tiptap/extension-highlight": "^2.10.0",
|
||||||
|
"@tiptap/extension-history": "^2.25.1",
|
||||||
|
"@tiptap/extension-link": "^2.25.0",
|
||||||
"@tiptap/extension-placeholder": "^2.10.0",
|
"@tiptap/extension-placeholder": "^2.10.0",
|
||||||
"@tiptap/extension-table": "^2.12.0",
|
"@tiptap/extension-table": "^2.12.0",
|
||||||
"@tiptap/extension-table-cell": "^2.12.0",
|
"@tiptap/extension-table-cell": "^2.12.0",
|
||||||
"@tiptap/extension-table-header": "^2.12.0",
|
"@tiptap/extension-table-header": "^2.12.0",
|
||||||
"@tiptap/extension-table-row": "^2.12.0",
|
"@tiptap/extension-table-row": "^2.12.0",
|
||||||
|
"@tiptap/extension-task-item": "^2.25.0",
|
||||||
|
"@tiptap/extension-task-list": "^2.25.0",
|
||||||
"@tiptap/extension-typography": "^2.10.0",
|
"@tiptap/extension-typography": "^2.10.0",
|
||||||
|
"@tiptap/extension-underline": "^2.25.0",
|
||||||
"@tiptap/pm": "^2.11.7",
|
"@tiptap/pm": "^2.11.7",
|
||||||
"@tiptap/starter-kit": "^2.10.0",
|
"@tiptap/starter-kit": "^2.10.0",
|
||||||
"@xyflow/svelte": "^0.1.19",
|
"@xyflow/svelte": "^0.1.19",
|
||||||
"async": "^3.2.5",
|
"async": "^3.2.5",
|
||||||
"bits-ui": "^0.21.15",
|
"bits-ui": "^0.21.15",
|
||||||
|
"chart.js": "^4.5.0",
|
||||||
"codemirror": "^6.0.1",
|
"codemirror": "^6.0.1",
|
||||||
"codemirror-lang-elixir": "^4.0.0",
|
"codemirror-lang-elixir": "^4.0.0",
|
||||||
"codemirror-lang-hcl": "^0.1.0",
|
"codemirror-lang-hcl": "^0.1.0",
|
||||||
|
|
@ -42,9 +51,10 @@
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"focus-trap": "^7.6.4",
|
"focus-trap": "^7.6.4",
|
||||||
"fuse.js": "^7.0.0",
|
"fuse.js": "^7.0.0",
|
||||||
|
"heic2any": "^0.0.4",
|
||||||
"highlight.js": "^11.9.0",
|
"highlight.js": "^11.9.0",
|
||||||
"html-entities": "^2.5.3",
|
"html-entities": "^2.5.3",
|
||||||
"html2canvas-pro": "^1.5.8",
|
"html2canvas-pro": "^1.5.11",
|
||||||
"i18next": "^23.10.0",
|
"i18next": "^23.10.0",
|
||||||
"i18next-browser-languagedetector": "^7.2.0",
|
"i18next-browser-languagedetector": "^7.2.0",
|
||||||
"i18next-resources-to-backend": "^1.2.0",
|
"i18next-resources-to-backend": "^1.2.0",
|
||||||
|
|
@ -53,10 +63,13 @@
|
||||||
"jspdf": "^3.0.0",
|
"jspdf": "^3.0.0",
|
||||||
"katex": "^0.16.22",
|
"katex": "^0.16.22",
|
||||||
"kokoro-js": "^1.1.1",
|
"kokoro-js": "^1.1.1",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"marked": "^9.1.0",
|
"marked": "^9.1.0",
|
||||||
"mermaid": "^11.6.0",
|
"mermaid": "^11.6.0",
|
||||||
"paneforge": "^0.0.6",
|
"paneforge": "^0.0.6",
|
||||||
"panzoom": "^9.4.3",
|
"panzoom": "^9.4.3",
|
||||||
|
"pdfjs-dist": "^5.3.93",
|
||||||
|
"prosemirror-collab": "^1.3.1",
|
||||||
"prosemirror-commands": "^1.6.0",
|
"prosemirror-commands": "^1.6.0",
|
||||||
"prosemirror-example-setup": "^1.2.3",
|
"prosemirror-example-setup": "^1.2.3",
|
||||||
"prosemirror-history": "^1.4.1",
|
"prosemirror-history": "^1.4.1",
|
||||||
|
|
@ -70,7 +83,7 @@
|
||||||
"prosemirror-view": "^1.34.3",
|
"prosemirror-view": "^1.34.3",
|
||||||
"pyodide": "^0.27.3",
|
"pyodide": "^0.27.3",
|
||||||
"socket.io-client": "^4.2.0",
|
"socket.io-client": "^4.2.0",
|
||||||
"sortablejs": "^1.15.2",
|
"sortablejs": "^1.15.6",
|
||||||
"svelte-sonner": "^0.3.19",
|
"svelte-sonner": "^0.3.19",
|
||||||
"tippy.js": "^6.3.7",
|
"tippy.js": "^6.3.7",
|
||||||
"turndown": "^7.2.0",
|
"turndown": "^7.2.0",
|
||||||
|
|
@ -78,7 +91,9 @@
|
||||||
"undici": "^7.3.0",
|
"undici": "^7.3.0",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
"vite-plugin-static-copy": "^2.2.0",
|
"vite-plugin-static-copy": "^2.2.0",
|
||||||
"yaml": "^2.7.1"
|
"y-prosemirror": "^1.3.7",
|
||||||
|
"yaml": "^2.7.1",
|
||||||
|
"yjs": "^13.6.27"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-auto": "3.2.2",
|
"@sveltejs/adapter-auto": "3.2.2",
|
||||||
|
|
@ -1870,6 +1885,12 @@
|
||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@kurkle/color": {
|
||||||
|
"version": "0.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||||
|
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@lezer/common": {
|
"node_modules/@lezer/common": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.1.tgz",
|
||||||
|
|
@ -2066,6 +2087,191 @@
|
||||||
"resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz",
|
||||||
"integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="
|
"integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="
|
||||||
},
|
},
|
||||||
|
"node_modules/@napi-rs/canvas": {
|
||||||
|
"version": "0.1.73",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.73.tgz",
|
||||||
|
"integrity": "sha512-9iwPZrNlCK4rG+vWyDvyvGeYjck9MoP0NVQP6N60gqJNFA1GsN0imG05pzNsqfCvFxUxgiTYlR8ff0HC1HXJiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"workspaces": [
|
||||||
|
"e2e/*"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@napi-rs/canvas-android-arm64": "0.1.73",
|
||||||
|
"@napi-rs/canvas-darwin-arm64": "0.1.73",
|
||||||
|
"@napi-rs/canvas-darwin-x64": "0.1.73",
|
||||||
|
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.73",
|
||||||
|
"@napi-rs/canvas-linux-arm64-gnu": "0.1.73",
|
||||||
|
"@napi-rs/canvas-linux-arm64-musl": "0.1.73",
|
||||||
|
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.73",
|
||||||
|
"@napi-rs/canvas-linux-x64-gnu": "0.1.73",
|
||||||
|
"@napi-rs/canvas-linux-x64-musl": "0.1.73",
|
||||||
|
"@napi-rs/canvas-win32-x64-msvc": "0.1.73"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-android-arm64": {
|
||||||
|
"version": "0.1.73",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.73.tgz",
|
||||||
|
"integrity": "sha512-s8dMhfYIHVv7gz8BXg3Nb6cFi950Y0xH5R/sotNZzUVvU9EVqHfkqiGJ4UIqu+15UhqguT6mI3Bv1mhpRkmMQw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-darwin-arm64": {
|
||||||
|
"version": "0.1.73",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.73.tgz",
|
||||||
|
"integrity": "sha512-bLPCq8Yyq1vMdVdIpQAqmgf6VGUknk8e7NdSZXJJFOA9gxkJ1RGcHOwoXo7h0gzhHxSorg71hIxyxtwXpq10Rw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-darwin-x64": {
|
||||||
|
"version": "0.1.73",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.73.tgz",
|
||||||
|
"integrity": "sha512-GR1CcehDjdNYXN3bj8PIXcXfYLUUOQANjQpM+KNnmpRo7ojsuqPjT7ZVH+6zoG/aqRJWhiSo+ChQMRazZlRU9g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
|
||||||
|
"version": "0.1.73",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.73.tgz",
|
||||||
|
"integrity": "sha512-cM7F0kBJVFio0+U2iKSW4fWSfYQ8CPg4/DRZodSum/GcIyfB8+UPJSRM1BvvlcWinKLfX1zUYOwonZX9IFRRcw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
|
||||||
|
"version": "0.1.73",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.73.tgz",
|
||||||
|
"integrity": "sha512-PMWNrMON9uz9klz1B8ZY/RXepQSC5dxxHQTowfw93Tb3fLtWO5oNX2k9utw7OM4ypT9BUZUWJnDQ5bfuXc/EUQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
|
||||||
|
"version": "0.1.73",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.73.tgz",
|
||||||
|
"integrity": "sha512-lX0z2bNmnk1PGZ+0a9OZwI2lPPvWjRYzPqvEitXX7lspyLFrOzh2kcQiLL7bhyODN23QvfriqwYqp5GreSzVvA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
|
||||||
|
"version": "0.1.73",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.73.tgz",
|
||||||
|
"integrity": "sha512-QDQgMElwxAoADsSR3UYvdTTQk5XOyD9J5kq15Z8XpGwpZOZsSE0zZ/X1JaOtS2x+HEZL6z1S6MF/1uhZFZb5ig==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
|
||||||
|
"version": "0.1.73",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.73.tgz",
|
||||||
|
"integrity": "sha512-wbzLJrTalQrpyrU1YRrO6w6pdr5vcebbJa+Aut5QfTaW9eEmMb1WFG6l1V+cCa5LdHmRr8bsvl0nJDU/IYDsmw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-x64-musl": {
|
||||||
|
"version": "0.1.73",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.73.tgz",
|
||||||
|
"integrity": "sha512-xbfhYrUufoTAKvsEx2ZUN4jvACabIF0h1F5Ik1Rk4e/kQq6c+Dwa5QF0bGrfLhceLpzHT0pCMGMDeQKQrcUIyA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
|
||||||
|
"version": "0.1.73",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.73.tgz",
|
||||||
|
"integrity": "sha512-YQmHXBufFBdWqhx+ympeTPkMfs3RNxaOgWm59vyjpsub7Us07BwCcmu1N5kildhO8Fm0syoI2kHnzGkJBLSvsg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@nodelib/fs.scandir": {
|
"node_modules/@nodelib/fs.scandir": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||||
|
|
@ -2941,6 +3147,23 @@
|
||||||
"@tiptap/core": "^2.7.0"
|
"@tiptap/core": "^2.7.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tiptap/extension-bubble-menu": {
|
||||||
|
"version": "2.25.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.25.0.tgz",
|
||||||
|
"integrity": "sha512-BnbfQWRXJDDy9/x/0Atu2Nka5ZAMyXLDFqzSLMAXqXSQcG6CZRTSNRgOCnjpda6Hq2yCtq7l/YEoXkbHT1ZZdQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tippy.js": "^6.3.7"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0",
|
||||||
|
"@tiptap/pm": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tiptap/extension-bullet-list": {
|
"node_modules/@tiptap/extension-bullet-list": {
|
||||||
"version": "2.10.0",
|
"version": "2.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-2.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-2.10.0.tgz",
|
||||||
|
|
@ -2954,6 +3177,20 @@
|
||||||
"@tiptap/core": "^2.7.0"
|
"@tiptap/core": "^2.7.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tiptap/extension-character-count": {
|
||||||
|
"version": "2.25.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-character-count/-/extension-character-count-2.25.0.tgz",
|
||||||
|
"integrity": "sha512-F+4DxJFptbX3oioqNwS38zOTi6gH9CumV/ISeOIvr4ao7Iija3tNonGDsHhxD05njjbYNIp1OKsxtnzbWukgMA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0",
|
||||||
|
"@tiptap/pm": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tiptap/extension-code": {
|
"node_modules/@tiptap/extension-code": {
|
||||||
"version": "2.10.0",
|
"version": "2.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-2.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-2.10.0.tgz",
|
||||||
|
|
@ -3025,6 +3262,23 @@
|
||||||
"@tiptap/pm": "^2.7.0"
|
"@tiptap/pm": "^2.7.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tiptap/extension-floating-menu": {
|
||||||
|
"version": "2.25.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-2.25.0.tgz",
|
||||||
|
"integrity": "sha512-hPZ5SNpI14smTz4GpWQXTnxmeICINYiABSgXcsU5V66tik9OtxKwoCSR/gpU35esaAFUVRdjW7+sGkACLZD5AQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tippy.js": "^6.3.7"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0",
|
||||||
|
"@tiptap/pm": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tiptap/extension-gapcursor": {
|
"node_modules/@tiptap/extension-gapcursor": {
|
||||||
"version": "2.10.0",
|
"version": "2.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-2.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-2.10.0.tgz",
|
||||||
|
|
@ -3079,9 +3333,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tiptap/extension-history": {
|
"node_modules/@tiptap/extension-history": {
|
||||||
"version": "2.10.0",
|
"version": "2.25.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.25.1.tgz",
|
||||||
"integrity": "sha512-5aYOmxqaCnw7e7wmWqFZmkpYCxxDjEzFbgVI6WknqNwqeOizR4+YJf3aAt/lTbksLJe47XF+NBX51gOm/ZBCiw==",
|
"integrity": "sha512-ZoxxOAObk1U8H3d+XEG0MjccJN0ViGIKEZqnLUSswmVweYPdkJG2WF2pEif9hpwJONslvLTKa+f8jwK5LEnJLQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
|
|
@ -3119,6 +3373,23 @@
|
||||||
"@tiptap/core": "^2.7.0"
|
"@tiptap/core": "^2.7.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tiptap/extension-link": {
|
||||||
|
"version": "2.25.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-2.25.0.tgz",
|
||||||
|
"integrity": "sha512-jNd+1Fd7wiIbxlS51weBzyDtBEBSVzW0cgzdwOzBYQtPJueRyXNNVERksyinDuVgcfvEWgmNZUylgzu7mehnEg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"linkifyjs": "^4.2.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0",
|
||||||
|
"@tiptap/pm": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tiptap/extension-list-item": {
|
"node_modules/@tiptap/extension-list-item": {
|
||||||
"version": "2.10.0",
|
"version": "2.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.10.0.tgz",
|
||||||
|
|
@ -3238,6 +3509,33 @@
|
||||||
"@tiptap/core": "^2.7.0"
|
"@tiptap/core": "^2.7.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tiptap/extension-task-item": {
|
||||||
|
"version": "2.25.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-task-item/-/extension-task-item-2.25.0.tgz",
|
||||||
|
"integrity": "sha512-8F7Z7jbsyGrPLHQCn+n39zdqIgxwR1kJ1nL5ZwhEW3ZhJgkFF0WMJSv36mwIJwL08p8um/c6g72AYB/e8CD7eA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0",
|
||||||
|
"@tiptap/pm": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-task-list": {
|
||||||
|
"version": "2.25.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-task-list/-/extension-task-list-2.25.0.tgz",
|
||||||
|
"integrity": "sha512-2mASqp8MJ0dyc1OK6c8P7m/zwoVDv8PV+XsRR9O3tpIz/zjUVrOl0W4IndjUPBMa7cpJX8fGj8iC3DaRNpSMcg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tiptap/extension-text": {
|
"node_modules/@tiptap/extension-text": {
|
||||||
"version": "2.10.0",
|
"version": "2.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.10.0.tgz",
|
||||||
|
|
@ -3277,6 +3575,19 @@
|
||||||
"@tiptap/core": "^2.7.0"
|
"@tiptap/core": "^2.7.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tiptap/extension-underline": {
|
||||||
|
"version": "2.25.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-2.25.0.tgz",
|
||||||
|
"integrity": "sha512-RqXkWSMJyllfsDukugDzWEZfWRUOgcqzuMWC40BnuDUs4KgdRA0nhVUWJbLfUEmXI0UVqN5OwYTTAdhaiF7kjQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tiptap/pm": {
|
"node_modules/@tiptap/pm": {
|
||||||
"version": "2.11.7",
|
"version": "2.11.7",
|
||||||
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.11.7.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.11.7.tgz",
|
||||||
|
|
@ -4723,6 +5034,18 @@
|
||||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/chart.js": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@kurkle/color": "^0.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"pnpm": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/check-error": {
|
"node_modules/check-error": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz",
|
||||||
|
|
@ -7295,6 +7618,12 @@
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/heic2any": {
|
||||||
|
"version": "0.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/heic2any/-/heic2any-0.0.4.tgz",
|
||||||
|
"integrity": "sha512-3lLnZiDELfabVH87htnRolZ2iehX9zwpRyGNz22GKXIu0fznlblf0/ftppXKNqS26dqFSeqfIBhAmAj/uSp0cA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/heimdalljs": {
|
"node_modules/heimdalljs": {
|
||||||
"version": "0.2.6",
|
"version": "0.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/heimdalljs/-/heimdalljs-0.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/heimdalljs/-/heimdalljs-0.2.6.tgz",
|
||||||
|
|
@ -7379,9 +7708,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/html2canvas-pro": {
|
"node_modules/html2canvas-pro": {
|
||||||
"version": "1.5.8",
|
"version": "1.5.11",
|
||||||
"resolved": "https://registry.npmjs.org/html2canvas-pro/-/html2canvas-pro-1.5.8.tgz",
|
"resolved": "https://registry.npmjs.org/html2canvas-pro/-/html2canvas-pro-1.5.11.tgz",
|
||||||
"integrity": "sha512-bVGAU7IvhBwBlRAmX6QhekX8lsaxmYoF6zIwf/HNlHscjx+KN8jw/U4PQRYqeEVm9+m13hcS1l5ChJB9/e29Lw==",
|
"integrity": "sha512-W4pEeKLG8+9a54RDOSiEKq7gRXXDzt0ORMaLXX+l6a3urSKbmnkmyzcRDCtgTOzmHLaZTLG2wiTQMJqKLlSh3w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"css-line-break": "^2.1.0",
|
"css-line-break": "^2.1.0",
|
||||||
|
|
@ -7830,6 +8159,16 @@
|
||||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="
|
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="
|
||||||
},
|
},
|
||||||
|
"node_modules/isomorphic.js": {
|
||||||
|
"version": "0.2.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz",
|
||||||
|
"integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "GitHub Sponsors ❤",
|
||||||
|
"url": "https://github.com/sponsors/dmonad"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/isstream": {
|
"node_modules/isstream": {
|
||||||
"version": "0.1.2",
|
"version": "0.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz",
|
||||||
|
|
@ -8046,6 +8385,12 @@
|
||||||
"node": ">=10.13.0"
|
"node": ">=10.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/leaflet": {
|
||||||
|
"version": "1.9.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||||
|
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
"node_modules/levn": {
|
"node_modules/levn": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||||
|
|
@ -8068,6 +8413,27 @@
|
||||||
"@lezer/lr": "^1.3.0"
|
"@lezer/lr": "^1.3.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lib0": {
|
||||||
|
"version": "0.2.109",
|
||||||
|
"resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.109.tgz",
|
||||||
|
"integrity": "sha512-jP0gbnyW0kwlx1Atc4dcHkBbrVAkdHjuyHxtClUPYla7qCmwIif1qZ6vQeJdR5FrOVdn26HvQT0ko01rgW7/Xw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"isomorphic.js": "^0.2.4"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js",
|
||||||
|
"0gentesthtml": "bin/gentesthtml.js",
|
||||||
|
"0serve": "bin/0serve.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "GitHub Sponsors ❤",
|
||||||
|
"url": "https://github.com/sponsors/dmonad"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lightningcss": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.29.1",
|
"version": "1.29.1",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.29.1.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.29.1.tgz",
|
||||||
|
|
@ -8331,6 +8697,12 @@
|
||||||
"uc.micro": "^2.0.0"
|
"uc.micro": "^2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/linkifyjs": {
|
||||||
|
"version": "4.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.1.tgz",
|
||||||
|
"integrity": "sha512-DRSlB9DKVW04c4SUdGvKK5FR6be45lTU9M76JnngqPeeGDqPwYc0zdUErtsNVMtxPXgUWV4HbXbnC4sNyBxkYg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/listr2": {
|
"node_modules/listr2": {
|
||||||
"version": "3.14.0",
|
"version": "3.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/listr2/-/listr2-3.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/listr2/-/listr2-3.14.0.tgz",
|
||||||
|
|
@ -9352,6 +9724,18 @@
|
||||||
"node": "*"
|
"node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pdfjs-dist": {
|
||||||
|
"version": "5.3.93",
|
||||||
|
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.3.93.tgz",
|
||||||
|
"integrity": "sha512-w3fQKVL1oGn8FRyx5JUG5tnbblggDqyx2XzA5brsJ5hSuS+I0NdnJANhmeWKLjotdbPQucLBug5t0MeWr0AAdg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.16.0 || >=22.3.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@napi-rs/canvas": "^0.1.71"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pend": {
|
"node_modules/pend": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
|
||||||
|
|
@ -9994,10 +10378,10 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/pyodide": {
|
"node_modules/pyodide": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/pyodide/-/pyodide-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/pyodide/-/pyodide-0.27.7.tgz",
|
||||||
"integrity": "sha512-6NwKEbPk0M3Wic2T1TCZijgZH9VE4RkHp1VGljS1sou0NjGdsmY2R/fG5oLmdDkjTRMI1iW7WYaY9pofX8gg1g==",
|
"integrity": "sha512-RUSVJlhQdfWfgO9hVHCiXoG+nVZQRS5D9FzgpLJ/VcgGBLSAKoPL8kTiOikxbHQm1kRISeWUBdulEgO26qpSRA==",
|
||||||
"license": "Apache-2.0",
|
"license": "MPL-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ws": "^8.5.0"
|
"ws": "^8.5.0"
|
||||||
},
|
},
|
||||||
|
|
@ -11138,9 +11522,10 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sortablejs": {
|
"node_modules/sortablejs": {
|
||||||
"version": "1.15.2",
|
"version": "1.15.6",
|
||||||
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.2.tgz",
|
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.6.tgz",
|
||||||
"integrity": "sha512-FJF5jgdfvoKn1MAKSdGs33bIqLi3LmsgVTliuX6iITj834F+JRQZN90Z93yql8h0K2t0RwDPBmxwlbZfDcxNZA=="
|
"integrity": "sha512-aNfiuwMEpfBM/CN6LY0ibyhxPfPbyFeBTYJKCvzkJ2GkUpazIt3H+QIPAMHwqQ7tMKaHz1Qj+rJJCqljnf4p3A==",
|
||||||
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
|
|
@ -13111,6 +13496,51 @@
|
||||||
"node": ">=0.4"
|
"node": ">=0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/y-prosemirror": {
|
||||||
|
"version": "1.3.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/y-prosemirror/-/y-prosemirror-1.3.7.tgz",
|
||||||
|
"integrity": "sha512-NpM99WSdD4Fx4if5xOMDpPtU3oAmTSjlzh5U4353ABbRHl1HtAFUx6HlebLZfyFxXN9jzKMDkVbcRjqOZVkYQg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"lib0": "^0.2.109"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.0.0",
|
||||||
|
"npm": ">=8.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "GitHub Sponsors ❤",
|
||||||
|
"url": "https://github.com/sponsors/dmonad"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"prosemirror-model": "^1.7.1",
|
||||||
|
"prosemirror-state": "^1.2.3",
|
||||||
|
"prosemirror-view": "^1.9.10",
|
||||||
|
"y-protocols": "^1.0.1",
|
||||||
|
"yjs": "^13.5.38"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/y-protocols": {
|
||||||
|
"version": "1.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.6.tgz",
|
||||||
|
"integrity": "sha512-vHRF2L6iT3rwj1jub/K5tYcTT/mEYDUppgNPXwp8fmLpui9f7Yeq3OEtTLVF012j39QnV+KEQpNqoN7CWU7Y9Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"lib0": "^0.2.85"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.0.0",
|
||||||
|
"npm": ">=8.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "GitHub Sponsors ❤",
|
||||||
|
"url": "https://github.com/sponsors/dmonad"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"yjs": "^13.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
|
||||||
|
|
@ -13142,6 +13572,23 @@
|
||||||
"fd-slicer": "~1.1.0"
|
"fd-slicer": "~1.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/yjs": {
|
||||||
|
"version": "13.6.27",
|
||||||
|
"resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.27.tgz",
|
||||||
|
"integrity": "sha512-OIDwaflOaq4wC6YlPBy2L6ceKeKuF7DeTxx+jPzv1FHn9tCZ0ZwSRnUBxD05E3yed46fv/FWJbvR+Ud7x0L7zw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"lib0": "^0.2.99"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.0.0",
|
||||||
|
"npm": ">=8.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "GitHub Sponsors ❤",
|
||||||
|
"url": "https://github.com/sponsors/dmonad"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yocto-queue": {
|
"node_modules/yocto-queue": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||||
|
|
|
||||||
23
package.json
23
package.json
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "open-webui",
|
"name": "open-webui",
|
||||||
"version": "0.6.15",
|
"version": "0.6.16",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "npm run pyodide:fetch && vite dev --host",
|
"dev": "npm run pyodide:fetch && vite dev --host",
|
||||||
|
|
@ -63,19 +63,28 @@
|
||||||
"@sveltejs/adapter-node": "^2.0.0",
|
"@sveltejs/adapter-node": "^2.0.0",
|
||||||
"@sveltejs/svelte-virtual-list": "^3.0.1",
|
"@sveltejs/svelte-virtual-list": "^3.0.1",
|
||||||
"@tiptap/core": "^2.11.9",
|
"@tiptap/core": "^2.11.9",
|
||||||
|
"@tiptap/extension-bubble-menu": "^2.25.0",
|
||||||
|
"@tiptap/extension-character-count": "^2.25.0",
|
||||||
"@tiptap/extension-code-block-lowlight": "^2.11.9",
|
"@tiptap/extension-code-block-lowlight": "^2.11.9",
|
||||||
|
"@tiptap/extension-floating-menu": "^2.25.0",
|
||||||
"@tiptap/extension-highlight": "^2.10.0",
|
"@tiptap/extension-highlight": "^2.10.0",
|
||||||
|
"@tiptap/extension-history": "^2.25.1",
|
||||||
|
"@tiptap/extension-link": "^2.25.0",
|
||||||
"@tiptap/extension-placeholder": "^2.10.0",
|
"@tiptap/extension-placeholder": "^2.10.0",
|
||||||
"@tiptap/extension-table": "^2.12.0",
|
"@tiptap/extension-table": "^2.12.0",
|
||||||
"@tiptap/extension-table-cell": "^2.12.0",
|
"@tiptap/extension-table-cell": "^2.12.0",
|
||||||
"@tiptap/extension-table-header": "^2.12.0",
|
"@tiptap/extension-table-header": "^2.12.0",
|
||||||
"@tiptap/extension-table-row": "^2.12.0",
|
"@tiptap/extension-table-row": "^2.12.0",
|
||||||
|
"@tiptap/extension-task-item": "^2.25.0",
|
||||||
|
"@tiptap/extension-task-list": "^2.25.0",
|
||||||
"@tiptap/extension-typography": "^2.10.0",
|
"@tiptap/extension-typography": "^2.10.0",
|
||||||
|
"@tiptap/extension-underline": "^2.25.0",
|
||||||
"@tiptap/pm": "^2.11.7",
|
"@tiptap/pm": "^2.11.7",
|
||||||
"@tiptap/starter-kit": "^2.10.0",
|
"@tiptap/starter-kit": "^2.10.0",
|
||||||
"@xyflow/svelte": "^0.1.19",
|
"@xyflow/svelte": "^0.1.19",
|
||||||
"async": "^3.2.5",
|
"async": "^3.2.5",
|
||||||
"bits-ui": "^0.21.15",
|
"bits-ui": "^0.21.15",
|
||||||
|
"chart.js": "^4.5.0",
|
||||||
"codemirror": "^6.0.1",
|
"codemirror": "^6.0.1",
|
||||||
"codemirror-lang-elixir": "^4.0.0",
|
"codemirror-lang-elixir": "^4.0.0",
|
||||||
"codemirror-lang-hcl": "^0.1.0",
|
"codemirror-lang-hcl": "^0.1.0",
|
||||||
|
|
@ -86,9 +95,10 @@
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"focus-trap": "^7.6.4",
|
"focus-trap": "^7.6.4",
|
||||||
"fuse.js": "^7.0.0",
|
"fuse.js": "^7.0.0",
|
||||||
|
"heic2any": "^0.0.4",
|
||||||
"highlight.js": "^11.9.0",
|
"highlight.js": "^11.9.0",
|
||||||
"html-entities": "^2.5.3",
|
"html-entities": "^2.5.3",
|
||||||
"html2canvas-pro": "^1.5.8",
|
"html2canvas-pro": "^1.5.11",
|
||||||
"i18next": "^23.10.0",
|
"i18next": "^23.10.0",
|
||||||
"i18next-browser-languagedetector": "^7.2.0",
|
"i18next-browser-languagedetector": "^7.2.0",
|
||||||
"i18next-resources-to-backend": "^1.2.0",
|
"i18next-resources-to-backend": "^1.2.0",
|
||||||
|
|
@ -97,10 +107,13 @@
|
||||||
"jspdf": "^3.0.0",
|
"jspdf": "^3.0.0",
|
||||||
"katex": "^0.16.22",
|
"katex": "^0.16.22",
|
||||||
"kokoro-js": "^1.1.1",
|
"kokoro-js": "^1.1.1",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"marked": "^9.1.0",
|
"marked": "^9.1.0",
|
||||||
"mermaid": "^11.6.0",
|
"mermaid": "^11.6.0",
|
||||||
"paneforge": "^0.0.6",
|
"paneforge": "^0.0.6",
|
||||||
"panzoom": "^9.4.3",
|
"panzoom": "^9.4.3",
|
||||||
|
"pdfjs-dist": "^5.3.93",
|
||||||
|
"prosemirror-collab": "^1.3.1",
|
||||||
"prosemirror-commands": "^1.6.0",
|
"prosemirror-commands": "^1.6.0",
|
||||||
"prosemirror-example-setup": "^1.2.3",
|
"prosemirror-example-setup": "^1.2.3",
|
||||||
"prosemirror-history": "^1.4.1",
|
"prosemirror-history": "^1.4.1",
|
||||||
|
|
@ -114,7 +127,7 @@
|
||||||
"prosemirror-view": "^1.34.3",
|
"prosemirror-view": "^1.34.3",
|
||||||
"pyodide": "^0.27.3",
|
"pyodide": "^0.27.3",
|
||||||
"socket.io-client": "^4.2.0",
|
"socket.io-client": "^4.2.0",
|
||||||
"sortablejs": "^1.15.2",
|
"sortablejs": "^1.15.6",
|
||||||
"svelte-sonner": "^0.3.19",
|
"svelte-sonner": "^0.3.19",
|
||||||
"tippy.js": "^6.3.7",
|
"tippy.js": "^6.3.7",
|
||||||
"turndown": "^7.2.0",
|
"turndown": "^7.2.0",
|
||||||
|
|
@ -122,7 +135,9 @@
|
||||||
"undici": "^7.3.0",
|
"undici": "^7.3.0",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
"vite-plugin-static-copy": "^2.2.0",
|
"vite-plugin-static-copy": "^2.2.0",
|
||||||
"yaml": "^2.7.1"
|
"y-prosemirror": "^1.3.7",
|
||||||
|
"yaml": "^2.7.1",
|
||||||
|
"yjs": "^13.6.27"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.13.0 <=22.x.x",
|
"node": ">=18.13.0 <=22.x.x",
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,13 @@ license = { file = "LICENSE" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastapi==0.115.7",
|
"fastapi==0.115.7",
|
||||||
"uvicorn[standard]==0.34.2",
|
"uvicorn[standard]==0.34.2",
|
||||||
"pydantic==2.10.6",
|
"pydantic==2.11.7",
|
||||||
"python-multipart==0.0.20",
|
"python-multipart==0.0.20",
|
||||||
|
|
||||||
"python-socketio==5.13.0",
|
"python-socketio==5.13.0",
|
||||||
"python-jose==3.4.0",
|
"python-jose==3.4.0",
|
||||||
"passlib[bcrypt]==1.7.4",
|
"passlib[bcrypt]==1.7.4",
|
||||||
|
"cryptography",
|
||||||
|
|
||||||
"requests==2.32.4",
|
"requests==2.32.4",
|
||||||
"aiohttp==3.11.11",
|
"aiohttp==3.11.11",
|
||||||
|
|
@ -50,13 +51,13 @@ dependencies = [
|
||||||
"google-generativeai==0.8.5",
|
"google-generativeai==0.8.5",
|
||||||
"tiktoken",
|
"tiktoken",
|
||||||
|
|
||||||
"langchain==0.3.24",
|
"langchain==0.3.26",
|
||||||
"langchain-community==0.3.23",
|
"langchain-community==0.3.26",
|
||||||
|
|
||||||
"fake-useragent==2.1.0",
|
"fake-useragent==2.1.0",
|
||||||
"chromadb==0.6.3",
|
"chromadb==0.6.3",
|
||||||
"pymilvus==2.5.0",
|
"pymilvus==2.5.0",
|
||||||
"qdrant-client~=1.12.0",
|
"qdrant-client==1.14.3",
|
||||||
"opensearch-py==2.8.0",
|
"opensearch-py==2.8.0",
|
||||||
"playwright==1.49.1",
|
"playwright==1.49.1",
|
||||||
"elasticsearch==9.0.1",
|
"elasticsearch==9.0.1",
|
||||||
|
|
@ -106,7 +107,7 @@ dependencies = [
|
||||||
"pytube==15.0.0",
|
"pytube==15.0.0",
|
||||||
|
|
||||||
"pydub",
|
"pydub",
|
||||||
"duckduckgo-search==8.0.2",
|
"ddgs==9.0.0",
|
||||||
|
|
||||||
"google-api-python-client",
|
"google-api-python-client",
|
||||||
"google-auth-httplib2",
|
"google-auth-httplib2",
|
||||||
|
|
@ -138,7 +139,7 @@ requires-python = ">= 3.11, < 3.13.0a1"
|
||||||
dynamic = ["version"]
|
dynamic = ["version"]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Development Status :: 4 - Beta",
|
"Development Status :: 4 - Beta",
|
||||||
"License :: OSI Approved :: MIT License",
|
"License :: Other/Proprietary License",
|
||||||
"Programming Language :: Python :: 3",
|
"Programming Language :: Python :: 3",
|
||||||
"Programming Language :: Python :: 3.11",
|
"Programming Language :: Python :: 3.11",
|
||||||
"Programming Language :: Python :: 3.12",
|
"Programming Language :: Python :: 3.12",
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,8 @@ const packages = [
|
||||||
'tiktoken',
|
'tiktoken',
|
||||||
'seaborn',
|
'seaborn',
|
||||||
'pytz',
|
'pytz',
|
||||||
'black'
|
'black',
|
||||||
|
'openai'
|
||||||
];
|
];
|
||||||
|
|
||||||
import { loadPyodide } from 'pyodide';
|
import { loadPyodide } from 'pyodide';
|
||||||
|
|
@ -74,8 +75,8 @@ async function downloadPackages() {
|
||||||
console.log('Pyodide version mismatch, removing static/pyodide directory');
|
console.log('Pyodide version mismatch, removing static/pyodide directory');
|
||||||
await rmdir('static/pyodide', { recursive: true });
|
await rmdir('static/pyodide', { recursive: true });
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (err) {
|
||||||
console.log('Pyodide package not found, proceeding with download.');
|
console.log('Pyodide package not found, proceeding with download.', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
185
src/app.css
185
src/app.css
|
|
@ -65,19 +65,23 @@ textarea::placeholder {
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-prose {
|
.input-prose {
|
||||||
@apply prose dark:prose-invert prose-headings:font-semibold prose-hr:my-4 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
|
@apply prose dark:prose-invert prose-headings:font-semibold prose-hr:my-4 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-1 prose-img:my-1 prose-headings:my-2 prose-pre:my-0 prose-table:my-1 prose-blockquote:my-0 prose-ul:my-1 prose-ol:my-1 prose-li:my-0.5 whitespace-pre-line;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-prose-sm {
|
.input-prose-sm {
|
||||||
@apply prose dark:prose-invert prose-headings:font-semibold prose-hr:my-4 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line text-sm;
|
@apply prose dark:prose-invert prose-headings:font-medium prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg prose-hr:my-4 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-1 prose-img:my-1 prose-headings:my-2 prose-pre:my-0 prose-table:my-1 prose-blockquote:my-0 prose-ul:my-1 prose-ol:my-1 prose-li:my-1 whitespace-pre-line text-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-prose {
|
.markdown-prose {
|
||||||
@apply prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-semibold prose-hr:my-4 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
|
@apply prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-semibold prose-hr:my-4 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.markdown-prose-sm {
|
||||||
|
@apply text-sm prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-semibold prose-hr:my-2 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
.markdown-prose-xs {
|
.markdown-prose-xs {
|
||||||
@apply text-xs prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-semibold prose-hr:my-0 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
|
@apply text-xs prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-semibold prose-hr:my-0.5 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown a {
|
.markdown a {
|
||||||
|
|
@ -326,6 +330,138 @@ input[type='number'] {
|
||||||
@apply line-clamp-1 absolute;
|
@apply line-clamp-1 absolute;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tiptap ul[data-type='taskList'] {
|
||||||
|
list-style: none;
|
||||||
|
margin-left: 0;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
li {
|
||||||
|
align-items: start;
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
> label {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
user-select: none;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
> div {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* checked data-checked="true" */
|
||||||
|
|
||||||
|
li[data-checked='true'] {
|
||||||
|
> div {
|
||||||
|
opacity: 0.5;
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='checkbox'] {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul[data-type='taskList'] {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reset nested regular ul elements to default styling */
|
||||||
|
ul:not([data-type='taskList']) {
|
||||||
|
list-style: disc;
|
||||||
|
padding-left: 1rem;
|
||||||
|
|
||||||
|
li {
|
||||||
|
align-items: initial;
|
||||||
|
display: list-item;
|
||||||
|
|
||||||
|
label {
|
||||||
|
flex: initial;
|
||||||
|
margin-right: initial;
|
||||||
|
margin-top: initial;
|
||||||
|
user-select: initial;
|
||||||
|
display: initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
div {
|
||||||
|
flex: initial;
|
||||||
|
align-items: initial;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-prose .tiptap ul[data-type='taskList'] {
|
||||||
|
list-style: none;
|
||||||
|
margin-left: 0;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
li {
|
||||||
|
align-items: start;
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
> label {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
user-select: none;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
> div {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* checked data-checked="true" */
|
||||||
|
|
||||||
|
li[data-checked='true'] {
|
||||||
|
> div {
|
||||||
|
opacity: 0.5;
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='checkbox'] {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul[data-type='taskList'] {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reset nested regular ul elements to default styling */
|
||||||
|
ul:not([data-type='taskList']) {
|
||||||
|
list-style: disc;
|
||||||
|
padding-left: 1rem;
|
||||||
|
|
||||||
|
li {
|
||||||
|
align-items: initial;
|
||||||
|
display: list-item;
|
||||||
|
|
||||||
|
label {
|
||||||
|
flex: initial;
|
||||||
|
margin-right: initial;
|
||||||
|
margin-top: initial;
|
||||||
|
user-select: initial;
|
||||||
|
display: initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
div {
|
||||||
|
flex: initial;
|
||||||
|
align-items: initial;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
.ProseMirror p.is-editor-empty:first-child::before {
|
.ProseMirror p.is-editor-empty:first-child::before {
|
||||||
color: #757575;
|
color: #757575;
|
||||||
|
|
@ -339,21 +475,21 @@ input[type='number'] {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiptap > pre > code {
|
.tiptap pre > code {
|
||||||
border-radius: 0.4rem;
|
border-radius: 0.4rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
padding: 0.25em 0.3em;
|
padding: 0.25em 0.3em;
|
||||||
|
|
||||||
@apply dark:bg-gray-800 bg-gray-100;
|
@apply dark:bg-gray-800 bg-gray-50;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiptap > pre {
|
.tiptap pre {
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
font-family: 'JetBrainsMono', monospace;
|
font-family: 'JetBrainsMono', monospace;
|
||||||
margin: 1.5rem 0;
|
margin: 1.5rem 0;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
|
|
||||||
@apply dark:bg-gray-800 bg-gray-100;
|
@apply dark:bg-gray-800 bg-gray-50;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiptap p code {
|
.tiptap p code {
|
||||||
|
|
@ -362,7 +498,7 @@ input[type='number'] {
|
||||||
padding: 3px 8px;
|
padding: 3px 8px;
|
||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@apply rounded-md dark:bg-gray-800 bg-gray-100 mx-0.5;
|
@apply rounded-md dark:bg-gray-800 bg-gray-50 mx-0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Code styling */
|
/* Code styling */
|
||||||
|
|
@ -442,3 +578,36 @@ input[type='number'] {
|
||||||
.tiptap tr {
|
.tiptap tr {
|
||||||
@apply bg-white dark:bg-gray-900 dark:border-gray-850 text-xs;
|
@apply bg-white dark:bg-gray-900 dark:border-gray-850 text-xs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tippy-box[data-theme~='transparent'] {
|
||||||
|
@apply bg-transparent p-0 m-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* this is a rough fix for the first cursor position when the first paragraph is empty */
|
||||||
|
.ProseMirror > .ProseMirror-yjs-cursor:first-child {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
/* This gives the remote user caret. The colors are automatically overwritten*/
|
||||||
|
.ProseMirror-yjs-cursor {
|
||||||
|
position: relative;
|
||||||
|
margin-left: -1px;
|
||||||
|
margin-right: -1px;
|
||||||
|
border-left: 1px solid black;
|
||||||
|
border-right: 1px solid black;
|
||||||
|
border-color: orange;
|
||||||
|
word-break: normal;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
/* This renders the username above the caret */
|
||||||
|
.ProseMirror-yjs-cursor > div {
|
||||||
|
position: absolute;
|
||||||
|
top: -1.05em;
|
||||||
|
left: -1px;
|
||||||
|
font-size: 13px;
|
||||||
|
background-color: rgb(250, 129, 0);
|
||||||
|
user-select: none;
|
||||||
|
color: white;
|
||||||
|
padding-left: 2px;
|
||||||
|
padding-right: 2px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
|
||||||
43
src/app.html
43
src/app.html
|
|
@ -77,28 +77,18 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
const isDarkMode = document.documentElement.classList.contains('dark');
|
||||||
|
|
||||||
function setSplashImage() {
|
const logo = document.createElement('img');
|
||||||
const logo = document.getElementById('logo');
|
logo.id = 'logo';
|
||||||
const isDarkMode = document.documentElement.classList.contains('dark');
|
logo.style =
|
||||||
|
'position: absolute; width: auto; height: 6rem; top: 44%; left: 50%; transform: translateX(-50%); display:block;';
|
||||||
|
logo.src = isDarkMode ? '/static/splash-dark.png' : '/static/splash.png';
|
||||||
|
|
||||||
if (isDarkMode) {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
const darkImage = new Image();
|
const splash = document.getElementById('splash-screen');
|
||||||
darkImage.src = '/static/splash-dark.png';
|
if (splash) splash.prepend(logo);
|
||||||
|
});
|
||||||
darkImage.onload = () => {
|
|
||||||
logo.src = '/static/splash-dark.png';
|
|
||||||
logo.style.filter = ''; // Ensure no inversion is applied if splash-dark.png exists
|
|
||||||
};
|
|
||||||
|
|
||||||
darkImage.onerror = () => {
|
|
||||||
logo.style.filter = 'invert(1)'; // Invert image if splash-dark.png is missing
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Runs after classes are assigned
|
|
||||||
window.onload = setSplashImage;
|
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -120,19 +110,6 @@
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<img
|
|
||||||
id="logo"
|
|
||||||
style="
|
|
||||||
position: absolute;
|
|
||||||
width: auto;
|
|
||||||
height: 6rem;
|
|
||||||
top: 44%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
"
|
|
||||||
src="/static/splash.png"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
style="
|
style="
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
|
||||||
|
|
@ -347,6 +347,8 @@ export const userSignOut = async () => {
|
||||||
if (error) {
|
if (error) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sessionStorage.clear();
|
||||||
return res;
|
return res;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { WEBUI_API_BASE_URL } from '$lib/constants';
|
import { WEBUI_API_BASE_URL } from '$lib/constants';
|
||||||
import { getTimeRange } from '$lib/utils';
|
import { getTimeRange } from '$lib/utils';
|
||||||
|
|
||||||
export const createNewChat = async (token: string, chat: object) => {
|
export const createNewChat = async (token: string, chat: object, folderId: string | null) => {
|
||||||
let error = null;
|
let error = null;
|
||||||
|
|
||||||
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/new`, {
|
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/new`, {
|
||||||
|
|
@ -12,7 +12,8 @@ export const createNewChat = async (token: string, chat: object) => {
|
||||||
authorization: `Bearer ${token}`
|
authorization: `Bearer ${token}`
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
chat: chat
|
chat: chat,
|
||||||
|
folder_id: folderId ?? null
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.then(async (res) => {
|
.then(async (res) => {
|
||||||
|
|
@ -37,7 +38,9 @@ export const importChat = async (
|
||||||
chat: object,
|
chat: object,
|
||||||
meta: object | null,
|
meta: object | null,
|
||||||
pinned?: boolean,
|
pinned?: boolean,
|
||||||
folderId?: string | null
|
folderId?: string | null,
|
||||||
|
createdAt: number | null = null,
|
||||||
|
updatedAt: number | null = null
|
||||||
) => {
|
) => {
|
||||||
let error = null;
|
let error = null;
|
||||||
|
|
||||||
|
|
@ -52,7 +55,9 @@ export const importChat = async (
|
||||||
chat: chat,
|
chat: chat,
|
||||||
meta: meta ?? {},
|
meta: meta ?? {},
|
||||||
pinned: pinned,
|
pinned: pinned,
|
||||||
folder_id: folderId
|
folder_id: folderId,
|
||||||
|
created_at: createdAt ?? null,
|
||||||
|
updated_at: updatedAt ?? null
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.then(async (res) => {
|
.then(async (res) => {
|
||||||
|
|
|
||||||
|
|
@ -58,10 +58,10 @@ export const exportConfig = async (token: string) => {
|
||||||
return res;
|
return res;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getDirectConnectionsConfig = async (token: string) => {
|
export const getConnectionsConfig = async (token: string) => {
|
||||||
let error = null;
|
let error = null;
|
||||||
|
|
||||||
const res = await fetch(`${WEBUI_API_BASE_URL}/configs/direct_connections`, {
|
const res = await fetch(`${WEBUI_API_BASE_URL}/configs/connections`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|
@ -85,10 +85,10 @@ export const getDirectConnectionsConfig = async (token: string) => {
|
||||||
return res;
|
return res;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const setDirectConnectionsConfig = async (token: string, config: object) => {
|
export const setConnectionsConfig = async (token: string, config: object) => {
|
||||||
let error = null;
|
let error = null;
|
||||||
|
|
||||||
const res = await fetch(`${WEBUI_API_BASE_URL}/configs/direct_connections`, {
|
const res = await fetch(`${WEBUI_API_BASE_URL}/configs/connections`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,12 @@ export const getFolderById = async (token: string, id: string) => {
|
||||||
return res;
|
return res;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateFolderNameById = async (token: string, id: string, name: string) => {
|
type FolderForm = {
|
||||||
|
name: string;
|
||||||
|
data?: Record<string, any>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateFolderById = async (token: string, id: string, folderForm: FolderForm) => {
|
||||||
let error = null;
|
let error = null;
|
||||||
|
|
||||||
const res = await fetch(`${WEBUI_API_BASE_URL}/folders/${id}/update`, {
|
const res = await fetch(`${WEBUI_API_BASE_URL}/folders/${id}/update`, {
|
||||||
|
|
@ -102,9 +107,7 @@ export const updateFolderNameById = async (token: string, id: string, name: stri
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
authorization: `Bearer ${token}`
|
authorization: `Bearer ${token}`
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(folderForm)
|
||||||
name: name
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
.then(async (res) => {
|
.then(async (res) => {
|
||||||
if (!res.ok) throw await res.json();
|
if (!res.ok) throw await res.json();
|
||||||
|
|
|
||||||
|
|
@ -8,17 +8,26 @@ import { toast } from 'svelte-sonner';
|
||||||
export const getModels = async (
|
export const getModels = async (
|
||||||
token: string = '',
|
token: string = '',
|
||||||
connections: object | null = null,
|
connections: object | null = null,
|
||||||
base: boolean = false
|
base: boolean = false,
|
||||||
|
refresh: boolean = false
|
||||||
) => {
|
) => {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
if (refresh) {
|
||||||
|
searchParams.append('refresh', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
let error = null;
|
let error = null;
|
||||||
const res = await fetch(`${WEBUI_BASE_URL}/api/models${base ? '/base' : ''}`, {
|
const res = await fetch(
|
||||||
method: 'GET',
|
`${WEBUI_BASE_URL}/api/models${base ? '/base' : ''}?${searchParams.toString()}`,
|
||||||
headers: {
|
{
|
||||||
Accept: 'application/json',
|
method: 'GET',
|
||||||
'Content-Type': 'application/json',
|
headers: {
|
||||||
...(token && { authorization: `Bearer ${token}` })
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token && { authorization: `Bearer ${token}` })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
.then(async (res) => {
|
.then(async (res) => {
|
||||||
if (!res.ok) throw await res.json();
|
if (!res.ok) throw await res.json();
|
||||||
return res.json();
|
return res.json();
|
||||||
|
|
@ -1587,6 +1596,7 @@ export interface ModelConfig {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModelMeta {
|
export interface ModelMeta {
|
||||||
|
toolIds: never[];
|
||||||
description?: string;
|
description?: string;
|
||||||
capabilities?: object;
|
capabilities?: object;
|
||||||
profile_image_url?: string;
|
profile_image_url?: string;
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ export const createNewNote = async (token: string, note: NoteItem) => {
|
||||||
return res;
|
return res;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getNotes = async (token: string = '') => {
|
export const getNotes = async (token: string = '', raw: boolean = false) => {
|
||||||
let error = null;
|
let error = null;
|
||||||
|
|
||||||
const res = await fetch(`${WEBUI_API_BASE_URL}/notes/`, {
|
const res = await fetch(`${WEBUI_API_BASE_URL}/notes/`, {
|
||||||
|
|
@ -67,6 +67,10 @@ export const getNotes = async (token: string = '') => {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (raw) {
|
||||||
|
return res; // Return raw response if requested
|
||||||
|
}
|
||||||
|
|
||||||
if (!Array.isArray(res)) {
|
if (!Array.isArray(res)) {
|
||||||
return {}; // or throw new Error("Notes response is not an array")
|
return {}; // or throw new Error("Notes response is not an array")
|
||||||
}
|
}
|
||||||
|
|
@ -87,6 +91,37 @@ export const getNotes = async (token: string = '') => {
|
||||||
return grouped;
|
return grouped;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getNoteList = async (token: string = '') => {
|
||||||
|
let error = null;
|
||||||
|
|
||||||
|
const res = await fetch(`${WEBUI_API_BASE_URL}/notes/list`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
authorization: `Bearer ${token}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(async (res) => {
|
||||||
|
if (!res.ok) throw await res.json();
|
||||||
|
return res.json();
|
||||||
|
})
|
||||||
|
.then((json) => {
|
||||||
|
return json;
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
error = err.detail;
|
||||||
|
console.error(err);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
export const getNoteById = async (token: string, id: string) => {
|
export const getNoteById = async (token: string, id: string) => {
|
||||||
let error = null;
|
let error = null;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -366,7 +366,7 @@ export const unloadModel = async (token: string, tagName: string) => {
|
||||||
Authorization: `Bearer ${token}`
|
Authorization: `Bearer ${token}`
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name: tagName
|
model: tagName
|
||||||
})
|
})
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
error = err;
|
error = err;
|
||||||
|
|
@ -419,7 +419,7 @@ export const deleteModel = async (token: string, tagName: string, urlIdx: string
|
||||||
Authorization: `Bearer ${token}`
|
Authorization: `Bearer ${token}`
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name: tagName
|
model: tagName
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -403,6 +403,7 @@ export const deleteUserById = async (token: string, userId: string) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
type UserUpdateForm = {
|
type UserUpdateForm = {
|
||||||
|
role: string;
|
||||||
profile_image_url: string;
|
profile_image_url: string;
|
||||||
email: string;
|
email: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@
|
||||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||||
import Switch from '$lib/components/common/Switch.svelte';
|
import Switch from '$lib/components/common/Switch.svelte';
|
||||||
import Tags from './common/Tags.svelte';
|
import Tags from './common/Tags.svelte';
|
||||||
|
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||||
|
import XMark from '$lib/components/icons/XMark.svelte';
|
||||||
|
|
||||||
export let onSubmit: Function = () => {};
|
export let onSubmit: Function = () => {};
|
||||||
export let onDelete: Function = () => {};
|
export let onDelete: Function = () => {};
|
||||||
|
|
@ -208,17 +210,7 @@
|
||||||
show = false;
|
show = false;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg
|
<XMark className={'size-5'} />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="currentColor"
|
|
||||||
aria-hidden="true"
|
|
||||||
class="w-5 h-5"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -524,29 +516,7 @@
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="ml-2 self-center">
|
<div class="ml-2 self-center">
|
||||||
<svg
|
<Spinner />
|
||||||
class=" w-4 h-4"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="currentColor"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
><style>
|
|
||||||
.spinner_ajPY {
|
|
||||||
transform-origin: center;
|
|
||||||
animation: spinner_AtaB 0.75s infinite linear;
|
|
||||||
}
|
|
||||||
@keyframes spinner_AtaB {
|
|
||||||
100% {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style><path
|
|
||||||
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
|
|
||||||
opacity=".25"
|
|
||||||
/><path
|
|
||||||
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
|
|
||||||
class="spinner_ajPY"
|
|
||||||
/></svg
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { getContext, onMount } from 'svelte';
|
import { getContext, onMount } from 'svelte';
|
||||||
const i18n = getContext('i18n');
|
const i18n = getContext('i18n');
|
||||||
|
|
||||||
|
import { settings } from '$lib/stores';
|
||||||
import Modal from '$lib/components/common/Modal.svelte';
|
import Modal from '$lib/components/common/Modal.svelte';
|
||||||
import Plus from '$lib/components/icons/Plus.svelte';
|
import Plus from '$lib/components/icons/Plus.svelte';
|
||||||
import Minus from '$lib/components/icons/Minus.svelte';
|
import Minus from '$lib/components/icons/Minus.svelte';
|
||||||
|
|
@ -14,6 +15,8 @@
|
||||||
import { getToolServerData } from '$lib/apis';
|
import { getToolServerData } from '$lib/apis';
|
||||||
import { verifyToolServerConnection } from '$lib/apis/configs';
|
import { verifyToolServerConnection } from '$lib/apis/configs';
|
||||||
import AccessControl from './workspace/common/AccessControl.svelte';
|
import AccessControl from './workspace/common/AccessControl.svelte';
|
||||||
|
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||||
|
import XMark from '$lib/components/icons/XMark.svelte';
|
||||||
|
|
||||||
export let onSubmit: Function = () => {};
|
export let onSubmit: Function = () => {};
|
||||||
export let onDelete: Function = () => {};
|
export let onDelete: Function = () => {};
|
||||||
|
|
@ -153,29 +156,21 @@
|
||||||
<Modal size="sm" bind:show>
|
<Modal size="sm" bind:show>
|
||||||
<div>
|
<div>
|
||||||
<div class=" flex justify-between dark:text-gray-100 px-5 pt-4 pb-2">
|
<div class=" flex justify-between dark:text-gray-100 px-5 pt-4 pb-2">
|
||||||
<div class=" text-lg font-medium self-center font-primary">
|
<h1 class=" text-lg font-medium self-center font-primary">
|
||||||
{#if edit}
|
{#if edit}
|
||||||
{$i18n.t('Edit Connection')}
|
{$i18n.t('Edit Connection')}
|
||||||
{:else}
|
{:else}
|
||||||
{$i18n.t('Add Connection')}
|
{$i18n.t('Add Connection')}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</h1>
|
||||||
<button
|
<button
|
||||||
class="self-center"
|
class="self-center"
|
||||||
|
aria-label={$i18n.t('Close Configure Connection Modal')}
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
show = false;
|
show = false;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg
|
<XMark className={'size-5'} />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="currentColor"
|
|
||||||
class="w-5 h-5"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -192,12 +187,17 @@
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<div class="flex flex-col w-full">
|
<div class="flex flex-col w-full">
|
||||||
<div class="flex justify-between mb-0.5">
|
<div class="flex justify-between mb-0.5">
|
||||||
<div class=" text-xs text-gray-500">{$i18n.t('URL')}</div>
|
<label
|
||||||
|
for="api-base-url"
|
||||||
|
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||||
|
>{$i18n.t('URL')}</label
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-1 items-center">
|
<div class="flex flex-1 items-center">
|
||||||
<input
|
<input
|
||||||
class="w-full flex-1 text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
|
id="api-base-url"
|
||||||
|
class={`w-full flex-1 text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={url}
|
bind:value={url}
|
||||||
placeholder={$i18n.t('API Base URL')}
|
placeholder={$i18n.t('API Base URL')}
|
||||||
|
|
@ -214,6 +214,7 @@
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
verifyHandler();
|
verifyHandler();
|
||||||
}}
|
}}
|
||||||
|
aria-label={$i18n.t('Verify Connection')}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
|
|
@ -221,6 +222,7 @@
|
||||||
viewBox="0 0 20 20"
|
viewBox="0 0 20 20"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
class="w-4 h-4"
|
class="w-4 h-4"
|
||||||
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
fill-rule="evenodd"
|
fill-rule="evenodd"
|
||||||
|
|
@ -237,9 +239,13 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 flex items-center">
|
<div class="flex-1 flex items-center">
|
||||||
|
<label for="url-or-path" class="sr-only"
|
||||||
|
>{$i18n.t('openapi.json URL or Path')}</label
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
class="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
|
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||||
type="text"
|
type="text"
|
||||||
|
id="url-or-path"
|
||||||
bind:value={path}
|
bind:value={path}
|
||||||
placeholder={$i18n.t('openapi.json URL or Path')}
|
placeholder={$i18n.t('openapi.json URL or Path')}
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
|
|
@ -249,7 +255,9 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-xs text-gray-500 mt-1">
|
<div
|
||||||
|
class={`text-xs mt-1 ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||||
|
>
|
||||||
{$i18n.t(`WebUI will make requests to "{{url}}"`, {
|
{$i18n.t(`WebUI will make requests to "{{url}}"`, {
|
||||||
url: path.includes('://') ? path : `${url}${path.startsWith('/') ? '' : '/'}${path}`
|
url: path.includes('://') ? path : `${url}${path.startsWith('/') ? '' : '/'}${path}`
|
||||||
})}
|
})}
|
||||||
|
|
@ -257,12 +265,17 @@
|
||||||
|
|
||||||
<div class="flex gap-2 mt-2">
|
<div class="flex gap-2 mt-2">
|
||||||
<div class="flex flex-col w-full">
|
<div class="flex flex-col w-full">
|
||||||
<div class=" text-xs text-gray-500">{$i18n.t('Auth')}</div>
|
<label
|
||||||
|
for="select-bearer-or-session"
|
||||||
|
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||||
|
>{$i18n.t('Auth')}</label
|
||||||
|
>
|
||||||
|
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<div class="flex-shrink-0 self-start">
|
<div class="flex-shrink-0 self-start">
|
||||||
<select
|
<select
|
||||||
class="w-full text-sm bg-transparent dark:bg-gray-900 placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden pr-5"
|
id="select-bearer-or-session"
|
||||||
|
class={`w-full text-sm bg-transparent pr-5 ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||||
bind:value={auth_type}
|
bind:value={auth_type}
|
||||||
>
|
>
|
||||||
<option value="bearer">Bearer</option>
|
<option value="bearer">Bearer</option>
|
||||||
|
|
@ -273,13 +286,14 @@
|
||||||
<div class="flex flex-1 items-center">
|
<div class="flex flex-1 items-center">
|
||||||
{#if auth_type === 'bearer'}
|
{#if auth_type === 'bearer'}
|
||||||
<SensitiveInput
|
<SensitiveInput
|
||||||
className="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
|
|
||||||
bind:value={key}
|
bind:value={key}
|
||||||
placeholder={$i18n.t('API Key')}
|
placeholder={$i18n.t('API Key')}
|
||||||
required={false}
|
required={false}
|
||||||
/>
|
/>
|
||||||
{:else if auth_type === 'session'}
|
{:else if auth_type === 'session'}
|
||||||
<div class="text-xs text-gray-500 self-center translate-y-[1px]">
|
<div
|
||||||
|
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||||
|
>
|
||||||
{$i18n.t('Forwards system user session credentials to authenticate')}
|
{$i18n.t('Forwards system user session credentials to authenticate')}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -293,11 +307,16 @@
|
||||||
|
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<div class="flex flex-col w-full">
|
<div class="flex flex-col w-full">
|
||||||
<div class=" mb-0.5 text-xs text-gray-500">{$i18n.t('Name')}</div>
|
<label
|
||||||
|
for="enter-name"
|
||||||
|
class={`mb-0.5 text-xs" ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||||
|
>{$i18n.t('Name')}</label
|
||||||
|
>
|
||||||
|
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<input
|
<input
|
||||||
class="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
|
id="enter-name"
|
||||||
|
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={name}
|
bind:value={name}
|
||||||
placeholder={$i18n.t('Enter name')}
|
placeholder={$i18n.t('Enter name')}
|
||||||
|
|
@ -309,11 +328,16 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col w-full mt-2">
|
<div class="flex flex-col w-full mt-2">
|
||||||
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Description')}</div>
|
<label
|
||||||
|
for="description"
|
||||||
|
class={`mb-1 text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100 placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700 text-gray-500'}`}
|
||||||
|
>{$i18n.t('Description')}</label
|
||||||
|
>
|
||||||
|
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<input
|
<input
|
||||||
class="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
|
id="description"
|
||||||
|
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={description}
|
bind:value={description}
|
||||||
placeholder={$i18n.t('Enter description')}
|
placeholder={$i18n.t('Enter description')}
|
||||||
|
|
@ -357,29 +381,7 @@
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="ml-2 self-center">
|
<div class="ml-2 self-center">
|
||||||
<svg
|
<Spinner />
|
||||||
class=" w-4 h-4"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="currentColor"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
><style>
|
|
||||||
.spinner_ajPY {
|
|
||||||
transform-origin: center;
|
|
||||||
animation: spinner_AtaB 0.75s infinite linear;
|
|
||||||
}
|
|
||||||
@keyframes spinner_AtaB {
|
|
||||||
100% {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style><path
|
|
||||||
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
|
|
||||||
opacity=".25"
|
|
||||||
/><path
|
|
||||||
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
|
|
||||||
class="spinner_ajPY"
|
|
||||||
/></svg
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
|
|
||||||
import Modal from './common/Modal.svelte';
|
import Modal from './common/Modal.svelte';
|
||||||
import { updateUserSettings } from '$lib/apis/users';
|
import { updateUserSettings } from '$lib/apis/users';
|
||||||
|
import XMark from '$lib/components/icons/XMark.svelte';
|
||||||
|
|
||||||
const i18n = getContext('i18n');
|
const i18n = getContext('i18n');
|
||||||
|
|
||||||
|
|
@ -36,18 +37,11 @@
|
||||||
localStorage.version = $config.version;
|
localStorage.version = $config.version;
|
||||||
show = false;
|
show = false;
|
||||||
}}
|
}}
|
||||||
|
aria-label={$i18n.t('Close')}
|
||||||
>
|
>
|
||||||
<svg
|
<XMark className={'size-5'}>
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="currentColor"
|
|
||||||
class="w-5 h-5"
|
|
||||||
>
|
|
||||||
<p class="sr-only">{$i18n.t('Close')}</p>
|
<p class="sr-only">{$i18n.t('Close')}</p>
|
||||||
<path
|
</XMark>
|
||||||
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center mt-1">
|
<div class="flex items-center mt-1">
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,9 @@
|
||||||
import { getContext, onMount } from 'svelte';
|
import { getContext, onMount } from 'svelte';
|
||||||
const i18n = getContext('i18n');
|
const i18n = getContext('i18n');
|
||||||
|
|
||||||
|
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||||
import Modal from '$lib/components/common/Modal.svelte';
|
import Modal from '$lib/components/common/Modal.svelte';
|
||||||
|
import XMark from '$lib/components/icons/XMark.svelte';
|
||||||
import { extractFrontmatter } from '$lib/utils';
|
import { extractFrontmatter } from '$lib/utils';
|
||||||
|
|
||||||
export let show = false;
|
export let show = false;
|
||||||
|
|
@ -69,16 +71,7 @@
|
||||||
show = false;
|
show = false;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg
|
<XMark className={'size-5'} />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="currentColor"
|
|
||||||
class="w-5 h-5"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -120,29 +113,7 @@
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="ml-2 self-center">
|
<div class="ml-2 self-center">
|
||||||
<svg
|
<Spinner />
|
||||||
class=" w-4 h-4"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="currentColor"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
><style>
|
|
||||||
.spinner_ajPY {
|
|
||||||
transform-origin: center;
|
|
||||||
animation: spinner_AtaB 0.75s infinite linear;
|
|
||||||
}
|
|
||||||
@keyframes spinner_AtaB {
|
|
||||||
100% {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style><path
|
|
||||||
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
|
|
||||||
opacity=".25"
|
|
||||||
/><path
|
|
||||||
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
|
|
||||||
class="spinner_ajPY"
|
|
||||||
/></svg
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { WEBUI_BASE_URL } from '$lib/constants';
|
||||||
import { settings, playingNotificationSound, isLastActiveTab } from '$lib/stores';
|
import { settings, playingNotificationSound, isLastActiveTab } from '$lib/stores';
|
||||||
import DOMPurify from 'dompurify';
|
import DOMPurify from 'dompurify';
|
||||||
|
|
||||||
|
|
@ -38,7 +39,7 @@
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="shrink-0 self-top -translate-y-0.5">
|
<div class="shrink-0 self-top -translate-y-0.5">
|
||||||
<img src={'/static/favicon.png'} alt="favicon" class="size-7 rounded-full" />
|
<img src="{WEBUI_BASE_URL}/static/favicon.png" alt="favicon" class="size-7 rounded-full" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -19,10 +19,10 @@
|
||||||
|
|
||||||
if (isDarkMode) {
|
if (isDarkMode) {
|
||||||
const darkImage = new Image();
|
const darkImage = new Image();
|
||||||
darkImage.src = '/static/favicon-dark.png';
|
darkImage.src = `${WEBUI_BASE_URL}/static/favicon-dark.png`;
|
||||||
|
|
||||||
darkImage.onload = () => {
|
darkImage.onload = () => {
|
||||||
logo.src = '/static/favicon-dark.png';
|
logo.src = `${WEBUI_BASE_URL}/static/favicon-dark.png`;
|
||||||
logo.style.filter = ''; // Ensure no inversion is applied if splash-dark.png exists
|
logo.style.filter = ''; // Ensure no inversion is applied if splash-dark.png exists
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,41 @@
|
||||||
import Modal from '$lib/components/common/Modal.svelte';
|
import Modal from '$lib/components/common/Modal.svelte';
|
||||||
import { getContext } from 'svelte';
|
import { getContext } from 'svelte';
|
||||||
const i18n = getContext('i18n');
|
const i18n = getContext('i18n');
|
||||||
|
import XMark from '$lib/components/icons/XMark.svelte';
|
||||||
|
import { getFeedbackById } from '$lib/apis/evaluations';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||||
|
|
||||||
export let show = false;
|
export let show = false;
|
||||||
export let selectedFeedback = null;
|
export let selectedFeedback = null;
|
||||||
|
|
||||||
export let onClose: () => void = () => {};
|
export let onClose: () => void = () => {};
|
||||||
|
|
||||||
|
let loaded = false;
|
||||||
|
|
||||||
|
let feedbackData = null;
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
show = false;
|
show = false;
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const init = async () => {
|
||||||
|
loaded = false;
|
||||||
|
feedbackData = null;
|
||||||
|
if (selectedFeedback) {
|
||||||
|
feedbackData = await getFeedbackById(localStorage.token, selectedFeedback.id).catch((err) => {
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Feedback Data:', selectedFeedback, feedbackData);
|
||||||
|
}
|
||||||
|
loaded = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
$: if (show) {
|
||||||
|
init();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Modal size="sm" bind:show>
|
<Modal size="sm" bind:show>
|
||||||
|
|
@ -22,58 +47,89 @@
|
||||||
{$i18n.t('Feedback Details')}
|
{$i18n.t('Feedback Details')}
|
||||||
</div>
|
</div>
|
||||||
<button class="self-center" on:click={close} aria-label="Close">
|
<button class="self-center" on:click={close} aria-label="Close">
|
||||||
<svg
|
<XMark className={'size-5'} />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="currentColor"
|
|
||||||
class="w-5 h-5"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col md:flex-row w-full px-5 pb-4 md:space-x-4 dark:text-gray-200">
|
<div class="flex flex-col md:flex-row w-full px-5 pb-4 md:space-x-4 dark:text-gray-200">
|
||||||
<div class="flex flex-col w-full">
|
{#if loaded}
|
||||||
<div class="flex flex-col w-full mb-2">
|
<div class="flex flex-col w-full">
|
||||||
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Rating')}</div>
|
{#if feedbackData}
|
||||||
|
{@const messageId = feedbackData?.meta?.message_id}
|
||||||
|
{@const messages = feedbackData?.snapshot?.chat?.chat?.history.messages}
|
||||||
|
|
||||||
<div class="flex-1">
|
{#if messages[messages[messageId]?.parentId]}
|
||||||
<span>{selectedFeedback?.data?.details?.rating ?? '-'}</span>
|
<div class="flex flex-col w-full mb-2">
|
||||||
</div>
|
<div class="mb-1 text-xs text-gray-500">{$i18n.t('Prompt')}</div>
|
||||||
</div>
|
|
||||||
<div class="flex flex-col w-full mb-2">
|
|
||||||
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Reason')}</div>
|
|
||||||
|
|
||||||
<div class="flex-1">
|
<div class="flex-1 text-xs whitespace-pre-line break-words">
|
||||||
<span>{selectedFeedback?.data?.reason || '-'}</span>
|
<span>{messages[messages[messageId]?.parentId]?.content || '-'}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="mb-2">
|
{#if messages[messageId]}
|
||||||
{#if selectedFeedback?.data?.tags && selectedFeedback?.data?.tags.length}
|
<div class="flex flex-col w-full mb-2">
|
||||||
<div class="flex flex-wrap gap-1 mt-1">
|
<div class="mb-1 text-xs text-gray-500">{$i18n.t('Response')}</div>
|
||||||
{#each selectedFeedback?.data?.tags as tag}
|
<div
|
||||||
<span class="px-2 py-0.5 rounded bg-gray-100 dark:bg-gray-800 text-xs">{tag}</span
|
class="flex-1 text-xs whitespace-pre-line break-words max-h-32 overflow-y-auto"
|
||||||
>
|
>
|
||||||
{/each}
|
<span>{messages[messageId]?.content || '-'}</span>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
</div>
|
||||||
<span>-</span>
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex flex-col w-full mb-2">
|
||||||
|
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Rating')}</div>
|
||||||
|
|
||||||
|
<div class="flex-1 text-xs">
|
||||||
|
<span>{selectedFeedback?.data?.details?.rating ?? '-'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col w-full mb-2">
|
||||||
|
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Reason')}</div>
|
||||||
|
|
||||||
|
<div class="flex-1 text-xs">
|
||||||
|
<span>{selectedFeedback?.data?.reason || '-'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col w-full mb-2">
|
||||||
|
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Comment')}</div>
|
||||||
|
|
||||||
|
<div class="flex-1 text-xs">
|
||||||
|
<span>{selectedFeedback?.data?.comment || '-'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if selectedFeedback?.data?.tags && selectedFeedback?.data?.tags.length}
|
||||||
|
<div class="mb-2 -mx-1">
|
||||||
|
<div class="flex flex-wrap gap-1 mt-1">
|
||||||
|
{#each selectedFeedback?.data?.tags as tag}
|
||||||
|
<span class="px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-850 text-[9px]"
|
||||||
|
>{tag}</span
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex justify-end pt-2">
|
||||||
|
<button
|
||||||
|
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
|
||||||
|
type="button"
|
||||||
|
on:click={close}
|
||||||
|
>
|
||||||
|
{$i18n.t('Close')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end pt-3">
|
{:else}
|
||||||
<button
|
<div class="flex items-center justify-center w-full h-32">
|
||||||
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
|
<Spinner className={'size-5'} />
|
||||||
type="button"
|
|
||||||
on:click={close}
|
|
||||||
>
|
|
||||||
{$i18n.t('Close')}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
|
|
||||||
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
|
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
|
||||||
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
|
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
|
||||||
|
import { WEBUI_BASE_URL } from '$lib/constants';
|
||||||
|
|
||||||
export let feedbacks = [];
|
export let feedbacks = [];
|
||||||
|
|
||||||
|
|
@ -305,7 +306,7 @@
|
||||||
<tbody class="">
|
<tbody class="">
|
||||||
{#each paginatedFeedbacks as feedback (feedback.id)}
|
{#each paginatedFeedbacks as feedback (feedback.id)}
|
||||||
<tr
|
<tr
|
||||||
class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 transition"
|
class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-850/50 transition"
|
||||||
on:click={() => openFeedbackModal(feedback)}
|
on:click={() => openFeedbackModal(feedback)}
|
||||||
>
|
>
|
||||||
<td class=" py-0.5 text-right font-semibold">
|
<td class=" py-0.5 text-right font-semibold">
|
||||||
|
|
@ -313,7 +314,7 @@
|
||||||
<Tooltip content={feedback?.user?.name}>
|
<Tooltip content={feedback?.user?.name}>
|
||||||
<div class="shrink-0">
|
<div class="shrink-0">
|
||||||
<img
|
<img
|
||||||
src={feedback?.user?.profile_image_url ?? '/user.png'}
|
src={feedback?.user?.profile_image_url ?? `${WEBUI_BASE_URL}/user.png`}
|
||||||
alt={feedback?.user?.name}
|
alt={feedback?.user?.name}
|
||||||
class="size-5 rounded-full object-cover shrink-0"
|
class="size-5 rounded-full object-cover shrink-0"
|
||||||
/>
|
/>
|
||||||
|
|
@ -369,7 +370,7 @@
|
||||||
{dayjs(feedback.updated_at * 1000).fromNow()}
|
{dayjs(feedback.updated_at * 1000).fromNow()}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td class=" px-3 py-1 text-right font-semibold">
|
<td class=" px-3 py-1 text-right font-semibold" on:click={(e) => e.stopPropagation()}>
|
||||||
<FeedbackMenu
|
<FeedbackMenu
|
||||||
on:delete={(e) => {
|
on:delete={(e) => {
|
||||||
deleteFeedbackHandler(feedback.id);
|
deleteFeedbackHandler(feedback.id);
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,11 @@
|
||||||
|
|
||||||
import Spinner from '$lib/components/common/Spinner.svelte';
|
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||||
import MagnifyingGlass from '$lib/components/icons/MagnifyingGlass.svelte';
|
import Search from '$lib/components/icons/Search.svelte';
|
||||||
|
|
||||||
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
|
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
|
||||||
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
|
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
|
||||||
|
import { WEBUI_BASE_URL } from '$lib/constants';
|
||||||
|
|
||||||
const i18n = getContext('i18n');
|
const i18n = getContext('i18n');
|
||||||
|
|
||||||
|
|
@ -77,7 +78,7 @@
|
||||||
let showLeaderboardModal = false;
|
let showLeaderboardModal = false;
|
||||||
let selectedModel = null;
|
let selectedModel = null;
|
||||||
|
|
||||||
const openFeedbackModal = (model) => {
|
const openLeaderboardModelModal = (model) => {
|
||||||
showLeaderboardModal = true;
|
showLeaderboardModal = true;
|
||||||
selectedModel = model;
|
selectedModel = model;
|
||||||
};
|
};
|
||||||
|
|
@ -350,7 +351,7 @@
|
||||||
<Tooltip content={$i18n.t('Re-rank models by topic similarity')}>
|
<Tooltip content={$i18n.t('Re-rank models by topic similarity')}>
|
||||||
<div class="flex flex-1">
|
<div class="flex flex-1">
|
||||||
<div class=" self-center ml-1 mr-3">
|
<div class=" self-center ml-1 mr-3">
|
||||||
<MagnifyingGlass className="size-3" />
|
<Search className="size-3" />
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
|
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
|
||||||
|
|
@ -371,7 +372,7 @@
|
||||||
{#if loadingLeaderboard}
|
{#if loadingLeaderboard}
|
||||||
<div class=" absolute top-0 bottom-0 left-0 right-0 flex">
|
<div class=" absolute top-0 bottom-0 left-0 right-0 flex">
|
||||||
<div class="m-auto">
|
<div class="m-auto">
|
||||||
<Spinner />
|
<Spinner className="size-5" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -504,8 +505,8 @@
|
||||||
<tbody class="">
|
<tbody class="">
|
||||||
{#each sortedModels as model, modelIdx (model.id)}
|
{#each sortedModels as model, modelIdx (model.id)}
|
||||||
<tr
|
<tr
|
||||||
class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs group cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 transition"
|
class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs group cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-850/50 transition"
|
||||||
on:click={() => openFeedbackModal(model)}
|
on:click={() => openLeaderboardModelModal(model)}
|
||||||
>
|
>
|
||||||
<td class="px-3 py-1.5 text-left font-medium text-gray-900 dark:text-white w-fit">
|
<td class="px-3 py-1.5 text-left font-medium text-gray-900 dark:text-white w-fit">
|
||||||
<div class=" line-clamp-1">
|
<div class=" line-clamp-1">
|
||||||
|
|
@ -516,7 +517,7 @@
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div class="shrink-0">
|
<div class="shrink-0">
|
||||||
<img
|
<img
|
||||||
src={model?.info?.meta?.profile_image_url ?? '/favicon.png'}
|
src={model?.info?.meta?.profile_image_url ?? `${WEBUI_BASE_URL}/favicon.png`}
|
||||||
alt={model.name}
|
alt={model.name}
|
||||||
class="size-5 rounded-full object-cover shrink-0"
|
class="size-5 rounded-full object-cover shrink-0"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
export let feedbacks = [];
|
export let feedbacks = [];
|
||||||
export let onClose: () => void = () => {};
|
export let onClose: () => void = () => {};
|
||||||
const i18n = getContext('i18n');
|
const i18n = getContext('i18n');
|
||||||
|
import XMark from '$lib/components/icons/XMark.svelte';
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
show = false;
|
show = false;
|
||||||
|
|
@ -37,25 +38,16 @@
|
||||||
{model.name}
|
{model.name}
|
||||||
</div>
|
</div>
|
||||||
<button class="self-center" on:click={close} aria-label="Close">
|
<button class="self-center" on:click={close} aria-label="Close">
|
||||||
<svg
|
<XMark className={'size-5'} />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="currentColor"
|
|
||||||
class="w-5 h-5"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="px-5 pb-4 dark:text-gray-200">
|
<div class="px-5 pb-4 dark:text-gray-200">
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
{#if topTags.length}
|
{#if topTags.length}
|
||||||
<div class="flex flex-wrap gap-1 mt-1">
|
<div class="flex flex-wrap gap-1 mt-1 -mx-1">
|
||||||
{#each topTags as tagInfo}
|
{#each topTags as tagInfo}
|
||||||
<span class="px-2 py-0.5 rounded bg-gray-100 dark:bg-gray-800 text-xs">
|
<span class="px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-850 text-xs">
|
||||||
{tagInfo.tag} <span class="text-gray-500">({tagInfo.count})</span>
|
{tagInfo.tag} <span class="text-gray-500 font-medium">{tagInfo.count}</span>
|
||||||
</span>
|
</span>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -63,7 +55,7 @@
|
||||||
<span>-</span>
|
<span>-</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end pt-3">
|
<div class="flex justify-end pt-2">
|
||||||
<button
|
<button
|
||||||
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
|
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,8 @@
|
||||||
let showConfirm = false;
|
let showConfirm = false;
|
||||||
let query = '';
|
let query = '';
|
||||||
|
|
||||||
|
let selectedType = 'all';
|
||||||
|
|
||||||
let showManifestModal = false;
|
let showManifestModal = false;
|
||||||
let showValvesModal = false;
|
let showValvesModal = false;
|
||||||
let selectedFunction = null;
|
let selectedFunction = null;
|
||||||
|
|
@ -59,9 +61,10 @@
|
||||||
$: filteredItems = $functions
|
$: filteredItems = $functions
|
||||||
.filter(
|
.filter(
|
||||||
(f) =>
|
(f) =>
|
||||||
query === '' ||
|
(selectedType !== 'all' ? f.type === selectedType : true) &&
|
||||||
f.name.toLowerCase().includes(query.toLowerCase()) ||
|
(query === '' ||
|
||||||
f.id.toLowerCase().includes(query.toLowerCase())
|
f.name.toLowerCase().includes(query.toLowerCase()) ||
|
||||||
|
f.id.toLowerCase().includes(query.toLowerCase()))
|
||||||
)
|
)
|
||||||
.sort((a, b) => a.type.localeCompare(b.type) || a.name.localeCompare(b.name));
|
.sort((a, b) => a.type.localeCompare(b.type) || a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
|
@ -135,7 +138,9 @@
|
||||||
models.set(
|
models.set(
|
||||||
await getModels(
|
await getModels(
|
||||||
localStorage.token,
|
localStorage.token,
|
||||||
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
|
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null),
|
||||||
|
false,
|
||||||
|
true
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -161,7 +166,9 @@
|
||||||
models.set(
|
models.set(
|
||||||
await getModels(
|
await getModels(
|
||||||
localStorage.token,
|
localStorage.token,
|
||||||
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
|
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null),
|
||||||
|
false,
|
||||||
|
true
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -215,8 +222,8 @@
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="flex flex-col gap-1 mt-1.5 mb-2">
|
<div class="flex flex-col mt-1.5 mb-0.5">
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center mb-1">
|
||||||
<div class="flex md:self-center text-xl items-center font-medium px-0.5">
|
<div class="flex md:self-center text-xl items-center font-medium px-0.5">
|
||||||
{$i18n.t('Functions')}
|
{$i18n.t('Functions')}
|
||||||
<div class="flex self-center w-[1px] h-6 mx-2.5 bg-gray-50 dark:bg-gray-850" />
|
<div class="flex self-center w-[1px] h-6 mx-2.5 bg-gray-50 dark:bg-gray-850" />
|
||||||
|
|
@ -266,12 +273,54 @@
|
||||||
</AddFunctionMenu>
|
</AddFunctionMenu>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class=" flex w-full">
|
||||||
|
<div
|
||||||
|
class="flex gap-1 scrollbar-none overflow-x-auto w-fit text-center text-sm font-medium rounded-full bg-transparent"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="min-w-fit p-1.5 {selectedType === 'all'
|
||||||
|
? ''
|
||||||
|
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
|
||||||
|
on:click={() => {
|
||||||
|
selectedType = 'all';
|
||||||
|
}}>{$i18n.t('All')}</button
|
||||||
|
>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="min-w-fit p-1.5 {selectedType === 'pipe'
|
||||||
|
? ''
|
||||||
|
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
|
||||||
|
on:click={() => {
|
||||||
|
selectedType = 'pipe';
|
||||||
|
}}>{$i18n.t('Pipe')}</button
|
||||||
|
>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="min-w-fit p-1.5 {selectedType === 'filter'
|
||||||
|
? ''
|
||||||
|
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
|
||||||
|
on:click={() => {
|
||||||
|
selectedType = 'filter';
|
||||||
|
}}>{$i18n.t('Filter')}</button
|
||||||
|
>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="min-w-fit p-1.5 {selectedType === 'action'
|
||||||
|
? ''
|
||||||
|
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
|
||||||
|
on:click={() => {
|
||||||
|
selectedType = 'action';
|
||||||
|
}}>{$i18n.t('Action')}</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-5">
|
<div class="mb-5">
|
||||||
{#each filteredItems as func (func.id)}
|
{#each filteredItems as func (func.id)}
|
||||||
<div
|
<div
|
||||||
class=" flex space-x-4 cursor-pointer w-full px-3 py-2 dark:hover:bg-white/5 hover:bg-black/5 rounded-xl"
|
class=" flex space-x-4 cursor-pointer w-full px-2 py-2 dark:hover:bg-white/5 hover:bg-black/5 rounded-xl"
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
class=" flex flex-1 space-x-3.5 cursor-pointer w-full"
|
class=" flex flex-1 space-x-3.5 cursor-pointer w-full"
|
||||||
|
|
@ -413,7 +462,9 @@
|
||||||
await getModels(
|
await getModels(
|
||||||
localStorage.token,
|
localStorage.token,
|
||||||
$config?.features?.enable_direct_connections &&
|
$config?.features?.enable_direct_connections &&
|
||||||
($settings?.directConnections ?? null)
|
($settings?.directConnections ?? null),
|
||||||
|
false,
|
||||||
|
true
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
|
@ -559,7 +610,9 @@
|
||||||
models.set(
|
models.set(
|
||||||
await getModels(
|
await getModels(
|
||||||
localStorage.token,
|
localStorage.token,
|
||||||
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
|
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null),
|
||||||
|
false,
|
||||||
|
true
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
|
@ -585,7 +638,9 @@
|
||||||
models.set(
|
models.set(
|
||||||
await getModels(
|
await getModels(
|
||||||
localStorage.token,
|
localStorage.token,
|
||||||
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
|
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null),
|
||||||
|
false,
|
||||||
|
true
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
} from '$lib/apis/audio';
|
} from '$lib/apis/audio';
|
||||||
import { config, settings } from '$lib/stores';
|
import { config, settings } from '$lib/stores';
|
||||||
|
|
||||||
|
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||||
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
|
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
|
||||||
|
|
||||||
import { TTS_RESPONSE_SPLIT } from '$lib/types';
|
import { TTS_RESPONSE_SPLIT } from '$lib/types';
|
||||||
|
|
@ -199,7 +200,9 @@
|
||||||
<input
|
<input
|
||||||
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
|
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
|
||||||
bind:value={STT_SUPPORTED_CONTENT_TYPES}
|
bind:value={STT_SUPPORTED_CONTENT_TYPES}
|
||||||
placeholder={$i18n.t('e.g., audio/wav,audio/mpeg (leave blank for defaults)')}
|
placeholder={$i18n.t(
|
||||||
|
'e.g., audio/wav,audio/mpeg,video/* (leave blank for defaults, * for all)'
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -373,33 +376,7 @@
|
||||||
>
|
>
|
||||||
{#if STT_WHISPER_MODEL_LOADING}
|
{#if STT_WHISPER_MODEL_LOADING}
|
||||||
<div class="self-center">
|
<div class="self-center">
|
||||||
<svg
|
<Spinner />
|
||||||
class=" w-4 h-4"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="currentColor"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<style>
|
|
||||||
.spinner_ajPY {
|
|
||||||
transform-origin: center;
|
|
||||||
animation: spinner_AtaB 0.75s infinite linear;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spinner_AtaB {
|
|
||||||
100% {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<path
|
|
||||||
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
|
|
||||||
opacity=".25"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
|
|
||||||
class="spinner_ajPY"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<svg
|
<svg
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
import { getOllamaConfig, updateOllamaConfig } from '$lib/apis/ollama';
|
import { getOllamaConfig, updateOllamaConfig } from '$lib/apis/ollama';
|
||||||
import { getOpenAIConfig, updateOpenAIConfig, getOpenAIModels } from '$lib/apis/openai';
|
import { getOpenAIConfig, updateOpenAIConfig, getOpenAIModels } from '$lib/apis/openai';
|
||||||
import { getModels as _getModels } from '$lib/apis';
|
import { getModels as _getModels } from '$lib/apis';
|
||||||
import { getDirectConnectionsConfig, setDirectConnectionsConfig } from '$lib/apis/configs';
|
import { getConnectionsConfig, setConnectionsConfig } from '$lib/apis/configs';
|
||||||
|
|
||||||
import { config, models, settings, user } from '$lib/stores';
|
import { config, models, settings, user } from '$lib/stores';
|
||||||
|
|
||||||
|
|
@ -25,7 +25,9 @@
|
||||||
const getModels = async () => {
|
const getModels = async () => {
|
||||||
const models = await _getModels(
|
const models = await _getModels(
|
||||||
localStorage.token,
|
localStorage.token,
|
||||||
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
|
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null),
|
||||||
|
false,
|
||||||
|
true
|
||||||
);
|
);
|
||||||
return models;
|
return models;
|
||||||
};
|
};
|
||||||
|
|
@ -41,7 +43,7 @@
|
||||||
let ENABLE_OPENAI_API: null | boolean = null;
|
let ENABLE_OPENAI_API: null | boolean = null;
|
||||||
let ENABLE_OLLAMA_API: null | boolean = null;
|
let ENABLE_OLLAMA_API: null | boolean = null;
|
||||||
|
|
||||||
let directConnectionsConfig = null;
|
let connectionsConfig = null;
|
||||||
|
|
||||||
let pipelineUrls = {};
|
let pipelineUrls = {};
|
||||||
let showAddOpenAIConnectionModal = false;
|
let showAddOpenAIConnectionModal = false;
|
||||||
|
|
@ -104,15 +106,13 @@
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateDirectConnectionsHandler = async () => {
|
const updateConnectionsHandler = async () => {
|
||||||
const res = await setDirectConnectionsConfig(localStorage.token, directConnectionsConfig).catch(
|
const res = await setConnectionsConfig(localStorage.token, connectionsConfig).catch((error) => {
|
||||||
(error) => {
|
toast.error(`${error}`);
|
||||||
toast.error(`${error}`);
|
});
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (res) {
|
if (res) {
|
||||||
toast.success($i18n.t('Direct Connections settings updated'));
|
toast.success($i18n.t('Connections settings updated'));
|
||||||
await models.set(await getModels());
|
await models.set(await getModels());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -148,7 +148,7 @@
|
||||||
openaiConfig = await getOpenAIConfig(localStorage.token);
|
openaiConfig = await getOpenAIConfig(localStorage.token);
|
||||||
})(),
|
})(),
|
||||||
(async () => {
|
(async () => {
|
||||||
directConnectionsConfig = await getDirectConnectionsConfig(localStorage.token);
|
connectionsConfig = await getConnectionsConfig(localStorage.token);
|
||||||
})()
|
})()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -215,36 +215,103 @@
|
||||||
|
|
||||||
<form class="flex flex-col h-full justify-between text-sm" on:submit|preventDefault={submitHandler}>
|
<form class="flex flex-col h-full justify-between text-sm" on:submit|preventDefault={submitHandler}>
|
||||||
<div class=" overflow-y-scroll scrollbar-hidden h-full">
|
<div class=" overflow-y-scroll scrollbar-hidden h-full">
|
||||||
{#if ENABLE_OPENAI_API !== null && ENABLE_OLLAMA_API !== null && directConnectionsConfig !== null}
|
{#if ENABLE_OPENAI_API !== null && ENABLE_OLLAMA_API !== null && connectionsConfig !== null}
|
||||||
<div class="my-2">
|
<div class="mb-3.5">
|
||||||
<div class="mt-2 space-y-2 pr-1.5">
|
<div class=" mb-2.5 text-base font-medium">{$i18n.t('General')}</div>
|
||||||
<div class="flex justify-between items-center text-sm">
|
|
||||||
<div class=" font-medium">{$i18n.t('OpenAI API')}</div>
|
|
||||||
|
|
||||||
<div class="flex items-center">
|
<hr class=" border-gray-100 dark:border-gray-850 my-2" />
|
||||||
<div class="">
|
|
||||||
<Switch
|
<div class="my-2">
|
||||||
bind:state={ENABLE_OPENAI_API}
|
<div class="mt-2 space-y-2">
|
||||||
on:change={async () => {
|
<div class="flex justify-between items-center text-sm">
|
||||||
updateOpenAIHandler();
|
<div class=" font-medium">{$i18n.t('OpenAI API')}</div>
|
||||||
}}
|
|
||||||
/>
|
<div class="flex items-center">
|
||||||
|
<div class="">
|
||||||
|
<Switch
|
||||||
|
bind:state={ENABLE_OPENAI_API}
|
||||||
|
on:change={async () => {
|
||||||
|
updateOpenAIHandler();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if ENABLE_OPENAI_API}
|
||||||
|
<div class="">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<div class="font-medium text-xs">{$i18n.t('Manage OpenAI API Connections')}</div>
|
||||||
|
|
||||||
|
<Tooltip content={$i18n.t(`Add Connection`)}>
|
||||||
|
<button
|
||||||
|
class="px-1"
|
||||||
|
on:click={() => {
|
||||||
|
showAddOpenAIConnectionModal = true;
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Plus />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1.5 mt-1.5">
|
||||||
|
{#each OPENAI_API_BASE_URLS as url, idx}
|
||||||
|
<OpenAIConnection
|
||||||
|
pipeline={pipelineUrls[url] ? true : false}
|
||||||
|
bind:url
|
||||||
|
bind:key={OPENAI_API_KEYS[idx]}
|
||||||
|
bind:config={OPENAI_API_CONFIGS[idx]}
|
||||||
|
onSubmit={() => {
|
||||||
|
updateOpenAIHandler();
|
||||||
|
}}
|
||||||
|
onDelete={() => {
|
||||||
|
OPENAI_API_BASE_URLS = OPENAI_API_BASE_URLS.filter(
|
||||||
|
(url, urlIdx) => idx !== urlIdx
|
||||||
|
);
|
||||||
|
OPENAI_API_KEYS = OPENAI_API_KEYS.filter((key, keyIdx) => idx !== keyIdx);
|
||||||
|
|
||||||
|
let newConfig = {};
|
||||||
|
OPENAI_API_BASE_URLS.forEach((url, newIdx) => {
|
||||||
|
newConfig[newIdx] =
|
||||||
|
OPENAI_API_CONFIGS[newIdx < idx ? newIdx : newIdx + 1];
|
||||||
|
});
|
||||||
|
OPENAI_API_CONFIGS = newConfig;
|
||||||
|
updateOpenAIHandler();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class=" my-2">
|
||||||
|
<div class="flex justify-between items-center text-sm mb-2">
|
||||||
|
<div class=" font-medium">{$i18n.t('Ollama API')}</div>
|
||||||
|
|
||||||
|
<div class="mt-1">
|
||||||
|
<Switch
|
||||||
|
bind:state={ENABLE_OLLAMA_API}
|
||||||
|
on:change={async () => {
|
||||||
|
updateOllamaHandler();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if ENABLE_OPENAI_API}
|
{#if ENABLE_OLLAMA_API}
|
||||||
<hr class=" border-gray-100 dark:border-gray-850" />
|
|
||||||
|
|
||||||
<div class="">
|
<div class="">
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
<div class="font-medium">{$i18n.t('Manage OpenAI API Connections')}</div>
|
<div class="font-medium text-xs">{$i18n.t('Manage Ollama API Connections')}</div>
|
||||||
|
|
||||||
<Tooltip content={$i18n.t(`Add Connection`)}>
|
<Tooltip content={$i18n.t(`Add Connection`)}>
|
||||||
<button
|
<button
|
||||||
class="px-1"
|
class="px-1"
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
showAddOpenAIConnectionModal = true;
|
showAddOllamaConnectionModal = true;
|
||||||
}}
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
|
|
@ -253,133 +320,89 @@
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-1.5 mt-1.5">
|
<div class="flex w-full gap-1.5">
|
||||||
{#each OPENAI_API_BASE_URLS as url, idx}
|
<div class="flex-1 flex flex-col gap-1.5 mt-1.5">
|
||||||
<OpenAIConnection
|
{#each OLLAMA_BASE_URLS as url, idx}
|
||||||
pipeline={pipelineUrls[url] ? true : false}
|
<OllamaConnection
|
||||||
bind:url
|
bind:url
|
||||||
bind:key={OPENAI_API_KEYS[idx]}
|
bind:config={OLLAMA_API_CONFIGS[idx]}
|
||||||
bind:config={OPENAI_API_CONFIGS[idx]}
|
{idx}
|
||||||
onSubmit={() => {
|
onSubmit={() => {
|
||||||
updateOpenAIHandler();
|
updateOllamaHandler();
|
||||||
}}
|
}}
|
||||||
onDelete={() => {
|
onDelete={() => {
|
||||||
OPENAI_API_BASE_URLS = OPENAI_API_BASE_URLS.filter(
|
OLLAMA_BASE_URLS = OLLAMA_BASE_URLS.filter((url, urlIdx) => idx !== urlIdx);
|
||||||
(url, urlIdx) => idx !== urlIdx
|
|
||||||
);
|
|
||||||
OPENAI_API_KEYS = OPENAI_API_KEYS.filter((key, keyIdx) => idx !== keyIdx);
|
|
||||||
|
|
||||||
let newConfig = {};
|
let newConfig = {};
|
||||||
OPENAI_API_BASE_URLS.forEach((url, newIdx) => {
|
OLLAMA_BASE_URLS.forEach((url, newIdx) => {
|
||||||
newConfig[newIdx] = OPENAI_API_CONFIGS[newIdx < idx ? newIdx : newIdx + 1];
|
newConfig[newIdx] =
|
||||||
});
|
OLLAMA_API_CONFIGS[newIdx < idx ? newIdx : newIdx + 1];
|
||||||
OPENAI_API_CONFIGS = newConfig;
|
});
|
||||||
updateOpenAIHandler();
|
OLLAMA_API_CONFIGS = newConfig;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
{$i18n.t('Trouble accessing Ollama?')}
|
||||||
|
<a
|
||||||
|
class=" text-gray-300 font-medium underline"
|
||||||
|
href="https://github.com/open-webui/open-webui#troubleshooting"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{$i18n.t('Click here for help.')}
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr class=" border-gray-100 dark:border-gray-850" />
|
<div class="my-2">
|
||||||
|
<div class="flex justify-between items-center text-sm">
|
||||||
|
<div class=" font-medium">{$i18n.t('Direct Connections')}</div>
|
||||||
|
|
||||||
<div class="pr-1.5 my-2">
|
<div class="flex items-center">
|
||||||
<div class="flex justify-between items-center text-sm mb-2">
|
<div class="">
|
||||||
<div class=" font-medium">{$i18n.t('Ollama API')}</div>
|
<Switch
|
||||||
|
bind:state={connectionsConfig.ENABLE_DIRECT_CONNECTIONS}
|
||||||
<div class="mt-1">
|
on:change={async () => {
|
||||||
<Switch
|
updateConnectionsHandler();
|
||||||
bind:state={ENABLE_OLLAMA_API}
|
|
||||||
on:change={async () => {
|
|
||||||
updateOllamaHandler();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if ENABLE_OLLAMA_API}
|
|
||||||
<hr class=" border-gray-100 dark:border-gray-850 my-2" />
|
|
||||||
|
|
||||||
<div class="">
|
|
||||||
<div class="flex justify-between items-center">
|
|
||||||
<div class="font-medium">{$i18n.t('Manage Ollama API Connections')}</div>
|
|
||||||
|
|
||||||
<Tooltip content={$i18n.t(`Add Connection`)}>
|
|
||||||
<button
|
|
||||||
class="px-1"
|
|
||||||
on:click={() => {
|
|
||||||
showAddOllamaConnectionModal = true;
|
|
||||||
}}
|
}}
|
||||||
type="button"
|
/>
|
||||||
>
|
|
||||||
<Plus />
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex w-full gap-1.5">
|
|
||||||
<div class="flex-1 flex flex-col gap-1.5 mt-1.5">
|
|
||||||
{#each OLLAMA_BASE_URLS as url, idx}
|
|
||||||
<OllamaConnection
|
|
||||||
bind:url
|
|
||||||
bind:config={OLLAMA_API_CONFIGS[idx]}
|
|
||||||
{idx}
|
|
||||||
onSubmit={() => {
|
|
||||||
updateOllamaHandler();
|
|
||||||
}}
|
|
||||||
onDelete={() => {
|
|
||||||
OLLAMA_BASE_URLS = OLLAMA_BASE_URLS.filter((url, urlIdx) => idx !== urlIdx);
|
|
||||||
|
|
||||||
let newConfig = {};
|
|
||||||
OLLAMA_BASE_URLS.forEach((url, newIdx) => {
|
|
||||||
newConfig[newIdx] = OLLAMA_API_CONFIGS[newIdx < idx ? newIdx : newIdx + 1];
|
|
||||||
});
|
|
||||||
OLLAMA_API_CONFIGS = newConfig;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
|
||||||
{$i18n.t('Trouble accessing Ollama?')}
|
|
||||||
<a
|
|
||||||
class=" text-gray-300 font-medium underline"
|
|
||||||
href="https://github.com/open-webui/open-webui#troubleshooting"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
{$i18n.t('Click here for help.')}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr class=" border-gray-100 dark:border-gray-850" />
|
<div class="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
{$i18n.t(
|
||||||
<div class="pr-1.5 my-2">
|
'Direct Connections allow users to connect to their own OpenAI compatible API endpoints.'
|
||||||
<div class="flex justify-between items-center text-sm">
|
)}
|
||||||
<div class=" font-medium">{$i18n.t('Direct Connections')}</div>
|
|
||||||
|
|
||||||
<div class="flex items-center">
|
|
||||||
<div class="">
|
|
||||||
<Switch
|
|
||||||
bind:state={directConnectionsConfig.ENABLE_DIRECT_CONNECTIONS}
|
|
||||||
on:change={async () => {
|
|
||||||
updateDirectConnectionsHandler();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-1.5">
|
<hr class=" border-gray-100 dark:border-gray-850 my-2" />
|
||||||
<div class="text-xs text-gray-500">
|
|
||||||
|
<div class="my-2">
|
||||||
|
<div class="flex justify-between items-center text-sm">
|
||||||
|
<div class=" text-xs font-medium">{$i18n.t('Cache Base Model List')}</div>
|
||||||
|
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="">
|
||||||
|
<Switch
|
||||||
|
bind:state={connectionsConfig.ENABLE_BASE_MODELS_CACHE}
|
||||||
|
on:change={async () => {
|
||||||
|
updateConnectionsHandler();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||||
{$i18n.t(
|
{$i18n.t(
|
||||||
'Direct Connections allow users to connect to their own OpenAI compatible API endpoints.'
|
'Base Model List Cache speeds up access by fetching base models only at startup or on settings save—faster, but may not show recent base model changes.'
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
|
|
||||||
import Modal from '$lib/components/common/Modal.svelte';
|
import Modal from '$lib/components/common/Modal.svelte';
|
||||||
import ManageOllama from '../Models/Manage/ManageOllama.svelte';
|
import ManageOllama from '../Models/Manage/ManageOllama.svelte';
|
||||||
|
import XMark from '$lib/components/icons/XMark.svelte';
|
||||||
|
|
||||||
export let show = false;
|
export let show = false;
|
||||||
export let urlIdx: number | null = null;
|
export let urlIdx: number | null = null;
|
||||||
|
|
@ -26,16 +27,7 @@
|
||||||
show = false;
|
show = false;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg
|
<XMark className={'size-5'} />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="currentColor"
|
|
||||||
class="w-5 h-5"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -90,10 +90,6 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (embeddingEngine === 'openai' && (OpenAIKey === '' || OpenAIUrl === '')) {
|
|
||||||
toast.error($i18n.t('OpenAI URL/Key required.'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
if (
|
||||||
embeddingEngine === 'azure_openai' &&
|
embeddingEngine === 'azure_openai' &&
|
||||||
(AzureOpenAIKey === '' || AzureOpenAIUrl === '' || AzureOpenAIVersion === '')
|
(AzureOpenAIKey === '' || AzureOpenAIUrl === '' || AzureOpenAIVersion === '')
|
||||||
|
|
@ -643,6 +639,7 @@
|
||||||
>
|
>
|
||||||
<option value="">{$i18n.t('Default')} ({$i18n.t('Character')})</option>
|
<option value="">{$i18n.t('Default')} ({$i18n.t('Character')})</option>
|
||||||
<option value="token">{$i18n.t('Token')} ({$i18n.t('Tiktoken')})</option>
|
<option value="token">{$i18n.t('Token')} ({$i18n.t('Tiktoken')})</option>
|
||||||
|
<option value="markdown_header">{$i18n.t('Markdown (Header)')}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -731,7 +728,11 @@
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SensitiveInput placeholder={$i18n.t('API Key')} bind:value={OpenAIKey} />
|
<SensitiveInput
|
||||||
|
placeholder={$i18n.t('API Key')}
|
||||||
|
bind:value={OpenAIKey}
|
||||||
|
required={false}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{:else if embeddingEngine === 'ollama'}
|
{:else if embeddingEngine === 'ollama'}
|
||||||
<div class="my-0.5 flex gap-2 pr-2">
|
<div class="my-0.5 flex gap-2 pr-2">
|
||||||
|
|
@ -808,33 +809,7 @@
|
||||||
>
|
>
|
||||||
{#if updateEmbeddingModelLoading}
|
{#if updateEmbeddingModelLoading}
|
||||||
<div class="self-center">
|
<div class="self-center">
|
||||||
<svg
|
<Spinner />
|
||||||
class=" w-4 h-4"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="currentColor"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<style>
|
|
||||||
.spinner_ajPY {
|
|
||||||
transform-origin: center;
|
|
||||||
animation: spinner_AtaB 0.75s infinite linear;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spinner_AtaB {
|
|
||||||
100% {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<path
|
|
||||||
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
|
|
||||||
opacity=".25"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
|
|
||||||
class="spinner_ajPY"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<svg
|
<svg
|
||||||
|
|
@ -1272,7 +1247,7 @@
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex items-center justify-center h-full">
|
<div class="flex items-center justify-center h-full">
|
||||||
<Spinner />
|
<Spinner className="size-5" />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
const i18n = getContext('i18n');
|
const i18n = getContext('i18n');
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||||
import Modal from '$lib/components/common/Modal.svelte';
|
import Modal from '$lib/components/common/Modal.svelte';
|
||||||
import { models } from '$lib/stores';
|
import { models } from '$lib/stores';
|
||||||
import Plus from '$lib/components/icons/Plus.svelte';
|
import Plus from '$lib/components/icons/Plus.svelte';
|
||||||
|
|
@ -11,6 +12,8 @@
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import AccessControl from '$lib/components/workspace/common/AccessControl.svelte';
|
import AccessControl from '$lib/components/workspace/common/AccessControl.svelte';
|
||||||
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
||||||
|
import XMark from '$lib/components/icons/XMark.svelte';
|
||||||
|
import { WEBUI_BASE_URL } from '$lib/constants';
|
||||||
|
|
||||||
export let show = false;
|
export let show = false;
|
||||||
export let edit = false;
|
export let edit = false;
|
||||||
|
|
@ -34,7 +37,7 @@
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let profileImageUrl = '/favicon.png';
|
let profileImageUrl = `${WEBUI_BASE_URL}/favicon.png`;
|
||||||
let description = '';
|
let description = '';
|
||||||
|
|
||||||
let selectedModelId = '';
|
let selectedModelId = '';
|
||||||
|
|
@ -90,7 +93,7 @@
|
||||||
|
|
||||||
name = '';
|
name = '';
|
||||||
id = '';
|
id = '';
|
||||||
profileImageUrl = '/favicon.png';
|
profileImageUrl = `${WEBUI_BASE_URL}/favicon.png`;
|
||||||
description = '';
|
description = '';
|
||||||
modelIds = [];
|
modelIds = [];
|
||||||
selectedModelId = '';
|
selectedModelId = '';
|
||||||
|
|
@ -141,16 +144,7 @@
|
||||||
show = false;
|
show = false;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg
|
<XMark className={'size-5'} />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="currentColor"
|
|
||||||
class="w-5 h-5"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -406,29 +400,7 @@
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="ml-2 self-center">
|
<div class="ml-2 self-center">
|
||||||
<svg
|
<Spinner />
|
||||||
class=" w-4 h-4"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="currentColor"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
><style>
|
|
||||||
.spinner_ajPY {
|
|
||||||
transform-origin: center;
|
|
||||||
animation: spinner_AtaB 0.75s infinite linear;
|
|
||||||
}
|
|
||||||
@keyframes spinner_AtaB {
|
|
||||||
100% {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style><path
|
|
||||||
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
|
|
||||||
opacity=".25"
|
|
||||||
/><path
|
|
||||||
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
|
|
||||||
class="spinner_ajPY"
|
|
||||||
/></svg
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,9 @@
|
||||||
};
|
};
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
checkForVersionUpdates();
|
if ($config?.features?.enable_version_update_check) {
|
||||||
|
checkForVersionUpdates();
|
||||||
|
}
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
(async () => {
|
(async () => {
|
||||||
|
|
@ -137,16 +139,18 @@
|
||||||
v{WEBUI_VERSION}
|
v{WEBUI_VERSION}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<a
|
{#if $config?.features?.enable_version_update_check}
|
||||||
href="https://github.com/open-webui/open-webui/releases/tag/v{version.latest}"
|
<a
|
||||||
target="_blank"
|
href="https://github.com/open-webui/open-webui/releases/tag/v{version.latest}"
|
||||||
>
|
target="_blank"
|
||||||
{updateAvailable === null
|
>
|
||||||
? $i18n.t('Checking for updates...')
|
{updateAvailable === null
|
||||||
: updateAvailable
|
? $i18n.t('Checking for updates...')
|
||||||
? `(v${version.latest} ${$i18n.t('available!')})`
|
: updateAvailable
|
||||||
: $i18n.t('(latest)')}
|
? `(v${version.latest} ${$i18n.t('available!')})`
|
||||||
</a>
|
: $i18n.t('(latest)')}
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|
@ -160,15 +164,17 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
{#if $config?.features?.enable_version_update_check}
|
||||||
class=" text-xs px-3 py-1.5 bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 transition rounded-lg font-medium"
|
<button
|
||||||
type="button"
|
class=" text-xs px-3 py-1.5 bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 transition rounded-lg font-medium"
|
||||||
on:click={() => {
|
type="button"
|
||||||
checkForVersionUpdates();
|
on:click={() => {
|
||||||
}}
|
checkForVersionUpdates();
|
||||||
>
|
}}
|
||||||
{$i18n.t('Check for updates')}
|
>
|
||||||
</button>
|
{$i18n.t('Check for updates')}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,11 @@
|
||||||
updateConfig,
|
updateConfig,
|
||||||
verifyConfigUrl
|
verifyConfigUrl
|
||||||
} from '$lib/apis/images';
|
} from '$lib/apis/images';
|
||||||
|
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||||
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
|
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
|
||||||
import Switch from '$lib/components/common/Switch.svelte';
|
import Switch from '$lib/components/common/Switch.svelte';
|
||||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||||
|
import Textarea from '$lib/components/common/Textarea.svelte';
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
const i18n = getContext('i18n');
|
const i18n = getContext('i18n');
|
||||||
|
|
@ -504,7 +506,7 @@
|
||||||
<div class=" mb-2 text-sm font-medium">{$i18n.t('ComfyUI Workflow')}</div>
|
<div class=" mb-2 text-sm font-medium">{$i18n.t('ComfyUI Workflow')}</div>
|
||||||
|
|
||||||
{#if config.comfyui.COMFYUI_WORKFLOW}
|
{#if config.comfyui.COMFYUI_WORKFLOW}
|
||||||
<textarea
|
<Textarea
|
||||||
class="w-full rounded-lg mb-1 py-2 px-4 text-xs bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden disabled:text-gray-600 resize-none"
|
class="w-full rounded-lg mb-1 py-2 px-4 text-xs bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden disabled:text-gray-600 resize-none"
|
||||||
rows="10"
|
rows="10"
|
||||||
bind:value={config.comfyui.COMFYUI_WORKFLOW}
|
bind:value={config.comfyui.COMFYUI_WORKFLOW}
|
||||||
|
|
@ -533,7 +535,7 @@
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="w-full text-sm font-medium py-2 bg-transparent hover:bg-gray-100 border border-dashed dark:border-gray-850 dark:hover:bg-gray-850 text-center rounded-xl"
|
class="w-full text-sm font-medium py-2 bg-transparent hover:bg-gray-50 border border-dashed border-gray-50 dark:border-gray-850 dark:hover:bg-gray-850 text-center rounded-xl"
|
||||||
type="button"
|
type="button"
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
document.getElementById('upload-comfyui-workflow-input')?.click();
|
document.getElementById('upload-comfyui-workflow-input')?.click();
|
||||||
|
|
@ -555,10 +557,10 @@
|
||||||
|
|
||||||
<div class="text-xs flex flex-col gap-1.5">
|
<div class="text-xs flex flex-col gap-1.5">
|
||||||
{#each requiredWorkflowNodes as node}
|
{#each requiredWorkflowNodes as node}
|
||||||
<div class="flex w-full items-center border dark:border-gray-850 rounded-lg">
|
<div class="flex w-full items-center">
|
||||||
<div class="shrink-0">
|
<div class="shrink-0">
|
||||||
<div
|
<div
|
||||||
class=" capitalize line-clamp-1 font-medium px-3 py-1 w-20 text-center rounded-l-lg bg-green-500/10 text-green-700 dark:text-green-200"
|
class=" capitalize line-clamp-1 font-medium px-3 py-1 w-20 text-center bg-green-500/10 text-green-700 dark:text-green-200"
|
||||||
>
|
>
|
||||||
{node.type}{node.type === 'prompt' ? '*' : ''}
|
{node.type}{node.type === 'prompt' ? '*' : ''}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -566,7 +568,7 @@
|
||||||
<div class="">
|
<div class="">
|
||||||
<Tooltip content="Input Key (e.g. text, unet_name, steps)">
|
<Tooltip content="Input Key (e.g. text, unet_name, steps)">
|
||||||
<input
|
<input
|
||||||
class="py-1 px-3 w-24 text-xs text-center bg-transparent outline-hidden border-r dark:border-gray-850"
|
class="py-1 px-3 w-24 text-xs text-center bg-transparent outline-hidden border-r border-gray-50 dark:border-gray-850"
|
||||||
placeholder="Key"
|
placeholder="Key"
|
||||||
bind:value={node.key}
|
bind:value={node.key}
|
||||||
required
|
required
|
||||||
|
|
@ -580,7 +582,7 @@
|
||||||
placement="top-start"
|
placement="top-start"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
class="w-full py-1 px-4 rounded-r-lg text-xs bg-transparent outline-hidden"
|
class="w-full py-1 px-4 text-xs bg-transparent outline-hidden"
|
||||||
placeholder="Node Ids"
|
placeholder="Node Ids"
|
||||||
bind:value={node.node_ids}
|
bind:value={node.node_ids}
|
||||||
/>
|
/>
|
||||||
|
|
@ -711,29 +713,7 @@
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="ml-2 self-center">
|
<div class="ml-2 self-center">
|
||||||
<svg
|
<Spinner />
|
||||||
class=" w-4 h-4"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="currentColor"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
><style>
|
|
||||||
.spinner_ajPY {
|
|
||||||
transform-origin: center;
|
|
||||||
animation: spinner_AtaB 0.75s infinite linear;
|
|
||||||
}
|
|
||||||
@keyframes spinner_AtaB {
|
|
||||||
100% {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style><path
|
|
||||||
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
|
|
||||||
opacity=".25"
|
|
||||||
/><path
|
|
||||||
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
|
|
||||||
class="spinner_ajPY"
|
|
||||||
/></svg
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -390,7 +390,7 @@
|
||||||
|
|
||||||
<div class="mb-2.5">
|
<div class="mb-2.5">
|
||||||
<div class="flex w-full justify-between">
|
<div class="flex w-full justify-between">
|
||||||
<div class=" self-center text-sm">
|
<div class=" self-center text-xs">
|
||||||
{$i18n.t('Banners')}
|
{$i18n.t('Banners')}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -432,7 +432,7 @@
|
||||||
{#if $user?.role === 'admin'}
|
{#if $user?.role === 'admin'}
|
||||||
<div class=" space-y-3">
|
<div class=" space-y-3">
|
||||||
<div class="flex w-full justify-between mb-2">
|
<div class="flex w-full justify-between mb-2">
|
||||||
<div class=" self-center text-sm">
|
<div class=" self-center text-xs">
|
||||||
{$i18n.t('Default Prompt Suggestions')}
|
{$i18n.t('Default Prompt Suggestions')}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -636,6 +636,6 @@
|
||||||
</form>
|
</form>
|
||||||
{:else}
|
{:else}
|
||||||
<div class=" h-full w-full flex justify-center items-center">
|
<div class=" h-full w-full flex justify-center items-center">
|
||||||
<Spinner />
|
<Spinner className="size-5" />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Switch from '$lib/components/common/Switch.svelte';
|
import Switch from '$lib/components/common/Switch.svelte';
|
||||||
|
import Textarea from '$lib/components/common/Textarea.svelte';
|
||||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||||
import EllipsisVertical from '$lib/components/icons/EllipsisVertical.svelte';
|
import EllipsisVertical from '$lib/components/icons/EllipsisVertical.svelte';
|
||||||
|
import XMark from '$lib/components/icons/XMark.svelte';
|
||||||
import Sortable from 'sortablejs';
|
import Sortable from 'sortablejs';
|
||||||
import { getContext } from 'svelte';
|
import { getContext } from 'svelte';
|
||||||
const i18n = getContext('i18n');
|
const i18n = getContext('i18n');
|
||||||
|
|
@ -23,6 +25,13 @@
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const classNames: Record<string, string> = {
|
||||||
|
info: 'bg-blue-500/20 text-blue-700 dark:text-blue-200 ',
|
||||||
|
success: 'bg-green-500/20 text-green-700 dark:text-green-200',
|
||||||
|
warning: 'bg-yellow-500/20 text-yellow-700 dark:text-yellow-200',
|
||||||
|
error: 'bg-red-500/20 text-red-700 dark:text-red-200'
|
||||||
|
};
|
||||||
|
|
||||||
$: if (banners) {
|
$: if (banners) {
|
||||||
init();
|
init();
|
||||||
}
|
}
|
||||||
|
|
@ -44,14 +53,14 @@
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class=" flex flex-col space-y-0.5" bind:this={bannerListElement}>
|
<div class=" flex flex-col gap-3 {banners?.length > 0 ? 'mt-2' : ''}" bind:this={bannerListElement}>
|
||||||
{#each banners as banner, bannerIdx (banner.id)}
|
{#each banners as banner, bannerIdx (banner.id)}
|
||||||
<div class=" flex justify-between items-center -ml-1" id="banner-item-{banner.id}">
|
<div class=" flex justify-between items-start -ml-1" id="banner-item-{banner.id}">
|
||||||
<EllipsisVertical className="size-4 cursor-move item-handle" />
|
<EllipsisVertical className="size-4 cursor-move item-handle" />
|
||||||
|
|
||||||
<div class="flex flex-row flex-1 gap-2 items-center">
|
<div class="flex flex-row flex-1 gap-2 items-start">
|
||||||
<select
|
<select
|
||||||
class="w-fit capitalize rounded-xl text-xs bg-transparent outline-hidden text-left pl-1 pr-2"
|
class="w-fit capitalize rounded-xl text-xs bg-transparent outline-hidden pl-1 pr-5"
|
||||||
bind:value={banner.type}
|
bind:value={banner.type}
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
|
|
@ -64,14 +73,15 @@
|
||||||
<option value="success" class="text-gray-900">{$i18n.t('Success')}</option>
|
<option value="success" class="text-gray-900">{$i18n.t('Success')}</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<input
|
<Textarea
|
||||||
class="pr-5 py-1.5 text-xs w-full bg-transparent outline-hidden"
|
className="mr-2 text-xs w-full bg-transparent outline-hidden resize-none"
|
||||||
placeholder={$i18n.t('Content')}
|
placeholder={$i18n.t('Content')}
|
||||||
bind:value={banner.content}
|
bind:value={banner.content}
|
||||||
|
maxSize={100}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="relative -left-2">
|
<div class="relative -left-2">
|
||||||
<Tooltip content={$i18n.t('Dismissible')} className="flex h-fit items-center">
|
<Tooltip content={$i18n.t('Remember Dismissal')} className="flex h-fit items-center">
|
||||||
<Switch bind:state={banner.dismissible} />
|
<Switch bind:state={banner.dismissible} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -85,16 +95,7 @@
|
||||||
banners = banners;
|
banners = banners;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg
|
<XMark className={'size-4'} />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="currentColor"
|
|
||||||
class="w-4 h-4"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@
|
||||||
toggleModelById,
|
toggleModelById,
|
||||||
updateModelById
|
updateModelById
|
||||||
} from '$lib/apis/models';
|
} from '$lib/apis/models';
|
||||||
|
import { copyToClipboard } from '$lib/utils';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
|
||||||
import { getModels } from '$lib/apis';
|
import { getModels } from '$lib/apis';
|
||||||
import Search from '$lib/components/icons/Search.svelte';
|
import Search from '$lib/components/icons/Search.svelte';
|
||||||
|
|
@ -34,7 +36,7 @@
|
||||||
import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte';
|
import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte';
|
||||||
import EyeSlash from '$lib/components/icons/EyeSlash.svelte';
|
import EyeSlash from '$lib/components/icons/EyeSlash.svelte';
|
||||||
import Eye from '$lib/components/icons/Eye.svelte';
|
import Eye from '$lib/components/icons/Eye.svelte';
|
||||||
import { copyToClipboard } from '$lib/utils';
|
import { WEBUI_BASE_URL } from '$lib/constants';
|
||||||
|
|
||||||
let shiftKey = false;
|
let shiftKey = false;
|
||||||
|
|
||||||
|
|
@ -205,6 +207,11 @@
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
await init();
|
await init();
|
||||||
|
const id = $page.url.searchParams.get('id');
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
selectedModelId = id;
|
||||||
|
}
|
||||||
|
|
||||||
const onKeyDown = (event) => {
|
const onKeyDown = (event) => {
|
||||||
if (event.key === 'Shift') {
|
if (event.key === 'Shift') {
|
||||||
|
|
@ -326,7 +333,7 @@
|
||||||
: 'opacity-50 dark:opacity-50'} "
|
: 'opacity-50 dark:opacity-50'} "
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={model?.meta?.profile_image_url ?? '/static/favicon.png'}
|
src={model?.meta?.profile_image_url ?? `${WEBUI_BASE_URL}/static/favicon.png`}
|
||||||
alt="modelfile profile"
|
alt="modelfile profile"
|
||||||
class=" rounded-full w-full h-auto object-cover"
|
class=" rounded-full w-full h-auto object-cover"
|
||||||
/>
|
/>
|
||||||
|
|
@ -563,6 +570,6 @@
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<div class=" h-full w-full flex justify-center items-center">
|
<div class=" h-full w-full flex justify-center items-center">
|
||||||
<Spinner />
|
<Spinner className="size-5" />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
import Plus from '$lib/components/icons/Plus.svelte';
|
import Plus from '$lib/components/icons/Plus.svelte';
|
||||||
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
|
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
|
||||||
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
|
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
|
||||||
|
import XMark from '$lib/components/icons/XMark.svelte';
|
||||||
|
|
||||||
export let show = false;
|
export let show = false;
|
||||||
export let initHandler = () => {};
|
export let initHandler = () => {};
|
||||||
|
|
@ -129,16 +130,7 @@
|
||||||
show = false;
|
show = false;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg
|
<XMark className={'size-5'} />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="currentColor"
|
|
||||||
class="w-5 h-5"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -278,29 +270,7 @@
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="ml-2 self-center">
|
<div class="ml-2 self-center">
|
||||||
<svg
|
<Spinner />
|
||||||
class=" w-4 h-4"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="currentColor"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
><style>
|
|
||||||
.spinner_ajPY {
|
|
||||||
transform-origin: center;
|
|
||||||
animation: spinner_AtaB 0.75s infinite linear;
|
|
||||||
}
|
|
||||||
@keyframes spinner_AtaB {
|
|
||||||
100% {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style><path
|
|
||||||
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
|
|
||||||
opacity=".25"
|
|
||||||
/><path
|
|
||||||
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
|
|
||||||
class="spinner_ajPY"
|
|
||||||
/></svg
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -308,7 +278,7 @@
|
||||||
</form>
|
</form>
|
||||||
{:else}
|
{:else}
|
||||||
<div>
|
<div>
|
||||||
<Spinner />
|
<Spinner className="size-5" />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1057,6 +1057,6 @@
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex justify-center items-center w-full h-full py-3">
|
<div class="flex justify-center items-center w-full h-full py-3">
|
||||||
<Spinner />
|
<Spinner className="size-5" />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
|
|
||||||
import { user } from '$lib/stores';
|
import { user } from '$lib/stores';
|
||||||
|
|
||||||
|
import XMark from '$lib/components/icons/XMark.svelte';
|
||||||
import Modal from '$lib/components/common/Modal.svelte';
|
import Modal from '$lib/components/common/Modal.svelte';
|
||||||
import ManageOllama from './Manage/ManageOllama.svelte';
|
import ManageOllama from './Manage/ManageOllama.svelte';
|
||||||
import { getOllamaConfig } from '$lib/apis/ollama';
|
import { getOllamaConfig } from '$lib/apis/ollama';
|
||||||
|
|
@ -48,16 +49,7 @@
|
||||||
show = false;
|
show = false;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg
|
<XMark className={'size-5'} />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="currentColor"
|
|
||||||
class="w-5 h-5"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -75,7 +67,7 @@
|
||||||
class="flex gap-1 scrollbar-none overflow-x-auto w-fit text-center text-sm font-medium rounded-full bg-transparent dark:text-gray-200"
|
class="flex gap-1 scrollbar-none overflow-x-auto w-fit text-center text-sm font-medium rounded-full bg-transparent dark:text-gray-200"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
class="min-w-fit rounded-full p-1.5 {selected === 'ollama'
|
class="min-w-fit p-1.5 {selected === 'ollama'
|
||||||
? ''
|
? ''
|
||||||
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
|
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
|
|
@ -84,7 +76,7 @@
|
||||||
>
|
>
|
||||||
|
|
||||||
<!-- <button
|
<!-- <button
|
||||||
class="min-w-fit rounded-full p-1.5 {selected === 'llamacpp'
|
class="min-w-fit p-1.5 {selected === 'llamacpp'
|
||||||
? ''
|
? ''
|
||||||
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
|
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
|
|
|
||||||
|
|
@ -141,6 +141,7 @@
|
||||||
placeholder={$i18n.t('Enter Searxng Query URL')}
|
placeholder={$i18n.t('Enter Searxng Query URL')}
|
||||||
bind:value={webConfig.SEARXNG_QUERY_URL}
|
bind:value={webConfig.SEARXNG_QUERY_URL}
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -248,7 +249,6 @@
|
||||||
bind:value={webConfig.KAGI_SEARCH_API_KEY}
|
bind:value={webConfig.KAGI_SEARCH_API_KEY}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
.
|
|
||||||
</div>
|
</div>
|
||||||
{:else if webConfig.WEB_SEARCH_ENGINE === 'mojeek'}
|
{:else if webConfig.WEB_SEARCH_ENGINE === 'mojeek'}
|
||||||
<div class="mb-2.5 flex w-full flex-col">
|
<div class="mb-2.5 flex w-full flex-col">
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
import UsersSolid from '$lib/components/icons/UsersSolid.svelte';
|
import UsersSolid from '$lib/components/icons/UsersSolid.svelte';
|
||||||
import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
|
import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
|
||||||
import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte';
|
import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte';
|
||||||
|
import Search from '$lib/components/icons/Search.svelte';
|
||||||
import User from '$lib/components/icons/User.svelte';
|
import User from '$lib/components/icons/User.svelte';
|
||||||
import UserCircleSolid from '$lib/components/icons/UserCircleSolid.svelte';
|
import UserCircleSolid from '$lib/components/icons/UserCircleSolid.svelte';
|
||||||
import GroupModal from './Groups/EditGroupModal.svelte';
|
import GroupModal from './Groups/EditGroupModal.svelte';
|
||||||
|
|
@ -159,18 +160,7 @@
|
||||||
<div class=" flex w-full space-x-2">
|
<div class=" flex w-full space-x-2">
|
||||||
<div class="flex flex-1">
|
<div class="flex flex-1">
|
||||||
<div class=" self-center ml-1 mr-3">
|
<div class=" self-center ml-1 mr-3">
|
||||||
<svg
|
<Search />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="currentColor"
|
|
||||||
class="w-4 h-4"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fill-rule="evenodd"
|
|
||||||
d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z"
|
|
||||||
clip-rule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
|
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,10 @@
|
||||||
import { getContext, onMount } from 'svelte';
|
import { getContext, onMount } from 'svelte';
|
||||||
const i18n = getContext('i18n');
|
const i18n = getContext('i18n');
|
||||||
|
|
||||||
|
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||||
import Modal from '$lib/components/common/Modal.svelte';
|
import Modal from '$lib/components/common/Modal.svelte';
|
||||||
import Textarea from '$lib/components/common/Textarea.svelte';
|
import Textarea from '$lib/components/common/Textarea.svelte';
|
||||||
|
import XMark from '$lib/components/icons/XMark.svelte';
|
||||||
export let onSubmit: Function = () => {};
|
export let onSubmit: Function = () => {};
|
||||||
export let show = false;
|
export let show = false;
|
||||||
|
|
||||||
|
|
@ -45,16 +47,7 @@
|
||||||
show = false;
|
show = false;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg
|
<XMark className={'size-5'} />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="currentColor"
|
|
||||||
class="w-5 h-5"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -111,29 +104,7 @@
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="ml-2 self-center">
|
<div class="ml-2 self-center">
|
||||||
<svg
|
<Spinner />
|
||||||
class=" w-4 h-4"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="currentColor"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
><style>
|
|
||||||
.spinner_ajPY {
|
|
||||||
transform-origin: center;
|
|
||||||
animation: spinner_AtaB 0.75s infinite linear;
|
|
||||||
}
|
|
||||||
@keyframes spinner_AtaB {
|
|
||||||
100% {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style><path
|
|
||||||
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
|
|
||||||
opacity=".25"
|
|
||||||
/><path
|
|
||||||
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
|
|
||||||
class="spinner_ajPY"
|
|
||||||
/></svg
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { getContext, onMount } from 'svelte';
|
import { getContext, onMount } from 'svelte';
|
||||||
const i18n = getContext('i18n');
|
const i18n = getContext('i18n');
|
||||||
|
|
||||||
|
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||||
import Modal from '$lib/components/common/Modal.svelte';
|
import Modal from '$lib/components/common/Modal.svelte';
|
||||||
import Display from './Display.svelte';
|
import Display from './Display.svelte';
|
||||||
import Permissions from './Permissions.svelte';
|
import Permissions from './Permissions.svelte';
|
||||||
|
|
@ -10,6 +11,7 @@
|
||||||
import UserPlusSolid from '$lib/components/icons/UserPlusSolid.svelte';
|
import UserPlusSolid from '$lib/components/icons/UserPlusSolid.svelte';
|
||||||
import WrenchSolid from '$lib/components/icons/WrenchSolid.svelte';
|
import WrenchSolid from '$lib/components/icons/WrenchSolid.svelte';
|
||||||
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
||||||
|
import XMark from '$lib/components/icons/XMark.svelte';
|
||||||
|
|
||||||
export let onSubmit: Function = () => {};
|
export let onSubmit: Function = () => {};
|
||||||
export let onDelete: Function = () => {};
|
export let onDelete: Function = () => {};
|
||||||
|
|
@ -124,16 +126,7 @@
|
||||||
show = false;
|
show = false;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg
|
<XMark className={'size-5'} />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="currentColor"
|
|
||||||
class="w-5 h-5"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -305,29 +298,7 @@
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="ml-2 self-center">
|
<div class="ml-2 self-center">
|
||||||
<svg
|
<Spinner />
|
||||||
class=" w-4 h-4"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="currentColor"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
><style>
|
|
||||||
.spinner_ajPY {
|
|
||||||
transform-origin: center;
|
|
||||||
animation: spinner_AtaB 0.75s infinite linear;
|
|
||||||
}
|
|
||||||
@keyframes spinner_AtaB {
|
|
||||||
100% {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style><path
|
|
||||||
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
|
|
||||||
opacity=".25"
|
|
||||||
/><path
|
|
||||||
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
|
|
||||||
class="spinner_ajPY"
|
|
||||||
/></svg
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
import { WEBUI_BASE_URL } from '$lib/constants';
|
import { WEBUI_BASE_URL } from '$lib/constants';
|
||||||
import Checkbox from '$lib/components/common/Checkbox.svelte';
|
import Checkbox from '$lib/components/common/Checkbox.svelte';
|
||||||
import Badge from '$lib/components/common/Badge.svelte';
|
import Badge from '$lib/components/common/Badge.svelte';
|
||||||
|
import Search from '$lib/components/icons/Search.svelte';
|
||||||
|
|
||||||
export let users = [];
|
export let users = [];
|
||||||
export let userIds = [];
|
export let userIds = [];
|
||||||
|
|
@ -15,10 +16,6 @@
|
||||||
|
|
||||||
$: filteredUsers = users
|
$: filteredUsers = users
|
||||||
.filter((user) => {
|
.filter((user) => {
|
||||||
if (user?.role === 'admin') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (query === '') {
|
if (query === '') {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -50,18 +47,7 @@
|
||||||
<div class="flex w-full">
|
<div class="flex w-full">
|
||||||
<div class="flex flex-1">
|
<div class="flex flex-1">
|
||||||
<div class=" self-center mr-3">
|
<div class=" self-center mr-3">
|
||||||
<svg
|
<Search />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="currentColor"
|
|
||||||
class="w-4 h-4"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fill-rule="evenodd"
|
|
||||||
d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z"
|
|
||||||
clip-rule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
class=" w-full text-sm pr-4 rounded-r-xl outline-hidden bg-transparent"
|
class=" w-full text-sm pr-4 rounded-r-xl outline-hidden bg-transparent"
|
||||||
|
|
@ -98,7 +84,7 @@
|
||||||
user.profile_image_url.startsWith('https://www.gravatar.com/avatar/') ||
|
user.profile_image_url.startsWith('https://www.gravatar.com/avatar/') ||
|
||||||
user.profile_image_url.startsWith('data:')
|
user.profile_image_url.startsWith('data:')
|
||||||
? user.profile_image_url
|
? user.profile_image_url
|
||||||
: `/user.png`}
|
: `${WEBUI_BASE_URL}/user.png`}
|
||||||
alt="user"
|
alt="user"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -151,7 +151,7 @@
|
||||||
|
|
||||||
{#if users === null || total === null}
|
{#if users === null || total === null}
|
||||||
<div class="my-10">
|
<div class="my-10">
|
||||||
<Spinner />
|
<Spinner className="size-5" />
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="mt-0.5 mb-2 gap-1 flex flex-col md:flex-row justify-between">
|
<div class="mt-0.5 mb-2 gap-1 flex flex-col md:flex-row justify-between">
|
||||||
|
|
@ -396,7 +396,7 @@
|
||||||
user.profile_image_url.startsWith('https://www.gravatar.com/avatar/') ||
|
user.profile_image_url.startsWith('https://www.gravatar.com/avatar/') ||
|
||||||
user.profile_image_url.startsWith('data:')
|
user.profile_image_url.startsWith('data:')
|
||||||
? user.profile_image_url
|
? user.profile_image_url
|
||||||
: `/user.png`}
|
: `${WEBUI_BASE_URL}/user.png`}
|
||||||
alt="user"
|
alt="user"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,10 @@
|
||||||
|
|
||||||
import { WEBUI_BASE_URL } from '$lib/constants';
|
import { WEBUI_BASE_URL } from '$lib/constants';
|
||||||
|
|
||||||
|
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||||
import Modal from '$lib/components/common/Modal.svelte';
|
import Modal from '$lib/components/common/Modal.svelte';
|
||||||
import { generateInitialsImage } from '$lib/utils';
|
import { generateInitialsImage } from '$lib/utils';
|
||||||
|
import XMark from '$lib/components/icons/XMark.svelte';
|
||||||
|
|
||||||
const i18n = getContext('i18n');
|
const i18n = getContext('i18n');
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
|
|
@ -132,16 +134,7 @@
|
||||||
show = false;
|
show = false;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg
|
<XMark className={'size-5'} />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="currentColor"
|
|
||||||
class="w-5 h-5"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -157,7 +150,7 @@
|
||||||
class="flex -mt-2 mb-1.5 gap-1 scrollbar-none overflow-x-auto w-fit text-center text-sm font-medium rounded-full bg-transparent dark:text-gray-200"
|
class="flex -mt-2 mb-1.5 gap-1 scrollbar-none overflow-x-auto w-fit text-center text-sm font-medium rounded-full bg-transparent dark:text-gray-200"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
class="min-w-fit rounded-full p-1.5 {tab === ''
|
class="min-w-fit p-1.5 {tab === ''
|
||||||
? ''
|
? ''
|
||||||
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
|
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -167,7 +160,7 @@
|
||||||
>
|
>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="min-w-fit rounded-full p-1.5 {tab === 'import'
|
class="min-w-fit p-1.5 {tab === 'import'
|
||||||
? ''
|
? ''
|
||||||
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
|
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -293,29 +286,7 @@
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="ml-2 self-center">
|
<div class="ml-2 self-center">
|
||||||
<svg
|
<Spinner />
|
||||||
class=" w-4 h-4"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="currentColor"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
><style>
|
|
||||||
.spinner_ajPY {
|
|
||||||
transform-origin: center;
|
|
||||||
animation: spinner_AtaB 0.75s infinite linear;
|
|
||||||
}
|
|
||||||
@keyframes spinner_AtaB {
|
|
||||||
100% {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style><path
|
|
||||||
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
|
|
||||||
opacity=".25"
|
|
||||||
/><path
|
|
||||||
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
|
|
||||||
class="spinner_ajPY"
|
|
||||||
/></svg
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
|
|
||||||
import Modal from '$lib/components/common/Modal.svelte';
|
import Modal from '$lib/components/common/Modal.svelte';
|
||||||
import localizedFormat from 'dayjs/plugin/localizedFormat';
|
import localizedFormat from 'dayjs/plugin/localizedFormat';
|
||||||
|
import XMark from '$lib/components/icons/XMark.svelte';
|
||||||
|
|
||||||
const i18n = getContext('i18n');
|
const i18n = getContext('i18n');
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
|
|
@ -54,16 +55,7 @@
|
||||||
show = false;
|
show = false;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg
|
<XMark className={'size-5'} />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="currentColor"
|
|
||||||
class="w-5 h-5"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||||
import Plus from '$lib/components/icons/Plus.svelte';
|
import Plus from '$lib/components/icons/Plus.svelte';
|
||||||
|
import { WEBUI_BASE_URL } from '$lib/constants';
|
||||||
|
|
||||||
let selected = '';
|
let selected = '';
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -25,7 +26,7 @@
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src="/static/splash.png"
|
src="{WEBUI_BASE_URL}/static/splash.png"
|
||||||
class="size-11 dark:invert p-0.5"
|
class="size-11 dark:invert p-0.5"
|
||||||
alt="logo"
|
alt="logo"
|
||||||
draggable="false"
|
draggable="false"
|
||||||
|
|
@ -49,7 +50,7 @@
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src="/static/favicon.png"
|
src="{WEBUI_BASE_URL}/static/favicon.png"
|
||||||
class="size-10 {selected === '' ? 'rounded-2xl' : 'rounded-full'}"
|
class="size-10 {selected === '' ? 'rounded-2xl' : 'rounded-full'}"
|
||||||
alt="logo"
|
alt="logo"
|
||||||
draggable="false"
|
draggable="false"
|
||||||
|
|
|
||||||
|
|
@ -246,7 +246,7 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class=" pb-[1rem]">
|
<div class=" pb-[1rem] px-2.5">
|
||||||
<MessageInput
|
<MessageInput
|
||||||
id="root"
|
id="root"
|
||||||
{typingUsers}
|
{typingUsers}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,24 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import heic2any from 'heic2any';
|
||||||
|
|
||||||
import { tick, getContext, onMount, onDestroy } from 'svelte';
|
import { tick, getContext, onMount, onDestroy } from 'svelte';
|
||||||
|
|
||||||
const i18n = getContext('i18n');
|
const i18n = getContext('i18n');
|
||||||
|
|
||||||
import { config, mobile, settings, socket } from '$lib/stores';
|
import { config, mobile, settings, socket, user } from '$lib/stores';
|
||||||
import { blobToFile, compressImage } from '$lib/utils';
|
import {
|
||||||
|
blobToFile,
|
||||||
|
compressImage,
|
||||||
|
extractInputVariables,
|
||||||
|
getCurrentDateTime,
|
||||||
|
getFormattedDate,
|
||||||
|
getFormattedTime,
|
||||||
|
getUserPosition,
|
||||||
|
getUserTimezone,
|
||||||
|
getWeekday
|
||||||
|
} from '$lib/utils';
|
||||||
|
|
||||||
import Tooltip from '../common/Tooltip.svelte';
|
import Tooltip from '../common/Tooltip.svelte';
|
||||||
import RichTextInput from '../common/RichTextInput.svelte';
|
import RichTextInput from '../common/RichTextInput.svelte';
|
||||||
|
|
@ -18,6 +29,8 @@
|
||||||
import FileItem from '../common/FileItem.svelte';
|
import FileItem from '../common/FileItem.svelte';
|
||||||
import Image from '../common/Image.svelte';
|
import Image from '../common/Image.svelte';
|
||||||
import FilesOverlay from '../chat/MessageInput/FilesOverlay.svelte';
|
import FilesOverlay from '../chat/MessageInput/FilesOverlay.svelte';
|
||||||
|
import Commands from '../chat/MessageInput/Commands.svelte';
|
||||||
|
import InputVariablesModal from '../chat/MessageInput/InputVariablesModal.svelte';
|
||||||
|
|
||||||
export let placeholder = $i18n.t('Send a Message');
|
export let placeholder = $i18n.t('Send a Message');
|
||||||
export let transparentBackground = false;
|
export let transparentBackground = false;
|
||||||
|
|
@ -30,16 +43,185 @@
|
||||||
let content = '';
|
let content = '';
|
||||||
let files = [];
|
let files = [];
|
||||||
|
|
||||||
|
export let chatInputElement;
|
||||||
|
|
||||||
|
let commandsElement;
|
||||||
let filesInputElement;
|
let filesInputElement;
|
||||||
let inputFiles;
|
let inputFiles;
|
||||||
|
|
||||||
export let typingUsers = [];
|
export let typingUsers = [];
|
||||||
|
export let inputLoading = false;
|
||||||
|
|
||||||
|
export let onSubmit: Function = (e) => {};
|
||||||
|
export let onChange: Function = (e) => {};
|
||||||
|
export let onStop: Function = (e) => {};
|
||||||
|
|
||||||
export let onSubmit: Function;
|
|
||||||
export let onChange: Function;
|
|
||||||
export let scrollEnd = true;
|
export let scrollEnd = true;
|
||||||
export let scrollToBottom: Function = () => {};
|
export let scrollToBottom: Function = () => {};
|
||||||
|
|
||||||
|
export let acceptFiles = true;
|
||||||
|
export let showFormattingButtons = true;
|
||||||
|
|
||||||
|
let showInputVariablesModal = false;
|
||||||
|
let inputVariables: Record<string, any> = {};
|
||||||
|
let inputVariableValues = {};
|
||||||
|
|
||||||
|
const inputVariableHandler = async (text: string) => {
|
||||||
|
inputVariables = extractInputVariables(text);
|
||||||
|
if (Object.keys(inputVariables).length > 0) {
|
||||||
|
showInputVariablesModal = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const textVariableHandler = async (text: string) => {
|
||||||
|
if (text.includes('{{CLIPBOARD}}')) {
|
||||||
|
const clipboardText = await navigator.clipboard.readText().catch((err) => {
|
||||||
|
toast.error($i18n.t('Failed to read clipboard contents'));
|
||||||
|
return '{{CLIPBOARD}}';
|
||||||
|
});
|
||||||
|
|
||||||
|
const clipboardItems = await navigator.clipboard.read();
|
||||||
|
|
||||||
|
let imageUrl = null;
|
||||||
|
for (const item of clipboardItems) {
|
||||||
|
// Check for known image types
|
||||||
|
for (const type of item.types) {
|
||||||
|
if (type.startsWith('image/')) {
|
||||||
|
const blob = await item.getType(type);
|
||||||
|
imageUrl = URL.createObjectURL(blob);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageUrl) {
|
||||||
|
files = [
|
||||||
|
...files,
|
||||||
|
{
|
||||||
|
type: 'image',
|
||||||
|
url: imageUrl
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
text = text.replaceAll('{{CLIPBOARD}}', clipboardText);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.includes('{{USER_LOCATION}}')) {
|
||||||
|
let location;
|
||||||
|
try {
|
||||||
|
location = await getUserPosition();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error($i18n.t('Location access not allowed'));
|
||||||
|
location = 'LOCATION_UNKNOWN';
|
||||||
|
}
|
||||||
|
text = text.replaceAll('{{USER_LOCATION}}', String(location));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.includes('{{USER_NAME}}')) {
|
||||||
|
const name = $user?.name || 'User';
|
||||||
|
text = text.replaceAll('{{USER_NAME}}', name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.includes('{{USER_LANGUAGE}}')) {
|
||||||
|
const language = localStorage.getItem('locale') || 'en-US';
|
||||||
|
text = text.replaceAll('{{USER_LANGUAGE}}', language);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.includes('{{CURRENT_DATE}}')) {
|
||||||
|
const date = getFormattedDate();
|
||||||
|
text = text.replaceAll('{{CURRENT_DATE}}', date);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.includes('{{CURRENT_TIME}}')) {
|
||||||
|
const time = getFormattedTime();
|
||||||
|
text = text.replaceAll('{{CURRENT_TIME}}', time);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.includes('{{CURRENT_DATETIME}}')) {
|
||||||
|
const dateTime = getCurrentDateTime();
|
||||||
|
text = text.replaceAll('{{CURRENT_DATETIME}}', dateTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.includes('{{CURRENT_TIMEZONE}}')) {
|
||||||
|
const timezone = getUserTimezone();
|
||||||
|
text = text.replaceAll('{{CURRENT_TIMEZONE}}', timezone);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.includes('{{CURRENT_WEEKDAY}}')) {
|
||||||
|
const weekday = getWeekday();
|
||||||
|
text = text.replaceAll('{{CURRENT_WEEKDAY}}', weekday);
|
||||||
|
}
|
||||||
|
|
||||||
|
inputVariableHandler(text);
|
||||||
|
return text;
|
||||||
|
};
|
||||||
|
|
||||||
|
const replaceVariables = (variables: Record<string, any>) => {
|
||||||
|
if (!chatInputElement) return;
|
||||||
|
console.log('Replacing variables:', variables);
|
||||||
|
|
||||||
|
chatInputElement.replaceVariables(variables);
|
||||||
|
chatInputElement.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setText = async (text?: string) => {
|
||||||
|
if (!chatInputElement) return;
|
||||||
|
|
||||||
|
text = await textVariableHandler(text || '');
|
||||||
|
|
||||||
|
chatInputElement?.setText(text);
|
||||||
|
chatInputElement?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCommand = () => {
|
||||||
|
if (!chatInputElement) return;
|
||||||
|
|
||||||
|
let word = '';
|
||||||
|
word = chatInputElement?.getWordAtDocPos();
|
||||||
|
|
||||||
|
return word;
|
||||||
|
};
|
||||||
|
|
||||||
|
const replaceCommandWithText = (text) => {
|
||||||
|
if (!chatInputElement) return;
|
||||||
|
|
||||||
|
chatInputElement?.replaceCommandWithText(text);
|
||||||
|
};
|
||||||
|
|
||||||
|
const insertTextAtCursor = async (text: string) => {
|
||||||
|
text = await textVariableHandler(text);
|
||||||
|
|
||||||
|
if (command) {
|
||||||
|
replaceCommandWithText(text);
|
||||||
|
} else {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (selection && selection.rangeCount > 0) {
|
||||||
|
const range = selection.getRangeAt(0);
|
||||||
|
range.deleteContents();
|
||||||
|
range.insertNode(document.createTextNode(text));
|
||||||
|
range.collapse(false);
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await tick();
|
||||||
|
const chatInputContainer = document.getElementById('chat-input-container');
|
||||||
|
if (chatInputContainer) {
|
||||||
|
chatInputContainer.scrollTop = chatInputContainer.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
await tick();
|
||||||
|
if (chatInputElement) {
|
||||||
|
chatInputElement.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let command = '';
|
||||||
|
|
||||||
|
export let showCommands = false;
|
||||||
|
$: showCommands = ['/'].includes(command?.charAt(0));
|
||||||
|
|
||||||
const screenCaptureHandler = async () => {
|
const screenCaptureHandler = async () => {
|
||||||
try {
|
try {
|
||||||
// Request screen media
|
// Request screen media
|
||||||
|
|
@ -78,7 +260,7 @@
|
||||||
};
|
};
|
||||||
|
|
||||||
const inputFilesHandler = async (inputFiles) => {
|
const inputFilesHandler = async (inputFiles) => {
|
||||||
inputFiles.forEach((file) => {
|
inputFiles.forEach(async (file) => {
|
||||||
console.info('Processing file:', {
|
console.info('Processing file:', {
|
||||||
name: file.name,
|
name: file.name,
|
||||||
type: file.type,
|
type: file.type,
|
||||||
|
|
@ -102,43 +284,50 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (file['type'].startsWith('image/')) {
|
||||||
['image/gif', 'image/webp', 'image/jpeg', 'image/png', 'image/avif'].includes(file['type'])
|
const compressImageHandler = async (imageUrl, settings = {}, config = {}) => {
|
||||||
) {
|
// Quick shortcut so we don’t do unnecessary work.
|
||||||
|
const settingsCompression = settings?.imageCompression ?? false;
|
||||||
|
const configWidth = config?.file?.image_compression?.width ?? null;
|
||||||
|
const configHeight = config?.file?.image_compression?.height ?? null;
|
||||||
|
|
||||||
|
// If neither settings nor config wants compression, return original URL.
|
||||||
|
if (!settingsCompression && !configWidth && !configHeight) {
|
||||||
|
return imageUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to null (no compression unless set)
|
||||||
|
let width = null;
|
||||||
|
let height = null;
|
||||||
|
|
||||||
|
// If user/settings want compression, pick their preferred size.
|
||||||
|
if (settingsCompression) {
|
||||||
|
width = settings?.imageCompressionSize?.width ?? null;
|
||||||
|
height = settings?.imageCompressionSize?.height ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply config limits as an upper bound if any
|
||||||
|
if (configWidth && (width === null || width > configWidth)) {
|
||||||
|
width = configWidth;
|
||||||
|
}
|
||||||
|
if (configHeight && (height === null || height > configHeight)) {
|
||||||
|
height = configHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do the compression if required
|
||||||
|
if (width || height) {
|
||||||
|
return await compressImage(imageUrl, width, height);
|
||||||
|
}
|
||||||
|
return imageUrl;
|
||||||
|
};
|
||||||
|
|
||||||
let reader = new FileReader();
|
let reader = new FileReader();
|
||||||
|
|
||||||
reader.onload = async (event) => {
|
reader.onload = async (event) => {
|
||||||
let imageUrl = event.target.result;
|
let imageUrl = event.target.result;
|
||||||
|
|
||||||
if (
|
// Compress the image if settings or config require it
|
||||||
($settings?.imageCompression ?? false) ||
|
imageUrl = await compressImageHandler(imageUrl, $settings, $config);
|
||||||
($config?.file?.image_compression?.width ?? null) ||
|
|
||||||
($config?.file?.image_compression?.height ?? null)
|
|
||||||
) {
|
|
||||||
let width = null;
|
|
||||||
let height = null;
|
|
||||||
|
|
||||||
if ($settings?.imageCompression ?? false) {
|
|
||||||
width = $settings?.imageCompressionSize?.width ?? null;
|
|
||||||
height = $settings?.imageCompressionSize?.height ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
($config?.file?.image_compression?.width ?? null) ||
|
|
||||||
($config?.file?.image_compression?.height ?? null)
|
|
||||||
) {
|
|
||||||
if (width > ($config?.file?.image_compression?.width ?? null)) {
|
|
||||||
width = $config?.file?.image_compression?.width ?? null;
|
|
||||||
}
|
|
||||||
if (height > ($config?.file?.image_compression?.height ?? null)) {
|
|
||||||
height = $config?.file?.image_compression?.height ?? null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (width || height) {
|
|
||||||
imageUrl = await compressImage(imageUrl, width, height);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
files = [
|
files = [
|
||||||
...files,
|
...files,
|
||||||
|
|
@ -149,7 +338,11 @@
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(
|
||||||
|
file['type'] === 'image/heic'
|
||||||
|
? await heic2any({ blob: file, toType: 'image/jpeg' })
|
||||||
|
: file
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
uploadFileHandler(file);
|
uploadFileHandler(file);
|
||||||
}
|
}
|
||||||
|
|
@ -247,7 +440,7 @@
|
||||||
const onDrop = async (e) => {
|
const onDrop = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (e.dataTransfer?.files) {
|
if (e.dataTransfer?.files && acceptFiles) {
|
||||||
const inputFiles = Array.from(e.dataTransfer?.files);
|
const inputFiles = Array.from(e.dataTransfer?.files);
|
||||||
if (inputFiles && inputFiles.length > 0) {
|
if (inputFiles && inputFiles.length > 0) {
|
||||||
console.log(inputFiles);
|
console.log(inputFiles);
|
||||||
|
|
@ -273,10 +466,13 @@
|
||||||
content = '';
|
content = '';
|
||||||
files = [];
|
files = [];
|
||||||
|
|
||||||
await tick();
|
if (chatInputElement) {
|
||||||
|
chatInputElement?.setText('');
|
||||||
|
|
||||||
const chatInputElement = document.getElementById(`chat-input-${id}`);
|
await tick();
|
||||||
chatInputElement?.focus();
|
|
||||||
|
chatInputElement.focus();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
$: if (content) {
|
$: if (content) {
|
||||||
|
|
@ -285,9 +481,10 @@
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
const chatInput = document.getElementById(`chat-input-${id}`);
|
if (chatInputElement) {
|
||||||
chatInput?.focus();
|
chatInputElement.focus();
|
||||||
}, 0);
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
await tick();
|
await tick();
|
||||||
|
|
@ -314,27 +511,39 @@
|
||||||
|
|
||||||
<FilesOverlay show={draggedOver} />
|
<FilesOverlay show={draggedOver} />
|
||||||
|
|
||||||
<input
|
{#if acceptFiles}
|
||||||
bind:this={filesInputElement}
|
<input
|
||||||
bind:files={inputFiles}
|
bind:this={filesInputElement}
|
||||||
type="file"
|
bind:files={inputFiles}
|
||||||
hidden
|
type="file"
|
||||||
multiple
|
hidden
|
||||||
on:change={async () => {
|
multiple
|
||||||
if (inputFiles && inputFiles.length > 0) {
|
on:change={async () => {
|
||||||
inputFilesHandler(Array.from(inputFiles));
|
if (inputFiles && inputFiles.length > 0) {
|
||||||
} else {
|
inputFilesHandler(Array.from(inputFiles));
|
||||||
toast.error($i18n.t(`File not found.`));
|
} else {
|
||||||
}
|
toast.error($i18n.t(`File not found.`));
|
||||||
|
}
|
||||||
|
|
||||||
filesInputElement.value = '';
|
filesInputElement.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<InputVariablesModal
|
||||||
|
bind:show={showInputVariablesModal}
|
||||||
|
variables={inputVariables}
|
||||||
|
onSave={(variableValues) => {
|
||||||
|
inputVariableValues = { ...inputVariableValues, ...variableValues };
|
||||||
|
replaceVariables(inputVariableValues);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="bg-transparent">
|
<div class="bg-transparent">
|
||||||
<div
|
<div
|
||||||
class="{($settings?.widescreenMode ?? null)
|
class="{($settings?.widescreenMode ?? null)
|
||||||
? 'max-w-full'
|
? 'max-w-full'
|
||||||
: 'max-w-6xl'} px-2.5 mx-auto inset-x-0 relative"
|
: 'max-w-6xl'} mx-auto inset-x-0 relative"
|
||||||
>
|
>
|
||||||
<div class="absolute top-0 left-0 right-0 mx-auto inset-x-0 bg-transparent flex justify-center">
|
<div class="absolute top-0 left-0 right-0 mx-auto inset-x-0 bg-transparent flex justify-center">
|
||||||
<div class="flex flex-col px-3 w-full">
|
<div class="flex flex-col px-3 w-full">
|
||||||
|
|
@ -378,6 +587,13 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Commands
|
||||||
|
bind:this={commandsElement}
|
||||||
|
show={showCommands}
|
||||||
|
{command}
|
||||||
|
insertTextHandler={insertTextAtCursor}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -390,15 +606,23 @@
|
||||||
recording = false;
|
recording = false;
|
||||||
|
|
||||||
await tick();
|
await tick();
|
||||||
document.getElementById(`chat-input-${id}`)?.focus();
|
|
||||||
|
if (chatInputElement) {
|
||||||
|
chatInputElement.focus();
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
onConfirm={async (data) => {
|
onConfirm={async (data) => {
|
||||||
const { text, filename } = data;
|
const { text, filename } = data;
|
||||||
content = `${content}${text} `;
|
|
||||||
recording = false;
|
recording = false;
|
||||||
|
|
||||||
await tick();
|
await tick();
|
||||||
document.getElementById(`chat-input-${id}`)?.focus();
|
insertTextAtCursor(text);
|
||||||
|
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
if (chatInputElement) {
|
||||||
|
chatInputElement.focus();
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
|
|
@ -470,40 +694,95 @@
|
||||||
|
|
||||||
<div class="px-2.5">
|
<div class="px-2.5">
|
||||||
<div
|
<div
|
||||||
class="scrollbar-hidden font-primary text-left bg-transparent dark:text-gray-100 outline-hidden w-full pt-3 px-1 rounded-xl resize-none h-fit max-h-80 overflow-auto"
|
class="scrollbar-hidden font-primary text-left bg-transparent dark:text-gray-100 outline-hidden w-full pt-3 px-1 resize-none h-fit max-h-80 overflow-auto"
|
||||||
>
|
>
|
||||||
<RichTextInput
|
<RichTextInput
|
||||||
bind:value={content}
|
bind:this={chatInputElement}
|
||||||
id={`chat-input-${id}`}
|
json={true}
|
||||||
messageInput={true}
|
messageInput={true}
|
||||||
shiftEnter={!$mobile ||
|
{showFormattingButtons}
|
||||||
!(
|
shiftEnter={!($settings?.ctrlEnterToSend ?? false) &&
|
||||||
'ontouchstart' in window ||
|
(!$mobile ||
|
||||||
navigator.maxTouchPoints > 0 ||
|
|
||||||
navigator.msMaxTouchPoints > 0
|
|
||||||
)}
|
|
||||||
{placeholder}
|
|
||||||
largeTextAsFile={$settings?.largeTextAsFile ?? false}
|
|
||||||
on:keydown={async (e) => {
|
|
||||||
e = e.detail.event;
|
|
||||||
const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac
|
|
||||||
if (
|
|
||||||
!$mobile ||
|
|
||||||
!(
|
!(
|
||||||
'ontouchstart' in window ||
|
'ontouchstart' in window ||
|
||||||
navigator.maxTouchPoints > 0 ||
|
navigator.maxTouchPoints > 0 ||
|
||||||
navigator.msMaxTouchPoints > 0
|
navigator.msMaxTouchPoints > 0
|
||||||
)
|
))}
|
||||||
) {
|
largeTextAsFile={$settings?.largeTextAsFile ?? false}
|
||||||
// Prevent Enter key from creating a new line
|
onChange={(e) => {
|
||||||
// Uses keyCode '13' for Enter key for chinese/japanese keyboards
|
const { md } = e;
|
||||||
if (e.keyCode === 13 && !e.shiftKey) {
|
content = md;
|
||||||
|
command = getCommand();
|
||||||
|
}}
|
||||||
|
on:keydown={async (e) => {
|
||||||
|
e = e.detail.event;
|
||||||
|
const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac
|
||||||
|
|
||||||
|
const commandsContainerElement = document.getElementById('commands-container');
|
||||||
|
|
||||||
|
if (commandsContainerElement) {
|
||||||
|
if (commandsContainerElement && e.key === 'ArrowUp') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
commandsElement.selectUp();
|
||||||
|
|
||||||
|
const commandOptionButton = [
|
||||||
|
...document.getElementsByClassName('selected-command-option-button')
|
||||||
|
]?.at(-1);
|
||||||
|
commandOptionButton.scrollIntoView({ block: 'center' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Submit the content when Enter key is pressed
|
if (commandsContainerElement && e.key === 'ArrowDown') {
|
||||||
if (content !== '' && e.keyCode === 13 && !e.shiftKey) {
|
e.preventDefault();
|
||||||
submitHandler();
|
commandsElement.selectDown();
|
||||||
|
|
||||||
|
const commandOptionButton = [
|
||||||
|
...document.getElementsByClassName('selected-command-option-button')
|
||||||
|
]?.at(-1);
|
||||||
|
commandOptionButton.scrollIntoView({ block: 'center' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (commandsContainerElement && e.key === 'Tab') {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const commandOptionButton = [
|
||||||
|
...document.getElementsByClassName('selected-command-option-button')
|
||||||
|
]?.at(-1);
|
||||||
|
|
||||||
|
commandOptionButton?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (commandsContainerElement && e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const commandOptionButton = [
|
||||||
|
...document.getElementsByClassName('selected-command-option-button')
|
||||||
|
]?.at(-1);
|
||||||
|
|
||||||
|
if (commandOptionButton) {
|
||||||
|
commandOptionButton?.click();
|
||||||
|
} else {
|
||||||
|
document.getElementById('send-message-button')?.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (
|
||||||
|
!$mobile ||
|
||||||
|
!(
|
||||||
|
'ontouchstart' in window ||
|
||||||
|
navigator.maxTouchPoints > 0 ||
|
||||||
|
navigator.msMaxTouchPoints > 0
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
// Prevent Enter key from creating a new line
|
||||||
|
// Uses keyCode '13' for Enter key for chinese/japanese keyboards
|
||||||
|
if (e.keyCode === 13 && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit the content when Enter key is pressed
|
||||||
|
if (content !== '' && e.keyCode === 13 && !e.shiftKey) {
|
||||||
|
submitHandler();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -520,30 +799,34 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class=" flex justify-between mb-2.5 mt-1.5 mx-0.5">
|
<div class=" flex justify-between mb-2.5 mt-1.5 mx-0.5">
|
||||||
<div class="ml-1 self-end flex space-x-1">
|
<div class="ml-1 self-end flex space-x-1 flex-1">
|
||||||
<InputMenu
|
<slot name="menu">
|
||||||
{screenCaptureHandler}
|
{#if acceptFiles}
|
||||||
uploadFilesHandler={() => {
|
<InputMenu
|
||||||
filesInputElement.click();
|
{screenCaptureHandler}
|
||||||
}}
|
uploadFilesHandler={() => {
|
||||||
>
|
filesInputElement.click();
|
||||||
<button
|
}}
|
||||||
class="bg-transparent hover:bg-white/80 text-gray-800 dark:text-white dark:hover:bg-gray-800 transition rounded-full p-1.5 outline-hidden focus:outline-hidden"
|
|
||||||
type="button"
|
|
||||||
aria-label="More"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="currentColor"
|
|
||||||
class="size-5"
|
|
||||||
>
|
>
|
||||||
<path
|
<button
|
||||||
d="M10.75 4.75a.75.75 0 0 0-1.5 0v4.5h-4.5a.75.75 0 0 0 0 1.5h4.5v4.5a.75.75 0 0 0 1.5 0v-4.5h4.5a.75.75 0 0 0 0-1.5h-4.5v-4.5Z"
|
class="bg-transparent hover:bg-white/80 text-gray-800 dark:text-white dark:hover:bg-gray-800 transition rounded-full p-1.5 outline-hidden focus:outline-hidden"
|
||||||
/>
|
type="button"
|
||||||
</svg>
|
aria-label="More"
|
||||||
</button>
|
>
|
||||||
</InputMenu>
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
class="size-5"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M10.75 4.75a.75.75 0 0 0-1.5 0v4.5h-4.5a.75.75 0 0 0 0 1.5h4.5v4.5a.75.75 0 0 0 1.5 0v-4.5h4.5a.75.75 0 0 0 0-1.5h-4.5v-4.5Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</InputMenu>
|
||||||
|
{/if}
|
||||||
|
</slot>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="self-end flex space-x-1 mr-1">
|
<div class="self-end flex space-x-1 mr-1">
|
||||||
|
|
@ -594,31 +877,57 @@
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class=" flex items-center">
|
<div class=" flex items-center">
|
||||||
<div class=" flex items-center">
|
{#if inputLoading && onStop}
|
||||||
<Tooltip content={$i18n.t('Send message')}>
|
<div class=" flex items-center">
|
||||||
<button
|
<Tooltip content={$i18n.t('Stop')}>
|
||||||
id="send-message-button"
|
<button
|
||||||
class="{content !== '' || files.length !== 0
|
class="bg-white hover:bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-800 transition rounded-full p-1.5"
|
||||||
? 'bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100 '
|
on:click={() => {
|
||||||
: 'text-white bg-gray-200 dark:text-gray-900 dark:bg-gray-700 disabled'} transition rounded-full p-1.5 self-center"
|
onStop();
|
||||||
type="submit"
|
}}
|
||||||
disabled={content === '' && files.length === 0}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
fill="currentColor"
|
|
||||||
class="size-5"
|
|
||||||
>
|
>
|
||||||
<path
|
<svg
|
||||||
fill-rule="evenodd"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
d="M8 14a.75.75 0 0 1-.75-.75V4.56L4.03 7.78a.75.75 0 0 1-1.06-1.06l4.5-4.5a.75.75 0 0 1 1.06 0l4.5 4.5a.75.75 0 0 1-1.06 1.06L8.75 4.56v8.69A.75.75 0 0 1 8 14Z"
|
viewBox="0 0 24 24"
|
||||||
clip-rule="evenodd"
|
fill="currentColor"
|
||||||
/>
|
class="size-5"
|
||||||
</svg>
|
>
|
||||||
</button>
|
<path
|
||||||
</Tooltip>
|
fill-rule="evenodd"
|
||||||
</div>
|
d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm6-2.438c0-.724.588-1.312 1.313-1.312h4.874c.725 0 1.313.588 1.313 1.313v4.874c0 .725-.588 1.313-1.313 1.313H9.564a1.312 1.312 0 01-1.313-1.313V9.564z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class=" flex items-center">
|
||||||
|
<Tooltip content={$i18n.t('Send message')}>
|
||||||
|
<button
|
||||||
|
id="send-message-button"
|
||||||
|
class="{content !== '' || files.length !== 0
|
||||||
|
? 'bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100 '
|
||||||
|
: 'text-white bg-gray-200 dark:text-gray-900 dark:bg-gray-700 disabled'} transition rounded-full p-1.5 self-center"
|
||||||
|
type="submit"
|
||||||
|
disabled={content === '' && files.length === 0}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="currentColor"
|
||||||
|
class="size-5"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M8 14a.75.75 0 0 1-.75-.75V4.56L4.03 7.78a.75.75 0 0 1-1.06-1.06l4.5-4.5a.75.75 0 0 1 1.06 0l4.5 4.5a.75.75 0 0 1-1.06 1.06L8.75 4.56v8.69A.75.75 0 0 1 8 14Z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -144,7 +144,9 @@
|
||||||
<ProfilePreview user={message.user}>
|
<ProfilePreview user={message.user}>
|
||||||
<ProfileImage
|
<ProfileImage
|
||||||
src={message.user?.profile_image_url ??
|
src={message.user?.profile_image_url ??
|
||||||
($i18n.language === 'dg-DG' ? `/doge.png` : `${WEBUI_BASE_URL}/static/favicon.png`)}
|
($i18n.language === 'dg-DG'
|
||||||
|
? `${WEBUI_BASE_URL}/doge.png`
|
||||||
|
: `${WEBUI_BASE_URL}/static/favicon.png`)}
|
||||||
className={'size-8 translate-y-1 ml-0.5'}
|
className={'size-8 translate-y-1 ml-0.5'}
|
||||||
/>
|
/>
|
||||||
</ProfilePreview>
|
</ProfilePreview>
|
||||||
|
|
@ -275,7 +277,7 @@
|
||||||
>
|
>
|
||||||
{#if $shortCodesToEmojis[reaction.name]}
|
{#if $shortCodesToEmojis[reaction.name]}
|
||||||
<img
|
<img
|
||||||
src="/assets/emojis/{$shortCodesToEmojis[
|
src="{WEBUI_BASE_URL}/assets/emojis/{$shortCodesToEmojis[
|
||||||
reaction.name
|
reaction.name
|
||||||
].toLowerCase()}.svg"
|
].toLowerCase()}.svg"
|
||||||
alt={reaction.name}
|
alt={reaction.name}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
import emojiShortCodes from '$lib/emoji-shortcodes.json';
|
import emojiShortCodes from '$lib/emoji-shortcodes.json';
|
||||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||||
import VirtualList from '@sveltejs/svelte-virtual-list';
|
import VirtualList from '@sveltejs/svelte-virtual-list';
|
||||||
|
import { WEBUI_BASE_URL } from '$lib/constants';
|
||||||
|
|
||||||
export let onClose = () => {};
|
export let onClose = () => {};
|
||||||
export let onSubmit = (name) => {};
|
export let onSubmit = (name) => {};
|
||||||
|
|
@ -147,7 +148,7 @@
|
||||||
on:click={() => selectEmoji(emojiItem)}
|
on:click={() => selectEmoji(emojiItem)}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src="/assets/emojis/{emojiItem.name.toLowerCase()}.svg"
|
src="{WEBUI_BASE_URL}/assets/emojis/{emojiItem.name.toLowerCase()}.svg"
|
||||||
alt={emojiItem.name}
|
alt={emojiItem.name}
|
||||||
class="size-5"
|
class="size-5"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
|
|
||||||
|
|
@ -119,7 +119,7 @@
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitHandler = async ({ content, data }) => {
|
const submitHandler = async ({ content, data }) => {
|
||||||
if (!content) {
|
if (!content && (data?.files ?? []).length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -196,7 +196,7 @@
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class=" pb-[1rem]">
|
<div class=" pb-[1rem] px-2.5">
|
||||||
<MessageInput id={threadId} {typingUsers} {onChange} onSubmit={submitHandler} />
|
<MessageInput id={threadId} {typingUsers} {onChange} onSubmit={submitHandler} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,8 @@
|
||||||
chatTitle,
|
chatTitle,
|
||||||
showArtifacts,
|
showArtifacts,
|
||||||
tools,
|
tools,
|
||||||
toolServers
|
toolServers,
|
||||||
|
selectedFolder
|
||||||
} from '$lib/stores';
|
} from '$lib/stores';
|
||||||
import {
|
import {
|
||||||
convertMessagesToHistory,
|
convertMessagesToHistory,
|
||||||
|
|
@ -55,10 +56,7 @@
|
||||||
|
|
||||||
import { generateChatCompletion } from '$lib/apis/ollama';
|
import { generateChatCompletion } from '$lib/apis/ollama';
|
||||||
import {
|
import {
|
||||||
addTagById,
|
|
||||||
createNewChat,
|
createNewChat,
|
||||||
deleteTagById,
|
|
||||||
deleteTagsById,
|
|
||||||
getAllTags,
|
getAllTags,
|
||||||
getChatById,
|
getChatById,
|
||||||
getChatList,
|
getChatList,
|
||||||
|
|
@ -99,6 +97,8 @@
|
||||||
let controlPane;
|
let controlPane;
|
||||||
let controlPaneComponent;
|
let controlPaneComponent;
|
||||||
|
|
||||||
|
let messageInput;
|
||||||
|
|
||||||
let autoScroll = true;
|
let autoScroll = true;
|
||||||
let processing = '';
|
let processing = '';
|
||||||
let messagesContainerElement: HTMLDivElement;
|
let messagesContainerElement: HTMLDivElement;
|
||||||
|
|
@ -126,6 +126,8 @@
|
||||||
let webSearchEnabled = false;
|
let webSearchEnabled = false;
|
||||||
let codeInterpreterEnabled = false;
|
let codeInterpreterEnabled = false;
|
||||||
|
|
||||||
|
let showCommands = false;
|
||||||
|
|
||||||
let chat = null;
|
let chat = null;
|
||||||
let tags = [];
|
let tags = [];
|
||||||
|
|
||||||
|
|
@ -143,24 +145,38 @@
|
||||||
let params = {};
|
let params = {};
|
||||||
|
|
||||||
$: if (chatIdProp) {
|
$: if (chatIdProp) {
|
||||||
(async () => {
|
navigateHandler();
|
||||||
loading = true;
|
}
|
||||||
|
|
||||||
prompt = '';
|
const navigateHandler = async () => {
|
||||||
files = [];
|
loading = true;
|
||||||
selectedToolIds = [];
|
|
||||||
selectedFilterIds = [];
|
|
||||||
webSearchEnabled = false;
|
|
||||||
imageGenerationEnabled = false;
|
|
||||||
|
|
||||||
if (localStorage.getItem(`chat-input${chatIdProp ? `-${chatIdProp}` : ''}`)) {
|
prompt = '';
|
||||||
|
messageInput?.setText('');
|
||||||
|
|
||||||
|
files = [];
|
||||||
|
selectedToolIds = [];
|
||||||
|
selectedFilterIds = [];
|
||||||
|
webSearchEnabled = false;
|
||||||
|
imageGenerationEnabled = false;
|
||||||
|
|
||||||
|
const storageChatInput = sessionStorage.getItem(
|
||||||
|
`chat-input${chatIdProp ? `-${chatIdProp}` : ''}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (chatIdProp && (await loadChat())) {
|
||||||
|
await tick();
|
||||||
|
loading = false;
|
||||||
|
window.setTimeout(() => scrollToBottom(), 0);
|
||||||
|
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
if (storageChatInput) {
|
||||||
try {
|
try {
|
||||||
const input = JSON.parse(
|
const input = JSON.parse(storageChatInput);
|
||||||
localStorage.getItem(`chat-input${chatIdProp ? `-${chatIdProp}` : ''}`)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!$temporaryChatEnabled) {
|
if (!$temporaryChatEnabled) {
|
||||||
prompt = input.prompt;
|
messageInput?.setText(input.prompt);
|
||||||
files = input.files;
|
files = input.files;
|
||||||
selectedToolIds = input.selectedToolIds;
|
selectedToolIds = input.selectedToolIds;
|
||||||
selectedFilterIds = input.selectedFilterIds;
|
selectedFilterIds = input.selectedFilterIds;
|
||||||
|
|
@ -171,17 +187,21 @@
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (chatIdProp && (await loadChat())) {
|
const chatInput = document.getElementById('chat-input');
|
||||||
await tick();
|
chatInput?.focus();
|
||||||
loading = false;
|
} else {
|
||||||
window.setTimeout(() => scrollToBottom(), 0);
|
await goto('/');
|
||||||
const chatInput = document.getElementById('chat-input');
|
}
|
||||||
chatInput?.focus();
|
};
|
||||||
} else {
|
|
||||||
await goto('/');
|
const onSelect = async (e) => {
|
||||||
}
|
const { type, data } = e;
|
||||||
})();
|
|
||||||
}
|
if (type === 'prompt') {
|
||||||
|
// Handle prompt selection
|
||||||
|
messageInput?.setText(data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
$: if (selectedModels && chatIdProp !== '') {
|
$: if (selectedModels && chatIdProp !== '') {
|
||||||
saveSessionSelectedModels();
|
saveSessionSelectedModels();
|
||||||
|
|
@ -408,7 +428,7 @@
|
||||||
const inputElement = document.getElementById('chat-input');
|
const inputElement = document.getElementById('chat-input');
|
||||||
|
|
||||||
if (inputElement) {
|
if (inputElement) {
|
||||||
prompt = event.data.text;
|
messageInput?.setText(event.data.text);
|
||||||
inputElement.focus();
|
inputElement.focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -446,8 +466,19 @@
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (localStorage.getItem(`chat-input${chatIdProp ? `-${chatIdProp}` : ''}`)) {
|
const storageChatInput = sessionStorage.getItem(
|
||||||
|
`chat-input${chatIdProp ? `-${chatIdProp}` : ''}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!chatIdProp) {
|
||||||
|
loading = false;
|
||||||
|
await tick();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (storageChatInput) {
|
||||||
prompt = '';
|
prompt = '';
|
||||||
|
messageInput?.setText('');
|
||||||
|
|
||||||
files = [];
|
files = [];
|
||||||
selectedToolIds = [];
|
selectedToolIds = [];
|
||||||
selectedFilterIds = [];
|
selectedFilterIds = [];
|
||||||
|
|
@ -456,12 +487,10 @@
|
||||||
codeInterpreterEnabled = false;
|
codeInterpreterEnabled = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const input = JSON.parse(
|
const input = JSON.parse(storageChatInput);
|
||||||
localStorage.getItem(`chat-input${chatIdProp ? `-${chatIdProp}` : ''}`)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!$temporaryChatEnabled) {
|
if (!$temporaryChatEnabled) {
|
||||||
prompt = input.prompt;
|
messageInput?.setText(input.prompt);
|
||||||
files = input.files;
|
files = input.files;
|
||||||
selectedToolIds = input.selectedToolIds;
|
selectedToolIds = input.selectedToolIds;
|
||||||
selectedFilterIds = input.selectedFilterIds;
|
selectedFilterIds = input.selectedFilterIds;
|
||||||
|
|
@ -472,11 +501,6 @@
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!chatIdProp) {
|
|
||||||
loading = false;
|
|
||||||
await tick();
|
|
||||||
}
|
|
||||||
|
|
||||||
showControls.subscribe(async (value) => {
|
showControls.subscribe(async (value) => {
|
||||||
if (controlPane && !$mobile) {
|
if (controlPane && !$mobile) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -708,6 +732,10 @@
|
||||||
//////////////////////////
|
//////////////////////////
|
||||||
|
|
||||||
const initNewChat = async () => {
|
const initNewChat = async () => {
|
||||||
|
if ($user?.role !== 'admin' && $user?.permissions?.chat?.temporary_enforced) {
|
||||||
|
await temporaryChatEnabled.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
const availableModels = $models
|
const availableModels = $models
|
||||||
.filter((m) => !(m?.info?.meta?.hidden ?? false))
|
.filter((m) => !(m?.info?.meta?.hidden ?? false))
|
||||||
.map((m) => m.id);
|
.map((m) => m.id);
|
||||||
|
|
@ -832,11 +860,14 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($page.url.searchParams.get('q')) {
|
if ($page.url.searchParams.get('q')) {
|
||||||
prompt = $page.url.searchParams.get('q') ?? '';
|
const q = $page.url.searchParams.get('q') ?? '';
|
||||||
|
messageInput?.setText(q);
|
||||||
|
|
||||||
if (prompt) {
|
if (q) {
|
||||||
await tick();
|
if (($page.url.searchParams.get('submit') ?? 'true') === 'true') {
|
||||||
submitPrompt(prompt);
|
await tick();
|
||||||
|
submitPrompt(q);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1068,7 +1099,7 @@
|
||||||
};
|
};
|
||||||
|
|
||||||
const createMessagePair = async (userPrompt) => {
|
const createMessagePair = async (userPrompt) => {
|
||||||
prompt = '';
|
messageInput?.setText('');
|
||||||
if (selectedModels.length === 0) {
|
if (selectedModels.length === 0) {
|
||||||
toast.error($i18n.t('Model not selected'));
|
toast.error($i18n.t('Model not selected'));
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1389,7 +1420,7 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
prompt = '';
|
messageInput?.setText('');
|
||||||
|
|
||||||
// Reset chat input textarea
|
// Reset chat input textarea
|
||||||
if (!($settings?.richTextInput ?? true)) {
|
if (!($settings?.richTextInput ?? true)) {
|
||||||
|
|
@ -1410,7 +1441,7 @@
|
||||||
);
|
);
|
||||||
|
|
||||||
files = [];
|
files = [];
|
||||||
prompt = '';
|
messageInput?.setText('');
|
||||||
|
|
||||||
// Create user message
|
// Create user message
|
||||||
let userMessageId = uuidv4();
|
let userMessageId = uuidv4();
|
||||||
|
|
@ -1567,9 +1598,8 @@
|
||||||
let files = JSON.parse(JSON.stringify(chatFiles));
|
let files = JSON.parse(JSON.stringify(chatFiles));
|
||||||
files.push(
|
files.push(
|
||||||
...(userMessage?.files ?? []).filter((item) =>
|
...(userMessage?.files ?? []).filter((item) =>
|
||||||
['doc', 'file', 'collection'].includes(item.type)
|
['doc', 'text', 'file', 'note', 'collection'].includes(item.type)
|
||||||
),
|
)
|
||||||
...(responseMessage?.files ?? []).filter((item) => ['web_search_results'].includes(item.type))
|
|
||||||
);
|
);
|
||||||
// Remove duplicates
|
// Remove duplicates
|
||||||
files = files.filter(
|
files = files.filter(
|
||||||
|
|
@ -1949,25 +1979,31 @@
|
||||||
let _chatId = $chatId;
|
let _chatId = $chatId;
|
||||||
|
|
||||||
if (!$temporaryChatEnabled) {
|
if (!$temporaryChatEnabled) {
|
||||||
chat = await createNewChat(localStorage.token, {
|
chat = await createNewChat(
|
||||||
id: _chatId,
|
localStorage.token,
|
||||||
title: $i18n.t('New Chat'),
|
{
|
||||||
models: selectedModels,
|
id: _chatId,
|
||||||
system: $settings.system ?? undefined,
|
title: $i18n.t('New Chat'),
|
||||||
params: params,
|
models: selectedModels,
|
||||||
history: history,
|
system: $settings.system ?? undefined,
|
||||||
messages: createMessagesList(history, history.currentId),
|
params: params,
|
||||||
tags: [],
|
history: history,
|
||||||
timestamp: Date.now()
|
messages: createMessagesList(history, history.currentId),
|
||||||
});
|
tags: [],
|
||||||
|
timestamp: Date.now()
|
||||||
|
},
|
||||||
|
$selectedFolder?.id
|
||||||
|
);
|
||||||
|
|
||||||
_chatId = chat.id;
|
_chatId = chat.id;
|
||||||
await chatId.set(_chatId);
|
await chatId.set(_chatId);
|
||||||
|
|
||||||
|
window.history.replaceState(history.state, '', `/c/${_chatId}`);
|
||||||
|
|
||||||
|
await tick();
|
||||||
|
|
||||||
await chats.set(await getChatList(localStorage.token, $currentChatPage));
|
await chats.set(await getChatList(localStorage.token, $currentChatPage));
|
||||||
currentChatPage.set(1);
|
currentChatPage.set(1);
|
||||||
|
|
||||||
window.history.replaceState(history.state, '', `/c/${_chatId}`);
|
|
||||||
} else {
|
} else {
|
||||||
_chatId = 'local';
|
_chatId = 'local';
|
||||||
await chatId.set('local');
|
await chatId.set('local');
|
||||||
|
|
@ -2064,6 +2100,7 @@
|
||||||
bind:selectedModels
|
bind:selectedModels
|
||||||
shareEnabled={!!history.currentId}
|
shareEnabled={!!history.currentId}
|
||||||
{initNewChat}
|
{initNewChat}
|
||||||
|
showBanners={!showCommands}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="flex flex-col flex-auto z-10 w-full @container">
|
<div class="flex flex-col flex-auto z-10 w-full @container">
|
||||||
|
|
@ -2095,12 +2132,14 @@
|
||||||
{chatActionHandler}
|
{chatActionHandler}
|
||||||
{addMessages}
|
{addMessages}
|
||||||
bottomPadding={files.length > 0}
|
bottomPadding={files.length > 0}
|
||||||
|
{onSelect}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class=" pb-2">
|
<div class=" pb-2">
|
||||||
<MessageInput
|
<MessageInput
|
||||||
|
bind:this={messageInput}
|
||||||
{history}
|
{history}
|
||||||
{taskIds}
|
{taskIds}
|
||||||
{selectedModels}
|
{selectedModels}
|
||||||
|
|
@ -2113,6 +2152,7 @@
|
||||||
bind:codeInterpreterEnabled
|
bind:codeInterpreterEnabled
|
||||||
bind:webSearchEnabled
|
bind:webSearchEnabled
|
||||||
bind:atSelectedModel
|
bind:atSelectedModel
|
||||||
|
bind:showCommands
|
||||||
toolServers={$toolServers}
|
toolServers={$toolServers}
|
||||||
transparentBackground={$settings?.backgroundImageUrl ?? false}
|
transparentBackground={$settings?.backgroundImageUrl ?? false}
|
||||||
{stopResponse}
|
{stopResponse}
|
||||||
|
|
@ -2120,12 +2160,12 @@
|
||||||
onChange={(input) => {
|
onChange={(input) => {
|
||||||
if (!$temporaryChatEnabled) {
|
if (!$temporaryChatEnabled) {
|
||||||
if (input.prompt !== null) {
|
if (input.prompt !== null) {
|
||||||
localStorage.setItem(
|
sessionStorage.setItem(
|
||||||
`chat-input${$chatId ? `-${$chatId}` : ''}`,
|
`chat-input${$chatId ? `-${$chatId}` : ''}`,
|
||||||
JSON.stringify(input)
|
JSON.stringify(input)
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
localStorage.removeItem(`chat-input${$chatId ? `-${$chatId}` : ''}`);
|
sessionStorage.removeItem(`chat-input${$chatId ? `-${$chatId}` : ''}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|
@ -2163,6 +2203,7 @@
|
||||||
<Placeholder
|
<Placeholder
|
||||||
{history}
|
{history}
|
||||||
{selectedModels}
|
{selectedModels}
|
||||||
|
bind:messageInput
|
||||||
bind:files
|
bind:files
|
||||||
bind:prompt
|
bind:prompt
|
||||||
bind:autoScroll
|
bind:autoScroll
|
||||||
|
|
@ -2172,10 +2213,12 @@
|
||||||
bind:codeInterpreterEnabled
|
bind:codeInterpreterEnabled
|
||||||
bind:webSearchEnabled
|
bind:webSearchEnabled
|
||||||
bind:atSelectedModel
|
bind:atSelectedModel
|
||||||
|
bind:showCommands
|
||||||
transparentBackground={$settings?.backgroundImageUrl ?? false}
|
transparentBackground={$settings?.backgroundImageUrl ?? false}
|
||||||
toolServers={$toolServers}
|
toolServers={$toolServers}
|
||||||
{stopResponse}
|
{stopResponse}
|
||||||
{createMessagePair}
|
{createMessagePair}
|
||||||
|
{onSelect}
|
||||||
on:upload={async (e) => {
|
on:upload={async (e) => {
|
||||||
const { type, data } = e.detail;
|
const { type, data } = e.detail;
|
||||||
|
|
||||||
|
|
@ -2227,7 +2270,7 @@
|
||||||
{:else if loading}
|
{:else if loading}
|
||||||
<div class=" flex items-center justify-center h-full w-full">
|
<div class=" flex items-center justify-center h-full w-full">
|
||||||
<div class="m-auto">
|
<div class="m-auto">
|
||||||
<Spinner />
|
<Spinner className="size-5" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@
|
||||||
import Overview from './Overview.svelte';
|
import Overview from './Overview.svelte';
|
||||||
import EllipsisVertical from '../icons/EllipsisVertical.svelte';
|
import EllipsisVertical from '../icons/EllipsisVertical.svelte';
|
||||||
import Artifacts from './Artifacts.svelte';
|
import Artifacts from './Artifacts.svelte';
|
||||||
import { min } from '@floating-ui/utils';
|
|
||||||
|
|
||||||
export let history;
|
export let history;
|
||||||
export let models = [];
|
export let models = [];
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@
|
||||||
export let models = [];
|
export let models = [];
|
||||||
export let atSelectedModel;
|
export let atSelectedModel;
|
||||||
|
|
||||||
export let submitPrompt;
|
export let onSelect = (e) => {};
|
||||||
|
|
||||||
let mounted = false;
|
let mounted = false;
|
||||||
let selectedModelIdx = 0;
|
let selectedModelIdx = 0;
|
||||||
|
|
@ -46,7 +46,9 @@
|
||||||
>
|
>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={marked.parse(
|
content={marked.parse(
|
||||||
sanitizeResponseContent(models[selectedModelIdx]?.info?.meta?.description ?? '')
|
sanitizeResponseContent(
|
||||||
|
models[selectedModelIdx]?.info?.meta?.description ?? ''
|
||||||
|
).replaceAll('\n', '<br>')
|
||||||
)}
|
)}
|
||||||
placement="right"
|
placement="right"
|
||||||
>
|
>
|
||||||
|
|
@ -54,7 +56,7 @@
|
||||||
crossorigin="anonymous"
|
crossorigin="anonymous"
|
||||||
src={model?.info?.meta?.profile_image_url ??
|
src={model?.info?.meta?.profile_image_url ??
|
||||||
($i18n.language === 'dg-DG'
|
($i18n.language === 'dg-DG'
|
||||||
? `/doge.png`
|
? `${WEBUI_BASE_URL}/doge.png`
|
||||||
: `${WEBUI_BASE_URL}/static/favicon.png`)}
|
: `${WEBUI_BASE_URL}/static/favicon.png`)}
|
||||||
class=" size-[2.7rem] rounded-full border-[1px] border-gray-100 dark:border-none"
|
class=" size-[2.7rem] rounded-full border-[1px] border-gray-100 dark:border-none"
|
||||||
alt="logo"
|
alt="logo"
|
||||||
|
|
@ -68,7 +70,7 @@
|
||||||
|
|
||||||
{#if $temporaryChatEnabled}
|
{#if $temporaryChatEnabled}
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={$i18n.t('This chat won’t 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"
|
className="w-full flex justify-start mb-0.5"
|
||||||
placement="top"
|
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"
|
class="mt-0.5 text-base font-normal text-gray-500 dark:text-gray-400 line-clamp-3 markdown"
|
||||||
>
|
>
|
||||||
{@html marked.parse(
|
{@html marked.parse(
|
||||||
sanitizeResponseContent(models[selectedModelIdx]?.info?.meta?.description)
|
sanitizeResponseContent(
|
||||||
|
models[selectedModelIdx]?.info?.meta?.description
|
||||||
|
).replaceAll('\n', '<br>')
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{#if models[selectedModelIdx]?.info?.meta?.user}
|
{#if models[selectedModelIdx]?.info?.meta?.user}
|
||||||
|
|
@ -131,9 +135,7 @@
|
||||||
models[selectedModelIdx]?.info?.meta?.suggestion_prompts ??
|
models[selectedModelIdx]?.info?.meta?.suggestion_prompts ??
|
||||||
$config?.default_prompt_suggestions ??
|
$config?.default_prompt_suggestions ??
|
||||||
[]}
|
[]}
|
||||||
on:select={(e) => {
|
{onSelect}
|
||||||
submitPrompt(e.detail);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
import FileItem from '$lib/components/common/FileItem.svelte';
|
import FileItem from '$lib/components/common/FileItem.svelte';
|
||||||
import Collapsible from '$lib/components/common/Collapsible.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 models = [];
|
||||||
export let chatFiles = [];
|
export let chatFiles = [];
|
||||||
export let params = {};
|
export let params = {};
|
||||||
|
|
@ -74,7 +74,9 @@
|
||||||
<div class="" slot="content">
|
<div class="" slot="content">
|
||||||
<textarea
|
<textarea
|
||||||
bind:value={params.system}
|
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"
|
rows="4"
|
||||||
placeholder={$i18n.t('Enter system prompt')}
|
placeholder={$i18n.t('Enter system prompt')}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import * as pdfjs from 'pdfjs-dist';
|
||||||
|
import * as pdfWorker from 'pdfjs-dist/build/pdf.worker.mjs';
|
||||||
|
pdfjs.GlobalWorkerOptions.workerSrc = import.meta.url + 'pdfjs-dist/build/pdf.worker.mjs';
|
||||||
|
|
||||||
import DOMPurify from 'dompurify';
|
import DOMPurify from 'dompurify';
|
||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
|
import heic2any from 'heic2any';
|
||||||
|
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
|
|
||||||
|
|
@ -22,14 +27,23 @@
|
||||||
tools,
|
tools,
|
||||||
user as _user,
|
user as _user,
|
||||||
showControls,
|
showControls,
|
||||||
TTSWorker
|
TTSWorker,
|
||||||
|
temporaryChatEnabled
|
||||||
} from '$lib/stores';
|
} from '$lib/stores';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
blobToFile,
|
blobToFile,
|
||||||
compressImage,
|
compressImage,
|
||||||
createMessagesList,
|
createMessagesList,
|
||||||
extractCurlyBraceWords
|
extractContentFromFile,
|
||||||
|
extractCurlyBraceWords,
|
||||||
|
extractInputVariables,
|
||||||
|
getCurrentDateTime,
|
||||||
|
getFormattedDate,
|
||||||
|
getFormattedTime,
|
||||||
|
getUserPosition,
|
||||||
|
getUserTimezone,
|
||||||
|
getWeekday
|
||||||
} from '$lib/utils';
|
} from '$lib/utils';
|
||||||
import { uploadFile } from '$lib/apis/files';
|
import { uploadFile } from '$lib/apis/files';
|
||||||
import { generateAutoCompletion } from '$lib/apis';
|
import { generateAutoCompletion } from '$lib/apis';
|
||||||
|
|
@ -57,7 +71,7 @@
|
||||||
import Sparkles from '../icons/Sparkles.svelte';
|
import Sparkles from '../icons/Sparkles.svelte';
|
||||||
|
|
||||||
import { KokoroWorker } from '$lib/workers/KokoroWorker';
|
import { KokoroWorker } from '$lib/workers/KokoroWorker';
|
||||||
|
import InputVariablesModal from './MessageInput/InputVariablesModal.svelte';
|
||||||
const i18n = getContext('i18n');
|
const i18n = getContext('i18n');
|
||||||
|
|
||||||
export let transparentBackground = false;
|
export let transparentBackground = false;
|
||||||
|
|
@ -89,6 +103,10 @@
|
||||||
export let webSearchEnabled = false;
|
export let webSearchEnabled = false;
|
||||||
export let codeInterpreterEnabled = false;
|
export let codeInterpreterEnabled = false;
|
||||||
|
|
||||||
|
let showInputVariablesModal = false;
|
||||||
|
let inputVariables = {};
|
||||||
|
let inputVariableValues = {};
|
||||||
|
|
||||||
$: onChange({
|
$: onChange({
|
||||||
prompt,
|
prompt,
|
||||||
files: files
|
files: files
|
||||||
|
|
@ -107,6 +125,254 @@
|
||||||
codeInterpreterEnabled
|
codeInterpreterEnabled
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const inputVariableHandler = async (text: string) => {
|
||||||
|
inputVariables = extractInputVariables(text);
|
||||||
|
if (Object.keys(inputVariables).length > 0) {
|
||||||
|
showInputVariablesModal = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const textVariableHandler = async (text: string) => {
|
||||||
|
if (text.includes('{{CLIPBOARD}}')) {
|
||||||
|
const clipboardText = await navigator.clipboard.readText().catch((err) => {
|
||||||
|
toast.error($i18n.t('Failed to read clipboard contents'));
|
||||||
|
return '{{CLIPBOARD}}';
|
||||||
|
});
|
||||||
|
|
||||||
|
const clipboardItems = await navigator.clipboard.read();
|
||||||
|
|
||||||
|
let imageUrl = null;
|
||||||
|
for (const item of clipboardItems) {
|
||||||
|
// Check for known image types
|
||||||
|
for (const type of item.types) {
|
||||||
|
if (type.startsWith('image/')) {
|
||||||
|
const blob = await item.getType(type);
|
||||||
|
imageUrl = URL.createObjectURL(blob);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageUrl) {
|
||||||
|
files = [
|
||||||
|
...files,
|
||||||
|
{
|
||||||
|
type: 'image',
|
||||||
|
url: imageUrl
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
text = text.replaceAll('{{CLIPBOARD}}', clipboardText);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.includes('{{USER_LOCATION}}')) {
|
||||||
|
let location;
|
||||||
|
try {
|
||||||
|
location = await getUserPosition();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error($i18n.t('Location access not allowed'));
|
||||||
|
location = 'LOCATION_UNKNOWN';
|
||||||
|
}
|
||||||
|
text = text.replaceAll('{{USER_LOCATION}}', String(location));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.includes('{{USER_NAME}}')) {
|
||||||
|
const name = $_user?.name || 'User';
|
||||||
|
text = text.replaceAll('{{USER_NAME}}', name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.includes('{{USER_LANGUAGE}}')) {
|
||||||
|
const language = localStorage.getItem('locale') || 'en-US';
|
||||||
|
text = text.replaceAll('{{USER_LANGUAGE}}', language);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.includes('{{CURRENT_DATE}}')) {
|
||||||
|
const date = getFormattedDate();
|
||||||
|
text = text.replaceAll('{{CURRENT_DATE}}', date);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.includes('{{CURRENT_TIME}}')) {
|
||||||
|
const time = getFormattedTime();
|
||||||
|
text = text.replaceAll('{{CURRENT_TIME}}', time);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.includes('{{CURRENT_DATETIME}}')) {
|
||||||
|
const dateTime = getCurrentDateTime();
|
||||||
|
text = text.replaceAll('{{CURRENT_DATETIME}}', dateTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.includes('{{CURRENT_TIMEZONE}}')) {
|
||||||
|
const timezone = getUserTimezone();
|
||||||
|
text = text.replaceAll('{{CURRENT_TIMEZONE}}', timezone);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.includes('{{CURRENT_WEEKDAY}}')) {
|
||||||
|
const weekday = getWeekday();
|
||||||
|
text = text.replaceAll('{{CURRENT_WEEKDAY}}', weekday);
|
||||||
|
}
|
||||||
|
|
||||||
|
inputVariableHandler(text);
|
||||||
|
return text;
|
||||||
|
};
|
||||||
|
|
||||||
|
const replaceVariables = (variables: Record<string, any>) => {
|
||||||
|
console.log('Replacing variables:', variables);
|
||||||
|
|
||||||
|
const chatInput = document.getElementById('chat-input');
|
||||||
|
|
||||||
|
if (chatInput) {
|
||||||
|
if ($settings?.richTextInput ?? true) {
|
||||||
|
chatInputElement.replaceVariables(variables);
|
||||||
|
chatInputElement.focus();
|
||||||
|
} else {
|
||||||
|
// Get current value from the input element
|
||||||
|
let currentValue = chatInput.value || '';
|
||||||
|
|
||||||
|
// Replace template variables using regex
|
||||||
|
const updatedValue = currentValue.replace(
|
||||||
|
/{{\s*([^|}]+)(?:\|[^}]*)?\s*}}/g,
|
||||||
|
(match, varName) => {
|
||||||
|
const trimmedVarName = varName.trim();
|
||||||
|
return variables.hasOwnProperty(trimmedVarName)
|
||||||
|
? String(variables[trimmedVarName])
|
||||||
|
: match;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update the input value
|
||||||
|
chatInput.value = updatedValue;
|
||||||
|
chatInput.focus();
|
||||||
|
chatInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setText = async (text?: string) => {
|
||||||
|
const chatInput = document.getElementById('chat-input');
|
||||||
|
|
||||||
|
if (chatInput) {
|
||||||
|
text = await textVariableHandler(text || '');
|
||||||
|
|
||||||
|
if ($settings?.richTextInput ?? true) {
|
||||||
|
chatInputElement?.setText(text);
|
||||||
|
chatInputElement?.focus();
|
||||||
|
} else {
|
||||||
|
chatInput.value = text;
|
||||||
|
prompt = text;
|
||||||
|
|
||||||
|
chatInput.focus();
|
||||||
|
chatInput.dispatchEvent(new Event('input'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCommand = () => {
|
||||||
|
const getWordAtCursor = (text, cursor) => {
|
||||||
|
if (typeof text !== 'string' || cursor == null) return '';
|
||||||
|
const left = text.slice(0, cursor);
|
||||||
|
const right = text.slice(cursor);
|
||||||
|
const leftWord = left.match(/(?:^|\s)([^\s]*)$/)?.[1] || '';
|
||||||
|
|
||||||
|
const rightWord = right.match(/^([^\s]*)/)?.[1] || '';
|
||||||
|
return leftWord + rightWord;
|
||||||
|
};
|
||||||
|
|
||||||
|
const chatInput = document.getElementById('chat-input');
|
||||||
|
let word = '';
|
||||||
|
|
||||||
|
if (chatInput) {
|
||||||
|
if ($settings?.richTextInput ?? true) {
|
||||||
|
word = chatInputElement?.getWordAtDocPos();
|
||||||
|
} else {
|
||||||
|
const cursor = chatInput ? chatInput.selectionStart : prompt.length;
|
||||||
|
word = getWordAtCursor(prompt, cursor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return word;
|
||||||
|
};
|
||||||
|
|
||||||
|
const replaceCommandWithText = (text) => {
|
||||||
|
const getWordBoundsAtCursor = (text, cursor) => {
|
||||||
|
let start = cursor,
|
||||||
|
end = cursor;
|
||||||
|
while (start > 0 && !/\s/.test(text[start - 1])) --start;
|
||||||
|
while (end < text.length && !/\s/.test(text[end])) ++end;
|
||||||
|
return { start, end };
|
||||||
|
};
|
||||||
|
|
||||||
|
const chatInput = document.getElementById('chat-input');
|
||||||
|
if (!chatInput) return;
|
||||||
|
|
||||||
|
if ($settings?.richTextInput ?? true) {
|
||||||
|
chatInputElement?.replaceCommandWithText(text);
|
||||||
|
} else {
|
||||||
|
const cursor = chatInput.selectionStart;
|
||||||
|
const { start, end } = getWordBoundsAtCursor(prompt, cursor);
|
||||||
|
prompt = prompt.slice(0, start) + text + prompt.slice(end);
|
||||||
|
chatInput.focus();
|
||||||
|
chatInput.setSelectionRange(start + text.length, start + text.length);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const insertTextAtCursor = async (text: string) => {
|
||||||
|
const chatInput = document.getElementById('chat-input');
|
||||||
|
if (!chatInput) return;
|
||||||
|
|
||||||
|
text = await textVariableHandler(text);
|
||||||
|
|
||||||
|
if (command) {
|
||||||
|
replaceCommandWithText(text);
|
||||||
|
} else {
|
||||||
|
if ($settings?.richTextInput ?? true) {
|
||||||
|
chatInputElement?.insertContent(text);
|
||||||
|
} else {
|
||||||
|
const cursor = chatInput.selectionStart;
|
||||||
|
prompt = prompt.slice(0, cursor) + text + prompt.slice(cursor);
|
||||||
|
chatInput.focus();
|
||||||
|
chatInput.setSelectionRange(cursor + text.length, cursor + text.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await tick();
|
||||||
|
const chatInputContainer = document.getElementById('chat-input-container');
|
||||||
|
if (chatInputContainer) {
|
||||||
|
chatInputContainer.scrollTop = chatInputContainer.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
await tick();
|
||||||
|
if (chatInput) {
|
||||||
|
chatInput.focus();
|
||||||
|
chatInput.dispatchEvent(new Event('input'));
|
||||||
|
|
||||||
|
const words = extractCurlyBraceWords(prompt);
|
||||||
|
|
||||||
|
if (words.length > 0) {
|
||||||
|
const word = words.at(0);
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
if (!($settings?.richTextInput ?? true)) {
|
||||||
|
// Move scroll to the first word
|
||||||
|
chatInput.setSelectionRange(word.startIndex, word.endIndex + 1);
|
||||||
|
chatInput.focus();
|
||||||
|
|
||||||
|
const selectionRow =
|
||||||
|
(word?.startIndex - (word?.startIndex % chatInput.cols)) / chatInput.cols;
|
||||||
|
const lineHeight = chatInput.clientHeight / chatInput.rows;
|
||||||
|
|
||||||
|
chatInput.scrollTop = lineHeight * selectionRow;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
chatInput.scrollTop = chatInput.scrollHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let command = '';
|
||||||
|
|
||||||
|
export let showCommands = false;
|
||||||
|
$: showCommands = ['/', '#', '@'].includes(command?.charAt(0)) || '\\#' === command?.slice(0, 2);
|
||||||
|
|
||||||
let showTools = false;
|
let showTools = false;
|
||||||
|
|
||||||
let loaded = false;
|
let loaded = false;
|
||||||
|
|
@ -261,47 +527,77 @@
|
||||||
|
|
||||||
files = [...files, fileItem];
|
files = [...files, fileItem];
|
||||||
|
|
||||||
try {
|
if (!$temporaryChatEnabled) {
|
||||||
// If the file is an audio file, provide the language for STT.
|
try {
|
||||||
let metadata = null;
|
// If the file is an audio file, provide the language for STT.
|
||||||
if (
|
let metadata = null;
|
||||||
(file.type.startsWith('audio/') || file.type.startsWith('video/')) &&
|
if (
|
||||||
$settings?.audio?.stt?.language
|
(file.type.startsWith('audio/') || file.type.startsWith('video/')) &&
|
||||||
) {
|
$settings?.audio?.stt?.language
|
||||||
metadata = {
|
) {
|
||||||
language: $settings?.audio?.stt?.language
|
metadata = {
|
||||||
};
|
language: $settings?.audio?.stt?.language
|
||||||
}
|
};
|
||||||
|
|
||||||
// During the file upload, file content is automatically extracted.
|
|
||||||
const uploadedFile = await uploadFile(localStorage.token, file, metadata);
|
|
||||||
|
|
||||||
if (uploadedFile) {
|
|
||||||
console.log('File upload completed:', {
|
|
||||||
id: uploadedFile.id,
|
|
||||||
name: fileItem.name,
|
|
||||||
collection: uploadedFile?.meta?.collection_name
|
|
||||||
});
|
|
||||||
|
|
||||||
if (uploadedFile.error) {
|
|
||||||
console.warn('File upload warning:', uploadedFile.error);
|
|
||||||
toast.warning(uploadedFile.error);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fileItem.status = 'uploaded';
|
// During the file upload, file content is automatically extracted.
|
||||||
fileItem.file = uploadedFile;
|
const uploadedFile = await uploadFile(localStorage.token, file, metadata);
|
||||||
fileItem.id = uploadedFile.id;
|
|
||||||
fileItem.collection_name =
|
|
||||||
uploadedFile?.meta?.collection_name || uploadedFile?.collection_name;
|
|
||||||
fileItem.url = `${WEBUI_API_BASE_URL}/files/${uploadedFile.id}`;
|
|
||||||
|
|
||||||
files = files;
|
if (uploadedFile) {
|
||||||
} else {
|
console.log('File upload completed:', {
|
||||||
|
id: uploadedFile.id,
|
||||||
|
name: fileItem.name,
|
||||||
|
collection: uploadedFile?.meta?.collection_name
|
||||||
|
});
|
||||||
|
|
||||||
|
if (uploadedFile.error) {
|
||||||
|
console.warn('File upload warning:', uploadedFile.error);
|
||||||
|
toast.warning(uploadedFile.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
fileItem.status = 'uploaded';
|
||||||
|
fileItem.file = uploadedFile;
|
||||||
|
fileItem.id = uploadedFile.id;
|
||||||
|
fileItem.collection_name =
|
||||||
|
uploadedFile?.meta?.collection_name || uploadedFile?.collection_name;
|
||||||
|
fileItem.url = `${WEBUI_API_BASE_URL}/files/${uploadedFile.id}`;
|
||||||
|
|
||||||
|
files = files;
|
||||||
|
} else {
|
||||||
|
files = files.filter((item) => item?.itemId !== tempItemId);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(`${e}`);
|
||||||
files = files.filter((item) => item?.itemId !== tempItemId);
|
files = files.filter((item) => item?.itemId !== tempItemId);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} else {
|
||||||
toast.error(`${e}`);
|
// If temporary chat is enabled, we just add the file to the list without uploading it.
|
||||||
files = files.filter((item) => item?.itemId !== tempItemId);
|
|
||||||
|
const content = await extractContentFromFile(file, pdfjsLib).catch((error) => {
|
||||||
|
toast.error(
|
||||||
|
$i18n.t('Failed to extract content from the file: {{error}}', { error: error })
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (content === null) {
|
||||||
|
toast.error($i18n.t('Failed to extract content from the file.'));
|
||||||
|
files = files.filter((item) => item?.itemId !== tempItemId);
|
||||||
|
return null;
|
||||||
|
} else {
|
||||||
|
console.log('Extracted content from file:', {
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
content: content
|
||||||
|
});
|
||||||
|
|
||||||
|
fileItem.status = 'uploaded';
|
||||||
|
fileItem.type = 'text';
|
||||||
|
fileItem.content = content;
|
||||||
|
fileItem.id = uuidv4(); // Temporary ID for the file
|
||||||
|
|
||||||
|
files = files;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -320,7 +616,7 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
inputFiles.forEach((file) => {
|
inputFiles.forEach(async (file) => {
|
||||||
console.log('Processing file:', {
|
console.log('Processing file:', {
|
||||||
name: file.name,
|
name: file.name,
|
||||||
type: file.type,
|
type: file.type,
|
||||||
|
|
@ -344,46 +640,53 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (file['type'].startsWith('image/')) {
|
||||||
['image/gif', 'image/webp', 'image/jpeg', 'image/png', 'image/avif'].includes(file['type'])
|
|
||||||
) {
|
|
||||||
if (visionCapableModels.length === 0) {
|
if (visionCapableModels.length === 0) {
|
||||||
toast.error($i18n.t('Selected model(s) do not support image inputs'));
|
toast.error($i18n.t('Selected model(s) do not support image inputs'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const compressImageHandler = async (imageUrl, settings = {}, config = {}) => {
|
||||||
|
// Quick shortcut so we don’t do unnecessary work.
|
||||||
|
const settingsCompression = settings?.imageCompression ?? false;
|
||||||
|
const configWidth = config?.file?.image_compression?.width ?? null;
|
||||||
|
const configHeight = config?.file?.image_compression?.height ?? null;
|
||||||
|
|
||||||
|
// If neither settings nor config wants compression, return original URL.
|
||||||
|
if (!settingsCompression && !configWidth && !configHeight) {
|
||||||
|
return imageUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to null (no compression unless set)
|
||||||
|
let width = null;
|
||||||
|
let height = null;
|
||||||
|
|
||||||
|
// If user/settings want compression, pick their preferred size.
|
||||||
|
if (settingsCompression) {
|
||||||
|
width = settings?.imageCompressionSize?.width ?? null;
|
||||||
|
height = settings?.imageCompressionSize?.height ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply config limits as an upper bound if any
|
||||||
|
if (configWidth && (width === null || width > configWidth)) {
|
||||||
|
width = configWidth;
|
||||||
|
}
|
||||||
|
if (configHeight && (height === null || height > configHeight)) {
|
||||||
|
height = configHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do the compression if required
|
||||||
|
if (width || height) {
|
||||||
|
return await compressImage(imageUrl, width, height);
|
||||||
|
}
|
||||||
|
return imageUrl;
|
||||||
|
};
|
||||||
|
|
||||||
let reader = new FileReader();
|
let reader = new FileReader();
|
||||||
reader.onload = async (event) => {
|
reader.onload = async (event) => {
|
||||||
let imageUrl = event.target.result;
|
let imageUrl = event.target.result;
|
||||||
|
|
||||||
if (
|
imageUrl = await compressImageHandler(imageUrl, $settings, $config);
|
||||||
($settings?.imageCompression ?? false) ||
|
|
||||||
($config?.file?.image_compression?.width ?? null) ||
|
|
||||||
($config?.file?.image_compression?.height ?? null)
|
|
||||||
) {
|
|
||||||
let width = null;
|
|
||||||
let height = null;
|
|
||||||
|
|
||||||
if ($settings?.imageCompression ?? false) {
|
|
||||||
width = $settings?.imageCompressionSize?.width ?? null;
|
|
||||||
height = $settings?.imageCompressionSize?.height ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
($config?.file?.image_compression?.width ?? null) ||
|
|
||||||
($config?.file?.image_compression?.height ?? null)
|
|
||||||
) {
|
|
||||||
if (width > ($config?.file?.image_compression?.width ?? null)) {
|
|
||||||
width = $config?.file?.image_compression?.width ?? null;
|
|
||||||
}
|
|
||||||
if (height > ($config?.file?.image_compression?.height ?? null)) {
|
|
||||||
height = $config?.file?.image_compression?.height ?? null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (width || height) {
|
|
||||||
imageUrl = await compressImage(imageUrl, width, height);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
files = [
|
files = [
|
||||||
...files,
|
...files,
|
||||||
|
|
@ -393,7 +696,11 @@
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(
|
||||||
|
file['type'] === 'image/heic'
|
||||||
|
? await heic2any({ blob: file, toType: 'image/jpeg' })
|
||||||
|
: file
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
uploadFileHandler(file);
|
uploadFileHandler(file);
|
||||||
}
|
}
|
||||||
|
|
@ -496,6 +803,14 @@
|
||||||
|
|
||||||
<FilesOverlay show={dragged} />
|
<FilesOverlay show={dragged} />
|
||||||
<ToolServersModal bind:show={showTools} {selectedToolIds} />
|
<ToolServersModal bind:show={showTools} {selectedToolIds} />
|
||||||
|
<InputVariablesModal
|
||||||
|
bind:show={showInputVariablesModal}
|
||||||
|
variables={inputVariables}
|
||||||
|
onSave={(variableValues) => {
|
||||||
|
inputVariableValues = { ...inputVariableValues, ...variableValues };
|
||||||
|
replaceVariables(inputVariableValues);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{#if loaded}
|
{#if loaded}
|
||||||
<div class="w-full font-primary">
|
<div class="w-full font-primary">
|
||||||
|
|
@ -548,7 +863,7 @@
|
||||||
src={$models.find((model) => model.id === atSelectedModel.id)?.info?.meta
|
src={$models.find((model) => model.id === atSelectedModel.id)?.info?.meta
|
||||||
?.profile_image_url ??
|
?.profile_image_url ??
|
||||||
($i18n.language === 'dg-DG'
|
($i18n.language === 'dg-DG'
|
||||||
? `/doge.png`
|
? `${WEBUI_BASE_URL}/doge.png`
|
||||||
: `${WEBUI_BASE_URL}/static/favicon.png`)}
|
: `${WEBUI_BASE_URL}/static/favicon.png`)}
|
||||||
/>
|
/>
|
||||||
<div class="translate-y-[0.5px]">
|
<div class="translate-y-[0.5px]">
|
||||||
|
|
@ -571,20 +886,36 @@
|
||||||
|
|
||||||
<Commands
|
<Commands
|
||||||
bind:this={commandsElement}
|
bind:this={commandsElement}
|
||||||
bind:prompt
|
|
||||||
bind:files
|
bind:files
|
||||||
on:upload={(e) => {
|
show={showCommands}
|
||||||
dispatch('upload', e.detail);
|
{command}
|
||||||
}}
|
insertTextHandler={insertTextAtCursor}
|
||||||
on:select={(e) => {
|
onUpload={(e) => {
|
||||||
const data = e.detail;
|
const { type, data } = e;
|
||||||
|
|
||||||
if (data?.type === 'model') {
|
if (type === 'file') {
|
||||||
atSelectedModel = data.data;
|
if (files.find((f) => f.id === data.id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
files = [
|
||||||
|
...files,
|
||||||
|
{
|
||||||
|
...data,
|
||||||
|
status: 'processed'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
dispatch('upload', e);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onSelect={(e) => {
|
||||||
|
const { type, data } = e;
|
||||||
|
|
||||||
|
if (type === 'model') {
|
||||||
|
atSelectedModel = data;
|
||||||
}
|
}
|
||||||
|
|
||||||
const chatInputElement = document.getElementById('chat-input');
|
document.getElementById('chat-input')?.focus();
|
||||||
chatInputElement?.focus();
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -627,10 +958,12 @@
|
||||||
}}
|
}}
|
||||||
onConfirm={async (data) => {
|
onConfirm={async (data) => {
|
||||||
const { text, filename } = data;
|
const { text, filename } = data;
|
||||||
prompt = `${prompt}${text} `;
|
|
||||||
|
|
||||||
recording = false;
|
recording = false;
|
||||||
|
|
||||||
|
await tick();
|
||||||
|
insertTextAtCursor(text);
|
||||||
|
|
||||||
await tick();
|
await tick();
|
||||||
document.getElementById('chat-input')?.focus();
|
document.getElementById('chat-input')?.focus();
|
||||||
|
|
||||||
|
|
@ -659,7 +992,7 @@
|
||||||
<div class="relative flex items-center">
|
<div class="relative flex items-center">
|
||||||
<Image
|
<Image
|
||||||
src={file.url}
|
src={file.url}
|
||||||
alt="input"
|
alt=""
|
||||||
imageClassName=" size-14 rounded-xl object-cover"
|
imageClassName=" size-14 rounded-xl object-cover"
|
||||||
/>
|
/>
|
||||||
{#if atSelectedModel ? visionCapableModels.length === 0 : selectedModels.length !== visionCapableModels.length}
|
{#if atSelectedModel ? visionCapableModels.length === 0 : selectedModels.length !== visionCapableModels.length}
|
||||||
|
|
@ -677,6 +1010,7 @@
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
class="size-4 fill-yellow-300"
|
class="size-4 fill-yellow-300"
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
|
|
@ -690,8 +1024,12 @@
|
||||||
</div>
|
</div>
|
||||||
<div class=" absolute -top-1 -right-1">
|
<div class=" absolute -top-1 -right-1">
|
||||||
<button
|
<button
|
||||||
class=" bg-white text-black border border-white rounded-full group-hover:visible invisible transition"
|
class=" bg-white text-black border border-white rounded-full {($settings?.highContrastMode ??
|
||||||
|
false)
|
||||||
|
? ''
|
||||||
|
: 'outline-hidden focus:outline-hidden group-hover:visible invisible transition'}"
|
||||||
type="button"
|
type="button"
|
||||||
|
aria-label={$i18n.t('Remove file')}
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
files.splice(fileIdx, 1);
|
files.splice(fileIdx, 1);
|
||||||
files = files;
|
files = files;
|
||||||
|
|
@ -701,6 +1039,7 @@
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 20 20"
|
viewBox="0 0 20 20"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
class="size-4"
|
class="size-4"
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
|
|
@ -719,18 +1058,8 @@
|
||||||
loading={file.status === 'uploading'}
|
loading={file.status === 'uploading'}
|
||||||
dismissible={true}
|
dismissible={true}
|
||||||
edit={true}
|
edit={true}
|
||||||
|
modal={['file', 'collection'].includes(file?.type)}
|
||||||
on:dismiss={async () => {
|
on:dismiss={async () => {
|
||||||
try {
|
|
||||||
if (file.type !== 'collection' && !file?.collection) {
|
|
||||||
if (file.id) {
|
|
||||||
// This will handle both file deletion and Chroma cleanup
|
|
||||||
await deleteFileById(localStorage.token, file.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error deleting file:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove from UI state
|
// Remove from UI state
|
||||||
files.splice(fileIdx, 1);
|
files.splice(fileIdx, 1);
|
||||||
files = files;
|
files = files;
|
||||||
|
|
@ -752,9 +1081,15 @@
|
||||||
>
|
>
|
||||||
<RichTextInput
|
<RichTextInput
|
||||||
bind:this={chatInputElement}
|
bind:this={chatInputElement}
|
||||||
bind:value={prompt}
|
|
||||||
id="chat-input"
|
id="chat-input"
|
||||||
|
onChange={(e) => {
|
||||||
|
prompt = e.md;
|
||||||
|
command = getCommand();
|
||||||
|
}}
|
||||||
|
json={true}
|
||||||
messageInput={true}
|
messageInput={true}
|
||||||
|
showFormattingButtons={false}
|
||||||
|
insertPromptAsRichText={$settings?.insertPromptAsRichText ?? false}
|
||||||
shiftEnter={!($settings?.ctrlEnterToSend ?? false) &&
|
shiftEnter={!($settings?.ctrlEnterToSend ?? false) &&
|
||||||
(!$mobile ||
|
(!$mobile ||
|
||||||
!(
|
!(
|
||||||
|
|
@ -972,6 +1307,12 @@
|
||||||
class="scrollbar-hidden bg-transparent dark:text-gray-200 outline-hidden w-full pt-3 px-1 resize-none"
|
class="scrollbar-hidden bg-transparent dark:text-gray-200 outline-hidden w-full pt-3 px-1 resize-none"
|
||||||
placeholder={placeholder ? placeholder : $i18n.t('Send a Message')}
|
placeholder={placeholder ? placeholder : $i18n.t('Send a Message')}
|
||||||
bind:value={prompt}
|
bind:value={prompt}
|
||||||
|
on:input={() => {
|
||||||
|
command = getCommand();
|
||||||
|
}}
|
||||||
|
on:click={() => {
|
||||||
|
command = getCommand();
|
||||||
|
}}
|
||||||
on:compositionstart={() => (isComposing = true)}
|
on:compositionstart={() => (isComposing = true)}
|
||||||
on:compositionend={() => (isComposing = false)}
|
on:compositionend={() => (isComposing = false)}
|
||||||
on:keydown={async (e) => {
|
on:keydown={async (e) => {
|
||||||
|
|
@ -1119,17 +1460,20 @@
|
||||||
|
|
||||||
if (words.length > 0) {
|
if (words.length > 0) {
|
||||||
const word = words.at(0);
|
const word = words.at(0);
|
||||||
const fullPrompt = prompt;
|
|
||||||
|
|
||||||
prompt = prompt.substring(0, word?.endIndex + 1);
|
if (word && e.target instanceof HTMLTextAreaElement) {
|
||||||
await tick();
|
// Prevent default tab behavior
|
||||||
|
e.preventDefault();
|
||||||
|
e.target.setSelectionRange(word?.startIndex, word.endIndex + 1);
|
||||||
|
e.target.focus();
|
||||||
|
|
||||||
e.target.scrollTop = e.target.scrollHeight;
|
const selectionRow =
|
||||||
prompt = fullPrompt;
|
(word?.startIndex - (word?.startIndex % e.target.cols)) /
|
||||||
await tick();
|
e.target.cols;
|
||||||
|
const lineHeight = e.target.clientHeight / e.target.rows;
|
||||||
|
|
||||||
e.preventDefault();
|
e.target.scrollTop = lineHeight * selectionRow;
|
||||||
e.target.setSelectionRange(word?.startIndex, word.endIndex + 1);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
e.target.style.height = '';
|
e.target.style.height = '';
|
||||||
|
|
@ -1250,14 +1594,13 @@
|
||||||
chatInput?.focus();
|
chatInput?.focus();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button
|
<div
|
||||||
class="bg-transparent hover:bg-gray-100 text-gray-800 dark:text-white dark:hover:bg-gray-800 transition rounded-full p-1.5 outline-hidden focus:outline-hidden"
|
class="bg-transparent hover:bg-gray-100 text-gray-800 dark:text-white dark:hover:bg-gray-800 rounded-full p-1.5 outline-hidden focus:outline-hidden"
|
||||||
type="button"
|
|
||||||
aria-label="More"
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 20 20"
|
viewBox="0 0 20 20"
|
||||||
|
aria-hidden="true"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
class="size-5"
|
class="size-5"
|
||||||
>
|
>
|
||||||
|
|
@ -1265,7 +1608,7 @@
|
||||||
d="M10.75 4.75a.75.75 0 0 0-1.5 0v4.5h-4.5a.75.75 0 0 0 0 1.5h4.5v4.5a.75.75 0 0 0 1.5 0v-4.5h4.5a.75.75 0 0 0 0-1.5h-4.5v-4.5Z"
|
d="M10.75 4.75a.75.75 0 0 0-1.5 0v4.5h-4.5a.75.75 0 0 0 0 1.5h4.5v4.5a.75.75 0 0 0 1.5 0v-4.5h4.5a.75.75 0 0 0 0-1.5h-4.5v-4.5Z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</div>
|
||||||
</InputMenu>
|
</InputMenu>
|
||||||
|
|
||||||
{#if $_user && (showToolsButton || (toggleFilters && toggleFilters.length > 0) || showWebSearchButton || showImageGenerationButton || showCodeInterpreterButton)}
|
{#if $_user && (showToolsButton || (toggleFilters && toggleFilters.length > 0) || showWebSearchButton || showImageGenerationButton || showCodeInterpreterButton)}
|
||||||
|
|
@ -1379,12 +1722,19 @@
|
||||||
{#if showCodeInterpreterButton}
|
{#if showCodeInterpreterButton}
|
||||||
<Tooltip content={$i18n.t('Execute code for analysis')} placement="top">
|
<Tooltip content={$i18n.t('Execute code for analysis')} placement="top">
|
||||||
<button
|
<button
|
||||||
|
aria-label={codeInterpreterEnabled
|
||||||
|
? $i18n.t('Disable Code Interpreter')
|
||||||
|
: $i18n.t('Enable Code Interpreter')}
|
||||||
|
aria-pressed={codeInterpreterEnabled}
|
||||||
on:click|preventDefault={() =>
|
on:click|preventDefault={() =>
|
||||||
(codeInterpreterEnabled = !codeInterpreterEnabled)}
|
(codeInterpreterEnabled = !codeInterpreterEnabled)}
|
||||||
type="button"
|
type="button"
|
||||||
class="px-2 @xl:px-2.5 py-2 flex gap-1.5 items-center text-sm rounded-full transition-colors duration-300 focus:outline-hidden max-w-full overflow-hidden hover:bg-gray-50 dark:hover:bg-gray-800 {codeInterpreterEnabled
|
class="px-2 @xl:px-2.5 py-2 flex gap-1.5 items-center text-sm transition-colors duration-300 max-w-full overflow-hidden hover:bg-gray-50 dark:hover:bg-gray-800 {codeInterpreterEnabled
|
||||||
? ' text-sky-500 dark:text-sky-300 bg-sky-50 dark:bg-sky-200/5'
|
? ' text-sky-500 dark:text-sky-300 bg-sky-50 dark:bg-sky-200/5'
|
||||||
: 'bg-transparent text-gray-600 dark:text-gray-300 '}"
|
: 'bg-transparent text-gray-600 dark:text-gray-300 '} {($settings?.highContrastMode ??
|
||||||
|
false)
|
||||||
|
? 'm-1'
|
||||||
|
: 'focus:outline-hidden rounded-full'}"
|
||||||
>
|
>
|
||||||
<CommandLine className="size-4" strokeWidth="1.75" />
|
<CommandLine className="size-4" strokeWidth="1.75" />
|
||||||
<span
|
<span
|
||||||
|
|
@ -1530,7 +1880,7 @@
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
aria-label="Call"
|
aria-label={$i18n.t('Voice mode')}
|
||||||
>
|
>
|
||||||
<Headphone className="size-5" />
|
<Headphone className="size-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -854,6 +854,7 @@
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="relative flex video-container w-full max-h-full pt-2 pb-4 md:py-6 px-2 h-full">
|
<div class="relative flex video-container w-full max-h-full pt-2 pb-4 md:py-6 px-2 h-full">
|
||||||
|
<!-- svelte-ignore a11y-media-has-caption -->
|
||||||
<video
|
<video
|
||||||
id="camera-feed"
|
id="camera-feed"
|
||||||
autoplay
|
autoplay
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue