feat: add document OCR and offline model archives
This commit is contained in:
@@ -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
@@ -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()
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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 服务已关闭'))
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -13,6 +13,10 @@ export const settingsCategoryList = [
|
||||
id: 'model',
|
||||
translationKey: 'model'
|
||||
},
|
||||
{
|
||||
id: 'document-parsing',
|
||||
translationKey: 'documentParsing'
|
||||
},
|
||||
{
|
||||
id: 'runtime',
|
||||
translationKey: 'runtime'
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user