@mokuyoaxis/dsh-iris 0.2.3 → 0.2.5

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 (45) hide show
  1. package/.dsh/skills/iris-compose-media/references/workflows.md +1 -1
  2. package/.dsh/skills/iris-use/SKILL.md +2 -1
  3. package/.dsh/skills/iris-use/references/media-contract.md +12 -2
  4. package/CHANGELOG.md +27 -1
  5. package/README.en.md +34 -44
  6. package/README.md +34 -44
  7. package/bin/dsh-iris.js +8 -0
  8. package/docs/AGENT_QUICKSTART.md +11 -13
  9. package/docs/ARCHITECTURE.md +3 -1
  10. package/docs/CLI_MANAGEMENT.md +11 -3
  11. package/docs/DSH_COMPATIBILITY.md +69 -0
  12. package/docs/DSH_RC2_ADAPTATION.md +4 -2
  13. package/docs/FUNCTION_MODEL_ASSIGNMENTS.md +63 -0
  14. package/docs/HEADLESS_CLI.md +10 -8
  15. package/docs/HOST_ADAPTER_CONTRACT.md +11 -7
  16. package/docs/HOST_DOCTOR.md +5 -3
  17. package/docs/IMAGE_IMPORT.md +49 -0
  18. package/docs/ROADMAP.md +6 -2
  19. package/docs/VISION_CLI.md +3 -3
  20. package/docs/VISION_MODEL.md +1 -1
  21. package/docs/WORKBENCH_ARTIFACTS.md +4 -2
  22. package/docs/releases/0.2.4.md +27 -0
  23. package/docs/releases/0.2.5.md +33 -0
  24. package/lib/actions.js +32 -12
  25. package/lib/adapters.js +5 -2
  26. package/lib/client.js +28 -29
  27. package/lib/command-service.js +16 -1
  28. package/lib/config.js +16 -22
  29. package/lib/doctor.js +4 -10
  30. package/lib/dsh-compatibility.js +16 -0
  31. package/lib/dsh-core-adapter.js +7 -1
  32. package/lib/dsh-host-adapter.js +22 -29
  33. package/lib/host-runtime.js +1 -0
  34. package/lib/image-edit-input.js +7 -5
  35. package/lib/image-import.js +61 -0
  36. package/lib/index.js +39 -15
  37. package/lib/models.js +9 -3
  38. package/lib/ocr-model-routing.js +1 -0
  39. package/lib/provider-adapters.js +11 -2
  40. package/lib/provider-catalog.js +6 -8
  41. package/lib/provider-config-service.js +5 -5
  42. package/lib/provider-protocol.js +6 -0
  43. package/lib/vision-model-routing.js +22 -8
  44. package/package.json +9 -8
  45. package/user_guide.md +27 -26
@@ -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
@@ -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
  },
@@ -468,7 +492,7 @@ export async function apply(ctx) {
468
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.',
469
493
  parameters: { type: 'object', properties: {
470
494
  prompt: { type: 'string', description: 'Describe the music, including any lyrics, vocals, mood or intended duration' },
471
- model: { type: 'string', description: 'Assigned providerId::modelId reference' },
495
+ model: { type: 'string', description: 'Optional assigned providerId::modelId; selects only this item' },
472
496
  timeout_ms: { type: 'integer', description: 'Per-attempt timeout; default 300000 ms' },
473
497
  retry_502: { type: 'integer', description: '502 regeneration retries, 0–5; default 2; each retry consumes another request' },
474
498
  retry_delay_ms: { type: 'integer', description: 'Delay between 502 retries; default 5000 ms' }
@@ -491,7 +515,7 @@ export async function apply(ctx) {
491
515
  properties: {
492
516
  text: { type: 'string', description: 'Text to speak' },
493
517
  voice: { type: 'string', description: "Voice name, e.g. 'Cherry' (DashScope qwen-tts voices)" },
494
- 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)' }
495
519
  },
496
520
  required: ['text']
497
521
  },
@@ -528,7 +552,7 @@ export async function apply(ctx) {
528
552
  type: 'object',
529
553
  properties: {
530
554
  audio_path: { type: 'string', description: 'Absolute path to the audio file (wav/mp3)' },
531
- 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)' }
532
556
  },
533
557
  required: ['audio_path']
534
558
  },
@@ -640,7 +664,7 @@ export async function apply(ctx) {
640
664
  target_width: { type: 'integer', description: 'Frame width for sampling (default 640)' },
641
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.' },
642
666
  transcribe_text: { type: 'string', description: 'Optional existing transcript text; does not create a transcription task' },
643
- 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' }
644
668
  },
645
669
  required: []
646
670
  },
