@mokuyoaxis/dsh-iris 0.1.4 → 0.2.0

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 (64) hide show
  1. package/CHANGELOG.md +54 -1
  2. package/README.en.md +72 -15
  3. package/README.md +72 -15
  4. package/bin/dsh-iris.js +392 -13
  5. package/docs/AGENT_QUICKSTART.md +35 -0
  6. package/docs/ARCHITECTURE.md +80 -64
  7. package/docs/ARTIFACT_MANIFEST.md +60 -0
  8. package/docs/CORE_RUNTIME_CONTRACT.md +83 -0
  9. package/docs/DSH_CORE_MIGRATION.md +106 -0
  10. package/docs/DSH_RC2_ADAPTATION.md +36 -0
  11. package/docs/HEADLESS_CLI.md +217 -0
  12. package/docs/HOST_ADAPTER_CONTRACT.md +31 -12
  13. package/docs/HOST_DOCTOR.md +2 -0
  14. package/docs/MODEL_PORT_CONTRACT.md +300 -0
  15. package/docs/PROVIDER_ADAPTER_CONTRACT.md +51 -10
  16. package/docs/PROVIDER_RUNTIME_HARNESS.md +33 -0
  17. package/docs/PROVIDER_SUBMISSION_CONTRACT.md +1 -1
  18. package/docs/ROADMAP.md +42 -86
  19. package/docs/SECURITY.md +4 -3
  20. package/docs/assets/logo/README.md +55 -0
  21. package/docs/assets/logo/iris-bubble-concept.svg +67 -0
  22. package/docs/assets/logo/iris-bubble.svg +14 -0
  23. package/docs/assets/logo/iris-interim-flower.png +0 -0
  24. package/docs/assets/logo/iris-logo-mono-inverse.svg +78 -0
  25. package/docs/assets/logo/iris-logo-mono.svg +78 -0
  26. package/docs/assets/logo/iris-logo.svg +78 -0
  27. package/docs/assets/logo/iris-mark-mono-inverse.svg +42 -0
  28. package/docs/assets/logo/iris-mark-mono.svg +42 -0
  29. package/docs/assets/logo/iris-mark.svg +42 -0
  30. package/docs/releases/0.2.0.md +48 -0
  31. package/lib/actions.js +374 -362
  32. package/lib/adapters.js +52 -15
  33. package/lib/api.js +152 -5
  34. package/lib/client.js +763 -48
  35. package/lib/command-service.js +420 -0
  36. package/lib/config.js +56 -43
  37. package/lib/core-artifact-store.js +598 -0
  38. package/lib/core-artifacts.js +4 -0
  39. package/lib/core-attention.js +103 -0
  40. package/lib/core-contract.js +105 -0
  41. package/lib/core-doctor.js +41 -0
  42. package/lib/core-lease-recovery.js +170 -0
  43. package/lib/core-runtime.js +232 -0
  44. package/lib/core-tasks.js +560 -0
  45. package/lib/core-user-projection.js +192 -0
  46. package/lib/doctor.js +92 -33
  47. package/lib/dsh-core-adapter.js +599 -0
  48. package/lib/dsh-host-adapter.js +58 -7
  49. package/lib/generation-input.js +95 -0
  50. package/lib/guard.js +3 -3
  51. package/lib/index.js +159 -54
  52. package/lib/model-invoker.js +218 -0
  53. package/lib/model-port-contract.js +333 -0
  54. package/lib/models.js +97 -11
  55. package/lib/provider-adapter.js +49 -23
  56. package/lib/provider-adapters.js +84 -21
  57. package/lib/provider-catalog.js +171 -0
  58. package/lib/provider-contract.js +72 -3
  59. package/lib/provider-protocol.js +51 -2
  60. package/lib/provider-task-runner.js +328 -0
  61. package/lib/tasks.js +16 -4
  62. package/lib/vision.js +28 -7
  63. package/package.json +8 -8
  64. package/user_guide.md +27 -12
@@ -41,6 +41,24 @@ function unavailableFor(value, reason) {
41
41
  return { kind: value ? 'incompatible' : 'unavailable', reason };
42
42
  }
43
43
 
