feat: 新增 Windows 本地图片文字识别能力

This commit is contained in:
电摇小子
2026-09-16 00:23:23 +08:00
parent e6db4de711
commit 24399f1d70
23 changed files with 1984 additions and 7 deletions
@@ -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}
+161
View File
@@ -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;