Compare commits

...

9 Commits

Author SHA1 Message Date
pablodanswer
da86610022 nit 2025-01-06 18:36:38 -08:00
pablodanswer
0027759dbf nit 2025-01-06 17:55:02 -08:00
pablodanswer
595ef152d2 updated UX 2025-01-05 14:38:52 -08:00
pablodanswer
083d669d1b minor logic update 2025-01-05 13:22:15 -08:00
pablodanswer
3ac31136b2 base functional 2025-01-03 17:11:14 -08:00
pablodanswer
a73a438d95 k 2025-01-03 14:33:24 -08:00
pablodanswer
c0770481e8 finalize 2025-01-03 14:29:52 -08:00
pablodanswer
c27d13c07f rm danswer 2025-01-03 14:27:25 -08:00
pablodanswer
ab34c4e772 add my docs v1 2025-01-03 14:25:56 -08:00
54 changed files with 2535 additions and 1015 deletions

View File

@@ -0,0 +1,129 @@
from alembic import op
import sqlalchemy as sa
import datetime
# revision identifiers, used by Alembic.
revision = "25d86cbfce78"
down_revision = "c0aab6edb6dd"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Create user_folder table with additional 'display_priority' field
op.create_table(
"user_folder",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("user_id", sa.UUID(), sa.ForeignKey("user.id"), nullable=True),
sa.Column(
"parent_id", sa.Integer(), sa.ForeignKey("user_folder.id"), nullable=True
),
sa.Column("name", sa.String(length=255), nullable=True),
sa.Column("display_priority", sa.Integer(), nullable=True, default=0),
sa.Column("created_at", sa.DateTime(), default=datetime.datetime.utcnow),
)
# Migrate data from chat_folder to user_folder
op.execute(
"""
INSERT INTO user_folder (id, user_id, name, display_priority, created_at)
SELECT id, user_id, name, display_priority, CURRENT_TIMESTAMP FROM chat_folder
"""
)
# Update chat_session table to reference user_folder instead of chat_folder
op.drop_constraint(
"chat_session_chat_folder_fk", "chat_session", type_="foreignkey"
)
op.alter_column(
"chat_session",
"folder_id",
existing_type=sa.Integer(),
nullable=True,
existing_nullable=True,
existing_server_default=None,
)
op.create_foreign_key(
"fk_chat_session_folder_id_user_folder",
"chat_session",
"user_folder",
["folder_id"],
["id"],
ondelete="SET NULL",
)
# Drop the chat_folder table
op.drop_table("chat_folder")
# Create user_file table
op.create_table(
"user_file",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("user_id", sa.UUID(), sa.ForeignKey("user.id"), nullable=True),
sa.Column(
"parent_folder_id",
sa.Integer(),
sa.ForeignKey("user_folder.id"),
nullable=True,
),
sa.Column("file_type", sa.String(), nullable=True),
sa.Column("file_id", sa.String(length=255), nullable=False),
sa.Column("document_id", sa.String(length=255), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column(
"created_at",
sa.DateTime(),
default=datetime.datetime.utcnow,
),
)
def downgrade() -> None:
# Recreate chat_folder table
op.create_table(
"chat_folder",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"user_id",
sa.UUID(),
sa.ForeignKey("user.id", ondelete="CASCADE"),
nullable=True,
),
sa.Column("name", sa.String(length=255), nullable=True),
sa.Column("display_priority", sa.Integer(), nullable=True, default=0),
)
# Migrate data back from user_folder to chat_folder
op.execute(
"""
INSERT INTO chat_folder (id, user_id, name, display_priority)
SELECT id, user_id, name, display_priority FROM user_folder
WHERE id IN (SELECT DISTINCT folder_id FROM chat_session WHERE folder_id IS NOT NULL)
"""
)
# Update chat_session table to reference chat_folder again
op.drop_constraint(
"fk_chat_session_folder_id_user_folder", "chat_session", type_="foreignkey"
)
op.alter_column(
"chat_session",
"folder_id",
existing_type=sa.Integer(),
nullable=True,
existing_nullable=True,
existing_server_default=None,
)
op.create_foreign_key(
"chat_session_chat_folder_fk",
"chat_session",
"chat_folder",
["folder_id"],
["id"],
ondelete="SET NULL",
)
# Drop the user_file table
op.drop_table("user_file")
# Drop the user_folder table
op.drop_table("user_folder")

View File

