Merge branch 'dev' into dev

This commit is contained in:
Timothy Jaeryang Baek 2024-10-20 18:37:20 -07:00 committed by GitHub
commit 768b7e139c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
134 changed files with 5490 additions and 1407 deletions

View file

@ -709,8 +709,10 @@ def save_docs_to_vector_db(
if overwrite:
VECTOR_DB_CLIENT.delete_collection(collection_name=collection_name)
log.info(f"deleting existing collection {collection_name}")
if add is False:
elif add is False:
log.info(
f"collection {collection_name} already exists, overwrite is False and add is False"
)
return True
log.info(f"adding to collection {collection_name}")
@ -823,7 +825,7 @@ def process_file(
# Process the file and save the content
# Usage: /files/
file_path = file.meta.get("path", None)
file_path = file.path
if file_path:
loader = Loader(
engine=app.state.config.CONTENT_EXTRACTION_ENGINE,

View file

@ -385,6 +385,8 @@ def get_rag_context(
extracted_collections.extend(collection_names)
if context:
if "data" in file:
del file["data"]
relevant_contexts.append({**context, "file": file})
contexts = []
@ -401,7 +403,6 @@ def get_rag_context(
]
)
)
contexts.append(
((", ".join(file_names) + ":\n\n") if file_names else "")
+ "\n\n".join(
@ -410,13 +411,14 @@ def get_rag_context(
)
if "metadatas" in context:
citations.append(
{
"source": context["file"],
"document": context["documents"][0],
"metadata": context["metadatas"][0],
}
)
citation = {
"source": context["file"],
"document": context["documents"][0],
"metadata": context["metadatas"][0],
}
if "distances" in context and context["distances"]:
citation["distances"] = context["distances"][0]
citations.append(citation)
except Exception as e:
log.exception(e)

View file

@ -109,7 +109,10 @@ class ChromaClient:
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.
collection = self.client.get_or_create_collection(name=collection_name)
collection = self.client.get_or_create_collection(
name=collection_name,
metadata={"hnsw:space": "cosine"}
)
ids = [item["id"] for item in items]
documents = [item["text"] for item in items]
@ -127,7 +130,10 @@ class ChromaClient:
def upsert(self, collection_name: str, items: list[VectorItem]):
# Update the items in the collection, if the items are not present, insert them. If the collection does not exist, it will be created.
collection = self.client.get_or_create_collection(name=collection_name)
collection = self.client.get_or_create_collection(
name=collection_name,
metadata={"hnsw:space": "cosine"}
)
ids = [item["id"] for item in items]
documents = [item["text"] for item in items]

View file

@ -9,6 +9,7 @@ from open_webui.apps.webui.models.models import Models
from open_webui.apps.webui.routers import (
auths,
chats,
folders,
configs,
files,
functions,
@ -119,6 +120,7 @@ app.include_router(configs.router, prefix="/configs", tags=["configs"])
app.include_router(auths.router, prefix="/auths", tags=["auths"])
app.include_router(users.router, prefix="/users", tags=["users"])
app.include_router(chats.router, prefix="/chats", tags=["chats"])
app.include_router(folders.router, prefix="/folders", tags=["folders"])
app.include_router(models.router, prefix="/models", tags=["models"])
app.include_router(knowledge.router, prefix="/knowledge", tags=["knowledge"])
@ -344,7 +346,7 @@ async def generate_function_chat_completion(form_data, user):
pipe = function_module.pipe
params = get_function_params(function_module, form_data, user, extra_params)
if form_data["stream"]:
if form_data.get("stream", False):
async def stream_content():
try:

View file

@ -33,6 +33,7 @@ class Chat(Base):
pinned = Column(Boolean, default=False, nullable=True)
meta = Column(JSON, server_default="{}")
folder_id = Column(Text, nullable=True)
class ChatModel(BaseModel):
@ -51,6 +52,7 @@ class ChatModel(BaseModel):
pinned: Optional[bool] = False
meta: dict = {}
folder_id: Optional[str] = None
####################
@ -62,6 +64,12 @@ class ChatForm(BaseModel):
chat: dict
class ChatImportForm(ChatForm):
meta: Optional[dict] = {}
pinned: Optional[bool] = False
folder_id: Optional[str] = None
class ChatTitleMessagesForm(BaseModel):
title: str
messages: list[dict]
@ -82,6 +90,7 @@ class ChatResponse(BaseModel):
archived: bool
pinned: Optional[bool] = False
meta: dict = {}
folder_id: Optional[str] = None
class ChatTitleIdResponse(BaseModel):
@ -116,6 +125,35 @@ class ChatTable:
db.refresh(result)
return ChatModel.model_validate(result) if result else None
def import_chat(
self, user_id: str, form_data: ChatImportForm
) -> Optional[ChatModel]:
with get_db() as db:
id = str(uuid.uuid4())
chat = ChatModel(
**{
"id": id,
"user_id": user_id,
"title": (
form_data.chat["title"]
if "title" in form_data.chat
else "New Chat"
),
"chat": form_data.chat,
"meta": form_data.meta,
"pinned": form_data.pinned,
"folder_id": form_data.folder_id,
"created_at": int(time.time()),
"updated_at": int(time.time()),
}
)
result = Chat(**chat.model_dump())
db.add(result)
db.commit()
db.refresh(result)
return ChatModel.model_validate(result) if result else None
def update_chat_by_id(self, id: str, chat: dict) -> Optional[ChatModel]:
try:
with get_db() as db:
@ -254,7 +292,7 @@ class ChatTable:
limit: int = 50,
) -> list[ChatModel]:
with get_db() as db:
query = db.query(Chat).filter_by(user_id=user_id)
query = db.query(Chat).filter_by(user_id=user_id).filter_by(folder_id=None)
if not include_archived:
query = query.filter_by(archived=False)
@ -276,7 +314,7 @@ class ChatTable:
limit: Optional[int] = None,
) -> list[ChatTitleIdResponse]:
with get_db() as db:
query = db.query(Chat).filter_by(user_id=user_id)
query = db.query(Chat).filter_by(user_id=user_id).filter_by(folder_id=None)
query = query.filter(or_(Chat.pinned == False, Chat.pinned == None))
if not include_archived:
@ -444,7 +482,18 @@ class ChatTable:
)
# Check if there are any tags to filter, it should have all the tags
if tag_ids:
if "none" in tag_ids:
query = query.filter(
text(
"""
NOT EXISTS (
SELECT 1
FROM json_each(Chat.meta, '$.tags') AS tag
)
"""
)
)
elif tag_ids:
query = query.filter(
and_(
*[
@ -482,7 +531,18 @@ class ChatTable:
)
# Check if there are any tags to filter, it should have all the tags
if tag_ids:
if "none" in tag_ids:
query = query.filter(
text(
"""
NOT EXISTS (
SELECT 1
FROM json_array_elements_text(Chat.meta->'tags') AS tag
)
"""
)
)
elif tag_ids:
query = query.filter(
and_(
*[
@ -512,6 +572,49 @@ class ChatTable:
# Validate and return chats
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
) -> list[ChatModel]:
with get_db() as db:
query = db.query(Chat).filter_by(folder_id=folder_id, user_id=user_id)
query = query.filter(or_(Chat.pinned == False, Chat.pinned == None))
query = query.filter_by(archived=False)
query = query.order_by(Chat.updated_at.desc())
all_chats = query.all()
return [ChatModel.model_validate(chat) for chat in all_chats]
def get_chats_by_folder_ids_and_user_id(
self, folder_ids: list[str], user_id: str
) -> list[ChatModel]:
with get_db() as db:
query = db.query(Chat).filter(
Chat.folder_id.in_(folder_ids), Chat.user_id == user_id
)
query = query.filter(or_(Chat.pinned == False, Chat.pinned == None))
query = query.filter_by(archived=False)
query = query.order_by(Chat.updated_at.desc())
all_chats = query.all()
return [ChatModel.model_validate(chat) for chat in all_chats]
def update_chat_folder_id_by_id_and_user_id(
self, id: str, user_id: str, folder_id: str
) -> Optional[ChatModel]:
try:
with get_db() as db:
chat = db.get(Chat, id)
chat.folder_id = folder_id
chat.updated_at = int(time.time())
chat.pinned = False
db.commit()
db.refresh(chat)
return ChatModel.model_validate(chat)
except Exception:
return None
def get_chat_tags_by_id_and_user_id(self, id: str, user_id: str) -> list[TagModel]:
with get_db() as db:
chat = db.get(Chat, id)
@ -673,6 +776,18 @@ class ChatTable:
except Exception:
return False
def delete_chats_by_user_id_and_folder_id(
self, user_id: str, folder_id: str
) -> bool:
try:
with get_db() as db:
db.query(Chat).filter_by(user_id=user_id, folder_id=folder_id).delete()
db.commit()
return True
except Exception:
return False
def delete_shared_chats_by_user_id(self, user_id: str) -> bool:
try:
with get_db() as db:

View file

@ -17,14 +17,15 @@ log.setLevel(SRC_LOG_LEVELS["MODELS"])
class File(Base):
__tablename__ = "file"
id = Column(String, primary_key=True)
user_id = Column(String)
hash = Column(Text, nullable=True)
filename = Column(Text)
path = Column(Text, nullable=True)
data = Column(JSON, nullable=True)
meta = Column(JSONField)
meta = Column(JSON, nullable=True)
created_at = Column(BigInteger)
updated_at = Column(BigInteger)
@ -38,8 +39,10 @@ class FileModel(BaseModel):
hash: Optional[str] = None
filename: str
path: Optional[str] = None
data: Optional[dict] = None
meta: dict
meta: Optional[dict] = None
created_at: int # timestamp in epoch
updated_at: int # timestamp in epoch
@ -82,6 +85,7 @@ class FileForm(BaseModel):
id: str
hash: Optional[str] = None
filename: str
path: str
data: dict = {}
meta: dict = {}

View file

@ -0,0 +1,271 @@
import logging
import time
import uuid
from typing import Optional
from open_webui.apps.webui.internal.db import Base, get_db
from open_webui.apps.webui.models.chats import Chats
from open_webui.env import SRC_LOG_LEVELS
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, Text, JSON, Boolean
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
####################
# Folder DB Schema
####################
class Folder(Base):
__tablename__ = "folder"
id = Column(Text, primary_key=True)
parent_id = Column(Text, nullable=True)
user_id = Column(Text)
name = Column(Text)
items = Column(JSON, nullable=True)
meta = Column(JSON, nullable=True)
is_expanded = Column(Boolean, default=False)
created_at = Column(BigInteger)
updated_at = Column(BigInteger)
class FolderModel(BaseModel):
id: str
parent_id: Optional[str] = None
user_id: str
name: str
items: Optional[dict] = None
meta: Optional[dict] = None
is_expanded: bool = False
created_at: int
updated_at: int
model_config = ConfigDict(from_attributes=True)
####################
# Forms
####################
class FolderForm(BaseModel):
name: str
model_config = ConfigDict(extra="allow")
class FolderTable:
def insert_new_folder(
self, user_id: str, name: str, parent_id: Optional[str] = None
) -> Optional[FolderModel]:
with get_db() as db:
id = str(uuid.uuid4())
folder = FolderModel(
**{
"id": id,
"user_id": user_id,
"name": name,
"parent_id": parent_id,
"created_at": int(time.time()),
"updated_at": int(time.time()),
}
)
try:
result = Folder(**folder.model_dump())
db.add(result)
db.commit()
db.refresh(result)
if result:
return FolderModel.model_validate(result)
else:
return None
except Exception as e:
print(e)
return None
def get_folder_by_id_and_user_id(
self, id: str, user_id: str
) -> Optional[FolderModel]:
try:
with get_db() as db:
folder = db.query(Folder).filter_by(id=id, user_id=user_id).first()
if not folder:
return None
return FolderModel.model_validate(folder)
except Exception:
return None
def get_children_folders_by_id_and_user_id(
self, id: str, user_id: str
) -> Optional[FolderModel]:
try:
with get_db() as db:
folders = []
def get_children(folder):
children = self.get_folders_by_parent_id_and_user_id(
folder.id, user_id
)
for child in children:
get_children(child)
folders.append(child)
folder = db.query(Folder).filter_by(id=id, user_id=user_id).first()
if not folder:
return None
get_children(folder)
return folders
except Exception:
return None
def get_folders_by_user_id(self, user_id: str) -> list[FolderModel]:
with get_db() as db:
return [
FolderModel.model_validate(folder)
for folder in db.query(Folder).filter_by(user_id=user_id).all()
]
def get_folder_by_parent_id_and_user_id_and_name(
self, parent_id: Optional[str], user_id: str, name: str
) -> Optional[FolderModel]:
try:
with get_db() as db:
# Check if folder exists
folder = (
db.query(Folder)
.filter_by(parent_id=parent_id, user_id=user_id)
.filter(Folder.name.ilike(name))
.first()
)
if not folder:
return None
return FolderModel.model_validate(folder)
except Exception as e:
log.error(f"get_folder_by_parent_id_and_user_id_and_name: {e}")
return None
def get_folders_by_parent_id_and_user_id(
self, parent_id: Optional[str], user_id: str
) -> list[FolderModel]:
with get_db() as db:
return [
FolderModel.model_validate(folder)
for folder in db.query(Folder)
.filter_by(parent_id=parent_id, user_id=user_id)
.all()
]
def update_folder_parent_id_by_id_and_user_id(
self,
id: str,
user_id: str,
parent_id: str,
) -> Optional[FolderModel]:
try:
with get_db() as db:
folder = db.query(Folder).filter_by(id=id, user_id=user_id).first()
if not folder:
return None
folder.parent_id = parent_id
folder.updated_at = int(time.time())
db.commit()
return FolderModel.model_validate(folder)
except Exception as e:
log.error(f"update_folder: {e}")
return
def update_folder_name_by_id_and_user_id(
self, id: str, user_id: str, name: str
) -> Optional[FolderModel]:
try:
with get_db() as db:
folder = db.query(Folder).filter_by(id=id, user_id=user_id).first()
if not folder:
return None
existing_folder = (
db.query(Folder)
.filter_by(name=name, parent_id=folder.parent_id, user_id=user_id)
.first()
)
if existing_folder:
return None
folder.name = name
folder.updated_at = int(time.time())
db.commit()
return FolderModel.model_validate(folder)
except Exception as e:
log.error(f"update_folder: {e}")
return
def update_folder_is_expanded_by_id_and_user_id(
self, id: str, user_id: str, is_expanded: bool
) -> Optional[FolderModel]:
try:
with get_db() as db:
folder = db.query(Folder).filter_by(id=id, user_id=user_id).first()
if not folder:
return None
folder.is_expanded = is_expanded
folder.updated_at = int(time.time())
db.commit()
return FolderModel.model_validate(folder)
except Exception as e:
log.error(f"update_folder: {e}")
return
def delete_folder_by_id_and_user_id(self, id: str, user_id: str) -> bool:
try:
with get_db() as db:
folder = db.query(Folder).filter_by(id=id, user_id=user_id).first()
if not folder:
return False
# Delete all chats in the folder
Chats.delete_chats_by_user_id_and_folder_id(user_id, folder.id)
# Delete all children folders
def delete_children(folder):
folder_children = self.get_folders_by_parent_id_and_user_id(
folder.id, user_id
)
for folder_child in folder_children:
Chats.delete_chats_by_user_id_and_folder_id(
user_id, folder_child.id
)
delete_children(folder_child)
folder = db.query(Folder).filter_by(id=folder_child.id).first()
db.delete(folder)
db.commit()
delete_children(folder)
db.delete(folder)
db.commit()
return True
except Exception as e:
log.error(f"delete_folder: {e}")
return False
Folders = FolderTable()

View file

@ -4,11 +4,13 @@ from typing import Optional
from open_webui.apps.webui.models.chats import (
ChatForm,
ChatImportForm,
ChatResponse,
Chats,
ChatTitleIdResponse,
)
from open_webui.apps.webui.models.tags import TagModel, Tags
from open_webui.apps.webui.models.folders import Folders
from open_webui.config import ENABLE_ADMIN_CHAT_ACCESS, ENABLE_ADMIN_EXPORT
from open_webui.constants import ERROR_MESSAGES
@ -99,6 +101,34 @@ async def create_new_chat(form_data: ChatForm, user=Depends(get_verified_user)):
)
############################
# ImportChat
############################
@router.post("/import", response_model=Optional[ChatResponse])
async def import_chat(form_data: ChatImportForm, user=Depends(get_verified_user)):
try:
chat = Chats.import_chat(user.id, form_data)
if chat:
tags = chat.meta.get("tags", [])
for tag_id in tags:
tag_id = tag_id.replace(" ", "_").lower()
tag_name = " ".join([word.capitalize() for word in tag_id.split("_")])
if (
tag_id != "none"
and Tags.get_tag_by_name_and_user_id(tag_name, user.id) is None
):
Tags.insert_new_tag(tag_name, user.id)
return ChatResponse(**chat.model_dump())
except Exception as e:
log.exception(e)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
)
############################
# GetChats
############################
@ -133,6 +163,26 @@ async def search_user_chats(
return chat_list
############################
# GetChatsByFolderId
############################
@router.get("/folder/{folder_id}", response_model=list[ChatResponse])
async def get_chats_by_folder_id(folder_id: str, user=Depends(get_verified_user)):
folder_ids = [folder_id]
children_folders = Folders.get_children_folders_by_id_and_user_id(
folder_id, user.id
)
if children_folders:
folder_ids.extend([folder.id for folder in children_folders])
return [
ChatResponse(**chat.model_dump())
for chat in Chats.get_chats_by_folder_ids_and_user_id(folder_ids, user.id)
]
############################
# GetPinnedChats
############################
@ -491,6 +541,31 @@ async def delete_shared_chat_by_id(id: str, user=Depends(get_verified_user)):
)
############################
# UpdateChatFolderIdById
############################
class ChatFolderIdForm(BaseModel):
folder_id: Optional[str] = None
@router.post("/{id}/folder", response_model=Optional[ChatResponse])
async def update_chat_folder_id_by_id(
id: str, form_data: ChatFolderIdForm, user=Depends(get_verified_user)
):
chat = Chats.get_chat_by_id_and_user_id(id, user.id)
if chat:
chat = Chats.update_chat_folder_id_by_id_and_user_id(
id, user.id, form_data.folder_id
)
return ChatResponse(**chat.model_dump())
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT()
)
############################
# GetChatTagsById
############################
@ -522,6 +597,12 @@ async def add_tag_by_id_and_tag_name(
tags = chat.meta.get("tags", [])
tag_id = form_data.name.replace(" ", "_").lower()
if tag_id == "none":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT("Tag name cannot be 'None'"),
)
print(tags, tag_id)
if tag_id not in tags:
Chats.add_chat_tag_by_id_and_user_id_and_tag_name(

View file

@ -57,11 +57,11 @@ def upload_file(file: UploadFile = File(...), user=Depends(get_verified_user)):
**{
"id": id,
"filename": filename,
"path": file_path,
"meta": {
"name": name,
"content_type": file.content_type,
"size": len(contents),
"path": file_path,
},
}
),
@ -218,7 +218,7 @@ async def get_file_content_by_id(id: str, user=Depends(get_verified_user)):
file = Files.get_file_by_id(id)
if file and (file.user_id == user.id or user.role == "admin"):
file_path = Path(file.meta["path"])
file_path = Path(file.path)
# Check if the file already exists in the cache
if file_path.is_file():
@ -244,7 +244,7 @@ async def get_file_content_by_id(id: str, user=Depends(get_verified_user)):
file = Files.get_file_by_id(id)
if file and (file.user_id == user.id or user.role == "admin"):
file_path = file.meta.get("path")
file_path = file.path
if file_path:
file_path = Path(file_path)

View file

@ -0,0 +1,251 @@
import logging
import os
import shutil
import uuid
from pathlib import Path
from typing import Optional
from pydantic import BaseModel
import mimetypes
from open_webui.apps.webui.models.folders import (
FolderForm,
FolderModel,
Folders,
)
from open_webui.apps.webui.models.chats import Chats
from open_webui.config import UPLOAD_DIR
from open_webui.env import SRC_LOG_LEVELS
from open_webui.constants import ERROR_MESSAGES
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from fastapi.responses import FileResponse, StreamingResponse
from open_webui.utils.utils import get_admin_user, get_verified_user
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
router = APIRouter()
############################
# Get Folders
############################
@router.get("/", response_model=list[FolderModel])
async def get_folders(user=Depends(get_verified_user)):
folders = Folders.get_folders_by_user_id(user.id)
return [
{
**folder.model_dump(),
"items": {
"chats": [
{"title": chat.title, "id": chat.id}
for chat in Chats.get_chats_by_folder_id_and_user_id(
folder.id, user.id
)
]
},
}
for folder in folders
]
############################
# Create Folder
############################
@router.post("/")
def create_folder(form_data: FolderForm, user=Depends(get_verified_user)):
folder = Folders.get_folder_by_parent_id_and_user_id_and_name(
None, user.id, form_data.name
)
if folder:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT("Folder already exists"),
)
try:
folder = Folders.insert_new_folder(user.id, form_data.name)
return folder
except Exception as e:
log.exception(e)
log.error("Error creating folder")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT("Error creating folder"),
)
############################
# Get Folders By Id
############################
@router.get("/{id}", response_model=Optional[FolderModel])
async def get_folder_by_id(id: str, user=Depends(get_verified_user)):
folder = Folders.get_folder_by_id_and_user_id(id, user.id)
if folder:
return folder
else:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
############################
# Update Folder Name By Id
############################
@router.post("/{id}/update")
async def update_folder_name_by_id(
id: str, form_data: FolderForm, user=Depends(get_verified_user)
):
folder = Folders.get_folder_by_id_and_user_id(id, user.id)
if folder:
existing_folder = Folders.get_folder_by_parent_id_and_user_id_and_name(
folder.parent_id, user.id, form_data.name
)
if existing_folder:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT("Folder already exists"),
)
try:
folder = Folders.update_folder_name_by_id_and_user_id(
id, user.id, form_data.name
)
return folder
except Exception as e:
log.exception(e)
log.error(f"Error updating folder: {id}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT("Error updating folder"),
)
else:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
############################
# Update Folder Parent Id By Id
############################
class FolderParentIdForm(BaseModel):
parent_id: Optional[str] = None
@router.post("/{id}/update/parent")
async def update_folder_parent_id_by_id(
id: str, form_data: FolderParentIdForm, user=Depends(get_verified_user)
):
folder = Folders.get_folder_by_id_and_user_id(id, user.id)
if folder:
existing_folder = Folders.get_folder_by_parent_id_and_user_id_and_name(
form_data.parent_id, user.id, folder.name
)
if existing_folder:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT("Folder already exists"),
)
try:
folder = Folders.update_folder_parent_id_by_id_and_user_id(
id, user.id, form_data.parent_id
)
return folder
except Exception as e:
log.exception(e)
log.error(f"Error updating folder: {id}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT("Error updating folder"),
)
else:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
############################
# Update Folder Is Expanded By Id
############################
class FolderIsExpandedForm(BaseModel):
is_expanded: bool
@router.post("/{id}/update/expanded")
async def update_folder_is_expanded_by_id(
id: str, form_data: FolderIsExpandedForm, user=Depends(get_verified_user)
):
folder = Folders.get_folder_by_id_and_user_id(id, user.id)
if folder:
try:
folder = Folders.update_folder_is_expanded_by_id_and_user_id(
id, user.id, form_data.is_expanded
)
return folder
except Exception as e:
log.exception(e)
log.error(f"Error updating folder: {id}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT("Error updating folder"),
)
else:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
############################
# Delete Folder By Id
############################
@router.delete("/{id}")
async def delete_folder_by_id(id: str, user=Depends(get_verified_user)):
folder = Folders.get_folder_by_id_and_user_id(id, user.id)
if folder:
try:
result = Folders.delete_folder_by_id_and_user_id(id, user.id)
if result:
return result
else:
raise Exception("Error deleting folder")
except Exception as e:
log.exception(e)
log.error(f"Error deleting folder: {id}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT("Error deleting folder"),
)
else:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)

View file

