Browse Source

feat: support i18n

Yifei Zhang 2 years ago
parent
commit
7cd170b933
7 changed files with 235 additions and 52 deletions
  1. 16 15
      app/components/home.tsx
  2. 34 27
      app/components/settings.tsx
  3. 71 0
      app/locales/cn.ts
  4. 70 0
      app/locales/en.ts
  5. 30 0
      app/locales/index.ts
  6. 10 8
      app/store.ts
  7. 4 2
      app/utils.ts

+ 16 - 15
app/components/home.tsx

@@ -23,6 +23,7 @@ import DownloadIcon from "../icons/download.svg";
 import { Message, SubmitKey, useChatStore, ChatSession } from "../store";
 import { showModal } from "./ui-lib";
 import { copyToClipboard, downloadAs, isIOS } from "../utils";
+import Locale from '../locales'
 
 import dynamic from "next/dynamic";
 
@@ -77,7 +78,7 @@ export function ChatItem(props: {
     >
       <div className={styles["chat-item-title"]}>{props.title}</div>
       <div className={styles["chat-item-info"]}>
-        <div className={styles["chat-item-count"]}>{props.count} 条对话</div>
+        <div className={styles["chat-item-count"]}>{Locale.ChatItem.ChatItemCount(props.count)}</div>
         <div className={styles["chat-item-date"]}>{props.time}</div>
       </div>
       <div className={styles["chat-item-delete"]} onClick={props.onDelete}>
@@ -200,7 +201,7 @@ export function Chat(props: { showSideBar?: () => void }) {
         <div className={styles["window-header-title"]}>
           <div className={styles["window-header-main-title"]}>{session.topic}</div>
           <div className={styles["window-header-sub-title"]}>
-            与 ChatGPT 的 {session.messages.length} 条对话
+            {Locale.Chat.SubTitle(session.messages.length)}
           </div>
         </div>
         <div className={styles["window-actions"]}>
@@ -208,7 +209,7 @@ export function Chat(props: { showSideBar?: () => void }) {
             <IconButton
               icon={<MenuIcon />}
               bordered
-              title="查看消息列表"
+              title={Locale.Chat.Actions.ChatList}
               onClick={props?.showSideBar}
             />
           </div>
@@ -216,7 +217,7 @@ export function Chat(props: { showSideBar?: () => void }) {
             <IconButton
               icon={<BrainIcon />}
               bordered
-              title="查看压缩后的历史 Prompt"
+              title={Locale.Chat.Actions.CompressedHistory}
               onClick={() => {
                 showMemoryPrompt(session)
               }}
@@ -226,7 +227,7 @@ export function Chat(props: { showSideBar?: () => void }) {
             <IconButton
               icon={<ExportIcon />}
               bordered
-              title="导出聊天记录"
+              title={Locale.Chat.Actions.Export}
               onClick={() => {
                 exportMessages(session.messages, session.topic)
               }}
@@ -251,7 +252,7 @@ export function Chat(props: { showSideBar?: () => void }) {
                   <Avatar role={message.role} />
                 </div>
                 {(message.preview || message.streaming) && (
-                  <div className={styles["chat-message-status"]}>正在输入…</div>
+                  <div className={styles["chat-message-status"]}>{Locale.Chat.Typing}</div>
                 )}
                 <div className={styles["chat-message-item"]}>
                   {(message.preview || message.content.length === 0) &&
@@ -283,7 +284,7 @@ export function Chat(props: { showSideBar?: () => void }) {
         <div className={styles["chat-input-panel-inner"]}>
           <textarea
             className={styles["chat-input"]}
-            placeholder={`输入消息,${submitKey} 发送`}
+            placeholder={Locale.Chat.Input(submitKey)}
             rows={3}
             onInput={(e) => setUserInput(e.currentTarget.value)}
             value={userInput}
@@ -291,7 +292,7 @@ export function Chat(props: { showSideBar?: () => void }) {
           />
           <IconButton
             icon={<SendWhiteIcon />}
-            text={"发送"}
+            text={Locale.Chat.Send}
             className={styles["chat-input-send"] + " no-dark"}
             onClick={onUserSubmit}
           />
@@ -322,21 +323,21 @@ function exportMessages(messages: Message[], topic: string) {
   const filename = `${topic}.md`
 
   showModal({
-    title: "导出聊天记录为 Markdown", children: <div className="markdown-body">
+    title: Locale.Export.Title, children: <div className="markdown-body">
       <pre className={styles['export-content']}>{mdText}</pre>
     </div>, actions: [
-      <IconButton key="copy" icon={<CopyIcon />} bordered text="全部复制" onClick={() => copyToClipboard(mdText)} />,
-      <IconButton key="download" icon={<DownloadIcon />} bordered text="下载文件" onClick={() => downloadAs(mdText, filename)} />
+      <IconButton key="copy" icon={<CopyIcon />} bordered text={Locale.Export.Copy} onClick={() => copyToClipboard(mdText)} />,
+      <IconButton key="download" icon={<DownloadIcon />} bordered text={Locale.Export.Download} onClick={() => downloadAs(mdText, filename)} />
     ]
   })
 }
 
 function showMemoryPrompt(session: ChatSession) {
   showModal({
-    title: `上下文记忆 Prompt (${session.lastSummarizeIndex} of ${session.messages.length})`, children: <div className="markdown-body">
-      <pre className={styles['export-content']}>{session.memoryPrompt || '无'}</pre>
+    title: `${Locale.Memory.Title} (${session.lastSummarizeIndex} of ${session.messages.length})`, children: <div className="markdown-body">
+      <pre className={styles['export-content']}>{session.memoryPrompt || Locale.Memory.EmptyContent}</pre>
     </div>, actions: [
-      <IconButton key="copy" icon={<CopyIcon />} bordered text="全部复制" onClick={() => copyToClipboard(session.memoryPrompt)} />,
+      <IconButton key="copy" icon={<CopyIcon />} bordered text={Locale.Memory.Copy} onClick={() => copyToClipboard(session.memoryPrompt)} />,
     ]
   })
 }
@@ -405,7 +406,7 @@ export function Home() {
           <div>
             <IconButton
               icon={<AddIcon />}
-              text={"新的聊天"}
+              text={Locale.Home.NewChat}
               onClick={createNewSession}
             />
           </div>

+ 34 - 27
app/components/settings.tsx

@@ -1,6 +1,6 @@
 import { useState, useRef, useEffect } from "react";
 
-import EmojiPicker, { Emoji, Theme as EmojiTheme } from "emoji-picker-react";
+import EmojiPicker, { Theme as EmojiTheme } from "emoji-picker-react";
 
 import styles from "./settings.module.scss";
 
@@ -14,6 +14,8 @@ import { IconButton } from "./button";
 import { SubmitKey, useChatStore, Theme } from "../store";
 import { Avatar } from "./home";
 
+import Locale, { changeLang, getLang } from '../locales'
+
 export function Settings(props: { closeSettings: () => void }) {
   const [showEmojiPicker, setShowEmojiPicker] = useState(false);
   const [config, updateConfig, resetConfig, clearAllData] = useChatStore((state) => [
@@ -27,8 +29,8 @@ export function Settings(props: { closeSettings: () => void }) {
     <>
       <div className={styles["window-header"]}>
         <div className={styles["window-header-title"]}>
-          <div className={styles["window-header-main-title"]}>设置</div>
-          <div className={styles["window-header-sub-title"]}>设置选项</div>
+          <div className={styles["window-header-main-title"]}>{Locale.Settings.Title}</div>
+          <div className={styles["window-header-sub-title"]}>{Locale.Settings.SubTitle}</div>
         </div>
         <div className={styles["window-actions"]}>
           <div className={styles["window-action-button"]}>
@@ -36,7 +38,7 @@ export function Settings(props: { closeSettings: () => void }) {
               icon={<ClearIcon />}
               onClick={clearAllData}
               bordered
-              title="清除所有数据"
+              title={Locale.Settings.Actions.ClearAll}
             />
           </div>
           <div className={styles["window-action-button"]}>
@@ -44,7 +46,7 @@ export function Settings(props: { closeSettings: () => void }) {
               icon={<ResetIcon />}
               onClick={resetConfig}
               bordered
-              title="重置所有选项"
+              title={Locale.Settings.Actions.ResetAll}
             />
           </div>
           <div className={styles["window-action-button"]}>
@@ -52,7 +54,7 @@ export function Settings(props: { closeSettings: () => void }) {
               icon={<CloseIcon />}
               onClick={props.closeSettings}
               bordered
-              title="关闭"
+              title={Locale.Settings.Actions.Close}
             />
           </div>
         </div>
@@ -60,7 +62,7 @@ export function Settings(props: { closeSettings: () => void }) {
       <div className={styles["settings"]}>
         <List>
           <ListItem>
-            <div className={styles["settings-title"]}>头像</div>
+            <div className={styles["settings-title"]}>{Locale.Settings.Avatar}</div>
             <Popover
               onClose={() => setShowEmojiPicker(false)}
               content={
@@ -85,7 +87,7 @@ export function Settings(props: { closeSettings: () => void }) {
           </ListItem>
 
           <ListItem>
-            <div className={styles["settings-title"]}>发送键</div>
+            <div className={styles["settings-title"]}>{Locale.Settings.SendKey}</div>
             <div className="">
               <select
                 value={config.submitKey}
@@ -106,7 +108,7 @@ export function Settings(props: { closeSettings: () => void }) {
           </ListItem>
 
           <ListItem>
-            <div className={styles["settings-title"]}>主题</div>
+            <div className={styles["settings-title"]}>{Locale.Settings.Theme}</div>
             <div className="">
               <select
                 value={config.theme}
@@ -126,7 +128,7 @@ export function Settings(props: { closeSettings: () => void }) {
           </ListItem>
 
           <ListItem>
-            <div className={styles["settings-title"]}>紧凑边框</div>
+            <div className={styles["settings-title"]}>{Locale.Settings.TightBorder}</div>
             <input
               type="checkbox"
               checked={config.tightBorder}
@@ -137,10 +139,30 @@ export function Settings(props: { closeSettings: () => void }) {
               }
             ></input>
           </ListItem>
+
+          <ListItem>
+            <div className={styles["settings-title"]}>{Locale.Settings.Lang.Name}</div>
+            <div className="">
+              <select
+                value={getLang()}
+                onChange={(e) => {
+                  changeLang(e.target.value as any)
+                }}
+              >
+                <option value='en' key='en'>
+                  {Locale.Settings.Lang.Options.en}
+                </option>
+
+                <option value='cn' key='cn'>
+                  {Locale.Settings.Lang.Options.cn}
+                </option>
+              </select>
+            </div>
+          </ListItem>
         </List>
         <List>
           <ListItem>
-            <div className={styles["settings-title"]}>附带历史消息数</div>
+            <div className={styles["settings-title"]}>{Locale.Settings.HistoryCount}</div>
             <input
               type="range"
               title={config.historyMessageCount.toString()}
@@ -159,7 +181,7 @@ export function Settings(props: { closeSettings: () => void }) {
 
           <ListItem>
             <div className={styles["settings-title"]}>
-              历史消息压缩长度阈值
+              {Locale.Settings.CompressThreshold}
             </div>
             <input
               type="number"
@@ -173,21 +195,6 @@ export function Settings(props: { closeSettings: () => void }) {
               }
             ></input>
           </ListItem>
-
-          <ListItem>
-            <div className={styles["settings-title"]}>
-              上下文中包含机器人消息
-            </div>
-            <input
-              type="checkbox"
-              checked={config.sendBotMessages}
-              onChange={(e) =>
-                updateConfig(
-                  (config) => (config.sendBotMessages = e.currentTarget.checked)
-                )
-              }
-            ></input>
-          </ListItem>
         </List>
       </div>
     </>

+ 71 - 0
app/locales/cn.ts

@@ -0,0 +1,71 @@
+
+const cn = {
+    ChatItem: {
+        ChatItemCount: (count: number) => `${count} 条对话`,
+    },
+    Chat: {
+        SubTitle: (count: number) => `与 ChatGPT 的 ${count} 条对话`,
+        Actions: {
+            ChatList: '查看消息列表',
+            CompressedHistory: '查看压缩后的历史 Prompt',
+            Export: '导出聊天记录',
+        },
+        Typing: '正在输入…',
+        Input: (submitKey: string) => `输入消息,${submitKey} 发送`,
+        Send: '发送',
+    },
+    Export: {
+        Title: '导出聊天记录为 Markdown',
+        Copy: '全部复制',
+        Download: '下载文件',
+    },
+    Memory: {
+        Title: '上下文记忆 Prompt',
+        EmptyContent: '尚未记忆',
+        Copy: '全部复制',
+    },
+    Home: {
+        NewChat: '新的聊天',
+    },
+    Settings: {
+        Title: '设置',
+        SubTitle: '设置选项',
+        Actions: {
+            ClearAll: '清除所有数据',
+            ResetAll: '重置所有选项',
+            Close: '关闭',
+        },
+        Lang: {
+            Name: 'Language',
+            Options: {
+                cn: '中文',
+                en: 'English'
+            }
+        },
+        Avatar: '头像',
+        SendKey: '发送键',
+        Theme: '主题',
+        TightBorder: '紧凑边框',
+        HistoryCount: '附带历史消息数',
+        CompressThreshold: '历史消息长度压缩阈值',
+    },
+    Store: {
+        DefaultTopic: '新的聊天',
+        BotHello: '有什么可以帮你的吗',
+        Error: '出错了,稍后重试吧',
+        Prompt: {
+            History: (content: string) => '这是 ai 和用户的历史聊天总结作为前情提要:' + content,
+            Topic: "直接返回这句话的简要主题,不要解释,如果没有主题,请直接返回“闲聊”",
+            Summarize: '简要总结一下你和用户的对话,用作后续的上下文提示 prompt,控制在 50 字以内',
+        },
+        ConfirmClearAll: '确认清除所有聊天、设置数据?',
+    },
+    Copy: {
+        Success: '已写入剪切板',
+        Failed: '复制失败,请赋予剪切板权限',
+    }
+}
+
+export type LocaleType = typeof cn;
+
+export default cn;

+ 70 - 0
app/locales/en.ts

@@ -0,0 +1,70 @@
+import type { LocaleType } from './index'
+
+const en: LocaleType = {
+    ChatItem: {
+        ChatItemCount: (count: number) => `${count} messages`,
+    },
+    Chat: {
+        SubTitle: (count: number) => `${count} messages with ChatGPT`,
+        Actions: {
+            ChatList: 'Go To Chat List',
+            CompressedHistory: 'Compressed History Memory Prompt',
+            Export: 'Export All Messages as Markdown',
+        },
+        Typing: 'Typing…',
+        Input: (submitKey: string) => `Type something and press ${submitKey} to send`,
+        Send: 'Send',
+    },
+    Export: {
+        Title: 'All Messages',
+        Copy: 'Copy All',
+        Download: 'Download',
+    },
+    Memory: {
+        Title: 'Memory Prompt',
+        EmptyContent: 'Nothing yet.',
+        Copy: 'Copy All',
+    },
+    Home: {
+        NewChat: 'New Chat',
+    },
+    Settings: {
+        Title: 'Settings',
+        SubTitle: 'All Settings',
+        Actions: {
+            ClearAll: 'Clear All Data',
+            ResetAll: 'Reset All Settings',
+            Close: 'Close',
+        },
+        Lang: {
+            Name: '语言',
+            Options: {
+                cn: '中文',
+                en: 'English'
+            }
+        },
+        Avatar: 'Avatar',
+        SendKey: 'Send Key',
+        Theme: 'Theme',
+        TightBorder: 'Tight Border',
+        HistoryCount: 'History Message Count',
+        CompressThreshold: 'Message Compression Threshold',
+    },
+    Store: {
+        DefaultTopic: 'New Conversation',
+        BotHello: 'Hello! How can I assist you today?',
+        Error: 'Something went wrong, please try again later.',
+        Prompt: {
+            History: (content: string) => 'This is a summary of the chat history between the AI and the user as a recap: ' + content,
+            Topic: "Provide a brief topic of the sentence without explanation. If there is no topic, return 'Chitchat'.",
+            Summarize: 'Summarize our discussion briefly in 50 characters or less to use as a prompt for future context.',
+        },
+        ConfirmClearAll: 'Confirm to clear all chat and setting data?',
+    },
+    Copy: {
+        Success: 'Copied to clipboard',
+        Failed: 'Copy failed, please grant permission to access clipboard',
+    }
+}
+
+export default en;

+ 30 - 0
app/locales/index.ts

@@ -0,0 +1,30 @@
+import CN from './cn'
+import EN from './en'
+
+export type { LocaleType } from './cn'
+
+type Lang = 'en' | 'cn'
+
+const LANG_KEY = 'lang'
+export function getLang(): Lang {
+    const savedLang = localStorage?.getItem(LANG_KEY)
+
+    if (['en', 'cn'].includes(savedLang ?? '')) {
+        return savedLang as Lang
+    }
+
+    const lang = navigator.language.toLowerCase()
+
+    if (lang.includes('zh') || lang.includes('cn')) {
+        return 'cn'
+    } else {
+        return 'en'
+    }
+}
+
+export function changeLang(lang: Lang) {
+    localStorage.setItem(LANG_KEY, lang)
+    location.reload()
+}
+
+export default { en: EN, cn: CN }[getLang()]

+ 10 - 8
app/store.ts

@@ -2,9 +2,11 @@ import { create } from "zustand";
 import { persist } from "zustand/middleware";
 
 import { type ChatCompletionResponseMessage } from "openai";
-import { requestChat, requestChatStream, requestWithPrompt } from "./requests";
+import { requestChatStream, requestWithPrompt } from "./requests";
 import { trimTopic } from "./utils";
 
+import Locale from './locales'
+
 export type Message = ChatCompletionResponseMessage & {
   date: string;
   streaming?: boolean;
@@ -60,7 +62,7 @@ export interface ChatSession {
   lastSummarizeIndex: number;
 }
 
-const DEFAULT_TOPIC = "新的聊天";
+const DEFAULT_TOPIC = Locale.Store.DefaultTopic;
 
 function createEmptySession(): ChatSession {
   const createDate = new Date().toLocaleString();
@@ -72,7 +74,7 @@ function createEmptySession(): ChatSession {
     messages: [
       {
         role: "assistant",
-        content: "有什么可以帮你的吗",
+        content: Locale.Store.BotHello,
         date: createDate,
       },
     ],
@@ -234,7 +236,7 @@ export const useChatStore = create<ChatStore>()(
             }
           },
           onError(error) {
-            botMessage.content += "\n\n出错了,稍后重试吧";
+            botMessage.content += "\n\n" + Locale.Store.Error;
             botMessage.streaming = false;
             set(() => ({}));
           },
@@ -247,7 +249,7 @@ export const useChatStore = create<ChatStore>()(
 
         return {
           role: 'system',
-          content: '这是 ai 和用户的历史聊天总结作为前情提要:' + session.memoryPrompt,
+          content: Locale.Store.Prompt.History(session.memoryPrompt),
           date: ''
         } as Message
       },
@@ -286,7 +288,7 @@ export const useChatStore = create<ChatStore>()(
           // should summarize topic
           requestWithPrompt(
             session.messages,
-            "直接返回这句话的简要主题,不要解释,如果没有主题,请直接返回“闲聊”"
+            Locale.Store.Prompt.Topic
           ).then((res) => {
             get().updateCurrentSession(
               (session) => (session.topic = trimTopic(res))
@@ -312,7 +314,7 @@ export const useChatStore = create<ChatStore>()(
         if (historyMsgLength > config.compressMessageLengthThreshold) {
           requestChatStream(toBeSummarizedMsgs.concat({
             role: 'system',
-            content: '简要总结一下你和用户的对话,用作后续的上下文提示 prompt,控制在 50 字以内',
+            content: Locale.Store.Prompt.Summarize,
             date: ''
           }), {
             filterBot: false,
@@ -345,7 +347,7 @@ export const useChatStore = create<ChatStore>()(
       },
 
       clearAllData() {
-        if (confirm('确认清除所有聊天、设置数据?')) {
+        if (confirm(Locale.Store.ConfirmClearAll)) {
           localStorage.clear()
           location.reload()
         }

+ 4 - 2
app/utils.ts

@@ -1,3 +1,5 @@
+import Locale from './locales'
+
 export function trimTopic(topic: string) {
   const s = topic.split("");
   let lastChar = s.at(-1); // 获取 s 的最后一个字符
@@ -12,9 +14,9 @@ export function trimTopic(topic: string) {
 
 export function copyToClipboard(text: string) {
   navigator.clipboard.writeText(text).then(res => {
-    alert('复制成功')
+    alert(Locale.Copy.Success)
   }).catch(err => {
-    alert('复制失败,请赋予剪切板权限')
+    alert(Locale.Copy.Failed)
   })
 }