From 25ed2cc7c778fe988b4b648a938ff86ab1a48a2c Mon Sep 17 00:00:00 2001 From: josc146 Date: Wed, 29 Mar 2023 14:17:16 +0800 Subject: [PATCH] feat: Internationalization User Interface Support. PR Welcome! (#89, #39) --- build.mjs | 3 + package-lock.json | 51 ++++++++- package.json | 2 + src/_locales/en/main.json | 87 ++++++++++++++ src/_locales/i18n.mjs | 11 ++ src/_locales/resources.mjs | 15 +++ src/_locales/zh-hans/main.json | 87 ++++++++++++++ src/_locales/zh-hant/main.json | 87 ++++++++++++++ src/background/index.mjs | 12 +- src/components/ConversationCard/index.jsx | 36 +++--- src/components/ConversationItem/index.jsx | 48 ++++++-- src/components/CopyButton/index.jsx | 8 +- src/components/DecisionCard/index.jsx | 8 +- .../FeedbackForChatGPTWeb/index.jsx | 4 +- src/components/FloatingToolbar/index.jsx | 4 +- src/components/InputBox/index.jsx | 6 +- src/config/index.mjs | 16 ++- src/config/language.mjs | 5 + src/content-script/index.jsx | 20 +++- src/popup/Popup.jsx | 106 ++++++++++++------ src/popup/index.jsx | 1 + src/utils/is-firefox.mjs | 2 +- 22 files changed, 541 insertions(+), 78 deletions(-) create mode 100644 src/_locales/en/main.json create mode 100644 src/_locales/i18n.mjs create mode 100644 src/_locales/resources.mjs create mode 100644 src/_locales/zh-hans/main.json create mode 100644 src/_locales/zh-hant/main.json diff --git a/build.mjs b/build.mjs index 06611a4..ea4645c 100644 --- a/build.mjs +++ b/build.mjs @@ -38,7 +38,10 @@ async function runWebpack(isWithoutKatex, isWithoutTiktoken, callback) { '@primer/octicons-react', 'react-bootstrap-icons', 'countries-list', + 'i18next', + 'react-i18next', './src/utils', + './src/_locales/i18n', ], }, output: { diff --git a/package-lock.json b/package-lock.json index 36f72c3..77bcd0b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "file-saver": "^2.0.5", "github-markdown-css": "^5.2.0", "gpt-3-encoder": "^1.1.4", + "i18next": "^22.4.13", "katex": "^0.16.4", "lodash-es": "^4.17.21", "parse5": "^6.0.1", @@ -24,6 +25,7 @@ "react-bootstrap-icons": "^1.10.2", "react-dom": "npm:@preact/compat@^17.1.2", "react-draggable": "^4.4.5", + "react-i18next": "^12.2.0", "react-markdown": "^8.0.5", "react-tabs": "^4.2.1", "rehype-highlight": "^6.0.0", @@ -1666,7 +1668,6 @@ "version": "7.21.0", "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.21.0.tgz", "integrity": "sha512-xwII0//EObnq89Ji5AKYQaRYiW/nZ3llSv29d49IuxPhKbtJoLP+9QUUZ4nVragQVtaVGeZrpB+ZtG/Pdy/POw==", - "dev": true, "dependencies": { "regenerator-runtime": "^0.13.11" }, @@ -4766,6 +4767,14 @@ "node": ">=12" } }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "dependencies": { + "void-elements": "3.1.0" + } + }, "node_modules/html-void-elements": { "version": "2.0.1", "resolved": "https://registry.npmmirror.com/html-void-elements/-/html-void-elements-2.0.1.tgz", @@ -4798,6 +4807,14 @@ "node": ">= 6" } }, + "node_modules/i18next": { + "version": "22.4.13", + "resolved": "https://registry.npmmirror.com/i18next/-/i18next-22.4.13.tgz", + "integrity": "sha512-GX7flMHRRqQA0I1yGLmaZ4Hwt1JfLqagk8QPDPZsqekbKtXsuIngSVWM/s3SLgNkrEXjA+0sMGNuOEkkmyqmWg==", + "dependencies": { + "@babel/runtime": "^7.20.6" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -7576,6 +7593,27 @@ "react-dom": ">= 16.3.0" } }, + "node_modules/react-i18next": { + "version": "12.2.0", + "resolved": "https://registry.npmmirror.com/react-i18next/-/react-i18next-12.2.0.tgz", + "integrity": "sha512-5XeVgSygaGfyFmDd2WcXvINRw2WEC1XviW1LXY/xLOEMzsCFRwKqfnHN+hUjla8ZipbVJR27GCMSuTr0BhBBBQ==", + "dependencies": { + "@babel/runtime": "^7.20.6", + "html-parse-stringify": "^3.0.1" + }, + "peerDependencies": { + "i18next": ">= 19.0.0", + "react": ">= 16.8.0" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmmirror.com/react-is/-/react-is-16.13.1.tgz", @@ -7701,8 +7739,7 @@ "node_modules/regenerator-runtime": { "version": "0.13.11", "resolved": "https://registry.npmmirror.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", - "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", - "dev": true + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" }, "node_modules/regenerator-transform": { "version": "0.15.1", @@ -8900,6 +8937,14 @@ "unist-util-stringify-position": "^3.0.0" } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/w3c-xmlserializer": { "version": "4.0.0", "resolved": "https://registry.npmmirror.com/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", diff --git a/package.json b/package.json index 41d8248..1c7b54c 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "file-saver": "^2.0.5", "github-markdown-css": "^5.2.0", "gpt-3-encoder": "^1.1.4", + "i18next": "^22.4.13", "katex": "^0.16.4", "lodash-es": "^4.17.21", "parse5": "^6.0.1", @@ -37,6 +38,7 @@ "react-bootstrap-icons": "^1.10.2", "react-dom": "npm:@preact/compat@^17.1.2", "react-draggable": "^4.4.5", + "react-i18next": "^12.2.0", "react-markdown": "^8.0.5", "react-tabs": "^4.2.1", "rehype-highlight": "^6.0.0", diff --git a/src/_locales/en/main.json b/src/_locales/en/main.json new file mode 100644 index 0000000..529241e --- /dev/null +++ b/src/_locales/en/main.json @@ -0,0 +1,87 @@ +{ + "General": "General", + "Selection Tools": "Selection Tools", + "Sites": "Sites", + "Advanced": "Advanced", + "Donate": "Donate", + "Triggers": "Triggers", + "Theme": "Theme", + "API Mode": "API Mode", + "Get": "Get", + "Balance": "Balance", + "Preferred Language": "Preferred Language", + "Insert ChatGPT at the top of search results": "Insert ChatGPT at the top of search results", + "Lock scrollbar while answering": "Lock scrollbar while answering", + "Current Version": "Current Version", + "Latest": "Latest", + "Help | Changelog ": "Help | Changelog ", + "Custom ChatGPT Web API Url": "Custom ChatGPT Web API Url", + "Custom ChatGPT Web API Path": "Custom ChatGPT Web API Path", + "Custom OpenAI API Url": "Custom OpenAI API Url", + "Custom Site Regex:": "Custom Site Regex:", + "Exclusively use Custom Site Regex for website matching,": "Exclusively use Custom Site Regex for website matching,", + "ignoring built-in rules": "ignoring built-in rules", + "Input Query:": "Input Query:", + "Append Query:": "Append Query:", + "Prepend Query:": "Prepend Query:", + "Wechat Pay": "Wechat Pay", + "Type your question here\nEnter to send, shift + enter to break line": "Type your question here\nEnter to send, shift + enter to break line", + "Wait for the answer to finish and then continue here": "Wait for the answer to finish and then continue here", + "Ask ChatGPT": "Ask ChatGPT", + "No Input Found": "No Input Found", + "You:": "You:", + "Collapse": "Collapse", + "Expand": "Expand", + "Stop": "Stop", + "Continue on official website": "Continue on official website", + "Error:": "Error:", + "Copy": "Copy", + "Question:": "Question:", + "Answer:": "Answer:", + "Waiting for response...": "Waiting for response...", + "Close the Window": "Close the Window", + "Pin the Window": "Pin the Window", + "Float the Window": "Float the Window", + "Save Conversation": "Save Conversation", + "UNAUTHORIZED": "UNAUTHORIZED", + "Please login at https://chat.openai.com first": "Please login at https://chat.openai.com first", + "Then open https://chat.openai.com/api/auth/session": "Then open https://chat.openai.com/api/auth/session", + "And refresh this page or type you question again": "And refresh this page or type you question again", + "Consider creating an api key at https://platform.openai.com/account/api-keys": "Consider creating an api key at https://platform.openai.com/account/api-keys", + "OpenAI Security Check Required": "OpenAI Security Check Required", + "Please open https://chat.openai.com/api/auth/session": "Please open https://chat.openai.com/api/auth/session", + "Please open https://chat.openai.com": "Please open https://chat.openai.com", + "New Chat": "New Chat", + "Summarize Page": "Summarize Page", + "Translate": "Translate", + "Translate (Bidirectional)": "Translate (Bidirectional)", + "Summary": "Summary", + "Polish": "Polish", + "Sentiment Analysis": "Sentiment Analysis", + "Divide Paragraphs": "Divide Paragraphs", + "Code Explain": "Code Explain", + "Ask": "Ask", + "Always": "Always", + "Manually": "Manually", + "When query ends with question mark (?)": "When query ends with question mark (?)", + "Light": "Light", + "Dark": "Dark", + "Auto": "Auto", + "ChatGPT (Web)": "ChatGPT (Web)", + "ChatGPT (Web, GPT-4)": "ChatGPT (Web, GPT-4)", + "Bing (Web, GPT-4)": "Bing (Web, GPT-4)", + "ChatGPT (GPT-3.5-turbo)": "ChatGPT (GPT-3.5-turbo)", + "ChatGPT (GPT-4-8k)": "ChatGPT (GPT-4-8k)", + "ChatGPT (GPT-4-32k)": "ChatGPT (GPT-4-32k)", + "GPT-3.5": "GPT-3.5", + "Custom Model": "Custom Model", + "Balanced": "Balanced", + "Creative": "Creative", + "Precise": "Precise", + "Fast": "Fast", + "API Key": "API Key", + "Model Name": "Model Name", + "Custom Model API Url": "Custom Model API Url", + "Loading...": "Loading...", + "Feedback": "Feedback" +} diff --git a/src/_locales/i18n.mjs b/src/_locales/i18n.mjs new file mode 100644 index 0000000..871f918 --- /dev/null +++ b/src/_locales/i18n.mjs @@ -0,0 +1,11 @@ +import i18n from 'i18next' +import { initReactI18next } from 'react-i18next' +import { resources } from './resources' + +i18n.use(initReactI18next).init({ + resources, + fallbackLng: 'en', + interpolation: { + escapeValue: false, // not needed for react as it escapes by default + }, +}) diff --git a/src/_locales/resources.mjs b/src/_locales/resources.mjs new file mode 100644 index 0000000..52dc3eb --- /dev/null +++ b/src/_locales/resources.mjs @@ -0,0 +1,15 @@ +import en from './en/main.json' +import zhHans from './zh-hans/main.json' +import zhHant from './zh-hant/main.json' + +export const resources = { + en: { + translation: en, + }, + zh: { + translation: zhHans, + }, + zhHant: { + translation: zhHant, + }, +} diff --git a/src/_locales/zh-hans/main.json b/src/_locales/zh-hans/main.json new file mode 100644 index 0000000..d28cc7b --- /dev/null +++ b/src/_locales/zh-hans/main.json @@ -0,0 +1,87 @@ +{ + "General": "常规", + "Selection Tools": "选择浮动工具", + "Sites": "站点适配", + "Advanced": "高级", + "Donate": "打赏", + "Triggers": "触发方式", + "Theme": "主题", + "API Mode": "API模式", + "Get": "获取", + "Balance": "余额", + "Preferred Language": "语言偏好", + "Insert ChatGPT at the top of search results": "将对话卡片插入到搜索结果顶部", + "Lock scrollbar while answering": "回答时锁定滚动条", + "Current Version": "当前版本", + "Latest": "最新", + "Help | Changelog ": "帮助 | 更新日志 ", + "Custom ChatGPT Web API Url": "自定义的ChatGPT网页API地址", + "Custom ChatGPT Web API Path": "自定义的ChatGPT网页API路径", + "Custom OpenAI API Url": "自定义的OpenAI API地址", + "Custom Site Regex:": "自定义站点正则匹配:", + "Exclusively use Custom Site Regex for website matching,": "只使用自定义站点正则匹配,", + "ignoring built-in rules": "忽略内置站点规则", + "Input Query:": "输入的查询选择器:", + "Append Query:": "挂载到末尾的查询选择器:", + "Prepend Query:": "插入到开头的查询选择器:", + "Wechat Pay": "微信打赏", + "Type your question here\nEnter to send, shift + enter to break line": "在此输入你的问题\n回车 发送, shift+回车 换行", + "Wait for the answer to finish and then continue here": "等待回答完成, 然后在此继续", + "Ask ChatGPT": "询问ChatGPT", + "No Input Found": "无输入", + "You:": "你:", + "Collapse": "折叠", + "Expand": "展开", + "Stop": "停止", + "Continue on official website": "在官网继续", + "Error:": "错误:", + "Copy": "复制", + "Question:": "问题:", + "Answer:": "回答:", + "Waiting for response...": "等待响应...", + "Close the Window": "关闭窗口", + "Pin the Window": "固定窗口", + "Float the Window": "浮出/分裂窗口", + "Save Conversation": "保存对话", + "UNAUTHORIZED": "未授权", + "Please login at https://chat.openai.com first": "请先登录 https://chat.openai.com", + "Then open https://chat.openai.com/api/auth/session": "然后打开 https://chat.openai.com/api/auth/session", + "And refresh this page or type you question again": "之后刷新页面或重新输入你的问题", + "Consider creating an api key at https://platform.openai.com/account/api-keys": "考虑在 https://platform.openai.com/account/api-keys 创建一个API Key", + "OpenAI Security Check Required": "需要通过OpenAI的安全检查", + "Please open https://chat.openai.com/api/auth/session": "请打开 https://chat.openai.com/api/auth/session", + "Please open https://chat.openai.com": "请打开 https://chat.openai.com", + "New Chat": "新建聊天", + "Summarize Page": "总结本页", + "Translate": "翻译", + "Translate (Bidirectional)": "双向翻译", + "Summary": "总结", + "Polish": "润色", + "Sentiment Analysis": "情感分析", + "Divide Paragraphs": "段落划分", + "Code Explain": "代码解释", + "Ask": "询问", + "Always": "自动触发", + "Manually": "手动触发", + "When query ends with question mark (?)": "问题以问号结尾时触发", + "Light": "浅色", + "Dark": "深色", + "Auto": "自动", + "ChatGPT (Web)": "ChatGPT (网页版)", + "ChatGPT (Web, GPT-4)": "ChatGPT (网页版, GPT-4)", + "Bing (Web, GPT-4)": "Bing (网页版, GPT-4)", + "ChatGPT (GPT-3.5-turbo)": "ChatGPT (GPT-3.5-turbo)", + "ChatGPT (GPT-4-8k)": "ChatGPT (GPT-4-8k)", + "ChatGPT (GPT-4-32k)": "ChatGPT (GPT-4-32k)", + "GPT-3.5": "GPT-3.5", + "Custom Model": "自定义模型", + "Balanced": "平衡", + "Creative": "有创造力", + "Precise": "精确", + "Fast": "快速", + "API Key": "API Key", + "Model Name": "模型名", + "Custom Model API Url": "自定义模型的API地址", + "Loading...": "正在读取...", + "Feedback": "反馈" +} diff --git a/src/_locales/zh-hant/main.json b/src/_locales/zh-hant/main.json new file mode 100644 index 0000000..658d997 --- /dev/null +++ b/src/_locales/zh-hant/main.json @@ -0,0 +1,87 @@ +{ + "General": "常規", + "Selection Tools": "選擇浮動工具", + "Sites": "站點適配", + "Advanced": "高級", + "Donate": "打賞", + "Triggers": "觸發方式", + "Theme": "主題", + "API Mode": "API模式", + "Get": "獲取", + "Balance": "余額", + "Preferred Language": "語言偏好", + "Insert ChatGPT at the top of search results": "將對話卡片插入到搜索結果頂部", + "Lock scrollbar while answering": "回答時鎖定滾動條", + "Current Version": "當前版本", + "Latest": "最新", + "Help | Changelog ": "幫助 | 更新日誌 ", + "Custom ChatGPT Web API Url": "自定義的ChatGPT網頁API地址", + "Custom ChatGPT Web API Path": "自定義的ChatGPT網頁API路徑", + "Custom OpenAI API Url": "自定義的OpenAI API地址", + "Custom Site Regex:": "自定義站點正則匹配:", + "Exclusively use Custom Site Regex for website matching,": "只使用自定義站點正則匹配,", + "ignoring built-in rules": "忽略內置站點規則", + "Input Query:": "輸入的查詢選擇器:", + "Append Query:": "掛載到末尾的查詢選擇器:", + "Prepend Query:": "插入到開頭的查詢選擇器:", + "Wechat Pay": "微信打賞", + "Type your question here\nEnter to send, shift + enter to break line": "在此輸入你的問題\n回車 發送, shift+回車 換行", + "Wait for the answer to finish and then continue here": "等待回答完成, 然後在此繼續", + "Ask ChatGPT": "詢問ChatGPT", + "No Input Found": "無輸入", + "You:": "你:", + "Collapse": "折疊", + "Expand": "展開", + "Stop": "停止", + "Continue on official website": "在官網繼續", + "Error:": "錯誤:", + "Copy": "復製", + "Question:": "問題:", + "Answer:": "回答:", + "Waiting for response...": "等待響應...", + "Close the Window": "關閉窗口", + "Pin the Window": "固定窗口", + "Float the Window": "浮出/分裂窗口", + "Save Conversation": "保存對話", + "UNAUTHORIZED": "未授權", + "Please login at https://chat.openai.com first": "請先登錄 https://chat.openai.com", + "Then open https://chat.openai.com/api/auth/session": "然後打開 https://chat.openai.com/api/auth/session", + "And refresh this page or type you question again": "之後刷新頁面或重新輸入你的問題", + "Consider creating an api key at https://platform.openai.com/account/api-keys": "考慮在 https://platform.openai.com/account/api-keys 創建一個API Key", + "OpenAI Security Check Required": "需要通過OpenAI的安全檢查", + "Please open https://chat.openai.com/api/auth/session": "請打開 https://chat.openai.com/api/auth/session", + "Please open https://chat.openai.com": "請打開 https://chat.openai.com", + "New Chat": "新建聊天", + "Summarize Page": "總結本頁", + "Translate": "翻譯", + "Translate (Bidirectional)": "雙向翻譯", + "Summary": "總結", + "Polish": "潤色", + "Sentiment Analysis": "情感分析", + "Divide Paragraphs": "段落劃分", + "Code Explain": "代碼解釋", + "Ask": "詢問", + "Always": "自動觸發", + "Manually": "手動觸發", + "When query ends with question mark (?)": "問題以問號結尾時觸發", + "Light": "淺色", + "Dark": "深色", + "Auto": "自動", + "ChatGPT (Web)": "ChatGPT (網頁版)", + "ChatGPT (Web, GPT-4)": "ChatGPT (網頁版, GPT-4)", + "Bing (Web, GPT-4)": "Bing (網頁版, GPT-4)", + "ChatGPT (GPT-3.5-turbo)": "ChatGPT (GPT-3.5-turbo)", + "ChatGPT (GPT-4-8k)": "ChatGPT (GPT-4-8k)", + "ChatGPT (GPT-4-32k)": "ChatGPT (GPT-4-32k)", + "GPT-3.5": "GPT-3.5", + "Custom Model": "自定義模型", + "Balanced": "平衡", + "Creative": "有創造力", + "Precise": "精確", + "Fast": "快速", + "API Key": "API Key", + "Model Name": "模型名", + "Custom Model API Url": "自定義模型的API地址", + "Loading...": "正在讀取...", + "Feedback": "反饋" +} diff --git a/src/background/index.mjs b/src/background/index.mjs index 758ee23..8a9e119 100644 --- a/src/background/index.mjs +++ b/src/background/index.mjs @@ -13,6 +13,7 @@ import { chatgptWebModelKeys, customApiModelKeys, defaultConfig, + getPreferredLanguageKey, getUserConfig, gptApiModelKeys, isUsingCustomModel, @@ -20,6 +21,8 @@ import { } from '../config/index.mjs' import { isSafari } from '../utils/is-safari' import { config as menuConfig } from '../content-script/menu-tools' +import { t, changeLanguage } from 'i18next' +import '../_locales/i18n' const KEY_ACCESS_TOKEN = 'accessToken' const cache = new ExpiryMap(10 * 1000) @@ -140,7 +143,10 @@ Browser.commands.onCommand.addListener(async (command) => { }) function refreshMenu() { - Browser.contextMenus.removeAll().then(() => { + Browser.contextMenus.removeAll().then(async () => { + await getPreferredLanguageKey().then((lang) => { + changeLanguage(lang) + }) const menuId = 'ChatGPTBox-Menu' Browser.contextMenus.create({ id: menuId, @@ -152,7 +158,7 @@ function refreshMenu() { Browser.contextMenus.create({ id: menuId + k, parentId: menuId, - title: v.label, + title: t(v.label), contexts: ['all'], }) } @@ -168,7 +174,7 @@ function refreshMenu() { Browser.contextMenus.create({ id: menuId + key, parentId: menuId, - title: desc, + title: t(desc), contexts: ['selection'], }) } diff --git a/src/components/ConversationCard/index.jsx b/src/components/ConversationCard/index.jsx index fd4b5a9..31ce6a2 100644 --- a/src/components/ConversationCard/index.jsx +++ b/src/components/ConversationCard/index.jsx @@ -11,6 +11,7 @@ import { render } from 'preact' import FloatingToolbar from '../FloatingToolbar' import { useClampWindowSize } from '../../hooks/use-clamp-window-size' import { defaultConfig, getUserConfig } from '../../config/index.mjs' +import { useTranslation } from 'react-i18next' const logo = Browser.runtime.getURL('logo.png') @@ -29,6 +30,7 @@ class ConversationItemData extends Object { } function ConversationCard(props) { + const { t } = useTranslation() const [isReady, setIsReady] = useState(!props.question) const [port, setPort] = useState(() => Browser.runtime.connect()) const [session, setSession] = useState(props.session) @@ -44,7 +46,7 @@ function ConversationCard(props) { return [ new ConversationItemData( 'answer', - '

Waiting for response...

', + `

${t(`Waiting for response...`)}

`, ), ] else return [] @@ -131,20 +133,26 @@ function ConversationCard(props) { switch (msg.error) { case 'UNAUTHORIZED': UpdateAnswer( - `UNAUTHORIZED
Please login at https://chat.openai.com first${ - isSafari() ? '
Then open https://chat.openai.com/api/auth/session' : '' - }
And refresh this page or type you question again` + - `

Consider creating an api key at https://platform.openai.com/account/api-keys
`, + `${t('UNAUTHORIZED')}
${t('Please login at https://chat.openai.com first')}${ + isSafari() ? `
${t('Then open https://chat.openai.com/api/auth/session')}` : '' + }
${t('And refresh this page or type you question again')}` + + `

${t( + 'Consider creating an api key at https://platform.openai.com/account/api-keys', + )}
`, false, 'error', ) break case 'CLOUDFLARE': UpdateAnswer( - `OpenAI Security Check Required
Please open ${ - isSafari() ? 'https://chat.openai.com/api/auth/session' : 'https://chat.openai.com' - }
And refresh this page or type you question again` + - `

Consider creating an api key at https://platform.openai.com/account/api-keys
`, + `${t('OpenAI Security Check Required')}
${ + isSafari() + ? t('Please open https://chat.openai.com/api/auth/session') + : t('Please open https://chat.openai.com') + }
${t('And refresh this page or type you question again')}` + + `

${t( + 'Consider creating an api key at https://platform.openai.com/account/api-keys', + )}
`, false, 'error', ) @@ -172,7 +180,7 @@ function ConversationCard(props) { { if (props.onClose) props.onClose() @@ -182,7 +190,7 @@ function ConversationCard(props) { { if (props.onDock) props.onDock() @@ -196,7 +204,7 @@ function ConversationCard(props) { ) : ( { const position = { x: window.innerWidth / 2 - 300, y: window.innerHeight / 2 - 200 } @@ -216,7 +224,7 @@ function ConversationCard(props) { /> )} { @@ -254,7 +262,7 @@ function ConversationCard(props) { const newQuestion = new ConversationItemData('question', question + '\n
') const newAnswer = new ConversationItemData( 'answer', - '

Waiting for response...

', + `

${t('Waiting for response...')}

`, ) setConversationItemData([...conversationItemData, newQuestion, newAnswer]) setIsReady(false) diff --git a/src/components/ConversationItem/index.jsx b/src/components/ConversationItem/index.jsx index 7bfd5bd..384f237 100644 --- a/src/components/ConversationItem/index.jsx +++ b/src/components/ConversationItem/index.jsx @@ -4,8 +4,10 @@ import { ChevronDownIcon, LinkExternalIcon, XCircleIcon } from '@primer/octicons import CopyButton from '../CopyButton' import PropTypes from 'prop-types' import MarkdownRender from '../MarkdownRender/markdown.jsx' +import { useTranslation } from 'react-i18next' export function ConversationItem({ type, content, session, done, port }) { + const { t } = useTranslation() const [collapsed, setCollapsed] = useState(false) switch (type) { @@ -13,15 +15,23 @@ export function ConversationItem({ type, content, session, done, port }) { return (
-

You:

+

{t('You:')}

content} size={14} /> {!collapsed ? ( - setCollapsed(true)}> + setCollapsed(true)} + > ) : ( - setCollapsed(false)}> + setCollapsed(false)} + > )} @@ -35,7 +45,7 @@ export function ConversationItem({ type, content, session, done, port }) {

- {session && session.aiName ? `${session.aiName}:` : 'Loading...'} + {session && session.aiName ? `${t(session.aiName)}:` : t('Loading...')}

{!done && ( @@ -46,7 +56,7 @@ export function ConversationItem({ type, content, session, done, port }) { port.postMessage({ stop: true }) }} > - Stop + {t('Stop')} )} {done && session && session.conversationId && ( @@ -57,7 +67,7 @@ export function ConversationItem({ type, content, session, done, port }) { )} {session && session.conversationId && ( content} size={14} />} {!collapsed ? ( - setCollapsed(true)}> + setCollapsed(true)} + > ) : ( - setCollapsed(false)}> + setCollapsed(false)} + > )} @@ -86,15 +104,23 @@ export function ConversationItem({ type, content, session, done, port }) { return (
-

Error:

+

{t('Error:')}

content} size={14} /> {!collapsed ? ( - setCollapsed(true)}> + setCollapsed(true)} + > ) : ( - setCollapsed(false)}> + setCollapsed(false)} + > )} diff --git a/src/components/CopyButton/index.jsx b/src/components/CopyButton/index.jsx index 2acf22e..4e4dc0a 100644 --- a/src/components/CopyButton/index.jsx +++ b/src/components/CopyButton/index.jsx @@ -1,6 +1,7 @@ import { useState } from 'react' import { CheckIcon, CopyIcon } from '@primer/octicons-react' import PropTypes from 'prop-types' +import { useTranslation } from 'react-i18next' CopyButton.propTypes = { contentFn: PropTypes.func.isRequired, @@ -9,6 +10,7 @@ CopyButton.propTypes = { } function CopyButton({ className, contentFn, size }) { + const { t } = useTranslation() const [copied, setCopied] = useState(false) const onClick = () => { @@ -23,7 +25,11 @@ function CopyButton({ className, contentFn, size }) { } return ( - + {copied ? : } ) diff --git a/src/components/DecisionCard/index.jsx b/src/components/DecisionCard/index.jsx index cd2dd46..07f520d 100644 --- a/src/components/DecisionCard/index.jsx +++ b/src/components/DecisionCard/index.jsx @@ -5,8 +5,10 @@ import ConversationCard from '../ConversationCard' import { defaultConfig, getUserConfig } from '../../config/index.mjs' import Browser from 'webextension-polyfill' import { getPossibleElementByQuerySelector, endsWithQuestionMark } from '../../utils' +import { useTranslation } from 'react-i18next' function DecisionCard(props) { + const { t } = useTranslation() const [triggered, setTriggered] = useState(false) const [config, setConfig] = useState(defaultConfig) const [render, setRender] = useState(false) @@ -103,7 +105,7 @@ function DecisionCard(props) { className="gpt-inner manual-btn icon-and-text" onClick={() => setTriggered(true)} > - Ask ChatGPT + {t('Ask ChatGPT')}

) case 'questionMark': @@ -118,14 +120,14 @@ function DecisionCard(props) { className="gpt-inner manual-btn icon-and-text" onClick={() => setTriggered(true)} > - Ask ChatGPT + {t('Ask ChatGPT')}

) } else return (

- No Input Found + {t('No Input Found')}

) })()} diff --git a/src/components/FeedbackForChatGPTWeb/index.jsx b/src/components/FeedbackForChatGPTWeb/index.jsx index 8b50cef..2a9d19d 100644 --- a/src/components/FeedbackForChatGPTWeb/index.jsx +++ b/src/components/FeedbackForChatGPTWeb/index.jsx @@ -2,8 +2,10 @@ import PropTypes from 'prop-types' import { memo, useCallback, useState } from 'react' import { ThumbsupIcon, ThumbsdownIcon } from '@primer/octicons-react' import Browser from 'webextension-polyfill' +import { useTranslation } from 'react-i18next' const FeedbackForChatGPTWeb = (props) => { + const { t } = useTranslation() const [action, setAction] = useState(null) const clickThumbsUp = useCallback(async () => { @@ -39,7 +41,7 @@ const FeedbackForChatGPTWeb = (props) => { }, [props, action]) return ( -
+
{ const p = getClientPosition(props.container) props.container.style.position = 'fixed' diff --git a/src/components/InputBox/index.jsx b/src/components/InputBox/index.jsx index 79b5fdc..b49c791 100644 --- a/src/components/InputBox/index.jsx +++ b/src/components/InputBox/index.jsx @@ -1,8 +1,10 @@ import { useEffect, useRef, useState } from 'react' import PropTypes from 'prop-types' import { updateRefHeight } from '../../utils' +import { useTranslation } from 'react-i18next' export function InputBox({ onSubmit, enabled }) { + const { t } = useTranslation() const [value, setValue] = useState('') const inputRef = useRef(null) @@ -28,8 +30,8 @@ export function InputBox({ onSubmit, enabled }) { className="interact-input" placeholder={ enabled - ? 'Type your question here\nEnter to send, shift + enter to break line' - : 'Wait for the answer to finish and then continue here' + ? t('Type your question here\nEnter to send, shift + enter to break line') + : t('Wait for the answer to finish and then continue here') } value={value} onChange={(e) => setValue(e.target.value)} diff --git a/src/config/index.mjs b/src/config/index.mjs index 30cbd0d..383090e 100644 --- a/src/config/index.mjs +++ b/src/config/index.mjs @@ -63,7 +63,7 @@ export const defaultConfig = { apiKey: '', /** @type {keyof ModelMode}*/ modelMode: 'balanced', - preferredLanguage: navigator.language.substring(0, 2), + preferredLanguage: getNavigatorLanguage(), insertAtTop: isMobile(), lockWhenAnswer: false, customModelApiUrl: 'http://localhost:8000/chat/completions', @@ -98,7 +98,7 @@ export const defaultConfig = { // unchangeable - userLanguage: navigator.language.substring(0, 2), + userLanguage: getNavigatorLanguage(), selectionTools: [ 'translate', 'translateBidi', @@ -132,6 +132,12 @@ export const defaultConfig = { ], } +export function getNavigatorLanguage() { + const l = navigator.language.toLowerCase() + if (['zh-hk', 'zh-mo', 'zh-tw', 'zh-cht', 'zh-hant'].includes(l)) return 'zhHant' + return navigator.language.substring(0, 2) +} + export function isUsingApiKey(config) { return ( gptApiModelKeys.includes(config.modelName) || chatgptApiModelKeys.includes(config.modelName) @@ -146,6 +152,12 @@ export function isUsingCustomModel(config) { return customApiModelKeys.includes(config.modelName) } +export async function getPreferredLanguageKey() { + const config = await getUserConfig() + if (config.preferredLanguage === 'auto') return config.userLanguage + return config.preferredLanguage +} + /** * get user config from local storage * @returns {Promise} diff --git a/src/config/language.mjs b/src/config/language.mjs index 8e9d03c..f39e053 100644 --- a/src/config/language.mjs +++ b/src/config/language.mjs @@ -2,6 +2,11 @@ import { languages } from 'countries-list' import { defaultConfig, getUserConfig } from './index.mjs' export const languageList = { auto: { name: 'Auto', native: 'Auto' }, ...languages } +languageList.zh.name = 'Chinese (Simplified)' +languageList.zh.native = '简体中文' +languageList.zhHant = { ...languageList.zh } +languageList.zhHant.name = 'Chinese (Traditional)' +languageList.zhHant.native = '正體中文' export async function getUserLanguage() { return languageList[defaultConfig.userLanguage].name diff --git a/src/content-script/index.jsx b/src/content-script/index.jsx index 4204611..09c6356 100644 --- a/src/content-script/index.jsx +++ b/src/content-script/index.jsx @@ -5,7 +5,12 @@ import DecisionCard from '../components/DecisionCard' import { config as siteConfig } from './site-adapters' import { config as toolsConfig } from './selection-tools' import { config as menuConfig } from './menu-tools' -import { clearOldAccessToken, getUserConfig, setAccessToken } from '../config/index.mjs' +import { + clearOldAccessToken, + getPreferredLanguageKey, + getUserConfig, + setAccessToken, +} from '../config/index.mjs' import { createElementAtPosition, cropText, @@ -17,6 +22,8 @@ import { import FloatingToolbar from '../components/FloatingToolbar' import Browser from 'webextension-polyfill' import { getPreferredLanguage } from '../config/language.mjs' +import '../_locales/i18n' +import { changeLanguage } from 'i18next' /** * @param {SiteConfig} siteConfig @@ -287,6 +294,17 @@ let userConfig async function run() { userConfig = await getUserConfig() + await getPreferredLanguageKey().then((lang) => { + changeLanguage(lang) + }) + Browser.runtime.onMessage.addListener(async (message) => { + console.log(message) + if (message.type === 'CHANGE_LANG') { + const data = message.data + changeLanguage(data.lang) + } + }) + if (isSafari()) await prepareForSafari() prepareForSelectionTools() prepareForSelectionToolsTouch() diff --git a/src/popup/Popup.jsx b/src/popup/Popup.jsx index e61a40b..f13d60a 100644 --- a/src/popup/Popup.jsx +++ b/src/popup/Popup.jsx @@ -2,6 +2,7 @@ import '@picocss/pico' import { useEffect, useState } from 'react' import { defaultConfig, + getPreferredLanguageKey, getUserConfig, isUsingApiKey, isUsingCustomModel, @@ -24,8 +25,10 @@ import bugmeacoffee from './donation/bugmeacoffee.png' import { useWindowTheme } from '../hooks/use-window-theme.mjs' import { languageList } from '../config/language.mjs' import { isFirefox, isSafari } from '../utils/index.mjs' +import { useTranslation } from 'react-i18next' function GeneralPart({ config, updateConfig }) { + const { t, i18n } = useTranslation() const [balance, setBalance] = useState(null) const getBalance = async () => { @@ -41,7 +44,7 @@ function GeneralPart({ config, updateConfig }) { return ( <> ) : balance ? ( ) : ( )} @@ -153,7 +158,7 @@ function GeneralPart({ config, updateConfig }) { { const customModelName = e.target.value updateConfig({ customModelName: customModelName }) @@ -166,7 +171,7 @@ function GeneralPart({ config, updateConfig }) { { const value = e.target.value updateConfig({ customModelApiUrl: value }) @@ -175,13 +180,31 @@ function GeneralPart({ config, updateConfig }) { )}
@@ -227,10 +250,12 @@ GeneralPart.propTypes = { } function AdvancedPart({ config, updateConfig }) { + const { t } = useTranslation() + return ( <>
))} @@ -383,6 +411,8 @@ SiteAdapters.propTypes = { } function Donation() { + const { t } = useTranslation() + return ( @@ -403,15 +433,17 @@ function Donation() { // eslint-disable-next-line react/prop-types function Footer({ currentVersion, latestVersion }) { + const { t } = useTranslation() + return (
@@ -438,6 +470,7 @@ function Footer({ currentVersion, latestVersion }) { } function Popup() { + const { t, i18n } = useTranslation() const [config, setConfig] = useState(defaultConfig) const [currentVersion, setCurrentVersion] = useState('') const [latestVersion, setLatestVersion] = useState('') @@ -449,6 +482,9 @@ function Popup() { } useEffect(() => { + getPreferredLanguageKey().then((lang) => { + i18n.changeLanguage(lang) + }) getUserConfig().then((config) => { setConfig(config) setCurrentVersion(Browser.runtime.getManifest().version.replace('v', '')) @@ -469,11 +505,11 @@ function Popup() {
- General - Selection Tools - Sites - Advanced - {isSafari() ? null : Donate} + {t('General')} + {t('Selection Tools')} + {t('Sites')} + {t('Advanced')} + {isSafari() ? null : {t('Donate')}} diff --git a/src/popup/index.jsx b/src/popup/index.jsx index 6f145c2..8d6d401 100644 --- a/src/popup/index.jsx +++ b/src/popup/index.jsx @@ -1,4 +1,5 @@ import { render } from 'preact' import Popup from './Popup' +import '../_locales/i18n' render(, document.getElementById('app')) diff --git a/src/utils/is-firefox.mjs b/src/utils/is-firefox.mjs index cea42bc..0382e4a 100644 --- a/src/utils/is-firefox.mjs +++ b/src/utils/is-firefox.mjs @@ -1,3 +1,3 @@ export function isFirefox() { - return navigator.userAgent.includes('Firefox') + return navigator.userAgent.toLowerCase().includes('firefox') }