update with upstream dev 21-11-2025

This commit is contained in:
Diego 2025-11-21 09:27:22 +01:00
commit b6947c9813
75 changed files with 1576 additions and 760 deletions

View file

@ -1203,6 +1203,12 @@ DEFAULT_USER_ROLE = PersistentConfig(
os.getenv("DEFAULT_USER_ROLE", "pending"),
)
DEFAULT_GROUP_ID = PersistentConfig(
"DEFAULT_GROUP_ID",
"ui.default_group_id",
os.environ.get("DEFAULT_GROUP_ID", ""),
)
PENDING_USER_OVERLAY_TITLE = PersistentConfig(
"PENDING_USER_OVERLAY_TITLE",
"ui.pending_user_overlay_title",
@ -1270,6 +1276,12 @@ USER_PERMISSIONS_WORKSPACE_TOOLS_EXPORT = (
os.environ.get("USER_PERMISSIONS_WORKSPACE_TOOLS_EXPORT", "False").lower() == "true"
)
USER_PERMISSIONS_WORKSPACE_MODELS_ALLOW_SHARING = (
os.environ.get("USER_PERMISSIONS_WORKSPACE_MODELS_ALLOW_SHARING", "False").lower()
== "true"
)
USER_PERMISSIONS_WORKSPACE_MODELS_ALLOW_PUBLIC_SHARING = (
os.environ.get(
"USER_PERMISSIONS_WORKSPACE_MODELS_ALLOW_PUBLIC_SHARING", "False"
@ -1277,8 +1289,10 @@ USER_PERMISSIONS_WORKSPACE_MODELS_ALLOW_PUBLIC_SHARING = (
== "true"
)
USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING = (
os.environ.get("USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING", "False").lower()
USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_SHARING = (
os.environ.get(
"USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_PUBLIC_SHARING", "False"
).lower()
== "true"
)
@ -1289,6 +1303,11 @@ USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_PUBLIC_SHARING = (
== "true"
)
USER_PERMISSIONS_WORKSPACE_PROMPTS_ALLOW_SHARING = (
os.environ.get("USER_PERMISSIONS_WORKSPACE_PROMPTS_ALLOW_SHARING", "False").lower()
== "true"
)
USER_PERMISSIONS_WORKSPACE_PROMPTS_ALLOW_PUBLIC_SHARING = (
os.environ.get(
"USER_PERMISSIONS_WORKSPACE_PROMPTS_ALLOW_PUBLIC_SHARING", "False"
@ -1296,6 +1315,12 @@ USER_PERMISSIONS_WORKSPACE_PROMPTS_ALLOW_PUBLIC_SHARING = (
== "true"
)
USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_SHARING = (
os.environ.get("USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_SHARING", "False").lower()
== "true"
)
USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_PUBLIC_SHARING = (
os.environ.get(
"USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_PUBLIC_SHARING", "False"
@ -1304,6 +1329,17 @@ USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_PUBLIC_SHARING = (
)
USER_PERMISSIONS_NOTES_ALLOW_SHARING = (
os.environ.get("USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING", "False").lower()
== "true"
)
USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING = (
os.environ.get("USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING", "False").lower()
== "true"
)
USER_PERMISSIONS_CHAT_CONTROLS = (
os.environ.get("USER_PERMISSIONS_CHAT_CONTROLS", "True").lower() == "true"
)
@ -1425,10 +1461,15 @@ DEFAULT_USER_PERMISSIONS = {
"tools_export": USER_PERMISSIONS_WORKSPACE_TOOLS_EXPORT,
},
"sharing": {
"models": USER_PERMISSIONS_WORKSPACE_MODELS_ALLOW_SHARING,
"public_models": USER_PERMISSIONS_WORKSPACE_MODELS_ALLOW_PUBLIC_SHARING,
"knowledge": USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_SHARING,
"public_knowledge": USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_PUBLIC_SHARING,
"prompts": USER_PERMISSIONS_WORKSPACE_PROMPTS_ALLOW_SHARING,
"public_prompts": USER_PERMISSIONS_WORKSPACE_PROMPTS_ALLOW_PUBLIC_SHARING,
"tools": USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_SHARING,
"public_tools": USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_PUBLIC_SHARING,
"notes": USER_PERMISSIONS_NOTES_ALLOW_SHARING,
"public_notes": USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING,
},
"chat": {
@ -2145,6 +2186,11 @@ ENABLE_QDRANT_MULTITENANCY_MODE = (
)
QDRANT_COLLECTION_PREFIX = os.environ.get("QDRANT_COLLECTION_PREFIX", "open-webui")
WEAVIATE_HTTP_HOST = os.environ.get("WEAVIATE_HTTP_HOST", "")
WEAVIATE_HTTP_PORT = int(os.environ.get("WEAVIATE_HTTP_PORT", "8080"))
WEAVIATE_GRPC_PORT = int(os.environ.get("WEAVIATE_GRPC_PORT", "50051"))
WEAVIATE_API_KEY = os.environ.get("WEAVIATE_API_KEY")
# OpenSearch
OPENSEARCH_URI = os.environ.get("OPENSEARCH_URI", "https://localhost:9200")
OPENSEARCH_SSL = os.environ.get("OPENSEARCH_SSL", "true").lower() == "true"
@ -3499,6 +3545,11 @@ IMAGES_GEMINI_ENDPOINT_METHOD = PersistentConfig(
os.getenv("IMAGES_GEMINI_ENDPOINT_METHOD", ""),
)
ENABLE_IMAGE_EDIT = PersistentConfig(
"ENABLE_IMAGE_EDIT",
"images.edit.enable",
os.environ.get("ENABLE_IMAGE_EDIT", "").lower() == "true",
)
IMAGE_EDIT_ENGINE = PersistentConfig(
"IMAGE_EDIT_ENGINE",

View file

@ -45,7 +45,7 @@ class ERROR_MESSAGES(str, Enum):
)
INVALID_CRED = "The email or password provided is incorrect. Please check for typos and try logging in again."
INVALID_EMAIL_FORMAT = "The email format you entered is invalid. Please double-check and make sure you're using a valid email address (e.g., yourname@example.com)."
INVALID_PASSWORD = (
INCORRECT_PASSWORD = (
"The password provided is incorrect. Please check for typos and try again."
)
INVALID_TRUSTED_HEADER = "Your provider has not provided a trusted header. Please contact your administrator for assistance."
@ -105,6 +105,10 @@ class ERROR_MESSAGES(str, Enum):
)
FILE_NOT_PROCESSED = "Extracted content is not available for this file. Please ensure that the file is processed before proceeding."
INVALID_PASSWORD = lambda err="": (
err if err else "The password does not meet the required validation criteria."
)
class TASKS(str, Enum):
def __str__(self) -> str:

View file

@ -8,6 +8,8 @@ import shutil
from uuid import uuid4
from pathlib import Path
from cryptography.hazmat.primitives import serialization
import re
import markdown
from bs4 import BeautifulSoup
@ -135,6 +137,9 @@ else:
PACKAGE_DATA = {"version": "0.0.0"}
VERSION = PACKAGE_DATA["version"]
DEPLOYMENT_ID = os.environ.get("DEPLOYMENT_ID", "")
INSTANCE_ID = os.environ.get("INSTANCE_ID", str(uuid4()))
@ -426,6 +431,17 @@ WEBUI_AUTH_TRUSTED_GROUPS_HEADER = os.environ.get(
)
ENABLE_PASSWORD_VALIDATION = (
os.environ.get("ENABLE_PASSWORD_VALIDATION", "False").lower() == "true"
)
PASSWORD_VALIDATION_REGEX_PATTERN = os.environ.get(
"PASSWORD_VALIDATION_REGEX_PATTERN",
"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\w\s]).{8,}$",
)
PASSWORD_VALIDATION_REGEX_PATTERN = re.compile(PASSWORD_VALIDATION_REGEX_PATTERN)
BYPASS_MODEL_ACCESS_CONTROL = (
os.environ.get("BYPASS_MODEL_ACCESS_CONTROL", "False").lower() == "true"
)

View file

@ -164,6 +164,7 @@ from open_webui.config import (
IMAGES_GEMINI_API_BASE_URL,
IMAGES_GEMINI_API_KEY,
IMAGES_GEMINI_ENDPOINT_METHOD,
ENABLE_IMAGE_EDIT,
IMAGE_EDIT_ENGINE,
IMAGE_EDIT_MODEL,
IMAGE_EDIT_SIZE,
@ -369,6 +370,7 @@ from open_webui.config import (
BYPASS_ADMIN_ACCESS_CONTROL,
USER_PERMISSIONS,
DEFAULT_USER_ROLE,
DEFAULT_GROUP_ID,
PENDING_USER_OVERLAY_CONTENT,
PENDING_USER_OVERLAY_TITLE,
DEFAULT_PROMPT_SUGGESTIONS,
@ -455,6 +457,7 @@ from open_webui.env import (
SAFE_MODE,
SRC_LOG_LEVELS,
VERSION,
DEPLOYMENT_ID,
INSTANCE_ID,
WEBUI_BUILD_HASH,
WEBUI_SECRET_KEY,
@ -762,6 +765,7 @@ app.state.config.MODEL_ORDER_LIST = MODEL_ORDER_LIST
app.state.config.DEFAULT_PROMPT_SUGGESTIONS = DEFAULT_PROMPT_SUGGESTIONS
app.state.config.DEFAULT_USER_ROLE = DEFAULT_USER_ROLE
app.state.config.DEFAULT_GROUP_ID = DEFAULT_GROUP_ID
app.state.config.PENDING_USER_OVERLAY_CONTENT = PENDING_USER_OVERLAY_CONTENT
app.state.config.PENDING_USER_OVERLAY_TITLE = PENDING_USER_OVERLAY_TITLE
@ -1116,6 +1120,7 @@ app.state.config.COMFYUI_WORKFLOW = COMFYUI_WORKFLOW
app.state.config.COMFYUI_WORKFLOW_NODES = COMFYUI_WORKFLOW_NODES
app.state.config.ENABLE_IMAGE_EDIT = ENABLE_IMAGE_EDIT
app.state.config.IMAGE_EDIT_ENGINE = IMAGE_EDIT_ENGINE
app.state.config.IMAGE_EDIT_MODEL = IMAGE_EDIT_MODEL
app.state.config.IMAGE_EDIT_SIZE = IMAGE_EDIT_SIZE
@ -1451,6 +1456,10 @@ async def get_models(
if "pipeline" in model and model["pipeline"].get("type", None) == "filter":
continue
# Remove profile image URL to reduce payload size
if model.get("info", {}).get("meta", {}).get("profile_image_url"):
model["info"]["meta"].pop("profile_image_url", None)
try:
model_tags = [
tag.get("name")
@ -1986,6 +1995,7 @@ async def update_webhook_url(form_data: UrlForm, user=Depends(get_admin_user)):
async def get_app_version():
return {
"version": VERSION,
"deployment_id": DEPLOYMENT_ID,
}

View file

@ -4,7 +4,7 @@ import uuid
from typing import Optional
from open_webui.internal.db import Base, get_db
from open_webui.models.chats import Chats
from open_webui.models.users import User
from open_webui.env import SRC_LOG_LEVELS
from pydantic import BaseModel, ConfigDict
@ -92,6 +92,28 @@ class FeedbackForm(BaseModel):
model_config = ConfigDict(extra="allow")
class UserResponse(BaseModel):
id: str
name: str
email: str
role: str = "pending"
last_active_at: int # timestamp in epoch
updated_at: int # timestamp in epoch
created_at: int # timestamp in epoch
model_config = ConfigDict(from_attributes=True)
class FeedbackUserResponse(FeedbackResponse):
user: Optional[UserResponse] = None
class FeedbackListResponse(BaseModel):
items: list[FeedbackUserResponse]
total: int
class FeedbackTable:
def insert_new_feedback(
self, user_id: str, form_data: FeedbackForm
@ -143,6 +165,70 @@ class FeedbackTable:
except Exception:
return None
def get_feedback_items(
self, filter: dict = {}, skip: int = 0, limit: int = 30
) -> FeedbackListResponse:
with get_db() as db:
query = db.query(Feedback, User).join(User, Feedback.user_id == User.id)
if filter:
order_by = filter.get("order_by")
direction = filter.get("direction")
if order_by == "username":
if direction == "asc":
query = query.order_by(User.name.asc())
else:
query = query.order_by(User.name.desc())
elif order_by == "model_id":
# it's stored in feedback.data['model_id']
if direction == "asc":
query = query.order_by(
Feedback.data["model_id"].as_string().asc()
)
else:
query = query.order_by(
Feedback.data["model_id"].as_string().desc()
)
elif order_by == "rating":
# it's stored in feedback.data['rating']
if direction == "asc":
query = query.order_by(
Feedback.data["rating"].as_string().asc()
)
else:
query = query.order_by(
Feedback.data["rating"].as_string().desc()
)
elif order_by == "updated_at":
if direction == "asc":
query = query.order_by(Feedback.updated_at.asc())
else:
query = query.order_by(Feedback.updated_at.desc())
else:
query = query.order_by(Feedback.created_at.desc())
# Count BEFORE pagination
total = query.count()
if skip:
query = query.offset(skip)
if limit:
query = query.limit(limit)
items = query.all()
feedbacks = []
for feedback, user in items:
feedback_model = FeedbackModel.model_validate(feedback)
user_model = UserResponse.model_validate(user)
feedbacks.append(
FeedbackUserResponse(**feedback_model.model_dump(), user=user_model)
)
return FeedbackListResponse(items=feedbacks, total=total)
def get_all_feedbacks(self) -> list[FeedbackModel]:
with get_db() as db:
return [

View file

@ -101,6 +101,7 @@ class GroupForm(BaseModel):
name: str
description: str
permissions: Optional[dict] = None
data: Optional[dict] = None
class UserIdsForm(BaseModel):

View file

@ -244,11 +244,9 @@ class ModelsTable:
try:
with get_db() as db:
# update only the fields that are present in the model
result = (
db.query(Model)
.filter_by(id=id)
.update(model.model_dump(exclude={"id"}))
)
data = model.model_dump(exclude={"id"})
result = db.query(Model).filter_by(id=id).update(data)
db.commit()
model = db.get(Model, id)

View file

@ -0,0 +1,301 @@
import weaviate
import re
import uuid
from typing import Any, Dict, List, Optional, Union
from open_webui.retrieval.vector.main import (
VectorDBBase,
VectorItem,
SearchResult,
GetResult,
)
from open_webui.retrieval.vector.utils import process_metadata
from open_webui.config import WEAVIATE_HTTP_HOST, WEAVIATE_HTTP_PORT, WEAVIATE_GRPC_PORT, WEAVIATE_API_KEY
def _convert_uuids_to_strings(obj: Any) -> Any:
"""
Recursively convert UUID objects to strings in nested data structures.
This function handles:
- UUID objects -> string
- Dictionaries with UUID values
- Lists/Tuples with UUID values
- Nested combinations of the above
Args:
obj: Any object that might contain UUIDs
Returns:
The same object structure with UUIDs converted to strings
"""
if isinstance(obj, uuid.UUID):
return str(obj)
elif isinstance(obj, dict):
return {key: _convert_uuids_to_strings(value) for key, value in obj.items()}
elif isinstance(obj, (list, tuple)):
return type(obj)(_convert_uuids_to_strings(item) for item in obj)
elif isinstance(obj, (str, int, float, bool, type(None))):
return obj
else:
return obj
class WeaviateClient(VectorDBBase):
def __init__(self):
self.url = WEAVIATE_HTTP_HOST
try:
# Build connection parameters
connection_params = {
"host": WEAVIATE_HTTP_HOST,
"port": WEAVIATE_HTTP_PORT,
"grpc_port": WEAVIATE_GRPC_PORT,
}
# Only add auth_credentials if WEAVIATE_API_KEY exists and is not empty
if WEAVIATE_API_KEY:
connection_params["auth_credentials"] = weaviate.classes.init.Auth.api_key(WEAVIATE_API_KEY)
self.client = weaviate.connect_to_local(**connection_params)
self.client.connect()
except Exception as e:
raise ConnectionError(f"Failed to connect to Weaviate: {e}") from e
def _sanitize_collection_name(self, collection_name: str) -> str:
"""Sanitize collection name to be a valid Weaviate class name."""
if not isinstance(collection_name, str) or not collection_name.strip():
raise ValueError("Collection name must be a non-empty string")
# Requirements for a valid Weaviate class name:
# The collection name must begin with a capital letter.
# The name can only contain letters, numbers, and the underscore (_) character. Spaces are not allowed.
# Replace hyphens with underscores and keep only alphanumeric characters
name = re.sub(r'[^a-zA-Z0-9_]', '', collection_name.replace("-", "_"))
name = name.strip("_")
if not name:
raise ValueError("Could not sanitize collection name to be a valid Weaviate class name")
# Ensure it starts with a letter and is capitalized
if not name[0].isalpha():
name = "C" + name
return name[0].upper() + name[1:]
def has_collection(self, collection_name: str) -> bool:
sane_collection_name = self._sanitize_collection_name(collection_name)
return self.client.collections.exists(sane_collection_name)
def delete_collection(self, collection_name: str) -> None:
sane_collection_name = self._sanitize_collection_name(collection_name)
if self.client.collections.exists(sane_collection_name):
self.client.collections.delete(sane_collection_name)
def _create_collection(self, collection_name: str) -> None:
self.client.collections.create(
name=collection_name,
vector_config=weaviate.classes.config.Configure.Vectors.self_provided(),
properties=[
weaviate.classes.config.Property(name="text", data_type=weaviate.classes.config.DataType.TEXT),
]
)
def insert(self, collection_name: str, items: List[VectorItem]) -> None:
sane_collection_name = self._sanitize_collection_name(collection_name)
if not self.client.collections.exists(sane_collection_name):
self._create_collection(sane_collection_name)
collection = self.client.collections.get(sane_collection_name)
with collection.batch.fixed_size(batch_size=100) as batch:
for item in items:
item_uuid = str(uuid.uuid4()) if not item["id"] else str(item["id"])
properties = {"text": item["text"]}
if item["metadata"]:
clean_metadata = _convert_uuids_to_strings(process_metadata(item["metadata"]))
clean_metadata.pop("text", None)
properties.update(clean_metadata)
batch.add_object(
properties=properties,
uuid=item_uuid,
vector=item["vector"]
)
def upsert(self, collection_name: str, items: List[VectorItem]) -> None:
sane_collection_name = self._sanitize_collection_name(collection_name)
if not self.client.collections.exists(sane_collection_name):
self._create_collection(sane_collection_name)
collection = self.client.collections.get(sane_collection_name)
with collection.batch.fixed_size(batch_size=100) as batch:
for item in items:
item_uuid = str(item["id"]) if item["id"] else None
properties = {"text": item["text"]}
if item["metadata"]:
clean_metadata = _convert_uuids_to_strings(process_metadata(item["metadata"]))
clean_metadata.pop("text", None)
properties.update(clean_metadata)
batch.add_object(
properties=properties,
uuid=item_uuid,
vector=item["vector"]
)
def search(
self, collection_name: str, vectors: List[List[Union[float, int]]], limit: int
) -> Optional[SearchResult]:
sane_collection_name = self._sanitize_collection_name(collection_name)
if not self.client.collections.exists(sane_collection_name):
return None
collection = self.client.collections.get(sane_collection_name)
result_ids, result_documents, result_metadatas, result_distances = [], [], [], []
for vector_embedding in vectors:
try:
response = collection.query.near_vector(
near_vector=vector_embedding,
limit=limit,
return_metadata=weaviate.classes.query.MetadataQuery(distance=True),
)
ids = [str(obj.uuid) for obj in response.objects]
documents = []
metadatas = []
distances = []
for obj in response.objects:
properties = dict(obj.properties) if obj.properties else {}
documents.append(properties.pop("text", ""))
metadatas.append(_convert_uuids_to_strings(properties))
# Weaviate has cosine distance, 2 (worst) -> 0 (best). Re-ordering to 0 -> 1
raw_distances = [obj.metadata.distance if obj.metadata and obj.metadata.distance else 2.0 for obj in response.objects]
distances = [(2 - dist) / 2 for dist in raw_distances]
result_ids.append(ids)
result_documents.append(documents)
result_metadatas.append(metadatas)
result_distances.append(distances)
except Exception:
result_ids.append([])
result_documents.append([])
result_metadatas.append([])
result_distances.append([])
return SearchResult(
**{
"ids": result_ids,
"documents": result_documents,
"metadatas": result_metadatas,
"distances": result_distances,
}
)
def query(
self, collection_name: str, filter: Dict, limit: Optional[int] = None
) -> Optional[GetResult]:
sane_collection_name = self._sanitize_collection_name(collection_name)
if not self.client.collections.exists(sane_collection_name):
return None
collection = self.client.collections.get(sane_collection_name)
weaviate_filter = None
if filter:
for key, value in filter.items():
prop_filter = weaviate.classes.query.Filter.by_property(name=key).equal(value)
weaviate_filter = prop_filter if weaviate_filter is None else weaviate.classes.query.Filter.all_of([weaviate_filter, prop_filter])
try:
response = collection.query.fetch_objects(filters=weaviate_filter, limit=limit)
ids = [str(obj.uuid) for obj in response.objects]
documents = []
metadatas = []
for obj in response.objects:
properties = dict(obj.properties) if obj.properties else {}
documents.append(properties.pop("text", ""))
metadatas.append(_convert_uuids_to_strings(properties))
return GetResult(
**{
"ids": [ids],
"documents": [documents],
"metadatas": [metadatas],
}
)
except Exception:
return None
def get(self, collection_name: str) -> Optional[GetResult]:
sane_collection_name = self._sanitize_collection_name(collection_name)
if not self.client.collections.exists(sane_collection_name):
return None
collection = self.client.collections.get(sane_collection_name)
ids, documents, metadatas = [], [], []
try:
for item in collection.iterator():
ids.append(str(item.uuid))
properties = dict(item.properties) if item.properties else {}
documents.append(properties.pop("text", ""))
metadatas.append(_convert_uuids_to_strings(properties))
if not ids:
return None
return GetResult(
**{
"ids": [ids],
"documents": [documents],
"metadatas": [metadatas],
}
)
except Exception:
return None
def delete(
self,
collection_name: str,
ids: Optional[List[str]] = None,
filter: Optional[Dict] = None,
) -> None:
sane_collection_name = self._sanitize_collection_name(collection_name)
if not self.client.collections.exists(sane_collection_name):
return
collection = self.client.collections.get(sane_collection_name)
try:
if ids:
for item_id in ids:
collection.data.delete_by_id(uuid=item_id)
elif filter:
weaviate_filter = None
for key, value in filter.items():
prop_filter = weaviate.classes.query.Filter.by_property(name=key).equal(value)
weaviate_filter = prop_filter if weaviate_filter is None else weaviate.classes.query.Filter.all_of([weaviate_filter, prop_filter])
if weaviate_filter:
collection.data.delete_many(where=weaviate_filter)
except Exception:
pass
def reset(self) -> None:
try:
for collection_name in self.client.collections.list_all().keys():
self.client.collections.delete(collection_name)
except Exception:
pass

View file

@ -67,6 +67,10 @@ class Vector:
from open_webui.retrieval.vector.dbs.oracle23ai import Oracle23aiClient
return Oracle23aiClient()
case VectorType.WEAVIATE:
from open_webui.retrieval.vector.dbs.weaviate import WeaviateClient
return WeaviateClient()
case _:
raise ValueError(f"Unsupported vector type: {vector_type}")

View file

@ -11,3 +11,4 @@ class VectorType(StrEnum):
PGVECTOR = "pgvector"
ORACLE23AI = "oracle23ai"
S3VECTOR = "s3vector"
WEAVIATE = "weaviate"

View file

@ -35,6 +35,7 @@ from pydantic import BaseModel
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.headers import include_user_info_headers
from open_webui.config import (
WHISPER_MODEL_AUTO_UPDATE,
WHISPER_MODEL_DIR,
@ -364,23 +365,17 @@ async def speech(request: Request, user=Depends(get_verified_user)):
**(request.app.state.config.TTS_OPENAI_PARAMS or {}),
}
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {request.app.state.config.TTS_OPENAI_API_KEY}",
}
if ENABLE_FORWARD_USER_INFO_HEADERS:
headers = include_user_info_headers(headers, user)
r = await session.post(
url=f"{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/speech",
json=payload,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {request.app.state.config.TTS_OPENAI_API_KEY}",
**(
{
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
"X-OpenWebUI-User-Id": user.id,
"X-OpenWebUI-User-Email": user.email,
"X-OpenWebUI-User-Role": user.role,
}
if ENABLE_FORWARD_USER_INFO_HEADERS
else {}
),
},
headers=headers,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
)
@ -570,7 +565,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
return FileResponse(file_path)
def transcription_handler(request, file_path, metadata):
def transcription_handler(request, file_path, metadata, user=None):
filename = os.path.basename(file_path)
file_dir = os.path.dirname(file_path)
id = filename.split(".")[0]
@ -621,11 +616,15 @@ def transcription_handler(request, file_path, metadata):
if language:
payload["language"] = language
headers = {
"Authorization": f"Bearer {request.app.state.config.STT_OPENAI_API_KEY}"
}
if user and ENABLE_FORWARD_USER_INFO_HEADERS:
headers = include_user_info_headers(headers, user)
r = requests.post(
url=f"{request.app.state.config.STT_OPENAI_API_BASE_URL}/audio/transcriptions",
headers={
"Authorization": f"Bearer {request.app.state.config.STT_OPENAI_API_KEY}"
},
headers=headers,
files={"file": (filename, open(file_path, "rb"))},
data=payload,
)
@ -1027,7 +1026,7 @@ def transcription_handler(request, file_path, metadata):
)
def transcribe(request: Request, file_path: str, metadata: Optional[dict] = None):
def transcribe(request: Request, file_path: str, metadata: Optional[dict] = None, user=None):
log.info(f"transcribe: {file_path} {metadata}")
if is_audio_conversion_required(file_path):
@ -1054,7 +1053,7 @@ def transcribe(request: Request, file_path: str, metadata: Optional[dict] = None
with ThreadPoolExecutor() as executor:
# Submit tasks for each chunk_path
futures = [
executor.submit(transcription_handler, request, chunk_path, metadata)
executor.submit(transcription_handler, request, chunk_path, metadata, user)
for chunk_path in chunk_paths
]
# Gather results as they complete
@ -1189,7 +1188,7 @@ def transcription(
if language:
metadata = {"language": language}
result = transcribe(request, file_path, metadata)
result = transcribe(request, file_path, metadata, user)
return {
**result,

View file

@ -45,6 +45,7 @@ from pydantic import BaseModel
from open_webui.utils.misc import parse_duration, validate_email_format
from open_webui.utils.auth import (
validate_password,
verify_password,
decode_token,
invalidate_token,
@ -181,10 +182,14 @@ async def update_password(
)
if user:
try:
validate_password(form_data.password)
except Exception as e:
raise HTTPException(400, detail=str(e))
hashed = get_password_hash(form_data.new_password)
return Auths.update_user_password_by_id(user.id, hashed)
else:
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_PASSWORD)
raise HTTPException(400, detail=ERROR_MESSAGES.INCORRECT_PASSWORD)
else:
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
@ -627,16 +632,14 @@ async def signup(request: Request, response: Response, form_data: SignupForm):
raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN)
try:
role = "admin" if not has_users else request.app.state.config.DEFAULT_USER_ROLE
# The password passed to bcrypt must be 72 bytes or fewer. If it is longer, it will be truncated before hashing.
if len(form_data.password.encode("utf-8")) > 72:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.PASSWORD_TOO_LONG,
)
try:
validate_password(form_data.password)
except Exception as e:
raise HTTPException(400, detail=str(e))
hashed = get_password_hash(form_data.password)
role = "admin" if not has_users else request.app.state.config.DEFAULT_USER_ROLE
user = Auths.insert_new_auth(
form_data.email.lower(),
hashed,
@ -692,6 +695,10 @@ async def signup(request: Request, response: Response, form_data: SignupForm):
# Disable signup after the first user is created
request.app.state.config.ENABLE_SIGNUP = False
default_group_id = getattr(request.app.state.config, 'DEFAULT_GROUP_ID', "")
if default_group_id and default_group_id:
Groups.add_users_to_group(default_group_id, [user.id])
return {
"token": token,
"token_type": "Bearer",
@ -805,6 +812,11 @@ async def add_user(form_data: AddUserForm, user=Depends(get_admin_user)):
raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN)
try:
try:
validate_password(form_data.password)
except Exception as e:
raise HTTPException(400, detail=str(e))
hashed = get_password_hash(form_data.password)
user = Auths.insert_new_auth(
form_data.email.lower(),
@ -880,6 +892,7 @@ async def get_admin_config(request: Request, user=Depends(get_admin_user)):
"ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS": request.app.state.config.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS,
"API_KEYS_ALLOWED_ENDPOINTS": request.app.state.config.API_KEYS_ALLOWED_ENDPOINTS,
"DEFAULT_USER_ROLE": request.app.state.config.DEFAULT_USER_ROLE,
"DEFAULT_GROUP_ID": request.app.state.config.DEFAULT_GROUP_ID,
"JWT_EXPIRES_IN": request.app.state.config.JWT_EXPIRES_IN,
"ENABLE_COMMUNITY_SHARING": request.app.state.config.ENABLE_COMMUNITY_SHARING,
"ENABLE_MESSAGE_RATING": request.app.state.config.ENABLE_MESSAGE_RATING,
@ -900,6 +913,7 @@ class AdminConfig(BaseModel):
ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS: bool
API_KEYS_ALLOWED_ENDPOINTS: str
DEFAULT_USER_ROLE: str
DEFAULT_GROUP_ID: str
JWT_EXPIRES_IN: str
ENABLE_COMMUNITY_SHARING: bool
ENABLE_MESSAGE_RATING: bool
@ -933,6 +947,8 @@ async def update_admin_config(
if form_data.DEFAULT_USER_ROLE in ["pending", "user", "admin"]:
request.app.state.config.DEFAULT_USER_ROLE = form_data.DEFAULT_USER_ROLE
request.app.state.config.DEFAULT_GROUP_ID = form_data.DEFAULT_GROUP_ID
pattern = r"^(-1|0|(-?\d+(\.\d+)?)(ms|s|m|h|d|w))$"
# Check if the input string matches the pattern
@ -963,6 +979,7 @@ async def update_admin_config(
"ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS": request.app.state.config.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS,
"API_KEYS_ALLOWED_ENDPOINTS": request.app.state.config.API_KEYS_ALLOWED_ENDPOINTS,
"DEFAULT_USER_ROLE": request.app.state.config.DEFAULT_USER_ROLE,
"DEFAULT_GROUP_ID": request.app.state.config.DEFAULT_GROUP_ID,
"JWT_EXPIRES_IN": request.app.state.config.JWT_EXPIRES_IN,
"ENABLE_COMMUNITY_SHARING": request.app.state.config.ENABLE_COMMUNITY_SHARING,
"ENABLE_MESSAGE_RATING": request.app.state.config.ENABLE_MESSAGE_RATING,

View file

@ -7,6 +7,8 @@ from open_webui.models.feedbacks import (
FeedbackModel,
FeedbackResponse,
FeedbackForm,
FeedbackUserResponse,
FeedbackListResponse,
Feedbacks,
)
@ -56,35 +58,10 @@ async def update_config(
}
class UserResponse(BaseModel):
id: str
name: str
email: str
role: str = "pending"
last_active_at: int # timestamp in epoch
updated_at: int # timestamp in epoch
created_at: int # timestamp in epoch
class FeedbackUserResponse(FeedbackResponse):
user: Optional[UserResponse] = None
@router.get("/feedbacks/all", response_model=list[FeedbackUserResponse])
@router.get("/feedbacks/all", response_model=list[FeedbackResponse])
async def get_all_feedbacks(user=Depends(get_admin_user)):
feedbacks = Feedbacks.get_all_feedbacks()
feedback_list = []
for feedback in feedbacks:
user = Users.get_user_by_id(feedback.user_id)
feedback_list.append(
FeedbackUserResponse(
**feedback.model_dump(),
user=UserResponse(**user.model_dump()) if user else None,
)
)
return feedback_list
return feedbacks
@router.delete("/feedbacks/all")
@ -111,6 +88,31 @@ async def delete_feedbacks(user=Depends(get_verified_user)):
return success
PAGE_ITEM_COUNT = 30
@router.get("/feedbacks/list", response_model=FeedbackListResponse)
async def get_feedbacks(
order_by: Optional[str] = None,
direction: Optional[str] = None,
page: Optional[int] = 1,
user=Depends(get_admin_user),
):
limit = PAGE_ITEM_COUNT
page = max(1, page)
skip = (page - 1) * limit
filter = {}
if order_by:
filter["order_by"] = order_by
if direction:
filter["direction"] = direction
result = Feedbacks.get_feedback_items(filter=filter, skip=skip, limit=limit)
return result
@router.post("/feedback", response_model=FeedbackModel)
async def create_feedback(
request: Request,

View file

@ -102,7 +102,7 @@ def process_uploaded_file(request, file, file_path, file_item, file_metadata, us
)
):
file_path = Storage.get_file(file_path)
result = transcribe(request, file_path, file_metadata)
result = transcribe(request, file_path, file_metadata, user)
process_file(
request,

View file

@ -31,20 +31,32 @@ router = APIRouter()
@router.get("/", response_model=list[GroupResponse])
async def get_groups(user=Depends(get_verified_user)):
async def get_groups(share: Optional[bool] = None, user=Depends(get_verified_user)):
if user.role == "admin":
groups = Groups.get_groups()
else:
groups = Groups.get_groups_by_member_id(user.id)
return [
GroupResponse(
**group.model_dump(),
member_count=Groups.get_group_member_count_by_id(group.id),
group_list = []
for group in groups:
if share is not None:
# Check if the group has data and a config with share key
if (
group.data
and "share" in group.data.get("config", {})
and group.data["config"]["share"] != share
):
continue
group_list.append(
GroupResponse(
**group.model_dump(),
member_count=Groups.get_group_member_count_by_id(group.id),
)
)
for group in groups
if group
]
return group_list
############################

View file

@ -126,6 +126,7 @@ class ImagesConfig(BaseModel):
IMAGES_GEMINI_API_KEY: str
IMAGES_GEMINI_ENDPOINT_METHOD: str
ENABLE_IMAGE_EDIT: bool
IMAGE_EDIT_ENGINE: str
IMAGE_EDIT_MODEL: str
IMAGE_EDIT_SIZE: Optional[str]
@ -164,6 +165,7 @@ async def get_config(request: Request, user=Depends(get_admin_user)):
"IMAGES_GEMINI_API_BASE_URL": request.app.state.config.IMAGES_GEMINI_API_BASE_URL,
"IMAGES_GEMINI_API_KEY": request.app.state.config.IMAGES_GEMINI_API_KEY,
"IMAGES_GEMINI_ENDPOINT_METHOD": request.app.state.config.IMAGES_GEMINI_ENDPOINT_METHOD,
"ENABLE_IMAGE_EDIT": request.app.state.config.ENABLE_IMAGE_EDIT,
"IMAGE_EDIT_ENGINE": request.app.state.config.IMAGE_EDIT_ENGINE,
"IMAGE_EDIT_MODEL": request.app.state.config.IMAGE_EDIT_MODEL,
"IMAGE_EDIT_SIZE": request.app.state.config.IMAGE_EDIT_SIZE,
@ -253,6 +255,7 @@ async def update_config(
)
# Edit Image
request.app.state.config.ENABLE_IMAGE_EDIT = form_data.ENABLE_IMAGE_EDIT
request.app.state.config.IMAGE_EDIT_ENGINE = form_data.IMAGE_EDIT_ENGINE
request.app.state.config.IMAGE_EDIT_MODEL = form_data.IMAGE_EDIT_MODEL
request.app.state.config.IMAGE_EDIT_SIZE = form_data.IMAGE_EDIT_SIZE
@ -308,6 +311,7 @@ async def update_config(
"IMAGES_GEMINI_API_BASE_URL": request.app.state.config.IMAGES_GEMINI_API_BASE_URL,
"IMAGES_GEMINI_API_KEY": request.app.state.config.IMAGES_GEMINI_API_KEY,
"IMAGES_GEMINI_ENDPOINT_METHOD": request.app.state.config.IMAGES_GEMINI_ENDPOINT_METHOD,
"ENABLE_IMAGE_EDIT": request.app.state.config.ENABLE_IMAGE_EDIT,
"IMAGE_EDIT_ENGINE": request.app.state.config.IMAGE_EDIT_ENGINE,
"IMAGE_EDIT_MODEL": request.app.state.config.IMAGE_EDIT_MODEL,
"IMAGE_EDIT_SIZE": request.app.state.config.IMAGE_EDIT_SIZE,

View file

@ -253,6 +253,7 @@ async def get_model_profile_image(id: str, user=Depends(get_verified_user)):
)
except Exception as e:
pass
return FileResponse(f"{STATIC_DIR}/favicon.png")
else:
return FileResponse(f"{STATIC_DIR}/favicon.png")
@ -320,7 +321,7 @@ async def update_model_by_id(
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
model = Models.update_model_by_id(form_data.id, form_data)
model = Models.update_model_by_id(form_data.id, ModelForm(**form_data.model_dump()))
return model

View file

@ -36,7 +36,12 @@ from open_webui.constants import ERROR_MESSAGES
from open_webui.env import SRC_LOG_LEVELS, STATIC_DIR
from open_webui.utils.auth import get_admin_user, get_password_hash, get_verified_user
from open_webui.utils.auth import (
get_admin_user,
get_password_hash,
get_verified_user,
validate_password,
)
from open_webui.utils.access_control import get_permissions, has_permission
@ -178,10 +183,15 @@ class WorkspacePermissions(BaseModel):
class SharingPermissions(BaseModel):
public_models: bool = True
public_knowledge: bool = True
public_prompts: bool = True
models: bool = False
public_models: bool = False
knowledge: bool = False
public_knowledge: bool = False
prompts: bool = False
public_prompts: bool = False
tools: bool = False
public_tools: bool = True
notes: bool = False
public_notes: bool = True
@ -497,8 +507,12 @@ async def update_user_by_id(
)
if form_data.password:
try:
validate_password(form_data.password)
except Exception as e:
raise HTTPException(400, detail=str(e))
hashed = get_password_hash(form_data.password)
log.debug(f"hashed: {hashed}")
Auths.update_user_password_by_id(user_id, hashed)
Auths.update_email_by_id(user_id, form_data.email.lower())

View file

@ -28,8 +28,10 @@ from open_webui.models.users import Users
from open_webui.constants import ERROR_MESSAGES
from open_webui.env import (
ENABLE_PASSWORD_VALIDATION,
OFFLINE_MODE,
LICENSE_BLOB,
PASSWORD_VALIDATION_REGEX_PATTERN,
REDIS_KEY_PREFIX,
pk,
WEBUI_SECRET_KEY,
@ -162,6 +164,20 @@ def get_password_hash(password: str) -> str:
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
def validate_password(password: str) -> bool:
# The password passed to bcrypt must be 72 bytes or fewer. If it is longer, it will be truncated before hashing.
if len(password.encode("utf-8")) > 72:
raise Exception(
ERROR_MESSAGES.PASSWORD_TOO_LONG,
)
if ENABLE_PASSWORD_VALIDATION:
if not PASSWORD_VALIDATION_REGEX_PATTERN.match(password):
raise Exception(ERROR_MESSAGES.INVALID_PASSWORD())
return True
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a password against its hash"""
return (

View file

@ -791,42 +791,13 @@ async def chat_image_generation_handler(
input_images = get_last_images(message_list)
system_message_content = ""
if len(input_images) == 0:
# Create image(s)
if request.app.state.config.ENABLE_IMAGE_PROMPT_GENERATION:
try:
res = await generate_image_prompt(
request,
{
"model": form_data["model"],
"messages": form_data["messages"],
},
user,
)
response = res["choices"][0]["message"]["content"]
try:
bracket_start = response.find("{")
bracket_end = response.rfind("}") + 1
if bracket_start == -1 or bracket_end == -1:
raise Exception("No JSON object found in the response")
response = response[bracket_start:bracket_end]
response = json.loads(response)
prompt = response.get("prompt", [])
except Exception as e:
prompt = user_message
except Exception as e:
log.exception(e)
prompt = user_message
if len(input_images) > 0 and request.app.state.config.ENABLE_IMAGE_EDIT:
# Edit image(s)
try:
images = await image_generations(
images = await image_edits(
request=request,
form_data=CreateImageForm(**{"prompt": prompt}),
form_data=EditImageForm(**{"prompt": prompt, "image": input_images}),
user=user,
)
@ -874,12 +845,43 @@ async def chat_image_generation_handler(
)
system_message_content = f"<context>Image generation was attempted but failed. The system is currently unable to generate the image. Tell the user that an error occurred: {error_message}</context>"
else:
# Edit image(s)
# Create image(s)
if request.app.state.config.ENABLE_IMAGE_PROMPT_GENERATION:
try:
res = await generate_image_prompt(
request,
{
"model": form_data["model"],
"messages": form_data["messages"],
},
user,
)
response = res["choices"][0]["message"]["content"]
try:
bracket_start = response.find("{")
bracket_end = response.rfind("}") + 1
if bracket_start == -1 or bracket_end == -1:
raise Exception("No JSON object found in the response")
response = response[bracket_start:bracket_end]
response = json.loads(response)
prompt = response.get("prompt", [])
except Exception as e:
prompt = user_message
except Exception as e:
log.exception(e)
prompt = user_message
try:
images = await image_edits(
images = await image_generations(
request=request,
form_data=EditImageForm(**{"prompt": prompt, "image": input_images}),
form_data=CreateImageForm(**{"prompt": prompt}),
user=user,
)

View file

@ -49,6 +49,7 @@ langchain-community==0.3.29
fake-useragent==2.2.0
chromadb==1.1.0
weaviate-client==4.17.0
opensearch-py==2.8.0
transformers

View file

@ -147,6 +147,7 @@ all = [
"elasticsearch==9.1.0",
"qdrant-client==1.14.3",
"weaviate-client==4.17.0",
"pymilvus==2.6.2",
"pinecone==6.0.2",
"oracledb==3.2.0",

View file

@ -93,6 +93,45 @@ export const getAllFeedbacks = async (token: string = '') => {
return res;
};
export const getFeedbackItems = async (token: string = '', orderBy, direction, page) => {
let error = null;
const searchParams = new URLSearchParams();
if (orderBy) searchParams.append('order_by', orderBy);
if (direction) searchParams.append('direction', direction);
if (page) searchParams.append('page', page.toString());
const res = await fetch(
`${WEBUI_API_BASE_URL}/evaluations/feedbacks/list?${searchParams.toString()}`,
{
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 exportAllFeedbacks = async (token: string = '') => {
let error = null;

View file

@ -31,10 +31,15 @@ export const createNewGroup = async (token: string, group: object) => {
return res;
};
export const getGroups = async (token: string = '') => {
export const getGroups = async (token: string = '', share?: boolean) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/groups/`, {
const searchParams = new URLSearchParams();
if (share !== undefined) {
searchParams.append('share', String(share));
}
const res = await fetch(`${WEBUI_API_BASE_URL}/groups/?${searchParams.toString()}`, {
method: 'GET',
headers: {
Accept: 'application/json',

View file

@ -1425,7 +1425,7 @@ export const getVersion = async (token: string) => {
throw error;
}
return res?.version ?? null;
return res;
};
export const getVersionUpdates = async (token: string) => {

View file

@ -33,7 +33,9 @@
let feedbacks = [];
onMount(async () => {
// TODO: feedbacks elo rating calculation should be done in the backend; remove below line later
feedbacks = await getAllFeedbacks(localStorage.token);
loaded = true;
const containerElement = document.getElementById('users-tabs-container');
@ -117,7 +119,7 @@
{#if selectedTab === 'leaderboard'}
<Leaderboard {feedbacks} />
{:else if selectedTab === 'feedbacks'}
<Feedbacks {feedbacks} />
<Feedbacks />
{/if}
</div>
</div>

View file

@ -10,7 +10,7 @@
import { onMount, getContext } from 'svelte';
const i18n = getContext('i18n');
import { deleteFeedbackById, exportAllFeedbacks, getAllFeedbacks } from '$lib/apis/evaluations';
import { deleteFeedbackById, exportAllFeedbacks, getFeedbackItems } from '$lib/apis/evaluations';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Download from '$lib/components/icons/Download.svelte';
@ -23,78 +23,25 @@
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import { WEBUI_BASE_URL } from '$lib/constants';
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
import { config } from '$lib/stores';
export let feedbacks = [];
import Spinner from '$lib/components/common/Spinner.svelte';
let page = 1;
$: paginatedFeedbacks = sortedFeedbacks.slice((page - 1) * 10, page * 10);
let items = null;
let total = null;
let orderBy: string = 'updated_at';
let direction: 'asc' | 'desc' = 'desc';
type Feedback = {
id: string;
data: {
rating: number;
model_id: string;
sibling_model_ids: string[] | null;
reason: string;
comment: string;
tags: string[];
};
user: {
name: string;
profile_image_url: string;
};
updated_at: number;
};
type ModelStats = {
rating: number;
won: number;
lost: number;
};
function setSortKey(key: string) {
const setSortKey = (key) => {
if (orderBy === key) {
direction = direction === 'asc' ? 'desc' : 'asc';
} else {
orderBy = key;
if (key === 'user' || key === 'model_id') {
direction = 'asc';
} else {
direction = 'desc';
}
direction = 'asc';
}
page = 1;
}
$: sortedFeedbacks = [...feedbacks].sort((a, b) => {
let aVal, bVal;
switch (orderBy) {
case 'user':
aVal = a.user?.name || '';
bVal = b.user?.name || '';
return direction === 'asc' ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal);
case 'model_id':
aVal = a.data.model_id || '';
bVal = b.data.model_id || '';
return direction === 'asc' ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal);
case 'rating':
aVal = a.data.rating;
bVal = b.data.rating;
return direction === 'asc' ? aVal - bVal : bVal - aVal;
case 'updated_at':
aVal = a.updated_at;
bVal = b.updated_at;
return direction === 'asc' ? aVal - bVal : bVal - aVal;
default:
return 0;
}
});
};
let showFeedbackModal = false;
let selectedFeedback = null;
@ -115,13 +62,41 @@
//
//////////////////////
const getFeedbacks = async () => {
try {
const res = await getFeedbackItems(localStorage.token, orderBy, direction, page).catch(
(error) => {
toast.error(`${error}`);
return null;
}
);
if (res) {
items = res.items;
total = res.total;
}
} catch (err) {
console.error(err);
}
};
$: if (page) {
getFeedbacks();
}
$: if (orderBy && direction) {
getFeedbacks();
}
const deleteFeedbackHandler = async (feedbackId: string) => {
const response = await deleteFeedbackById(localStorage.token, feedbackId).catch((err) => {
toast.error(err);
return null;
});
if (response) {
feedbacks = feedbacks.filter((f) => f.id !== feedbackId);
toast.success($i18n.t('Feedback deleted successfully'));
page = 1;
getFeedbacks();
}
};
@ -169,256 +144,266 @@
<FeedbackModal bind:show={showFeedbackModal} {selectedFeedback} onClose={closeFeedbackModal} />
<div class="mt-0.5 mb-1 gap-1 flex flex-row justify-between">
<div class="flex md:self-center text-lg font-medium px-0.5">
{$i18n.t('Feedback History')}
{#if items === null || total === null}
<div class="my-10">
<Spinner className="size-5" />
</div>
{:else}
<div class="mt-0.5 mb-1 gap-1 flex flex-row justify-between">
<div class="flex items-center md:self-center text-xl font-medium px-0.5 gap-2 shrink-0">
<div>
{$i18n.t('Feedback History')}
</div>
<div class="flex self-center w-[1px] h-6 mx-2.5 bg-gray-50 dark:bg-gray-850" />
<div class="text-lg font-medium text-gray-500 dark:text-gray-500">
{total}
</div>
</div>
<span class="text-lg font-medium text-gray-500 dark:text-gray-300">{feedbacks.length}</span>
{#if total > 0}
<div>
<Tooltip content={$i18n.t('Export')}>
<button
class=" p-2 rounded-xl hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-gray-850 transition font-medium text-sm flex items-center space-x-1"
on:click={() => {
exportHandler();
}}
>
<Download className="size-3" />
</button>
</Tooltip>
</div>
{/if}
</div>
{#if feedbacks.length > 0}
<div>
<Tooltip content={$i18n.t('Export')}>
<button
class=" p-2 rounded-xl hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-gray-850 transition font-medium text-sm flex items-center space-x-1"
on:click={() => {
exportHandler();
}}
>
<Download className="size-3" />
</button>
</Tooltip>
</div>
{/if}
</div>
<div class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full">
{#if (feedbacks ?? []).length === 0}
<div class="text-center text-xs text-gray-500 dark:text-gray-400 py-1">
{$i18n.t('No feedbacks found')}
</div>
{:else}
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto max-w-full">
<thead class="text-xs text-gray-800 uppercase bg-transparent dark:text-gray-200">
<tr class=" border-b-[1.5px] border-gray-50 dark:border-gray-850">
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none w-3"
on:click={() => setSortKey('user')}
>
<div class="flex gap-1.5 items-center justify-end">
{$i18n.t('User')}
{#if orderBy === 'user'}
<span class="font-normal">
{#if direction === 'asc'}
<div class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full">
{#if (items ?? []).length === 0}
<div class="text-center text-xs text-gray-500 dark:text-gray-400 py-1">
{$i18n.t('No feedbacks found')}
</div>
{:else}
<table
class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto max-w-full"
>
<thead class="text-xs text-gray-800 uppercase bg-transparent dark:text-gray-200">
<tr class=" border-b-[1.5px] border-gray-50 dark:border-gray-850">
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none w-3"
on:click={() => setSortKey('user')}
>
<div class="flex gap-1.5 items-center justify-end">
{$i18n.t('User')}
{#if orderBy === 'user'}
<span class="font-normal">
{#if direction === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
</span>
{:else}
<span class="invisible">
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
</span>
{:else}
<span class="invisible">
<ChevronUp className="size-2" />
</span>
{/if}
</div>
</th>
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none"
on:click={() => setSortKey('model_id')}
>
<div class="flex gap-1.5 items-center">
{$i18n.t('Models')}
{#if orderBy === 'model_id'}
<span class="font-normal">
{#if direction === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
</span>
{:else}
<span class="invisible">
<ChevronUp className="size-2" />
</span>
{/if}
</div>
</th>
<th
scope="col"
class="px-2.5 py-2 text-right cursor-pointer select-none w-fit"
on:click={() => setSortKey('rating')}
>
<div class="flex gap-1.5 items-center justify-end">
{$i18n.t('Result')}
{#if orderBy === 'rating'}
<span class="font-normal">
{#if direction === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
</span>
{:else}
<span class="invisible">
<ChevronUp className="size-2" />
</span>
{/if}
</div>
</th>
<th
scope="col"
class="px-2.5 py-2 text-right cursor-pointer select-none w-0"
on:click={() => setSortKey('updated_at')}
>
<div class="flex gap-1.5 items-center justify-end">
{$i18n.t('Updated At')}
{#if orderBy === 'updated_at'}
<span class="font-normal">
{#if direction === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
</span>
{:else}
<span class="invisible">
<ChevronUp className="size-2" />
</span>
{/if}
</div>
</th>
<th scope="col" class="px-2.5 py-2 text-right cursor-pointer select-none w-0"> </th>
</tr>
</thead>
<tbody class="">
{#each paginatedFeedbacks as feedback (feedback.id)}
<tr
class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-850/50 transition"
on:click={() => openFeedbackModal(feedback)}
>
<td class=" py-0.5 text-right font-semibold">
<div class="flex justify-center">
<Tooltip content={feedback?.user?.name}>
<div class="shrink-0">
<img
src={feedback?.user?.profile_image_url ?? `${WEBUI_BASE_URL}/user.png`}
alt={feedback?.user?.name}
class="size-5 rounded-full object-cover shrink-0"
/>
</div>
</Tooltip>
</span>
{/if}
</div>
</td>
</th>
<td class=" py-1 pl-3 flex flex-col">
<div class="flex flex-col items-start gap-0.5 h-full">
<div class="flex flex-col h-full">
{#if feedback.data?.sibling_model_ids}
<div class="font-semibold text-gray-600 dark:text-gray-400 flex-1">
{feedback.data?.model_id}
</div>
<Tooltip content={feedback.data.sibling_model_ids.join(', ')}>
<div class=" text-[0.65rem] text-gray-600 dark:text-gray-400 line-clamp-1">
{#if feedback.data.sibling_model_ids.length > 2}
<!-- {$i18n.t('and {{COUNT}} more')} -->
{feedback.data.sibling_model_ids.slice(0, 2).join(', ')}, {$i18n.t(
'and {{COUNT}} more',
{ COUNT: feedback.data.sibling_model_ids.length - 2 }
)}
{:else}
{feedback.data.sibling_model_ids.join(', ')}
{/if}
</div>
</Tooltip>
{:else}
<div
class=" text-sm font-medium text-gray-600 dark:text-gray-400 flex-1 py-1.5"
>
{feedback.data?.model_id}
</div>
{/if}
</div>
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none"
on:click={() => setSortKey('model_id')}
>
<div class="flex gap-1.5 items-center">
{$i18n.t('Models')}
{#if orderBy === 'model_id'}
<span class="font-normal">
{#if direction === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
</span>
{:else}
<span class="invisible">
<ChevronUp className="size-2" />
</span>
{/if}
</div>
</td>
</th>
{#if feedback?.data?.rating}
<td class="px-3 py-1 text-right font-medium text-gray-900 dark:text-white w-max">
<div class=" flex justify-end">
{#if feedback?.data?.rating.toString() === '1'}
<Badge type="info" content={$i18n.t('Won')} />
{:else if feedback?.data?.rating.toString() === '0'}
<Badge type="muted" content={$i18n.t('Draw')} />
{:else if feedback?.data?.rating.toString() === '-1'}
<Badge type="error" content={$i18n.t('Lost')} />
{/if}
<th
scope="col"
class="px-2.5 py-2 text-right cursor-pointer select-none w-fit"
on:click={() => setSortKey('rating')}
>
<div class="flex gap-1.5 items-center justify-end">
{$i18n.t('Result')}
{#if orderBy === 'rating'}
<span class="font-normal">
{#if direction === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
</span>
{:else}
<span class="invisible">
<ChevronUp className="size-2" />
</span>
{/if}
</div>
</th>
<th
scope="col"
class="px-2.5 py-2 text-right cursor-pointer select-none w-0"
on:click={() => setSortKey('updated_at')}
>
<div class="flex gap-1.5 items-center justify-end">
{$i18n.t('Updated At')}
{#if orderBy === 'updated_at'}
<span class="font-normal">
{#if direction === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
</span>
{:else}
<span class="invisible">
<ChevronUp className="size-2" />
</span>
{/if}
</div>
</th>
<th scope="col" class="px-2.5 py-2 text-right cursor-pointer select-none w-0"> </th>
</tr>
</thead>
<tbody class="">
{#each items as feedback (feedback.id)}
<tr
class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-850/50 transition"
on:click={() => openFeedbackModal(feedback)}
>
<td class=" py-0.5 text-right font-semibold">
<div class="flex justify-center">
<Tooltip content={feedback?.user?.name}>
<div class="shrink-0">
<img
src={`${WEBUI_API_BASE_URL}/users/${feedback.user.id}/profile/image`}
alt={feedback?.user?.name}
class="size-5 rounded-full object-cover shrink-0"
/>
</div>
</Tooltip>
</div>
</td>
{/if}
<td class=" px-3 py-1 text-right font-medium">
{dayjs(feedback.updated_at * 1000).fromNow()}
</td>
<td class=" py-1 pl-3 flex flex-col">
<div class="flex flex-col items-start gap-0.5 h-full">
<div class="flex flex-col h-full">
{#if feedback.data?.sibling_model_ids}
<div class="font-semibold text-gray-600 dark:text-gray-400 flex-1">
{feedback.data?.model_id}
</div>
<td class=" px-3 py-1 text-right font-semibold" on:click={(e) => e.stopPropagation()}>
<FeedbackMenu
on:delete={(e) => {
deleteFeedbackHandler(feedback.id);
}}
>
<button
class="self-center w-fit text-sm p-1.5 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
<Tooltip content={feedback.data.sibling_model_ids.join(', ')}>
<div class=" text-[0.65rem] text-gray-600 dark:text-gray-400 line-clamp-1">
{#if feedback.data.sibling_model_ids.length > 2}
<!-- {$i18n.t('and {{COUNT}} more')} -->
{feedback.data.sibling_model_ids.slice(0, 2).join(', ')}, {$i18n.t(
'and {{COUNT}} more',
{ COUNT: feedback.data.sibling_model_ids.length - 2 }
)}
{:else}
{feedback.data.sibling_model_ids.join(', ')}
{/if}
</div>
</Tooltip>
{:else}
<div
class=" text-sm font-medium text-gray-600 dark:text-gray-400 flex-1 py-1.5"
>
{feedback.data?.model_id}
</div>
{/if}
</div>
</div>
</td>
{#if feedback?.data?.rating}
<td class="px-3 py-1 text-right font-medium text-gray-900 dark:text-white w-max">
<div class=" flex justify-end">
{#if feedback?.data?.rating.toString() === '1'}
<Badge type="info" content={$i18n.t('Won')} />
{:else if feedback?.data?.rating.toString() === '0'}
<Badge type="muted" content={$i18n.t('Draw')} />
{:else if feedback?.data?.rating.toString() === '-1'}
<Badge type="error" content={$i18n.t('Lost')} />
{/if}
</div>
</td>
{/if}
<td class=" px-3 py-1 text-right font-medium">
{dayjs(feedback.updated_at * 1000).fromNow()}
</td>
<td class=" px-3 py-1 text-right font-semibold" on:click={(e) => e.stopPropagation()}>
<FeedbackMenu
on:delete={(e) => {
deleteFeedbackHandler(feedback.id);
}}
>
<EllipsisHorizontal />
</button>
</FeedbackMenu>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
{#if feedbacks.length > 0 && $config?.features?.enable_community_sharing}
<div class=" flex flex-col justify-end w-full text-right gap-1">
<div class="line-clamp-1 text-gray-500 text-xs">
{$i18n.t('Help us create the best community leaderboard by sharing your feedback history!')}
</div>
<div class="flex space-x-1 ml-auto">
<Tooltip
content={$i18n.t(
'To protect your privacy, only ratings, model IDs, tags, and metadata are shared from your feedback—your chat logs remain private and are not included.'
)}
>
<button
class="flex text-xs items-center px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-200 transition"
on:click={async () => {
shareHandler();
}}
>
<div class=" self-center mr-2 font-medium line-clamp-1">
{$i18n.t('Share to Open WebUI Community')}
</div>
<div class=" self-center">
<CloudArrowUp className="size-3" strokeWidth="3" />
</div>
</button>
</Tooltip>
</div>
<button
class="self-center w-fit text-sm p-1.5 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
>
<EllipsisHorizontal />
</button>
</FeedbackMenu>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
{/if}
{#if feedbacks.length > 10}
<Pagination bind:page count={feedbacks.length} perPage={10} />
{#if total > 0 && $config?.features?.enable_community_sharing}
<div class=" flex flex-col justify-end w-full text-right gap-1">
<div class="line-clamp-1 text-gray-500 text-xs">
{$i18n.t('Help us create the best community leaderboard by sharing your feedback history!')}
</div>
<div class="flex space-x-1 ml-auto">
<Tooltip
content={$i18n.t(
'To protect your privacy, only ratings, model IDs, tags, and metadata are shared from your feedback—your chat logs remain private and are not included.'
)}
>
<button
class="flex text-xs items-center px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-200 transition"
on:click={async () => {
shareHandler();
}}
>
<div class=" self-center mr-2 font-medium line-clamp-1">
{$i18n.t('Share to Open WebUI Community')}
</div>
<div class=" self-center">
<CloudArrowUp className="size-3" strokeWidth="3" />
</div>
</button>
</Tooltip>
</div>
</div>
{/if}
{#if total > 30}
<Pagination bind:page count={total} perPage={30} />
{/if}
{/if}

View file

@ -10,7 +10,7 @@
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import { WEBUI_BASE_URL } from '$lib/constants';
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
const i18n = getContext('i18n');
@ -339,16 +339,14 @@
<div
class="pt-0.5 pb-1 gap-1 flex flex-col md:flex-row justify-between sticky top-0 z-10 bg-white dark:bg-gray-900"
>
<div class="flex md:self-center text-lg font-medium px-0.5 shrink-0 items-center">
<div class=" gap-1">
<div class="flex items-center md:self-center text-xl font-medium px-0.5 gap-2 shrink-0">
<div>
{$i18n.t('Leaderboard')}
</div>
<div class="flex self-center w-[1px] h-6 mx-2.5 bg-gray-50 dark:bg-gray-850" />
<span class="text-lg font-medium text-gray-500 dark:text-gray-300 mr-1.5"
>{rankedModels.length}</span
>
<div class="text-lg font-medium text-gray-500 dark:text-gray-500">
{rankedModels.length}
</div>
</div>
<div class=" flex space-x-2">
@ -517,7 +515,7 @@
<div class="flex items-center gap-2">
<div class="shrink-0">
<img
src={model?.info?.meta?.profile_image_url ?? `${WEBUI_BASE_URL}/favicon.png`}
src={`${WEBUI_API_BASE_URL}/models/model/profile/image?id=${model.id}`}
alt={model.name}
class="size-5 rounded-full object-cover shrink-0"
/>

View file

@ -757,18 +757,18 @@
{/if}
{/if}
<div class="flex justify-between w-full mt-2">
<div class="self-center text-xs font-medium">
<Tooltip content={''} placement="top-start">
<div class="flex flex-col gap-2 mt-2">
<div class=" flex flex-col w-full justify-between">
<div class=" mb-1 text-xs font-medium">
{$i18n.t('Parameters')}
</Tooltip>
</div>
<div class="">
<Textarea
bind:value={RAGConfig.DOCLING_PARAMS}
placeholder={$i18n.t('Enter additional parameters in JSON format')}
minSize={100}
/>
</div>
<div class="flex w-full items-center relative">
<Textarea
bind:value={RAGConfig.DOCLING_PARAMS}
placeholder={$i18n.t('Enter additional parameters in JSON format')}
minSize={100}
/>
</div>
</div>
</div>
{:else if RAGConfig.CONTENT_EXTRACTION_ENGINE === 'document_intelligence'}

View file

@ -5,6 +5,7 @@
import Cog6 from '$lib/components/icons/Cog6.svelte';
import ArenaModelModal from './ArenaModelModal.svelte';
import { WEBUI_API_BASE_URL } from '$lib/constants';
export let model;
let showModel = false;
@ -27,7 +28,7 @@
<div class="flex flex-col flex-1">
<div class="flex gap-2.5 items-center">
<img
src={model.meta.profile_image_url}
src={`${WEBUI_API_BASE_URL}/models/model/profile/image?id=${model.id}`}
alt={model.name}
class="size-8 rounded-full object-cover shrink-0"
/>

View file

@ -10,6 +10,7 @@
updateLdapConfig,
updateLdapServer
} from '$lib/apis/auths';
import { getGroups } from '$lib/apis/groups';
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
import Switch from '$lib/components/common/Switch.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
@ -32,6 +33,7 @@
let adminConfig = null;
let webhookUrl = '';
let groups = [];
// LDAP
let ENABLE_LDAP = false;
@ -104,6 +106,9 @@
})(),
(async () => {
LDAP_SERVER = await getLdapServer(localStorage.token);
})(),
(async () => {
groups = await getGroups(localStorage.token);
})()
]);
@ -299,6 +304,22 @@
</div>
</div>
<div class=" mb-2.5 flex w-full justify-between">
<div class=" self-center text-xs font-medium">{$i18n.t('Default Group')}</div>
<div class="flex items-center relative">
<select
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-hidden text-right"
bind:value={adminConfig.DEFAULT_GROUP_ID}
placeholder={$i18n.t('Select a group')}
>
<option value={""}>None</option>
{#each groups as group}
<option value={group.id}>{group.name}</option>
{/each}
</select>
</div>
</div>
<div class=" mb-2.5 flex w-full justify-between pr-2">
<div class=" self-center text-xs font-medium">{$i18n.t('Enable New Sign Ups')}</div>

View file

@ -888,23 +888,15 @@
<div class="flex w-full justify-between items-center">
<div class="text-xs pr-2">
<div class="">
{$i18n.t('Image Edit Engine')}
{$i18n.t('Image Edit')}
</div>
</div>
<select
class=" dark:bg-gray-900 w-fit pr-8 cursor-pointer rounded-sm px-2 text-xs bg-transparent outline-hidden text-right"
bind:value={config.IMAGE_EDIT_ENGINE}
placeholder={$i18n.t('Select Engine')}
>
<option value="openai">{$i18n.t('Default (Open AI)')}</option>
<option value="comfyui">{$i18n.t('ComfyUI')}</option>
<option value="gemini">{$i18n.t('Gemini')}</option>
</select>
<Switch bind:state={config.ENABLE_IMAGE_EDIT} />
</div>
</div>
{#if config.ENABLE_IMAGE_GENERATION}
{#if config?.ENABLE_IMAGE_GENERATION && config?.ENABLE_IMAGE_EDIT}
<div class="mb-2.5">
<div class="flex w-full justify-between items-center">
<div class="text-xs pr-2">
@ -949,6 +941,26 @@
</div>
{/if}
<div class="mb-2.5">
<div class="flex w-full justify-between items-center">
<div class="text-xs pr-2">
<div class="">
{$i18n.t('Image Edit Engine')}
</div>
</div>
<select
class=" dark:bg-gray-900 w-fit pr-8 cursor-pointer rounded-sm px-2 text-xs bg-transparent outline-hidden text-right"
bind:value={config.IMAGE_EDIT_ENGINE}
placeholder={$i18n.t('Select Engine')}
>
<option value="openai">{$i18n.t('Default (Open AI)')}</option>
<option value="comfyui">{$i18n.t('ComfyUI')}</option>
<option value="gemini">{$i18n.t('Gemini')}</option>
</select>
</div>
</div>
{#if config?.IMAGE_EDIT_ENGINE === 'openai'}
<div class="mb-2.5">
<div class="flex w-full justify-between items-center">

View file

@ -37,7 +37,7 @@
import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte';
import EyeSlash from '$lib/components/icons/EyeSlash.svelte';
import Eye from '$lib/components/icons/Eye.svelte';
import { WEBUI_BASE_URL } from '$lib/constants';
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
let shiftKey = false;
@ -334,7 +334,7 @@
: 'opacity-50 dark:opacity-50'} "
>
<img
src={model?.meta?.profile_image_url ?? `${WEBUI_BASE_URL}/static/favicon.png`}
src={`${WEBUI_API_BASE_URL}/models/model/profile/image?id=${model.id}`}
alt="modelfile profile"
class=" rounded-full w-full h-auto object-cover"
/>

View file

@ -105,11 +105,14 @@
/>
<div class="mt-0.5 mb-2 gap-1 flex flex-col md:flex-row justify-between">
<div class="flex md:self-center text-lg font-medium px-0.5">
{$i18n.t('Groups')}
<div class="flex self-center w-[1px] h-6 mx-2.5 bg-gray-50 dark:bg-gray-850" />
<div class="flex items-center md:self-center text-xl font-medium px-0.5 gap-2 shrink-0">
<div>
{$i18n.t('Groups')}
</div>
<span class="text-lg font-medium text-gray-500 dark:text-gray-300">{groups.length}</span>
<div class="text-lg font-medium text-gray-500 dark:text-gray-500">
{groups.length}
</div>
</div>
<div class="flex gap-1">

View file

@ -5,7 +5,7 @@
import Spinner from '$lib/components/common/Spinner.svelte';
import Modal from '$lib/components/common/Modal.svelte';
import Display from './Display.svelte';
import General from './General.svelte';
import Permissions from './Permissions.svelte';
import Users from './Users.svelte';
import UserPlusSolid from '$lib/components/icons/UserPlusSolid.svelte';
@ -34,6 +34,7 @@
export let name = '';
export let description = '';
export let data = {};
export let permissions = {
workspace: {
@ -49,10 +50,15 @@
tools_export: false
},
sharing: {
models: false,
public_models: false,
knowledge: false,
public_knowledge: false,
prompts: false,
public_prompts: false,
tools: false,
public_tools: false,
notes: false,
public_notes: false
},
chat: {
@ -92,6 +98,7 @@
const group = {
name,
description,
data,
permissions
};
@ -106,6 +113,7 @@
name = group.name;
description = group.description;
permissions = group?.permissions ?? {};
data = group?.data ?? {};
userCount = group?.member_count ?? 0;
}
@ -236,9 +244,10 @@
<div class="flex-1 mt-1 lg:mt-1 lg:h-[30rem] lg:max-h-[30rem] flex flex-col">
<div class="w-full h-full overflow-y-auto scrollbar-hidden">
{#if selectedTab == 'general'}
<Display
<General
bind:name
bind:description
bind:data
{edit}
onDelete={() => {
showDeleteConfirmDialog = true;

View file

@ -2,12 +2,14 @@
import { getContext } from 'svelte';
import Textarea from '$lib/components/common/Textarea.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Switch from '$lib/components/common/Switch.svelte';
const i18n = getContext('i18n');
export let name = '';
export let color = '';
export let description = '';
export let data = {};
export let edit = false;
export let onDelete: Function = () => {};
@ -63,6 +65,34 @@
</div>
</div>
<hr class="border-gray-50 dark:border-gray-850 my-1" />
<div class="flex flex-col w-full mt-2">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Setting')}</div>
<div>
<div class=" flex w-full justify-between">
<div class=" self-center text-xs">
{$i18n.t('Allow Group Sharing')}
</div>
<div class="flex items-center gap-2 p-1">
<Switch
tooltip={true}
state={data?.config?.share ?? true}
on:change={(e) => {
if (data?.config?.share) {
data.config.share = e.detail;
} else {
data.config = { ...(data?.config ?? {}), share: e.detail };
}
}}
/>
</div>
</div>
</div>
</div>
{#if edit}
<div class="flex flex-col w-full mt-2">
<div class=" mb-0.5 text-xs text-gray-500">{$i18n.t('Actions')}</div>

View file

@ -10,7 +10,7 @@
import Pencil from '$lib/components/icons/Pencil.svelte';
import User from '$lib/components/icons/User.svelte';
import UserCircleSolid from '$lib/components/icons/UserCircleSolid.svelte';
import GroupModal from './EditGroupModal.svelte';
import EditGroupModal from './EditGroupModal.svelte';
export let group = {
name: 'Admins',
@ -54,7 +54,7 @@
});
</script>
<GroupModal
<EditGroupModal
bind:show={showEdit}
edit
{group}

View file

@ -20,10 +20,15 @@
tools_export: false
},
sharing: {
models: false,
public_models: false,
knowledge: false,
public_knowledge: false,
prompts: false,
public_prompts: false,
tools: false,
public_tools: false,
notes: false,
public_notes: false
},
chat: {
@ -217,11 +222,11 @@
<div class="flex flex-col w-full">
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs font-medium">
{$i18n.t('Models Public Sharing')}
{$i18n.t('Models Sharing')}
</div>
<Switch bind:state={permissions.sharing.public_models} />
<Switch bind:state={permissions.sharing.models} />
</div>
{#if defaultPermissions?.sharing?.public_models && !permissions.sharing.public_models}
{#if defaultPermissions?.sharing?.models && !permissions.sharing.models}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
@ -230,14 +235,32 @@
{/if}
</div>
{#if permissions.sharing.models}
<div class="flex flex-col w-full">
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs font-medium">
{$i18n.t('Models Public Sharing')}
</div>
<Switch bind:state={permissions.sharing.public_models} />
</div>
{#if defaultPermissions?.sharing?.public_models && !permissions.sharing.public_models}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
{/if}
<div class="flex flex-col w-full">
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs font-medium">
{$i18n.t('Knowledge Public Sharing')}
{$i18n.t('Knowledge Sharing')}
</div>
<Switch bind:state={permissions.sharing.public_knowledge} />
<Switch bind:state={permissions.sharing.knowledge} />
</div>
{#if defaultPermissions?.sharing?.public_knowledge && !permissions.sharing.public_knowledge}
{#if defaultPermissions?.sharing?.knowledge && !permissions.sharing.knowledge}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
@ -246,14 +269,32 @@
{/if}
</div>
{#if permissions.sharing.knowledge}
<div class="flex flex-col w-full">
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs font-medium">
{$i18n.t('Knowledge Public Sharing')}
</div>
<Switch bind:state={permissions.sharing.public_knowledge} />
</div>
{#if defaultPermissions?.sharing?.public_knowledge && !permissions.sharing.public_knowledge}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
{/if}
<div class="flex flex-col w-full">
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs font-medium">
{$i18n.t('Prompts Public Sharing')}
{$i18n.t('Prompts Sharing')}
</div>
<Switch bind:state={permissions.sharing.public_prompts} />
<Switch bind:state={permissions.sharing.prompts} />
</div>
{#if defaultPermissions?.sharing?.public_prompts && !permissions.sharing.public_prompts}
{#if defaultPermissions?.sharing?.prompts && !permissions.sharing.prompts}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
@ -262,14 +303,32 @@
{/if}
</div>
{#if permissions.sharing.prompts}
<div class="flex flex-col w-full">
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs font-medium">
{$i18n.t('Prompts Public Sharing')}
</div>
<Switch bind:state={permissions.sharing.public_prompts} />
</div>
{#if defaultPermissions?.sharing?.public_prompts && !permissions.sharing.public_prompts}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
{/if}
<div class="flex flex-col w-full">
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs font-medium">
{$i18n.t('Tools Public Sharing')}
{$i18n.t('Tools Sharing')}
</div>
<Switch bind:state={permissions.sharing.public_tools} />
<Switch bind:state={permissions.sharing.tools} />
</div>
{#if defaultPermissions?.sharing?.public_tools && !permissions.sharing.public_tools}
{#if defaultPermissions?.sharing?.tools && !permissions.sharing.tools}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
@ -278,14 +337,32 @@
{/if}
</div>
{#if permissions.sharing.tools}
<div class="flex flex-col w-full">
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs font-medium">
{$i18n.t('Tools Public Sharing')}
</div>
<Switch bind:state={permissions.sharing.public_tools} />
</div>
{#if defaultPermissions?.sharing?.public_tools && !permissions.sharing.public_tools}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
{/if}
<div class="flex flex-col w-full">
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs font-medium">
{$i18n.t('Notes Public Sharing')}
{$i18n.t('Notes Sharing')}
</div>
<Switch bind:state={permissions.sharing.public_notes} />
<Switch bind:state={permissions.sharing.notes} />
</div>
{#if defaultPermissions?.sharing?.public_notes && !permissions.sharing.public_notes}
{#if defaultPermissions?.sharing?.notes && !permissions.sharing.notes}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
@ -293,6 +370,24 @@
</div>
{/if}
</div>
{#if permissions.sharing.notes}
<div class="flex flex-col w-full">
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs font-medium">
{$i18n.t('Notes Public Sharing')}
</div>
<Switch bind:state={permissions.sharing.public_notes} />
</div>
{#if defaultPermissions?.sharing?.public_notes && !permissions.sharing.public_notes}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
{/if}
</div>
<hr class=" border-gray-100 dark:border-gray-850" />

View file

@ -1,5 +1,5 @@
<script>
import { WEBUI_BASE_URL } from '$lib/constants';
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
import { WEBUI_NAME, config, user, showSidebar } from '$lib/stores';
import { goto } from '$app/navigation';
import { onMount, getContext } from 'svelte';
@ -154,27 +154,28 @@
<div
class="pt-0.5 pb-1 gap-1 flex flex-col md:flex-row justify-between sticky top-0 z-10 bg-white dark:bg-gray-900"
>
<div class="flex md:self-center text-lg font-medium px-0.5">
<div class="flex md:self-center text-lg font-medium px-0.5 gap-2">
<div class="flex-shrink-0">
{$i18n.t('Users')}
</div>
<div class="flex self-center w-[1px] h-6 mx-2.5 bg-gray-50 dark:bg-gray-850" />
{#if ($config?.license_metadata?.seats ?? null) !== null}
{#if total > $config?.license_metadata?.seats}
<span class="text-lg font-medium text-red-500"
>{total} of {$config?.license_metadata?.seats}
<span class="text-sm font-normal">{$i18n.t('available users')}</span></span
>
<div>
{#if ($config?.license_metadata?.seats ?? null) !== null}
{#if total > $config?.license_metadata?.seats}
<span class="text-lg font-medium text-red-500"
>{total} of {$config?.license_metadata?.seats}
<span class="text-sm font-normal">{$i18n.t('available users')}</span></span
>
{:else}
<span class="text-lg font-medium text-gray-500 dark:text-gray-300"
>{total} of {$config?.license_metadata?.seats}
<span class="text-sm font-normal">{$i18n.t('available users')}</span></span
>
{/if}
{:else}
<span class="text-lg font-medium text-gray-500 dark:text-gray-300"
>{total} of {$config?.license_metadata?.seats}
<span class="text-sm font-normal">{$i18n.t('available users')}</span></span
>
<span class="text-lg font-medium text-gray-500 dark:text-gray-300">{total}</span>
{/if}
{:else}
<span class="text-lg font-medium text-gray-500 dark:text-gray-300">{total}</span>
{/if}
</div>
</div>
<div class="flex gap-1">
@ -361,11 +362,7 @@
<div class="flex items-center">
<img
class="rounded-full w-6 h-6 object-cover mr-2.5 flex-shrink-0"
src={user?.profile_image_url?.startsWith(WEBUI_BASE_URL) ||
user.profile_image_url.startsWith('https://www.gravatar.com/avatar/') ||
user.profile_image_url.startsWith('data:')
? user.profile_image_url
: `${WEBUI_BASE_URL}/user.png`}
src={`${WEBUI_API_BASE_URL}/users/${user.id}/profile/image`}
alt="user"
/>

View file

@ -171,8 +171,7 @@
</div>
{:else if item.type === 'model'}
<img
src={item?.data?.info?.meta?.profile_image_url ??
`${WEBUI_BASE_URL}/static/favicon.png`}
src={`${WEBUI_API_BASE_URL}/models/model/profile/image?id=${item.id}&lang=${$i18n.language}`}
alt={item?.data?.name ?? item.id}
class="rounded-full size-5 items-center mr-2"
/>

View file

@ -157,7 +157,7 @@
{#if message?.reply_to_message?.user}
<div class="relative text-xs mb-1">
<div
class="absolute h-3 w-7 left-[18px] top-2 rounded-tl-lg border-t-2 border-l-2 border-gray-300 dark:border-gray-500 z-0"
class="absolute h-3 w-7 left-[18px] top-2 rounded-tl-lg border-t-[1.5px] border-l-[1.5px] border-gray-200 dark:border-gray-700 z-0"
></div>
<button
@ -185,8 +185,7 @@
/>
{:else}
<img
src={message.reply_to_message.user?.profile_image_url ??
`${WEBUI_BASE_URL}/static/favicon.png`}
src={`${WEBUI_API_BASE_URL}/users/${message.reply_to_message.user?.id}/profile/image`}
alt={message.reply_to_message.user?.name ?? $i18n.t('Unknown User')}
class="size-4 ml-0.5 rounded-full object-cover"
/>
@ -220,7 +219,7 @@
{:else}
<ProfilePreview user={message.user}>
<ProfileImage
src={message.user?.profile_image_url ?? `${WEBUI_BASE_URL}/static/favicon.png`}
src={`${WEBUI_API_BASE_URL}/users/${message.user.id}/profile/image`}
className={'size-8 ml-0.5'}
/>
</ProfilePreview>

View file

@ -2,7 +2,7 @@
import { getContext, onMount } from 'svelte';
const i18n = getContext('i18n');
import { WEBUI_BASE_URL } from '$lib/constants';
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
export let user = null;
</script>
@ -11,8 +11,7 @@
<div class=" flex gap-3.5 w-full py-3 px-3 items-center">
<div class=" items-center flex shrink-0">
<img
crossorigin="anonymous"
src={user?.profile_image_url ?? `${WEBUI_BASE_URL}/static/favicon.png`}
src={`${WEBUI_API_BASE_URL}/users/${user?.id}/profile/image`}
class=" size-12 object-cover rounded-xl"
alt="profile"
/>

View file

@ -11,6 +11,7 @@
import PencilSquare from '../icons/PencilSquare.svelte';
import Tooltip from '../common/Tooltip.svelte';
import Sidebar from '../icons/Sidebar.svelte';
import { WEBUI_API_BASE_URL } from '$lib/constants';
const i18n = getContext('i18n');
@ -80,7 +81,7 @@
>
<div class=" self-center">
<img
src={$user?.profile_image_url}
src={`${WEBUI_API_BASE_URL}/users/${$user?.id}/profile/image`}
class="size-6 object-cover rounded-full"
alt="User profile"
draggable="false"

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { WEBUI_BASE_URL } from '$lib/constants';
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
import { marked } from 'marked';
import { config, user, models as _models, temporaryChatEnabled } from '$lib/stores';
@ -53,11 +53,7 @@
placement="right"
>
<img
crossorigin="anonymous"
src={model?.info?.meta?.profile_image_url ??
($i18n.language === 'dg-DG'
? `${WEBUI_BASE_URL}/doge.png`
: `${WEBUI_BASE_URL}/static/favicon.png`)}
src={`${WEBUI_API_BASE_URL}/models/model/profile/image?id=${model.id}&lang=${$i18n.language}`}
class=" size-[2.7rem] rounded-full border-[1px] border-gray-100 dark:border-none"
alt="logo"
draggable="false"

View file

@ -1069,14 +1069,9 @@
<div class="flex items-center justify-between w-full">
<div class="pl-[1px] flex items-center gap-2 text-sm dark:text-gray-500">
<img
crossorigin="anonymous"
alt="model profile"
class="size-3.5 max-w-[28px] object-cover rounded-full"
src={$models.find((model) => model.id === atSelectedModel.id)?.info?.meta
?.profile_image_url ??
($i18n.language === 'dg-DG'
? `${WEBUI_BASE_URL}/doge.png`
: `${WEBUI_BASE_URL}/static/favicon.png`)}
src={`${WEBUI_API_BASE_URL}/models/model/profile/image?id=${$models.find((model) => model.id === atSelectedModel.id).id}&lang=${$i18n.language}`}
/>
<div class="translate-y-[0.5px]">
<span class="">{atSelectedModel.name}</span>

View file

@ -13,6 +13,7 @@
import Tooltip from '$lib/components/common/Tooltip.svelte';
import VideoInputMenu from './CallOverlay/VideoInputMenu.svelte';
import { KokoroWorker } from '$lib/workers/KokoroWorker';
import { WEBUI_API_BASE_URL } from '$lib/constants';
const i18n = getContext('i18n');
@ -759,14 +760,8 @@
? ' size-16'
: rmsLevel * 100 > 1
? 'size-14'
: 'size-12'} transition-all rounded-full {(model?.info?.meta
?.profile_image_url ?? '/static/favicon.png') !== '/static/favicon.png'
? ' bg-cover bg-center bg-no-repeat'
: 'bg-black dark:bg-white'} bg-black dark:bg-white"
style={(model?.info?.meta?.profile_image_url ?? '/static/favicon.png') !==
'/static/favicon.png'
? `background-image: url('${model?.info?.meta?.profile_image_url}');`
: ''}
: 'size-12'} transition-all rounded-full bg-cover bg-center bg-no-repeat"
style={`background-image: url('${WEBUI_API_BASE_URL}/models/model/profile/image?id=${model?.id}&lang=${$i18n.language}&voice=true');`}
/>
{/if}
<!-- navbar -->
@ -841,14 +836,8 @@
? 'size-48'
: rmsLevel * 100 > 1
? 'size-44'
: 'size-40'} transition-all rounded-full {(model?.info?.meta
?.profile_image_url ?? '/static/favicon.png') !== '/static/favicon.png'
? ' bg-cover bg-center bg-no-repeat'
: 'bg-black dark:bg-white'} "
style={(model?.info?.meta?.profile_image_url ?? '/static/favicon.png') !==
'/static/favicon.png'
? `background-image: url('${model?.info?.meta?.profile_image_url}');`
: ''}
: 'size-40'} transition-all rounded-full bg-cover bg-center bg-no-repeat"
style={`background-image: url('${WEBUI_API_BASE_URL}/models/model/profile/image?id=${model?.id}&lang=${$i18n.language}&voice=true');`}
/>
{/if}
</button>

View file

@ -5,7 +5,7 @@
import { tick, getContext } from 'svelte';
import { models } from '$lib/stores';
import { WEBUI_BASE_URL } from '$lib/constants';
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
import Tooltip from '$lib/components/common/Tooltip.svelte';
const i18n = getContext('i18n');
@ -83,7 +83,7 @@
>
<div class="flex text-black dark:text-gray-100 line-clamp-1">
<img
src={model?.info?.meta?.profile_image_url ?? `${WEBUI_BASE_URL}/static/favicon.png`}
src={`${WEBUI_API_BASE_URL}/models/model/profile/image?id=${model.id}&lang=${$i18n.language}`}
alt={model?.name ?? model.id}
class="rounded-full size-5 items-center mr-2"
/>

View file

@ -272,14 +272,6 @@
}}
>
<div class="flex items-center gap-1.5">
<!-- <ProfileImage
src={model?.info?.meta?.profile_image_url ??
($i18n.language === 'dg-DG'
? `${WEBUI_BASE_URL}/doge.png`
: `${WEBUI_BASE_URL}/favicon.png`)}
className={'size-5 assistant-message-profile-image'}
/> -->
<div class="-translate-y-[1px]">
{model ? `${model.name}` : history.messages[_messageId]?.model}
</div>

View file

@ -6,7 +6,6 @@
</script>
<img
crossorigin="anonymous"
aria-hidden="true"
src={src === ''
? `${WEBUI_BASE_URL}/static/favicon.png`

View file

@ -36,7 +36,7 @@
removeDetails,
removeAllDetails
} from '$lib/utils';
import { WEBUI_BASE_URL } from '$lib/constants';
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
import Name from './Name.svelte';
import ProfileImage from './ProfileImage.svelte';
@ -627,10 +627,7 @@
>
<div class={`shrink-0 ltr:mr-3 rtl:ml-3 hidden @lg:flex mt-1 `}>
<ProfileImage
src={model?.info?.meta?.profile_image_url ??
($i18n.language === 'dg-DG'
? `${WEBUI_BASE_URL}/doge.png`
: `${WEBUI_BASE_URL}/favicon.png`)}
src={`${WEBUI_API_BASE_URL}/models/model/profile/image?id=${model.id}&lang=${$i18n.language}`}
className={'size-8 assistant-message-profile-image'}
/>
</div>

View file

@ -124,10 +124,7 @@
{#if !($settings?.chatBubble ?? true)}
<div class={`shrink-0 ltr:mr-3 rtl:ml-3 mt-1`}>
<ProfileImage
src={message.user
? ($models.find((m) => m.id === message.user)?.info?.meta?.profile_image_url ??
`${WEBUI_BASE_URL}/user.png`)
: (user?.profile_image_url ?? `${WEBUI_BASE_URL}/user.png`)}
src={`${WEBUI_API_BASE_URL}/users/${user.id}/profile/image`}
className={'size-8 user-message-profile-image'}
/>
</div>

View file

@ -5,7 +5,7 @@
import dayjs from '$lib/dayjs';
import { mobile, settings, user } from '$lib/stores';
import { WEBUI_BASE_URL } from '$lib/constants';
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import { copyToClipboard, sanitizeResponseContent } from '$lib/utils';
@ -77,8 +77,7 @@
<div class="flex items-center min-w-fit">
<Tooltip content={$user?.role === 'admin' ? (item?.value ?? '') : ''} placement="top-start">
<img
src={item.model?.info?.meta?.profile_image_url ??
`${WEBUI_BASE_URL}/static/favicon.png`}
src={`${WEBUI_API_BASE_URL}/models/model/profile/image?id=${item.model.id}&lang=${$i18n.language}`}
alt="Model"
class="rounded-full size-5 flex items-center"
/>

View file

@ -38,6 +38,7 @@
import ChatPlus from '../icons/ChatPlus.svelte';
import ChatCheck from '../icons/ChatCheck.svelte';
import Knobs from '../icons/Knobs.svelte';
import { WEBUI_API_BASE_URL } from '$lib/constants';
const i18n = getContext('i18n');
@ -242,7 +243,7 @@
<div class=" self-center">
<span class="sr-only">{$i18n.t('User menu')}</span>
<img
src={$user?.profile_image_url}
src={`${WEBUI_API_BASE_URL}/users/${$user?.id}/profile/image`}
class="size-6 object-cover rounded-full"
alt=""
draggable="false"

View file

@ -1,11 +1,14 @@
<script lang="ts">
import { WEBUI_BASE_URL } from '$lib/constants';
import { WEBUI_API_BASE_URL } from '$lib/constants';
import { Handle, Position, type NodeProps } from '@xyflow/svelte';
import { getContext } from 'svelte';
import ProfileImage from '../Messages/ProfileImage.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Heart from '$lib/components/icons/Heart.svelte';
const i18n = getContext('i18n');
type $$Props = NodeProps;
export let data: $$Props['data'];
</script>
@ -21,7 +24,7 @@
{#if data.message.role === 'user'}
<div class="flex w-full">
<ProfileImage
src={data.user?.profile_image_url ?? `${WEBUI_BASE_URL}/user.png`}
src={`${WEBUI_API_BASE_URL}/users/${data.user.id}/profile/image`}
className={'size-5 -translate-y-[1px]'}
/>
<div class="ml-2">
@ -41,7 +44,7 @@
{:else}
<div class="flex w-full">
<ProfileImage
src={data?.model?.info?.meta?.profile_image_url ?? ''}
src={`${WEBUI_API_BASE_URL}/models/model/profile/image?id=${data.model.id}&lang=${$i18n.language}`}
className={'size-5 -translate-y-[1px]'}
/>

View file

@ -20,7 +20,7 @@
currentChatPage
} from '$lib/stores';
import { sanitizeResponseContent, extractCurlyBraceWords } from '$lib/utils';
import { WEBUI_BASE_URL } from '$lib/constants';
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
import Suggestions from './Suggestions.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
@ -121,11 +121,7 @@
}}
>
<img
crossorigin="anonymous"
src={model?.info?.meta?.profile_image_url ??
($i18n.language === 'dg-DG'
? `${WEBUI_BASE_URL}/doge.png`
: `${WEBUI_BASE_URL}/static/favicon.png`)}
src={`${WEBUI_API_BASE_URL}/models/model/profile/image?id=${model?.id}&lang=${$i18n.language}`}
class=" size-9 @sm:size-10 rounded-full border-[1px] border-gray-100 dark:border-none"
aria-hidden="true"
draggable="false"

View file

@ -4,8 +4,10 @@
import dayjs from 'dayjs';
import localizedFormat from 'dayjs/plugin/localizedFormat';
import calendar from 'dayjs/plugin/calendar'
dayjs.extend(localizedFormat);
dayjs.extend(calendar);
import { deleteChatById } from '$lib/apis/chats';
@ -242,7 +244,14 @@
<div class="basis-2/5 flex items-center justify-end">
<div class="hidden sm:flex text-gray-500 dark:text-gray-400 text-xs">
{dayjs(chat?.updated_at * 1000).calendar()}
{$i18n.t(dayjs(chat?.updated_at * 1000).calendar(null, {
sameDay: '[Today]',
nextDay: '[Tomorrow]',
nextWeek: 'dddd',
lastDay: '[Yesterday]',
lastWeek: '[Last] dddd',
sameElse: 'L' // use localized format, otherwise dayjs.calendar() defaults to DD/MM/YYYY
}))}
</div>
<div class="flex justify-end pl-2.5 text-gray-600 dark:text-gray-300">

View file

@ -9,6 +9,7 @@
import Spinner from '../common/Spinner.svelte';
import dayjs from '$lib/dayjs';
import localizedFormat from 'dayjs/plugin/localizedFormat';
import calendar from 'dayjs/plugin/calendar';
import Loader from '../common/Loader.svelte';
import { createMessagesList } from '$lib/utils';
@ -18,6 +19,7 @@
import PencilSquare from '../icons/PencilSquare.svelte';
import PageEdit from '../icons/PageEdit.svelte';
dayjs.extend(calendar);
dayjs.extend(localizedFormat);
export let show = false;
export let onClose = () => {};
@ -387,7 +389,14 @@
</div>
<div class=" pl-3 shrink-0 text-gray-500 dark:text-gray-400 text-xs">
{dayjs(chat?.updated_at * 1000).calendar()}
{$i18n.t(dayjs(chat?.updated_at * 1000).calendar(null, {
sameDay: '[Today]',
nextDay: '[Tomorrow]',
nextWeek: 'dddd',
lastDay: '[Yesterday]',
lastWeek: '[Last] dddd',
sameElse: 'L' // use localized format, otherwise dayjs.calendar() defaults to DD/MM/YYYY
}))}
</div>
</a>
{/each}

View file

@ -41,7 +41,7 @@
importChat
} from '$lib/apis/chats';
import { createNewFolder, getFolders, updateFolderParentIdById } from '$lib/apis/folders';
import { WEBUI_BASE_URL } from '$lib/constants';
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
import ArchivedChatsModal from './ArchivedChatsModal.svelte';
import UserMenu from './Sidebar/UserMenu.svelte';
@ -537,7 +537,7 @@
{#if !$mobile && !$showSidebar}
<div
class=" py-2 px-1.5 flex flex-col justify-between text-black dark:text-white hover:bg-gray-50/30 dark:hover:bg-gray-950/30 h-full z-10 transition-all border-e-[0.5px] border-gray-50 dark:border-gray-850"
class=" pt-[7px] pb-2 px-1.5 flex flex-col justify-between text-black dark:text-white hover:bg-gray-50/30 dark:hover:bg-gray-950/30 h-full z-10 transition-all border-e-[0.5px] border-gray-50 dark:border-gray-850"
id="sidebar"
>
<button
@ -559,7 +559,6 @@
>
<div class=" self-center flex items-center justify-center size-9">
<img
crossorigin="anonymous"
src="{WEBUI_BASE_URL}/static/favicon.png"
class="sidebar-new-chat-icon size-6 rounded-full group-hover:hidden"
alt=""
@ -571,7 +570,7 @@
</Tooltip>
</div>
<div>
<div class="-mt-[0.5px]">
<div class="">
<Tooltip content={$i18n.t('New Chat')} placement="right">
<a
@ -594,7 +593,7 @@
</Tooltip>
</div>
<div class="">
<div>
<Tooltip content={$i18n.t('Search')} placement="right">
<button
class=" cursor-pointer flex rounded-xl hover:bg-gray-100 dark:hover:bg-gray-850 transition group"
@ -694,7 +693,7 @@
>
<div class=" self-center flex items-center justify-center size-9">
<img
src={$user?.profile_image_url}
src={`${WEBUI_API_BASE_URL}/users/${$user?.id}/profile/image`}
class=" size-6 object-cover rounded-full"
alt={$i18n.t('Open User Profile Menu')}
aria-label={$i18n.t('Open User Profile Menu')}
@ -789,7 +788,7 @@
}}
>
<div class="pb-1.5">
<div class="px-[7px] flex justify-center text-gray-800 dark:text-gray-200">
<div class="px-1.5 flex justify-center text-gray-800 dark:text-gray-200">
<a
id="sidebar-new-chat-button"
class="group grow flex items-center space-x-3 rounded-2xl px-2.5 py-2 hover:bg-gray-100 dark:hover:bg-gray-900 transition outline-none"
@ -810,7 +809,7 @@
</a>
</div>
<div class="px-[7px] flex justify-center text-gray-800 dark:text-gray-200">
<div class="px-1.5 flex justify-center text-gray-800 dark:text-gray-200">
<button
id="sidebar-search-button"
class="group grow flex items-center space-x-3 rounded-2xl px-2.5 py-2 hover:bg-gray-100 dark:hover:bg-gray-900 transition outline-none"
@ -832,7 +831,7 @@
</div>
{#if ($config?.features?.enable_notes ?? false) && ($user?.role === 'admin' || ($user?.permissions?.features?.notes ?? true))}
<div class="px-[7px] flex justify-center text-gray-800 dark:text-gray-200">
<div class="px-1.5 flex justify-center text-gray-800 dark:text-gray-200">
<a
id="sidebar-notes-button"
class="grow flex items-center space-x-3 rounded-2xl px-2.5 py-2 hover:bg-gray-100 dark:hover:bg-gray-900 transition"
@ -853,7 +852,7 @@
{/if}
{#if $user?.role === 'admin' || $user?.permissions?.workspace?.models || $user?.permissions?.workspace?.knowledge || $user?.permissions?.workspace?.prompts || $user?.permissions?.workspace?.tools}
<div class="px-[7px] flex justify-center text-gray-800 dark:text-gray-200">
<div class="px-1.5 flex justify-center text-gray-800 dark:text-gray-200">
<a
id="sidebar-workspace-button"
class="grow flex items-center space-x-3 rounded-2xl px-2.5 py-2 hover:bg-gray-100 dark:hover:bg-gray-900 transition"
@ -1233,7 +1232,7 @@
>
<div class=" self-center mr-3">
<img
src={$user?.profile_image_url}
src={`${WEBUI_API_BASE_URL}/users/${$user?.id}/profile/image`}
class=" size-6 object-cover rounded-full"
alt={$i18n.t('Open User Profile Menu')}
aria-label={$i18n.t('Open User Profile Menu')}

View file

@ -3,7 +3,7 @@
const i18n = getContext('i18n');
import { WEBUI_BASE_URL } from '$lib/constants';
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import PinSlash from '$lib/components/icons/PinSlash.svelte';
@ -36,8 +36,7 @@
>
<div class="self-center shrink-0">
<img
crossorigin="anonymous"
src={model?.info?.meta?.profile_image_url ?? `${WEBUI_BASE_URL}/static/favicon.png`}
src={`${WEBUI_API_BASE_URL}/models/model/profile/image?id=${model.id}&lang=${$i18n.language}`}
class=" size-5 rounded-full -translate-x-[0.5px]"
alt="logo"
/>

View file

@ -116,7 +116,8 @@
<AccessControl
bind:accessControl
accessRoles={['read', 'write']}
allowPublic={$user?.permissions?.sharing?.public_knowledge || $user?.role === 'admin'}
share={$user?.permissions?.sharing?.knowledge || $user?.role === 'admin'}
sharePublic={$user?.permissions?.sharing?.public_knowledge || $user?.role === 'admin'}
/>
</div>
</div>

View file

@ -546,14 +546,42 @@
e.preventDefault();
dragged = false;
const handleUploadingFileFolder = (items) => {
for (const item of items) {
if (item.isFile) {
item.file((file) => {
uploadFileHandler(file);
});
continue;
}
// Not sure why you have to call webkitGetAsEntry and isDirectory seperate, but it won't work if you try item.webkitGetAsEntry().isDirectory
const wkentry = item.webkitGetAsEntry();
const isDirectory = wkentry.isDirectory;
if (isDirectory) {
// Read the directory
wkentry.createReader().readEntries(
(entries) => {
handleUploadingFileFolder(entries);
},
(error) => {
console.error('Error reading directory entries:', error);
}
);
} else {
toast.info($i18n.t('Uploading file...'));
uploadFileHandler(item.getAsFile());
toast.success($i18n.t('File uploaded!'));
}
}
};
if (e.dataTransfer?.types?.includes('Files')) {
if (e.dataTransfer?.files) {
const inputFiles = e.dataTransfer?.files;
const inputItems = e.dataTransfer?.items;
if (inputFiles && inputFiles.length > 0) {
for (const file of inputFiles) {
await uploadFileHandler(file);
}
if (inputItems && inputItems.length > 0) {
handleUploadingFileFolder(inputItems);
} else {
toast.error($i18n.t(`File not found.`));
}
@ -682,7 +710,8 @@
<AccessControlModal
bind:show={showAccessControlModal}
bind:accessControl={knowledge.access_control}
allowPublic={$user?.permissions?.sharing?.public_knowledge || $user?.role === 'admin'}
share={$user?.permissions?.sharing?.knowledge || $user?.role === 'admin'}
sharePublic={$user?.permissions?.sharing?.public_knowledge || $user?.role === 'admin'}
onChange={() => {
changeDebounceHandler();
}}

View file

@ -12,7 +12,7 @@
const i18n = getContext('i18n');
import { WEBUI_NAME, config, mobile, models as _models, settings, user } from '$lib/stores';
import { WEBUI_BASE_URL } from '$lib/constants';
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
import {
createNewModel,
deleteModelById,
@ -438,13 +438,12 @@
<div class="self-center pl-0.5">
<div class="flex bg-white rounded-2xl">
<div
class="{model.is_active ? '' : 'opacity-50 dark:opacity-50'} {model.meta
.profile_image_url !== `${WEBUI_BASE_URL}/static/favicon.png`
? 'bg-transparent'
: 'bg-white'} rounded-2xl"
class="{model.is_active
? ''
: 'opacity-50 dark:opacity-50'} bg-transparent rounded-2xl"
>
<img
src={model?.meta?.profile_image_url ?? `${WEBUI_BASE_URL}/static/favicon.png`}
src={`${WEBUI_API_BASE_URL}/models/model/profile/image?id=${model.id}&lang=${$i18n.language}`}
alt="modelfile profile"
class=" rounded-2xl size-12 object-cover"
/>

View file

@ -748,7 +748,8 @@
<AccessControl
bind:accessControl
accessRoles={['read', 'write']}
allowPublic={$user?.permissions?.sharing?.public_models || $user?.role === 'admin'}
share={$user?.permissions?.sharing?.models || $user?.role === 'admin'}
sharePublic={$user?.permissions?.sharing?.public_models || $user?.role === 'admin'}
/>
</div>
</div>

View file

@ -83,7 +83,8 @@
bind:show={showAccessControlModal}
bind:accessControl
accessRoles={['read', 'write']}
allowPublic={$user?.permissions?.sharing?.public_prompts || $user?.role === 'admin'}
share={$user?.permissions?.sharing?.prompts || $user?.role === 'admin'}
sharePublic={$user?.permissions?.sharing?.public_prompts || $user?.role === 'admin'}
/>
<div class="w-full max-h-full flex justify-center">

View file

@ -189,7 +189,8 @@ class Tools:
bind:show={showAccessControlModal}
bind:accessControl
accessRoles={['read', 'write']}
allowPublic={$user?.permissions?.sharing?.public_tools || $user?.role === 'admin'}
share={$user?.permissions?.sharing?.tools || $user?.role === 'admin'}
sharePublic={$user?.permissions?.sharing?.public_tools || $user?.role === 'admin'}
/>
<div class=" flex flex-col justify-between w-full overflow-y-auto h-full">

View file

@ -15,17 +15,18 @@
export let accessRoles = ['read'];
export let accessControl = {};
export let allowPublic = true;
export let share = true;
export let sharePublic = true;
let selectedGroupId = '';
let groups = [];
$: if (!allowPublic && accessControl === null) {
$: if (!sharePublic && accessControl === null) {
initPublicAccess();
}
const initPublicAccess = () => {
if (!allowPublic && accessControl === null) {
if (!sharePublic && accessControl === null) {
accessControl = {
read: {
group_ids: [],
@ -41,7 +42,7 @@
};
onMount(async () => {
groups = await getGroups(localStorage.token);
groups = await getGroups(localStorage.token, true);
if (accessControl === null) {
initPublicAccess();
@ -125,7 +126,7 @@
}}
>
<option class=" text-gray-700" value="private" selected>{$i18n.t('Private')}</option>
{#if allowPublic}
{#if share && sharePublic}
<option class=" text-gray-700" value="public" selected>{$i18n.t('Public')}</option>
{/if}
</select>
@ -140,48 +141,50 @@
</div>
</div>
</div>
{#if accessControl !== null}
{@const accessGroups = groups.filter((group) =>
(accessControl?.read?.group_ids ?? []).includes(group.id)
)}
<div>
<div class="">
<div class="flex justify-between mb-1.5">
<div class="text-sm font-semibold">
{$i18n.t('Groups')}
</div>
</div>
<div class="mb-1">
<div class="flex w-full">
<div class="flex flex-1 items-center">
<div class="w-full px-0.5">
<select
class="outline-hidden bg-transparent text-sm rounded-lg block w-full pr-10 max-w-full
{#if share}
{#if accessControl !== null}
{@const accessGroups = groups.filter((group) =>
(accessControl?.read?.group_ids ?? []).includes(group.id)
)}
<div>
<div class="">
<div class="flex justify-between mb-1.5">
<div class="text-sm font-semibold">
{$i18n.t('Groups')}
</div>
</div>
<div class="mb-1">
<div class="flex w-full">
<div class="flex flex-1 items-center">
<div class="w-full px-0.5">
<select
class="outline-hidden bg-transparent text-sm rounded-lg block w-full pr-10 max-w-full
{selectedGroupId ? '' : 'text-gray-500'}
dark:placeholder-gray-500"
bind:value={selectedGroupId}
on:change={() => {
if (selectedGroupId !== '') {
accessControl.read.group_ids = [
...(accessControl?.read?.group_ids ?? []),
selectedGroupId
];
bind:value={selectedGroupId}
on:change={() => {
if (selectedGroupId !== '') {
accessControl.read.group_ids = [
...(accessControl?.read?.group_ids ?? []),
selectedGroupId
];
selectedGroupId = '';
onChange(accessControl);
}
}}
>
<option class=" text-gray-700" value="" disabled selected
>{$i18n.t('Select a group')}</option
selectedGroupId = '';
onChange(accessControl);
}
}}
>
{#each groups.filter((group) => !(accessControl?.read?.group_ids ?? []).includes(group.id)) as group}
<option class=" text-gray-700" value={group.id}>{group.name}</option>
{/each}
</select>
</div>
<!-- <div>
<option class=" text-gray-700" value="" disabled selected
>{$i18n.t('Select a group')}</option
>
{#each groups.filter((group) => !(accessControl?.read?.group_ids ?? []).includes(group.id)) as group}
<option class=" text-gray-700" value={group.id}>{group.name}</option>
{/each}
</select>
</div>
<!-- <div>
<Tooltip content={$i18n.t('Add Group')}>
<button
class=" p-1 rounded-xl bg-transparent dark:hover:bg-white/5 hover:bg-black/5 transition font-medium text-sm flex items-center space-x-1"
@ -192,80 +195,81 @@
</button>
</Tooltip>
</div> -->
</div>
</div>
</div>
</div>
<hr class=" border-gray-100 dark:border-gray-700/10 mt-1.5 mb-2.5 w-full" />
<hr class=" border-gray-100 dark:border-gray-700/10 mt-1.5 mb-2.5 w-full" />
<div class="flex flex-col gap-2 mb-1 px-0.5">
{#if accessGroups.length > 0}
{#each accessGroups as group}
<div class="flex items-center gap-3 justify-between text-xs w-full transition">
<div class="flex items-center gap-1.5 w-full font-medium">
<div>
<UserCircleSolid className="size-4" />
<div class="flex flex-col gap-2 mb-1 px-0.5">
{#if accessGroups.length > 0}
{#each accessGroups as group}
<div class="flex items-center gap-3 justify-between text-xs w-full transition">
<div class="flex items-center gap-1.5 w-full font-medium">
<div>
<UserCircleSolid className="size-4" />
</div>
<div>
{group.name}
</div>
</div>
<div>
{group.name}
</div>
</div>
<div class="w-full flex justify-end items-center gap-0.5">
<button
class=""
type="button"
on:click={() => {
if (accessRoles.includes('write')) {
if ((accessControl?.write?.group_ids ?? []).includes(group.id)) {
accessControl.write.group_ids = (
accessControl?.write?.group_ids ?? []
).filter((group_id) => group_id !== group.id);
} else {
accessControl.write.group_ids = [
...(accessControl?.write?.group_ids ?? []),
group.id
];
<div class="w-full flex justify-end items-center gap-0.5">
<button
class=""
type="button"
on:click={() => {
if (accessRoles.includes('write')) {
if ((accessControl?.write?.group_ids ?? []).includes(group.id)) {
accessControl.write.group_ids = (
accessControl?.write?.group_ids ?? []
).filter((group_id) => group_id !== group.id);
} else {
accessControl.write.group_ids = [
...(accessControl?.write?.group_ids ?? []),
group.id
];
}
onChange(accessControl);
}
onChange(accessControl);
}
}}
>
{#if (accessControl?.write?.group_ids ?? []).includes(group.id)}
<Badge type={'success'} content={$i18n.t('Write')} />
{:else}
<Badge type={'info'} content={$i18n.t('Read')} />
{/if}
</button>
}}
>
{#if (accessControl?.write?.group_ids ?? []).includes(group.id)}
<Badge type={'success'} content={$i18n.t('Write')} />
{:else}
<Badge type={'info'} content={$i18n.t('Read')} />
{/if}
</button>
<button
class=" rounded-full p-1 hover:bg-gray-100 dark:hover:bg-gray-850 transition"
type="button"
on:click={() => {
accessControl.read.group_ids = (accessControl?.read?.group_ids ?? []).filter(
(id) => id !== group.id
);
accessControl.write.group_ids = (
accessControl?.write?.group_ids ?? []
).filter((id) => id !== group.id);
onChange(accessControl);
}}
>
<XMark />
</button>
<button
class=" rounded-full p-1 hover:bg-gray-100 dark:hover:bg-gray-850 transition"
type="button"
on:click={() => {
accessControl.read.group_ids = (
accessControl?.read?.group_ids ?? []
).filter((id) => id !== group.id);
accessControl.write.group_ids = (
accessControl?.write?.group_ids ?? []
).filter((id) => id !== group.id);
onChange(accessControl);
}}
>
<XMark />
</button>
</div>
</div>
{/each}
{:else}
<div class="flex items-center justify-center">
<div class="text-gray-500 text-xs text-center py-2 px-10">
{$i18n.t('No groups with access, add a group to grant access')}
</div>
</div>
{/each}
{:else}
<div class="flex items-center justify-center">
<div class="text-gray-500 text-xs text-center py-2 px-10">
{$i18n.t('No groups with access, add a group to grant access')}
</div>
</div>
{/if}
{/if}
</div>
</div>
</div>
</div>
{/if}
{/if}
</div>

View file

@ -9,7 +9,9 @@
export let show = false;
export let accessControl = {};
export let accessRoles = ['read'];
export let allowPublic = true;
export let share = true;
export let sharePublic = true;
export let onChange = () => {};
</script>
@ -31,7 +33,7 @@
</div>
<div class="w-full px-5 pb-4 dark:text-white">
<AccessControl bind:accessControl {onChange} {accessRoles} {allowPublic} />
<AccessControl bind:accessControl {onChange} {accessRoles} {share} {sharePublic} />
</div>
</div>
</Modal>

View file

@ -101,5 +101,6 @@ import 'dayjs/locale/yo';
import 'dayjs/locale/zh';
import 'dayjs/locale/zh-tw';
import 'dayjs/locale/et';
import 'dayjs/locale/en-gb'
export default dayjs;

View file

@ -76,7 +76,7 @@
"Advanced Parameters": "고급 매개변수",
"Advanced parameters for MinerU parsing (enable_ocr, enable_formula, enable_table, language, model_version, page_ranges)": "",
"Advanced Params": "고급 매개변수",
"After updating or changing the embedding model, you must reindex the knowledge base for the changes to take effect. You can do this using the \"Reindex\" button below.": "",
"After updating or changing the embedding model, you must reindex the knowledge base for the changes to take effect. You can do this using the \"Reindex\" button below.": "임베딩 모델을 업데이트하거나 변경 후 변경 사항을 적용하려면 지식 베이스를 다시 인덱싱해야 합니다. 아래의 \"재색인\" 버튼을 사용하여 수행할 수 있습니다.",
"AI": "",
"All": "전체",
"All chats have been unarchived.": "모든 채팅이 보관 해제되었습니다.",
@ -153,7 +153,7 @@
"Ask": "질문",
"Ask a question": "질문하기",
"Assistant": "어시스턴트",
"Attach File From Knowledge": "",
"Attach File From Knowledge": "지식 기반에서 파일 첨부",
"Attach Knowledge": "지식 기반 첨부",
"Attach Notes": "노트 첨부",
"Attach Webpage": "웹페이지 첨부",
@ -233,7 +233,7 @@
"Chat Background Image": "채팅 배경 이미지",
"Chat Bubble UI": "버블형 채팅 UI",
"Chat Controls": "채팅 제어",
"Chat Conversation": "",
"Chat Conversation": "채팅 대화",
"Chat direction": "채팅 방향",
"Chat ID": "채팅 ID",
"Chat moved successfully": "채팅 이동 성공",
@ -279,7 +279,7 @@
"cloud": "",
"CMU ARCTIC speaker embedding name": "",
"Code Block": "코드 블록",
"Code Editor": "",
"Code Editor": "코드 편집기",
"Code execution": "코드 실행",
"Code Execution": "코드 실행",
"Code Execution Engine": "코드 실행 엔진",
@ -337,8 +337,8 @@
"Copied to clipboard": "클립보드에 복사되었습니다",
"Copy": "복사",
"Copy Formatted Text": "서식 있는 텍스트 복사",
"Copy Last Code Block": "",
"Copy Last Response": "",
"Copy Last Code Block": "마지막 코드 블록 복사",
"Copy Last Response": "마지막 응답 복사",
"Copy link": "링크 복사",
"Copy Link": "링크 복사",
"Copy to clipboard": "클립보드에 복사",
@ -347,13 +347,13 @@
"Create": "생성",
"Create a knowledge base": "지식 기반 생성",
"Create a model": "모델 생성",
"Create a new note": "",
"Create a new note": "새 노트 생성",
"Create Account": "계정 생성",
"Create Admin Account": "관리자 계정 생성",
"Create Channel": "채널 생성",
"Create Folder": "폴더 생성",
"Create Group": "그룹 생성",
"Create Image": "",
"Create Image": "이미지 생성",
"Create Knowledge": "지식 생성",
"Create Model": "모델 생성",
"Create new key": "새로운 키 생성",
@ -502,7 +502,7 @@
"Edit Connection": "연결 편집",
"Edit Default Permissions": "기본 권한 편집",
"Edit Folder": "폴더 편집",
"Edit Image": "",
"Edit Image": "이미지 편집",
"Edit Last Message": "",
"Edit Memory": "메모리 편집",
"Edit User": "사용자 편집",
@ -592,8 +592,8 @@
"Enter Kagi Search API Key": "Kagi Search API 키 입력",
"Enter Key Behavior": "키 동작 입력",
"Enter language codes": "언어 코드 입력",
"Enter MinerU API Key": "",
"Enter Mistral API Base URL": "",
"Enter MinerU API Key": "MinerU API 키 입력",
"Enter Mistral API Base URL": "Mistral API Base URL 입력",
"Enter Mistral API Key": "Mistral API 키 입력",
"Enter Model ID": "모델 ID 입력",
"Enter model tag (e.g. {{modelTag}})": "모델 태그 입력(예: {{modelTag}})",
@ -718,8 +718,8 @@
"Failed to load chat preview": "채팅 미리보기 로드 실패",
"Failed to load file content.": "파일 내용 로드 실패.",
"Failed to move chat": "채팅 이동 실패",
"Failed to read clipboard contents": "클립보드 내용 가져오기를 실패하였습니다.",
"Failed to render diagram": "",
"Failed to read clipboard contents": "클립보드 내용 가져오기를 실패하였습니다",
"Failed to render diagram": "다이어그램을 표시할 수 없습니다",
"Failed to render visualization": "",
"Failed to save connections": "연결 저장 실패",
"Failed to save conversation": "대화 저장 실패",
@ -753,7 +753,7 @@
"Firecrawl API Base URL": "Firecrawl API 기본 URL",
"Firecrawl API Key": "Firecrawl API 키",
"Floating Quick Actions": "",
"Focus Chat Input": "",
"Focus Chat Input": "채팅 입력창에 포커스",
"Folder": "폴더",
"Folder Background Image": "폴더 배경 이미지",
"Folder deleted successfully": "성공적으로 폴더가 삭제되었습니다",
@ -774,8 +774,8 @@
"Format Lines": "줄 서식",
"Format the lines in the output. Defaults to False. If set to True, the lines will be formatted to detect inline math and styles.": "출력되는 줄에 서식을 적용합니다. 기본값은 False입니다. 이 옵션을 True로 하면, 인라인 수식 및 스타일을 감지하도록 줄에 서식이 적용됩니다.",
"Format your variables using brackets like this:": "변수를 다음과 같이 괄호를 사용하여 생성하세요",
"Formatting may be inconsistent from source.": "",
"Forwards system user OAuth access token to authenticate": "",
"Formatting may be inconsistent from source.": "출처에서의 서식이 일관되지 않을 수 있습니다.",
"Forwards system user OAuth access token to authenticate": "인증을 위해 시스템 사용자 OAuth 액세스 토큰을 전달합니다.",
"Forwards system user session credentials to authenticate": "인증을 위해 시스템 사용자 세션 자격 증명 전달",
"Full Context Mode": "전체 컨텍스트 모드",
"Function": "함수",
@ -803,7 +803,7 @@
"Generate an image": "이미지 생성",
"Generate Image": "이미지 생성",
"Generate Message Pair": "",
"Generated Image": "",
"Generated Image": "생성된 이미지",
"Generating search query": "검색 쿼리 생성",
"Generating...": "생성 중...",
"Get information on {{name}} in the UI": "UI에서 {{name}} 정보 확인",
@ -865,7 +865,7 @@
"Image Max Compression Size width": "이미지 최대 압축 크기 너비",
"Image Prompt Generation": "이미지 프롬프트 생성",
"Image Prompt Generation Prompt": "이미지 프롬프트를 생성하기 위한 프롬프트",
"Image Size": "",
"Image Size": "이미지 크기",
"Images": "이미지",
"Import": "가져오기",
"Import Chats": "채팅 가져오기",
@ -963,7 +963,7 @@
"Leave empty to include all models from \"{{url}}/api/tags\" endpoint": "\"{{url}}/api/tags\" 엔드포인트의 모든 모델을 포함하려면 비워 두세요",
"Leave empty to include all models from \"{{url}}/models\" endpoint": "\"{{url}}/models\" 엔드포인트의 모든 모델을 포함하려면 비워 두세요",
"Leave empty to include all models or select specific models": "비워두면 모든 모델이 포함되며, 특정 모델을 선택할 수도 있습니다.",
"Leave empty to use the default model (voxtral-mini-latest).": "",
"Leave empty to use the default model (voxtral-mini-latest).": "비워두면 기본 모델(voxtral-mini-latest)을 사용합니다.",
"Leave empty to use the default prompt, or enter a custom prompt": "기본 프롬프트를 사용하기 위해 빈칸으로 남겨두거나, 커스텀 프롬프트를 입력하세요",
"Leave model field empty to use the default model.": "기본 모델을 사용하려면 모델 필드를 비워 두세요.",
"Legacy": "",
@ -1017,14 +1017,14 @@
"Memory updated successfully": "성공적으로 메모리가 업데이트되었습니다",
"Merge Responses": "응답들 결합하기",
"Merged Response": "결합된 응답",
"Message": "",
"Message": "메시지",
"Message rating should be enabled to use this feature": "이 기능을 사용하려면 메시지 평가가 활성화되어야합니다",
"Messages you send after creating your link won't be shared. Users with the URL will be able to view the shared chat.": "링크 생성 후에 보낸 메시지는 공유되지 않습니다. URL이 있는 사용자는 공유된 채팅을 볼 수 있습니다.",
"Microsoft OneDrive": "",
"Microsoft OneDrive (personal)": "Microsoft OneDrive (개인용)",
"Microsoft OneDrive (work/school)": "Microsoft OneDrive (회사/학교용)",
"MinerU": "",
"MinerU API Key required for Cloud API mode.": "",
"MinerU API Key required for Cloud API mode.": "클라우드 API 모드를 사용하려면 MinerU API 키가 필요합니다.",
"Mistral OCR": "",
"Mistral OCR API Key required.": "Mistral OCR API Key가 필요합니다.",
"MistralAI": "",
@ -1048,7 +1048,7 @@
"Model ID is required.": "모델 ID가 필요합니다",
"Model IDs": "모델 IDs",
"Model Name": "모델 이름",
"Model name already exists, please choose a different one": "",
"Model name already exists, please choose a different one": "이 모델 이름은 이미 존재합니다. 다른 이름을 선택해주세요.",
"Model Name is required.": "모델 이름이 필요합니다",
"Model not selected": "모델이 선택되지 않았습니다.",
"Model Params": "모델 매개변수",
@ -1060,7 +1060,7 @@
"Models": "모델",
"Models Access": "모델 접근",
"Models configuration saved successfully": "모델 구성이 성공적으로 저장되었습니다",
"Models imported successfully": "",
"Models imported successfully": "모델을 성공적으로 가져왔습니다.",
"Models Public Sharing": "모델 공개 공유",
"Mojeek Search API Key": "Mojeek Search API 키",
"More": "더보기",
@ -1246,7 +1246,7 @@
"Please enter a valid URL.": "올바른 URL을 입력하세요.",
"Please fill in all fields.": "모두 빈칸없이 채워주세요",
"Please register the OAuth client": "OAuth clith를 등록해주세요",
"Please save the connection to persist the OAuth client information and do not change the ID": "",
"Please save the connection to persist the OAuth client information and do not change the ID": "OAuth 클라이언트 정보를 저장하려면 연결을 저장하고 ID를 변경하지 마세요.",
"Please select a model first.": "먼저 모델을 선택하세요.",
"Please select a model.": "모델을 선택하세요.",
"Please select a reason": "이유를 선택해주세요",
@ -1257,7 +1257,7 @@
"Prefer not to say": "언급하고 싶지 않습니다.",
"Prefix ID": "Prefix ID",
"Prefix ID is used to avoid conflicts with other connections by adding a prefix to the model IDs - leave empty to disable": "Prefix ID는 모델 ID에 접두사를 추가하여 다른 연결과의 충돌을 방지하는 데 사용됩니다. - 비활성화하려면 비워 둡니다.",
"Prevent File Creation": "",
"Prevent File Creation": "파일 생성 방지",
"Preview": "미리보기",
"Previous 30 days": "이전 30일",
"Previous 7 days": "이전 7일",
@ -1281,7 +1281,7 @@
"Pull Model": "모델 pull",
"pypdfium2": "",
"Query Generation Prompt": "쿼리 생성 프롬프트",
"Querying": "",
"Querying": "쿼리 진행중",
"Quick Actions": "빠른 작업",
"RAG Template": "RAG 템플릿",
"Rating": "평가",
@ -1367,7 +1367,7 @@
"Scroll On Branch Change": "브랜치 변경 시 스크롤",
"Search": "검색",
"Search a model": "모델 검색",
"Search all emojis": "",
"Search all emojis": "모든 이모지 검색",
"Search Base": "검색 기반",
"Search Chats": "채팅 검색",
"Search Collection": "컬렉션 검색",
@ -1470,7 +1470,7 @@
"Show Formatting Toolbar": "서식 툴바 표시",
"Show image preview": "이미지 미리보기",
"Show Model": "모델 보기",
"Show Shortcuts": "",
"Show Shortcuts": "단축키 보기",
"Show your support!": "당신의 응원을 보내주세요!",
"Showcased creativity": "창의성 발휘",
"Sign in": "로그인",
@ -1499,14 +1499,14 @@
"Speech-to-Text": "음성-텍스트 변환",
"Speech-to-Text Engine": "음성-텍스트 변환 엔진",
"standard": "",
"Start a new conversation": "",
"Start a new conversation": "새 대화 시작",
"Start of the channel": "채널 시작",
"Start Tag": "시작 태그",
"Status Updates": "상태 업데이트",
"STDOUT/STDERR": "STDOUT/STDERR",
"Steps": "",
"Stop": "정지",
"Stop Generating": "",
"Stop Generating": "생성 중지",
"Stop Sequence": "중지 시퀀스",
"Stream Chat Response": "스트림 채팅 응답",
"Stream Delta Chunk Size": "스트림 델타 청크 크기",
@ -1530,7 +1530,7 @@
"System": "시스템",
"System Instructions": "시스템 지침",
"System Prompt": "시스템 프롬프트",
"Table Mode": "",
"Table Mode": "테이블 모드",
"Tag": "",
"Tags": "태그",
"Tags Generation": "태그 생성",
@ -1578,7 +1578,7 @@
"This chat won't appear in history and your messages will not be saved.": "이 채팅은 기록에 나타나지 않으며 메시지가 저장되지 않습니다.",
"This ensures that your valuable conversations are securely saved to your backend database. Thank you!": "이렇게 하면 소중한 대화 내용이 백엔드 데이터베이스에 안전하게 저장됩니다. 감사합니다!",
"This feature is experimental and may be modified or discontinued without notice.": "이 기능은 실험 중이며, 사전 통보 없이 수정되거나 중단될 수 있습니다.",
"This is a default user permission and will remain enabled.": "",
"This is a default user permission and will remain enabled.": "이것은 기본 사용자 권한이며 계속 활성화됩니다.",
"This is an experimental feature, it may not function as expected and is subject to change at any time.": "이것은 실험적 기능으로, 예상대로 작동하지 않을 수 있으며 언제든지 변경될 수 있습니다.",
"This model is not publicly available. Please select another model.": "이 모델은 공개적으로 사용할 수 없습니다. 다른 모델을 선택해주세요.",
"This option controls how long the model will stay loaded into memory following the request (default: 5m)": "이 옵션은 요청 처리 후 모델이 메모리에 유지하는 시간을 제어합니다. (기본값: 5분)",

View file

@ -95,6 +95,7 @@
"Allow Continue Response": "允许继续生成回答",
"Allow Delete Messages": "允许删除对话消息",
"Allow File Upload": "允许上传文件",
"Allow Group Sharing": "允许群组内共享",
"Allow Multiple Models in Chat": "允许在对话中使用多个模型",
"Allow non-local voices": "允许调用非本土音色",
"Allow Rate Response": "允许对回答进行评价",
@ -388,6 +389,7 @@
"Default description enabled": "默认描述已启用",
"Default Features": "默认功能",
"Default Filters": "默认过滤器",
"Default Group": "默认权限组",
"Default mode works with a wider range of models by calling tools once before execution. Native mode leverages the model's built-in tool-calling capabilities, but requires the model to inherently support this feature.": "“默认”模式会在请求模型前调用工具,因此能兼容更多模型。“原生”模式则依赖模型本身的工具调用能力,但需要模型本身支持该功能。",
"Default Model": "默认模型",
"Default model updated": "默认模型已更新",
@ -731,6 +733,7 @@
"Features": "功能",
"Features Permissions": "功能权限",
"February": "二月",
"Feedback deleted successfully": "反馈删除成功",
"Feedback Details": "反馈详情",
"Feedback History": "历史反馈",
"Feedbacks": "反馈",
@ -745,6 +748,7 @@
"File size should not exceed {{maxSize}} MB.": "文件大小不应超过 {{maxSize}} MB",
"File Upload": "文件上传",
"File uploaded successfully": "文件上传成功",
"File uploaded!": "文件上传成功",
"Files": "文件",
"Filter": "过滤",
"Filter is now globally disabled": "过滤器已全局禁用",
@ -858,6 +862,7 @@
"Image Compression": "压缩图像",
"Image Compression Height": "压缩图像高度",
"Image Compression Width": "压缩图像宽度",
"Image Edit": "图片编辑",
"Image Edit Engine": "图片编辑引擎",
"Image Generation": "图像生成",
"Image Generation Engine": "图像生成引擎",
@ -942,6 +947,7 @@
"Knowledge Name": "知识库名称",
"Knowledge Public Sharing": "公开分享知识库",
"Knowledge reset successfully.": "知识库重置成功",
"Knowledge Sharing": "分享知识库",
"Knowledge updated successfully": "知识库更新成功",
"Kokoro.js (Browser)": "Kokoro.js运行于用户浏览器",
"Kokoro.js Dtype": "Kokoro.js Dtype",
@ -1062,7 +1068,8 @@
"Models Access": "访问模型列表",
"Models configuration saved successfully": "模型配置保存成功",
"Models imported successfully": "已成功导入模型配置",
"Models Public Sharing": "模型公开共享",
"Models Public Sharing": "模型公开分享",
"Models Sharing": "分享模型",
"Mojeek Search API Key": "Mojeek Search 接口密钥",
"More": "更多",
"More Concise": "精炼表达",
@ -1129,6 +1136,7 @@
"Note: If you set a minimum score, the search will only return documents with a score greater than or equal to the minimum score.": "注意:如果设置了最低分数,搜索结果只会返回分数大于或等于最低分数的文档。",
"Notes": "笔记",
"Notes Public Sharing": "公开分享笔记",
"Notes Sharing": "分享笔记",
"Notification Sound": "通知提示音",
"Notification Webhook": "通知 Webhook",
"Notifications": "桌面通知",
@ -1274,7 +1282,8 @@
"Prompt updated successfully": "提示词更新成功",
"Prompts": "提示词",
"Prompts Access": "访问提示词",
"Prompts Public Sharing": "提示词公开共享",
"Prompts Public Sharing": "提示词公开分享",
"Prompts Sharing": "分享提示词",
"Provider Type": "服务提供商类型",
"Public": "公共",
"Pull \"{{searchValue}}\" from Ollama.com": "从 Ollama.com 下载 “{{searchValue}}”",
@ -1457,6 +1466,7 @@
"Sets the random number seed to use for generation. Setting this to a specific number will make the model generate the same text for the same prompt.": "设置用于内容生成的随机数种子。将其设置为特定数字可使模型针对同一提示词生成相同的文本。",
"Sets the size of the context window used to generate the next token.": "设置用于生成下一个 Token 的上下文窗口的大小。",
"Sets the stop sequences to use. When this pattern is encountered, the LLM will stop generating text and return. Multiple stop patterns may be set by specifying multiple separate stop parameters in a modelfile.": "设置要使用的停止序列。在该模式下,大语言模型将停止生成文本并返回。可以通过在模型文件中指定多个单独的停止参数来设置多个停止模式。",
"Setting": "设置",
"Settings": "设置",
"Settings saved successfully!": "设置已成功保存!",
"Share": "分享",
@ -1636,7 +1646,8 @@
"Tools are a function calling system with arbitrary code execution": "工具是一个支持任意代码执行的函数调用系统",
"Tools Function Calling Prompt": "工具函数调用提示词",
"Tools have a function calling system that allows arbitrary code execution.": "注意:工具有权执行任意代码",
"Tools Public Sharing": "工具公开共享",
"Tools Public Sharing": "工具公开分享",
"Tools Sharing": "分享工具",
"Top K": "Top K",
"Top K Reranker": "Top K Reranker",
"Transformers": "Transformers",
@ -1684,6 +1695,7 @@
"Upload Pipeline": "上传 Pipeline",
"Upload Progress": "上传进度",
"Upload Progress: {{uploadedFiles}}/{{totalFiles}} ({{percentage}}%)": "上传进度:{{uploadedFiles}}/{{totalFiles}} ({{percentage}}%)",
"Uploading file...": "正在上传文件...",
"URL": "URL",
"URL is required": "URL 是必填项。",
"URL Mode": "URL 模式",

View file

@ -95,6 +95,7 @@
"Allow Continue Response": "允許繼續回應",
"Allow Delete Messages": "允許刪除訊息",
"Allow File Upload": "允許上傳檔案",
"Allow Group Sharing": "允許在群組內分享",
"Allow Multiple Models in Chat": "允許在對話中使用多個模型",
"Allow non-local voices": "允許非本機語音",
"Allow Rate Response": "允許為回應評分",
@ -388,6 +389,7 @@
"Default description enabled": "預設描述已啟用",
"Default Features": "預設功能",
"Default Filters": "預設篩選器",
"Default Group": "預設群組",
"Default mode works with a wider range of models by calling tools once before execution. Native mode leverages the model's built-in tool-calling capabilities, but requires the model to inherently support this feature.": "預設模式透過在執行前呼叫工具一次,來與更廣泛的模型相容。原生模式則利用模型內建的工具呼叫能力,但需要模型本身就支援此功能。",
"Default Model": "預設模型",
"Default model updated": "預設模型已更新",
@ -731,6 +733,7 @@
"Features": "功能",
"Features Permissions": "功能權限",
"February": "2 月",
"Feedback deleted successfully": "成功刪除回饋",
"Feedback Details": "回饋詳情",
"Feedback History": "回饋歷史",
"Feedbacks": "回饋",
@ -745,6 +748,7 @@
"File size should not exceed {{maxSize}} MB.": "檔案大小不應超過 {{maxSize}} MB。",
"File Upload": "檔案上傳",
"File uploaded successfully": "成功上傳檔案",
"File uploaded!": "檔案已上傳",
"Files": "檔案",
"Filter": "篩選",
"Filter is now globally disabled": "篩選器已全域停用",
@ -858,6 +862,7 @@
"Image Compression": "圖片壓縮",
"Image Compression Height": "圖片壓縮高度",
"Image Compression Width": "圖片壓縮寬度",
"Image Edit": "圖片編輯",
"Image Edit Engine": "圖片編輯引擎",
"Image Generation": "圖片生成",
"Image Generation Engine": "圖片生成引擎",
@ -933,16 +938,17 @@
"Key is required": "金鑰為必填項目",
"Keyboard shortcuts": "鍵盤快捷鍵",
"Keyboard Shortcuts": "鍵盤快捷鍵",
"Knowledge": "知識",
"Knowledge Access": "知識存取",
"Knowledge": "知識",
"Knowledge Access": "知識存取",
"Knowledge Base": "知識庫",
"Knowledge created successfully.": "成功建立知識。",
"Knowledge deleted successfully.": "成功刪除知識。",
"Knowledge created successfully.": "成功建立知識。",
"Knowledge deleted successfully.": "成功刪除知識。",
"Knowledge Description": "知識庫描述",
"Knowledge Name": "知識庫名稱",
"Knowledge Public Sharing": "知識公開分享",
"Knowledge reset successfully.": "成功重設知識。",
"Knowledge updated successfully": "成功更新知識",
"Knowledge Public Sharing": "知識庫公開分享",
"Knowledge reset successfully.": "成功重設知識庫。",
"Knowledge Sharing": "分享知識庫",
"Knowledge updated successfully": "成功更新知識庫",
"Kokoro.js (Browser)": "Kokoro.js (瀏覽器)",
"Kokoro.js Dtype": "Kokoro.js Dtype",
"Label": "標籤",
@ -1063,6 +1069,7 @@
"Models configuration saved successfully": "成功儲存模型設定",
"Models imported successfully": "已成功匯入模型",
"Models Public Sharing": "模型公開分享",
"Models Sharing": "分享模型",
"Mojeek Search API Key": "Mojeek 搜尋 API 金鑰",
"More": "更多",
"More Concise": "精煉表達",
@ -1129,6 +1136,7 @@
"Note: If you set a minimum score, the search will only return documents with a score greater than or equal to the minimum score.": "注意:如果您設定了最低分數,則搜尋只會回傳分數大於或等於最低分數的檔案。",
"Notes": "筆記",
"Notes Public Sharing": "公開分享筆記",
"Notes Sharing": "分享筆記",
"Notification Sound": "通知聲音",
"Notification Webhook": "通知 Webhook",
"Notifications": "通知",
@ -1275,6 +1283,7 @@
"Prompts": "提示詞",
"Prompts Access": "提示詞存取",
"Prompts Public Sharing": "提示詞公開分享",
"Prompts Sharing": "分享提示詞",
"Provider Type": "服務提供商類型",
"Public": "公開",
"Pull \"{{searchValue}}\" from Ollama.com": "從 Ollama.com 下載「{{searchValue}}」",
@ -1457,6 +1466,7 @@
"Sets the random number seed to use for generation. Setting this to a specific number will make the model generate the same text for the same prompt.": "設定用於生成的隨機數種子。將其設定為特定數字將使模型針對相同的提示生成相同的文字。",
"Sets the size of the context window used to generate the next token.": "設定用於生成下一個 token 的上下文視窗大小。",
"Sets the stop sequences to use. When this pattern is encountered, the LLM will stop generating text and return. Multiple stop patterns may be set by specifying multiple separate stop parameters in a modelfile.": "設定要使用的停止序列。當遇到此模式時,大型語言模型將停止生成文字並返回。可以在模型檔案中指定多個單獨的停止參數來設定多個停止模式。",
"Setting": "設定",
"Settings": "設定",
"Settings saved successfully!": "設定已成功儲存!",
"Share": "分享",
@ -1637,6 +1647,7 @@
"Tools Function Calling Prompt": "工具函式呼叫提示詞",
"Tools have a function calling system that allows arbitrary code execution.": "工具具有允許執行任意程式碼的函式呼叫系統。",
"Tools Public Sharing": "工具公開分享",
"Tools Sharing": "分享工具",
"Top K": "Top K",
"Top K Reranker": "Top K Reranker",
"Transformers": "Transformers",
@ -1684,6 +1695,7 @@
"Upload Pipeline": "上傳管線",
"Upload Progress": "上傳進度",
"Upload Progress: {{uploadedFiles}}/{{totalFiles}} ({{percentage}}%)": "上傳進度:{{uploadedFiles}}/{{totalFiles}} ({{percentage}}%)",
"Uploading file...": "正在上傳檔案...",
"URL": "URL",
"URL is required": "URL 為必填項目",
"URL Mode": "URL 模式",

View file

@ -8,7 +8,10 @@ import emojiShortCodes from '$lib/emoji-shortcodes.json';
// Backend
export const WEBUI_NAME = writable(APP_NAME);
export const WEBUI_VERSION = writable(null);
export const WEBUI_DEPLOYMENT_ID = writable(null);
export const config: Writable<Config | undefined> = writable(undefined);
export const user: Writable<SessionUser | undefined> = writable(undefined);

View file

@ -14,6 +14,7 @@
import Notes from '$lib/components/notes/Notes.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Sidebar from '$lib/components/icons/Sidebar.svelte';
import { WEBUI_API_BASE_URL } from '$lib/constants';
let loaded = false;
@ -95,7 +96,7 @@
>
<div class=" self-center">
<img
src={$user?.profile_image_url}
src={`${WEBUI_API_BASE_URL}/users/${$user?.id}/profile/image`}
class="size-6 object-cover rounded-full"
alt="User profile"
draggable="false"

View file

@ -17,6 +17,7 @@
theme,
WEBUI_NAME,
WEBUI_VERSION,
WEBUI_DEPLOYMENT_ID,
mobile,
socket,
chatId,
@ -46,7 +47,7 @@
import { getAllTags, getChatList } from '$lib/apis/chats';
import { chatCompletion } from '$lib/apis/openai';
import { WEBUI_BASE_URL, WEBUI_HOSTNAME } from '$lib/constants';
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL, WEBUI_HOSTNAME } from '$lib/constants';
import { bestMatchingLanguage } from '$lib/utils';
import { setTextScale } from '$lib/utils/text-scale';
@ -54,10 +55,26 @@
import AppSidebar from '$lib/components/app/AppSidebar.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import { getUserSettings } from '$lib/apis/users';
import dayjs from 'dayjs';
const unregisterServiceWorkers = async () => {
if ('serviceWorker' in navigator) {
try {
const registrations = await navigator.serviceWorker.getRegistrations();
await Promise.all(registrations.map((r) => r.unregister()));
return true;
} catch (error) {
console.error('Error unregistering service workers:', error);
return false;
}
}
return false;
};
// handle frontend updates (https://svelte.dev/docs/kit/configuration#version)
beforeNavigate(({ willUnload, to }) => {
beforeNavigate(async ({ willUnload, to }) => {
if (updated.current && !willUnload && to?.url) {
await unregisterServiceWorkers();
location.href = to.url.href;
}
});
@ -91,15 +108,30 @@
_socket.on('connect', async () => {
console.log('connected', _socket.id);
const version = await getVersion(localStorage.token);
if (version !== null) {
if ($WEBUI_VERSION !== null && version !== $WEBUI_VERSION) {
const res = await getVersion(localStorage.token);
const deploymentId = res?.deployment_id ?? null;
const version = res?.version ?? null;
if (version !== null || deploymentId !== null) {
if (
($WEBUI_VERSION !== null && version !== $WEBUI_VERSION) ||
($WEBUI_DEPLOYMENT_ID !== null && deploymentId !== $WEBUI_DEPLOYMENT_ID)
) {
await unregisterServiceWorkers();
location.href = location.href;
} else {
WEBUI_VERSION.set(version);
return;
}
}
if (deploymentId !== null) {
WEBUI_DEPLOYMENT_ID.set(deploymentId);
}
if (version !== null) {
WEBUI_VERSION.set(version);
}
console.log('version', version);
if (localStorage.getItem('token')) {
@ -457,7 +489,7 @@
if ($settings?.notificationEnabled ?? false) {
new Notification(`${data?.user?.name} (#${event?.channel?.name}) • Open WebUI`, {
body: data?.content,
icon: data?.user?.profile_image_url ?? `${WEBUI_BASE_URL}/static/favicon.png`
icon: `${WEBUI_API_BASE_URL}/users/${data?.user?.id}/profile/image`
});
}
}
@ -637,6 +669,7 @@
? backendConfig.default_locale
: bestMatchingLanguage(languages, browserLanguages, 'en-US');
changeLanguage(lang);
dayjs.locale(lang);
}
if (backendConfig) {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 394 KiB