@mokuyoaxis/dsh-iris 0.2.2 → 0.2.4

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 (79) hide show
  1. package/.dsh/skills/iris-compose-media/SKILL.md +23 -73
  2. package/.dsh/skills/iris-compose-media/agents/openai.yaml +1 -1
  3. package/.dsh/skills/iris-compose-media/references/task-v2-recovery.md +23 -22
  4. package/.dsh/skills/iris-compose-media/references/workflows.md +25 -30
  5. package/.dsh/skills/iris-use/SKILL.md +42 -0
  6. package/.dsh/skills/iris-use/agents/openai.yaml +4 -0
  7. package/.dsh/skills/iris-use/references/media-contract.md +83 -0
  8. package/.dsh/skills/iris-verify-ui/SKILL.md +9 -7
  9. package/.dsh/skills/iris-verify-ui/agents/openai.yaml +1 -1
  10. package/.dsh/skills/iris-verify-ui/references/verdict-and-degradation.md +3 -0
  11. package/CHANGELOG.md +41 -2
  12. package/README.en.md +50 -38
  13. package/README.md +48 -37
  14. package/bin/dsh-iris.js +35 -6
  15. package/docs/AGENT_QUICKSTART.md +9 -9
  16. package/docs/ARCHITECTURE.md +4 -2
  17. package/docs/CLI_MANAGEMENT.md +11 -3
  18. package/docs/CORE_RUNTIME_CONTRACT.md +3 -1
  19. package/docs/DSH_COMPATIBILITY.md +43 -0
  20. package/docs/DSH_CORE_MIGRATION.md +6 -2
  21. package/docs/DSH_RC2_ADAPTATION.md +4 -2
  22. package/docs/FAULT_INJECTION.md +1 -0
  23. package/docs/FUNCTION_MODEL_ASSIGNMENTS.md +63 -0
  24. package/docs/HEADLESS_CLI.md +14 -8
  25. package/docs/HOST_ADAPTER_CONTRACT.md +10 -7
  26. package/docs/HOST_DOCTOR.md +6 -4
  27. package/docs/IMAGE_IMPORT.md +49 -0
  28. package/docs/MODEL_PORT_CONTRACT.md +6 -2
  29. package/docs/MUSIC.md +77 -0
  30. package/docs/PROMPT_OPTIMIZER.md +48 -10
  31. package/docs/PROVIDER_ADAPTER_CONTRACT.md +2 -0
  32. package/docs/ROADMAP.md +16 -25
  33. package/docs/SECURITY.md +3 -2
  34. package/docs/SYNC_DELIVERY_RECOVERY.md +39 -0
  35. package/docs/TASK_SEMANTICS.md +4 -4
  36. package/docs/VISION_CLI.md +3 -3
  37. package/docs/VISION_MODEL.md +1 -1
  38. package/docs/WORKBENCH_ARTIFACTS.md +4 -2
  39. package/docs/releases/0.2.3.md +54 -0
  40. package/docs/releases/0.2.4.md +27 -0
  41. package/lib/actions.js +71 -14
  42. package/lib/adapters.js +6 -2
  43. package/lib/api.js +14 -5
  44. package/lib/bundled-skills.js +1 -1
  45. package/lib/capability.js +2 -0
  46. package/lib/client.js +131 -47
  47. package/lib/command-service.js +28 -4
  48. package/lib/config.js +41 -23
  49. package/lib/core-artifact-store.js +44 -6
  50. package/lib/core-delivery-cache.js +145 -0
  51. package/lib/core-maintenance.js +12 -1
  52. package/lib/core-tasks.js +19 -5
  53. package/lib/core-user-projection.js +2 -2
  54. package/lib/doctor.js +4 -10
  55. package/lib/dsh-compatibility.js +10 -0
  56. package/lib/dsh-core-adapter.js +20 -4
  57. package/lib/dsh-text-model-adapter.js +15 -2
  58. package/lib/generation-input.js +10 -0
  59. package/lib/host-runtime.js +5 -2
  60. package/lib/image-edit-input.js +7 -5
  61. package/lib/image-import.js +61 -0
  62. package/lib/index.js +60 -17
  63. package/lib/models.js +12 -3
  64. package/lib/music-generation.js +21 -0
  65. package/lib/music-options.js +18 -0
  66. package/lib/music-transport.js +46 -0
  67. package/lib/ocr-model-routing.js +1 -0
  68. package/lib/prompt-optimizer-config.js +92 -21
  69. package/lib/prompt-optimizer-core.js +51 -6
  70. package/lib/prompt-optimizer.js +20 -14
  71. package/lib/provider-adapter.js +1 -0
  72. package/lib/provider-adapters.js +35 -3
  73. package/lib/provider-catalog.js +20 -11
  74. package/lib/provider-config-service.js +18 -6
  75. package/lib/provider-protocol.js +18 -0
  76. package/lib/provider-task-runner.js +80 -5
  77. package/lib/vision-model-routing.js +22 -8
  78. package/package.json +12 -9
  79. package/user_guide.md +45 -23
@@ -65,7 +65,11 @@ export async function prepareDshTextModelPort(textModel, binding, { signal } = {
65
65
  if (signal?.aborted) throw abortError(signal, 'prepare', 'not_invoked');
66
66
  return textModel.resolveModelInfo(route.provider, route.model, signal);
67
67
  }), signal, 'prepare', 'not_invoked'); }