44
+ function imageAttachmentDto(ref) {
45
+ const out = {
46
+ attachmentId: ref.attachmentId,
47
+ mediaType: ref.mediaType,
48
+ name: typeof ref.name === 'string' ? ref.name : ''
49
+ };
50
+ // readImage 会核对这三项完整性事实;省略未知值,不从 ID/文件名猜测。
51
+ for (const key of ['bytes', 'width', 'height']) {
52
+ if (Number.isSafeInteger(ref[key]) && ref[key] > 0) out[key] = ref[key];
53
+ }
54
+ const original = ref.originalDimensions;
55
+ if (original && Number.isSafeInteger(original.width) && original.width > 0
56
+ && Number.isSafeInteger(original.height) && original.height > 0) {
57
+ out.originalDimensions = Object.freeze({ width: original.width, height: original.height });
58
+ }
59
+ return Object.freeze(out);
60
+ }
61
+
44
62
  function collectImageAttachments(nodes) {
45
63
  const out = [];
46
64
  const ids = new Set();
@@ -59,11 +77,7 @@ function collectImageAttachments(nodes) {
59
77
  && typeof ref.mediaType === 'string' && ref.mediaType.startsWith('image/')
60
78
  && !ids.has(ref.attachmentId)) {
61
79
  ids.add(ref.attachmentId);
62
- out.push(Object.freeze({
63
- attachmentId: ref.attachmentId,
64
- mediaType: ref.mediaType,
65
- name: typeof ref.name === 'string' ? ref.name : ''
66
- }));
80
+ out.push(imageAttachmentDto(ref));
67
81
  }
68
82
  for (const value of Object.values(node)) if (value && typeof value === 'object') stack.push(value);
69
83
  }
@@ -75,16 +89,41 @@ function registerWithEffect(ctx, registry, definition) {
75
89
  return registry.register(definition);
76
90
  }
77
91
 
