feat: support i18n

This commit is contained in:
Yifei Zhang 2023-03-20 16:17:45 +00:00
parent ce5abac9fb
commit 7cd170b933
7 changed files with 235 additions and 52 deletions

View File

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

View File

@ -1,6 +1,6 @@
import { useState, useRef, useEffect } from "react"; 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"; import styles from "./settings.module.scss";
@ -14,6 +14,8 @@ import { IconButton } from "./button";
import { SubmitKey, useChatStore, Theme } from "../store"; import { SubmitKey, useChatStore, Theme } from "../store";
import { Avatar } from "./home"; import { Avatar } from "./home";
import Locale, { changeLang, getLang } from '../locales'
export function Settings(props: { closeSettings: () => void }) { export function Settings(props: { closeSettings: () => void }) {
const [showEmojiPicker, setShowEmojiPicker] = useState(false); const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const [config, updateConfig, resetConfig, clearAllData] = useChatStore((state) => [ 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"]}>
<div className={styles["window-header-title"]}> <div className={styles["window-header-title"]}>
<div className={styles["window-header-main-title"]}></div> <div className={styles["window-header-main-title"]}>{Locale.Settings.Title}</div>
<div className={styles["window-header-sub-title"]}></div> <div className={styles["window-header-sub-title"]}>{Locale.Settings.SubTitle}</div>
</div> </div>
<div className={styles["window-actions"]}> <div className={styles["window-actions"]}>
<div className={styles["window-action-button"]}> <div className={styles["window-action-button"]}>
@ -36,7 +38,7 @@ export function Settings(props: { closeSettings: () => void }) {
icon={<ClearIcon />} icon={<ClearIcon />}
onClick={clearAllData} onClick={clearAllData}
bordered bordered
title="清除所有数据" title={Locale.Settings.Actions.ClearAll}
/> />
</div> </div>
<div className={styles["window-action-button"]}> <div className={styles["window-action-button"]}>
@ -44,7 +46,7 @@ export function Settings(props: { closeSettings: () => void }) {
icon={<ResetIcon />} icon={<ResetIcon />}
onClick={resetConfig} onClick={resetConfig}
bordered bordered
title="重置所有选项" title={Locale.Settings.Actions.ResetAll}
/> />
</div> </div>
<div className={styles["window-action-button"]}> <div className={styles["window-action-button"]}>
@ -52,7 +54,7 @@ export function Settings(props: { closeSettings: () => void }) {
icon={<CloseIcon />} icon={<CloseIcon />}
onClick={props.closeSettings} onClick={props.closeSettings}
bordered bordered
title="关闭" title={Locale.Settings.Actions.Close}
/> />
</div> </div>
</div> </div>
@ -60,7 +62,7 @@ export function Settings(props: { closeSettings: () => void }) {
<div className={styles["settings"]}> <div className={styles["settings"]}>
<List> <List>
<ListItem> <ListItem>
<div className={styles["settings-title"]}></div> <div className={styles["settings-title"]}>{Locale.Settings.Avatar}</div>
<Popover <Popover
onClose={() => setShowEmojiPicker(false)} onClose={() => setShowEmojiPicker(false)}
content={ content={
@ -85,7 +87,7 @@ export function Settings(props: { closeSettings: () => void }) {
</ListItem> </ListItem>
<ListItem> <ListItem>
<div className={styles["settings-title"]}></div> <div className={styles["settings-title"]}>{Locale.Settings.SendKey}</div>
<div className=""> <div className="">
<select <select
value={config.submitKey} value={config.submitKey}
@ -106,7 +108,7 @@ export function Settings(props: { closeSettings: () => void }) {
</ListItem> </ListItem>
<ListItem> <ListItem>
<div className={styles["settings-title"]}></div> <div className={styles["settings-title"]}>{Locale.Settings.Theme}</div>
<div className=""> <div className="">
<select <select
value={config.theme} value={config.theme}
@ -126,7 +128,7 @@ export function Settings(props: { closeSettings: () => void }) {
</ListItem> </ListItem>
<ListItem> <ListItem>
<div className={styles["settings-title"]}></div> <div className={styles["settings-title"]}>{Locale.Settings.TightBorder}</div>
<input <input
type="checkbox" type="checkbox"
checked={config.tightBorder} checked={config.tightBorder}
@ -137,10 +139,30 @@ export function Settings(props: { closeSettings: () => void }) {
} }
></input> ></input>
</ListItem> </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>
<List> <List>
<ListItem> <ListItem>
<div className={styles["settings-title"]}></div> <div className={styles["settings-title"]}>{Locale.Settings.HistoryCount}</div>
<input <input
type="range" type="range"
title={config.historyMessageCount.toString()} title={config.historyMessageCount.toString()}
@ -159,7 +181,7 @@ export function Settings(props: { closeSettings: () => void }) {
<ListItem> <ListItem>
<div className={styles["settings-title"]}> <div className={styles["settings-title"]}>
{Locale.Settings.CompressThreshold}
</div> </div>
<input <input
type="number" type="number"
@ -173,21 +195,6 @@ export function Settings(props: { closeSettings: () => void }) {
} }
></input> ></input>
</ListItem> </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> </List>
</div> </div>
</> </>

71
app/locales/cn.ts Normal file
View File

@ -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
app/locales/en.ts Normal file
View File

@ -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
app/locales/index.ts Normal file
View File

@ -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()]

View File

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

View File

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