mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-06 05:09:09 +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 { Modal, ModalProps } from 'antd'
|
||||||
import TextArea from 'antd/es/input/TextArea'
|
import TextArea from 'antd/es/input/TextArea'
|
||||||
import { TextAreaProps } from 'antd/lib/input'
|
import { TextAreaProps } from 'antd/lib/input'
|
||||||
import { TextAreaRef } from 'antd/lib/input/TextArea'
|
import { TextAreaRef } from 'antd/lib/input/TextArea'
|
||||||
|
import { Languages } from 'lucide-react'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
@ -23,7 +29,17 @@ const PopupContainer: React.FC<Props> = ({ text, textareaProps, modalProps, reso
|
|||||||
const [open, setOpen] = useState(true)
|
const [open, setOpen] = useState(true)
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [textValue, setTextValue] = useState(text)
|
const [textValue, setTextValue] = useState(text)
|
||||||
|
const [isTranslating, setIsTranslating] = useState(false)
|
||||||
const textareaRef = useRef<TextAreaRef>(null)
|
const textareaRef = useRef<TextAreaRef>(null)
|
||||||
|
const { translateModel } = useDefaultModel()
|
||||||
|
const { targetLanguage, showTranslateConfirm } = useSettings()
|
||||||
|
const isMounted = useRef(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
isMounted.current = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const onOk = () => {
|
const onOk = () => {
|
||||||
setOpen(false)
|
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
|
TextEditPopup.hide = onCancel
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -78,16 +137,24 @@ const PopupContainer: React.FC<Props> = ({ text, textareaProps, modalProps, reso
|
|||||||
afterClose={onClose}
|
afterClose={onClose}
|
||||||
afterOpenChange={handleAfterOpenChange}
|
afterOpenChange={handleAfterOpenChange}
|
||||||
centered>
|
centered>
|
||||||
<TextArea
|
<TextAreaContainer>
|
||||||
ref={textareaRef}
|
<TextArea
|
||||||
rows={2}
|
ref={textareaRef}
|
||||||
autoFocus
|
rows={2}
|
||||||
spellCheck={false}
|
autoFocus
|
||||||
{...textareaProps}
|
spellCheck={false}
|
||||||
value={textValue}
|
{...textareaProps}
|
||||||
onInput={resizeTextArea}
|
value={textValue}
|
||||||
onChange={(e) => setTextValue(e.target.value)}
|
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>
|
<ChildrenContainer>{children && children({ onOk, onCancel })}</ChildrenContainer>
|
||||||
</Modal>
|
</Modal>
|
||||||
)
|
)
|
||||||
@ -99,6 +166,35 @@ const ChildrenContainer = styled.div`
|
|||||||
position: relative;
|
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 {
|
export default class TextEditPopup {
|
||||||
static topviewId = 0
|
static topviewId = 0
|
||||||
static hide() {
|
static hide() {
|
||||||
|
|||||||
@ -29,7 +29,10 @@ describe('Unclassified Utils', () => {
|
|||||||
const start = Date.now()
|
const start = Date.now()
|
||||||
await delay(0.01)
|
await delay(0.01)
|
||||||
const end = Date.now()
|
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 () => {
|
it('should resolve immediately for zero delay', async () => {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user