@mokuyoaxis/dsh-iris 0.1.4 → 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.
Files changed (110) hide show
  1. package/.dsh/skills/iris-compose-media/SKILL.md +10 -5
  2. package/.dsh/skills/iris-compose-media/references/workflows.md +7 -1
  3. package/.dsh/skills/iris-verify-ui/SKILL.md +2 -1
  4. package/CHANGELOG.md +99 -1
  5. package/README.en.md +124 -31
  6. package/README.md +124 -31
  7. package/bin/dsh-iris.js +593 -13
  8. package/docs/AGENT_QUICKSTART.md +39 -0
  9. package/docs/ARCHITECTURE.md +87 -64
  10. package/docs/ARTIFACT_LIBRARY.md +5 -5
  11. package/docs/ARTIFACT_MANIFEST.md +60 -0
  12. package/docs/CLI_MANAGEMENT.md +181 -0
  13. package/docs/COMPOSITE_VISION.md +35 -0
  14. package/docs/CORE_RUNTIME_CONTRACT.md +84 -0
  15. package/docs/DSH_CORE_MIGRATION.md +135 -0
  16. package/docs/DSH_RC2_ADAPTATION.md +38 -0
  17. package/docs/FAULT_INJECTION.md +12 -2
  18. package/docs/HEADLESS_CLI.md +223 -0
  19. package/docs/HOST_ADAPTER_CONTRACT.md +36 -17
  20. package/docs/HOST_DOCTOR.md +4 -2
  21. package/docs/MODEL_PORT_CONTRACT.md +305 -0
  22. package/docs/OCR_MODEL.md +39 -0
  23. package/docs/PROMPT_OPTIMIZER.md +47 -0
  24. package/docs/PROVIDER_ADAPTER_CONTRACT.md +53 -10
  25. package/docs/PROVIDER_HEALTH.md +22 -5
  26. package/docs/PROVIDER_RUNTIME_HARNESS.md +33 -0
  27. package/docs/PROVIDER_SUBMISSION_CONTRACT.md +2 -2
  28. package/docs/ROADMAP.md +49 -83
  29. package/docs/SECURITY.md +8 -7
  30. package/docs/TASK_SEMANTICS.md +2 -2
  31. package/docs/VISION_CLI.md +132 -0
  32. package/docs/VISION_MODEL.md +51 -0
  33. package/docs/WORKBENCH_ARTIFACTS.md +67 -0
  34. package/docs/assets/logo/README.md +55 -0
  35. package/docs/assets/logo/iris-bubble-concept.svg +67 -0
  36. package/docs/assets/logo/iris-bubble.svg +14 -0
  37. package/docs/assets/logo/iris-interim-flower.png +0 -0
  38. package/docs/assets/logo/iris-logo-mono-inverse.svg +78 -0
  39. package/docs/assets/logo/iris-logo-mono.svg +78 -0
  40. package/docs/assets/logo/iris-logo.svg +78 -0
  41. package/docs/assets/logo/iris-mark-mono-inverse.svg +42 -0
  42. package/docs/assets/logo/iris-mark-mono.svg +42 -0
  43. package/docs/assets/logo/iris-mark.svg +42 -0
  44. package/docs/file-access-across-environments.md +8 -5
  45. package/docs/releases/0.2.0.md +48 -0
  46. package/docs/releases/0.2.1.md +56 -0
  47. package/lib/actions.js +520 -591
  48. package/lib/adapters.js +184 -36
  49. package/lib/api.js +200 -11
  50. package/lib/chromium-browser.js +117 -0
  51. package/lib/client.js +1390 -105
  52. package/lib/command-service.js +534 -0
  53. package/lib/composite-vision-routing.js +42 -0
  54. package/lib/config.js +143 -51
  55. package/lib/core-artifact-store.js +630 -0
  56. package/lib/core-artifacts.js +4 -0
  57. package/lib/core-attention.js +103 -0
  58. package/lib/core-contract.js +105 -0
  59. package/lib/core-doctor.js +41 -0
  60. package/lib/core-lease-recovery.js +170 -0
  61. package/lib/core-maintenance.js +213 -0
  62. package/lib/core-runtime.js +232 -0
  63. package/lib/core-tasks.js +567 -0
  64. package/lib/core-user-projection.js +193 -0
  65. package/lib/doctor.js +92 -33
  66. package/lib/dsh-core-adapter.js +653 -0
  67. package/lib/dsh-host-adapter.js +58 -7
  68. package/lib/dsh-text-model-adapter.js +222 -0
  69. package/lib/dsh-vision-model-adapter.js +75 -0
  70. package/lib/generation-input.js +126 -0
  71. package/lib/guard.js +3 -3
  72. package/lib/headless-vision.js +181 -0
  73. package/lib/host-runtime.js +1 -0
  74. package/lib/http-vision-model-adapter.js +187 -0
  75. package/lib/image-edit-input.js +34 -0
  76. package/lib/index.js +323 -188
  77. package/lib/locate.js +86 -28
  78. package/lib/media-probe.js +2 -1
  79. package/lib/model-call-runtime.js +45 -0
  80. package/lib/model-invoker.js +220 -0
  81. package/lib/model-port-contract.js +341 -0
  82. package/lib/models.js +105 -14
  83. package/lib/ocr-model-routing.js +26 -0
  84. package/lib/ocr.js +110 -64
  85. package/lib/prompt-optimizer-config.js +2 -1
  86. package/lib/prompt-optimizer-core.js +121 -0
  87. package/lib/prompt-optimizer.js +37 -193
  88. package/lib/provider-adapter.js +49 -23
  89. package/lib/provider-adapters.js +165 -26
  90. package/lib/provider-catalog.js +183 -0
  91. package/lib/provider-config-service.js +236 -0
  92. package/lib/provider-contract.js +74 -4
  93. package/lib/provider-health.js +80 -1
  94. package/lib/provider-protocol.js +74 -2
  95. package/lib/provider-task-runner.js +370 -0
  96. package/lib/summarize.js +31 -20
  97. package/lib/summary-input.js +52 -0
  98. package/lib/task-wait.js +40 -0
  99. package/lib/tasks.js +16 -4
  100. package/lib/video-input.js +30 -0
  101. package/lib/vision-core.js +42 -0
  102. package/lib/vision-image-input.js +83 -0
  103. package/lib/vision-input.js +31 -0
  104. package/lib/vision-model-routing.js +180 -0
  105. package/lib/vision.js +31 -9
  106. package/lib/workbench-artifacts.js +52 -0
  107. package/lib/workbench-management.js +151 -0
  108. package/lib/workbench-zip.js +39 -0
  109. package/package.json +8 -8
  110. package/user_guide.md +71 -26
