Browse Source

feat: i18n (#723)

* feat: i18n

* fix: ci lint error

* fix: pnpm run pre script not trigger

---------

Co-authored-by: Jim Su <jimsu@protonmail.com>
mashiro 1 year ago
parent
commit
0534c14279

+ 1 - 1
Makefile

@@ -25,7 +25,7 @@ build:
 		rm -rf node_modules; \
 	fi
 	@which corepack > /dev/null || (echo "Installing corepack..." && npm install -g corepack)
-	@cd frontend && corepack enable && pnpm install
+	@cd frontend && corepack enable && pnpm install && pnpm run make-i18n
 
 # Start backend
 start-backend:

+ 3 - 0
frontend/.gitignore

@@ -0,0 +1,3 @@
+# i18n translation files make by script using `make build`
+public/locales/**/*
+src/i18n/declaration.ts

+ 1 - 0
frontend/.npmrc

@@ -1 +1,2 @@
 public-hoist-pattern[]=*@nextui-org/*
+enable-pre-post-scripts=true

File diff suppressed because it is too large
+ 672 - 232
frontend/package-lock.json


+ 6 - 0
frontend/package.json

@@ -23,8 +23,12 @@
     "@xterm/xterm": "^5.4.0",
     "eslint-config-airbnb-typescript": "^18.0.0",
     "framer-motion": "^11.0.24",
+    "i18next": "^23.10.1",
+    "i18next-browser-languagedetector": "^7.2.1",
+    "i18next-http-backend": "^2.5.0",
     "react": "^18.2.0",
     "react-dom": "^18.2.0",
+    "react-i18next": "^14.1.0",
     "react-redux": "^9.1.0",
     "react-syntax-highlighter": "^15.5.0",
     "tailwind-merge": "^2.2.2",
@@ -39,6 +43,8 @@
     "build": "tsc && vite build",
     "test": "jest",
     "preview": "vite preview",
+    "make-i18n": "node scripts/make-i18n-translations.cjs",
+    "prelint": "pnpm run make-i18n",
     "lint": "eslint src/**/*.ts* && prettier --check src/**/*.ts*",
     "prepare": "cd .. && husky install frontend/.husky"
   },

+ 98 - 0
frontend/pnpm-lock.yaml

@@ -53,12 +53,24 @@ dependencies:
   framer-motion:
     specifier: ^11.0.24
     version: 11.0.24(react-dom@18.2.0)(react@18.2.0)
+  i18next:
+    specifier: ^23.10.1
+    version: 23.10.1
+  i18next-browser-languagedetector:
+    specifier: ^7.2.1
+    version: 7.2.1
+  i18next-http-backend:
+    specifier: ^2.5.0
+    version: 2.5.0
   react:
     specifier: ^18.2.0
     version: 18.2.0
   react-dom:
     specifier: ^18.2.0
     version: 18.2.0(react@18.2.0)
+  react-i18next:
+    specifier: ^14.1.0
+    version: 14.1.0(i18next@23.10.1)(react-dom@18.2.0)(react@18.2.0)
   react-redux:
     specifier: ^9.1.0
     version: 9.1.0(@types/react@18.2.74)(react@18.2.0)(redux@5.0.1)
@@ -4465,6 +4477,14 @@ packages:
       - ts-node
     dev: true
 
+  /cross-fetch@4.0.0:
+    resolution: {integrity: sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==}
+    dependencies:
+      node-fetch: 2.7.0
+    transitivePeerDependencies:
+      - encoding
+    dev: false
+
   /cross-spawn@7.0.3:
     resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==}
     engines: {node: '>= 8'}
@@ -5590,6 +5610,12 @@ packages:
     resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
     dev: true
 
+  /html-parse-stringify@3.0.1:
+    resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==}
+    dependencies:
+      void-elements: 3.1.0
+    dev: false
+
   /http-proxy-agent@5.0.0:
     resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==}
     engines: {node: '>= 6'}
