@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
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/** DSH textModel 的单轮协议映射;宿主路由/会话封闭在适配器,不进入共享请求。 */
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { ModelPortError, modelErrorRecord, normalizeModelDescriptor, normalizeModelCall,
|
|
5
|
+
normalizeModelCompletion, createModelTextCollector } from './model-port-contract.js';
|
|
6
|
+
|
|
7
|
+
const OFF_IDS = new Set(['off', 'none', 'disabled', 'disable', 'no-thinking']);
|
|
8
|
+
const validId = value => typeof value === 'string' && value.length > 0 && value.length <= 256
|
|
9
|
+
&& value === value.trim() && !/[\u0000-\u001f\u007f]/.test(value) && !value.startsWith('/') && !value.includes('://');
|
|
10
|
+
|
|
11
|
+
function abortError(signal, stage, invocation) {
|
|
12
|
+
const code = modelErrorRecord(signal?.reason).code === 'IRIS_MODEL_TIMEOUT' ? 'IRIS_MODEL_TIMEOUT' : 'IRIS_MODEL_ABORTED';
|
|
13
|
+
return new ModelPortError(code, { stage, invocation });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** 信号传入底层;race 仅保证不合作的元数据/迭代器不会让本地永远挂住。 */
|
|
17
|
+
async function wait(work, signal, stage, invocation) {
|
|
18
|
+
if (!signal) return work;
|
|
19
|
+
let onAbort;
|
|
20
|
+
const canceled = new Promise((_, reject) => {
|
|
21
|
+
onAbort = () => reject(abortError(signal, stage, invocation));
|
|
22
|
+
if (signal.aborted) onAbort();
|
|
23
|
+
else signal.addEventListener('abort', onAbort, { once: true });
|
|
24
|
+
});
|
|
25
|
+
try { return await Promise.race([work, canceled]); }
|
|
26
|
+
finally { signal.removeEventListener('abort', onAbort); }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function descriptorFor(binding, metadata, kind = 'text') {
|
|
30
|
+
const efforts = metadata?.reasoning?.efforts;
|
|
31
|
+
const ids = Array.isArray(efforts) ? [...new Set(efforts.map(value => value?.id).filter(validId))] : [];
|
|
32
|
+
const offId = ids.find(id => OFF_IDS.has(id.toLowerCase()));
|
|
33
|
+
const known = Array.isArray(efforts) && efforts.every(value => validId(value?.id));
|
|
34
|
+
return { descriptor: normalizeModelDescriptor({ contractVersion: 0, kind,
|
|
35
|
+
identity: { origin: 'host', backendId: 'dsh-' + kind + ':' + crypto.createHash('sha256').update(JSON.stringify([binding.provider, binding.model])).digest('hex'),
|
|
36
|
+
providerId: binding.provider, modelId: binding.model }, availability: 'available',
|
|
37
|
+
// 这些字段有明确的 DSH GenerateOptions 映射,不代表已证明远端模型能力。
|
|
38
|
+
features: { system: 'supported', temperature: 'supported', maxOutputTokens: 'supported', reasoning: known ? 'supported' : 'unknown' },
|
|
39
|
+
...(known ? { reasoning: { off: offId ? 'supported' : 'unsupported', effortIds: ids } } : {}) }), offId: known ? offId : undefined };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function usageSnapshot(value) {
|
|
43
|
+
if (!value || typeof value !== 'object') return undefined;
|
|
44
|
+
const usage = {};
|
|
45
|
+
for (const key of ['outputTokens', 'totalTokens', 'reasoningTokens']) {
|
|
46
|
+
if (Number.isSafeInteger(value[key]) && value[key] >= 0) usage[key] = value[key];
|
|
47
|
+
}
|
|
48
|
+
// rc.2 inputTokens 不含缓存;这里只把权威的三个不重叠输入计数相加。
|
|
49
|
+
const inputs = [value.inputTokens, value.cacheReadTokens ?? 0, value.cacheWriteTokens ?? 0];
|
|
50
|
+
const inputTotal = inputs.reduce((sum, count) => sum + count, 0);
|
|
51
|
+
if (inputs.every(count => Number.isSafeInteger(count) && count >= 0) && Number.isSafeInteger(inputTotal)) usage.inputTokens = inputTotal;
|
|
52
|
+
return Object.keys(usage).length ? usage : undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 元数据解析显式进行且受同一操作信号约束;describe 永远只复制已绑定事实。 */
|
|
56
|
+
export async function prepareDshTextModelPort(textModel, binding, { signal } = {}) {
|
|
57
|
+
if (signal?.aborted) throw abortError(signal, 'prepare', 'not_invoked');
|
|
58
|
+
if (!textModel || typeof textModel.stream !== 'function') throw new ModelPortError('IRIS_MODEL_UNAVAILABLE');
|
|
59
|
+
if (!validId(binding?.provider) || !validId(binding?.model)) throw new ModelPortError('IRIS_MODEL_INPUT_INVALID');
|
|
60
|
+
const route = { provider: binding.provider, model: binding.model };
|
|
61
|
+
const sessionId = typeof binding.sessionId === 'string' && binding.sessionId.trim() && binding.sessionId.length <= 256 ? binding.sessionId.trim() : undefined;
|
|
62
|
+
let metadata;
|
|
63
|
+
if (typeof textModel.resolveModelInfo === 'function') {
|
|
64
|
+
try { metadata = await wait(Promise.resolve().then(() => {
|
|
65
|
+
if (signal?.aborted) throw abortError(signal, 'prepare', 'not_invoked');
|
|
66
|
+
return textModel.resolveModelInfo(route.provider, route.model, signal);
|
|
67
|
+
}), signal, 'prepare', 'not_invoked'); }
|
|
68
|
+
catch (_) { if (signal?.aborted) throw abortError(signal, 'prepare', 'not_invoked'); }
|
|
69
|
+
}
|
|
70
|
+
if (signal?.aborted) throw abortError(signal, 'prepare', 'not_invoked');
|
|
71
|
+
return createDshTextModelPort(textModel, { ...route, sessionId }, { metadata });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 已绑定事实的纯构造;用于缓存元数据/协议 fixture,不执行元数据查询。 */
|
|
75
|
+
export function createDshTextModelPort(textModel, binding, { metadata } = {}) {
|
|
76
|
+
return createDshBoundModelPort(textModel, binding, { metadata });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 文本与视觉共用经过验证的 DSH 事件读取器;图片桥接仅由视觉适配器注入。 */
|
|
80
|
+
export function createDshBoundModelPort(textModel, binding, { metadata, prepareImage, prepareImageRequest, kind = 'text' } = {}) {
|
|
81
|
+
if (!textModel || typeof textModel.stream !== 'function') throw new ModelPortError('IRIS_MODEL_UNAVAILABLE');
|
|
82
|
+
if (!validId(binding?.provider) || !validId(binding?.model)) throw new ModelPortError('IRIS_MODEL_INPUT_INVALID');
|
|
83
|
+
const route = { provider: binding.provider, model: binding.model };
|
|
84
|
+
// 不接受绑定身份与解析出的模型漂移;旧 fixture 未提供身份时保持 unknown。
|
|
85
|
+
if (metadata && (metadata.provider !== undefined && metadata.provider !== route.provider || metadata.id !== undefined && metadata.id !== route.model)) {
|
|
86
|
+
throw new ModelPortError('IRIS_MODEL_INCOMPATIBLE', { stage: 'prepare', invocation: 'not_invoked' });
|
|
87
|
+
}
|
|
88
|
+
const sessionId = typeof binding.sessionId === 'string' && binding.sessionId.trim() && binding.sessionId.length <= 256 ? binding.sessionId.trim() : undefined;
|
|
89
|
+
const { descriptor, offId } = descriptorFor(route, metadata, kind);
|
|
90
|
+
const port = Object.freeze({
|
|
91
|
+
describe() { return normalizeModelDescriptor(descriptor); },
|
|
92
|
+
async complete(request, options) {
|
|
93
|
+
const input = normalizeModelCall(descriptor, request, options);
|
|
94
|
+
if (input.options.signal?.aborted) throw abortError(input.options.signal, 'invoke', 'not_invoked');
|
|
95
|
+
const controller = new AbortController();
|
|
96
|
+
const parent = input.options.signal;
|
|
97
|
+
const onAbort = () => controller.abort(parent.reason);
|
|
98
|
+
parent?.addEventListener('abort', onAbort, { once: true });
|
|
99
|
+
const deadline = performance.now() + input.options.budget.timeoutMs;
|
|
100
|
+
let timer;
|
|
101
|
+
const scheduleDeadline = () => {
|
|
102
|
+
const remaining = deadline - performance.now();
|
|
103
|
+
if (remaining <= 0) controller.abort(new ModelPortError('IRIS_MODEL_TIMEOUT'));
|
|
104
|
+
else timer = setTimeout(scheduleDeadline, Math.min(2147483647, Math.ceil(remaining)));
|
|
105
|
+
};
|
|
106
|
+
scheduleDeadline();
|
|
107
|
+
const signal = controller.signal;
|
|
108
|
+
const collector = createModelTextCollector({ maxOutputChars: input.options.budget.maxOutputChars, signal, backendId: descriptor.identity.backendId });
|
|
109
|
+
const sourceRequest = { ...route, messages: [{ role: 'user', content: [{ type: 'text', text: input.request.prompt }] }],
|
|
110
|
+
...(input.request.system === undefined ? {} : { system: input.request.system }), signal,
|
|
111
|
+
...(sessionId ? { sessionId } : {}) };
|
|
112
|
+
if (input.request.generation?.temperature !== undefined) sourceRequest.temperature = input.request.generation.temperature;
|
|
113
|
+
if (input.request.generation?.maxOutputTokens !== undefined) sourceRequest.maxTokens = input.request.generation.maxOutputTokens;
|
|
114
|
+
const reasoning = input.request.generation?.reasoning;
|
|
115
|
+
if (reasoning?.mode === 'off') sourceRequest.reasoningEffort = offId;
|
|
116
|
+
if (reasoning?.mode === 'effort') sourceRequest.reasoningEffort = reasoning.effortId;
|
|
117
|
+
let iterator;
|
|
118
|
+
let usage;
|
|
119
|
+
let finished = false;
|
|
120
|
+
let ended = false;
|
|
121
|
+
let invoked = false;
|
|
122
|
+
let responded = false;
|
|
123
|
+
let prepared;
|
|
124
|
+
const blocks = new Map();
|
|
125
|
+
const blockId = index => {
|
|
126
|
+
if (!Number.isSafeInteger(index) || index < 0) throw new ModelPortError('IRIS_MODEL_PROTOCOL_INVALID', { stage: 'read', invocation: 'unknown' });
|
|
127
|
+
return String(index);
|
|
128
|
+
};
|
|
129
|
+
try {
|
|
130
|
+
if (kind === 'vision') {
|
|
131
|
+
if (typeof prepareImage !== 'function') throw new ModelPortError('IRIS_MODEL_UNAVAILABLE', { stage: 'prepare' });
|
|
132
|
+
const preparedImage = await wait(Promise.resolve().then(() => {
|
|
133
|
+
if (signal.aborted) throw abortError(signal, 'prepare', 'not_invoked');
|
|
134
|
+
return prepareImage(input.request.image, signal);
|
|
135
|
+
}), signal, 'prepare', 'not_invoked');
|
|
136
|
+
if (signal.aborted) throw abortError(signal, 'prepare', 'not_invoked');
|
|
137
|
+
prepared = await wait(Promise.resolve().then(() => prepareImageRequest?.({ request: input.request,
|
|
138
|
+
image: preparedImage.image, signal, identity: descriptor.identity })), signal, 'prepare', 'not_invoked');
|
|
139
|
+
const prompt = prepared?.prompt ?? input.request.prompt;
|
|
140
|
+
normalizeModelCall(descriptor, { ...input.request, image: preparedImage.image, prompt }, input.options);
|
|
141
|
+
sourceRequest.messages[0].content[0].text = prompt;
|
|
142
|
+
sourceRequest.messages[0].content.push({ type: 'image', attachment: preparedImage.attachment });
|
|
143
|
+
}
|
|
144
|
+
invoked = true;
|
|
145
|
+
const stream = textModel.stream(sourceRequest);
|
|
146
|
+
if (!stream || typeof stream[Symbol.asyncIterator] !== 'function') throw new ModelPortError('IRIS_MODEL_PROTOCOL_INVALID', { stage: 'invoke', invocation: 'unknown' });
|
|
147
|
+
iterator = stream[Symbol.asyncIterator]();
|
|
148
|
+
while (true) {
|
|
149
|
+
const next = await wait(Promise.resolve().then(() => iterator.next()), signal, 'read', responded ? 'responded' : 'unknown');
|
|
150
|
+
if (signal.aborted) throw abortError(signal, 'read', responded ? 'responded' : 'unknown');
|
|
151
|
+
if (next.done) { ended = true; break; }
|
|
152
|
+
const chunk = next.value;
|
|
153
|
+
if (!chunk || typeof chunk !== 'object' || finished) throw new ModelPortError('IRIS_MODEL_PROTOCOL_INVALID', { stage: 'read', invocation: 'responded' });
|
|
154
|
+
if (chunk.type === 'block-start') {
|
|
155
|
+
const id = blockId(chunk.index);
|
|
156
|
+
if (!['text', 'reasoning'].includes(chunk.blockType)) collector.finish(chunk.blockType === 'tool-call' ? 'tool-calls' : 'unknown');
|
|
157
|
+
if (blocks.has(id)) collector.finish('unknown');
|
|
158
|
+
blocks.set(id, chunk.blockType);
|
|
159
|
+
if (chunk.blockType === 'text') collector.append('', id);
|
|
160
|
+
} else if (chunk.type === 'text-delta') {
|
|
161
|
+
const id = blockId(chunk.index);
|
|
162
|
+
if (blocks.has(id) && blocks.get(id) !== 'text') collector.finish('unknown');
|
|
163
|
+
blocks.set(id, 'text');
|
|
164
|
+
if (typeof chunk.text === 'string' && chunk.text.length) responded = true;
|
|
165
|
+
collector.append(chunk.text, id);
|
|
166
|
+
} else if (chunk.type === 'reasoning-delta') {
|
|
167
|
+
const id = blockId(chunk.index);
|
|
168
|
+
if (typeof chunk.text !== 'string' || blocks.has(id) && blocks.get(id) !== 'reasoning') collector.finish('unknown');
|
|
169
|
+
blocks.set(id, 'reasoning');
|
|
170
|
+
} else if (chunk.type === 'block-end') {
|
|
171
|
+
const id = blockId(chunk.index);
|
|
172
|
+
if (chunk.block?.type === 'text') {
|
|
173
|
+
if (blocks.has(id) && blocks.get(id) !== 'text') collector.finish('unknown');
|
|
174
|
+
blocks.set(id, 'text');
|
|
175
|
+
if (typeof chunk.block.text === 'string' && chunk.block.text.length) responded = true;
|
|
176
|
+
collector.replace(chunk.block.text, id);
|
|
177
|
+
} else if (chunk.block?.type === 'reasoning') {
|
|
178
|
+
if (typeof chunk.block.text !== 'string' || blocks.has(id) && blocks.get(id) !== 'reasoning') collector.finish('unknown');
|
|
179
|
+
blocks.set(id, 'reasoning');
|
|
180
|
+
} else collector.finish(chunk.block?.type === 'tool-call' ? 'tool-calls' : 'unknown');
|
|
181
|
+
} else if (chunk.type === 'tool-call-delta') collector.finish('tool-calls');
|
|
182
|
+
else if (chunk.type === 'usage') usage = usageSnapshot(chunk.usage);
|
|
183
|
+
else if (chunk.type === 'finish') {
|
|
184
|
+
const kind = chunk.reason?.kind;
|
|
185
|
+
if (kind === 'aborted') throw new ModelPortError('IRIS_MODEL_ABORTED', { stage: 'read', invocation: responded ? 'responded' : 'unknown' });
|
|
186
|
+
if (kind === 'error') collector.fail(new ModelPortError('IRIS_MODEL_REQUEST_FAILED', { stage: 'read', invocation: 'unknown' }));
|
|
187
|
+
collector.finish(kind); finished = true;
|
|
188
|
+
} else if (chunk.type === 'error') collector.fail(chunk.error);
|
|
189
|
+
else if (chunk.type === undefined && typeof chunk.delta === 'string') {
|
|
190
|
+
if (chunk.delta.length) responded = true;
|
|
191
|
+
collector.append(chunk.delta, '0');
|
|
192
|
+
}
|
|
193
|
+
else collector.finish('unknown');
|
|
194
|
+
}
|
|
195
|
+
const collected = collector.complete();
|
|
196
|
+
const text = prepared?.mapText ? prepared.mapText(collected) : collected;
|
|
197
|
+
return normalizeModelCompletion(descriptor, { contractVersion: 0, text, finishReason: 'stop', identity: descriptor.identity,
|
|
198
|
+
...(usage ? { usage } : {}) }, input.options.budget);
|
|
199
|
+
} catch (error) {
|
|
200
|
+
if (signal.aborted) throw abortError(signal, invoked ? 'read' : 'prepare', responded ? 'responded' : invoked ? 'unknown' : 'not_invoked');
|
|
201
|
+
if (!invoked) {
|
|
202
|
+
controller.abort(error);
|
|
203
|
+
const record = modelErrorRecord(error);
|
|
204
|
+
throw new ModelPortError(record.code, { stage: 'prepare', invocation: 'not_invoked',
|
|
205
|
+
...(record.imageBytes ? { imageBytes: record.imageBytes } : {}),
|
|
206
|
+
...(record.imageMaxBytes ? { imageMaxBytes: record.imageMaxBytes } : {}) });
|
|
207
|
+
}
|
|
208
|
+
// 只有受控模型错误可以离开协议边界;供应商原文不进入 UI/日志。
|
|
209
|
+
try { collector.fail(error); }
|
|
210
|
+
finally { controller.abort(error); }
|
|
211
|
+
} finally {
|
|
212
|
+
try {
|
|
213
|
+
if (!ended && iterator && typeof iterator.return === 'function') {
|
|
214
|
+
const closing = Promise.resolve().then(() => iterator.return());
|
|
215
|
+
await wait(closing, signal, 'read', 'unknown').catch(() => {});
|
|
216
|
+
}
|
|
217
|
+
} finally { clearTimeout(timer); parent?.removeEventListener('abort', onAbort); }
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
return Object.freeze({ port, offId });
|
|
222
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
import { ModelPortError } from './model-port-contract.js';
|
|
3
|
+
import { createDshBoundModelPort } from './dsh-text-model-adapter.js';
|
|
4
|
+
import { waitForModelWork } from './model-call-runtime.js';
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
import sharp from 'sharp';
|
|
7
|
+
import { prepareVisionImage } from './vision-image-input.js';
|
|
8
|
+
|
|
9
|
+
/** rc.2 只接受附件引用;业务钩子使用核验后真正发给宿主的图片。 */
|
|
10
|
+
export function createDshVisionModelPort(textModel, attachments, binding, { metadata, allowImageNormalization = false,
|
|
11
|
+
imageInput, orientImages = false, prepareImageRequest } = {}) {
|
|
12
|
+
if (!attachments || typeof attachments.saveImage !== 'function' || typeof attachments.readImage !== 'function') {
|
|
13
|
+
throw new ModelPortError('IRIS_MODEL_UNAVAILABLE', { stage: 'prepare' });
|
|
14
|
+
}
|
|
15
|
+
if (!metadata || metadata.provider !== binding?.provider || metadata.id !== binding?.model
|
|
16
|
+
|| !Array.isArray(metadata.inputModalities) || !metadata.inputModalities.includes('image')) {
|
|
17
|
+
throw new ModelPortError('IRIS_MODEL_INCOMPATIBLE', { stage: 'prepare' });
|
|
18
|
+
}
|
|
19
|
+
return createDshBoundModelPort(textModel, binding, { metadata, kind: 'vision', prepareImageRequest,
|
|
20
|
+
async prepareImage(image, signal) {
|
|
21
|
+
if (imageInput) image = await prepareVisionImage(image, imageInput, { signal, orient: orientImages });
|
|
22
|
+
const data = new Uint8Array(image.bytes);
|
|
23
|
+
// saveImage 在 rc.2 无取消参数;等待有界,迟到保存可留下 Host 附件,但绝不启动生成。
|
|
24
|
+
const ref = await waitForModelWork(() => attachments.saveImage({ data, mediaType: image.mediaType, name: 'iris-vision' }), signal);
|
|
25
|
+
if (!ref || typeof ref.attachmentId !== 'string' || !ref.attachmentId) {
|
|
26
|
+
throw new ModelPortError('IRIS_MODEL_INCOMPATIBLE', { stage: 'prepare' });
|
|
27
|
+
}
|
|
28
|
+
const stored = await waitForModelWork(() => attachments.readImage(ref, signal), signal);
|
|
29
|
+
if (!(stored?.data instanceof Uint8Array) || stored.mediaType !== undefined && stored.mediaType !== ref.mediaType) {
|
|
30
|
+
throw new ModelPortError('IRIS_MODEL_INCOMPATIBLE', { stage: 'prepare' });
|
|
31
|
+
}
|
|
32
|
+
const unchanged = ref.mediaType === image.mediaType && Buffer.from(stored.data).equals(Buffer.from(image.bytes));
|
|
33
|
+
if (!unchanged) {
|
|
34
|
+
// 正规宿主附件用内容哈希绑定读回字节;任意旧引用/损坏数据不能冒充压缩副本。
|
|
35
|
+
if (!allowImageNormalization || ref.attachmentId !== 'sha256:' + createHash('sha256').update(stored.data).digest('hex')
|
|
36
|
+
|| ref.bytes !== stored.data.byteLength) throw new ModelPortError('IRIS_MODEL_INCOMPATIBLE', { stage: 'prepare' });
|
|
37
|
+
const [source, normalized] = await waitForModelWork(() => Promise.all([
|
|
38
|
+
sharp(Buffer.from(image.bytes)).metadata(), sharp(Buffer.from(stored.data)).metadata()
|
|
39
|
+
]), signal);
|
|
40
|
+
const original = ref.originalDimensions || ref;
|
|
41
|
+
const width = source.autoOrient?.width || source.width, height = source.autoOrient?.height || source.height;
|
|
42
|
+
if (original.width !== width || original.height !== height || ref.width !== normalized.width || ref.height !== normalized.height
|
|
43
|
+
|| ref.width > width || ref.height > height
|
|
44
|
+
|| ref.mediaType !== { jpeg: 'image/jpeg', png: 'image/png', webp: 'image/webp', gif: 'image/gif' }[normalized.format]) {
|
|
45
|
+
throw new ModelPortError('IRIS_MODEL_INCOMPATIBLE', { stage: 'prepare' });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return { attachment: ref, image: { bytes: new Uint8Array(stored.data), mediaType: ref.mediaType } };
|
|
49
|
+
}
|
|
50
|
+
}).port;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 只在显式调用时读取默认选择/精确元数据;不把默认文本模型猜成视觉模型。 */
|
|
54
|
+
export async function prepareDshVisionModelPort(textModel, attachments, { signal, ...options } = {}) {
|
|
55
|
+
try { return await waitForModelWork(async () => {
|
|
56
|
+
if (typeof textModel?.currentSelection !== 'function' || typeof textModel?.resolveModelInfo !== 'function') {
|
|
57
|
+
throw new ModelPortError('IRIS_MODEL_UNAVAILABLE', { stage: 'prepare' });
|
|
58
|
+
}
|
|
59
|
+
if (typeof attachments?.saveImage !== 'function' || typeof attachments?.readImage !== 'function') {
|
|
60
|
+
throw new ModelPortError('IRIS_MODEL_UNAVAILABLE', { stage: 'prepare' });
|
|
61
|
+
}
|
|
62
|
+
const selection = textModel.currentSelection();
|
|
63
|
+
if (typeof selection?.provider !== 'string' || !selection.provider.trim()
|
|
64
|
+
|| typeof selection?.model !== 'string' || !selection.model.trim()) {
|
|
65
|
+
throw new ModelPortError('IRIS_MODEL_UNAVAILABLE', { stage: 'prepare' });
|
|
66
|
+
}
|
|
67
|
+
const binding = { provider: selection.provider, model: selection.model };
|
|
68
|
+
const metadata = await waitForModelWork(() => textModel.resolveModelInfo(binding.provider, binding.model, signal), signal);
|
|
69
|
+
return createDshVisionModelPort(textModel, attachments, binding, { metadata, ...options });
|
|
70
|
+
}, signal); }
|
|
71
|
+
catch (error) {
|
|
72
|
+
if (error instanceof ModelPortError) throw error;
|
|
73
|
+
throw new ModelPortError('IRIS_MODEL_REQUEST_FAILED', { stage: 'prepare', invocation: 'not_invoked' });
|
|
74
|
+
}
|
|
75
|
+
}
|
package/lib/generation-input.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import fs from 'node:fs';
|
|
4
5
|
|
|
5
6
|
const FIELDS = Object.freeze({
|
|
6
|
-
image: ['prompt', 'size', 'n'],
|
|
7
|
-
video: ['prompt', 'size', 'duration', 'img_data_url'],
|
|
7
|
+
image: ['prompt', 'size', 'n', 'source_artifact_id'],
|
|
8
|
+
video: ['prompt', 'size', 'duration', 'img_data_url', 'first_frame_path', 'audio_path', 'resolution'],
|
|
8
9
|
tts: ['text', 'voice'],
|
|
9
10
|
transcribe: ['audio_url', 'audio_path']
|
|
10
11
|
});
|
|
@@ -33,7 +34,7 @@ function text(value, field, maxLength, required = false) {
|
|
|
33
34
|
* 不读取配置或文件,不上传,不从旧 Task 恢复 Prompt/媒体地址。
|
|
34
35
|
* Host 附件解析和本地音频上传仍由入口负责。
|
|
35
36
|
*/
|
|
36
|
-
export function normalizeGenerationInput(capability, input, { allowAudioPath = false } = {}) {
|
|
37
|
+
export function normalizeGenerationInput(capability, input, { allowAudioPath = false, allowVideoPaths = false } = {}) {
|
|
37
38
|
const fields = Object.hasOwn(FIELDS, capability) ? FIELDS[capability] : null;
|
|
38
39
|
if (!fields) fail('不支持的生成能力');
|
|
39
40
|
if (!input || typeof input !== 'object' || Array.isArray(input)) fail('生成输入必须是 JSON 对象');
|
|
@@ -45,12 +46,40 @@ export function normalizeGenerationInput(capability, input, { allowAudioPath = f
|
|
|
45
46
|
const modelRef = String(input.model_ref || '').trim();
|
|
46
47
|
const providerInput = {};
|
|
47
48
|
let audioPath;
|
|
49
|
+
let sourceArtifactId;
|
|
50
|
+
let firstFramePath, videoMode;
|
|
51
|
+
if (capability === 'video') {
|
|
52
|
+
audioPath = String(input.audio_path || '').trim();
|
|
53
|
+
firstFramePath = String(input.first_frame_path || '').trim();
|
|
54
|
+
if ((audioPath || firstFramePath) && !allowVideoPaths) fail('本地视频输入需要支持上传的入口');
|
|
55
|
+
if (firstFramePath && input.img_data_url !== undefined) fail('首帧路径与 img_data_url 不能同时提供');
|
|
56
|
+
for (const file of [firstFramePath, audioPath].filter(Boolean)) {
|
|
57
|
+
if (!path.isAbsolute(file)) fail('首帧和音频路径必须是绝对路径');
|
|
58
|
+
try { if (!fs.statSync(file).isFile()) throw new Error(); }
|
|
59
|
+
catch (_) { fail('首帧或音频文件不存在或不是普通文件'); }
|
|
60
|
+
}
|
|
61
|
+
if (audioPath && !firstFramePath) fail('s2v 需要 first_frame_path 和 audio_path');
|
|
62
|
+
videoMode = audioPath ? 's2v' : firstFramePath || input.img_data_url ? 'i2v' : 't2v';
|
|
63
|
+
if (videoMode === 's2v') {
|
|
64
|
+
if (input.size !== undefined || input.duration !== undefined || input.img_data_url !== undefined) fail('s2v 不支持 size、duration 或 img_data_url');
|
|
65
|
+
const resolution = input.resolution ?? '480P';
|
|
66
|
+
if (!['480P', '720P'].includes(resolution)) fail('s2v resolution 只支持 480P 或 720P');
|
|
67
|
+
providerInput.resolution = resolution;
|
|
68
|
+
} else if (input.resolution !== undefined) fail('resolution 仅适用于 s2v');
|
|
69
|
+
}
|
|
48
70
|
if (capability === 'image' || capability === 'video') {
|
|
49
|
-
|
|
71
|
+
const prompt = text(input.prompt, 'prompt', 20000, videoMode !== 's2v');
|
|
72
|
+
if (prompt !== undefined) providerInput.prompt = prompt;
|
|
50
73
|
const size = text(input.size, 'size', 64);
|
|
51
74
|
if (size !== undefined) providerInput.size = size;
|
|
52
75
|
}
|
|
53
76
|
if (capability === 'image') {
|
|
77
|
+
if (input.source_artifact_id !== undefined) {
|
|
78
|
+
if (typeof input.source_artifact_id !== 'string' || !/^artifact_[a-f0-9]{24}$/.test(input.source_artifact_id)) {
|
|
79
|
+
fail('source_artifact_id 必须是有效的 Core Artifact ID');
|
|
80
|
+
}
|
|
81
|
+
sourceArtifactId = input.source_artifact_id;
|
|
82
|
+
}
|
|
54
83
|
const n = input.n === undefined ? 1 : Number(input.n);
|
|
55
84
|
if (!Number.isSafeInteger(n) || n < 1 || n > 4) fail('n 必须为 1–4 的整数');
|
|
56
85
|
providerInput.n = n;
|
|
@@ -90,6 +119,8 @@ export function normalizeGenerationInput(capability, input, { allowAudioPath = f
|
|
|
90
119
|
return Object.freeze({
|
|
91
120
|
modelRef,
|
|
92
121
|
providerInput: Object.freeze(providerInput),
|
|
93
|
-
...(
|
|
122
|
+
...(sourceArtifactId ? { sourceArtifactId } : {}),
|
|
123
|
+
...(audioPath ? { audioPath } : {}),
|
|
124
|
+
...(capability === 'video' ? { firstFramePath, videoMode } : {})
|
|
94
125
|
});
|
|
95
126
|
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/** 独立视觉入口:显式配置/媒体,已有 Core 帧只读,主动转写使用 writer。 */
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
7
|
+
import sharp from 'sharp';
|
|
8
|
+
import { CommandError } from './command-service.js';
|
|
9
|
+
import { visionCandidatesFromCatalog, transcribeCandidatesFromCatalog, providerForTaskFromCatalog, providerTaskBinding } from './provider-catalog.js';
|
|
10
|
+
import { createConfiguredVisionModelPort, readVisionImageFile } from './vision-model-routing.js';
|
|
11
|
+
import { createConfiguredProviderAdapter } from './provider-adapters.js';
|
|
12
|
+
import { prepareProviderInput } from './provider-adapter.js';
|
|
13
|
+
import { createProviderTaskRunner } from './provider-task-runner.js';
|
|
14
|
+
import { CoreArtifactError, readCoreArtifactBytes } from './core-artifacts.js';
|
|
15
|
+
import { createModelOperation } from './model-invoker.js';
|
|
16
|
+
import { ModelPortError, modelErrorRecord } from './model-port-contract.js';
|
|
17
|
+
import { completeVision, VISION_BUDGET } from './vision-core.js';
|
|
18
|
+
import { locateObject, createLocateImageRequest } from './locate.js';
|
|
19
|
+
import { createVisionInputObserver } from './vision-image-input.js';
|
|
20
|
+
import { longOcr, normalizeOcrSettings, formatOcrResult } from './ocr.js';
|
|
21
|
+
import { summarizeMedia } from './summarize.js';
|
|
22
|
+
import { normalizeSummarySource, readSummaryFrames, summaryFrameRecords } from './summary-input.js';
|
|
23
|
+
import { normalizeVisionImageSource, readVisionArtifact } from './vision-input.js';
|
|
24
|
+
import { extractFrames, normalizeFramesOptions, probeVideo, extractAudioTrack, ffmpegAvailable } from './media-probe.js';
|
|
25
|
+
import { waitForModelWork } from './model-call-runtime.js';
|
|
26
|
+
|
|
27
|
+
function invalid(message) { throw new CommandError('IRIS_COMMAND_INPUT_INVALID', message); }
|
|
28
|
+
|
|
29
|
+
export function normalizeVisionInput(command, input) {
|
|
30
|
+
const fields = {
|
|
31
|
+
look: ['image_path', 'artifact_id', 'question'], locate: ['image_path', 'artifact_id', 'target'],
|
|
32
|
+
ocr: ['image_path', 'artifact_id', 'chunk_height', 'overlap', 'max_dimension', 'max_invocations'],
|
|
33
|
+
summarize: ['video_path', 'frame_artifact_ids', 'question', 'max_frames', 'target_width', 'transcribe_text', 'transcribe', 'transcribe_model_ref']
|
|
34
|
+
}[command];
|
|
35
|
+
if (!fields) invalid('未知视觉命令');
|
|
36
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) invalid('--input 必须是 JSON 对象');
|
|
37
|
+
if (Object.keys(input).some(key => !fields.includes(key))) invalid('视觉输入含不支持的字段');
|
|
38
|
+
const fileField = command === 'summarize' ? 'video_path' : 'image_path';
|
|
39
|
+
const fileSource = command === 'summarize' ? normalizeSummarySource(input) === 'video'
|
|
40
|
+
: normalizeVisionImageSource(input) === 'image_path';
|
|
41
|
+
if (fileSource) {
|
|
42
|
+
if (typeof input[fileField] !== 'string' || !path.isAbsolute(input[fileField])) invalid(fileField + ' 必须是绝对路径');
|
|
43
|
+
try { if (!fs.statSync(input[fileField]).isFile()) throw new Error(); }
|
|
44
|
+
catch (_) { invalid('输入媒体文件不存在或不是普通文件'); }
|
|
45
|
+
}
|
|
46
|
+
for (const field of ['question', 'target', 'transcribe_text', 'transcribe_model_ref']) {
|
|
47
|
+
if (input[field] !== undefined && typeof input[field] !== 'string') invalid(field + ' 必须是字符串');
|
|
48
|
+
}
|
|
49
|
+
if (command === 'locate' && !input.target?.trim()) invalid('target 必须是非空字符串');
|
|
50
|
+
if (input.transcribe !== undefined && typeof input.transcribe !== 'boolean') invalid('transcribe 必须是布尔值');
|
|
51
|
+
if (input.transcribe && input.transcribe_text?.trim()) invalid('transcribe 与 transcribe_text 不能同时使用');
|
|
52
|
+
if (input.transcribe_model_ref !== undefined && !input.transcribe) invalid('transcribe_model_ref 需要 transcribe=true');
|
|
53
|
+
if (input.transcribe_model_ref !== undefined && !/^[^:]+::.+$/.test(input.transcribe_model_ref)) invalid('transcribe_model_ref 必须是 providerId::modelId');
|
|
54
|
+
if (command === 'ocr') normalizeOcrSettings({ chunkHeight: input.chunk_height, overlap: input.overlap,
|
|
55
|
+
maxDimension: input.max_dimension, maxInvocations: input.max_invocations });
|
|
56
|
+
if (command === 'summarize') {
|
|
57
|
+
try { normalizeFramesOptions({ maxFrames: input.max_frames, targetWidth: input.target_width }); }
|
|
58
|
+
catch (_) { invalid('max_frames 或 target_width 无效'); }
|
|
59
|
+
}
|
|
60
|
+
return input;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function selectedModel(completion, routes) {
|
|
64
|
+
const route = routes.find(value => value.provider.id === completion.identity?.providerId && value.model === completion.identity?.modelId);
|
|
65
|
+
return route ? { modelRef: route.modelRef, selectionReason: route.selectionReason } : {};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function completionResult(completion, routes) {
|
|
69
|
+
const { sheet: _sheet, ...result } = completion;
|
|
70
|
+
return { ...result, ...selectedModel(completion, routes) };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function transcribeVideo({ catalog, input, runtime, operation, onRateLimit }) {
|
|
74
|
+
const routes = transcribeCandidatesFromCatalog(catalog, input.transcribe_model_ref);
|
|
75
|
+
const candidates = routes.map(route => ({ adapter: createConfiguredProviderAdapter(route.provider, { onRateLimit }),
|
|
76
|
+
model: route.modelRef, selectionReason: route.selectionReason, providerBinding: providerTaskBinding(route.provider) }));
|
|
77
|
+
const audio = await waitForModelWork(() => extractAudioTrack({ inputPath: input.video_path, signal: operation.signal }), operation.signal);
|
|
78
|
+
const abortRuntime = () => { void runtime.dispose().catch(() => {}); };
|
|
79
|
+
operation.signal.addEventListener('abort', abortRuntime, { once: true });
|
|
80
|
+
let task;
|
|
81
|
+
try {
|
|
82
|
+
const prepared = await waitForModelWork(() => prepareProviderInput(candidates[0].adapter, {
|
|
83
|
+
model: routes[0].model, filePath: audio.filePath, signal: operation.signal
|
|
84
|
+
}), operation.signal);
|
|
85
|
+
const runner = createProviderTaskRunner(runtime);
|
|
86
|
+
const submitted = await waitForModelWork(() => runner.submit({ capability: 'transcribe', candidates,
|
|
87
|
+
providerInput: { audioUrl: prepared.url } }), operation.signal, 'invoke', 'unknown');
|
|
88
|
+
task = submitted.task;
|
|
89
|
+
if (task.acceptance === 'accepted' && task.outcome === 'none') {
|
|
90
|
+
const adapter = createConfiguredProviderAdapter(providerForTaskFromCatalog(catalog, task), { onRateLimit,
|
|
91
|
+
modelId: routes.find(route => route.modelRef === task.modelRef)?.model });
|
|
92
|
+
while (task.outcome === 'none') {
|
|
93
|
+
task = await waitForModelWork(() => runner.observe(task.id, adapter), operation.signal, 'read', 'responded');
|
|
94
|
+
if (task.outcome === 'none') await waitForModelWork(() => delay(1000, undefined, { signal: operation.signal }), operation.signal);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const report = { taskId: task.id, modelRef: task.modelRef, artifactIds: task.artifactIds, status: 'failed' };
|
|
98
|
+
if (task.outcome !== 'succeeded' || task.deliveryState !== 'ready') return { report, text: '' };
|
|
99
|
+
const texts = await runtime.run('inspect', ({ dataRoot }) => task.artifactIds.map(id => {
|
|
100
|
+
const artifact = readCoreArtifactBytes(dataRoot, id);
|
|
101
|
+
return artifact.artifact.mediaType.startsWith('text/') ? artifact.bytes.toString('utf8') : '';
|
|
102
|
+
}));
|
|
103
|
+
return { report: { ...report, status: 'complete' }, text: texts.filter(Boolean).join('\n') };
|
|
104
|
+
} catch (error) {
|
|
105
|
+
const record = modelErrorRecord(error);
|
|
106
|
+
if (['IRIS_MODEL_ABORTED', 'IRIS_MODEL_TIMEOUT'].includes(record.code)) throw error;
|
|
107
|
+
return { report: { status: 'failed', code: record.code, ...(task ? { taskId: task.id, modelRef: task.modelRef, artifactIds: task.artifactIds } : {}) }, text: '' };
|
|
108
|
+
} finally {
|
|
109
|
+
operation.signal.removeEventListener('abort', abortRuntime);
|
|
110
|
+
fs.rmSync(audio.outDir, { recursive: true, force: true });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function executeVisionCommand({ command, input, catalog, modelRef = '', runtime, signal, timeoutMs = VISION_BUDGET.timeoutMs, onRateLimit }) {
|
|
115
|
+
normalizeVisionInput(command, input);
|
|
116
|
+
if (modelRef && !/^[^:]+::.+$/.test(modelRef)) invalid('--model-ref 必须是 providerId::modelId');
|
|
117
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > VISION_BUDGET.timeoutMs) invalid('--timeout-ms 必须是 1–120000 的整数');
|
|
118
|
+
if (input.transcribe && !runtime) invalid('主动音轨转写需要 --data-root');
|
|
119
|
+
if ((input.artifact_id || input.frame_artifact_ids) && !runtime) invalid('读取 Core Artifact 需要 --data-root');
|
|
120
|
+
const routes = visionCandidatesFromCatalog(catalog, modelRef);
|
|
121
|
+
const inputs = new Map();
|
|
122
|
+
const prepareImageRequest = command === 'locate' ? createLocateImageRequest(input.target, inputs)
|
|
123
|
+
: command === 'ocr' ? createVisionInputObserver(inputs) : undefined;
|
|
124
|
+
const ports = routes.map(route => createConfiguredVisionModelPort(route.provider, route.model, { onRateLimit,
|
|
125
|
+
prepareImages: ['look', 'ocr', 'locate'].includes(command), orientImages: command === 'locate', prepareImageRequest }));
|
|
126
|
+
const budget = { ...VISION_BUDGET, timeoutMs };
|
|
127
|
+
const operation = createModelOperation({ signal, budget: { timeoutMs, maxInvocations: command === 'ocr' ? input.max_invocations ?? 64 : ports.length } });
|
|
128
|
+
const base = { schemaVersion: 1, command: 'vision.' + command, status: 'complete',
|
|
129
|
+
...(input.artifact_id ? { artifactId: input.artifact_id } : {}) };
|
|
130
|
+
try {
|
|
131
|
+
if (command === 'summarize') {
|
|
132
|
+
let meta, frames;
|
|
133
|
+
if (input.frame_artifact_ids) {
|
|
134
|
+
({ meta, frames } = await readSummaryFrames(runtime, input.frame_artifact_ids, operation.signal));
|
|
135
|
+
} else {
|
|
136
|
+
if (!ffmpegAvailable()) throw new CommandError('IRIS_MEDIA_TOOL_UNAVAILABLE', '视频摘要需要 PATH 中的 ffmpeg 和 ffprobe');
|
|
137
|
+
meta = await waitForModelWork(() => probeVideo(input.video_path), operation.signal);
|
|
138
|
+
frames = await waitForModelWork(() => extractFrames({ inputPath: input.video_path,
|
|
139
|
+
maxFrames: input.max_frames, targetWidth: input.target_width, signal: operation.signal }), operation.signal);
|
|
140
|
+
}
|
|
141
|
+
let transcript = input.transcribe_text || '', transcription = { status: transcript.trim() ? 'provided' : 'disabled' };
|
|
142
|
+
if (input.transcribe) {
|
|
143
|
+
const asr = meta.hasAudio ? await transcribeVideo({ catalog, input, runtime, operation, onRateLimit }) : { report: { status: 'no_audio' }, text: '' };
|
|
144
|
+
transcript = asr.text; transcription = asr.report;
|
|
145
|
+
}
|
|
146
|
+
const completion = await summarizeMedia({ ports, frames, question: input.question, transcript, operation, budget });
|
|
147
|
+
const sheet = completion.sheet;
|
|
148
|
+
const note = transcription.status === 'failed' ? '音轨转写失败,本次摘要只分析画面。' : '';
|
|
149
|
+
if (note) transcription.note = note;
|
|
150
|
+
return { result: { ...base, ...completionResult(completion, routes), meta,
|
|
151
|
+
frames: summaryFrameRecords(frames), transcription,
|
|
152
|
+
contactSheet: { mediaType: 'image/png', width: sheet.width, height: sheet.height,
|
|
153
|
+
sha256: createHash('sha256').update(sheet.buffer).digest('hex') } }, text: completion.text + (note ? '\n\n[iris] ' + note : ''), sheetBuffer: sheet.buffer, exitCode: 0 };
|
|
154
|
+
}
|
|
155
|
+
let image;
|
|
156
|
+
if (input.artifact_id) image = await readVisionArtifact(runtime, input.artifact_id, operation.signal);
|
|
157
|
+
else {
|
|
158
|
+
const mediaType = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.gif': 'image/gif' }[path.extname(input.image_path).toLowerCase()];
|
|
159
|
+
if (!mediaType) invalid('图片只支持 PNG、JPEG、WebP、GIF');
|
|
160
|
+
image = await readVisionImageFile(input.image_path, mediaType, operation.signal);
|
|
161
|
+
}
|
|
162
|
+
if (command === 'ocr') {
|
|
163
|
+
const ocr = await longOcr({ image, ports, chunkHeight: input.chunk_height, overlap: input.overlap,
|
|
164
|
+
maxDimension: input.max_dimension, maxInvocations: input.max_invocations, operation, budget, inputs });
|
|
165
|
+
return { result: { ...base, ...ocr, chunks: ocr.chunks.map(chunk => ({ ...chunk, ...selectedModel(chunk, routes) })) },
|
|
166
|
+
text: formatOcrResult(ocr), exitCode: ocr.status === 'complete' ? 0 : 1 };
|
|
167
|
+
}
|
|
168
|
+
let completion;
|
|
169
|
+
if (command === 'locate') {
|
|
170
|
+
const { width, height } = await waitForModelWork(() => sharp(Buffer.from(image.bytes)).metadata(), operation.signal);
|
|
171
|
+
completion = await locateObject(ports, { target: input.target, image, width, height }, { operation, budget });
|
|
172
|
+
return { result: { ...base, ...completionResult(completion, routes), width, height,
|
|
173
|
+
input: inputs.get(completion.identity.backendId) }, text: JSON.stringify(completion.bbox), exitCode: 0 };
|
|
174
|
+
}
|
|
175
|
+
completion = await completeVision(ports, { prompt: input.question?.trim() || '请用中文描述这张图片的内容。', image }, { operation, budget });
|
|
176
|
+
return { result: { ...base, ...completionResult(completion, routes) }, text: completion.text, exitCode: 0 };
|
|
177
|
+
} catch (error) {
|
|
178
|
+
if (error instanceof ModelPortError || error instanceof CommandError || error instanceof CoreArtifactError || error.name === 'ProviderCatalogError') throw error;
|
|
179
|
+
throw new ModelPortError('IRIS_MODEL_INPUT_INVALID', { stage: 'prepare', invocation: 'not_invoked' });
|
|
180
|
+
} finally { operation.dispose(); }
|
|
181
|
+
}
|