@mokuyoaxis/dsh-iris 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/.dsh/skills/iris-compose-media/SKILL.md +10 -5
  2. package/.dsh/skills/iris-compose-media/references/workflows.md +7 -1
  3. package/.dsh/skills/iris-verify-ui/SKILL.md +2 -1
  4. package/CHANGELOG.md +69 -2
  5. package/README.en.md +67 -31
  6. package/README.md +67 -31
  7. package/bin/dsh-iris.js +222 -21
  8. package/docs/AGENT_QUICKSTART.md +10 -6
  9. package/docs/ARCHITECTURE.md +34 -19
  10. package/docs/ARTIFACT_LIBRARY.md +5 -5
  11. package/docs/ARTIFACT_MANIFEST.md +1 -1
  12. package/docs/CLI_MANAGEMENT.md +183 -0
  13. package/docs/COMPOSITE_VISION.md +35 -0
  14. package/docs/CORE_RUNTIME_CONTRACT.md +16 -7
  15. package/docs/DSH_CORE_MIGRATION.md +48 -19
  16. package/docs/DSH_RC2_ADAPTATION.md +4 -2
  17. package/docs/FAULT_INJECTION.md +21 -2
  18. package/docs/HEADLESS_CLI.md +15 -7
  19. package/docs/HOST_ADAPTER_CONTRACT.md +10 -10
  20. package/docs/HOST_DOCTOR.md +3 -3
  21. package/docs/MODEL_PORT_CONTRACT.md +21 -14
  22. package/docs/OCR_MODEL.md +39 -0
  23. package/docs/PROMPT_OPTIMIZER.md +77 -0
  24. package/docs/PROVIDER_ADAPTER_CONTRACT.md +15 -5
  25. package/docs/PROVIDER_HEALTH.md +22 -5
  26. package/docs/PROVIDER_RUNTIME_HARNESS.md +1 -1
  27. package/docs/PROVIDER_SUBMISSION_CONTRACT.md +2 -2
  28. package/docs/ROADMAP.md +37 -19
  29. package/docs/SECURITY.md +5 -5
  30. package/docs/TASK_SEMANTICS.md +2 -2
  31. package/docs/VISION_CLI.md +132 -0
  32. package/docs/VISION_MODEL.md +51 -0
  33. package/docs/WORKBENCH_ARTIFACTS.md +67 -0
  34. package/docs/file-access-across-environments.md +8 -5
  35. package/docs/releases/0.2.1.md +56 -0
  36. package/docs/releases/0.2.2.md +31 -0
  37. package/lib/actions.js +199 -286
  38. package/lib/adapters.js +216 -41
  39. package/lib/api.js +49 -7
  40. package/lib/capability.js +23 -16
  41. package/lib/chromium-browser.js +117 -0
  42. package/lib/client.js +933 -117
  43. package/lib/command-service.js +121 -7
  44. package/lib/composite-vision-routing.js +42 -0
  45. package/lib/config.js +102 -14
  46. package/lib/core-artifact-store.js +45 -13
  47. package/lib/core-lease-recovery.js +64 -92
  48. package/lib/core-maintenance.js +213 -0
  49. package/lib/core-runtime.js +45 -47
  50. package/lib/core-tasks.js +12 -5
  51. package/lib/core-user-projection.js +1 -0
  52. package/lib/core-writer-lease.js +134 -0
  53. package/lib/dsh-core-adapter.js +63 -9
  54. package/lib/dsh-text-model-adapter.js +222 -0
  55. package/lib/dsh-vision-model-adapter.js +75 -0
  56. package/lib/generation-input.js +36 -5
  57. package/lib/headless-vision.js +181 -0
  58. package/lib/host-runtime.js +1 -0
  59. package/lib/http-vision-model-adapter.js +187 -0
  60. package/lib/image-edit-input.js +34 -0
  61. package/lib/index.js +205 -175
  62. package/lib/locate.js +86 -28
  63. package/lib/media-probe.js +2 -1
  64. package/lib/model-call-runtime.js +45 -0
  65. package/lib/model-invoker.js +4 -2
  66. package/lib/model-port-contract.js +13 -5
  67. package/lib/models.js +29 -42
  68. package/lib/ocr-model-routing.js +26 -0
  69. package/lib/ocr.js +110 -64
  70. package/lib/prompt-optimizer-config.js +21 -10
  71. package/lib/prompt-optimizer-core.js +247 -0
  72. package/lib/prompt-optimizer.js +37 -193
  73. package/lib/provider-adapters.js +87 -10
  74. package/lib/provider-catalog.js +19 -7
  75. package/lib/provider-config-service.js +235 -0
  76. package/lib/provider-contract.js +3 -2
  77. package/lib/provider-health.js +80 -1
  78. package/lib/provider-protocol.js +84 -0
  79. package/lib/provider-task-runner.js +63 -19
  80. package/lib/summarize.js +31 -20
  81. package/lib/summary-input.js +52 -0
  82. package/lib/task-wait.js +40 -0
  83. package/lib/video-input.js +30 -0
  84. package/lib/vision-core.js +42 -0
  85. package/lib/vision-image-input.js +83 -0
  86. package/lib/vision-input.js +31 -0
  87. package/lib/vision-model-routing.js +180 -0
  88. package/lib/vision.js +3 -2
  89. package/lib/workbench-artifacts.js +52 -0
  90. package/lib/workbench-management.js +151 -0
  91. package/lib/workbench-zip.js +39 -0
  92. package/package.json +1 -1
  93. package/user_guide.md +74 -30
package/lib/adapters.js CHANGED
@@ -3,14 +3,18 @@
3
3
  * Iris 媒体生成后端 —— 多协议适配器。
4
4
  * - dashscope:阿里云百炼(图像/视频异步任务 + qwen-tts 同步)
5
5
  * - openai-images:OpenAI Images 兼容协议(generations)
