Merge pull request #17827 from open-webui/dev
Release / release (push) Waiting to run Details
Deploy to HuggingFace Spaces / check-secret (push) Waiting to run Details
Deploy to HuggingFace Spaces / deploy (push) Blocked by required conditions Details
Create and publish Docker images with specific build args / build-main-image (linux/amd64, ubuntu-latest) (push) Waiting to run Details
Create and publish Docker images with specific build args / build-main-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run Details
Create and publish Docker images with specific build args / build-cuda-image (linux/amd64, ubuntu-latest) (push) Waiting to run Details
Create and publish Docker images with specific build args / build-cuda-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run Details
Create and publish Docker images with specific build args / build-cuda126-image (linux/amd64, ubuntu-latest) (push) Waiting to run Details
Create and publish Docker images with specific build args / build-cuda126-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run Details
Create and publish Docker images with specific build args / build-ollama-image (linux/amd64, ubuntu-latest) (push) Waiting to run Details
Create and publish Docker images with specific build args / build-ollama-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run Details
Create and publish Docker images with specific build args / build-slim-image (linux/amd64, ubuntu-latest) (push) Waiting to run Details
Create and publish Docker images with specific build args / build-slim-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run Details
Create and publish Docker images with specific build args / merge-main-images (push) Blocked by required conditions Details
Create and publish Docker images with specific build args / merge-cuda-images (push) Blocked by required conditions Details
Create and publish Docker images with specific build args / merge-cuda126-images (push) Blocked by required conditions Details
Create and publish Docker images with specific build args / merge-ollama-images (push) Blocked by required conditions Details
Create and publish Docker images with specific build args / merge-slim-images (push) Blocked by required conditions Details
Python CI / Format Backend (3.11.x) (push) Waiting to run Details
Python CI / Format Backend (3.12.x) (push) Waiting to run Details
Frontend Build / Format & Build Frontend (push) Waiting to run Details
Frontend Build / Frontend Unit Tests (push) Waiting to run Details
Release to PyPI / release (push) Waiting to run Details

0.6.32
This commit is contained in:
Tim Jaeryang Baek 2025-09-29 01:13:00 -05:00 committed by GitHub
commit 37d1c85c99
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
160 changed files with 5592 additions and 1467 deletions

View File

@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.6.32] - 2025-09-29
### Added
- 🗝️ Permission toggle for public sharing of notes was added, allowing note owners to quickly enable or disable public access from the note settings interface.
- ⚠️ A warning is now displayed in the user edit modal if conflicting group permissions are detected, helping administrators resolve access control ambiguities before saving changes.
### Fixed
- 🧰 Fixed regression where External Tool servers (OpenAPI) were nonfunctional after the 0.6.31 update; external tools integration is now restored and reliable.
- 🚑 Resolved a critical bug causing Streamable HTTP OAuth 2.1 (MCP server) integrations to throw a 500 error on first invocation due to missing 'SessionMiddleware'. OAuth 2.1 registration now succeeds and works on subsequent requests as expected.
- 🐛 The "Set as default" option is now reliably clickable in model and filter selection menus, fixing cases where the interface appeared unresponsive.
- 🛠️ Embed UI now works seamlessly with both default and native function calling flows, ensuring the tool embedding experience is consistent regardless of invocation method.
- 🧹 Addressed various minor UI bugs and inconsistencies for a cleaner user experience.
### Changed
- 🧬 MCP tool result handling code was refactored for improved parsing and robustness of tool outputs.
- 🧩 The user edit modal was overhauled for clarity and usability, improving the organization of group, permission, and public sharing controls.
## [0.6.31] - 2025-09-25
### Added

View File

@ -1217,6 +1217,11 @@ 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()
== "true"
)
USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_PUBLIC_SHARING = (
os.environ.get(
"USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_PUBLIC_SHARING", "False"
@ -1354,6 +1359,7 @@ DEFAULT_USER_PERMISSIONS = {
"public_knowledge": USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_PUBLIC_SHARING,
"public_prompts": USER_PERMISSIONS_WORKSPACE_PROMPTS_ALLOW_PUBLIC_SHARING,
"public_tools": USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_PUBLIC_SHARING,
"public_notes": USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING,
},
"chat": {
"controls": USER_PERMISSIONS_CHAT_CONTROLS,
@ -1999,16 +2005,23 @@ if VECTOR_DB == "chroma":
# this uses the model defined in the Dockerfile ENV variable. If you dont use docker or docker based deployments such as k8s, the default embedding model will be used (sentence-transformers/all-MiniLM-L6-v2)
# Milvus
MILVUS_URI = os.environ.get("MILVUS_URI", f"{DATA_DIR}/vector_db/milvus.db")
MILVUS_DB = os.environ.get("MILVUS_DB", "default")
MILVUS_TOKEN = os.environ.get("MILVUS_TOKEN", None)
MILVUS_INDEX_TYPE = os.environ.get("MILVUS_INDEX_TYPE", "HNSW")
MILVUS_METRIC_TYPE = os.environ.get("MILVUS_METRIC_TYPE", "COSINE")
MILVUS_HNSW_M = int(os.environ.get("MILVUS_HNSW_M", "16"))
MILVUS_HNSW_EFCONSTRUCTION = int(os.environ.get("MILVUS_HNSW_EFCONSTRUCTION", "100"))
MILVUS_IVF_FLAT_NLIST = int(os.environ.get("MILVUS_IVF_FLAT_NLIST", "128"))
MILVUS_DISKANN_MAX_DEGREE = int(os.environ.get("MILVUS_DISKANN_MAX_DEGREE", "56"))
MILVUS_DISKANN_SEARCH_LIST_SIZE = int(
os.environ.get("MILVUS_DISKANN_SEARCH_LIST_SIZE", "100")
)
ENABLE_MILVUS_MULTITENANCY_MODE = (
os.environ.get("ENABLE_MILVUS_MULTITENANCY_MODE", "true").lower() == "true"
)
# Hyphens not allowed, need to use underscores in collection names
MILVUS_COLLECTION_PREFIX = os.environ.get("MILVUS_COLLECTION_PREFIX", "open_webui")
# Qdrant
QDRANT_URI = os.environ.get("QDRANT_URI", None)

View File

@ -86,6 +86,10 @@ async def get_function_models(request):
try:
function_module = get_function_module_by_id(request, pipe.id)
has_user_valves = False
if hasattr(function_module, "UserValves"):
has_user_valves = True
# Check if function is a manifold
if hasattr(function_module, "pipes"):
sub_pipes = []
@ -124,6 +128,7 @@ async def get_function_models(request):
"created": pipe.created_at,
"owned_by": "openai",
"pipe": pipe_flag,
"has_user_valves": has_user_valves,
}
)
else:
@ -141,6 +146,7 @@ async def get_function_models(request):
"created": pipe.created_at,
"owned_by": "openai",
"pipe": pipe_flag,
"has_user_valves": has_user_valves,
}
)
except Exception as e:

View File

@ -1552,7 +1552,7 @@ async def chat_completion(
finally:
try:
if mcp_clients := metadata.get("mcp_clients"):
for client in mcp_clients:
for client in mcp_clients.values():
await client.disconnect()
except Exception as e:
log.debug(f"Error cleaning up: {e}")
@ -1908,23 +1908,18 @@ if len(app.state.config.TOOL_SERVER_CONNECTIONS) > 0:
f"mcp:{server_id}", OAuthClientInformationFull(**oauth_client_info)
)
# SessionMiddleware is used by authlib for oauth
if len(OAUTH_PROVIDERS) > 0:
try:
if REDIS_URL:
redis_session_store = RedisStore(
url=REDIS_URL,
prefix=(
f"{REDIS_KEY_PREFIX}:session:" if REDIS_KEY_PREFIX else "session:"
),
prefix=(f"{REDIS_KEY_PREFIX}:session:" if REDIS_KEY_PREFIX else "session:"),
)
app.add_middleware(SessionAutoloadMiddleware)
app.add_middleware(
StarSessionsMiddleware,
store=redis_session_store,
cookie_name="oui-session",
cookie_name="owui-session",
cookie_same_site=WEBUI_SESSION_COOKIE_SAME_SITE,
cookie_https_only=WEBUI_SESSION_COOKIE_SECURE,
)
@ -1935,7 +1930,7 @@ if len(OAUTH_PROVIDERS) > 0:
app.add_middleware(
SessionMiddleware,
secret_key=WEBUI_SECRET_KEY,
session_cookie="oui-session",
session_cookie="owui-session",
same_site=WEBUI_SESSION_COOKIE_SAME_SITE,
https_only=WEBUI_SESSION_COOKIE_SECURE,
)

View File

@ -0,0 +1,34 @@
"""Add reply_to_id column to message
Revision ID: a5c220713937
Revises: 38d63c18f30f
Create Date: 2025-09-27 02:24:18.058455
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "a5c220713937"
down_revision: Union[str, None] = "38d63c18f30f"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add 'reply_to_id' column to the 'message' table for replying to messages
op.add_column(
"message",
sa.Column("reply_to_id", sa.Text(), nullable=True),
)
pass
def downgrade() -> None:
# Remove 'reply_to_id' column from the 'message' table
op.drop_column("message", "reply_to_id")
pass

View File

@ -366,6 +366,15 @@ class ChatTable:
except Exception:
return False
def unarchive_all_chats_by_user_id(self, user_id: str) -> bool:
try:
with get_db() as db:
db.query(Chat).filter_by(user_id=user_id).update({"archived": False})
db.commit()
return True
except Exception:
return False
def update_chat_share_id_by_id(
self, id: str, share_id: Optional[str]
) -> Optional[ChatModel]:
@ -810,7 +819,7 @@ class ChatTable:
return [ChatModel.model_validate(chat) for chat in all_chats]
def get_chats_by_folder_id_and_user_id(
self, folder_id: str, user_id: str
self, folder_id: str, user_id: str, skip: int = 0, limit: int = 60
) -> list[ChatModel]:
with get_db() as db:
query = db.query(Chat).filter_by(folder_id=folder_id, user_id=user_id)
@ -819,6 +828,11 @@ class ChatTable:
query = query.order_by(Chat.updated_at.desc())
if skip:
query = query.offset(skip)
if limit:
query = query.limit(limit)
all_chats = query.all()
return [ChatModel.model_validate(chat) for chat in all_chats]

View File

@ -50,6 +50,20 @@ class FolderModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
class FolderMetadataResponse(BaseModel):
icon: Optional[str] = None
class FolderNameIdResponse(BaseModel):
id: str
name: str
meta: Optional[FolderMetadataResponse] = None
parent_id: Optional[str] = None
is_expanded: bool = False
created_at: int
updated_at: int
####################
# Forms
####################

View File

@ -5,6 +5,7 @@ from typing import Optional
from open_webui.internal.db import Base, get_db
from open_webui.models.tags import TagModel, Tag, Tags
from open_webui.models.users import Users, UserNameResponse
from pydantic import BaseModel, ConfigDict
@ -43,6 +44,7 @@ class Message(Base):
user_id = Column(Text)
channel_id = Column(Text, nullable=True)
reply_to_id = Column(Text, nullable=True)
parent_id = Column(Text, nullable=True)
content = Column(Text)
@ -60,6 +62,7 @@ class MessageModel(BaseModel):
user_id: str
channel_id: Optional[str] = None
reply_to_id: Optional[str] = None
parent_id: Optional[str] = None
content: str
@ -77,6 +80,7 @@ class MessageModel(BaseModel):
class MessageForm(BaseModel):
content: str
reply_to_id: Optional[str] = None
parent_id: Optional[str] = None
data: Optional[dict] = None
meta: Optional[dict] = None
@ -88,7 +92,15 @@ class Reactions(BaseModel):
count: int
class MessageResponse(MessageModel):
class MessageUserResponse(MessageModel):
user: Optional[UserNameResponse] = None
class MessageReplyToResponse(MessageUserResponse):
reply_to_message: Optional[MessageUserResponse] = None
class MessageResponse(MessageReplyToResponse):
latest_reply_at: Optional[int]
reply_count: int
reactions: list[Reactions]
@ -107,6 +119,7 @@ class MessageTable:
"id": id,
"user_id": user_id,
"channel_id": channel_id,
"reply_to_id": form_data.reply_to_id,
"parent_id": form_data.parent_id,
"content": form_data.content,
"data": form_data.data,
@ -128,19 +141,32 @@ class MessageTable:
if not message:
return None
reactions = self.get_reactions_by_message_id(id)
replies = self.get_replies_by_message_id(id)
reply_to_message = (
self.get_message_by_id(message.reply_to_id)
if message.reply_to_id
else None
)
return MessageResponse(
**{
reactions = self.get_reactions_by_message_id(id)
thread_replies = self.get_thread_replies_by_message_id(id)
user = Users.get_user_by_id(message.user_id)
return MessageResponse.model_validate(
{
**MessageModel.model_validate(message).model_dump(),
"latest_reply_at": replies[0].created_at if replies else None,
"reply_count": len(replies),
"user": user.model_dump() if user else None,
"reply_to_message": (
reply_to_message.model_dump() if reply_to_message else None
),
"latest_reply_at": (
thread_replies[0].created_at if thread_replies else None
),
"reply_count": len(thread_replies),
"reactions": reactions,
}
)
def get_replies_by_message_id(self, id: str) -> list[MessageModel]:
def get_thread_replies_by_message_id(self, id: str) -> list[MessageReplyToResponse]:
with get_db() as db:
all_messages = (
db.query(Message)
@ -148,7 +174,27 @@ class MessageTable:
.order_by(Message.created_at.desc())
.all()
)
return [MessageModel.model_validate(message) for message in all_messages]
messages = []
for message in all_messages:
reply_to_message = (
self.get_message_by_id(message.reply_to_id)
if message.reply_to_id
else None
)
messages.append(
MessageReplyToResponse.model_validate(
{
**MessageModel.model_validate(message).model_dump(),
"reply_to_message": (
reply_to_message.model_dump()
if reply_to_message
else None
),
}
)
)
return messages
def get_reply_user_ids_by_message_id(self, id: str) -> list[str]:
with get_db() as db:
@ -159,7 +205,7 @@ class MessageTable:
def get_messages_by_channel_id(
self, channel_id: str, skip: int = 0, limit: int = 50
) -> list[MessageModel]:
) -> list[MessageReplyToResponse]:
with get_db() as db:
all_messages = (
db.query(Message)
@ -169,11 +215,31 @@ class MessageTable:
.limit(limit)
.all()
)
return [MessageModel.model_validate(message) for message in all_messages]
messages = []
for message in all_messages:
reply_to_message = (
self.get_message_by_id(message.reply_to_id)
if message.reply_to_id
else None
)
messages.append(
MessageReplyToResponse.model_validate(
{
**MessageModel.model_validate(message).model_dump(),
"reply_to_message": (
reply_to_message.model_dump()
if reply_to_message
else None
),
}
)
)
return messages
def get_messages_by_parent_id(
self, channel_id: str, parent_id: str, skip: int = 0, limit: int = 50
) -> list[MessageModel]:
) -> list[MessageReplyToResponse]:
with get_db() as db:
message = db.get(Message, parent_id)
@ -193,7 +259,26 @@ class MessageTable:
if len(all_messages) < limit:
all_messages.append(message)
return [MessageModel.model_validate(message) for message in all_messages]
messages = []
for message in all_messages:
reply_to_message = (
self.get_message_by_id(message.reply_to_id)
if message.reply_to_id
else None
)
messages.append(
MessageReplyToResponse.model_validate(
{
**MessageModel.model_validate(message).model_dump(),
"reply_to_message": (
reply_to_message.model_dump()
if reply_to_message
else None
),
}
)
)
return messages
def update_message_by_id(
self, id: str, form_data: MessageForm

View File

@ -11,7 +11,7 @@ from open_webui.retrieval.vector.main import (
SearchResult,
GetResult,
)
from open_webui.retrieval.vector.utils import stringify_metadata
from open_webui.retrieval.vector.utils import process_metadata
from open_webui.config import (
CHROMA_DATA_PATH,
@ -146,7 +146,7 @@ class ChromaClient(VectorDBBase):
ids = [item["id"] for item in items]
documents = [item["text"] for item in items]
embeddings = [item["vector"] for item in items]
metadatas = [stringify_metadata(item["metadata"]) for item in items]
metadatas = [process_metadata(item["metadata"]) for item in items]
for batch in create_batches(
api=self.client,
@ -166,7 +166,7 @@ class ChromaClient(VectorDBBase):
ids = [item["id"] for item in items]
documents = [item["text"] for item in items]
embeddings = [item["vector"] for item in items]
metadatas = [stringify_metadata(item["metadata"]) for item in items]
metadatas = [process_metadata(item["metadata"]) for item in items]
collection.upsert(
ids=ids, documents=documents, embeddings=embeddings, metadatas=metadatas

View File

@ -3,7 +3,7 @@ from typing import Optional
import ssl
from elasticsearch.helpers import bulk, scan
from open_webui.retrieval.vector.utils import stringify_metadata
from open_webui.retrieval.vector.utils import process_metadata
from open_webui.retrieval.vector.main import (
VectorDBBase,
VectorItem,
@ -245,7 +245,7 @@ class ElasticsearchClient(VectorDBBase):
"collection": collection_name,
"vector": item["vector"],
"text": item["text"],
"metadata": stringify_metadata(item["metadata"]),
"metadata": process_metadata(item["metadata"]),
},
}
for item in batch
@ -266,7 +266,7 @@ class ElasticsearchClient(VectorDBBase):
"collection": collection_name,
"vector": item["vector"],
"text": item["text"],
"metadata": stringify_metadata(item["metadata"]),
"metadata": process_metadata(item["metadata"]),
},
"doc_as_upsert": True,
}

View File

@ -6,7 +6,7 @@ import json
import logging
from typing import Optional
from open_webui.retrieval.vector.utils import stringify_metadata
from open_webui.retrieval.vector.utils import process_metadata
from open_webui.retrieval.vector.main import (
VectorDBBase,
VectorItem,
@ -22,6 +22,8 @@ from open_webui.config import (
MILVUS_HNSW_M,
MILVUS_HNSW_EFCONSTRUCTION,
MILVUS_IVF_FLAT_NLIST,
MILVUS_DISKANN_MAX_DEGREE,
MILVUS_DISKANN_SEARCH_LIST_SIZE,
)
from open_webui.env import SRC_LOG_LEVELS
@ -131,12 +133,18 @@ class MilvusClient(VectorDBBase):
elif index_type == "IVF_FLAT":
index_creation_params = {"nlist": MILVUS_IVF_FLAT_NLIST}
log.info(f"IVF_FLAT params: {index_creation_params}")
elif index_type == "DISKANN":
index_creation_params = {
"max_degree": MILVUS_DISKANN_MAX_DEGREE,
"search_list_size": MILVUS_DISKANN_SEARCH_LIST_SIZE,
}
log.info(f"DISKANN params: {index_creation_params}")
elif index_type in ["FLAT", "AUTOINDEX"]:
log.info(f"Using {index_type} index with no specific build-time params.")
else:
log.warning(
f"Unsupported MILVUS_INDEX_TYPE: '{index_type}'. "
f"Supported types: HNSW, IVF_FLAT, FLAT, AUTOINDEX. "
f"Supported types: HNSW, IVF_FLAT, DISKANN, FLAT, AUTOINDEX. "
f"Milvus will use its default for the collection if this type is not directly supported for index creation."
)
# For unsupported types, pass the type directly to Milvus; it might handle it or use a default.
@ -189,7 +197,7 @@ class MilvusClient(VectorDBBase):
)
return self._result_to_search_result(result)
def query(self, collection_name: str, filter: dict, limit: Optional[int] = None):
def query(self, collection_name: str, filter: dict, limit: int = -1):
connections.connect(uri=MILVUS_URI, token=MILVUS_TOKEN, db_name=MILVUS_DB)
# Construct the filter string for querying
@ -222,7 +230,7 @@ class MilvusClient(VectorDBBase):
"data",
"metadata",
],
limit=limit, # Pass the limit directly; None means no limit.
limit=limit, # Pass the limit directly; -1 means no limit.
)
while True:
@ -249,7 +257,7 @@ class MilvusClient(VectorDBBase):
)
# Using query with a trivial filter to get all items.
# This will use the paginated query logic.
return self.query(collection_name=collection_name, filter={}, limit=None)
return self.query(collection_name=collection_name, filter={}, limit=-1)
def insert(self, collection_name: str, items: list[VectorItem]):
# Insert the items into the collection, if the collection does not exist, it will be created.
@ -281,7 +289,7 @@ class MilvusClient(VectorDBBase):
"id": item["id"],
"vector": item["vector"],
"data": {"text": item["text"]},
"metadata": stringify_metadata(item["metadata"]),
"metadata": process_metadata(item["metadata"]),
}
for item in items
],
@ -317,7 +325,7 @@ class MilvusClient(VectorDBBase):
"id": item["id"],
"vector": item["vector"],
"data": {"text": item["text"]},
"metadata": stringify_metadata(item["metadata"]),
"metadata": process_metadata(item["metadata"]),
}
for item in items
],

View File

@ -0,0 +1,282 @@
import logging
from typing import Optional, Tuple, List, Dict, Any
from open_webui.config import (
MILVUS_URI,
MILVUS_TOKEN,
MILVUS_DB,
MILVUS_COLLECTION_PREFIX,
MILVUS_INDEX_TYPE,
MILVUS_METRIC_TYPE,
MILVUS_HNSW_M,
MILVUS_HNSW_EFCONSTRUCTION,
MILVUS_IVF_FLAT_NLIST,
)
from open_webui.env import SRC_LOG_LEVELS
from open_webui.retrieval.vector.main import (
GetResult,
SearchResult,
VectorDBBase,
VectorItem,
)
from pymilvus import (
connections,
utility,
Collection,
CollectionSchema,
FieldSchema,
DataType,
)
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
RESOURCE_ID_FIELD = "resource_id"
class MilvusClient(VectorDBBase):
def __init__(self):
# Milvus collection names can only contain numbers, letters, and underscores.
self.collection_prefix = MILVUS_COLLECTION_PREFIX.replace("-", "_")
connections.connect(
alias="default",
uri=MILVUS_URI,
token=MILVUS_TOKEN,
db_name=MILVUS_DB,
)
# Main collection types for multi-tenancy
self.MEMORY_COLLECTION = f"{self.collection_prefix}_memories"
self.KNOWLEDGE_COLLECTION = f"{self.collection_prefix}_knowledge"
self.FILE_COLLECTION = f"{self.collection_prefix}_files"
self.WEB_SEARCH_COLLECTION = f"{self.collection_prefix}_web_search"
self.HASH_BASED_COLLECTION = f"{self.collection_prefix}_hash_based"
self.shared_collections = [
self.MEMORY_COLLECTION,
self.KNOWLEDGE_COLLECTION,
self.FILE_COLLECTION,
self.WEB_SEARCH_COLLECTION,
self.HASH_BASED_COLLECTION,
]
def _get_collection_and_resource_id(self, collection_name: str) -> Tuple[str, str]:
"""
Maps the traditional collection name to multi-tenant collection and resource ID.
WARNING: This mapping relies on current Open WebUI naming conventions for
collection names. If Open WebUI changes how it generates collection names
(e.g., "user-memory-" prefix, "file-" prefix, web search patterns, or hash
formats), this mapping will break and route data to incorrect collections.
POTENTIALLY CAUSING HUGE DATA CORRUPTION, DATA CONSISTENCY ISSUES AND INCORRECT
DATA MAPPING INSIDE THE DATABASE.
"""
resource_id = collection_name
if collection_name.startswith("user-memory-"):
return self.MEMORY_COLLECTION, resource_id
elif collection_name.startswith("file-"):
return self.FILE_COLLECTION, resource_id
elif collection_name.startswith("web-search-"):
return self.WEB_SEARCH_COLLECTION, resource_id
elif len(collection_name) == 63 and all(
c in "0123456789abcdef" for c in collection_name
):
return self.HASH_BASED_COLLECTION, resource_id
else:
return self.KNOWLEDGE_COLLECTION, resource_id
def _create_shared_collection(self, mt_collection_name: str, dimension: int):
fields = [
FieldSchema(
name="id",
dtype=DataType.VARCHAR,
is_primary=True,
auto_id=False,
max_length=36,
),
FieldSchema(name="vector", dtype=DataType.FLOAT_VECTOR, dim=dimension),
FieldSchema(name="text", dtype=DataType.VARCHAR, max_length=65535),
FieldSchema(name="metadata", dtype=DataType.JSON),
FieldSchema(name=RESOURCE_ID_FIELD, dtype=DataType.VARCHAR, max_length=255),
]
schema = CollectionSchema(fields, "Shared collection for multi-tenancy")
collection = Collection(mt_collection_name, schema)
index_params = {
"metric_type": MILVUS_METRIC_TYPE,
"index_type": MILVUS_INDEX_TYPE,
"params": {},
}
if MILVUS_INDEX_TYPE == "HNSW":
index_params["params"] = {
"M": MILVUS_HNSW_M,
"efConstruction": MILVUS_HNSW_EFCONSTRUCTION,
}
elif MILVUS_INDEX_TYPE == "IVF_FLAT":
index_params["params"] = {"nlist": MILVUS_IVF_FLAT_NLIST}
collection.create_index("vector", index_params)
collection.create_index(RESOURCE_ID_FIELD)
log.info(f"Created shared collection: {mt_collection_name}")
return collection
def _ensure_collection(self, mt_collection_name: str, dimension: int):
if not utility.has_collection(mt_collection_name):
self._create_shared_collection(mt_collection_name, dimension)
def has_collection(self, collection_name: str) -> bool:
mt_collection, resource_id = self._get_collection_and_resource_id(
collection_name
)
if not utility.has_collection(mt_collection):
return False
collection = Collection(mt_collection)
collection.load()
res = collection.query(expr=f"{RESOURCE_ID_FIELD} == '{resource_id}'", limit=1)
return len(res) > 0
def upsert(self, collection_name: str, items: List[VectorItem]):
if not items:
return
mt_collection, resource_id = self._get_collection_and_resource_id(
collection_name
)
dimension = len(items[0]["vector"])
self._ensure_collection(mt_collection, dimension)
collection = Collection(mt_collection)
entities = [
{
"id": item["id"],
"vector": item["vector"],
"text": item["text"],
"metadata": item["metadata"],
RESOURCE_ID_FIELD: resource_id,
}
for item in items
]
collection.insert(entities)
collection.flush()
def search(
self, collection_name: str, vectors: List[List[float]], limit: int
) -> Optional[SearchResult]:
if not vectors:
return None
mt_collection, resource_id = self._get_collection_and_resource_id(
collection_name
)
if not utility.has_collection(mt_collection):
return None
collection = Collection(mt_collection)
collection.load()
search_params = {"metric_type": MILVUS_METRIC_TYPE, "params": {}}
results = collection.search(
data=vectors,
anns_field="vector",
param=search_params,
limit=limit,
expr=f"{RESOURCE_ID_FIELD} == '{resource_id}'",
output_fields=["id", "text", "metadata"],
)
ids, documents, metadatas, distances = [], [], [], []
for hits in results:
batch_ids, batch_docs, batch_metadatas, batch_dists = [], [], [], []
for hit in hits:
batch_ids.append(hit.entity.get("id"))
batch_docs.append(hit.entity.get("text"))
batch_metadatas.append(hit.entity.get("metadata"))
batch_dists.append(hit.distance)
ids.append(batch_ids)
documents.append(batch_docs)
metadatas.append(batch_metadatas)
distances.append(batch_dists)
return SearchResult(
ids=ids, documents=documents, metadatas=metadatas, distances=distances
)
def delete(
self,
collection_name: str,
ids: Optional[List[str]] = None,
filter: Optional[Dict[str, Any]] = None,
):
mt_collection, resource_id = self._get_collection_and_resource_id(
collection_name
)
if not utility.has_collection(mt_collection):
return
collection = Collection(mt_collection)
# Build expression
expr = [f"{RESOURCE_ID_FIELD} == '{resource_id}'"]
if ids:
# Milvus expects a string list for 'in' operator
id_list_str = ", ".join([f"'{id_val}'" for id_val in ids])
expr.append(f"id in [{id_list_str}]")
if filter:
for key, value in filter.items():
expr.append(f"metadata['{key}'] == '{value}'")
collection.delete(" and ".join(expr))
def reset(self):
for collection_name in self.shared_collections:
if utility.has_collection(collection_name):
utility.drop_collection(collection_name)
def delete_collection(self, collection_name: str):
mt_collection, resource_id = self._get_collection_and_resource_id(
collection_name
)
if not utility.has_collection(mt_collection):
return
collection = Collection(mt_collection)
collection.delete(f"{RESOURCE_ID_FIELD} == '{resource_id}'")
def query(
self, collection_name: str, filter: Dict[str, Any], limit: Optional[int] = None
) -> Optional[GetResult]:
mt_collection, resource_id = self._get_collection_and_resource_id(
collection_name
)
if not utility.has_collection(mt_collection):
return None
collection = Collection(mt_collection)
collection.load()
expr = [f"{RESOURCE_ID_FIELD} == '{resource_id}'"]
if filter:
for key, value in filter.items():
if isinstance(value, str):
expr.append(f"metadata['{key}'] == '{value}'")
else:
expr.append(f"metadata['{key}'] == {value}")
results = collection.query(
expr=" and ".join(expr),
output_fields=["id", "text", "metadata"],
limit=limit,
)
ids = [res["id"] for res in results]
documents = [res["text"] for res in results]
metadatas = [res["metadata"] for res in results]
return GetResult(ids=[ids], documents=[documents], metadatas=[metadatas])
def get(self, collection_name: str) -> Optional[GetResult]:
return self.query(collection_name, filter={}, limit=None)
def insert(self, collection_name: str, items: List[VectorItem]):
return self.upsert(collection_name, items)

View File

@ -2,7 +2,7 @@ from opensearchpy import OpenSearch
from opensearchpy.helpers import bulk
from typing import Optional
from open_webui.retrieval.vector.utils import stringify_metadata
from open_webui.retrieval.vector.utils import process_metadata
from open_webui.retrieval.vector.main import (
VectorDBBase,
VectorItem,
@ -201,7 +201,7 @@ class OpenSearchClient(VectorDBBase):
"_source": {
"vector": item["vector"],
"text": item["text"],
"metadata": stringify_metadata(item["metadata"]),
"metadata": process_metadata(item["metadata"]),
},
}
for item in batch
@ -223,7 +223,7 @@ class OpenSearchClient(VectorDBBase):
"doc": {
"vector": item["vector"],
"text": item["text"],
"metadata": stringify_metadata(item["metadata"]),
"metadata": process_metadata(item["metadata"]),
},
"doc_as_upsert": True,
}

View File

@ -27,7 +27,7 @@ from sqlalchemy.ext.mutable import MutableDict
from sqlalchemy.exc import NoSuchTableError
from open_webui.retrieval.vector.utils import stringify_metadata
from open_webui.retrieval.vector.utils import process_metadata
from open_webui.retrieval.vector.main import (
VectorDBBase,
VectorItem,
@ -265,7 +265,7 @@ class PgvectorClient(VectorDBBase):
vector=vector,
collection_name=collection_name,
text=item["text"],
vmetadata=stringify_metadata(item["metadata"]),
vmetadata=process_metadata(item["metadata"]),
)
new_items.append(new_chunk)
self.session.bulk_save_objects(new_items)
@ -323,7 +323,7 @@ class PgvectorClient(VectorDBBase):
if existing:
existing.vector = vector
existing.text = item["text"]
existing.vmetadata = stringify_metadata(item["metadata"])
existing.vmetadata = process_metadata(item["metadata"])
existing.collection_name = (
collection_name # Update collection_name if necessary
)
@ -333,7 +333,7 @@ class PgvectorClient(VectorDBBase):
vector=vector,
collection_name=collection_name,
text=item["text"],
vmetadata=stringify_metadata(item["metadata"]),
vmetadata=process_metadata(item["metadata"]),
)
self.session.add(new_chunk)
self.session.commit()

View File

@ -32,7 +32,7 @@ from open_webui.config import (
PINECONE_CLOUD,
)
from open_webui.env import SRC_LOG_LEVELS
from open_webui.retrieval.vector.utils import stringify_metadata
from open_webui.retrieval.vector.utils import process_metadata
NO_LIMIT = 10000 # Reasonable limit to avoid overwhelming the system
@ -185,7 +185,7 @@ class PineconeClient(VectorDBBase):
point = {
"id": item["id"],
"values": item["vector"],
"metadata": stringify_metadata(metadata),
"metadata": process_metadata(metadata),
}
points.append(point)
return points

View File

@ -105,6 +105,13 @@ class QdrantClient(VectorDBBase):
Returns:
tuple: (collection_name, tenant_id)
WARNING: This mapping relies on current Open WebUI naming conventions for
collection names. If Open WebUI changes how it generates collection names
(e.g., "user-memory-" prefix, "file-" prefix, web search patterns, or hash
formats), this mapping will break and route data to incorrect collections.
POTENTIALLY CAUSING HUGE DATA CORRUPTION, DATA CONSISTENCY ISSUES AND INCORRECT
DATA MAPPING INSIDE THE DATABASE.
"""
# Check for user memory collections
tenant_id = collection_name