@@ -5627,6 +5653,26 @@ packages:
     hasBin: true
     dev: true
 
+  /i18next-browser-languagedetector@7.2.1:
+    resolution: {integrity: sha512-h/pM34bcH6tbz8WgGXcmWauNpQupCGr25XPp9cZwZInR9XHSjIFDYp1SIok7zSPsTOMxdvuLyu86V+g2Kycnfw==}
+    dependencies:
+      '@babel/runtime': 7.24.1
+    dev: false
+
+  /i18next-http-backend@2.5.0:
+    resolution: {integrity: sha512-Z/aQsGZk1gSxt2/DztXk92DuDD20J+rNudT7ZCdTrNOiK8uQppfvdjq9+DFQfpAnFPn3VZS+KQIr1S/W1KxhpQ==}
+    dependencies:
+      cross-fetch: 4.0.0
+    transitivePeerDependencies:
+      - encoding
+    dev: false
+
+  /i18next@23.10.1:
+    resolution: {integrity: sha512-NDiIzFbcs3O9PXpfhkjyf7WdqFn5Vq6mhzhtkXzj51aOcNuPNcTwuYNuXCpHsanZGHlHKL35G7huoFeVic1hng==}
+    dependencies:
+      '@babel/runtime': 7.24.1
+    dev: false
+
   /iconv-lite@0.6.3:
     resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
     engines: {node: '>=0.10.0'}
@@ -6806,6 +6852,18 @@ packages:
   /natural-compare@1.4.0:
     resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
 
+  /node-fetch@2.7.0:
+    resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
+    engines: {node: 4.x || >=6.0.0}
+    peerDependencies:
+      encoding: ^0.1.0
+    peerDependenciesMeta:
+      encoding:
+        optional: true
+    dependencies:
+      whatwg-url: 5.0.0
+    dev: false
+
   /node-int64@0.4.0:
     resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==}
     dev: true
@@ -7229,6 +7287,26 @@ packages:
       scheduler: 0.23.0
     dev: false
 
+  /react-i18next@14.1.0(i18next@23.10.1)(react-dom@18.2.0)(react@18.2.0):
+    resolution: {integrity: sha512-3KwX6LHpbvGQ+sBEntjV4sYW3Zovjjl3fpoHbUwSgFHf0uRBcbeCBLR5al6ikncI5+W0EFb71QXZmfop+J6NrQ==}
+    peerDependencies:
+      i18next: '>= 23.2.3'
+      react: '>= 16.8.0'
+      react-dom: '*'
+      react-native: '*'
+    peerDependenciesMeta:
+      react-dom:
+        optional: true
+      react-native:
+        optional: true
+    dependencies:
+      '@babel/runtime': 7.24.1
+      html-parse-stringify: 3.0.1
+      i18next: 23.10.1
+      react: 18.2.0
+      react-dom: 18.2.0(react@18.2.0)
+    dev: false
+
   /react-is@16.13.1:
     resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
     dev: true
@@ -7966,6 +8044,10 @@ packages:
       url-parse: 1.5.10
     dev: true
 
+  /tr46@0.0.3:
+    resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
+    dev: false
+
   /tr46@3.0.0:
     resolution: {integrity: sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==}
     engines: {node: '>=12'}
@@ -8284,6 +8366,11 @@ packages:
       fsevents: 2.3.3
     dev: false
 
+  /void-elements@3.1.0:
+    resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
+    engines: {node: '>=0.10.0'}
+    dev: false
+
   /w3c-xmlserializer@4.0.0:
     resolution: {integrity: sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==}
     engines: {node: '>=14'}
@@ -8301,6 +8388,10 @@ packages:
     resolution: {integrity: sha512-sVWcwhU5mX6crfI5Vd2dC4qchyTqxV8URinzt25XqVh+bHEPGH4C3NPrNionCP7Obx59wrYEbNlw4Z8sjALzZg==}
     dev: false
 
+  /webidl-conversions@3.0.1:
+    resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
+    dev: false
+
   /webidl-conversions@7.0.0:
     resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
     engines: {node: '>=12'}
