mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-06 13:54:10 +08:00
feat: 新增 Windows 本地图片文字识别能力
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type { SystemOcrCapability, SystemOcrResult } from '../../../../../shared/system-ocr'
|
||||
import { Button } from '../../../components/ui'
|
||||
|
||||
const MAX_FILE_BYTES = 10 * 1024 * 1024
|
||||
|
||||
type LocalOcrStatus = 'idle' | 'reading' | 'ready' | 'running' | 'done' | 'error'
|
||||
|
||||
interface LocalOcrState {
|
||||
status: LocalOcrStatus
|
||||
image?: {
|
||||
dataUrl: string
|
||||
fileName: string
|
||||
size: number
|
||||
}
|
||||
result?: SystemOcrResult
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地图片文字识别(Windows 系统 OCR)。
|
||||
*
|
||||
* 这是**本地 Runtime**,不是 AI 图片理解:
|
||||
* - 只把图片里的文字读出来;不描述画面、人物、场景,也不做视觉推理;
|
||||
* - 原始图片不会因为这一步发给任何 AI Provider;
|
||||
* - 结果只是派生内容,不会写进本地知识库。
|
||||
*/
|
||||
export function LocalImageTextRecognition(): React.ReactElement {
|
||||
const [capability, setCapability] = useState<SystemOcrCapability | null>(null)
|
||||
const [state, setState] = useState<LocalOcrState>({ status: 'idle' })
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
void window.api
|
||||
.getSystemOcrCapability()
|
||||
.then((value) => {
|
||||
if (alive) setCapability(value)
|
||||
})
|
||||
.catch(() => {
|
||||
if (alive) setCapability(null)
|
||||
})
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const selectImage = useCallback(async (file: File): Promise<void> => {
|
||||
const extension = file.name.split('.').pop()?.toLowerCase()
|
||||
const inferredType =
|
||||
extension === 'png'
|
||||
? 'image/png'
|
||||
: extension === 'jpg' || extension === 'jpeg'
|
||||
? 'image/jpeg'
|
||||
: extension === 'webp'
|
||||
? 'image/webp'
|
||||
: extension === 'bmp'
|
||||
? 'image/bmp'
|
||||
: extension === 'gif'
|
||||
? 'image/gif'
|
||||
: ''
|
||||
const mimeType = file.type === 'image/jpg' ? 'image/jpeg' : file.type || inferredType
|
||||
const supportedTypes = new Set([
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/webp',
|
||||
'image/bmp',
|
||||
'image/gif'
|
||||
])
|
||||
if (!supportedTypes.has(mimeType)) {
|
||||
setState({ status: 'error', error: '请选择 PNG、JPG、JPEG、WebP、BMP 或 GIF 图片' })
|
||||
return
|
||||
}
|
||||
if (!file.size || file.size > MAX_FILE_BYTES) {
|
||||
setState({ status: 'error', error: '图片大小必须在 10 MB 以内' })
|
||||
return
|
||||
}
|
||||
setState({ status: 'reading' })
|
||||
try {
|
||||
const rawDataUrl = await readFileAsDataUrl(file)
|
||||
const dataUrl = rawDataUrl.replace(/^data:[^;]*;/, `data:${mimeType};`)
|
||||
setState({
|
||||
status: 'ready',
|
||||
image: { dataUrl, fileName: file.name, size: file.size }
|
||||
})
|
||||
} catch {
|
||||
setState({ status: 'error', error: '图片无法读取,请重新选择' })
|
||||
}
|
||||
}, [])
|
||||
|
||||
const run = useCallback(async (): Promise<void> => {
|
||||
const image = state.image
|
||||
if (!image) return
|
||||
if (!capability?.available) {
|
||||
setState((current) => ({
|
||||
...current,
|
||||
status: 'error',
|
||||
error: capability?.message || '本机当前不支持本地图片文字识别'
|
||||
}))
|
||||
return
|
||||
}
|
||||
setState((current) => ({ ...current, status: 'running', result: undefined, error: undefined }))
|
||||
try {
|
||||
const result = await window.api.recognizeLocalImageText({ imageDataUrl: image.dataUrl })
|
||||
setState((current) =>
|
||||
result.success
|
||||
? { ...current, status: 'done', result, error: undefined }
|
||||
: { ...current, status: 'error', result: undefined, error: localOcrErrorMessage(result) }
|
||||
)
|
||||
} catch {
|
||||
setState((current) => ({
|
||||
...current,
|
||||
status: 'error',
|
||||
result: undefined,
|
||||
error: '本地文字识别调用失败,请重试'
|
||||
}))
|
||||
}
|
||||
}, [capability, state.image])
|
||||
|
||||
const clear = useCallback(() => setState({ status: 'idle' }), [])
|
||||
|
||||
const running = state.status === 'running'
|
||||
const result = state.result
|
||||
|
||||
return (
|
||||
<section className="settings-card local-ocr-test">
|
||||
<header>
|
||||
<div>
|
||||
<h2>本地图片文字识别</h2>
|
||||
<p>使用 Windows 系统 OCR 在本机读取图片中的文字,原始图片无需发送给 AI Provider。</p>
|
||||
</div>
|
||||
<span className={`local-ocr-capability ${capability?.available ? 'supported' : ''}`}>
|
||||
{capability ? (capability.available ? '本机可用' : '本机不可用') : '检测中…'}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
{capability && !capability.available ? (
|
||||
<p className="local-ocr-notice">{capability.message}</p>
|
||||
) : null}
|
||||
{capability?.available ? (
|
||||
<p className="local-ocr-runtime">
|
||||
引擎:Windows 系统 OCR
|
||||
{capability.runtimeVersion ? ` · 组件 ${capability.runtimeVersion}` : ''}
|
||||
{capability.language ? ` · 语言 ${capability.language}` : ' · 语言跟随系统'}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<label className={`local-ocr-upload ${state.image ? 'has-image' : ''}`}>
|
||||
<input
|
||||
type="file"
|
||||
accept=".png,.jpg,.jpeg,.webp,.bmp,.gif,image/png,image/jpeg,image/webp,image/bmp,image/gif"
|
||||
onChange={(event) => {
|
||||
const file = event.currentTarget.files?.[0]
|
||||
if (file) void selectImage(file)
|
||||
event.currentTarget.value = ''
|
||||
}}
|
||||
/>
|
||||
{state.image ? (
|
||||
<>
|
||||
<img src={state.image.dataUrl} alt="本地文字识别测试预览" />
|
||||
<div>
|
||||
<strong>{state.image.fileName}</strong>
|
||||
<small>{formatFileSize(state.image.size)} · 仅保存在内存中</small>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div>
|
||||
<strong>{state.status === 'reading' ? '正在读取图片…' : '选择本地图片'}</strong>
|
||||
<small>支持 PNG、JPG、JPEG、WebP、BMP、GIF,最大 10 MB</small>
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<p className="local-ocr-privacy">
|
||||
使用本地 OCR 时,原始图片无需发送给 AI Provider,也不会写入本地缓存或知识库。如果后续继续使用云端
|
||||
AI 分析,提取出的文字可能按当前 Provider 配置发送。
|
||||
</p>
|
||||
|
||||
{state.error ? <p className="local-ocr-error">{state.error}</p> : null}
|
||||
|
||||
{result?.success ? (
|
||||
<div className="local-ocr-result">
|
||||
<h3>识别结果</h3>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>引擎</dt>
|
||||
<dd>Windows 系统 OCR</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>语言</dt>
|
||||
<dd>{result.language || '跟随系统'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>耗时</dt>
|
||||
<dd>{Math.round(result.durationMs)} ms</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<pre className="local-ocr-text">{result.text}</pre>
|
||||
<p className="local-ocr-hint">
|
||||
本地文字识别只读取图片中的文字内容,不会描述画面、人物或场景。
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<footer>
|
||||
{state.image ? (
|
||||
<Button variant="outline" onClick={clear}>
|
||||
移除图片
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
disabled={!state.image || running || !capability?.available}
|
||||
onClick={() => void run()}
|
||||
>
|
||||
{running ? '识别中…' : '本地文字识别'}
|
||||
</Button>
|
||||
</footer>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function localOcrErrorMessage(result: SystemOcrResult): string {
|
||||
switch (result.errorCode) {
|
||||
case 'UNSUPPORTED_PLATFORM':
|
||||
return '本地图片文字识别目前仅支持 Windows。'
|
||||
case 'SYSTEM_OCR_UNAVAILABLE':
|
||||
return '本地文字识别组件不可用,请重新安装 TraceMemo。'
|
||||
case 'OCR_LANGUAGE_UNAVAILABLE':
|
||||
return '当前 Windows 未安装可用的 OCR 语言支持,请在系统「语言和区域」中安装中文或英文语言包。'
|
||||
case 'UNSUPPORTED_IMAGE':
|
||||
return '这张图片的格式暂不支持本地文字识别。'
|
||||
case 'IMAGE_DECODE_FAILED':
|
||||
return '图片解码失败,无法读取这张图片。'
|
||||
case 'OCR_EMPTY_RESULT':
|
||||
return '没有在这张图片里识别到文字。'
|
||||
default:
|
||||
return result.error || '本地文字识别失败,请重试。'
|
||||
}
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
return bytes < 1024 * 1024
|
||||
? `${Math.max(1, Math.round(bytes / 1024))} KB`
|
||||
: `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function readFileAsDataUrl(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.addEventListener('load', () =>
|
||||
typeof reader.result === 'string' ? resolve(reader.result) : reject(new Error('invalid image'))
|
||||
)
|
||||
reader.addEventListener('error', () => reject(reader.error || new Error('read failed')))
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { Button } from '../../../components/ui'
|
||||
import { AIProviderCard } from '../ai-model/AIProviderCard'
|
||||
import { AIProviderEditor } from '../ai-model/AIProviderEditor'
|
||||
import { AIImageUnderstandingTest } from '../ai-model/AIImageUnderstandingTest'
|
||||
import { LocalImageTextRecognition } from '../ai-model/LocalImageTextRecognition'
|
||||
import { useAIModelSettingsController } from '../ai-model/useAIModelSettingsController'
|
||||
|
||||
export function AIModelPage({
|
||||
@@ -68,6 +69,7 @@ export function AIModelPage({
|
||||
onTest={() => void controller.runVisionTest()}
|
||||
onClear={controller.clearVisionImage}
|
||||
/>
|
||||
<LocalImageTextRecognition />
|
||||
{controller.state.error ? (
|
||||
<p className="ai-model-page-error">{controller.state.error}</p>
|
||||
) : null}
|
||||
|
||||
@@ -1605,6 +1605,167 @@
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* 本地图片文字识别(System OCR,本地 Runtime,与 AI 图片理解刻意区分) */
|
||||
.local-ocr-test {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.local-ocr-test > header,
|
||||
.local-ocr-test > footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.local-ocr-test h2,
|
||||
.local-ocr-test h3,
|
||||
.local-ocr-test p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.local-ocr-test h2 {
|
||||
color: var(--wxex-text-primary);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.local-ocr-test header p,
|
||||
.local-ocr-runtime,
|
||||
.local-ocr-upload small,
|
||||
.local-ocr-hint {
|
||||
margin-top: 4px;
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.local-ocr-capability {
|
||||
border-radius: 999px;
|
||||
padding: 5px 9px;
|
||||
background: var(--wxex-bg-sidebar);
|
||||
color: var(--wxex-text-secondary);
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.local-ocr-capability.supported {
|
||||
background: var(--wxex-brand-soft);
|
||||
color: var(--wxex-brand);
|
||||
}
|
||||
|
||||
.local-ocr-notice {
|
||||
border-left: 3px solid var(--wxex-danger);
|
||||
padding: 9px 11px;
|
||||
background: color-mix(in srgb, var(--wxex-danger) 12%, transparent);
|
||||
color: var(--wxex-danger);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.local-ocr-upload {
|
||||
display: flex;
|
||||
min-height: 112px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
border: 1px dashed var(--wxex-border);
|
||||
border-radius: var(--wxex-radius-md);
|
||||
padding: 14px;
|
||||
background: var(--wxex-bg-sidebar);
|
||||
color: var(--wxex-text-primary);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.local-ocr-upload:hover {
|
||||
border-color: var(--wxex-brand);
|
||||
background: var(--wxex-brand-soft);
|
||||
}
|
||||
|
||||
.local-ocr-upload input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.local-ocr-upload.has-image {
|
||||
justify-content: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.local-ocr-upload img {
|
||||
width: 112px;
|
||||
height: 82px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.local-ocr-upload strong,
|
||||
.local-ocr-upload small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.local-ocr-privacy {
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.local-ocr-error {
|
||||
border-left: 3px solid var(--wxex-danger);
|
||||
padding: 9px 11px;
|
||||
background: color-mix(in srgb, var(--wxex-danger) 12%, transparent);
|
||||
color: var(--wxex-danger);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.local-ocr-result {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: var(--wxex-radius-md);
|
||||
padding: 14px;
|
||||
background: var(--wxex-bg-sidebar);
|
||||
}
|
||||
|
||||
.local-ocr-result dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.local-ocr-result dt {
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.local-ocr-result dd {
|
||||
margin: 4px 0 0;
|
||||
color: var(--wxex-text-primary);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.local-ocr-text {
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
margin: 0;
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
background: var(--wxex-bg-app);
|
||||
color: var(--wxex-text-primary);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.local-ocr-test > footer {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.report-history-sidebar {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
|
||||
Reference in New Issue
Block a user