@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,370 @@
1
+ 'use strict';
2
+
3
+ import crypto from 'node:crypto';
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import sharp from 'sharp';
7
+ import { createCoreArtifact } from './core-artifacts.js';
8
+ import {
9
+ activateCoreWatch,
10
+ beginCoreAttempt,
11
+ beginCoreDelivery,
12
+ completeCoreDelivery,
13
+ createCoreTask,
14
+ failCoreDelivery,
15
+ finalizeCoreNoAcceptance,
16
+ inspectCoreTask,
17
+ prepareCoreRedelivery,
18
+ recordCoreAttemptResult,
19
+ recordCoreCancelResult,
20
+ recordCoreDownloadCompleted,
21
+ recordCorePollResult,
22
+ recordCoreWatchError,
23
+ recoverCoreTask,
24
+ requestCoreCancel
25
+ } from './core-tasks.js';
26
+ import { ensurePrivateDir } from './private-storage.js';
27
+ import { parseModelRef } from './models.js';
28
+ import {
29
+ hasProviderOperation,
30
+ invokeProviderOperation
31
+ } from './provider-adapter.js';
32
+ import { ProviderContractError, providerErrorRecord, submitWithAcceptanceBoundary } from './provider-contract.js';
33
+ import { imageEditCandidates, readImageEditArtifact } from './image-edit-input.js';
34
+
35
+ export const PROVIDER_TASK_RUNNER_VERSION = 0;
36
+
37
+ /**
38
+ * 按 capability 冻结的交付 Profile(E 阶段视频迁移):媒体类型白名单、Artifact
39
+ * kind 与默认 metadata。新增 capability 必须显式登记;未登记的 capability 直接
40
+ * 失败,不会按图片语义猜。
41
+ */
42
+ export const DELIVERY_PROFILES = Object.freeze({
43
+ image: Object.freeze({ kind: 'generated-image', mediaTypes: Object.freeze(['image/png', 'image/jpeg', 'image/webp']), defaultMediaType: 'image/png' }),
44
+ video: Object.freeze({ kind: 'generated-video', mediaTypes: Object.freeze(['video/mp4']), defaultMediaType: 'video/mp4' }),
45
+ // TTS 同步完成型:submit 内 completed → 同一次调用直接交付(无 remote 长轮询)。
46
+ tts: Object.freeze({ kind: 'generated-audio', mediaTypes: Object.freeze(['audio/mpeg', 'audio/wav']), defaultMediaType: 'audio/mpeg' }),
47
+ // 转写上传型异步:受理 + remoteTaskId → 长轮询 → 文本物化为 UTF-8 Artifact。
48
+ transcribe: Object.freeze({ kind: 'transcript', mediaTypes: Object.freeze(['text/plain']), defaultMediaType: 'text/plain' })
49
+ });
50
+ const runtimeTaskGates = new WeakMap();
51
+
52
+ export class ProviderTaskRunnerError extends Error {
53
+ constructor(code, message) {
54
+ super(message);
55
+ this.name = 'ProviderTaskRunnerError';
56
+ this.code = code;
57
+ }
58
+ }
59
+
60
+ function checkRuntime(runtime) {
61
+ if (!runtime || typeof runtime.run !== 'function') {
62
+ throw new TypeError('Provider Task Runner 需要已创建的 Core Runtime');
63
+ }
64
+ }
65
+
66
+ function checkAdapterForTask(task, adapter) {
67
+ if (!adapter || adapter.id !== task.providerId) {
68
+ throw new ProviderTaskRunnerError('IRIS_PROVIDER_TASK_IDENTITY_MISMATCH', 'Provider Adapter 与 Task 已持久化身份不一致');
69
+ }
70
+ return adapter;
71
+ }
72
+
73
+ function normalizedCandidates(capability, candidates) {
74
+ if (!Array.isArray(candidates) || !candidates.length) throw new TypeError('至少需要一个 Provider 候选');
75
+ return candidates.map((candidate, index) => {
76
+ const adapter = candidate?.adapter;
77
+ const model = String(candidate?.model || '').trim();
78
+ const parsed = parseModelRef(model);
79
+ const selectionReason = candidate?.selectionReason;
80
+ if (!adapter || !adapter.capabilities?.includes(capability) || !parsed
81
+ || parsed.providerId !== adapter.id || !parsed.modelId
82
+ || (candidate.providerBinding !== undefined && !/^sha256:[a-f0-9]{64}$/.test(candidate.providerBinding))
83
+ || (selectionReason !== undefined && !['explicit', 'assignment', 'pool'].includes(selectionReason))) {
84
+ throw new TypeError('Provider 候选 ' + (index + 1) + ' 不支持 Task capability 或模型复合身份无效');
85
+ }
86
+ if (candidate.prepareInput !== undefined && typeof candidate.prepareInput !== 'function') throw new TypeError('候选 prepareInput 必须是函数');
87
+ return { adapter, model, providerModel: parsed.modelId, providerBinding: candidate.providerBinding, selectionReason, prepareInput: candidate.prepareInput };
88
+ });
89
+ }
90
+
91
+ function stagingFile(dataRoot) {
92
+ const directory = path.join(dataRoot, 'provider-staging', 'v0');
93
+ ensurePrivateDir(directory);
94
+ return path.join(directory, 'download-' + process.pid + '-' + crypto.randomBytes(8).toString('hex') + '.part');
95
+ }
96
+
97
+ async function generatedImageMediaType(bytes) {
98
+ let format;
99
+ try { format = (await sharp(bytes).metadata()).format; }
100
+ catch (_) {
101
+ throw new ProviderContractError('供应商产物不是可识别的图片', {
102
+ stage: 'download', category: 'protocol', acceptance: 'accepted'
103
+ });
104
+ }
105
+ const mediaType = { png: 'image/png', jpeg: 'image/jpeg', webp: 'image/webp' }[format];
106
+ if (!mediaType) throw new ProviderContractError('供应商图片格式仅支持 PNG、JPEG、WebP', {
107
+ stage: 'download', category: 'protocol', acceptance: 'accepted'
108
+ });
109
+ return mediaType;
110
+ }
111
+
112
+ async function deliverArtifacts(dataRoot, taskId, adapter, remoteArtifacts, signal) {
113
+ beginCoreDelivery(dataRoot, taskId);
114
+ const artifactIds = [];
115
+ const stagedFiles = [];
116
+ try {
117
+ if (!Array.isArray(remoteArtifacts) || remoteArtifacts.length === 0) {
118
+ throw Object.assign(new Error('Provider Task Runner 没有收到可物化产物'), {
119
+ stage: 'download', category: 'protocol', acceptance: 'accepted'
120
+ });
121
+ }
122
+ const task = inspectCoreTask(dataRoot, taskId);
123
+ const profile = DELIVERY_PROFILES[task.capability];
124
+ if (!profile) {
125
+ throw Object.assign(new Error('Provider Task Runner 没有该 capability 的交付 Profile:' + task.capability), {
126
+ stage: 'download', category: 'protocol', acceptance: 'accepted'
127
+ });
128
+ }
129
+ for (const remote of remoteArtifacts) {
130
+ const mediaType = String(remote.mediaType || profile.defaultMediaType);
131
+ if (task.capability !== 'image' && !profile.mediaTypes.includes(mediaType)) {
132
+ throw Object.assign(new Error('Provider Task Runner 交付媒体类型不在 Profile 白名单:' + mediaType), {
133
+ stage: 'download', category: 'protocol', acceptance: 'accepted'
134
+ });
135
+ }
136
+ const target = stagingFile(dataRoot);
137
+ stagedFiles.push({ target, mediaType });
138
+ const downloaded = await invokeProviderOperation(adapter, 'download', {
139
+ capability: task.capability,
140
+ artifact: remote,
141
+ targetPath: target,
142
+ signal
143
+ }, { signal, taskId });
144
+ const stat = fs.lstatSync(target);
145
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size !== downloaded.bytes) {
146
+ throw Object.assign(new Error('Provider 物化结果与文件证据不一致'), {
147
+ stage: 'download', category: 'local_io', acceptance: 'accepted'
148
+ });
149
+ }
150
+ const staged = stagedFiles.at(-1);
151
+ if (task.capability === 'image') {
152
+ staged.bytes = fs.readFileSync(target);
153
+ staged.mediaType = await generatedImageMediaType(staged.bytes);
154
+ }
155
+ }
156
+ recordCoreDownloadCompleted(dataRoot, taskId);
157
+ for (const staged of stagedFiles) {
158
+ const artifact = createCoreArtifact(dataRoot, {
159
+ kind: profile.kind,
160
+ mediaType: staged.mediaType,
161
+ bytes: staged.bytes || fs.readFileSync(staged.target),
162
+ metadata: { taskId, capability: task.capability },
163
+ ...(task.sourceArtifactId ? { relations: [{ type: 'derived-from', artifactId: task.sourceArtifactId }] } : {})
164
+ });
165
+ artifactIds.push(artifact.id);
166
+ }
167
+ return completeCoreDelivery(dataRoot, taskId, artifactIds);
168
+ } catch (error) {
169
+ return failCoreDelivery(dataRoot, taskId, error);
170
+ } finally {
171
+ for (const staged of stagedFiles) {
172
+ try { fs.rmSync(staged.target, { force: true }); } catch (_) { /* 后续显式清理可处理残留 */ }
173
+ }
174
+ }
175
+ }
176
+
177
+ async function observeWithinRoot(dataRoot, taskId, adapter, signal) {
178
+ const before = inspectCoreTask(dataRoot, taskId);
179
+ checkAdapterForTask(before, adapter);
180
+ activateCoreWatch(dataRoot, taskId);
181
+ let result;
182
+ try {
183
+ result = await invokeProviderOperation(adapter, 'poll', {
184
+ capability: before.capability,
185
+ remoteTaskId: before.remoteTaskId,
186
+ signal
187
+ }, { signal, taskId });
188
+ } catch (error) {
189
+ return recordCoreWatchError(dataRoot, taskId, error);
190
+ }
191
+ const recorded = recordCorePollResult(dataRoot, taskId, result);
192
+ if (result.kind !== 'succeeded') return recorded;
193
+ return deliverArtifacts(dataRoot, taskId, adapter, result.artifacts, signal);
194
+ }
195
+
196
+ /**
197
+ * FakeProvider 与未来真实 Host 共用的生命周期调度候选。它不拥有 timer,也不会
198
+ * 自动循环轮询;每次 observe 只消费一个 poll 结果,使重启和故障边界可重复验证。
199
+ */
200
+ export function createProviderTaskRunner(runtime) {
201
+ checkRuntime(runtime);
202
+ let activeTasks = runtimeTaskGates.get(runtime);
203
+ if (!activeTasks) {
204
+ activeTasks = new Set();
205
+ runtimeTaskGates.set(runtime, activeTasks);
206
+ }
207
+
208
+ async function runExclusive(taskId, operation, callback) {
209
+ const key = String(taskId || '');
210
+ if (activeTasks.has(key)) {
211
+ throw new ProviderTaskRunnerError(
212
+ 'IRIS_PROVIDER_TASK_BUSY',
213
+ '该 Task 已有生命周期操作正在执行'
214
+ );
215
+ }
216
+ activeTasks.add(key);
217
+ try {
218
+ return await runtime.run(operation, callback);
219
+ } finally {
220
+ activeTasks.delete(key);
221
+ }
222
+ }
223
+
224
+ async function submit(input = {}) {
225
+ return runtime.run('execute', async ({ dataRoot, signal }) => {
226
+ if (signal.aborted) throw new ProviderTaskRunnerError('IRIS_PROVIDER_ABORTED', '提交已取消');
227
+ const capability = String(input.capability || '').trim();
228
+ let candidates = normalizedCandidates(capability, input.candidates);
229
+ let providerInput = input.providerInput ?? {};
230
+ if (input.sourceArtifactId !== undefined) {
231
+ if (capability !== 'image') throw new TypeError('sourceArtifactId 仅用于图片编辑');
232
+ candidates = imageEditCandidates(candidates);
233
+ const image = await readImageEditArtifact(dataRoot, input.sourceArtifactId, signal);
234
+ providerInput = { ...providerInput, image };
235
+ }
236
+ const task = createCoreTask(dataRoot, { capability, retriedFrom: input.retriedFrom, sourceArtifactId: input.sourceArtifactId });
237
+ const wrapped = candidates.map(({ adapter, model, providerModel, prepareInput }) => ({
238
+ id: adapter.id,
239
+ model,
240
+ adapter,
241
+ submit: async (providerInput, context) => {
242
+ if (prepareInput) {
243
+ try { providerInput = await prepareInput({ providerInput, signal }); }
244
+ catch (error) { throw new ProviderContractError('视频输入准备失败:' + (error.safeMessage || error.message), {
245
+ ...providerErrorRecord(error), stage: error.stage || 'prepare', category: error.category || 'local_io', acceptance: 'not_accepted'
246
+ }); }
247
+ }
248
+ if (signal.aborted) throw new ProviderContractError('提交已取消', { stage: 'prepare', category: 'aborted', acceptance: 'not_accepted' });
249
+ return invokeProviderOperation(
250
+ adapter,
251
+ 'submit',
252
+ { capability, model: providerModel, input: providerInput, signal },
253
+ { ...context, signal, taskId: task.id }
254
+ );
255
+ }
256
+ }));
257
+ const submitted = await submitWithAcceptanceBoundary(wrapped, providerInput, {
258
+ signal,
259
+ beforeAttempt: async (attempt) => {
260
+ const candidate = candidates[attempt.ordinal - 1];
261
+ return beginCoreAttempt(dataRoot, task.id, {
262
+ ...attempt,
263
+ ...(candidate.providerBinding !== undefined ? { providerBinding: candidate.providerBinding } : {}),
264
+ ...(candidate.selectionReason !== undefined ? { selectionReason: candidate.selectionReason } : {})
265
+ });
266
+ },
267
+ afterResult: async (attempt) => recordCoreAttemptResult(dataRoot, task.id, attempt)
268
+ });
269
+ if (submitted.localError) {
270
+ return Object.freeze({ taskId: task.id, task: inspectCoreTask(dataRoot, task.id), localError: submitted.localError });
271
+ }
272
+ if (!submitted.result) throw new Error('Provider 提交流程没有返回结果');
273
+ if (submitted.result.kind === 'not_accepted') {
274
+ finalizeCoreNoAcceptance(dataRoot, task.id, submitted.result.error);
275
+ } else if (submitted.result.kind === 'completed') {
276
+ const artifacts = submitted.result.artifacts;
277
+ return Object.freeze({
278
+ taskId: task.id,
279
+ task: await deliverArtifacts(dataRoot, task.id, submitted.candidate.adapter, artifacts, signal),
280
+ attempts: submitted.attempts
281
+ });
282
+ }
283
+ return Object.freeze({
284
+ taskId: task.id,
285
+ task: inspectCoreTask(dataRoot, task.id),
286
+ attempts: submitted.attempts
287
+ });
288
+ });
289
+ }
290
+
291
+ function inspect(taskId) {
292
+ return runtime.run('inspect', ({ dataRoot }) => inspectCoreTask(dataRoot, taskId));
293
+ }
294
+
295
+ function observe(taskId, adapter, { resumeActive = false } = {}) {
296
+ return runExclusive(taskId, 'execute', ({ dataRoot, signal }) => {
297
+ if (resumeActive) {
298
+ const current = inspectCoreTask(dataRoot, taskId);
299
+ checkAdapterForTask(current, adapter);
300
+ if (current.watchState === 'active') recoverCoreTask(dataRoot, taskId);
301
+ }
302
+ return observeWithinRoot(dataRoot, taskId, adapter, signal);
303
+ });
304
+ }
305
+
306
+ function redeliver(taskId, adapter) {
307
+ return runExclusive(taskId, 'execute', async ({ dataRoot, signal }) => {
308
+ const task = inspectCoreTask(dataRoot, taskId);
309
+ checkAdapterForTask(task, adapter);
310
+ prepareCoreRedelivery(dataRoot, taskId);
311
+ let result;
312
+ try {
313
+ result = await invokeProviderOperation(adapter, 'poll', {
314
+ capability: task.capability,
315
+ remoteTaskId: task.remoteTaskId,
316
+ signal
317
+ }, { signal, taskId, redelivery: true });
318
+ } catch (error) {
319
+ return failCoreDelivery(dataRoot, taskId, error);
320
+ }
321
+ if (result.kind !== 'succeeded') {
322
+ return failCoreDelivery(dataRoot, taskId, providerErrorRecord(
323
+ result.error || '重新查询没有得到可交付产物',
324
+ { stage: 'download', category: 'protocol', acceptance: 'accepted' }
325
+ ));
326
+ }
327
+ return deliverArtifacts(dataRoot, taskId, adapter, result.artifacts, signal);
328
+ });
329
+ }
330
+
331
+ function cancel(taskId, adapter) {
332
+ return runExclusive(taskId, 'execute', async ({ dataRoot, signal }) => {
333
+ const task = inspectCoreTask(dataRoot, taskId);
334
+ checkAdapterForTask(task, adapter);
335
+ requestCoreCancel(dataRoot, taskId);
336
+ if (!hasProviderOperation(adapter, 'cancel')) {
337
+ return recordCoreCancelResult(dataRoot, taskId, { kind: 'not_supported' });
338
+ }
339
+ const result = await invokeProviderOperation(adapter, 'cancel', {
340
+ capability: task.capability,
341
+ remoteTaskId: task.remoteTaskId,
342
+ signal
343
+ }, { signal, taskId });
344
+ return recordCoreCancelResult(dataRoot, taskId, result);
345
+ });
346
+ }
347
+
348
+ function recover(taskId, adapter) {
349
+ return runExclusive(taskId, 'recover', async ({ dataRoot, signal }) => {
350
+ const recovered = recoverCoreTask(dataRoot, taskId);
351
+ if (recovered.acceptance === 'accepted' && recovered.remoteTaskId
352
+ && recovered.outcome === 'none'
353
+ && ['accepted', 'running'].includes(recovered.phase)) {
354
+ checkAdapterForTask(recovered, adapter);
355
+ return observeWithinRoot(dataRoot, taskId, adapter, signal);
356
+ }
357
+ return recovered;
358
+ });
359
+ }
360
+
361
+ return Object.freeze({
362
+ version: PROVIDER_TASK_RUNNER_VERSION,
363
+ submit,
364
+ inspect,
365
+ observe,
366
+ redeliver,
367
+ cancel,
368
+ recover
369
+ });
370
+ }
package/lib/summarize.js CHANGED
@@ -3,16 +3,20 @@
3
3
  * Iris 多模态上下文摘要(阶段 7.3)—— 把视频压成「一张联系表 + 一段转写文本」喂给视觉模型。
