feat(translate): 支持后台执行翻译任务

- 新增translate store模块管理翻译状态
- 实现useTranslate hook封装翻译逻辑
- 重构TranslatePage组件使用新的翻译逻辑
This commit is contained in:
icarus 2025-07-06 21:03:45 +08:00
parent ed9ecd4667
commit 6d929c322b
4 changed files with 126 additions and 50 deletions

View File

@ -0,0 +1,70 @@
import db from '@renderer/databases'
import { fetchTranslate } from '@renderer/services/ApiService'
import store, { useAppDispatch, useAppSelector } from '@renderer/store'
import {
setTranslatedContent as _setTranslatedContent,
setTranslating as _setTranslating
} from '@renderer/store/translate'
import { Assistant, TranslateHistory } from '@renderer/types'
import { uuid } from '@renderer/utils'
export default function useTranslate() {
const translatedContent = useAppSelector((state) => state.translate.translatedContent)
const translating = useAppSelector((state) => state.translate.translating)
const dispatch = useAppDispatch()
const setTranslatedContent = (content: string) => {
dispatch(_setTranslatedContent(content))
}
const setTranslating = (translating: boolean) => {
dispatch(_setTranslating(translating))
}
const translate = async (
text: string,
assistant: Assistant,
actualSourceLanguage: string,
actualTargetLanguage: string
) => {
setTranslating(true)
await fetchTranslate({
content: text,
assistant,
onResponse: (text) => {
setTranslatedContent(text)
}
})
const translatedContent = store.getState().translate.translatedContent
await saveTranslateHistory(text, translatedContent, actualSourceLanguage, actualTargetLanguage)
setTranslating(false)
}
const saveTranslateHistory = async (
sourceText: string,
targetText: string,
sourceLanguage: string,
targetLanguage: string
) => {
const history: TranslateHistory = {
id: uuid(),
sourceText,
targetText,
sourceLanguage,
targetLanguage,
createdAt: new Date().toISOString()
}
await db.translate_history.add(history)
}
return {
translatedContent,
translating,
setTranslatedContent,
setTranslating,
translate,
saveTranslateHistory
}
}

View File

