@mokuyoaxis/dsh-iris 0.2.0 → 0.2.2

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 (93) 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 +69 -2
  5. package/README.en.md +67 -31
  6. package/README.md +67 -31
  7. package/bin/dsh-iris.js +222 -21
  8. package/docs/AGENT_QUICKSTART.md +10 -6
  9. package/docs/ARCHITECTURE.md +34 -19
  10. package/docs/ARTIFACT_LIBRARY.md +5 -5
  11. package/docs/ARTIFACT_MANIFEST.md +1 -1
  12. package/docs/CLI_MANAGEMENT.md +183 -0
  13. package/docs/COMPOSITE_VISION.md +35 -0
  14. package/docs/CORE_RUNTIME_CONTRACT.md +16 -7
  15. package/docs/DSH_CORE_MIGRATION.md +48 -19
  16. package/docs/DSH_RC2_ADAPTATION.md +4 -2
  17. package/docs/FAULT_INJECTION.md +21 -2
  18. package/docs/HEADLESS_CLI.md +15 -7
  19. package/docs/HOST_ADAPTER_CONTRACT.md +10 -10
  20. package/docs/HOST_DOCTOR.md +3 -3
  21. package/docs/MODEL_PORT_CONTRACT.md +21 -14
  22. package/docs/OCR_MODEL.md +39 -0
  23. package/docs/PROMPT_OPTIMIZER.md +77 -0
  24. package/docs/PROVIDER_ADAPTER_CONTRACT.md +15 -5
  25. package/docs/PROVIDER_HEALTH.md +22 -5
  26. package/docs/PROVIDER_RUNTIME_HARNESS.md +1 -1
  27. package/docs/PROVIDER_SUBMISSION_CONTRACT.md +2 -2
  28. package/docs/ROADMAP.md +37 -19
  29. package/docs/SECURITY.md +5 -5
  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/file-access-across-environments.md +8 -5
  35. package/docs/releases/0.2.1.md +56 -0
  36. package/docs/releases/0.2.2.md +31 -0
  37. package/lib/actions.js +199 -286
  38. package/lib/adapters.js +216 -41
  39. package/lib/api.js +49 -7
  40. package/lib/capability.js +23 -16
  41. package/lib/chromium-browser.js +117 -0
  42. package/lib/client.js +933 -117
  43. package/lib/command-service.js +121 -7
  44. package/lib/composite-vision-routing.js +42 -0
  45. package/lib/config.js +102 -14
  46. package/lib/core-artifact-store.js +45 -13
  47. package/lib/core-lease-recovery.js +64 -92
  48. package/lib/core-maintenance.js +213 -0
  49. package/lib/core-runtime.js +45 -47
  50. package/lib/core-tasks.js +12 -5
  51. package/lib/core-user-projection.js +1 -0
  52. package/lib/core-writer-lease.js +134 -0
  53. package/lib/dsh-core-adapter.js +63 -9
  54. package/lib/dsh-text-model-adapter.js +222 -0
  55. package/lib/dsh-vision-model-adapter.js +75 -0
  56. package/lib/generation-input.js +36 -5
  57. package/lib/headless-vision.js +181 -0
  58. package/lib/host-runtime.js +1 -0
  59. package/lib/http-vision-model-adapter.js +187 -0
  60. package/lib/image-edit-input.js +34 -0
  61. package/lib/index.js +205 -175
  62. package/lib/locate.js +86 -28
  63. package/lib/media-probe.js +2 -1
  64. package/lib/model-call-runtime.js +45 -0
  65. package/lib/model-invoker.js +4 -2
  66. package/lib/model-port-contract.js +13 -5
  67. package/lib/models.js +29 -42
  68. package/lib/ocr-model-routing.js +26 -0
  69. package/lib/ocr.js +110 -64
  70. package/lib/prompt-optimizer-config.js +21 -10
  71. package/lib/prompt-optimizer-core.js +247 -0
  72. package/lib/prompt-optimizer.js +37 -193
  73. package/lib/provider-adapters.js +87 -10
  74. package/lib/provider-catalog.js +19 -7
  75. package/lib/provider-config-service.js +235 -0
  76. package/lib/provider-contract.js +3 -2
  77. package/lib/provider-health.js +80 -1
  78. package/lib/provider-protocol.js +84 -0
  79. package/lib/provider-task-runner.js +63 -19
  80. package/lib/summarize.js +31 -20
  81. package/lib/summary-input.js +52 -0
  82. package/lib/task-wait.js +40 -0
  83. package/lib/video-input.js +30 -0
  84. package/lib/vision-core.js +42 -0
  85. package/lib/vision-image-input.js +83 -0
  86. package/lib/vision-input.js +31 -0
  87. package/lib/vision-model-routing.js +180 -0
  88. package/lib/vision.js +3 -2
  89. package/lib/workbench-artifacts.js +52 -0
  90. package/lib/workbench-management.js +151 -0
  91. package/lib/workbench-zip.js +39 -0
  92. package/package.json +1 -1
  93. package/user_guide.md +74 -30
