From d193c4f452933444ddc4c6a8006c56899997dc88 Mon Sep 17 00:00:00 2001 From: pablodanswer Date: Mon, 2 Dec 2024 13:20:43 -0800 Subject: [PATCH] add text embedding --- ' | 10 + backend/Dockerfile | 1 + backend/danswer/auth/users.py | 2 +- backend/danswer/document_index/vespa/index.py | 3 + backend/danswer/file_store/file_store.py | 6 + .../server/query_and_chat/chat_backend.py | 19 +- backend/shared_configs/configs.py | 45 +++++ .../integration/common_utils/managers/file.py | 62 ++++++ node_modules/.package-lock.json | 6 + package-lock.json | 6 + package.json | 1 + web/src/app/chat/ChatPage.tsx | 56 ++++-- .../documentSidebar/ChatDocumentDisplay.tsx | 13 ++ .../chat/documentSidebar/DocumentSidebar.tsx | 169 ++++++++++++++++ web/src/app/chat/lib.tsx | 1 - .../chat/message/MemoizedTextComponents.tsx | 92 +++++---- web/src/app/chat/message/Messages.tsx | 107 ++++++++++- .../shared/[chatId]/SharedChatDisplay.tsx | 114 ++++++----- web/src/components/chat_search/TextView.tsx | 181 ++++++++++++++++++ web/src/components/search/DocumentDisplay.tsx | 57 ++++-- .../components/search/results/Citation.tsx | 10 +- web/src/components/ui/dialog.tsx | 125 ++++++++++++ web/src/lib/search/interfaces.ts | 1 + 23 files changed, 960 insertions(+), 127 deletions(-) create mode 100644 ' create mode 100644 backend/tests/integration/common_utils/managers/file.py create mode 100644 node_modules/.package-lock.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 web/src/app/chat/documentSidebar/DocumentSidebar.tsx create mode 100644 web/src/components/chat_search/TextView.tsx create mode 100644 web/src/components/ui/dialog.tsx diff --git a/' b/' new file mode 100644 index 00000000000..0f6597668dd --- /dev/null +++ b/' @@ -0,0 +1,10 @@ +basic tests +# Please enter the commit message for your changes. Lines starting +# with '#' will be ignored, and an empty message aborts the commit. +# +# On branch text_view +# Changes to be committed: +# modified: backend/danswer/server/query_and_chat/query_backend.py +# modified: backend/tests/integration/common_utils/managers/file.py +# modified: backend/tests/integration/tests/file/test_file_detection.py +# diff --git a/backend/Dockerfile b/backend/Dockerfile index 2f8de6e7996..d77b4e8737e 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -73,6 +73,7 @@ RUN apt-get update && \ rm -rf /var/lib/apt/lists/* && \ rm -f /usr/local/lib/python3.11/site-packages/tornado/test/test.key + # Pre-downloading models for setups with limited egress RUN python -c "from tokenizers import Tokenizer; \ Tokenizer.from_pretrained('nomic-ai/nomic-embed-text-v1')" diff --git a/backend/danswer/auth/users.py b/backend/danswer/auth/users.py index cf3de018f4b..3ead458ea26 100644 --- a/backend/danswer/auth/users.py +++ b/backend/danswer/auth/users.py @@ -147,7 +147,7 @@ def verify_email_is_invited(email: str) -> None: whitelist = get_invited_users() if not whitelist: return - + print("whitelist is", whitelist) if not email: raise PermissionError("Email must be specified") diff --git a/backend/danswer/document_index/vespa/index.py b/backend/danswer/document_index/vespa/index.py index ebe6daca1a2..91afd80bbc3 100644 --- a/backend/danswer/document_index/vespa/index.py +++ b/backend/danswer/document_index/vespa/index.py @@ -295,6 +295,9 @@ def register_multitenant_indices( zip_file = in_memory_zip_from_file_bytes(zip_dict) + # Reset the file pointer to the beginning for the subsequent POST request + zip_file.seek(0) + headers = {"Content-Type": "application/zip"} response = requests.post(deploy_url, headers=headers, data=zip_file) diff --git a/backend/danswer/file_store/file_store.py b/backend/danswer/file_store/file_store.py index 9bc4c41d361..e57b9222a1b 100644 --- a/backend/danswer/file_store/file_store.py +++ b/backend/danswer/file_store/file_store.py @@ -59,6 +59,12 @@ def read_file( Contents of the file and metadata dict """ + @abstractmethod + def read_file_record(self, file_name: str) -> PGFileStore: + """ + Read the file record by the name + """ + @abstractmethod def delete_file(self, file_name: str) -> None: """ diff --git a/backend/danswer/server/query_and_chat/chat_backend.py b/backend/danswer/server/query_and_chat/chat_backend.py index 954728c32a3..28d5c4afd8f 100644 --- a/backend/danswer/server/query_and_chat/chat_backend.py +++ b/backend/danswer/server/query_and_chat/chat_backend.py @@ -1,6 +1,7 @@ import asyncio import io import json +import mimetypes import uuid from collections.abc import Callable from collections.abc import Generator @@ -707,14 +708,24 @@ def upload_files_for_chat( } -@router.get("/file/{file_id}") +@router.get("/file/{file_id:path}") def fetch_chat_file( file_id: str, db_session: Session = Depends(get_session), _: User | None = Depends(current_user), ) -> Response: file_store = get_default_file_store(db_session) + file_record = file_store.read_file_record(file_id) + if not file_record: + raise HTTPException(status_code=404, detail="File not found") + + media_type: str | None = None + if file_record.file_type: + media_type = file_record.file_type + else: + media_type, __ = mimetypes.guess_type(file_record.display_name) + if not media_type: + media_type = "application/octet-stream" + file_io = file_store.read_file(file_id, mode="b") - # NOTE: specifying "image/jpeg" here, but it still works for pngs - # TODO: do this properly - return Response(content=file_io.read(), media_type="image/jpeg") + return StreamingResponse(file_io, media_type=media_type) diff --git a/backend/shared_configs/configs.py b/backend/shared_configs/configs.py index 2f558629def..21448ccd66d 100644 --- a/backend/shared_configs/configs.py +++ b/backend/shared_configs/configs.py @@ -163,47 +163,92 @@ async def async_return_default_schema(*args: Any, **kwargs: Any) -> str: dim=1024, index_name="danswer_chunk_cohere_embed_english_v3_0", ), + SupportedEmbeddingModel( + name="cohere/embed-english-v3.0", + dim=1024, + index_name="danswer_chunk_embed_english_v3_0", + ), SupportedEmbeddingModel( name="cohere/embed-english-light-v3.0", dim=384, index_name="danswer_chunk_cohere_embed_english_light_v3_0", ), + SupportedEmbeddingModel( + name="cohere/embed-english-light-v3.0", + dim=384, + index_name="danswer_chunk_embed_english_light_v3_0", + ), SupportedEmbeddingModel( name="openai/text-embedding-3-large", dim=3072, index_name="danswer_chunk_openai_text_embedding_3_large", ), + SupportedEmbeddingModel( + name="openai/text-embedding-3-large", + dim=3072, + index_name="danswer_chunk_text_embedding_3_large", + ), SupportedEmbeddingModel( name="openai/text-embedding-3-small", dim=1536, index_name="danswer_chunk_openai_text_embedding_3_small", ), + SupportedEmbeddingModel( + name="openai/text-embedding-3-small", + dim=1536, + index_name="danswer_chunk_text_embedding_3_small", + ), SupportedEmbeddingModel( name="google/text-embedding-004", dim=768, index_name="danswer_chunk_google_text_embedding_004", ), + SupportedEmbeddingModel( + name="google/text-embedding-004", + dim=768, + index_name="danswer_chunk_text_embedding_004", + ), SupportedEmbeddingModel( name="google/textembedding-gecko@003", dim=768, index_name="danswer_chunk_google_textembedding_gecko_003", ), + SupportedEmbeddingModel( + name="google/textembedding-gecko@003", + dim=768, + index_name="danswer_chunk_textembedding_gecko_003", + ), SupportedEmbeddingModel( name="voyage/voyage-large-2-instruct", dim=1024, index_name="danswer_chunk_voyage_large_2_instruct", ), + SupportedEmbeddingModel( + name="voyage/voyage-large-2-instruct", + dim=1024, + index_name="danswer_chunk_large_2_instruct", + ), SupportedEmbeddingModel( name="voyage/voyage-light-2-instruct", dim=384, index_name="danswer_chunk_voyage_light_2_instruct", ), + SupportedEmbeddingModel( + name="voyage/voyage-light-2-instruct", + dim=384, + index_name="danswer_chunk_light_2_instruct", + ), # Self-hosted models SupportedEmbeddingModel( name="nomic-ai/nomic-embed-text-v1", dim=768, index_name="danswer_chunk_nomic_ai_nomic_embed_text_v1", ), + SupportedEmbeddingModel( + name="nomic-ai/nomic-embed-text-v1", + dim=768, + index_name="danswer_chunk_nomic_embed_text_v1", + ), SupportedEmbeddingModel( name="intfloat/e5-base-v2", dim=768, diff --git a/backend/tests/integration/common_utils/managers/file.py b/backend/tests/integration/common_utils/managers/file.py new file mode 100644 index 00000000000..461874f7ec5 --- /dev/null +++ b/backend/tests/integration/common_utils/managers/file.py @@ -0,0 +1,62 @@ +import mimetypes +from typing import cast +from typing import IO +from typing import List +from typing import Tuple + +import requests + +from danswer.file_store.models import FileDescriptor +from tests.integration.common_utils.constants import API_SERVER_URL +from tests.integration.common_utils.constants import GENERAL_HEADERS +from tests.integration.common_utils.test_models import DATestUser + + +class FileManager: + @staticmethod + def upload_files( + files: List[Tuple[str, IO]], + user_performing_action: DATestUser | None = None, + ) -> Tuple[List[FileDescriptor], str]: + headers = ( + user_performing_action.headers + if user_performing_action + else GENERAL_HEADERS + ) + headers.pop("Content-Type", None) + + files_param = [] + for filename, file_obj in files: + mime_type, _ = mimetypes.guess_type(filename) + if mime_type is None: + mime_type = "application/octet-stream" + files_param.append(("files", (filename, file_obj, mime_type))) + + response = requests.post( + f"{API_SERVER_URL}/chat/file", + files=files_param, + headers=headers, + ) + + if not response.ok: + return ( + cast(List[FileDescriptor], []), + f"Failed to upload files - {response.json().get('detail', 'Unknown error')}", + ) + + response_json = response.json() + return response_json.get("files", cast(List[FileDescriptor], [])), "" + + @staticmethod + def fetch_uploaded_file( + file_id: str, + user_performing_action: DATestUser | None = None, + ) -> bytes: + response = requests.get( + f"{API_SERVER_URL}/chat/file/{file_id}", + headers=user_performing_action.headers + if user_performing_action + else GENERAL_HEADERS, + ) + response.raise_for_status() + return response.content diff --git a/node_modules/.package-lock.json b/node_modules/.package-lock.json new file mode 100644 index 00000000000..b3aaf2c4dec --- /dev/null +++ b/node_modules/.package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "danswer", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000000..b3aaf2c4dec --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "danswer", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/package.json b/package.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/package.json @@ -0,0 +1 @@ +{} diff --git a/web/src/app/chat/ChatPage.tsx b/web/src/app/chat/ChatPage.tsx index 634dc0624b8..def2872328a 100644 --- a/web/src/app/chat/ChatPage.tsx +++ b/web/src/app/chat/ChatPage.tsx @@ -106,8 +106,10 @@ import { NoAssistantModal } from "@/components/modals/NoAssistantModal"; import { useAssistants } from "@/components/context/AssistantsContext"; import { Separator } from "@/components/ui/separator"; import AssistantBanner from "../../components/assistants/AssistantBanner"; +import TextView from "@/components/chat_search/TextView"; import AssistantSelector from "@/components/chat_search/AssistantSelector"; import { Modal } from "@/components/Modal"; +import { DocumentSidebar } from "./documentSidebar/DocumentSidebar"; const TEMP_USER_MESSAGE_ID = -1; const TEMP_ASSISTANT_MESSAGE_ID = -2; @@ -241,10 +243,10 @@ export function ChatPage({ (assistant) => assistant.id === existingChatSessionAssistantId ) : defaultAssistantId !== undefined - ? availableAssistants.find( - (assistant) => assistant.id === defaultAssistantId - ) - : undefined + ? availableAssistants.find( + (assistant) => assistant.id === defaultAssistantId + ) + : undefined ); // Gather default temperature settings const search_param_temperature = searchParams.get( @@ -254,12 +256,12 @@ export function ChatPage({ const defaultTemperature = search_param_temperature ? parseFloat(search_param_temperature) : selectedAssistant?.tools.some( - (tool) => - tool.in_code_tool_id === "SearchTool" || - tool.in_code_tool_id === "InternetSearchTool" - ) - ? 0 - : 0.7; + (tool) => + tool.in_code_tool_id === "SearchTool" || + tool.in_code_tool_id === "InternetSearchTool" + ) + ? 0 + : 0.7; const setSelectedAssistantFromId = (assistantId: number) => { // NOTE: also intentionally look through available assistants here, so that @@ -279,6 +281,9 @@ export function ChatPage({ const [alternativeAssistant, setAlternativeAssistant] = useState(null); + const [presentingDocument, setPresentingDocument] = + useState(null); + const { visibleAssistants: assistants, recentAssistants, @@ -490,6 +495,7 @@ export function ChatPage({ clientScrollToBottom(true); } } + setIsFetchingChatMessages(false); // if this is a seeded chat, then kick off the AI message generation @@ -1152,8 +1158,8 @@ export function ChatPage({ const currentAssistantId = alternativeAssistantOverride ? alternativeAssistantOverride.id : alternativeAssistant - ? alternativeAssistant.id - : liveAssistant.id; + ? alternativeAssistant.id + : liveAssistant.id; resetInputBar(); let messageUpdates: Message[] | null = null; @@ -1649,7 +1655,6 @@ export function ChatPage({ scrollDist, endDivRef, debounceNumber, - waitForScrollRef, mobile: settings?.isMobile, enableAutoScroll: autoScrollEnabled, }); @@ -1946,6 +1951,7 @@ export function ChatPage({ {popup} + {currentFeedback && ( )} + {presentingDocument && ( + setPresentingDocument(null)} + /> + )} + {stackTraceModalContent && ( setStackTraceModalContent(null)} @@ -2424,6 +2437,9 @@ export function ChatPage({ } > {/* Right Sidebar - DocumentSidebar */} + setDocumentSelection(false)} + selectedMessage={aiMessage} + selectedDocuments={selectedDocuments} + toggleDocumentSelection={toggleDocumentSelection} + clearSelectedDocuments={clearSelectedDocuments} + selectedDocumentTokens={selectedDocumentTokens} + maxTokens={maxTokens} + isLoading={isFetchingChatMessages} + isOpen={documentSelection} + setPresentingDocument={setPresentingDocument} + /> ); } diff --git a/web/src/app/chat/documentSidebar/ChatDocumentDisplay.tsx b/web/src/app/chat/documentSidebar/ChatDocumentDisplay.tsx index 5f61e4b9db8..497322b9c03 100644 --- a/web/src/app/chat/documentSidebar/ChatDocumentDisplay.tsx +++ b/web/src/app/chat/documentSidebar/ChatDocumentDisplay.tsx @@ -6,13 +6,16 @@ import { buildDocumentSummaryDisplay } from "@/components/search/DocumentDisplay import { DocumentUpdatedAtBadge } from "@/components/search/DocumentUpdatedAtBadge"; import { MetadataBadge } from "@/components/MetadataBadge"; import { WebResultIcon } from "@/components/WebResultIcon"; +import { Dispatch, SetStateAction } from "react"; interface DocumentDisplayProps { + closeSidebar: () => void; document: DanswerDocument; modal?: boolean; isSelected: boolean; handleSelect: (documentId: string) => void; tokenLimitReached: boolean; + setPresentingDocument: Dispatch>; } export function DocumentMetadataBlock({ @@ -55,11 +58,13 @@ export function DocumentMetadataBlock({ } export function ChatDocumentDisplay({ + closeSidebar, document, modal, isSelected, handleSelect, tokenLimitReached, + setPresentingDocument, }: DocumentDisplayProps) { const isInternet = document.is_internet; @@ -67,6 +72,14 @@ export function ChatDocumentDisplay({ return null; } + const handleViewFile = async () => { + closeSidebar(); + + setTimeout(async () => { + setPresentingDocument(document); + }, 100); + }; + return (
void; + selectedMessage: Message | null; + selectedDocuments: DanswerDocument[] | null; + toggleDocumentSelection: (document: DanswerDocument) => void; + clearSelectedDocuments: () => void; + selectedDocumentTokens: number; + maxTokens: number; + isLoading: boolean; + initialWidth: number; + isOpen: boolean; + setPresentingDocument: Dispatch>; +} + +export const DocumentSidebar = forwardRef( + ( + { + closeSidebar, + selectedMessage, + selectedDocuments, + toggleDocumentSelection, + clearSelectedDocuments, + selectedDocumentTokens, + maxTokens, + isLoading, + initialWidth, + isOpen, + setPresentingDocument, + }, + ref: ForwardedRef + ) => { + const { popup } = usePopup(); + + const selectedDocumentIds = + selectedDocuments?.map((document) => document.document_id) || []; + + const currentDocuments = selectedMessage?.documents || null; + const dedupedDocuments = removeDuplicateDocs(currentDocuments || []); + + // NOTE: do not allow selection if less than 75 tokens are left + // this is to prevent the case where they are able to select the doc + // but it basically is unused since it's truncated right at the very + // start of the document (since title + metadata + misc overhead) takes up + // space + const tokenLimitReached = selectedDocumentTokens > maxTokens - 75; + + return ( +
{ + if (e.target === e.currentTarget) { + closeSidebar(); + } + }} + > +
+
+ {popup} +
+ {dedupedDocuments.length} Document + {dedupedDocuments.length > 1 ? "s" : ""} +

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

+
+ + + + {currentDocuments ? ( +
+ {dedupedDocuments.length > 0 ? ( + dedupedDocuments.map((document, ind) => ( +
+ { + toggleDocumentSelection( + dedupedDocuments.find( + (document) => document.document_id === documentId + )! + ); + }} + tokenLimitReached={tokenLimitReached} + /> +
+ )) + ) : ( +
+ No documents found for the query. +
+ )} +
+ ) : ( + !isLoading && ( +
+ + When you run ask a question, the retrieved documents will + show up here! + +
+ ) + )} +
+ +
+
+ + + +
+
+
+ ); + } +); + +DocumentSidebar.displayName = "DocumentSidebar"; diff --git a/web/src/app/chat/lib.tsx b/web/src/app/chat/lib.tsx index b5264ba1c54..195e0df9bf8 100644 --- a/web/src/app/chat/lib.tsx +++ b/web/src/app/chat/lib.tsx @@ -644,7 +644,6 @@ export async function useScrollonStream({ }: { chatState: ChatState; scrollableDivRef: RefObject; - waitForScrollRef: RefObject; scrollDist: MutableRefObject; endDivRef: RefObject; debounceNumber: number; diff --git a/web/src/app/chat/message/MemoizedTextComponents.tsx b/web/src/app/chat/message/MemoizedTextComponents.tsx index 7c8144e8ced..8b08e3c140d 100644 --- a/web/src/app/chat/message/MemoizedTextComponents.tsx +++ b/web/src/app/chat/message/MemoizedTextComponents.tsx @@ -6,45 +6,54 @@ import { ValidSources } from "@/lib/types"; import React, { memo } from "react"; import isEqual from "lodash/isEqual"; -export const MemoizedAnchor = memo(({ docs, children }: any) => { - console.log(children); - const value = children?.toString(); - if (value?.startsWith("[") && value?.endsWith("]")) { - const match = value.match(/\[(\d+)\]/); - if (match) { - const index = parseInt(match[1], 10) - 1; - const associatedDoc = docs && docs[index]; +export const MemoizedAnchor = memo( + ({ docs, updatePresentingDocument, children }: any) => { + console.log(children); + const value = children?.toString(); + if (value?.startsWith("[") && value?.endsWith("]")) { + const match = value.match(/\[(\d+)\]/); + if (match) { + const index = parseInt(match[1], 10) - 1; + const associatedDoc = docs && docs[index]; - const url = associatedDoc?.link - ? new URL(associatedDoc.link).origin + "/favicon.ico" - : ""; + const url = associatedDoc?.link + ? new URL(associatedDoc.link).origin + "/favicon.ico" + : ""; - const getIcon = (sourceType: ValidSources, link: string) => { - return getSourceMetadata(sourceType).icon({ size: 18 }); - }; + const getIcon = (sourceType: ValidSources, link: string) => { + return getSourceMetadata(sourceType).icon({ size: 18 }); + }; - const icon = - associatedDoc?.source_type === "web" ? ( - - ) : ( - getIcon( - associatedDoc?.source_type || "web", - associatedDoc?.link || "" - ) - ); + const icon = + associatedDoc?.source_type === "web" ? ( + + ) : ( + getIcon( + associatedDoc?.source_type || "web", + associatedDoc?.link || "" + ) + ); - return ( - - {children} - - ); + return ( + + {children} + + ); + } } + return ( + + {children} + + ); } - return {children}; -}); +); export const MemoizedLink = memo((props: any) => { - const { node, document, ...rest } = props; + const { node, document, updatePresentingDocument, ...rest } = props; const value = rest.children; if (value?.toString().startsWith("*")) { @@ -58,22 +67,21 @@ export const MemoizedLink = memo((props: any) => { icon={document?.icon as React.ReactNode} link={rest?.href} document={document as LoadedDanswerDocument} + updatePresentingDocument={updatePresentingDocument} > {rest.children} ); - } else { - return ( - - rest.href ? window.open(rest.href, "_blank") : undefined - } - className="cursor-pointer text-link hover:text-link-hover" - > - {rest.children} - - ); } + + return ( + rest.href && window.open(rest.href, "_blank")} + className="cursor-pointer text-link hover:text-link-hover" + > + {rest.children} + + ); }); export const MemoizedParagraph = memo( diff --git a/web/src/app/chat/message/Messages.tsx b/web/src/app/chat/message/Messages.tsx index 0aa9ba82683..7bd4626307a 100644 --- a/web/src/app/chat/message/Messages.tsx +++ b/web/src/app/chat/message/Messages.tsx @@ -10,6 +10,7 @@ import { import { FeedbackType } from "../types"; import React, { memo, + ReactNode, useCallback, useContext, useEffect, @@ -21,6 +22,7 @@ import ReactMarkdown from "react-markdown"; import { DanswerDocument, FilteredDanswerDocument, + LoadedDanswerDocument, } from "@/lib/search/interfaces"; import { SearchSummary } from "./SearchSummary"; @@ -70,6 +72,10 @@ import CsvContent from "../../../components/tools/CSVContent"; import SourceCard, { SeeMoreBlock, } from "@/components/chat_search/sources/SourceCard"; +import { Citation } from "@/components/search/results/Citation"; +import { SourceIcon } from "@/components/SourceIcon"; +import { SearchResultIcon } from "@/components/SearchResultIcon"; +import { DocumentMetadataBlock } from "@/components/search/DocumentDisplay"; const TOOLS_WITH_CUSTOM_HANDLING = [ SEARCH_TOOL_NAME, @@ -188,6 +194,7 @@ export const AIMessage = ({ currentPersona, otherMessagesCanSwitchTo, onMessageSelection, + setPresentingDocument, index, }: { index?: number; @@ -218,6 +225,7 @@ export const AIMessage = ({ retrievalDisabled?: boolean; overriddenModel?: string; regenerate?: (modelOverRide: LlmOverride) => Promise; + setPresentingDocument?: (document: DanswerDocument) => void; }) => { const toolCallGenerating = toolCall && !toolCall.tool_result; const processContent = (content: string | JSX.Element) => { @@ -308,7 +316,12 @@ export const AIMessage = ({ const anchorCallback = useCallback( (props: any) => ( - {props.children} + + {props.children} + ), [docs] ); @@ -321,6 +334,11 @@ export const AIMessage = ({ new Set((docs || []).map((doc) => doc.source_type)) ).slice(0, 3); + const updatePresentingDocument = (documentIndex: number) => { + setPresentingDocument && + setPresentingDocument(filteredDocs[documentIndex - 1]); + }; + const markdownComponents = useMemo( () => ({ a: anchorCallback, @@ -339,7 +357,7 @@ export const AIMessage = ({ ); }, }), - [anchorCallback, paragraphCallback, finalContent] + [anchorCallback, paragraphCallback, finalContent, updatePresentingDocument] ); const renderedMarkdown = useMemo(() => { @@ -490,6 +508,91 @@ export const AIMessage = ({ ) : isComplete ? null : ( <> )} + {isComplete && docs && docs.length > 0 && ( +
+
+
+ {!settings?.isMobile && + filteredDocs.length > 0 && + filteredDocs.slice(0, 2).map((doc, ind) => ( +
+ +
+ +
+
+ {doc.blurb} +
+
+ ))} +
{ + if (messageId) { + onMessageSelection?.(messageId); + } + 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 && diff --git a/web/src/app/chat/shared/[chatId]/SharedChatDisplay.tsx b/web/src/app/chat/shared/[chatId]/SharedChatDisplay.tsx index f0acaa0ace3..7f302904c64 100644 --- a/web/src/app/chat/shared/[chatId]/SharedChatDisplay.tsx +++ b/web/src/app/chat/shared/[chatId]/SharedChatDisplay.tsx @@ -17,6 +17,8 @@ import { SettingsContext } from "@/components/settings/SettingsProvider"; import { DanswerInitializingLoader } from "@/components/DanswerInitializingLoader"; import { Persona } from "@/app/admin/assistants/interfaces"; import { Button } from "@/components/ui/button"; +import { DanswerDocument } from "@/lib/search/interfaces"; +import TextView from "@/components/chat_search/TextView"; function BackToDanswerButton() { const router = useRouter(); @@ -41,6 +43,9 @@ export function SharedChatDisplay({ persona: Persona; }) { const [isReady, setIsReady] = useState(false); + const [presentingDocument, setPresentingDocument] = + useState(null); + useEffect(() => { Prism.highlightAll(); setIsReady(true); @@ -63,61 +68,70 @@ export function SharedChatDisplay({ ); return ( -
-
-
-
-
-

- {chatSession.description || - `Chat ${chatSession.chat_session_id}`} -

-

- {humanReadableFormat(chatSession.time_created)} -

+ <> + {presentingDocument && ( + setPresentingDocument(null)} + /> + )} +
+
+
+
+
+

+ {chatSession.description || + `Chat ${chatSession.chat_session_id}`} +

+

+ {humanReadableFormat(chatSession.time_created)} +

- -
- {isReady ? ( -
- {messages.map((message) => { - if (message.type === "user") { - return ( - - ); - } else { - return ( - - ); - } - })} +
- ) : ( -
-
- + {isReady ? ( +
+ {messages.map((message) => { + if (message.type === "user") { + return ( + + ); + } else { + return ( + + ); + } + })}
-
- )} + ) : ( +
+
+ +
+
+ )} +
-
- -
+ +
+ ); } diff --git a/web/src/components/chat_search/TextView.tsx b/web/src/components/chat_search/TextView.tsx new file mode 100644 index 00000000000..390909994ba --- /dev/null +++ b/web/src/components/chat_search/TextView.tsx @@ -0,0 +1,181 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Download, XIcon, ZoomIn, ZoomOut } from "lucide-react"; +import { DanswerDocument } from "@/lib/search/interfaces"; +import { MinimalMarkdown } from "./MinimalMarkdown"; + +interface TextViewProps { + presentingDocument: DanswerDocument; + onClose: () => void; +} +export default function TextView({ + presentingDocument, + onClose, +}: TextViewProps) { + const [zoom, setZoom] = useState(100); + const [fileContent, setFileContent] = useState(""); + const [fileUrl, setFileUrl] = useState(""); + const [fileName, setFileName] = useState(""); + const [isLoading, setIsLoading] = useState(true); + const [fileType, setFileType] = useState("application/octet-stream"); + + const isMarkdownFormat = (mimeType: string): boolean => { + const markdownFormats = [ + "text/markdown", + "text/x-markdown", + "text/plain", + "text/x-rst", + "text/x-org", + ]; + return markdownFormats.some((format) => mimeType.startsWith(format)); + }; + + const isSupportedIframeFormat = (mimeType: string): boolean => { + const supportedFormats = [ + "application/pdf", + "image/png", + "image/jpeg", + "image/gif", + "image/svg+xml", + ]; + return supportedFormats.some((format) => mimeType.startsWith(format)); + }; + + const fetchFile = useCallback(async () => { + setIsLoading(true); + const fileId = presentingDocument.document_id.split("__")[1]; + try { + const response = await fetch( + `/api/chat/file/${encodeURIComponent(fileId)}`, + { + method: "GET", + } + ); + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + setFileUrl(url); + setFileName(presentingDocument.semantic_identifier || "document"); + const contentType = + response.headers.get("Content-Type") || "application/octet-stream"; + setFileType(contentType); + + if (isMarkdownFormat(blob.type)) { + const text = await blob.text(); + setFileContent(text); + } + } catch (error) { + console.error("Error fetching file:", error); + } finally { + setTimeout(() => { + setIsLoading(false); + }, 1000); + } + }, [presentingDocument]); + + useEffect(() => { + fetchFile(); + }, [fetchFile]); + + const handleDownload = () => { + const link = document.createElement("a"); + link.href = fileUrl; + link.download = fileName; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + + const handleZoomIn = () => setZoom((prev) => Math.min(prev + 25, 200)); + const handleZoomOut = () => setZoom((prev) => Math.max(prev - 25, 100)); + + return ( + + + + + {fileName} + +
+ + {zoom}% + + + +
+
+
+
+ {isLoading ? ( +
+
+

+ Loading document... +

+
+ ) : ( +
+ {isSupportedIframeFormat(fileType) ? ( +