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
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:
commit
37d1c85c99
20
CHANGELOG.md
20
CHANGELOG.md
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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,34 +1908,29 @@ 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:
|
||||
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,
|
||||
)
|
||||
log.info("Using Redis for session")
|
||||
else:
|
||||
raise ValueError("No Redis URL provided")
|
||||
except Exception as e:
|
||||
except Exception as e:
|
||||
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,
|
||||
)
|
||||
|
|
|
@ -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
|
|
@ -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]
|
||||
|
||||
|
|
|
@ -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
|
||||
####################
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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
|
||||
],
|
||||
|
|
|
@ -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)
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"])
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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
|
||||
############################
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
]
|
||||
|
|
|
@ -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
|
||||
############################
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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"],
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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"),
|
||||
}
|
||||
]
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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", {})
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -248,6 +248,7 @@ export const getChannelThreadMessages = async (
|
|||
};
|
||||
|
||||
type MessageForm = {
|
||||
reply_to_id?: string;
|
||||
parent_id?: string;
|
||||
content: string;
|
||||
data?: object;
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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'}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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);
|
||||
}}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
|
@ -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'}"
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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[]) {
|
||||
|
|
|
@ -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 });
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
|
@ -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);
|
||||
};
|
||||
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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
|
||||
>
|
|
@ -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>
|
|
@ -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();
|
||||
}}
|
||||
|
|
|
@ -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" />
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
@ -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);
|
||||
}}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 ??
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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": "",
|
||||
|
|
|
@ -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": "إزالة التثبيت",
|
||||
|
|
|
@ -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
Loading…
Reference in New Issue