From c7ed9e955123572c62a25a8e19d6e75f59719657 Mon Sep 17 00:00:00 2001 From: Pleasurecruise <3196812536@qq.com> Date: Tue, 27 May 2025 20:54:57 +0800 Subject: [PATCH] feat: add language detection and bidirectional translation utilities --- package.json | 1 + .../src/pages/translate/TranslatePage.tsx | 112 ++----------- src/renderer/src/utils/translate.ts | 154 ++++++++++++++++++ yarn.lock | 34 ++++ 4 files changed, 200 insertions(+), 101 deletions(-) create mode 100644 src/renderer/src/utils/translate.ts diff --git a/package.json b/package.json index f2cfcf2402..a22decabb5 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/renderer/src/pages/translate/TranslatePage.tsx b/src/renderer/src/pages/translate/TranslatePage.tsx index 298e84cea3..80cc30854e 100644 --- a/src/renderer/src/pages/translate/TranslatePage.tsx +++ b/src/renderer/src/pages/translate/TranslatePage.tsx @@ -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 => { - 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) => { - 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) => { - 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 = () => { diff --git a/src/renderer/src/utils/translate.ts b/src/renderer/src/utils/translate.ts new file mode 100644 index 0000000000..e4df23d392 --- /dev/null +++ b/src/renderer/src/utils/translate.ts @@ -0,0 +1,154 @@ +import { fetchTranslate } from '@renderer/services/ApiService' +import { franc } from 'franc' +import React, { MutableRefObject } from 'react' + +/** + * 检测输入文本的语言 + * @param {string} inputText 需要检测语言的文本 + * @returns {Promise} 检测到的语言代码 + */ +export const detectLanguage = async (inputText: string): Promise => { + if (!inputText.trim()) return 'any' + + const text = inputText.trim() + const detectedLangCode = franc(text) + + // 映射 ISO 639-3 代码到应用使用的语言代码 + const languageMap: Record = { + 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 +): 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, + isProgrammaticScrollRef: MutableRefObject, + isScrollSyncEnabled: boolean +) => { + return (e: React.UIEvent) => { + if (!isScrollSyncEnabled || !targetRef.current || isProgrammaticScrollRef.current) return + handleScrollSync(e.currentTarget, targetRef.current, isProgrammaticScrollRef) + } +} + +/** + * 创建输出区域滚动处理函数 + */ +export const createOutputScrollHandler = ( + textAreaRef: MutableRefObject, + isProgrammaticScrollRef: MutableRefObject, + isScrollSyncEnabled: boolean +) => { + return (e: React.UIEvent) => { + const inputEl = textAreaRef.current?.resizableTextArea?.textArea + if (!isScrollSyncEnabled || !inputEl || isProgrammaticScrollRef.current) return + handleScrollSync(e.currentTarget, inputEl, isProgrammaticScrollRef) + } +} diff --git a/yarn.lock b/yarn.lock index 48d47a1405..aa7d1504e9 100644 --- a/yarn.lock +++ b/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"