From c744faf25e13b9f6fcb2fbba52fdd22dbd672429 Mon Sep 17 00:00:00 2001 From: pablonyx Date: Wed, 26 Feb 2025 13:14:39 -0800 Subject: [PATCH 1/2] add my docs --- .../versions/9aadf32dfeb4_add_user_files.py | 107 ++ .../agent_search/shared_graph_utils/utils.py | 2 + backend/onyx/chat/process_message.py | 19 +- backend/onyx/configs/chat_configs.py | 2 +- backend/onyx/context/search/models.py | 1 + .../search/preprocessing/preprocessing.py | 9 + backend/onyx/db/connector_credential_pair.py | 12 +- backend/onyx/db/document.py | 4 +- backend/onyx/db/document_set.py | 1 - backend/onyx/db/models.py | 101 ++ backend/onyx/db/persona.py | 37 + backend/onyx/db/user_documents.py | 175 ++ .../vespa/app_config/schemas/danswer_chunk.sd | 10 + backend/onyx/document_index/vespa/index.py | 3 + .../document_index/vespa/indexing_utils.py | 3 + .../shared_utils/vespa_request_builders.py | 60 +- .../onyx/document_index/vespa_constants.py | 2 + backend/onyx/file_processing/unstructured.py | 1 + backend/onyx/file_store/utils.py | 86 + backend/onyx/indexing/indexing_pipeline.py | 6 + backend/onyx/indexing/models.py | 6 + backend/onyx/main.py | 2 + backend/onyx/seeding/load_docs.py | 1 + backend/onyx/seeding/load_yamls.py | 26 + backend/onyx/seeding/user_folders.yaml | 6 + backend/onyx/server/documents/connector.py | 152 +- backend/onyx/server/documents/models.py | 3 +- backend/onyx/server/features/folder/api.py | 4 +- backend/onyx/server/features/folder/models.py | 4 +- backend/onyx/server/features/persona/api.py | 4 +- .../onyx/server/features/persona/models.py | 2 + backend/onyx/server/manage/llm/models.py | 34 +- .../server/query_and_chat/chat_backend.py | 82 +- backend/onyx/server/query_and_chat/models.py | 6 +- backend/onyx/server/user_documents/api.py | 443 +++++ backend/onyx/server/user_documents/models.py | 70 + backend/onyx/tools/tool_constructor.py | 28 + .../custom/custom_tool.py | 10 +- backend/scripts/debugging/onyx_vespa.py | 5 + .../docker_compose/docker-compose.prod.yml | 2 +- tsconfig.json | 20 + web/package-lock.json | 1476 +++++------------ web/package.json | 2 + .../app/admin/assistants/AssistantEditor.tsx | 281 +++- .../admin/assistants/assistantFileUtils.ts | 106 ++ web/src/app/admin/assistants/interfaces.ts | 2 + web/src/app/admin/assistants/lib.ts | 9 + .../app/admin/configuration/llm/interfaces.ts | 8 + web/src/app/assistants/SidebarWrapper.tsx | 11 +- web/src/app/chat/ChatPage.tsx | 251 ++- web/src/app/chat/folders/FolderManagement.tsx | 3 +- web/src/app/chat/input/ChatInputBar.tsx | 84 +- web/src/app/chat/input/LLMPopover.tsx | 1 + web/src/app/chat/lib.tsx | 9 + .../chat/my-documents/DocumentsContext.tsx | 454 +++++ .../app/chat/my-documents/MyDocumenItem.tsx | 342 ++++ web/src/app/chat/my-documents/MyDocuments.tsx | 380 +++++ .../chat/my-documents/WrappedDocuments.tsx | 15 + .../my-documents/[id]/UserFileContent.tsx | 7 + .../app/chat/my-documents/[id]/UserFolder.tsx | 18 + .../my-documents/[id]/UserFolderContent.tsx | 344 ++++ .../[id]/components/DocumentList.tsx | 130 ++ .../components/panels/AddWebsitePanel.tsx | 74 + .../components/panels/ContextLimitPanel.tsx | 79 + .../[id]/components/panels/ModelSelector.tsx | 45 + .../[id]/components/panels/SharingPanel.tsx | 84 + .../components/upload/FileUploadSection.tsx | 66 + .../[id]/components/upload/UploadWarning.tsx | 24 + web/src/app/chat/my-documents/[id]/page.tsx | 22 + .../my-documents/components/FileListItem.tsx | 226 +++ .../my-documents/components/FilePicker.tsx | 833 ++++++++++ .../components/SearchResultItem.tsx | 117 ++ .../components/SelectedItemsList.tsx | 83 + .../components/SharedFolderItem.tsx | 84 + .../app/chat/my-documents/components/types.ts | 31 + web/src/app/chat/my-documents/page.tsx | 12 + web/src/app/chat/my-documents/useDocuments.ts | 64 + web/src/app/chat/page.tsx | 11 +- web/src/app/chat/searchParams.ts | 1 + .../chat/sessionSidebar/HistorySidebar.tsx | 22 +- web/src/app/chat/sessionSidebar/PagesTab.tsx | 7 +- web/src/app/chat/useDocumentSelection.ts | 15 + .../stats/[id]/WrappedAssistantsStats.tsx | 7 +- web/src/app/layout.tsx | 13 +- web/src/components/DeleteEntityModal.tsx | 40 + web/src/components/MoveFolderModal.tsx | 53 + web/src/components/chat/TextView.tsx | 13 +- web/src/components/icons/icons.tsx | 47 + .../components/modals/CreateEntityModal.tsx | 80 + web/src/components/ui/button.tsx | 4 +- web/src/components/ui/context-menu.tsx | 198 +++ web/src/components/ui/input.tsx | 2 + web/src/components/ui/progress.tsx | 28 + web/src/components/ui/select.tsx | 19 + web/src/lib/chat/fetchChatData.ts | 3 + web/src/lib/dateUtils.ts | 5 +- web/src/lib/search/interfaces.ts | 10 +- web/src/lib/search/utils.ts | 6 +- web/src/services/documentsService.ts | 146 ++ 99 files changed, 6684 insertions(+), 1374 deletions(-) create mode 100644 backend/alembic/versions/9aadf32dfeb4_add_user_files.py create mode 100644 backend/onyx/db/user_documents.py create mode 100644 backend/onyx/seeding/user_folders.yaml create mode 100644 backend/onyx/server/user_documents/api.py create mode 100644 backend/onyx/server/user_documents/models.py create mode 100644 tsconfig.json create mode 100644 web/src/app/admin/assistants/assistantFileUtils.ts create mode 100644 web/src/app/chat/my-documents/DocumentsContext.tsx create mode 100644 web/src/app/chat/my-documents/MyDocumenItem.tsx create mode 100644 web/src/app/chat/my-documents/MyDocuments.tsx create mode 100644 web/src/app/chat/my-documents/WrappedDocuments.tsx create mode 100644 web/src/app/chat/my-documents/[id]/UserFileContent.tsx create mode 100644 web/src/app/chat/my-documents/[id]/UserFolder.tsx create mode 100644 web/src/app/chat/my-documents/[id]/UserFolderContent.tsx create mode 100644 web/src/app/chat/my-documents/[id]/components/DocumentList.tsx create mode 100644 web/src/app/chat/my-documents/[id]/components/panels/AddWebsitePanel.tsx create mode 100644 web/src/app/chat/my-documents/[id]/components/panels/ContextLimitPanel.tsx create mode 100644 web/src/app/chat/my-documents/[id]/components/panels/ModelSelector.tsx create mode 100644 web/src/app/chat/my-documents/[id]/components/panels/SharingPanel.tsx create mode 100644 web/src/app/chat/my-documents/[id]/components/upload/FileUploadSection.tsx create mode 100644 web/src/app/chat/my-documents/[id]/components/upload/UploadWarning.tsx create mode 100644 web/src/app/chat/my-documents/[id]/page.tsx create mode 100644 web/src/app/chat/my-documents/components/FileListItem.tsx create mode 100644 web/src/app/chat/my-documents/components/FilePicker.tsx create mode 100644 web/src/app/chat/my-documents/components/SearchResultItem.tsx create mode 100644 web/src/app/chat/my-documents/components/SelectedItemsList.tsx create mode 100644 web/src/app/chat/my-documents/components/SharedFolderItem.tsx create mode 100644 web/src/app/chat/my-documents/components/types.ts create mode 100644 web/src/app/chat/my-documents/page.tsx create mode 100644 web/src/app/chat/my-documents/useDocuments.ts create mode 100644 web/src/components/DeleteEntityModal.tsx create mode 100644 web/src/components/MoveFolderModal.tsx create mode 100644 web/src/components/modals/CreateEntityModal.tsx create mode 100644 web/src/components/ui/context-menu.tsx create mode 100644 web/src/components/ui/progress.tsx create mode 100644 web/src/services/documentsService.ts diff --git a/backend/alembic/versions/9aadf32dfeb4_add_user_files.py b/backend/alembic/versions/9aadf32dfeb4_add_user_files.py new file mode 100644 index 00000000000..654366dfb8b --- /dev/null +++ b/backend/alembic/versions/9aadf32dfeb4_add_user_files.py @@ -0,0 +1,107 @@ +"""add user files + +Revision ID: 9aadf32dfeb4 +Revises: f5437cc136c5 +Create Date: 2025-01-26 16:08:21.551022 + +""" +from alembic import op +import sqlalchemy as sa +import datetime + + +# revision identifiers, used by Alembic. +revision = "9aadf32dfeb4" +down_revision = "f5437cc136c5" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Create user_folder table without parent_id + 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("name", sa.String(length=255), nullable=True), + sa.Column("description", sa.String(length=255), nullable=True), + sa.Column("display_priority", sa.Integer(), nullable=True, default=0), + sa.Column( + "created_at", sa.DateTime(timezone=True), server_default=sa.func.now() + ), + ) + + # Create user_file table with folder_id instead of parent_folder_id + 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( + "folder_id", + sa.Integer(), + sa.ForeignKey("user_folder.id"), + nullable=True, + ), + sa.Column("token_count", sa.Integer(), 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, + ), + sa.Column( + "cc_pair_id", + sa.Integer(), + sa.ForeignKey("connector_credential_pair.id"), + nullable=True, + unique=True, + ), + ) + + # Create persona__user_file table + op.create_table( + "persona__user_file", + sa.Column( + "persona_id", sa.Integer(), sa.ForeignKey("persona.id"), primary_key=True + ), + sa.Column( + "user_file_id", + sa.Integer(), + sa.ForeignKey("user_file.id"), + primary_key=True, + ), + ) + + # Create persona__user_folder table + op.create_table( + "persona__user_folder", + sa.Column( + "persona_id", sa.Integer(), sa.ForeignKey("persona.id"), primary_key=True + ), + sa.Column( + "user_folder_id", + sa.Integer(), + sa.ForeignKey("user_folder.id"), + primary_key=True, + ), + ) + + op.add_column( + "connector_credential_pair", + sa.Column("is_user_file", sa.Boolean(), nullable=True), + ) + + +def downgrade() -> None: + # Drop the persona__user_folder table + op.drop_table("persona__user_folder") + # Drop the persona__user_file table + op.drop_table("persona__user_file") + # Drop the user_file table + op.drop_table("user_file") + # Drop the user_folder table + op.drop_table("user_folder") + op.drop_column("connector_credential_pair", "is_user_file") diff --git a/backend/onyx/agents/agent_search/shared_graph_utils/utils.py b/backend/onyx/agents/agent_search/shared_graph_utils/utils.py index 672706d181f..0fbd91e72d3 100644 --- a/backend/onyx/agents/agent_search/shared_graph_utils/utils.py +++ b/backend/onyx/agents/agent_search/shared_graph_utils/utils.py @@ -319,8 +319,10 @@ def dispatch_separated( sep: str = DISPATCH_SEP_CHAR, ) -> list[BaseMessage_Content]: num = 1 + accumulated_tokens = "" streamed_tokens: list[BaseMessage_Content] = [] for token in tokens: + accumulated_tokens += cast(str, token.content) content = cast(str, token.content) if sep in content: sub_question_parts = content.split(sep) diff --git a/backend/onyx/chat/process_message.py b/backend/onyx/chat/process_message.py index 2bc43e368a4..e1ee301cf88 100644 --- a/backend/onyx/chat/process_message.py +++ b/backend/onyx/chat/process_message.py @@ -86,6 +86,7 @@ from onyx.file_store.models import ChatFileType from onyx.file_store.models import FileDescriptor from onyx.file_store.utils import load_all_chat_files +from onyx.file_store.utils import load_all_user_files from onyx.file_store.utils import save_files from onyx.llm.exceptions import GenAIDisabledException from onyx.llm.factory import get_llms_for_persona @@ -262,8 +263,11 @@ def _get_force_search_settings( search_tool_available = any(isinstance(tool, SearchTool) for tool in tools) if not internet_search_available and not search_tool_available: - # Does not matter much which tool is set here as force is false and neither tool is available - return ForceUseTool(force_use=False, tool_name=SearchTool._NAME) + if new_msg_req.force_user_file_search: + return ForceUseTool(force_use=True, tool_name=SearchTool._NAME) + else: + # Does not matter much which tool is set here as force is false and neither tool is available + return ForceUseTool(force_use=False, tool_name=SearchTool._NAME) tool_name = SearchTool._NAME if search_tool_available else InternetSearchTool._NAME # Currently, the internet search tool does not support query override @@ -279,6 +283,7 @@ def _get_force_search_settings( should_force_search = any( [ + new_msg_req.force_user_file_search, new_msg_req.retrieval_options and new_msg_req.retrieval_options.run_search == OptionalSearchSetting.ALWAYS, @@ -538,6 +543,15 @@ def stream_chat_message_objects( req_file_ids = [f["id"] for f in new_msg_req.file_descriptors] latest_query_files = [file for file in files if file.file_id in req_file_ids] + if not new_msg_req.force_user_file_search: + user_files = load_all_user_files( + new_msg_req.user_file_ids, + new_msg_req.user_folder_ids, + db_session, + ) + + latest_query_files += user_files + if user_message: attach_files_to_chat_message( chat_message=user_message, @@ -681,6 +695,7 @@ def stream_chat_message_objects( user=user, llm=llm, fast_llm=fast_llm, + use_file_search=new_msg_req.force_user_file_search, search_tool_config=SearchToolConfig( answer_style_config=answer_style_config, document_pruning_config=document_pruning_config, diff --git a/backend/onyx/configs/chat_configs.py b/backend/onyx/configs/chat_configs.py index d99261294b0..5e424554583 100644 --- a/backend/onyx/configs/chat_configs.py +++ b/backend/onyx/configs/chat_configs.py @@ -3,7 +3,7 @@ INPUT_PROMPT_YAML = "./onyx/seeding/input_prompts.yaml" PROMPTS_YAML = "./onyx/seeding/prompts.yaml" PERSONAS_YAML = "./onyx/seeding/personas.yaml" - +USER_FOLDERS_YAML = "./onyx/seeding/user_folders.yaml" NUM_RETURNED_HITS = 50 # Used for LLM filtering and reranking # We want this to be approximately the number of results we want to show on the first page diff --git a/backend/onyx/context/search/models.py b/backend/onyx/context/search/models.py index 7eeb3568695..2f8c9f4a502 100644 --- a/backend/onyx/context/search/models.py +++ b/backend/onyx/context/search/models.py @@ -98,6 +98,7 @@ class BaseFilters(BaseModel): document_set: list[str] | None = None time_cutoff: datetime | None = None tags: list[Tag] | None = None + user_file_ids: list[int] | None = None class IndexFilters(BaseFilters): diff --git a/backend/onyx/context/search/preprocessing/preprocessing.py b/backend/onyx/context/search/preprocessing/preprocessing.py index d18ddd32be1..f463fb64860 100644 --- a/backend/onyx/context/search/preprocessing/preprocessing.py +++ b/backend/onyx/context/search/preprocessing/preprocessing.py @@ -160,7 +160,16 @@ def retrieval_preprocessing( user_acl_filters = ( None if bypass_acl else build_access_filters_for_user(user, db_session) ) + user_file_ids = preset_filters.user_file_ids + if persona and persona.user_files: + user_file_ids = user_file_ids + [ + file.id + for file in persona.user_files + if file.id not in preset_filters.user_file_ids + ] + final_filters = IndexFilters( + user_file_ids=user_file_ids, source_type=preset_filters.source_type or predicted_source_filters, document_set=preset_filters.document_set, time_cutoff=time_filter or predicted_time_cutoff, diff --git a/backend/onyx/db/connector_credential_pair.py b/backend/onyx/db/connector_credential_pair.py index 078a09a256d..cd45a81291a 100644 --- a/backend/onyx/db/connector_credential_pair.py +++ b/backend/onyx/db/connector_credential_pair.py @@ -104,6 +104,7 @@ def get_connector_credential_pairs_for_user( get_editable: bool = True, ids: list[int] | None = None, eager_load_connector: bool = False, + include_user_files: bool = False, eager_load_credential: bool = False, eager_load_user: bool = False, ) -> list[ConnectorCredentialPair]: @@ -126,6 +127,9 @@ def get_connector_credential_pairs_for_user( if ids: stmt = stmt.where(ConnectorCredentialPair.id.in_(ids)) + if not include_user_files: + stmt = stmt.where(ConnectorCredentialPair.is_user_file != True) # noqa: E712 + return list(db_session.scalars(stmt).unique().all()) @@ -153,14 +157,16 @@ def get_connector_credential_pairs_for_user_parallel( def get_connector_credential_pairs( - db_session: Session, - ids: list[int] | None = None, + db_session: Session, ids: list[int] | None = None, include_user_files: bool = False ) -> list[ConnectorCredentialPair]: stmt = select(ConnectorCredentialPair).distinct() if ids: stmt = stmt.where(ConnectorCredentialPair.id.in_(ids)) + if not include_user_files: + stmt = stmt.where(ConnectorCredentialPair.is_user_file != True) # noqa: E712 + return list(db_session.scalars(stmt).all()) @@ -446,6 +452,7 @@ def add_credential_to_connector( initial_status: ConnectorCredentialPairStatus = ConnectorCredentialPairStatus.ACTIVE, last_successful_index_time: datetime | None = None, seeding_flow: bool = False, + is_user_file: bool = False, ) -> StatusResponse: connector = fetch_connector_by_id(connector_id, db_session) @@ -511,6 +518,7 @@ def add_credential_to_connector( access_type=access_type, auto_sync_options=auto_sync_options, last_successful_index_time=last_successful_index_time, + is_user_file=is_user_file, ) db_session.add(association) db_session.flush() # make sure the association has an id diff --git a/backend/onyx/db/document.py b/backend/onyx/db/document.py index e408447664b..154ecca9f7a 100644 --- a/backend/onyx/db/document.py +++ b/backend/onyx/db/document.py @@ -274,7 +274,7 @@ def get_document_counts_for_cc_pairs_parallel( def get_access_info_for_document( db_session: Session, document_id: str, -) -> tuple[str, list[str | None], bool] | None: +) -> tuple[str, list[str | None], bool, list[int], list[int]] | None: """Gets access info for a single document by calling the get_access_info_for_documents function and passing a list with a single document ID. Args: @@ -294,7 +294,7 @@ def get_access_info_for_document( def get_access_info_for_documents( db_session: Session, document_ids: list[str], -) -> Sequence[tuple[str, list[str | None], bool]]: +) -> Sequence[tuple[str, list[str | None], bool, list[int], list[int]]]: """Gets back all relevant access info for the given documents. This includes the user_ids for cc pairs that the document is associated with + whether any of the associated cc pairs are intending to make the document globally public. diff --git a/backend/onyx/db/document_set.py b/backend/onyx/db/document_set.py index 0f91cbc71b8..025e48876c2 100644 --- a/backend/onyx/db/document_set.py +++ b/backend/onyx/db/document_set.py @@ -605,7 +605,6 @@ def fetch_document_sets_for_document( result = fetch_document_sets_for_documents([document_id], db_session) if not result: return [] - return result[0][1] diff --git a/backend/onyx/db/models.py b/backend/onyx/db/models.py index 132b2d63f7d..eace8b93eaa 100644 --- a/backend/onyx/db/models.py +++ b/backend/onyx/db/models.py @@ -205,6 +205,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") + @property def password_configured(self) -> bool: """ @@ -407,6 +412,7 @@ class ConnectorCredentialPair(Base): """ __tablename__ = "connector_credential_pair" + is_user_file: Mapped[bool] = mapped_column(Boolean, default=False) # NOTE: this `id` column has to use `Sequence` instead of `autoincrement=True` # due to some SQLAlchemy quirks + this not being a primary key column id: Mapped[int] = mapped_column( @@ -493,6 +499,10 @@ class ConnectorCredentialPair(Base): primaryjoin="foreign(ConnectorCredentialPair.creator_id) == remote(User.id)", ) + user_file: Mapped["UserFile"] = relationship( + "UserFile", back_populates="cc_pair", uselist=False + ) + background_errors: Mapped[list["BackgroundError"]] = relationship( "BackgroundError", back_populates="cc_pair", cascade="all, delete-orphan" ) @@ -1713,6 +1723,17 @@ class Persona(Base): secondary="persona__user_group", viewonly=True, ) + # Relationship to UserFile + user_files: Mapped[list["UserFile"]] = relationship( + "UserFile", + secondary="persona__user_file", + back_populates="assistants", + ) + user_folders: Mapped[list["UserFolder"]] = relationship( + "UserFolder", + secondary="persona__user_folder", + back_populates="assistants", + ) labels: Mapped[list["PersonaLabel"]] = relationship( "PersonaLabel", secondary=Persona__PersonaLabel.__table__, @@ -1729,6 +1750,24 @@ class Persona(Base): ) +class Persona__UserFolder(Base): + __tablename__ = "persona__user_folder" + + persona_id: Mapped[int] = mapped_column(ForeignKey("persona.id"), primary_key=True) + user_folder_id: Mapped[int] = mapped_column( + ForeignKey("user_folder.id"), primary_key=True + ) + + +class Persona__UserFile(Base): + __tablename__ = "persona__user_file" + + persona_id: Mapped[int] = mapped_column(ForeignKey("persona.id"), primary_key=True) + user_file_id: Mapped[int] = mapped_column( + ForeignKey("user_file.id"), primary_key=True + ) + + class PersonaLabel(Base): __tablename__ = "persona_label" @@ -2250,6 +2289,68 @@ class InputPrompt__User(Base): disabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) +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) + name: Mapped[str] = mapped_column(nullable=False) + description: Mapped[str] = mapped_column(nullable=False) + created_at: Mapped[datetime.datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + + # Mapped[datetime.datetime] = mapped_column( + # DateTime(timezone=True), server_default=func.now() + # ) + + user: Mapped["User"] = relationship(back_populates="folders") + files: Mapped[list["UserFile"]] = relationship(back_populates="folder") + assistants: Mapped[list["Persona"]] = relationship( + "Persona", + secondary=Persona__UserFolder.__table__, + back_populates="user_folders", + ) + + +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) + assistants: Mapped[list["Persona"]] = relationship( + "Persona", + secondary=Persona__UserFile.__table__, + back_populates="user_files", + ) + 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 + ) + user: Mapped["User"] = relationship(back_populates="files") + folder: Mapped["UserFolder"] = relationship(back_populates="files") + token_count: Mapped[int | None] = mapped_column(Integer, nullable=True) + + cc_pair_id: Mapped[int | None] = mapped_column( + ForeignKey("connector_credential_pair.id"), nullable=True, unique=True + ) + cc_pair: Mapped["ConnectorCredentialPair"] = relationship( + "ConnectorCredentialPair", back_populates="user_file" + ) + + """ Multi-tenancy related tables """ diff --git a/backend/onyx/db/persona.py b/backend/onyx/db/persona.py index 250b261173c..1d09b19a976 100644 --- a/backend/onyx/db/persona.py +++ b/backend/onyx/db/persona.py @@ -33,6 +33,8 @@ from onyx.db.models import Tool from onyx.db.models import User from onyx.db.models import User__UserGroup +from onyx.db.models import UserFile +from onyx.db.models import UserFolder from onyx.db.models import UserGroup from onyx.db.notification import create_notification from onyx.server.features.persona.models import PersonaSharedNotificationData @@ -237,6 +239,8 @@ def create_update_persona( llm_relevance_filter=create_persona_request.llm_relevance_filter, llm_filter_extraction=create_persona_request.llm_filter_extraction, is_default_persona=create_persona_request.is_default_persona, + user_file_ids=create_persona_request.user_file_ids, + user_folder_ids=create_persona_request.user_folder_ids, ) versioned_make_persona_private = fetch_versioned_implementation( @@ -331,6 +335,8 @@ def get_personas_for_user( selectinload(Persona.groups), selectinload(Persona.users), selectinload(Persona.labels), + selectinload(Persona.user_files), + selectinload(Persona.user_folders), ) results = db_session.execute(stmt).scalars().all() @@ -425,6 +431,8 @@ def upsert_persona( builtin_persona: bool = False, is_default_persona: bool = False, label_ids: list[int] | None = None, + user_file_ids: list[int] | None = None, + user_folder_ids: list[int] | None = None, chunks_above: int = CONTEXT_CHUNKS_ABOVE, chunks_below: int = CONTEXT_CHUNKS_BELOW, ) -> Persona: @@ -450,6 +458,7 @@ def upsert_persona( user=user, get_editable=True, ) + # Fetch and attach tools by IDs tools = None if tool_ids is not None: @@ -468,6 +477,26 @@ def upsert_persona( if not document_sets and document_set_ids: raise ValueError("document_sets not found") + # Fetch and attach user_files by IDs + user_files = None + if user_file_ids is not None: + user_files = ( + db_session.query(UserFile).filter(UserFile.id.in_(user_file_ids)).all() + ) + if not user_files and user_file_ids: + raise ValueError("user_files not found") + + # Fetch and attach user_folders by IDs + user_folders = None + if user_folder_ids is not None: + user_folders = ( + db_session.query(UserFolder) + .filter(UserFolder.id.in_(user_folder_ids)) + .all() + ) + if not user_folders and user_folder_ids: + raise ValueError("user_folders not found") + # Fetch and attach prompts by IDs prompts = None if prompt_ids is not None: @@ -532,6 +561,14 @@ def upsert_persona( if tools is not None: existing_persona.tools = tools or [] + if user_file_ids is not None: + existing_persona.user_files.clear() + existing_persona.user_files = user_files or [] + + if user_folder_ids is not None: + existing_persona.user_folders.clear() + existing_persona.user_folders = user_folders or [] + # We should only update display priority if it is not already set if existing_persona.display_priority is None: existing_persona.display_priority = display_priority diff --git a/backend/onyx/db/user_documents.py b/backend/onyx/db/user_documents.py new file mode 100644 index 00000000000..e8ce9c570bb --- /dev/null +++ b/backend/onyx/db/user_documents.py @@ -0,0 +1,175 @@ +import datetime +from typing import List + +from fastapi import UploadFile +from sqlalchemy import and_ +from sqlalchemy.orm import Session + +from onyx.connectors.file.connector import _read_files_and_metadata +from onyx.db.models import Persona +from onyx.db.models import Persona__UserFile +from onyx.db.models import User +from onyx.db.models import UserFile +from onyx.db.models import UserFolder +from onyx.file_processing.extract_file_text import read_text_file +from onyx.llm.factory import get_default_llms +from onyx.natural_language_processing.utils import get_tokenizer +from onyx.server.documents.connector import upload_files + +USER_FILE_CONSTANT = "USER_FILE_CONNECTOR" + + +def create_user_files( + files: List[UploadFile], + folder_id: int | None, + user: User | None, + db_session: Session, +) -> list[UserFile]: + upload_response = upload_files(files, db_session) + user_files = [] + + context_files = _read_files_and_metadata( + file_name=str(upload_response.file_paths[0]), db_session=db_session + ) + + content, _ = read_text_file(next(context_files)[1]) + llm, _ = get_default_llms() + + llm_tokenizer = get_tokenizer( + model_name=llm.config.model_name, + provider_type=llm.config.model_provider, + ) + token_count = len(llm_tokenizer.encode(content)) + + for file_path, file in zip(upload_response.file_paths, files): + new_file = UserFile( + user_id=user.id if user else None, + folder_id=folder_id, + file_id=file_path, + document_id="USER_FILE_CONNECTOR__" + file_path, + name=file.filename, + token_count=token_count, + ) + db_session.add(new_file) + user_files.append(new_file) + db_session.commit() + return user_files + + +def get_user_files_from_folder(folder_id: int, db_session: Session) -> list[UserFile]: + return db_session.query(UserFile).filter(UserFile.folder_id == folder_id).all() + + +def share_file_with_assistant( + file_id: int, assistant_id: int, db_session: Session +) -> None: + file = db_session.query(UserFile).filter(UserFile.id == file_id).first() + assistant = db_session.query(Persona).filter(Persona.id == assistant_id).first() + + if file and assistant: + file.assistants.append(assistant) + db_session.commit() + + +def unshare_file_with_assistant( + file_id: int, assistant_id: int, db_session: Session +) -> None: + db_session.query(Persona__UserFile).filter( + and_( + Persona__UserFile.user_file_id == file_id, + Persona__UserFile.persona_id == assistant_id, + ) + ).delete() + db_session.commit() + + +def share_folder_with_assistant( + folder_id: int, assistant_id: int, db_session: Session +) -> None: + folder = db_session.query(UserFolder).filter(UserFolder.id == folder_id).first() + assistant = db_session.query(Persona).filter(Persona.id == assistant_id).first() + + if folder and assistant: + for file in folder.files: + share_file_with_assistant(file.id, assistant_id, db_session) + + +def unshare_folder_with_assistant( + folder_id: int, assistant_id: int, db_session: Session +) -> None: + folder = db_session.query(UserFolder).filter(UserFolder.id == folder_id).first() + + if folder: + for file in folder.files: + unshare_file_with_assistant(file.id, assistant_id, db_session) + + +def fetch_user_files_for_documents( + document_ids: list[str], + db_session: Session, +) -> dict[str, None | int]: + # Query UserFile objects for the given document_ids + user_files = ( + db_session.query(UserFile).filter(UserFile.document_id.in_(document_ids)).all() + ) + + # Create a dictionary mapping document_ids to UserFile objects + result = {doc_id: None for doc_id in document_ids} + for user_file in user_files: + result[user_file.document_id] = user_file.id + + return result + + +def upsert_user_folder( + db_session: Session, + id: int | None = None, + user_id: int | None = None, + name: str | None = None, + description: str | None = None, + created_at: datetime.datetime | None = None, + user: User | None = None, + files: list[UserFile] | None = None, + assistants: list[Persona] | None = None, +) -> UserFolder: + if id is not None: + user_folder = db_session.query(UserFolder).filter_by(id=id).first() + else: + user_folder = ( + db_session.query(UserFolder).filter_by(name=name, user_id=user_id).first() + ) + + if user_folder: + if user_id is not None: + user_folder.user_id = user_id + if name is not None: + user_folder.name = name + if description is not None: + user_folder.description = description + if created_at is not None: + user_folder.created_at = created_at + if user is not None: + user_folder.user = user + if files is not None: + user_folder.files = files + if assistants is not None: + user_folder.assistants = assistants + else: + user_folder = UserFolder( + id=id, + user_id=user_id, + name=name, + description=description, + created_at=created_at or datetime.datetime.utcnow(), + user=user, + files=files or [], + assistants=assistants or [], + ) + db_session.add(user_folder) + + db_session.flush() + return user_folder + + +def get_user_folder_by_name(db_session: Session, name: str) -> UserFolder | None: + return db_session.query(UserFolder).filter(UserFolder.name == name).first() diff --git a/backend/onyx/document_index/vespa/app_config/schemas/danswer_chunk.sd b/backend/onyx/document_index/vespa/app_config/schemas/danswer_chunk.sd index 2fd861b779e..6757b48f3af 100644 --- a/backend/onyx/document_index/vespa/app_config/schemas/danswer_chunk.sd +++ b/backend/onyx/document_index/vespa/app_config/schemas/danswer_chunk.sd @@ -112,6 +112,16 @@ schema DANSWER_CHUNK_NAME { rank: filter attribute: fast-search } + field user_file type int { + indexing: summary | attribute + rank: filter + attribute: fast-search + } + field user_folders type weightedset { + indexing: summary | attribute + rank: filter + attribute: fast-search + } } # If using different tokenization settings, the fieldset has to be removed, and the field must diff --git a/backend/onyx/document_index/vespa/index.py b/backend/onyx/document_index/vespa/index.py index c2e631f6c8c..ff3587f27e1 100644 --- a/backend/onyx/document_index/vespa/index.py +++ b/backend/onyx/document_index/vespa/index.py @@ -645,6 +645,8 @@ def update_single( tenant_id=tenant_id, large_chunks_enabled=large_chunks_enabled, ) + logger.error("CHECKing chunks") + logger.error(doc_chunk_ids) doc_chunk_count += len(doc_chunk_ids) @@ -691,6 +693,7 @@ def delete_single( tenant_id=tenant_id, large_chunks_enabled=large_chunks_enabled, ) + for doc_chunk_ids_batch in batch_generator( chunks_to_delete, BATCH_SIZE ): diff --git a/backend/onyx/document_index/vespa/indexing_utils.py b/backend/onyx/document_index/vespa/indexing_utils.py index 2802b59f586..725ac84b9f9 100644 --- a/backend/onyx/document_index/vespa/indexing_utils.py +++ b/backend/onyx/document_index/vespa/indexing_utils.py @@ -47,6 +47,7 @@ from onyx.document_index.vespa_constants import TENANT_ID from onyx.document_index.vespa_constants import TITLE from onyx.document_index.vespa_constants import TITLE_EMBEDDING +from onyx.document_index.vespa_constants import USER_FILE from onyx.indexing.models import DocMetadataAwareIndexChunk from onyx.utils.logger import setup_logger @@ -198,6 +199,8 @@ def _index_vespa_chunk( # which only calls VespaIndex.update ACCESS_CONTROL_LIST: {acl_entry: 1 for acl_entry in chunk.access.to_acl()}, DOCUMENT_SETS: {document_set: 1 for document_set in chunk.document_sets}, + USER_FILE: chunk.user_file if chunk.user_file is not None else None, + # USER_FOLDERS: {user_folder: 1 for user_folder in chunk.user_folders}, BOOST: chunk.boost, } diff --git a/backend/onyx/document_index/vespa/shared_utils/vespa_request_builders.py b/backend/onyx/document_index/vespa/shared_utils/vespa_request_builders.py index cc11a42d384..4a4ff208cf7 100644 --- a/backend/onyx/document_index/vespa/shared_utils/vespa_request_builders.py +++ b/backend/onyx/document_index/vespa/shared_utils/vespa_request_builders.py @@ -5,7 +5,6 @@ from onyx.configs.constants import INDEX_SEPARATOR from onyx.context.search.models import IndexFilters from onyx.document_index.interfaces import VespaChunkRequest -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 @@ -14,6 +13,7 @@ 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_FILE from onyx.utils.logger import setup_logger from shared_configs.configs import MULTI_TENANT @@ -27,14 +27,26 @@ def build_vespa_filters( remove_trailing_and: bool = False, # Set to True when using as a complete Vespa query ) -> str: def _build_or_filters(key: str, vals: list[str] | None) -> str: - if vals is None: + """For string-based 'contains' filters, e.g. WSET fields or array fields.""" + if not key or not vals: return "" - - valid_vals = [val for val in vals if val] - if not key or not valid_vals: + eq_elems = [f'{key} contains "{val}"' for val in vals if val] + if not eq_elems: + return "" + or_clause = " or ".join(eq_elems) + return f"({or_clause}) and " + + def _build_int_or_filters(key: str, vals: list[int] | None) -> str: + """ + For an integer field filter. + If vals is not None, we want *only* docs whose key matches one of vals. + """ + # If `vals` is None => skip the filter entirely + if vals is None or not vals: return "" - eq_elems = [f'{key} contains "{elem}"' for elem in valid_vals] + # Otherwise build the OR filter + eq_elems = [f"{key} = {val}" for val in vals] or_clause = " or ".join(eq_elems) result = f"({or_clause}) and " @@ -42,53 +54,55 @@ def _build_or_filters(key: str, vals: list[str] | None) -> str: def _build_time_filter( cutoff: datetime | None, - # Slightly over 3 Months, approximately 1 fiscal quarter untimed_doc_cutoff: timedelta = timedelta(days=92), ) -> str: if not cutoff: return "" - - # For Documents that don't have an updated at, filter them out for queries asking for - # very recent documents (3 months) default. Documents that don't have an updated at - # time are assigned 3 months for time decay value include_untimed = datetime.now(timezone.utc) - untimed_doc_cutoff > cutoff cutoff_secs = int(cutoff.timestamp()) if include_untimed: - # Documents without updated_at are assigned -1 as their date return f"!({DOC_UPDATED_AT} < {cutoff_secs}) and " - return f"({DOC_UPDATED_AT} >= {cutoff_secs}) and " + # Start building the filter string filter_str = f"!({HIDDEN}=true) and " if not include_hidden else "" - # If running in multi-tenant mode, we may want to filter by tenant_id + # If running in multi-tenant mode if filters.tenant_id and MULTI_TENANT: filter_str += f'({TENANT_ID} contains "{filters.tenant_id}") and ' - # CAREFUL touching this one, currently there is no second ACL double-check post retrieval - if filters.access_control_list is not None: - filter_str += _build_or_filters( - ACCESS_CONTROL_LIST, filters.access_control_list - ) + # ACL filters + # if filters.access_control_list is not None: + # filter_str += _build_or_filters(ACCESS_CONTROL_LIST, filters.access_control_list) + # Source type filters source_strs = ( [s.value for s in filters.source_type] if filters.source_type else None ) filter_str += _build_or_filters(SOURCE_TYPE, source_strs) + # Tag filters tag_attributes = None - tags = filters.tags - if tags: - tag_attributes = [tag.tag_key + INDEX_SEPARATOR + tag.tag_value for tag in tags] + if filters.tags: + # build e.g. "tag_key|tag_value" + tag_attributes = [ + f"{tag.tag_key}{INDEX_SEPARATOR}{tag.tag_value}" for tag in filters.tags + ] filter_str += _build_or_filters(METADATA_LIST, tag_attributes) + # Document sets filter_str += _build_or_filters(DOCUMENT_SETS, filters.document_set) + # New: user_file_ids as integer filters + filter_str += _build_int_or_filters(USER_FILE, filters.user_file_ids) + + # Time filter filter_str += _build_time_filter(filters.time_cutoff) + # Trim trailing " and " if remove_trailing_and and filter_str.endswith(" and "): - filter_str = filter_str[:-5] # We remove the trailing " and " + filter_str = filter_str[:-5] return filter_str diff --git a/backend/onyx/document_index/vespa_constants.py b/backend/onyx/document_index/vespa_constants.py index a259aede5ca..18209b93464 100644 --- a/backend/onyx/document_index/vespa_constants.py +++ b/backend/onyx/document_index/vespa_constants.py @@ -66,6 +66,8 @@ TITLE_EMBEDDING = "title_embedding" ACCESS_CONTROL_LIST = "access_control_list" DOCUMENT_SETS = "document_sets" +USER_FILE = "user_file" +USER_FOLDERS = "user_folders" LARGE_CHUNK_REFERENCE_IDS = "large_chunk_reference_ids" METADATA = "metadata" METADATA_LIST = "metadata_list" diff --git a/backend/onyx/file_processing/unstructured.py b/backend/onyx/file_processing/unstructured.py index 3827c689149..7cbabadfd6a 100644 --- a/backend/onyx/file_processing/unstructured.py +++ b/backend/onyx/file_processing/unstructured.py @@ -37,6 +37,7 @@ def delete_unstructured_api_key() -> None: def _sdk_partition_request( file: IO[Any], file_name: str, **kwargs: Any ) -> operations.PartitionRequest: + file.seek(0, 0) try: request = operations.PartitionRequest( partition_parameters=shared.PartitionParameters( diff --git a/backend/onyx/file_store/utils.py b/backend/onyx/file_store/utils.py index 91198790a1f..319f3dd1cf3 100644 --- a/backend/onyx/file_store/utils.py +++ b/backend/onyx/file_store/utils.py @@ -10,7 +10,10 @@ from onyx.configs.constants import FileOrigin from onyx.db.engine import get_session_with_current_tenant from onyx.db.models import ChatMessage +from onyx.db.models import UserFile +from onyx.db.models import UserFolder from onyx.file_store.file_store import get_default_file_store +from onyx.file_store.models import ChatFileType from onyx.file_store.models import FileDescriptor from onyx.file_store.models import InMemoryChatFile from onyx.utils.b64 import get_image_type @@ -53,6 +56,53 @@ def load_all_chat_files( return files + + + +def load_user_folder(folder_id: int, db_session: Session) -> list[InMemoryChatFile]: + user_files = ( + db_session.query(UserFile).filter(UserFile.folder_id == folder_id).all() + ) + return [load_user_file(file.id, db_session) for file in user_files] + + +def load_user_file(file_id: int, db_session: Session) -> InMemoryChatFile: + user_file = db_session.query(UserFile).filter(UserFile.id == file_id).first() + if not user_file: + raise ValueError(f"User file with id {file_id} not found") + + file_io = get_default_file_store(db_session).read_file( + user_file.document_id, mode="b" + ) + return InMemoryChatFile( + file_id=str(user_file.id), + content=file_io.read(), + file_type=ChatFileType.PLAIN_TEXT, + filename=user_file.name, + ) + + +def load_all_user_files( + user_file_ids: list[int], + user_folder_ids: list[int], + db_session: Session, +) -> list[InMemoryChatFile]: + return cast( + list[InMemoryChatFile], + run_functions_tuples_in_parallel( + [(load_user_file, (file_id, db_session)) for file_id in user_file_ids] + ) + + [ + file + for folder_id in user_folder_ids + for file in load_user_folder(folder_id, db_session) + ], + ) + + + + + def save_file_from_url(url: str) -> str: """NOTE: using multiple sessions here, since this is often called using multithreading. In practice, sharing a session has resulted in @@ -128,3 +178,39 @@ def save_files(urls: list[str], base64_files: list[str]) -> list[str]: ] return run_functions_tuples_in_parallel(funcs) + + +def load_all_persona_files_for_chat( + persona_id: int, db_session: Session +) -> tuple[list[InMemoryChatFile], list[int]]: + from onyx.db.models import Persona + from sqlalchemy.orm import joinedload + + persona = ( + db_session.query(Persona) + .filter(Persona.id == persona_id) + .options( + joinedload(Persona.user_files), + joinedload(Persona.user_folders).joinedload(UserFolder.files), + ) + .one() + ) + + persona_file_calls = [ + (load_user_file, (user_file.id, db_session)) for user_file in persona.user_files + ] + persona_loaded_files = run_functions_tuples_in_parallel(persona_file_calls) + + persona_folder_files = [] + persona_folder_file_ids = [] + for user_folder in persona.user_folders: + folder_files = load_user_folder(user_folder.id, db_session) + persona_folder_files.extend(folder_files) + persona_folder_file_ids.extend([file.id for file in user_folder.files]) + + persona_files = list(persona_loaded_files) + persona_folder_files + persona_file_ids = [ + file.id for file in persona.user_files + ] + persona_folder_file_ids + + return persona_files, persona_file_ids diff --git a/backend/onyx/indexing/indexing_pipeline.py b/backend/onyx/indexing/indexing_pipeline.py index fe95f2a9ba1..e76a6e3678e 100644 --- a/backend/onyx/indexing/indexing_pipeline.py +++ b/backend/onyx/indexing/indexing_pipeline.py @@ -31,6 +31,7 @@ from onyx.db.search_settings import get_current_search_settings from onyx.db.tag import create_or_add_document_tag from onyx.db.tag import create_or_add_document_tag_list +from onyx.db.user_documents import fetch_user_files_for_documents from onyx.document_index.document_index_utils import ( get_multipass_config, ) @@ -402,6 +403,10 @@ def index_doc_batch( ) } + doc_id_to_user_file_id: dict[str, int | None] = fetch_user_files_for_documents( + document_ids=updatable_ids, db_session=db_session + ) + doc_id_to_previous_chunk_cnt: dict[str, int | None] = { document_id: chunk_count for document_id, chunk_count in fetch_chunk_counts_for_documents( @@ -433,6 +438,7 @@ def index_doc_batch( document_sets=set( doc_id_to_document_set.get(chunk.source_document.id, []) ), + user_file=doc_id_to_user_file_id.get(chunk.source_document.id, None), boost=( ctx.id_to_db_doc_map[chunk.source_document.id].boost if chunk.source_document.id in ctx.id_to_db_doc_map diff --git a/backend/onyx/indexing/models.py b/backend/onyx/indexing/models.py index 0c4451cc7ae..06163a23c35 100644 --- a/backend/onyx/indexing/models.py +++ b/backend/onyx/indexing/models.py @@ -87,6 +87,8 @@ class DocMetadataAwareIndexChunk(IndexChunk): tenant_id: str access: "DocumentAccess" document_sets: set[str] + user_file: int | None + # user_folders: list[int] boost: int @classmethod @@ -95,6 +97,8 @@ def from_index_chunk( index_chunk: IndexChunk, access: "DocumentAccess", document_sets: set[str], + user_file: int | None, + # user_folder: list[int], boost: int, tenant_id: str, ) -> "DocMetadataAwareIndexChunk": @@ -103,6 +107,8 @@ def from_index_chunk( **index_chunk_data, access=access, document_sets=document_sets, + user_file=user_file, + # user_folders=user_folders, boost=boost, tenant_id=tenant_id, ) diff --git a/backend/onyx/main.py b/backend/onyx/main.py index 2444e6f1928..e78e5b8af91 100644 --- a/backend/onyx/main.py +++ b/backend/onyx/main.py @@ -97,6 +97,7 @@ 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 @@ -295,6 +296,7 @@ def get_application() -> FastAPI: include_router_with_global_prefix_prepended(application, input_prompt_router) include_router_with_global_prefix_prepended(application, admin_input_prompt_router) include_router_with_global_prefix_prepended(application, cc_pair_router) + include_router_with_global_prefix_prepended(application, user_documents_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) diff --git a/backend/onyx/seeding/load_docs.py b/backend/onyx/seeding/load_docs.py index 38ad5234515..15f599e2a79 100644 --- a/backend/onyx/seeding/load_docs.py +++ b/backend/onyx/seeding/load_docs.py @@ -91,6 +91,7 @@ def _create_indexable_chunks( tenant_id=tenant_id if MULTI_TENANT else POSTGRES_DEFAULT_SCHEMA, access=default_public_access, document_sets=set(), + user_file=None, boost=DEFAULT_BOOST, large_chunk_id=None, ) diff --git a/backend/onyx/seeding/load_yamls.py b/backend/onyx/seeding/load_yamls.py index 204b7180aac..1db698fa056 100644 --- a/backend/onyx/seeding/load_yamls.py +++ b/backend/onyx/seeding/load_yamls.py @@ -5,6 +5,7 @@ from onyx.configs.chat_configs import MAX_CHUNKS_FED_TO_CHAT from onyx.configs.chat_configs import PERSONAS_YAML from onyx.configs.chat_configs import PROMPTS_YAML +from onyx.configs.chat_configs import USER_FOLDERS_YAML from onyx.context.search.enums import RecencyBiasSetting from onyx.db.document_set import get_or_create_document_set_by_name from onyx.db.input_prompt import insert_input_prompt_if_not_exists @@ -15,6 +16,30 @@ from onyx.db.persona import upsert_persona from onyx.db.prompts import get_prompt_by_name from onyx.db.prompts import upsert_prompt +from onyx.db.user_documents import upsert_user_folder + + +def load_user_folders_from_yaml( + db_session: Session, + user_folders_yaml: str = USER_FOLDERS_YAML, +) -> None: + with open(user_folders_yaml, "r") as file: + data = yaml.safe_load(file) + + all_user_folders = data.get("user_folders", []) + for user_folder in all_user_folders: + upsert_user_folder( + db_session=db_session, + id=user_folder.get("id"), + user_id=user_folder.get("user_id"), + name=user_folder.get("name"), + description=user_folder.get("description"), + created_at=user_folder.get("created_at"), + user=user_folder.get("user"), + files=user_folder.get("files"), + assistants=user_folder.get("assistants"), + ) + db_session.flush() def load_prompts_from_yaml( @@ -179,3 +204,4 @@ def load_chat_yamls( load_prompts_from_yaml(db_session, prompt_yaml) load_personas_from_yaml(db_session, personas_yaml) load_input_prompts_from_yaml(db_session, input_prompts_yaml) + load_user_folders_from_yaml(db_session) diff --git a/backend/onyx/seeding/user_folders.yaml b/backend/onyx/seeding/user_folders.yaml new file mode 100644 index 00000000000..e68b414c601 --- /dev/null +++ b/backend/onyx/seeding/user_folders.yaml @@ -0,0 +1,6 @@ +user_folders: + - id: -1 + name: "Recent Documents" + description: "Documents uploaded by the user" + files: [] + assistants: [] diff --git a/backend/onyx/server/documents/connector.py b/backend/onyx/server/documents/connector.py index 60511ae9340..47edca97c07 100644 --- a/backend/onyx/server/documents/connector.py +++ b/backend/onyx/server/documents/connector.py @@ -389,12 +389,7 @@ 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: +def upload_files(files: list[UploadFile], db_session: Session) -> FileUploadResponse: for file in files: if not file.filename: raise HTTPException(status_code=400, detail="File name cannot be empty") @@ -455,6 +450,15 @@ def should_process_file(file_path: str) -> bool: 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) + + @router.get("/admin/connector") def get_connectors_by_credential( _: User = Depends(current_curator_or_admin_user), @@ -1042,55 +1046,16 @@ def connector_run_once( status_code=400, detail="Connector has no valid credentials, cannot create index attempts.", ) - - # Prevents index attempts for cc pairs that already have an index attempt currently running - skipped_credentials = [ - credential_id - for credential_id in credential_ids - if get_index_attempts_for_cc_pair( - cc_pair_identifier=ConnectorCredentialPairIdentifier( - connector_id=run_info.connector_id, - credential_id=credential_id, - ), - only_current=True, - db_session=db_session, - disinclude_finished=True, - ) - ] - - connector_credential_pairs = [ - get_connector_credential_pair( - db_session=db_session, - connector_id=connector_id, - credential_id=credential_id, + try: + num_triggers = trigger_indexing_for_cc_pair( + credential_ids, + connector_id, + run_info.from_beginning, + tenant_id, + db_session, ) - for credential_id in credential_ids - if credential_id not in skipped_credentials - ] - - num_triggers = 0 - for cc_pair in connector_credential_pairs: - if cc_pair is not None: - indexing_mode = IndexingMode.UPDATE - if run_info.from_beginning: - indexing_mode = IndexingMode.REINDEX - - mark_ccpair_with_indexing_trigger(cc_pair.id, indexing_mode, db_session) - num_triggers += 1 - - logger.info( - f"connector_run_once - marking cc_pair with indexing trigger: " - f"connector={run_info.connector_id} " - f"cc_pair={cc_pair.id} " - f"indexing_trigger={indexing_mode}" - ) - - # run the beat task to pick up the triggers immediately - primary_app.send_task( - OnyxCeleryTask.CHECK_FOR_INDEXING, - priority=OnyxCeleryPriority.HIGH, - kwargs={"tenant_id": tenant_id}, - ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) logger.info("connector_run_once - running check_for_indexing") @@ -1264,3 +1229,82 @@ def get_basic_connector_indexing_status( for cc_pair in cc_pairs if cc_pair.connector.source != DocumentSource.INGESTION_API ] + + +def trigger_indexing_for_cc_pair( + specified_credential_ids: list[int], + connector_id: int, + from_beginning: bool, + tenant_id: str, + db_session: Session, +) -> int: + try: + possible_credential_ids = get_connector_credential_ids(connector_id, db_session) + except ValueError as e: + raise ValueError(f"Connector by id {connector_id} does not exist: {str(e)}") + + if not specified_credential_ids: + credential_ids = possible_credential_ids + else: + if set(specified_credential_ids).issubset(set(possible_credential_ids)): + credential_ids = specified_credential_ids + else: + raise ValueError( + "Not all specified credentials are associated with connector" + ) + + if not credential_ids: + raise ValueError( + "Connector has no valid credentials, cannot create index attempts." + ) + + # Prevents index attempts for cc pairs that already have an index attempt currently running + skipped_credentials = [ + credential_id + for credential_id in credential_ids + if get_index_attempts_for_cc_pair( + cc_pair_identifier=ConnectorCredentialPairIdentifier( + connector_id=connector_id, + credential_id=credential_id, + ), + only_current=True, + db_session=db_session, + disinclude_finished=True, + ) + ] + + connector_credential_pairs = [ + get_connector_credential_pair( + db_session=db_session, + connector_id=connector_id, + credential_id=credential_id, + ) + for credential_id in credential_ids + if credential_id not in skipped_credentials + ] + + num_triggers = 0 + for cc_pair in connector_credential_pairs: + if cc_pair is not None: + indexing_mode = IndexingMode.UPDATE + if from_beginning: + indexing_mode = IndexingMode.REINDEX + + mark_ccpair_with_indexing_trigger(cc_pair.id, indexing_mode, db_session) + num_triggers += 1 + + logger.info( + f"connector_run_once - marking cc_pair with indexing trigger: " + f"connector={connector_id} " + f"cc_pair={cc_pair.id} " + f"indexing_trigger={indexing_mode}" + ) + + # run the beat task to pick up the triggers immediately + primary_app.send_task( + OnyxCeleryTask.CHECK_FOR_INDEXING, + priority=OnyxCeleryPriority.HIGH, + kwargs={"tenant_id": tenant_id}, + ) + + return num_triggers diff --git a/backend/onyx/server/documents/models.py b/backend/onyx/server/documents/models.py index 9cf97a18875..c557cae3cec 100644 --- a/backend/onyx/server/documents/models.py +++ b/backend/onyx/server/documents/models.py @@ -122,6 +122,7 @@ class CredentialBase(BaseModel): name: str | None = None curator_public: bool = False groups: list[int] = Field(default_factory=list) + is_user_file: bool = False class CredentialSnapshot(CredentialBase): @@ -392,7 +393,7 @@ class FileUploadResponse(BaseModel): class ObjectCreationIdResponse(BaseModel): - id: int | str + id: int credential: CredentialSnapshot | None = None diff --git a/backend/onyx/server/features/folder/api.py b/backend/onyx/server/features/folder/api.py index 503c66258f9..ae3e880640c 100644 --- a/backend/onyx/server/features/folder/api.py +++ b/backend/onyx/server/features/folder/api.py @@ -18,9 +18,9 @@ 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.features.folder.models import UserFolderSnapshot from onyx.server.models import DisplayPriorityRequest from onyx.server.query_and_chat.models import ChatSessionDetails @@ -39,7 +39,7 @@ def get_folders( folders.sort() return GetUserFoldersResponse( folders=[ - FolderResponse( + UserFolderSnapshot( folder_id=folder.id, folder_name=folder.name, display_priority=folder.display_priority, diff --git a/backend/onyx/server/features/folder/models.py b/backend/onyx/server/features/folder/models.py index acb3fa415a6..f63921d30df 100644 --- a/backend/onyx/server/features/folder/models.py +++ b/backend/onyx/server/features/folder/models.py @@ -5,7 +5,7 @@ from onyx.server.query_and_chat.models import ChatSessionDetails -class FolderResponse(BaseModel): +class UserFolderSnapshot(BaseModel): folder_id: int folder_name: str | None display_priority: int @@ -13,7 +13,7 @@ class FolderResponse(BaseModel): class GetUserFoldersResponse(BaseModel): - folders: list[FolderResponse] + folders: list[UserFolderSnapshot] class FolderCreationRequest(BaseModel): diff --git a/backend/onyx/server/features/persona/api.py b/backend/onyx/server/features/persona/api.py index d022244ea0b..4f9f8d013fc 100644 --- a/backend/onyx/server/features/persona/api.py +++ b/backend/onyx/server/features/persona/api.py @@ -26,6 +26,7 @@ from onyx.db.persona import create_update_persona from onyx.db.persona import delete_persona_label from onyx.db.persona import get_assistant_labels +from shared_configs.contextvars import get_current_tenant_id from onyx.db.persona import get_persona_by_id from onyx.db.persona import get_personas_for_user from onyx.db.persona import mark_persona_as_deleted @@ -55,11 +56,9 @@ from onyx.tools.utils import is_image_generation_available from onyx.utils.logger import setup_logger from onyx.utils.telemetry import create_milestone_and_report -from shared_configs.contextvars import get_current_tenant_id logger = setup_logger() - admin_router = APIRouter(prefix="/admin/persona") basic_router = APIRouter(prefix="/persona") @@ -210,6 +209,7 @@ def create_persona( and len(persona_upsert_request.prompt_ids) > 0 else None ) + prompt = upsert_prompt( db_session=db_session, user=user, diff --git a/backend/onyx/server/features/persona/models.py b/backend/onyx/server/features/persona/models.py index 7ed43f47a21..306f7f779f7 100644 --- a/backend/onyx/server/features/persona/models.py +++ b/backend/onyx/server/features/persona/models.py @@ -85,6 +85,8 @@ class PersonaUpsertRequest(BaseModel): label_ids: list[int] | None = None is_default_persona: bool = False display_priority: int | None = None + user_file_ids: list[int] | None = None + user_folder_ids: list[int] | None = None class PersonaSnapshot(BaseModel): diff --git a/backend/onyx/server/manage/llm/models.py b/backend/onyx/server/manage/llm/models.py index 91c59fb15d4..c6edd070340 100644 --- a/backend/onyx/server/manage/llm/models.py +++ b/backend/onyx/server/manage/llm/models.py @@ -4,6 +4,7 @@ from pydantic import Field from onyx.llm.llm_provider_options import fetch_models_for_provider +from onyx.llm.utils import get_max_input_tokens if TYPE_CHECKING: @@ -35,22 +36,36 @@ class LLMProviderDescriptor(BaseModel): fast_default_model_name: str | None is_default_provider: bool | None display_model_names: list[str] | None + model_token_limits: dict[str, int] | None = None @classmethod def from_model( cls, llm_provider_model: "LLMProviderModel" ) -> "LLMProviderDescriptor": + model_names = ( + llm_provider_model.model_names + or fetch_models_for_provider(llm_provider_model.provider) + or [llm_provider_model.default_model_name] + ) + + model_token_rate = ( + { + model_name: get_max_input_tokens( + model_name, llm_provider_model.provider + ) + for model_name in model_names + } + if model_names is not None + else None + ) return cls( name=llm_provider_model.name, provider=llm_provider_model.provider, default_model_name=llm_provider_model.default_model_name, fast_default_model_name=llm_provider_model.fast_default_model_name, is_default_provider=llm_provider_model.is_default_provider, - model_names=( - llm_provider_model.model_names - or fetch_models_for_provider(llm_provider_model.provider) - or [llm_provider_model.default_model_name] - ), + model_names=model_names, + model_token_limits=model_token_rate, display_model_names=llm_provider_model.display_model_names, ) @@ -80,6 +95,7 @@ class FullLLMProvider(LLMProvider): id: int is_default_provider: bool | None = None model_names: list[str] + model_token_limits: dict[str, int] | None = None @classmethod def from_model(cls, llm_provider_model: "LLMProviderModel") -> "FullLLMProvider": @@ -100,6 +116,14 @@ def from_model(cls, llm_provider_model: "LLMProviderModel") -> "FullLLMProvider" or fetch_models_for_provider(llm_provider_model.provider) or [llm_provider_model.default_model_name] ), + model_token_limits={ + model_name: get_max_input_tokens( + model_name, llm_provider_model.provider + ) + for model_name in llm_provider_model.model_names + } + if llm_provider_model.model_names is not None + else None, is_public=llm_provider_model.is_public, groups=[group.id for group in llm_provider_model.groups], deployment_name=llm_provider_model.deployment_name, diff --git a/backend/onyx/server/query_and_chat/chat_backend.py b/backend/onyx/server/query_and_chat/chat_backend.py index 505047c6da7..07fd934dd20 100644 --- a/backend/onyx/server/query_and_chat/chat_backend.py +++ b/backend/onyx/server/query_and_chat/chat_backend.py @@ -3,6 +3,7 @@ import io import json import os +import time import uuid from collections.abc import Callable from collections.abc import Generator @@ -29,10 +30,12 @@ compute_max_document_tokens_for_persona, ) from onyx.configs.app_configs import WEB_DOMAIN +from onyx.configs.constants import DocumentSource from onyx.configs.constants import FileOrigin from onyx.configs.constants import MessageType from onyx.configs.constants import MilestoneRecordType from onyx.configs.model_configs import LITELLM_PASS_THROUGH_HEADERS +from onyx.connectors.models import InputType from onyx.db.chat import add_chats_to_session_from_slack_thread from onyx.db.chat import create_chat_session from onyx.db.chat import create_new_chat_message @@ -47,13 +50,18 @@ from onyx.db.chat import set_as_latest_chat_message from onyx.db.chat import translate_db_message_to_chat_message_detail from onyx.db.chat import update_chat_session +from onyx.db.connector import create_connector +from onyx.db.connector_credential_pair import add_credential_to_connector +from onyx.db.credentials import create_credential from onyx.db.chat_search import search_chat_sessions from onyx.db.engine import get_session from onyx.db.engine import get_session_with_tenant +from onyx.db.enums import AccessType 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.persona import get_persona_by_id +from onyx.db.user_documents import create_user_files from onyx.file_processing.extract_file_text import docx_to_txt_filename from onyx.file_processing.extract_file_text import extract_file_text from onyx.file_store.file_store import get_default_file_store @@ -66,6 +74,8 @@ from onyx.secondary_llm_flows.chat_session_naming import ( get_renamed_conversation_name, ) +from onyx.server.documents.models import ConnectorBase +from onyx.server.documents.models import CredentialBase from onyx.server.query_and_chat.models import ChatFeedbackRequest from onyx.server.query_and_chat.models import ChatMessageIdentifier from onyx.server.query_and_chat.models import ChatRenameRequest @@ -91,6 +101,7 @@ from onyx.utils.telemetry import create_milestone_and_report from shared_configs.contextvars import get_current_tenant_id +RECENT_DOCS_FOLDER_ID = -1 logger = setup_logger() @@ -647,7 +658,7 @@ def seed_chat_from_slack( 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"} @@ -685,17 +696,11 @@ def upload_files_for_chat( if file.content_type in image_content_types: error_detail = "Unsupported image file type. Supported image types include .jpg, .jpeg, .png, .webp." elif file.content_type in text_content_types: - error_detail = "Unsupported text file type. Supported text types include .txt, .csv, .md, .mdx, .conf, " - ".log, .tsv." + error_detail = "Unsupported text file type." elif file.content_type in csv_content_types: - error_detail = ( - "Unsupported CSV file type. Supported CSV types include .csv." - ) + error_detail = "Unsupported CSV file type." else: - error_detail = ( - "Unsupported document file type. Supported document types include .pdf, .docx, .pptx, .xlsx, " - ".json, .xml, .yml, .yaml, .eml, .epub." - ) + error_detail = "Unsupported document file type." raise HTTPException(status_code=400, detail=error_detail) if ( @@ -743,11 +748,12 @@ def upload_files_for_chat( file_type=new_content_type or file_type.value, ) - # if the file is a doc, extract text and store that so we don't need - # to re-extract it every time we send a message + # 4) If the file is a doc, extract text and store that separately if file_type == ChatFileType.DOC: + # Re-wrap bytes in a fresh BytesIO so we start at position 0 + extracted_text_io = io.BytesIO(file_content) extracted_text = extract_file_text( - file=file_content_io, # use the bytes we already read + file=extracted_text_io, # use the bytes we already read file_name=file.filename or "", ) text_file_id = str(uuid.uuid4()) @@ -759,13 +765,57 @@ def upload_files_for_chat( file_origin=FileOrigin.CHAT_UPLOAD, file_type="text/plain", ) - # for DOC type, just return this for the FileDescriptor - # as we would always use this as the ID to attach to the - # message + # Return the text file as the "main" file descriptor for doc types file_info.append((text_file_id, file.filename, ChatFileType.PLAIN_TEXT)) else: file_info.append((file_id, file.filename, file_type)) + # 5) Create a user file for each uploaded file + user_files = create_user_files([file], RECENT_DOCS_FOLDER_ID, user, db_session) + for user_file in user_files: + # 6) Create connector + connector_base = ConnectorBase( + name=f"UserFile-{int(time.time())}", + source=DocumentSource.FILE, + input_type=InputType.LOAD_STATE, + connector_specific_config={ + "file_locations": [user_file.file_id], + }, + refresh_freq=None, + prune_freq=None, + indexing_start=None, + ) + connector = create_connector( + db_session=db_session, + connector_data=connector_base, + ) + + # 7) Create credential + credential_info = CredentialBase( + credential_json={}, + admin_public=True, + source=DocumentSource.FILE, + curator_public=True, + groups=[], + name=f"UserFileCredential-{int(time.time())}", + is_user_file=True, + ) + credential = create_credential(credential_info, user, db_session) + + # 8) Create connector credential pair + cc_pair = add_credential_to_connector( + db_session=db_session, + user=user, + connector_id=connector.id, + credential_id=credential.id, + cc_pair_name=f"UserFileCCPair-{int(time.time())}", + access_type=AccessType.PRIVATE, + auto_sync_options=None, + groups=[], + ) + user_file.cc_pair_id = cc_pair.data + db_session.commit() + return { "files": [ {"id": file_id, "type": file_type, "name": file_name} diff --git a/backend/onyx/server/query_and_chat/models.py b/backend/onyx/server/query_and_chat/models.py index 2c68b38f1fd..dbe4d25a139 100644 --- a/backend/onyx/server/query_and_chat/models.py +++ b/backend/onyx/server/query_and_chat/models.py @@ -92,6 +92,8 @@ class CreateChatMessageRequest(ChunkContext): message: str # Files that we should attach to this message file_descriptors: list[FileDescriptor] + user_file_ids: list[int] = [] + user_folder_ids: list[int] = [] # If no prompt provided, uses the largest prompt of the chat session # but really this should be explicitly specified, only in the simplified APIs is this inferred @@ -118,7 +120,7 @@ class CreateChatMessageRequest(ChunkContext): # this does persist in the chat thread details temperature_override: float | None = None - # allow user to specify an alternate assistnat + # allow user to specify an alternate assistant alternate_assistant_id: int | None = None # This takes the priority over the prompt_override @@ -135,6 +137,8 @@ class CreateChatMessageRequest(ChunkContext): # https://platform.openai.com/docs/guides/structured-outputs/introduction structured_response_format: dict | None = None + force_user_file_search: bool = False + # If true, ignores most of the search options and uses pro search instead. # TODO: decide how many of the above options we want to pass through to pro search use_agentic_search: bool = False diff --git a/backend/onyx/server/user_documents/api.py b/backend/onyx/server/user_documents/api.py new file mode 100644 index 00000000000..f55de1e59f8 --- /dev/null +++ b/backend/onyx/server/user_documents/api.py @@ -0,0 +1,443 @@ +import io +import time +from typing import List + +import requests +import sqlalchemy.exc +from bs4 import BeautifulSoup +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.configs.constants import DocumentSource +from onyx.connectors.models import InputType +from onyx.db.connector import create_connector +from onyx.db.connector_credential_pair import add_credential_to_connector +from onyx.db.credentials import create_credential +from onyx.db.engine import get_session +from onyx.db.enums import AccessType +from onyx.db.models import User +from onyx.db.models import UserFile +from onyx.db.models import UserFolder +from onyx.db.user_documents import create_user_files +from onyx.db.user_documents import share_file_with_assistant +from onyx.db.user_documents import share_folder_with_assistant +from onyx.db.user_documents import unshare_file_with_assistant +from onyx.db.user_documents import unshare_folder_with_assistant +from onyx.file_processing.html_utils import web_html_cleanup +from onyx.server.documents.models import ConnectorBase +from onyx.server.documents.models import CredentialBase +from onyx.server.documents.models import FileUploadResponse +from onyx.server.user_documents.models import MessageResponse +from onyx.server.user_documents.models import UserFileSnapshot +from onyx.server.user_documents.models import UserFolderSnapshot + +router = APIRouter() + + +class FolderCreationRequest(BaseModel): + name: str + description: str + + +@router.post("/user/folder") +def create_folder( + request: FolderCreationRequest, + user: User = Depends(current_user), + db_session: Session = Depends(get_session), +) -> UserFolderSnapshot: + try: + new_folder = UserFolder( + user_id=user.id if user else None, + name=request.name, + description=request.description, + ) + db_session.add(new_folder) + db_session.commit() + return UserFolderSnapshot.from_model(new_folder) + except sqlalchemy.exc.DataError as e: + if "StringDataRightTruncation" in str(e): + raise HTTPException( + status_code=400, + detail="Folder name or description is too long. Please use a shorter name or description.", + ) + raise + + +@router.get( + "/user/folder", +) +def get_folders( + user: User = Depends(current_user), + db_session: Session = Depends(get_session), +) -> list[UserFolderSnapshot]: + user_id = user.id if user else None + folders = db_session.query(UserFolder).filter(UserFolder.user_id == user_id).all() + return [UserFolderSnapshot.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), +) -> UserFolderSnapshot: + 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") + + return UserFolderSnapshot.from_model(folder) + + +RECENT_DOCS_FOLDER_ID = -1 + + +@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: + if folder_id == 0: + folder_id = None + + user_files = create_user_files(files, folder_id, user, db_session) + for user_file in user_files: + connector_base = ConnectorBase( + name=f"UserFile-{user_file.file_id}-{int(time.time())}", + source=DocumentSource.FILE, + input_type=InputType.LOAD_STATE, + connector_specific_config={ + "file_locations": [user_file.file_id], + }, + refresh_freq=None, + prune_freq=None, + indexing_start=None, + ) + + connector = create_connector( + db_session=db_session, + connector_data=connector_base, + ) + + credential_info = CredentialBase( + credential_json={}, + admin_public=True, + source=DocumentSource.FILE, + curator_public=True, + groups=[], + name=f"UserFileCredential-{user_file.file_id}-{int(time.time())}", + is_user_file=True, + ) + credential = create_credential(credential_info, user, db_session) + + cc_pair = add_credential_to_connector( + db_session=db_session, + user=user, + connector_id=connector.id, + credential_id=credential.id, + cc_pair_name=f"UserFileCCPair-{user_file.file_id}-{int(time.time())}", + access_type=AccessType.PRIVATE, + auto_sync_options=None, + groups=[], + is_user_file=True, + ) + user_file.cc_pair_id = cc_pair.data + print("A") + db_session.commit() + + db_session.commit() + # TODO: functional document indexing + # trigger_document_indexing(db_session, user.id) + return FileUploadResponse( + file_paths=[user_file.file_id for user_file in user_files], + ) + + +@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), +) -> UserFolderSnapshot: + 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 UserFolderSnapshot.from_model(folder) + + +@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") + + +@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): + new_folder_id: int | None + + +@router.put("/user/file/{file_id}/move") +def move_file( + file_id: int, + request: FileMoveRequest, + user: User = Depends(current_user), + db_session: Session = Depends(get_session), +) -> UserFileSnapshot: + 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.folder_id = request.new_folder_id + db_session.commit() + return UserFileSnapshot.from_model(file) + + +@router.get("/user/file-system") +def get_file_system( + user: User = Depends(current_user), + db_session: Session = Depends(get_session), +) -> list[UserFolderSnapshot]: + user_id = user.id if user else None + folders = db_session.query(UserFolder).filter(UserFolder.user_id == user_id).all() + return [UserFolderSnapshot.from_model(folder) for folder in folders] + + +@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), +) -> UserFileSnapshot: + 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 UserFileSnapshot.from_model(file) + + +class ShareRequest(BaseModel): + assistant_id: int + + +@router.post("/user/file/{file_id}/share") +def share_file( + file_id: int, + request: ShareRequest, + 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") + + share_file_with_assistant(file_id, request.assistant_id, db_session) + return MessageResponse(message="File shared successfully with the assistant") + + +@router.post("/user/file/{file_id}/unshare") +def unshare_file( + file_id: int, + request: ShareRequest, + 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") + + unshare_file_with_assistant(file_id, request.assistant_id, db_session) + return MessageResponse(message="File unshared successfully from the assistant") + + +@router.post("/user/folder/{folder_id}/share") +def share_folder( + folder_id: int, + request: ShareRequest, + 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") + + share_folder_with_assistant(folder_id, request.assistant_id, db_session) + return MessageResponse( + message="Folder and its files shared successfully with the assistant" + ) + + +@router.post("/user/folder/{folder_id}/unshare") +def unshare_folder( + folder_id: int, + request: ShareRequest, + 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") + + unshare_folder_with_assistant(folder_id, request.assistant_id, db_session) + return MessageResponse( + message="Folder and its files unshared successfully from the assistant" + ) + + +class CreateFileFromLinkRequest(BaseModel): + url: str + folder_id: int | None + + +@router.post("/user/file/create-from-link") +def create_file_from_link( + request: CreateFileFromLinkRequest, + user: User = Depends(current_user), + db_session: Session = Depends(get_session), +) -> FileUploadResponse: + try: + response = requests.get(request.url) + response.raise_for_status() + content = response.text + soup = BeautifulSoup(content, "html.parser") + parsed_html = web_html_cleanup(soup, mintlify_cleanup_enabled=False) + + file_name = f"{parsed_html.title or 'Untitled'}.txt" + file_content = parsed_html.cleaned_text.encode() + + file = UploadFile(filename=file_name, file=io.BytesIO(file_content)) + user_files = create_user_files([file], request.folder_id, user, db_session) + + # Create connector and credential (same as in upload_user_files) + for user_file in user_files: + connector_base = ConnectorBase( + name=f"UserFile-{user_file.file_id}-{int(time.time())}", + source=DocumentSource.FILE, + input_type=InputType.LOAD_STATE, + connector_specific_config={ + "file_locations": [user_file.file_id], + }, + refresh_freq=None, + prune_freq=None, + indexing_start=None, + ) + + connector = create_connector( + db_session=db_session, + connector_data=connector_base, + ) + + credential_info = CredentialBase( + credential_json={}, + admin_public=True, + source=DocumentSource.FILE, + curator_public=True, + groups=[], + name=f"UserFileCredential-{user_file.file_id}-{int(time.time())}", + ) + credential = create_credential(credential_info, user, db_session) + + cc_pair = add_credential_to_connector( + db_session=db_session, + user=user, + connector_id=connector.id, + credential_id=credential.id, + cc_pair_name=f"UserFileCCPair-{int(time.time())}", + access_type=AccessType.PRIVATE, + auto_sync_options=None, + groups=[], + is_user_file=True, + ) + user_file.cc_pair_id = cc_pair.data + db_session.commit() + + db_session.commit() + return FileUploadResponse( + file_paths=[user_file.file_id for user_file in user_files] + ) + except requests.RequestException as e: + raise HTTPException(status_code=400, detail=f"Failed to fetch URL: {str(e)}") diff --git a/backend/onyx/server/user_documents/models.py b/backend/onyx/server/user_documents/models.py new file mode 100644 index 00000000000..4715d13d6c2 --- /dev/null +++ b/backend/onyx/server/user_documents/models.py @@ -0,0 +1,70 @@ +from datetime import datetime +from typing import List + +from pydantic import BaseModel + +from onyx.db.models import UserFile +from onyx.db.models import UserFolder + + +class UserFileSnapshot(BaseModel): + id: int + name: str + document_id: str + folder_id: int | None = None + user_id: int | None + file_id: str + created_at: datetime + assistant_ids: List[int] = [] # List of assistant IDs + token_count: int | None + indexed: bool + + @classmethod + def from_model(cls, model: UserFile) -> "UserFileSnapshot": + return cls( + id=model.id, + name=model.name, + folder_id=model.folder_id, + document_id=model.document_id, + user_id=model.user_id, + file_id=model.file_id, + created_at=model.created_at, + assistant_ids=[assistant.id for assistant in model.assistants], + token_count=model.token_count, + indexed=model.cc_pair.last_successful_index_time is not None + if model.cc_pair + else False, + ) + + +class UserFolderSnapshot(BaseModel): + id: int + name: str + description: str + files: List[UserFileSnapshot] + created_at: datetime + user_id: int | None + assistant_ids: List[int] = [] # List of assistant IDs + token_count: int | None + + @classmethod + def from_model(cls, model: UserFolder) -> "UserFolderSnapshot": + return cls( + id=model.id, + name=model.name, + description=model.description, + files=[UserFileSnapshot.from_model(file) for file in model.files], + created_at=model.created_at, + user_id=model.user_id, + assistant_ids=[assistant.id for assistant in model.assistants], + token_count=sum(file.token_count or 0 for file in model.files) or None, + ) + + +class MessageResponse(BaseModel): + message: str + + +class FileSystemResponse(BaseModel): + folders: list[UserFolderSnapshot] + files: list[UserFileSnapshot] diff --git a/backend/onyx/tools/tool_constructor.py b/backend/onyx/tools/tool_constructor.py index 4650dd3b0ee..8c406086e6a 100644 --- a/backend/onyx/tools/tool_constructor.py +++ b/backend/onyx/tools/tool_constructor.py @@ -138,6 +138,7 @@ def construct_tools( user: User | None, llm: LLM, fast_llm: LLM, + use_file_search: bool, search_tool_config: SearchToolConfig | None = None, internet_search_tool_config: InternetSearchToolConfig | None = None, image_generation_tool_config: ImageGenerationToolConfig | None = None, @@ -251,6 +252,33 @@ def construct_tools( for tool_list in tool_dict.values(): tools.extend(tool_list) + if use_file_search: + search_tool_config = SearchToolConfig() + + search_tool = SearchTool( + db_session=db_session, + user=user, + persona=persona, + retrieval_options=search_tool_config.retrieval_options, + prompt_config=prompt_config, + llm=llm, + fast_llm=fast_llm, + pruning_config=search_tool_config.document_pruning_config, + answer_style_config=search_tool_config.answer_style_config, + selected_sections=search_tool_config.selected_sections, + chunks_above=search_tool_config.chunks_above, + chunks_below=search_tool_config.chunks_below, + full_doc=search_tool_config.full_doc, + evaluation_type=( + LLMEvaluationType.BASIC + if persona.llm_relevance_filter + else LLMEvaluationType.SKIP + ), + rerank_settings=search_tool_config.rerank_settings, + bypass_acl=search_tool_config.bypass_acl, + ) + tool_dict[1] = [search_tool] + # factor in tool definition size when pruning if search_tool_config: search_tool_config.document_pruning_config.tool_num_tokens = ( diff --git a/backend/onyx/tools/tool_implementations/custom/custom_tool.py b/backend/onyx/tools/tool_implementations/custom/custom_tool.py index d38e0accbd1..cf11d137f77 100644 --- a/backend/onyx/tools/tool_implementations/custom/custom_tool.py +++ b/backend/onyx/tools/tool_implementations/custom/custom_tool.py @@ -64,7 +64,7 @@ CUSTOM_TOOL_RESPONSE_ID = "custom_tool_response" -class CustomToolFileResponse(BaseModel): +class CustomToolUserFileSnapshot(BaseModel): file_ids: List[str] # References to saved images or CSVs @@ -131,7 +131,7 @@ def build_tool_message_content( response = cast(CustomToolCallSummary, args[0].response) if response.response_type == "image" or response.response_type == "csv": - image_response = cast(CustomToolFileResponse, response.tool_result) + image_response = cast(CustomToolUserFileSnapshot, response.tool_result) return json.dumps({"file_ids": image_response.file_ids}) # For JSON or other responses, return as-is @@ -267,14 +267,14 @@ def run( file_ids = self._save_and_get_file_references( response.content, content_type ) - tool_result = CustomToolFileResponse(file_ids=file_ids) + tool_result = CustomToolUserFileSnapshot(file_ids=file_ids) response_type = "csv" elif "image/" in content_type: file_ids = self._save_and_get_file_references( response.content, content_type ) - tool_result = CustomToolFileResponse(file_ids=file_ids) + tool_result = CustomToolUserFileSnapshot(file_ids=file_ids) response_type = "image" else: @@ -358,7 +358,7 @@ def build_next_prompt( def final_result(self, *args: ToolResponse) -> JSON_ro: response = cast(CustomToolCallSummary, args[0].response) - if isinstance(response.tool_result, CustomToolFileResponse): + if isinstance(response.tool_result, CustomToolUserFileSnapshot): return response.tool_result.model_dump() return response.tool_result diff --git a/backend/scripts/debugging/onyx_vespa.py b/backend/scripts/debugging/onyx_vespa.py index 954072feb3b..9c36fa08c7e 100644 --- a/backend/scripts/debugging/onyx_vespa.py +++ b/backend/scripts/debugging/onyx_vespa.py @@ -444,12 +444,15 @@ def get_document_acls( response = vespa_client.get(document_url) if response.status_code == 200: fields = response.json().get("fields", {}) + document_id = fields.get("document_id") or fields.get( "documentid", "Unknown" ) acls = fields.get("access_control_list", {}) title = fields.get("title", "") source_type = fields.get("source_type", "") + doc_sets = fields.get("document_sets", []) + user_file = fields.get("user_file", None) source_links_raw = fields.get("source_links", "{}") try: source_links = json.loads(source_links_raw) @@ -462,6 +465,8 @@ def get_document_acls( print(f"Source Links: {source_links}") print(f"Title: {title}") print(f"Source Type: {source_type}") + print(f"Document Sets: {doc_sets}") + print(f"User File: {user_file}") if MULTI_TENANT: print(f"Tenant ID: {fields.get('tenant_id', 'N/A')}") print("-" * 80) diff --git a/deployment/docker_compose/docker-compose.prod.yml b/deployment/docker_compose/docker-compose.prod.yml index 90a1aa9c11c..df53fafd922 100644 --- a/deployment/docker_compose/docker-compose.prod.yml +++ b/deployment/docker_compose/docker-compose.prod.yml @@ -3,7 +3,7 @@ services: image: onyxdotapp/onyx-backend:${IMAGE_TAG:-latest} build: context: ../../backend - dockerfile: Dockerfile + is dockerfile: Dockerfile command: > /bin/sh -c " alembic upgrade head && diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000000..e23887ea68c --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"] +} \ No newline at end of file diff --git a/web/package-lock.json b/web/package-lock.json index e0fd1e98101..57e4b1a78c9 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -18,10 +18,12 @@ "@radix-ui/react-accordion": "^1.2.2", "@radix-ui/react-checkbox": "^1.1.2", "@radix-ui/react-collapsible": "^1.1.2", + "@radix-ui/react-context-menu": "^2.2.5", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-label": "^2.1.1", "@radix-ui/react-popover": "^1.1.6", + "@radix-ui/react-progress": "^1.1.1", "@radix-ui/react-radio-group": "^1.2.2", "@radix-ui/react-scroll-area": "^1.2.2", "@radix-ui/react-select": "^2.1.6", @@ -855,15 +857,6 @@ "react": ">=16.8.0" } }, - "node_modules/@emnapi/runtime": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.3.1.tgz", - "integrity": "sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw==", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emotion/babel-plugin": { "version": "11.12.0", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.12.0.tgz", @@ -959,74 +952,6 @@ "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==" }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.0.tgz", - "integrity": "sha512-WtKdFM7ls47zkKHFVzMz8opM7LkcsIp9amDUBIAWirg70RM71WRSjdILPsY5Uv1D42ZpUfaPILDlfactHgsRkw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.0.tgz", - "integrity": "sha512-arAtTPo76fJ/ICkXWetLCc9EwEHKaeya4vMrReVlEIUCAUncH7M4bhMQ+M9Vf+FFOZJdTNMXNBrWwW+OXWpSew==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.0.tgz", - "integrity": "sha512-Vsm497xFM7tTIPYK9bNTYJyF/lsP590Qc1WxJdlB6ljCbdZKU9SY8i7+Iin4kyhV/KV5J2rOKsBQbB77Ab7L/w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.0.tgz", - "integrity": "sha512-t8GrvnFkiIY7pa7mMgJd7p8p8qqYIz1NYiAoKc75Zyv73L3DZW++oYMSHPRarcotTKuSs6m3hTOa5CKHaS02TQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, "node_modules/@esbuild/darwin-arm64": { "version": "0.24.0", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.0.tgz", @@ -1044,451 +969,128 @@ "node": ">=18" } }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.0.tgz", - "integrity": "sha512-rgtz6flkVkh58od4PwTRqxbKH9cOjaXCMZgWD905JOzjFKW+7EiUObfd/Kav+A6Gyud6WZk9w+xu6QLytdi2OA==", - "cpu": [ - "x64" - ], + "node_modules/@eslint-community/eslint-utils": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", + "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "dependencies": { + "eslint-visitor-keys": "^3.3.0" + }, "engines": { - "node": ">=18" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.0.tgz", - "integrity": "sha512-6Mtdq5nHggwfDNLAHkPlyLBpE5L6hwsuXZX8XNmHno9JuL2+bg2BX5tRkwjyfn6sKbxZTq68suOjgWqCicvPXA==", - "cpu": [ - "arm64" - ], + "node_modules/@eslint-community/regexpp": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz", + "integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], "engines": { - "node": ">=18" + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.0.tgz", - "integrity": "sha512-D3H+xh3/zphoX8ck4S2RxKR6gHlHDXXzOf6f/9dbFt/NRBDIE33+cVa49Kil4WUjxMGW0ZIYBYtaGCa2+OsQwQ==", - "cpu": [ - "x64" - ], + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, "engines": { - "node": ">=18" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/@esbuild/linux-arm": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.0.tgz", - "integrity": "sha512-gJKIi2IjRo5G6Glxb8d3DzYXlxdEj2NlkixPsqePSZMhLudqPhtZ4BUrpIuTjJYXxvF9njql+vRjB2oaC9XpBw==", - "cpu": [ - "arm" - ], + "node_modules/@eslint/js": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", + "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": ">=18" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.0.tgz", - "integrity": "sha512-TDijPXTOeE3eaMkRYpcy3LarIg13dS9wWHRdwYRnzlwlA370rNdZqbcp0WTyyV/k2zSxfko52+C7jU5F9Tfj1g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" + "node_modules/@floating-ui/core": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.2.tgz", + "integrity": "sha512-+2XpQV9LLZeanU4ZevzRnGFg2neDeKHgFLjP6YLW+tly0IvrhqT4u8enLGjLH3qeh85g19xY5rsAusfwTdn5lg==", + "dependencies": { + "@floating-ui/utils": "^0.2.0" } }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.0.tgz", - "integrity": "sha512-K40ip1LAcA0byL05TbCQ4yJ4swvnbzHscRmUilrmP9Am7//0UjPreh4lpYzvThT2Quw66MhjG//20mrufm40mA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" + "node_modules/@floating-ui/dom": { + "version": "1.6.5", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.5.tgz", + "integrity": "sha512-Nsdud2X65Dz+1RHjAIP0t8z5e2ff/IRbei6BqFrl1urT8sDVzM1HMQ+R0XcU5ceRfyO3I6ayeqIfh+6Wb8LGTw==", + "dependencies": { + "@floating-ui/core": "^1.0.0", + "@floating-ui/utils": "^0.2.0" } }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.0.tgz", - "integrity": "sha512-0mswrYP/9ai+CU0BzBfPMZ8RVm3RGAN/lmOMgW4aFUSOQBjA31UP8Mr6DDhWSuMwj7jaWOT0p0WoZ6jeHhrD7g==", - "cpu": [ - "loong64" - ], - "dev": true, + "node_modules/@floating-ui/react": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.3.tgz", + "integrity": "sha512-CLHnes3ixIFFKVQDdICjel8muhFLOBdQH7fgtHNPY8UbCNqbeKZ262G7K66lGQOUQWWnYocf7ZbUsLJgGfsLHg==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@floating-ui/react-dom": "^2.1.2", + "@floating-ui/utils": "^0.2.9", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=17.0.0", + "react-dom": ">=17.0.0" } }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.0.tgz", - "integrity": "sha512-hIKvXm0/3w/5+RDtCJeXqMZGkI2s4oMUGj3/jM0QzhgIASWrGO5/RlzAzm5nNh/awHE0A19h/CvHQe6FaBNrRA==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" + "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", + "integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==", + "dependencies": { + "@floating-ui/dom": "^1.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" } }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.0.tgz", - "integrity": "sha512-HcZh5BNq0aC52UoocJxaKORfFODWXZxtBaaZNuN3PUX3MoDsChsZqopzi5UupRhPHSEHotoiptqikjN/B77mYQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } + "node_modules/@floating-ui/utils": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz", + "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==", + "license": "MIT" }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.0.tgz", - "integrity": "sha512-bEh7dMn/h3QxeR2KTy1DUszQjUrIHPZKyO6aN1X4BCnhfYhuQqedHaa5MxSQA/06j3GpiIlFGSsy1c7Gf9padw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "node_modules/@headlessui/react": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.0.tgz", + "integrity": "sha512-RzCEg+LXsuI7mHiSomsu/gBJSjpupm6A1qIZ5sWjd7JhARNlMiSA4kKfJpCKwU9tE+zMRterhhrP74PvfJrpXQ==", + "dependencies": { + "@floating-ui/react": "^0.26.16", + "@react-aria/focus": "^3.17.1", + "@react-aria/interactions": "^3.21.3", + "@tanstack/react-virtual": "^3.8.1" + }, "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.0.tgz", - "integrity": "sha512-ZcQ6+qRkw1UcZGPyrCiHHkmBaj9SiCD8Oqd556HldP+QlpUIe2Wgn3ehQGVoPOvZvtHm8HPx+bH20c9pvbkX3g==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.0.tgz", - "integrity": "sha512-vbutsFqQ+foy3wSSbmjBXXIJ6PL3scghJoM8zCL142cGaZKAdCZHyf+Bpu/MmX9zT9Q0zFBVKb36Ma5Fzfa8xA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.0.tgz", - "integrity": "sha512-hjQ0R/ulkO8fCYFsG0FZoH+pWgTTDreqpqY7UnQntnaKv95uP5iW3+dChxnx7C3trQQU40S+OgWhUVwCjVFLvg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.0.tgz", - "integrity": "sha512-MD9uzzkPQbYehwcN583yx3Tu5M8EIoTD+tUgKF982WYL9Pf5rKy9ltgD0eUgs8pvKnmizxjXZyLt0z6DC3rRXg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.0.tgz", - "integrity": "sha512-4ir0aY1NGUhIC1hdoCzr1+5b43mw99uNwVzhIq1OY3QcEwPDO3B7WNXBzaKY5Nsf1+N11i1eOfFcq+D/gOS15Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.0.tgz", - "integrity": "sha512-jVzdzsbM5xrotH+W5f1s+JtUy1UWgjU0Cf4wMvffTB8m6wP5/kx0KiaLHlbJO+dMgtxKV8RQ/JvtlFcdZ1zCPA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.0.tgz", - "integrity": "sha512-iKc8GAslzRpBytO2/aN3d2yb2z8XTVfNV0PjGlCxKo5SgWmNXx82I/Q3aG1tFfS+A2igVCY97TJ8tnYwpUWLCA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.0.tgz", - "integrity": "sha512-vQW36KZolfIudCcTnaTpmLQ24Ha1RjygBo39/aLkM2kmjkWmZGEJ5Gn9l5/7tzXA42QGIoWbICfg6KLLkIw6yw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.0.tgz", - "integrity": "sha512-7IAFPrjSQIJrGsK6flwg7NFmwBoSTyF3rl7If0hNUFQU4ilTsEPL6GuMuU9BfIWVVGuRnuIidkSMC+c0Otu8IA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", - "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", - "dev": true, - "dependencies": { - "eslint-visitor-keys": "^3.3.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.10.0", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz", - "integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==", - "dev": true, - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", - "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", - "dev": true, - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^9.6.0", - "globals": "^13.19.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/js": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", - "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", - "dev": true, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, - "node_modules/@floating-ui/core": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.2.tgz", - "integrity": "sha512-+2XpQV9LLZeanU4ZevzRnGFg2neDeKHgFLjP6YLW+tly0IvrhqT4u8enLGjLH3qeh85g19xY5rsAusfwTdn5lg==", - "dependencies": { - "@floating-ui/utils": "^0.2.0" - } - }, - "node_modules/@floating-ui/dom": { - "version": "1.6.5", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.5.tgz", - "integrity": "sha512-Nsdud2X65Dz+1RHjAIP0t8z5e2ff/IRbei6BqFrl1urT8sDVzM1HMQ+R0XcU5ceRfyO3I6ayeqIfh+6Wb8LGTw==", - "dependencies": { - "@floating-ui/core": "^1.0.0", - "@floating-ui/utils": "^0.2.0" - } - }, - "node_modules/@floating-ui/react": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.3.tgz", - "integrity": "sha512-CLHnes3ixIFFKVQDdICjel8muhFLOBdQH7fgtHNPY8UbCNqbeKZ262G7K66lGQOUQWWnYocf7ZbUsLJgGfsLHg==", - "license": "MIT", - "dependencies": { - "@floating-ui/react-dom": "^2.1.2", - "@floating-ui/utils": "^0.2.9", - "tabbable": "^6.0.0" - }, - "peerDependencies": { - "react": ">=17.0.0", - "react-dom": ">=17.0.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", - "integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==", - "dependencies": { - "@floating-ui/dom": "^1.0.0" - }, - "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" - } - }, - "node_modules/@floating-ui/utils": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz", - "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==", - "license": "MIT" - }, - "node_modules/@headlessui/react": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.0.tgz", - "integrity": "sha512-RzCEg+LXsuI7mHiSomsu/gBJSjpupm6A1qIZ5sWjd7JhARNlMiSA4kKfJpCKwU9tE+zMRterhhrP74PvfJrpXQ==", - "dependencies": { - "@floating-ui/react": "^0.26.16", - "@react-aria/focus": "^3.17.1", - "@react-aria/interactions": "^3.21.3", - "@tanstack/react-virtual": "^3.8.1" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "react": "^18 || ^19 || ^19.0.0-rc", - "react-dom": "^18 || ^19 || ^19.0.0-rc" + "node": ">=10" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "node_modules/@headlessui/react/node_modules/@floating-ui/react": { @@ -1626,409 +1228,103 @@ "version": "3.25.0", "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.25.0.tgz", "integrity": "sha512-OZSyhzU6vTdW3eV/mz5i6hQwQUhkRs7xwY2d1aqPvTdMe0+2cY7Fwp45PAiwYLEj73i9ro2FxF9qC4DvHGSCgQ==", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/@headlessui/react/node_modules/@tanstack/react-virtual": { - "version": "3.10.8", - "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.10.8.tgz", - "integrity": "sha512-VbzbVGSsZlQktyLrP5nxE+vE1ZR+U0NFAWPbJLoG2+DKPwd2D7dVICTVIIaYlJqX1ZCEnYDbaOpmMwbsyhBoIA==", - "dependencies": { - "@tanstack/virtual-core": "3.10.8" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" - } - }, - "node_modules/@headlessui/tailwindcss": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@headlessui/tailwindcss/-/tailwindcss-0.2.1.tgz", - "integrity": "sha512-2+5+NZ+RzMyrVeCZOxdbvkUSssSxGvcUxphkIfSVLpRiKsj+/63T2TOL9dBYMXVfj/CGr6hMxSRInzXv6YY7sA==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "tailwindcss": "^3.0" - } - }, - "node_modules/@humanwhocodes/config-array": { - "version": "0.11.14", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", - "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", - "dev": true, - "dependencies": { - "@humanwhocodes/object-schema": "^2.0.2", - "debug": "^4.3.1", - "minimatch": "^3.0.5" - }, - "engines": { - "node": ">=10.10.0" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/object-schema": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", - "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", - "dev": true - }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", - "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.0.4" - } - }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", - "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.0.4" - } - }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", - "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", - "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", - "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", - "cpu": [ - "arm" - ], - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", - "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", - "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", - "cpu": [ - "s390x" - ], - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", - "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", - "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", - "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-linux-arm": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", - "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", - "cpu": [ - "arm" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.0.5" - } - }, - "node_modules/@img/sharp-linux-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", - "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.0.4" - } - }, - "node_modules/@img/sharp-linux-s390x": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", - "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", - "cpu": [ - "s390x" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.0.4" - } - }, - "node_modules/@img/sharp-linux-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", - "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.0.4" + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0" } }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", - "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node_modules/@headlessui/react/node_modules/@tanstack/react-virtual": { + "version": "3.10.8", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.10.8.tgz", + "integrity": "sha512-VbzbVGSsZlQktyLrP5nxE+vE1ZR+U0NFAWPbJLoG2+DKPwd2D7dVICTVIIaYlJqX1ZCEnYDbaOpmMwbsyhBoIA==", + "dependencies": { + "@tanstack/virtual-core": "3.10.8" }, "funding": { - "url": "https://opencollective.com/libvips" + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, - "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", - "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], + "node_modules/@headlessui/tailwindcss": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@headlessui/tailwindcss/-/tailwindcss-0.2.1.tgz", + "integrity": "sha512-2+5+NZ+RzMyrVeCZOxdbvkUSssSxGvcUxphkIfSVLpRiKsj+/63T2TOL9dBYMXVfj/CGr6hMxSRInzXv6YY7sA==", + "license": "MIT", "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" + "node": ">=10" }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + "peerDependencies": { + "tailwindcss": "^3.0" } }, - "node_modules/@img/sharp-wasm32": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", - "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", - "cpu": [ - "wasm32" - ], - "optional": true, + "node_modules/@humanwhocodes/config-array": { + "version": "0.11.14", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", + "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", + "dev": true, "dependencies": { - "@emnapi/runtime": "^1.2.0" + "@humanwhocodes/object-schema": "^2.0.2", + "debug": "^4.3.1", + "minimatch": "^3.0.5" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "engines": { + "node": ">=12.22" }, "funding": { - "url": "https://opencollective.com/libvips" + "type": "github", + "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@img/sharp-win32-ia32": { + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "dev": true + }, + "node_modules/@img/sharp-darwin-arm64": { "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", - "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", "cpu": [ - "ia32" + "arm64" ], "optional": true, "os": [ - "win32" + "darwin" ], "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" } }, - "node_modules/@img/sharp-win32-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", - "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", "cpu": [ - "x64" + "arm64" ], "optional": true, "os": [ - "win32" + "darwin" ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, "funding": { "url": "https://opencollective.com/libvips" } @@ -2659,111 +1955,6 @@ "node": ">= 10" } }, - "node_modules/@next/swc-darwin-x64": { - "version": "15.0.2", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.0.2.tgz", - "integrity": "sha512-KUpBVxIbjzFiUZhiLIpJiBoelqzQtVZbdNNsehhUn36e2YzKHphnK8eTUW1s/4aPy5kH/UTid8IuVbaOpedhpw==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-gnu": { - "version": "15.0.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.0.2.tgz", - "integrity": "sha512-9J7TPEcHNAZvwxXRzOtiUvwtTD+fmuY0l7RErf8Yyc7kMpE47MIQakl+3jecmkhOoIyi/Rp+ddq7j4wG6JDskQ==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-musl": { - "version": "15.0.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.0.2.tgz", - "integrity": "sha512-BjH4ZSzJIoTTZRh6rG+a/Ry4SW0HlizcPorqNBixBWc3wtQtj4Sn9FnRZe22QqrPnzoaW0ctvSz4FaH4eGKMww==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-gnu": { - "version": "15.0.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.0.2.tgz", - "integrity": "sha512-i3U2TcHgo26sIhcwX/Rshz6avM6nizrZPvrDVDY1bXcLH1ndjbO8zuC7RoHp0NSK7wjJMPYzm7NYL1ksSKFreA==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-musl": { - "version": "15.0.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.0.2.tgz", - "integrity": "sha512-AMfZfSVOIR8fa+TXlAooByEF4OB00wqnms1sJ1v+iu8ivwvtPvnkwdzzFMpsK5jA2S9oNeeQ04egIWVb4QWmtQ==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-arm64-msvc": { - "version": "15.0.2", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.0.2.tgz", - "integrity": "sha512-JkXysDT0/hEY47O+Hvs8PbZAeiCQVxKfGtr4GUpNAhlG2E0Mkjibuo8ryGD29Qb5a3IOnKYNoZlh/MyKd2Nbww==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-x64-msvc": { - "version": "15.0.2", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.0.2.tgz", - "integrity": "sha512-foaUL0NqJY/dX0Pi/UcZm5zsmSk5MtP/gxx3xOPyREkMFN+CTjctPfu3QaqrQHinaKdPnMWPJDKt4VjDfTBe/Q==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -3835,17 +3026,89 @@ } } }, - "node_modules/@radix-ui/react-context": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.0.tgz", - "integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==", + "node_modules/@radix-ui/react-context": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.0.tgz", + "integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.6.tgz", + "integrity": "sha512-aUP99QZ3VU84NPsHeaFt4cQUNgJqFsLLOt/RbbWXszZ6MP0DpDyjkFZORr4RpAEx3sUBk+Kc8h13yGtC5Qw8dg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-menu": "2.1.6", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz", + "integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-context": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", + "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-primitive": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz", + "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.1.2" + }, "peerDependencies": { "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true + }, + "@types/react-dom": { + "optional": true } } }, @@ -4951,6 +4214,68 @@ } } }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.2.tgz", + "integrity": "sha512-u1IgJFQ4zNAUTjGdDL5dcl/U8ntOR6jsnhxKb5RKp5Ozwl88xKR9EqRZOe/Mk8tnx0x5tNUe2F+MzsyjqMg0MA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-context": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", + "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-primitive": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz", + "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-radio-group": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.2.2.tgz", @@ -6449,108 +5774,6 @@ "node": ">=10" } }, - "node_modules/@sentry/cli-linux-arm": { - "version": "2.39.1", - "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-2.39.1.tgz", - "integrity": "sha512-DkENbxyRxUrfLnJLXTA4s5UL/GoctU5Cm4ER1eB7XN7p9WsamFJd/yf2KpltkjEyiTuplv0yAbdjl1KX3vKmEQ==", - "cpu": [ - "arm" - ], - "license": "BSD-3-Clause", - "optional": true, - "os": [ - "linux", - "freebsd" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@sentry/cli-linux-arm64": { - "version": "2.39.1", - "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.39.1.tgz", - "integrity": "sha512-5VbVJDatolDrWOgaffsEM7znjs0cR8bHt9Bq0mStM3tBolgAeSDHE89NgHggfZR+DJ2VWOy4vgCwkObrUD6NQw==", - "cpu": [ - "arm64" - ], - "license": "BSD-3-Clause", - "optional": true, - "os": [ - "linux", - "freebsd" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@sentry/cli-linux-i686": { - "version": "2.39.1", - "resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-2.39.1.tgz", - "integrity": "sha512-pXWVoKXCRrY7N8vc9H7mETiV9ZCz+zSnX65JQCzZxgYrayQPJTc+NPRnZTdYdk5RlAupXaFicBI2GwOCRqVRkg==", - "cpu": [ - "x86", - "ia32" - ], - "license": "BSD-3-Clause", - "optional": true, - "os": [ - "linux", - "freebsd" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@sentry/cli-linux-x64": { - "version": "2.39.1", - "resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-2.39.1.tgz", - "integrity": "sha512-IwayNZy+it7FWG4M9LayyUmG1a/8kT9+/IEm67sT5+7dkMIMcpmHDqL8rWcPojOXuTKaOBBjkVdNMBTXy0mXlA==", - "cpu": [ - "x64" - ], - "license": "BSD-3-Clause", - "optional": true, - "os": [ - "linux", - "freebsd" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@sentry/cli-win32-i686": { - "version": "2.39.1", - "resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-2.39.1.tgz", - "integrity": "sha512-NglnNoqHSmE+Dz/wHeIVRnV2bLMx7tIn3IQ8vXGO5HWA2f8zYJGktbkLq1Lg23PaQmeZLPGlja3gBQfZYSG10Q==", - "cpu": [ - "x86", - "ia32" - ], - "license": "BSD-3-Clause", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@sentry/cli-win32-x64": { - "version": "2.39.1", - "resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-2.39.1.tgz", - "integrity": "sha512-xv0R2CMf/X1Fte3cMWie1NXuHmUyQPDBfCyIt6k6RPFPxAYUgcqgMPznYwVMwWEA1W43PaOkSn3d8ZylsDaETw==", - "cpu": [ - "x64" - ], - "license": "BSD-3-Clause", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=10" - } - }, "node_modules/@sentry/core": { "version": "8.50.0", "resolved": "https://registry.npmjs.org/@sentry/core/-/core-8.50.0.tgz", @@ -21711,6 +20934,111 @@ "type": "github", "url": "https://github.com/sponsors/wooorm" } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "15.0.2", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.0.2.tgz", + "integrity": "sha512-KUpBVxIbjzFiUZhiLIpJiBoelqzQtVZbdNNsehhUn36e2YzKHphnK8eTUW1s/4aPy5kH/UTid8IuVbaOpedhpw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "15.0.2", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.0.2.tgz", + "integrity": "sha512-9J7TPEcHNAZvwxXRzOtiUvwtTD+fmuY0l7RErf8Yyc7kMpE47MIQakl+3jecmkhOoIyi/Rp+ddq7j4wG6JDskQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "15.0.2", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.0.2.tgz", + "integrity": "sha512-BjH4ZSzJIoTTZRh6rG+a/Ry4SW0HlizcPorqNBixBWc3wtQtj4Sn9FnRZe22QqrPnzoaW0ctvSz4FaH4eGKMww==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "15.0.2", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.0.2.tgz", + "integrity": "sha512-i3U2TcHgo26sIhcwX/Rshz6avM6nizrZPvrDVDY1bXcLH1ndjbO8zuC7RoHp0NSK7wjJMPYzm7NYL1ksSKFreA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "15.0.2", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.0.2.tgz", + "integrity": "sha512-AMfZfSVOIR8fa+TXlAooByEF4OB00wqnms1sJ1v+iu8ivwvtPvnkwdzzFMpsK5jA2S9oNeeQ04egIWVb4QWmtQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "15.0.2", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.0.2.tgz", + "integrity": "sha512-JkXysDT0/hEY47O+Hvs8PbZAeiCQVxKfGtr4GUpNAhlG2E0Mkjibuo8ryGD29Qb5a3IOnKYNoZlh/MyKd2Nbww==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "15.0.2", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.0.2.tgz", + "integrity": "sha512-foaUL0NqJY/dX0Pi/UcZm5zsmSk5MtP/gxx3xOPyREkMFN+CTjctPfu3QaqrQHinaKdPnMWPJDKt4VjDfTBe/Q==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } } } } diff --git a/web/package.json b/web/package.json index 22d1df72a18..c94d1d1aff6 100644 --- a/web/package.json +++ b/web/package.json @@ -20,11 +20,13 @@ "@phosphor-icons/react": "^2.0.8", "@radix-ui/react-accordion": "^1.2.2", "@radix-ui/react-checkbox": "^1.1.2", + "@radix-ui/react-context-menu": "^2.2.5", "@radix-ui/react-collapsible": "^1.1.2", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-label": "^2.1.1", "@radix-ui/react-popover": "^1.1.6", + "@radix-ui/react-progress": "^1.1.1", "@radix-ui/react-radio-group": "^1.2.2", "@radix-ui/react-scroll-area": "^1.2.2", "@radix-ui/react-select": "^2.1.6", diff --git a/web/src/app/admin/assistants/AssistantEditor.tsx b/web/src/app/admin/assistants/AssistantEditor.tsx index 69cc800886b..d3d2379b781 100644 --- a/web/src/app/admin/assistants/AssistantEditor.tsx +++ b/web/src/app/admin/assistants/AssistantEditor.tsx @@ -64,10 +64,10 @@ import { debounce } from "lodash"; import { FullLLMProvider } from "../configuration/llm/interfaces"; import StarterMessagesList from "./StarterMessageList"; -import { Switch, SwitchField } from "@/components/ui/switch"; +import { SwitchField } from "@/components/ui/switch"; import { generateIdenticon } from "@/components/assistants/AssistantIcon"; import { BackButton } from "@/components/BackButton"; -import { Checkbox, CheckboxField } from "@/components/ui/checkbox"; +import { Checkbox } from "@/components/ui/checkbox"; import { AdvancedOptionsToggle } from "@/components/AdvancedOptionsToggle"; import { MinimalUserSnapshot } from "@/lib/types"; import { useUserGroups } from "@/lib/hooks"; @@ -76,12 +76,26 @@ import { Option as DropdownOption, } from "@/components/Dropdown"; import { SourceChip } from "@/app/chat/input/ChatInputBar"; -import { TagIcon, UserIcon, XIcon, InfoIcon } from "lucide-react"; +import { + TagIcon, + UserIcon, + FileIcon, + FolderIcon, + InfoIcon, +} from "lucide-react"; import { LLMSelector } from "@/components/llm/LLMSelector"; import useSWR from "swr"; import { errorHandlingFetcher } from "@/lib/fetcher"; import { ConfirmEntityModal } from "@/components/modals/ConfirmEntityModal"; -import Title from "@/components/ui/title"; + +import { FilePickerModal } from "@/app/chat/my-documents/components/FilePicker"; +import { useDocumentsContext } from "@/app/chat/my-documents/DocumentsContext"; +import { + FileResponse, + FolderResponse, +} from "@/app/chat/my-documents/DocumentsContext"; +import { RadioGroup } from "@/components/ui/radio-group"; +import { RadioGroupItemField } from "@/components/ui/RadioGroupItemField"; import { SEARCH_TOOL_ID } from "@/app/chat/tools/constants"; function findSearchTool(tools: ToolSnapshot[]) { @@ -147,6 +161,7 @@ export function AssistantEditor({ "#6FFFFF", ]; + const [filePickerModalOpen, setFilePickerModalOpen] = useState(false); const [showAdvancedOptions, setShowAdvancedOptions] = useState(false); // state to persist across formik reformatting @@ -221,6 +236,16 @@ export function AssistantEditor({ enabledToolsMap[tool.id] = personaCurrentToolIds.includes(tool.id); }); + const { + selectedFiles, + selectedFolders, + addSelectedFile, + removeSelectedFile, + addSelectedFolder, + removeSelectedFolder, + clearSelectedItems, + } = useDocumentsContext(); + const [showVisibilityWarning, setShowVisibilityWarning] = useState(false); const initialValues = { @@ -259,6 +284,9 @@ export function AssistantEditor({ (u) => u.id !== existingPersona.owner?.id ) ?? [], selectedGroups: existingPersona?.groups ?? [], + user_file_ids: existingPersona?.user_file_ids ?? [], + user_folder_ids: existingPersona?.user_folder_ids ?? [], + knowledge_source: "user_files", is_default_persona: existingPersona?.is_default_persona ?? false, }; @@ -368,6 +396,24 @@ export function AssistantEditor({ )} + {filePickerModalOpen && ( + { + setFilePickerModalOpen(false); + }} + onSave={() => { + setFilePickerModalOpen(false); + }} + title="Add Documents to your Assistant" + buttonContent="Add to Assistant" + /> + )} {labelToDelete && ( file.id), + user_folder_ids: selectedFolders.map((folder) => folder.id), }; let personaResponse; + if (isUpdate) { personaResponse = await updatePersona( existingPersona.id, @@ -846,77 +896,168 @@ export function AssistantEditor({ values.enabled_tools_map[searchTool.id] && !(user?.role != "admin" && documentSets.length === 0) && ( -
- {ccPairs.length > 0 && ( - <> - -
+
+ + { + setFieldValue("knowledge_source", value); + }} + > + + + + + {values.knowledge_source === "user_files" && + !existingPersona?.is_default_persona && + !admin && ( +
+
+ + setFilePickerModalOpen(true)} + > + Attach Files and Folders + +
+ - <> - Select which{" "} - {!user || user.role === "admin" ? ( - - Document Sets - - ) : ( - "Document Sets" - )}{" "} - this Assistant should use to inform its - responses. If none are specified, the - Assistant will reference all available - documents. - + Select which of your user files and folders + this Assistant should use to inform its + responses. If none are specified, the + Assistant will not have access to any + user-specific documents. + +
+

+ Selected Files and Folders +

+
+ {selectedFiles.map((file: FileResponse) => ( + { + removeSelectedFile(file); + setFieldValue( + "selectedFiles", + values.selectedFiles.filter( + (f: FileResponse) => + f.id !== file.id + ) + ); + }} + title={file.name} + icon={} + /> + ))} + {selectedFolders.map( + (folder: FolderResponse) => ( + { + removeSelectedFolder(folder); + setFieldValue( + "selectedFolders", + values.selectedFolders.filter( + (f: FolderResponse) => + f.id !== folder.id + ) + ); + }} + title={folder.name} + icon={} + /> + ) + )} +
+
+ )} - {documentSets.length > 0 ? ( - ( -
-
- {documentSets.map((documentSet) => ( - { - const index = - values.document_set_ids.indexOf( - documentSet.id - ); - if (index !== -1) { - arrayHelpers.remove(index); - } else { - arrayHelpers.push( - documentSet.id - ); - } - }} - /> - ))} + {values.knowledge_source === "team_knowledge" && + ccPairs.length > 0 && ( +
+ +
+ + <> + Select which{" "} + {!user || user.role === "admin" ? ( + + Team Document Sets + + ) : ( + "Team Document Sets" + )}{" "} + this Assistant should use to inform its + responses. If none are specified, the + Assistant will reference all available + documents. + + +
+ + {documentSets.length > 0 ? ( + ( +
+
+ {documentSets.map((documentSet) => ( + { + const index = + values.document_set_ids.indexOf( + documentSet.id + ); + if (index !== -1) { + arrayHelpers.remove(index); + } else { + arrayHelpers.push( + documentSet.id + ); + } + }} + /> + ))} +
-
- )} - /> - ) : ( -

- - + Create Document Set - -

- )} - - )} + )} + /> + ) : ( +

+ + + Create Document Set + +

+ )} +
+ )}
)} diff --git a/web/src/app/admin/assistants/assistantFileUtils.ts b/web/src/app/admin/assistants/assistantFileUtils.ts new file mode 100644 index 00000000000..92f332a79b9 --- /dev/null +++ b/web/src/app/admin/assistants/assistantFileUtils.ts @@ -0,0 +1,106 @@ +import { + FileResponse, + FolderResponse, +} from "@/app/chat/my-documents/DocumentsContext"; + +export interface AssistantFileChanges { + filesToShare: number[]; + filesToUnshare: number[]; + foldersToShare: number[]; + foldersToUnshare: number[]; +} + +export function calculateFileChanges( + existingFileIds: number[], + existingFolderIds: number[], + selectedFiles: FileResponse[], + selectedFolders: FolderResponse[] +): AssistantFileChanges { + const selectedFileIds = selectedFiles.map((file) => file.id); + const selectedFolderIds = selectedFolders.map((folder) => folder.id); + + return { + filesToShare: selectedFileIds.filter((id) => !existingFileIds.includes(id)), + filesToUnshare: existingFileIds.filter( + (id) => !selectedFileIds.includes(id) + ), + foldersToShare: selectedFolderIds.filter( + (id) => !existingFolderIds.includes(id) + ), + foldersToUnshare: existingFolderIds.filter( + (id) => !selectedFolderIds.includes(id) + ), + }; +} + +export async function shareFiles( + assistantId: number, + fileIds: number[] +): Promise { + for (const fileId of fileIds) { + await fetch(`/api/user/file/${fileId}/share`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ assistant_id: assistantId }), + }); + } +} + +export async function unshareFiles( + assistantId: number, + fileIds: number[] +): Promise { + for (const fileId of fileIds) { + await fetch(`/api/user/file/${fileId}/unshare`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ assistant_id: assistantId }), + }); + } +} + +export async function shareFolders( + assistantId: number, + folderIds: number[] +): Promise { + for (const folderId of folderIds) { + await fetch(`/api/user/folder/${folderId}/share`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ assistant_id: assistantId }), + }); + } +} + +export async function unshareFolders( + assistantId: number, + folderIds: number[] +): Promise { + for (const folderId of folderIds) { + await fetch(`/api/user/folder/${folderId}/unshare`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ assistant_id: assistantId }), + }); + } +} + +export async function updateAssistantFiles( + assistantId: number, + changes: AssistantFileChanges +): Promise { + await Promise.all([ + shareFiles(assistantId, changes.filesToShare), + unshareFiles(assistantId, changes.filesToUnshare), + shareFolders(assistantId, changes.foldersToShare), + unshareFolders(assistantId, changes.foldersToUnshare), + ]); +} diff --git a/web/src/app/admin/assistants/interfaces.ts b/web/src/app/admin/assistants/interfaces.ts index 25bcaf868b6..a82afccb013 100644 --- a/web/src/app/admin/assistants/interfaces.ts +++ b/web/src/app/admin/assistants/interfaces.ts @@ -45,6 +45,8 @@ export interface Persona { icon_color?: string; uploaded_image_id?: string; labels?: PersonaLabel[]; + user_file_ids?: number[]; + user_folder_ids?: number[]; } export interface PersonaLabel { diff --git a/web/src/app/admin/assistants/lib.ts b/web/src/app/admin/assistants/lib.ts index a6494782f4a..d8015886413 100644 --- a/web/src/app/admin/assistants/lib.ts +++ b/web/src/app/admin/assistants/lib.ts @@ -29,6 +29,8 @@ interface PersonaUpsertRequest { is_default_persona: boolean; display_priority: number | null; label_ids: number[] | null; + user_file_ids: number[] | null; + user_folder_ids: number[] | null; } export interface PersonaUpsertParameters { @@ -56,6 +58,8 @@ export interface PersonaUpsertParameters { uploaded_image: File | null; is_default_persona: boolean; label_ids: number[] | null; + user_file_ids: number[]; + user_folder_ids: number[]; } export const createPersonaLabel = (name: string) => { @@ -114,7 +118,10 @@ function buildPersonaUpsertRequest( icon_shape, remove_image, search_start_date, + user_file_ids, + user_folder_ids, } = creationRequest; + return { name, description, @@ -145,6 +152,8 @@ function buildPersonaUpsertRequest( starter_messages: creationRequest.starter_messages ?? null, display_priority: null, label_ids: creationRequest.label_ids ?? null, + user_file_ids: user_file_ids ?? null, + user_folder_ids: user_folder_ids ?? null, }; } diff --git a/web/src/app/admin/configuration/llm/interfaces.ts b/web/src/app/admin/configuration/llm/interfaces.ts index 897bfb8961e..3d9f99ad938 100644 --- a/web/src/app/admin/configuration/llm/interfaces.ts +++ b/web/src/app/admin/configuration/llm/interfaces.ts @@ -41,6 +41,12 @@ export interface WellKnownLLMProviderDescriptor { groups: number[]; } +export interface LLMModelDescriptor { + modelName: string; + provider: string; + maxTokens: number; +} + export interface LLMProvider { name: string; provider: string; @@ -54,6 +60,7 @@ export interface LLMProvider { groups: number[]; display_model_names: string[] | null; deployment_name: string | null; + model_token_limits: { [key: string]: number } | null; } export interface FullLLMProvider extends LLMProvider { @@ -73,6 +80,7 @@ export interface LLMProviderDescriptor { is_public: boolean; groups: number[]; display_model_names: string[] | null; + model_token_limits: { [key: string]: number } | null; } export const getProviderIcon = (providerName: string, modelName?: string) => { diff --git a/web/src/app/assistants/SidebarWrapper.tsx b/web/src/app/assistants/SidebarWrapper.tsx index aaeccfeeb81..51d13da64b9 100644 --- a/web/src/app/assistants/SidebarWrapper.tsx +++ b/web/src/app/assistants/SidebarWrapper.tsx @@ -23,16 +23,15 @@ import AssistantModal from "./mine/AssistantModal"; import { useSidebarShortcut } from "@/lib/browserUtilities"; interface SidebarWrapperProps { - initiallyToggled: boolean; size?: "sm" | "lg"; children: ReactNode; } export default function SidebarWrapper({ - initiallyToggled, size = "sm", children, }: SidebarWrapperProps) { + const { sidebarInitiallyVisible: initiallyToggled } = useChatContext(); const [sidebarVisible, setSidebarVisible] = 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 @@ -135,13 +134,7 @@ export default function SidebarWrapper({ ${sidebarVisible ? "w-[250px]" : "w-[0px]"}`} /> -
- {children} -
+
{children}
diff --git a/web/src/app/chat/ChatPage.tsx b/web/src/app/chat/ChatPage.tsx index 927b21b5837..5d61b10b1b6 100644 --- a/web/src/app/chat/ChatPage.tsx +++ b/web/src/app/chat/ChatPage.tsx @@ -66,7 +66,6 @@ import { } from "react"; import { usePopup } from "@/components/admin/connectors/Popup"; import { SEARCH_PARAM_NAMES, shouldSubmitOnLoad } from "./searchParams"; -import { useDocumentSelection } from "./useDocumentSelection"; import { LlmDescriptor, useFilters, useLlmManager } from "@/lib/hooks"; import { ChatState, FeedbackType, RegenerationState } from "./types"; import { DocumentResults } from "./documentSidebar/DocumentResults"; @@ -100,14 +99,13 @@ import { ChatInputBar } from "./input/ChatInputBar"; import { useChatContext } from "@/components/context/ChatContext"; import { v4 as uuidv4 } from "uuid"; import { ChatPopup } from "./ChatPopup"; - import FunctionalHeader from "@/components/chat/Header"; import { useSidebarVisibility } from "@/components/chat/hooks"; import { PRO_SEARCH_TOGGLED_COOKIE_NAME, SIDEBAR_TOGGLED_COOKIE_NAME, } from "@/components/resizable/constants"; -import FixedLogo from "../../components/logo/FixedLogo"; +import FixedLogo from "@/components/logo/FixedLogo"; import { MinimalMarkdown } from "@/components/chat/MinimalMarkdown"; import ExceptionTraceModal from "@/components/modals/ExceptionTraceModal"; @@ -135,12 +133,16 @@ import { UserSettingsModal } from "./modal/UserSettingsModal"; import { AlignStartVertical } from "lucide-react"; import { AgenticMessage } from "./message/AgenticMessage"; import AssistantModal from "../assistants/mine/AssistantModal"; +import { useSidebarShortcut } from "@/lib/browserUtilities"; +import { FilePickerModal } from "./my-documents/components/FilePicker"; + +import { SourceMetadata } from "@/lib/search/interfaces"; +import { ValidSources } from "@/lib/types"; import { - OperatingSystem, - useOperatingSystem, - useSidebarShortcut, -} from "@/lib/browserUtilities"; -import { Button } from "@/components/ui/button"; + FileUploadResponse, + FileResponse, + useDocumentsContext, +} from "./my-documents/DocumentsContext"; import { ConfirmEntityModal } from "@/components/modals/ConfirmEntityModal"; import { MessageChannel } from "node:worker_threads"; import { ChatSearchModal } from "./chat_search/ChatSearchModal"; @@ -175,10 +177,22 @@ export function ChatPage({ proSearchToggled, } = useChatContext(); + const { + selectedFiles, + selectedFolders, + addSelectedFile, + addSelectedFolder, + removeSelectedFolder, + clearSelectedItems, + folders: userFolders, + uploadFile, + } = useDocumentsContext(); + const defaultAssistantIdRaw = searchParams.get(SEARCH_PARAM_NAMES.PERSONA_ID); const defaultAssistantId = defaultAssistantIdRaw ? parseInt(defaultAssistantIdRaw) : undefined; + const [forceUserFileSearch, setForceUserFileSearch] = useState(true); function useScreenSize() { const [screenSize, setScreenSize] = useState({ @@ -208,6 +222,8 @@ export function ChatPage({ const settings = useContext(SettingsContext); const enterpriseSettings = settings?.enterpriseSettings; + const [viewingFilePicker, setViewingFilePicker] = useState(false); + const [toggleDocSelection, setToggleDocSelection] = useState(false); const [documentSidebarVisible, setDocumentSidebarVisible] = useState(false); const [proSearchEnabled, setProSearchEnabled] = useState(proSearchToggled); const [streamingAllowed, setStreamingAllowed] = useState(false); @@ -299,10 +315,10 @@ export function ChatPage({ (assistant) => assistant.id === existingChatSessionAssistantId ) : defaultAssistantId !== undefined - ? availableAssistants.find( - (assistant) => assistant.id === defaultAssistantId - ) - : undefined + ? availableAssistants.find( + (assistant) => assistant.id === defaultAssistantId + ) + : undefined ); // Gather default temperature settings const search_param_temperature = searchParams.get( @@ -312,12 +328,12 @@ export function ChatPage({ const defaultTemperature = search_param_temperature ? parseFloat(search_param_temperature) : selectedAssistant?.tools.some( - (tool) => - tool.in_code_tool_id === SEARCH_TOOL_ID || - tool.in_code_tool_id === INTERNET_SEARCH_TOOL_ID - ) - ? 0 - : 0.7; + (tool) => + tool.in_code_tool_id === SEARCH_TOOL_ID || + tool.in_code_tool_id === INTERNET_SEARCH_TOOL_ID + ) + ? 0 + : 0.7; const setSelectedAssistantFromId = (assistantId: number) => { // NOTE: also intentionally look through available assistants here, so that @@ -362,9 +378,14 @@ export function ChatPage({ const noAssistants = liveAssistant == null || liveAssistant == undefined; - const availableSources = ccPairs.map((ccPair) => ccPair.source); - const uniqueSources = Array.from(new Set(availableSources)); - const sources = uniqueSources.map((source) => getSourceMetadata(source)); + const availableSources: ValidSources[] = useMemo(() => { + return ccPairs.map((ccPair) => ccPair.source); + }, [ccPairs]); + + const sources: SourceMetadata[] = useMemo(() => { + const uniqueSources = Array.from(new Set(availableSources)); + return uniqueSources.map((source) => getSourceMetadata(source)); + }, [availableSources]); const stopGenerating = () => { const currentSession = currentSessionId(); @@ -561,6 +582,18 @@ export function ChatPage({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [existingChatSessionId, searchParams.get(SEARCH_PARAM_NAMES.PERSONA_ID)]); + useEffect(() => { + const userFolderId = searchParams.get(SEARCH_PARAM_NAMES.USER_FOLDER_ID); + if (userFolderId) { + const userFolder = userFolders.find( + (folder) => folder.id === parseInt(userFolderId) + ); + if (userFolder) { + addSelectedFolder(userFolder); + } + } + }, [userFolders, searchParams.get(SEARCH_PARAM_NAMES.USER_FOLDER_ID)]); + const [message, setMessage] = useState( searchParams.get(SEARCH_PARAM_NAMES.USER_PROMPT) || "" ); @@ -846,13 +879,6 @@ export function ChatPage({ ); } }, [submittedMessage, currentSessionChatState]); - - const [ - selectedDocuments, - toggleDocumentSelection, - clearSelectedDocuments, - selectedDocumentTokens, - ] = useDocumentSelection(); // just choose a conservative default, this will be updated in the // background on initial load / on persona change const [maxTokens, setMaxTokens] = useState(4096); @@ -1350,7 +1376,9 @@ export function ChatPage({ filterManager.selectedSources, filterManager.selectedDocumentSets, filterManager.timeRange, - filterManager.selectedTags + filterManager.selectedTags, + selectedFiles.map((file) => file.id), + selectedFolders.map((folder) => folder.id) ), selectedDocumentIds: selectedDocuments .filter( @@ -1360,6 +1388,8 @@ export function ChatPage({ .map((document) => document.db_doc_id as number), queryOverride, forceSearch, + userFolderIds: selectedFolders.map((folder) => folder.id), + userFileIds: selectedFiles.map((file) => file.id), regenerate: regenerationRequest !== undefined, modelProvider: modelOverride?.name || llmManager.currentLlm.name || undefined, @@ -1376,6 +1406,7 @@ export function ChatPage({ settings?.settings.pro_search_enabled && proSearchEnabled && retrievalEnabled, + forceUserFileSearch: forceUserFileSearch, }); const delay = (ms: number) => { @@ -1874,17 +1905,61 @@ export function ChatPage({ }; updateChatState("uploading", currentSessionId()); - await uploadFilesForChat(acceptedFiles).then(([files, error]) => { - if (error) { - setCurrentMessageFiles((prev) => removeTempFiles(prev)); - setPopup({ - type: "error", - message: error, - }); - } else { - setCurrentMessageFiles((prev) => [...removeTempFiles(prev), ...files]); + // const files = await uploadFilesForChat(acceptedFiles).then( + // ([files, error]) => { + // if (error) { + // setCurrentMessageFiles((prev) => removeTempFiles(prev)); + // setPopup({ + // type: "error", + // message: error, + // }); + // } else { + // setCurrentMessageFiles((prev) => [ + // ...removeTempFiles(prev), + // ...files, + // ]); + // } + // return files; + // } + // ); + + for (let i = 0; i < acceptedFiles.length; i++) { + const file = acceptedFiles[i]; + const formData = new FormData(); + formData.append("files", file); + const response: FileUploadResponse = await uploadFile(formData, null); + + if (response.file_paths && response.file_paths.length > 0) { + const uploadedFile: FileResponse = { + id: Date.now(), + name: file.name, + document_id: response.file_paths[0], + folder_id: null, + size: file.size, + type: file.type, + lastModified: new Date().toISOString(), + token_count: 0, + }; + addSelectedFile(uploadedFile); } - }); + } + + // const fileToAdd: FileResponse[] = files.map((file: FileDescriptor) => { + // return { + // document_id: file.id, + // type: file.type.startsWith("image/") + // ? ChatFileType.IMAGE + // : ChatFileType.DOCUMENT, + // name: file.name || "Name not available", + // size: 10, + // folder_id: -1, + // id: 10, + // }; + // }); + // setSelectedFiles((prevFiles: FileResponse[]) => [ + // ...prevFiles, + // ...fileToAdd, + // ]); updateChatState("input", currentSessionId()); }; @@ -1980,6 +2055,11 @@ export function ChatPage({ const [settingsToggled, setSettingsToggled] = useState(false); const [showDeleteAllModal, setShowDeleteAllModal] = useState(false); + const [selectedDocuments, setSelectedDocuments] = useState( + [] + ); + const [selectedDocumentTokens, setSelectedDocumentTokens] = useState(0); + const currentPersona = alternativeAssistant || liveAssistant; const HORIZON_DISTANCE = 800; @@ -2104,6 +2184,28 @@ export function ChatPage({ ); + const clearSelectedDocuments = () => { + setSelectedDocuments([]); + setSelectedDocumentTokens(0); + clearSelectedItems(); + }; + + const toggleDocumentSelection = (document: OnyxDocument) => { + setSelectedDocuments((prev) => + prev.some((d) => d.document_id === document.document_id) + ? prev.filter((d) => d.document_id !== document.document_id) + : [...prev, document] + ); + }; + + const handleFileUpload = async (files: File[]) => { + // Implement file upload logic here + // After successful upload, you might want to add the file to selected files + // For example: + // const uploadedFile = await uploadFile(files[0]); + // addSelectedFile(uploadedFile); + }; + return ( <> @@ -2176,6 +2278,40 @@ export function ChatPage({ /> )} + {toggleDocSelection && ( + setToggleDocSelection(false)} + onSave={() => { + setToggleDocSelection(false); + }} + selectedFiles={selectedFiles} + selectedFolders={selectedFolders} + addSelectedFile={addSelectedFile} + addSelectedFolder={addSelectedFolder} + removeSelectedFile={() => {}} + /> + )} + + {toggleDocSelection && ( + setToggleDocSelection(false)} + onSave={() => { + setToggleDocSelection(false); + }} + selectedFiles={selectedFiles} + selectedFolders={selectedFolders} + addSelectedFile={addSelectedFile} + addSelectedFolder={addSelectedFolder} + removeSelectedFile={() => {}} + /> + )} + setIsChatSearchModalOpen(false)} @@ -3114,23 +3250,26 @@ export function ChatPage({ clearSelectedDocuments(); }} retrievalEnabled={retrievalEnabled} + toggleDocSelection={() => + setToggleDocSelection(true) + } showConfigureAPIKey={() => setShowApiKeyModal(true) } - chatState={currentSessionChatState} - stopGenerating={stopGenerating} selectedDocuments={selectedDocuments} - // assistant stuff - selectedAssistant={liveAssistant} - setAlternativeAssistant={setAlternativeAssistant} - alternativeAssistant={alternativeAssistant} - // end assistant stuff message={message} setMessage={setMessage} + stopGenerating={stopGenerating} onSubmit={onSubmit} + chatState={currentSessionChatState} + alternativeAssistant={alternativeAssistant} + selectedAssistant={ + selectedAssistant || finalAssistants[0] + } + setAlternativeAssistant={setAlternativeAssistant} files={currentMessageFiles} setFiles={setCurrentMessageFiles} - handleFileUpload={handleImageUpload} + handleFileUpload={handleFileUpload} textAreaRef={textAreaRef} /> {enterpriseSettings && @@ -3202,6 +3341,24 @@ export function ChatPage({
{/* Right Sidebar - DocumentSidebar */} + + {/* Add the fixed toggle button */} +
+ +
); } diff --git a/web/src/app/chat/folders/FolderManagement.tsx b/web/src/app/chat/folders/FolderManagement.tsx index f7f65d8e950..94be3c843d7 100644 --- a/web/src/app/chat/folders/FolderManagement.tsx +++ b/web/src/app/chat/folders/FolderManagement.tsx @@ -8,7 +8,8 @@ export async function createFolder(folderName: string): Promise { body: JSON.stringify({ folder_name: folderName }), }); if (!response.ok) { - throw new Error("Failed to create folder"); + const errorData = await response.json(); + throw new Error(errorData.detail || "Failed to create folder"); } const data = await response.json(); return data; diff --git a/web/src/app/chat/input/ChatInputBar.tsx b/web/src/app/chat/input/ChatInputBar.tsx index f8a5c34aa25..15121d5ac7d 100644 --- a/web/src/app/chat/input/ChatInputBar.tsx +++ b/web/src/app/chat/input/ChatInputBar.tsx @@ -27,7 +27,7 @@ import { Hoverable } from "@/components/Hoverable"; import { ChatState } from "../types"; import UnconfiguredProviderText from "@/components/chat/UnconfiguredProviderText"; import { useAssistants } from "@/components/context/AssistantsContext"; -import { CalendarIcon, TagIcon, XIcon } from "lucide-react"; +import { CalendarIcon, TagIcon, XIcon, FolderIcon } from "lucide-react"; import { FilterPopup } from "@/components/search/filtering/FilterPopup"; import { DocumentSet, Tag } from "@/lib/types"; import { SourceIcon } from "@/components/SourceIcon"; @@ -35,6 +35,7 @@ import { getFormattedDateRangeString } from "@/lib/dateUtils"; import { truncateString } from "@/lib/utils"; import { buildImgUrl } from "../files/images/utils"; import { useUser } from "@/components/user/UserProvider"; +import { useDocumentsContext } from "../my-documents/DocumentsContext"; import { AgenticToggle } from "./AgenticToggle"; import { SettingsContext } from "@/components/settings/SettingsProvider"; import { LoadingIndicator } from "react-select/dist/declarations/src/components/indicators"; @@ -173,6 +174,7 @@ export const SourceChip = ({ ); interface ChatInputBarProps { + toggleDocSelection: () => void; removeDocs: () => void; showConfigureAPIKey: () => void; selectedDocuments: OnyxDocument[]; @@ -201,6 +203,7 @@ interface ChatInputBarProps { } export function ChatInputBar({ + toggleDocSelection, retrievalEnabled, removeDocs, toggleDocumentSidebar, @@ -230,6 +233,13 @@ export function ChatInputBar({ setProSearchEnabled, }: ChatInputBarProps) { const { user } = useUser(); + const { + selectedFiles, + selectedFolders, + removeSelectedFile, + removeSelectedFolder, + } = useDocumentsContext(); + const settings = useContext(SettingsContext); useEffect(() => { const textarea = textAreaRef.current; @@ -628,6 +638,8 @@ export function ChatInputBar({ /> {(selectedDocuments.length > 0 || + selectedFiles.length > 0 || + selectedFolders.length > 0 || files.length > 0 || filterManager.timeRange || filterManager.selectedDocumentSets.length > 0 || @@ -651,6 +663,24 @@ export function ChatInputBar({ /> ))} + {selectedFiles.map((file) => ( + } + title={file.name} + onRemove={() => removeSelectedFile(file)} + /> + ))} + + {selectedFolders.map((folder) => ( + } + title={folder.name} + onRemove={() => removeSelectedFolder(folder)} + /> + ))} + {filterManager.timeRange && (
- { - const input = document.createElement("input"); - input.type = "file"; - input.multiple = true; - input.onchange = (event: any) => { - const files = Array.from( - event?.target?.files || [] - ) as File[]; - if (files.length > 0) { - handleFileUpload(files); - } - }; - input.click(); - }} - tooltipContent={"Upload files"} - /> + {retrievalEnabled ? ( + { + const input = document.createElement("input"); + input.type = "file"; + input.multiple = true; + input.onchange = (event: any) => { + const files = Array.from( + event?.target?.files || [] + ) as File[]; + if (files.length > 0) { + handleFileUpload(files); + } + }; + input.click(); + }} + tooltipContent={"Upload files"} + /> + ) : ( + { + toggleDocSelection(); + }} + tooltipContent={"Upload files and attach user files"} + /> + )} ; }[]; } = {}; + const uniqueModelNames = new Set(); llmProviders.forEach((llmProvider) => { diff --git a/web/src/app/chat/lib.tsx b/web/src/app/chat/lib.tsx index 62c908d5558..74a47f5dd53 100644 --- a/web/src/app/chat/lib.tsx +++ b/web/src/app/chat/lib.tsx @@ -162,6 +162,8 @@ export async function* sendMessage({ regenerate, message, fileDescriptors, + userFileIds, + userFolderIds, parentMessageId, chatSessionId, promptId, @@ -176,6 +178,7 @@ export async function* sendMessage({ useExistingUserMessage, alternateAssistantId, signal, + forceUserFileSearch, useLanggraph, }: { regenerate: boolean; @@ -195,6 +198,9 @@ export async function* sendMessage({ useExistingUserMessage?: boolean; alternateAssistantId?: number; signal?: AbortSignal; + userFileIds?: number[]; + userFolderIds?: number[]; + forceUserFileSearch?: boolean; useLanggraph?: boolean; }): AsyncGenerator { const documentsAreSelected = @@ -206,7 +212,10 @@ export async function* sendMessage({ message: message, prompt_id: promptId, search_doc_ids: documentsAreSelected ? selectedDocumentIds : null, + force_user_file_search: forceUserFileSearch, file_descriptors: fileDescriptors, + user_file_ids: userFileIds, + user_folder_ids: userFolderIds, regenerate, retrieval_options: !documentsAreSelected ? { diff --git a/web/src/app/chat/my-documents/DocumentsContext.tsx b/web/src/app/chat/my-documents/DocumentsContext.tsx new file mode 100644 index 00000000000..5f796971b96 --- /dev/null +++ b/web/src/app/chat/my-documents/DocumentsContext.tsx @@ -0,0 +1,454 @@ +"use client"; +import React, { + createContext, + useContext, + useState, + useCallback, + ReactNode, + useEffect, + Dispatch, + SetStateAction, +} from "react"; +import { MinimalOnyxDocument } from "@/lib/search/interfaces"; +import * as documentsService from "@/services/documentsService"; + +export interface FolderResponse { + id: number; + name: string; + description: string; + files: FileResponse[]; + assistant_ids?: number[]; + created_at: string; +} + +export type FileResponse = { + id: number; + name: string; + document_id: string; + folder_id: number | null; + size?: number; + type?: string; + lastModified?: string; + token_count?: number; + assistant_ids?: number[]; + indexed?: boolean; +}; + +export interface FileUploadResponse { + file_paths: string[]; +} + +export interface DocumentsContextType { + folders: FolderResponse[]; + currentFolder: number | null; + presentingDocument: MinimalOnyxDocument | null; + searchQuery: string; + page: number; + refreshFolders: () => Promise; + createFolder: (name: string, description: string) => Promise; + deleteItem: (itemId: number, isFolder: boolean) => Promise; + moveItem: ( + itemId: number, + currentFolderId: number | null, + isFolder: boolean + ) => Promise; + downloadItem: (documentId: string) => Promise; + renameItem: ( + itemId: number, + currentName: string, + isFolder: boolean + ) => Promise; + setCurrentFolder: (folderId: number | null) => void; + setPresentingDocument: (document: MinimalOnyxDocument | null) => void; + setSearchQuery: (query: string) => void; + setPage: (page: number) => void; + getFolderDetails: (folderId: number) => Promise; + updateFolderDetails: ( + folderId: number, + name: string, + description: string + ) => Promise; + isLoading: boolean; + uploadFile: ( + formData: FormData, + folderId: number | null + ) => Promise; + selectedFiles: FileResponse[]; + selectedFolders: FolderResponse[]; + addSelectedFile: (file: FileResponse) => void; + removeSelectedFile: (file: FileResponse) => void; + addSelectedFolder: (folder: FolderResponse) => void; + removeSelectedFolder: (folder: FolderResponse) => void; + clearSelectedItems: () => void; + createFileFromLink: ( + url: string, + folderId: number | null + ) => Promise; + setSelectedFiles: Dispatch>; + setSelectedFolders: Dispatch>; + handleUpload: (files: File[]) => Promise; + handleCreateFileFromLink: () => Promise; + refreshFolderDetails: () => Promise; + folderDetails: FolderResponse | undefined | null; + setFolderDetails: Dispatch>; + showUploadWarning: boolean; + setShowUploadWarning: Dispatch>; + linkUrl: string; + setLinkUrl: Dispatch>; + isCreatingFileFromLink: boolean; + setIsCreatingFileFromLink: Dispatch>; + error: string | null; + setError: Dispatch>; + getFolders: () => Promise; +} + +const DocumentsContext = createContext( + undefined +); + +interface DocumentsProviderProps { + children: ReactNode; + initialFolderDetails?: FolderResponse | null; +} + +export const DocumentsProvider: React.FC = ({ + children, + initialFolderDetails, +}) => { + const [isLoading, setIsLoading] = useState(true); + const [folders, setFolders] = useState([]); + const [currentFolder, setCurrentFolder] = useState(null); + const [presentingDocument, setPresentingDocument] = + useState(null); + const [searchQuery, setSearchQuery] = useState(""); + const [page, setPage] = useState(1); + const [selectedFiles, setSelectedFiles] = useState([]); + const [selectedFolders, setSelectedFolders] = useState([]); + const [folderDetails, setFolderDetails] = useState< + FolderResponse | undefined | null + >(initialFolderDetails || null); + const [showUploadWarning, setShowUploadWarning] = useState(false); + const [linkUrl, setLinkUrl] = useState(""); + const [isCreatingFileFromLink, setIsCreatingFileFromLink] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchFolders = async () => { + await refreshFolders(); + setIsLoading(false); + }; + fetchFolders(); + }, []); + + const refreshFolders = useCallback(async () => { + try { + const data = await documentsService.fetchFolders(); + setFolders(data); + } catch (error) { + console.error("Failed to fetch folders:", error); + setError("Failed to fetch folders"); + } + }, []); + + const uploadFile = useCallback( + async ( + formData: FormData, + folderId: number | null + ): Promise => { + if (folderId) { + formData.append("folder_id", folderId.toString()); + } + try { + const data = await documentsService.uploadFileRequest(formData); + await refreshFolders(); + return data; + } catch (error) { + console.error("Failed to upload file:", error); + throw error; + } + }, + [refreshFolders] + ); + + const createFolder = useCallback( + async (name: string, description: string) => { + try { + const newFolder = await documentsService.createNewFolder( + name, + description + ); + await refreshFolders(); + return newFolder; + } catch (error) { + console.error("Failed to create folder:", error); + throw error; + } + }, + [refreshFolders] + ); + + const deleteItem = useCallback( + async (itemId: number, isFolder: boolean) => { + try { + if (isFolder) { + await documentsService.deleteFolder(itemId); + } else { + await documentsService.deleteFile(itemId); + } + await refreshFolders(); + } catch (error) { + console.error("Failed to delete item:", error); + throw error; + } + }, + [refreshFolders] + ); + + const moveItem = useCallback( + async ( + itemId: number, + currentFolderId: number | null, + isFolder: boolean + ) => { + try { + await documentsService.moveItem(itemId, currentFolderId, isFolder); + await refreshFolders(); + } catch (error) { + console.error("Failed to move item:", error); + throw error; + } + }, + [refreshFolders] + ); + + const downloadItem = useCallback(async (documentId: string) => { + try { + const blob = await documentsService.downloadItem(documentId); + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = "document"; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + } catch (error) { + console.error("Failed to download item:", error); + throw error; + } + }, []); + + const renameItem = useCallback( + async (itemId: number, newName: string, isFolder: boolean) => { + try { + await documentsService.renameItem(itemId, newName, isFolder); + if (isFolder) { + await refreshFolders(); + } + } catch (error) { + console.error("Failed to rename item:", error); + throw error; + } + }, + [refreshFolders] + ); + + const getFolderDetails = useCallback(async (folderId: number) => { + try { + return await documentsService.getFolderDetails(folderId); + } catch (error) { + console.error("Failed to get folder details:", error); + throw error; + } + }, []); + + const updateFolderDetails = useCallback( + async (folderId: number, name: string, description: string) => { + try { + await documentsService.updateFolderDetails(folderId, name, description); + await refreshFolders(); + } catch (error) { + console.error("Failed to update folder details:", error); + throw error; + } + }, + [refreshFolders] + ); + + const addSelectedFile = useCallback((file: FileResponse) => { + setSelectedFiles((prev) => [...prev, file]); + }, []); + + const removeSelectedFile = useCallback((file: FileResponse) => { + setSelectedFiles((prev) => prev.filter((f) => f.id !== file.id)); + }, []); + + const addSelectedFolder = useCallback((folder: FolderResponse) => { + setSelectedFolders((prev) => { + if (prev.find((f) => f.id === folder.id)) { + return prev; + } + return [...prev, folder]; + }); + }, []); + + const removeSelectedFolder = useCallback((folder: FolderResponse) => { + setSelectedFolders((prev) => prev.filter((f) => f.id !== folder.id)); + }, []); + + const clearSelectedItems = useCallback(() => { + setSelectedFiles([]); + setSelectedFolders([]); + }, []); + + const refreshFolderDetails = useCallback(async () => { + if (folderDetails) { + const details = await getFolderDetails(folderDetails.id); + setFolderDetails(details); + } + }, [folderDetails, getFolderDetails]); + + const createFileFromLink = useCallback( + async ( + url: string, + folderId: number | null + ): Promise => { + try { + const data = await documentsService.createFileFromLinkRequest( + url, + folderId + ); + await refreshFolders(); + return data; + } catch (error) { + console.error("Failed to create file from link:", error); + throw error; + } + }, + [refreshFolders] + ); + + const handleUpload = useCallback( + async (files: File[]) => { + if ( + folderDetails?.assistant_ids && + folderDetails.assistant_ids.length > 0 + ) { + setShowUploadWarning(true); + } else { + await performUpload(files); + } + }, + [folderDetails] + ); + + const performUpload = useCallback( + async (files: File[]) => { + try { + const formData = new FormData(); + files.forEach((file) => { + formData.append("files", file); + }); + setIsLoading(true); + + await uploadFile(formData, folderDetails?.id || null); + await refreshFolderDetails(); + } catch (error) { + console.error("Error uploading documents:", error); + setError("Failed to upload documents. Please try again."); + } finally { + setIsLoading(false); + setShowUploadWarning(false); + } + }, + [uploadFile, folderDetails, refreshFolderDetails] + ); + + const handleCreateFileFromLink = useCallback(async () => { + if (!linkUrl) return; + setIsCreatingFileFromLink(true); + try { + await createFileFromLink(linkUrl, folderDetails?.id || null); + setLinkUrl(""); + await refreshFolderDetails(); + } catch (error) { + console.error("Error creating file from link:", error); + setError("Failed to create file from link. Please try again."); + } finally { + setIsCreatingFileFromLink(false); + } + }, [linkUrl, createFileFromLink, folderDetails, refreshFolderDetails]); + + const getFolders = async (): Promise => { + try { + const response = await fetch("/api/user/folder"); + if (!response.ok) { + throw new Error("Failed to fetch folders"); + } + return await response.json(); + } catch (error) { + console.error("Error fetching folders:", error); + return []; + } + }; + + const value: DocumentsContextType = { + folderDetails, + setFolderDetails, + folders, + currentFolder, + presentingDocument, + searchQuery, + page, + refreshFolders, + createFolder, + deleteItem, + moveItem, + downloadItem, + renameItem, + setCurrentFolder, + setPresentingDocument, + setSearchQuery, + setPage, + getFolderDetails, + updateFolderDetails, + isLoading, + uploadFile, + selectedFiles, + selectedFolders, + addSelectedFile, + removeSelectedFile, + addSelectedFolder, + removeSelectedFolder, + clearSelectedItems, + createFileFromLink, + setSelectedFiles, + setSelectedFolders, + handleUpload, + handleCreateFileFromLink, + refreshFolderDetails, + showUploadWarning, + setShowUploadWarning, + linkUrl, + setLinkUrl, + isCreatingFileFromLink, + setIsCreatingFileFromLink, + error, + setError, + getFolders, + }; + + return ( + + {children} + + ); +}; + +export const useDocumentsContext = () => { + const context = useContext(DocumentsContext); + if (context === undefined) { + throw new Error("useDocuments must be used within a DocumentsProvider"); + } + return context; +}; diff --git a/web/src/app/chat/my-documents/MyDocumenItem.tsx b/web/src/app/chat/my-documents/MyDocumenItem.tsx new file mode 100644 index 00000000000..d2fb22f984e --- /dev/null +++ b/web/src/app/chat/my-documents/MyDocumenItem.tsx @@ -0,0 +1,342 @@ +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, + item: { id: number; isFolder: boolean; name: string } + ) => void; + onDrop: (e: React.DragEvent, targetFolderId: number) => void; +} + +export function FolderItem({ + folder, + onFolderClick, + onDeleteItem, + onMoveItem, + editingItem, + setEditingItem, + handleRename, + onDragStart, + onDrop, +}: FolderItemProps) { + const [showMenu, setShowMenu] = useState(undefined); + const [newName, setNewName] = useState(folder.name); + + const isEditing = + editingItem && editingItem.id === folder.id && editingItem.isFolder; + + const folderItemRef = useRef(null); + + const handleContextMenu = (e: React.MouseEvent) => { + 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 ( +
!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)} + > +
+ + {isEditing ? ( +
+ e.stopPropagation()} + type="text" + value={newName} + onChange={(e) => { + e.stopPropagation(); + setNewName(e.target.value); + }} + className="border rounded px-2 py-1 mr-2" + autoFocus + /> + + +
+ ) : ( + {folder.name} + )} +
+ {showMenu && !isEditing && ( +
+ + + +
+ )} +
+ ); +} + +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, + 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(); + const [newFileName, setNewFileName] = useState(file.name); + + const isEditing = + editingItem && editingItem.id === file.id && !editingItem.isFolder; + + const fileItemRef = useRef(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 ( +
+ onDragStart(e, { id: file.id, isFolder: false, name: file.name }) + } + > + + +
+ ) : ( +

{file.name}

+ )} + + {showMenu && !isEditing && ( +
+ + + + +
+ )} +
+ ); +} diff --git a/web/src/app/chat/my-documents/MyDocuments.tsx b/web/src/app/chat/my-documents/MyDocuments.tsx new file mode 100644 index 00000000000..48b3c6c7aee --- /dev/null +++ b/web/src/app/chat/my-documents/MyDocuments.tsx @@ -0,0 +1,380 @@ +"use client"; + +import React, { useMemo, useState, useTransition } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Search, Plus, FolderOpen, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { usePopup } from "@/components/admin/connectors/Popup"; +import { PageSelector } from "@/components/PageSelector"; +import { SharedFolderItem } from "./components/SharedFolderItem"; +import CreateEntityModal from "@/components/modals/CreateEntityModal"; +import { useDocumentsContext } from "./DocumentsContext"; +import { SortIcon } from "@/components/icons/icons"; +import TextView from "@/components/chat/TextView"; + +enum SortType { + TimeCreated = "Time Created", + Alphabetical = "Alphabetical", +} + +interface SortSelectorProps { + onSortChange: (sortType: SortType) => void; +} + +const SortSelector: React.FC = ({ onSortChange }) => { + const [isOpen, setIsOpen] = useState(false); + const [currentSort, setCurrentSort] = useState( + SortType.TimeCreated + ); + + const handleSortChange = (sortType: SortType) => { + setCurrentSort(sortType); + onSortChange(sortType); + setIsOpen(false); + }; + + return ( +
+ {isOpen && ( +
+
+ {Object.values(SortType).map((sortType) => ( + + ))} +
+
+ )} + + +
+ ); +}; + +const SkeletonLoader = ({ count = 5 }) => ( +
+ {[...Array(count)].map((_, index) => ( +
+ ))} +
+); + +export default function MyDocuments() { + const { + folders, + currentFolder, + presentingDocument, + searchQuery, + page, + refreshFolders, + createFolder, + deleteItem, + moveItem, + isLoading, + downloadItem, + renameItem, + setCurrentFolder, + setPresentingDocument, + setSearchQuery, + setPage, + } = useDocumentsContext(); + + const [sortType, setSortType] = useState(SortType.TimeCreated); + const handleSortChange = (sortType: SortType) => { + setSortType(sortType); + }; + const pageLimit = 10; + const searchParams = useSearchParams(); + const router = useRouter(); + const { popup, setPopup } = usePopup(); + const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false); + const [isPending, startTransition] = useTransition(); + + const folderIdFromParams = parseInt(searchParams.get("folder") || "0", 10); + + const handleFolderClick = (id: number) => { + startTransition(() => { + router.push(`/chat/my-documents/${id}`); + setPage(1); + setCurrentFolder(id); + }); + }; + + const handleCreateFolder = async (name: string, description: string) => { + try { + const folderResponse = await createFolder(name, description); + // setPopup({ + // message: "Folder created successfully", + // type: "success", + // }); + // await refreshFolders(); + // setIsCreateFolderOpen(false); + startTransition(() => { + router.push( + `/chat/my-documents/${folderResponse.id}?message=folder-created` + ); + setPage(1); + setCurrentFolder(folderResponse.id); + }); + } catch (error) { + console.error("Error creating folder:", error); + setPopup({ + message: + error instanceof Error + ? error.message + : "Failed to create knowledge group", + type: "error", + }); + } + }; + + const handleDeleteItem = async (itemId: number, isFolder: boolean) => { + const itemType = isFolder ? "Knowledge Group" : "File"; + const confirmDelete = window.confirm( + `Are you sure you want to delete this ${itemType}?` + ); + + if (confirmDelete) { + try { + await deleteItem(itemId, isFolder); + setPopup({ + message: `${itemType} deleted successfully`, + type: "success", + }); + await refreshFolders(); + } catch (error) { + console.error("Error deleting item:", error); + setPopup({ + message: `Failed to delete ${itemType}`, + type: "error", + }); + } + } + }; + + const handleMoveItem = async ( + itemId: number, + currentFolderId: number | null, + isFolder: boolean + ) => { + const availableFolders = folders + .filter((folder) => folder.id !== itemId) + .map((folder) => `${folder.id}: ${folder.name}`) + .join("\n"); + + const promptMessage = `Enter the ID of the destination folder:\n\nAvailable folders:\n${availableFolders}\n\nEnter 0 to move to the root folder.`; + const destinationFolderId = prompt(promptMessage); + + if (destinationFolderId !== null) { + const newFolderId = parseInt(destinationFolderId, 10); + if (isNaN(newFolderId)) { + setPopup({ + message: "Invalid folder ID", + type: "error", + }); + return; + } + + try { + await moveItem( + itemId, + newFolderId === 0 ? null : newFolderId, + isFolder + ); + setPopup({ + message: `${ + isFolder ? "Knowledge Group" : "File" + } moved successfully`, + type: "success", + }); + await refreshFolders(); + } catch (error) { + console.error("Error moving item:", error); + setPopup({ + message: "Failed to move item", + type: "error", + }); + } + } + }; + + const handleDownloadItem = async (documentId: string) => { + try { + await downloadItem(documentId); + } catch (error) { + console.error("Error downloading file:", error); + setPopup({ + message: "Failed to download file", + type: "error", + }); + } + }; + + const onRenameItem = async ( + itemId: number, + currentName: string, + isFolder: boolean + ) => { + const newName = prompt( + `Enter new name for ${isFolder ? "Knowledge Group" : "File"}:`, + currentName + ); + if (newName && newName !== currentName) { + try { + await renameItem(itemId, newName, isFolder); + setPopup({ + message: `${ + isFolder ? "Knowledge Group" : "File" + } renamed successfully`, + type: "success", + }); + await refreshFolders(); + } catch (error) { + console.error("Error renaming item:", error); + setPopup({ + message: `Failed to rename ${isFolder ? "Knowledge Group" : "File"}`, + type: "error", + }); + } + } + }; + + const filteredFolders = useMemo(() => { + return folders + .filter( + (folder) => + folder.name.toLowerCase().includes(searchQuery.toLowerCase()) || + folder.description.toLowerCase().includes(searchQuery.toLowerCase()) + ) + .sort((a, b) => { + if (sortType === SortType.TimeCreated) { + return ( + new Date(b.created_at).getTime() - new Date(a.created_at).getTime() + ); + } else if (sortType === SortType.Alphabetical) { + return a.name.localeCompare(b.name); + } + return 0; + }); + }, [folders, searchQuery, sortType]); + + return ( +
+
+

+ Knowledge Groups +

+
+ + + New Group + + } + /> +
+
+
+
+
+
+
+ +
+ setSearchQuery(e.target.value)} + className="w-full placeholder:text-text-500 dark:placeholder:text-neutral-400 m-0 bg-transparent p-0 focus:outline-none focus:ring-0 disabled:cursor-not-allowed disabled:opacity-50" + /> +
+ +
+
+ {isPending && ( +
+ +
+ )} + {presentingDocument && ( + setPresentingDocument(null)} + /> + )} + {popup} +
+ {isLoading ? ( + + ) : filteredFolders.length > 0 ? ( +
+ {filteredFolders.map((folder) => ( + onRenameItem(folder.id, folder.name, true)} + onDelete={() => handleDeleteItem(folder.id, true)} + onMove={() => handleMoveItem(folder.id, currentFolder, true)} + /> + ))} +
+ ) : ( +
+ +

+ No items found +

+
+ )} +
+
+ { + setPage(newPage); + window.scrollTo({ + top: 0, + left: 0, + behavior: "smooth", + }); + }} + /> +
+
+
+
+
+ ); +} diff --git a/web/src/app/chat/my-documents/WrappedDocuments.tsx b/web/src/app/chat/my-documents/WrappedDocuments.tsx new file mode 100644 index 00000000000..569e2aae902 --- /dev/null +++ b/web/src/app/chat/my-documents/WrappedDocuments.tsx @@ -0,0 +1,15 @@ +"use client"; + +import Title from "@/components/ui/title"; +import SidebarWrapper from "../../assistants/SidebarWrapper"; +import MyDocuments from "./MyDocuments"; + +export default function WrappedUserDocuments({}: {}) { + return ( + +
+ +
+
+ ); +} diff --git a/web/src/app/chat/my-documents/[id]/UserFileContent.tsx b/web/src/app/chat/my-documents/[id]/UserFileContent.tsx new file mode 100644 index 00000000000..bd9dcf7ed01 --- /dev/null +++ b/web/src/app/chat/my-documents/[id]/UserFileContent.tsx @@ -0,0 +1,7 @@ +import { useDocumentsContext } from "../DocumentsContext"; + +export default function UserFolder({ userFileId }: { userFileId: string }) { + const { folders } = useDocumentsContext(); + + return
{folders.length}
; +} diff --git a/web/src/app/chat/my-documents/[id]/UserFolder.tsx b/web/src/app/chat/my-documents/[id]/UserFolder.tsx new file mode 100644 index 00000000000..6b8bca44ef5 --- /dev/null +++ b/web/src/app/chat/my-documents/[id]/UserFolder.tsx @@ -0,0 +1,18 @@ +"use client"; + +import SidebarWrapper from "@/app/assistants/SidebarWrapper"; +import UserFolderContent from "./UserFolderContent"; + +export default function WrappedUserFolders({ + userFileId, +}: { + userFileId: string; +}) { + return ( + +
+ +
+
+ ); +} diff --git a/web/src/app/chat/my-documents/[id]/UserFolderContent.tsx b/web/src/app/chat/my-documents/[id]/UserFolderContent.tsx new file mode 100644 index 00000000000..5d6a3531951 --- /dev/null +++ b/web/src/app/chat/my-documents/[id]/UserFolderContent.tsx @@ -0,0 +1,344 @@ +import React, { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { ArrowLeft, MessageSquare } from "lucide-react"; +import { useDocumentsContext } from "../DocumentsContext"; +import { useAssistants } from "@/components/context/AssistantsContext"; +import { useChatContext } from "@/components/context/ChatContext"; +import { Button } from "@/components/ui/button"; +import { DocumentList } from "./components/DocumentList"; +import { usePopup } from "@/components/admin/connectors/Popup"; +import { usePopupFromQuery } from "@/components/popup/PopupFromQuery"; +import { Input } from "@/components/ui/input"; +import { DeleteEntityModal } from "@/components/DeleteEntityModal"; +import { MoveFolderModal } from "@/components/MoveFolderModal"; +import { FolderResponse } from "../DocumentsContext"; +import { SharingPanel } from "./components/panels/SharingPanel"; +import { ContextLimitPanel } from "./components/panels/ContextLimitPanel"; +import { AddWebsitePanel } from "./components/panels/AddWebsitePanel"; + +export default function UserFolderContent({ folderId }: { folderId: number }) { + const router = useRouter(); + const { assistants } = useAssistants(); + const { llmProviders } = useChatContext(); + const { popup, setPopup } = usePopup(); + const { + folderDetails, + getFolderDetails, + downloadItem, + renameItem, + deleteItem, + createFileFromLink, + handleUpload, + refreshFolderDetails, + getFolders, + moveItem, + } = useDocumentsContext(); + + const [isCapacityOpen, setIsCapacityOpen] = useState(false); + const [isSharedOpen, setIsSharedOpen] = useState(false); + const [editingItemId, setEditingItemId] = useState(null); + const [newItemName, setNewItemName] = useState(""); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [deleteItemId, setDeleteItemId] = useState(null); + const [deleteItemType, setDeleteItemType] = useState<"file" | "folder">( + "file" + ); + const [deleteItemName, setDeleteItemName] = useState(""); + const [isMoveModalOpen, setIsMoveModalOpen] = useState(false); + const [folders, setFolders] = useState([]); + + const modelDescriptors = llmProviders.flatMap((provider) => + Object.entries(provider.model_token_limits ?? {}).map( + ([modelName, maxTokens]) => ({ + modelName, + provider: provider.provider, + maxTokens, + }) + ) + ); + + const [selectedModel, setSelectedModel] = useState(modelDescriptors[0]); + + const { popup: folderCreatedPopup } = usePopupFromQuery({ + "folder-created": { + message: `Folder created successfully`, + type: "success", + }, + }); + + useEffect(() => { + if (!folderDetails) { + getFolderDetails(folderId); + } + }, [folderId, folderDetails, getFolderDetails]); + + useEffect(() => { + const fetchFolders = async () => { + try { + const fetchedFolders = await getFolders(); + setFolders(fetchedFolders); + } catch (error) { + console.error("Error fetching folders:", error); + } + }; + + fetchFolders(); + }, []); + + const handleBack = () => { + router.push("/chat/my-documents"); + }; + if (!folderDetails) { + return ( +
+
+

+ No Folder Found +

+

+ The requested folder does not exist or you dont have permission to + view it. +

+ +
+
+ ); + } + + const totalTokens = folderDetails.files.reduce( + (acc, file) => acc + (file.token_count || 0), + 0 + ); + const maxTokens = selectedModel.maxTokens; + const tokenPercentage = (totalTokens / maxTokens) * 100; + + const handleStartChat = () => { + router.push(`/chat?userFolderId=${folderId}`); + }; + + const handleCreateFileFromLink = async (url: string) => { + await createFileFromLink(url, folderId); + }; + + const handleRenameItem = async ( + itemId: number, + currentName: string, + isFolder: boolean + ) => { + setEditingItemId(itemId); + setNewItemName(currentName); + }; + + const handleSaveRename = async (itemId: number, isFolder: boolean) => { + if (newItemName && newItemName !== folderDetails.name) { + try { + await renameItem(itemId, newItemName, isFolder); + setPopup({ + message: `${isFolder ? "Folder" : "File"} renamed successfully`, + type: "success", + }); + await refreshFolderDetails(); + } catch (error) { + console.error("Error renaming item:", error); + setPopup({ + message: `Failed to rename ${isFolder ? "folder" : "file"}`, + type: "error", + }); + } + } + setEditingItemId(null); + }; + + const handleCancelRename = () => { + setEditingItemId(null); + setNewItemName(""); + }; + + const handleDeleteItem = ( + itemId: number, + isFolder: boolean, + itemName: string + ) => { + setDeleteItemId(itemId); + setDeleteItemType(isFolder ? "folder" : "file"); + setDeleteItemName(itemName); + setIsDeleteModalOpen(true); + }; + + const confirmDelete = async () => { + if (deleteItemId !== null) { + try { + await deleteItem(deleteItemId, deleteItemType === "folder"); + setPopup({ + message: `${deleteItemType} deleted successfully`, + type: "success", + }); + await refreshFolderDetails(); + } catch (error) { + console.error("Error deleting item:", error); + setPopup({ + message: `Failed to delete ${deleteItemType}`, + type: "error", + }); + } + } + setIsDeleteModalOpen(false); + }; + + const handleMoveFolder = () => { + setIsMoveModalOpen(true); + }; + + const confirmMove = async (targetFolderId: number) => { + try { + await moveItem(folderId, targetFolderId, true); + setPopup({ + message: "Folder moved successfully", + type: "success", + }); + router.push(`/chat/my-documents/${targetFolderId}`); + } catch (error) { + console.error("Error moving folder:", error); + setPopup({ + message: "Failed to move folder", + type: "error", + }); + } + setIsMoveModalOpen(false); + }; + + const handleMoveFile = async (fileId: number, targetFolderId: number) => { + try { + await moveItem(fileId, targetFolderId, false); + setPopup({ + message: "File moved successfully", + type: "success", + }); + await refreshFolderDetails(); + } catch (error) { + console.error("Error moving file:", error); + setPopup({ + message: "Failed to move file", + type: "error", + }); + } + }; + + return ( +
+ {popup} + {folderCreatedPopup} + setIsDeleteModalOpen(false)} + onConfirm={confirmDelete} + entityType={deleteItemType} + entityName={deleteItemName} + /> + setIsMoveModalOpen(false)} + onMove={confirmMove} + folders={folders} + currentFolderId={folderId} + /> +
+
+
+ Back to My Knowledge Groups +
+ {editingItemId === folderDetails.id ? ( +
+ setNewItemName(e.target.value)} + className="mr-2" + /> + + +
+ ) : ( +
+

+ handleRenameItem(folderDetails.id, folderDetails.name, true) + } + > + {folderDetails.name} +

+
+ )} +

+ {folderDetails.description} +

+ + +
+ +
+ setIsCapacityOpen(!isCapacityOpen)} + tokenPercentage={tokenPercentage} + totalTokens={totalTokens} + maxTokens={maxTokens} + selectedModel={selectedModel} + modelDescriptors={modelDescriptors} + onSelectModel={setSelectedModel} + /> + + setIsSharedOpen(!isSharedOpen)} + /> + + + +
+ +
+
+
+
+ ); +} diff --git a/web/src/app/chat/my-documents/[id]/components/DocumentList.tsx b/web/src/app/chat/my-documents/[id]/components/DocumentList.tsx new file mode 100644 index 00000000000..bccb5519b4e --- /dev/null +++ b/web/src/app/chat/my-documents/[id]/components/DocumentList.tsx @@ -0,0 +1,130 @@ +import React, { useState } from "react"; +import { FileResponse, FolderResponse } from "../../DocumentsContext"; +import { + FileListItem, + SkeletonFileListItem, +} from "../../components/FileListItem"; +import { Button } from "@/components/ui/button"; +import { Grid, List, Loader2 } from "lucide-react"; +import { MinimalOnyxDocument } from "@/lib/search/interfaces"; +import TextView from "@/components/chat/TextView"; +import { Input } from "@/components/ui/input"; +import { FileUploadSection } from "./upload/FileUploadSection"; + +interface DocumentListProps { + files: FileResponse[]; + onRename: ( + itemId: number, + currentName: string, + isFolder: boolean + ) => Promise; + onDelete: (itemId: number, isFolder: boolean, itemName: string) => void; + onDownload: (documentId: string) => Promise; + onUpload: (files: File[]) => void; + onMove: (fileId: number, targetFolderId: number) => Promise; + folders: FolderResponse[]; + isLoading: boolean; + disabled?: boolean; + editingItemId: number | null; + onSaveRename: (itemId: number, isFolder: boolean) => Promise; + onCancelRename: () => void; + newItemName: string; + setNewItemName: React.Dispatch>; +} + +export const DocumentList: React.FC = ({ + files, + onRename, + onDelete, + onDownload, + onUpload, + onMove, + folders, + isLoading, + disabled, + editingItemId, + onSaveRename, + onCancelRename, + newItemName, + setNewItemName, +}) => { + const [presentingDocument, setPresentingDocument] = + useState(null); + const [view, setView] = useState<"grid" | "list">("list"); + + const toggleView = () => { + setView(view === "grid" ? "list" : "grid"); + }; + + return ( +
+ {presentingDocument && ( + setPresentingDocument(null)} + /> + )} + +
+

+ Documents in this Project +

+ +
+ + +
+ {files.map((file) => ( +
+ {editingItemId === file.id ? ( +
+ setNewItemName(e.target.value)} + className="mr-2" + /> + + +
+ ) : ( + + setPresentingDocument({ + semantic_identifier: file.name, + document_id: file.document_id, + }) + } + isIndexed={file.indexed || false} + /> + )} +
+ ))} + {isLoading && } +
+
+ ); +}; diff --git a/web/src/app/chat/my-documents/[id]/components/panels/AddWebsitePanel.tsx b/web/src/app/chat/my-documents/[id]/components/panels/AddWebsitePanel.tsx new file mode 100644 index 00000000000..1f919430a77 --- /dev/null +++ b/web/src/app/chat/my-documents/[id]/components/panels/AddWebsitePanel.tsx @@ -0,0 +1,74 @@ +import React, { useState } from "react"; +import { Link, ChevronDown, ChevronRight } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +interface AddWebsitePanelProps { + folderId: number; + onCreateFileFromLink: (url: string, folderId: number) => Promise; +} + +export function AddWebsitePanel({ + folderId, + onCreateFileFromLink, +}: AddWebsitePanelProps) { + const [isOpen, setIsOpen] = useState(false); + const [linkUrl, setLinkUrl] = useState(""); + const [isCreating, setIsCreating] = useState(false); + + const handleCreateFileFromLink = async () => { + if (!linkUrl) return; + setIsCreating(true); + try { + await onCreateFileFromLink(linkUrl, folderId); + setLinkUrl(""); + } catch (error) { + console.error("Error creating file from link:", error); + } finally { + setIsCreating(false); + } + }; + + return ( +
+
setIsOpen(!isOpen)} + > +
+ + + Add a website + +
+ +
+ + {isOpen && ( +
+ setLinkUrl(e.target.value)} + placeholder="Enter URL" + className="flex-grow !text-sm mr-2 px-2 py-1 border border-neutral-300 dark:border-neutral-600 rounded bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100" + /> + +
+ )} +
+ ); +} diff --git a/web/src/app/chat/my-documents/[id]/components/panels/ContextLimitPanel.tsx b/web/src/app/chat/my-documents/[id]/components/panels/ContextLimitPanel.tsx new file mode 100644 index 00000000000..9e2b59bd142 --- /dev/null +++ b/web/src/app/chat/my-documents/[id]/components/panels/ContextLimitPanel.tsx @@ -0,0 +1,79 @@ +import React from "react"; +import { Info, ChevronRight, ChevronDown } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { LLMModelDescriptor } from "@/app/admin/configuration/llm/interfaces"; +import { ModelSelector } from "./ModelSelector"; + +interface ContextLimitPanelProps { + isOpen: boolean; + onToggle: () => void; + tokenPercentage: number; + totalTokens: number; + maxTokens: number; + selectedModel: LLMModelDescriptor; + modelDescriptors: LLMModelDescriptor[]; + onSelectModel: (model: LLMModelDescriptor) => void; +} + +export function ContextLimitPanel({ + isOpen, + onToggle, + tokenPercentage, + totalTokens, + maxTokens, + selectedModel, + modelDescriptors, + onSelectModel, +}: ContextLimitPanelProps) { + return ( +
+
+
+ + + Context Limit + +
+ + +
+ + {isOpen && ( +
+
+ +
+
+ Tokens: {totalTokens} / {maxTokens} +
+
+
100 ? "bg-green-600" : "bg-blue-600" + }`} + style={{ width: `${Math.min(tokenPercentage, 100)}%` }} + >
+
+ {tokenPercentage > 100 && ( +
+ Capacity exceeded. Search will be performed over content. +
+ )} +
+ )} +
+ ); +} diff --git a/web/src/app/chat/my-documents/[id]/components/panels/ModelSelector.tsx b/web/src/app/chat/my-documents/[id]/components/panels/ModelSelector.tsx new file mode 100644 index 00000000000..05b95877d35 --- /dev/null +++ b/web/src/app/chat/my-documents/[id]/components/panels/ModelSelector.tsx @@ -0,0 +1,45 @@ +import React from "react"; +import { LLMModelDescriptor } from "@/app/admin/configuration/llm/interfaces"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { OpenAIIcon } from "@/components/icons/icons"; +import { getDisplayNameForModel } from "@/lib/hooks"; + +interface ModelSelectorProps { + models: LLMModelDescriptor[]; + selectedModel: LLMModelDescriptor; + onSelectModel: (model: LLMModelDescriptor) => void; +} + +export const ModelSelector: React.FC = ({ + models, + selectedModel, + onSelectModel, +}) => ( + +); diff --git a/web/src/app/chat/my-documents/[id]/components/panels/SharingPanel.tsx b/web/src/app/chat/my-documents/[id]/components/panels/SharingPanel.tsx new file mode 100644 index 00000000000..5f23df32b05 --- /dev/null +++ b/web/src/app/chat/my-documents/[id]/components/panels/SharingPanel.tsx @@ -0,0 +1,84 @@ +import React from "react"; +import { User, Users, ChevronDown, ChevronRight } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { AssistantIcon } from "@/components/assistants/AssistantIcon"; + +// Define a simplified Assistant interface with only the properties we use +interface Assistant { + id: number; + name: string; +} + +interface SharingPanelProps { + assistantIds?: number[]; + assistants: Assistant[]; + isOpen: boolean; + onToggle: () => void; +} + +export function SharingPanel({ + assistantIds = [], + assistants, + isOpen, + onToggle, +}: SharingPanelProps) { + const count = assistantIds.length; + return ( +
+
+
+ {count > 0 ? ( + <> + + + Shared with {count} Assistant{count > 1 ? "s" : ""} + + + ) : ( + <> + + + Not shared + + + )} +
+ +
+ {isOpen && ( +
+ {count > 0 ? ( +
+ {assistantIds.map((id) => { + const assistant = assistants.find((a) => a.id === id); + return assistant ? ( + + + + {assistant.name} + + + ) : null; + })} +
+ ) : ( + Not shared with any assistants + )} +
+ )} +
+ ); +} diff --git a/web/src/app/chat/my-documents/[id]/components/upload/FileUploadSection.tsx b/web/src/app/chat/my-documents/[id]/components/upload/FileUploadSection.tsx new file mode 100644 index 00000000000..b342d70aec5 --- /dev/null +++ b/web/src/app/chat/my-documents/[id]/components/upload/FileUploadSection.tsx @@ -0,0 +1,66 @@ +import React from "react"; +import { Upload } from "lucide-react"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +interface FileUploadSectionProps { + onUpload: (files: File[]) => void; + disabledMessage?: string; + disabled?: boolean; +} + +export const FileUploadSection: React.FC = ({ + onUpload, + disabledMessage, + disabled, +}) => { + const handleChange = (e: React.ChangeEvent) => { + e.preventDefault(); + if (e.target.files && e.target.files.length > 0) { + const newFiles = Array.from(e.target.files); + onUpload(newFiles); + } + }; + + return ( + + + +
+ +
+
+ {disabled ? {disabledMessage} : null} +
+
+ ); +}; diff --git a/web/src/app/chat/my-documents/[id]/components/upload/UploadWarning.tsx b/web/src/app/chat/my-documents/[id]/components/upload/UploadWarning.tsx new file mode 100644 index 00000000000..cf25e93e287 --- /dev/null +++ b/web/src/app/chat/my-documents/[id]/components/upload/UploadWarning.tsx @@ -0,0 +1,24 @@ +import React from "react"; +import { AlertTriangle } from "lucide-react"; + +interface UploadWarningProps { + className?: string; +} + +export const UploadWarning: React.FC = ({ className }) => { + return ( +
+
+ +

+ Warning: This folder is shared. Any + documents you upload will be accessible to the shared assistants. +

+
+
+ ); +}; diff --git a/web/src/app/chat/my-documents/[id]/page.tsx b/web/src/app/chat/my-documents/[id]/page.tsx new file mode 100644 index 00000000000..abfb57b34a7 --- /dev/null +++ b/web/src/app/chat/my-documents/[id]/page.tsx @@ -0,0 +1,22 @@ +import WrappedUserFolders from "./UserFolder"; +import { DocumentsProvider, FolderResponse } from "../DocumentsContext"; +import { fetchSS } from "@/lib/utilsSS"; + +export default async function GalleryPage(props: { + params: Promise<{ ["id"]: string }>; +}) { + const searchParams = await props.params; + const response = await fetchSS(`/user/folder/${searchParams.id}`); + + // Simulate a 20-second delay + // await new Promise((resolve) => setTimeout(resolve, 20000)); + const folderResponse: FolderResponse | undefined = response.ok + ? await response.json() + : null; + + return ( + + + + ); +} diff --git a/web/src/app/chat/my-documents/components/FileListItem.tsx b/web/src/app/chat/my-documents/components/FileListItem.tsx new file mode 100644 index 00000000000..d2eb4346fc4 --- /dev/null +++ b/web/src/app/chat/my-documents/components/FileListItem.tsx @@ -0,0 +1,226 @@ +import React, { useState } from "react"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + CheckCircle, + File as FileIcon, + MoreVertical, + X, + ArrowLeft, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { FileResponse, FolderResponse } from "../DocumentsContext"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { MinimalOnyxDocument } from "@/lib/search/interfaces"; +import { + FiArrowDown, + FiDownload, + FiEdit, + FiSearch, + FiTrash, +} from "react-icons/fi"; + +interface FileListItemProps { + file: FileResponse; + isSelected?: boolean; + onSelect?: () => void; + view: "grid" | "list"; + onRename: ( + itemId: number, + currentName: string, + isFolder: boolean + ) => Promise; + onDelete: (itemId: number, isFolder: boolean, itemName: string) => void; + onDownload: (documentId: string) => Promise; + onMove: (fileId: number, targetFolderId: number) => Promise; + folders: FolderResponse[]; + isIndexed: boolean; +} + +export const FileListItem: React.FC = ({ + file, + isSelected, + onSelect, + view, + onRename, + onDelete, + onDownload, + onMove, + folders, + isIndexed, +}) => { + const [showMoveOptions, setShowMoveOptions] = useState(false); + + const handleDelete = () => { + onDelete(file.id, false, file.name); + }; + + const handleMove = (targetFolderId: number) => { + onMove(file.id, targetFolderId); + setShowMoveOptions(false); + }; + + return ( +
+
+ {isSelected !== undefined && ( + + )} + + +

{file.name}

+ + + +
+ + + {!isIndexed ? ( +

Not yet indexed. This will be completed momentarily.

+ ) : ( +

Indexed

+ )} +
+ + + +
+ + + + + + {!showMoveOptions ? ( +
+ + + + + +
+ ) : ( +
+
+ +

Move folder

+
+
+
+ {[...folders, ...folders].map((folder) => ( + + ))} +
+
+
+ )} +
+
+
+ ); +}; + +export const SkeletonFileListItem: React.FC<{ + view: "grid" | "list"; +}> = ({ view }) => { + return ( +
+
+
+
+
+
+
+ ); +}; diff --git a/web/src/app/chat/my-documents/components/FilePicker.tsx b/web/src/app/chat/my-documents/components/FilePicker.tsx new file mode 100644 index 00000000000..ce76c54b3f7 --- /dev/null +++ b/web/src/app/chat/my-documents/components/FilePicker.tsx @@ -0,0 +1,833 @@ +import React, { useState, useEffect, useMemo } from "react"; +import { Button } from "@/components/ui/button"; +import { Modal } from "@/components/Modal"; +import { + Grid, + List, + UploadIcon, + FolderIcon, + FileIcon, + PlusIcon, + Router, + X, +} from "lucide-react"; +import { SelectedItemsList } from "./SelectedItemsList"; +import { Separator } from "@/components/ui/separator"; +import { + useDocumentsContext, + FolderResponse, + FileResponse, + FileUploadResponse, +} from "../DocumentsContext"; +import { + DndContext, + closestCenter, + DragOverlay, + DragEndEvent, + DragStartEvent, + useSensor, + useSensors, + PointerSensor, + DragMoveEvent, + KeyboardSensor, +} from "@dnd-kit/core"; +import { + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; + +import { + TooltipProvider, + Tooltip, + TooltipTrigger, + TooltipContent, +} from "@/components/ui/tooltip"; +import { useRouter } from "next/navigation"; +import { usePopup } from "@/components/admin/connectors/Popup"; + +const ListIcon = () => ; +const GridIcon = () => ; + +const IconButton: React.FC<{ + icon: React.ComponentType; + onClick: () => void; + active: boolean; +}> = ({ icon: Icon, onClick, active }) => ( + +); + +const DraggableItem: React.FC<{ + id: string; + type: "folder" | "file"; + item: FolderResponse | FileResponse; + onClick?: () => void; + isSelected: boolean; +}> = ({ id, type, item, onClick, isSelected }) => { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id }); + + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + position: "relative", + zIndex: isDragging ? 1 : "auto", + }; + + const selectedClassName = isSelected + ? "bg-blue-100 border-blue-300 shadow-sm" + : "hover:bg-gray-100"; + + if (type === "folder") { + return ( +
+ {})} + onSelect={() => {}} + isSelected={isSelected} + allFilesSelected={false} + /> +
+ ); + } + + return ( +
+ + {(item as FileResponse).name} +
+ ); +}; + +const FilePickerFolderItem: React.FC<{ + folder: FolderResponse; + onClick: () => void; + onSelect: () => void; + isSelected: boolean; + allFilesSelected: boolean; +}> = ({ folder, onClick, onSelect, isSelected, allFilesSelected }) => { + const selectedClassName = + isSelected || allFilesSelected + ? "from-blue-100 to-blue-50 border-blue-300 shadow-sm dark:from-blue-900 dark:to-blue-800 dark:border-blue-700" + : "from-[#f2f0e8]/80 to-[#F7F6F0] hover:from-[#f2f0e8] hover:to-[#F7F6F0] dark:from-neutral-800 dark:to-neutral-900 dark:hover:from-neutral-700 dark:hover:to-neutral-800"; + + return ( +
+
+
+ + + + + {folder.name} + + + +

{folder.name}

+
+
+
+ +
+ {folder.description && ( +
+ {folder.description} +
+ )} +
+
+ ); +}; + +export interface FilePickerModalProps { + isOpen: boolean; + onClose: () => void; + onSave: () => void; + title: string; + buttonContent: string; + selectedFiles: FileResponse[]; + selectedFolders: FolderResponse[]; + addSelectedFile: (file: FileResponse) => void; + removeSelectedFile: (file: FileResponse) => void; + addSelectedFolder: (folder: FolderResponse) => void; +} + +export const FilePickerModal: React.FC = ({ + isOpen, + onClose, + onSave, + title, + buttonContent, + selectedFiles, + selectedFolders, + addSelectedFile, + addSelectedFolder, +}) => { + const { + folders, + refreshFolders, + uploadFile, + currentFolder, + setCurrentFolder, + renameItem, + deleteItem, + moveItem, + downloadItem, + removeSelectedFile, + createFileFromLink, + setSelectedFiles, + setSelectedFolders, + } = useDocumentsContext(); + + const router = useRouter(); + const [uploadedFiles, setUploadedFiles] = useState([]); + const [linkUrl, setLinkUrl] = useState(""); + const [isCreatingFileFromLink, setIsCreatingFileFromLink] = useState(false); + const [isUploadingFile, setIsUploadingFile] = useState(false); + + const [view, setView] = useState<"grid" | "list">("list"); + const [searchQuery, setSearchQuery] = useState(""); + const [currentFolderFiles, setCurrentFolderFiles] = useState( + [] + ); + const [activeId, setActiveId] = useState(null); + const [isHoveringRight, setIsHoveringRight] = useState(false); + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ); + + const [selectedFileIds, setSelectedFileIds] = useState>( + new Set() + ); + const [selectedFolderIds, setSelectedFolderIds] = useState>( + new Set() + ); + + const { setPopup } = usePopup(); + + useEffect(() => { + if (isOpen) { + refreshFolders(); + } + }, [isOpen, refreshFolders]); + + useEffect(() => { + if (currentFolder) { + const folder = folders.find((f) => f.id === currentFolder); + setCurrentFolderFiles(folder?.files || []); + } else { + setCurrentFolderFiles([]); + } + }, [currentFolder, folders]); + + useEffect(() => { + if (searchQuery) { + setCurrentFolder(null); + } + }, [searchQuery]); + + const handleSave = () => { + // onSave(selectedItems); + onClose(); + }; + + const handleFolderClick = (folderId: number) => { + console.log(`Folder clicked: ${folderId}`); + setCurrentFolder(folderId); + const clickedFolder = folders.find((f) => f.id === folderId); + if (clickedFolder) { + console.log(`Found folder: ${clickedFolder.name}`); + setCurrentFolderFiles(clickedFolder.files || []); + } else { + console.log(`Folder not found for id: ${folderId}`); + setCurrentFolderFiles([]); + } + }; + + const handleFileSelect = (file: FileResponse) => { + setSelectedFileIds((prev) => { + const newSet = new Set(prev); + if (newSet.has(file.id)) { + newSet.delete(file.id); + } else { + newSet.add(file.id); + } + return newSet; + }); + removeSelectedFile(file); + + // Check if the file's folder should be unselected + if (file.folder_id) { + setSelectedFolderIds((prev) => { + const newSet = new Set(prev); + if (newSet.has(file.folder_id!)) { + const folder = folders.find((f) => f.id === file.folder_id); + if (folder) { + const allFilesSelected = folder.files.every( + (f) => selectedFileIds.has(f.id) || f.id === file.id + ); + if (!allFilesSelected) { + newSet.delete(file.folder_id!); + } + } + } + return newSet; + }); + } + }; + + const handleFolderSelect = (folder: FolderResponse) => { + setSelectedFolderIds((prev) => { + const newSet = new Set(prev); + if (newSet.has(folder.id)) { + newSet.delete(folder.id); + } else { + newSet.add(folder.id); + } + return newSet; + }); + + // Update selectedFileIds based on folder selection + setSelectedFileIds((prev) => { + const newSet = new Set(prev); + folder.files.forEach((file) => { + if (selectedFolderIds.has(folder.id)) { + newSet.delete(file.id); + } else { + newSet.add(file.id); + } + }); + return newSet; + }); + }; + + const selectedItems = useMemo(() => { + const items: { folders: FolderResponse[]; files: FileResponse[] } = { + folders: [], + files: [], + }; + selectedFiles.forEach((file) => { + if (!folders.some((f) => f.id === file.folder_id)) { + items.files.push(file); + } + }); + + folders.forEach((folder) => { + if (selectedFolderIds.has(folder.id)) { + items.folders.push(folder); + } else { + const selectedFilesInFolder = folder.files.filter((file) => + selectedFileIds.has(file.id) + ); + if (selectedFilesInFolder.length === folder.files.length) { + items.folders.push(folder); + } else { + items.files.push(...selectedFilesInFolder); + } + } + }); + setSelectedFiles(items.files); + setSelectedFolders(items.folders); + return items; + }, [folders, selectedFileIds, selectedFolderIds]); + + const addUploadedFileToContext = async (files: FileList) => { + for (let i = 0; i < files.length; i++) { + const file = files[i]; + const formData = new FormData(); + formData.append("files", file); + const response: FileUploadResponse = await uploadFile(formData, null); + + if (response.file_paths && response.file_paths.length > 0) { + const uploadedFile: FileResponse = { + id: Date.now(), + name: file.name, + document_id: response.file_paths[0], + folder_id: null, + size: file.size, + type: file.type, + lastModified: new Date().toISOString(), + token_count: 0, + }; + addSelectedFile(uploadedFile); + } + } + }; + + const handleFileUpload = async (e: React.ChangeEvent) => { + console.log("File upload started"); + const files = e.target.files; + if (files) { + setIsUploadingFile(true); + try { + await addUploadedFileToContext(files); + await refreshFolders(); + } catch (error) { + console.error("Error uploading file:", error); + } finally { + setIsUploadingFile(false); + } + } + }; + + const handleDragStart = (event: DragStartEvent) => { + console.log("Drag started:", event); + setActiveId(event.active.id.toString()); + }; + + const handleDragMove = (event: DragMoveEvent) => { + console.log("Drag move:", event); + }; + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + console.log("Drag ended:", { active, over, isHoveringRight }); + + if (active.id !== over?.id && isHoveringRight) { + const activeType = active.id.toString().startsWith("folder") + ? "folders" + : "files"; + const activeId = parseInt(active.id.toString().split("-")[1], 10); + + console.log(`Added ${activeType} with id ${activeId} to selected items`); + } else { + console.log("Item not added to selection"); + } + + setActiveId(null); + setIsHoveringRight(false); + }; + + const handleDragCancel = () => { + setActiveId(null); + setIsHoveringRight(false); + }; + + const handleCreateFileFromLink = async () => { + if (!linkUrl) return; + setIsCreatingFileFromLink(true); + try { + const response: FileUploadResponse = await createFileFromLink( + linkUrl, + currentFolder + ); + setLinkUrl(""); + + if (response.file_paths && response.file_paths.length > 0) { + const createdFile: FileResponse = { + id: Date.now(), + name: new URL(linkUrl).hostname, + document_id: response.file_paths[0], + folder_id: currentFolder || null, + size: 0, + type: "link", + lastModified: new Date().toISOString(), + token_count: 0, + }; + addSelectedFile(createdFile); + } + + await refreshFolders(); + } catch (error) { + console.error("Error creating file from link:", error); + } finally { + setIsCreatingFileFromLink(false); + } + }; + + const filteredFolders = folders.filter((folder) => + folder.name.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + const renderNavigation = () => { + if (currentFolder !== null) { + return ( +
setCurrentFolder(null)} + > + + + + Back to Folders +
+ ); + } + return null; + }; + + const isAllFilesInFolderSelected = (folder: FolderResponse) => { + return folder.files.every((file) => selectedFileIds.has(file.id)); + }; + + const handleRenameItem = async ( + itemId: number, + currentName: string, + isFolder: boolean + ) => { + const newName = prompt( + `Enter new name for ${isFolder ? "folder" : "file"}:`, + currentName + ); + if (newName && newName !== currentName) { + try { + await renameItem(itemId, newName, isFolder); + setPopup({ + message: `${isFolder ? "Folder" : "File"} renamed successfully`, + type: "success", + }); + await refreshFolders(); + } catch (error) { + console.error("Error renaming item:", error); + setPopup({ + message: `Failed to rename ${isFolder ? "folder" : "file"}`, + type: "error", + }); + } + } + }; + + const handleDeleteItem = async (itemId: number, isFolder: boolean) => { + const itemType = isFolder ? "folder" : "file"; + const confirmDelete = window.confirm( + `Are you sure you want to delete this ${itemType}?` + ); + + if (confirmDelete) { + try { + await deleteItem(itemId, isFolder); + setPopup({ + message: `${itemType} deleted successfully`, + type: "success", + }); + await refreshFolders(); + } catch (error) { + console.error("Error deleting item:", error); + setPopup({ + message: `Failed to delete ${itemType}`, + type: "error", + }); + } + } + }; + + const handleMoveItem = async ( + itemId: number, + currentFolderId: number | null, + isFolder: boolean + ) => { + const availableFolders = folders + .filter((folder) => folder.id !== itemId) + .map((folder) => `${folder.id}: ${folder.name}`) + .join("\n"); + + const promptMessage = `Enter the ID of the destination folder:\n\nAvailable folders:\n${availableFolders}\n\nEnter 0 to move to the root folder.`; + const destinationFolderId = prompt(promptMessage); + + if (destinationFolderId !== null) { + const newFolderId = parseInt(destinationFolderId, 10); + if (isNaN(newFolderId)) { + setPopup({ + message: "Invalid folder ID", + type: "error", + }); + return; + } + + try { + await moveItem( + itemId, + newFolderId === 0 ? null : newFolderId, + isFolder + ); + setPopup({ + message: `${isFolder ? "Folder" : "File"} moved successfully`, + type: "success", + }); + await refreshFolders(); + } catch (error) { + console.error("Error moving item:", error); + setPopup({ + message: "Failed to move item", + type: "error", + }); + } + } + }; + + return ( + +
+
+
+
+ setSearchQuery(e.target.value)} + /> +
+ + + +
+
+ {renderNavigation()} +
+ + {filteredFolders.length + currentFolderFiles.length > 0 ? ( +
+ + `folder-${f.id}`), + ...currentFolderFiles.map((f) => `file-${f.id}`), + ]} + strategy={verticalListSortingStrategy} + > +
+ {currentFolder === null + ? filteredFolders.map((folder) => ( + handleFolderClick(folder.id)} + onSelect={() => handleFolderSelect(folder)} + isSelected={selectedFolderIds.has(folder.id)} + allFilesSelected={isAllFilesInFolderSelected( + folder + )} + /> + )) + : currentFolderFiles.map((file) => ( + handleFileSelect(file)} + isSelected={selectedFileIds.has(file.id)} + /> + ))} +
+
+ + + {activeId ? ( + + f.id === parseInt(activeId.split("-")[1], 10) + )! + : currentFolderFiles.find( + (f) => + f.id === parseInt(activeId.split("-")[1], 10) + )! + } + isSelected={ + activeId.startsWith("folder") + ? selectedFolderIds.has( + parseInt(activeId.split("-")[1], 10) + ) + : selectedFileIds.has( + parseInt(activeId.split("-")[1], 10) + ) + } + /> + ) : null} + +
+
+ ) : folders.length > 0 ? ( +
+

+ No files or folders found +

+
+ ) : ( +
+

+ No files or folders found +

+ + + Create folder in My Documents + +
+ )} +
+
setIsHoveringRight(true)} + onDragLeave={() => setIsHoveringRight(false)} + > +
+ handleFileSelect(file)} + onRemoveFolder={(folder) => handleFolderSelect(folder)} + /> +
+ +
+
+ + +
+ + + +
+
+

