mirror of
https://github.com/danswer-ai/danswer.git
synced 2025-03-18 13:51:46 +01:00
File Chat Upload (#1490)
Also includes minor UX improvements --------- Co-authored-by: Weves <chrisweaver101@gmail.com>
This commit is contained in:
parent
d6ea92b185
commit
57452b1030
backend
alembic/versions
danswer
chat
configs
db
file_processing
file_store
llm
server
web
package-lock.jsonpackage.jsontailwind.config.js
src
app/chat
components/tooltip
@ -11,8 +11,8 @@ import sqlalchemy as sa
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "3879338f8ba1"
|
||||
down_revision = "f1c6478c3fd8"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
branch_labels: None = None
|
||||
depends_on: None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
|
@ -0,0 +1,68 @@
|
||||
"""More Descriptive Filestore
|
||||
|
||||
Revision ID: 70f00c45c0f2
|
||||
Revises: 3879338f8ba1
|
||||
Create Date: 2024-05-17 17:51:41.926893
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "70f00c45c0f2"
|
||||
down_revision = "3879338f8ba1"
|
||||
branch_labels: None = None
|
||||
depends_on: None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("file_store", sa.Column("display_name", sa.String(), nullable=True))
|
||||
op.add_column(
|
||||
"file_store",
|
||||
sa.Column(
|
||||
"file_origin",
|
||||
sa.String(),
|
||||
nullable=False,
|
||||
server_default="connector", # Default to connector
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"file_store",
|
||||
sa.Column(
|
||||
"file_type", sa.String(), nullable=False, server_default="text/plain"
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"file_store",
|
||||
sa.Column(
|
||||
"file_metadata",
|
||||
postgresql.JSONB(astext_type=sa.Text()),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE file_store
|
||||
SET file_origin = CASE
|
||||
WHEN file_name LIKE 'chat__%' THEN 'chat_upload'
|
||||
ELSE 'connector'
|
||||
END,
|
||||
file_name = CASE
|
||||
WHEN file_name LIKE 'chat__%' THEN SUBSTR(file_name, 7)
|
||||
ELSE file_name
|
||||
END,
|
||||
file_type = CASE
|
||||
WHEN file_name LIKE 'chat__%' THEN 'image/png'
|
||||
ELSE 'text/plain'
|
||||
END
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("file_store", "file_metadata")
|
||||
op.drop_column("file_store", "file_type")
|
||||
op.drop_column("file_store", "file_origin")
|
||||
op.drop_column("file_store", "display_name")
|
@ -16,6 +16,7 @@ from danswer.chat.models import StreamingError
|
||||
from danswer.configs.chat_configs import CHAT_TARGET_CHUNK_PERCENTAGE
|
||||
from danswer.configs.chat_configs import MAX_CHUNKS_FED_TO_CHAT
|
||||
from danswer.configs.constants import MessageType
|
||||
from danswer.db.chat import attach_files_to_chat_message
|
||||
from danswer.db.chat import create_db_search_doc
|
||||
from danswer.db.chat import create_new_chat_message
|
||||
from danswer.db.chat import get_chat_message
|
||||
@ -240,6 +241,7 @@ def stream_chat_message_objects(
|
||||
else:
|
||||
parent_message = root_message
|
||||
|
||||
user_message = None
|
||||
if not use_existing_user_message:
|
||||
# Create new message at the right place in the tree and update the parent's child pointer
|
||||
# Don't commit yet until we verify the chat message chain
|
||||
@ -250,10 +252,7 @@ def stream_chat_message_objects(
|
||||
message=message_text,
|
||||
token_count=len(llm_tokenizer_encode_func(message_text)),
|
||||
message_type=MessageType.USER,
|
||||
files=[
|
||||
{"id": str(file_id), "type": ChatFileType.IMAGE}
|
||||
for file_id in new_msg_req.file_ids
|
||||
],
|
||||
files=None, # Need to attach later for optimization to only load files once in parallel
|
||||
db_session=db_session,
|
||||
commit=False,
|
||||
)
|
||||
@ -283,11 +282,24 @@ def stream_chat_message_objects(
|
||||
)
|
||||
|
||||
# load all files needed for this chat chain in memory
|
||||
files = load_all_chat_files(history_msgs, new_msg_req.file_ids, db_session)
|
||||
files = load_all_chat_files(
|
||||
history_msgs, new_msg_req.file_descriptors, db_session
|
||||
)
|
||||
latest_query_files = [
|
||||
file for file in files if file.file_id in new_msg_req.file_ids
|
||||
file
|
||||
for file in files
|
||||
if file.file_id in [f["id"] for f in new_msg_req.file_descriptors]
|
||||
]
|
||||
|
||||
if user_message:
|
||||
attach_files_to_chat_message(
|
||||
chat_message=user_message,
|
||||
files=[
|
||||
new_file.to_file_descriptor() for new_file in latest_query_files
|
||||
],
|
||||
db_session=db_session,
|
||||
)
|
||||
|
||||
selected_db_search_docs = None
|
||||
selected_llm_docs: list[LlmDoc] | None = None
|
||||
if reference_doc_ids:
|
||||
|
@ -130,3 +130,9 @@ class TokenRateLimitScope(str, Enum):
|
||||
USER = "user"
|
||||
USER_GROUP = "user_group"
|
||||
GLOBAL = "global"
|
||||
|
||||
|
||||
class FileOrigin(str, Enum):
|
||||
CHAT_UPLOAD = "chat_upload"
|
||||
CHAT_IMAGE_GEN = "chat_image_gen"
|
||||
CONNECTOR = "connector"
|
||||
|
@ -319,6 +319,17 @@ def set_as_latest_chat_message(
|
||||
db_session.commit()
|
||||
|
||||
|
||||
def attach_files_to_chat_message(
|
||||
chat_message: ChatMessage,
|
||||
files: list[FileDescriptor],
|
||||
db_session: Session,
|
||||
commit: bool = True,
|
||||
) -> None:
|
||||
chat_message.files = files
|
||||
if commit:
|
||||
db_session.commit()
|
||||
|
||||
|
||||
def get_prompt_by_id(
|
||||
prompt_id: int,
|
||||
user: User | None,
|
||||
|
@ -35,6 +35,7 @@ from sqlalchemy.types import TypeDecorator
|
||||
from danswer.auth.schemas import UserRole
|
||||
from danswer.configs.constants import DEFAULT_BOOST
|
||||
from danswer.configs.constants import DocumentSource
|
||||
from danswer.configs.constants import FileOrigin
|
||||
from danswer.configs.constants import MessageType
|
||||
from danswer.configs.constants import SearchFeedbackType
|
||||
from danswer.configs.constants import TokenRateLimitScope
|
||||
@ -1071,8 +1072,13 @@ class KVStore(Base):
|
||||
|
||||
class PGFileStore(Base):
|
||||
__tablename__ = "file_store"
|
||||
file_name = mapped_column(String, primary_key=True)
|
||||
lobj_oid = mapped_column(Integer, nullable=False)
|
||||
|
||||
file_name: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
display_name: Mapped[str] = mapped_column(String, nullable=True)
|
||||
file_origin: Mapped[FileOrigin] = mapped_column(Enum(FileOrigin, native_enum=False))
|
||||
file_type: Mapped[str] = mapped_column(String, default="text/plain")
|
||||
file_metadata: Mapped[JSON_ro] = mapped_column(postgresql.JSONB(), nullable=True)
|
||||
lobj_oid: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
|
||||
|
||||
"""
|
||||
|
@ -4,6 +4,7 @@ from typing import IO
|
||||
from psycopg2.extensions import connection
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from danswer.configs.constants import FileOrigin
|
||||
from danswer.db.models import PGFileStore
|
||||
from danswer.utils.logger import setup_logger
|
||||
|
||||
@ -48,7 +49,14 @@ def delete_lobj_by_id(
|
||||
|
||||
|
||||
def upsert_pgfilestore(
|
||||
file_name: str, lobj_oid: int, db_session: Session, commit: bool = False
|
||||
file_name: str,
|
||||
display_name: str | None,
|
||||
file_origin: FileOrigin,
|
||||
file_type: str,
|
||||
lobj_oid: int,
|
||||
db_session: Session,
|
||||
commit: bool = False,
|
||||
file_metadata: dict | None = None,
|
||||
) -> PGFileStore:
|
||||
pgfilestore = db_session.query(PGFileStore).filter_by(file_name=file_name).first()
|
||||
|
||||
@ -65,7 +73,14 @@ def upsert_pgfilestore(
|
||||
|
||||
pgfilestore.lobj_oid = lobj_oid
|
||||
else:
|
||||
pgfilestore = PGFileStore(file_name=file_name, lobj_oid=lobj_oid)
|
||||
pgfilestore = PGFileStore(
|
||||
file_name=file_name,
|
||||
display_name=display_name,
|
||||
file_origin=file_origin,
|
||||
file_type=file_type,
|
||||
file_metadata=file_metadata,
|
||||
lobj_oid=lobj_oid,
|
||||
)
|
||||
db_session.add(pgfilestore)
|
||||
|
||||
if commit:
|
||||
|
@ -254,9 +254,12 @@ def file_io_to_text(file: IO[Any]) -> str:
|
||||
|
||||
|
||||
def extract_file_text(
|
||||
file_name: str,
|
||||
file_name: str | None,
|
||||
file: IO[Any],
|
||||
) -> str:
|
||||
if not file_name:
|
||||
return file_io_to_text(file)
|
||||
|
||||
extension = get_file_ext(file_name)
|
||||
if not check_file_ext_is_valid(extension):
|
||||
raise RuntimeError("Unprocessable file type")
|
||||
|
@ -4,6 +4,7 @@ from typing import IO
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from danswer.configs.constants import FileOrigin
|
||||
from danswer.db.pg_file_store import create_populate_lobj
|
||||
from danswer.db.pg_file_store import delete_lobj_by_id
|
||||
from danswer.db.pg_file_store import delete_pgfilestore_by_file_name
|
||||
@ -18,7 +19,14 @@ class FileStore(ABC):
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def save_file(self, file_name: str, content: IO) -> None:
|
||||
def save_file(
|
||||
self,
|
||||
file_name: str,
|
||||
content: IO,
|
||||
display_name: str | None,
|
||||
file_origin: FileOrigin,
|
||||
file_type: str,
|
||||
) -> None:
|
||||
"""
|
||||
Save a file to the blob store
|
||||
|
||||
@ -26,6 +34,9 @@ class FileStore(ABC):
|
||||
- connector_name: Name of the CC-Pair (as specified by the user in the UI)
|
||||
- file_name: Name of the file to save
|
||||
- content: Contents of the file
|
||||
- display_name: Display name of the file
|
||||
- file_origin: Origin of the file
|
||||
- file_type: Type of the file
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@ -55,13 +66,25 @@ class PostgresBackedFileStore(FileStore):
|
||||
def __init__(self, db_session: Session):
|
||||
self.db_session = db_session
|
||||
|
||||
def save_file(self, file_name: str, content: IO) -> None:
|
||||
def save_file(
|
||||
self,
|
||||
file_name: str,
|
||||
content: IO,
|
||||
display_name: str | None,
|
||||
file_origin: FileOrigin,
|
||||
file_type: str,
|
||||
) -> None:
|
||||
try:
|
||||
# The large objects in postgres are saved as special objects can can be listed with
|
||||
# The large objects in postgres are saved as special objects can be listed with
|
||||
# SELECT * FROM pg_largeobject_metadata;
|
||||
obj_id = create_populate_lobj(content=content, db_session=self.db_session)
|
||||
upsert_pgfilestore(
|
||||
file_name=file_name, lobj_oid=obj_id, db_session=self.db_session
|
||||
file_name=file_name,
|
||||
display_name=display_name or file_name,
|
||||
file_origin=file_origin,
|
||||
file_type=file_type,
|
||||
lobj_oid=obj_id,
|
||||
db_session=self.db_session,
|
||||
)
|
||||
self.db_session.commit()
|
||||
except Exception:
|
||||
|
@ -1,13 +1,18 @@
|
||||
import base64
|
||||
from enum import Enum
|
||||
from typing import NotRequired
|
||||
from typing import TypedDict
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ChatFileType(str, Enum):
|
||||
# Image types only contain the binary data
|
||||
IMAGE = "image"
|
||||
# Doc types are saved as both the binary, and the parsed text
|
||||
DOC = "document"
|
||||
# Plain text only contain the text
|
||||
PLAIN_TEXT = "plain_text"
|
||||
|
||||
|
||||
class FileDescriptor(TypedDict):
|
||||
@ -16,18 +21,26 @@ class FileDescriptor(TypedDict):
|
||||
|
||||
id: str
|
||||
type: ChatFileType
|
||||
name: NotRequired[str | None]
|
||||
|
||||
|
||||
class InMemoryChatFile(BaseModel):
|
||||
file_id: UUID
|
||||
file_id: str
|
||||
content: bytes
|
||||
file_type: ChatFileType = ChatFileType.IMAGE
|
||||
file_type: ChatFileType
|
||||
filename: str | None = None
|
||||
|
||||
def to_base64(self) -> str:
|
||||
if self.file_type == ChatFileType.IMAGE:
|
||||
return base64.b64encode(self.content).decode()
|
||||
else:
|
||||
raise RuntimeError(
|
||||
"Should not be trying to convert a non-image file to base64"
|
||||
)
|
||||
|
||||
def to_file_descriptor(self) -> FileDescriptor:
|
||||
return {
|
||||
"id": str(self.file_id),
|
||||
"type": self.file_type,
|
||||
"name": self.filename,
|
||||
}
|
||||
|
@ -1,50 +1,56 @@
|
||||
from io import BytesIO
|
||||
from typing import cast
|
||||
from uuid import UUID
|
||||
from uuid import uuid4
|
||||
|
||||
import requests
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from danswer.configs.constants import FileOrigin
|
||||
from danswer.db.engine import get_session_context_manager
|
||||
from danswer.db.models import ChatMessage
|
||||
from danswer.file_store.file_store import get_default_file_store
|
||||
from danswer.file_store.models import FileDescriptor
|
||||
from danswer.file_store.models import InMemoryChatFile
|
||||
from danswer.utils.threadpool_concurrency import run_functions_tuples_in_parallel
|
||||
|
||||
|
||||
def build_chat_file_name(file_id: UUID | str) -> str:
|
||||
return f"chat__{file_id}"
|
||||
|
||||
|
||||
def load_chat_file(file_id: UUID, db_session: Session) -> InMemoryChatFile:
|
||||
def load_chat_file(
|
||||
file_descriptor: FileDescriptor, db_session: Session
|
||||
) -> InMemoryChatFile:
|
||||
file_io = get_default_file_store(db_session).read_file(
|
||||
build_chat_file_name(file_id), mode="b"
|
||||
file_descriptor["id"], mode="b"
|
||||
)
|
||||
return InMemoryChatFile(
|
||||
file_id=file_descriptor["id"],
|
||||
content=file_io.read(),
|
||||
file_type=file_descriptor["type"],
|
||||
filename=file_descriptor["name"],
|
||||
)
|
||||
return InMemoryChatFile(file_id=file_id, content=file_io.read())
|
||||
|
||||
|
||||
def load_all_chat_files(
|
||||
chat_messages: list[ChatMessage], new_file_ids: list[UUID], db_session: Session
|
||||
chat_messages: list[ChatMessage],
|
||||
file_descriptors: list[FileDescriptor],
|
||||
db_session: Session,
|
||||
) -> list[InMemoryChatFile]:
|
||||
file_ids_for_history = []
|
||||
file_descriptors_for_history: list[FileDescriptor] = []
|
||||
for chat_message in chat_messages:
|
||||
if chat_message.files:
|
||||
file_ids_for_history.extend([file["id"] for file in chat_message.files])
|
||||
file_descriptors_for_history.extend(chat_message.files)
|
||||
|
||||
files = cast(
|
||||
list[InMemoryChatFile],
|
||||
run_functions_tuples_in_parallel(
|
||||
[
|
||||
(load_chat_file, (file_id, db_session))
|
||||
for file_id in new_file_ids + file_ids_for_history
|
||||
(load_chat_file, (file, db_session))
|
||||
for file in file_descriptors + file_descriptors_for_history
|
||||
]
|
||||
),
|
||||
)
|
||||
return files
|
||||
|
||||
|
||||
def save_file_from_url(url: str) -> UUID:
|
||||
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
|
||||
weird errors."""
|
||||
@ -52,15 +58,20 @@ def save_file_from_url(url: str) -> UUID:
|
||||
response = requests.get(url)
|
||||
response.raise_for_status()
|
||||
|
||||
file_id = uuid4()
|
||||
file_name = build_chat_file_name(file_id)
|
||||
unique_id = str(uuid4())
|
||||
|
||||
file_io = BytesIO(response.content)
|
||||
file_store = get_default_file_store(db_session)
|
||||
file_store.save_file(file_name=file_name, content=file_io)
|
||||
return file_id
|
||||
file_store.save_file(
|
||||
file_name=unique_id,
|
||||
content=file_io,
|
||||
display_name="GeneratedImage",
|
||||
file_origin=FileOrigin.CHAT_IMAGE_GEN,
|
||||
file_type="image/png;base64",
|
||||
)
|
||||
return unique_id
|
||||
|
||||
|
||||
def save_files_from_urls(urls: list[str]) -> list[UUID]:
|
||||
def save_files_from_urls(urls: list[str]) -> list[str]:
|
||||
funcs = [(save_file_from_url, (url,)) for url in urls]
|
||||
return run_functions_tuples_in_parallel(funcs)
|
||||
|
@ -86,6 +86,7 @@ class Answer:
|
||||
message_history: list[PreviousMessage] | None = None,
|
||||
single_message_history: str | None = None,
|
||||
# newly passed in files to include as part of this question
|
||||
# TODO THIS NEEDS TO BE HANDLED
|
||||
latest_query_files: list[InMemoryChatFile] | None = None,
|
||||
files: list[InMemoryChatFile] | None = None,
|
||||
tools: list[Tool] | None = None,
|
||||
|
@ -24,8 +24,10 @@ from danswer.configs.model_configs import GEN_AI_MAX_OUTPUT_TOKENS
|
||||
from danswer.configs.model_configs import GEN_AI_MAX_TOKENS
|
||||
from danswer.configs.model_configs import GEN_AI_MODEL_PROVIDER
|
||||
from danswer.db.models import ChatMessage
|
||||
from danswer.file_store.models import ChatFileType
|
||||
from danswer.file_store.models import InMemoryChatFile
|
||||
from danswer.llm.interfaces import LLM
|
||||
from danswer.prompts.constants import CODE_BLOCK_PAT
|
||||
from danswer.search.models import InferenceChunk
|
||||
from danswer.utils.logger import setup_logger
|
||||
from shared_configs.configs import LOG_LEVEL
|
||||
@ -113,23 +115,50 @@ def translate_history_to_basemessages(
|
||||
return history_basemessages, history_token_counts
|
||||
|
||||
|
||||
def _build_content(
|
||||
message: str,
|
||||
files: list[InMemoryChatFile] | None = None,
|
||||
) -> str:
|
||||
"""Applies all non-image files."""
|
||||
text_files = (
|
||||
[file for file in files if file.file_type == ChatFileType.PLAIN_TEXT]
|
||||
if files
|
||||
else None
|
||||
)
|
||||
if not text_files:
|
||||
return message
|
||||
|
||||
final_message_with_files = "FILES:\n\n"
|
||||
for file in text_files:
|
||||
file_content = file.content.decode("utf-8")
|
||||
file_name_section = f"DOCUMENT: {file.filename}\n" if file.filename else ""
|
||||
final_message_with_files += (
|
||||
f"{file_name_section}{CODE_BLOCK_PAT.format(file_content.strip())}\n\n\n"
|
||||
)
|
||||
final_message_with_files += message
|
||||
|
||||
return final_message_with_files
|
||||
|
||||
|
||||
def build_content_with_imgs(
|
||||
message: str,
|
||||
files: list[InMemoryChatFile] | None = None,
|
||||
img_urls: list[str] | None = None,
|
||||
) -> str | list[str | dict[str, Any]]: # matching Langchain's BaseMessage content type
|
||||
if not files and not img_urls:
|
||||
return message
|
||||
|
||||
files = files or []
|
||||
img_files = [file for file in files if file.file_type == ChatFileType.IMAGE]
|
||||
img_urls = img_urls or []
|
||||
message_main_content = _build_content(message, files)
|
||||
|
||||
if not img_files and not img_urls:
|
||||
return message_main_content
|
||||
|
||||
return cast(
|
||||
list[str | dict[str, Any]],
|
||||
[
|
||||
{
|
||||
"type": "text",
|
||||
"text": message,
|
||||
"text": message_main_content,
|
||||
},
|
||||
]
|
||||
+ [
|
||||
|
@ -16,6 +16,7 @@ from danswer.auth.users import current_user
|
||||
from danswer.background.celery.celery_utils import get_deletion_status
|
||||
from danswer.configs.app_configs import ENABLED_CONNECTOR_TYPES
|
||||
from danswer.configs.constants import DocumentSource
|
||||
from danswer.configs.constants import FileOrigin
|
||||
from danswer.connectors.gmail.connector_auth import delete_gmail_service_account_key
|
||||
from danswer.connectors.gmail.connector_auth import delete_google_app_gmail_cred
|
||||
from danswer.connectors.gmail.connector_auth import get_gmail_auth_url
|
||||
@ -351,7 +352,13 @@ def upload_files(
|
||||
for file in files:
|
||||
file_path = os.path.join(str(uuid.uuid4()), cast(str, file.filename))
|
||||
deduped_file_paths.append(file_path)
|
||||
file_store.save_file(file_name=file_path, content=file.file)
|
||||
file_store.save_file(
|
||||
file_name=file_path,
|
||||
content=file.file,
|
||||
display_name=file.filename,
|
||||
file_origin=FileOrigin.CONNECTOR,
|
||||
file_type=file.content_type or "text/plain",
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
return FileUploadResponse(file_paths=deduped_file_paths)
|
||||
|
@ -1,3 +1,4 @@
|
||||
import io
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter
|
||||
@ -13,6 +14,7 @@ from danswer.auth.users import current_user
|
||||
from danswer.chat.chat_utils import create_chat_chain
|
||||
from danswer.chat.process_message import stream_chat_message
|
||||
from danswer.configs.app_configs import WEB_DOMAIN
|
||||
from danswer.configs.constants import FileOrigin
|
||||
from danswer.configs.constants import MessageType
|
||||
from danswer.db.chat import create_chat_session
|
||||
from danswer.db.chat import create_new_chat_message
|
||||
@ -32,8 +34,10 @@ from danswer.db.feedback import create_doc_retrieval_feedback
|
||||
from danswer.db.models import User
|
||||
from danswer.document_index.document_index_utils import get_both_index_names
|
||||
from danswer.document_index.factory import get_default_document_index
|
||||
from danswer.file_processing.extract_file_text import extract_file_text
|
||||
from danswer.file_store.file_store import get_default_file_store
|
||||
from danswer.file_store.utils import build_chat_file_name
|
||||
from danswer.file_store.models import ChatFileType
|
||||
from danswer.file_store.models import FileDescriptor
|
||||
from danswer.llm.answering.prompts.citations_prompt import (
|
||||
compute_max_document_tokens_for_persona,
|
||||
)
|
||||
@ -422,15 +426,51 @@ def upload_files_for_chat(
|
||||
files: list[UploadFile],
|
||||
db_session: Session = Depends(get_session),
|
||||
_: User | None = Depends(current_user),
|
||||
) -> dict[str, list[uuid.UUID]]:
|
||||
for file in files:
|
||||
if file.content_type not in ("image/jpeg", "image/png", "image/webp"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Only .jpg, .jpeg, .png, and .webp files are currently supported",
|
||||
) -> dict[str, list[FileDescriptor]]:
|
||||
image_content_types = {"image/jpeg", "image/png", "image/webp"}
|
||||
text_content_types = {
|
||||
"text/plain",
|
||||
"text/csv",
|
||||
"text/markdown",
|
||||
"text/x-markdown",
|
||||
"text/x-config",
|
||||
"text/tab-separated-values",
|
||||
"application/json",
|
||||
"application/xml",
|
||||
"application/x-yaml",
|
||||
}
|
||||
document_content_types = {
|
||||
"application/pdf",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"message/rfc822",
|
||||
"application/epub+zip",
|
||||
}
|
||||
|
||||
allowed_content_types = image_content_types.union(text_content_types).union(
|
||||
document_content_types
|
||||
)
|
||||
|
||||
if file.size and file.size > 20 * 1024 * 1024:
|
||||
for file in files:
|
||||
if file.content_type not in allowed_content_types:
|
||||
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."
|
||||
else:
|
||||
error_detail = (
|
||||
"Unsupported document file type. Supported document types include .pdf, .docx, .pptx, .xlsx, "
|
||||
".json, .xml, .yml, .yaml, .eml, .epub."
|
||||
)
|
||||
raise HTTPException(status_code=400, detail=error_detail)
|
||||
|
||||
if (
|
||||
file.content_type in image_content_types
|
||||
and file.size
|
||||
and file.size > 20 * 1024 * 1024
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="File size must be less than 20MB",
|
||||
@ -438,14 +478,50 @@ def upload_files_for_chat(
|
||||
|
||||
file_store = get_default_file_store(db_session)
|
||||
|
||||
file_ids = []
|
||||
file_info: list[tuple[str, str | None, ChatFileType]] = []
|
||||
for file in files:
|
||||
file_id = uuid.uuid4()
|
||||
file_name = build_chat_file_name(file_id)
|
||||
file_store.save_file(file_name=file_name, content=file.file)
|
||||
file_ids.append(file_id)
|
||||
if file.content_type in image_content_types:
|
||||
file_type = ChatFileType.IMAGE
|
||||
elif file.content_type in document_content_types:
|
||||
file_type = ChatFileType.DOC
|
||||
else:
|
||||
file_type = ChatFileType.PLAIN_TEXT
|
||||
|
||||
return {"file_ids": file_ids}
|
||||
# store the raw file
|
||||
file_id = str(uuid.uuid4())
|
||||
file_store.save_file(
|
||||
file_name=file_id,
|
||||
content=file.file,
|
||||
display_name=file.filename,
|
||||
file_origin=FileOrigin.CHAT_UPLOAD,
|
||||
file_type=file.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
|
||||
if file_type == ChatFileType.DOC:
|
||||
extracted_text = extract_file_text(file_name=file.filename, file=file.file)
|
||||
text_file_id = str(uuid.uuid4())
|
||||
file_store.save_file(
|
||||
file_name=text_file_id,
|
||||
content=io.BytesIO(extracted_text.encode()),
|
||||
display_name=file.filename,
|
||||
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
|
||||
file_info.append((text_file_id, file.filename, ChatFileType.PLAIN_TEXT))
|
||||
else:
|
||||
file_info.append((file_id, file.filename, file_type))
|
||||
|
||||
return {
|
||||
"files": [
|
||||
{"id": file_id, "type": file_type, "name": file_name}
|
||||
for file_id, file_name, file_type in file_info
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/file/{file_id}")
|
||||
@ -455,7 +531,7 @@ def fetch_chat_file(
|
||||
_: User | None = Depends(current_user),
|
||||
) -> Response:
|
||||
file_store = get_default_file_store(db_session)
|
||||
file_io = file_store.read_file(build_chat_file_name(file_id), mode="b")
|
||||
file_io = file_store.read_file(file_id, mode="b")
|
||||
# NOTE: specifying "image/jpeg" here, but it still works for pngs
|
||||
# TODO: do this properly
|
||||
return Response(content=file_io.read(), media_type="image/jpeg")
|
||||
|
@ -1,6 +1,5 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import root_validator
|
||||
@ -85,7 +84,7 @@ class CreateChatMessageRequest(ChunkContext):
|
||||
# New message contents
|
||||
message: str
|
||||
# file's that we should attach to this message
|
||||
file_ids: list[UUID]
|
||||
file_descriptors: list[FileDescriptor]
|
||||
# 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
|
||||
# Use prompt_id 0 to use the system default prompt which is Answer-Question
|
||||
|
208
web/package-lock.json
generated
208
web/package-lock.json
generated
@ -14,12 +14,14 @@
|
||||
"@phosphor-icons/react": "^2.0.8",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"@tremor/react": "^3.9.2",
|
||||
"@types/js-cookie": "^3.0.3",
|
||||
"@types/lodash": "^4.17.0",
|
||||
"@types/node": "18.15.11",
|
||||
"@types/react": "18.0.32",
|
||||
"@types/react-dom": "18.0.11",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"formik": "^2.2.9",
|
||||
"js-cookie": "^3.0.5",
|
||||
@ -40,6 +42,7 @@
|
||||
"swr": "^2.1.5",
|
||||
"tailwindcss": "^3.3.1",
|
||||
"typescript": "5.0.3",
|
||||
"uuid": "^9.0.1",
|
||||
"yup": "^1.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -1383,6 +1386,40 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tooltip": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.0.7.tgz",
|
||||
"integrity": "sha512-lPh5iKNFVQ/jav/j6ZrWq3blfDJ0OH9R6FlNUHPMqdLuQ9vwDgFsRxvl8b7Asuy5c8xmoojHUxKHQSOAvMHxyw==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.13.10",
|
||||
"@radix-ui/primitive": "1.0.1",
|
||||
"@radix-ui/react-compose-refs": "1.0.1",
|
||||
"@radix-ui/react-context": "1.0.1",
|
||||
"@radix-ui/react-dismissable-layer": "1.0.5",
|
||||
"@radix-ui/react-id": "1.0.1",
|
||||
"@radix-ui/react-popper": "1.1.3",
|
||||
"@radix-ui/react-portal": "1.0.4",
|
||||
"@radix-ui/react-presence": "1.0.1",
|
||||
"@radix-ui/react-primitive": "1.0.3",
|
||||
"@radix-ui/react-slot": "1.0.2",
|
||||
"@radix-ui/react-use-controllable-state": "1.0.1",
|
||||
"@radix-ui/react-visually-hidden": "1.0.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz",
|
||||
@ -1489,6 +1526,29 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-visually-hidden": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.0.3.tgz",
|
||||
"integrity": "sha512-D4w41yN5YRKtu464TLnByKzMDG/JlMPHtfZgQAu9v6mNakUqGUI9vUrfQKz8NK41VMm/xbZbh76NUTVtIYqOMA==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.13.10",
|
||||
"@radix-ui/react-primitive": "1.0.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/rect": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.0.1.tgz",
|
||||
@ -1735,6 +1795,11 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.2.tgz",
|
||||
"integrity": "sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ=="
|
||||
},
|
||||
"node_modules/@types/uuid": {
|
||||
"version": "9.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz",
|
||||
"integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA=="
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.2.0.tgz",
|
||||
@ -2344,6 +2409,7 @@
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
||||
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
@ -2501,6 +2567,7 @@
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
@ -2657,7 +2724,8 @@
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="
|
||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
@ -4185,6 +4253,7 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
@ -5930,6 +5999,7 @@
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
@ -10216,6 +10286,7 @@
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
@ -10343,7 +10414,8 @@
|
||||
"node_modules/text-table": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
|
||||
"integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw=="
|
||||
"integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/thenify": {
|
||||
"version": "3.3.1",
|
||||
@ -10788,6 +10860,18 @@
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
|
||||
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/vfile": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.1.tgz",
|
||||
@ -11089,126 +11173,6 @@
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-x64": {
|
||||
"version": "14.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.3.tgz",
|
||||
"integrity": "sha512-6adp7waE6P1TYFSXpY366xwsOnEXM+y1kgRpjSRVI2CBDOcbRjsJ67Z6EgKIqWIue52d2q/Mx8g9MszARj8IEA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||
"version": "14.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.3.tgz",
|
||||
"integrity": "sha512-cuzCE/1G0ZSnTAHJPUT1rPgQx1w5tzSX7POXSLaS7w2nIUJUD+e25QoXD/hMfxbsT9rslEXugWypJMILBj/QsA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-musl": {
|
||||
"version": "14.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.3.tgz",
|
||||
"integrity": "sha512-0D4/oMM2Y9Ta3nGuCcQN8jjJjmDPYpHX9OJzqk42NZGJocU2MqhBq5tWkJrUQOQY9N+In9xOdymzapM09GeiZw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-gnu": {
|
||||
"version": "14.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.3.tgz",
|
||||
"integrity": "sha512-ENPiNnBNDInBLyUU5ii8PMQh+4XLr4pG51tOp6aJ9xqFQ2iRI6IH0Ds2yJkAzNV1CfyagcyzPfROMViS2wOZ9w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-musl": {
|
||||
"version": "14.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.3.tgz",
|
||||
"integrity": "sha512-BTAbq0LnCbF5MtoM7I/9UeUu/8ZBY0i8SFjUMCbPDOLv+un67e2JgyN4pmgfXBwy/I+RHu8q+k+MCkDN6P9ViQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||
"version": "14.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.3.tgz",
|
||||
"integrity": "sha512-AEHIw/dhAMLNFJFJIJIyOFDzrzI5bAjI9J26gbO5xhAKHYTZ9Or04BesFPXiAYXDNdrwTP2dQceYA4dL1geu8A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-ia32-msvc": {
|
||||
"version": "14.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.3.tgz",
|
||||
"integrity": "sha512-vga40n1q6aYb0CLrM+eEmisfKCR45ixQYXuBXxOOmmoV8sYST9k7E3US32FsY+CkkF7NtzdcebiFT4CHuMSyZw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-x64-msvc": {
|
||||
"version": "14.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.3.tgz",
|
||||
"integrity": "sha512-Q1/zm43RWynxrO7lW4ehciQVj+5ePBhOK+/K2P7pLFX3JaJ/IZVC69SHidrmZSOkqz7ECIOhhy7XhAFG4JYyHA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -15,12 +15,14 @@
|
||||
"@phosphor-icons/react": "^2.0.8",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"@tremor/react": "^3.9.2",
|
||||
"@types/js-cookie": "^3.0.3",
|
||||
"@types/lodash": "^4.17.0",
|
||||
"@types/node": "18.15.11",
|
||||
"@types/react": "18.0.32",
|
||||
"@types/react-dom": "18.0.11",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"formik": "^2.2.9",
|
||||
"js-cookie": "^3.0.5",
|
||||
@ -41,6 +43,7 @@
|
||||
"swr": "^2.1.5",
|
||||
"tailwindcss": "^3.3.1",
|
||||
"typescript": "5.0.3",
|
||||
"uuid": "^9.0.1",
|
||||
"yup": "^1.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
@ -4,6 +4,7 @@ import { useRouter, useSearchParams } from "next/navigation";
|
||||
import {
|
||||
BackendChatSession,
|
||||
BackendMessage,
|
||||
ChatFileType,
|
||||
ChatSession,
|
||||
ChatSessionSharedStatus,
|
||||
DocumentsResponse,
|
||||
@ -66,12 +67,13 @@ import { SettingsContext } from "@/components/settings/SettingsProvider";
|
||||
import Dropzone from "react-dropzone";
|
||||
import { LLMProviderDescriptor } from "../admin/models/llm/interfaces";
|
||||
import { checkLLMSupportsImageInput, getFinalLLM } from "@/lib/llm/utils";
|
||||
import { InputBarPreviewImage } from "./images/InputBarPreviewImage";
|
||||
import { InputBarPreviewImage } from "./files/images/InputBarPreviewImage";
|
||||
import { Folder } from "./folders/interfaces";
|
||||
import { ChatInputBar } from "./input/ChatInputBar";
|
||||
import { ConfigurationModal } from "./modal/configuration/ConfigurationModal";
|
||||
import { useChatContext } from "@/components/context/ChatContext";
|
||||
import { UserDropdown } from "@/components/UserDropdown";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
const MAX_INPUT_HEIGHT = 200;
|
||||
const TEMP_USER_MESSAGE_ID = -1;
|
||||
@ -131,7 +133,7 @@ export function ChatPage({
|
||||
useEffect(() => {
|
||||
urlChatSessionId.current = existingChatSessionId;
|
||||
|
||||
textareaRef.current?.focus();
|
||||
textAreaRef.current?.focus();
|
||||
|
||||
// only clear things if we're going from one chat session to another
|
||||
if (chatSessionId !== null && existingChatSessionId !== chatSessionId) {
|
||||
@ -150,7 +152,7 @@ export function ChatPage({
|
||||
});
|
||||
llmOverrideManager.setTemperature(null);
|
||||
// remove uploaded files
|
||||
setCurrentMessageFileIds([]);
|
||||
setCurrentMessageFiles([]);
|
||||
|
||||
if (isStreaming) {
|
||||
setIsCancelled(true);
|
||||
@ -317,9 +319,9 @@ export function ChatPage({
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
|
||||
// uploaded files
|
||||
const [currentMessageFileIds, setCurrentMessageFileIds] = useState<string[]>(
|
||||
[]
|
||||
);
|
||||
const [currentMessageFiles, setCurrentMessageFiles] = useState<
|
||||
FileDescriptor[]
|
||||
>([]);
|
||||
|
||||
// for document display
|
||||
// NOTE: -1 is a special designation that means the latest AI message
|
||||
@ -423,9 +425,9 @@ export function ChatPage({
|
||||
}, [isFetchingChatMessages]);
|
||||
|
||||
// handle re-sizing of the text area
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const textAreaRef = useRef<HTMLTextAreaElement>(null);
|
||||
useEffect(() => {
|
||||
const textarea = textareaRef.current;
|
||||
const textarea = textAreaRef.current;
|
||||
if (textarea) {
|
||||
textarea.style.height = "0px";
|
||||
textarea.style.height = `${Math.min(
|
||||
@ -528,10 +530,6 @@ export function ChatPage({
|
||||
(currMessageHistory.length > 0
|
||||
? currMessageHistory[currMessageHistory.length - 1]
|
||||
: null);
|
||||
const currFiles = currentMessageFileIds.map((id) => ({
|
||||
id,
|
||||
type: "image",
|
||||
})) as FileDescriptor[];
|
||||
|
||||
// if we're resending, set the parent's child to null
|
||||
// we will use tempMessages until the regenerated message is complete
|
||||
@ -540,7 +538,7 @@ export function ChatPage({
|
||||
messageId: TEMP_USER_MESSAGE_ID,
|
||||
message: currMessage,
|
||||
type: "user",
|
||||
files: currFiles,
|
||||
files: currentMessageFiles,
|
||||
parentMessageId: parentMessage?.messageId || null,
|
||||
},
|
||||
];
|
||||
@ -562,7 +560,7 @@ export function ChatPage({
|
||||
parentMessage = frozenCompleteMessageMap.get(SYSTEM_MESSAGE_ID) || null;
|
||||
}
|
||||
setMessage("");
|
||||
setCurrentMessageFileIds([]);
|
||||
setCurrentMessageFiles([]);
|
||||
|
||||
setIsStreaming(true);
|
||||
let answer = "";
|
||||
@ -580,7 +578,7 @@ export function ChatPage({
|
||||
getLastSuccessfulMessageId(currMessageHistory);
|
||||
for await (const packetBunch of sendMessage({
|
||||
message: currMessage,
|
||||
fileIds: currentMessageFileIds,
|
||||
fileDescriptors: currentMessageFiles,
|
||||
parentMessageId: lastSuccessfulMessageId,
|
||||
chatSessionId: currChatSessionId,
|
||||
promptId: livePersona?.prompts[0]?.id || 0,
|
||||
@ -628,7 +626,7 @@ export function ChatPage({
|
||||
(fileId) => {
|
||||
return {
|
||||
id: fileId,
|
||||
type: "image",
|
||||
type: ChatFileType.IMAGE,
|
||||
};
|
||||
}
|
||||
);
|
||||
@ -662,7 +660,7 @@ export function ChatPage({
|
||||
messageId: newUserMessageId,
|
||||
message: currMessage,
|
||||
type: "user",
|
||||
files: currFiles,
|
||||
files: currentMessageFiles,
|
||||
parentMessageId: parentMessage?.messageId || null,
|
||||
childrenMessageIds: [newAssistantMessageId],
|
||||
latestChildMessageId: newAssistantMessageId,
|
||||
@ -692,7 +690,7 @@ export function ChatPage({
|
||||
messageId: TEMP_USER_MESSAGE_ID,
|
||||
message: currMessage,
|
||||
type: "user",
|
||||
files: currFiles,
|
||||
files: currentMessageFiles,
|
||||
parentMessageId: null,
|
||||
},
|
||||
{
|
||||
@ -769,10 +767,10 @@ export function ChatPage({
|
||||
const onPersonaChange = (persona: Persona | null) => {
|
||||
if (persona && persona.id !== livePersona.id) {
|
||||
// remove uploaded files
|
||||
setCurrentMessageFileIds([]);
|
||||
setCurrentMessageFiles([]);
|
||||
|
||||
setSelectedPersona(persona);
|
||||
textareaRef.current?.focus();
|
||||
textAreaRef.current?.focus();
|
||||
router.push(buildChatUrl(searchParams, null, persona.id));
|
||||
}
|
||||
};
|
||||
@ -781,7 +779,10 @@ export function ChatPage({
|
||||
const llmAcceptsImages = checkLLMSupportsImageInput(
|
||||
...getFinalLLM(llmProviders, livePersona)
|
||||
);
|
||||
if (!llmAcceptsImages) {
|
||||
const imageFiles = acceptedFiles.filter((file) =>
|
||||
file.type.startsWith("image/")
|
||||
);
|
||||
if (imageFiles.length > 0 && !llmAcceptsImages) {
|
||||
setPopup({
|
||||
type: "error",
|
||||
message:
|
||||
@ -790,15 +791,35 @@ export function ChatPage({
|
||||
return;
|
||||
}
|
||||
|
||||
uploadFilesForChat(acceptedFiles).then(([fileIds, error]) => {
|
||||
const tempFileDescriptors = acceptedFiles.map((file) => ({
|
||||
id: uuidv4(),
|
||||
type: file.type.startsWith("image/")
|
||||
? ChatFileType.IMAGE
|
||||
: ChatFileType.DOCUMENT,
|
||||
isUploading: true,
|
||||
}));
|
||||
|
||||
// only show loading spinner for reasonably large files
|
||||
const totalSize = acceptedFiles.reduce((sum, file) => sum + file.size, 0);
|
||||
if (totalSize > 50 * 1024) {
|
||||
setCurrentMessageFiles((prev) => [...prev, ...tempFileDescriptors]);
|
||||
}
|
||||
|
||||
const removeTempFiles = (prev: FileDescriptor[]) => {
|
||||
return prev.filter(
|
||||
(file) => !tempFileDescriptors.some((newFile) => newFile.id === file.id)
|
||||
);
|
||||
};
|
||||
|
||||
uploadFilesForChat(acceptedFiles).then(([files, error]) => {
|
||||
if (error) {
|
||||
setCurrentMessageFiles((prev) => removeTempFiles(prev));
|
||||
setPopup({
|
||||
type: "error",
|
||||
message: error,
|
||||
});
|
||||
} else {
|
||||
const newFileIds = [...currentMessageFileIds, ...fileIds];
|
||||
setCurrentMessageFileIds(newFileIds);
|
||||
setCurrentMessageFiles((prev) => [...removeTempFiles(prev), ...files]);
|
||||
}
|
||||
});
|
||||
};
|
||||
@ -884,11 +905,12 @@ export function ChatPage({
|
||||
>
|
||||
{/* <input {...getInputProps()} /> */}
|
||||
<div
|
||||
className={`w-full h-full pt-2 flex flex-col overflow-y-auto overflow-x-hidden relative`}
|
||||
className={`w-full h-full flex flex-col overflow-y-auto overflow-x-hidden relative`}
|
||||
ref={scrollableDivRef}
|
||||
>
|
||||
{livePersona && (
|
||||
<div className="sticky top-0 left-80 z-10 w-full bg-background/90 flex">
|
||||
<div className="sticky top-0 left-80 z-10 w-full bg-background flex">
|
||||
<div className="mt-2 flex w-full">
|
||||
<div className="ml-2 p-1 rounded w-fit">
|
||||
<ChatPersonaSelector
|
||||
personas={availablePersonas}
|
||||
@ -919,6 +941,7 @@ export function ChatPage({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messageHistory.length === 0 &&
|
||||
@ -930,7 +953,7 @@ export function ChatPage({
|
||||
selectedPersona={selectedPersona}
|
||||
handlePersonaSelect={(persona) => {
|
||||
setSelectedPersona(persona);
|
||||
textareaRef.current?.focus();
|
||||
textAreaRef.current?.focus();
|
||||
router.push(
|
||||
buildChatUrl(searchParams, null, persona.id)
|
||||
);
|
||||
@ -1149,7 +1172,7 @@ export function ChatPage({
|
||||
)}
|
||||
|
||||
{/* Some padding at the bottom so the search bar has space at the bottom to not cover the last message*/}
|
||||
<div className={`min-h-[30px] w-full`}></div>
|
||||
<div className={`min-h-[100px] w-full`}></div>
|
||||
|
||||
{livePersona &&
|
||||
livePersona.starter_messages &&
|
||||
@ -1206,10 +1229,11 @@ export function ChatPage({
|
||||
filterManager={filterManager}
|
||||
llmOverrideManager={llmOverrideManager}
|
||||
selectedAssistant={livePersona}
|
||||
fileIds={currentMessageFileIds}
|
||||
setFileIds={setCurrentMessageFileIds}
|
||||
files={currentMessageFiles}
|
||||
setFiles={setCurrentMessageFiles}
|
||||
handleFileUpload={handleImageUpload}
|
||||
setConfigModalActiveTab={setConfigModalActiveTab}
|
||||
textAreaRef={textAreaRef}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
73
web/src/app/chat/files/InputBarPreview.tsx
Normal file
73
web/src/app/chat/files/InputBarPreview.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
import { useState } from "react";
|
||||
import { ChatFileType, FileDescriptor } from "../interfaces";
|
||||
import { DocumentPreview } from "./documents/DocumentPreview";
|
||||
import { InputBarPreviewImage } from "./images/InputBarPreviewImage";
|
||||
import { FiX, FiLoader } from "react-icons/fi";
|
||||
|
||||
function DeleteButton({ onDelete }: { onDelete: () => void }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="
|
||||
absolute
|
||||
-top-1
|
||||
-right-1
|
||||
cursor-pointer
|
||||
border-none
|
||||
bg-hover
|
||||
p-1
|
||||
rounded-full
|
||||
z-10
|
||||
"
|
||||
>
|
||||
<FiX />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function InputBarPreview({
|
||||
file,
|
||||
onDelete,
|
||||
isUploading,
|
||||
}: {
|
||||
file: FileDescriptor;
|
||||
onDelete: () => void;
|
||||
isUploading: boolean;
|
||||
}) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
const renderContent = () => {
|
||||
if (file.type === ChatFileType.IMAGE) {
|
||||
return <InputBarPreviewImage fileId={file.id} />;
|
||||
}
|
||||
return <DocumentPreview fileName={file.name || file.id} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{isHovered && <DeleteButton onDelete={onDelete} />}
|
||||
{isUploading && (
|
||||
<div
|
||||
className="
|
||||
absolute
|
||||
inset-0
|
||||
flex
|
||||
items-center
|
||||
justify-center
|
||||
bg-black
|
||||
bg-opacity-50
|
||||
rounded-lg
|
||||
z-0
|
||||
"
|
||||
>
|
||||
<FiLoader className="animate-spin text-white" />
|
||||
</div>
|
||||
)}
|
||||
{renderContent()}
|
||||
</div>
|
||||
);
|
||||
}
|
67
web/src/app/chat/files/documents/DocumentPreview.tsx
Normal file
67
web/src/app/chat/files/documents/DocumentPreview.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
import { FiFileText } from "react-icons/fi";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Tooltip } from "@/components/tooltip/Tooltip";
|
||||
|
||||
export function DocumentPreview({
|
||||
fileName,
|
||||
maxWidth,
|
||||
}: {
|
||||
fileName: string;
|
||||
maxWidth?: string;
|
||||
}) {
|
||||
const [isOverflowing, setIsOverflowing] = useState(false);
|
||||
const fileNameRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (fileNameRef.current) {
|
||||
setIsOverflowing(
|
||||
fileNameRef.current.scrollWidth > fileNameRef.current.clientWidth
|
||||
);
|
||||
}
|
||||
}, [fileName]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="
|
||||
flex
|
||||
items-center
|
||||
p-2
|
||||
bg-hover-light
|
||||
border
|
||||
border-border
|
||||
rounded-md
|
||||
box-border
|
||||
h-16
|
||||
"
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<div
|
||||
className="
|
||||
w-12
|
||||
h-12
|
||||
bg-document
|
||||
flex
|
||||
items-center
|
||||
justify-center
|
||||
rounded-md
|
||||
"
|
||||
>
|
||||
<FiFileText className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-4 relative">
|
||||
<Tooltip content={fileName} side="top" align="start">
|
||||
<div
|
||||
ref={fileNameRef}
|
||||
className={`font-medium text-sm truncate ${
|
||||
maxWidth ? maxWidth : "max-w-48"
|
||||
}`}
|
||||
>
|
||||
{fileName}
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div className="text-subtle text-sm">Document</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,18 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { FiX } from "react-icons/fi";
|
||||
import { buildImgUrl } from "./utils";
|
||||
import { FullImageModal } from "./FullImageModal";
|
||||
|
||||
export function InputBarPreviewImage({
|
||||
fileId,
|
||||
onDelete,
|
||||
}: {
|
||||
fileId: string;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
export function InputBarPreviewImage({ fileId }: { fileId: string }) {
|
||||
const [fullImageShowing, setFullImageShowing] = useState(false);
|
||||
|
||||
return (
|
||||
@ -22,19 +14,7 @@ export function InputBarPreviewImage({
|
||||
open={fullImageShowing}
|
||||
onOpenChange={(open) => setFullImageShowing(open)}
|
||||
/>
|
||||
<div
|
||||
className="p-1 relative"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{isHovered && (
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="absolute top-0 right-0 cursor-pointer border-none bg-hover p-1 rounded-full"
|
||||
>
|
||||
<FiX />
|
||||
</button>
|
||||
)}
|
||||
<div>
|
||||
<img
|
||||
onClick={() => setFullImageShowing(true)}
|
||||
className="h-16 w-16 object-cover rounded-lg bg-background cursor-pointer"
|
@ -1,4 +1,4 @@
|
||||
import React, { useRef } from "react";
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { FiSend, FiFilter, FiPlusCircle, FiCpu } from "react-icons/fi";
|
||||
import ChatInputOption from "./ChatInputOption";
|
||||
import { FaBrain } from "react-icons/fa";
|
||||
@ -7,7 +7,10 @@ import { FilterManager, LlmOverride, LlmOverrideManager } from "@/lib/hooks";
|
||||
import { SelectedFilterDisplay } from "./SelectedFilterDisplay";
|
||||
import { useChatContext } from "@/components/context/ChatContext";
|
||||
import { getFinalLLM } from "@/lib/llm/utils";
|
||||
import { InputBarPreviewImage } from "../images/InputBarPreviewImage";
|
||||
import { FileDescriptor } from "../interfaces";
|
||||
import { InputBarPreview } from "../files/InputBarPreview";
|
||||
|
||||
const MAX_INPUT_HEIGHT = 200;
|
||||
|
||||
export function ChatInputBar({
|
||||
message,
|
||||
@ -19,10 +22,11 @@ export function ChatInputBar({
|
||||
filterManager,
|
||||
llmOverrideManager,
|
||||
selectedAssistant,
|
||||
fileIds,
|
||||
setFileIds,
|
||||
files,
|
||||
setFiles,
|
||||
handleFileUpload,
|
||||
setConfigModalActiveTab,
|
||||
textAreaRef,
|
||||
}: {
|
||||
message: string;
|
||||
setMessage: (message: string) => void;
|
||||
@ -33,12 +37,23 @@ export function ChatInputBar({
|
||||
filterManager: FilterManager;
|
||||
llmOverrideManager: LlmOverrideManager;
|
||||
selectedAssistant: Persona;
|
||||
fileIds: string[];
|
||||
setFileIds: (fileIds: string[]) => void;
|
||||
files: FileDescriptor[];
|
||||
setFiles: (files: FileDescriptor[]) => void;
|
||||
handleFileUpload: (files: File[]) => void;
|
||||
setConfigModalActiveTab: (tab: string) => void;
|
||||
textAreaRef: React.RefObject<HTMLTextAreaElement>;
|
||||
}) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
// handle re-sizing of the text area
|
||||
useEffect(() => {
|
||||
const textarea = textAreaRef.current;
|
||||
if (textarea) {
|
||||
textarea.style.height = "0px";
|
||||
textarea.style.height = `${Math.min(
|
||||
textarea.scrollHeight,
|
||||
MAX_INPUT_HEIGHT
|
||||
)}px`;
|
||||
}
|
||||
}, [message]);
|
||||
|
||||
const { llmProviders } = useChatContext();
|
||||
const [_, llmName] = getFinalLLM(llmProviders, selectedAssistant);
|
||||
@ -76,23 +91,28 @@ export function ChatInputBar({
|
||||
[&:has(textarea:focus)]::ring-black
|
||||
"
|
||||
>
|
||||
{fileIds.length > 0 && (
|
||||
<div className="flex flex-wrap gap-y-2 px-1">
|
||||
{fileIds.map((fileId) => (
|
||||
<div key={fileId} className="py-1">
|
||||
<InputBarPreviewImage
|
||||
fileId={fileId}
|
||||
{files.length > 0 && (
|
||||
<div className="flex flex-wrap gap-y-1 gap-x-2 px-2 pt-2">
|
||||
{files.map((file) => (
|
||||
<div key={file.id}>
|
||||
<InputBarPreview
|
||||
file={file}
|
||||
onDelete={() => {
|
||||
setFileIds(fileIds.filter((id) => id !== fileId));
|
||||
setFiles(
|
||||
files.filter(
|
||||
(fileInFilter) => fileInFilter.id !== file.id
|
||||
)
|
||||
);
|
||||
}}
|
||||
isUploading={file.isUploading || false}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="
|
||||
ref={textAreaRef}
|
||||
className={`
|
||||
m-0
|
||||
w-full
|
||||
shrink
|
||||
@ -100,10 +120,10 @@ export function ChatInputBar({
|
||||
border-0
|
||||
bg-transparent
|
||||
${
|
||||
textareaRef.current &&
|
||||
textareaRef.current.scrollHeight > 200
|
||||
? 'overflow-y-auto'
|
||||
: ''
|
||||
textAreaRef.current &&
|
||||
textAreaRef.current.scrollHeight > MAX_INPUT_HEIGHT
|
||||
? "overflow-y-auto mt-2"
|
||||
: ""
|
||||
}
|
||||
whitespace-normal
|
||||
break-word
|
||||
@ -116,7 +136,7 @@ export function ChatInputBar({
|
||||
pr-12
|
||||
py-4
|
||||
h-14
|
||||
"
|
||||
`}
|
||||
autoFocus
|
||||
style={{ scrollbarWidth: "thin" }}
|
||||
role="textarea"
|
||||
|
@ -20,9 +20,18 @@ export interface RetrievalDetails {
|
||||
|
||||
type CitationMap = { [key: string]: number };
|
||||
|
||||
export enum ChatFileType {
|
||||
IMAGE = "image",
|
||||
DOCUMENT = "document",
|
||||
PLAIN_TEXT = "plain_text",
|
||||
}
|
||||
|
||||
export interface FileDescriptor {
|
||||
id: string;
|
||||
type: "image";
|
||||
type: ChatFileType;
|
||||
name?: string | null;
|
||||
// FE only
|
||||
isUploading?: boolean;
|
||||
}
|
||||
|
||||
export interface ChatSession {
|
||||
|
@ -10,6 +10,7 @@ import {
|
||||
BackendMessage,
|
||||
ChatSession,
|
||||
DocumentsResponse,
|
||||
FileDescriptor,
|
||||
ImageGenerationDisplay,
|
||||
Message,
|
||||
RetrievalType,
|
||||
@ -49,7 +50,7 @@ export async function createChatSession(
|
||||
|
||||
export async function* sendMessage({
|
||||
message,
|
||||
fileIds,
|
||||
fileDescriptors,
|
||||
parentMessageId,
|
||||
chatSessionId,
|
||||
promptId,
|
||||
@ -64,7 +65,7 @@ export async function* sendMessage({
|
||||
useExistingUserMessage,
|
||||
}: {
|
||||
message: string;
|
||||
fileIds: string[];
|
||||
fileDescriptors: FileDescriptor[];
|
||||
parentMessageId: number | null;
|
||||
chatSessionId: number;
|
||||
promptId: number | null | undefined;
|
||||
@ -95,7 +96,7 @@ export async function* sendMessage({
|
||||
message: message,
|
||||
prompt_id: promptId,
|
||||
search_doc_ids: documentsAreSelected ? selectedDocumentIds : null,
|
||||
file_ids: fileIds,
|
||||
file_descriptors: fileDescriptors,
|
||||
retrieval_options: !documentsAreSelected
|
||||
? {
|
||||
run_search:
|
||||
@ -529,7 +530,7 @@ export function buildChatUrl(
|
||||
|
||||
export async function uploadFilesForChat(
|
||||
files: File[]
|
||||
): Promise<[string[], string | null]> {
|
||||
): Promise<[FileDescriptor[], string | null]> {
|
||||
const formData = new FormData();
|
||||
files.forEach((file) => {
|
||||
formData.append("files", file);
|
||||
@ -544,5 +545,5 @@ export async function uploadFilesForChat(
|
||||
}
|
||||
const responseJson = await response.json();
|
||||
|
||||
return [responseJson.file_ids as string[], null];
|
||||
return [responseJson.files as FileDescriptor[], null];
|
||||
}
|
||||
|
@ -18,13 +18,48 @@ import { ThreeDots } from "react-loader-spinner";
|
||||
import { SkippedSearch } from "./SkippedSearch";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { CopyButton } from "@/components/CopyButton";
|
||||
import { FileDescriptor } from "../interfaces";
|
||||
import { InMessageImage } from "../images/InMessageImage";
|
||||
import { ChatFileType, FileDescriptor } from "../interfaces";
|
||||
import { IMAGE_GENERATION_TOOL_NAME } from "../tools/constants";
|
||||
import { ToolRunningAnimation } from "../tools/ToolRunningAnimation";
|
||||
import { Hoverable } from "@/components/Hoverable";
|
||||
import { DocumentPreview } from "../files/documents/DocumentPreview";
|
||||
import { InMessageImage } from "../files/images/InMessageImage";
|
||||
|
||||
const ICON_SIZE = 15;
|
||||
function FileDisplay({ files }: { files: FileDescriptor[] }) {
|
||||
const imageFiles = files.filter((file) => file.type === ChatFileType.IMAGE);
|
||||
const nonImgFiles = files.filter((file) => file.type !== ChatFileType.IMAGE);
|
||||
|
||||
return (
|
||||
<>
|
||||
{" "}
|
||||
{nonImgFiles && nonImgFiles.length > 0 && (
|
||||
<div className="mt-2 mb-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
{nonImgFiles.map((file) => {
|
||||
return (
|
||||
<div key={file.id} className="w-fit">
|
||||
<DocumentPreview
|
||||
fileName={file.name || file.id}
|
||||
maxWidth="max-w-64"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{imageFiles && imageFiles.length > 0 && (
|
||||
<div className="mt-2 mb-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{imageFiles.map((file) => {
|
||||
return <InMessageImage key={file.id} fileId={file.id} />;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const AIMessage = ({
|
||||
messageId,
|
||||
@ -142,17 +177,8 @@ export const AIMessage = ({
|
||||
|
||||
{content ? (
|
||||
<>
|
||||
{files && files.length > 0 && (
|
||||
<div className="mt-2 mb-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{files.map((file) => {
|
||||
return (
|
||||
<InMessageImage key={file.id} fileId={file.id} />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<FileDisplay files={files || []} />
|
||||
|
||||
{typeof content === "string" ? (
|
||||
<ReactMarkdown
|
||||
className="prose max-w-full"
|
||||
@ -335,15 +361,7 @@ export const HumanMessage = ({
|
||||
</div>
|
||||
<div className="mx-auto mt-1 ml-8 w-searchbar-xs 2xl:w-searchbar-sm 3xl:w-searchbar-default flex flex-wrap">
|
||||
<div className="w-message-xs 2xl:w-message-sm 3xl:w-message-default break-words">
|
||||
{files && files.length > 0 && (
|
||||
<div className="mt-2 mb-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{files.map((file) => {
|
||||
return <InMessageImage key={file.id} fileId={file.id} />;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<FileDisplay files={files || []} />
|
||||
|
||||
{isEditing ? (
|
||||
<div>
|
||||
|
48
web/src/components/tooltip/Tooltip.tsx
Normal file
48
web/src/components/tooltip/Tooltip.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
interface TooltipProps {
|
||||
children: ReactNode;
|
||||
content: ReactNode;
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
align?: "start" | "center" | "end";
|
||||
}
|
||||
|
||||
export function Tooltip({
|
||||
children,
|
||||
content,
|
||||
delayDuration = 200,
|
||||
side = "top",
|
||||
align = "center",
|
||||
}: {
|
||||
children: ReactNode;
|
||||
content: ReactNode;
|
||||
delayDuration?: number;
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
align?: "start" | "center" | "end";
|
||||
}) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider delayDuration={delayDuration}>
|
||||
<TooltipPrimitive.Root>
|
||||
<TooltipPrimitive.Trigger asChild>{children}</TooltipPrimitive.Trigger>
|
||||
<TooltipPrimitive.Content
|
||||
side={side}
|
||||
align={align}
|
||||
className="
|
||||
bg-background-inverted
|
||||
text-inverted
|
||||
text-sm
|
||||
rounded
|
||||
py-1
|
||||
px-2
|
||||
shadow-lg
|
||||
z-50
|
||||
"
|
||||
>
|
||||
{content}
|
||||
<TooltipPrimitive.Arrow className="fill-black" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Root>
|
||||
</TooltipPrimitive.Provider>
|
||||
);
|
||||
}
|
@ -37,32 +37,62 @@ module.exports = {
|
||||
"document-sidebar": "1000px",
|
||||
},
|
||||
colors: {
|
||||
// background
|
||||
background: "#f9fafb", // gray-50
|
||||
"background-emphasis": "#f6f7f8",
|
||||
"background-strong": "#eaecef",
|
||||
"background-search": "#ffffff",
|
||||
"background-custom-header": "#f3f4f6",
|
||||
"background-inverted": "#000000",
|
||||
|
||||
// text or icons
|
||||
link: "#3b82f6", // blue-500
|
||||
"link-hover": "#1d4ed8", // blue-700
|
||||
subtle: "#6b7280", // gray-500
|
||||
default: "#4b5563", // gray-600
|
||||
emphasis: "#374151", // gray-700
|
||||
strong: "#111827", // gray-900
|
||||
inverted: "#ffffff", // white
|
||||
background: "#f9fafb", // gray-50
|
||||
"background-emphasis": "#f6f7f8",
|
||||
"background-strong": "#eaecef",
|
||||
border: "#e5e7eb", // gray-200
|
||||
"border-light": "#f3f4f6", // gray-100
|
||||
"border-strong": "#9ca3af", // gray-400
|
||||
"hover-light": "#f3f4f6", // gray-100
|
||||
hover: "#e5e7eb", // gray-200
|
||||
"hover-emphasis": "#d1d5db", // gray-300
|
||||
popup: "#ffffff", // white
|
||||
accent: "#6671d0",
|
||||
"accent-hover": "#5964c2",
|
||||
highlight: {
|
||||
text: "#fef9c3", // yellow-100
|
||||
},
|
||||
error: "#ef4444", // red-500
|
||||
success: "#059669", // emerald-600
|
||||
alert: "#f59e0b", // amber-600
|
||||
accent: "#6671d0",
|
||||
|
||||
// borders
|
||||
border: "#e5e7eb", // gray-200
|
||||
"border-light": "#f3f4f6", // gray-100
|
||||
"border-strong": "#9ca3af", // gray-400
|
||||
|
||||
// hover
|
||||
"hover-light": "#f3f4f6", // gray-100
|
||||
hover: "#e5e7eb", // gray-200
|
||||
"hover-emphasis": "#d1d5db", // gray-300
|
||||
"accent-hover": "#5964c2",
|
||||
|
||||
// keyword highlighting
|
||||
highlight: {
|
||||
text: "#fef9c3", // yellow-100
|
||||
},
|
||||
|
||||
// scrollbar
|
||||
scrollbar: {
|
||||
track: "#f9fafb",
|
||||
thumb: "#e5e7eb",
|
||||
"thumb-hover": "#d1d5db",
|
||||
|
||||
dark: {
|
||||
thumb: "#989a9c",
|
||||
"thumb-hover": "#c7cdd2",
|
||||
},
|
||||
},
|
||||
|
||||
// bubbles in chat for each "user"
|
||||
user: "#fb7185", // yellow-400
|
||||
ai: "#60a5fa", // blue-400
|
||||
|
||||
// for display documents
|
||||
document: "#ec4899", // pink-500
|
||||
|
||||
// light mode
|
||||
tremor: {
|
||||
brand: {
|
||||
|
Loading…
x
Reference in New Issue
Block a user