feat: more abort control (#9267)

* feat(abort): 添加 abortKey 参数支持以自定义中止控制

支持通过参数传入 abortKey 来替代 messageId,提供更灵活的中止控制方式

* fix(aiCore): 修复checkApi时未正确abort的问题

* feat(翻译): 添加翻译中止功能

支持在翻译过程中中止操作,包括添加中止键状态管理、中止错误处理和界面停止按钮
添加相关国际化文案

* feat(i18n): 添加翻译中止相关文本和多语言支持

* style(translate): 调整停止按钮图标大小以保持视觉一致性

* fix(TranslateService): 改进翻译错误处理逻辑

正确处理翻译中止和失败的情况,统一错误信息格式化方式

* fix(aiCore): 去除不必要的类型断言

* style(TabContainer): 移除多余的空格并保持代码整洁

* fix(translate): 添加翻译前校验并修复文件处理异步问题

在翻译前添加couldTranslate校验,防止无效操作
将processFile改为异步调用以正确处理文件处理流程
This commit is contained in:
Phantom 2025-08-27 23:56:11 +08:00 committed by GitHub
parent aaa0eb7140
commit 7407bb335d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 157 additions and 78 deletions

View File

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

View File

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

View File

@ -59,6 +59,9 @@ export interface CompletionsParams {
contextCount?: number
topicId?: string // 主题ID用于关联上下文
// abort 控制
abortKey?: string
_internal?: ProcessingState
}

View File

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

View File

@ -789,6 +789,7 @@
"label": "ピンインでソート"
}
},
"stop": "停止",
"success": "成功",
"swap": "交換",
"topics": "トピック",
@ -3805,6 +3806,9 @@
},
"title": "翻訳履歴"
},
"info": {
"aborted": "翻訳中止"
},
"input": {
"placeholder": "翻訳するテキストを入力"
},

View File

@ -789,6 +789,7 @@
"label": "Сортировать по пиньинь"
}
},
"stop": "остановить",
"success": "Успешно",
"swap": "Поменять местами",
"topics": "Топики",
@ -3805,6 +3806,9 @@
},
"title": "История переводов"
},
"info": {
"aborted": "Перевод прерван"
},
"input": {
"placeholder": "Введите текст для перевода"
},

View File

@ -789,6 +789,7 @@
"label": "按拼音排序"
}
},
"stop": "停止",
"success": "成功",
"swap": "交换",
"topics": "话题",
@ -3805,6 +3806,9 @@
},
"title": "翻译历史"
},
"info": {
"aborted": "翻译中止"
},
"input": {
"placeholder": "输入文本进行翻译"
},

View File

@ -789,6 +789,7 @@
"label": "按拼音排序"
}
},
"stop": "停止",
"success": "成功",
"swap": "交換",
"topics": "話題",
@ -3805,6 +3806,9 @@
},
"title": "翻譯歷史"
},
"info": {
"aborted": "翻譯中止"
},
"input": {
"placeholder": "輸入文字進行翻譯"
},

View File

@ -789,6 +789,7 @@
"label": "Ταξινόμηση κατά Πινγίν"
}
},
"stop": "σταματήστε",
"success": "Επιτυχία",
"swap": "Εναλλαγή",
"topics": "Θέματα",
@ -3804,6 +3805,9 @@
},
"title": "Ιστορικό μετάφρασης"
},
"info": {
"aborted": "Η μετάφραση διακόπηκε"
},
"input": {
"placeholder": "Εισαγάγετε κείμενο για μετάφραση"
},

View File

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

View File

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

View File

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

View File

@ -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<HTMLDivElement>(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 = () => {
/>
</Tooltip>
{getLanguageDisplay()}
<TranslateButton translating={translating} onTranslate={onTranslate} couldTranslate={couldTranslate} />
<TranslateButton
translating={translating}
onTranslate={onTranslate}
couldTranslate={couldTranslate}
onAbort={onAbort}
/>
</InnerOperationBar>
<InnerOperationBar style={{ justifyContent: 'flex-end' }}>
<ModelSelectButton
@ -903,11 +926,13 @@ const OutputText = styled.div`
const TranslateButton = ({
translating,
onTranslate,
couldTranslate
couldTranslate,
onAbort
}: {
translating: boolean
onTranslate: () => void
couldTranslate: boolean
onAbort: () => void
}) => {
const { t } = useTranslation()
return (
@ -922,14 +947,16 @@ const TranslateButton = ({
Shift + Enter: {t('translate.tooltip.newline')}
</div>
}>
<Button
type="primary"
loading={translating}
onClick={onTranslate}
disabled={!couldTranslate}
icon={<SendOutlined />}>
{t('translate.button.translate')}
</Button>
{!translating && (
<Button type="primary" onClick={onTranslate} disabled={!couldTranslate} icon={<SendOutlined />}>
{t('translate.button.translate')}
</Button>
)}
{translating && (
<Button danger type="primary" onClick={onAbort} icon={<CirclePause size={14} />}>
{t('common.stop')}
</Button>
)}
</Tooltip>
)
}

View File

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

View File

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

View File

@ -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<boolean>) => {
state.translating = action.payload
},
setTranslateAbortKey: (state, action: PayloadAction<string>) => {
state.translateAbortKey = action.payload
},
setMinappShow: (state, action: PayloadAction<boolean>) => {
state.minappShow = action.payload
},
@ -162,6 +166,7 @@ export const {
setAvatar,
setGenerating,
setTranslating,
setTranslateAbortKey,
setMinappShow,
setOpenedKeepAliveMinapps,
setOpenedOneOffMinapp,