feat: translate support stream output

This commit is contained in:
kangfenmao 2025-01-19 16:44:45 +08:00
parent afc2e2f595
commit aecc5fefcf
14 changed files with 205 additions and 165 deletions

View File

@ -0,0 +1,59 @@
import i18n from '@renderer/i18n'
export const TranslateLanguageOptions = [
{
value: 'english',
label: i18n.t('languages.english'),
emoji: '🇬🇧'
},
{
value: 'chinese',
label: i18n.t('languages.chinese'),
emoji: '🇨🇳'
},
{
value: 'chinese-traditional',
label: i18n.t('languages.chinese-traditional'),
emoji: '🇭🇰'
},
{
value: 'japanese',
label: i18n.t('languages.japanese'),
emoji: '🇯🇵'
},
{
value: 'korean',
label: i18n.t('languages.korean'),
emoji: '🇰🇷'
},
{
value: 'russian',
label: i18n.t('languages.russian'),
emoji: '🇷🇺'
},
{
value: 'spanish',
label: i18n.t('languages.spanish'),
emoji: '🇪🇸'
},
{
value: 'french',
label: i18n.t('languages.french'),
emoji: '🇫🇷'
},
{
value: 'italian',
label: i18n.t('languages.italian'),
emoji: '🇮🇹'
},
{
value: 'portuguese',
label: i18n.t('languages.portuguese'),
emoji: '🇵🇹'
},
{
value: 'arabic',
label: i18n.t('languages.arabic'),
emoji: '🇸🇦'
}
]

View File

