@mokuyoaxis/dsh-iris 0.2.0 → 0.2.1
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/.dsh/skills/iris-compose-media/SKILL.md +10 -5
- package/.dsh/skills/iris-compose-media/references/workflows.md +7 -1
- package/.dsh/skills/iris-verify-ui/SKILL.md +2 -1
- package/CHANGELOG.md +47 -2
- package/README.en.md +65 -29
- package/README.md +65 -29
- package/bin/dsh-iris.js +222 -21
- package/docs/AGENT_QUICKSTART.md +10 -6
- package/docs/ARCHITECTURE.md +25 -18
- package/docs/ARTIFACT_LIBRARY.md +5 -5
- package/docs/ARTIFACT_MANIFEST.md +1 -1
- package/docs/CLI_MANAGEMENT.md +181 -0
- package/docs/COMPOSITE_VISION.md +35 -0
- package/docs/CORE_RUNTIME_CONTRACT.md +8 -7
- package/docs/DSH_CORE_MIGRATION.md +48 -19
- package/docs/DSH_RC2_ADAPTATION.md +4 -2
- package/docs/FAULT_INJECTION.md +12 -2
- package/docs/HEADLESS_CLI.md +13 -7
- package/docs/HOST_ADAPTER_CONTRACT.md +10 -10
- package/docs/HOST_DOCTOR.md +3 -3
- package/docs/MODEL_PORT_CONTRACT.md +19 -14
- package/docs/OCR_MODEL.md +39 -0
- package/docs/PROMPT_OPTIMIZER.md +47 -0
- package/docs/PROVIDER_ADAPTER_CONTRACT.md +6 -4
- package/docs/PROVIDER_HEALTH.md +22 -5
- package/docs/PROVIDER_RUNTIME_HARNESS.md +1 -1
- package/docs/PROVIDER_SUBMISSION_CONTRACT.md +2 -2
- package/docs/ROADMAP.md +29 -19
- package/docs/SECURITY.md +5 -5
- package/docs/TASK_SEMANTICS.md +2 -2
- package/docs/VISION_CLI.md +132 -0
- package/docs/VISION_MODEL.md +51 -0
- package/docs/WORKBENCH_ARTIFACTS.md +67 -0
- package/docs/file-access-across-environments.md +8 -5
- package/docs/releases/0.2.1.md +56 -0
- package/lib/actions.js +187 -270
- package/lib/adapters.js +133 -22
- package/lib/api.js +49 -7
- package/lib/chromium-browser.js +117 -0
- package/lib/client.js +671 -101
- package/lib/command-service.js +121 -7
- package/lib/composite-vision-routing.js +42 -0
- package/lib/config.js +89 -10
- package/lib/core-artifact-store.js +45 -13
- package/lib/core-maintenance.js +213 -0
- package/lib/core-tasks.js +12 -5
- package/lib/core-user-projection.js +1 -0
- package/lib/dsh-core-adapter.js +63 -9
- package/lib/dsh-text-model-adapter.js +222 -0
- package/lib/dsh-vision-model-adapter.js +75 -0
- package/lib/generation-input.js +36 -5
- package/lib/headless-vision.js +181 -0
- package/lib/host-runtime.js +1 -0
- package/lib/http-vision-model-adapter.js +187 -0
- package/lib/image-edit-input.js +34 -0
- package/lib/index.js +205 -175
- package/lib/locate.js +86 -28
- package/lib/media-probe.js +2 -1
- package/lib/model-call-runtime.js +45 -0
- package/lib/model-invoker.js +4 -2
- package/lib/model-port-contract.js +13 -5
- package/lib/models.js +10 -5
- package/lib/ocr-model-routing.js +26 -0
- package/lib/ocr.js +110 -64
- package/lib/prompt-optimizer-config.js +2 -1
- package/lib/prompt-optimizer-core.js +121 -0
- package/lib/prompt-optimizer.js +37 -193
- package/lib/provider-adapters.js +85 -9
- package/lib/provider-catalog.js +19 -7
- package/lib/provider-config-service.js +236 -0
- package/lib/provider-contract.js +3 -2
- package/lib/provider-health.js +80 -1
- package/lib/provider-protocol.js +23 -0
- package/lib/provider-task-runner.js +58 -16
- package/lib/summarize.js +31 -20
- package/lib/summary-input.js +52 -0
- package/lib/task-wait.js +40 -0
- package/lib/video-input.js +30 -0
- package/lib/vision-core.js +42 -0
- package/lib/vision-image-input.js +83 -0
- package/lib/vision-input.js +31 -0
- package/lib/vision-model-routing.js +180 -0
- package/lib/vision.js +3 -2
- package/lib/workbench-artifacts.js +52 -0
- package/lib/workbench-management.js +151 -0
- package/lib/workbench-zip.js +39 -0
- package/package.json +1 -1
- package/user_guide.md +52 -22
package/lib/locate.js
CHANGED
|
@@ -6,20 +6,80 @@
|
|
|
6
6
|
* 保证 iris_crop 可以无缝接力裁剪(vision-router 的 vision_ground 同款契约)。
|
|
7
7
|
*
|
|
8
8
|
* 实现:
|
|
9
|
-
* -
|
|
9
|
+
* - 消费共享 Vision Ports;
|
|
10
10
|
* - 提示词强制模型只回严格 JSON bbox;
|
|
11
11
|
* - 解析/校验/钳制:JSON 提取、字段数字校验、x1<x2 且 y1<y2、轻微越界 clamp、
|
|
12
12
|
* 完全越界报错、found=false 明确返回。
|
|
13
13
|
*/
|
|
14
|
-
import {
|
|
14
|
+
import { completeVision } from './vision-core.js';
|
|
15
|
+
import { ModelPortError } from './model-port-contract.js';
|
|
16
|
+
import { createVisionInputObserver } from './vision-image-input.js';
|
|
15
17
|
|
|
16
|
-
export class LocateError extends
|
|
18
|
+
export class LocateError extends ModelPortError {
|
|
17
19
|
constructor(message) {
|
|
18
|
-
super(
|
|
20
|
+
super('IRIS_MODEL_PROTOCOL_INVALID', { stage: 'normalize', invocation: 'responded' });
|
|
21
|
+
this.message = message;
|
|
19
22
|
this.name = 'LocateError';
|
|
20
23
|
}
|
|
21
24
|
}
|
|
22
25
|
|
|
26
|
+
export function normalizeLocateTarget(target) {
|
|
27
|
+
const value = String(target || '').trim();
|
|
28
|
+
if (!value) throw new ModelPortError('IRIS_MODEL_INPUT_INVALID');
|
|
29
|
+
return value.slice(0, 200);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function locatePrompt(target, width, height, normalized = false) {
|
|
33
|
+
const coordinates = normalized
|
|
34
|
+
? `坐标必须使用 0–1000 归一化坐标:x=像素x/${width}*1000,y=像素y/${height}*1000;左上角(0,0),右下角(1000,1000),不得直接输出像素坐标`
|
|
35
|
+
: '本次输入图片的像素坐标,图片左上角为(0,0)';
|
|
36
|
+
return `在图片中定位「${target}」。只返回一个 JSON 对象,不要输出任何其他文字或解释。格式:`
|
|
37
|
+
+ `{"x1":左上角x,"y1":左上角y,"x2":右下角x,"y2":右下角y}`
|
|
38
|
+
+ `(图片尺寸 ${width}x${height};${coordinates};要求 x1<x2 且 y1<y2)。`
|
|
39
|
+
+ `请确保 bbox 完全包围目标,不得遗漏目标的任何部分。如果图片中没有该目标,返回 {"found":false}。`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 输入已显式转正;将四角逆变换回原始像素,向外取整以供原图裁剪。 */
|
|
43
|
+
export function mapLocateBbox(bbox, input) {
|
|
44
|
+
if (bbox.found === false) return bbox;
|
|
45
|
+
const { width: W, height: H } = input.source;
|
|
46
|
+
const orientation = input.orientation;
|
|
47
|
+
const swapped = orientation >= 5 && orientation <= 8;
|
|
48
|
+
const sx = (swapped ? H : W) / (input.coordinateSpace === 'normalized-1000' ? 1000 : input.sent.width);
|
|
49
|
+
const sy = (swapped ? W : H) / (input.coordinateSpace === 'normalized-1000' ? 1000 : input.sent.height);
|
|
50
|
+
const inverse = (x, y) => {
|
|
51
|
+
switch (orientation) {
|
|
52
|
+
case 2: return [W - x, y];
|
|
53
|
+
case 3: return [W - x, H - y];
|
|
54
|
+
case 4: return [x, H - y];
|
|
55
|
+
case 5: return [y, x];
|
|
56
|
+
case 6: return [y, H - x];
|
|
57
|
+
case 7: return [W - y, H - x];
|
|
58
|
+
case 8: return [W - y, x];
|
|
59
|
+
default: return [x, y];
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
const corners = [[bbox.x1, bbox.y1], [bbox.x1, bbox.y2], [bbox.x2, bbox.y1], [bbox.x2, bbox.y2]]
|
|
63
|
+
.map(([x, y]) => inverse(x * sx, y * sy));
|
|
64
|
+
const xs = corners.map(([x]) => x), ys = corners.map(([, y]) => y);
|
|
65
|
+
// 消除比例换算在整数边缘的浮点误差,再对真实小数向外取整。
|
|
66
|
+
const edge = (value, round) => Math.abs(value - Math.round(value)) < 1e-9 ? Math.round(value) : round(value);
|
|
67
|
+
return { found: true, x1: Math.max(0, edge(Math.min(...xs), Math.floor)), y1: Math.max(0, edge(Math.min(...ys), Math.floor)),
|
|
68
|
+
x2: Math.min(W, edge(Math.max(...xs), Math.ceil)), y2: Math.min(H, edge(Math.max(...ys), Math.ceil)) };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 工厂钩子局限于本次业务,协议适配器不解析 bbox;候选各用自己的真实输入。 */
|
|
72
|
+
export function createLocateImageRequest(target, inputs) {
|
|
73
|
+
target = normalizeLocateTarget(target);
|
|
74
|
+
const observe = createVisionInputObserver(inputs);
|
|
75
|
+
return async args => {
|
|
76
|
+
const input = await observe(args);
|
|
77
|
+
input.coordinateSpace = 'normalized-1000';
|
|
78
|
+
return { prompt: locatePrompt(target, input.sent.width, input.sent.height, true),
|
|
79
|
+
mapText: text => JSON.stringify(mapLocateBbox(parseLocateBbox(text, 1000, 1000, { round: false }), input)) };
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
23
83
|
/** 从模型回答里提取第一个平衡的 {...} JSON 对象文本;无则 null */
|
|
24
84
|
export function extractBboxJson(answer) {
|
|
25
85
|
const text = String(answer || '');
|
|
@@ -38,57 +98,55 @@ export function extractBboxJson(answer) {
|
|
|
38
98
|
|
|
39
99
|
/**
|
|
40
100
|
* 在原图上定位目标,返回原像素 bbox。
|
|
41
|
-
* @param {Array}
|
|
42
|
-
* @param {{target:string,
|
|
43
|
-
* @returns {Promise<
|
|
101
|
+
* @param {Array} ports 有序 Vision Ports
|
|
102
|
+
* @param {{target:string, image:object, width:number, height:number}}
|
|
103
|
+
* @returns {Promise<object>} 完整模型结果及 bbox
|
|
44
104
|
* @throws LocateError 模型未返回有效 bbox / bbox 完全越界 / 字段非法
|
|
45
105
|
*/
|
|
46
|
-
export async function locateObject(
|
|
106
|
+
export async function locateObject(ports, { target, image, width, height }, options = {}) {
|
|
107
|
+
target = normalizeLocateTarget(target);
|
|
47
108
|
const W = Math.floor(width);
|
|
48
109
|
const H = Math.floor(height);
|
|
49
110
|
if (!Number.isFinite(W) || !Number.isFinite(H) || W <= 0 || H <= 0) {
|
|
50
|
-
throw new
|
|
111
|
+
throw new ModelPortError('IRIS_MODEL_INPUT_INVALID');
|
|
51
112
|
}
|
|
52
|
-
const prompt =
|
|
53
|
-
`在图片中定位「${String(target).slice(0, 200)}」。` +
|
|
54
|
-
`只返回一个 JSON 对象,不要输出任何其他文字或解释。格式:` +
|
|
55
|
-
`{"x1":左上角x,"y1":左上角y,"x2":右下角x,"y2":右下角y}` +
|
|
56
|
-
`(原像素坐标,图片左上角为(0,0),尺寸 ${W}x${H},要求 x1<x2 且 y1<y2)。` +
|
|
57
|
-
`请确保 bbox 完全包围目标,不得遗漏目标的任何部分。` +
|
|
58
|
-
`如果图片中没有该目标,返回 {"found":false}。`;
|
|
113
|
+
const prompt = locatePrompt(target, W, H);
|
|
59
114
|
|
|
60
|
-
const
|
|
115
|
+
const result = await completeVision(ports, { prompt, image }, options);
|
|
116
|
+
return { ...result, bbox: parseLocateBbox(result.text, W, H) };
|
|
117
|
+
}
|
|
61
118
|
|
|
119
|
+
/** 保持原像素解析与钳制规则;格式失败不再发起模型调用。 */
|
|
120
|
+
export function parseLocateBbox(answer, W, H, { round = true } = {}) {
|
|
62
121
|
const jsonText = extractBboxJson(answer);
|
|
63
|
-
if (!jsonText) throw new LocateError('iris_locate: 模型未返回有效 JSON
|
|
122
|
+
if (!jsonText) throw new LocateError('iris_locate: 模型未返回有效 JSON');
|
|
64
123
|
let obj;
|
|
65
124
|
try {
|
|
66
125
|
obj = JSON.parse(jsonText);
|
|
67
126
|
} catch (_) {
|
|
68
|
-
throw new LocateError('iris_locate: bbox JSON
|
|
127
|
+
throw new LocateError('iris_locate: bbox JSON 解析失败');
|
|
69
128
|
}
|
|
70
|
-
if (obj && obj.found === false) return { found: false
|
|
129
|
+
if (obj && obj.found === false) return { found: false };
|
|
71
130
|
|
|
72
131
|
const { x1, y1, x2, y2 } = obj || {};
|
|
73
132
|
if (![x1, y1, x2, y2].every((v) => Number.isFinite(v))) {
|
|
74
|
-
throw new LocateError('iris_locate: bbox
|
|
133
|
+
throw new LocateError('iris_locate: bbox 字段必须是数字');
|
|
75
134
|
}
|
|
76
135
|
if (!(x1 < x2 && y1 < y2)) {
|
|
77
|
-
throw new LocateError(
|
|
136
|
+
throw new LocateError('iris_locate: bbox 无效(要求 x1<x2 且 y1<y2)');
|
|
78
137
|
}
|
|
79
138
|
// 完全越界(与图片无交集)→ 报错;否则钳制到图片边界
|
|
80
139
|
if (x1 >= W || x2 <= 0 || y1 >= H || y2 <= 0) {
|
|
81
|
-
throw new LocateError(
|
|
140
|
+
throw new LocateError('iris_locate: bbox 完全超出图片边界');
|
|
82
141
|
}
|
|
83
|
-
const
|
|
84
|
-
const
|
|
142
|
+
const pixel = v => round ? (v < 0 ? Math.round(v) : Math.ceil(v)) : v;
|
|
143
|
+
const clamp = (v) => Math.max(0, Math.min(pixel(v), W));
|
|
144
|
+
const clampY = (v) => Math.max(0, Math.min(pixel(v), H));
|
|
85
145
|
return {
|
|
86
146
|
found: true,
|
|
87
147
|
x1: clamp(x1),
|
|
88
148
|
y1: clampY(y1),
|
|
89
149
|
x2: clamp(x2),
|
|
90
|
-
y2: clampY(y2)
|
|
91
|
-
via,
|
|
92
|
-
model
|
|
150
|
+
y2: clampY(y2)
|
|
93
151
|
};
|
|
94
152
|
}
|
package/lib/media-probe.js
CHANGED
|
@@ -125,7 +125,8 @@ export async function extractFrames({ inputPath, maxFrames = 8, targetWidth = 64
|
|
|
125
125
|
else args.push('-q:v', String(opts.quality), outFile);
|
|
126
126
|
await runFfmpeg(args, { signal });
|
|
127
127
|
const frames = [];
|
|
128
|
-
const names = fs.readdirSync(outDir).filter((f) => f.endsWith('.jpg') || f.endsWith('.png'))
|
|
128
|
+
const names = fs.readdirSync(outDir).filter((f) => f.endsWith('.jpg') || f.endsWith('.png'))
|
|
129
|
+
.sort((a, b) => Number(a.match(/\d+/)[0]) - Number(b.match(/\d+/)[0]));
|
|
129
130
|
for (const name of names) {
|
|
130
131
|
const idx = Number(name.replace(/^f/, '').replace(/\.(jpg|png)$/, ''));
|
|
131
132
|
if (!Number.isFinite(idx) || idx < 1 || idx > n) continue; // 只取前 N 帧
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/** 协议适配器的有界等待;调用方仍须把 signal 传给实际 I/O。 */
|
|
3
|
+
import { ModelPortError, modelErrorRecord } from './model-port-contract.js';
|
|
4
|
+
|
|
5
|
+
export function modelAbortError(signal, stage = 'prepare', invocation = 'not_invoked') {
|
|
6
|
+
return new ModelPortError(modelErrorRecord(signal?.reason).code === 'IRIS_MODEL_TIMEOUT'
|
|
7
|
+
? 'IRIS_MODEL_TIMEOUT' : 'IRIS_MODEL_ABORTED', { stage, invocation });
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function waitForModelWork(work, signal, stage = 'prepare', invocation = 'not_invoked') {
|
|
11
|
+
if (signal?.aborted) throw modelAbortError(signal, stage, invocation);
|
|
12
|
+
let listener;
|
|
13
|
+
const canceled = new Promise((_, reject) => {
|
|
14
|
+
listener = () => reject(modelAbortError(signal, stage, invocation));
|
|
15
|
+
signal?.addEventListener('abort', listener, { once: true });
|
|
16
|
+
});
|
|
17
|
+
// 延迟调用以便预先取消时零 I/O;race 同时消费晚到的 rejection。
|
|
18
|
+
const pending = Promise.resolve().then(() => {
|
|
19
|
+
if (signal?.aborted) throw modelAbortError(signal, stage, invocation);
|
|
20
|
+
return work();
|
|
21
|
+
});
|
|
22
|
+
try {
|
|
23
|
+
const result = await Promise.race([pending, canceled]);
|
|
24
|
+
if (signal?.aborted) throw modelAbortError(signal, stage, invocation);
|
|
25
|
+
return result;
|
|
26
|
+
} finally { signal?.removeEventListener('abort', listener); }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function modelCallScope(parent, timeoutMs) {
|
|
30
|
+
const controller = new AbortController();
|
|
31
|
+
const onAbort = () => controller.abort(parent.reason);
|
|
32
|
+
if (parent?.aborted) onAbort();
|
|
33
|
+
else parent?.addEventListener('abort', onAbort, { once: true });
|
|
34
|
+
const deadline = performance.now() + timeoutMs;
|
|
35
|
+
let timer;
|
|
36
|
+
const schedule = () => {
|
|
37
|
+
if (controller.signal.aborted) return;
|
|
38
|
+
const left = deadline - performance.now();
|
|
39
|
+
if (left <= 0) controller.abort(new ModelPortError('IRIS_MODEL_TIMEOUT'));
|
|
40
|
+
else timer = setTimeout(schedule, Math.min(2147483647, Math.ceil(left)));
|
|
41
|
+
};
|
|
42
|
+
schedule();
|
|
43
|
+
return { signal: controller.signal, abort: reason => controller.abort(reason),
|
|
44
|
+
dispose() { clearTimeout(timer); parent?.removeEventListener('abort', onAbort); } };
|
|
45
|
+
}
|
package/lib/model-invoker.js
CHANGED
|
@@ -60,13 +60,15 @@ function scope({ parents = [], timeoutMs, startedAt = now() }) {
|
|
|
60
60
|
/** abort 必须已传播到底层;race 只用于不合作的端口有界退出并处理晚到拒绝。 */
|
|
61
61
|
async function waitForCompletion(work, signal) {
|
|
62
62
|
let listener;
|
|
63
|
+
let cancelTimer;
|
|
63
64
|
const interrupted = new Promise((_, reject) => {
|
|
64
|
-
|
|
65
|
+
// 给合作的适配器一个事件循环拍完成清理并报告 responded;不合作端口仍有界退出。
|
|
66
|
+
listener = () => { cancelTimer = setTimeout(() => reject(signal.reason), 0); };
|
|
65
67
|
if (signal.aborted) listener();
|
|
66
68
|
else signal.addEventListener('abort', listener, { once: true });
|
|
67
69
|
});
|
|
68
70
|
try { return await Promise.race([work, interrupted]); }
|
|
69
|
-
finally { signal.removeEventListener('abort', listener); }
|
|
71
|
+
finally { clearTimeout(cancelTimer); signal.removeEventListener('abort', listener); }
|
|
70
72
|
}
|
|
71
73
|
|
|
72
74
|
function candidatePolicy(value) {
|
|
@@ -6,6 +6,7 @@ export const MODEL_IMAGE_MEDIA_TYPES = Object.freeze(['image/png', 'image/jpeg',
|
|
|
6
6
|
|
|
7
7
|
const MESSAGES = Object.freeze({
|
|
8
8
|
IRIS_MODEL_INPUT_INVALID: '模型请求或预算不合法',
|
|
9
|
+
IRIS_MODEL_IMAGE_TOO_LARGE: '图片超过模型输入预算',
|
|
9
10
|
IRIS_MODEL_UNAVAILABLE: '模型能力不可用',
|
|
10
11
|
IRIS_MODEL_INCOMPATIBLE: '模型端口或协议不兼容',
|
|
11
12
|
IRIS_MODEL_UNSUPPORTED: '模型不支持所请求的能力或参数',
|
|
@@ -40,14 +41,19 @@ export class ModelPortError extends Error {
|
|
|
40
41
|
if (typeof code !== 'string' || !Object.hasOwn(MESSAGES, code)) throw new TypeError('未知模型错误码');
|
|
41
42
|
super(MESSAGES[code]);
|
|
42
43
|
this.name = 'ModelPortError';
|
|
43
|
-
const { stage = 'validate', invocation = 'not_invoked', backendId, status } = details;
|
|
44
|
+
const { stage = 'validate', invocation = 'not_invoked', backendId, status, imageBytes, imageMaxBytes } = details;
|
|
44
45
|
if (!STAGES.has(stage) || !INVOCATIONS.has(invocation)
|
|
45
46
|
|| (backendId !== undefined && !backendIdValid(backendId))
|
|
46
|
-
|| (status !== undefined && (!Number.isInteger(status) || status < 100 || status > 599))
|
|
47
|
+
|| (status !== undefined && (!Number.isInteger(status) || status < 100 || status > 599))
|
|
48
|
+
|| [imageBytes, imageMaxBytes].some(value => value !== undefined && (!Number.isSafeInteger(value) || value <= 0))) {
|
|
47
49
|
throw new TypeError('模型错误上下文不合法');
|
|
48
50
|
}
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
+
const message = code === 'IRIS_MODEL_IMAGE_TOO_LARGE' && imageBytes
|
|
52
|
+
? `图片大小 ${imageBytes} 字节${imageMaxBytes ? `,输入上限 ${imageMaxBytes} 字节` : ',超过模型输入上限'};请调整账号或模型的看图输入预算`
|
|
53
|
+
: MESSAGES[code];
|
|
54
|
+
const record = Object.freeze({ code, message, stage, invocation,
|
|
55
|
+
...(backendId !== undefined ? { backendId } : {}), ...(status !== undefined ? { status } : {}),
|
|
56
|
+
...(imageBytes !== undefined ? { imageBytes } : {}), ...(imageMaxBytes !== undefined ? { imageMaxBytes } : {}) });
|
|
51
57
|
ERROR_RECORDS.set(this, record);
|
|
52
58
|
Object.assign(this, record);
|
|
53
59
|
}
|
|
@@ -67,7 +73,9 @@ export function normalizeModelError(error, context = {}) {
|
|
|
67
73
|
return new ModelPortError(record.code, {
|
|
68
74
|
stage: record.stage, invocation: record.invocation,
|
|
69
75
|
...(record.backendId !== undefined ? { backendId: record.backendId } : {}),
|
|
70
|
-
...(record.status !== undefined ? { status: record.status } : {}), ...context
|
|
76
|
+
...(record.status !== undefined ? { status: record.status } : {}), ...context,
|
|
77
|
+
...(record.imageBytes !== undefined ? { imageBytes: record.imageBytes } : {}),
|
|
78
|
+
...(record.imageMaxBytes !== undefined ? { imageMaxBytes: record.imageMaxBytes } : {})
|
|
71
79
|
});
|
|
72
80
|
}
|
|
73
81
|
|
package/lib/models.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { CAPABILITIES } from './capability.js';
|
|
15
15
|
import { isDashScopeBaseUrl, selectMediaProtocol } from './provider-protocol.js';
|
|
16
|
+
import { modelRateLimit } from './provider-health.js';
|
|
16
17
|
|
|
17
18
|
/** 能力 → provider 上的模型字段(保持向后兼容的返回形状) */
|
|
18
19
|
export const CAP_FIELD = {
|
|
@@ -119,7 +120,8 @@ export function providerModels(p) {
|
|
|
119
120
|
const push = (id, caps, extra) => {
|
|
120
121
|
const name = String(id || '').trim();
|
|
121
122
|
if (!name || out.some((m) => m.id === name)) return;
|
|
122
|
-
out.push({ id: name, providerId: p.id, ref: modelRef(p.id, name), capabilities: [...caps], ...(extra || {})
|
|
123
|
+
out.push({ id: name, providerId: p.id, ref: modelRef(p.id, name), capabilities: [...caps], ...(extra || {}),
|
|
124
|
+
...(modelRateLimit(p, name) ? { rateLimited: true } : {}) });
|
|
123
125
|
};
|
|
124
126
|
// ① 显式 models 数组(带可选 capabilities 覆盖 + verified/source 透传)
|
|
125
127
|
// 该字段一旦存在(含空数组)即为权威声明:用户可明确标记「此供应商没有可用模型」,
|
|
@@ -130,7 +132,9 @@ export function providerModels(p) {
|
|
|
130
132
|
const id = typeof m === 'string' ? m : m && m.id;
|
|
131
133
|
// 元素级空能力数组同样是权威覆盖:用户可明确标记“此模型无能力”。
|
|
132
134
|
const caps = Array.isArray(m && m.capabilities) ? m.capabilities : capabilitiesOfModel(id);
|
|
133
|
-
const extra = typeof m === 'object' && m ? { verified: m.verified, source: m.source
|
|
135
|
+
const extra = typeof m === 'object' && m ? { verified: m.verified, source: m.source,
|
|
136
|
+
...(m.imageProtocol !== undefined ? { imageProtocol: m.imageProtocol } : {}),
|
|
137
|
+
...(m.visionInput !== undefined ? { visionInput: m.visionInput } : {}) } : {};
|
|
134
138
|
push(id, caps, extra);
|
|
135
139
|
}
|
|
136
140
|
return out;
|
|
@@ -168,11 +172,12 @@ export function resolvePoolModel(pool, raw, capability) {
|
|
|
168
172
|
}
|
|
169
173
|
|
|
170
174
|
/** DSH 与 CLI 共用的候选顺序:分配优先、按池顺序补齐并去重。 */
|
|
171
|
-
export function orderedModels(pool, capability, assignments) {
|
|
175
|
+
export function orderedModels(pool, capability, assignments, { includeRateLimited = false } = {}) {
|
|
172
176
|
const ordered = [];
|
|
173
177
|
const seen = new Set();
|
|
174
178
|
const add = (model) => {
|
|
175
|
-
if (model &&
|
|
179
|
+
if (model && (includeRateLimited || !model.rateLimited)
|
|
180
|
+
&& model.capabilities.includes(capability) && !seen.has(model.ref)) {
|
|
176
181
|
seen.add(model.ref);
|
|
177
182
|
ordered.push(model);
|
|
178
183
|
}
|
|
@@ -210,5 +215,5 @@ export function modelPool(providers) {
|
|
|
210
215
|
|
|
211
216
|
/** 从池里挑有某能力的模型(按池顺序) */
|
|
212
217
|
export function pickModel(pool, capability) {
|
|
213
|
-
return (pool || []).find((m) => m.capabilities.includes(capability)) || null;
|
|
218
|
+
return (pool || []).find((m) => !m.rateLimited && m.capabilities.includes(capability)) || null;
|
|
214
219
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/** OCR Host 边界:配置显式传入;同一 operation 从解引用开始覆盖整张长图。 */
|
|
3
|
+
import { buildVisionModelCandidates } from './vision-model-routing.js';
|
|
4
|
+
import { longOcr, normalizeOcrSettings, OCR_BUDGET } from './ocr.js';
|
|
5
|
+
import { createModelOperation } from './model-invoker.js';
|
|
6
|
+
import { ModelPortError, modelPortSnapshot, normalizeModelCall } from './model-port-contract.js';
|
|
7
|
+
import { waitForModelWork } from './model-call-runtime.js';
|
|
8
|
+
import { createVisionInputObserver } from './vision-image-input.js';
|
|
9
|
+
|
|
10
|
+
export async function runOcrRequest(host, { providers = [], model, image, prepareImage, signal,
|
|
11
|
+
chunkHeight, overlap, maxDimension, maxInvocations, budget = OCR_BUDGET, onRateLimit }) {
|
|
12
|
+
const settings = normalizeOcrSettings({ chunkHeight, overlap, maxDimension, maxInvocations });
|
|
13
|
+
const operation = createModelOperation({ signal, budget: { timeoutMs: budget.timeoutMs, maxInvocations: settings.maxInvocations } });
|
|
14
|
+
try {
|
|
15
|
+
const inputs = new Map();
|
|
16
|
+
const candidates = buildVisionModelCandidates(host, { providers, model, onRateLimit, prepareImages: true,
|
|
17
|
+
prepareImageRequest: createVisionInputObserver(inputs) });
|
|
18
|
+
normalizeModelCall(modelPortSnapshot(candidates[0].port), { prompt: 'OCR',
|
|
19
|
+
image: { bytes: new Uint8Array([0]), mediaType: 'image/png' } }, { budget });
|
|
20
|
+
const resolved = await waitForModelWork(() => prepareImage ? prepareImage(operation.signal) : image, operation.signal);
|
|
21
|
+
return await longOcr({ image: resolved, ports: candidates.map(candidate => candidate.port), ...settings, operation, budget, inputs });
|
|
22
|
+
} catch (error) {
|
|
23
|
+
if (error instanceof ModelPortError) throw error;
|
|
24
|
+
throw new ModelPortError('IRIS_MODEL_REQUEST_FAILED', { stage: 'prepare', invocation: 'not_invoked' });
|
|
25
|
+
} finally { operation.dispose(); }
|
|
26
|
+
}
|
package/lib/ocr.js
CHANGED
|
@@ -1,80 +1,126 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
/**
|
|
3
|
-
* Iris 长截图 OCR(阶段 3B):视觉模型分块文字识别。
|
|
4
|
-
*
|
|
5
|
-
* 调研结论(RESEARCH.md §7):qwen-vl 视觉模型中文零误差,tesseract.js 准确率差且置信度虚高。
|
|
6
|
-
* 方案:复用阶段 1 VisionBackend 链,sharp 分块(1200px 高 + 120px 重叠避免切断文本行),
|
|
7
|
-
* 逐块 OCR 后拼接。
|
|
8
|
-
*
|
|
9
|
-
* 契约:
|
|
10
|
-
* - chunkHeight 默认 1200,overlap 默认 120
|
|
11
|
-
* - 每块独立调用视觉模型,结果按 y 坐标顺序拼接
|
|
12
|
-
* - 返回全文 + 分块明细 + 元数据
|
|
13
|
-
*/
|
|
2
|
+
/** M4 OCR 业务:显式图片字节、Sharp 分块和共享 Vision Ports;无 Host/路径/Store。 */
|
|
14
3
|
import sharp from 'sharp';
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
4
|
+
import { completeVision, VISION_BUDGET } from './vision-core.js';
|
|
5
|
+
import { createModelOperation } from './model-invoker.js';
|
|
6
|
+
import { ModelPortError, modelErrorRecord, modelPortSnapshot, normalizeModelCall } from './model-port-contract.js';
|
|
7
|
+
import { waitForModelWork, modelAbortError } from './model-call-runtime.js';
|
|
17
8
|
|
|
18
|
-
export
|
|
19
|
-
|
|
20
|
-
|
|
9
|
+
export const OCR_BUDGET = Object.freeze({ ...VISION_BUDGET });
|
|
10
|
+
export const OCR_LIMITS = Object.freeze({ maxChunks: 32, maxInvocations: 64, maxPixels: 40000000 });
|
|
11
|
+
|
|
12
|
+
export class OcrError extends ModelPortError {
|
|
13
|
+
constructor(code = 'IRIS_MODEL_INPUT_INVALID') {
|
|
14
|
+
super(code, { stage: 'prepare', invocation: 'not_invoked' });
|
|
21
15
|
this.name = 'OcrError';
|
|
22
16
|
}
|
|
23
17
|
}
|
|
24
18
|
|
|
25
19
|
const OCR_PROMPT = '请完整读出图片中的全部文字,包括标点符号。按从上到下的顺序逐行输出。不要添加额外解释。';
|
|
26
20
|
|
|
27
|
-
/**
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
if (!Array.isArray(backends) || backends.length === 0) {
|
|
36
|
-
throw new OcrError('iris_long_ocr: 没有可用的视觉后端(至少需要一个 vision provider)');
|
|
21
|
+
/** 保留既有最小块高及 overlap 钳制;NaN/无限值不进入图片准备或循环。 */
|
|
22
|
+
export function normalizeOcrSettings({ chunkHeight = 1200, overlap = 120, maxDimension = 2048,
|
|
23
|
+
maxInvocations = OCR_LIMITS.maxInvocations } = {}) {
|
|
24
|
+
if (typeof chunkHeight !== 'number' || !Number.isFinite(chunkHeight) || chunkHeight <= 0
|
|
25
|
+
|| typeof overlap !== 'number' || !Number.isFinite(overlap)
|
|
26
|
+
|| !Number.isSafeInteger(maxDimension) || maxDimension <= 0 || maxDimension > 4096
|
|
27
|
+
|| !Number.isSafeInteger(maxInvocations) || maxInvocations <= 0 || maxInvocations > OCR_LIMITS.maxInvocations) {
|
|
28
|
+
throw new OcrError();
|
|
37
29
|
}
|
|
38
|
-
const { width: W, height: H } = await imageDimensions(input);
|
|
39
|
-
if (W <= 0 || H <= 0) throw new OcrError('iris_long_ocr: 图片尺寸无效');
|
|
40
30
|
const ch = Math.max(100, Math.floor(chunkHeight));
|
|
41
31
|
const ov = Math.max(0, Math.min(Math.floor(overlap), ch - 1));
|
|
42
|
-
|
|
32
|
+
if (!Number.isSafeInteger(ch)) throw new OcrError();
|
|
33
|
+
return Object.freeze({ chunkHeight: ch, overlap: ov, maxDimension, maxInvocations });
|
|
34
|
+
}
|
|
43
35
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
img = resized;
|
|
36
|
+
async function imageWork(action, signal) {
|
|
37
|
+
try { return await waitForModelWork(action, signal); }
|
|
38
|
+
catch (error) {
|
|
39
|
+
if (error instanceof ModelPortError) throw error;
|
|
40
|
+
throw new OcrError();
|
|
50
41
|
}
|
|
51
|
-
|
|
52
|
-
const iw = meta.width || W;
|
|
53
|
-
const ih = meta.height || H;
|
|
42
|
+
}
|
|
54
43
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
44
|
+
/** 一轮共用 operation:准备、全部块和候选切换均消费同一 deadline/次数。 */
|
|
45
|
+
export async function longOcr({ image, ports, chunkHeight, overlap, maxDimension, maxInvocations,
|
|
46
|
+
signal, operation, budget = OCR_BUDGET, inputs }) {
|
|
47
|
+
const settings = normalizeOcrSettings({ chunkHeight, overlap, maxDimension, maxInvocations });
|
|
48
|
+
const op = operation || createModelOperation({ signal, budget: { timeoutMs: budget.timeoutMs, maxInvocations: settings.maxInvocations } });
|
|
49
|
+
try {
|
|
50
|
+
if (op.signal.aborted) throw modelAbortError(op.signal);
|
|
51
|
+
if (op.snapshot().maxInvocations > settings.maxInvocations) throw new OcrError();
|
|
52
|
+
if (!Array.isArray(ports) || !ports.length) throw new OcrError('IRIS_MODEL_UNAVAILABLE');
|
|
53
|
+
const input = normalizeModelCall(modelPortSnapshot(ports[0]), { prompt: OCR_PROMPT, image }, { budget }).request.image;
|
|
54
|
+
const source = Buffer.from(input.bytes);
|
|
55
|
+
const meta = await imageWork(() => sharp(source, { limitInputPixels: OCR_LIMITS.maxPixels }).metadata(), op.signal);
|
|
56
|
+
const W = meta.autoOrient?.width || meta.width, H = meta.autoOrient?.height || meta.height;
|
|
57
|
+
if (!Number.isSafeInteger(W) || !Number.isSafeInteger(H) || W <= 0 || H <= 0
|
|
58
|
+
|| W * H > OCR_LIMITS.maxPixels || (meta.pages || 1) > 1
|
|
59
|
+
|| ({ png: 'image/png', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif' })[meta.format] !== input.mediaType) {
|
|
60
|
+
throw new OcrError();
|
|
71
61
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
.
|
|
76
|
-
|
|
77
|
-
.
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
62
|
+
const width = Math.min(W, settings.maxDimension);
|
|
63
|
+
const height = W > width ? Math.max(1, Math.round(H * width / W)) : H;
|
|
64
|
+
const step = settings.chunkHeight - settings.overlap;
|
|
65
|
+
const totalChunks = Math.ceil(height / step);
|
|
66
|
+
if (totalChunks > OCR_LIMITS.maxChunks) throw new OcrError();
|
|
67
|
+
const img = W > width || meta.orientation > 1 ? await imageWork(() => sharp(source, { limitInputPixels: OCR_LIMITS.maxPixels })
|
|
68
|
+
.autoOrient().resize(width, height).png().toBuffer(), op.signal) : source;
|
|
69
|
+
const chunks = [];
|
|
70
|
+
let stopCode;
|
|
71
|
+
for (let index = 0; index < totalChunks; index++) {
|
|
72
|
+
if (op.signal.aborted) throw modelAbortError(op.signal);
|
|
73
|
+
const y = index * step, h = Math.min(settings.chunkHeight, height - y);
|
|
74
|
+
const chunk = { index: index + 1, y, height: h, text: '', status: 'skipped' };
|
|
75
|
+
if (stopCode) {
|
|
76
|
+
chunk.code = stopCode; chunk.error = modelErrorRecord(new ModelPortError(stopCode)).message;
|
|
77
|
+
chunks.push(chunk); continue;
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
const snapshot = op.snapshot();
|
|
81
|
+
if (snapshot.invocations >= Math.min(snapshot.maxInvocations, settings.maxInvocations)) throw new ModelPortError('IRIS_MODEL_CALL_LIMIT');
|
|
82
|
+
const png = await imageWork(() => sharp(img, { limitInputPixels: OCR_LIMITS.maxPixels })
|
|
83
|
+
.extract({ left: 0, top: y, width, height: h }).png().toBuffer(), op.signal);
|
|
84
|
+
const result = await completeVision(ports, { prompt: OCR_PROMPT,
|
|
85
|
+
image: { bytes: new Uint8Array(png), mediaType: 'image/png' } }, { operation: op, budget });
|
|
86
|
+
chunk.text = result.text.trim(); chunk.status = 'succeeded';
|
|
87
|
+
chunk.identity = result.identity; chunk.errors = result.errors;
|
|
88
|
+
if (inputs?.has(result.identity.backendId)) chunk.input = inputs.get(result.identity.backendId);
|
|
89
|
+
if (result.usage) chunk.usage = result.usage;
|
|
90
|
+
} catch (error) {
|
|
91
|
+
const record = modelErrorRecord(error);
|
|
92
|
+
if (['IRIS_MODEL_ABORTED', 'IRIS_MODEL_TIMEOUT'].includes(record.code)) throw error;
|
|
93
|
+
if (op.signal.aborted) throw modelAbortError(op.signal);
|
|
94
|
+
if (record.code === 'IRIS_MODEL_CALL_LIMIT') stopCode = record.code;
|
|
95
|
+
else chunk.status = 'failed';
|
|
96
|
+
chunk.error = record.message; chunk.code = record.code;
|
|
97
|
+
chunk.errors = Object.freeze([...(error.errors || [record])]);
|
|
98
|
+
}
|
|
99
|
+
chunks.push(chunk);
|
|
100
|
+
}
|
|
101
|
+
if (op.signal.aborted) throw modelAbortError(op.signal);
|
|
102
|
+
const successfulChunks = chunks.filter(chunk => chunk.status === 'succeeded').length;
|
|
103
|
+
const failedChunks = chunks.filter(chunk => chunk.status === 'failed').length;
|
|
104
|
+
const skippedChunks = totalChunks - successfulChunks - failedChunks;
|
|
105
|
+
return { status: successfulChunks === totalChunks ? 'complete' : successfulChunks ? 'partial' : 'failed',
|
|
106
|
+
fullText: chunks.filter(chunk => chunk.status === 'succeeded').map(chunk => `[第${chunk.index}段 y=${chunk.y}] ${chunk.text}`).join('\n'),
|
|
107
|
+
chunks, totalChunks, successfulChunks, failedChunks, skippedChunks, width, height,
|
|
108
|
+
sourceWidth: W, sourceHeight: H,
|
|
109
|
+
invocations: op.snapshot().invocations, ...(stopCode ? { stopCode } : {}) };
|
|
110
|
+
} finally { if (!operation) op.dispose(); }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Agent/工作台共用明确的完成度,不把缺块或未处理误称为完整全文。 */
|
|
114
|
+
export function formatOcrResult(result) {
|
|
115
|
+
const label = { complete: '完成', partial: '部分完成', failed: '失败' }[result.status];
|
|
116
|
+
const counts = result.status === 'complete' ? ''
|
|
117
|
+
: `(成功 ${result.successfulChunks} 块,失败 ${result.failedChunks} 块,未处理 ${result.skippedChunks} 块)`;
|
|
118
|
+
const stop = result.stopCode === 'IRIS_MODEL_CALL_LIMIT' ? ';已达到本次调用上限' : '';
|
|
119
|
+
const resized = result.chunks.filter(chunk => chunk.input?.resized);
|
|
120
|
+
const note = (result.sourceWidth > result.width || resized.length)
|
|
121
|
+
? '\n[iris] OCR 输入已缩小,细小文字可能丢失;可提高 OCR 最大宽度或模型输入预算。'
|
|
122
|
+
+ (resized.length ? '\n' + resized.map(chunk => `[第${chunk.index}段] ${chunk.input.source.width}x${chunk.input.source.height} → ${chunk.input.sent.width}x${chunk.input.sent.height}px`).join('\n') : '')
|
|
123
|
+
: '';
|
|
124
|
+
return `[iris] 长截图 OCR ${label}:${result.width}x${result.height}px,${result.totalChunks} 块${counts}${stop}\n`
|
|
125
|
+
+ (result.fullText || '(没有完整识别结果)') + note;
|
|
126
|
+
}
|
|
@@ -9,8 +9,9 @@ import fs from 'node:fs';
|
|
|
9
9
|
import path from 'node:path';
|
|
10
10
|
import { irisHome } from './config.js';
|
|
11
11
|
import { atomicWritePrivate, chmodPrivateFile, privateSibling } from './private-storage.js';
|
|
12
|
+
import { PROMPT_TARGETS } from './prompt-optimizer-core.js';
|
|
12
13
|
|
|
13
|
-
export
|
|
14
|
+
export { PROMPT_TARGETS } from './prompt-optimizer-core.js';
|
|
14
15
|
|
|
15
16
|
const DEFAULT_VALUE = {
|
|
16
17
|
version: 1,
|