Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions app/frontend/src/api/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,11 @@ export type Config = {
export type SimpleAPIResponse = {
message?: string;
};

export interface SpeechConfig {
speechUrls: (string | null)[];
setSpeechUrls: (urls: (string | null)[]) => void;
audio: HTMLAudioElement;
isPlaying: boolean;
setIsPlaying: (isPlaying: boolean) => void;
}
15 changes: 9 additions & 6 deletions app/frontend/src/components/Answer/Answer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,16 @@ import remarkGfm from "remark-gfm";
import rehypeRaw from "rehype-raw";

import styles from "./Answer.module.css";
import { ChatAppResponse, getCitationFilePath } from "../../api";
import { ChatAppResponse, getCitationFilePath, SpeechConfig } from "../../api";
import { parseAnswerToHtml } from "./AnswerParser";
import { AnswerIcon } from "./AnswerIcon";
import { SpeechOutputBrowser } from "./SpeechOutputBrowser";
import { SpeechOutputAzure } from "./SpeechOutputAzure";

interface Props {
answer: ChatAppResponse;
index: number;
speechConfig: SpeechConfig;
isSelected?: boolean;
isStreaming: boolean;
onCitationClicked: (filePath: string) => void;
Expand All @@ -23,11 +25,12 @@ interface Props {
showFollowupQuestions?: boolean;
showSpeechOutputBrowser?: boolean;
showSpeechOutputAzure?: boolean;
speechUrl: string | null;
}

export const Answer = ({
answer,
index,
speechConfig,
isSelected,
isStreaming,
onCitationClicked,
Expand All @@ -36,13 +39,11 @@ export const Answer = ({
onFollowupQuestionClicked,
showFollowupQuestions,
showSpeechOutputAzure,
showSpeechOutputBrowser,
speechUrl
showSpeechOutputBrowser
}: Props) => {
const followupQuestions = answer.context?.followup_questions;
const messageContent = answer.message.content;
const parsedAnswer = useMemo(() => parseAnswerToHtml(messageContent, isStreaming, onCitationClicked), [answer]);

const sanitizedAnswerHtml = DOMPurify.sanitize(parsedAnswer.answerHtml);

return (
Expand All @@ -67,7 +68,9 @@ export const Answer = ({
onClick={() => onSupportingContentClicked()}
disabled={!answer.context.data_points}
/>
{showSpeechOutputAzure && <SpeechOutputAzure url={speechUrl} />}
{showSpeechOutputAzure && (
<SpeechOutputAzure answer={sanitizedAnswerHtml} index={index} speechConfig={speechConfig} isStreaming={isStreaming} />
)}
{showSpeechOutputBrowser && <SpeechOutputBrowser answer={sanitizedAnswerHtml} />}
</div>
</Stack>
Expand Down
71 changes: 51 additions & 20 deletions app/frontend/src/components/Answer/SpeechOutputAzure.tsx
Original file line number Diff line number Diff line change
@@ -1,44 +1,75 @@
import { useState } from "react";

import { IconButton } from "@fluentui/react";
import { getSpeechApi, SpeechConfig } from "../../api";

interface Props {
url: string | null;
answer: string;
speechConfig: SpeechConfig;
index: number;
isStreaming: boolean;
}

let audio = new Audio();
export const SpeechOutputAzure = ({ answer, speechConfig, index, isStreaming }: Props) => {
const [isLoading, setIsLoading] = useState(false);
const [localPlayingState, setLocalPlayingState] = useState(false);

export const SpeechOutputAzure = ({ url }: Props) => {
const [isPlaying, setIsPlaying] = useState(false);
const playAudio = async (url: string) => {
speechConfig.audio.src = url;
await speechConfig.audio
.play()
.then(() => {
speechConfig.audio.onended = () => {
speechConfig.setIsPlaying(false);
setLocalPlayingState(false);
};
speechConfig.setIsPlaying(true);
setLocalPlayingState(true);
})
.catch(() => {
alert("Failed to play speech output.");
console.error("Failed to play speech output.");
speechConfig.setIsPlaying(false);
setLocalPlayingState(false);
});
};

const startOrStopAudio = async () => {
if (isPlaying) {
audio.pause();
setIsPlaying(false);
const startOrStopSpeech = async (answer: string) => {
if (speechConfig.isPlaying) {
speechConfig.audio.pause();
speechConfig.audio.currentTime = 0;
speechConfig.setIsPlaying(false);
setLocalPlayingState(false);
return;
}

if (!url) {
console.error("Speech output is not yet available.");
if (speechConfig.speechUrls[index]) {
playAudio(speechConfig.speechUrls[index]);
return;
}
audio = new Audio(url);
await audio.play();
audio.addEventListener("ended", () => {
setIsPlaying(false);
setIsLoading(true);
await getSpeechApi(answer).then(async speechUrl => {
if (!speechUrl) {
alert("Speech output is not available.");
console.error("Speech output is not available.");
return;
}
setIsLoading(false);
speechConfig.setSpeechUrls(speechConfig.speechUrls.map((url, i) => (i === index ? speechUrl : url)));
playAudio(speechUrl);
});
setIsPlaying(true);
};

const color = isPlaying ? "red" : "black";
return (
const color = localPlayingState ? "red" : "black";
return isLoading ? (
<IconButton style={{ color: color }} iconProps={{ iconName: "Sync" }} title="Loading" ariaLabel="Loading answer" disabled={true} />
) : (
<IconButton
style={{ color: color }}
iconProps={{ iconName: "Volume3" }}
title="Speak answer"
ariaLabel="Speak answer"
onClick={() => startOrStopAudio()}
disabled={!url}
onClick={() => startOrStopSpeech(answer)}
disabled={isStreaming}
/>
);
};
27 changes: 15 additions & 12 deletions app/frontend/src/pages/ask/Ask.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useId } from "@fluentui/react-hooks";

import styles from "./Ask.module.css";

import { askApi, configApi, getSpeechApi, ChatAppResponse, ChatAppRequest, RetrievalMode, VectorFieldOptions, GPT4VInput } from "../../api";
import { askApi, configApi, ChatAppResponse, ChatAppRequest, RetrievalMode, VectorFieldOptions, GPT4VInput, SpeechConfig } from "../../api";
import { Answer, AnswerError } from "../../components/Answer";
import { QuestionInput } from "../../components/QuestionInput";
import { ExampleList } from "../../components/Example";
Expand Down Expand Up @@ -48,13 +48,23 @@ export function Component(): JSX.Element {
const [showSpeechInput, setShowSpeechInput] = useState<boolean>(false);
const [showSpeechOutputBrowser, setShowSpeechOutputBrowser] = useState<boolean>(false);
const [showSpeechOutputAzure, setShowSpeechOutputAzure] = useState<boolean>(false);
const audio = useRef(new Audio()).current;
const [isPlaying, setIsPlaying] = useState(false);

const lastQuestionRef = useRef<string>("");

const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<unknown>();
const [answer, setAnswer] = useState<ChatAppResponse>();
const [speechUrl, setSpeechUrl] = useState<string | null>(null);
const [speechUrls, setSpeechUrls] = useState<(string | null)[]>([]);

const speechConfig: SpeechConfig = {
speechUrls,
setSpeechUrls,
audio,
isPlaying,
setIsPlaying
};

const [activeCitation, setActiveCitation] = useState<string>();
const [activeAnalysisPanelTab, setActiveAnalysisPanelTab] = useState<AnalysisPanelTabs | undefined>(undefined);
Expand Down Expand Up @@ -82,14 +92,6 @@ export function Component(): JSX.Element {
getConfig();
}, []);

useEffect(() => {
if (answer && showSpeechOutputAzure) {
getSpeechApi(answer.message.content).then(speechUrl => {
setSpeechUrl(speechUrl);
});
}
}, [answer]);

const makeApiRequest = async (question: string) => {
lastQuestionRef.current = question;

Expand Down Expand Up @@ -134,7 +136,7 @@ export function Component(): JSX.Element {
};
const result = await askApi(request, token);
setAnswer(result);
setSpeechUrl(null);
setSpeechUrls([null]);
} catch (e) {
setError(e);
} finally {
Expand Down Expand Up @@ -256,13 +258,14 @@ export function Component(): JSX.Element {
<div className={styles.askAnswerContainer}>
<Answer
answer={answer}
index={0}
speechConfig={speechConfig}
isStreaming={false}
onCitationClicked={x => onShowCitation(x)}
onThoughtProcessClicked={() => onToggleTab(AnalysisPanelTabs.ThoughtProcessTab)}
onSupportingContentClicked={() => onToggleTab(AnalysisPanelTabs.SupportingContentTab)}
showSpeechOutputAzure={showSpeechOutputAzure}
showSpeechOutputBrowser={showSpeechOutputBrowser}
speechUrl={speechUrl}
/>
</div>
)}
Expand Down
37 changes: 19 additions & 18 deletions app/frontend/src/pages/chat/Chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@ import styles from "./Chat.module.css";
import {
chatApi,
configApi,
getSpeechApi,
RetrievalMode,
ChatAppResponse,
ChatAppResponseOrError,
ChatAppRequest,
ResponseMessage,
VectorFieldOptions,
GPT4VInput
GPT4VInput,
SpeechConfig
} from "../../api";
import { Answer, AnswerError, AnswerLoading } from "../../components/Answer";
import { QuestionInput } from "../../components/QuestionInput";
Expand Down Expand Up @@ -67,8 +67,8 @@ const Chat = () => {

const [selectedAnswer, setSelectedAnswer] = useState<number>(0);
const [answers, setAnswers] = useState<[user: string, response: ChatAppResponse][]>([]);
const [streamedAnswers, setStreamedAnswers] = useState<[user: string, response: ChatAppResponse][]>([]);
const [speechUrls, setSpeechUrls] = useState<(string | null)[]>([]);
const [streamedAnswers, setStreamedAnswers] = useState<[user: string, response: ChatAppResponse][]>([]);

const [showGPT4VOptions, setShowGPT4VOptions] = useState<boolean>(false);
const [showSemanticRankerOption, setShowSemanticRankerOption] = useState<boolean>(false);
Expand All @@ -77,6 +77,16 @@ const Chat = () => {
const [showSpeechInput, setShowSpeechInput] = useState<boolean>(false);
const [showSpeechOutputBrowser, setShowSpeechOutputBrowser] = useState<boolean>(false);
const [showSpeechOutputAzure, setShowSpeechOutputAzure] = useState<boolean>(false);
const audio = useRef(new Audio()).current;
const [isPlaying, setIsPlaying] = useState(false);

const speechConfig: SpeechConfig = {
speechUrls,
setSpeechUrls,
audio,
isPlaying,
setIsPlaying
};

const getConfig = async () => {
configApi().then(config => {
Expand Down Expand Up @@ -199,6 +209,7 @@ const Chat = () => {
}
setAnswers([...answers, [question, parsedResponse as ChatAppResponse]]);
}
setSpeechUrls([...speechUrls, null]);
} catch (e) {
setError(e);
} finally {
Expand All @@ -212,6 +223,7 @@ const Chat = () => {
setActiveCitation(undefined);
setActiveAnalysisPanelTab(undefined);
setAnswers([]);
setSpeechUrls([]);
setStreamedAnswers([]);
setIsLoading(false);
setIsStreaming(false);
Expand All @@ -223,19 +235,6 @@ const Chat = () => {
getConfig();
}, []);

useEffect(() => {
if (answers && showSpeechOutputAzure) {
// For each answer that is missing a speech URL, fetch the speech URL
for (let i = 0; i < answers.length; i++) {
if (!speechUrls[i]) {
getSpeechApi(answers[i][1].message.content).then(speechUrl => {
setSpeechUrls([...speechUrls.slice(0, i), speechUrl, ...speechUrls.slice(i + 1)]);
});
}
}
}
}, [answers]);

const onPromptTemplateChange = (_ev?: React.FormEvent<HTMLInputElement | HTMLTextAreaElement>, newValue?: string) => {
setPromptTemplate(newValue || "");
};
Expand Down Expand Up @@ -368,6 +367,8 @@ const Chat = () => {
isStreaming={true}
key={index}
answer={streamedAnswer[1]}
index={index}
speechConfig={speechConfig}
isSelected={false}
onCitationClicked={c => onShowCitation(c, index)}
onThoughtProcessClicked={() => onToggleTab(AnalysisPanelTabs.ThoughtProcessTab, index)}
Expand All @@ -376,7 +377,6 @@ const Chat = () => {
showFollowupQuestions={useSuggestFollowupQuestions && answers.length - 1 === index}
showSpeechOutputAzure={showSpeechOutputAzure}
showSpeechOutputBrowser={showSpeechOutputBrowser}
speechUrl={speechUrls[index]}
/>
</div>
</div>
Expand All @@ -390,6 +390,8 @@ const Chat = () => {
isStreaming={false}
key={index}
answer={answer[1]}
index={index}
speechConfig={speechConfig}
isSelected={selectedAnswer === index && activeAnalysisPanelTab !== undefined}
onCitationClicked={c => onShowCitation(c, index)}
onThoughtProcessClicked={() => onToggleTab(AnalysisPanelTabs.ThoughtProcessTab, index)}
Expand All @@ -398,7 +400,6 @@ const Chat = () => {
showFollowupQuestions={useSuggestFollowupQuestions && answers.length - 1 === index}
showSpeechOutputAzure={showSpeechOutputAzure}
showSpeechOutputBrowser={showSpeechOutputBrowser}
speechUrl={speechUrls[index]}
/>
</div>
</div>
Expand Down