From c97ece946ae217febe64e3e8ef0b5bbc8bb0960e Mon Sep 17 00:00:00 2001 From: Phantom <59059173+EurFelux@users.noreply.github.com> Date: Sat, 9 Aug 2025 00:06:15 +0800 Subject: [PATCH] Fix/translate selection (#8943) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(translate): 重构翻译窗口使用翻译服务接口 * refactor(translate): 重构翻译功能,提取翻译服务为独立模块 将翻译相关逻辑从ApiService中提取到独立的TranslateService模块 简化组件中翻译功能的调用方式,移除重复代码 * fix(selection): 防止流式输出完成后的重复处理 添加finished标志位,在收到LLM_RESPONSE_COMPLETE时标记完成,避免后续chunk继续处理 * fix(TranslateService): 修复翻译失败时的错误处理和日志记录 改进翻译服务的错误处理逻辑,添加日志记录以便排查问题 * fix(翻译服务): 修正未配置模型时的错误提示信息 --- .../src/components/Popups/TextEditPopup.tsx | 16 +-- .../src/components/TranslateButton.tsx | 16 +-- src/renderer/src/hooks/useTranslate.ts | 23 +--- src/renderer/src/services/ApiService.ts | 56 +--------- src/renderer/src/services/TranslateService.ts | 105 ++++++++++++++---- .../mini/translate/TranslateWindow.tsx | 20 +--- .../action/components/ActionUtils.ts | 8 ++ 7 files changed, 102 insertions(+), 142 deletions(-) diff --git a/src/renderer/src/components/Popups/TextEditPopup.tsx b/src/renderer/src/components/Popups/TextEditPopup.tsx index d5cd04a1c3..403c218dc9 100644 --- a/src/renderer/src/components/Popups/TextEditPopup.tsx +++ b/src/renderer/src/components/Popups/TextEditPopup.tsx @@ -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 = ({ const [textValue, setTextValue] = useState(text) const [isTranslating, setIsTranslating] = useState(false) const textareaRef = useRef(null) - const { translateModel } = useDefaultModel() const { targetLanguage, showTranslateConfirm } = useSettings() const isMounted = useRef(true) @@ -103,21 +100,12 @@ const PopupContainer: React.FC = ({ 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) } diff --git a/src/renderer/src/components/TranslateButton.tsx b/src/renderer/src/components/TranslateButton.tsx index c158d42a21..dd5598ccbc 100644 --- a/src/renderer/src/components/TranslateButton.tsx +++ b/src/renderer/src/components/TranslateButton.tsx @@ -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 = ({ 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 = ({ 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) diff --git a/src/renderer/src/hooks/useTranslate.ts b/src/renderer/src/hooks/useTranslate.ts index 177fe2fa20..09c00f3032 100644 --- a/src/renderer/src/hooks/useTranslate.ts +++ b/src/renderer/src/hooks/useTranslate.ts @@ -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')) diff --git a/src/renderer/src/services/ApiService.ts b/src/renderer/src/services/ApiService.ts index 1421a592ae..8673cd4cb0 100644 --- a/src/renderer/src/services/ApiService.ts +++ b/src/renderer/src/services/ApiService.ts @@ -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) diff --git a/src/renderer/src/services/TranslateService.ts b/src/renderer/src/services/TranslateService.ts index 1fb25d1a86..6c051293f0 100644 --- a/src/renderer/src/services/TranslateService.ts +++ b/src/renderer/src/services/TranslateService.ts @@ -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 } diff --git a/src/renderer/src/windows/mini/translate/TranslateWindow.tsx b/src/renderer/src/windows/mini/translate/TranslateWindow.tsx index 908d1c44b3..81d6e8baf9 100644 --- a/src/renderer/src/windows/mini/translate/TranslateWindow.tsx +++ b/src/renderer/src/windows/mini/translate/TranslateWindow.tsx @@ -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 = ({ 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) { diff --git a/src/renderer/src/windows/selection/action/components/ActionUtils.ts b/src/renderer/src/windows/selection/action/components/ActionUtils.ts index 8802b6b78d..541a4e3bb0 100644 --- a/src/renderer/src/windows/selection/action/components/ActionUtils.ts +++ b/src/renderer/src/windows/selection/action/components/ActionUtils.ts @@ -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