Fix/translate selection (#8943)

* refactor(translate): 重构翻译窗口使用翻译服务接口

* refactor(translate): 重构翻译功能,提取翻译服务为独立模块

将翻译相关逻辑从ApiService中提取到独立的TranslateService模块
简化组件中翻译功能的调用方式,移除重复代码

* fix(selection): 防止流式输出完成后的重复处理

添加finished标志位,在收到LLM_RESPONSE_COMPLETE时标记完成,避免后续chunk继续处理

* fix(TranslateService): 修复翻译失败时的错误处理和日志记录

改进翻译服务的错误处理逻辑,添加日志记录以便排查问题

* fix(翻译服务): 修正未配置模型时的错误提示信息
This commit is contained in:
Phantom 2025-08-09 00:06:15 +08:00 committed by GitHub
parent 5647d6e6d4
commit c97ece946a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 102 additions and 142 deletions

View File

@ -1,9 +1,7 @@
import { LoadingOutlined } from '@ant-design/icons'
import { loggerService } from '@logger'
import { useDefaultModel } from '@renderer/hooks/useAssistant'
import { useSettings } from '@renderer/hooks/useSettings'
import { fetchTranslate } from '@renderer/services/ApiService'
import { getDefaultTranslateAssistant } from '@renderer/services/AssistantService'
import { translateText } from '@renderer/services/TranslateService'
import { getLanguageByLangcode } from '@renderer/utils/translate'
import { Modal, ModalProps } from 'antd'
import TextArea from 'antd/es/input/TextArea'
@ -43,7 +41,6 @@ const PopupContainer: React.FC<Props> = ({
const [textValue, setTextValue] = useState(text)
const [isTranslating, setIsTranslating] = useState(false)
const textareaRef = useRef<TextAreaRef>(null)
const { translateModel } = useDefaultModel()
const { targetLanguage, showTranslateConfirm } = useSettings()
const isMounted = useRef(true)
@ -103,21 +100,12 @@ const PopupContainer: React.FC<Props> = ({
if (!confirmed) return
}
if (!translateModel) {
window.message.error({
content: t('translate.error.not_configured'),
key: 'translate-message'
})
return
}
if (isMounted.current) {
setIsTranslating(true)
}
try {
const assistant = getDefaultTranslateAssistant(getLanguageByLangcode(targetLanguage), textValue)
const translatedText = await fetchTranslate({ content: textValue, assistant })
const translatedText = await translateText(textValue, getLanguageByLangcode(targetLanguage))
if (isMounted.current) {
setTextValue(translatedText)
}

View File

@ -1,9 +1,7 @@
import { LoadingOutlined } from '@ant-design/icons'
import { loggerService } from '@logger'
import { useDefaultModel } from '@renderer/hooks/useAssistant'
import { useSettings } from '@renderer/hooks/useSettings'
import { fetchTranslate } from '@renderer/services/ApiService'
import { getDefaultTranslateAssistant } from '@renderer/services/AssistantService'
import { translateText } from '@renderer/services/TranslateService'
import { getLanguageByLangcode } from '@renderer/utils/translate'
import { Button, Tooltip } from 'antd'
import { Languages } from 'lucide-react'
@ -23,7 +21,6 @@ const logger = loggerService.withContext('TranslateButton')
const TranslateButton: FC<Props> = ({ text, onTranslated, disabled, style, isLoading }) => {
const { t } = useTranslation()
const { translateModel } = useDefaultModel()
const [isTranslating, setIsTranslating] = useState(false)
const { targetLanguage, showTranslateConfirm } = useSettings()
@ -45,21 +42,12 @@ const TranslateButton: FC<Props> = ({ text, onTranslated, disabled, style, isLoa
return
}
if (!translateModel) {
window.message.error({
content: t('translate.error.not_configured'),
key: 'translate-message'
})
return
}
// 先复制原文到剪贴板
await navigator.clipboard.writeText(text)
setIsTranslating(true)
try {
const assistant = getDefaultTranslateAssistant(getLanguageByLangcode(targetLanguage), text)
const translatedText = await fetchTranslate({ content: text, assistant })
const translatedText = await translateText(text, getLanguageByLangcode(targetLanguage))
onTranslated(translatedText)
} catch (error) {
logger.error('Translation failed:', error as Error)

View File

@ -1,11 +1,10 @@
import db from '@renderer/databases'
import { fetchTranslate } from '@renderer/services/ApiService'
import { getDefaultTranslateAssistant } from '@renderer/services/AssistantService'
import { loggerService } from '@renderer/services/LoggerService'
import { translateText } from '@renderer/services/TranslateService'
import store, { useAppDispatch, useAppSelector } from '@renderer/store'
import { setTranslating as _setTranslating } from '@renderer/store/runtime'
import { setTranslatedContent as _setTranslatedContent } from '@renderer/store/translate'
import { Language, LanguageCode, TranslateAssistant, TranslateHistory } from '@renderer/types'
import { Language, LanguageCode, TranslateHistory } from '@renderer/types'
import { uuid } from '@renderer/utils'
import { t } from 'i18next'
import { throttle } from 'lodash'
@ -55,24 +54,8 @@ export default function useTranslate() {
setTranslating(true)
let assistant: TranslateAssistant
try {
assistant = getDefaultTranslateAssistant(actualTargetLanguage, text)
} catch (e) {
if (e instanceof Error) {
window.message.error(e.message)
return
} else {
throw e
}
}
try {
await fetchTranslate({
content: text,
assistant,
onResponse: throttle(setTranslatedContent, 100)
})
await translateText(text, actualTargetLanguage, throttle(setTranslatedContent, 100))
} catch (e) {
logger.error('Failed to translate text', e as Error)
window.message.error(t('translate.error.failed'))

View File

@ -30,7 +30,6 @@ import {
MemoryItem,
Model,
Provider,
TranslateAssistant,
WebSearchResponse,
WebSearchSource
} from '@renderer/types'
@ -62,8 +61,7 @@ import {
getDefaultAssistant,
getDefaultModel,
getProviderByModel,
getTopNamingModel,
getTranslateModel
getTopNamingModel
} from './AssistantService'
import { processKnowledgeSearch } from './KnowledgeService'
import { MemoryProcessor } from './MemoryProcessor'
@ -606,56 +604,6 @@ async function processConversationMemory(messages: Message[], assistant: Assista
}
}
interface FetchTranslateProps {
content: string
assistant: TranslateAssistant
onResponse?: (text: string, isComplete: boolean) => void
}
export async function fetchTranslate({ content, assistant, onResponse }: FetchTranslateProps) {
const model = getTranslateModel() || assistant.model || getDefaultModel()
if (!model) {
throw new Error(i18n.t('error.provider_disabled'))
}
const provider = getProviderByModel(model)
if (!hasApiKey(provider)) {
throw new Error(i18n.t('error.no_api_key'))
}
const isSupportedStreamOutput = () => {
if (!onResponse) {
return false
}
return true
}
const stream = isSupportedStreamOutput()
const enableReasoning =
((isSupportedThinkingTokenModel(model) || isSupportedReasoningEffortModel(model)) &&
assistant.settings?.reasoning_effort !== undefined) ||
(isReasoningModel(model) && (!isSupportedThinkingTokenModel(model) || !isSupportedReasoningEffortModel(model)))
const params: CompletionsParams = {
callType: 'translate',
messages: content,
assistant: { ...assistant, model },
streamOutput: stream,
enableReasoning,
onResponse
}
const AI = new AiProvider(provider)
try {
return (await AI.completions(params)).getText() || ''
} catch (error: any) {
return ''
}
}
export async function fetchMessagesSummary({ messages, assistant }: { messages: Message[]; assistant: Assistant }) {
let prompt = (getStoreSetting('topicNamingPrompt') as string) || i18n.t('prompts.title')
const model = getTopNamingModel() || assistant.model || getDefaultModel()
@ -789,7 +737,7 @@ export async function fetchGenerate({
}
}
function hasApiKey(provider: Provider) {
export function hasApiKey(provider: Provider) {
if (!provider) return false
if (provider.id === 'ollama' || provider.id === 'lmstudio' || provider.type === 'vertexai') return true
return !isEmpty(provider.apiKey)

View File

@ -1,34 +1,93 @@
import { loggerService } from '@logger'
import AiProvider from '@renderer/aiCore'
import { CompletionsParams } from '@renderer/aiCore/middleware/schemas'
import {
isReasoningModel,
isSupportedReasoningEffortModel,
isSupportedThinkingTokenModel
} from '@renderer/config/models'
import i18n from '@renderer/i18n'
import store from '@renderer/store'
import { Language } from '@renderer/types'
import { Language, TranslateAssistant } from '@renderer/types'
import { t } from 'i18next'
import { fetchTranslate } from './ApiService'
import { getDefaultTranslateAssistant } from './AssistantService'
import { hasApiKey } from './ApiService'
import {
getDefaultModel,
getDefaultTranslateAssistant,
getProviderByModel,
getTranslateModel
} from './AssistantService'
const logger = loggerService.withContext('TranslateService')
interface FetchTranslateProps {
content: string
assistant: TranslateAssistant
onResponse?: (text: string, isComplete: boolean) => void
}
async function fetchTranslate({ content, assistant, onResponse }: FetchTranslateProps) {
const model = getTranslateModel() || assistant.model || getDefaultModel()
if (!model) {
throw new Error(i18n.t('translate.error.not_configured'))
}
const provider = getProviderByModel(model)
if (!hasApiKey(provider)) {
throw new Error(i18n.t('error.no_api_key'))
}
const isSupportedStreamOutput = () => {
if (!onResponse) {
return false
}
return true
}
const stream = isSupportedStreamOutput()
const enableReasoning =
((isSupportedThinkingTokenModel(model) || isSupportedReasoningEffortModel(model)) &&
assistant.settings?.reasoning_effort !== undefined) ||
(isReasoningModel(model) && (!isSupportedThinkingTokenModel(model) || !isSupportedReasoningEffortModel(model)))
const params: CompletionsParams = {
callType: 'translate',
messages: content,
assistant: { ...assistant, model },
streamOutput: stream,
enableReasoning,
onResponse
}
const AI = new AiProvider(provider)
return (await AI.completions(params)).getText().trim()
}
/**
*
* @param text -
* @param targetLanguage -
* @param onResponse -
* @returns
* @throws {Error}
*/
export const translateText = async (
text: string,
targetLanguage: Language,
onResponse?: (text: string, isComplete: boolean) => void
) => {
const translateModel = store.getState().llm.translateModel
try {
const assistant = getDefaultTranslateAssistant(targetLanguage, text)
if (!translateModel) {
window.message.error({
content: i18n.t('translate.error.not_configured'),
key: 'translate-message'
})
return Promise.reject(new Error(i18n.t('translate.error.not_configured')))
const translatedText = await fetchTranslate({ content: text, assistant, onResponse })
return translatedText
} catch (e) {
logger.error('Failed to translate', e as Error)
const message = e instanceof Error ? e.message : String(e)
window.message.error(t('translate.error.failed' + ': ' + message))
return ''
}
const assistant = getDefaultTranslateAssistant(targetLanguage, text)
const translatedText = await fetchTranslate({ content: text, assistant, onResponse })
const trimmedText = translatedText.trim()
if (!trimmedText) {
return Promise.reject(new Error(i18n.t('translate.error.failed')))
}
return trimmedText
}

View File

@ -4,9 +4,8 @@ import Scrollbar from '@renderer/components/Scrollbar'
import { LanguagesEnum, 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, Language } from '@renderer/types'
import { translateText } from '@renderer/services/TranslateService'
import { Language } from '@renderer/types'
import { runAsyncFunction } from '@renderer/utils'
import { getLanguageByLangcode } from '@renderer/utils/translate'
import { Select } from 'antd'
@ -41,20 +40,7 @@ const Translate: FC<Props> = ({ text }) => {
try {
translatingRef.current = true
const assistant: Assistant = getDefaultTranslateAssistant(targetLanguage, text)
// const message: Message = {
// id: uuid(),
// role: 'user',
// content: '',
// assistantId: assistant.id,
// topicId: uuid(),
// model: translateModel,
// createdAt: new Date().toISOString(),
// type: 'text',
// status: 'sending'
// }
await fetchTranslate({ content: text, assistant, onResponse: setResult })
await translateText(text, targetLanguage, setResult)
translatingRef.current = false
} catch (error) {

View File

@ -51,10 +51,15 @@ export const processMessages = async (
})
)
let finished = false
await fetchChatCompletion({
messages: [userMessage],
assistant: { ...assistant, settings: { streamOutput: true } },
onChunkReceived: (chunk: Chunk) => {
if (finished) {
return
}
switch (chunk.type) {
case ChunkType.THINKING_START:
{
@ -163,6 +168,9 @@ export const processMessages = async (
)
}
break
case ChunkType.LLM_RESPONSE_COMPLETE:
finished = true
break
case ChunkType.ERROR:
{
const blockId = textBlockId || thinkingBlockId