mirror of
https://github.com/open-webui/open-webui
synced 2025-03-23 22:31:38 +00:00
feat: folder ui
This commit is contained in:
parent
ede71740d2
commit
a942c30ca8
@ -9,6 +9,7 @@ from open_webui.apps.webui.models.models import Models
|
|||||||
from open_webui.apps.webui.routers import (
|
from open_webui.apps.webui.routers import (
|
||||||
auths,
|
auths,
|
||||||
chats,
|
chats,
|
||||||
|
folders,
|
||||||
configs,
|
configs,
|
||||||
files,
|
files,
|
||||||
functions,
|
functions,
|
||||||
@ -110,6 +111,7 @@ app.include_router(configs.router, prefix="/configs", tags=["configs"])
|
|||||||
app.include_router(auths.router, prefix="/auths", tags=["auths"])
|
app.include_router(auths.router, prefix="/auths", tags=["auths"])
|
||||||
app.include_router(users.router, prefix="/users", tags=["users"])
|
app.include_router(users.router, prefix="/users", tags=["users"])
|
||||||
app.include_router(chats.router, prefix="/chats", tags=["chats"])
|
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(models.router, prefix="/models", tags=["models"])
|
||||||
app.include_router(knowledge.router, prefix="/knowledge", tags=["knowledge"])
|
app.include_router(knowledge.router, prefix="/knowledge", tags=["knowledge"])
|
||||||
|
@ -33,6 +33,7 @@ class Chat(Base):
|
|||||||
pinned = Column(Boolean, default=False, nullable=True)
|
pinned = Column(Boolean, default=False, nullable=True)
|
||||||
|
|
||||||
meta = Column(JSON, server_default="{}")
|
meta = Column(JSON, server_default="{}")
|
||||||
|
folder_id = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
class ChatModel(BaseModel):
|
class ChatModel(BaseModel):
|
||||||
@ -51,6 +52,7 @@ class ChatModel(BaseModel):
|
|||||||
pinned: Optional[bool] = False
|
pinned: Optional[bool] = False
|
||||||
|
|
||||||
meta: dict = {}
|
meta: dict = {}
|
||||||
|
folder_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
####################
|
####################
|
||||||
@ -512,6 +514,29 @@ class ChatTable:
|
|||||||
# Validate and return chats
|
# Validate and return chats
|
||||||
return [ChatModel.model_validate(chat) for chat in all_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:
|
||||||
|
all_chats = (
|
||||||
|
db.query(Chat).filter_by(folder_id=folder_id, user_id=user_id).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())
|
||||||
|
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]:
|
def get_chat_tags_by_id_and_user_id(self, id: str, user_id: str) -> list[TagModel]:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
chat = db.get(Chat, id)
|
chat = db.get(Chat, id)
|
||||||
|
@ -22,7 +22,6 @@ log.setLevel(SRC_LOG_LEVELS["MODELS"])
|
|||||||
class FolderItems(BaseModel):
|
class FolderItems(BaseModel):
|
||||||
chat_ids: Optional[list[str]] = None
|
chat_ids: Optional[list[str]] = None
|
||||||
file_ids: Optional[list[str]] = None
|
file_ids: Optional[list[str]] = None
|
||||||
folder_ids: Optional[list[str]] = None
|
|
||||||
|
|
||||||
model_config = ConfigDict(extra="allow")
|
model_config = ConfigDict(extra="allow")
|
||||||
|
|
||||||
@ -52,6 +51,21 @@ class FolderModel(BaseModel):
|
|||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
####################
|
||||||
|
# Forms
|
||||||
|
####################
|
||||||
|
|
||||||
|
|
||||||
|
class FolderForm(BaseModel):
|
||||||
|
name: str
|
||||||
|
model_config = ConfigDict(extra="allow")
|
||||||
|
|
||||||
|
|
||||||
|
class FolderItemsUpdateForm(BaseModel):
|
||||||
|
items: FolderItems
|
||||||
|
model_config = ConfigDict(extra="allow")
|
||||||
|
|
||||||
|
|
||||||
class FolderTable:
|
class FolderTable:
|
||||||
def insert_new_folder(self, name: str, user_id: str) -> Optional[FolderModel]:
|
def insert_new_folder(self, name: str, user_id: str) -> Optional[FolderModel]:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
@ -96,7 +110,59 @@ class FolderTable:
|
|||||||
for folder in db.query(Folder).filter_by(user_id=user_id).all()
|
for folder in db.query(Folder).filter_by(user_id=user_id).all()
|
||||||
]
|
]
|
||||||
|
|
||||||
def update_folder_by_name_and_user_id(
|
def get_folders_by_parent_id_and_user_id(self, parent_id: str, user_id: str):
|
||||||
|
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()
|
||||||
|
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_name_and_user_id(
|
||||||
|
self, name: str, user_id: str, new_name: str
|
||||||
|
) -> Optional[FolderModel]:
|
||||||
|
try:
|
||||||
|
id = name.lower()
|
||||||
|
new_id = new_name.lower()
|
||||||
|
with get_db() as db:
|
||||||
|
# Check if new folder name already exists
|
||||||
|
folder = db.query(Folder).filter_by(id=new_id, user_id=user_id).first()
|
||||||
|
if folder:
|
||||||
|
return None
|
||||||
|
|
||||||
|
folder = db.query(Folder).filter_by(id=id, user_id=user_id).first()
|
||||||
|
folder.id = new_id
|
||||||
|
folder.name = new_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_items_by_name_and_user_id(
|
||||||
self, name: str, user_id: str, items: FolderItems
|
self, name: str, user_id: str, items: FolderItems
|
||||||
) -> Optional[FolderModel]:
|
) -> Optional[FolderModel]:
|
||||||
try:
|
try:
|
||||||
|
197
backend/open_webui/apps/webui/routers/folders.py
Normal file
197
backend/open_webui/apps/webui/routers/folders.py
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
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,
|
||||||
|
FolderItemsUpdateForm,
|
||||||
|
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 folders
|
||||||
|
|
||||||
|
|
||||||
|
############################
|
||||||
|
# Create Folder
|
||||||
|
############################
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/")
|
||||||
|
def create_folder(form_data: FolderForm, user=Depends(get_verified_user)):
|
||||||
|
folder = Folders.get_folder_by_name_and_user_id(form_data.name, user.id)
|
||||||
|
if folder:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=ERROR_MESSAGES.DEFAULT("Folder already exists"),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
folder = Folders.insert_new_folder(form_data.name, user.id)
|
||||||
|
return folder
|
||||||
|
except Exception as e:
|
||||||
|
log.exception(e)
|
||||||
|
log.error(f"Error creating folder: {form_data.name}")
|
||||||
|
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_name_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)
|
||||||
|
):
|
||||||
|
new_id = form_data.name.lower()
|
||||||
|
folder = Folders.get_folder_by_name_and_user_id(new_id, user.id)
|
||||||
|
if folder:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=ERROR_MESSAGES.DEFAULT("Folder already exists"),
|
||||||
|
)
|
||||||
|
|
||||||
|
folder = Folders.get_folder_by_name_and_user_id(id, user.id)
|
||||||
|
if folder:
|
||||||
|
try:
|
||||||
|
folder = Folders.update_folder_name_by_name_and_user_id(
|
||||||
|
id, user.id, form_data.name
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update children folders parent_id
|
||||||
|
children_folders = Folders.get_folders_by_parent_id_and_user_id(id, user.id)
|
||||||
|
for child in children_folders:
|
||||||
|
Folders.update_folder_parent_id_by_id_and_user_id(
|
||||||
|
child.id, user.id, folder.id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update children items parent_id
|
||||||
|
chats = Chats.get_chats_by_folder_id_and_user_id(id, user.id)
|
||||||
|
for chat in chats:
|
||||||
|
Chats.update_chat_folder_id_by_id_and_user_id(
|
||||||
|
chat.id, user.id, folder.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 Items By Id
|
||||||
|
############################
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{id}/update/items")
|
||||||
|
async def update_folder_items_by_id(
|
||||||
|
id: str, form_data: FolderItemsUpdateForm, user=Depends(get_verified_user)
|
||||||
|
):
|
||||||
|
folder = Folders.get_folder_by_name_and_user_id(id, user.id)
|
||||||
|
if folder:
|
||||||
|
try:
|
||||||
|
folder = Folders.update_folder_by_name_and_user_id(
|
||||||
|
id, user.id, form_data.items
|
||||||
|
)
|
||||||
|
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_name_and_user_id(id, user.id)
|
||||||
|
if folder:
|
||||||
|
try:
|
||||||
|
result = Folders.delete_folder_by_name_and_user_id(id, user.id)
|
||||||
|
return result
|
||||||
|
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,
|
||||||
|
)
|
@ -34,9 +34,16 @@ def upgrade():
|
|||||||
server_default=sa.func.now(),
|
server_default=sa.func.now(),
|
||||||
onupdate=sa.func.now(),
|
onupdate=sa.func.now(),
|
||||||
),
|
),
|
||||||
sa.PrimaryKeyConstraint("id", "user_id")
|
sa.PrimaryKeyConstraint("id", "user_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.add_column(
|
||||||
|
"chat",
|
||||||
|
sa.Column("folder_id", sa.Text(), nullable=True),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def downgrade():
|
def downgrade():
|
||||||
|
op.drop_column("chat", "folder_id")
|
||||||
|
|
||||||
op.drop_table("folder")
|
op.drop_table("folder")
|
||||||
|
198
src/lib/apis/folders/index.ts
Normal file
198
src/lib/apis/folders/index.ts
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FolderItems = {
|
||||||
|
chat_ids: string[];
|
||||||
|
file_ids: string[];
|
||||||
|
folder_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;
|
||||||
|
};
|
@ -24,7 +24,7 @@
|
|||||||
bind:this={popupElement}
|
bind:this={popupElement}
|
||||||
class="fixed top-0 left-0 w-screen h-[100dvh] z-50 touch-none pointer-events-none"
|
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>
|
<slot></slot>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -14,13 +14,15 @@
|
|||||||
export let name = '';
|
export let name = '';
|
||||||
export let collapsible = true;
|
export let collapsible = true;
|
||||||
|
|
||||||
|
export let className = '';
|
||||||
|
|
||||||
let folderElement;
|
let folderElement;
|
||||||
|
|
||||||
let dragged = false;
|
let draggedOver = false;
|
||||||
|
|
||||||
const onDragOver = (e) => {
|
const onDragOver = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dragged = true;
|
draggedOver = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const onDrop = (e) => {
|
const onDrop = (e) => {
|
||||||
@ -29,19 +31,23 @@
|
|||||||
if (folderElement.contains(e.target)) {
|
if (folderElement.contains(e.target)) {
|
||||||
console.log('Dropped on the Button');
|
console.log('Dropped on the Button');
|
||||||
|
|
||||||
// get data from the drag event
|
try {
|
||||||
const dataTransfer = e.dataTransfer.getData('text/plain');
|
// get data from the drag event
|
||||||
const data = JSON.parse(dataTransfer);
|
const dataTransfer = e.dataTransfer.getData('text/plain');
|
||||||
console.log(data);
|
const data = JSON.parse(dataTransfer);
|
||||||
dispatch('drop', data);
|
console.log(data);
|
||||||
|
dispatch('drop', data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
dragged = false;
|
draggedOver = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onDragLeave = (e) => {
|
const onDragLeave = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dragged = false;
|
draggedOver = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@ -57,10 +63,10 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div bind:this={folderElement} class="relative">
|
<div bind:this={folderElement} class="relative {className}">
|
||||||
{#if dragged}
|
{#if draggedOver}
|
||||||
<div
|
<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(258,88%,66%,0.1)] bg-opacity-50 dark:bg-opacity-10 z-50 pointer-events-none touch-none"
|
||||||
></div>
|
></div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@ -74,7 +80,7 @@
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||||
<div class="mx-2 w-full">
|
<div class="w-full">
|
||||||
<button
|
<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"
|
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 +98,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div slot="content" class=" pl-2">
|
<div slot="content" class="w-full">
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</div>
|
</div>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
|
19
src/lib/components/icons/Document.svelte
Normal file
19
src/lib/components/icons/Document.svelte
Normal 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>
|
@ -43,6 +43,8 @@
|
|||||||
import Folder from '../common/Folder.svelte';
|
import Folder from '../common/Folder.svelte';
|
||||||
import Plus from '../icons/Plus.svelte';
|
import Plus from '../icons/Plus.svelte';
|
||||||
import Tooltip from '../common/Tooltip.svelte';
|
import Tooltip from '../common/Tooltip.svelte';
|
||||||
|
import { createNewFolder, getFolders } from '$lib/apis/folders';
|
||||||
|
import Folders from './Sidebar/Folders.svelte';
|
||||||
|
|
||||||
const BREAKPOINT = 768;
|
const BREAKPOINT = 768;
|
||||||
|
|
||||||
@ -65,6 +67,55 @@
|
|||||||
let chatListLoading = false;
|
let chatListLoading = false;
|
||||||
let allChatsLoaded = false;
|
let allChatsLoaded = false;
|
||||||
|
|
||||||
|
let folders = {};
|
||||||
|
|
||||||
|
const initFolders = async () => {
|
||||||
|
const folderList = await getFolders(localStorage.token).catch((error) => {
|
||||||
|
toast.error(error);
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const folder of folderList) {
|
||||||
|
folders[folder.id] = { ...(folders[folder.id] ? folders[folder.id] : {}), ...folder };
|
||||||
|
|
||||||
|
if (folders[folder.id].parent_id) {
|
||||||
|
folders[folders[folder.id].parent_id].childrenIds = folders[folders[folder.id].parent_id]
|
||||||
|
.childrenIds
|
||||||
|
? [...folders[folders[folder.id].parent_id].childrenIds, folder.id]
|
||||||
|
: [folder.id];
|
||||||
|
|
||||||
|
folders[folders[folder.id].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;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.toLowerCase() in folders) {
|
||||||
|
// If a folder with the same name already exists, append a number to the name
|
||||||
|
let i = 1;
|
||||||
|
while (name.toLowerCase() + ` ${i}` in folders) {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
name = name + ` ${i}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await createNewFolder(localStorage.token, name).catch((error) => {
|
||||||
|
toast.error(error);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res) {
|
||||||
|
await initFolders();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const initChatList = async () => {
|
const initChatList = async () => {
|
||||||
// Reset pagination variables
|
// Reset pagination variables
|
||||||
tags.set(await getAllTags(localStorage.token));
|
tags.set(await getAllTags(localStorage.token));
|
||||||
@ -280,6 +331,7 @@
|
|||||||
localStorage.sidebar = value;
|
localStorage.sidebar = value;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await initFolders();
|
||||||
await pinnedChats.set(await getPinnedChatList(localStorage.token));
|
await pinnedChats.set(await getPinnedChatList(localStorage.token));
|
||||||
await initChatList();
|
await initChatList();
|
||||||
|
|
||||||
@ -491,7 +543,12 @@
|
|||||||
|
|
||||||
<div class="absolute z-40 right-4 top-1">
|
<div class="absolute z-40 right-4 top-1">
|
||||||
<Tooltip content={$i18n.t('New folder')}>
|
<Tooltip content={$i18n.t('New folder')}>
|
||||||
<button class="p-1 rounded-lg hover:bg-white/5 transition">
|
<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 />
|
<Plus />
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@ -514,33 +571,40 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if !search && $pinnedChats.length > 0}
|
{#if !search && $pinnedChats.length > 0}
|
||||||
<div class=" flex flex-col space-y-1">
|
<div class="flex flex-col space-y-1 rounded-xl">
|
||||||
<Folder
|
<Folder
|
||||||
|
className="px-2"
|
||||||
bind:open={showPinnedChat}
|
bind:open={showPinnedChat}
|
||||||
on:change={(e) => {
|
on:change={(e) => {
|
||||||
localStorage.setItem('showPinnedChat', e.detail);
|
localStorage.setItem('showPinnedChat', e.detail);
|
||||||
console.log(e.detail);
|
console.log(e.detail);
|
||||||
}}
|
}}
|
||||||
on:drop={async (e) => {
|
on:drop={async (e) => {
|
||||||
const { id } = e.detail;
|
const { type, id } = e.detail;
|
||||||
|
|
||||||
const status = await getChatPinnedStatusById(localStorage.token, id);
|
if (type === 'chat') {
|
||||||
|
const status = await getChatPinnedStatusById(localStorage.token, id);
|
||||||
|
|
||||||
if (!status) {
|
if (!status) {
|
||||||
const res = await toggleChatPinnedStatusById(localStorage.token, id);
|
const res = await toggleChatPinnedStatusById(localStorage.token, id);
|
||||||
|
|
||||||
if (res) {
|
if (res) {
|
||||||
await pinnedChats.set(await getPinnedChatList(localStorage.token));
|
await pinnedChats.set(await getPinnedChatList(localStorage.token));
|
||||||
initChatList();
|
initChatList();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
name={$i18n.t('Pinned')}
|
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 dark:border-gray-850"
|
||||||
|
>
|
||||||
{#each $pinnedChats as chat, idx}
|
{#each $pinnedChats as chat, idx}
|
||||||
<ChatItem
|
<ChatItem
|
||||||
{chat}
|
className=""
|
||||||
|
id={chat.id}
|
||||||
|
title={chat.title}
|
||||||
{shiftKey}
|
{shiftKey}
|
||||||
selected={selectedChatId === chat.id}
|
selected={selectedChatId === chat.id}
|
||||||
on:select={() => {
|
on:select={() => {
|
||||||
@ -557,6 +621,10 @@
|
|||||||
showDeleteConfirm = true;
|
showDeleteConfirm = true;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
on:change={async () => {
|
||||||
|
await pinnedChats.set(await getPinnedChatList(localStorage.token));
|
||||||
|
initChatList();
|
||||||
|
}}
|
||||||
on:tag={(e) => {
|
on:tag={(e) => {
|
||||||
const { type, name } = e.detail;
|
const { type, name } = e.detail;
|
||||||
tagEventHandler(type, name, chat.id);
|
tagEventHandler(type, name, chat.id);
|
||||||
@ -568,20 +636,28 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if folders}
|
||||||
|
<div class=" flex flex-col">
|
||||||
|
<Folders {folders} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="flex-1 flex flex-col space-y-1 overflow-y-auto scrollbar-hidden">
|
<div class="flex-1 flex flex-col space-y-1 overflow-y-auto scrollbar-hidden">
|
||||||
<Folder
|
<Folder
|
||||||
collapsible={false}
|
collapsible={false}
|
||||||
on:drop={async (e) => {
|
on:drop={async (e) => {
|
||||||
const { id } = e.detail;
|
const { type, id } = e.detail;
|
||||||
|
|
||||||
const status = await getChatPinnedStatusById(localStorage.token, id);
|
if (type === 'chat') {
|
||||||
|
const status = await getChatPinnedStatusById(localStorage.token, id);
|
||||||
|
|
||||||
if (status) {
|
if (status) {
|
||||||
const res = await toggleChatPinnedStatusById(localStorage.token, id);
|
const res = await toggleChatPinnedStatusById(localStorage.token, id);
|
||||||
|
|
||||||
if (res) {
|
if (res) {
|
||||||
await pinnedChats.set(await getPinnedChatList(localStorage.token));
|
await pinnedChats.set(await getPinnedChatList(localStorage.token));
|
||||||
initChatList();
|
initChatList();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@ -619,7 +695,8 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<ChatItem
|
<ChatItem
|
||||||
{chat}
|
id={chat.id}
|
||||||
|
title={chat.title}
|
||||||
{shiftKey}
|
{shiftKey}
|
||||||
selected={selectedChatId === chat.id}
|
selected={selectedChatId === chat.id}
|
||||||
on:select={() => {
|
on:select={() => {
|
||||||
@ -636,6 +713,10 @@
|
|||||||
showDeleteConfirm = true;
|
showDeleteConfirm = true;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
on:change={async () => {
|
||||||
|
await pinnedChats.set(await getPinnedChatList(localStorage.token));
|
||||||
|
initChatList();
|
||||||
|
}}
|
||||||
on:tag={(e) => {
|
on:tag={(e) => {
|
||||||
const { type, name } = e.detail;
|
const { type, name } = e.detail;
|
||||||
tagEventHandler(type, name, chat.id);
|
tagEventHandler(type, name, chat.id);
|
||||||
|
@ -33,8 +33,15 @@
|
|||||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||||
import ArchiveBox from '$lib/components/icons/ArchiveBox.svelte';
|
import ArchiveBox from '$lib/components/icons/ArchiveBox.svelte';
|
||||||
import DragGhost from '$lib/components/common/DragGhost.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 = 'pr-2';
|
||||||
|
|
||||||
|
export let id;
|
||||||
|
export let title;
|
||||||
|
|
||||||
export let chat;
|
|
||||||
export let selected = false;
|
export let selected = false;
|
||||||
export let shiftKey = false;
|
export let shiftKey = false;
|
||||||
|
|
||||||
@ -43,7 +50,7 @@
|
|||||||
let showShareChatModal = false;
|
let showShareChatModal = false;
|
||||||
let confirmEdit = false;
|
let confirmEdit = false;
|
||||||
|
|
||||||
let chatTitle = chat.title;
|
let chatTitle = title;
|
||||||
|
|
||||||
const editChatTitle = async (id, title) => {
|
const editChatTitle = async (id, title) => {
|
||||||
if (title === '') {
|
if (title === '') {
|
||||||
@ -93,7 +100,7 @@
|
|||||||
|
|
||||||
let itemElement;
|
let itemElement;
|
||||||
|
|
||||||
let drag = false;
|
let dragged = false;
|
||||||
let x = 0;
|
let x = 0;
|
||||||
let y = 0;
|
let y = 0;
|
||||||
|
|
||||||
@ -108,11 +115,12 @@
|
|||||||
event.dataTransfer.setData(
|
event.dataTransfer.setData(
|
||||||
'text/plain',
|
'text/plain',
|
||||||
JSON.stringify({
|
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
|
itemElement.style.opacity = '0.5'; // Optional: Visual cue to show it's being dragged
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -123,7 +131,7 @@
|
|||||||
|
|
||||||
const onDragEnd = (event) => {
|
const onDragEnd = (event) => {
|
||||||
itemElement.style.opacity = '1'; // Reset visual cue after drag
|
itemElement.style.opacity = '1'; // Reset visual cue after drag
|
||||||
drag = false;
|
dragged = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@ -146,24 +154,26 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ShareChatModal bind:show={showShareChatModal} chatId={chat.id} />
|
<ShareChatModal bind:show={showShareChatModal} chatId={id} />
|
||||||
|
|
||||||
{#if drag && x && y}
|
{#if dragged && x && y}
|
||||||
<DragGhost {x} {y}>
|
<DragGhost {x} {y}>
|
||||||
<div class=" bg-black/80 backdrop-blur-2xl px-2 py-1 rounded-lg w-44">
|
<div class=" bg-black/80 backdrop-blur-2xl px-2 py-1 rounded-lg w-40">
|
||||||
<div>
|
<div class="flex items-center gap-1">
|
||||||
|
<Document className="size-4" strokeWidth="2" />
|
||||||
<div class=" text-xs text-white line-clamp-1">
|
<div class=" text-xs text-white line-clamp-1">
|
||||||
{chat.title}
|
{title}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DragGhost>
|
</DragGhost>
|
||||||
{/if}
|
{/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}
|
{#if confirmEdit}
|
||||||
<div
|
<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-[7px] {id === $chatId ||
|
||||||
|
confirmEdit
|
||||||
? 'bg-gray-200 dark:bg-gray-900'
|
? 'bg-gray-200 dark:bg-gray-900'
|
||||||
: selected
|
: selected
|
||||||
? 'bg-gray-100 dark:bg-gray-950'
|
? 'bg-gray-100 dark:bg-gray-950'
|
||||||
@ -177,12 +187,13 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<a
|
<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-[7px] {id === $chatId ||
|
||||||
|
confirmEdit
|
||||||
? 'bg-gray-200 dark:bg-gray-900'
|
? 'bg-gray-200 dark:bg-gray-900'
|
||||||
: selected
|
: selected
|
||||||
? 'bg-gray-100 dark:bg-gray-950'
|
? 'bg-gray-100 dark:bg-gray-950'
|
||||||
: ' group-hover:bg-gray-100 dark:group-hover:bg-gray-950'} whitespace-nowrap text-ellipsis"
|
: ' group-hover:bg-gray-100 dark:group-hover:bg-gray-950'} whitespace-nowrap text-ellipsis"
|
||||||
href="/c/{chat.id}"
|
href="/c/{id}"
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
dispatch('select');
|
dispatch('select');
|
||||||
|
|
||||||
@ -191,7 +202,7 @@
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
on:dblclick={() => {
|
on:dblclick={() => {
|
||||||
chatTitle = chat.title;
|
chatTitle = title;
|
||||||
confirmEdit = true;
|
confirmEdit = true;
|
||||||
}}
|
}}
|
||||||
on:mouseenter={(e) => {
|
on:mouseenter={(e) => {
|
||||||
@ -205,7 +216,7 @@
|
|||||||
>
|
>
|
||||||
<div class=" flex self-center flex-1 w-full">
|
<div class=" flex self-center flex-1 w-full">
|
||||||
<div class=" text-left self-center overflow-hidden w-full h-[20px]">
|
<div class=" text-left self-center overflow-hidden w-full h-[20px]">
|
||||||
{chat.title}
|
{title}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
@ -214,12 +225,14 @@
|
|||||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||||
<div
|
<div
|
||||||
class="
|
class="
|
||||||
{chat.id === $chatId || confirmEdit
|
{id === $chatId || confirmEdit
|
||||||
? 'from-gray-200 dark:from-gray-900'
|
? 'from-gray-200 dark:from-gray-900'
|
||||||
: selected
|
: selected
|
||||||
? 'from-gray-100 dark:from-gray-950'
|
? 'from-gray-100 dark:from-gray-950'
|
||||||
: 'invisible group-hover:visible 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-[5px] py-1 pr-0.5 mr-2 pl-5 bg-gradient-to-l from-80%
|
||||||
|
|
||||||
to-transparent"
|
to-transparent"
|
||||||
on:mouseenter={(e) => {
|
on:mouseenter={(e) => {
|
||||||
@ -230,28 +243,19 @@
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{#if confirmEdit}
|
{#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')}>
|
<Tooltip content={$i18n.t('Confirm')}>
|
||||||
<button
|
<button
|
||||||
class=" self-center dark:hover:text-white transition"
|
class=" self-center dark:hover:text-white transition"
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
editChatTitle(chat.id, chatTitle);
|
editChatTitle(id, chatTitle);
|
||||||
confirmEdit = false;
|
confirmEdit = false;
|
||||||
chatTitle = '';
|
chatTitle = '';
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg
|
<Check className=" size-3.5" strokeWidth="2.5" />
|
||||||
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>
|
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
@ -263,16 +267,7 @@
|
|||||||
chatTitle = '';
|
chatTitle = '';
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg
|
<XMark strokeWidth="2.5" />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="currentColor"
|
|
||||||
class="w-4 h-4"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
@ -282,7 +277,7 @@
|
|||||||
<button
|
<button
|
||||||
class=" self-center dark:hover:text-white transition"
|
class=" self-center dark:hover:text-white transition"
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
archiveChatHandler(chat.id);
|
archiveChatHandler(id);
|
||||||
}}
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
@ -305,18 +300,18 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="flex self-center space-x-1 z-10">
|
<div class="flex self-center space-x-1 z-10">
|
||||||
<ChatMenu
|
<ChatMenu
|
||||||
chatId={chat.id}
|
chatId={id}
|
||||||
cloneChatHandler={() => {
|
cloneChatHandler={() => {
|
||||||
cloneChatHandler(chat.id);
|
cloneChatHandler(id);
|
||||||
}}
|
}}
|
||||||
shareHandler={() => {
|
shareHandler={() => {
|
||||||
showShareChatModal = true;
|
showShareChatModal = true;
|
||||||
}}
|
}}
|
||||||
archiveChatHandler={() => {
|
archiveChatHandler={() => {
|
||||||
archiveChatHandler(chat.id);
|
archiveChatHandler(id);
|
||||||
}}
|
}}
|
||||||
renameHandler={() => {
|
renameHandler={() => {
|
||||||
chatTitle = chat.title;
|
chatTitle = title;
|
||||||
|
|
||||||
confirmEdit = true;
|
confirmEdit = true;
|
||||||
}}
|
}}
|
||||||
@ -327,7 +322,7 @@
|
|||||||
dispatch('unselect');
|
dispatch('unselect');
|
||||||
}}
|
}}
|
||||||
on:change={async () => {
|
on:change={async () => {
|
||||||
await pinnedChats.set(await getPinnedChatList(localStorage.token));
|
dispatch('change');
|
||||||
}}
|
}}
|
||||||
on:tag={(e) => {
|
on:tag={(e) => {
|
||||||
dispatch('tag', e.detail);
|
dispatch('tag', e.detail);
|
||||||
@ -353,7 +348,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</ChatMenu>
|
</ChatMenu>
|
||||||
|
|
||||||
{#if chat.id === $chatId}
|
{#if id === $chatId}
|
||||||
<!-- Shortcut support using "delete-chat-button" id -->
|
<!-- Shortcut support using "delete-chat-button" id -->
|
||||||
<button
|
<button
|
||||||
id="delete-chat-button"
|
id="delete-chat-button"
|
||||||
|
14
src/lib/components/layout/Sidebar/Folders.svelte
Normal file
14
src/lib/components/layout/Sidebar/Folders.svelte
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
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));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#each folderList as folderId (folderId)}
|
||||||
|
<RecursiveFolder className="px-2" {folders} {folderId} />
|
||||||
|
{/each}
|
220
src/lib/components/layout/Sidebar/RecursiveFolder.svelte
Normal file
220
src/lib/components/layout/Sidebar/RecursiveFolder.svelte
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
<script>
|
||||||
|
import { getContext, createEventDispatcher, onMount, onDestroy } from 'svelte';
|
||||||
|
|
||||||
|
const i18n = getContext('i18n');
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
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';
|
||||||
|
|
||||||
|
export let open = true;
|
||||||
|
|
||||||
|
export let folders;
|
||||||
|
export let folderId;
|
||||||
|
|
||||||
|
export let className;
|
||||||
|
|
||||||
|
let folderElement;
|
||||||
|
|
||||||
|
let edit = false;
|
||||||
|
|
||||||
|
let draggedOver = false;
|
||||||
|
let dragged = false;
|
||||||
|
|
||||||
|
let name = '';
|
||||||
|
|
||||||
|
const onDragOver = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
draggedOver = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDrop = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (folderElement.contains(e.target)) {
|
||||||
|
console.log('Dropped on the Button');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// get data from the drag event
|
||||||
|
const dataTransfer = e.dataTransfer.getData('text/plain');
|
||||||
|
const data = JSON.parse(dataTransfer);
|
||||||
|
console.log(data);
|
||||||
|
dispatch('drop', data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
draggedOver = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDragLeave = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
draggedOver = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const dragImage = new Image();
|
||||||
|
dragImage.src =
|
||||||
|
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
|
||||||
|
|
||||||
|
let x;
|
||||||
|
let y;
|
||||||
|
|
||||||
|
const onDragStart = (event) => {
|
||||||
|
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) => {
|
||||||
|
x = event.clientX;
|
||||||
|
y = event.clientY;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDragEnd = (event) => {
|
||||||
|
folderElement.style.opacity = '1'; // Reset visual cue after drag
|
||||||
|
dragged = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if dragged && x && y}
|
||||||
|
<DragGhost {x} {y}>
|
||||||
|
<div class=" bg-black/80 backdrop-blur-2xl px-2 py-1 rounded-lg 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(258,88%,66%,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"
|
||||||
|
on:change={(e) => {
|
||||||
|
dispatch('open', e.detail);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||||
|
<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"
|
||||||
|
on:dblclick={() => {
|
||||||
|
name = folders[folderId].name;
|
||||||
|
edit = true;
|
||||||
|
|
||||||
|
// focus on the input
|
||||||
|
setTimeout(() => {
|
||||||
|
const input = document.getElementById(`folder-${folderId}-input`);
|
||||||
|
input.focus();
|
||||||
|
}, 0);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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">
|
||||||
|
{#if edit}
|
||||||
|
<input
|
||||||
|
id="folder-{folderId}-input"
|
||||||
|
type="text"
|
||||||
|
bind:value={folders[folderId].name}
|
||||||
|
on:input={(e) => {
|
||||||
|
folders[folderId].name = e.target.value;
|
||||||
|
}}
|
||||||
|
on:blur={() => {
|
||||||
|
edit = false;
|
||||||
|
}}
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div slot="content" class="w-full">
|
||||||
|
{#if folders[folderId].childrenIds || folders[folderId].items?.chat_ids}
|
||||||
|
<div
|
||||||
|
class="ml-3 pl-1 mt-[1px] flex flex-col overflow-y-auto scrollbar-hidden border-s dark:border-gray-850"
|
||||||
|
>
|
||||||
|
{#if folders[folderId]?.childrenIds}
|
||||||
|
{#each folders[folderId]?.childrenIds as folderId (folderId)}
|
||||||
|
<svelte:self {folders} {folderId} />
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if folders[folderId].items?.chat_ids}
|
||||||
|
{#each folder.items.chat_ids as chatId (chatId)}
|
||||||
|
{chatId}
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</Collapsible>
|
||||||
|
</div>
|
Loading…
Reference in New Issue
Block a user