mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-25 03:10:08 +08:00
feat: popup question editor support translation assistant (#5660)
* feat: TextEditPopup support translation assistant * polish: ensure safe state updates in TextEditPopup during unmount * test: make delay assertion more lenient in unclassified utils tests * feat: add loading indicator to translation button in TextEditPopup
This commit is contained in:
parent
98a2269b8a
commit
80d970050e
@ -1,7 +1,13 @@
|
||||
import { LoadingOutlined } from '@ant-design/icons'
|
||||
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 { Modal, ModalProps } from 'antd'
|
||||
import TextArea from 'antd/es/input/TextArea'
|
||||
import { TextAreaProps } from 'antd/lib/input'
|
||||
import { TextAreaRef } from 'antd/lib/input/TextArea'
|
||||
import { Languages } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
@ -23,7 +29,17 @@ const PopupContainer: React.FC<Props> = ({ text, textareaProps, modalProps, reso
|
||||
const [open, setOpen] = useState(true)
|
||||
const { t } = useTranslation()
|
||||
const [textValue, setTextValue] = useState(text)
|
||||
const [isTranslating, setIsTranslating] = useState(false)
|
||||
const textareaRef = useRef<TextAreaRef>(null)
|
||||
const { translateModel } = useDefaultModel()
|
||||
const { targetLanguage, showTranslateConfirm } = useSettings()
|
||||
const isMounted = useRef(true)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isMounted.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const onOk = () => {
|
||||
setOpen(false)
|
||||
@ -62,6 +78,49 @@ const PopupContainer: React.FC<Props> = ({ text, textareaProps, modalProps, reso
|
||||
}
|
||||
}
|
||||
|
||||
const handleTranslate = async () => {
|
||||
if (!textValue.trim() || isTranslating) return
|
||||
|
||||
if (showTranslateConfirm) {
|
||||
const confirmed = await window?.modal?.confirm({
|
||||
title: t('translate.confirm.title'),
|
||||
content: t('translate.confirm.content'),
|
||||
centered: true
|
||||
})
|
||||
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(targetLanguage, textValue)
|
||||
const translatedText = await fetchTranslate({ content: textValue, assistant })
|
||||
if (isMounted.current) {
|
||||
setTextValue(translatedText)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Translation failed:', error)
|
||||
window.message.error({
|
||||
content: t('translate.error.failed'),
|
||||
key: 'translate-message'
|
||||
})
|
||||
} finally {
|
||||
if (isMounted.current) {
|
||||
setIsTranslating(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextEditPopup.hide = onCancel
|
||||
|
||||
return (
|
||||
@ -78,16 +137,24 @@ const PopupContainer: React.FC<Props> = ({ text, textareaProps, modalProps, reso
|
||||
afterClose={onClose}
|
||||
afterOpenChange={handleAfterOpenChange}
|
||||
centered>
|
||||
<TextArea
|
||||
ref={textareaRef}
|
||||
rows={2}
|
||||
autoFocus
|
||||
spellCheck={false}
|
||||
{...textareaProps}
|
||||
value={textValue}
|
||||
onInput={resizeTextArea}
|
||||
onChange={(e) => setTextValue(e.target.value)}
|
||||
/>
|
||||
<TextAreaContainer>
|
||||
<TextArea
|
||||
ref={textareaRef}
|
||||
rows={2}
|
||||
autoFocus
|
||||
spellCheck={false}
|
||||
{...textareaProps}
|
||||
value={textValue}
|
||||
onInput={resizeTextArea}
|
||||
onChange={(e) => setTextValue(e.target.value)}
|
||||
/>
|
||||
<TranslateButton
|
||||
onClick={handleTranslate}
|
||||
aria-label="Translate text"
|
||||
disabled={isTranslating || !textValue.trim()}>
|
||||
{isTranslating ? <LoadingOutlined spin /> : <Languages size={16} />}
|
||||
</TranslateButton>
|
||||
</TextAreaContainer>
|
||||
<ChildrenContainer>{children && children({ onOk, onCancel })}</ChildrenContainer>
|
||||
</Modal>
|
||||
)
|
||||
@ -99,6 +166,35 @@ const ChildrenContainer = styled.div`
|
||||
position: relative;
|
||||
`
|
||||
|
||||
const TextAreaContainer = styled.div`
|
||||
position: relative;
|
||||
`
|
||||
|
||||
const TranslateButton = styled.button`
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 8px;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--color-icon);
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-background-mute);
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`
|
||||
|
||||
export default class TextEditPopup {
|
||||
static topviewId = 0
|
||||
static hide() {
|
||||
|
||||
@ -29,7 +29,10 @@ describe('Unclassified Utils', () => {
|
||||
const start = Date.now()
|
||||
await delay(0.01)
|
||||
const end = Date.now()
|
||||
expect(end - start).toBeGreaterThanOrEqual(10)
|
||||
// In JavaScript, the delay time of setTimeout is not always precise
|
||||
// and may be slightly shorter than specified. Make it more lenient:
|
||||
const lenientRatio = 0.8
|
||||
expect(end - start).toBeGreaterThanOrEqual(10 * lenientRatio)
|
||||
})
|
||||
|
||||
it('should resolve immediately for zero delay', async () => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user