@mokuyoaxis/dsh-iris 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) 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 +47 -2
  5. package/README.en.md +65 -29
  6. package/README.md +65 -29
  7. package/bin/dsh-iris.js +222 -21
  8. package/docs/AGENT_QUICKSTART.md +10 -6
  9. package/docs/ARCHITECTURE.md +25 -18
  10. package/docs/ARTIFACT_LIBRARY.md +5 -5
  11. package/docs/ARTIFACT_MANIFEST.md +1 -1
  12. package/docs/CLI_MANAGEMENT.md +181 -0
  13. package/docs/COMPOSITE_VISION.md +35 -0
  14. package/docs/CORE_RUNTIME_CONTRACT.md +8 -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 +12 -2
  18. package/docs/HEADLESS_CLI.md +13 -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 +19 -14
  22. package/docs/OCR_MODEL.md +39 -0
  23. package/docs/PROMPT_OPTIMIZER.md +47 -0
  24. package/docs/PROVIDER_ADAPTER_CONTRACT.md +6 -4
  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 +29 -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/lib/actions.js +187 -270
  37. package/lib/adapters.js +133 -22
  38. package/lib/api.js +49 -7
  39. package/lib/chromium-browser.js +117 -0
  40. package/lib/client.js +671 -101
  41. package/lib/command-service.js +121 -7
  42. package/lib/composite-vision-routing.js +42 -0
  43. package/lib/config.js +89 -10
  44. package/lib/core-artifact-store.js +45 -13
  45. package/lib/core-maintenance.js +213 -0
  46. package/lib/core-tasks.js +12 -5
  47. package/lib/core-user-projection.js +1 -0
  48. package/lib/dsh-core-adapter.js +63 -9
  49. package/lib/dsh-text-model-adapter.js +222 -0
  50. package/lib/dsh-vision-model-adapter.js +75 -0
  51. package/lib/generation-input.js +36 -5
  52. package/lib/headless-vision.js +181 -0
  53. package/lib/host-runtime.js +1 -0
  54. package/lib/http-vision-model-adapter.js +187 -0
  55. package/lib/image-edit-input.js +34 -0
  56. package/lib/index.js +205 -175
  57. package/lib/locate.js +86 -28
  58. package/lib/media-probe.js +2 -1
  59. package/lib/model-call-runtime.js +45 -0
  60. package/lib/model-invoker.js +4 -2
  61. package/lib/model-port-contract.js +13 -5
  62. package/lib/models.js +10 -5
  63. package/lib/ocr-model-routing.js +26 -0
  64. package/lib/ocr.js +110 -64
  65. package/lib/prompt-optimizer-config.js +2 -1
  66. package/lib/prompt-optimizer-core.js +121 -0
  67. package/lib/prompt-optimizer.js +37 -193
  68. package/lib/provider-adapters.js +85 -9
  69. package/lib/provider-catalog.js +19 -7
  70. package/lib/provider-config-service.js +236 -0
  71. package/lib/provider-contract.js +3 -2
  72. package/lib/provider-health.js +80 -1
  73. package/lib/provider-protocol.js +23 -0
  74. package/lib/provider-task-runner.js +58 -16
  75. package/lib/summarize.js +31 -20
  76. package/lib/summary-input.js +52 -0
  77. package/lib/task-wait.js +40 -0
  78. package/lib/video-input.js +30 -0
  79. package/lib/vision-core.js +42 -0
  80. package/lib/vision-image-input.js +83 -0
  81. package/lib/vision-input.js +31 -0
  82. package/lib/vision-model-routing.js +180 -0
  83. package/lib/vision.js +3 -2
  84. package/lib/workbench-artifacts.js +52 -0
  85. package/lib/workbench-management.js +151 -0
  86. package/lib/workbench-zip.js +39 -0
  87. package/package.json +1 -1
  88. package/user_guide.md +52 -22
package/lib/adapters.js CHANGED
@@ -3,6 +3,8 @@
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';
@@ -11,6 +13,8 @@ import { Readable } from 'node:stream';
11
13
  import { pipeline } from 'node:stream/promises';
12
14
  import { dashscopeApiBase, isDashScopeBaseUrl } 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,7 +91,8 @@ 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
98
  /** 包装 fetch 层的网络异常(无 HTTP 状态) */
