mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-07 05:39:05 +08:00
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:
parent
aaa0eb7140
commit
7407bb335d
@ -112,7 +112,7 @@ export default class AiProvider {
|
|||||||
builder.remove(ToolUseExtractionMiddlewareName)
|
builder.remove(ToolUseExtractionMiddlewareName)
|
||||||
logger.silly('ToolUseExtractionMiddleware is removed')
|
logger.silly('ToolUseExtractionMiddleware is removed')
|
||||||
}
|
}
|
||||||
if (params.callType !== 'chat') {
|
if (params.callType !== 'chat' && params.callType !== 'check' && params.callType !== 'translate') {
|
||||||
logger.silly('AbortHandlerMiddleware is removed')
|
logger.silly('AbortHandlerMiddleware is removed')
|
||||||
builder.remove(AbortHandlerMiddlewareName)
|
builder.remove(AbortHandlerMiddlewareName)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,32 +21,38 @@ export const AbortHandlerMiddleware: CompletionsMiddleware =
|
|||||||
return result
|
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 abortController = new AbortController()
|
||||||
const abortFn = (): void => abortController.abort()
|
const abortFn = (): void => abortController.abort()
|
||||||
|
|
||||||
addAbortController(messageId, abortFn)
|
|
||||||
|
|
||||||
let abortSignal: AbortSignal | null = abortController.signal
|
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 => {
|
const cleanup = (): void => {
|
||||||
removeAbortController(messageId as string, abortFn)
|
removeAbortController(abortKey, abortFn)
|
||||||
if (ctx._internal?.flowControl) {
|
if (ctx._internal?.flowControl) {
|
||||||
ctx._internal.flowControl.abortController = undefined
|
ctx._internal.flowControl.abortController = undefined
|
||||||
ctx._internal.flowControl.abortSignal = undefined
|
ctx._internal.flowControl.abortSignal = undefined
|
||||||
|
|||||||
@ -59,6 +59,9 @@ export interface CompletionsParams {
|
|||||||
contextCount?: number
|
contextCount?: number
|
||||||
topicId?: string // 主题ID,用于关联上下文
|
topicId?: string // 主题ID,用于关联上下文
|
||||||
|
|
||||||
|
// abort 控制
|
||||||
|
abortKey?: string
|
||||||
|
|
||||||
_internal?: ProcessingState
|
_internal?: ProcessingState
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -789,6 +789,7 @@
|
|||||||
"label": "Sort by Pinyin"
|
"label": "Sort by Pinyin"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"stop": "Stop",
|
||||||
"success": "Success",
|
"success": "Success",
|
||||||
"swap": "Swap",
|
"swap": "Swap",
|
||||||
"topics": "Topics",
|
"topics": "Topics",
|
||||||
@ -3805,6 +3806,9 @@
|
|||||||
},
|
},
|
||||||
"title": "Translation History"
|
"title": "Translation History"
|
||||||
},
|
},
|
||||||
|
"info": {
|
||||||
|
"aborted": "Translation aborted"
|
||||||
|
},
|
||||||
"input": {
|
"input": {
|
||||||
"placeholder": "Enter text to translate"
|
"placeholder": "Enter text to translate"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -789,6 +789,7 @@
|
|||||||
"label": "ピンインでソート"
|
"label": "ピンインでソート"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"stop": "停止",
|
||||||
"success": "成功",
|
"success": "成功",
|
||||||
"swap": "交換",
|
"swap": "交換",
|
||||||
"topics": "トピック",
|
"topics": "トピック",
|
||||||
@ -3805,6 +3806,9 @@
|
|||||||
},
|
},
|
||||||
"title": "翻訳履歴"
|
"title": "翻訳履歴"
|
||||||
},
|
},
|
||||||
|
"info": {
|
||||||
|
"aborted": "翻訳中止"
|
||||||
|
},
|
||||||
"input": {
|
"input": {
|
||||||
"placeholder": "翻訳するテキストを入力"
|
"placeholder": "翻訳するテキストを入力"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -789,6 +789,7 @@
|
|||||||
"label": "Сортировать по пиньинь"
|
"label": "Сортировать по пиньинь"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"stop": "остановить",
|
||||||
"success": "Успешно",
|
"success": "Успешно",
|
||||||
"swap": "Поменять местами",
|
"swap": "Поменять местами",
|
||||||
"topics": "Топики",
|
"topics": "Топики",
|
||||||
@ -3805,6 +3806,9 @@
|
|||||||
},
|
},
|
||||||
"title": "История переводов"
|
"title": "История переводов"
|
||||||
},
|
},
|
||||||
|
"info": {
|
||||||
|
"aborted": "Перевод прерван"
|
||||||
|
},
|
||||||
"input": {
|
"input": {
|
||||||
"placeholder": "Введите текст для перевода"
|
"placeholder": "Введите текст для перевода"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -789,6 +789,7 @@
|
|||||||
"label": "按拼音排序"
|
"label": "按拼音排序"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"stop": "停止",
|
||||||
"success": "成功",
|
"success": "成功",
|
||||||
"swap": "交换",
|
"swap": "交换",
|
||||||
"topics": "话题",
|
"topics": "话题",
|
||||||
@ -3805,6 +3806,9 @@
|
|||||||
},
|
},
|
||||||
"title": "翻译历史"
|
"title": "翻译历史"
|
||||||
},
|
},
|
||||||
|
"info": {
|
||||||
|
"aborted": "翻译中止"
|
||||||
|
},
|
||||||
"input": {
|
"input": {
|
||||||
"placeholder": "输入文本进行翻译"
|
"placeholder": "输入文本进行翻译"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -789,6 +789,7 @@
|
|||||||
"label": "按拼音排序"
|
"label": "按拼音排序"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"stop": "停止",
|
||||||
"success": "成功",
|
"success": "成功",
|
||||||
"swap": "交換",
|
"swap": "交換",
|
||||||
"topics": "話題",
|
"topics": "話題",
|
||||||
@ -3805,6 +3806,9 @@
|
|||||||
},
|
},
|
||||||
"title": "翻譯歷史"
|
"title": "翻譯歷史"
|
||||||
},
|
},
|
||||||
|
"info": {
|
||||||
|
"aborted": "翻譯中止"
|
||||||
|
},
|
||||||
"input": {
|
"input": {
|
||||||
"placeholder": "輸入文字進行翻譯"
|
"placeholder": "輸入文字進行翻譯"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -789,6 +789,7 @@
|
|||||||
"label": "Ταξινόμηση κατά Πινγίν"
|
"label": "Ταξινόμηση κατά Πινγίν"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"stop": "σταματήστε",
|
||||||
"success": "Επιτυχία",
|
"success": "Επιτυχία",
|
||||||
"swap": "Εναλλαγή",
|
"swap": "Εναλλαγή",
|
||||||
"topics": "Θέματα",
|
"topics": "Θέματα",
|
||||||
@ -3804,6 +3805,9 @@
|
|||||||
},
|
},
|
||||||
"title": "Ιστορικό μετάφρασης"
|
"title": "Ιστορικό μετάφρασης"
|
||||||
},
|
},
|
||||||
|
"info": {
|
||||||
|
"aborted": "Η μετάφραση διακόπηκε"
|
||||||
|
},
|
||||||
"input": {
|
"input": {
|
||||||
"placeholder": "Εισαγάγετε κείμενο για μετάφραση"
|
"placeholder": "Εισαγάγετε κείμενο για μετάφραση"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -789,6 +789,7 @@
|
|||||||
"label": "Ordenar por pinyin"
|
"label": "Ordenar por pinyin"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"stop": "Detener",
|
||||||
"success": "Éxito",
|
"success": "Éxito",
|
||||||
"swap": "Intercambiar",
|
"swap": "Intercambiar",
|
||||||
"topics": "Temas",
|
"topics": "Temas",
|
||||||
@ -3804,6 +3805,9 @@
|
|||||||
},
|
},
|
||||||
"title": "Historial de traducciones"
|
"title": "Historial de traducciones"
|
||||||
},
|
},
|
||||||
|
"info": {
|
||||||
|
"aborted": "Traducción cancelada"
|
||||||
|
},
|
||||||
"input": {
|
"input": {
|
||||||
"placeholder": "Ingrese el texto para traducir"
|
"placeholder": "Ingrese el texto para traducir"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -789,6 +789,7 @@
|
|||||||
"label": "Сортировать по пиньинь"
|
"label": "Сортировать по пиньинь"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"stop": "Arrêter",
|
||||||
"success": "Succès",
|
"success": "Succès",
|
||||||
"swap": "Échanger",
|
"swap": "Échanger",
|
||||||
"topics": "Sujets",
|
"topics": "Sujets",
|
||||||
@ -3804,6 +3805,9 @@
|
|||||||
},
|
},
|
||||||
"title": "Historique des traductions"
|
"title": "Historique des traductions"
|
||||||
},
|
},
|
||||||
|
"info": {
|
||||||
|
"aborted": "Traduction annulée"
|
||||||
|
},
|
||||||
"input": {
|
"input": {
|
||||||
"placeholder": "entrez le texte à traduire"
|
"placeholder": "entrez le texte à traduire"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -789,6 +789,7 @@
|
|||||||
"label": "Ordenar por Pinyin"
|
"label": "Ordenar por Pinyin"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"stop": "Parar",
|
||||||
"success": "Sucesso",
|
"success": "Sucesso",
|
||||||
"swap": "Trocar",
|
"swap": "Trocar",
|
||||||
"topics": "Tópicos",
|
"topics": "Tópicos",
|
||||||
@ -3804,6 +3805,9 @@
|
|||||||
},
|
},
|
||||||
"title": "Histórico de Tradução"
|
"title": "Histórico de Tradução"
|
||||||
},
|
},
|
||||||
|
"info": {
|
||||||
|
"aborted": "Tradução interrompida"
|
||||||
|
},
|
||||||
"input": {
|
"input": {
|
||||||
"placeholder": "Digite o texto para traduzir"
|
"placeholder": "Digite o texto para traduzir"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -17,7 +17,7 @@ import useTranslate from '@renderer/hooks/useTranslate'
|
|||||||
import { estimateTextTokens } from '@renderer/services/TokenService'
|
import { estimateTextTokens } from '@renderer/services/TokenService'
|
||||||
import { saveTranslateHistory, translateText } from '@renderer/services/TranslateService'
|
import { saveTranslateHistory, translateText } from '@renderer/services/TranslateService'
|
||||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
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 { setTranslatedContent as setTranslatedContentAction, setTranslateInput } from '@renderer/store/translate'
|
||||||
import {
|
import {
|
||||||
type AutoDetectionMethod,
|
type AutoDetectionMethod,
|
||||||
@ -27,7 +27,9 @@ import {
|
|||||||
type TranslateHistory,
|
type TranslateHistory,
|
||||||
type TranslateLanguage
|
type TranslateLanguage
|
||||||
} from '@renderer/types'
|
} 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 { formatErrorMessage } from '@renderer/utils/error'
|
||||||
import { getFilesFromDropEvent, getTextFromDropEvent } from '@renderer/utils/input'
|
import { getFilesFromDropEvent, getTextFromDropEvent } from '@renderer/utils/input'
|
||||||
import {
|
import {
|
||||||
@ -40,7 +42,7 @@ import { imageExts, MB, textExts } from '@shared/config/constant'
|
|||||||
import { Button, Flex, FloatButton, Popover, Tooltip, Typography } from 'antd'
|
import { Button, Flex, FloatButton, Popover, Tooltip, Typography } from 'antd'
|
||||||
import TextArea, { TextAreaRef } from 'antd/es/input/TextArea'
|
import TextArea, { TextAreaRef } from 'antd/es/input/TextArea'
|
||||||
import { isEmpty, throttle } from 'lodash'
|
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 { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
@ -86,6 +88,7 @@ const TranslatePage: FC = () => {
|
|||||||
const text = useAppSelector((state) => state.translate.translateInput)
|
const text = useAppSelector((state) => state.translate.translateInput)
|
||||||
const translatedContent = useAppSelector((state) => state.translate.translatedContent)
|
const translatedContent = useAppSelector((state) => state.translate.translatedContent)
|
||||||
const translating = useAppSelector((state) => state.runtime.translating)
|
const translating = useAppSelector((state) => state.runtime.translating)
|
||||||
|
const abortKey = useAppSelector((state) => state.runtime.translateAbortKey)
|
||||||
|
|
||||||
// ref
|
// ref
|
||||||
const contentContainerRef = useRef<HTMLDivElement>(null)
|
const contentContainerRef = useRef<HTMLDivElement>(null)
|
||||||
@ -144,11 +147,16 @@ const TranslatePage: FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let translated: string
|
let translated: string
|
||||||
|
const abortKey = uuid()
|
||||||
|
dispatch(setTranslateAbortKey(abortKey))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
translated = await translateText(text, actualTargetLanguage, throttle(setTranslatedContent, 100))
|
translated = await translateText(text, actualTargetLanguage, throttle(setTranslatedContent, 100), abortKey)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Failed to translate text', e as Error)
|
if (!isAbortError(e)) {
|
||||||
window.message.error(t('translate.error.failed' + ': ' + (e as Error).message))
|
logger.error('Failed to translate text', e as Error)
|
||||||
|
window.message.error(t('translate.error.failed' + ': ' + (e as Error).message))
|
||||||
|
}
|
||||||
setTranslating(false)
|
setTranslating(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -166,11 +174,12 @@ const TranslatePage: FC = () => {
|
|||||||
window.message.error(t('translate.error.unknown') + ': ' + (e as Error).message)
|
window.message.error(t('translate.error.unknown') + ': ' + (e as Error).message)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[setTranslatedContent, setTranslating, t, translating]
|
[dispatch, setTranslatedContent, setTranslating, t, translating]
|
||||||
)
|
)
|
||||||
|
|
||||||
// 控制翻译按钮,翻译前进行校验
|
// 控制翻译按钮,翻译前进行校验
|
||||||
const onTranslate = useCallback(async () => {
|
const onTranslate = useCallback(async () => {
|
||||||
|
if (!couldTranslate) return
|
||||||
if (!text.trim()) return
|
if (!text.trim()) return
|
||||||
if (!translateModel) {
|
if (!translateModel) {
|
||||||
window.message.error({
|
window.message.error({
|
||||||
@ -237,6 +246,15 @@ const TranslatePage: FC = () => {
|
|||||||
translateModel
|
translateModel
|
||||||
])
|
])
|
||||||
|
|
||||||
|
// 控制停止翻译
|
||||||
|
const onAbort = async () => {
|
||||||
|
if (!abortKey || !abortKey.trim()) {
|
||||||
|
logger.error('Failed to abort. Invalid abortKey.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
abortCompletion(abortKey)
|
||||||
|
}
|
||||||
|
|
||||||
// 控制双向翻译切换
|
// 控制双向翻译切换
|
||||||
const toggleBidirectional = (value: boolean) => {
|
const toggleBidirectional = (value: boolean) => {
|
||||||
setIsBidirectional(value)
|
setIsBidirectional(value)
|
||||||
@ -607,7 +625,7 @@ const TranslatePage: FC = () => {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
processFile(selectedFile)
|
await processFile(selectedFile)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('onPaste:', error as Error)
|
logger.error('onPaste:', error as Error)
|
||||||
window.message.error(t('chat.input.file_error'))
|
window.message.error(t('chat.input.file_error'))
|
||||||
@ -672,7 +690,12 @@ const TranslatePage: FC = () => {
|
|||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{getLanguageDisplay()}
|
{getLanguageDisplay()}
|
||||||
<TranslateButton translating={translating} onTranslate={onTranslate} couldTranslate={couldTranslate} />
|
<TranslateButton
|
||||||
|
translating={translating}
|
||||||
|
onTranslate={onTranslate}
|
||||||
|
couldTranslate={couldTranslate}
|
||||||
|
onAbort={onAbort}
|
||||||
|
/>
|
||||||
</InnerOperationBar>
|
</InnerOperationBar>
|
||||||
<InnerOperationBar style={{ justifyContent: 'flex-end' }}>
|
<InnerOperationBar style={{ justifyContent: 'flex-end' }}>
|
||||||
<ModelSelectButton
|
<ModelSelectButton
|
||||||
@ -903,11 +926,13 @@ const OutputText = styled.div`
|
|||||||
const TranslateButton = ({
|
const TranslateButton = ({
|
||||||
translating,
|
translating,
|
||||||
onTranslate,
|
onTranslate,
|
||||||
couldTranslate
|
couldTranslate,
|
||||||
|
onAbort
|
||||||
}: {
|
}: {
|
||||||
translating: boolean
|
translating: boolean
|
||||||
onTranslate: () => void
|
onTranslate: () => void
|
||||||
couldTranslate: boolean
|
couldTranslate: boolean
|
||||||
|
onAbort: () => void
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
return (
|
return (
|
||||||
@ -922,14 +947,16 @@ const TranslateButton = ({
|
|||||||
Shift + Enter: {t('translate.tooltip.newline')}
|
Shift + Enter: {t('translate.tooltip.newline')}
|
||||||
</div>
|
</div>
|
||||||
}>
|
}>
|
||||||
<Button
|
{!translating && (
|
||||||
type="primary"
|
<Button type="primary" onClick={onTranslate} disabled={!couldTranslate} icon={<SendOutlined />}>
|
||||||
loading={translating}
|
{t('translate.button.translate')}
|
||||||
onClick={onTranslate}
|
</Button>
|
||||||
disabled={!couldTranslate}
|
)}
|
||||||
icon={<SendOutlined />}>
|
{translating && (
|
||||||
{t('translate.button.translate')}
|
<Button danger type="primary" onClick={onAbort} icon={<CirclePause size={14} />}>
|
||||||
</Button>
|
{t('common.stop')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -39,12 +39,7 @@ import { type Chunk, ChunkType } from '@renderer/types/chunk'
|
|||||||
import { Message } from '@renderer/types/newMessage'
|
import { Message } from '@renderer/types/newMessage'
|
||||||
import { SdkModel } from '@renderer/types/sdk'
|
import { SdkModel } from '@renderer/types/sdk'
|
||||||
import { removeSpecialCharactersForTopicName, uuid } from '@renderer/utils'
|
import { removeSpecialCharactersForTopicName, uuid } from '@renderer/utils'
|
||||||
import {
|
import { abortCompletion } from '@renderer/utils/abortController'
|
||||||
abortCompletion,
|
|
||||||
addAbortController,
|
|
||||||
createAbortPromise,
|
|
||||||
removeAbortController
|
|
||||||
} from '@renderer/utils/abortController'
|
|
||||||
import { isAbortError } from '@renderer/utils/error'
|
import { isAbortError } from '@renderer/utils/error'
|
||||||
import { extractInfoFromXML, ExtractResults } from '@renderer/utils/extract'
|
import { extractInfoFromXML, ExtractResults } from '@renderer/utils/extract'
|
||||||
import { filterAdjacentUserMessaegs, filterLastAssistantMessage } from '@renderer/utils/messageUtils/filters'
|
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> {
|
export async function checkApi(provider: Provider, model: Model, timeout = 15000): Promise<void> {
|
||||||
checkApiProvider(provider)
|
checkApiProvider(provider)
|
||||||
|
|
||||||
const controller = new AbortController()
|
|
||||||
const abortFn = () => controller.abort()
|
|
||||||
const taskId = uuid()
|
const taskId = uuid()
|
||||||
addAbortController(taskId, abortFn)
|
|
||||||
|
|
||||||
const ai = new AiProvider(provider)
|
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))
|
const timerPromise = new Promise((_, reject) => setTimeout(() => reject('Timeout'), timeout))
|
||||||
await Promise.race([ai.getEmbeddingDimensions(model), timerPromise])
|
await Promise.race([ai.getEmbeddingDimensions(model), timerPromise])
|
||||||
} else {
|
} else {
|
||||||
// 通过该状态判断abort原因
|
|
||||||
let streamError: Error | undefined = undefined
|
let streamError: Error | undefined = undefined
|
||||||
|
|
||||||
// 15s超时
|
// 15s超时
|
||||||
@ -905,31 +896,25 @@ export async function checkApi(provider: Provider, model: Model, timeout = 15000
|
|||||||
assistant,
|
assistant,
|
||||||
streamOutput: true,
|
streamOutput: true,
|
||||||
enableReasoning: false,
|
enableReasoning: false,
|
||||||
onChunk: () => {
|
onChunk: (chunk: Chunk) => {
|
||||||
// 接收到任意chunk都直接abort
|
if (chunk.type === ChunkType.ERROR && !isAbortError(chunk.error)) {
|
||||||
|
streamError = new Error(JSON.stringify(chunk.error))
|
||||||
|
}
|
||||||
abortCompletion(taskId)
|
abortCompletion(taskId)
|
||||||
},
|
},
|
||||||
onError: (e) => {
|
shouldThrow: true,
|
||||||
// 捕获stream error
|
abortKey: taskId
|
||||||
streamError = e
|
|
||||||
abortCompletion(taskId)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try streaming check first
|
// Try streaming check first
|
||||||
try {
|
try {
|
||||||
await createAbortPromise(controller.signal, ai.completions(params))
|
await ai.completions(params)
|
||||||
} catch (e: any) {
|
|
||||||
if (isAbortError(e)) {
|
|
||||||
if (streamError) {
|
|
||||||
throw streamError
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timer)
|
clearTimeout(timer)
|
||||||
}
|
}
|
||||||
|
if (streamError) {
|
||||||
|
throw streamError
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// FIXME: 这种判断方法无法严格保证错误是流式引起的
|
// FIXME: 这种判断方法无法严格保证错误是流式引起的
|
||||||
@ -947,8 +932,6 @@ export async function checkApi(provider: Provider, model: Model, timeout = 15000
|
|||||||
} else {
|
} else {
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
removeAbortController(taskId, abortFn)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -9,7 +9,9 @@ import {
|
|||||||
import { db } from '@renderer/databases'
|
import { db } from '@renderer/databases'
|
||||||
import { CustomTranslateLanguage, TranslateHistory, TranslateLanguage, TranslateLanguageCode } from '@renderer/types'
|
import { CustomTranslateLanguage, TranslateHistory, TranslateLanguage, TranslateLanguageCode } from '@renderer/types'
|
||||||
import { TranslateAssistant } from '@renderer/types'
|
import { TranslateAssistant } from '@renderer/types'
|
||||||
|
import { ChunkType } from '@renderer/types/chunk'
|
||||||
import { uuid } from '@renderer/utils'
|
import { uuid } from '@renderer/utils'
|
||||||
|
import { formatErrorMessage, isAbortError } from '@renderer/utils/error'
|
||||||
import { t } from 'i18next'
|
import { t } from 'i18next'
|
||||||
|
|
||||||
import { hasApiKey } from './ApiService'
|
import { hasApiKey } from './ApiService'
|
||||||
@ -24,9 +26,10 @@ const logger = loggerService.withContext('TranslateService')
|
|||||||
interface FetchTranslateProps {
|
interface FetchTranslateProps {
|
||||||
assistant: TranslateAssistant
|
assistant: TranslateAssistant
|
||||||
onResponse?: (text: string, isComplete: boolean) => void
|
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()
|
const model = getTranslateModel() || assistant.model || getDefaultModel()
|
||||||
|
|
||||||
if (!model) {
|
if (!model) {
|
||||||
@ -51,6 +54,7 @@ async function fetchTranslate({ assistant, onResponse }: FetchTranslateProps) {
|
|||||||
((isSupportedThinkingTokenModel(model) || isSupportedReasoningEffortModel(model)) &&
|
((isSupportedThinkingTokenModel(model) || isSupportedReasoningEffortModel(model)) &&
|
||||||
assistant.settings?.reasoning_effort !== undefined) ||
|
assistant.settings?.reasoning_effort !== undefined) ||
|
||||||
(isReasoningModel(model) && (!isSupportedThinkingTokenModel(model) || !isSupportedReasoningEffortModel(model)))
|
(isReasoningModel(model) && (!isSupportedThinkingTokenModel(model) || !isSupportedReasoningEffortModel(model)))
|
||||||
|
let abortError
|
||||||
|
|
||||||
const params: CompletionsParams = {
|
const params: CompletionsParams = {
|
||||||
callType: 'translate',
|
callType: 'translate',
|
||||||
@ -58,12 +62,22 @@ async function fetchTranslate({ assistant, onResponse }: FetchTranslateProps) {
|
|||||||
assistant: { ...assistant, model },
|
assistant: { ...assistant, model },
|
||||||
streamOutput: stream,
|
streamOutput: stream,
|
||||||
enableReasoning,
|
enableReasoning,
|
||||||
onResponse
|
onResponse,
|
||||||
|
onChunk: (chunk) => {
|
||||||
|
if (chunk.type === ChunkType.ERROR && isAbortError(chunk.error)) {
|
||||||
|
abortError = chunk.error
|
||||||
|
}
|
||||||
|
},
|
||||||
|
abortKey
|
||||||
}
|
}
|
||||||
|
|
||||||
const AI = new AiProvider(provider)
|
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 text - 需要翻译的文本内容
|
||||||
* @param targetLanguage - 目标语言
|
* @param targetLanguage - 目标语言
|
||||||
* @param onResponse - 流式输出的回调函数,用于实时获取翻译结果
|
* @param onResponse - 流式输出的回调函数,用于实时获取翻译结果
|
||||||
|
* @param abortKey - 用于控制 abort 的键
|
||||||
* @returns 返回翻译后的文本
|
* @returns 返回翻译后的文本
|
||||||
* @throws {Error} 当翻译模型未配置或翻译失败时抛出错误
|
* @throws {Error} 翻译中止或失败时抛出异常
|
||||||
*/
|
*/
|
||||||
export const translateText = async (
|
export const translateText = async (
|
||||||
text: string,
|
text: string,
|
||||||
targetLanguage: TranslateLanguage,
|
targetLanguage: TranslateLanguage,
|
||||||
onResponse?: (text: string, isComplete: boolean) => void
|
onResponse?: (text: string, isComplete: boolean) => void,
|
||||||
|
abortKey?: string
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const assistant = getDefaultTranslateAssistant(targetLanguage, text)
|
const assistant = getDefaultTranslateAssistant(targetLanguage, text)
|
||||||
|
|
||||||
const translatedText = await fetchTranslate({ assistant, onResponse })
|
const translatedText = await fetchTranslate({ assistant, onResponse, abortKey })
|
||||||
|
|
||||||
const trimmedText = translatedText.trim()
|
const trimmedText = translatedText.trim()
|
||||||
|
|
||||||
@ -92,10 +108,13 @@ export const translateText = async (
|
|||||||
|
|
||||||
return trimmedText
|
return trimmedText
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Failed to translate', e as Error)
|
if (isAbortError(e)) {
|
||||||
const message = e instanceof Error ? e.message : String(e)
|
window.message.info(t('translate.info.aborted'))
|
||||||
window.message.error(t('translate.error.failed' + ': ' + message))
|
} else {
|
||||||
return ''
|
logger.error('Failed to translate', e as Error)
|
||||||
|
window.message.error(t('translate.error.failed' + ': ' + formatErrorMessage(e)))
|
||||||
|
}
|
||||||
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -30,6 +30,7 @@ export interface RuntimeState {
|
|||||||
avatar: string
|
avatar: string
|
||||||
generating: boolean
|
generating: boolean
|
||||||
translating: boolean
|
translating: boolean
|
||||||
|
translateAbortKey?: string
|
||||||
/** whether the minapp popup is shown */
|
/** whether the minapp popup is shown */
|
||||||
minappShow: boolean
|
minappShow: boolean
|
||||||
/** the minapps that are opened and should be keep alive */
|
/** the minapps that are opened and should be keep alive */
|
||||||
@ -98,6 +99,9 @@ const runtimeSlice = createSlice({
|
|||||||
setTranslating: (state, action: PayloadAction<boolean>) => {
|
setTranslating: (state, action: PayloadAction<boolean>) => {
|
||||||
state.translating = action.payload
|
state.translating = action.payload
|
||||||
},
|
},
|
||||||
|
setTranslateAbortKey: (state, action: PayloadAction<string>) => {
|
||||||
|
state.translateAbortKey = action.payload
|
||||||
|
},
|
||||||
setMinappShow: (state, action: PayloadAction<boolean>) => {
|
setMinappShow: (state, action: PayloadAction<boolean>) => {
|
||||||
state.minappShow = action.payload
|
state.minappShow = action.payload
|
||||||
},
|
},
|
||||||
@ -162,6 +166,7 @@ export const {
|
|||||||
setAvatar,
|
setAvatar,
|
||||||
setGenerating,
|
setGenerating,
|
||||||
setTranslating,
|
setTranslating,
|
||||||
|
setTranslateAbortKey,
|
||||||
setMinappShow,
|
setMinappShow,
|
||||||
setOpenedKeepAliveMinapps,
|
setOpenedKeepAliveMinapps,
|
||||||
setOpenedOneOffMinapp,
|
setOpenedOneOffMinapp,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user