@@ -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
@@ -1,4 +1,5 @@
1
1
  'use strict';
2
+ import { providerApiKey, unsupportedProtocolError } from './provider-protocol.js';
2
3
  /**
3
4
  * Iris 视觉后端抽象(阶段 1)。
4
5
  *
@@ -12,12 +13,14 @@
12
13
  */
13
14
  import * as adapters from './adapters.js';
14
15
  import { hasHostPort } from './host-contract.js';
16
+ import { resolveModelRef, parseModelRef } from './models.js';
15
17
 
16
18
  /** 后端接口约定:id / kind / model 只读,analyze(request) → Promise<string> */
17
19
 
18
20
  export class SelfStackVisionBackend {
19
- constructor({ provider }) {
21
+ constructor({ provider, model }) {
20
22
  this.provider = provider;
23
+ this.requestedModel = model;
21
24
  }
22
25
  get id() {
23
26
  return this.provider.id;
@@ -26,13 +29,18 @@ export class SelfStackVisionBackend {
26
29
  return 'selfstack';
27
30
  }
28
31
  get model() {
29
- return this.provider.visionModel || 'qwen-vl-plus';
32
+ const ref = resolveModelRef(this.provider, 'vision', this.requestedModel);
33
+ return parseModelRef(ref)?.modelId || '';
30
34
  }
31
35
  async analyze({ question, imageDataUrl, signal }) {
36
+ const model = this.model;
37
+ if (!model) throw Object.assign(new Error('iris: 未配置视觉模型,请发现或手工添加并标注 vision 能力'), {
38
+ code: 'IRIS_PROVIDER_MODEL_UNAVAILABLE'
39
+ });
32
40
  return adapters.visionStream({
33
- key: this.provider.apiKey,
41
+ key: providerApiKey(this.provider),
34
42
  baseUrl: this.provider.baseUrl,
35
- model: this.model,
43
+ model,
36
44
  prompt: question,
37
45
  imageDataUrl,
38
46
  signal
@@ -48,13 +56,24 @@ export class HostVisionBackend {
48
56
  async analyze(request) { return this.port.analyze(request); }
49
57
  }
50
58
 
59
+ const VISION_FACTORIES = new Map([
60
+ ['openai', (provider, model) => new SelfStackVisionBackend({ provider, model })]
61
+ ]);
62
+
51
63
  /** Host Adapter 入口;Command 不读取 DSH ctx。 */
52
64
  export function buildVisionBackendsFromHost(host, { providers, model }) {
53
65
  const backends = [];
54
66
  for (const p of providers || []) {
55
- if (p && p.type === 'openai') {
56
- const provider = model ? { ...p, visionModel: model } : p;
57
- backends.push(new SelfStackVisionBackend({ provider }));
67
+ if (p) {
68
+ const type = typeof p.type === 'string' && p.type.trim() ? p.type.trim() : 'openai';
69
+ const factory = VISION_FACTORIES.get(type);
70
+ if (!factory) {
71
+ // 保留不可用证据,让宿主视觉端口仍能按既有顺序降级。
72
+ backends.push({
73
+ id: p.id, kind: 'selfstack', model: String(model || p.visionModel || ''),
74
+ async analyze() { throw unsupportedProtocolError(type, 'vision'); }
75
+ });
76
+ } else backends.push(factory(p, model));
58
77
  }
59
78
  }
60
79
  if (hasHostPort(host, 'visionModel') && typeof host.ports.visionModel.analyze === 'function') {
@@ -83,6 +102,7 @@ export async function askWithBackends(backends, request) {
83
102
  backendId: b.id,
84
103
  kind: b.kind,
85
104
  model: b.model,
105
+ code: err && err.code,
86
106
  category: err && err.category,
87
107
  status: err && err.status,
88
108
  message: String((err && err.message) || err)
@@ -90,6 +110,7 @@ export async function askWithBackends(backends, request) {
90
110
  }
91
111
  }
92
112
  const err = new Error('iris: 视觉模型不可用——自持栈与全局视觉模型都失败了');
113
+ if (errors.length && errors[0].code && errors.every((error) => error.code === errors[0].code)) err.code = errors[0].code;
93
114
  err.errors = errors;
94
115
  throw err;
95
116
  }
@@ -97,11 +118,12 @@ export async function askWithBackends(backends, request) {
97
118
  /* ---------------- 视觉能力测试("test it, don't guess it") ---------------- */
98
119
 
99
120
  /**
100
- * 固定红色测试图(1x1 纯红 PNG,真实生成)。用于验证模型真的能看图,
121
+ * 固定红色测试图(128x128 纯红 PNG,真实生成),避免过小图片被模型拒绝。
122
+ * 用于验证模型真的能看图,
101
123
  * 而不是只看 HTTP 返回 200 就声称支持 vision。
102
124
  */
103
125
  export const RED_TEST_IMAGE =
104
- 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC';
126
+ 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAIAAABMXPacAAAACXBIWXMAAAPoAAAD6AG1e1JrAAABnklEQVR4nO3UUQ0AAAiE0OtfGmPwgdsLwNQ5toc3hJ/+3Pv7BewXsPIP9Ato8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wto8wtoOyBow7IT86bXAAAAAElFTkSuQmCC';
105
127
 
106
128
  /** 期望的回答语义:必须提到红色(中文"红"或英文 red),才判定具备视觉能力 */
107
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
+ }
@@ -0,0 +1,151 @@
1
+ /** 工作台显式选择/详情/下载和可恢复管理;不修改既有 Core 维护语义。 */
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import crypto from 'node:crypto';
5
+ import { setImmediate } from 'node:timers/promises';
6
+ import * as legacy from './artifacts.js';
7
+ import { mimeOf } from './media.js';
8
+ import { inspectWorkbenchArtifactForDsh, inspectProviderTaskForDsh, maintainWorkbenchForDsh, readCoreArtifactMediaForDsh } from './dsh-core-adapter.js';
9
+ import { workbenchZip } from './workbench-zip.js';
10
+
11
+ export const MAX_WORKBENCH_SELECTION = 200;
12
+ export const MAX_WORKBENCH_DOWNLOAD_BYTES = 128 * 1024 * 1024;
13
+ const CORE_ID = /^artifact_[a-f0-9]{24}$/, LEGACY_ID = /^a_[a-f0-9]{24}$/, TASK_ID = /^task_[a-f0-9]{24}$/;
14
+ const EXTENSIONS = { 'image/png': '.png', 'image/jpeg': '.jpg', 'image/webp': '.webp', 'image/gif': '.gif',
15
+ 'video/mp4': '.mp4', 'video/webm': '.webm', 'audio/wav': '.wav', 'audio/mpeg': '.mp3', 'audio/ogg': '.ogg',
16
+ 'audio/flac': '.flac', 'text/plain': '.txt', 'application/json': '.json' };
17
+ const fail = (code, message, statusCode = 400) => { throw Object.assign(new Error(message), { code, statusCode }); };
18
+ function active(signal) { if (signal?.aborted) throw Object.assign(new Error('作品操作已取消'), { name: 'AbortError' }); }
19
+ function exact(input, keys) {
20
+ if (!input || typeof input !== 'object' || Array.isArray(input) || Object.keys(input).some(key => !keys.includes(key))) {
21
+ fail('IRIS_COMMAND_INPUT_INVALID', '作品操作参数无效');
22
+ }
23
+ }
24
+ function validItem(item) {
25
+ exact(item, ['source', 'id']);
26
+ if (!(item.source === 'core' ? CORE_ID.test(item.id) : item.source === 'legacy' && LEGACY_ID.test(item.id))) {
27
+ fail('IRIS_COMMAND_INPUT_INVALID', '作品来源或 ID 无效');
28
+ }
29
+ return item;
30
+ }
31
+ function selection(input) {
32
+ exact(input, ['items']);
33
+ if (!Array.isArray(input.items) || !input.items.length || input.items.length > MAX_WORKBENCH_SELECTION) {
34
+ fail('IRIS_COMMAND_INPUT_INVALID', '请明确选择 1–200 个作品');
35
+ }
36
+ const items = input.items.map(validItem);
37
+ if (new Set(items.map(item => item.source + ':' + item.id)).size !== items.length) fail('IRIS_COMMAND_INPUT_INVALID', '选中作品不能重复');
38
+ return items;
39
+ }
40
+
41
+ function legacyFile(id) {
42
+ const entry = legacy.all().find(item => item.id === id);
43
+ const hit = entry && legacy.authorize(entry.id, entry.token, entry.file);
44
+ if (!hit) fail('IRIS_WORK_NOT_FOUND', '作品文件不存在或不可读取', 404);
45
+ return { entry, ...hit };
46
+ }
47
+ function safeMetadata(metadata) {
48
+ const result = {};
49
+ for (const key of ['width', 'height', 'durationMs', 'frameCount', 'frameIndex', 'sourceFrameIndex', 'timeMs']) {
50
+ if (typeof metadata[key] === 'number' && Number.isFinite(metadata[key])) result[key] = metadata[key];
51
+ }
52
+ if (TASK_ID.test(metadata.taskId)) result.taskId = metadata.taskId;
53
+ return result;
54
+ }
55
+
56
+ export async function workbenchDetail(source, id, options = {}) {
57
+ validItem({ source, id }); active(options.signal);
58
+ if (source === 'core') {
59
+ const artifact = await inspectWorkbenchArtifactForDsh(id, options);
60
+ return { item: { id, source, file: id + EXTENSIONS[artifact.mediaType], mime: artifact.mediaType,
61
+ kind: artifact.kind, size: artifact.size, createdAt: artifact.createdAt, digest: artifact.digest.value,
62
+ relations: artifact.relations, metadata: safeMetadata(artifact.metadata), integrity: 'verified',
63
+ url: '/iris/api/core/artifact/' + id + '/media' } };
64
+ }
65
+ const hit = legacyFile(id), entry = hit.entry;
66
+ return { item: { id, source, file: entry.file, mime: entry.mime || mimeOf(entry.file), size: hit.size,
67
+ kind: 'legacy-output', createdAt: entry.createdAt || '', integrity: 'file-exists', url: legacy.artifactUrl(entry) } };
68
+ }
69
+
70
+ export async function downloadWorkbenchSelection(input, options = {}) {
71
+ const items = selection(input), details = [];
72
+ let bytes = 0;
73
+ for (const item of items) {
74
+ active(options.signal);
75
+ const detail = (await workbenchDetail(item.source, item.id, options)).item;
76
+ bytes += detail.size;
77
+ if (bytes > MAX_WORKBENCH_DOWNLOAD_BYTES) fail('IRIS_WORK_DOWNLOAD_TOO_LARGE', '单次下载最多 128 MiB,请减少选中作品', 413);
78
+ details.push(detail);
79
+ await setImmediate();
80
+ }
81
+ const entries = [], manifest = [];
82
+ bytes = 0;
83
+ for (const detail of details) {
84
+ active(options.signal);
85
+ const data = detail.source === 'core' ? (await readCoreArtifactMediaForDsh(detail.id, options)).bytes : fs.readFileSync(legacyFile(detail.id).abs);
86
+ const digest = crypto.createHash('sha256').update(data).digest('hex');
87
+ if (detail.source === 'core' && digest !== detail.digest) fail('IRIS_ARTIFACT_DIGEST_MISMATCH', '选中作品内容已改变', 409);
88
+ bytes += data.length;
89
+ if (bytes > MAX_WORKBENCH_DOWNLOAD_BYTES) fail('IRIS_WORK_DOWNLOAD_TOO_LARGE', '单次下载最多 128 MiB,请减少选中作品', 413);
90
+ const name = detail.source + '/' + detail.id + path.extname(detail.file).toLowerCase();
91
+ entries.push({ name, bytes: data });
92
+ manifest.push({ source: detail.source, id: detail.id, file: detail.file, archiveFile: name, mime: detail.mime,
93
+ size: data.length, createdAt: detail.createdAt, digest });
94
+ await setImmediate();
95
+ }
96
+ active(options.signal);
97
+ return workbenchZip([{ name: 'works.json', bytes: Buffer.from(JSON.stringify({ schemaVersion: 0, items: manifest }, null, 2) + '\n') }, ...entries]);
98
+ }
99
+
100
+ export async function deleteWorkbenchArtifacts(input, options = {}) {
101
+ exact(input, ['artifact_ids', 'task_ids', 'confirm_delete']);
102
+ const chosen = selection({ items: Array.isArray(input.artifact_ids) ? input.artifact_ids.map(id => ({ source: 'core', id })) : null });
103
+ if (input.confirm_delete !== undefined && typeof input.confirm_delete !== 'boolean') fail('IRIS_COMMAND_INPUT_INVALID', 'confirm_delete 必须是布尔值');
104
+ const taskIds = input.task_ids ?? [];
105
+ if (!Array.isArray(taskIds) || taskIds.length > 200 || !taskIds.every(id => typeof id === 'string' && TASK_ID.test(id))
106
+ || new Set(taskIds).size !== taskIds.length) fail('IRIS_COMMAND_INPUT_INVALID', '关联任务 ID 无效');
107
+ active(options.signal);
108
+ const artifactIds = chosen.map(item => item.id);
109
+ const base = await maintainWorkbenchForDsh('core.delete', { artifact_ids: artifactIds }, options);
110
+ const referencing = [...new Set(base.blockers.filter(blocker => ['task_artifact_reference', 'task_input_reference'].includes(blocker.reason)).map(blocker => blocker.referencedBy))];
111
+ if (taskIds.some(id => !referencing.includes(id))) fail('IRIS_COMMAND_INPUT_INVALID', '只能显式选择这些作品直接关联的任务');
112
+ if (input.confirm_delete === true) return { ok: true, ...await maintainWorkbenchForDsh('core.delete', {
113
+ artifact_ids: artifactIds, task_ids: taskIds, confirm_delete: true }, options) };
114
+ const plan = taskIds.length ? await maintainWorkbenchForDsh('core.delete', { artifact_ids: artifactIds, task_ids: taskIds }, options) : base;
115
+ const tasks = [];
116
+ for (const id of referencing) {
117
+ const task = await inspectProviderTaskForDsh(id, options);
118
+ tasks.push({ id, settled: task.phase === 'terminal' && task.acceptance !== 'unknown' && task.outcome !== 'unknown' });
119
+ }
120
+ return { ok: true, preview: true, allowed: plan.allowed, artifactIds, taskIds, files: plan.entries.length,
121
+ bytes: plan.bytes, blockers: plan.blockers, referencingTasks: tasks };
122
+ }
123
+
124
+ export async function workbenchTransactions(options = {}) {
125
+ return { ok: true, ...await maintainWorkbenchForDsh('core.transactions', {}, options) };
126
+ }
127
+
128
+ export async function restoreWorkbenchTransaction(input, options = {}) {
129
+ exact(input, ['transaction_id', 'confirm_restore']);
130
+ if (input.confirm_restore !== true) fail('IRIS_WORK_RESTORE_CONFIRM_REQUIRED', '请明确确认恢复此隔离事务');
131
+ if (typeof input.transaction_id !== 'string' || !/^delete_[a-f0-9]{24}$/.test(input.transaction_id)) fail('IRIS_COMMAND_INPUT_INVALID', '隔离事务 ID 无效');
132
+ active(options.signal);
133
+ return { ok: true, ...await maintainWorkbenchForDsh('core.restore', { transaction_id: input.transaction_id }, options) };
134
+ }
135
+
136
+ /** API 边界只返回固定提示和稳定码,避免底层 ENOENT/JSON 错误带出文件路径。 */
137
+ export function workbenchError(error) {
138
+ if (error.name === 'AbortError') return error;
139
+ const code = String(error.code || 'IRIS_WORK_FAILED');
140
+ const messages = { IRIS_CORE_DELETE_BLOCKED: '作品仍被引用或任务尚未结束,请重新查看删除预览',
141
+ IRIS_CORE_MAINTENANCE_FAILED: '隔离操作未完成,恢复清单已保留,请查看 Core 回收区',
142
+ IRIS_CORE_RESTORE_CONFLICT: '恢复目标已存在,未覆盖已有作品', IRIS_CORE_RESTORE_INVALID: '此事务当前不可恢复',
143
+ IRIS_CORE_DATA_ROOT_BUSY: 'Core 正在被其他进程写入,请稍后重试',
144
+ IRIS_ARTIFACT_DIGEST_MISMATCH: '作品内容哈希核验失败', IRIS_WORK_DOWNLOAD_TOO_LARGE: '单次下载最多 128 MiB,请减少选中作品',
145
+ IRIS_WORK_RESTORE_CONFIRM_REQUIRED: '请明确确认恢复此隔离事务', IRIS_COMMAND_INPUT_INVALID: '作品操作参数无效' };
146
+ const missing = code === 'ENOENT' || /NOT_FOUND/.test(code);
147
+ return Object.assign(new Error(messages[code] || (missing ? '作品或隔离事务不存在' : '作品操作失败,请刷新后重试')), {
148
+ code, statusCode: error.statusCode || (missing ? 404 : code === 'IRIS_COMMAND_INPUT_INVALID' || code === 'IRIS_WORK_RESTORE_CONFIRM_REQUIRED' ? 400 : 409),
149
+ ...(error.transactionId ? { transactionId: error.transactionId } : {})
150
+ });
151
+ }