mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
feat: i18n (#723)
* feat: i18n * fix: ci lint error * fix: pnpm run pre script not trigger --------- Co-authored-by: Jim Su <jimsu@protonmail.com>
This commit is contained in:
parent
baa981cda7
commit
0534c14279
2
Makefile
2
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
frontend/.gitignore
vendored
Normal file
3
frontend/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
# i18n translation files make by script using `make build`
|
||||
public/locales/**/*
|
||||
src/i18n/declaration.ts
|
||||
@ -1 +1,2 @@
|
||||
public-hoist-pattern[]=*@nextui-org/*
|
||||
enable-pre-post-scripts=true
|
||||
|
||||
10020
frontend/package-lock.json
generated
10020
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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
frontend/pnpm-lock.yaml
generated
98
frontend/pnpm-lock.yaml
generated
@ -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
frontend/scripts/make-i18n-translations.cjs
Normal file
42
frontend/scripts/make-i18n-translations.cjs
Normal file
@ -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');
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
|
||||
@ -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
frontend/src/i18n/index.ts
Normal file
61
frontend/src/i18n/index.ts
Normal file
@ -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
frontend/src/i18n/translation.json
Normal file
74
frontend/src/i18n/translation.json
Normal file
@ -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": "发送"
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user