+ Add links to the context +

+
+
{ + e.preventDefault(); + handleCreateFileFromLink(); + }} + > +
+ setLinkUrl(e.target.value)} + placeholder="Enter URL" + className="flex-grow !text-sm mr-2 px-2 py-1 border border-gray-300 dark:border-neutral-600 rounded dark:bg-neutral-800 dark:text-neutral-100" + /> + +
+
+
+
+
+
+
+ ); +}; diff --git a/web/src/app/chat/my-documents/components/SearchResultItem.tsx b/web/src/app/chat/my-documents/components/SearchResultItem.tsx new file mode 100644 index 00000000000..4d5ccf1b5de --- /dev/null +++ b/web/src/app/chat/my-documents/components/SearchResultItem.tsx @@ -0,0 +1,117 @@ +import React from "react"; +import { File, Link as LinkIcon, Folder } from "lucide-react"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; + +interface SearchResultItemProps { + item: { + id: number; + name: string; + document_id: string; + }; + view: "grid" | "list"; + onClick: (documentId: string, name: string) => void; + isLink?: boolean; + lastUpdated?: string; + onRename: () => void; + onDelete: () => void; + onMove: () => void; + parentFolder?: { + id: number; + name: string; + }; + onParentFolderClick?: (folderId: number) => void; + fileSize?: FileSize; +} +export enum FileSize { + SMALL = "Small", + MEDIUM = "Medium", + LARGE = "Large", +} +export const fileSizeToDescription = { + [FileSize.SMALL]: "Small", + [FileSize.MEDIUM]: "Medium", + [FileSize.LARGE]: "Large", +}; + +export const SearchResultItem: React.FC = ({ + item, + view, + onClick, + isLink = false, + lastUpdated, + onRename, + onDelete, + onMove, + parentFolder, + onParentFolderClick, + fileSize = FileSize.SMALL, +}) => { + const Icon = isLink ? LinkIcon : File; + + return ( + + ); +}; diff --git a/web/src/app/chat/my-documents/components/SelectedItemsList.tsx b/web/src/app/chat/my-documents/components/SelectedItemsList.tsx new file mode 100644 index 00000000000..342c5fd6188 --- /dev/null +++ b/web/src/app/chat/my-documents/components/SelectedItemsList.tsx @@ -0,0 +1,83 @@ +import React from "react"; +import { Button } from "@/components/ui/button"; +import { X, Folder, File } from "lucide-react"; +import { + FolderResponse, + FileResponse, + useDocumentsContext, +} from "../DocumentsContext"; +import { useDocumentSelection } from "../../useDocumentSelection"; + +interface SelectedItemsListProps { + folders: FolderResponse[]; + files: FileResponse[]; + onRemoveFile: (file: FileResponse) => void; + onRemoveFolder: (folder: FolderResponse) => void; +} + +export const SelectedItemsList: React.FC = ({ + folders, + files, + onRemoveFile, + onRemoveFolder, +}) => { + // const { + // selectedFiles, + // selectedFolders, + // setSelectedFiles, + // setSelectedFolders, + // } = useDocumentsContext(); + return ( +
+

+ Selected Items +

+
+
+ {folders?.map((folder: FolderResponse) => ( +
+
+ + + {folder.name} + +
+ +
+ ))} + {files?.map((file: FileResponse) => ( +
+
+ + + {file.name} + +
+ +
+ ))} +
+
+
+ ); +}; diff --git a/web/src/app/chat/my-documents/components/SharedFolderItem.tsx b/web/src/app/chat/my-documents/components/SharedFolderItem.tsx new file mode 100644 index 00000000000..04793ccdde7 --- /dev/null +++ b/web/src/app/chat/my-documents/components/SharedFolderItem.tsx @@ -0,0 +1,84 @@ +import React from "react"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { getTimeAgoString } from "@/lib/dateUtils"; + +interface SharedFolderItemProps { + folder: { + id: number; + name: string; + }; + onClick: (folderId: number) => void; + description?: string; + lastUpdated?: string; + onRename: () => void; + onDelete: () => void; + onMove: () => void; +} + +export const SharedFolderItem: React.FC = ({ + folder, + onClick, + description, + lastUpdated, + onRename, + onDelete, + onMove, +}) => { + return ( + { + e.preventDefault(); + onClick(folder.id); + }} + > +
+
+ + + + + {folder.name} + + + +

{folder.name}

+
+
+
+
+ {description && ( +
+ {description} +
+ )} +
+ {lastUpdated && ( +
+   + + Updated{" "} + + {getTimeAgoString(new Date(lastUpdated))} + + +
+ )} +
+ ); +}; diff --git a/web/src/app/chat/my-documents/components/types.ts b/web/src/app/chat/my-documents/components/types.ts new file mode 100644 index 00000000000..23150739636 --- /dev/null +++ b/web/src/app/chat/my-documents/components/types.ts @@ -0,0 +1,31 @@ +import { FileResponse } from "../DocumentsContext"; + +export interface UserFolder { + id: number; + name: string; + parent_id: number | null; + token_count: number | null; +} + +export interface UserFile { + id: number; + name: string; + parent_folder_id: number | null; + token_count: number | null; +} + +export interface FolderNode extends UserFolder { + children: FolderNode[]; + files: UserFolder[]; +} + +export interface FilePickerModalProps { + isOpen: boolean; + onClose: () => void; + onSave: (selectedItems: { files: number[]; folders: number[] }) => void; + title: string; + buttonContent: string; + selectedFiles: FileResponse[]; + addSelectedFile: (file: FileResponse) => void; + removeSelectedFile: (file: FileResponse) => void; +} diff --git a/web/src/app/chat/my-documents/page.tsx b/web/src/app/chat/my-documents/page.tsx new file mode 100644 index 00000000000..0d96801c277 --- /dev/null +++ b/web/src/app/chat/my-documents/page.tsx @@ -0,0 +1,12 @@ +import WrappedDocuments from "./WrappedDocuments"; +import { DocumentsProvider } from "./DocumentsContext"; + +export default async function GalleryPage(props: { + searchParams: Promise<{ [key: string]: string }>; +}) { + return ( + + + + ); +} diff --git a/web/src/app/chat/my-documents/useDocuments.ts b/web/src/app/chat/my-documents/useDocuments.ts new file mode 100644 index 00000000000..7f222b65c42 --- /dev/null +++ b/web/src/app/chat/my-documents/useDocuments.ts @@ -0,0 +1,64 @@ +import { useState, useEffect, useCallback } from "react"; + +// API functions +const fetchDocuments = async (): Promise => { + 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 => { + 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([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(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, + }; +}; diff --git a/web/src/app/chat/page.tsx b/web/src/app/chat/page.tsx index 582b2c53e77..80c3ef69c29 100644 --- a/web/src/app/chat/page.tsx +++ b/web/src/app/chat/page.tsx @@ -1,3 +1,4 @@ +import { DocumentsProvider } from "./my-documents/DocumentsContext"; import { SEARCH_PARAMS } from "@/lib/extension/constants"; import WrappedChat from "./WrappedChat"; @@ -10,9 +11,11 @@ export default async function Page(props: { searchParams[SEARCH_PARAMS.DEFAULT_SIDEBAR_OFF] === "true"; return ( - + + + ); } diff --git a/web/src/app/chat/searchParams.ts b/web/src/app/chat/searchParams.ts index b32390aa4c1..dc40a2af4db 100644 --- a/web/src/app/chat/searchParams.ts +++ b/web/src/app/chat/searchParams.ts @@ -5,6 +5,7 @@ export const SEARCH_PARAM_NAMES = { CHAT_ID: "chatId", SEARCH_ID: "searchId", PERSONA_ID: "assistantId", + USER_FOLDER_ID: "userFolderId", // overrides TEMPERATURE: "temperature", MODEL_VERSION: "model-version", diff --git a/web/src/app/chat/sessionSidebar/HistorySidebar.tsx b/web/src/app/chat/sessionSidebar/HistorySidebar.tsx index 1206fd815d0..d921808acb1 100644 --- a/web/src/app/chat/sessionSidebar/HistorySidebar.tsx +++ b/web/src/app/chat/sessionSidebar/HistorySidebar.tsx @@ -19,7 +19,11 @@ import { ChatSession } from "../interfaces"; import { Folder } from "../folders/interfaces"; import { SettingsContext } from "@/components/settings/SettingsProvider"; -import { DocumentIcon2, NewChatIcon } from "@/components/icons/icons"; +import { + DocumentIcon2, + KnowledgeGroupIcon, + NewChatIcon, +} from "@/components/icons/icons"; import { PagesTab } from "./PagesTab"; import { pageType } from "./types"; import LogoWithText from "@/components/header/LogoWithText"; @@ -47,7 +51,7 @@ import { } from "@dnd-kit/sortable"; import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import { CircleX, PinIcon } from "lucide-react"; +import { CircleX, FolderIcon, PinIcon } from "lucide-react"; import { restrictToVerticalAxis } from "@dnd-kit/modifiers"; import { TruncatedText } from "@/components/ui/truncatedText"; @@ -257,7 +261,7 @@ export const HistorySidebar = forwardRef( flex flex-none gap-y-4 - bg-background-sidebar + bg-white w-full border-r dark:border-none @@ -304,6 +308,18 @@ export const HistorySidebar = forwardRef( New Chat

