@mokuyoaxis/dsh-iris 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dsh/skills/iris-compose-media/SKILL.md +10 -5
- package/.dsh/skills/iris-compose-media/references/workflows.md +7 -1
- package/.dsh/skills/iris-verify-ui/SKILL.md +2 -1
- package/CHANGELOG.md +47 -2
- package/README.en.md +65 -29
- package/README.md +65 -29
- package/bin/dsh-iris.js +222 -21
- package/docs/AGENT_QUICKSTART.md +10 -6
- package/docs/ARCHITECTURE.md +25 -18
- package/docs/ARTIFACT_LIBRARY.md +5 -5
- package/docs/ARTIFACT_MANIFEST.md +1 -1
- package/docs/CLI_MANAGEMENT.md +181 -0
- package/docs/COMPOSITE_VISION.md +35 -0
- package/docs/CORE_RUNTIME_CONTRACT.md +8 -7
- package/docs/DSH_CORE_MIGRATION.md +48 -19
- package/docs/DSH_RC2_ADAPTATION.md +4 -2
- package/docs/FAULT_INJECTION.md +12 -2
- package/docs/HEADLESS_CLI.md +13 -7
- package/docs/HOST_ADAPTER_CONTRACT.md +10 -10
- package/docs/HOST_DOCTOR.md +3 -3
- package/docs/MODEL_PORT_CONTRACT.md +19 -14
- package/docs/OCR_MODEL.md +39 -0
- package/docs/PROMPT_OPTIMIZER.md +47 -0
- package/docs/PROVIDER_ADAPTER_CONTRACT.md +6 -4
- package/docs/PROVIDER_HEALTH.md +22 -5
- package/docs/PROVIDER_RUNTIME_HARNESS.md +1 -1
- package/docs/PROVIDER_SUBMISSION_CONTRACT.md +2 -2
- package/docs/ROADMAP.md +29 -19
- package/docs/SECURITY.md +5 -5
- package/docs/TASK_SEMANTICS.md +2 -2
- package/docs/VISION_CLI.md +132 -0
- package/docs/VISION_MODEL.md +51 -0
- package/docs/WORKBENCH_ARTIFACTS.md +67 -0
- package/docs/file-access-across-environments.md +8 -5
- package/docs/releases/0.2.1.md +56 -0
- package/lib/actions.js +187 -270
- package/lib/adapters.js +133 -22
- package/lib/api.js +49 -7
- package/lib/chromium-browser.js +117 -0
- package/lib/client.js +671 -101
- package/lib/command-service.js +121 -7
- package/lib/composite-vision-routing.js +42 -0
- package/lib/config.js +89 -10
- package/lib/core-artifact-store.js +45 -13
- package/lib/core-maintenance.js +213 -0
- package/lib/core-tasks.js +12 -5
- package/lib/core-user-projection.js +1 -0
- package/lib/dsh-core-adapter.js +63 -9
- package/lib/dsh-text-model-adapter.js +222 -0
- package/lib/dsh-vision-model-adapter.js +75 -0
- package/lib/generation-input.js +36 -5
- package/lib/headless-vision.js +181 -0
- package/lib/host-runtime.js +1 -0
- package/lib/http-vision-model-adapter.js +187 -0
- package/lib/image-edit-input.js +34 -0
- package/lib/index.js +205 -175
- package/lib/locate.js +86 -28
- package/lib/media-probe.js +2 -1
- package/lib/model-call-runtime.js +45 -0
- package/lib/model-invoker.js +4 -2
- package/lib/model-port-contract.js +13 -5
- package/lib/models.js +10 -5
- package/lib/ocr-model-routing.js +26 -0
- package/lib/ocr.js +110 -64
- package/lib/prompt-optimizer-config.js +2 -1
- package/lib/prompt-optimizer-core.js +121 -0
- package/lib/prompt-optimizer.js +37 -193
- package/lib/provider-adapters.js +85 -9
- package/lib/provider-catalog.js +19 -7
- package/lib/provider-config-service.js +236 -0
- package/lib/provider-contract.js +3 -2
- package/lib/provider-health.js +80 -1
- package/lib/provider-protocol.js +23 -0
- package/lib/provider-task-runner.js +58 -16
- package/lib/summarize.js +31 -20
- package/lib/summary-input.js +52 -0
- package/lib/task-wait.js +40 -0
- package/lib/video-input.js +30 -0
- package/lib/vision-core.js +42 -0
- package/lib/vision-image-input.js +83 -0
- package/lib/vision-input.js +31 -0
- package/lib/vision-model-routing.js +180 -0
- package/lib/vision.js +3 -2
- package/lib/workbench-artifacts.js +52 -0
- package/lib/workbench-management.js +151 -0
- package/lib/workbench-zip.js +39 -0
- package/package.json +1 -1
- package/user_guide.md +52 -22
package/lib/command-service.js
CHANGED
|
@@ -12,16 +12,20 @@ import {
|
|
|
12
12
|
rebuildCoreArtifactIndex
|
|
13
13
|
} from './core-artifacts.js';
|
|
14
14
|
import { inspectCoreTask, listCoreTasks } from './core-tasks.js';
|
|
15
|
-
import { cropImage, pixelDiff, PixelError } from './pixels.js';
|
|
15
|
+
import { cropImage, pixelDiff, imageDimensions, PixelError } from './pixels.js';
|
|
16
16
|
import { extractFrames, normalizeFramesOptions, probeVideo } from './media-probe.js';
|
|
17
17
|
import { hasProviderOperation } from './provider-adapter.js';
|
|
18
18
|
import { createProviderTaskRunner } from './provider-task-runner.js';
|
|
19
19
|
import { GenerationInputError, normalizeGenerationInput } from './generation-input.js';
|
|
20
|
+
import { videoTaskCandidates } from './video-input.js';
|
|
21
|
+
import { waitForCoreTask } from './task-wait.js';
|
|
22
|
+
import { planCoreDeletion, planCoreCleanup, quarantineCoreFiles, inspectCoreTransactions, restoreCoreTransaction } from './core-maintenance.js';
|
|
20
23
|
|
|
21
24
|
export const COMMAND_CONTRACT_VERSION = 0;
|
|
22
25
|
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'
|
|
26
|
+
'crop', 'media.diff', 'media.frames', 'media.html', 'task.inspect', 'task.list', 'task.wait', 'task.observe', 'task.reobserve', 'task.redeliver', 'task.cancel', 'task.retry',
|
|
27
|
+
'task.inspect-many', 'artifact.inspect', 'artifact.inspect-many', 'artifact.list', 'artifact.export', 'artifact.export-many', 'artifact.rebuild',
|
|
28
|
+
'core.delete', 'core.cleanup', 'core.transactions', 'core.restore'
|
|
25
29
|
]);
|
|
26
30
|
|
|
27
31
|
export class CommandError extends Error {
|
|
@@ -82,11 +86,77 @@ function abortError() {
|
|
|
82
86
|
return error;
|
|
83
87
|
}
|
|
84
88
|
|
|
89
|
+
function idList(input, field) {
|
|
90
|
+
const ids = input[field];
|
|
91
|
+
if (!Array.isArray(ids) || !ids.length || ids.length > 200 || !ids.every(id => typeof id === 'string') || new Set(ids).size !== ids.length) {
|
|
92
|
+
throw commandError('IRIS_COMMAND_INPUT_INVALID', field + ' 必须是 1–200 个不重复 ID 的数组');
|
|
93
|
+
}
|
|
94
|
+
return ids;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function queryInput(input, fields) {
|
|
98
|
+
exactInput(input, ['offset', 'limit', ...fields]);
|
|
99
|
+
for (const [key, value] of Object.entries(input)) {
|
|
100
|
+
if (['offset', 'limit'].includes(key)) {
|
|
101
|
+
if (!Number.isSafeInteger(value) || value < (key === 'offset' ? 0 : 1) || (key === 'limit' && value > 200)) throw commandError('IRIS_COMMAND_INPUT_INVALID', 'offset 必须非负,limit 必须是 1–200 的整数');
|
|
102
|
+
} else if (typeof value !== 'string' || !value.trim()) throw commandError('IRIS_COMMAND_INPUT_INVALID', '过滤字段必须是非空字符串');
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
85
106
|
export function createCommandService(runtime, ports = {}) {
|
|
86
107
|
if (!runtime || typeof runtime.run !== 'function') throw new TypeError('Command Service 需要 Core Runtime');
|
|
87
108
|
|
|
88
109
|
async function execute(name, input) {
|
|
89
110
|
if (!CORE_COMMANDS.includes(name)) throw commandError('IRIS_COMMAND_UNKNOWN', `未知 Command:${String(name)}`);
|
|
111
|
+
if (name === 'core.delete' || name === 'core.cleanup') {
|
|
112
|
+
exactInput(input, name === 'core.delete' ? ['task_ids', 'artifact_ids', 'confirm_delete'] : ['paths', 'older_than_ms', 'confirm_delete']);
|
|
113
|
+
if (input.confirm_delete !== undefined && typeof input.confirm_delete !== 'boolean') throw commandError('IRIS_COMMAND_INPUT_INVALID', 'confirm_delete 必须是布尔值');
|
|
114
|
+
return runtime.run(input.confirm_delete === true ? 'execute' : 'inspect', ({ dataRoot, signal }) => {
|
|
115
|
+
if (signal.aborted) throw abortError();
|
|
116
|
+
let plan = name === 'core.delete' ? planCoreDeletion(dataRoot, input) : planCoreCleanup(dataRoot, input);
|
|
117
|
+
if (name === 'core.cleanup' && input.confirm_delete === true) {
|
|
118
|
+
const paths = input.paths;
|
|
119
|
+
if (!Array.isArray(paths) || !paths.length || paths.length > 200 || !paths.every(file => typeof file === 'string') || new Set(paths).size !== paths.length) throw commandError('IRIS_COMMAND_INPUT_INVALID', '执行清理必须明确指定 1–200 个预览中的 paths');
|
|
120
|
+
const entries = paths.map(file => plan.candidates.find(entry => entry.path === file));
|
|
121
|
+
if (entries.some(entry => !entry)) throw commandError('IRIS_CORE_CLEANUP_CHANGED', '指定路径不在当前可清理预览中');
|
|
122
|
+
plan = { operation: 'cleanup', allowed: true, entries };
|
|
123
|
+
}
|
|
124
|
+
const result = input.confirm_delete === true ? quarantineCoreFiles(dataRoot, plan) : { preview: true, ...plan };
|
|
125
|
+
return { contractVersion: COMMAND_CONTRACT_VERSION, command: name, ...result };
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
if (name === 'core.transactions') {
|
|
129
|
+
exactInput(input, []);
|
|
130
|
+
return runtime.run('inspect', ({ dataRoot }) => ({ contractVersion: COMMAND_CONTRACT_VERSION, command: name, transactions: inspectCoreTransactions(dataRoot) }));
|
|
131
|
+
}
|
|
132
|
+
if (name === 'core.restore') {
|
|
133
|
+
exactInput(input, ['transaction_id']);
|
|
134
|
+
return runtime.run('execute', ({ dataRoot }) => ({ contractVersion: COMMAND_CONTRACT_VERSION, command: name, ...restoreCoreTransaction(dataRoot, input.transaction_id) }));
|
|
135
|
+
}
|
|
136
|
+
if (name === 'media.html') {
|
|
137
|
+
exactInput(input, ['html', 'width', 'height', 'full_page']);
|
|
138
|
+
if (typeof input.html !== 'string' || !input.html.trim() || Buffer.byteLength(input.html) > 1024 * 1024) {
|
|
139
|
+
throw commandError('IRIS_COMMAND_INPUT_INVALID', 'html 必须是非空字符串,最多 1 MiB');
|
|
140
|
+
}
|
|
141
|
+
const width = input.width ?? 1280, height = input.height ?? 720;
|
|
142
|
+
if (![width, height].every(value => Number.isSafeInteger(value) && value > 0 && value <= 4096)
|
|
143
|
+
|| (input.full_page !== undefined && typeof input.full_page !== 'boolean')) {
|
|
144
|
+
throw commandError('IRIS_COMMAND_INPUT_INVALID', 'width/height 必须是 1–4096 的整数,full_page 必须是布尔值');
|
|
145
|
+
}
|
|
146
|
+
if (typeof ports.browser?.renderHtml !== 'function') throw commandError('IRIS_COMMAND_BROWSER_REQUIRED', 'HTML 截图需要 Browser Port');
|
|
147
|
+
return runtime.run('execute', async ({ dataRoot, signal }) => {
|
|
148
|
+
const rendered = await ports.browser.renderHtml({ html: input.html, width, height, fullPage: input.full_page !== false, signal });
|
|
149
|
+
if (signal.aborted) throw abortError();
|
|
150
|
+
const bytes = Buffer.from(rendered.bytes);
|
|
151
|
+
if (rendered.mediaType !== 'image/png' || !bytes.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex'))) {
|
|
152
|
+
throw commandError('IRIS_COMMAND_PROCESSING_FAILED', '浏览器没有返回 PNG 截图');
|
|
153
|
+
}
|
|
154
|
+
const dimensions = await imageDimensions(bytes);
|
|
155
|
+
if (signal.aborted) throw abortError();
|
|
156
|
+
const artifact = createCoreArtifact(dataRoot, { bytes, mediaType: 'image/png', kind: 'html-screenshot', metadata: dimensions });
|
|
157
|
+
return Object.freeze({ contractVersion: COMMAND_CONTRACT_VERSION, command: name, artifact });
|
|
158
|
+
});
|
|
159
|
+
}
|
|
90
160
|
if (name === 'crop') {
|
|
91
161
|
exactInput(input, ['image_path', 'artifact_id', 'left', 'top', 'width', 'height']);
|
|
92
162
|
const hasPath = Boolean(String(input.image_path || '').trim());
|
|
@@ -274,6 +344,12 @@ export function createCommandService(runtime, ports = {}) {
|
|
|
274
344
|
});
|
|
275
345
|
});
|
|
276
346
|
}
|
|
347
|
+
if (name === 'task.wait') {
|
|
348
|
+
exactInput(input, ['task_id', 'timeout_ms', 'poll_interval_ms']);
|
|
349
|
+
const result = await waitForCoreTask(runtime, input.task_id, { timeoutMs: input.timeout_ms, pollIntervalMs: input.poll_interval_ms,
|
|
350
|
+
...(typeof ports.resolveTaskAdapter === 'function' ? { observe: () => execute('task.observe', { task_id: input.task_id }) } : {}) });
|
|
351
|
+
return Object.freeze({ contractVersion: COMMAND_CONTRACT_VERSION, command: name, ...result });
|
|
352
|
+
}
|
|
277
353
|
// task.reobserve 是人工触发观察的对外命令名(DSH API/工作台),与 task.observe
|
|
278
354
|
// 共用同一份门与单步 poll 实现;语义不区分,绝不 submit。
|
|
279
355
|
if (name === 'task.observe' || name === 'task.reobserve') {
|
|
@@ -339,23 +415,28 @@ export function createCommandService(runtime, ports = {}) {
|
|
|
339
415
|
throw commandError('IRIS_TASK_NOT_RETRYABLE', '只有终态且未成功交付的 Task 可以重试为新任务');
|
|
340
416
|
}
|
|
341
417
|
let normalized;
|
|
342
|
-
try { normalized = normalizeGenerationInput(before.capability, input.provider_input); }
|
|
418
|
+
try { normalized = normalizeGenerationInput(before.capability, input.provider_input, { allowVideoPaths: before.capability === 'video' }); }
|
|
343
419
|
catch (error) {
|
|
344
420
|
if (error instanceof GenerationInputError) throw commandError('IRIS_COMMAND_INPUT_INVALID', error.message);
|
|
345
421
|
throw error;
|
|
346
422
|
}
|
|
347
423
|
const providerInput = normalized.providerInput;
|
|
424
|
+
if (before.sourceArtifactId && !normalized.sourceArtifactId) {
|
|
425
|
+
throw commandError('IRIS_COMMAND_INPUT_INVALID', '改图重试必须重新提供 source_artifact_id 和修改指令');
|
|
426
|
+
}
|
|
348
427
|
if (Object.hasOwn(input.provider_input, 'model_ref')) {
|
|
349
428
|
throw commandError('IRIS_COMMAND_INPUT_INVALID', 'retry 的模型覆盖请使用外层 model_ref');
|
|
350
429
|
}
|
|
351
430
|
const modelRef = String(input.model_ref || '').trim();
|
|
352
431
|
if (input.model_ref !== undefined && !modelRef) throw commandError('IRIS_COMMAND_INPUT_INVALID', 'model_ref 必须是 providerId::modelId 复合引用');
|
|
353
432
|
if (typeof ports.resolveTaskCandidates !== 'function') throw commandError('IRIS_COMMAND_PROVIDER_REQUIRED', 'task.retry 需要宿主显式提供候选链 resolver');
|
|
354
|
-
|
|
433
|
+
let candidates = await ports.resolveTaskCandidates({ capability: before.capability, modelRef });
|
|
434
|
+
if (before.capability === 'video') candidates = videoTaskCandidates(candidates, normalized);
|
|
355
435
|
const result = await createProviderTaskRunner(runtime).submit({
|
|
356
436
|
capability: before.capability,
|
|
357
437
|
candidates,
|
|
358
438
|
providerInput,
|
|
439
|
+
sourceArtifactId: normalized.sourceArtifactId,
|
|
359
440
|
retriedFrom: before.id
|
|
360
441
|
});
|
|
361
442
|
return Object.freeze({
|
|
@@ -377,7 +458,7 @@ export function createCommandService(runtime, ports = {}) {
|
|
|
377
458
|
}));
|
|
378
459
|
}
|
|
379
460
|
if (name === 'task.list') {
|
|
380
|
-
|
|
461
|
+
queryInput(input, ['status', 'capability', 'provider_id', 'model_ref', 'outcome', 'delivery_state']);
|
|
381
462
|
return runtime.run('inspect', ({ dataRoot }) => Object.freeze({
|
|
382
463
|
contractVersion: COMMAND_CONTRACT_VERSION,
|
|
383
464
|
command: name,
|
|
@@ -393,7 +474,7 @@ export function createCommandService(runtime, ports = {}) {
|
|
|
393
474
|
}));
|
|
394
475
|
}
|
|
395
476
|
if (name === 'artifact.list') {
|
|
396
|
-
|
|
477
|
+
queryInput(input, ['kind', 'media_type', 'task_id']);
|
|
397
478
|
return runtime.run('inspect', ({ dataRoot }) => Object.freeze({
|
|
398
479
|
contractVersion: COMMAND_CONTRACT_VERSION,
|
|
399
480
|
command: name,
|
|
@@ -408,6 +489,39 @@ export function createCommandService(runtime, ports = {}) {
|
|
|
408
489
|
...rebuildCoreArtifactIndex(dataRoot)
|
|
409
490
|
}));
|
|
410
491
|
}
|
|
492
|
+
if (name === 'task.inspect-many' || name === 'artifact.inspect-many') {
|
|
493
|
+
const task = name.startsWith('task.'), field = task ? 'task_ids' : 'artifact_ids';
|
|
494
|
+
exactInput(input, [field]);
|
|
495
|
+
const ids = idList(input, field);
|
|
496
|
+
return runtime.run('inspect', ({ dataRoot }) => ({ contractVersion: COMMAND_CONTRACT_VERSION, command: name,
|
|
497
|
+
results: ids.map(id => {
|
|
498
|
+
try { return { id, [task ? 'task' : 'artifact']: task ? inspectCoreTask(dataRoot, id) : inspectCoreArtifact(dataRoot, id) }; }
|
|
499
|
+
catch (error) { return { id, error: { code: error.code || 'IRIS_COMMAND_FAILED' } }; }
|
|
500
|
+
}) }));
|
|
501
|
+
}
|
|
502
|
+
if (name === 'artifact.export-many') {
|
|
503
|
+
exactInput(input, ['artifact_ids', 'output_directory']);
|
|
504
|
+
const ids = idList(input, 'artifact_ids');
|
|
505
|
+
const directory = input.output_directory;
|
|
506
|
+
if (typeof directory !== 'string' || !path.isAbsolute(directory) || !fs.existsSync(directory) || !fs.statSync(directory).isDirectory()) {
|
|
507
|
+
throw commandError('IRIS_COMMAND_INPUT_INVALID', 'output_directory 必须是已存在的绝对目录');
|
|
508
|
+
}
|
|
509
|
+
return runtime.run('execute', ({ dataRoot, signal }) => {
|
|
510
|
+
const extensions = { 'image/png': '.png', 'image/jpeg': '.jpg', 'image/webp': '.webp', 'image/gif': '.gif', 'video/mp4': '.mp4', 'video/webm': '.webm', 'audio/wav': '.wav', 'audio/mpeg': '.mp3', 'audio/ogg': '.ogg', 'audio/flac': '.flac', 'text/plain': '.txt', 'application/json': '.json' };
|
|
511
|
+
const outputs = ids.map(id => {
|
|
512
|
+
const artifact = inspectCoreArtifact(dataRoot, id);
|
|
513
|
+
const file = path.join(directory, id + (extensions[artifact.mediaType] || '.bin'));
|
|
514
|
+
if (fs.existsSync(file)) throw commandError('IRIS_ARTIFACT_EXPORT_EXISTS', '批量导出目标已存在;未导出任何文件');
|
|
515
|
+
return { id, file };
|
|
516
|
+
});
|
|
517
|
+
const results = outputs.map(({ id, file }) => {
|
|
518
|
+
if (signal.aborted) return { id, exported: false, error: { code: 'IRIS_COMMAND_ABORTED' } };
|
|
519
|
+
try { return { id, ...exportCoreArtifact(dataRoot, id, file) }; }
|
|
520
|
+
catch (error) { return { id, exported: false, error: { code: error.code || 'IRIS_ARTIFACT_EXPORT_FAILED' } }; }
|
|
521
|
+
});
|
|
522
|
+
return { contractVersion: COMMAND_CONTRACT_VERSION, command: name, results };
|
|
523
|
+
});
|
|
524
|
+
}
|
|
411
525
|
exactInput(input, ['artifact_id', 'output_path']);
|
|
412
526
|
return runtime.run('execute', ({ dataRoot }) => Object.freeze({
|
|
413
527
|
contractVersion: COMMAND_CONTRACT_VERSION,
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/** Host 选型留边界,定位/拼图只消费共享模型端口;准备也属于本次预算。 */
|
|
3
|
+
import sharp from 'sharp';
|
|
4
|
+
import { runVisionOperation, runVisionRequest } from './vision-model-routing.js';
|
|
5
|
+
import { locateObject, normalizeLocateTarget, createLocateImageRequest } from './locate.js';
|
|
6
|
+
import { summarizeMedia } from './summarize.js';
|
|
7
|
+
import { VISION_BUDGET } from './vision-core.js';
|
|
8
|
+
import { normalizeModelCall } from './model-port-contract.js';
|
|
9
|
+
import { waitForModelWork } from './model-call-runtime.js';
|
|
10
|
+
|
|
11
|
+
export async function runLocateRequest(host, { providers = [], model, target, image, prepareImage, signal, budget = VISION_BUDGET, onRateLimit }) {
|
|
12
|
+
target = normalizeLocateTarget(target);
|
|
13
|
+
const inputs = new Map();
|
|
14
|
+
const result = await runVisionOperation(host, { providers, model, signal, budget, onRateLimit, prepareImages: true,
|
|
15
|
+
orientImages: true, prepareImageRequest: createLocateImageRequest(target, inputs) }, async (ports, operation) => {
|
|
16
|
+
const resolved = await waitForModelWork(() => prepareImage ? prepareImage(operation.signal) : image, operation.signal);
|
|
17
|
+
const source = normalizeModelCall(ports[0].describe(), { prompt: target, image: resolved }, { budget }).request.image;
|
|
18
|
+
const { width, height } = await waitForModelWork(() => sharp(Buffer.from(source.bytes)).metadata(), operation.signal);
|
|
19
|
+
const completion = await locateObject(ports, { target, image: source, width, height }, { operation, budget });
|
|
20
|
+
return { completion, ...completion.bbox, width, height, input: inputs.get(completion.identity.backendId) };
|
|
21
|
+
});
|
|
22
|
+
const { answer: _answer, ...location } = result;
|
|
23
|
+
return location;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function runSummaryRequest(host, { providers = [], model, prepareMedia, frames, question, transcript, signal, budget = VISION_BUDGET, onRateLimit }) {
|
|
27
|
+
return runVisionOperation(host, { providers, model, signal, budget, onRateLimit }, async (ports, operation) => {
|
|
28
|
+
const media = await waitForModelWork(() => prepareMedia ? prepareMedia(operation.signal) : { frames, transcript }, operation.signal);
|
|
29
|
+
const completion = await summarizeMedia({ ports, frames: media.frames, question, transcript: media.transcript,
|
|
30
|
+
operation, budget });
|
|
31
|
+
return { completion, sheet: completion.sheet, frames: media.frames, meta: media.meta, transcriptNote: media.transcriptNote || '' };
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 增强描述沿用共享调用;失败不改变已经完成的媒体交付。 */
|
|
36
|
+
export async function describeGeneratedImage(host, { providers = [], originalPrompt, image, prepareImage, signal, onRateLimit }) {
|
|
37
|
+
try {
|
|
38
|
+
const result = await runVisionRequest(host, { providers, image, prepareImage, signal, onRateLimit,
|
|
39
|
+
question: `用不超过两句话描述这张图片的主题与构图。生成该图的提示词是:「${String(originalPrompt).slice(0, 200)}」` });
|
|
40
|
+
return result.answer.slice(0, 300);
|
|
41
|
+
} catch (_) { return ''; }
|
|
42
|
+
}
|
package/lib/config.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
+
import { normalizeVisionInputLimits } from './vision-image-input.js';
|
|
2
3
|
/**
|
|
3
4
|
* Iris 配置存储:$DSH_HOME/iris/v1/providers.json
|
|
4
5
|
* Iris 独立持有配置;旧工作台仅作为用户显式指定的一次性导入来源。
|
|
@@ -10,14 +11,15 @@ import os from 'node:os';
|
|
|
10
11
|
import * as cap from './capability.js';
|
|
11
12
|
import * as models from './models.js';
|
|
12
13
|
import { atomicWritePrivate, chmodPrivateFile, privateSibling } from './private-storage.js';
|
|
13
|
-
import { selectMediaProtocol, isConfiguredProvider } from './provider-protocol.js';
|
|
14
|
+
import { selectMediaProtocol, selectImageProtocol, normalizeImageProtocol, isConfiguredProvider } from './provider-protocol.js';
|
|
14
15
|
import { redactProviderMessage } from './provider-contract.js';
|
|
15
16
|
import {
|
|
16
17
|
PROVIDER_HEALTH_FRESH_MS,
|
|
17
18
|
aggregateHealthStates,
|
|
18
19
|
healthObservationState,
|
|
19
20
|
isDefinitiveHealthFailure,
|
|
20
|
-
newestHealthEvidence
|
|
21
|
+
newestHealthEvidence,
|
|
22
|
+
modelRateLimit, recordModelRateLimit, clearModelRateLimit, clearModelCapabilityHealth
|
|
21
23
|
} from './provider-health.js';
|
|
22
24
|
|
|
23
25
|
function isPlainObject(value) {
|
|
@@ -191,7 +193,7 @@ export function pickFor(capability) {
|
|
|
191
193
|
const pool = models.modelPool(providers());
|
|
192
194
|
let m = null;
|
|
193
195
|
for (const ref of assignmentOrder(capability)) {
|
|
194
|
-
m = pool.find((x) => x.ref === ref && x.capabilities.includes(capability));
|
|
196
|
+
m = pool.find((x) => !x.rateLimited && x.ref === ref && x.capabilities.includes(capability));
|
|
195
197
|
if (m) break;
|
|
196
198
|
}
|
|
197
199
|
if (!m) m = models.pickModel(pool, capability);
|
|
@@ -207,9 +209,9 @@ export function pickFor(capability) {
|
|
|
207
209
|
* 已分配顺序优先(阶段 6 条目 4),其余按池顺序补齐(去重)。
|
|
208
210
|
* 一个 provider 若池里有多个该能力模型,会返回多条(各自带不同模型)。
|
|
209
211
|
*/
|
|
210
|
-
export function pickAllFor(capability) {
|
|
212
|
+
export function pickAllFor(capability, { includeRateLimited = false } = {}) {
|
|
211
213
|
const pool = models.modelPool(providers());
|
|
212
|
-
const chosen = models.orderedModels(pool, capability, assignments()[capability]);
|
|
214
|
+
const chosen = models.orderedModels(pool, capability, assignments()[capability], { includeRateLimited });
|
|
213
215
|
const out = [];
|
|
214
216
|
for (const m of chosen) {
|
|
215
217
|
const p = providerById(m.providerId);
|
|
@@ -352,8 +354,10 @@ function applyProviderHealth(provider, modelId, capability, result, fallbackSour
|
|
|
352
354
|
health.observations.push(observation);
|
|
353
355
|
}
|
|
354
356
|
const evidence = observationEvidence(provider, result || {}, fallbackSource);
|
|
357
|
+
recordModelRateLimit(provider, model, { ...evidence, retryAfterMs: result?.retryAfterMs, until: result?.until, reason: result?.reason });
|
|
355
358
|
if (result && result.ok === true) {
|
|
356
359
|
observation.lastSuccess = evidence;
|
|
360
|
+
if (evidence.source === 'probe') clearModelRateLimit(provider, model);
|
|
357
361
|
} else if (isDefinitiveHealthFailure({ ...result, ...evidence })) {
|
|
358
362
|
observation.lastFailure = evidence;
|
|
359
363
|
} else {
|
|
@@ -391,10 +395,11 @@ function resetHealthOnCriticalChange(previous, next, patch) {
|
|
|
391
395
|
if (!changed) return;
|
|
392
396
|
const current = previous.health;
|
|
393
397
|
const revision = Number.isSafeInteger(current && current.revision) ? current.revision + 1 : 1;
|
|
394
|
-
next.health = { version: 1, revision, observations: []
|
|
398
|
+
next.health = { version: 1, revision, observations: [],
|
|
399
|
+
...(current?.rateLimits?.length ? { rateLimits: current.rateLimits } : {}) };
|
|
395
400
|
}
|
|
396
401
|
|
|
397
|
-
/**
|
|
402
|
+
/** 记录显式实测或真实任务观察;限流资格另存,保留原成功事实。 */
|
|
398
403
|
export function recordProviderHealth(id, modelId, capability, result) {
|
|
399
404
|
const c = load();
|
|
400
405
|
const provider = c.providers.find((item) => item.id === id);
|
|
@@ -405,10 +410,24 @@ export function recordProviderHealth(id, modelId, capability, result) {
|
|
|
405
410
|
return true;
|
|
406
411
|
}
|
|
407
412
|
|
|
413
|
+
/** DSH 调用边界记录冷却或明确耗尽;覆盖同账号该模型的所有能力。 */
|
|
414
|
+
export function recordRateLimit({ providerId, modelId, capability, httpStatus = 429, category = 'rate_limit', at, until, retryAfterMs, reason }) {
|
|
415
|
+
const known = { image: 'image-gen', video: 'video-gen' }[capability] || capability;
|
|
416
|
+
return recordProviderHealth(providerId, modelId, known, {
|
|
417
|
+
ok: false, source: 'task', category, httpStatus, at, until, retryAfterMs, reason,
|
|
418
|
+
note: category === 'quota' ? '额度或预算耗尽后已停用,手动实测成功后恢复' : '429 限流冷却,暂时跳过'
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
|
|
408
422
|
/** 返回单个 Provider × Model × Capability 的安全状态。 */
|
|
409
423
|
export function modelHealth(id, modelId, capability, { now = Date.now() } = {}) {
|
|
410
424
|
const provider = load().providers.find((item) => item.id === id);
|
|
411
425
|
if (!provider || provider.enabled === false || missingCredentials(provider)) return { status: 'unconfigured' };
|
|
426
|
+
const blocked = modelRateLimit(provider, modelId, { now });
|
|
427
|
+
if (blocked) return { status: 'failed', rateLimited: true, observedAt: blocked.at,
|
|
428
|
+
source: blocked.source, category: blocked.category, httpStatus: blocked.httpStatus,
|
|
429
|
+
...(blocked.until ? { retryAt: blocked.until } : { reason: blocked.reason }),
|
|
430
|
+
note: blocked.until ? '429 限流冷却,暂时跳过' : '额度或预算耗尽后已停用,手动实测成功后恢复' };
|
|
412
431
|
const observation = healthObservation(provider, modelId, capability);
|
|
413
432
|
const status = healthObservationState(observation, { now });
|
|
414
433
|
const evidence = status === 'verified'
|
|
@@ -430,7 +449,7 @@ export function modelHealth(id, modelId, capability, { now = Date.now() } = {})
|
|
|
430
449
|
export function providerHealthSnapshot({ now = Date.now() } = {}) {
|
|
431
450
|
const capabilities = {};
|
|
432
451
|
for (const capability of Object.values(cap.CAPABILITIES)) {
|
|
433
|
-
const candidates = pickAllFor(capability).map((provider) => {
|
|
452
|
+
const candidates = pickAllFor(capability, { includeRateLimited: true }).map((provider) => {
|
|
434
453
|
const modelId = String(provider[FIELD_OF[capability]] || '');
|
|
435
454
|
const health = modelHealth(provider.id, modelId, capability, { now });
|
|
436
455
|
return { providerId: provider.id, modelId, ...health };
|
|
@@ -443,14 +462,21 @@ export function providerHealthSnapshot({ now = Date.now() } = {}) {
|
|
|
443
462
|
status,
|
|
444
463
|
candidateCount: candidates.length,
|
|
445
464
|
...(newest ? { observedAt: newest.observedAt } : {}),
|
|
465
|
+
...(status === 'failed' && candidates.every(item => item.retryAt) ? {
|
|
466
|
+
rateLimited: true, retryAt: candidates.map(item => item.retryAt).sort()[0]
|
|
467
|
+
} : {}),
|
|
446
468
|
candidates
|
|
447
469
|
};
|
|
448
470
|
}
|
|
471
|
+
const overall = aggregateHealthStates(Object.values(capabilities).map((item) => item.status));
|
|
472
|
+
const configured = Object.values(capabilities).filter(item => item.status !== 'unconfigured');
|
|
449
473
|
return {
|
|
450
474
|
schemaVersion: 1,
|
|
451
475
|
freshForMs: PROVIDER_HEALTH_FRESH_MS,
|
|
452
476
|
generatedAt: new Date(Number(now)).toISOString(),
|
|
453
|
-
overall
|
|
477
|
+
overall,
|
|
478
|
+
...(overall === 'failed' && configured.every(item => item.retryAt)
|
|
479
|
+
? { retryAt: configured.map(item => item.retryAt).sort()[0] } : {}),
|
|
454
480
|
capabilities
|
|
455
481
|
};
|
|
456
482
|
}
|
|
@@ -460,6 +486,10 @@ export function upsert(provider) {
|
|
|
460
486
|
const { id, ...input } = provider;
|
|
461
487
|
// PATCH 语义:缺失字段和 undefined 都不覆盖旧值;null/空串仍是显式更新。
|
|
462
488
|
const rest = Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined));
|
|
489
|
+
if (rest.visionInput !== undefined) rest.visionInput = normalizeVisionInputLimits(rest.visionInput) || null;
|
|
490
|
+
if (Array.isArray(rest.models)) rest.models = rest.models.map(model => typeof model === 'string' ? model : ({ ...model,
|
|
491
|
+
...(model?.imageProtocol !== undefined ? { imageProtocol: normalizeImageProtocol(model.imageProtocol) } : {}),
|
|
492
|
+
...(model?.visionInput !== undefined ? { visionInput: normalizeVisionInputLimits(model.visionInput) || null } : {}) }));
|
|
463
493
|
if (id) {
|
|
464
494
|
const i = c.providers.findIndex((p) => p.id === id);
|
|
465
495
|
if (i >= 0) {
|
|
@@ -468,6 +498,7 @@ export function upsert(provider) {
|
|
|
468
498
|
if (Object.prototype.hasOwnProperty.call(rest, 'mediaProtocol')) next.protocolInferred = false;
|
|
469
499
|
normalizeStoredProvider(next);
|
|
470
500
|
resetHealthOnCriticalChange(previous, next, rest);
|
|
501
|
+
resetChangedImageProtocolHealth(previous, next);
|
|
471
502
|
if (Object.prototype.hasOwnProperty.call(rest, 'models')) pruneHealthToEffectiveModels(next);
|
|
472
503
|
c.providers[i] = next;
|
|
473
504
|
persist();
|
|
@@ -502,23 +533,29 @@ export function setProviderModels(id, modelEntries) {
|
|
|
502
533
|
const c = load();
|
|
503
534
|
const p = c.providers.find((x) => x.id === id);
|
|
504
535
|
if (!p) return null;
|
|
536
|
+
const previous = { ...p };
|
|
505
537
|
if (Array.isArray(modelEntries)) {
|
|
506
538
|
// 保留同名模型的实测结果;手工能力标注优先于后续自动发现。
|
|
507
539
|
const prev = new Map();
|
|
508
540
|
for (const m of p.models || []) if (m && m.id) prev.set(m.id, m);
|
|
509
541
|
p.models = modelEntries.map((m) => {
|
|
510
|
-
const entry = typeof m === 'string' ? { id: m } : { id: m.id, capabilities: m.capabilities
|
|
542
|
+
const entry = typeof m === 'string' ? { id: m } : { id: m.id, capabilities: m.capabilities,
|
|
543
|
+
...(m.imageProtocol !== undefined ? { imageProtocol: normalizeImageProtocol(m.imageProtocol) } : {}),
|
|
544
|
+
...(m.visionInput !== undefined ? { visionInput: normalizeVisionInputLimits(m.visionInput) || null } : {}) };
|
|
511
545
|
const old = prev.get(entry.id);
|
|
512
546
|
if (old) {
|
|
513
547
|
if (old.verified) entry.verified = old.verified;
|
|
514
548
|
if (old.source) entry.source = old.source;
|
|
515
549
|
if (old.source === 'manual' && Array.isArray(old.capabilities)) entry.capabilities = old.capabilities;
|
|
550
|
+
if (m?.imageProtocol === undefined && old.imageProtocol !== undefined) entry.imageProtocol = old.imageProtocol;
|
|
551
|
+
if (m?.visionInput === undefined && old.visionInput !== undefined) entry.visionInput = old.visionInput;
|
|
516
552
|
}
|
|
517
553
|
if (m && m.verified) entry.verified = m.verified;
|
|
518
554
|
if (m && m.source) entry.source = m.source;
|
|
519
555
|
return entry;
|
|
520
556
|
});
|
|
521
557
|
}
|
|
558
|
+
resetChangedImageProtocolHealth(previous, p);
|
|
522
559
|
pruneHealthToEffectiveModels(p);
|
|
523
560
|
persist();
|
|
524
561
|
return p;
|
|
@@ -542,6 +579,48 @@ function findModelEntry(p, modelId) {
|
|
|
542
579
|
return (p.models || []).find((m) => m && m.id === modelId);
|
|
543
580
|
}
|
|
544
581
|
|
|
582
|
+
function resetChangedImageProtocolHealth(previous, next) {
|
|
583
|
+
for (const model of models.providerModels(previous)) {
|
|
584
|
+
if (selectImageProtocol(previous, model.id).mediaProtocol !== selectImageProtocol(next, model.id).mediaProtocol) {
|
|
585
|
+
clearModelCapabilityHealth(next, model.id, cap.CAPABILITIES.IMAGE);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** 手动选择单模型图片协议;auto 恢复账号默认,不解除模型停用。 */
|
|
591
|
+
export function setModelImageProtocol(id, modelId, value) {
|
|
592
|
+
const protocol = normalizeImageProtocol(value);
|
|
593
|
+
const p = load().providers.find(provider => provider.id === id);
|
|
594
|
+
if (!p) return false;
|
|
595
|
+
const entries = Array.isArray(p.models) ? p.models : models.providerModels(p).map(({ id, capabilities }) => ({ id, capabilities }));
|
|
596
|
+
const index = entries.findIndex(model => (typeof model === 'string' ? model : model?.id)?.trim() === modelId);
|
|
597
|
+
if (index < 0) return false;
|
|
598
|
+
p.models = entries;
|
|
599
|
+
const entry = typeof p.models[index] === 'string' ? { id: p.models[index] } : p.models[index];
|
|
600
|
+
p.models[index] = entry;
|
|
601
|
+
const previous = selectImageProtocol(p, modelId).mediaProtocol;
|
|
602
|
+
if (protocol === 'auto') delete entry.imageProtocol;
|
|
603
|
+
else entry.imageProtocol = protocol;
|
|
604
|
+
if (previous !== selectImageProtocol(p, modelId).mediaProtocol) clearModelCapabilityHealth(p, modelId, cap.CAPABILITIES.IMAGE);
|
|
605
|
+
persist();
|
|
606
|
+
return true;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** 看图预算变更不会伪造实测成功、解除额度停用或改写原图。 */
|
|
610
|
+
export function setModelVisionInput(id, modelId, value) {
|
|
611
|
+
const limits = normalizeVisionInputLimits(value);
|
|
612
|
+
const p = load().providers.find(provider => provider.id === id);
|
|
613
|
+
if (!p) return false;
|
|
614
|
+
const entries = (Array.isArray(p.models) ? p.models : models.providerModels(p)).map(model => typeof model === 'string' ? { id: model } : { ...model });
|
|
615
|
+
const entry = entries.find(model => model.id === modelId);
|
|
616
|
+
if (!entry) return false;
|
|
617
|
+
if (limits) entry.visionInput = limits;
|
|
618
|
+
else delete entry.visionInput;
|
|
619
|
+
p.models = entries;
|
|
620
|
+
persist();
|
|
621
|
+
return true;
|
|
622
|
+
}
|
|
623
|
+
|
|
545
624
|
/** 手动添加一个模型到池(source=manual,能力由调用方给) */
|
|
546
625
|
export function addProviderModel(id, modelId, capabilities) {
|
|
547
626
|
const c = load();
|
|
@@ -174,11 +174,11 @@ function publicArtifact(manifest) {
|
|
|
174
174
|
});
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
-
function verifyObject(dataRoot, manifest) {
|
|
177
|
+
function verifyObject(dataRoot, manifest, verifyContent = true) {
|
|
178
178
|
const object = path.join(dirs(dataRoot).objects, manifest.object);
|
|
179
179
|
const stat = regularFile(object);
|
|
180
180
|
if (stat.size !== manifest.size) fail('IRIS_ARTIFACT_DIGEST_MISMATCH', 'Artifact 大小与 Manifest 不一致');
|
|
181
|
-
if (hashFile(object) !== manifest.digest.value) {
|
|
181
|
+
if (verifyContent && hashFile(object) !== manifest.digest.value) {
|
|
182
182
|
fail('IRIS_ARTIFACT_DIGEST_MISMATCH', 'Artifact 内容哈希与 Manifest 不一致');
|
|
183
183
|
}
|
|
184
184
|
return object;
|
|
@@ -209,7 +209,7 @@ function legacyManifest(dataRoot, record, id) {
|
|
|
209
209
|
};
|
|
210
210
|
}
|
|
211
211
|
|
|
212
|
-
function readArtifact(dataRoot, id, { allowLegacy = true } = {}) {
|
|
212
|
+
function readArtifact(dataRoot, id, { allowLegacy = true, verifyContent = true } = {}) {
|
|
213
213
|
checkStore(dataRoot);
|
|
214
214
|
const stable = stableId(id);
|
|
215
215
|
let record;
|
|
@@ -226,7 +226,7 @@ function readArtifact(dataRoot, id, { allowLegacy = true } = {}) {
|
|
|
226
226
|
);
|
|
227
227
|
} else if (allowLegacy) manifest = legacyManifest(dataRoot, record, stable);
|
|
228
228
|
else fail('IRIS_ARTIFACT_INVALID', 'Artifact 提交记录无效');
|
|
229
|
-
const object = verifyObject(dataRoot, manifest);
|
|
229
|
+
const object = verifyObject(dataRoot, manifest, verifyContent);
|
|
230
230
|
return { manifest, object, legacy: record.schemaVersion === 0 };
|
|
231
231
|
}
|
|
232
232
|
|
|
@@ -284,6 +284,14 @@ function refreshIndex(dataRoot) {
|
|
|
284
284
|
return writeIndex(dataRoot, manifests);
|
|
285
285
|
}
|
|
286
286
|
|
|
287
|
+
/** 只更新已提交条目的派生索引,不升级旧记录或接回孤儿。 */
|
|
288
|
+
export function refreshCoreArtifactInventory(dataRoot) {
|
|
289
|
+
checkStore(dataRoot, { manifests: true });
|
|
290
|
+
const manifests = committedIds(dataRoot).map(id => readArtifact(dataRoot, id).manifest);
|
|
291
|
+
const index = writeIndex(dataRoot, manifests);
|
|
292
|
+
return { revision: index.revision, total: manifests.length };
|
|
293
|
+
}
|
|
294
|
+
|
|
287
295
|
function validateRelationTargets(dataRoot, relations) {
|
|
288
296
|
for (const edge of relations) readArtifact(dataRoot, edge.artifactId);
|
|
289
297
|
}
|
|
@@ -402,14 +410,11 @@ export function exportCoreArtifact(dataRoot, id, outputPath) {
|
|
|
402
410
|
return Object.freeze({ artifact: publicArtifact(hit.manifest), exported: true, bytes: hit.manifest.size });
|
|
403
411
|
}
|
|
404
412
|
|
|
405
|
-
|
|
413
|
+
function readArtifactIndex(dataRoot) {
|
|
406
414
|
try { fs.lstatSync(dirs(dataRoot).storeRoot); }
|
|
407
415
|
catch (error) {
|
|
408
416
|
if (error?.code !== 'ENOENT') fail('IRIS_ARTIFACT_STORE_INVALID', 'Artifact Store 无法读取');
|
|
409
|
-
return
|
|
410
|
-
schemaVersion: 0, revision: 0, total: 0, offset: 0, limit: Math.max(1, Math.min(200, Math.trunc(Number(limit) || 50))),
|
|
411
|
-
artifacts: Object.freeze([])
|
|
412
|
-
});
|
|
417
|
+
return { schemaVersion: 0, revision: 0, artifacts: [], absent: true };
|
|
413
418
|
}
|
|
414
419
|
checkStore(dataRoot, { manifests: true });
|
|
415
420
|
const index = jsonFile(dirs(dataRoot).index, 'IRIS_ARTIFACT_INDEX_INVALID');
|
|
@@ -420,10 +425,32 @@ export function listCoreArtifacts(dataRoot, { offset = 0, limit = 50 } = {}) {
|
|
|
420
425
|
|| !HASH.test(String(index.inventoryDigest || '')) || index.inventoryDigest !== inventoryDigest) {
|
|
421
426
|
fail('IRIS_ARTIFACT_INDEX_INVALID', 'Artifact Index 结构或自校验无效');
|
|
422
427
|
}
|
|
423
|
-
|
|
428
|
+
return index;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** 工作台目录只读已提交元数据和文件大小;媒体打开/识别仍须核验完整内容哈希。 */
|
|
432
|
+
export function listCoreArtifactMetadata(dataRoot) {
|
|
433
|
+
const index = readArtifactIndex(dataRoot), artifacts = [];
|
|
434
|
+
let dropped = 0;
|
|
435
|
+
for (const entry of index.artifacts) {
|
|
436
|
+
try {
|
|
437
|
+
const { manifest } = readArtifact(dataRoot, entry.id, { allowLegacy: false, verifyContent: false });
|
|
438
|
+
if (entry.createdAt !== manifest.createdAt || entry.mediaType !== manifest.mediaType
|
|
439
|
+
|| entry.size !== manifest.size || entry.digest !== manifest.digest.value) throw new Error('index mismatch');
|
|
440
|
+
artifacts.push(publicArtifact(manifest));
|
|
441
|
+
} catch (_) { dropped++; }
|
|
442
|
+
}
|
|
443
|
+
return { revision: index.revision, artifacts, dropped };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
export function listCoreArtifacts(dataRoot, { offset = 0, limit = 50, kind, media_type, task_id } = {}) {
|
|
447
|
+
const index = readArtifactIndex(dataRoot);
|
|
448
|
+
const start = index.absent ? 0 : Math.max(0, Math.trunc(Number(offset) || 0));
|
|
424
449
|
const size = Math.max(1, Math.min(200, Math.trunc(Number(limit) || 50)));
|
|
425
450
|
const artifacts = [];
|
|
426
|
-
|
|
451
|
+
const filtering = kind !== undefined || media_type !== undefined || task_id !== undefined;
|
|
452
|
+
let matchedTotal = 0;
|
|
453
|
+
for (const entry of (filtering ? index.artifacts : index.artifacts.slice(start, start + size))) {
|
|
427
454
|
if (!entry || !ID.test(String(entry.id || ''))) fail('IRIS_ARTIFACT_INDEX_INVALID', 'Artifact Index 条目无效');
|
|
428
455
|
try {
|
|
429
456
|
const artifact = inspectCoreArtifact(dataRoot, entry.id);
|
|
@@ -431,7 +458,12 @@ export function listCoreArtifacts(dataRoot, { offset = 0, limit = 50 } = {}) {
|
|
|
431
458
|
|| entry.size !== artifact.size || entry.digest !== artifact.digest.value) {
|
|
432
459
|
fail('IRIS_ARTIFACT_INDEX_INVALID', 'Artifact Index 条目与 Manifest 不一致');
|
|
433
460
|
}
|
|
434
|
-
|
|
461
|
+
if (filtering) {
|
|
462
|
+
if ((kind !== undefined && artifact.kind !== kind) || (media_type !== undefined && artifact.mediaType !== media_type)
|
|
463
|
+
|| (task_id !== undefined && artifact.metadata.taskId !== task_id)) continue;
|
|
464
|
+
if (matchedTotal >= start && matchedTotal < start + size) artifacts.push(artifact);
|
|
465
|
+
matchedTotal++;
|
|
466
|
+
} else artifacts.push(artifact);
|
|
435
467
|
} catch (error) {
|
|
436
468
|
if (error?.code === 'IRIS_ARTIFACT_INDEX_INVALID') throw error;
|
|
437
469
|
fail('IRIS_ARTIFACT_INDEX_INVALID', 'Artifact Index 与存储事实不一致;请显式重建');
|
|
@@ -440,7 +472,7 @@ export function listCoreArtifacts(dataRoot, { offset = 0, limit = 50 } = {}) {
|
|
|
440
472
|
return Object.freeze({
|
|
441
473
|
schemaVersion: 0,
|
|
442
474
|
revision: index.revision,
|
|
443
|
-
total: index.artifacts.length,
|
|
475
|
+
total: filtering ? matchedTotal : index.artifacts.length,
|
|
444
476
|
offset: start,
|
|
445
477
|
limit: size,
|
|
446
478
|
artifacts: Object.freeze(artifacts)
|