frontend/src/pages/chat/Chat.tsx (331 lines of code) (raw):

import { useRef, useState, useEffect } from "react"; import { Checkbox, Panel, DefaultButton, TextField, SpinButton } from "@fluentui/react"; import { SparkleFilled, TabDesktopMultipleBottomRegular } from "@fluentui/react-icons"; import { getLanguageText } from '../../utils/languageUtils'; import styles from "./Chat.module.css"; import { chatApiGpt, Approaches, AskResponse, ChatRequest, ChatRequestGpt, ChatTurn } from "../../api"; import { Answer, AnswerError, AnswerLoading } from "../../components/Answer"; import { QuestionInput } from "../../components/QuestionInput"; import { ExampleList } from "../../components/Example"; import { UserChatMessage } from "../../components/UserChatMessage"; import { AnalysisPanel, AnalysisPanelTabs } from "../../components/AnalysisPanel"; import { ClearChatButton } from "../../components/ClearChatButton"; import { getTokenOrRefresh } from "../../components/QuestionInput/token_util"; import { SpeechConfig, AudioConfig, SpeechSynthesizer, ResultReason } from "microsoft-cognitiveservices-speech-sdk"; import { getFileType } from "../../utils/functions"; const error_message_text = getLanguageText('errorMessage'); const Chat = () => { // speech synthesis is disabled by default const speechSynthesisEnabled = false; const [fileName, setFileName] = useState<string>(""); const [placeholderText, setPlaceholderText] = useState(""); const [isConfigPanelOpen, setIsConfigPanelOpen] = useState(false); const [promptTemplate, setPromptTemplate] = useState<string>(""); const [retrieveCount, setRetrieveCount] = useState<number>(3); const [useSemanticRanker, setUseSemanticRanker] = useState<boolean>(true); const [useSemanticCaptions, setUseSemanticCaptions] = useState<boolean>(false); const [excludeCategory, setExcludeCategory] = useState<string>(""); const [useSuggestFollowupQuestions, setUseSuggestFollowupQuestions] = useState<boolean>(false); const lastQuestionRef = useRef<string>(""); const chatMessageStreamEnd = useRef<HTMLDivElement | null>(null); const [fileType, setFileType] = useState<string>("txt"); const [isLoading, setIsLoading] = useState<boolean>(false); const [error, setError] = useState<unknown>(); const [activeCitation, setActiveCitation] = useState<string>(); const [activeAnalysisPanelTab, setActiveAnalysisPanelTab] = useState<AnalysisPanelTabs | undefined>(undefined); const [selectedAnswer, setSelectedAnswer] = useState<number>(0); const [answers, setAnswers] = useState<[user: string, response: AskResponse][]>([]); const [userId, setUserId] = useState<string>(""); const triggered = useRef(false); const makeApiRequestGpt = async (question: string) => { lastQuestionRef.current = question; error && setError(undefined); setIsLoading(true); setActiveCitation(undefined); setActiveAnalysisPanelTab(undefined); try { const history: ChatTurn[] = answers.map(a => ({ user: a[0], bot: a[1].answer })); const request: ChatRequestGpt = { history: [...history, { user: question, bot: undefined }], approach: Approaches.ReadRetrieveRead, conversation_id: userId, query: question, overrides: { promptTemplate: promptTemplate.length === 0 ? undefined : promptTemplate, excludeCategory: excludeCategory.length === 0 ? undefined : excludeCategory, top: retrieveCount, semanticRanker: useSemanticRanker, semanticCaptions: useSemanticCaptions, suggestFollowupQuestions: useSuggestFollowupQuestions } }; const result = await chatApiGpt(request); console.log(result); console.log(result.answer); // Check if result.thoughts exists if (!result.thoughts) { result.thoughts = "No thought process available."; } setAnswers([...answers, [question, result]]); setUserId(result.conversation_id); // Voice Synthesis if (speechSynthesisEnabled) { const tokenObj = await getTokenOrRefresh(); const speechConfig = SpeechConfig.fromAuthorizationToken(tokenObj.authToken, tokenObj.region); const audioConfig = AudioConfig.fromDefaultSpeakerOutput(); speechConfig.speechSynthesisLanguage = tokenObj.speechSynthesisLanguage; speechConfig.speechSynthesisVoiceName = tokenObj.speechSynthesisVoiceName; const synthesizer = new SpeechSynthesizer(speechConfig, audioConfig); synthesizer.speakTextAsync( result.answer.replace(/ *\[[^)]*\] */g, ""), function (result) { if (result.reason === ResultReason.SynthesizingAudioCompleted) { console.log("synthesis finished."); } else { console.error("Speech synthesis canceled, " + result.errorDetails + "\nDid you update the subscription info?"); } synthesizer.close(); }, function (err) { console.trace("err - " + err); synthesizer.close(); } ); } } catch (e) { setError(e); } finally { setIsLoading(false); } }; const clearChat = () => { lastQuestionRef.current = ""; error && setError(undefined); setActiveCitation(undefined); setActiveAnalysisPanelTab(undefined); setAnswers([]); setUserId(""); }; /**Get Document */ const getDocument = async (documentName: string) => { /** get file type */ console.log(`Get document: ${documentName}`); let type = getFileType(documentName); setFileType(type); try { const response = await fetch("/api/get-blob", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ blob_name: documentName }) }); if (!response.ok) { // Create a dummy blob with an error message const dummyContent = `Download Error: It was not possible to download the document: ${documentName}`; const dummyBlob = new Blob([dummyContent], { type: "text/html" }); console.log('Error fetching DOC: ${response.status}'); return dummyBlob; } return await response.blob(); } catch (error) { console.error(error); console.log("Error details:", error); throw new Error("Error fetching DOC."); } }; useEffect(() => { chatMessageStreamEnd.current?.scrollIntoView({ behavior: "smooth" }); if (triggered.current === false) { triggered.current = true; } const language = navigator.language; if (language.startsWith("pt")) { setPlaceholderText("Escreva aqui sua pergunta"); } if (language.startsWith("es")) { setPlaceholderText("Escribe tu pregunta aqui"); } else { setPlaceholderText("Write your question here"); } }, [isLoading]); const onPromptTemplateChange = (_ev?: React.FormEvent<HTMLInputElement | HTMLTextAreaElement>, newValue?: string) => { setPromptTemplate(newValue || ""); }; const onRetrieveCountChange = (_ev?: React.SyntheticEvent<HTMLElement, Event>, newValue?: string) => { setRetrieveCount(parseInt(newValue || "3")); }; const onUseSemanticRankerChange = (_ev?: React.FormEvent<HTMLElement | HTMLInputElement>, checked?: boolean) => { setUseSemanticRanker(!!checked); }; const onUseSemanticCaptionsChange = (_ev?: React.FormEvent<HTMLElement | HTMLInputElement>, checked?: boolean) => { setUseSemanticCaptions(!!checked); }; const onExcludeCategoryChanged = (_ev?: React.FormEvent, newValue?: string) => { setExcludeCategory(newValue || ""); }; const onUseSuggestFollowupQuestionsChange = (_ev?: React.FormEvent<HTMLElement | HTMLInputElement>, checked?: boolean) => { setUseSuggestFollowupQuestions(!!checked); }; const onExampleClicked = (example: string) => { makeApiRequestGpt(example); }; const onShowCitation = async (citation: string, fileName: string, index: number) => { try { // Get the document blob const response = await getDocument(fileName); // Determine file type from file name let type = getFileType(fileName); // Set file type and file name in state setFileType(type); setFileName(fileName); if (activeCitation === citation && activeAnalysisPanelTab === AnalysisPanelTabs.CitationTab && selectedAnswer === index) { setActiveAnalysisPanelTab(undefined); } else { readFile(response); function readFile(input: Blob) { const fr = new FileReader(); fr.readAsDataURL(input); fr.onload = function (event) { const res: any = event.target ? event.target.result : undefined; setActiveCitation(res); }; } setActiveAnalysisPanelTab(AnalysisPanelTabs.CitationTab); } setSelectedAnswer(index); } catch (e) { console.error('Error fetching document:', e); } }; const onToggleTab = (tab: AnalysisPanelTabs, index: number) => { console.log("onToggleTab called with tab:", tab, "index:", index); console.log("Tab clicked:", tab); if (activeAnalysisPanelTab === tab && selectedAnswer === index) { setActiveAnalysisPanelTab(undefined); } else { setActiveAnalysisPanelTab(tab); } setSelectedAnswer(index); }; // Add or update the onThoughtProcessClicked function const onThoughtProcessClicked = (index: number) => { console.log('onThoughtProcessClicked called with index:', index); if (activeAnalysisPanelTab === AnalysisPanelTabs.ThoughtProcessTab && selectedAnswer === index) { setActiveAnalysisPanelTab(undefined); } else { setActiveAnalysisPanelTab(AnalysisPanelTabs.ThoughtProcessTab); } setSelectedAnswer(index); console.log('activeAnalysisPanelTab is now:', activeAnalysisPanelTab); }; return ( <div className={styles.container}> <div className={styles.commandsContainer}> <ClearChatButton className={styles.commandButton} onClick={clearChat} disabled={!lastQuestionRef.current || isLoading} /> </div> <div className={styles.chatRoot}> <div className={styles.chatContainer}> {!lastQuestionRef.current ? ( <div className={styles.chatEmptyState}> </div> ) : ( <div className={styles.chatMessageStream}> {answers.map((answer, index) => ( <div key={index}> <UserChatMessage message={answer[0]} /> <div className={styles.chatMessageGpt}> <Answer key={index} answer={answer[1]} isSelected={selectedAnswer === index && activeAnalysisPanelTab !== undefined} onCitationClicked={(c, n) => onShowCitation(c, n, index)} onThoughtProcessClicked={() => onThoughtProcessClicked(index)} onSupportingContentClicked={() => onToggleTab(AnalysisPanelTabs.SupportingContentTab, index)} onFollowupQuestionClicked={q => makeApiRequestGpt(q)} showFollowupQuestions={false} showSources={true} /> </div> </div> ))} {isLoading && ( <> <UserChatMessage message={lastQuestionRef.current} /> <div className={styles.chatMessageGptMinWidth}> <AnswerLoading /> </div> </> )} {error ? ( <> <UserChatMessage message={lastQuestionRef.current} /> <div className={styles.chatMessageGptMinWidth}> <AnswerError error={error.toString() === "SyntaxError: Unexpected end of JSON input" ? error_message_text + "Error: Orchestrator call failed or did not return a valid response." : error_message_text + error.toString()} onRetry={() => makeApiRequestGpt(lastQuestionRef.current)} /> </div> </> ) : null} <div ref={chatMessageStreamEnd} /> </div> )} <div className={styles.chatInput}> <QuestionInput clearOnSend placeholder={placeholderText} disabled={isLoading} onSend={question => makeApiRequestGpt(question)} /> </div> </div> {answers.length > 0 && ( <AnalysisPanel activeTab={activeAnalysisPanelTab as AnalysisPanelTabs} className={styles.chatAnalysisPanel} activeCitation={activeCitation} onActiveTabChanged={x => onToggleTab(x, selectedAnswer)} citationHeight="810px" answer={answers[selectedAnswer][1]} fileType={fileType} fileName={fileName} /> )} <Panel headerText="Configure answer generation" isOpen={isConfigPanelOpen} isBlocking={false} onDismiss={() => setIsConfigPanelOpen(false)} closeButtonAriaLabel="Close" onRenderFooterContent={() => <DefaultButton onClick={() => setIsConfigPanelOpen(false)}>Close</DefaultButton>} isFooterAtBottom={true} > <TextField className={styles.chatSettingsSeparator} defaultValue={promptTemplate} label="Override prompt template" multiline autoAdjustHeight onChange={onPromptTemplateChange} /> <SpinButton className={styles.chatSettingsSeparator} label="Retrieve this many documents from search:" min={1} max={50} defaultValue={retrieveCount.toString()} onChange={onRetrieveCountChange} /> <TextField className={styles.chatSettingsSeparator} label="Exclude category" onChange={onExcludeCategoryChanged} /> <Checkbox className={styles.chatSettingsSeparator} checked={useSemanticRanker} label="Use semantic ranker for retrieval" onChange={onUseSemanticRankerChange} /> <Checkbox className={styles.chatSettingsSeparator} checked={useSemanticCaptions} label="Use query-contextual summaries instead of whole documents" onChange={onUseSemanticCaptionsChange} disabled={!useSemanticRanker} /> <Checkbox className={styles.chatSettingsSeparator} checked={useSuggestFollowupQuestions} label="Suggest follow-up questions" onChange={onUseSuggestFollowupQuestionsChange} /> </Panel> </div> </div> ); }; export default Chat;