diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index 1fec5990d5d6..94004b69c174 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -1,11 +1,11 @@ -import { SearchSection } from "@/components/SearchBar"; +import { SearchSection } from "@/components/search/SearchSection"; import { Header } from "@/components/Header"; export default function Home() { return ( <>
-
+
diff --git a/web/src/components/SearchResultsDisplay.tsx b/web/src/components/SearchResultsDisplay.tsx deleted file mode 100644 index e53ac6448959..000000000000 --- a/web/src/components/SearchResultsDisplay.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import React from "react"; -import { Globe, SlackLogo, GoogleDriveLogo } from "@phosphor-icons/react"; -import "tailwindcss/tailwind.css"; -import { Quote, SearchResponse } from "./types"; -import { ThinkingAnimation } from "./Thinking"; - -interface SearchResultsDisplayProps { - data: SearchResponse | undefined; - isFetching: boolean; -} - -const ICON_SIZE = "20"; -const ICON_STYLE = "text-blue-600 my-auto mr-1 flex flex-shrink-0"; - -const getSourceIcon = (sourceType: string) => { - switch (sourceType) { - case "web": - return ; - case "slack": - return ; - case "google_drive": - return ; - default: - return null; - } -}; - -export const SearchResultsDisplay: React.FC = ({ - data, - isFetching, -}) => { - if (isFetching) { - return ; - } - - if (!data) { - return null; - } - - const { answer, quotes } = data; - if (!answer || !quotes) { - return
Unable to find an answer
; - } - - const dedupedQuotes: Quote[] = []; - const seen = new Set(); - Object.values(quotes).forEach((quote) => { - if (!seen.has(quote.document_id)) { - dedupedQuotes.push(quote); - seen.add(quote.document_id); - } - }); - - return ( - <> -
-

AI Answer

-

{answer}

- -

Sources

- -
-
-
- Results -
- {dedupedQuotes.map((quoteInfo) => ( - - ))} -
- - ); -}; diff --git a/web/src/components/SearchBar.tsx b/web/src/components/search/SearchBar.tsx similarity index 53% rename from web/src/components/SearchBar.tsx rename to web/src/components/search/SearchBar.tsx index 03a00e1ed624..6516048c1bfa 100644 --- a/web/src/components/SearchBar.tsx +++ b/web/src/components/search/SearchBar.tsx @@ -1,63 +1,20 @@ -"use client"; - import React, { useState, KeyboardEvent, ChangeEvent } from "react"; import { MagnifyingGlass } from "@phosphor-icons/react"; -import "tailwindcss/tailwind.css"; -import { SearchResultsDisplay } from "./SearchResultsDisplay"; -import { SearchResponse } from "./types"; - -const searchRequest = async (query: string): Promise => { - const url = new URL("/api/direct-qa", window.location.origin); - const params = new URLSearchParams({ - query, - collection: "semantic_search", - }).toString(); - url.search = params; - - const response = await fetch(url); - return response.json(); -}; - -export const SearchSection: React.FC<{}> = () => { - const [answer, setAnswer] = useState(); - const [isFetching, setIsFetching] = useState(false); - - return ( - <> - { - setIsFetching(true); - searchRequest(query).then((response) => { - setIsFetching(false); - setAnswer(response); - }); - }} - /> -
- -
- - ); -}; interface SearchBarProps { onSearch: (searchTerm: string) => void; } -const SearchBar: React.FC = ({ onSearch }) => { +export const SearchBar: React.FC = ({ onSearch }) => { const [searchTerm, setSearchTerm] = useState(""); const handleChange = (event: ChangeEvent) => { const target = event.target; setSearchTerm(target.value); - // Reset the textarea height + // Resize the textarea to fit the content target.style.height = "24px"; - - // Calculate the new height based on scrollHeight const newHeight = target.scrollHeight; - - // Apply the new height target.style.height = `${newHeight}px`; }; diff --git a/web/src/components/search/SearchResultsDisplay.tsx b/web/src/components/search/SearchResultsDisplay.tsx new file mode 100644 index 000000000000..7fb98161627b --- /dev/null +++ b/web/src/components/search/SearchResultsDisplay.tsx @@ -0,0 +1,115 @@ +import React from "react"; +import { Globe, SlackLogo, GoogleDriveLogo } from "@phosphor-icons/react"; +import "tailwindcss/tailwind.css"; +import { Quote, Document } from "./types"; +import { ThinkingAnimation } from "../Thinking"; + +interface SearchResultsDisplayProps { + answer: string | null; + quotes: Record | null; + documents: Document[] | null; + isFetching: boolean; +} + +const ICON_SIZE = "20"; +const ICON_STYLE = "text-blue-600 my-auto mr-1 flex flex-shrink-0"; + +const getSourceIcon = (sourceType: string) => { + switch (sourceType) { + case "web": + return ; + case "slack": + return ; + case "google_drive": + return ; + default: + return null; + } +}; + +export const SearchResultsDisplay: React.FC = ({ + answer, + quotes, + documents, + isFetching, +}) => { + if (!answer) { + if (isFetching) { + return ; + } + return null; + } + + if (answer === null) { + return
Unable to find an answer
; + } + + const dedupedQuotes: Quote[] = []; + const seen = new Set(); + if (quotes) { + Object.values(quotes).forEach((quote) => { + if (!seen.has(quote.document_id)) { + dedupedQuotes.push(quote); + seen.add(quote.document_id); + } + }); + } + + return ( + <> +
+

AI Answer

+

{answer}

+ + {dedupedQuotes.length > 0 && ( + <> +

Sources

+ + + )} +
+ {/* Only display docs once we're done fetching to avoid distracting from the AI answer*/} + {!isFetching && documents && documents.length > 0 && ( +
+
+ Results +
+ {documents.slice(0, 5).map((doc) => ( + + ))} +
+ )} + + ); +}; diff --git a/web/src/components/search/SearchSection.tsx b/web/src/components/search/SearchSection.tsx new file mode 100644 index 000000000000..a0a2dc932e01 --- /dev/null +++ b/web/src/components/search/SearchSection.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { useState } from "react"; +import { SearchBar } from "./SearchBar"; +import { SearchResultsDisplay } from "./SearchResultsDisplay"; +import { Quote, Document } from "./types"; + +const searchRequestStreamed = async ( + query: string, + updateCurrentAnswer: (val: string) => void, + updateQuotes: (quotes: Record) => void, + updateDocs: (docs: Document[]) => void +) => { + const url = new URL("/api/stream-direct-qa", window.location.origin); + const params = new URLSearchParams({ + query, + collection: "semantic_search", + }).toString(); + url.search = params; + + let answer = ""; + try { + const response = await fetch(url); + const reader = response.body?.getReader(); + const decoder = new TextDecoder("utf-8"); + + while (true) { + const rawChunk = await reader?.read(); + if (!rawChunk) { + throw new Error("Unable to process chunk"); + } + const { done, value } = rawChunk; + if (done) { + break; + } + + // Process each chunk as it arrives + const chunk = decoder.decode(value, { stream: true }); + if (!chunk) { + break; + } + const chunkJson = JSON.parse(chunk); + const answerChunk = chunkJson.answer_data; + if (answerChunk) { + answer += answerChunk; + updateCurrentAnswer(answer); + } else { + const docs = chunkJson.top_documents as any[]; + if (docs) { + updateDocs(docs.map((doc) => JSON.parse(doc))); + } else { + updateQuotes(chunkJson); + } + } + } + } catch (err) { + console.error("Fetch error:", err); + } + return answer; +}; + +export const SearchSection: React.FC<{}> = () => { + const [answer, setAnswer] = useState(""); + const [quotes, setQuotes] = useState | null>(null); + const [documents, setDocuments] = useState(null); + const [isFetching, setIsFetching] = useState(false); + + return ( + <> + { + setIsFetching(true); + setAnswer(""); + setQuotes(null); + setDocuments(null); + searchRequestStreamed(query, setAnswer, setQuotes, setDocuments).then( + () => { + setIsFetching(false); + } + ); + }} + /> +
+ +
+ + ); +}; diff --git a/web/src/components/types.tsx b/web/src/components/search/types.tsx similarity index 62% rename from web/src/components/types.tsx rename to web/src/components/search/types.tsx index 6e6a7cacf6e4..928d006eef54 100644 --- a/web/src/components/types.tsx +++ b/web/src/components/search/types.tsx @@ -6,6 +6,14 @@ export interface Quote { semantic_identifier: string | null; } +export interface Document { + document_id: string; + link: string; + source_type: string; + blurb: string; + semantic_name: string | null; +} + export interface SearchResponse { answer: string; quotes: Record;