@@ -963,7 +963,7 @@ def connector_indexing_task_wrapper(
def connector_indexing_task(
index_attempt_id: int,
index_attempt_id: int | None,
cc_pair_id: int,
search_settings_id: int,
tenant_id: str | None,

View File

@@ -221,6 +221,7 @@ class FileOrigin(str, Enum):
CHAT_IMAGE_GEN = "chat_image_gen"
CONNECTOR = "connector"
GENERATED_REPORT = "generated_report"
MY_DOCUMENTS = "my_documents"
OTHER = "other"

View File

@@ -153,7 +153,7 @@ class OnyxConfluence(Confluence):
try:
response = self.get(url, params=params)
except HTTPError as e:
if e.response.status_code == 403:
if e.response is not None and e.response.status_code == 403:
raise ApiPermissionError(
"The calling user does not have permission", reason=e
)

View File

@@ -96,6 +96,8 @@ class Tag(BaseModel):
class BaseFilters(BaseModel):
source_type: list[DocumentSource] | None = None
document_set: list[str] | None = None
user_folders: list[str] | None = None
document_ids: list[str] | None = None
time_cutoff: datetime | None = None
tags: list[Tag] | None = None

View File

@@ -1,132 +0,0 @@
from uuid import UUID
from sqlalchemy.orm import Session
from onyx.db.chat import delete_chat_session
from onyx.db.models import ChatFolder
from onyx.db.models import ChatSession
from onyx.utils.logger import setup_logger
logger = setup_logger()
def get_user_folders(
user_id: UUID | None,
db_session: Session,
) -> list[ChatFolder]:
return db_session.query(ChatFolder).filter(ChatFolder.user_id == user_id).all()
def update_folder_display_priority(
user_id: UUID | None,
display_priority_map: dict[int, int],
db_session: Session,
) -> None:
folders = get_user_folders(user_id=user_id, db_session=db_session)
folder_ids = {folder.id for folder in folders}
if folder_ids != set(display_priority_map.keys()):
raise ValueError("Invalid Folder IDs provided")
for folder in folders:
folder.display_priority = display_priority_map[folder.id]
db_session.commit()
def get_folder_by_id(
user_id: UUID | None,
folder_id: int,
db_session: Session,
) -> ChatFolder:
folder = (
db_session.query(ChatFolder).filter(ChatFolder.id == folder_id).one_or_none()
)
if not folder:
raise ValueError("Folder by specified id does not exist")
if folder.user_id != user_id:
raise PermissionError(f"Folder does not belong to user: {user_id}")
return folder
def create_folder(
user_id: UUID | None, folder_name: str | None, db_session: Session
) -> int:
new_folder = ChatFolder(
user_id=user_id,
name=folder_name,
)
db_session.add(new_folder)
db_session.commit()
return new_folder.id
def rename_folder(
user_id: UUID | None, folder_id: int, folder_name: str | None, db_session: Session
) -> None:
folder = get_folder_by_id(
user_id=user_id, folder_id=folder_id, db_session=db_session
)
folder.name = folder_name
db_session.commit()
def add_chat_to_folder(
user_id: UUID | None, folder_id: int, chat_session: ChatSession, db_session: Session
) -> None:
folder = get_folder_by_id(
user_id=user_id, folder_id=folder_id, db_session=db_session
)
chat_session.folder_id = folder.id
db_session.commit()
def remove_chat_from_folder(
user_id: UUID | None, folder_id: int, chat_session: ChatSession, db_session: Session
) -> None:
folder = get_folder_by_id(
user_id=user_id, folder_id=folder_id, db_session=db_session
)
if chat_session.folder_id != folder.id:
raise ValueError("The chat session is not in the specified folder.")
if folder.user_id != user_id:
raise ValueError(
f"Tried to remove a chat session from a folder that does not below to "
f"this user, user id: {user_id}"
)
chat_session.folder_id = None
if chat_session in folder.chat_sessions:
folder.chat_sessions.remove(chat_session)
db_session.commit()
def delete_folder(
user_id: UUID | None,
folder_id: int,
including_chats: bool,
db_session: Session,
) -> None:
folder = get_folder_by_id(
user_id=user_id, folder_id=folder_id, db_session=db_session
)
# Assuming there will not be a massive number of chats in any given folder
if including_chats:
for chat_session in folder.chat_sessions:
delete_chat_session(
user_id=user_id,
chat_session_id=chat_session.id,
db_session=db_session,
)
db_session.delete(folder)
db_session.commit()

View File

@@ -129,6 +129,56 @@ Auth/Authz (users, permissions, access) Tables
"""
class UserFolder(Base):
__tablename__ = "user_folder"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("user.id"), nullable=False)
parent_id: Mapped[int | None] = mapped_column(
ForeignKey("user_folder.id"), nullable=True
)
name: Mapped[str] = mapped_column(nullable=False)
created_at: Mapped[datetime.datetime] = mapped_column(
default=datetime.datetime.utcnow
)
user: Mapped["User"] = relationship(back_populates="folders")
parent: Mapped["UserFolder"] = relationship(
remote_side=[id], back_populates="children"
)
children: Mapped[list["UserFolder"]] = relationship(back_populates="parent")
files: Mapped[list["UserFile"]] = relationship(back_populates="folder")
chat_sessions: Mapped[list["ChatSession"]] = relationship(back_populates="folder")
class UserDocument(str, Enum):
CHAT = "chat"
RECENT = "recent"
FILE = "file"
class UserFile(Base):
__tablename__ = "user_file"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
user_id: Mapped[int | None] = mapped_column(ForeignKey("user.id"), nullable=False)
parent_folder_id: Mapped[int | None] = mapped_column(
ForeignKey("user_folder.id"), nullable=True
)
file_id: Mapped[str] = mapped_column(nullable=False)
document_id: Mapped[str] = mapped_column(nullable=False)
name: Mapped[str] = mapped_column(nullable=False)
created_at: Mapped[datetime.datetime] = mapped_column(
default=datetime.datetime.utcnow
)
ccpair_id: Mapped[int | None] = mapped_column(
ForeignKey("connector_credential_pair.id"), nullable=False
)
user: Mapped["User"] = relationship(back_populates="files")
folder: Mapped["UserFolder"] = relationship(back_populates="files")
class OAuthAccount(SQLAlchemyBaseOAuthAccountTableUUID, Base):
# even an almost empty token from keycloak will not fit the default 1024 bytes
access_token: Mapped[str] = mapped_column(Text, nullable=False) # type: ignore
@@ -178,9 +228,6 @@ class User(SQLAlchemyBaseUserTableUUID, Base):
chat_sessions: Mapped[list["ChatSession"]] = relationship(
"ChatSession", back_populates="user"
)
chat_folders: Mapped[list["ChatFolder"]] = relationship(
"ChatFolder", back_populates="user"
)
prompts: Mapped[list["Prompt"]] = relationship("Prompt", back_populates="user")
@@ -198,6 +245,11 @@ class User(SQLAlchemyBaseUserTableUUID, Base):
primaryjoin="User.id == foreign(ConnectorCredentialPair.creator_id)",
)
folders: Mapped[list["UserFolder"]] = relationship(
"UserFolder", back_populates="user"
)
files: Mapped[list["UserFile"]] = relationship("UserFile", back_populates="user")
class AccessToken(SQLAlchemyBaseAccessTokenTableUUID, Base):
pass
@@ -998,7 +1050,7 @@ class ChatSession(Base):
default=ChatSessionSharedStatus.PRIVATE,
)
folder_id: Mapped[int | None] = mapped_column(
ForeignKey("chat_folder.id"), nullable=True
ForeignKey("user_folder.id", ondelete="SET NULL"), nullable=True
)
current_alternate_model: Mapped[str | None] = mapped_column(String, default=None)
@@ -1028,8 +1080,8 @@ class ChatSession(Base):
DateTime(timezone=True), server_default=func.now()
)
user: Mapped[User] = relationship("User", back_populates="chat_sessions")
folder: Mapped["ChatFolder"] = relationship(
"ChatFolder", back_populates="chat_sessions"
folder: Mapped["UserFolder"] = relationship(
"UserFolder", back_populates="chat_sessions"
)
messages: Mapped[list["ChatMessage"]] = relationship(
"ChatMessage", back_populates="chat_session", cascade="all, delete-orphan"
@@ -1117,33 +1169,6 @@ class ChatMessage(Base):
)
class ChatFolder(Base):
"""For organizing chat sessions"""
__tablename__ = "chat_folder"
id: Mapped[int] = mapped_column(primary_key=True)
# Only null if auth is off
user_id: Mapped[UUID | None] = mapped_column(
ForeignKey("user.id", ondelete="CASCADE"), nullable=True
)
name: Mapped[str | None] = mapped_column(String, nullable=True)
display_priority: Mapped[int] = mapped_column(Integer, nullable=True, default=0)
user: Mapped[User] = relationship("User", back_populates="chat_folders")
chat_sessions: Mapped[list["ChatSession"]] = relationship(
"ChatSession", back_populates="folder"
)
def __lt__(self, other: Any) -> bool:
if not isinstance(other, ChatFolder):
return NotImplemented
if self.display_priority == other.display_priority:
# Bigger ID (created later) show earlier
return self.id > other.id
return self.display_priority < other.display_priority
"""
Feedback, Logging, Metrics Tables
"""

View File

@@ -0,0 +1,36 @@
from typing import List
from fastapi import UploadFile
from sqlalchemy.orm import Session
from onyx.db.models import User
from onyx.db.models import UserFile
from onyx.server.documents.connector import upload_files
from onyx.server.documents.models import FileUploadResponse
CHAT_FOLDER_ID = -1
RECENT_DOCUMENTS_FOLDER_ID = -2
def create_user_files(
files: List[UploadFile],
folder_id: int | None,
user: User | None,
db_session: Session,
) -> FileUploadResponse:
upload_response = upload_files(files, db_session)
for file_path, file in zip(upload_response.file_paths, files):
new_file = UserFile(
user_id=user.id if user else None,
parent_folder_id=folder_id,
file_id=file_path,
document_id=file_path, # We'll use the same ID for now
name=file.filename,
)
db_session.add(new_file)
db_session.commit()
return upload_response
# def trigger_document_indexing(db_session: Session, user_id: int) -> None:

View File

@@ -0,0 +1,29 @@
from typing import List
from fastapi import UploadFile
from sqlalchemy.orm import Session
from onyx.db.models import User
from onyx.db.models import UserFile
from onyx.server.documents.connector import upload_files
from onyx.server.documents.models import FileUploadResponse
def create_user_files(
files: List[UploadFile],
folder_id: int | None,
user: User,
db_session: Session,
) -> FileUploadResponse:
upload_response = upload_files(files, db_session)
for file_path, file in zip(upload_response.file_paths, files):
new_file = UserFile(
user_id=user.id if user else None,
parent_folder_id=folder_id if folder_id != -1 else None,
file_id=file_path,
document_id=file_path,
name=file.filename,
)
db_session.add(new_file)
db_session.commit()
return upload_response

View File

View File

@@ -112,6 +112,11 @@ schema DANSWER_CHUNK_NAME {
rank: filter
attribute: fast-search
}
field user_folders type weightedset<string> {
indexing: summary | attribute
rank: filter
attribute: fast-search
}
}
# If using different tokenization settings, the fieldset has to be removed, and the field must

View File

@@ -313,6 +313,7 @@ class VespaIndex(DocumentIndex):
with updating the associated permissions. Assumes that a document will not be split into
multiple chunk batches calling this function multiple times, otherwise only the last set of
chunks will be kept"""
# IMPORTANT: This must be done one index at a time, do not use secondary index here
cleaned_chunks = [clean_chunk_id_copy(chunk) for chunk in chunks]
@@ -706,6 +707,8 @@ class VespaIndex(DocumentIndex):
offset: int = 0,
title_content_ratio: float | None = TITLE_CONTENT_RATIO,
) -> list[InferenceChunkUncleaned]:
print("filters", filters)
print("filters.user_folders", filters.__dict__)
vespa_where_clauses = build_vespa_filters(filters)
# Needs to be at least as much as the value set in Vespa schema config
target_hits = max(10 * num_to_retrieve, 1000)

View File

@@ -9,11 +9,13 @@ from onyx.document_index.vespa_constants import ACCESS_CONTROL_LIST
from onyx.document_index.vespa_constants import CHUNK_ID
from onyx.document_index.vespa_constants import DOC_UPDATED_AT
from onyx.document_index.vespa_constants import DOCUMENT_ID
from onyx.document_index.vespa_constants import DOCUMENT_IDS
from onyx.document_index.vespa_constants import DOCUMENT_SETS
from onyx.document_index.vespa_constants import HIDDEN
from onyx.document_index.vespa_constants import METADATA_LIST
from onyx.document_index.vespa_constants import SOURCE_TYPE
from onyx.document_index.vespa_constants import TENANT_ID
from onyx.document_index.vespa_constants import USER_FOLDERS
from onyx.utils.logger import setup_logger
logger = setup_logger()
@@ -77,10 +79,15 @@ def build_vespa_filters(
tags = filters.tags
if tags:
tag_attributes = [tag.tag_key + INDEX_SEPARATOR + tag.tag_value for tag in tags]
filter_str += _build_or_filters(METADATA_LIST, tag_attributes)
filter_str += _build_or_filters(DOCUMENT_SETS, filters.document_set)
filter_str += _build_or_filters(USER_FOLDERS, filters.user_folders)
filter_str += _build_or_filters(DOCUMENT_IDS, filters.document_ids)
filter_str += _build_time_filter(filters.time_cutoff)
if remove_trailing_and and filter_str.endswith(" and "):

View File

@@ -64,6 +64,8 @@ EMBEDDINGS = "embeddings"
TITLE_EMBEDDING = "title_embedding"
ACCESS_CONTROL_LIST = "access_control_list"
DOCUMENT_SETS = "document_sets"
USER_FOLDERS = "user_folders"
DOCUMENT_IDS = "document_ids"
LARGE_CHUNK_REFERENCE_IDS = "large_chunk_reference_ids"
METADATA = "metadata"
METADATA_LIST = "metadata_list"

View File

@@ -53,7 +53,6 @@ from onyx.server.documents.document import router as document_router
from onyx.server.documents.indexing import router as indexing_router
from onyx.server.documents.standard_oauth import router as oauth_router
from onyx.server.features.document_set.api import router as document_set_router
from onyx.server.features.folder.api import router as folder_router
from onyx.server.features.notifications.api import router as notification_router
from onyx.server.features.persona.api import admin_router as admin_persona_router
from onyx.server.features.persona.api import basic_router as persona_router
@@ -91,6 +90,7 @@ from onyx.server.settings.api import basic_router as settings_router
from onyx.server.token_rate_limits.api import (
router as token_rate_limit_settings_router,
)
from onyx.server.user_documents.api import router as user_documents_router
from onyx.server.utils import BasicAuthenticationError
from onyx.setup import setup_multitenant_onyx
from onyx.setup import setup_onyx
@@ -270,8 +270,6 @@ def get_application() -> FastAPI:
include_router_with_global_prefix_prepended(application, user_router)
include_router_with_global_prefix_prepended(application, credential_router)
include_router_with_global_prefix_prepended(application, cc_pair_router)
include_router_with_global_prefix_prepended(application, folder_router)
include_router_with_global_prefix_prepended(application, document_set_router)
include_router_with_global_prefix_prepended(application, search_settings_router)
include_router_with_global_prefix_prepended(
application, slack_bot_management_router
@@ -286,15 +284,18 @@ def get_application() -> FastAPI:
include_router_with_global_prefix_prepended(application, onyx_api_router)
include_router_with_global_prefix_prepended(application, gpts_router)
include_router_with_global_prefix_prepended(application, settings_router)
include_router_with_global_prefix_prepended(application, user_documents_router)
include_router_with_global_prefix_prepended(application, settings_admin_router)
include_router_with_global_prefix_prepended(application, llm_admin_router)
include_router_with_global_prefix_prepended(application, llm_router)
include_router_with_global_prefix_prepended(application, embedding_admin_router)
include_router_with_global_prefix_prepended(application, embedding_router)
include_router_with_global_prefix_prepended(application, document_set_router)
include_router_with_global_prefix_prepended(application, indexing_router)
include_router_with_global_prefix_prepended(
application, token_rate_limit_settings_router
)
include_router_with_global_prefix_prepended(application, indexing_router)
include_router_with_global_prefix_prepended(
application, get_full_openai_assistants_api_router()
)

View File

@@ -0,0 +1,9 @@
# This file is used to seed the default chat folder for a user
default_folder:
name: "Chat"
description: "This is the default chat folder for a user"
name: "Recent Documents"
description: "This is the default folder for users to store their recent documents"

View File

@@ -375,10 +375,8 @@ def check_drive_tokens(
return AuthStatus(authenticated=True)
@router.post("/admin/connector/file/upload")
def upload_files(
files: list[UploadFile],
_: User = Depends(current_curator_or_admin_user),
db_session: Session = Depends(get_session),
) -> FileUploadResponse:
for file in files:
@@ -408,6 +406,15 @@ def upload_files(
return FileUploadResponse(file_paths=deduped_file_paths)
@router.post("/admin/connector/file/upload")
def upload_files_api(
files: list[UploadFile],
_: User = Depends(current_curator_or_admin_user),
db_session: Session = Depends(get_session),
) -> FileUploadResponse:
return upload_files(files, db_session)
# Retrieves most recent failure cases for connectors that are currently failing
@router.get("/admin/connector/failed-indexing-status")
def get_currently_failed_indexing_status(

View File

@@ -1,176 +0,0 @@
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
from fastapi import Path
from sqlalchemy.orm import Session
from onyx.auth.users import current_user
from onyx.db.chat import get_chat_session_by_id
from onyx.db.engine import get_session
from onyx.db.folder import add_chat_to_folder
from onyx.db.folder import create_folder
from onyx.db.folder import delete_folder
from onyx.db.folder import get_user_folders
from onyx.db.folder import remove_chat_from_folder
from onyx.db.folder import rename_folder
from onyx.db.folder import update_folder_display_priority
from onyx.db.models import User
from onyx.server.features.folder.models import DeleteFolderOptions
from onyx.server.features.folder.models import FolderChatSessionRequest
from onyx.server.features.folder.models import FolderCreationRequest
from onyx.server.features.folder.models import FolderResponse
from onyx.server.features.folder.models import FolderUpdateRequest
from onyx.server.features.folder.models import GetUserFoldersResponse
from onyx.server.models import DisplayPriorityRequest
from onyx.server.query_and_chat.models import ChatSessionDetails
router = APIRouter(prefix="/folder")
@router.get("")
def get_folders(
user: User = Depends(current_user),
db_session: Session = Depends(get_session),
) -> GetUserFoldersResponse:
folders = get_user_folders(
user_id=user.id if user else None,
db_session=db_session,
)
folders.sort()
return GetUserFoldersResponse(
folders=[
FolderResponse(
folder_id=folder.id,
folder_name=folder.name,
display_priority=folder.display_priority,
chat_sessions=[
ChatSessionDetails(
id=chat_session.id,
name=chat_session.description,
persona_id=chat_session.persona_id,
time_created=chat_session.time_created.isoformat(),
shared_status=chat_session.shared_status,
folder_id=folder.id,
)
for chat_session in folder.chat_sessions
if not chat_session.deleted
],
)
for folder in folders
]
)
@router.put("/reorder")
def put_folder_display_priority(
display_priority_request: DisplayPriorityRequest,
user: User | None = Depends(current_user),
db_session: Session = Depends(get_session),
) -> None:
update_folder_display_priority(
user_id=user.id if user else None,
display_priority_map=display_priority_request.display_priority_map,
db_session=db_session,
)
@router.post("")
def create_folder_endpoint(
request: FolderCreationRequest,
user: User = Depends(current_user),
db_session: Session = Depends(get_session),
) -> int:
return create_folder(
user_id=user.id if user else None,
folder_name=request.folder_name,
db_session=db_session,
)
@router.patch("/{folder_id}")
def patch_folder_endpoint(
request: FolderUpdateRequest,
folder_id: int = Path(..., description="The ID of the folder to rename"),
user: User = Depends(current_user),
db_session: Session = Depends(get_session),
) -> None:
try:
rename_folder(
user_id=user.id if user else None,
folder_id=folder_id,
folder_name=request.folder_name,
db_session=db_session,
)
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@router.delete("/{folder_id}")
def delete_folder_endpoint(
request: DeleteFolderOptions,
folder_id: int = Path(..., description="The ID of the folder to delete"),
user: User = Depends(current_user),
db_session: Session = Depends(get_session),
) -> None:
user_id = user.id if user else None
try:
delete_folder(
user_id=user_id,
folder_id=folder_id,
including_chats=request.including_chats,
db_session=db_session,
)
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/{folder_id}/add-chat-session")
def add_chat_to_folder_endpoint(
request: FolderChatSessionRequest,
folder_id: int = Path(
..., description="The ID of the folder in which to add the chat session"
),
user: User = Depends(current_user),
db_session: Session = Depends(get_session),
) -> None:
user_id = user.id if user else None
try:
chat_session = get_chat_session_by_id(
chat_session_id=request.chat_session_id,
user_id=user_id,
db_session=db_session,
)
add_chat_to_folder(
user_id=user.id if user else None,
folder_id=folder_id,
chat_session=chat_session,
db_session=db_session,
)
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/{folder_id}/remove-chat-session/")
def remove_chat_from_folder_endpoint(
request: FolderChatSessionRequest,
folder_id: int = Path(
..., description="The ID of the folder from which to remove the chat session"
),
user: User = Depends(current_user),
db_session: Session = Depends(get_session),
) -> None:
user_id = user.id if user else None
try:
chat_session = get_chat_session_by_id(
chat_session_id=request.chat_session_id,
user_id=user_id,
db_session=db_session,
)
remove_chat_from_folder(
user_id=user_id,
folder_id=folder_id,
chat_session=chat_session,
db_session=db_session,
)
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))

View File

@@ -1,32 +0,0 @@
from uuid import UUID
from pydantic import BaseModel
from onyx.server.query_and_chat.models import ChatSessionDetails
class FolderResponse(BaseModel):
folder_id: int
folder_name: str | None
display_priority: int
chat_sessions: list[ChatSessionDetails]
class GetUserFoldersResponse(BaseModel):
folders: list[FolderResponse]
class FolderCreationRequest(BaseModel):
folder_name: str | None = None
class FolderUpdateRequest(BaseModel):
folder_name: str | None = None
class FolderChatSessionRequest(BaseModel):
chat_session_id: UUID
class DeleteFolderOptions(BaseModel):
including_chats: bool = False

View File

@@ -53,6 +53,8 @@ from onyx.db.engine import get_session_with_tenant
from onyx.db.feedback import create_chat_message_feedback
from onyx.db.feedback import create_doc_retrieval_feedback
from onyx.db.models import User
from onyx.db.my_documents import create_user_files
from onyx.db.my_documents import RECENT_DOCUMENTS_FOLDER_ID
from onyx.db.persona import get_persona_by_id
from onyx.document_index.document_index_utils import get_both_index_names
from onyx.document_index.factory import get_default_document_index
@@ -620,7 +622,7 @@ def convert_to_jpeg(file: UploadFile) -> Tuple[io.BytesIO, str]:
def upload_files_for_chat(
files: list[UploadFile],
db_session: Session = Depends(get_session),
_: User | None = Depends(current_user),
user: User | None = Depends(current_user),
) -> dict[str, list[FileDescriptor]]:
image_content_types = {"image/jpeg", "image/png", "image/webp"}
csv_content_types = {"text/csv"}
@@ -678,6 +680,9 @@ def upload_files_for_chat(
detail="File size must be less than 20MB",
)
# Create the user files in the recent documents folder
create_user_files(files, RECENT_DOCUMENTS_FOLDER_ID, user, db_session)
file_store = get_default_file_store(db_session)
file_info: list[tuple[str, str | None, ChatFileType]] = []

View File

@@ -0,0 +1,288 @@
from typing import List
from fastapi import APIRouter
from fastapi import Depends
from fastapi import File
from fastapi import Form
from fastapi import HTTPException
from fastapi import UploadFile
from pydantic import BaseModel
from sqlalchemy.orm import Session
from onyx.auth.users import current_user
from onyx.db.engine import get_session
from onyx.db.models import User
from onyx.db.models import UserFile
from onyx.db.models import UserFolder
from onyx.db.my_documents import create_user_files
from onyx.server.documents.models import FileUploadResponse
from onyx.server.user_documents.models import FileResponse
from onyx.server.user_documents.models import FileSystemResponse
from onyx.server.user_documents.models import FolderDetailResponse
from onyx.server.user_documents.models import FolderFullDetailResponse
from onyx.server.user_documents.models import FolderResponse
from onyx.server.user_documents.models import MessageResponse
router = APIRouter()
class FolderCreationRequest(BaseModel):
name: str
parent_id: int | None = None
@router.post("/user/folder")
def create_folder(
request: FolderCreationRequest,
user: User = Depends(current_user),
db_session: Session = Depends(get_session),
) -> FolderDetailResponse:
new_folder = UserFolder(
user_id=user.id if user else None,
parent_id=None if request.parent_id == -1 else request.parent_id,
name=request.name,
)
db_session.add(new_folder)
db_session.commit()
return FolderDetailResponse(
id=new_folder.id,
name=new_folder.name,
parent_id=new_folder.parent_id,
children=[],
files=[],
)
@router.get(
"/user/folder",
)
def get_folders(
user: User = Depends(current_user),
db_session: Session = Depends(get_session),
) -> List[FolderResponse]:
user_id = user.id if user else None
folders = db_session.query(UserFolder).filter(UserFolder.user_id == user_id).all()
return [FolderResponse.from_model(folder) for folder in folders]
@router.get("/user/folder/{folder_id}")
def get_folder(
folder_id: int,
user: User | None = Depends(current_user),
db_session: Session = Depends(get_session),
) -> FolderFullDetailResponse:
user_id = user.id if user else None
if folder_id == -1:
children = (
db_session.query(UserFolder)
.filter(UserFolder.user_id == user_id, UserFolder.parent_id.is_(None))
.all()
)
files = (
db_session.query(UserFile)
.filter(UserFile.user_id == user_id, UserFile.parent_folder_id.is_(None))
.all()
)
return FolderFullDetailResponse(
name="Default Folder",
parent_id=None,
id=-1,
children=[FolderResponse.from_model(child).dict() for child in children],
files=[FileResponse.from_model(file).dict() for file in files],
parents=[],
)
else:
folder = (
db_session.query(UserFolder)
.filter(UserFolder.id == folder_id, UserFolder.user_id == user_id)
.first()
)
if not folder:
raise HTTPException(status_code=404, detail="Folder not found")
parents: List[FolderResponse] = []
current_folder = folder
while current_folder.parent_id is not None:
parent = (
db_session.query(UserFolder)
.filter(UserFolder.id == current_folder.parent_id)
.first()
)
if parent:
parents.insert(
0,
FolderResponse.from_model(parent).dict(),
)
current_folder = parent
else:
break
return FolderFullDetailResponse(
name=folder.name,
parent_id=folder.parent_id,
id=folder.id,
children=[
FolderResponse.from_model(child).dict() for child in folder.children
],
files=[FileResponse.from_model(file).dict() for file in folder.files],
parents=parents,
)
@router.post("/user/file/upload")
def upload_user_files(
files: List[UploadFile] = File(...),
folder_id: int | None = Form(None),
user: User = Depends(current_user),
db_session: Session = Depends(get_session),
) -> FileUploadResponse:
file_upload_response = FileUploadResponse(
file_paths=create_user_files(files, folder_id, user, db_session).file_id
)
# trigger_document_indexing(db_session, user.id)
return file_upload_response
@router.put("/user/folder/{folder_id}")
def update_folder(
folder_id: int,
name: str,
user: User | None = Depends(current_user),
db_session: Session = Depends(get_session),
) -> FolderDetailResponse:
user_id = user.id if user else None
folder = (
db_session.query(UserFolder)
.filter(UserFolder.id == folder_id, UserFolder.user_id == user_id)
.first()
)
if not folder:
raise HTTPException(status_code=404, detail="Folder not found")
folder.name = name
db_session.commit()
return FolderDetailResponse(
id=folder.id,
name=folder.name,
parent_id=folder.parent_id,
children=[FolderResponse.from_model(child) for child in folder.children],
files=[FileResponse.from_model(file) for file in folder.files],
)
@router.delete("/user/folder/{folder_id}")
def delete_folder(
folder_id: int,
user: User = Depends(current_user),
db_session: Session = Depends(get_session),
) -> MessageResponse:
user_id = user.id if user else None
folder = (
db_session.query(UserFolder)
.filter(UserFolder.id == folder_id, UserFolder.user_id == user_id)
.first()
)
if not folder:
raise HTTPException(status_code=404, detail="Folder not found")
db_session.delete(folder)
db_session.commit()
return MessageResponse(message="Folder deleted successfully")
class FolderMoveRequest(BaseModel):
folder_id: int
new_parent_id: int | None
@router.put("/user/folder/{folder_id}/move")
def move_folder(
request: FolderMoveRequest,
user: User = Depends(current_user),
db_session: Session = Depends(get_session),
) -> FolderResponse:
user_id = user.id if user else None
folder = (
db_session.query(UserFolder)
.filter(UserFolder.id == request.folder_id, UserFolder.user_id == user_id)
.first()
)
if not folder:
raise HTTPException(status_code=404, detail="Folder not found")
folder.parent_id = request.new_parent_id
db_session.commit()
return FolderResponse.from_model(folder)
@router.delete("/user/file/{file_id}")
def delete_file(
file_id: int,
user: User = Depends(current_user),
db_session: Session = Depends(get_session),
) -> MessageResponse:
user_id = user.id if user else None
file = (
db_session.query(UserFile)
.filter(UserFile.id == file_id, UserFile.user_id == user_id)
.first()
)
if not file:
raise HTTPException(status_code=404, detail="File not found")
db_session.delete(file)
db_session.commit()
return MessageResponse(message="File deleted successfully")
class FileMoveRequest(BaseModel):
file_id: int
new_parent_id: int | None
@router.put("/user/file/{file_id}/move")
def move_file(
request: FileMoveRequest,
user: User = Depends(current_user),
db_session: Session = Depends(get_session),
) -> FileResponse:
user_id = user.id if user else None
file = (
db_session.query(UserFile)
.filter(UserFile.id == request.file_id, UserFile.user_id == user_id)
.first()
)
if not file:
raise HTTPException(status_code=404, detail="File not found")
file.parent_folder_id = request.new_parent_id
db_session.commit()
return FileResponse.from_model(file)
@router.get("/user/file-system")
def get_file_system(
user: User = Depends(current_user),
db_session: Session = Depends(get_session),
) -> FileSystemResponse:
user_id = user.id if user else None
folders = db_session.query(UserFolder).filter(UserFolder.user_id == user_id).all()
files = db_session.query(UserFile).filter(UserFile.user_id == user_id).all()
return FileSystemResponse(
folders=[FolderResponse.from_model(folder) for folder in folders],
files=[FileResponse.from_model(file) for file in files],
)
@router.put("/user/file/{file_id}/rename")
def rename_file(
file_id: int,
name: str,
user: User = Depends(current_user),
db_session: Session = Depends(get_session),
) -> FileResponse:
user_id = user.id if user else None
file = (
db_session.query(UserFile)
.filter(UserFile.id == file_id, UserFile.user_id == user_id)
.first()
)
if not file:
raise HTTPException(status_code=404, detail="File not found")
file.name = name
db_session.commit()
return FileResponse.from_model(file)

View File

@@ -0,0 +1,54 @@
from typing import List
from fastapi import APIRouter
from pydantic import BaseModel
from onyx.db.models import UserFile
from onyx.db.models import UserFolder
router = APIRouter()
class FolderResponse(BaseModel):
id: int
name: str
parent_id: int | None = None
@classmethod
def from_model(cls, model: UserFolder) -> "FolderResponse":
return cls(id=model.id, name=model.name, parent_id=model.parent_id)
class FolderDetailResponse(FolderResponse):
children: List[FolderResponse]
files: List[dict]
class FolderFullDetailResponse(FolderDetailResponse):
parents: List[FolderResponse]
class FileResponse(BaseModel):
id: int
name: str
document_id: str
parent_folder_id: int | None = None
@classmethod
def from_model(cls, model: UserFile) -> "FileResponse":
return cls(
id=model.id,
name=model.name,
parent_folder_id=model.parent_folder_id,
document_id=model.document_id,
)
class MessageResponse(BaseModel):
message: str
class FileSystemResponse(BaseModel):
folders: list[FolderResponse]
files: list[FileResponse]

72
web/package-lock.json generated
View File

@@ -12,6 +12,7 @@
"@dnd-kit/modifiers": "^7.0.0",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@floating-ui/react": "^0.26.28",
"@headlessui/react": "^2.2.0",
"@headlessui/tailwindcss": "^0.2.1",
"@phosphor-icons/react": "^2.0.8",
@@ -56,6 +57,7 @@
"react-icons": "^4.8.0",
"react-loader-spinner": "^5.4.5",
"react-markdown": "^9.0.1",
"react-popper": "^2.3.0",
"react-select": "^5.8.0",
"recharts": "^2.13.1",
"rehype-katex": "^7.0.1",
@@ -1194,6 +1196,21 @@
"@floating-ui/utils": "^0.2.0"
}
},
"node_modules/@floating-ui/react": {
"version": "0.26.28",
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz",
"integrity": "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.1.2",
"@floating-ui/utils": "^0.2.8",
"tabbable": "^6.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@floating-ui/react-dom": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz",
@@ -1229,20 +1246,6 @@
"react-dom": "^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/@headlessui/react/node_modules/@floating-ui/react": {
"version": "0.26.27",
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.27.tgz",
"integrity": "sha512-jLP72x0Kr2CgY6eTYi/ra3VA9LOkTo4C+DUTrbFgFOExKy3omYVmwMjNKqxAHdsnyLS96BIDLcO2SlnsNf8KUQ==",
"dependencies": {
"@floating-ui/react-dom": "^2.1.2",
"@floating-ui/utils": "^0.2.8",
"tabbable": "^6.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@headlessui/react/node_modules/@react-aria/focus": {
"version": "3.18.4",
"resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.18.4.tgz",
@@ -2592,6 +2595,17 @@
"node": ">=18"
}
},
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@prisma/instrumentation": {
"version": "5.19.1",
"resolved": "https://registry.npmjs.org/@prisma/instrumentation/-/instrumentation-5.19.1.tgz",
@@ -14171,6 +14185,27 @@
"react": ">=18"
}
},
"node_modules/react-popper": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/react-popper/-/react-popper-2.3.0.tgz",
"integrity": "sha512-e1hj8lL3uM+sgSR4Lxzn5h1GxBlpa4CQz0XLF8kx4MDrDRWY0Ena4c97PUeSX9i5W3UAfDP0z0FXCTQkoXUl3Q==",
"license": "MIT",
"dependencies": {
"react-fast-compare": "^3.0.1",
"warning": "^4.0.2"
},
"peerDependencies": {
"@popperjs/core": "^2.0.0",
"react": "^16.8.0 || ^17 || ^18",
"react-dom": "^16.8.0 || ^17 || ^18"
}
},
"node_modules/react-popper/node_modules/react-fast-compare": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
"integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==",
"license": "MIT"
},
"node_modules/react-remove-scroll": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.6.0.tgz",
@@ -16206,6 +16241,15 @@
"d3-timer": "^3.0.1"
}
},
"node_modules/warning": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
"integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.0.0"
}
},
"node_modules/watchpack": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz",

