add russian translate, fix delete dialog and add pwa updater

This commit is contained in:
Zhang Minghan 2023-09-16 12:24:19 +08:00
parent 7957b0d47d
commit 2993672af9
10 changed files with 248 additions and 70 deletions

View File

@ -41,6 +41,7 @@
- 🌏 Internationalization support
- 🇨🇳 简体中文
- 🇺🇸 English
- 🇷🇺 Русский
14. 🍎 主题切换
- 🍎 Theme switching

View File

@ -1,7 +1,7 @@
{
"name": "chatnio",
"private": false,
"version": "2.0.0",
"version": "2.6.0",
"type": "module",
"scripts": {
"dev": "vite",
@ -44,7 +44,8 @@
"sort-by": "^1.2.0",
"tailwind-merge": "^1.14.0",
"tailwindcss-animate": "^1.0.7",
"unist-util-visit": "^5.0.0"
"unist-util-visit": "^5.0.0",
"workbox-window": "^7.0.0"
},
"devDependencies": {
"@types/node": "^20.5.9",

6
app/pnpm-lock.yaml generated
View File

@ -107,6 +107,9 @@ dependencies:
unist-util-visit:
specifier: ^5.0.0
version: 5.0.0
workbox-window:
specifier: ^7.0.0
version: 7.0.0
devDependencies:
'@types/node':
@ -2794,7 +2797,6 @@ packages:
/@types/trusted-types@2.0.3:
resolution: {integrity: sha512-NfQ4gyz38SL8sDNrSixxU2Os1a5xcdFxipAFxYEuLUlvU2uDwS4NUpsImcf1//SlWItCVMMLiylsxbmNMToV/g==}
dev: true
/@types/unist@2.0.8:
resolution: {integrity: sha512-d0XxK3YTObnWVp6rZuev3c49+j4Lo8g4L1ZRm9z5L0xpoZycUPshHgczK5gsUMaZOstjVYYi09p5gYvUtfChYw==}
@ -7095,7 +7097,6 @@ packages:
/workbox-core@7.0.0:
resolution: {integrity: sha512-81JkAAZtfVP8darBpfRTovHg8DGAVrKFgHpOArZbdFd78VqHr5Iw65f2guwjE2NlCFbPFDoez3D3/6ZvhI/rwQ==}
dev: true
/workbox-expiration@7.0.0:
resolution: {integrity: sha512-MLK+fogW+pC3IWU9SFE+FRStvDVutwJMR5if1g7oBJx3qwmO69BNoJQVaMXq41R0gg3MzxVfwOGKx3i9P6sOLQ==}
@ -7172,7 +7173,6 @@ packages:
dependencies:
'@types/trusted-types': 2.0.3
workbox-core: 7.0.0
dev: true
/wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}

View File

@ -69,6 +69,13 @@
-webkit-overflow-scrolling: touch;
transition: 0.2s ease-in-out;
.empty {
color: hsl(var(--text-secondary));
font-size: 14px;
margin: auto;
user-select: none;
}
.conversation {
display: flex;
flex-direction: row;

View File

@ -20,18 +20,9 @@ function I18nProvider() {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuCheckboxItem
checked={i18n.language === "cn"}
onClick={() => setLanguage(i18n, "cn")}
>
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={i18n.language === "en"}
onClick={() => setLanguage(i18n, "en")}
>
English
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem checked={i18n.language === "cn"} onClick={() => setLanguage(i18n, "cn")}></DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem checked={i18n.language === "en"} onClick={() => setLanguage(i18n, "en")}>English</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem checked={i18n.language === "ru"} onClick={() => setLanguage(i18n, "ru")}>Русский</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
);

View File

@ -1,6 +1,6 @@
import axios from "axios";
export const version: string = "2.5.0";
export const version: string = "2.6.0";
export const deploy: boolean = true;
export let rest_api: string = "http://localhost:8094";
export let ws_api: string = "ws://localhost:8094";

View File

@ -28,6 +28,7 @@ const resources = {
"Request failed. Please check your network and try again.",
conversation: {
title: "Conversation",
"empty": "Empty",
"refresh-failed": "Refresh failed",
"refresh-failed-prompt":
"There was an error during your request. Please try again.",
@ -164,6 +165,7 @@ const resources = {
"request-failed": "请求失败,请检查您的网络并重试。",
conversation: {
title: "会话",
"empty": "空空如也",
"refresh-failed": "刷新失败",
"refresh-failed-prompt": "请求出错,请重试。",
"remove-title": "是否确定?",
@ -273,9 +275,148 @@ const resources = {
},
},
},
ru: {
translation: {
end: "",
"not-found": "Страница не найдена",
home: "Главная",
login: "Войти",
logout: "Выйти",
quota: "Квота",
"try-again": "Попробуйте еще раз",
"invalid-token": "Неверный токен",
"invalid-token-prompt": "Пожалуйста, попробуйте еще раз.",
"login-failed": "Ошибка входа",
"login-failed-prompt":
"Ошибка входа! Пожалуйста, проверьте срок действия вашего токена и попробуйте еще раз.",
"login-success": "Успешный вход",
"login-success-prompt": "Вы успешно вошли в систему.",
"server-error": "Ошибка сервера",
"server-error-prompt":
"При входе произошла ошибка. Пожалуйста, попробуйте еще раз.",
"request-failed":
"Ошибка запроса. Пожалуйста, проверьте свою сеть и попробуйте еще раз.",
conversation: {
title: "Разговор",
"empty": "Пусто",
"refresh-failed": "Ошибка обновления",
"refresh-failed-prompt":
"При выполнении запроса произошла ошибка. Пожалуйста, попробуйте еще раз.",
"remove-title": "Вы уверены?",
"remove-description":
"Это действие нельзя отменить. Это навсегда удалит разговор ",
cancel: "Отмена",
delete: "Удалить",
"delete-success": "Разговор удален",
"delete-success-prompt": "Разговор был удален.",
"delete-failed": "Ошибка удаления",
"delete-failed-prompt":
"Не удалось удалить разговор. Пожалуйста, проверьте свою сеть и попробуйте еще раз.",
},
chat: {
web: "веб-поиск",
"web-aria": "Переключить веб-поиск",
placeholder: "Напишите что-нибудь (/image для генерации изображения)",
},
message: {
copy: "Копировать",
save: "Сохранить как файл",
use: "Использовать сообщение",
},
"quota-description": "квота расходов на сообщение",
buy: {
choose: "Выберите сумму",
other: "Другое",
"other-desc": "Сколько очков?",
buy: "Купить {{amount}} очков",
dalle: "Генератор изображений DALL·E",
"dalle-free": "5 бесплатных квот в день",
gpt4: "GPT-4",
flex: "Гибкая тарификация",
input: "Вход",
output: "Выход",
tip: "Цены выровнены (или ниже) по моделям OpenAI",
"learn-more": "Узнать больше",
"dialog-title": "Купить очки",
"dialog-desc": "Вы уверены, что хотите купить {{amount}} очков?",
"dialog-cancel": "Отмена",
"dialog-buy": "Купить",
success: "Покупка прошла успешно",
"success-prompt": "Вы успешно приобрели {{amount}} очков.",
failed: "Покупка не удалась",
"failed-prompt":
"Не удалось приобрести очки. Пожалуйста, убедитесь, что у вас достаточно баланса, вы скоро перейдете в кошелек deeptrain для оплаты баланса.",
"gpt4-tip": "Совет: функция веб-поиска может потреблять больше входных очков",
},
pkg: {
title: "Пакеты",
go: "Перейти к проверке",
cert: "Пакет сертификации",
"cert-desc":
"После сертификации подлинности вы можете получить 50 очков (стоимостью 5 CNY)",
teen: "Подростковый пакет",
"teen-desc":
"После сертификации подлинности подростки (до 18 лет) могут получить дополнительно 150 очков (стоимостью 15 CNY)",
close: "Закрыть",
state: {
true: "Получено",
false: "Не получено",
},
},
sub: {
title: "Подписка",
"dialog-title": "Подписка",
free: "Бесплатно",
"free-price": "Бесплатно навсегда",
pro: "Профессиональный",
"pro-price": "8 CNY/месяц",
"free-gpt3": "GPT-3.5 бесплатно навсегда",
"free-dalle": "5 бесплатных квот в день",
"free-web": "веб-поиск",
"free-conversation": "хранение разговоров",
"free-api": "API вызовы",
"pro-gpt4": "GPT-4 10 запросов в день",
"pro-dalle": "50 квот в день",
"pro-service": "Приоритетная служба поддержки",
"pro-thread": "Увеличение параллелизма",
current: "Текущая подписка",
upgrade: "Обновить",
renew: "Продлить",
"cannot-select": "Невозможно выбрать",
"select-time": "Выберите время подписки",
price: "Цена {{price}} CNY",
expired: "Ваша подписка Pro истечет через {{expired}} дней",
time: {
1: "1 месяц",
3: "3 месяца",
6: "6 месяцев",
12: "1 год",
},
success: "Подписка успешна",
"success-prompt": "Вы успешно подписались на {{month}} месяцев Pro.",
failed: "Подписка не удалась",
"failed-prompt":
"Не удалось подписаться, пожалуйста, убедитесь, что у вас достаточно баланса, вы скоро перейдете в кошелек deeptrain для оплаты баланса.",
},
cancel: "Отмена",
confirm: "Подтвердить",
percent: "{{cent}}0%",
file: {
upload: "Загрузить файл",
type: "В настоящее время поддерживаются только текстовые файлы для загрузки",
drop: "Перетащите файлы сюда или нажмите, чтобы загрузить",
"parse-error": "Ошибка разбора",
"parse-error-prompt":
"Ошибка разбора, в настоящее время поддерживаются только текстовые файлы",
"max-length": "Слишком длинный контент",
"max-length-prompt":
"Содержимое было усечено из-за ограничения длины контекста",
},
},
},
};
export const supportedLanguages = ["en", "cn"];
export const supportedLanguages = ["en", "cn", "ru"];
i18n
.use(initReactI18next)
@ -296,8 +437,14 @@ export function getStorage(): string {
if (storage && supportedLanguages.includes(storage)) {
return storage;
}
// get browser language
const lang = navigator.language.split("-")[0];
if (supportedLanguages.includes(lang)) {
return lang;
}
return "cn";
}
export function setLanguage(i18n: any, lang: string): void {
if (supportedLanguages.includes(lang)) {
i18n

View File

@ -5,6 +5,7 @@ import "./conf.ts";
import "./i18n.ts";
import "./assets/main.less";
import "./assets/globals.less";
import "./service.ts";
import "./conf.ts";
ReactDOM.createRoot(document.getElementById("root")!).render(

View File

@ -57,7 +57,6 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "../components/ui/alert-dialog.tsx";
import { manager } from "../conversation/manager.ts";
import { useTranslation } from "react-i18next";
@ -71,6 +70,7 @@ function SideBar() {
const open = useSelector((state: RootState) => state.menu.open);
const auth = useSelector(selectAuthenticated);
const current = useSelector(selectCurrent);
const [ removeConversation, setRemoveConversation ] = useState<ConversationInstance | null>(null);
const { toast } = useToast();
const history: ConversationInstance[] = useSelector(selectHistory);
const refresh = useRef(null);
@ -116,13 +116,15 @@ function SideBar() {
</Button>
</div>
<div className={`conversation-list`}>
{history.map((conversation, i) => (
{history.length ? history.map((conversation, i) => (
<div
className={`conversation ${
current === conversation.id ? "active" : ""
}`}
key={i}
onClick={async () => {
onClick={async (e) => {
const target = e.target as HTMLElement;
if (target.classList.contains("delete") || target.parentElement?.classList.contains("delete")) return;
await toggleConversation(dispatch, conversation.id);
if (mobile) dispatch(setMenu(false));
}}
@ -132,56 +134,67 @@ function SideBar() {
{filterMessage(conversation.name)}
</div>
<div className={`id`}>{conversation.id}</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Trash2 className={`delete h-4 w-4`} />
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("conversation.remove-title")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("conversation.remove-description")}
<strong className={`conversation-name`}>
{filterMessage(conversation.name)}
</strong>
{t("end")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("conversation.cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={async (e) => {
e.preventDefault();
if (
await deleteConversation(dispatch, conversation.id)
)
toast({
title: t("conversation.delete-success"),
description: t(
"conversation.delete-success-prompt",
),
});
else
toast({
title: t("conversation.delete-failed"),
description: t(
"conversation.delete-failed-prompt",
),
});
}}
>
{t("conversation.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Trash2 className={`delete h-4 w-4`} onClick={
(e) => {
e.preventDefault();
e.stopPropagation();
setRemoveConversation(conversation);
}
} />
</div>
))}
)) : (
<div className={`empty`}>{t("conversation.empty")}</div>
)}
</div>
<AlertDialog open={removeConversation !== null} onOpenChange={(open) => {
if (!open) setRemoveConversation(null);
}}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("conversation.remove-title")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("conversation.remove-description")}
<strong className={`conversation-name`}>
{filterMessage(removeConversation?.name || "")}
</strong>
{t("end")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("conversation.cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={async (e) => {
e.preventDefault();
e.stopPropagation();
if (
await deleteConversation(dispatch, removeConversation?.id || -1)
)
toast({
title: t("conversation.delete-success"),
description: t(
"conversation.delete-success-prompt",
),
});
else
toast({
title: t("conversation.delete-failed"),
description: t(
"conversation.delete-failed-prompt",
),
});
setRemoveConversation(null);
}}
>
{t("conversation.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
) : (
<Button className={`login-action`} variant={`default`} onClick={login}>

17
app/src/service.ts Normal file
View File

@ -0,0 +1,17 @@
// @ts-ignore
import { registerSW } from 'virtual:pwa-register'
export const updateSW = registerSW({
onRegisteredSW(url: string, registration: ServiceWorkerRegistration) {
if (!(!registration.installing && navigator)) return
if (('connection' in navigator) && !navigator.onLine) return
fetch(url, { headers: { 'Service-Worker': 'script', 'Cache-Control': 'no-cache' }, cache: 'no-store' })
.then(async (resp) => {
if (resp?.status === 200) {
await registration.update();
if (registration.onupdatefound) console.log('update found');
}
})
}
})