78
- function dshVisionPort(llm) {
92
+ function visionRouteError(message) {
93
+ return Object.assign(new Error(message), { code: 'IRIS_HOST_CAPABILITY_INCOMPATIBLE' });
94
+ }
95
+
96
+ function dshVisionPort(llm, { defaults, requiresRoute }) {
79
97
  return Object.freeze({
80
98
  id: 'dsh-global',
81
99
  model: '全局视觉模型',
82
100
  async analyze({ question, ref, signal }) {
101
+ let route = {};
102
+ if (requiresRoute) {
103
+ // rc.2 路由检查前就停止取消请求;旧桥接继续由宿主处理 signal,
104
+ // 其 fallback 语义待共享调用控制迁移时统一。
105
+ if (signal?.aborted) throw signal.reason instanceof Error ? signal.reason : new Error('已取消');
106
+ const selection = defaults.currentSelection();
107
+ if (typeof selection?.provider !== 'string' || !selection.provider.trim()
108
+ || typeof selection?.model !== 'string' || !selection.model.trim()) {
109
+ throw visionRouteError('DSH 默认模型没有明确路由;请在 Iris 配置视觉模型');
110
+ }
111
+ // 默认文本选择只有明确声明 image 输入时才可成为视觉候选。
112
+ const { provider, model } = selection;
113
+ const info = await llm.resolveModelInfo(provider, model, signal);
114
+ if (signal?.aborted) throw signal.reason instanceof Error ? signal.reason : new Error('已取消');
115
+ if (info?.provider !== provider || info?.id !== model || !Array.isArray(info.inputModalities)
116
+ || !info.inputModalities.includes('image')) {
117
+ throw visionRouteError('DSH 默认模型未确认支持图片;请在 Iris 配置视觉模型或选择已声明图片输入的宿主模型');
118
+ }
119
+ route = { provider, model };
120
+ }
83
121
  const chunks = [];
84
122
  for await (const chunk of llm.stream({
85
123
  sessionId: undefined,
86
124
  provider: undefined,
87
125
  model: undefined,
126
+ ...route,
88
127
  signal,
89
128
  messages: [{
90
129
  role: 'user',
@@ -94,6 +133,12 @@ function dshVisionPort(llm) {
94
133
  ]
95
134
  }]
96
135
  })) {
136
+ if (chunk?.type === 'error') throw visionRouteError('DSH 视觉调用失败;请检查宿主模型状态');
137
+ // rc.2 的 Runtime 把 Adapter 异常转换为 finish,而不是单独的 error 事件。
138
+ if (requiresRoute && chunk?.type === 'finish') {
139
+ if (chunk.reason?.kind === 'error') throw visionRouteError('DSH 视觉调用失败;请检查宿主模型状态');
140
+ if (chunk.reason?.kind === 'aborted') throw visionRouteError('DSH 视觉调用已取消');
141
+ }
97
142
  const text = typeof chunk?.delta === 'string'
98
143
  ? chunk.delta
99
144
  : (chunk?.type === 'text-delta' ? String(chunk.text || '') : '');
@@ -174,7 +219,13 @@ export function createDshHostAdapter(ctx, { version = detectDshVersion() } = {})
174
219
  ? () => defaults.currentSelection()
175
220
  : () => null
176
221
  });
177
- ports.visionModel = dshVisionPort(llm);
222
+ const requiresRoute = /^0\.2\./.test(version);
223
+ if (!requiresRoute || defaults && typeof defaults.currentSelection === 'function'
224
+ && typeof llm.resolveModelInfo === 'function') {
225
+ ports.visionModel = dshVisionPort(llm, { defaults, requiresRoute });
226
+ } else unavailable.visionModel = {
227
+ kind: 'unavailable', reason: 'DSH 0.2 视觉桥接需要默认模型选择和精确图片能力元数据'
228
+ };
178
229
  } else {
179
230
  unavailable.textModel = unavailableFor(llm, 'DSH llm 需要 stream');
180
231
  unavailable.visionModel = unavailableFor(llm, 'DSH llm 需要 stream');
@@ -0,0 +1,95 @@
1
+ 'use strict';
2
+
3
+ import path from 'node:path';
4
+
5
+ const FIELDS = Object.freeze({
6
+ image: ['prompt', 'size', 'n'],
7
+ video: ['prompt', 'size', 'duration', 'img_data_url'],
8
+ tts: ['text', 'voice'],
9
+ transcribe: ['audio_url', 'audio_path']
10
+ });
11
+
12
+ export class GenerationInputError extends Error {
13
+ constructor(message) {
14
+ super(message);
15
+ this.name = 'GenerationInputError';
16
+ this.code = 'IRIS_GENERATION_INPUT_INVALID';
17
+ }
18
+ }
19
+
20
+ function fail(message) { throw new GenerationInputError(message); }
21
+
22
+ function text(value, field, maxLength, required = false) {
23
+ if (value === undefined && !required) return undefined;
24
+ const normalized = String(value || '').trim();
25
+ if (!normalized || normalized.length > maxLength) {
26
+ fail(`${field} 必须为 1–${maxLength} 字符`);
27
+ }
28
+ return normalized;
29
+ }
30
+
31
+ /**
32
+ * CLI、DSH 与知情重试共用的纯输入边界。只校验/转换调用方重新提供的参数,
33
+ * 不读取配置或文件,不上传,不从旧 Task 恢复 Prompt/媒体地址。
34
+ * Host 附件解析和本地音频上传仍由入口负责。
35
+ */
36
+ export function normalizeGenerationInput(capability, input, { allowAudioPath = false } = {}) {
37
+ const fields = Object.hasOwn(FIELDS, capability) ? FIELDS[capability] : null;
38
+ if (!fields) fail('不支持的生成能力');
39
+ if (!input || typeof input !== 'object' || Array.isArray(input)) fail('生成输入必须是 JSON 对象');
40
+ for (const field of Object.keys(input)) {
41
+ if (!fields.includes(field) && field !== 'model_ref') {
42
+ fail('生成输入不支持字段:' + field + ';允许 ' + fields.join(', ') + ', model_ref');
43
+ }
44
+ }
45
+ const modelRef = String(input.model_ref || '').trim();
46
+ const providerInput = {};
47
+ let audioPath;
48
+ if (capability === 'image' || capability === 'video') {
49
+ providerInput.prompt = text(input.prompt, 'prompt', 20000, true);
50
+ const size = text(input.size, 'size', 64);
51
+ if (size !== undefined) providerInput.size = size;
52
+ }
53
+ if (capability === 'image') {
54
+ const n = input.n === undefined ? 1 : Number(input.n);
55
+ if (!Number.isSafeInteger(n) || n < 1 || n > 4) fail('n 必须为 1–4 的整数');
56
+ providerInput.n = n;
57
+ }
58
+ if (capability === 'video') {
59
+ if (input.duration !== undefined) {
60
+ const duration = Number(input.duration);
61
+ if (!Number.isFinite(duration) || duration < 1 || duration > 60) fail('duration 必须为 1–60 的数字');
62
+ providerInput.duration = duration;
63
+ }
64
+ if (input.img_data_url !== undefined) {
65
+ const imgDataUrl = String(input.img_data_url).trim();
66
+ if (!imgDataUrl.startsWith('data:image/')) fail('img_data_url 必须是 data:image/ 开头的 data URL');
67
+ providerInput.imgDataUrl = imgDataUrl;
68
+ }
69
+ }
70
+ if (capability === 'tts') {
71
+ providerInput.text = text(input.text, 'text', 20000, true);
72
+ const voice = text(input.voice, 'voice', 64);
73
+ if (voice !== undefined) providerInput.voice = voice;
74
+ }
75
+ if (capability === 'transcribe') {
76
+ const audioUrl = String(input.audio_url || '').trim();
77
+ audioPath = String(input.audio_path || '').trim();
78
+ if (Boolean(audioUrl) === Boolean(audioPath)) {
79
+ fail('转写必须且只能提供 audio_url(公网/oss://)或 audio_path(本地绝对路径)之一');
80
+ }
81
+ if (audioUrl) {
82
+ if (audioUrl.length > 20000) fail('audio_url 最多为 20000 字符');
83
+ if (!/^(https:\/\/|oss:\/\/)/.test(audioUrl)) fail('audio_url 必须是 https:// 或 oss:// 地址');
84
+ providerInput.audioUrl = audioUrl;
85
+ } else {
86
+ if (!allowAudioPath) fail('retry 必须重新提供 audio_url;本地音频上传由调用入口负责');
87
+ if (!path.isAbsolute(audioPath)) fail('audio_path 必须是绝对路径');
88
+ }
89
+ }
90
+ return Object.freeze({
91
+ modelRef,
92
+ providerInput: Object.freeze(providerInput),
93
+ ...(audioPath ? { audioPath } : {})
94
+ });
95
+ }
package/lib/guard.js CHANGED
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * `/iris/*` 不经过 DSH 的 `/api` 信任栅栏,因此默认只接受回环 Host。
6
6
  * LAN/反代场景需用 IRIS_TRUSTED_HOSTS 显式列出 host 或 host:port;这只是
7
- * Host 边界,不是身份认证。POST 另外校验 Sec-Fetch-Site 与 Origin==Host。
7
+ * Host 边界,不是身份认证。浏览器请求另校验 Sec-Fetch-Site 与 Origin==Host。
8
8
  */
9
9
 
10
10
  /** 请求 Host 头 → host:port 规范化(小写,去 IPv6 方括号保留) */
@@ -44,12 +44,12 @@ export function isTrustedHost(hostHeader, configured = process.env.IRIS_TRUSTED_
44
44
  */
45
45
  export function checkRequest(req, options = {}) {
46
46
  const headers = req.headers || {};
47
- const method = String(req.method || 'GET').toUpperCase();
48
47
  if (!isTrustedHost(headers.host, options.trustedHosts)) {
49
48
  return { ok: false, status: 403, error: 'untrusted host' };
50
49
  }
51
- if (method !== 'POST') return { ok: true };
52
50
 
51
+ // 浏览器的读请求同样可能泄露媒体与状态;明确的跨站/跨源请求一律拒绝。
52
+ // curl/CLI 等不发送 Origin/Sec-Fetch-Site 的非浏览器客户端仍可在受信 Host 上使用。
53
53
  const sfs = String(headers['sec-fetch-site'] || '').toLowerCase();
54
54
  if (sfs === 'cross-site') return { ok: false, status: 403, error: 'cross-site request denied' };
55
55
 
package/lib/index.js CHANGED
@@ -17,7 +17,8 @@ import { serveApi, closeAllSse, purgeStaleUploads } from './api.js';
17
17
  import { buildVisionBackendsFromHost, askWithBackends } from './vision.js';
18
18
  import { hasHostPort, requireHostPort } from './host-contract.js';
19
19
  import { createDshHostAdapter } from './dsh-host-adapter.js';
20
- import { cropImage, pixelDiff, imageDimensions } from './pixels.js';
20
+ import { cropForDsh, diffForDsh, framesForDsh, inspectProviderTaskForDsh, projectCoreTaskForDsh, readCoreArtifactMediaForDsh, resumeProviderTaskWatchesForDsh, stopProviderTaskWatchesForDsh } from './dsh-core-adapter.js';
21
+ import { imageDimensions } from './pixels.js';
21
22
  import { locateObject } from './locate.js';
22
23
  import { serveRender } from './render.js';
23
24
  import { ensurePrivateDir, hardenPrivateTree } from './private-storage.js';
@@ -51,7 +52,7 @@ function dshHost(ctx) {
51
52
  }
52
53
 
53
54
  /** 工具内同步等待上限;超过即转后台盯守,用 iris_task_status 查询 */
54
- const AWAIT_MS = { image: 180000, video: 480000 };
55
+ const AWAIT_MS = { image: 180000, video: 480000, transcribe: 120000 };
55
56
 
56
57
  function outputDir() {
57
58
  const dir = tasks.outputsDir();
@@ -153,6 +154,39 @@ async function awaitTerminal(taskId, { timeoutMs, signal }) {
153
154
  }
154
155
  }
155
156
 
157
+ function coreTaskWaitFinished(task) {
158
+ return Boolean(task && (task.phase === 'terminal'
159
+ || (task.outcome === 'succeeded' && ['ready', 'failed'].includes(task.deliveryState))));
160
+ }
161
+
162
+ function coreTaskTerminalProblem(task, label = '任务') {
163
+ if (!task) return label + '记录不存在';
164
+ if (task.outcome === 'succeeded' && task.deliveryState === 'ready') return '';
165
+ if (task.outcome === 'succeeded' && task.deliveryState === 'failed') {
166
+ return label + '已成功,但 Core Artifact 交付失败:'
167
+ + (task.lastError?.safeMessage || '可稍后重新交付;禁止重新生成');
168
+ }
169
+ if (task.acceptance === 'unknown' || task.outcome === 'unknown') {
170
+ return label + '的远端受理或结果状态未知:'
171
+ + (task.lastError?.safeMessage || '禁止自动重提,请先人工确认');
172
+ }
173
+ if (task.outcome === 'canceled') return label + '已取消';
174
+ if (task.outcome === 'failed') return label + '失败:'
175
+ + (task.lastError?.safeMessage || '供应商明确返回失败');
176
+ return label + '需要人工处理';
177
+ }
178
+
179
+ async function awaitCoreTerminal(taskId, { timeoutMs, signal }) {
180
+ const deadline = Date.now() + timeoutMs;
181
+ for (;;) {
182
+ if (signal?.aborted) throw new Error('已取消;Core 任务仍保留远端受理事实,可稍后查询');
183
+ const task = await inspectProviderTaskForDsh(taskId, { signal });
184
+ if (coreTaskWaitFinished(task)) return task;
185
+ if (Date.now() > deadline) return null;
186
+ await new Promise((resolve) => setTimeout(resolve, 1000));
187
+ }
188
+ }
189
+
156
190
  /* ---------------- 图片 data URL(视觉工具共用) ---------------- */
157
191
 
158
192
  function toDataUrl(absPath, mediaType) {
@@ -160,17 +194,6 @@ function toDataUrl(absPath, mediaType) {
160
194
  return `data:${mediaType};base64,${buf.toString('base64')}`;
161
195
  }
162
196
 
163
- function fmtTask(t) {
164
- const lines = [`[${t.cap}] ${t.id} · ${t.status}${t.progress ? ' · ' + t.progress : ''} · ${t.model}`];
165
- lines.push('prompt: ' + String(t.prompt || '').slice(0, 120));
166
- if (t.error) lines.push('error: ' + t.error);
167
- if ((t.files || []).length) lines.push('files:\n' + t.files.map((f) => path.join(tasks.outputsDir(), f)).join('\n'));
168
- const links = mediaLinksOf(t);
169
- if (links.length) lines.push('links:\n' + links.join('\n'));
170
- if (t.remoteTaskId) lines.push('remoteTask: ' + t.remoteTaskId);
171
- return lines.join('\n');
172
- }
173
-
174
197
  export const name = 'mokuyoaxis-dsh-iris';
175
198
  export const inject = ['tools'];
176
199
 
@@ -223,9 +246,19 @@ export async function apply(ctx) {
223
246
  if (resumed.length) console.log(`[iris] 已恢复接管 ${resumed.length} 个后台任务`);
224
247
  });
225
248
 
226
- // Fiber 清理:插件停用/更新时停掉全部盯守句柄
249
+ try {
250
+ const resumed = await resumeProviderTaskWatchesForDsh();
251
+ if (resumed.length) console.log(`[iris] 已恢复接管 ${resumed.length} 个 Core 图片任务`);
252
+ } catch (error) {
253
+ console.error('[iris] 恢复 Core 图片后台任务失败(已隔离,不影响宿主):', error?.message || error);
254
+ }
255
+
256
+ // Fiber 清理:插件停用/更新时停掉 legacy 与 Core 的全部 Host 盯守句柄
227
257
  guard('盯守句柄注册', () => {
228
- ctx.effect(() => () => tasks.stopWatchAll());
258
+ ctx.effect(() => () => {
259
+ tasks.stopWatchAll();
260
+ stopProviderTaskWatchesForDsh();
261
+ });
229
262
  });
230
263
 
231
264
  // 媒体路由(视频/音频在对话流里的可播通路;图片仍走原生附件)
@@ -273,8 +306,35 @@ export async function apply(ctx) {
273
306
  isConcurrencySafe: () => false,
274
307
  async execute(args, exec) {
275
308
  const prompt = String(args.prompt || '').trim();
276
- if (!prompt) throw new Error('iris: prompt 不能为空');
277
309
  const submitted = await runAction(dshHost(ctx), 'image', args, { signal: exec.signal });
310
+ if (submitted.storage === 'core') {
311
+ let final = await inspectProviderTaskForDsh(submitted.taskId, { signal: exec.signal });
312
+ if (!coreTaskWaitFinished(final)) {
313
+ final = await awaitCoreTerminal(submitted.taskId, { timeoutMs: AWAIT_MS.image, signal: exec.signal });
314
+ }
315
+ if (!final) {
316
+ return { blocks: [{ type: 'text', text: `[iris] Core 图片任务仍在后台观察(task: ${submitted.taskId})。稍后可用 iris_task_status 查询进度与 Artifact。` }] };
317
+ }
318
+ const terminalProblem = coreTaskTerminalProblem(final, '图像生成');
319
+ if (terminalProblem) throw new Error(terminalProblem);
320
+ const projected = await projectCoreTaskForDsh(dshHost(ctx), submitted.taskId, { signal: exec.signal });
321
+ const refs = projected.artifacts.map((item) => item.attachment);
322
+ const blocks = projected.artifacts.map((item) => ({ type: 'image', attachment: item.attachment }));
323
+ let note = '';
324
+ try {
325
+ const first = await readCoreArtifactMediaForDsh(projected.artifacts[0].artifact.id, { signal: exec.signal });
326
+ const described = await describeImageData(dshHost(ctx), refs[0],
327
+ `data:${first.artifact.mediaType};base64,${first.bytes.toString('base64')}`, prompt);
328
+ if (described) note = `画面内容:${described}`;
329
+ } catch (_) { /* 自述是增强项,失败不影响 Core 交付 */ }
330
+ blocks.unshift({ type: 'text', text:
331
+ `[iris] 图像已生成(${submitted.model},Core task: ${submitted.taskId})。` +
332
+ (note ? `\n${note}` : '') +
333
+ `\nartifact: ${projected.artifacts.map((item) => item.artifact.id).join(', ')}` +
334
+ `\nattachment: ${refs.map((ref) => ref.attachmentId).join(', ')}`
335
+ });
336
+ return { blocks };
337
+ }
278
338
  let final = tasks.get(submitted.taskId);
279
339
  if (final && !taskWaitFinished(final)) {
280
340
  final = await awaitTerminal(submitted.taskId, { timeoutMs: AWAIT_MS.image, signal: exec.signal });
@@ -344,6 +404,25 @@ export async function apply(ctx) {
344
404
  isConcurrencySafe: () => false,
345
405
  async execute(args, exec) {
346
406
  const submitted = await runAction(dshHost(ctx), 'video', args, { signal: exec.signal });
407
+ if (submitted.storage === 'core') {
408
+ let final = await inspectProviderTaskForDsh(submitted.taskId, { signal: exec.signal });
409
+ if (!coreTaskWaitFinished(final)) {
410
+ final = await awaitCoreTerminal(submitted.taskId, { timeoutMs: AWAIT_MS.video, signal: exec.signal });
411
+ }
412
+ if (!final) {
413
+ return `[iris] Core 视频任务已提交并转入后台观察(task: ${submitted.taskId},remoteTask: ${submitted.remoteTaskId})。\n` +
414
+ '渲染通常需要数分钟;完成后用 iris_task_status 查询进度与 Artifact,或在工作台「重新观察」。';
415
+ }
416
+ const terminalProblem = coreTaskTerminalProblem(final, '视频生成');
417
+ if (terminalProblem) throw new Error(terminalProblem);
418
+ const secs = Math.round((Date.now() - new Date(final.createdAt).getTime()) / 1000);
419
+ const links = final.artifactIds.map((artifactId) =>
420
+ '/iris/api/core/artifact/' + encodeURIComponent(artifactId) + '/media');
421
+ return `[iris] 视频已生成(${submitted.model || final.modelRef},${submitted.mode || 't2v'},约 ${secs}s,Core task: ${final.id}):\n` +
422
+ `artifact: ${final.artifactIds.join(', ')}\n` +
423
+ links.join('\n') +
424
+ '\n(链接受 DSH Host 与浏览器同源守卫保护;Artifact ID 持有者可访问,请勿公开)';
425
+ }
347
426
  const final = await awaitTerminal(submitted.taskId, { timeoutMs: AWAIT_MS.video, signal: exec.signal });
348
427
  if (!final) {
349
428
  return `[iris] 视频任务已提交并转入后台盯守(task: ${submitted.taskId},remoteTask: ${submitted.remoteTaskId})。\n` +
@@ -378,6 +457,16 @@ export async function apply(ctx) {
378
457
  isConcurrencySafe: () => true,
379
458
  async execute(args, exec) {
380
459
  const submitted = await runAction(dshHost(ctx), 'tts', args, { signal: exec.signal });
460
+ if (submitted.storage === 'core') {
461
+ const final = await inspectProviderTaskForDsh(submitted.taskId, { signal: exec.signal });
462
+ const terminalProblem = coreTaskTerminalProblem(final, '语音合成');
463
+ if (terminalProblem) throw new Error(terminalProblem);
464
+ const links = final.artifactIds.map((artifactId) =>
465
+ '/iris/api/core/artifact/' + encodeURIComponent(artifactId) + '/media');
466
+ return `[iris] 语音已合成(${submitted.model || final.modelRef},Core task: ${final.id}):\n` +
467
+ `artifact: ${final.artifactIds.join(', ')}\n` +
468
+ links.map((link) => `[♪ 音频播放](${link})`).join('\n');
469
+ }
381
470
  const final = tasks.get(submitted.taskId);
382
471
  const terminalProblem = taskTerminalProblem(final, '语音合成');
383
472
  if (terminalProblem) throw new Error(terminalProblem);
@@ -405,6 +494,25 @@ export async function apply(ctx) {
405
494
  isConcurrencySafe: () => false,
406
495
  async execute(args, exec) {
407
496
  const submitted = await runAction(dshHost(ctx), 'transcribe', args, { signal: exec.signal });
497
+ if (submitted.storage === 'core') {
498
+ let final = await inspectProviderTaskForDsh(submitted.taskId, { signal: exec.signal });
499
+ if (!coreTaskWaitFinished(final)) {
500
+ final = await awaitCoreTerminal(submitted.taskId, { timeoutMs: AWAIT_MS.transcribe, signal: exec.signal });
501
+ }
502
+ if (!final) {
503
+ return `[iris] Core 转写任务已提交并转入后台观察(task: ${submitted.taskId})。完成后用 iris_task_status 查询进度与 Artifact。`;
504
+ }
505
+ const terminalProblem = coreTaskTerminalProblem(final, '音频转写');
506
+ if (terminalProblem) throw new Error(terminalProblem);
507
+ const texts = [];
508
+ for (const artifactId of final.artifactIds) {
509
+ const media = await readCoreArtifactMediaForDsh(artifactId, { signal: exec.signal });
510
+ texts.push(Buffer.from(media.bytes).toString('utf8'));
511
+ }
512
+ return `[iris] 音频转写完成(${submitted.model || final.modelRef},Core task: ${final.id}):\n` +
513
+ texts.join('\n') +
514
+ `\nartifact: ${final.artifactIds.join(', ')}`;
515
+ }
408
516
  const final = await awaitTerminal(submitted.taskId, { timeoutMs: 120000, signal: exec.signal });
409
517
  if (!final) return `[iris] 音频转写任务已提交并转入后台(task: ${submitted.taskId})。完成后用 iris_task_status 查询。`;
410
518
  const terminalProblem = taskTerminalProblem(final, '音频转写');
@@ -435,26 +543,22 @@ export async function apply(ctx) {
435
543
  isConcurrencySafe: () => false,
436
544
  async execute(args, exec) {
437
545
  if (exec.signal && exec.signal.aborted) throw new Error('已取消');
438
- const p = String(args.video_path || '').trim();
439
- if (!p || !path.isAbsolute(p)) throw new Error('iris: video_path 必须是绝对路径');
440
- if (!ffmpegAvailable()) throw new Error('iris: ffmpeg 不可用,无法抽帧(可选安装 ffmpeg/ffprobe 后启用;不影响其他 iris 工具)');
441
- const meta = probeVideo(p);
442
- const frames = await extractFrames({
443
- inputPath: p,
546
+ const { media: meta, frames } = await framesForDsh({
547
+ videoPath: args.video_path,
444
548
  maxFrames: args.max_frames,
445
549
  targetWidth: args.target_width,
446
550
  format: args.format,
447
551
  quality: args.quality,
448
552
  signal: exec.signal
449
553
  });
450
- const mediaType = args.format === 'png' ? 'image/png' : 'image/jpeg';
451
- const ext = args.format === 'png' ? 'png' : 'jpg';
554
+ const mediaType = frames[0].mediaType;
555
+ const ext = mediaType === 'image/png' ? 'png' : 'jpg';
452
556
  const blocks = [];
453
557
  const refs = [];
454
558
  for (let i = 0; i < frames.length; i++) {
455
559
  const f = frames[i];
456
560
  const ref = await attachmentService(host).saveImage({
457
- data: new Uint8Array(f.buffer),
561
+ data: new Uint8Array(f.bytes),
458
562
  mediaType,
459
563
  name: `iris-frame-${Date.now()}-${i + 1}.${ext}`
460
564
  });
@@ -470,7 +574,7 @@ export async function apply(ctx) {
470
574
  `时间戳:${ats}\n` +
471
575
  `attachments: ${refs.map((r) => r.attachmentId).join(', ')}`
472
576
  });
473
- return { blocks };
577
+ return { blocks, artifactIds: frames.map((frame) => frame.artifact.id) };
474
578
  }
475
579
  });
476
580
 
@@ -648,14 +752,8 @@ export async function apply(ctx) {
648
752
  output: { schema: { type: 'string' }, render: (_args, v) => [{ type: 'text', text: v }] },
649
753
  isConcurrencySafe: () => true,
650
754
  async execute(args) {
651
- if (args.task_id) {
652
- const t = tasks.get(String(args.task_id).trim());
653
- if (!t) throw new Error('iris: 没有这个任务:' + args.task_id);
654
- return fmtTask(t);
655
- }
656
- const recent = tasks.list().slice(0, 5);
657
- if (!recent.length) return '[iris] 还没有生成任务记录。';
658
- return recent.map(fmtTask).join('\n---\n');
755
+ const result = await runAction(dshHost(ctx), 'status', args || {});
756
+ return result.text;
659
757
  }
660
758
  });
661
759
 
@@ -685,21 +783,25 @@ export async function apply(ctx) {
685
783
  image_path: args.image_path,
686
784
  attachment_id: args.attachment_id
687
785
  });
688
- const { buffer, width, height, mime } = await cropImage({
689
- input: input.buffer,
786
+ const result = await cropForDsh({
787
+ ...(args.image_path
788
+ ? { imagePath: String(args.image_path).trim() }
789
+ : { bytes: input.buffer, mediaType: input.mediaType }),
690
790
  left: args.left,
691
791
  top: args.top,
692
792
  width: args.width,
693
- height: args.height
793
+ height: args.height,
794
+ signal: exec.signal
694
795
  });
796
+ if (exec.signal && exec.signal.aborted) throw new Error('已取消');
695
797
  const ref = await attachmentService(host).saveImage({
696
- data: new Uint8Array(buffer),
697
- mediaType: mime,
798
+ data: new Uint8Array(result.bytes),
799
+ mediaType: result.mediaType,
698
800
  name: `iris-crop-${Date.now()}.png`
699
801
  });
700
802
  return {
701
803
  blocks: [
702
- { type: 'text', text: `[iris] 裁剪完成:${width}x${height}(原区域 ${args.left},${args.top},${args.width},${args.height})\nattachment: ${ref.attachmentId}` },
804
+ { type: 'text', text: `[iris] 裁剪完成:${result.width}x${result.height}(原区域 ${args.left},${args.top},${args.width},${args.height})\nattachment: ${ref.attachmentId}` },
703
805
  { type: 'image', attachment: ref }
704
806
  ]
705
807
  };
@@ -732,20 +834,21 @@ export async function apply(ctx) {
732
834
  if (!(args.image_b_path || args.attachment_b_id)) throw new Error('iris: 需要 image_b_path 或 attachment_b_id');
733
835
  const a = await resolveImageInput(dshHost(ctx), exec, { image_path: args.image_a_path, attachment_id: args.attachment_a_id });
734
836
  const b = await resolveImageInput(dshHost(ctx), exec, { image_path: args.image_b_path, attachment_id: args.attachment_b_id });
735
- const { ratio, diffPixels, totalPixels, width, height, worstRegions, heatmap, mime } = await pixelDiff({
736
- inputA: a.buffer,
737
- inputB: b.buffer,
738
- grid: args.grid || 8,
739
- topRegions: args.top_regions || 3
837
+ const result = await diffForDsh({
838
+ imageA: { bytes: a.buffer, mediaType: a.mediaType },
839
+ imageB: { bytes: b.buffer, mediaType: b.mediaType },
840
+ grid: args.grid, topRegions: args.top_regions, signal: exec.signal
740
841
  });
842
+ const { ratio, diffPixels, totalPixels, width, height, worstRegions } = result.metrics;
741
843
  const ref = await attachmentService(host).saveImage({
742
- data: new Uint8Array(heatmap),
743
- mediaType: mime,
844
+ data: new Uint8Array(result.bytes),
845
+ mediaType: result.mediaType,
744
846
  name: `iris-diff-${Date.now()}.png`
745
847
  });
746
848
  const pct = (ratio * 100).toFixed(2);
747
849
  const regions = worstRegions.map((r) => `(col ${r.col},row ${r.row}) ${(r.score * 100).toFixed(1)}%`).join(' ');
748
850
  return {
851
+ artifactIds: [result.artifact.id],
749
852
  blocks: [
750
853
  {
751
854
  type: 'text',
@@ -1012,14 +1115,16 @@ export async function runVisionTool(host, exec, { origin, model, question, ref,
1012
1115
  async function describeFirstImage(host, ref, absPath, originalPrompt) {
1013
1116
  try {
1014
1117
  const mt = MEDIA_TYPES[path.extname(absPath).toLowerCase()] || 'image/png';
1015
- const { answer } = await askVision(host, {
1016
- question: `用不超过两句话描述这张图片的主题与构图。生成该图的提示词是:「${String(originalPrompt).slice(0, 200)}」`,
1017
- ref,
1018
- dataUrl: toDataUrl(absPath, mt),
1019
- signal: undefined
1020
- });
1021
- return answer.slice(0, 300);
1118
+ return await describeImageData(host, ref, toDataUrl(absPath, mt), originalPrompt);
1022
1119
  } catch (_) {
1023
1120
  return ''; // 自述是增强项,失败不影响交付
1024
1121
  }
1025
1122
  }
1123
+
1124
+ async function describeImageData(host, ref, dataUrl, originalPrompt) {
1125
+ const { answer } = await askVision(host, {
1126
+ question: `用不超过两句话描述这张图片的主题与构图。生成该图的提示词是:「${String(originalPrompt).slice(0, 200)}」`,
1127
+ ref, dataUrl, signal: undefined
1128
+ });
1129
+ return answer.slice(0, 300);
1130
+ }