From 2bf94539bd48b161eecef4cc55713c5ae438d60c Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 13 May 2025 22:39:19 +0800 Subject: [PATCH] Add i18n support with language selector on login page (#1410) * feat: add i18n support with language selector on login page Co-Authored-By: Junyan Qin * feat: complete i18n implementation for all webui components Co-Authored-By: Junyan Qin * feat: complete all hardcoded text * feat: dynamic label i18n * fix: lint errors * fix: lint errors * delete sh fils * fix: edit model dialog title --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Junyan Qin --- web/package-lock.json | 98 +++++++++- web/package.json | 3 + .../home/bots/components/bot-form/BotForm.tsx | 83 ++++---- web/src/app/home/bots/page.tsx | 10 +- .../dynamic-form/DynamicFormComponent.tsx | 5 +- .../dynamic-form/DynamicFormItemComponent.tsx | 21 +- .../components/home-sidebar/HomeSidebar.tsx | 10 +- .../home-sidebar/sidbarConfigList.tsx | 21 +- .../models/component/llm-card/LLMCard.tsx | 14 +- .../models/component/llm-form/LLMForm.tsx | 185 ++++++++++-------- web/src/app/home/models/page.tsx | 11 +- .../components/pipeline-card/PipelineCard.tsx | 9 +- .../pipeline-form/PipelineFormComponent.tsx | 71 ++++--- web/src/app/home/pipelines/page.tsx | 12 +- web/src/app/home/plugins/page.tsx | 30 +-- .../PluginInstalledComponent.tsx | 9 +- .../plugin-form/PluginForm.tsx | 3 +- .../plugin-market/PluginMarketComponent.tsx | 22 ++- .../plugins/plugin-sort/PluginSortDialog.tsx | 17 +- web/src/app/layout.tsx | 9 +- web/src/app/login/page.tsx | 91 +++++++-- web/src/i18n/I18nProvider.tsx | 20 ++ web/src/i18n/index.ts | 34 ++++ web/src/i18n/locales/en-US.ts | 174 ++++++++++++++++ web/src/i18n/locales/zh-Hans.ts | 169 ++++++++++++++++ 25 files changed, 898 insertions(+), 233 deletions(-) create mode 100644 web/src/i18n/I18nProvider.tsx create mode 100644 web/src/i18n/index.ts create mode 100644 web/src/i18n/locales/en-US.ts create mode 100644 web/src/i18n/locales/zh-Hans.ts diff --git a/web/package-lock.json b/web/package-lock.json index 410bc379..ca0c9730 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -25,6 +25,8 @@ "axios": "^1.8.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "i18next": "^25.1.2", + "i18next-browser-languagedetector": "^8.1.0", "lodash": "^4.17.21", "lucide-react": "^0.507.0", "next": "15.2.4", @@ -33,6 +35,7 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "react-hook-form": "^7.56.3", + "react-i18next": "^15.5.1", "sonner": "^2.0.3", "tailwind-merge": "^3.2.0", "tailwindcss": "^4.1.5", @@ -68,6 +71,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@babel/runtime": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.1.tgz", + "integrity": "sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@dnd-kit/accessibility": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", @@ -4375,6 +4387,15 @@ "node": ">= 0.4" } }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, "node_modules/human-signals": { "version": "5.0.0", "resolved": "https://registry.npmmirror.com/human-signals/-/human-signals-5.0.0.tgz", @@ -4384,6 +4405,46 @@ "node": ">=16.17.0" } }, + "node_modules/i18next": { + "version": "25.1.2", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.1.2.tgz", + "integrity": "sha512-SP63m8LzdjkrAjruH7SCI3ndPSgjt4/wX7ouUUOzCW/eY+HzlIo19IQSfYA9X3qRiRP1SYtaTsg/Oz/PGsfD8w==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.10" + }, + "peerDependencies": { + "typescript": "^5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.1.0.tgz", + "integrity": "sha512-mHZxNx1Lq09xt5kCauZ/4bsXOEA2pfpwSoU11/QTJB+pD94iONFwp+ohqi///PwiFvjFOxe1akYCdHyFo1ng5Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -6033,6 +6094,32 @@ "react": "^16.8.0 || ^17 || ^18 || ^19" } }, + "node_modules/react-i18next": { + "version": "15.5.1", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.5.1.tgz", + "integrity": "sha512-C8RZ7N7H0L+flitiX6ASjq9p5puVJU1Z8VyL3OgM/QOMRf40BMZX+5TkpxzZVcTmOLPX5zlti4InEX5pFyiVeA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.0", + "html-parse-stringify": "^3.0.1" + }, + "peerDependencies": { + "i18next": ">= 23.2.3", + "react": ">= 16.8.0", + "typescript": "^5" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -7069,7 +7156,7 @@ "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", - "dev": true, + "devOptional": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7184,6 +7271,15 @@ "uuidjs": "dist/cli.js" } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/web/package.json b/web/package.json index ee33ccc5..f12dd666 100644 --- a/web/package.json +++ b/web/package.json @@ -33,6 +33,8 @@ "axios": "^1.8.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "i18next": "^25.1.2", + "i18next-browser-languagedetector": "^8.1.0", "lodash": "^4.17.21", "lucide-react": "^0.507.0", "next": "15.2.4", @@ -41,6 +43,7 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "react-hook-form": "^7.56.3", + "react-i18next": "^15.5.1", "sonner": "^2.0.3", "tailwind-merge": "^3.2.0", "tailwindcss": "^4.1.5", diff --git a/web/src/app/home/bots/components/bot-form/BotForm.tsx b/web/src/app/home/bots/components/bot-form/BotForm.tsx index 2ab46bdb..384abb79 100644 --- a/web/src/app/home/bots/components/bot-form/BotForm.tsx +++ b/web/src/app/home/bots/components/bot-form/BotForm.tsx @@ -18,6 +18,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; import { toast } from 'sonner'; +import { useTranslation } from 'react-i18next'; import { Dialog, @@ -46,15 +47,19 @@ import { SelectValue, } from '@/components/ui/select'; import { Switch } from '@/components/ui/switch'; +import { i18nObj } from '@/i18n/I18nProvider'; -const formSchema = z.object({ - name: z.string().min(1, { message: '机器人名称不能为空' }), - description: z.string().min(1, { message: '机器人描述不能为空' }), - adapter: z.string().min(1, { message: '适配器不能为空' }), - adapter_config: z.record(z.string(), z.any()), - enable: z.boolean().optional(), - use_pipeline_uuid: z.string().optional(), -}); +const getFormSchema = (t: (key: string) => string) => + z.object({ + name: z.string().min(1, { message: t('bots.botNameRequired') }), + description: z + .string() + .min(1, { message: t('bots.botDescriptionRequired') }), + adapter: z.string().min(1, { message: t('bots.adapterRequired') }), + adapter_config: z.record(z.string(), z.any()), + enable: z.boolean().optional(), + use_pipeline_uuid: z.string().optional(), + }); export default function BotForm({ initBotId, @@ -64,16 +69,19 @@ export default function BotForm({ onNewBotCreated, }: { initBotId?: string; - onFormSubmit: (value: z.infer) => void; + onFormSubmit: (value: z.infer>) => void; onFormCancel: () => void; onBotDeleted: () => void; onNewBotCreated: (botId: string) => void; }) { + const { t } = useTranslation(); + const formSchema = getFormSchema(t); + const form = useForm>({ resolver: zodResolver(formSchema), defaultValues: { name: '', - description: '一个机器人', + description: t('bots.defaultDescription'), adapter: '', adapter_config: {}, enable: true, @@ -129,7 +137,7 @@ export default function BotForm({ // dynamicForm.setFieldsValue(val.adapter_config); }) .catch((err) => { - toast.error('获取机器人配置失败:' + err.message); + toast.error(t('bots.getBotConfigError') + err.message); }); } else { form.reset(); @@ -156,7 +164,7 @@ export default function BotForm({ setAdapterNameList( adaptersRes.adapters.map((item) => { return { - label: item.label.zh_CN, + label: i18nObj(item.label), value: item.name, }; }), @@ -177,7 +185,7 @@ export default function BotForm({ setAdapterDescriptionList( adaptersRes.adapters.reduce( (acc, item) => { - acc[item.name] = item.description.zh_CN; + acc[item.name] = i18nObj(item.description); return acc; }, {} as Record, @@ -266,10 +274,10 @@ export default function BotForm({ .then((res) => { console.log('update bot success', res); onFormSubmit(form.getValues()); - toast.success('保存成功'); + toast.success(t('bots.saveSuccess')); }) .catch((err) => { - toast.error('保存失败:' + err.message); + toast.error(t('bots.saveError') + err.message); }) .finally(() => { setIsLoading(false); @@ -289,7 +297,7 @@ export default function BotForm({ .createBot(newBot) .then((res) => { console.log('create bot success', res); - toast.success('创建成功 请启用或修改绑定流水线'); + toast.success(t('bots.createSuccess')); initBotId = res.uuid; setBotFormValues(); @@ -297,7 +305,7 @@ export default function BotForm({ onNewBotCreated(res.uuid); }) .catch((err) => { - toast.error('创建失败:' + err.message); + toast.error(t('bots.createError') + err.message); }) .finally(() => { setIsLoading(false); @@ -315,10 +323,10 @@ export default function BotForm({ .deleteBot(initBotId) .then(() => { onBotDeleted(); - toast.success('删除成功'); + toast.success(t('bots.deleteSuccess')); }) .catch((err) => { - toast.error('删除失败:' + err.message); + toast.error(t('bots.deleteError') + err.message); }); } } @@ -331,9 +339,9 @@ export default function BotForm({ > - 删除确认 + {t('common.confirmDelete')} - 你确定要删除这个机器人吗? + {t('bots.deleteConfirmation')} @@ -368,7 +376,7 @@ export default function BotForm({ name="enable" render={({ field }) => ( - 是否启用 + {t('common.enable')} ( - 绑定流水线 + {t('bots.bindPipeline')} @@ -428,7 +439,8 @@ export default function BotForm({ render={({ field }) => ( - 机器人描述* + {t('bots.botDescription')} + * @@ -444,7 +456,8 @@ export default function BotForm({ render={({ field }) => ( - 平台/适配器选择* + {t('bots.platformAdapter')} + *
@@ -456,7 +469,7 @@ export default function BotForm({ value={field.value} > - + @@ -499,7 +512,9 @@ export default function BotForm({ {showDynamicForm && dynamicFormConfigList.length > 0 && (
-
适配器配置
+
+ {t('bots.adapterConfig')} +
- 提交 + {t('common.submit')} )} {initBotId && ( @@ -528,13 +543,13 @@ export default function BotForm({ variant="destructive" onClick={() => setShowDeleteConfirmModal(true)} > - 删除 + {t('common.delete')} )} @@ -543,7 +558,7 @@ export default function BotForm({ variant="outline" onClick={() => onFormCancel()} > - 取消 + {t('common.cancel')}
diff --git a/web/src/app/home/bots/page.tsx b/web/src/app/home/bots/page.tsx index 34d0b3cf..cc696300 100644 --- a/web/src/app/home/bots/page.tsx +++ b/web/src/app/home/bots/page.tsx @@ -15,7 +15,11 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { toast } from 'sonner'; +import { useTranslation } from 'react-i18next'; +import { i18nObj } from '@/i18n/I18nProvider'; + export default function BotConfigPage() { + const { t } = useTranslation(); const [modalOpen, setModalOpen] = useState(false); const [botList, setBotList] = useState([]); const [isEditForm, setIsEditForm] = useState(false); @@ -29,7 +33,7 @@ export default function BotConfigPage() { const adapterListResp = await httpClient.getAdapters(); const adapterList = adapterListResp.adapters.map((adapter: Adapter) => { return { - label: adapter.label.zh_CN, + label: i18nObj(adapter.label), value: adapter.name, }; }); @@ -53,7 +57,7 @@ export default function BotConfigPage() { }) .catch((err) => { console.error('get bot list error', err); - toast.error('获取机器人列表失败:' + err.message); + toast.error(t('bots.getBotListError') + err.message); }) .finally(() => { // setIsLoading(false); @@ -78,7 +82,7 @@ export default function BotConfigPage() { - {isEditForm ? '编辑机器人' : '创建机器人'} + {isEditForm ? t('bots.editBot') : t('bots.createBot')}
diff --git a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx index 465f2ae1..f3df9e87 100644 --- a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx +++ b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx @@ -12,6 +12,7 @@ import { } from '@/components/ui/form'; import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent'; import { useEffect } from 'react'; +import { i18nObj } from '@/i18n/I18nProvider'; export default function DynamicFormComponent({ itemConfigList, @@ -141,7 +142,7 @@ export default function DynamicFormComponent({ render={({ field }) => ( - {config.label.zh_CN}{' '} + {i18nObj(config.label)}{' '} {config.required && *} @@ -149,7 +150,7 @@ export default function DynamicFormComponent({ {config.description && (

- {config.description.zh_CN} + {i18nObj(config.description)}

)} diff --git a/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx b/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx index c46f1ef0..28d963d3 100644 --- a/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx +++ b/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx @@ -23,6 +23,8 @@ import { HoverCardContent, HoverCardTrigger, } from '@/components/ui/hover-card'; +import { useTranslation } from 'react-i18next'; +import { i18nObj } from '@/i18n/I18nProvider'; export default function DynamicFormItemComponent({ config, @@ -33,6 +35,7 @@ export default function DynamicFormItemComponent({ field: ControllerRenderProps; }) { const [llmModels, setLlmModels] = useState([]); + const { t } = useTranslation(); useEffect(() => { if (config.type === DynamicFormItemType.LLM_MODEL_SELECTOR) { @@ -106,7 +109,7 @@ export default function DynamicFormItemComponent({ field.onChange([...field.value, '']); }} > - 添加 + {t('common.add')}
); @@ -115,13 +118,13 @@ export default function DynamicFormItemComponent({ return ( - + @@ -205,9 +208,9 @@ export default function DynamicFormItemComponent({ )} {ability === 'vision' - ? '视觉能力' + ? t('models.visionAbility') : ability === 'func_call' - ? '函数调用' + ? t('models.functionCallAbility') : ability} @@ -217,7 +220,9 @@ export default function DynamicFormItemComponent({ {model.extra_args && Object.keys(model.extra_args).length > 0 && (
-
额外参数:
+
+ {t('models.extraParameters')} +
{Object.entries( model.extra_args as Record, @@ -321,7 +326,7 @@ export default function DynamicFormItemComponent({ field.onChange([...field.value, { role: 'user', content: '' }]); }} > - 添加回合 + {t('common.addRound')}
); diff --git a/web/src/app/home/components/home-sidebar/HomeSidebar.tsx b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx index ff3edc47..93ee355d 100644 --- a/web/src/app/home/components/home-sidebar/HomeSidebar.tsx +++ b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx @@ -10,6 +10,7 @@ import { useRouter, usePathname } from 'next/navigation'; import { sidebarConfigList } from '@/app/home/components/home-sidebar/sidbarConfigList'; import langbotIcon from '@/app/assets/langbot-logo.webp'; import { httpClient } from '@/app/infra/http/HttpClient'; +import { useTranslation } from 'react-i18next'; // TODO 侧边导航栏要加动画 export default function HomeSidebar({ @@ -27,14 +28,15 @@ export default function HomeSidebar({ const [selectedChild, setSelectedChild] = useState(); + const { t } = useTranslation(); + useEffect(() => { - console.log('HomeSidebar挂载完成'); initSelect(); if (!localStorage.getItem('token')) { localStorage.setItem('token', 'test-token'); localStorage.setItem('userEmail', 'test@example.com'); } - return () => console.log('HomeSidebar卸载'); + return () => console.log('sidebar.unmounted'); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -148,7 +150,7 @@ export default function HomeSidebar({ } - name="帮助文档" + name={t('common.helpDocs')} /> { @@ -164,7 +166,7 @@ export default function HomeSidebar({ } - name="退出登录" + name={t('common.logout')} />
diff --git a/web/src/app/home/components/home-sidebar/sidbarConfigList.tsx b/web/src/app/home/components/home-sidebar/sidbarConfigList.tsx index fb343f55..c98c4956 100644 --- a/web/src/app/home/components/home-sidebar/sidbarConfigList.tsx +++ b/web/src/app/home/components/home-sidebar/sidbarConfigList.tsx @@ -1,10 +1,15 @@ import { SidebarChildVO } from '@/app/home/components/home-sidebar/HomeSidebarChild'; import styles from './HomeSidebar.module.css'; +import i18n from '@/i18n'; + +const t = (key: string) => { + return i18n.t(key); +}; export const sidebarConfigList = [ new SidebarChildVO({ id: 'bots', - name: '机器人', + name: t('bots.title'), icon: ( ), route: '/home/bots', - description: '创建和管理机器人,这是 LangBot 与各个平台连接的入口', + description: t('bots.description'), helpLink: 'https://docs.langbot.app/zh/deploy/platforms/readme.html', }), new SidebarChildVO({ id: 'models', - name: '模型配置', + name: t('models.title'), icon: ( ), route: '/home/models', - description: '配置和管理可在流水线中使用的模型', + description: t('models.description'), helpLink: 'https://docs.langbot.app/zh/deploy/models/readme.html', }), new SidebarChildVO({ id: 'pipelines', - name: '流水线', + name: t('pipelines.title'), icon: ( ), route: '/home/pipelines', - description: '流水线定义了对消息事件的处理流程,用于绑定到机器人', + description: t('pipelines.description'), helpLink: 'https://docs.langbot.app/zh/deploy/pipelines/readme.html', }), new SidebarChildVO({ id: 'plugins', - name: '插件管理', + name: t('plugins.title'), icon: ( ), route: '/home/plugins', - description: '安装和配置用于扩展 LangBot 功能的插件', + description: t('plugins.description'), helpLink: 'https://docs.langbot.app/zh/plugin/plugin-intro.html', }), ]; diff --git a/web/src/app/home/models/component/llm-card/LLMCard.tsx b/web/src/app/home/models/component/llm-card/LLMCard.tsx index bd7c2f5e..90dc3fab 100644 --- a/web/src/app/home/models/component/llm-card/LLMCard.tsx +++ b/web/src/app/home/models/component/llm-card/LLMCard.tsx @@ -1,7 +1,9 @@ import styles from './LLMCard.module.css'; import { LLMCardVO } from '@/app/home/models/component/llm-card/LLMCardVO'; +import { useTranslation } from 'react-i18next'; -function checkAbilityBadges(abilities: string[]) { +function AbilityBadges(abilities: string[]) { + const { t } = useTranslation(); const abilityBadges = { vision: (
@@ -13,7 +15,9 @@ function checkAbilityBadges(abilities: string[]) { > - 视觉能力 + + {t('models.visionAbility')} +
), func_call: ( @@ -26,7 +30,9 @@ function checkAbilityBadges(abilities: string[]) { > - 函数调用 + + {t('models.functionCallAbility')} + ), }; @@ -83,7 +89,7 @@ export default function LLMCard({ cardVO }: { cardVO: LLMCardVO }) { {/* 能力 */}
- {checkAbilityBadges(cardVO.abilities)} + {AbilityBadges(cardVO.abilities)}
diff --git a/web/src/app/home/models/component/llm-form/LLMForm.tsx b/web/src/app/home/models/component/llm-form/LLMForm.tsx index 7c52eb5a..9b7087d0 100644 --- a/web/src/app/home/models/component/llm-form/LLMForm.tsx +++ b/web/src/app/home/models/component/llm-form/LLMForm.tsx @@ -8,6 +8,7 @@ import { UUID } from 'uuidjs'; import { zodResolver } from '@hookform/resolvers/zod'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; +import { useTranslation } from 'react-i18next'; import { Dialog, @@ -38,41 +39,47 @@ import { } from '@/components/ui/select'; import { Checkbox } from '@/components/ui/checkbox'; import { toast } from 'sonner'; -const extraArgSchema = z - .object({ - key: z.string().min(1, { message: '键名不能为空' }), - type: z.enum(['string', 'number', 'boolean']), - value: z.string(), - }) - .superRefine((data, ctx) => { - if (data.type === 'number' && isNaN(Number(data.value))) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: '必须是有效的数字', - path: ['value'], - }); - } - if ( - data.type === 'boolean' && - data.value !== 'true' && - data.value !== 'false' - ) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: '必须是 true 或 false', - path: ['value'], - }); - } - }); +import { i18nObj } from '@/i18n/I18nProvider'; -const formSchema = z.object({ - name: z.string().min(1, { message: '模型名称不能为空' }), - model_provider: z.string().min(1, { message: '模型供应商不能为空' }), - url: z.string().min(1, { message: '请求URL不能为空' }), - api_key: z.string().min(1, { message: 'API Key不能为空' }), - abilities: z.array(z.string()), - extra_args: z.array(extraArgSchema).optional(), -}); +const getExtraArgSchema = (t: (key: string) => string) => + z + .object({ + key: z.string().min(1, { message: t('models.keyNameRequired') }), + type: z.enum(['string', 'number', 'boolean']), + value: z.string(), + }) + .superRefine((data, ctx) => { + if (data.type === 'number' && isNaN(Number(data.value))) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t('models.mustBeValidNumber'), + path: ['value'], + }); + } + if ( + data.type === 'boolean' && + data.value !== 'true' && + data.value !== 'false' + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t('models.mustBeTrueOrFalse'), + path: ['value'], + }); + } + }); + +const getFormSchema = (t: (key: string) => string) => + z.object({ + name: z.string().min(1, { message: t('models.modelNameRequired') }), + model_provider: z + .string() + .min(1, { message: t('models.modelProviderRequired') }), + url: z.string().min(1, { message: t('models.requestURLRequired') }), + api_key: z.string().min(1, { message: t('models.apiKeyRequired') }), + abilities: z.array(z.string()), + extra_args: z.array(getExtraArgSchema(t)).optional(), + }); export default function LLMForm({ editMode, @@ -87,6 +94,9 @@ export default function LLMForm({ onFormCancel: () => void; onLLMDeleted: () => void; }) { + const { t } = useTranslation(); + const formSchema = getFormSchema(t); + const form = useForm>({ resolver: zodResolver(formSchema), defaultValues: { @@ -106,11 +116,11 @@ export default function LLMForm({ const [showDeleteConfirmModal, setShowDeleteConfirmModal] = useState(false); const abilityOptions: { label: string; value: string }[] = [ { - label: '视觉能力', + label: t('models.visionAbility'), value: 'vision', }, { - label: '函数调用', + label: t('models.functionCallAbility'), value: 'func_call', }, ]; @@ -185,7 +195,7 @@ export default function LLMForm({ setRequesterNameList( requesterNameList.requesters.map((item) => { return { - label: item.label.zh_CN, + label: i18nObj(item.label), value: item.name, }; }), @@ -223,15 +233,17 @@ export default function LLMForm({ function handleFormSubmit(value: z.infer) { const extraArgsObj: Record = {}; - value.extra_args?.forEach((arg) => { - if (arg.type === 'number') { - extraArgsObj[arg.key] = Number(arg.value); - } else if (arg.type === 'boolean') { - extraArgsObj[arg.key] = arg.value === 'true'; - } else { - extraArgsObj[arg.key] = arg.value; - } - }); + value.extra_args?.forEach( + (arg: { key: string; type: string; value: string }) => { + if (arg.type === 'number') { + extraArgsObj[arg.key] = Number(arg.value); + } else if (arg.type === 'boolean') { + extraArgsObj[arg.key] = arg.value === 'true'; + } else { + extraArgsObj[arg.key] = arg.value; + } + }, + ); const llmModel: LLMModel = { uuid: editMode ? initLLMId || '' : UUID.generate(), @@ -262,9 +274,9 @@ export default function LLMForm({ try { await httpClient.createProviderLLMModel(llmModel); onFormSubmit(); - toast.success('创建成功'); + toast.success(t('models.createSuccess')); } catch (err) { - toast.error('创建失败:' + (err as Error).message); + toast.error(t('models.createError') + (err as Error).message); } } @@ -272,9 +284,9 @@ export default function LLMForm({ try { await httpClient.updateProviderLLMModel(initLLMId || '', llmModel); onFormSubmit(); - toast.success('保存成功'); + toast.success(t('models.saveSuccess')); } catch (err) { - toast.error('保存失败:' + (err as Error).message); + toast.error(t('models.saveError') + (err as Error).message); } } @@ -284,10 +296,10 @@ export default function LLMForm({ .deleteProviderLLMModel(initLLMId) .then(() => { onLLMDeleted(); - toast.success('删除成功'); + toast.success(t('models.deleteSuccess')); }) .catch((err) => { - toast.error('删除失败:' + err.message); + toast.error(t('models.deleteError') + err.message); }); } } @@ -300,15 +312,17 @@ export default function LLMForm({ > - 删除确认 + {t('common.confirmDelete')} - 你确定要删除这个模型吗? + + {t('models.deleteConfirmation')} + @@ -335,14 +349,15 @@ export default function LLMForm({ render={({ field }) => ( - 模型名称* + {t('models.modelName')} + * - 请填写供应商向您提供的模型名称 + {t('models.modelProviderDescription')} )} @@ -354,7 +369,8 @@ export default function LLMForm({ render={({ field }) => ( - 模型供应商* + {t('models.modelProvider')} + * @@ -409,7 +428,8 @@ export default function LLMForm({ render={({ field }) => ( - API Key* + {t('models.apiKey')} + * @@ -423,9 +443,11 @@ export default function LLMForm({ name="abilities" render={() => ( - 能力 + {t('models.abilities')}
- 选择模型能力 + + {t('models.selectModelAbilities')} +
{abilityOptions.map((item) => ( value !== item.value, + (value: string) => + value !== item.value, ), ); }} @@ -472,12 +495,12 @@ export default function LLMForm({ /> - 额外参数 + {t('models.extraParameters')}
{extraArgs.map((arg, index) => (
updateExtraArg(index, 'key', e.target.value) @@ -490,16 +513,22 @@ export default function LLMForm({ } > - + - 字符串 - 数字 - 布尔值 + + {t('models.string')} + + + {t('models.number')} + + + {t('models.boolean')} + updateExtraArg(index, 'value', e.target.value) @@ -522,11 +551,11 @@ export default function LLMForm({
))}
- 将在请求时附加到请求体中,如 max_tokens, temperature, top_p 等 + {t('models.extraParametersDescription')}
@@ -538,18 +567,20 @@ export default function LLMForm({ variant="destructive" onClick={() => setShowDeleteConfirmModal(true)} > - 删除 + {t('common.delete')} )} - + diff --git a/web/src/app/home/models/page.tsx b/web/src/app/home/models/page.tsx index 30fcb889..3ccec486 100644 --- a/web/src/app/home/models/page.tsx +++ b/web/src/app/home/models/page.tsx @@ -15,8 +15,11 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { toast } from 'sonner'; +import { useTranslation } from 'react-i18next'; +import { i18nObj } from '@/i18n/I18nProvider'; export default function LLMConfigPage() { + const { t } = useTranslation(); const [cardList, setCardList] = useState([]); const [modalOpen, setModalOpen] = useState(false); const [isEditForm, setIsEditForm] = useState(false); @@ -30,7 +33,7 @@ export default function LLMConfigPage() { const requesterNameListResp = await httpClient.getProviderRequesters(); const requesterNameList = requesterNameListResp.requesters.map((item) => { return { - label: item.label.zh_CN, + label: i18nObj(item.label), value: item.name, }; }); @@ -56,7 +59,7 @@ export default function LLMConfigPage() { }) .catch((err) => { console.error('get LLM model list error', err); - toast.error('获取模型列表失败:' + err.message); + toast.error(t('models.getModelListError') + err.message); }); } @@ -77,7 +80,9 @@ export default function LLMConfigPage() { - {isEditForm ? '预览模型' : '创建模型'} + + {isEditForm ? t('models.editModel') : t('models.createModel')} +
@@ -24,7 +26,8 @@ export default function PipelineCard({ cardVO }: { cardVO: PipelineCardVO }) {
- 更新于{cardVO.lastUpdatedTimeAgo} + {t('pipelines.updateTime')} + {cardVO.lastUpdatedTimeAgo}
@@ -40,7 +43,9 @@ export default function PipelineCard({ cardVO }: { cardVO: PipelineCardVO }) { > -
默认
+
+ {t('pipelines.defaultBadge')} +
)} diff --git a/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx b/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx index 6b73a9db..fe450c0a 100644 --- a/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx +++ b/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx @@ -30,6 +30,8 @@ import { DialogDescription, DialogFooter, } from '@/components/ui/dialog'; +import { useTranslation } from 'react-i18next'; +import { i18nObj } from '@/i18n/I18nProvider'; export default function PipelineFormComponent({ initValues, @@ -48,11 +50,14 @@ export default function PipelineFormComponent({ onFinish: () => void; onNewPipelineCreated: (pipelineId: string) => void; }) { + const { t } = useTranslation(); const formSchema = isEditMode ? z.object({ basic: z.object({ - name: z.string().min(1, { message: '名称不能为空' }), - description: z.string().min(1, { message: '描述不能为空' }), + name: z.string().min(1, { message: t('pipelines.nameRequired') }), + description: z + .string() + .min(1, { message: t('pipelines.descriptionRequired') }), }), ai: z.record(z.string(), z.any()), trigger: z.record(z.string(), z.any()), @@ -61,8 +66,10 @@ export default function PipelineFormComponent({ }) : z.object({ basic: z.object({ - name: z.string().min(1, { message: '名称不能为空' }), - description: z.string().min(1, { message: '描述不能为空' }), + name: z.string().min(1, { message: t('pipelines.nameRequired') }), + description: z + .string() + .min(1, { message: t('pipelines.descriptionRequired') }), }), ai: z.record(z.string(), z.any()).optional(), trigger: z.record(z.string(), z.any()).optional(), @@ -74,13 +81,13 @@ export default function PipelineFormComponent({ // 这里不好,可以改成enum等 const formLabelList: FormLabel[] = isEditMode ? [ - { label: '基础信息', name: 'basic' }, - { label: 'AI 能力', name: 'ai' }, - { label: '触发条件', name: 'trigger' }, - { label: '安全控制', name: 'safety' }, - { label: '输出处理', name: 'output' }, + { label: t('pipelines.basicInfo'), name: 'basic' }, + { label: t('pipelines.aiCapabilities'), name: 'ai' }, + { label: t('pipelines.triggerConditions'), name: 'trigger' }, + { label: t('pipelines.safetyControls'), name: 'safety' }, + { label: t('pipelines.outputProcessing'), name: 'output' }, ] - : [{ label: '基础信息', name: 'basic' }]; + : [{ label: t('pipelines.basicInfo'), name: 'basic' }]; const [aiConfigTabSchema, setAIConfigTabSchema] = useState(); @@ -156,10 +163,10 @@ export default function PipelineFormComponent({ .then((resp) => { onFinish(); onNewPipelineCreated(resp.uuid); - toast.success('创建成功 请编辑流水线详细参数'); + toast.success(t('pipelines.createSuccess')); }) .catch((err) => { - toast.error('创建失败:' + err.message); + toast.error(t('pipelines.createError') + err.message); }); } @@ -186,10 +193,10 @@ export default function PipelineFormComponent({ .updatePipeline(pipelineId || '', pipeline) .then(() => { onFinish(); - toast.success('保存成功'); + toast.success(t('pipelines.saveSuccess')); }) .catch((err) => { - toast.error('保存失败:' + err.message); + toast.error(t('pipelines.saveError') + err.message); }); } @@ -206,10 +213,10 @@ export default function PipelineFormComponent({ if (stage.name === 'runner') { return (
-
{stage.label.zh_CN}
+
{i18nObj(stage.label)}
{stage.description && (
- {stage.description.zh_CN} + {i18nObj(stage.description)}
)} -
{stage.label.zh_CN}
+
{i18nObj(stage.label)}
{stage.description && ( -
{stage.description.zh_CN}
+
+ {i18nObj(stage.description)} +
)} { onFinish(); - toast.success('删除成功'); + toast.success(t('common.deleteSuccess')); }) .catch((err) => { - toast.error('删除失败:' + err.message); + toast.error(t('common.deleteError') + err.message); }); } @@ -285,17 +294,17 @@ export default function PipelineFormComponent({ > - 删除确认 + {t('common.confirmDelete')} - 你确定要删除这个流水线吗?已绑定此流水线的机器人将无法使用。 + {t('pipelines.deleteConfirmation')} @@ -337,7 +346,8 @@ export default function PipelineFormComponent({ render={({ field }) => ( - 名称* + {t('common.name')} + * @@ -353,7 +363,8 @@ export default function PipelineFormComponent({ render={({ field }) => ( - 描述* + {t('common.description')} + * @@ -408,7 +419,7 @@ export default function PipelineFormComponent({
{isEditMode && isDefaultPipeline && ( - 默认流水线不可删除 + {t('pipelines.defaultPipelineCannotDelete')} )} @@ -421,12 +432,12 @@ export default function PipelineFormComponent({ }} className="cursor-pointer" > - 删除 + {t('common.delete')} )}
diff --git a/web/src/app/home/pipelines/page.tsx b/web/src/app/home/pipelines/page.tsx index 03031bdd..f12fad49 100644 --- a/web/src/app/home/pipelines/page.tsx +++ b/web/src/app/home/pipelines/page.tsx @@ -14,7 +14,9 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { toast } from 'sonner'; +import { useTranslation } from 'react-i18next'; export default function PluginConfigPage() { + const { t } = useTranslation(); const [modalOpen, setModalOpen] = useState(false); const [isEditForm, setIsEditForm] = useState(false); const [pipelineList, setPipelineList] = useState([]); @@ -53,7 +55,9 @@ export default function PluginConfigPage() { ); const lastUpdatedTimeAgoText = - lastUpdatedTimeAgo > 0 ? ` ${lastUpdatedTimeAgo} 天前` : '今天'; + lastUpdatedTimeAgo > 0 + ? ` ${lastUpdatedTimeAgo} ${t('pipelines.daysAgo')}` + : t('pipelines.today'); return new PipelineCardVO({ lastUpdatedTimeAgo: lastUpdatedTimeAgoText, @@ -67,7 +71,7 @@ export default function PluginConfigPage() { }) .catch((error) => { console.log(error); - toast.error('获取流水线列表失败:' + error.message); + toast.error(t('pipelines.getPipelineListError') + error.message); }); } @@ -94,7 +98,9 @@ export default function PluginConfigPage() { - {isEditForm ? '编辑流水线' : '创建流水线'} + {isEditForm + ? t('pipelines.editPipeline') + : t('pipelines.createPipeline')}
diff --git a/web/src/app/home/plugins/page.tsx b/web/src/app/home/plugins/page.tsx index e9be58c3..9935c0db 100644 --- a/web/src/app/home/plugins/page.tsx +++ b/web/src/app/home/plugins/page.tsx @@ -20,6 +20,7 @@ import { GithubIcon } from 'lucide-react'; import { useState, useRef } from 'react'; import { httpClient } from '@/app/infra/http/HttpClient'; import { toast } from 'sonner'; +import { useTranslation } from 'react-i18next'; enum PluginInstallStatus { WAIT_INPUT = 'wait_input', @@ -28,6 +29,7 @@ enum PluginInstallStatus { } export default function PluginConfigPage() { + const { t } = useTranslation(); const [modalOpen, setModalOpen] = useState(false); const [sortModalOpen, setSortModalOpen] = useState(false); const [pluginInstallStatus, setPluginInstallStatus] = @@ -61,7 +63,7 @@ export default function PluginConfigPage() { } else { // success if (!alreadySuccess) { - toast.success('插件安装成功'); + toast.success(t('plugins.installSuccess')); alreadySuccess = true; } setGithubURL(''); @@ -85,10 +87,10 @@ export default function PluginConfigPage() {
- 已安装 + {t('plugins.installed')} - 插件市场 + {t('plugins.marketplace')} @@ -100,7 +102,7 @@ export default function PluginConfigPage() { setSortModalOpen(true); }} > - 编排 + {t('plugins.arrange')}
@@ -136,14 +138,14 @@ export default function PluginConfigPage() { - 从 GitHub 安装插件 + {t('plugins.installFromGithub')} {pluginInstallStatus === PluginInstallStatus.WAIT_INPUT && (
-

目前仅支持从 GitHub 安装

+

{t('plugins.onlySupportGithub')}

setGithubURL(e.target.value)} className="mb-4" @@ -152,12 +154,12 @@ export default function PluginConfigPage() { )} {pluginInstallStatus === PluginInstallStatus.INSTALLING && (
-

正在安装插件...

+

{t('plugins.installing')}

)} {pluginInstallStatus === PluginInstallStatus.ERROR && (
-

插件安装失败:

+

{t('plugins.installFailed')}

{installError}

)} @@ -165,14 +167,16 @@ export default function PluginConfigPage() { {pluginInstallStatus === PluginInstallStatus.WAIT_INPUT && ( <> + - )} {pluginInstallStatus === PluginInstallStatus.ERROR && ( )} diff --git a/web/src/app/home/plugins/plugin-installed/PluginInstalledComponent.tsx b/web/src/app/home/plugins/plugin-installed/PluginInstalledComponent.tsx index 0918b7db..3e5dd9b5 100644 --- a/web/src/app/home/plugins/plugin-installed/PluginInstalledComponent.tsx +++ b/web/src/app/home/plugins/plugin-installed/PluginInstalledComponent.tsx @@ -12,6 +12,8 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; +import { useTranslation } from 'react-i18next'; +import { i18nObj } from '@/i18n/I18nProvider'; export interface PluginInstalledComponentRef { refreshPluginList: () => void; @@ -20,6 +22,7 @@ export interface PluginInstalledComponentRef { // eslint-disable-next-line react/display-name const PluginInstalledComponent = forwardRef( (props, ref) => { + const { t } = useTranslation(); const [pluginList, setPluginList] = useState([]); const [modalOpen, setModalOpen] = useState(false); const [selectedPlugin, setSelectedPlugin] = useState( @@ -41,7 +44,7 @@ const PluginInstalledComponent = forwardRef( value.plugins.map((plugin) => { return new PluginCardVO({ author: plugin.author, - description: plugin.description.zh_CN, + description: i18nObj(plugin.description), enabled: plugin.enabled, name: plugin.name, version: plugin.version, @@ -77,14 +80,14 @@ const PluginInstalledComponent = forwardRef( > -
暂未安装任何插件
+
{t('plugins.noPluginInstalled')}
) : (
- 插件配置 + {t('plugins.pluginConfig')}
{selectedPlugin && ( diff --git a/web/src/app/home/plugins/plugin-installed/plugin-form/PluginForm.tsx b/web/src/app/home/plugins/plugin-installed/plugin-form/PluginForm.tsx index 060afa06..beadb27a 100644 --- a/web/src/app/home/plugins/plugin-installed/plugin-form/PluginForm.tsx +++ b/web/src/app/home/plugins/plugin-installed/plugin-form/PluginForm.tsx @@ -12,6 +12,7 @@ import { DialogFooter, } from '@/components/ui/dialog'; import { toast } from 'sonner'; +import { i18nObj } from '@/i18n/I18nProvider'; enum PluginRemoveStatus { WAIT_INPUT = 'WAIT_INPUT', @@ -179,7 +180,7 @@ export default function PluginForm({
{pluginInfo.name}
- {pluginInfo.description.zh_CN} + {i18nObj(pluginInfo.description)}
{pluginInfo.config_schema.length > 0 && ( void; }) { + const { t } = useTranslation(); const [marketPluginList, setMarketPluginList] = useState< PluginMarketCardVO[] >([]); @@ -105,7 +107,7 @@ export default function PluginMarketComponent({ console.log('market plugins:', res); }) .catch((error) => { - console.error('获取插件列表失败:', error); + console.error(t('plugins.getPluginListError'), error); setLoading(false); }); } @@ -131,7 +133,7 @@ export default function PluginMarketComponent({ width: '300px', }} value={searchKeyword} - placeholder="搜索插件" + placeholder={t('plugins.searchPlugin')} onChange={(e) => onInputSearchKeyword(e.target.value)} /> @@ -140,12 +142,16 @@ export default function PluginMarketComponent({ onValueChange={handleSortChange} > - + - 最多星标 - 最近新增 - 最近更新 + {t('plugins.mostStars')} + + {t('plugins.recentlyAdded')} + + + {t('plugins.recentlyUpdated')} + @@ -221,11 +227,11 @@ export default function PluginMarketComponent({
{loading ? (
- {/* 加载中... */} + {t('plugins.loading')}
) : marketPluginList.length === 0 ? (
- {/* 没有找到匹配的插件 */} + {t('plugins.noMatchingPlugins')}
) : ( marketPluginList.map((vo, index) => ( diff --git a/web/src/app/home/plugins/plugin-sort/PluginSortDialog.tsx b/web/src/app/home/plugins/plugin-sort/PluginSortDialog.tsx index b712c374..ad6874eb 100644 --- a/web/src/app/home/plugins/plugin-sort/PluginSortDialog.tsx +++ b/web/src/app/home/plugins/plugin-sort/PluginSortDialog.tsx @@ -31,6 +31,8 @@ import { verticalListSortingStrategy, } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; +import { useTranslation } from 'react-i18next'; +import { i18nObj } from '@/i18n/I18nProvider'; interface PluginSortDialogProps { open: boolean; @@ -75,6 +77,7 @@ export default function PluginSortDialog({ onOpenChange, onSortComplete, }: PluginSortDialogProps) { + const { t } = useTranslation(); const [sortedPlugins, setSortedPlugins] = useState([]); const [isLoading, setIsLoading] = useState(false); @@ -84,7 +87,7 @@ export default function PluginSortDialog({ value.plugins.map((plugin) => { return new PluginCardVO({ author: plugin.author, - description: plugin.description.zh_CN, + description: i18nObj(plugin.description), enabled: plugin.enabled, name: plugin.name, version: plugin.version, @@ -146,12 +149,12 @@ export default function PluginSortDialog({ httpClient .reorderPlugins(reorderElements) .then(() => { - toast.success('插件排序成功'); + toast.success(t('plugins.pluginSortSuccess')); onSortComplete(); onOpenChange(false); }) .catch((err) => { - toast.error('排序失败:' + err.message); + toast.error(t('plugins.pluginSortError') + err.message); }) .finally(() => { setIsLoading(false); @@ -162,11 +165,11 @@ export default function PluginSortDialog({ - 插件排序 + {t('plugins.pluginSort')}

- 插件顺序会影响同一事件内的处理顺序,请拖动插件卡片排序 + {t('plugins.pluginSortDescription')}

onOpenChange(false)} disabled={isLoading} > - 取消 + {t('common.cancel')} diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 8aebbeb8..c7e9267f 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -1,6 +1,7 @@ import './global.css'; import type { Metadata } from 'next'; import { Toaster } from '@/components/ui/sonner'; +import I18nProvider from '@/i18n/I18nProvider'; export const metadata: Metadata = { title: 'LangBot', @@ -13,10 +14,12 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + - {children} - + + {children} + + ); diff --git a/web/src/app/login/page.tsx b/web/src/app/login/page.tsx index 8eb4dd54..5f0cf62c 100644 --- a/web/src/app/login/page.tsx +++ b/web/src/app/login/page.tsx @@ -8,6 +8,13 @@ import { CardTitle, CardDescription, } from '@/components/ui/card'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import * as z from 'zod'; @@ -19,23 +26,28 @@ import { FormLabel, FormMessage, } from '@/components/ui/form'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { httpClient } from '@/app/infra/http/HttpClient'; import { useRouter } from 'next/navigation'; -import { Mail, Lock } from 'lucide-react'; +import { Mail, Lock, Globe } from 'lucide-react'; import langbotIcon from '@/app/assets/langbot-logo.webp'; import { toast } from 'sonner'; +import { useTranslation } from 'react-i18next'; +import i18n from '@/i18n'; -const formSchema = z.object({ - email: z.string().email('请输入有效的邮箱地址'), - password: z.string().min(1, '请输入密码'), -}); +const formSchema = (t: (key: string) => string) => + z.object({ + email: z.string().email(t('common.invalidEmail')), + password: z.string().min(1, t('common.emptyPassword')), + }); export default function Login() { const router = useRouter(); + const { t } = useTranslation(); + const [currentLanguage, setCurrentLanguage] = useState(i18n.language); - const form = useForm>({ - resolver: zodResolver(formSchema), + const form = useForm>>({ + resolver: zodResolver(formSchema(t)), defaultValues: { email: '', password: '', @@ -43,10 +55,34 @@ export default function Login() { }); useEffect(() => { + judgeLanguage(); getIsInitialized(); checkIfAlreadyLoggedIn(); }, []); + const judgeLanguage = () => { + // here's for user have never set the language + // judge the language by the browser + const language = navigator.language; + if (language) { + let lang = 'zh-Hans'; + if (language === 'zh-CN') { + lang = 'zh-Hans'; + } else { + lang = 'en-US'; + } + i18n.changeLanguage(lang); + setCurrentLanguage(lang); + localStorage.setItem('langbot_language', lang); + } + }; + + const handleLanguageChange = (value: string) => { + i18n.changeLanguage(value); + setCurrentLanguage(value); + localStorage.setItem('langbot_language', value); + }; + function getIsInitialized() { httpClient .checkIfInited() @@ -73,7 +109,7 @@ export default function Login() { console.log('error at checkIfAlreadyLoggedIn: ', err); }); } - function onSubmit(values: z.infer) { + function onSubmit(values: z.infer>) { handleLogin(values.email, values.password); } @@ -85,28 +121,45 @@ export default function Login() { localStorage.setItem('userEmail', username); console.log('login success: ', res); router.push('/home'); - toast.success('登录成功'); + toast.success(t('common.loginSuccess')); }) .catch((err) => { console.log('login error: ', err); - toast.error('登录失败,请检查邮箱和密码是否正确'); + toast.error(t('common.loginFailed')); }); } return (
- + +
+ +
LangBot - 欢迎回到 LangBot 👋 + {t('common.welcome')} - 登录以继续 + + {t('common.continueToLogin')} +
@@ -116,12 +169,12 @@ export default function Login() { name="email" render={({ field }) => ( - 邮箱 + {t('common.email')}
@@ -137,13 +190,13 @@ export default function Login() { name="password" render={({ field }) => ( - 密码 + {t('common.password')}
@@ -155,7 +208,7 @@ export default function Login() { /> diff --git a/web/src/i18n/I18nProvider.tsx b/web/src/i18n/I18nProvider.tsx new file mode 100644 index 00000000..f00836d7 --- /dev/null +++ b/web/src/i18n/I18nProvider.tsx @@ -0,0 +1,20 @@ +'use client'; + +import { ReactNode } from 'react'; +import '@/i18n'; +import { I18nText } from '@/app/infra/entities/api'; + +interface I18nProviderProps { + children: ReactNode; +} + +export default function I18nProvider({ children }: I18nProviderProps) { + return <>{children}; +} +export function i18nObj(i18nText: I18nText): string { + const language = localStorage.getItem('langbot_language'); + if ((language === 'zh-Hans' && i18nText.zh_CN) || !i18nText.en_US) { + return i18nText.zh_CN; + } + return i18nText.en_US; +} diff --git a/web/src/i18n/index.ts b/web/src/i18n/index.ts new file mode 100644 index 00000000..a8e16152 --- /dev/null +++ b/web/src/i18n/index.ts @@ -0,0 +1,34 @@ +'use client'; + +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import LanguageDetector from 'i18next-browser-languagedetector'; + +import enUS from './locales/en-US'; +import zhHans from './locales/zh-Hans'; + +i18n + .use(LanguageDetector) + .use(initReactI18next) + .init({ + resources: { + 'en-US': { + translation: enUS, + }, + 'zh-Hans': { + translation: zhHans, + }, + }, + fallbackLng: 'zh-Hans', + debug: process.env.NODE_ENV === 'development', + interpolation: { + escapeValue: false, // React already escapes values + }, + detection: { + order: ['localStorage', 'navigator'], + lookupLocalStorage: 'langbot_language', + caches: ['localStorage'], + }, + }); + +export default i18n; diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts new file mode 100644 index 00000000..d7a50b35 --- /dev/null +++ b/web/src/i18n/locales/en-US.ts @@ -0,0 +1,174 @@ +const enUS = { + common: { + login: 'Login', + logout: 'Logout', + email: 'Email', + password: 'Password', + welcome: 'Welcome back to LangBot 👋', + continueToLogin: 'Login to continue', + loginSuccess: 'Login successful', + loginFailed: 'Login failed, please check your email and password', + enterEmail: 'Enter email address', + enterPassword: 'Enter password', + invalidEmail: 'Please enter a valid email address', + emptyPassword: 'Please enter your password', + language: 'Language', + helpDocs: 'Get Help', + create: 'Create', + edit: 'Edit', + delete: 'Delete', + add: 'Add', + select: 'Select', + cancel: 'Cancel', + submit: 'Submit', + error: 'Error', + success: 'Success', + save: 'Save', + saving: 'Saving...', + confirm: 'Confirm', + confirmDelete: 'Confirm Delete', + deleteConfirmation: 'Are you sure you want to delete this?', + selectOption: 'Select an option', + required: 'Required', + enable: 'Enable', + name: 'Name', + description: 'Description', + close: 'Close', + deleteSuccess: 'Deleted successfully', + deleteError: 'Delete failed: ', + addRound: 'Add Round', + }, + models: { + title: 'Models', + description: 'Configure and manage models that can be used in pipelines', + createModel: 'Create Model', + editModel: 'Edit Model', + getModelListError: 'Failed to get model list: ', + modelName: 'Model Name', + modelProvider: 'Model Provider', + modelBaseURL: 'Base URL', + modelAbilities: 'Model Abilities', + saveSuccess: 'Saved successfully', + saveError: 'Save failed: ', + createSuccess: 'Created successfully', + createError: 'Creation failed: ', + deleteSuccess: 'Deleted successfully', + deleteError: 'Delete failed: ', + deleteConfirmation: 'Are you sure you want to delete this model?', + modelNameRequired: 'Model name cannot be empty', + modelProviderRequired: 'Model provider cannot be empty', + requestURLRequired: 'Request URL cannot be empty', + apiKeyRequired: 'API Key cannot be empty', + keyNameRequired: 'Key name cannot be empty', + mustBeValidNumber: 'Must be a valid number', + mustBeTrueOrFalse: 'Must be true or false', + requestURL: 'Request URL', + apiKey: 'API Key', + abilities: 'Abilities', + selectModelAbilities: 'Select model abilities', + visionAbility: 'Vision Ability', + functionCallAbility: 'Function Call', + extraParameters: 'Extra Parameters', + addParameter: 'Add Parameter', + keyName: 'Key Name', + type: 'Type', + value: 'Value', + string: 'String', + number: 'Number', + boolean: 'Boolean', + extraParametersDescription: + 'Will be attached to the request body, such as max_tokens, temperature, top_p, etc.', + selectModelProvider: 'Select Model Provider', + modelProviderDescription: + 'Please fill in the model name provided by the supplier', + selectModel: 'Select Model', + }, + bots: { + title: 'Bots', + description: + 'Create and manage bots, which are the entry points for LangBot to connect with various platforms', + createBot: 'Create Bot', + editBot: 'Edit Bot', + getBotListError: 'Failed to get bot list: ', + botName: 'Bot Name', + botDescription: 'Bot Description', + botNameRequired: 'Bot name cannot be empty', + botDescriptionRequired: 'Bot description cannot be empty', + adapterRequired: 'Adapter cannot be empty', + defaultDescription: 'A bot', + getBotConfigError: 'Failed to get bot configuration: ', + saveSuccess: 'Saved successfully', + saveError: 'Save failed: ', + createSuccess: + 'Created successfully. Please enable or modify the bound pipeline', + createError: 'Creation failed: ', + deleteSuccess: 'Deleted successfully', + deleteError: 'Delete failed: ', + deleteConfirmation: 'Are you sure you want to delete this bot?', + platformAdapter: 'Platform/Adapter Selection', + selectAdapter: 'Select Adapter', + adapterConfig: 'Adapter Configuration', + bindPipeline: 'Bind Pipeline', + selectPipeline: 'Select Pipeline', + }, + plugins: { + title: 'Plugins', + description: + 'Install and configure plugins to extend LangBot functionality', + createPlugin: 'Create Plugin', + editPlugin: 'Edit Plugin', + installed: 'Installed', + marketplace: 'Marketplace', + arrange: 'Sort Plugins', + install: 'Install', + installFromGithub: 'Install Plugin from GitHub', + onlySupportGithub: 'Currently only supports installation from GitHub', + enterGithubLink: 'Enter GitHub link of the plugin', + installing: 'Installing plugin...', + installSuccess: 'Plugin installed successfully', + installFailed: 'Plugin installation failed:', + searchPlugin: 'Search plugins', + sortBy: 'Sort by', + mostStars: 'Most stars', + recentlyAdded: 'Recently added', + recentlyUpdated: 'Recently updated', + noMatchingPlugins: 'No matching plugins found', + loading: 'Loading...', + getPluginListError: 'Failed to get plugin list:', + noPluginInstalled: 'No plugins installed', + pluginConfig: 'Plugin Configuration', + pluginSort: 'Plugin Sort', + pluginSortDescription: + 'Plugin order affects the processing order within the same event, please drag the plugin card to sort', + pluginSortSuccess: 'Plugin sort successful', + pluginSortError: 'Plugin sort failed: ', + }, + pipelines: { + title: 'Pipelines', + description: + 'Pipelines define the processing flow for message events, used to bind to bots', + createPipeline: 'Create Pipeline', + editPipeline: 'Edit Pipeline', + getPipelineListError: 'Failed to get pipeline list: ', + daysAgo: 'days ago', + today: 'Today', + updateTime: 'Updated ', + defaultBadge: 'Default', + basicInfo: 'Basic', + aiCapabilities: 'AI', + triggerConditions: 'Trigger', + safetyControls: 'Safety', + outputProcessing: 'Output', + nameRequired: 'Name cannot be empty', + descriptionRequired: 'Description cannot be empty', + createSuccess: 'Created successfully. Please edit pipeline parameters', + createError: 'Creation failed: ', + saveSuccess: 'Saved successfully', + saveError: 'Save failed: ', + deleteConfirmation: + 'Are you sure you want to delete this pipeline? Bots bound to this pipeline will not work.', + defaultPipelineCannotDelete: 'Default pipeline cannot be deleted', + }, +}; + +export default enUS; diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts new file mode 100644 index 00000000..b368043f --- /dev/null +++ b/web/src/i18n/locales/zh-Hans.ts @@ -0,0 +1,169 @@ +const zhHans = { + common: { + login: '登录', + logout: '退出登录', + email: '邮箱', + password: '密码', + welcome: '欢迎回到 LangBot 👋', + continueToLogin: '登录以继续', + loginSuccess: '登录成功', + loginFailed: '登录失败,请检查邮箱和密码是否正确', + enterEmail: '输入邮箱地址', + enterPassword: '输入密码', + invalidEmail: '请输入有效的邮箱地址', + emptyPassword: '请输入密码', + language: '语言', + helpDocs: '帮助文档', + create: '创建', + edit: '编辑', + delete: '删除', + add: '添加', + select: '请选择', + cancel: '取消', + submit: '提交', + error: '错误', + success: '成功', + save: '保存', + saving: '保存中...', + confirm: '确认', + confirmDelete: '删除确认', + deleteConfirmation: '你确定要删除这个吗?', + selectOption: '选择一个选项', + required: '必填', + enable: '是否启用', + name: '名称', + description: '描述', + close: '关闭', + deleteSuccess: '删除成功', + deleteError: '删除失败:', + addRound: '添加回合', + }, + models: { + title: '模型配置', + description: '配置和管理可在流水线中使用的模型', + createModel: '创建模型', + editModel: '编辑模型', + getModelListError: '获取模型列表失败:', + modelName: '模型名称', + modelProvider: '模型提供商', + modelBaseURL: '基础 URL', + modelAbilities: '模型能力', + saveSuccess: '保存成功', + saveError: '保存失败:', + createSuccess: '创建成功', + createError: '创建失败:', + deleteSuccess: '删除成功', + deleteError: '删除失败:', + deleteConfirmation: '你确定要删除这个模型吗?', + modelNameRequired: '模型名称不能为空', + modelProviderRequired: '模型供应商不能为空', + requestURLRequired: '请求URL不能为空', + apiKeyRequired: 'API Key不能为空', + keyNameRequired: '键名不能为空', + mustBeValidNumber: '必须是有效的数字', + mustBeTrueOrFalse: '必须是 true 或 false', + requestURL: '请求URL', + apiKey: 'API Key', + abilities: '能力', + selectModelAbilities: '选择模型能力', + visionAbility: '视觉能力', + functionCallAbility: '函数调用', + extraParameters: '额外参数', + addParameter: '添加参数', + keyName: '键名', + type: '类型', + value: '值', + string: '字符串', + number: '数字', + boolean: '布尔值', + extraParametersDescription: + '将在请求时附加到请求体中,如 max_tokens, temperature, top_p 等', + selectModelProvider: '选择模型供应商', + modelProviderDescription: '请填写供应商向您提供的模型名称', + selectModel: '请选择模型', + }, + bots: { + title: '机器人', + description: '创建和管理机器人,这是 LangBot 与各个平台连接的入口', + createBot: '创建机器人', + editBot: '编辑机器人', + getBotListError: '获取机器人列表失败:', + botName: '机器人名称', + botDescription: '机器人描述', + botNameRequired: '机器人名称不能为空', + botDescriptionRequired: '机器人描述不能为空', + adapterRequired: '适配器不能为空', + defaultDescription: '一个机器人', + getBotConfigError: '获取机器人配置失败:', + saveSuccess: '保存成功', + saveError: '保存失败:', + createSuccess: '创建成功 请启用或修改绑定流水线', + createError: '创建失败:', + deleteSuccess: '删除成功', + deleteError: '删除失败:', + deleteConfirmation: '你确定要删除这个机器人吗?', + platformAdapter: '平台/适配器选择', + selectAdapter: '选择适配器', + adapterConfig: '适配器配置', + bindPipeline: '绑定流水线', + selectPipeline: '选择流水线', + }, + plugins: { + title: '插件管理', + description: '安装和配置用于扩展 LangBot 功能的插件', + createPlugin: '创建插件', + editPlugin: '编辑插件', + installed: '已安装', + marketplace: '插件市场', + arrange: '编排', + install: '安装', + installFromGithub: '从 GitHub 安装插件', + onlySupportGithub: '目前仅支持从 GitHub 安装', + enterGithubLink: '请输入插件的Github链接', + installing: '正在安装插件...', + installSuccess: '插件安装成功', + installFailed: '插件安装失败:', + searchPlugin: '搜索插件', + sortBy: '排序方式', + mostStars: '最多星标', + recentlyAdded: '最近新增', + recentlyUpdated: '最近更新', + noMatchingPlugins: '没有找到匹配的插件', + loading: '加载中...', + getPluginListError: '获取插件列表失败:', + pluginConfig: '插件配置', + noPluginInstalled: '暂未安装任何插件', + pluginSort: '插件排序', + pluginSortDescription: + '插件顺序会影响同一事件内的处理顺序,请拖动插件卡片排序', + pluginSortSuccess: '插件排序成功', + pluginSortError: '插件排序失败:', + }, + pipelines: { + title: '流水线', + description: '流水线定义了对消息事件的处理流程,用于绑定到机器人', + createPipeline: '创建流水线', + editPipeline: '编辑流水线', + getPipelineListError: '获取流水线列表失败:', + daysAgo: '天前', + today: '今天', + updateTime: '更新于', + defaultBadge: '默认', + basicInfo: '基础信息', + aiCapabilities: 'AI 能力', + triggerConditions: '触发条件', + safetyControls: '安全控制', + outputProcessing: '输出处理', + nameRequired: '名称不能为空', + descriptionRequired: '描述不能为空', + createSuccess: '创建成功 请编辑流水线详细参数', + createError: '创建失败:', + saveSuccess: '保存成功', + saveError: '保存失败:', + deleteConfirmation: + '你确定要删除这个流水线吗?已绑定此流水线的机器人将无法使用。', + defaultPipelineCannotDelete: '默认流水线不可删除', + }, +}; + +export default zhHans;