544 lines
15 KiB
TypeScript
544 lines
15 KiB
TypeScript
import {
|
|
CheckOutlined,
|
|
DeleteOutlined,
|
|
HistoryOutlined,
|
|
SendOutlined,
|
|
SettingOutlined,
|
|
SyncOutlined,
|
|
WarningOutlined
|
|
} from '@ant-design/icons'
|
|
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
|
|
import CopyIcon from '@renderer/components/Icons/CopyIcon'
|
|
import { isLocalAi } from '@renderer/config/env'
|
|
import { translateLanguageOptions } from '@renderer/config/translate'
|
|
import db from '@renderer/databases'
|
|
import { useDefaultModel } from '@renderer/hooks/useAssistant'
|
|
import { fetchTranslate } from '@renderer/services/ApiService'
|
|
import { getDefaultTranslateAssistant } from '@renderer/services/AssistantService'
|
|
import { Assistant, Message, TranslateHistory } from '@renderer/types'
|
|
import { runAsyncFunction, uuid } from '@renderer/utils'
|
|
import { Button, Dropdown, Empty, Flex, Popconfirm, Select, Space, Tooltip } from 'antd'
|
|
import TextArea, { TextAreaRef } from 'antd/es/input/TextArea'
|
|
import dayjs from 'dayjs'
|
|
import { useLiveQuery } from 'dexie-react-hooks'
|
|
import { isEmpty } from 'lodash'
|
|
import { FC, useEffect, useRef, useState } from 'react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { Link } from 'react-router-dom'
|
|
import styled from 'styled-components'
|
|
|
|
let _text = ''
|
|
let _result = ''
|
|
let _targetLanguage = 'english'
|
|
|
|
const TranslatePage: FC = () => {
|
|
const { t } = useTranslation()
|
|
const [targetLanguage, setTargetLanguage] = useState(_targetLanguage)
|
|
const [text, setText] = useState(_text)
|
|
const [result, setResult] = useState(_result)
|
|
const { translateModel } = useDefaultModel()
|
|
const [loading, setLoading] = useState(false)
|
|
const [copied, setCopied] = useState(false)
|
|
const [historyDrawerVisible, setHistoryDrawerVisible] = useState(false)
|
|
const [isScrollSyncEnabled, setIsScrollSyncEnabled] = useState(true)
|
|
const contentContainerRef = useRef<HTMLDivElement>(null)
|
|
const textAreaRef = useRef<TextAreaRef>(null)
|
|
const outputTextRef = useRef<HTMLDivElement>(null)
|
|
const isProgrammaticScroll = useRef(false)
|
|
|
|
const translateHistory = useLiveQuery(() => db.translate_history.orderBy('createdAt').reverse().toArray(), [])
|
|
|
|
_text = text
|
|
_result = result
|
|
_targetLanguage = targetLanguage
|
|
|
|
const saveTranslateHistory = async (
|
|
sourceText: string,
|
|
targetText: string,
|
|
sourceLanguage: string,
|
|
targetLanguage: string
|
|
) => {
|
|
const history: TranslateHistory = {
|
|
id: uuid(),
|
|
sourceText,
|
|
targetText,
|
|
sourceLanguage,
|
|
targetLanguage,
|
|
createdAt: new Date().toISOString()
|
|
}
|
|
console.log('🌟TEO🌟 ~ saveTranslateHistory ~ history:', history)
|
|
await db.translate_history.add(history)
|
|
}
|
|
|
|
const deleteHistory = async (id: string) => {
|
|
db.translate_history.delete(id)
|
|
}
|
|
|
|
const clearHistory = async () => {
|
|
db.translate_history.clear()
|
|
}
|
|
|
|
const onTranslate = async () => {
|
|
if (!text.trim()) {
|
|
return
|
|
}
|
|
|
|
if (!translateModel) {
|
|
window.message.error({
|
|
content: t('translate.error.not_configured'),
|
|
key: 'translate-message'
|
|
})
|
|
return
|
|
}
|
|
|
|
const assistant: Assistant = getDefaultTranslateAssistant(targetLanguage, text)
|
|
|
|
const message: Message = {
|
|
id: uuid(),
|
|
role: 'user',
|
|
content: '',
|
|
assistantId: assistant.id,
|
|
topicId: uuid(),
|
|
model: translateModel,
|
|
createdAt: new Date().toISOString(),
|
|
type: 'text',
|
|
status: 'sending'
|
|
}
|
|
|
|
setLoading(true)
|
|
let translatedText = ''
|
|
try {
|
|
await fetchTranslate({
|
|
message,
|
|
assistant,
|
|
onResponse: (text) => {
|
|
translatedText = text
|
|
setResult(text)
|
|
}
|
|
})
|
|
} catch (error) {
|
|
console.error('Translation error:', error)
|
|
window.message.error({
|
|
content: String(error),
|
|
key: 'translate-message'
|
|
})
|
|
setLoading(false)
|
|
return
|
|
}
|
|
|
|
await saveTranslateHistory(text, translatedText, 'any', targetLanguage)
|
|
setLoading(false)
|
|
}
|
|
|
|
const onCopy = () => {
|
|
navigator.clipboard.writeText(result)
|
|
setCopied(true)
|
|
setTimeout(() => setCopied(false), 2000)
|
|
}
|
|
|
|
const onHistoryItemClick = (history: TranslateHistory) => {
|
|
setText(history.sourceText)
|
|
setResult(history.targetText)
|
|
setTargetLanguage(history.targetLanguage)
|
|
}
|
|
|
|
useEffect(() => {
|
|
isEmpty(text) && setResult('')
|
|
}, [text])
|
|
|
|
useEffect(() => {
|
|
runAsyncFunction(async () => {
|
|
const targetLang = await db.settings.get({ id: 'translate:target:language' })
|
|
targetLang && setTargetLanguage(targetLang.value)
|
|
})
|
|
}, [])
|
|
|
|
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
const isEnterPressed = e.keyCode == 13
|
|
if (isEnterPressed && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
|
|
e.preventDefault()
|
|
onTranslate()
|
|
}
|
|
}
|
|
|
|
const SettingButton = () => {
|
|
if (isLocalAi) {
|
|
return null
|
|
}
|
|
|
|
if (translateModel) {
|
|
return (
|
|
<Link to="/settings/model" style={{ color: 'var(--color-text-2)' }}>
|
|
<SettingOutlined />
|
|
</Link>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<Link to="/settings/model" style={{ marginLeft: -10 }}>
|
|
<Button
|
|
type="link"
|
|
style={{ color: 'var(--color-error)', textDecoration: 'underline' }}
|
|
icon={<WarningOutlined />}>
|
|
{t('translate.error.not_configured')}
|
|
</Button>
|
|
</Link>
|
|
)
|
|
}
|
|
|
|
// Handle input area scroll event
|
|
const handleInputScroll = (e: React.UIEvent<HTMLTextAreaElement>) => {
|
|
if (!isScrollSyncEnabled || !outputTextRef.current || isProgrammaticScroll.current) return
|
|
|
|
isProgrammaticScroll.current = true
|
|
|
|
const inputEl = e.currentTarget
|
|
const outputEl = outputTextRef.current
|
|
|
|
// Calculate scroll position by ratio
|
|
const inputScrollRatio = inputEl.scrollTop / (inputEl.scrollHeight - inputEl.clientHeight || 1)
|
|
const outputScrollPosition = inputScrollRatio * (outputEl.scrollHeight - outputEl.clientHeight || 1)
|
|
|
|
outputEl.scrollTop = outputScrollPosition
|
|
|
|
requestAnimationFrame(() => {
|
|
isProgrammaticScroll.current = false
|
|
})
|
|
}
|
|
|
|
// Handle output area scroll event
|
|
const handleOutputScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
|
const inputEl = textAreaRef.current?.resizableTextArea?.textArea
|
|
if (!isScrollSyncEnabled || !inputEl || isProgrammaticScroll.current) return
|
|
|
|
isProgrammaticScroll.current = true
|
|
|
|
const outputEl = e.currentTarget
|
|
|
|
// Calculate scroll position by ratio
|
|
const outputScrollRatio = outputEl.scrollTop / (outputEl.scrollHeight - outputEl.clientHeight || 1)
|
|
const inputScrollPosition = outputScrollRatio * (inputEl.scrollHeight - inputEl.clientHeight || 1)
|
|
|
|
inputEl.scrollTop = inputScrollPosition
|
|
|
|
requestAnimationFrame(() => {
|
|
isProgrammaticScroll.current = false
|
|
})
|
|
}
|
|
|
|
const toggleScrollSync = () => {
|
|
setIsScrollSyncEnabled(!isScrollSyncEnabled)
|
|
}
|
|
|
|
return (
|
|
<Container id="translate-page">
|
|
<Navbar>
|
|
<NavbarCenter style={{ borderRight: 'none', gap: 10 }}>
|
|
{t('translate.title')}
|
|
<Button
|
|
className="nodrag"
|
|
color="default"
|
|
variant={historyDrawerVisible ? 'filled' : 'text'}
|
|
type="text"
|
|
icon={<HistoryOutlined />}
|
|
onClick={() => setHistoryDrawerVisible(!historyDrawerVisible)}
|
|
/>
|
|
</NavbarCenter>
|
|
</Navbar>
|
|
<ContentContainer id="content-container" ref={contentContainerRef} $historyDrawerVisible={historyDrawerVisible}>
|
|
<HistoryContainner $historyDrawerVisible={historyDrawerVisible}>
|
|
<OperationBar>
|
|
<span style={{ fontSize: 16 }}>{t('translate.history.title')}</span>
|
|
{!isEmpty(translateHistory) && (
|
|
<Popconfirm
|
|
title={t('translate.history.clear')}
|
|
description={t('translate.history.clear_description')}
|
|
onConfirm={clearHistory}>
|
|
<Button type="text" size="small" danger icon={<DeleteOutlined />}>
|
|
{t('translate.history.clear')}
|
|
</Button>
|
|
</Popconfirm>
|
|
)}
|
|
</OperationBar>
|
|
{translateHistory && translateHistory.length ? (
|
|
<HistoryList>
|
|
{translateHistory.map((item) => (
|
|
<Dropdown
|
|
key={item.id}
|
|
trigger={['contextMenu']}
|
|
menu={{
|
|
items: [
|
|
{
|
|
key: 'delete',
|
|
label: t('translate.history.delete'),
|
|
icon: <DeleteOutlined />,
|
|
danger: true,
|
|
onClick: () => deleteHistory(item.id)
|
|
}
|
|
]
|
|
}}>
|
|
<HistoryListItem onClick={() => onHistoryItemClick(item)}>
|
|
<Flex justify="space-between" vertical gap={4} style={{ width: '100%' }}>
|
|
<HistoryListItemTitle>{item.sourceText}</HistoryListItemTitle>
|
|
<HistoryListItemTitle>{item.targetText}</HistoryListItemTitle>
|
|
<HistoryListItemDate>{dayjs(item.createdAt).format('MM/DD HH:mm')}</HistoryListItemDate>
|
|
</Flex>
|
|
</HistoryListItem>
|
|
</Dropdown>
|
|
))}
|
|
</HistoryList>
|
|
) : (
|
|
<Flex justify="center" align="center" style={{ flex: 1 }}>
|
|
<Empty description={t('translate.history.empty')} />
|
|
</Flex>
|
|
)}
|
|
</HistoryContainner>
|
|
|
|
<InputContainer>
|
|
<OperationBar>
|
|
<Flex align="center" gap={20}>
|
|
<Select
|
|
showSearch
|
|
value="any"
|
|
style={{ width: 180 }}
|
|
optionFilterProp="label"
|
|
disabled
|
|
options={[{ label: t('translate.any.language'), value: 'any' }]}
|
|
/>
|
|
<SettingButton />
|
|
<Tooltip
|
|
mouseEnterDelay={0.5}
|
|
title={isScrollSyncEnabled ? t('translate.scroll_sync.disable') : t('translate.scroll_sync.enable')}>
|
|
<SyncOutlined
|
|
style={{
|
|
color: isScrollSyncEnabled ? 'var(--color-primary)' : 'var(--color-text-2)'
|
|
}}
|
|
onClick={toggleScrollSync}
|
|
/>
|
|
</Tooltip>
|
|
</Flex>
|
|
|
|
<Tooltip
|
|
mouseEnterDelay={0.5}
|
|
styles={{ body: { fontSize: '12px' } }}
|
|
title={
|
|
<div style={{ textAlign: 'center' }}>
|
|
Enter: {t('translate.button.translate')}
|
|
<br />
|
|
Shift + Enter: {t('translate.tooltip.newline')}
|
|
</div>
|
|
}>
|
|
<TranslateButton
|
|
type="primary"
|
|
loading={loading}
|
|
onClick={onTranslate}
|
|
disabled={!text.trim()}
|
|
icon={<SendOutlined />}>
|
|
{t('translate.button.translate')}
|
|
</TranslateButton>
|
|
</Tooltip>
|
|
</OperationBar>
|
|
|
|
<Textarea
|
|
ref={textAreaRef}
|
|
variant="borderless"
|
|
placeholder={t('translate.input.placeholder')}
|
|
value={text}
|
|
onChange={(e) => setText(e.target.value)}
|
|
onKeyDown={onKeyDown}
|
|
onScroll={handleInputScroll}
|
|
disabled={loading}
|
|
spellCheck={false}
|
|
allowClear
|
|
/>
|
|
</InputContainer>
|
|
|
|
<OutputContainer>
|
|
<OperationBar>
|
|
<Select
|
|
showSearch
|
|
value={targetLanguage}
|
|
style={{ width: 180 }}
|
|
optionFilterProp="label"
|
|
options={translateLanguageOptions()}
|
|
onChange={(value) => {
|
|
setTargetLanguage(value)
|
|
db.settings.put({ id: 'translate:target:language', value })
|
|
}}
|
|
optionRender={(option) => (
|
|
<Space>
|
|
<span role="img" aria-label={option.data.label}>
|
|
{option.data.emoji}
|
|
</span>
|
|
{option.label}
|
|
</Space>
|
|
)}
|
|
/>
|
|
<CopyButton
|
|
onClick={onCopy}
|
|
disabled={!result}
|
|
icon={copied ? <CheckOutlined style={{ color: 'var(--color-primary)' }} /> : <CopyIcon />}
|
|
/>
|
|
</OperationBar>
|
|
|
|
<OutputText ref={outputTextRef} onScroll={handleOutputScroll}>
|
|
{result || t('translate.output.placeholder')}
|
|
</OutputText>
|
|
</OutputContainer>
|
|
</ContentContainer>
|
|
</Container>
|
|
)
|
|
}
|
|
|
|
const Container = styled.div`
|
|
flex: 1;
|
|
`
|
|
|
|
const ContentContainer = styled.div<{ $historyDrawerVisible: boolean }>`
|
|
height: calc(100vh - var(--navbar-height));
|
|
display: grid;
|
|
grid-template-columns: auto 1fr 1fr;
|
|
flex: 1;
|
|
padding: 20px 15px;
|
|
position: relative;
|
|
`
|
|
|
|
const InputContainer = styled.div`
|
|
position: relative;
|
|
display: flex;
|
|
flex: 1;
|
|
flex-direction: column;
|
|
border: 1px solid var(--color-border-soft);
|
|
border-radius: 10px;
|
|
padding-bottom: 5px;
|
|
padding-right: 2px;
|
|
margin-right: 15px;
|
|
`
|
|
|
|
const OperationBar = styled.div`
|
|
display: flex;
|
|
flex-direction: row;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 20px;
|
|
padding: 10px 8px 10px 10px;
|
|
`
|
|
|
|
const Textarea = styled(TextArea)`
|
|
display: flex;
|
|
flex: 1;
|
|
font-size: 16px;
|
|
border-radius: 0;
|
|
.ant-input {
|
|
resize: none;
|
|
padding: 5px 16px;
|
|
}
|
|
.ant-input-clear-icon {
|
|
font-size: 16px;
|
|
}
|
|
`
|
|
|
|
const OutputContainer = styled.div`
|
|
min-height: 0;
|
|
position: relative;
|
|
display: flex;
|
|
flex-direction: column;
|
|
background-color: var(--color-background-soft);
|
|
border-radius: 10px;
|
|
padding-bottom: 5px;
|
|
padding-right: 2px;
|
|
`
|
|
|
|
const OutputText = styled.div`
|
|
min-height: 0;
|
|
flex: 1;
|
|
padding: 5px 16px;
|
|
overflow-y: auto;
|
|
white-space: pre-wrap;
|
|
`
|
|
|
|
const TranslateButton = styled(Button)``
|
|
|
|
const CopyButton = styled(Button)``
|
|
|
|
const HistoryContainner = styled.div<{ $historyDrawerVisible: boolean }>`
|
|
width: ${({ $historyDrawerVisible }) => ($historyDrawerVisible ? '300px' : '0')};
|
|
height: calc(100vh - var(--navbar-height) - 40px);
|
|
transition:
|
|
width 0.2s,
|
|
opacity 0.2s;
|
|
border: 1px solid var(--color-border-soft);
|
|
border-radius: 10px;
|
|
margin-right: 15px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
padding-right: 2px;
|
|
padding-bottom: 5px;
|
|
|
|
${({ $historyDrawerVisible }) =>
|
|
!$historyDrawerVisible &&
|
|
`
|
|
border: none;
|
|
margin-right: 0;
|
|
opacity: 0;
|
|
`}
|
|
`
|
|
|
|
const HistoryList = styled.div`
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 16px;
|
|
overflow-y: auto;
|
|
padding: 0 5px;
|
|
`
|
|
|
|
const HistoryListItem = styled.div`
|
|
width: 100%;
|
|
padding: 5px 10px;
|
|
border-radius: var(--list-item-border-radius);
|
|
cursor: pointer;
|
|
transition: background-color 0.2s;
|
|
position: relative;
|
|
|
|
button {
|
|
opacity: 0;
|
|
transition: opacity 0.2s;
|
|
}
|
|
|
|
&:hover {
|
|
background-color: var(--color-background-mute);
|
|
button {
|
|
opacity: 1;
|
|
}
|
|
}
|
|
|
|
&:not(:last-child)::after {
|
|
content: '';
|
|
display: block;
|
|
width: 100%;
|
|
height: 1px;
|
|
border-bottom: 1px dashed var(--color-border-soft);
|
|
position: absolute;
|
|
bottom: -8px;
|
|
left: 0;
|
|
}
|
|
`
|
|
|
|
const HistoryListItemTitle = styled.div`
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 2;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
font-size: 13px;
|
|
`
|
|
|
|
const HistoryListItemDate = styled.div`
|
|
font-size: 12px;
|
|
color: var(--color-text-3);
|
|
`
|
|
|
|
export default TranslatePage
|