diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 515ea508..4e6ede0f 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -9,6 +9,7 @@ import { AntdThemeConfig, getAntdLocale } from './config/antd' import AppsPage from './pages/apps/AppsPage' import HomePage from './pages/home/HomePage' import SettingsPage from './pages/settings/SettingsPage' +import TranslatePage from './pages/translate/TranslatePage' function App(): JSX.Element { return ( @@ -21,6 +22,7 @@ function App(): JSX.Element { } /> } /> + } /> } /> diff --git a/src/renderer/src/assets/fonts/icon-fonts/iconfont.css b/src/renderer/src/assets/fonts/icon-fonts/iconfont.css new file mode 100644 index 00000000..098b60a9 --- /dev/null +++ b/src/renderer/src/assets/fonts/icon-fonts/iconfont.css @@ -0,0 +1,47 @@ +@font-face { + font-family: "iconfont"; /* Project id 4563475 */ + src: url('iconfont.woff2?t=1722099305424') format('woff2'), + url('iconfont.woff?t=1722099305424') format('woff'), + url('iconfont.ttf?t=1722099305424') format('truetype'); +} + +.iconfont { + font-family: "iconfont" !important; + font-size: 16px; + font-style: normal; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.icon-translate_line:before { + content: "\e7de"; +} + +.icon-history:before { + content: "\e758"; +} + +.icon-hidesidebarhoriz:before { + content: "\e8eb"; +} + +.icon-showsidebarhoriz:before { + content: "\e944"; +} + +.icon-a-addchat:before { + content: "\e658"; +} + +.icon-appstore:before { + content: "\e792"; +} + +.icon-chat:before { + content: "\e615"; +} + +.icon-setting:before { + content: "\e78e"; +} + diff --git a/src/renderer/src/assets/fonts/icon-fonts/iconfont.ttf b/src/renderer/src/assets/fonts/icon-fonts/iconfont.ttf new file mode 100644 index 00000000..64e9a4e3 Binary files /dev/null and b/src/renderer/src/assets/fonts/icon-fonts/iconfont.ttf differ diff --git a/src/renderer/src/assets/fonts/icon-fonts/iconfont.woff b/src/renderer/src/assets/fonts/icon-fonts/iconfont.woff new file mode 100644 index 00000000..44baa8be Binary files /dev/null and b/src/renderer/src/assets/fonts/icon-fonts/iconfont.woff differ diff --git a/src/renderer/src/assets/fonts/icon-fonts/iconfont.woff2 b/src/renderer/src/assets/fonts/icon-fonts/iconfont.woff2 new file mode 100644 index 00000000..c2e0fb69 Binary files /dev/null and b/src/renderer/src/assets/fonts/icon-fonts/iconfont.woff2 differ diff --git a/src/renderer/src/assets/styles/index.scss b/src/renderer/src/assets/styles/index.scss index 5d1f089a..211f849f 100644 --- a/src/renderer/src/assets/styles/index.scss +++ b/src/renderer/src/assets/styles/index.scss @@ -1,4 +1,4 @@ -@import 'https://at.alicdn.com/t/c/font_4563475_hrx8c92awui.css'; +@import '../fonts/icon-fonts/iconfont.css'; @import './markdown.scss'; @import './scrollbar.scss'; diff --git a/src/renderer/src/components/app/Sidebar.tsx b/src/renderer/src/components/app/Sidebar.tsx index 155182d0..3de3e7ca 100644 --- a/src/renderer/src/components/app/Sidebar.tsx +++ b/src/renderer/src/components/app/Sidebar.tsx @@ -4,6 +4,7 @@ import styled from 'styled-components' import { Link, useLocation } from 'react-router-dom' import useAvatar from '@renderer/hooks/useAvatar' import { isMac, isWindows } from '@renderer/config/constant' +import { TranslationOutlined } from '@ant-design/icons' const Sidebar: FC = () => { const { pathname } = useLocation() @@ -29,6 +30,11 @@ const Sidebar: FC = () => { + + + + + @@ -85,22 +91,28 @@ const Icon = styled.div` margin-bottom: 5px; transition: background-color 0.2s ease; -webkit-app-region: none; - .iconfont { + .iconfont, + .anticon { color: var(--color-icon); font-size: 20px; transition: color 0.2s ease; text-decoration: none; } + .anticon { + font-size: 17px; + } &:hover { background-color: #ffffff30; cursor: pointer; - .iconfont { + .iconfont, + .anticon { color: var(--color-icon-white); } } &.active { background-color: #ffffff20; - .iconfont { + .iconfont, + .anticon { color: var(--color-icon-white); } } diff --git a/src/renderer/src/hooks/useAssistant.ts b/src/renderer/src/hooks/useAssistant.ts index f923910d..817a9835 100644 --- a/src/renderer/src/hooks/useAssistant.ts +++ b/src/renderer/src/hooks/useAssistant.ts @@ -14,7 +14,7 @@ import { updateTopic, updateTopics } from '@renderer/store/assistants' -import { setDefaultModel as _setDefaultModel, setTopicNamingModel as _setTopicNamingModel } from '@renderer/store/llm' +import { setDefaultModel, setTopicNamingModel, setTranslateModel } from '@renderer/store/llm' import { Assistant, AssistantSettings, Model, Topic } from '@renderer/types' import localforage from 'localforage' @@ -71,13 +71,15 @@ export function useDefaultAssistant() { } export function useDefaultModel() { - const { defaultModel, topicNamingModel } = useAppSelector((state) => state.llm) + const { defaultModel, topicNamingModel, translateModel } = useAppSelector((state) => state.llm) const dispatch = useAppDispatch() return { defaultModel, topicNamingModel, - setDefaultModel: (model: Model) => dispatch(_setDefaultModel({ model })), - setTopicNamingModel: (model: Model) => dispatch(_setTopicNamingModel({ model })) + translateModel, + setDefaultModel: (model: Model) => dispatch(setDefaultModel({ model })), + setTopicNamingModel: (model: Model) => dispatch(setTopicNamingModel({ model })), + setTranslateModel: (model: Model) => dispatch(setTranslateModel({ model })) } } diff --git a/src/renderer/src/i18n/index.ts b/src/renderer/src/i18n/index.ts index ab46ca35..0a903840 100644 --- a/src/renderer/src/i18n/index.ts +++ b/src/renderer/src/i18n/index.ts @@ -102,7 +102,7 @@ const resources = { title: 'Settings', general: 'General Settings', provider: 'Model Provider', - model: 'Model Settings', + model: 'Default Model', assistant: 'Default Assistant', about: 'About & Feedback', 'messages.model.title': 'Model Settings', @@ -124,6 +124,7 @@ const resources = { 'provider.api.url.reset': 'Reset', 'models.default_assistant_model': 'Default Assistant Model', 'models.topic_naming_model': 'Topic Naming Model', + 'models.translate_model': 'Translate Model', 'models.add.add_model': 'Add Model', 'models.add.model_id.placeholder': 'Required e.g. gpt-3.5-turbo', 'models.add.model_id': 'Model ID', @@ -156,6 +157,27 @@ const resources = { 'about.contact.title': '📧 Contact', 'about.contact.button': 'Email', 'proxy.title': 'Proxy Address' + }, + translate: { + title: 'Translation', + 'any.language': 'Any language', + 'button.translate': 'Translate', + 'error.not_configured': 'Translation model is not configured', + 'input.placeholder': 'Enter text to translate', + 'output.placeholder': 'Translation' + }, + languages: { + english: 'English', + chinese: 'Chinese', + 'chinese-traditional': 'Traditional Chinese', + japanese: 'Japanese', + korean: 'Korean', + russian: 'Russian', + spanish: 'Spanish', + french: 'French', + italian: 'Italian', + portuguese: 'Portuguese', + arabic: 'Arabic' } } }, @@ -258,7 +280,7 @@ const resources = { title: '设置', general: '常规设置', provider: '模型提供商', - model: '模型设置', + model: '默认模型', assistant: '默认助手', about: '关于我们', 'messages.model.title': '模型设置', @@ -280,6 +302,7 @@ const resources = { 'provider.api.url.reset': '重置', 'models.default_assistant_model': '默认助手模型', 'models.topic_naming_model': '话题命名模型', + 'models.translate_model': '翻译模型', 'models.add.add_model': '添加模型', 'models.add.model_id.placeholder': '必填 例如 gpt-3.5-turbo', 'models.add.model_id': '模型 ID', @@ -312,6 +335,27 @@ const resources = { 'about.contact.title': '📧 邮件联系', 'about.contact.button': '邮件', 'proxy.title': '代理地址' + }, + translate: { + title: '翻译', + 'any.language': '任意语言', + 'button.translate': '翻译', + 'error.not_configured': '翻译模型未配置', + 'input.placeholder': '输入文本进行翻译', + 'output.placeholder': '翻译' + }, + languages: { + english: '英文', + chinese: '简体中文', + 'chinese-traditional': '繁体中文', + japanese: '日文', + korean: '韩文', + russian: '俄文', + spanish: '西班牙文', + french: '法文', + italian: '意大利文', + portuguese: '葡萄牙文', + arabic: '阿拉伯文' } } } diff --git a/src/renderer/src/pages/settings/ModelSettings.tsx b/src/renderer/src/pages/settings/ModelSettings.tsx index 0937a34a..2fa73d6f 100644 --- a/src/renderer/src/pages/settings/ModelSettings.tsx +++ b/src/renderer/src/pages/settings/ModelSettings.tsx @@ -6,9 +6,11 @@ import { useDefaultModel } from '@renderer/hooks/useAssistant' import { find } from 'lodash' import { Model } from '@renderer/types' import { useTranslation } from 'react-i18next' +import { EditOutlined, MessageOutlined, TranslationOutlined } from '@ant-design/icons' const ModelSettings: FC = () => { - const { defaultModel, topicNamingModel, setDefaultModel, setTopicNamingModel } = useDefaultModel() + const { defaultModel, topicNamingModel, translateModel, setDefaultModel, setTopicNamingModel, setTranslateModel } = + useDefaultModel() const { providers } = useProviders() const allModels = providers.map((p) => p.models).flat() const { t } = useTranslation() @@ -24,9 +26,16 @@ const ModelSettings: FC = () => { })) })) + const iconStyle = { fontSize: 16, marginRight: 8 } + return ( - {t('settings.models.default_assistant_model')} + + + + {t('settings.models.default_assistant_model')} + + { options={selectOptions} /> - {t('settings.models.topic_naming_model')} + + + + {t('settings.models.topic_naming_model')} + + { onChange={(id) => setTopicNamingModel(find(allModels, { id }) as Model)} options={selectOptions} /> + + + + + {t('settings.models.translate_model')} + + + + setTranslateModel(find(allModels, { id }) as Model)} + options={selectOptions} + placeholder={t('settings.models.empty')} + /> ) } diff --git a/src/renderer/src/pages/translate/TranslatePage.tsx b/src/renderer/src/pages/translate/TranslatePage.tsx new file mode 100644 index 00000000..434f0c36 --- /dev/null +++ b/src/renderer/src/pages/translate/TranslatePage.tsx @@ -0,0 +1,311 @@ +import { + CheckOutlined, + CopyOutlined, + SendOutlined, + SettingOutlined, + SwapOutlined, + WarningOutlined +} from '@ant-design/icons' +import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar' +import { useDefaultModel } from '@renderer/hooks/useAssistant' +import { fetchTranslate } from '@renderer/services/api' +import { getDefaultAssistant } from '@renderer/services/assistant' +import { Assistant, Message } from '@renderer/types' +import { uuid } from '@renderer/utils' +import { Button, Select, Space } from 'antd' +import TextArea from 'antd/es/input/TextArea' +import { isEmpty } from 'lodash' +import { FC, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import Markdown from 'react-markdown' +import { Link } from 'react-router-dom' +import styled from 'styled-components' +import CodeBlock from '../home/components/CodeBlock' + +let _text = '' +let _result = '' +let _targetLanguage = 'english' + +const TranslatePage: FC = () => { + const { t } = useTranslation() + const [targetLanguage, setTargetLanguage] = useState(_targetLanguage) + const [text, setText] = useState(_text) + const [result, setResult] = useState(_result) + const { translateModel } = useDefaultModel() + const [loading, setLoading] = useState(false) + const [copied, setCopied] = useState(false) + + _text = text + _result = result + _targetLanguage = targetLanguage + + const languageOptions = [ + { + value: 'english', + label: t('languages.english'), + emoji: '🇬🇧' + }, + { + value: 'chinese', + label: t('languages.chinese'), + emoji: '🇨🇳' + }, + { + value: 'chinese-traditional', + label: t('languages.chinese-traditional'), + emoji: '🇭🇰' + }, + { + value: 'japanese', + label: t('languages.japanese'), + emoji: '🇯🇵' + }, + { + value: 'korean', + label: t('languages.korean'), + emoji: '🇰🇷' + }, + { + value: 'russian', + label: t('languages.russian'), + emoji: '🇷🇺' + }, + { + value: 'spanish', + label: t('languages.spanish'), + emoji: '🇪🇸' + }, + { + value: 'french', + label: t('languages.french'), + emoji: '🇫🇷' + }, + { + value: 'italian', + label: t('languages.italian'), + emoji: '🇮🇹' + }, + { + value: 'portuguese', + label: t('languages.portuguese'), + emoji: '🇵🇹' + }, + { + value: 'arabic', + label: t('languages.arabic'), + emoji: '🇸🇦' + } + ] + + const onTranslate = async () => { + if (!text.trim()) { + return + } + + if (!translateModel) { + window.message.error({ + content: t('translate.error.not_configured'), + key: 'translate-message' + }) + return + } + + const assistant: Assistant = getDefaultAssistant() + assistant.model = translateModel + assistant.prompt = `Translate from input language to ${targetLanguage}, provide the translation result directly without any explanation, keep original format. If the target language is the same as the source language, do not translate. The text to be translated is as follows:\n\n ${text}` + + const message: Message = { + id: uuid(), + role: 'user', + content: text, + assistantId: assistant.id, + topicId: uuid(), + modelId: translateModel.id, + createdAt: new Date().toISOString(), + status: 'sending' + } + + setLoading(true) + const translateText = await fetchTranslate({ message, assistant }) + setResult(translateText) + setLoading(false) + } + + const onCopy = () => { + navigator.clipboard.writeText(result) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + useEffect(() => { + isEmpty(text) && setResult('') + }, [text]) + + return ( + + + {t('translate.title')} + + + + + + setTargetLanguage(value)} + optionRender={(option) => ( + + + {option.data.emoji} + + {option.label} + + )} + /> + {translateModel && ( + + + + )} + {!translateModel && ( + + }> + {t('translate.error.not_configured')} + + + )} + + + + setText(e.target.value)} + disabled={loading} + allowClear + /> + }> + {t('translate.button.translate')} + + + + + + {result || t('translate.output.placeholder')} + + + : } + /> + + + + + ) +} + +const Container = styled.div` + display: flex; + flex: 1; + flex-direction: column; + height: 100%; +` + +const ContentContainer = styled.div` + display: flex; + flex: 1; + flex-direction: column; + height: 100%; + padding: 20px; +` + +const MenuContainer = styled.div` + display: flex; + flex-direction: row; + align-items: center; + margin-bottom: 15px; + gap: 20px; +` + +const TranslateInputWrapper = styled.div` + display: flex; + flex-direction: row; + min-height: 350px; + gap: 20px; +` + +const InputContainer = styled.div` + position: relative; + display: flex; + flex: 1; + flex-direction: column; + height: 100%; + border: 1px solid var(--color-border); + border-radius: 10px; +` + +const Textarea = styled(TextArea)` + display: flex; + flex: 1; + padding: 20px; + font-size: 16px; + overflow: auto; + .ant-input { + resize: none; + padding: 15px 20px; + } +` + +const OutputContainer = styled.div` + position: relative; + display: flex; + flex: 1; + flex-direction: column; + height: 100%; + padding: 10px; + background-color: var(--color-background-soft); + border-radius: 10px; +` + +const OutputText = styled.div` + padding: 5px 10px; + max-height: calc(100vh - var(--navbar-height) - 120px); + overflow: auto; +` + +const TranslateButton = styled(Button)` + position: absolute; + right: 15px; + bottom: 15px; + z-index: 10; +` + +const CopyButton = styled(Button)` + position: absolute; + right: 15px; + bottom: 15px; +` + +export default TranslatePage diff --git a/src/renderer/src/services/ProviderSDK.ts b/src/renderer/src/services/ProviderSDK.ts index a7a1efc1..d5a88b68 100644 --- a/src/renderer/src/services/ProviderSDK.ts +++ b/src/renderer/src/services/ProviderSDK.ts @@ -73,6 +73,30 @@ export default class ProviderSDK { } } + public async translate(message: Message, assistant: Assistant) { + const defaultModel = getDefaultModel() + const model = assistant.model || defaultModel + const messages = [{ role: 'system', content: assistant.prompt }, message] + + if (this.isAnthropic) { + const response = await this.anthropicSdk.messages.create({ + model: model.id, + messages: messages as MessageParam[], + max_tokens: 4096, + temperature: assistant?.settings?.temperature, + stream: false + }) + return response.content[0].type === 'text' ? response.content[0].text : '' + } else { + const response = await this.openaiSdk.chat.completions.create({ + model: model.id, + messages: messages as ChatCompletionMessageParam[], + stream: false + }) + return response.choices[0].message?.content || '' + } + } + public async summaries(messages: Message[], assistant: Assistant): Promise { const model = getTopNamingModel() || assistant.model || getDefaultModel() diff --git a/src/renderer/src/services/api.ts b/src/renderer/src/services/api.ts index 04694948..03153cee 100644 --- a/src/renderer/src/services/api.ts +++ b/src/renderer/src/services/api.ts @@ -4,9 +4,16 @@ import { setGenerating } from '@renderer/store/runtime' import { Assistant, Message, Provider, Topic } from '@renderer/types' import { uuid } from '@renderer/utils' import dayjs from 'dayjs' -import { getAssistantProvider, getDefaultModel, getProviderByModel, getTopNamingModel } from './assistant' +import { + getAssistantProvider, + getDefaultModel, + getProviderByModel, + getTopNamingModel, + getTranslateModel +} from './assistant' import { EVENT_NAMES, EventEmitter } from './event' import ProviderSDK from './ProviderSDK' +import { isEmpty } from 'lodash' export async function fetchChatCompletion({ messages, @@ -63,11 +70,33 @@ export async function fetchChatCompletion({ return message } +export async function fetchTranslate({ message, assistant }: { message: Message; assistant: Assistant }) { + const model = getTranslateModel() + + if (!model) { + return '' + } + + const provider = getProviderByModel(model) + + if (!hasApiKey(provider)) { + return '' + } + + const providerSdk = new ProviderSDK(provider) + + try { + return await providerSdk.translate(message, assistant) + } catch (error: any) { + return '' + } +} + export async function fetchMessagesSummary({ messages, assistant }: { messages: Message[]; assistant: Assistant }) { const model = getTopNamingModel() || assistant.model || getDefaultModel() const provider = getProviderByModel(model) - if (provider.id !== 'ollama' && !provider.apiKey) { + if (!hasApiKey(provider)) { return null } @@ -114,6 +143,12 @@ export async function checkApi(provider: Provider) { return valid } +function hasApiKey(provider: Provider) { + if (!provider) return false + if (provider.id === 'ollama') return true + return !isEmpty(provider.apiKey) +} + export async function fetchModels(provider: Provider) { const providerSdk = new ProviderSDK(provider) diff --git a/src/renderer/src/services/assistant.ts b/src/renderer/src/services/assistant.ts index b9111e55..a0d4bfc3 100644 --- a/src/renderer/src/services/assistant.ts +++ b/src/renderer/src/services/assistant.ts @@ -32,6 +32,10 @@ export function getTopNamingModel() { return store.getState().llm.topicNamingModel } +export function getTranslateModel() { + return store.getState().llm.translateModel +} + export function getAssistantProvider(assistant: Assistant) { const providers = store.getState().llm.providers const provider = providers.find((p) => p.id === assistant.model?.provider) diff --git a/src/renderer/src/store/llm.ts b/src/renderer/src/store/llm.ts index f745f473..566a978a 100644 --- a/src/renderer/src/store/llm.ts +++ b/src/renderer/src/store/llm.ts @@ -7,11 +7,13 @@ export interface LlmState { providers: Provider[] defaultModel: Model topicNamingModel: Model + translateModel: Model } const initialState: LlmState = { defaultModel: SYSTEM_MODELS.openai[0], topicNamingModel: SYSTEM_MODELS.openai[0], + translateModel: SYSTEM_MODELS.openai[0], providers: [ { id: 'openai', @@ -174,6 +176,9 @@ const settingsSlice = createSlice({ }, setTopicNamingModel: (state, action: PayloadAction<{ model: Model }>) => { state.topicNamingModel = action.payload.model + }, + setTranslateModel: (state, action: PayloadAction<{ model: Model }>) => { + state.translateModel = action.payload.model } } }) @@ -186,7 +191,8 @@ export const { addModel, removeModel, setDefaultModel, - setTopicNamingModel + setTopicNamingModel, + setTranslateModel } = settingsSlice.actions export default settingsSlice.reducer