6
+ * - openai-chat-images:网关 Chat Completions 生图扩展(非流式)
7
+ * - openai-responses-images:Responses image_generation 工具及网关图片消息(同步)
6
8
  * 所有函数只收显式 key;错误统一 throw Error(人话)。
7
9
  */
8
10
  import fs from 'node:fs';
9
11
  import path from 'node:path';
10
12
  import { Readable } from 'node:stream';
11
13
  import { pipeline } from 'node:stream/promises';
12
- import { dashscopeApiBase, isDashScopeBaseUrl } from './provider-protocol.js';
14
+ import { dashscopeApiBase, imageSizeViolation, isDashScopeBaseUrl, normalizeImageSize } from './provider-protocol.js';
13
15
  import { chmodPrivateFile, ensurePrivateDir, privateSibling } from './private-storage.js';
16
+ import { isFreeQuotaExhausted, retryAfterDelay } from './provider-health.js';
17
+ import { ProviderContractError } from './provider-contract.js';
14
18
 
15
19
  /**
16
20
  * 网络调用超时档位(2026-09-03 健康检查):挂死的连接绝不能冻住盯守 tick——
@@ -53,26 +57,31 @@ async function readJson(res) {
53
57
  * message 保持人话(向后兼容 String(err.message))。
54
58
  */