@@ -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
- * 固定红色测试图(1x1 纯红 PNG,真实生成)。用于验证模型真的能看图,
121
+ * 固定红色测试图(128x128 纯红 PNG,真实生成),避免过小图片被模型拒绝。
122
+ * 用于验证模型真的能看图,
122
123
  * 而不是只看 HTTP 返回 200 就声称支持 vision。
123
124
  */
124
125
  export const RED_TEST_IMAGE =
125
- 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC';
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
+ }
@@ -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
+ }
@@ -0,0 +1,39 @@
1
+ /** 作品下载专用 ZIP stored writer;无压缩/加密/ZIP64,不读取或解压外部归档。
2
+ * 布局依据 https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT §4.3。
3
+ * 调用方已限制 200 项/128 MiB,并生成安全的来源/ID 文件名。
4
+ */
5
+ const crcTable = Uint32Array.from({ length: 256 }, (_, value) => {
6
+ let crc = value;
7
+ for (let bit = 0; bit < 8; bit++) crc = crc & 1 ? 0xedb88320 ^ (crc >>> 1) : crc >>> 1;
8
+ return crc >>> 0;
9
+ });
10
+
11
+ function crc32(bytes) {
12
+ let crc = 0xffffffff;
13
+ for (const byte of bytes) crc = crcTable[(crc ^ byte) & 255] ^ (crc >>> 8);
14
+ return (crc ^ 0xffffffff) >>> 0;
15
+ }
16
+
17
+ export function workbenchZip(entries) {
18
+ const local = [], central = [];
19
+ let offset = 0;
20
+ for (const entry of entries) {
21
+ const name = Buffer.from(entry.name, 'utf8'), bytes = entry.bytes, crc = crc32(bytes);
22
+ const header = Buffer.alloc(30);
23
+ header.writeUInt32LE(0x04034b50, 0); header.writeUInt16LE(20, 4);
24
+ header.writeUInt16LE(0x800, 6); header.writeUInt16LE(33, 12); // UTF-8,1980-01-01,stored。
25
+ header.writeUInt32LE(crc, 14); header.writeUInt32LE(bytes.length, 18); header.writeUInt32LE(bytes.length, 22);
26
+ header.writeUInt16LE(name.length, 26);
27
+ const record = Buffer.alloc(46);
28
+ record.writeUInt32LE(0x02014b50, 0); record.writeUInt16LE(20, 4); record.writeUInt16LE(20, 6);
29
+ record.writeUInt16LE(0x800, 8); record.writeUInt16LE(33, 14);
30
+ record.writeUInt32LE(crc, 16); record.writeUInt32LE(bytes.length, 20); record.writeUInt32LE(bytes.length, 24);
31
+ record.writeUInt16LE(name.length, 28); record.writeUInt32LE(offset, 42);
32
+ local.push(header, name, bytes); central.push(record, name);
33
+ offset += header.length + name.length + bytes.length;
34
+ }
35
+ const centralSize = central.reduce((sum, bytes) => sum + bytes.length, 0), end = Buffer.alloc(22);
36
+ end.writeUInt32LE(0x06054b50, 0); end.writeUInt16LE(entries.length, 8); end.writeUInt16LE(entries.length, 10);
37
+ end.writeUInt32LE(centralSize, 12); end.writeUInt32LE(offset, 16);
38
+ return Buffer.concat([...local, ...central, end]);
39
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mokuyoaxis/dsh-iris",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Multi-provider image, video, and speech generation, transcription, visual understanding, and prompt optimization with an integrated Iris workbench for DeepSeek Harness.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",