1
0
forked from github/onyx

Compare commits

...

2 Commits

Author SHA1 Message Date
pablonyx
9ea35d51c3 k 2025-02-26 13:55:36 -08:00
pablonyx
c744faf25e add my docs 2025-02-26 13:42:46 -08:00
100 changed files with 6526 additions and 1218 deletions

View 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")

View 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)

View File

@@ -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,

View File

@@ -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

View File

@@ -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):

View File

@@ -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,

View File

@@ -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

View File

@@ -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.

View File

@@ -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]

View File

@@ -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
"""

View File

@@ -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

View 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()

View File

@@ -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

View File

@@ -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
):

View File

@@ -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,
}

View File

@@ -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

View File

@@ -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"

View File

@@ -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(

View File

@@ -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

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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)

View File

@@ -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,
)

View File

@@ -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)

View File

@@ -0,0 +1,6 @@
user_folders:
- id: -1
name: "Recent Documents"
description: "Documents uploaded by the user"
files: []
assistants: []

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -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):

View File

@@ -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,

View File

@@ -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):

View File

@@ -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,

View File

@@ -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}

View File

@@ -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

View 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)}")

View 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]

View File

@@ -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 = (

View File

@@ -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

View File

@@ -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)

View File

@@ -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
View 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

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

@@ -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>
)}

View 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),
]);
}

View File

@@ -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 {

View File

@@ -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,
};
}

View File

@@ -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) => {

View File

@@ -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} />

View File

@@ -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>
</>
);
}

View File

@@ -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;

View File

@@ -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}

View File

@@ -58,6 +58,7 @@ export default function LLMPopover({
icon: React.FC<{ size?: number; className?: string }>;
}[];
} = {};
const uniqueModelNames = new Set<string>();
llmProviders.forEach((llmProvider) => {

View File

@@ -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
? {

View 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;
};

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,7 @@
import { useDocumentsContext } from "../DocumentsContext";
export default function UserFolder({ userFileId }: { userFileId: string }) {
const { folders } = useDocumentsContext();
return <div>{folders.length}</div>;
}

View 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>
);
}

View 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>
);
}

View 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>
);
};

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);

View File

@@ -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>
);
}

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View 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>
);
}

View 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>
);
};

View 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>
);
};

View 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>
);
};

View File

@@ -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>
);
};

View File

@@ -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">
&nbsp;
<span>
Updated{" "}
<span data-state="closed">
{getTimeAgoString(new Date(lastUpdated))}
</span>
</span>
</div>
)}
</a>
);
};

View 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;
}

View 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>
);
}

View File

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

View File

@@ -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>
);
}

View File

@@ -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",

View File

@@ -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"

View File

@@ -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",
});
}

View File

@@ -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,

View File

@@ -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>
);

View File

@@ -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>
);
}

View 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} &quot;{entityName}
&quot;? 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>
);
};

View 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>
);
};

View File

@@ -9,11 +9,11 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Download, XIcon, ZoomIn, ZoomOut } from "lucide-react";
import { OnyxDocument } from "@/lib/search/interfaces";
import { 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 ? (

View File

@@ -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>
);
};

View 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>
);
}

View File

@@ -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",
},

View 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,
};

View File

@@ -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}

View 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 };

View File

@@ -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;

View File

@@ -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
)}

View File

@@ -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[] = [];

View File

@@ -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`;

View File

@@ -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 {

View File

@@ -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;

View 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();
}