@ -2,6 +2,7 @@ import { CheckOutlined, SendOutlined, SettingOutlined, SwapOutlined, WarningOutl
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
import CopyIcon from '@renderer/components/Icons/CopyIcon'
import { isLocalAi } from '@renderer/config/env'
import { TranslateLanguageOptions } from '@renderer/config/translate'
import db from '@renderer/databases'
import { useDefaultModel } from '@renderer/hooks/useAssistant'
import { fetchTranslate } from '@renderer/services/ApiService'
@ -33,64 +34,6 @@ const TranslatePage: FC = () => {
_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
@ -119,8 +62,7 @@ const TranslatePage: FC = () => {
}
setLoading(true)
const translateText = await fetchTranslate({ message, assistant })
setResult(translateText)
await fetchTranslate({ message, assistant, onResponse: (text) => setResult(text) })
setLoading(false)
}
@ -187,7 +129,7 @@ const TranslatePage: FC = () => {
value={targetLanguage}
style={{ width: 180 }}
optionFilterProp="label"
options={languageOptions}
options={TranslateLanguageOptions}
onChange={(value) => {
setTargetLanguage(value)
db.settings.put({ id: 'translate:target:language', value })

View File

@ -20,8 +20,8 @@ export default class AiProvider {
return this.sdk.completions({ messages, assistant, onChunk, onFilterMessages })
}
public async translate(message: Message, assistant: Assistant): Promise<string> {
return this.sdk.translate(message, assistant)
public async translate(message: Message, assistant: Assistant, onResponse?: (text: string) => void): Promise<string> {
return this.sdk.translate(message, assistant, onResponse)
}
public async summaries(messages: Message[], assistant: Assistant): Promise<string> {

View File

@ -149,7 +149,7 @@ export default class AnthropicProvider extends BaseProvider {
})
}
public async translate(message: Message, assistant: Assistant) {
public async translate(message: Message, assistant: Assistant, onResponse?: (text: string) => void) {
const defaultModel = getDefaultModel()
const model = assistant.model || defaultModel
const messages = [
@ -157,18 +157,35 @@ export default class AnthropicProvider extends BaseProvider {
{ role: 'user', content: message.content }
]
const response = await this.sdk.messages.create({
const stream = onResponse ? true : false
const body: MessageCreateParamsNonStreaming = {
model: model.id,
messages: messages.filter((m) => m.role === 'user') as MessageParam[],
max_tokens: 4096,
temperature: assistant?.settings?.temperature,
system: assistant.prompt,
stream: false
})
system: assistant.prompt
}
if (!stream) {
const response = await this.sdk.messages.create({ ...body, stream: false })
return response.content[0].type === 'text' ? response.content[0].text : ''
}
let text = ''
return new Promise<string>((resolve, reject) => {
this.sdk.messages
.stream({ ...body, stream: true })
.on('text', (_text) => {
text += _text
onResponse?.(text)
})
.on('finalMessage', () => resolve(text))
.on('error', (error) => reject(error))
})
}
public async summaries(messages: Message[], assistant: Assistant): Promise<string> {
const model = getTopNamingModel() || assistant.model || getDefaultModel()

View File

@ -20,7 +20,7 @@ export default abstract class BaseProvider {
}
abstract completions({ messages, assistant, onChunk, onFilterMessages }: CompletionsParams): Promise<void>
abstract translate(message: Message, assistant: Assistant): Promise<string>
abstract translate(message: Message, assistant: Assistant, onResponse?: (text: string) => void): Promise<string>
abstract summaries(messages: Message[], assistant: Assistant): Promise<string>
abstract suggestions(messages: Message[], assistant: Assistant): Promise<Suggestion[]>
abstract generateText({ prompt, content }: { prompt: string; content: string }): Promise<string>

View File

@ -230,7 +230,7 @@ export default class GeminiProvider extends BaseProvider {
}
}
async translate(message: Message, assistant: Assistant) {
async translate(message: Message, assistant: Assistant, onResponse?: (text: string) => void) {
const defaultModel = getDefaultModel()
const { maxTokens } = getAssistantSettings(assistant)
const model = assistant.model || defaultModel
@ -247,11 +247,23 @@ export default class GeminiProvider extends BaseProvider {
this.requestOptions
)
if (!onResponse) {
const { response } = await geminiModel.generateContent(message.content)
return response.text()
}
const response = await geminiModel.generateContentStream(message.content)
let text = ''
for await (const chunk of response.stream) {
text += chunk.text()
onResponse(text)
}
return text
}
public async summaries(messages: Message[], assistant: Assistant): Promise<string> {
const model = getTopNamingModel() || assistant.model || getDefaultModel()

View File

@ -192,7 +192,7 @@ export default class OpenAIProvider extends BaseProvider {
}
}
async translate(message: Message, assistant: Assistant) {
async translate(message: Message, assistant: Assistant, onResponse?: (text: string) => void) {
const defaultModel = getDefaultModel()
const model = assistant.model || defaultModel
const messages = [
@ -200,18 +200,43 @@ export default class OpenAIProvider extends BaseProvider {
{ role: 'user', content: message.content }
]
const isOpenAIo1 = model.id.startsWith('o1')
const isSupportedStreamOutput = () => {
if (!onResponse) {
return false
}
if (this.provider.id === 'github' && isOpenAIo1) {
return false
}
return true
}
const stream = isSupportedStreamOutput()
// @ts-ignore key is not typed
const response = await this.sdk.chat.completions.create({
model: model.id,
messages: messages as ChatCompletionMessageParam[],
stream: false,
stream,
keep_alive: this.keepAliveTime,
temperature: assistant?.settings?.temperature
})
if (!stream) {
return response.choices[0].message?.content || ''
}
let text = ''
for await (const chunk of response) {
text += chunk.choices[0]?.delta?.content || ''
onResponse?.(text)
}
return text
}
public async summaries(messages: Message[], assistant: Assistant): Promise<string> {
const model = getTopNamingModel() || assistant.model || getDefaultModel()

View File

@ -101,7 +101,13 @@ export async function fetchChatCompletion({
return message
}
export async function fetchTranslate({ message, assistant }: { message: Message; assistant: Assistant }) {
interface FetchTranslateProps {
message: Message
assistant: Assistant
onResponse?: (text: string) => void
}
export async function fetchTranslate({ message, assistant, onResponse }: FetchTranslateProps) {
const model = getTranslateModel()
if (!model) {
@ -117,7 +123,7 @@ export async function fetchTranslate({ message, assistant }: { message: Message;
const AI = new AiProvider(provider)
try {
return await AI.translate(message, assistant)
return await AI.translate(message, assistant, onResponse)
} catch (error: any) {
return ''
}

View File

@ -4,7 +4,7 @@ import { getDefaultModel } from '@renderer/services/AssistantService'
import { FC } from 'react'
import styled from 'styled-components'
import Messages from './Messages'
import Messages from './components/Messages'
interface Props {
route: string

View File

@ -1,5 +1,7 @@
import { isMac } from '@renderer/config/constant'
import { useDefaultAssistant, useDefaultModel } from '@renderer/hooks/useAssistant'
import { useSettings } from '@renderer/hooks/useSettings'
import i18n from '@renderer/i18n'
import { EVENT_NAMES } from '@renderer/services/EventService'
import { EventEmitter } from '@renderer/services/EventService'
import { uuid } from '@renderer/utils'
@ -11,11 +13,11 @@ import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import ChatWindow from '../chat/ChatWindow'
import TranslateWindow from '../translate/TranslateWindow'
import ClipboardPreview from './components/ClipboardPreview'
import FeatureMenus from './components/FeatureMenus'
import Footer from './components/Footer'
import InputBar from './components/InputBar'
import Translate from './Translate'
const HomeWindow: FC = () => {
const [route, setRoute] = useState<'home' | 'chat' | 'translate' | 'summary' | 'explanation'>('home')
@ -24,6 +26,7 @@ const HomeWindow: FC = () => {
const [text, setText] = useState('')
const { defaultAssistant } = useDefaultAssistant()
const { defaultModel: model } = useDefaultModel()
const { language } = useSettings()
const { t } = useTranslation()
const textRef = useRef(text)
@ -42,6 +45,10 @@ const HomeWindow: FC = () => {
onReadClipboard()
}, [onReadClipboard])
useEffect(() => {
i18n.changeLanguage(language || navigator.language || 'en-US')
}, [language])
const onCloseWindow = () => isMiniWindow && window.close()
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
@ -145,7 +152,7 @@ const HomeWindow: FC = () => {
if (route === 'translate') {
return (
<Container>
<Translate text={referenceText} />
<TranslateWindow text={referenceText} />
<Divider style={{ margin: '10px 0' }} />
<Footer route={route} onExit={() => setRoute('home')} />
</Container>

View File

@ -1,11 +1,14 @@
import { SwapOutlined } from '@ant-design/icons'
import Scrollbar from '@renderer/components/Scrollbar'
import { TranslateLanguageOptions } from '@renderer/config/translate'
import db from '@renderer/databases'
import { useDefaultModel } from '@renderer/hooks/useAssistant'
import { fetchTranslate } from '@renderer/services/ApiService'
import { getDefaultTranslateAssistant } from '@renderer/services/AssistantService'
import { Assistant, Message } from '@renderer/types'
import { uuid } from '@renderer/utils'
import { runAsyncFunction, uuid } from '@renderer/utils'
import { Select, Space } from 'antd'
import { isEmpty } from 'lodash'
import { FC, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@ -14,70 +17,15 @@ interface Props {
text: string
}
const Translate: FC<Props> = ({ text }) => {
const { t } = useTranslation()
const [loading, setLoading] = useState(false)
const [result, setResult] = useState('')
const [targetLanguage, setTargetLanguage] = useState('chinese')
const { translateModel } = useDefaultModel()
let _targetLanguage = 'chinese'
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 Translate: FC<Props> = ({ text }) => {
const [result, setResult] = useState('')
const [targetLanguage, setTargetLanguage] = useState(_targetLanguage)
const { translateModel } = useDefaultModel()
const { t } = useTranslation()
_targetLanguage = targetLanguage
const translate = useCallback(async () => {
if (!text.trim() || !translateModel) return
@ -95,36 +43,41 @@ const Translate: FC<Props> = ({ text }) => {
status: 'sending'
}
setLoading(true)
const translateText = await fetchTranslate({ message, assistant })
setResult(translateText)
setLoading(false)
await fetchTranslate({ message, assistant, onResponse: setResult })
}, [text, targetLanguage, translateModel])
useEffect(() => {
// 获取默认目标语言
db.settings.get({ id: 'translate:target:language' }).then((targetLang) => {
if (targetLang) {
setTargetLanguage(targetLang.value)
}
runAsyncFunction(async () => {
const targetLang = await db.settings.get({ id: 'translate:target:language' })
targetLang && setTargetLanguage(targetLang.value)
})
}, [])
useEffect(() => {
translate()
}, [])
}, [translate])
return (
<Container>
<LanguageSelect>
<MenuContainer>
<Select
value={targetLanguage}
style={{ width: 140 }}
showSearch
value="any"
style={{ width: 200 }}
optionFilterProp="label"
options={languageOptions}
disabled
options={[{ label: t('translate.any.language'), value: 'any' }]}
/>
<SwapOutlined />
<Select
showSearch
value={targetLanguage}
style={{ width: 200 }}
optionFilterProp="label"
options={TranslateLanguageOptions}
onChange={(value) => {
setTargetLanguage(value)
translate()
db.settings.put({ id: 'translate:target:language', value })
}}
optionRender={(option) => (
<Space>
@ -135,8 +88,16 @@ const Translate: FC<Props> = ({ text }) => {
</Space>
)}
/>
</LanguageSelect>
<Main>{loading ? <LoadingText>...</LoadingText> : <ResultText>{result || text}</ResultText>}</Main>
</MenuContainer>
<Main>
{isEmpty(result) ? (
<LoadingText>{t('translate.output.placeholder')}...</LoadingText>
) : (
<OutputContainer>
<ResultText>{result}</ResultText>
</OutputContainer>
)}
</Main>
</Container>
)
}
@ -158,7 +119,7 @@ const Main = styled.div`
overflow: hidden;
`
const ResultText = styled(Scrollbar)`
const ResultText = styled.div`
white-space: pre-wrap;
word-break: break-word;
width: 100%;
@ -169,8 +130,19 @@ const LoadingText = styled.div`
font-style: italic;
`
const LanguageSelect = styled.div`
margin-bottom: 8px;
const MenuContainer = styled.div`
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 15px;
gap: 20px;
`
const OutputContainer = styled(Scrollbar)`
display: flex;
flex-direction: column;
flex: 1;
gap: 10px;
`
export default Translate