@@ -8326,6 +8417,13 @@ packages:
       webidl-conversions: 7.0.0
     dev: true
 
+  /whatwg-url@5.0.0:
+    resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
+    dependencies:
+      tr46: 0.0.3
+      webidl-conversions: 3.0.1
+    dev: false
+
   /which-boxed-primitive@1.0.2:
     resolution: {integrity: sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==}
     dependencies:

+ 42 - 0
frontend/scripts/make-i18n-translations.cjs

@@ -0,0 +1,42 @@
+const fs = require("fs");
+const path = require("path");
+const i18n = require("../src/i18n/translation.json");
+
+// { [lang]: { [key]: content } }
+const translationMap = {};
+
+Object.entries(i18n).forEach(([key, transMap]) => {
+  Object.entries(transMap).forEach(([lang, content]) => {
+    if (!translationMap[lang]) {
+      translationMap[lang] = {};
+    }
+    translationMap[lang][key] = content;
+  })
+});
+
+// remove old locales directory
+const localesPath = path.join(__dirname, "../public/locales");
+if (fs.existsSync(localesPath)) {
+  fs.rmSync(localesPath, { recursive: true });
+} 
+
+// write translation files
+Object.entries(translationMap).forEach(([lang, transMap]) => {
+  const filePath = path.join(__dirname, `../public/locales/${lang}/translation.json`);
+  if (!fs.existsSync(filePath)) {
+    fs.mkdirSync(path.dirname(filePath), { recursive: true });
+  }
+  fs.writeFileSync(filePath, JSON.stringify(transMap, null, 2));
+});
+
+// write translation key enum
+const transKeys = Object.keys(translationMap.en);
+const transKeyDeclareFilePath = path.join(__dirname, "../src/i18n/declaration.ts");
+if (!fs.existsSync(transKeyDeclareFilePath)) {
+  fs.mkdirSync(path.dirname(transKeyDeclareFilePath), { recursive: true });
+}
+fs.writeFileSync(transKeyDeclareFilePath, `
+// this file generate by script, don't modify it manually!!!
+export enum I18nKey {
+${transKeys.map(key => `  ${key} = "${key}",`).join('\n')}
+}`.trim() + '\n');

+ 5 - 1
frontend/src/components/ChatInterface.tsx

@@ -1,6 +1,7 @@
 import { Card, CardBody } from "@nextui-org/react";
 import React, { useEffect, useRef } from "react";
 import { useSelector } from "react-redux";
+import { useTranslation } from "react-i18next";
 import assistantAvatar from "../assets/assistant-avatar.png";
 import CogTooth from "../assets/cog-tooth";
 import userAvatar from "../assets/user-avatar.png";
