@mokuyoaxis/dsh-iris 0.1.4 → 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 (110) 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 +99 -1
  5. package/README.en.md +124 -31
  6. package/README.md +124 -31
  7. package/bin/dsh-iris.js +593 -13
  8. package/docs/AGENT_QUICKSTART.md +39 -0
  9. package/docs/ARCHITECTURE.md +87 -64
  10. package/docs/ARTIFACT_LIBRARY.md +5 -5
  11. package/docs/ARTIFACT_MANIFEST.md +60 -0
  12. package/docs/CLI_MANAGEMENT.md +181 -0
  13. package/docs/COMPOSITE_VISION.md +35 -0
  14. package/docs/CORE_RUNTIME_CONTRACT.md +84 -0
  15. package/docs/DSH_CORE_MIGRATION.md +135 -0
  16. package/docs/DSH_RC2_ADAPTATION.md +38 -0
  17. package/docs/FAULT_INJECTION.md +12 -2
  18. package/docs/HEADLESS_CLI.md +223 -0
  19. package/docs/HOST_ADAPTER_CONTRACT.md +36 -17
  20. package/docs/HOST_DOCTOR.md +4 -2
  21. package/docs/MODEL_PORT_CONTRACT.md +305 -0
  22. package/docs/OCR_MODEL.md +39 -0
  23. package/docs/PROMPT_OPTIMIZER.md +47 -0
  24. package/docs/PROVIDER_ADAPTER_CONTRACT.md +53 -10
  25. package/docs/PROVIDER_HEALTH.md +22 -5
  26. package/docs/PROVIDER_RUNTIME_HARNESS.md +33 -0
  27. package/docs/PROVIDER_SUBMISSION_CONTRACT.md +2 -2
  28. package/docs/ROADMAP.md +49 -83
  29. package/docs/SECURITY.md +8 -7
  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/assets/logo/README.md +55 -0
  35. package/docs/assets/logo/iris-bubble-concept.svg +67 -0
  36. package/docs/assets/logo/iris-bubble.svg +14 -0
  37. package/docs/assets/logo/iris-interim-flower.png +0 -0
  38. package/docs/assets/logo/iris-logo-mono-inverse.svg +78 -0
  39. package/docs/assets/logo/iris-logo-mono.svg +78 -0
  40. package/docs/assets/logo/iris-logo.svg +78 -0
  41. package/docs/assets/logo/iris-mark-mono-inverse.svg +42 -0
  42. package/docs/assets/logo/iris-mark-mono.svg +42 -0
  43. package/docs/assets/logo/iris-mark.svg +42 -0
  44. package/docs/file-access-across-environments.md +8 -5
  45. package/docs/releases/0.2.0.md +48 -0
  46. package/docs/releases/0.2.1.md +56 -0
  47. package/lib/actions.js +520 -591
  48. package/lib/adapters.js +184 -36
  49. package/lib/api.js +200 -11
  50. package/lib/chromium-browser.js +117 -0
  51. package/lib/client.js +1390 -105
  52. package/lib/command-service.js +534 -0
  53. package/lib/composite-vision-routing.js +42 -0
  54. package/lib/config.js +143 -51
  55. package/lib/core-artifact-store.js +630 -0
  56. package/lib/core-artifacts.js +4 -0
  57. package/lib/core-attention.js +103 -0
  58. package/lib/core-contract.js +105 -0
  59. package/lib/core-doctor.js +41 -0
  60. package/lib/core-lease-recovery.js +170 -0
  61. package/lib/core-maintenance.js +213 -0
  62. package/lib/core-runtime.js +232 -0
  63. package/lib/core-tasks.js +567 -0
  64. package/lib/core-user-projection.js +193 -0
  65. package/lib/doctor.js +92 -33
  66. package/lib/dsh-core-adapter.js +653 -0
  67. package/lib/dsh-host-adapter.js +58 -7
  68. package/lib/dsh-text-model-adapter.js +222 -0
  69. package/lib/dsh-vision-model-adapter.js +75 -0
  70. package/lib/generation-input.js +126 -0
  71. package/lib/guard.js +3 -3
  72. package/lib/headless-vision.js +181 -0
  73. package/lib/host-runtime.js +1 -0
  74. package/lib/http-vision-model-adapter.js +187 -0
  75. package/lib/image-edit-input.js +34 -0
  76. package/lib/index.js +323 -188
  77. package/lib/locate.js +86 -28
  78. package/lib/media-probe.js +2 -1
  79. package/lib/model-call-runtime.js +45 -0
  80. package/lib/model-invoker.js +220 -0
  81. package/lib/model-port-contract.js +341 -0
  82. package/lib/models.js +105 -14
  83. package/lib/ocr-model-routing.js +26 -0
  84. package/lib/ocr.js +110 -64
  85. package/lib/prompt-optimizer-config.js +2 -1
  86. package/lib/prompt-optimizer-core.js +121 -0
  87. package/lib/prompt-optimizer.js +37 -193
  88. package/lib/provider-adapter.js +49 -23
  89. package/lib/provider-adapters.js +165 -26
  90. package/lib/provider-catalog.js +183 -0
  91. package/lib/provider-config-service.js +236 -0
  92. package/lib/provider-contract.js +74 -4
  93. package/lib/provider-health.js +80 -1
  94. package/lib/provider-protocol.js +74 -2
  95. package/lib/provider-task-runner.js +370 -0
  96. package/lib/summarize.js +31 -20
  97. package/lib/summary-input.js +52 -0
  98. package/lib/task-wait.js +40 -0
  99. package/lib/tasks.js +16 -4
  100. package/lib/video-input.js +30 -0
  101. package/lib/vision-core.js +42 -0
  102. package/lib/vision-image-input.js +83 -0
  103. package/lib/vision-input.js +31 -0
  104. package/lib/vision-model-routing.js +180 -0
  105. package/lib/vision.js +31 -9
  106. package/lib/workbench-artifacts.js +52 -0
  107. package/lib/workbench-management.js +151 -0
  108. package/lib/workbench-zip.js +39 -0
  109. package/package.json +8 -8
  110. package/user_guide.md +71 -26