@@ -702,7 +726,7 @@ export async function apply(ctx) {
702
726
  name: `iris-sheet-${Date.now()}.png`
703
727
  });
704
728
  if (exec.signal?.aborted) throw exec.signal.reason;
705
- const viaLabel = via === 'selfstack' ? 'iris 自持栈' : 'DSH 全局视觉模型';
729
+ const viaLabel = via === 'selfstack' ? 'iris 自持栈' : 'DSH 当前默认模型';
706
730
  return {
707
731
  frames: summaryFrameRecords(frames),
708
732
  blocks: [
@@ -731,7 +755,7 @@ export async function apply(ctx) {
731
755
  image_path: { type: 'string', description: 'Absolute path to the image file (png/jpg/jpeg/webp)' },
732
756
  artifact_id: { type: 'string', description: 'Existing Core image Artifact ID in the current profile (alternative to image_path)' },
733
757
  question: { type: 'string', description: 'Question or extraction request about the image; defaults to a detailed description' },
734
- 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' }
735
759
  },
736
760
  required: []
737
761
  },
@@ -769,7 +793,7 @@ export async function apply(ctx) {
769
793
  properties: {
770
794
  attachment_id: { type: 'string', description: 'The attachment_id of an image that appeared in this session or was generated by iris' },
771
795
  question: { type: 'string', description: 'New question about the image pixels' },
772
- 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' }
773
797
  },
774
798
  required: ['attachment_id', 'question']
775
799
  },
@@ -934,7 +958,7 @@ export async function apply(ctx) {
934
958
  artifact_id: { type: 'string', description: 'Existing Core image Artifact ID in the current profile (alternative to image_path or attachment_id)' },
935
959
  attachment_id: { type: 'string', description: 'Session attachment id of an image (alternative to image_path)' },
936
960
  target: { type: 'string', description: 'The object or UI element to locate, e.g. "send button"' },
937
- 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' }
938
962
  },
939
963
  required: ['target']
940
964
  },
@@ -1041,7 +1065,7 @@ export async function apply(ctx) {
1041
1065
  }
1042
1066
  });
1043
1067
 
1044
- console.log('[iris] tools registered: 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');
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');
1045
1069
  };
1046
1070
 
1047
1071
  /* ---------------- 视觉路由(M3 补全):look/relook 共用执行器 + 降级链 ---------------- */
