diff --git a/src/renderer/src/aiCore/index.ts b/src/renderer/src/aiCore/index.ts index cea27d2568..99eb6c940b 100644 --- a/src/renderer/src/aiCore/index.ts +++ b/src/renderer/src/aiCore/index.ts @@ -112,7 +112,7 @@ export default class AiProvider { builder.remove(ToolUseExtractionMiddlewareName) logger.silly('ToolUseExtractionMiddleware is removed') } - if (params.callType !== 'chat') { + if (params.callType !== 'chat' && params.callType !== 'check' && params.callType !== 'translate') { logger.silly('AbortHandlerMiddleware is removed') builder.remove(AbortHandlerMiddlewareName) } diff --git a/src/renderer/src/aiCore/middleware/common/AbortHandlerMiddleware.ts b/src/renderer/src/aiCore/middleware/common/AbortHandlerMiddleware.ts index c1d3102ed9..a733e45d70 100644 --- a/src/renderer/src/aiCore/middleware/common/AbortHandlerMiddleware.ts +++ b/src/renderer/src/aiCore/middleware/common/AbortHandlerMiddleware.ts @@ -21,32 +21,38 @@ export const AbortHandlerMiddleware: CompletionsMiddleware = return result } - // 获取当前消息的ID用于abort管理 - // 优先使用处理过的消息,如果没有则使用原始消息 - let messageId: string | undefined - - if (typeof params.messages === 'string') { - messageId = `message-${Date.now()}-${Math.random().toString(36).substring(2, 9)}` - } else { - const processedMessages = params.messages - const lastUserMessage = processedMessages.findLast((m) => m.role === 'user') - messageId = lastUserMessage?.id - } - - if (!messageId) { - logger.warn(`No messageId found, abort functionality will not be available.`) - return next(ctx, params) - } - const abortController = new AbortController() const abortFn = (): void => abortController.abort() - - addAbortController(messageId, abortFn) - let abortSignal: AbortSignal | null = abortController.signal + let abortKey: string + // 如果参数中传入了abortKey则优先使用 + if (params.abortKey) { + abortKey = params.abortKey + } else { + // 获取当前消息的ID用于abort管理 + // 优先使用处理过的消息,如果没有则使用原始消息 + let messageId: string | undefined + + if (typeof params.messages === 'string') { + messageId = `message-${Date.now()}-${Math.random().toString(36).substring(2, 9)}` + } else { + const processedMessages = params.messages + const lastUserMessage = processedMessages.findLast((m) => m.role === 'user') + messageId = lastUserMessage?.id + } + + if (!messageId) { + logger.warn(`No messageId found, abort functionality will not be available.`) + return next(ctx, params) + } + + abortKey = messageId + } + + addAbortController(abortKey, abortFn) const cleanup = (): void => { - removeAbortController(messageId as string, abortFn) + removeAbortController(abortKey, abortFn) if (ctx._internal?.flowControl) { ctx._internal.flowControl.abortController = undefined ctx._internal.flowControl.abortSignal = undefined diff --git a/src/renderer/src/aiCore/middleware/schemas.ts b/src/renderer/src/aiCore/middleware/schemas.ts index fcb59d4aff..ce89934f02 100644 --- a/src/renderer/src/aiCore/middleware/schemas.ts +++ b/src/renderer/src/aiCore/middleware/schemas.ts @@ -59,6 +59,9 @@ export interface CompletionsParams { contextCount?: number topicId?: string // 主题ID,用于关联上下文 + // abort 控制 + abortKey?: string + _internal?: ProcessingState } diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 9bc790caeb..b0a09110af 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -789,6 +789,7 @@ "label": "Sort by Pinyin" } }, + "stop": "Stop", "success": "Success", "swap": "Swap", "topics": "Topics", @@ -3805,6 +3806,9 @@ }, "title": "Translation History" }, + "info": { + "aborted": "Translation aborted" + }, "input": { "placeholder": "Enter text to translate" }, diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index c30c00a617..00da07ed51 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -789,6 +789,7 @@ "label": "ピンインでソート" } }, + "stop": "停止", "success": "成功", "swap": "交換", "topics": "トピック", @@ -3805,6 +3806,9 @@ }, "title": "翻訳履歴" }, + "info": { + "aborted": "翻訳中止" + }, "input": { "placeholder": "翻訳するテキストを入力" }, diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index f9203453a3..2d23a04f60 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -789,6 +789,7 @@ "label": "Сортировать по пиньинь" } }, + "stop": "остановить", "success": "Успешно", "swap": "Поменять местами", "topics": "Топики", @@ -3805,6 +3806,9 @@ }, "title": "История переводов" }, + "info": { + "aborted": "Перевод прерван" + }, "input": { "placeholder": "Введите текст для перевода" }, diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index a80c3e8826..66b8e6a825 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -789,6 +789,7 @@ "label": "按拼音排序" } }, + "stop": "停止", "success": "成功", "swap": "交换", "topics": "话题", @@ -3805,6 +3806,9 @@ }, "title": "翻译历史" }, + "info": { + "aborted": "翻译中止" + }, "input": { "placeholder": "输入文本进行翻译" }, diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index ffb331f603..4f45de4d57 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -789,6 +789,7 @@ "label": "按拼音排序" } }, + "stop": "停止", "success": "成功", "swap": "交換", "topics": "話題", @@ -3805,6 +3806,9 @@ }, "title": "翻譯歷史" }, + "info": { + "aborted": "翻譯中止" + }, "input": { "placeholder": "輸入文字進行翻譯" }, diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index 572b816e7d..b5f481eaaa 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -789,6 +789,7 @@ "label": "Ταξινόμηση κατά Πινγίν" } }, + "stop": "σταματήστε", "success": "Επιτυχία", "swap": "Εναλλαγή", "topics": "Θέματα", @@ -3804,6 +3805,9 @@ }, "title": "Ιστορικό μετάφρασης" }, + "info": { + "aborted": "Η μετάφραση διακόπηκε" + }, "input": { "placeholder": "Εισαγάγετε κείμενο για μετάφραση" }, diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index 310b4a71da..6369bd26d8 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -789,6 +789,7 @@ "label": "Ordenar por pinyin" } }, + "stop": "Detener", "success": "Éxito", "swap": "Intercambiar", "topics": "Temas", @@ -3804,6 +3805,9 @@ }, "title": "Historial de traducciones" }, + "info": { + "aborted": "Traducción cancelada" + }, "input": { "placeholder": "Ingrese el texto para traducir" }, diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index 27501b7d8f..459e9930a0 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -789,6 +789,7 @@ "label": "Сортировать по пиньинь" } }, + "stop": "Arrêter", "success": "Succès", "swap": "Échanger", "topics": "Sujets", @@ -3804,6 +3805,9 @@ }, "title": "Historique des traductions" }, + "info": { + "aborted": "Traduction annulée" + }, "input": { "placeholder": "entrez le texte à traduire" }, diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index b0623b2dee..a642dc7bb8 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -789,6 +789,7 @@ "label": "Ordenar por Pinyin" } }, + "stop": "Parar", "success": "Sucesso", "swap": "Trocar", "topics": "Tópicos", @@ -3804,6 +3805,9 @@ }, "title": "Histórico de Tradução" }, + "info": { + "aborted": "Tradução interrompida" + }, "input": { "placeholder": "Digite o texto para traduzir" }, diff --git a/src/renderer/src/pages/translate/TranslatePage.tsx b/src/renderer/src/pages/translate/TranslatePage.tsx index fcb759daa4..1caee506fa 100644 --- a/src/renderer/src/pages/translate/TranslatePage.tsx +++ b/src/renderer/src/pages/translate/TranslatePage.tsx @@ -17,7 +17,7 @@ import useTranslate from '@renderer/hooks/useTranslate' import { estimateTextTokens } from '@renderer/services/TokenService' import { saveTranslateHistory, translateText } from '@renderer/services/TranslateService' import { useAppDispatch, useAppSelector } from '@renderer/store' -import { setTranslating as setTranslatingAction } from '@renderer/store/runtime' +import { setTranslateAbortKey, setTranslating as setTranslatingAction } from '@renderer/store/runtime' import { setTranslatedContent as setTranslatedContentAction, setTranslateInput } from '@renderer/store/translate' import { type AutoDetectionMethod, @@ -27,7 +27,9 @@ import { type TranslateHistory, type TranslateLanguage } from '@renderer/types' -import { getFileExtension, runAsyncFunction } from '@renderer/utils' +import { getFileExtension, runAsyncFunction, uuid } from '@renderer/utils' +import { abortCompletion } from '@renderer/utils/abortController' +import { isAbortError } from '@renderer/utils/error' import { formatErrorMessage } from '@renderer/utils/error' import { getFilesFromDropEvent, getTextFromDropEvent } from '@renderer/utils/input' import { @@ -40,7 +42,7 @@ import { imageExts, MB, textExts } from '@shared/config/constant' import { Button, Flex, FloatButton, Popover, Tooltip, Typography } from 'antd' import TextArea, { TextAreaRef } from 'antd/es/input/TextArea' import { isEmpty, throttle } from 'lodash' -import { Check, FolderClock, Settings2, UploadIcon } from 'lucide-react' +import { Check, CirclePause, FolderClock, Settings2, UploadIcon } from 'lucide-react' import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -86,6 +88,7 @@ const TranslatePage: FC = () => { const text = useAppSelector((state) => state.translate.translateInput) const translatedContent = useAppSelector((state) => state.translate.translatedContent) const translating = useAppSelector((state) => state.runtime.translating) + const abortKey = useAppSelector((state) => state.runtime.translateAbortKey) // ref const contentContainerRef = useRef(null) @@ -144,11 +147,16 @@ const TranslatePage: FC = () => { } let translated: string + const abortKey = uuid() + dispatch(setTranslateAbortKey(abortKey)) + try { - translated = await translateText(text, actualTargetLanguage, throttle(setTranslatedContent, 100)) + translated = await translateText(text, actualTargetLanguage, throttle(setTranslatedContent, 100), abortKey) } catch (e) { - logger.error('Failed to translate text', e as Error) - window.message.error(t('translate.error.failed' + ': ' + (e as Error).message)) + if (!isAbortError(e)) { + logger.error('Failed to translate text', e as Error) + window.message.error(t('translate.error.failed' + ': ' + (e as Error).message)) + } setTranslating(false) return } @@ -166,11 +174,12 @@ const TranslatePage: FC = () => { window.message.error(t('translate.error.unknown') + ': ' + (e as Error).message) } }, - [setTranslatedContent, setTranslating, t, translating] + [dispatch, setTranslatedContent, setTranslating, t, translating] ) // 控制翻译按钮,翻译前进行校验 const onTranslate = useCallback(async () => { + if (!couldTranslate) return if (!text.trim()) return if (!translateModel) { window.message.error({ @@ -237,6 +246,15 @@ const TranslatePage: FC = () => { translateModel ]) + // 控制停止翻译 + const onAbort = async () => { + if (!abortKey || !abortKey.trim()) { + logger.error('Failed to abort. Invalid abortKey.') + return + } + abortCompletion(abortKey) + } + // 控制双向翻译切换 const toggleBidirectional = (value: boolean) => { setIsBidirectional(value) @@ -607,7 +625,7 @@ const TranslatePage: FC = () => { }) return } - processFile(selectedFile) + await processFile(selectedFile) } catch (error) { logger.error('onPaste:', error as Error) window.message.error(t('chat.input.file_error')) @@ -672,7 +690,12 @@ const TranslatePage: FC = () => { /> {getLanguageDisplay()} - + void couldTranslate: boolean + onAbort: () => void }) => { const { t } = useTranslation() return ( @@ -922,14 +947,16 @@ const TranslateButton = ({ Shift + Enter: {t('translate.tooltip.newline')} }> - + {!translating && ( + + )} + {translating && ( + + )} ) } diff --git a/src/renderer/src/services/ApiService.ts b/src/renderer/src/services/ApiService.ts index 415b704225..d86854acec 100644 --- a/src/renderer/src/services/ApiService.ts +++ b/src/renderer/src/services/ApiService.ts @@ -39,12 +39,7 @@ import { type Chunk, ChunkType } from '@renderer/types/chunk' import { Message } from '@renderer/types/newMessage' import { SdkModel } from '@renderer/types/sdk' import { removeSpecialCharactersForTopicName, uuid } from '@renderer/utils' -import { - abortCompletion, - addAbortController, - createAbortPromise, - removeAbortController -} from '@renderer/utils/abortController' +import { abortCompletion } from '@renderer/utils/abortController' import { isAbortError } from '@renderer/utils/error' import { extractInfoFromXML, ExtractResults } from '@renderer/utils/extract' import { filterAdjacentUserMessaegs, filterLastAssistantMessage } from '@renderer/utils/messageUtils/filters' @@ -874,10 +869,7 @@ export function checkApiProvider(provider: Provider): void { export async function checkApi(provider: Provider, model: Model, timeout = 15000): Promise { checkApiProvider(provider) - const controller = new AbortController() - const abortFn = () => controller.abort() const taskId = uuid() - addAbortController(taskId, abortFn) const ai = new AiProvider(provider) @@ -890,7 +882,6 @@ export async function checkApi(provider: Provider, model: Model, timeout = 15000 const timerPromise = new Promise((_, reject) => setTimeout(() => reject('Timeout'), timeout)) await Promise.race([ai.getEmbeddingDimensions(model), timerPromise]) } else { - // 通过该状态判断abort原因 let streamError: Error | undefined = undefined // 15s超时 @@ -905,31 +896,25 @@ export async function checkApi(provider: Provider, model: Model, timeout = 15000 assistant, streamOutput: true, enableReasoning: false, - onChunk: () => { - // 接收到任意chunk都直接abort + onChunk: (chunk: Chunk) => { + if (chunk.type === ChunkType.ERROR && !isAbortError(chunk.error)) { + streamError = new Error(JSON.stringify(chunk.error)) + } abortCompletion(taskId) }, - onError: (e) => { - // 捕获stream error - streamError = e - abortCompletion(taskId) - } + shouldThrow: true, + abortKey: taskId } // Try streaming check first try { - await createAbortPromise(controller.signal, ai.completions(params)) - } catch (e: any) { - if (isAbortError(e)) { - if (streamError) { - throw streamError - } - } else { - throw e - } + await ai.completions(params) } finally { clearTimeout(timer) } + if (streamError) { + throw streamError + } } } catch (error: any) { // FIXME: 这种判断方法无法严格保证错误是流式引起的 @@ -947,8 +932,6 @@ export async function checkApi(provider: Provider, model: Model, timeout = 15000 } else { throw error } - } finally { - removeAbortController(taskId, abortFn) } } diff --git a/src/renderer/src/services/TranslateService.ts b/src/renderer/src/services/TranslateService.ts index 5312b2e8e0..1b892bb326 100644 --- a/src/renderer/src/services/TranslateService.ts +++ b/src/renderer/src/services/TranslateService.ts @@ -9,7 +9,9 @@ import { import { db } from '@renderer/databases' import { CustomTranslateLanguage, TranslateHistory, TranslateLanguage, TranslateLanguageCode } from '@renderer/types' import { TranslateAssistant } from '@renderer/types' +import { ChunkType } from '@renderer/types/chunk' import { uuid } from '@renderer/utils' +import { formatErrorMessage, isAbortError } from '@renderer/utils/error' import { t } from 'i18next' import { hasApiKey } from './ApiService' @@ -24,9 +26,10 @@ const logger = loggerService.withContext('TranslateService') interface FetchTranslateProps { assistant: TranslateAssistant onResponse?: (text: string, isComplete: boolean) => void + abortKey?: string } -async function fetchTranslate({ assistant, onResponse }: FetchTranslateProps) { +async function fetchTranslate({ assistant, onResponse, abortKey }: FetchTranslateProps) { const model = getTranslateModel() || assistant.model || getDefaultModel() if (!model) { @@ -51,6 +54,7 @@ async function fetchTranslate({ assistant, onResponse }: FetchTranslateProps) { ((isSupportedThinkingTokenModel(model) || isSupportedReasoningEffortModel(model)) && assistant.settings?.reasoning_effort !== undefined) || (isReasoningModel(model) && (!isSupportedThinkingTokenModel(model) || !isSupportedReasoningEffortModel(model))) + let abortError const params: CompletionsParams = { callType: 'translate', @@ -58,12 +62,22 @@ async function fetchTranslate({ assistant, onResponse }: FetchTranslateProps) { assistant: { ...assistant, model }, streamOutput: stream, enableReasoning, - onResponse + onResponse, + onChunk: (chunk) => { + if (chunk.type === ChunkType.ERROR && isAbortError(chunk.error)) { + abortError = chunk.error + } + }, + abortKey } const AI = new AiProvider(provider) - return (await AI.completionsForTrace(params)).getText().trim() + const result = (await AI.completionsForTrace(params)).getText().trim() + if (abortError) { + throw abortError + } + return result } /** @@ -71,18 +85,20 @@ async function fetchTranslate({ assistant, onResponse }: FetchTranslateProps) { * @param text - 需要翻译的文本内容 * @param targetLanguage - 目标语言 * @param onResponse - 流式输出的回调函数,用于实时获取翻译结果 + * @param abortKey - 用于控制 abort 的键 * @returns 返回翻译后的文本 - * @throws {Error} 当翻译模型未配置或翻译失败时抛出错误 + * @throws {Error} 翻译中止或失败时抛出异常 */ export const translateText = async ( text: string, targetLanguage: TranslateLanguage, - onResponse?: (text: string, isComplete: boolean) => void + onResponse?: (text: string, isComplete: boolean) => void, + abortKey?: string ) => { try { const assistant = getDefaultTranslateAssistant(targetLanguage, text) - const translatedText = await fetchTranslate({ assistant, onResponse }) + const translatedText = await fetchTranslate({ assistant, onResponse, abortKey }) const trimmedText = translatedText.trim() @@ -92,10 +108,13 @@ export const translateText = async ( return trimmedText } 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 '' + if (isAbortError(e)) { + window.message.info(t('translate.info.aborted')) + } else { + logger.error('Failed to translate', e as Error) + window.message.error(t('translate.error.failed' + ': ' + formatErrorMessage(e))) + } + throw e } } diff --git a/src/renderer/src/store/runtime.ts b/src/renderer/src/store/runtime.ts index 643d2636c2..1565e43b27 100644 --- a/src/renderer/src/store/runtime.ts +++ b/src/renderer/src/store/runtime.ts @@ -30,6 +30,7 @@ export interface RuntimeState { avatar: string generating: boolean translating: boolean + translateAbortKey?: string /** whether the minapp popup is shown */ minappShow: boolean /** the minapps that are opened and should be keep alive */ @@ -98,6 +99,9 @@ const runtimeSlice = createSlice({ setTranslating: (state, action: PayloadAction) => { state.translating = action.payload }, + setTranslateAbortKey: (state, action: PayloadAction) => { + state.translateAbortKey = action.payload + }, setMinappShow: (state, action: PayloadAction) => { state.minappShow = action.payload }, @@ -162,6 +166,7 @@ export const { setAvatar, setGenerating, setTranslating, + setTranslateAbortKey, setMinappShow, setOpenedKeepAliveMinapps, setOpenedOneOffMinapp,