package/lib/api.js CHANGED
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * 输出纪律(与整仓一致):
9
9
  * - 只回标量字段(字符串/数字/布尔/受限数组),apiKey 永不明文,只给 hint;
10
- * - 文件路径只给相对 outputs/ 的 basename + 授权播放链接(token 随机 128bit);
10
+ * - legacy 文件路径只给相对 outputs/ 的 basename + 128-bit token 链接;Core 媒体按随机 Artifact ID 读取,不宣称独立 token;
11
11
  * - tasks/providers 读的是 $DSH_HOME/iris/v1 的持久 JSON,与任务盯守共用同一存储。
12
12
  */
13
13
  import fs from 'node:fs';
@@ -17,13 +17,18 @@ 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
- import { inferMediaProtocol } from './provider-protocol.js';
25
+ import { selectMediaProtocol } from './provider-protocol.js';
23
26
  import { deriveUserState } from './task-semantics.js';
24
27
  import { hostDoctor } from './doctor.js';
25
28
  import { hostRuntimeEvidence, recordHostClient } from './host-runtime.js';
29
+ import { cancelProviderTaskForDsh, coreSnapshotForDsh, disposeCoreTaskAttention, readCoreArtifactMediaForDsh, redeliverProviderTaskForDsh, reobserveProviderTaskForDsh, retryProviderTaskForDsh } from './dsh-core-adapter.js';
26
30
  import { optimizePrompt } from './prompt-optimizer.js';
