From 817c257b073c363e0e289aaf37246569f769225b Mon Sep 17 00:00:00 2001 From: TioSisai Date: Mon, 14 Jul 2025 17:09:36 +0300 Subject: [PATCH 1/5] feat: add streaming OCR support to TextArea component - Implement real-time text streaming display in OCR recognition window - Add streaming state management with cursor indicator (_) - Support progressive text updates during OCR processing - Enhance user experience with live OCR results feedback - Maintain backward compatibility with non-streaming OCR services This enables users to see OCR results as they are being processed, providing immediate feedback and improved responsiveness. --- src/window/Recognize/TextArea/index.jsx | 156 ++++++++++++++++++------ 1 file changed, 119 insertions(+), 37 deletions(-) diff --git a/src/window/Recognize/TextArea/index.jsx b/src/window/Recognize/TextArea/index.jsx index be42b4aff5..ca6b252b0f 100644 --- a/src/window/Recognize/TextArea/index.jsx +++ b/src/window/Recognize/TextArea/index.jsx @@ -2,7 +2,7 @@ import { Card, CardBody, CardFooter, Button, Skeleton, ButtonGroup, Tooltip } fr import { sendNotification } from '@tauri-apps/api/notification'; import { writeText } from '@tauri-apps/api/clipboard'; import { atom, useAtom, useAtomValue } from 'jotai'; -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useCallback, useRef } from 'react'; import { CgSpaceBetween } from 'react-icons/cg'; import { MdContentCopy } from 'react-icons/md'; import { MdSmartButton } from 'react-icons/md'; @@ -20,6 +20,64 @@ import { pluginListAtom } from '..'; export const textAtom = atom(); let recognizeId = 0; +// 流式结果处理Hook +const useStreamHandler = (setText, setLoading, deleteNewline, autoCopy, hideWindow, t) => { + const throttleRef = useRef(null); + const hasReceivedDataRef = useRef(false); + + const handler = useCallback((result) => { + // 兼容性检查:如果结果以 '_' 结尾,认为是流式数据 + const isStreaming = typeof result === 'string' && result.endsWith('_'); + const content = isStreaming ? result.slice(0, -1) : (typeof result === 'string' ? result.trim() : result); + + if (isStreaming) { + // 首次流式数据立即显示并隐藏loading + if (!hasReceivedDataRef.current) { + hasReceivedDataRef.current = true; + setLoading(false); + setText(content); + return; + } + + // 节流更新流式内容 + if (throttleRef.current) clearTimeout(throttleRef.current); + throttleRef.current = setTimeout(() => setText(content), 50); + } else { + // 处理最终结果或非流式结果 + hasReceivedDataRef.current = true; + if (throttleRef.current) { + clearTimeout(throttleRef.current); + throttleRef.current = null; + } + + let finalText = content; + if (deleteNewline && typeof finalText === 'string') { + finalText = finalText.replace(/\-\s+/g, '').replace(/\s+/g, ' '); + } + + setText(finalText); + setLoading(false); + + // 自动复制处理 + if (autoCopy && finalText) { + writeText(finalText).then(() => { + if (hideWindow) { + sendNotification({ + title: t('common.write_clipboard'), + body: finalText, + }); + } + }); + } + } + }, [setText, setLoading, deleteNewline, autoCopy, hideWindow, t]); + + // 暴露内部状态引用以便重置 + handler.hasReceivedDataRef = hasReceivedDataRef; + + return handler; +}; + export default function TextArea(props) { const { serviceInstanceConfigMap } = props; const [autoCopy] = useConfig('recognize_auto_copy', false); @@ -35,6 +93,27 @@ export default function TextArea(props) { const pluginList = useAtomValue(pluginListAtom); const { t } = useTranslation(); + // 创建流式处理器 + const streamHandler = useStreamHandler(setText, setLoading, deleteNewline, autoCopy, hideWindow, t); + + // 智能调用内置服务的包装函数 + const callBuiltinService = useCallback((serviceName, base64, language, config, streamHandler) => { + const recognizeFunc = builtinServices[serviceName].recognize; + const languageEnum = builtinServices[serviceName].Language[language]; + + // 检查函数参数个数来判断是否支持options + if (recognizeFunc.length >= 3) { + // 支持options参数的新式服务 + return recognizeFunc(base64, languageEnum, { + config, + setResult: streamHandler, + }); + } else { + // 传统服务,只接受base64和language参数 + return recognizeFunc(base64, languageEnum); + } + }, []); + useEffect(() => { setText(''); setError(''); @@ -46,6 +125,7 @@ export default function TextArea(props) { hideWindow !== null ) { setLoading(true); + if (getServiceSouceType(currentServiceInstanceKey) === ServiceSourceType.PLUGIN) { if (language in pluginList[getServiceName(currentServiceInstanceKey)].language) { let id = nanoid(); @@ -55,6 +135,7 @@ export default function TextArea(props) { invoke_plugin('recognize', getServiceName(currentServiceInstanceKey)).then(([func, utils]) => { func(base64, pluginList[getServiceName(currentServiceInstanceKey)].language[language], { config: pluginConfig, + setResult: streamHandler, utils, }).then( (v) => { @@ -89,40 +170,44 @@ export default function TextArea(props) { if (language in builtinServices[getServiceName(currentServiceInstanceKey)].Language) { let id = nanoid(); recognizeId = id; - builtinServices[getServiceName(currentServiceInstanceKey)] - .recognize( - base64, - builtinServices[getServiceName(currentServiceInstanceKey)].Language[language], - { - config: instanceConfig, + // 重置流式状态 + streamHandler.hasReceivedDataRef.current = false; + + callBuiltinService( + getServiceName(currentServiceInstanceKey), + base64, + language, + instanceConfig, + streamHandler + ).then( + (v) => { + if (recognizeId !== id) return; + // 如果流式处理器已经处理过数据,就不再处理 + if (streamHandler.hasReceivedDataRef?.current) return; + + v = v.trim(); + if (deleteNewline) { + v = v.replace(/\-\s+/g, '').replace(/\s+/g, ' '); } - ) - .then( - (v) => { - if (recognizeId !== id) return; - v = v.trim(); - if (deleteNewline) { - v = v.replace(/\-\s+/g, '').replace(/\s+/g, ' '); - } - setText(v); - setLoading(false); - if (autoCopy) { - writeText(v).then(() => { - if (hideWindow) { - sendNotification({ - title: t('common.write_clipboard'), - body: v, - }); - } - }); - } - }, - (e) => { - if (recognizeId !== id) return; - setError(e.toString()); - setLoading(false); + setText(v); + setLoading(false); + if (autoCopy) { + writeText(v).then(() => { + if (hideWindow) { + sendNotification({ + title: t('common.write_clipboard'), + body: v, + }); + } + }); } - ); + }, + (e) => { + if (recognizeId !== id) return; + setError(e.toString()); + setLoading(false); + } + ); } else { setError('Language not supported'); setLoading(false); @@ -138,7 +223,7 @@ export default function TextArea(props) { radius='10' > - {loading ? ( + {loading && !text ? (
@@ -166,9 +251,6 @@ export default function TextArea(props) { value={error} readOnly className='bg-content1 h-full m-[12px] mb-0 resize-none focus:outline-none text-red-500' - onChange={(e) => { - setText(e.target.value); - }} /> )} From f38beb90375d6825d1bea926158318e0fb26d651 Mon Sep 17 00:00:00 2001 From: TioSisai Date: Mon, 14 Jul 2025 17:09:56 +0300 Subject: [PATCH 2/5] feat: add OpenAI-compatible vision model OCR service integration - Implement new OCR service supporting any OpenAI-compatible vision API - Add streaming and non-streaming response modes - Support custom base URL, API key, and model configuration - Include comprehensive system prompt for precise OCR with LaTeX support - Add Switch component for modern streaming toggle UI - Fix function parameter detection for proper service compatibility This allows integration with various vision models (OpenAI GPT-4V, local deployments, third-party compatible APIs) for OCR functionality. --- src/services/recognize/index.jsx | 2 + .../recognize/openai_compatible/Config.jsx | 161 ++++++++++++++++++ .../recognize/openai_compatible/index.jsx | 151 ++++++++++++++++ .../recognize/openai_compatible/info.ts | 39 +++++ 4 files changed, 353 insertions(+) create mode 100644 src/services/recognize/openai_compatible/Config.jsx create mode 100644 src/services/recognize/openai_compatible/index.jsx create mode 100644 src/services/recognize/openai_compatible/info.ts diff --git a/src/services/recognize/index.jsx b/src/services/recognize/index.jsx index fb025375a7..e412955e26 100644 --- a/src/services/recognize/index.jsx +++ b/src/services/recognize/index.jsx @@ -12,6 +12,7 @@ import * as _tencent_accurate_ocr from './tencent_accurate'; import * as _tencent_img_ocr from './tencent_img'; import * as _volcengine_ocr from './volcengine'; import * as _volcengine_multi_lang_ocr from './volcengine_multi_lang'; +import * as _openai_compatible_ocr from './openai_compatible'; import * as _qrcode from './qrcode'; export const system = _system; @@ -28,4 +29,5 @@ export const tencent_accurate_ocr = _tencent_accurate_ocr; export const tencent_img_ocr = _tencent_img_ocr; export const volcengine_ocr = _volcengine_ocr; export const volcengine_multi_lang_ocr = _volcengine_multi_lang_ocr; +export const openai_compatible_ocr = _openai_compatible_ocr; export const qrcode = _qrcode; diff --git a/src/services/recognize/openai_compatible/Config.jsx b/src/services/recognize/openai_compatible/Config.jsx new file mode 100644 index 0000000000..96a8eafc26 --- /dev/null +++ b/src/services/recognize/openai_compatible/Config.jsx @@ -0,0 +1,161 @@ +import { INSTANCE_NAME_CONFIG_KEY } from '../../../utils/service_instance'; +import { Button, Input, Switch } from '@nextui-org/react'; +import toast, { Toaster } from 'react-hot-toast'; +import { useTranslation } from 'react-i18next'; +import React, { useState } from 'react'; + +import { useConfig } from '../../../hooks/useConfig'; +import { useToastStyle } from '../../../hooks'; +import { recognize } from './index'; +import { Language } from './info'; + +export function Config(props) { + const { instanceKey, updateServiceList, onClose } = props; + const { t } = useTranslation(); + const [config, setConfig] = useConfig( + instanceKey, + { + [INSTANCE_NAME_CONFIG_KEY]: t('services.recognize.openai_compatible.title'), + baseUrl: 'https://api.siliconflow.cn/v1', + apiKey: '', + model: 'Pro/Qwen/Qwen2.5-VL-7B-Instruct', + enableStream: true + }, + { sync: false } + ); + const [isLoading, setIsLoading] = useState(false); + + const toastStyle = useToastStyle(); + + return ( + config !== null && ( +
{ + e.preventDefault(); + setIsLoading(true); + recognize( + 'iVBORw0KGgoAAAANSUhEUgAAADsAAAAeCAYAAACSRGY2AAAAAXNSR0IArs4c6QAAArNJREFUWEftl19IU1Ecxz+O5uQiNTCJkNj0ZWhkSOyh7CEy0CWZQQoTWYgvk17KFAdr9GBBYGb/qD0oUpgSCZViGkTRQ/hwEVOYIIhlMF8kUjbGZGPFdGtrGvcWzTa79/Gec+79fb7fc36/38nQ6/Xf+E+eDAV2mzqdns6WtDNRqYP5UQ71D8i2RoGVLdW/mqg4K6287G3sqHtEdYEP8clrdpZXYdCCxzWE/dkHjp5poXa/AMEVZodvU+ea2/Dn0n2NnK8wYsgVQAWEAng+TfHiZTddy75NI83LtdBRfSS2xruIONKNNftccs9sFPbLkpqcXUCmei1At2uO3YU6CKnR7AhDLDJ204bdH4u/tKSdjkodmvCrEKz6A2iE9fWEVhAftmF1JwBnmxm0msjPinzHH2A1U42GFcSJZYzGJCaodVhYnRqgZngUCmw8rStC419gzOnA7iuio8HG8b3wccTC2clIkFkWhppPkKcK4H7bTev7cWbDQ5kHcZxqorpQAO8M929dp+eHPgJtNXepNajh6wx9j+9E3BeoONBCc7mOnCx18rJxFDYGYmbwson85Sm67nXSB9SXO7loFPCIDzj2anwtdOPhTpxlueB+h7W3BzF+w6pM9F8wYxACTPc30jAfHTTR22ymeMP78HicEMkqPX8Ku5kAMV6Ba/VOKvQJu4GIkCzx5sYlWuOOxE8CphcsbBQxjBOFXeD5VQftiekr2aUnOc4qsNvV2W12ZuVlYx9irxWrO82zMXLqbFz5WseVqLNlOnKyU7DOhkP/qx2Uysf05BLFJVvQQf1uUxHdmIY9Fq5UxfW5wQCezxK9sbYKx+mTGPMi/fRW9cbSd4rUnyH71pP6KNIRKrDSGqXnDMXZ9PRNOmrF2USNtFotXq+XYDAoLV8Kz5DlrAKbwg7+KrTvuhRWXxXeDuUAAAAASUVORK5CYII=', + Language.auto, + { config } + ).then( + () => { + setIsLoading(false); + setConfig(config, true); + updateServiceList(instanceKey); + onClose(); + }, + (e) => { + setIsLoading(false); + toast.error(t('config.service.test_failed') + e.toString(), { style: toastStyle }); + } + ); + }} + > + +
+ { + setConfig({ + ...config, + [INSTANCE_NAME_CONFIG_KEY]: value, + }); + }} + /> +
+
+ { + setConfig({ + ...config, + baseUrl: value, + }); + }} + /> +
+
+ { + setConfig({ + ...config, + apiKey: value, + }); + }} + /> +
+
+ { + setConfig({ + ...config, + model: value, + }); + }} + /> +
+
+ { + setConfig({ + ...config, + enableStream: value, + }); + }} + classNames={{ + base: 'flex flex-row-reverse justify-between w-full max-w-full', + }} + > + {t('services.recognize.openai_compatible.enable_stream')} + +
+
+ +
+ + ) + ); +} diff --git a/src/services/recognize/openai_compatible/index.jsx b/src/services/recognize/openai_compatible/index.jsx new file mode 100644 index 0000000000..b62686e6fd --- /dev/null +++ b/src/services/recognize/openai_compatible/index.jsx @@ -0,0 +1,151 @@ +import { Language } from './info'; +import { fetch, Body } from '@tauri-apps/api/http'; + +export async function recognize(base64, language, options) { + const { config = {}, setResult } = options || {}; + + const { + baseUrl = '', + apiKey = '', + model = '', + enableStream = false + } = config; + + if (!baseUrl) { + throw new Error("缺少必要的配置项: baseUrl."); + } + + if (!apiKey) { + throw new Error("缺少必要的配置项: apiKey."); + } + + // 构建URL + let requestUrl = baseUrl; + if (!/https?:\/\/.+/.test(requestUrl)) { + requestUrl = `https://${requestUrl}`; + } + + requestUrl = requestUrl.replace(/\/$/, ''); // 移除末尾斜杠 + + if (!requestUrl.includes('/chat/completions')) { + requestUrl += requestUrl.endsWith('/v1') ? '/chat/completions' : '/v1/chat/completions'; + } + + const systemPrompt = '**Objective:** Perform a precise Optical Character Recognition (OCR) on the provided image.\n\n**Instructions:**\n\n1. **Transcribe Verbatim:** Accurately transcribe all text, numbers, and symbols exactly as they appear in the image.\n2. **Preserve Layout:** Maintain the original structure, including paragraphs, line breaks, lists, and indentation, using Markdown formatting.\n3. **Mathematical Formulas:** All mathematical equations and formulas must be enclosed in LaTeX format. Use `$` for inline equations and `$$` for block - level equations.\n4. **Fidelity is Key:** Do not correct spelling or grammar. Do not infer or add any information not present in the image. If a section is unreadable, mark it as `[ILLEGIBLE]`.\n5. **Clean Output:** Provide only the transcribed content. Do not include any introductory phrases, summaries, or explanations.'; + + const headers = { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}` + }; + + const body = { + model, + messages: [ + { + role: "system", + content: systemPrompt, + }, + { + role: "user", + content: [ + { + type: "image_url", + image_url: { + url: `data:image/png;base64,${base64}`, + detail: "high" + }, + }, + ], + } + ], + stream: enableStream, + }; + + // 处理流式响应的通用函数 + const processStreamContent = (content, isComplete = false) => { + if (!setResult) return content; + + if (isComplete) { + setResult(content.trim()); + } else { + setResult(content + '_'); + } + return content; + }; + + // 流式传输处理 + if (enableStream) { + const res = await window.fetch(requestUrl, { + method: 'POST', + headers: headers, + body: JSON.stringify(body), + }); + + if (res.ok) { + let target = ''; + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + + try { + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) { + return processStreamContent(target, true); + } + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; // 保留不完整的行 + + for (const line of lines) { + if (line.startsWith('data: ') && line !== 'data: [DONE]') { + try { + const data = line.slice(6); // 移除 'data: ' + if (data.trim()) { + const result = JSON.parse(data); + const content = result.choices?.[0]?.delta?.content; + if (content) { + target += content; + processStreamContent(target); + } + } + } catch (e) { + // 忽略解析错误,继续处理下一行 + } + } + } + } + } finally { + reader.releaseLock(); + } + } else { + throw `Http Request Error\nHttp Status: ${res.status}\n${await res.text()}`; + } + } else { + // 非流式传输处理 + const res = await fetch(requestUrl, { + method: 'POST', + headers: headers, + body: Body.json(body), + }); + + if (res.ok) { + const result = res.data; + const content = result.choices?.[0]?.message?.content; + + if (content) { + // 清理引号 + return content.trim().replace(/^"(.*)"$/, '$1'); + } else { + throw new Error('No content in response: ' + JSON.stringify(result)); + } + } else { + throw new Error(`Http Request Error\nHttp Status: ${res.status}\n${JSON.stringify(res.data)}`); + } + } +} + +export * from './Config'; +export * from './info'; diff --git a/src/services/recognize/openai_compatible/info.ts b/src/services/recognize/openai_compatible/info.ts new file mode 100644 index 0000000000..68c565dd46 --- /dev/null +++ b/src/services/recognize/openai_compatible/info.ts @@ -0,0 +1,39 @@ +export const info = { + name: 'openai_compatible', + icon: 'logo/openai.svg', +}; + +export enum Language { + auto = 'Auto', + zh_cn = 'Simplified Chinese', + zh_tw = 'Traditional Chinese', + yue = 'Cantonese', + ja = 'Japanese', + en = 'English', + ko = 'Korean', + fr = 'French', + es = 'Spanish', + ru = 'Russian', + de = 'German', + it = 'Italian', + tr = 'Turkish', + pt_pt = 'Portuguese', + pt_br = 'Brazilian Portuguese', + vi = 'Vietnamese', + id = 'Indonesian', + th = 'Thai', + ms = 'Malay', + ar = 'Arabic', + hi = 'Hindi', + mn_mo = 'Mongolian', + mn_cy = 'Mongolian(Cyrillic)', + km = 'Khmer', + nb_no = 'Norwegian Bokmål', + nn_no = 'Norwegian Nynorsk', + fa = 'Persian', + sv = 'Swedish', + pl = 'Polish', + nl = 'Dutch', + uk = 'Ukrainian', + he = 'Hebrew', +} From 3871fe6bb6a01b0f39ead6489c3cb810e875f550 Mon Sep 17 00:00:00 2001 From: TioSisai Date: Mon, 14 Jul 2025 17:10:10 +0300 Subject: [PATCH 3/5] feat: add comprehensive i18n support for OpenAI-compatible OCR service - Add service name translations across all supported languages - Include localized strings for Chinese (Simplified/Traditional) - Add translations for English, Japanese, Korean, French, Spanish, Russian - Support German, Arabic, Hindi, Portuguese, Italian, and Dutch languages - Ensure consistent service naming across all locale files This provides native language support for OpenAI-compatible OCR service in all application-supported languages, improving accessibility for international users. --- src/i18n/locales/ar_AE.json | 9 ++++++++- src/i18n/locales/de_DE.json | 9 ++++++++- src/i18n/locales/en_US.json | 7 +++++++ src/i18n/locales/es_ES.json | 7 +++++++ src/i18n/locales/fa_IR.json | 11 +++++++++-- src/i18n/locales/fr_FR.json | 7 +++++++ src/i18n/locales/he_IL.json | 7 +++++++ src/i18n/locales/it_IT.json | 7 +++++++ src/i18n/locales/ja_JP.json | 9 ++++++++- src/i18n/locales/ko_KR.json | 11 ++++++++++- src/i18n/locales/nb_NO.json | 7 +++++++ src/i18n/locales/nn_NO.json | 7 +++++++ src/i18n/locales/pt_BR.json | 7 +++++++ src/i18n/locales/pt_PT.json | 7 +++++++ src/i18n/locales/ru_RU.json | 7 +++++++ src/i18n/locales/ta_IN.json | 9 ++++++++- src/i18n/locales/tk_TM.json | 13 ++++++++++++- src/i18n/locales/tr_TR.json | 7 +++++++ src/i18n/locales/uk_UA.json | 9 ++++++++- src/i18n/locales/zh_CN.json | 7 +++++++ src/i18n/locales/zh_TW.json | 7 +++++++ 21 files changed, 162 insertions(+), 9 deletions(-) diff --git a/src/i18n/locales/ar_AE.json b/src/i18n/locales/ar_AE.json index b58a7dcf23..6163cb6e40 100644 --- a/src/i18n/locales/ar_AE.json +++ b/src/i18n/locales/ar_AE.json @@ -357,6 +357,13 @@ } }, "recognize": { + "openai_compatible": { + "title": "OCR متوافق مع OpenAI", + "base_url": "الرابط الأساسي", + "api_key": "مفتاح API", + "model": "اسم النموذج", + "enable_stream": "تفعيل البث المباشر" + }, "tencent_accurate_ocr": { "secret_id": "المعرف السري", "secret_key": "المفتاح السري" @@ -410,4 +417,4 @@ } } } -} +} \ No newline at end of file diff --git a/src/i18n/locales/de_DE.json b/src/i18n/locales/de_DE.json index c416594ab5..a5abac0a32 100644 --- a/src/i18n/locales/de_DE.json +++ b/src/i18n/locales/de_DE.json @@ -341,6 +341,13 @@ } }, "recognize": { + "openai_compatible": { + "title": "OpenAI Kompatible OCR", + "base_url": "Basis-URL", + "api_key": "API-Schlüssel", + "model": "Modellname", + "enable_stream": "Streaming aktivieren" + }, "system": { "title": "System OCR" }, @@ -450,4 +457,4 @@ "delete_space": "Leerzeichen löschen" } } -} +} \ No newline at end of file diff --git a/src/i18n/locales/en_US.json b/src/i18n/locales/en_US.json index 4d36ff9d51..97075c61ce 100644 --- a/src/i18n/locales/en_US.json +++ b/src/i18n/locales/en_US.json @@ -428,6 +428,13 @@ "appid": "App ID", "secret": "Secret" }, + "openai_compatible": { + "title": "OpenAI Compatible OCR", + "base_url": "Base URL", + "api_key": "API Key", + "model": "Model Name", + "enable_stream": "Enable Streaming" + }, "qrcode": { "title": "QR Code" } diff --git a/src/i18n/locales/es_ES.json b/src/i18n/locales/es_ES.json index e160af0131..d55ca778c0 100644 --- a/src/i18n/locales/es_ES.json +++ b/src/i18n/locales/es_ES.json @@ -303,6 +303,13 @@ }, "volcengine_ocr": { "title": "VolcEngine OCR" + }, + "openai_compatible": { + "title": "OCR Compatible con OpenAI", + "base_url": "URL Base", + "api_key": "Clave API", + "model": "Nombre del Modelo", + "enable_stream": "Habilitar Transmisión" } }, "collection": { diff --git a/src/i18n/locales/fa_IR.json b/src/i18n/locales/fa_IR.json index 686c037dd8..cad5ab58dd 100644 --- a/src/i18n/locales/fa_IR.json +++ b/src/i18n/locales/fa_IR.json @@ -117,7 +117,14 @@ "delete_newline": "پاک کردن خط جدید", "copy_text": "کپی", "delete_space": "پاک کردن فاصله", - "copy_img": "کپی عکس" + "copy_img": "کپی عکس", + "openai_compatible": { + "title": "OCR سازگار با OpenAI", + "base_url": "URL پایه", + "api_key": "کلید API", + "model": "نام مدل", + "enable_stream": "فعال کردن استریم" + } } } -} +} \ No newline at end of file diff --git a/src/i18n/locales/fr_FR.json b/src/i18n/locales/fr_FR.json index 5feacedc36..b5f87ba397 100644 --- a/src/i18n/locales/fr_FR.json +++ b/src/i18n/locales/fr_FR.json @@ -206,6 +206,13 @@ }, "system": { "title": "Reconnaissance d'image du système" + }, + "openai_compatible": { + "title": "OCR Compatible OpenAI", + "base_url": "URL de Base", + "api_key": "Clé API", + "model": "Nom du Modèle", + "enable_stream": "Activer le Streaming" } }, "collection": { diff --git a/src/i18n/locales/he_IL.json b/src/i18n/locales/he_IL.json index c577b4d134..05699e9aba 100644 --- a/src/i18n/locales/he_IL.json +++ b/src/i18n/locales/he_IL.json @@ -337,6 +337,13 @@ }, "no_need": "אין צורך בתצורה", "recognize": { + "openai_compatible": { + "title": "OCR תואם OpenAI", + "base_url": "URL בסיס", + "api_key": "מפתח API", + "model": "שם המודל", + "enable_stream": "הפעל זרימה" + }, "qrcode": { "title": "קוד QR" }, diff --git a/src/i18n/locales/it_IT.json b/src/i18n/locales/it_IT.json index ac5cba764d..174d383cb7 100644 --- a/src/i18n/locales/it_IT.json +++ b/src/i18n/locales/it_IT.json @@ -119,6 +119,13 @@ } }, "recognize": { + "openai_compatible": { + "title": "OCR Compatibile OpenAI", + "base_url": "URL Base", + "api_key": "Chiave API", + "model": "Nome Modello", + "enable_stream": "Abilita Streaming" + }, "iflytek_latex_ocr": { "apikey": "Chiave API", "title": "iFlyTek LaTeX", diff --git a/src/i18n/locales/ja_JP.json b/src/i18n/locales/ja_JP.json index 4c94650801..6001e7e1fc 100644 --- a/src/i18n/locales/ja_JP.json +++ b/src/i18n/locales/ja_JP.json @@ -132,6 +132,13 @@ "volcengine_multi_lang_ocr": { "appid": "アプリID" }, + "openai_compatible": { + "title": "OpenAI 互換 OCR", + "base_url": "ベース URL", + "api_key": "API キー", + "model": "モデル名", + "enable_stream": "ストリーミングを有効にする" + }, "qrcode": { "title": "QRコード" }, @@ -158,4 +165,4 @@ } } } -} +} \ No newline at end of file diff --git a/src/i18n/locales/ko_KR.json b/src/i18n/locales/ko_KR.json index 982795c8f5..00d54553c0 100644 --- a/src/i18n/locales/ko_KR.json +++ b/src/i18n/locales/ko_KR.json @@ -12,7 +12,16 @@ "lingva": { "title": "Lingva" } + }, + "recognize": { + "openai_compatible": { + "title": "OpenAI 호환 OCR", + "base_url": "기본 URL", + "api_key": "API 키", + "model": "모델 이름", + "enable_stream": "스트리밍 활성화" + } } } } -} +} \ No newline at end of file diff --git a/src/i18n/locales/nb_NO.json b/src/i18n/locales/nb_NO.json index 764ec7171f..68a6cb58a9 100644 --- a/src/i18n/locales/nb_NO.json +++ b/src/i18n/locales/nb_NO.json @@ -37,6 +37,13 @@ "openai_summary": {} }, "recognize": { + "openai_compatible": { + "title": "OpenAI Kompatibel OCR", + "base_url": "Grunnleggende URL", + "api_key": "API-nøkkel", + "model": "Modellnavn", + "enable_stream": "Aktiver Streaming" + }, "iflytek_latex_ocr": {}, "baidu_ocr": {}, "simple_latex_ocr": {}, diff --git a/src/i18n/locales/nn_NO.json b/src/i18n/locales/nn_NO.json index 764ec7171f..6dbc811340 100644 --- a/src/i18n/locales/nn_NO.json +++ b/src/i18n/locales/nn_NO.json @@ -37,6 +37,13 @@ "openai_summary": {} }, "recognize": { + "openai_compatible": { + "title": "OpenAI Kompatibel OCR", + "base_url": "Grunn-URL", + "api_key": "API-nøkkel", + "model": "Modellnamn", + "enable_stream": "Aktiver Strøyming" + }, "iflytek_latex_ocr": {}, "baidu_ocr": {}, "simple_latex_ocr": {}, diff --git a/src/i18n/locales/pt_BR.json b/src/i18n/locales/pt_BR.json index 8c1295bf7e..bc88e3b2ea 100644 --- a/src/i18n/locales/pt_BR.json +++ b/src/i18n/locales/pt_BR.json @@ -360,6 +360,13 @@ } }, "recognize": { + "openai_compatible": { + "title": "OCR Compatível com OpenAI", + "base_url": "URL Base", + "api_key": "Chave da API", + "model": "Nome do Modelo", + "enable_stream": "Habilitar Streaming" + }, "system": { "title": "OCR do Sistema" }, diff --git a/src/i18n/locales/pt_PT.json b/src/i18n/locales/pt_PT.json index 8358457172..9223706261 100644 --- a/src/i18n/locales/pt_PT.json +++ b/src/i18n/locales/pt_PT.json @@ -191,6 +191,13 @@ }, "system": { "title": "OCR do Sistema" + }, + "openai_compatible": { + "title": "OCR Compatível OpenAI", + "base_url": "URL Base", + "api_key": "Chave API", + "model": "Nome do Modelo", + "enable_stream": "Activar Streaming" } }, "collection": { diff --git a/src/i18n/locales/ru_RU.json b/src/i18n/locales/ru_RU.json index ef052e15f4..ef78aaf2e3 100644 --- a/src/i18n/locales/ru_RU.json +++ b/src/i18n/locales/ru_RU.json @@ -430,6 +430,13 @@ }, "qrcode": { "title": "QR-код" + }, + "openai_compatible": { + "title": "OpenAI Совместимый OCR", + "base_url": "Базовый URL", + "api_key": "API Ключ", + "model": "Название Модели", + "enable_stream": "Включить Потоковый Режим" } }, "tts": { diff --git a/src/i18n/locales/ta_IN.json b/src/i18n/locales/ta_IN.json index 378e9af0bb..54ec2da414 100644 --- a/src/i18n/locales/ta_IN.json +++ b/src/i18n/locales/ta_IN.json @@ -338,6 +338,13 @@ } }, "recognize": { + "openai_compatible": { + "title": "OpenAI இணக்கமான OCR", + "base_url": "அடிப்படை URL", + "api_key": "API விசை", + "model": "மாதிரி பெயர்", + "enable_stream": "ஸ்ட்ரீமிங்கை இயக்கு" + }, "iflytek_ocr": { "title": "iflytek ocr", "appid": "பயன்பாட்டு ஐடி", @@ -469,4 +476,4 @@ "translate_back": "மொழிபெயர்ப்பு மீண்டும் அசலுக்கு" } } -} +} \ No newline at end of file diff --git a/src/i18n/locales/tk_TM.json b/src/i18n/locales/tk_TM.json index 517062b029..26d58205b5 100644 --- a/src/i18n/locales/tk_TM.json +++ b/src/i18n/locales/tk_TM.json @@ -81,6 +81,17 @@ "plugin": "Plugin", "coming": "Tiz ýakynda…", "clear": "Arassala" + }, + "services": { + "recognize": { + "openai_compatible": { + "title": "OpenAI Laýyk OCR", + "base_url": "Esasy URL", + "api_key": "API Açary", + "model": "Model Ady", + "enable_stream": "Streaming-i Açmak" + } + } } } -} +} \ No newline at end of file diff --git a/src/i18n/locales/tr_TR.json b/src/i18n/locales/tr_TR.json index 764ec7171f..e61c42ce6b 100644 --- a/src/i18n/locales/tr_TR.json +++ b/src/i18n/locales/tr_TR.json @@ -37,6 +37,13 @@ "openai_summary": {} }, "recognize": { + "openai_compatible": { + "title": "OpenAI Uyumlu OCR", + "base_url": "Temel URL", + "api_key": "API Anahtarı", + "model": "Model Adı", + "enable_stream": "Akışı Etkinleştir" + }, "iflytek_latex_ocr": {}, "baidu_ocr": {}, "simple_latex_ocr": {}, diff --git a/src/i18n/locales/uk_UA.json b/src/i18n/locales/uk_UA.json index 818dab482c..d6d26796de 100644 --- a/src/i18n/locales/uk_UA.json +++ b/src/i18n/locales/uk_UA.json @@ -89,6 +89,13 @@ }, "iflytek_ocr": { "apikey": "API Ключ" + }, + "openai_compatible": { + "title": "OpenAI Сумісний OCR", + "base_url": "Базовий URL", + "api_key": "API Ключ", + "model": "Назва Моделі", + "enable_stream": "Увімкнути Потокове Передавання" } }, "collection": { @@ -116,4 +123,4 @@ "delete_newline": "Видалити новий рядок" } } -} +} \ No newline at end of file diff --git a/src/i18n/locales/zh_CN.json b/src/i18n/locales/zh_CN.json index b706528aee..0cc082e4ff 100644 --- a/src/i18n/locales/zh_CN.json +++ b/src/i18n/locales/zh_CN.json @@ -428,6 +428,13 @@ "appid": "App ID", "secret": "Secret" }, + "openai_compatible": { + "title": "OpenAI 兼容 OCR", + "base_url": "API 基础地址", + "api_key": "API 密钥", + "model": "模型名称", + "enable_stream": "启用流式输出" + }, "qrcode": { "title": "二维码识别" } diff --git a/src/i18n/locales/zh_TW.json b/src/i18n/locales/zh_TW.json index e26148f655..2672801736 100644 --- a/src/i18n/locales/zh_TW.json +++ b/src/i18n/locales/zh_TW.json @@ -426,6 +426,13 @@ }, "qrcode": { "title": "QR Code" + }, + "openai_compatible": { + "title": "OpenAI 相容 OCR", + "base_url": "API 基礎位址", + "api_key": "API 金鑰", + "model": "模型名稱", + "enable_stream": "啟用串流輸出" } }, "tts": { From ef7e0b0421b78f07052cf429ac9c7b82cf231604 Mon Sep 17 00:00:00 2001 From: TioSisai Date: Mon, 14 Jul 2025 20:53:52 +0300 Subject: [PATCH 4/5] fix(config): modifiy the mismatched ocr pack name --- src-tauri/src/config.rs | 1 + src/services/recognize/index.jsx | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 6c69dda3a2..d90edf93b1 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -58,6 +58,7 @@ pub fn check_service_available() -> Result<(), Error> { "iflytek_ocr", "iflytek_intsig_ocr", "iflytek_latex_ocr", + "openai_compatible", "qrcode", "simple_latex_ocr", "system", diff --git a/src/services/recognize/index.jsx b/src/services/recognize/index.jsx index e412955e26..bc35fb1ad7 100644 --- a/src/services/recognize/index.jsx +++ b/src/services/recognize/index.jsx @@ -12,7 +12,7 @@ import * as _tencent_accurate_ocr from './tencent_accurate'; import * as _tencent_img_ocr from './tencent_img'; import * as _volcengine_ocr from './volcengine'; import * as _volcengine_multi_lang_ocr from './volcengine_multi_lang'; -import * as _openai_compatible_ocr from './openai_compatible'; +import * as _openai_compatible from './openai_compatible'; import * as _qrcode from './qrcode'; export const system = _system; @@ -29,5 +29,5 @@ export const tencent_accurate_ocr = _tencent_accurate_ocr; export const tencent_img_ocr = _tencent_img_ocr; export const volcengine_ocr = _volcengine_ocr; export const volcengine_multi_lang_ocr = _volcengine_multi_lang_ocr; -export const openai_compatible_ocr = _openai_compatible_ocr; +export const openai_compatible = _openai_compatible; export const qrcode = _qrcode; From 5a3fe826bc6bc2c39339ffba99112461788d8710 Mon Sep 17 00:00:00 2001 From: TioSisai Date: Mon, 14 Jul 2025 22:27:09 +0300 Subject: [PATCH 5/5] refactor(TextArea): Optimize the process of the stream flow --- src/window/Recognize/TextArea/index.jsx | 122 +++++++++++++++--------- 1 file changed, 76 insertions(+), 46 deletions(-) diff --git a/src/window/Recognize/TextArea/index.jsx b/src/window/Recognize/TextArea/index.jsx index ca6b252b0f..3586aca8d8 100644 --- a/src/window/Recognize/TextArea/index.jsx +++ b/src/window/Recognize/TextArea/index.jsx @@ -20,9 +20,22 @@ import { pluginListAtom } from '..'; export const textAtom = atom(); let recognizeId = 0; +// 创建仅执行一次的函数,参考翻译模块的 invokeOnce 实现 +function invokeOnce(fn) { + let isInvoke = false; + + return (...args) => { + if (isInvoke) { + return; + } else { + fn(...args); + isInvoke = true; + } + }; +} + // 流式结果处理Hook const useStreamHandler = (setText, setLoading, deleteNewline, autoCopy, hideWindow, t) => { - const throttleRef = useRef(null); const hasReceivedDataRef = useRef(false); const handler = useCallback((result) => { @@ -31,24 +44,18 @@ const useStreamHandler = (setText, setLoading, deleteNewline, autoCopy, hideWind const content = isStreaming ? result.slice(0, -1) : (typeof result === 'string' ? result.trim() : result); if (isStreaming) { - // 首次流式数据立即显示并隐藏loading - if (!hasReceivedDataRef.current) { - hasReceivedDataRef.current = true; - setLoading(false); - setText(content); - return; - } + // 创建只执行一次的 setLoading(false),参考翻译模块的 setHideOnce + const setLoadingOnce = invokeOnce(setLoading); + setLoadingOnce(false); + + // 标记已接收数据 + hasReceivedDataRef.current = true; - // 节流更新流式内容 - if (throttleRef.current) clearTimeout(throttleRef.current); - throttleRef.current = setTimeout(() => setText(content), 50); + // 直接更新流式内容 + setText(content); } else { // 处理最终结果或非流式结果 hasReceivedDataRef.current = true; - if (throttleRef.current) { - clearTimeout(throttleRef.current); - throttleRef.current = null; - } let finalText = content; if (deleteNewline && typeof finalText === 'string') { @@ -130,6 +137,9 @@ export default function TextArea(props) { if (language in pluginList[getServiceName(currentServiceInstanceKey)].language) { let id = nanoid(); recognizeId = id; + // 重置流式状态 + streamHandler.hasReceivedDataRef.current = false; + const pluginConfig = serviceInstanceConfigMap[currentServiceInstanceKey] ?? {}; invoke_plugin('recognize', getServiceName(currentServiceInstanceKey)).then(([func, utils]) => { @@ -140,21 +150,28 @@ export default function TextArea(props) { }).then( (v) => { if (recognizeId !== id) return; - v = v.trim(); - if (deleteNewline) { - v = v.replace(/\-\s+/g, '').replace(/\s+/g, ' '); - } - setText(v); - setLoading(false); - if (autoCopy) { - writeText(v).then(() => { - if (hideWindow) { - sendNotification({ - title: t('common.write_clipboard'), - body: v, - }); - } - }); + // 如果流式处理器已经处理过数据,就不再处理 + if (streamHandler.hasReceivedDataRef?.current) return; + + if (v) { + v = v.trim(); + if (deleteNewline) { + v = v.replace(/\-\s+/g, '').replace(/\s+/g, ' '); + } + setText(v); + setLoading(false); + if (autoCopy) { + writeText(v).then(() => { + if (hideWindow) { + sendNotification({ + title: t('common.write_clipboard'), + body: v, + }); + } + }); + } + } else { + setLoading(false); } }, (e) => { @@ -182,24 +199,37 @@ export default function TextArea(props) { ).then( (v) => { if (recognizeId !== id) return; - // 如果流式处理器已经处理过数据,就不再处理 - if (streamHandler.hasReceivedDataRef?.current) return; - v = v.trim(); - if (deleteNewline) { - v = v.replace(/\-\s+/g, '').replace(/\s+/g, ' '); + // 检查是否是支持流式的服务 + const serviceName = getServiceName(currentServiceInstanceKey); + const recognizeFunc = builtinServices[serviceName].recognize; + const supportsStream = recognizeFunc.length >= 3; + + if (supportsStream) { + // 支持流式的服务:只有在非流式模式下才处理最终结果 + if (streamHandler.hasReceivedDataRef?.current) return; } - setText(v); - setLoading(false); - if (autoCopy) { - writeText(v).then(() => { - if (hideWindow) { - sendNotification({ - title: t('common.write_clipboard'), - body: v, - }); - } - }); + // 不支持流式的传统服务:总是处理返回值 + + if (v) { + v = v.trim(); + if (deleteNewline) { + v = v.replace(/\-\s+/g, '').replace(/\s+/g, ' '); + } + setText(v); + setLoading(false); + if (autoCopy) { + writeText(v).then(() => { + if (hideWindow) { + sendNotification({ + title: t('common.write_clipboard'), + body: v, + }); + } + }); + } + } else { + setLoading(false); } }, (e) => {