mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-29 23:12:38 +08:00
feat: add language detection and bidirectional translation utilities
This commit is contained in:
parent
6260ea76ce
commit
c7ed9e9551
@ -84,6 +84,7 @@
|
||||
"electron-window-state": "^5.0.3",
|
||||
"epub": "patch:epub@npm%3A1.3.0#~/.yarn/patches/epub-npm-1.3.0-8325494ffe.patch",
|
||||
"fast-xml-parser": "^5.2.0",
|
||||
"franc": "^6.2.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
"jsdom": "^26.0.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
|
||||
@ -12,6 +12,13 @@ import { getDefaultTranslateAssistant } from '@renderer/services/AssistantServic
|
||||
import { getModelUniqId, hasModel } from '@renderer/services/ModelService'
|
||||
import type { Model, TranslateHistory } from '@renderer/types'
|
||||
import { runAsyncFunction, uuid } from '@renderer/utils'
|
||||
import {
|
||||
createInputScrollHandler,
|
||||
createOutputScrollHandler,
|
||||
detectLanguage,
|
||||
getTargetLanguageForBidirectional,
|
||||
isLanguageInPair
|
||||
} from '@renderer/utils/translate'
|
||||
import { Button, Divider, Empty, Flex, Modal, Popconfirm, Select, Space, Switch, Tooltip } from 'antd'
|
||||
import TextArea, { TextAreaRef } from 'antd/es/input/TextArea'
|
||||
import dayjs from 'dayjs'
|
||||
@ -268,70 +275,6 @@ const TranslatePage: FC = () => {
|
||||
await db.translate_history.clear()
|
||||
}
|
||||
|
||||
const detectLanguage = async (inputText: string): Promise<string> => {
|
||||
if (!inputText.trim()) return 'any'
|
||||
|
||||
const langPatterns = {
|
||||
chinese: /[\u4e00-\u9fa5]/,
|
||||
japanese: /[\u3040-\u30ff\u3400-\u4dbf]/,
|
||||
korean: /[\uAC00-\uD7AF]/,
|
||||
russian: /[\u0400-\u04FF]/,
|
||||
arabic: /[\u0600-\u06FF]/
|
||||
}
|
||||
|
||||
for (const [lang, pattern] of Object.entries(langPatterns)) {
|
||||
if (pattern.test(inputText)) return lang
|
||||
}
|
||||
|
||||
try {
|
||||
const prompt = `Identify language: "${inputText.substring(0, 50)}". Reply with one word only: english, chinese, japanese, korean, russian, spanish, french, german, italian, portuguese, arabic.`
|
||||
|
||||
let detectedCode = ''
|
||||
await fetchTranslate({
|
||||
content: inputText.substring(0, 50),
|
||||
assistant: {
|
||||
id: 'lang-detector',
|
||||
name: 'Language Detector',
|
||||
prompt,
|
||||
topics: [],
|
||||
type: 'translator'
|
||||
},
|
||||
onResponse: (response) => {
|
||||
detectedCode = response.trim().toLowerCase()
|
||||
}
|
||||
})
|
||||
|
||||
const validCodes = [
|
||||
'english',
|
||||
'chinese',
|
||||
'japanese',
|
||||
'korean',
|
||||
'russian',
|
||||
'spanish',
|
||||
'french',
|
||||
'italian',
|
||||
'portuguese',
|
||||
'arabic',
|
||||
'german'
|
||||
]
|
||||
|
||||
return validCodes.find((code) => detectedCode.includes(code)) || 'english'
|
||||
} catch (error) {
|
||||
console.error('语言检测错误:', error)
|
||||
return 'english'
|
||||
}
|
||||
}
|
||||
|
||||
const getTargetLanguageForBidirectional = (sourceLanguage: string): string => {
|
||||
if (sourceLanguage === bidirectionalPair[0]) {
|
||||
return bidirectionalPair[1]
|
||||
} else if (sourceLanguage === bidirectionalPair[1]) {
|
||||
return bidirectionalPair[0]
|
||||
}
|
||||
|
||||
return bidirectionalPair[0] === sourceLanguage ? bidirectionalPair[1] : bidirectionalPair[0]
|
||||
}
|
||||
|
||||
const onTranslate = async () => {
|
||||
if (!text.trim()) return
|
||||
if (!translateModel) {
|
||||
@ -350,7 +293,7 @@ const TranslatePage: FC = () => {
|
||||
let actualTargetLanguage = targetLanguage
|
||||
|
||||
if (isBidirectional) {
|
||||
if (![bidirectionalPair[0], bidirectionalPair[1]].includes(sourceLanguage)) {
|
||||
if (!isLanguageInPair(sourceLanguage, bidirectionalPair)) {
|
||||
window.message.warning({
|
||||
content: t('translate.language.not_pair'),
|
||||
key: 'translate-message'
|
||||
@ -358,7 +301,7 @@ const TranslatePage: FC = () => {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
actualTargetLanguage = getTargetLanguageForBidirectional(sourceLanguage)
|
||||
actualTargetLanguage = getTargetLanguageForBidirectional(sourceLanguage, bidirectionalPair)
|
||||
setTargetLanguage(actualTargetLanguage)
|
||||
} else {
|
||||
if (sourceLanguage === targetLanguage) {
|
||||
@ -453,41 +396,8 @@ const TranslatePage: FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
outputEl.scrollTop = inputScrollRatio * (outputEl.scrollHeight - outputEl.clientHeight || 1)
|
||||
|
||||
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)
|
||||
inputEl.scrollTop = outputScrollRatio * (inputEl.scrollHeight - inputEl.clientHeight || 1)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
isProgrammaticScroll.current = false
|
||||
})
|
||||
}
|
||||
const handleInputScroll = createInputScrollHandler(outputTextRef, isProgrammaticScroll, isScrollSyncEnabled)
|
||||
const handleOutputScroll = createOutputScrollHandler(textAreaRef, isProgrammaticScroll, isScrollSyncEnabled)
|
||||
|
||||
// 获取当前语言状态显示
|
||||
const getLanguageDisplay = () => {
|
||||
|
||||
154
src/renderer/src/utils/translate.ts
Normal file
154
src/renderer/src/utils/translate.ts
Normal file
@ -0,0 +1,154 @@
|
||||
import { fetchTranslate } from '@renderer/services/ApiService'
|
||||
import { franc } from 'franc'
|
||||
import React, { MutableRefObject } from 'react'
|
||||
|
||||
/**
|
||||
* 检测输入文本的语言
|
||||
* @param {string} inputText 需要检测语言的文本
|
||||
* @returns {Promise<string>} 检测到的语言代码
|
||||
*/
|
||||
export const detectLanguage = async (inputText: string): Promise<string> => {
|
||||
if (!inputText.trim()) return 'any'
|
||||
|
||||
const text = inputText.trim()
|
||||
const detectedLangCode = franc(text)
|
||||
|
||||
// 映射 ISO 639-3 代码到应用使用的语言代码
|
||||
const languageMap: Record<string, string> = {
|
||||
cmn: 'chinese', // 普通话
|
||||
zho: 'chinese', // 中文
|
||||
jpn: 'japanese', // 日语
|
||||
kor: 'korean', // 韩语
|
||||
rus: 'russian', // 俄语
|
||||
spa: 'spanish', // 西班牙语
|
||||
fra: 'french', // 法语
|
||||
deu: 'german', // 德语
|
||||
ita: 'italian', // 意大利语
|
||||
por: 'portuguese', // 葡萄牙语
|
||||
ara: 'arabic', // 阿拉伯语
|
||||
eng: 'english' // 英语
|
||||
}
|
||||
|
||||
if (detectedLangCode !== 'und' && languageMap[detectedLangCode]) {
|
||||
return languageMap[detectedLangCode]
|
||||
}
|
||||
|
||||
try {
|
||||
const sampleText = text.substring(0, 200)
|
||||
const prompt = `Identify the primary language in this text: "${sampleText}". Reply with only one word from this list: english, chinese, japanese, korean, russian, spanish, french, german, italian, portuguese, arabic.`
|
||||
|
||||
let detectedCode = ''
|
||||
await fetchTranslate({
|
||||
content: sampleText,
|
||||
assistant: {
|
||||
id: 'lang-detector',
|
||||
name: 'Language Detector',
|
||||
prompt,
|
||||
topics: [],
|
||||
type: 'translator'
|
||||
},
|
||||
onResponse: (response) => {
|
||||
detectedCode = response.trim().toLowerCase()
|
||||
}
|
||||
})
|
||||
|
||||
const validCodes = [
|
||||
'english',
|
||||
'chinese',
|
||||
'japanese',
|
||||
'korean',
|
||||
'russian',
|
||||
'spanish',
|
||||
'french',
|
||||
'german',
|
||||
'italian',
|
||||
'portuguese',
|
||||
'arabic'
|
||||
]
|
||||
|
||||
return validCodes.find((code) => detectedCode.includes(code)) || 'english'
|
||||
} catch (error) {
|
||||
console.error('语言检测错误:', error)
|
||||
return 'english'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取双向翻译的目标语言
|
||||
* @param sourceLanguage 检测到的源语言
|
||||
* @param languagePair 配置的语言对
|
||||
* @returns 目标语言
|
||||
*/
|
||||
export const getTargetLanguageForBidirectional = (sourceLanguage: string, languagePair: [string, string]): string => {
|
||||
if (sourceLanguage === languagePair[0]) {
|
||||
return languagePair[1]
|
||||
} else if (sourceLanguage === languagePair[1]) {
|
||||
return languagePair[0]
|
||||
}
|
||||
|
||||
// 默认返回第一个不同于源语言的语言
|
||||
return languagePair[0] === sourceLanguage ? languagePair[1] : languagePair[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查源语言是否在配置的语言对中
|
||||
* @param sourceLanguage 检测到的源语言
|
||||
* @param languagePair 配置的语言对
|
||||
* @returns 是否在语言对中
|
||||
*/
|
||||
export const isLanguageInPair = (sourceLanguage: string, languagePair: [string, string]): boolean => {
|
||||
return [languagePair[0], languagePair[1]].includes(sourceLanguage)
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理滚动同步
|
||||
* @param sourceElement 源元素
|
||||
* @param targetElement 目标元素
|
||||
* @param isProgrammaticScrollRef 是否程序控制滚动的引用
|
||||
*/
|
||||
export const handleScrollSync = (
|
||||
sourceElement: HTMLElement,
|
||||
targetElement: HTMLElement,
|
||||
isProgrammaticScrollRef: MutableRefObject<boolean>
|
||||
): void => {
|
||||
if (isProgrammaticScrollRef.current) return
|
||||
|
||||
isProgrammaticScrollRef.current = true
|
||||
|
||||
// 计算滚动位置比例
|
||||
const scrollRatio = sourceElement.scrollTop / (sourceElement.scrollHeight - sourceElement.clientHeight || 1)
|
||||
targetElement.scrollTop = scrollRatio * (targetElement.scrollHeight - targetElement.clientHeight || 1)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
isProgrammaticScrollRef.current = false
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建输入区域滚动处理函数
|
||||
*/
|
||||
export const createInputScrollHandler = (
|
||||
targetRef: MutableRefObject<HTMLDivElement | null>,
|
||||
isProgrammaticScrollRef: MutableRefObject<boolean>,
|
||||
isScrollSyncEnabled: boolean
|
||||
) => {
|
||||
return (e: React.UIEvent<HTMLTextAreaElement>) => {
|
||||
if (!isScrollSyncEnabled || !targetRef.current || isProgrammaticScrollRef.current) return
|
||||
handleScrollSync(e.currentTarget, targetRef.current, isProgrammaticScrollRef)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建输出区域滚动处理函数
|
||||
*/
|
||||
export const createOutputScrollHandler = (
|
||||
textAreaRef: MutableRefObject<any>,
|
||||
isProgrammaticScrollRef: MutableRefObject<boolean>,
|
||||
isScrollSyncEnabled: boolean
|
||||
) => {
|
||||
return (e: React.UIEvent<HTMLDivElement>) => {
|
||||
const inputEl = textAreaRef.current?.resizableTextArea?.textArea
|
||||
if (!isScrollSyncEnabled || !inputEl || isProgrammaticScrollRef.current) return
|
||||
handleScrollSync(e.currentTarget, inputEl, isProgrammaticScrollRef)
|
||||
}
|
||||
}
|
||||
34
yarn.lock
34
yarn.lock
@ -6010,6 +6010,7 @@ __metadata:
|
||||
eslint-plugin-unused-imports: "npm:^4.1.4"
|
||||
fast-diff: "npm:^1.3.0"
|
||||
fast-xml-parser: "npm:^5.2.0"
|
||||
franc: "npm:^6.2.0"
|
||||
fs-extra: "npm:^11.2.0"
|
||||
html-to-image: "npm:^1.11.13"
|
||||
husky: "npm:^9.1.7"
|
||||
@ -7465,6 +7466,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"collapse-white-space@npm:^2.0.0":
|
||||
version: 2.1.0
|
||||
resolution: "collapse-white-space@npm:2.1.0"
|
||||
checksum: 10c0/b2e2800f4ab261e62eb27a1fbe853378296e3a726d6695117ed033e82d61fb6abeae4ffc1465d5454499e237005de9cfc52c9562dc7ca4ac759b9a222ef14453
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"color-convert@npm:^1.9.0":
|
||||
version: 1.9.3
|
||||
resolution: "color-convert@npm:1.9.3"
|
||||
@ -10527,6 +10535,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"franc@npm:^6.2.0":
|
||||
version: 6.2.0
|
||||
resolution: "franc@npm:6.2.0"
|
||||
dependencies:
|
||||
trigram-utils: "npm:^2.0.0"
|
||||
checksum: 10c0/136a08d6e4632f17eae6f0ae93b224b0bf2233dc1d5dbd0b23e479960f6c71c0847bef834d3b6b7c9cefb4f905d5e08fc82b0738bb3ed4a6c83faffcf9fa2a11
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"fresh@npm:^2.0.0":
|
||||
version: 2.0.0
|
||||
resolution: "fresh@npm:2.0.0"
|
||||
@ -14572,6 +14589,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"n-gram@npm:^2.0.0":
|
||||
version: 2.0.2
|
||||
resolution: "n-gram@npm:2.0.2"
|
||||
checksum: 10c0/72e2cdc8c37c9253b556a0deb9cd26d5ac59a5d7a38b2d2928927e3959bc7d3cb591d766e30309a4c685dbc51330025cb30c5c6518ee516caf3318aed2635f1b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"nanoid@npm:^3.3.7, nanoid@npm:^3.3.8":
|
||||
version: 3.3.11
|
||||
resolution: "nanoid@npm:3.3.11"
|
||||
@ -18953,6 +18977,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"trigram-utils@npm:^2.0.0":
|
||||
version: 2.0.1
|
||||
resolution: "trigram-utils@npm:2.0.1"
|
||||
dependencies:
|
||||
collapse-white-space: "npm:^2.0.0"
|
||||
n-gram: "npm:^2.0.0"
|
||||
checksum: 10c0/d024dc91a9c0310e75fa68422185e3a32814831971b9e86a2925e74bd1932a30501aa2ac214768f0a545f3db63610ee14b4748ac31532e1bc46c791941d71c6d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"trim-lines@npm:^3.0.0":
|
||||
version: 3.0.1
|
||||
resolution: "trim-lines@npm:3.0.1"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user