feat: Add i18n support for Chinese language (#142)

* feat: Add i18n support for Chinese language

* fix: locale not working in Docker environment
This commit is contained in:
Jungley
2023-03-25 23:42:48 +08:00
committed by GitHub
parent 932853f1ba
commit 92eab6c634
26 changed files with 320 additions and 40 deletions
+9 -7
View File
@@ -1,5 +1,6 @@
import { Conversation, KeyValuePair, Message, OpenAIModel } from "@/types";
import { FC, MutableRefObject, useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "next-i18next";
import { ChatInput } from "./ChatInput";
import { ChatLoader } from "./ChatLoader";
import { ChatMessage } from "./ChatMessage";
@@ -23,6 +24,7 @@ interface Props {
}
export const Chat: FC<Props> = ({ conversation, models, apiKey, serverSideApiKeyIsSet, messageIsStreaming, modelError, messageError, loading, lightMode, onSend, onUpdateConversation, onEditMessage, stopConversationRef }) => {
const { t } = useTranslation('chat');
const [currentMessage, setCurrentMessage] = useState<Message>();
const [autoScrollEnabled, setAutoScrollEnabled] = useState(true);
@@ -71,14 +73,14 @@ export const Chat: FC<Props> = ({ conversation, models, apiKey, serverSideApiKey
<div className="relative flex-1 overflow-none dark:bg-[#343541] bg-white">
{!(apiKey || serverSideApiKeyIsSet) ? (
<div className="flex flex-col justify-center mx-auto h-full w-[300px] sm:w-[500px] space-y-6">
<div className="text-2xl font-semibold text-center text-gray-800 dark:text-gray-100">OpenAI API Key Required</div>
<div className="text-center text-gray-500 dark:text-gray-400">Please set your OpenAI API key in the bottom left of the sidebar.</div>
<div className="text-2xl font-semibold text-center text-gray-800 dark:text-gray-100">{t('OpenAI API Key Required')}</div>
<div className="text-center text-gray-500 dark:text-gray-400">{t('Please set your OpenAI API key in the bottom left of the sidebar.')}</div>
</div>
) : modelError ? (
<div className="flex flex-col justify-center mx-auto h-full w-[300px] sm:w-[500px] space-y-6">
<div className="text-center text-red-500">Error fetching models.</div>
<div className="text-center text-red-500">Make sure your OpenAI API key is set in the bottom left of the sidebar.</div>
<div className="text-center text-red-500">If you completed this step, OpenAI may be experiencing issues.</div>
<div className="text-center text-red-500">{t('Error fetching models.')}</div>
<div className="text-center text-red-500">{t('Make sure your OpenAI API key is set in the bottom left of the sidebar.')}</div>
<div className="text-center text-red-500">{t('If you completed this step, OpenAI may be experiencing issues.')}</div>
</div>
) : (
<>
@@ -89,7 +91,7 @@ export const Chat: FC<Props> = ({ conversation, models, apiKey, serverSideApiKey
{conversation.messages.length === 0 ? (
<>
<div className="flex flex-col mx-auto pt-12 space-y-10 w-[350px] sm:w-[600px]">
<div className="text-4xl font-semibold text-center text-gray-800 dark:text-gray-100">{models.length === 0 ? "Loading..." : "Chatbot UI"}</div>
<div className="text-4xl font-semibold text-center text-gray-800 dark:text-gray-100">{models.length === 0 ? t("Loading...") : "Chatbot UI"}</div>
{models.length > 0 && (
<div className="flex flex-col h-full space-y-4 border p-4 rounded border-neutral-500">
@@ -109,7 +111,7 @@ export const Chat: FC<Props> = ({ conversation, models, apiKey, serverSideApiKey
</>
) : (
<>
<div className="flex justify-center py-2 text-neutral-500 bg-neutral-100 dark:bg-[#444654] dark:text-neutral-200 text-sm border border-b-neutral-300 dark:border-none">Model: {conversation.model.name}</div>
<div className="flex justify-center py-2 text-neutral-500 bg-neutral-100 dark:bg-[#444654] dark:text-neutral-200 text-sm border border-b-neutral-300 dark:border-none">{t('Model')}: {conversation.model.name}</div>
{conversation.messages.map((message, index) => (
<ChatMessage
+7 -5
View File
@@ -1,6 +1,7 @@
import { Message, OpenAIModel, OpenAIModelID } from "@/types";
import { IconPlayerStop, IconRepeat, IconSend } from "@tabler/icons-react";
import { FC, KeyboardEvent, MutableRefObject, useEffect, useState } from "react";
import { useTranslation } from "next-i18next";
interface Props {
messageIsStreaming: boolean;
@@ -13,6 +14,7 @@ interface Props {
}
export const ChatInput: FC<Props> = ({ messageIsStreaming, model, messages, onSend, onRegenerate, stopConversationRef, textareaRef }) => {
const { t } = useTranslation('chat');
const [content, setContent] = useState<string>();
const [isTyping, setIsTyping] = useState<boolean>(false);
@@ -21,7 +23,7 @@ export const ChatInput: FC<Props> = ({ messageIsStreaming, model, messages, onSe
const maxLength = model.id === OpenAIModelID.GPT_3_5 ? 12000 : 24000;
if (value.length > maxLength) {
alert(`Message limit is ${maxLength} characters. You have entered ${value.length} characters.`);
alert(t(`Message limit is {{maxLength}} characters. You have entered {{valueLength}} characters.`, { maxLength, valueLength: value.length }));
return;
}
@@ -34,7 +36,7 @@ export const ChatInput: FC<Props> = ({ messageIsStreaming, model, messages, onSe
}
if (!content) {
alert("Please enter a message");
alert(t("Please enter a message"));
return;
}
@@ -88,7 +90,7 @@ export const ChatInput: FC<Props> = ({ messageIsStreaming, model, messages, onSe
size={16}
className="inline-block mb-[2px]"
/>{" "}
Stop Generating
{t('Stop Generating')}
</button>
)}
@@ -115,7 +117,7 @@ export const ChatInput: FC<Props> = ({ messageIsStreaming, model, messages, onSe
maxHeight: "400px",
overflow: `${textareaRef.current && textareaRef.current.scrollHeight > 400 ? "auto" : "hidden"}`
}}
placeholder="Type a message..."
placeholder={t("Type a message...") || ''}
value={content}
rows={1}
onCompositionStart={() => setIsTyping(true)}
@@ -144,7 +146,7 @@ export const ChatInput: FC<Props> = ({ messageIsStreaming, model, messages, onSe
>
ChatBot UI
</a>
. Chatbot UI is an advanced chatbot kit for OpenAI&apos;s chat models aiming to mimic ChatGPT&apos;s interface and functionality.
. {t("Chatbot UI is an advanced chatbot kit for OpenAI's chat models aiming to mimic ChatGPT's interface and functionality.")}
</div>
</div>
);
+3 -1
View File
@@ -1,6 +1,7 @@
import { Message } from "@/types";
import { IconEdit } from "@tabler/icons-react";
import { FC, useEffect, useRef, useState } from "react";
import { useTranslation } from "next-i18next";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { CodeBlock } from "../Markdown/CodeBlock";
@@ -13,6 +14,7 @@ interface Props {
}
export const ChatMessage: FC<Props> = ({ message, messageIndex, lightMode, onEditMessage }) => {
const { t } = useTranslation('chat');
const [isEditing, setIsEditing] = useState<boolean>(false);
const [isHovering, setIsHovering] = useState<boolean>(false);
const [messageContent, setMessageContent] = useState(message.content);
@@ -60,7 +62,7 @@ export const ChatMessage: FC<Props> = ({ message, messageIndex, lightMode, onEdi
onMouseLeave={() => setIsHovering(false)}
>
<div className="text-base gap-4 md:gap-6 md:max-w-2xl lg:max-w-2xl xl:max-w-3xl p-4 md:py-6 flex lg:px-0 m-auto relative">
<div className="font-bold min-w-[40px]">{message.role === "assistant" ? "AI:" : "You:"}</div>
<div className="font-bold min-w-[40px]">{message.role === "assistant" ? t("AI") : t("You")}:</div>
<div className="prose dark:prose-invert mt-[-2px] w-full">
{message.role === "user" ? (
+4 -2
View File
@@ -1,5 +1,6 @@
import { OpenAIModel } from "@/types";
import { FC } from "react";
import { useTranslation } from "next-i18next";
interface Props {
model: OpenAIModel;
@@ -8,12 +9,13 @@ interface Props {
}
export const ModelSelect: FC<Props> = ({ model, models, onModelChange }) => {
const {t} = useTranslation('chat')
return (
<div className="flex flex-col">
<label className="text-left mb-2 dark:text-neutral-400 text-neutral-700">Model</label>
<label className="text-left mb-2 dark:text-neutral-400 text-neutral-700">{t('Model')}</label>
<select
className="w-full p-3 dark:text-white dark:bg-[#343541] border border-neutral-500 rounded-lg appearance-none focus:shadow-outline text-neutral-900 cursor-pointer"
placeholder="Select a model"
placeholder={t("Select a model") || ''}
value={model.id}
onChange={(e) => {
onModelChange(models.find((model) => model.id === e.target.value) as OpenAIModel);
+4 -2
View File
@@ -1,20 +1,22 @@
import { IconRefresh } from "@tabler/icons-react";
import { FC } from "react";
import { useTranslation } from "next-i18next";
interface Props {
onRegenerate: () => void;
}
export const Regenerate: FC<Props> = ({ onRegenerate }) => {
const { t } = useTranslation('chat')
return (
<div className="fixed sm:absolute bottom-4 sm:bottom-8 w-full sm:w-1/2 px-2 left-0 sm:left-[280px] lg:left-[200px] right-0 ml-auto mr-auto">
<div className="text-center mb-4 text-red-500">Sorry, there was an error.</div>
<div className="text-center mb-4 text-red-500">{t('Sorry, there was an error.')}</div>
<button
className="flex items-center justify-center w-full h-12 bg-neutral-100 dark:bg-[#444654] text-neutral-500 dark:text-neutral-200 text-sm font-semibold rounded-lg border border-b-neutral-300 dark:border-none"
onClick={onRegenerate}
>
<IconRefresh className="mr-2" />
<div>Regenerate response</div>
<div>{t('Regenerate response')}</div>
</button>
</div>
);
+6 -4
View File
@@ -1,6 +1,7 @@
import { Conversation } from "@/types";
import { DEFAULT_SYSTEM_PROMPT } from "@/utils/app/const";
import { FC, useEffect, useRef, useState } from "react";
import { useTranslation } from "next-i18next";
interface Props {
conversation: Conversation;
@@ -8,6 +9,7 @@ interface Props {
}
export const SystemPrompt: FC<Props> = ({ conversation, onChangePrompt }) => {
const { t } = useTranslation('chat')
const [value, setValue] = useState<string>("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -17,7 +19,7 @@ export const SystemPrompt: FC<Props> = ({ conversation, onChangePrompt }) => {
const maxLength = 4000;
if (value.length > maxLength) {
alert(`Prompt limit is ${maxLength} characters`);
alert(t(`Prompt limit is {{maxLength}} characters`, { maxLength }));
return;
}
@@ -45,7 +47,7 @@ export const SystemPrompt: FC<Props> = ({ conversation, onChangePrompt }) => {
return (
<div className="flex flex-col">
<label className="text-left dark:text-neutral-400 text-neutral-700 mb-2">System Prompt</label>
<label className="text-left dark:text-neutral-400 text-neutral-700 mb-2">{t('System Prompt')}</label>
<textarea
ref={textareaRef}
className="w-full rounded-lg px-4 py-2 focus:outline-none dark:bg-[#40414F] dark:border-opacity-50 dark:border-neutral-800 dark:text-neutral-100 border border-neutral-500 shadow text-neutral-900"
@@ -55,8 +57,8 @@ export const SystemPrompt: FC<Props> = ({ conversation, onChangePrompt }) => {
maxHeight: "300px",
overflow: `${textareaRef.current && textareaRef.current.scrollHeight > 400 ? "auto" : "hidden"}`
}}
placeholder="Enter a prompt"
value={value}
placeholder={t("Enter a prompt") || ''}
value={t(value) || ''}
rows={1}
onChange={handleChange}
/>
+4 -2
View File
@@ -1,6 +1,7 @@
import { generateRandomString, programmingLanguages } from "@/utils/app/codeblock";
import { IconCheck, IconClipboard, IconDownload } from "@tabler/icons-react";
import { FC, useState } from "react";
import { useTranslation } from "next-i18next";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneDark, oneLight } from "react-syntax-highlighter/dist/cjs/styles/prism";
@@ -11,6 +12,7 @@ interface Props {
}
export const CodeBlock: FC<Props> = ({ language, value, lightMode }) => {
const { t } = useTranslation('markdown');
const [isCopied, setIsCopied] = useState<Boolean>(false);
const copyToClipboard = () => {
@@ -29,7 +31,7 @@ export const CodeBlock: FC<Props> = ({ language, value, lightMode }) => {
const downloadAsFile = () => {
const fileExtension = programmingLanguages[language] || ".file";
const suggestedFileName = `file-${generateRandomString(3, true)}${fileExtension}`;
const fileName = window.prompt("Enter file name", suggestedFileName);
const fileName = window.prompt(t("Enter file name") || '', suggestedFileName);
if (!fileName) {
// user pressed cancel on prompt
@@ -57,7 +59,7 @@ export const CodeBlock: FC<Props> = ({ language, value, lightMode }) => {
onClick={copyToClipboard}
>
{isCopied ? <IconCheck size={18} className="mr-1.5"/> : <IconClipboard size={18} className="mr-1.5"/>}
{isCopied ? "Copied!" : "Copy code"}
{isCopied ? t("Copied!") : t("Copy code")}
</button>
<button
className="text-white bg-none py-0.5 pl-2 rounded focus:outline-none text-xs flex items-center"
+5 -2
View File
@@ -1,5 +1,6 @@
import { IconCheck, IconTrash, IconX } from "@tabler/icons-react";
import { FC, useState } from "react";
import { useTranslation } from "next-i18next";
import { SidebarButton } from "./SidebarButton";
interface Props {
@@ -9,6 +10,8 @@ interface Props {
export const ClearConversations: FC<Props> = ({ onClearConversations }) => {
const [isConfirming, setIsConfirming] = useState<boolean>(false);
const { t } = useTranslation('sidebar')
const handleClearConversations = () => {
onClearConversations();
setIsConfirming(false);
@@ -18,7 +21,7 @@ export const ClearConversations: FC<Props> = ({ onClearConversations }) => {
<div className="flex hover:bg-[#343541] py-3 px-3 rounded-md cursor-pointer w-full items-center">
<IconTrash size={16} />
<div className="ml-3 flex-1 text-left text-white">Are you sure?</div>
<div className="ml-3 flex-1 text-left text-white">{t('Are you sure?')}</div>
<div className="flex w-[40px]">
<IconCheck
@@ -42,7 +45,7 @@ export const ClearConversations: FC<Props> = ({ onClearConversations }) => {
</div>
) : (
<SidebarButton
text="Clear conversations"
text={t("Clear conversations")}
icon={<IconTrash size={16} />}
onClick={() => setIsConfirming(true)}
/>
+3 -1
View File
@@ -3,12 +3,14 @@ import { cleanConversationHistory } from "@/utils/app/clean";
import { IconFileImport } from "@tabler/icons-react";
import { FC } from "react";
import { SidebarButton } from "./SidebarButton";
import { useTranslation } from "next-i18next";
interface Props {
onImport: (data: { conversations: Conversation[]; folders: ChatFolder[] }) => void;
}
export const Import: FC<Props> = ({ onImport }) => {
const { t} = useTranslation('sidebar')
return (
<>
<input
@@ -36,7 +38,7 @@ export const Import: FC<Props> = ({ onImport }) => {
/>
<SidebarButton
text="Import conversations"
text={t("Import conversations")}
icon={<IconFileImport size={16} />}
onClick={() => {
const importFile = document.querySelector("#import-file") as HTMLInputElement;
+3 -1
View File
@@ -1,5 +1,6 @@
import { IconCheck, IconKey, IconX } from "@tabler/icons-react";
import { FC, KeyboardEvent, useState } from "react";
import { useTranslation } from "next-i18next";
import { SidebarButton } from "./SidebarButton";
interface Props {
@@ -8,6 +9,7 @@ interface Props {
}
export const Key: FC<Props> = ({ apiKey, onApiKeyChange }) => {
const { t } = useTranslation('sidebar');
const [isChanging, setIsChanging] = useState(false);
const [newKey, setNewKey] = useState(apiKey);
@@ -58,7 +60,7 @@ export const Key: FC<Props> = ({ apiKey, onApiKeyChange }) => {
</div>
) : (
<SidebarButton
text="OpenAI API Key"
text={t("OpenAI API Key")}
icon={<IconKey size={16} />}
onClick={() => setIsChanging(true)}
/>
+4 -1
View File
@@ -1,5 +1,6 @@
import { IconX } from "@tabler/icons-react";
import { FC } from "react";
import { useTranslation } from "next-i18next";
interface Props {
searchTerm: string;
@@ -7,6 +8,8 @@ interface Props {
}
export const Search: FC<Props> = ({ searchTerm, onSearch }) => {
const { t } = useTranslation('sidebar');
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onSearch(e.target.value);
};
@@ -20,7 +23,7 @@ export const Search: FC<Props> = ({ searchTerm, onSearch }) => {
<input
className="flex-1 w-full pr-10 bg-[#202123] border border-neutral-600 text-sm rounded-md px-4 py-3 text-white"
type="text"
placeholder="Search conversations..."
placeholder={t('Search conversations...') || ''}
value={searchTerm}
onChange={handleSearchChange}
/>
+6 -3
View File
@@ -1,6 +1,7 @@
import { ChatFolder, Conversation, KeyValuePair } from "@/types";
import { IconArrowBarLeft, IconFolderPlus, IconPlus } from "@tabler/icons-react";
import { FC, useEffect, useState } from "react";
import { useTranslation } from "next-i18next";
import { Conversations } from "./Conversations";
import { Folders } from "./Folders";
import { Search } from "./Search";
@@ -29,6 +30,8 @@ interface Props {
}
export const Sidebar: FC<Props> = ({ loading, conversations, lightMode, selectedConversation, apiKey, folders, onCreateFolder, onDeleteFolder, onUpdateFolder, onNewConversation, onToggleLightMode, onSelectConversation, onDeleteConversation, onToggleSidebar, onUpdateConversation, onApiKeyChange, onClearConversations, onExportConversations, onImportConversations }) => {
const { t } = useTranslation('sidebar');
const [searchTerm, setSearchTerm] = useState<string>("");
const [filteredConversations, setFilteredConversations] = useState<Conversation[]>(conversations);
@@ -87,12 +90,12 @@ export const Sidebar: FC<Props> = ({ loading, conversations, lightMode, selected
}}
>
<IconPlus size={16} />
New chat
{t('New chat')}
</button>
<button
className="ml-2 flex gap-3 p-3 items-center rounded-md hover:bg-gray-500/10 transition-colors duration-200 text-white cursor-pointer text-sm flex-shrink-0 border border-white/20"
onClick={() => onCreateFolder("New folder")}
onClick={() => onCreateFolder(t("New folder"))}
>
<IconFolderPlus size={16} />
</button>
@@ -148,7 +151,7 @@ export const Sidebar: FC<Props> = ({ loading, conversations, lightMode, selected
</div>
) : (
<div className="mt-4 text-white text-center">
<div>No conversations.</div>
<div>{t('No conversations.')}</div>
</div>
)}
</div>
+4 -2
View File
@@ -1,6 +1,7 @@
import { ChatFolder, Conversation } from "@/types";
import { IconFileExport, IconMoon, IconSun } from "@tabler/icons-react";
import { FC } from "react";
import { useTranslation } from "next-i18next";
import { ClearConversations } from "./ClearConversations";
import { Import } from "./Import";
import { Key } from "./Key";
@@ -17,6 +18,7 @@ interface Props {
}
export const SidebarSettings: FC<Props> = ({ lightMode, apiKey, onToggleLightMode, onApiKeyChange, onClearConversations, onExportConversations, onImportConversations }) => {
const { t} = useTranslation('sidebar')
return (
<div className="flex flex-col pt-1 items-center border-t border-white/20 text-sm space-y-1">
<ClearConversations onClearConversations={onClearConversations} />
@@ -24,13 +26,13 @@ export const SidebarSettings: FC<Props> = ({ lightMode, apiKey, onToggleLightMod
<Import onImport={onImportConversations} />
<SidebarButton
text="Export conversations"
text={t("Export conversations")}
icon={<IconFileExport size={16} />}
onClick={() => onExportConversations()}
/>
<SidebarButton
text={lightMode === "light" ? "Dark mode" : "Light mode"}
text={lightMode === "light" ? t("Dark mode") : t("Light mode")}
icon={lightMode === "light" ? <IconMoon size={16} /> : <IconSun size={16} />}
onClick={() => onToggleLightMode(lightMode === "light" ? "dark" : "light")}
/>