4
4
  *
5
5
  * 为什么是 contact sheet:
6
- * - VisionBackend 一次 analyze 只送一张图(自持栈吃 dataUrl / 全局模型吃 attachment ref),
6
+ * - Vision Port 一次 complete 只送一张图(字节与 MIME),
7
7
  * 逐帧送 N 次要 N 次模型调用、费用与延迟都不可接受;
8
8
  * - 把 N 帧缩略图按时间序拼成网格 + 每帧盖时间戳标签,一次视觉调用即可看全片(上下文压缩)。
9
9
  *
10
10
  * 契约:
11
- * - buildContactSheet(frames, opts) → PNG buffer + 网格尺寸(纯 sharp,可离线测试)
12
- * - summarizeMedia({backends, question?, transcript?, imageDataUrl, ref, signal})
13
- * → askWithBackends(复用阶段 1 降级链),prompt 里嵌入转写文本(若有)
11
+ * - buildContactSheet({frames, ...opts}) → PNG buffer + 网格尺寸(纯 sharp,可离线测试)
12
+ * - summarizeMedia({ports, frames, question?, transcript?, signal})
13
+ * → 一张拼图通过共享 Vision Ports,转写只消费显式输入
14
14
  */
15
15
  import sharp from 'sharp';
16
+ import { completeVision, VISION_BUDGET } from './vision-core.js';
17
+ import { createModelOperation } from './model-invoker.js';
18
+ import { waitForModelWork } from './model-call-runtime.js';
19
+ import { ModelPortError } from './model-port-contract.js';
16
20
 
