@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,236 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { loadProviderCatalog, providerCatalogSnapshot, catalogCapabilitySnapshot } from './provider-catalog.js';
|
|
5
|
+
import { isKnownCapability } from './capability.js';
|
|
6
|
+
import { providerModels, modelPool, parseModelRef, capabilitiesOfModel, capabilitiesOfDiscoveredModel, resolvePoolModel } from './models.js';
|
|
7
|
+
import { atomicWritePrivate, privateSibling, ensurePrivateDir } from './private-storage.js';
|
|
8
|
+
import { createConfiguredProviderAdapter } from './provider-adapters.js';
|
|
9
|
+
import { selectImageProtocol, normalizeImageProtocol } from './provider-protocol.js';
|
|
10
|
+
import { invokeProviderOperation } from './provider-adapter.js';
|
|
11
|
+
import { redactProviderMessage } from './provider-contract.js';
|
|
12
|
+
import { modelRateLimit, recordModelRateLimit, clearModelRateLimit, clearModelCapabilityHealth, isDefinitiveHealthFailure } from './provider-health.js';
|
|
13
|
+
import { normalizeVisionInputLimits, visionInputLimits } from './vision-image-input.js';
|
|
14
|
+
|
|
15
|
+
const fail = message => { throw Object.assign(new Error(message), { code: 'IRIS_CONFIG_INPUT_INVALID' }); };
|
|
16
|
+
const hash = bytes => crypto.createHash('sha256').update(bytes).digest('hex');
|
|
17
|
+
const safe = value => redactProviderMessage(String(value || '').replace(/https?:\/\/[^\s<>"']+/gi, '[URL]'));
|
|
18
|
+
const text = value => { if (typeof value !== 'string' || !value.trim()) fail('ID 必须是非空字符串'); return value.trim(); };
|
|
19
|
+
const caps = value => {
|
|
20
|
+
if (!Array.isArray(value) || !value.every(isKnownCapability) || new Set(value).size !== value.length) fail('capabilities 必须是不重复的已知能力数组');
|
|
21
|
+
return value;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function assignmentSnapshot(catalog) {
|
|
25
|
+
const pool = modelPool(catalog.providers);
|
|
26
|
+
return Object.fromEntries(Object.entries(catalog.assignments || {}).map(([capability, value]) => {
|
|
27
|
+
const refs = (Array.isArray(value) ? value : [value]).map(ref => resolvePoolModel(pool, ref, capability));
|
|
28
|
+
return [safe(capability), { model_refs: refs.filter(Boolean).map(model => safe(model.ref)), invalidRefs: refs.filter(ref => !ref).length }];
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function configSnapshot(catalog) {
|
|
33
|
+
return { ...providerCatalogSnapshot(catalog), models: catalog.providers.flatMap(provider => providerModels(provider).map(model => ({
|
|
34
|
+
ref: safe(model.ref), providerId: safe(provider.id), id: safe(model.id), capabilities: model.capabilities,
|
|
35
|
+
...(model.imageProtocol !== undefined ? { imageProtocol: model.imageProtocol } : {}),
|
|
36
|
+
...(model.visionInput !== undefined ? { visionInput: model.visionInput } : {}),
|
|
37
|
+
...(model.capabilities.includes('vision') ? { visionInputEffective: visionInputLimits(provider, model.id) } : {}),
|
|
38
|
+
...(model.capabilities.includes('image-gen') ? { imageRouting: selectImageProtocol(provider, model.id) } : {}),
|
|
39
|
+
verified: Object.fromEntries(Object.entries(model.verified || {}).map(([capability, value]) => [capability, { ok: value.ok === true, at: safe(value.at), category: safe(value.category) }])),
|
|
40
|
+
...(model.rateLimited ? { rateLimited: true, rateLimitedAt: modelRateLimit(provider, model.id).at,
|
|
41
|
+
...(modelRateLimit(provider, model.id).until ? { retryAt: modelRateLimit(provider, model.id).until }
|
|
42
|
+
: { reason: modelRateLimit(provider, model.id).reason }) } : {})
|
|
43
|
+
}))), assignments: assignmentSnapshot(catalog), routing: catalogCapabilitySnapshot(catalog) };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function executeConfigCommand(configPath, command, input = {}, { signal, probe } = {}) {
|
|
47
|
+
if (command === 'init') {
|
|
48
|
+
if (!path.isAbsolute(configPath)) fail('provider-config 必须是绝对路径');
|
|
49
|
+
ensurePrivateDir(path.dirname(configPath));
|
|
50
|
+
fs.writeFileSync(configPath, JSON.stringify({ providers: [], assignments: {} }, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
|
|
51
|
+
return { command, created: true };
|
|
52
|
+
}
|
|
53
|
+
const catalog = loadProviderCatalog(configPath);
|
|
54
|
+
const before = fs.readFileSync(configPath);
|
|
55
|
+
if (JSON.stringify(JSON.parse(before)) !== JSON.stringify(catalog)) throw Object.assign(new Error('配置在读取期间发生变化,请重试'), { code: 'IRIS_CONFIG_CHANGED' });
|
|
56
|
+
if (command === 'show') return configSnapshot(catalog);
|
|
57
|
+
if (command === 'check') {
|
|
58
|
+
const issues = [];
|
|
59
|
+
const ids = catalog.providers.map(provider => provider.id);
|
|
60
|
+
if (new Set(ids).size !== ids.length) issues.push('duplicate_provider_id');
|
|
61
|
+
const pool = modelPool(catalog.providers);
|
|
62
|
+
for (const provider of catalog.providers) {
|
|
63
|
+
try { normalizeVisionInputLimits(provider.visionInput); } catch (_) { issues.push('invalid_vision_input'); }
|
|
64
|
+
for (const model of provider.models || []) {
|
|
65
|
+
try { normalizeVisionInputLimits(model?.visionInput); } catch (_) { issues.push('invalid_model_vision_input'); }
|
|
66
|
+
if (model?.imageProtocol === undefined) continue;
|
|
67
|
+
try { normalizeImageProtocol(model.imageProtocol); } catch (_) { issues.push('invalid_model_image_protocol'); }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
for (const [capability, assignment] of Object.entries(catalog.assignments || {})) {
|
|
71
|
+
if (!isKnownCapability(capability)) issues.push('unknown_assignment_capability');
|
|
72
|
+
for (const ref of (Array.isArray(assignment) ? assignment : [assignment])) if (!resolvePoolModel(pool, ref, capability)) issues.push('unavailable_assignment_model');
|
|
73
|
+
}
|
|
74
|
+
return { valid: issues.length === 0, issues: [...new Set(issues)] };
|
|
75
|
+
}
|
|
76
|
+
if (command === 'models.list') return { models: configSnapshot(catalog).models };
|
|
77
|
+
if (command === 'assignments.list') return { ...catalogCapabilitySnapshot(catalog), assignments: assignmentSnapshot(catalog) };
|
|
78
|
+
let result = { command, changed: true };
|
|
79
|
+
const initialPool = modelPool(catalog.providers);
|
|
80
|
+
let provider;
|
|
81
|
+
const providerId = input.provider_id || parseModelRef(input.model_ref)?.providerId || input.provider?.id;
|
|
82
|
+
if (providerId) {
|
|
83
|
+
const matches = catalog.providers.filter(value => value.id === providerId);
|
|
84
|
+
if (matches.length > 1) fail('Provider ID 不唯一');
|
|
85
|
+
provider = matches[0];
|
|
86
|
+
}
|
|
87
|
+
if (command === 'providers.add' || command === 'providers.set') {
|
|
88
|
+
const value = input.provider;
|
|
89
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) fail('需要 provider 对象');
|
|
90
|
+
const id = text(value.id);
|
|
91
|
+
if (command === 'providers.add' ? Boolean(provider) : !provider) fail(command === 'providers.add' ? 'Provider 已存在' : 'Provider 不存在');
|
|
92
|
+
const updated = { ...(provider || {}), ...value, id };
|
|
93
|
+
if (updated.visionInput !== undefined) updated.visionInput = normalizeVisionInputLimits(updated.visionInput) || null;
|
|
94
|
+
let url;
|
|
95
|
+
try { url = new URL(updated.baseUrl); } catch (_) { fail('baseUrl 必须是 HTTP(S) 地址'); }
|
|
96
|
+
if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) fail('baseUrl 含不支持的协议或凭据参数');
|
|
97
|
+
if (updated.enabled !== undefined && typeof updated.enabled !== 'boolean') fail('enabled 必须是布尔值');
|
|
98
|
+
if (updated.models !== undefined) {
|
|
99
|
+
if (!Array.isArray(updated.models)) fail('models 必须是数组');
|
|
100
|
+
for (const model of updated.models) {
|
|
101
|
+
text(typeof model === 'string' ? model : model?.id);
|
|
102
|
+
if (model?.capabilities !== undefined) caps(model.capabilities);
|
|
103
|
+
}
|
|
104
|
+
updated.models = updated.models.map(model => typeof model === 'string' ? model : ({ ...model,
|
|
105
|
+
...(model?.imageProtocol !== undefined ? { imageProtocol: normalizeImageProtocol(model.imageProtocol) } : {}),
|
|
106
|
+
...(model?.visionInput !== undefined ? { visionInput: normalizeVisionInputLimits(model.visionInput) || null } : {}) }));
|
|
107
|
+
}
|
|
108
|
+
if (provider) {
|
|
109
|
+
for (const model of providerModels(provider)) {
|
|
110
|
+
if (selectImageProtocol(provider, model.id).mediaProtocol !== selectImageProtocol(updated, model.id).mediaProtocol) {
|
|
111
|
+
clearModelCapabilityHealth(updated, model.id, 'image-gen');
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (provider) catalog.providers[catalog.providers.indexOf(provider)] = updated;
|
|
116
|
+
else catalog.providers.push(updated);
|
|
117
|
+
} else if (command === 'providers.remove') {
|
|
118
|
+
if (!provider) fail('Provider 不存在');
|
|
119
|
+
catalog.providers.splice(catalog.providers.indexOf(provider), 1);
|
|
120
|
+
} else if (command.startsWith('models.')) {
|
|
121
|
+
if (!provider) fail('Provider 不存在');
|
|
122
|
+
const parsed = parseModelRef(input.model_ref);
|
|
123
|
+
const modelId = parsed?.modelId || input.model_id;
|
|
124
|
+
let entries = Array.isArray(provider.models) ? provider.models.map(model => typeof model === 'string' ? { id: model } : { ...model })
|
|
125
|
+
: providerModels(provider).map(({ id, capabilities }) => ({ id, capabilities }));
|
|
126
|
+
let entry = entries.find(model => model.id === modelId);
|
|
127
|
+
if (command === 'models.discover') {
|
|
128
|
+
const discovered = await invokeProviderOperation(createConfiguredProviderAdapter(provider), 'discover', { signal, timeoutMs: 15000 });
|
|
129
|
+
const models = discovered.models.map(model => ({ id: text(typeof model === 'string' ? model : model.id), capabilities: capabilitiesOfDiscoveredModel(model), source: 'discovered' }));
|
|
130
|
+
result = { command, changed: input.apply === true, total: models.length, mediaCount: models.filter(model => model.capabilities.length).length, models: models.map(model => ({ ...model, id: safe(model.id) })) };
|
|
131
|
+
if (input.apply !== true) return result;
|
|
132
|
+
for (const model of models) {
|
|
133
|
+
if (!model.capabilities.length) continue;
|
|
134
|
+
const old = entries.find(value => value.id === model.id);
|
|
135
|
+
if (!old) entries.push(model);
|
|
136
|
+
else if (old.source !== 'manual') Object.assign(old, model);
|
|
137
|
+
}
|
|
138
|
+
} else if (command === 'models.add') {
|
|
139
|
+
text(modelId);
|
|
140
|
+
const capabilities = input.capabilities === undefined ? capabilitiesOfModel(modelId) : caps(input.capabilities);
|
|
141
|
+
if (entry) fail('模型已存在;请用 models caps 修改能力');
|
|
142
|
+
entries.push({ id: modelId, capabilities, source: 'manual',
|
|
143
|
+
...(input.imageProtocol !== undefined ? { imageProtocol: normalizeImageProtocol(input.imageProtocol) } : {}),
|
|
144
|
+
...(input.visionInput !== undefined ? { visionInput: normalizeVisionInputLimits(input.visionInput) || null } : {}) });
|
|
145
|
+
} else if (command === 'models.remove') {
|
|
146
|
+
if (!entry) fail('模型不存在');
|
|
147
|
+
entries = entries.filter(model => model.id !== modelId);
|
|
148
|
+
} else if (command === 'models.caps') {
|
|
149
|
+
if (!entry) fail('模型不存在');
|
|
150
|
+
entry.capabilities = caps(input.capabilities); entry.source = 'manual';
|
|
151
|
+
} else if (command === 'models.protocol') {
|
|
152
|
+
if (!entry) fail('模型不存在');
|
|
153
|
+
const protocol = normalizeImageProtocol(input.imageProtocol);
|
|
154
|
+
const previous = selectImageProtocol(provider, modelId).mediaProtocol;
|
|
155
|
+
if (protocol === 'auto') delete entry.imageProtocol;
|
|
156
|
+
else entry.imageProtocol = protocol;
|
|
157
|
+
provider.models = entries;
|
|
158
|
+
if (previous !== selectImageProtocol(provider, modelId).mediaProtocol) clearModelCapabilityHealth(provider, modelId, 'image-gen');
|
|
159
|
+
result = { ...result, model_ref: safe(input.model_ref), imageRouting: selectImageProtocol(provider, modelId) };
|
|
160
|
+
} else if (command === 'models.vision-input') {
|
|
161
|
+
if (!entry || !Object.hasOwn(input, 'visionInput')) fail('需要已存在的模型和 visionInput 对象或 null');
|
|
162
|
+
const limits = normalizeVisionInputLimits(input.visionInput);
|
|
163
|
+
if (limits) entry.visionInput = limits;
|
|
164
|
+
else delete entry.visionInput;
|
|
165
|
+
provider.models = entries;
|
|
166
|
+
result = { ...result, model_ref: safe(input.model_ref), visionInputEffective: visionInputLimits(provider, modelId) };
|
|
167
|
+
} else if (command === 'models.test') {
|
|
168
|
+
if (!entry || !isKnownCapability(input.capability) || !providerModels(provider).find(model => model.id === modelId)?.capabilities.includes(input.capability)) fail('模型不存在或没有指定能力');
|
|
169
|
+
if (typeof probe !== 'function') fail('模型实测缺少 Probe Port');
|
|
170
|
+
const tested = await probe({ provider, modelId, capability: input.capability, signal });
|
|
171
|
+
if (tested.skipped) return { command, changed: false, ...tested };
|
|
172
|
+
const evidence = { at: new Date().toISOString(), source: 'probe', category: tested.category || (tested.ok ? 'success' : 'unknown'),
|
|
173
|
+
...(tested.httpStatus || tested.status ? { httpStatus: tested.httpStatus || tested.status } : {}) };
|
|
174
|
+
entry.verified = { ...(entry.verified || {}), [input.capability]: { ok: tested.ok === true, ...evidence } };
|
|
175
|
+
provider.health ||= { version: 1, revision: 0, observations: [] };
|
|
176
|
+
const observations = provider.health.observations;
|
|
177
|
+
let observation = observations.find(value => value.modelId === modelId && value.capability === input.capability);
|
|
178
|
+
if (!observation) { observation = { modelId, capability: input.capability }; observations.push(observation); }
|
|
179
|
+
if (tested.ok === true) { observation.lastSuccess = evidence; clearModelRateLimit(provider, modelId); }
|
|
180
|
+
else {
|
|
181
|
+
observation[isDefinitiveHealthFailure(evidence) ? 'lastFailure' : 'lastTransient'] = evidence;
|
|
182
|
+
recordModelRateLimit(provider, modelId, evidence);
|
|
183
|
+
}
|
|
184
|
+
result = { command, changed: true, passed: tested.ok === true, ...(tested.taskId ? { taskId: tested.taskId } : {}), ...(tested.code ? { code: tested.code } : {}) };
|
|
185
|
+
} else fail('未知模型命令');
|
|
186
|
+
provider.models = entries;
|
|
187
|
+
} else if (command === 'assignments.set' || command === 'assignments.clear') {
|
|
188
|
+
if (!isKnownCapability(input.capability)) fail('未知能力');
|
|
189
|
+
catalog.assignments ||= {};
|
|
190
|
+
if (command === 'assignments.clear') delete catalog.assignments[input.capability];
|
|
191
|
+
else {
|
|
192
|
+
if (!Array.isArray(input.model_refs) || new Set(input.model_refs).size !== input.model_refs.length) fail('model_refs 必须是不重复的复合引用数组');
|
|
193
|
+
const pool = modelPool(catalog.providers);
|
|
194
|
+
for (const ref of input.model_refs) if (!parseModelRef(ref) || !resolvePoolModel(pool, ref, input.capability)) fail('分配模型不存在或没有该能力');
|
|
195
|
+
catalog.assignments[input.capability] = input.model_refs;
|
|
196
|
+
}
|
|
197
|
+
} else fail('未知配置命令');
|
|
198
|
+
|
|
199
|
+
// 移除/降级条目时剪去明确指向该条目的分配,保留其他账号和旧裸引用格式。
|
|
200
|
+
if (command === 'providers.remove' || command === 'models.remove' || command === 'models.caps') {
|
|
201
|
+
const pool = modelPool(catalog.providers);
|
|
202
|
+
for (const [capability, assignment] of Object.entries(catalog.assignments || {})) {
|
|
203
|
+
const values = (Array.isArray(assignment) ? assignment : [assignment]).filter(ref => resolvePoolModel(pool, ref, capability) || !resolvePoolModel(initialPool, ref, capability));
|
|
204
|
+
if (!values.length) delete catalog.assignments[capability];
|
|
205
|
+
else catalog.assignments[capability] = Array.isArray(assignment) ? values : values[0];
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
signal?.throwIfAborted();
|
|
209
|
+
return writeConfigChange(configPath, catalog, before, result);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** 请求边界持久化冷却或明确耗尽;沿用配置锁、备份与原子写入。 */
|
|
213
|
+
export function recordCatalogRateLimit(configPath, event) {
|
|
214
|
+
const { providerId, modelId } = event;
|
|
215
|
+
const catalog = loadProviderCatalog(configPath);
|
|
216
|
+
const before = fs.readFileSync(configPath);
|
|
217
|
+
const provider = catalog.providers.find(value => value.id === providerId);
|
|
218
|
+
if (!provider || !recordModelRateLimit(provider, modelId, { ...event, source: 'task' })) return;
|
|
219
|
+
return writeConfigChange(configPath, catalog, before, { changed: true });
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function writeConfigChange(configPath, catalog, before, result) {
|
|
223
|
+
const lock = configPath + '.lock';
|
|
224
|
+
let fd;
|
|
225
|
+
try { fd = fs.openSync(lock, 'wx', 0o600); }
|
|
226
|
+
catch (_) { throw Object.assign(new Error('配置正在被另一进程修改'), { code: 'IRIS_CONFIG_BUSY' }); }
|
|
227
|
+
try {
|
|
228
|
+
loadProviderCatalog(configPath);
|
|
229
|
+
if (hash(fs.readFileSync(configPath)) !== hash(before)) throw Object.assign(new Error('配置在操作期间发生变化,请重新读取后重试'), { code: 'IRIS_CONFIG_CHANGED' });
|
|
230
|
+
const backupPath = privateSibling(configPath, 'backup');
|
|
231
|
+
fs.writeFileSync(backupPath, before, { flag: 'wx', mode: 0o600 });
|
|
232
|
+
atomicWritePrivate(configPath, JSON.stringify(catalog, null, 2) + '\n');
|
|
233
|
+
result.backupPath = backupPath;
|
|
234
|
+
return result;
|
|
235
|
+
} finally { fs.closeSync(fd); fs.unlinkSync(lock); }
|
|
236
|
+
}
|
package/lib/provider-contract.js
CHANGED
|
@@ -146,7 +146,8 @@ export function normalizeProviderArtifacts(value) {
|
|
|
146
146
|
}
|
|
147
147
|
if (item.kind === 'inline-base64') {
|
|
148
148
|
const data = String(item.data || '').trim();
|
|
149
|
-
|
|
149
|
+
// 避免按四字符重复分组:4K 图片的大 base64 正文会耗尽正则回溯栈。
|
|
150
|
+
if (!data || data.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(data)) {
|
|
150
151
|
throw new TypeError('Provider inline-base64 artifact 数据无效');
|
|
151
152
|
}
|
|
152
153
|
const padding = data.endsWith('==') ? 2 : (data.endsWith('=') ? 1 : 0);
|
|
@@ -287,7 +288,7 @@ export async function submitWithAcceptanceBoundary(candidates, input, hooks = {}
|
|
|
287
288
|
}
|
|
288
289
|
}
|
|
289
290
|
|
|
290
|
-
if (!allowsAutomaticFailover(result)) {
|
|
291
|
+
if (hooks.signal?.aborted || !allowsAutomaticFailover(result)) {
|
|
291
292
|
return { candidate, result, attempts, localError: null };
|
|
292
293
|
}
|
|
293
294
|
}
|
package/lib/provider-health.js
CHANGED
|
@@ -10,6 +10,85 @@ export const PROVIDER_HEALTH_STATES = Object.freeze([
|
|
|
10
10
|
]);
|
|
11
11
|
|
|
12
12
|
const DEFINITIVE_FAILURES = new Set(['authentication', 'auth', 'permission']);
|
|
13
|
+
export const MODEL_RATE_LIMIT_COOLDOWN_MS = 60000;
|
|
14
|
+
|
|
15
|
+
export function retryAfterDelay(value, now = Date.now()) {
|
|
16
|
+
const raw = String(value ?? '').trim();
|
|
17
|
+
if (!raw) return MODEL_RATE_LIMIT_COOLDOWN_MS;
|
|
18
|
+
const seconds = Number(raw);
|
|
19
|
+
if (Number.isFinite(seconds) && seconds >= 0) return Math.max(1000, seconds * 1000);
|
|
20
|
+
const at = Date.parse(raw);
|
|
21
|
+
return Number.isFinite(at) && at > now ? at - now : MODEL_RATE_LIMIT_COOLDOWN_MS;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 百炼“免费额度用完即停”返回 403,不应误归类为 Key 无效。 */
|
|
25
|
+
export function isFreeQuotaExhausted(value) {
|
|
26
|
+
const status = Number(value?.httpStatus ?? value?.status);
|
|
27
|
+
if (![403, 429].includes(status)) return false;
|
|
28
|
+
return status === 403 && (value.category === 'quota' || value.code === 'IRIS_MODEL_RATE_LIMITED')
|
|
29
|
+
|| (value.providerCode || value.code) === 'AllocationQuota.FreeTierOnly'
|
|
30
|
+
|| /\bfree (?:allocated quota|quota|tier)\b[^\n]{0,120}\b(?:exhausted|expired|exceeded)\b/i.test(String(value.message || value.safeMessage || value.note || ''));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function quotaExhaustionReason(value) {
|
|
34
|
+
if (isFreeQuotaExhausted(value) || value?.reason === 'free_quota') return 'free_quota';
|
|
35
|
+
const status = Number(value?.httpStatus ?? value?.status);
|
|
36
|
+
if (status !== 429) return null;
|
|
37
|
+
if ((value.providerCode || value.code) === 'BudgetLimitExceeded' || value.reason === 'budget') return 'budget';
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 短时限流到期即可参与候选;额度/预算耗尽只有成功实测才解除。 */
|
|
42
|
+
export function modelRateLimit(provider, modelId, { now = Date.now() } = {}) {
|
|
43
|
+
const blocked = provider?.health?.rateLimits?.find(item => item.modelId === modelId);
|
|
44
|
+
return blocked && (!blocked.until || Date.parse(blocked.until) > now) ? blocked : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function recordModelRateLimit(provider, modelId, result) {
|
|
48
|
+
const status = Number(result?.httpStatus ?? result?.status);
|
|
49
|
+
const reason = quotaExhaustionReason(result);
|
|
50
|
+
const category = reason ? 'quota' : status === 429 ? 'rate_limit' : null;
|
|
51
|
+
const existing = modelRateLimit(provider, modelId);
|
|
52
|
+
if (!category || !modelId || existing && (!reason || existing.reason)) return false;
|
|
53
|
+
provider.health ||= { version: 1, revision: 0, observations: [] };
|
|
54
|
+
provider.health.rateLimits ||= [];
|
|
55
|
+
const at = Date.parse(result.at || '');
|
|
56
|
+
const timestamp = Number.isFinite(at) ? at : Date.now();
|
|
57
|
+
const delay = Number.isFinite(result.retryAfterMs) ? result.retryAfterMs : MODEL_RATE_LIMIT_COOLDOWN_MS;
|
|
58
|
+
provider.health.rateLimits = provider.health.rateLimits.filter(item => item.modelId !== modelId);
|
|
59
|
+
provider.health.rateLimits.push({ modelId,
|
|
60
|
+
at: new Date(timestamp).toISOString(),
|
|
61
|
+
source: result.source === 'probe' ? 'probe' : 'task', category, httpStatus: status,
|
|
62
|
+
...(reason ? { reason } : { until: result.until || new Date(timestamp + Math.max(1000, delay)).toISOString() }) });
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 只有显式模型实测通过才调用;普通任务成功不能解除停用。 */
|
|
67
|
+
export function clearModelRateLimit(provider, modelId) {
|
|
68
|
+
if (!provider?.health?.rateLimits) return;
|
|
69
|
+
provider.health.rateLimits = provider.health.rateLimits.filter(item => item.modelId !== modelId);
|
|
70
|
+
if (!provider.health.rateLimits.length) delete provider.health.rateLimits;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 配置改变时使一项能力验证失效;保留同模型的限流/耗尽资格和其他能力。 */
|
|
74
|
+
export function clearModelCapabilityHealth(provider, modelId, capability) {
|
|
75
|
+
if (provider.health?.observations) {
|
|
76
|
+
provider.health = { ...provider.health, revision: (provider.health.revision || 0) + 1,
|
|
77
|
+
observations: provider.health.observations.filter(item => item.modelId !== modelId || item.capability !== capability) };
|
|
78
|
+
}
|
|
79
|
+
const entry = provider.models?.find(model => model?.id?.trim() === modelId);
|
|
80
|
+
if (entry?.verified) {
|
|
81
|
+
entry.verified = { ...entry.verified };
|
|
82
|
+
delete entry.verified[capability];
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function reportModelRateLimit(provider, modelId, result, onRateLimit, capability) {
|
|
87
|
+
if (!recordModelRateLimit(provider, modelId, result)) return;
|
|
88
|
+
const blocked = modelRateLimit(provider, modelId);
|
|
89
|
+
try { onRateLimit?.({ providerId: provider.id, capability, ...blocked }); }
|
|
90
|
+
catch (_) { console.warn('[iris] 模型停用状态未能持久化,请检查配置写入权限'); }
|
|
91
|
+
}
|
|
13
92
|
|
|
14
93
|
function validTime(value) {
|
|
15
94
|
const ms = Date.parse(String(value || ''));
|
|
@@ -19,7 +98,7 @@ function validTime(value) {
|
|
|
19
98
|
export function isDefinitiveHealthFailure(value) {
|
|
20
99
|
const status = Number(value && (value.httpStatus ?? value.status));
|
|
21
100
|
const category = String(value && value.category || '').toLowerCase();
|
|
22
|
-
return status === 401 || status === 403 || DEFINITIVE_FAILURES.has(category);
|
|
101
|
+
return !isFreeQuotaExhausted(value) && (status === 401 || status === 403 || DEFINITIVE_FAILURES.has(category));
|
|
23
102
|
}
|
|
24
103
|
|
|
25
104
|
export function healthObservationState(observation, {
|
package/lib/provider-protocol.js
CHANGED
|
@@ -45,6 +45,29 @@ export function selectMediaProtocol(provider) {
|
|
|
45
45
|
return { mediaProtocol: detected || 'openai-images', protocolInferred: !detected };
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/** 模型图片协议只覆盖生图;auto 沿用账号级默认,不另外猜测端点。 */
|
|
49
|
+
export function normalizeImageProtocol(value) {
|
|
50
|
+
const protocol = typeof value === 'string' ? value.trim() : '';
|
|
51
|
+
if (!['auto', 'dashscope', 'openai-images', 'openai-chat-images', 'openai-responses-images'].includes(protocol)) {
|
|
52
|
+
throw Object.assign(new TypeError('imageProtocol 必须是 auto、dashscope、openai-images、openai-chat-images 或 openai-responses-images'), {
|
|
53
|
+
code: 'IRIS_PROVIDER_IMAGE_PROTOCOL_INVALID'
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return protocol;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function selectImageProtocol(provider, modelId) {
|
|
60
|
+
const entry = Array.isArray(provider?.models) ? provider.models.find(model => model?.id?.trim() === modelId) : null;
|
|
61
|
+
if (entry?.imageProtocol === undefined) return selectMediaProtocol(provider);
|
|
62
|
+
const protocol = normalizeImageProtocol(entry.imageProtocol);
|
|
63
|
+
return protocol === 'auto' ? selectMediaProtocol(provider) : { mediaProtocol: protocol, protocolInferred: false };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 给已绑定图片模型的 Adapter/binding 投影协议,不改写账号默认值。 */
|
|
67
|
+
export function providerForImageModel(provider, modelId) {
|
|
68
|
+
return { ...provider, ...selectImageProtocol(provider, modelId) };
|
|
69
|
+
}
|
|
70
|
+
|
|
48
71
|
export function unsupportedProtocolError(protocol, kind = 'media') {
|
|
49
72
|
const safeName = /^[a-z][a-z0-9-]{0,63}$/.test(String(protocol)) ? protocol : '(invalid)';
|
|
50
73
|
const error = new TypeError('iris: 不支持的 ' + (kind === 'vision' ? 'Vision' : 'Provider') + ' 协议:' + safeName);
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import crypto from 'node:crypto';
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
+
import sharp from 'sharp';
|
|
6
7
|
import { createCoreArtifact } from './core-artifacts.js';
|
|
7
8
|
import {
|
|
8
9
|
activateCoreWatch,
|
|
@@ -28,7 +29,8 @@ import {
|
|
|
28
29
|
hasProviderOperation,
|
|
29
30
|
invokeProviderOperation
|
|
30
31
|
} from './provider-adapter.js';
|
|
31
|
-
import { providerErrorRecord, submitWithAcceptanceBoundary } from './provider-contract.js';
|
|
32
|
+
import { ProviderContractError, providerErrorRecord, submitWithAcceptanceBoundary } from './provider-contract.js';
|
|
33
|
+
import { imageEditCandidates, readImageEditArtifact } from './image-edit-input.js';
|
|
32
34
|
|
|
33
35
|
export const PROVIDER_TASK_RUNNER_VERSION = 0;
|
|
34
36
|
|
|
@@ -38,7 +40,7 @@ export const PROVIDER_TASK_RUNNER_VERSION = 0;
|
|
|
38
40
|
* 失败,不会按图片语义猜。
|
|
39
41
|
*/
|
|
40
42
|
export const DELIVERY_PROFILES = Object.freeze({
|
|
41
|
-
image: Object.freeze({ kind: 'generated-image', mediaTypes: Object.freeze(['image/png']), defaultMediaType: 'image/png' }),
|
|
43
|
+
image: Object.freeze({ kind: 'generated-image', mediaTypes: Object.freeze(['image/png', 'image/jpeg', 'image/webp']), defaultMediaType: 'image/png' }),
|
|
42
44
|
video: Object.freeze({ kind: 'generated-video', mediaTypes: Object.freeze(['video/mp4']), defaultMediaType: 'video/mp4' }),
|
|
43
45
|
// TTS 同步完成型:submit 内 completed → 同一次调用直接交付(无 remote 长轮询)。
|
|
44
46
|
tts: Object.freeze({ kind: 'generated-audio', mediaTypes: Object.freeze(['audio/mpeg', 'audio/wav']), defaultMediaType: 'audio/mpeg' }),
|
|
@@ -81,7 +83,8 @@ function normalizedCandidates(capability, candidates) {
|
|
|
81
83
|
|| (selectionReason !== undefined && !['explicit', 'assignment', 'pool'].includes(selectionReason))) {
|
|
82
84
|
throw new TypeError('Provider 候选 ' + (index + 1) + ' 不支持 Task capability 或模型复合身份无效');
|
|
83
85
|
}
|
|
84
|
-
|
|
86
|
+
if (candidate.prepareInput !== undefined && typeof candidate.prepareInput !== 'function') throw new TypeError('候选 prepareInput 必须是函数');
|
|
87
|
+
return { adapter, model, providerModel: parsed.modelId, providerBinding: candidate.providerBinding, selectionReason, prepareInput: candidate.prepareInput };
|
|
85
88
|
});
|
|
86
89
|
}
|
|
87
90
|
|
|
@@ -91,6 +94,21 @@ function stagingFile(dataRoot) {
|
|
|
91
94
|
return path.join(directory, 'download-' + process.pid + '-' + crypto.randomBytes(8).toString('hex') + '.part');
|
|
92
95
|
}
|
|
93
96
|
|
|
97
|
+
async function generatedImageMediaType(bytes) {
|
|
98
|
+
let format;
|
|
99
|
+
try { format = (await sharp(bytes).metadata()).format; }
|
|
100
|
+
catch (_) {
|
|
101
|
+
throw new ProviderContractError('供应商产物不是可识别的图片', {
|
|
102
|
+
stage: 'download', category: 'protocol', acceptance: 'accepted'
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
const mediaType = { png: 'image/png', jpeg: 'image/jpeg', webp: 'image/webp' }[format];
|
|
106
|
+
if (!mediaType) throw new ProviderContractError('供应商图片格式仅支持 PNG、JPEG、WebP', {
|
|
107
|
+
stage: 'download', category: 'protocol', acceptance: 'accepted'
|
|
108
|
+
});
|
|
109
|
+
return mediaType;
|
|
110
|
+
}
|
|
111
|
+
|
|
94
112
|
async function deliverArtifacts(dataRoot, taskId, adapter, remoteArtifacts, signal) {
|
|
95
113
|
beginCoreDelivery(dataRoot, taskId);
|
|
96
114
|
const artifactIds = [];
|
|
@@ -110,7 +128,7 @@ async function deliverArtifacts(dataRoot, taskId, adapter, remoteArtifacts, sign
|
|
|
110
128
|
}
|
|
111
129
|
for (const remote of remoteArtifacts) {
|
|
112
130
|
const mediaType = String(remote.mediaType || profile.defaultMediaType);
|
|
113
|
-
if (!profile.mediaTypes.includes(mediaType)) {
|
|
131
|
+
if (task.capability !== 'image' && !profile.mediaTypes.includes(mediaType)) {
|
|
114
132
|
throw Object.assign(new Error('Provider Task Runner 交付媒体类型不在 Profile 白名单:' + mediaType), {
|
|
115
133
|
stage: 'download', category: 'protocol', acceptance: 'accepted'
|
|
116
134
|
});
|
|
@@ -129,14 +147,20 @@ async function deliverArtifacts(dataRoot, taskId, adapter, remoteArtifacts, sign
|
|
|
129
147
|
stage: 'download', category: 'local_io', acceptance: 'accepted'
|
|
130
148
|
});
|
|
131
149
|
}
|
|
150
|
+
const staged = stagedFiles.at(-1);
|
|
151
|
+
if (task.capability === 'image') {
|
|
152
|
+
staged.bytes = fs.readFileSync(target);
|
|
153
|
+
staged.mediaType = await generatedImageMediaType(staged.bytes);
|
|
154
|
+
}
|
|
132
155
|
}
|
|
133
156
|
recordCoreDownloadCompleted(dataRoot, taskId);
|
|
134
157
|
for (const staged of stagedFiles) {
|
|
135
158
|
const artifact = createCoreArtifact(dataRoot, {
|
|
136
159
|
kind: profile.kind,
|
|
137
160
|
mediaType: staged.mediaType,
|
|
138
|
-
bytes: fs.readFileSync(staged.target),
|
|
139
|
-
metadata: { taskId, capability: task.capability }
|
|
161
|
+
bytes: staged.bytes || fs.readFileSync(staged.target),
|
|
162
|
+
metadata: { taskId, capability: task.capability },
|
|
163
|
+
...(task.sourceArtifactId ? { relations: [{ type: 'derived-from', artifactId: task.sourceArtifactId }] } : {})
|
|
140
164
|
});
|
|
141
165
|
artifactIds.push(artifact.id);
|
|
142
166
|
}
|
|
@@ -199,21 +223,39 @@ export function createProviderTaskRunner(runtime) {
|
|
|
199
223
|
|
|
200
224
|
async function submit(input = {}) {
|
|
201
225
|
return runtime.run('execute', async ({ dataRoot, signal }) => {
|
|
226
|
+
if (signal.aborted) throw new ProviderTaskRunnerError('IRIS_PROVIDER_ABORTED', '提交已取消');
|
|
202
227
|
const capability = String(input.capability || '').trim();
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
228
|
+
let candidates = normalizedCandidates(capability, input.candidates);
|
|
229
|
+
let providerInput = input.providerInput ?? {};
|
|
230
|
+
if (input.sourceArtifactId !== undefined) {
|
|
231
|
+
if (capability !== 'image') throw new TypeError('sourceArtifactId 仅用于图片编辑');
|
|
232
|
+
candidates = imageEditCandidates(candidates);
|
|
233
|
+
const image = await readImageEditArtifact(dataRoot, input.sourceArtifactId, signal);
|
|
234
|
+
providerInput = { ...providerInput, image };
|
|
235
|
+
}
|
|
236
|
+
const task = createCoreTask(dataRoot, { capability, retriedFrom: input.retriedFrom, sourceArtifactId: input.sourceArtifactId });
|
|
237
|
+
const wrapped = candidates.map(({ adapter, model, providerModel, prepareInput }) => ({
|
|
206
238
|
id: adapter.id,
|
|
207
239
|
model,
|
|
208
240
|
adapter,
|
|
209
|
-
submit: (providerInput, context) =>
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
241
|
+
submit: async (providerInput, context) => {
|
|
242
|
+
if (prepareInput) {
|
|
243
|
+
try { providerInput = await prepareInput({ providerInput, signal }); }
|
|
244
|
+
catch (error) { throw new ProviderContractError('视频输入准备失败:' + (error.safeMessage || error.message), {
|
|
245
|
+
...providerErrorRecord(error), stage: error.stage || 'prepare', category: error.category || 'local_io', acceptance: 'not_accepted'
|
|
246
|
+
}); }
|
|
247
|
+
}
|
|
248
|
+
if (signal.aborted) throw new ProviderContractError('提交已取消', { stage: 'prepare', category: 'aborted', acceptance: 'not_accepted' });
|
|
249
|
+
return invokeProviderOperation(
|
|
250
|
+
adapter,
|
|
251
|
+
'submit',
|
|
252
|
+
{ capability, model: providerModel, input: providerInput, signal },
|
|
253
|
+
{ ...context, signal, taskId: task.id }
|
|
254
|
+
);
|
|
255
|
+
}
|
|
215
256
|
}));
|
|
216
|
-
const submitted = await submitWithAcceptanceBoundary(wrapped,
|
|
257
|
+
const submitted = await submitWithAcceptanceBoundary(wrapped, providerInput, {
|
|
258
|
+
signal,
|
|
217
259
|
beforeAttempt: async (attempt) => {
|
|
218
260
|
const candidate = candidates[attempt.ordinal - 1];
|
|
219
261
|
return beginCoreAttempt(dataRoot, task.id, {
|
package/lib/summarize.js
CHANGED
|
@@ -3,16 +3,20 @@
|
|
|
3
3
|
* Iris 多模态上下文摘要(阶段 7.3)—— 把视频压成「一张联系表 + 一段转写文本」喂给视觉模型。
|
|
4
4
|
*
|
|
5
5
|
* 为什么是 contact sheet:
|
|
6
|
-
* -
|
|
6
|
+
* - Vision Port 一次 complete 只送一张图(字节与 MIME),
|
|
7
7
|
* 逐帧送 N 次要 N 次模型调用、费用与延迟都不可接受;
|
|
8
8
|
* - 把 N 帧缩略图按时间序拼成网格 + 每帧盖时间戳标签,一次视觉调用即可看全片(上下文压缩)。
|
|
9
9
|
*
|
|
10
10
|
* 契约:
|
|
11
|
-
* - buildContactSheet(frames, opts) → PNG buffer + 网格尺寸(纯 sharp,可离线测试)
|
|
12
|
-
* - summarizeMedia({
|
|
13
|
-
* →
|
|
11
|
+
* - buildContactSheet({frames, ...opts}) → PNG buffer + 网格尺寸(纯 sharp,可离线测试)
|
|
12
|
+
* - summarizeMedia({ports, frames, question?, transcript?, signal})
|
|
13
|
+
* → 一张拼图通过共享 Vision Ports,转写只消费显式输入
|
|
14
14
|
*/
|
|
15
15
|
import sharp from 'sharp';
|
|
16
|
+
import { completeVision, VISION_BUDGET } from './vision-core.js';
|
|
17
|
+
import { createModelOperation } from './model-invoker.js';
|
|
18
|
+
import { waitForModelWork } from './model-call-runtime.js';
|
|
19
|
+
import { ModelPortError } from './model-port-contract.js';
|
|
16
20
|
|
|
17
21
|
export class SummarizeError extends Error {
|
|
18
22
|
constructor(message) {
|
|
@@ -28,7 +32,11 @@ const DEFAULT_QUESTION = '请根据这些视频关键帧(按时间顺序从左
|
|
|
28
32
|
* @param {{frames:Array<{buffer:Buffer, atSec:number, width:number, height:number}>, cols?:number, maxSheetWidth?:number}}
|
|
29
33
|
* @returns {Promise<{buffer:Buffer, width:number, height:number, rows:number, cols:number}>}
|
|
30
34
|
*/
|
|
31
|
-
export async function buildContactSheet({ frames, cols = 3, maxSheetWidth = 1024 }) {
|
|
35
|
+
export async function buildContactSheet({ frames, cols = 3, maxSheetWidth = 1024, signal }) {
|
|
36
|
+
return waitForModelWork(() => makeContactSheet({ frames, cols, maxSheetWidth, signal }), signal);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function makeContactSheet({ frames, cols, maxSheetWidth, signal }) {
|
|
32
40
|
if (!Array.isArray(frames) || frames.length === 0) {
|
|
33
41
|
throw new SummarizeError('iris_media_summarize: 没有可拼的帧');
|
|
34
42
|
}
|
|
@@ -47,10 +55,10 @@ export async function buildContactSheet({ frames, cols = 3, maxSheetWidth = 1024
|
|
|
47
55
|
const w = Math.max(1, cellW);
|
|
48
56
|
const scale = w / Math.max(1, f.width || w);
|
|
49
57
|
const h = Math.max(1, Math.round((f.height || 0) * scale));
|
|
50
|
-
const thumb = await sharp(f.buffer)
|
|
58
|
+
const thumb = await waitForModelWork(() => sharp(f.buffer)
|
|
51
59
|
.resize(w, h, { fit: 'fill' })
|
|
52
60
|
.png()
|
|
53
|
-
.toBuffer();
|
|
61
|
+
.toBuffer(), signal);
|
|
54
62
|
// 时间戳标签(SVG 覆盖在缩略图下方)
|
|
55
63
|
const label = Buffer.from(
|
|
56
64
|
`<svg width="${w}" height="${labelH}" xmlns="http://www.w3.org/2000/svg">` +
|
|
@@ -58,10 +66,11 @@ export async function buildContactSheet({ frames, cols = 3, maxSheetWidth = 1024
|
|
|
58
66
|
`<text x="4" y="${labelH - 7}" font-family="monospace" font-size="12" fill="#ffffff">` +
|
|
59
67
|
`${f.atSec.toFixed(1)}s</text></svg>`
|
|
60
68
|
);
|
|
61
|
-
const cell = await sharp(thumb)
|
|
69
|
+
const cell = await waitForModelWork(() => sharp(thumb)
|
|
70
|
+
.extend({ bottom: labelH, background: { r: 16, g: 16, b: 20 } })
|
|
62
71
|
.composite([{ input: label, top: h, left: 0 }])
|
|
63
72
|
.png()
|
|
64
|
-
.toBuffer();
|
|
73
|
+
.toBuffer(), signal);
|
|
65
74
|
cells.push({ buffer: cell, width: w, height: h + labelH });
|
|
66
75
|
}
|
|
67
76
|
|
|
@@ -73,12 +82,12 @@ export async function buildContactSheet({ frames, cols = 3, maxSheetWidth = 1024
|
|
|
73
82
|
left: (i % colsN) * (cellW + gap),
|
|
74
83
|
top: Math.floor(i / colsN) * (cellH + gap)
|
|
75
84
|
}));
|
|
76
|
-
const buffer = await sharp({
|
|
85
|
+
const buffer = await waitForModelWork(() => sharp({
|
|
77
86
|
create: { width: sheetW, height: sheetH, channels: 3, background: { r: 16, g: 16, b: 20 } }
|
|
78
87
|
})
|
|
79
88
|
.composite(composite)
|
|
80
89
|
.png()
|
|
81
|
-
.toBuffer();
|
|
90
|
+
.toBuffer(), signal);
|
|
82
91
|
return { buffer, width: sheetW, height: sheetH, rows: rowsN, cols: colsN };
|
|
83
92
|
}
|
|
84
93
|
|
|
@@ -98,14 +107,16 @@ export function buildSummaryPrompt({ question, transcript }) {
|
|
|
98
107
|
|
|
99
108
|
/**
|
|
100
109
|
* 多模态摘要:contact sheet 图 + 可选转写文本 → 视觉后端链 → 摘要文本。
|
|
101
|
-
* @param {{
|
|
102
|
-
* @returns {Promise<
|
|
110
|
+
* @param {{ports:Array, frames:Array, question?:string, transcript?:string, signal?:AbortSignal}}
|
|
111
|
+
* @returns {Promise<object>} 完整模型结果和同一张拼图
|
|
103
112
|
*/
|
|
104
|
-
export async function summarizeMedia({
|
|
105
|
-
if (!
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
113
|
+
export async function summarizeMedia({ ports = [], frames, question, transcript, signal, operation, budget = VISION_BUDGET }) {
|
|
114
|
+
if (!ports.length) throw new ModelPortError('IRIS_MODEL_UNAVAILABLE');
|
|
115
|
+
const op = operation || createModelOperation({ signal, budget: { timeoutMs: budget.timeoutMs, maxInvocations: ports.length } });
|
|
116
|
+
try {
|
|
117
|
+
const sheet = await buildContactSheet({ frames, cols: 3, signal: op.signal });
|
|
118
|
+
const result = await completeVision(ports, { prompt: buildSummaryPrompt({ question, transcript }),
|
|
119
|
+
image: { bytes: new Uint8Array(sheet.buffer), mediaType: 'image/png' } }, { operation: op, budget });
|
|
120
|
+
return { ...result, sheet };
|
|
121
|
+
} finally { if (!operation) op.dispose(); }
|
|
111
122
|
}
|