Skip to content

Commit

Permalink
WIP(4): Implement language switching for File Upload Feature
Browse files Browse the repository at this point in the history
  • Loading branch information
bnodir committed Jul 15, 2024
1 parent ed8c1e9 commit f764afd
Show file tree
Hide file tree
Showing 14 changed files with 196 additions and 109 deletions.
2 changes: 1 addition & 1 deletion app/frontend/src/components/Answer/Answer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ export const Answer = ({
{!!parsedAnswer.citations.length && (
<Stack.Item>
<Stack horizontal wrap tokens={{ childrenGap: 5 }}>
<span className={styles.citationLearnMore}>Citations:</span>
<span className={styles.citationLearnMore}>{t("headerTexts.citation")}:</span>
{parsedAnswer.citations.map((x, i) => {
const path = getCitationFilePath(x);
return (
Expand Down
20 changes: 13 additions & 7 deletions app/frontend/src/components/Answer/SpeechOutputBrowser.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useState } from "react";
import { IconButton } from "@fluentui/react";
import { useTranslation } from "react-i18next";
import { supportedLngs } from "../../i18n/config";

interface Props {
answer: string;
Expand All @@ -16,20 +17,25 @@ try {
console.error("SpeechSynthesis is not supported");
}

const getUtterance = function (text: string) {
const getUtterance = function (text: string, lngCode: string) {
if (synth) {
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = "en-US";
utterance.lang = lngCode;
utterance.volume = 1;
utterance.rate = 1;
utterance.pitch = 1;
utterance.voice = synth.getVoices().filter((voice: SpeechSynthesisVoice) => voice.lang === "en-US")[0];
utterance.voice = synth.getVoices().filter((voice: SpeechSynthesisVoice) => voice.lang === lngCode)[0];
return utterance;
}
};

export const SpeechOutputBrowser = ({ answer }: Props) => {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const currentLng = i18n.language;
let lngCode = supportedLngs[currentLng]?.locale;
if (!lngCode) {
lngCode = "en-US";
}
const [isPlaying, setIsPlaying] = useState<boolean>(false);

const startOrStopSpeech = (answer: string) => {
Expand All @@ -39,7 +45,7 @@ export const SpeechOutputBrowser = ({ answer }: Props) => {
setIsPlaying(false);
return;
}
const utterance: SpeechSynthesisUtterance | undefined = getUtterance(answer);
const utterance: SpeechSynthesisUtterance | undefined = getUtterance(answer, lngCode);

if (!utterance) {
return;
Expand All @@ -64,8 +70,8 @@ export const SpeechOutputBrowser = ({ answer }: Props) => {
<IconButton
style={{ color: color }}
iconProps={{ iconName: "Volume3" }}
title="Speak answer"
ariaLabel={t("tooltips.speakAnswer")}
title={t("tooltips.speakAnswer")}
ariaLabel="Speak answer"
onClick={() => startOrStopSpeech(answer)}
disabled={!synth}
/>
Expand Down
2 changes: 1 addition & 1 deletion app/frontend/src/components/LoginButton/LoginButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export const LoginButton = () => {
};
return (
<DefaultButton
text={loggedIn ? `t("logout")\n${username}` : t("login")}
text={loggedIn ? `${t("logout")}\n${username}` : `${t("login")}`}
className={styles.loginButton}
onClick={loggedIn ? handleLogoutPopup : handleLoginPopup}
></DefaultButton>
Expand Down
4 changes: 2 additions & 2 deletions app/frontend/src/components/MarkdownViewer/MarkdownViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,8 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({ src }) => {
className={styles.downloadButton}
style={{ color: "black" }}
iconProps={{ iconName: "Save" }}
title="Save"
ariaLabel={t("tooltips.save")}
title={t("tooltips.save")}
ariaLabel="Save"
href={src}
download
/>
Expand Down
56 changes: 35 additions & 21 deletions app/frontend/src/components/QuestionInput/SpeechInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,35 +3,49 @@ import { Button, Tooltip } from "@fluentui/react-components";
import { Mic28Filled } from "@fluentui/react-icons";
import { useTranslation } from "react-i18next";
import styles from "./QuestionInput.module.css";
import { supportedLngs } from "../../i18n/config";

interface Props {
updateQuestion: (question: string) => void;
}

const SpeechRecognition = (window as any).speechRecognition || (window as any).webkitSpeechRecognition;
let speechRecognition: {
continuous: boolean;
lang: string;
interimResults: boolean;
maxAlternatives: number;
start: () => void;
onresult: (event: { results: { transcript: SetStateAction<string> }[][] }) => void;
onend: () => void;
onerror: (event: { error: string }) => void;
stop: () => void;
} | null = null;
try {
speechRecognition = new SpeechRecognition();
if (speechRecognition != null) {
speechRecognition.lang = "en-US";
speechRecognition.interimResults = true;
const useCustomSpeechRecognition = () => {
const { i18n } = useTranslation();
const currentLng = i18n.language;
let lngCode = supportedLngs[currentLng]?.locale;
if (!lngCode) {
lngCode = "en-US";
}
} catch (err) {
console.error("SpeechRecognition not supported");
speechRecognition = null;
}

const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
let speechRecognition: {
continuous: boolean;
lang: string;
interimResults: boolean;
maxAlternatives: number;
start: () => void;
onresult: (event: { results: { transcript: SetStateAction<string> }[][] }) => void;
onend: () => void;
onerror: (event: { error: string }) => void;
stop: () => void;
} | null = null;

try {
speechRecognition = new SpeechRecognition();
if (speechRecognition != null) {
speechRecognition.lang = lngCode;
speechRecognition.interimResults = true;
}
} catch (err) {
console.error("SpeechRecognition not supported");
speechRecognition = null;
}

return speechRecognition;
};

export const SpeechInput = ({ updateQuestion }: Props) => {
let speechRecognition = useCustomSpeechRecognition();
const { t } = useTranslation();
const [isRecording, setIsRecording] = useState<boolean>(false);
const startRecording = () => {
Expand Down
24 changes: 13 additions & 11 deletions app/frontend/src/components/UploadFile/UploadFile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Callout, Label, Text } from "@fluentui/react";
import { Button } from "@fluentui/react-components";
import { Add24Regular, Delete24Regular } from "@fluentui/react-icons";
import { useMsal } from "@azure/msal-react";
import { useTranslation } from "react-i18next";

import { SimpleAPIResponse, uploadFileApi, deleteUploadedFileApi, listUploadedFilesApi } from "../../api";
import { useLogin, getToken } from "../../authConfig";
Expand All @@ -22,6 +23,7 @@ export const UploadFile: React.FC<Props> = ({ className, disabled }: Props) => {
const [uploadedFile, setUploadedFile] = useState<SimpleAPIResponse>();
const [uploadedFileError, setUploadedFileError] = useState<string>();
const [uploadedFiles, setUploadedFiles] = useState<string[]>([]);
const { t } = useTranslation();

if (!useLogin) {
throw new Error("The UploadFile component requires useLogin to be true");
Expand Down Expand Up @@ -96,15 +98,15 @@ export const UploadFile: React.FC<Props> = ({ className, disabled }: Props) => {
} catch (error) {
console.error(error);
setIsUploading(false);
setUploadedFileError(`Error uploading file - please try again or contact admin.`);
setUploadedFileError(t("upload.uploadedFileError"));
}
};

return (
<div className={`${styles.container} ${className ?? ""}`}>
<div>
<Button id="calloutButton" icon={<Add24Regular />} disabled={disabled} onClick={handleButtonClick}>
Manage file uploads
{t("upload.manageFileUploads")}
</Button>

{isCalloutVisible && (
Expand All @@ -118,7 +120,7 @@ export const UploadFile: React.FC<Props> = ({ className, disabled }: Props) => {
>
<form encType="multipart/form-data">
<div>
<Label>Upload file:</Label>
<Label>{t("upload.fileLabel")}</Label>
<input
accept=".txt, .md, .json, .png, .jpg, .jpeg, .bmp, .heic, .tiff, .pdf, .docx, .xlsx, .pptx, .html"
className={styles.chooseFiles}
Expand All @@ -129,15 +131,15 @@ export const UploadFile: React.FC<Props> = ({ className, disabled }: Props) => {
</form>

{/* Show a loading message while files are being uploaded */}
{isUploading && <Text>{"Uploading files..."}</Text>}
{isUploading && <Text>{t("upload.uploadingFiles")}</Text>}
{!isUploading && uploadedFileError && <Text>{uploadedFileError}</Text>}
{!isUploading && uploadedFile && <Text>{uploadedFile.message}</Text>}

{/* Display the list of already uploaded */}
<h3>Previously uploaded files:</h3>
<h3>{t("upload.uploadedFilesLabel")}</h3>

{isLoading && <Text>Loading...</Text>}
{!isLoading && uploadedFiles.length === 0 && <Text>No files uploaded yet</Text>}
{isLoading && <Text>{t("upload.loading")}</Text>}
{!isLoading && uploadedFiles.length === 0 && <Text>{t("upload.noFilesUploaded")}</Text>}
{uploadedFiles.map((filename, index) => {
return (
<div key={index} className={styles.list}>
Expand All @@ -148,10 +150,10 @@ export const UploadFile: React.FC<Props> = ({ className, disabled }: Props) => {
onClick={() => handleRemoveFile(filename)}
disabled={deletionStatus[filename] === "pending" || deletionStatus[filename] === "success"}
>
{!deletionStatus[filename] && "Delete file"}
{deletionStatus[filename] == "pending" && "Deleting file..."}
{deletionStatus[filename] == "error" && "Error deleting."}
{deletionStatus[filename] == "success" && "File deleted"}
{!deletionStatus[filename] && t("upload.deleteFile")}
{deletionStatus[filename] == "pending" && t("upload.deletingFile")}
{deletionStatus[filename] == "error" && t("upload.errorDeleting")}
{deletionStatus[filename] == "success" && t("upload.fileDeleted")}
</Button>
</div>
);
Expand Down
10 changes: 5 additions & 5 deletions app/frontend/src/i18n/LocaleSwitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,18 @@ export const LocaleSwitcher = ({ onLanguageChange }: Props) => {

const handleLanguageChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
onLanguageChange(event.target.value);
}
};

return (
<div className={styles.localeSwitcher}>
<LocalLanguage24Regular className={styles.localeSwitcherIcon} />
<select value={i18n.language} onChange={handleLanguageChange} className={styles.localeSwitcherText}>
{Object.entries(supportedLngs).map(([code, name]) => (
{Object.entries(supportedLngs).map(([code, details]) => (
<option value={code} key={code}>
{name}
{details.name}
</option>
))}
</select>
</select>
</div>
);
}
};
70 changes: 41 additions & 29 deletions app/frontend/src/i18n/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,41 +3,53 @@ import LanguageDetector from "i18next-browser-languagedetector";
import HttpApi from "i18next-http-backend";
import { initReactI18next } from "react-i18next";
import formatters from "./formatters";
import enTranslation from '../locales/en/translation.json';
import esTranslation from '../locales/es/translation.json';
import jaTranslation from '../locales/ja/translation.json';
import frTranslation from '../locales/fr/translation.json';
import enTranslation from "../locales/en/translation.json";
import esTranslation from "../locales/es/translation.json";
import jaTranslation from "../locales/ja/translation.json";
import frTranslation from "../locales/fr/translation.json";

export const supportedLngs = {
en: "English",
es: "Español",
fr: "Français",
ja: "日本語",
export const supportedLngs: { [key: string]: { name: string; locale: string } } = {
en: {
name: "English",
locale: "en-US"
},
es: {
name: "Español",
locale: "es-ES"
},
fr: {
name: "Français",
locale: "fr-FR"
},
ja: {
name: "日本語",
locale: "ja-JP"
}
};

i18next
.use(HttpApi)
.use(LanguageDetector)
.use(initReactI18next)
// init i18next
// for all options read: https://www.i18next.com/overview/configuration-options
.init({
resources: {
en: { translation: enTranslation },
es: { translation: esTranslation },
fr: { translation: frTranslation },
ja: { translation: jaTranslation },
},
fallbackLng: "en",
supportedLngs: Object.keys(supportedLngs),
debug: import.meta.env.DEV,
interpolation: {
escapeValue: false, // not needed for react as it escapes by default
},
});
.use(HttpApi)
.use(LanguageDetector)
.use(initReactI18next)
// init i18next
// for all options read: https://www.i18next.com/overview/configuration-options
.init({
resources: {
en: { translation: enTranslation },
es: { translation: esTranslation },
fr: { translation: frTranslation },
ja: { translation: jaTranslation }
},
fallbackLng: "en",
supportedLngs: Object.keys(supportedLngs),
debug: import.meta.env.DEV,
interpolation: {
escapeValue: false // not needed for react as it escapes by default
}
});

Object.entries(formatters).forEach(([key, resolver]) => {
i18next.services.formatter?.add(key, resolver);
i18next.services.formatter?.add(key, resolver);
});

export default i18next;
28 changes: 21 additions & 7 deletions app/frontend/src/locales/en/translation.json
Original file line number Diff line number Diff line change
@@ -1,12 +1,24 @@
{
"language_name": "English",
"pageTitle": "GPT + Enterprise data | Sample",
"headerTitle": "GPT + Enterprise data | Sample",
"pageTitle": "Azure OpenAI + AI Search",
"headerTitle": "Azure OpenAI + AI Search",
"chat": "Chat",
"qa": "Ask a question",
"login": "Login",
"logout": "Logout",
"clearChat": "Clear chat",
"upload": {
"fileLabel": "Upload file:",
"uploadedFilesLabel": "Previously uploaded files:",
"noFilesUploaded": "No files uploaded yet",
"loading": "Loading...",
"manageFileUploads": "Manage file uploads",
"uploadingFiles": "Uploading files...",
"uploadedFileError": "Error uploading file - please try again or contact admin.",
"deleteFile": "Delete file",
"deletingFile": "Deleting file...",
"errorDeleting": "Error deleting.",
"fileDeleted": "File deleted"
},
"developerSettings": "Developer settings",

"chatEmptyStateTitle": "Chat with your data",
Expand Down Expand Up @@ -96,8 +108,10 @@
"Sets a minimum score for search results coming back from the semantic reranker. The score always ranges between 0-4. The higher the score, the more semantically relevant the result is to the question.",
"retrieveNumber":
"Sets the number of search results to retrieve from Azure AI search. More results may increase the likelihood of finding the correct answer, but may lead to the model getting 'lost in the middle'.",
"excludeCategory": "Specifies a category to exclude from the search results. There are no categories used in the default data set.",
"useSemanticReranker": "Enables the Azure AI Search semantic ranker, a model that re-ranks search results based on semantic similarity to the user's query.",
"excludeCategory":
"Specifies a category to exclude from the search results. There are no categories used in the default data set.",
"useSemanticReranker":
"Enables the Azure AI Search semantic ranker, a model that re-ranks search results based on semantic similarity to the user's query.",
"useSemanticCaptions":
"Sends semantic captions to the LLM instead of the full search result. A semantic caption is extracted from a search result during the process of semantic ranking.",
"suggestFollowupQuestions": "Asks the LLM to suggest follow-up questions based on the user's query.",
Expand All @@ -111,5 +125,5 @@
"streamChat": "Continuously streams the response to the chat UI as it is generated.",
"useOidSecurityFilter": "Filter search results based on the authenticated user's OID.",
"useGroupsSecurityFilter": "Filter search results based on the authenticated user's groups."
}
}
}
}
Loading

0 comments on commit f764afd

Please sign in to comment.