@@ -96,8 +106,8 @@ export function networkError(err) {
96
106
  return e;
97
107
  }
98
108
 
99
- function providerError(status, body, fallback) {
100
- return classifyHttpError(status, body, fallback);
109
+ function providerError(status, body, fallback, headers) {
110
+ return classifyHttpError(status, body, fallback, headers);
101
111
  }
102
112
 
103
113
  /* ---------------- DashScope:图像(旧异步 / 新版多模态) ---------------- */
@@ -129,7 +139,7 @@ export async function generateImageMultimodal({ key, baseUrl, model, prompt, siz
129
139
  })
130
140
  });
131
141
  const body = await readJson(res);
132
- if (!res.ok) throw providerError(res.status, body, '生成失败');
142
+ if (!res.ok) throw providerError(res.status, body, '生成失败', res.headers);
133
143
  const urls = [];
134
144
  for (const choice of (body && body.output && body.output.choices) || []) {
135
145
  for (const item of (choice && choice.message && choice.message.content) || []) {
@@ -157,7 +167,7 @@ export async function submitImage({ key, baseUrl, model, prompt, size, n = 1, ti
157
167
  body: JSON.stringify({ model, input: { prompt }, parameters: { ...(size ? { size } : {}), n: Number(n) || 1 } })
158
168
  });
159
169
  const body = await readJson(res);
160
- if (!res.ok) throw providerError(res.status, body, '提交失败');
170
+ if (!res.ok) throw providerError(res.status, body, '提交失败', res.headers);
161
171
  const taskId = body && body.output && body.output.task_id;
162
172
  if (!taskId) throw new Error('提交成功但未返回 task_id');
163
173
  return taskId;
@@ -199,7 +209,7 @@ export async function submitVideo({ key, baseUrl, model, prompt, imgDataUrl, siz
199
209
  body: JSON.stringify({ model, input, parameters })
200
210
  });
201
211
  const body = await readJson(res);
202
- if (!res.ok) throw providerError(res.status, body, '提交失败');
212
+ if (!res.ok) throw providerError(res.status, body, '提交失败', res.headers);
203
213
  const taskId = body && body.output && body.output.task_id;
204
214
  if (!taskId) throw new Error('提交成功但未返回 task_id');
205
215
  return taskId;
@@ -218,7 +228,7 @@ export async function uploadTempFile({ key, baseUrl, model, filePath, timeoutMs
218
228
  u.searchParams.set('model', model);
219
229
  const pres = await fetch(u, { headers: authHeaders(key), signal: timeoutSignal(timeoutMs, signal) });
220
230
  const pbody = await readJson(pres);
221
- if (!pres.ok || !pbody || !pbody.data) throw providerError(pres.status, pbody, '获取上传凭证失败');
231
+ if (!pres.ok || !pbody || !pbody.data) throw providerError(pres.status, pbody, '获取上传凭证失败', pres.headers);
222
232
  const pol = pbody.data;
223
233
  const name = path.basename(filePath);
224
234
  const keyPath = `${pol.upload_dir}/${Date.now()}-${name}`;
@@ -246,7 +256,7 @@ export async function pollTask({ key, baseUrl, remoteTaskId, timeoutMs = T_POLL,
246
256
  signal: timeoutSignal(timeoutMs, signal)
247
257
  });
248
258
  const body = await readJson(res);
249
- if (!res.ok) throw providerError(res.status, body, '查询任务失败');
259
+ if (!res.ok) throw providerError(res.status, body, '查询任务失败', res.headers);
250
260
  const out = (body && body.output) || {};
251
261
  const status = out.task_status || 'UNKNOWN';
252
262
  const urls = [];
@@ -291,7 +301,7 @@ export async function synthesizeTts({ key, baseUrl, model = 'qwen-tts-latest', t
291
301
  body: JSON.stringify({ model, input: { text, voice } })
292
302
  });
293
303
  const body = await readJson(res);
294
- if (!res.ok) throw providerError(res.status, body, '合成失败');
304
+ if (!res.ok) throw providerError(res.status, body, '合成失败', res.headers);
295
305
  const audio = body && body.output && body.output.audio;
296
306
  const url = audio && (audio.url || audio.audio_url);
297
307
  const b64 = audio && typeof audio.data === 'string' ? audio.data : null;
@@ -309,17 +319,118 @@ export async function openAiGenerateImage({ key, baseUrl, model, prompt, size =
309
319
  body: JSON.stringify({ model, prompt, size, n: Number(n) || 1 })
310
320
  });
311
321
  const body = await readJson(res);
312
- if (!res.ok) throw providerError(res.status, body, '生成失败');
322
+ if (!res.ok) throw providerError(res.status, body, '生成失败', res.headers);
313
323
  const items = (body && Array.isArray(body.data) && body.data) || [];
314
324
  const out = [];
315
325
  for (const it of items) {
316
326
  if (it && typeof it.b64_json === 'string') out.push({ b64: it.b64_json });
317
327
  else if (it && typeof it.url === 'string') out.push({ url: it.url });
318
328
  }
319
- if (!out.length) throw new Error('生成完成但未返回图片数据');
329
+ if (!out.length) throw imageResponseError('生成响应未返回图片数据');
320
330
  return out;
321
331
  }
322
332
 
333
+ function imageResponseError(message) {
334
+ return new ProviderContractError(message, {
335
+ stage: 'response', category: 'protocol', acceptance: 'unknown'
336
+ });
337
+ }
338
+
339
+ function imageBase64Item(value, mediaType, label) {
340
+ const b64 = typeof value === 'string' ? value.replace(/\s/g, '') : '';
341
+ const bytes = Buffer.from(b64, 'base64');
342
+ if (!bytes.length || bytes.toString('base64').replace(/=+$/, '') !== b64.replace(/=+$/, '')) {
343
+ throw imageResponseError(label + '响应包含无效的图片 base64');
344
+ }
345
+ return { b64: bytes.toString('base64'), ...(mediaType ? { mediaType } : {}) };
346
+ }
347
+
348
+ /** 网关扩展:图片 Markdown/data URL、image_url 内容块或 message.images。 */
349
+ function chatImageItems(body, label = '聊天生图') {
350
+ const items = [], seen = new Set();
351
+ function add(source) {
352
+ if (typeof source !== 'string') return;
353
+ const value = source.trim();
354
+ if (seen.has(value)) return;
355
+ const data = /^data:(image\/[a-z0-9.+-]+);base64,([\s\S]+)$/i.exec(value);
356
+ if (data) {
357
+ items.push(imageBase64Item(data[2], data[1].toLowerCase(), label));
358
+ } else if (/^https?:\/\//i.test(value)) {
359
+ items.push({ url: value });
360
+ } else return;
361
+ seen.add(value);
362
+ }
363
+ function text(content) {
364
+ for (const match of content.matchAll(/!\[[^\]]*\]\(([^)]+)\)/g)) add(match[1]);
365
+ for (const match of content.matchAll(/data:image\/[a-z0-9.+-]+;base64,[^\s)"<>]+/gi)) add(match[0]);
366
+ }
367
+ function part(item) {
368
+ if (item?.type === 'text' && typeof item.text === 'string') text(item.text);
369
+ else if (item?.image_url) add(typeof item.image_url === 'string' ? item.image_url : item.image_url.url);
370
+ }
371
+ for (const choice of Array.isArray(body?.choices) ? body.choices : []) {
372
+ const message = choice?.message;
373
+ if (typeof message?.content === 'string') text(message.content);
374
+ else if (Array.isArray(message?.content)) message.content.forEach(part);
375
+ if (Array.isArray(message?.images)) message.images.forEach(part);
376
+ }
377
+ if (!items.length) throw imageResponseError(label + '响应未返回图片;受理结果未知,禁止自动重提');
378
+ return items;
379
+ }
380
+
381
+ export async function openAiChatGenerateImage({ key, baseUrl, model, prompt, image, size, n = 1, timeoutMs = T_SYNC_GEN, signal }) {
382
+ if (typeof size === 'string' && size.trim()) {
383
+ throw new ProviderContractError('聊天生图协议不支持 size 参数;请在提示词中描述所需尺寸', {
384
+ stage: 'validate', category: 'invalid_request', acceptance: 'not_accepted'
385
+ });
386
+ }
387
+ const base = String(baseUrl || '').replace(/\/+$/, '');
388
+ const content = image ? [
389
+ { type: 'text', text: prompt },
390
+ { type: 'image_url', image_url: { url: 'data:' + image.mediaType + ';base64,' + Buffer.from(image.bytes).toString('base64') } }
391
+ ] : prompt;
392
+ const res = await fetch(`${base}/chat/completions`, {
393
+ method: 'POST', headers: authHeaders(key), signal: timeoutSignal(timeoutMs, signal),
394
+ body: JSON.stringify({ model, stream: false, messages: [{ role: 'user', content }],
395
+ ...(Number(n) > 1 ? { n: Number(n) } : {}) })
396
+ });
397
+ const body = await readJson(res);
398
+ if (!res.ok) throw providerError(res.status, body, '聊天生图失败', res.headers);
399
+ return chatImageItems(body);
400
+ }
401
+
402
+ /** 同步 Responses 生图:主模型由 model 指定,图片工具模型沿用服务端默认。 */
403
+ export async function openAiResponsesGenerateImage({ key, baseUrl, model, prompt, size, n = 1, timeoutMs = T_SYNC_GEN, signal }) {
404
+ if (Number(n) !== 1) {
405
+ throw new ProviderContractError('Responses 生图当前只支持 n=1', {
406
+ stage: 'validate', category: 'invalid_request', acceptance: 'not_accepted'
407
+ });
408
+ }
409
+ const base = String(baseUrl || '').replace(/\/+$/, '');
410
+ const res = await fetch(`${base}/responses`, {
411
+ method: 'POST', headers: authHeaders(key), signal: timeoutSignal(timeoutMs, signal),
412
+ body: JSON.stringify({ model, input: prompt, stream: false, store: false, background: false,
413
+ tools: [{ type: 'image_generation', action: 'generate', ...(size ? { size: size.replace('*', 'x') } : {}) }],
414
+ tool_choice: { type: 'image_generation' } })
415
+ });
416
+ const body = await readJson(res);
417
+ if (!res.ok) throw providerError(res.status, body, 'Responses 生图失败', res.headers);
418
+ if (body?.status !== 'completed') throw imageResponseError('Responses 生图未返回同步完成结果;禁止自动重提');
419
+ const output = Array.isArray(body.output) ? body.output : [];
420
+ const images = output.filter(item => item?.type === 'image_generation_call');
421
+ if (images.length) return images.map(item => {
422
+ if (item.status !== 'completed') throw imageResponseError('Responses 图片工具未完成;禁止自动重提');
423
+ return imageBase64Item(item.result, undefined, 'Responses 生图');
424
+ });
425
+ // 实测网关将图片作为 output_text 返回;这不证明执行了官方图片工具。
426
+ const messages = output.filter(item => item?.type === 'message');
427
+ if (messages.some(item => item.status !== 'completed')) throw imageResponseError('Responses 图片消息未完成;禁止自动重提');
428
+ return chatImageItems({ choices: messages.map(item => ({ message: {
429
+ content: (Array.isArray(item.content) ? item.content : []).map(part => part?.type === 'output_text'
430
+ ? { type: 'text', text: part.text } : part)
431
+ } })) }, 'Responses 生图');
432
+ }
433
+
323
434
  /* ---------------- 模型发现(OpenAI GET /models + DashScope GET /api/v1/models) ---------------- */
324
435
  /**
325
436
  * 拉取供应商模型目录。DashScope 官方端点分页读取并保留 capabilities;
@@ -342,7 +453,7 @@ export async function listModels({ key, baseUrl, timeoutMs = T_SUBMIT, signal })
342
453
  }
343
454
  const res = await fetch(url, { headers: authHeaders(key), signal: timeoutSignal(timeoutMs, signal) });
344
455
  const body = await readJson(res);
345
- if (!res.ok) throw providerError(res.status, body, '模型发现失败');
456
+ if (!res.ok) throw providerError(res.status, body, '模型发现失败', res.headers);
346
457
  const output = body && body.output;
347
458
  const items = (output && Array.isArray(output.models) && output.models)
348
459
  || (body && Array.isArray(body.data) && body.data)
@@ -411,7 +522,7 @@ export async function visionStream({ key, baseUrl, model = 'qwen-vl-plus', promp
411
522
  const text = await res.text().catch(() => '');
412
523
  let body = null;
413
524
  try { body = JSON.parse(text); } catch (_) { /* keep null */ }
414
- throw providerError(res.status, body, '视觉理解失败');
525
+ throw providerError(res.status, body, '视觉理解失败', res.headers);
415
526
  }
416
527
  let full = '';
417
528
  const decoder = new TextDecoder();
@@ -488,7 +599,7 @@ export async function submitTranscription({ key, baseUrl, model = 'qwen-audio-3.
488
599
  body: JSON.stringify({ model, input })
489
600
  });
490
601
  const body = await readJson(res);
491
- if (!res.ok) throw providerError(res.status, body, '提交转写失败');
602
+ if (!res.ok) throw providerError(res.status, body, '提交转写失败', res.headers);
492
603
  const taskId = body && body.output && body.output.task_id;
493
604
  if (!taskId) throw new Error('提交成功但未返回 task_id');
494
605
  return taskId;
@@ -520,7 +631,7 @@ export async function pollTranscriptionTask({ key, baseUrl, remoteTaskId, timeou
520
631
  signal: timeoutSignal(timeoutMs, signal)
521
632
  });
522
633
  const body = await readJson(res);
523
- if (!res.ok) throw providerError(res.status, body, '查询转写任务失败');
634
+ if (!res.ok) throw providerError(res.status, body, '查询转写任务失败', res.headers);
524
635
  const out = (body && body.output) || {};
525
636
  const status = out.task_status || 'UNKNOWN';
526
637
  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
  }