@ -876,6 +876,12 @@ TITLE_GENERATION_PROMPT_TEMPLATE = PersistentConfig(
os.environ.get("TITLE_GENERATION_PROMPT_TEMPLATE", ""),
)
TAGS_GENERATION_PROMPT_TEMPLATE = PersistentConfig(
"TAGS_GENERATION_PROMPT_TEMPLATE",
"task.tags.prompt_template",
os.environ.get("TAGS_GENERATION_PROMPT_TEMPLATE", ""),
)
ENABLE_SEARCH_QUERY = PersistentConfig(
"ENABLE_SEARCH_QUERY",
"task.search.enable",

View file

@ -20,7 +20,9 @@ class ERROR_MESSAGES(str, Enum):
def __str__(self) -> str:
return super().__str__()
DEFAULT = lambda err="": f"Something went wrong :/\n[ERROR: {err if err else ''}]"
DEFAULT = (
lambda err="": f'{"Something went wrong :/" if err == "" else "[ERROR: " + str(err) + "]"}'
)
ENV_VAR_NOT_FOUND = "Required environment variable not found. Terminating now."
CREATE_USER_ERROR = "Oops! Something went wrong while creating your account. Please try again later. If the issue persists, contact support for assistance."
DELETE_USER_ERROR = "Oops! Something went wrong. We encountered an issue while trying to delete the user. Please give it another shot."
@ -106,6 +108,7 @@ class TASKS(str, Enum):
DEFAULT = lambda task="": f"{task if task else 'generation'}"
TITLE_GENERATION = "title_generation"
TAGS_GENERATION = "tags_generation"
EMOJI_GENERATION = "emoji_generation"
QUERY_GENERATION = "query_generation"
FUNCTION_CALLING = "function_calling"

View file

@ -82,6 +82,7 @@ from open_webui.config import (
TASK_MODEL,
TASK_MODEL_EXTERNAL,
TITLE_GENERATION_PROMPT_TEMPLATE,
TAGS_GENERATION_PROMPT_TEMPLATE,
TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE,
WEBHOOK_URL,
WEBUI_AUTH,
@ -118,6 +119,7 @@ from open_webui.utils.response import (
from open_webui.utils.security_headers import SecurityHeadersMiddleware
from open_webui.utils.task import (
moa_response_generation_template,
tags_generation_template,
search_query_generation_template,
title_generation_template,
tools_function_calling_generation_template,
@ -194,6 +196,7 @@ app.state.config.WEBHOOK_URL = WEBHOOK_URL
app.state.config.TASK_MODEL = TASK_MODEL
app.state.config.TASK_MODEL_EXTERNAL = TASK_MODEL_EXTERNAL
app.state.config.TITLE_GENERATION_PROMPT_TEMPLATE = TITLE_GENERATION_PROMPT_TEMPLATE
app.state.config.TAGS_GENERATION_PROMPT_TEMPLATE = TAGS_GENERATION_PROMPT_TEMPLATE
app.state.config.SEARCH_QUERY_GENERATION_PROMPT_TEMPLATE = (
SEARCH_QUERY_GENERATION_PROMPT_TEMPLATE
)
@ -1403,6 +1406,7 @@ async def get_task_config(user=Depends(get_verified_user)):
"TASK_MODEL": app.state.config.TASK_MODEL,
"TASK_MODEL_EXTERNAL": app.state.config.TASK_MODEL_EXTERNAL,
"TITLE_GENERATION_PROMPT_TEMPLATE": app.state.config.TITLE_GENERATION_PROMPT_TEMPLATE,
"TAGS_GENERATION_PROMPT_TEMPLATE": app.state.config.TAGS_GENERATION_PROMPT_TEMPLATE,
"ENABLE_SEARCH_QUERY": app.state.config.ENABLE_SEARCH_QUERY,
"SEARCH_QUERY_GENERATION_PROMPT_TEMPLATE": app.state.config.SEARCH_QUERY_GENERATION_PROMPT_TEMPLATE,
"TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE": app.state.config.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE,
@ -1413,6 +1417,7 @@ class TaskConfigForm(BaseModel):
TASK_MODEL: Optional[str]
TASK_MODEL_EXTERNAL: Optional[str]
TITLE_GENERATION_PROMPT_TEMPLATE: str
TAGS_GENERATION_PROMPT_TEMPLATE: str
SEARCH_QUERY_GENERATION_PROMPT_TEMPLATE: str
ENABLE_SEARCH_QUERY: bool
TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE: str
@ -1425,6 +1430,10 @@ async def update_task_config(form_data: TaskConfigForm, user=Depends(get_admin_u
app.state.config.TITLE_GENERATION_PROMPT_TEMPLATE = (
form_data.TITLE_GENERATION_PROMPT_TEMPLATE
)
app.state.config.TAGS_GENERATION_PROMPT_TEMPLATE = (
form_data.TAGS_GENERATION_PROMPT_TEMPLATE
)
app.state.config.SEARCH_QUERY_GENERATION_PROMPT_TEMPLATE = (
form_data.SEARCH_QUERY_GENERATION_PROMPT_TEMPLATE
)
@ -1437,6 +1446,7 @@ async def update_task_config(form_data: TaskConfigForm, user=Depends(get_admin_u
"TASK_MODEL": app.state.config.TASK_MODEL,
"TASK_MODEL_EXTERNAL": app.state.config.TASK_MODEL_EXTERNAL,
"TITLE_GENERATION_PROMPT_TEMPLATE": app.state.config.TITLE_GENERATION_PROMPT_TEMPLATE,
"TAGS_GENERATION_PROMPT_TEMPLATE": app.state.config.TAGS_GENERATION_PROMPT_TEMPLATE,
"SEARCH_QUERY_GENERATION_PROMPT_TEMPLATE": app.state.config.SEARCH_QUERY_GENERATION_PROMPT_TEMPLATE,
"ENABLE_SEARCH_QUERY": app.state.config.ENABLE_SEARCH_QUERY,
"TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE": app.state.config.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE,
@ -1521,6 +1531,75 @@ Prompt: {{prompt:middletruncate:8000}}"""
return await generate_chat_completions(form_data=payload, user=user)
@app.post("/api/task/tags/completions")
async def generate_chat_tags(form_data: dict, user=Depends(get_verified_user)):
print("generate_chat_tags")
model_id = form_data["model"]
if model_id not in app.state.MODELS:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Model not found",
)
# Check if the user has a custom task model
# If the user has a custom task model, use that model
task_model_id = get_task_model_id(model_id)
print(task_model_id)
if app.state.config.TAGS_GENERATION_PROMPT_TEMPLATE != "":
template = app.state.config.TAGS_GENERATION_PROMPT_TEMPLATE
else:
template = """### Task:
Generate 1-3 broad tags categorizing the main themes of the chat history, along with 1-3 more specific subtopic tags.
### Guidelines:
- Start with high-level domains (e.g. Science, Technology, Philosophy, Arts, Politics, Business, Health, Sports, Entertainment, Education)
- Consider including relevant subfields/subdomains if they are strongly represented throughout the conversation
- If content is too short (less than 3 messages) or too diverse, use only ["General"]
- Use the chat's primary language; default to English if multilingual
- Prioritize accuracy over specificity
### Output:
JSON format: { "tags": ["tag1", "tag2", "tag3"] }
### Chat History:
<chat_history>
{{MESSAGES:END:6}}
</chat_history>"""
content = tags_generation_template(
template, form_data["messages"], {"name": user.name}
)
print("content", content)
payload = {
"model": task_model_id,
"messages": [{"role": "user", "content": content}],
"stream": False,
"metadata": {"task": str(TASKS.TAGS_GENERATION), "task_body": form_data},
}
log.debug(payload)
# Handle pipeline filters
try:
payload = filter_pipeline(payload, user)
except Exception as e:
if len(e.args) > 1:
return JSONResponse(
status_code=e.args[0],
content={"detail": e.args[1]},
)
else:
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={"detail": str(e)},
)
if "chat_id" in payload:
del payload["chat_id"]
return await generate_chat_completions(form_data=payload, user=user)
@app.post("/api/task/query/completions")
async def generate_search_query(form_data: dict, user=Depends(get_verified_user)):
print("generate_search_query")

View file

@ -0,0 +1,79 @@
"""Update file table path
Revision ID: c29facfe716b
Revises: c69f45358db4
Create Date: 2024-10-20 17:02:35.241684
"""
from alembic import op
import sqlalchemy as sa
import json
from sqlalchemy.sql import table, column
from sqlalchemy import String, Text, JSON, and_
revision = "c29facfe716b"
down_revision = "c69f45358db4"
branch_labels = None
depends_on = None
def upgrade():
# 1. Add the `path` column to the "file" table.
op.add_column("file", sa.Column("path", sa.Text(), nullable=True))
# 2. Convert the `meta` column from Text/JSONField to `JSON()`
# Use Alembic's default batch_op for dialect compatibility.
with op.batch_alter_table("file", schema=None) as batch_op:
batch_op.alter_column(
"meta",
type_=sa.JSON(),
existing_type=sa.Text(),
existing_nullable=True,
nullable=True,
postgresql_using="meta::json",
)
# 3. Migrate legacy data from `meta` JSONField
# Fetch and process `meta` data from the table, add values to the new `path` column as necessary.
# We will use SQLAlchemy core bindings to ensure safety across different databases.
file_table = table(
"file", column("id", String), column("meta", JSON), column("path", Text)
)
# Create connection to the database
connection = op.get_bind()
# Get the rows where `meta` has a path and `path` column is null (new column)
# Loop through each row in the result set to update the path
results = connection.execute(
sa.select(file_table.c.id, file_table.c.meta).where(
and_(file_table.c.path.is_(None), file_table.c.meta.isnot(None))
)
).fetchall()
# Iterate over each row to extract and update the `path` from `meta` column
for row in results:
if "path" in row.meta:
# Extract the `path` field from the `meta` JSON
path = row.meta.get("path")
# Update the `file` table with the new `path` value
connection.execute(
file_table.update()
.where(file_table.c.id == row.id)
.values({"path": path})
)
def downgrade():
# 1. Remove the `path` column
op.drop_column("file", "path")
# 2. Revert the `meta` column back to Text/JSONField
with op.batch_alter_table("file", schema=None) as batch_op:
batch_op.alter_column(
"meta", type_=sa.Text(), existing_type=sa.JSON(), existing_nullable=True
)

View file

@ -0,0 +1,50 @@
"""Add folder table
Revision ID: c69f45358db4
Revises: 3ab32c4b8f59
Create Date: 2024-10-16 02:02:35.241684
"""
from alembic import op
import sqlalchemy as sa
revision = "c69f45358db4"
down_revision = "3ab32c4b8f59"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"folder",
sa.Column("id", sa.Text(), nullable=False),
sa.Column("parent_id", sa.Text(), nullable=True),
sa.Column("user_id", sa.Text(), nullable=False),
sa.Column("name", sa.Text(), nullable=False),
sa.Column("items", sa.JSON(), nullable=True),
sa.Column("meta", sa.JSON(), nullable=True),
sa.Column("is_expanded", sa.Boolean(), default=False, nullable=False),
sa.Column(
"created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False
),
sa.Column(
"updated_at",
sa.DateTime(),
nullable=False,
server_default=sa.func.now(),
onupdate=sa.func.now(),
),
sa.PrimaryKeyConstraint("id", "user_id"),
)
op.add_column(
"chat",
sa.Column("folder_id", sa.Text(), nullable=True),
)
def downgrade():
op.drop_column("chat", "folder_id")
op.drop_table("folder")

View file

@ -123,6 +123,24 @@ def replace_messages_variable(template: str, messages: list[str]) -> str:
return template
def tags_generation_template(
template: str, messages: list[dict], user: Optional[dict] = None
) -> str:
prompt = get_last_user_message(messages)
template = replace_prompt_variable(template, prompt)
template = replace_messages_variable(template, messages)
template = prompt_template(
template,
**(
{"user_name": user.get("name"), "user_location": user.get("location")}
if user
else {}
),
)
return template
def search_query_generation_template(
template: str, messages: list[dict], user: Optional[dict] = None
) -> str:

View file

@ -91,4 +91,4 @@ docker~=7.1.0
pytest~=8.3.2
pytest-docker~=3.1.1
googleapis-common-protos=1.63.2
googleapis-common-protos==1.63.2

View file

@ -30,7 +30,7 @@ describe('Settings', () => {
// Select the first model
cy.get('button[aria-label="model-item"]').first().click();
// Type a message
cy.get('#chat-textarea').type('Hi, what can you do? A single sentence only please.', {
cy.get('#chat-input').type('Hi, what can you do? A single sentence only please.', {
force: true
});
// Send the message
@ -50,7 +50,7 @@ describe('Settings', () => {
// Select the first model
cy.get('button[aria-label="model-item"]').first().click();
// Type a message
cy.get('#chat-textarea').type('Hi, what can you do? A single sentence only please.', {
cy.get('#chat-input').type('Hi, what can you do? A single sentence only please.', {
force: true
});
// Send the message
@ -85,7 +85,7 @@ describe('Settings', () => {
// Select the first model
cy.get('button[aria-label="model-item"]').first().click();
// Type a message
cy.get('#chat-textarea').type('Hi, what can you do? A single sentence only please.', {
cy.get('#chat-input').type('Hi, what can you do? A single sentence only please.', {
force: true
});
// Send the message

236
package-lock.json generated
View file

@ -35,6 +35,16 @@
"mermaid": "^10.9.1",
"paneforge": "^0.0.6",
"panzoom": "^9.4.3",
"prosemirror-commands": "^1.6.0",
"prosemirror-example-setup": "^1.2.3",
"prosemirror-history": "^1.4.1",
"prosemirror-keymap": "^1.2.2",
"prosemirror-markdown": "^1.13.1",
"prosemirror-model": "^1.23.0",
"prosemirror-schema-basic": "^1.2.3",
"prosemirror-schema-list": "^1.4.1",
"prosemirror-state": "^1.4.3",
"prosemirror-view": "^1.34.3",
"pyodide": "^0.26.1",
"socket.io-client": "^4.2.0",
"sortablejs": "^1.15.2",
@ -1963,6 +1973,20 @@
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"dev": true
},
"node_modules/@types/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q=="
},
"node_modules/@types/markdown-it": {
"version": "14.1.2",
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz",
"integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
"dependencies": {
"@types/linkify-it": "^5",
"@types/mdurl": "^2"
}
},
"node_modules/@types/mdast": {
"version": "3.0.15",
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.15.tgz",
@ -1971,6 +1995,11 @@
"@types/unist": "^2"
}
},
"node_modules/@types/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg=="
},
"node_modules/@types/minimatch": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz",
@ -2552,8 +2581,7 @@
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
},
"node_modules/aria-query": {
"version": "5.3.0",
@ -4460,7 +4488,6 @@
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"dev": true,
"engines": {
"node": ">=0.12"
},
@ -6233,6 +6260,14 @@
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"dev": true
},
"node_modules/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
"dependencies": {
"uc.micro": "^2.0.0"
}
},
"node_modules/listr2": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/listr2/-/listr2-3.14.0.tgz",
@ -6470,6 +6505,22 @@
"@jridgewell/sourcemap-codec": "^1.5.0"
}
},
"node_modules/markdown-it": {
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz",
"integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==",
"dependencies": {
"argparse": "^2.0.1",
"entities": "^4.4.0",
"linkify-it": "^5.0.0",
"mdurl": "^2.0.0",
"punycode.js": "^2.3.1",
"uc.micro": "^2.1.0"
},
"bin": {
"markdown-it": "bin/markdown-it.mjs"
}
},
"node_modules/marked": {
"version": "9.1.6",
"resolved": "https://registry.npmjs.org/marked/-/marked-9.1.6.tgz",
@ -6556,6 +6607,11 @@
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz",
"integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA=="
},
"node_modules/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="
},
"node_modules/merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
@ -7332,6 +7388,11 @@
"node": ">= 0.8.0"
}
},
"node_modules/orderedmap": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g=="
},
"node_modules/ospath": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/ospath/-/ospath-1.2.2.tgz",
@ -7941,6 +8002,157 @@
"node": "10.* || >= 12.*"
}
},
"node_modules/prosemirror-commands": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.6.0.tgz",
"integrity": "sha512-xn1U/g36OqXn2tn5nGmvnnimAj/g1pUx2ypJJIe8WkVX83WyJVC5LTARaxZa2AtQRwntu9Jc5zXs9gL9svp/mg==",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-dropcursor": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.1.tgz",
"integrity": "sha512-M30WJdJZLyXHi3N8vxN6Zh5O8ZBbQCz0gURTfPmTIBNQ5pxrdU7A58QkNqfa98YEjSAL1HUyyU34f6Pm5xBSGw==",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0",
"prosemirror-view": "^1.1.0"
}
},
"node_modules/prosemirror-example-setup": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/prosemirror-example-setup/-/prosemirror-example-setup-1.2.3.tgz",
"integrity": "sha512-+hXZi8+xbFvYM465zZH3rdZ9w7EguVKmUYwYLZjIJIjPK+I0nPTwn8j0ByW2avchVczRwZmOJGNvehblyIerSQ==",
"dependencies": {
"prosemirror-commands": "^1.0.0",
"prosemirror-dropcursor": "^1.0.0",
"prosemirror-gapcursor": "^1.0.0",
"prosemirror-history": "^1.0.0",
"prosemirror-inputrules": "^1.0.0",
"prosemirror-keymap": "^1.0.0",
"prosemirror-menu": "^1.0.0",
"prosemirror-schema-list": "^1.0.0",
"prosemirror-state": "^1.0.0"
}
},
"node_modules/prosemirror-gapcursor": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.3.2.tgz",
"integrity": "sha512-wtjswVBd2vaQRrnYZaBCbyDqr232Ed4p2QPtRIUK5FuqHYKGWkEwl08oQM4Tw7DOR0FsasARV5uJFvMZWxdNxQ==",
"dependencies": {
"prosemirror-keymap": "^1.0.0",
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-view": "^1.0.0"
}
},
"node_modules/prosemirror-history": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.4.1.tgz",
"integrity": "sha512-2JZD8z2JviJrboD9cPuX/Sv/1ChFng+xh2tChQ2X4bB2HeK+rra/bmJ3xGntCcjhOqIzSDG6Id7e8RJ9QPXLEQ==",
"dependencies": {
"prosemirror-state": "^1.2.2",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.31.0",
"rope-sequence": "^1.3.0"
}
},
"node_modules/prosemirror-inputrules": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.4.0.tgz",
"integrity": "sha512-6ygpPRuTJ2lcOXs9JkefieMst63wVJBgHZGl5QOytN7oSZs3Co/BYbc3Yx9zm9H37Bxw8kVzCnDsihsVsL4yEg==",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-keymap": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.2.tgz",
"integrity": "sha512-EAlXoksqC6Vbocqc0GtzCruZEzYgrn+iiGnNjsJsH4mrnIGex4qbLdWWNza3AW5W36ZRrlBID0eM6bdKH4OStQ==",
"dependencies": {
"prosemirror-state": "^1.0.0",
"w3c-keyname": "^2.2.0"
}
},
"node_modules/prosemirror-markdown": {
"version": "1.13.1",
"resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.1.tgz",
"integrity": "sha512-Sl+oMfMtAjWtlcZoj/5L/Q39MpEnVZ840Xo330WJWUvgyhNmLBLN7MsHn07s53nG/KImevWHSE6fEj4q/GihHw==",
"dependencies": {
"@types/markdown-it": "^14.0.0",
"markdown-it": "^14.0.0",
"prosemirror-model": "^1.20.0"
}
},
"node_modules/prosemirror-menu": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.2.4.tgz",
"integrity": "sha512-S/bXlc0ODQup6aiBbWVsX/eM+xJgCTAfMq/nLqaO5ID/am4wS0tTCIkzwytmao7ypEtjj39i7YbJjAgO20mIqA==",
"dependencies": {
"crelt": "^1.0.0",
"prosemirror-commands": "^1.0.0",
"prosemirror-history": "^1.0.0",
"prosemirror-state": "^1.0.0"
}
},
"node_modules/prosemirror-model": {
"version": "1.23.0",
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.23.0.tgz",
"integrity": "sha512-Q/fgsgl/dlOAW9ILu4OOhYWQbc7TQd4BwKH/RwmUjyVf8682Be4zj3rOYdLnYEcGzyg8LL9Q5IWYKD8tdToreQ==",
"dependencies": {
"orderedmap": "^2.0.0"
}
},
"node_modules/prosemirror-schema-basic": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.3.tgz",
"integrity": "sha512-h+H0OQwZVqMon1PNn0AG9cTfx513zgIG2DY00eJ00Yvgb3UD+GQ/VlWW5rcaxacpCGT1Yx8nuhwXk4+QbXUfJA==",
"dependencies": {
"prosemirror-model": "^1.19.0"
}
},
"node_modules/prosemirror-schema-list": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.4.1.tgz",
"integrity": "sha512-jbDyaP/6AFfDfu70VzySsD75Om2t3sXTOdl5+31Wlxlg62td1haUpty/ybajSfJ1pkGadlOfwQq9kgW5IMo1Rg==",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.7.3"
}
},
"node_modules/prosemirror-state": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.3.tgz",
"integrity": "sha512-goFKORVbvPuAQaXhpbemJFRKJ2aixr+AZMGiquiqKxaucC6hlpHNZHWgz5R7dS4roHiwq9vDctE//CZ++o0W1Q==",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.27.0"
}
},
"node_modules/prosemirror-transform": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.10.0.tgz",
"integrity": "sha512-9UOgFSgN6Gj2ekQH5CTDJ8Rp/fnKR2IkYfGdzzp5zQMFsS4zDllLVx/+jGcX86YlACpG7UR5fwAXiWzxqWtBTg==",
"dependencies": {
"prosemirror-model": "^1.21.0"
}
},
"node_modules/prosemirror-view": {
"version": "1.34.3",
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.34.3.tgz",
"integrity": "sha512-mKZ54PrX19sSaQye+sef+YjBbNu2voNwLS1ivb6aD2IRmxRGW64HU9B644+7OfJStGLyxvOreKqEgfvXa91WIA==",
"dependencies": {
"prosemirror-model": "^1.20.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0"
}
},
"node_modules/proxy-from-env": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.0.0.tgz",
@ -7977,6 +8189,14 @@
"node": ">=6"
}
},
"node_modules/punycode.js": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
"engines": {
"node": ">=6"
}
},
"node_modules/pyodide": {
"version": "0.26.1",
"resolved": "https://registry.npmjs.org/pyodide/-/pyodide-0.26.1.tgz",
@ -8345,6 +8565,11 @@
"fsevents": "~2.3.2"
}
},
"node_modules/rope-sequence": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ=="
},
"node_modules/rsvp": {
"version": "4.8.5",
"resolved": "https://registry.npmjs.org/rsvp/-/rsvp-4.8.5.tgz",
@ -9562,6 +9787,11 @@
"node": ">=14.17"
}
},
"node_modules/uc.micro": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="
},
"node_modules/ufo": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.5.3.tgz",

View file

@ -75,6 +75,16 @@
"mermaid": "^10.9.1",
"paneforge": "^0.0.6",
"panzoom": "^9.4.3",
"prosemirror-commands": "^1.6.0",
"prosemirror-example-setup": "^1.2.3",
"prosemirror-history": "^1.4.1",
"prosemirror-keymap": "^1.2.2",
"prosemirror-markdown": "^1.13.1",
"prosemirror-model": "^1.23.0",
"prosemirror-schema-basic": "^1.2.3",
"prosemirror-schema-list": "^1.4.1",
"prosemirror-state": "^1.4.3",
"prosemirror-view": "^1.34.3",
"pyodide": "^0.26.1",
"socket.io-client": "^4.2.0",
"sortablejs": "^1.15.2",

View file

@ -95,7 +95,7 @@ dependencies = [
"pytest~=8.3.2",
"pytest-docker~=3.1.1",
"googleapis-common-protos=1.63.2"
"googleapis-common-protos==1.63.2"
]
readme = "README.md"
requires-python = ">= 3.11, < 3.12.0a1"

View file

@ -34,6 +34,14 @@ math {
@apply rounded-lg;
}
.input-prose {
@apply prose dark:prose-invert prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
}
.input-prose-sm {
@apply prose dark:prose-invert prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line text-sm;
}
.markdown-prose {
@apply prose dark:prose-invert prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
}
@ -56,7 +64,7 @@ li p {
::-webkit-scrollbar-thumb {
--tw-border-opacity: 1;
background-color: rgba(217, 217, 227, 0.8);
background-color: rgba(236, 236, 236, 0.8);
border-color: rgba(255, 255, 255, var(--tw-border-opacity));
border-radius: 9999px;
border-width: 1px;
@ -64,7 +72,7 @@ li p {
/* Dark theme scrollbar styles */
.dark ::-webkit-scrollbar-thumb {
background-color: rgba(69, 69, 74, 0.8); /* Darker color for dark theme */
background-color: rgba(33, 33, 33, 0.8); /* Darker color for dark theme */
border-color: rgba(0, 0, 0, var(--tw-border-opacity));
}
@ -179,3 +187,21 @@ input[type='number'] {
.bg-gray-950-90 {
background-color: rgba(var(--color-gray-950, #0d0d0d), 0.9);
}
.ProseMirror {
@apply h-full min-h-fit max-h-full;
}
.ProseMirror:focus {
outline: none;
}
.placeholder::after {
content: attr(data-placeholder);
cursor: text;
pointer-events: none;
float: left;
@apply absolute inset-0 z-0 text-gray-500;
}

View file

@ -32,6 +32,46 @@ export const createNewChat = async (token: string, chat: object) => {
return res;
};
export const importChat = async (
token: string,
chat: object,
meta: object | null,
pinned?: boolean,
folderId?: string | null
) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/import`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({
chat: chat,
meta: meta ?? {},
pinned: pinned,
folder_id: folderId
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getChatList = async (token: string = '', page: number | null = null) => {
let error = null;
const searchParams = new URLSearchParams();
@ -205,6 +245,37 @@ export const getChatListBySearchText = async (token: string, text: string, page:
}));
};
export const getChatsByFolderId = async (token: string, folderId: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/folder/${folderId}`, {
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.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getAllArchivedChats = async (token: string) => {
let error = null;
@ -579,6 +650,41 @@ export const shareChatById = async (token: string, id: string) => {
return res;
};
export const updateChatFolderIdById = async (token: string, id: string, folderId?: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/${id}/folder`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
},
body: JSON.stringify({
folder_id: folderId
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const archiveChatById = async (token: string, id: string) => {
let error = null;
@ -764,8 +870,7 @@ export const addTagById = async (token: string, id: string, tagName: string) =>
return json;
})
.catch((err) => {
error = err;
error = err.detail;
console.log(err);
return null;
});

View file

@ -0,0 +1,269 @@
import { WEBUI_API_BASE_URL } from '$lib/constants';
export const createNewFolder = async (token: string, name: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/folders/`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({
name: name
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
return null;
});
if (error) {
throw error;
}
return res;
};
export const getFolders = async (token: string = '') => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/folders/`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getFolderById = async (token: string, id: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/folders/${id}`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const updateFolderNameById = async (token: string, id: string, name: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/folders/${id}/update`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({
name: name
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const updateFolderIsExpandedById = async (
token: string,
id: string,
isExpanded: boolean
) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/folders/${id}/update/expanded`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({
is_expanded: isExpanded
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const updateFolderParentIdById = async (token: string, id: string, parentId?: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/folders/${id}/update/parent`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({
parent_id: parentId
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
type FolderItems = {
chat_ids: string[];
file_ids: string[];
};
export const updateFolderItemsById = async (token: string, id: string, items: FolderItems) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/folders/${id}/update/items`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({
items: items
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const deleteFolderById = async (token: string, id: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/folders/${id}`, {
method: 'DELETE',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};

View file

@ -245,6 +245,78 @@ export const generateTitle = async (
return res?.choices[0]?.message?.content.replace(/["']/g, '') ?? 'New Chat';
};
export const generateTags = async (
token: string = '',
model: string,
messages: string,
chat_id?: string
) => {
let error = null;
const res = await fetch(`${WEBUI_BASE_URL}/api/task/tags/completions`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
model: model,
messages: messages,
...(chat_id && { chat_id: chat_id })
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
if ('detail' in err) {
error = err.detail;
}
return null;
});
if (error) {
throw error;
}
try {
// Step 1: Safely extract the response string
const response = res?.choices[0]?.message?.content ?? '';
// Step 2: Attempt to fix common JSON format issues like single quotes
const sanitizedResponse = response.replace(/['`]/g, '"'); // Convert single quotes to double quotes for valid JSON
// Step 3: Find the relevant JSON block within the response
const jsonStartIndex = sanitizedResponse.indexOf('{');
const jsonEndIndex = sanitizedResponse.lastIndexOf('}');
// Step 4: Check if we found a valid JSON block (with both `{` and `}`)
if (jsonStartIndex !== -1 && jsonEndIndex !== -1) {
const jsonResponse = sanitizedResponse.substring(jsonStartIndex, jsonEndIndex + 1);
// Step 5: Parse the JSON block
const parsed = JSON.parse(jsonResponse);
// Step 6: If there's a "tags" key, return the tags array; otherwise, return an empty array
if (parsed && parsed.tags) {
return Array.isArray(parsed.tags) ? parsed.tags : [];
} else {
return [];
}
}
// If no valid JSON block found, return an empty array
return [];
} catch (e) {
// Catch and safely return empty array on any parsing errors
console.error('Failed to parse response: ', e);
return [];
}
};
export const generateEmoji = async (
token: string = '',
model: string,

View file

@ -28,6 +28,7 @@
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Switch from '$lib/components/common/Switch.svelte';
import { text } from '@sveltejs/kit';
import Textarea from '$lib/components/common/Textarea.svelte';
const i18n = getContext('i18n');
@ -629,11 +630,9 @@
content={$i18n.t('Leave empty to use the default prompt, or enter a custom prompt')}
placement="top-start"
>
<textarea
<Textarea
bind:value={querySettings.template}
placeholder={$i18n.t('Leave empty to use the default prompt, or enter a custom prompt')}
class="w-full rounded-lg px-4 py-3 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none resize-none"
rows="4"
/>
</Tooltip>
</div>

View file

@ -14,6 +14,7 @@
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Switch from '$lib/components/common/Switch.svelte';
import Textarea from '$lib/components/common/Textarea.svelte';
const dispatch = createEventDispatcher();
@ -23,6 +24,7 @@
TASK_MODEL: '',
TASK_MODEL_EXTERNAL: '',
TITLE_GENERATION_PROMPT_TEMPLATE: '',
TAG_GENERATION_PROMPT_TEMPLATE: '',
ENABLE_SEARCH_QUERY: true,
SEARCH_QUERY_GENERATION_PROMPT_TEMPLATE: ''
};
@ -124,10 +126,22 @@
content={$i18n.t('Leave empty to use the default prompt, or enter a custom prompt')}
placement="top-start"
>
<textarea
<Textarea
bind:value={taskConfig.TITLE_GENERATION_PROMPT_TEMPLATE}
class="w-full rounded-lg py-3 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none resize-none"
rows="3"
placeholder={$i18n.t('Leave empty to use the default prompt, or enter a custom prompt')}
/>
</Tooltip>
</div>
<div class="mt-3">
<div class=" mb-2.5 text-xs font-medium">{$i18n.t('Tags Generation Prompt')}</div>
<Tooltip
content={$i18n.t('Leave empty to use the default prompt, or enter a custom prompt')}
placement="top-start"
>
<Textarea
bind:value={taskConfig.TAG_GENERATION_PROMPT_TEMPLATE}
placeholder={$i18n.t('Leave empty to use the default prompt, or enter a custom prompt')}
/>
</Tooltip>
@ -151,10 +165,8 @@
content={$i18n.t('Leave empty to use the default prompt, or enter a custom prompt')}
placement="top-start"
>
<textarea
<Textarea
bind:value={taskConfig.SEARCH_QUERY_GENERATION_PROMPT_TEMPLATE}
class="w-full rounded-lg py-3 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none resize-none"
rows="3"
placeholder={$i18n.t(
'Leave empty to use the default prompt, or enter a custom prompt'
)}

View file

@ -763,7 +763,7 @@
<option value="" disabled selected>{$i18n.t('Select a model')}</option>
{/if}
{#each $models.filter((m) => !(m?.preset ?? false) && m.owned_by === 'ollama' && (selectedOllamaUrlIdx === null ? true : (m?.ollama?.urls ?? []).includes(selectedOllamaUrlIdx))) as model}
<option value={model.name} class="bg-gray-50 dark:bg-gray-700"
<option value={model.id} class="bg-gray-50 dark:bg-gray-700"
>{model.name +
' (' +
(model.ollama.size / 1024 ** 3).toFixed(1) +

View file

@ -546,12 +546,14 @@
{/if}
</div>
<div class="flex justify-end pt-3 text-sm font-medium">
<button
class=" px-4 py-2 bg-emerald-700 hover:bg-emerald-800 text-gray-100 transition rounded-lg"
type="submit"
>
{$i18n.t('Save')}
</button>
</div>
{#if PIPELINES_LIST !== null && PIPELINES_LIST.length > 0}
<div class="flex justify-end pt-3 text-sm font-medium">
<button
class=" px-4 py-2 bg-emerald-700 hover:bg-emerald-800 text-gray-100 transition rounded-lg"
type="submit"
>
{$i18n.t('Save')}
</button>
</div>
{/if}
</form>

View file

@ -10,7 +10,7 @@
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import type { Unsubscriber, Writable } from 'svelte/store';
import { get, type Unsubscriber, type Writable } from 'svelte/store';
import type { i18n as i18nType } from 'i18next';
import { WEBUI_BASE_URL } from '$lib/constants';
@ -20,6 +20,7 @@
config,
type Model,
models,
tags as allTags,
settings,
showSidebar,
WEBUI_NAME,
@ -46,7 +47,9 @@
import { generateChatCompletion } from '$lib/apis/ollama';
import {
addTagById,
createNewChat,
getAllTags,
getChatById,
getChatList,
getTagsById,
@ -62,7 +65,8 @@
generateTitle,
generateSearchQuery,
chatAction,
generateMoACompletion
generateMoACompletion,
generateTags
} from '$lib/apis';
import Banner from '../common/Banner.svelte';
@ -85,6 +89,8 @@
let processing = '';
let messagesContainerElement: HTMLDivElement;
let navbarElement;
let showEventConfirmation = false;
let eventConfirmationTitle = '';
let eventConfirmationMessage = '';
@ -125,7 +131,7 @@
loaded = true;
window.setTimeout(() => scrollToBottom(), 0);
const chatInput = document.getElementById('chat-textarea');
const chatInput = document.getElementById('chat-input');
chatInput?.focus();
} else {
await goto('/');
@ -264,7 +270,7 @@
if (event.data.type === 'input:prompt') {
console.debug(event.data.text);
const inputElement = document.getElementById('chat-textarea');
const inputElement = document.getElementById('chat-input');
if (inputElement) {
prompt = event.data.text;
@ -327,7 +333,7 @@
}
});
const chatInput = document.getElementById('chat-textarea');
const chatInput = document.getElementById('chat-input');
chatInput?.focus();
chats.subscribe(() => {});
@ -437,7 +443,29 @@
if ($page.url.searchParams.get('models')) {
selectedModels = $page.url.searchParams.get('models')?.split(',');
} else if ($page.url.searchParams.get('model')) {
selectedModels = $page.url.searchParams.get('model')?.split(',');
const urlModels = $page.url.searchParams.get('model')?.split(',');
if (urlModels.length === 1) {
const m = $models.find((m) => m.id === urlModels[0]);
if (!m) {
const modelSelectorButton = document.getElementById('model-selector-0-button');
if (modelSelectorButton) {
modelSelectorButton.click();
await tick();
const modelSelectorInput = document.getElementById('model-search-input');
if (modelSelectorInput) {
modelSelectorInput.focus();
modelSelectorInput.value = urlModels[0];
modelSelectorInput.dispatchEvent(new Event('input'));
}
}
} else {
selectedModels = urlModels;
}
} else {
selectedModels = urlModels;
}
} else if ($settings?.models) {
selectedModels = $settings?.models;
} else if ($config?.default_models) {
@ -501,7 +529,7 @@
settings.set(JSON.parse(localStorage.getItem('settings') ?? '{}'));
}
const chatInput = document.getElementById('chat-textarea');
const chatInput = document.getElementById('chat-input');
setTimeout(() => chatInput?.focus(), 0);
};
@ -513,7 +541,10 @@
});
if (chat) {
tags = await getTags();
tags = await getTagsById(localStorage.token, $chatId).catch(async (error) => {
return [];
});
const chatContent = chat.chat;
if (chatContent) {
@ -798,12 +829,14 @@
})
);
} else {
// Reset chat input textarea
const chatTextAreaElement = document.getElementById('chat-textarea');
prompt = '';
if (chatTextAreaElement) {
chatTextAreaElement.value = '';
chatTextAreaElement.style.height = '';
// Reset chat input textarea
const chatInputContainer = document.getElementById('chat-input-container');
if (chatInputContainer) {
chatInputContainer.value = '';
chatInputContainer.style.height = '';
}
const _files = JSON.parse(JSON.stringify(files));
@ -841,6 +874,11 @@
// Wait until history/message have been updated
await tick();
// focus on chat input
const chatInput = document.getElementById('chat-input');
chatInput?.focus();
_responses = await sendPrompt(userPrompt, userMessageId, { newChat: true });
}
@ -1364,6 +1402,10 @@
window.history.replaceState(history.state, '', `/c/${_chatId}`);
const title = await generateChatTitle(userPrompt);
await setChatTitle(_chatId, title);
if ($settings?.autoTags ?? true) {
await setChatTags(messages);
}
}
return _response;
@ -1678,6 +1720,10 @@
window.history.replaceState(history.state, '', `/c/${_chatId}`);
const title = await generateChatTitle(userPrompt);
await setChatTitle(_chatId, title);
if ($settings?.autoTags ?? true) {
await setChatTags(messages);
}
}
return _response;
@ -1864,6 +1910,33 @@
}
};
const setChatTags = async (messages) => {
if (!$temporaryChatEnabled) {
let generatedTags = await generateTags(
localStorage.token,
selectedModels[0],
messages,
$chatId
).catch((error) => {
console.error(error);
return [];
});
const currentTags = await getTagsById(localStorage.token, $chatId);
generatedTags = generatedTags.filter(
(tag) => !currentTags.find((t) => t.id === tag.replaceAll(' ', '_').toLowerCase())
);
console.log(generatedTags);
for (const tag of generatedTags) {
await addTagById(localStorage.token, $chatId, tag);
}
chat = await getChatById(localStorage.token, $chatId);
allTags.set(await getAllTags(localStorage.token));
}
};
const getWebSearchResults = async (
model: string,
parentId: string,
@ -1949,12 +2022,6 @@
}
};
const getTags = async () => {
return await getTagsById(localStorage.token, $chatId).catch(async (error) => {
return [];
});
};
const initChatHandler = async () => {
if (!$temporaryChatEnabled) {
chat = await createNewChat(localStorage.token, {
@ -2046,6 +2113,7 @@
{/if}
<Navbar
bind:this={navbarElement}
chat={{
id: $chatId,
chat: {
@ -2182,9 +2250,8 @@
}}
on:submit={async (e) => {
if (e.detail) {
prompt = '';
await tick();
submitPrompt(e.detail);
submitPrompt(e.detail.replaceAll('\n\n', '\n'));
}
}}
/>
@ -2227,9 +2294,8 @@
}}
on:submit={async (e) => {
if (e.detail) {
prompt = '';
await tick();
submitPrompt(e.detail);
submitPrompt(e.detail.replaceAll('\n\n', '\n'));
}
}}
/>

View file

@ -29,6 +29,7 @@
import FilesOverlay from './MessageInput/FilesOverlay.svelte';
import Commands from './MessageInput/Commands.svelte';
import XMark from '../icons/XMark.svelte';
import RichTextInput from '../common/RichTextInput.svelte';
const i18n = getContext('i18n');
@ -52,9 +53,10 @@
let recording = false;
let chatTextAreaElement: HTMLTextAreaElement;
let filesInputElement;
let chatInputContainerElement;
let chatInputElement;
let filesInputElement;
let commandsElement;
let inputFiles;
@ -69,9 +71,10 @@
);
$: if (prompt) {
if (chatTextAreaElement) {
chatTextAreaElement.style.height = '';
chatTextAreaElement.style.height = Math.min(chatTextAreaElement.scrollHeight, 200) + 'px';
if (chatInputContainerElement) {
chatInputContainerElement.style.height = '';
chatInputContainerElement.style.height =
Math.min(chatInputContainerElement.scrollHeight, 200) + 'px';
}
}
@ -213,7 +216,10 @@
};
onMount(() => {
window.setTimeout(() => chatTextAreaElement?.focus(), 0);
window.setTimeout(() => {
const chatInput = document.getElementById('chat-input');
chatInput?.focus();
}, 0);
window.addEventListener('keydown', handleKeyDown);
@ -316,7 +322,8 @@
atSelectedModel = data.data;
}
chatTextAreaElement?.focus();
const chatInputElement = document.getElementById('chat-input');
chatInputElement?.focus();
}}
/>
</div>
@ -351,7 +358,7 @@
recording = false;
await tick();
document.getElementById('chat-textarea')?.focus();
document.getElementById('chat-input')?.focus();
}}
on:confirm={async (e) => {
const response = e.detail;
@ -360,7 +367,7 @@
recording = false;
await tick();
document.getElementById('chat-textarea')?.focus();
document.getElementById('chat-input')?.focus();
if ($settings?.speechAutoSend ?? false) {
dispatch('submit', prompt);
@ -478,7 +485,9 @@
}}
onClose={async () => {
await tick();
chatTextAreaElement?.focus();
const chatInput = document.getElementById('chat-input');
chatInput?.focus();
}}
>
<button
@ -500,177 +509,172 @@
</InputMenu>
</div>
<textarea
id="chat-textarea"
bind:this={chatTextAreaElement}
class="scrollbar-hidden bg-gray-50 dark:bg-gray-850 dark:text-gray-100 outline-none w-full py-3 px-1 rounded-xl resize-none h-[48px]"
placeholder={placeholder ? placeholder : $i18n.t('Send a Message')}
bind:value={prompt}
on:keypress={(e) => {
if (
!$mobile ||
<div
bind:this={chatInputContainerElement}
id="chat-input-container"
class="scrollbar-hidden text-left bg-gray-50 dark:bg-gray-850 dark:text-gray-100 outline-none w-full py-2.5 px-1 rounded-xl resize-none h-[48px] overflow-auto"
>
<RichTextInput
bind:this={chatInputElement}
id="chat-input"
placeholder={placeholder ? placeholder : $i18n.t('Send a Message')}
bind:value={prompt}
shiftEnter={!$mobile ||
!(
'ontouchstart' in window ||
navigator.maxTouchPoints > 0 ||
navigator.msMaxTouchPoints > 0
)
) {
// Prevent Enter key from creating a new line
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
}
// Submit the prompt when Enter key is pressed
if (prompt !== '' && e.key === 'Enter' && !e.shiftKey) {
)}
on:enter={async (e) => {
if (prompt !== '') {
dispatch('submit', prompt);
}
}
}}
on:keydown={async (e) => {
const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac
const commandsContainerElement = document.getElementById('commands-container');
// Command/Ctrl + Shift + Enter to submit a message pair
if (isCtrlPressed && e.key === 'Enter' && e.shiftKey) {
e.preventDefault();
createMessagePair(prompt);
}
// Check if Ctrl + R is pressed
if (prompt === '' && isCtrlPressed && e.key.toLowerCase() === 'r') {
e.preventDefault();
console.log('regenerate');
const regenerateButton = [
...document.getElementsByClassName('regenerate-response-button')
]?.at(-1);
regenerateButton?.click();
}
if (prompt === '' && e.key == 'ArrowUp') {
e.preventDefault();
const userMessageElement = [
...document.getElementsByClassName('user-message')
]?.at(-1);
const editButton = [
...document.getElementsByClassName('edit-user-message-button')
]?.at(-1);
console.log(userMessageElement);
userMessageElement.scrollIntoView({ block: 'center' });
editButton?.click();
}
if (commandsContainerElement && e.key === 'ArrowUp') {
e.preventDefault();
commandsElement.selectUp();
const commandOptionButton = [
...document.getElementsByClassName('selected-command-option-button')
]?.at(-1);
commandOptionButton.scrollIntoView({ block: 'center' });
}
if (commandsContainerElement && e.key === 'ArrowDown') {
e.preventDefault();
commandsElement.selectDown();
const commandOptionButton = [
...document.getElementsByClassName('selected-command-option-button')
]?.at(-1);
commandOptionButton.scrollIntoView({ block: 'center' });
}
if (commandsContainerElement && e.key === 'Enter') {
e.preventDefault();
const commandOptionButton = [
...document.getElementsByClassName('selected-command-option-button')
]?.at(-1);
if (e.shiftKey) {
prompt = `${prompt}\n`;
} else if (commandOptionButton) {
commandOptionButton?.click();
} else {
document.getElementById('send-message-button')?.click();
}}
on:input={async (e) => {
if (chatInputContainerElement) {
chatInputContainerElement.style.height = '';
chatInputContainerElement.style.height =
Math.min(chatInputContainerElement.scrollHeight, 200) + 'px';
}
}
}}
on:focus={async (e) => {
if (chatInputContainerElement) {
chatInputContainerElement.style.height = '';
chatInputContainerElement.style.height =
Math.min(chatInputContainerElement.scrollHeight, 200) + 'px';
}
}}
on:keypress={(e) => {
e = e.detail.event;
}}
on:keydown={async (e) => {
e = e.detail.event;
if (commandsContainerElement && e.key === 'Tab') {
e.preventDefault();
if (chatInputContainerElement) {
chatInputContainerElement.style.height = '';
chatInputContainerElement.style.height =
Math.min(chatInputContainerElement.scrollHeight, 200) + 'px';
}
const commandOptionButton = [
...document.getElementsByClassName('selected-command-option-button')
]?.at(-1);
commandOptionButton?.click();
} else if (e.key === 'Tab') {
const words = findWordIndices(prompt);
if (words.length > 0) {
const word = words.at(0);
const fullPrompt = prompt;
prompt = prompt.substring(0, word?.endIndex + 1);
await tick();
e.target.scrollTop = e.target.scrollHeight;
prompt = fullPrompt;
await tick();
const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac
const commandsContainerElement =
document.getElementById('commands-container');
// Command/Ctrl + Shift + Enter to submit a message pair
if (isCtrlPressed && e.key === 'Enter' && e.shiftKey) {
e.preventDefault();
e.target.setSelectionRange(word?.startIndex, word.endIndex + 1);
createMessagePair(prompt);
}
e.target.style.height = '';
e.target.style.height = Math.min(e.target.scrollHeight, 200) + 'px';
}
// Check if Ctrl + R is pressed
if (prompt === '' && isCtrlPressed && e.key.toLowerCase() === 'r') {
e.preventDefault();
console.log('regenerate');
if (e.key === 'Escape') {
console.log('Escape');
atSelectedModel = undefined;
}
}}
rows="1"
on:input={async (e) => {
e.target.style.height = '';
e.target.style.height = Math.min(e.target.scrollHeight, 200) + 'px';
user = null;
}}
on:focus={async (e) => {
e.target.style.height = '';
e.target.style.height = Math.min(e.target.scrollHeight, 200) + 'px';
}}
on:paste={async (e) => {
const clipboardData = e.clipboardData || window.clipboardData;
const regenerateButton = [
...document.getElementsByClassName('regenerate-response-button')
]?.at(-1);
if (clipboardData && clipboardData.items) {
for (const item of clipboardData.items) {
if (item.type.indexOf('image') !== -1) {
const blob = item.getAsFile();
const reader = new FileReader();
regenerateButton?.click();
}
reader.onload = function (e) {
files = [
...files,
{
type: 'image',
url: `${e.target.result}`
}
];
};
if (prompt === '' && e.key == 'ArrowUp') {
e.preventDefault();
reader.readAsDataURL(blob);
const userMessageElement = [
...document.getElementsByClassName('user-message')
]?.at(-1);
const editButton = [
...document.getElementsByClassName('edit-user-message-button')
]?.at(-1);
console.log(userMessageElement);
userMessageElement.scrollIntoView({ block: 'center' });
editButton?.click();
}
if (commandsContainerElement && e.key === 'ArrowUp') {
e.preventDefault();
commandsElement.selectUp();
const commandOptionButton = [
...document.getElementsByClassName('selected-command-option-button')
]?.at(-1);
commandOptionButton.scrollIntoView({ block: 'center' });
}
if (commandsContainerElement && e.key === 'ArrowDown') {
e.preventDefault();
commandsElement.selectDown();
const commandOptionButton = [
...document.getElementsByClassName('selected-command-option-button')
]?.at(-1);
commandOptionButton.scrollIntoView({ block: 'center' });
}
if (commandsContainerElement && e.key === 'Enter') {
e.preventDefault();
const commandOptionButton = [
...document.getElementsByClassName('selected-command-option-button')
]?.at(-1);
if (e.shiftKey) {
prompt = `${prompt}\n`;
} else if (commandOptionButton) {
commandOptionButton?.click();
} else {
document.getElementById('send-message-button')?.click();
}
}
}
}}
/>
if (commandsContainerElement && e.key === 'Tab') {
e.preventDefault();
const commandOptionButton = [
...document.getElementsByClassName('selected-command-option-button')
]?.at(-1);
commandOptionButton?.click();
}
if (e.key === 'Escape') {
console.log('Escape');
atSelectedModel = undefined;
}
}}
on:paste={async (e) => {
e = e.detail.event;
console.log(e);
const clipboardData = e.clipboardData || window.clipboardData;
if (clipboardData && clipboardData.items) {
for (const item of clipboardData.items) {
if (item.type.indexOf('image') !== -1) {
const blob = item.getAsFile();
const reader = new FileReader();
reader.onload = function (e) {
files = [
...files,
{
type: 'image',
url: `${e.target.result}`
}
];
};
reader.readAsDataURL(blob);
}
}
}
}}
/>
</div>
<div class="self-end mb-2 flex space-x-1 mr-1">
{#if !history?.currentId || history.messages[history.currentId]?.done == true}

View file

@ -25,17 +25,17 @@
};
let command = '';
$: command = (prompt?.trim() ?? '').split(' ')?.at(-1) ?? '';
$: command = prompt?.split('\n').pop()?.split(' ')?.pop() ?? '';
</script>
{#if ['/', '#', '@'].includes(command?.charAt(0))}
{#if ['/', '#', '@'].includes(command?.charAt(0)) || '\\#' === command.slice(0, 2)}
{#if command?.charAt(0) === '/'}
<Prompts bind:this={commandElement} bind:prompt bind:files {command} />
{:else if command?.charAt(0) === '#'}
{:else if (command?.charAt(0) === '#' && command.startsWith('#') && !command.includes('# ')) || ('\\#' === command.slice(0, 2) && command.startsWith('#') && !command.includes('# '))}
<Knowledge
bind:this={commandElement}
bind:prompt
{command}
command={command.includes('\\#') ? command.slice(2) : command}
on:youtube={(e) => {
console.log(e);
dispatch('upload', {

View file

@ -46,7 +46,7 @@
dispatch('select', item);
prompt = removeLastWordFromString(prompt, command);
const chatInputElement = document.getElementById('chat-textarea');
const chatInputElement = document.getElementById('chat-input');
await tick();
chatInputElement?.focus();
@ -57,7 +57,7 @@
dispatch('url', url);
prompt = removeLastWordFromString(prompt, command);
const chatInputElement = document.getElementById('chat-textarea');
const chatInputElement = document.getElementById('chat-input');
await tick();
chatInputElement?.focus();
@ -68,7 +68,7 @@
dispatch('youtube', url);
prompt = removeLastWordFromString(prompt, command);
const chatInputElement = document.getElementById('chat-textarea');
const chatInputElement = document.getElementById('chat-input');
await tick();
chatInputElement?.focus();

View file

@ -58,7 +58,7 @@
onMount(async () => {
await tick();
const chatInputElement = document.getElementById('chat-textarea');
const chatInputElement = document.getElementById('chat-input');
await tick();
chatInputElement?.focus();
await tick();

View file

@ -110,21 +110,17 @@
prompt = text;
const chatInputElement = document.getElementById('chat-textarea');
const chatInputContainerElement = document.getElementById('chat-input-container');
const chatInputElement = document.getElementById('chat-input');
await tick();
chatInputElement.style.height = '';
chatInputElement.style.height = Math.min(chatInputElement.scrollHeight, 200) + 'px';
if (chatInputContainerElement) {
chatInputContainerElement.style.height = '';
chatInputContainerElement.style.height =
Math.min(chatInputContainerElement.scrollHeight, 200) + 'px';
chatInputElement?.focus();
await tick();
const words = findWordIndices(prompt);
if (words.length > 0) {
const word = words.at(0);
chatInputElement.setSelectionRange(word?.startIndex, word.endIndex + 1);
chatInputElement?.focus();
}
};
</script>

View file

@ -61,9 +61,14 @@
<div
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer rounded-xl"
>
<div class="flex-1 flex items-center gap-2">
<WrenchSolid />
<Tooltip content={tools[toolId]?.description ?? ''} className="flex-1">
<div class="flex-1">
<Tooltip
content={tools[toolId]?.description ?? ''}
placement="top-start"
className="flex flex-1 gap-2 items-center"
>
<WrenchSolid />
<div class=" line-clamp-1">{tools[toolId].name}</div>
</Tooltip>
</div>

View file

@ -11,6 +11,7 @@
const dispatch = createEventDispatcher();
export let recording = false;
export let className = ' p-2.5 w-full max-w-full';
let loading = false;
let confirmed = false;
@ -213,7 +214,7 @@
transcription = `${transcription}${transcript}`;
await tick();
document.getElementById('chat-textarea')?.focus();
document.getElementById('chat-input')?.focus();
// Restart the inactivity timeout
timeoutId = setTimeout(() => {
@ -282,7 +283,7 @@
<div
class="{loading
? ' bg-gray-100/50 dark:bg-gray-850/50'
: 'bg-indigo-300/10 dark:bg-indigo-500/10 '} rounded-full flex p-2.5"
: 'bg-indigo-300/10 dark:bg-indigo-500/10 '} rounded-full flex {className}"
>
<div class="flex items-center mr-1">
<button

View file

@ -330,20 +330,14 @@
await tick();
const chatInputElement = document.getElementById('chat-textarea');
if (chatInputElement) {
const chatInputContainerElement = document.getElementById('chat-input-container');
if (chatInputContainerElement) {
prompt = p;
chatInputElement.style.height = '';
chatInputElement.style.height = Math.min(chatInputElement.scrollHeight, 200) + 'px';
chatInputElement.focus();
const words = findWordIndices(prompt);
if (words.length > 0) {
const word = words.at(0);
chatInputElement.setSelectionRange(word?.startIndex, word.endIndex + 1);
}
chatInputContainerElement.style.height = '';
chatInputContainerElement.style.height =
Math.min(chatInputContainerElement.scrollHeight, 200) + 'px';
chatInputContainerElement.focus();
}
await tick();

View file

@ -1,67 +1,219 @@
<script lang="ts">
import { getContext } from 'svelte';
import CitationsModal from './CitationsModal.svelte';
import Collapsible from '$lib/components/common/Collapsible.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
const i18n = getContext('i18n');
export let citations = [];
let _citations = [];
$: _citations = citations.reduce((acc, citation) => {
citation.document.forEach((document, index) => {
const metadata = citation.metadata?.[index];
const id = metadata?.source ?? 'N/A';
let source = citation?.source;
if (metadata?.name) {
source = { ...source, name: metadata.name };
}
// Check if ID looks like a URL
if (id.startsWith('http://') || id.startsWith('https://')) {
source = { name: id };
}
const existingSource = acc.find((item) => item.id === id);
if (existingSource) {
existingSource.document.push(document);
existingSource.metadata.push(metadata);
} else {
acc.push({
id: id,
source: source,
document: [document],
metadata: metadata ? [metadata] : []
});
}
});
return acc;
}, []);
let showPercentage = false;
let showRelevance = true;
let showCitationModal = false;
let selectedCitation = null;
let selectedCitation: any = null;
let isCollapsibleOpen = false;
function calculateShowRelevance(citations: any[]) {
const distances = citations.flatMap((citation) => citation.distances ?? []);
const inRange = distances.filter((d) => d !== undefined && d >= -1 && d <= 1).length;
const outOfRange = distances.filter((d) => d !== undefined && (d < -1 || d > 1)).length;
if (distances.length === 0) {
return false;
}
if (
(inRange === distances.length - 1 && outOfRange === 1) ||
(outOfRange === distances.length - 1 && inRange === 1)
) {
return false;
}
return true;
}
function shouldShowPercentage(citations: any[]) {
const distances = citations.flatMap((citation) => citation.distances ?? []);
return distances.every((d) => d !== undefined && d >= -1 && d <= 1);
}
$: {
_citations = citations.reduce((acc, citation) => {
citation.document.forEach((document, index) => {
const metadata = citation.metadata?.[index];
const distance = citation.distances?.[index];
const id = metadata?.source ?? 'N/A';
let source = citation?.source;
if (metadata?.name) {
source = { ...source, name: metadata.name };
}
if (id.startsWith('http://') || id.startsWith('https://')) {
source = { name: id };
}
const existingSource = acc.find((item) => item.id === id);
if (existingSource) {
existingSource.document.push(document);
existingSource.metadata.push(metadata);
if (distance !== undefined) existingSource.distances.push(distance);
} else {
acc.push({
id: id,
source: source,
document: [document],
metadata: metadata ? [metadata] : [],
distances: distance !== undefined ? [distance] : undefined
});
}
});
return acc;
}, []);
showRelevance = calculateShowRelevance(_citations);
showPercentage = shouldShowPercentage(_citations);
}
</script>
<CitationsModal bind:show={showCitationModal} citation={selectedCitation} />
<CitationsModal
bind:show={showCitationModal}
citation={selectedCitation}
{showPercentage}
{showRelevance}
/>
{#if _citations.length > 0}
<div class="mt-1 mb-2 w-full flex gap-1 items-center flex-wrap">
{#each _citations as citation, idx}
<div class="flex gap-1 text-xs font-semibold">
<button
class="flex dark:text-gray-300 py-1 px-1 bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 transition rounded-xl max-w-96"
on:click={() => {
showCitationModal = true;
selectedCitation = citation;
}}
{#if _citations.length <= 3}
{#each _citations as citation, idx}
<div class="flex gap-1 text-xs font-semibold">
<button
class="no-toggle flex dark:text-gray-300 py-1 px-1 bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 transition rounded-xl max-w-96"
on:click={() => {
showCitationModal = true;
selectedCitation = citation;
}}
>
{#if _citations.every((c) => c.distances !== undefined)}
<div class="bg-white dark:bg-gray-700 rounded-full size-4">
{idx + 1}
</div>
{/if}
<div class="flex-1 mx-2 line-clamp-1 truncate">
{citation.source.name}
</div>
</button>
</div>
{/each}
{:else}
<Collapsible bind:open={isCollapsibleOpen} className="w-full">
<div
class="flex items-center gap-1 text-gray-500 hover:text-gray-600 dark:hover:text-gray-400 transition cursor-pointer"
>
<div class="bg-white dark:bg-gray-700 rounded-full size-4">
{idx + 1}
<div class="flex-grow flex items-center gap-1 overflow-hidden">
<span class="whitespace-nowrap hidden sm:inline">{$i18n.t('References from')}</span>
<div class="flex items-center">
{#if _citations.length > 1 && _citations
.slice(0, 2)
.reduce((acc, citation) => acc + citation.source.name.length, 0) <= 50}
{#each _citations.slice(0, 2) as citation, idx}
<div class="flex items-center">
<button
class="no-toggle flex dark:text-gray-300 py-1 px-1 bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 transition rounded-xl max-w-96 text-xs font-semibold"
on:click={() => {
showCitationModal = true;
selectedCitation = citation;
}}
>
{#if _citations.every((c) => c.distances !== undefined)}
<div class="bg-white dark:bg-gray-700 rounded-full size-4">
{idx + 1}
</div>
{/if}
<div class="flex-1 mx-2 line-clamp-1">
{citation.source.name}
</div>
</button>
{#if idx === 0}<span class="mr-1">,</span>
{/if}
</div>
{/each}
{:else}
{#each _citations.slice(0, 1) as citation, idx}
<div class="flex items-center">
<button
class="no-toggle flex dark:text-gray-300 py-1 px-1 bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 transition rounded-xl max-w-96 text-xs font-semibold"
on:click={() => {
showCitationModal = true;
selectedCitation = citation;
}}
>
{#if _citations.every((c) => c.distances !== undefined)}
<div class="bg-white dark:bg-gray-700 rounded-full size-4">
{idx + 1}
</div>
{/if}
<div class="flex-1 mx-2 line-clamp-1">
{citation.source.name}
</div>
</button>
</div>
{/each}
{/if}
</div>
<div class="flex items-center gap-1 whitespace-nowrap">
<span class="hidden sm:inline">{$i18n.t('and')}</span>
<span class="text-gray-600 dark:text-gray-400">
{_citations.length -
(_citations.length > 1 &&
_citations
.slice(0, 2)
.reduce((acc, citation) => acc + citation.source.name.length, 0) <= 50
? 2
: 1)}
</span>
<span>{$i18n.t('more')}</span>
</div>
</div>
<div class="flex-1 mx-2 line-clamp-1">
{citation.source.name}
<div class="flex-shrink-0">
{#if isCollapsibleOpen}
<ChevronUp strokeWidth="3.5" className="size-3.5" />
{:else}
<ChevronDown strokeWidth="3.5" className="size-3.5" />
{/if}
</div>
</button>
</div>
{/each}
</div>
<div slot="content" class="mt-2">
<div class="flex flex-wrap gap-2">
{#each _citations as citation, idx}
<div class="flex gap-1 text-xs font-semibold">
<button
class="no-toggle flex dark:text-gray-300 py-1 px-1 bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 transition rounded-xl max-w-96"
on:click={() => {
showCitationModal = true;
selectedCitation = citation;
}}
>
{#if _citations.every((c) => c.distances !== undefined)}
<div class="bg-white dark:bg-gray-700 rounded-full size-4">
{idx + 1}
</div>
{/if}
<div class="flex-1 mx-2 line-clamp-1">
{citation.source.name}
</div>
</button>
</div>
{/each}
</div>
</div>
</Collapsible>
{/if}
</div>
{/if}

View file

@ -2,21 +2,44 @@
import { getContext, onMount, tick } from 'svelte';
import Modal from '$lib/components/common/Modal.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
const i18n = getContext('i18n');
export let show = false;
export let citation;
export let showPercentage = false;
export let showRelevance = true;
let mergedDocuments = [];
function calculatePercentage(distance: number) {
if (distance < 0) return 100;
if (distance > 1) return 0;
return Math.round((1 - distance) * 10000) / 100;
}
function getRelevanceColor(percentage: number) {
if (percentage >= 80)
return 'bg-green-200 dark:bg-green-800 text-green-800 dark:text-green-200';
if (percentage >= 60)
return 'bg-yellow-200 dark:bg-yellow-800 text-yellow-800 dark:text-yellow-200';
if (percentage >= 40)
return 'bg-orange-200 dark:bg-orange-800 text-orange-800 dark:text-orange-200';
return 'bg-red-200 dark:bg-red-800 text-red-800 dark:text-red-200';
}
$: if (citation) {
mergedDocuments = citation.document?.map((c, i) => {
return {
source: citation.source,
document: c,
metadata: citation.metadata?.[i]
metadata: citation.metadata?.[i],
distance: citation.distances?.[i]
};
});
if (mergedDocuments.every((doc) => doc.distance !== undefined)) {
mergedDocuments.sort((a, b) => (a.distance ?? Infinity) - (b.distance ?? Infinity));
}
}
</script>
@ -57,13 +80,14 @@
{#if document.source?.name}
<Tooltip
className="w-fit"
content={$i18n.t('Open file')}
placement="left"
tippyOptions={{ duration: [500, 0], animation: 'perspective' }}
placement="top-start"
tippyOptions={{ duration: [500, 0] }}
>
<div class="text-sm dark:text-gray-400">
<div class="text-sm dark:text-gray-400 flex items-center gap-2 w-fit">
<a
class="hover:text-gray-500 hover:dark:text-gray-100 underline"
class="hover:text-gray-500 hover:dark:text-gray-100 underline flex-grow"
href={document?.metadata?.file_id
? `/api/v1/files/${document?.metadata?.file_id}/content${document?.metadata?.page !== undefined ? `#page=${document.metadata.page + 1}` : ''}`
: document.source.name.includes('http')
@ -73,11 +97,47 @@
>
{document?.metadata?.name ?? document.source.name}
</a>
{document?.metadata?.page
? `(${$i18n.t('page')} ${document.metadata.page + 1})`
: ''}
{#if document?.metadata?.page}
<span class="text-xs text-gray-500 dark:text-gray-400">
({$i18n.t('page')}
{document.metadata.page + 1})
</span>
{/if}
</div>
</Tooltip>
{#if showRelevance}
<div class="text-sm font-medium dark:text-gray-300 mt-2">
{$i18n.t('Relevance')}
</div>
{#if document.distance !== undefined}
<Tooltip
className="w-fit"
content={$i18n.t('Semantic distance to query')}
placement="top-start"
tippyOptions={{ duration: [500, 0] }}
>
<div class="text-sm my-1 dark:text-gray-400 flex items-center gap-2 w-fit">
{#if showPercentage}
{@const percentage = calculatePercentage(document.distance)}
<span class={`px-1 rounded font-medium ${getRelevanceColor(percentage)}`}>
{percentage.toFixed(2)}%
</span>
<span class="text-gray-500 dark:text-gray-500">
({document.distance.toFixed(4)})
</span>
{:else}
<span class="text-gray-500 dark:text-gray-500">
{document.distance.toFixed(4)}
</span>
{/if}
</div>
</Tooltip>
{:else}
<div class="text-sm dark:text-gray-400">
{$i18n.t('No distance available')}
</div>
{/if}
{/if}
{:else}
<div class="text-sm dark:text-gray-400">
{$i18n.t('No source available')}
@ -85,7 +145,7 @@
{/if}
</div>
<div class="flex flex-col w-full">
<div class=" text-sm font-medium dark:text-gray-300">
<div class=" text-sm font-medium dark:text-gray-300 mt-2">
{$i18n.t('Content')}
</div>
<pre class="text-sm dark:text-gray-400 whitespace-pre-line">

View file

@ -2,6 +2,7 @@
import { toast } from 'svelte-sonner';
import { createEventDispatcher, onMount, getContext } from 'svelte';
import { config } from '$lib/stores';
const i18n = getContext('i18n');
@ -69,7 +70,7 @@
</script>
<div
class=" my-2.5 rounded-xl px-4 py-3 border dark:border-gray-850"
class=" my-2.5 rounded-xl px-4 py-3 border border-gray-50 dark:border-gray-850"
id="message-feedback-{message.id}"
>
<div class="flex justify-between items-center">
@ -97,7 +98,7 @@
<div class="flex flex-wrap gap-2 text-sm mt-2.5">
{#each reasons as reason}
<button
class="px-3.5 py-1 border dark:border-gray-850 hover:bg-gray-100 dark:hover:bg-gray-850 {selectedReason ===
class="px-3.5 py-1 border border-gray-50 dark:border-gray-850 hover:bg-gray-100 dark:hover:bg-gray-850 {selectedReason ===
reason
? 'bg-gray-200 dark:bg-gray-800'
: ''} transition rounded-lg"
@ -120,9 +121,21 @@
/>
</div>
<div class="mt-2 flex justify-end">
<div class="mt-2 gap-1.5 flex justify-end">
{#if $config?.features.enable_community_sharing}
<button
class=" self-center px-3.5 py-2 rounded-xl text-sm font-medium bg-gray-50 hover:bg-gray-100 text-gray-800 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-white transition"
type="button"
on:click={() => {
show = false;
}}
>
{$i18n.t('Share to OpenWebUI Community')}
</button>
{/if}
<button
class=" bg-emerald-700 text-white text-sm font-medium rounded-lg px-3.5 py-1.5"
class=" bg-emerald-700 hover:bg-emerald-800 transition text-white text-sm font-medium rounded-xl px-3.5 py-1.5"
on:click={() => {
saveHandler();
}}

View file

@ -142,28 +142,30 @@
{#if edit === true}
<div class=" w-full bg-gray-50 dark:bg-gray-800 rounded-3xl px-5 py-3 mb-2">
<textarea
id="message-edit-{message.id}"
bind:this={messageEditTextAreaElement}
class=" bg-transparent outline-none w-full resize-none"
bind:value={editedContent}
on:input={(e) => {
e.target.style.height = '';
e.target.style.height = `${e.target.scrollHeight}px`;
}}
on:keydown={(e) => {
if (e.key === 'Escape') {
document.getElementById('close-edit-message-button')?.click();
}
<div class="max-h-[25dvh] overflow-auto">
<textarea
id="message-edit-{message.id}"
bind:this={messageEditTextAreaElement}
class=" bg-transparent outline-none w-full resize-none"
bind:value={editedContent}
on:input={(e) => {
e.target.style.height = '';
e.target.style.height = `${e.target.scrollHeight}px`;
}}
on:keydown={(e) => {
if (e.key === 'Escape') {
document.getElementById('close-edit-message-button')?.click();
}
const isCmdOrCtrlPressed = e.metaKey || e.ctrlKey;
const isEnterPressed = e.key === 'Enter';
const isCmdOrCtrlPressed = e.metaKey || e.ctrlKey;
const isEnterPressed = e.key === 'Enter';
if (isCmdOrCtrlPressed && isEnterPressed) {
document.getElementById('confirm-edit-message-button')?.click();
}
}}
/>
if (isCmdOrCtrlPressed && isEnterPressed) {
document.getElementById('confirm-edit-message-button')?.click();
}
}}
/>
</div>
<div class=" mt-2 mb-1 flex justify-between text-sm font-medium">
<div>

View file

@ -40,6 +40,7 @@
<div class="overflow-hidden w-full">
<div class="mr-1 max-w-full">
<Selector
id={`${selectedModelIdx}`}
placeholder={$i18n.t('Select a model')}
items={$models.map((model) => ({
value: model.id,

View file

@ -25,6 +25,7 @@
const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
export let id = '';
export let value = '';
export let placeholder = 'Select a model';
export let searchEnabled = true;
@ -229,7 +230,11 @@
}}
closeFocus={false}
>
<DropdownMenu.Trigger class="relative w-full font-primary" aria-label={placeholder}>
<DropdownMenu.Trigger
class="relative w-full font-primary"
aria-label={placeholder}
id="model-selector-{id}-button"
>
<div
class="flex w-full text-left px-0.5 outline-none bg-transparent truncate text-lg font-medium placeholder-gray-400 focus:outline-none"
>
@ -245,10 +250,10 @@
<DropdownMenu.Content
class=" z-40 {$mobile
? `w-full`
: `${className}`} max-w-[calc(100vw-1rem)] justify-start rounded-xl bg-white dark:bg-gray-850 dark:text-white shadow-lg border border-gray-300/30 dark:border-gray-700/40 outline-none"
: `${className}`} max-w-[calc(100vw-1rem)] justify-start rounded-xl bg-white dark:bg-gray-850 dark:text-white shadow-lg outline-none"
transition={flyAndScale}
side={$mobile ? 'bottom' : 'bottom-start'}
sideOffset={4}
sideOffset={3}
>
<slot>
{#if searchEnabled}
@ -281,7 +286,7 @@
/>
</div>
<hr class="border-gray-100 dark:border-gray-800" />
<hr class="border-gray-50 dark:border-gray-800" />
{/if}
<div class="px-3 my-2 max-h-64 overflow-y-auto scrollbar-hidden group">
@ -474,10 +479,16 @@
</div>
<div class="flex flex-col self-start">
<div class="line-clamp-1">
Downloading "{model}" {'pullProgress' in $MODEL_DOWNLOAD_POOL[model]
? `(${$MODEL_DOWNLOAD_POOL[model].pullProgress}%)`
: ''}
<div class="flex gap-1">
<div class="line-clamp-1">
Downloading "{model}"
</div>
<div class="flex-shrink-0">
{'pullProgress' in $MODEL_DOWNLOAD_POOL[model]
? `(${$MODEL_DOWNLOAD_POOL[model].pullProgress}%)`
: ''}
</div>
</div>
{#if 'digest' in $MODEL_DOWNLOAD_POOL[model] && $MODEL_DOWNLOAD_POOL[model].digest}
@ -488,7 +499,7 @@
</div>
</div>
<div class="mr-2 translate-y-0.5">
<div class="mr-2 ml-1 translate-y-0.5">
<Tooltip content={$i18n.t('Cancel')}>
<button
class="text-gray-800 dark:text-gray-100"
@ -521,7 +532,7 @@
</div>
{#if showTemporaryChatControl}
<hr class="border-gray-100 dark:border-gray-800" />
<hr class="border-gray-50 dark:border-gray-800" />
<div class="flex items-center mx-2 my-2">
<button

View file

@ -57,18 +57,14 @@
console.log(prompt);
await tick();
const chatInputElement = document.getElementById('chat-textarea');
if (chatInputElement) {
chatInputElement.style.height = '';
chatInputElement.style.height = Math.min(chatInputElement.scrollHeight, 200) + 'px';
chatInputElement.focus();
const chatInputContainerElement = document.getElementById('chat-input-container');
if (chatInputContainerElement) {
chatInputContainerElement.style.height = '';
chatInputContainerElement.style.height =
Math.min(chatInputContainerElement.scrollHeight, 200) + 'px';
const words = findWordIndices(prompt);
if (words.length > 0) {
const word = words.at(0);
chatInputElement.setSelectionRange(word?.startIndex, word.endIndex + 1);
}
const chatInputElement = document.getElementById('chat-input');
chatInputElement?.focus();
}
await tick();
@ -106,7 +102,7 @@
class="w-full text-3xl text-gray-800 dark:text-gray-100 font-medium text-center flex items-center gap-4 font-primary"
>
<div class="w-full flex flex-col justify-center items-center">
<div class="flex flex-col md:flex-row justify-center gap-2 md:gap-3.5 w-fit">
<div class="flex flex-row justify-center gap-3 sm:gap-3.5 w-fit px-5">
<div class="flex flex-shrink-0 justify-center">
<div class="flex -space-x-4 mb-0.5" in:fade={{ duration: 100 }}>
{#each models as model, modelIdx}
@ -127,7 +123,7 @@
($i18n.language === 'dg-DG'
? `/doge.png`
: `${WEBUI_BASE_URL}/static/favicon.png`)}
class=" size-[2.5rem] rounded-full border-[1px] border-gray-200 dark:border-none"
class=" size-9 sm:size-10 rounded-full border-[1px] border-gray-200 dark:border-none"
alt="logo"
draggable="false"
/>
@ -137,7 +133,7 @@
</div>
</div>
<div class=" capitalize line-clamp-1 text-3xl md:text-4xl" in:fade={{ duration: 100 }}>
<div class=" capitalize text-3xl sm:text-4xl line-clamp-1" in:fade={{ duration: 100 }}>
{#if models[selectedModelIdx]?.info}
{models[selectedModelIdx]?.info?.name}
{:else}

View file

@ -19,6 +19,8 @@
// Addons
let titleAutoGenerate = true;
let autoTags = true;
let responseAutoCopy = false;
let widescreenMode = false;
let splitLargeChunks = false;
@ -112,6 +114,11 @@
});
};
const toggleAutoTags = async () => {
autoTags = !autoTags;
saveSettings({ autoTags });
};
const toggleResponseAutoCopy = async () => {
const permission = await navigator.clipboard
.readText()
@ -149,6 +156,7 @@
onMount(async () => {
titleAutoGenerate = $settings?.title?.auto ?? true;
autoTags = $settings.autoTags ?? true;
responseAutoCopy = $settings.responseAutoCopy ?? false;
showUsername = $settings.showUsername ?? false;
@ -431,6 +439,26 @@
</div>
</div>
<div>
<div class=" py-0.5 flex w-full justify-between">
<div class=" self-center text-xs">{$i18n.t('Chat Tags Auto-Generation')}</div>
<button
class="p-1 px-3 text-xs flex rounded transition"
on:click={() => {
toggleAutoTags();
}}
type="button"
>
{#if autoTags === true}
<span class="ml-2 self-center">{$i18n.t('On')}</span>
{:else}
<span class="ml-2 self-center">{$i18n.t('Off')}</span>
{/if}
</button>
</div>
</div>
<div>
<div class=" py-0.5 flex w-full justify-between">
<div class=" self-center text-xs">

View file

@ -184,7 +184,7 @@
<div
class=" h-fit py-1 px-2 flex items-center justify-center rounded border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
>
/Delete
</div>
</div>
</div>

View file

@ -20,6 +20,7 @@
const dispatch = createEventDispatcher();
import Tags from '../common/Tags.svelte';
import { toast } from 'svelte-sonner';
export let chatId = '';
let tags = [];
@ -31,7 +32,14 @@
};
const addTag = async (tagName) => {
const res = await addTagById(localStorage.token, chatId, tagName);
const res = await addTagById(localStorage.token, chatId, tagName).catch(async (error) => {
toast.error(error);
return null;
});
if (!res) {
return;
}
tags = await getTags();
await updateChatById(localStorage.token, chatId, {
tags: tags

View file

@ -6,7 +6,8 @@
info: 'bg-blue-500/20 text-blue-700 dark:text-blue-200 ',
success: 'bg-green-500/20 text-green-700 dark:text-green-200',
warning: 'bg-yellow-500/20 text-yellow-700 dark:text-yellow-200',
error: 'bg-red-500/20 text-red-700 dark:text-red-200'
error: 'bg-red-500/20 text-red-700 dark:text-red-200',
muted: 'bg-gray-500/20 text-gray-700 dark:text-gray-200'
};
</script>

View file

@ -14,11 +14,23 @@
export let className = '';
export let buttonClassName = 'w-fit';
export let title = null;
export let disabled = false;
export let hide = false;
</script>
<div class={className}>
{#if title !== null}
<button class={buttonClassName} on:click={() => (open = !open)}>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="{buttonClassName} cursor-pointer"
on:pointerup={() => {
if (!disabled) {
open = !open;
}
}}
>
<div class=" w-fit font-medium transition flex items-center justify-between gap-2">
<div>
{title}
@ -26,24 +38,33 @@
<div>
{#if open}
<ChevronUp strokeWidth="3.5" className="size-3.5 " />
<ChevronUp strokeWidth="3.5" className="size-3.5" />
{:else}
<ChevronDown strokeWidth="3.5" className="size-3.5 " />
<ChevronDown strokeWidth="3.5" className="size-3.5" />
{/if}
</div>
</div>
</button>
</div>
{:else}
<button class={buttonClassName} on:click={() => (open = !open)}>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="{buttonClassName} cursor-pointer"
on:pointerup={() => {
if (!disabled) {
open = !open;
}
}}
>
<div
class="flex items-center gap-2 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition"
>
<slot />
</div>
</button>
</div>
{/if}
{#if open}
{#if open && !hide}
<div transition:slide={{ duration: 300, easing: quintOut, axis: 'y' }}>
<slot name="content" />
</div>

View file

@ -1,12 +1,11 @@
<script lang="ts">
import { onMount, getContext, createEventDispatcher } from 'svelte';
import { fade } from 'svelte/transition';
const i18n = getContext('i18n');
import { flyAndScale } from '$lib/utils/transitions';
const dispatch = createEventDispatcher();
import { fade } from 'svelte/transition';
import { flyAndScale } from '$lib/utils/transitions';
export let title = '';
export let message = '';
@ -27,6 +26,12 @@
console.log('Escape');
show = false;
}
if (event.key === 'Enter') {
console.log('Enter');
show = false;
dispatch('confirm', inputValue);
}
};
onMount(() => {
@ -56,7 +61,7 @@
}}
>
<div
class=" m-auto rounded-2xl max-w-full w-[32rem] mx-2 bg-gray-50 dark:bg-gray-950 max-h-[100dvh] shadow-3xl border border-gray-850"
class=" m-auto rounded-2xl max-w-full w-[32rem] mx-2 bg-gray-50 dark:bg-gray-950 max-h-[100dvh] shadow-3xl"
in:flyAndScale
on:mousedown={(e) => {
e.stopPropagation();

View file

@ -24,7 +24,7 @@
bind:this={popupElement}
class="fixed top-0 left-0 w-screen h-[100dvh] z-50 touch-none pointer-events-none"
>
<div class=" absolute text-white z-[99999]" style="top: {y}px; left: {x}px;">
<div class=" absolute text-white z-[99999]" style="top: {y + 10}px; left: {x + 10}px;">
<slot></slot>
</div>
</div>

View file

@ -6,23 +6,11 @@
const dispatch = createEventDispatcher();
export let show = false;
export let size = 'md';
export let className = '';
let modalElement = null;
let mounted = false;
const sizeToWidth = (size) => {
if (size === 'xs') {
return 'w-[16rem]';
} else if (size === 'sm') {
return 'w-[30rem]';
} else if (size === 'md') {
return 'w-[48rem]';
} else {
return 'w-[56rem]';
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && isTopModal()) {
console.log('Escape');
@ -76,7 +64,7 @@
}}
>
<div
class=" mt-auto max-w-full w-full bg-gray-50 dark:bg-gray-900 max-h-[100dvh] overflow-y-auto scrollbar-hidden"
class=" mt-auto max-w-full w-full bg-gray-50 dark:bg-gray-900 dark:text-gray-100 {className} max-h-[100dvh] overflow-y-auto scrollbar-hidden"
on:mousedown={(e) => {
e.stopPropagation();
}}

View file

@ -22,7 +22,7 @@
<slot name="content">
<DropdownMenu.Content
class="w-full max-w-[130px] rounded-lg px-1 py-1.5 border border-gray-700 z-50 bg-gray-850 text-white"
class="w-full max-w-[130px] rounded-lg px-1 py-1.5 border border-gray-900 z-50 bg-gray-850 text-white"
sideOffset={8}
side="bottom"
align="start"

View file

@ -14,34 +14,73 @@
export let name = '';
export let collapsible = true;
export let className = '';
let folderElement;
let dragged = false;
let draggedOver = false;
const onDragOver = (e) => {
e.preventDefault();
dragged = true;
e.stopPropagation();
draggedOver = true;
};
const onDrop = (e) => {
e.preventDefault();
e.stopPropagation();
if (folderElement.contains(e.target)) {
console.log('Dropped on the Button');
// get data from the drag event
const dataTransfer = e.dataTransfer.getData('text/plain');
const data = JSON.parse(dataTransfer);
console.log(data);
dispatch('drop', data);
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
// Iterate over all items in the DataTransferItemList use functional programming
for (const item of Array.from(e.dataTransfer.items)) {
// If dropped items aren't files, reject them
if (item.kind === 'file') {
const file = item.getAsFile();
if (file && file.type === 'application/json') {
console.log('Dropped file is a JSON file!');
dragged = false;
// Read the JSON file with FileReader
const reader = new FileReader();
reader.onload = async function (event) {
try {
const fileContent = JSON.parse(event.target.result);
console.log('Parsed JSON Content: ', fileContent);
open = true;
dispatch('import', fileContent);
} catch (error) {
console.error('Error parsing JSON file:', error);
}
};
// Start reading the file
reader.readAsText(file);
} else {
console.error('Only JSON file types are supported.');
}
} else {
open = true;
const dataTransfer = e.dataTransfer.getData('text/plain');
const data = JSON.parse(dataTransfer);
console.log(data);
dispatch('drop', data);
}
}
}
draggedOver = false;
}
};
const onDragLeave = (e) => {
e.preventDefault();
dragged = false;
e.stopPropagation();
draggedOver = false;
};
onMount(() => {
@ -57,10 +96,10 @@
});
</script>
<div bind:this={folderElement} class="relative">
{#if dragged}
<div bind:this={folderElement} class="relative {className}">
{#if draggedOver}
<div
class="absolute top-0 left-0 w-full h-full rounded-sm bg-gray-200 bg-opacity-50 dark:bg-opacity-10 z-50 pointer-events-none touch-none"
class="absolute top-0 left-0 w-full h-full rounded-sm bg-[hsla(260,85%,65%,0.1)] bg-opacity-50 dark:bg-opacity-10 z-50 pointer-events-none touch-none"
></div>
{/if}
@ -74,7 +113,7 @@
}}
>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="mx-2 w-full">
<div class="w-full">
<button
class="w-full py-1.5 px-2 rounded-md flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-500 font-medium hover:bg-gray-100 dark:hover:bg-gray-900 transition"
>
@ -92,7 +131,7 @@
</button>
</div>
<div slot="content" class=" pl-2">
<div slot="content" class="w-full">
<slot></slot>
</div>
</Collapsible>

View file

@ -6,11 +6,15 @@
export let show = true;
export let size = 'md';
export let className = 'bg-gray-50 dark:bg-gray-900 rounded-2xl';
let modalElement = null;
let mounted = false;
const sizeToWidth = (size) => {
if (size === 'full') {
return 'w-full';
}
if (size === 'xs') {
return 'w-[16rem]';
} else if (size === 'sm') {
@ -68,9 +72,9 @@
}}
>
<div
class=" m-auto rounded-2xl max-w-full {sizeToWidth(
size
)} mx-2 bg-gray-50 dark:bg-gray-900 shadow-3xl max-h-[100dvh] overflow-y-auto scrollbar-hidden"
class=" m-auto max-w-full {sizeToWidth(size)} {size !== 'full'
? 'mx-2'
: ''} shadow-3xl max-h-[100dvh] overflow-y-auto scrollbar-hidden {className}"
in:flyAndScale
on:mousedown={(e) => {
e.stopPropagation();

View file

@ -0,0 +1,470 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { createEventDispatcher } from 'svelte';
const eventDispatch = createEventDispatcher();
import { EditorState, Plugin, TextSelection } from 'prosemirror-state';
import { EditorView, Decoration, DecorationSet } from 'prosemirror-view';
import { undo, redo, history } from 'prosemirror-history';
import {
schema,
defaultMarkdownParser,
MarkdownParser,
defaultMarkdownSerializer
} from 'prosemirror-markdown';
import {
inputRules,
wrappingInputRule,
textblockTypeInputRule,
InputRule
} from 'prosemirror-inputrules'; // Import input rules
import { splitListItem, liftListItem, sinkListItem } from 'prosemirror-schema-list'; // Import from prosemirror-schema-list
import { keymap } from 'prosemirror-keymap';
import { baseKeymap, chainCommands } from 'prosemirror-commands';
import { DOMParser, DOMSerializer, Schema, Fragment } from 'prosemirror-model';
export let className = 'input-prose';
export let shiftEnter = false;
export let id = '';
export let value = '';
export let placeholder = 'Type here...';
let element: HTMLElement; // Element where ProseMirror will attach
let state;
let view;
// Plugin to add placeholder when the content is empty
function placeholderPlugin(placeholder: string) {
return new Plugin({
props: {
decorations(state) {
const doc = state.doc;
if (
doc.childCount === 1 &&
doc.firstChild.isTextblock &&
doc.firstChild?.textContent === ''
) {
// If there's nothing in the editor, show the placeholder decoration
const decoration = Decoration.node(0, doc.content.size, {
'data-placeholder': placeholder,
class: 'placeholder'
});
return DecorationSet.create(doc, [decoration]);
}
return DecorationSet.empty;
}
}
});
}
function unescapeMarkdown(text: string): string {
return text
.replace(/\\([\\`*{}[\]()#+\-.!_>])/g, '$1') // unescape backslashed characters
.replace(/&amp;/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'");
}
// Custom parsing rule that creates proper paragraphs for newlines and empty lines
function markdownToProseMirrorDoc(markdown: string) {
// Split the markdown into lines
const lines = markdown.split('\n\n');
// Create an array to hold our paragraph nodes
const paragraphs = [];
// Process each line
lines.forEach((line) => {
if (line.trim() === '') {
// For empty lines, create an empty paragraph
paragraphs.push(schema.nodes.paragraph.create());
} else {
// For non-empty lines, parse as usual
const doc = defaultMarkdownParser.parse(line);
// Extract the content of the parsed document
doc.content.forEach((node) => {
paragraphs.push(node);
});
}
});
// Create a new document with these paragraphs
return schema.node('doc', null, paragraphs);
}
// Create a custom serializer for paragraphs
// Custom paragraph serializer to preserve newlines for empty paragraphs (empty block).
function serializeParagraph(state, node: Node) {
const content = node.textContent.trim();
// If the paragraph is empty, just add an empty line.
if (content === '') {
state.write('\n\n');
} else {
state.renderInline(node);
state.closeBlock(node);
}
}
const customMarkdownSerializer = new defaultMarkdownSerializer.constructor(
{
...defaultMarkdownSerializer.nodes,
paragraph: (state, node) => {
serializeParagraph(state, node); // Use custom paragraph serialization
}
// Customize other block formats if needed
},
// Copy marks directly from the original serializer (or customize them if necessary)
defaultMarkdownSerializer.marks
);
// Utility function to convert ProseMirror content back to markdown text
function serializeEditorContent(doc) {
const markdown = customMarkdownSerializer.serialize(doc);
return unescapeMarkdown(markdown);
}
// ---- Input Rules ----
// Input rule for heading (e.g., # Headings)
function headingRule(schema) {
return textblockTypeInputRule(/^(#{1,6})\s$/, schema.nodes.heading, (match) => ({
level: match[1].length
}));
}
// Input rule for bullet list (e.g., `- item`)
function bulletListRule(schema) {
return wrappingInputRule(/^\s*([-+*])\s$/, schema.nodes.bullet_list);
}
// Input rule for ordered list (e.g., `1. item`)
function orderedListRule(schema) {
return wrappingInputRule(/^(\d+)\.\s$/, schema.nodes.ordered_list, (match) => ({
order: +match[1]
}));
}
// Custom input rules for Bold/Italic (using * or _)
function markInputRule(regexp: RegExp, markType: any) {
return new InputRule(regexp, (state, match, start, end) => {
const { tr } = state;
if (match) {
tr.replaceWith(start, end, schema.text(match[1], [markType.create()]));
}
return tr;
});
}
function boldRule(schema) {
return markInputRule(/\*([^*]+)\*/, schema.marks.strong);
}
function italicRule(schema) {
return markInputRule(/\_([^*]+)\_/, schema.marks.em);
}
// Initialize Editor State and View
function afterSpacePress(state, dispatch) {
// Get the position right after the space was naturally inserted by the browser.
let { from, to, empty } = state.selection;
if (dispatch && empty) {
let tr = state.tr;
// Check for any active marks at `from - 1` (the space we just inserted)
const storedMarks = state.storedMarks || state.selection.$from.marks();
const hasBold = storedMarks.some((mark) => mark.type === state.schema.marks.strong);
const hasItalic = storedMarks.some((mark) => mark.type === state.schema.marks.em);
// Remove marks from the space character (marks applied to the space character will be marked as false)
if (hasBold) {
tr = tr.removeMark(from - 1, from, state.schema.marks.strong);
}
if (hasItalic) {
tr = tr.removeMark(from - 1, from, state.schema.marks.em);
}
// Dispatch the resulting transaction to update the editor state
dispatch(tr);
}
return true;
}
function toggleMark(markType) {
return (state, dispatch) => {
const { from, to } = state.selection;
if (state.doc.rangeHasMark(from, to, markType)) {
if (dispatch) dispatch(state.tr.removeMark(from, to, markType));
return true;
} else {
if (dispatch) dispatch(state.tr.addMark(from, to, markType.create()));
return true;
}
};
}
function isInList(state) {
const { $from } = state.selection;
return (
$from.parent.type === schema.nodes.paragraph && $from.node(-1).type === schema.nodes.list_item
);
}
function isEmptyListItem(state) {
const { $from } = state.selection;
return isInList(state) && $from.parent.content.size === 0 && $from.node(-1).childCount === 1;
}
function exitList(state, dispatch) {
return liftListItem(schema.nodes.list_item)(state, dispatch);
}
function findNextTemplate(doc, from = 0) {
const patterns = [
{ start: '[', end: ']' },
{ start: '{{', end: '}}' }
];
let result = null;
doc.nodesBetween(from, doc.content.size, (node, pos) => {
if (result) return false; // Stop if we've found a match
if (node.isText) {
const text = node.text;
let index = Math.max(0, from - pos);
while (index < text.length) {
for (const pattern of patterns) {
if (text.startsWith(pattern.start, index)) {
const endIndex = text.indexOf(pattern.end, index + pattern.start.length);
if (endIndex !== -1) {
result = {
from: pos + index,
to: pos + endIndex + pattern.end.length
};
return false; // Stop searching
}
}
}
index++;
}
}
});
return result;
}
function selectNextTemplate(state, dispatch) {
const { doc, selection } = state;
const from = selection.to;
let template = findNextTemplate(doc, from);
if (!template) {
// If not found, search from the beginning
template = findNextTemplate(doc, 0);
}
if (template) {
if (dispatch) {
const tr = state.tr.setSelection(TextSelection.create(doc, template.from, template.to));
dispatch(tr);
}
return true;
}
return false;
}
onMount(() => {
const initialDoc = markdownToProseMirrorDoc(value || ''); // Convert the initial content
state = EditorState.create({
doc: initialDoc,
schema,
plugins: [
history(),
placeholderPlugin(placeholder),
inputRules({
rules: [
headingRule(schema), // Handle markdown-style headings (# H1, ## H2, etc.)
bulletListRule(schema), // Handle `-` or `*` input to start bullet list
orderedListRule(schema), // Handle `1.` input to start ordered list
boldRule(schema), // Bold input rule
italicRule(schema) // Italic input rule
]
}),
keymap({
...baseKeymap,
'Mod-z': undo,
'Mod-y': redo,
Enter: (state, dispatch, view) => {
if (shiftEnter) {
eventDispatch('enter');
return true;
}
return chainCommands(
(state, dispatch, view) => {
if (isEmptyListItem(state)) {
return exitList(state, dispatch);
}
return false;
},
(state, dispatch, view) => {
if (isInList(state)) {
return splitListItem(schema.nodes.list_item)(state, dispatch);
}
return false;
},
baseKeymap.Enter
)(state, dispatch, view);
},
'Shift-Enter': (state, dispatch, view) => {
if (shiftEnter) {
return chainCommands(
(state, dispatch, view) => {
if (isEmptyListItem(state)) {
return exitList(state, dispatch);
}
return false;
},
(state, dispatch, view) => {
if (isInList(state)) {
return splitListItem(schema.nodes.list_item)(state, dispatch);
}
return false;
},
baseKeymap.Enter
)(state, dispatch, view);
} else {
return baseKeymap.Enter(state, dispatch, view);
}
return false;
},
// Prevent default tab navigation and provide indent/outdent behavior inside lists:
Tab: chainCommands((state, dispatch, view) => {
const { $from } = state.selection;
if (isInList(state)) {
return sinkListItem(schema.nodes.list_item)(state, dispatch);
} else {
return selectNextTemplate(state, dispatch);
}
return true; // Prevent Tab from moving the focus
}),
'Shift-Tab': (state, dispatch, view) => {
const { $from } = state.selection;
if (isInList(state)) {
return liftListItem(schema.nodes.list_item)(state, dispatch);
}
return true; // Prevent Shift-Tab from moving the focus
},
'Mod-b': toggleMark(schema.marks.strong),
'Mod-i': toggleMark(schema.marks.em)
})
]
});
view = new EditorView(element, {
state,
dispatchTransaction(transaction) {
// Update editor state
let newState = view.state.apply(transaction);
view.updateState(newState);
value = serializeEditorContent(newState.doc); // Convert ProseMirror content to markdown text
eventDispatch('input', { value });
},
handleDOMEvents: {
focus: (view, event) => {
eventDispatch('focus', { event });
return false;
},
keypress: (view, event) => {
eventDispatch('keypress', { event });
return false;
},
keydown: (view, event) => {
eventDispatch('keydown', { event });
return false;
},
paste: (view, event) => {
if (event.clipboardData) {
// Check if the pasted content contains image files
const hasImageFile = Array.from(event.clipboardData.files).some((file) =>
file.type.startsWith('image/')
);
// Check for image in dataTransfer items (for cases where files are not available)
const hasImageItem = Array.from(event.clipboardData.items).some((item) =>
item.type.startsWith('image/')
);
if (hasImageFile) {
// If there's an image, dispatch the event to the parent
eventDispatch('paste', { event });
event.preventDefault();
return true;
}
if (hasImageItem) {
// If there's an image item, dispatch the event to the parent
eventDispatch('paste', { event });
event.preventDefault();
return true;
}
}
// For all other cases (text, formatted text, etc.), let ProseMirror handle it
return false;
},
// Handle space input after browser has completed it
keyup: (view, event) => {
if (event.key === ' ' && event.code === 'Space') {
afterSpacePress(view.state, view.dispatch);
}
return false;
}
},
attributes: { id }
});
});
// Reinitialize the editor if the value is externally changed (i.e. when `value` is updated)
$: if (view && value !== serializeEditorContent(view.state.doc)) {
const newDoc = markdownToProseMirrorDoc(value || '');
const newState = EditorState.create({
doc: newDoc,
schema,
plugins: view.state.plugins,
selection: TextSelection.atEnd(newDoc) // This sets the cursor at the end
});
view.updateState(newState);
if (value !== '') {
// After updating the state, try to find and select the next template
setTimeout(() => {
const templateFound = selectNextTemplate(view.state, view.dispatch);
if (!templateFound) {
// If no template found, set cursor at the end
const endPos = view.state.doc.content.size;
view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, endPos)));
}
}, 0);
}
}
// Destroy ProseMirror instance on unmount
onDestroy(() => {
view?.destroy();
});
</script>
<div bind:this={element} class="relative w-full min-w-full h-full min-h-fit {className}"></div>

View file

@ -1,34 +1,32 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import Tooltip from '../Tooltip.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import Badge from '../Badge.svelte';
const dispatch = createEventDispatcher();
export let tags = [];
</script>
{#each tags as tag}
<div
class="px-2 py-[0.5px] gap-0.5 flex justify-between h-fit items-center rounded-full transition border dark:border-gray-800 dark:text-white"
>
<div class=" text-[0.7rem] font-medium self-center line-clamp-1">
{tag.name}
</div>
<button
class="h-full flex self-center cursor-pointer"
on:click={() => {
dispatch('delete', tag.name);
}}
type="button"
<Tooltip content={tag.name}>
<div
class="relative group px-1.5 py-[0.2px] gap-0.5 flex justify-between h-fit max-h-fit w-fit items-center rounded-full bg-gray-500/20 text-gray-700 dark:text-gray-200 transition cursor-pointer"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="size-3 m-auto self-center translate-y-[0.3px] translate-x-[3px]"
>
<path
d="M5.28 4.22a.75.75 0 0 0-1.06 1.06L6.94 8l-2.72 2.72a.75.75 0 1 0 1.06 1.06L8 9.06l2.72 2.72a.75.75 0 1 0 1.06-1.06L9.06 8l2.72-2.72a.75.75 0 0 0-1.06-1.06L8 6.94 5.28 4.22Z"
/>
</svg>
</button>
</div>
<div class=" text-[0.7rem] font-medium self-center line-clamp-1 w-fit">
{tag.name}
</div>
<div class="absolute invisible right-0.5 group-hover:visible transition">
<button
class="rounded-full border bg-white dark:bg-gray-700 h-full flex self-center cursor-pointer"
on:click={() => {
dispatch('delete', tag.name);
}}
type="button"
>
<XMark className="size-3" strokeWidth="2.5" />
</button>
</div>
</div>
</Tooltip>
{/each}

View file

@ -0,0 +1,37 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
export let value = '';
export let placeholder = '';
export let className =
'w-full rounded-lg px-3 py-2 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none resize-none h-full';
let textareaElement;
onMount(async () => {
await tick();
if (textareaElement) {
setInterval(adjustHeight, 0);
}
});
const adjustHeight = () => {
if (textareaElement) {
textareaElement.style.height = '';
textareaElement.style.height = `${textareaElement.scrollHeight}px`;
}
};
</script>
<textarea
bind:this={textareaElement}
bind:value
{placeholder}
class={className}
on:input={(e) => {
e.target.style.height = '';
e.target.style.height = `${e.target.scrollHeight}px`;
}}
rows="1"
/>

View file

@ -1,124 +0,0 @@
<script lang="ts">
import { DropdownMenu } from 'bits-ui';
import { flyAndScale } from '$lib/utils/transitions';
import { getContext } from 'svelte';
import Dropdown from '$lib/components/common/Dropdown.svelte';
import GarbageBin from '$lib/components/icons/GarbageBin.svelte';
import Pencil from '$lib/components/icons/Pencil.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Tags from '$lib/components/chat/Tags.svelte';
import Share from '$lib/components/icons/Share.svelte';
import ArchiveBox from '$lib/components/icons/ArchiveBox.svelte';
import DocumentDuplicate from '$lib/components/icons/DocumentDuplicate.svelte';
import Star from '$lib/components/icons/Star.svelte';
const i18n = getContext('i18n');
export let pinHandler: Function;
export let shareHandler: Function;
export let cloneChatHandler: Function;
export let archiveChatHandler: Function;
export let renameHandler: Function;
export let deleteHandler: Function;
export let onClose: Function;
export let chatId = '';
let show = false;
</script>
<Dropdown
bind:show
on:change={(e) => {
if (e.detail === false) {
onClose();
}
}}
>
<Tooltip content={$i18n.t('More')}>
<slot />
</Tooltip>
<div slot="content">
<DropdownMenu.Content
class="w-full max-w-[180px] rounded-xl px-1 py-1.5 border border-gray-300/30 dark:border-gray-700/50 z-50 bg-white dark:bg-gray-850 dark:text-white shadow"
sideOffset={-2}
side="bottom"
align="start"
transition={flyAndScale}
>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
pinHandler();
}}
>
<Star strokeWidth="2" />
<div class="flex items-center">{$i18n.t('Pin')}</div>
</DropdownMenu.Item>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
renameHandler();
}}
>
<Pencil strokeWidth="2" />
<div class="flex items-center">{$i18n.t('Rename')}</div>
</DropdownMenu.Item>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
cloneChatHandler();
}}
>
<DocumentDuplicate strokeWidth="2" />
<div class="flex items-center">{$i18n.t('Clone')}</div>
</DropdownMenu.Item>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
archiveChatHandler();
}}
>
<ArchiveBox strokeWidth="2" />
<div class="flex items-center">{$i18n.t('Archive')}</div>
</DropdownMenu.Item>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
shareHandler();
}}
>
<Share />
<div class="flex items-center">{$i18n.t('Share')}</div>
</DropdownMenu.Item>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
deleteHandler();
}}
>
<GarbageBin strokeWidth="2" />
<div class="flex items-center">{$i18n.t('Delete')}</div>
</DropdownMenu.Item>
<hr class="border-gray-100 dark:border-gray-800 mt-2.5 mb-1.5" />
<div class="flex p-1">
<Tags
{chatId}
on:close={() => {
show = false;
onClose();
}}
/>
</div>
</DropdownMenu.Content>
</div>
</Dropdown>

View file

@ -0,0 +1,19 @@
<script lang="ts">
export let className = 'size-4';
export let strokeWidth = '1.5';
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width={strokeWidth}
stroke="currentColor"
class={className}
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"
/>
</svg>

View file

@ -0,0 +1,19 @@
<script lang="ts">
export let className = 'size-4';
export let strokeWidth = '1.5';
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width={strokeWidth}
stroke="currentColor"
class={className}
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3"
/>
</svg>

View file

@ -0,0 +1,10 @@
<script lang="ts">
export let className = 'size-4';
</script>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class={className}>
<path d="M7 4a3 3 0 0 1 6 0v6a3 3 0 1 1-6 0V4Z" />
<path
d="M5.5 9.643a.75.75 0 0 0-1.5 0V10c0 3.06 2.29 5.585 5.25 5.954V17.5h-1.5a.75.75 0 0 0 0 1.5h4.5a.75.75 0 0 0 0-1.5h-1.5v-1.546A6.001 6.001 0 0 0 16 10v-.357a.75.75 0 0 0-1.5 0V10a4.5 4.5 0 0 1-9 0v-.357Z"
/>
</svg>

View file

@ -47,7 +47,7 @@
class=" bg-gradient-to-b via-50% from-white via-white to-transparent dark:from-gray-900 dark:via-gray-900 dark:to-transparent pointer-events-none absolute inset-0 -bottom-7 z-[-1] blur"
></div>
<div class=" flex max-w-full w-full mx-auto px-5 pt-0.5 md:px-[1rem] bg-transparen">
<div class=" flex max-w-full w-full mx-auto px-2 pt-0.5 md:px-[1rem] bg-transparent">
<div class="flex items-center w-full max-w-full">
<div
class="{$showSidebar

View file

@ -103,7 +103,7 @@
<div slot="content">
<DropdownMenu.Content
class="w-full max-w-[200px] rounded-xl px-1 py-1.5 border border-gray-300/30 dark:border-gray-700/50 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg"
class="w-full max-w-[200px] rounded-xl px-1 py-1.5 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg"
sideOffset={8}
side="bottom"
align="end"
@ -152,6 +152,30 @@
</DropdownMenu.Item>
{/if}
{#if !$temporaryChatEnabled}
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
id="chat-share-button"
on:click={() => {
shareHandler();
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="size-4"
>
<path
fill-rule="evenodd"
d="M15.75 4.5a3 3 0 1 1 .825 2.066l-8.421 4.679a3.002 3.002 0 0 1 0 1.51l8.421 4.679a3 3 0 1 1-.729 1.31l-8.421-4.678a3 3 0 1 1 0-4.132l8.421-4.679a3 3 0 0 1-.096-.755Z"
clip-rule="evenodd"
/>
</svg>
<div class="flex items-center">{$i18n.t('Share')}</div>
</DropdownMenu.Item>
{/if}
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
id="chat-overview-button"
@ -178,47 +202,6 @@
<div class="flex items-center">{$i18n.t('Artifacts')}</div>
</DropdownMenu.Item>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
id="chat-copy-button"
on:click={async () => {
const res = await copyToClipboard(await getChatAsText()).catch((e) => {
console.error(e);
});
if (res) {
toast.success($i18n.t('Copied to clipboard'));
}
}}
>
<Clipboard className=" size-4" strokeWidth="1.5" />
<div class="flex items-center">{$i18n.t('Copy')}</div>
</DropdownMenu.Item>
{#if !$temporaryChatEnabled}
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
id="chat-share-button"
on:click={() => {
shareHandler();
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="size-4"
>
<path
fill-rule="evenodd"
d="M15.75 4.5a3 3 0 1 1 .825 2.066l-8.421 4.679a3.002 3.002 0 0 1 0 1.51l8.421 4.679a3 3 0 1 1-.729 1.31l-8.421-4.678a3 3 0 1 1 0-4.132l8.421-4.679a3 3 0 0 1-.096-.755Z"
clip-rule="evenodd"
/>
</svg>
<div class="flex items-center">{$i18n.t('Share')}</div>
</DropdownMenu.Item>
{/if}
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
@ -241,7 +224,7 @@
<div class="flex items-center">{$i18n.t('Download')}</div>
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
class="w-full rounded-lg px-1 py-1.5 border border-gray-300/30 dark:border-gray-700/50 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg"
class="w-full rounded-xl px-1 py-1.5 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg"
transition={flyAndScale}
sideOffset={8}
>
@ -273,8 +256,25 @@
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
id="chat-copy-button"
on:click={async () => {
const res = await copyToClipboard(await getChatAsText()).catch((e) => {
console.error(e);
});
if (res) {
toast.success($i18n.t('Copied to clipboard'));
}
}}
>
<Clipboard className=" size-4" strokeWidth="1.5" />
<div class="flex items-center">{$i18n.t('Copy')}</div>
</DropdownMenu.Item>
{#if !$temporaryChatEnabled}
<hr class="border-gray-100 dark:border-gray-800 mt-2.5 mb-1.5" />
<hr class="border-gray-50 dark:border-gray-850 my-0.5" />
<div class="flex p-1">
<Tags chatId={chat.id} />

View file

@ -1,5 +1,7 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { v4 as uuidv4 } from 'uuid';
import { goto } from '$app/navigation';
import {
user,
@ -28,25 +30,25 @@
createNewChat,
getPinnedChatList,
toggleChatPinnedStatusById,
getChatPinnedStatusById
getChatPinnedStatusById,
getChatById,
updateChatFolderIdById,
importChat
} from '$lib/apis/chats';
import { WEBUI_BASE_URL } from '$lib/constants';
import ArchivedChatsModal from './Sidebar/ArchivedChatsModal.svelte';
import UserMenu from './Sidebar/UserMenu.svelte';
import ChatItem from './Sidebar/ChatItem.svelte';
import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import Spinner from '../common/Spinner.svelte';
import Loader from '../common/Loader.svelte';
import FilesOverlay from '../chat/MessageInput/FilesOverlay.svelte';
import AddFilesPlaceholder from '../AddFilesPlaceholder.svelte';
import { select } from 'd3-selection';
import SearchInput from './Sidebar/SearchInput.svelte';
import ChevronDown from '../icons/ChevronDown.svelte';
import ChevronUp from '../icons/ChevronUp.svelte';
import ChevronRight from '../icons/ChevronRight.svelte';
import Collapsible from '../common/Collapsible.svelte';
import Folder from '../common/Folder.svelte';
import Plus from '../icons/Plus.svelte';
import Tooltip from '../common/Tooltip.svelte';
import { createNewFolder, getFolders, updateFolderParentIdById } from '$lib/apis/folders';
import Folders from './Sidebar/Folders.svelte';
const BREAKPOINT = 768;
@ -56,26 +58,105 @@
let shiftKey = false;
let selectedChatId = null;
let deleteChat = null;
let showDeleteConfirm = false;
let showDropdown = false;
let selectedTagName = null;
let showPinnedChat = true;
// Pagination variables
let chatListLoading = false;
let allChatsLoaded = false;
let folders = {};
const initFolders = async () => {
const folderList = await getFolders(localStorage.token).catch((error) => {
toast.error(error);
return [];
});
folders = {};
// First pass: Initialize all folder entries
for (const folder of folderList) {
// Ensure folder is added to folders with its data
folders[folder.id] = { ...(folders[folder.id] || {}), ...folder };
}
// Second pass: Tie child folders to their parents
for (const folder of folderList) {
if (folder.parent_id) {
// Ensure the parent folder is initialized if it doesn't exist
if (!folders[folder.parent_id]) {
folders[folder.parent_id] = {}; // Create a placeholder if not already present
}
// Initialize childrenIds array if it doesn't exist and add the current folder id
folders[folder.parent_id].childrenIds = folders[folder.parent_id].childrenIds
? [...folders[folder.parent_id].childrenIds, folder.id]
: [folder.id];
// Sort the children by updated_at field
folders[folder.parent_id].childrenIds.sort((a, b) => {
return folders[b].updated_at - folders[a].updated_at;
});
}
}
};
const createFolder = async (name = 'Untitled') => {
if (name === '') {
toast.error($i18n.t('Folder name cannot be empty.'));
return;
}
const rootFolders = Object.values(folders).filter((folder) => folder.parent_id === null);
if (rootFolders.find((folder) => folder.name.toLowerCase() === name.toLowerCase())) {
// If a folder with the same name already exists, append a number to the name
let i = 1;
while (
rootFolders.find((folder) => folder.name.toLowerCase() === `${name} ${i}`.toLowerCase())
) {
i++;
}
name = `${name} ${i}`;
}
// Add a dummy folder to the list to show the user that the folder is being created
const tempId = uuidv4();
folders = {
...folders,
tempId: {
id: tempId,
name: name,
created_at: Date.now(),
updated_at: Date.now()
}
};
const res = await createNewFolder(localStorage.token, name).catch((error) => {
toast.error(error);
return null;
});
if (res) {
await initFolders();
}
};
const initChatList = async () => {
// Reset pagination variables
tags.set(await getAllTags(localStorage.token));
pinnedChats.set(await getPinnedChatList(localStorage.token));
initFolders();
currentChatPage.set(1);
allChatsLoaded = false;
await chats.set(await getChatList(localStorage.token, $currentChatPage));
if (search) {
await chats.set(await getChatListBySearchText(localStorage.token, search, $currentChatPage));
} else {
await chats.set(await getChatList(localStorage.token, $currentChatPage));
}
// Enable pagination
scrollPaginationEnabled.set(true);
@ -106,7 +187,6 @@
const searchDebounceHandler = async () => {
console.log('search', search);
chats.set(null);
selectedTagName = null;
if (searchDebounceTimeout) {
clearTimeout(searchDebounceTimeout);
@ -117,6 +197,7 @@
return;
} else {
searchDebounceTimeout = setTimeout(async () => {
allChatsLoaded = false;
currentChatPage.set(1);
await chats.set(await getChatListBySearchText(localStorage.token, search));
@ -127,26 +208,16 @@
}
};
const deleteChatHandler = async (id) => {
const res = await deleteChatById(localStorage.token, id).catch((error) => {
toast.error(error);
return null;
});
if (res) {
tags.set(await getAllTags(localStorage.token));
if ($chatId === id) {
await chatId.set('');
await tick();
goto('/');
const importChatHandler = async (items, pinned = false, folderId = null) => {
console.log('importChatHandler', items, pinned, folderId);
for (const item of items) {
console.log(item);
if (item.chat) {
await importChat(localStorage.token, item.chat, item?.meta ?? {}, pinned, folderId);
}
allChatsLoaded = false;
currentChatPage.set(1);
await chats.set(await getChatList(localStorage.token, $currentChatPage));
await pinnedChats.set(await getPinnedChatList(localStorage.token));
}
initChatList();
};
const inputFilesHandler = async (files) => {
@ -158,18 +229,11 @@
const content = e.target.result;
try {
const items = JSON.parse(content);
for (const item of items) {
if (item.chat) {
await createNewChat(localStorage.token, item.chat);
}
}
const chatItems = JSON.parse(content);
importChatHandler(chatItems);
} catch {
toast.error($i18n.t(`Invalid file format.`));
}
initChatList();
};
reader.readAsText(file);
@ -179,29 +243,27 @@
const tagEventHandler = async (type, tagName, chatId) => {
console.log(type, tagName, chatId);
if (type === 'delete') {
currentChatPage.set(1);
await chats.set(await getChatListBySearchText(localStorage.token, search, $currentChatPage));
initChatList();
} else if (type === 'add') {
currentChatPage.set(1);
await chats.set(await getChatListBySearchText(localStorage.token, search, $currentChatPage));
initChatList();
}
};
let dragged = false;
let draggedOver = false;
const onDragOver = (e) => {
e.preventDefault();
// Check if a file is being dragged.
// Check if a file is being draggedOver.
if (e.dataTransfer?.types?.includes('Files')) {
dragged = true;
draggedOver = true;
} else {
dragged = false;
draggedOver = false;
}
};
const onDragLeave = () => {
dragged = false;
draggedOver = false;
};
const onDrop = async (e) => {
@ -218,7 +280,7 @@
}
}
dragged = false; // Reset dragged status after drop
draggedOver = false; // Reset draggedOver status after drop
};
let touchstart;
@ -284,7 +346,6 @@
localStorage.sidebar = value;
});
await pinnedChats.set(await getPinnedChatList(localStorage.token));
await initChatList();
window.addEventListener('keydown', onKeyDown);
@ -324,23 +385,10 @@
<ArchivedChatsModal
bind:show={$showArchivedChats}
on:change={async () => {
await pinnedChats.set(await getPinnedChatList(localStorage.token));
await initChatList();
}}
/>
<DeleteConfirmDialog
bind:show={showDeleteConfirm}
title={$i18n.t('Delete chat?')}
on:confirm={() => {
deleteChatHandler(deleteChat.id);
}}
>
<div class=" text-sm text-gray-500 flex-1 line-clamp-3">
{$i18n.t('This will delete')} <span class=" font-semibold">{deleteChat.title}</span>.
</div>
</DeleteConfirmDialog>
<!-- svelte-ignore a11y-no-static-element-interactions -->
{#if $showSidebar}
@ -361,18 +409,6 @@
"
data-state={$showSidebar}
>
{#if dragged}
<div
class="absolute w-full h-full max-h-full backdrop-blur bg-gray-800/40 flex justify-center z-[999] touch-none pointer-events-none"
>
<div class="m-auto pt-64 flex flex-col justify-center">
<AddFilesPlaceholder
title={$i18n.t('Drop Chat Export')}
content={$i18n.t('Drop a chat export file here to import it.')}
/>
</div>
</div>
{/if}
<div
class="py-2.5 my-auto flex flex-col justify-between h-screen max-h-[100dvh] w-[260px] overflow-x-hidden z-50 {$showSidebar
? ''
@ -381,7 +417,7 @@
<div class="px-2.5 flex justify-between space-x-1 text-gray-600 dark:text-gray-400">
<a
id="sidebar-new-chat-button"
class="flex flex-1 justify-between rounded-xl px-2 h-full hover:bg-gray-100 dark:hover:bg-gray-900 transition"
class="flex flex-1 justify-between rounded-lg px-2 h-full hover:bg-gray-100 dark:hover:bg-gray-900 transition"
href="/"
draggable="false"
on:click={async () => {
@ -425,7 +461,7 @@
</a>
<button
class=" cursor-pointer px-2 py-2 flex rounded-xl hover:bg-gray-100 dark:hover:bg-gray-900 transition"
class=" cursor-pointer px-2 py-2 flex rounded-lg hover:bg-gray-100 dark:hover:bg-gray-900 transition"
on:click={() => {
showSidebar.set(!$showSidebar);
}}
@ -452,7 +488,7 @@
{#if $user?.role === 'admin'}
<div class="px-2.5 flex justify-center text-gray-800 dark:text-gray-200">
<a
class="flex-grow flex space-x-3 rounded-xl px-2.5 py-2 hover:bg-gray-100 dark:hover:bg-gray-900 transition"
class="flex-grow flex space-x-3 rounded-lg px-2.5 py-2 hover:bg-gray-100 dark:hover:bg-gray-900 transition"
href="/workspace"
on:click={() => {
selectedChatId = null;
@ -493,6 +529,19 @@
<div class="absolute z-40 w-full h-full flex justify-center"></div>
{/if}
<div class="absolute z-40 right-4 top-1">
<Tooltip content={$i18n.t('New folder')}>
<button
class="p-1 rounded-lg bg-gray-50 hover:bg-gray-100 dark:bg-gray-950 dark:hover:bg-gray-900 transition"
on:click={() => {
createFolder();
}}
>
<Plus />
</button>
</Tooltip>
</div>
<SearchInput
bind:value={search}
on:input={searchDebounceHandler}
@ -510,33 +559,60 @@
{/if}
{#if !search && $pinnedChats.length > 0}
<div class=" flex flex-col space-y-1">
<div class="flex flex-col space-y-1 rounded-xl">
<Folder
className="px-2"
bind:open={showPinnedChat}
on:change={(e) => {
localStorage.setItem('showPinnedChat', e.detail);
console.log(e.detail);
}}
on:import={(e) => {
importChatHandler(e.detail, true);
}}
on:drop={async (e) => {
const { id } = e.detail;
const { type, id } = e.detail;
const status = await getChatPinnedStatusById(localStorage.token, id);
if (type === 'chat') {
const chat = await getChatById(localStorage.token, id);
if (!status) {
const res = await toggleChatPinnedStatusById(localStorage.token, id);
if (chat) {
console.log(chat);
if (chat.folder_id) {
const res = await updateChatFolderIdById(
localStorage.token,
chat.id,
null
).catch((error) => {
toast.error(error);
return null;
});
if (res) {
await pinnedChats.set(await getPinnedChatList(localStorage.token));
initChatList();
if (res) {
initChatList();
}
}
if (!chat.pinned) {
const res = await toggleChatPinnedStatusById(localStorage.token, id);
if (res) {
initChatList();
}
}
}
}
}}
name={$i18n.t('Pinned')}
>
<div class="pl-2 mt-0.5 flex flex-col overflow-y-auto scrollbar-hidden">
<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"
>
{#each $pinnedChats as chat, idx}
<ChatItem
{chat}
className=""
id={chat.id}
title={chat.title}
{shiftKey}
selected={selectedChatId === chat.id}
on:select={() => {
@ -545,13 +621,8 @@
on:unselect={() => {
selectedChatId = null;
}}
on:delete={(e) => {
if ((e?.detail ?? '') === 'shift') {
deleteChatHandler(chat.id);
} else {
deleteChat = chat;
showDeleteConfirm = true;
}
on:change={async () => {
initChatList();
}}
on:tag={(e) => {
const { type, name } = e.detail;
@ -564,25 +635,78 @@
</div>
{/if}
<div class="flex-1 flex flex-col space-y-1 overflow-y-auto scrollbar-hidden">
<div class=" flex-1 flex flex-col overflow-y-auto scrollbar-hidden">
{#if !search && folders}
<Folders
{folders}
on:import={(e) => {
const { folderId, items } = e.detail;
importChatHandler(items, false, folderId);
}}
on:update={async (e) => {
initChatList();
}}
on:change={async () => {
initChatList();
}}
/>
{/if}
<Folder
collapsible={false}
collapsible={!search}
className="px-2 mt-0.5"
name={$i18n.t('All chats')}
on:import={(e) => {
importChatHandler(e.detail);
}}
on:drop={async (e) => {
const { id } = e.detail;
const { type, id } = e.detail;
const status = await getChatPinnedStatusById(localStorage.token, id);
if (type === 'chat') {
const chat = await getChatById(localStorage.token, id);
if (status) {
const res = await toggleChatPinnedStatusById(localStorage.token, id);
if (chat) {
console.log(chat);
if (chat.folder_id) {
const res = await updateChatFolderIdById(localStorage.token, chat.id, null).catch(
(error) => {
toast.error(error);
return null;
}
);
if (res) {
initChatList();
}
}
if (chat.pinned) {
const res = await toggleChatPinnedStatusById(localStorage.token, id);
if (res) {
initChatList();
}
}
}
} else if (type === 'folder') {
if (folders[id].parent_id === null) {
return;
}
const res = await updateFolderParentIdById(localStorage.token, id, null).catch(
(error) => {
toast.error(error);
return null;
}
);
if (res) {
await pinnedChats.set(await getPinnedChatList(localStorage.token));
initChatList();
await initFolders();
}
}
}}
>
<div class="pt-2 pl-2">
<div class="pt-1.5">
{#if $chats}
{#each $chats as chat, idx}
{#if idx === 0 || (idx > 0 && chat.time_range !== $chats[idx - 1].time_range)}
@ -615,7 +739,9 @@
{/if}
<ChatItem
{chat}
className=""
id={chat.id}
title={chat.title}
{shiftKey}
selected={selectedChatId === chat.id}
on:select={() => {
@ -624,13 +750,8 @@
on:unselect={() => {
selectedChatId = null;
}}
on:delete={(e) => {
if ((e?.detail ?? '') === 'shift') {
deleteChatHandler(chat.id);
} else {
deleteChat = chat;
showDeleteConfirm = true;
}
on:change={async () => {
initChatList();
}}
on:tag={(e) => {
const { type, name } = e.detail;

View file

@ -28,13 +28,21 @@
} from '$lib/stores';
import ChatMenu from './ChatMenu.svelte';
import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import ShareChatModal from '$lib/components/chat/ShareChatModal.svelte';
import GarbageBin from '$lib/components/icons/GarbageBin.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import ArchiveBox from '$lib/components/icons/ArchiveBox.svelte';
import DragGhost from '$lib/components/common/DragGhost.svelte';
import Check from '$lib/components/icons/Check.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import Document from '$lib/components/icons/Document.svelte';
export let className = '';
export let id;
export let title;
export let chat;
export let selected = false;
export let shiftKey = false;
@ -43,7 +51,7 @@
let showShareChatModal = false;
let confirmEdit = false;
let chatTitle = chat.title;
let chatTitle = title;
const editChatTitle = async (id, title) => {
if (title === '') {
@ -78,13 +86,27 @@
}
};
const deleteChatHandler = async (id) => {
const res = await deleteChatById(localStorage.token, id).catch((error) => {
toast.error(error);
return null;
});
if (res) {
tags.set(await getAllTags(localStorage.token));
if ($chatId === id) {
await chatId.set('');
await tick();
goto('/');
}
dispatch('change');
}
};
const archiveChatHandler = async (id) => {
await archiveChatById(localStorage.token, id);
tags.set(await getAllTags(localStorage.token));
currentChatPage.set(1);
await chats.set(await getChatList(localStorage.token, $currentChatPage));
await pinnedChats.set(await getPinnedChatList(localStorage.token));
dispatch('change');
};
const focusEdit = async (node: HTMLInputElement) => {
@ -93,7 +115,7 @@
let itemElement;
let drag = false;
let dragged = false;
let x = 0;
let y = 0;
@ -102,28 +124,35 @@
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
const onDragStart = (event) => {
event.stopPropagation();
event.dataTransfer.setDragImage(dragImage, 0, 0);
// Set the data to be transferred
event.dataTransfer.setData(
'text/plain',
JSON.stringify({
id: chat.id
type: 'chat',
id: id
})
);
drag = true;
dragged = true;
itemElement.style.opacity = '0.5'; // Optional: Visual cue to show it's being dragged
};
const onDrag = (event) => {
event.stopPropagation();
x = event.clientX;
y = event.clientY;
};
const onDragEnd = (event) => {
event.stopPropagation();
itemElement.style.opacity = '1'; // Reset visual cue after drag
drag = false;
dragged = false;
};
onMount(() => {
@ -144,26 +173,42 @@
itemElement.removeEventListener('dragend', onDragEnd);
}
});
let showDeleteConfirm = false;
</script>
<ShareChatModal bind:show={showShareChatModal} chatId={chat.id} />
<ShareChatModal bind:show={showShareChatModal} chatId={id} />
{#if drag && x && y}
<DeleteConfirmDialog
bind:show={showDeleteConfirm}
title={$i18n.t('Delete chat?')}
on:confirm={() => {
deleteChatHandler(id);
}}
>
<div class=" text-sm text-gray-500 flex-1 line-clamp-3">
{$i18n.t('This will delete')} <span class=" font-semibold">{title}</span>.
</div>
</DeleteConfirmDialog>
{#if dragged && x && y}
<DragGhost {x} {y}>
<div class=" bg-black/80 backdrop-blur-2xl px-2 py-1 rounded-lg w-44">
<div>
<div class=" bg-black/80 backdrop-blur-2xl px-2 py-1 rounded-lg w-fit max-w-40">
<div class="flex items-center gap-1">
<Document className=" size-[18px]" strokeWidth="2" />
<div class=" text-xs text-white line-clamp-1">
{chat.title}
{title}
</div>
</div>
</div>
</DragGhost>
{/if}
<div bind:this={itemElement} class=" w-full pr-2 relative group" draggable="true">
<div bind:this={itemElement} class=" w-full {className} relative group" draggable="true">
{#if confirmEdit}
<div
class=" w-full flex justify-between rounded-xl px-2.5 py-2 {chat.id === $chatId || confirmEdit
class=" w-full flex justify-between rounded-lg px-[11px] py-[6px] {id === $chatId ||
confirmEdit
? 'bg-gray-200 dark:bg-gray-900'
: selected
? 'bg-gray-100 dark:bg-gray-950'
@ -177,12 +222,13 @@
</div>
{:else}
<a
class=" w-full flex justify-between rounded-lg px-2.5 py-2 {chat.id === $chatId || confirmEdit
class=" w-full flex justify-between rounded-lg px-[11px] py-[6px] {id === $chatId ||
confirmEdit
? 'bg-gray-200 dark:bg-gray-900'
: selected
? 'bg-gray-100 dark:bg-gray-950'
: ' group-hover:bg-gray-100 dark:group-hover:bg-gray-950'} whitespace-nowrap text-ellipsis"
href="/c/{chat.id}"
href="/c/{id}"
on:click={() => {
dispatch('select');
@ -191,7 +237,7 @@
}
}}
on:dblclick={() => {
chatTitle = chat.title;
chatTitle = title;
confirmEdit = true;
}}
on:mouseenter={(e) => {
@ -205,7 +251,7 @@
>
<div class=" flex self-center flex-1 w-full">
<div class=" text-left self-center overflow-hidden w-full h-[20px]">
{chat.title}
{title}
</div>
</div>
</a>
@ -214,12 +260,14 @@
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="
{chat.id === $chatId || confirmEdit
{id === $chatId || confirmEdit
? 'from-gray-200 dark:from-gray-900'
: selected
? 'from-gray-100 dark:from-gray-950'
: 'invisible group-hover:visible from-gray-100 dark:from-gray-950'}
absolute right-[10px] top-[6px] py-1 pr-2 pl-5 bg-gradient-to-l from-80%
absolute {className === 'pr-2'
? 'right-[8px]'
: 'right-0'} top-[4px] py-1 pr-0.5 mr-1.5 pl-5 bg-gradient-to-l from-80%
to-transparent"
on:mouseenter={(e) => {
@ -230,28 +278,19 @@
}}
>
{#if confirmEdit}
<div class="flex self-center space-x-1.5 z-10">
<div
class="flex self-center items-center space-x-1.5 z-10 translate-y-[0.5px] -translate-x-[0.5px]"
>
<Tooltip content={$i18n.t('Confirm')}>
<button
class=" self-center dark:hover:text-white transition"
on:click={() => {
editChatTitle(chat.id, chatTitle);
editChatTitle(id, chatTitle);
confirmEdit = false;
chatTitle = '';
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z"
clip-rule="evenodd"
/>
</svg>
<Check className=" size-3.5" strokeWidth="2.5" />
</button>
</Tooltip>
@ -263,16 +302,7 @@
chatTitle = '';
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
<XMark strokeWidth="2.5" />
</button>
</Tooltip>
</div>
@ -282,7 +312,7 @@
<button
class=" self-center dark:hover:text-white transition"
on:click={() => {
archiveChatHandler(chat.id);
archiveChatHandler(id);
}}
type="button"
>
@ -294,7 +324,7 @@
<button
class=" self-center dark:hover:text-white transition"
on:click={() => {
dispatch('delete', 'shift');
deleteChatHandler(id);
}}
type="button"
>
@ -305,29 +335,29 @@
{:else}
<div class="flex self-center space-x-1 z-10">
<ChatMenu
chatId={chat.id}
chatId={id}
cloneChatHandler={() => {
cloneChatHandler(chat.id);
cloneChatHandler(id);
}}
shareHandler={() => {
showShareChatModal = true;
}}
archiveChatHandler={() => {
archiveChatHandler(chat.id);
archiveChatHandler(id);
}}
renameHandler={() => {
chatTitle = chat.title;
chatTitle = title;
confirmEdit = true;
}}
deleteHandler={() => {
dispatch('delete');
showDeleteConfirm = true;
}}
onClose={() => {
dispatch('unselect');
}}
on:change={async () => {
await pinnedChats.set(await getPinnedChatList(localStorage.token));
dispatch('change');
}}
on:tag={(e) => {
dispatch('tag', e.detail);
@ -353,13 +383,13 @@
</button>
</ChatMenu>
{#if chat.id === $chatId}
{#if id === $chatId}
<!-- Shortcut support using "delete-chat-button" id -->
<button
id="delete-chat-button"
class="hidden"
on:click={() => {
dispatch('delete');
showDeleteConfirm = true;
}}
>
<svg

View file

@ -3,6 +3,9 @@
import { flyAndScale } from '$lib/utils/transitions';
import { getContext, createEventDispatcher } from 'svelte';
import fileSaver from 'file-saver';
const { saveAs } = fileSaver;
const dispatch = createEventDispatcher();
import Dropdown from '$lib/components/common/Dropdown.svelte';
@ -15,8 +18,15 @@
import DocumentDuplicate from '$lib/components/icons/DocumentDuplicate.svelte';
import Bookmark from '$lib/components/icons/Bookmark.svelte';
import BookmarkSlash from '$lib/components/icons/BookmarkSlash.svelte';
import { getChatPinnedStatusById, toggleChatPinnedStatusById } from '$lib/apis/chats';
import {
getChatById,
getChatPinnedStatusById,
toggleChatPinnedStatusById
} from '$lib/apis/chats';
import { chats } from '$lib/stores';
import { createMessagesList } from '$lib/utils';
import { downloadChatAsPDF } from '$lib/apis/utils';
import Download from '$lib/components/icons/Download.svelte';
const i18n = getContext('i18n');
@ -41,6 +51,70 @@
pinned = await getChatPinnedStatusById(localStorage.token, chatId);
};
const getChatAsText = async (chat) => {
const history = chat.chat.history;
const messages = createMessagesList(history, history.currentId);
const chatText = messages.reduce((a, message, i, arr) => {
return `${a}### ${message.role.toUpperCase()}\n${message.content}\n\n`;
}, '');
return chatText.trim();
};
const downloadTxt = async () => {
const chat = await getChatById(localStorage.token, chatId);
if (!chat) {
return;
}
const chatText = await getChatAsText(chat);
let blob = new Blob([chatText], {
type: 'text/plain'
});
saveAs(blob, `chat-${chat.chat.title}.txt`);
};
const downloadPdf = async () => {
const chat = await getChatById(localStorage.token, chatId);
if (!chat) {
return;
}
const history = chat.chat.history;
const messages = createMessagesList(history, history.currentId);
const blob = await downloadChatAsPDF(chat.chat.title, messages);
// Create a URL for the blob
const url = window.URL.createObjectURL(blob);
// Create a link element to trigger the download
const a = document.createElement('a');
a.href = url;
a.download = `chat-${chat.chat.title}.pdf`;
// Append the link to the body and click it programmatically
document.body.appendChild(a);
a.click();
// Remove the link from the body
document.body.removeChild(a);
// Revoke the URL to release memory
window.URL.revokeObjectURL(url);
};
const downloadJSONExport = async () => {
const chat = await getChatById(localStorage.token, chatId);
if (chat) {
let blob = new Blob([JSON.stringify([chat])], {
type: 'application/json'
});
saveAs(blob, `chat-export-${Date.now()}.json`);
}
};
$: if (show) {
checkPinned();
}
@ -60,14 +134,14 @@
<div slot="content">
<DropdownMenu.Content
class="w-full max-w-[180px] rounded-xl px-1 py-1.5 border border-gray-300/30 dark:border-gray-700/50 z-50 bg-white dark:bg-gray-850 dark:text-white shadow"
class="w-full max-w-[200px] rounded-xl px-1 py-1.5 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-xl"
sideOffset={-2}
side="bottom"
align="start"
transition={flyAndScale}
>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
pinHandler();
}}
@ -82,7 +156,7 @@
</DropdownMenu.Item>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
renameHandler();
}}
@ -92,7 +166,7 @@
</DropdownMenu.Item>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
cloneChatHandler();
}}
@ -102,7 +176,7 @@
</DropdownMenu.Item>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
archiveChatHandler();
}}
@ -112,7 +186,7 @@
</DropdownMenu.Item>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
shareHandler();
}}
@ -121,8 +195,48 @@
<div class="flex items-center">{$i18n.t('Share')}</div>
</DropdownMenu.Item>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
>
<Download strokeWidth="2" />
<div class="flex items-center">{$i18n.t('Download')}</div>
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
class="w-full rounded-xl px-1 py-1.5 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg"
transition={flyAndScale}
sideOffset={8}
>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
downloadJSONExport();
}}
>
<div class="flex items-center line-clamp-1">{$i18n.t('Export chat (.json)')}</div>
</DropdownMenu.Item>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
downloadTxt();
}}
>
<div class="flex items-center line-clamp-1">{$i18n.t('Plain text (.txt)')}</div>
</DropdownMenu.Item>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
downloadPdf();
}}
>
<div class="flex items-center line-clamp-1">{$i18n.t('PDF document (.pdf)')}</div>
</DropdownMenu.Item>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
deleteHandler();
}}
@ -131,7 +245,7 @@
<div class="flex items-center">{$i18n.t('Delete')}</div>
</DropdownMenu.Item>
<hr class="border-gray-100 dark:border-gray-800 mt-2.5 mb-1.5" />
<hr class="border-gray-50 dark:border-gray-850 my-0.5" />
<div class="flex p-1">
<Tags

View file

@ -0,0 +1,35 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
import RecursiveFolder from './RecursiveFolder.svelte';
export let folders = {};
let folderList = [];
// Get the list of folders that have no parent, sorted by name alphabetically
$: folderList = Object.keys(folders)
.filter((key) => folders[key].parent_id === null)
.sort((a, b) =>
folders[a].name.localeCompare(folders[b].name, undefined, {
numeric: true,
sensitivity: 'base'
})
);
</script>
{#each folderList as folderId (folderId)}
<RecursiveFolder
className="px-2"
{folders}
{folderId}
on:import={(e) => {
dispatch('import', e.detail);
}}
on:update={(e) => {
dispatch('update', e.detail);
}}
on:change={(e) => {
dispatch('change', e.detail);
}}
/>
{/each}

View file

@ -0,0 +1,70 @@
<script lang="ts">
import { DropdownMenu } from 'bits-ui';
import { flyAndScale } from '$lib/utils/transitions';
import { getContext, createEventDispatcher } from 'svelte';
const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
import Dropdown from '$lib/components/common/Dropdown.svelte';
import GarbageBin from '$lib/components/icons/GarbageBin.svelte';
import Pencil from '$lib/components/icons/Pencil.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Download from '$lib/components/icons/Download.svelte';
let show = false;
</script>
<Dropdown
bind:show
on:change={(e) => {
if (e.detail === false) {
dispatch('close');
}
}}
>
<Tooltip content={$i18n.t('More')}>
<slot />
</Tooltip>
<div slot="content">
<DropdownMenu.Content
class="w-full max-w-[160px] rounded-lg px-1 py-1.5 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-xl"
sideOffset={-2}
side="bottom"
align="start"
transition={flyAndScale}
>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
dispatch('rename');
}}
>
<Pencil strokeWidth="2" />
<div class="flex items-center">{$i18n.t('Rename')}</div>
</DropdownMenu.Item>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
dispatch('export');
}}
>
<Download strokeWidth="2" />
<div class="flex items-center">{$i18n.t('Export')}</div>
</DropdownMenu.Item>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
dispatch('delete');
}}
>
<GarbageBin strokeWidth="2" />
<div class="flex items-center">{$i18n.t('Delete')}</div>
</DropdownMenu.Item>
</DropdownMenu.Content>
</div>
</Dropdown>

View file

@ -0,0 +1,482 @@
<script>
import { getContext, createEventDispatcher, onMount, onDestroy, tick } from 'svelte';
const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
import DOMPurify from 'dompurify';
import fileSaver from 'file-saver';
const { saveAs } = fileSaver;
import ChevronDown from '../../icons/ChevronDown.svelte';
import ChevronRight from '../../icons/ChevronRight.svelte';
import Collapsible from '../../common/Collapsible.svelte';
import DragGhost from '$lib/components/common/DragGhost.svelte';
import FolderOpen from '$lib/components/icons/FolderOpen.svelte';
import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte';
import {
deleteFolderById,
updateFolderIsExpandedById,
updateFolderNameById,
updateFolderParentIdById
} from '$lib/apis/folders';
import { toast } from 'svelte-sonner';
import { getChatsByFolderId, updateChatFolderIdById } from '$lib/apis/chats';
import ChatItem from './ChatItem.svelte';
import FolderMenu from './Folders/FolderMenu.svelte';
import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
export let open = false;
export let folders;
export let folderId;
export let className = '';
export let parentDragged = false;
let folderElement;
let edit = false;
let draggedOver = false;
let dragged = false;
let name = '';
const onDragOver = (e) => {
e.preventDefault();
e.stopPropagation();
if (dragged || parentDragged) {
return;
}
draggedOver = true;
};
const onDrop = async (e) => {
e.preventDefault();
e.stopPropagation();
if (dragged || parentDragged) {
return;
}
if (folderElement.contains(e.target)) {
console.log('Dropped on the Button');
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
// Iterate over all items in the DataTransferItemList use functional programming
for (const item of Array.from(e.dataTransfer.items)) {
// If dropped items aren't files, reject them
if (item.kind === 'file') {
const file = item.getAsFile();
if (file && file.type === 'application/json') {
console.log('Dropped file is a JSON file!');
// Read the JSON file with FileReader
const reader = new FileReader();
reader.onload = async function (event) {
try {
const fileContent = JSON.parse(event.target.result);
open = true;
dispatch('import', {
folderId: folderId,
items: fileContent
});
} catch (error) {
console.error('Error parsing JSON file:', error);
}
};
// Start reading the file
reader.readAsText(file);
} else {
console.error('Only JSON file types are supported.');
}
console.log(file);
} else {
// Handle the drag-and-drop data for folders or chats (same as before)
const dataTransfer = e.dataTransfer.getData('text/plain');
const data = JSON.parse(dataTransfer);
console.log(data);
const { type, id } = data;
if (type === 'folder') {
open = true;
if (id === folderId) {
return;
}
// Move the folder
const res = await updateFolderParentIdById(localStorage.token, id, folderId).catch(
(error) => {
toast.error(error);
return null;
}
);
if (res) {
dispatch('update');
}
} else if (type === 'chat') {
open = true;
// Move the chat
const res = await updateChatFolderIdById(localStorage.token, id, folderId).catch(
(error) => {
toast.error(error);
return null;
}
);
if (res) {
dispatch('update');
}
}
}
}
}
draggedOver = false;
}
};
const onDragLeave = (e) => {
e.preventDefault();
if (dragged || parentDragged) {
return;
}
draggedOver = false;
};
const dragImage = new Image();
dragImage.src =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
let x;
let y;
const onDragStart = (event) => {
event.stopPropagation();
event.dataTransfer.setDragImage(dragImage, 0, 0);
// Set the data to be transferred
event.dataTransfer.setData(
'text/plain',
JSON.stringify({
type: 'folder',
id: folderId
})
);
dragged = true;
folderElement.style.opacity = '0.5'; // Optional: Visual cue to show it's being dragged
};
const onDrag = (event) => {
event.stopPropagation();
x = event.clientX;
y = event.clientY;
};
const onDragEnd = (event) => {
event.stopPropagation();
folderElement.style.opacity = '1'; // Reset visual cue after drag
dragged = false;
};
onMount(() => {
open = folders[folderId].is_expanded;
if (folderElement) {
folderElement.addEventListener('dragover', onDragOver);
folderElement.addEventListener('drop', onDrop);
folderElement.addEventListener('dragleave', onDragLeave);
// Event listener for when dragging starts
folderElement.addEventListener('dragstart', onDragStart);
// Event listener for when dragging occurs (optional)
folderElement.addEventListener('drag', onDrag);
// Event listener for when dragging ends
folderElement.addEventListener('dragend', onDragEnd);
}
});
onDestroy(() => {
if (folderElement) {
folderElement.addEventListener('dragover', onDragOver);
folderElement.removeEventListener('drop', onDrop);
folderElement.removeEventListener('dragleave', onDragLeave);
folderElement.removeEventListener('dragstart', onDragStart);
folderElement.removeEventListener('drag', onDrag);
folderElement.removeEventListener('dragend', onDragEnd);
}
});
let showDeleteConfirm = false;
const deleteHandler = async () => {
const res = await deleteFolderById(localStorage.token, folderId).catch((error) => {
toast.error(error);
return null;
});
if (res) {
toast.success($i18n.t('Folder deleted successfully'));
dispatch('update');
}
};
const nameUpdateHandler = async () => {
if (name === '') {
toast.error($i18n.t('Folder name cannot be empty'));
return;
}
if (name === folders[folderId].name) {
edit = false;
return;
}
const currentName = folders[folderId].name;
name = name.trim();
folders[folderId].name = name;
const res = await updateFolderNameById(localStorage.token, folderId, name).catch((error) => {
toast.error(error);
folders[folderId].name = currentName;
return null;
});
if (res) {
folders[folderId].name = name;
toast.success($i18n.t('Folder name updated successfully'));
dispatch('update');
}
};
const isExpandedUpdateHandler = async () => {
const res = await updateFolderIsExpandedById(localStorage.token, folderId, open).catch(
(error) => {
toast.error(error);
return null;
}
);
};
let isExpandedUpdateTimeout;
const isExpandedUpdateDebounceHandler = (open) => {
clearTimeout(isExpandedUpdateTimeout);
isExpandedUpdateTimeout = setTimeout(() => {
isExpandedUpdateHandler();
}, 500);
};
$: isExpandedUpdateDebounceHandler(open);
const editHandler = async () => {
console.log('Edit');
await tick();
name = folders[folderId].name;
edit = true;
await tick();
// focus on the input
setTimeout(() => {
const input = document.getElementById(`folder-${folderId}-input`);
input.focus();
}, 100);
};
const exportHandler = async () => {
const chats = await getChatsByFolderId(localStorage.token, folderId).catch((error) => {
toast.error(error);
return null;
});
if (!chats) {
return;
}
const blob = new Blob([JSON.stringify(chats)], {
type: 'application/json'
});
saveAs(blob, `folder-${folders[folderId].name}-export-${Date.now()}.json`);
};
</script>
<DeleteConfirmDialog
bind:show={showDeleteConfirm}
title={$i18n.t('Delete folder?')}
on:confirm={() => {
deleteHandler();
}}
>
<div class=" text-sm text-gray-700 dark:text-gray-300 flex-1 line-clamp-3">
{@html DOMPurify.sanitize(
$i18n.t('This will delete <strong>{{NAME}}</strong> and <strong>all its contents</strong>.', {
NAME: folders[folderId].name
})
)}
</div>
</DeleteConfirmDialog>
{#if dragged && x && y}
<DragGhost {x} {y}>
<div class=" bg-black/80 backdrop-blur-2xl px-2 py-1 rounded-lg w-fit max-w-40">
<div class="flex items-center gap-1">
<FolderOpen className="size-3.5" strokeWidth="2" />
<div class=" text-xs text-white line-clamp-1">
{folders[folderId].name}
</div>
</div>
</div>
</DragGhost>
{/if}
<div bind:this={folderElement} class="relative {className}" draggable="true">
{#if draggedOver}
<div
class="absolute top-0 left-0 w-full h-full rounded-sm bg-[hsla(260,85%,65%,0.1)] bg-opacity-50 dark:bg-opacity-10 z-50 pointer-events-none touch-none"
></div>
{/if}
<Collapsible
bind:open
className="w-full"
buttonClassName="w-full"
hide={(folders[folderId]?.childrenIds ?? []).length === 0 &&
(folders[folderId].items?.chats ?? []).length === 0}
on:change={(e) => {
dispatch('open', e.detail);
}}
>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="w-full group">
<button
id="folder-{folderId}-button"
class="relative w-full py-1.5 px-2 rounded-md flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-500 font-medium hover:bg-gray-100 dark:hover:bg-gray-900 transition"
on:dblclick={() => {
editHandler();
}}
>
<div class="text-gray-300 dark:text-gray-600">
{#if open}
<ChevronDown className=" size-3" strokeWidth="2.5" />
{:else}
<ChevronRight className=" size-3" strokeWidth="2.5" />
{/if}
</div>
<div class="translate-y-[0.5px] flex-1 justify-start text-start line-clamp-1">
{#if edit}
<input
id="folder-{folderId}-input"
type="text"
bind:value={name}
on:blur={() => {
nameUpdateHandler();
edit = false;
}}
on:click={(e) => {
// Prevent accidental collapse toggling when clicking inside input
e.stopPropagation();
}}
on:mousedown={(e) => {
// Prevent accidental collapse toggling when clicking inside input
e.stopPropagation();
}}
on:keydown={(e) => {
if (e.key === 'Enter') {
edit = false;
}
}}
class="w-full h-full bg-transparent text-gray-500 dark:text-gray-500 outline-none"
/>
{:else}
{folders[folderId].name}
{/if}
</div>
<button
class="absolute z-10 right-2 invisible group-hover:visible self-center flex items-center dark:text-gray-300"
on:pointerup={(e) => {
e.stopPropagation();
}}
>
<FolderMenu
on:rename={() => {
editHandler();
}}
on:delete={() => {
showDeleteConfirm = true;
}}
on:export={() => {
exportHandler();
}}
>
<button class="p-0.5 dark:hover:bg-gray-850 rounded-lg touch-auto" on:click={(e) => {}}>
<EllipsisHorizontal className="size-4" strokeWidth="2.5" />
</button>
</FolderMenu>
</button>
</button>
</div>
<div slot="content" class="w-full">
{#if (folders[folderId]?.childrenIds ?? []).length > 0 || (folders[folderId].items?.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"
>
{#if folders[folderId]?.childrenIds}
{@const children = folders[folderId]?.childrenIds
.map((id) => folders[id])
.sort((a, b) =>
a.name.localeCompare(b.name, undefined, {
numeric: true,
sensitivity: 'base'
})
)}
{#each children as childFolder (`${folderId}-${childFolder.id}`)}
<svelte:self
{folders}
folderId={childFolder.id}
parentDragged={dragged}
on:import={(e) => {
dispatch('import', e.detail);
}}
on:update={(e) => {
dispatch('update', e.detail);
}}
on:change={(e) => {
dispatch('change', e.detail);
}}
/>
{/each}
{/if}
{#if folders[folderId].items?.chats}
{#each folders[folderId].items.chats as chat (chat.id)}
<ChatItem
id={chat.id}
title={chat.title}
on:change={(e) => {
dispatch('change', e.detail);
}}
/>
{/each}
{/if}
</div>
{/if}
</div>
</Collapsible>
</div>

View file

@ -30,7 +30,13 @@
let filteredTags = [];
$: filteredTags = lastWord.startsWith('tag:')
? $tags.filter((tag) => {
? [
...$tags,
{
id: 'none',
name: $i18n.t('Untagged')
}
].filter((tag) => {
const tagName = lastWord.slice(4);
if (tagName) {
const tagId = tagName.replace(' ', '_').toLowerCase();
@ -144,7 +150,7 @@
{#if filteredTags.length > 0}
<div class="px-1 font-medium dark:text-gray-300 text-gray-700 mb-1">Tags</div>
<div class="">
<div class="max-h-60 overflow-auto">
{#each filteredTags as tag, tagIdx}
<button
class=" px-1.5 py-0.5 flex gap-1 hover:bg-gray-100 dark:hover:bg-gray-900 w-full rounded {selectedIdx ===
@ -163,7 +169,11 @@
dispatch('input');
}}
>
<div class="dark:text-gray-300 text-gray-700 font-medium">{tag.name}</div>
<div
class="dark:text-gray-300 text-gray-700 font-medium line-clamp-1 flex-shrink-0"
>
{tag.name}
</div>
<div class=" text-gray-500 line-clamp-1">
{tag.id}
@ -174,7 +184,7 @@
{:else if filteredOptions.length > 0}
<div class="px-1 font-medium dark:text-gray-300 text-gray-700 mb-1">Search options</div>
<div class="">
<div class=" max-h-60 overflow-auto">
{#each filteredOptions as option, optionIdx}
<button
class=" px-1.5 py-0.5 flex gap-1 hover:bg-gray-100 dark:hover:bg-gray-900 w-full rounded {selectedIdx ===

View file

@ -30,7 +30,7 @@
<slot name="content">
<DropdownMenu.Content
class="w-full {className} text-sm rounded-xl px-1 py-1.5 border border-gray-300/30 dark:border-gray-700/50 z-50 bg-white dark:bg-gray-850 dark:text-white shadow font-primary"
class="w-full {className} text-sm rounded-xl px-1 py-1.5 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-xl font-primary"
sideOffset={8}
side="bottom"
align="start"
@ -68,7 +68,7 @@
/>
</svg>
</div>
<div class=" self-center font-medium">{$i18n.t('Settings')}</div>
<div class=" self-center">{$i18n.t('Settings')}</div>
</button>
<button
@ -85,7 +85,7 @@
<div class=" self-center mr-3">
<ArchiveBox className="size-5" strokeWidth="1.5" />
</div>
<div class=" self-center font-medium">{$i18n.t('Archived Chats')}</div>
<div class=" self-center">{$i18n.t('Archived Chats')}</div>
</button>
{#if role === 'admin'}
@ -116,7 +116,7 @@
/>
</svg>
</div>
<div class=" self-center font-medium">{$i18n.t('Playground')}</div>
<div class=" self-center">{$i18n.t('Playground')}</div>
</button>
<button
@ -146,7 +146,7 @@
/>
</svg>
</div>
<div class=" self-center font-medium">{$i18n.t('Admin Panel')}</div>
<div class=" self-center">{$i18n.t('Admin Panel')}</div>
</button>
{/if}
@ -179,7 +179,7 @@
/>
</svg>
</div>
<div class=" self-center font-medium">{$i18n.t('Sign Out')}</div>
<div class=" self-center">{$i18n.t('Sign Out')}</div>
</button>
{#if $activeUserCount}
@ -201,7 +201,7 @@
</div>
<div class=" ">
<span class=" font-medium">
<span class="">
{$i18n.t('Active Users')}:
</span>
<span class=" font-semibold">
@ -212,7 +212,7 @@
</Tooltip>
{/if}
<!-- <DropdownMenu.Item class="flex items-center px-3 py-2 text-sm font-medium">
<!-- <DropdownMenu.Item class="flex items-center px-3 py-2 text-sm ">
<div class="flex items-center">Profile</div>
</DropdownMenu.Item> -->
</DropdownMenu.Content>

View file

@ -3,7 +3,7 @@
import fileSaver from 'file-saver';
const { saveAs } = fileSaver;
import { WEBUI_NAME, functions, models } from '$lib/stores';
import { WEBUI_NAME, config, functions, models } from '$lib/stores';
import { onMount, getContext, tick } from 'svelte';
import { createNewPrompt, deletePromptByCommand, getPrompts } from '$lib/apis/prompts';
@ -468,38 +468,45 @@
</div>
</div>
<div class=" my-16">
<div class=" text-lg font-semibold mb-3 line-clamp-1">
{$i18n.t('Made by OpenWebUI Community')}
{#if $config?.features.enable_community_sharing}
<div class=" my-16">
<div class=" text-lg font-semibold mb-3 line-clamp-1">
{$i18n.t('Made by OpenWebUI Community')}
</div>
<a
class=" flex space-x-4 cursor-pointer w-full mb-2 px-3 py-2"
href="https://openwebui.com/#open-webui-community"
target="_blank"
>
<div class=" self-center w-10 flex-shrink-0">
<div
class="w-full h-10 flex justify-center rounded-full bg-transparent dark:bg-gray-700 border border-dashed border-gray-200"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="w-6"
>
<path
fill-rule="evenodd"
d="M12 3.75a.75.75 0 01.75.75v6.75h6.75a.75.75 0 010 1.5h-6.75v6.75a.75.75 0 01-1.5 0v-6.75H4.5a.75.75 0 010-1.5h6.75V4.5a.75.75 0 01.75-.75z"
clip-rule="evenodd"
/>
</svg>
</div>
</div>
<div class=" self-center">
<div class=" font-semibold line-clamp-1">{$i18n.t('Discover a function')}</div>
<div class=" text-sm line-clamp-1">
{$i18n.t('Discover, download, and explore custom functions')}
</div>
</div>
</a>
</div>
<a
class=" flex space-x-4 cursor-pointer w-full mb-2 px-3 py-2"
href="https://openwebui.com/#open-webui-community"
target="_blank"
>
<div class=" self-center w-10 flex-shrink-0">
<div
class="w-full h-10 flex justify-center rounded-full bg-transparent dark:bg-gray-700 border border-dashed border-gray-200"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-6">
<path
fill-rule="evenodd"
d="M12 3.75a.75.75 0 01.75.75v6.75h6.75a.75.75 0 010 1.5h-6.75v6.75a.75.75 0 01-1.5 0v-6.75H4.5a.75.75 0 010-1.5h6.75V4.5a.75.75 0 01.75-.75z"
clip-rule="evenodd"
/>
</svg>
</div>
</div>
<div class=" self-center">
<div class=" font-semibold line-clamp-1">{$i18n.t('Discover a function')}</div>
<div class=" text-sm line-clamp-1">
{$i18n.t('Discover, download, and explore custom functions')}
</div>
</div>
</a>
</div>
{/if}
<DeleteConfirmDialog
bind:show={showDeleteConfirm}

View file

@ -7,6 +7,9 @@
import CodeEditor from '$lib/components/common/CodeEditor.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import Badge from '$lib/components/common/Badge.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import ChevronLeft from '$lib/components/icons/ChevronLeft.svelte';
let formElement = null;
let loading = false;
@ -294,61 +297,64 @@ class Pipe:
}
}}
>
<div class="mb-2.5">
<button
class="flex space-x-1"
on:click={() => {
goto('/workspace/functions');
}}
type="button"
>
<div class=" self-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M17 10a.75.75 0 01-.75.75H5.612l4.158 3.96a.75.75 0 11-1.04 1.08l-5.5-5.25a.75.75 0 010-1.08l5.5-5.25a.75.75 0 111.04 1.08L5.612 9.25H16.25A.75.75 0 0117 10z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class=" self-center font-medium text-sm">{$i18n.t('Back')}</div>
</button>
</div>
<div class="flex flex-col flex-1 overflow-auto h-0 rounded-lg">
<div class="w-full mb-2 flex flex-col gap-1.5">
<div class="flex gap-2 w-full">
<input
class="w-full px-3 py-2 text-sm font-medium bg-gray-50 dark:bg-gray-850 dark:text-gray-200 rounded-lg outline-none"
type="text"
placeholder={$i18n.t('Function Name (e.g. My Filter)')}
bind:value={name}
required
/>
<div class="w-full mb-2 flex flex-col gap-0.5">
<div class="flex w-full items-center">
<div class=" flex-shrink-0 mr-2">
<Tooltip content={$i18n.t('Back')}>
<button
class="w-full text-left text-sm py-1.5 px-1 rounded-lg dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-gray-850"
on:click={() => {
goto('/workspace/functions');
}}
type="button"
>
<ChevronLeft strokeWidth="2.5" />
</button>
</Tooltip>
</div>
<div class="flex-1">
<input
class="w-full text-2xl font-medium bg-transparent outline-none font-primary"
type="text"
placeholder={$i18n.t('Function Name (e.g. My Filter)')}
bind:value={name}
required
/>
</div>
<div>
<Badge type="muted" content={$i18n.t('Function')} />
</div>
</div>
<div class=" flex gap-2 px-1">
{#if edit}
<div class="text-sm text-gray-500 flex-shrink-0">
{id}
</div>
{:else}
<input
class="w-full text-sm disabled:text-gray-500 bg-transparent outline-none"
type="text"
placeholder={$i18n.t('Function ID (e.g. my_filter)')}
bind:value={id}
required
disabled={edit}
/>
{/if}
<input
class="w-full px-3 py-2 text-sm font-medium disabled:text-gray-300 dark:disabled:text-gray-700 bg-gray-50 dark:bg-gray-850 dark:text-gray-200 rounded-lg outline-none"
class="w-full text-sm bg-transparent outline-none"
type="text"
placeholder={$i18n.t('Function ID (e.g. my_filter)')}
bind:value={id}
placeholder={$i18n.t(
'Function Description (e.g. A filter to remove profanity from text)'
)}
bind:value={meta.description}
required
disabled={edit}
/>
</div>
<input
class="w-full px-3 py-2 text-sm font-medium bg-gray-50 dark:bg-gray-850 dark:text-gray-200 rounded-lg outline-none"
type="text"
placeholder={$i18n.t(
'Function Description (e.g. A filter to remove profanity from text)'
)}
bind:value={meta.description}
required
/>
</div>
<div class="mb-2 flex-1 overflow-auto h-0 rounded-lg">
@ -380,7 +386,7 @@ class Pipe:
</div>
<button
class="px-3 py-1.5 text-sm font-medium bg-emerald-600 hover:bg-emerald-700 text-gray-50 transition rounded-lg"
class="px-3 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
type="submit"
>
{$i18n.t('Save')}

View file

@ -21,6 +21,7 @@
import Pencil from '../icons/Pencil.svelte';
import DeleteConfirmDialog from '../common/ConfirmDialog.svelte';
import ItemMenu from './Knowledge/ItemMenu.svelte';
import Badge from '../common/Badge.svelte';
let query = '';
let selectedItem = null;
@ -167,20 +168,12 @@
<div class="mt-5 flex justify-between">
<div>
{#if item?.meta?.document}
<div
class="bg-gray-500/20 text-gray-700 dark:text-gray-200 rounded uppercase text-xs font-bold px-1"
>
{$i18n.t('Document')}
</div>
<Badge type="muted" content={$i18n.t('Document')} />
{:else}
<div
class="bg-green-500/20 text-green-700 dark:text-green-200 rounded uppercase text-xs font-bold px-1"
>
{$i18n.t('Collection')}
</div>
<Badge type="success" content={$i18n.t('Collection')} />
{/if}
</div>
<div class=" text-xs text-gray-500">
<div class=" text-xs text-gray-500 line-clamp-1">
{$i18n.t('Updated')}
{dayjs(item.updated_at * 1000).fromNow()}
</div>

View file

@ -2,6 +2,7 @@
import Fuse from 'fuse.js';
import { toast } from 'svelte-sonner';
import { v4 as uuidv4 } from 'uuid';
import { PaneGroup, Pane, PaneResizer } from 'paneforge';
import { onMount, getContext, onDestroy, tick } from 'svelte';
const i18n = getContext('i18n');
@ -21,22 +22,30 @@
updateKnowledgeById
} from '$lib/apis/knowledge';
import Spinner from '$lib/components/common/Spinner.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Badge from '$lib/components/common/Badge.svelte';
import Files from './Collection/Files.svelte';
import AddFilesPlaceholder from '$lib/components/AddFilesPlaceholder.svelte';
import AddContentModal from './Collection/AddTextContentModal.svelte';
import { transcribeAudio } from '$lib/apis/audio';
import { blobToFile } from '$lib/utils';
import { processFile } from '$lib/apis/retrieval';
import Spinner from '$lib/components/common/Spinner.svelte';
import Files from './Collection/Files.svelte';
import AddFilesPlaceholder from '$lib/components/AddFilesPlaceholder.svelte';
import AddContentMenu from './Collection/AddContentMenu.svelte';
import AddTextContentModal from './Collection/AddTextContentModal.svelte';
import SyncConfirmDialog from '../../common/ConfirmDialog.svelte';
import RichTextInput from '$lib/components/common/RichTextInput.svelte';
import EllipsisVertical from '$lib/components/icons/EllipsisVertical.svelte';
import Drawer from '$lib/components/common/Drawer.svelte';
import ChevronLeft from '$lib/components/icons/ChevronLeft.svelte';
import MenuLines from '$lib/components/icons/MenuLines.svelte';
let largeScreen = true;
let pane;
let showSidepanel = true;
let minSize = 0;
type Knowledge = {
id: string;
name: string;
@ -93,7 +102,7 @@
const createFileFromText = (name, content) => {
const blob = new Blob([content], { type: 'text/plain' });
const file = blobToFile(blob, `${name}.md`);
const file = blobToFile(blob, `${name}.txt`);
console.log(file);
return file;
@ -459,6 +468,36 @@
mediaQuery.addEventListener('change', handleMediaQuery);
handleMediaQuery(mediaQuery);
// Select the container element you want to observe
const container = document.getElementById('collection-container');
// initialize the minSize based on the container width
minSize = !largeScreen ? 100 : Math.floor((300 / container.clientWidth) * 100);
// Create a new ResizeObserver instance
const resizeObserver = new ResizeObserver((entries) => {
for (let entry of entries) {
const width = entry.contentRect.width;
// calculate the percentage of 300
const percentage = (300 / width) * 100;
// set the minSize to the percentage, must be an integer
minSize = !largeScreen ? 100 : Math.floor(percentage);
if (showSidepanel) {
if (pane && pane.isExpanded() && pane.getSize() < minSize) {
pane.resize(minSize);
}
}
}
});
// Start observing the container's size changes
resizeObserver.observe(container);
if (pane) {
pane.expand();
}
id = $page.params.id;
const res = await getKnowledgeById(localStorage.token, id).catch((e) => {
@ -552,91 +591,222 @@
}}
/>
<div class="flex flex-col w-full max-h-[100dvh] h-full">
<div class="flex flex-col mb-2 flex-1 overflow-auto h-0">
{#if id && knowledge}
<div class="flex flex-row h-0 flex-1 overflow-auto">
<div
class=" {largeScreen
? 'flex-shrink-0'
: 'flex-1'} flex py-2.5 w-80 rounded-2xl border border-gray-50 dark:border-gray-850"
<div class="flex flex-col w-full h-full max-h-[100dvh]" id="collection-container">
{#if id && knowledge}
<div class="flex flex-row flex-1 h-full max-h-full pb-2.5">
<PaneGroup direction="horizontal">
<Pane
bind:pane
defaultSize={minSize}
collapsible={true}
maxSize={50}
{minSize}
class="h-full"
onExpand={() => {
showSidepanel = true;
}}
onCollapse={() => {
showSidepanel = false;
}}
>
<div class=" flex flex-col w-full space-x-2 rounded-lg h-full">
<div class="w-full h-full flex flex-col">
<div class=" px-3">
<div class="flex">
<div class=" self-center ml-1 mr-3">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z"
clip-rule="evenodd"
/>
</svg>
</div>
<input
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-none bg-transparent"
bind:value={query}
placeholder={$i18n.t('Search Collection')}
on:focus={() => {
selectedFileId = null;
}}
/>
<div>
<AddContentMenu
on:upload={(e) => {
if (e.detail.type === 'directory') {
uploadDirectoryHandler();
} else if (e.detail.type === 'text') {
showAddTextContentModal = true;
} else {
document.getElementById('files-input').click();
}
<div
class="{largeScreen ? 'flex-shrink-0' : 'flex-1'}
flex
py-2
rounded-2xl
border
border-gray-50
h-full
dark:border-gray-850"
>
<div class=" flex flex-col w-full space-x-2 rounded-lg h-full">
<div class="w-full h-full flex flex-col">
<div class=" px-3">
<div class="flex py-1">
<div class=" self-center ml-1 mr-3">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z"
clip-rule="evenodd"
/>
</svg>
</div>
<input
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-none bg-transparent"
bind:value={query}
placeholder={$i18n.t('Search Collection')}
on:focus={() => {
selectedFileId = null;
}}
on:sync={(e) => {
showSyncConfirmModal = true;
/>
<div>
<AddContentMenu
on:upload={(e) => {
if (e.detail.type === 'directory') {
uploadDirectoryHandler();
} else if (e.detail.type === 'text') {
showAddTextContentModal = true;
} else {
document.getElementById('files-input').click();
}
}}
on:sync={(e) => {
showSyncConfirmModal = true;
}}
/>
</div>
</div>
</div>
{#if filteredItems.length > 0}
<div class=" flex overflow-y-auto h-full w-full scrollbar-hidden text-xs">
<Files
files={filteredItems}
{selectedFileId}
on:click={(e) => {
selectedFileId = selectedFileId === e.detail ? null : e.detail;
}}
on:delete={(e) => {
console.log(e.detail);
selectedFileId = null;
deleteFileHandler(e.detail);
}}
/>
</div>
</div>
<hr class=" mt-2 mb-1 border-gray-50 dark:border-gray-850" />
{:else}
<div class="m-auto text-gray-500 text-xs">{$i18n.t('No content found')}</div>
{/if}
</div>
{#if filteredItems.length > 0}
<div class=" flex overflow-y-auto h-full w-full scrollbar-hidden text-xs">
<Files
files={filteredItems}
{selectedFileId}
on:click={(e) => {
selectedFileId = selectedFileId === e.detail ? null : e.detail;
}}
on:delete={(e) => {
console.log(e.detail);
selectedFileId = null;
deleteFileHandler(e.detail);
}}
/>
</div>
{:else}
<div class="m-auto text-gray-500 text-xs">{$i18n.t('No content found')}</div>
{/if}
</div>
</div>
</div>
</Pane>
{#if largeScreen}
<div class="flex-1 flex justify-start max-h-full overflow-hidden pl-3">
{#if selectedFile}
<div class=" flex flex-col w-full h-full">
<div class=" flex-shrink-0 mb-2 flex items-center">
<PaneResizer class="relative flex w-2 items-center justify-center bg-background group">
<div class="z-10 flex h-7 w-5 items-center justify-center rounded-sm">
<EllipsisVertical className="size-4 invisible group-hover:visible" />
</div>
</PaneResizer>
<Pane>
<div class="flex-1 flex justify-start h-full max-h-full">
{#if selectedFile}
<div class=" flex flex-col w-full h-full max-h-full ml-2.5">
<div class="flex-shrink-0 mb-2 flex items-center">
{#if !showSidepanel}
<div class="-translate-x-2">
<button
class="w-full text-left text-sm p-1.5 rounded-lg dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-gray-850"
on:click={() => {
pane.expand();
}}
>
<ChevronLeft strokeWidth="2.5" />
</button>
</div>
{/if}
<div class=" flex-1 text-2xl font-medium">
<a
class="hover:text-gray-500 hover:dark:text-gray-100 hover:underline flex-grow line-clamp-1"
href={selectedFile.id ? `/api/v1/files/${selectedFile.id}/content` : '#'}
target="_blank"
>
{selectedFile?.meta?.name}
</a>
</div>
<div>
<button
class="self-center w-fit text-sm py-1 px-2.5 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-lg"
on:click={() => {
updateFileContentHandler();
}}
>
{$i18n.t('Save')}
</button>
</div>
</div>
<div
class=" flex-1 w-full h-full max-h-full text-sm bg-transparent outline-none overflow-y-auto scrollbar-hidden"
>
{#key selectedFile.id}
<RichTextInput
className="input-prose-sm"
bind:value={selectedFile.data.content}
placeholder={$i18n.t('Add content here')}
/>
{/key}
</div>
</div>
{:else}
<div class="m-auto pb-32">
<div>
<div class=" flex w-full mt-1 mb-3.5">
<div class="flex-1">
<div class="flex items-center justify-between w-full px-0.5 mb-1">
<div class="w-full">
<input
type="text"
class="text-center w-full font-medium text-3xl font-primary bg-transparent outline-none"
bind:value={knowledge.name}
on:input={() => {
changeDebounceHandler();
}}
/>
</div>
</div>
<div class="flex w-full px-1">
<input
type="text"
class="text-center w-full text-gray-500 bg-transparent outline-none"
bind:value={knowledge.description}
on:input={() => {
changeDebounceHandler();
}}
/>
</div>
</div>
</div>
</div>
<div class=" mt-2 text-center text-sm text-gray-200 dark:text-gray-700 w-full">
{$i18n.t('Select a file to view or drag and drop a file to upload')}
</div>
</div>
{/if}
</div>
</Pane>
{:else if !largeScreen && selectedFileId !== null}
<Drawer
className="h-full"
show={selectedFileId !== null}
on:close={() => {
selectedFileId = null;
}}
>
<div class="flex flex-col justify-start h-full max-h-full p-2">
<div class=" flex flex-col w-full h-full max-h-full">
<div class="flex-shrink-0 mt-1 mb-2 flex items-center">
<div class="mr-2">
<button
class="w-full text-left text-sm p-1.5 rounded-lg dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-gray-850"
on:click={() => {
selectedFileId = null;
}}
>
<ChevronLeft strokeWidth="2.5" />
</button>
</div>
<div class=" flex-1 text-xl line-clamp-1">
{selectedFile?.meta?.name}
</div>
@ -653,56 +823,24 @@
</div>
</div>
<div class=" flex-grow">
<textarea
class=" w-full h-full resize-none rounded-xl py-4 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
bind:value={selectedFile.data.content}
placeholder={$i18n.t('Add content here')}
/>
<div
class=" flex-1 w-full h-full max-h-full py-2.5 px-3.5 rounded-lg text-sm bg-transparent overflow-y-auto scrollbar-hidden"
>
{#key selectedFile.id}
<RichTextInput
className="input-prose-sm"
bind:value={selectedFile.data.content}
placeholder={$i18n.t('Add content here')}
/>
{/key}
</div>
</div>
{:else}
<div class="m-auto pb-32">
<div>
<div class=" flex w-full mt-1 mb-3.5">
<div class="flex-1">
<div class="flex items-center justify-between w-full px-0.5 mb-1">
<div class="w-full">
<input
type="text"
class="text-center w-full font-medium text-3xl font-primary bg-transparent outline-none"
bind:value={knowledge.name}
on:input={() => {
changeDebounceHandler();
}}
/>
</div>
</div>
<div class="flex w-full px-1">
<input
type="text"
class="text-center w-full text-gray-500 bg-transparent outline-none"
bind:value={knowledge.description}
on:input={() => {
changeDebounceHandler();
}}
/>
</div>
</div>
</div>
</div>
<div class=" mt-2 text-center text-sm text-gray-200 dark:text-gray-700 w-full">
{$i18n.t('Select a file to view or drag and drop a file to upload')}
</div>
</div>
{/if}
</div>
</div>
</Drawer>
{/if}
</div>
{:else}
<Spinner />
{/if}
</div>
</PaneGroup>
</div>
{:else}
<Spinner />
{/if}
</div>

View file

@ -29,7 +29,7 @@
>
<Tooltip content={$i18n.t('Add Content')}>
<button
class=" px-2 py-2 rounded-xl border border-gray-50 dark:border-gray-600 dark:border-0 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 transition font-medium text-sm flex items-center space-x-1"
class=" p-1.5 rounded-xl hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 transition font-medium text-sm flex items-center space-x-1"
on:click={(e) => {
e.stopPropagation();
show = true;

View file

@ -7,98 +7,138 @@
const dispatch = createEventDispatcher();
import Modal from '$lib/components/common/Modal.svelte';
import RichTextInput from '$lib/components/common/RichTextInput.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import Mic from '$lib/components/icons/Mic.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import VoiceRecording from '$lib/components/chat/MessageInput/VoiceRecording.svelte';
export let show = false;
let name = '';
let name = 'Untitled';
let content = '';
let voiceInput = false;
</script>
<Modal size="md" bind:show>
<div>
<div class=" flex justify-between dark:text-gray-300 px-5 pt-4">
<div class=" text-lg font-medium self-center">{$i18n.t('Add Content')}</div>
<button
class="self-center"
on:click={() => {
show = false;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
</button>
</div>
<div class="flex flex-col md:flex-row w-full px-5 py-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|preventDefault={() => {
if (name.trim() === '' || content.trim() === '') {
toast.error($i18n.t('Please fill in all fields.'));
name = '';
content = '';
return;
}
<Modal size="full" className="h-full bg-white dark:bg-gray-900" bind:show>
<div class="absolute top-0 right-0 p-5">
<button
class="self-center dark:text-white"
type="button"
on:click={() => {
show = false;
}}
>
<XMark className="size-3.5" />
</button>
</div>
<div class="flex flex-col md:flex-row w-full h-full md:space-x-4 dark:text-gray-200">
<form
class="flex flex-col w-full h-full"
on:submit|preventDefault={() => {
if (name.trim() === '' || content.trim() === '') {
toast.error($i18n.t('Please fill in all fields.'));
name = name.trim();
content = content.trim();
return;
}
dispatch('submit', {
name,
content
});
show = false;
name = '';
content = '';
}}
>
<div class="mb-3 w-full">
<div class="w-full flex flex-col gap-2.5">
<div class="w-full">
<div class=" text-sm mb-2">{$i18n.t('Title')}</div>
<div class="w-full mt-1">
<input
class="w-full rounded-lg py-2 px-4 text-sm bg-white dark:text-gray-300 dark:bg-gray-850 outline-none"
type="text"
bind:value={name}
placeholder={`Name your content`}
required
/>
</div>
</div>
<div>
<div class="text-sm mb-2">{$i18n.t('Content')}</div>
<div class=" w-full mt-1">
<textarea
class="w-full resize-none rounded-lg py-2 px-4 text-sm bg-whites dark:text-gray-300 dark:bg-gray-850 outline-none"
rows="10"
bind:value={content}
placeholder={`Write your content here`}
required
/>
</div>
</div>
dispatch('submit', {
name,
content
});
show = false;
name = '';
content = '';
}}
>
<div class=" flex-1 w-full h-full flex justify-center overflow-auto px-5 py-4">
<div class=" max-w-3xl py-2 md:py-10 w-full flex flex-col gap-2">
<div class="flex-shrink-0 w-full flex justify-between items-center">
<div class="w-full">
<input
class="w-full text-3xl font-semibold bg-transparent outline-none"
type="text"
bind:value={name}
placeholder={$i18n.t('Title')}
required
/>
</div>
</div>
<div class="flex justify-end text-sm font-medium">
<div class=" flex-1 w-full h-full">
<RichTextInput bind:value={content} placeholder={$i18n.t('Write something...')} />
</div>
</div>
</div>
<div
class="flex flex-row items-center justify-end text-sm font-medium flex-shrink-0 mt-1 p-4 gap-1.5"
>
<div class="">
{#if voiceInput}
<div class=" max-w-full w-64">
<VoiceRecording
bind:recording={voiceInput}
className="p-1"
on:cancel={() => {
voiceInput = false;
}}
on:confirm={(e) => {
const response = e.detail;
content = `${content}${response} `;
voiceInput = false;
}}
/>
</div>
{:else}
<Tooltip content={$i18n.t('Voice Input')}>
<button
class=" p-2 bg-gray-50 text-gray-700 dark:bg-gray-700 dark:text-white transition rounded-full"
type="button"
on:click={async () => {
try {
let stream = await navigator.mediaDevices
.getUserMedia({ audio: true })
.catch(function (err) {
toast.error(
$i18n.t(`Permission denied when accessing microphone: {{error}}`, {
error: err
})
);
return null;
});
if (stream) {
voiceInput = true;
const tracks = stream.getTracks();
tracks.forEach((track) => track.stop());
}
stream = null;
} catch {
toast.error($i18n.t('Permission denied when accessing microphone'));
}
}}
>
<Mic className="size-5" />
</button>
</Tooltip>
{/if}
</div>
<div class=" flex-shrink-0">
<Tooltip content={$i18n.t('Save')}>
<button
class=" px-4 py-2 bg-emerald-700 hover:bg-emerald-800 text-gray-100 transition rounded-lg"
class=" px-3.5 py-2 bg-black text-white dark:bg-white dark:text-black transition rounded-full"
type="submit"
>
{$i18n.t('Add Content')}
{$i18n.t('Save')}
</button>
</div>
</form>
</Tooltip>
</div>
</div>
</div>
</form>
</div>
</Modal>

View file

@ -10,7 +10,7 @@
<div class=" max-h-full flex flex-col w-full">
{#each files as file}
<div class="mt-2 px-2">
<div class="mt-1 px-2">
<FileItem
className="w-full"
colorClassName="{selectedFileId === file.id
@ -23,9 +23,17 @@
loading={file.status === 'uploading'}
dismissible
on:click={() => {
if (file.status === 'uploading') {
return;
}
dispatch('click', file.id);
}}
on:dismiss={() => {
if (file.status === 'uploading') {
return;
}
dispatch('delete', file.id);
}}
/>

View file

@ -9,7 +9,7 @@
import { onMount, getContext, tick } from 'svelte';
import { WEBUI_NAME, mobile, models, settings, user } from '$lib/stores';
import { WEBUI_NAME, config, mobile, models, settings, user } from '$lib/stores';
import { addNewModel, deleteModelById, getModelInfos, updateModelById } from '$lib/apis/models';
import { deleteModel } from '$lib/apis/ollama';
@ -674,35 +674,42 @@
{/if}
</div>
<div class=" my-16">
<div class=" text-lg font-semibold mb-3 line-clamp-1">
{$i18n.t('Made by OpenWebUI Community')}
{#if $config?.features.enable_community_sharing}
<div class=" my-16">
<div class=" text-lg font-semibold mb-3 line-clamp-1">
{$i18n.t('Made by OpenWebUI Community')}
</div>
<a
class=" flex space-x-4 cursor-pointer w-full mb-2 px-3 py-2"
href="https://openwebui.com/#open-webui-community"
target="_blank"
>
<div class=" self-center w-10 flex-shrink-0">
<div
class="w-full h-10 flex justify-center rounded-full bg-transparent dark:bg-gray-700 border border-dashed border-gray-200"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="w-6"
>
<path
fill-rule="evenodd"
d="M12 3.75a.75.75 0 01.75.75v6.75h6.75a.75.75 0 010 1.5h-6.75v6.75a.75.75 0 01-1.5 0v-6.75H4.5a.75.75 0 010-1.5h6.75V4.5a.75.75 0 01.75-.75z"
clip-rule="evenodd"
/>
</svg>
</div>
</div>
<div class=" self-center">
<div class=" font-semibold line-clamp-1">{$i18n.t('Discover a model')}</div>
<div class=" text-sm line-clamp-1">
{$i18n.t('Discover, download, and explore model presets')}
</div>
</div>
</a>
</div>
<a
class=" flex space-x-4 cursor-pointer w-full mb-2 px-3 py-2"
href="https://openwebui.com/#open-webui-community"
target="_blank"
>
<div class=" self-center w-10 flex-shrink-0">
<div
class="w-full h-10 flex justify-center rounded-full bg-transparent dark:bg-gray-700 border border-dashed border-gray-200"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-6">
<path
fill-rule="evenodd"
d="M12 3.75a.75.75 0 01.75.75v6.75h6.75a.75.75 0 010 1.5h-6.75v6.75a.75.75 0 01-1.5 0v-6.75H4.5a.75.75 0 010-1.5h6.75V4.5a.75.75 0 01.75-.75z"
clip-rule="evenodd"
/>
</svg>
</div>
</div>
<div class=" self-center">
<div class=" font-semibold line-clamp-1">{$i18n.t('Discover a model')}</div>
<div class=" text-sm line-clamp-1">
{$i18n.t('Discover, download, and explore model presets')}
</div>
</div>
</a>
</div>
{/if}

View file

@ -4,7 +4,7 @@
const { saveAs } = fileSaver;
import { onMount, getContext } from 'svelte';
import { WEBUI_NAME, prompts } from '$lib/stores';
import { WEBUI_NAME, config, prompts } from '$lib/stores';
import { createNewPrompt, deletePromptByCommand, getPrompts } from '$lib/apis/prompts';
import { error } from '@sveltejs/kit';
import { goto } from '$app/navigation';
@ -67,6 +67,18 @@
</title>
</svelte:head>
<DeleteConfirmDialog
bind:show={showDeleteConfirm}
title={$i18n.t('Delete prompt?')}
on:confirm={() => {
deleteHandler(deletePrompt);
}}
>
<div class=" text-sm text-gray-500">
{$i18n.t('This will delete')} <span class=" font-semibold">{deletePrompt.command}</span>.
</div>
</DeleteConfirmDialog>
<div class=" flex w-full space-x-2 mb-2.5">
<div class="flex flex-1">
<div class=" self-center ml-1 mr-3">
@ -128,7 +140,7 @@
>
<div class=" flex flex-1 space-x-4 cursor-pointer w-full">
<a href={`/workspace/prompts/edit?command=${encodeURIComponent(prompt.command)}`}>
<div class=" flex-1 self-center pl-5">
<div class=" flex-1 self-center pl-1.5">
<div class=" font-semibold line-clamp-1">{prompt.command}</div>
<div class=" text-xs overflow-hidden text-ellipsis line-clamp-1">
{prompt.title}
@ -284,47 +296,42 @@
</div>
</div>
<div class=" my-16">
<div class=" text-lg font-semibold mb-3 line-clamp-1">
{$i18n.t('Made by OpenWebUI Community')}
</div>
<a
class=" flex space-x-4 cursor-pointer w-full mb-2 px-3 py-2"
href="https://openwebui.com/#open-webui-community"
target="_blank"
>
<div class=" self-center w-10 flex-shrink-0">
<div
class="w-full h-10 flex justify-center rounded-full bg-transparent dark:bg-gray-700 border border-dashed border-gray-200"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-6">
<path
fill-rule="evenodd"
d="M12 3.75a.75.75 0 01.75.75v6.75h6.75a.75.75 0 010 1.5h-6.75v6.75a.75.75 0 01-1.5 0v-6.75H4.5a.75.75 0 010-1.5h6.75V4.5a.75.75 0 01.75-.75z"
clip-rule="evenodd"
/>
</svg>
</div>
{#if $config?.features.enable_community_sharing}
<div class=" my-16">
<div class=" text-lg font-semibold mb-3 line-clamp-1">
{$i18n.t('Made by OpenWebUI Community')}
</div>
<div class=" self-center">
<div class=" font-semibold line-clamp-1">{$i18n.t('Discover a prompt')}</div>
<div class=" text-sm line-clamp-1">
{$i18n.t('Discover, download, and explore custom prompts')}
<a
class=" flex space-x-4 cursor-pointer w-full mb-2 px-3 py-2"
href="https://openwebui.com/#open-webui-community"
target="_blank"
>
<div class=" self-center w-10 flex-shrink-0">
<div
class="w-full h-10 flex justify-center rounded-full bg-transparent dark:bg-gray-700 border border-dashed border-gray-200"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="w-6"
>
<path
fill-rule="evenodd"
d="M12 3.75a.75.75 0 01.75.75v6.75h6.75a.75.75 0 010 1.5h-6.75v6.75a.75.75 0 01-1.5 0v-6.75H4.5a.75.75 0 010-1.5h6.75V4.5a.75.75 0 01.75-.75z"
clip-rule="evenodd"
/>
</svg>
</div>
</div>
</div>
</a>
</div>
<DeleteConfirmDialog
bind:show={showDeleteConfirm}
title={$i18n.t('Delete prompt?')}
on:confirm={() => {
deleteHandler(deletePrompt);
}}
>
<div class=" text-sm text-gray-500">
{$i18n.t('This will delete')} <span class=" font-semibold">{deletePrompt.command}</span>.
<div class=" self-center">
<div class=" font-semibold line-clamp-1">{$i18n.t('Discover a prompt')}</div>
<div class=" text-sm line-clamp-1">
{$i18n.t('Discover, download, and explore custom prompts')}
</div>
</div>
</a>
</div>
</DeleteConfirmDialog>
{/if}

View file

@ -4,7 +4,7 @@
const { saveAs } = fileSaver;
import { onMount, getContext } from 'svelte';
import { WEBUI_NAME, prompts, tools } from '$lib/stores';
import { WEBUI_NAME, config, prompts, tools } from '$lib/stores';
import { createNewPrompt, deletePromptByCommand, getPrompts } from '$lib/apis/prompts';
import { goto } from '$app/navigation';
@ -422,38 +422,45 @@
</div>
</div>
<div class=" my-16">
<div class=" text-lg font-semibold mb-3 line-clamp-1">
{$i18n.t('Made by OpenWebUI Community')}
{#if $config?.features.enable_community_sharing}
<div class=" my-16">
<div class=" text-lg font-semibold mb-3 line-clamp-1">
{$i18n.t('Made by OpenWebUI Community')}
</div>
<a
class=" flex space-x-4 cursor-pointer w-full mb-2 px-3 py-2"
href="https://openwebui.com/#open-webui-community"
target="_blank"
>
<div class=" self-center w-10 flex-shrink-0">
<div
class="w-full h-10 flex justify-center rounded-full bg-transparent dark:bg-gray-700 border border-dashed border-gray-200"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="w-6"
>
<path
fill-rule="evenodd"
d="M12 3.75a.75.75 0 01.75.75v6.75h6.75a.75.75 0 010 1.5h-6.75v6.75a.75.75 0 01-1.5 0v-6.75H4.5a.75.75 0 010-1.5h6.75V4.5a.75.75 0 01.75-.75z"
clip-rule="evenodd"
/>
</svg>
</div>
</div>
<div class=" self-center">
<div class=" font-semibold line-clamp-1">{$i18n.t('Discover a tool')}</div>
<div class=" text-sm line-clamp-1">
{$i18n.t('Discover, download, and explore custom tools')}
</div>
</div>
</a>
</div>
<a
class=" flex space-x-4 cursor-pointer w-full mb-2 px-3 py-2"
href="https://openwebui.com/#open-webui-community"
target="_blank"
>
<div class=" self-center w-10 flex-shrink-0">
<div
class="w-full h-10 flex justify-center rounded-full bg-transparent dark:bg-gray-700 border border-dashed border-gray-200"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-6">
<path
fill-rule="evenodd"
d="M12 3.75a.75.75 0 01.75.75v6.75h6.75a.75.75 0 010 1.5h-6.75v6.75a.75.75 0 01-1.5 0v-6.75H4.5a.75.75 0 010-1.5h6.75V4.5a.75.75 0 01.75-.75z"
clip-rule="evenodd"
/>
</svg>
</div>
</div>
<div class=" self-center">
<div class=" font-semibold line-clamp-1">{$i18n.t('Discover a tool')}</div>
<div class=" text-sm line-clamp-1">
{$i18n.t('Discover, download, and explore custom tools')}
</div>
</div>
</a>
</div>
{/if}
<DeleteConfirmDialog
bind:show={showDeleteConfirm}

View file

@ -6,6 +6,9 @@
import CodeEditor from '$lib/components/common/CodeEditor.svelte';
import { goto } from '$app/navigation';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import Badge from '$lib/components/common/Badge.svelte';
import ChevronLeft from '$lib/components/icons/ChevronLeft.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
const dispatch = createEventDispatcher();
@ -182,61 +185,64 @@ class Tools:
}
}}
>
<div class="mb-2.5">
<button
class="flex space-x-1"
on:click={() => {
goto('/workspace/tools');
}}
type="button"
>
<div class=" self-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M17 10a.75.75 0 01-.75.75H5.612l4.158 3.96a.75.75 0 11-1.04 1.08l-5.5-5.25a.75.75 0 010-1.08l5.5-5.25a.75.75 0 111.04 1.08L5.612 9.25H16.25A.75.75 0 0117 10z"
clip-rule="evenodd"
<div class="flex flex-col flex-1 overflow-auto h-0">
<div class="w-full mb-2 flex flex-col gap-0.5">
<div class="flex w-full items-center">
<div class=" flex-shrink-0 mr-2">
<Tooltip content={$i18n.t('Back')}>
<button
class="w-full text-left text-sm py-1.5 px-1 rounded-lg dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-gray-850"
on:click={() => {
goto('/workspace/tools');
}}
type="button"
>
<ChevronLeft strokeWidth="2.5" />
</button>
</Tooltip>
</div>
<div class="flex-1">
<input
class="w-full text-2xl font-medium bg-transparent outline-none"
type="text"
placeholder={$i18n.t('Toolkit Name (e.g. My ToolKit)')}
bind:value={name}
required
/>
</svg>
</div>
<div class=" self-center font-medium text-sm">{$i18n.t('Back')}</div>
</button>
</div>
</div>
<div class="flex flex-col flex-1 overflow-auto h-0 rounded-lg">
<div class="w-full mb-2 flex flex-col gap-1.5">
<div class="flex gap-2 w-full">
<input
class="w-full px-3 py-2 text-sm font-medium bg-gray-50 dark:bg-gray-850 dark:text-gray-200 rounded-lg outline-none"
type="text"
placeholder={$i18n.t('Toolkit Name (e.g. My ToolKit)')}
bind:value={name}
required
/>
<div>
<Badge type="muted" content={$i18n.t('Tool')} />
</div>
</div>
<div class=" flex gap-2 px-1">
{#if edit}
<div class="text-sm text-gray-500 flex-shrink-0">
{id}
</div>
{:else}
<input
class="w-full text-sm disabled:text-gray-500 bg-transparent outline-none"
type="text"
placeholder={$i18n.t('Toolkit ID (e.g. my_toolkit)')}
bind:value={id}
required
disabled={edit}
/>
{/if}
<input
class="w-full px-3 py-2 text-sm font-medium disabled:text-gray-300 dark:disabled:text-gray-700 bg-gray-50 dark:bg-gray-850 dark:text-gray-200 rounded-lg outline-none"
class="w-full text-sm bg-transparent outline-none"
type="text"
placeholder={$i18n.t('Toolkit ID (e.g. my_toolkit)')}
bind:value={id}
placeholder={$i18n.t(
'Toolkit Description (e.g. A toolkit for performing various operations)'
)}
bind:value={meta.description}
required
disabled={edit}
/>
</div>
<input
class="w-full px-3 py-2 text-sm font-medium bg-gray-50 dark:bg-gray-850 dark:text-gray-200 rounded-lg outline-none"
type="text"
placeholder={$i18n.t(
'Toolkit Description (e.g. A toolkit for performing various operations)'
)}
bind:value={meta.description}
required
/>
</div>
<div class="mb-2 flex-1 overflow-auto h-0 rounded-lg">
@ -268,7 +274,7 @@ class Tools:
</div>
<button
class="px-3 py-1.5 text-sm font-medium bg-emerald-600 hover:bg-emerald-700 text-gray-50 transition rounded-lg"
class="px-3 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
type="submit"
>
{$i18n.t('Save')}

View file

@ -42,6 +42,7 @@
"Admins have access to all tools at all times; users need tools assigned per model in the workspace.": "",
"Advanced Parameters": "التعليمات المتقدمة",
"Advanced Params": "المعلمات المتقدمة",
"All chats": "",
"All Documents": "جميع الملفات",
"All Users": "جميع المستخدمين",
"Allow Chat Deletion": "يستطيع حذف المحادثات",
@ -185,6 +186,7 @@
"Delete chat": "حذف المحادثه",
"Delete Chat": "حذف المحادثه.",
"Delete chat?": "",
"Delete folder?": "",
"Delete function?": "",
"Delete prompt?": "",
"delete this link": "أحذف هذا الرابط",
@ -220,9 +222,7 @@
"Download": "تحميل",
"Download canceled": "تم اللغاء التحميل",
"Download Database": "تحميل قاعدة البيانات",
"Drop a chat export file here to import it.": "",
"Drop any files here to add to the conversation": "أسقط أية ملفات هنا لإضافتها إلى المحادثة",
"Drop Chat Export": "",
"e.g. '30s','10m'. Valid time units are 's', 'm', 'h'.": "e.g. '30s','10m'. الوحدات الزمنية الصالحة هي 's', 'm', 'h'.",
"Edit": "تعديل",
"Edit Memory": "",
@ -312,6 +312,10 @@
"Fingerprint spoofing detected: Unable to use initials as avatar. Defaulting to default profile image.": "تم اكتشاف انتحال بصمة الإصبع: غير قادر على استخدام الأحرف الأولى كصورة رمزية. الافتراضي لصورة الملف الشخصي الافتراضية.",
"Fluidly stream large external response chunks": "دفق قطع الاستجابة الخارجية الكبيرة بسلاسة",
"Focus chat input": "التركيز على إدخال الدردشة",
"Folder deleted successfully": "",
"Folder name cannot be empty": "",
"Folder name cannot be empty.": "",
"Folder name updated successfully": "",
"Followed instructions perfectly": "اتبعت التعليمات على أكمل وجه",
"Form": "",
"Format your variables using square brackets like this:": "قم بتنسيق المتغيرات الخاصة بك باستخدام الأقواس المربعة مثل هذا:",
@ -440,14 +444,17 @@
"Model(s) Whitelisted": "القائمة البيضاء الموديل",
"Modelfile Content": "محتوى الملف النموذجي",
"Models": "الموديلات",
"more": "",
"More": "المزيد",
"Move to Top": "",
"Name": "الأسم",
"Name your model": "قم بتسمية النموذج الخاص بك",
"New Chat": "دردشة جديدة",
"New folder": "",
"New Password": "كلمة المرور الجديدة",
"No content found": "",
"No content to speak": "",
"No distance available": "",
"No file selected": "",
"No files found.": "",
"No HTML, CSS, or JavaScript content found.": "",
@ -532,9 +539,11 @@
"Record voice": "سجل صوت",
"Redirecting you to OpenWebUI Community": "OpenWebUI إعادة توجيهك إلى مجتمع ",
"Refer to yourself as \"User\" (e.g., \"User is learning Spanish\")": "",
"References from": "",
"Refused when it shouldn't have": "رفض عندما لا ينبغي أن يكون",
"Regenerate": "تجديد",
"Release Notes": "ملاحظات الإصدار",
"Relevance": "",
"Remove": "إزالة",
"Remove Model": "حذف الموديل",
"Rename": "إعادة تسمية",
@ -604,6 +613,7 @@
"Select model": " أختار موديل",
"Select only one model to call": "",
"Selected model(s) do not support image inputs": "النموذج (النماذج) المحددة لا تدعم مدخلات الصور",
"Semantic distance to query": "",
"Send": "تم",
"Send a Message": "يُرجى إدخال طلبك هنا",
"Send message": "يُرجى إدخال طلبك هنا.",
@ -684,6 +694,7 @@
"This is an experimental feature, it may not function as expected and is subject to change at any time.": "",
"This option will delete all existing files in the collection and replace them with newly uploaded files.": "",
"This will delete": "",
"This will delete <strong>{{NAME}}</strong> and <strong>all its contents</strong>.": "",
"This will reset the knowledge base and sync all files. Do you wish to continue?": "",
"Thorough explanation": "شرح شامل",
"Tika": "",

View file

@ -42,6 +42,7 @@
"Admins have access to all tools at all times; users need tools assigned per model in the workspace.": "",
"Advanced Parameters": "Разширени Параметри",
"Advanced Params": "Разширени параметри",
"All chats": "",
"All Documents": "Всички Документи",
"All Users": "Всички Потребители",
"Allow Chat Deletion": "Позволи Изтриване на Чат",
@ -185,6 +186,7 @@
"Delete chat": "Изтриване на чат",
"Delete Chat": "Изтриване на Чат",
"Delete chat?": "",
"Delete folder?": "",
"Delete function?": "",
"Delete prompt?": "",
"delete this link": "Изтриване на този линк",
@ -220,9 +222,7 @@
"Download": "Изтегляне отменено",
"Download canceled": "Изтегляне отменено",
"Download Database": "Сваляне на база данни",
"Drop a chat export file here to import it.": "",
"Drop any files here to add to the conversation": "Пускане на файлове тук, за да ги добавите в чата",
"Drop Chat Export": "",
"e.g. '30s','10m'. Valid time units are 's', 'm', 'h'.": "напр. '30с','10м'. Валидни единици са 'с', 'м', 'ч'.",
"Edit": "Редактиране",
"Edit Memory": "",
@ -312,6 +312,10 @@
"Fingerprint spoofing detected: Unable to use initials as avatar. Defaulting to default profile image.": "Потвърждаване на отпечатък: Не може да се използва инициализационна буква като аватар. Потребителят се връща към стандартна аватарка.",
"Fluidly stream large external response chunks": "Плавно предаване на големи части от външен отговор",
"Focus chat input": "Фокусиране на чат вход",
"Folder deleted successfully": "",
"Folder name cannot be empty": "",
"Folder name cannot be empty.": "",
"Folder name updated successfully": "",
"Followed instructions perfectly": "Следвайте инструкциите перфектно",
"Form": "",
"Format your variables using square brackets like this:": "Форматирайте вашите променливи, като използвате квадратни скоби, както следва:",
@ -440,14 +444,17 @@
"Model(s) Whitelisted": "Модели Whitelisted",
"Modelfile Content": "Съдържание на модфайл",
"Models": "Модели",
"more": "",
"More": "Повече",
"Move to Top": "",
"Name": "Име",
"Name your model": "Дайте име на вашия модел",
"New Chat": "Нов чат",
"New folder": "",
"New Password": "Нова парола",
"No content found": "",
"No content to speak": "",
"No distance available": "",
"No file selected": "",
"No files found.": "",
"No HTML, CSS, or JavaScript content found.": "",
@ -532,9 +539,11 @@
"Record voice": "Записване на глас",
"Redirecting you to OpenWebUI Community": "Пренасочване към OpenWebUI общността",
"Refer to yourself as \"User\" (e.g., \"User is learning Spanish\")": "",
"References from": "",
"Refused when it shouldn't have": "Отказано, когато не трябва да бъде",
"Regenerate": "Регенериране",
"Release Notes": "Бележки по изданието",
"Relevance": "",
"Remove": "Изтриване",
"Remove Model": "Изтриване на модела",
"Rename": "Преименуване",
@ -600,6 +609,7 @@
"Select model": "Изберете модел",
"Select only one model to call": "",
"Selected model(s) do not support image inputs": "Избраният(те) модел(и) не поддържа въвеждане на изображения",
"Semantic distance to query": "",
"Send": "Изпрати",
"Send a Message": "Изпращане на Съобщение",
"Send message": "Изпращане на съобщение",
@ -680,6 +690,7 @@
"This is an experimental feature, it may not function as expected and is subject to change at any time.": "",
"This option will delete all existing files in the collection and replace them with newly uploaded files.": "",
"This will delete": "",
"This will delete <strong>{{NAME}}</strong> and <strong>all its contents</strong>.": "",
"This will reset the knowledge base and sync all files. Do you wish to continue?": "",
"Thorough explanation": "Това е подробно описание.",
"Tika": "",

View file

@ -42,6 +42,7 @@
"Admins have access to all tools at all times; users need tools assigned per model in the workspace.": "",
"Advanced Parameters": "এডভান্সড প্যারামিটার্স",
"Advanced Params": "অ্যাডভান্সড প্যারাম",
"All chats": "",
"All Documents": "সব ডকুমেন্ট",
"All Users": "সব ইউজার",
"Allow Chat Deletion": "চ্যাট ডিলিট করতে দিন",
@ -185,6 +186,7 @@
"Delete chat": "চ্যাট মুছে ফেলুন",
"Delete Chat": "চ্যাট মুছে ফেলুন",
"Delete chat?": "",
"Delete folder?": "",
"Delete function?": "",
"Delete prompt?": "",
"delete this link": "এই লিংক মুছে ফেলুন",
@ -220,9 +222,7 @@
"Download": "ডাউনলোড",
"Download canceled": "ডাউনলোড বাতিল করা হয়েছে",
"Download Database": "ডেটাবেজ ডাউনলোড করুন",
"Drop a chat export file here to import it.": "",
"Drop any files here to add to the conversation": "আলোচনায় যুক্ত করার জন্য যে কোন ফাইল এখানে ড্রপ করুন",
"Drop Chat Export": "",
"e.g. '30s','10m'. Valid time units are 's', 'm', 'h'.": "যেমন '30s','10m'. সময়ের অনুমোদিত অনুমোদিত এককগুলি হচ্ছে 's', 'm', 'h'.",
"Edit": "এডিট করুন",
"Edit Memory": "",
@ -312,6 +312,10 @@
"Fingerprint spoofing detected: Unable to use initials as avatar. Defaulting to default profile image.": "ফিঙ্গারপ্রিন্ট স্পুফিং ধরা পড়েছে: অ্যাভাটার হিসেবে নামের আদ্যক্ষর ব্যবহার করা যাচ্ছে না। ডিফল্ট প্রোফাইল পিকচারে ফিরিয়ে নেয়া হচ্ছে।",
"Fluidly stream large external response chunks": "বড় এক্সটার্নাল রেসপন্স চাঙ্কগুলো মসৃণভাবে প্রবাহিত করুন",
"Focus chat input": "চ্যাট ইনপুট ফোকাস করুন",
"Folder deleted successfully": "",
"Folder name cannot be empty": "",
"Folder name cannot be empty.": "",
"Folder name updated successfully": "",
"Followed instructions perfectly": "নির্দেশাবলী নিখুঁতভাবে অনুসরণ করা হয়েছে",
"Form": "",
"Format your variables using square brackets like this:": "আপনার ভেরিয়বলগুলো এভাবে স্কয়ার ব্রাকেটের মাধ্যমে সাজান",
@ -440,14 +444,17 @@
"Model(s) Whitelisted": "হোয়াইটলিস্টেড মডেল(সমূহ)",
"Modelfile Content": "মডেলফাইল কনটেন্ট",
"Models": "মডেলসমূহ",
"more": "",
"More": "আরো",
"Move to Top": "",
"Name": "নাম",
"Name your model": "আপনার মডেলের নাম দিন",
"New Chat": "নতুন চ্যাট",
"New folder": "",
"New Password": "নতুন পাসওয়ার্ড",
"No content found": "",
"No content to speak": "",
"No distance available": "",
"No file selected": "",
"No files found.": "",
"No HTML, CSS, or JavaScript content found.": "",
@ -532,9 +539,11 @@
"Record voice": "ভয়েস রেকর্ড করুন",
"Redirecting you to OpenWebUI Community": "আপনাকে OpenWebUI কমিউনিটিতে পাঠানো হচ্ছে",
"Refer to yourself as \"User\" (e.g., \"User is learning Spanish\")": "",
"References from": "",
"Refused when it shouldn't have": "যদি উপযুক্ত নয়, তবে রেজিগেনেট করা হচ্ছে",
"Regenerate": "রেজিগেনেট করুন",
"Release Notes": "রিলিজ নোটসমূহ",
"Relevance": "",
"Remove": "রিমুভ করুন",
"Remove Model": "মডেল রিমুভ করুন",
"Rename": "রেনেম",
@ -600,6 +609,7 @@
"Select model": "মডেল নির্বাচন করুন",
"Select only one model to call": "",
"Selected model(s) do not support image inputs": "নির্বাচিত মডেল(গুলি) চিত্র ইনপুট সমর্থন করে না",
"Semantic distance to query": "",
"Send": "পাঠান",
"Send a Message": "একটি মেসেজ পাঠান",
"Send message": "মেসেজ পাঠান",
@ -680,6 +690,7 @@
"This is an experimental feature, it may not function as expected and is subject to change at any time.": "",
"This option will delete all existing files in the collection and replace them with newly uploaded files.": "",
"This will delete": "",
"This will delete <strong>{{NAME}}</strong> and <strong>all its contents</strong>.": "",
"This will reset the knowledge base and sync all files. Do you wish to continue?": "",
"Thorough explanation": "পুঙ্খানুপুঙ্খ ব্যাখ্যা",
"Tika": "",

View file

@ -42,6 +42,7 @@
"Admins have access to all tools at all times; users need tools assigned per model in the workspace.": "Els administradors tenen accés a totes les eines en tot moment; els usuaris necessiten eines assignades per model a l'espai de treball.",
"Advanced Parameters": "Paràmetres avançats",
"Advanced Params": "Paràmetres avançats",
"All chats": "Tots els xats",
"All Documents": "Tots els documents",
"All Users": "Tots els usuaris",
"Allow Chat Deletion": "Permetre la supressió del xat",
@ -95,7 +96,7 @@
"Cancel": "Cancel·lar",
"Capabilities": "Capacitats",
"Change Password": "Canviar la contrasenya",
"Character": "",
"Character": "Personatge",
"Chat": "Xat",
"Chat Background Image": "Imatge de fons del xat",
"Chat Bubble UI": "Chat Bubble UI",
@ -124,7 +125,7 @@
"Clipboard write permission denied. Please check your browser settings to grant the necessary access.": "Permís d'escriptura al porta-retalls denegat. Comprova els ajustos de navegador per donar l'accés necessari.",
"Clone": "Clonar",
"Close": "Tancar",
"Code execution": "",
"Code execution": "Execució de codi",
"Code formatted successfully": "Codi formatat correctament",
"Collection": "Col·lecció",
"ComfyUI": "ComfyUI",
@ -153,7 +154,7 @@
"Copy last code block": "Copiar l'últim bloc de codi",
"Copy last response": "Copiar l'última resposta",
"Copy Link": "Copiar l'enllaç",
"Copy to clipboard": "",
"Copy to clipboard": "Copiar al porta-retalls",
"Copying to clipboard was successful!": "La còpia al porta-retalls s'ha realitzat correctament",
"Create a model": "Crear un model",
"Create Account": "Crear un compte",
@ -185,6 +186,7 @@
"Delete chat": "Eliminar xat",
"Delete Chat": "Eliminar xat",
"Delete chat?": "Eliminar el xat?",
"Delete folder?": "",
"Delete function?": "Eliminar funció?",
"Delete prompt?": "Eliminar indicació?",
"delete this link": "Eliminar aquest enllaç",
@ -220,9 +222,7 @@
"Download": "Descarregar",
"Download canceled": "Descàrrega cancel·lada",
"Download Database": "Descarregar la base de dades",
"Drop a chat export file here to import it.": "",
"Drop any files here to add to the conversation": "Deixa qualsevol arxiu aquí per afegir-lo a la conversa",
"Drop Chat Export": "",
"e.g. '30s','10m'. Valid time units are 's', 'm', 'h'.": "p. ex. '30s','10m'. Les unitats de temps vàlides són 's', 'm', 'h'.",
"Edit": "Editar",
"Edit Memory": "Editar la memòria",
@ -278,7 +278,7 @@
"Enter Your Password": "Introdueix la teva contrasenya",
"Enter Your Role": "Introdueix el teu rol",
"Error": "Error",
"ERROR": "",
"ERROR": "ERROR",
"Experimental": "Experimental",
"Export": "Exportar",
"Export All Chats (All Users)": "Exportar tots els xats (Tots els usuaris)",
@ -312,6 +312,10 @@
"Fingerprint spoofing detected: Unable to use initials as avatar. Defaulting to default profile image.": "S'ha detectat la suplantació d'identitat de l'empremta digital: no es poden utilitzar les inicials com a avatar. S'estableix la imatge de perfil predeterminada.",
"Fluidly stream large external response chunks": "Transmetre amb fluïdesa grans trossos de resposta externa",
"Focus chat input": "Estableix el focus a l'entrada del xat",
"Folder deleted successfully": "Carpeta eliminada correctament",
"Folder name cannot be empty": "El nom de la carpeta no pot ser buit",
"Folder name cannot be empty.": "El nom de la carpeta no pot ser buit.",
"Folder name updated successfully": "Nom de la carpeta actualitzat correctament",
"Followed instructions perfectly": "S'han seguit les instruccions perfectament",
"Form": "Formulari",
"Format your variables using square brackets like this:": "Formata les teves variables utilitzant claudàtors així:",
@ -365,7 +369,7 @@
"Install from Github URL": "Instal·lar des de l'URL de Github",
"Instant Auto-Send After Voice Transcription": "Enviament automàtic després de la transcripció de veu",
"Interface": "Interfície",
"Invalid file format.": "",
"Invalid file format.": "Format d'arxiu no vàlid.",
"Invalid Tag": "Etiqueta no vàlida",
"January": "Gener",
"join our Discord for help.": "uneix-te al nostre Discord per obtenir ajuda.",
@ -440,16 +444,19 @@
"Model(s) Whitelisted": "Model(s) a la llista blanca",
"Modelfile Content": "Contingut del Modelfile",
"Models": "Models",
"more": "més",
"More": "Més",
"Move to Top": "Moure a dalt de tot",
"Name": "Nom",
"Name your model": "Posa un nom al teu model",
"New Chat": "Nou xat",
"New folder": "Nova carpeta",
"New Password": "Nova contrasenya",
"No content found": "No s'ha trobat contingut",
"No content to speak": "No hi ha contingut per parlar",
"No distance available": "No hi ha distància disponible",
"No file selected": "No s'ha escollit cap fitxer",
"No files found.": "",
"No files found.": "No s'han trobat arxius.",
"No HTML, CSS, or JavaScript content found.": "No s'ha trobat contingut HTML, CSS o JavaScript.",
"No knowledge found": "No s'ha trobat Coneixement",
"No results found": "No s'han trobat resultats",
@ -492,7 +499,7 @@
"OpenAI URL/Key required.": "URL/Clau d'OpenAI requerides.",
"or": "o",
"Other": "Altres",
"OUTPUT": "",
"OUTPUT": "SORTIDA",
"Output format": "Format de sortida",
"Overview": "Vista general",
"page": "pàgina",
@ -532,9 +539,11 @@
"Record voice": "Enregistrar la veu",
"Redirecting you to OpenWebUI Community": "Redirigint-te a la comunitat OpenWebUI",
"Refer to yourself as \"User\" (e.g., \"User is learning Spanish\")": "Fes referència a tu mateix com a \"Usuari\" (p. ex., \"L'usuari està aprenent espanyol\")",
"References from": "Referències de",
"Refused when it shouldn't have": "Refusat quan no hauria d'haver estat",
"Regenerate": "Regenerar",
"Release Notes": "Notes de la versió",
"Relevance": "Rellevància",
"Remove": "Eliminar",
"Remove Model": "Eliminar el model",
"Rename": "Canviar el nom",
@ -545,7 +554,7 @@
"Reranking model set to \"{{reranking_model}}\"": "Model de reavaluació establert a \"{{reranking_model}}\"",
"Reset": "Restableix",
"Reset Upload Directory": "Restableix el directori de pujades",
"Reset Vector Storage/Knowledge": "",
"Reset Vector Storage/Knowledge": "Restableix el Repositori de vectors/Coneixement",
"Response AutoCopy to Clipboard": "Copiar la resposta automàticament al porta-retalls",
"Response notifications cannot be activated as the website permissions have been denied. Please visit your browser settings to grant the necessary access.": "Les notifications de resposta no es poden activar perquè els permisos del lloc web han estat rebutjats. Comprova les preferències del navegador per donar l'accés necessari.",
"Response splitting": "Divisió de la resposta",
@ -568,7 +577,7 @@
"Search a model": "Cercar un model",
"Search Chats": "Cercar xats",
"Search Collection": "Cercar col·leccions",
"search for tags": "",
"search for tags": "cercar etiquetes",
"Search Functions": "Cercar funcions",
"Search Knowledge": "Cercar coneixement",
"Search Models": "Cercar models",
@ -601,6 +610,7 @@
"Select model": "Seleccionar un model",
"Select only one model to call": "Seleccionar només un model per trucar",
"Selected model(s) do not support image inputs": "El(s) model(s) seleccionats no admeten l'entrada d'imatges",
"Semantic distance to query": "",
"Send": "Enviar",
"Send a Message": "Enviar un missatge",
"Send message": "Enviar missatge",
@ -643,7 +653,7 @@
"Speech Playback Speed": "Velocitat de la parla",
"Speech recognition error: {{error}}": "Error de reconeixement de veu: {{error}}",
"Speech-to-Text Engine": "Motor de veu a text",
"Stop": "",
"Stop": "Atura",
"Stop Sequence": "Atura la seqüència",
"Stream Chat Response": "Fer streaming de la resposta del xat",
"STT Model": "Model SST",
@ -666,7 +676,7 @@
"Template": "Plantilla",
"Temporary Chat": "Xat temporal",
"Text Completion": "Completament de text",
"Text Splitter": "",
"Text Splitter": "Separador de text",
"Text-to-Speech Engine": "Motor de text a veu",
"Tfs Z": "Tfs Z",
"Thanks for your feedback!": "Gràcies pel teu comentari!",
@ -681,11 +691,12 @@
"This is an experimental feature, it may not function as expected and is subject to change at any time.": "Aquesta és una funció experimental, és possible que no funcioni com s'espera i està subjecta a canvis en qualsevol moment.",
"This option will delete all existing files in the collection and replace them with newly uploaded files.": "Aquesta opció eliminarà tots els fitxers existents de la col·lecció i els substituirà per fitxers recentment penjats.",
"This will delete": "Això eliminarà",
"This will delete <strong>{{NAME}}</strong> and <strong>all its contents</strong>.": "",
"This will reset the knowledge base and sync all files. Do you wish to continue?": "Això restablirà la base de coneixement i sincronitzarà tots els fitxers. Vols continuar?",
"Thorough explanation": "Explicació en detall",
"Tika": "Tika",
"Tika Server URL required.": "La URL del servidor Tika és obligatòria.",
"Tiktoken": "",
"Tiktoken": "Tiktoken",
"Tip: Update multiple variable slots consecutively by pressing the tab key in the chat input after each replacement.": "Consell: Actualitza les diverses variables consecutivament prement la tecla de tabulació en l'entrada del xat després de cada reemplaçament.",
"Title": "Títol",
"Title (e.g. Tell me a fun fact)": "Títol (p. ex. Digues-me quelcom divertit)",
@ -703,7 +714,7 @@
"Today": "Avui",
"Toggle settings": "Alterna preferències",
"Toggle sidebar": "Alterna la barra lateral",
"Token": "",
"Token": "Token",
"Tokens To Keep On Context Refresh (num_keep)": "Tokens a mantenir en l'actualització del context (num_keep)",
"Tool created successfully": "Eina creada correctament",
"Tool deleted successfully": "Eina eliminada correctament",

View file

@ -42,6 +42,7 @@
"Admins have access to all tools at all times; users need tools assigned per model in the workspace.": "",
"Advanced Parameters": "advanced settings",
"Advanced Params": "",
"All chats": "",
"All Documents": "",
"All Users": "Ang tanan nga mga tiggamit",
"Allow Chat Deletion": "Tugoti nga mapapas ang mga chat",
@ -185,6 +186,7 @@
"Delete chat": "Pagtangtang sa panaghisgot",
"Delete Chat": "",
"Delete chat?": "",
"Delete folder?": "",
"Delete function?": "",
"Delete prompt?": "",
"delete this link": "",
@ -220,9 +222,7 @@
"Download": "",
"Download canceled": "",
"Download Database": "I-download ang database",
"Drop a chat export file here to import it.": "",
"Drop any files here to add to the conversation": "Ihulog ang bisan unsang file dinhi aron idugang kini sa panag-istoryahanay",
"Drop Chat Export": "",
"e.g. '30s','10m'. Valid time units are 's', 'm', 'h'.": "p. ",
"Edit": "",
"Edit Memory": "",
@ -312,6 +312,10 @@
"Fingerprint spoofing detected: Unable to use initials as avatar. Defaulting to default profile image.": "",
"Fluidly stream large external response chunks": "Hapsay nga paghatud sa daghang mga tipik sa eksternal nga mga tubag",
"Focus chat input": "Pag-focus sa entry sa diskusyon",
"Folder deleted successfully": "",
"Folder name cannot be empty": "",
"Folder name cannot be empty.": "",
"Folder name updated successfully": "",
"Followed instructions perfectly": "",
"Form": "",
"Format your variables using square brackets like this:": "I-format ang imong mga variable gamit ang square brackets sama niini:",
@ -440,14 +444,17 @@
"Model(s) Whitelisted": "Gi-whitelist nga (mga) modelo",
"Modelfile Content": "Mga sulod sa template file",
"Models": "Mga modelo",
"more": "",
"More": "",
"Move to Top": "",
"Name": "Ngalan",
"Name your model": "",
"New Chat": "Bag-ong diskusyon",
"New folder": "",
"New Password": "Bag-ong Password",
"No content found": "",
"No content to speak": "",
"No distance available": "",
"No file selected": "",
"No files found.": "",
"No HTML, CSS, or JavaScript content found.": "",
@ -532,9 +539,11 @@
"Record voice": "Irekord ang tingog",
"Redirecting you to OpenWebUI Community": "Gi-redirect ka sa komunidad sa OpenWebUI",
"Refer to yourself as \"User\" (e.g., \"User is learning Spanish\")": "",
"References from": "",
"Refused when it shouldn't have": "",
"Regenerate": "",
"Release Notes": "Release Notes",
"Relevance": "",
"Remove": "",
"Remove Model": "",
"Rename": "",
@ -600,6 +609,7 @@
"Select model": "Pagpili og modelo",
"Select only one model to call": "",
"Selected model(s) do not support image inputs": "",
"Semantic distance to query": "",
"Send": "",
"Send a Message": "Magpadala ug mensahe",
"Send message": "Magpadala ug mensahe",
@ -680,6 +690,7 @@
"This is an experimental feature, it may not function as expected and is subject to change at any time.": "",
"This option will delete all existing files in the collection and replace them with newly uploaded files.": "",
"This will delete": "",
"This will delete <strong>{{NAME}}</strong> and <strong>all its contents</strong>.": "",
"This will reset the knowledge base and sync all files. Do you wish to continue?": "",
"Thorough explanation": "",
"Tika": "",

View file

@ -42,6 +42,7 @@
"Admins have access to all tools at all times; users need tools assigned per model in the workspace.": "Administratoren haben jederzeit Zugriff auf alle Werkzeuge. Benutzer können im Arbeitsbereich zugewiesen.",
"Advanced Parameters": "Erweiterte Parameter",
"Advanced Params": "Erweiterte Parameter",
"All chats": "",
"All Documents": "Alle Dokumente",
"All Users": "Alle Benutzer",
"Allow Chat Deletion": "Unterhaltungen löschen erlauben",
@ -185,6 +186,7 @@
"Delete chat": "Unterhaltung löschen",
"Delete Chat": "Unterhaltung löschen",
"Delete chat?": "Unterhaltung löschen?",
"Delete folder?": "",
"Delete function?": "Funktion löschen?",
"Delete prompt?": "Prompt löschen?",
"delete this link": "diesen Link löschen",
@ -220,9 +222,7 @@
"Download": "Exportieren",
"Download canceled": "Exportierung abgebrochen",
"Download Database": "Datenbank exportieren",
"Drop a chat export file here to import it.": "",
"Drop any files here to add to the conversation": "Ziehen Sie beliebige Dateien hierher, um sie der Unterhaltung hinzuzufügen",
"Drop Chat Export": "",
"e.g. '30s','10m'. Valid time units are 's', 'm', 'h'.": "z. B. '30s','10m'. Gültige Zeiteinheiten sind 's', 'm', 'h'.",
"Edit": "Bearbeiten",
"Edit Memory": "Erinnerungen bearbeiten",
@ -312,6 +312,10 @@
"Fingerprint spoofing detected: Unable to use initials as avatar. Defaulting to default profile image.": "Fingerabdruck-Spoofing erkannt: Initialen können nicht als Avatar verwendet werden. Standard-Avatar wird verwendet.",
"Fluidly stream large external response chunks": "Nahtlose Übertragung großer externer Antwortabschnitte",
"Focus chat input": "Chat-Eingabe fokussieren",
"Folder deleted successfully": "",
"Folder name cannot be empty": "",
"Folder name cannot be empty.": "",
"Folder name updated successfully": "",
"Followed instructions perfectly": "Anweisungen perfekt befolgt",
"Form": "Formular",
"Format your variables using square brackets like this:": "Formatieren Sie Ihre Variablen mit eckigen Klammern wie folgt:",
@ -440,14 +444,17 @@
"Model(s) Whitelisted": "Modell(e) auf der Whitelist",
"Modelfile Content": "Modelfile-Inhalt",
"Models": "Modelle",
"more": "mehr",
"More": "Mehr",
"Move to Top": "",
"Name": "Name",
"Name your model": "Benennen Sie Ihr Modell",
"New Chat": "Neue Unterhaltung",
"New folder": "",
"New Password": "Neues Passwort",
"No content found": "",
"No content to speak": "Kein Inhalt zum Vorlesen",
"No distance available": "",
"No file selected": "Keine Datei ausgewählt",
"No files found.": "",
"No HTML, CSS, or JavaScript content found.": "",
@ -532,9 +539,11 @@
"Record voice": "Stimme aufnehmen",
"Redirecting you to OpenWebUI Community": "Sie werden zur OpenWebUI-Community weitergeleitet",
"Refer to yourself as \"User\" (e.g., \"User is learning Spanish\")": "",
"References from": "Referenzen aus",
"Refused when it shouldn't have": "Abgelehnt, obwohl es nicht hätte abgelehnt werden sollen",
"Regenerate": "Neu generieren",
"Release Notes": "Veröffentlichungshinweise",
"Relevance": "",
"Remove": "Entfernen",
"Remove Model": "Modell entfernen",
"Rename": "Umbenennen",
@ -600,6 +609,7 @@
"Select model": "Modell auswählen",
"Select only one model to call": "Wählen Sie nur ein Modell zum Anrufen aus",
"Selected model(s) do not support image inputs": "Ihre ausgewählten Modelle unterstützen keine Bildeingaben",
"Semantic distance to query": "",
"Send": "Senden",
"Send a Message": "Eine Nachricht senden",
"Send message": "Nachricht senden",
@ -680,6 +690,7 @@
"This is an experimental feature, it may not function as expected and is subject to change at any time.": "Dies ist eine experimentelle Funktion, sie funktioniert möglicherweise nicht wie erwartet und kann jederzeit geändert werden.",
"This option will delete all existing files in the collection and replace them with newly uploaded files.": "",
"This will delete": "Dies löscht",
"This will delete <strong>{{NAME}}</strong> and <strong>all its contents</strong>.": "",
"This will reset the knowledge base and sync all files. Do you wish to continue?": "",
"Thorough explanation": "Ausführliche Erklärung",
"Tika": "Tika",

View file

@ -42,6 +42,7 @@
"Admins have access to all tools at all times; users need tools assigned per model in the workspace.": "",
"Advanced Parameters": "Advanced Parameters",
"Advanced Params": "",
"All chats": "",
"All Documents": "",
"All Users": "All Users",
"Allow Chat Deletion": "Allow Delete Chats",
@ -185,6 +186,7 @@
"Delete chat": "Delete chat",
"Delete Chat": "",
"Delete chat?": "",
"Delete folder?": "",
"Delete function?": "",
"Delete prompt?": "",
"delete this link": "",
@ -220,9 +222,7 @@
"Download": "",
"Download canceled": "",
"Download Database": "Download Database",
"Drop a chat export file here to import it.": "",
"Drop any files here to add to the conversation": "Drop files here to add to conversation",
"Drop Chat Export": "",
"e.g. '30s','10m'. Valid time units are 's', 'm', 'h'.": "e.g. '30s','10m'. Much time units are 's', 'm', 'h'.",
"Edit": "",
"Edit Memory": "",
@ -312,6 +312,10 @@
"Fingerprint spoofing detected: Unable to use initials as avatar. Defaulting to default profile image.": "Fingerprint dogeing: Unable to use initials as avatar. Defaulting to default doge image.",
"Fluidly stream large external response chunks": "Fluidly wow big chunks",
"Focus chat input": "Focus chat bork",
"Folder deleted successfully": "",
"Folder name cannot be empty": "",
"Folder name cannot be empty.": "",
"Folder name updated successfully": "",
"Followed instructions perfectly": "",
"Form": "",
"Format your variables using square brackets like this:": "Format variables using square brackets like wow:",
@ -440,14 +444,17 @@
"Model(s) Whitelisted": "Wowdel(s) Whitelisted",
"Modelfile Content": "Modelfile Content",
"Models": "Wowdels",
"more": "",
"More": "",
"Move to Top": "",
"Name": "Name",
"Name your model": "",
"New Chat": "New Bark",
"New folder": "",
"New Password": "New Barkword",
"No content found": "",
"No content to speak": "",
"No distance available": "",
"No file selected": "",
"No files found.": "",
"No HTML, CSS, or JavaScript content found.": "",
@ -532,9 +539,11 @@
"Record voice": "Record Bark",
"Redirecting you to OpenWebUI Community": "Redirecting you to OpenWebUI Community",
"Refer to yourself as \"User\" (e.g., \"User is learning Spanish\")": "",
"References from": "",
"Refused when it shouldn't have": "",
"Regenerate": "",
"Release Notes": "Release Borks",
"Relevance": "",
"Remove": "",
"Remove Model": "",
"Rename": "",
@ -602,6 +611,7 @@
"Select model": "Select model much choice",
"Select only one model to call": "",
"Selected model(s) do not support image inputs": "",
"Semantic distance to query": "",
"Send": "",
"Send a Message": "Send a Message much message",
"Send message": "Send message very send",
@ -682,6 +692,7 @@
"This is an experimental feature, it may not function as expected and is subject to change at any time.": "",
"This option will delete all existing files in the collection and replace them with newly uploaded files.": "",
"This will delete": "",
"This will delete <strong>{{NAME}}</strong> and <strong>all its contents</strong>.": "",
"This will reset the knowledge base and sync all files. Do you wish to continue?": "",
"Thorough explanation": "",
"Tika": "",

View file

@ -42,6 +42,7 @@
"Admins have access to all tools at all times; users need tools assigned per model in the workspace.": "",
"Advanced Parameters": "",
"Advanced Params": "",
"All chats": "",
"All Documents": "",
"All Users": "",
"Allow Chat Deletion": "",
@ -185,6 +186,7 @@
"Delete chat": "",
"Delete Chat": "",
"Delete chat?": "",
"Delete folder?": "",
"Delete function?": "",
"Delete prompt?": "",
"delete this link": "",
@ -220,9 +222,7 @@
"Download": "",
"Download canceled": "",
"Download Database": "",
"Drop a chat export file here to import it.": "",
"Drop any files here to add to the conversation": "",
"Drop Chat Export": "",
"e.g. '30s','10m'. Valid time units are 's', 'm', 'h'.": "",
"Edit": "",
"Edit Memory": "",
@ -312,6 +312,10 @@
"Fingerprint spoofing detected: Unable to use initials as avatar. Defaulting to default profile image.": "",
"Fluidly stream large external response chunks": "",
"Focus chat input": "",
"Folder deleted successfully": "",
"Folder name cannot be empty": "",
"Folder name cannot be empty.": "",
"Folder name updated successfully": "",
"Followed instructions perfectly": "",
"Form": "",
"Format your variables using square brackets like this:": "",
@ -440,14 +444,17 @@
"Model(s) Whitelisted": "",
"Modelfile Content": "",
"Models": "",
"more": "",
"More": "",
"Move to Top": "",
"Name": "",
"Name your model": "",
"New Chat": "",
"New folder": "",
"New Password": "",
"No content found": "",
"No content to speak": "",
"No distance available": "",
"No file selected": "",
"No files found.": "",
"No HTML, CSS, or JavaScript content found.": "",
@ -532,9 +539,11 @@
"Record voice": "",
"Redirecting you to OpenWebUI Community": "",
"Refer to yourself as \"User\" (e.g., \"User is learning Spanish\")": "",
"References from": "",
"Refused when it shouldn't have": "",
"Regenerate": "",
"Release Notes": "",
"Relevance": "",
"Remove": "",
"Remove Model": "",
"Rename": "",
@ -600,6 +609,7 @@
"Select model": "",
"Select only one model to call": "",
"Selected model(s) do not support image inputs": "",
"Semantic distance to query": "",
"Send": "",
"Send a Message": "",
"Send message": "",
@ -680,6 +690,7 @@
"This is an experimental feature, it may not function as expected and is subject to change at any time.": "",
"This option will delete all existing files in the collection and replace them with newly uploaded files.": "",
"This will delete": "",
"This will delete <strong>{{NAME}}</strong> and <strong>all its contents</strong>.": "",
"This will reset the knowledge base and sync all files. Do you wish to continue?": "",
"Thorough explanation": "",
"Tika": "",

View file

@ -42,6 +42,7 @@
"Admins have access to all tools at all times; users need tools assigned per model in the workspace.": "",
"Advanced Parameters": "",
"Advanced Params": "",
"All chats": "",
"All Documents": "",
"All Users": "",
"Allow Chat Deletion": "",
@ -185,6 +186,7 @@
"Delete chat": "",
"Delete Chat": "",
"Delete chat?": "",
"Delete folder?": "",
"Delete function?": "",
"Delete prompt?": "",
"delete this link": "",
@ -220,9 +222,7 @@
"Download": "",
"Download canceled": "",
"Download Database": "",
"Drop a chat export file here to import it.": "",
"Drop any files here to add to the conversation": "",
"Drop Chat Export": "",
"e.g. '30s','10m'. Valid time units are 's', 'm', 'h'.": "",
"Edit": "",
"Edit Memory": "",
@ -312,6 +312,10 @@
"Fingerprint spoofing detected: Unable to use initials as avatar. Defaulting to default profile image.": "",
"Fluidly stream large external response chunks": "",
"Focus chat input": "",
"Folder deleted successfully": "",
"Folder name cannot be empty": "",
"Folder name cannot be empty.": "",
"Folder name updated successfully": "",
"Followed instructions perfectly": "",
"Form": "",
"Format your variables using square brackets like this:": "",
@ -440,14 +444,17 @@
"Model(s) Whitelisted": "",
"Modelfile Content": "",
"Models": "",
"more": "",
"More": "",
"Move to Top": "",
"Name": "",
"Name your model": "",
"New Chat": "",
"New folder": "",
"New Password": "",
"No content found": "",
"No content to speak": "",
"No distance available": "",
"No file selected": "",
"No files found.": "",
"No HTML, CSS, or JavaScript content found.": "",
@ -532,9 +539,11 @@
"Record voice": "",
"Redirecting you to OpenWebUI Community": "",
"Refer to yourself as \"User\" (e.g., \"User is learning Spanish\")": "",
"References from": "",
"Refused when it shouldn't have": "",
"Regenerate": "",
"Release Notes": "",
"Relevance": "",
"Remove": "",
"Remove Model": "",
"Rename": "",
@ -600,6 +609,7 @@
"Select model": "",
"Select only one model to call": "",
"Selected model(s) do not support image inputs": "",
"Semantic distance to query": "",
"Send": "",
"Send a Message": "",
"Send message": "",
@ -680,6 +690,7 @@
"This is an experimental feature, it may not function as expected and is subject to change at any time.": "",
"This option will delete all existing files in the collection and replace them with newly uploaded files.": "",
"This will delete": "",
"This will delete <strong>{{NAME}}</strong> and <strong>all its contents</strong>.": "",
"This will reset the knowledge base and sync all files. Do you wish to continue?": "",
"Thorough explanation": "",
"Tika": "",

View file

@ -42,6 +42,7 @@
"Admins have access to all tools at all times; users need tools assigned per model in the workspace.": "Admins tienen acceso a todas las herramientas en todo momento; los usuarios necesitan herramientas asignadas por modelo en el espacio de trabajo.",
"Advanced Parameters": "Parámetros Avanzados",
"Advanced Params": "Parámetros avanzados",
"All chats": "",
"All Documents": "Todos los Documentos",
"All Users": "Todos los Usuarios",
"Allow Chat Deletion": "Permitir Borrar Chats",
@ -185,6 +186,7 @@
"Delete chat": "Borrar chat",
"Delete Chat": "Borrar Chat",
"Delete chat?": "Borrar el chat?",
"Delete folder?": "",
"Delete function?": "Borrar la función?",
"Delete prompt?": "Borrar el prompt?",
"delete this link": "Borrar este enlace",
@ -220,9 +222,7 @@
"Download": "Descargar",
"Download canceled": "Descarga cancelada",
"Download Database": "Descarga la Base de Datos",
"Drop a chat export file here to import it.": "",
"Drop any files here to add to the conversation": "Suelta cualquier archivo aquí para agregarlo a la conversación",
"Drop Chat Export": "",
"e.g. '30s','10m'. Valid time units are 's', 'm', 'h'.": "p.ej. '30s','10m'. Unidades válidas de tiempo son 's', 'm', 'h'.",
"Edit": "Editar",
"Edit Memory": "Editar Memoria",
@ -312,6 +312,10 @@
"Fingerprint spoofing detected: Unable to use initials as avatar. Defaulting to default profile image.": "Se detectó suplantación de huellas: No se pueden usar las iniciales como avatar. Por defecto se utiliza la imagen de perfil predeterminada.",
"Fluidly stream large external response chunks": "Transmita con fluidez grandes fragmentos de respuesta externa",
"Focus chat input": "Enfoca la entrada del chat",
"Folder deleted successfully": "",
"Folder name cannot be empty": "",
"Folder name cannot be empty.": "",
"Folder name updated successfully": "",
"Followed instructions perfectly": "Siguió las instrucciones perfectamente",
"Form": "De",
"Format your variables using square brackets like this:": "Formatea tus variables usando corchetes de la siguiente manera:",
@ -440,14 +444,17 @@
"Model(s) Whitelisted": "Modelo(s) habilitados",
"Modelfile Content": "Contenido del Modelfile",
"Models": "Modelos",
"more": "",
"More": "Más",
"Move to Top": "Mueve al tope",
"Name": "Nombre",
"Name your model": "Asigne un nombre a su modelo",
"New Chat": "Nuevo Chat",
"New folder": "",
"New Password": "Nueva Contraseña",
"No content found": "",
"No content to speak": "No hay contenido para hablar",
"No distance available": "",
"No file selected": "Ningún archivo fué seleccionado",
"No files found.": "",
"No HTML, CSS, or JavaScript content found.": "No se encontró contenido HTML, CSS, o JavaScript.",
@ -532,9 +539,11 @@
"Record voice": "Grabar voz",
"Redirecting you to OpenWebUI Community": "Redireccionándote a la comunidad OpenWebUI",
"Refer to yourself as \"User\" (e.g., \"User is learning Spanish\")": "Referirse a usted mismo como \"Usuario\" (por ejemplo, \"El usuario está aprendiendo Español\")",
"References from": "",
"Refused when it shouldn't have": "Rechazado cuando no debería",
"Regenerate": "Regenerar",
"Release Notes": "Notas de la versión",
"Relevance": "",
"Remove": "Eliminar",
"Remove Model": "Eliminar modelo",
"Rename": "Renombrar",
@ -601,6 +610,7 @@
"Select model": "Selecciona un modelo",
"Select only one model to call": "Selecciona sólo un modelo para llamar",
"Selected model(s) do not support image inputs": "Los modelos seleccionados no admiten entradas de imagen",
"Semantic distance to query": "",
"Send": "Enviar",
"Send a Message": "Enviar un Mensaje",
"Send message": "Enviar Mensaje",
@ -681,6 +691,7 @@
"This is an experimental feature, it may not function as expected and is subject to change at any time.": "Esta es una característica experimental que puede no funcionar como se esperaba y está sujeto a cambios en cualquier momento.",
"This option will delete all existing files in the collection and replace them with newly uploaded files.": " Esta opción eliminará todos los archivos existentes en la colección y los reemplazará con nuevos archivos subidos.",
"This will delete": "Esto eliminará",
"This will delete <strong>{{NAME}}</strong> and <strong>all its contents</strong>.": "",
"This will reset the knowledge base and sync all files. Do you wish to continue?": "Esto reseteará la base de conocimientos y sincronizará todos los archivos. ¿Desea continuar?",
"Thorough explanation": "Explicación exhaustiva",
"Tika": "Tika",

View file

@ -42,6 +42,7 @@
"Admins have access to all tools at all times; users need tools assigned per model in the workspace.": "",
"Advanced Parameters": "پارامترهای پیشرفته",
"Advanced Params": "پارام های پیشرفته",
"All chats": "",
"All Documents": "تمام سند ها",
"All Users": "همه کاربران",
"Allow Chat Deletion": "اجازه حذف گپ",
@ -185,6 +186,7 @@
"Delete chat": "حذف گپ",
"Delete Chat": "حذف گپ",
"Delete chat?": "",
"Delete folder?": "",
"Delete function?": "",
"Delete prompt?": "",
"delete this link": "حذف این لینک",
@ -220,9 +222,7 @@
"Download": "دانلود کن",
"Download canceled": "دانلود لغو شد",
"Download Database": "دانلود پایگاه داده",
"Drop a chat export file here to import it.": "",
"Drop any files here to add to the conversation": "هر فایلی را اینجا رها کنید تا به مکالمه اضافه شود",
"Drop Chat Export": "",
"e.g. '30s','10m'. Valid time units are 's', 'm', 'h'.": "به طور مثال '30s','10m'. واحد\u200cهای زمانی معتبر 's', 'm', 'h' هستند.",
"Edit": "ویرایش",
"Edit Memory": "",
@ -312,6 +312,10 @@
"Fingerprint spoofing detected: Unable to use initials as avatar. Defaulting to default profile image.": "فانگ سرفیس شناسایی شد: نمی توان از نمایه شما به عنوان آواتار استفاده کرد. پیش فرض به عکس پروفایل پیش فرض برگشت داده شد.",
"Fluidly stream large external response chunks": "تکه های پاسخ خارجی بزرگ را به صورت سیال پخش کنید",
"Focus chat input": "فوکوس کردن ورودی گپ",
"Folder deleted successfully": "",
"Folder name cannot be empty": "",
"Folder name cannot be empty.": "",
"Folder name updated successfully": "",
"Followed instructions perfectly": "دستورالعمل ها را کاملا دنبال کرد",
"Form": "",
"Format your variables using square brackets like this:": "متغیرهای خود را با استفاده از براکت مربع به شکل زیر قالب بندی کنید:",
@ -440,14 +444,17 @@
"Model(s) Whitelisted": "مدل در لیست سفید ثبت شد",
"Modelfile Content": "محتویات فایل مدل",
"Models": "مدل\u200cها",
"more": "",
"More": "بیشتر",
"Move to Top": "",
"Name": "نام",
"Name your model": "نام مدل خود را",
"New Chat": "گپ جدید",
"New folder": "",
"New Password": "رمز عبور جدید",
"No content found": "",
"No content to speak": "",
"No distance available": "",
"No file selected": "",
"No files found.": "",
"No HTML, CSS, or JavaScript content found.": "",
@ -532,9 +539,11 @@
"Record voice": "ضبط صدا",
"Redirecting you to OpenWebUI Community": "در حال هدایت به OpenWebUI Community",
"Refer to yourself as \"User\" (e.g., \"User is learning Spanish\")": "",
"References from": "",
"Refused when it shouldn't have": "رد شده زمانی که باید نباشد",
"Regenerate": "ری\u200cسازی",
"Release Notes": "یادداشت\u200cهای انتشار",
"Relevance": "",
"Remove": "حذف",
"Remove Model": "حذف مدل",
"Rename": "تغییر نام",
@ -600,6 +609,7 @@
"Select model": "انتخاب یک مدل",
"Select only one model to call": "",
"Selected model(s) do not support image inputs": "مدل) های (انتخاب شده ورودیهای تصویر را پشتیبانی نمیکند",
"Semantic distance to query": "",
"Send": "ارسال",
"Send a Message": "ارسال یک پیام",
"Send message": "ارسال پیام",
@ -680,6 +690,7 @@
"This is an experimental feature, it may not function as expected and is subject to change at any time.": "",
"This option will delete all existing files in the collection and replace them with newly uploaded files.": "",
"This will delete": "",
"This will delete <strong>{{NAME}}</strong> and <strong>all its contents</strong>.": "",
"This will reset the knowledge base and sync all files. Do you wish to continue?": "",
"Thorough explanation": "توضیح کامل",
"Tika": "",

View file

@ -42,6 +42,7 @@
"Admins have access to all tools at all times; users need tools assigned per model in the workspace.": "",
"Advanced Parameters": "Edistyneet parametrit",
"Advanced Params": "Edistyneet parametrit",
"All chats": "",
"All Documents": "Kaikki asiakirjat",
"All Users": "Kaikki käyttäjät",
"Allow Chat Deletion": "Salli keskustelujen poisto",
@ -185,6 +186,7 @@
"Delete chat": "Poista keskustelu",
"Delete Chat": "Poista keskustelu",
"Delete chat?": "",
"Delete folder?": "",
"Delete function?": "",
"Delete prompt?": "",
"delete this link": "poista tämä linkki",
@ -220,9 +222,7 @@
"Download": "Lataa",
"Download canceled": "Lataus peruutettu",
"Download Database": "Lataa tietokanta",
"Drop a chat export file here to import it.": "",
"Drop any files here to add to the conversation": "Pudota tiedostoja tähän lisätäksesi ne keskusteluun",
"Drop Chat Export": "",
"e.g. '30s','10m'. Valid time units are 's', 'm', 'h'.": "esim. '30s', '10m'. Kelpoiset aikayksiköt ovat 's', 'm', 'h'.",
"Edit": "Muokkaa",
"Edit Memory": "",
@ -312,6 +312,10 @@
"Fingerprint spoofing detected: Unable to use initials as avatar. Defaulting to default profile image.": "Sormenjäljen väärentäminen havaittu: Ei voi käyttää alkukirjaimia avatarina. Käytetään oletusprofiilikuvaa.",
"Fluidly stream large external response chunks": "Virtaa suuria ulkoisia vastausosia joustavasti",
"Focus chat input": "Fokusoi syöttökenttään",
"Folder deleted successfully": "",
"Folder name cannot be empty": "",
"Folder name cannot be empty.": "",
"Folder name updated successfully": "",
"Followed instructions perfectly": "Noudatti ohjeita täydellisesti",
"Form": "",
"Format your variables using square brackets like this:": "Muotoile muuttujat hakasulkeilla näin:",
@ -440,14 +444,17 @@
"Model(s) Whitelisted": "Malli(t) sallittu",
"Modelfile Content": "Mallitiedoston sisältö",
"Models": "Mallit",
"more": "",
"More": "Lisää",
"Move to Top": "",
"Name": "Nimi",
"Name your model": "Mallin nimeäminen",
"New Chat": "Uusi keskustelu",
"New folder": "",
"New Password": "Uusi salasana",
"No content found": "",
"No content to speak": "",
"No distance available": "",
"No file selected": "",
"No files found.": "",
"No HTML, CSS, or JavaScript content found.": "",
@ -532,9 +539,11 @@
"Record voice": "Nauhoita ääni",
"Redirecting you to OpenWebUI Community": "Ohjataan sinut OpenWebUI-yhteisöön",
"Refer to yourself as \"User\" (e.g., \"User is learning Spanish\")": "",
"References from": "",
"Refused when it shouldn't have": "Kieltäytyi, vaikka ei olisi pitänyt",
"Regenerate": "Uudelleenluo",
"Release Notes": "Julkaisutiedot",
"Relevance": "",
"Remove": "Poista",
"Remove Model": "Poista malli",
"Rename": "Nimeä uudelleen",
@ -600,6 +609,7 @@
"Select model": "Valitse malli",
"Select only one model to call": "",
"Selected model(s) do not support image inputs": "Valitut mallit eivät tue kuvasyötteitä",
"Semantic distance to query": "",
"Send": "Lähetä",
"Send a Message": "Lähetä viesti",
"Send message": "Lähetä viesti",
@ -680,6 +690,7 @@
"This is an experimental feature, it may not function as expected and is subject to change at any time.": "",
"This option will delete all existing files in the collection and replace them with newly uploaded files.": "",
"This will delete": "",
"This will delete <strong>{{NAME}}</strong> and <strong>all its contents</strong>.": "",
"This will reset the knowledge base and sync all files. Do you wish to continue?": "",
"Thorough explanation": "Perusteellinen selitys",
"Tika": "",

View file

@ -42,6 +42,7 @@
"Admins have access to all tools at all times; users need tools assigned per model in the workspace.": "Les administrateurs ont accès à tous les outils en tout temps ; les utilisateurs ont besoin d'outils affectés par modèle dans l'espace de travail.",
"Advanced Parameters": "Paramètres avancés",
"Advanced Params": "Paramètres avancés",
"All chats": "",
"All Documents": "Tous les documents",
"All Users": "Tous les Utilisateurs",
"Allow Chat Deletion": "Autoriser la suppression de l'historique de chat",
@ -185,6 +186,7 @@
"Delete chat": "Supprimer la conversation",
"Delete Chat": "Supprimer la Conversation",
"Delete chat?": "Supprimer la conversation ?",
"Delete folder?": "",
"Delete function?": "Supprimer la fonction ?",
"Delete prompt?": "Supprimer la prompt ?",
"delete this link": "supprimer ce lien",
@ -220,9 +222,7 @@
"Download": "Télécharger",
"Download canceled": "Téléchargement annulé",
"Download Database": "Télécharger la base de données",
"Drop a chat export file here to import it.": "",
"Drop any files here to add to the conversation": "Déposez des fichiers ici pour les ajouter à la conversation",
"Drop Chat Export": "",
"e.g. '30s','10m'. Valid time units are 's', 'm', 'h'.": "par ex. '30s', '10 min'. Les unités de temps valides sont 's', 'm', 'h'.",
"Edit": "Modifier",
"Edit Memory": "Modifier la mémoire",
@ -312,6 +312,10 @@
"Fingerprint spoofing detected: Unable to use initials as avatar. Defaulting to default profile image.": "Spoofing détecté : impossible d'utiliser les initiales comme avatar. Retour à l'image de profil par défaut.",
"Fluidly stream large external response chunks": "Diffuser de manière fluide de larges portions de réponses externes",
"Focus chat input": "Se concentrer sur le chat en entrée",
"Folder deleted successfully": "",
"Folder name cannot be empty": "",
"Folder name cannot be empty.": "",
"Folder name updated successfully": "",
"Followed instructions perfectly": "A parfaitement suivi les instructions",
"Form": "Formulaire",
"Format your variables using square brackets like this:": "Formatez vos variables en utilisant des crochets comme suit :",
@ -440,14 +444,17 @@
"Model(s) Whitelisted": "Modèle(s) Autorisé(s)",
"Modelfile Content": "Contenu du Fichier de Modèle",
"Models": "Modèles",
"more": "",
"More": "Plus de",
"Move to Top": "",
"Name": "Nom",
"Name your model": "Nommez votre modèle",
"New Chat": "Nouvelle conversation",
"New folder": "",
"New Password": "Nouveau mot de passe",
"No content found": "",
"No content to speak": "Rien à signaler",
"No distance available": "",
"No file selected": "Aucun fichier sélectionné",
"No files found.": "",
"No HTML, CSS, or JavaScript content found.": "",
@ -532,9 +539,11 @@
"Record voice": "Enregistrer la voix",
"Redirecting you to OpenWebUI Community": "Redirection vers la communauté OpenWebUI",
"Refer to yourself as \"User\" (e.g., \"User is learning Spanish\")": "Désignez-vous comme « Utilisateur » (par ex. « L'utilisateur apprend l'espagnol »)",
"References from": "",
"Refused when it shouldn't have": "Refusé alors qu'il n'aurait pas dû l'être",
"Regenerate": "Regénérer",
"Release Notes": "Notes de publication",
"Relevance": "",
"Remove": "Retirer",
"Remove Model": "Retirer le modèle",
"Rename": "Renommer",
@ -601,6 +610,7 @@
"Select model": "Sélectionnez un modèle",
"Select only one model to call": "Sélectionnez seulement un modèle pour appeler",
"Selected model(s) do not support image inputs": "Les modèle(s) sélectionné(s) ne prennent pas en charge les entrées d'images",
"Semantic distance to query": "",
"Send": "Envoyer",
"Send a Message": "Envoyer un message",
"Send message": "Envoyer un message",
@ -681,6 +691,7 @@
"This is an experimental feature, it may not function as expected and is subject to change at any time.": "Il s'agit d'une fonctionnalité expérimentale, elle peut ne pas fonctionner comme prévu et est sujette à modification à tout moment.",
"This option will delete all existing files in the collection and replace them with newly uploaded files.": "",
"This will delete": "Cela supprimera",
"This will delete <strong>{{NAME}}</strong> and <strong>all its contents</strong>.": "",
"This will reset the knowledge base and sync all files. Do you wish to continue?": "",
"Thorough explanation": "Explication approfondie",
"Tika": "Tika",

View file

@ -42,6 +42,7 @@
"Admins have access to all tools at all times; users need tools assigned per model in the workspace.": "Les administrateurs ont accès à tous les outils en tout temps ; il faut attribuer des outils aux utilisateurs par modèle dans l'espace de travail.",
"Advanced Parameters": "Paramètres avancés",
"Advanced Params": "Paramètres avancés",
"All chats": "",
"All Documents": "Tous les documents",
"All Users": "Tous les Utilisateurs",
"Allow Chat Deletion": "Autoriser la suppression de l'historique de chat",
@ -185,6 +186,7 @@
"Delete chat": "Supprimer la conversation",
"Delete Chat": "Supprimer la Conversation",
"Delete chat?": "Supprimer la conversation ?",
"Delete folder?": "",
"Delete function?": "Supprimer la fonction ?",
"Delete prompt?": "Supprimer la prompt ?",
"delete this link": "supprimer ce lien",
@ -220,9 +222,7 @@
"Download": "Télécharger",
"Download canceled": "Téléchargement annulé",
"Download Database": "Télécharger la base de données",
"Drop a chat export file here to import it.": "",
"Drop any files here to add to the conversation": "Déposez des fichiers ici pour les ajouter à la conversation",
"Drop Chat Export": "",
"e.g. '30s','10m'. Valid time units are 's', 'm', 'h'.": "par ex. '30s', '10 min'. Les unités de temps valides sont 's', 'm', 'h'.",
"Edit": "Modifier",
"Edit Memory": "Modifier la mémoire",
@ -312,6 +312,10 @@
"Fingerprint spoofing detected: Unable to use initials as avatar. Defaulting to default profile image.": "Spoofing détecté : impossible d'utiliser les initiales comme avatar. Retour à l'image de profil par défaut.",
"Fluidly stream large external response chunks": "Streaming fluide de gros morceaux de réponses externes",
"Focus chat input": "Se concentrer sur le chat en entrée",
"Folder deleted successfully": "",
"Folder name cannot be empty": "",
"Folder name cannot be empty.": "",
"Folder name updated successfully": "",
"Followed instructions perfectly": "A parfaitement suivi les instructions",
"Form": "Formulaire",
"Format your variables using square brackets like this:": "Formatez vos variables en utilisant des crochets comme suit :",
@ -440,14 +444,17 @@
"Model(s) Whitelisted": "Modèle(s) Autorisé(s)",
"Modelfile Content": "Contenu du Fichier de Modèle",
"Models": "Modèles",
"more": "",
"More": "Plus de",
"Move to Top": "Déplacer en haut",
"Name": "Nom d'utilisateur",
"Name your model": "Nommez votre modèle",
"New Chat": "Nouvelle conversation",
"New folder": "",
"New Password": "Nouveau mot de passe",
"No content found": "",
"No content to speak": "Rien à signaler",
"No distance available": "",
"No file selected": "Aucun fichier sélectionné",
"No files found.": "",
"No HTML, CSS, or JavaScript content found.": "Aucun contenu HTML, CSS ou JavaScript trouvé.",
@ -532,9 +539,11 @@
"Record voice": "Enregistrer la voix",
"Redirecting you to OpenWebUI Community": "Redirection vers la communauté OpenWebUI",
"Refer to yourself as \"User\" (e.g., \"User is learning Spanish\")": "Désignez-vous comme « Utilisateur » (par ex. « L'utilisateur apprend l'espagnol »)",
"References from": "",
"Refused when it shouldn't have": "Refusé alors qu'il n'aurait pas dû l'être",
"Regenerate": "Regénérer",
"Release Notes": "Notes de publication",
"Relevance": "",
"Remove": "Retirer",
"Remove Model": "Retirer le modèle",
"Rename": "Renommer",
@ -601,6 +610,7 @@
"Select model": "Sélectionnez un modèle",
"Select only one model to call": "Sélectionnez seulement un modèle pour appeler",
"Selected model(s) do not support image inputs": "Les modèle(s) sélectionné(s) ne prennent pas en charge les entrées d'images",
"Semantic distance to query": "",
"Send": "Envoyer",
"Send a Message": "Envoyer un message",
"Send message": "Envoyer un message",
@ -681,6 +691,7 @@
"This is an experimental feature, it may not function as expected and is subject to change at any time.": "Il s'agit d'une fonctionnalité expérimentale, elle peut ne pas fonctionner comme prévu et est sujette à modification à tout moment.",
"This option will delete all existing files in the collection and replace them with newly uploaded files.": "Cette option supprimera tous les fichiers existants dans la collection et les remplacera par les fichiers nouvellement téléchargés.",
"This will delete": "Cela supprimera",
"This will delete <strong>{{NAME}}</strong> and <strong>all its contents</strong>.": "",
"This will reset the knowledge base and sync all files. Do you wish to continue?": "Cela réinitialisera la base de connaissances et synchronisera tous les fichiers. Souhaitez-vous continuer ?",
"Thorough explanation": "Explication approfondie",
"Tika": "Tika",

View file

@ -42,6 +42,7 @@
"Admins have access to all tools at all times; users need tools assigned per model in the workspace.": "",
"Advanced Parameters": "פרמטרים מתקדמים",
"Advanced Params": "פרמטרים מתקדמים",
"All chats": "",
"All Documents": "כל המסמכים",
"All Users": "כל המשתמשים",
"Allow Chat Deletion": "אפשר מחיקת צ'אט",
@ -185,6 +186,7 @@
"Delete chat": "מחק צ'אט",
"Delete Chat": "מחק צ'אט",
"Delete chat?": "",
"Delete folder?": "",
"Delete function?": "",
"Delete prompt?": "",
"delete this link": "מחק את הקישור הזה",
@ -220,9 +222,7 @@
"Download": "הורד",
"Download canceled": "ההורדה בוטלה",
"Download Database": "הורד מסד נתונים",
"Drop a chat export file here to import it.": "",
"Drop any files here to add to the conversation": "גרור כל קובץ לכאן כדי להוסיף לשיחה",
"Drop Chat Export": "",
"e.g. '30s','10m'. Valid time units are 's', 'm', 'h'.": "למשל '30s', '10m'. יחידות זמן חוקיות הן 's', 'm', 'h'.",
"Edit": "ערוך",
"Edit Memory": "",
@ -312,6 +312,10 @@
"Fingerprint spoofing detected: Unable to use initials as avatar. Defaulting to default profile image.": "התגלתה הזיית טביעת אצבע: לא ניתן להשתמש בראשי תיבות כאווטאר. משתמש בתמונת פרופיל ברירת מחדל.",
"Fluidly stream large external response chunks": "שידור נתונים חיצוניים בקצב רציף",
"Focus chat input": "מיקוד הקלט לצ'אט",
"Folder deleted successfully": "",
"Folder name cannot be empty": "",
"Folder name cannot be empty.": "",
"Folder name updated successfully": "",
"Followed instructions perfectly": "עקב אחר ההוראות במושלמות",
"Form": "",
"Format your variables using square brackets like this:": "עצב את המשתנים שלך באמצעות סוגריים מרובעים כך:",
@ -440,14 +444,17 @@
"Model(s) Whitelisted": "מודלים שנכללו ברשימה הלבנה",
"Modelfile Content": "תוכן קובץ מודל",
"Models": "מודלים",
"more": "",
"More": "עוד",
"Move to Top": "",
"Name": "שם",
"Name your model": "תן שם לדגם שלך",
"New Chat": "צ'אט חדש",
"New folder": "",
"New Password": "סיסמה חדשה",
"No content found": "",
"No content to speak": "",
"No distance available": "",
"No file selected": "",
"No files found.": "",
"No HTML, CSS, or JavaScript content found.": "",
@ -532,9 +539,11 @@
"Record voice": "הקלט קול",
"Redirecting you to OpenWebUI Community": "מפנה אותך לקהילת OpenWebUI",
"Refer to yourself as \"User\" (e.g., \"User is learning Spanish\")": "",
"References from": "",
"Refused when it shouldn't have": "נדחה כאשר לא היה צריך",
"Regenerate": "הפק מחדש",
"Release Notes": "הערות שחרור",
"Relevance": "",
"Remove": "הסר",
"Remove Model": "הסר מודל",
"Rename": "שנה שם",
@ -601,6 +610,7 @@
"Select model": "בחר מודל",
"Select only one model to call": "",
"Selected model(s) do not support image inputs": "דגמים נבחרים אינם תומכים בקלט תמונה",
"Semantic distance to query": "",
"Send": "שלח",
"Send a Message": "שלח הודעה",
"Send message": "שלח הודעה",
@ -681,6 +691,7 @@
"This is an experimental feature, it may not function as expected and is subject to change at any time.": "",
"This option will delete all existing files in the collection and replace them with newly uploaded files.": "",
"This will delete": "",
"This will delete <strong>{{NAME}}</strong> and <strong>all its contents</strong>.": "",
"This will reset the knowledge base and sync all files. Do you wish to continue?": "",
"Thorough explanation": "תיאור מפורט",
"Tika": "",

View file

@ -42,6 +42,7 @@
"Admins have access to all tools at all times; users need tools assigned per model in the workspace.": "",
"Advanced Parameters": "उन्नत पैरामीटर",
"Advanced Params": "उन्नत परम",
"All chats": "",
"All Documents": "सभी डॉक्यूमेंट्स",
"All Users": "सभी उपयोगकर्ता",
"Allow Chat Deletion": "चैट हटाने की अनुमति दें",
@ -185,6 +186,7 @@
"Delete chat": "चैट हटाएं",
"Delete Chat": "चैट हटाएं",
"Delete chat?": "",
"Delete folder?": "",
"Delete function?": "",
"Delete prompt?": "",
"delete this link": "इस लिंक को हटाएं",
@ -220,9 +222,7 @@
"Download": "डाउनलोड",
"Download canceled": "डाउनलोड रद्द किया गया",
"Download Database": "डेटाबेस डाउनलोड करें",
"Drop a chat export file here to import it.": "",
"Drop any files here to add to the conversation": "बातचीत में जोड़ने के लिए कोई भी फ़ाइल यहां छोड़ें",
"Drop Chat Export": "",
"e.g. '30s','10m'. Valid time units are 's', 'm', 'h'.": "जैसे '30s', '10m', मान्य समय इकाइयाँ 's', 'm', 'h' हैं।",
"Edit": "संपादित करें",
"Edit Memory": "",
@ -312,6 +312,10 @@
"Fingerprint spoofing detected: Unable to use initials as avatar. Defaulting to default profile image.": "फ़िंगरप्रिंट स्पूफ़िंग का पता चला: प्रारंभिक अक्षरों को अवतार के रूप में उपयोग करने में असमर्थ। प्रोफ़ाइल छवि को डिफ़ॉल्ट पर डिफ़ॉल्ट किया जा रहा है.",
"Fluidly stream large external response chunks": "बड़े बाह्य प्रतिक्रिया खंडों को तरल रूप से प्रवाहित करें",
"Focus chat input": "चैट इनपुट पर फ़ोकस करें",
"Folder deleted successfully": "",
"Folder name cannot be empty": "",
"Folder name cannot be empty.": "",
"Folder name updated successfully": "",
"Followed instructions perfectly": "निर्देशों का पूर्णतः पालन किया",
"Form": "",
"Format your variables using square brackets like this:": "वर्गाकार कोष्ठकों का उपयोग करके अपने चरों को इस प्रकार प्रारूपित करें :",
@ -440,14 +444,17 @@
"Model(s) Whitelisted": "मॉडल श्वेतसूची में है",
"Modelfile Content": "मॉडल फ़ाइल सामग्री",
"Models": "सभी मॉडल",
"more": "",
"More": "और..",
"Move to Top": "",
"Name": "नाम",
"Name your model": "अपने मॉडल को नाम दें",
"New Chat": "नई चैट",
"New folder": "",
"New Password": "नया पासवर्ड",
"No content found": "",
"No content to speak": "",
"No distance available": "",
"No file selected": "",
"No files found.": "",
"No HTML, CSS, or JavaScript content found.": "",
@ -532,9 +539,11 @@
"Record voice": "आवाज रिकॉर्ड करना",
"Redirecting you to OpenWebUI Community": "आपको OpenWebUI समुदाय पर पुनर्निर्देशित किया जा रहा है",
"Refer to yourself as \"User\" (e.g., \"User is learning Spanish\")": "",
"References from": "",
"Refused when it shouldn't have": "जब ऐसा नहीं होना चाहिए था तो मना कर दिया",
"Regenerate": "पुनः जेनरेट",
"Release Notes": "रिलीज नोट्स",
"Relevance": "",
"Remove": "हटा दें",
"Remove Model": "मोडेल हटाएँ",
"Rename": "नाम बदलें",
@ -600,6 +609,7 @@
"Select model": "मॉडल चुनें",
"Select only one model to call": "",
"Selected model(s) do not support image inputs": "चयनित मॉडल छवि इनपुट का समर्थन नहीं करते हैं",
"Semantic distance to query": "",
"Send": "भेज",
"Send a Message": "एक संदेश भेजो",
"Send message": "मेसेज भेजें",
@ -680,6 +690,7 @@
"This is an experimental feature, it may not function as expected and is subject to change at any time.": "",
"This option will delete all existing files in the collection and replace them with newly uploaded files.": "",
"This will delete": "",
"This will delete <strong>{{NAME}}</strong> and <strong>all its contents</strong>.": "",
"This will reset the knowledge base and sync all files. Do you wish to continue?": "",
"Thorough explanation": "विस्तृत व्याख्या",
"Tika": "",

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