feat: add translation module

This commit is contained in:
kangfenmao 2024-07-28 01:01:15 +08:00
parent 5b123f2c33
commit a267a8d4c3
15 changed files with 532 additions and 16 deletions

View File

@ -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 {
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/apps" element={<AppsPage />} />
<Route path="/translate" element={<TranslatePage />} />
<Route path="/settings/*" element={<SettingsPage />} />
</Routes>
</HashRouter>

View File

@ -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";
}

Binary file not shown.

Binary file not shown.

View File

@ -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';

View File

@ -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 = () => {
<i className="iconfont icon-appstore"></i>
</Icon>
</StyledLink>
<StyledLink to="/translate">
<Icon className={isRoute('/translate')}>
<TranslationOutlined />
</Icon>
</StyledLink>
</Menus>
</MainMenus>
<Menus>
@ -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);
}
}

View File

@ -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 }))
}
}

View File

@ -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: '阿拉伯文'
}
}
}

View File

@ -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 (
<SettingContainer>
<SettingTitle>{t('settings.models.default_assistant_model')}</SettingTitle>
<SettingTitle>
<div>
<MessageOutlined style={iconStyle} />
{t('settings.models.default_assistant_model')}
</div>
</SettingTitle>
<SettingDivider />
<Select
defaultValue={defaultModel.id}
@ -35,7 +44,12 @@ const ModelSettings: FC = () => {
options={selectOptions}
/>
<div style={{ height: 30 }} />
<SettingTitle>{t('settings.models.topic_naming_model')}</SettingTitle>
<SettingTitle>
<div>
<EditOutlined style={iconStyle} />
{t('settings.models.topic_naming_model')}
</div>
</SettingTitle>
<SettingDivider />
<Select
defaultValue={topicNamingModel.id}
@ -43,6 +57,21 @@ const ModelSettings: FC = () => {
onChange={(id) => setTopicNamingModel(find(allModels, { id }) as Model)}
options={selectOptions}
/>
<div style={{ height: 30 }} />
<SettingTitle>
<div>
<TranslationOutlined style={iconStyle} />
{t('settings.models.translate_model')}
</div>
</SettingTitle>
<SettingDivider />
<Select
defaultValue={translateModel?.id}
style={{ width: 200 }}
onChange={(id) => setTranslateModel(find(allModels, { id }) as Model)}
options={selectOptions}
placeholder={t('settings.models.empty')}
/>
</SettingContainer>
)
}

View File

@ -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 (
<Container>
<Navbar>
<NavbarCenter style={{ borderRight: 'none' }}>{t('translate.title')}</NavbarCenter>
</Navbar>
<ContentContainer>
<MenuContainer>
<Select
showSearch
value="any"
style={{ width: 180 }}
optionFilterProp="label"
disabled
options={[{ label: t('translate.any.language'), value: 'any' }]}
/>
<SwapOutlined />
<Select
showSearch
value={targetLanguage}
style={{ width: 180 }}
optionFilterProp="label"
options={languageOptions}
onChange={(value) => setTargetLanguage(value)}
optionRender={(option) => (
<Space>
<span role="img" aria-label={option.data.label}>
{option.data.emoji}
</span>
{option.label}
</Space>
)}
/>
{translateModel && (
<Link to="/settings/model" style={{ color: 'var(--color-text-2)' }}>
<SettingOutlined />
</Link>
)}
{!translateModel && (
<Link to="/settings/model" style={{ marginLeft: -10 }}>
<Button
type="link"
style={{ color: 'var(--color-error)', textDecoration: 'underline' }}
icon={<WarningOutlined />}>
{t('translate.error.not_configured')}
</Button>
</Link>
)}
</MenuContainer>
<TranslateInputWrapper>
<InputContainer>
<Textarea
variant="borderless"
placeholder={t('translate.input.placeholder')}
value={text}
onChange={(e) => setText(e.target.value)}
disabled={loading}
allowClear
/>
<TranslateButton
type="primary"
loading={loading}
onClick={onTranslate}
disabled={!text.trim()}
icon={<SendOutlined />}>
{t('translate.button.translate')}
</TranslateButton>
</InputContainer>
<OutputContainer>
<OutputText>
<Markdown className="markdown" components={{ code: CodeBlock as any }}>
{result || t('translate.output.placeholder')}
</Markdown>
</OutputText>
<CopyButton
onClick={onCopy}
disabled={!result}
icon={copied ? <CheckOutlined style={{ color: 'var(--color-primary)' }} /> : <CopyOutlined />}
/>
</OutputContainer>
</TranslateInputWrapper>
</ContentContainer>
</Container>
)
}
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

View File

@ -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<string | null> {
const model = getTopNamingModel() || assistant.model || getDefaultModel()

View File

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

View File

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

View File

@ -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