feat: add document OCR and offline model archives

This commit is contained in:
lofyer
2026-08-11 16:49:51 +08:00
parent 19a4469561
commit e0e7bc573c
46 changed files with 7831 additions and 85 deletions
@@ -0,0 +1,377 @@
import {
cleanup,
fireEvent,
render,
screen,
waitFor
} from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type {
DocumentParsingSettings,
DocumentParsingSnapshot
} from '../../shared/document-parsing-contracts'
import { changeUiLocale } from './i18n'
import { DocumentParsingSettingsSection } from './DocumentParsingSettingsSection'
const settings: DocumentParsingSettings = {
chatWorkflow: 'auto',
knowledgeWorkflow: 'complete-index',
pdfOcrMode: 'auto',
ocrProvider: 'local',
localOcrEnabled: true,
localOcrModelId: 'pp-ocrv6-tiny',
maximumPages: 100,
ocrConcurrency: 1,
pageTimeoutSeconds: 60
}
const modelEntry = {
id: 'pp-ocrv6-tiny' as const,
displayName: 'PP-OCRv6 Tiny',
description: '轻量中文 OCR 模型',
languages: ['中文', '英语'],
runtime: 'onnxruntime-web-wasm' as const,
quality: 'basic' as const,
speed: 'fast' as const,
recommended: false,
repositoryUrl:
'https://modelscope.cn/models/PaddlePaddle/PP-OCRv6_tiny_rec_onnx',
license: {
name: 'Apache License 2.0',
notice: '使用前请阅读模型许可证。',
url: 'https://example.com/license'
},
files: [
{
name: 'detection.onnx',
role: 'detection' as const,
download: {
url: 'https://modelscope.cn/models/example/detection.onnx',
size: 1_000,
sha256: 'a'.repeat(64)
}
},
{
name: 'recognition.onnx',
role: 'recognition' as const,
download: {
url: 'https://modelscope.cn/models/example/recognition.onnx',
size: 2_000,
sha256: 'b'.repeat(64)
}
},
{
name: 'dictionary.yml',
role: 'dictionary' as const,
download: {
url: 'https://modelscope.cn/models/example/dictionary.yml',
size: 500,
sha256: 'c'.repeat(64)
}
}
]
}
const secondModelEntry = {
...modelEntry,
id: 'pp-ocrv6-small',
displayName: 'PP-OCRv6 Small',
quality: 'balanced' as const,
speed: 'balanced' as const,
recommended: true
}
const thirdModelEntry = {
...modelEntry,
id: 'pp-ocrv6-medium',
displayName: 'PP-OCRv6 Medium',
quality: 'high' as const,
speed: 'slow' as const,
recommended: false
}
const snapshot: DocumentParsingSnapshot = {
settings,
status: {
nativeParsingAvailable: true,
conversionAvailable: false,
localOcr: {
id: 'pp-ocrv6-tiny',
displayName: 'PP-OCRv6 Tiny',
available: false,
verified: false,
runtime: 'onnxruntime-web-wasm',
detail: '模型尚未安装'
}
},
ocrModels: {
rootDirectory: 'C:\\Users\\test\\models\\document-ocr',
catalog: [modelEntry, secondModelEntry, thirdModelEntry],
installed: [
{
id: 'pp-ocrv6-small',
displayName: 'PP-OCRv6 Small',
source: 'download',
installedAt: '2026-08-11T00:00:00.000Z',
files: secondModelEntry.files.map((file) => ({
name: file.name,
role: file.role,
size: file.download.size,
sha256: file.download.sha256
}))
}
],
operations: []
}
}
const getSnapshot = vi.fn(async () => snapshot)
const update = vi.fn(async (input: DocumentParsingSettings) => ({
...snapshot,
settings: input
}))
const test = vi.fn(async () => ({
fileName: 'scan.pdf',
sourceFormat: 'PDF',
pageCount: 2,
ocrPageCount: 2,
characterCount: 120,
method: 'ocr' as const,
durationMs: 1_250,
preview: '扫描件识别正文',
warnings: []
}))
const installOcrModel = vi.fn(async () => ({
...snapshot,
status: {
...snapshot.status,
localOcr: {
...snapshot.status.localOcr,
available: true,
verified: true,
detail: '模型已安装并校验'
}
},
ocrModels: {
...snapshot.ocrModels,
installed: [
{
id: 'pp-ocrv6-tiny' as const,
displayName: 'PP-OCRv6 Tiny',
source: 'download' as const,
installedAt: '2026-08-11T00:00:00.000Z',
files: modelEntry.files.map((file) => ({
name: file.name,
role: file.role,
size: file.download.size,
sha256: file.download.sha256
}))
}
]
}
}))
const importOcrModelArchive = vi.fn(async () => snapshot)
const exportOcrModelArchive = vi.fn(async () => snapshot)
const openOcrModelRepository = vi.fn(async () => undefined)
describe('DocumentParsingSettingsSection', () => {
beforeEach(async () => {
await changeUiLocale('zh-CN')
vi.clearAllMocks()
Object.defineProperty(window, 'goodbuddy', {
configurable: true,
value: {
documentParsing: {
getSnapshot,
update,
test,
installOcrModel,
cancelOcrModelOperation: vi.fn(async () => true),
removeOcrModel: vi.fn(async () => snapshot),
importOcrModelArchive,
exportOcrModelArchive,
openOcrModelRepository,
openOcrModelsDirectory: vi.fn(),
getOcrAssets: vi.fn(),
respondOcr: vi.fn(),
onOcrRequest: vi.fn(() => () => undefined),
onOcrCancel: vi.fn(() => () => undefined)
}
}
})
})
afterEach(() => cleanup())
it('shows actual capability status and saves workflow settings', async () => {
const onNotify = vi.fn()
render(
<DocumentParsingSettingsSection onNotify={onNotify} />
)
expect(await screen.findByText('PP-OCRv6 Tiny')).toBeInTheDocument()
expect(screen.getByText('ModelScope')).toBeInTheDocument()
expect(screen.getByText('质量:基础')).toBeInTheDocument()
expect(screen.getByText('速度:快')).toBeInTheDocument()
expect(screen.getByText('旧版 Office 转换')).toBeInTheDocument()
expect(
screen.getByRole('button', { name: '本地模型' })
).toHaveAttribute('aria-pressed', 'true')
expect(
screen.getByRole('button', {
name: '远程服务(即将支持)'
})
).toBeDisabled()
expect(screen.queryByText('隐私与云端处理')).not.toBeInTheDocument()
expect(
screen.queryByText('模型详情与手动导入')
).not.toBeInTheDocument()
fireEvent.click(
screen.getByRole('button', {
name: '打开 PP-OCRv6 Tiny 的 ModelScope 页面'
})
)
expect(openOcrModelRepository).toHaveBeenCalledWith('pp-ocrv6-tiny')
fireEvent.change(screen.getByLabelText('聊天附件'), {
target: { value: 'fast-text' }
})
fireEvent.click(
screen.getByRole('button', { name: '保存设置' })
)
await waitFor(() =>
expect(update).toHaveBeenCalledWith(
expect.objectContaining({ chatWorkflow: 'fast-text' })
)
)
expect(onNotify).toHaveBeenCalledWith(
expect.objectContaining({
message: '文档解析设置已保存'
})
)
})
it('downloads the verified OCR model from the model catalog', async () => {
const onNotify = vi.fn()
render(
<DocumentParsingSettingsSection onNotify={onNotify} />
)
fireEvent.click(
await screen.findByRole('button', {
name: '下载 PP-OCRv6 Tiny'
})
)
await waitFor(() =>
expect(installOcrModel).toHaveBeenCalledWith('pp-ocrv6-tiny')
)
expect(onNotify).toHaveBeenCalledWith(
expect.objectContaining({
message: 'PP-OCRv6 Tiny 已安装'
})
)
})
it('imports and exports verified OCR model ZIP archives', async () => {
const onNotify = vi.fn()
render(
<DocumentParsingSettingsSection onNotify={onNotify} />
)
fireEvent.click(
await screen.findByRole('button', {
name: '从 ZIP 导入 PP-OCRv6 Tiny'
})
)
await waitFor(() =>
expect(importOcrModelArchive).toHaveBeenCalledWith(
'pp-ocrv6-tiny'
)
)
expect(onNotify).toHaveBeenCalledWith(
expect.objectContaining({
message: 'PP-OCRv6 Tiny 已从 ZIP 导入'
})
)
fireEvent.change(screen.getByLabelText('当前 OCR 模型'), {
target: { value: 'pp-ocrv6-small' }
})
fireEvent.click(
screen.getByRole('button', {
name: '将 PP-OCRv6 Small 导出为 ZIP'
})
)
await waitFor(() =>
expect(exportOcrModelArchive).toHaveBeenCalledWith(
'pp-ocrv6-small'
)
)
expect(update).not.toHaveBeenCalled()
expect(onNotify).toHaveBeenCalledWith(
expect.objectContaining({
message: 'PP-OCRv6 Small 已导出为 ZIP'
})
)
})
it('switches the selected OCR model only when settings are saved', async () => {
render(<DocumentParsingSettingsSection />)
const selector = await screen.findByLabelText('当前 OCR 模型')
expect(
screen.getByRole('option', {
name: 'PP-OCRv6 Tiny · 可下载'
})
).toBeInTheDocument()
expect(
screen.getByRole('option', {
name: 'PP-OCRv6 Small · 已安装'
})
).toBeInTheDocument()
expect(
screen.getByRole('option', {
name: 'PP-OCRv6 Medium · 可下载'
})
).toBeInTheDocument()
fireEvent.change(selector, {
target: { value: 'pp-ocrv6-small' }
})
expect(
screen.getByText('模型选择尚未生效,点击“保存设置”后切换。')
).toBeInTheDocument()
expect(screen.getByText('PP-OCRv6 Small')).toBeInTheDocument()
expect(screen.getByText('质量:均衡')).toBeInTheDocument()
expect(screen.getByText('速度:均衡')).toBeInTheDocument()
expect(update).not.toHaveBeenCalled()
fireEvent.click(
screen.getByRole('button', { name: '保存设置' })
)
await waitFor(() =>
expect(update).toHaveBeenCalledWith(
expect.objectContaining({
localOcrModelId: 'pp-ocrv6-small'
})
)
)
})
it('runs a real-file diagnostic flow and displays its result', async () => {
render(<DocumentParsingSettingsSection />)
await screen.findByText('PP-OCRv6 Tiny')
fireEvent.click(
screen.getByRole('button', { name: '测试解析' })
)
expect(
await screen.findByRole('dialog', {
name: '解析测试结果'
})
).toHaveTextContent('扫描件识别正文')
expect(test).toHaveBeenCalledOnce()
})
})
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -478,7 +478,8 @@ describe('SettingsPanel runtime files', () => {
cancel: vi.fn(async () => true),
remove: vi.fn(),
select: selectSpeechModel,
importLocalDirectory: vi.fn(),
importArchive: vi.fn(),
exportArchive: vi.fn(),
openRepository: vi.fn(),
openModelsDirectory: vi.fn()
},
+6
View File
@@ -38,6 +38,7 @@ import { UpdateSettingsSection } from './UpdateSettingsSection'
import { PlatformFeaturesSettingsSection } from './PlatformFeaturesSettingsSection'
import { SpeechModelSettingsSection } from './SpeechModelSettingsSection'
import { EmbeddingSettingsSection } from './EmbeddingSettingsSection'
import { DocumentParsingSettingsSection } from './DocumentParsingSettingsSection'
import { PageHeader, SegmentedControl } from './WorkspacePrimitives'
import { SettingsCategoryHeader } from './SettingsPrimitives'
import {
@@ -346,6 +347,7 @@ export function SettingsPanel({
activeTab === 'roles'
const categoryRendersOwnHeader =
activeTab === 'platform-features' ||
activeTab === 'document-parsing' ||
activeTab === 'channels' ||
activeTab === 'skills' ||
activeTab === 'mcp' ||
@@ -2258,6 +2260,10 @@ export function SettingsPanel({
</>
)}
{activeTab === 'document-parsing' && (
<DocumentParsingSettingsSection onNotify={onNotify} />
)}
{activeTab === 'security' && (
<>
<div className="settings-section">
@@ -75,7 +75,8 @@ describe('SpeechModelSettingsSection', () => {
cancel: vi.fn(async () => true),
remove: vi.fn(),
select: vi.fn(),
importLocalDirectory: vi.fn(),
importArchive: vi.fn(),
exportArchive: vi.fn(),
openRepository: vi.fn(),
openModelsDirectory: vi.fn()
}
@@ -128,7 +129,8 @@ describe('SpeechModelSettingsSection', () => {
cancel: vi.fn(async () => true),
remove: vi.fn(),
select: vi.fn(),
importLocalDirectory: vi.fn(),
importArchive: vi.fn(),
exportArchive: vi.fn(),
openRepository: vi.fn(),
openModelsDirectory: vi.fn()
}
@@ -181,7 +183,8 @@ describe('SpeechModelSettingsSection', () => {
cancel: vi.fn(async () => true),
remove: vi.fn(),
select: vi.fn(),
importLocalDirectory: vi.fn(),
importArchive: vi.fn(),
exportArchive: vi.fn(),
openRepository: vi.fn(),
openModelsDirectory: vi.fn()
}
@@ -200,6 +203,86 @@ describe('SpeechModelSettingsSection', () => {
)
})
it('imports and exports verified speech model ZIP archives', async () => {
const installedSnapshot: SpeechModelSnapshot = {
...snapshot,
installed: [
{
id: entry.id,
displayName: entry.displayName,
source: 'local',
installedAt: '2026-08-11T00:00:00.000Z',
files: [
{
name: 'model.int8.onnx',
role: 'model',
size: 1_000,
sha256: 'a'.repeat(64)
}
]
}
]
}
const importArchive = vi.fn(async () => installedSnapshot)
const exportArchive = vi.fn(async () => installedSnapshot)
const select = vi.fn()
const onNotify = vi.fn()
const getSnapshot = vi
.fn<() => Promise<SpeechModelSnapshot>>()
.mockResolvedValueOnce(snapshot)
.mockResolvedValue(installedSnapshot)
Object.defineProperty(window, 'goodbuddy', {
configurable: true,
value: {
speechModels: {
getSnapshot,
install: vi.fn(),
cancel: vi.fn(async () => true),
remove: vi.fn(),
select,
importArchive,
exportArchive,
openRepository: vi.fn(),
openModelsDirectory: vi.fn()
}
} as unknown as DesktopApi
})
render(<SpeechModelSettingsSection onNotify={onNotify} />)
fireEvent.click(
await screen.findByRole('button', {
name: '从 ZIP 导入 SenseVoiceSmall INT8'
})
)
await waitFor(() =>
expect(importArchive).toHaveBeenCalledWith(
'sensevoice-small-int8'
)
)
expect(onNotify).toHaveBeenCalledWith(
expect.objectContaining({
message: 'SenseVoiceSmall INT8 已从 ZIP 导入'
})
)
fireEvent.click(
await screen.findByRole('button', {
name: '将 SenseVoiceSmall INT8 导出为 ZIP'
})
)
await waitFor(() =>
expect(exportArchive).toHaveBeenCalledWith(
'sensevoice-small-int8'
)
)
expect(select).not.toHaveBeenCalled()
expect(onNotify).toHaveBeenCalledWith(
expect.objectContaining({
message: 'SenseVoiceSmall INT8 已导出为 ZIP'
})
)
})
it('shows live progress and cancellation for an active download', async () => {
const active: SpeechModelSnapshot = {
...snapshot,
@@ -224,7 +307,8 @@ describe('SpeechModelSettingsSection', () => {
cancel,
remove: vi.fn(),
select: vi.fn(),
importLocalDirectory: vi.fn(),
importArchive: vi.fn(),
exportArchive: vi.fn(),
openRepository: vi.fn(),
openModelsDirectory: vi.fn()
}
@@ -290,7 +374,8 @@ describe('SpeechModelSettingsSection', () => {
cancel: vi.fn(async () => true),
remove: vi.fn(),
select: vi.fn(),
importLocalDirectory: vi.fn(),
importArchive: vi.fn(),
exportArchive: vi.fn(),
openRepository: vi.fn(),
openModelsDirectory: vi.fn()
}
@@ -355,7 +440,8 @@ describe('SpeechModelSettingsSection', () => {
cancel: vi.fn(async () => true),
remove: vi.fn(),
select,
importLocalDirectory: vi.fn(),
importArchive: vi.fn(),
exportArchive: vi.fn(),
openRepository: vi.fn(),
openModelsDirectory: vi.fn()
}
+50 -24
View File
@@ -6,7 +6,8 @@ import {
FolderOpen,
Mic,
Square,
Trash2
Trash2,
Upload
} from 'lucide-react'
import type { TFunction } from 'i18next'
import { useCallback, useEffect, useRef, useState } from 'react'
@@ -408,24 +409,49 @@ export function SpeechModelSettingsSection({
{t('speech.actions.cancel')}
</button>
) : installed ? (
<button
aria-label={t('speech.accessibility.deleteModel', {
name: displayName
})}
className={
confirmingRemove === entry.id
? 'danger-button'
: 'danger-ghost'
}
disabled={busyModelId === entry.id}
onClick={() => void remove(entry.id)}
type="button"
>
<Trash2 aria-hidden="true" size={12} />
{confirmingRemove === entry.id
? t('speech.actions.confirmDelete')
: t('speech.actions.delete')}
</button>
<>
<button
aria-label={t(
'speech.accessibility.exportModelZip',
{ name: displayName }
)}
className="secondary-button"
disabled={busyModelId === entry.id}
onClick={() =>
void run(
entry.id,
() =>
window.goodbuddy.speechModels!
.exportArchive(entry.id),
t('speech.notifications.exportedZip', {
name: displayName
})
)
}
type="button"
>
<Download aria-hidden="true" size={13} />
{t('speech.actions.exportZip')}
</button>
<button
aria-label={t('speech.accessibility.deleteModel', {
name: displayName
})}
className={
confirmingRemove === entry.id
? 'danger-button'
: 'danger-ghost'
}
disabled={busyModelId === entry.id}
onClick={() => void remove(entry.id)}
type="button"
>
<Trash2 aria-hidden="true" size={12} />
{confirmingRemove === entry.id
? t('speech.actions.confirmDelete')
: t('speech.actions.delete')}
</button>
</>
) : (
<>
{!entry.manualOnly && (
@@ -455,7 +481,7 @@ export function SpeechModelSettingsSection({
</button>
)}
<button
aria-label={t('speech.accessibility.importModel', {
aria-label={t('speech.accessibility.importModelZip', {
name: displayName
})}
className="secondary-button"
@@ -465,16 +491,16 @@ export function SpeechModelSettingsSection({
entry.id,
() =>
window.goodbuddy.speechModels!
.importLocalDirectory(entry.id),
t('speech.notifications.imported', {
.importArchive(entry.id),
t('speech.notifications.importedZip', {
name: displayName
})
)
}
type="button"
>
<FolderOpen aria-hidden="true" size={13} />
{t('speech.actions.import')}
<Upload aria-hidden="true" size={13} />
{t('speech.actions.importZip')}
</button>
</>
)}
+22 -2
View File
@@ -30,6 +30,7 @@ export type PageTab<T extends string> = {
export type SegmentedOption<T extends string> = {
value: T
label: string
disabled?: boolean
}
function nextControlIndex(
@@ -258,11 +259,11 @@ export function SegmentedControl<T extends string>({
? 'segmented-control__option segmented-control__option--active'
: 'segmented-control__option'
}
disabled={disabled}
disabled={disabled || option.disabled}
key={option.value}
onClick={() => onChange(option.value)}
onKeyDown={(event) => {
const nextIndex = nextControlIndex(
let nextIndex = nextControlIndex(
event,
index,
options.length
@@ -270,6 +271,25 @@ export function SegmentedControl<T extends string>({
if (nextIndex === undefined) {
return
}
const direction =
event.key === 'ArrowLeft' ||
event.key === 'ArrowUp' ||
event.key === 'End'
? -1
: 1
for (
let attempts = 0;
attempts < options.length &&
options[nextIndex]?.disabled;
attempts += 1
) {
nextIndex =
(nextIndex + direction + options.length) %
options.length
}
if (options[nextIndex]?.disabled) {
return
}
event.preventDefault()
onChange(options[nextIndex]!.value)
const controls =
+203
View File
@@ -0,0 +1,203 @@
import type {
DocumentOcrFailure,
DocumentOcrRequest,
DocumentOcrResult
} from '../../shared/document-parsing-contracts'
type WorkerOutput =
| { type: 'ready' }
| { type: 'result'; result: DocumentOcrResult }
| { type: 'error'; requestId?: string; error: string }
type PendingWorkerRequest = {
resolve: (result: DocumentOcrResult) => void
reject: (error: Error) => void
timer: number
}
let worker: Worker | undefined
let workerModelId: string | undefined
let workerReady: Promise<void> | undefined
let resolveWorkerReady: (() => void) | undefined
let rejectWorkerReady: ((error: Error) => void) | undefined
const pending = new Map<string, PendingWorkerRequest>()
const cancelledRequestIds = new Set<string>()
let activeRequestId: string | undefined
let requestQueue: Promise<void> = Promise.resolve()
function safeError(error: unknown): string {
return error instanceof Error
? error.message.slice(0, 1_000)
: '本地 OCR 解析失败'
}
function terminateWorker(error: Error): void {
worker?.terminate()
rejectWorkerReady?.(error)
worker = undefined
workerModelId = undefined
workerReady = undefined
resolveWorkerReady = undefined
rejectWorkerReady = undefined
for (const request of pending.values()) {
window.clearTimeout(request.timer)
request.reject(error)
}
pending.clear()
}
async function ensureWorker(modelId: string): Promise<Worker> {
if (worker && workerReady && workerModelId === modelId) {
await workerReady
return worker
}
if (worker) {
terminateWorker(new Error('本地 OCR 模型已切换'))
}
const api = window.goodbuddy.documentParsing
if (!api) {
throw new Error('文档解析服务不可用')
}
worker = new Worker(
new URL('./document-ocr-worker.ts', import.meta.url),
{ type: 'module', name: 'goodbuddy-document-ocr' }
)
workerReady = new Promise<void>((resolve, reject) => {
resolveWorkerReady = resolve
rejectWorkerReady = reject
})
worker.addEventListener(
'message',
(event: MessageEvent<WorkerOutput>) => {
const output = event.data
if (output.type === 'ready') {
resolveWorkerReady?.()
return
}
if (output.type === 'error' && !output.requestId) {
rejectWorkerReady?.(new Error(output.error))
return
}
const requestId =
output.type === 'result'
? output.result.requestId
: output.requestId
if (!requestId) {
return
}
const request = pending.get(requestId)
if (!request) {
return
}
window.clearTimeout(request.timer)
pending.delete(requestId)
if (output.type === 'result') {
request.resolve(output.result)
} else {
request.reject(new Error(output.error))
}
}
)
worker.addEventListener('error', (event) => {
terminateWorker(
new Error(event.message || '本地 OCR Worker 异常')
)
})
try {
const assets = await api.getOcrAssets(modelId)
if (assets.modelId !== modelId) {
throw new Error('本地 OCR 模型与请求不匹配')
}
workerModelId = modelId
worker.postMessage(
{ type: 'initialize', assets },
[
assets.detection,
assets.recognition,
assets.dictionary
]
)
await workerReady
} catch (error) {
terminateWorker(
error instanceof Error ? error : new Error('本地 OCR 初始化失败')
)
throw error
}
return worker
}
async function recognize(
request: DocumentOcrRequest
): Promise<DocumentOcrResult> {
const activeWorker = await ensureWorker(request.modelId)
if (cancelledRequestIds.has(request.requestId)) {
throw new Error('本地 OCR 解析已取消')
}
const pageCount = request.pageNumbers?.length ?? request.maximumPages
const timeoutMs = Math.min(
10 * 60 * 1_000,
Math.max(
request.pageTimeoutSeconds * 1_000,
request.pageTimeoutSeconds * pageCount * 1_000
)
)
return new Promise<DocumentOcrResult>((resolve, reject) => {
const timer = window.setTimeout(() => {
pending.delete(request.requestId)
terminateWorker(new Error('本地 OCR 解析超时'))
reject(new Error('本地 OCR 解析超时'))
}, timeoutMs)
pending.set(request.requestId, { resolve, reject, timer })
activeWorker.postMessage(
{ type: 'recognize', request },
[request.data]
)
})
}
async function handleRequest(request: DocumentOcrRequest): Promise<void> {
const api = window.goodbuddy.documentParsing
if (!api || cancelledRequestIds.has(request.requestId)) {
cancelledRequestIds.delete(request.requestId)
return
}
activeRequestId = request.requestId
try {
await api.respondOcr(await recognize(request))
} catch (error) {
const failure: DocumentOcrFailure = {
requestId: request.requestId,
error: safeError(error)
}
await api.respondOcr(failure).catch(() => undefined)
} finally {
activeRequestId = undefined
cancelledRequestIds.delete(request.requestId)
}
}
export function installDocumentOcrBridge(): () => void {
const api = window.goodbuddy.documentParsing
if (!api) {
return () => undefined
}
const removeRequestListener = api.onOcrRequest((request) => {
requestQueue = requestQueue
.then(() => handleRequest(request))
.catch(() => undefined)
})
const removeCancelListener = api.onOcrCancel((requestId) => {
cancelledRequestIds.add(requestId)
if (activeRequestId === requestId) {
terminateWorker(new Error('本地 OCR 解析已取消'))
}
})
return () => {
removeRequestListener()
removeCancelListener()
cancelledRequestIds.clear()
activeRequestId = undefined
terminateWorker(new Error('本地 OCR 服务已关闭'))
}
}
+233
View File
@@ -0,0 +1,233 @@
/// <reference lib="webworker" />
import * as ort from 'onnxruntime-web'
import wasmModuleUrl from '../../../node_modules/onnxruntime-web/dist/ort-wasm-simd-threaded.mjs?url'
import wasmBinaryUrl from '../../../node_modules/onnxruntime-web/dist/ort-wasm-simd-threaded.wasm?url'
import pdfWorkerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'
import type { PDFPageProxy } from 'pdfjs-dist/types/src/display/api'
import type {
DocumentOcrAssets,
DocumentOcrRequest,
DocumentOcrResult
} from '../../shared/document-parsing-contracts'
type InitializeMessage = {
type: 'initialize'
assets: DocumentOcrAssets
}
type RecognizeMessage = {
type: 'recognize'
request: DocumentOcrRequest
}
type WorkerInput = InitializeMessage | RecognizeMessage
type WorkerOutput =
| { type: 'ready' }
| { type: 'result'; result: DocumentOcrResult }
| { type: 'error'; requestId?: string; error: string }
type OcrService = InstanceType<
typeof import('ppu-paddle-ocr/web').PaddleOcrService
>
const worker = self as DedicatedWorkerGlobalScope
let service: OcrService | undefined
function absoluteAssetUrl(value: string): string {
return new URL(value, worker.location.href).href
}
ort.env.wasm.numThreads = 1
ort.env.wasm.proxy = false
ort.env.wasm.wasmPaths = {
mjs: absoluteAssetUrl(wasmModuleUrl),
wasm: absoluteAssetUrl(wasmBinaryUrl)
}
function safeWorkerError(error: unknown): string {
return error instanceof Error
? error.message.slice(0, 1_000)
: '本地 OCR 识别失败'
}
async function initialize(assets: DocumentOcrAssets): Promise<void> {
await service?.destroy()
const { PaddleOcrService } = await import('ppu-paddle-ocr/web')
service = new PaddleOcrService({
model: {
detection: assets.detection,
recognition: assets.recognition,
charactersDictionary: assets.dictionary
},
session: {
executionProviders: ['wasm'],
graphOptimizationLevel: 'disabled'
},
processing: {
engine: 'canvas-native'
},
recognition: {
charactersDictionary: [],
minimumConfidence: 0.5,
strategy: 'per-line',
recBatchSize: 4
},
detection: {
maxSideLength: 1920
}
})
await service.initialize()
}
async function recognizeImage(
data: ArrayBuffer,
locator: string
): Promise<DocumentOcrResult['sections'][number] | undefined> {
if (!service?.isInitialized()) {
throw new Error('本地 OCR 模型尚未初始化')
}
const result = await service.recognize(data)
const content = result.text.replace(/\n{3,}/gu, '\n\n').trim()
return content
? {
locator,
content,
confidence: result.confidence
}
: undefined
}
async function renderPdfPage(
page: PDFPageProxy
): Promise<ArrayBuffer> {
const baseViewport = page.getViewport({ scale: 1 })
const scale = Math.min(
2,
2200 / Math.max(baseViewport.width, baseViewport.height, 1)
)
const viewport = page.getViewport({ scale })
const canvas = new OffscreenCanvas(
Math.max(1, Math.ceil(viewport.width)),
Math.max(1, Math.ceil(viewport.height))
)
const context = canvas.getContext('2d', {
alpha: false,
willReadFrequently: true
})
if (!context) {
throw new Error('无法创建 PDF 页面渲染画布')
}
await page.render({
canvas: canvas as unknown as HTMLCanvasElement,
canvasContext: context as unknown as CanvasRenderingContext2D,
viewport
}).promise
const blob = await canvas.convertToBlob({
type: 'image/png'
})
return blob.arrayBuffer()
}
async function recognizePdf(
request: DocumentOcrRequest
): Promise<DocumentOcrResult> {
const pdfjs = await import('pdfjs-dist')
pdfjs.GlobalWorkerOptions.workerSrc = pdfWorkerUrl
const loadingTask = pdfjs.getDocument({
data: new Uint8Array(request.data)
})
const document = await loadingTask.promise
const selectedPages = new Set(
request.pageNumbers ??
Array.from(
{ length: Math.min(document.numPages, request.maximumPages) },
(_, index) => index + 1
)
)
if (document.numPages > request.maximumPages) {
await loadingTask.destroy()
throw new Error(
`PDF 共 ${document.numPages} 页,超过 ${request.maximumPages} 页限制`
)
}
const sections: DocumentOcrResult['sections'] = []
const warnings: string[] = []
try {
for (
let pageNumber = 1;
pageNumber <= document.numPages;
pageNumber += 1
) {
if (!selectedPages.has(pageNumber)) {
continue
}
const page = await document.getPage(pageNumber)
try {
const section = await recognizeImage(
await renderPdfPage(page),
`${pageNumber}`
)
if (section) {
sections.push(section)
} else {
warnings.push(`${pageNumber} 页未识别到文字`)
}
} finally {
page.cleanup()
}
}
} finally {
await loadingTask.destroy()
}
return {
requestId: request.requestId,
sections,
pageCount: document.numPages,
warnings
}
}
async function recognize(request: DocumentOcrRequest): Promise<DocumentOcrResult> {
if (request.mimeType === 'application/pdf') {
return recognizePdf(request)
}
const section = await recognizeImage(request.data, '图片')
return {
requestId: request.requestId,
sections: section ? [section] : [],
pageCount: 1,
warnings: section ? [] : ['图片中未识别到文字']
}
}
worker.addEventListener('message', (event: MessageEvent<WorkerInput>) => {
const input = event.data
if (input.type === 'initialize') {
void initialize(input.assets).then(
() => worker.postMessage({ type: 'ready' } satisfies WorkerOutput),
(error: unknown) =>
worker.postMessage({
type: 'error',
error: safeWorkerError(error)
} satisfies WorkerOutput)
)
return
}
void recognize(input.request).then(
(result) =>
worker.postMessage({
type: 'result',
result
} satisfies WorkerOutput),
(error: unknown) =>
worker.postMessage({
type: 'error',
requestId: input.request.requestId,
error: safeWorkerError(error)
} satisfies WorkerOutput)
)
})
export {}
+174 -3
View File
@@ -26,6 +26,12 @@ export const settings = {
navigationDescription: 'LLMs, embedding models, and credentials',
description: 'LLMs, embedding models, and credentials'
},
documentParsing: {
label: 'Document parsing',
navigationDescription: 'Attachments, knowledge, and local OCR',
description:
'Configure extraction, conversion, and OCR for chat attachments and knowledge imports'
},
runtime: {
label: 'Agent Runtime',
navigationDescription: 'OpenCode, Continue, and workspace settings',
@@ -76,6 +82,8 @@ export const settings = {
saveAndTestRuntime: 'Save and test {{runtime}}',
saving: 'Saving…',
saveSettings: 'Save settings',
testParsing: 'Test parsing',
testingParsing: 'Parsing…',
select: 'Select',
selectFile: 'Select file',
clear: 'Clear',
@@ -115,11 +123,18 @@ export const settings = {
openRuntimeConfig: 'Could not open the Runtime configuration',
selectWorkspace: 'Could not select the workspace folder',
retainModelConnection: 'Keep at least one model connection',
clearLocalData: 'Could not clear local data'
clearLocalData: 'Could not clear local data',
documentParsingUnavailable: 'Document parsing is unavailable',
readDocumentParsing: 'Could not load document parsing settings',
saveDocumentParsing: 'Could not save document parsing settings',
testDocumentParsing: 'Document parsing test failed',
manageDocumentOcrModel: 'OCR model operation failed'
},
notifications: {
settingsSaved: 'Settings saved',
connectionSucceeded: 'Connected: {{label}}'
connectionSucceeded: 'Connected: {{label}}',
documentParsingSaved: 'Document parsing settings saved',
documentParsingTestSucceeded: 'Document parsing test completed'
},
credentials: {
none: 'Not configured',
@@ -212,6 +227,162 @@ export const settings = {
'Continue remains unavailable without a configuration file and will not load a remote default model anonymously.'
}
},
documentParsing: {
status: {
title: 'Runtime status',
description: 'Capabilities currently available on this device',
available: 'Available',
unavailable: 'Unavailable',
verified: 'Verified',
native: 'Native document parsing',
nativeDetail:
'Text, HTML, text PDFs, and modern Office documents',
conversion: 'Legacy Office conversion',
conversionUnavailable:
'Not implemented yet; DOC, XLS, and PPT are currently unavailable',
localOcr: 'Local OCR',
ocrReady:
'The model is installed, SHA-256 verified, and available offline',
ocrUnavailable:
'The model is not installed or failed verification. Download it from ModelScope.',
partialNotice:
'Basic document parsing is available. Legacy Office conversion is not implemented yet; scanned PDFs use local OCR.'
},
workflows: {
title: 'Usage scenarios',
description:
'Choose different parsing depth for chat attachments and knowledge imports',
chat: 'Chat attachments',
chatDescription:
'Controls parsing before an attachment is added to the current request',
knowledge: 'Knowledge imports',
knowledgeDescription:
'Controls parsing before chunking, indexing, and source location',
chatOptions: {
auto: 'Automatic parsing (recommended)',
fastText: 'Fast text',
highFidelity: 'High-fidelity parsing'
},
knowledgeOptions: {
completeIndex: 'Complete indexing (recommended)',
fastIndex: 'Fast indexing',
highFidelity: 'High-fidelity indexing'
}
},
ocr: {
title: 'OCR recognition',
description:
'Install a local model on demand to recognize scanned PDFs on this device',
enabled: 'Enable local OCR',
enabledDescription:
'After installation, the model runs only on this device through ONNX Runtime WebAssembly. Documents are not uploaded for recognition.',
model: 'Local model',
runtime: 'Runtime',
provider: {
title: 'OCR source',
description:
'Choose either a local model or a remote service, then save settings to switch.',
local: 'Local model',
remote: 'Remote service (coming soon)',
remoteDescription:
'Remote integrations will support services such as MinerU and PaddleOCR-VL. They are disabled in this version.'
},
modelSelector: 'Current OCR model',
modelSelectorDescription:
'This saved model is used for chat attachments and knowledge imports.',
pendingSelection:
'This model selection is not active yet. Save settings to switch.',
installedOption: 'Installed',
downloadableOption: 'Available to download',
openModelsDirectory: 'Open model folder',
storagePrefix: 'Models are installed on demand in',
storageSuffix:
' and can be exported as ZIP archives for offline devices.',
recommended: 'Recommended',
quality: {
label: 'Quality: {{value}}',
values: {
basic: 'Basic',
balanced: 'Balanced',
high: 'High'
}
},
speed: {
label: 'Speed: {{value}}',
values: {
fast: 'Fast',
balanced: 'Balanced',
slow: 'Slow'
}
},
installed: 'Installed and verified',
availableToDownload: 'Available from ModelScope',
download: 'Download',
importZip: 'Import ZIP',
exportZip: 'Export ZIP',
delete: 'Delete',
confirmDelete: 'Confirm delete',
cancel: 'Cancel',
openRepository: 'Open ModelScope',
catalogUnavailable:
'No OCR model catalog is available in this version.',
mode: 'PDF OCR strategy',
modes: {
auto: 'Automatic; recognize only pages without useful text',
always: 'Always recognize every page',
disabled: 'Use only the PDF text layer'
},
modelLicense:
'The model uses Apache License 2.0 and is SHA-256 verified before loading.',
operations: {
preparing: 'Preparing model files',
downloading: 'Downloading from ModelScope',
importing: 'Importing model ZIP',
installing: 'Verifying and installing'
},
accessibility: {
downloadModel: 'Download {{name}}',
importModelZip: 'Import {{name}} from a ZIP archive',
exportModelZip: 'Export {{name}} as a ZIP archive',
deleteModel: 'Delete {{name}}',
cancelOperation: 'Cancel {{name}} operation',
downloadProgress: '{{name}} download progress',
openRepository: 'Open the ModelScope page for {{name}}'
},
notifications: {
installed: '{{name}} installed',
importedZip: '{{name}} imported from ZIP',
exportedZip: '{{name}} exported as ZIP',
removed: 'OCR model deleted'
}
},
advanced: {
title: 'Advanced parsing settings',
maximumPages: 'Maximum OCR pages per document',
concurrency: 'OCR concurrency',
timeout: 'OCR time budget per page (seconds)',
concurrencyHint:
'The WASM baseline currently processes pages serially; this value is reserved for batching and hardware acceleration.'
},
diagnostic: {
title: 'Parsing test result',
file: 'File',
format: 'Format',
method: 'Method',
pages: 'Pages',
ocrPages: 'OCR pages',
characters: 'Extracted characters',
duration: 'Duration',
preview: 'Text preview',
warnings: 'Warnings',
methods: {
native: 'Native parsing',
ocr: 'Local OCR',
mixed: 'Native parsing and OCR'
},
close: 'Close result'
}
},
model: {
typeAriaLabel: 'Model type',
types: {
@@ -228,7 +399,7 @@ export const settings = {
speech: {
label: 'Speech model',
description:
'Select an installed model and save Settings to apply it; models can be downloaded or imported from a local folder.'
'Select an installed model and save Settings to apply it; models can be downloaded or moved offline with ZIP archives.'
}
},
profile: {
@@ -7,11 +7,11 @@ export const settingsSections = {
speech: {
title: 'Speech models',
description:
'Model weights are not bundled. Download them as needed or import them from a local directory.',
'Model weights are not bundled. Download them as needed or move them offline with ZIP archives.',
openModelsDirectory: 'Open models directory',
storagePrefix: 'Models are stored in',
storageSuffix:
'. Automatic downloads pin the source revision and verify file sizes and SHA-256 hashes. You can also download models from their repositories and import them.',
'. Automatic downloads pin the source revision and verify SHA-256 hashes. Export a ZIP on an online device and import it directly on an offline device.',
availableModels: 'Available speech models',
loading: 'Loading speech models…',
errors: {
@@ -59,7 +59,8 @@ export const settingsSections = {
delete: 'Delete',
confirmDelete: 'Confirm delete',
download: 'Download',
import: 'Import',
importZip: 'Import ZIP',
exportZip: 'Export ZIP',
modelDetails: 'Model details',
openRepository: 'Open model repository'
},
@@ -69,13 +70,15 @@ export const settingsSections = {
cancelOperation: 'Cancel the {{name}} operation',
deleteModel: 'Delete {{name}}',
downloadModel: 'Download {{name}}',
importModel: 'Import {{name}} from a local directory',
importModelZip: 'Import {{name}} from a ZIP archive',
exportModelZip: 'Export {{name}} as a ZIP archive',
downloadProgress: '{{name}} download progress',
openRepository: 'Open the {{name}} model repository'
},
notifications: {
installed: '{{name}} installed',
imported: '{{name}} imported from a local directory',
importedZip: '{{name}} imported from ZIP',
exportedZip: '{{name}} exported as ZIP',
removed: 'Speech model deleted'
},
details: {
+160 -3
View File
@@ -22,6 +22,11 @@ export const settings = {
navigationDescription: 'LLM、向量模型与凭据',
description: 'LLM、向量模型与凭据'
},
documentParsing: {
label: '文档解析',
navigationDescription: '附件、知识库与本地 OCR',
description: '统一配置聊天附件和知识库使用的提取、转换与 OCR 策略'
},
runtime: {
label: 'Agent Runtime',
navigationDescription: 'OpenCode、Continue 与工作区',
@@ -69,6 +74,8 @@ export const settings = {
saveAndTestRuntime: '保存并测试 {{runtime}}',
saving: '保存中…',
saveSettings: '保存设置',
testParsing: '测试解析',
testingParsing: '正在解析…',
select: '选择',
selectFile: '选择文件',
clear: '清除',
@@ -105,11 +112,18 @@ export const settings = {
openRuntimeConfig: '打开 Runtime 配置失败',
selectWorkspace: '选择工作区目录失败',
retainModelConnection: '请至少保留一个模型连接',
clearLocalData: '本地数据清除失败'
clearLocalData: '本地数据清除失败',
documentParsingUnavailable: '文档解析服务不可用',
readDocumentParsing: '读取文档解析设置失败',
saveDocumentParsing: '保存文档解析设置失败',
testDocumentParsing: '测试文档解析失败',
manageDocumentOcrModel: 'OCR 模型操作失败'
},
notifications: {
settingsSaved: '设置已保存',
connectionSucceeded: '连接成功:{{label}}'
connectionSucceeded: '连接成功:{{label}}',
documentParsingSaved: '文档解析设置已保存',
documentParsingTestSucceeded: '文档解析测试完成'
},
credentials: {
none: '尚未配置',
@@ -195,6 +209,149 @@ export const settings = {
'未指定配置文件时 Continue 将保持不可用,不会匿名加载远程默认模型。'
}
},
documentParsing: {
status: {
title: '运行状态',
description: '显示当前设备实际可用的解析能力',
available: '可用',
unavailable: '不可用',
verified: '已校验',
native: '原生文档解析',
nativeDetail: '文本、HTML、文本型 PDF 和新式 Office 文档',
conversion: '旧版 Office 转换',
conversionUnavailable: '尚未实现,DOC、XLS、PPT 暂不可用',
localOcr: '本地 OCR',
ocrReady: '模型已安装并通过 SHA-256 校验,可离线使用',
ocrUnavailable: '模型尚未安装或校验失败,请从 ModelScope 下载',
partialNotice:
'基础文档解析可用。旧版 Office 转换尚未实现;扫描 PDF 使用本地 OCR。'
},
workflows: {
title: '使用场景',
description: '为聊天附件和知识库选择不同的解析深度',
chat: '聊天附件',
chatDescription: '控制附件加入当前请求前的解析方式',
knowledge: '知识库导入',
knowledgeDescription: '控制文档分块、索引和来源定位前的解析方式',
chatOptions: {
auto: '自动解析(推荐)',
fastText: '快速文本',
highFidelity: '高保真解析'
},
knowledgeOptions: {
completeIndex: '完整索引(推荐)',
fastIndex: '快速索引',
highFidelity: '高保真索引'
}
},
ocr: {
title: 'OCR 识别',
description: '按需安装本地模型,在设备上识别扫描 PDF',
enabled: '启用本地 OCR',
enabledDescription:
'模型安装后仅在本机通过 ONNX Runtime WebAssembly 运行,识别时不会上传文档。',
model: '本地模型',
runtime: '运行时',
provider: {
title: 'OCR 来源',
description: '本地模型与远程服务二选一,切换后保存设置生效。',
local: '本地模型',
remote: '远程服务(即将支持)',
remoteDescription:
'远程服务将支持 MinerU、PaddleOCR-VL 等接口,当前版本暂不可选。'
},
modelSelector: '当前 OCR 模型',
modelSelectorDescription: '选择已保存,聊天附件和知识库将使用此模型。',
pendingSelection: '模型选择尚未生效,点击“保存设置”后切换。',
installedOption: '已安装',
downloadableOption: '可下载',
openModelsDirectory: '打开模型目录',
storagePrefix: '模型按需安装到',
storageSuffix: '。可导出 ZIP,并在内网设备直接导入。',
recommended: '推荐',
quality: {
label: '质量:{{value}}',
values: {
basic: '基础',
balanced: '均衡',
high: '高'
}
},
speed: {
label: '速度:{{value}}',
values: {
fast: '快',
balanced: '均衡',
slow: '慢'
}
},
installed: '已安装并校验',
availableToDownload: '可从 ModelScope 下载',
download: '下载',
importZip: '导入 ZIP',
exportZip: '导出 ZIP',
delete: '删除',
confirmDelete: '确认删除',
cancel: '取消',
openRepository: '打开 ModelScope',
catalogUnavailable: '当前版本没有可用的 OCR 模型目录。',
mode: 'PDF OCR 策略',
modes: {
auto: '自动,仅识别无有效文本的页面',
always: '始终识别所有页面',
disabled: '仅使用 PDF 文本层'
},
modelLicense:
'模型采用 Apache License 2.0,并在加载前校验 SHA-256。',
operations: {
preparing: '正在准备模型文件',
downloading: '正在从 ModelScope 下载',
importing: '正在导入模型 ZIP',
installing: '正在校验并安装'
},
accessibility: {
downloadModel: '下载 {{name}}',
importModelZip: '从 ZIP 导入 {{name}}',
exportModelZip: '将 {{name}} 导出为 ZIP',
deleteModel: '删除 {{name}}',
cancelOperation: '取消 {{name}} 操作',
downloadProgress: '{{name}} 下载进度',
openRepository: '打开 {{name}} 的 ModelScope 页面'
},
notifications: {
installed: '{{name}} 已安装',
importedZip: '{{name}} 已从 ZIP 导入',
exportedZip: '{{name}} 已导出为 ZIP',
removed: 'OCR 模型已删除'
}
},
advanced: {
title: '高级解析设置',
maximumPages: '单文档最大 OCR 页数',
concurrency: 'OCR 并发数',
timeout: '每页 OCR 时间预算(秒)',
concurrencyHint:
'当前 WASM 基线按页串行执行;该值为后续批处理和硬件加速保留。'
},
diagnostic: {
title: '解析测试结果',
file: '文件',
format: '格式',
method: '处理方式',
pages: '页数',
ocrPages: 'OCR 页数',
characters: '提取字符',
duration: '耗时',
preview: '文本预览',
warnings: '警告',
methods: {
native: '原生解析',
ocr: '本地 OCR',
mixed: '原生解析与 OCR'
},
close: '关闭结果'
}
},
model: {
typeAriaLabel: '模型类型',
types: {
@@ -209,7 +366,7 @@ export const settings = {
speech: {
label: '语音模型',
description:
'选择已安装模型后保存设置生效;模型可按需下载或从本地目录导入。'
'选择已安装模型后保存设置生效;模型可按需下载或通过 ZIP 离线迁移。'
}
},
profile: {
@@ -1,11 +1,11 @@
export const settingsSections = {
speech: {
title: '语音模型',
description: '应用不内置模型权重,按需下载或从本地目录导入',
description: '应用不内置模型权重,按需下载或通过 ZIP 离线迁移',
openModelsDirectory: '打开模型目录',
storagePrefix: '模型保存在',
storageSuffix:
'。自动下载会固定来源版本并校验文件大小和 SHA-256也可以从模型仓库手动下载后导入。',
'。自动下载会固定来源版本并校验 SHA-256;外网设备可导出 ZIP,内网设备可直接导入。',
availableModels: '可用语音模型',
loading: '正在读取语音模型…',
errors: {
@@ -52,7 +52,8 @@ export const settingsSections = {
delete: '删除',
confirmDelete: '确认删除',
download: '下载',
import: '导入',
importZip: '导入 ZIP',
exportZip: '导出 ZIP',
modelDetails: '模型详情',
openRepository: '打开模型仓库'
},
@@ -62,13 +63,15 @@ export const settingsSections = {
cancelOperation: '取消 {{name}} 操作',
deleteModel: '删除 {{name}}',
downloadModel: '下载 {{name}}',
importModel: '从本地目录导入 {{name}}',
importModelZip: '从 ZIP 导入 {{name}}',
exportModelZip: '将 {{name}} 导出为 ZIP',
downloadProgress: '{{name}}下载进度',
openRepository: '打开 {{name}} 模型仓库'
},
notifications: {
installed: '{{name}} 已安装',
imported: '{{name}} 已从本地目录导入',
importedZip: '{{name}} 已从 ZIP 导入',
exportedZip: '{{name}} 已导出为 ZIP',
removed: '语音模型已删除'
},
details: {
+3
View File
@@ -17,6 +17,7 @@ import {
loadAppearanceTheme,
resolveAppearanceTheme
} from './theme'
import { installDocumentOcrBridge } from './document-ocr-bridge'
import './styles.css'
const root = document.getElementById('root')
@@ -43,6 +44,8 @@ applyAppearanceTheme(
)
)
installDocumentOcrBridge()
createRoot(root).render(
<StrictMode>
<UiLocaleProvider
+4
View File
@@ -13,6 +13,10 @@ export const settingsCategoryList = [
id: 'model',
translationKey: 'model'
},
{
id: 'document-parsing',
translationKey: 'documentParsing'
},
{
id: 'runtime',
translationKey: 'runtime'
+451
View File
@@ -5093,6 +5093,457 @@ details.settings-section > :not(summary) + :not(summary) {
}
}
.document-parsing-status__list {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-3);
}
.document-parsing-status__row {
display: grid;
min-width: 0;
align-items: center;
padding: var(--space-3);
border: 1px solid var(--border-default);
border-radius: var(--radius-card);
background: var(--surface-raised);
grid-template-columns: auto minmax(0, 1fr);
gap: var(--space-2) var(--space-3);
}
.document-parsing-status__row > svg {
color: var(--text-muted);
}
.document-parsing-status__row:has(
.document-parsing-status__badge--available
)
> svg {
color: var(--success);
}
.document-parsing-status__row > span:nth-child(2),
.document-parsing-model > span:first-child {
display: grid;
min-width: 0;
gap: var(--space-1);
}
.document-parsing-status__row strong,
.document-parsing-model strong {
color: var(--text-primary);
font-size: var(--font-body);
}
.document-parsing-status__row small,
.document-parsing-model small {
color: var(--text-muted);
font-size: var(--font-caption);
line-height: 1.5;
}
.document-parsing-status__badge {
width: fit-content;
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-control);
background: var(--surface-muted);
color: var(--text-muted);
font-size: var(--font-caption);
grid-column: 2;
white-space: nowrap;
}
.document-parsing-status__badge--available {
background: var(--success-subtle);
color: var(--success);
}
.document-parsing-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-4);
}
.document-parsing-grid .field > small {
color: var(--text-muted);
font-size: var(--font-caption);
line-height: 1.5;
}
.document-parsing-workflows .field {
padding: var(--space-3);
border: 1px solid var(--border-default);
border-radius: var(--radius-card);
background: var(--surface-raised);
}
.document-ocr-settings .settings-section__title--actions > button,
.document-ocr-model__actions,
.document-ocr-model__actions button,
.document-ocr-model__repository {
display: flex;
align-items: center;
}
.document-ocr-settings .settings-section__title--actions > button,
.document-ocr-model__actions button,
.document-ocr-model__repository {
gap: var(--space-2);
}
.document-ocr-settings__storage {
overflow: hidden;
}
.document-ocr-settings__storage code {
overflow-wrap: anywhere;
}
.document-ocr-provider {
display: grid;
align-items: center;
padding: var(--space-3);
border: 1px solid var(--border-default);
border-radius: var(--radius-card);
background: var(--surface-raised);
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--space-2) var(--space-4);
}
.document-ocr-provider > div:first-child {
display: grid;
gap: var(--space-1);
}
.document-ocr-provider strong {
color: var(--text-primary);
font-size: var(--font-body);
}
.document-ocr-provider small {
color: var(--text-muted);
font-size: var(--font-caption);
line-height: 1.5;
}
.document-ocr-provider > small {
grid-column: 1 / -1;
}
.document-ocr-model-selector {
padding: var(--space-3);
border: 1px solid var(--border-default);
border-radius: var(--radius-card);
background: var(--surface-raised);
}
.document-ocr-model-selector > small {
color: var(--text-muted);
font-size: var(--font-caption);
line-height: 1.5;
}
.document-ocr-model {
display: grid;
min-width: 0;
align-items: start;
padding: var(--space-3);
border: 1px solid var(--border-default);
border-radius: var(--radius-card);
background: var(--surface-raised);
grid-template-columns: minmax(0, 1fr) minmax(150px, auto);
gap: var(--space-2) var(--space-4);
}
.document-ocr-model__summary {
display: grid;
min-width: 0;
gap: var(--space-1);
}
.document-ocr-model__header {
display: flex;
min-width: 0;
align-items: flex-start;
justify-content: space-between;
grid-column: 1 / -1;
gap: var(--space-4);
}
.document-ocr-model__repository {
min-height: 30px;
flex: 0 0 auto;
white-space: nowrap;
}
.document-ocr-model__name,
.document-ocr-model__tags,
.document-ocr-model__status {
display: flex;
align-items: center;
}
.document-ocr-model__name,
.document-ocr-model__tags {
flex-wrap: wrap;
gap: var(--space-2);
}
.document-ocr-model__summary strong {
color: var(--text-primary);
font-size: var(--font-body);
}
.document-ocr-model__summary p {
margin: 0;
color: var(--text-secondary);
font-size: var(--font-caption);
line-height: 1.55;
}
.document-ocr-model__state {
display: flex;
justify-content: flex-start;
}
.document-ocr-model__status {
color: var(--text-muted);
font-size: var(--font-caption);
font-weight: 650;
gap: var(--space-1);
white-space: nowrap;
}
.document-ocr-model__status--installed {
color: var(--success);
}
.document-ocr-model__actions {
justify-content: flex-end;
flex-wrap: wrap;
grid-column: 2;
gap: var(--space-2);
}
.document-ocr-model__actions button {
min-height: 30px;
white-space: nowrap;
}
.document-ocr-model__actions .danger-ghost {
display: flex;
align-items: center;
padding: 0 var(--space-2);
border: 1px solid transparent;
border-radius: var(--radius-control);
background: transparent;
color: var(--danger);
font: inherit;
font-size: var(--font-caption);
gap: var(--space-1);
}
.document-ocr-model__actions .danger-ghost:hover {
border-color: var(--danger-border);
background: var(--danger-subtle);
}
.document-ocr-model__operation {
display: grid;
grid-column: 1 / -1;
gap: var(--space-1);
}
.document-ocr-model__operation progress {
width: 100%;
accent-color: var(--accent-solid);
}
.document-ocr-model__operation small {
color: var(--text-muted);
font-size: var(--font-caption);
}
.document-ocr-settings__options {
display: grid;
align-items: start;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-4);
}
.document-ocr-settings__options > * {
min-height: 100%;
padding: var(--space-3);
border: 1px solid var(--border-default);
border-radius: var(--radius-card);
background: var(--surface-raised);
}
.document-ocr-settings__options .settings-checkbox {
display: grid;
align-items: start;
grid-template-columns: auto minmax(0, 1fr);
gap: var(--space-3);
}
.document-ocr-settings__options .settings-checkbox > input {
width: 16px;
height: 16px;
margin: 2px 0 0;
accent-color: var(--accent-solid);
}
.document-ocr-settings__options .settings-checkbox > span {
display: grid;
gap: var(--space-1);
}
.document-ocr-settings__options .settings-checkbox strong {
color: var(--text-primary);
font-size: var(--font-body);
}
.document-ocr-settings__options .settings-checkbox small {
color: var(--text-muted);
font-size: var(--font-caption);
line-height: 1.5;
}
.document-parsing-diagnostic-backdrop {
position: fixed;
z-index: var(--z-dialog);
display: grid;
padding: var(--space-4);
background: rgb(5 12 24 / 58%);
inset: 0;
place-items: center;
}
.document-parsing-diagnostic {
display: grid;
width: min(620px, 100%);
max-height: min(760px, calc(100vh - 32px));
overflow-y: auto;
padding: var(--space-4);
border: 1px solid var(--border-default);
border-radius: var(--radius-card);
background: var(--surface-raised);
box-shadow: var(--shadow-dialog);
gap: var(--space-4);
}
.document-parsing-diagnostic > header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-4);
}
.document-parsing-diagnostic > header > div {
display: grid;
gap: var(--space-1);
}
.document-parsing-diagnostic > header strong {
color: var(--text-primary);
font-size: var(--font-section-title);
}
.document-parsing-diagnostic > header small {
color: var(--text-muted);
font-size: var(--font-caption);
}
.document-parsing-diagnostic dl {
display: grid;
margin: 0;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-3);
}
.document-parsing-diagnostic dl > div {
display: grid;
padding: var(--space-3);
border-radius: var(--radius-control);
background: var(--surface-subtle);
gap: var(--space-1);
}
.document-parsing-diagnostic dt {
color: var(--text-muted);
font-size: var(--font-caption);
}
.document-parsing-diagnostic dd {
margin: 0;
color: var(--text-primary);
font-size: var(--font-body);
font-weight: 650;
}
.document-parsing-diagnostic__preview {
display: grid;
min-width: 0;
gap: var(--space-2);
}
.document-parsing-diagnostic__preview pre {
max-height: 260px;
overflow: auto;
margin: 0;
padding: var(--space-3);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-control);
background: var(--surface-subtle);
color: var(--text-secondary);
font-family: var(--font-family-mono);
font-size: var(--font-caption);
line-height: 1.55;
white-space: pre-wrap;
word-break: break-word;
}
@media (max-width: 720px) {
.document-parsing-status__list,
.document-parsing-grid,
.document-parsing-diagnostic dl,
.document-ocr-settings__options {
grid-template-columns: minmax(0, 1fr);
}
.document-ocr-settings .settings-section__title--actions {
align-items: flex-start;
flex-wrap: wrap;
}
.document-ocr-provider {
grid-template-columns: minmax(0, 1fr);
}
.document-ocr-provider > small {
grid-column: 1;
}
.document-ocr-model {
grid-template-columns: minmax(0, 1fr);
}
.document-ocr-model__header {
align-items: stretch;
flex-direction: column;
}
.document-ocr-model__repository {
width: fit-content;
}
.document-ocr-model__state,
.document-ocr-model__actions {
justify-content: flex-start;
grid-column: 1;
}
}
.role-prompt-empty {
min-height: 180px;
padding: var(--space-6);