dsh-ocr-local 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +31 -0
- package/README.en.md +218 -0
- package/README.md +195 -0
- package/cordis.patch.yml +4 -0
- package/dsh/capability.js +82 -0
- package/dsh/index.js +514 -0
- package/ocr/download_models.py +130 -0
- package/ocr/ocr.py +386 -0
- package/ocr/setup.py +168 -0
- package/package.json +52 -0
package/dsh/index.js
ADDED
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-ocr-local — host half (cordis plugin), Web only.
|
|
3
|
+
*
|
|
4
|
+
* 1. Registers the `ocr_image` tool: local PP-OCRv5 (ONNX Runtime) OCR on an
|
|
5
|
+
* image path, fully offline. Models cached in ~/.dsh-ocr/models.
|
|
6
|
+
* 2. Registers the `ocr_setup` tool: one-command bootstrap (venv + deps +
|
|
7
|
+
* models), so first use is automatic instead of a manual pip dance.
|
|
8
|
+
* 3. Auto-OCR (`autoOcr`): watches `user/message` events for image
|
|
9
|
+
* attachments. The Web composer intakes pasted image files natively, so
|
|
10
|
+
* the attachment is the single entry point — no client-side interception.
|
|
11
|
+
* Only when the routed model is **explicitly** declared as not accepting
|
|
12
|
+
* image input does the plugin save the image to ~/.dsh/ocr/cache and
|
|
13
|
+
* inject its path, so the text-only model can call `ocr_image` on it.
|
|
14
|
+
*
|
|
15
|
+
* - model declares `image` → silent; the vision path handles it, and the
|
|
16
|
+
* harness already gives the model a read-only copy path it may OCR when
|
|
17
|
+
* verbatim text matters.
|
|
18
|
+
* - model declares text only → the harness substitutes an opaque
|
|
19
|
+
* "[image omitted ...]" placeholder with no path, so this plugin is the
|
|
20
|
+
* only way the model can read the image.
|
|
21
|
+
* - cannot be determined → silent (see dsh/capability.js).
|
|
22
|
+
*
|
|
23
|
+
* Loaded via cordis.patch.yml; zero runtime dependencies (node builtins).
|
|
24
|
+
*/
|
|
25
|
+
import { execFile } from 'node:child_process'
|
|
26
|
+
import { createHash } from 'node:crypto'
|
|
27
|
+
import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
28
|
+
import { homedir } from 'node:os'
|
|
29
|
+
import { dirname, join } from 'node:path'
|
|
30
|
+
import { fileURLToPath } from 'node:url'
|
|
31
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
32
|
+
import { autoOcrMode, modalityCacheKey, pickRoute, shouldInjectOcrPath } from './capability.js'
|
|
33
|
+
|
|
34
|
+
export const name = 'dsh-ocr-local'
|
|
35
|
+
export const inject = ['tools', 'agents']
|
|
36
|
+
|
|
37
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
38
|
+
const PLUGIN_ROOT = join(__dirname, '..')
|
|
39
|
+
const OCR_SCRIPT = join(PLUGIN_ROOT, 'ocr', 'ocr.py')
|
|
40
|
+
const SETUP_SCRIPT = join(PLUGIN_ROOT, 'ocr', 'setup.py')
|
|
41
|
+
const OCR_HOME = join(homedir(), '.dsh-ocr')
|
|
42
|
+
const MODELS_DIR = join(OCR_HOME, 'models')
|
|
43
|
+
const VENV_DIR = join(OCR_HOME, 'venv')
|
|
44
|
+
/** 图片缓存目录(内容去重 + 按数量/天数清理)。 */
|
|
45
|
+
const CACHE_DIR = join(homedir(), '.dsh', 'ocr', 'cache')
|
|
46
|
+
|
|
47
|
+
/* ------------------------------------------------------------------ */
|
|
48
|
+
/* 环境解析 */
|
|
49
|
+
/* ------------------------------------------------------------------ */
|
|
50
|
+
|
|
51
|
+
function venvPythonPath() {
|
|
52
|
+
return process.platform === 'win32'
|
|
53
|
+
? join(VENV_DIR, 'Scripts', 'python.exe')
|
|
54
|
+
: join(VENV_DIR, 'bin', 'python')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** python 解析链:config.pythonPath → DSH_OCR_PYTHON → 内置 venv → python3 → python */
|
|
58
|
+
function resolvePython(config = {}) {
|
|
59
|
+
const candidates = [
|
|
60
|
+
config.pythonPath,
|
|
61
|
+
process.env.DSH_OCR_PYTHON,
|
|
62
|
+
venvPythonPath(),
|
|
63
|
+
process.platform === 'win32' ? 'python.exe' : 'python3',
|
|
64
|
+
'python',
|
|
65
|
+
].filter(Boolean)
|
|
66
|
+
for (const c of candidates) {
|
|
67
|
+
if (!/[/\\]/.test(c) || existsSync(c)) return c
|
|
68
|
+
}
|
|
69
|
+
return candidates[0]
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/* ------------------------------------------------------------------ */
|
|
73
|
+
/* OCR 执行与诊断 */
|
|
74
|
+
/* ------------------------------------------------------------------ */
|
|
75
|
+
|
|
76
|
+
function runDoctor(config = {}) {
|
|
77
|
+
return new Promise(resolve => {
|
|
78
|
+
const python = resolvePython(config)
|
|
79
|
+
execFile(
|
|
80
|
+
python,
|
|
81
|
+
['-X', 'utf8', OCR_SCRIPT, '--doctor', ...(config.modelDir ? ['--model-dir', config.modelDir] : [])],
|
|
82
|
+
{ encoding: 'utf8', windowsHide: true, timeout: 30000 },
|
|
83
|
+
(error, stdout) => {
|
|
84
|
+
if (error) {
|
|
85
|
+
resolve({ ok: false, python: { ok: false, error: 'python 不可用:' + String(error.message || error).slice(0, 120) } })
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
resolve(JSON.parse(stdout))
|
|
90
|
+
} catch {
|
|
91
|
+
resolve({ ok: false, python: { ok: true, error: 'doctor 输出无法解析' } })
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
)
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function runOcr(path, config = {}) {
|
|
99
|
+
return new Promise(resolve => {
|
|
100
|
+
const python = resolvePython(config)
|
|
101
|
+
const args = [OCR_SCRIPT, path, '--full', ...(config.modelDir ? ['--model-dir', config.modelDir] : [])]
|
|
102
|
+
execFile(
|
|
103
|
+
python,
|
|
104
|
+
['-X', 'utf8', ...args],
|
|
105
|
+
{ encoding: 'utf8', windowsHide: true, timeout: 120000, maxBuffer: 32 * 1024 * 1024 },
|
|
106
|
+
(error, stdout) => {
|
|
107
|
+
if (error) {
|
|
108
|
+
let pyErr = null
|
|
109
|
+
try {
|
|
110
|
+
pyErr = JSON.parse(stdout.trim())
|
|
111
|
+
} catch { /* stdout 不是 JSON */ }
|
|
112
|
+
const reason = pyErr && pyErr.error ? pyErr.error : String(error.message || error).slice(0, 300)
|
|
113
|
+
runDoctor(config).then(doctor => resolve({ text: '', path, error: reason, doctor }))
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
let data = null
|
|
117
|
+
try {
|
|
118
|
+
data = JSON.parse(stdout)
|
|
119
|
+
} catch { /* ignore */ }
|
|
120
|
+
if (!data || !Array.isArray(data.lines)) {
|
|
121
|
+
resolve({ text: '', path, error: 'OCR 输出无法解析' })
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
resolve({
|
|
125
|
+
text: data.lines.map(l => l.text).join('\n'),
|
|
126
|
+
lines: data.lines,
|
|
127
|
+
blocks: data.blocks || [],
|
|
128
|
+
path,
|
|
129
|
+
engine: 'ppocrv5',
|
|
130
|
+
})
|
|
131
|
+
},
|
|
132
|
+
)
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/* ------------------------------------------------------------------ */
|
|
137
|
+
/* 渲染 */
|
|
138
|
+
/* ------------------------------------------------------------------ */
|
|
139
|
+
|
|
140
|
+
function missingNames(doctor) {
|
|
141
|
+
const missing = []
|
|
142
|
+
if (doctor.python && doctor.python.ok === false) missing.push('python')
|
|
143
|
+
for (const [k, v] of Object.entries(doctor.dependencies || {})) if (!v.ok) missing.push(k)
|
|
144
|
+
for (const [k, v] of Object.entries(doctor.models || {})) {
|
|
145
|
+
if (!v.present) missing.push(k)
|
|
146
|
+
else if (v.sha256_ok === false) missing.push(`${k}(损坏)`)
|
|
147
|
+
}
|
|
148
|
+
return missing
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function renderText(value) {
|
|
152
|
+
if (value.error) {
|
|
153
|
+
const doc = value.doctor
|
|
154
|
+
let hint
|
|
155
|
+
if (doc && doc.ok === false) {
|
|
156
|
+
const missing = missingNames(doc).join('、')
|
|
157
|
+
hint = `环境未就绪,缺少: ${missing}。\n调用 ocr_setup 工具可一键安装(建 venv + 装依赖 + 下模型),或手动运行:\n ${SETUP_SCRIPT}`
|
|
158
|
+
} else {
|
|
159
|
+
hint = `可调用 ocr_setup 工具检查/安装环境。`
|
|
160
|
+
}
|
|
161
|
+
return `[dsh-ocr] ${value.error}\n${hint}`
|
|
162
|
+
}
|
|
163
|
+
const head = `图片识别结果(${value.path}):`
|
|
164
|
+
const lines = value.lines || []
|
|
165
|
+
const body = lines.map(l => l.text).join('\n').trim()
|
|
166
|
+
const low = lines.filter(l => l.low_confidence)
|
|
167
|
+
let tail = ''
|
|
168
|
+
if (low.length) {
|
|
169
|
+
const names = low.map(l => `「${l.text.slice(0, 10)}」(字高${l.font_px ?? '?'}px/置信${Math.round((l.confidence ?? 0) * 100)}%)`).join('、')
|
|
170
|
+
tail = `\n\n⚠ 以下 ${low.length} 行字太小或检测置信度低,可能有误: ${names}`
|
|
171
|
+
}
|
|
172
|
+
return body ? `${head}\n${body}${tail}` : `${head}\n(未识别到文字)`
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function renderSetup(value) {
|
|
176
|
+
if (value.error) return `[dsh-ocr] 安装失败: ${value.error}`
|
|
177
|
+
if (value.checkOnly) return `[dsh-ocr] ${value.ok ? '环境就绪 ✓' : '环境未就绪 ✗'}`
|
|
178
|
+
const steps = Object.entries(value.steps || {})
|
|
179
|
+
.map(([k, v]) => ` - ${k}: ${v}`)
|
|
180
|
+
.join('\n')
|
|
181
|
+
return `[dsh-ocr] 安装${value.ok ? '完成 ✓' : '未完成 ✗'}\n${steps}`
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/* ------------------------------------------------------------------ */
|
|
185
|
+
/* 图片缓存:去重 + 类型感知命名 + 清理 */
|
|
186
|
+
/* ------------------------------------------------------------------ */
|
|
187
|
+
|
|
188
|
+
/** 附件媒体类型 → 落盘扩展名。 */
|
|
189
|
+
const IMAGE_EXT = {
|
|
190
|
+
'image/png': '.png',
|
|
191
|
+
'image/jpeg': '.jpg',
|
|
192
|
+
'image/webp': '.webp',
|
|
193
|
+
'image/gif': '.gif',
|
|
194
|
+
'image/bmp': '.bmp',
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** timestamped cache filename: yyyyMMdd-HHmmss.fffffff-<hash8><ext> */
|
|
198
|
+
function pasteName(ext, hash, now = new Date()) {
|
|
199
|
+
const p = (n, w) => String(n).padStart(w, '0')
|
|
200
|
+
const base = `${now.getFullYear()}${p(now.getMonth() + 1, 2)}${p(now.getDate(), 2)}-` +
|
|
201
|
+
`${p(now.getHours(), 2)}${p(now.getMinutes(), 2)}${p(now.getSeconds(), 2)}.` +
|
|
202
|
+
`${p(now.getMilliseconds() * 10000, 7)}-${hash}`
|
|
203
|
+
return `${base}${ext}`
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** 按内容哈希查重:返回已存在的相同图片路径 */
|
|
207
|
+
function findByHashIn(hash, dir) {
|
|
208
|
+
let names
|
|
209
|
+
try {
|
|
210
|
+
names = readdirSync(dir)
|
|
211
|
+
} catch {
|
|
212
|
+
return null
|
|
213
|
+
}
|
|
214
|
+
for (const n of names) {
|
|
215
|
+
if (n.includes(`-${hash}.`)) {
|
|
216
|
+
const p = join(dir, n)
|
|
217
|
+
if (existsSync(p)) return p
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return null
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function pruneCacheIn(dir, maxFiles, maxAgeDays) {
|
|
224
|
+
if (maxFiles <= 0 && maxAgeDays <= 0) return
|
|
225
|
+
let entries
|
|
226
|
+
try {
|
|
227
|
+
entries = readdirSync(dir)
|
|
228
|
+
.map(n => {
|
|
229
|
+
try {
|
|
230
|
+
const s = statSync(join(dir, n))
|
|
231
|
+
return s.isFile() ? { n, m: s.mtimeMs } : null
|
|
232
|
+
} catch {
|
|
233
|
+
return null
|
|
234
|
+
}
|
|
235
|
+
})
|
|
236
|
+
.filter(Boolean)
|
|
237
|
+
} catch {
|
|
238
|
+
return
|
|
239
|
+
}
|
|
240
|
+
const now = Date.now()
|
|
241
|
+
if (maxAgeDays > 0) {
|
|
242
|
+
for (const e of entries) {
|
|
243
|
+
if (now - e.m > maxAgeDays * 864e5) {
|
|
244
|
+
try {
|
|
245
|
+
unlinkSync(join(dir, e.n))
|
|
246
|
+
} catch { /* ignore */ }
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (maxFiles > 0) {
|
|
251
|
+
const remaining = readdirSync(dir).length
|
|
252
|
+
const excess = remaining - maxFiles
|
|
253
|
+
if (excess > 0) {
|
|
254
|
+
const alive = entries
|
|
255
|
+
.sort((a, b) => a.m - b.m)
|
|
256
|
+
.slice(0, excess)
|
|
257
|
+
for (const e of alive) {
|
|
258
|
+
try {
|
|
259
|
+
unlinkSync(join(dir, e.n))
|
|
260
|
+
} catch { /* ignore */ }
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** 保存图片字节到缓存目录(内容去重 + 类型命名 + 清理)。返回路径。 */
|
|
267
|
+
function saveImageToCache(buffer, mediaType, opts = {}) {
|
|
268
|
+
const dir = opts.cacheDir || CACHE_DIR
|
|
269
|
+
const maxFiles = Number(opts.maxFiles ?? 300)
|
|
270
|
+
const maxAgeDays = Number(opts.maxAgeDays ?? 30)
|
|
271
|
+
const hash = createHash('sha1').update(buffer).digest('hex').slice(0, 8)
|
|
272
|
+
const existing = findByHashIn(hash, dir)
|
|
273
|
+
if (existing) return { path: existing, deduped: true }
|
|
274
|
+
mkdirSync(dir, { recursive: true })
|
|
275
|
+
const ext = IMAGE_EXT[mediaType] || '.png'
|
|
276
|
+
const target = join(dir, pasteName(ext, hash))
|
|
277
|
+
writeFileSync(target, buffer)
|
|
278
|
+
pruneCacheIn(dir, maxFiles, maxAgeDays)
|
|
279
|
+
return { path: target, deduped: false }
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* 读取一个**可选**服务,不写进 inject 契约。
|
|
284
|
+
*
|
|
285
|
+
* `ctx.reflect.get(name)` 默认 strict:只返回「提供该服务的 fiber 当前处于活动
|
|
286
|
+
* 状态」的实现。这里再退一步用 strict=false 兜底,避免因为作用域判定而让插件
|
|
287
|
+
* 永久静默——静默本身是本插件的正常行为,但它必须来自「模型能力判定」,
|
|
288
|
+
* 而不是来自「我们拿不到服务」。
|
|
289
|
+
*
|
|
290
|
+
* @param ctx - cordis 上下文。
|
|
291
|
+
* @param name - 服务名。
|
|
292
|
+
* @returns 服务实例,取不到时 undefined。
|
|
293
|
+
*/
|
|
294
|
+
function optionalService(ctx, name) {
|
|
295
|
+
const reflect = ctx?.reflect
|
|
296
|
+
if (!reflect || typeof reflect.get !== 'function') return undefined
|
|
297
|
+
try {
|
|
298
|
+
return reflect.get(name) ?? reflect.get(name, false)
|
|
299
|
+
} catch {
|
|
300
|
+
return undefined
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Auto-OCR: 监听 user/message 里的图片附件,**只在路由到的模型明确不支持
|
|
306
|
+
* 图片输入时**把图片存到 ~/.dsh/ocr/cache 并把路径注入 agent 上下文,让该模型
|
|
307
|
+
* 调 ocr_image 本地识别。
|
|
308
|
+
*
|
|
309
|
+
* Web 端粘贴的图片由 composer 原生收进附件流程,插件不做任何客户端拦截。
|
|
310
|
+
* 视觉模型下插件完全静默:harness 自己会在图片前附带只读副本路径,模型需要
|
|
311
|
+
* 逐字核对时可直接调 ocr_image。判定逻辑见 dsh/capability.js。
|
|
312
|
+
*/
|
|
313
|
+
function registerAutoOcr(ctx, config = {}) {
|
|
314
|
+
const mode = autoOcrMode(config)
|
|
315
|
+
if (mode === 'off') return
|
|
316
|
+
const maxFiles = Number(config.maxCacheFiles ?? 300)
|
|
317
|
+
const maxAgeDays = Number(config.maxCacheAgeDays ?? 30)
|
|
318
|
+
// 已处理过的附件引用(防事件重放重复注入);有界缓存。
|
|
319
|
+
const seen = new Set()
|
|
320
|
+
// session.id → 用户刚切换、尚未发请求的模型选择(来源优先级见 pickRoute)。
|
|
321
|
+
const pendingRoutes = new Map()
|
|
322
|
+
// `${provider}\0${model}` → inputModalities 数组 | null(查询失败)。
|
|
323
|
+
const modalityCache = new Map()
|
|
324
|
+
|
|
325
|
+
/** 解析某个 provider/model 声明的输入模态;失败或服务缺失返回 undefined。 */
|
|
326
|
+
async function resolveModalities(route) {
|
|
327
|
+
if (!route) return undefined
|
|
328
|
+
const key = modalityCacheKey(route.provider, route.model)
|
|
329
|
+
if (modalityCache.has(key)) return modalityCache.get(key) ?? undefined
|
|
330
|
+
let modalities
|
|
331
|
+
try {
|
|
332
|
+
const llm = optionalService(ctx, 'llm')
|
|
333
|
+
if (llm && typeof llm.resolveModelInfo === 'function') {
|
|
334
|
+
const info = await llm.resolveModelInfo(route.provider, route.model)
|
|
335
|
+
if (Array.isArray(info?.inputModalities) && info.inputModalities.length > 0) {
|
|
336
|
+
modalities = [...info.inputModalities]
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
} catch { /* 未注册 provider / 未知模型 / 适配器报错 → 视为无法确定 */ }
|
|
340
|
+
modalityCache.set(key, modalities ?? null)
|
|
341
|
+
return modalities
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
ctx.on('session/event', async (session, event) => {
|
|
345
|
+
// 用户在客户端切换模型 → 记下待生效路由,供下一次判定优先采用。
|
|
346
|
+
if (event.type === 'model/selection') {
|
|
347
|
+
const route = pickRoute({ pending: event.data })
|
|
348
|
+
if (route) pendingRoutes.set(session.id, route)
|
|
349
|
+
return
|
|
350
|
+
}
|
|
351
|
+
if (event.type !== 'user/message') return
|
|
352
|
+
const content = Array.isArray(event.data?.content) ? event.data.content : []
|
|
353
|
+
const refs = content
|
|
354
|
+
.filter(b => b && b.type === 'image' && b.attachment)
|
|
355
|
+
.map(b => b.attachment)
|
|
356
|
+
if (refs.length === 0) return
|
|
357
|
+
|
|
358
|
+
const agent = ctx.agents?.get?.(session.id) || ctx.agents?.list?.().find(a => a.session?.id === session.id)
|
|
359
|
+
if (!agent || typeof agent.inject !== 'function') return
|
|
360
|
+
|
|
361
|
+
// 'always' 模式无条件介入,不必查询模型能力。
|
|
362
|
+
if (mode !== 'always') {
|
|
363
|
+
let header
|
|
364
|
+
try {
|
|
365
|
+
header = session.requestHeader?.()?.config
|
|
366
|
+
} catch { /* 无请求头则退回其它来源 */ }
|
|
367
|
+
const route = pickRoute({ pending: pendingRoutes.get(session.id), header, options: agent.options })
|
|
368
|
+
const modalities = await resolveModalities(route)
|
|
369
|
+
if (!shouldInjectOcrPath(mode, modalities)) return
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const attachments = optionalService(ctx, 'attachments')
|
|
373
|
+
if (!attachments || typeof attachments.readImage !== 'function') return
|
|
374
|
+
const paths = []
|
|
375
|
+
for (const ref of refs) {
|
|
376
|
+
// 稳定标识是 attachmentId(sha256:…);ref.id 只是兼容兜底。
|
|
377
|
+
const refKey = ref.attachmentId ?? ref.id
|
|
378
|
+
if (refKey && seen.has(refKey)) continue
|
|
379
|
+
try {
|
|
380
|
+
const stored = await attachments.readImage(ref)
|
|
381
|
+
const bytes = Buffer.from(stored.data ?? stored)
|
|
382
|
+
if (bytes.length === 0) continue
|
|
383
|
+
const saved = saveImageToCache(bytes, ref.mediaType || 'image/png', { maxFiles, maxAgeDays })
|
|
384
|
+
if (refKey) seen.add(refKey)
|
|
385
|
+
paths.push(saved.path)
|
|
386
|
+
} catch { /* 附件读取失败则跳过,不影响其它图片 */ }
|
|
387
|
+
}
|
|
388
|
+
if (seen.size > 5000) seen.clear()
|
|
389
|
+
if (paths.length === 0) return
|
|
390
|
+
agent.inject({
|
|
391
|
+
role: 'user',
|
|
392
|
+
content: [{
|
|
393
|
+
type: 'text',
|
|
394
|
+
text: '用户附上了图片,当前模型不支持图片输入,图片已保存到本地缓存。'
|
|
395
|
+
+ '请用 ocr_image 工具读取其中的文字:\n' + paths.join('\n'),
|
|
396
|
+
}],
|
|
397
|
+
source: {
|
|
398
|
+
kind: 'plugin',
|
|
399
|
+
plugin: 'dsh-ocr-local',
|
|
400
|
+
form: 'notice',
|
|
401
|
+
summary: `已保存 ${paths.length} 张图片到本地 OCR 缓存`,
|
|
402
|
+
},
|
|
403
|
+
})
|
|
404
|
+
})
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/* ------------------------------------------------------------------ */
|
|
408
|
+
/* 工具注册 */
|
|
409
|
+
/* ------------------------------------------------------------------ */
|
|
410
|
+
|
|
411
|
+
function runSetup(python, argv) {
|
|
412
|
+
return new Promise(resolve => {
|
|
413
|
+
execFile(
|
|
414
|
+
python,
|
|
415
|
+
['-X', 'utf8', ...argv],
|
|
416
|
+
{ encoding: 'utf8', windowsHide: true, timeout: 900000, maxBuffer: 16 * 1024 * 1024 },
|
|
417
|
+
(error, stdout, stderr) => {
|
|
418
|
+
if (error) {
|
|
419
|
+
let data = null
|
|
420
|
+
try {
|
|
421
|
+
data = JSON.parse(stdout.trim())
|
|
422
|
+
} catch { /* ignore */ }
|
|
423
|
+
resolve(data || { ok: false, error: (data?.error) || String(error.message || error).slice(0, 300) + (stderr ? ' / ' + stderr.slice(-200) : '') })
|
|
424
|
+
return
|
|
425
|
+
}
|
|
426
|
+
try {
|
|
427
|
+
resolve(JSON.parse(stdout.trim()))
|
|
428
|
+
} catch {
|
|
429
|
+
resolve({ ok: false, error: 'setup 输出无法解析' })
|
|
430
|
+
}
|
|
431
|
+
},
|
|
432
|
+
)
|
|
433
|
+
})
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
export function apply(ctx, config = {}) {
|
|
437
|
+
registerAutoOcr(ctx, config)
|
|
438
|
+
|
|
439
|
+
ctx.tools.register(defineTool({
|
|
440
|
+
name: 'ocr_image',
|
|
441
|
+
description:
|
|
442
|
+
'Run local OCR (PP-OCRv5, fully offline) on an image file and return its text content. ' +
|
|
443
|
+
'Use it when the user references an image file and you need the text in it — no vision model ' +
|
|
444
|
+
'required. With a model that accepts image input the harness also sends the image itself plus a ' +
|
|
445
|
+
'read-only copy path; call this tool on that path when you need verbatim characters (code, error ' +
|
|
446
|
+
'messages, logs, table numbers) or per-line confidence instead of a visual reading. ' +
|
|
447
|
+
'Returns the recognized text lines with per-line confidence, or a diagnosis (plus a hint to run ' +
|
|
448
|
+
'the ocr_setup tool) when the OCR engine is not installed yet.',
|
|
449
|
+
parameters: {
|
|
450
|
+
path: {
|
|
451
|
+
type: 'string',
|
|
452
|
+
required: true,
|
|
453
|
+
description: 'Absolute path of the image file (png/jpg/webp).',
|
|
454
|
+
},
|
|
455
|
+
full: {
|
|
456
|
+
type: 'boolean',
|
|
457
|
+
description: 'Return structured JSON (lines + blocks with confidence and box coordinates) instead of plain text.',
|
|
458
|
+
},
|
|
459
|
+
},
|
|
460
|
+
output: {
|
|
461
|
+
schema: { type: 'object', additionalProperties: true },
|
|
462
|
+
render: (_args, value) => [{ type: 'text', text: renderText(value) }],
|
|
463
|
+
},
|
|
464
|
+
execute: async args => {
|
|
465
|
+
const path = String(args.path ?? '').trim()
|
|
466
|
+
if (!path) return { text: '', error: '缺少 path 参数(图片文件路径)', path: '' }
|
|
467
|
+
if (!existsSync(path)) {
|
|
468
|
+
return { text: '', error: `图片文件不存在:${path}`, path }
|
|
469
|
+
}
|
|
470
|
+
const result = await runOcr(path, config)
|
|
471
|
+
if (args.full) result.full = { lines: result.lines || [], blocks: result.blocks || [] }
|
|
472
|
+
return result
|
|
473
|
+
},
|
|
474
|
+
timeoutMs: 120000,
|
|
475
|
+
}))
|
|
476
|
+
|
|
477
|
+
ctx.tools.register(defineTool({
|
|
478
|
+
name: 'ocr_setup',
|
|
479
|
+
description:
|
|
480
|
+
'Install or verify the local OCR engine (creates a venv, installs onnxruntime/numpy/opencv, ' +
|
|
481
|
+
'downloads the PP-OCRv5 models with sha256 verification). Use when ocr_image reports the engine ' +
|
|
482
|
+
'is not ready. Idempotent — safe to run repeatedly. Supports a mirror via DSH_OCR_MODELS_MIRROR.',
|
|
483
|
+
parameters: {
|
|
484
|
+
checkOnly: {
|
|
485
|
+
type: 'boolean',
|
|
486
|
+
description: 'Only check readiness (python + deps + models), do not install anything.',
|
|
487
|
+
},
|
|
488
|
+
noModels: {
|
|
489
|
+
type: 'boolean',
|
|
490
|
+
description: 'Install dependencies only, skip model download.',
|
|
491
|
+
},
|
|
492
|
+
force: {
|
|
493
|
+
type: 'boolean',
|
|
494
|
+
description: 'Force reinstall dependencies even if imports succeed.',
|
|
495
|
+
},
|
|
496
|
+
},
|
|
497
|
+
output: {
|
|
498
|
+
schema: { type: 'object', additionalProperties: true },
|
|
499
|
+
render: (_args, value) => [{ type: 'text', text: renderSetup(value) }],
|
|
500
|
+
},
|
|
501
|
+
execute: async args => {
|
|
502
|
+
const python = resolvePython(config)
|
|
503
|
+
const argv = [SETUP_SCRIPT, '--json']
|
|
504
|
+
if (args.checkOnly) argv.push('--check')
|
|
505
|
+
if (args.noModels) argv.push('--no-models')
|
|
506
|
+
if (args.force) argv.push('--force')
|
|
507
|
+
if (config.modelDir) argv.push('--model-dir', config.modelDir)
|
|
508
|
+
const result = await runSetup(python, argv)
|
|
509
|
+
result.checkOnly = Boolean(args.checkOnly)
|
|
510
|
+
return result
|
|
511
|
+
},
|
|
512
|
+
timeoutMs: 900000,
|
|
513
|
+
}))
|
|
514
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
下载 PP-OCRv5 ONNX 模型与字典到缓存目录(默认 ~/.dsh-ocr/models)。
|
|
5
|
+
|
|
6
|
+
特点:
|
|
7
|
+
- sha256 校验:已存在且校验通过则跳过;损坏/不完整文件自动删除重下
|
|
8
|
+
- 镜像支持:DSH_OCR_MODELS_MIRROR 指定镜像前缀(ghproxy 风格,直接拼在原 URL 前)
|
|
9
|
+
- 原子写入:先写 .part 再 rename,中断不会留下半截"可用"文件
|
|
10
|
+
- 自动重试:单文件最多 3 次
|
|
11
|
+
|
|
12
|
+
用法:
|
|
13
|
+
python download_models.py # 下载到 ~/.dsh-ocr/models
|
|
14
|
+
python download_models.py --model-dir D # 自定义目录
|
|
15
|
+
DSH_OCR_MODELS_MIRROR=https://ghproxy.com/ python download_models.py
|
|
16
|
+
|
|
17
|
+
模型来源:paddleocr-onnx 社区发布(PaddleOCR v5 官方模型导出),Apache-2.0 许可。
|
|
18
|
+
"""
|
|
19
|
+
import argparse
|
|
20
|
+
import hashlib
|
|
21
|
+
import os
|
|
22
|
+
import sys
|
|
23
|
+
import time
|
|
24
|
+
import urllib.request
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
BASE = "https://github.com/MeKo-Christian/paddleocr-onnx/releases/download/v1.0.0"
|
|
28
|
+
DICT_URL = "https://raw.githubusercontent.com/PaddlePaddle/PaddleOCR/main/ppocr/utils/dict/ppocrv5_dict.txt"
|
|
29
|
+
|
|
30
|
+
# 文件 -> (默认 URL, sha256)。镜像通过环境变量 DSH_OCR_MODELS_MIRROR 注入。
|
|
31
|
+
MANIFEST = {
|
|
32
|
+
"PP-OCRv5_mobile_det.onnx": (
|
|
33
|
+
f"{BASE}/PP-OCRv5_mobile_det.onnx",
|
|
34
|
+
"ca3014670099126189c9519ef770470c03bf41695fb138c6bc19737bd4ba2875",
|
|
35
|
+
),
|
|
36
|
+
"PP-OCRv5_mobile_rec.onnx": (
|
|
37
|
+
f"{BASE}/PP-OCRv5_mobile_rec.onnx",
|
|
38
|
+
"64ea1b54ea0506609378a3638ff5b2547af7e24809b890e501fb0cce54de21f7",
|
|
39
|
+
),
|
|
40
|
+
"ppocrv5_dict.txt": (
|
|
41
|
+
DICT_URL,
|
|
42
|
+
"d1979e9f794c464c0d2e0b70a7fe14dd978e9dc644c0e71f14158cdf8342af1b",
|
|
43
|
+
),
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
MODELS_DIR = Path(os.environ.get("DSH_OCR_MODELS") or Path.home() / ".dsh-ocr" / "models")
|
|
47
|
+
RETRIES = 3
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def sha256_of(path: Path) -> str:
|
|
51
|
+
h = hashlib.sha256()
|
|
52
|
+
with open(path, "rb") as f:
|
|
53
|
+
for chunk in iter(lambda: f.read(256 * 1024), b""):
|
|
54
|
+
h.update(chunk)
|
|
55
|
+
return h.hexdigest()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def effective_url(url: str) -> str:
|
|
59
|
+
mirror = os.environ.get("DSH_OCR_MODELS_MIRROR", "").strip().rstrip("/")
|
|
60
|
+
return f"{mirror}/{url}" if mirror else url
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def download(url: str, dest: Path, expected_sha: str) -> bool:
|
|
64
|
+
if dest.exists():
|
|
65
|
+
try:
|
|
66
|
+
if dest.stat().st_size > 0 and sha256_of(dest) == expected_sha:
|
|
67
|
+
print(f" 已存在且校验通过,跳过: {dest.name}")
|
|
68
|
+
return True
|
|
69
|
+
print(f" 文件损坏(sha256 不匹配),重新下载: {dest.name}")
|
|
70
|
+
dest.unlink()
|
|
71
|
+
except OSError as e:
|
|
72
|
+
print(f" 校验失败: {dest.name}: {e}", file=sys.stderr)
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
url = effective_url(url)
|
|
76
|
+
for attempt in range(1, RETRIES + 1):
|
|
77
|
+
tmp = dest.with_suffix(dest.suffix + f".part{attempt}")
|
|
78
|
+
try:
|
|
79
|
+
print(f" 下载 {dest.name} (尝试 {attempt}/{RETRIES}) ...")
|
|
80
|
+
req = urllib.request.Request(url, headers={"User-Agent": "dsh-ocr-local/0.3.3"})
|
|
81
|
+
with urllib.request.urlopen(req, timeout=120) as resp, open(tmp, "wb") as f:
|
|
82
|
+
total = int(resp.headers.get("Content-Length") or 0)
|
|
83
|
+
done = 0
|
|
84
|
+
while True:
|
|
85
|
+
chunk = resp.read(256 * 1024)
|
|
86
|
+
if not chunk:
|
|
87
|
+
break
|
|
88
|
+
f.write(chunk)
|
|
89
|
+
done += len(chunk)
|
|
90
|
+
if total:
|
|
91
|
+
pct = done * 100 // total
|
|
92
|
+
sys.stdout.write(f"\r {pct}% ({done // 1024 // 1024}MB/{total // 1024 // 1024}MB)")
|
|
93
|
+
sys.stdout.flush()
|
|
94
|
+
sys.stdout.write("\n")
|
|
95
|
+
actual = sha256_of(tmp)
|
|
96
|
+
if actual != expected_sha:
|
|
97
|
+
print(f" sha256 不匹配(期望 {expected_sha[:12]}…,实际 {actual[:12]}…),重试", file=sys.stderr)
|
|
98
|
+
tmp.unlink(missing_ok=True)
|
|
99
|
+
continue
|
|
100
|
+
tmp.rename(dest)
|
|
101
|
+
print(f" 完成: {dest.name} ({dest.stat().st_size} 字节)")
|
|
102
|
+
return True
|
|
103
|
+
except Exception as e:
|
|
104
|
+
tmp.unlink(missing_ok=True)
|
|
105
|
+
print(f" 失败: {dest.name}: {e}", file=sys.stderr)
|
|
106
|
+
if attempt < RETRIES:
|
|
107
|
+
time.sleep(1)
|
|
108
|
+
return False
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def main() -> int:
|
|
112
|
+
ap = argparse.ArgumentParser(description="下载 PP-OCRv5 ONNX 模型(含 sha256 校验)")
|
|
113
|
+
ap.add_argument("--model-dir", default=str(MODELS_DIR), help="模型缓存目录")
|
|
114
|
+
args = ap.parse_args()
|
|
115
|
+
md = Path(args.model_dir)
|
|
116
|
+
md.mkdir(parents=True, exist_ok=True)
|
|
117
|
+
print(f"模型目录: {md}")
|
|
118
|
+
ok = True
|
|
119
|
+
for name, (url, sha) in MANIFEST.items():
|
|
120
|
+
if not download(url, md / name, sha):
|
|
121
|
+
ok = False
|
|
122
|
+
if ok:
|
|
123
|
+
print("全部完成 ✓")
|
|
124
|
+
return 0
|
|
125
|
+
print("存在失败项,请检查网络或设置 DSH_OCR_MODELS_MIRROR", file=sys.stderr)
|
|
126
|
+
return 1
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
if __name__ == "__main__":
|
|
130
|
+
sys.exit(main())
|