@@ -0,0 +1,117 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import http from 'node:http';
5
+ import crypto from 'node:crypto';
6
+ import { spawn } from 'node:child_process';
7
+
8
+ // 与 DSH render 的页面权限一致:只渲染内联样式和内嵌图片,不执行脚本或加载网络资源。
9
+ const CSP = "sandbox; default-src 'none'; img-src data: blob:; style-src 'unsafe-inline'; font-src data:; connect-src 'none'; form-action 'none'; base-uri 'none'; frame-src 'none'";
10
+ const failure = (code, message) => Object.assign(new Error(message), { code });
11
+
12
+ export function createChromiumBrowser({ executable, noSandbox = false, timeoutMs = 30000 } = {}) {
13
+ if (!executable || !path.isAbsolute(executable)) {
14
+ throw failure('IRIS_BROWSER_REQUIRED', '请通过 --browser-executable 或 IRIS_BROWSER_EXECUTABLE 提供 Chromium 的绝对路径');
15
+ }
16
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 120000) {
17
+ throw failure('IRIS_BROWSER_INPUT_INVALID', '浏览器超时必须是 1–120000 毫秒');
18
+ }
19
+ return Object.freeze({ async renderHtml({ html, width, height, fullPage, signal }) {
20
+ const operation = AbortSignal.any([...(signal ? [signal] : []), AbortSignal.timeout(timeoutMs)]);
21
+ operation.throwIfAborted();
22
+ const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'iris-browser-'));
23
+ const route = '/' + crypto.randomBytes(16).toString('hex');
24
+ const server = http.createServer((req, res) => {
25
+ if (req.url !== route || !['GET', 'HEAD'].includes(req.method)) { res.writeHead(404); res.end(); return; }
26
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Content-Security-Policy': CSP,
27
+ 'Referrer-Policy': 'no-referrer', 'X-Content-Type-Options': 'nosniff', 'Cache-Control': 'no-store' });
28
+ res.end(req.method === 'HEAD' ? '' : html);
29
+ });
30
+ let child;
31
+ let closed;
32
+ let nextId = 0;
33
+ let incoming = Buffer.alloc(0);
34
+ const pending = new Map();
35
+ const events = new Map();
36
+ const rejectPending = error => {
37
+ for (const request of pending.values()) request.reject(error);
38
+ pending.clear();
39
+ for (const event of events.values()) event.reject(error);
40
+ events.clear();
41
+ };
42
+ const kill = () => {
43
+ if (!child?.pid) return;
44
+ try { process.kill(-child.pid, 'SIGKILL'); } catch (_) { /* 已结束 */ }
45
+ };
46
+ const onAbort = () => { rejectPending(operation.reason); kill(); };
47
+ operation.addEventListener('abort', onAbort, { once: true });
48
+ const send = (method, params = {}, sessionId) => {
49
+ operation.throwIfAborted();
50
+ return new Promise((resolve, reject) => {
51
+ const id = ++nextId;
52
+ pending.set(id, { resolve, reject });
53
+ child.stdio[3].write(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }) + '\0', error => {
54
+ if (error) { pending.delete(id); reject(failure('IRIS_BROWSER_FAILED', '浏览器通信失败')); }
55
+ });
56
+ });
57
+ };
58
+ try {
59
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
60
+ operation.throwIfAborted();
61
+ const args = ['--headless=new', '--hide-scrollbars', '--remote-debugging-pipe', '--disable-gpu', '--disable-dev-shm-usage', '--disable-background-networking',
62
+ '--no-first-run', '--no-default-browser-check', '--disable-extensions', '--disable-sync',
63
+ '--user-data-dir=' + profile, ...(noSandbox ? ['--no-sandbox'] : [])];
64
+ child = spawn(executable, args, { detached: true, stdio: ['ignore', 'ignore', 'ignore', 'pipe', 'pipe'] });
65
+ closed = new Promise(resolve => {
66
+ child.once('error', () => { rejectPending(failure('IRIS_BROWSER_FAILED', '无法启动 Chromium')); resolve(); });
67
+ child.once('close', () => { rejectPending(failure('IRIS_BROWSER_FAILED', 'Chromium 提前退出;请检查可执行文件及运行环境')); resolve(); });
68
+ });
69
+ child.stdio[3].on('error', () => rejectPending(failure('IRIS_BROWSER_FAILED', '浏览器通信失败')));
70
+ child.stdio[4].on('data', chunk => {
71
+ incoming = Buffer.concat([incoming, chunk]);
72
+ let separator;
73
+ while ((separator = incoming.indexOf(0)) >= 0) {
74
+ const packet = incoming.subarray(0, separator); incoming = incoming.subarray(separator + 1);
75
+ let message;
76
+ try { message = JSON.parse(packet.toString()); } catch (_) { continue; }
77
+ if (message.id) {
78
+ const request = pending.get(message.id);
79
+ pending.delete(message.id);
80
+ if (request) message.error ? request.reject(failure('IRIS_BROWSER_FAILED', 'Chromium 无法完成截图操作')) : request.resolve(message.result);
81
+ } else {
82
+ const key = message.sessionId + ':' + message.method;
83
+ const event = events.get(key);
84
+ if (event) { events.delete(key); event.resolve(message.params); }
85
+ }
86
+ }
87
+ });
88
+ const { targetId } = await send('Target.createTarget', { url: 'about:blank' });
89
+ const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true });
90
+ await send('Page.enable', {}, sessionId);
91
+ await send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: false }, sessionId);
92
+ const loaded = new Promise((resolve, reject) => events.set(sessionId + ':Page.loadEventFired', { resolve, reject }));
93
+ // 导航失败时也必须消费事件 Promise 的拒绝。
94
+ loaded.catch(() => {});
95
+ const navigation = await send('Page.navigate', { url: 'http://127.0.0.1:' + server.address().port + route }, sessionId);
96
+ if (navigation.errorText) throw failure('IRIS_BROWSER_FAILED', 'HTML 页面无法加载');
97
+ await loaded;
98
+ const layout = await send('Page.getLayoutMetrics', {}, sessionId);
99
+ const size = fullPage ? layout.cssContentSize : { width, height };
100
+ const captureWidth = Math.max(width, Math.ceil(size.width)), captureHeight = Math.ceil(size.height);
101
+ if (captureWidth > 16384 || captureHeight > 16384 || captureWidth * captureHeight > 40000000) {
102
+ throw failure('IRIS_BROWSER_INPUT_INVALID', '截图尺寸超过限制(边长 16384,4000 万像素)');
103
+ }
104
+ const screenshot = await send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: fullPage,
105
+ clip: { x: 0, y: 0, width: captureWidth, height: captureHeight, scale: 1 } }, sessionId);
106
+ operation.throwIfAborted();
107
+ return { bytes: Buffer.from(screenshot.data, 'base64'), mediaType: 'image/png' };
108
+ } finally {
109
+ operation.removeEventListener('abort', onAbort);
110
+ kill();
111
+ if (closed) await closed;
112
+ server.closeAllConnections();
113
+ await new Promise(resolve => server.close(resolve));
114
+ fs.rmSync(profile, { recursive: true, force: true });
115
+ }
116
+ } });
117
+ }