@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,52 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/** CLI/Host 共用的摘要来源:已有帧只读,不导出媒体或重新抽帧。 */
|
|
3
|
+
import { CommandError } from './command-service.js';
|
|
4
|
+
import { readCoreArtifactBytes } from './core-artifacts.js';
|
|
5
|
+
import { waitForModelWork } from './model-call-runtime.js';
|
|
6
|
+
|
|
7
|
+
function invalid(message) { throw new CommandError('IRIS_COMMAND_INPUT_INVALID', message); }
|
|
8
|
+
|
|
9
|
+
export function normalizeSummarySource(input) {
|
|
10
|
+
const hasFrames = input.frame_artifact_ids !== undefined;
|
|
11
|
+
const hasVideo = Boolean(input.video_path);
|
|
12
|
+
if (hasFrames === hasVideo) invalid('摘要必须且只能提供 video_path 或 frame_artifact_ids 之一');
|
|
13
|
+
if (hasFrames) {
|
|
14
|
+
const ids = input.frame_artifact_ids;
|
|
15
|
+
if (!Array.isArray(ids) || !ids.length || ids.length > 20
|
|
16
|
+
|| !ids.every(id => typeof id === 'string' && /^artifact_[a-f0-9]{24}$/.test(id))
|
|
17
|
+
|| new Set(ids).size !== ids.length) invalid('frame_artifact_ids 必须是 1–20 个不重复的 Core Artifact ID');
|
|
18
|
+
if (input.max_frames !== undefined || input.target_width !== undefined) invalid('已有帧不使用 max_frames 或 target_width;请直接选择帧 ID');
|
|
19
|
+
if (input.transcribe === true) invalid('自动音轨转写需要 video_path;已有帧可提供 transcribe_text');
|
|
20
|
+
}
|
|
21
|
+
if (input.transcribe_text !== undefined && typeof input.transcribe_text !== 'string') invalid('transcribe_text 必须是字符串');
|
|
22
|
+
if (input.transcribe === true && input.transcribe_text?.trim()) invalid('transcribe 与 transcribe_text 不能同时使用');
|
|
23
|
+
return hasFrames ? 'core-artifacts' : 'video';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function readSummaryFrames(runtime, frameArtifactIds, signal) {
|
|
27
|
+
return waitForModelWork(() => runtime.run('inspect', ({ dataRoot }) => {
|
|
28
|
+
const frames = frameArtifactIds.map(id => {
|
|
29
|
+
const { artifact, bytes } = readCoreArtifactBytes(dataRoot, id);
|
|
30
|
+
const { frameIndex, atSec, width, height } = artifact.metadata;
|
|
31
|
+
if (artifact.kind !== 'video-frame' || !['image/png', 'image/jpeg'].includes(artifact.mediaType)
|
|
32
|
+
|| !Number.isFinite(atSec) || atSec < 0 || !Number.isSafeInteger(frameIndex) || frameIndex < 1
|
|
33
|
+
|| !Number.isSafeInteger(width) || width < 1 || !Number.isSafeInteger(height) || height < 1) {
|
|
34
|
+
invalid('摘要需要含有效帧序号、时间戳和尺寸的 video-frame Artifact');
|
|
35
|
+
}
|
|
36
|
+
return { artifactId: artifact.id, frameIndex, atSec, width, height, buffer: bytes };
|
|
37
|
+
}).sort((a, b) => a.atSec - b.atSec || a.frameIndex - b.frameIndex);
|
|
38
|
+
return { frames, meta: { source: 'core-artifacts', startSec: frames[0].atSec, endSec: frames.at(-1).atSec } };
|
|
39
|
+
}), signal);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function summaryFrameRecords(frames) {
|
|
43
|
+
return frames.map(({ artifactId, frameIndex, atSec, width, height }) => ({
|
|
44
|
+
...(artifactId ? { artifactId, frameIndex } : {}), atSec, width, height
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function summaryTimelineLabel(meta) {
|
|
49
|
+
return meta.source === 'core-artifacts'
|
|
50
|
+
? `${meta.startSec.toFixed(1)}–${meta.endSec.toFixed(1)}s 已选帧`
|
|
51
|
+
: `${meta.durationSec.toFixed(1)}s`;
|
|
52
|
+
}
|
package/lib/task-wait.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
2
|
+
import { inspectCoreTask } from './core-tasks.js';
|
|
3
|
+
|
|
4
|
+
export function coreTaskWaitFinished(task) {
|
|
5
|
+
return task.phase === 'terminal' || task.acceptance !== 'accepted'
|
|
6
|
+
|| !['none', 'unknown'].includes(task.outcome);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** 有界观察原 Task,超时中止 poll/下载并保留事实;没有任何 submit/retry 路径。 */
|
|
10
|
+
export async function waitForCoreTask(runtime, taskId, { observe, timeoutMs = 120000, pollIntervalMs = 2500 } = {}) {
|
|
11
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 1200000
|
|
12
|
+
|| !Number.isSafeInteger(pollIntervalMs) || pollIntervalMs < 50 || pollIntervalMs > 60000) {
|
|
13
|
+
throw Object.assign(new Error('timeout_ms 必须是 1–1200000,poll_interval_ms 必须是 50–60000 的整数'), { code: 'IRIS_COMMAND_INPUT_INVALID' });
|
|
14
|
+
}
|
|
15
|
+
const dataRoot = await runtime.run('inspect', context => context.dataRoot);
|
|
16
|
+
let task = inspectCoreTask(dataRoot, taskId);
|
|
17
|
+
const completed = timedOut => ({ taskId, task, timedOut, ready: task.outcome === 'succeeded' && task.deliveryState === 'ready' });
|
|
18
|
+
if (coreTaskWaitFinished(task)) return completed(false);
|
|
19
|
+
if (typeof observe !== 'function') throw Object.assign(new Error('等待远端任务需要 Provider resolver'), { code: 'IRIS_COMMAND_PROVIDER_REQUIRED' });
|
|
20
|
+
let timedOut = false;
|
|
21
|
+
const timer = setTimeout(() => { timedOut = true; runtime.dispose().catch(() => {}); }, timeoutMs);
|
|
22
|
+
try {
|
|
23
|
+
while (!coreTaskWaitFinished(task)) {
|
|
24
|
+
runtime.signal.throwIfAborted();
|
|
25
|
+
try { task = (await observe()).task; }
|
|
26
|
+
catch (error) {
|
|
27
|
+
if (runtime.signal.aborted) throw error;
|
|
28
|
+
if (error.code && !['IRIS_PROVIDER_POLL_FAILED', 'IRIS_PROVIDER_DOWNLOAD_FAILED'].includes(error.code)) throw error;
|
|
29
|
+
task = inspectCoreTask(dataRoot, taskId);
|
|
30
|
+
}
|
|
31
|
+
if (!coreTaskWaitFinished(task)) await delay(pollIntervalMs, undefined, { signal: runtime.signal });
|
|
32
|
+
}
|
|
33
|
+
return completed(false);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
if (!timedOut) throw error;
|
|
36
|
+
await runtime.dispose();
|
|
37
|
+
task = inspectCoreTask(dataRoot, taskId);
|
|
38
|
+
return completed(true);
|
|
39
|
+
} finally { clearTimeout(timer); }
|
|
40
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/** 视频输入按候选准备;短期地址和本地路径不落 Task。 */
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { GenerationInputError } from './generation-input.js';
|
|
6
|
+
import { parseModelRef } from './models.js';
|
|
7
|
+
import { prepareProviderInput } from './provider-adapter.js';
|
|
8
|
+
import { ProviderContractError } from './provider-contract.js';
|
|
9
|
+
|
|
10
|
+
export function videoTaskCandidates(candidates, normalized) {
|
|
11
|
+
const s2v = normalized.videoMode === 's2v';
|
|
12
|
+
const compatible = candidates.filter(candidate => /s2v/i.test(parseModelRef(candidate.model)?.modelId || '') === s2v);
|
|
13
|
+
if (!compatible.length) throw new GenerationInputError(s2v
|
|
14
|
+
? 'audio_path 只能配合 s2v 数字人模型' : 's2v 模型需要首帧和音频输入');
|
|
15
|
+
return compatible.map(candidate => ({ ...candidate,
|
|
16
|
+
...(normalized.firstFramePath ? { async prepareInput({ signal }) {
|
|
17
|
+
if (signal.aborted) throw new ProviderContractError('视频输入准备已取消', { stage: 'prepare', category: 'aborted', acceptance: 'not_accepted' });
|
|
18
|
+
if (s2v) {
|
|
19
|
+
const model = parseModelRef(candidate.model).modelId;
|
|
20
|
+
const image = await prepareProviderInput(candidate.adapter, { model, filePath: normalized.firstFramePath, signal });
|
|
21
|
+
if (signal.aborted) throw new ProviderContractError('视频输入准备已取消', { stage: 'upload', category: 'aborted', acceptance: 'not_accepted' });
|
|
22
|
+
const audio = await prepareProviderInput(candidate.adapter, { model, filePath: normalized.audioPath, signal });
|
|
23
|
+
return { ...normalized.providerInput, imgDataUrl: image.url, audioUrl: audio.url };
|
|
24
|
+
}
|
|
25
|
+
const mime = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.gif': 'image/gif' }[path.extname(normalized.firstFramePath).toLowerCase()];
|
|
26
|
+
if (!mime) throw new ProviderContractError('首帧格式不受支持', { stage: 'prepare', category: 'invalid_request', acceptance: 'not_accepted' });
|
|
27
|
+
return { ...normalized.providerInput, imgDataUrl: 'data:' + mime + ';base64,' + fs.readFileSync(normalized.firstFramePath).toString('base64') };
|
|
28
|
+
} } : {})
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/** 单图业务只消费显式字节和 Model Ports,不读取 Host/配置或保存产物。 */
|
|
3
|
+
import { createModelOperation } from './model-invoker.js';
|
|
4
|
+
import { ModelPortError, modelErrorRecord, modelPortSnapshot } from './model-port-contract.js';
|
|
5
|
+
|
|
6
|
+
export const VISION_BUDGET = Object.freeze({ timeoutMs: 120000, maxInputTextBytes: 32768,
|
|
7
|
+
maxOutputChars: 6000, maxImageBytes: 20 * 1024 * 1024 });
|
|
8
|
+
export const VISION_CANDIDATE_POLICY = Object.freeze({ skipUnavailable: true, allowRejected: true, allowEmptyResult: true });
|
|
9
|
+
|
|
10
|
+
export async function completeVision(ports, request, { signal, operation, budget = VISION_BUDGET } = {}) {
|
|
11
|
+
if (!Array.isArray(ports) || !ports.length) throw new ModelPortError('IRIS_MODEL_UNAVAILABLE');
|
|
12
|
+
const op = operation || createModelOperation({ signal, budget: { timeoutMs: budget.timeoutMs, maxInvocations: ports.length } });
|
|
13
|
+
const errors = [];
|
|
14
|
+
const observed = ports.map(port => {
|
|
15
|
+
let diagnosed = false;
|
|
16
|
+
return {
|
|
17
|
+
describe() {
|
|
18
|
+
const descriptor = modelPortSnapshot(port);
|
|
19
|
+
if (!diagnosed && descriptor.availability !== 'available') {
|
|
20
|
+
errors.push(modelErrorRecord(new ModelPortError(descriptor.reasonCode, { stage: 'prepare', backendId: descriptor.identity.backendId })));
|
|
21
|
+
diagnosed = true;
|
|
22
|
+
}
|
|
23
|
+
return descriptor;
|
|
24
|
+
},
|
|
25
|
+
async complete(input, options) {
|
|
26
|
+
try { return await port.complete(input, options); }
|
|
27
|
+
catch (error) {
|
|
28
|
+
errors.push({ ...modelErrorRecord(error), backendId: modelPortSnapshot(port).identity.backendId });
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
});
|
|
34
|
+
try {
|
|
35
|
+
const result = await op.runCandidates(observed, request, { budget }, VISION_CANDIDATE_POLICY);
|
|
36
|
+
return Object.freeze({ ...result, errors: Object.freeze([...errors]) });
|
|
37
|
+
} catch (error) {
|
|
38
|
+
// 受控记录供入口写健康反馈,不包含原始错误/图片/问题。
|
|
39
|
+
if (error instanceof ModelPortError) error.errors = Object.freeze([...errors]);
|
|
40
|
+
throw error;
|
|
41
|
+
} finally { if (!operation) op.dispose(); }
|
|
42
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/** 视觉发送副本;原始文件和 Core Artifact 始终只读。 */
|
|
3
|
+
import sharp from 'sharp';
|
|
4
|
+
import { ModelPortError } from './model-port-contract.js';
|
|
5
|
+
import { waitForModelWork } from './model-call-runtime.js';
|
|
6
|
+
|
|
7
|
+
// 客户端默认发送预算,不表示任何供应商承诺的输入上限。
|
|
8
|
+
export const DEFAULT_VISION_INPUT = Object.freeze({ maxBytes: 8 * 1024 * 1024 });
|
|
9
|
+
|
|
10
|
+
export function normalizeVisionInputLimits(value) {
|
|
11
|
+
if (value === null || value === undefined) return undefined;
|
|
12
|
+
if (typeof value !== 'object' || Array.isArray(value)
|
|
13
|
+
|| Object.keys(value).some(key => !['maxBytes', 'maxDimension'].includes(key))
|
|
14
|
+
|| !Object.values(value).every(number => Number.isSafeInteger(number) && number > 0)) {
|
|
15
|
+
throw Object.assign(new Error('visionInput 只接受正整数 maxBytes、maxDimension,null 恢复继承'), { code: 'IRIS_CONFIG_INPUT_INVALID' });
|
|
16
|
+
}
|
|
17
|
+
return { ...value };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function visionInputLimits(provider, modelId) {
|
|
21
|
+
const model = provider.models?.find(value => typeof value === 'object' && value?.id === modelId);
|
|
22
|
+
return { ...DEFAULT_VISION_INPUT, ...normalizeVisionInputLimits(provider.visionInput),
|
|
23
|
+
...normalizeVisionInputLimits(model?.visionInput) };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function processImage(processor, signal, output) {
|
|
27
|
+
const cancel = () => processor.destroy();
|
|
28
|
+
signal?.addEventListener('abort', cancel, { once: true });
|
|
29
|
+
try { return await waitForModelWork(() => output(processor), signal); }
|
|
30
|
+
finally { signal?.removeEventListener('abort', cancel); }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function prepareVisionImage(image, limits, { signal, orient = false } = {}) {
|
|
34
|
+
const { maxBytes, maxDimension } = { ...DEFAULT_VISION_INPUT, ...normalizeVisionInputLimits(limits) };
|
|
35
|
+
if (signal?.aborted) return waitForModelWork(() => image, signal);
|
|
36
|
+
if (image.bytes.byteLength <= maxBytes && !maxDimension && !orient) return image;
|
|
37
|
+
const tooLarge = () => new ModelPortError('IRIS_MODEL_IMAGE_TOO_LARGE', {
|
|
38
|
+
stage: 'prepare', imageBytes: image.bytes.byteLength, imageMaxBytes: maxBytes
|
|
39
|
+
});
|
|
40
|
+
const format = { 'image/png': 'png', 'image/jpeg': 'jpeg', 'image/webp': 'webp' }[image.mediaType];
|
|
41
|
+
let metadata;
|
|
42
|
+
try { metadata = await processImage(sharp(Buffer.from(image.bytes)), signal, processor => processor.metadata()); }
|
|
43
|
+
catch (error) {
|
|
44
|
+
if (error instanceof ModelPortError) throw error;
|
|
45
|
+
throw new ModelPortError('IRIS_MODEL_INPUT_INVALID', { stage: 'prepare' });
|
|
46
|
+
}
|
|
47
|
+
const sourceDimension = Math.max(metadata.width, metadata.height);
|
|
48
|
+
if (image.bytes.byteLength <= maxBytes && (!maxDimension || sourceDimension <= maxDimension)
|
|
49
|
+
&& (!orient || !metadata.orientation || metadata.orientation === 1)) return image;
|
|
50
|
+
// 合预算的 GIF/动画仍原字节透传,需要缩放时不静默压成一帧。
|
|
51
|
+
if (!format || metadata.pages > 1) throw tooLarge();
|
|
52
|
+
let dimension = Math.min(sourceDimension, maxDimension || sourceDimension);
|
|
53
|
+
let size = image.bytes.byteLength;
|
|
54
|
+
// 每次从原字节缩放,不反复压缩上一次副本;只进行本地处理,不重发模型请求。
|
|
55
|
+
for (let attempt = 0; attempt < 6; attempt++) {
|
|
56
|
+
if (size > maxBytes) dimension = Math.max(1, Math.min(dimension,
|
|
57
|
+
Math.floor((attempt === 0 ? sourceDimension : dimension) * Math.min(0.8, Math.sqrt(maxBytes / size) * 0.9))));
|
|
58
|
+
const processor = sharp(Buffer.from(image.bytes)).autoOrient().resize({ width: dimension, height: dimension,
|
|
59
|
+
fit: 'inside', withoutEnlargement: true }).toFormat(format);
|
|
60
|
+
const bytes = await processImage(processor, signal, value => value.toBuffer());
|
|
61
|
+
if (bytes.byteLength <= maxBytes) return { bytes: new Uint8Array(bytes), mediaType: image.mediaType };
|
|
62
|
+
size = bytes.byteLength;
|
|
63
|
+
if (dimension === 1) break;
|
|
64
|
+
}
|
|
65
|
+
throw tooLarge();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 每次调用的实际图片事实;仅包含尺寸/大小,不包含字节、路径或凭据。 */
|
|
69
|
+
export function createVisionInputObserver(inputs) {
|
|
70
|
+
return async ({ request, image, signal, identity }) => {
|
|
71
|
+
const [source, sent] = await waitForModelWork(() => Promise.all([
|
|
72
|
+
sharp(Buffer.from(request.image.bytes)).metadata(), sharp(Buffer.from(image.bytes)).metadata()
|
|
73
|
+
]), signal);
|
|
74
|
+
const record = {
|
|
75
|
+
source: { width: source.width, height: source.height, bytes: request.image.bytes.byteLength, mediaType: request.image.mediaType },
|
|
76
|
+
sent: { width: sent.width, height: sent.height, bytes: image.bytes.byteLength, mediaType: image.mediaType },
|
|
77
|
+
orientation: source.orientation || 1,
|
|
78
|
+
resized: (source.autoOrient?.width || source.width) !== sent.width || (source.autoOrient?.height || source.height) !== sent.height
|
|
79
|
+
};
|
|
80
|
+
inputs.set(identity.backendId, record);
|
|
81
|
+
return record;
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/** 单图来源由入口选择;Core 图片以原字节/MIME 只读进入视觉业务。 */
|
|
3
|
+
import { CommandError } from './command-service.js';
|
|
4
|
+
import { inspectCoreArtifact, readCoreArtifactBytes } from './core-artifacts.js';
|
|
5
|
+
import { MODEL_IMAGE_MEDIA_TYPES } from './model-port-contract.js';
|
|
6
|
+
import { VISION_BUDGET } from './vision-core.js';
|
|
7
|
+
import { waitForModelWork } from './model-call-runtime.js';
|
|
8
|
+
|
|
9
|
+
export function normalizeVisionImageSource(input, { allowAttachment = false } = {}) {
|
|
10
|
+
const fields = ['image_path', 'artifact_id', ...(allowAttachment ? ['attachment_id'] : [])];
|
|
11
|
+
const selected = fields.filter(field => input[field] !== undefined);
|
|
12
|
+
if (selected.length !== 1) throw new CommandError('IRIS_COMMAND_INPUT_INVALID',
|
|
13
|
+
'必须且只能提供 ' + fields.join('、') + ' 之一');
|
|
14
|
+
if (selected[0] === 'artifact_id'
|
|
15
|
+
&& (typeof input.artifact_id !== 'string' || !/^artifact_[a-f0-9]{24}$/.test(input.artifact_id))) {
|
|
16
|
+
throw new CommandError('IRIS_COMMAND_INPUT_INVALID', 'artifact_id 必须是有效的 Core Artifact ID');
|
|
17
|
+
}
|
|
18
|
+
return selected[0];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function readVisionArtifact(runtime, artifactId, signal) {
|
|
22
|
+
return waitForModelWork(() => runtime.run('inspect', ({ dataRoot }) => {
|
|
23
|
+
// 先检查大小/MIME,再物化字节;沿用图片输入的 20 MiB 上限。
|
|
24
|
+
const artifact = inspectCoreArtifact(dataRoot, artifactId);
|
|
25
|
+
if (!MODEL_IMAGE_MEDIA_TYPES.includes(artifact.mediaType) || artifact.size > VISION_BUDGET.maxImageBytes) {
|
|
26
|
+
throw new CommandError('IRIS_COMMAND_INPUT_INVALID', '视觉 Artifact 必须是最多 20 MiB 的 PNG、JPEG、WebP 或 GIF 图片');
|
|
27
|
+
}
|
|
28
|
+
const { bytes } = readCoreArtifactBytes(dataRoot, artifactId);
|
|
29
|
+
return { bytes: new Uint8Array(bytes), mediaType: artifact.mediaType };
|
|
30
|
+
}), signal);
|
|
31
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/** Host 边界:显式配置选型、字节解析和结果投影。 */
|
|
3
|
+
import { resolveModelRef, parseModelRef } from './models.js';
|
|
4
|
+
import { providerApiKey } from './provider-protocol.js';
|
|
5
|
+
import { createHttpVisionModelPort } from './http-vision-model-adapter.js';
|
|
6
|
+
import { prepareDshVisionModelPort } from './dsh-vision-model-adapter.js';
|
|
7
|
+
import { createModelOperation } from './model-invoker.js';
|
|
8
|
+
import { ModelPortError, normalizeModelDescriptor, normalizeModelCall, modelErrorRecord } from './model-port-contract.js';
|
|
9
|
+
import { waitForModelWork } from './model-call-runtime.js';
|
|
10
|
+
import { modelRateLimit, reportModelRateLimit } from './provider-health.js';
|
|
11
|
+
import { completeVision, VISION_BUDGET } from './vision-core.js';
|
|
12
|
+
import { open } from 'node:fs/promises';
|
|
13
|
+
import { DEFAULT_VISION_INPUT, visionInputLimits } from './vision-image-input.js';
|
|
14
|
+
|
|
15
|
+
const features = Object.freeze({ system: 'unknown', temperature: 'unknown', maxOutputTokens: 'unknown', reasoning: 'unknown' });
|
|
16
|
+
|
|
17
|
+
function unavailablePort(id, code = 'IRIS_MODEL_UNAVAILABLE') {
|
|
18
|
+
const descriptor = normalizeModelDescriptor({ contractVersion: 0, kind: 'vision',
|
|
19
|
+
identity: { origin: 'host', backendId: id }, availability: code === 'IRIS_MODEL_UNSUPPORTED' ? 'incompatible' : 'unavailable', reasonCode: code, features });
|
|
20
|
+
return Object.freeze({ describe: () => descriptor, async complete() { throw new ModelPortError(code, { stage: 'prepare', backendId: id }); } });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** 默认选择仅在轮到 Host 候选时解析;自持成功不会多查 Host 元数据。 */
|
|
24
|
+
function deferredDshPort(host, { prepareImages, orientImages, prepareImageRequest }) {
|
|
25
|
+
const ready = typeof host?.ports?.textModel?.stream === 'function'
|
|
26
|
+
&& typeof host?.ports?.textModel?.currentSelection === 'function'
|
|
27
|
+
&& typeof host?.ports?.textModel?.resolveModelInfo === 'function'
|
|
28
|
+
&& typeof host?.ports?.attachments?.saveImage === 'function' && typeof host?.ports?.attachments?.readImage === 'function';
|
|
29
|
+
const initial = normalizeModelDescriptor({ contractVersion: 0, kind: 'vision',
|
|
30
|
+
identity: { origin: 'host', backendId: 'dsh-vision:default' }, availability: ready ? 'available' : 'unavailable',
|
|
31
|
+
...(ready ? {} : { reasonCode: 'IRIS_MODEL_UNAVAILABLE' }), features });
|
|
32
|
+
// 保持请求前后的不透明身份一致,实际模型身份作为边界内投影事实返回。
|
|
33
|
+
let selected, bound;
|
|
34
|
+
return { port: Object.freeze({ describe: () => initial,
|
|
35
|
+
async complete(request, options) {
|
|
36
|
+
// 一轮复合操作的后续图片复用已解析路由,默认模型切换只影响下次操作。
|
|
37
|
+
const port = bound || await prepareDshVisionModelPort(host?.ports?.textModel, host?.ports?.attachments, {
|
|
38
|
+
signal: options.signal, allowImageNormalization: prepareImages, orientImages,
|
|
39
|
+
...(prepareImages ? { imageInput: DEFAULT_VISION_INPUT } : {}),
|
|
40
|
+
...(prepareImageRequest ? { prepareImageRequest: args => prepareImageRequest({ ...args, identity: initial.identity }) } : {}) });
|
|
41
|
+
bound = port;
|
|
42
|
+
const result = await port.complete(request, options);
|
|
43
|
+
selected = result.identity;
|
|
44
|
+
return { ...result, identity: initial.identity };
|
|
45
|
+
}
|
|
46
|
+
}), selected: () => selected };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 配置只绑定一个自持模型;CLI 不附加宿主候选。 */
|
|
50
|
+
export function createConfiguredVisionModelPort(provider, model, { allowRateLimited = false, onRateLimit, prepareImages = false,
|
|
51
|
+
orientImages = false, prepareImageRequest } = {}) {
|
|
52
|
+
const parsed = parseModelRef(resolveModelRef(provider, 'vision', model));
|
|
53
|
+
const id = 'iris-provider:' + encodeURIComponent(provider.id);
|
|
54
|
+
if (!parsed) return unavailablePort(id);
|
|
55
|
+
if (typeof provider.type === 'string' && provider.type.trim() && provider.type.trim() !== 'openai') {
|
|
56
|
+
return unavailablePort(id, 'IRIS_MODEL_UNSUPPORTED');
|
|
57
|
+
}
|
|
58
|
+
const port = createHttpVisionModelPort({ providerId: provider.id, modelId: parsed.modelId,
|
|
59
|
+
baseUrl: provider.baseUrl, apiKey: providerApiKey(provider),
|
|
60
|
+
orientImages, prepareImageRequest,
|
|
61
|
+
...(prepareImages ? { imageInput: visionInputLimits(provider, parsed.modelId) } : {}) });
|
|
62
|
+
return Object.freeze({ describe: port.describe,
|
|
63
|
+
async complete(request, options) {
|
|
64
|
+
if (!allowRateLimited && modelRateLimit(provider, parsed.modelId)) {
|
|
65
|
+
throw new ModelPortError('IRIS_MODEL_UNAVAILABLE', { stage: 'prepare', invocation: 'not_invoked' });
|
|
66
|
+
}
|
|
67
|
+
try { return await port.complete(request, options); }
|
|
68
|
+
catch (error) {
|
|
69
|
+
reportModelRateLimit(provider, parsed.modelId, { status: error.status, code: error.code,
|
|
70
|
+
providerCode: error.providerCode, retryAfterMs: error.retryAfterMs,
|
|
71
|
+
...(error.quotaExhausted ? { reason: 'free_quota' } : {}),
|
|
72
|
+
source: allowRateLimited ? 'probe' : 'task' }, onRateLimit, 'vision');
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function buildVisionModelCandidates(host, { providers = [], model, allowRateLimited = false, onRateLimit, prepareImages = false,
|
|
80
|
+
orientImages = false, prepareImageRequest } = {}) {
|
|
81
|
+
const candidates = [], seen = new Set();
|
|
82
|
+
for (const provider of providers.filter(Boolean)) {
|
|
83
|
+
const ref = resolveModelRef(provider, 'vision', model);
|
|
84
|
+
const parsed = parseModelRef(ref);
|
|
85
|
+
// 显式模型会把同账号的多份池记录解析为同一复合引用,只保留首次候选。
|
|
86
|
+
if (parsed && seen.has(ref)) continue;
|
|
87
|
+
if (parsed) seen.add(ref);
|
|
88
|
+
const port = createConfiguredVisionModelPort(provider, model, { allowRateLimited, onRateLimit, prepareImages, orientImages, prepareImageRequest });
|
|
89
|
+
const id = port.describe().identity.backendId;
|
|
90
|
+
candidates.push({ port, id, via: 'selfstack', backendId: provider.id, model: parsed?.modelId || '' });
|
|
91
|
+
}
|
|
92
|
+
const hostCandidate = deferredDshPort(host, { prepareImages, orientImages, prepareImageRequest });
|
|
93
|
+
candidates.push({ ...hostCandidate, id: 'dsh-vision:default', via: 'global', backendId: 'dsh-global', model: '宿主视觉模型' });
|
|
94
|
+
return candidates;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function visionImageFromDataUrl(value, maxBytes = VISION_BUDGET.maxImageBytes) {
|
|
98
|
+
if (typeof value !== 'string' || value.length > Math.ceil(maxBytes / 3) * 4 + 64) throw new ModelPortError('IRIS_MODEL_INPUT_INVALID');
|
|
99
|
+
const match = /^data:(image\/(?:png|jpeg|webp|gif));base64,([A-Za-z0-9+/]+={0,2})$/.exec(value);
|
|
100
|
+
if (!match || match[2].length % 4 !== 0) throw new ModelPortError('IRIS_MODEL_INPUT_INVALID');
|
|
101
|
+
const bytes = Buffer.from(match[2], 'base64');
|
|
102
|
+
if (!bytes.length || bytes.length > maxBytes || bytes.toString('base64') !== match[2]) throw new ModelPortError('IRIS_MODEL_INPUT_INVALID');
|
|
103
|
+
return { bytes: new Uint8Array(bytes), mediaType: match[1] };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** 打开一次并限额读取,避免用巨大文件分配无界内存;读取受操作取消控制。 */
|
|
107
|
+
export async function readVisionImageFile(file, mediaType, signal) {
|
|
108
|
+
return waitForModelWork(async () => {
|
|
109
|
+
const handle = await open(file, 'r');
|
|
110
|
+
try {
|
|
111
|
+
if (signal?.aborted) throw signal.reason;
|
|
112
|
+
const stat = await handle.stat();
|
|
113
|
+
if (!stat.isFile() || !stat.size || stat.size > VISION_BUDGET.maxImageBytes) throw new ModelPortError('IRIS_MODEL_INPUT_INVALID');
|
|
114
|
+
const bytes = new Uint8Array(stat.size);
|
|
115
|
+
let offset = 0;
|
|
116
|
+
while (offset < bytes.length) {
|
|
117
|
+
if (signal?.aborted) throw signal.reason;
|
|
118
|
+
const { bytesRead } = await handle.read(bytes, offset, Math.min(65536, bytes.length - offset), offset);
|
|
119
|
+
if (!bytesRead) throw new ModelPortError('IRIS_MODEL_INPUT_INVALID');
|
|
120
|
+
offset += bytesRead;
|
|
121
|
+
}
|
|
122
|
+
const extra = new Uint8Array(1);
|
|
123
|
+
if ((await handle.read(extra, 0, 1, offset)).bytesRead) throw new ModelPortError('IRIS_MODEL_INPUT_INVALID');
|
|
124
|
+
return { bytes, mediaType };
|
|
125
|
+
} finally { await handle.close(); }
|
|
126
|
+
}, signal);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function projectError(record, candidates) {
|
|
130
|
+
const candidate = candidates.find(value => value.id === record.backendId);
|
|
131
|
+
const category = { IRIS_MODEL_AUTH_FAILED: 'auth', IRIS_MODEL_RATE_LIMITED: record.status === 403 ? 'quota' : 'rate_limit',
|
|
132
|
+
IRIS_MODEL_TIMEOUT: 'timeout', IRIS_MODEL_ABORTED: 'aborted' }[record.code] || 'unknown';
|
|
133
|
+
return { ...record, kind: candidate?.via, backendId: candidate?.backendId || record.backendId,
|
|
134
|
+
model: candidate?.model || '', category };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** prepareImage 的信号覆盖文件/会话读取;函数开始时即建立整体预算。 */
|
|
138
|
+
export async function runVisionOperation(host, { providers, model, signal, budget = VISION_BUDGET, onRateLimit, prepareImages = false,
|
|
139
|
+
orientImages = false, prepareImageRequest }, consume) {
|
|
140
|
+
const candidates = buildVisionModelCandidates(host, { providers, model, onRateLimit, prepareImages, orientImages, prepareImageRequest });
|
|
141
|
+
const operation = createModelOperation({ signal, budget: { timeoutMs: budget.timeoutMs, maxInvocations: candidates.length } });
|
|
142
|
+
try {
|
|
143
|
+
const { completion: result, ...details } = await consume(candidates.map(candidate => candidate.port), operation);
|
|
144
|
+
const selected = candidates.find(candidate => candidate.id === result.identity.backendId);
|
|
145
|
+
return { ...details, answer: result.text.trim(), via: selected.via, backendId: selected.backendId,
|
|
146
|
+
model: selected.selected?.()?.modelId || selected.model, errors: result.errors.map(record => projectError(record, candidates)) };
|
|
147
|
+
} catch (error) {
|
|
148
|
+
if (error instanceof ModelPortError) {
|
|
149
|
+
error.errors = (error.errors || []).map(record => projectError(record, candidates));
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
throw new ModelPortError('IRIS_MODEL_REQUEST_FAILED', { stage: 'prepare', invocation: 'not_invoked' });
|
|
153
|
+
} finally { operation.dispose(); }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** 单图与复合业务共用同一候选、整体预算和结果投影。 */
|
|
157
|
+
export async function runVisionRequest(host, { providers, model, question, image, dataUrl, prepareImage, signal, budget = VISION_BUDGET, onRateLimit }) {
|
|
158
|
+
return runVisionOperation(host, { providers, model, signal, budget, onRateLimit, prepareImages: true }, async (ports, operation) => {
|
|
159
|
+
normalizeModelCall(ports[0].describe(), { prompt: question,
|
|
160
|
+
image: { bytes: new Uint8Array([0]), mediaType: 'image/png' } }, { budget });
|
|
161
|
+
const resolved = await waitForModelWork(() => prepareImage ? prepareImage(operation.signal)
|
|
162
|
+
: image || visionImageFromDataUrl(dataUrl, budget.maxImageBytes), operation.signal);
|
|
163
|
+
return { completion: await completeVision(ports, { prompt: question, image: resolved }, { operation, budget }) };
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** 付费确认在动作入口;红图实测只有一个供应商,不退到 Host 冒充其能力。 */
|
|
168
|
+
export async function probeVisionModel(provider, model, image, { signal, timeoutMs = 15000 } = {}) {
|
|
169
|
+
try {
|
|
170
|
+
const [candidate] = buildVisionModelCandidates(null, { providers: [provider], model, allowRateLimited: true });
|
|
171
|
+
const result = await completeVision([candidate.port], { prompt: '这张图片是什么颜色?请只回答颜色名称(中文)。', image },
|
|
172
|
+
{ signal, budget: { ...VISION_BUDGET, timeoutMs } });
|
|
173
|
+
return { ok: /红|red/i.test(result.text), answer: result.text };
|
|
174
|
+
} catch (error) {
|
|
175
|
+
const record = modelErrorRecord(error);
|
|
176
|
+
const projected = projectError(record, []);
|
|
177
|
+
return { ok: false, answer: '', error: record.message, code: record.code,
|
|
178
|
+
timedOut: record.code === 'IRIS_MODEL_TIMEOUT', category: projected.category, status: record.status };
|
|
179
|
+
}
|
|
180
|
+
}
|
package/lib/vision.js
CHANGED
|
@@ -118,11 +118,12 @@ export async function askWithBackends(backends, request) {
|
|
|
118
118
|
/* ---------------- 视觉能力测试("test it, don't guess it") ---------------- */
|
|
119
119
|
|
|
120
120
|
/**
|
|
121
|
-
* 固定红色测试图(
|
|
121
|
+
* 固定红色测试图(128x128 纯红 PNG,真实生成),避免过小图片被模型拒绝。
|
|
122
|
+
* 用于验证模型真的能看图,
|
|
122
123
|
* 而不是只看 HTTP 返回 200 就声称支持 vision。
|
|
123
124
|
*/
|
|
124
125
|
export const RED_TEST_IMAGE =
|
|
125
|
-
'data:image/png;base64,
|
|
126
|
+
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAIAAABMXPacAAAACXBIWXMAAAPoAAAD6AG1e1JrAAABnklEQVR4nO3UUQ0AAAiE0OtfGmPwgdsLwNQ5toc3hJ/+3Pv7BewXsPIP9Ato8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wtoOyBow7IT86bXAAAAAElFTkSuQmCC';
|
|
126
127
|
|
|
127
128
|
/** 期望的回答语义:必须提到红色(中文"红"或英文 red),才判定具备视觉能力 */
|
|
128
129
|
const RED_HINTS = /红|red/i;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/** Host 作品目录:两种既有存储保持原样,在展示边界统一过滤、排序与分页。 */
|
|
3
|
+
import * as legacy from './artifacts.js';
|
|
4
|
+
import { galleryArtifactsForDsh } from './dsh-core-adapter.js';
|
|
5
|
+
import { mimeOf } from './media.js';
|
|
6
|
+
|
|
7
|
+
export function normalizeGalleryQuery(input = {}) {
|
|
8
|
+
const query = { offset: 0, limit: 24, media_type: 'all', source: 'all', kind: 'all', ...input };
|
|
9
|
+
const invalid = () => { throw Object.assign(new Error('作品查询参数无效'), { code: 'IRIS_GALLERY_QUERY_INVALID', statusCode: 400 }); };
|
|
10
|
+
for (const key of Object.keys(query)) if (!['offset', 'limit', 'media_type', 'source', 'kind'].includes(key)) invalid();
|
|
11
|
+
if (!Number.isSafeInteger(query.offset) || query.offset < 0 || !Number.isSafeInteger(query.limit)
|
|
12
|
+
|| query.limit < 1 || query.limit > 60) invalid();
|
|
13
|
+
if (!['all', 'image', 'video', 'audio', 'text'].includes(query.media_type)
|
|
14
|
+
|| !['all', 'core', 'legacy'].includes(query.source)
|
|
15
|
+
|| typeof query.kind !== 'string' || !query.kind.trim() || query.kind.length > 256) invalid();
|
|
16
|
+
return query;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function workbenchArtifacts(input, options = {}) {
|
|
20
|
+
const query = normalizeGalleryQuery(input);
|
|
21
|
+
const items = [];
|
|
22
|
+
let droppedCore = 0, coreError = '';
|
|
23
|
+
if (query.source !== 'legacy') {
|
|
24
|
+
try {
|
|
25
|
+
const core = await galleryArtifactsForDsh(options);
|
|
26
|
+
droppedCore = core.dropped;
|
|
27
|
+
for (const artifact of core.artifacts) {
|
|
28
|
+
if (artifact.kind === 'host-input') continue;
|
|
29
|
+
items.push({ id: artifact.id, file: artifact.id, mime: artifact.mediaType,
|
|
30
|
+
size: artifact.size, createdAt: artifact.createdAt, source: 'core', kind: artifact.kind,
|
|
31
|
+
digest: artifact.digest.value, url: '/iris/api/core/artifact/' + artifact.id + '/media' });
|
|
32
|
+
}
|
|
33
|
+
} catch (error) {
|
|
34
|
+
if (options.signal?.aborted) throw error;
|
|
35
|
+
coreError = error.code || 'IRIS_GALLERY_CORE_UNAVAILABLE';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (query.source !== 'core') {
|
|
39
|
+
for (const item of legacy.all()) items.push({ id: item.id, file: item.file, mime: item.mime || mimeOf(item.file),
|
|
40
|
+
size: Number(item.size || 0), createdAt: item.createdAt || '', source: 'legacy', kind: 'legacy-output', url: legacy.artifactUrl(item) });
|
|
41
|
+
}
|
|
42
|
+
const candidates = items.filter(item => query.media_type === 'all'
|
|
43
|
+
|| (query.media_type === 'text' ? item.mime.startsWith('text/') || item.mime === 'application/json'
|
|
44
|
+
: item.mime.startsWith(query.media_type + '/')));
|
|
45
|
+
const kinds = [...new Set(candidates.map(item => item.kind))].sort();
|
|
46
|
+
const matches = candidates.filter(item => query.kind === 'all' || item.kind === query.kind)
|
|
47
|
+
.sort((a, b) => b.createdAt.localeCompare(a.createdAt) || (a.source + ':' + a.id).localeCompare(b.source + ':' + b.id));
|
|
48
|
+
const offset = matches.length ? Math.min(query.offset, Math.floor((matches.length - 1) / query.limit) * query.limit) : 0;
|
|
49
|
+
return { total: matches.length, offset, limit: query.limit, kinds,
|
|
50
|
+
counts: { core: matches.filter(item => item.source === 'core').length, legacy: matches.filter(item => item.source === 'legacy').length },
|
|
51
|
+
droppedCore, ...(coreError ? { coreError } : {}), items: matches.slice(offset, offset + query.limit) };
|
|
52
|
+
}
|