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.
- package/README.md +160 -139
- package/client.js +536 -0
- package/package.json +15 -3
- package/scripts/doc-to-image.py +194 -0
- package/scripts/setup-doc-venv.mjs +66 -0
- package/scripts/setup-rapid.mjs +85 -0
- package/src/bridge.js +162 -0
- package/src/config.js +70 -0
- package/src/core.js +94 -2
- package/src/doc-tools.js +326 -0
- package/src/image-batch.js +504 -0
- package/src/index.js +258 -32
- package/src/more-tools.js +695 -0
- package/src/picturereader-vision.mjs +164 -0
- package/src/routing.js +130 -0
- package/src/runtime.js +127 -0
- package/src/settings-expose.js +75 -0
- package/src/tool.js +19 -14
- package/src/vision-analyze.js +35 -13
- package/src/vlm.js +198 -15
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* picturereader 视觉孪生 adapter
|
|
3
|
+
*
|
|
4
|
+
* 用 Proxy 把已注册的 adapter(如 PiAiAdapter,它服务 opencode-go / deepseek
|
|
5
|
+
* / xiaomi / qiu 等多个 provider)包装成"孪生":
|
|
6
|
+
*
|
|
7
|
+
* - listModels / resolveModel:将被勾选的模型标成 inputModalities:['text',
|
|
8
|
+
* 'image'] + 名称加「(视觉)」后缀 → DSH 原生缩略图/图片块进会话。
|
|
9
|
+
* - stream:拦截请求里的 image block → 用 picturereader 本地工具链分析 → 替换
|
|
10
|
+
* 成文本 → 再转发给原始 adapter(pi-ai 收到纯文本,不会 UNSUPPORTED_CONTENT)。
|
|
11
|
+
*
|
|
12
|
+
* @module picturereader/picturereader-vision
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { homedir } from 'node:os';
|
|
18
|
+
import { createHash } from 'node:crypto';
|
|
19
|
+
import { contentHasImage } from '@deepseek-ai/dsh-llm';
|
|
20
|
+
|
|
21
|
+
const DSH_HOME = process.env.DSH_HOME || join(homedir(), '.dsh');
|
|
22
|
+
const IMAGE_DIR = join(DSH_HOME, 'picturereader-vision', 'images');
|
|
23
|
+
|
|
24
|
+
/** 从配置读取被勾选的模型 Map<provider/id, entry>。 */
|
|
25
|
+
function selectedMap(getConfig) {
|
|
26
|
+
try {
|
|
27
|
+
const cfg = getConfig?.();
|
|
28
|
+
const list = cfg?.vision_models;
|
|
29
|
+
if (!Array.isArray(list)) return new Map();
|
|
30
|
+
const map = new Map();
|
|
31
|
+
for (const m of list) {
|
|
32
|
+
const id = typeof m === 'string' ? m : m.id;
|
|
33
|
+
const provider = (typeof m === 'object' ? m.provider : '') || '';
|
|
34
|
+
if (id) map.set(provider + '/' + id, m);
|
|
35
|
+
}
|
|
36
|
+
return map;
|
|
37
|
+
} catch { return new Map(); }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isSelected(getConfig, provider, id) {
|
|
41
|
+
const map = selectedMap(getConfig);
|
|
42
|
+
return map.has(provider + '/' + id);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function noteOf(getConfig, provider, id) {
|
|
46
|
+
const map = selectedMap(getConfig);
|
|
47
|
+
const entry = map.get(provider + '/' + id);
|
|
48
|
+
return entry && typeof entry === 'object' ? (entry.note || '') : '';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 给被勾选模型注入视觉元数据(inputModalities / pi-ai 的 input)。 */
|
|
52
|
+
function applyVisionMeta(model, provider, getConfig) {
|
|
53
|
+
if (!model || !isSelected(getConfig, provider, model.id)) return model;
|
|
54
|
+
const note = noteOf(getConfig, provider, model.id);
|
|
55
|
+
const suffix = note ? ` (${note})` : ' (视觉)';
|
|
56
|
+
const out = { ...model, name: (model.name || model.id) + suffix, inputModalities: ['text', 'image'] };
|
|
57
|
+
// pi-ai 系列用 `input` 数组;一并注入,保证 resolveModel 也通过。
|
|
58
|
+
if ('input' in model) out.input = [...model.input, 'image'];
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 把图片字节落盘为临时文件,返回路径。 */
|
|
63
|
+
async function saveImageBytes(bytes, mediaType) {
|
|
64
|
+
await mkdir(IMAGE_DIR, { recursive: true });
|
|
65
|
+
const hash = createHash('sha1').update(bytes).digest('hex').slice(0, 24);
|
|
66
|
+
const ext = mediaType === 'image/jpeg' ? '.jpg'
|
|
67
|
+
: mediaType === 'image/webp' ? '.webp'
|
|
68
|
+
: mediaType === 'image/gif' ? '.gif' : '.png';
|
|
69
|
+
const path = join(IMAGE_DIR, hash + ext);
|
|
70
|
+
try { await writeFile(path, bytes, { flag: 'wx' }); } catch (e) { if (e?.code !== 'EEXIST') throw e; }
|
|
71
|
+
return path;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 读图(经 attachments)并做本地说明,返回一段文本证据(path 供工具续读)。 */
|
|
75
|
+
async function analyzeImage(block, attachments) {
|
|
76
|
+
let data;
|
|
77
|
+
try {
|
|
78
|
+
({ data } = await attachments.readImage(block.attachment));
|
|
79
|
+
} catch (e) {
|
|
80
|
+
return `[图片](读取失败:${e?.message || e}),请用 image_scan 分析附件`;
|
|
81
|
+
}
|
|
82
|
+
const path = await saveImageBytes(data, block.attachment.mediaType);
|
|
83
|
+
return `[用户粘贴了一张图片]\n图片已导出到:${path}\n请先用 image_scan 分析该图片(如含文字再用 image_ocr),结合内容回答。`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 把消息里的 image block 替换成分析文本。 */
|
|
87
|
+
async function sanitizeImages(ctx, messages) {
|
|
88
|
+
const attachments = ctx.get?.('attachments') ?? ctx.attachments;
|
|
89
|
+
const next = [];
|
|
90
|
+
for (const message of messages) {
|
|
91
|
+
const content = message?.content;
|
|
92
|
+
if (!Array.isArray(content) || !content.some((b) => b?.type === 'image')) { next.push(message); continue; }
|
|
93
|
+
const blocks = [];
|
|
94
|
+
for (const block of content) {
|
|
95
|
+
if (block?.type !== 'image') { blocks.push(block); continue; }
|
|
96
|
+
blocks.push({ type: 'text', text: await analyzeImage(block, attachments) });
|
|
97
|
+
}
|
|
98
|
+
next.push({ ...message, content: blocks });
|
|
99
|
+
}
|
|
100
|
+
return next;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 对被选中模型所属的 provider,用 Proxy 包装原始 adapter 成孪生并原位替换
|
|
105
|
+
* registration.adapter(避免 DUPLICATE_ADAPTER)。返回注册数;注册者用 ctx.effect
|
|
106
|
+
* 在卸载时恢复原 adapter。
|
|
107
|
+
*/
|
|
108
|
+
export function registerTwinAdapters(ctx, llm, getConfig) {
|
|
109
|
+
if (!llm || !getConfig) return 0;
|
|
110
|
+
const map = selectedMap(getConfig);
|
|
111
|
+
const providers = new Set();
|
|
112
|
+
for (const key of map.keys()) {
|
|
113
|
+
const prov = key.split('/')[0];
|
|
114
|
+
if (prov) providers.add(prov);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const restores = [];
|
|
118
|
+
let count = 0;
|
|
119
|
+
for (const provider of providers) {
|
|
120
|
+
let reg;
|
|
121
|
+
try { reg = llm.registration(provider); } catch { continue; }
|
|
122
|
+
if (!reg || !reg.adapter) continue;
|
|
123
|
+
const orig = reg.adapter;
|
|
124
|
+
|
|
125
|
+
const origList = orig.listModels.bind(orig);
|
|
126
|
+
const origResolve = orig.resolveModel.bind(orig);
|
|
127
|
+
const origStream = orig.stream.bind(orig);
|
|
128
|
+
|
|
129
|
+
const twin = new Proxy(orig, {
|
|
130
|
+
get(target, prop, receiver) {
|
|
131
|
+
if (prop === 'listModels') {
|
|
132
|
+
return async (p) => (await origList(p)).map((m) => applyVisionMeta(m, p, getConfig));
|
|
133
|
+
}
|
|
134
|
+
if (prop === 'resolveModel') {
|
|
135
|
+
return async (p, m, signal) => applyVisionMeta(await origResolve(p, m, signal), p, getConfig);
|
|
136
|
+
}
|
|
137
|
+
if (prop === 'stream') {
|
|
138
|
+
return async function* (options) {
|
|
139
|
+
if (options?.messages?.some((msg) => contentHasImage(msg?.content))) {
|
|
140
|
+
options = { ...options, messages: await sanitizeImages(ctx, options.messages) };
|
|
141
|
+
}
|
|
142
|
+
yield* origStream(options);
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
const value = Reflect.get(target, prop, receiver);
|
|
146
|
+
return typeof value === 'function' ? value.bind(target) : value;
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
reg.adapter = twin;
|
|
151
|
+
restores.push({ reg, orig });
|
|
152
|
+
count++;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (count > 0) console.log(`[picturereader] vision twin active on provider(s): ${[...providers].join(', ')}`);
|
|
156
|
+
|
|
157
|
+
if (restores.length > 0) {
|
|
158
|
+
ctx.effect(
|
|
159
|
+
() => () => { for (const { reg, orig } of restores) reg.adapter = orig; },
|
|
160
|
+
'picturereader: vision twin restore',
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
return count;
|
|
164
|
+
}
|
package/src/routing.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* picturereader 三模式路由 (routing.js)
|
|
3
|
+
*
|
|
4
|
+
* 用户在设置卡里选择一个使用模式,控制"什么时候走外部 VLM API、什么时候
|
|
5
|
+
* 只用本地工具、要不要交叉验证"。这把语义集中在这里,供各工具 / 图片桥 /
|
|
6
|
+
* vision_analyze 共享,保证行为一致:
|
|
7
|
+
*
|
|
8
|
+
* - privacy(隐私):无论是否配置了外部 API 一律不调用。所有路线只走本地
|
|
9
|
+
* (image_scan / image_ocr / image_sample)。硬 gate,绝不外呼。
|
|
10
|
+
* - smart(智能):让 LLM 先简单看图(image_scan),再自己选是走外部 API、
|
|
11
|
+
* 自己看细节、还是 OCR,目标是减少调用轮数与耗时。
|
|
12
|
+
* - strict(严谨):LLM 自行选择路线,在必要时交叉验证(多证据对照),
|
|
13
|
+
* 可以仔细查看细节。
|
|
14
|
+
*
|
|
15
|
+
* @module picturereader/routing
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** 三模式取值。 */
|
|
19
|
+
export const MODES = Object.freeze({
|
|
20
|
+
privacy: 'privacy',
|
|
21
|
+
smart: 'smart',
|
|
22
|
+
strict: 'strict',
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
/** 三模式中文标签。 */
|
|
26
|
+
export const MODE_LABELS = Object.freeze({
|
|
27
|
+
privacy: '隐私模式',
|
|
28
|
+
smart: '智能模式',
|
|
29
|
+
strict: '严谨模式',
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/** 三模式英文标签。 */
|
|
33
|
+
export const MODE_LABELS_EN = Object.freeze({
|
|
34
|
+
privacy: 'Privacy',
|
|
35
|
+
smart: 'Smart',
|
|
36
|
+
strict: 'Strict',
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
/** 合法模式集合。 */
|
|
40
|
+
export const MODE_KEYS = Object.freeze(Object.keys(MODES));
|
|
41
|
+
|
|
42
|
+
/** 归一化任意输入为一个合法模式值;非法值回退默认 'smart'。 */
|
|
43
|
+
export function normalizeMode(raw) {
|
|
44
|
+
const v = String(raw ?? '').trim();
|
|
45
|
+
return MODE_KEYS.includes(v) ? v : MODES.smart;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 某模式下是否允许调用外部 VLM / 任何网络视觉 API。
|
|
50
|
+
* 隐私模式为硬门禁:即使配置了外部 API 也不调用。
|
|
51
|
+
* @param {string} mode - 归一化后的模式。
|
|
52
|
+
* @returns {boolean} true=允许外呼(smart/strict),false=禁用(privacy)。
|
|
53
|
+
*/
|
|
54
|
+
export function vlmAllowed(mode) {
|
|
55
|
+
return normalizeMode(mode) !== MODES.privacy;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 隐私模式下即使配置了外部 API 也要强制本地——这是对 vision_analyze /
|
|
60
|
+
* 图片桥的硬约束说明。
|
|
61
|
+
*/
|
|
62
|
+
export function isPrivacy(mode) {
|
|
63
|
+
return normalizeMode(mode) === MODES.privacy;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 某模式下 vision_analyze 的推荐证据默认。
|
|
68
|
+
* @param {string} mode
|
|
69
|
+
* @returns {{includeScan:boolean, includeOcr:boolean, includeVlm:boolean, allowLowInfo:boolean}}
|
|
70
|
+
*/
|
|
71
|
+
export function visionAnalyzeDefaults(mode) {
|
|
72
|
+
const m = normalizeMode(mode);
|
|
73
|
+
if (m === MODES.privacy) {
|
|
74
|
+
// 隐私:本地证据为主,VLM 永远归零。
|
|
75
|
+
return { includeScan: true, includeOcr: true, includeVlm: false, allowLowInfo: false };
|
|
76
|
+
}
|
|
77
|
+
if (m === MODES.smart) {
|
|
78
|
+
// 智能:先 scan,OCR 按需,能调外部 VLM(省轮数靠"值得才调"引导)。
|
|
79
|
+
return { includeScan: true, includeOcr: false, includeVlm: true, allowLowInfo: false };
|
|
80
|
+
}
|
|
81
|
+
// strict:全证据 + 允许多看细节,必要时交叉验证。
|
|
82
|
+
return { includeScan: true, includeOcr: true, includeVlm: true, allowLowInfo: false };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 把模式策略转成给纯文本 LLM 的中文行为引导(用于图片桥 hint、也用于
|
|
87
|
+
* vision_analyze 的描述构成,让模型据此决定路线)。
|
|
88
|
+
* @param {string} mode - 归一化后的模式。
|
|
89
|
+
* @param {{vlmConfigured: boolean}} [opts]
|
|
90
|
+
* @returns {string} 一段给模型的行为说明。
|
|
91
|
+
*/
|
|
92
|
+
export function routePolicyText(mode, opts = {}) {
|
|
93
|
+
const m = normalizeMode(mode);
|
|
94
|
+
const vlmConfigured = opts.vlmConfigured === undefined ? true : !!opts.vlmConfigured;
|
|
95
|
+
if (m === MODES.privacy) {
|
|
96
|
+
return (
|
|
97
|
+
'【当前模式:隐私模式】绝不调用任何外部视觉 API,也不访问网络看模型。' +
|
|
98
|
+
'对每张图只能使用本地工具:image_scan(看布局/颜色/结构)、image_ocr(读文字)、' +
|
|
99
|
+
'image_sample(细看材质纹理)。请用这些本地工具自行理解图片内容。'
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
if (m === MODES.smart) {
|
|
103
|
+
return (
|
|
104
|
+
'【当前模式:智能模式】先用 image_scan 快速看一眼图片(布局/颜色/是否含文字/是否照片)。' +
|
|
105
|
+
'然后自行判断:' +
|
|
106
|
+
'(1)若图片以文字为主 → 用 image_ocr 读文字即可,不必调 VLM;' +
|
|
107
|
+
'(2)若图片是普通图表/界面/简单内容 → 用 image_scan + image_sample 自己看就能说清,不必调 VLM;' +
|
|
108
|
+
'(3)仅当图片内容复杂、需要语义理解(如照片、抽象画面)' +
|
|
109
|
+
(vlmConfigured ? '且值得时,才调用 vision_analyze(include_vlm=true) 走外部 VLM' : ')时才尝试 VLM,但当前未配置外部 VLM,尽量用本地工具') +
|
|
110
|
+
'。目标是减少调用轮数与耗时,能本地就别外呼。'
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
return (
|
|
114
|
+
'【当前模式:严谨模式】自行选择路线并追求可靠:先用 image_scan 了解整体,' +
|
|
115
|
+
'必要时用 image_ocr 读文字、image_sample 细看细节。对关键判断采用交叉验证:' +
|
|
116
|
+
'把 image_scan / image_ocr ( / 外部 VLM) 多种证据相互对照,不轻易下结论。' +
|
|
117
|
+
(vlmConfigured ? '需要语义理解且值得时可用 vision_analyze(include_vlm=true) 走外部 VLM。' : '当前未配置外部 VLM,优先用本地工具自行理解。') +
|
|
118
|
+
'可以仔细查看细节,但要避免幻觉、给出有依据的描述。'
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 渲染一条批量/桥接时用的简短模式说明(首行),供 hint 复用。
|
|
124
|
+
* @param {string} mode
|
|
125
|
+
* @returns {string}
|
|
126
|
+
*/
|
|
127
|
+
export function routeModeTag(mode) {
|
|
128
|
+
const m = normalizeMode(mode);
|
|
129
|
+
return `[模式:${MODE_LABELS[m]}]`;
|
|
130
|
+
}
|
package/src/runtime.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* picturereader 运行时配置快照 (runtime.js)
|
|
3
|
+
*
|
|
4
|
+
* host 侧(index.js)注册 picturereader 设置命名空间后,把每次读取到的
|
|
5
|
+
* 最新配置(mode + VLM 显式配置)注入本模块。各工具 / vlm.js /
|
|
6
|
+
* vision-analyze.js / 图片桥在 execute 时读 getRuntimeConfig() 得到当前
|
|
7
|
+
* 有效的模式与视觉端点,从而做到"改设置热生效 + privacy 硬 gate"。
|
|
8
|
+
*
|
|
9
|
+
* 两种注入方式:
|
|
10
|
+
* - setRuntimeConfig(cfg):直接替换快照(测试 / 手动)。
|
|
11
|
+
* - setRuntimeSource(fn):注册一个返回原始 config 的 getter(host 用
|
|
12
|
+
* installSettingsSection 的 getConfig),读取时惰性缓存最新值,保证
|
|
13
|
+
* mode / vlm 配置热更立即生效。
|
|
14
|
+
*
|
|
15
|
+
* 设计要点:vlm.js 顶部的静态 DEFAULT_BASE 等只是后盾(env / settings.yaml /
|
|
16
|
+
* GLM 默认),runtime 里显式填写的 vlm 配置优先级最高;而 `mode` 的快照使
|
|
17
|
+
* privacy 模式在任意调用点都能被识别,彻底拦截外部调用。
|
|
18
|
+
*
|
|
19
|
+
* @module picturereader/runtime
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { normalizeMode } from './routing.js';
|
|
23
|
+
|
|
24
|
+
let current = { mode: 'smart', vlm: { baseUrl: '', model: '', apiKey: '', apiKeyEnv: '' } };
|
|
25
|
+
let sourceFn = null;
|
|
26
|
+
|
|
27
|
+
/** 从原始扁平 config 构建快照。 */
|
|
28
|
+
function build(raw) {
|
|
29
|
+
const v = raw || {};
|
|
30
|
+
return {
|
|
31
|
+
mode: normalizeMode(v.mode),
|
|
32
|
+
vlm: {
|
|
33
|
+
baseUrl: String(v.vlm_base ?? ''),
|
|
34
|
+
model: String(v.vlm_model ?? ''),
|
|
35
|
+
apiKey: String(v.vlm_key ?? ''),
|
|
36
|
+
apiKeyEnv: String(v.vlm_key_env ?? ''),
|
|
37
|
+
enabled: v.vlm_enabled === undefined ? Boolean(v.vlm_base) : v.vlm_enabled === true,
|
|
38
|
+
requestTimeoutMs: v.vlm_timeout_ms !== undefined ? Number(v.vlm_timeout_ms) : undefined,
|
|
39
|
+
maxTokens: v.vlm_max_tokens !== undefined ? Number(v.vlm_max_tokens) : undefined,
|
|
40
|
+
},
|
|
41
|
+
bridge: {
|
|
42
|
+
exportDir: String(v.bridge_export_dir ?? ''),
|
|
43
|
+
},
|
|
44
|
+
ocr: {
|
|
45
|
+
engine: String(v.ocr_engine ?? 'windows'),
|
|
46
|
+
language: String(v.ocr_language ?? ''),
|
|
47
|
+
},
|
|
48
|
+
scan: {
|
|
49
|
+
defaultSize: v.scan_default_size !== undefined ? Number(v.scan_default_size) : 32,
|
|
50
|
+
palette: String(v.scan_palette ?? 'auto'),
|
|
51
|
+
mode: String(v.scan_mode ?? 'auto'),
|
|
52
|
+
},
|
|
53
|
+
batch: {
|
|
54
|
+
probeFirst: v.batch_probe_first !== undefined ? Number(v.batch_probe_first) : 3,
|
|
55
|
+
ocrLimitChars: v.batch_ocr_limit_chars !== undefined ? Number(v.batch_ocr_limit_chars) : 800,
|
|
56
|
+
},
|
|
57
|
+
doc: {
|
|
58
|
+
dpi: v.doc_dpi !== undefined ? Number(v.doc_dpi) : 150,
|
|
59
|
+
maxPages: v.doc_max_pages !== undefined ? Number(v.doc_max_pages) : 50,
|
|
60
|
+
},
|
|
61
|
+
maxImageBytes: v.max_image_bytes !== undefined ? Number(v.max_image_bytes) : 52428800,
|
|
62
|
+
multimodalModels: String(v.multimodal_models ?? '').split(',').map(s => s.trim()).filter(Boolean),
|
|
63
|
+
requestGuard: v.request_guard !== undefined ? Boolean(v.request_guard) : true,
|
|
64
|
+
debug: v.debug === true,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 若注册了 source getter,则先同步一次最新值。 */
|
|
69
|
+
function refresh() {
|
|
70
|
+
if (sourceFn) {
|
|
71
|
+
try {
|
|
72
|
+
const raw = sourceFn();
|
|
73
|
+
if (raw !== undefined && raw !== null) current = build(raw);
|
|
74
|
+
} catch {
|
|
75
|
+
// 读取失败则沿用上次快照。
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 注册一个返回原始 config 的 getter(host:() => getConfig())。
|
|
82
|
+
* @param {() => object|null} fn
|
|
83
|
+
*/
|
|
84
|
+
export function setRuntimeSource(fn) {
|
|
85
|
+
sourceFn = typeof fn === 'function' ? fn : null;
|
|
86
|
+
refresh();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 直接替换运行时快照(测试 / 手动)。
|
|
91
|
+
* @param {object} cfg - 支持 {mode, vlm:{...}} 结构,或扁平原始 config
|
|
92
|
+
* (含 vlm_base/vlm_model/vlm_key/vlm_key_env)。
|
|
93
|
+
*/
|
|
94
|
+
export function setRuntimeConfig(cfg = {}) {
|
|
95
|
+
// 兼容扁平原始 config(含 vlm_base/vlm_key 等键)与 (mode, vlm) 结构两种形态。
|
|
96
|
+
if (cfg && cfg.vlm === undefined && (cfg.vlm_base !== undefined || cfg.mode !== undefined)) {
|
|
97
|
+
current = build(cfg);
|
|
98
|
+
} else {
|
|
99
|
+
const vlm = {
|
|
100
|
+
baseUrl: String(cfg?.vlm?.baseUrl ?? ''),
|
|
101
|
+
model: String(cfg?.vlm?.model ?? ''),
|
|
102
|
+
apiKey: String(cfg?.vlm?.apiKey ?? ''),
|
|
103
|
+
apiKeyEnv: String(cfg?.vlm?.apiKeyEnv ?? ''),
|
|
104
|
+
// 选配:显式给定则用之;未给定时向后兼容"配了 baseUrl 即视为启用"。
|
|
105
|
+
enabled: cfg?.vlm?.enabled ?? Boolean(cfg?.vlm?.baseUrl),
|
|
106
|
+
};
|
|
107
|
+
current = { mode: normalizeMode(cfg?.mode), vlm };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** 读取运行时快照(返回内部引用;调用方不应修改)。 */
|
|
112
|
+
export function getRuntimeConfig() {
|
|
113
|
+
refresh();
|
|
114
|
+
return current;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** 当前有效模式(已归一化)。 */
|
|
118
|
+
export function currentMode() {
|
|
119
|
+
refresh();
|
|
120
|
+
return current.mode;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** 当前模式下是否允许外部 VLM(privacy 恒 false)。 */
|
|
124
|
+
export function vlmAllowedByRuntime() {
|
|
125
|
+
refresh();
|
|
126
|
+
return current.mode !== 'privacy';
|
|
127
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-host-apiproxy exposes only namespaces listed in its hard-coded
|
|
3
|
+
* WEB_SETTINGS_NAMESPACES allowlist to the Web settings client. A namespace
|
|
4
|
+
* registered by a third-party plugin answers `settings-not-exposed` even
|
|
5
|
+
* though it is registered — upstream explicitly defers letting plugins
|
|
6
|
+
* expose their own configuration.
|
|
7
|
+
*
|
|
8
|
+
* This module idempotently patches that allowlist in the dsh installation
|
|
9
|
+
* actually loaded by the host process, so the plugin's settings section
|
|
10
|
+
* becomes visible in the Web UI without manual edits. A dsh update
|
|
11
|
+
* overwrites the file; the next plugin start re-patches it.
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { createRequire } from "node:module";
|
|
15
|
+
import { dirname, join, sep } from "node:path";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Ensure `nsName` is present in dsh-host-apiproxy's WEB_SETTINGS_NAMESPACES.
|
|
19
|
+
* No-op when already exposed or when the file cannot be located/patched.
|
|
20
|
+
* @param {import("cordis").Context} ctx
|
|
21
|
+
* @param {string} nsName - settings namespace short name (e.g. "tdai-memory").
|
|
22
|
+
* @param {{info?: Function, warn?: Function}} logger - dsh logger.
|
|
23
|
+
*/
|
|
24
|
+
export function ensureSettingsNamespaceExposed(ctx, nsName, logger) {
|
|
25
|
+
void ctx;
|
|
26
|
+
try {
|
|
27
|
+
const target = findApiproxyIndex();
|
|
28
|
+
if (!target) {
|
|
29
|
+
logger?.warn?.(`[settings-expose] could not locate dsh-host-apiproxy; add "${nsName}" to WEB_SETTINGS_NAMESPACES in dsh-host-apiproxy/lib/index.js to get a Web settings section`);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
let src;
|
|
33
|
+
try {
|
|
34
|
+
src = readFileSync(target, "utf8");
|
|
35
|
+
} catch (error) {
|
|
36
|
+
logger?.warn?.(`[settings-expose] cannot read ${target}: ${String(error)}`);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const body = src.match(/const WEB_SETTINGS_NAMESPACES = \[([\s\S]*?)\];/)?.[1] ?? "";
|
|
40
|
+
if (body.includes(`"${nsName}"`)) return; // already exposed (manual or previous patch)
|
|
41
|
+
const patched = src.replace(/(const WEB_SETTINGS_NAMESPACES = \[[\s\S]*?)(\n\s*\];)/, (_, pre, post) => {
|
|
42
|
+
const trailingComma = /,\s*$/.test(pre) ? "" : ",";
|
|
43
|
+
const sep2 = pre.trimEnd().endsWith("[") ? "" : trailingComma;
|
|
44
|
+
return `${pre}${sep2}\n\t"${nsName}"${post}`;
|
|
45
|
+
});
|
|
46
|
+
if (patched === src) {
|
|
47
|
+
logger?.warn?.(`[settings-expose] allowlist pattern not found in ${target}; add "${nsName}" to WEB_SETTINGS_NAMESPACES manually`);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
writeFileSync(target, patched, "utf8");
|
|
51
|
+
logger?.info?.(`[settings-expose] added "${nsName}" to WEB_SETTINGS_NAMESPACES (${target}) — restart dsh web for the settings section to appear`);
|
|
52
|
+
} catch (error) {
|
|
53
|
+
logger?.warn?.(`[settings-expose] failed: ${String(error)}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function findApiproxyIndex() {
|
|
58
|
+
// 1) The host process has already loaded dsh-host-apiproxy: read the real
|
|
59
|
+
// module path from the CommonJS module cache (any install layout).
|
|
60
|
+
try {
|
|
61
|
+
const Module = createRequire(import.meta.url)("module");
|
|
62
|
+
const cache = Module._cache ?? {};
|
|
63
|
+
for (const key of Object.keys(cache)) {
|
|
64
|
+
if (key.includes(`${sep}dsh-host-apiproxy${sep}`) && key.endsWith(`${sep}index.js`)) return key;
|
|
65
|
+
}
|
|
66
|
+
} catch { /* fall through */ }
|
|
67
|
+
// 2) Fallback: sibling of @deepseek-ai/dsh-settings (dsh's nested layout).
|
|
68
|
+
try {
|
|
69
|
+
const require = createRequire(import.meta.url);
|
|
70
|
+
const settingsEntry = require.resolve("@deepseek-ai/dsh-settings");
|
|
71
|
+
const candidate = join(dirname(dirname(dirname(settingsEntry))), "dsh-host-apiproxy", "lib", "index.js");
|
|
72
|
+
if (existsSync(candidate)) return candidate;
|
|
73
|
+
} catch { /* fall through */ }
|
|
74
|
+
return "";
|
|
75
|
+
}
|
package/src/tool.js
CHANGED
|
@@ -255,9 +255,9 @@ export function createImageOcrTool(ctx) {
|
|
|
255
255
|
return {
|
|
256
256
|
name: 'image_ocr',
|
|
257
257
|
description: [
|
|
258
|
-
'Recognize text in a local image.
|
|
259
|
-
'Use it together with image_scan: when the pixel grid shows a dense, regular, high-contrast structure that looks like text (e.g. titles, labels, buttons, dialogs, glowing banners), call image_ocr on that region and read the actual characters. If the Windows engine returns nothing but text is expected, retry with engine="paddle".',
|
|
260
|
-
'Parameters: file_path (required), region: [x0, y0, x1, y1] (0..1 fractions) or focus: [row0, col0, row1, col1] (grid coordinates) to restrict recognition to an area, language (optional BCP-47 tag like "zh-Hans" or "en-US", Windows engine only), engine ("windows" default, "paddle").',
|
|
258
|
+
'Recognize text in a local image. Three engines: engine="windows" (default) uses the Windows built-in OCR (no install, good for printed/UI text); engine="paddle" uses PaddleOCR via the local paddle_venv (much better for glowing, curved, stylized or game-rendered text and complex backgrounds, Chinese-friendly; ~2s model load per call); engine="rapid" uses RapidOCR via the local rapid_venv (bundled ONNX models, no network download, fast).',
|
|
259
|
+
'Use it together with image_scan: when the pixel grid shows a dense, regular, high-contrast structure that looks like text (e.g. titles, labels, buttons, dialogs, glowing banners), call image_ocr on that region and read the actual characters. If the Windows engine returns nothing but text is expected, retry with engine="paddle" or engine="rapid".',
|
|
260
|
+
'Parameters: file_path (required), region: [x0, y0, x1, y1] (0..1 fractions) or focus: [row0, col0, row1, col1] (grid coordinates) to restrict recognition to an area, language (optional BCP-47 tag like "zh-Hans" or "en-US", Windows engine only), engine ("windows" default, "paddle", "rapid").',
|
|
261
261
|
'The result lists each recognized line with its pixel bounding box and confidence score (paddle).'
|
|
262
262
|
].join(' '),
|
|
263
263
|
parameters: {
|
|
@@ -284,8 +284,8 @@ export function createImageOcrTool(ctx) {
|
|
|
284
284
|
},
|
|
285
285
|
engine: {
|
|
286
286
|
type: 'string',
|
|
287
|
-
enum: ['windows', 'paddle'],
|
|
288
|
-
description: '"windows" (default) = Windows built-in OCR; "paddle" = PaddleOCR via local paddle_venv (better for glowing/curved/game text).'
|
|
287
|
+
enum: ['windows', 'paddle', 'rapid'],
|
|
288
|
+
description: '"windows" (default) = Windows built-in OCR; "paddle" = PaddleOCR via local paddle_venv (better for glowing/curved/game text); "rapid" = RapidOCR via local rapid_venv (bundled ONNX models, fast).'
|
|
289
289
|
}
|
|
290
290
|
},
|
|
291
291
|
required: ['file_path']
|
|
@@ -299,7 +299,7 @@ export function createImageOcrTool(ctx) {
|
|
|
299
299
|
width: { type: 'integer' },
|
|
300
300
|
height: { type: 'integer' },
|
|
301
301
|
region: { type: 'string' },
|
|
302
|
-
engine: { type: 'string', enum: ['windows', 'paddle'] },
|
|
302
|
+
engine: { type: 'string', enum: ['windows', 'paddle', 'rapid'] },
|
|
303
303
|
note: { type: 'string' },
|
|
304
304
|
lines: {
|
|
305
305
|
type: 'array',
|
|
@@ -349,8 +349,8 @@ export function createImageOcrTool(ctx) {
|
|
|
349
349
|
throw new Error('image_ocr: language must be a non-empty BCP-47 tag');
|
|
350
350
|
}
|
|
351
351
|
const engine = args.engine === undefined ? 'windows' : String(args.engine);
|
|
352
|
-
if (engine !== 'windows' && engine !== 'paddle') {
|
|
353
|
-
throw new Error("image_ocr: engine must be 'windows' (default) or 'paddle'");
|
|
352
|
+
if (engine !== 'windows' && engine !== 'paddle' && engine !== 'rapid') {
|
|
353
|
+
throw new Error("image_ocr: engine must be 'windows' (default) or 'paddle' or 'rapid'");
|
|
354
354
|
}
|
|
355
355
|
|
|
356
356
|
const cwd = exec.agent?.session?.header?.cwd;
|
|
@@ -387,13 +387,18 @@ export function createImageOcrTool(ctx) {
|
|
|
387
387
|
regionDisplay = 'full';
|
|
388
388
|
}
|
|
389
389
|
|
|
390
|
-
// PaddleOCR
|
|
391
|
-
// engine (with a note) when
|
|
390
|
+
// PaddleOCR / RapidOCR are optional engines: degrade gracefully to the
|
|
391
|
+
// Windows engine (with a note) when they are missing or fail — never crash.
|
|
392
|
+
const OPTIONAL = {
|
|
393
|
+
paddle: { available: () => core.paddleAvailable(), install: 'node scripts/setup-ocr.mjs' },
|
|
394
|
+
rapid: { available: () => core.rapidAvailable(), install: 'node scripts/setup-rapid.mjs' }
|
|
395
|
+
};
|
|
392
396
|
let effectiveEngine = engine;
|
|
393
397
|
let note;
|
|
394
|
-
|
|
398
|
+
const opt = OPTIONAL[engine];
|
|
399
|
+
if (opt !== undefined && !(await opt.available())) {
|
|
395
400
|
effectiveEngine = 'windows';
|
|
396
|
-
note =
|
|
401
|
+
note = `${engine[0].toUpperCase()}${engine.slice(1)}OCR is not installed (engine="${engine}" requested) — fell back to Windows OCR. To install it, run: ${opt.install} (see README).`;
|
|
397
402
|
}
|
|
398
403
|
let result;
|
|
399
404
|
try {
|
|
@@ -403,9 +408,9 @@ export function createImageOcrTool(ctx) {
|
|
|
403
408
|
engine: effectiveEngine
|
|
404
409
|
});
|
|
405
410
|
} catch (error) {
|
|
406
|
-
if (
|
|
411
|
+
if (opt !== undefined && effectiveEngine === engine) {
|
|
407
412
|
effectiveEngine = 'windows';
|
|
408
|
-
note =
|
|
413
|
+
note = `${engine[0].toUpperCase()}${engine.slice(1)}OCR failed (${error.message.slice(0, 140)}) — fell back to Windows OCR.`;
|
|
409
414
|
result = await core.ocrImage(data, ext, {
|
|
410
415
|
region: regionArray,
|
|
411
416
|
language: args.language === undefined ? undefined : String(args.language).trim(),
|
package/src/vision-analyze.js
CHANGED
|
@@ -21,7 +21,9 @@
|
|
|
21
21
|
import { extname } from 'node:path';
|
|
22
22
|
import { BYTE_CAP, MAX_PIXELS } from './tool.js';
|
|
23
23
|
import { isLowInformationImage } from './guard.js';
|
|
24
|
-
import { ensureServer, stopServer, sendVisionRequest, defaultVlmConfig, isVlmConfigured } from './vlm.js';
|
|
24
|
+
import { ensureServer, stopServer, sendVisionRequest, defaultVlmConfig, isVlmConfigured, DEFAULT_BASE, DEFAULT_API_KEY } from './vlm.js';
|
|
25
|
+
import { getRuntimeConfig } from './runtime.js';
|
|
26
|
+
import { visionAnalyzeDefaults, isPrivacy, routePolicyText } from './routing.js';
|
|
25
27
|
|
|
26
28
|
const CORE_URL = new URL('./core.js', import.meta.url).href;
|
|
27
29
|
let coreCache = { url: null, mtime: -1, module: null };
|
|
@@ -94,8 +96,8 @@ export function createVisionAnalyzeTool(ctx) {
|
|
|
94
96
|
},
|
|
95
97
|
ocr_engine: {
|
|
96
98
|
type: 'string',
|
|
97
|
-
enum: ['windows', 'paddle'],
|
|
98
|
-
description: 'OCR engine: windows (default) or
|
|
99
|
+
enum: ['windows', 'paddle', 'rapid'],
|
|
100
|
+
description: 'OCR engine: windows (default), paddle or rapid (see image_ocr for details).'
|
|
99
101
|
},
|
|
100
102
|
include_vlm: {
|
|
101
103
|
type: 'boolean',
|
|
@@ -168,15 +170,23 @@ export function createVisionAnalyzeTool(ctx) {
|
|
|
168
170
|
);
|
|
169
171
|
}
|
|
170
172
|
|
|
171
|
-
const
|
|
172
|
-
const
|
|
173
|
-
const
|
|
173
|
+
const rt = getRuntimeConfig();
|
|
174
|
+
const mode = rt?.mode ?? 'smart';
|
|
175
|
+
const defaults = visionAnalyzeDefaults(mode);
|
|
176
|
+
const privacy = isPrivacy(mode);
|
|
177
|
+
const includeScan = args.include_scan === undefined ? defaults.includeScan : boolArg(args.include_scan, true);
|
|
178
|
+
const includeOcr = args.include_ocr === undefined ? defaults.includeOcr : boolArg(args.include_ocr, false);
|
|
179
|
+
// privacy 硬 gate:不管 include_vlm 传什么、外部是否配置,一律不调用 VLM。
|
|
180
|
+
const includeVlm = privacy ? false : (args.include_vlm === undefined ? defaults.includeVlm : boolArg(args.include_vlm, true));
|
|
174
181
|
const allowLowInfo = boolArg(args.allow_low_info, false);
|
|
175
182
|
const stopAfter = boolArg(args.stop_after, false);
|
|
176
183
|
const prompt = args.prompt ?? 'Describe this image in detail.';
|
|
177
184
|
|
|
178
|
-
//
|
|
179
|
-
const
|
|
185
|
+
// 当前模式的调用策略,注入到返回文本里给主模型做路由引导。
|
|
186
|
+
const modePolicy = routePolicyText(mode, { vlmConfigured: isVlmConfigured() });
|
|
187
|
+
|
|
188
|
+
// Check if VLM is configured (privacy 下恒不可用)
|
|
189
|
+
const vlmAvailable = privacy ? false : isVlmConfigured();
|
|
180
190
|
const shouldCallVlm = includeVlm && vlmAvailable;
|
|
181
191
|
|
|
182
192
|
const lowInfo = isLowInformationImage(image.data, image.width, image.height);
|
|
@@ -194,11 +204,12 @@ export function createVisionAnalyzeTool(ctx) {
|
|
|
194
204
|
}
|
|
195
205
|
|
|
196
206
|
if (includeScan) {
|
|
207
|
+
const rtScan = rt?.scan || {};
|
|
197
208
|
const analysis = core.analyzeImage(image.data, image.width, image.height, {
|
|
198
|
-
size: 32,
|
|
199
|
-
mode: 'auto',
|
|
209
|
+
size: rtScan.defaultSize || 32,
|
|
210
|
+
mode: rtScan.mode || 'auto',
|
|
200
211
|
region: undefined,
|
|
201
|
-
palette: 'auto'
|
|
212
|
+
palette: rtScan.palette || 'auto'
|
|
202
213
|
});
|
|
203
214
|
scanText = core.renderImageScan({
|
|
204
215
|
path: target.displayPath,
|
|
@@ -242,11 +253,22 @@ export function createVisionAnalyzeTool(ctx) {
|
|
|
242
253
|
}
|
|
243
254
|
}
|
|
244
255
|
} else if (includeVlm && !vlmAvailable) {
|
|
245
|
-
|
|
256
|
+
const hasBase = DEFAULT_BASE.length > 0;
|
|
257
|
+
const hasKey = DEFAULT_API_KEY.length > 0;
|
|
258
|
+
if (hasBase && !hasKey) {
|
|
259
|
+
blocks.push('[vlm] VLM 未就绪:已配置端点但缺少 API key。\n' +
|
|
260
|
+
'要使用免费的 GLM-4V-Flash 视觉模型,请:\n' +
|
|
261
|
+
'1. 访问 https://open.bigmodel.cn 注册智谱账号\n' +
|
|
262
|
+
'2. 获取 API Key\n' +
|
|
263
|
+
'3. 设置环境变量:GLM_API_KEY=你的key 或 SEE_API_KEY=你的key\n' +
|
|
264
|
+
'4. 重启 DSH 生效');
|
|
265
|
+
} else {
|
|
266
|
+
blocks.push('[vlm] VLM 未配置(SEE_BASE 环境变量为空)');
|
|
267
|
+
}
|
|
246
268
|
}
|
|
247
269
|
|
|
248
270
|
ctx.emit('fs/observed', target, { kind: 'present', version: info.version }, exec);
|
|
249
|
-
const combined = blocks.join('\n\n---\n\n');
|
|
271
|
+
const combined = [modePolicy, ...blocks].join('\n\n---\n\n');
|
|
250
272
|
return {
|
|
251
273
|
path: target.displayPath,
|
|
252
274
|
lowInformation: false,
|