+ + +

+ Knowledge Groups +

+ {user?.preferences?.shortcut_enabled && ( void, + (file: FileResponse) => void, OnyxDocument[], (document: OnyxDocument) => void, () => void, number, ] { + const [selectedFiles, setSelectedFiles] = useState([]); const [selectedDocuments, setSelectedDocuments] = useState( [] ); + const removeSelectedFile = (file: FileResponse) => { + setSelectedFiles(selectedFiles.filter((f) => f.id !== file.id)); + }; + + const addSelectedFile = (file: FileResponse) => { + setSelectedFiles([...selectedFiles, file]); + }; const [totalTokens, setTotalTokens] = useState(0); const selectedDocumentIds = selectedDocuments.map( (document) => document.document_id @@ -61,6 +73,9 @@ export function useDocumentSelection(): [ } return [ + selectedFiles, + addSelectedFile, + removeSelectedFile, selectedDocuments, toggleDocumentSelection, clearDocuments, diff --git a/web/src/app/ee/assistants/stats/[id]/WrappedAssistantsStats.tsx b/web/src/app/ee/assistants/stats/[id]/WrappedAssistantsStats.tsx index 63eb34cfdab..edd06d21d05 100644 --- a/web/src/app/ee/assistants/stats/[id]/WrappedAssistantsStats.tsx +++ b/web/src/app/ee/assistants/stats/[id]/WrappedAssistantsStats.tsx @@ -1,16 +1,15 @@ "use client"; -import SidebarWrapper from "../../../../assistants/SidebarWrapper"; + +import SidebarWrapper from "@/app/assistants/SidebarWrapper"; import { AssistantStats } from "./AssistantStats"; export default function WrappedAssistantsStats({ - initiallyToggled, assistantId, }: { - initiallyToggled: boolean; assistantId: number; }) { return ( - + ); diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index dc483552206..da865245483 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -27,6 +27,7 @@ import Script from "next/script"; import { Hanken_Grotesk } from "next/font/google"; import { WebVitals } from "./web-vitals"; import { ThemeProvider } from "next-themes"; +import { DocumentsProvider } from "./chat/my-documents/DocumentsContext"; import CloudError from "@/components/errorPages/CloudErrorPage"; import Error from "@/components/errorPages/ErrorPage"; import AccessRestrictedPage from "@/components/errorPages/AccessRestrictedPage"; @@ -153,11 +154,13 @@ export default async function RootLayout({ hasAnyConnectors={hasAnyConnectors} hasImageCompatibleModel={hasImageCompatibleModel} > - - - - {children} - {process.env.NEXT_PUBLIC_POSTHOG_KEY && } + + + + + {children} + {process.env.NEXT_PUBLIC_POSTHOG_KEY && } + ); } diff --git a/web/src/components/DeleteEntityModal.tsx b/web/src/components/DeleteEntityModal.tsx new file mode 100644 index 00000000000..246440fbfd2 --- /dev/null +++ b/web/src/components/DeleteEntityModal.tsx @@ -0,0 +1,40 @@ +import React from "react"; +import { Button } from "@/components/ui/button"; + +interface DeleteEntityModalProps { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + entityType: "file" | "folder"; + entityName: string; +} + +export const DeleteEntityModal: React.FC = ({ + isOpen, + onClose, + onConfirm, + entityType, + entityName, +}) => { + if (!isOpen) return null; + + return ( +
+
+

Delete {entityType}

+

+ Are you sure you want to delete the {entityType} "{entityName} + "? This action cannot be undone. +

+
+ + +
+
+
+ ); +}; diff --git a/web/src/components/MoveFolderModal.tsx b/web/src/components/MoveFolderModal.tsx new file mode 100644 index 00000000000..281ac5b56d9 --- /dev/null +++ b/web/src/components/MoveFolderModal.tsx @@ -0,0 +1,53 @@ +import React from "react"; +import { Button } from "@/components/ui/button"; + +interface Folder { + id: number; + name: string; +} + +interface MoveFolderModalProps { + isOpen: boolean; + onClose: () => void; + onMove: (targetFolderId: number) => void; + folders: Folder[]; + currentFolderId: number; +} + +export const MoveFolderModal: React.FC = ({ + isOpen, + onClose, + onMove, + folders, + currentFolderId, +}) => { + if (!isOpen) return null; + + return ( +
+
+

Move Folder

+

Select a destination folder:

+
+ {folders + .filter((folder) => folder.id !== currentFolderId) + .map((folder) => ( + + ))} +
+
+ +
+
+
+ ); +}; diff --git a/web/src/components/chat/TextView.tsx b/web/src/components/chat/TextView.tsx index a70da405f4e..a88e2e4f810 100644 --- a/web/src/components/chat/TextView.tsx +++ b/web/src/components/chat/TextView.tsx @@ -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 { MinimalOnyxDocument } from "@/lib/search/interfaces"; import { MinimalMarkdown } from "./MinimalMarkdown"; interface TextViewProps { - presentingDocument: OnyxDocument; + presentingDocument: MinimalOnyxDocument; onClose: () => void; } @@ -55,7 +55,9 @@ export default function TextView({ const fetchFile = useCallback(async () => { setIsLoading(true); - const fileId = presentingDocument.document_id.split("__")[1]; + const fileId = + presentingDocument.document_id.split("__")[1] || + presentingDocument.document_id; try { const response = await fetch( @@ -107,7 +109,7 @@ export default function TextView({ const handleDownload = () => { const link = document.createElement("a"); link.href = fileUrl; - link.download = fileName; + link.download = presentingDocument.document_id || fileName; document.body.appendChild(link); link.click(); document.body.removeChild(link); @@ -120,7 +122,7 @@ export default function TextView({ @@ -146,7 +148,6 @@ export default function TextView({
-
{isLoading ? ( diff --git a/web/src/components/icons/icons.tsx b/web/src/components/icons/icons.tsx index 0f375cace1a..1027bc5c6e7 100644 --- a/web/src/components/icons/icons.tsx +++ b/web/src/components/icons/icons.tsx @@ -3241,6 +3241,28 @@ export const SearchAssistantIcon = ({ ); }; +export const SortIcon = ({ + size = 24, + className = defaultTailwindCSS, +}: IconProps) => { + return ( + + + + ); +}; + export const CirclingArrowIcon = ({ size = 24, className = defaultTailwindCSS, @@ -3293,3 +3315,28 @@ export const CirclingArrowIcon = ({ // ); }; + +export const KnowledgeGroupIcon = ({ + size = 24, + className = defaultTailwindCSS, +}: IconProps) => { + return ( + + + + ); +}; diff --git a/web/src/components/modals/CreateEntityModal.tsx b/web/src/components/modals/CreateEntityModal.tsx new file mode 100644 index 00000000000..7741662ee73 --- /dev/null +++ b/web/src/components/modals/CreateEntityModal.tsx @@ -0,0 +1,80 @@ +import React, { useState } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +interface CreateEntityModalProps { + title: string; + entityName: string; + onSubmit: (name: string, description: string) => void; + trigger: React.ReactNode; + open: boolean; + setOpen: (open: boolean) => void; +} + +export default function CreateEntityModal({ + title, + entityName, + onSubmit, + trigger, + open, + setOpen, +}: CreateEntityModalProps) { + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (name.trim()) { + onSubmit(name.trim(), description.trim()); + } + }; + + return ( + + {trigger} + + + {title} + +
+
+ + setName(e.target.value)} + placeholder={`Enter ${entityName.toLowerCase()} name`} + required + className="w-full" + /> +
+
+ + setDescription(e.target.value)} + placeholder={`Enter ${entityName.toLowerCase()} description`} + className="w-full" + /> +
+ +
+
+
+ ); +} diff --git a/web/src/components/ui/button.tsx b/web/src/components/ui/button.tsx index fa8fd9d6f9a..8d13edc01c5 100644 --- a/web/src/components/ui/button.tsx +++ b/web/src/components/ui/button.tsx @@ -9,6 +9,8 @@ const buttonVariants = cva( { variants: { variant: { + menu: "w-full justify-start text-neutral-500 !gap-x-2 !py-0 hover:bg-neutral-100 hover:text-neutral-700 dark:hover:bg-neutral-800 dark:hover:text-neutral-50", + success: "bg-green-100 text-green-600 hover:bg-green-500/90 dark:bg-green-700 dark:text-green-100 dark:hover:bg-green-600/90", "success-reverse": @@ -56,8 +58,8 @@ const buttonVariants = cva( }, size: { default: "h-10 px-4 py-2", - xs: "h-8 px-3 py-1", sm: "h-9 px-3", + xs: "h-7 rounded-md px-2", lg: "h-11 px-8", icon: "h-10 w-10", }, diff --git a/web/src/components/ui/context-menu.tsx b/web/src/components/ui/context-menu.tsx new file mode 100644 index 00000000000..f1c79725d11 --- /dev/null +++ b/web/src/components/ui/context-menu.tsx @@ -0,0 +1,198 @@ +import * as React from "react"; +import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"; +import { Check, ChevronRight, Circle } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const ContextMenu = ContextMenuPrimitive.Root; + +const ContextMenuTrigger = ContextMenuPrimitive.Trigger; + +const ContextMenuGroup = ContextMenuPrimitive.Group; + +const ContextMenuPortal = ContextMenuPrimitive.Portal; + +const ContextMenuSub = ContextMenuPrimitive.Sub; + +const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup; + +const ContextMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)); +ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName; + +const ContextMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName; + +const ContextMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName; + +const ContextMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName; + +const ContextMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)); +ContextMenuCheckboxItem.displayName = + ContextMenuPrimitive.CheckboxItem.displayName; + +const ContextMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName; + +const ContextMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName; + +const ContextMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName; + +const ContextMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ); +}; +ContextMenuShortcut.displayName = "ContextMenuShortcut"; + +export { + ContextMenu, + ContextMenuTrigger, + ContextMenuContent, + ContextMenuItem, + ContextMenuCheckboxItem, + ContextMenuRadioItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuGroup, + ContextMenuPortal, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuRadioGroup, +}; diff --git a/web/src/components/ui/input.tsx b/web/src/components/ui/input.tsx index f226383ee63..b72e2a5c0cf 100644 --- a/web/src/components/ui/input.tsx +++ b/web/src/components/ui/input.tsx @@ -16,6 +16,8 @@ const Input = React.forwardRef( removeFocusRing ? "" : "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:border-neutral-800 dark:bg-neutral-950 dark:ring-offset-neutral-950 dark:file:text-neutral-50 dark:placeholder:text-neutral-400 dark:focus-visible:ring-neutral-300", + "!focus:ring-0 !focus-visible:ring-transparent !focus-visible:ring-0 !focus:outline-none", + "flex h-10 w-full rounded-md border border-border bg-background/75 focus:border-border-dark focus:ring-none focus:outline-none 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 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", className )} ref={ref} diff --git a/web/src/components/ui/progress.tsx b/web/src/components/ui/progress.tsx new file mode 100644 index 00000000000..d5bd24031ec --- /dev/null +++ b/web/src/components/ui/progress.tsx @@ -0,0 +1,28 @@ +"use client"; + +import * as React from "react"; +import * as ProgressPrimitive from "@radix-ui/react-progress"; + +import { cn } from "@/lib/utils"; + +const Progress = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, value, ...props }, ref) => ( + + + +)); +Progress.displayName = ProgressPrimitive.Root.displayName; + +export { Progress }; diff --git a/web/src/components/ui/select.tsx b/web/src/components/ui/select.tsx index f0691aa3056..935e3a6b8c5 100644 --- a/web/src/components/ui/select.tsx +++ b/web/src/components/ui/select.tsx @@ -150,6 +150,25 @@ const SelectItem = React.forwardRef< {children} + + // + // {!selected && Icon && ( + // + // + // + // )} + + // + // {children} + // + // )); SelectItem.displayName = SelectPrimitive.Item.displayName; diff --git a/web/src/lib/chat/fetchChatData.ts b/web/src/lib/chat/fetchChatData.ts index 88a646437b1..068eb41ca67 100644 --- a/web/src/lib/chat/fetchChatData.ts +++ b/web/src/lib/chat/fetchChatData.ts @@ -90,7 +90,10 @@ export async function fetchChatData(searchParams: { const chatSessionsResponse = results[4] as Response | null; const tagsResponse = results[5] as Response | null; + console.log("-----tagsResponse-----"); + console.log(results[6]); const llmProviders = (results[6] || []) as LLMProviderDescriptor[]; + console.log(llmProviders); const foldersResponse = results[7] as Response | null; let inputPrompts: InputPrompt[] = []; diff --git a/web/src/lib/dateUtils.ts b/web/src/lib/dateUtils.ts index 5cdf5749709..77c0866f3fb 100644 --- a/web/src/lib/dateUtils.ts +++ b/web/src/lib/dateUtils.ts @@ -118,12 +118,13 @@ export const getDateRangeString = (from: Date | null, to: Date | null) => { export const getTimeAgoString = (date: Date | null) => { if (!date) return null; - const diffMs = new Date().getTime() - date.getTime(); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); const diffWeeks = Math.floor(diffDays / 7); const diffMonths = Math.floor(diffDays / 30); - if (buildDateString(date).includes("Today")) return "Today"; + if (now.toDateString() === date.toDateString()) return "Today"; if (diffDays === 1) return "Yesterday"; if (diffDays < 7) return `${diffDays}d ago`; if (diffDays < 30) return `${diffWeeks}w ago`; diff --git a/web/src/lib/search/interfaces.ts b/web/src/lib/search/interfaces.ts index cad836d2d02..39413725aa0 100644 --- a/web/src/lib/search/interfaces.ts +++ b/web/src/lib/search/interfaces.ts @@ -95,13 +95,15 @@ export interface Quote { 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; @@ -188,6 +190,8 @@ export interface Filters { source_type: string[] | null; document_set: string[] | null; time_cutoff: Date | null; + user_file_ids: number[] | null; + // user_folder_ids: number[] | null; } export interface SearchRequestArgs { diff --git a/web/src/lib/search/utils.ts b/web/src/lib/search/utils.ts index 55601f9f8d1..c2052141714 100644 --- a/web/src/lib/search/utils.ts +++ b/web/src/lib/search/utils.ts @@ -6,7 +6,9 @@ export const buildFilters = ( sources: SourceMetadata[], documentSets: string[], timeRange: DateRangePickerValue | null, - tags: Tag[] + tags: Tag[], + userFileIds?: number[] | null, + userFolderIds?: number[] | null ): Filters => { const filters = { source_type: @@ -14,6 +16,8 @@ export const buildFilters = ( document_set: documentSets.length > 0 ? documentSets : null, time_cutoff: timeRange?.from ? timeRange.from : null, tags: tags, + user_file_ids: userFileIds || null, + // user_folder_ids: userFolderIds || null, }; return filters; diff --git a/web/src/services/documentsService.ts b/web/src/services/documentsService.ts new file mode 100644 index 00000000000..1f359e55c64 --- /dev/null +++ b/web/src/services/documentsService.ts @@ -0,0 +1,146 @@ +import { + FileResponse, + FolderResponse, + FileUploadResponse, +} from "@/app/chat/my-documents/DocumentsContext"; + +export async function fetchFolders(): Promise { + const response = await fetch("/api/user/folder"); + if (!response.ok) { + throw new Error("Failed to fetch folders"); + } + return response.json(); +} + +export async function createNewFolder( + name: string, + description: string +): Promise { + const response = await fetch("/api/user/folder", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, description }), + }); + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.detail || "Failed to create folder"); + } + return response.json(); +} + +export async function deleteFolder(folderId: number): Promise { + const response = await fetch(`/api/user/folder/${folderId}`, { + method: "DELETE", + }); + if (!response.ok) { + throw new Error("Failed to delete folder"); + } +} + +export async function deleteFile(fileId: number): Promise { + const response = await fetch(`/api/user/file/${fileId}`, { + method: "DELETE", + }); + if (!response.ok) { + throw new Error("Failed to delete file"); + } +} + +export async function uploadFileRequest( + formData: FormData +): Promise { + const response = await fetch("/api/user/file/upload", { + method: "POST", + body: formData, + }); + if (!response.ok) { + throw new Error("Failed to upload file"); + } + return response.json(); +} + +export async function createFileFromLinkRequest( + url: string, + folderId: number | null +): Promise { + const response = await fetch("/api/user/file/create-from-link", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url, folder_id: folderId }), + }); + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.detail || "Failed to create file from link"); + } + return response.json(); +} + +export async function getFolderDetails( + folderId: number +): Promise { + const response = await fetch(`/api/user/folder/${folderId}`); + if (!response.ok) { + throw new Error("Failed to fetch folder details"); + } + return response.json(); +} + +export async function updateFolderDetails( + folderId: number, + name: string, + description: string +): Promise { + const response = await fetch(`/api/user/folder/${folderId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, description }), + }); + if (!response.ok) { + throw new Error("Failed to update folder details"); + } +} + +export async function moveItem( + itemId: number, + newFolderId: number | null, + isFolder: boolean +): Promise { + const endpoint = isFolder + ? `/api/user/folder/${itemId}/move` + : `/api/user/file/${itemId}/move`; + const response = await fetch(endpoint, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ new_folder_id: newFolderId }), + }); + if (!response.ok) { + throw new Error("Failed to move item"); + } +} + +export async function renameItem( + itemId: number, + newName: string, + isFolder: boolean +): Promise { + const endpoint = isFolder + ? `/api/user/folder/${itemId}?name=${encodeURIComponent(newName)}` + : `/api/user/file/${itemId}/rename?name=${encodeURIComponent(newName)}`; + const response = await fetch(endpoint, { method: "PUT" }); + if (!response.ok) { + throw new Error(`Failed to rename ${isFolder ? "folder" : "file"}`); + } +} + +export async function downloadItem(documentId: string): Promise { + const response = await fetch( + `/api/chat/file/${encodeURIComponent(documentId)}`, + { + method: "GET", + } + ); + if (!response.ok) { + throw new Error("Failed to fetch file"); + } + return response.blob(); +} From 9ea35d51c347ed5fdfd3d5a2869a9045de8dcce5 Mon Sep 17 00:00:00 2001 From: pablonyx Date: Wed, 26 Feb 2025 13:55:36 -0800 Subject: [PATCH 2/2] k --- backend/alembic/versions/9aadf32dfeb4_add_user_files.py | 4 ++-- web/src/app/chat/my-documents/components/FileListItem.tsx | 8 +++----- web/src/components/ui/tooltip.tsx | 2 +- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/backend/alembic/versions/9aadf32dfeb4_add_user_files.py b/backend/alembic/versions/9aadf32dfeb4_add_user_files.py index 654366dfb8b..58164e7c101 100644 --- a/backend/alembic/versions/9aadf32dfeb4_add_user_files.py +++ b/backend/alembic/versions/9aadf32dfeb4_add_user_files.py @@ -1,7 +1,7 @@ """add user files Revision ID: 9aadf32dfeb4 -Revises: f5437cc136c5 +Revises: 8f43500ee275 Create Date: 2025-01-26 16:08:21.551022 """ @@ -12,7 +12,7 @@ # revision identifiers, used by Alembic. revision = "9aadf32dfeb4" -down_revision = "f5437cc136c5" +down_revision = "8f43500ee275" branch_labels = None depends_on = None diff --git a/web/src/app/chat/my-documents/components/FileListItem.tsx b/web/src/app/chat/my-documents/components/FileListItem.tsx index d2eb4346fc4..5705e04f5e0 100644 --- a/web/src/app/chat/my-documents/components/FileListItem.tsx +++ b/web/src/app/chat/my-documents/components/FileListItem.tsx @@ -110,11 +110,9 @@ export const FileListItem: React.FC = ({ /> - {!isIndexed ? ( -

Not yet indexed. This will be completed momentarily.

- ) : ( -

Indexed

- )} + {!isIndexed + ? "Not yet indexed. This will be completed momentarily." + : "Indexed"}
diff --git a/web/src/components/ui/tooltip.tsx b/web/src/components/ui/tooltip.tsx index 0c04f5f0dd8..b79dd36eb74 100644 --- a/web/src/components/ui/tooltip.tsx +++ b/web/src/components/ui/tooltip.tsx @@ -47,7 +47,7 @@ const TooltipContent = React.forwardRef< "bg-neutral-900 dark:bg-neutral-200 dark:text-neutral-900" } ${width || "max-w-40"} - + text-wrap px-2 py-1.5 text-xs shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2`, className )}