68
- catch (_) { if (signal?.aborted) throw abortError(signal, 'prepare', 'not_invoked'); }
68
+ catch (_) {
69
+ if (signal?.aborted) throw abortError(signal, 'prepare', 'not_invoked');
70
+ // 查询失败不同于成功查询但能力缺失;不要吞掉错误后进入付费生成。
71
+ throw new ModelPortError('IRIS_MODEL_UNAVAILABLE', { stage: 'prepare', invocation: 'not_invoked' });
72
+ }
69
73
  }
70
74
  if (signal?.aborted) throw abortError(signal, 'prepare', 'not_invoked');
71
75
  return createDshTextModelPort(textModel, { ...route, sessionId }, { metadata });
@@ -73,7 +77,16 @@ export async function prepareDshTextModelPort(textModel, binding, { signal } = {
73
77
 
74
78
  /** 已绑定事实的纯构造;用于缓存元数据/协议 fixture,不执行元数据查询。 */
75
79
  export function createDshTextModelPort(textModel, binding, { metadata } = {}) {
76
- return createDshBoundModelPort(textModel, binding, { metadata });
80
+ const bound = createDshBoundModelPort(textModel, binding, { metadata });
81
+ const modalities = metadata?.inputModalities;
82
+ if (modalities !== undefined) {
83
+ if (!Array.isArray(modalities) || modalities.some(value => typeof value !== 'string' || !value.trim())) {
84
+ throw new ModelPortError('IRIS_MODEL_PROTOCOL_INVALID', { stage: 'prepare', invocation: 'not_invoked' });
85
+ }
86
+ if (!modalities.includes('text')) throw new ModelPortError('IRIS_MODEL_UNSUPPORTED', { stage: 'prepare', invocation: 'not_invoked' });
87
+ }
88
+ // 缺失能力只表示未知;宿主接受文本输入也不证明远端必定只返回文字。
89
+ return Object.freeze({ ...bound, textInput: modalities === undefined ? 'unknown' : 'supported' });
77
90
  }
78
91
 
79
92
  /** 文本与视觉共用经过验证的 DSH 事件读取器;图片桥接仅由视觉适配器注入。 */
@@ -2,11 +2,13 @@
2
2
 
3
3
  import path from 'node:path';
4
4
  import fs from 'node:fs';
5
+ import { normalizeMusicOptions } from './music-options.js';
5
6
 
6
7
  const FIELDS = Object.freeze({
7
8
  image: ['prompt', 'size', 'n', 'source_artifact_id'],
8
9
  video: ['prompt', 'size', 'duration', 'img_data_url', 'first_frame_path', 'audio_path', 'resolution'],
9
10
  tts: ['text', 'voice'],
11
+ music: ['prompt', 'timeout_ms', 'retry_502', 'retry_delay_ms'],
10
12
  transcribe: ['audio_url', 'audio_path']
11
13
  });
12
14
 
@@ -47,6 +49,7 @@ export function normalizeGenerationInput(capability, input, { allowAudioPath = f
47
49
  const providerInput = {};
48
50
  let audioPath;
49
51
  let sourceArtifactId;
52
+ let musicOptions;
50
53
  let firstFramePath, videoMode;
51
54
  if (capability === 'video') {
52
55
  audioPath = String(input.audio_path || '').trim();
@@ -101,6 +104,12 @@ export function normalizeGenerationInput(capability, input, { allowAudioPath = f
101
104
  const voice = text(input.voice, 'voice', 64);
102
105
  if (voice !== undefined) providerInput.voice = voice;
103
106
  }
107
+ if (capability === 'music') {
108
+ providerInput.prompt = text(input.prompt, 'prompt', 20000, true);
109
+ try { musicOptions = normalizeMusicOptions(input); }
110
+ catch (error) { fail(error.message); }
111
+ providerInput.timeoutMs = musicOptions.timeoutMs;
112
+ }
104
113
  if (capability === 'transcribe') {
105
114
  const audioUrl = String(input.audio_url || '').trim();
106
115
  audioPath = String(input.audio_path || '').trim();
@@ -119,6 +128,7 @@ export function normalizeGenerationInput(capability, input, { allowAudioPath = f
119
128
  return Object.freeze({
120
129
  modelRef,
121
130
  providerInput: Object.freeze(providerInput),
131
+ ...(musicOptions ? { musicOptions } : {}),
122
132
  ...(sourceArtifactId ? { sourceArtifactId } : {}),
123
133
  ...(audioPath ? { audioPath } : {}),
124
134
  ...(capability === 'video' ? { firstFramePath, videoMode } : {})
@@ -10,9 +10,11 @@ export const IRIS_PLUGIN_ID = '@mokuyoaxis/dsh-iris';
10
10
  export const HOST_CLIENT_PROTOCOL_VERSION = 0;
11
11
 
12
12
  export const EXPECTED_IRIS_TOOLS = Object.freeze([
13
+ 'iris_import_image',
13
14
  'iris_draw_image',
14
15
  'iris_edit_image',
15
16
  'iris_generate_video',
17
+ 'iris_generate_music',
16
18
  'iris_speak_text',
17
19
  'iris_transcribe_audio',
18
20
  'iris_task_status',
@@ -28,8 +30,9 @@ export const EXPECTED_IRIS_TOOLS = Object.freeze([
28
30
  ]);
29
31
 
30
32
  export const EXPECTED_IRIS_SKILLS = Object.freeze([
31
- 'iris-verify-ui',
32
- 'iris-compose-media'
33
+ 'iris-use',
34
+ 'iris-compose-media',
35
+ 'iris-verify-ui'
33
36
  ]);
34
37
 
35
38
  export const EXPECTED_IRIS_ROUTES = Object.freeze([
@@ -1,20 +1,22 @@
1
1
  'use strict';
2
- /** 聊天改图只接受当前 Core 中的静态图片;输入原字节不进入 Task 记录。 */
2
+ /** 改图只接受当前 Core 中的静态图片;输入原字节不进入 Task 记录。 */
3
3
  import sharp from 'sharp';
4
4
  import { inspectCoreArtifact, readCoreArtifactBytes } from './core-artifacts.js';
5
5
  import { waitForModelWork } from './model-call-runtime.js';
6
+ import { supportsImageEdit } from './provider-protocol.js';
6
7
 
7
8
  function invalid(message) {
8
9
  return Object.assign(new Error(message), { code: 'IRIS_IMAGE_EDIT_INPUT_INVALID' });
9
10
  }
10
11
 
11
12
  export function imageEditCandidates(candidates) {
13
+ const supported = candidate => supportsImageEdit(candidate.adapter.protocol, candidate.providerModel);
12
14
  if (candidates.some(candidate => candidate.selectionReason === 'explicit'
13
- && candidate.adapter.protocol !== 'openai-chat-images')) {
14
- throw invalid('指定模型的图片协议不支持聊天改图,请选择 openai-chat-images 模型');
15
+ && !supported(candidate))) {
16
+ throw invalid('指定模型的图片协议不支持此类改图,请选择聊天生图模型或 DashScope Qwen 原生改图模型');
15
17
  }
16
- const selected = candidates.filter(candidate => candidate.adapter.protocol === 'openai-chat-images');
17
- if (!selected.length) throw invalid('未配置可用的 openai-chat-images 生图模型,无法编辑原图');
18
+ const selected = candidates.filter(supported);
19
+ if (!selected.length) throw invalid('当前生图名单没有可用的聊天生图或 DashScope Qwen 改图模型');
18
20
  return selected;
19
21
  }
20
22
 
@@ -0,0 +1,61 @@
1
+ 'use strict';
2
+
3
+ import { open } from 'node:fs/promises';
4
+ import sharp from 'sharp';
5
+
6
+ export const MAX_IMPORT_IMAGE_BYTES = 20 * 1024 * 1024;
7
+ const TYPES = Object.freeze({ png: 'image/png', jpeg: 'image/jpeg', webp: 'image/webp' });
8
+
9
+ function invalid(message) {
10
+ return Object.assign(new Error(message), { code: 'IRIS_IMAGE_IMPORT_INVALID' });
11
+ }
12
+
13
+ function checkSize(size) {
14
+ if (!size || size > MAX_IMPORT_IMAGE_BYTES) throw invalid('导入图片必须非空,且不超过20 MiB');
15
+ }
16
+
17
+ /** 只读一次有界快照;后续校验和保存使用同一份字节,不再复制变化中的源路径。 */
18
+ export async function readImportImage({ filePath, imageBytes }, signal) {
19
+ signal.throwIfAborted();
20
+ let bytes;
21
+ if (filePath) {
22
+ let file;
23
+ try {
24
+ file = await open(filePath, 'r');
25
+ const stat = await file.stat();
26
+ if (!stat.isFile()) throw invalid('导入来源必须是普通图片文件');
27
+ checkSize(stat.size);
28
+ bytes = Buffer.alloc(stat.size);
29
+ let offset = 0;
30
+ while (offset < bytes.length) {
31
+ signal.throwIfAborted();
32
+ const { bytesRead } = await file.read(bytes, offset, Math.min(65536, bytes.length - offset), offset);
33
+ if (!bytesRead) throw invalid('读取期间图片发生变化,请重新导入');
34
+ offset += bytesRead;
35
+ }
36
+ const extra = await file.read(Buffer.alloc(1), 0, 1, offset);
37
+ if (extra.bytesRead) throw invalid('读取期间图片发生变化,请重新导入');
38
+ } catch (error) {
39
+ signal.throwIfAborted();
40
+ if (error.code === 'IRIS_IMAGE_IMPORT_INVALID') throw error;
41
+ throw invalid('图片无法读取,请选择可读的普通文件');
42
+ } finally { await file?.close(); }
43
+ } else {
44
+ if (!(imageBytes instanceof Uint8Array)) throw invalid('导入图片内容必须是字节数据');
45
+ checkSize(imageBytes.byteLength);
46
+ bytes = Buffer.from(imageBytes);
47
+ }
48
+ signal.throwIfAborted();
49
+ let metadata;
50
+ try { metadata = await sharp(bytes).metadata(); }
51
+ catch (_) {
52
+ signal.throwIfAborted();
53
+ throw invalid('无法读取图片,请使用有效的静态PNG、JPEG或WebP');
54
+ }
55
+ signal.throwIfAborted();
56
+ const mediaType = TYPES[metadata.format];
57
+ if (!mediaType || metadata.pages > 1 || !metadata.width || !metadata.height) {
58
+ throw invalid('仅支持静态PNG、JPEG或WebP,不支持动画');
59
+ }
60
+ return { bytes, mediaType, metadata: { width: metadata.width, height: metadata.height } };
61
+ }
package/lib/index.js CHANGED
@@ -263,7 +263,7 @@ export async function apply(ctx) {
263
263
  // 若 webServer 已在运行,inject 回调也会触发;这里兜底立即挂载一次
264
264
  guard('媒体路由兜底挂载', () => mountIrisRoutes(ctx));
265
265
 
266
- // DSH 的 Skill registry 是可选宿主能力。存在时把 npm 包内两项 Skill 注册到
266
+ // DSH 的 Skill registry 是可选宿主能力。存在时把 npm 包内三项 Skill 注册到
267
267
  // 全局层;项目目录中的同名 Skill 仍由 DSH 原生优先级覆盖。
268
268
  ctx.inject(['skills'], (skillCtx) => {
269
269
  guard('随包 Skill 注册', () => {
@@ -309,6 +309,30 @@ export async function apply(ctx) {
309
309
  return { blocks };
310
310
  }
311
311
 
312
+ safeRegister(host, {
313
+ name: 'iris_import_image',
314
+ description: 'Save a local image or a current-session image attachment as a persistent Iris image Artifact for later viewing, editing and export. Provide exactly one of image_path or attachment_id. Supports static PNG/JPEG/WebP up to 20 MiB, preserves the source, and never calls a model.',
315
+ parameters: {
316
+ type: 'object',
317
+ properties: {
318
+ image_path: { type: 'string', description: 'Absolute path to an image readable by the DSH host' },
319
+ attachment_id: { type: 'string', description: 'Image attachment from the current session, alternative to image_path' }
320
+ },
321
+ required: []
322
+ },
323
+ output: { schema: { type: 'object' }, render: (_args, value) => value.blocks },
324
+ isConcurrencySafe: () => true,
325
+ async execute(args, exec = {}) {
326
+ const imagePath = String(args.image_path || '').trim();
327
+ const attachmentId = String(args.attachment_id || '').trim();
328
+ if (Boolean(imagePath) === Boolean(attachmentId)) throw new Error('图片导入须选择image_path或attachment_id之一');
329
+ const input = imagePath ? { image_path: imagePath } : {
330
+ image_bytes: (await resolveVisionImageInput(dshHost(ctx), exec, { attachment_id: attachmentId }, exec.signal)).bytes
331
+ };
332
+ return runAction(dshHost(ctx), 'image_import', input, { signal: exec.signal });
333
+ }
334
+ });
335
+
312
336
  /* ---------- 🖼️ 画图 ---------- */
313
337
  safeRegister(host, {
314
338
  name: 'iris_draw_image',
@@ -321,7 +345,7 @@ export async function apply(ctx) {
321
345
  prompt: { type: 'string', description: 'Detailed visual description of the image to create' },
322
346
  size: { type: 'string', description: "Output size; DashScope uses 'W*H' (e.g. '1024*1024'), OpenAI uses 'WxH'. Default provider default." },
323
347
  n: { type: 'string', description: "Number of images, default '1'" },
324
- model: { type: 'string', description: 'Override with a providerId::modelId reference (legacy bare model id also accepted)' }
348
+ model: { type: 'string', description: 'Optional assigned providerId::modelId; selects only this item (legacy bare model id also accepted)' }
325
349
  },
326
350
  required: ['prompt']
327
351
  },
@@ -383,12 +407,12 @@ export async function apply(ctx) {
383
407
 
384
408
  safeRegister(host, {
385
409
  name: 'iris_edit_image',
386
- description: 'Edit an existing Core image by Artifact ID and an instruction using a configured openai-chat-images model. Returns new Core Artifacts and DSH image attachments; preserves the source image and records its provenance.',
410
+ description: 'Edit an existing Core image by Artifact ID and an instruction using an assigned chat-image model or native DashScope Qwen edit model. Returns new Core Artifacts and DSH image attachments; preserves the source image and records its provenance.',
387
411
  parameters: {
388
412
  type: 'object', properties: {
389
413
  source_artifact_id: { type: 'string', description: 'Core Artifact ID of the source PNG, JPEG or WebP image (up to 20 MiB)' },
390
414
  prompt: { type: 'string', description: 'Describe the changes and what should be preserved in the source image' },
391
- model: { type: 'string', description: 'Optional providerId::modelId; image protocol must be openai-chat-images' },
415
+ model: { type: 'string', description: 'Optional assigned providerId::modelId; selects only this item; supports openai-chat-images or DashScope Qwen edit/max/plus and 2.0/pro models' },
392
416
  n: { type: 'string', description: "Number of images, default '1'" }
393
417
  }, required: ['source_artifact_id', 'prompt']
394
418
  },
@@ -419,7 +443,7 @@ export async function apply(ctx) {
419
443
  resolution: { type: 'string', description: "s2v only: output tier '480P' (default) or '720P'" },
420
444
  size: { type: 'string', description: "t2v/i2v only: output size 'W*H', e.g. '1280*720' (default)" },
421
445
  duration: { type: 'number', description: 't2v/i2v only: duration in seconds, if the model supports it' },
422
- model: { type: 'string', description: 'Override with a providerId::modelId reference (legacy bare model id accepted)' }
446
+ model: { type: 'string', description: 'Optional assigned providerId::modelId; selects only this item (legacy bare model id accepted)' }
423
447
  },
424
448
  required: []
425
449
  },
@@ -463,16 +487,35 @@ export async function apply(ctx) {
463
487
  }
464
488
  });
465
489
 
490
+ safeRegister(host, {
491
+ name: 'iris_generate_music',
492
+ description: 'Generate music using a model explicitly assigned to Music. Returns audio artifact IDs and playback links. Lyrics, vocals and duration are prompt intentions. Configured 502 retries may generate again.',
493
+ parameters: { type: 'object', properties: {
494
+ prompt: { type: 'string', description: 'Describe the music, including any lyrics, vocals, mood or intended duration' },
495
+ model: { type: 'string', description: 'Optional assigned providerId::modelId; selects only this item' },
496
+ timeout_ms: { type: 'integer', description: 'Per-attempt timeout; default 300000 ms' },
497
+ retry_502: { type: 'integer', description: '502 regeneration retries, 0–5; default 2; each retry consumes another request' },
498
+ retry_delay_ms: { type: 'integer', description: 'Delay between 502 retries; default 5000 ms' }
499
+ }, required: ['prompt'] },
500
+ output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value }] },
501
+ isConcurrencySafe: () => true,
502
+ async execute(args, exec) {
503
+ const submitted = await runAction(dshHost(ctx), 'music', args, { signal: exec.signal });
504
+ return submitted.text + '\nartifact: ' + submitted.artifactIds.join(', ') + '\n' +
505
+ submitted.artifactIds.map(id => '[♪ 音乐播放](/iris/api/core/artifact/' + encodeURIComponent(id) + '/media)').join('\n');
506
+ }
507
+ });
508
+
466
509
  /* ---------- 🔊 语音合成 ---------- */
467
510
  safeRegister(host, {
468
511
  name: 'iris_speak_text',
469
- description: 'Synthesize speech audio from text using the configured TTS model (qwen-tts / compatible). Returns the saved audio file path.',
512
+ description: 'Synthesize spoken audio from text using the configured TTS model. Returns Core audio Artifact IDs and playback links; export an Artifact to a host file before passing it as audio_path to another tool. Use iris_generate_music for songs or background music.',
470
513
  parameters: {
471
514
  type: 'object',
472
515
  properties: {
473
516
  text: { type: 'string', description: 'Text to speak' },
474
517
  voice: { type: 'string', description: "Voice name, e.g. 'Cherry' (DashScope qwen-tts voices)" },
475
- model: { type: 'string', description: 'Override with a providerId::modelId reference (legacy bare model id accepted)' }
518
+ model: { type: 'string', description: 'Optional assigned providerId::modelId; selects only this item (legacy bare model id accepted)' }
476
519
  },
477
520
  required: ['text']
478
521
  },
@@ -509,7 +552,7 @@ export async function apply(ctx) {
509
552
  type: 'object',
510
553
  properties: {
511
554
  audio_path: { type: 'string', description: 'Absolute path to the audio file (wav/mp3)' },
512
- model: { type: 'string', description: 'Override with a providerId::modelId transcription reference (legacy bare model id accepted)' }
555
+ model: { type: 'string', description: 'Optional assigned transcription providerId::modelId; selects only this item (legacy bare model id accepted)' }
513
556
  },
514
557
  required: ['audio_path']
515
558
  },
@@ -621,7 +664,7 @@ export async function apply(ctx) {
621
664
  target_width: { type: 'integer', description: 'Frame width for sampling (default 640)' },
622
665
  transcribe: { type: 'boolean', description: 'Auto-transcribe the audio track for video_path (default true unless transcribe_text is provided). Existing frames only consume explicit text.' },
623
666
  transcribe_text: { type: 'string', description: 'Optional existing transcript text; does not create a transcription task' },
624
- model: { type: 'string', description: 'Override the configured vision model id' }
667
+ model: { type: 'string', description: 'Optional item from the vision assignment list (providerId::modelId or host:current-vision); selects only this item' }
625
668
  },
626
669
  required: []
627
670
  },
@@ -683,7 +726,7 @@ export async function apply(ctx) {
683
726
  name: `iris-sheet-${Date.now()}.png`
684
727
  });
685
728
  if (exec.signal?.aborted) throw exec.signal.reason;
686
- const viaLabel = via === 'selfstack' ? 'iris 自持栈' : 'DSH 全局视觉模型';
729
+ const viaLabel = via === 'selfstack' ? 'iris 自持栈' : 'DSH 当前默认模型';
687
730
  return {
688
731
  frames: summaryFrameRecords(frames),
689
732
  blocks: [
@@ -712,7 +755,7 @@ export async function apply(ctx) {
712
755
  image_path: { type: 'string', description: 'Absolute path to the image file (png/jpg/jpeg/webp)' },
713
756
  artifact_id: { type: 'string', description: 'Existing Core image Artifact ID in the current profile (alternative to image_path)' },
714
757
  question: { type: 'string', description: 'Question or extraction request about the image; defaults to a detailed description' },
715
- model: { type: 'string', description: 'Override the configured vision model id (default qwen-vl-plus)' }
758
+ model: { type: 'string', description: 'Optional item from the vision assignment list (providerId::modelId or host:current-vision); selects only this item' }
716
759
  },
717
760
  required: []
718
761
  },
@@ -750,7 +793,7 @@ export async function apply(ctx) {
750
793
  properties: {
751
794
  attachment_id: { type: 'string', description: 'The attachment_id of an image that appeared in this session or was generated by iris' },
752
795
  question: { type: 'string', description: 'New question about the image pixels' },
753
- model: { type: 'string', description: 'Override the configured vision model id' }
796
+ model: { type: 'string', description: 'Optional item from the vision assignment list (providerId::modelId or host:current-vision); selects only this item' }
754
797
  },
755
798
  required: ['attachment_id', 'question']
756
799
  },
@@ -780,7 +823,7 @@ export async function apply(ctx) {
780
823
  safeRegister(host, {
781
824
  name: 'iris_task_status',
782
825
  description:
783
- 'Query iris generation tasks (image/video/tts). Pass a task_id for one task, or omit it to list the latest tasks with status, progress, errors and output file paths.',
826
+ 'Query Iris tasks (image/video/tts/music/transcription). Pass a task_id for one task, or omit it to list recent tasks. Core results provide Artifact IDs and playback links; legacy tasks may provide file paths. Querying does not re-observe or redeliver a task.',
784
827
  parameters: {
785
828
  type: 'object',
786
829
  properties: {
@@ -915,7 +958,7 @@ export async function apply(ctx) {
915
958
  artifact_id: { type: 'string', description: 'Existing Core image Artifact ID in the current profile (alternative to image_path or attachment_id)' },
916
959
  attachment_id: { type: 'string', description: 'Session attachment id of an image (alternative to image_path)' },
917
960
  target: { type: 'string', description: 'The object or UI element to locate, e.g. "send button"' },
918
- model: { type: 'string', description: 'Override the vision model for this locate call (e.g. qwen3-vl-235b-a22b-thinking for higher grounding precision; default is the provider visionModel, typically qwen-vl-plus)' }
961
+ model: { type: 'string', description: 'Optional item from the vision assignment list (providerId::modelId or host:current-vision); selects only this item' }
919
962
  },
920
963
  required: ['target']
921
964
  },
@@ -1022,7 +1065,7 @@ export async function apply(ctx) {
1022
1065
  }
1023
1066
  });
1024
1067
 
1025
- console.log('[iris] tools registered: iris_draw_image, iris_edit_image, iris_generate_video, iris_speak_text, iris_transcribe_audio, iris_task_status, iris_look_at_image, iris_relook_attachment, iris_crop, iris_pixel_diff, iris_locate, iris_html_screenshot, iris_long_ocr, iris_video_frames, iris_media_summarize');
1068
+ console.log('[iris] tools registered: iris_import_image, iris_draw_image, iris_edit_image, iris_generate_video, iris_generate_music, iris_speak_text, iris_transcribe_audio, iris_task_status, iris_look_at_image, iris_relook_attachment, iris_crop, iris_pixel_diff, iris_locate, iris_html_screenshot, iris_long_ocr, iris_video_frames, iris_media_summarize');
1026
1069
  };
1027
1070
 
1028
1071
  /* ---------------- 视觉路由(M3 补全):look/relook 共用执行器 + 降级链 ---------------- */
@@ -1132,7 +1175,7 @@ export async function sessionAttachmentRef(host, exec, attachmentId) {
1132
1175
  /**
1133
1176
  * 看图问答(阶段 1:VisionBackend 降级链)。
1134
1177
  * 只取声明/推断出 vision 能力的自持栈 provider(严格选择),按序组装后端链,
1135
- * 全局视觉模型兜底;每个后端的失败现场保留在 errors 里。
1178
+ * 仅使用视觉功能的明确分配;宿主默认模型也需显式加入,每个后端失败保留在errors里。
1136
1179
  * @returns {{answer:string,via:'selfstack'|'global',model?:string,backendId:string}}
1137
1180
  */
1138
1181
  export async function askVision(host, { question, dataUrl, image, prepareImage, signal, model, budget }) {
@@ -1155,6 +1198,6 @@ export async function runVisionTool(host, exec, { origin, model, question, dataU
1155
1198
  model
1156
1199
  });
1157
1200
  const label = origin === 'relook' ? '重看回答' : '看图回答';
1158
- const viaLabel = via === 'selfstack' ? 'iris 自持栈' : 'DSH 全局视觉模型';
1201
+ const viaLabel = via === 'selfstack' ? 'iris 自持栈' : 'DSH 当前默认模型';
1159
1202
  return `[iris] ${label}(${usedModel || '默认'} · ${viaLabel}):\n${answer}` + (artifactId ? `\nCore Artifact: ${artifactId}` : '');
1160
1203
  }
package/lib/models.js CHANGED
@@ -19,12 +19,18 @@ export const CAP_FIELD = {
19
19
  [CAPABILITIES.IMAGE]: 'imageModel',
20
20
  [CAPABILITIES.VIDEO]: 'videoModel',
21
21
  [CAPABILITIES.TTS]: 'ttsModel',
22
+ [CAPABILITIES.MUSIC]: 'musicModel',
22
23
  [CAPABILITIES.TRANSCRIBE]: 'transcribeModel',
23
24
  [CAPABILITIES.VISION]: 'visionModel'
24
25
  };
25
26
 
26
27
  const MODEL_REF_SEP = '::';
27
28
 
29
+ /** Explicit host resource; never a Provider account or an implicit fallback. */
30
+ export const HOST_VISION_REF = 'host:current-vision';
31
+ export const HOST_VISION_MODEL = Object.freeze({ ref: HOST_VISION_REF, id: 'current-vision',
32
+ providerId: HOST_VISION_REF, capabilities: Object.freeze([CAPABILITIES.VISION]), source: 'host' });
33
+
28
34
  /** 稳定的模型复合引用;provider/model 分别编码,避免同名模型跨账号碰撞。 */
29
35
  export function modelRef(providerId, modelId) {
30
36
  return encodeURIComponent(String(providerId || '')) + MODEL_REF_SEP + encodeURIComponent(String(modelId || ''));
@@ -77,6 +83,7 @@ const DISCOVERY_CAPABILITIES = Object.freeze({
77
83
  IG: CAPABILITIES.IMAGE, IMAGE: CAPABILITIES.IMAGE, 'IMAGE-GEN': CAPABILITIES.IMAGE,
78
84
  VG: CAPABILITIES.VIDEO, VIDEO: CAPABILITIES.VIDEO, 'VIDEO-GEN': CAPABILITIES.VIDEO,
79
85
  TTS: CAPABILITIES.TTS, ASR: CAPABILITIES.TRANSCRIBE,
86
+ MUSIC: CAPABILITIES.MUSIC, 'MUSIC-GEN': CAPABILITIES.MUSIC,
80
87
  TRANSCRIBE: CAPABILITIES.TRANSCRIBE, VU: CAPABILITIES.VISION, VISION: CAPABILITIES.VISION
81
88
  });
82
89
 
@@ -115,6 +122,7 @@ export function providerModels(p) {
115
122
  const caps = configuredModelCapabilities(p, m);
116
123
  const extra = typeof m === 'object' && m ? { verified: m.verified, source: m.source,
117
124
  ...(m.imageProtocol !== undefined ? { imageProtocol: m.imageProtocol } : {}),
125
+ ...(m.musicProtocol !== undefined ? { musicProtocol: m.musicProtocol } : {}),
118
126
  ...(m.visionInput !== undefined ? { visionInput: m.visionInput } : {}) } : {};
119
127
  push(id, caps, extra);
120
128
  }
@@ -139,6 +147,7 @@ export function resolveModelRef(provider, capability, requested = '') {
139
147
 
140
148
  /** 共享旧分配对象、裸名称和复合引用的解析,池顺序决定同名匹配。 */
141
149
  export function resolvePoolModel(pool, raw, capability) {
150
+ if (raw === HOST_VISION_REF) return pool.find(m => m.ref === raw && m.capabilities.includes(capability)) || null;
142
151
  let providerId = '';
143
152
  let modelId = '';
144
153
  if (raw && typeof raw === 'object') {
@@ -153,7 +162,7 @@ export function resolvePoolModel(pool, raw, capability) {
153
162
  && m.capabilities.includes(capability)) || null;
154
163
  }
155
164
 
156
- /** DSH 与 CLI 共用的候选顺序:分配优先、按池顺序补齐并去重。 */
165
+ /** DSH 与 CLI 共用的执行名单:只按明确分配排序并去重。 */
157
166
  export function orderedModels(pool, capability, assignments, { includeRateLimited = false } = {}) {
158
167
  const ordered = [];
159
168
  const seen = new Set();
@@ -167,7 +176,6 @@ export function orderedModels(pool, capability, assignments, { includeRateLimite
167
176
  for (const raw of (Array.isArray(assignments) ? assignments : (assignments ? [assignments] : []))) {
168
177
  add(resolvePoolModel(pool, raw, capability));
169
178
  }
170
- for (const model of pool) add(model);
171
179
  return ordered;
172
180
  }
173
181
 
@@ -189,9 +197,10 @@ export function selectionReasonForModel(pool, capability, assignments, modelOrRe
189
197
  }
190
198
 
191
199
  /** 全局模型池:合并所有 provider 的模型 */
192
- export function modelPool(providers) {
200
+ export function modelPool(providers, { includeHostVision = false } = {}) {
193
201
  const pool = [];
194
202
  for (const p of providers || []) pool.push(...providerModels(p));
203
+ if (includeHostVision) pool.push(HOST_VISION_MODEL);
195
204
  return pool;
196
205
  }
197
206
 
@@ -0,0 +1,21 @@
1
+ import { setTimeout as delay } from 'node:timers/promises';
2
+
3
+ /** Explicit Music 502 regeneration policy; each resubmission is a new Core Task. */
4
+ export async function generateMusicTask({ runner, candidates, providerInput, options, signal }) {
5
+ const taskIds = [];
6
+ let result, retriedFrom;
7
+ for (let index = 0; index <= options.retry502; index++) {
8
+ signal?.throwIfAborted();
9
+ result = await runner.submit({ capability: 'music', candidates, providerInput, ...(retriedFrom ? { retriedFrom } : {}) });
10
+ taskIds.push(result.task.id);
11
+ const task = result.task;
12
+ if (result.localError || task.phase !== 'terminal' || task.lastError?.httpStatus !== 502
13
+ || task.outcome === 'succeeded' || index === options.retry502) break;
14
+ // Retry the actual model, without switching to another (potentially paid) source.
15
+ candidates = candidates.filter(candidate => candidate.model === task.modelRef && candidate.adapter.id === task.providerId);
16
+ if (!candidates.length) throw new Error('Music 重试无法找到原模型绑定');
17
+ retriedFrom = task.id;
18
+ await delay(options.retryDelayMs, undefined, { signal });
19
+ }
20
+ return Object.freeze({ ...result, taskIds: Object.freeze(taskIds), retries: taskIds.length - 1 });
21
+ }
@@ -0,0 +1,18 @@
1
+ /** Music caller controls; generation parameters remain in the prompt. */
2
+ export function normalizeMusicOptions(input = {}) {
3
+ const integer = (field, fallback, min, max) => {
4
+ if (input[field] !== undefined && !['number', 'string'].includes(typeof input[field])) {
5
+ throw new TypeError(`${field} 必须是整数`);
6
+ }
7
+ const value = input[field] === undefined ? fallback : Number(input[field]);
8
+ if (!Number.isSafeInteger(value) || value < min || value > max) {
9
+ throw new TypeError(`${field} 必须是 ${min}–${max} 的整数`);
10
+ }
11
+ return value;
12
+ };
13
+ return Object.freeze({
14
+ timeoutMs: integer('timeout_ms', 300000, 1, 3600000),
15
+ retry502: integer('retry_502', 2, 0, 5),
16
+ retryDelayMs: integer('retry_delay_ms', 5000, 0, 60000)
17
+ });
18
+ }
@@ -0,0 +1,46 @@
1
+ import { ProviderContractError } from './provider-contract.js';
2
+
3
+ function responseError(message) {
4
+ return new ProviderContractError(message, { stage: 'response', category: 'protocol', acceptance: 'unknown' });
5
+ }
6
+
7
+ /** Gemini web gateway's documented non-streaming Markdown audio response. */
8
+ export function musicArtifactsFromResponse(body) {
9
+ if (!Array.isArray(body?.choices)) throw responseError('Music 响应缺少 choices');
10
+ const artifacts = [], seen = new Set();
11
+ for (const choice of body.choices) {
12
+ const content = choice?.message?.content;
13
+ if (typeof content !== 'string') continue;
14
+ for (const match of content.matchAll(/data:(audio\/(?:mpeg|wav|x-wav));base64,([A-Za-z0-9+/=]+)/gi)) {
15
+ const data = match[2];
16
+ const bytes = Buffer.from(data, 'base64');
17
+ if (!bytes.length || bytes.toString('base64') !== data) throw responseError('Music 音频 base64 无效');
18
+ if (seen.has(data)) continue;
19
+ seen.add(data);
20
+ artifacts.push({ kind: 'inline-base64', data, mediaType: match[1].toLowerCase() === 'audio/mpeg' ? 'audio/mpeg' : 'audio/wav' });
21
+ }
22
+ }
23
+ if (!artifacts.length) throw responseError('Music 响应没有可交付的 MP3/WAV 音频');
24
+ return artifacts;
25
+ }
26
+
27
+ export async function generateMusic({ key, baseUrl, model, prompt, timeoutMs = 300000, signal }) {
28
+ const timeout = AbortSignal.timeout(timeoutMs);
29
+ const response = await fetch(String(baseUrl).replace(/\/+$/, '') + '/chat/completions', {
30
+ method: 'POST',
31
+ headers: { 'Content-Type': 'application/json', ...(key ? { Authorization: `Bearer ${key}` } : {}) },
32
+ signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
33
+ body: JSON.stringify({ model, stream: false, messages: [{ role: 'user', content: prompt }] })
34
+ });
35
+ const text = await response.text();
36
+ let body;
37
+ try { body = JSON.parse(text); }
38
+ catch (error) { if (response.ok) throw responseError('Music 响应不是有效 JSON'); }
39
+ if (!response.ok) {
40
+ const message = String(body?.error?.message || body?.message || `Music HTTP ${response.status}`);
41
+ throw new ProviderContractError(key ? message.replaceAll(key, '[REDACTED]') : message, {
42
+ stage: 'submit', httpStatus: response.status, category: 'provider', acceptance: 'unknown', retryable: response.status === 502
43
+ });
44
+ }
45
+ return musicArtifactsFromResponse(body);
46
+ }
@@ -12,6 +12,7 @@ export async function runOcrRequest(host, { providers = [], model, image, prepar
12
12
  const settings = normalizeOcrSettings({ chunkHeight, overlap, maxDimension, maxInvocations });
13
13
  const operation = createModelOperation({ signal, budget: { timeoutMs: budget.timeoutMs, maxInvocations: settings.maxInvocations } });
14
14
  try {
15
+ if (operation.signal.aborted) throw new ModelPortError('IRIS_MODEL_ABORTED');
15
16
  const inputs = new Map();
16
17
  const candidates = buildVisionModelCandidates(host, { providers, model, onRateLimit, prepareImages: true,
17
18
  prepareImageRequest: createVisionInputObserver(inputs) });