31
+ import { PROMPT_OPTIMIZER_CAPABILITIES } from './prompt-optimizer-core.js';
27
32
  import {
28
33
  importPromptOptimizerConfig,
29
34
  loadPromptOptimizerConfig,
@@ -55,8 +60,9 @@ function providerPublic(p) {
55
60
  name: p.name || p.id,
56
61
  type: p.type || 'openai',
57
62
  baseUrl: p.baseUrl,
63
+ mediaBaseUrl: p.mediaBaseUrl || '',
58
64
  enabled: p.enabled !== false,
59
- mediaProtocol: p.mediaProtocol || inferMediaProtocol(p.baseUrl),
65
+ ...selectMediaProtocol(p),
60
66
  apiKeyHint: keyHint(p.apiKey),
61
67
  capabilities: capabilitiesOf(p),
62
68
  // 迁移/推断结果是否与原始声明不同(UI 提示旧配置已推断)
@@ -329,6 +335,9 @@ export function serveApi(req, res, host) {
329
335
 
330
336
  // POST /iris/api/actions/:name —— GUI 直连触发工具执行
331
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);
332
341
  if (urlPath === '/iris/api/host-client') {
333
342
  if (!host || typeof host !== 'object') return sendJson(res, 503, { error: 'host unavailable' });
334
343
  return handleJsonPost(req, res, (body) => recordHostClient(body));
@@ -337,6 +346,13 @@ export function serveApi(req, res, host) {
337
346
  if (urlPath === '/iris/api/upload') {
338
347
  return handleUpload(req, res);
339
348
  }
349
+ // D4 人工 retry as new task:产生新的真实计费,请求体必须显式 confirmBilling:true
350
+ const coreTaskRetry = urlPath.match(/^\/iris\/api\/core\/task\/([^/]+)\/retry\/?$/);
351
+ if (coreTaskRetry) return handleCoreTaskRetry(coreTaskRetry[1], req, res);
352
+ // D1/D2/D3 人工动作:reobserve 显式单步 poll 一次;redeliver 只对 succeeded+failed
353
+ // 重新取回产物;cancel 只有 Provider 明确确认才写已取消。三者绝不 submit。
354
+ const coreTaskAction = urlPath.match(/^\/iris\/api\/core\/task\/([^/]+)\/(reobserve|redeliver|cancel|acknowledge|restore|hide|unhide)\/?$/);
355
+ if (coreTaskAction) return handleCoreTaskManual(coreTaskAction[2], coreTaskAction[1], req, res);
340
356
  const promptMatch = typeof host === 'object' && urlPath.match(/^\/iris\/api\/prompt-optimizer\/(optimize|import|reset|enabled)\/?$/);
341
357
  if (promptMatch) return handlePromptOptimizer(host, promptMatch[1], req, res);
342
358
  const actionMatch = typeof host === 'object' && urlPath.match(/^\/iris\/api\/actions\/([A-Za-z0-9_-]+)\/?$/);
@@ -353,12 +369,25 @@ export function serveApi(req, res, host) {
353
369
  return serveSse(res);
354
370
  }
355
371
  if (urlPath === '/iris/api/prompt-optimizer/config') {
356
- return sendJson(res, 200, method === 'HEAD' ? {} : loadPromptOptimizerConfig());
372
+ return sendJson(res, 200, method === 'HEAD' ? {} : { ...loadPromptOptimizerConfig(), capabilities: PROMPT_OPTIMIZER_CAPABILITIES });
357
373
  }
358
374
  if (urlPath === '/iris/api/doctor') {
359
375
  if (!host || typeof host !== 'object') return sendJson(res, 503, { error: 'host unavailable' });
360
376
  return sendJson(res, 200, method === 'HEAD' ? {} : hostDoctor(host, hostRuntimeEvidence()));
361
377
  }
378
+ if (urlPath === '/iris/api/core/snapshot') {
379
+ if (method === 'HEAD') return sendJson(res, 200, {});
380
+ const query = new URL(req.url || '', 'http://iris.local').searchParams;
381
+ return serveCoreSnapshot(res, { limit: Number(query.get('limit') || 12) });
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]));
389
+ const coreMediaMatch = urlPath.match(/^\/iris\/api\/core\/artifact\/(artifact_[a-f0-9]{24})\/media$/);
390
+ if (coreMediaMatch) return serveCoreArtifactMedia(coreMediaMatch[1], method, res);
362
391
  if (urlPath === '/iris/api/artifacts') {
363
392
  const query = new URL(req.url || '', 'http://iris.local').searchParams;
364
393
  const page = artifacts.page({ offset: Number(query.get('offset') || 0), limit: Number(query.get('limit') || 24) });
@@ -384,6 +413,63 @@ export function serveApi(req, res, host) {
384
413
  }
385
414
  }
386
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
+
442
+ async function serveCoreArtifactMedia(artifactId, method, res) {
443
+ try {
444
+ const result = await readCoreArtifactMediaForDsh(artifactId);
445
+ if (res.writableEnded || res.destroyed) return;
446
+ res.writeHead(200, {
447
+ 'Content-Type': result.artifact.mediaType,
448
+ 'Content-Length': result.bytes.length,
449
+ 'Content-Disposition': 'inline',
450
+ 'Cache-Control': 'private, max-age=3600',
451
+ 'X-Content-Type-Options': 'nosniff',
452
+ 'Cross-Origin-Resource-Policy': 'same-origin',
453
+ 'Referrer-Policy': 'no-referrer'
454
+ });
455
+ res.end(method === 'HEAD' ? undefined : result.bytes);
456
+ } catch (_) {
457
+ return sendJson(res, 404, { error: 'not found' });
458
+ }
459
+ }
460
+
461
+ async function serveCoreSnapshot(res, options = {}) {
462
+ try {
463
+ return sendJson(res, 200, await coreSnapshotForDsh({ limit: options.limit || 12 }));
464
+ } catch (error) {
465
+ console.error('[iris] Core 只读快照失败:', error && error.code || 'unknown');
466
+ return sendJson(res, 409, {
467
+ schemaVersion: 0, available: true, readOnly: true,
468
+ error: { code: String(error && error.code || 'IRIS_CORE_SNAPSHOT_FAILED') }
469
+ });
470
+ }
471
+ }
472
+
387
473
  function sendJson(res, status, obj) {
388
474
  if (res.writableEnded || res.destroyed) return; // 已结束/已销毁的连接不再写(end 后二次 end 防御)
389
475
  if (!res.headersSent) res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' });
@@ -392,7 +478,7 @@ function sendJson(res, status, obj) {
392
478
 
393
479
  /** POST /iris/api/actions/:name:读取 JSON body 调 runAction 返回结果 */
394
480
  const MAX_BODY_BYTES = 1e6; // 1MB 上限(防滥用)
395
- function handleJsonPost(req, res, execute) {
481
+ function handleJsonPost(req, res, execute, respond = sendJson) {
396
482
  const chunks = [];
397
483
  const controller = new AbortController();
398
484
  let completed = false;
@@ -401,7 +487,7 @@ function handleJsonPost(req, res, execute) {
401
487
  const finish = (status, body) => {
402
488
  if (completed) return;
403
489
  completed = true;
404
- sendJson(res, status, body);
490
+ (status === 200 ? respond : sendJson)(res, status, body);
405
491
  };
406
492
  const disconnect = () => { if (!completed) controller.abort(); };
407
493
  if (typeof req.on === 'function') req.on('aborted', disconnect);
@@ -422,10 +508,13 @@ function handleJsonPost(req, res, execute) {
422
508
  const raw = Buffer.concat(chunks).toString('utf8');
423
509
  const body = raw ? JSON.parse(raw) : {};
424
510
  const result = await execute(body, controller.signal);
511
+ if (controller.signal.aborted) return finish(499, { error: 'request canceled' });
425
512
  finish(200, result);
426
513
  } catch (err) {
427
- finish(controller.signal.aborted ? 499 : 400, {
428
- error: controller.signal.aborted ? 'request canceled' : String((err && err.message) || err)
514
+ finish(controller.signal.aborted ? 499 : (Number(err && err.statusCode) || 400), {
515
+ error: controller.signal.aborted ? 'request canceled' : String((err && err.message) || err),
516
+ ...(err && err.code ? { code: String(err.code) } : {}),
517
+ ...(err && /^delete_[a-f0-9]{24}$/.test(err.transactionId) ? { transactionId: err.transactionId } : {})
429
518
  });
430
519
  }
431
520
  });
@@ -436,15 +525,115 @@ function handleAction(host, name, req, res) {
436
525
  return handleJsonPost(req, res, (args, signal) => runAction(host, name, args, { signal }));
437
526
  }
438
527
 
528
+ /**
529
+ * POST /iris/api/core/task/:id/(reobserve|redeliver) —— D1/D2 人工动作。
530
+ * 成功返回 200 + 任务最新五类用户投影行;坏 ID/未知/不满足动作门映射为稳定 4xx。
531
+ * 任何拒绝都在 Provider 调用与 Task 写入之前发生;错误体不含绝对路径、Key、
532
+ * 供应商身份或 binding。请求体不接受参数(Task ID 只在 URL 里),客户端断开
533
+ * 经 AbortSignal 传入在途操作,与 GUI actions 同一约定。
534
+ */
535
+ const CORE_TASK_MANUAL_ACTIONS = Object.freeze({
536
+ reobserve: { command: 'task.reobserve', run: reobserveProviderTaskForDsh },
537
+ redeliver: { command: 'task.redeliver', run: redeliverProviderTaskForDsh },
538
+ cancel: { command: 'task.cancel', run: cancelProviderTaskForDsh },
539
+ // 注意力处置是 Host 偏好(不写 Core 事实):acknowledge/restore/hide/unhide。
540
+ acknowledge: { command: 'attention.acknowledge', run: (input) => disposeCoreTaskAttention({ ...input, action: 'acknowledge' }) },
541
+ restore: { command: 'attention.restore', run: (input) => disposeCoreTaskAttention({ ...input, action: 'restore' }) },
542
+ hide: { command: 'attention.hide', run: (input) => disposeCoreTaskAttention({ ...input, action: 'hide' }) },
543
+ unhide: { command: 'attention.unhide', run: (input) => disposeCoreTaskAttention({ ...input, action: 'unhide' }) }
544
+ });
545
+
546
+ /**
547
+ * POST /iris/api/core/task/:id/retry —— D4 人工重试为新任务。
548
+ * body 必须携带 { prompt, confirmBilling: true, modelRef? }:Core 记录不持久化
549
+ * Prompt,只能由调用方重新提供;缺显式计费确认在读取旧 Task 之前拒绝。
550
+ * 成功返回 200 + 新任务的五类用户投影行与 retriedFrom 关系。
551
+ */
552
+ function handleCoreTaskRetry(taskId, req, res) {
553
+ if (!/^task_[a-f0-9]{24}$/.test(taskId)) {
554
+ return sendJson(res, 400, { error: { code: 'IRIS_DSH_TASK_INVALID', message: 'Core Task ID 无效' } });
555
+ }
556
+ handleJsonPost(req, res, async (body, signal) => {
557
+ try {
558
+ if (!body || body.confirmBilling !== true) {
559
+ const error = new Error('重试会创建新任务并可能产生重复生成费用;请求体必须显式 confirmBilling:true');
560
+ error.code = 'IRIS_COMMAND_BILLING_CONFIRM_REQUIRED';
561
+ error.statusCode = 400;
562
+ throw error;
563
+ }
564
+ const prompt = String(body.prompt || '');
565
+ const result = await retryProviderTaskForDsh({
566
+ taskId,
567
+ prompt,
568
+ ...(body.source_artifact_id !== undefined ? { sourceArtifactId: body.source_artifact_id } : {}),
569
+ ...(body.text !== undefined ? { text: String(body.text) } : {}),
570
+ ...(body.voice !== undefined ? { voice: String(body.voice) } : {}),
571
+ ...(body.audio_url !== undefined ? { audio_url: String(body.audio_url) } : {}),
572
+ modelRef: typeof body.modelRef === 'string' && body.modelRef.trim() ? body.modelRef.trim() : undefined,
573
+ confirmBilling: true,
574
+ signal
575
+ });
576
+ return {
577
+ ok: true,
578
+ contractVersion: 0,
579
+ command: 'task.retry',
580
+ retriedFrom: result.retriedFrom,
581
+ task: result.row
582
+ };
583
+ } catch (error) {
584
+ const code = String(error && error.code || '');
585
+ error.statusCode = Number(error.statusCode)
586
+ || (code === 'IRIS_TASK_NOT_FOUND' ? 404
587
+ : (code === 'IRIS_TASK_NOT_RETRYABLE' || code.startsWith('IRIS_PROVIDER_')) ? 409
588
+ : 400);
589
+ throw error;
590
+ }
591
+ });
592
+ }
593
+
594
+ function handleCoreTaskManual(action, taskId, req, res) {
595
+ if (!/^task_[a-f0-9]{24}$/.test(taskId)) {
596
+ return sendJson(res, 400, { error: { code: 'IRIS_DSH_TASK_INVALID', message: 'Core Task ID 无效' } });
597
+ }
598
+ const entry = CORE_TASK_MANUAL_ACTIONS[action];
599
+ if (!entry) return sendJson(res, 404, { error: 'not found' });
600
+ const controller = new AbortController();
601
+ let completed = false;
602
+ const finish = (status, body) => {
603
+ if (completed) return;
604
+ completed = true;
605
+ sendJson(res, status, body);
606
+ };
607
+ const disconnect = () => { if (!completed) controller.abort(); };
608
+ if (typeof req.on === 'function') req.on('aborted', disconnect);
609
+ if (typeof res.on === 'function') res.on('close', disconnect);
610
+ if (typeof req.resume === 'function') req.resume(); // 无 body 入参;避免小请求体滞留
611
+ entry.run({ taskId, signal: controller.signal })
612
+ .then((result) => finish(200, {
613
+ ok: true,
614
+ contractVersion: 0,
615
+ command: entry.command,
616
+ task: result.row
617
+ }))
618
+ .catch((error) => {
619
+ const code = String(error && error.code || 'IRIS_CORE_MANUAL_ACTION_FAILED');
620
+ const status = controller.signal.aborted ? 499
621
+ : code === 'IRIS_TASK_NOT_FOUND' ? 404
622
+ : (code === 'IRIS_TASK_ID_INVALID' || code === 'IRIS_DSH_TASK_INVALID') ? 400
623
+ : 409;
624
+ finish(status, { error: { code, message: String((error && error.message) || '无法执行 Core Task 动作') } });
625
+ });
626
+ }
627
+
439
628
  function handlePromptOptimizer(host, operation, req, res) {
440
629
  return handleJsonPost(req, res, async (body, signal) => {
441
630
  if (operation === 'optimize') return optimizePrompt(host, body, { signal });
442
631
  if (operation === 'import') {
443
632
  const imported = body && body.config !== undefined ? body.config : body;
444
- return { ok: true, ...importPromptOptimizerConfig(imported) };
633
+ return { ok: true, ...importPromptOptimizerConfig(imported), capabilities: PROMPT_OPTIMIZER_CAPABILITIES };
445
634
  }
446
- if (operation === 'reset') return { ok: true, ...resetPromptOptimizerConfig() };
447
- 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 };
448
637
  throw new Error('unknown prompt optimizer operation');
449
638
  });
450
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
+ }