picturereader 2.0.0 → 3.0.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.
@@ -0,0 +1,194 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ doc-to-image.py — Office/PDF 逐页转 PNG 渲染脚本(由 DSH document_to_image 工具调用)。
4
+
5
+ 完整链路:
6
+ .pdf ──直接──► PyMuPDF(fitz) 逐页渲染 PNG
7
+ .docx/.doc/.xlsx/.xls/.pptx/.ppt ──► LibreOffice(soffice) headless 转 PDF ──► fitz 渲染 PNG
8
+
9
+ 用法(argv):
10
+ python doc-to-image.py <input> <out_dir> <prefix> <dpi> <max_pages>
11
+
12
+ <input> 源文档的绝对本地路径(pdf 或 office 文件;Node 侧已落盘)。
13
+ <out_dir> 输出目录(已存在;PNG 写到这里)。
14
+ <prefix> PNG 文件名前缀,输出形如 <out_dir>/<prefix>_<i>.png,i 从 1 起。
15
+ <dpi> 渲染分辨率(72..300,默认 150)。
16
+ <max_pages> 最多渲染前 N 页(默认 50)。
17
+
18
+ 输出:
19
+ stdout 打印一行 JSON:
20
+ {"pages": [{"path": "...", "width": 888, "height": 1258, "bytes": 123456}], "page_count": 42, "truncated": false}
21
+
22
+ page_count 是文档实际总页数;pages 只含实际渲染的(<= max_pages)页面。
23
+ 任何错误以非零退出码 + stderr 信息返回。
24
+
25
+ soffice 可执行路径:
26
+ 优先读环境变量 DSH_SOFFICE;未设置则用默认
27
+ C:/Program Files/LibreOffice/program/soffice.exe,并通过 glob 兜底大小写
28
+ (实际 Windows 安装是 "Program"/"program" 小写目录)。soffice 缺失时报清晰错误。
29
+ """
30
+ import glob
31
+ import json
32
+ import os
33
+ import shutil
34
+ import subprocess
35
+ import sys
36
+ import tempfile
37
+
38
+ # ---- 常量 ------------------------------------------------------------------
39
+
40
+ SUPPORTED_EXTS = {".pdf", ".docx", ".doc", ".xlsx", ".xls", ".pptx", ".ppt"}
41
+ OFFICE_EXTS = SUPPORTED_EXTS - {".pdf"}
42
+ DEFAULT_SOFFICE = r"C:/Program Files/LibreOffice/program/soffice.exe"
43
+ SOFFICE_CANDIDATES = (
44
+ "C:/Program Files/LibreOffice/program/soffice.exe",
45
+ "C:/Program Files/LibreOffice/Program/soffice.exe",
46
+ "C:/Program Files (x86)/LibreOffice/program/soffice.exe",
47
+ "C:/Program Files (x86)/LibreOffice/Program/soffice.exe",
48
+ )
49
+
50
+
51
+ def find_soffice():
52
+ """返回可用的 soffice 可执行路径,找不到返回 None。glob 兜底大小写差异。"""
53
+ env = os.environ.get("DSH_SOFFICE", "").strip()
54
+ if env:
55
+ if os.path.exists(env):
56
+ return env
57
+ # 环境变量指了但不存在 -> 继续往下,但先尝试把它的上级目录 glob 一下
58
+ pattern = os.path.join(os.path.dirname(env), "soffice.exe")
59
+ for hit in glob.glob(pattern):
60
+ return hit
61
+ for cand in SOFFICE_CANDIDATES:
62
+ if os.path.exists(cand):
63
+ return cand
64
+ # 大小写兜底:在标准根目录里找 program* / Program* 下的 soffice.exe
65
+ for base in ("C:/Program Files/LibreOffice", "C:/Program Files (x86)/LibreOffice"):
66
+ for sub in ("program", "Program", "PROGRAM", "Program Files"):
67
+ p = os.path.join(base, sub, "soffice.exe")
68
+ if os.path.exists(p):
69
+ return p
70
+ return None
71
+
72
+
73
+ def soffice_to_pdf(src, out_dir, soffice, timeout_s=120):
74
+ """headless 把 office 文件转成 pdf,返回 pdf 路径。带独立 UserInstallation profile 避免锁冲突。"""
75
+ profile_dir = os.path.join(out_dir, ".lo_profile")
76
+ os.makedirs(profile_dir, exist_ok=True)
77
+ profile_uri = "file:///" + profile_dir.replace("\\", "/")
78
+ cmd = [
79
+ soffice,
80
+ "--headless",
81
+ "--norestore",
82
+ "--nofirststartwizard",
83
+ "-env:UserInstallation=" + profile_uri,
84
+ "--convert-to", "pdf",
85
+ "--outdir", out_dir,
86
+ src,
87
+ ]
88
+ # soffice 不开管道,capture_output 会乖乖返回;timeout 兜底防挂起。
89
+ proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout_s)
90
+ base = os.path.splitext(os.path.basename(src))[0]
91
+ pdf_path = os.path.join(out_dir, base + ".pdf")
92
+ if not os.path.exists(pdf_path):
93
+ msg = "soffice 未产出 pdf"
94
+ if proc.stderr and proc.stderr.strip():
95
+ msg += ": " + proc.stderr.strip()[-500:]
96
+ raise RuntimeError(msg)
97
+ return pdf_path
98
+
99
+
100
+ def render_pdf(pdf_path, out_dir, prefix, dpi, max_pages):
101
+ """用 fitz 把 pdf 逐页渲染成 PNG,返回 (pages:list[dict], page_count:int, truncated:bool)。"""
102
+ import fitz # PyMuPDF
103
+
104
+ doc = fitz.open(pdf_path)
105
+ total = doc.page_count
106
+ n = min(total, max_pages)
107
+ pages = []
108
+ for i in range(n):
109
+ pix = doc[i].get_pixmap(dpi=dpi)
110
+ p = os.path.join(out_dir, "{}_{}.png".format(prefix, i + 1))
111
+ pix.save(p)
112
+ size = os.path.getsize(p)
113
+ pages.append({
114
+ "path": p,
115
+ "width": pix.width,
116
+ "height": pix.height,
117
+ "bytes": size,
118
+ "index": i + 1,
119
+ })
120
+ truncated = total > max_pages
121
+ return pages, total, truncated
122
+
123
+
124
+ def main(argv):
125
+ if len(argv) < 5:
126
+ print(json.dumps({"error": "usage: doc-to-image.py <input> <out_dir> <prefix> <dpi> <max_pages>"}))
127
+ return 2
128
+
129
+ src, out_dir, prefix = argv[0], argv[1], argv[2]
130
+ dpi = int(argv[3])
131
+ max_pages = int(argv[4])
132
+
133
+ if not os.path.exists(src):
134
+ print(json.dumps({"error": "input file not found: {}".format(src)}))
135
+ return 1
136
+
137
+ ext = os.path.splitext(src)[1].lower()
138
+ if ext not in SUPPORTED_EXTS:
139
+ print(json.dumps({"error": "unsupported extension '{}' (supported: {})".format(
140
+ ext, ", ".join(sorted(SUPPORTED_EXTS)))}))
141
+ return 1
142
+
143
+ os.makedirs(out_dir, exist_ok=True)
144
+
145
+ # 1) 得到待渲染的 pdf 路径。
146
+ pdf_path = None
147
+ tmp_dir = None
148
+ if ext == ".pdf":
149
+ pdf_path = src
150
+ else:
151
+ soffice = find_soffice()
152
+ if not soffice:
153
+ print(json.dumps({"error": "LibreOffice(soffice) 未找到。请安装 LibreOffice,或设置环境变量 "
154
+ "DSH_SOFFICE 指向 soffice.exe 的可执行路径。"}))
155
+ return 1
156
+ # 独立临时目录放中间 pdf,避免多个同 base 文件互覆盖。
157
+ tmp_dir = tempfile.mkdtemp(prefix="lo_pdf_", dir=out_dir)
158
+ try:
159
+ pdf_path = soffice_to_pdf(src, tmp_dir, soffice)
160
+ except subprocess.TimeoutExpired:
161
+ print(json.dumps({"error": "soffice 转换超时(>120s),请检查文档是否损坏或过大。"}))
162
+ return 1
163
+ except Exception as e: # noqa: BLE001
164
+ print(json.dumps({"error": "soffice 转换失败: {}".format(e)}))
165
+ return 1
166
+
167
+ # 2) fitz 渲染。
168
+ try:
169
+ pages, page_count, truncated = render_pdf(pdf_path, out_dir, prefix, dpi, max_pages)
170
+ except Exception as e: # noqa: BLE001
171
+ print(json.dumps({"error": "pdf 渲染失败: {}".format(e)}))
172
+ return 1
173
+ finally:
174
+ # 清理中间 pdf 临时目录(保留最终 PNG)。
175
+ if tmp_dir and os.path.isdir(tmp_dir):
176
+ shutil.rmtree(tmp_dir, ignore_errors=True)
177
+
178
+ print(json.dumps({
179
+ "pages": pages,
180
+ "page_count": page_count,
181
+ "truncated": truncated,
182
+ "input": os.path.basename(src),
183
+ "out_dir": out_dir,
184
+ }))
185
+ return 0
186
+
187
+
188
+ if __name__ == "__main__":
189
+ try:
190
+ code = main(sys.argv[1:])
191
+ except Exception as e: # 顶层兜底:任何未捕获异常都以 JSON error 传出
192
+ print(json.dumps({"error": "unexpected: {}".format(e)}))
193
+ code = 1
194
+ sys.exit(code)
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Optional install helper for the document_to_image tool's Python environment.
3
+ *
4
+ * The document conversion chain (LibreOffice -> PyMuPDF/fitz) runs in an
5
+ * isolated venv at C:\Users\Administrator\doc_venv so it does not depend on
6
+ * the global Python. This script makes sure that venv exists with pymupdf
7
+ * installed, mirroring the pattern of scripts/setup-ocr.mjs.
8
+ *
9
+ * What this does:
10
+ * 1. Locates the global Python 3.14 interpreter.
11
+ * 2. Creates doc_venv if missing (from that interpreter).
12
+ * 3. Installs pymupdf (fitz) into the venv from the Tsinghua PyPI mirror if
13
+ * `import fitz` does not already work.
14
+ *
15
+ * Idempotent: if the venv python exists and can import fitz, it does nothing.
16
+ *
17
+ * Usage: node scripts/setup-doc-venv.mjs
18
+ */
19
+ import { existsSync, mkdirSync } from 'node:fs';
20
+ import { spawnSync } from 'node:child_process';
21
+ import { dirname } from 'node:path';
22
+ import { fileURLToPath } from 'node:url';
23
+
24
+ const BASE_PY = 'C:\\Users\\Administrator\\AppData\\Local\\Programs\\Python\\Python314\\python.exe';
25
+ const VENV_PY = 'C:\\Users\\Administrator\\doc_venv\\Scripts\\python.exe';
26
+ const PYPI = 'https://pypi.tuna.tsinghua.edu.cn/simple';
27
+
28
+ function run(cmd, args, opts = {}) {
29
+ console.log(`> ${cmd} ${args.join(' ')}`);
30
+ const result = spawnSync(cmd, args, { stdio: 'inherit', ...opts });
31
+ if (result.status !== 0) {
32
+ console.error(`!! command failed (exit ${result.status})`);
33
+ process.exit(1);
34
+ }
35
+ }
36
+
37
+ // 1. base Python 3.14
38
+ if (existsSync(BASE_PY)) {
39
+ console.log('[1/3] Global Python 3.14 found at ' + BASE_PY);
40
+ } else {
41
+ console.error('!! Global Python 3.14 not found at ' + BASE_PY);
42
+ console.error(' Install Python 3.14, or edit BASE_PY in scripts/setup-doc-venv.mjs to your interpreter.');
43
+ process.exit(1);
44
+ }
45
+
46
+ // 2. venv
47
+ if (!existsSync(VENV_PY)) {
48
+ console.log('[2/3] Creating doc_venv...');
49
+ mkdirSync(dirname(VENV_PY), { recursive: true });
50
+ run(BASE_PY, ['-m', 'venv', 'C:\\Users\\Administrator\\doc_venv']);
51
+ } else {
52
+ console.log('[2/3] doc_venv found');
53
+ }
54
+
55
+ // 3. pymupdf
56
+ const probe = spawnSync(VENV_PY, ['-c', 'import fitz; print(fitz.__doc__ or fitz.version)'], { encoding: 'utf8' });
57
+ if (probe.status !== 0) {
58
+ console.log('[3/3] Installing pymupdf (Tsinghua mirror)...');
59
+ run(VENV_PY, ['-m', 'pip', 'install', '-i', PYPI, '--upgrade', 'pip']);
60
+ run(VENV_PY, ['-m', 'pip', 'install', '-i', PYPI, 'pymupdf']);
61
+ } else {
62
+ console.log(`[3/3] pymupdf already installed (${probe.stdout.trim()})`);
63
+ }
64
+
65
+ console.log('\nDone. document_to_image can now call the doc_venv Python.');
66
+ console.log('Verify: run document_to_image on a small pdf/docx and inspect the returned PNG list.');
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Optional install helper for the RapidOCR engine (image_ocr engine="rapid").
3
+ * RapidOCR is an OPTIONAL engine — image_ocr degrades to the Windows engine
4
+ * when it is missing.
5
+ *
6
+ * RapidOCR uses the `rapidocr_onnxruntime` package with 3 bundled ONNX models
7
+ * (det/rec/cls), so no network model download happens on first run.
8
+ *
9
+ * What this does:
10
+ * 1. Ensures a base Python interpreter exists (default: the user Python314).
11
+ * 2. Creates/repairs the rapid_venv.
12
+ * 3. Installs rapidocr_onnxruntime from the Tsinghua PyPI mirror.
13
+ * 4. Warms up by running one recognition on the OCR test image.
14
+ *
15
+ * Usage: node scripts/setup-rapid.mjs
16
+ */
17
+ import { existsSync, mkdirSync } from 'node:fs';
18
+ import { spawnSync } from 'node:child_process';
19
+ import { join, dirname } from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+
22
+ const BASE_PYTHON = process.env.DSH_RAPID_BASE_PYTHON ?? 'C:\\Users\\Administrator\\AppData\\Local\\Programs\\Python\\Python314\\python.exe';
23
+ const VENV = 'C:\\Users\\Administrator\\rapid_venv\\Scripts\\python.exe';
24
+ const PYPI = 'https://pypi.tuna.tsinghua.edu.cn/simple';
25
+
26
+ function run(cmd, args, opts = {}) {
27
+ console.log(`> ${cmd} ${args.join(' ')}`);
28
+ const result = spawnSync(cmd, args, { stdio: 'inherit', ...opts });
29
+ // pwsh can mis-report pip's Chinese output as exit 1; trust the actual status.
30
+ if (result.status !== 0) {
31
+ console.error(`!! command failed (exit ${result.status})`);
32
+ process.exit(1);
33
+ }
34
+ }
35
+
36
+ // 1. base Python
37
+ if (!existsSync(BASE_PYTHON)) {
38
+ console.error('!! base Python not found at ' + BASE_PYTHON);
39
+ console.error(' Set DSH_RAPID_BASE_PYTHON to a valid python.exe (3.9+) and rerun.');
40
+ process.exit(1);
41
+ }
42
+
43
+ // 2. venv
44
+ if (!existsSync(VENV)) {
45
+ console.log('[1/3] Creating rapid_venv...');
46
+ mkdirSync(dirname(VENV), { recursive: true });
47
+ run(BASE_PYTHON, ['-m', 'venv', 'C:\\Users\\Administrator\\rapid_venv']);
48
+ } else {
49
+ console.log('[1/3] rapid_venv found');
50
+ }
51
+
52
+ // 3. rapidocr_onnxruntime
53
+ const probe = spawnSync(VENV, ['-c', 'import rapidocr_onnxruntime; print(rapidocr_onnxruntime.__version__)'], { encoding: 'utf8' });
54
+ if (probe.status !== 0) {
55
+ console.log('[2/3] Installing rapidocr_onnxruntime (Tsinghua mirror, ~1 min)...');
56
+ run(VENV, ['-m', 'pip', 'install', '-i', PYPI, '--upgrade', 'pip']);
57
+ run(VENV, ['-m', 'pip', 'install', '-i', PYPI, 'rapidocr_onnxruntime']);
58
+ } else {
59
+ console.log(`[2/3] rapidocr_onnxruntime already installed (${probe.stdout.trim()})`);
60
+ }
61
+
62
+ // 4. warm-up with one recognition
63
+ console.log('[3/3] Warming up RapidOCR (bundled ONNX models — no download)...');
64
+ const testImage = join(dirname(fileURLToPath(import.meta.url)), '..', 'tests', 'fixtures-out', 'ocr-test.png');
65
+ if (existsSync(testImage)) {
66
+ const warm = spawnSync(VENV, ['-c', [
67
+ 'import json, sys',
68
+ 'from rapidocr_onnxruntime import RapidOCR',
69
+ "_r, _ = RapidOCR()(sys.argv[1])",
70
+ 'print("warm-up OCR ok, lines:", len(_r or []))'
71
+ ].join('; '), testImage.replaceAll('\\', '/')], {
72
+ env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
73
+ encoding: 'utf8'
74
+ });
75
+ if (warm.status !== 0) {
76
+ console.error('!! warm-up failed — see output above; the engine may still work once models are present');
77
+ process.exit(1);
78
+ }
79
+ console.log(warm.stdout.trim());
80
+ } else {
81
+ console.log('[3/3] test image missing — skip warm-up (first image_ocr rapid call loads models)');
82
+ }
83
+
84
+ console.log('\nDone. image_ocr engine="rapid" is now available.');
85
+ console.log('Verify: ask the model to read an image with image_ocr(engine="rapid").');
package/src/bridge.js ADDED
@@ -0,0 +1,162 @@
1
+ /**
2
+ * picturereader 图片桥 (bridge.js)
3
+ *
4
+ * DSH 原生支持粘贴图片:粘贴后即生成 image content block,并在输入框/会话里
5
+ * 渲染缩略图。但主模型一般是纯文本(如 deepseek-v4-flash),若直接把 image
6
+ * block 发给文本型适配器会 UNSUPPORTED_CONTENT 导致整轮失败。本桥负责把发给
7
+ * 文本模型的 image block 按当前使用模式降级为"本地图片理解引导":
8
+ *
9
+ * - agent/pre-step:把进入本轮的消息里的图片降级(privacy 只引导本地工具;
10
+ * smart/strict 附带对应策略,并说明何时可考虑 vision_analyze 走外部 VLM)。
11
+ * - llm/stream requestGuard:对仍带着 image block 的非多模态请求再兜底一次,
12
+ * 避免适配器抛错。
13
+ *
14
+ * 隐私模式为硬 gate:即使配置了外部 API,降级后的引导也明确"只用本地工具",
15
+ * 绝不把图发给任何外部视觉端点。
16
+ *
17
+ * @module picturereader/bridge
18
+ */
19
+
20
+ import { mkdir, writeFile } from 'node:fs/promises';
21
+ import { join } from 'node:path';
22
+ import os from 'node:os';
23
+ import { getRuntimeConfig } from './runtime.js';
24
+ import { routePolicyText, routeModeTag } from './routing.js';
25
+
26
+ const EXT_BY_MEDIA = {
27
+ 'image/png': '.png',
28
+ 'image/jpeg': '.jpg',
29
+ 'image/webp': '.webp',
30
+ 'image/gif': '.gif',
31
+ 'image/bmp': '.bmp',
32
+ 'image/avif': '.avif',
33
+ };
34
+
35
+ /** 判断消息是否含 image content block。 */
36
+ export function hasImageBlock(messages) {
37
+ return (messages ?? []).some(
38
+ (m) => Array.isArray(m?.content) && m.content.some((b) => b?.type === 'image'),
39
+ );
40
+ }
41
+
42
+ /** 深冻结(与 harness 对持久消息的冻结一致)。 */
43
+ export function deepFreeze(value) {
44
+ if (value === null || typeof value !== 'object' || Object.isFrozen(value)) return value;
45
+ if (Array.isArray(value)) {
46
+ for (const item of value) deepFreeze(item);
47
+ return Object.freeze(value);
48
+ }
49
+ for (const key of Object.keys(value)) deepFreeze(value[key]);
50
+ return Object.freeze(value);
51
+ }
52
+
53
+ /** 导出 attachment 到磁盘,返回路径(按 attachmentId 缓存,进程内复用)。 */
54
+ const exportedPaths = new Map();
55
+ export async function exportImage(attachment, ctx, dir) {
56
+ const cached = exportedPaths.get(attachment.attachmentId);
57
+ if (cached) return cached;
58
+ let data;
59
+ const attachments = ctx.get?.('attachments') ?? ctx.attachments;
60
+ try {
61
+ ({ data } = await attachments.readImage(attachment));
62
+ } catch (error) {
63
+ throw new Error(`picturereader: cannot read pasted image: ${String(error && error.message || error)}`);
64
+ }
65
+ await mkdir(dir, { recursive: true });
66
+ const ext = EXT_BY_MEDIA[attachment.mediaType] ?? '.img';
67
+ const safeName = attachment.name
68
+ ? attachment.name.replace(/\.[^.]+$/, '').replace(/[^\w\-]+/g, '_').slice(0, 40)
69
+ : '';
70
+ const base = (safeName ? `${safeName}_` : '') + attachment.attachmentId.slice(0, 12);
71
+ const path = join(dir, `${base}${ext}`);
72
+ await writeFile(path, data);
73
+ exportedPaths.set(attachment.attachmentId, path);
74
+ return path;
75
+ }
76
+
77
+ /**
78
+ * 把消息里的 image block 替换成文本引导。纯函数(可测)。
79
+ * 读取当前 runtime mode 生成对应策略。
80
+ * @param {Array} messages - 待处理消息。
81
+ * @param {object} ctx - 提供 ctx.attachments。
82
+ * @param {string} dir - 图片导出目录。
83
+ * @returns {Promise<Array>} 处理后消息(图片消息被替换成 fresh frozen 对象)。
84
+ */
85
+ export async function bridgeMessages(messages, ctx, dir) {
86
+ const mode = getRuntimeConfig()?.mode ?? 'smart';
87
+ const policy = routePolicyText(mode, { vlmConfigured: true });
88
+ const next = [];
89
+ for (const message of messages) {
90
+ const content = message?.content;
91
+ if (!Array.isArray(content) || !content.some((b) => b?.type === 'image')) {
92
+ next.push(message);
93
+ continue;
94
+ }
95
+ const blocks = [];
96
+ for (const block of content) {
97
+ if (block?.type !== 'image') {
98
+ blocks.push(block);
99
+ continue;
100
+ }
101
+ let path;
102
+ try {
103
+ path = await exportImage(block.attachment, ctx, dir);
104
+ } catch {
105
+ // 导出失败时回退成纯提示,不让整轮崩。
106
+ blocks.push({ type: 'text', text: '[图片附件已粘贴,将尝试读取分析]' });
107
+ continue;
108
+ }
109
+ const name = block.attachment.name ? `(${block.attachment.name})` : '';
110
+ blocks.push({
111
+ type: 'text',
112
+ text:
113
+ `用户粘贴了一张图片${name},已导出到:${path}\n` +
114
+ `${routeModeTag(mode)}\n` +
115
+ policy +
116
+ `\n请先用 image_scan 分析 ${path}(如含文字再用 image_ocr)。`,
117
+ });
118
+ }
119
+ next.push(deepFreeze({ ...message, content: blocks }));
120
+ }
121
+ return next;
122
+ }
123
+
124
+ /**
125
+ * 注册图片桥(agent/pre-step 桥 + llm/stream 兜底)。
126
+ * @param {object} ctx - Cordis 上下文(inject: tools/llm/attachments)。
127
+ * @param {() => object} 未使用 getConfig —— mode 从 runtime 读,保持实时。
128
+ */
129
+ export function attachImageBridge(ctx) {
130
+ // 注意:不在 agent/pre-step 读图降级 —— 该阶段图片 attachment 可能尚未落盘,
131
+ // readImage 读不到会报错。真正的图片降级/分析放在 llm/stream(适配器层,此时
132
+ // attachment 已保存)完成。
133
+
134
+ // llm/stream 兜底:还带着 image block 的非多模态请求,降级后放行。
135
+ ctx.on('llm/stream', (options, next) => {
136
+ console.log('[picturereader] llm/stream fired, model=', options?.model, 'hasImage=', hasImageBlock(options?.messages));
137
+ return (async function* () {
138
+ let downstream;
139
+ try {
140
+ const rt = getRuntimeConfig();
141
+ const guardOn = rt?.requestGuard !== false;
142
+ const multimodal = rt?.multimodalModels || [];
143
+ const model = options?.model || '';
144
+ const inWhitelist = multimodal.includes(model);
145
+ if (guardOn && !inWhitelist && hasImageBlock(options.messages)) {
146
+ const exportDir = (rt?.bridge?.exportDir || '').trim() || join(os.tmpdir(), 'picturereader-bridge');
147
+ const before = options.messages.reduce((n, m) => n + (Array.isArray(m?.content) ? m.content.filter(b => b?.type === 'image').length : 0), 0);
148
+ const messages = await bridgeMessages(options.messages, ctx, exportDir);
149
+ const after = messages.reduce((n, m) => n + (Array.isArray(m?.content) ? m.content.filter(b => b?.type === 'image').length : 0), 0);
150
+ const changed = messages.some((m, i) => m !== options.messages[i]);
151
+ console.log(`[picturereader] llm/stream images before=${before} after=${after} changed=${changed} model=${options.model}`);
152
+ if (changed) {
153
+ downstream = next({ ...options, messages });
154
+ }
155
+ }
156
+ } catch (error) {
157
+ console.log('[picturereader] llm/stream downgrade failed:', String(error && error.message || error));
158
+ }
159
+ yield* downstream ?? next();
160
+ })();
161
+ });
162
+ }
package/src/config.js ADDED
@@ -0,0 +1,70 @@
1
+ /**
2
+ * picturereader 设置命名空间 (config.js) — 纯逻辑部分
3
+ *
4
+ * schema(schemastery)在宿主侧 index.js 定义(DSH 运行时才可解析
5
+ * @deepseek-ai/schemastery);本模块只保留可独立测试的解析纯函数。
6
+ *
7
+ * 字段采用扁平 key(与 dsh-tool-vision 一致),避免嵌套带来的 YAML 歧义:
8
+ * mode 三模式(privacy / smart / strict)
9
+ * vlm_base OpenAI 兼容视觉端点 URL
10
+ * vlm_model 视觉模型名
11
+ * vlm_key 视觉 API key(role:'secret',只写不读、保存可覆盖、不回显)
12
+ * vlm_key_env 环境变量名(vlm_key 为空时回退读取)
13
+ * ocr_engine 默认 OCR 引擎(windows / paddle / rapid)
14
+ * @module picturereader/config
15
+ */
16
+
17
+ /** 本插件拥有的设置命名空间名。 */
18
+ export const NS = 'picturereader';
19
+
20
+ /** 合法模式(与 routing.MODE_KEYS 一致,避免循环依赖单独声明)。 */
21
+ export const MODE_KEYS = ['privacy', 'smart', 'strict'];
22
+
23
+ /** 合法 OCR 引擎。 */
24
+ export const OCR_ENGINE_KEYS = ['windows', 'paddle', 'rapid'];
25
+
26
+ /**
27
+ * 把扁平配置映射成稳定的模式值(容错非法输入)。
28
+ * @param {object} value - 原始配置对象。
29
+ * @returns {'privacy'|'smart'|'strict'}
30
+ */
31
+ export function modeOf(value) {
32
+ const m = String(value?.mode ?? 'smart').trim();
33
+ return MODE_KEYS.includes(m) ? m : 'smart';
34
+ }
35
+
36
+ /**
37
+ * 从扁平配置解析 VLM 端点信息。
38
+ * @param {object} value
39
+ * @returns {{baseUrl:string, model:string, apiKey:string, apiKeyEnv:string}}
40
+ */
41
+ export function vlmConfigOf(value) {
42
+ const v = value ?? {};
43
+ return {
44
+ baseUrl: String(v.vlm_base ?? ''),
45
+ model: String(v.vlm_model ?? ''),
46
+ apiKey: String(v.vlm_key ?? ''),
47
+ apiKeyEnv: String(v.vlm_key_env ?? ''),
48
+ };
49
+ }
50
+
51
+ /**
52
+ * 解析默认 OCR 引擎(容错)。
53
+ * @param {object} value
54
+ * @returns {'windows'|'paddle'|'rapid'}
55
+ */
56
+ export function ocrEngineOf(value) {
57
+ const e = String(value?.ocr_engine ?? 'windows').trim();
58
+ return OCR_ENGINE_KEYS.includes(e) ? e : 'windows';
59
+ }
60
+
61
+ /** 读取 API key:优先 vlm_key,其次 环境变量 vlm_key_env,最后 ''。 */
62
+ export function resolveVlmApiKey(vlm) {
63
+ if (vlm?.apiKey) return vlm.apiKey;
64
+ const envName = vlm?.apiKeyEnv;
65
+ if (envName) {
66
+ const fromEnv = process.env[envName];
67
+ if (fromEnv) return fromEnv;
68
+ }
69
+ return '';
70
+ }