@mokuyoaxis/dsh-iris 0.1.4 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +54 -1
- package/README.en.md +72 -15
- package/README.md +72 -15
- package/bin/dsh-iris.js +392 -13
- package/docs/AGENT_QUICKSTART.md +35 -0
- package/docs/ARCHITECTURE.md +80 -64
- package/docs/ARTIFACT_MANIFEST.md +60 -0
- package/docs/CORE_RUNTIME_CONTRACT.md +83 -0
- package/docs/DSH_CORE_MIGRATION.md +106 -0
- package/docs/DSH_RC2_ADAPTATION.md +36 -0
- package/docs/HEADLESS_CLI.md +217 -0
- package/docs/HOST_ADAPTER_CONTRACT.md +31 -12
- package/docs/HOST_DOCTOR.md +2 -0
- package/docs/MODEL_PORT_CONTRACT.md +300 -0
- package/docs/PROVIDER_ADAPTER_CONTRACT.md +51 -10
- package/docs/PROVIDER_RUNTIME_HARNESS.md +33 -0
- package/docs/PROVIDER_SUBMISSION_CONTRACT.md +1 -1
- package/docs/ROADMAP.md +42 -86
- package/docs/SECURITY.md +4 -3
- package/docs/assets/logo/README.md +55 -0
- package/docs/assets/logo/iris-bubble-concept.svg +67 -0
- package/docs/assets/logo/iris-bubble.svg +14 -0
- package/docs/assets/logo/iris-interim-flower.png +0 -0
- package/docs/assets/logo/iris-logo-mono-inverse.svg +78 -0
- package/docs/assets/logo/iris-logo-mono.svg +78 -0
- package/docs/assets/logo/iris-logo.svg +78 -0
- package/docs/assets/logo/iris-mark-mono-inverse.svg +42 -0
- package/docs/assets/logo/iris-mark-mono.svg +42 -0
- package/docs/assets/logo/iris-mark.svg +42 -0
- package/docs/releases/0.2.0.md +48 -0
- package/lib/actions.js +374 -362
- package/lib/adapters.js +52 -15
- package/lib/api.js +152 -5
- package/lib/client.js +763 -48
- package/lib/command-service.js +420 -0
- package/lib/config.js +56 -43
- package/lib/core-artifact-store.js +598 -0
- package/lib/core-artifacts.js +4 -0
- package/lib/core-attention.js +103 -0
- package/lib/core-contract.js +105 -0
- package/lib/core-doctor.js +41 -0
- package/lib/core-lease-recovery.js +170 -0
- package/lib/core-runtime.js +232 -0
- package/lib/core-tasks.js +560 -0
- package/lib/core-user-projection.js +192 -0
- package/lib/doctor.js +92 -33
- package/lib/dsh-core-adapter.js +599 -0
- package/lib/dsh-host-adapter.js +58 -7
- package/lib/generation-input.js +95 -0
- package/lib/guard.js +3 -3
- package/lib/index.js +159 -54
- package/lib/model-invoker.js +218 -0
- package/lib/model-port-contract.js +333 -0
- package/lib/models.js +97 -11
- package/lib/provider-adapter.js +49 -23
- package/lib/provider-adapters.js +84 -21
- package/lib/provider-catalog.js +171 -0
- package/lib/provider-contract.js +72 -3
- package/lib/provider-protocol.js +51 -2
- package/lib/provider-task-runner.js +328 -0
- package/lib/tasks.js +16 -4
- package/lib/vision.js +28 -7
- package/package.json +8 -8
- package/user_guide.md +27 -12
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import {
|
|
7
|
+
createCoreArtifact,
|
|
8
|
+
exportCoreArtifact,
|
|
9
|
+
inspectCoreArtifact,
|
|
10
|
+
listCoreArtifacts,
|
|
11
|
+
readCoreArtifactBytes,
|
|
12
|
+
rebuildCoreArtifactIndex
|
|
13
|
+
} from './core-artifacts.js';
|
|
14
|
+
import { inspectCoreTask, listCoreTasks } from './core-tasks.js';
|
|
15
|
+
import { cropImage, pixelDiff, PixelError } from './pixels.js';
|
|
16
|
+
import { extractFrames, normalizeFramesOptions, probeVideo } from './media-probe.js';
|
|
17
|
+
import { hasProviderOperation } from './provider-adapter.js';
|
|
18
|
+
import { createProviderTaskRunner } from './provider-task-runner.js';
|
|
19
|
+
import { GenerationInputError, normalizeGenerationInput } from './generation-input.js';
|
|
20
|
+
|
|
21
|
+
export const COMMAND_CONTRACT_VERSION = 0;
|
|
22
|
+
export const CORE_COMMANDS = Object.freeze([
|
|
23
|
+
'crop', 'media.diff', 'media.frames', 'task.inspect', 'task.list', 'task.observe', 'task.reobserve', 'task.redeliver', 'task.cancel', 'task.retry',
|
|
24
|
+
'artifact.inspect', 'artifact.list', 'artifact.export', 'artifact.rebuild'
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
export class CommandError extends Error {
|
|
28
|
+
constructor(code, message) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.name = 'CommandError';
|
|
31
|
+
this.code = code;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function commandError(code, message) {
|
|
36
|
+
return new CommandError(code, message);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function plain(value) {
|
|
40
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function exactInput(input, allowed) {
|
|
44
|
+
if (!plain(input)) throw commandError('IRIS_COMMAND_INPUT_INVALID', 'Command 输入必须是 JSON 对象');
|
|
45
|
+
for (const key of Object.keys(input)) {
|
|
46
|
+
if (!allowed.includes(key)) throw commandError('IRIS_COMMAND_INPUT_INVALID', `Command 不支持输入字段:${key}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function localFile(value, field, label) {
|
|
51
|
+
const supplied = String(value || '').trim();
|
|
52
|
+
if (!supplied || !path.isAbsolute(supplied)) {
|
|
53
|
+
throw commandError('IRIS_COMMAND_INPUT_INVALID', field + ' 必须是宿主可读的绝对路径');
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
const resolved = fs.realpathSync.native(supplied);
|
|
57
|
+
if (!fs.statSync(resolved).isFile()) throw new Error('not file');
|
|
58
|
+
return resolved;
|
|
59
|
+
} catch (_) {
|
|
60
|
+
throw commandError('IRIS_COMMAND_INPUT_INVALID', label + '不存在或不是普通文件');
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function localImage(input) {
|
|
65
|
+
return localFile(input.image_path, 'image_path', '图片');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function coordinates(input) {
|
|
69
|
+
const values = {};
|
|
70
|
+
for (const name of ['left', 'top', 'width', 'height']) {
|
|
71
|
+
if (typeof input[name] !== 'number' || !Number.isFinite(input[name])) {
|
|
72
|
+
throw commandError('IRIS_COMMAND_INPUT_INVALID', `${name} 必须是有限数字`);
|
|
73
|
+
}
|
|
74
|
+
values[name] = input[name];
|
|
75
|
+
}
|
|
76
|
+
return values;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function abortError() {
|
|
80
|
+
const error = new Error('Core Command 已取消');
|
|
81
|
+
error.name = 'AbortError';
|
|
82
|
+
return error;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function createCommandService(runtime, ports = {}) {
|
|
86
|
+
if (!runtime || typeof runtime.run !== 'function') throw new TypeError('Command Service 需要 Core Runtime');
|
|
87
|
+
|
|
88
|
+
async function execute(name, input) {
|
|
89
|
+
if (!CORE_COMMANDS.includes(name)) throw commandError('IRIS_COMMAND_UNKNOWN', `未知 Command:${String(name)}`);
|
|
90
|
+
if (name === 'crop') {
|
|
91
|
+
exactInput(input, ['image_path', 'artifact_id', 'left', 'top', 'width', 'height']);
|
|
92
|
+
const hasPath = Boolean(String(input.image_path || '').trim());
|
|
93
|
+
const hasArtifact = Boolean(String(input.artifact_id || '').trim());
|
|
94
|
+
if (hasPath === hasArtifact) {
|
|
95
|
+
throw commandError('IRIS_COMMAND_INPUT_INVALID', 'crop 必须且只能提供 image_path 或 artifact_id 之一');
|
|
96
|
+
}
|
|
97
|
+
const localPath = hasPath ? localImage(input) : null;
|
|
98
|
+
const region = coordinates(input);
|
|
99
|
+
return runtime.run('execute', async ({ dataRoot, signal }) => {
|
|
100
|
+
if (signal.aborted) throw abortError();
|
|
101
|
+
const source = hasArtifact ? readCoreArtifactBytes(dataRoot, input.artifact_id) : null;
|
|
102
|
+
const image = source ? source.bytes : localPath;
|
|
103
|
+
let cropped;
|
|
104
|
+
try { cropped = await cropImage({ input: image, ...region }); }
|
|
105
|
+
catch (error) {
|
|
106
|
+
if (error instanceof PixelError) throw commandError('IRIS_COMMAND_INPUT_INVALID', error.message);
|
|
107
|
+
throw commandError('IRIS_COMMAND_PROCESSING_FAILED', '图片无法裁剪;请确认格式受支持且文件可读取');
|
|
108
|
+
}
|
|
109
|
+
if (signal.aborted) throw abortError();
|
|
110
|
+
const artifact = createCoreArtifact(dataRoot, {
|
|
111
|
+
bytes: cropped.buffer,
|
|
112
|
+
mediaType: cropped.mime,
|
|
113
|
+
kind: 'crop',
|
|
114
|
+
metadata: { width: cropped.width, height: cropped.height },
|
|
115
|
+
relations: source ? [{ type: 'derived-from', artifactId: source.artifact.id }] : []
|
|
116
|
+
});
|
|
117
|
+
return Object.freeze({
|
|
118
|
+
contractVersion: COMMAND_CONTRACT_VERSION,
|
|
119
|
+
command: 'crop',
|
|
120
|
+
artifact
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
if (name === 'media.diff') {
|
|
125
|
+
exactInput(input, [
|
|
126
|
+
'image_a_path', 'image_a_artifact_id', 'image_b_path', 'image_b_artifact_id',
|
|
127
|
+
'grid', 'top_regions'
|
|
128
|
+
]);
|
|
129
|
+
const aPathSupplied = Boolean(String(input.image_a_path || '').trim());
|
|
130
|
+
const aArtifactSupplied = Boolean(String(input.image_a_artifact_id || '').trim());
|
|
131
|
+
const bPathSupplied = Boolean(String(input.image_b_path || '').trim());
|
|
132
|
+
const bArtifactSupplied = Boolean(String(input.image_b_artifact_id || '').trim());
|
|
133
|
+
if (aPathSupplied === aArtifactSupplied || bPathSupplied === bArtifactSupplied) {
|
|
134
|
+
throw commandError('IRIS_COMMAND_INPUT_INVALID',
|
|
135
|
+
'media.diff 的 A/B 两侧都必须且只能提供本地路径或 Artifact ID 之一');
|
|
136
|
+
}
|
|
137
|
+
const imageAPath = aPathSupplied
|
|
138
|
+
? localFile(input.image_a_path, 'image_a_path', '图片 A') : null;
|
|
139
|
+
const imageBPath = bPathSupplied
|
|
140
|
+
? localFile(input.image_b_path, 'image_b_path', '图片 B') : null;
|
|
141
|
+
const grid = input.grid === undefined ? 8 : Number(input.grid);
|
|
142
|
+
const topRegions = input.top_regions === undefined ? 3 : Number(input.top_regions);
|
|
143
|
+
if (!Number.isSafeInteger(grid) || grid < 1 || grid > 64
|
|
144
|
+
|| !Number.isSafeInteger(topRegions) || topRegions < 1 || topRegions > 100) {
|
|
145
|
+
throw commandError('IRIS_COMMAND_INPUT_INVALID',
|
|
146
|
+
'grid 必须是 1–64 的整数,top_regions 必须是 1–100 的整数');
|
|
147
|
+
}
|
|
148
|
+
return runtime.run('execute', async ({ dataRoot, signal }) => {
|
|
149
|
+
if (signal.aborted) throw abortError();
|
|
150
|
+
const sourceA = aArtifactSupplied
|
|
151
|
+
? readCoreArtifactBytes(dataRoot, input.image_a_artifact_id) : null;
|
|
152
|
+
const sourceB = bArtifactSupplied
|
|
153
|
+
? readCoreArtifactBytes(dataRoot, input.image_b_artifact_id) : null;
|
|
154
|
+
for (const source of [sourceA, sourceB]) {
|
|
155
|
+
if (source && !source.artifact.mediaType.startsWith('image/')) {
|
|
156
|
+
throw commandError('IRIS_COMMAND_INPUT_INVALID', 'media.diff 的 Artifact 输入必须是图片');
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
let diff;
|
|
160
|
+
try {
|
|
161
|
+
diff = await pixelDiff({
|
|
162
|
+
inputA: sourceA ? sourceA.bytes : imageAPath,
|
|
163
|
+
inputB: sourceB ? sourceB.bytes : imageBPath,
|
|
164
|
+
grid,
|
|
165
|
+
topRegions
|
|
166
|
+
});
|
|
167
|
+
} catch (error) {
|
|
168
|
+
if (signal.aborted) throw abortError();
|
|
169
|
+
if (error instanceof PixelError) {
|
|
170
|
+
throw commandError('IRIS_COMMAND_INPUT_INVALID', error.message);
|
|
171
|
+
}
|
|
172
|
+
throw commandError('IRIS_COMMAND_PROCESSING_FAILED',
|
|
173
|
+
'图片差异分析失败;请确认格式受支持且文件可读取');
|
|
174
|
+
}
|
|
175
|
+
if (signal.aborted) throw abortError();
|
|
176
|
+
const sourceIds = [...new Set([sourceA?.artifact.id, sourceB?.artifact.id].filter(Boolean))];
|
|
177
|
+
const metrics = Object.freeze({
|
|
178
|
+
ratio: diff.ratio,
|
|
179
|
+
diffPixels: diff.diffPixels,
|
|
180
|
+
totalPixels: diff.totalPixels,
|
|
181
|
+
width: diff.width,
|
|
182
|
+
height: diff.height,
|
|
183
|
+
worstRegions: Object.freeze(diff.worstRegions.map((region) => Object.freeze({ ...region })))
|
|
184
|
+
});
|
|
185
|
+
const artifact = createCoreArtifact(dataRoot, {
|
|
186
|
+
bytes: diff.heatmap,
|
|
187
|
+
mediaType: diff.mime,
|
|
188
|
+
kind: 'pixel-diff',
|
|
189
|
+
metadata: { ...metrics, worstRegions: diff.worstRegions },
|
|
190
|
+
relations: sourceIds.map((artifactId) => ({ type: 'derived-from', artifactId }))
|
|
191
|
+
});
|
|
192
|
+
return Object.freeze({
|
|
193
|
+
contractVersion: COMMAND_CONTRACT_VERSION,
|
|
194
|
+
command: name,
|
|
195
|
+
metrics,
|
|
196
|
+
artifact
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
if (name === 'media.frames') {
|
|
201
|
+
exactInput(input, ['video_path', 'artifact_id', 'max_frames', 'target_width', 'quality', 'format']);
|
|
202
|
+
const hasPath = Boolean(String(input.video_path || '').trim());
|
|
203
|
+
const hasArtifact = Boolean(String(input.artifact_id || '').trim());
|
|
204
|
+
if (hasPath === hasArtifact) {
|
|
205
|
+
throw commandError('IRIS_COMMAND_INPUT_INVALID',
|
|
206
|
+
'media.frames 必须且只能提供 video_path 或 artifact_id 之一');
|
|
207
|
+
}
|
|
208
|
+
const videoPath = hasPath ? localFile(input.video_path, 'video_path', '视频') : null;
|
|
209
|
+
let frameOptions;
|
|
210
|
+
try {
|
|
211
|
+
frameOptions = normalizeFramesOptions({
|
|
212
|
+
...(input.max_frames !== undefined ? { maxFrames: input.max_frames } : {}),
|
|
213
|
+
...(input.target_width !== undefined ? { targetWidth: input.target_width } : {}),
|
|
214
|
+
...(input.quality !== undefined ? { quality: input.quality } : {}),
|
|
215
|
+
...(input.format !== undefined ? { format: input.format } : {})
|
|
216
|
+
});
|
|
217
|
+
} catch (error) {
|
|
218
|
+
throw commandError('IRIS_COMMAND_INPUT_INVALID', error.message);
|
|
219
|
+
}
|
|
220
|
+
return runtime.run('execute', async ({ dataRoot, signal }) => {
|
|
221
|
+
if (signal.aborted) throw abortError();
|
|
222
|
+
const source = hasArtifact ? readCoreArtifactBytes(dataRoot, input.artifact_id) : null;
|
|
223
|
+
if (source && !source.artifact.mediaType.startsWith('video/')) {
|
|
224
|
+
throw commandError('IRIS_COMMAND_INPUT_INVALID', 'media.frames 的 Artifact 输入必须是视频');
|
|
225
|
+
}
|
|
226
|
+
let temporaryDirectory;
|
|
227
|
+
let readablePath = videoPath;
|
|
228
|
+
if (source) {
|
|
229
|
+
try {
|
|
230
|
+
temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'iris-command-frames-'));
|
|
231
|
+
readablePath = path.join(temporaryDirectory, 'source.mp4');
|
|
232
|
+
fs.writeFileSync(readablePath, source.bytes, { mode: 0o600 });
|
|
233
|
+
} catch (_) {
|
|
234
|
+
if (temporaryDirectory) {
|
|
235
|
+
try { fs.rmSync(temporaryDirectory, { recursive: true, force: true }); } catch (_) { /* 临时副本 */ }
|
|
236
|
+
}
|
|
237
|
+
throw commandError('IRIS_COMMAND_PROCESSING_FAILED', '视频临时副本无法安全写入');
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
let media;
|
|
241
|
+
let frames;
|
|
242
|
+
try {
|
|
243
|
+
media = probeVideo(readablePath);
|
|
244
|
+
frames = await extractFrames({ inputPath: readablePath, ...frameOptions, signal });
|
|
245
|
+
} catch (error) {
|
|
246
|
+
if (signal.aborted) throw abortError();
|
|
247
|
+
throw commandError('IRIS_COMMAND_PROCESSING_FAILED',
|
|
248
|
+
'视频抽帧失败;请确认 ffmpeg 可用且文件格式受支持');
|
|
249
|
+
} finally {
|
|
250
|
+
if (temporaryDirectory) {
|
|
251
|
+
try { fs.rmSync(temporaryDirectory, { recursive: true, force: true }); } catch (_) { /* 临时副本 */ }
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (signal.aborted) throw abortError();
|
|
255
|
+
const relations = source ? [{ type: 'frame-of', artifactId: source.artifact.id }] : [];
|
|
256
|
+
const mediaType = frameOptions.format === 'png' ? 'image/png' : 'image/jpeg';
|
|
257
|
+
const artifacts = frames.map((frame, index) => createCoreArtifact(dataRoot, {
|
|
258
|
+
bytes: frame.buffer,
|
|
259
|
+
mediaType,
|
|
260
|
+
kind: 'video-frame',
|
|
261
|
+
metadata: {
|
|
262
|
+
frameIndex: index + 1,
|
|
263
|
+
atSec: frame.atSec,
|
|
264
|
+
width: frame.width,
|
|
265
|
+
height: frame.height
|
|
266
|
+
},
|
|
267
|
+
relations
|
|
268
|
+
}));
|
|
269
|
+
return Object.freeze({
|
|
270
|
+
contractVersion: COMMAND_CONTRACT_VERSION,
|
|
271
|
+
command: name,
|
|
272
|
+
media: Object.freeze({ ...media }),
|
|
273
|
+
artifacts: Object.freeze(artifacts)
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
// task.reobserve 是人工触发观察的对外命令名(DSH API/工作台),与 task.observe
|
|
278
|
+
// 共用同一份门与单步 poll 实现;语义不区分,绝不 submit。
|
|
279
|
+
if (name === 'task.observe' || name === 'task.reobserve') {
|
|
280
|
+
exactInput(input, ['task_id']);
|
|
281
|
+
return runtime.run('execute', async ({ dataRoot }) => {
|
|
282
|
+
const before = inspectCoreTask(dataRoot, input.task_id);
|
|
283
|
+
if (before.acceptance !== 'accepted' || !before.remoteTaskId || !['none', 'unknown'].includes(before.outcome)) throw commandError('IRIS_TASK_NOT_OBSERVABLE', 'Task 没有可观察的远端受理事实,或已终止;不会重新提交');
|
|
284
|
+
if (typeof ports.resolveTaskAdapter !== 'function') throw commandError('IRIS_COMMAND_PROVIDER_REQUIRED', 'task.observe 需要宿主显式提供 Task Provider resolver');
|
|
285
|
+
const adapter = await ports.resolveTaskAdapter(before);
|
|
286
|
+
if (!adapter || adapter.id !== before.providerId || !adapter.capabilities?.includes(before.capability)) throw commandError('IRIS_PROVIDER_TASK_IDENTITY_MISMATCH', 'Task Provider resolver 返回了不匹配的 Adapter');
|
|
287
|
+
if (!hasProviderOperation(adapter, 'poll')) throw commandError('IRIS_PROVIDER_TASK_OBSERVE_UNSUPPORTED', 'Task 原 Provider 不支持 poll;不会重新提交');
|
|
288
|
+
const task = await createProviderTaskRunner(runtime).observe(input.task_id, adapter, { resumeActive: true });
|
|
289
|
+
return Object.freeze({ contractVersion: COMMAND_CONTRACT_VERSION, command: name, taskId: task.id, task });
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
// task.redeliver:只对 outcome=succeeded / deliveryState=failed 的任务重新取回
|
|
293
|
+
// 已生成的远端产物(re-poll 拿产物清单,绝不 submit,也不会重新生成)。
|
|
294
|
+
if (name === 'task.redeliver') {
|
|
295
|
+
exactInput(input, ['task_id']);
|
|
296
|
+
return runtime.run('execute', async ({ dataRoot }) => {
|
|
297
|
+
const before = inspectCoreTask(dataRoot, input.task_id);
|
|
298
|
+
if (before.outcome !== 'succeeded' || before.deliveryState !== 'failed' || !before.remoteTaskId) throw commandError('IRIS_TASK_NOT_REDELIVERABLE', 'Task 不处于可重新交付状态;不会重新生成');
|
|
299
|
+
if (typeof ports.resolveTaskAdapter !== 'function') throw commandError('IRIS_COMMAND_PROVIDER_REQUIRED', 'task.redeliver 需要宿主显式提供 Task Provider resolver');
|
|
300
|
+
const adapter = await ports.resolveTaskAdapter(before);
|
|
301
|
+
if (!adapter || adapter.id !== before.providerId || !adapter.capabilities?.includes(before.capability)) throw commandError('IRIS_PROVIDER_TASK_IDENTITY_MISMATCH', 'Task Provider resolver 返回了不匹配的 Adapter');
|
|
302
|
+
if (!hasProviderOperation(adapter, 'poll')) throw commandError('IRIS_PROVIDER_TASK_OBSERVE_UNSUPPORTED', 'Task 原 Provider 不支持 poll;不会重新生成');
|
|
303
|
+
const task = await createProviderTaskRunner(runtime).redeliver(input.task_id, adapter);
|
|
304
|
+
return Object.freeze({ contractVersion: COMMAND_CONTRACT_VERSION, command: name, taskId: task.id, task });
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
// task.cancel:只对已受理、有远端 ID、结果未定论且非终态的任务开放。
|
|
308
|
+
// 只有 Provider 明确确认才写 outcome=canceled;不支持或无法确认时保持真实状态。
|
|
309
|
+
if (name === 'task.cancel') {
|
|
310
|
+
exactInput(input, ['task_id']);
|
|
311
|
+
return runtime.run('execute', async ({ dataRoot }) => {
|
|
312
|
+
const before = inspectCoreTask(dataRoot, input.task_id);
|
|
313
|
+
if (before.acceptance !== 'accepted' || !before.remoteTaskId
|
|
314
|
+
|| !['none', 'unknown'].includes(before.outcome) || before.phase === 'terminal') {
|
|
315
|
+
throw commandError('IRIS_TASK_NOT_CANCELABLE', 'Task 缺少可取消的远端受理事实,或已终止');
|
|
316
|
+
}
|
|
317
|
+
if (before.cancelState !== 'none') {
|
|
318
|
+
throw commandError('IRIS_TASK_CANCEL_ALREADY_REQUESTED', '该 Task 已请求过取消;请显式重新观察以确认远端真实状态');
|
|
319
|
+
}
|
|
320
|
+
if (typeof ports.resolveTaskAdapter !== 'function') throw commandError('IRIS_COMMAND_PROVIDER_REQUIRED', 'task.cancel 需要宿主显式提供 Task Provider resolver');
|
|
321
|
+
const adapter = await ports.resolveTaskAdapter(before);
|
|
322
|
+
if (!adapter || adapter.id !== before.providerId || !adapter.capabilities?.includes(before.capability)) throw commandError('IRIS_PROVIDER_TASK_IDENTITY_MISMATCH', 'Task Provider resolver 返回了不匹配的 Adapter');
|
|
323
|
+
const task = await createProviderTaskRunner(runtime).cancel(input.task_id, adapter);
|
|
324
|
+
return Object.freeze({ contractVersion: COMMAND_CONTRACT_VERSION, command: name, taskId: task.id, task });
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
// task.retry:D4 重试为新任务。产生新的真实计费,必须显式 confirm_billing;
|
|
328
|
+
// 只对终态且未成功交付的 Task 开放;新 Task 有全新 id/attempts/binding 并记录
|
|
329
|
+
// 单向 retriedFrom 关系,旧 Task 不动;Core 不持久化 Prompt,prompt 必须由
|
|
330
|
+
// 调用方重新提供(绝不从旧记录"恢复")。
|
|
331
|
+
if (name === 'task.retry') {
|
|
332
|
+
exactInput(input, ['task_id', 'provider_input', 'model_ref', 'confirm_billing']);
|
|
333
|
+
if (input.confirm_billing !== true) {
|
|
334
|
+
throw commandError('IRIS_COMMAND_BILLING_CONFIRM_REQUIRED', '重试会创建新任务并可能产生重复生成费用;必须显式确认计费');
|
|
335
|
+
}
|
|
336
|
+
return runtime.run('execute', async ({ dataRoot }) => {
|
|
337
|
+
const before = inspectCoreTask(dataRoot, input.task_id);
|
|
338
|
+
if (before.phase !== 'terminal' || (before.outcome === 'succeeded' && before.deliveryState === 'ready')) {
|
|
339
|
+
throw commandError('IRIS_TASK_NOT_RETRYABLE', '只有终态且未成功交付的 Task 可以重试为新任务');
|
|
340
|
+
}
|
|
341
|
+
let normalized;
|
|
342
|
+
try { normalized = normalizeGenerationInput(before.capability, input.provider_input); }
|
|
343
|
+
catch (error) {
|
|
344
|
+
if (error instanceof GenerationInputError) throw commandError('IRIS_COMMAND_INPUT_INVALID', error.message);
|
|
345
|
+
throw error;
|
|
346
|
+
}
|
|
347
|
+
const providerInput = normalized.providerInput;
|
|
348
|
+
if (Object.hasOwn(input.provider_input, 'model_ref')) {
|
|
349
|
+
throw commandError('IRIS_COMMAND_INPUT_INVALID', 'retry 的模型覆盖请使用外层 model_ref');
|
|
350
|
+
}
|
|
351
|
+
const modelRef = String(input.model_ref || '').trim();
|
|
352
|
+
if (input.model_ref !== undefined && !modelRef) throw commandError('IRIS_COMMAND_INPUT_INVALID', 'model_ref 必须是 providerId::modelId 复合引用');
|
|
353
|
+
if (typeof ports.resolveTaskCandidates !== 'function') throw commandError('IRIS_COMMAND_PROVIDER_REQUIRED', 'task.retry 需要宿主显式提供候选链 resolver');
|
|
354
|
+
const candidates = await ports.resolveTaskCandidates({ capability: before.capability, modelRef });
|
|
355
|
+
const result = await createProviderTaskRunner(runtime).submit({
|
|
356
|
+
capability: before.capability,
|
|
357
|
+
candidates,
|
|
358
|
+
providerInput,
|
|
359
|
+
retriedFrom: before.id
|
|
360
|
+
});
|
|
361
|
+
return Object.freeze({
|
|
362
|
+
contractVersion: COMMAND_CONTRACT_VERSION,
|
|
363
|
+
command: name,
|
|
364
|
+
taskId: result.taskId,
|
|
365
|
+
retriedFrom: before.id,
|
|
366
|
+
task: result.task,
|
|
367
|
+
...(result.localError ? { localError: result.localError } : {})
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
if (name === 'task.inspect') {
|
|
372
|
+
exactInput(input, ['task_id']);
|
|
373
|
+
return runtime.run('inspect', ({ dataRoot }) => Object.freeze({
|
|
374
|
+
contractVersion: COMMAND_CONTRACT_VERSION,
|
|
375
|
+
command: name,
|
|
376
|
+
task: inspectCoreTask(dataRoot, input.task_id)
|
|
377
|
+
}));
|
|
378
|
+
}
|
|
379
|
+
if (name === 'task.list') {
|
|
380
|
+
exactInput(input, ['offset', 'limit']);
|
|
381
|
+
return runtime.run('inspect', ({ dataRoot }) => Object.freeze({
|
|
382
|
+
contractVersion: COMMAND_CONTRACT_VERSION,
|
|
383
|
+
command: name,
|
|
384
|
+
...listCoreTasks(dataRoot, input)
|
|
385
|
+
}));
|
|
386
|
+
}
|
|
387
|
+
if (name === 'artifact.inspect') {
|
|
388
|
+
exactInput(input, ['artifact_id']);
|
|
389
|
+
return runtime.run('inspect', ({ dataRoot }) => Object.freeze({
|
|
390
|
+
contractVersion: COMMAND_CONTRACT_VERSION,
|
|
391
|
+
command: name,
|
|
392
|
+
artifact: inspectCoreArtifact(dataRoot, input.artifact_id)
|
|
393
|
+
}));
|
|
394
|
+
}
|
|
395
|
+
if (name === 'artifact.list') {
|
|
396
|
+
exactInput(input, ['offset', 'limit']);
|
|
397
|
+
return runtime.run('inspect', ({ dataRoot }) => Object.freeze({
|
|
398
|
+
contractVersion: COMMAND_CONTRACT_VERSION,
|
|
399
|
+
command: name,
|
|
400
|
+
...listCoreArtifacts(dataRoot, input)
|
|
401
|
+
}));
|
|
402
|
+
}
|
|
403
|
+
if (name === 'artifact.rebuild') {
|
|
404
|
+
exactInput(input, []);
|
|
405
|
+
return runtime.run('recover', ({ dataRoot }) => Object.freeze({
|
|
406
|
+
contractVersion: COMMAND_CONTRACT_VERSION,
|
|
407
|
+
command: name,
|
|
408
|
+
...rebuildCoreArtifactIndex(dataRoot)
|
|
409
|
+
}));
|
|
410
|
+
}
|
|
411
|
+
exactInput(input, ['artifact_id', 'output_path']);
|
|
412
|
+
return runtime.run('execute', ({ dataRoot }) => Object.freeze({
|
|
413
|
+
contractVersion: COMMAND_CONTRACT_VERSION,
|
|
414
|
+
command: name,
|
|
415
|
+
...exportCoreArtifact(dataRoot, input.artifact_id, input.output_path)
|
|
416
|
+
}));
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return Object.freeze({ contractVersion: COMMAND_CONTRACT_VERSION, commands: CORE_COMMANDS, execute });
|
|
420
|
+
}
|
package/lib/config.js
CHANGED
|
@@ -10,7 +10,7 @@ import os from 'node:os';
|
|
|
10
10
|
import * as cap from './capability.js';
|
|
11
11
|
import * as models from './models.js';
|
|
12
12
|
import { atomicWritePrivate, chmodPrivateFile, privateSibling } from './private-storage.js';
|
|
13
|
-
import {
|
|
13
|
+
import { selectMediaProtocol, isConfiguredProvider } from './provider-protocol.js';
|
|
14
14
|
import { redactProviderMessage } from './provider-contract.js';
|
|
15
15
|
import {
|
|
16
16
|
PROVIDER_HEALTH_FRESH_MS,
|
|
@@ -32,6 +32,7 @@ function validateStoredConfig(value) {
|
|
|
32
32
|
if (!isPlainObject(provider)) throw new Error('provider 条目必须是对象');
|
|
33
33
|
if (provider.id !== undefined && typeof provider.id !== 'string') throw new Error('provider.id 必须是字符串');
|
|
34
34
|
if (provider.baseUrl !== undefined && typeof provider.baseUrl !== 'string') throw new Error('provider.baseUrl 必须是字符串');
|
|
35
|
+
if (provider.mediaBaseUrl !== undefined && typeof provider.mediaBaseUrl !== 'string') throw new Error('provider.mediaBaseUrl 必须是字符串');
|
|
35
36
|
if (provider.apiKey !== undefined && typeof provider.apiKey !== 'string') throw new Error('provider.apiKey 必须是字符串');
|
|
36
37
|
if (provider.models !== undefined && !Array.isArray(provider.models)) throw new Error('provider.models 必须是数组');
|
|
37
38
|
for (const model of provider.models || []) {
|
|
@@ -43,10 +44,27 @@ function validateStoredConfig(value) {
|
|
|
43
44
|
}
|
|
44
45
|
}
|
|
45
46
|
|
|
47
|
+
/**
|
|
48
|
+
* 供应商认证方式:'bearer'(默认,需要 apiKey)或 'none'(本地/内网无鉴权端点)。
|
|
49
|
+
* T-01:解除 ComfyUI、Ollama、本地 TTS 等本地部署的硬编码阻塞(审计 C-1)。
|
|
50
|
+
* 无 auth 字段时一律视为 'bearer'——现有用户行为完全不变。
|
|
51
|
+
*/
|
|
52
|
+
function authModeOf(provider) {
|
|
53
|
+
return provider && provider.auth === 'none' ? 'none' : 'bearer';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 该供应商是否缺少必需凭据('none' 认证永不缺)。 */
|
|
57
|
+
function missingCredentials(provider) {
|
|
58
|
+
if (!provider || authModeOf(provider) === 'none') return false;
|
|
59
|
+
return typeof provider.apiKey !== 'string' || !provider.apiKey.trim();
|
|
60
|
+
}
|
|
61
|
+
|
|
46
62
|
function normalizeStoredProvider(provider) {
|
|
47
|
-
|
|
48
|
-
provider.mediaProtocol
|
|
49
|
-
|
|
63
|
+
const selected = selectMediaProtocol(provider);
|
|
64
|
+
const changed = provider.mediaProtocol !== selected.mediaProtocol
|
|
65
|
+
|| (provider.protocolInferred === true) !== selected.protocolInferred;
|
|
66
|
+
Object.assign(provider, selected);
|
|
67
|
+
return models.migrateProviderModels(provider) || changed;
|
|
50
68
|
}
|
|
51
69
|
|
|
52
70
|
export function irisHome() {
|
|
@@ -134,15 +152,17 @@ export function importFromWorkbench(workbenchConfigPath) {
|
|
|
134
152
|
for (const p of list) {
|
|
135
153
|
if (!p || typeof p.apiKey !== 'string' || !p.apiKey.trim() ||
|
|
136
154
|
typeof p.baseUrl !== 'string' || !p.baseUrl.trim()) continue;
|
|
137
|
-
|
|
155
|
+
const imported = {
|
|
138
156
|
id: 'iris_' + Math.random().toString(36).slice(2, 8),
|
|
139
157
|
name: p.name || p.id,
|
|
140
|
-
type: p.type === '
|
|
158
|
+
type: typeof p.type === 'string' && p.type.trim() ? p.type.trim() : 'openai',
|
|
141
159
|
baseUrl: p.baseUrl,
|
|
142
160
|
apiKey: p.apiKey,
|
|
143
161
|
enabled: p.enabled !== false,
|
|
144
|
-
mediaProtocol:
|
|
145
|
-
}
|
|
162
|
+
mediaProtocol: p.mediaProtocol
|
|
163
|
+
};
|
|
164
|
+
normalizeStoredProvider(imported);
|
|
165
|
+
c.providers.push(imported);
|
|
146
166
|
n++;
|
|
147
167
|
}
|
|
148
168
|
if (n) persist();
|
|
@@ -150,7 +170,7 @@ export function importFromWorkbench(workbenchConfigPath) {
|
|
|
150
170
|
}
|
|
151
171
|
|
|
152
172
|
export function providers() {
|
|
153
|
-
return load().providers.filter(
|
|
173
|
+
return load().providers.filter(isConfiguredProvider);
|
|
154
174
|
}
|
|
155
175
|
|
|
156
176
|
export function providerById(id) {
|
|
@@ -189,16 +209,7 @@ export function pickFor(capability) {
|
|
|
189
209
|
*/
|
|
190
210
|
export function pickAllFor(capability) {
|
|
191
211
|
const pool = models.modelPool(providers());
|
|
192
|
-
const chosen = [];
|
|
193
|
-
const seen = new Set();
|
|
194
|
-
const add = (m) => {
|
|
195
|
-
const k = m.providerId + '\u0000' + m.id;
|
|
196
|
-
if (!seen.has(k)) { seen.add(k); chosen.push(m); }
|
|
197
|
-
};
|
|
198
|
-
for (const ref of assignmentOrder(capability)) {
|
|
199
|
-
for (const m of pool) if (m.ref === ref && m.capabilities.includes(capability)) add(m);
|
|
200
|
-
}
|
|
201
|
-
for (const m of pool) if (m.capabilities.includes(capability)) add(m);
|
|
212
|
+
const chosen = models.orderedModels(pool, capability, assignments()[capability]);
|
|
202
213
|
const out = [];
|
|
203
214
|
for (const m of chosen) {
|
|
204
215
|
const p = providerById(m.providerId);
|
|
@@ -214,21 +225,7 @@ export function assignments() {
|
|
|
214
225
|
return load().assignments || {};
|
|
215
226
|
}
|
|
216
227
|
|
|
217
|
-
|
|
218
|
-
let providerId = '';
|
|
219
|
-
let modelId = '';
|
|
220
|
-
if (raw && typeof raw === 'object') {
|
|
221
|
-
providerId = String(raw.providerId || '');
|
|
222
|
-
modelId = String(raw.modelId || raw.id || '');
|
|
223
|
-
} else if (typeof raw === 'string') {
|
|
224
|
-
const parsed = models.parseModelRef(raw);
|
|
225
|
-
if (parsed) ({ providerId, modelId } = parsed);
|
|
226
|
-
else modelId = raw; // v1 旧格式:纯 model id,按池顺序取第一个
|
|
227
|
-
}
|
|
228
|
-
return pool.find((m) => m.id === modelId
|
|
229
|
-
&& (!providerId || m.providerId === providerId)
|
|
230
|
-
&& m.capabilities.includes(capability)) || null;
|
|
231
|
-
}
|
|
228
|
+
const resolveAssignment = models.resolvePoolModel;
|
|
232
229
|
|
|
233
230
|
/** 归一化某能力的有序分配列表 → [providerId::modelId, ...]。 */
|
|
234
231
|
export function assignmentOrder(capability) {
|
|
@@ -302,7 +299,7 @@ export function capabilitiesOf(p) {
|
|
|
302
299
|
}
|
|
303
300
|
|
|
304
301
|
const HEALTH_SENSITIVE_FIELDS = new Set([
|
|
305
|
-
'type', 'baseUrl', 'apiKey', 'mediaProtocol',
|
|
302
|
+
'auth', 'type', 'baseUrl', 'mediaBaseUrl', 'apiKey', 'mediaProtocol',
|
|
306
303
|
'imageModel', 'videoModel', 'ttsModel', 'transcribeModel', 'visionModel'
|
|
307
304
|
]);
|
|
308
305
|
|
|
@@ -411,7 +408,7 @@ export function recordProviderHealth(id, modelId, capability, result) {
|
|
|
411
408
|
/** 返回单个 Provider × Model × Capability 的安全状态。 */
|
|
412
409
|
export function modelHealth(id, modelId, capability, { now = Date.now() } = {}) {
|
|
413
410
|
const provider = load().providers.find((item) => item.id === id);
|
|
414
|
-
if (!provider || provider.enabled === false ||
|
|
411
|
+
if (!provider || provider.enabled === false || missingCredentials(provider)) return { status: 'unconfigured' };
|
|
415
412
|
const observation = healthObservation(provider, modelId, capability);
|
|
416
413
|
const status = healthObservationState(observation, { now });
|
|
417
414
|
const evidence = status === 'verified'
|
|
@@ -467,8 +464,8 @@ export function upsert(provider) {
|
|
|
467
464
|
const i = c.providers.findIndex((p) => p.id === id);
|
|
468
465
|
if (i >= 0) {
|
|
469
466
|
const previous = c.providers[i];
|
|
470
|
-
if (rest.mediaProtocol === 'auto') rest.mediaProtocol = inferMediaProtocol(rest.baseUrl ?? previous.baseUrl);
|
|
471
467
|
const next = { ...previous, ...rest };
|
|
468
|
+
if (Object.prototype.hasOwnProperty.call(rest, 'mediaProtocol')) next.protocolInferred = false;
|
|
472
469
|
normalizeStoredProvider(next);
|
|
473
470
|
resetHealthOnCriticalChange(previous, next, rest);
|
|
474
471
|
if (Object.prototype.hasOwnProperty.call(rest, 'models')) pruneHealthToEffectiveModels(next);
|
|
@@ -478,7 +475,7 @@ export function upsert(provider) {
|
|
|
478
475
|
}
|
|
479
476
|
}
|
|
480
477
|
const fresh = { id: 'iris_' + Math.random().toString(36).slice(2, 8), ...rest };
|
|
481
|
-
|
|
478
|
+
fresh.protocolInferred = false;
|
|
482
479
|
normalizeStoredProvider(fresh);
|
|
483
480
|
c.providers.push(fresh);
|
|
484
481
|
persist();
|
|
@@ -500,21 +497,22 @@ export function removeProvider(id) {
|
|
|
500
497
|
return true;
|
|
501
498
|
}
|
|
502
499
|
|
|
503
|
-
/**
|
|
500
|
+
/** 显式替换模型池;空数组表示没有可用模型。发现使用合并入口。 */
|
|
504
501
|
export function setProviderModels(id, modelEntries) {
|
|
505
502
|
const c = load();
|
|
506
503
|
const p = c.providers.find((x) => x.id === id);
|
|
507
504
|
if (!p) return null;
|
|
508
505
|
if (Array.isArray(modelEntries)) {
|
|
509
|
-
//
|
|
510
|
-
const prev =
|
|
511
|
-
for (const m of p.models || []) if (m && m.id) prev
|
|
506
|
+
// 保留同名模型的实测结果;手工能力标注优先于后续自动发现。
|
|
507
|
+
const prev = new Map();
|
|
508
|
+
for (const m of p.models || []) if (m && m.id) prev.set(m.id, m);
|
|
512
509
|
p.models = modelEntries.map((m) => {
|
|
513
510
|
const entry = typeof m === 'string' ? { id: m } : { id: m.id, capabilities: m.capabilities };
|
|
514
|
-
const old = prev
|
|
511
|
+
const old = prev.get(entry.id);
|
|
515
512
|
if (old) {
|
|
516
513
|
if (old.verified) entry.verified = old.verified;
|
|
517
514
|
if (old.source) entry.source = old.source;
|
|
515
|
+
if (old.source === 'manual' && Array.isArray(old.capabilities)) entry.capabilities = old.capabilities;
|
|
518
516
|
}
|
|
519
517
|
if (m && m.verified) entry.verified = m.verified;
|
|
520
518
|
if (m && m.source) entry.source = m.source;
|
|
@@ -526,6 +524,19 @@ export function setProviderModels(id, modelEntries) {
|
|
|
526
524
|
return p;
|
|
527
525
|
}
|
|
528
526
|
|
|
527
|
+
/** 发现只追加或更新;用户手工项和未再返回的旧项始终保留。 */
|
|
528
|
+
export function mergeDiscoveredModels(id, modelEntries) {
|
|
529
|
+
const provider = load().providers.find((p) => p.id === id);
|
|
530
|
+
if (!provider) return null;
|
|
531
|
+
const merged = new Map(models.providerModels(provider).map((m) => [m.id, m]));
|
|
532
|
+
for (const model of modelEntries) {
|
|
533
|
+
const previous = merged.get(model.id);
|
|
534
|
+
if (previous?.source === 'manual') continue;
|
|
535
|
+
merged.set(model.id, { ...previous, ...model, source: 'discovered' });
|
|
536
|
+
}
|
|
537
|
+
return setProviderModels(id, [...merged.values()]);
|
|
538
|
+
}
|
|
539
|
+
|
|
529
540
|
/** 找 provider 里的某个模型条目(可变引用) */
|
|
530
541
|
function findModelEntry(p, modelId) {
|
|
531
542
|
return (p.models || []).find((m) => m && m.id === modelId);
|
|
@@ -546,6 +557,7 @@ export function addProviderModel(id, modelId, capabilities) {
|
|
|
546
557
|
} else if (Array.isArray(capabilities)) {
|
|
547
558
|
entry.capabilities = capabilities;
|
|
548
559
|
}
|
|
560
|
+
entry.source = 'manual';
|
|
549
561
|
pruneHealthToEffectiveModels(p);
|
|
550
562
|
persist();
|
|
551
563
|
return entry;
|
|
@@ -573,6 +585,7 @@ export function setModelCapabilities(id, modelId, capabilities) {
|
|
|
573
585
|
if (!entry) return false;
|
|
574
586
|
const nextCapabilities = (Array.isArray(capabilities) ? capabilities : []).filter((x) => cap.isKnownCapability(x));
|
|
575
587
|
entry.capabilities = nextCapabilities;
|
|
588
|
+
entry.source = 'manual';
|
|
576
589
|
pruneHealthToEffectiveModels(p);
|
|
577
590
|
persist();
|
|
578
591
|
return true;
|