17
21
  export class SummarizeError extends Error {
18
22
  constructor(message) {
@@ -28,7 +32,11 @@ const DEFAULT_QUESTION = '请根据这些视频关键帧(按时间顺序从左
28
32
  * @param {{frames:Array<{buffer:Buffer, atSec:number, width:number, height:number}>, cols?:number, maxSheetWidth?:number}}
29
33
  * @returns {Promise<{buffer:Buffer, width:number, height:number, rows:number, cols:number}>}
30
34
  */
31
- export async function buildContactSheet({ frames, cols = 3, maxSheetWidth = 1024 }) {
35
+ export async function buildContactSheet({ frames, cols = 3, maxSheetWidth = 1024, signal }) {
36
+ return waitForModelWork(() => makeContactSheet({ frames, cols, maxSheetWidth, signal }), signal);
37
+ }
38
+
39
+ async function makeContactSheet({ frames, cols, maxSheetWidth, signal }) {
32
40
  if (!Array.isArray(frames) || frames.length === 0) {
33
41
  throw new SummarizeError('iris_media_summarize: 没有可拼的帧');
34
42
  }
@@ -47,10 +55,10 @@ export async function buildContactSheet({ frames, cols = 3, maxSheetWidth = 1024
47
55
  const w = Math.max(1, cellW);
48
56
  const scale = w / Math.max(1, f.width || w);
49
57
  const h = Math.max(1, Math.round((f.height || 0) * scale));
50
- const thumb = await sharp(f.buffer)
58
+ const thumb = await waitForModelWork(() => sharp(f.buffer)
51
59
  .resize(w, h, { fit: 'fill' })
52
60
  .png()
53
- .toBuffer();
61
+ .toBuffer(), signal);
54
62
  // 时间戳标签(SVG 覆盖在缩略图下方)
55
63
  const label = Buffer.from(
56
64
  `<svg width="${w}" height="${labelH}" xmlns="http://www.w3.org/2000/svg">` +
@@ -58,10 +66,11 @@ export async function buildContactSheet({ frames, cols = 3, maxSheetWidth = 1024
58
66
  `<text x="4" y="${labelH - 7}" font-family="monospace" font-size="12" fill="#ffffff">` +
59
67
  `${f.atSec.toFixed(1)}s</text></svg>`
60
68
  );
61
- const cell = await sharp(thumb)
69
+ const cell = await waitForModelWork(() => sharp(thumb)
70
+ .extend({ bottom: labelH, background: { r: 16, g: 16, b: 20 } })
62
71
  .composite([{ input: label, top: h, left: 0 }])
63
72
  .png()
64
- .toBuffer();
73
+ .toBuffer(), signal);
65
74
  cells.push({ buffer: cell, width: w, height: h + labelH });
66
75
  }
67
76
 
@@ -73,12 +82,12 @@ export async function buildContactSheet({ frames, cols = 3, maxSheetWidth = 1024
73
82
  left: (i % colsN) * (cellW + gap),
74
83
  top: Math.floor(i / colsN) * (cellH + gap)
75
84
  }));
76
- const buffer = await sharp({
85
+ const buffer = await waitForModelWork(() => sharp({
77
86
  create: { width: sheetW, height: sheetH, channels: 3, background: { r: 16, g: 16, b: 20 } }
78
87
  })
79
88
  .composite(composite)
80
89
  .png()
81
- .toBuffer();
90
+ .toBuffer(), signal);
82
91
  return { buffer, width: sheetW, height: sheetH, rows: rowsN, cols: colsN };
83
92
  }
84
93
 
@@ -98,14 +107,16 @@ export function buildSummaryPrompt({ question, transcript }) {
98
107
 
99
108
  /**
100
109
  * 多模态摘要:contact sheet 图 + 可选转写文本 → 视觉后端链 → 摘要文本。
101
- * @param {{backends:Array, question?:string, transcript?:string, imageDataUrl:string, ref?:object, signal?:AbortSignal}}
102
- * @returns {Promise<{answer:string, via:string, model:string, backendId:string, errors:Array}>}
110
+ * @param {{ports:Array, frames:Array, question?:string, transcript?:string, signal?:AbortSignal}}
111
+ * @returns {Promise<object>} 完整模型结果和同一张拼图
103
112
  */
104
- export async function summarizeMedia({ backends, question, transcript, imageDataUrl, ref, signal }) {
105
- if (!Array.isArray(backends) || backends.length === 0) {
106
- throw new SummarizeError('iris_media_summarize: 没有可用的视觉后端(至少需要一个 vision provider)');
107
- }
108
- const { askWithBackends } = await import('./vision.js');
109
- const prompt = buildSummaryPrompt({ question, transcript });
110
- return askWithBackends(backends, { question: prompt, imageDataUrl, ref, signal });
113
+ export async function summarizeMedia({ ports = [], frames, question, transcript, signal, operation, budget = VISION_BUDGET }) {
114
+ if (!ports.length) throw new ModelPortError('IRIS_MODEL_UNAVAILABLE');
115
+ const op = operation || createModelOperation({ signal, budget: { timeoutMs: budget.timeoutMs, maxInvocations: ports.length } });
116
+ try {
117
+ const sheet = await buildContactSheet({ frames, cols: 3, signal: op.signal });
118
+ const result = await completeVision(ports, { prompt: buildSummaryPrompt({ question, transcript }),
119
+ image: { bytes: new Uint8Array(sheet.buffer), mediaType: 'image/png' } }, { operation: op, budget });
120
+ return { ...result, sheet };
121
+ } finally { if (!operation) op.dispose(); }
111
122
  }
@@ -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
+ }
@@ -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
+ }
package/lib/tasks.js CHANGED
@@ -455,15 +455,25 @@ export function all() {
455
455
  return [...load().tasks].sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)));
456
456
  }
457
457
 
458
+ /** 终态或已显式处理提醒的 Task v2 可删;活跃/未处理任务仍受保护。 */
459
+ export function taskRecordRemovable(task) {
460
+ if (!task) return false;
461
+ if (task.status !== 'running') return true;
462
+ return isV2Task(task)
463
+ && ATTENTION_USER_STATES.has(deriveUserState(task))
464
+ && attentionDisposition(task).status === 'acknowledged';
465
+ }
466
+
458
467
  /**
459
- * 删除单条任务元数据(仅终态;运行中须先 cancel)。
468
+ * 删除单条任务元数据。事实终态可直接删除;状态未知只有在用户已读/已重试归档后才可删除。
460
469
  * 只删记录,产物文件留在 outputs/(媒体链接因 token 随记录消失而自然失效)。
461
470
  * @returns {{ok:boolean, reason?:string}}
462
471
  */
463
472
  export function remove(id) {
464
473
  const t = get(id);
465
474
  if (!t) return { ok: false, reason: '任务不存在' };
466
- if (t.status === 'running') return { ok: false, reason: '运行中任务请先取消再删除' };
475
+ if (!taskRecordRemovable(t)) return { ok: false, reason: '活跃或待确认任务请先取消、完成提醒处置,或标为已读后再删除' };
476
+ stopWatch(id);
467
477
  const arr = load().tasks;
468
478
  const i = arr.findIndex((x) => x.id === id);
469
479
  if (i >= 0) arr.splice(i, 1);
@@ -481,8 +491,10 @@ export function prune(pred) {
481
491
  const removed = [];
482
492
  const kept = [];
483
493
  for (const t of arr) {
484
- if (t.status !== 'running' && pred(t)) removed.push(t.id);
485
- else kept.push(t);
494
+ if (taskRecordRemovable(t) && pred(t)) {
495
+ stopWatch(t.id);
496
+ removed.push(t.id);
497
+ } else kept.push(t);
486
498
  }
487
499
  if (removed.length) {
488
500
  load().tasks = kept;
@@ -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
+ }