View File

@@ -14,6 +14,7 @@
"@dnd-kit/modifiers": "^7.0.0",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@floating-ui/react": "^0.26.28",
"@headlessui/react": "^2.2.0",
"@headlessui/tailwindcss": "^0.2.1",
"@phosphor-icons/react": "^2.0.8",
@@ -58,6 +59,7 @@
"react-icons": "^4.8.0",
"react-loader-spinner": "^5.4.5",
"react-markdown": "^9.0.1",
"react-popper": "^2.3.0",
"react-select": "^5.8.0",
"recharts": "^2.13.1",
"rehype-katex": "^7.0.1",

View File

@@ -2,8 +2,6 @@
import { HistorySidebar } from "@/app/chat/sessionSidebar/HistorySidebar";
import { ChatSession } from "@/app/chat/interfaces";
import { Folder } from "@/app/chat/folders/interfaces";
import { User } from "@/lib/types";
import Cookies from "js-cookie";
import { SIDEBAR_TOGGLED_COOKIE_NAME } from "@/components/resizable/constants";
import {
@@ -35,7 +33,7 @@ export default function SidebarWrapper<T extends object>({
size = "sm",
children,
}: SidebarWrapperProps<T>) {
const { chatSessions, folders, openedFolders } = useChatContext();
const { chatSessions } = useChatContext();
const [toggledSidebar, setToggledSidebar] = useState(initiallyToggled);
const [showDocSidebar, setShowDocSidebar] = useState(false); // State to track if sidebar is open
// Used to maintain a "time out" for history sidebar so our existing refs can have time to process change
@@ -122,8 +120,6 @@ export default function SidebarWrapper<T extends object>({
toggled={toggledSidebar}
existingChats={chatSessions}
currentChatSession={null}
folders={folders}
openedFolders={openedFolders}
/>
</div>
</div>

View File

@@ -23,8 +23,6 @@ export default async function GalleryPage(props: {
const {
user,
chatSessions,
folders,
openedFolders,
toggleSidebar,
shouldShowWelcomeModal,
availableSources,
@@ -46,8 +44,6 @@ export default async function GalleryPage(props: {
availableDocumentSets: documentSets,
availableTags: tags,
llmProviders,
folders,
openedFolders,
shouldShowWelcomeModal,
defaultAssistantId,
}}

View File

@@ -24,8 +24,6 @@ export default async function GalleryPage(props: {
const {
user,
chatSessions,
folders,
openedFolders,
toggleSidebar,
shouldShowWelcomeModal,
availableSources,
@@ -47,8 +45,6 @@ export default async function GalleryPage(props: {
availableDocumentSets: documentSets,
availableTags: tags,
llmProviders,
folders,
openedFolders,
shouldShowWelcomeModal,
defaultAssistantId,
}}

View File

@@ -110,7 +110,7 @@ import AssistantBanner from "../../components/assistants/AssistantBanner";
import TextView from "@/components/chat_search/TextView";
import AssistantSelector from "@/components/chat_search/AssistantSelector";
import { Modal } from "@/components/Modal";
import { createPostponedAbortSignal } from "next/dist/server/app-render/dynamic-rendering";
import { FilePicker, UserFile, UserFolder } from "../my-documents/FilePicker";
const TEMP_USER_MESSAGE_ID = -1;
const TEMP_ASSISTANT_MESSAGE_ID = -2;
@@ -134,8 +134,6 @@ export function ChatPage({
tags,
documentSets,
llmProviders,
folders,
openedFolders,
defaultAssistantId,
shouldShowWelcomeModal,
refreshChatSessions,
@@ -200,6 +198,11 @@ export function ChatPage({
SEARCH_PARAM_NAMES.STRUCTURED_MODEL
);
const [myDocumentsToggled, setMyDocumentsToggled] = useState(false);
const toggleMyDocuments = () => {
setMyDocumentsToggled(!myDocumentsToggled);
};
// Effect to handle sendOnLoad
useEffect(() => {
if (sendOnLoad) {
@@ -1928,6 +1931,36 @@ export function ChatPage({
}
};
const [allFolders, setAllFolders] = useState<UserFolder[]>([]);
const [allFiles, setAllFiles] = useState<UserFile[]>([]);
useEffect(() => {
const loadFileSystem = async () => {
const res = await fetch("/api/user/file-system");
const data = await res.json();
const folders = data.folders.map((f: any) => ({
id: f.id,
name: f.name,
parent_id: f.parent_id,
}));
const files = data.files.map((f: any) => ({
id: f.id,
name: f.name,
parent_id: f.parent_id,
}));
setAllFolders(folders);
setAllFiles(files);
};
loadFileSystem();
}, []);
const [folders, setFolders] = useState<UserFolder[]>([]);
const [userFiles, setUserFiles] = useState<UserFile[]>([]);
const removeFolder = (folderId: number) => {
setFolders((prev) => prev.filter((f) => f.id !== folderId));
};
interface RegenerationRequest {
messageId: number;
parentMessage: Message;
@@ -1969,6 +2002,21 @@ export function ChatPage({
{popup}
<ChatPopup />
{myDocumentsToggled && (
<FilePicker
allFolders={allFolders}
setSelectedFolders={(folders) => setFolders(folders)}
setUserFiles={(userFiles) => {
setUserFiles(userFiles);
}}
allFiles={allFiles}
isOpen={myDocumentsToggled}
userFiles={userFiles}
selectedFolders={folders}
onClose={() => setMyDocumentsToggled(false)}
onSave={() => {}}
/>
)}
{showDeleteAllModal && (
<DeleteEntityModal
@@ -2143,17 +2191,13 @@ export function ChatPage({
<div className="w-full relative">
<HistorySidebar
explicitlyUntoggle={explicitlyUntoggle}
stopGenerating={stopGenerating}
reset={() => setMessage("")}
page="chat"
ref={innerSidebarElementRef}
toggleSidebar={toggleSidebar}
toggled={toggledSidebar}
backgroundToggled={toggledSidebar || showHistorySidebar}
toggled={toggledSidebar && !settings?.isMobile}
existingChats={chatSessions}
currentChatSession={selectedChatSession}
folders={folders}
openedFolders={openedFolders}
removeToggle={removeToggle}
showShareModal={showShareModal}
showDeleteModal={showDeleteModal}
@@ -2761,7 +2805,22 @@ export function ChatPage({
</button>
</div>
)}
<ChatInputBar
removeUserFile={(userFileId) => {
setUserFiles(
userFiles.filter(
(file) => file.id !== userFileId
)
);
}}
removeFilters={() => {
setFiltersToggled(false);
}}
userFiles={userFiles}
folders={folders}
removeFolder={removeFolder}
toggleMyDocuments={toggleMyDocuments}
removeDocs={() => {
clearSelectedDocuments();
}}
@@ -2857,6 +2916,7 @@ export function ChatPage({
)}
</div>
</div>
<FixedLogo backgroundToggled={toggledSidebar || showHistorySidebar} />
</div>
{/* Right Sidebar - DocumentSidebar */}

View File

@@ -79,9 +79,7 @@ export const ChatFilters = forwardRef<HTMLDivElement, ChatFiltersProps>(
const currentDocuments = selectedMessage?.documents || null;
const dedupedDocuments = removeDuplicateDocs(currentDocuments || []);
const tokenLimitReached = selectedDocumentTokens > maxTokens - 75;
console.log("SELECTED MESSAGE is", selectedMessage);
const hasSelectedDocuments = selectedDocumentIds.length > 0;

View File

@@ -1,358 +0,0 @@
"use client";
import React, { useState, useEffect, useRef } from "react";
import { Folder } from "./interfaces";
import { ChatSessionDisplay } from "../sessionSidebar/ChatSessionDisplay"; // Ensure this is correctly imported
import {
FiChevronDown,
FiChevronRight,
FiFolder,
FiEdit2,
FiCheck,
FiX,
FiTrash, // Import the trash icon
} from "react-icons/fi";
import { BasicSelectable } from "@/components/BasicClickable";
import {
addChatToFolder,
deleteFolder,
updateFolderName,
} from "./FolderManagement";
import { usePopup } from "@/components/admin/connectors/Popup";
import { useRouter } from "next/navigation";
import { CHAT_SESSION_ID_KEY } from "@/lib/drag/constants";
import Cookies from "js-cookie";
import { Popover } from "@/components/popover/Popover";
import { ChatSession } from "../interfaces";
import { useChatContext } from "@/components/context/ChatContext";
const FolderItem = ({
folder,
currentChatId,
isInitiallyExpanded,
initiallySelected,
showShareModal,
showDeleteModal,
}: {
folder: Folder;
currentChatId?: string;
isInitiallyExpanded: boolean;
initiallySelected: boolean;
showShareModal: ((chatSession: ChatSession) => void) | undefined;
showDeleteModal: ((chatSession: ChatSession) => void) | undefined;
}) => {
const { refreshChatSessions } = useChatContext();
const [isExpanded, setIsExpanded] = useState<boolean>(isInitiallyExpanded);
const [isEditing, setIsEditing] = useState<boolean>(initiallySelected);
const [editedFolderName, setEditedFolderName] = useState<string>(
folder.folder_name
);
const [isHovering, setIsHovering] = useState<boolean>(false);
const [isDragOver, setIsDragOver] = useState<boolean>(false);
const { setPopup } = usePopup();
const router = useRouter();
const toggleFolderExpansion = () => {
if (!isEditing) {
const newIsExpanded = !isExpanded;
setIsExpanded(newIsExpanded);
// Update the cookie with the new state
const openedFoldersCookieVal = Cookies.get("openedFolders");
const openedFolders = openedFoldersCookieVal
? JSON.parse(openedFoldersCookieVal)
: {};
if (newIsExpanded) {
openedFolders[folder.folder_id] = true;
} else {
setShowDeleteConfirm(false);
delete openedFolders[folder.folder_id];
}
Cookies.set("openedFolders", JSON.stringify(openedFolders));
}
};
const handleEditFolderName = (event: React.MouseEvent<HTMLDivElement>) => {
event.stopPropagation(); // Prevent the event from bubbling up to the toggle expansion
setIsEditing(true);
};
const handleFolderNameChange = (
event: React.ChangeEvent<HTMLInputElement>
) => {
setEditedFolderName(event.target.value);
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
saveFolderName();
}
};
const saveFolderName = async (continueEditing?: boolean) => {
try {
await updateFolderName(folder.folder_id, editedFolderName);
if (!continueEditing) {
setIsEditing(false);
}
router.refresh(); // Refresh values to update the sidebar
} catch (error) {
setPopup({ message: "Failed to save folder name", type: "error" });
}
};
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean>(false);
const deleteConfirmRef = useRef<HTMLDivElement>(null);
const handleDeleteClick = (event: React.MouseEvent<HTMLDivElement>) => {
event.stopPropagation();
setShowDeleteConfirm(true);
};
const confirmDelete = async (event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
try {
await deleteFolder(folder.folder_id);
router.refresh();
} catch (error) {
setPopup({ message: "Failed to delete folder", type: "error" });
} finally {
setShowDeleteConfirm(false);
}
};
const cancelDelete = (event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
setShowDeleteConfirm(false);
};
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
deleteConfirmRef.current &&
!deleteConfirmRef.current.contains(event.target as Node)
) {
setShowDeleteConfirm(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (initiallySelected && inputRef.current) {
inputRef.current.focus();
}
}, [initiallySelected]);
const handleDrop = async (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
setIsDragOver(false);
const chatSessionId = event.dataTransfer.getData(CHAT_SESSION_ID_KEY);
try {
await addChatToFolder(folder.folder_id, chatSessionId);
await refreshChatSessions();
router.refresh();
} catch (error) {
setPopup({
message: "Failed to add chat session to folder",
type: "error",
});
}
};
const folders = folder.chat_sessions.sort((a, b) => {
return a.time_created.localeCompare(b.time_created);
});
// Determine whether to show the trash can icon
const showTrashIcon = (isHovering && !isEditing) || showDeleteConfirm;
return (
<div
key={folder.folder_id}
onDragOver={(event) => {
event.preventDefault();
setIsDragOver(true);
}}
onDragLeave={() => setIsDragOver(false)}
onDrop={handleDrop}
className={`transition duration-300 ease-in-out rounded-md ${
isDragOver ? "bg-hover" : ""
}`}
>
<BasicSelectable fullWidth selected={false}>
<div
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
>
<div onClick={toggleFolderExpansion} className="cursor-pointer">
<div className="text-sm text-text-600 flex items-center justify-start w-full">
<div className="mr-2">
{isExpanded ? (
<FiChevronDown size={16} />
) : (
<FiChevronRight size={16} />
)}
</div>
<div>
<FiFolder size={16} className="mr-2" />
</div>
{isEditing ? (
<input
ref={inputRef}
type="text"
value={editedFolderName}
onChange={handleFolderNameChange}
onKeyDown={handleKeyDown}
onBlur={() => saveFolderName(true)}
className="text-sm px-1 flex-1 min-w-0 -my-px mr-2"
/>
) : (
<div className="flex-1 break-all min-w-0">
{editedFolderName || folder.folder_name}
</div>
)}
<div className="flex ml-auto my-auto">
<div
onClick={handleEditFolderName}
className={`hover:bg-black/10 p-1 -m-1 rounded ${
isHovering && !isEditing
? ""
: "opacity-0 pointer-events-none"
}`}
>
<FiEdit2 size={16} />
</div>
<div className="relative">
<Popover
open={showDeleteConfirm}
onOpenChange={setShowDeleteConfirm}
content={
<div
onClick={handleDeleteClick}
className={`hover:bg-black/10 p-1 -m-1 rounded ml-2 ${
showTrashIcon ? "" : "opacity-0 pointer-events-none"
}`}
>
<FiTrash size={16} />
</div>
}
popover={
<div className="p-2 w-[225px] bg-background-100 rounded shadow-lg">
<p className="text-sm mb-2">
Are you sure you want to delete folder{" "}
<i>{folder.folder_name}</i>?
</p>
<div className="flex justify-end">
<button
onClick={confirmDelete}
className="bg-red-500 hover:bg-red-600 text-white px-2 py-1 rounded text-xs mr-2"
>
Yes
</button>
<button
onClick={cancelDelete}
className="bg-gray-300 hover:bg-gray-200 px-2 py-1 rounded text-xs"
>
No
</button>
</div>
</div>
}
side="top"
align="center"
/>
</div>
</div>
{isEditing && (
<div className="flex ml-auto my-auto">
<div
onClick={() => saveFolderName()}
className="hover:bg-black/10 p-1 -m-1 rounded"
>
<FiCheck size={16} />
</div>
<div
onClick={() => setIsEditing(false)}
className="hover:bg-black/10 p-1 -m-1 rounded ml-2"
>
<FiX size={16} />
</div>
</div>
)}
</div>
</div>
</div>
</BasicSelectable>
{/* Expanded Folder Content */}
{isExpanded && folders && (
<div className={"ml-2 pl-2 border-l border-border"}>
{folders.map((chatSession) => (
<ChatSessionDisplay
key={chatSession.id}
chatSession={chatSession}
isSelected={chatSession.id === currentChatId}
skipGradient={isDragOver}
showShareModal={showShareModal}
showDeleteModal={showDeleteModal}
/>
))}
</div>
)}
</div>
);
};
export const FolderList = ({
folders,
currentChatId,
openedFolders,
newFolderId,
showShareModal,
showDeleteModal,
}: {
folders: Folder[];
currentChatId?: string;
openedFolders?: { [key: number]: boolean };
newFolderId: number | null;
showShareModal: ((chatSession: ChatSession) => void) | undefined;
showDeleteModal: ((chatSession: ChatSession) => void) | undefined;
}) => {
if (folders.length === 0) {
return null;
}
return (
<div className="mt-1 mb-1 overflow-visible">
{folders.map((folder) => (
<FolderItem
key={folder.folder_id}
folder={folder}
currentChatId={currentChatId}
initiallySelected={newFolderId == folder.folder_id}
isInitiallyExpanded={
openedFolders ? openedFolders[folder.folder_id] || false : false
}
showShareModal={showShareModal}
showDeleteModal={showDeleteModal}
/>
))}
{folders.length == 1 && folders[0].chat_sessions.length == 0 && (
<p className="text-sm font-normal text-subtle mt-2">
{" "}
Drag a chat into a folder to save for later{" "}
</p>
)}
</div>
);
};

View File

@@ -1,80 +0,0 @@
// Function to create a new folder
export async function createFolder(folderName: string): Promise<number> {
const response = await fetch("/api/folder", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ folder_name: folderName }),
});
if (!response.ok) {
throw new Error("Failed to create folder");
}
const data = await response.json();
return data;
}
// Function to add a chat session to a folder
export async function addChatToFolder(
folderId: number,
chatSessionId: string
): Promise<void> {
const response = await fetch(`/api/folder/${folderId}/add-chat-session`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ chat_session_id: chatSessionId }),
});
if (!response.ok) {
throw new Error("Failed to add chat to folder");
}
}
// Function to remove a chat session from a folder
export async function removeChatFromFolder(
folderId: number,
chatSessionId: string
): Promise<void> {
const response = await fetch(`/api/folder/${folderId}/remove-chat-session`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ chat_session_id: chatSessionId }),
});
if (!response.ok) {
throw new Error("Failed to remove chat from folder");
}
}
// Function to delete a folder
export async function deleteFolder(folderId: number): Promise<void> {
const response = await fetch(`/api/folder/${folderId}`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({}),
});
if (!response.ok) {
throw new Error("Failed to delete folder");
}
}
// Function to update a folder's name
export async function updateFolderName(
folderId: number,
newName: string
): Promise<void> {
const response = await fetch(`/api/folder/${folderId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ folder_name: newName }),
});
if (!response.ok) {
throw new Error("Failed to update folder name");
}
}

View File

@@ -1,8 +0,0 @@
import { ChatSession } from "../interfaces";
export interface Folder {
folder_id: number;
folder_name: string;
display_priority: number;
chat_sessions: ChatSession[];
}

View File

@@ -1,5 +1,12 @@
import React, { useContext, useEffect, useRef, useState } from "react";
import { FiPlusCircle, FiPlus, FiInfo, FiX, FiSearch } from "react-icons/fi";
import {
FiPlusCircle,
FiPlus,
FiInfo,
FiX,
FiSearch,
FiFolder,
} from "react-icons/fi";
import { ChatInputOption } from "./ChatInputOption";
import { Persona } from "@/app/admin/assistants/interfaces";
@@ -30,12 +37,21 @@ import { SettingsContext } from "@/components/settings/SettingsProvider";
import { ChatState } from "../types";
import UnconfiguredProviderText from "@/components/chat_search/UnconfiguredProviderText";
import { useAssistants } from "@/components/context/AssistantsContext";
import { XIcon } from "lucide-react";
import AnimatedToggle from "@/components/search/SearchBar";
import { Popup } from "@/components/admin/connectors/Popup";
import { AssistantsTab } from "../modal/configuration/AssistantsTab";
import { IconType } from "react-icons";
import { LlmTab } from "../modal/configuration/LlmTab";
import { FolderIcon, XIcon } from "lucide-react";
import FiltersDisplay from "./FilterDisplay";
import { UserFolder, UserFile } from "@/app/my-documents/FilePicker";
import { useRouter } from "next/navigation";
const MAX_INPUT_HEIGHT = 200;
interface ChatInputBarProps {
removeFilters: () => void;
removeDocs: () => void;
openModelSettings: () => void;
showDocs: () => void;
@@ -46,6 +62,8 @@ interface ChatInputBarProps {
stopGenerating: () => void;
onSubmit: () => void;
filterManager: FilterManager;
folders: UserFolder[];
removeFolder: (folderId: number) => void;
chatState: ChatState;
alternativeAssistant: Persona | null;
// assistants
@@ -57,12 +75,20 @@ interface ChatInputBarProps {
handleFileUpload: (files: File[]) => void;
textAreaRef: React.RefObject<HTMLTextAreaElement>;
toggleFilters?: () => void;
toggleMyDocuments: () => void;
userFiles: UserFile[];
removeUserFile: (fileId: number) => void;
}
export function ChatInputBar({
removeUserFile,
removeFilters,
removeDocs,
removeFolder,
folders,
openModelSettings,
showDocs,
userFiles,
showConfigureAPIKey,
selectedDocuments,
message,
@@ -82,7 +108,9 @@ export function ChatInputBar({
textAreaRef,
alternativeAssistant,
toggleFilters,
toggleMyDocuments,
}: ChatInputBarProps) {
const router = useRouter();
useEffect(() => {
const textarea = textAreaRef.current;
if (textarea) {
@@ -322,9 +350,54 @@ export function ChatInputBar({
</div>
)}
{(selectedDocuments.length > 0 || files.length > 0) && (
{(selectedDocuments.length > 0 ||
files.length > 0 ||
folders.length > 0 ||
userFiles.length > 0) && (
<div className="flex gap-x-2 px-2 pt-2">
<div className="flex gap-x-1 px-2 overflow-visible overflow-x-scroll items-end miniscroll">
{userFiles.map((file) => (
<div className="flex-none" key={file.id}>
<button className="flex-none relative overflow-visible flex items-center gap-x-2 h-10 px-3 rounded-lg bg-background-150 hover:bg-background-200 transition-colors duration-300 cursor-pointer max-w-[150px]">
<FileIcon size={20} />
<span className="text-sm whitespace-nowrap overflow-hidden text-ellipsis">
{file.name}
</span>
<XIcon
onClick={(e) => {
e.stopPropagation();
removeUserFile(file.id);
}}
size={16}
className="flex-none text-text-400 hover:text-text-600 ml-auto"
/>
</button>
</div>
))}
{folders.map((folder) => (
<button
key={folder.id}
onClick={() =>
router.push(`/my-documents?path=${folder.id}`)
}
className="flex-none relative overflow-visible flex items-center gap-x-2 h-10 px-3 rounded-lg bg-background-150 hover:bg-background-200 transition-colors duration-300 cursor-pointer max-w-[150px]"
>
<FolderIcon size={20} />
<span className="text-sm whitespace-nowrap overflow-hidden text-ellipsis">
{folder.name}
</span>
<XIcon
onClick={(e) => {
e.stopPropagation();
removeFolder(folder.id);
// Remove this folder
}}
size={16}
className="text-text-400 hover:text-text-600 ml-auto"
/>
</button>
))}
{selectedDocuments.length > 0 && (
<button
onClick={showDocs}
@@ -445,6 +518,14 @@ export function ChatInputBar({
input.click();
}}
/>
<ChatInputOption
flexPriority="stiff"
name="Documents"
Icon={FiFolder}
onClick={toggleMyDocuments}
/>
{toggleFilters && (
<ChatInputOption
flexPriority="stiff"

View File

@@ -26,9 +26,7 @@ export default async function Page(props: {
documentSets,
tags,
llmProviders,
folders,
toggleSidebar,
openedFolders,
defaultAssistantId,
shouldShowWelcomeModal,
ccPairs,
@@ -50,8 +48,6 @@ export default async function Page(props: {
availableDocumentSets: documentSets,
availableTags: tags,
llmProviders,
folders,
openedFolders,
shouldShowWelcomeModal,
defaultAssistantId,
}}

View File

@@ -1,13 +1,11 @@
"use client";
import { FiEdit, FiFolderPlus } from "react-icons/fi";
import { FiBarChart, FiBook, FiEdit } from "react-icons/fi";
import React, { ForwardedRef, forwardRef, useContext, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ChatSession } from "../interfaces";
import { NEXT_PUBLIC_NEW_CHAT_DIRECTS_TO_SAME_PERSONA } from "@/lib/constants";
import { Folder } from "../folders/interfaces";
import { createFolder } from "../folders/FolderManagement";
import { usePopup } from "@/components/admin/connectors/Popup";
import { SettingsContext } from "@/components/settings/SettingsProvider";
@@ -20,18 +18,14 @@ interface HistorySidebarProps {
page: pageType;
existingChats?: ChatSession[];
currentChatSession?: ChatSession | null | undefined;
folders?: Folder[];
openedFolders?: { [key: number]: boolean };
toggleSidebar?: () => void;
toggled?: boolean;
removeToggle?: () => void;
reset?: () => void;
showShareModal?: (chatSession: ChatSession) => void;
showDeleteModal?: (chatSession: ChatSession) => void;
stopGenerating?: () => void;
explicitlyUntoggle: () => void;
showDeleteAllModal?: () => void;
backgroundToggled?: boolean;
}
export const HistorySidebar = forwardRef<HTMLDivElement, HistorySidebarProps>(
@@ -42,24 +36,16 @@ export const HistorySidebar = forwardRef<HTMLDivElement, HistorySidebarProps>(
page,
existingChats,
currentChatSession,
folders,
openedFolders,
explicitlyUntoggle,
toggleSidebar,
removeToggle,
stopGenerating = () => null,
showShareModal,
showDeleteModal,
showDeleteAllModal,
backgroundToggled,
},
ref: ForwardedRef<HTMLDivElement>
) => {
const router = useRouter();
const { popup, setPopup } = usePopup();
// For determining intial focus state
const [newFolderId, setNewFolderId] = useState<number | null>(null);
const currentChatId = currentChatSession?.id;
@@ -86,7 +72,6 @@ export const HistorySidebar = forwardRef<HTMLDivElement, HistorySidebarProps>(
return (
<>
{popup}
<div
ref={ref}
className={`
@@ -135,26 +120,23 @@ export const HistorySidebar = forwardRef<HTMLDivElement, HistorySidebarProps>(
<FiEdit className="flex-none text-text-history-sidebar-button" />
<p className="my-auto flex items-center text-sm">New Chat</p>
</Link>
<button
onClick={() =>
createFolder("New Folder")
.then((folderId) => {
router.refresh();
setNewFolderId(folderId);
})
.catch((error) => {
console.error("Failed to create folder:", error);
setPopup({
message: `Failed to create folder: ${error.message}`,
type: "error",
});
})
}
className="w-full p-2 bg-white border-border border rounded items-center hover:bg-background-history-sidebar-button-hover cursor-pointer transition-all duration-150 flex gap-x-2"
<Link
className=" w-full p-2 bg-white border-border border rounded items-center hover:bg-background-200 cursor-pointer transition-all duration-150 flex gap-x-2"
href={`/my-documents`}
onClick={(e) => {
if (e.metaKey || e.ctrlKey) {
return;
}
if (handleNewChat) {
handleNewChat();
}
}}
>
<FiFolderPlus className="my-auto text-text-history-sidebar-button" />
<p className="my-auto flex items-center text-sm">New Folder</p>
</button>
<FiBook className="flex-none text-text-history-sidebar-button" />
<p className="my-auto flex items-center text-sm">
My Documents
</p>
</Link>
<Link
href="/assistants/mine"
@@ -169,16 +151,12 @@ export const HistorySidebar = forwardRef<HTMLDivElement, HistorySidebarProps>(
)}
<div className="border-b border-divider-history-sidebar-bar pb-4 mx-3" />
<PagesTab
newFolderId={newFolderId}
showDeleteModal={showDeleteModal}
showShareModal={showShareModal}
closeSidebar={removeToggle}
page={page}
existingChats={existingChats}
currentChatId={currentChatId}
folders={folders}
openedFolders={openedFolders}
showDeleteAllModal={showDeleteAllModal}
/>
</div>
</>

View File

@@ -1,10 +1,6 @@
import { ChatSession } from "../interfaces";
import { groupSessionsByDateRange } from "../lib";
import { ChatSessionDisplay } from "./ChatSessionDisplay";
import { removeChatFromFolder } from "../folders/FolderManagement";
import { FolderList } from "../folders/FolderList";
import { Folder } from "../folders/interfaces";
import { CHAT_SESSION_ID_KEY, FOLDER_ID_KEY } from "@/lib/drag/constants";
import { usePopup } from "@/components/admin/connectors/Popup";
import { useRouter } from "next/navigation";
import { useState } from "react";
@@ -16,10 +12,7 @@ export function PagesTab({
page,
existingChats,
currentChatId,
folders,
openedFolders,
closeSidebar,
newFolderId,
showShareModal,
showDeleteModal,
showDeleteAllModal,
@@ -27,10 +20,7 @@ export function PagesTab({
page: pageType;
existingChats?: ChatSession[];
currentChatId?: string;
folders?: Folder[];
openedFolders?: { [key: number]: boolean };
closeSidebar?: () => void;
newFolderId: number | null;
showShareModal?: (chatSession: ChatSession) => void;
showDeleteModal?: (chatSession: ChatSession) => void;
showDeleteAllModal?: () => void;
@@ -43,27 +33,6 @@ export function PagesTab({
const router = useRouter();
const [isDragOver, setIsDragOver] = useState<boolean>(false);
const handleDropToRemoveFromFolder = async (
event: React.DragEvent<HTMLDivElement>
) => {
event.preventDefault();
setIsDragOver(false); // Reset drag over state on drop
const chatSessionId = event.dataTransfer.getData(CHAT_SESSION_ID_KEY);
const folderId = event.dataTransfer.getData(FOLDER_ID_KEY);
if (folderId) {
try {
await removeChatFromFolder(parseInt(folderId, 10), chatSessionId);
router.refresh(); // Refresh the page to reflect the changes
} catch (error) {
setPopup({
message: "Failed to remove chat from folder",
type: "error",
});
}
}
};
const isHistoryEmpty = !existingChats || existingChats.length === 0;
return (
@@ -73,29 +42,12 @@ export function PagesTab({
NEXT_PUBLIC_DELETE_ALL_CHATS_ENABLED && "pb-20 "
}`}
>
{folders && folders.length > 0 && (
<div className="py-2 border-b border-border">
<div className="text-xs text-subtle flex pb-0.5 mb-1.5 mt-2 font-bold">
Chat Folders
</div>
<FolderList
newFolderId={newFolderId}
folders={folders}
currentChatId={currentChatId}
openedFolders={openedFolders}
showShareModal={showShareModal}
showDeleteModal={showDeleteModal}
/>
</div>
)}
<div
onDragOver={(event) => {
event.preventDefault();
setIsDragOver(true);
}}
onDragLeave={() => setIsDragOver(false)}
onDrop={handleDropToRemoveFromFolder}
className={`pt-1 transition duration-300 ease-in-out mr-3 ${
isDragOver ? "bg-hover" : ""
} rounded-md`}

View File

@@ -0,0 +1,361 @@
import React, { useState, useEffect } from "react";
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
import { Modal } from "@/components/Modal";
import {
Folder as FolderIcon,
File as FileIcon,
ChevronDown,
ChevronRight,
} from "lucide-react";
export interface UserFolder {
id: number;
name: string;
parent_id: number | null;
}
export interface UserFile {
id: number;
name: string;
parent_folder_id: number | null;
}
interface FolderNode extends UserFolder {
children: FolderNode[];
files: UserFile[];
}
interface FilePickerProps {
isOpen: boolean;
onClose: () => void;
allFolders: UserFolder[];
setSelectedFolders: (folders: UserFolder[]) => void;
onSave: (selectedItems: { files: number[]; folders: number[] }) => void;
allFiles: UserFile[];
setUserFiles: (files: UserFile[]) => void;
selectedFolders: UserFolder[];
userFiles: UserFile[];
}
function buildTree(folders: UserFolder[], files: UserFile[]): FolderNode[] {
const folderMap: { [key: number]: FolderNode } = {};
folders.forEach((folder) => {
folderMap[folder.id] = { ...folder, children: [], files: [] };
});
files.forEach((file) => {
if (file.parent_folder_id !== null && folderMap[file.parent_folder_id]) {
folderMap[file.parent_folder_id].files.push(file);
}
});
const roots: FolderNode[] = [];
Object.values(folderMap).forEach((folder) => {
if (folder.parent_id === null) {
roots.push(folder);
} else if (folderMap[folder.parent_id]) {
folderMap[folder.parent_id].children.push(folder);
}
});
return roots;
}
const FolderTreeItem: React.FC<{
node: FolderNode;
selectedItems: { files: number[]; folders: number[] };
setSelectedItems: React.Dispatch<
React.SetStateAction<{ files: number[]; folders: number[] }>
>;
parentNode?: FolderNode;
}> = ({ node, selectedItems, setSelectedItems, parentNode }) => {
const [isOpen, setIsOpen] = useState(true);
const toggleFolder = () => {
setIsOpen(!isOpen);
};
const isFolderSelected = selectedItems.folders.includes(node.id);
const getAllDescendantIds = (
folder: FolderNode
): { folderIds: number[]; fileIds: number[] } => {
let folderIds: number[] = [];
let fileIds: number[] = [];
const traverse = (node: FolderNode) => {
folderIds.push(node.id);
fileIds.push(...node.files.map((file) => file.id));
node.children.forEach(traverse);
};
traverse(folder);
return { folderIds, fileIds };
};
const shouldFolderBeSelected = (
folder: FolderNode,
newlySelectedItem: number,
newlySelectedItemType: "file" | "folder"
): boolean => {
const allFilesSelected = folder.files.every((file) => {
const isSelected =
selectedItems.files.includes(file.id) ||
(newlySelectedItemType === "file" && newlySelectedItem === file.id);
return isSelected;
});
const allChildrenSelected = folder.children.every((child) => {
const isSelected =
selectedItems.folders.includes(child.id) ||
(newlySelectedItemType === "folder" && newlySelectedItem === child.id);
return isSelected;
});
const shouldBeSelected = allFilesSelected && allChildrenSelected;
return shouldBeSelected;
};
const updateParentFolderSelection = (currentNode: FolderNode) => {
if (parentNode) {
// const shouldSelect = shouldFolderBeSelected(currentNode);
// setSelectedItems((prev) => {
// const newFolders = shouldSelect
// ? Array.from(new Set([...prev.folders, currentNode.id]))
// : prev.folders.filter((id) => id !== currentNode.id);
// return { ...prev, folders: newFolders };
// });
}
};
const handleFolderSelect = () => {
setSelectedItems((prev) => {
const { folderIds, fileIds } = getAllDescendantIds(node);
if (isFolderSelected) {
const newState = {
folders: prev.folders.filter((id) => !folderIds.includes(id)),
files: prev.files.filter((id) => !fileIds.includes(id)),
};
setTimeout(() => updateParentFolderSelection(node), 0);
return newState;
} else {
const newState = {
folders: Array.from(new Set([...prev.folders, ...folderIds])),
files: Array.from(new Set([...prev.files, ...fileIds])),
};
setTimeout(() => updateParentFolderSelection(node), 0);
return newState;
}
});
};
const handleFileSelect = (fileId: number) => {
setSelectedItems((prev) => {
const newFiles = prev.files.includes(fileId)
? prev.files.filter((id) => id !== fileId)
: [...prev.files, fileId];
const newState = { ...prev, files: newFiles };
setTimeout(() => {
const shouldSelect = shouldFolderBeSelected(node, fileId, "file");
setSelectedItems((prevState) => {
const newFolders = shouldSelect
? Array.from(new Set([...prevState.folders, node.id]))
: prevState.folders.filter((id) => id !== node.id);
updateParentFolderSelection(node);
return { ...prevState, folders: newFolders };
});
}, 100);
return newState;
});
};
return (
<li className="my-1">
<div className="flex items-center">
{node.children.length > 0 || node.files.length > 0 ? (
<button onClick={toggleFolder} className="mr-1">
{isOpen ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</button>
) : (
<div className="mr-4" />
)}
<Checkbox
checked={isFolderSelected}
onCheckedChange={handleFolderSelect}
/>
<FolderIcon className="ml-2 mr-1 h-5 w-5 text-text-600" />
<span className="ml-1">
{node.name}
{node.id}
</span>
</div>
{isOpen && (
<ul className="ml-6">
{node.children.map((child) => (
<FolderTreeItem
key={child.id}
node={child}
selectedItems={selectedItems}
setSelectedItems={setSelectedItems}
parentNode={node}
/>
))}
{node.files.map((file) => (
<li key={file.id} className="my-1">
<div className="flex items-center">
<Checkbox
checked={selectedItems.files.includes(file.id)}
onCheckedChange={() => {
handleFileSelect(file.id);
}}
/>
<FileIcon className="ml-2 mr-1 h-5 w-5 text-text-600" />
<span className="ml-1">
{file.name}
{file.id}
</span>
</div>
</li>
))}
</ul>
)}
</li>
);
};
export const FilePicker: React.FC<FilePickerProps> = ({
isOpen,
setSelectedFolders,
setUserFiles,
allFolders,
allFiles,
selectedFolders,
userFiles,
onClose,
onSave,
}) => {
const [fileSystem, setFileSystem] = useState<FolderNode[]>([]);
const [selectedItems, setSelectedItems] = useState<{
files: number[];
folders: number[];
}>({
files: userFiles.map((file) => file.id),
folders: selectedFolders.map((folder) => folder.id),
});
useEffect(() => {
if (isOpen) {
const loadFileSystem = async () => {
const response = await fetch("/api/user/file-system");
const data = await response.json();
const tree = buildTree(data.folders, data.files);
setFileSystem(tree);
};
loadFileSystem();
}
}, [isOpen]);
const getAllDescendantIds = (
folder: FolderNode
): { folderIds: number[]; fileIds: number[] } => {
let folderIds: number[] = [];
let fileIds: number[] = [];
const traverse = (node: FolderNode) => {
folderIds.push(node.id);
fileIds.push(...node.files.map((file) => file.id));
node.children.forEach(traverse);
};
traverse(folder);
return { folderIds, fileIds };
};
const isFullySelected = (node: FolderNode): boolean => {
const allDescendants = getAllDescendantIds(node);
return (
allDescendants.folderIds.every((id: number) =>
selectedItems.folders.includes(id)
) &&
allDescendants.fileIds.every((id: number) =>
selectedItems.files.includes(id)
)
);
};
const getOptimizedSelection = (
nodes: FolderNode[]
): { folders: number[]; files: number[] } => {
let optimizedFolders: number[] = [];
let optimizedFiles: number[] = [];
const processNode = (node: FolderNode) => {
if (isFullySelected(node)) {
optimizedFolders.push(node.id);
} else {
node.children.forEach(processNode);
node.files.forEach((file) => {
if (selectedItems.files.includes(file.id)) {
optimizedFiles.push(file.id);
}
});
}
};
nodes.forEach(processNode);
return { folders: optimizedFolders, files: optimizedFiles };
};
const handleSave = () => {
const optimizedSelection = getOptimizedSelection(fileSystem);
setSelectedFolders(
allFolders.filter((folder) =>
optimizedSelection.folders.includes(folder.id)
)
);
const selectedFiles = optimizedSelection.files
.map((fileId) => allFiles.find((file) => file.id === fileId))
.filter((file): file is UserFile => file !== undefined);
setUserFiles(selectedFiles);
onSave(optimizedSelection);
onClose();
};
return (
<Modal
onOutsideClick={onClose}
className="max-w-xl"
title="Select Files and Folders"
>
<div className="p-4 w-full mx-auto">
<div className="max-h-96 overflow-y-auto border rounded p-2">
<ul className="list-none">
{fileSystem.map((node) => (
<FolderTreeItem
key={node.id}
node={node}
selectedItems={selectedItems}
setSelectedItems={setSelectedItems}
/>
))}
</ul>
</div>
<div className="mt-4 flex justify-end space-x-2">
<Button onClick={onClose} variant="outline">
Cancel
</Button>
<Button onClick={handleSave} variant="default">
Select
</Button>
</div>
</div>
</Modal>
);
};

View File

@@ -0,0 +1,108 @@
import React, { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Plus, Upload, RefreshCw } from "lucide-react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
interface FolderActionsProps {
onRefresh: () => void;
onCreateFolder: (folderName: string) => void;
onUploadFiles: (files: FileList) => void;
}
export function FolderActions({
onRefresh,
onCreateFolder,
onUploadFiles,
}: FolderActionsProps) {
const [newFolderName, setNewFolderName] = useState("");
const [isCreatingFolder, setIsCreatingFolder] = useState(false);
const handleCreateFolder = () => {
if (newFolderName.trim()) {
onCreateFolder(newFolderName.trim());
setNewFolderName("");
setIsCreatingFolder(false);
}
};
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (files) {
onUploadFiles(files);
}
};
return (
<div className="flex items-center space-x-2">
<Button
onClick={onRefresh}
variant="outline"
size="sm"
className="border-background-300 hover:bg-background-100"
>
<RefreshCw className="h-4 w-4 text-text-600" />
</Button>
<Popover open={isCreatingFolder} onOpenChange={setIsCreatingFolder}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="border-background-300 hover:bg-background-100"
>
<Plus className="h-4 w-4 text-text-600" />
</Button>
</PopoverTrigger>
{isCreatingFolder && (
<PopoverContent className="w-56 p-3 bg-white shadow-md rounded-md">
<div className="space-y-2 flex flex-col">
<Input
type="text"
placeholder="New folder name"
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
className="!w-full p-1 flex text-sm border border-background-300 focus:border-background-500 rounded"
/>
<div className="flex justify-between space-x-2">
<Button
onClick={handleCreateFolder}
size="sm"
className="bg-background-800 hover:bg-background-900 text-white text-xs"
>
Create
</Button>
<Button
onClick={() => setIsCreatingFolder(false)}
variant="outline"
size="sm"
className="border border-background-300 hover:bg-background-100 text-xs"
>
Cancel
</Button>
</div>
</div>
</PopoverContent>
)}
</Popover>
<Button
variant="outline"
size="sm"
className="border-background-300 hover:bg-background-100"
onClick={() => document.getElementById("file-upload")?.click()}
>
<Upload className="h-4 w-4 text-text-600" />
</Button>
<input
id="file-upload"
type="file"
multiple
onChange={handleFileUpload}
className="hidden"
/>
</div>
);
}

View File

@@ -0,0 +1,42 @@
import React from "react";
import { ChevronRight } from "lucide-react";
interface FolderBreadcrumbProps {
parents: { name: string; id: number }[];
currentFolder: { name: string; id: number };
onBreadcrumbClick: (id: number) => void;
}
export function FolderBreadcrumb({
parents,
onBreadcrumbClick,
currentFolder,
}: FolderBreadcrumbProps) {
return (
<div className="flex items-center space-x-2 text-sm text-text-500 mb-4">
<span
className="cursor-pointer hover:text-text-700"
onClick={() => onBreadcrumbClick(-1)}
>
Root
</span>
{parents.map((parent, index) => (
<React.Fragment key={index}>
<ChevronRight className="h-4 w-4" />
<span
className="cursor-pointer hover:text-text-700"
onClick={() => onBreadcrumbClick(parent.id)}
>
{parent.name}
</span>
</React.Fragment>
))}
{currentFolder && currentFolder.id !== -1 && (
<>
<ChevronRight className="h-4 w-4" />
<span className="text-text-700">{currentFolder.name}</span>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,143 @@
import React, { useState } from "react";
import { MoveFileModal } from "./MoveFileModal";
import { FileItem, FolderItem } from "./MyDocumenItem";
interface FolderContentsProps {
pageLimit: number;
currentPage: number;
contents: {
children: { name: string; id: number }[];
files: { name: string; id: number; document_id: string }[];
};
onFolderClick: (folderId: number) => void;
currentFolder: number;
onDeleteItem: (itemId: number, isFolder: boolean) => void;
onDownloadItem: (documentId: string) => void;
onMoveItem: (
itemId: number,
destinationFolderId: number | null,
isFolder: boolean
) => void;
setPresentingDocument: (
document_id: string,
semantic_identifier: string
) => void;
onRenameItem: (itemId: number, newName: string, isFolder: boolean) => void;
}
export function FolderContents({
pageLimit,
currentPage,
setPresentingDocument,
contents,
onFolderClick,
currentFolder,
onDeleteItem,
onDownloadItem,
onMoveItem,
onRenameItem,
}: FolderContentsProps) {
const [isMoveModalOpen, setIsMoveModalOpen] = useState(false);
const [itemToMove, setItemToMove] = useState<{
id: number;
name: string;
isFolder: boolean;
} | null>(null);
const [editingItem, setEditingItem] = useState<{
id: number;
name: string;
isFolder: boolean;
} | null>(null);
const handleMove = (destinationFolderId: number | null) => {
if (itemToMove) {
onMoveItem(itemToMove.id, destinationFolderId, itemToMove.isFolder);
setIsMoveModalOpen(false);
setItemToMove(null);
}
};
const handleRename = (itemId: number, newName: string, isFolder: boolean) => {
onRenameItem(itemId, newName, isFolder);
setEditingItem(null);
};
const handleDragStart = (
e: React.DragEvent<HTMLDivElement>,
item: { id: number; isFolder: boolean; name: string }
) => {
e.dataTransfer.setData("application/json", JSON.stringify(item));
};
const handleDrop = (
e: React.DragEvent<HTMLDivElement>,
targetFolderId: number
) => {
e.preventDefault();
const item = JSON.parse(e.dataTransfer.getData("application/json"));
if (item && typeof item.id === "number") {
// Move the dragged item to the target folder
onMoveItem(item.id, targetFolderId, item.isFolder);
}
};
// we need the logic to ben let's show all the files firs then folders (ie if we have 4 files and 10 foldres and page size of 3,
// First index: first 3 files,
// nexte index; lat fileand first two folders, etc.
return (
<div className="flex-grow" onDragOver={(e) => e.preventDefault()}>
{contents.files
.slice(pageLimit * (currentPage - 1), pageLimit * currentPage)
.map((file) => (
<FileItem
setPresentingDocument={setPresentingDocument}
key={file.id}
file={file}
onDeleteItem={onDeleteItem}
onDownloadItem={onDownloadItem}
onMoveItem={(id) => {
setItemToMove({ id, name: file.name, isFolder: false });
setIsMoveModalOpen(true);
}}
editingItem={editingItem}
setEditingItem={setEditingItem}
handleRename={handleRename}
onDragStart={handleDragStart}
/>
))}
{contents.children
.slice(pageLimit * (currentPage - 1), pageLimit * currentPage)
.map((folder) => (
<FolderItem
key={folder.id}
folder={folder}
onFolderClick={onFolderClick}
onDeleteItem={onDeleteItem}
onMoveItem={(id) => {
setItemToMove({ id, name: folder.name, isFolder: true });
setIsMoveModalOpen(true);
}}
editingItem={editingItem}
setEditingItem={setEditingItem}
handleRename={handleRename}
onDragStart={handleDragStart}
onDrop={handleDrop}
/>
))}
{itemToMove && (
<MoveFileModal
isOpen={isMoveModalOpen}
onClose={() => setIsMoveModalOpen(false)}
onMove={handleMove}
fileName={itemToMove.name}
currentFolderId={currentFolder}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,47 @@
import React from "react";
import { Folder as FolderIcon } from "lucide-react";
interface FolderNode {
id: number;
name: string;
parent_id: number | null;
children?: FolderNode[];
}
interface FolderTreeProps {
treeData: FolderNode[];
onFolderClick: (folderId: number) => void;
}
function renderTree(
nodes: FolderNode[],
onFolderClick: (folderId: number) => void
) {
return (
<ul className="ml-4 list-none">
{nodes.map((node) => (
<li key={node.id} className="my-1">
<div
className="flex items-center cursor-pointer hover:text-text-700"
onClick={() => onFolderClick(node.id)}
>
<FolderIcon className="mr-1 h-4 w-4 text-text-600" />
<span>{node.name}</span>
</div>
{node.children &&
node.children.length > 0 &&
renderTree(node.children, onFolderClick)}
</li>
))}
</ul>
);
}
export function FolderTree({ treeData, onFolderClick }: FolderTreeProps) {
return (
<div className="w-64 border-r border-background-300 p-2 overflow-y-auto hidden lg:block">
<h2 className="font-bold text-sm mb-2">Folders</h2>
{renderTree(treeData, onFolderClick)}
</div>
);
}

View File

@@ -0,0 +1,113 @@
import React, { useState, useEffect } from "react";
import { Folder } from "lucide-react";
interface Folder {
id: number | null;
name: string;
}
interface MoveFileModalProps {
isOpen: boolean;
onClose: () => void;
onMove: (destinationFolderId: number | null) => void;
fileName: string;
currentFolderId: number | null;
}
export function MoveFileModal({
isOpen,
onClose,
onMove,
fileName,
currentFolderId,
}: MoveFileModalProps) {
const [folders, setFolders] = useState<Folder[]>([]);
const [selectedFolder, setSelectedFolder] = useState<Folder | null>(null);
useEffect(() => {
if (isOpen) {
const loadFolders = async () => {
const res = await fetch("/api/user/folder");
const data = await res.json();
setFolders(data);
};
loadFolders();
}
}, [isOpen]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-96">
<h2 className="text-xl font-semibold mb-4">
Move &quot;{fileName}&quot;
</h2>
<div className="mb-4">
<span className="font-medium">Choose a folder:</span>
<div className="max-h-60 overflow-y-auto mt-2 border rounded">
{folders.map((folder) => (
<div
key={folder.id}
className="flex items-center justify-between py-2 px-3 hover:bg-background-100 cursor-pointer"
onClick={() => setSelectedFolder(folder)}
>
<div className="flex items-center">
<Folder className="mr-2 h-5 w-5" />
<span>{folder.name}</span>
{folder.id === currentFolderId && (
<span className="text-sm my-auto ml-2 text-text-500">
(Current folder)
</span>
)}
</div>
<div
className={`w-4 h-4 rounded-full border ${
selectedFolder?.id === folder.id
? "bg-blue-600 border-blue-600"
: "border-blue-300 border-2"
}`}
/>
</div>
))}
<div
className="flex items-center justify-between py-2 px-3 hover:bg-background-100 cursor-pointer"
onClick={() => setSelectedFolder({ id: null, name: "Root" })}
>
<div className="flex items-center">
<Folder className="mr-2 h-5 w-5" />
<span>Root</span>
</div>
<div
className={`w-4 h-4 rounded-full border ${
selectedFolder?.id === null
? "bg-blue-600 border-blue-600"
: "border-blue-300 border-2"
}`}
/>
</div>
</div>
</div>
<div className="flex justify-end space-x-2">
<button
className="px-4 py-2 cursor-pointer text-text-600 hover:bg-background-100 rounded"
onClick={onClose}
>
Cancel
</button>
<button
className={`px-4 py-2 text-white rounded ${
selectedFolder
? "bg-blue-600 hover:bg-blue-700 cursor-pointer"
: "bg-blue-400 cursor-not-allowed"
}`}
onClick={() => selectedFolder && onMove(selectedFolder.id)}
disabled={!selectedFolder}
>
Move
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,343 @@
import React, { useEffect, useRef, useState } from "react";
import {
FolderIcon,
FileIcon,
DownloadIcon,
TrashIcon,
PencilIcon,
InfoIcon,
CheckIcon,
XIcon,
} from "lucide-react";
interface FolderItemProps {
folder: { name: string; id: number };
onFolderClick: (folderId: number) => void;
onDeleteItem: (itemId: number, isFolder: boolean) => void;
onMoveItem: (folderId: number) => void;
editingItem: { id: number; name: string; isFolder: boolean } | null;
setEditingItem: React.Dispatch<
React.SetStateAction<{ id: number; name: string; isFolder: boolean } | null>
>;
handleRename: (id: number, newName: string, isFolder: boolean) => void;
onDragStart: (
e: React.DragEvent<HTMLDivElement>,
item: { id: number; isFolder: boolean; name: string }
) => void;
onDrop: (e: React.DragEvent<HTMLDivElement>, targetFolderId: number) => void;
}
export function FolderItem({
folder,
onFolderClick,
onDeleteItem,
onMoveItem,
editingItem,
setEditingItem,
handleRename,
onDragStart,
onDrop,
}: FolderItemProps) {
const [showMenu, setShowMenu] = useState<undefined | number>(undefined);
const [newName, setNewName] = useState(folder.name);
const isEditing =
editingItem && editingItem.id === folder.id && editingItem.isFolder;
const folderItemRef = useRef<HTMLDivElement>(null);
const handleContextMenu = (e: React.MouseEvent) => {
console.log("Context menu clicked");
e.preventDefault();
const xPos =
e.clientX - folderItemRef.current?.getBoundingClientRect().left! - 40;
setShowMenu(xPos);
};
const startEditing = () => {
setEditingItem({ id: folder.id, name: folder.name, isFolder: true });
setNewName(folder.name);
setShowMenu(undefined);
};
const submitRename = (e: React.MouseEvent) => {
e.stopPropagation();
handleRename(folder.id, newName, true);
};
const cancelEditing = (e: React.MouseEvent) => {
e.stopPropagation();
setEditingItem(null);
setNewName(folder.name);
};
useEffect(() => {
document.addEventListener("click", (e) => {
setShowMenu(undefined);
});
return () => {
document.removeEventListener("click", () => {});
};
}, [showMenu]);
return (
<div
ref={folderItemRef}
className="flex items-center justify-between p-2 hover:bg-background-100 cursor-pointer relative"
onClick={() => !isEditing && onFolderClick(folder.id)}
onContextMenu={handleContextMenu}
draggable={!isEditing}
onDragStart={(e) =>
onDragStart(e, { id: folder.id, isFolder: true, name: folder.name })
}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => onDrop(e, folder.id)}
>
<div className="flex items-center">
<FolderIcon className="mr-2" />
{isEditing ? (
<div className="flex items-center">
<input
onClick={(e) => e.stopPropagation()}
type="text"
value={newName}
onChange={(e) => {
e.stopPropagation();
setNewName(e.target.value);
}}
className="border rounded px-2 py-1 mr-2"
autoFocus
/>
<button
onClick={submitRename}
className="text-green-500 hover:text-green-700 mr-2"
>
<CheckIcon className="h-4 w-4" />
</button>
<button
onClick={cancelEditing}
className="text-red-500 hover:text-red-700"
>
<XIcon className="h-4 w-4" />
</button>
</div>
) : (
<span>{folder.name}</span>
)}
</div>
{showMenu && !isEditing && (
<div
className="absolute bg-white border rounded shadow py-1 right-0 top-full mt-1 z-50"
style={{ left: showMenu }}
>
<button
className="block w-full text-left px-4 py-2 hover:bg-background-100 text-sm"
onClick={(e) => {
e.stopPropagation();
startEditing();
}}
>
Rename
</button>
<button
className="block w-full text-left px-4 py-2 hover:bg-background-100 text-sm"
onClick={(e) => {
e.stopPropagation();
onMoveItem(folder.id);
setShowMenu(undefined);
}}
>
Move
</button>
<button
className="block w-full text-left px-4 py-2 hover:bg-background-100 text-sm text-red-600"
onClick={(e) => {
e.stopPropagation();
onDeleteItem(folder.id, true);
setShowMenu(undefined);
}}
>
Delete
</button>
</div>
)}
</div>
);
}
interface FileItemProps {
file: { name: string; id: number; document_id: string };
onDeleteItem: (itemId: number, isFolder: boolean) => void;
onDownloadItem: (documentId: string) => void;
onMoveItem: (fileId: number) => void;
editingItem: { id: number; name: string; isFolder: boolean } | null;
setEditingItem: React.Dispatch<
React.SetStateAction<{ id: number; name: string; isFolder: boolean } | null>
>;
setPresentingDocument: (
document_id: string,
semantic_identifier: string
) => void;
handleRename: (fileId: number, newName: string, isFolder: boolean) => void;
onDragStart: (
e: React.DragEvent<HTMLDivElement>,
item: { id: number; isFolder: boolean; name: string }
) => void;
}
export function FileItem({
setPresentingDocument,
file,
onDeleteItem,
onDownloadItem,
onMoveItem,
editingItem,
setEditingItem,
handleRename,
onDragStart,
}: FileItemProps) {
const [showMenu, setShowMenu] = useState<undefined | number>();
const [newFileName, setNewFileName] = useState(file.name);
const isEditing =
editingItem && editingItem.id === file.id && !editingItem.isFolder;
const fileItemRef = useRef<HTMLDivElement>(null);
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
const xPos =
e.clientX - fileItemRef.current?.getBoundingClientRect().left! - 40;
setShowMenu(xPos);
};
useEffect(() => {
document.addEventListener("click", (e) => {
if (fileItemRef.current?.contains(e.target as Node)) {
return;
}
setShowMenu(undefined);
});
document.addEventListener("contextmenu", (e) => {
if (fileItemRef.current?.contains(e.target as Node)) {
return;
}
setShowMenu(undefined);
});
return () => {
document.removeEventListener("click", () => {});
document.removeEventListener("contextmenu", () => {});
};
}, [showMenu]);
const startEditing = () => {
setEditingItem({ id: file.id, name: file.name, isFolder: false });
setNewFileName(file.name);
setShowMenu(undefined);
};
const submitRename = (e: React.MouseEvent) => {
e.stopPropagation();
handleRename(file.id, newFileName, false);
};
const cancelEditing = (e: React.MouseEvent) => {
e.stopPropagation();
setEditingItem(null);
setNewFileName(file.name);
};
return (
<div
ref={fileItemRef}
key={file.id}
className="flex items-center w-full justify-between p-2 hover:bg-background-100 cursor-pointer relative"
onContextMenu={handleContextMenu}
draggable={!isEditing}
onDragStart={(e) =>
onDragStart(e, { id: file.id, isFolder: false, name: file.name })
}
>
<button
onClick={() => setPresentingDocument(file.document_id, file.name)}
className="flex items-center flex-grow"
>
<FileIcon className="mr-2" />
{isEditing ? (
<div className="flex items-center">
<input
onClick={(e) => e.stopPropagation()}
type="text"
value={newFileName}
onChange={(e) => {
e.stopPropagation();
setNewFileName(e.target.value);
}}
className="border rounded px-2 py-1 mr-2"
autoFocus
/>
<button
onClick={submitRename}
className="text-green-500 hover:text-green-700 mr-2"
>
<CheckIcon className="h-4 w-4" />
</button>
<button
onClick={cancelEditing}
className="text-red-500 hover:text-red-700"
>
<XIcon className="h-4 w-4" />
</button>
</div>
) : (
<p className="flex text-wrap text-left line-clamp-2">{file.name}</p>
)}
</button>
{showMenu && !isEditing && (
<div
className="absolute bg-white max-w-40 border rounded shadow py-1 right-0 top-full mt-1 z-50"
style={{ left: showMenu }}
>
<button
className="block w-full text-left px-4 py-2 hover:bg-background-100 text-sm"
onClick={(e) => {
e.stopPropagation();
onDownloadItem(file.document_id);
setShowMenu(undefined);
}}
>
Download
</button>
<button
className="block w-full text-left px-4 py-2 hover:bg-background-100 text-sm"
onClick={(e) => {
e.stopPropagation();
startEditing();
}}
>
Rename
</button>
<button
className="block w-full text-left px-4 py-2 hover:bg-background-100 text-sm"
onClick={(e) => {
e.stopPropagation();
onMoveItem(file.id);
setShowMenu(undefined);
}}
>
Move
</button>
<button
className="block w-full text-left px-4 py-2 hover:bg-background-100 text-sm text-red-600"
onClick={(e) => {
e.stopPropagation();
onDeleteItem(file.id, false);
setShowMenu(undefined);
}}
>
Delete
</button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,347 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { usePopup } from "@/components/admin/connectors/Popup";
import { FolderActions } from "./FolderActions";
import { FolderBreadcrumb } from "./FolderBreadcrumb";
import { FolderContents } from "./FolderContents";
import TextView from "@/components/chat_search/TextView";
import { Button } from "@/components/ui/button";
import { MinimalOnyxDocument } from "@/lib/search/interfaces";
import { PageSelector } from "@/components/PageSelector";
interface FolderResponse {
children: { name: string; id: number }[];
files: { name: string; document_id: string; id: number }[];
parents: { name: string; id: number }[];
name: string;
id: number;
document_id: string;
}
export default function MyDocuments() {
const [currentFolder, setCurrentFolder] = useState<number>(-1);
const [folderContents, setFolderContents] = useState<FolderResponse | null>(
null
);
const [page, setPage] = useState<number>(1);
const pageLimit = 10;
const searchParams = useSearchParams();
const router = useRouter();
const { popup, setPopup } = usePopup();
const [presentingDocument, setPresentingDocument] =
useState<MinimalOnyxDocument | null>(null);
const folderIdFromParams = parseInt(searchParams.get("path") || "-1", 10);
const fetchFolderContents = useCallback(async (folderId: number) => {
try {
const response = await fetch(`/api/user/folder/${folderId}?page=${page}`);
if (!response.ok) {
throw new Error("Failed to fetch folder contents");
}
const data = await response.json();
setFolderContents(data);
} catch (error) {
console.error("Error fetching folder contents:", error);
setPopup({
message: "Failed to fetch folder contents",
type: "error",
});
}
}, []);
useEffect(() => {
setCurrentFolder(folderIdFromParams);
fetchFolderContents(folderIdFromParams);
}, [searchParams]);
const refreshFolderContents = useCallback(() => {
fetchFolderContents(currentFolder);
}, [fetchFolderContents, currentFolder]);
const handleFolderClick = (id: number) => {
router.push(`/my-documents?path=${id}`);
setPage(1);
};
const handleBreadcrumbClick = (folderId: number) => {
router.push(`/my-documents?path=${folderId}`);
setPage(1);
};
const handleCreateFolder = async (folderName: string) => {
try {
const response = await fetch("/api/user/folder", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: folderName, parent_id: currentFolder }),
});
if (response.ok) {
refreshFolderContents();
setPopup({
message: "Folder created successfully",
type: "success",
});
} else {
throw new Error("Failed to create folder");
}
} catch (error) {
console.error("Error creating folder:", error);
setPopup({
message: "Failed to create folder",
type: "error",
});
}
};
const handleDeleteItem = async (itemId: number, isFolder: boolean) => {
try {
const endpoint = isFolder
? `/api/user/folder/${itemId}`
: `/api/user/file/${itemId}`;
const response = await fetch(endpoint, {
method: "DELETE",
});
if (response.ok) {
fetchFolderContents(currentFolder);
setPopup({
message: `${isFolder ? "Folder" : "File"} deleted successfully`,
type: "success",
});
} else {
throw new Error(`Failed to delete ${isFolder ? "folder" : "file"}`);
}
} catch (error) {
console.error("Error deleting item:", error);
setPopup({
message: `Failed to delete ${isFolder ? "folder" : "file"}`,
type: "error",
});
}
refreshFolderContents();
};
const handleUploadFiles = async (files: FileList) => {
const formData = new FormData();
for (let i = 0; i < files.length; i++) {
formData.append("files", files[i]);
}
formData.append(
"folder_id",
currentFolder.toString() === "-1" ? "" : currentFolder.toString()
);
try {
const response = await fetch("/api/user/file/upload", {
method: "POST",
body: formData,
});
if (response.ok) {
await fetchFolderContents(currentFolder);
setPopup({
message: "Files uploaded successfully",
type: "success",
});
} else {
throw new Error("Failed to upload files");
}
} catch (error) {
console.error("Error uploading files:", error);
setPopup({
message: "Failed to upload files",
type: "error",
});
}
await refreshFolderContents();
setPage(1);
};
const handleMoveItem = async (
itemId: number,
destinationFolderId: number | null,
isFolder: boolean
) => {
const endpoint = isFolder
? `/api/user/folder/${itemId}/move`
: `/api/user/file/${itemId}/move`;
try {
const response = await fetch(endpoint, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
new_parent_id: destinationFolderId,
[isFolder ? "folder_id" : "file_id"]: itemId,
}),
});
if (response.ok) {
fetchFolderContents(currentFolder);
setPopup({
message: `${isFolder ? "Folder" : "File"} moved successfully`,
type: "success",
});
} else {
throw new Error("Failed to move item");
}
} catch (error) {
console.error("Error moving item:", error);
setPopup({
message: "Failed to move item",
type: "error",
});
}
refreshFolderContents();
};
const handleDownloadItem = async (documentId: string) => {
try {
const response = await fetch(
`/api/chat/file/${encodeURIComponent(documentId)}`,
{
method: "GET",
}
);
if (!response.ok) {
throw new Error("Failed to fetch file");
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const contentDisposition = response.headers.get("Content-Disposition");
const fileName = contentDisposition
? contentDisposition.split("filename=")[1]
: "document";
const link = document.createElement("a");
link.href = url;
link.download = fileName || "document";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
} catch (error) {
console.error("Error downloading file:", error);
setPopup({
message: "Failed to download file",
type: "error",
});
}
};
const onRenameItem = async (
itemId: number,
newName: string,
isFolder: boolean
) => {
const endpoint = isFolder
? `/api/user/folder/${itemId}?name=${encodeURIComponent(newName)}`
: `/api/user/file/${itemId}/rename?name=${encodeURIComponent(newName)}`;
try {
const response = await fetch(endpoint, {
method: "PUT",
});
if (response.ok) {
fetchFolderContents(currentFolder);
setPopup({
message: `${isFolder ? "Folder" : "File"} renamed successfully`,
type: "success",
});
} else {
throw new Error("Failed to rename item");
}
} catch (error) {
console.error("Error renaming item:", error);
setPopup({
message: `Failed to rename ${isFolder ? "folder" : "file"}`,
type: "error",
});
}
};
return (
<div className="container mx-auto p-4">
{presentingDocument && (
<TextView
presentingDocument={presentingDocument}
onClose={() => setPresentingDocument(null)}
/>
)}
{popup}
<div className="flex-grow">
<FolderBreadcrumb
currentFolder={{
name: folderContents ? folderContents.name : "",
id: currentFolder,
}}
parents={folderContents?.parents || []}
onBreadcrumbClick={handleBreadcrumbClick}
/>
<Card>
<CardHeader>
<CardTitle>Folder Contents</CardTitle>
<FolderActions
onRefresh={() => fetchFolderContents(currentFolder)}
onCreateFolder={handleCreateFolder}
onUploadFiles={handleUploadFiles}
/>
</CardHeader>
<CardContent>
{folderContents ? (
folderContents.files.length > 0 ||
folderContents.children.length > 0 ? (
<FolderContents
currentPage={page}
pageLimit={pageLimit}
setPresentingDocument={(
document_id: string,
semantic_identifier: string
) =>
setPresentingDocument({ document_id, semantic_identifier })
}
contents={folderContents}
onFolderClick={handleFolderClick}
currentFolder={currentFolder}
onDeleteItem={handleDeleteItem}
onDownloadItem={handleDownloadItem}
onMoveItem={handleMoveItem}
onRenameItem={onRenameItem}
/>
) : (
<p>No content in this folder</p>
)
) : (
<p>Loading...</p>
)}
</CardContent>
</Card>
<div className="mt-3 flex">
<div className="mx-auto">
<PageSelector
currentPage={page}
totalPages={Math.ceil(
((folderContents?.files?.length || 0) +
(folderContents?.children?.length || 0)) /
pageLimit
)}
onPageChange={(newPage) => {
setPage(newPage);
window.scrollTo({
top: 0,
left: 0,
behavior: "smooth",
});
}}
/>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,20 @@
"use client";
import { AssistantsPageTitle } from "../assistants/AssistantsPageTitle";
import SidebarWrapper from "../assistants/SidebarWrapper";
import MyDocuments from "./MyDocuments";
export default function WrappedUserDocuments({
initiallyToggled,
}: {
initiallyToggled: boolean;
}) {
return (
<SidebarWrapper size="lg" page="chat" initiallyToggled={initiallyToggled}>
<div className="mx-auto w-searchbar-xs 2xl:w-searchbar-sm 3xl:w-searchbar">
<AssistantsPageTitle>My Documents</AssistantsPageTitle>
<MyDocuments />
</div>
</SidebarWrapper>
);
}

View File

@@ -0,0 +1,46 @@
import { fetchChatData } from "@/lib/chat/fetchChatData";
import WrappedDocuments from "./WrappedDocuments";
import { redirect } from "next/navigation";
import { ChatProvider } from "@/components/context/ChatContext";
export default async function GalleryPage(props: {
searchParams: Promise<{ [key: string]: string }>;
}) {
const searchParams = await props.searchParams;
const data = await fetchChatData(searchParams);
if ("redirect" in data) {
redirect(data.redirect);
}
const {
chatSessions,
toggleSidebar,
shouldShowWelcomeModal,
availableSources,
ccPairs,
documentSets,
tags,
llmProviders,
defaultAssistantId,
} = data;
return (
<ChatProvider
value={{
chatSessions,
availableSources,
ccPairs,
documentSets,
tags,
availableDocumentSets: documentSets,
availableTags: tags,
llmProviders,
shouldShowWelcomeModal,
defaultAssistantId,
}}
>
<WrappedDocuments initiallyToggled={toggleSidebar} />
</ChatProvider>
);
}

View File

@@ -0,0 +1,64 @@
import { useState, useEffect, useCallback } from "react";
// API functions
const fetchDocuments = async (): Promise<Document[]> => {
const response = await fetch("/api/manage/admin/documents");
if (!response.ok) {
throw new Error("Failed to fetch documents");
}
return response.json();
};
const deleteDocument = async (documentId: number): Promise<void> => {
const response = await fetch(`/api/manage/admin/documents/${documentId}`, {
method: "DELETE",
});
if (!response.ok) {
throw new Error("Failed to delete document");
}
};
export interface Document {
id: number;
document_id: string;
}
// Custom hook
export const useDocuments = () => {
const [documents, setDocuments] = useState<Document[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadDocuments = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const fetchedDocuments = await fetchDocuments();
setDocuments(fetchedDocuments);
} catch (err) {
setError("Failed to load documents err: " + err);
} finally {
setIsLoading(false);
}
}, []);
const handleDeleteDocument = async (documentId: number) => {
try {
await deleteDocument(documentId);
await loadDocuments();
} catch (err) {
setError("Failed to delete document");
}
};
useEffect(() => {
loadDocuments();
}, [loadDocuments]);
return {
documents,
isLoading,
error,
loadDocuments,
handleDeleteDocument,
};
};

View File

@@ -7,9 +7,6 @@ const getPaginationOptions = (
pageCount: number
): number[] => {
const paginationOptions = [currentPage];
// if (currentPage !== 1) {
// paginationOptions.push(currentPage)
// }
let offset = 1;

View File

@@ -9,11 +9,11 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Download, XIcon, ZoomIn, ZoomOut } from "lucide-react";
import { OnyxDocument } from "@/lib/search/interfaces";
import { OnyxDocument, MinimalOnyxDocument } from "@/lib/search/interfaces";
import { MinimalMarkdown } from "./MinimalMarkdown";
interface TextViewProps {
presentingDocument: OnyxDocument;
presentingDocument: MinimalOnyxDocument;
onClose: () => void;
}
export default function TextView({

View File

@@ -1,16 +1,9 @@
"use client";
import React, { createContext, useContext, useState } from "react";
import {
CCPairBasicInfo,
DocumentSet,
Tag,
User,
ValidSources,
} from "@/lib/types";
import { CCPairBasicInfo, DocumentSet, Tag, ValidSources } from "@/lib/types";
import { ChatSession } from "@/app/chat/interfaces";
import { LLMProviderDescriptor } from "@/app/admin/configuration/llm/interfaces";
import { Folder } from "@/app/chat/folders/interfaces";
interface ChatContextProps {
chatSessions: ChatSession[];
@@ -21,8 +14,6 @@ interface ChatContextProps {
availableDocumentSets: DocumentSet[];
availableTags: Tag[];
llmProviders: LLMProviderDescriptor[];
folders: Folder[];
openedFolders: Record<string, boolean>;
shouldShowWelcomeModal?: boolean;
shouldDisplaySourcesIncompleteModal?: boolean;
defaultAssistantId?: number;

View File

@@ -2,8 +2,14 @@ import * as React from "react";
import { cn } from "@/lib/utils";
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {
isEditing?: boolean;
width?: string;
}
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
({ className, width, style, type, ...props }, ref) => {
return (
<input
type={type}
@@ -11,6 +17,15 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
style={{
width:
width ||
`${Math.max(
1,
String(props.value || props.defaultValue || "").length
)}ch`,
...style,
}}
ref={ref}
{...props}
/>

View File

@@ -17,7 +17,6 @@ import { FullEmbeddingModelResponse } from "@/components/embedding/interfaces";
import { Settings } from "@/app/admin/settings/interfaces";
import { fetchLLMProvidersSS } from "@/lib/llm/fetchLLMs";
import { LLMProviderDescriptor } from "@/app/admin/configuration/llm/interfaces";
import { Folder } from "@/app/chat/folders/interfaces";
import { cookies, headers } from "next/headers";
import {
SIDEBAR_TOGGLED_COOKIE_NAME,
@@ -35,8 +34,6 @@ interface FetchChatDataResult {
documentSets: DocumentSet[];
tags: Tag[];
llmProviders: LLMProviderDescriptor[];
folders: Folder[];
openedFolders: Record<string, boolean>;
defaultAssistantId?: number;
toggleSidebar: boolean;
finalDocumentSidebarInitialWidth?: number;
@@ -55,7 +52,6 @@ export async function fetchChatData(searchParams: {
fetchSS("/chat/get-user-chat-sessions"),
fetchSS("/query/valid-tags"),
fetchLLMProvidersSS(),
fetchSS("/folder"),
];
let results: (
@@ -83,7 +79,6 @@ export async function fetchChatData(searchParams: {
const tagsResponse = results[5] as Response | null;
const llmProviders = (results[6] || []) as LLMProviderDescriptor[];
const foldersResponse = results[7] as Response | null;
const authDisabled = authTypeMetadata?.authType === "disabled";
@@ -176,18 +171,6 @@ export async function fetchChatData(searchParams: {
// if no connectors are setup, only show personas that are pure
// passthrough and don't do any retrieval
let folders: Folder[] = [];
if (foldersResponse?.ok) {
folders = (await foldersResponse.json()).folders as Folder[];
} else {
console.log(`Failed to fetch folders - ${foldersResponse?.status}`);
}
const openedFoldersCookie = requestCookies.get("openedFolders");
const openedFolders = openedFoldersCookie
? JSON.parse(openedFoldersCookie.value)
: {};
return {
user,
chatSessions,
@@ -196,8 +179,6 @@ export async function fetchChatData(searchParams: {
documentSets,
tags,
llmProviders,
folders,
openedFolders,
defaultAssistantId,
finalDocumentSidebarInitialWidth,
toggleSidebar,

View File

@@ -15,7 +15,6 @@ import { ChatSession } from "@/app/chat/interfaces";
import { Persona } from "@/app/admin/assistants/interfaces";
import { fetchLLMProvidersSS } from "@/lib/llm/fetchLLMs";
import { LLMProviderDescriptor } from "@/app/admin/configuration/llm/interfaces";
import { Folder } from "@/app/chat/folders/interfaces";
import { personaComparator } from "@/app/admin/assistants/lib";
import { cookies } from "next/headers";
import {
@@ -36,8 +35,6 @@ interface FetchChatDataResult {
assistants?: Persona[];
tags?: Tag[];
llmProviders?: LLMProviderDescriptor[];
folders?: Folder[];
openedFolders?: Record<string, boolean>;
defaultAssistantId?: number;
toggleSidebar?: boolean;
finalDocumentSidebarInitialWidth?: number;
@@ -52,8 +49,7 @@ type FetchOption =
| "documentSets"
| "assistants"
| "tags"
| "llmProviders"
| "folders";
| "llmProviders";
/*
NOTE: currently unused, but leaving here for future use.
@@ -72,7 +68,6 @@ export async function fetchSomeChatData(
assistants: fetchAssistantsSS,
tags: () => fetchSS("/query/valid-tags"),
llmProviders: fetchLLMProvidersSS,
folders: () => fetchSS("/folder"),
};
// Always fetch auth type metadata
@@ -129,7 +124,7 @@ export async function fetchSomeChatData(
case "assistants":
const [rawAssistantsList, assistantsFetchError] = result as [
Persona[],
string | null,
string | null
];
result.assistants = rawAssistantsList
.filter((assistant) => assistant.is_visible)
@@ -143,11 +138,6 @@ export async function fetchSomeChatData(
case "llmProviders":
result.llmProviders = result || [];
break;
case "folders":
result.folders = result?.ok
? ((await result.json()) as { folders: Folder[] }).folders
: [];
break;
}
}
@@ -185,13 +175,6 @@ export async function fetchSomeChatData(
}
}
if (fetchOptions.includes("folders")) {
const openedFoldersCookie = requestCookies.get("openedFolders");
result.openedFolders = openedFoldersCookie
? JSON.parse(openedFoldersCookie.value)
: {};
}
const defaultAssistantIdRaw = searchParams["assistantId"];
result.defaultAssistantId = defaultAssistantIdRaw
? parseInt(defaultAssistantIdRaw)

View File

@@ -45,12 +45,15 @@ export interface QuotesInfoPacket {
quotes: Quote[];
}
export interface OnyxDocument {
export interface MinimalOnyxDocument {
document_id: string;
semantic_identifier: string | null;
}
export interface OnyxDocument extends MinimalOnyxDocument {
link: string;
source_type: ValidSources;
blurb: string;
semantic_identifier: string | null;
boost: number;
hidden: boolean;
score: number;