55
59
  export class ProviderError extends Error {
56
- constructor({ category, status, message, rootCause }) {
60
+ constructor({ category, status, message, rootCause, providerCode, retryAfterMs }) {
57
61
  super(message);
58
62
  this.name = 'ProviderError';
59
63
  this.category = category;
60
64
  this.status = status;
61
65
  this.rootCause = rootCause;
66
+ this.providerCode = providerCode;
67
+ this.retryAfterMs = retryAfterMs;
62
68
  }
63
69
  }
64
70
 
65
- function classifyHttpError(status, body, fallback) {
66
- const msg = (body && (body.message || (body.output && body.output.message))) || (body && body.code) || `HTTP ${status}`;
67
- const code = String((body && body.code) || '');
71
+ function classifyHttpError(status, body, fallback, headers) {
72
+ const msg = body?.message || body?.output?.message || body?.error?.message || body?.code || `HTTP ${status}`;
73
+ const code = String(body?.code || body?.error?.code || '');
68
74
  let category = 'unknown';
69
75
  let text;
70
- if (status === 401 || status === 403) {
76
+ if (isFreeQuotaExhausted({ status, code, message: msg })) {
77
+ category = 'quota';
78
+ text = `免费额度耗尽:${msg}`;
79
+ } else if (status === 401 || status === 403) {
71
80
  category = 'auth';
72
81
  text = `鉴权失败:${msg}`;
73
82
  } else if (status === 429) {
74
- category = /quota|insufficient|balance|limit|额度/i.test(msg + ' ' + code) ? 'quota' : 'rate_limit';
75
- text = `限流或额度不足:${msg}`;
83
+ category = code === 'BudgetLimitExceeded' ? 'quota' : 'rate_limit';
84
+ text = `${category === 'quota' ? '预算额度耗尽' : '请求限流'}:${msg}`;
76
85
  } else if (/InvalidParameter|NotFound\.Model|unsupported/i.test(code)) {
77
86
  category = 'invalid_parameter';
78
87
  text = `模型或参数不受支持:${msg}`;
@@ -82,22 +91,71 @@ function classifyHttpError(status, body, fallback) {
82
91
  } else {
83
92
  text = `${fallback}:${msg}`;
84
93
  }
85
- return new ProviderError({ category, status, message: text, rootCause: msg });
94
+ return new ProviderError({ category, status, message: text, rootCause: msg, providerCode: code,
95
+ ...(status === 429 ? { retryAfterMs: retryAfterDelay(headers?.get('retry-after')) } : {}) });
86
96
  }
87
97
 
88
- /** 包装 fetch 层的网络异常(无 HTTP 状态) */
89
- export function networkError(err) {
98
+ /** 包装 fetch 层的网络异常(无 HTTP 状态)。hint 补一句可执行的排查方向。 */
99
+ export function networkError(err, hint) {
100
+ const code = String(err?.cause?.code || err?.code || '');
90
101
  const e = new ProviderError({
91
102
  category: 'network',
92
103
  status: 0,
93
- message: '网络错误:' + String((err && err.message) || err),
94
- rootCause: err
104
+ message: '网络错误:' + String((err && err.message) || err) + (hint ? '(' + hint + ')' : ''),
105
+ rootCause: err,
106
+ ...(/^[A-Z][A-Z0-9_]{1,63}$/.test(code) ? { providerCode: code } : {})
95
107
  });
96
108
  return e;
97
109
  }
98
110
 
99
- function providerError(status, body, fallback) {
100
- return classifyHttpError(status, body, fallback);
111
+ /** 超时/取消必须保持原分类:轮询 errStreak 容错与 submitGuard 都依赖 network 与 timeout 的区分。 */
112
+ function isAbortLike(err) {
113
+ return err?.name === 'AbortError' || err?.name === 'TimeoutError' || err?.code === 'ABORT_ERR';
114
+ }
115
+
116
+ function transportFetchError(err, response) {
117
+ if (isAbortLike(err)) return err;
118
+ const code = String(err?.cause?.code || err?.code || '');
119
+ const detail = {
120
+ ENOTFOUND: 'DNS 未找到目标主机',
121
+ EAI_AGAIN: 'DNS 解析暂时失败',
122
+ ECONNREFUSED: '目标连接被拒绝',
123
+ UND_ERR_CONNECT_TIMEOUT: '连接建立超时',
124
+ UND_ERR_HEADERS_TIMEOUT: '等待响应头超时',
125
+ UND_ERR_BODY_TIMEOUT: '响应正文读取超时',
126
+ ETIMEDOUT: '网络操作超时',
127
+ ECONNRESET: '连接被重置',
128
+ UND_ERR_SOCKET: '底层连接中断',
129
+ CERT_HAS_EXPIRED: 'TLS 证书已过期',
130
+ DEPTH_ZERO_SELF_SIGNED_CERT: 'TLS 证书不受信任',
131
+ UNABLE_TO_VERIFY_LEAF_SIGNATURE: 'TLS 证书链验证失败'
132
+ }[code];
133
+ const phase = response
134
+ ? `已收到 HTTP ${response.status} 响应头,读取响应正文失败`
135
+ : '请求传输失败,尚未收到响应头';
136
+ const error = networkError(err, phase + (detail ? ';' + detail : '') +
137
+ (code && /^[A-Z][A-Z0-9_]{1,63}$/.test(code) ? ';' + code : '') + ';受理结果未知,禁止自动重提');
138
+ if (['UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT', 'UND_ERR_BODY_TIMEOUT', 'ETIMEDOUT'].includes(code)) {
139
+ error.category = 'timeout';
140
+ }
141
+ error.stage = response ? 'response' : 'submit';
142
+ error.acceptance = 'unknown';
143
+ return error;
144
+ }
145
+
146
+ /** 只包装网络读取;HTTP 业务错误和图片协议校验由调用方处理。 */
147
+ async function fetchImageJson(url, options) {
148
+ let response;
149
+ try {
150
+ response = await fetch(url, options);
151
+ return { res: response, body: await readJson(response) };
152
+ } catch (err) {
153
+ throw transportFetchError(err, response);
154
+ }
155
+ }
156
+
157
+ function providerError(status, body, fallback, headers) {
158
+ return classifyHttpError(status, body, fallback, headers);
101
159
  }
102
160
 
103
161
  /* ---------------- DashScope:图像(旧异步 / 新版多模态) ---------------- */
@@ -115,21 +173,35 @@ export function dashscopeImageMode(model) {
115
173
  return 'legacy-async';
116
174
  }
117
175
 
176
+ /**
177
+ * 尺寸在进入请求体前统一「归一 + 校验」:
178
+ * 协议差异(分隔符、数值边界)全部来自 provider-protocol.js,本地违规明确标记为未受理。
179
+ * 归一的意义是跨协议可用 —— 例如把 `1024x1536` 送给 DashScope 多模态(服务端要求 `*`),
180
+ * 或把 `1024*1536` 送给 OpenAI 兼容端点(服务端要求 `x`),都不再需要调用方自己记格式。
181
+ */
182
+ export function applyImageSize({ protocol, mode, size }) {
183
+ const violation = imageSizeViolation({ protocol, mode, size });
184
+ if (violation) throw new ProviderContractError(violation, {
185
+ stage: 'validate', category: 'invalid_request', acceptance: 'not_accepted'
186
+ });
187
+ return normalizeImageSize({ protocol, mode, size });
188
+ }
189
+
118
190
  /** 新版多模态同步协议:响应中直接返回图片 URL。 */
119
191
  export async function generateImageMultimodal({ key, baseUrl, model, prompt, size, n = 1, timeoutMs = T_SYNC_GEN, signal }) {
120
192
  const apiBase = dashscopeApiBase(baseUrl);
121
- const res = await fetch(`${apiBase}/services/aigc/multimodal-generation/generation`, {
193
+ const requestSize = applyImageSize({ protocol: 'dashscope', mode: 'multimodal-sync', size });
194
+ const { res, body } = await fetchImageJson(`${apiBase}/services/aigc/multimodal-generation/generation`, {
122
195
  method: 'POST',
123
196
  headers: authHeaders(key),
124
197
  signal: timeoutSignal(timeoutMs, signal),
125
198
  body: JSON.stringify({
126
199
  model,
127
200
  input: { messages: [{ role: 'user', content: [{ text: prompt }] }] },
128
- parameters: { ...(size ? { size } : {}), n: Number(n) || 1 }
201
+ parameters: { ...(requestSize ? { size: requestSize } : {}), n: Number(n) || 1 }
129
202
  })
130
203
  });
131
- const body = await readJson(res);
132
- if (!res.ok) throw providerError(res.status, body, '生成失败');
204
+ if (!res.ok) throw providerError(res.status, body, '生成失败', res.headers);
133
205
  const urls = [];
134
206
  for (const choice of (body && body.output && body.output.choices) || []) {
135
207
  for (const item of (choice && choice.message && choice.message.content) || []) {
@@ -150,14 +222,14 @@ export async function startImageGeneration(opts) {
150
222
  }
151
223
  export async function submitImage({ key, baseUrl, model, prompt, size, n = 1, timeoutMs = T_SUBMIT, signal }) {
152
224
  const apiBase = dashscopeApiBase(baseUrl);
153
- const res = await fetch(`${apiBase}/services/aigc/text2image/image-synthesis`, {
225
+ const requestSize = applyImageSize({ protocol: 'dashscope', mode: 'legacy-async', size });
226
+ const { res, body } = await fetchImageJson(`${apiBase}/services/aigc/text2image/image-synthesis`, {
154
227
  method: 'POST',
155
228
  headers: { ...authHeaders(key), 'X-DashScope-Async': 'enable' },
156
229
  signal: timeoutSignal(timeoutMs, signal),
157
- body: JSON.stringify({ model, input: { prompt }, parameters: { ...(size ? { size } : {}), n: Number(n) || 1 } })
230
+ body: JSON.stringify({ model, input: { prompt }, parameters: { ...(requestSize ? { size: requestSize } : {}), n: Number(n) || 1 } })
158
231
  });
159
- const body = await readJson(res);
160
- if (!res.ok) throw providerError(res.status, body, '提交失败');
232
+ if (!res.ok) throw providerError(res.status, body, '提交失败', res.headers);
161
233
  const taskId = body && body.output && body.output.task_id;
162
234
  if (!taskId) throw new Error('提交成功但未返回 task_id');
163
235
  return taskId;
@@ -199,7 +271,7 @@ export async function submitVideo({ key, baseUrl, model, prompt, imgDataUrl, siz
199
271
  body: JSON.stringify({ model, input, parameters })
200
272
  });
201
273
  const body = await readJson(res);
202
- if (!res.ok) throw providerError(res.status, body, '提交失败');
274
+ if (!res.ok) throw providerError(res.status, body, '提交失败', res.headers);
203
275
  const taskId = body && body.output && body.output.task_id;
204
276
  if (!taskId) throw new Error('提交成功但未返回 task_id');
205
277
  return taskId;
@@ -218,7 +290,7 @@ export async function uploadTempFile({ key, baseUrl, model, filePath, timeoutMs
218
290
  u.searchParams.set('model', model);
219
291
  const pres = await fetch(u, { headers: authHeaders(key), signal: timeoutSignal(timeoutMs, signal) });
220
292
  const pbody = await readJson(pres);
221
- if (!pres.ok || !pbody || !pbody.data) throw providerError(pres.status, pbody, '获取上传凭证失败');
293
+ if (!pres.ok || !pbody || !pbody.data) throw providerError(pres.status, pbody, '获取上传凭证失败', pres.headers);
222
294
  const pol = pbody.data;
223
295
  const name = path.basename(filePath);
224
296
  const keyPath = `${pol.upload_dir}/${Date.now()}-${name}`;
@@ -246,7 +318,7 @@ export async function pollTask({ key, baseUrl, remoteTaskId, timeoutMs = T_POLL,
246
318
  signal: timeoutSignal(timeoutMs, signal)
247
319
  });
248
320
  const body = await readJson(res);
249
- if (!res.ok) throw providerError(res.status, body, '查询任务失败');
321
+ if (!res.ok) throw providerError(res.status, body, '查询任务失败', res.headers);
250
322
  const out = (body && body.output) || {};
251
323
  const status = out.task_status || 'UNKNOWN';
252
324
  const urls = [];
@@ -291,7 +363,7 @@ export async function synthesizeTts({ key, baseUrl, model = 'qwen-tts-latest', t
291
363
  body: JSON.stringify({ model, input: { text, voice } })
292
364
  });
293
365
  const body = await readJson(res);
294
- if (!res.ok) throw providerError(res.status, body, '合成失败');
366
+ if (!res.ok) throw providerError(res.status, body, '合成失败', res.headers);
295
367
  const audio = body && body.output && body.output.audio;
296
368
  const url = audio && (audio.url || audio.audio_url);
297
369
  const b64 = audio && typeof audio.data === 'string' ? audio.data : null;
@@ -302,24 +374,124 @@ export async function synthesizeTts({ key, baseUrl, model = 'qwen-tts-latest', t
302
374
  /* ---------------- OpenAI Images 兼容(同步 base64/url) ---------------- */
303
375
  export async function openAiGenerateImage({ key, baseUrl, model, prompt, size = '1024x1024', n = 1, timeoutMs = T_SYNC_GEN, signal }) {
304
376
  const base = String(baseUrl || '').replace(/\/+$/, '');
305
- const res = await fetch(`${base}/images/generations`, {
377
+ const requestSize = applyImageSize({ protocol: 'openai-images', size });
378
+ const { res, body } = await fetchImageJson(`${base}/images/generations`, {
306
379
  method: 'POST',
307
380
  headers: authHeaders(key),
308
381
  signal: timeoutSignal(timeoutMs, signal),
309
- body: JSON.stringify({ model, prompt, size, n: Number(n) || 1 })
382
+ body: JSON.stringify({ model, prompt, size: requestSize, n: Number(n) || 1 })
310
383
  });
311
- const body = await readJson(res);
312
- if (!res.ok) throw providerError(res.status, body, '生成失败');
384
+ if (!res.ok) throw providerError(res.status, body, '生成失败', res.headers);
313
385
  const items = (body && Array.isArray(body.data) && body.data) || [];
314
386
  const out = [];
315
387
  for (const it of items) {
316
388
  if (it && typeof it.b64_json === 'string') out.push({ b64: it.b64_json });
317
389
  else if (it && typeof it.url === 'string') out.push({ url: it.url });
318
390
  }
319
- if (!out.length) throw new Error('生成完成但未返回图片数据');
391
+ if (!out.length) throw imageResponseError('生成响应未返回图片数据');
320
392
  return out;
321
393
  }
322
394
 
395
+ function imageResponseError(message) {
396
+ return new ProviderContractError(message, {
397
+ stage: 'response', category: 'protocol', acceptance: 'unknown'
398
+ });
399
+ }
400
+
401
+ function imageBase64Item(value, mediaType, label) {
402
+ const b64 = typeof value === 'string' ? value.replace(/\s/g, '') : '';
403
+ const bytes = Buffer.from(b64, 'base64');
404
+ if (!bytes.length || bytes.toString('base64').replace(/=+$/, '') !== b64.replace(/=+$/, '')) {
405
+ throw imageResponseError(label + '响应包含无效的图片 base64');
406
+ }
407
+ return { b64: bytes.toString('base64'), ...(mediaType ? { mediaType } : {}) };
408
+ }
409
+
410
+ /** 网关扩展:图片 Markdown/data URL、image_url 内容块或 message.images。 */
411
+ function chatImageItems(body, label = '聊天生图') {
412
+ const items = [], seen = new Set();
413
+ function add(source) {
414
+ if (typeof source !== 'string') return;
415
+ const value = source.trim();
416
+ if (seen.has(value)) return;
417
+ const data = /^data:(image\/[a-z0-9.+-]+);base64,([\s\S]+)$/i.exec(value);
418
+ if (data) {
419
+ items.push(imageBase64Item(data[2], data[1].toLowerCase(), label));
420
+ } else if (/^https?:\/\//i.test(value)) {
421
+ items.push({ url: value });
422
+ } else return;
423
+ seen.add(value);
424
+ }
425
+ function text(content) {
426
+ for (const match of content.matchAll(/!\[[^\]]*\]\(([^)]+)\)/g)) add(match[1]);
427
+ for (const match of content.matchAll(/data:image\/[a-z0-9.+-]+;base64,[^\s)"<>]+/gi)) add(match[0]);
428
+ }
429
+ function part(item) {
430
+ if (item?.type === 'text' && typeof item.text === 'string') text(item.text);
431
+ else if (item?.image_url) add(typeof item.image_url === 'string' ? item.image_url : item.image_url.url);
432
+ }
433
+ for (const choice of Array.isArray(body?.choices) ? body.choices : []) {
434
+ const message = choice?.message;
435
+ if (typeof message?.content === 'string') text(message.content);
436
+ else if (Array.isArray(message?.content)) message.content.forEach(part);
437
+ if (Array.isArray(message?.images)) message.images.forEach(part);
438
+ }
439
+ if (!items.length) throw imageResponseError(label + '响应未返回图片;受理结果未知,禁止自动重提');
440
+ return items;
441
+ }
442
+
443
+ export async function openAiChatGenerateImage({ key, baseUrl, model, prompt, image, size, n = 1, timeoutMs = T_SYNC_GEN, signal }) {
444
+ if (typeof size === 'string' && size.trim()) {
445
+ throw new ProviderContractError('聊天生图协议不支持 size 参数;请在提示词中描述所需尺寸', {
446
+ stage: 'validate', category: 'invalid_request', acceptance: 'not_accepted'
447
+ });
448
+ }
449
+ const base = String(baseUrl || '').replace(/\/+$/, '');
450
+ const content = image ? [
451
+ { type: 'text', text: prompt },
452
+ { type: 'image_url', image_url: { url: 'data:' + image.mediaType + ';base64,' + Buffer.from(image.bytes).toString('base64') } }
453
+ ] : prompt;
454
+ const { res, body } = await fetchImageJson(`${base}/chat/completions`, {
455
+ method: 'POST', headers: authHeaders(key), signal: timeoutSignal(timeoutMs, signal),
456
+ body: JSON.stringify({ model, stream: false, messages: [{ role: 'user', content }],
457
+ ...(Number(n) > 1 ? { n: Number(n) } : {}) })
458
+ });
459
+ if (!res.ok) throw providerError(res.status, body, '聊天生图失败', res.headers);
460
+ return chatImageItems(body);
461
+ }
462
+
463
+ /** 同步 Responses 生图:主模型由 model 指定,图片工具模型沿用服务端默认。 */
464
+ export async function openAiResponsesGenerateImage({ key, baseUrl, model, prompt, size, n = 1, timeoutMs = T_SYNC_GEN, signal }) {
465
+ if (Number(n) !== 1) {
466
+ throw new ProviderContractError('Responses 生图当前只支持 n=1', {
467
+ stage: 'validate', category: 'invalid_request', acceptance: 'not_accepted'
468
+ });
469
+ }
470
+ const base = String(baseUrl || '').replace(/\/+$/, '');
471
+ const requestSize = applyImageSize({ protocol: 'openai-responses-images', size });
472
+ const { res, body } = await fetchImageJson(`${base}/responses`, {
473
+ method: 'POST', headers: authHeaders(key), signal: timeoutSignal(timeoutMs, signal),
474
+ body: JSON.stringify({ model, input: prompt, stream: false, store: false, background: false,
475
+ tools: [{ type: 'image_generation', action: 'generate', ...(requestSize ? { size: requestSize } : {}) }],
476
+ tool_choice: { type: 'image_generation' } })
477
+ });
478
+ if (!res.ok) throw providerError(res.status, body, 'Responses 生图失败', res.headers);
479
+ if (body?.status !== 'completed') throw imageResponseError('Responses 生图未返回同步完成结果;禁止自动重提');
480
+ const output = Array.isArray(body.output) ? body.output : [];
481
+ const images = output.filter(item => item?.type === 'image_generation_call');
482
+ if (images.length) return images.map(item => {
483
+ if (item.status !== 'completed') throw imageResponseError('Responses 图片工具未完成;禁止自动重提');
484
+ return imageBase64Item(item.result, undefined, 'Responses 生图');
485
+ });
486
+ // 实测网关将图片作为 output_text 返回;这不证明执行了官方图片工具。
487
+ const messages = output.filter(item => item?.type === 'message');
488
+ if (messages.some(item => item.status !== 'completed')) throw imageResponseError('Responses 图片消息未完成;禁止自动重提');
489
+ return chatImageItems({ choices: messages.map(item => ({ message: {
490
+ content: (Array.isArray(item.content) ? item.content : []).map(part => part?.type === 'output_text'
491
+ ? { type: 'text', text: part.text } : part)
492
+ } })) }, 'Responses 生图');
493
+ }
494
+
323
495
  /* ---------------- 模型发现(OpenAI GET /models + DashScope GET /api/v1/models) ---------------- */
324
496
  /**
325
497
  * 拉取供应商模型目录。DashScope 官方端点分页读取并保留 capabilities;
@@ -342,7 +514,7 @@ export async function listModels({ key, baseUrl, timeoutMs = T_SUBMIT, signal })
342
514
  }
343
515
  const res = await fetch(url, { headers: authHeaders(key), signal: timeoutSignal(timeoutMs, signal) });
344
516
  const body = await readJson(res);
345
- if (!res.ok) throw providerError(res.status, body, '模型发现失败');
517
+ if (!res.ok) throw providerError(res.status, body, '模型发现失败', res.headers);
346
518
  const output = body && body.output;
347
519
  const items = (output && Array.isArray(output.models) && output.models)
348
520
  || (body && Array.isArray(body.data) && body.data)
@@ -358,11 +530,14 @@ export async function listModels({ key, baseUrl, timeoutMs = T_SUBMIT, signal })
358
530
  ? item.inference_metadata.request_modality.map((value) => String(value).toLowerCase()) : [];
359
531
  const responseModalities = Array.isArray(item?.inference_metadata?.response_modality)
360
532
  ? item.inference_metadata.response_modality.map((value) => String(value).toLowerCase()) : [];
361
- if (responseModalities.includes('image')) capabilities.push('IG');
362
- if (responseModalities.includes('video')) capabilities.push('VG');
363
- if (requestModalities.includes('audio') && responseModalities.includes('text')) capabilities.push('ASR');
364
- if (requestModalities.includes('text') && responseModalities.includes('audio')) capabilities.push('TTS');
365
- if (requestModalities.includes('image') && responseModalities.includes('text')) capabilities.push('VU');
533
+ // 明确能力代码(含空数组/未知新能力)优先,不能被通用模态推断追加用途。
534
+ if (!Array.isArray(item?.capabilities)) {
535
+ if (responseModalities.includes('image')) capabilities.push('IG');
536
+ if (responseModalities.includes('video')) capabilities.push('VG');
537
+ if (requestModalities.includes('audio') && responseModalities.includes('text')) capabilities.push('ASR');
538
+ if (requestModalities.includes('text') && responseModalities.includes('audio')) capabilities.push('TTS');
539
+ if (requestModalities.includes('image') && responseModalities.includes('text')) capabilities.push('VU');
540
+ }
366
541
  const normalizedCapabilities = [...new Set(capabilities)];
367
542
  found.set(id, normalizedCapabilities.length ? { id, capabilities: normalizedCapabilities } : id);
368
543
  }
@@ -411,7 +586,7 @@ export async function visionStream({ key, baseUrl, model = 'qwen-vl-plus', promp
411
586
  const text = await res.text().catch(() => '');
412
587
  let body = null;
413
588
  try { body = JSON.parse(text); } catch (_) { /* keep null */ }
414
- throw providerError(res.status, body, '视觉理解失败');
589
+ throw providerError(res.status, body, '视觉理解失败', res.headers);
415
590
  }
416
591
  let full = '';
417
592
  const decoder = new TextDecoder();
@@ -488,7 +663,7 @@ export async function submitTranscription({ key, baseUrl, model = 'qwen-audio-3.
488
663
  body: JSON.stringify({ model, input })
489
664
  });
490
665
  const body = await readJson(res);
491
- if (!res.ok) throw providerError(res.status, body, '提交转写失败');
666
+ if (!res.ok) throw providerError(res.status, body, '提交转写失败', res.headers);
492
667
  const taskId = body && body.output && body.output.task_id;
493
668
  if (!taskId) throw new Error('提交成功但未返回 task_id');
494
669
  return taskId;
@@ -520,7 +695,7 @@ export async function pollTranscriptionTask({ key, baseUrl, remoteTaskId, timeou
520
695
  signal: timeoutSignal(timeoutMs, signal)
521
696
  });
522
697
  const body = await readJson(res);
523
- if (!res.ok) throw providerError(res.status, body, '查询转写任务失败');
698
+ if (!res.ok) throw providerError(res.status, body, '查询转写任务失败', res.headers);
524
699
  const out = (body && body.output) || {};
525
700
  const status = out.task_status || 'UNKNOWN';
526
701
  let text = '';
package/lib/api.js CHANGED
@@ -17,6 +17,9 @@ import { irisHome, capabilitiesOf, onChange as onConfigChange, providerHealthSna
17
17
  import { mimeOf, mediaUrl } from './media.js';
18
18
  import * as tasks from './tasks.js';
19
19
  import * as artifacts from './artifacts.js';
20
+ import { workbenchArtifacts } from './workbench-artifacts.js';
21
+ import { workbenchDetail, downloadWorkbenchSelection, deleteWorkbenchArtifacts, workbenchTransactions,
22
+ restoreWorkbenchTransaction, workbenchError } from './workbench-management.js';
20
23
  import { runAction } from './actions.js';
21
24
  import { chmodPrivateFile, ensurePrivateDir } from './private-storage.js';
22
25
  import { selectMediaProtocol } from './provider-protocol.js';
@@ -25,6 +28,7 @@ import { hostDoctor } from './doctor.js';
25
28
  import { hostRuntimeEvidence, recordHostClient } from './host-runtime.js';
26
29
  import { cancelProviderTaskForDsh, coreSnapshotForDsh, disposeCoreTaskAttention, readCoreArtifactMediaForDsh, redeliverProviderTaskForDsh, reobserveProviderTaskForDsh, retryProviderTaskForDsh } from './dsh-core-adapter.js';
27
30
  import { optimizePrompt } from './prompt-optimizer.js';
31
+ import { PROMPT_OPTIMIZER_CAPABILITIES } from './prompt-optimizer-core.js';
28
32
  import {
29
33
  importPromptOptimizerConfig,
30
34
  loadPromptOptimizerConfig,
@@ -331,6 +335,9 @@ export function serveApi(req, res, host) {
331
335
 
332
336
  // POST /iris/api/actions/:name —— GUI 直连触发工具执行
333
337
  if (method === 'POST') {
338
+ if (urlPath === '/iris/api/works/download') return handleWorkbenchPost(req, res, downloadWorkbenchSelection, true);
339
+ if (urlPath === '/iris/api/works/delete') return handleWorkbenchPost(req, res, deleteWorkbenchArtifacts);
340
+ if (urlPath === '/iris/api/works/restore') return handleWorkbenchPost(req, res, restoreWorkbenchTransaction);
334
341
  if (urlPath === '/iris/api/host-client') {
335
342
  if (!host || typeof host !== 'object') return sendJson(res, 503, { error: 'host unavailable' });
336
343
  return handleJsonPost(req, res, (body) => recordHostClient(body));
@@ -362,7 +369,7 @@ export function serveApi(req, res, host) {
362
369
  return serveSse(res);
363
370
  }
364
371
  if (urlPath === '/iris/api/prompt-optimizer/config') {
365
- return sendJson(res, 200, method === 'HEAD' ? {} : loadPromptOptimizerConfig());
372
+ return sendJson(res, 200, method === 'HEAD' ? {} : { ...loadPromptOptimizerConfig(), capabilities: PROMPT_OPTIMIZER_CAPABILITIES });
366
373
  }
367
374
  if (urlPath === '/iris/api/doctor') {
368
375
  if (!host || typeof host !== 'object') return sendJson(res, 503, { error: 'host unavailable' });
@@ -373,6 +380,12 @@ export function serveApi(req, res, host) {
373
380
  const query = new URL(req.url || '', 'http://iris.local').searchParams;
374
381
  return serveCoreSnapshot(res, { limit: Number(query.get('limit') || 12) });
375
382
  }
383
+ if (urlPath === '/iris/api/works') {
384
+ return serveWorkbenchArtifacts(req, method, res);
385
+ }
386
+ if (urlPath === '/iris/api/works/transactions') return serveWorkbenchRead(res, method, () => workbenchTransactions());
387
+ const workDetailMatch = urlPath.match(/^\/iris\/api\/works\/(core|legacy)\/([^/]+)$/);
388
+ if (workDetailMatch) return serveWorkbenchRead(res, method, () => workbenchDetail(workDetailMatch[1], workDetailMatch[2]));
376
389
  const coreMediaMatch = urlPath.match(/^\/iris\/api\/core\/artifact\/(artifact_[a-f0-9]{24})\/media$/);
377
390
  if (coreMediaMatch) return serveCoreArtifactMedia(coreMediaMatch[1], method, res);
378
391
  if (urlPath === '/iris/api/artifacts') {
@@ -400,6 +413,32 @@ export function serveApi(req, res, host) {
400
413
  }
401
414
  }
402
415
 
416
+ async function serveWorkbenchArtifacts(req, method, res) {
417
+ const params = new URL(req.url || '', 'http://iris.local').searchParams;
418
+ const input = Object.fromEntries(params);
419
+ for (const key of ['offset', 'limit']) if (input[key] !== undefined) input[key] = Number(input[key]);
420
+ try { return sendJson(res, 200, method === 'HEAD' ? {} : await workbenchArtifacts(input)); }
421
+ catch (error) { return sendJson(res, error.statusCode || 500, { error: '作品读取失败', code: error.code || 'IRIS_GALLERY_FAILED' }); }
422
+ }
423
+
424
+ async function serveWorkbenchRead(res, method, execute) {
425
+ try { return sendJson(res, 200, method === 'HEAD' ? {} : await execute()); }
426
+ catch (error) { const safe = workbenchError(error); return sendJson(res, safe.statusCode || 400, { error: safe.message, code: safe.code }); }
427
+ }
428
+
429
+ function handleWorkbenchPost(req, res, execute, download = false) {
430
+ return handleJsonPost(req, res, async (body, signal) => {
431
+ try { return await execute(body, { signal }); }
432
+ catch (error) { throw workbenchError(error); }
433
+ }, download ? (response, _status, bytes) => {
434
+ if (response.writableEnded || response.destroyed) return;
435
+ response.writeHead(200, { 'Content-Type': 'application/zip', 'Content-Length': bytes.length,
436
+ 'Content-Disposition': 'attachment; filename="iris-works.zip"', 'Cache-Control': 'no-store',
437
+ 'X-Content-Type-Options': 'nosniff', 'Cross-Origin-Resource-Policy': 'same-origin' });
438
+ response.end(bytes);
439
+ } : sendJson);
440
+ }
441
+
403
442
  async function serveCoreArtifactMedia(artifactId, method, res) {
404
443
  try {
405
444
  const result = await readCoreArtifactMediaForDsh(artifactId);
@@ -439,7 +478,7 @@ function sendJson(res, status, obj) {
439
478
 
440
479
  /** POST /iris/api/actions/:name:读取 JSON body 调 runAction 返回结果 */
441
480
  const MAX_BODY_BYTES = 1e6; // 1MB 上限(防滥用)
442
- function handleJsonPost(req, res, execute) {
481
+ function handleJsonPost(req, res, execute, respond = sendJson) {
443
482
  const chunks = [];
444
483
  const controller = new AbortController();
445
484
  let completed = false;
@@ -448,7 +487,7 @@ function handleJsonPost(req, res, execute) {
448
487
  const finish = (status, body) => {
449
488
  if (completed) return;
450
489
  completed = true;
451
- sendJson(res, status, body);
490
+ (status === 200 ? respond : sendJson)(res, status, body);
452
491
  };
453
492
  const disconnect = () => { if (!completed) controller.abort(); };
454
493
  if (typeof req.on === 'function') req.on('aborted', disconnect);
@@ -469,11 +508,13 @@ function handleJsonPost(req, res, execute) {
469
508
  const raw = Buffer.concat(chunks).toString('utf8');
470
509
  const body = raw ? JSON.parse(raw) : {};
471
510
  const result = await execute(body, controller.signal);
511
+ if (controller.signal.aborted) return finish(499, { error: 'request canceled' });
472
512
  finish(200, result);
473
513
  } catch (err) {
474
514
  finish(controller.signal.aborted ? 499 : (Number(err && err.statusCode) || 400), {
475
515
  error: controller.signal.aborted ? 'request canceled' : String((err && err.message) || err),
476
- ...(err && err.code ? { code: String(err.code) } : {})
516
+ ...(err && err.code ? { code: String(err.code) } : {}),
517
+ ...(err && /^delete_[a-f0-9]{24}$/.test(err.transactionId) ? { transactionId: err.transactionId } : {})
477
518
  });
478
519
  }
479
520
  });
@@ -524,6 +565,7 @@ function handleCoreTaskRetry(taskId, req, res) {
524
565
  const result = await retryProviderTaskForDsh({
525
566
  taskId,
526
567
  prompt,
568
+ ...(body.source_artifact_id !== undefined ? { sourceArtifactId: body.source_artifact_id } : {}),
527
569
  ...(body.text !== undefined ? { text: String(body.text) } : {}),
528
570
  ...(body.voice !== undefined ? { voice: String(body.voice) } : {}),
529
571
  ...(body.audio_url !== undefined ? { audio_url: String(body.audio_url) } : {}),
@@ -588,10 +630,10 @@ function handlePromptOptimizer(host, operation, req, res) {
588
630
  if (operation === 'optimize') return optimizePrompt(host, body, { signal });
589
631
  if (operation === 'import') {
590
632
  const imported = body && body.config !== undefined ? body.config : body;
591
- return { ok: true, ...importPromptOptimizerConfig(imported) };
633
+ return { ok: true, ...importPromptOptimizerConfig(imported), capabilities: PROMPT_OPTIMIZER_CAPABILITIES };
592
634
  }
593
- if (operation === 'reset') return { ok: true, ...resetPromptOptimizerConfig() };
594
- if (operation === 'enabled') return { ok: true, ...setPromptOptimizerEnabled(body && body.enabled) };
635
+ if (operation === 'reset') return { ok: true, ...resetPromptOptimizerConfig(), capabilities: PROMPT_OPTIMIZER_CAPABILITIES };
636
+ if (operation === 'enabled') return { ok: true, ...setPromptOptimizerEnabled(body && body.enabled), capabilities: PROMPT_OPTIMIZER_CAPABILITIES };
595
637
  throw new Error('unknown prompt optimizer operation');
596
638
  });
597
639
  }
package/lib/capability.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * - 能力常量:image-gen / video-gen / tts / transcribe / vision
7
7
  * - 严格选择:不具备某能力的 Provider 不得被兜底选中
8
8
  * - 有序 failover:同一能力按 providers.json 声明顺序返回,调用方按序尝试
9
- * - 旧配置迁移:老 provider(无 capabilities 数组)按模型字段推断能力
9
+ * - 能力只来自明确标签或旧模型字段绑定,不按供应商端点赋能
10
10
  *
11
11
  * 本模块是纯函数,不读写磁盘;只吃 provider 记录、吐能力判断。
12
12
  */
@@ -30,36 +30,43 @@ const INFER_FIELD = {
30
30
  [CAPABILITIES.VISION]: 'visionModel'
31
31
  };
32
32
 
33
+ /** 模型级明确标签优先;旧能力字段只为同名模型提供明确绑定。 */
34
+ export function configuredModelCapabilities(provider, model) {
35
+ if (Array.isArray(model?.capabilities)) {
36
+ return [...new Set(model.capabilities.filter(isKnownCapability))];
37
+ }
38
+ const id = typeof model === 'string' ? model.trim() : String(model?.id || '').trim();
39
+ if (!id) return [];
40
+ const bound = Object.entries(INFER_FIELD)
41
+ .filter(([, field]) => typeof provider?.[field] === 'string' && provider[field].trim() === id)
42
+ .map(([capability]) => capability);
43
+ return Array.isArray(provider?.capabilities) ? bound.filter((capability) => provider.capabilities.includes(capability)) : bound;
44
+ }
45
+
33
46
  export function isKnownCapability(capability) {
34
47
  return KNOWN.has(capability);
35
48
  }
36
49
 
37
50
  /**
38
51
  * 一个 provider 的能力列表(去重、只留已知能力)。
39
- * 迁移规则(旧配置):
40
- * ① capabilities 显式声明 → 权威(严格使用,不额外推断);
41
- * ② 否则按模型字段推断(imageModel→image-gen 等);
42
- * ③ 仍为空且是 DashScope 端点 → 全部媒体能力
43
- * (百炼单一 API 服务 t2i/t2v/tts/compatible-vision,裸账号常见于迁移期)。
52
+ * 显式模型池优先,包括空池;其次是账号明确标签(含空数组),
53
+ * 最后兼容旧能力字段绑定。账号地址和模型名称都不证明能力。
44
54
  * @param {object} p provider 记录
45
55
  * @returns {string[]}
46
56
  */
47
57
  export function capabilitiesOf(p) {
48
- const declared = Array.isArray(p && p.capabilities) ? p.capabilities : [];
49
- if (declared.some((c) => isKnownCapability(c))) {
50
- return [...new Set(declared)].filter(isKnownCapability);
58
+ if (Array.isArray(p?.models)) {
59
+ return [...new Set(p.models.flatMap((model) => configuredModelCapabilities(p, model)))];
60
+ }
61
+ if (Array.isArray(p?.capabilities)) {
62
+ return [...new Set(p.capabilities.filter(isKnownCapability))];
51
63
  }
52
64
  // 旧配置迁移:按模型字段推断(有该能力模型才认为具备该能力)
53
65
  const inferred = [];
54
66
  for (const [cap, field] of Object.entries(INFER_FIELD)) {
55
- if (p && p[field]) inferred.push(cap);
56
- }
57
- if (inferred.length) return inferred;
58
- // 裸 DashScope 账号兜底迁移
59
- if (p && /dashscope/i.test(String(p.baseUrl || ''))) {
60
- return Object.values(CAPABILITIES);
67
+ if (typeof p?.[field] === 'string' && p[field].trim()) inferred.push(cap);
61
68
  }
62
- return [];
69
+ return inferred;
63
70
  }
64
71
 
65
72
  /**