@@ -1151,7 +1175,7 @@ export async function sessionAttachmentRef(host, exec, attachmentId) {
1151
1175
  /**
1152
1176
  * 看图问答(阶段 1:VisionBackend 降级链)。
1153
1177
  * 只取声明/推断出 vision 能力的自持栈 provider(严格选择),按序组装后端链,
1154
- * 全局视觉模型兜底;每个后端的失败现场保留在 errors 里。
1178
+ * 仅使用视觉功能的明确分配;宿主默认模型也需显式加入,每个后端失败保留在errors里。
1155
1179
  * @returns {{answer:string,via:'selfstack'|'global',model?:string,backendId:string}}
1156
1180
  */
1157
1181
  export async function askVision(host, { question, dataUrl, image, prepareImage, signal, model, budget }) {
@@ -1174,6 +1198,6 @@ export async function runVisionTool(host, exec, { origin, model, question, dataU
1174
1198
  model
1175
1199
  });
1176
1200
  const label = origin === 'relook' ? '重看回答' : '看图回答';
1177
- const viaLabel = via === 'selfstack' ? 'iris 自持栈' : 'DSH 全局视觉模型';
1201
+ const viaLabel = via === 'selfstack' ? 'iris 自持栈' : 'DSH 当前默认模型';
1178
1202
  return `[iris] ${label}(${usedModel || '默认'} · ${viaLabel}):\n${answer}` + (artifactId ? `\nCore Artifact: ${artifactId}` : '');
1179
1203
  }
package/lib/models.js CHANGED
@@ -26,6 +26,11 @@ export const CAP_FIELD = {
26
26
 
27
27
  const MODEL_REF_SEP = '::';
28
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
+
29
34
  /** 稳定的模型复合引用;provider/model 分别编码,避免同名模型跨账号碰撞。 */
30
35
  export function modelRef(providerId, modelId) {
31
36
  return encodeURIComponent(String(providerId || '')) + MODEL_REF_SEP + encodeURIComponent(String(modelId || ''));
@@ -142,6 +147,7 @@ export function resolveModelRef(provider, capability, requested = '') {
142
147
 
143
148
  /** 共享旧分配对象、裸名称和复合引用的解析,池顺序决定同名匹配。 */
144
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;
145
151
  let providerId = '';
146
152
  let modelId = '';
147
153
  if (raw && typeof raw === 'object') {
@@ -156,7 +162,7 @@ export function resolvePoolModel(pool, raw, capability) {
156
162
  && m.capabilities.includes(capability)) || null;
157
163
  }
158
164
 
159
- /** DSH 与 CLI 共用的候选顺序:分配优先、按池顺序补齐并去重。 */
165
+ /** DSH 与 CLI 共用的执行名单:只按明确分配排序并去重。 */
160
166
  export function orderedModels(pool, capability, assignments, { includeRateLimited = false } = {}) {
161
167
  const ordered = [];
162
168
  const seen = new Set();
@@ -170,7 +176,6 @@ export function orderedModels(pool, capability, assignments, { includeRateLimite
170
176
  for (const raw of (Array.isArray(assignments) ? assignments : (assignments ? [assignments] : []))) {
171
177
  add(resolvePoolModel(pool, raw, capability));
172
178
  }
173
- if (capability !== CAPABILITIES.MUSIC) for (const model of pool) add(model);
174
179
  return ordered;
175
180
  }
176
181
 
@@ -192,9 +197,10 @@ export function selectionReasonForModel(pool, capability, assignments, modelOrRe
192
197
  }
193
198
 
194
199
  /** 全局模型池:合并所有 provider 的模型 */
195
- export function modelPool(providers) {
200
+ export function modelPool(providers, { includeHostVision = false } = {}) {
196
201
  const pool = [];
197
202
  for (const p of providers || []) pool.push(...providerModels(p));
203
+ if (includeHostVision) pool.push(HOST_VISION_MODEL);
198
204
  return pool;
199
205
  }
200
206
 
@@ -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) });
@@ -10,7 +10,7 @@ import * as defaultTransport from './adapters.js';
10
10
  import { ProviderContractError, providerErrorRecord } from './provider-contract.js';
11
11
  import { writePrivateFile } from './private-storage.js';
12
12
  import { defineProviderAdapter } from './provider-adapter.js';
13
- import { selectMediaProtocol, providerApiKey, providerMediaBaseUrl, unsupportedProtocolError } from './provider-protocol.js';
13
+ import { selectMediaProtocol, providerApiKey, providerMediaBaseUrl, unsupportedProtocolError, supportsImageEdit } from './provider-protocol.js';
14
14
  import { modelRateLimit, reportModelRateLimit, isFreeQuotaExhausted } from './provider-health.js';
15
15
  import { prepareVisionImage, visionInputLimits } from './vision-image-input.js';
16
16
 
@@ -132,6 +132,15 @@ function operationsForDashScope(provider, transport) {
132
132
  ...(request.timeoutMs ? { timeoutMs: request.timeoutMs } : {})
133
133
  };
134
134
  if (request.capability === 'image') {
135
+ let image;
136
+ if (input.image) {
137
+ if (!supportsImageEdit('dashscope', request.model)) throw new ProviderContractError('该 DashScope 模型尚未接入原生改图', {
138
+ stage: 'validate', category: 'invalid_request', acceptance: 'not_accepted'
139
+ });
140
+ const limits = visionInputLimits(provider, request.model);
141
+ image = await prepareVisionImage(input.image, { ...limits, maxBytes: Math.min(limits.maxBytes, 10 * 1024 * 1024) },
142
+ { signal: request.signal, orient: true });
143
+ }
135
144
  if (transport.dashscopeImageMode(request.model) === 'legacy-async') {
136
145
  const remoteTaskId = await transport.submitImage({
137
146
  ...common, prompt: input.prompt, size: input.size, n: input.n
@@ -139,7 +148,7 @@ function operationsForDashScope(provider, transport) {
139
148
  return { kind: 'accepted', remoteTaskId };
140
149
  }
141
150
  const urls = await transport.generateImageMultimodal({
142
- ...common, prompt: input.prompt, size: input.size, n: input.n
151
+ ...common, prompt: input.prompt, size: input.size, n: input.n, ...(image ? { image } : {})
143
152
  });
144
153
  return {
145
154
  kind: 'completed', value: { kind: 'urls', items: urls },
@@ -58,20 +58,19 @@ const CANDIDATE_CAP_LABEL = Object.freeze({
58
58
  [CAPABILITIES.MUSIC]: '音乐'
59
59
  });
60
60
 
61
- /** 与稳定配置层相同的“手工顺序优先、池序补齐”候选规则。 */
61
+ /** 与 DSH 相同的严格功能名单;CLI 不附加宿主资源。 */
62
62
  function candidatesFromCatalog(catalog, requestedModelRef, capability) {
63
63
  const label = CANDIDATE_CAP_LABEL[capability];
64
64
  if (!label) fail('IRIS_PROVIDER_TASK_OBSERVE_UNSUPPORTED', '当前 Headless 候选链只开放图片、视频、语音与转写能力');
65
65
  const providers = catalog.providers.filter(isConfiguredProvider);
66
66
  const configuredPool = modelPool(providers).filter((item) => item.capabilities.includes(capability));
67
- const pool = capability === CAPABILITIES.MUSIC
68
- ? orderedModels(configuredPool, capability, catalog.assignments?.[capability], { includeRateLimited: true }) : configuredPool;
67
+ const pool = orderedModels(configuredPool, capability, catalog.assignments?.[capability], { includeRateLimited: true });
69
68
  const requested = String(requestedModelRef || '').trim();
70
69
  if (requested) {
71
70
  const parsed = parseModelRef(requested);
72
71
  if (!parsed && requested.includes('::')) fail('IRIS_PROVIDER_MODEL_INVALID', 'model_ref 格式无效');
73
72
  const exact = resolvePoolModel(pool, requested, capability);
74
- if (!exact) fail('IRIS_PROVIDER_MODEL_UNAVAILABLE', `指定模型不存在、未启用或没有${label}能力`);
73
+ if (!exact) fail('IRIS_PROVIDER_MODEL_UNAVAILABLE', `指定模型未分配给${label}功能、未启用或没有所需能力`);
75
74
  const provider = providers.find((item) => item.id === exact.providerId);
76
75
  if (exact.rateLimited) fail('IRIS_PROVIDER_MODEL_RATE_LIMITED', modelRateLimit(provider, exact.id)?.until
77
76
  ? '指定模型正在限流冷却,请稍后使用或手动实测'
@@ -82,14 +81,13 @@ function candidatesFromCatalog(catalog, requestedModelRef, capability) {
82
81
  })]);
83
82
  }
84
83
  const assignment = catalog.assignments?.[capability];
85
- const ordered = orderedModels(pool, capability, assignment);
86
- if (!ordered.length) fail('IRIS_PROVIDER_MODEL_UNAVAILABLE', capability === CAPABILITIES.MUSIC
87
- ? '请先给 Music 功能分配可用音乐模型' : `provider-config 中没有可用${label}模型`);
84
+ const ordered = pool.filter(model => !model.rateLimited);
85
+ if (!ordered.length) fail('IRIS_PROVIDER_MODEL_UNAVAILABLE', `请先给${label}功能分配当前入口可用的模型`);
88
86
  return Object.freeze(ordered.map((item) => Object.freeze({
89
87
  provider: providers.find((provider) => provider.id === item.providerId),
90
88
  model: item.id,
91
89
  modelRef: item.ref,
92
- selectionReason: selectionReasonForModel(pool, capability, assignment, item)
90
+ selectionReason: selectionReasonForModel(configuredPool, capability, assignment, item)
93
91
  })));
94
92
  }
95
93
 
@@ -3,7 +3,7 @@ import path from 'node:path';
3
3
  import crypto from 'node:crypto';
4
4
  import { loadProviderCatalog, providerCatalogSnapshot, catalogCapabilitySnapshot } from './provider-catalog.js';
5
5
  import { isKnownCapability } from './capability.js';
6
- import { providerModels, modelPool, parseModelRef, capabilitiesOfDiscoveredModel, hasConfiguredModelCapabilities, resolvePoolModel } from './models.js';
6
+ import { providerModels, modelPool, parseModelRef, HOST_VISION_REF, capabilitiesOfDiscoveredModel, hasConfiguredModelCapabilities, resolvePoolModel } from './models.js';
7
7
  import { atomicWritePrivate, privateSibling, ensurePrivateDir } from './private-storage.js';
8
8
  import { createConfiguredProviderAdapter } from './provider-adapters.js';
9
9
  import { selectImageProtocol, normalizeImageProtocol, normalizeMusicProtocol } from './provider-protocol.js';
@@ -22,7 +22,7 @@ const caps = value => {
22
22
  };
23
23
 
24
24
  function assignmentSnapshot(catalog) {
25
- const pool = modelPool(catalog.providers);
25
+ const pool = modelPool(catalog.providers, { includeHostVision: true });
26
26
  return Object.fromEntries(Object.entries(catalog.assignments || {}).map(([capability, value]) => {
27
27
  const refs = (Array.isArray(value) ? value : [value]).map(ref => resolvePoolModel(pool, ref, capability));
28
28
  return [safe(capability), { model_refs: refs.filter(Boolean).map(model => safe(model.ref)), invalidRefs: refs.filter(ref => !ref).length }];
@@ -59,7 +59,7 @@ export async function executeConfigCommand(configPath, command, input = {}, { si
59
59
  const issues = [];
60
60
  const ids = catalog.providers.map(provider => provider.id);
61
61
  if (new Set(ids).size !== ids.length) issues.push('duplicate_provider_id');
62
- const pool = modelPool(catalog.providers);
62
+ const pool = modelPool(catalog.providers, { includeHostVision: true });
63
63
  for (const provider of catalog.providers) {
64
64
  try { normalizeVisionInputLimits(provider.visionInput); } catch (_) { issues.push('invalid_vision_input'); }
65
65
  for (const model of provider.models || []) {
@@ -201,8 +201,8 @@ export async function executeConfigCommand(configPath, command, input = {}, { si
201
201
  if (command === 'assignments.clear') delete catalog.assignments[input.capability];
202
202
  else {
203
203
  if (!Array.isArray(input.model_refs) || new Set(input.model_refs).size !== input.model_refs.length) fail('model_refs 必须是不重复的复合引用数组');
204
- const pool = modelPool(catalog.providers);
205
- for (const ref of input.model_refs) if (!parseModelRef(ref) || !resolvePoolModel(pool, ref, input.capability)) fail('分配模型不存在或没有该能力');
204
+ const pool = modelPool(catalog.providers, { includeHostVision: true });
205
+ for (const ref of input.model_refs) if ((!parseModelRef(ref) && ref !== HOST_VISION_REF) || !resolvePoolModel(pool, ref, input.capability)) fail('分配模型不存在或没有该能力');
206
206
  catalog.assignments[input.capability] = input.model_refs;
207
207
  }
208
208
  } else fail('未知配置命令');
@@ -63,6 +63,12 @@ export function selectImageProtocol(provider, modelId) {
63
63
  return protocol === 'auto' ? selectMediaProtocol(provider) : { mediaProtocol: protocol, protocolInferred: false };
64
64
  }
65
65
 
66
+ /** 已接入的带图编辑协议;这里只判断传输,不授予模型 image-gen 能力。 */
67
+ export function supportsImageEdit(protocol, modelId) {
68
+ return protocol === 'openai-chat-images' || (protocol === 'dashscope'
69
+ && /^qwen-image-(?:edit(?:-(?:max|plus))?|2\.0(?:-pro)?)(?:-\d{4}-\d{2}-\d{2})?$/.test(String(modelId || '')));
70
+ }
71
+
66
72
  /** 给已绑定图片模型的 Adapter/binding 投影协议,不改写账号默认值。 */
67
73
  export function providerForImageModel(provider, modelId) {
68
74
  return { ...provider, ...selectImageProtocol(provider, modelId) };
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
  /** Host 边界:显式配置选型、字节解析和结果投影。 */
3
- import { resolveModelRef, parseModelRef } from './models.js';
3
+ import { resolveModelRef, parseModelRef, HOST_VISION_REF } from './models.js';
4
4
  import { providerApiKey } from './provider-protocol.js';
5
5
  import { createHttpVisionModelPort } from './http-vision-model-adapter.js';
6
6
  import { prepareDshVisionModelPort } from './dsh-vision-model-adapter.js';
@@ -79,18 +79,31 @@ export function createConfiguredVisionModelPort(provider, model, { allowRateLimi
79
79
  export function buildVisionModelCandidates(host, { providers = [], model, allowRateLimited = false, onRateLimit, prepareImages = false,
80
80
  orientImages = false, prepareImageRequest } = {}) {
81
81
  const candidates = [], seen = new Set();
82
- for (const provider of providers.filter(Boolean)) {
83
- const ref = resolveModelRef(provider, 'vision', model);
82
+ let selected = providers.filter(Boolean);
83
+ const requested = String(model || '').trim();
84
+ if (requested) {
85
+ const parsed = parseModelRef(requested);
86
+ const provider = selected.find(p => p.id === HOST_VISION_REF ? requested === HOST_VISION_REF
87
+ : parsed ? parsed.providerId === p.id && parsed.modelId === p.visionModel : requested === p.visionModel);
88
+ if (!provider) throw new ModelPortError('IRIS_MODEL_UNAVAILABLE', { stage: 'prepare', invocation: 'not_invoked' });
89
+ selected = [provider];
90
+ }
91
+ if (!selected.length) throw new ModelPortError('IRIS_MODEL_UNAVAILABLE', { stage: 'prepare', invocation: 'not_invoked' });
92
+ for (const provider of selected) {
93
+ if (provider.id === HOST_VISION_REF) {
94
+ const hostCandidate = deferredDshPort(host, { prepareImages, orientImages, prepareImageRequest });
95
+ candidates.push({ ...hostCandidate, id: 'dsh-vision:default', via: 'global', backendId: 'dsh-global', model: 'DSH 当前默认模型' });
96
+ continue;
97
+ }
98
+ const ref = resolveModelRef(provider, 'vision', provider.visionModel);
84
99
  const parsed = parseModelRef(ref);
85
100
  // 显式模型会把同账号的多份池记录解析为同一复合引用,只保留首次候选。
86
101
  if (parsed && seen.has(ref)) continue;
87
102
  if (parsed) seen.add(ref);
88
- const port = createConfiguredVisionModelPort(provider, model, { allowRateLimited, onRateLimit, prepareImages, orientImages, prepareImageRequest });
103
+ const port = createConfiguredVisionModelPort(provider, provider.visionModel, { allowRateLimited, onRateLimit, prepareImages, orientImages, prepareImageRequest });
89
104
  const id = port.describe().identity.backendId;
90
105
  candidates.push({ port, id, via: 'selfstack', backendId: provider.id, model: parsed?.modelId || '' });
91
106
  }
92
- const hostCandidate = deferredDshPort(host, { prepareImages, orientImages, prepareImageRequest });
93
- candidates.push({ ...hostCandidate, id: 'dsh-vision:default', via: 'global', backendId: 'dsh-global', model: '宿主视觉模型' });
94
107
  return candidates;
95
108
  }
96
109
 
@@ -137,6 +150,7 @@ function projectError(record, candidates) {
137
150
  /** prepareImage 的信号覆盖文件/会话读取;函数开始时即建立整体预算。 */
138
151
  export async function runVisionOperation(host, { providers, model, signal, budget = VISION_BUDGET, onRateLimit, prepareImages = false,
139
152
  orientImages = false, prepareImageRequest }, consume) {
153
+ if (signal?.aborted) throw new ModelPortError('IRIS_MODEL_ABORTED');
140
154
  const candidates = buildVisionModelCandidates(host, { providers, model, onRateLimit, prepareImages, orientImages, prepareImageRequest });
141
155
  const operation = createModelOperation({ signal, budget: { timeoutMs: budget.timeoutMs, maxInvocations: candidates.length } });
142
156
  try {
@@ -167,8 +181,8 @@ export async function runVisionRequest(host, { providers, model, question, image
167
181
  /** 付费确认在动作入口;红图实测只有一个供应商,不退到 Host 冒充其能力。 */
168
182
  export async function probeVisionModel(provider, model, image, { signal, timeoutMs = 15000 } = {}) {
169
183
  try {
170
- const [candidate] = buildVisionModelCandidates(null, { providers: [provider], model, allowRateLimited: true });
171
- const result = await completeVision([candidate.port], { prompt: '这张图片是什么颜色?请只回答颜色名称(中文)。', image },
184
+ const port = createConfiguredVisionModelPort(provider, model, { allowRateLimited: true });
185
+ const result = await completeVision([port], { prompt: '这张图片是什么颜色?请只回答颜色名称(中文)。', image },
172
186
  { signal, budget: { ...VISION_BUDGET, timeoutMs } });
173
187
  return { ok: /红|red/i.test(result.text), answer: result.text };
174
188
  } catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mokuyoaxis/dsh-iris",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "Multi-provider image, video, music, and speech generation, transcription, visual understanding, and prompt optimization with an integrated Iris workbench for DeepSeek Harness.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -34,7 +34,7 @@
34
34
  ],
35
35
  "dsh": {
36
36
  "engines": {
37
- "dsh": "0.2.0-rc.2"
37
+ "dsh": "0.1.2-rc.1 || 0.1.5-rc.1 || 0.1.5-rc.2 || 0.1.5-rc.3 || 0.1.7-rc.1 || 0.1.7-rc.2 || 0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-alpha.1 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-alpha.4"
38
38
  },
39
39
  "bundle": {
40
40
  "patch": "./cordis.patch.yml"
@@ -65,7 +65,8 @@
65
65
  ],
66
66
  "license": "MIT",
67
67
  "engines": {
68
- "node": ">=22.0.0"
68
+ "node": ">=22.0.0",
69
+ "dsh": "0.1.2-rc.1 || 0.1.5-rc.1 || 0.1.5-rc.2 || 0.1.5-rc.3 || 0.1.7-rc.1 || 0.1.7-rc.2 || 0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-alpha.1 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-alpha.4"
69
70
  },
70
71
  "scripts": {
71
72
  "test": "node scripts/test.mjs",
@@ -73,11 +74,11 @@
73
74
  "prepublishOnly": "npm test"
74
75
  },
75
76
  "peerDependencies": {
76
- "@deepseek-ai/dsh-client-locale": "0.2.0-rc.2",
77
- "@deepseek-ai/dsh-client-ui-conversation": "0.2.0-rc.2",
78
- "@deepseek-ai/dsh-client-ui-renderer": "0.2.0-rc.2",
79
- "@deepseek-ai/dsh-client-ui-session": "0.2.0-rc.2",
80
- "@deepseek-ai/dsh-client-ui-settings": "0.2.0-rc.2"
77
+ "@deepseek-ai/dsh-client-locale": "0.1.2-rc.1 || 0.1.5-rc.1 || 0.1.5-rc.2 || 0.1.5-rc.3 || 0.1.7-rc.1 || 0.1.7-rc.2 || 0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-alpha.1 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-alpha.4",
78
+ "@deepseek-ai/dsh-client-ui-conversation": "0.1.2-rc.1 || 0.1.5-rc.1 || 0.1.5-rc.2 || 0.1.5-rc.3 || 0.1.7-rc.1 || 0.1.7-rc.2 || 0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-alpha.1 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-alpha.4",
79
+ "@deepseek-ai/dsh-client-ui-renderer": "0.1.2-rc.1 || 0.1.5-rc.1 || 0.1.5-rc.2 || 0.1.5-rc.3 || 0.1.7-rc.1 || 0.1.7-rc.2 || 0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-alpha.1 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-alpha.4",
80
+ "@deepseek-ai/dsh-client-ui-session": "0.1.2-rc.1 || 0.1.5-rc.1 || 0.1.5-rc.2 || 0.1.5-rc.3 || 0.1.7-rc.1 || 0.1.7-rc.2 || 0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-alpha.1 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-alpha.4",
81
+ "@deepseek-ai/dsh-client-ui-settings": "0.1.2-rc.1 || 0.1.5-rc.1 || 0.1.5-rc.2 || 0.1.5-rc.3 || 0.1.7-rc.1 || 0.1.7-rc.2 || 0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-alpha.1 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-alpha.4"
81
82
  },
82
83
  "peerDependenciesMeta": {
83
84
  "@deepseek-ai/dsh-client-locale": {