View File

@ -1,4 +1,4 @@
from open_webui.retrieval.vector.utils import stringify_metadata
from open_webui.retrieval.vector.utils import process_metadata
from open_webui.retrieval.vector.main import (
VectorDBBase,
VectorItem,
@ -185,7 +185,7 @@ class S3VectorClient(VectorDBBase):
metadata["text"] = item["text"]
# Convert metadata to string format for consistency
metadata = stringify_metadata(metadata)
metadata = process_metadata(metadata)
# Filter metadata to comply with S3 Vector API limit of 10 keys
metadata = self._filter_metadata(metadata, item["id"])
@ -256,7 +256,7 @@ class S3VectorClient(VectorDBBase):
metadata["text"] = item["text"]
# Convert metadata to string format for consistency
metadata = stringify_metadata(metadata)
metadata = process_metadata(metadata)
# Filter metadata to comply with S3 Vector API limit of 10 keys
metadata = self._filter_metadata(metadata, item["id"])

View File

@ -1,6 +1,10 @@
from open_webui.retrieval.vector.main import VectorDBBase
from open_webui.retrieval.vector.type import VectorType
from open_webui.config import VECTOR_DB, ENABLE_QDRANT_MULTITENANCY_MODE
from open_webui.config import (
VECTOR_DB,
ENABLE_QDRANT_MULTITENANCY_MODE,
ENABLE_MILVUS_MULTITENANCY_MODE,
)
class Vector:
@ -12,6 +16,13 @@ class Vector:
"""
match vector_type:
case VectorType.MILVUS:
if ENABLE_MILVUS_MULTITENANCY_MODE:
from open_webui.retrieval.vector.dbs.milvus_multitenancy import (
MilvusClient,
)
return MilvusClient()
else:
from open_webui.retrieval.vector.dbs.milvus import MilvusClient
return MilvusClient()

View File

@ -1,10 +1,24 @@
from datetime import datetime
KEYS_TO_EXCLUDE = ["content", "pages", "tables", "paragraphs", "sections", "figures"]
def stringify_metadata(
def filter_metadata(metadata: dict[str, any]) -> dict[str, any]:
metadata = {
key: value for key, value in metadata.items() if key not in KEYS_TO_EXCLUDE
}
return metadata
def process_metadata(
metadata: dict[str, any],
) -> dict[str, any]:
for key, value in metadata.items():
# Remove large fields
if key in KEYS_TO_EXCLUDE:
del metadata[key]
# Convert non-serializable fields to strings
if (
isinstance(value, datetime)
or isinstance(value, list)

View File

@ -167,7 +167,7 @@ async def delete_channel_by_id(id: str, user=Depends(get_admin_user)):
class MessageUserResponse(MessageResponse):
user: UserNameResponse
pass
@router.get("/{id}/messages", response_model=list[MessageUserResponse])
@ -196,15 +196,17 @@ async def get_channel_messages(
user = Users.get_user_by_id(message.user_id)
users[message.user_id] = user
replies = Messages.get_replies_by_message_id(message.id)
latest_reply_at = replies[0].created_at if replies else None
thread_replies = Messages.get_thread_replies_by_message_id(message.id)
latest_thread_reply_at = (
thread_replies[0].created_at if thread_replies else None
)
messages.append(
MessageUserResponse(
**{
**message.model_dump(),
"reply_count": len(replies),
"latest_reply_at": latest_reply_at,
"reply_count": len(thread_replies),
"latest_reply_at": latest_thread_reply_at,
"reactions": Messages.get_reactions_by_message_id(message.id),
"user": UserNameResponse(**users[message.user_id].model_dump()),
}
@ -253,12 +255,26 @@ async def model_response_handler(request, channel, message, user):
mentions = extract_mentions(message.content)
message_content = replace_mentions(message.content)
model_mentions = {}
# check if the message is a reply to a message sent by a model
if (
message.reply_to_message
and message.reply_to_message.meta
and message.reply_to_message.meta.get("model_id", None)
):
model_id = message.reply_to_message.meta.get("model_id", None)
model_mentions[model_id] = {"id": model_id, "id_type": "M"}
# check if any of the mentions are models
model_mentions = [mention for mention in mentions if mention["id_type"] == "M"]
for mention in mentions:
if mention["id_type"] == "M" and mention["id"] not in model_mentions:
model_mentions[mention["id"]] = mention
if not model_mentions:
return False
for mention in model_mentions:
for mention in model_mentions.values():
model_id = mention["id"]
model = MODELS.get(model_id, None)
@ -326,9 +342,9 @@ async def model_response_handler(request, channel, message, user):
system_message = {
"role": "system",
"content": f"You are {model.get('name', model_id)}, an AI assistant participating in a threaded conversation. Be helpful, concise, and conversational."
"content": f"You are {model.get('name', model_id)}, participating in a threaded conversation. Be concise and conversational."
+ (
f"Here's the thread history:\n\n{''.join([f'{msg}' for msg in thread_history])}\n\nContinue the conversation naturally, addressing the most recent message while being aware of the full context."
f"Here's the thread history:\n\n{''.join([f'{msg}' for msg in thread_history])}\n\nContinue the conversation naturally as {model.get('name', model_id)}, addressing the most recent message while being aware of the full context."
if thread_history
else ""
),
@ -406,24 +422,14 @@ async def new_message_handler(
try:
message = Messages.insert_new_message(form_data, channel.id, user.id)
if message:
message = Messages.get_message_by_id(message.id)
event_data = {
"channel_id": channel.id,
"message_id": message.id,
"data": {
"type": "message",
"data": MessageUserResponse(
**{
**message.model_dump(),
"reply_count": 0,
"latest_reply_at": None,
"reactions": Messages.get_reactions_by_message_id(
message.id
),
"user": UserNameResponse(**user.model_dump()),
}
).model_dump(),
"data": message.model_dump(),
},
"user": UserNameResponse(**user.model_dump()).model_dump(),
"channel": channel.model_dump(),
@ -447,23 +453,16 @@ async def new_message_handler(
"message_id": parent_message.id,
"data": {
"type": "message:reply",
"data": MessageUserResponse(
**{
**parent_message.model_dump(),
"user": UserNameResponse(
**Users.get_user_by_id(
parent_message.user_id
).model_dump()
),
}
).model_dump(),
"data": parent_message.model_dump(),
},
"user": UserNameResponse(**user.model_dump()).model_dump(),
"channel": channel.model_dump(),
},
to=f"channel:{channel.id}",
)
return MessageModel(**message.model_dump()), channel
return message, channel
else:
raise Exception("Error creating message")
except Exception as e:
log.exception(e)
raise HTTPException(
@ -651,14 +650,7 @@ async def update_message_by_id(
"message_id": message.id,
"data": {
"type": "message:update",
"data": MessageUserResponse(
**{
**message.model_dump(),
"user": UserNameResponse(
**user.model_dump()
).model_dump(),
}
).model_dump(),
"data": message.model_dump(),
},
"user": UserNameResponse(**user.model_dump()).model_dump(),
"channel": channel.model_dump(),
@ -724,9 +716,6 @@ async def add_reaction_to_message(
"type": "message:reaction:add",
"data": {
**message.model_dump(),
"user": UserNameResponse(
**Users.get_user_by_id(message.user_id).model_dump()
).model_dump(),
"name": form_data.name,
},
},
@ -793,9 +782,6 @@ async def remove_reaction_by_id_and_user_id_and_name(
"type": "message:reaction:remove",
"data": {
**message.model_dump(),
"user": UserNameResponse(
**Users.get_user_by_id(message.user_id).model_dump()
).model_dump(),
"name": form_data.name,
},
},
@ -882,16 +868,7 @@ async def delete_message_by_id(
"message_id": parent_message.id,
"data": {
"type": "message:reply",
"data": MessageUserResponse(
**{
**parent_message.model_dump(),
"user": UserNameResponse(
**Users.get_user_by_id(
parent_message.user_id
).model_dump()
),
}
).model_dump(),
"data": parent_message.model_dump(),
},
"user": UserNameResponse(**user.model_dump()).model_dump(),
"channel": channel.model_dump(),

View File

@ -218,6 +218,28 @@ async def get_chats_by_folder_id(folder_id: str, user=Depends(get_verified_user)
]
@router.get("/folder/{folder_id}/list")
async def get_chat_list_by_folder_id(
folder_id: str, page: Optional[int] = 1, user=Depends(get_verified_user)
):
try:
limit = 60
skip = (page - 1) * limit
return [
{"title": chat.title, "id": chat.id, "updated_at": chat.updated_at}
for chat in Chats.get_chats_by_folder_id_and_user_id(
folder_id, user.id, skip=skip, limit=limit
)
]
except Exception as e:
log.exception(e)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
)
############################
# GetPinnedChats
############################
@ -339,6 +361,16 @@ async def archive_all_chats(user=Depends(get_verified_user)):
return Chats.archive_all_chats_by_user_id(user.id)
############################
# UnarchiveAllChats
############################
@router.post("/unarchive/all", response_model=bool)
async def unarchive_all_chats(user=Depends(get_verified_user)):
return Chats.unarchive_all_chats_by_user_id(user.id)
############################
# GetSharedChatById
############################

View File

@ -207,20 +207,21 @@ async def verify_tool_servers_config(
if form_data.type == "mcp":
if form_data.auth_type == "oauth_2.1":
discovery_urls = get_discovery_urls(form_data.url)
for discovery_url in discovery_urls:
log.debug(
f"Trying to fetch OAuth 2.1 discovery document from {discovery_url}"
)
async with aiohttp.ClientSession() as session:
async with session.get(
discovery_urls[0]
) as oauth_server_metadata_response:
if oauth_server_metadata_response.status != 200:
raise HTTPException(
status_code=400,
detail=f"Failed to fetch OAuth 2.1 discovery document from {discovery_urls[0]}",
)
if oauth_server_metadata_response.status == 200:
try:
oauth_server_metadata = OAuthMetadata.model_validate(
oauth_server_metadata = (
OAuthMetadata.model_validate(
await oauth_server_metadata_response.json()
)
)
return {
"status": True,
"oauth_server_metadata": oauth_server_metadata.model_dump(
@ -238,7 +239,7 @@ async def verify_tool_servers_config(
raise HTTPException(
status_code=400,
detail=f"Failed to fetch OAuth 2.1 discovery document from {discovery_urls[0]}",
detail=f"Failed to fetch OAuth 2.1 discovery document from {discovery_urls}",
)
else:
try:

View File

@ -12,6 +12,7 @@ from open_webui.models.folders import (
FolderForm,
FolderUpdateForm,
FolderModel,
FolderNameIdResponse,
Folders,
)
from open_webui.models.chats import Chats
@ -44,7 +45,7 @@ router = APIRouter()
############################
@router.get("/", response_model=list[FolderModel])
@router.get("/", response_model=list[FolderNameIdResponse])
async def get_folders(user=Depends(get_verified_user)):
folders = Folders.get_folders_by_user_id(user.id)
@ -76,14 +77,6 @@ async def get_folders(user=Depends(get_verified_user)):
return [
{
**folder.model_dump(),
"items": {
"chats": [
{"title": chat.title, "id": chat.id, "updated_at": chat.updated_at}
for chat in Chats.get_chats_by_folder_id_and_user_id(
folder.id, user.id
)
]
},
}
for folder in folders
]

View File

@ -1,6 +1,9 @@
from typing import Optional
import io
import base64
import json
import asyncio
import logging
from open_webui.models.models import (
ModelForm,
@ -12,7 +15,14 @@ from open_webui.models.models import (
from pydantic import BaseModel
from open_webui.constants import ERROR_MESSAGES
from fastapi import APIRouter, Depends, HTTPException, Request, status, Response
from fastapi import (
APIRouter,
Depends,
HTTPException,
Request,
status,
Response,
)
from fastapi.responses import FileResponse, StreamingResponse
@ -20,6 +30,8 @@ from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_access, has_permission
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL, STATIC_DIR
log = logging.getLogger(__name__)
router = APIRouter()
@ -93,6 +105,50 @@ async def export_models(user=Depends(get_admin_user)):
return Models.get_models()
############################
# ImportModels
############################
class ModelsImportForm(BaseModel):
models: list[dict]
@router.post("/import", response_model=bool)
async def import_models(
user: str = Depends(get_admin_user), form_data: ModelsImportForm = (...)
):
try:
data = form_data.models
if isinstance(data, list):
for model_data in data:
# Here, you can add logic to validate model_data if needed
model_id = model_data.get("id")
if model_id:
existing_model = Models.get_model_by_id(model_id)
if existing_model:
# Update existing model
model_data["meta"] = model_data.get("meta", {})
model_data["params"] = model_data.get("params", {})
updated_model = ModelForm(
**{**existing_model.model_dump(), **model_data}
)
Models.update_model_by_id(model_id, updated_model)
else:
# Insert new model
model_data["meta"] = model_data.get("meta", {})
model_data["params"] = model_data.get("params", {})
new_model = ModelForm(**model_data)
Models.insert_new_model(user_id=user.id, form_data=new_model)
return True
else:
raise HTTPException(status_code=400, detail="Invalid JSON format")
except Exception as e:
log.exception(e)
raise HTTPException(status_code=500, detail=str(e))
############################
# SyncModels
############################

View File

@ -180,6 +180,18 @@ async def update_note_by_id(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
# Check if user can share publicly
if (
user.role != "admin"
and form_data.access_control == None
and not has_permission(
user.id,
"sharing.public_notes",
request.app.state.config.USER_PERMISSIONS,
)
):
form_data.access_control = {}
try:
note = Notes.update_note_by_id(id, form_data)
await sio.emit(

View File

@ -78,6 +78,7 @@ from open_webui.retrieval.utils import (
query_doc,
query_doc_with_hybrid_search,
)
from open_webui.retrieval.vector.utils import filter_metadata
from open_webui.utils.misc import (
calculate_sha256_string,
)
@ -1535,7 +1536,7 @@ def process_file(
Document(
page_content=doc.page_content,
metadata={
**doc.metadata,
**filter_metadata(doc.metadata),
"name": file.filename,
"created_by": file.user_id,
"file_id": file.id,

View File

@ -17,7 +17,11 @@ from open_webui.models.tools import (
ToolUserResponse,
Tools,
)
from open_webui.utils.plugin import load_tool_module_by_id, replace_imports
from open_webui.utils.plugin import (
load_tool_module_by_id,
replace_imports,
get_tool_module_from_cache,
)
from open_webui.utils.tools import get_tool_specs
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_access, has_permission
@ -35,6 +39,14 @@ log.setLevel(SRC_LOG_LEVELS["MAIN"])
router = APIRouter()
def get_tool_module(request, tool_id, load_from_db=True):
"""
Get the tool module by its ID.
"""
tool_module, _ = get_tool_module_from_cache(request, tool_id, load_from_db)
return tool_module
############################
# GetTools
############################
@ -42,15 +54,19 @@ router = APIRouter()
@router.get("/", response_model=list[ToolUserResponse])
async def get_tools(request: Request, user=Depends(get_verified_user)):
tools = [
tools = []
# Local Tools
for tool in Tools.get_tools():
tool_module = get_tool_module(request, tool.id)
tools.append(
ToolUserResponse(
**{
**tool.model_dump(),
"has_user_valves": "class UserValves(BaseModel):" in tool.content,
"has_user_valves": hasattr(tool_module, "UserValves"),
}
)
for tool in Tools.get_tools()
]
)
# OpenAPI Tool Servers
for server in await get_tool_servers(request):

View File

@ -157,6 +157,7 @@ class SharingPermissions(BaseModel):
public_knowledge: bool = True
public_prompts: bool = True
public_tools: bool = True
public_notes: bool = True
class ChatPermissions(BaseModel):

View File

@ -705,6 +705,23 @@ def get_event_emitter(request_info, update_db=True):
},
)
if "type" in event_data and event_data["type"] == "embeds":
message = Chats.get_message_by_id_and_message_id(
request_info["chat_id"],
request_info["message_id"],
)
embeds = event_data.get("data", {}).get("embeds", [])
embeds.extend(message.get("embeds", []))
Chats.upsert_message_to_chat_by_id_and_message_id(
request_info["chat_id"],
request_info["message_id"],
{
"embeds": embeds,
},
)
if "type" in event_data and event_data["type"] == "files":
message = Chats.get_message_by_id_and_message_id(
request_info["chat_id"],

View File

@ -133,6 +133,149 @@ DEFAULT_SOLUTION_TAGS = [("<|begin_of_solution|>", "<|end_of_solution|>")]
DEFAULT_CODE_INTERPRETER_TAGS = [("<code_interpreter>", "</code_interpreter>")]
def process_tool_result(
request,
tool_function_name,
tool_result,
tool_type,
direct_tool=False,
metadata=None,
user=None,
):
tool_result_embeds = []
if isinstance(tool_result, HTMLResponse):
content_disposition = tool_result.headers.get("Content-Disposition", "")
if "inline" in content_disposition:
content = tool_result.body.decode("utf-8")
tool_result_embeds.append(content)
if 200 <= tool_result.status_code < 300:
tool_result = {
"status": "success",
"code": "ui_component",
"message": f"{tool_function_name}: Embedded UI result is active and visible to the user.",
}
elif 400 <= tool_result.status_code < 500:
tool_result = {
"status": "error",
"code": "ui_component",
"message": f"{tool_function_name}: Client error {tool_result.status_code} from embedded UI result.",
}
elif 500 <= tool_result.status_code < 600:
tool_result = {
"status": "error",
"code": "ui_component",
"message": f"{tool_function_name}: Server error {tool_result.status_code} from embedded UI result.",
}
else:
tool_result = {
"status": "error",
"code": "ui_component",
"message": f"{tool_function_name}: Unexpected status code {tool_result.status_code} from embedded UI result.",
}
else:
tool_result = tool_result.body.decode("utf-8")
elif (tool_type == "external" and isinstance(tool_result, tuple)) or (
direct_tool and isinstance(tool_result, list) and len(tool_result) == 2
):
tool_result, tool_response_headers = tool_result
try:
if not isinstance(tool_response_headers, dict):
tool_response_headers = dict(tool_response_headers)
except Exception as e:
tool_response_headers = {}
log.debug(e)
if tool_response_headers and isinstance(tool_response_headers, dict):
content_disposition = tool_response_headers.get(
"Content-Disposition",
tool_response_headers.get("content-disposition", ""),
)
if "inline" in content_disposition:
content_type = tool_response_headers.get(
"Content-Type",
tool_response_headers.get("content-type", ""),
)
location = tool_response_headers.get(
"Location",
tool_response_headers.get("location", ""),
)
if "text/html" in content_type:
# Display as iframe embed
tool_result_embeds.append(tool_result)
tool_result = {
"status": "success",
"code": "ui_component",
"message": f"{tool_function_name}: Embedded UI result is active and visible to the user.",
}
elif location:
tool_result_embeds.append(location)
tool_result = {
"status": "success",
"code": "ui_component",
"message": f"{tool_function_name}: Embedded UI result is active and visible to the user.",
}
tool_result_files = []
if isinstance(tool_result, list):
if tool_type == "mcp": # MCP
tool_response = []
for item in tool_result:
if isinstance(item, dict):
if item.get("type") == "text":
text = item.get("text", "")
if isinstance(text, str):
try:
text = json.loads(text)
except json.JSONDecodeError:
pass
tool_response.append(text)
elif item.get("type") in ["image", "audio"]:
file_url = get_file_url_from_base64(
request,
f"data:{item.get('mimeType')};base64,{item.get('data', item.get('blob', ''))}",
{
"chat_id": metadata.get("chat_id", None),
"message_id": metadata.get("message_id", None),
"session_id": metadata.get("session_id", None),
"result": item,
},
user,
)
tool_result_files.append(
{
"type": item.get("type", "data"),
"url": file_url,
}
)
tool_result = tool_response[0] if len(tool_response) == 1 else tool_response
else: # OpenAPI
for item in tool_result:
if isinstance(item, str) and item.startswith("data:"):
tool_result_files.append(
{
"type": "data",
"content": item,
}
)
tool_result.remove(item)
if isinstance(tool_result, list):
tool_result = {"results": tool_result}
if isinstance(tool_result, dict) or isinstance(tool_result, list):
tool_result = json.dumps(tool_result, indent=2, ensure_ascii=False)
return tool_result, tool_result_files, tool_result_embeds
async def chat_completion_tools_handler(
request: Request, body: dict, extra_params: dict, user: UserModel, models, tools
) -> tuple[dict, dict]:
@ -172,6 +315,7 @@ async def chat_completion_tools_handler(
}
event_caller = extra_params["__event_call__"]
event_emitter = extra_params["__event_emitter__"]
metadata = extra_params["__metadata__"]
task_model_id = get_task_model_id(
@ -226,8 +370,14 @@ async def chat_completion_tools_handler(
tool_function_params = tool_call.get("parameters", {})
tool = None
tool_type = ""
direct_tool = False
try:
tool = tools[tool_function_name]
tool_type = tool.get("type", "")
direct_tool = tool.get("direct", False)
spec = tool.get("spec", {})
allowed_params = (
@ -259,18 +409,46 @@ async def chat_completion_tools_handler(
except Exception as e:
tool_result = str(e)
tool_result_files = []
if isinstance(tool_result, list):
for item in tool_result:
# check if string
if isinstance(item, str) and item.startswith("data:"):
tool_result_files.append(item)
tool_result.remove(item)
tool_result, tool_result_files, tool_result_embeds = (
process_tool_result(
request,
tool_function_name,
tool_result,
tool_type,
direct_tool,
metadata,
user,
)
)
if isinstance(tool_result, dict) or isinstance(tool_result, list):
tool_result = json.dumps(tool_result, indent=2)
if event_emitter:
if tool_result_files:
await event_emitter(
{
"type": "files",
"data": {
"files": tool_result_files,
},
}
)
if isinstance(tool_result, str):
if tool_result_embeds:
await event_emitter(
{
"type": "embeds",
"data": {
"embeds": tool_result_embeds,
},
}
)
print(
f"Tool {tool_function_name} result: {tool_result}",
tool_result_files,
tool_result_embeds,
)
if tool_result:
tool = tools[tool_function_name]
tool_id = tool.get("tool_id", "")
@ -284,18 +462,19 @@ async def chat_completion_tools_handler(
sources.append(
{
"source": {
"name": (f"TOOL:{tool_name}"),
"name": (f"{tool_name}"),
},
"document": [tool_result],
"document": [str(tool_result)],
"metadata": [
{
"source": (f"TOOL:{tool_name}"),
"source": (f"{tool_name}"),
"parameters": tool_function_params,
}
],
"tool_result": True,
}
)
# Citation is not enabled for this tool
body["messages"] = add_or_update_user_message(
f"\nTool `{tool_name}` Output: {tool_result}",
@ -1010,7 +1189,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
tools_dict = {}
mcp_clients = []
mcp_clients = {}
mcp_tools_dict = {}
if tool_ids:
@ -1071,35 +1250,41 @@ async def process_chat_payload(request, form_data, user, metadata, model):
log.error(f"Error getting OAuth token: {e}")
oauth_token = None
mcp_client = MCPClient()
await mcp_client.connect(
mcp_clients[server_id] = MCPClient()
await mcp_clients[server_id].connect(
url=mcp_server_connection.get("url", ""),
headers=headers if headers else None,
)
tool_specs = await mcp_client.list_tool_specs()
tool_specs = await mcp_clients[server_id].list_tool_specs()
for tool_spec in tool_specs:
def make_tool_function(function_name):
def make_tool_function(client, function_name):
async def tool_function(**kwargs):
return await mcp_client.call_tool(
print(kwargs)
print(client)
print(await client.list_tool_specs())
return await client.call_tool(
function_name,
function_args=kwargs,
)
return tool_function
tool_function = make_tool_function(tool_spec["name"])
tool_function = make_tool_function(
mcp_clients[server_id], tool_spec["name"]
)
mcp_tools_dict[tool_spec["name"]] = {
"spec": tool_spec,
mcp_tools_dict[f"{server_id}_{tool_spec['name']}"] = {
"spec": {
**tool_spec,
"name": f"{server_id}_{tool_spec['name']}",
},
"callable": tool_function,
"type": "mcp",
"client": mcp_client,
"client": mcp_clients[server_id],
"direct": False,
}
mcp_clients.append(mcp_client)
except Exception as e:
log.debug(e)
continue
@ -1140,7 +1325,6 @@ async def process_chat_payload(request, form_data, user, metadata, model):
{"type": "function", "function": tool.get("spec", {})}
for tool in tools_dict.values()
]
else:
# If the function calling is not native, then call the tools function calling handler
try:
@ -1165,9 +1349,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
citation_idx_map = {}
for source in sources:
is_tool_result = source.get("tool_result", False)
if "document" in source and not is_tool_result:
if "document" in source:
for document_text, document_metadata in zip(
source["document"], source["metadata"]
):
@ -1228,6 +1410,10 @@ async def process_chat_payload(request, form_data, user, metadata, model):
}
)
print("Final form_data:", form_data)
print("Final metadata:", metadata)
print("Final events:", events)
return form_data, metadata, events
@ -2436,7 +2622,9 @@ async def process_chat_response(
print("tool_call", tool_call)
tool_call_id = tool_call.get("id", "")
tool_name = tool_call.get("function", {}).get("name", "")
tool_function_name = tool_call.get("function", {}).get(
"name", ""
)
tool_args = tool_call.get("function", {}).get("arguments", "{}")
tool_function_params = {}
@ -2466,11 +2654,17 @@ async def process_chat_response(
)
tool_result = None
tool = None
tool_type = None
direct_tool = False
if tool_name in tools:
tool = tools[tool_name]
if tool_function_name in tools:
tool = tools[tool_function_name]
spec = tool.get("spec", {})
tool_type = tool.get("type", "")
direct_tool = tool.get("direct", False)
try:
allowed_params = (
spec.get("parameters", {})
@ -2484,13 +2678,13 @@ async def process_chat_response(
if k in allowed_params
}
if tool.get("direct", False):
if direct_tool:
tool_result = await event_caller(
{
"type": "execute:tool",
"data": {
"id": str(uuid4()),
"name": tool_name,
"name": tool_function_name,
"params": tool_function_params,
"server": tool.get("server", {}),
"session_id": metadata.get(
@ -2509,150 +2703,16 @@ async def process_chat_response(
except Exception as e:
tool_result = str(e)
tool_result_embeds = []
if isinstance(tool_result, HTMLResponse):
content_disposition = tool_result.headers.get(
"Content-Disposition", ""
)
if "inline" in content_disposition:
content = tool_result.body.decode("utf-8")
tool_result_embeds.append(content)
if 200 <= tool_result.status_code < 300:
tool_result = {
"status": "success",
"code": "ui_component",
"message": "Embedded UI result is active and visible to the user.",
}
elif 400 <= tool_result.status_code < 500:
tool_result = {
"status": "error",
"code": "ui_component",
"message": f"Client error {tool_result.status_code} from embedded UI result.",
}
elif 500 <= tool_result.status_code < 600:
tool_result = {
"status": "error",
"code": "ui_component",
"message": f"Server error {tool_result.status_code} from embedded UI result.",
}
else:
tool_result = {
"status": "error",
"code": "ui_component",
"message": f"Unexpected status code {tool_result.status_code} from embedded UI result.",
}
else:
tool_result = tool_result.body.decode("utf-8")
elif (
tool.get("type") == "external"
and isinstance(tool_result, tuple)
) or (
tool.get("direct", True)
and isinstance(tool_result, list)
and len(tool_result) == 2
):
tool_result, tool_response_headers = tool_result
if tool_response_headers:
content_disposition = tool_response_headers.get(
"Content-Disposition",
tool_response_headers.get(
"content-disposition", ""
),
)
if "inline" in content_disposition:
content_type = tool_response_headers.get(
"Content-Type",
tool_response_headers.get("content-type", ""),
)
location = tool_response_headers.get(
"Location",
tool_response_headers.get("location", ""),
)
if "text/html" in content_type:
# Display as iframe embed
tool_result_embeds.append(tool_result)
tool_result = {
"status": "success",
"code": "ui_component",
"message": "Embedded UI result is active and visible to the user.",
}
elif location:
tool_result_embeds.append(location)
tool_result = {
"status": "success",
"code": "ui_component",
"message": "Embedded UI result is active and visible to the user.",
}
tool_result_files = []
if isinstance(tool_result, list):
for item in tool_result:
# check if string
if isinstance(item, str) and item.startswith("data:"):
tool_result_files.append(
{
"type": "data",
"content": item,
}
)
tool_result.remove(item)
if tool.get("type") == "mcp":
if isinstance(item, dict):
if (
item.get("type") == "image"
or item.get("type") == "audio"
):
file_url = get_file_url_from_base64(
tool_result, tool_result_files, tool_result_embeds = (
process_tool_result(
request,
f"data:{item.get('mimeType')};base64,{item.get('data', item.get('blob', ''))}",
{
"chat_id": metadata.get(
"chat_id", None
),
"message_id": metadata.get(
"message_id", None
),
"session_id": metadata.get(
"session_id", None
),
"result": item,
},
tool_function_name,
tool_result,
tool_type,
direct_tool,
metadata,
user,
)
tool_result_files.append(
{
"type": item.get("type", "data"),
"url": file_url,
}
)
tool_result.remove(item)
if tool_result_files:
if not isinstance(tool_result, list):
tool_result = [
tool_result,
]
for file in tool_result_files:
tool_result.append(
{
"type": file.get("type", "data"),
"content": "Result is being displayed as a file.",
}
)
if isinstance(tool_result, dict) or isinstance(
tool_result, list
):
tool_result = json.dumps(
tool_result, indent=2, ensure_ascii=False
)
results.append(
@ -2673,7 +2733,6 @@ async def process_chat_response(
)
content_blocks[-1]["results"] = results
content_blocks.append(
{
"type": "text",

View File

@ -391,17 +391,10 @@ def parse_ollama_modelfile(model_text):
"top_k": int,
"top_p": float,
"num_keep": int,
"typical_p": float,
"presence_penalty": float,
"frequency_penalty": float,
"penalize_newline": bool,
"numa": bool,
"num_batch": int,
"num_gpu": int,
"main_gpu": int,
"low_vram": bool,
"f16_kv": bool,
"vocab_only": bool,
"use_mmap": bool,
"use_mlock": bool,
"num_thread": int,

View File

@ -263,6 +263,7 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
"icon": function.meta.manifest.get("icon_url", None)
or getattr(module, "icon_url", None)
or getattr(module, "icon", None),
"has_user_valves": hasattr(module, "UserValves"),
}
]

View File

@ -198,13 +198,25 @@ def get_parsed_and_base_url(server_url) -> tuple[urllib.parse.ParseResult, str]:
def get_discovery_urls(server_url) -> list[str]:
urls = []
parsed, base_url = get_parsed_and_base_url(server_url)
urls = [
urllib.parse.urljoin(base_url, "/.well-known/oauth-authorization-server"),
urllib.parse.urljoin(base_url, "/.well-known/openid-configuration"),
]
if parsed.path and parsed.path != "/":
urls.append(
urllib.parse.urljoin(base_url, "/.well-known/oauth-authorization-server")
urllib.parse.urljoin(
base_url,
f"/.well-known/oauth-authorization-server{parsed.path.rstrip('/')}",
)
)
urls.append(
urllib.parse.urljoin(
base_url, f"/.well-known/openid-configuration{parsed.path.rstrip('/')}"
)
)
urls.append(urllib.parse.urljoin(base_url, "/.well-known/openid-configuration"))
return urls

View File

@ -153,17 +153,11 @@ def apply_model_params_to_body_ollama(params: dict, form_data: dict) -> dict:
"repeat_last_n": int,
"top_k": int,
"min_p": float,
"typical_p": float,
"repeat_penalty": float,
"presence_penalty": float,
"frequency_penalty": float,
"penalize_newline": bool,
"stop": lambda x: [bytes(s, "utf-8").decode("unicode_escape") for s in x],
"numa": bool,
"num_gpu": int,
"main_gpu": int,
"low_vram": bool,
"vocab_only": bool,
"use_mmap": bool,
"use_mlock": bool,
"num_thread": int,

View File

@ -166,6 +166,48 @@ def load_function_module_by_id(function_id: str, content: str | None = None):
os.unlink(temp_file.name)
def get_tool_module_from_cache(request, tool_id, load_from_db=True):
if load_from_db:
# Always load from the database by default
tool = Tools.get_tool_by_id(tool_id)
if not tool:
raise Exception(f"Tool not found: {tool_id}")
content = tool.content
new_content = replace_imports(content)
if new_content != content:
content = new_content
# Update the tool content in the database
Tools.update_tool_by_id(tool_id, {"content": content})
if (
hasattr(request.app.state, "TOOL_CONTENTS")
and tool_id in request.app.state.TOOL_CONTENTS
) and (
hasattr(request.app.state, "TOOLS") and tool_id in request.app.state.TOOLS
):
if request.app.state.TOOL_CONTENTS[tool_id] == content:
return request.app.state.TOOLS[tool_id], None
tool_module, frontmatter = load_tool_module_by_id(tool_id, content)
else:
if hasattr(request.app.state, "TOOLS") and tool_id in request.app.state.TOOLS:
return request.app.state.TOOLS[tool_id], None
tool_module, frontmatter = load_tool_module_by_id(tool_id)
if not hasattr(request.app.state, "TOOLS"):
request.app.state.TOOLS = {}
if not hasattr(request.app.state, "TOOL_CONTENTS"):
request.app.state.TOOL_CONTENTS = {}
request.app.state.TOOLS[tool_id] = tool_module
request.app.state.TOOL_CONTENTS[tool_id] = content
return tool_module, frontmatter
def get_function_module_from_cache(request, function_id, load_from_db=True):
if load_from_db:
# Always load from the database by default

View File

@ -588,28 +588,20 @@ async def get_tool_server_data(token: str, url: str) -> Dict[str, Any]:
error = str(err)
raise Exception(error)
data = {
"openapi": res,
"info": res.get("info", {}),
"specs": convert_openapi_to_tool_payload(res),
}
log.info(f"Fetched data: {data}")
return data
log.debug(f"Fetched data: {res}")
return res
async def get_tool_servers_data(servers: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
# Prepare list of enabled servers along with their original index
tasks = []
server_entries = []
for idx, server in enumerate(servers):
if (
server.get("config", {}).get("enable")
and server.get("type", "openapi") == "openapi"
):
# Path (to OpenAPI spec URL) can be either a full URL or a path to append to the base URL
openapi_path = server.get("path", "openapi.json")
full_url = get_tool_server_url(server.get("url"), openapi_path)
info = server.get("info", {})
auth_type = server.get("auth_type", "bearer")
@ -625,12 +617,34 @@ async def get_tool_servers_data(servers: List[Dict[str, Any]]) -> List[Dict[str,
if not id:
id = str(idx)
server_entries.append((id, idx, server, full_url, info, token))
server_url = server.get("url")
spec_type = server.get("spec_type", "url")
# Create async tasks to fetch data
tasks = [
get_tool_server_data(token, url) for (_, _, _, url, _, token) in server_entries
]
task = None
if spec_type == "url":
# Path (to OpenAPI spec URL) can be either a full URL or a path to append to the base URL
openapi_path = server.get("path", "openapi.json")
spec_url = get_tool_server_url(server_url, openapi_path)
# Fetch from URL
task = get_tool_server_data(token, spec_url)
elif spec_type == "json" and server.get("spec", ""):
# Use provided JSON spec
spec_json = None
try:
spec_json = json.loads(server.get("spec", ""))
except Exception as e:
log.error(f"Error parsing JSON spec for tool server {id}: {e}")
if spec_json:
task = asyncio.sleep(
0,
result=spec_json,
)
if task:
tasks.append(task)
server_entries.append((id, idx, server, server_url, info, token))
# Execute tasks concurrently
responses = await asyncio.gather(*tasks, return_exceptions=True)
@ -642,8 +656,13 @@ async def get_tool_servers_data(servers: List[Dict[str, Any]]) -> List[Dict[str,
log.error(f"Failed to connect to {url} OpenAPI tool server")
continue
openapi_data = response.get("openapi", {})
response = {
"openapi": response,
"info": response.get("info", {}),
"specs": convert_openapi_to_tool_payload(response),
}
openapi_data = response.get("openapi", {})
if info and isinstance(openapi_data, dict):
openapi_data["info"] = openapi_data.get("info", {})

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "open-webui",
"version": "0.6.31",
"version": "0.6.32",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "open-webui",
"version": "0.6.31",
"version": "0.6.32",
"dependencies": {
"@azure/msal-browser": "^4.5.0",
"@codemirror/lang-javascript": "^6.2.2",

View File

@ -1,6 +1,6 @@
{
"name": "open-webui",
"version": "0.6.31",
"version": "0.6.32",
"private": true,
"scripts": {
"dev": "npm run pyodide:fetch && vite dev --host",

View File

@ -248,6 +248,7 @@ export const getChannelThreadMessages = async (
};
type MessageForm = {
reply_to_id?: string;
parent_id?: string;
content: string;
data?: object;

View File

@ -33,6 +33,38 @@ export const createNewChat = async (token: string, chat: object, folderId: strin
return res;
};
export const unarchiveAllChats = async (token: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/unarchive/all`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const importChat = async (
token: string,
chat: object,
@ -327,6 +359,45 @@ export const getChatsByFolderId = async (token: string, folderId: string) => {
return res;
};
export const getChatListByFolderId = async (token: string, folderId: string, page: number = 1) => {
let error = null;
const searchParams = new URLSearchParams();
if (page !== null) {
searchParams.append('page', `${page}`);
}
const res = await fetch(
`${WEBUI_API_BASE_URL}/chats/folder/${folderId}/list?${searchParams.toString()}`,
{
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
}
}
)
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getAllArchivedChats = async (token: string) => {
let error = null;

View File

@ -23,7 +23,7 @@ export const uploadFile = async (token: string, file: File, metadata?: object |
return res.json();
})
.catch((err) => {
error = err.detail;
error = err.detail || err.message;
console.error(err);
return null;
});

View File

@ -337,14 +337,8 @@ export const getToolServerData = async (token: string, url: string) => {
throw error;
}
const data = {
openapi: res,
info: res.info,
specs: convertOpenApiToToolPayload(res)
};
console.log(data);
return data;
console.log(res);
return res;
};
export const getToolServersData = async (servers: object[]) => {
@ -356,6 +350,7 @@ export const getToolServersData = async (servers: object[]) => {
let error = null;
let toolServerToken = null;
const auth_type = server?.auth_type ?? 'bearer';
if (auth_type === 'bearer') {
toolServerToken = server?.key;
@ -365,7 +360,11 @@ export const getToolServersData = async (servers: object[]) => {
toolServerToken = localStorage.token;
}
const data = await getToolServerData(
let res = null;
const specType = server?.spec_type ?? 'url';
if (specType === 'url') {
res = await getToolServerData(
toolServerToken,
(server?.path ?? '').includes('://')
? server?.path
@ -374,9 +373,21 @@ export const getToolServersData = async (servers: object[]) => {
error = err;
return null;
});
} else if ((specType === 'json' && server?.spec) ?? null) {
try {
res = JSON.parse(server?.spec);
} catch (e) {
error = 'Failed to parse JSON spec';
}
}
if (res) {
const { openapi, info, specs } = {
openapi: res,
info: res.info,
specs: convertOpenApiToToolPayload(res)
};
if (data) {
const { openapi, info, specs } = data;
return {
url: server?.url,
openapi: openapi,

View File

@ -31,6 +31,34 @@ export const getModels = async (token: string = '') => {
return res;
};
export const importModels = async (token: string, models: object[]) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/models/import`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({ models: models })
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getBaseModels = async (token: string = '') => {
let error = null;

View File

@ -1,4 +1,9 @@
<script lang="ts">
import { v4 as uuidv4 } from 'uuid';
import fileSaver from 'file-saver';
const { saveAs } = fileSaver;
import { toast } from 'svelte-sonner';
import { getContext, onMount } from 'svelte';
const i18n = getContext('i18n');
@ -27,11 +32,16 @@
export let direct = false;
export let connection = null;
let url = '';
let path = 'openapi.json';
let inputElement = null;
let type = 'openapi'; // 'openapi', 'mcp'
let url = '';
let spec_type = 'url'; // 'url', 'json'
let spec = ''; // used when spec_type is 'json'
let path = 'openapi.json';
let auth_type = 'bearer';
let key = '';
@ -132,6 +142,84 @@
}
};
const importHandler = async (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
const json = event.target.result;
console.log('importHandler', json);
try {
let data = JSON.parse(json);
// validate data
if (Array.isArray(data)) {
if (data.length === 0) {
toast.error($i18n.t('Please select a valid JSON file'));
return;
}
data = data[0];
}
if (data.type) type = data.type;
if (data.url) url = data.url;
if (data.spec_type) spec_type = data.spec_type;
if (data.spec) spec = data.spec;
if (data.path) path = data.path;
if (data.auth_type) auth_type = data.auth_type;
if (data.key) key = data.key;
if (data.info) {
id = data.info.id ?? '';
name = data.info.name ?? '';
description = data.info.description ?? '';
}
if (data.config) {
enable = data.config.enable ?? true;
accessControl = data.config.access_control ?? {};
}
toast.success($i18n.t('Import successful'));
} catch (error) {
toast.error($i18n.t('Please select a valid JSON file'));
}
};
reader.readAsText(file);
};
const exportHandler = async () => {
// export current connection as json file
const json = JSON.stringify([
{
type,
url,
spec_type,
spec,
path,
auth_type,
key,
info: {
id: id,
name: name,
description: description
}
}
]);
const blob = new Blob([json], {
type: 'application/json'
});
saveAs(blob, `tool-server-${id || name || 'export'}.json`);
};
const submitHandler = async () => {
loading = true;
@ -149,10 +237,26 @@
return;
}
// validate spec
if (spec_type === 'json') {
try {
const specJSON = JSON.parse(spec);
spec = JSON.stringify(specJSON, null, 2);
} catch (e) {
toast.error($i18n.t('Please enter a valid JSON spec'));
loading = false;
return;
}
}
const connection = {
url,
path,
type,
url,
spec_type,
spec,
path,
auth_type,
key,
config: {
@ -173,9 +277,12 @@
show = false;
// reset form
url = '';
path = 'openapi.json';
type = 'openapi';
url = '';
spec_type = 'url';
spec = '';
path = 'openapi.json';
key = '';
auth_type = 'bearer';
@ -191,10 +298,13 @@
const init = () => {
if (connection) {
type = connection?.type ?? 'openapi';
url = connection.url;
spec_type = connection?.spec_type ?? 'url';
spec = connection?.spec ?? '';
path = connection?.path ?? 'openapi.json';
type = connection?.type ?? 'openapi';
auth_type = connection?.auth_type ?? 'bearer';
key = connection?.key ?? '';
@ -227,6 +337,23 @@
{$i18n.t('Add Connection')}
{/if}
</h1>
<div class="flex items-center gap-3">
<div class="flex gap-1.5 text-xs justify-end">
<button
class=" hover:underline"
type="button"
on:click={() => {
inputElement?.click();
}}
>
{$i18n.t('Import')}
</button>
<button class=" hover:underline" type="button" on:click={exportHandler}>
{$i18n.t('Export')}
</button>
</div>
<button
class="self-center"
aria-label={$i18n.t('Close Configure Connection Modal')}
@ -237,9 +364,20 @@
<XMark className={'size-5'} />
</button>
</div>
</div>
<div class="flex flex-col md:flex-row w-full px-4 pb-4 md:space-x-4 dark:text-gray-200">
<div class=" flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
<input
bind:this={inputElement}
type="file"
hidden
accept=".json"
on:change={(e) => {
importHandler(e);
}}
/>
<form
class="flex flex-col w-full"
on:submit={(e) => {
@ -326,8 +464,37 @@
<Switch bind:state={enable} />
</Tooltip>
</div>
</div>
</div>
{#if ['', 'openapi'].includes(type)}
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<div class="flex justify-between items-center mb-0.5">
<div class="flex gap-2 items-center">
<div
for="select-bearer-or-session"
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t('OpenAPI Spec')}
</div>
</div>
</div>
<div class="flex gap-2">
<div class="flex-shrink-0 self-start">
<select
id="select-bearer-or-session"
class={`w-full text-sm bg-transparent pr-5 ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
bind:value={spec_type}
>
<option value="url">{$i18n.t('URL')}</option>
<option value="json">{$i18n.t('JSON')}</option>
</select>
</div>
<div class="flex flex-1 items-center">
{#if spec_type === 'url'}
<div class="flex-1 flex items-center">
<label for="url-or-path" class="sr-only"
>{$i18n.t('openapi.json URL or Path')}</label
@ -342,11 +509,25 @@
required
/>
</div>
{:else if spec_type === 'json'}
<div
class={`text-xs w-full self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
<label for="url-or-path" class="sr-only">{$i18n.t('JSON Spec')}</label>
<textarea
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700 text-black dark:text-white'}`}
bind:value={spec}
placeholder={$i18n.t('JSON Spec')}
autocomplete="off"
required
rows="5"
/>
</div>
{/if}
</div>
</div>
{#if ['', 'openapi'].includes(type)}
{#if ['', 'url'].includes(spec_type)}
<div
class={`text-xs mt-1 ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
@ -357,6 +538,9 @@
})}
</div>
{/if}
</div>
</div>
{/if}
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
@ -566,7 +750,9 @@
</div>
{/if}
<div class="flex justify-end pt-3 text-sm font-medium gap-1.5">
<div class="flex justify-between pt-3 text-sm font-medium gap-1.5">
<div></div>
<div class="flex gap-1.5">
{#if edit}
<button
class="px-3.5 py-1.5 text-sm font-medium dark:bg-black dark:hover:bg-gray-900 dark:text-white bg-white text-black hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center"
@ -596,6 +782,7 @@
{/if}
</button>
</div>
</div>
</form>
</div>
</div>

View File

@ -595,7 +595,7 @@
deleteHandler(selectedFunction);
}}
>
<div class=" text-sm text-gray-500">
<div class=" text-sm text-gray-500 truncate">
{$i18n.t('This will delete')} <span class=" font-semibold">{selectedFunction.name}</span>.
</div>
</DeleteConfirmDialog>

View File

@ -34,7 +34,7 @@
<div class="w-full flex flex-col">
<div class="flex items-center gap-1">
<div class="shrink-0 line-clamp-1">
<div class=" line-clamp-1">
{model.name}
</div>
</div>

View File

@ -12,7 +12,8 @@
deleteAllModels,
getBaseModels,
toggleModelById,
updateModelById
updateModelById,
importModels
} from '$lib/apis/models';
import { copyToClipboard } from '$lib/utils';
import { page } from '$app/stores';
@ -40,6 +41,7 @@
let shiftKey = false;
let modelsImportInProgress = false;
let importFiles;
let modelsImportInputElement: HTMLInputElement;
@ -464,47 +466,41 @@
accept=".json"
hidden
on:change={() => {
console.log(importFiles);
let reader = new FileReader();
if (importFiles.length > 0) {
const reader = new FileReader();
reader.onload = async (event) => {
let savedModels = JSON.parse(event.target.result);
console.log(savedModels);
try {
const models = JSON.parse(String(event.target.result));
modelsImportInProgress = true;
const res = await importModels(localStorage.token, models);
modelsImportInProgress = false;
for (const model of savedModels) {
if (Object.keys(model).includes('base_model_id')) {
if (model.base_model_id === null) {
upsertModelHandler(model);
}
if (res) {
toast.success($i18n.t('Models imported successfully'));
await init();
} else {
if (model?.info ?? false) {
if (model.info.base_model_id === null) {
upsertModelHandler(model.info);
toast.error($i18n.t('Failed to import models'));
}
} catch (e) {
toast.error($i18n.t('Invalid JSON file'));
console.error(e);
}
}
}
await _models.set(
await getModels(
localStorage.token,
$config?.features?.enable_direct_connections &&
($settings?.directConnections ?? null)
)
);
init();
};
reader.readAsText(importFiles[0]);
}
}}
/>
<button
class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 dark:text-gray-200 transition"
disabled={modelsImportInProgress}
on:click={() => {
modelsImportInputElement.click();
}}
>
{#if modelsImportInProgress}
<Spinner className="size-3" />
{/if}
<div class=" self-center mr-2 font-medium line-clamp-1">
{$i18n.t('Import Presets')}
</div>

View File

@ -19,10 +19,9 @@
import Search from '$lib/components/icons/Search.svelte';
import User from '$lib/components/icons/User.svelte';
import UserCircleSolid from '$lib/components/icons/UserCircleSolid.svelte';
import GroupModal from './Groups/EditGroupModal.svelte';
import EditGroupModal from './Groups/EditGroupModal.svelte';
import Pencil from '$lib/components/icons/Pencil.svelte';
import GroupItem from './Groups/GroupItem.svelte';
import AddGroupModal from './Groups/AddGroupModal.svelte';
import { createNewGroup, getGroups } from '$lib/apis/groups';
import {
getUserDefaultPermissions,
@ -51,54 +50,20 @@
});
let search = '';
let defaultPermissions = {
workspace: {
models: false,
knowledge: false,
prompts: false,
tools: false
},
sharing: {
public_models: false,
public_knowledge: false,
public_prompts: false,
public_tools: false
},
chat: {
controls: true,
valves: true,
system_prompt: true,
params: true,
file_upload: true,
delete: true,
delete_message: true,
continue_response: true,
regenerate_response: true,
rate_response: true,
edit: true,
share: true,
export: true,
stt: true,
tts: true,
call: true,
multiple_models: true,
temporary: true,
temporary_enforced: false
},
features: {
direct_tool_servers: false,
web_search: true,
image_generation: true,
code_interpreter: true,
notes: true
}
};
let defaultPermissions = {};
let showCreateGroupModal = false;
let showAddGroupModal = false;
let showDefaultPermissionsModal = false;
const setGroups = async () => {
groups = await getGroups(localStorage.token);
const allGroups = await getGroups(localStorage.token);
const userGroup = allGroups.find((g) => g.name.toLowerCase() === 'user');
if (userGroup) {
defaultPermissions = userGroup.permissions;
}
groups = allGroups.filter((g) => g.name.toLowerCase() !== 'user');
};
const addGroupHandler = async (group) => {
@ -146,14 +111,18 @@
}
await setGroups();
defaultPermissions = await getUserDefaultPermissions(localStorage.token);
loaded = true;
});
</script>
{#if loaded}
<AddGroupModal bind:show={showCreateGroupModal} onSubmit={addGroupHandler} />
<EditGroupModal
bind:show={showAddGroupModal}
edit={false}
permissions={defaultPermissions}
onSubmit={addGroupHandler}
/>
<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')}
@ -180,7 +149,7 @@
<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={() => {
showCreateGroupModal = !showCreateGroupModal;
showAddGroupModal = !showAddGroupModal;
}}
>
<Plus className="size-3.5" />
@ -207,7 +176,7 @@
class=" px-4 py-1.5 text-sm rounded-full bg-black hover:bg-gray-800 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition font-medium flex items-center space-x-1"
aria-label={$i18n.t('Create Group')}
on:click={() => {
showCreateGroupModal = true;
showAddGroupModal = true;
}}
>
{$i18n.t('Create Group')}
@ -226,7 +195,7 @@
{#each filteredGroups as group}
<div class="my-2">
<GroupItem {group} {users} {setGroups} />
<GroupItem {group} {users} {setGroups} {defaultPermissions} />
</div>
{/each}
</div>
@ -234,7 +203,7 @@
<hr class="mb-2 border-gray-100 dark:border-gray-850" />
<GroupModal
<EditGroupModal
bind:show={showDefaultPermissionsModal}
tabs={['permissions']}
bind:permissions={defaultPermissions}

View File

@ -1,116 +0,0 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { getContext, onMount } from 'svelte';
const i18n = getContext('i18n');
import Spinner from '$lib/components/common/Spinner.svelte';
import Modal from '$lib/components/common/Modal.svelte';
import Textarea from '$lib/components/common/Textarea.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
export let onSubmit: Function = () => {};
export let show = false;
let name = '';
let description = '';
let userIds = [];
let loading = false;
const submitHandler = async () => {
loading = true;
const group = {
name,
description
};
await onSubmit(group);
loading = false;
show = false;
name = '';
description = '';
userIds = [];
};
</script>
<Modal size="sm" bind:show>
<div>
<div class=" flex justify-between dark:text-gray-100 px-5 pt-4 mb-1.5">
<div class=" text-lg font-medium self-center font-primary">
{$i18n.t('Add User Group')}
</div>
<button
class="self-center"
on:click={() => {
show = false;
}}
>
<XMark className={'size-5'} />
</button>
</div>
<div class="flex flex-col md:flex-row w-full px-4 pb-4 md:space-x-4 dark:text-gray-200">
<div class=" flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
<form
class="flex flex-col w-full"
on:submit={(e) => {
e.preventDefault();
submitHandler();
}}
>
<div class="px-1 flex flex-col w-full">
<div class="flex gap-2">
<div class="flex flex-col w-full">
<div class=" mb-0.5 text-xs text-gray-500">{$i18n.t('Name')}</div>
<div class="flex-1">
<input
class="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
type="text"
bind:value={name}
placeholder={$i18n.t('Group Name')}
autocomplete="off"
required
/>
</div>
</div>
</div>
<div class="flex flex-col w-full mt-2">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Description')}</div>
<div class="flex-1">
<Textarea
className="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden resize-none"
rows={2}
bind:value={description}
placeholder={$i18n.t('Group Description')}
/>
</div>
</div>
</div>
<div class="flex justify-end pt-3 text-sm font-medium gap-1.5">
<button
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center {loading
? ' cursor-not-allowed'
: ''}"
type="submit"
disabled={loading}
>
{$i18n.t('Create')}
{#if loading}
<div class="ml-2 self-center">
<Spinner />
</div>
{/if}
</button>
</div>
</form>
</div>
</div>
</div>
</Modal>

View File

@ -21,6 +21,7 @@
export let users = [];
export let group = null;
export let defaultPermissions = {};
export let custom = true;
@ -230,7 +231,7 @@
{#if selectedTab == 'general'}
<Display bind:name bind:description />
{:else if selectedTab == 'permissions'}
<Permissions bind:permissions />
<Permissions bind:permissions {defaultPermissions} />
{:else if selectedTab == 'users'}
<Users bind:userIds {users} />
{/if}

View File

@ -17,6 +17,7 @@
name: 'Admins',
user_ids: [1, 2, 3]
};
export let defaultPermissions = {};
export let setGroups = () => {};
@ -59,6 +60,7 @@
edit
{users}
{group}
{defaultPermissions}
onSubmit={updateHandler}
onDelete={deleteHandler}
/>

View File

@ -6,7 +6,7 @@
import Tooltip from '$lib/components/common/Tooltip.svelte';
// Default values for permissions
const defaultPermissions = {
const DEFAULT_PERMISSIONS = {
workspace: {
models: false,
knowledge: false,
@ -17,7 +17,8 @@
public_models: false,
public_knowledge: false,
public_prompts: false,
public_tools: false
public_tools: false,
public_notes: false
},
chat: {
controls: true,
@ -50,10 +51,11 @@
};
export let permissions = {};
export let defaultPermissions = {};
// Reactive statement to ensure all fields are present in `permissions`
$: {
permissions = fillMissingProperties(permissions, defaultPermissions);
permissions = fillMissingProperties(permissions, DEFAULT_PERMISSIONS);
}
function fillMissingProperties(obj: any, defaults: any) {
@ -68,140 +70,70 @@
}
onMount(() => {
permissions = fillMissingProperties(permissions, defaultPermissions);
permissions = fillMissingProperties(permissions, DEFAULT_PERMISSIONS);
});
</script>
<div>
<!-- <div>
<div class=" mb-2 text-sm font-medium">{$i18n.t('Model Permissions')}</div>
<div class="mb-2">
<div class="flex justify-between items-center text-xs pr-2">
<div class=" text-xs font-medium">{$i18n.t('Model Filtering')}</div>
<Switch bind:state={permissions.model.filter} />
</div>
</div>
{#if permissions.model.filter}
<div class="mb-2">
<div class=" space-y-1.5">
<div class="flex flex-col w-full">
<div class="mb-1 flex justify-between">
<div class="text-xs text-gray-500">{$i18n.t('Model IDs')}</div>
</div>
{#if model_ids.length > 0}
<div class="flex flex-col">
{#each model_ids as modelId, modelIdx}
<div class=" flex gap-2 w-full justify-between items-center">
<div class=" text-sm flex-1 rounded-lg">
{modelId}
</div>
<div class="shrink-0">
<button
type="button"
on:click={() => {
model_ids = model_ids.filter((_, idx) => idx !== modelIdx);
}}
>
<Minus strokeWidth="2" className="size-3.5" />
</button>
</div>
</div>
{/each}
</div>
{:else}
<div class="text-gray-500 text-xs text-center py-2 px-10">
{$i18n.t('No model IDs')}
</div>
{/if}
</div>
</div>
<hr class=" border-gray-100 dark:border-gray-700/10 mt-2.5 mb-1 w-full" />
<div class="flex items-center">
<select
class="w-full py-1 text-sm rounded-lg bg-transparent {selectedModelId
? ''
: 'text-gray-500'} placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
bind:value={selectedModelId}
>
<option value="">{$i18n.t('Select a model')}</option>
{#each $models.filter((m) => m?.owned_by !== 'arena') as model}
<option value={model.id} class="bg-gray-50 dark:bg-gray-700">{model.name}</option>
{/each}
</select>
<div>
<button
type="button"
on:click={() => {
if (selectedModelId && !permissions.model.model_ids.includes(selectedModelId)) {
permissions.model.model_ids = [...permissions.model.model_ids, selectedModelId];
selectedModelId = '';
}
}}
>
<Plus className="size-3.5" strokeWidth="2" />
</button>
</div>
</div>
</div>
{/if}
<div class=" space-y-1 mb-3">
<div class="">
<div class="flex justify-between items-center text-xs">
<div class=" text-xs font-medium">{$i18n.t('Default Model')}</div>
</div>
</div>
<div class="flex-1 mr-2">
<select
class="w-full bg-transparent outline-hidden py-0.5 text-sm"
bind:value={permissions.model.default_id}
placeholder={$i18n.t('Select a model')}
>
<option value="" disabled selected>{$i18n.t('Select a model')}</option>
{#each permissions.model.filter ? $models.filter( (model) => filterModelIds.includes(model.id) ) : $models.filter((model) => model.id) as model}
<option value={model.id} class="bg-gray-100 dark:bg-gray-700">{model.name}</option>
{/each}
</select>
</div>
</div>
</div>
<hr class=" border-gray-100 dark:border-gray-850 my-2" /> -->
<div class="space-y-2">
<!-- {$i18n.t('Default Model')}
{$i18n.t('Model Filtering')}
{$i18n.t('Model Permissions')}
{$i18n.t('No model IDs')} -->
<div>
<div class=" mb-2 text-sm font-medium">{$i18n.t('Workspace Permissions')}</div>
<div class=" flex w-full justify-between my-2 pr-2">
<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 Access')}
</div>
<Switch bind:state={permissions.workspace.models} />
</div>
{#if defaultPermissions?.workspace?.models && !permissions.workspace.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>
<div class=" flex w-full justify-between my-2 pr-2">
<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 Access')}
</div>
<Switch bind:state={permissions.workspace.knowledge} />
</div>
{#if defaultPermissions?.workspace?.knowledge && !permissions.workspace.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>
<div class=" flex w-full justify-between my-2 pr-2">
<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 Access')}
</div>
<Switch bind:state={permissions.workspace.prompts} />
</div>
{#if defaultPermissions?.workspace?.prompts && !permissions.workspace.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>
<div class=" ">
<div class="flex flex-col w-full">
<Tooltip
className=" flex w-full justify-between my-2 pr-2"
className="flex w-full justify-between my-1"
content={$i18n.t(
'Warning: Enabling this will allow users to upload arbitrary code on the server.'
)}
@ -212,247 +144,499 @@
</div>
<Switch bind:state={permissions.workspace.tools} />
</Tooltip>
{#if defaultPermissions?.workspace?.tools && !permissions.workspace.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>
</div>
<hr class=" border-gray-100 dark:border-gray-850 my-2" />
<hr class=" border-gray-100 dark:border-gray-850" />
<div>
<div class=" mb-2 text-sm font-medium">{$i18n.t('Sharing Permissions')}</div>
<div class=" flex w-full justify-between my-2 pr-2">
<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>
<div class=" flex w-full justify-between my-2 pr-2">
<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>
<div class=" flex w-full justify-between my-2 pr-2">
<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>
<div class=" flex w-full justify-between my-2 pr-2">
<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>
<hr class=" border-gray-100 dark:border-gray-850 my-2" />
<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>
</div>
<hr class=" border-gray-100 dark:border-gray-850" />
<div>
<div class=" mb-2 text-sm font-medium">{$i18n.t('Chat Permissions')}</div>
<div class=" flex w-full justify-between my-2 pr-2">
<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('Allow File Upload')}
</div>
<Switch bind:state={permissions.chat.file_upload} />
</div>
{#if defaultPermissions?.chat?.file_upload && !permissions.chat.file_upload}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<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('Allow Chat Controls')}
</div>
<Switch bind:state={permissions.chat.controls} />
</div>
{#if defaultPermissions?.chat?.controls && !permissions.chat.controls}
<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 permissions.chat.controls}
<div class=" flex w-full justify-between my-2 pr-2">
<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('Allow Chat Valves')}
</div>
<Switch bind:state={permissions.chat.valves} />
</div>
{#if defaultPermissions?.chat?.valves && !permissions.chat.valves}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<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('Allow Chat System Prompt')}
</div>
<Switch bind:state={permissions.chat.system_prompt} />
</div>
{#if defaultPermissions?.chat?.system_prompt && !permissions.chat.system_prompt}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<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('Allow Chat Params')}
</div>
<Switch bind:state={permissions.chat.params} />
</div>
{#if defaultPermissions?.chat?.params && !permissions.chat.params}
<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 w-full justify-between my-2 pr-2">
<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('Allow Chat Edit')}
</div>
<Switch bind:state={permissions.chat.edit} />
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Chat Delete')}
{#if defaultPermissions?.chat?.edit && !permissions.chat.edit}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
<Switch bind:state={permissions.chat.delete} />
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Delete Messages')}
</div>
<Switch bind:state={permissions.chat.delete_message} />
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Continue Response')}
</div>
<Switch bind:state={permissions.chat.continue_response} />
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Regenerate Response')}
</div>
<Switch bind:state={permissions.chat.regenerate_response} />
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Rate Response')}
</div>
<Switch bind:state={permissions.chat.rate_response} />
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Chat Share')}
</div>
<Switch bind:state={permissions.chat.share} />
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Chat Export')}
</div>
<Switch bind:state={permissions.chat.export} />
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Speech to Text')}
</div>
<Switch bind:state={permissions.chat.stt} />
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Text to Speech')}
</div>
<Switch bind:state={permissions.chat.tts} />
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Call')}
</div>
<Switch bind:state={permissions.chat.call} />
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Multiple Models in Chat')}
</div>
<Switch bind:state={permissions.chat.multiple_models} />
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Temporary Chat')}
</div>
<Switch bind:state={permissions.chat.temporary} />
</div>
{#if permissions.chat.temporary}
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Enforce Temporary Chat')}
</div>
<Switch bind:state={permissions.chat.temporary_enforced} />
</div>
{/if}
</div>
<hr class=" border-gray-100 dark:border-gray-850 my-2" />
<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('Allow Chat Delete')}
</div>
<Switch bind:state={permissions.chat.delete} />
</div>
{#if defaultPermissions?.chat?.delete && !permissions.chat.delete}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
<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('Allow Delete Messages')}
</div>
<Switch bind:state={permissions.chat.delete_message} />
</div>
{#if defaultPermissions?.chat?.delete_message && !permissions.chat.delete_message}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
<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('Allow Continue Response')}
</div>
<Switch bind:state={permissions.chat.continue_response} />
</div>
{#if defaultPermissions?.chat?.continue_response && !permissions.chat.continue_response}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
<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('Allow Regenerate Response')}
</div>
<Switch bind:state={permissions.chat.regenerate_response} />
</div>
{#if defaultPermissions?.chat?.regenerate_response && !permissions.chat.regenerate_response}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
<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('Allow Rate Response')}
</div>
<Switch bind:state={permissions.chat.rate_response} />
</div>
{#if defaultPermissions?.chat?.rate_response && !permissions.chat.rate_response}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
<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('Allow Chat Share')}
</div>
<Switch bind:state={permissions.chat.share} />
</div>
{#if defaultPermissions?.chat?.share && !permissions.chat.share}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
<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('Allow Chat Export')}
</div>
<Switch bind:state={permissions.chat.export} />
</div>
{#if defaultPermissions?.chat?.export && !permissions.chat.export}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
<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('Allow Speech to Text')}
</div>
<Switch bind:state={permissions.chat.stt} />
</div>
{#if defaultPermissions?.chat?.stt && !permissions.chat.stt}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
<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('Allow Text to Speech')}
</div>
<Switch bind:state={permissions.chat.tts} />
</div>
{#if defaultPermissions?.chat?.tts && !permissions.chat.tts}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
<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('Allow Call')}
</div>
<Switch bind:state={permissions.chat.call} />
</div>
{#if defaultPermissions?.chat?.call && !permissions.chat.call}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
<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('Allow Multiple Models in Chat')}
</div>
<Switch bind:state={permissions.chat.multiple_models} />
</div>
{#if defaultPermissions?.chat?.multiple_models && !permissions.chat.multiple_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>
<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('Allow Temporary Chat')}
</div>
<Switch bind:state={permissions.chat.temporary} />
</div>
{#if defaultPermissions?.chat?.temporary && !permissions.chat.temporary}
<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 permissions.chat.temporary}
<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('Enforce Temporary Chat')}
</div>
<Switch bind:state={permissions.chat.temporary_enforced} />
</div>
{#if defaultPermissions?.chat?.temporary_enforced && !permissions.chat.temporary_enforced}
<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" />
<div>
<div class=" mb-2 text-sm font-medium">{$i18n.t('Features Permissions')}</div>
<div class=" flex w-full justify-between my-2 pr-2">
<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('Direct Tool Servers')}
</div>
<Switch bind:state={permissions.features.direct_tool_servers} />
</div>
{#if defaultPermissions?.features?.direct_tool_servers && !permissions.features.direct_tool_servers}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<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('Web Search')}
</div>
<Switch bind:state={permissions.features.web_search} />
</div>
{#if defaultPermissions?.features?.web_search && !permissions.features.web_search}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<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('Image Generation')}
</div>
<Switch bind:state={permissions.features.image_generation} />
</div>
{#if defaultPermissions?.features?.image_generation && !permissions.features.image_generation}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<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('Code Interpreter')}
</div>
<Switch bind:state={permissions.features.code_interpreter} />
</div>
{#if defaultPermissions?.features?.code_interpreter && !permissions.features.code_interpreter}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<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')}
</div>
<Switch bind:state={permissions.features.notes} />
</div>
{#if defaultPermissions?.features?.notes && !permissions.features.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>
</div>
</div>

View File

@ -75,10 +75,10 @@
/>
</div>
<div class="flex w-full items-center justify-between">
<div class="flex w-full items-center justify-between overflow-hidden">
<Tooltip content={user.email} placement="top-start">
<div class="flex">
<div class=" font-medium self-center">{user.name}</div>
<div class=" font-medium self-center truncate">{user.name}</div>
</div>
</Tooltip>

View File

@ -339,30 +339,6 @@
</div>
</th>
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none"
on:click={() => setSortKey('oauth_sub')}
>
<div class="flex gap-1.5 items-center">
{$i18n.t('OAuth ID')}
{#if orderBy === 'oauth_sub'}
<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" />
</tr>
</thead>
@ -383,10 +359,10 @@
/>
</button>
</td>
<td class="px-3 py-1 font-medium text-gray-900 dark:text-white w-max">
<div class="flex flex-row w-max">
<td class="px-3 py-1 font-medium text-gray-900 dark:text-white max-w-48">
<div class="flex items-center">
<img
class=" rounded-full w-6 h-6 object-cover mr-2.5"
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:')
@ -395,7 +371,7 @@
alt="user"
/>
<div class=" font-medium self-center">{user.name}</div>
<div class="font-medium truncate">{user.name}</div>
</div>
</td>
<td class=" px-3 py-1"> {user.email} </td>
@ -408,8 +384,6 @@
{dayjs(user.created_at * 1000).format('LL')}
</td>
<td class=" px-3 py-1"> {user.oauth_sub ?? ''} </td>
<td class="px-3 py-1 text-right">
<div class="flex justify-end w-full">
{#if $config.features.enable_admin_chat_access && user.role !== 'admin'}

View File

@ -12,6 +12,7 @@
import localizedFormat from 'dayjs/plugin/localizedFormat';
import XMark from '$lib/components/icons/XMark.svelte';
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
import UserProfileImage from '$lib/components/chat/Settings/Account/UserProfileImage.svelte';
const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
@ -83,27 +84,47 @@
submitHandler();
}}
>
<div class=" flex items-center rounded-md px-5 py-2 w-full">
<div class=" self-center mr-5">
<img
src={selectedUser.profile_image_url}
class=" max-w-[55px] object-cover rounded-full"
alt="User profile"
/>
<div class=" px-5 pt-3 pb-5 w-full">
<div class="flex self-center w-full">
<div class=" self-start h-full mr-6">
<UserProfileImage bind:profileImageUrl={_user.profile_image_url} user={_user} />
</div>
<div>
<div class=" self-center capitalize font-semibold">{selectedUser.name}</div>
<div class=" flex-1">
<div class="overflow-hidden w-ful mb-2">
<div class=" self-center capitalize font-medium truncate">
{selectedUser.name}
</div>
<div class="text-xs text-gray-500">
{$i18n.t('Created at')}
{dayjs(selectedUser.created_at * 1000).format('LL')}
</div>
</div>
</div>
<div class=" px-5 pt-3 pb-5">
<div class=" flex flex-col space-y-1.5">
{#if (userGroups ?? []).length > 0}
<div class="flex flex-col w-full text-sm">
<div class="mb-1 text-xs text-gray-500">{$i18n.t('User Groups')}</div>
<div class="flex flex-wrap gap-1 my-0.5 -mx-1">
{#each userGroups as userGroup}
<span
class="px-1.5 py-0.5 rounded-xl bg-gray-100 dark:bg-gray-850 text-xs"
>
<a
href={'/admin/users/groups?id=' + userGroup.id}
on:click|preventDefault={() =>
goto('/admin/users/groups?id=' + userGroup.id)}
>
{userGroup.name}
</a>
</span>
{/each}
</div>
</div>
{/if}
<div class="flex flex-col w-full">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Role')}</div>
@ -121,45 +142,6 @@
</div>
</div>
{#if userGroups}
<div class="flex flex-col w-full text-sm">
<div class="mb-1 text-xs text-gray-500">{$i18n.t('User Groups')}</div>
{#if userGroups.length}
<div class="flex flex-wrap gap-1 my-0.5 -mx-1">
{#each userGroups as userGroup}
<span class="px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-850 text-xs">
<a
href={'/admin/users/groups?id=' + userGroup.id}
on:click|preventDefault={() =>
goto('/admin/users/groups?id=' + userGroup.id)}
>
{userGroup.name}
</a>
</span>
{/each}
</div>
{:else}
<span>-</span>
{/if}
</div>
{/if}
<div class="flex flex-col w-full">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Email')}</div>
<div class="flex-1">
<input
class="w-full text-sm bg-transparent disabled:text-gray-500 dark:disabled:text-gray-500 outline-hidden"
type="email"
bind:value={_user.email}
placeholder={$i18n.t('Enter Your Email')}
autocomplete="off"
required
/>
</div>
</div>
<div class="flex flex-col w-full">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Name')}</div>
@ -175,6 +157,31 @@
</div>
</div>
<div class="flex flex-col w-full">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Email')}</div>
<div class="flex-1">
<input
class="w-full text-sm bg-transparent disabled:text-gray-500 dark:disabled:text-gray-500 outline-hidden"
type="email"
bind:value={_user.email}
placeholder={$i18n.t('Enter Your Email')}
autocomplete="off"
required
/>
</div>
</div>
{#if _user?.oauth_sub}
<div class="flex flex-col w-full">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('OAuth ID')}</div>
<div class="flex-1 text-sm break-all mb-1">
{_user.oauth_sub ?? ''}
</div>
</div>
{/if}
<div class="flex flex-col w-full">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('New Password')}</div>
@ -190,6 +197,8 @@
</div>
</div>
</div>
</div>
</div>
<div class="flex justify-end pt-3 text-sm font-medium">
<button

View File

@ -107,7 +107,9 @@
bind:query
bind:orderBy
bind:direction
title={$i18n.t("{{user}}'s Chats", { user: user.name })}
title={$i18n.t("{{user}}'s Chats", {
user: user.name.length > 32 ? `${user.name.slice(0, 32)}...` : user.name
})}
emptyPlaceholder={$i18n.t('No chats found for this user.')}
shareUrl={true}
{chatList}

View File

@ -20,12 +20,14 @@
let scrollEnd = true;
let messagesContainerElement = null;
let chatInputElement = null;
let top = false;
let channel = null;
let messages = null;
let replyToMessage = null;
let threadId = null;
let typingUsers = [];
@ -141,16 +143,20 @@
return;
}
const res = await sendMessage(localStorage.token, id, { content: content, data: data }).catch(
(error) => {
const res = await sendMessage(localStorage.token, id, {
content: content,
data: data,
reply_to_id: replyToMessage?.id ?? null
}).catch((error) => {
toast.error(`${error}`);
return null;
}
);
});
if (res) {
messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight;
}
replyToMessage = null;
};
const onChange = async () => {
@ -222,8 +228,14 @@
{#key id}
<Messages
{channel}
{messages}
{top}
{messages}
{replyToMessage}
onReply={async (message) => {
replyToMessage = message;
await tick();
chatInputElement?.focus();
}}
onThread={(id) => {
threadId = id;
}}
@ -250,6 +262,8 @@
<div class=" pb-[1rem] px-2.5">
<MessageInput
id="root"
bind:chatInputElement
bind:replyToMessage
{typingUsers}
userSuggestions={true}
channelSuggestions={true}

View File

@ -23,20 +23,23 @@
import { getSessionUser } from '$lib/apis/auths';
import { uploadFile } from '$lib/apis/files';
import { WEBUI_API_BASE_URL } from '$lib/constants';
import { getSuggestionRenderer } from '../common/RichTextInput/suggestions';
import CommandSuggestionList from '../chat/MessageInput/CommandSuggestionList.svelte';
import InputMenu from './MessageInput/InputMenu.svelte';
import Tooltip from '../common/Tooltip.svelte';
import RichTextInput from '../common/RichTextInput.svelte';
import VoiceRecording from '../chat/MessageInput/VoiceRecording.svelte';
import InputMenu from './MessageInput/InputMenu.svelte';
import { uploadFile } from '$lib/apis/files';
import { WEBUI_API_BASE_URL } from '$lib/constants';
import FileItem from '../common/FileItem.svelte';
import Image from '../common/Image.svelte';
import FilesOverlay from '../chat/MessageInput/FilesOverlay.svelte';
import InputVariablesModal from '../chat/MessageInput/InputVariablesModal.svelte';
import { getSuggestionRenderer } from '../common/RichTextInput/suggestions';
import CommandSuggestionList from '../chat/MessageInput/CommandSuggestionList.svelte';
import MentionList from './MessageInput/MentionList.svelte';
import Skeleton from '../chat/Messages/Skeleton.svelte';
import XMark from '../icons/XMark.svelte';
export let placeholder = $i18n.t('Type here...');
@ -60,6 +63,8 @@
export let userSuggestions = false;
export let channelSuggestions = false;
export let replyToMessage = null;
export let typingUsersClassName = 'from-white dark:from-gray-900';
let loaded = false;
@ -773,6 +778,32 @@
class="flex-1 flex flex-col relative w-full shadow-lg rounded-3xl border border-gray-50 dark:border-gray-850 hover:border-gray-100 focus-within:border-gray-100 hover:dark:border-gray-800 focus-within:dark:border-gray-800 transition px-1 bg-white/90 dark:bg-gray-400/5 dark:text-gray-100"
dir={$settings?.chatDirection ?? 'auto'}
>
{#if replyToMessage !== null}
<div class="px-3 pt-3 text-left w-full flex flex-col z-10">
<div class="flex items-center justify-between w-full">
<div class="pl-[1px] flex items-center gap-2 text-sm">
<div class="translate-y-[0.5px]">
<span class=""
>{$i18n.t('Replying to {{NAME}}', {
NAME: replyToMessage?.meta?.model_name ?? replyToMessage.user.name
})}</span
>
</div>
</div>
<div>
<button
class="flex items-center dark:text-gray-500"
on:click={() => {
replyToMessage = null;
}}
>
<XMark />
</button>
</div>
</div>
</div>
{/if}
{#if files.length > 0}
<div class="mx-2 mt-2.5 -mb-1 flex flex-wrap gap-2">
{#each files as file, fileIdx}
@ -890,6 +921,7 @@
if (e.key === 'Escape') {
console.info('Escape');
replyToMessage = null;
}
}}
on:paste={async (e) => {

View File

@ -23,10 +23,12 @@
export let id = null;
export let channel = null;
export let messages = [];
export let replyToMessage = null;
export let top = false;
export let thread = false;
export let onLoad: Function = () => {};
export let onReply: Function = () => {};
export let onThread: Function = () => {};
let messagesLoading = false;
@ -94,10 +96,12 @@
<Message
{message}
{thread}
replyToMessage={replyToMessage?.id === message.id}
disabled={!channel?.write_access}
showUserProfile={messageIdx === 0 ||
messageList.at(messageIdx - 1)?.user_id !== message.user_id ||
messageList.at(messageIdx - 1)?.meta?.model_id !== message?.meta?.model_id}
messageList.at(messageIdx - 1)?.meta?.model_id !== message?.meta?.model_id ||
message?.reply_to_message}
onDelete={() => {
messages = messages.filter((m) => m.id !== message.id);
@ -123,6 +127,9 @@
return null;
});
}}
onReply={(message) => {
onReply(message);
}}
onThread={(id) => {
onThread(id);
}}

View File

@ -13,8 +13,9 @@
import { getContext, onMount } from 'svelte';
const i18n = getContext<Writable<i18nType>>('i18n');
import { settings, user, shortCodesToEmojis } from '$lib/stores';
import { formatDate } from '$lib/utils';
import { settings, user, shortCodesToEmojis } from '$lib/stores';
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
import Markdown from '$lib/components/chat/Messages/Markdown.svelte';
@ -32,18 +33,20 @@
import FaceSmile from '$lib/components/icons/FaceSmile.svelte';
import EmojiPicker from '$lib/components/common/EmojiPicker.svelte';
import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
import { formatDate } from '$lib/utils';
import Emoji from '$lib/components/common/Emoji.svelte';
import { t } from 'i18next';
import Skeleton from '$lib/components/chat/Messages/Skeleton.svelte';
import ArrowUpLeftAlt from '$lib/components/icons/ArrowUpLeftAlt.svelte';
export let message;
export let showUserProfile = true;
export let thread = false;
export let replyToMessage = false;
export let disabled = false;
export let onDelete: Function = () => {};
export let onEdit: Function = () => {};
export let onReply: Function = () => {};
export let onThread: Function = () => {};
export let onReaction: Function = () => {};
@ -65,9 +68,15 @@
{#if message}
<div
id="message-{message.id}"
class="flex flex-col justify-between px-5 {showUserProfile
? 'pt-1.5 pb-0.5'
: ''} w-full max-w-full mx-auto group hover:bg-gray-300/5 dark:hover:bg-gray-700/5 transition relative"
: ''} w-full max-w-full mx-auto group hover:bg-gray-300/5 dark:hover:bg-gray-700/5 transition relative {replyToMessage
? 'border-l-4 border-blue-500 bg-blue-100/10 dark:bg-blue-100/5 pl-4'
: ''} {(message?.reply_to_message?.meta?.model_id ?? message?.reply_to_message?.user_id) ===
$user?.id
? 'border-l-4 border-orange-500 bg-orange-100/10 dark:bg-orange-100/5 pl-4'
: ''}"
>
{#if !edit && !disabled}
<div
@ -95,6 +104,17 @@
</Tooltip>
</EmojiPicker>
<Tooltip content={$i18n.t('Reply')}>
<button
class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-0.5"
on:click={() => {
onReply(message);
}}
>
<ArrowUpLeftAlt className="size-5" />
</button>
</Tooltip>
{#if !thread}
<Tooltip content={$i18n.t('Reply in Thread')}>
<button
@ -134,6 +154,56 @@
</div>
{/if}
{#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"
></div>
<button
class="ml-12 flex items-center space-x-2 relative z-0"
on:click={() => {
const messageElement = document.getElementById(
`message-${message.reply_to_message.id}`
);
if (messageElement) {
messageElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
messageElement.classList.add('highlight');
setTimeout(() => {
messageElement.classList.remove('highlight');
}, 2000);
return;
}
}}
>
{#if message?.reply_to_message?.meta?.model_id}
<img
src={`${WEBUI_API_BASE_URL}/models/model/profile/image?id=${message.reply_to_message.meta.model_id}`}
alt={message.reply_to_message.meta.model_name ??
message.reply_to_message.meta.model_id}
class="size-4 ml-0.5 rounded-full object-cover"
/>
{:else}
<img
src={message.reply_to_message.user?.profile_image_url ??
`${WEBUI_BASE_URL}/static/favicon.png`}
alt={message.reply_to_message.user?.name ?? $i18n.t('Unknown User')}
class="size-4 ml-0.5 rounded-full object-cover"
/>
{/if}
<div class="shrink-0">
{message?.reply_to_message.meta?.model_name ??
message?.reply_to_message.user?.name ??
$i18n.t('Unknown User')}
</div>
<div class="italic text-sm text-gray-500 dark:text-gray-400 line-clamp-1 w-full flex-1">
<Markdown id={`${message.id}-reply-to`} content={message?.reply_to_message?.content} />
</div>
</button>
</div>
{/if}
<div
class=" flex w-full message-{message.id}"
id="message-{message.id}"
@ -151,7 +221,7 @@
<ProfilePreview user={message.user}>
<ProfileImage
src={message.user?.profile_image_url ?? `${WEBUI_BASE_URL}/static/favicon.png`}
className={'size-8 translate-y-1 ml-0.5'}
className={'size-8 ml-0.5'}
/>
</ProfilePreview>
{/if}
@ -348,3 +418,18 @@
</div>
</div>
{/if}
<style>
.highlight {
animation: highlightAnimation 2s ease-in-out;
}
@keyframes highlightAnimation {
0% {
background-color: rgba(0, 60, 255, 0.1);
}
100% {
background-color: transparent;
}
}
</style>

View File

@ -22,11 +22,14 @@
let messages = null;
let top = false;
let messagesContainerElement = null;
let chatInputElement = null;
let replyToMessage = null;
let typingUsers = [];
let typingUsersTimeout = {};
let messagesContainerElement = null;
$: if (threadId) {
initHandler();
}
@ -128,12 +131,15 @@
const res = await sendMessage(localStorage.token, channel.id, {
parent_id: threadId,
reply_to_id: replyToMessage?.id ?? null,
content: content,
data: data
}).catch((error) => {
toast.error(`${error}`);
return null;
});
replyToMessage = null;
};
const onChange = async () => {
@ -180,9 +186,16 @@
<Messages
id={threadId}
{channel}
{messages}
{top}
{messages}
{replyToMessage}
thread={true}
onReply={async (message) => {
replyToMessage = message;
await tick();
chatInputElement?.focus();
}}
onLoad={async () => {
const newMessages = await getChannelThreadMessages(
localStorage.token,
@ -207,6 +220,8 @@
<div class=" pb-[1rem] px-2.5 w-full">
<MessageInput
bind:replyToMessage
bind:chatInputElement
id={threadId}
disabled={!channel?.write_access}
placeholder={!channel?.write_access

View File

@ -38,7 +38,8 @@
toolServers,
functions,
selectedFolder,
pinnedChats
pinnedChats,
showEmbeds
} from '$lib/stores';
import {
convertMessagesToHistory,
@ -362,6 +363,8 @@
message.content = data.content;
} else if (type === 'chat:message:files' || type === 'files') {
message.files = data.files;
} else if (type === 'chat:message:embeds' || type === 'embeds') {
message.embeds = data.embeds;
} else if (type === 'chat:message:error') {
message.error = data.error;
} else if (type === 'chat:message:follow_ups') {
@ -562,6 +565,7 @@
showCallOverlay.set(false);
showOverview.set(false);
showArtifacts.set(false);
showEmbeds.set(false);
}
});
@ -2230,7 +2234,7 @@
<svelte:head>
<title>
{$chatTitle
{$settings.showChatTitleInTab !== false && $chatTitle
? `${$chatTitle.length > 30 ? `${$chatTitle.slice(0, 30)}...` : $chatTitle} • ${$WEBUI_NAME}`
: `${$WEBUI_NAME}`}
</title>

View File

@ -4,7 +4,14 @@
import { Pane, PaneResizer } from 'paneforge';
import { onDestroy, onMount, tick } from 'svelte';
import { mobile, showControls, showCallOverlay, showOverview, showArtifacts } from '$lib/stores';
import {
mobile,
showControls,
showCallOverlay,
showOverview,
showArtifacts,
showEmbeds
} from '$lib/stores';
import Modal from '../common/Modal.svelte';
import Controls from './Controls/Controls.svelte';
@ -13,6 +20,7 @@
import Overview from './Overview.svelte';
import EllipsisVertical from '../icons/EllipsisVertical.svelte';
import Artifacts from './Artifacts.svelte';
import Embeds from './ChatControls/Embeds.svelte';
export let history;
export let models = [];
@ -134,6 +142,7 @@
showControls.set(false);
showOverview.set(false);
showArtifacts.set(false);
showEmbeds.set(false);
if ($showCallOverlay) {
showCallOverlay.set(false);
@ -155,9 +164,9 @@
}}
>
<div
class=" {$showCallOverlay || $showOverview || $showArtifacts
class=" {$showCallOverlay || $showOverview || $showArtifacts || $showEmbeds
? ' h-screen w-full'
: 'px-6 py-4'} h-full"
: 'px-4 py-3'} h-full"
>
{#if $showCallOverlay}
<div
@ -175,6 +184,8 @@
}}
/>
</div>
{:else if $showEmbeds}
<Embeds />
{:else if $showArtifacts}
<Artifacts {history} />
{:else if $showOverview}
@ -241,9 +252,9 @@
{#if $showControls}
<div class="flex max-h-full min-h-full">
<div
class="w-full {($showOverview || $showArtifacts) && !$showCallOverlay
class="w-full {($showOverview || $showArtifacts || $showEmbeds) && !$showCallOverlay
? ' '
: 'px-4 py-4 bg-white dark:shadow-lg dark:bg-gray-850 '} z-40 pointer-events-auto overflow-y-auto scrollbar-hidden"
: 'px-4 py-3 bg-white dark:shadow-lg dark:bg-gray-850 '} z-40 pointer-events-auto overflow-y-auto scrollbar-hidden"
id="controls-container"
>
{#if $showCallOverlay}
@ -260,6 +271,8 @@
}}
/>
</div>
{:else if $showEmbeds}
<Embeds overlay={dragged} />
{:else if $showArtifacts}
<Artifacts {history} overlay={dragged} />
{:else if $showOverview}

View File

@ -0,0 +1,41 @@
<script>
import { embed, showControls, showEmbeds } from '$lib/stores';
import FullHeightIframe from '$lib/components/common/FullHeightIframe.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
export let overlay = false;
</script>
{#if $embed}
<div class="h-full w-full">
<div
class="pointer-events-auto z-20 flex justify-between items-center py-3 px-2 font-primar text-gray-900 dark:text-white"
>
<div class="flex-1 flex items-center justify-between pl-2">
<div class="flex items-center space-x-2">
{$embed?.title ?? 'Embedded Content'}
</div>
</div>
<button
class="self-center pointer-events-auto p-1 rounded-full bg-white dark:bg-gray-850"
on:click={() => {
showControls.set(false);
showEmbeds.set(false);
embed.set(null);
}}
>
<XMark className="size-3.5 text-gray-900 dark:text-white" />
</button>
</div>
<div class=" w-full h-full relative">
{#if overlay}
<div class=" absolute top-0 left-0 right-0 bottom-0 z-10"></div>
{/if}
<FullHeightIframe src={$embed?.url} iframeClassName="w-full h-full" />
</div>
</div>
{/if}

View File

@ -78,6 +78,8 @@
import { getSuggestionRenderer } from '../common/RichTextInput/suggestions';
import CommandSuggestionList from './MessageInput/CommandSuggestionList.svelte';
import Knobs from '../icons/Knobs.svelte';
import ValvesModal from '../workspace/common/ValvesModal.svelte';
const i18n = getContext('i18n');
@ -112,6 +114,10 @@
let inputVariables = {};
let inputVariableValues = {};
let showValvesModal = false;
let selectedValvesType = 'tool'; // 'tool' or 'function'
let selectedValvesItemId = null;
$: onChange({
prompt,
files: files
@ -932,6 +938,16 @@
onSave={inputVariablesModalCallback}
/>
<ValvesModal
bind:show={showValvesModal}
userValves={true}
type={selectedValvesType}
id={selectedValvesItemId ?? null}
on:save={async () => {
await tick();
}}
/>
{#if loaded}
<div class="w-full font-primary">
<div class=" mx-auto inset-x-0 bg-transparent flex justify-center">
@ -1449,6 +1465,12 @@
bind:webSearchEnabled
bind:imageGenerationEnabled
bind:codeInterpreterEnabled
onShowValves={(e) => {
const { type, id } = e;
selectedValvesType = type;
selectedValvesItemId = id;
showValvesModal = true;
}}
onClose={async () => {
await tick();
@ -1465,6 +1487,24 @@
</IntegrationsMenu>
{/if}
{#if selectedModelIds.length === 1 && $models.find((m) => m.id === selectedModelIds[0])?.has_user_valves}
<div class="ml-1 flex gap-1.5">
<Tooltip content={$i18n.t('Valves')} placement="top">
<button
id="model-valves-button"
class="bg-transparent hover:bg-gray-100 text-gray-700 dark:text-white dark:hover:bg-gray-800 rounded-full size-8 flex justify-center items-center outline-hidden focus:outline-hidden"
on:click={() => {
selectedValvesType = 'function';
selectedValvesItemId = selectedModelIds[0]?.split('.')[0];
showValvesModal = true;
}}
>
<Knobs className="size-4" strokeWidth="1.5" />
</button>
</Tooltip>
</div>
{/if}
<div class="ml-1 flex gap-1.5">
{#if (selectedToolIds ?? []).length > 0}
<Tooltip
@ -1500,11 +1540,11 @@
);
}}
type="button"
class="group p-[7px] flex gap-1.5 items-center text-sm rounded-full transition-colors duration-300 focus:outline-hidden max-w-full overflow-hidden hover:bg-gray-50 dark:hover:bg-gray-800 {selectedFilterIds.includes(
class="group p-[7px] flex gap-1.5 items-center text-sm rounded-full transition-colors duration-300 focus:outline-hidden max-w-full overflow-hidden {selectedFilterIds.includes(
filterId
)
? 'text-sky-500 dark:text-sky-300 bg-sky-50 dark:bg-sky-400/10 border border-sky-200/40 dark:border-sky-500/20'
: 'bg-transparent text-gray-600 dark:text-gray-300 '} capitalize"
? 'text-sky-500 dark:text-sky-300 bg-sky-50 hover:bg-sky-100 dark:bg-sky-400/10 dark:hover:bg-sky-600/10 border border-sky-200/40 dark:border-sky-500/20'
: 'bg-transparent text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 '} capitalize"
>
{#if filter?.icon}
<div class="size-4 items-center flex justify-center">
@ -1533,10 +1573,10 @@
<button
on:click|preventDefault={() => (webSearchEnabled = !webSearchEnabled)}
type="button"
class="group p-[7px] flex gap-1.5 items-center text-sm rounded-full transition-colors duration-300 focus:outline-hidden max-w-full overflow-hidden hover:bg-gray-50 dark:hover:bg-gray-800 {webSearchEnabled ||
class="group p-[7px] flex gap-1.5 items-center text-sm rounded-full transition-colors duration-300 focus:outline-hidden max-w-full overflow-hidden {webSearchEnabled ||
($settings?.webSearch ?? false) === 'always'
? ' text-sky-500 dark:text-sky-300 bg-sky-50 dark:bg-sky-400/10 border border-sky-200/40 dark:border-sky-500/20'
: 'bg-transparent text-gray-600 dark:text-gray-300 '}"
? ' text-sky-500 dark:text-sky-300 bg-sky-50 hover:bg-sky-100 dark:bg-sky-400/10 dark:hover:bg-sky-600/10 border border-sky-200/40 dark:border-sky-500/20'
: 'bg-transparent text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 '}"
>
<GlobeAlt className="size-4" strokeWidth="1.75" />
<div class="hidden group-hover:block">
@ -1552,9 +1592,9 @@
on:click|preventDefault={() =>
(imageGenerationEnabled = !imageGenerationEnabled)}
type="button"
class="group p-[7px] flex gap-1.5 items-center text-sm rounded-full transition-colors duration-300 focus:outline-hidden max-w-full overflow-hidden hover:bg-gray-50 dark:hover:bg-gray-800 {imageGenerationEnabled
? ' text-sky-500 dark:text-sky-300 bg-sky-50 dark:bg-sky-400/10 border border-sky-200/40 dark:border-sky-500/20'
: 'bg-transparent text-gray-600 dark:text-gray-300 '}"
class="group p-[7px] flex gap-1.5 items-center text-sm rounded-full transition-colors duration-300 focus:outline-hidden max-w-full overflow-hidden {imageGenerationEnabled
? ' text-sky-500 dark:text-sky-300 bg-sky-50 hover:bg-sky-100 dark:bg-sky-400/10 dark:hover:bg-sky-700/10 border border-sky-200/40 dark:border-sky-500/20'
: 'bg-transparent text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 '}"
>
<Photo className="size-4" strokeWidth="1.75" />
<div class="hidden group-hover:block">
@ -1574,9 +1614,9 @@
on:click|preventDefault={() =>
(codeInterpreterEnabled = !codeInterpreterEnabled)}
type="button"
class=" group p-[7px] flex gap-1.5 items-center text-sm transition-colors duration-300 max-w-full overflow-hidden hover:bg-gray-50 dark:hover:bg-gray-800 {codeInterpreterEnabled
? ' text-sky-500 dark:text-sky-300 bg-sky-50 dark:bg-sky-400/10 border border-sky-200/40 dark:border-sky-500/20'
: 'bg-transparent text-gray-600 dark:text-gray-300 '} {($settings?.highContrastMode ??
class=" group p-[7px] flex gap-1.5 items-center text-sm transition-colors duration-300 max-w-full overflow-hidden {codeInterpreterEnabled
? ' text-sky-500 dark:text-sky-300 bg-sky-50 hover:bg-sky-100 dark:bg-sky-400/10 dark:hover:bg-sky-700/10 border border-sky-200/40 dark:border-sky-500/20'
: 'bg-transparent text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 '} {($settings?.highContrastMode ??
false)
? 'm-1'
: 'focus:outline-hidden rounded-full'}"

View File

@ -6,8 +6,10 @@
import { config, user, tools as _tools, mobile, settings, toolServers } from '$lib/stores';
import { getOAuthClientAuthorizationUrl } from '$lib/apis/configs';
import { getTools } from '$lib/apis/tools';
import Knobs from '$lib/components/icons/Knobs.svelte';
import Dropdown from '$lib/components/common/Dropdown.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Switch from '$lib/components/common/Switch.svelte';
@ -19,9 +21,6 @@
import Terminal from '$lib/components/icons/Terminal.svelte';
import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
import ChevronLeft from '$lib/components/icons/ChevronLeft.svelte';
import ValvesModal from '$lib/components/workspace/common/ValvesModal.svelte';
import { getOAuthClientAuthorizationUrl } from '$lib/apis/configs';
import { partition } from 'd3-hierarchy';
const i18n = getContext('i18n');
@ -41,16 +40,12 @@
export let showCodeInterpreterButton = false;
export let codeInterpreterEnabled = false;
export let onShowValves: Function;
export let onClose: Function;
let show = false;
let tab = '';
let showValvesModal = false;
let selectedValvesType = 'tool';
let selectedValvesItemId = null;
let tools = null;
$: if (show) {
@ -96,16 +91,6 @@
};
</script>
<ValvesModal
bind:show={showValvesModal}
userValves={true}
type={selectedValvesType}
id={selectedValvesItemId ?? null}
on:save={async () => {
await tick();
}}
/>
<Dropdown
bind:show
on:change={(e) => {
@ -192,6 +177,27 @@
</div>
</div>
{#if filter?.has_user_valves}
<div class=" shrink-0">
<Tooltip content={$i18n.t('Valves')}>
<button
class="self-center w-fit text-sm text-gray-600 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 transition rounded-full"
type="button"
on:click={(e) => {
e.stopPropagation();
e.preventDefault();
onShowValves({
type: 'function',
id: filter.id
});
}}
>
<Knobs />
</button>
</Tooltip>
</div>
{/if}
<div class=" shrink-0">
<Switch
state={selectedFilterIds.includes(filter.id)}
@ -340,7 +346,7 @@
>
{#if !(tools[toolId]?.authenticated ?? true)}
<!-- make it slighly darker and not clickable -->
<div class="absolute inset-0 opacity-50 rounded-xl cursor-not-allowed z-10" />
<div class="absolute inset-0 opacity-50 rounded-xl cursor-pointer z-10" />
{/if}
<div class="flex-1 truncate">
<div class="flex flex-1 gap-2 items-center">
@ -364,30 +370,13 @@
on:click={(e) => {
e.stopPropagation();
e.preventDefault();
selectedValvesType = 'tool';
selectedValvesItemId = toolId;
showValvesModal = true;
onShowValves({
type: 'tool',
id: toolId
});
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
/>
</svg>
<Knobs />
</button>
</Tooltip>
</div>

View File

@ -1,6 +1,7 @@
<script lang="ts">
import { getContext } from 'svelte';
import CitationModal from './Citations/CitationModal.svelte';
import { embed, showControls, showEmbeds } from '$lib/stores';
const i18n = getContext('i18n');
@ -21,9 +22,25 @@
export const showSourceModal = (sourceIdx) => {
if (citations[sourceIdx]) {
console.log('Showing citation modal for:', citations[sourceIdx]);
if (citations[sourceIdx]?.source?.embed_url) {
const embedUrl = citations[sourceIdx].source.embed_url;
if (embedUrl) {
showControls.set(true);
showEmbeds.set(true);
embed.set({
title: citations[sourceIdx]?.source?.name || 'Embedded Content',
url: embedUrl
});
} else {
selectedCitation = citations[sourceIdx];
showCitationModal = true;
}
} else {
selectedCitation = citations[sourceIdx];
showCitationModal = true;
}
}
};
function calculateShowRelevance(sources: any[]) {

View File

@ -10,6 +10,7 @@
settings,
showArtifacts,
showControls,
showEmbeds,
showOverview
} from '$lib/stores';
import FloatingButtons from '../ContentRenderer/FloatingButtons.svelte';
@ -194,6 +195,7 @@
await showControls.set(true);
await showArtifacts.set(true);
await showOverview.set(false);
await showEmbeds.set(false);
}}
/>
</div>
@ -209,7 +211,7 @@
: (selectedModels ?? []).length > 0
? selectedModels.at(0)
: model?.id}
messages={createMessagesList(history, id)}
messages={createMessagesList(history, messageId)}
onAdd={({ modelId, parentId, messages }) => {
console.log(modelId, parentId, messages);
onAddMessages({ modelId, parentId, messages });

View File

@ -53,6 +53,7 @@
import { flyAndScale } from '$lib/utils/transitions';
import RegenerateMenu from './ResponseMessage/RegenerateMenu.svelte';
import StatusHistory from './ResponseMessage/StatusHistory.svelte';
import FullHeightIframe from '$lib/components/common/FullHeightIframe.svelte';
interface MessageType {
id: string;
@ -676,6 +677,22 @@
</div>
{/if}
{#if message?.embeds && message.embeds.length > 0}
<div class="my-1 w-full flex overflow-x-auto gap-2 flex-wrap">
{#each message.embeds as embed, idx}
<div class="my-2 w-full" id={`${message.id}-embeds-${idx}`}>
<FullHeightIframe
src={embed}
allowScripts={true}
allowForms={true}
allowSameOrigin={true}
allowPopups={true}
/>
</div>
{/each}
</div>
{/if}
{#if edit === true}
<div class="w-full bg-gray-50 dark:bg-gray-800 rounded-3xl px-5 py-3 my-2">
<textarea

View File

@ -81,7 +81,7 @@
};
const editMessageConfirmHandler = async (submit = true) => {
if (!editedContent && editedFiles.length === 0) {
if (!editedContent && (editedFiles ?? []).length === 0) {
toast.error($i18n.t('Please enter a message or attach a file.'));
return;
}

View File

@ -125,7 +125,7 @@
{#if showSetDefault}
<div
class="absolute text-left mt-[1px] ml-1 text-[0.7rem] text-gray-600 dark:text-gray-400 font-primary"
class="relative text-left mt-[1px] ml-1 text-[0.7rem] text-gray-600 dark:text-gray-400 font-primary"
>
<button on:click={saveDefaultModel}> {$i18n.t('Set as default')}</button>
</div>

View File

@ -435,7 +435,7 @@
}}
>
<div
class="flex gap-1 w-fit text-center text-sm rounded-full bg-transparent px-1.5"
class="flex gap-1 w-fit text-center text-sm rounded-full bg-transparent px-1.5 whitespace-nowrap"
bind:this={tagsContainerElement}
>
{#if items.find((item) => item.model?.connection_type === 'local') || items.find((item) => item.model?.connection_type === 'external') || items.find((item) => item.model?.direct) || tags.length > 0}
@ -500,6 +500,7 @@
{/if}
{#each tags as tag}
<Tooltip content={tag}>
<button
class="min-w-fit outline-none px-1.5 py-0.5 {selectedTag === tag
? ''
@ -510,8 +511,9 @@
selectedTag = tag;
}}
>
{tag}
{tag.length > 16 ? `${tag.slice(0, 16)}...` : tag}
</button>
</Tooltip>
{/each}
</div>
</div>

View File

@ -37,6 +37,7 @@
import EllipsisHorizontal from '../icons/EllipsisHorizontal.svelte';
import ChatPlus from '../icons/ChatPlus.svelte';
import ChatCheck from '../icons/ChatCheck.svelte';
import Knobs from '../icons/Knobs.svelte';
const i18n = getContext('i18n');
@ -210,7 +211,7 @@
aria-label="Controls"
>
<div class=" m-auto self-center">
<AdjustmentsHorizontal className=" size-5" strokeWidth="1" />
<Knobs className=" size-5" strokeWidth="1" />
</div>
</button>
</Tooltip>
@ -255,7 +256,7 @@
<div class="absolute top-[100%] left-0 right-0 h-fit">
{#if !history.currentId && !$chatId && ($banners.length > 0 || ($config?.license_metadata?.type ?? null) === 'trial' || (($config?.license_metadata?.seats ?? null) !== null && $config?.user_count > $config?.license_metadata?.seats))}
<div class=" w-full z-30 mt-4">
<div class=" w-full z-30">
<div class=" flex flex-col gap-1 w-full">
{#if ($config?.license_metadata?.type ?? null) === 'trial'}
<Banner

View File

@ -160,7 +160,7 @@
</script>
<div class="w-full h-full relative">
<div class=" absolute z-50 w-full flex justify-between dark:text-gray-100 px-4 py-3.5">
<div class=" absolute z-50 w-full flex justify-between dark:text-gray-100 px-4 py-3">
<div class="flex items-center gap-2.5">
<button
class="self-center p-0.5"

View File

@ -1,15 +1,40 @@
<script>
import { getContext } from 'svelte';
import { getContext, onMount } from 'svelte';
const i18n = getContext('i18n');
import { fade } from 'svelte/transition';
import ChatList from './ChatList.svelte';
import FolderKnowledge from './FolderKnowledge.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import { getChatListByFolderId } from '$lib/apis/chats';
export let folder = null;
let selectedTab = 'chats';
let chats = null;
let page = 1;
const setChatList = async () => {
chats = null;
if (folder && folder.id) {
const res = await getChatListByFolderId(localStorage.token, folder.id, page);
if (res) {
chats = res;
} else {
chats = [];
}
} else {
chats = [];
}
};
$: if (folder) {
setChatList();
}
</script>
<div>
@ -45,7 +70,13 @@
{#if selectedTab === 'knowledge'}
<FolderKnowledge />
{:else if selectedTab === 'chats'}
<ChatList chats={folder?.items?.chats ?? []} />
{#if chats !== null}
<ChatList {chats} />
{:else}
<div class="py-10">
<Spinner />
</div>
{/if}
{/if}
</div>
</div>

View File

@ -115,7 +115,12 @@
</script>
{#if folder}
<FolderModal bind:show={showFolderModal} edit={true} {folder} onSubmit={updateHandler} />
<FolderModal
bind:show={showFolderModal}
edit={true}
folderId={folder.id}
onSubmit={updateHandler}
/>
<DeleteConfirmDialog
bind:show={showDeleteConfirm}

View File

@ -15,6 +15,8 @@
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
import Textarea from '$lib/components/common/Textarea.svelte';
import { getUserById } from '$lib/apis/users';
import User from '$lib/components/icons/User.svelte';
import UserProfileImage from './Account/UserProfileImage.svelte';
const i18n = getContext('i18n');
@ -118,68 +120,6 @@
<div id="tab-account" class="flex flex-col h-full justify-between text-sm">
<div class=" overflow-y-scroll max-h-[28rem] md:max-h-full">
<input
id="profile-image-input"
bind:this={profileImageInputElement}
type="file"
hidden
accept="image/*"
on:change={(e) => {
const files = profileImageInputElement.files ?? [];
let reader = new FileReader();
reader.onload = (event) => {
let originalImageUrl = `${event.target.result}`;
const img = new Image();
img.src = originalImageUrl;
img.onload = function () {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// Calculate the aspect ratio of the image
const aspectRatio = img.width / img.height;
// Calculate the new width and height to fit within 250x250
let newWidth, newHeight;
if (aspectRatio > 1) {
newWidth = 250 * aspectRatio;
newHeight = 250;
} else {
newWidth = 250;
newHeight = 250 / aspectRatio;
}
// Set the canvas size
canvas.width = 250;
canvas.height = 250;
// Calculate the position to center the image
const offsetX = (250 - newWidth) / 2;
const offsetY = (250 - newHeight) / 2;
// Draw the image on the canvas
ctx.drawImage(img, offsetX, offsetY, newWidth, newHeight);
// Get the base64 representation of the compressed image
const compressedSrc = canvas.toDataURL('image/jpeg');
// Display the compressed image
profileImageUrl = compressedSrc;
profileImageInputElement.files = null;
};
};
if (
files.length > 0 &&
['image/gif', 'image/webp', 'image/jpeg', 'image/png'].includes(files[0]['type'])
) {
reader.readAsDataURL(files[0]);
}
}}
/>
<div class="space-y-1">
<div>
<div class="text-base font-medium">{$i18n.t('Your Account')}</div>
@ -192,73 +132,8 @@
<!-- <div class=" text-sm font-medium">{$i18n.t('Account')}</div> -->
<div class="flex space-x-5 my-4">
<div class="flex flex-col self-start group">
<div class="self-center flex">
<button
class="relative rounded-full dark:bg-gray-700"
type="button"
on:click={() => {
profileImageInputElement.click();
}}
>
<img
src={profileImageUrl !== '' ? profileImageUrl : generateInitialsImage(name)}
alt="profile"
class=" rounded-full size-14 md:size-18 object-cover"
/>
<UserProfileImage bind:profileImageUrl user={$user} />
<div class="absolute bottom-0 right-0 opacity-0 group-hover:opacity-100 transition">
<div class="p-1 rounded-full bg-white text-black border-gray-100 shadow">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3"
>
<path
d="m2.695 14.762-1.262 3.155a.5.5 0 0 0 .65.65l3.155-1.262a4 4 0 0 0 1.343-.886L17.5 5.501a2.121 2.121 0 0 0-3-3L3.58 13.419a4 4 0 0 0-.885 1.343Z"
/>
</svg>
</div>
</div>
</button>
</div>
<div class="flex flex-col w-full justify-center mt-2">
<button
class=" text-xs text-center text-gray-500 rounded-lg py-0.5 opacity-0 group-hover:opacity-100 transition-all"
on:click={async () => {
profileImageUrl = `${WEBUI_BASE_URL}/user.png`;
}}>{$i18n.t('Remove')}</button
>
<button
class=" text-xs text-center text-gray-800 dark:text-gray-400 rounded-lg py-0.5 opacity-0 group-hover:opacity-100 transition-all"
on:click={async () => {
if (canvasPixelTest()) {
profileImageUrl = generateInitialsImage(name);
} else {
toast.info(
$i18n.t(
'Fingerprint spoofing detected: Unable to use initials as avatar. Defaulting to default profile image.'
),
{
duration: 1000 * 10
}
);
}
}}>{$i18n.t('Initials')}</button
>
<button
class=" text-xs text-center text-gray-800 dark:text-gray-400 rounded-lg py-0.5 opacity-0 group-hover:opacity-100 transition-all"
on:click={async () => {
const url = await getGravatarUrl(localStorage.token, $user?.email);
profileImageUrl = url;
}}>{$i18n.t('Gravatar')}</button
>
</div>
</div>
<div class="flex flex-1 flex-col">
<div class=" flex-1">
<div class="flex flex-col w-full">

View File

@ -0,0 +1,149 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { getContext } from 'svelte';
const i18n = getContext('i18n');
import { getGravatarUrl } from '$lib/apis/utils';
import { canvasPixelTest, generateInitialsImage } from '$lib/utils';
import { WEBUI_BASE_URL } from '$lib/constants';
export let profileImageUrl;
export let user = null;
let profileImageInputElement;
</script>
<input
id="profile-image-input"
bind:this={profileImageInputElement}
type="file"
hidden
accept="image/*"
on:change={(e) => {
const files = profileImageInputElement.files ?? [];
let reader = new FileReader();
reader.onload = (event) => {
let originalImageUrl = `${event.target.result}`;
const img = new Image();
img.src = originalImageUrl;
img.onload = function () {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// Calculate the aspect ratio of the image
const aspectRatio = img.width / img.height;
// Calculate the new width and height to fit within 250x250
let newWidth, newHeight;
if (aspectRatio > 1) {
newWidth = 250 * aspectRatio;
newHeight = 250;
} else {
newWidth = 250;
newHeight = 250 / aspectRatio;
}
// Set the canvas size
canvas.width = 250;
canvas.height = 250;
// Calculate the position to center the image
const offsetX = (250 - newWidth) / 2;
const offsetY = (250 - newHeight) / 2;
// Draw the image on the canvas
ctx.drawImage(img, offsetX, offsetY, newWidth, newHeight);
// Get the base64 representation of the compressed image
const compressedSrc = canvas.toDataURL('image/jpeg');
// Display the compressed image
profileImageUrl = compressedSrc;
profileImageInputElement.files = null;
};
};
if (
files.length > 0 &&
['image/gif', 'image/webp', 'image/jpeg', 'image/png'].includes(files[0]['type'])
) {
reader.readAsDataURL(files[0]);
}
}}
/>
<div class="flex flex-col self-start group">
<div class="self-center flex">
<button
class="relative rounded-full dark:bg-gray-700"
type="button"
on:click={() => {
profileImageInputElement.click();
}}
>
<img
src={profileImageUrl !== '' ? profileImageUrl : generateInitialsImage(user?.name)}
alt="profile"
class=" rounded-full size-14 md:size-18 object-cover"
/>
<div class="absolute bottom-0 right-0 opacity-0 group-hover:opacity-100 transition">
<div class="p-1 rounded-full bg-white text-black border-gray-100 shadow">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3"
>
<path
d="m2.695 14.762-1.262 3.155a.5.5 0 0 0 .65.65l3.155-1.262a4 4 0 0 0 1.343-.886L17.5 5.501a2.121 2.121 0 0 0-3-3L3.58 13.419a4 4 0 0 0-.885 1.343Z"
/>
</svg>
</div>
</div>
</button>
</div>
<div class="flex flex-col w-full justify-center mt-2">
<button
class=" text-xs text-center text-gray-500 rounded-lg py-0.5 opacity-0 group-hover:opacity-100 transition-all"
type="button"
on:click={async () => {
profileImageUrl = `${WEBUI_BASE_URL}/user.png`;
}}>{$i18n.t('Remove')}</button
>
<button
class=" text-xs text-center text-gray-800 dark:text-gray-400 rounded-lg py-0.5 opacity-0 group-hover:opacity-100 transition-all"
type="button"
on:click={async () => {
if (canvasPixelTest()) {
profileImageUrl = generateInitialsImage(user?.name);
} else {
toast.info(
$i18n.t(
'Fingerprint spoofing detected: Unable to use initials as avatar. Defaulting to default profile image.'
),
{
duration: 1000 * 10
}
);
}
}}>{$i18n.t('Initials')}</button
>
<button
class=" text-xs text-center text-gray-800 dark:text-gray-400 rounded-lg py-0.5 opacity-0 group-hover:opacity-100 transition-all"
type="button"
on:click={async () => {
const url = await getGravatarUrl(localStorage.token, user?.email);
profileImageUrl = url;
}}>{$i18n.t('Gravatar')}</button
>
</div>
</div>

View File

@ -2,14 +2,22 @@
import fileSaver from 'file-saver';
const { saveAs } = fileSaver;
import { chats, user, settings, scrollPaginationEnabled, currentChatPage } from '$lib/stores';
import {
chats,
user,
settings,
scrollPaginationEnabled,
currentChatPage,
pinnedChats
} from '$lib/stores';
import {
archiveAllChats,
deleteAllChats,
getAllChats,
getChatList,
importChat
importChat,
getPinnedChatList
} from '$lib/apis/chats';
import { getImportOrigin, convertOpenAIChats } from '$lib/utils';
import { onMount, getContext } from 'svelte';
@ -74,6 +82,7 @@
currentChatPage.set(1);
await chats.set(await getChatList(localStorage.token, $currentChatPage));
pinnedChats.set(await getPinnedChatList(localStorage.token));
scrollPaginationEnabled.set(true);
};
@ -92,6 +101,7 @@
currentChatPage.set(1);
await chats.set(await getChatList(localStorage.token, $currentChatPage));
pinnedChats.set([]);
scrollPaginationEnabled.set(true);
};

View File

@ -66,6 +66,7 @@
let chatFadeStreamingText = true;
let collapseCodeBlocks = false;
let expandDetails = false;
let showChatTitleInTab = true;
let showFloatingActionButtons = true;
let floatingActionButtons = null;
@ -224,6 +225,7 @@
temporaryChatByDefault = $settings?.temporaryChatByDefault ?? false;
chatDirection = $settings?.chatDirection ?? 'auto';
userLocation = $settings?.userLocation ?? false;
showChatTitleInTab = $settings?.showChatTitleInTab ?? true;
notificationSound = $settings?.notificationSound ?? true;
notificationSoundAlways = $settings?.notificationSoundAlways ?? false;
@ -329,6 +331,25 @@
</div>
</div>
<div>
<div class=" py-0.5 flex w-full justify-between">
<div id="use-chat-title-as-tab-title-label" class=" self-center text-xs">
{$i18n.t('Display chat title in tab')}
</div>
<div class="flex items-center gap-2 p-1">
<Switch
ariaLabelledbyId="use-chat-title-as-tab-title-label"
tooltip={true}
bind:state={showChatTitleInTab}
on:change={() => {
saveSettings({ showChatTitleInTab });
}}
/>
</div>
</div>
</div>
<div>
<div class="py-0.5 flex w-full justify-between">
<div id="notification-sound-label" class=" self-center text-xs">

View File

@ -46,8 +46,8 @@
<div class=" text-sm dark:text-gray-300 mb-1">
{#each selectedTools as tool}
<Collapsible buttonClassName="w-full mb-0.5">
<div>
<div class="text-sm font-medium dark:text-gray-100 text-gray-800">
<div class="truncate">
<div class="text-sm font-medium dark:text-gray-100 text-gray-800 truncate">
{tool?.name}
</div>

View File

@ -6,6 +6,8 @@
export let title = 'Embedded Content';
export let initialHeight: number | null = null; // initial height in px, null = auto
export let iframeClassName = 'w-full rounded-2xl';
export let args = null;
export let allowScripts = true;
@ -174,7 +176,7 @@ window.Chart = parent.Chart; // Chart previously assigned on parent
bind:this={iframe}
srcdoc={iframeDoc}
{title}
class="w-full rounded-2xl"
class={iframeClassName}
style={`${initialHeight ? `height:${initialHeight}px;` : ''}`}
width="100%"
frameborder="0"
@ -187,7 +189,7 @@ window.Chart = parent.Chart; // Chart previously assigned on parent
bind:this={iframe}
src={iframeSrc}
{title}
class="w-full rounded-2xl"
class={iframeClassName}
style={`${initialHeight ? `height:${initialHeight}px;` : ''}`}
width="100%"
frameborder="0"

View File

@ -1030,6 +1030,19 @@
// For all other cases, let ProseMirror perform its default paste behavior.
view.dispatch(view.state.tr.scrollIntoView());
return false;
},
copy: (view, event: ClipboardEvent) => {
if (!event.clipboardData) return false;
if (richText) return false; // Let ProseMirror handle normal copy in rich text mode
const plain = editor.getText();
const html = editor.getHTML();
event.clipboardData.setData('text/plain', plain.replaceAll('\n\n', '\n'));
event.clipboardData.setData('text/html', html);
event.preventDefault();
return true;
}
}
},

View File

@ -0,0 +1,20 @@
<script lang="ts">
export let className = 'w-4 h-4';
export let strokeWidth = '1.5';
</script>
<svg
class={className}
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
stroke-width={strokeWidth}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
><path d="M10.25 4.75L6.75 8.25L10.25 11.75" stroke-linecap="round" stroke-linejoin="round"
></path><path
d="M6.75 8.25L12.75 8.25C14.9591 8.25 16.75 10.0409 16.75 12.25V19.25"
stroke-linecap="round"
stroke-linejoin="round"
></path></svg
>

View File

@ -0,0 +1,20 @@
<script lang="ts">
export let className = 'w-4 h-4';
export let strokeWidth = '1.5';
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
class={className}
aria-hidden="true"
stroke-width={strokeWidth}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75"
/>
</svg>

View File

@ -1,19 +1,26 @@
<script>
<script lang="ts">
import fileSaver from 'file-saver';
const { saveAs } = fileSaver;
import { toast } from 'svelte-sonner';
import { getContext } from 'svelte';
import { archiveChatById, getAllArchivedChats, getArchivedChatList } from '$lib/apis/chats';
import {
archiveChatById,
getAllArchivedChats,
getArchivedChatList,
unarchiveAllChats
} from '$lib/apis/chats';
import ChatsModal from './ChatsModal.svelte';
import UnarchiveAllConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import Spinner from '../common/Spinner.svelte';
const i18n = getContext('i18n');
export let show = false;
export let onUpdate = () => {};
let loading = false;
let chatList = null;
let page = 1;
@ -105,13 +112,17 @@
};
const unarchiveAllHandler = async () => {
const chats = await getAllArchivedChats(localStorage.token);
for (const chat of chats) {
await archiveChatById(localStorage.token, chat.id);
}
loading = true;
try {
await unarchiveAllChats(localStorage.token);
toast.success($i18n.t('All chats have been unarchived.'));
onUpdate();
init();
await init();
} catch (error) {
toast.error(`${error}`);
} finally {
loading = false;
}
};
const init = async () => {
@ -152,15 +163,21 @@
<div class="flex flex-wrap text-sm font-medium gap-1.5 mt-2 m-1 justify-end w-full">
<button
class=" px-3.5 py-1.5 font-medium hover:bg-black/5 dark:hover:bg-white/5 outline outline-1 outline-gray-100 dark:outline-gray-800 rounded-3xl"
disabled={loading}
on:click={() => {
showUnarchiveAllConfirmDialog = true;
}}
>
{#if loading}
<Spinner className="size-4" />
{:else}
{$i18n.t('Unarchive All Archived Chats')}
{/if}
</button>
<button
class="px-3.5 py-1.5 font-medium hover:bg-black/5 dark:hover:bg-white/5 outline outline-1 outline-gray-100 dark:outline-gray-800 rounded-3xl"
disabled={loading}
on:click={() => {
exportChatsHandler();
}}

View File

@ -18,7 +18,8 @@
theme,
user,
settings,
folders
folders,
showEmbeds
} from '$lib/stores';
import { flyAndScale } from '$lib/utils/transitions';
import { getChatById } from '$lib/apis/chats';
@ -319,6 +320,7 @@
await showControls.set(true);
await showOverview.set(false);
await showArtifacts.set(false);
await showEmbeds.set(false);
}}
>
<AdjustmentsHorizontal className=" size-4" strokeWidth="1.5" />
@ -333,6 +335,7 @@
await showControls.set(true);
await showOverview.set(true);
await showArtifacts.set(false);
await showEmbeds.set(false);
}}
>
<Map className=" size-4" strokeWidth="1.5" />
@ -346,6 +349,7 @@
await showControls.set(true);
await showArtifacts.set(true);
await showOverview.set(false);
await showEmbeds.set(false);
}}
>
<Cube className=" size-4" strokeWidth="1.5" />

View File

@ -80,7 +80,10 @@
let allChatsLoaded = false;
let showCreateFolderModal = false;
let folders = {};
let folderRegistry = {};
let newFolderId = null;
const initFolders = async () => {
@ -122,6 +125,13 @@
});
}
}
await tick();
for (const folderId in folders) {
if (folders[folderId] && folders[folderId].is_expanded) {
folderRegistry[folderId]?.setFolderItems();
}
}
};
const createFolder = async ({ name, data }) => {
@ -922,6 +932,7 @@
}}
>
<Folders
bind:folderRegistry
{folders}
{shiftKey}
onDelete={(folderId) => {
@ -981,6 +992,8 @@
return null;
}
);
folderRegistry[chat.folder_id]?.setFolderItems();
}
if (chat.pinned) {

View File

@ -51,6 +51,8 @@
export let selected = false;
export let shiftKey = false;
export let onDragEnd = () => {};
let chat = null;
let mouseOver = false;
@ -201,11 +203,13 @@
y = event.clientY;
};
const onDragEnd = (event) => {
const onDragEndHandler = (event) => {
event.stopPropagation();
itemElement.style.opacity = '1'; // Reset visual cue after drag
dragged = false;
onDragEnd(event);
};
const onClickOutside = (event) => {
@ -225,7 +229,7 @@
// Event listener for when dragging occurs (optional)
itemElement.addEventListener('drag', onDrag);
// Event listener for when dragging ends
itemElement.addEventListener('dragend', onDragEnd);
itemElement.addEventListener('dragend', onDragEndHandler);
}
});
@ -235,7 +239,7 @@
itemElement.removeEventListener('dragstart', onDragStart);
itemElement.removeEventListener('drag', onDrag);
itemElement.removeEventListener('dragend', onDragEnd);
itemElement.removeEventListener('dragend', onDragEndHandler);
}
});

View File

@ -1,8 +1,11 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
import RecursiveFolder from './RecursiveFolder.svelte';
export let folderRegistry = {};
export let folders = {};
export let shiftKey = false;
@ -18,15 +21,23 @@
sensitivity: 'base'
})
);
const onItemMove = (e) => {
if (e.originFolderId) {
folderRegistry[e.originFolderId]?.setFolderItems();
}
};
</script>
{#each folderList as folderId (folderId)}
<RecursiveFolder
className=""
bind:folderRegistry
{folders}
{folderId}
{shiftKey}
{onDelete}
{onItemMove}
on:import={(e) => {
dispatch('import', e.detail);
}}

View File

@ -12,15 +12,16 @@
import Textarea from '$lib/components/common/Textarea.svelte';
import Knowledge from '$lib/components/workspace/Models/Knowledge.svelte';
import { getFolderById } from '$lib/apis/folders';
const i18n = getContext('i18n');
export let show = false;
export let onSubmit: Function = (e) => {};
export let folderId = null;
export let edit = false;
export let folder = null;
let folder = null;
let name = '';
let meta = {
background_image_url: null
@ -50,7 +51,13 @@
loading = false;
};
const init = () => {
const init = async () => {
if (folderId) {
folder = await getFolderById(localStorage.token, folderId).catch((error) => {
toast.error(`${error}`);
return null;
});
name = folder.name;
meta = folder.meta || {
background_image_url: null
@ -59,8 +66,9 @@
system_prompt: '',
files: []
};
}
console.log(folder);
focusInput();
};
const focusInput = async () => {
@ -73,10 +81,6 @@
};
$: if (show) {
focusInput();
}
$: if (folder) {
init();
}

View File

@ -8,6 +8,7 @@
import fileSaver from 'file-saver';
const { saveAs } = fileSaver;
import { goto } from '$app/navigation';
import { toast } from 'svelte-sonner';
import { chatId, mobile, selectedFolder, showSidebar } from '$lib/stores';
@ -16,11 +17,13 @@
deleteFolderById,
updateFolderIsExpandedById,
updateFolderById,
updateFolderParentIdById
updateFolderParentIdById,
getFolderById
} from '$lib/apis/folders';
import {
getChatById,
getChatsByFolderId,
getChatListByFolderId,
importChat,
updateChatFolderIdById
} from '$lib/apis/chats';
@ -37,9 +40,10 @@
import FolderMenu from './Folders/FolderMenu.svelte';
import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import FolderModal from './Folders/FolderModal.svelte';
import { goto } from '$app/navigation';
import Emoji from '$lib/components/common/Emoji.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
export let folderRegistry = {};
export let open = false;
export let folders;
@ -51,6 +55,7 @@
export let parentDragged = false;
export let onDelete = (e) => {};
export let onItemMove = (e) => {};
let folderElement;
@ -171,6 +176,12 @@
return null;
});
onItemMove({
originFolderId: chat.folder_id,
targetFolderId: folderId,
e
});
if (res) {
dispatch('update');
}
@ -182,6 +193,7 @@
}
}
setFolderItems();
draggedOver = false;
}
};
@ -234,6 +246,10 @@
};
onMount(async () => {
folderRegistry[folderId] = {
setFolderItems: () => setFolderItems()
};
open = folders[folderId].is_expanded;
if (folderElement) {
folderElement.addEventListener('dragover', onDragOver);
@ -250,7 +266,6 @@
if (folders[folderId]?.new) {
delete folders[folderId].new;
await tick();
renameHandler();
}
@ -314,9 +329,15 @@
toast.success($i18n.t('Folder updated successfully'));
if ($selectedFolder?.id === folderId) {
selectedFolder.set(folders[folderId]);
}
const folder = await getFolderById(localStorage.token, folderId).catch((error) => {
toast.error(`${error}`);
return null;
});
if (folder) {
selectedFolder.set(folder);
}
}
dispatch('update');
}
};
@ -339,6 +360,32 @@
}, 500);
};
let chats = null;
export const setFolderItems = async () => {
await tick();
if (open) {
chats = await getChatListByFolderId(localStorage.token, folderId).catch((error) => {
toast.error(`${error}`);
return [];
});
if ($selectedFolder?.id === folderId) {
const folder = await getFolderById(localStorage.token, folderId).catch((error) => {
toast.error(`${error}`);
return null;
});
if (folder) {
selectedFolder.set(folder);
}
}
} else {
chats = null;
}
};
$: setFolderItems(open);
const renameHandler = async () => {
console.log('Edit');
await tick();
@ -388,12 +435,7 @@
</div>
</DeleteConfirmDialog>
<FolderModal
bind:show={showFolderModal}
edit={true}
folder={folders[folderId]}
onSubmit={updateHandler}
/>
<FolderModal bind:show={showFolderModal} edit={true} {folderId} onSubmit={updateHandler} />
{#if dragged && x && y}
<DragGhost {x} {y}>
@ -419,8 +461,6 @@
bind:open
className="w-full"
buttonClassName="w-full"
hide={(folders[folderId]?.childrenIds ?? []).length === 0 &&
(folders[folderId].items?.chats ?? []).length === 0}
onChange={(state) => {
dispatch('open', state);
}}
@ -450,7 +490,14 @@
clickTimer = setTimeout(async () => {
await goto('/');
selectedFolder.set(folders[folderId]);
const folder = await getFolderById(localStorage.token, folderId).catch((error) => {
toast.error(`${error}`);
return null;
});
if (folder) {
selectedFolder.set(folder);
}
if ($mobile) {
showSidebar.set(!$showSidebar);
@ -466,6 +513,7 @@
class="text-gray-500 dark:text-gray-500 transition-all p-1 hover:bg-gray-200 dark:hover:bg-gray-850 rounded-lg"
on:click={(e) => {
e.stopPropagation();
e.stopImmediatePropagation();
open = !open;
isExpandedUpdateDebounceHandler();
}}
@ -548,7 +596,7 @@
</div>
<div slot="content" class="w-full">
{#if (folders[folderId]?.childrenIds ?? []).length > 0 || (folders[folderId].items?.chats ?? []).length > 0}
{#if (folders[folderId]?.childrenIds ?? []).length > 0 || (chats ?? []).length > 0}
<div
class="ml-3 pl-1 mt-[1px] flex flex-col overflow-y-auto scrollbar-hidden border-s border-gray-100 dark:border-gray-900"
>
@ -564,10 +612,12 @@
{#each children as childFolder (`${folderId}-${childFolder.id}`)}
<svelte:self
bind:folderRegistry
{folders}
folderId={childFolder.id}
{shiftKey}
parentDragged={dragged}
{onItemMove}
{onDelete}
on:import={(e) => {
dispatch('import', e.detail);
@ -582,8 +632,7 @@
{/each}
{/if}
{#if folders[folderId].items?.chats}
{#each folders[folderId].items.chats as chat (chat.id)}
{#each chats ?? [] as chat (chat.id)}
<ChatItem
id={chat.id}
title={chat.title}
@ -593,7 +642,12 @@
}}
/>
{/each}
</div>
{/if}
{#if chats === null}
<div class="flex justify-center items-center p-2">
<Spinner className="size-4 text-gray-500" />
</div>
{/if}
</div>

View File

@ -340,7 +340,7 @@
showDeleteConfirm = false;
}}
>
<div class=" text-sm text-gray-500">
<div class=" text-sm text-gray-500 truncate">
{$i18n.t('This will delete')} <span class=" font-semibold">{selectedNote.title}</span>.
</div>
</DeleteConfirmDialog>

View File

@ -296,7 +296,7 @@
<div
class="flex justify-between flex-col sm:flex-row items-start sm:items-center gap-2 mt-2"
>
<div class="flex-1 shrink-0">
<div class="shrink-0">
<button
type="button"
class="px-3.5 py-1.5 text-sm font-medium bg-gray-50 hover:bg-gray-100 text-gray-900 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-200 transition rounded-lg shrink-0 {($settings?.highContrastMode ??

View File

@ -305,6 +305,7 @@
</button>
{#each tags as tag}
<Tooltip content={tag}>
<button
class="min-w-fit outline-none p-1.5 {selectedTag === tag
? ''
@ -313,8 +314,9 @@
selectedTag = tag;
}}
>
{tag}
{tag.length > 32 ? `${tag.slice(0, 32)}...` : tag}
</button>
</Tooltip>
{/each}
</div>
</div>

View File

@ -156,7 +156,7 @@
deleteHandler(deletePrompt);
}}
>
<div class=" text-sm text-gray-500">
<div class=" text-sm text-gray-500 truncate">
{$i18n.t('This will delete')} <span class=" font-semibold">{deletePrompt.command}</span>.
</div>
</DeleteConfirmDialog>

View File

@ -522,7 +522,7 @@
deleteHandler(selectedTool);
}}
>
<div class=" text-sm text-gray-500">
<div class=" text-sm text-gray-500 truncate">
{$i18n.t('This will delete')} <span class=" font-semibold">{selectedTool.name}</span>.
</div>
</DeleteConfirmDialog>

View File

@ -75,6 +75,7 @@
"Advanced Params": "المعلمات المتقدمة",
"AI": "",
"All": "",
"All chats have been unarchived.": "",
"All Documents": "جميع الملفات",
"All models deleted successfully": "",
"Allow Call": "",
@ -430,6 +431,7 @@
"Discover, download, and explore custom tools": "",
"Discover, download, and explore model presets": "اكتشاف وتنزيل واستكشاف الإعدادات المسبقة للنموذج",
"Display": "",
"Display chat title in tab": "",
"Display Emoji in Call": "",
"Display Multi-model Responses in Tabs": "",
"Display the username instead of You in the Chat": "اعرض اسم المستخدم بدلاً منك في الدردشة",
@ -691,6 +693,7 @@
"Failed to extract content from the file.": "",
"Failed to fetch models": "",
"Failed to generate title": "",
"Failed to import models": "",
"Failed to load chat preview": "",
"Failed to load file content.": "",
"Failed to move chat": "",
@ -844,6 +847,7 @@
"Import Presets": "",
"Import Prompt Suggestions": "",
"Import Prompts": "مطالبات الاستيراد",
"Import successful": "",
"Import Tools": "",
"Important Update": "تحديث مهم",
"In order to force OCR, performing OCR must be enabled.": "",
@ -881,6 +885,7 @@
"join our Discord for help.": "انضم إلى Discord للحصول على المساعدة.",
"JSON": "JSON",
"JSON Preview": "معاينة JSON",
"JSON Spec": "",
"July": "يوليو",
"June": "يونيو",
"Jupyter Auth": "",
@ -1014,6 +1019,7 @@
"Models": "الموديلات",
"Models Access": "",
"Models configuration saved successfully": "",
"Models imported successfully": "",
"Models Public Sharing": "",
"Mojeek Search API Key": "",
"More": "المزيد",
@ -1073,6 +1079,7 @@
"Note deleted successfully": "",
"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": "",
"Notification Sound": "",
"Notification Webhook": "",
"Notifications": "إشعارات",
@ -1122,6 +1129,7 @@
"OpenAI API settings updated": "",
"OpenAI URL/Key required.": "URL/مفتاح OpenAI.مطلوب عنوان ",
"OpenAPI": "",
"OpenAPI Spec": "",
"openapi.json URL or Path": "",
"Optional": "",
"Options for running a local vision-language model in the picture description. The parameters refer to a model hosted on Hugging Face. This parameter is mutually exclusive with picture_description_api.": "",
@ -1179,6 +1187,7 @@
"Please enter a message or attach a file.": "",
"Please enter a prompt": "",
"Please enter a valid ID": "",
"Please enter a valid JSON spec": "",
"Please enter a valid path": "",
"Please enter a valid URL": "",
"Please enter a valid URL.": "",
@ -1188,6 +1197,7 @@
"Please select a model first.": "",
"Please select a model.": "",
"Please select a reason": "",
"Please select a valid JSON file": "",
"Please wait until all files are uploaded.": "",
"Port": "",
"Positive attitude": "موقف ايجابي",
@ -1258,8 +1268,10 @@
"Remove this tag from list": "",
"Rename": "إعادة تسمية",
"Reorder Models": "",
"Reply": "",
"Reply in Thread": "",
"Reply to thread...": "",
"Replying to {{NAME}}": "",
"required": "",
"Reranking Engine": "",
"Reranking Model": "إعادة تقييم النموذج",
@ -1517,6 +1529,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 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)": "",
@ -1595,6 +1608,7 @@
"Unarchive Chat": "",
"Underline": "",
"Unknown": "",
"Unknown User": "",
"Unloads {{FROM_NOW}}": "",
"Unlock mysteries": "",
"Unpin": "",

View File

@ -75,6 +75,7 @@
"Advanced Params": "المعلمات المتقدمة",
"AI": "",
"All": "الكل",
"All chats have been unarchived.": "",
"All Documents": "جميع المستندات",
"All models deleted successfully": "تم حذف جميع النماذج بنجاح",
"Allow Call": "",
@ -430,6 +431,7 @@
"Discover, download, and explore custom tools": "اكتشف، حمّل، واستعرض الأدوات المخصصة",
"Discover, download, and explore model presets": "اكتشاف وتنزيل واستكشاف الإعدادات المسبقة للنموذج",
"Display": "العرض",
"Display chat title in tab": "",
"Display Emoji in Call": "عرض الرموز التعبيرية أثناء المكالمة",
"Display Multi-model Responses in Tabs": "",
"Display the username instead of You in the Chat": "اعرض اسم المستخدم بدلاً منك في الدردشة",
@ -691,6 +693,7 @@
"Failed to extract content from the file.": "",
"Failed to fetch models": "فشل في جلب النماذج",
"Failed to generate title": "",
"Failed to import models": "",
"Failed to load chat preview": "",
"Failed to load file content.": "",
"Failed to move chat": "",
@ -844,6 +847,7 @@
"Import Presets": "استيراد الإعدادات المسبقة",
"Import Prompt Suggestions": "",
"Import Prompts": "مطالبات الاستيراد",
"Import successful": "",
"Import Tools": "استيراد الأدوات",
"Important Update": "تحديث مهم",
"In order to force OCR, performing OCR must be enabled.": "",
@ -881,6 +885,7 @@
"join our Discord for help.": "انضم إلى Discord للحصول على المساعدة.",
"JSON": "JSON",
"JSON Preview": "معاينة JSON",
"JSON Spec": "",
"July": "يوليو",
"June": "يونيو",
"Jupyter Auth": "مصادقة Jupyter",
@ -1014,6 +1019,7 @@
"Models": "الموديلات",
"Models Access": "الوصول إلى النماذج",
"Models configuration saved successfully": "تم حفظ إعدادات النماذج بنجاح",
"Models imported successfully": "",
"Models Public Sharing": "",
"Mojeek Search API Key": "مفتاح API لـ Mojeek Search",
"More": "المزيد",
@ -1073,6 +1079,7 @@
"Note deleted successfully": "",
"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": "",
"Notification Sound": "صوت الإشعارات",
"Notification Webhook": "رابط Webhook للإشعارات",
"Notifications": "إشعارات",
@ -1122,6 +1129,7 @@
"OpenAI API settings updated": "تم تحديث إعدادات OpenAI API",
"OpenAI URL/Key required.": "URL/مفتاح OpenAI.مطلوب عنوان ",
"OpenAPI": "",
"OpenAPI Spec": "",
"openapi.json URL or Path": "",
"Optional": "",
"Options for running a local vision-language model in the picture description. The parameters refer to a model hosted on Hugging Face. This parameter is mutually exclusive with picture_description_api.": "",
@ -1179,6 +1187,7 @@
"Please enter a message or attach a file.": "",
"Please enter a prompt": "الرجاء إدخال توجيه",
"Please enter a valid ID": "",
"Please enter a valid JSON spec": "",
"Please enter a valid path": "",
"Please enter a valid URL": "",
"Please enter a valid URL.": "",
@ -1188,6 +1197,7 @@
"Please select a model first.": "الرجاء اختيار نموذج أولاً.",
"Please select a model.": "الرجاء اختيار نموذج.",
"Please select a reason": "الرجاء اختيار سبب",
"Please select a valid JSON file": "",
"Please wait until all files are uploaded.": "",
"Port": "المنفذ",
"Positive attitude": "موقف ايجابي",
@ -1258,8 +1268,10 @@
"Remove this tag from list": "",
"Rename": "إعادة تسمية",
"Reorder Models": "إعادة ترتيب النماذج",
"Reply": "",
"Reply in Thread": "الرد داخل سلسلة الرسائل",
"Reply to thread...": "",
"Replying to {{NAME}}": "",
"required": "",
"Reranking Engine": "",
"Reranking Model": "إعادة تقييم النموذج",
@ -1517,6 +1529,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 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)": "",
@ -1595,6 +1608,7 @@
"Unarchive Chat": "إلغاء أرشفة المحادثة",
"Underline": "",
"Unknown": "",
"Unknown User": "",
"Unloads {{FROM_NOW}}": "",
"Unlock mysteries": "اكشف الأسرار",
"Unpin": "إزالة التثبيت",

View File

@ -75,6 +75,7 @@
"Advanced Params": "Разширени параметри",
"AI": "",
"All": "",
"All chats have been unarchived.": "",
"All Documents": "Всички Документи",
"All models deleted successfully": "Всички модели са изтрити успешно",
"Allow Call": "",
@ -430,6 +431,7 @@
"Discover, download, and explore custom tools": "Открийте, изтеглете и разгледайте персонализирани инструменти",
"Discover, download, and explore model presets": "Откриване, сваляне и преглед на пресетове на модели",
"Display": "Показване",
"Display chat title in tab": "",
"Display Emoji in Call": "Показване на емотикони в обаждането",
"Display Multi-model Responses in Tabs": "",
"Display the username instead of You in the Chat": "Показване на потребителското име вместо Вие в чата",
@ -691,6 +693,7 @@
"Failed to extract content from the file.": "",
"Failed to fetch models": "Неуспешно извличане на модели",
"Failed to generate title": "",
"Failed to import models": "",
"Failed to load chat preview": "",
"Failed to load file content.": "",
"Failed to move chat": "",
@ -844,6 +847,7 @@
"Import Presets": "Импортиране на предварителни настройки",
"Import Prompt Suggestions": "",
"Import Prompts": "Импортване на промптове",
"Import successful": "",
"Import Tools": "Импортиране на инструменти",
"Important Update": "Важна актуализация",
"In order to force OCR, performing OCR must be enabled.": "",
@ -881,6 +885,7 @@
"join our Discord for help.": "свържете се с нашия Discord за помощ.",
"JSON": "JSON",
"JSON Preview": "JSON Преглед",
"JSON Spec": "",
"July": "Юли",
"June": "Юни",
"Jupyter Auth": "Jupyter удостоверяване",
@ -1014,6 +1019,7 @@
"Models": "Модели",
"Models Access": "Достъп до модели",
"Models configuration saved successfully": "Конфигурацията на моделите е запазена успешно",
"Models imported successfully": "",
"Models Public Sharing": "Споделяне на моделите публично",
"Mojeek Search API Key": "API ключ за Mojeek Search",
"More": "Повече",
@ -1073,6 +1079,7 @@
"Note deleted successfully": "",
"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": "",
"Notification Sound": "Звук за известия",
"Notification Webhook": "Webhook за известия",
"Notifications": "Известия",
@ -1122,6 +1129,7 @@
"OpenAI API settings updated": "Настройките на OpenAI API са актуализирани",
"OpenAI URL/Key required.": "OpenAI URL/Key е задължителен.",
"OpenAPI": "",
"OpenAPI Spec": "",
"openapi.json URL or Path": "",
"Optional": "",
"Options for running a local vision-language model in the picture description. The parameters refer to a model hosted on Hugging Face. This parameter is mutually exclusive with picture_description_api.": "",
@ -1179,6 +1187,7 @@
"Please enter a message or attach a file.": "",
"Please enter a prompt": "Моля, въведете промпт",
"Please enter a valid ID": "",
"Please enter a valid JSON spec": "",
"Please enter a valid path": "",
"Please enter a valid URL": "",
"Please enter a valid URL.": "",
@ -1188,6 +1197,7 @@
"Please select a model first.": "Моля, първо изберете модела.",
"Please select a model.": "Моля, изберете модел.",
"Please select a reason": "Моля, изберете причина",
"Please select a valid JSON file": "",
"Please wait until all files are uploaded.": "",
"Port": "Порт",
"Positive attitude": "Позитивно отношение",
@ -1258,8 +1268,10 @@
"Remove this tag from list": "",
"Rename": "Преименуване",
"Reorder Models": "Преорганизиране на моделите",
"Reply": "",
"Reply in Thread": "Отговори в тред",
"Reply to thread...": "",
"Replying to {{NAME}}": "",
"required": "",
"Reranking Engine": "Двигател за пренареждане",
"Reranking Model": "Модел за преподреждане",
@ -1513,6 +1525,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 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)": "",
@ -1591,6 +1604,7 @@
"Unarchive Chat": "Разархивирай чат",
"Underline": "",
"Unknown": "",
"Unknown User": "",
"Unloads {{FROM_NOW}}": "",
"Unlock mysteries": "Разкрий мистерии",
"Unpin": "Откачи",

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