From 87fadb07eabf9983a5e6cf48f61245d04d0fb2d1 Mon Sep 17 00:00:00 2001 From: pablodanswer Date: Wed, 17 Jul 2024 19:44:21 -0700 Subject: [PATCH] COMPLETE USER EXPERIENCE OVERHAUL (#1822) --- ...f07c00_add_search_doc_relevance_details.py | 32 + backend/danswer/chat/models.py | 10 +- backend/danswer/configs/chat_configs.py | 11 + backend/danswer/db/chat.py | 85 +- backend/danswer/db/models.py | 3 + backend/danswer/llm/answering/answer.py | 43 +- .../danswer/llm/answering/prune_and_merge.py | 1 + .../one_shot_answer/answer_question.py | 37 +- backend/danswer/one_shot_answer/models.py | 7 + .../danswer/prompts/miscellaneous_prompts.py | 19 + backend/danswer/search/models.py | 5 + backend/danswer/search/pipeline.py | 48 +- .../secondary_llm_flows/agentic_evaluation.py | 71 + .../server/query_and_chat/chat_backend.py | 1 - .../danswer/server/query_and_chat/models.py | 9 +- .../server/query_and_chat/query_backend.py | 114 +- backend/danswer/tools/search/search_tool.py | 31 +- .../danswer/utils/threadpool_concurrency.py | 1 + .../regression/answer_quality/__init__.py | 0 backend/throttle.ctrl | 2 +- .../docker_compose/docker-compose.dev.yml | 2 + .../app/admin/assistants/AssistantEditor.tsx | 4 +- .../admin/assistants/CollapsibleSection.tsx | 4 +- web/src/app/admin/assistants/new/page.tsx | 2 +- web/src/app/admin/assistants/page.tsx | 4 +- web/src/app/admin/bot/page.tsx | 2 +- .../app/admin/connectors/gmail/Credential.tsx | 2 +- .../connectors/google-drive/Credential.tsx | 2 +- .../models/embedding/CloudEmbeddingPage.tsx | 8 +- web/src/app/admin/models/embedding/page.tsx | 17 +- web/src/app/admin/models/llm/page.tsx | 3 +- web/src/app/admin/settings/page.tsx | 3 +- web/src/app/admin/token-rate-limits/page.tsx | 7 +- .../app/admin/tools/edit/[toolId]/page.tsx | 3 +- web/src/app/admin/tools/new/page.tsx | 3 +- web/src/app/admin/tools/page.tsx | 3 +- web/src/app/assistants/SidebarWrapper.tsx | 148 ++ web/src/app/assistants/ToolsDisplay.tsx | 1 + .../gallery/WrappedAssistantsGallery.tsx | 44 + web/src/app/assistants/gallery/page.tsx | 37 +- .../assistants/mine/WrappedAssistantsMine.tsx | 43 + web/src/app/assistants/mine/page.tsx | 38 +- web/src/app/auth/login/SignInButton.tsx | 2 +- web/src/app/chat/ChatBanner.tsx | 2 +- web/src/app/chat/ChatIntro.tsx | 6 +- web/src/app/chat/ChatPage.tsx | 963 +++++----- web/src/app/chat/WrappedChat.tsx | 26 + .../documentSidebar/ChatDocumentDisplay.tsx | 41 +- .../chat/documentSidebar/DocumentSelector.tsx | 3 +- .../chat/documentSidebar/DocumentSidebar.tsx | 157 +- web/src/app/chat/files/InputBarPreview.tsx | 117 +- .../chat/files/documents/DocumentPreview.tsx | 75 +- .../app/chat/files/images/InMessageImage.tsx | 9 +- .../files/images/InputBarPreviewImage.tsx | 18 +- web/src/app/chat/folders/FolderList.tsx | 93 +- web/src/app/chat/input/ChatInputAssistant.tsx | 52 + web/src/app/chat/input/ChatInputBar.tsx | 268 +-- web/src/app/chat/input/ChatInputOption.tsx | 162 +- web/src/app/chat/interfaces.ts | 20 +- web/src/app/chat/lib.tsx | 15 +- web/src/app/chat/message/Messages.tsx | 849 +++++---- web/src/app/chat/message/SearchSummary.tsx | 36 +- web/src/app/chat/message/SkippedSearch.tsx | 4 +- web/src/app/chat/message/hooks.ts | 38 + web/src/app/chat/modal/FeedbackModal.tsx | 17 +- .../modal/configuration/AssistantsTab.tsx | 12 +- .../configuration/ConfigurationModal.tsx | 2 + .../app/chat/modal/configuration/LlmTab.tsx | 212 +-- web/src/app/chat/page.tsx | 12 +- web/src/app/chat/searchParams.ts | 1 + .../app/chat/sessionSidebar/AssistantsTab.tsx | 4 +- .../sessionSidebar/ChatSessionDisplay.tsx | 12 +- .../app/chat/sessionSidebar/ChatSidebar.tsx | 164 -- .../chat/sessionSidebar/HistorySidebar.tsx | 185 ++ .../{ChatTab.tsx => PagesTab.tsx} | 38 +- .../app/chat/shared_chat_search/FixedLogo.tsx | 25 + .../shared_chat_search/FunctionalWrapper.tsx | 143 ++ web/src/app/ee/admin/groups/page.tsx | 2 +- .../admin/whitelabeling/WhitelabelingForm.tsx | 1 + web/src/app/ee/admin/whitelabeling/page.tsx | 4 +- web/src/app/globals.css | 88 +- web/src/app/layout.tsx | 3 +- web/src/app/search/WrappedSearch.tsx | 51 + web/src/app/search/page.tsx | 55 +- web/src/components/BasicClickable.tsx | 45 +- web/src/components/CopyButton.tsx | 7 +- web/src/components/CustomCheckbox.tsx | 12 +- .../components/DanswerInitializingLoader.tsx | 2 +- web/src/components/Dropdown.tsx | 160 +- web/src/components/Hoverable.tsx | 15 + web/src/components/UserDropdown.tsx | 64 +- web/src/components/admin/Layout.tsx | 86 +- web/src/components/admin/Title.tsx | 2 +- .../admin/connectors/AdminSidebar.tsx | 54 +- web/src/components/admin/connectors/Field.tsx | 6 +- .../components/assistants/AssistantIcon.tsx | 30 +- web/src/components/chat_search/Header.tsx | 112 ++ web/src/components/chat_search/hooks.ts | 57 + web/src/components/header/Header.tsx | 31 +- web/src/components/health/healthcheck.tsx | 6 +- web/src/components/icons/icons.tsx | 1553 ++++++++++++++++- web/src/components/popup/Popup.tsx | 107 ++ .../components/resizable/ResizableSection.tsx | 20 +- web/src/components/resizable/contants.ts | 1 + web/src/components/search/DocumentDisplay.tsx | 282 ++- .../search/DocumentFeedbackBlock.tsx | 28 +- web/src/components/search/SearchBar.tsx | 226 +++ .../search/SearchResultsDisplay.tsx | 310 +++- web/src/components/search/SearchSection.tsx | 461 ++++- .../components/search/filtering/Filters.tsx | 20 +- .../components/search/results/Citation.tsx | 55 + .../search/results/QuotesSection.tsx | 9 - web/src/components/tooltip/CustomTooltip.tsx | 135 ++ web/src/components/tooltip/Tooltip.tsx | 7 +- web/src/lib/browserUtilities.tsx | 32 + web/src/lib/chat/fetchChatData.ts | 15 +- web/src/lib/constants.ts | 4 + web/src/lib/documentUtils.ts | 12 +- web/src/lib/search/interfaces.ts | 37 +- web/src/lib/search/streamingQa.ts | 41 +- web/src/lib/sources.ts | 4 +- web/tailwind-themes/tailwind.config.js | 73 +- 122 files changed, 6814 insertions(+), 2204 deletions(-) create mode 100644 backend/alembic/versions/05c07bf07c00_add_search_doc_relevance_details.py create mode 100644 backend/danswer/secondary_llm_flows/agentic_evaluation.py create mode 100644 backend/tests/regression/answer_quality/__init__.py create mode 100644 web/src/app/assistants/SidebarWrapper.tsx create mode 100644 web/src/app/assistants/gallery/WrappedAssistantsGallery.tsx create mode 100644 web/src/app/assistants/mine/WrappedAssistantsMine.tsx create mode 100644 web/src/app/chat/WrappedChat.tsx create mode 100644 web/src/app/chat/input/ChatInputAssistant.tsx create mode 100644 web/src/app/chat/message/hooks.ts delete mode 100644 web/src/app/chat/sessionSidebar/ChatSidebar.tsx create mode 100644 web/src/app/chat/sessionSidebar/HistorySidebar.tsx rename web/src/app/chat/sessionSidebar/{ChatTab.tsx => PagesTab.tsx} (75%) create mode 100644 web/src/app/chat/shared_chat_search/FixedLogo.tsx create mode 100644 web/src/app/chat/shared_chat_search/FunctionalWrapper.tsx create mode 100644 web/src/app/search/WrappedSearch.tsx create mode 100644 web/src/components/chat_search/Header.tsx create mode 100644 web/src/components/chat_search/hooks.ts create mode 100644 web/src/components/popup/Popup.tsx create mode 100644 web/src/components/search/results/Citation.tsx create mode 100644 web/src/components/tooltip/CustomTooltip.tsx create mode 100644 web/src/lib/browserUtilities.tsx diff --git a/backend/alembic/versions/05c07bf07c00_add_search_doc_relevance_details.py b/backend/alembic/versions/05c07bf07c00_add_search_doc_relevance_details.py new file mode 100644 index 0000000000..2049bebfcf --- /dev/null +++ b/backend/alembic/versions/05c07bf07c00_add_search_doc_relevance_details.py @@ -0,0 +1,32 @@ +"""add search doc relevance details + +Revision ID: 05c07bf07c00 +Revises: 3a7802814195 +Create Date: 2024-07-10 17:48:15.886653 + +""" + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "05c07bf07c00" +down_revision = "b896bbd0d5a7" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "search_doc", + sa.Column("is_relevant", sa.Boolean(), nullable=True), + ) + op.add_column( + "search_doc", + sa.Column("relevance_explanation", sa.String(), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("search_doc", "relevance_explanation") + op.drop_column("search_doc", "is_relevant") diff --git a/backend/danswer/chat/models.py b/backend/danswer/chat/models.py index 7fc526a5cb..1fb8586f02 100644 --- a/backend/danswer/chat/models.py +++ b/backend/danswer/chat/models.py @@ -42,11 +42,19 @@ class QADocsResponse(RetrievalDocs): return initial_dict -# Second chunk of info for streaming QA class LLMRelevanceFilterResponse(BaseModel): relevant_chunk_indices: list[int] +class RelevanceChunk(BaseModel): + relevant: bool | None = None + content: str | None = None + + +class LLMRelevanceSummaryResponse(BaseModel): + relevance_summaries: dict[str, RelevanceChunk] + + class DanswerAnswerPiece(BaseModel): # A small piece of a complete answer. Used for streaming back answers. answer_piece: str | None # if None, specifies the end of an Answer diff --git a/backend/danswer/configs/chat_configs.py b/backend/danswer/configs/chat_configs.py index 3595480e22..0d8aadb6fb 100644 --- a/backend/danswer/configs/chat_configs.py +++ b/backend/danswer/configs/chat_configs.py @@ -75,6 +75,17 @@ LANGUAGE_CHAT_NAMING_HINT = ( or "The name of the conversation must be in the same language as the user query." ) + +# Agentic search takes significantly more tokens and therefore has much higher cost. +# This configuration allows users to get a search-only experience with instant results +# and no involvement from the LLM. +# Additionally, some LLM providers have strict rate limits which may prohibit +# sending many API requests at once (as is done in agentic search). +DISABLE_AGENTIC_SEARCH = ( + os.environ.get("DISABLE_AGENTIC_SEARCH") or "false" +).lower() == "true" + + # Stops streaming answers back to the UI if this pattern is seen: STOP_STREAM_PAT = os.environ.get("STOP_STREAM_PAT") or None diff --git a/backend/danswer/db/chat.py b/backend/danswer/db/chat.py index e2208266c8..d3650eed30 100644 --- a/backend/danswer/db/chat.py +++ b/backend/danswer/db/chat.py @@ -3,15 +3,20 @@ from datetime import datetime from datetime import timedelta from uuid import UUID +from sqlalchemy import and_ from sqlalchemy import delete +from sqlalchemy import desc +from sqlalchemy import func from sqlalchemy import nullsfirst from sqlalchemy import or_ from sqlalchemy import select +from sqlalchemy import update from sqlalchemy.exc import MultipleResultsFound from sqlalchemy.orm import joinedload from sqlalchemy.orm import Session from danswer.auth.schemas import UserRole +from danswer.chat.models import LLMRelevanceSummaryResponse from danswer.configs.chat_configs import HARD_DELETE_CHATS from danswer.configs.constants import MessageType from danswer.db.models import ChatMessage @@ -34,6 +39,7 @@ from danswer.server.query_and_chat.models import ChatMessageDetail from danswer.tools.tool_runner import ToolCallFinalResult from danswer.utils.logger import setup_logger + logger = setup_logger() @@ -81,17 +87,46 @@ def get_chat_sessions_by_slack_thread_id( return db_session.scalars(stmt).all() +def get_first_messages_for_chat_sessions( + chat_session_ids: list[int], db_session: Session +) -> dict[int, str]: + subquery = ( + select(ChatMessage.chat_session_id, func.min(ChatMessage.id).label("min_id")) + .where( + and_( + ChatMessage.chat_session_id.in_(chat_session_ids), + ChatMessage.message_type == MessageType.USER, # Select USER messages + ) + ) + .group_by(ChatMessage.chat_session_id) + .subquery() + ) + + query = select(ChatMessage.chat_session_id, ChatMessage.message).join( + subquery, + (ChatMessage.chat_session_id == subquery.c.chat_session_id) + & (ChatMessage.id == subquery.c.min_id), + ) + + first_messages = db_session.execute(query).all() + return dict([(row.chat_session_id, row.message) for row in first_messages]) + + def get_chat_sessions_by_user( user_id: UUID | None, deleted: bool | None, db_session: Session, - include_one_shot: bool = False, + only_one_shot: bool = False, ) -> list[ChatSession]: stmt = select(ChatSession).where(ChatSession.user_id == user_id) - if not include_one_shot: + if only_one_shot: + stmt = stmt.where(ChatSession.one_shot.is_(True)) + else: stmt = stmt.where(ChatSession.one_shot.is_(False)) + stmt = stmt.order_by(desc(ChatSession.time_created)) + if deleted is not None: stmt = stmt.where(ChatSession.deleted == deleted) @@ -275,6 +310,20 @@ def get_chat_messages_by_sessions( return db_session.execute(stmt).scalars().all() +def get_search_docs_for_chat_message( + chat_message_id: int, db_session: Session +) -> list[SearchDoc]: + stmt = ( + select(SearchDoc) + .join( + ChatMessage__SearchDoc, ChatMessage__SearchDoc.search_doc_id == SearchDoc.id + ) + .where(ChatMessage__SearchDoc.chat_message_id == chat_message_id) + ) + + return list(db_session.scalars(stmt).all()) + + def get_chat_messages_by_session( chat_session_id: int, user_id: UUID | None, @@ -295,8 +344,6 @@ def get_chat_messages_by_session( if prefetch_tool_calls: stmt = stmt.options(joinedload(ChatMessage.tool_calls)) - - if prefetch_tool_calls: result = db_session.scalars(stmt).unique().all() else: result = db_session.scalars(stmt).all() @@ -484,6 +531,27 @@ def get_doc_query_identifiers_from_model( return doc_query_identifiers +def update_search_docs_table_with_relevance( + db_session: Session, + reference_db_search_docs: list[SearchDoc], + relevance_summary: LLMRelevanceSummaryResponse, +) -> None: + for search_doc in reference_db_search_docs: + relevance_data = relevance_summary.relevance_summaries.get( + f"{search_doc.document_id}-{search_doc.chunk_ind}" + ) + if relevance_data is not None: + db_session.execute( + update(SearchDoc) + .where(SearchDoc.id == search_doc.id) + .values( + is_relevant=relevance_data.relevant, + relevance_explanation=relevance_data.content, + ) + ) + db_session.commit() + + def create_db_search_doc( server_search_doc: ServerSearchDoc, db_session: Session, @@ -498,6 +566,8 @@ def create_db_search_doc( boost=server_search_doc.boost, hidden=server_search_doc.hidden, doc_metadata=server_search_doc.metadata, + is_relevant=server_search_doc.is_relevant, + relevance_explanation=server_search_doc.relevance_explanation, # For docs further down that aren't reranked, we can't use the retrieval score score=server_search_doc.score or 0.0, match_highlights=server_search_doc.match_highlights, @@ -509,7 +579,6 @@ def create_db_search_doc( db_session.add(db_search_doc) db_session.commit() - return db_search_doc @@ -538,6 +607,8 @@ def translate_db_search_doc_to_server_search_doc( match_highlights=( db_search_doc.match_highlights if not remove_doc_content else [] ), + relevance_explanation=db_search_doc.relevance_explanation, + is_relevant=db_search_doc.is_relevant, updated_at=db_search_doc.updated_at if not remove_doc_content else None, primary_owners=db_search_doc.primary_owners if not remove_doc_content else [], secondary_owners=( @@ -561,9 +632,11 @@ def get_retrieval_docs_from_chat_message( def translate_db_message_to_chat_message_detail( - chat_message: ChatMessage, remove_doc_content: bool = False + chat_message: ChatMessage, + remove_doc_content: bool = False, ) -> ChatMessageDetail: chat_msg_detail = ChatMessageDetail( + chat_session_id=chat_message.chat_session_id, message_id=chat_message.id, parent_message=chat_message.parent_message, latest_child_message=chat_message.latest_child_message, diff --git a/backend/danswer/db/models.py b/backend/danswer/db/models.py index 66a68a4732..2666ed96c2 100644 --- a/backend/danswer/db/models.py +++ b/backend/danswer/db/models.py @@ -671,6 +671,9 @@ class SearchDoc(Base): ) is_internet: Mapped[bool] = mapped_column(Boolean, default=False, nullable=True) + is_relevant: Mapped[bool | None] = mapped_column(Boolean, nullable=True) + relevance_explanation: Mapped[str | None] = mapped_column(String, nullable=True) + chat_messages = relationship( "ChatMessage", secondary="chat_message__search_doc", diff --git a/backend/danswer/llm/answering/answer.py b/backend/danswer/llm/answering/answer.py index 3daca91e00..b59dee5a46 100644 --- a/backend/danswer/llm/answering/answer.py +++ b/backend/danswer/llm/answering/answer.py @@ -89,6 +89,9 @@ def _get_answer_stream_processor( AnswerStream = Iterator[AnswerQuestionPossibleReturn | ToolCallKickoff | ToolResponse] +logger = setup_logger() + + class Answer: def __init__( self, @@ -112,6 +115,7 @@ class Answer: skip_explicit_tool_calling: bool = False, # Returns the full document sections text from the search tool return_contexts: bool = False, + skip_gen_ai_answer_generation: bool = False, ) -> None: if single_message_history and message_history: raise ValueError( @@ -140,11 +144,12 @@ class Answer: self._final_prompt: list[BaseMessage] | None = None self._streamed_output: list[str] | None = None - self._processed_stream: list[ - AnswerQuestionPossibleReturn | ToolResponse | ToolCallKickoff - ] | None = None + self._processed_stream: ( + list[AnswerQuestionPossibleReturn | ToolResponse | ToolCallKickoff] | None + ) = None self._return_contexts = return_contexts + self.skip_gen_ai_answer_generation = skip_gen_ai_answer_generation def _update_prompt_builder_for_search_tool( self, prompt_builder: AnswerPromptBuilder, final_context_documents: list[LlmDoc] @@ -403,8 +408,9 @@ class Answer: ) ) ) + final = tool_runner.tool_final_result() - yield tool_runner.tool_final_result() + yield final prompt = prompt_builder.build() yield from message_generator_to_string_generator(self.llm.stream(prompt=prompt)) @@ -467,22 +473,23 @@ class Answer: # assumes all tool responses will come first, then the final answer break - process_answer_stream_fn = _get_answer_stream_processor( - context_docs=final_context_docs or [], - # if doc selection is enabled, then search_results will be None, - # so we need to use the final_context_docs - doc_id_to_rank_map=map_document_id_order( - search_results or final_context_docs or [] - ), - answer_style_configs=self.answer_style_config, - ) + if not self.skip_gen_ai_answer_generation: + process_answer_stream_fn = _get_answer_stream_processor( + context_docs=final_context_docs or [], + # if doc selection is enabled, then search_results will be None, + # so we need to use the final_context_docs + doc_id_to_rank_map=map_document_id_order( + search_results or final_context_docs or [] + ), + answer_style_configs=self.answer_style_config, + ) - def _stream() -> Iterator[str]: - if message: - yield cast(str, message) - yield from cast(Iterator[str], stream) + def _stream() -> Iterator[str]: + if message: + yield cast(str, message) + yield from cast(Iterator[str], stream) - yield from process_answer_stream_fn(_stream()) + yield from process_answer_stream_fn(_stream()) processed_stream = [] for processed_packet in _process_stream(output_generator): diff --git a/backend/danswer/llm/answering/prune_and_merge.py b/backend/danswer/llm/answering/prune_and_merge.py index 3fee5266d8..b7206a1ac2 100644 --- a/backend/danswer/llm/answering/prune_and_merge.py +++ b/backend/danswer/llm/answering/prune_and_merge.py @@ -265,6 +265,7 @@ def prune_sections( max_tokens=document_pruning_config.max_tokens, tool_token_count=document_pruning_config.tool_num_tokens, ) + return _apply_pruning( sections=sections, section_relevance_list=section_relevance_list, diff --git a/backend/danswer/one_shot_answer/answer_question.py b/backend/danswer/one_shot_answer/answer_question.py index 8767bc03ab..1bdbc48c6b 100644 --- a/backend/danswer/one_shot_answer/answer_question.py +++ b/backend/danswer/one_shot_answer/answer_question.py @@ -10,6 +10,7 @@ from danswer.chat.models import DanswerAnswerPiece from danswer.chat.models import DanswerContexts from danswer.chat.models import DanswerQuotes from danswer.chat.models import LLMRelevanceFilterResponse +from danswer.chat.models import LLMRelevanceSummaryResponse from danswer.chat.models import QADocsResponse from danswer.chat.models import StreamingError from danswer.configs.chat_configs import MAX_CHUNKS_FED_TO_CHAT @@ -21,6 +22,7 @@ from danswer.db.chat import create_new_chat_message from danswer.db.chat import get_or_create_root_message from danswer.db.chat import translate_db_message_to_chat_message_detail from danswer.db.chat import translate_db_search_doc_to_server_search_doc +from danswer.db.chat import update_search_docs_table_with_relevance from danswer.db.engine import get_session_context_manager from danswer.db.models import User from danswer.db.persona import get_prompt_by_id @@ -48,6 +50,7 @@ from danswer.server.query_and_chat.models import ChatMessageDetail from danswer.server.utils import get_json_line from danswer.tools.force import ForceUseTool from danswer.tools.search.search_tool import SEARCH_DOC_CONTENT_ID +from danswer.tools.search.search_tool import SEARCH_EVALUATION_ID from danswer.tools.search.search_tool import SEARCH_RESPONSE_SUMMARY_ID from danswer.tools.search.search_tool import SearchResponseSummary from danswer.tools.search.search_tool import SearchTool @@ -57,6 +60,7 @@ from danswer.tools.tool_runner import ToolCallKickoff from danswer.utils.logger import setup_logger from danswer.utils.timing import log_generator_function_time + logger = setup_logger() AnswerObjectIterator = Iterator[ @@ -70,6 +74,7 @@ AnswerObjectIterator = Iterator[ | ChatMessageDetail | CitationInfo | ToolCallKickoff + | LLMRelevanceSummaryResponse ] @@ -88,8 +93,9 @@ def stream_answer_objects( bypass_acl: bool = False, use_citations: bool = False, danswerbot_flow: bool = False, - retrieval_metrics_callback: Callable[[RetrievalMetricsContainer], None] - | None = None, + retrieval_metrics_callback: ( + Callable[[RetrievalMetricsContainer], None] | None + ) = None, rerank_metrics_callback: Callable[[RerankMetricsContainer], None] | None = None, ) -> AnswerObjectIterator: """Streams in order: @@ -127,6 +133,7 @@ def stream_answer_objects( user_query=query_msg.message, history_str=history_str, ) + # Given back ahead of the documents for latency reasons # In chat flow it's given back along with the documents yield QueryRephrase(rephrased_query=rephrased_query) @@ -182,6 +189,7 @@ def stream_answer_objects( chunks_below=query_req.chunks_below, full_doc=query_req.full_doc, bypass_acl=bypass_acl, + evaluate_response=query_req.evaluate_response, ) answer_config = AnswerStyleConfig( @@ -189,6 +197,7 @@ def stream_answer_objects( quotes_config=QuotesConfig() if not use_citations else None, document_pruning_config=document_pruning_config, ) + answer = Answer( question=query_msg.message, answer_style_config=answer_config, @@ -204,12 +213,16 @@ def stream_answer_objects( # tested quotes with tool calling too much yet skip_explicit_tool_calling=True, return_contexts=query_req.return_contexts, + skip_gen_ai_answer_generation=query_req.skip_gen_ai_answer_generation, ) + # won't be any ImageGenerationDisplay responses since that tool is never passed in dropped_inds: list[int] = [] + for packet in cast(AnswerObjectIterator, answer.processed_streamed_output): # for one-shot flow, don't currently do anything with these if isinstance(packet, ToolResponse): + # (likely fine that it comes after the initial creation of the search docs) if packet.id == SEARCH_RESPONSE_SUMMARY_ID: search_response_summary = cast(SearchResponseSummary, packet.response) @@ -242,6 +255,7 @@ def stream_answer_objects( recency_bias_multiplier=search_response_summary.recency_bias_multiplier, ) yield initial_response + elif packet.id == SECTION_RELEVANCE_LIST_ID: chunk_indices = packet.response @@ -253,8 +267,21 @@ def stream_answer_objects( ) yield LLMRelevanceFilterResponse(relevant_chunk_indices=packet.response) + elif packet.id == SEARCH_DOC_CONTENT_ID: yield packet.response + + elif packet.id == SEARCH_EVALUATION_ID: + evaluation_response = LLMRelevanceSummaryResponse( + relevance_summaries=packet.response + ) + if reference_db_search_docs is not None: + update_search_docs_table_with_relevance( + db_session=db_session, + reference_db_search_docs=reference_db_search_docs, + relevance_summary=evaluation_response, + ) + yield evaluation_response else: yield packet @@ -275,7 +302,6 @@ def stream_answer_objects( msg_detail_response = translate_db_message_to_chat_message_detail( gen_ai_response_message ) - yield msg_detail_response @@ -309,8 +335,9 @@ def get_search_answer( bypass_acl: bool = False, use_citations: bool = False, danswerbot_flow: bool = False, - retrieval_metrics_callback: Callable[[RetrievalMetricsContainer], None] - | None = None, + retrieval_metrics_callback: ( + Callable[[RetrievalMetricsContainer], None] | None + ) = None, rerank_metrics_callback: Callable[[RerankMetricsContainer], None] | None = None, ) -> OneShotQAResponse: """Collects the streamed one shot answer responses into a single object""" diff --git a/backend/danswer/one_shot_answer/models.py b/backend/danswer/one_shot_answer/models.py index 8681991643..e0b978a0b1 100644 --- a/backend/danswer/one_shot_answer/models.py +++ b/backend/danswer/one_shot_answer/models.py @@ -27,12 +27,19 @@ class DirectQARequest(ChunkContext): messages: list[ThreadMessage] prompt_id: int | None persona_id: int + agentic: bool | None = None retrieval_options: RetrievalDetails = Field(default_factory=RetrievalDetails) # This is to forcibly skip (or run) the step, if None it uses the system defaults skip_rerank: bool | None = None skip_llm_chunk_filter: bool | None = None chain_of_thought: bool = False return_contexts: bool = False + # This is to toggle agentic evaluation: + # 1. Evaluates whether each response is relevant or not + # 2. Provides a summary of the document's relevance in the resulsts + evaluate_response: bool = False + # If True, skips generative an AI response to the search query + skip_gen_ai_answer_generation: bool = False @root_validator def check_chain_of_thought_and_prompt_id( diff --git a/backend/danswer/prompts/miscellaneous_prompts.py b/backend/danswer/prompts/miscellaneous_prompts.py index 81ae516432..c57fe73acc 100644 --- a/backend/danswer/prompts/miscellaneous_prompts.py +++ b/backend/danswer/prompts/miscellaneous_prompts.py @@ -24,6 +24,25 @@ Query: """.strip() +AGENTIC_SEARCH_EVALUATION_PROMPT = """ +1. Chain of Thought Analysis: +Provide a chain of thought analysis considering: +- The main purpose and content of the document +- What the user is searching for +- How the document's topic relates to the query +- Potential uses of the document for the given query +Be thorough, but avoid unnecessary repetition. Think step by step. + +2. Useful Analysis: +[ANALYSIS_START] +State the most important point from the chain of thought. +DO NOT refer to "the document" (describe it as "this")- ONLY state the core point in a description. +[ANALYSIS_END] + +3. Relevance Determination: +RESULT: True (if potentially relevant) +RESULT: False (if not relevant) +""".strip() # Use the following for easy viewing of prompts if __name__ == "__main__": print(LANGUAGE_REPHRASE_PROMPT) diff --git a/backend/danswer/search/models.py b/backend/danswer/search/models.py index a94b9f63da..8c675ae5c7 100644 --- a/backend/danswer/search/models.py +++ b/backend/danswer/search/models.py @@ -130,11 +130,14 @@ class InferenceChunk(BaseChunk): recency_bias: float score: float | None hidden: bool + is_relevant: bool | None = None + relevance_explanation: str | None = None metadata: dict[str, str | list[str]] # Matched sections in the chunk. Uses Vespa syntax e.g. TEXT # to specify that a set of words should be highlighted. For example: # ["the answer is 42", "he couldn't find an answer"] match_highlights: list[str] + # when the doc was last updated updated_at: datetime | None primary_owners: list[str] | None = None @@ -227,6 +230,8 @@ class SearchDoc(BaseModel): hidden: bool metadata: dict[str, str | list[str]] score: float | None + is_relevant: bool | None = None + relevance_explanation: str | None = None # Matched sections in the doc. Uses Vespa syntax e.g. TEXT # to specify that a set of words should be highlighted. For example: # ["the answer is 42", "the answer is 42""] diff --git a/backend/danswer/search/pipeline.py b/backend/danswer/search/pipeline.py index 2d990c15a3..4f9a2880df 100644 --- a/backend/danswer/search/pipeline.py +++ b/backend/danswer/search/pipeline.py @@ -5,10 +5,14 @@ from typing import cast from sqlalchemy.orm import Session +from danswer.chat.models import RelevanceChunk +from danswer.configs.chat_configs import DISABLE_AGENTIC_SEARCH from danswer.configs.chat_configs import MULTILINGUAL_QUERY_EXPANSION from danswer.db.embedding_model import get_current_db_embedding_model from danswer.db.models import User from danswer.document_index.factory import get_default_document_index +from danswer.llm.answering.models import DocumentPruningConfig +from danswer.llm.answering.models import PromptConfig from danswer.llm.answering.prune_and_merge import ChunkRange from danswer.llm.answering.prune_and_merge import merge_chunk_intervals from danswer.llm.interfaces import LLM @@ -25,7 +29,10 @@ from danswer.search.postprocessing.postprocessing import search_postprocessing from danswer.search.preprocessing.preprocessing import retrieval_preprocessing from danswer.search.retrieval.search_runner import retrieve_chunks from danswer.search.utils import inference_section_from_chunks +from danswer.secondary_llm_flows.agentic_evaluation import evaluate_inference_section from danswer.utils.logger import setup_logger +from danswer.utils.threadpool_concurrency import FunctionCall +from danswer.utils.threadpool_concurrency import run_functions_in_parallel from danswer.utils.threadpool_concurrency import run_functions_tuples_in_parallel logger = setup_logger() @@ -40,9 +47,12 @@ class SearchPipeline: fast_llm: LLM, db_session: Session, bypass_acl: bool = False, # NOTE: VERY DANGEROUS, USE WITH CAUTION - retrieval_metrics_callback: Callable[[RetrievalMetricsContainer], None] - | None = None, + retrieval_metrics_callback: ( + Callable[[RetrievalMetricsContainer], None] | None + ) = None, rerank_metrics_callback: Callable[[RerankMetricsContainer], None] | None = None, + prompt_config: PromptConfig | None = None, + pruning_config: DocumentPruningConfig | None = None, ): self.search_request = search_request self.user = user @@ -58,6 +68,8 @@ class SearchPipeline: primary_index_name=self.embedding_model.index_name, secondary_index_name=None, ) + self.prompt_config: PromptConfig | None = prompt_config + self.pruning_config: DocumentPruningConfig | None = pruning_config # Preprocessing steps generate this self._search_query: SearchQuery | None = None @@ -74,9 +86,9 @@ class SearchPipeline: self._relevant_section_indices: list[int] | None = None # Generates reranked chunks and LLM selections - self._postprocessing_generator: Iterator[ - list[InferenceSection] | list[int] - ] | None = None + self._postprocessing_generator: ( + Iterator[list[InferenceSection] | list[int]] | None + ) = None """Pre-processing""" @@ -323,6 +335,32 @@ class SearchPipeline: ) return self._relevant_section_indices + @property + def relevance_summaries(self) -> dict[str, RelevanceChunk]: + if DISABLE_AGENTIC_SEARCH: + raise ValueError( + "Agentic saerch operation called while DISABLE_AGENTIC_SEARCH is toggled" + ) + if len(self.reranked_sections) == 0: + logger.warning( + "No sections found in agentic search evalution. Returning empty dict." + ) + return {} + + sections = self.reranked_sections + functions = [ + FunctionCall( + evaluate_inference_section, (section, self.search_query.query, self.llm) + ) + for section in sections + ] + + results = run_functions_in_parallel(function_calls=functions) + + return { + next(iter(value)): value[next(iter(value))] for value in results.values() + } + @property def section_relevance_list(self) -> list[bool]: return [ diff --git a/backend/danswer/secondary_llm_flows/agentic_evaluation.py b/backend/danswer/secondary_llm_flows/agentic_evaluation.py new file mode 100644 index 0000000000..3f884e8f24 --- /dev/null +++ b/backend/danswer/secondary_llm_flows/agentic_evaluation.py @@ -0,0 +1,71 @@ +from danswer.chat.models import RelevanceChunk +from danswer.llm.interfaces import LLM +from danswer.llm.utils import message_to_string +from danswer.prompts.miscellaneous_prompts import AGENTIC_SEARCH_EVALUATION_PROMPT +from danswer.search.models import InferenceSection +from danswer.utils.logger import setup_logger + +logger = setup_logger() + + +def evaluate_inference_section( + document: InferenceSection, query: str, llm: LLM +) -> dict[str, RelevanceChunk]: + relevance: RelevanceChunk = RelevanceChunk() + results = {} + + # At least for now, is the same doucment ID across chunks + document_id = document.center_chunk.document_id + chunk_id = document.center_chunk.chunk_id + + prompt = f""" + Analyze the relevance of this document to the search query: + Title: {document_id.split("/")[-1]} + Blurb: {document.combined_content} + Query: {query} + + {AGENTIC_SEARCH_EVALUATION_PROMPT} + """ + + content = message_to_string(llm.invoke(prompt=prompt)) + analysis = "" + relevant = False + chain_of_thought = "" + + parts = content.split("[ANALYSIS_START]", 1) + if len(parts) == 2: + chain_of_thought, rest = parts + else: + logger.warning(f"Missing [ANALYSIS_START] tag for document {document_id}") + rest = content + + parts = rest.split("[ANALYSIS_END]", 1) + if len(parts) == 2: + analysis, result = parts + else: + logger.warning(f"Missing [ANALYSIS_END] tag for document {document_id}") + result = rest + + chain_of_thought = chain_of_thought.strip() + analysis = analysis.strip() + result = result.strip().lower() + + # Determine relevance + if "result: true" in result: + relevant = True + elif "result: false" in result: + relevant = False + else: + logger.warning(f"Invalid result format for document {document_id}") + + if not analysis: + logger.warning( + f"Couldn't extract proper analysis for document {document_id}. Using full content." + ) + analysis = content + + relevance.content = analysis + relevance.relevant = relevant + + results[f"{document_id}-{chunk_id}"] = relevance + return results diff --git a/backend/danswer/server/query_and_chat/chat_backend.py b/backend/danswer/server/query_and_chat/chat_backend.py index e4172b8c6b..5e109b7942 100644 --- a/backend/danswer/server/query_and_chat/chat_backend.py +++ b/backend/danswer/server/query_and_chat/chat_backend.py @@ -303,7 +303,6 @@ def handle_new_chat_message( request.headers ), ) - return StreamingResponse(packets, media_type="application/json") diff --git a/backend/danswer/server/query_and_chat/models.py b/backend/danswer/server/query_and_chat/models.py index ea1ce1ff68..83f8560de9 100644 --- a/backend/danswer/server/query_and_chat/models.py +++ b/backend/danswer/server/query_and_chat/models.py @@ -171,7 +171,6 @@ class SearchFeedbackRequest(BaseModel): if click is False and feedback is None: raise ValueError("Empty feedback received.") - return values @@ -186,6 +185,7 @@ class ChatMessageDetail(BaseModel): time_sent: datetime alternate_assistant_id: str | None # Dict mapping citation number to db_doc_id + chat_session_id: int | None = None citations: dict[int, int] | None files: list[FileDescriptor] tool_calls: list[ToolCallFinalResult] @@ -196,6 +196,13 @@ class ChatMessageDetail(BaseModel): return initial_dict +class SearchSessionDetailResponse(BaseModel): + search_session_id: int + description: str + documents: list[SearchDoc] + messages: list[ChatMessageDetail] + + class ChatSessionDetailResponse(BaseModel): chat_session_id: int description: str diff --git a/backend/danswer/server/query_and_chat/query_backend.py b/backend/danswer/server/query_and_chat/query_backend.py index a7505178b1..e7e1ca493b 100644 --- a/backend/danswer/server/query_and_chat/query_backend.py +++ b/backend/danswer/server/query_and_chat/query_backend.py @@ -7,6 +7,14 @@ from sqlalchemy.orm import Session from danswer.auth.users import current_admin_user from danswer.auth.users import current_user from danswer.configs.constants import DocumentSource +from danswer.configs.constants import MessageType +from danswer.db.chat import get_chat_messages_by_session +from danswer.db.chat import get_chat_session_by_id +from danswer.db.chat import get_chat_sessions_by_user +from danswer.db.chat import get_first_messages_for_chat_sessions +from danswer.db.chat import get_search_docs_for_chat_message +from danswer.db.chat import translate_db_message_to_chat_message_detail +from danswer.db.chat import translate_db_search_doc_to_server_search_doc from danswer.db.embedding_model import get_current_db_embedding_model from danswer.db.engine import get_session from danswer.db.models import User @@ -24,8 +32,11 @@ from danswer.secondary_llm_flows.query_validation import get_query_answerability from danswer.secondary_llm_flows.query_validation import stream_query_answerability from danswer.server.query_and_chat.models import AdminSearchRequest from danswer.server.query_and_chat.models import AdminSearchResponse +from danswer.server.query_and_chat.models import ChatSessionDetails +from danswer.server.query_and_chat.models import ChatSessionsResponse from danswer.server.query_and_chat.models import HelperResponse from danswer.server.query_and_chat.models import QueryValidationResponse +from danswer.server.query_and_chat.models import SearchSessionDetailResponse from danswer.server.query_and_chat.models import SimpleQueryRequest from danswer.server.query_and_chat.models import SourceTag from danswer.server.query_and_chat.models import TagResponse @@ -46,7 +57,6 @@ def admin_search( ) -> AdminSearchResponse: query = question.query logger.info(f"Received admin search query: {query}") - user_acl_filters = build_access_filters_for_user(user, db_session) final_filters = IndexFilters( source_type=question.filters.source_type, @@ -55,19 +65,15 @@ def admin_search( tags=question.filters.tags, access_control_list=user_acl_filters, ) - embedding_model = get_current_db_embedding_model(db_session) - document_index = get_default_document_index( primary_index_name=embedding_model.index_name, secondary_index_name=None ) - if not isinstance(document_index, VespaIndex): raise HTTPException( status_code=400, detail="Cannot use admin-search when using a non-Vespa document index", ) - matching_chunks = document_index.admin_retrieval(query=query, filters=final_filters) documents = chunks_or_sections_to_search_docs(matching_chunks) @@ -136,6 +142,103 @@ def query_validation( return QueryValidationResponse(reasoning=reasoning, answerable=answerable) +@basic_router.get("/user-searches") +def get_user_search_sessions( + user: User | None = Depends(current_user), + db_session: Session = Depends(get_session), +) -> ChatSessionsResponse: + user_id = user.id if user is not None else None + + try: + search_sessions = get_chat_sessions_by_user( + user_id=user_id, deleted=False, db_session=db_session, only_one_shot=True + ) + except ValueError: + raise HTTPException( + status_code=404, detail="Chat session does not exist or has been deleted" + ) + + search_session_ids = [chat.id for chat in search_sessions] + first_messages = get_first_messages_for_chat_sessions( + search_session_ids, db_session + ) + first_messages_dict = dict(first_messages) + + response = ChatSessionsResponse( + sessions=[ + ChatSessionDetails( + id=search.id, + name=first_messages_dict.get(search.id, search.description), + persona_id=search.persona_id, + time_created=search.time_created.isoformat(), + shared_status=search.shared_status, + folder_id=search.folder_id, + current_alternate_model=search.current_alternate_model, + ) + for search in search_sessions + ] + ) + return response + + +@basic_router.get("/search-session/{session_id}") +def get_search_session( + session_id: int, + is_shared: bool = False, + user: User | None = Depends(current_user), + db_session: Session = Depends(get_session), +) -> SearchSessionDetailResponse: + user_id = user.id if user is not None else None + + try: + search_session = get_chat_session_by_id( + chat_session_id=session_id, + user_id=user_id, + db_session=db_session, + is_shared=is_shared, + ) + except ValueError: + raise ValueError("Search session does not exist or has been deleted") + + session_messages = get_chat_messages_by_session( + chat_session_id=session_id, + user_id=user_id, + db_session=db_session, + # we already did a permission check above with the call to + # `get_chat_session_by_id`, so we can skip it here + skip_permission_check=True, + # we need the tool call objs anyways, so just fetch them in a single call + prefetch_tool_calls=True, + ) + docs_response: list[SearchDoc] = [] + for message in session_messages: + if ( + message.message_type == MessageType.ASSISTANT + or message.message_type == MessageType.SYSTEM + ): + docs = get_search_docs_for_chat_message( + db_session=db_session, chat_message_id=message.id + ) + for doc in docs: + server_doc = translate_db_search_doc_to_server_search_doc(doc) + docs_response.append(server_doc) + + response = SearchSessionDetailResponse( + search_session_id=session_id, + description=search_session.description, + documents=docs_response, + messages=[ + translate_db_message_to_chat_message_detail( + msg, remove_doc_content=is_shared # if shared, don't leak doc content + ) + for msg in session_messages + ], + ) + return response + + +# NOTE No longer used, after search/chat redesign. +# No search responses are answered with a conversational generative AI response @basic_router.post("/stream-query-validation") def stream_query_validation( simple_query: SimpleQueryRequest, _: User = Depends(current_user) @@ -156,6 +259,7 @@ def get_answer_with_quote( _: None = Depends(check_token_rate_limits), ) -> StreamingResponse: query = query_request.messages[0].message + logger.info(f"Received query for one shot answer with quotes: {query}") packets = stream_search_answer( query_req=query_request, diff --git a/backend/danswer/tools/search/search_tool.py b/backend/danswer/tools/search/search_tool.py index 44c5001d6c..1706ba2646 100644 --- a/backend/danswer/tools/search/search_tool.py +++ b/backend/danswer/tools/search/search_tool.py @@ -10,6 +10,7 @@ from danswer.chat.chat_utils import llm_doc_from_inference_section from danswer.chat.models import DanswerContext from danswer.chat.models import DanswerContexts from danswer.chat.models import LlmDoc +from danswer.configs.chat_configs import DISABLE_AGENTIC_SEARCH from danswer.db.models import Persona from danswer.db.models import User from danswer.dynamic_configs.interface import JSON_ro @@ -30,11 +31,15 @@ from danswer.secondary_llm_flows.query_expansion import history_based_query_reph from danswer.tools.search.search_utils import llm_doc_to_dict from danswer.tools.tool import Tool from danswer.tools.tool import ToolResponse +from danswer.utils.logger import setup_logger + +logger = setup_logger() SEARCH_RESPONSE_SUMMARY_ID = "search_response_summary" SEARCH_DOC_CONTENT_ID = "search_doc_content" SECTION_RELEVANCE_LIST_ID = "section_relevance_list" FINAL_CONTEXT_DOCUMENTS = "final_context_documents" +SEARCH_EVALUATION_ID = "evaluate_response" class SearchResponseSummary(BaseModel): @@ -80,6 +85,7 @@ class SearchTool(Tool): chunks_below: int = 0, full_doc: bool = False, bypass_acl: bool = False, + evaluate_response: bool = False, ) -> None: self.user = user self.persona = persona @@ -96,6 +102,7 @@ class SearchTool(Tool): self.full_doc = full_doc self.bypass_acl = bypass_acl self.db_session = db_session + self.evaluate_response = evaluate_response @property def name(self) -> str: @@ -218,23 +225,28 @@ class SearchTool(Tool): self.retrieval_options.filters if self.retrieval_options else None ), persona=self.persona, - offset=self.retrieval_options.offset - if self.retrieval_options - else None, + offset=( + self.retrieval_options.offset if self.retrieval_options else None + ), limit=self.retrieval_options.limit if self.retrieval_options else None, chunks_above=self.chunks_above, chunks_below=self.chunks_below, full_doc=self.full_doc, - enable_auto_detect_filters=self.retrieval_options.enable_auto_detect_filters - if self.retrieval_options - else None, + enable_auto_detect_filters=( + self.retrieval_options.enable_auto_detect_filters + if self.retrieval_options + else None + ), ), user=self.user, llm=self.llm, fast_llm=self.fast_llm, bypass_acl=self.bypass_acl, db_session=self.db_session, + prompt_config=self.prompt_config, + pruning_config=self.pruning_config, ) + yield ToolResponse( id=SEARCH_RESPONSE_SUMMARY_ID, response=SearchResponseSummary( @@ -246,6 +258,7 @@ class SearchTool(Tool): recency_bias_multiplier=search_pipeline.search_query.recency_bias_multiplier, ), ) + yield ToolResponse( id=SEARCH_DOC_CONTENT_ID, response=DanswerContexts( @@ -260,6 +273,7 @@ class SearchTool(Tool): ] ), ) + yield ToolResponse( id=SECTION_RELEVANCE_LIST_ID, response=search_pipeline.relevant_section_indices, @@ -281,6 +295,11 @@ class SearchTool(Tool): yield ToolResponse(id=FINAL_CONTEXT_DOCUMENTS, response=llm_docs) + if self.evaluate_response and not DISABLE_AGENTIC_SEARCH: + yield ToolResponse( + id=SEARCH_EVALUATION_ID, response=search_pipeline.relevance_summaries + ) + def final_result(self, *args: ToolResponse) -> JSON_ro: final_docs = cast( list[LlmDoc], diff --git a/backend/danswer/utils/threadpool_concurrency.py b/backend/danswer/utils/threadpool_concurrency.py index 463d43c1a7..d8fc40a7d9 100644 --- a/backend/danswer/utils/threadpool_concurrency.py +++ b/backend/danswer/utils/threadpool_concurrency.py @@ -87,6 +87,7 @@ def run_functions_in_parallel( are the result_id of the FunctionCall and the values are the results of the call. """ results = {} + with ThreadPoolExecutor(max_workers=len(function_calls)) as executor: future_to_id = { executor.submit(func_call.execute): func_call.result_id diff --git a/backend/tests/regression/answer_quality/__init__.py b/backend/tests/regression/answer_quality/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/throttle.ctrl b/backend/throttle.ctrl index 03aa917953..f187eb1231 100644 --- a/backend/throttle.ctrl +++ b/backend/throttle.ctrl @@ -1 +1 @@ -f1f2 1 1718910083.03085 wikipedia:en \ No newline at end of file +f1f2 2 1721064549.902656 wikipedia:en diff --git a/deployment/docker_compose/docker-compose.dev.yml b/deployment/docker_compose/docker-compose.dev.yml index 9773527015..97a7df6515 100644 --- a/deployment/docker_compose/docker-compose.dev.yml +++ b/deployment/docker_compose/docker-compose.dev.yml @@ -51,6 +51,7 @@ services: - DISABLE_LITELLM_STREAMING=${DISABLE_LITELLM_STREAMING:-} - LITELLM_EXTRA_HEADERS=${LITELLM_EXTRA_HEADERS:-} - BING_API_KEY=${BING_API_KEY:-} + - DISABLE_AGENTIC_SEARCH=${DISABLE_AGENTIC_SEARCH:-} # if set, allows for the use of the token budget system - TOKEN_BUDGET_GLOBALLY_ENABLED=${TOKEN_BUDGET_GLOBALLY_ENABLED:-} # Enables the use of bedrock models @@ -230,6 +231,7 @@ services: - INTERNAL_URL=http://api_server:8080 - WEB_DOMAIN=${WEB_DOMAIN:-} - THEME_IS_DARK=${THEME_IS_DARK:-} + - DISABLE_AGENTIC_SEARCH=${DISABLE_AGENTIC_SEARCH:-} # Enterprise Edition only - ENABLE_PAID_ENTERPRISE_EDITION_FEATURES=${ENABLE_PAID_ENTERPRISE_EDITION_FEATURES:-false} diff --git a/web/src/app/admin/assistants/AssistantEditor.tsx b/web/src/app/admin/assistants/AssistantEditor.tsx index 665af18743..0088abdea5 100644 --- a/web/src/app/admin/assistants/AssistantEditor.tsx +++ b/web/src/app/admin/assistants/AssistantEditor.tsx @@ -400,7 +400,7 @@ export function AssistantEditor({ } return ( -
+
-
+
{ccPairs.length > 0 && ( <> diff --git a/web/src/app/admin/assistants/CollapsibleSection.tsx b/web/src/app/admin/assistants/CollapsibleSection.tsx index 72b598846e..d442efea83 100644 --- a/web/src/app/admin/assistants/CollapsibleSection.tsx +++ b/web/src/app/admin/assistants/CollapsibleSection.tsx @@ -39,8 +39,10 @@ const CollapsibleSection: React.FC = ({ `} onClick={toggleCollapse} > + {" "} + Great and also a {isCollapsed ? ( - + {prompt}{" "} diff --git a/web/src/app/admin/assistants/new/page.tsx b/web/src/app/admin/assistants/new/page.tsx index 5123dc4f4f..ee6b28328f 100644 --- a/web/src/app/admin/assistants/new/page.tsx +++ b/web/src/app/admin/assistants/new/page.tsx @@ -28,7 +28,7 @@ export default async function Page() { } return ( -
+
- } title="Assistants" /> + } title="Assistants" /> Assistants are a way to build custom search/question-answering diff --git a/web/src/app/admin/bot/page.tsx b/web/src/app/admin/bot/page.tsx index 8c1eca6781..14f270ee9b 100644 --- a/web/src/app/admin/bot/page.tsx +++ b/web/src/app/admin/bot/page.tsx @@ -301,7 +301,7 @@ const Page = () => { return (
} + icon={} title="Slack Bot Configuration" /> diff --git a/web/src/app/admin/connectors/gmail/Credential.tsx b/web/src/app/admin/connectors/gmail/Credential.tsx index 68f5bba2d2..b92399e069 100644 --- a/web/src/app/admin/connectors/gmail/Credential.tsx +++ b/web/src/app/admin/connectors/gmail/Credential.tsx @@ -34,7 +34,7 @@ const DriveJsonUpload = ({ { if (enabled) { setAlreadySelectedModel(model); diff --git a/web/src/app/admin/models/embedding/page.tsx b/web/src/app/admin/models/embedding/page.tsx index 92e7de6df9..828cd902c3 100644 --- a/web/src/app/admin/models/embedding/page.tsx +++ b/web/src/app/admin/models/embedding/page.tsx @@ -39,6 +39,7 @@ export interface EmbeddingDetails { default_model_id?: number; name: string; } +import { EmbeddingIcon, PackageIcon } from "@/components/icons/icons"; function Main() { const [openToggle, setOpenToggle] = useState(true); @@ -364,17 +365,25 @@ function Main() { monitor the progress of the re-indexing on this page. -
+
@@ -516,7 +525,7 @@ function Page() {
} + icon={} />
diff --git a/web/src/app/admin/models/llm/page.tsx b/web/src/app/admin/models/llm/page.tsx index a774b36966..9771a53c3a 100644 --- a/web/src/app/admin/models/llm/page.tsx +++ b/web/src/app/admin/models/llm/page.tsx @@ -3,13 +3,14 @@ import { AdminPageTitle } from "@/components/admin/Title"; import { FiCpu } from "react-icons/fi"; import { LLMConfiguration } from "./LLMConfiguration"; +import { CpuIcon } from "@/components/icons/icons"; const Page = () => { return (
} + icon={} /> diff --git a/web/src/app/admin/settings/page.tsx b/web/src/app/admin/settings/page.tsx index eff4262f0d..1fe2ca8833 100644 --- a/web/src/app/admin/settings/page.tsx +++ b/web/src/app/admin/settings/page.tsx @@ -2,13 +2,14 @@ import { AdminPageTitle } from "@/components/admin/Title"; import { FiSettings } from "react-icons/fi"; import { SettingsForm } from "./SettingsForm"; import { Text } from "@tremor/react"; +import { SettingsIcon } from "@/components/icons/icons"; export default async function Page() { return (
} + icon={} /> diff --git a/web/src/app/admin/token-rate-limits/page.tsx b/web/src/app/admin/token-rate-limits/page.tsx index 7aeff20c67..fb4b711a2e 100644 --- a/web/src/app/admin/token-rate-limits/page.tsx +++ b/web/src/app/admin/token-rate-limits/page.tsx @@ -23,6 +23,7 @@ import { mutate } from "swr"; import { usePopup } from "@/components/admin/connectors/Popup"; import { CreateRateLimitModal } from "./CreateRateLimitModal"; import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled"; +import { ShieldIcon } from "@/components/icons/icons"; const BASE_URL = "/api/admin/token-rate-limits"; const GLOBAL_TOKEN_FETCH_URL = `${BASE_URL}/global`; @@ -219,8 +220,10 @@ function Main() { export default function Page() { return (
- } /> - + } + />
); diff --git a/web/src/app/admin/tools/edit/[toolId]/page.tsx b/web/src/app/admin/tools/edit/[toolId]/page.tsx index 8dd54be46b..8ae1e908a2 100644 --- a/web/src/app/admin/tools/edit/[toolId]/page.tsx +++ b/web/src/app/admin/tools/edit/[toolId]/page.tsx @@ -6,6 +6,7 @@ import { DeleteToolButton } from "./DeleteToolButton"; import { FiTool } from "react-icons/fi"; import { AdminPageTitle } from "@/components/admin/Title"; import { BackButton } from "@/components/BackButton"; +import { ToolIcon } from "@/components/icons/icons"; export default async function Page({ params }: { params: { toolId: string } }) { const tool = await fetchToolByIdSS(params.toolId); @@ -46,7 +47,7 @@ export default async function Page({ params }: { params: { toolId: string } }) { } + icon={} /> {body} diff --git a/web/src/app/admin/tools/new/page.tsx b/web/src/app/admin/tools/new/page.tsx index 5d1723f96a..efff155be5 100644 --- a/web/src/app/admin/tools/new/page.tsx +++ b/web/src/app/admin/tools/new/page.tsx @@ -3,6 +3,7 @@ import { ToolEditor } from "@/app/admin/tools/ToolEditor"; import { BackButton } from "@/components/BackButton"; import { AdminPageTitle } from "@/components/admin/Title"; +import { ToolIcon } from "@/components/icons/icons"; import { Card } from "@tremor/react"; import { FiTool } from "react-icons/fi"; @@ -13,7 +14,7 @@ export default function NewToolPage() { } + icon={} /> diff --git a/web/src/app/admin/tools/page.tsx b/web/src/app/admin/tools/page.tsx index 7b9edf7abe..543f89ac36 100644 --- a/web/src/app/admin/tools/page.tsx +++ b/web/src/app/admin/tools/page.tsx @@ -6,6 +6,7 @@ import { Divider, Text, Title } from "@tremor/react"; import { fetchSS } from "@/lib/utilsSS"; import { ErrorCallout } from "@/components/ErrorCallout"; import { AdminPageTitle } from "@/components/admin/Title"; +import { ToolIcon } from "@/components/icons/icons"; export default async function Page() { const toolResponse = await fetchSS("/tool"); @@ -24,7 +25,7 @@ export default async function Page() { return (
} + icon={} title="Tools" /> diff --git a/web/src/app/assistants/SidebarWrapper.tsx b/web/src/app/assistants/SidebarWrapper.tsx new file mode 100644 index 0000000000..87081e07f5 --- /dev/null +++ b/web/src/app/assistants/SidebarWrapper.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { HistorySidebar } from "@/app/chat/sessionSidebar/HistorySidebar"; +import { AssistantsGallery } from "./gallery/AssistantsGallery"; +import FixedLogo from "@/app/chat/shared_chat_search/FixedLogo"; +import { UserDropdown } from "@/components/UserDropdown"; +import { ChatSession } from "@/app/chat/interfaces"; +import { Folder } from "@/app/chat/folders/interfaces"; +import { User } from "@/lib/types"; +import { Persona } from "@/app/admin/assistants/interfaces"; +import Cookies from "js-cookie"; +import { SIDEBAR_TOGGLED_COOKIE_NAME } from "@/components/resizable/contants"; +import { ReactNode, useEffect, useRef, useState } from "react"; +import { useSidebarVisibility } from "@/components/chat_search/hooks"; +import FunctionalHeader from "@/components/chat_search/Header"; +import { useRouter } from "next/navigation"; + +interface SidebarWrapperProps { + chatSessions: ChatSession[]; + folders: Folder[]; + initiallyToggled: boolean; + openedFolders?: { [key: number]: boolean }; + content: (props: T) => ReactNode; + headerProps: { + page: string; + user: User | null; + }; + contentProps: T; +} + +export default function SidebarWrapper({ + chatSessions, + initiallyToggled, + folders, + openedFolders, + headerProps, + contentProps, + content, +}: SidebarWrapperProps) { + const [toggledSidebar, setToggledSidebar] = useState(initiallyToggled); + + const [showDocSidebar, setShowDocSidebar] = useState(false); // State to track if sidebar is open + + const toggleSidebar = () => { + Cookies.set( + SIDEBAR_TOGGLED_COOKIE_NAME, + String(!toggledSidebar).toLocaleLowerCase() + ), + { + path: "/", + }; + setToggledSidebar((toggledSidebar) => !toggledSidebar); + }; + + const sidebarElementRef = useRef(null); + + useSidebarVisibility({ + toggledSidebar, + sidebarElementRef, + showDocSidebar, + setShowDocSidebar, + }); + + const innerSidebarElementRef = useRef(null); + const router = useRouter(); + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.metaKey || event.ctrlKey) { + switch (event.key.toLowerCase()) { + case "e": + event.preventDefault(); + toggleSidebar(); + break; + } + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, [router]); + + return ( +
+
+
+ +
+
+ +
+ +
+
+ +
{content(contentProps)}
+
+
+
+ ); +} diff --git a/web/src/app/assistants/ToolsDisplay.tsx b/web/src/app/assistants/ToolsDisplay.tsx index f0a331a21e..443f1ac57a 100644 --- a/web/src/app/assistants/ToolsDisplay.tsx +++ b/web/src/app/assistants/ToolsDisplay.tsx @@ -5,6 +5,7 @@ import { FiImage, FiSearch, FiGlobe } from "react-icons/fi"; export function ToolsDisplay({ tools }: { tools: ToolSnapshot[] }) { return (
+

Tools:

{tools.map((tool) => { let toolName = tool.name; let toolIcon = null; diff --git a/web/src/app/assistants/gallery/WrappedAssistantsGallery.tsx b/web/src/app/assistants/gallery/WrappedAssistantsGallery.tsx new file mode 100644 index 0000000000..97a5ba6cb9 --- /dev/null +++ b/web/src/app/assistants/gallery/WrappedAssistantsGallery.tsx @@ -0,0 +1,44 @@ +"use client"; + +import SidebarWrapper from "../SidebarWrapper"; +import { ChatSession } from "@/app/chat/interfaces"; +import { Folder } from "@/app/chat/folders/interfaces"; +import { Persona } from "@/app/admin/assistants/interfaces"; +import { User } from "@/lib/types"; +import { AssistantsGallery } from "./AssistantsGallery"; + +export default function WrappedAssistantsGallery({ + chatSessions, + initiallyToggled, + folders, + openedFolders, + user, + assistants, +}: { + chatSessions: ChatSession[]; + folders: Folder[]; + initiallyToggled: boolean; + openedFolders?: { [key: number]: boolean }; + user: User | null; + assistants: Persona[]; +}) { + return ( + ( + + )} + /> + ); +} diff --git a/web/src/app/assistants/gallery/page.tsx b/web/src/app/assistants/gallery/page.tsx index 86b9a6a8b5..1c2b5c9c77 100644 --- a/web/src/app/assistants/gallery/page.tsx +++ b/web/src/app/assistants/gallery/page.tsx @@ -1,4 +1,4 @@ -import { ChatSidebar } from "@/app/chat/sessionSidebar/ChatSidebar"; +import { HistorySidebar } from "@/app/chat/sessionSidebar/HistorySidebar"; import { InstantSSRAutoRefresh } from "@/components/SSRAutoRefresh"; import { UserDropdown } from "@/components/UserDropdown"; import { ChatProvider } from "@/components/context/ChatContext"; @@ -7,6 +7,9 @@ import { fetchChatData } from "@/lib/chat/fetchChatData"; import { unstable_noStore as noStore } from "next/cache"; import { redirect } from "next/navigation"; import { AssistantsGallery } from "./AssistantsGallery"; +import FixedLogo from "@/app/chat/shared_chat_search/FixedLogo"; +import GalleryWrapper from "../SidebarWrapper"; +import WrappedAssistantsGallery from "./WrappedAssistantsGallery"; export default async function GalleryPage({ searchParams, @@ -32,6 +35,7 @@ export default async function GalleryPage({ folders, openedFolders, shouldShowWelcomeModal, + toggleSidebar, } = data; return ( @@ -53,28 +57,17 @@ export default async function GalleryPage({ openedFolders, }} > -
- + -
-
-
- -
-
- -
- -
-
-
+ {/* Temporary - fixed logo */} + ); diff --git a/web/src/app/assistants/mine/WrappedAssistantsMine.tsx b/web/src/app/assistants/mine/WrappedAssistantsMine.tsx new file mode 100644 index 0000000000..873dc226a5 --- /dev/null +++ b/web/src/app/assistants/mine/WrappedAssistantsMine.tsx @@ -0,0 +1,43 @@ +"use client"; +import { AssistantsList } from "./AssistantsList"; +import SidebarWrapper from "../SidebarWrapper"; +import { ChatSession } from "@/app/chat/interfaces"; +import { Folder } from "@/app/chat/folders/interfaces"; +import { Persona } from "@/app/admin/assistants/interfaces"; +import { User } from "@/lib/types"; + +export default function WrappedAssistantsMine({ + chatSessions, + initiallyToggled, + folders, + openedFolders, + user, + assistants, +}: { + chatSessions: ChatSession[]; + folders: Folder[]; + initiallyToggled: boolean; + openedFolders?: { [key: number]: boolean }; + user: User | null; + assistants: Persona[]; +}) { + return ( + ( + + )} + /> + ); +} diff --git a/web/src/app/assistants/mine/page.tsx b/web/src/app/assistants/mine/page.tsx index c1cac2273e..748217b5d6 100644 --- a/web/src/app/assistants/mine/page.tsx +++ b/web/src/app/assistants/mine/page.tsx @@ -1,4 +1,4 @@ -import { ChatSidebar } from "@/app/chat/sessionSidebar/ChatSidebar"; +import { HistorySidebar } from "@/app/chat/sessionSidebar/HistorySidebar"; import { InstantSSRAutoRefresh } from "@/components/SSRAutoRefresh"; import { UserDropdown } from "@/components/UserDropdown"; import { ChatProvider } from "@/components/context/ChatContext"; @@ -8,6 +8,10 @@ import { fetchChatData } from "@/lib/chat/fetchChatData"; import { unstable_noStore as noStore } from "next/cache"; import { redirect } from "next/navigation"; import { AssistantsList } from "./AssistantsList"; +import { Logo } from "@/components/Logo"; +import FixedLogo from "@/app/chat/shared_chat_search/FixedLogo"; +import SidebarWrapper from "../SidebarWrapper"; +import WrappedAssistantsMine from "./WrappedAssistantsMine"; export default async function GalleryPage({ searchParams, @@ -33,6 +37,7 @@ export default async function GalleryPage({ folders, openedFolders, shouldShowWelcomeModal, + toggleSidebar, } = data; return ( @@ -54,28 +59,17 @@ export default async function GalleryPage({ openedFolders, }} > -
- + -
-
-
- -
-
- -
- -
-
-
+ {/* Temporary - fixed logo */} + ); diff --git a/web/src/app/auth/login/SignInButton.tsx b/web/src/app/auth/login/SignInButton.tsx index 3dda2afff7..9d04321e80 100644 --- a/web/src/app/auth/login/SignInButton.tsx +++ b/web/src/app/auth/login/SignInButton.tsx @@ -42,7 +42,7 @@ export function SignInButton({ return ( {button} diff --git a/web/src/app/chat/ChatBanner.tsx b/web/src/app/chat/ChatBanner.tsx index 4158549574..39df94e999 100644 --- a/web/src/app/chat/ChatBanner.tsx +++ b/web/src/app/chat/ChatBanner.tsx @@ -16,7 +16,7 @@ export function ChatBanner() { className={` z-[39] h-[30px] - bg-background-custom-header + bg-background-100 shadow-sm m-2 rounded diff --git a/web/src/app/chat/ChatIntro.tsx b/web/src/app/chat/ChatIntro.tsx index 926656958e..e6a40b1fd2 100644 --- a/web/src/app/chat/ChatIntro.tsx +++ b/web/src/app/chat/ChatIntro.tsx @@ -35,17 +35,13 @@ export function ChatIntro({ }) { const availableSourceMetadata = getSourceMetadataForSources(availableSources); - const [displaySources, setDisplaySources] = useState(false); - return ( <>
- - -
+
{selectedPersona?.name || "How can I help you today?"}
{selectedPersona && ( diff --git a/web/src/app/chat/ChatPage.tsx b/web/src/app/chat/ChatPage.tsx index 6321fa9a3e..a24a7cc05d 100644 --- a/web/src/app/chat/ChatPage.tsx +++ b/web/src/app/chat/ChatPage.tsx @@ -1,6 +1,6 @@ "use client"; -import { useRouter, useSearchParams } from "next/navigation"; +import { redirect, useRouter, useSearchParams } from "next/navigation"; import { BackendChatSession, BackendMessage, @@ -14,7 +14,10 @@ import { StreamingError, ToolCallMetadata, } from "./interfaces"; -import { ChatSidebar } from "./sessionSidebar/ChatSidebar"; + +import Cookies from "js-cookie"; + +import { HistorySidebar } from "./sessionSidebar/HistorySidebar"; import { Persona } from "../admin/assistants/interfaces"; import { HealthCheckBanner } from "@/components/health/healthcheck"; import { InstantSSRAutoRefresh } from "@/components/SSRAutoRefresh"; @@ -49,8 +52,7 @@ import { DocumentSidebar } from "./documentSidebar/DocumentSidebar"; import { DanswerInitializingLoader } from "@/components/DanswerInitializingLoader"; import { FeedbackModal } from "./modal/FeedbackModal"; import { ShareChatSessionModal } from "./modal/ShareChatSessionModal"; -import { ChatPersonaSelector } from "./ChatPersonaSelector"; -import { FiArrowDown, FiShare2 } from "react-icons/fi"; +import { FiArrowDown } from "react-icons/fi"; import { ChatIntro } from "./ChatIntro"; import { AIMessage, HumanMessage } from "./message/Messages"; import { ThreeDots } from "react-loader-spinner"; @@ -63,26 +65,30 @@ import { checkLLMSupportsImageInput, getFinalLLM } from "@/lib/llm/utils"; 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"; import { orderAssistantsForUser } from "@/lib/assistants/orderAssistants"; import { ChatPopup } from "./ChatPopup"; import { ChatBanner } from "./ChatBanner"; -import { TbLayoutSidebarRightExpand } from "react-icons/tb"; -import { SIDEBAR_WIDTH_CONST } from "@/lib/constants"; -import ResizableSection from "@/components/resizable/ResizableSection"; +import FunctionalHeader from "@/components/chat_search/Header"; +import { useSidebarVisibility } from "@/components/chat_search/hooks"; +import { SIDEBAR_TOGGLED_COOKIE_NAME } from "@/components/resizable/contants"; +import FixedLogo from "./shared_chat_search/FixedLogo"; const TEMP_USER_MESSAGE_ID = -1; const TEMP_ASSISTANT_MESSAGE_ID = -2; const SYSTEM_MESSAGE_ID = -3; export function ChatPage({ + toggle, documentSidebarInitialWidth, defaultSelectedPersonaId, + toggledSidebar, }: { + toggle: () => void; documentSidebarInitialWidth?: number; defaultSelectedPersonaId?: number; + toggledSidebar: boolean; }) { const [configModalActiveTab, setConfigModalActiveTab] = useState< string | null @@ -265,19 +271,6 @@ export function ChatPage({ initialSessionFetch(); }, [existingChatSessionId]); - const [usedSidebarWidth, setUsedSidebarWidth] = useState( - documentSidebarInitialWidth || parseInt(SIDEBAR_WIDTH_CONST) - ); - - const updateSidebarWidth = (newWidth: number) => { - setUsedSidebarWidth(newWidth); - if (sidebarElementRef.current && innerSidebarElementRef.current) { - sidebarElementRef.current.style.transition = ""; - sidebarElementRef.current.style.width = `${newWidth}px`; - innerSidebarElementRef.current.style.width = `${newWidth}px`; - } - }; - const [message, setMessage] = useState( searchParams.get(SEARCH_PARAM_NAMES.USER_MESSAGE) || "" ); @@ -509,7 +502,6 @@ export function ChatPage({ } else { endDivRef.current?.scrollIntoView({ behavior: "smooth" }); } - setHasPerformedInitialScroll(true); }, 50); }; @@ -788,6 +780,7 @@ export function ChatPage({ searchParams.get(SEARCH_PARAM_NAMES.SYSTEM_PROMPT) || undefined, useExistingUserMessage: isSeededChat, }); + const updateFn = (messages: Message[]) => { const replacementsMap = finalMessage ? new Map([ @@ -1039,20 +1032,29 @@ export function ChatPage({ router.push("/search"); } - const [showDocSidebar, setShowDocSidebar] = useState(true); // State to track if sidebar is open + const [showDocSidebar, setShowDocSidebar] = useState(false); // State to track if sidebar is open const toggleSidebar = () => { - if (sidebarElementRef.current) { - sidebarElementRef.current.style.transition = "width 0.3s ease-in-out"; + Cookies.set( + SIDEBAR_TOGGLED_COOKIE_NAME, + String(!toggledSidebar).toLocaleLowerCase() + ), + { + path: "/", + }; - sidebarElementRef.current.style.width = showDocSidebar - ? "0px" - : `${usedSidebarWidth}px`; - } - - setShowDocSidebar((showDocSidebar) => !showDocSidebar); // Toggle the state which will in turn toggle the class + toggle(); }; + const sidebarElementRef = useRef(null); + + useSidebarVisibility({ + toggledSidebar, + sidebarElementRef, + showDocSidebar, + setShowDocSidebar, + }); + useEffect(() => { const includes = checkAnyAssistantHasSearch( messageHistory, @@ -1069,21 +1071,35 @@ export function ChatPage({ livePersona ); }); - const [editingRetrievalEnabled, setEditingRetrievalEnabled] = useState(false); - const sidebarElementRef = useRef(null); + const innerSidebarElementRef = useRef(null); const currentPersona = selectedAssistant || livePersona; - const updateSelectedAssistant = (newAssistant: Persona | null) => { - setSelectedAssistant(newAssistant); - if (newAssistant) { - setEditingRetrievalEnabled(personaIncludesRetrieval(newAssistant)); - } else { - setEditingRetrievalEnabled(false); - } + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.metaKey || event.ctrlKey) { + switch (event.key.toLowerCase()) { + case "e": + event.preventDefault(); + toggleSidebar(); + break; + } + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, [router]); + + const [documentSelection, setDocumentSelection] = useState(false); + const toggleDocumentSelectionAspects = () => { + setDocumentSelection((documentSelection) => !documentSelection); + setShowDocSidebar(false); }; - console.log(hasPerformedInitialScroll); + return ( <> @@ -1093,14 +1109,40 @@ export function ChatPage({ Only used in the EE version of the app. */} -
- - +
+
+
+ +
+
{popup} {currentFeedback && ( @@ -1147,346 +1189,335 @@ export function ChatPage({ llmOverrideManager={llmOverrideManager} /> - {documentSidebarInitialWidth !== undefined ? ( - - {({ getRootProps }) => ( - <> -
- {/* */} - +
+ {livePersona && ( + + )} + {documentSidebarInitialWidth !== undefined ? ( + + {({ getRootProps }) => ( +
+
- {/* ChatBanner is a custom banner that displays a admin-specified message at - the top of the chat page. Only used in the EE version of the app. */} - - - {livePersona && ( -
-
-
- -
- -
- {chatSessionIdRef.current !== null && ( -
setSharingModalVisible(true)} - className={` - my-auto - p-2 - rounded - cursor-pointer - hover:bg-hover-light - `} - > - -
- )} - -
- - {retrievalEnabled && !showDocSidebar && ( - - )} -
-
-
-
- )} - - {messageHistory.length === 0 && - !isFetchingChatMessages && - !isStreaming && ( - - )} - + {/* */}
- {messageHistory.map((message, i) => { - const messageMap = completeMessageDetail.messageMap; - const messageReactComponentKey = `${i}-${completeMessageDetail.sessionId}`; - if (message.type === "user") { - const parentMessage = message.parentMessageId - ? messageMap.get(message.parentMessageId) - : null; - return ( -
- { - const parentMessageId = - message.parentMessageId!; - const parentMessage = - messageMap.get(parentMessageId)!; - upsertToCompleteMessageMap({ - messages: [ - { - ...parentMessage, - latestChildMessageId: null, - }, - ], - }); - onSubmit({ - messageIdToResend: - message.messageId || undefined, - messageOverride: editedContent, - }); - }} - onMessageSelection={(messageId) => { - const newCompleteMessageMap = new Map( - messageMap - ); - newCompleteMessageMap.get( - message.parentMessageId! - )!.latestChildMessageId = messageId; - setCompleteMessageDetail({ - sessionId: - completeMessageDetail.sessionId, - messageMap: newCompleteMessageMap, - }); - setSelectedMessageForDocDisplay(messageId); - // set message as latest so we can edit this message - // and so it sticks around on page reload - setMessageAsLatest(messageId); - }} - /> -
- ); - } else if (message.type === "assistant") { - const isShowingRetrieved = - (selectedMessageForDocDisplay !== null && - selectedMessageForDocDisplay === - message.messageId) || - (selectedMessageForDocDisplay === - TEMP_USER_MESSAGE_ID && - i === messageHistory.length - 1); - const previousMessage = - i !== 0 ? messageHistory[i - 1] : null; + {/* ChatBanner is a custom banner that displays a admin-specified message at + the top of the chat page. Only used in the EE version of the app. */} + - const currentAlternativeAssistant = - message.alternateAssistantID != null - ? availablePersonas.find( - (persona) => - persona.id == message.alternateAssistantID - ) + {messageHistory.length === 0 && + !isFetchingChatMessages && + !isStreaming && ( + + )} +
+ {messageHistory.map((message, i) => { + const messageMap = completeMessageDetail.messageMap; + const messageReactComponentKey = `${i}-${completeMessageDetail.sessionId}`; + if (message.type === "user") { + const parentMessage = message.parentMessageId + ? messageMap.get(message.parentMessageId) : null; + return ( +
+ { + const parentMessageId = + message.parentMessageId!; + const parentMessage = + messageMap.get(parentMessageId)!; + upsertToCompleteMessageMap({ + messages: [ + { + ...parentMessage, + latestChildMessageId: null, + }, + ], + }); + onSubmit({ + messageIdToResend: + message.messageId || undefined, + messageOverride: editedContent, + }); + }} + onMessageSelection={(messageId) => { + const newCompleteMessageMap = new Map( + messageMap + ); + newCompleteMessageMap.get( + message.parentMessageId! + )!.latestChildMessageId = messageId; + setCompleteMessageDetail({ + sessionId: + completeMessageDetail.sessionId, + messageMap: newCompleteMessageMap, + }); + setSelectedMessageForDocDisplay( + messageId + ); + // set message as latest so we can edit this message + // and so it sticks around on page reload + setMessageAsLatest(messageId); + }} + /> +
+ ); + } else if (message.type === "assistant") { + const isShowingRetrieved = + (selectedMessageForDocDisplay !== null && + selectedMessageForDocDisplay === + message.messageId) || + (selectedMessageForDocDisplay === + TEMP_USER_MESSAGE_ID && + i === messageHistory.length - 1); + const previousMessage = + i !== 0 ? messageHistory[i - 1] : null; - return ( + const currentAlternativeAssistant = + message.alternateAssistantID != null + ? availablePersonas.find( + (persona) => + persona.id == + message.alternateAssistantID + ) + : null; + + return ( +
+ 0) === true + } + handleFeedback={ + i === messageHistory.length - 1 && + isStreaming + ? undefined + : (feedbackType) => + setCurrentFeedback([ + feedbackType, + message.messageId as number, + ]) + } + handleSearchQueryEdit={ + i === messageHistory.length - 1 && + !isStreaming + ? (newQuery) => { + if (!previousMessage) { + setPopup({ + type: "error", + message: + "Cannot edit query of first message - please refresh the page and try again.", + }); + return; + } + + if ( + previousMessage.messageId === null + ) { + setPopup({ + type: "error", + message: + "Cannot edit query of a pending message - please wait a few seconds and try again.", + }); + return; + } + onSubmit({ + messageIdToResend: + previousMessage.messageId, + queryOverride: newQuery, + alternativeAssistant: + currentAlternativeAssistant, + }); + } + : undefined + } + isCurrentlyShowingRetrieved={ + isShowingRetrieved + } + handleShowRetrieved={(messageNumber) => { + if (isShowingRetrieved) { + setSelectedMessageForDocDisplay(null); + } else { + if (messageNumber !== null) { + setSelectedMessageForDocDisplay( + messageNumber + ); + } else { + setSelectedMessageForDocDisplay(-1); + } + } + }} + handleForceSearch={() => { + if ( + previousMessage && + previousMessage.messageId + ) { + onSubmit({ + messageIdToResend: + previousMessage.messageId, + forceSearch: true, + alternativeAssistant: + currentAlternativeAssistant, + }); + } else { + setPopup({ + type: "error", + message: + "Failed to force search - please refresh the page and try again.", + }); + } + }} + retrievalDisabled={ + currentAlternativeAssistant + ? !personaIncludesRetrieval( + currentAlternativeAssistant! + ) + : !retrievalEnabled + } + /> +
+ ); + } else { + return ( +
+ + {message.message} +

+ } + /> +
+ ); + } + })} + {isStreaming && + messageHistory.length > 0 && + messageHistory[messageHistory.length - 1].type === + "user" && (
0) === true - } - handleFeedback={ - i === messageHistory.length - 1 && - isStreaming - ? undefined - : (feedbackType) => - setCurrentFeedback([ - feedbackType, - message.messageId as number, - ]) - } - handleSearchQueryEdit={ - i === messageHistory.length - 1 && - !isStreaming - ? (newQuery) => { - if (!previousMessage) { - setPopup({ - type: "error", - message: - "Cannot edit query of first message - please refresh the page and try again.", - }); - return; - } - - if ( - previousMessage.messageId === null - ) { - setPopup({ - type: "error", - message: - "Cannot edit query of a pending message - please wait a few seconds and try again.", - }); - return; - } - onSubmit({ - messageIdToResend: - previousMessage.messageId, - queryOverride: newQuery, - alternativeAssistant: - currentAlternativeAssistant, - }); - } - : undefined - } - isCurrentlyShowingRetrieved={ - isShowingRetrieved - } - handleShowRetrieved={(messageNumber) => { - if (isShowingRetrieved) { - setSelectedMessageForDocDisplay(null); - } else { - if (messageNumber !== null) { - setSelectedMessageForDocDisplay( - messageNumber - ); - } else { - setSelectedMessageForDocDisplay(-1); - } - } - }} - handleForceSearch={() => { - if ( - previousMessage && - previousMessage.messageId - ) { - onSubmit({ - messageIdToResend: - previousMessage.messageId, - forceSearch: true, - alternativeAssistant: - currentAlternativeAssistant, - }); - } else { - setPopup({ - type: "error", - message: - "Failed to force search - please refresh the page and try again.", - }); - } - }} - retrievalDisabled={ - currentAlternativeAssistant - ? !personaIncludesRetrieval( - currentAlternativeAssistant! - ) - : !retrievalEnabled - } - /> -
- ); - } else { - return ( -
- - {message.message} -

+
+ +
} />
- ); - } - })} - {isStreaming && - messageHistory.length > 0 && - messageHistory[messageHistory.length - 1].type === - "user" && ( -
- - -
- } - /> -
- )} + )} - {/* Some padding at the bottom so the search bar has space at the bottom to not cover the last message*/} -
-
+ {/* Some padding at the bottom so the search bar has space at the bottom to not cover the last message*/} +
+
- {currentPersona && - currentPersona.starter_messages && - currentPersona.starter_messages.length > 0 && - selectedPersona && - messageHistory.length === 0 && - !isFetchingChatMessages && ( -
0 && + selectedPersona && + messageHistory.length === 0 && + !isFetchingChatMessages && ( +
- {currentPersona.starter_messages.map( - (starterMessage, i) => ( -
- - onSubmit({ - messageOverride: - starterMessage.message, - }) - } - /> -
- ) - )} + > + {currentPersona.starter_messages.map( + (starterMessage, i) => ( +
+ + onSubmit({ + messageOverride: + starterMessage.message, + }) + } + /> +
+ ) + )} +
+ )} +
+
+
+
+
+ {aboveHorizon && ( +
+
)} -
-
-
-
-
- {aboveHorizon && ( -
- -
- )} - - { - updateSelectedAssistant(alternativeAssistant); - }} - alternativeAssistant={selectedAssistant} - personas={filteredAssistants} - message={message} - setMessage={setMessage} - onSubmit={onSubmit} - isStreaming={isStreaming} - setIsCancelled={setIsCancelled} - retrievalDisabled={ - !personaIncludesRetrieval(currentPersona) - } - filterManager={filterManager} - llmOverrideManager={llmOverrideManager} - selectedAssistant={livePersona} - files={currentMessageFiles} - setFiles={setCurrentMessageFiles} - handleFileUpload={handleImageUpload} - setConfigModalActiveTab={setConfigModalActiveTab} - textAreaRef={textAreaRef} - /> + setDocumentSelection(true)} + selectedDocuments={selectedDocuments} + setSelectedAssistant={onPersonaChange} + onSetSelectedAssistant={( + alternativeAssistant: Persona | null + ) => { + setSelectedAssistant(alternativeAssistant); + }} + alternativeAssistant={selectedAssistant} + personas={filteredAssistants} + message={message} + setMessage={setMessage} + onSubmit={onSubmit} + isStreaming={isStreaming} + setIsCancelled={setIsCancelled} + retrievalDisabled={ + !personaIncludesRetrieval(currentPersona) + } + filterManager={filterManager} + llmOverrideManager={llmOverrideManager} + selectedAssistant={livePersona} + files={currentMessageFiles} + setFiles={setCurrentMessageFiles} + handleFileUpload={handleImageUpload} + setConfigModalActiveTab={setConfigModalActiveTab} + textAreaRef={textAreaRef} + chatSessionId={chatSessionIdRef.current!} + availableAssistants={availablePersonas} + /> +
- - {retrievalEnabled || editingRetrievalEnabled ? ( -
- - toggleSidebar()} - selectedMessage={aiMessage} - selectedDocuments={selectedDocuments} - toggleDocumentSelection={toggleDocumentSelection} - clearSelectedDocuments={clearSelectedDocuments} - selectedDocumentTokens={selectedDocumentTokens} - maxTokens={maxTokens} - isLoading={isFetchingChatMessages} - /> - -
- ) : // Another option is to use a div with the width set to the initial width, so that the - // chat section appears in the same place as before - //
- null} - - )} - - ) : ( -
-
- + )} + + ) : ( +
+
+
+ +
-
- )} + )} +
+ + setDocumentSelection(false)} + selectedMessage={aiMessage} + selectedDocuments={selectedDocuments} + toggleDocumentSelection={toggleDocumentSelection} + clearSelectedDocuments={clearSelectedDocuments} + selectedDocumentTokens={selectedDocumentTokens} + maxTokens={maxTokens} + isLoading={isFetchingChatMessages} + isOpen={documentSelection} + />
+
); diff --git a/web/src/app/chat/WrappedChat.tsx b/web/src/app/chat/WrappedChat.tsx new file mode 100644 index 0000000000..08c96941ea --- /dev/null +++ b/web/src/app/chat/WrappedChat.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { useState } from "react"; +import { ChatPage } from "./ChatPage"; +import FunctionalWrapper from "./shared_chat_search/FunctionalWrapper"; + +export default function WrappedChat({ + defaultPersonaId, + initiallyToggled, +}: { + defaultPersonaId?: number; + initiallyToggled: boolean; +}) { + return ( + ( + + )} + /> + ); +} diff --git a/web/src/app/chat/documentSidebar/ChatDocumentDisplay.tsx b/web/src/app/chat/documentSidebar/ChatDocumentDisplay.tsx index 00b3b13cc0..de6274c758 100644 --- a/web/src/app/chat/documentSidebar/ChatDocumentDisplay.tsx +++ b/web/src/app/chat/documentSidebar/ChatDocumentDisplay.tsx @@ -1,8 +1,6 @@ import { HoverPopup } from "@/components/HoverPopup"; import { SourceIcon } from "@/components/SourceIcon"; import { PopupSpec } from "@/components/admin/connectors/Popup"; -import { DocumentFeedbackBlock } from "@/components/search/DocumentFeedbackBlock"; -import { DocumentUpdatedAtBadge } from "@/components/search/DocumentUpdatedAtBadge"; import { DanswerDocument } from "@/lib/search/interfaces"; import { FiInfo, FiRadio } from "react-icons/fi"; import { DocumentSelector } from "./DocumentSelector"; @@ -32,25 +30,32 @@ export function ChatDocumentDisplay({ tokenLimitReached, }: DocumentDisplayProps) { const isInternet = document.is_internet; + // Consider reintroducing null scored docs in the future + + if (document.score === null) { + return null; + } return ( -
-
+
+
{isInternet ? ( ) : ( )} -

+

{document.semantic_identifier || document.document_id}

@@ -75,6 +80,21 @@ export function ChatDocumentDisplay({ />
)} +
+ {Math.abs(document.score).toFixed(2)} +
)} @@ -91,8 +111,9 @@ export function ChatDocumentDisplay({
-

+

{buildDocumentSummaryDisplay(document.match_highlights, document.blurb)} + test

{/* diff --git a/web/src/app/chat/documentSidebar/DocumentSelector.tsx b/web/src/app/chat/documentSidebar/DocumentSelector.tsx index 833c6a7cad..2153ce5bdc 100644 --- a/web/src/app/chat/documentSidebar/DocumentSelector.tsx +++ b/web/src/app/chat/documentSidebar/DocumentSelector.tsx @@ -32,9 +32,8 @@ export function DocumentSelector({ } onClick={onClick} > -

Select

( @@ -64,6 +65,7 @@ export const DocumentSidebar = forwardRef( maxTokens, isLoading, initialWidth, + isOpen, }, ref: ForwardedRef ) => { @@ -86,43 +88,53 @@ export const DocumentSidebar = forwardRef( return (
{ + if (e.target === e.currentTarget) { + closeSidebar(); + } + }} >
- {popup} - -
-
- +
+ {popup} +
+ {dedupedDocuments.length} Documents +

+ Select to add to continuous context + + Learn more + +

+ + {currentDocuments ? ( -
+
{dedupedDocuments.length > 0 ? ( dedupedDocuments.map((document, ind) => (
( )}
-
-
-
- - {tokenLimitReached && ( -
-
- - } - popupContent={ - - Over LLM context length by:{" "} - {selectedDocumentTokens - maxTokens} tokens -
-
- {selectedDocuments && - selectedDocuments.length > 0 && ( - <> - Truncating: " - - { - selectedDocuments[ - selectedDocuments.length - 1 - ].semantic_identifier - } - - " - - )} -
- } - direction="left" - /> -
-
- )} -
- {selectedDocuments && selectedDocuments.length > 0 && ( -
- - De-Select All - -
- )} -
+
+
+ - {selectedDocuments && selectedDocuments.length > 0 ? ( -
- {selectedDocuments.map((document) => ( - { - toggleDocumentSelection( - dedupedDocuments.find( - (document) => document.document_id === documentId - )! - ); - }} - /> - ))} -
- ) : ( - !isLoading && ( - - Select documents from the retrieved documents section to chat - specifically with them! - - ) - )} +
diff --git a/web/src/app/chat/files/InputBarPreview.tsx b/web/src/app/chat/files/InputBarPreview.tsx index 8eee7bbf9c..5d473b9f2d 100644 --- a/web/src/app/chat/files/InputBarPreview.tsx +++ b/web/src/app/chat/files/InputBarPreview.tsx @@ -1,8 +1,9 @@ -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { ChatFileType, FileDescriptor } from "../interfaces"; -import { DocumentPreview } from "./documents/DocumentPreview"; + +import { FiX, FiLoader, FiFileText } from "react-icons/fi"; import { InputBarPreviewImage } from "./images/InputBarPreviewImage"; -import { FiX, FiLoader } from "react-icons/fi"; +import { Tooltip } from "@/components/tooltip/Tooltip"; function DeleteButton({ onDelete }: { onDelete: () => void }) { return ( @@ -15,7 +16,7 @@ function DeleteButton({ onDelete }: { onDelete: () => void }) { cursor-pointer border-none bg-hover - p-1 + p-.5 rounded-full z-10 " @@ -25,6 +26,45 @@ function DeleteButton({ onDelete }: { onDelete: () => void }) { ); } +export function InputBarPreviewImageProvider({ + file, + onDelete, + isUploading, +}: { + file: FileDescriptor; + onDelete: () => void; + isUploading: boolean; +}) { + const [isHovered, setIsHovered] = useState(false); + + return ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + {isHovered && } + {isUploading && ( +
+ +
+ )} + +
+ ); +} + export function InputBarPreview({ file, onDelete, @@ -36,12 +76,16 @@ export function InputBarPreview({ }) { const [isHovered, setIsHovered] = useState(false); - const renderContent = () => { - if (file.type === ChatFileType.IMAGE) { - return ; + const fileNameRef = useRef(null); + const [isOverflowing, setIsOverflowing] = useState(false); + + useEffect(() => { + if (fileNameRef.current) { + setIsOverflowing( + fileNameRef.current.scrollWidth > fileNameRef.current.clientWidth + ); } - return ; - }; + }, [file.name]); return (
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} > - {isHovered && } {isUploading && (
)} - {renderContent()} +
+
+
+ +
+
+
+ +
+ {file.name} +
+
+
+ +
); } diff --git a/web/src/app/chat/files/documents/DocumentPreview.tsx b/web/src/app/chat/files/documents/DocumentPreview.tsx index 7e584f34d3..07dddd53cf 100644 --- a/web/src/app/chat/files/documents/DocumentPreview.tsx +++ b/web/src/app/chat/files/documents/DocumentPreview.tsx @@ -5,9 +5,11 @@ import { Tooltip } from "@/components/tooltip/Tooltip"; export function DocumentPreview({ fileName, maxWidth, + alignBubble, }: { fileName: string; maxWidth?: string; + alignBubble?: boolean; }) { const [isOverflowing, setIsOverflowing] = useState(false); const fileNameRef = useRef(null); @@ -22,7 +24,8 @@ export function DocumentPreview({ return (
@@ -65,3 +68,69 @@ export function DocumentPreview({
); } + +export function InputDocumentPreview({ + fileName, + maxWidth, + alignBubble, +}: { + fileName: string; + maxWidth?: string; + alignBubble?: boolean; +}) { + const [isOverflowing, setIsOverflowing] = useState(false); + const fileNameRef = useRef(null); + + useEffect(() => { + if (fileNameRef.current) { + setIsOverflowing( + fileNameRef.current.scrollWidth > fileNameRef.current.clientWidth + ); + } + }, [fileName]); + + return ( +
+
+
+ +
+
+
+ +
+ {fileName} +
+
+
+
+ ); +} diff --git a/web/src/app/chat/files/images/InMessageImage.tsx b/web/src/app/chat/files/images/InMessageImage.tsx index 75b58cbed9..97f5c6ea5a 100644 --- a/web/src/app/chat/files/images/InMessageImage.tsx +++ b/web/src/app/chat/files/images/InMessageImage.tsx @@ -16,14 +16,7 @@ export function InMessageImage({ fileId }: { fileId: string }) { /> setFullImageShowing(true)} src={buildImgUrl(fileId)} loading="lazy" diff --git a/web/src/app/chat/files/images/InputBarPreviewImage.tsx b/web/src/app/chat/files/images/InputBarPreviewImage.tsx index 372d0be60f..51260af1d2 100644 --- a/web/src/app/chat/files/images/InputBarPreviewImage.tsx +++ b/web/src/app/chat/files/images/InputBarPreviewImage.tsx @@ -14,10 +14,24 @@ export function InputBarPreviewImage({ fileId }: { fileId: string }) { open={fullImageShowing} onOpenChange={(open) => setFullImageShowing(open)} /> -
+
setFullImageShowing(true)} - className="h-16 w-16 object-cover rounded-lg bg-background cursor-pointer" + className="h-8 w-8 object-cover rounded-lg bg-background cursor-pointer" src={buildImgUrl(fileId)} />
diff --git a/web/src/app/chat/folders/FolderList.tsx b/web/src/app/chat/folders/FolderList.tsx index 8fa0f05a48..37937ade26 100644 --- a/web/src/app/chat/folders/FolderList.tsx +++ b/web/src/app/chat/folders/FolderList.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useRef } from "react"; import { Folder } from "./interfaces"; import { ChatSessionDisplay } from "../sessionSidebar/ChatSessionDisplay"; // Ensure this is correctly imported import { @@ -22,6 +22,9 @@ import { usePopup } from "@/components/admin/connectors/Popup"; import { useRouter } from "next/navigation"; import { CHAT_SESSION_ID_KEY } from "@/lib/drag/constants"; import Cookies from "js-cookie"; +import { CustomTooltip } from "@/components/tooltip/CustomTooltip"; +import { Tooltip } from "@/components/tooltip/Tooltip"; +import { Popover } from "@/components/popover/Popover"; const FolderItem = ({ folder, @@ -54,6 +57,8 @@ const FolderItem = ({ if (newIsExpanded) { openedFolders[folder.folder_id] = true; } else { + setShowDeleteConfirm(false); + delete openedFolders[folder.folder_id]; } Cookies.set("openedFolders", JSON.stringify(openedFolders)); @@ -87,18 +92,47 @@ const FolderItem = ({ } }; - const deleteFolderHandler = async ( - event: React.MouseEvent - ) => { - event.stopPropagation(); // Prevent the event from bubbling up to the toggle expansion + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const deleteConfirmRef = useRef(null); + + const handleDeleteClick = (event: React.MouseEvent) => { + event.stopPropagation(); + setShowDeleteConfirm(true); + }; + + const confirmDelete = async (event: React.MouseEvent) => { + event.stopPropagation(); try { await deleteFolder(folder.folder_id); - router.refresh(); // Refresh values to update the sidebar + router.refresh(); } catch (error) { setPopup({ message: "Failed to delete folder", type: "error" }); + } finally { + setShowDeleteConfirm(false); } }; + const cancelDelete = (event: React.MouseEvent) => { + event.stopPropagation(); + setShowDeleteConfirm(false); + }; + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + deleteConfirmRef.current && + !deleteConfirmRef.current.contains(event.target as Node) + ) { + setShowDeleteConfirm(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, []); + const handleDrop = async (event: React.DragEvent) => { event.preventDefault(); setIsDragOver(false); @@ -134,13 +168,38 @@ const FolderItem = ({ isDragOver ? "bg-hover" : "" }`} > + {showDeleteConfirm && ( +
+

+ Are you sure you want to delete {folder.folder_name}? All the + content inside this folder will also be deleted +

+
+ + +
+
+ )}
setIsHovering(true)} onMouseLeave={() => setIsHovering(false)} >
-
+
{isExpanded ? ( @@ -172,14 +231,24 @@ const FolderItem = ({ >
-
+
+ +
+
+ + {/*
-
+
*/}
)} + {isEditing && (
{ if (folders.length === 0) { return null; @@ -236,7 +305,9 @@ export const FolderList = ({ key={folder.folder_id} folder={folder} currentChatId={currentChatId} - isInitiallyExpanded={openedFolders[folder.folder_id] || false} + isInitiallyExpanded={ + openedFolders ? openedFolders[folder.folder_id] || false : false + } /> ))}
diff --git a/web/src/app/chat/input/ChatInputAssistant.tsx b/web/src/app/chat/input/ChatInputAssistant.tsx new file mode 100644 index 0000000000..d2d062eb2f --- /dev/null +++ b/web/src/app/chat/input/ChatInputAssistant.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { Persona } from "@/app/admin/assistants/interfaces"; +import { AssistantIcon } from "@/components/assistants/AssistantIcon"; +import { Tooltip } from "@/components/tooltip/Tooltip"; +import { ForwardedRef, forwardRef, useState } from "react"; +import { FiX } from "react-icons/fi"; + +interface DocumentSidebarProps { + alternativeAssistant: Persona; + unToggle: () => void; +} + +export const ChatInputAssistant = forwardRef< + HTMLDivElement, + DocumentSidebarProps +>(({ alternativeAssistant, unToggle }, ref: ForwardedRef) => { + const [isHovered, setIsHovered] = useState(false); + + return ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + className="flex-none h-10 duration-300 h-10 items-center rounded-lg bg-background-150" + > + {alternativeAssistant.description}

+ } + > +
+ +

+ {alternativeAssistant.name} +

+
+ +
+
+
+
+ ); +}); + +ChatInputAssistant.displayName = "TempAssistant"; +export default ChatInputAssistant; diff --git a/web/src/app/chat/input/ChatInputBar.tsx b/web/src/app/chat/input/ChatInputBar.tsx index 5664fb7658..6934999ae8 100644 --- a/web/src/app/chat/input/ChatInputBar.tsx +++ b/web/src/app/chat/input/ChatInputBar.tsx @@ -1,36 +1,37 @@ -import React, { - Dispatch, - SetStateAction, - useEffect, - useRef, - useState, -} from "react"; -import { - FiSend, - FiFilter, - FiPlusCircle, - FiCpu, - FiX, - FiPlus, - FiInfo, -} from "react-icons/fi"; -import ChatInputOption from "./ChatInputOption"; -import { FaBrain } from "react-icons/fa"; +import React, { useEffect, useRef, useState } from "react"; +import { FiPlusCircle, FiPlus, FiInfo, FiX } from "react-icons/fi"; +import { ChatInputOption } from "./ChatInputOption"; import { Persona } from "@/app/admin/assistants/interfaces"; import { FilterManager, LlmOverrideManager } from "@/lib/hooks"; import { SelectedFilterDisplay } from "./SelectedFilterDisplay"; import { useChatContext } from "@/components/context/ChatContext"; import { getFinalLLM } from "@/lib/llm/utils"; -import { FileDescriptor } from "../interfaces"; -import { InputBarPreview } from "../files/InputBarPreview"; -import { RobotIcon } from "@/components/icons/icons"; -import { Hoverable } from "@/components/Hoverable"; +import { ChatFileType, FileDescriptor } from "../interfaces"; +import { + InputBarPreview, + InputBarPreviewImageProvider, +} from "../files/InputBarPreview"; +import { + AssistantsIconSkeleton, + CpuIconSkeleton, + FileIcon, + SendIcon, +} from "@/components/icons/icons"; +import { IconType } from "react-icons"; +import Popup from "../../../components/popup/Popup"; +import { LlmTab } from "../modal/configuration/LlmTab"; +import { AssistantsTab } from "../modal/configuration/AssistantsTab"; +import ChatInputAssistant from "./ChatInputAssistant"; +import { DanswerDocument } from "@/lib/search/interfaces"; import { AssistantIcon } from "@/components/assistants/AssistantIcon"; import { Tooltip } from "@/components/tooltip/Tooltip"; +import { Hoverable } from "@/components/Hoverable"; const MAX_INPUT_HEIGHT = 200; export function ChatInputBar({ personas, + showDocs, + selectedDocuments, message, setMessage, onSubmit, @@ -42,13 +43,21 @@ export function ChatInputBar({ onSetSelectedAssistant, selectedAssistant, files, + + setSelectedAssistant, setFiles, handleFileUpload, setConfigModalActiveTab, textAreaRef, alternativeAssistant, + chatSessionId, + availableAssistants, }: { + showDocs: () => void; + selectedDocuments: DanswerDocument[]; + availableAssistants: Persona[]; onSetSelectedAssistant: (alternativeAssistant: Persona | null) => void; + setSelectedAssistant: (assistant: Persona) => void; personas: Persona[]; message: string; setMessage: (message: string) => void; @@ -65,6 +74,7 @@ export function ChatInputBar({ handleFileUpload: (files: File[]) => void; setConfigModalActiveTab: (tab: string) => void; textAreaRef: React.RefObject; + chatSessionId?: number; }) { // handle re-sizing of the text area useEffect(() => { @@ -102,19 +112,6 @@ export function ChatInputBar({ const [showSuggestions, setShowSuggestions] = useState(false); const interactionsRef = useRef(null); - - const hideSuggestions = () => { - setShowSuggestions(false); - setAssistantIconIndex(0); - }; - - // Update selected persona - const updateCurrentPersona = (persona: Persona) => { - onSetSelectedAssistant(persona.id == selectedAssistant.id ? null : persona); - hideSuggestions(); - setMessage(""); - }; - // Click out of assistant suggestions useEffect(() => { const handleClickOutside = (event: MouseEvent) => { @@ -133,6 +130,18 @@ export function ChatInputBar({ }; }, []); + const hideSuggestions = () => { + setShowSuggestions(false); + setAssistantIconIndex(0); + }; + + // Update selected persona + const updateCurrentPersona = (persona: Persona) => { + onSetSelectedAssistant(persona.id == selectedAssistant.id ? null : persona); + hideSuggestions(); + setMessage(""); + }; + // Complete user input handling const handleInputChange = (event: React.ChangeEvent) => { const text = event.target.value; @@ -143,7 +152,6 @@ export function ChatInputBar({ return; } - // If looking for an assistant...fup const match = text.match(/(?:\s|^)@(\w*)$/); if (match) { setShowSuggestions(true); @@ -179,7 +187,12 @@ export function ChatInputBar({ filteredPersonas[assistantIconIndex >= 0 ? assistantIconIndex : 0]; updateCurrentPersona(option); } - } else if (e.key === "ArrowDown") { + } + if (!showSuggestions) { + return; + } + + if (e.key === "ArrowDown") { e.preventDefault(); setAssistantIconIndex((assistantIconIndex) => Math.min(assistantIconIndex + 1, filteredPersonas.length) @@ -197,13 +210,12 @@ export function ChatInputBar({
@@ -212,16 +224,18 @@ export function ChatInputBar({ ref={suggestionsRef} className="text-sm absolute inset-x-0 top-0 w-full transform -translate-y-full" > -
+
{filteredPersonas.map((currentPersona, index) => (
)} -
-

{alternativeAssistant.name}

-
+
@@ -293,24 +307,50 @@ export function ChatInputBar({
)} - - {files.length > 0 && ( -
- {files.map((file) => ( -
- { - setFiles( - files.filter( - (fileInFilter) => fileInFilter.id !== file.id - ) - ); - }} - isUploading={file.isUploading || false} - /> -
- ))} + {(selectedDocuments.length > 0 || files.length > 0) && ( +
+
+ {selectedDocuments.length > 0 && ( + + )} + {files.map((file) => ( +
+ {file.type === ChatFileType.IMAGE ? ( + { + setFiles( + files.filter( + (fileInFilter) => fileInFilter.id !== file.id + ) + ); + }} + isUploading={file.isUploading || false} + /> + ) : ( + { + setFiles( + files.filter( + (fileInFilter) => fileInFilter.id !== file.id + ) + ); + }} + isUploading={file.isUploading || false} + /> + )} +
+ ))} +
)} @@ -324,21 +364,20 @@ export function ChatInputBar({ w-full shrink resize-none + rounded-lg border-0 - bg-background-weak + bg-background-100 ${ textAreaRef.current && textAreaRef.current.scrollHeight > MAX_INPUT_HEIGHT ? "overflow-y-auto mt-2" : "" } - overflow-hidden whitespace-normal break-word overscroll-contain outline-none placeholder-subtle - overflow-hidden resize-none pl-4 pr-12 @@ -349,7 +388,7 @@ export function ChatInputBar({ style={{ scrollbarWidth: "thin" }} role="textarea" aria-multiline - placeholder="Send a message..." + placeholder="Send a message or @ to tag an assistant..." value={message} onKeyDown={(event) => { if ( @@ -364,39 +403,60 @@ export function ChatInputBar({ }} suppressContentEditableWarning={true} /> -
- setConfigModalActiveTab("assistants")} - /> - setConfigModalActiveTab("llms")} - /> - - {!retrievalDisabled && ( +
+ ( + { + setSelectedAssistant(assistant); + close(); + }} + /> + )} + position="top" + > setConfigModalActiveTab("filters")} + flexPriority="shrink" + name={ + selectedAssistant ? selectedAssistant.name : "Assistants" + } + Icon={AssistantsIconSkeleton as IconType} /> - )} + + + ( + + )} + position="top" + > + + { const input = document.createElement("input"); input.type = "file"; @@ -426,10 +486,10 @@ export function ChatInputBar({ } }} > -
diff --git a/web/src/app/chat/input/ChatInputOption.tsx b/web/src/app/chat/input/ChatInputOption.tsx index 0d22111646..69ed373e56 100644 --- a/web/src/app/chat/input/ChatInputOption.tsx +++ b/web/src/app/chat/input/ChatInputOption.tsx @@ -1,110 +1,96 @@ -import React, { useState } from "react"; +import React, { useState, useRef, useEffect } from "react"; import { IconType } from "react-icons"; import { DefaultDropdownElement } from "../../../components/Dropdown"; import { Popover } from "../../../components/popover/Popover"; +import { IconProps } from "@/components/icons/icons"; interface ChatInputOptionProps { name: string; - icon: IconType; - onClick: () => void; + Icon: ({ size, className }: IconProps) => JSX.Element; + onClick?: () => void; size?: number; - + tooltipContent?: React.ReactNode; options?: { name: string; value: number; onClick?: () => void }[]; flexPriority?: "shrink" | "stiff" | "second"; } -const ChatInputOption = ({ +export const ChatInputOption: React.FC = ({ name, - icon: Icon, - onClick, + Icon, + // icon: Icon, size = 16, options, flexPriority, -}: ChatInputOptionProps) => { + tooltipContent, + onClick, +}) => { const [isDropupVisible, setDropupVisible] = useState(false); + const [isTooltipVisible, setIsTooltipVisible] = useState(false); + const componentRef = useRef(null); - const handleClick = () => { - setDropupVisible(!isDropupVisible); - // onClick(); - }; + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + componentRef.current && + !componentRef.current.contains(event.target as Node) + ) { + setIsTooltipVisible(false); + setDropupVisible(false); + } + }; - const dropdownContent = options ? ( -
- {options.map((option) => ( - { - if (option.onClick) { - option.onClick(); - setDropupVisible(false); - } - }} - isSelected={false} - /> - ))} -
- ) : null; - - const option = ( -
-
- - {name} -
-
- ); - - if (!dropdownContent) { - return ( -
- {option} -
- ); - } + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, []); return ( - +
+ + {name} + {isTooltipVisible && tooltipContent && ( +
+ {tooltipContent} +
+ )} +
); }; - -export default ChatInputOption; diff --git a/web/src/app/chat/interfaces.ts b/web/src/app/chat/interfaces.ts index 902f5b8655..ee918dc8dc 100644 --- a/web/src/app/chat/interfaces.ts +++ b/web/src/app/chat/interfaces.ts @@ -1,4 +1,8 @@ -import { DanswerDocument, Filters } from "@/lib/search/interfaces"; +import { + DanswerDocument, + Filters, + SearchDanswerDocument, +} from "@/lib/search/interfaces"; export enum RetrievalType { None = "none", @@ -30,10 +34,15 @@ export interface FileDescriptor { id: string; type: ChatFileType; name?: string | null; + // FE only isUploading?: boolean; } +export interface LLMRelevanceFilterPacket { + relevant_chunk_indices: number[]; +} + export interface ToolCallMetadata { tool_name: string; tool_args: Record; @@ -56,6 +65,13 @@ export interface ChatSession { current_alternate_model: string; } +export interface SearchSession { + search_session_id: number; + documents: SearchDanswerDocument[]; + messages: BackendMessage[]; + description: string; +} + export interface Message { messageId: number; message: string; @@ -86,6 +102,8 @@ export interface BackendChatSession { export interface BackendMessage { message_id: number; + comments: any; + chat_session_id: number; parent_message: number | null; latest_child_message: number | null; message: string; diff --git a/web/src/app/chat/lib.tsx b/web/src/app/chat/lib.tsx index c666914c26..3477b77937 100644 --- a/web/src/app/chat/lib.tsx +++ b/web/src/app/chat/lib.tsx @@ -516,11 +516,9 @@ export function checkAnyAssistantHasSearch( ) { return false; } - const alternateAssistant = availablePersonas.find( (persona) => persona.id === message.alternateAssistantID ); - return alternateAssistant ? personaIncludesRetrieval(alternateAssistant) : false; @@ -549,11 +547,16 @@ const PARAMS_TO_SKIP = [ export function buildChatUrl( existingSearchParams: ReadonlyURLSearchParams, chatSessionId: number | null, - personaId: number | null + personaId: number | null, + search?: boolean ) { const finalSearchParams: string[] = []; if (chatSessionId) { - finalSearchParams.push(`${SEARCH_PARAM_NAMES.CHAT_ID}=${chatSessionId}`); + finalSearchParams.push( + `${ + search ? SEARCH_PARAM_NAMES.SEARCH_ID : SEARCH_PARAM_NAMES.CHAT_ID + }=${chatSessionId}` + ); } if (personaId !== null) { finalSearchParams.push(`${SEARCH_PARAM_NAMES.PERSONA_ID}=${personaId}`); @@ -567,10 +570,10 @@ export function buildChatUrl( const finalSearchParamsString = finalSearchParams.join("&"); if (finalSearchParamsString) { - return `/chat?${finalSearchParamsString}`; + return `/${search ? "search" : "chat"}?${finalSearchParamsString}`; } - return "/chat"; + return `/${search ? "search" : "chat"}`; } export async function uploadFilesForChat( diff --git a/web/src/app/chat/message/Messages.tsx b/web/src/app/chat/message/Messages.tsx index a28488d8cc..a435c97233 100644 --- a/web/src/app/chat/message/Messages.tsx +++ b/web/src/app/chat/message/Messages.tsx @@ -15,10 +15,12 @@ import { import { FeedbackType } from "../types"; import { useEffect, useRef, useState } from "react"; import ReactMarkdown from "react-markdown"; -import { DanswerDocument } from "@/lib/search/interfaces"; -import { SearchSummary, ShowHideDocsButton } from "./SearchSummary"; +import { + DanswerDocument, + FilteredDanswerDocument, +} from "@/lib/search/interfaces"; +import { SearchSummary } from "./SearchSummary"; import { SourceIcon } from "@/components/SourceIcon"; -import { ThreeDots } from "react-loader-spinner"; import { SkippedSearch } from "./SkippedSearch"; import remarkGfm from "remark-gfm"; import { CopyButton } from "@/components/CopyButton"; @@ -29,7 +31,7 @@ import { INTERNET_SEARCH_TOOL_NAME, } from "../tools/constants"; import { ToolRunDisplay } from "../tools/ToolRunningAnimation"; -import { Hoverable } from "@/components/Hoverable"; +import { Hoverable, HoverableIcon } from "@/components/Hoverable"; import { DocumentPreview } from "../files/documents/DocumentPreview"; import { InMessageImage } from "../files/images/InMessageImage"; import { CodeBlock } from "./CodeBlock"; @@ -42,7 +44,19 @@ import "prismjs/themes/prism-tomorrow.css"; import "./custom-code-styles.css"; import { Persona } from "@/app/admin/assistants/interfaces"; import { AssistantIcon } from "@/components/assistants/AssistantIcon"; -import { InternetSearchIcon } from "@/components/InternetSearchIcon"; +import { Citation } from "@/components/search/results/Citation"; +import { DocumentMetadataBlock } from "@/components/search/DocumentDisplay"; +import { + DislikeFeedbackIcon, + LikeFeedbackIcon, +} from "@/components/icons/icons"; +import { + CustomTooltip, + TooltipGroup, +} from "@/components/tooltip/CustomTooltip"; +import { ValidSources } from "@/lib/types"; +import { Tooltip } from "@/components/tooltip/Tooltip"; +import { useMouseTracking } from "./hooks"; const TOOLS_WITH_CUSTOM_HANDLING = [ SEARCH_TOOL_NAME, @@ -50,14 +64,20 @@ const TOOLS_WITH_CUSTOM_HANDLING = [ IMAGE_GENERATION_TOOL_NAME, ]; -function FileDisplay({ files }: { files: FileDescriptor[] }) { +function FileDisplay({ + files, + alignBubble, +}: { + files: FileDescriptor[]; + alignBubble?: boolean; +}) { const imageFiles = files.filter((file) => file.type === ChatFileType.IMAGE); const nonImgFiles = files.filter((file) => file.type !== ChatFileType.IMAGE); return ( <> {nonImgFiles && nonImgFiles.length > 0 && ( -
+
{nonImgFiles.map((file) => { return ( @@ -65,6 +85,7 @@ function FileDisplay({ files }: { files: FileDescriptor[] }) {
); @@ -73,8 +94,8 @@ function FileDisplay({ files }: { files: FileDescriptor[] }) {
)} {imageFiles && imageFiles.length > 0 && ( -
-
+
+
{imageFiles.map((file) => { return ; })} @@ -86,10 +107,14 @@ function FileDisplay({ files }: { files: FileDescriptor[] }) { } export const AIMessage = ({ + isActive, + toggleDocumentSelection, alternativeAssistant, + docs, messageId, content, files, + selectedDocuments, query, personaName, citedDocuments, @@ -104,6 +129,10 @@ export const AIMessage = ({ retrievalDisabled, currentPersona, }: { + isActive?: boolean; + selectedDocuments?: DanswerDocument[] | null; + toggleDocumentSelection?: () => void; + docs?: DanswerDocument[] | null; alternativeAssistant?: Persona | null; currentPersona: Persona; messageId: number | null; @@ -122,17 +151,29 @@ export const AIMessage = ({ handleForceSearch?: () => void; retrievalDisabled?: boolean; }) => { + const finalContent = content + (!isComplete ? "[*](test)" : ""); + const [isReady, setIsReady] = useState(false); useEffect(() => { Prism.highlightAll(); setIsReady(true); }, []); + const { isHovering, trackedElementRef, hoverElementRef } = useMouseTracking(); + // this is needed to give Prism a chance to load if (!isReady) { return
; } + const selectedDocumentIds = + selectedDocuments?.map((document) => document.document_id) || []; + let citedDocumentIds: string[] = []; + + citedDocuments?.forEach((doc) => { + citedDocumentIds.push(doc[1].document_id); + }); + if (!isComplete) { const trimIncompleteCodeSection = ( content: string | JSX.Element @@ -155,242 +196,371 @@ export const AIMessage = ({ const danswerSearchToolEnabledForPersona = currentPersona.tools.some( (tool) => tool.in_code_tool_id === SEARCH_TOOL_NAME ); - const shouldShowLoader = - !toolCall || (toolCall.tool_name === SEARCH_TOOL_NAME && !content); - const defaultLoader = shouldShowLoader ? ( -
- -
- ) : undefined; + + let filteredDocs: FilteredDanswerDocument[] = []; + + if (docs) { + filteredDocs = docs + .filter( + (doc, index, self) => + doc.document_id && + doc.document_id !== "" && + index === self.findIndex((d) => d.document_id === doc.document_id) + ) + .filter((doc) => { + return citedDocumentIds.includes(doc.document_id); + }) + .map((doc: DanswerDocument, ind: number) => { + return { + ...doc, + included: selectedDocumentIds.includes(doc.document_id), + }; + }); + } + + const uniqueSources: ValidSources[] = Array.from( + new Set((docs || []).map((doc) => doc.source_type)) + ).slice(0, 3); return ( -
-
-
+
+
+
-
- {alternativeAssistant - ? alternativeAssistant.name - : personaName || "Danswer"} -
+
+
+ {(!toolCall || toolCall.tool_name === SEARCH_TOOL_NAME) && + danswerSearchToolEnabledForPersona && ( + <> + {query !== undefined && + handleShowRetrieved !== undefined && + isCurrentlyShowingRetrieved !== undefined && + !retrievalDisabled && ( +
+ +
+ )} + {handleForceSearch && + content && + query === undefined && + !hasDocs && + !retrievalDisabled && ( +
+ +
+ )} + + )} - {query === undefined && - hasDocs && - handleShowRetrieved !== undefined && - isCurrentlyShowingRetrieved !== undefined && - !retrievalDisabled && ( -
-
- -
-
- )} -
- -
- {(!toolCall || toolCall.tool_name === SEARCH_TOOL_NAME) && - danswerSearchToolEnabledForPersona && ( - <> - {query !== undefined && - handleShowRetrieved !== undefined && - isCurrentlyShowingRetrieved !== undefined && - !retrievalDisabled && ( -
- -
- )} - {handleForceSearch && - content && - query === undefined && - !hasDocs && - !retrievalDisabled && ( -
- -
- )} - - )} - - {toolCall && - !TOOLS_WITH_CUSTOM_HANDLING.includes(toolCall.tool_name) && ( -
- } - isRunning={!toolCall.tool_result || !content} - /> -
- )} - - {toolCall && - toolCall.tool_name === IMAGE_GENERATION_TOOL_NAME && - !toolCall.tool_result && ( -
- } - isRunning={!toolCall.tool_result} - /> -
- )} - - {toolCall && toolCall.tool_name === INTERNET_SEARCH_TOOL_NAME && ( -
- } - isRunning={!toolCall.tool_result} - /> -
- )} - - {content ? ( - <> - - - {typeof content === "string" ? ( - { - const { node, ...rest } = props; - // for some reason tags cause the onClick to not apply - // and the links are unclickable - // TODO: fix the fact that you have to double click to follow link - // for the first link - return ( - - rest.href - ? window.open(rest.href, "_blank") - : undefined - } - className="cursor-pointer text-link hover:text-link-hover" - // href={rest.href} - // target="_blank" - // rel="noopener noreferrer" - > - {rest.children} - - ); - }, - code: (props) => ( - - ), - p: ({ node, ...props }) => ( -

- ), - }} - remarkPlugins={[remarkGfm]} - rehypePlugins={[[rehypePrism, { ignoreMissing: true }]]} - > - {content} - - ) : ( - content - )} - - ) : isComplete ? null : ( - defaultLoader - )} - {citedDocuments && citedDocuments.length > 0 && ( -

- Sources: -
- {citedDocuments - .filter(([_, document]) => document.semantic_identifier) - .map(([citationKey, document], ind) => { - const display = ( -
-
- {document.is_internet ? ( - - ) : ( - +
+ {(!toolCall || toolCall.tool_name === SEARCH_TOOL_NAME) && ( + <> + {query !== undefined && + handleShowRetrieved !== undefined && + isCurrentlyShowingRetrieved !== undefined && + !retrievalDisabled && ( +
+ - )} -
- [{citationKey}] {document!.semantic_identifier} +
+ )} + {handleForceSearch && + content && + query === undefined && + !hasDocs && + !retrievalDisabled && ( +
+ +
+ )} + + )} + {toolCall && + !TOOLS_WITH_CUSTOM_HANDLING.includes( + toolCall.tool_name + ) && ( +
+ + } + isRunning={!toolCall.tool_result || !content} + />
- ); - if (document.link) { - return ( - + + } + isRunning={!toolCall.tool_result} + /> +
+ )} + + {toolCall && + toolCall.tool_name === INTERNET_SEARCH_TOOL_NAME && ( +
+ + } + isRunning={!toolCall.tool_result} + /> +
+ )} + + {content ? ( + <> + + + {typeof content === "string" ? ( + { + const { node, ...rest } = props; + const value = rest.children; + + if (value?.toString().startsWith("*")) { + return ( +
+ ); + } else if (value?.toString().startsWith("[")) { + // for some reason tags cause the onClick to not apply + // and the links are unclickable + // TODO: fix the fact that you have to double click to follow link + // for the first link + return ( + + {rest.children} + + ); + } else { + return ( + + rest.href + ? window.open(rest.href, "_blank") + : undefined + } + className="cursor-pointer text-link hover:text-link-hover" + > + {rest.children} + + ); + } + }, + code: (props) => ( + + ), + p: ({ node, ...props }) => ( +

+ ), + }} + remarkPlugins={[remarkGfm]} + rehypePlugins={[ + [rehypePrism, { ignoreMissing: true }], + ]} > - {display} - - ); - } else { - return ( -

- {display} + {finalContent} + + ) : ( + content + )} + + ) : isComplete ? null : ( + <> + )} + + {isComplete && docs && docs.length > 0 && ( +
+
+
+ {filteredDocs.length > 0 && + filteredDocs.slice(0, 2).map((doc, ind) => ( + + ))} +
{ + if (toggleDocumentSelection) { + toggleDocumentSelection(); + } + }} + key={-1} + className="cursor-pointer w-[200px] rounded-lg flex-none transition-all duration-500 hover:bg-background-125 bg-text-100 px-4 py-2 border-b" + > +
+

See context

+
+ {uniqueSources.map((sourceType, ind) => { + return ( +
+ +
+ ); + })} +
+
+
+ See more +
+
- ); - } - })} +
+
+ )} +
+ {handleFeedback && + (isActive ? ( +
+ + + + + + } + onClick={() => handleFeedback("like")} + /> + + + } + onClick={() => handleFeedback("dislike")} + /> + + +
+ ) : ( +
+ + + + + + + } + onClick={() => handleFeedback("like")} + /> + + + + } + onClick={() => handleFeedback("dislike")} + /> + + +
+ ))}
- )} -
- {handleFeedback && ( -
- - handleFeedback("like")} - /> - handleFeedback("dislike")} - />
- )} +
@@ -414,9 +584,11 @@ function MessageSwitcher({ icon={FiChevronLeft} onClick={currentPage === 1 ? undefined : handlePrevious} /> - + + {currentPage} / {totalPages} + setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} > -
-
-
-
-
- -
-
+
+
+
+ -
You
-
-
-
- - - {isEditing ? ( -
-
+
+ {isEditing ? ( +
+
-