@ -10,13 +10,13 @@ import db from '@renderer/databases'
import { useDefaultModel } from '@renderer/hooks/useAssistant'
import { useProviders } from '@renderer/hooks/useProvider'
import { useSettings } from '@renderer/hooks/useSettings'
import { fetchTranslate } from '@renderer/services/ApiService'
import useTranslate from '@renderer/hooks/useTranslate'
import { getDefaultTranslateAssistant } from '@renderer/services/AssistantService'
import { getModelUniqId, hasModel } from '@renderer/services/ModelService'
import { useAppDispatch } from '@renderer/store'
import { setTranslateModelPrompt } from '@renderer/store/settings'
import type { Model, TranslateHistory } from '@renderer/types'
import { runAsyncFunction, uuid } from '@renderer/utils'
import { runAsyncFunction } from '@renderer/utils'
import {
createInputScrollHandler,
createOutputScrollHandler,
@ -34,7 +34,6 @@ import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
let _text = ''
let _result = ''
let _targetLanguage = 'english'
const TranslateSettings: FC<{
@ -277,10 +276,8 @@ const TranslatePage: FC = () => {
const { shikiMarkdownIt } = useCodeStyle()
const [targetLanguage, setTargetLanguage] = useState(_targetLanguage)
const [text, setText] = useState(_text)
const [result, setResult] = useState(_result)
const [renderedMarkdown, setRenderedMarkdown] = useState<string>('')
const { translateModel, setTranslateModel } = useDefaultModel()
const [loading, setLoading] = useState(false)
const [copied, setCopied] = useState(false)
const [historyDrawerVisible, setHistoryDrawerVisible] = useState(false)
const [isScrollSyncEnabled, setIsScrollSyncEnabled] = useState(false)
@ -299,9 +296,9 @@ const TranslatePage: FC = () => {
const allModels = useMemo(() => providers.map((p) => p.models).flat(), [providers])
const translateHistory = useLiveQuery(() => db.translate_history.orderBy('createdAt').reverse().toArray(), [])
const { translatedContent, translating, translate, setTranslatedContent, setTranslating } = useTranslate()
_text = text
_result = result
_targetLanguage = targetLanguage
const selectOptions = useMemo(
@ -326,23 +323,6 @@ const TranslatePage: FC = () => {
db.settings.put({ id: 'translate:model', value: model.id })
}
const saveTranslateHistory = async (
sourceText: string,
targetText: string,
sourceLanguage: string,
targetLanguage: string
) => {
const history: TranslateHistory = {
id: uuid(),
sourceText,
targetText,
sourceLanguage,
targetLanguage,
createdAt: new Date().toISOString()
}
await db.translate_history.add(history)
}
const deleteHistory = async (id: string) => {
db.translate_history.delete(id)
}
@ -361,7 +341,7 @@ const TranslatePage: FC = () => {
return
}
setLoading(true)
setTranslating(true)
try {
// 确定源语言:如果用户选择了特定语言,使用用户选择的;如果选择'auto',则自动检测
let actualSourceLanguage: string
@ -385,7 +365,7 @@ const TranslatePage: FC = () => {
content: errorMessage,
key: 'translate-message'
})
setLoading(false)
setTranslating(false)
return
}
@ -395,25 +375,15 @@ const TranslatePage: FC = () => {
}
const assistant = getDefaultTranslateAssistant(actualTargetLanguage, text)
let translatedText = ''
await fetchTranslate({
content: text,
assistant,
onResponse: (text) => {
translatedText = text.replace(/^\s*\n+/g, '')
setResult(translatedText)
}
})
await saveTranslateHistory(text, translatedText, actualSourceLanguage, actualTargetLanguage)
setLoading(false)
await translate(text, assistant, actualSourceLanguage, actualTargetLanguage)
} catch (error) {
console.error('Translation error:', error)
window.message.error({
content: String(error),
key: 'translate-message'
})
setLoading(false)
setTranslating(false)
return
}
}
@ -424,26 +394,26 @@ const TranslatePage: FC = () => {
}
const onCopy = () => {
navigator.clipboard.writeText(result)
navigator.clipboard.writeText(translatedContent)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
const onHistoryItemClick = (history: TranslateHistory) => {
setText(history.sourceText)
setResult(history.targetText)
setTranslatedContent(history.targetText)
setTargetLanguage(history.targetLanguage)
}
useEffect(() => {
isEmpty(text) && setResult('')
}, [text])
isEmpty(text) && setTranslatedContent('')
}, [setTranslatedContent, text])
// Render markdown content when result or enableMarkdown changes
useEffect(() => {
if (enableMarkdown && result) {
if (enableMarkdown && translatedContent) {
let isMounted = true
shikiMarkdownIt(result).then((rendered) => {
shikiMarkdownIt(translatedContent).then((rendered) => {
if (isMounted) {
setRenderedMarkdown(rendered)
}
@ -455,7 +425,7 @@ const TranslatePage: FC = () => {
setRenderedMarkdown('')
return undefined
}
}, [result, enableMarkdown, shikiMarkdownIt])
}, [enableMarkdown, shikiMarkdownIt, translatedContent])
useEffect(() => {
runAsyncFunction(async () => {
@ -650,7 +620,7 @@ const TranslatePage: FC = () => {
}>
<TranslateButton
type="primary"
loading={loading}
loading={translating}
onClick={onTranslate}
disabled={!text.trim()}
icon={<SendOutlined />}>
@ -667,7 +637,7 @@ const TranslatePage: FC = () => {
onChange={(e) => setText(e.target.value)}
onKeyDown={onKeyDown}
onScroll={handleInputScroll}
disabled={loading}
disabled={translating}
spellCheck={false}
allowClear
/>
@ -680,18 +650,18 @@ const TranslatePage: FC = () => {
</HStack>
<CopyButton
onClick={onCopy}
disabled={!result}
disabled={!translatedContent}
icon={copied ? <CheckOutlined style={{ color: 'var(--color-primary)' }} /> : <CopyIcon />}
/>
</OperationBar>
<OutputText ref={outputTextRef} onScroll={handleOutputScroll} className={'selectable'}>
{!result ? (
{!translatedContent ? (
t('translate.output.placeholder')
) : enableMarkdown ? (
<div className="markdown" dangerouslySetInnerHTML={{ __html: renderedMarkdown }} />
) : (
<div className="plain">{result}</div>
<div className="plain">{translatedContent}</div>
)}
</OutputText>
</OutputContainer>

View File

@ -24,6 +24,7 @@ import runtime from './runtime'
import selectionStore from './selectionStore'
import settings from './settings'
import shortcuts from './shortcuts'
import translate from './translate'
import websearch from './websearch'
const rootReducer = combineReducers({
@ -47,7 +48,8 @@ const rootReducer = combineReducers({
preprocess,
messages: newMessagesReducer,
messageBlocks: messageBlocksReducer,
inputTools: inputToolsReducer
inputTools: inputToolsReducer,
translate
})
const persistedReducer = persistReducer(

View File

@ -0,0 +1,34 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
export interface TranslateState {
translating: boolean
translatedContent: string
}
const initialState: TranslateState = {
translating: false,
translatedContent: ''
}
const translateSlice = createSlice({
name: 'translate',
initialState,
reducers: {
setTranslating: (state, action: PayloadAction<boolean>) => {
return {
...state,
translating: action.payload
}
},
setTranslatedContent: (state, action: PayloadAction<string>) => {
return {
...state,
translatedContent: action.payload
}
}
}
})
export const { setTranslating, setTranslatedContent } = translateSlice.actions
export default translateSlice.reducer