mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-02-16 23:35:46 +00:00
Compare commits
2 Commits
v2.9.6
...
my_docs_fe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ea35d51c3 | ||
|
|
c744faf25e |
107
backend/alembic/versions/9aadf32dfeb4_add_user_files.py
Normal file
107
backend/alembic/versions/9aadf32dfeb4_add_user_files.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""add user files
|
||||
|
||||
Revision ID: 9aadf32dfeb4
|
||||
Revises: 8f43500ee275
|
||||
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 = "8f43500ee275"
|
||||
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")
|
||||
@@ -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)
|
||||
|
||||
@@ -86,6 +86,7 @@ from onyx.document_index.factory import get_default_document_index
|
||||
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,
|
||||
|
||||
@@ -3,7 +3,7 @@ import os
|
||||
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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
"""
|
||||
|
||||
@@ -33,6 +33,8 @@ from onyx.db.models import StarterMessage
|
||||
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
|
||||
|
||||
175
backend/onyx/db/user_documents.py
Normal file
175
backend/onyx/db/user_documents.py
Normal file
@@ -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()
|
||||
@@ -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<int> {
|
||||
indexing: summary | attribute
|
||||
rank: filter
|
||||
attribute: fast-search
|
||||
}
|
||||
}
|
||||
|
||||
# If using different tokenization settings, the fieldset has to be removed, and the field must
|
||||
|
||||
@@ -645,6 +645,8 @@ class VespaIndex(DocumentIndex):
|
||||
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 @@ class VespaIndex(DocumentIndex):
|
||||
tenant_id=tenant_id,
|
||||
large_chunks_enabled=large_chunks_enabled,
|
||||
)
|
||||
|
||||
for doc_chunk_ids_batch in batch_generator(
|
||||
chunks_to_delete, BATCH_SIZE
|
||||
):
|
||||
|
||||
@@ -47,6 +47,7 @@ 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 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,
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ from datetime import timezone
|
||||
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 HIDDEN
|
||||
from onyx.document_index.vespa_constants import METADATA_LIST
|
||||
from onyx.document_index.vespa_constants import SOURCE_TYPE
|
||||
from onyx.document_index.vespa_constants import TENANT_ID
|
||||
from onyx.document_index.vespa_constants import USER_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<string> fields."""
|
||||
if not key or not vals:
|
||||
return ""
|
||||
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 ""
|
||||
|
||||
valid_vals = [val for val in vals if val]
|
||||
if not key or not valid_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_vespa_filters(
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -66,6 +66,8 @@ EMBEDDINGS = "embeddings"
|
||||
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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -10,7 +10,10 @@ from sqlalchemy.orm import Session
|
||||
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
|
||||
|
||||
@@ -31,6 +31,7 @@ from onyx.db.models import Document as DBDocument
|
||||
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
|
||||
|
||||
@@ -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 @@ class DocMetadataAwareIndexChunk(IndexChunk):
|
||||
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 @@ class DocMetadataAwareIndexChunk(IndexChunk):
|
||||
**index_chunk_data,
|
||||
access=access,
|
||||
document_sets=document_sets,
|
||||
user_file=user_file,
|
||||
# user_folders=user_folders,
|
||||
boost=boost,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
|
||||
@@ -97,6 +97,7 @@ from onyx.server.settings.api import basic_router as settings_router
|
||||
from onyx.server.token_rate_limits.api import (
|
||||
router as token_rate_limit_settings_router,
|
||||
)
|
||||
from onyx.server.user_documents.api import router as user_documents_router
|
||||
from onyx.server.utils import BasicAuthenticationError
|
||||
from onyx.setup import setup_multitenant_onyx
|
||||
from onyx.setup import setup_onyx
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ from onyx.configs.chat_configs import INPUT_PROMPT_YAML
|
||||
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.models import Tool as ToolDBModel
|
||||
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)
|
||||
|
||||
6
backend/onyx/seeding/user_folders.yaml
Normal file
6
backend/onyx/seeding/user_folders.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
user_folders:
|
||||
- id: -1
|
||||
name: "Recent Documents"
|
||||
description: "Documents uploaded by the user"
|
||||
files: []
|
||||
assistants: []
|
||||
@@ -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 upload_files(
|
||||
return FileUploadResponse(file_paths=deduped_file_paths)
|
||||
|
||||
|
||||
@router.post("/admin/connector/file/upload")
|
||||
def upload_files_api(
|
||||
files: list[UploadFile],
|
||||
_: User = Depends(current_curator_or_admin_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> FileUploadResponse:
|
||||
return upload_files(files, db_session)
|
||||
|
||||
|
||||
@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,
|
||||
try:
|
||||
num_triggers = trigger_indexing_for_cc_pair(
|
||||
credential_ids,
|
||||
connector_id,
|
||||
run_info.from_beginning,
|
||||
tenant_id,
|
||||
db_session,
|
||||
)
|
||||
]
|
||||
|
||||
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 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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -18,9 +18,9 @@ from onyx.db.models import User
|
||||
from onyx.server.features.folder.models import DeleteFolderOptions
|
||||
from onyx.server.features.folder.models import FolderChatSessionRequest
|
||||
from onyx.server.features.folder.models import FolderCreationRequest
|
||||
from onyx.server.features.folder.models import FolderResponse
|
||||
from onyx.server.features.folder.models import FolderUpdateRequest
|
||||
from onyx.server.features.folder.models import GetUserFoldersResponse
|
||||
from onyx.server.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,
|
||||
|
||||
@@ -5,7 +5,7 @@ from pydantic import BaseModel
|
||||
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):
|
||||
|
||||
@@ -26,6 +26,7 @@ from onyx.db.persona import create_assistant_label
|
||||
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.server.models import DisplayPriorityRequest
|
||||
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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -4,6 +4,7 @@ from pydantic import BaseModel
|
||||
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 @@ class FullLLMProvider(LLMProvider):
|
||||
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,
|
||||
|
||||
@@ -3,6 +3,7 @@ import datetime
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Generator
|
||||
@@ -29,10 +30,12 @@ from onyx.chat.prompt_builder.citations_prompt import (
|
||||
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 get_or_create_root_message
|
||||
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.natural_language_processing.utils import get_tokenizer
|
||||
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.logger import setup_logger
|
||||
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}
|
||||
|
||||
@@ -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
|
||||
|
||||
443
backend/onyx/server/user_documents/api.py
Normal file
443
backend/onyx/server/user_documents/api.py
Normal file
@@ -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)}")
|
||||
70
backend/onyx/server/user_documents/models.py
Normal file
70
backend/onyx/server/user_documents/models.py
Normal file
@@ -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]
|
||||
@@ -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 = (
|
||||
|
||||
@@ -64,7 +64,7 @@ logger = setup_logger()
|
||||
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 @@ class CustomTool(BaseTool):
|
||||
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 @@ class CustomTool(BaseTool):
|
||||
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 @@ class CustomTool(BaseTool):
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
20
tsconfig.json
Normal file
20
tsconfig.json
Normal file
@@ -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"]
|
||||
}
|
||||
1154
web/package-lock.json
generated
1154
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
<BackButton />
|
||||
</div>
|
||||
)}
|
||||
{filePickerModalOpen && (
|
||||
<FilePickerModal
|
||||
selectedFiles={selectedFiles}
|
||||
selectedFolders={selectedFolders}
|
||||
addSelectedFile={addSelectedFile}
|
||||
removeSelectedFile={removeSelectedFile}
|
||||
addSelectedFolder={addSelectedFolder}
|
||||
isOpen={filePickerModalOpen}
|
||||
onClose={() => {
|
||||
setFilePickerModalOpen(false);
|
||||
}}
|
||||
onSave={() => {
|
||||
setFilePickerModalOpen(false);
|
||||
}}
|
||||
title="Add Documents to your Assistant"
|
||||
buttonContent="Add to Assistant"
|
||||
/>
|
||||
)}
|
||||
|
||||
{labelToDelete && (
|
||||
<ConfirmEntityModal
|
||||
@@ -434,6 +480,7 @@ export function AssistantEditor({
|
||||
label_ids: Yup.array().of(Yup.number()),
|
||||
selectedUsers: Yup.array().of(Yup.object()),
|
||||
selectedGroups: Yup.array().of(Yup.number()),
|
||||
knowledge_source: Yup.string().required(),
|
||||
is_default_persona: Yup.boolean().required(),
|
||||
})
|
||||
.test(
|
||||
@@ -522,9 +569,12 @@ export function AssistantEditor({
|
||||
? new Date(values.search_start_date)
|
||||
: null,
|
||||
num_chunks: numChunks,
|
||||
user_file_ids: selectedFiles.map((file) => 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) && (
|
||||
<CollapsibleSection>
|
||||
<div className="mt-2">
|
||||
{ccPairs.length > 0 && (
|
||||
<>
|
||||
<Label small>Document Sets</Label>
|
||||
<div>
|
||||
<SubLabel>
|
||||
<>
|
||||
Select which{" "}
|
||||
{!user || user.role === "admin" ? (
|
||||
<Link
|
||||
href="/admin/documents/sets"
|
||||
className="font-semibold underline hover:underline text-text"
|
||||
target="_blank"
|
||||
>
|
||||
Document Sets
|
||||
</Link>
|
||||
) : (
|
||||
"Document Sets"
|
||||
)}{" "}
|
||||
this Assistant should use to inform its
|
||||
responses. If none are specified, the
|
||||
Assistant will reference all available
|
||||
documents.
|
||||
</>
|
||||
</SubLabel>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Knowledge Source</Label>
|
||||
<RadioGroup
|
||||
className="flex flex-col gap-y-4 mt-2"
|
||||
value={values.knowledge_source}
|
||||
onValueChange={(value: string) => {
|
||||
setFieldValue("knowledge_source", value);
|
||||
}}
|
||||
>
|
||||
<RadioGroupItemField
|
||||
value="user_files"
|
||||
id="user_files"
|
||||
label="User Files"
|
||||
sublabel="Select specific user files and folders for this Assistant to use"
|
||||
/>
|
||||
<RadioGroupItemField
|
||||
value="team_knowledge"
|
||||
id="team_knowledge"
|
||||
label="Team Knowledge"
|
||||
sublabel="Use team-wide document sets for this Assistant"
|
||||
/>
|
||||
</RadioGroup>
|
||||
|
||||
{documentSets.length > 0 ? (
|
||||
<FieldArray
|
||||
name="document_set_ids"
|
||||
render={(arrayHelpers: ArrayHelpers) => (
|
||||
<div>
|
||||
<div className="mb-3 mt-2 flex gap-2 flex-wrap text-sm">
|
||||
{documentSets.map((documentSet) => (
|
||||
<DocumentSetSelectable
|
||||
key={documentSet.id}
|
||||
documentSet={documentSet}
|
||||
isSelected={values.document_set_ids.includes(
|
||||
documentSet.id
|
||||
)}
|
||||
onSelect={() => {
|
||||
const index =
|
||||
values.document_set_ids.indexOf(
|
||||
documentSet.id
|
||||
);
|
||||
if (index !== -1) {
|
||||
arrayHelpers.remove(index);
|
||||
} else {
|
||||
arrayHelpers.push(
|
||||
documentSet.id
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm">
|
||||
<Link
|
||||
href="/admin/documents/sets/new"
|
||||
className="text-primary hover:underline"
|
||||
{values.knowledge_source === "user_files" &&
|
||||
!existingPersona?.is_default_persona &&
|
||||
!admin && (
|
||||
<div className="mt-4">
|
||||
<div className="flex justify-start gap-x-2 items-center">
|
||||
<Label>User Files</Label>
|
||||
<span
|
||||
className="cursor-pointer text-xs text-primary hover:underline"
|
||||
onClick={() => setFilePickerModalOpen(true)}
|
||||
>
|
||||
+ Create Document Set
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
Attach Files and Folders
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<SubLabel>
|
||||
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.
|
||||
</SubLabel>
|
||||
|
||||
<div className="mt-2 mb-4">
|
||||
<h4 className="text-xs font-normal mb-2">
|
||||
Selected Files and Folders
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedFiles.map((file: FileResponse) => (
|
||||
<SourceChip
|
||||
key={file.id}
|
||||
onRemove={() => {
|
||||
removeSelectedFile(file);
|
||||
setFieldValue(
|
||||
"selectedFiles",
|
||||
values.selectedFiles.filter(
|
||||
(f: FileResponse) =>
|
||||
f.id !== file.id
|
||||
)
|
||||
);
|
||||
}}
|
||||
title={file.name}
|
||||
icon={<FileIcon size={12} />}
|
||||
/>
|
||||
))}
|
||||
{selectedFolders.map(
|
||||
(folder: FolderResponse) => (
|
||||
<SourceChip
|
||||
key={folder.id}
|
||||
onRemove={() => {
|
||||
removeSelectedFolder(folder);
|
||||
setFieldValue(
|
||||
"selectedFolders",
|
||||
values.selectedFolders.filter(
|
||||
(f: FolderResponse) =>
|
||||
f.id !== folder.id
|
||||
)
|
||||
);
|
||||
}}
|
||||
title={folder.name}
|
||||
icon={<FolderIcon size={12} />}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{values.knowledge_source === "team_knowledge" &&
|
||||
ccPairs.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<Label>Team Knowledge</Label>
|
||||
<div>
|
||||
<SubLabel>
|
||||
<>
|
||||
Select which{" "}
|
||||
{!user || user.role === "admin" ? (
|
||||
<Link
|
||||
href="/admin/documents/sets"
|
||||
className="font-semibold underline hover:underline text-text"
|
||||
target="_blank"
|
||||
>
|
||||
Team Document Sets
|
||||
</Link>
|
||||
) : (
|
||||
"Team Document Sets"
|
||||
)}{" "}
|
||||
this Assistant should use to inform its
|
||||
responses. If none are specified, the
|
||||
Assistant will reference all available
|
||||
documents.
|
||||
</>
|
||||
</SubLabel>
|
||||
</div>
|
||||
|
||||
{documentSets.length > 0 ? (
|
||||
<FieldArray
|
||||
name="document_set_ids"
|
||||
render={(arrayHelpers: ArrayHelpers) => (
|
||||
<div>
|
||||
<div className="mb-3 mt-2 flex gap-2 flex-wrap text-sm">
|
||||
{documentSets.map((documentSet) => (
|
||||
<DocumentSetSelectable
|
||||
key={documentSet.id}
|
||||
documentSet={documentSet}
|
||||
isSelected={values.document_set_ids.includes(
|
||||
documentSet.id
|
||||
)}
|
||||
onSelect={() => {
|
||||
const index =
|
||||
values.document_set_ids.indexOf(
|
||||
documentSet.id
|
||||
);
|
||||
if (index !== -1) {
|
||||
arrayHelpers.remove(index);
|
||||
} else {
|
||||
arrayHelpers.push(
|
||||
documentSet.id
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm">
|
||||
<Link
|
||||
href="/admin/documents/sets/new"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
+ Create Document Set
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
106
web/src/app/admin/assistants/assistantFileUtils.ts
Normal file
106
web/src/app/admin/assistants/assistantFileUtils.ts
Normal file
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await Promise.all([
|
||||
shareFiles(assistantId, changes.filesToShare),
|
||||
unshareFiles(assistantId, changes.filesToUnshare),
|
||||
shareFolders(assistantId, changes.foldersToShare),
|
||||
unshareFolders(assistantId, changes.foldersToUnshare),
|
||||
]);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -23,16 +23,15 @@ import AssistantModal from "./mine/AssistantModal";
|
||||
import { useSidebarShortcut } from "@/lib/browserUtilities";
|
||||
|
||||
interface SidebarWrapperProps<T extends object> {
|
||||
initiallyToggled: boolean;
|
||||
size?: "sm" | "lg";
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function SidebarWrapper<T extends object>({
|
||||
initiallyToggled,
|
||||
size = "sm",
|
||||
children,
|
||||
}: SidebarWrapperProps<T>) {
|
||||
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<T extends object>({
|
||||
${sidebarVisible ? "w-[250px]" : "w-[0px]"}`}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`mt-4 w-full ${
|
||||
size == "lg" ? "max-w-4xl" : "max-w-3xl"
|
||||
} mx-auto`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<div className={`mt-4 w-full mx-auto`}>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
<FixedLogo backgroundToggled={sidebarVisible || showDocSidebar} />
|
||||
|
||||
@@ -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<number>(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<OnyxDocument[]>(
|
||||
[]
|
||||
);
|
||||
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 (
|
||||
<>
|
||||
<HealthCheckBanner />
|
||||
@@ -2176,6 +2278,40 @@ export function ChatPage({
|
||||
/>
|
||||
)}
|
||||
|
||||
{toggleDocSelection && (
|
||||
<FilePickerModal
|
||||
buttonContent="Set as Context"
|
||||
title="User Documents"
|
||||
isOpen={true}
|
||||
onClose={() => setToggleDocSelection(false)}
|
||||
onSave={() => {
|
||||
setToggleDocSelection(false);
|
||||
}}
|
||||
selectedFiles={selectedFiles}
|
||||
selectedFolders={selectedFolders}
|
||||
addSelectedFile={addSelectedFile}
|
||||
addSelectedFolder={addSelectedFolder}
|
||||
removeSelectedFile={() => {}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{toggleDocSelection && (
|
||||
<FilePickerModal
|
||||
buttonContent="Set as Context"
|
||||
title="User Documents"
|
||||
isOpen={true}
|
||||
onClose={() => setToggleDocSelection(false)}
|
||||
onSave={() => {
|
||||
setToggleDocSelection(false);
|
||||
}}
|
||||
selectedFiles={selectedFiles}
|
||||
selectedFolders={selectedFolders}
|
||||
addSelectedFile={addSelectedFile}
|
||||
addSelectedFolder={addSelectedFolder}
|
||||
removeSelectedFile={() => {}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ChatSearchModal
|
||||
open={isChatSearchModalOpen}
|
||||
onCloseModal={() => 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({
|
||||
</div>
|
||||
{/* Right Sidebar - DocumentSidebar */}
|
||||
</div>
|
||||
|
||||
{/* Add the fixed toggle button */}
|
||||
<div className="fixed right-4 top-1/2 transform -translate-y-1/2 z-50">
|
||||
<button
|
||||
onClick={() => {
|
||||
setPopup({
|
||||
message: "This feature is not available yet.",
|
||||
type: "error",
|
||||
});
|
||||
setForceUserFileSearch(!forceUserFileSearch);
|
||||
}}
|
||||
className={`p-2 rounded-full ${
|
||||
forceUserFileSearch ? "bg-blue-500" : "bg-gray-300"
|
||||
} transition-colors duration-200`}
|
||||
>
|
||||
{forceUserFileSearch ? "On" : "Off"}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ export async function createFolder(folderName: string): Promise<number> {
|
||||
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;
|
||||
|
||||
@@ -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) => (
|
||||
<SourceChip
|
||||
key={file.id}
|
||||
icon={<FileIcon size={16} />}
|
||||
title={file.name}
|
||||
onRemove={() => removeSelectedFile(file)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{selectedFolders.map((folder) => (
|
||||
<SourceChip
|
||||
key={folder.id}
|
||||
icon={<FolderIcon size={16} />}
|
||||
title={folder.name}
|
||||
onRemove={() => removeSelectedFolder(folder)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{filterManager.timeRange && (
|
||||
<SourceChip
|
||||
truncateTitle={false}
|
||||
@@ -758,26 +788,38 @@ export function ChatInputBar({
|
||||
|
||||
<div className="flex pr-4 pb-2 justify-between bg-input-background items-center w-full ">
|
||||
<div className="space-x-1 flex px-4 ">
|
||||
<ChatInputOption
|
||||
flexPriority="stiff"
|
||||
name="File"
|
||||
Icon={FiPlusCircle}
|
||||
onClick={() => {
|
||||
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 ? (
|
||||
<ChatInputOption
|
||||
flexPriority="stiff"
|
||||
name="File"
|
||||
Icon={FiPlusCircle}
|
||||
onClick={() => {
|
||||
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"}
|
||||
/>
|
||||
) : (
|
||||
<ChatInputOption
|
||||
flexPriority="stiff"
|
||||
name="File"
|
||||
Icon={FiPlusCircle}
|
||||
onClick={() => {
|
||||
toggleDocSelection();
|
||||
}}
|
||||
tooltipContent={"Upload files and attach user files"}
|
||||
/>
|
||||
)}
|
||||
|
||||
<LLMPopover
|
||||
llmProviders={llmProviders}
|
||||
|
||||
@@ -58,6 +58,7 @@ export default function LLMPopover({
|
||||
icon: React.FC<{ size?: number; className?: string }>;
|
||||
}[];
|
||||
} = {};
|
||||
|
||||
const uniqueModelNames = new Set<string>();
|
||||
|
||||
llmProviders.forEach((llmProvider) => {
|
||||
|
||||
@@ -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<PacketType, void, unknown> {
|
||||
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
|
||||
? {
|
||||
|
||||
454
web/src/app/chat/my-documents/DocumentsContext.tsx
Normal file
454
web/src/app/chat/my-documents/DocumentsContext.tsx
Normal file
@@ -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<void>;
|
||||
createFolder: (name: string, description: string) => Promise<FolderResponse>;
|
||||
deleteItem: (itemId: number, isFolder: boolean) => Promise<void>;
|
||||
moveItem: (
|
||||
itemId: number,
|
||||
currentFolderId: number | null,
|
||||
isFolder: boolean
|
||||
) => Promise<void>;
|
||||
downloadItem: (documentId: string) => Promise<void>;
|
||||
renameItem: (
|
||||
itemId: number,
|
||||
currentName: string,
|
||||
isFolder: boolean
|
||||
) => Promise<void>;
|
||||
setCurrentFolder: (folderId: number | null) => void;
|
||||
setPresentingDocument: (document: MinimalOnyxDocument | null) => void;
|
||||
setSearchQuery: (query: string) => void;
|
||||
setPage: (page: number) => void;
|
||||
getFolderDetails: (folderId: number) => Promise<FolderResponse>;
|
||||
updateFolderDetails: (
|
||||
folderId: number,
|
||||
name: string,
|
||||
description: string
|
||||
) => Promise<void>;
|
||||
isLoading: boolean;
|
||||
uploadFile: (
|
||||
formData: FormData,
|
||||
folderId: number | null
|
||||
) => Promise<FileUploadResponse>;
|
||||
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<FileUploadResponse>;
|
||||
setSelectedFiles: Dispatch<SetStateAction<FileResponse[]>>;
|
||||
setSelectedFolders: Dispatch<SetStateAction<FolderResponse[]>>;
|
||||
handleUpload: (files: File[]) => Promise<void>;
|
||||
handleCreateFileFromLink: () => Promise<void>;
|
||||
refreshFolderDetails: () => Promise<void>;
|
||||
folderDetails: FolderResponse | undefined | null;
|
||||
setFolderDetails: Dispatch<SetStateAction<FolderResponse | undefined | null>>;
|
||||
showUploadWarning: boolean;
|
||||
setShowUploadWarning: Dispatch<SetStateAction<boolean>>;
|
||||
linkUrl: string;
|
||||
setLinkUrl: Dispatch<SetStateAction<string>>;
|
||||
isCreatingFileFromLink: boolean;
|
||||
setIsCreatingFileFromLink: Dispatch<SetStateAction<boolean>>;
|
||||
error: string | null;
|
||||
setError: Dispatch<SetStateAction<string | null>>;
|
||||
getFolders: () => Promise<FolderResponse[]>;
|
||||
}
|
||||
|
||||
const DocumentsContext = createContext<DocumentsContextType | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
interface DocumentsProviderProps {
|
||||
children: ReactNode;
|
||||
initialFolderDetails?: FolderResponse | null;
|
||||
}
|
||||
|
||||
export const DocumentsProvider: React.FC<DocumentsProviderProps> = ({
|
||||
children,
|
||||
initialFolderDetails,
|
||||
}) => {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [folders, setFolders] = useState<FolderResponse[]>([]);
|
||||
const [currentFolder, setCurrentFolder] = useState<number | null>(null);
|
||||
const [presentingDocument, setPresentingDocument] =
|
||||
useState<MinimalOnyxDocument | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [selectedFiles, setSelectedFiles] = useState<FileResponse[]>([]);
|
||||
const [selectedFolders, setSelectedFolders] = useState<FolderResponse[]>([]);
|
||||
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<string | null>(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<FileUploadResponse> => {
|
||||
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<FileUploadResponse> => {
|
||||
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<FolderResponse[]> => {
|
||||
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 (
|
||||
<DocumentsContext.Provider value={value}>
|
||||
{children}
|
||||
</DocumentsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useDocumentsContext = () => {
|
||||
const context = useContext(DocumentsContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useDocuments must be used within a DocumentsProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
342
web/src/app/chat/my-documents/MyDocumenItem.tsx
Normal file
342
web/src/app/chat/my-documents/MyDocumenItem.tsx
Normal file
@@ -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<HTMLDivElement>,
|
||||
item: { id: number; isFolder: boolean; name: string }
|
||||
) => void;
|
||||
onDrop: (e: React.DragEvent<HTMLDivElement>, targetFolderId: number) => void;
|
||||
}
|
||||
|
||||
export function FolderItem({
|
||||
folder,
|
||||
onFolderClick,
|
||||
onDeleteItem,
|
||||
onMoveItem,
|
||||
editingItem,
|
||||
setEditingItem,
|
||||
handleRename,
|
||||
onDragStart,
|
||||
onDrop,
|
||||
}: FolderItemProps) {
|
||||
const [showMenu, setShowMenu] = useState<undefined | number>(undefined);
|
||||
const [newName, setNewName] = useState(folder.name);
|
||||
|
||||
const isEditing =
|
||||
editingItem && editingItem.id === folder.id && editingItem.isFolder;
|
||||
|
||||
const folderItemRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const xPos =
|
||||
e.clientX - folderItemRef.current?.getBoundingClientRect().left! - 40;
|
||||
setShowMenu(xPos);
|
||||
};
|
||||
|
||||
const startEditing = () => {
|
||||
setEditingItem({ id: folder.id, name: folder.name, isFolder: true });
|
||||
setNewName(folder.name);
|
||||
setShowMenu(undefined);
|
||||
};
|
||||
|
||||
const submitRename = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
handleRename(folder.id, newName, true);
|
||||
};
|
||||
|
||||
const cancelEditing = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setEditingItem(null);
|
||||
setNewName(folder.name);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener("click", (e) => {
|
||||
setShowMenu(undefined);
|
||||
});
|
||||
return () => {
|
||||
document.removeEventListener("click", () => {});
|
||||
};
|
||||
}, [showMenu]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={folderItemRef}
|
||||
className="flex items-center justify-between p-2 hover:bg-background-100 cursor-pointer relative"
|
||||
onClick={() => !isEditing && onFolderClick(folder.id)}
|
||||
onContextMenu={handleContextMenu}
|
||||
draggable={!isEditing}
|
||||
onDragStart={(e) =>
|
||||
onDragStart(e, { id: folder.id, isFolder: true, name: folder.name })
|
||||
}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => onDrop(e, folder.id)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<FolderIcon className="mr-2" />
|
||||
{isEditing ? (
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
setNewName(e.target.value);
|
||||
}}
|
||||
className="border rounded px-2 py-1 mr-2"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={submitRename}
|
||||
className="text-green-500 hover:text-green-700 mr-2"
|
||||
>
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelEditing}
|
||||
className="text-red-500 hover:text-red-700"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<span>{folder.name}</span>
|
||||
)}
|
||||
</div>
|
||||
{showMenu && !isEditing && (
|
||||
<div
|
||||
className="absolute bg-white border rounded shadow py-1 right-0 top-full mt-1 z-50"
|
||||
style={{ left: showMenu }}
|
||||
>
|
||||
<button
|
||||
className="block w-full text-left px-4 py-2 hover:bg-background-100 text-sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
startEditing();
|
||||
}}
|
||||
>
|
||||
Rename
|
||||
</button>
|
||||
<button
|
||||
className="block w-full text-left px-4 py-2 hover:bg-background-100 text-sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMoveItem(folder.id);
|
||||
setShowMenu(undefined);
|
||||
}}
|
||||
>
|
||||
Move
|
||||
</button>
|
||||
<button
|
||||
className="block w-full text-left px-4 py-2 hover:bg-background-100 text-sm text-red-600"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteItem(folder.id, true);
|
||||
setShowMenu(undefined);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface FileItemProps {
|
||||
file: { name: string; id: number; document_id: string };
|
||||
onDeleteItem: (itemId: number, isFolder: boolean) => void;
|
||||
onDownloadItem: (documentId: string) => void;
|
||||
onMoveItem: (fileId: number) => void;
|
||||
editingItem: { id: number; name: string; isFolder: boolean } | null;
|
||||
setEditingItem: React.Dispatch<
|
||||
React.SetStateAction<{ id: number; name: string; isFolder: boolean } | null>
|
||||
>;
|
||||
setPresentingDocument: (
|
||||
document_id: string,
|
||||
semantic_identifier: string
|
||||
) => void;
|
||||
handleRename: (fileId: number, newName: string, isFolder: boolean) => void;
|
||||
onDragStart: (
|
||||
e: React.DragEvent<HTMLDivElement>,
|
||||
item: { id: number; isFolder: boolean; name: string }
|
||||
) => void;
|
||||
}
|
||||
|
||||
export function FileItem({
|
||||
setPresentingDocument,
|
||||
file,
|
||||
onDeleteItem,
|
||||
onDownloadItem,
|
||||
onMoveItem,
|
||||
editingItem,
|
||||
setEditingItem,
|
||||
handleRename,
|
||||
onDragStart,
|
||||
}: FileItemProps) {
|
||||
const [showMenu, setShowMenu] = useState<undefined | number>();
|
||||
const [newFileName, setNewFileName] = useState(file.name);
|
||||
|
||||
const isEditing =
|
||||
editingItem && editingItem.id === file.id && !editingItem.isFolder;
|
||||
|
||||
const fileItemRef = useRef<HTMLDivElement>(null);
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const xPos =
|
||||
e.clientX - fileItemRef.current?.getBoundingClientRect().left! - 40;
|
||||
setShowMenu(xPos);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener("click", (e) => {
|
||||
if (fileItemRef.current?.contains(e.target as Node)) {
|
||||
return;
|
||||
}
|
||||
setShowMenu(undefined);
|
||||
});
|
||||
document.addEventListener("contextmenu", (e) => {
|
||||
if (fileItemRef.current?.contains(e.target as Node)) {
|
||||
return;
|
||||
}
|
||||
setShowMenu(undefined);
|
||||
});
|
||||
return () => {
|
||||
document.removeEventListener("click", () => {});
|
||||
document.removeEventListener("contextmenu", () => {});
|
||||
};
|
||||
}, [showMenu]);
|
||||
|
||||
const startEditing = () => {
|
||||
setEditingItem({ id: file.id, name: file.name, isFolder: false });
|
||||
setNewFileName(file.name);
|
||||
setShowMenu(undefined);
|
||||
};
|
||||
|
||||
const submitRename = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
handleRename(file.id, newFileName, false);
|
||||
};
|
||||
|
||||
const cancelEditing = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setEditingItem(null);
|
||||
setNewFileName(file.name);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={fileItemRef}
|
||||
key={file.id}
|
||||
className="flex items-center w-full justify-between p-2 hover:bg-background-100 cursor-pointer relative"
|
||||
onContextMenu={handleContextMenu}
|
||||
draggable={!isEditing}
|
||||
onDragStart={(e) =>
|
||||
onDragStart(e, { id: file.id, isFolder: false, name: file.name })
|
||||
}
|
||||
>
|
||||
<button
|
||||
onClick={() => setPresentingDocument(file.document_id, file.name)}
|
||||
className="flex items-center flex-grow"
|
||||
>
|
||||
<FileIcon className="mr-2" />
|
||||
{isEditing ? (
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
type="text"
|
||||
value={newFileName}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
setNewFileName(e.target.value);
|
||||
}}
|
||||
className="border rounded px-2 py-1 mr-2"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={submitRename}
|
||||
className="text-green-500 hover:text-green-700 mr-2"
|
||||
>
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelEditing}
|
||||
className="text-red-500 hover:text-red-700"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="flex text-wrap text-left line-clamp-2">{file.name}</p>
|
||||
)}
|
||||
</button>
|
||||
{showMenu && !isEditing && (
|
||||
<div
|
||||
className="absolute bg-white max-w-40 border rounded shadow py-1 right-0 top-full mt-1 z-50"
|
||||
style={{ left: showMenu }}
|
||||
>
|
||||
<button
|
||||
className="block w-full text-left px-4 py-2 hover:bg-background-100 text-sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDownloadItem(file.document_id);
|
||||
setShowMenu(undefined);
|
||||
}}
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
<button
|
||||
className="block w-full text-left px-4 py-2 hover:bg-background-100 text-sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
startEditing();
|
||||
}}
|
||||
>
|
||||
Rename
|
||||
</button>
|
||||
<button
|
||||
className="block w-full text-left px-4 py-2 hover:bg-background-100 text-sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMoveItem(file.id);
|
||||
setShowMenu(undefined);
|
||||
}}
|
||||
>
|
||||
Moveewsd
|
||||
</button>
|
||||
<button
|
||||
className="block w-full text-left px-4 py-2 hover:bg-background-100 text-sm text-red-600"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteItem(file.id, false);
|
||||
setShowMenu(undefined);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
380
web/src/app/chat/my-documents/MyDocuments.tsx
Normal file
380
web/src/app/chat/my-documents/MyDocuments.tsx
Normal file
@@ -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<SortSelectorProps> = ({ onSortChange }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [currentSort, setCurrentSort] = useState<SortType>(
|
||||
SortType.TimeCreated
|
||||
);
|
||||
|
||||
const handleSortChange = (sortType: SortType) => {
|
||||
setCurrentSort(sortType);
|
||||
onSortChange(sortType);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative h-fit">
|
||||
{isOpen && (
|
||||
<div className="absolute right-0 top-full w-48 bg-white rounded-md shadow-lg z-10">
|
||||
<div className="py-1">
|
||||
{Object.values(SortType).map((sortType) => (
|
||||
<button
|
||||
key={sortType}
|
||||
onClick={() => handleSortChange(sortType)}
|
||||
className="block w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 focus:outline-none"
|
||||
>
|
||||
{sortType}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex items-center space-x-2 text-sm text-neutral-600 hover:text-neutral-800 focus:outline-none"
|
||||
>
|
||||
<span>{currentSort}</span>
|
||||
<SortIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SkeletonLoader = ({ count = 5 }) => (
|
||||
<div className={`mt-4 grid gap-3 md:mt-8 md:grid-cols-2 md:gap-6`}>
|
||||
{[...Array(count)].map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="animate-pulse bg-background-200 rounded-xl h-24"
|
||||
></div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
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>(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 (
|
||||
<div className="min-h-full w-full min-w-0 flex-1 mx-auto mt-4 w-full max-w-5xl flex-1 px-4 pb-20 md:pl-8 lg:mt-6 md:pr-8 2xl:pr-14">
|
||||
<header className="flex w-full items-center justify-between gap-4 pt-2 -translate-y-px">
|
||||
<h1 className=" flex items-center gap-1.5 text-lg font-medium leading-tight tracking-tight max-md:hidden">
|
||||
Knowledge Groups
|
||||
</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<CreateEntityModal
|
||||
title="Create New Knowledge Group"
|
||||
entityName="Knowledge Group"
|
||||
open={isCreateFolderOpen}
|
||||
setOpen={setIsCreateFolderOpen}
|
||||
onSubmit={handleCreateFolder}
|
||||
trigger={
|
||||
<Button className="inline-flex items-center justify-center relative shrink-0 h-9 px-4 py-2 rounded-lg min-w-[5rem] active:scale-[0.985] whitespace-nowrap pl-2 pr-3 gap-1">
|
||||
<Plus className="h-5 w-5" />
|
||||
New Group
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
<main className="w-full mt-4">
|
||||
<div className=" top-3 w-full z-[5] flex gap-4 bg-gradient-to-b via-50% max-lg:flex-col lg:sticky lg:items-center">
|
||||
<div className="flex justify-between w-full ">
|
||||
<div className="bg-background-000 dark:bg-neutral-800 border md:max-w-96 border-border-200 dark:border-neutral-700 hover:border-border-100 dark:hover:border-neutral-600 transition-colors placeholder:text-text-500 dark:placeholder:text-neutral-400 focus:border-accent-secondary-100 focus-within:!border-accent-secondary-100 focus:ring-0 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 h-11 px-3 rounded-[0.6rem] w-full inline-flex cursor-text items-stretch gap-2">
|
||||
<div className="flex items-center">
|
||||
<Search className="h-4 w-4 text-text-400 dark:text-neutral-400" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search groups..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<SortSelector onSortChange={handleSortChange} />
|
||||
</div>
|
||||
</div>
|
||||
{isPending && (
|
||||
<div className="flex fixed left-20 top-1/3 justify-center items-center mt-4">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-primary dark:text-neutral-300" />
|
||||
</div>
|
||||
)}
|
||||
{presentingDocument && (
|
||||
<TextView
|
||||
presentingDocument={presentingDocument}
|
||||
onClose={() => setPresentingDocument(null)}
|
||||
/>
|
||||
)}
|
||||
{popup}
|
||||
<div className="flex-grow">
|
||||
{isLoading ? (
|
||||
<SkeletonLoader />
|
||||
) : filteredFolders.length > 0 ? (
|
||||
<div
|
||||
className={`mt-4 grid gap-3 md:mt-8 ${
|
||||
true ? "md:grid-cols-2" : ""
|
||||
} md:gap-6 transition-all duration-300 ease-in-out`}
|
||||
>
|
||||
{filteredFolders.map((folder) => (
|
||||
<SharedFolderItem
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
onClick={handleFolderClick}
|
||||
description={folder.description}
|
||||
lastUpdated={folder.created_at}
|
||||
onRename={() => onRenameItem(folder.id, folder.name, true)}
|
||||
onDelete={() => handleDeleteItem(folder.id, true)}
|
||||
onMove={() => handleMoveItem(folder.id, currentFolder, true)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-64">
|
||||
<FolderOpen
|
||||
className="w-20 h-20 text-orange-400 dark:text-orange-300 mb-4 "
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<p className="text-text-500 dark:text-neutral-400 text-lg font-normal">
|
||||
No items found
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3 flex">
|
||||
<div className="mx-auto">
|
||||
<PageSelector
|
||||
currentPage={page}
|
||||
totalPages={Math.ceil((folders?.length || 0) / pageLimit)}
|
||||
onPageChange={(newPage) => {
|
||||
setPage(newPage);
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
left: 0,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
15
web/src/app/chat/my-documents/WrappedDocuments.tsx
Normal file
15
web/src/app/chat/my-documents/WrappedDocuments.tsx
Normal file
@@ -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 (
|
||||
<SidebarWrapper size="lg">
|
||||
<div className="mx-auto w-full">
|
||||
<MyDocuments />
|
||||
</div>
|
||||
</SidebarWrapper>
|
||||
);
|
||||
}
|
||||
7
web/src/app/chat/my-documents/[id]/UserFileContent.tsx
Normal file
7
web/src/app/chat/my-documents/[id]/UserFileContent.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { useDocumentsContext } from "../DocumentsContext";
|
||||
|
||||
export default function UserFolder({ userFileId }: { userFileId: string }) {
|
||||
const { folders } = useDocumentsContext();
|
||||
|
||||
return <div>{folders.length}</div>;
|
||||
}
|
||||
18
web/src/app/chat/my-documents/[id]/UserFolder.tsx
Normal file
18
web/src/app/chat/my-documents/[id]/UserFolder.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import SidebarWrapper from "@/app/assistants/SidebarWrapper";
|
||||
import UserFolderContent from "./UserFolderContent";
|
||||
|
||||
export default function WrappedUserFolders({
|
||||
userFileId,
|
||||
}: {
|
||||
userFileId: string;
|
||||
}) {
|
||||
return (
|
||||
<SidebarWrapper size="lg">
|
||||
<div className="mx-auto w-full">
|
||||
<UserFolderContent folderId={Number(userFileId)} />
|
||||
</div>
|
||||
</SidebarWrapper>
|
||||
);
|
||||
}
|
||||
344
web/src/app/chat/my-documents/[id]/UserFolderContent.tsx
Normal file
344
web/src/app/chat/my-documents/[id]/UserFolderContent.tsx
Normal file
@@ -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<number | null>(null);
|
||||
const [newItemName, setNewItemName] = useState("");
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [deleteItemId, setDeleteItemId] = useState<number | null>(null);
|
||||
const [deleteItemType, setDeleteItemType] = useState<"file" | "folder">(
|
||||
"file"
|
||||
);
|
||||
const [deleteItemName, setDeleteItemName] = useState("");
|
||||
const [isMoveModalOpen, setIsMoveModalOpen] = useState(false);
|
||||
const [folders, setFolders] = useState<FolderResponse[]>([]);
|
||||
|
||||
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 (
|
||||
<div className="min-h-full w-full min-w-0 flex-1 mx-auto max-w-5xl px-4 pb-20 md:pl-8 mt-6 md:pr-8 2xl:pr-14">
|
||||
<div className="text-left space-y-4">
|
||||
<h2 className="flex items-center gap-1.5 text-lg font-medium leading-tight tracking-tight max-md:hidden">
|
||||
No Folder Found
|
||||
</h2>
|
||||
<p className="text-neutral-600">
|
||||
The requested folder does not exist or you dont have permission to
|
||||
view it.
|
||||
</p>
|
||||
<Button onClick={handleBack} variant="outline" className="mt-2">
|
||||
Back to My Documents
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="min-h-full w-full min-w-0 flex-1 mx-auto max-w-5xl px-4 pb-20 md:pl-8 mt-6 md:pr-8 2xl:pr-14">
|
||||
{popup}
|
||||
{folderCreatedPopup}
|
||||
<DeleteEntityModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
onClose={() => setIsDeleteModalOpen(false)}
|
||||
onConfirm={confirmDelete}
|
||||
entityType={deleteItemType}
|
||||
entityName={deleteItemName}
|
||||
/>
|
||||
<MoveFolderModal
|
||||
isOpen={isMoveModalOpen}
|
||||
onClose={() => setIsMoveModalOpen(false)}
|
||||
onMove={confirmMove}
|
||||
folders={folders}
|
||||
currentFolderId={folderId}
|
||||
/>
|
||||
<div className="flex justify-between items-start mb-6">
|
||||
<div className="flex-1 mr-4">
|
||||
<div
|
||||
className="flex text-sm mb-4 items-center cursor-pointer text-neutral-700 dark:text-neutral-300"
|
||||
onClick={handleBack}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" /> Back to My Knowledge Groups
|
||||
</div>
|
||||
{editingItemId === folderDetails.id ? (
|
||||
<div className="flex items-center">
|
||||
<Input
|
||||
value={newItemName}
|
||||
onChange={(e) => setNewItemName(e.target.value)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => handleSaveRename(folderDetails.id, true)}
|
||||
className="mr-2"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button onClick={handleCancelRename} variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center">
|
||||
<h1
|
||||
className="flex items-center gap-1.5 text-lg font-medium leading-tight tracking-tight max-md:hidden cursor-pointer mr-4 text-neutral-900 dark:text-neutral-100"
|
||||
onClick={() =>
|
||||
handleRenameItem(folderDetails.id, folderDetails.name, true)
|
||||
}
|
||||
>
|
||||
{folderDetails.name}
|
||||
</h1>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-neutral-600 dark:text-neutral-200 mb-4">
|
||||
{folderDetails.description}
|
||||
</p>
|
||||
|
||||
<DocumentList
|
||||
isLoading={false}
|
||||
files={folderDetails.files}
|
||||
onRename={handleRenameItem}
|
||||
onDelete={handleDeleteItem}
|
||||
onDownload={downloadItem}
|
||||
onUpload={handleUpload}
|
||||
onMove={handleMoveFile}
|
||||
folders={folders}
|
||||
disabled={folderDetails.id === -1}
|
||||
editingItemId={editingItemId}
|
||||
onSaveRename={handleSaveRename}
|
||||
onCancelRename={handleCancelRename}
|
||||
newItemName={newItemName}
|
||||
setNewItemName={setNewItemName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-[313.33px] bg-[#fff] dark:bg-neutral-800 mt-20 relative rounded-md border border-neutral-200 dark:border-neutral-700 overflow-hidden">
|
||||
<ContextLimitPanel
|
||||
isOpen={isCapacityOpen}
|
||||
onToggle={() => setIsCapacityOpen(!isCapacityOpen)}
|
||||
tokenPercentage={tokenPercentage}
|
||||
totalTokens={totalTokens}
|
||||
maxTokens={maxTokens}
|
||||
selectedModel={selectedModel}
|
||||
modelDescriptors={modelDescriptors}
|
||||
onSelectModel={setSelectedModel}
|
||||
/>
|
||||
|
||||
<SharingPanel
|
||||
assistantIds={folderDetails.assistant_ids}
|
||||
assistants={assistants}
|
||||
isOpen={isSharedOpen}
|
||||
onToggle={() => setIsSharedOpen(!isSharedOpen)}
|
||||
/>
|
||||
|
||||
<AddWebsitePanel
|
||||
folderId={folderId}
|
||||
onCreateFileFromLink={handleCreateFileFromLink}
|
||||
/>
|
||||
|
||||
<div className="p-4">
|
||||
<Button
|
||||
variant="default"
|
||||
className="w-full"
|
||||
onClick={handleStartChat}
|
||||
>
|
||||
<MessageSquare className="w-4 h-4 mr-2" />
|
||||
Chat with This Group
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
130
web/src/app/chat/my-documents/[id]/components/DocumentList.tsx
Normal file
130
web/src/app/chat/my-documents/[id]/components/DocumentList.tsx
Normal file
@@ -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<void>;
|
||||
onDelete: (itemId: number, isFolder: boolean, itemName: string) => void;
|
||||
onDownload: (documentId: string) => Promise<void>;
|
||||
onUpload: (files: File[]) => void;
|
||||
onMove: (fileId: number, targetFolderId: number) => Promise<void>;
|
||||
folders: FolderResponse[];
|
||||
isLoading: boolean;
|
||||
disabled?: boolean;
|
||||
editingItemId: number | null;
|
||||
onSaveRename: (itemId: number, isFolder: boolean) => Promise<void>;
|
||||
onCancelRename: () => void;
|
||||
newItemName: string;
|
||||
setNewItemName: React.Dispatch<React.SetStateAction<string>>;
|
||||
}
|
||||
|
||||
export const DocumentList: React.FC<DocumentListProps> = ({
|
||||
files,
|
||||
onRename,
|
||||
onDelete,
|
||||
onDownload,
|
||||
onUpload,
|
||||
onMove,
|
||||
folders,
|
||||
isLoading,
|
||||
disabled,
|
||||
editingItemId,
|
||||
onSaveRename,
|
||||
onCancelRename,
|
||||
newItemName,
|
||||
setNewItemName,
|
||||
}) => {
|
||||
const [presentingDocument, setPresentingDocument] =
|
||||
useState<MinimalOnyxDocument | null>(null);
|
||||
const [view, setView] = useState<"grid" | "list">("list");
|
||||
|
||||
const toggleView = () => {
|
||||
setView(view === "grid" ? "list" : "grid");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{presentingDocument && (
|
||||
<TextView
|
||||
presentingDocument={presentingDocument}
|
||||
onClose={() => setPresentingDocument(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-sm font-semibold text-neutral-800 dark:text-neutral-200">
|
||||
Documents in this Project
|
||||
</h2>
|
||||
<Button onClick={toggleView} variant="outline" size="sm">
|
||||
{view === "grid" ? <List size={16} /> : <Grid size={16} />}
|
||||
</Button>
|
||||
</div>
|
||||
<FileUploadSection
|
||||
disabled={disabled}
|
||||
disabledMessage={
|
||||
disabled
|
||||
? "This folder cannot be edited. It contains your recent documents."
|
||||
: undefined
|
||||
}
|
||||
onUpload={onUpload}
|
||||
/>
|
||||
|
||||
<div className={view === "grid" ? "grid grid-cols-4 gap-4" : "space-y-2"}>
|
||||
{files.map((file) => (
|
||||
<div key={file.id}>
|
||||
{editingItemId === file.id ? (
|
||||
<div className="flex items-center">
|
||||
<Input
|
||||
value={newItemName}
|
||||
onChange={(e) => setNewItemName(e.target.value)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => onSaveRename(file.id, false)}
|
||||
className="mr-2"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button onClick={onCancelRename} variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<FileListItem
|
||||
file={file}
|
||||
view={view}
|
||||
onRename={onRename}
|
||||
onDelete={onDelete}
|
||||
onDownload={onDownload}
|
||||
onMove={onMove}
|
||||
folders={folders}
|
||||
onSelect={() =>
|
||||
setPresentingDocument({
|
||||
semantic_identifier: file.name,
|
||||
document_id: file.document_id,
|
||||
})
|
||||
}
|
||||
isIndexed={file.indexed || false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{isLoading && <SkeletonFileListItem view={view} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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<void>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="p-4 border-b border-neutral-200 dark:border-neutral-700">
|
||||
<div
|
||||
className="flex items-center justify-between w-full cursor-pointer text-[#13343a] dark:text-neutral-300"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Link className="w-5 h-4 mr-3" />
|
||||
<span className="text-sm font-medium leading-tight">
|
||||
Add a website
|
||||
</span>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" className="w-6 h-6 p-0">
|
||||
{isOpen ? (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<div className="flex mt-4 items-center">
|
||||
<input
|
||||
type="text"
|
||||
value={linkUrl}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<Button
|
||||
variant="default"
|
||||
className="!text-sm"
|
||||
size="xs"
|
||||
onClick={handleCreateFileFromLink}
|
||||
disabled={isCreating || !linkUrl}
|
||||
>
|
||||
{isCreating ? "Creating..." : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="p-4 border-b border-neutral-200 dark:border-neutral-700">
|
||||
<div
|
||||
className="flex items-center justify-between text-[#13343a] dark:text-neutral-300"
|
||||
onClick={onToggle}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Info className="w-5 h-4 mr-3" />
|
||||
<span className="text-sm font-medium leading-tight">
|
||||
Context Limit
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button variant="ghost" size="sm" className="w-6 h-6 p-0 rounded-full">
|
||||
{isOpen ? (
|
||||
<ChevronDown className="w-[15px] h-3" />
|
||||
) : (
|
||||
<ChevronRight className="w-[15px] h-3" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<div className="mt-2 text-neutral-600 dark:text-neutral-400 text-sm font-normal leading-tight">
|
||||
<div className="mb-2">
|
||||
<ModelSelector
|
||||
models={modelDescriptors}
|
||||
selectedModel={selectedModel}
|
||||
onSelectModel={onSelectModel}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-1">
|
||||
Tokens: {totalTokens} / {maxTokens}
|
||||
</div>
|
||||
<div className="w-full bg-neutral-200 dark:bg-neutral-700 rounded-full h-2.5">
|
||||
<div
|
||||
className={`h-2.5 rounded-full ${
|
||||
tokenPercentage > 100 ? "bg-green-600" : "bg-blue-600"
|
||||
}`}
|
||||
style={{ width: `${Math.min(tokenPercentage, 100)}%` }}
|
||||
></div>
|
||||
</div>
|
||||
{tokenPercentage > 100 && (
|
||||
<div className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
Capacity exceeded. Search will be performed over content.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<ModelSelectorProps> = ({
|
||||
models,
|
||||
selectedModel,
|
||||
onSelectModel,
|
||||
}) => (
|
||||
<Select
|
||||
value={selectedModel.modelName}
|
||||
onValueChange={(value) =>
|
||||
onSelectModel(models.find((m) => m.modelName === value) || models[0])
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{models.map((model) => (
|
||||
<SelectItem
|
||||
icon={OpenAIIcon}
|
||||
key={model.modelName}
|
||||
value={model.modelName}
|
||||
>
|
||||
{getDisplayNameForModel(model.modelName)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
@@ -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 (
|
||||
<div className="p-4 border-b border-[#d9d9d0]">
|
||||
<div
|
||||
className="text-[#13343a] dark:text-neutral-300 flex items-center justify-between"
|
||||
onClick={onToggle}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{count > 0 ? (
|
||||
<>
|
||||
<Users className="w-5 h-4 mr-3 " />
|
||||
<span className="text-sm font-medium leading-tight">
|
||||
Shared with {count} Assistant{count > 1 ? "s" : ""}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<User className="w-5 h-4 mr-3 " />
|
||||
<span className="text-sm font-medium leading-tight">
|
||||
Not shared
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="w-6 h-6 p-0 rounded-full">
|
||||
{isOpen ? (
|
||||
<ChevronDown className="w-[15px] h-3 " />
|
||||
) : (
|
||||
<ChevronRight className="w-[15px] h-3 " />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div className="mt-2 text-[#64645e] dark:tex-neutral-300 text-sm font-normal leading-tight">
|
||||
{count > 0 ? (
|
||||
<div className="flex flex-wrap gap-2 mt-1">
|
||||
{assistantIds.map((id) => {
|
||||
const assistant = assistants.find((a) => a.id === id);
|
||||
return assistant ? (
|
||||
<a
|
||||
href={`/assistants/edit/${assistant.id}`}
|
||||
key={assistant.id}
|
||||
className="flex bg-neutral-200/80 hover:bg-neutral-200 dark:bg-neutral-700/80 dark:hover:bg-neutral-700 cursor-pointer px-2 py-1 rounded-md items-center space-x-2"
|
||||
>
|
||||
<AssistantIcon assistant={assistant as any} size="xs" />
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-neutral-300">
|
||||
{assistant.name}
|
||||
</span>
|
||||
</a>
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<span>Not shared with any assistants</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<FileUploadSectionProps> = ({
|
||||
onUpload,
|
||||
disabledMessage,
|
||||
disabled,
|
||||
}) => {
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
e.preventDefault();
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
const newFiles = Array.from(e.target.files);
|
||||
onUpload(newFiles);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger className="mt-6 w-full">
|
||||
<div
|
||||
className={`border border-neutral-200 dark:border-neutral-700 bg-transparent rounded-lg p-4 shadow-sm ${
|
||||
!disabled && "hover:bg-neutral-50 dark:hover:bg-neutral-800"
|
||||
} transition-colors duration-200 ${
|
||||
disabled ? "cursor-not-allowed" : "cursor-pointer"
|
||||
}`}
|
||||
>
|
||||
<label
|
||||
htmlFor="file-upload"
|
||||
className={`w-full h-full block ${
|
||||
disabled ? "pointer-events-none" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col gap-y-2 items-center justify-between">
|
||||
<p className="flex flex-col text-center text-sm text-neutral-500 dark:text-neutral-400">
|
||||
Add files to this project
|
||||
</p>
|
||||
<Upload className="w-5 h-5 text-neutral-400 dark:text-neutral-500" />
|
||||
</div>
|
||||
<input
|
||||
disabled={disabled}
|
||||
id="file-upload"
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{disabled ? <TooltipContent>{disabledMessage}</TooltipContent> : null}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import React from "react";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
|
||||
interface UploadWarningProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const UploadWarning: React.FC<UploadWarningProps> = ({ className }) => {
|
||||
return (
|
||||
<div
|
||||
className={`bg-yellow-100 border-l-4 border-yellow-500 text-yellow-700 p-4 ${
|
||||
className || ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<AlertTriangle className="h-6 w-6 mr-2" />
|
||||
<p>
|
||||
<span className="font-bold">Warning:</span> This folder is shared. Any
|
||||
documents you upload will be accessible to the shared assistants.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
22
web/src/app/chat/my-documents/[id]/page.tsx
Normal file
22
web/src/app/chat/my-documents/[id]/page.tsx
Normal file
@@ -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 (
|
||||
<DocumentsProvider initialFolderDetails={folderResponse}>
|
||||
<WrappedUserFolders userFileId={searchParams.id} />
|
||||
</DocumentsProvider>
|
||||
);
|
||||
}
|
||||
224
web/src/app/chat/my-documents/components/FileListItem.tsx
Normal file
224
web/src/app/chat/my-documents/components/FileListItem.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
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<void>;
|
||||
onDelete: (itemId: number, isFolder: boolean, itemName: string) => void;
|
||||
onDownload: (documentId: string) => Promise<void>;
|
||||
onMove: (fileId: number, targetFolderId: number) => Promise<void>;
|
||||
folders: FolderResponse[];
|
||||
isIndexed: boolean;
|
||||
}
|
||||
|
||||
export const FileListItem: React.FC<FileListItemProps> = ({
|
||||
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 (
|
||||
<div
|
||||
className={`p-2 group ${
|
||||
view === "grid"
|
||||
? "flex flex-col items-center"
|
||||
: "flex items-center justify-between hover:bg-neutral-100 dark:hover:bg-neutral-900 dark:hover:text-neutral-100 rounded cursor-pointer"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex items-center ${
|
||||
view === "grid" ? "flex-col" : "w-full"
|
||||
}`}
|
||||
onClick={onSelect}
|
||||
>
|
||||
{isSelected !== undefined && (
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
className={view === "grid" ? "mb-2" : "mr-2"}
|
||||
/>
|
||||
)}
|
||||
<FileIcon
|
||||
className={`${
|
||||
view === "grid" ? "h-12 w-12 mb-2" : "h-5 w-5 mr-2"
|
||||
} text-neutral-500`}
|
||||
/>
|
||||
<span
|
||||
className={`w-full flex justify-between items-center text-sm truncate ${
|
||||
view === "grid" ? "text-center" : ""
|
||||
}`}
|
||||
>
|
||||
<p>{file.name}</p>
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={`h-2 w-2 rounded-full ${
|
||||
isIndexed ? "bg-transparent" : "bg-red-600 animate-pulse"
|
||||
}`}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{!isIndexed
|
||||
? "Not yet indexed. This will be completed momentarily."
|
||||
: "Indexed"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</span>
|
||||
</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="group-hover:visible invisible h-8 w-8 p-0"
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className={`!p-0 ${showMoveOptions ? "w-52" : "w-40"}`}>
|
||||
{!showMoveOptions ? (
|
||||
<div className="space-y-0">
|
||||
<Button variant="menu" onClick={() => setShowMoveOptions(true)}>
|
||||
<FiArrowDown className="h-4 w-4" />
|
||||
Move
|
||||
</Button>
|
||||
<Button variant="menu" onClick={() => {}}>
|
||||
<FiSearch className="h-4 w-4" />
|
||||
Summarize
|
||||
</Button>
|
||||
<Button
|
||||
variant="menu"
|
||||
onClick={() => onRename(file.id, file.name, false)}
|
||||
>
|
||||
<FiEdit className="h-4 w-4" />
|
||||
Rename
|
||||
</Button>
|
||||
<Button variant="menu" onClick={handleDelete}>
|
||||
<FiTrash className="h-4 w-4" />
|
||||
Delete
|
||||
</Button>
|
||||
<Button
|
||||
variant="menu"
|
||||
onClick={() => onDownload(file.document_id)}
|
||||
>
|
||||
<FiDownload className="h-4 w-4" />
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-2 text-text-dark space-y-2">
|
||||
<div className="flex items-center space-x-2 mb-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setShowMoveOptions(false)}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<h3 className="text-sm font-medium">Move folder</h3>
|
||||
</div>
|
||||
<div className="max-h-60 overflow-y-auto pr-2">
|
||||
<div className="space-y-1">
|
||||
{[...folders, ...folders].map((folder) => (
|
||||
<Button
|
||||
key={folder.id}
|
||||
variant="ghost"
|
||||
onClick={() => handleMove(folder.id)}
|
||||
className="w-full justify-start text-sm py-2 px-3"
|
||||
>
|
||||
{folder.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const SkeletonFileListItem: React.FC<{
|
||||
view: "grid" | "list";
|
||||
}> = ({ view }) => {
|
||||
return (
|
||||
<div
|
||||
className={`p-2 ${
|
||||
view === "grid"
|
||||
? "flex flex-col items-center"
|
||||
: "flex items-center justify-between hover:bg-neutral-100 rounded"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex items-center ${
|
||||
view === "grid" ? "flex-col" : "w-full"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`${
|
||||
view === "grid" ? "h-12 w-12 mb-2" : "h-5 w-5 mr-2"
|
||||
} bg-neutral-200 rounded animate-pulse`}
|
||||
/>
|
||||
<div
|
||||
className={`h-6 bg-neutral-200 rounded animate-pulse ${
|
||||
view === "grid" ? "w-20 mt-2" : "w-72"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="h-6 w-6 mr-1 bg-neutral-200 rounded-full animate-pulse" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
833
web/src/app/chat/my-documents/components/FilePicker.tsx
Normal file
833
web/src/app/chat/my-documents/components/FilePicker.tsx
Normal file
@@ -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 = () => <List className="h-4 w-4" />;
|
||||
const GridIcon = () => <Grid className="h-4 w-4" />;
|
||||
|
||||
const IconButton: React.FC<{
|
||||
icon: React.ComponentType;
|
||||
onClick: () => void;
|
||||
active: boolean;
|
||||
}> = ({ icon: Icon, onClick, active }) => (
|
||||
<button
|
||||
className={`p-2 flex-none h-10 w-10 flex items-center justify-center rounded ${
|
||||
active ? "bg-gray-200" : "hover:bg-gray-100"
|
||||
}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Icon />
|
||||
</button>
|
||||
);
|
||||
|
||||
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 (
|
||||
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
|
||||
<FilePickerFolderItem
|
||||
folder={item as FolderResponse}
|
||||
onClick={onClick || (() => {})}
|
||||
onSelect={() => {}}
|
||||
isSelected={isSelected}
|
||||
allFilesSelected={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className={`flex items-center p-2 cursor-pointer rounded-md ${
|
||||
isDragging ? "bg-gray-200" : ""
|
||||
} ${selectedClassName}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<FileIcon className="mr-2 text-gray-500" />
|
||||
<span className="text-sm font-medium">{(item as FileResponse).name}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<div
|
||||
className={`${selectedClassName} border-0.5 border-border hover:border-border-200 dark:border-neutral-700 dark:hover:border-neutral-600 text-md group relative flex cursor-pointer flex-col overflow-x-hidden text-ellipsis rounded-xl bg-gradient-to-b py-4 pl-5 pr-4 transition-all ease-in-out hover:shadow-sm active:scale-[0.99]`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex flex-col flex-1">
|
||||
<div className="font-tiempos flex items-center justify-between">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-truncate text-text-dark dark:text-neutral-200 inline-block max-w-md">
|
||||
{folder.name}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{folder.name}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`ml-2 ${
|
||||
isSelected || allFilesSelected
|
||||
? "text-blue-500 dark:text-blue-400"
|
||||
: "text-gray-500 dark:text-neutral-400"
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelect();
|
||||
}}
|
||||
>
|
||||
{isSelected || allFilesSelected ? (
|
||||
<X size={16} />
|
||||
) : (
|
||||
<PlusIcon size={16} />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{folder.description && (
|
||||
<div className="text-text-400 dark:text-neutral-400 mt-1 line-clamp-2 text-xs">
|
||||
{folder.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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<FilePickerModalProps> = ({
|
||||
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<File[]>([]);
|
||||
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<FileResponse[]>(
|
||||
[]
|
||||
);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [isHoveringRight, setIsHoveringRight] = useState(false);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
|
||||
const [selectedFileIds, setSelectedFileIds] = useState<Set<number>>(
|
||||
new Set()
|
||||
);
|
||||
const [selectedFolderIds, setSelectedFolderIds] = useState<Set<number>>(
|
||||
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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div
|
||||
className="flex items-center mb-2 text-sm text-gray-600 cursor-pointer hover:text-gray-800"
|
||||
onClick={() => setCurrentFolder(null)}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4 mr-1"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
Back to Folders
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<Modal
|
||||
hideDividerForTitle
|
||||
onOutsideClick={onClose}
|
||||
className="max-w-4xl flex flex-col w-full !overflow-hidden h-[70vh]"
|
||||
title={title}
|
||||
>
|
||||
<div className="grid h-full grid-cols-2 overflow-y-hidden w-full divide-x divide-gray-200 dark:divide-neutral-700">
|
||||
<div className="w-full h-full pb-4 overflow-y-auto">
|
||||
<div className="sticky flex flex-col gap-y-2 border-b bg-background dark:bg-transparent z-[1000] top-0 mb-2 flex gap-x-2 w-full pr-4">
|
||||
<div className="w-full relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search folders..."
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-neutral-600 rounded-md focus:border-transparent dark:bg-neutral-800 dark:text-neutral-100"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg
|
||||
className="h-5 w-5 text-text-dark dark:text-neutral-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
{renderNavigation()}
|
||||
</div>
|
||||
|
||||
{filteredFolders.length + currentFolderFiles.length > 0 ? (
|
||||
<div className="flex-grow pr-4">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
onDragStart={handleDragStart}
|
||||
onDragMove={handleDragMove}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={handleDragCancel}
|
||||
collisionDetection={closestCenter}
|
||||
>
|
||||
<SortableContext
|
||||
items={[
|
||||
...filteredFolders.map((f) => `folder-${f.id}`),
|
||||
...currentFolderFiles.map((f) => `file-${f.id}`),
|
||||
]}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="overflow-y-auto space-y-3">
|
||||
{currentFolder === null
|
||||
? filteredFolders.map((folder) => (
|
||||
<FilePickerFolderItem
|
||||
key={`folder-${folder.id}`}
|
||||
folder={folder}
|
||||
onClick={() => handleFolderClick(folder.id)}
|
||||
onSelect={() => handleFolderSelect(folder)}
|
||||
isSelected={selectedFolderIds.has(folder.id)}
|
||||
allFilesSelected={isAllFilesInFolderSelected(
|
||||
folder
|
||||
)}
|
||||
/>
|
||||
))
|
||||
: currentFolderFiles.map((file) => (
|
||||
<DraggableItem
|
||||
key={`file-${file.id}`}
|
||||
id={`file-${file.id}`}
|
||||
type="file"
|
||||
item={file}
|
||||
onClick={() => handleFileSelect(file)}
|
||||
isSelected={selectedFileIds.has(file.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
|
||||
<DragOverlay>
|
||||
{activeId ? (
|
||||
<DraggableItem
|
||||
id={activeId}
|
||||
type={activeId.startsWith("folder") ? "folder" : "file"}
|
||||
item={
|
||||
activeId.startsWith("folder")
|
||||
? folders.find(
|
||||
(f) =>
|
||||
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}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</div>
|
||||
) : folders.length > 0 ? (
|
||||
<div className="flex-grow overflow-y-auto px-4">
|
||||
<p className="text-text-subtle dark:text-neutral-400">
|
||||
No files or folders found
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-grow flex-col overflow-y-auto px-4 flex items-start justify-start gap-y-2">
|
||||
<p className="text-sm text-muted-foreground dark:text-neutral-400">
|
||||
No files or folders found
|
||||
</p>
|
||||
<a
|
||||
href="/chat/my-documents"
|
||||
className="inline-flex items-center text-sm justify-center text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
<FolderIcon className="mr-2 h-4 w-4" />
|
||||
Create folder in My Documents
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`w-full h-full px-4 pb-4 flex flex-col h-[450px] ${
|
||||
isHoveringRight ? "bg-blue-50 dark:bg-blue-900/20" : ""
|
||||
}`}
|
||||
onDragEnter={() => setIsHoveringRight(true)}
|
||||
onDragLeave={() => setIsHoveringRight(false)}
|
||||
>
|
||||
<div className="shrink flex h-full overflow-y-auto mb-1">
|
||||
<SelectedItemsList
|
||||
folders={selectedItems.folders}
|
||||
files={selectedItems.files}
|
||||
onRemoveFile={(file) => handleFileSelect(file)}
|
||||
onRemoveFolder={(folder) => handleFolderSelect(folder)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<div className="p-4 flex-none border rounded-lg bg-neutral-50 dark:bg-neutral-800 dark:border-neutral-700">
|
||||
<label
|
||||
htmlFor="file-upload"
|
||||
className="cursor-pointer flex items-center justify-center space-x-2"
|
||||
>
|
||||
<UploadIcon className="w-5 h-5 text-gray-600 dark:text-neutral-300" />
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-neutral-200">
|
||||
{isUploadingFile ? "Uploading..." : "Upload files"}
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id="file-upload"
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleFileUpload}
|
||||
disabled={isUploadingFile}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator className="my-2 dark:bg-neutral-700" />
|
||||
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<p className="text-sm text-text-subtle dark:text-neutral-400">
|
||||
Add links to the context
|
||||
</p>
|
||||
</div>
|
||||
<form
|
||||
className="flex gap-x-4 mt-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleCreateFileFromLink();
|
||||
}}
|
||||
>
|
||||
<div className="w-full gap-x-2 flex">
|
||||
<input
|
||||
type="text"
|
||||
value={linkUrl}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<Button
|
||||
variant="default"
|
||||
className="!text-sm"
|
||||
size="xs"
|
||||
onClick={handleCreateFileFromLink}
|
||||
disabled={isCreatingFileFromLink || !linkUrl}
|
||||
>
|
||||
{isCreatingFileFromLink ? "Creating..." : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
117
web/src/app/chat/my-documents/components/SearchResultItem.tsx
Normal file
117
web/src/app/chat/my-documents/components/SearchResultItem.tsx
Normal file
@@ -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<SearchResultItemProps> = ({
|
||||
item,
|
||||
view,
|
||||
onClick,
|
||||
isLink = false,
|
||||
lastUpdated,
|
||||
onRename,
|
||||
onDelete,
|
||||
onMove,
|
||||
parentFolder,
|
||||
onParentFolderClick,
|
||||
fileSize = FileSize.SMALL,
|
||||
}) => {
|
||||
const Icon = isLink ? LinkIcon : File;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<a
|
||||
className={`flex items-center flex-grow ${
|
||||
view === "list" ? "w-full" : "w-4/5"
|
||||
} p-3 rounded-lg hover:bg-[#F3F2EA]/60 transition-colors duration-200`}
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onClick(item.document_id, item.name);
|
||||
}}
|
||||
>
|
||||
<Icon className="h-5 w-5 mr-3 text-orange-600 flex-shrink-0" />
|
||||
<div className="flex flex-col min-w-0">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-sm font-medium text-text-900 truncate">
|
||||
{item.name}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{item.name}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<div className="flex items-center w-full justify-start gap-x-1">
|
||||
{lastUpdated && (
|
||||
<span className="text-xs text-text-500"> {lastUpdated}</span>
|
||||
)}
|
||||
{fileSize && (
|
||||
<>
|
||||
<div className="flex items-center justify-center h-1.5 w-1.5 mx-1 rounded-full bg-background-400 transition-colors duration-200"></div>
|
||||
<span className="text-xs text-text-500">
|
||||
{fileSizeToDescription[fileSize]}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<div className="flex items-center justify-center h-2 w-2 rounded-full hover:bg-background-200 transition-colors duration-200"></div>
|
||||
</div>
|
||||
</div>
|
||||
{parentFolder && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className="flex items-center justify-center h-10 w-10 rounded-full hover:bg-background-200 transition-colors duration-200"
|
||||
onClick={() => onParentFolderClick?.(parentFolder.id)}
|
||||
>
|
||||
<Folder className="h-5 w-5 text-text-500" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Parent Folder: {parentFolder.name}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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<SelectedItemsListProps> = ({
|
||||
folders,
|
||||
files,
|
||||
onRemoveFile,
|
||||
onRemoveFolder,
|
||||
}) => {
|
||||
// const {
|
||||
// selectedFiles,
|
||||
// selectedFolders,
|
||||
// setSelectedFiles,
|
||||
// setSelectedFolders,
|
||||
// } = useDocumentsContext();
|
||||
return (
|
||||
<div className="h-full w-full flex flex-col">
|
||||
<h3 className="font-semibold fixed mb-2 dark:text-neutral-200">
|
||||
Selected Items
|
||||
</h3>
|
||||
<div className="w-full overflow-y-auto mt-8 border-t border-t-text-subtle dark:border-t-neutral-700 flex-grow">
|
||||
<div className="space-y-2 pt-2">
|
||||
{folders?.map((folder: FolderResponse) => (
|
||||
<div
|
||||
key={folder.id}
|
||||
className="flex items-center justify-between bg-blue-50 dark:bg-neutral-800 p-2 rounded-md border border-blue-200 dark:border-neutral-700"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Folder className="h-4 w-4 mr-2 text-blue-500 dark:text-blue-400" />
|
||||
<span className="text-sm font-medium dark:text-neutral-200">
|
||||
{folder.name}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onRemoveFolder(folder)}
|
||||
className="hover:bg-blue-100 dark:hover:bg-neutral-700"
|
||||
>
|
||||
<X className="h-4 w-4 dark:text-neutral-300" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{files?.map((file: FileResponse) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="flex items-center justify-between bg-gray-50 dark:bg-neutral-800 p-2 rounded-md border border-gray-200 dark:border-neutral-700"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<File className="h-4 w-4 mr-2 text-gray-500 dark:text-neutral-400" />
|
||||
<span className="text-sm truncate dark:text-neutral-200">
|
||||
{file.name}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onRemoveFile(file)}
|
||||
className="hover:bg-gray-100 dark:hover:bg-neutral-700"
|
||||
>
|
||||
<X className="h-4 w-4 dark:text-neutral-300" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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<SharedFolderItemProps> = ({
|
||||
folder,
|
||||
onClick,
|
||||
description,
|
||||
lastUpdated,
|
||||
onRename,
|
||||
onDelete,
|
||||
onMove,
|
||||
}) => {
|
||||
return (
|
||||
<a
|
||||
className={`from-[#f2f0e8]/80 to-[#F7F6F0] dark:from-[#1a1a1a] dark:to-[#222222] border-0.5 border-border dark:border-border hover:from-[#f2f0e8] hover:to-[#F7F6F0] dark:hover:from-[#222222] dark:hover:to-[#2a2a2a] hover:border-border-200 dark:hover:border-border-200 text-md group relative flex cursor-pointer ${
|
||||
false ? "flex-row items-center" : "flex-col"
|
||||
} overflow-x-hidden text-ellipsis rounded-xl bg-gradient-to-b py-4 pl-5 pr-4 transition-all ease-in-out hover:shadow-sm active:scale-[0.99]`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onClick(folder.id);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`flex ${
|
||||
false ? "flex-row items-center" : "flex-col"
|
||||
} flex-1`}
|
||||
>
|
||||
<div className="font-tiempos flex items-center">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-truncate line-clamp-2 text-text-dark dark:text-text-dark inline-block max-w-md">
|
||||
{folder.name}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{folder.name}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
{description && (
|
||||
<div
|
||||
className={`text-text-400 dark:text-neutral-400 ${
|
||||
false ? "ml-4" : "mt-1"
|
||||
} line-clamp-2 text-xs`}
|
||||
>
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{lastUpdated && (
|
||||
<div className="text-text-500 dark:text-text-500 mt-3 flex justify-between text-xs">
|
||||
|
||||
<span>
|
||||
Updated{" "}
|
||||
<span data-state="closed">
|
||||
{getTimeAgoString(new Date(lastUpdated))}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
31
web/src/app/chat/my-documents/components/types.ts
Normal file
31
web/src/app/chat/my-documents/components/types.ts
Normal file
@@ -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;
|
||||
}
|
||||
12
web/src/app/chat/my-documents/page.tsx
Normal file
12
web/src/app/chat/my-documents/page.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import WrappedDocuments from "./WrappedDocuments";
|
||||
import { DocumentsProvider } from "./DocumentsContext";
|
||||
|
||||
export default async function GalleryPage(props: {
|
||||
searchParams: Promise<{ [key: string]: string }>;
|
||||
}) {
|
||||
return (
|
||||
<DocumentsProvider>
|
||||
<WrappedDocuments />
|
||||
</DocumentsProvider>
|
||||
);
|
||||
}
|
||||
64
web/src/app/chat/my-documents/useDocuments.ts
Normal file
64
web/src/app/chat/my-documents/useDocuments.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
|
||||
// API functions
|
||||
const fetchDocuments = async (): Promise<Document[]> => {
|
||||
const response = await fetch("/api/manage/admin/documents");
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch documents");
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
const deleteDocument = async (documentId: number): Promise<void> => {
|
||||
const response = await fetch(`/api/manage/admin/documents/${documentId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to delete document");
|
||||
}
|
||||
};
|
||||
|
||||
export interface Document {
|
||||
id: number;
|
||||
document_id: string;
|
||||
}
|
||||
// Custom hook
|
||||
export const useDocuments = () => {
|
||||
const [documents, setDocuments] = useState<Document[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadDocuments = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const fetchedDocuments = await fetchDocuments();
|
||||
setDocuments(fetchedDocuments);
|
||||
} catch (err) {
|
||||
setError("Failed to load documents err: " + err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDeleteDocument = async (documentId: number) => {
|
||||
try {
|
||||
await deleteDocument(documentId);
|
||||
await loadDocuments();
|
||||
} catch (err) {
|
||||
setError("Failed to delete document");
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadDocuments();
|
||||
}, [loadDocuments]);
|
||||
|
||||
return {
|
||||
documents,
|
||||
isLoading,
|
||||
error,
|
||||
loadDocuments,
|
||||
handleDeleteDocument,
|
||||
};
|
||||
};
|
||||
@@ -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 (
|
||||
<WrappedChat
|
||||
firstMessage={firstMessage}
|
||||
defaultSidebarOff={defaultSidebarOff}
|
||||
/>
|
||||
<DocumentsProvider>
|
||||
<WrappedChat
|
||||
firstMessage={firstMessage}
|
||||
defaultSidebarOff={defaultSidebarOff}
|
||||
/>
|
||||
</DocumentsProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<HTMLDivElement, HistorySidebarProps>(
|
||||
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<HTMLDivElement, HistorySidebarProps>(
|
||||
New Chat
|
||||
</p>
|
||||
</Link>
|
||||
<Link
|
||||
className="w-full px-2 py-1 rounded-md items-center hover:bg-hover cursor-pointer transition-all duration-150 flex gap-x-2"
|
||||
href="/chat/my-documents"
|
||||
>
|
||||
<KnowledgeGroupIcon
|
||||
size={20}
|
||||
className="flex-none text-text-history-sidebar-button"
|
||||
/>
|
||||
<p className="my-auto flex font-normal items-center text-base">
|
||||
Knowledge Groups
|
||||
</p>
|
||||
</Link>
|
||||
{user?.preferences?.shortcut_enabled && (
|
||||
<Link
|
||||
className="w-full px-2 py-1 rounded-md items-center hover:bg-accent-background-hovered cursor-pointer transition-all duration-150 flex gap-x-2"
|
||||
|
||||
@@ -196,9 +196,10 @@ export function PagesTab({
|
||||
} catch (error) {
|
||||
console.error("Failed to create folder:", error);
|
||||
setPopup({
|
||||
message: `Failed to create folder: ${
|
||||
error instanceof Error ? error.message : "Unknown error"
|
||||
}`,
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to create folder",
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { OnyxDocument } from "@/lib/search/interfaces";
|
||||
import { useState } from "react";
|
||||
import { FileResponse } from "./my-documents/DocumentsContext";
|
||||
|
||||
interface DocumentInfo {
|
||||
num_chunks: number;
|
||||
@@ -18,14 +19,25 @@ async function fetchDocumentLength(documentId: string) {
|
||||
}
|
||||
|
||||
export function useDocumentSelection(): [
|
||||
FileResponse[],
|
||||
(file: FileResponse) => void,
|
||||
(file: FileResponse) => void,
|
||||
OnyxDocument[],
|
||||
(document: OnyxDocument) => void,
|
||||
() => void,
|
||||
number,
|
||||
] {
|
||||
const [selectedFiles, setSelectedFiles] = useState<FileResponse[]>([]);
|
||||
const [selectedDocuments, setSelectedDocuments] = useState<OnyxDocument[]>(
|
||||
[]
|
||||
);
|
||||
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,
|
||||
|
||||
@@ -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 (
|
||||
<SidebarWrapper initiallyToggled={initiallyToggled}>
|
||||
<SidebarWrapper>
|
||||
<AssistantStats assistantId={assistantId} />
|
||||
</SidebarWrapper>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<PostHogPageView />
|
||||
</Suspense>
|
||||
{children}
|
||||
{process.env.NEXT_PUBLIC_POSTHOG_KEY && <WebVitals />}
|
||||
<DocumentsProvider>
|
||||
<Suspense fallback={null}>
|
||||
<PostHogPageView />
|
||||
</Suspense>
|
||||
{children}
|
||||
{process.env.NEXT_PUBLIC_POSTHOG_KEY && <WebVitals />}
|
||||
</DocumentsProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
}
|
||||
|
||||
40
web/src/components/DeleteEntityModal.tsx
Normal file
40
web/src/components/DeleteEntityModal.tsx
Normal file
@@ -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<DeleteEntityModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
entityType,
|
||||
entityName,
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed z-[10000] inset-0 bg-black bg-opacity-50 flex items-center justify-center">
|
||||
<div className="max-w-md w-full bg-white p-6 rounded-lg shadow-lg">
|
||||
<h2 className="text-xl font-bold mb-4">Delete {entityType}</h2>
|
||||
<p className="mb-6">
|
||||
Are you sure you want to delete the {entityType} "{entityName}
|
||||
"? This action cannot be undone.
|
||||
</p>
|
||||
<div className="flex justify-end space-x-4">
|
||||
<Button onClick={onClose} variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={onConfirm} variant="destructive">
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
53
web/src/components/MoveFolderModal.tsx
Normal file
53
web/src/components/MoveFolderModal.tsx
Normal file
@@ -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<MoveFolderModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onMove,
|
||||
folders,
|
||||
currentFolderId,
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center">
|
||||
<div className="bg-white p-6 rounded-lg shadow-lg w-96">
|
||||
<h2 className="text-xl font-bold mb-4">Move Folder</h2>
|
||||
<p className="mb-4">Select a destination folder:</p>
|
||||
<div className="max-h-60 overflow-y-auto mb-4">
|
||||
{folders
|
||||
.filter((folder) => folder.id !== currentFolderId)
|
||||
.map((folder) => (
|
||||
<Button
|
||||
key={folder.id}
|
||||
onClick={() => onMove(folder.id)}
|
||||
variant="outline"
|
||||
className="w-full mb-2 justify-start"
|
||||
>
|
||||
{folder.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={onClose} variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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({
|
||||
<Dialog open onOpenChange={onClose}>
|
||||
<DialogContent
|
||||
hideCloseIcon
|
||||
className="max-w-5xl w-[90vw] flex flex-col justify-between gap-y-0 h-full max-h-[80vh] p-0"
|
||||
className="max-w-4xl w-[90vw] flex flex-col justify-between gap-y-0 h-full max-h-[80vh] p-0"
|
||||
>
|
||||
<DialogHeader className="px-4 mb-0 pt-2 pb-3 flex flex-row items-center justify-between border-b">
|
||||
<DialogTitle className="text-lg font-medium truncate">
|
||||
@@ -146,7 +148,6 @@ export default function TextView({
|
||||
</Button>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="mt-0 rounded-b-lg flex-1 overflow-hidden">
|
||||
<div className="flex items-center justify-center w-full h-full">
|
||||
{isLoading ? (
|
||||
|
||||
@@ -3241,6 +3241,28 @@ export const SearchAssistantIcon = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const SortIcon = ({
|
||||
size = 24,
|
||||
className = defaultTailwindCSS,
|
||||
}: IconProps) => {
|
||||
return (
|
||||
<svg
|
||||
style={{ width: `${size}px`, height: `${size}px` }}
|
||||
className={`w-[${size}px] h-[${size}px] ` + className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
d="M17 3.25a.75.75 0 0 1 .75.75v13.75l1.65-2.2a.75.75 0 1 1 1.2.9l-3 4a.75.75 0 0 1-1.35-.45V4a.75.75 0 0 1 .75-.75ZM7.25 6A.75.75 0 0 1 8 5.25h5a.75.75 0 0 1 0 1.5H8A.75.75 0 0 1 7.25 6Zm-2 5a.75.75 0 0 1 .75-.75h7a.75.75 0 0 1 0 1.5H6a.75.75 0 0 1-.75-.75Zm-2 5a.75.75 0 0 1 .75-.75h9a.75.75 0 0 1 0 1.5H4a.75.75 0 0 1-.75-.75Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const CirclingArrowIcon = ({
|
||||
size = 24,
|
||||
className = defaultTailwindCSS,
|
||||
@@ -3293,3 +3315,28 @@ export const CirclingArrowIcon = ({
|
||||
// </svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const KnowledgeGroupIcon = ({
|
||||
size = 24,
|
||||
className = defaultTailwindCSS,
|
||||
}: IconProps) => {
|
||||
return (
|
||||
<svg
|
||||
style={{ width: `${size}px`, height: `${size}px` }}
|
||||
className={`w-[${size}px] h-[${size}px] ` + className}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="200"
|
||||
height="200"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M21.25 9.883v7.698a3.083 3.083 0 0 1-3.083 3.083H5.833a3.083 3.083 0 0 1-3.083-3.083V6.419a3.083 3.083 0 0 1 3.083-3.083h3.084a3.083 3.083 0 0 1 2.57 1.377l.873 1.326a1.748 1.748 0 0 0 1.449.77h4.358a3.084 3.084 0 0 1 3.083 3.074"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
80
web/src/components/modals/CreateEntityModal.tsx
Normal file
80
web/src/components/modals/CreateEntityModal.tsx
Normal file
@@ -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 (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-col justify-stretch space-y-2 w-full"
|
||||
>
|
||||
<div className="space-y-2 w-full">
|
||||
<Label htmlFor="name">{entityName} Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={`Enter ${entityName.toLowerCase()} name`}
|
||||
required
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 w-full">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Input
|
||||
type="textarea"
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={`Enter ${entityName.toLowerCase()} description`}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full">
|
||||
Create {entityName}
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
198
web/src/components/ui/context-menu.tsx
Normal file
198
web/src/components/ui/context-menu.tsx
Normal file
@@ -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<typeof ContextMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-neutral-100 focus:text-neutral-900 data-[state=open]:bg-neutral-100 data-[state=open]:text-neutral-900",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</ContextMenuPrimitive.SubTrigger>
|
||||
));
|
||||
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const ContextMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-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
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const ContextMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Portal>
|
||||
<ContextMenuPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] bg-neutral-100 overflow-hidden rounded-md border p-1 text-popover-foreground shadow-md animate-in fade-in-80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-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
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ContextMenuPrimitive.Portal>
|
||||
));
|
||||
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
|
||||
|
||||
const ContextMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative bg-neutral-50 flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-neutral-100 focus:text-neutral-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
|
||||
|
||||
const ContextMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-neutral-100 focus:text-neutral-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
||||
));
|
||||
ContextMenuCheckboxItem.displayName =
|
||||
ContextMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const ContextMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-neutral-100 focus:text-neutral-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.RadioItem>
|
||||
));
|
||||
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName;
|
||||
|
||||
const ContextMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold text-foreground",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
|
||||
|
||||
const ContextMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
|
||||
|
||||
const ContextMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
ContextMenuShortcut.displayName = "ContextMenuShortcut";
|
||||
|
||||
export {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuRadioItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuShortcut,
|
||||
ContextMenuGroup,
|
||||
ContextMenuPortal,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuRadioGroup,
|
||||
};
|
||||
@@ -16,6 +16,8 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
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}
|
||||
|
||||
28
web/src/components/ui/progress.tsx
Normal file
28
web/src/components/ui/progress.tsx
Normal file
@@ -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<typeof ProgressPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||
>(({ className, value, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className="h-full w-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
));
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName;
|
||||
|
||||
export { Progress };
|
||||
@@ -150,6 +150,25 @@ const SelectItem = React.forwardRef<
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
|
||||
// <SelectPrimitive.Item
|
||||
// ref={ref}
|
||||
// className={cn(
|
||||
// "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 px-2 text-sm outline-none focus:bg-neutral-100 focus:text-neutral-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-neutral-800 dark:focus:text-neutral-50",
|
||||
// className
|
||||
// )}
|
||||
// {...props}
|
||||
// >
|
||||
// {!selected && Icon && (
|
||||
// <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
// <Icon className="h-4 w-4" />
|
||||
// </span>
|
||||
// )}
|
||||
|
||||
// <SelectPrimitive.ItemText className="pl-2">
|
||||
// {children}
|
||||
// </SelectPrimitive.ItemText>
|
||||
// </SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
146
web/src/services/documentsService.ts
Normal file
146
web/src/services/documentsService.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import {
|
||||
FileResponse,
|
||||
FolderResponse,
|
||||
FileUploadResponse,
|
||||
} from "@/app/chat/my-documents/DocumentsContext";
|
||||
|
||||
export async function fetchFolders(): Promise<FolderResponse[]> {
|
||||
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<FolderResponse> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<FileUploadResponse> {
|
||||
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<FileUploadResponse> {
|
||||
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<FolderResponse> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<Blob> {
|
||||
const response = await fetch(
|
||||
`/api/chat/file/${encodeURIComponent(documentId)}`,
|
||||
{
|
||||
method: "GET",
|
||||
}
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch file");
|
||||
}
|
||||
return response.blob();
|
||||
}
|
||||
Reference in New Issue
Block a user