@@ -14,6 +15,7 @@ import {
 import { RootState } from "../store";
 import { Message } from "../state/chatSlice";
 import Input from "./Input";
+import { I18nKey } from "../i18n/declaration";
 
 interface IChatBubbleProps {
   msg: Message;
@@ -169,6 +171,8 @@ function MessageList(): JSX.Element {
 }
 
 function InitializingStatus(): JSX.Element {
+  const { t } = useTranslation();
+
   return (
     <div className="flex items-center m-auto h-full">
       <img
@@ -176,7 +180,7 @@ function InitializingStatus(): JSX.Element {
         alt="assistant avatar"
         className="w-[40px] h-[40px] mx-2.5"
       />
-      <div>Initializing agent (may take up to 10 seconds)...</div>
+      <div>{t(I18nKey.CHAT_INTERFACE$INITIALZING_AGENT_LOADING_MESSAGE)}</div>
     </div>
   );
 }

+ 5 - 2
frontend/src/components/Input.tsx

@@ -2,11 +2,14 @@ import React, { ChangeEvent, useState, KeyboardEvent } from "react";
 import { useSelector } from "react-redux";
 import { Textarea } from "@nextui-org/react";
 import { twMerge } from "tailwind-merge";
+import { useTranslation } from "react-i18next";
 import { RootState } from "../store";
 import useInputComposition from "../hooks/useInputComposition";
 import { sendChatMessage } from "../services/chatService";
+import { I18nKey } from "../i18n/declaration";
 
 function Input() {
+  const { t } = useTranslation();
   const { initialized } = useSelector((state: RootState) => state.task);
   const [inputMessage, setInputMessage] = useState("");
 
@@ -54,7 +57,7 @@ function Input() {
         onKeyDown={handleSendMessageOnEnter}
         onCompositionStart={onCompositionStart}
         onCompositionEnd={onCompositionEnd}
-        placeholder="Send a message (won't interrupt the Assistant)"
+        placeholder={t(I18nKey.CHAT_INTERFACE$INPUT_PLACEHOLDER)}
       />
       <button
         type="button"
@@ -65,7 +68,7 @@ function Input() {
         onClick={handleSendMessage}
         disabled={!initialized}
       >
-        Send
+        {t(I18nKey.CHAT_INTERFACE$INPUT_SEND_MESSAGE_BUTTON_CONTENT)}
       </button>
     </div>
   );

+ 39 - 12
frontend/src/components/SettingModal.tsx

@@ -10,8 +10,12 @@ import {
   Button,
   Autocomplete,
   AutocompleteItem,
+  Select,
+  SelectItem,
 } from "@nextui-org/react";
 import { KeyboardEvent } from "@react-types/shared/src/events";
+import { useTranslation } from "react-i18next";
+import i18next from "i18next";
 import {
   INITIAL_AGENTS,
   fetchModels,
@@ -24,9 +28,12 @@ import {
   setModel,
   setAgent,
   setWorkspaceDirectory,
+  setLanguage,
 } from "../state/settingsSlice";
 import store, { RootState } from "../store";
 import socket from "../socket/socket";
+import { I18nKey } from "../i18n/declaration";
+import { AvailableLanguages } from "../i18n";
 
 interface Props {
   isOpen: boolean;
@@ -41,11 +48,13 @@ const cachedAgents = JSON.parse(
 );
 
 function SettingModal({ isOpen, onClose }: Props): JSX.Element {
+  const { t } = useTranslation();
   const model = useSelector((state: RootState) => state.settings.model);
   const agent = useSelector((state: RootState) => state.settings.agent);
   const workspaceDirectory = useSelector(
     (state: RootState) => state.settings.workspaceDirectory,
   );
+  const language = useSelector((state: RootState) => state.settings.language);
 
   const [supportedModels, setSupportedModels] = useState(
     cachedModels.length > 0 ? cachedModels : INITIAL_MODELS,
@@ -72,10 +81,12 @@ function SettingModal({ isOpen, onClose }: Props): JSX.Element {
   }, []);
 
   const handleSaveCfg = () => {
-    sendSettings(socket, { model, agent, workspaceDirectory });
+    sendSettings(socket, { model, agent, workspaceDirectory, language });
     localStorage.setItem("model", model);
     localStorage.setItem("workspaceDirectory", workspaceDirectory);
     localStorage.setItem("agent", agent);
+    localStorage.setItem("language", language);
+    i18next.changeLanguage(language);
     onClose();
   };
 
@@ -87,14 +98,18 @@ function SettingModal({ isOpen, onClose }: Props): JSX.Element {
       <ModalContent>
         <>
           <ModalHeader className="flex flex-col gap-1">
-            Configuration
+            {t(I18nKey.CONFIGURATION$MODAL_TITLE)}
           </ModalHeader>
           <ModalBody>
             <Input
               type="text"
-              label="OpenDevin Workspace Directory"
+              label={t(
+                I18nKey.CONFIGURATION$OPENDEVIN_WORKSPACE_DIRECTORY_INPUT_LABEL,
+              )}
               defaultValue={workspaceDirectory}
-              placeholder="Default: ./workspace"
+              placeholder={t(
+                I18nKey.CONFIGURATION$OPENDEVIN_WORKSPACE_DIRECTORY_INPUT_PLACEHOLDER,
+              )}
               onChange={(e) =>
                 store.dispatch(setWorkspaceDirectory(e.target.value))
               }
@@ -105,10 +120,9 @@ function SettingModal({ isOpen, onClose }: Props): JSX.Element {
                 label: v,
                 value: v,
               }))}
-              label="Model"
-              placeholder="Select a model"
+              label={t(I18nKey.CONFIGURATION$MODEL_SELECT_LABEL)}
+              placeholder={t(I18nKey.CONFIGURATION$MODEL_SELECT_PLACEHOLDER)}
               selectedKey={model}
-              // className="max-w-xs"
               onSelectionChange={(key) => {
                 store.dispatch(setModel(key as string));
               }}
@@ -127,10 +141,9 @@ function SettingModal({ isOpen, onClose }: Props): JSX.Element {
                 label: v,
                 value: v,
               }))}
-              label="Agent"
-              placeholder="Select a agent"
+              label={t(I18nKey.CONFIGURATION$AGENT_SELECT_LABEL)}
+              placeholder={t(I18nKey.CONFIGURATION$AGENT_SELECT_PLACEHOLDER)}
               defaultSelectedKey={agent}
-              // className="max-w-xs"
               onSelectionChange={(key) => {
                 store.dispatch(setAgent(key as string));
               }}
@@ -143,14 +156,28 @@ function SettingModal({ isOpen, onClose }: Props): JSX.Element {
                 </AutocompleteItem>
               )}
             </Autocomplete>
+            <Select
+              selectionMode="single"
+              onChange={(e) => {
+                store.dispatch(setLanguage(e.target.value));
+              }}
+              selectedKeys={[language]}
+              label={t(I18nKey.CONFIGURATION$LANGUAGE_SELECT_LABEL)}
+            >
+              {AvailableLanguages.map((lang) => (
+                <SelectItem key={lang.value} value={lang.value}>
+                  {lang.label}
+                </SelectItem>
+              ))}
+            </Select>
           </ModalBody>
 
           <ModalFooter>
             <Button color="danger" variant="light" onPress={onClose}>
-              Close
+              {t(I18nKey.CONFIGURATION$MODAL_CLOSE_BUTTON_LABEL)}
             </Button>
             <Button color="primary" onPress={handleSaveCfg}>
-              Save
+              {t(I18nKey.CONFIGURATION$MODAL_SAVE_BUTTON_LABEL)}
             </Button>
           </ModalFooter>
         </>

+ 31 - 25
frontend/src/components/Workspace.tsx

@@ -1,5 +1,6 @@
-import React, { useState } from "react";
+import React, { useMemo, useState } from "react";
 import { Tab, Tabs } from "@nextui-org/react";
+import { useTranslation } from "react-i18next";
 import Terminal from "./Terminal";
 import Planner from "./Planner";
 import CodeEditor from "./CodeEditor";
@@ -9,37 +10,42 @@ import CmdLine from "../assets/cmd-line";
 import Calendar from "../assets/calendar";
 import Earth from "../assets/earth";
 import Pencil from "../assets/pencil";
-
-const tabData = {
-  [TabOption.TERMINAL]: {
-    name: "Terminal",
-    icon: <CmdLine />,
-    component: <Terminal key="terminal" />,
-  },
-  [TabOption.PLANNER]: {
-    name: "Planner",
-    icon: <Calendar />,
-    component: <Planner key="planner" />,
-  },
-  [TabOption.CODE]: {
-    name: "Code Editor",
-    icon: <Pencil />,
-    component: <CodeEditor key="code" />,
-  },
-  [TabOption.BROWSER]: {
-    name: "Browser",
-    icon: <Earth />,
-    component: <Browser key="browser" />,
-  },
-};
+import { I18nKey } from "../i18n/declaration";
 
 function Workspace() {
+  const { t } = useTranslation();
   const [activeTab, setActiveTab] = useState<TabType>(TabOption.TERMINAL);
 
+  const tabData = useMemo(
+    () => ({
+      [TabOption.TERMINAL]: {
+        name: t(I18nKey.WORKSPACE$TERMINAL_TAB_LABEL),
+        icon: <CmdLine />,
+        component: <Terminal key="terminal" />,
+      },
+      [TabOption.PLANNER]: {
+        name: t(I18nKey.WORKSPACE$PLANNER_TAB_LABEL),
+        icon: <Calendar />,
+        component: <Planner key="planner" />,
+      },
+      [TabOption.CODE]: {
+        name: t(I18nKey.WORKSPACE$CODE_EDITOR_TAB_LABEL),
+        icon: <Pencil />,
+        component: <CodeEditor key="code" />,
+      },
+      [TabOption.BROWSER]: {
+        name: t(I18nKey.WORKSPACE$BROWSER_TAB_LABEL),
+        icon: <Earth />,
+        component: <Browser key="browser" />,
+      },
+    }),
+    [t],
+  );
+
   return (
     <>
       <div className="w-full p-4 text-2xl font-bold select-none">
-        OpenDevin Workspace
+        {t(I18nKey.WORKSPACE$TITLE)}
       </div>
       <div role="tablist" className="tabs tabs-bordered tabs-lg ">
         <Tabs

+ 61 - 0
frontend/src/i18n/index.ts

@@ -0,0 +1,61 @@
+import i18n from "i18next";
+import Backend from "i18next-http-backend";
+import LanguageDetector from "i18next-browser-languagedetector";
+import { initReactI18next } from "react-i18next";
+
+export const AvailableLanguages = [
+  { label: "English", value: "en" },
+  { label: "简体中文", value: "zh-CN" },
+];
+
+i18n
+  .use(Backend)
+  .use(LanguageDetector)
+  .use(initReactI18next)
+  .init({
+    fallbackLng: "en",
+    debug: process.env.NODE_ENV === "development",
+  })
+  .then(() => {
+    // assume all detected   languages are available
+    const detectLanguage = i18n.language;
+    // cannot trust browser language setting
+    const settingLanguage = localStorage.getItem("language");
+
+    // if setting is not initialized, but detected language is available, use detected language and update language setting
+    if (
+      !settingLanguage &&
+      AvailableLanguages.some((lang) => detectLanguage === lang.value)
+    ) {
+      localStorage.setItem("language", detectLanguage);
+      i18n.changeLanguage(detectLanguage);
+      return;
+    }
+
+    // if setting is not initialized and detected language is not available, use en and update language setting
+    if (
+      !settingLanguage &&
+      !AvailableLanguages.some((lang) => detectLanguage === lang.value)
+    ) {
+      localStorage.setItem("language", "en");
+      i18n.changeLanguage("en");
+      return;
+    }
+
+    // if setting is initialized and setting language is not available, use en and update language setting
+    if (
+      settingLanguage &&
+      !AvailableLanguages.some((lang) => settingLanguage === lang.value)
+    ) {
+      localStorage.setItem("language", "en");
+      i18n.changeLanguage("en");
+      return;
+    }
+
+    // if setting is initialized and setting language is available, use setting language
+    if (settingLanguage && settingLanguage !== detectLanguage) {
+      i18n.changeLanguage(settingLanguage);
+    }
+  });
+
+export default i18n;

+ 74 - 0
frontend/src/i18n/translation.json

@@ -0,0 +1,74 @@
+{
+  "WORKSPACE$TITLE": {
+    "en": "OpenDevin Workspace",
+    "zh-CN": "OpenDevin 工作区"
+  },
+  "WORKSPACE$TERMINAL_TAB_LABEL": {
+    "en": "Terminal",
+    "zh-CN": "终端"
+  },
+  "WORKSPACE$PLANNER_TAB_LABEL": {
+    "en": "Planner",
+    "zh-CN": "规划器"
+  },
+  "WORKSPACE$CODE_EDITOR_TAB_LABEL": {
+    "en": "Code Editor",
+    "zh-CN": "代码编辑器"
+  },
+  "WORKSPACE$BROWSER_TAB_LABEL": {
+    "en": "Browser",
+    "zh-CN": "浏览器"
+  },
+  "CONFIGURATION$OPENDEVIN_WORKSPACE_DIRECTORY_INPUT_LABEL": {
+    "en": "OpenDevin Workspace directory",
+    "zh-CN": "OpenDevin 工作区目录"
+  },
+  "CONFIGURATION$OPENDEVIN_WORKSPACE_DIRECTORY_INPUT_PLACEHOLDER": {
+    "en": "Default: ./workspace",
+    "zh-CN": "默认:./workspace"
+  },
+  "CONFIGURATION$MODAL_TITLE": {
+    "en": "Configuration",
+    "zh-CN": "配置"
+  },
+  "CONFIGURATION$MODEL_SELECT_LABEL": {
+    "en": "Model",
+    "zh-CN": "模型"
+  },
+  "CONFIGURATION$MODEL_SELECT_PLACEHOLDER": {
+    "en": "Select a model",
+    "zh-CN": "选择一个模型"
+  },
+  "CONFIGURATION$AGENT_SELECT_LABEL": {
+    "en": "Agent",
+    "zh-CN": "代理"
+  },
+  "CONFIGURATION$AGENT_SELECT_PLACEHOLDER": {
+    "en": "Select a agent",
+    "zh-CN": "选择一个代理"
+  },
+  "CONFIGURATION$LANGUAGE_SELECT_LABEL": {
+    "en": "Language",
+    "zh-CN": "语言"
+  },
+  "CONFIGURATION$MODAL_CLOSE_BUTTON_LABEL": {
+    "en": "Close",
+    "zh-CN": "关闭"
+  },
+  "CONFIGURATION$MODAL_SAVE_BUTTON_LABEL": {
+    "en": "Save",
+    "zh-CN": "保存"
+  },
+  "CHAT_INTERFACE$INITIALZING_AGENT_LOADING_MESSAGE": {
+    "en": "Initializing agent (may take up to 10 seconds)...",
+    "zh-CN": "初始化代理(可能需要 10 秒以上时间)"
+  },
+  "CHAT_INTERFACE$INPUT_PLACEHOLDER": {
+    "en": "Send a message (won't interrupt the Assistant)",
+    "zh-CN": "发送消息(不会打断助理)"
+  },
+  "CHAT_INTERFACE$INPUT_SEND_MESSAGE_BUTTON_CONTENT": {
+    "en": "Send",
+    "zh-CN": "发送"
+  }
+}

+ 1 - 0
frontend/src/index.tsx

@@ -7,6 +7,7 @@ import { NextUIProvider } from "@nextui-org/react";
 import App from "./App";
 import reportWebVitals from "./reportWebVitals";
 import store from "./store";
+import "./i18n";
 
 const root = ReactDOM.createRoot(
   document.getElementById("root") as HTMLElement,

+ 5 - 1
frontend/src/state/settingsSlice.ts

@@ -7,6 +7,7 @@ export const settingsSlice = createSlice({
     agent: localStorage.getItem("agent") || "MonologueAgent",
     workspaceDirectory:
       localStorage.getItem("workspaceDirectory") || "./workspace",
+    language: localStorage.getItem("language") || "en",
   },
   reducers: {
     setModel: (state, action) => {
@@ -18,10 +19,13 @@ export const settingsSlice = createSlice({
     setWorkspaceDirectory: (state, action) => {
       state.workspaceDirectory = action.payload;
     },
+    setLanguage: (state, action) => {
+      state.language = action.payload;
+    },
   },
 });
 
-export const { setModel, setAgent, setWorkspaceDirectory } =
+export const { setModel, setAgent, setWorkspaceDirectory, setLanguage } =
   settingsSlice.actions;
 
 export default settingsSlice.reducer;

Some files were not shown because too many files changed in this diff