@mokuyoaxis/dsh-iris 0.2.7 → 0.3.0-beta.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 (234) hide show
  1. package/CHANGELOG.md +143 -1
  2. package/README.en.md +109 -311
  3. package/README.md +106 -309
  4. package/bin/dsh-iris.js +452 -92
  5. package/docs/AGENT_CORE_QUICKSTART.md +64 -0
  6. package/docs/AGENT_QUICKSTART.md +2 -0
  7. package/docs/ARCHITECTURE.md +13 -3
  8. package/docs/ARTIFACT_MANIFEST.md +4 -0
  9. package/docs/AUDIO_FLOWS.md +87 -0
  10. package/docs/BETA_SCOPE.md +42 -0
  11. package/docs/CONTACT_SHEET.md +62 -0
  12. package/docs/CORE_CONNECTIONS.md +101 -0
  13. package/docs/CORE_QUICKSTART.md +110 -0
  14. package/docs/CORE_RUNTIME_CONTRACT.md +7 -2
  15. package/docs/CORE_SERVICE.md +101 -0
  16. package/docs/CORE_SKILLS.md +35 -3
  17. package/docs/DSH_COMPATIBILITY.md +21 -1
  18. package/docs/DSH_CORE_MIGRATION.md +6 -0
  19. package/docs/DSH_RC2_ADAPTATION.md +1 -1
  20. package/docs/DSH_RUNTIME.md +6 -2
  21. package/docs/HEADLESS_CLI.md +30 -5
  22. package/docs/HOST_ADAPTER_CONTRACT.md +5 -3
  23. package/docs/IMAGE_FLOW.md +4 -0
  24. package/docs/IMAGE_GENERATION.md +127 -0
  25. package/docs/LOCAL_API.md +4 -2
  26. package/docs/LOCAL_API_CONTRACT.md +42 -15
  27. package/docs/LOCAL_CREATION.md +7 -1
  28. package/docs/LOCAL_OPERATIONS.md +46 -0
  29. package/docs/LOCAL_SELECTION.md +57 -0
  30. package/docs/LOCAL_WEB.md +207 -0
  31. package/docs/MCP.md +212 -0
  32. package/docs/MEDIA_SCHEDULING.md +72 -0
  33. package/docs/MUSIC.md +2 -0
  34. package/docs/NETWORK_RESOURCES.md +106 -0
  35. package/docs/NETWORK_ROUTING.md +103 -0
  36. package/docs/NETWORK_WORKBENCH.md +65 -0
  37. package/docs/PANEL_EXTENSIONS.md +64 -0
  38. package/docs/PLUGINS.md +193 -0
  39. package/docs/PLUGIN_DEVELOPMENT.md +39 -0
  40. package/docs/PLUGIN_STARTUP.md +113 -0
  41. package/docs/PROVIDER_ADAPTER_CONTRACT.md +8 -2
  42. package/docs/PROVIDER_CREDENTIALS.md +67 -0
  43. package/docs/PROVIDER_EXTENSIONS.md +82 -0
  44. package/docs/PROVIDER_HEALTH.md +5 -1
  45. package/docs/PROVIDER_NETWORK.md +45 -0
  46. package/docs/PROVIDER_PLUGIN_CONTRACT.md +24 -0
  47. package/docs/PROVIDER_SETTINGS.md +91 -0
  48. package/docs/PROVIDER_SUBMISSION_CONTRACT.md +8 -0
  49. package/docs/ROADMAP.md +18 -8
  50. package/docs/SECURITY.md +1 -1
  51. package/docs/SYNC_DELIVERY_RECOVERY.md +5 -1
  52. package/docs/VIDEO_FLOWS.md +89 -0
  53. package/docs/VISION_CLI.md +1 -1
  54. package/docs/VISION_MODEL.md +2 -0
  55. package/docs/WEB_PROTOTYPE.md +21 -6
  56. package/docs/WEB_RECOVERY.md +46 -0
  57. package/docs/WORKBENCH_ARTIFACTS.md +6 -0
  58. package/docs/WORKBENCH_LOGIN.md +63 -0
  59. package/docs/assets/screenshots/core-connections-preview.webp +0 -0
  60. package/docs/assets/screenshots/core-model-pools-preview.webp +0 -0
  61. package/docs/assets/screenshots/core-overview-preview.webp +0 -0
  62. package/docs/assets/screenshots/core-spectrum-preview.webp +0 -0
  63. package/docs/assets/screenshots/core-studio-dark-preview.webp +0 -0
  64. package/docs/assets/screenshots/core-studio-mobile-preview.webp +0 -0
  65. package/docs/assets/screenshots/core-studio-preview.webp +0 -0
  66. package/docs/releases/0.3.0-alpha.1.md +51 -0
  67. package/docs/releases/0.3.0-beta.1.md +34 -0
  68. package/docs/screenshots.md +41 -1
  69. package/lib/actions.js +56 -4
  70. package/lib/adapters.js +75 -49
  71. package/lib/api.js +23 -13
  72. package/lib/application-runtime.js +73 -17
  73. package/lib/application-writer.js +193 -0
  74. package/lib/audio-flow-input.js +37 -0
  75. package/lib/client.d.ts +3 -3
  76. package/lib/client.js +402 -72
  77. package/lib/comfyui-adapter.js +95 -0
  78. package/lib/comfyui-client.js +60 -0
  79. package/lib/comfyui-inspection.js +73 -0
  80. package/lib/comfyui-workflow.js +94 -0
  81. package/lib/command-service.js +18 -5
  82. package/lib/config.js +49 -10
  83. package/lib/configured-image-runtime.js +69 -0
  84. package/lib/connection-skill.js +98 -0
  85. package/lib/connection-template.js +23 -0
  86. package/lib/core-artifact-store.js +84 -28
  87. package/lib/core-bridge-selection.js +31 -0
  88. package/lib/core-bridge-tools.js +85 -0
  89. package/lib/core-capability-catalog.js +192 -0
  90. package/lib/core-connection-diagnostics.js +49 -0
  91. package/lib/core-connection-store.js +277 -0
  92. package/lib/core-connections.js +225 -0
  93. package/lib/core-delivery-cache.js +8 -3
  94. package/lib/core-maintenance.js +9 -3
  95. package/lib/core-runtime.js +2 -1
  96. package/lib/core-service-client.js +174 -0
  97. package/lib/core-tasks.js +113 -21
  98. package/lib/core-writer-lease.js +16 -0
  99. package/lib/creation-request-store.js +41 -64
  100. package/lib/doctor.js +2 -2
  101. package/lib/dsh-core-adapter.js +61 -9
  102. package/lib/dsh-host-adapter.js +4 -1
  103. package/lib/dsh-provider-config.js +1 -1
  104. package/lib/dsh-service-bridge.js +67 -0
  105. package/lib/execution-ownership.js +43 -0
  106. package/lib/flow-store.js +156 -14
  107. package/lib/generation-input.js +10 -1
  108. package/lib/headless-vision.js +7 -7
  109. package/lib/image-edit-input.js +1 -1
  110. package/lib/image-flow.js +97 -31
  111. package/lib/index.js +47 -22
  112. package/lib/local-api-projection.js +15 -6
  113. package/lib/local-api-token-directory.js +31 -0
  114. package/lib/local-api.js +861 -71
  115. package/lib/local-creation.js +96 -24
  116. package/lib/local-operations.js +36 -0
  117. package/lib/local-request-root.js +86 -0
  118. package/lib/local-web-assets.js +39 -0
  119. package/lib/mcp-bridge.js +58 -0
  120. package/lib/mcp-check.js +26 -0
  121. package/lib/mcp-config.js +42 -0
  122. package/lib/mcp-delivery.js +46 -0
  123. package/lib/mcp-http.js +174 -0
  124. package/lib/mcp-server.js +69 -0
  125. package/lib/media-scheduler.js +194 -0
  126. package/lib/model-directory-service.js +37 -10
  127. package/lib/model-directory.js +3 -3
  128. package/lib/models.js +2 -2
  129. package/lib/music-transport.js +6 -3
  130. package/lib/network-config-service.js +123 -0
  131. package/lib/network-config.js +127 -0
  132. package/lib/network-doctor.js +94 -0
  133. package/lib/network-management.js +51 -0
  134. package/lib/network-policy.js +19 -0
  135. package/lib/network-route-evidence.js +38 -0
  136. package/lib/network-routing.js +283 -0
  137. package/lib/operation-runner.js +281 -0
  138. package/lib/operation-store.js +216 -0
  139. package/lib/panel-extension.js +36 -0
  140. package/lib/plugin-contract.js +194 -0
  141. package/lib/plugin-execution.js +51 -0
  142. package/lib/plugin-launch.js +86 -0
  143. package/lib/plugin-operation-store.js +101 -0
  144. package/lib/plugin-operations.js +28 -0
  145. package/lib/plugin-registry.js +109 -0
  146. package/lib/plugin-selection.js +15 -0
  147. package/lib/plugin-source-image.js +5 -0
  148. package/lib/plugin-web-assets.js +54 -0
  149. package/lib/plugins.d.ts +307 -0
  150. package/lib/plugins.js +59 -0
  151. package/lib/provider-adapter.js +7 -3
  152. package/lib/provider-adapters.js +92 -22
  153. package/lib/provider-catalog.js +64 -36
  154. package/lib/provider-config-format.js +51 -0
  155. package/lib/provider-config-service.js +143 -31
  156. package/lib/provider-context.d.ts +90 -0
  157. package/lib/provider-context.js +42 -0
  158. package/lib/provider-credentials.js +97 -0
  159. package/lib/provider-diagnostics.js +56 -0
  160. package/lib/provider-extensions.js +29 -0
  161. package/lib/provider-health.js +18 -6
  162. package/lib/provider-monitor.js +81 -0
  163. package/lib/provider-network.js +52 -0
  164. package/lib/provider-protocol.js +73 -22
  165. package/lib/provider-registry.js +128 -0
  166. package/lib/provider-response.js +38 -0
  167. package/lib/provider-settings.js +136 -0
  168. package/lib/provider-task-runner.js +132 -43
  169. package/lib/provider-transport.js +16 -0
  170. package/lib/render.js +11 -1
  171. package/lib/scheduling-policy.js +66 -0
  172. package/lib/skill-installation.js +23 -11
  173. package/lib/tasks.js +3 -2
  174. package/lib/video-flow-input.js +16 -0
  175. package/lib/video-input.js +12 -5
  176. package/lib/vision-model-routing.js +5 -0
  177. package/lib/web/app.js +703 -0
  178. package/lib/web/appearance.js +111 -0
  179. package/lib/web/comfyui.js +146 -0
  180. package/lib/web/connections.js +516 -0
  181. package/lib/web/creation.js +359 -0
  182. package/lib/web/flow-view.js +34 -0
  183. package/lib/web/gallery.js +133 -0
  184. package/lib/web/index.html +344 -0
  185. package/lib/web/media-client.js +218 -0
  186. package/lib/web/network.js +232 -0
  187. package/lib/web/plugin-panel.js +223 -0
  188. package/lib/web/plugin-status.js +140 -0
  189. package/lib/web/plugin-surface.css +17 -0
  190. package/lib/web/providers.js +513 -0
  191. package/lib/web/recovery.js +105 -0
  192. package/lib/web/startup.js +18 -0
  193. package/lib/web/styles.css +571 -0
  194. package/lib/web/transport.js +101 -0
  195. package/lib/web/workbench-login.js +165 -0
  196. package/lib/workbench-artifacts.js +3 -2
  197. package/lib/workbench-auth-cli.js +47 -0
  198. package/lib/workbench-auth.js +200 -0
  199. package/package.json +24 -6
  200. package/plugins/contact-sheet/index.d.mts +4 -0
  201. package/plugins/contact-sheet/index.mjs +33 -0
  202. package/plugins/contact-sheet/web/board.css +14 -0
  203. package/plugins/contact-sheet/web/board.mjs +93 -0
  204. package/skills/iris-core/SKILL.md +8 -4
  205. package/skills/iris-core/references/cli.md +2 -2
  206. package/skills/iris-core/references/comfyui.md +69 -0
  207. package/skills/iris-core/references/tasks.md +2 -0
  208. package/templates/plugin-starter/LICENSE +21 -0
  209. package/templates/plugin-starter/README.md +74 -0
  210. package/templates/plugin-starter/assets/peach.png +0 -0
  211. package/templates/plugin-starter/assets/violet.png +0 -0
  212. package/templates/plugin-starter/index.mjs +27 -0
  213. package/templates/plugin-starter/package.json +18 -0
  214. package/templates/plugin-starter/scripts/core-cli.mjs +20 -0
  215. package/templates/plugin-starter/scripts/environment.mjs +65 -0
  216. package/templates/plugin-starter/scripts/mcp-probe.mjs +22 -0
  217. package/templates/plugin-starter/scripts/setup.mjs +42 -0
  218. package/templates/plugin-starter/scripts/smoke.mjs +76 -0
  219. package/templates/plugin-starter/web/board.css +27 -0
  220. package/templates/plugin-starter/web/board.mjs +160 -0
  221. package/templates/plugin-starter/web/png.mjs +23 -0
  222. package/templates/provider-starter/LICENSE +21 -0
  223. package/templates/provider-starter/README.md +50 -0
  224. package/templates/provider-starter/index.d.mts +5 -0
  225. package/templates/provider-starter/index.mjs +165 -0
  226. package/templates/provider-starter/package.json +13 -0
  227. package/templates/provider-starter/scripts/core-cli.mjs +22 -0
  228. package/templates/provider-starter/scripts/environment.mjs +122 -0
  229. package/templates/provider-starter/scripts/fixture.mjs +54 -0
  230. package/templates/provider-starter/scripts/mcp-probe.mjs +21 -0
  231. package/templates/provider-starter/scripts/protocol-smoke.mjs +48 -0
  232. package/templates/provider-starter/scripts/setup.mjs +44 -0
  233. package/templates/provider-starter/scripts/smoke.mjs +145 -0
  234. package/user_guide.md +5 -1
package/lib/actions.js CHANGED
@@ -30,7 +30,7 @@ import { formatOcrResult } from './ocr.js';
30
30
  import { runOcrRequest } from './ocr-model-routing.js';
31
31
  import { ffmpegAvailable, extractFrames, probeVideo } from './media-probe.js';
32
32
  import { ensurePrivateDir, writePrivateFile } from './private-storage.js';
33
- import { selectMediaProtocol, selectImageProtocol, providerForImageModel, supportsImageEdit, supportsModelCapability, dashscopeTtsProtocolIssue, dashscopeTtsInputIssue } from './provider-protocol.js';
33
+ import { selectMediaProtocol, selectImageProtocol, providerForImageModel, providerApiKey, supportsImageEdit, supportsModelCapability, dashscopeTtsProtocolIssue, dashscopeTtsInputIssue } from './provider-protocol.js';
34
34
  import { musicCandidatesFromCatalog, providerTaskBinding } from './provider-catalog.js';
35
35
  import { invokeProviderOperation, providerPollTaskView, prepareProviderInput } from './provider-adapter.js';
36
36
  import { createConfiguredProviderAdapter as createProviderAdapter } from './provider-adapters.js';
@@ -40,10 +40,14 @@ import { videoTaskCandidates } from './video-input.js';
40
40
  import { normalizeDshProvider, withDshProviderConfig } from './dsh-provider-config.js';
41
41
  import { modelDirectorySummary, modelUnavailableReason } from './model-directory.js';
42
42
  import { ModelPortError } from './model-port-contract.js';
43
+ import { compileComfyWorkflow, projectComfyWorkflow } from './comfyui-workflow.js';
44
+ import { createComfyUiClient } from './comfyui-client.js';
45
+ import { inspectComfyWorkflow } from './comfyui-inspection.js';
46
+ import { createProviderSecretScrubber } from './provider-credentials.js';
43
47
 
44
48
  const PROVIDER_ACTIONS = new Set(['image', 'image_edit', 'video', 'music', 'tts', 'transcribe', 'look', 'relook',
45
49
  'locate', 'ocr', 'media_summarize', 'task_reobserve', 'task_redeliver', 'task_manual_retry',
46
- 'providers_discover', 'providers_sync_directory', 'providers_test_vision', 'providers_test_model']);
50
+ 'providers_discover', 'providers_sync_directory', 'providers_test_vision', 'providers_test_model', 'providers_check_comfy_workflow']);
47
51
  const AUTO_DIRECTORY_ACTIONS = new Set(['image', 'image_edit', 'video', 'music', 'tts', 'transcribe', 'look', 'relook',
48
52
  'locate', 'ocr', 'media_summarize']);
49
53
  const VISION_ACTIONS = new Set(['look', 'relook', 'locate', 'ocr', 'media_summarize']);
@@ -64,6 +68,12 @@ function imageRoutingForModel(provider, modelId) {
64
68
  return { ...routing, editSupported: supportsImageEdit(routing.mediaProtocol, modelId) };
65
69
  }
66
70
 
71
+ function comfySummaryForModel(provider, modelId) {
72
+ const route = providerForImageModel(provider, modelId);
73
+ return route.mediaProtocol === 'comfyui' && route.comfyWorkflow !== undefined
74
+ ? { comfyWorkflow: projectComfyWorkflow(route.comfyWorkflow) } : {};
75
+ }
76
+
67
77
  const EMPTY_ACTION_HOST = defineHostAdapter({
68
78
  id: 'iris-hostless',
69
79
  ports: {},
@@ -512,11 +522,22 @@ async function imageAction(host, args, { signal, core }, editing = false) {
512
522
  if (editing && !args.source_artifact_id) throw new Error('iris: 改图必须提供 source_artifact_id');
513
523
  const { providerInput, sourceArtifactId } = normalizeGenerationInput('image', {
514
524
  prompt: args.prompt, size: optionalActionText(args.size), n: args.n,
525
+ ...(args.workflow_parameters !== undefined ? { model_ref: args.model, workflow_parameters: args.workflow_parameters } : {}),
515
526
  ...(editing ? { source_artifact_id: args.source_artifact_id } : {})
516
527
  });
517
528
 
518
529
  const imageProviders = selectProviders(cap.CAPABILITIES.IMAGE, '图像', args.model);
519
530
  const routes = imageProviders.map((provider) => imageRoute(provider, args));
531
+ if (args.workflow_sha !== undefined && (typeof args.workflow_sha !== 'string' || routes.length !== 1
532
+ || projectComfyWorkflow(routes[0].provider.comfyWorkflow)?.sha256 !== args.workflow_sha)) {
533
+ throw Object.assign(new Error('工作流已变化,请重新选择并核对参数'), { code: 'IRIS_CONFIG_CHANGED' });
534
+ }
535
+ if (providerInput.workflow_parameters !== undefined) {
536
+ if (!models.parseModelRef(args.model) || routes.length !== 1 || routes[0].protocol !== 'comfyui') {
537
+ throw new TypeError('工作流参数需要明确选择一个 ComfyUI 工作流');
538
+ }
539
+ compileComfyWorkflow(routes[0].provider.comfyWorkflow, providerInput);
540
+ }
520
541
  const submitted = await submitCoreImage(core, routes, providerInput, signal, sourceArtifactId);
521
542
  const firstArtifactId = submitted.task.artifactIds[0];
522
543
  const ready = submitted.task.outcome === 'succeeded'
@@ -1084,13 +1105,14 @@ register('providers_list', async (host, args) => {
1084
1105
  ...selectMediaProtocol(p),
1085
1106
  visionInput: p.visionInput || null,
1086
1107
  directory: modelDirectorySummary(p),
1087
- apiKeyHint: p.dshProvider ? '由 DSH 管理' : String(p.apiKey || '').length <= 8 ? '****' : String(p.apiKey).slice(0, 3) + '****' + String(p.apiKey).slice(-4),
1108
+ apiKeyHint: p.dshProvider ? '由 DSH 管理' : p.apiKeyEnv ? '由环境变量提供' : String(p.apiKey || '').length <= 8 ? '****' : String(p.apiKey).slice(0, 3) + '****' + String(p.apiKey).slice(-4),
1088
1109
  models: models.providerModelsWithDirectory(p).map((m) => ({
1089
1110
  id: m.id,
1090
1111
  capabilities: m.capabilities,
1091
1112
  ...(m.directoryOnly ? { directoryOnly: true } : {}),
1092
1113
  ...(m.catalogStatus ? { catalogStatus: m.catalogStatus } : {}),
1093
1114
  ...(m.imageProtocol !== undefined ? { imageProtocol: m.imageProtocol } : {}),
1115
+ ...comfySummaryForModel(p, m.id),
1094
1116
  ...(m.musicProtocol !== undefined ? { musicProtocol: m.musicProtocol } : {}),
1095
1117
  ...(m.visionInput !== undefined ? { visionInput: m.visionInput } : {}),
1096
1118
  ...(m.capabilities.includes(cap.CAPABILITIES.VISION) ? { visionInputEffective: visionInputLimits(p, m.id) } : {}),
@@ -1112,6 +1134,34 @@ register('providers_host_connections', async host => {
1112
1134
  return { ok: true, connections: await host.ports.providerConnections.list() };
1113
1135
  });
1114
1136
 
1137
+ /** Explicit template save; no provider request or model execution. */
1138
+ register('providers_save_comfy_workflow', async (_host, args) => {
1139
+ if (Object.keys(args).some(key => !['id', 'model_id', 'workflow', 'expectedSha', 'enableAutomatic'].includes(key))) throw new TypeError('工作流保存包含不支持的字段');
1140
+ const entry = store.setComfyWorkflow(args.id, args.model_id, args.workflow, {
1141
+ expectedSha: args.expectedSha, enableAutomatic: args.enableAutomatic
1142
+ });
1143
+ return { ok: true, modelId: entry.id, comfyWorkflow: projectComfyWorkflow(entry.comfyWorkflow),
1144
+ text: '工作流已保存;未执行生成' + (args.enableAutomatic ? ',图片功能已使用自动池' : ',功能池设置保持') };
1145
+ });
1146
+
1147
+ /** Uses only an already saved connection. /object_info never enqueues a prompt. */
1148
+ register('providers_check_comfy_workflow', async (_host, args, { signal }) => {
1149
+ if (Object.keys(args).some(key => !['id', 'model_id', 'workflow', 'workflow_parameters'].includes(key))
1150
+ || (args.workflow !== undefined) === (args.model_id !== undefined)) throw new TypeError('请选择已保存工作流或导入一份草稿进行检查');
1151
+ const provider = store.providerById(args.id);
1152
+ if (!provider) throw new TypeError('请先保存并启用 ComfyUI 连接');
1153
+ const route = args.workflow !== undefined ? provider : providerForImageModel(provider, args.model_id);
1154
+ if (route.mediaProtocol !== 'comfyui') throw new TypeError('此检查仅用于 ComfyUI 连接');
1155
+ const file = path.join(store.irisHome(), 'providers.json'), before = fs.readFileSync(file);
1156
+ const workflow = compileComfyWorkflow(args.workflow !== undefined ? args.workflow : route.comfyWorkflow,
1157
+ { workflow_parameters: args.workflow_parameters });
1158
+ const scrub = createProviderSecretScrubber([route]);
1159
+ const report = await inspectComfyWorkflow(createComfyUiClient(route), workflow, { signal });
1160
+ signal?.throwIfAborted();
1161
+ if (!fs.readFileSync(file).equals(before)) throw Object.assign(new Error('检查期间配置已变化,请重新检查'), { code: 'IRIS_CONFIG_CHANGED' });
1162
+ return { ok: true, report: scrub(report) };
1163
+ });
1164
+
1115
1165
  /** 新增/更新供应商 */
1116
1166
  register('providers_upsert', async (host, args) => {
1117
1167
  const id = args.id || undefined;
@@ -1121,6 +1171,7 @@ register('providers_upsert', async (host, args) => {
1121
1171
  for (const key of allowed) {
1122
1172
  if (Object.prototype.hasOwnProperty.call(args, key)) patch[key] = args[key];
1123
1173
  }
1174
+ if (Object.hasOwn(patch, 'auth') && !['none', 'bearer'].includes(patch.auth)) throw new TypeError('认证方式必须为 bearer 或 none');
1124
1175
  if (Object.hasOwn(args, 'dshProvider')) {
1125
1176
  patch.dshProvider = normalizeDshProvider(args.dshProvider);
1126
1177
  const source = requireHostPort(host, 'providerConnections', '复用 DSH 供应商连接');
@@ -1235,6 +1286,7 @@ async function probeModel(host, p, modelId, capability, signal) {
1235
1286
  }
1236
1287
  if (capability === cap.CAPABILITIES.IMAGE) {
1237
1288
  p = providerForImageModel(p, modelId);
1289
+ if (p.mediaProtocol === 'comfyui') return { skipped: true, ok: false, note: 'ComfyUI 请在画图表单选择工作流验证,以持久 Task 保留受理与结果;只读工作流检查不生成图片' };
1238
1290
  const providerAdapter = createConfiguredProviderAdapter(p, { allowRateLimited: true, modelId });
1239
1291
  const submitted = await invokeProviderOperation(providerAdapter, 'submit', {
1240
1292
  capability: 'image', model: modelId,
@@ -1407,7 +1459,7 @@ register('assignments_get', async (host, args) => {
1407
1459
  ? (c === cap.CAPABILITIES.TTS && selectMediaProtocol(provider).mediaProtocol === 'dashscope'
1408
1460
  && dashscopeTtsProtocolIssue(m.id)) || '当前接口协议不支持此功能'
1409
1461
  : modelUnavailable ? modelUnavailableReason() : m.rateLimited ? '限流冷却或额度不可用,请稍后或检查账号' : m.source === 'discovered' ? '已发现,待确认模型用途' : '',
1410
- ...(c === cap.CAPABILITIES.IMAGE && provider ? { imageRouting: imageRoutingForModel(provider, m.id) } : {}) });
1462
+ ...(c === cap.CAPABILITIES.IMAGE && provider ? { imageRouting: imageRoutingForModel(provider, m.id), ...comfySummaryForModel(provider, m.id) } : {}) });
1411
1463
  }
1412
1464
  }
1413
1465
  // order:归一化的有序分配列表(GUI 直接渲染 failover 顺序,无需处理单字符串旧格式)
package/lib/adapters.js CHANGED
@@ -8,6 +8,7 @@
8
8
  * 所有函数只收显式 key;错误统一 throw Error(人话)。
9
9
  */
10
10
  import fs from 'node:fs';
11
+ import { inheritedProviderFetch } from './provider-network.js';
11
12
  import path from 'node:path';
12
13
  import { Readable } from 'node:stream';
13
14
  import { pipeline } from 'node:stream/promises';
@@ -15,6 +16,7 @@ import { dashscopeApiBase, imageSizeViolation, isDashScopeBaseUrl, normalizeImag
15
16
  import { chmodPrivateFile, ensurePrivateDir, privateSibling } from './private-storage.js';
16
17
  import { isFreeQuotaExhausted, retryAfterDelay } from './provider-health.js';
17
18
  import { ProviderContractError } from './provider-contract.js';
19
+ import { boundedProviderBody, readProviderText, PROVIDER_RESPONSE_LIMITS } from './provider-response.js';
18
20
  export { generateMusic } from './music-transport.js';
19
21
 
20
22
  /**
@@ -42,8 +44,8 @@ function authHeaders(key) {
42
44
  };
43
45
  }
44
46
 
45
- async function readJson(res) {
46
- const text = await res.text();
47
+ async function readJson(res, maxBytes = PROVIDER_RESPONSE_LIMITS.json) {
48
+ const text = await readProviderText(res, { maxBytes });
47
49
  try {
48
50
  return text ? JSON.parse(text) : null;
49
51
  } catch (_) {
@@ -145,12 +147,13 @@ function transportFetchError(err, response) {
145
147
  }
146
148
 
147
149
  /** 只包装网络读取;HTTP 业务错误和图片协议校验由调用方处理。 */
148
- async function fetchImageJson(url, options) {
150
+ async function fetchImageJson(url, options, fetchPort) {
149
151
  let response;
150
152
  try {
151
- response = await fetch(url, options);
152
- return { res: response, body: await readJson(response) };
153
+ response = await fetchPort(url, options);
154
+ return { res: response, body: await readJson(response, response.ok ? PROVIDER_RESPONSE_LIMITS.mediaJson : PROVIDER_RESPONSE_LIMITS.json) };
153
155
  } catch (err) {
156
+ if (err instanceof ProviderContractError) throw err;
154
157
  throw transportFetchError(err, response);
155
158
  }
156
159
  }
@@ -189,11 +192,12 @@ export function applyImageSize({ protocol, mode, size }) {
189
192
  }
190
193
 
191
194
  /** 新版多模态同步协议:响应中直接返回图片 URL。 */
192
- export async function generateImageMultimodal({ key, baseUrl, model, prompt, image, size, n = 1, timeoutMs = T_SYNC_GEN, signal }) {
195
+ export async function generateImageMultimodal({ key, baseUrl, model, prompt, image, size, n = 1, timeoutMs = T_SYNC_GEN, signal, fetchPort = inheritedProviderFetch }) {
193
196
  const apiBase = dashscopeApiBase(baseUrl);
194
197
  const requestSize = applyImageSize({ protocol: 'dashscope', mode: 'multimodal-sync', size });
195
198
  const { res, body } = await fetchImageJson(`${apiBase}/services/aigc/multimodal-generation/generation`, {
196
199
  method: 'POST',
200
+ redirect: 'error',
197
201
  headers: authHeaders(key),
198
202
  signal: timeoutSignal(timeoutMs, signal),
199
203
  body: JSON.stringify({
@@ -204,7 +208,7 @@ export async function generateImageMultimodal({ key, baseUrl, model, prompt, ima
204
208
  ] }] },
205
209
  parameters: { ...(requestSize ? { size: requestSize } : {}), n: Number(n) || 1 }
206
210
  })
207
- });
211
+ }, fetchPort);
208
212
  if (!res.ok) throw providerError(res.status, body, '生成失败', res.headers);
209
213
  const urls = [];
210
214
  for (const choice of (body && body.output && body.output.choices) || []) {
@@ -224,15 +228,16 @@ export async function startImageGeneration(opts) {
224
228
  }
225
229
  return { remoteTaskId: await submitImage(opts), urls: [] };
226
230
  }
227
- export async function submitImage({ key, baseUrl, model, prompt, size, n = 1, timeoutMs = T_SUBMIT, signal }) {
231
+ export async function submitImage({ key, baseUrl, model, prompt, size, n = 1, timeoutMs = T_SUBMIT, signal, fetchPort = inheritedProviderFetch }) {
228
232
  const apiBase = dashscopeApiBase(baseUrl);
229
233
  const requestSize = applyImageSize({ protocol: 'dashscope', mode: 'legacy-async', size });
230
234
  const { res, body } = await fetchImageJson(`${apiBase}/services/aigc/text2image/image-synthesis`, {
231
235
  method: 'POST',
236
+ redirect: 'error',
232
237
  headers: { ...authHeaders(key), 'X-DashScope-Async': 'enable' },
233
238
  signal: timeoutSignal(timeoutMs, signal),
234
239
  body: JSON.stringify({ model, input: { prompt }, parameters: { ...(requestSize ? { size: requestSize } : {}), n: Number(n) || 1 } })
235
- });
240
+ }, fetchPort);
236
241
  if (!res.ok) throw providerError(res.status, body, '提交失败', res.headers);
237
242
  const taskId = body && body.output && body.output.task_id;
238
243
  if (!taskId) throw new Error('提交成功但未返回 task_id');
@@ -248,7 +253,7 @@ export async function submitImage({ key, baseUrl, model, prompt, size, n = 1, ti
248
253
  * - 其他(t2v/i2v):POST /services/aigc/video-generation/video-synthesis,
249
254
  * imgDataUrl 支持公网 URL 或 data:image base64。
250
255
  */
251
- export async function submitVideo({ key, baseUrl, model, prompt, imgDataUrl, size = '1280*720', duration, audioUrl, resolution, timeoutMs = T_SUBMIT, signal }) {
256
+ export async function submitVideo({ key, baseUrl, model, prompt, imgDataUrl, size = '1280*720', duration, audioUrl, resolution, timeoutMs = T_SUBMIT, signal, fetchPort = inheritedProviderFetch }) {
252
257
  const apiBase = dashscopeApiBase(baseUrl);
253
258
  const isS2V = /s2v/i.test(model || '');
254
259
  let url, input, parameters;
@@ -265,11 +270,12 @@ export async function submitVideo({ key, baseUrl, model, prompt, imgDataUrl, siz
265
270
  url = `${apiBase}/services/aigc/video-generation/video-synthesis`;
266
271
  input = { prompt };
267
272
  if (imgDataUrl) input.img_url = imgDataUrl;
268
- parameters = { size };
273
+ parameters = { size: typeof size === 'string' ? size.replace(/^(\d+)[xX](\d+)$/, '$1*$2') : size };
269
274
  if (duration) parameters.duration = Number(duration);
270
275
  }
271
- const res = await fetch(url, {
276
+ const res = await fetchPort(url, {
272
277
  method: 'POST',
278
+ redirect: 'error',
273
279
  headers,
274
280
  signal: timeoutSignal(timeoutMs, signal),
275
281
  body: JSON.stringify({ model, input, parameters })
@@ -287,16 +293,19 @@ export async function submitVideo({ key, baseUrl, model, prompt, imgDataUrl, siz
287
293
  * 返回 oss:// 形式 URL;调用模型时请求头须带 X-DashScope-OssResourceResolve: enable
288
294
  * (submitVideo 的 s2v 分支已自动处理)。
289
295
  */
290
- export async function uploadTempFile({ key, baseUrl, model, filePath, timeoutMs = T_UPLOAD, signal }) {
296
+ export async function uploadTempFile({ key, baseUrl, model, filePath, bytes, filename, timeoutMs = T_UPLOAD, signal, fetchPort = inheritedProviderFetch }) {
297
+ if ((filePath === undefined) === (bytes === undefined)) throw new TypeError('上传须且只能提供本地路径或明确字节');
298
+ const name = filePath !== undefined ? path.basename(filePath) : filename;
299
+ if (typeof name !== 'string' || !name || name.length > 128 || /[\/\\\0]/.test(name)
300
+ || bytes !== undefined && (!(bytes instanceof Uint8Array) || !bytes.length)) throw new TypeError('上传字节或文件名无效');
291
301
  const apiBase = dashscopeApiBase(baseUrl);
292
302
  const u = new URL(`${apiBase}/uploads`);
293
303
  u.searchParams.set('action', 'getPolicy');
294
304
  u.searchParams.set('model', model);
295
- const pres = await fetch(u, { headers: authHeaders(key), signal: timeoutSignal(timeoutMs, signal) });
305
+ const pres = await fetchPort(u, { headers: authHeaders(key), signal: timeoutSignal(timeoutMs, signal) });
296
306
  const pbody = await readJson(pres);
297
307
  if (!pres.ok || !pbody || !pbody.data) throw providerError(pres.status, pbody, '获取上传凭证失败', pres.headers);
298
308
  const pol = pbody.data;
299
- const name = path.basename(filePath);
300
309
  const keyPath = `${pol.upload_dir}/${Date.now()}-${name}`;
301
310
  const fd = new FormData();
302
311
  fd.append('OSSAccessKeyId', pol.oss_access_key_id);
@@ -306,8 +315,9 @@ export async function uploadTempFile({ key, baseUrl, model, filePath, timeoutMs
306
315
  fd.append('x-oss-forbid-overwrite', pol.x_oss_forbid_overwrite);
307
316
  fd.append('key', keyPath);
308
317
  fd.append('success_action_status', '200');
309
- fd.append('file', new Blob([fs.readFileSync(filePath)]), name);
310
- const up = await fetch(pol.upload_host, { method: 'POST', body: fd, signal: timeoutSignal(timeoutMs, signal) });
318
+ fd.append('file', new Blob([bytes ?? fs.readFileSync(filePath)]), name);
319
+ const up = await fetchPort(pol.upload_host, { method: 'POST', redirect: 'error', body: fd, signal: timeoutSignal(timeoutMs, signal) });
320
+ await up.body?.cancel();
311
321
  if (up.status !== 200) throw new Error(`临时上传失败:HTTP ${up.status}`);
312
322
  return 'oss://' + keyPath;
313
323
  }
@@ -315,9 +325,9 @@ export async function uploadTempFile({ key, baseUrl, model, filePath, timeoutMs
315
325
  const TERMINAL = new Set(['SUCCEEDED', 'FAILED', 'CANCELED', 'UNKNOWN']);
316
326
 
317
327
  /** @returns {{done:boolean, ok:boolean, urls:string[], message?:string}} */
318
- export async function pollTask({ key, baseUrl, remoteTaskId, timeoutMs = T_POLL, signal }) {
328
+ export async function pollTask({ key, baseUrl, remoteTaskId, timeoutMs = T_POLL, signal, fetchPort = inheritedProviderFetch }) {
319
329
  const apiBase = dashscopeApiBase(baseUrl);
320
- const res = await fetch(`${apiBase}/tasks/${encodeURIComponent(remoteTaskId)}`, {
330
+ const res = await fetchPort(`${apiBase}/tasks/${encodeURIComponent(remoteTaskId)}`, {
321
331
  headers: authHeaders(key),
322
332
  signal: timeoutSignal(timeoutMs, signal)
323
333
  });
@@ -358,15 +368,16 @@ export async function pollTask({ key, baseUrl, remoteTaskId, timeoutMs = T_POLL,
358
368
  }
359
369
 
360
370
  /* ---------------- DashScope:qwen-tts(同步) ---------------- */
361
- export async function synthesizeTts({ key, baseUrl, model = 'qwen-tts-latest', text, voice = 'Cherry', timeoutMs = T_SYNC_GEN, signal }) {
371
+ export async function synthesizeTts({ key, baseUrl, model = 'qwen-tts-latest', text, voice = 'Cherry', timeoutMs = T_SYNC_GEN, signal, fetchPort = inheritedProviderFetch }) {
362
372
  const apiBase = dashscopeApiBase(baseUrl);
363
- const res = await fetch(`${apiBase}/services/aigc/multimodal-generation/generation`, {
373
+ const res = await fetchPort(`${apiBase}/services/aigc/multimodal-generation/generation`, {
364
374
  method: 'POST',
375
+ redirect: 'error',
365
376
  headers: authHeaders(key),
366
377
  signal: timeoutSignal(timeoutMs, signal),
367
378
  body: JSON.stringify({ model, input: { text, voice } })
368
379
  });
369
- const body = await readJson(res);
380
+ const body = await readJson(res, res.ok ? PROVIDER_RESPONSE_LIMITS.mediaJson : PROVIDER_RESPONSE_LIMITS.json);
370
381
  if (!res.ok) {
371
382
  const error = providerError(res.status, body, '合成失败', res.headers);
372
383
  if (res.status === 404 && (/NotFound\.Model|model_not_found/i.test(error.providerCode)
@@ -384,15 +395,16 @@ export async function synthesizeTts({ key, baseUrl, model = 'qwen-tts-latest', t
384
395
  }
385
396
 
386
397
  /* ---------------- OpenAI Images 兼容(同步 base64/url) ---------------- */
387
- export async function openAiGenerateImage({ key, baseUrl, model, prompt, size = '1024x1024', n = 1, timeoutMs = T_SYNC_GEN, signal }) {
398
+ export async function openAiGenerateImage({ key, baseUrl, model, prompt, size = '1024x1024', n = 1, timeoutMs = T_SYNC_GEN, signal, fetchPort = inheritedProviderFetch }) {
388
399
  const base = String(baseUrl || '').replace(/\/+$/, '');
389
400
  const requestSize = applyImageSize({ protocol: 'openai-images', size });
390
401
  const { res, body } = await fetchImageJson(`${base}/images/generations`, {
391
402
  method: 'POST',
403
+ redirect: 'error',
392
404
  headers: authHeaders(key),
393
405
  signal: timeoutSignal(timeoutMs, signal),
394
406
  body: JSON.stringify({ model, prompt, size: requestSize, n: Number(n) || 1 })
395
- });
407
+ }, fetchPort);
396
408
  if (!res.ok) throw providerError(res.status, body, '生成失败', res.headers);
397
409
  const items = (body && Array.isArray(body.data) && body.data) || [];
398
410
  const out = [];
@@ -452,7 +464,7 @@ function chatImageItems(body, label = '聊天生图') {
452
464
  return items;
453
465
  }
454
466
 
455
- export async function openAiChatGenerateImage({ key, baseUrl, model, prompt, image, size, n = 1, timeoutMs = T_SYNC_GEN, signal }) {
467
+ export async function openAiChatGenerateImage({ key, baseUrl, model, prompt, image, size, n = 1, timeoutMs = T_SYNC_GEN, signal, fetchPort = inheritedProviderFetch }) {
456
468
  if (typeof size === 'string' && size.trim()) {
457
469
  throw new ProviderContractError('聊天生图协议不支持 size 参数;请在提示词中描述所需尺寸', {
458
470
  stage: 'validate', category: 'invalid_request', acceptance: 'not_accepted'
@@ -464,16 +476,16 @@ export async function openAiChatGenerateImage({ key, baseUrl, model, prompt, ima
464
476
  { type: 'image_url', image_url: { url: 'data:' + image.mediaType + ';base64,' + Buffer.from(image.bytes).toString('base64') } }
465
477
  ] : prompt;
466
478
  const { res, body } = await fetchImageJson(`${base}/chat/completions`, {
467
- method: 'POST', headers: authHeaders(key), signal: timeoutSignal(timeoutMs, signal),
479
+ method: 'POST', redirect: 'error', headers: authHeaders(key), signal: timeoutSignal(timeoutMs, signal),
468
480
  body: JSON.stringify({ model, stream: false, messages: [{ role: 'user', content }],
469
481
  ...(Number(n) > 1 ? { n: Number(n) } : {}) })
470
- });
482
+ }, fetchPort);
471
483
  if (!res.ok) throw providerError(res.status, body, '聊天生图失败', res.headers);
472
484
  return chatImageItems(body);
473
485
  }
474
486
 
475
487
  /** 同步 Responses 生图:主模型由 model 指定,图片工具模型沿用服务端默认。 */
476
- export async function openAiResponsesGenerateImage({ key, baseUrl, model, prompt, size, n = 1, timeoutMs = T_SYNC_GEN, signal }) {
488
+ export async function openAiResponsesGenerateImage({ key, baseUrl, model, prompt, size, n = 1, timeoutMs = T_SYNC_GEN, signal, fetchPort = inheritedProviderFetch }) {
477
489
  if (Number(n) !== 1) {
478
490
  throw new ProviderContractError('Responses 生图当前只支持 n=1', {
479
491
  stage: 'validate', category: 'invalid_request', acceptance: 'not_accepted'
@@ -482,11 +494,11 @@ export async function openAiResponsesGenerateImage({ key, baseUrl, model, prompt
482
494
  const base = String(baseUrl || '').replace(/\/+$/, '');
483
495
  const requestSize = applyImageSize({ protocol: 'openai-responses-images', size });
484
496
  const { res, body } = await fetchImageJson(`${base}/responses`, {
485
- method: 'POST', headers: authHeaders(key), signal: timeoutSignal(timeoutMs, signal),
497
+ method: 'POST', redirect: 'error', headers: authHeaders(key), signal: timeoutSignal(timeoutMs, signal),
486
498
  body: JSON.stringify({ model, input: prompt, stream: false, store: false, background: false,
487
499
  tools: [{ type: 'image_generation', action: 'generate', ...(requestSize ? { size: requestSize } : {}) }],
488
500
  tool_choice: { type: 'image_generation' } })
489
- });
501
+ }, fetchPort);
490
502
  if (!res.ok) throw providerError(res.status, body, 'Responses 生图失败', res.headers);
491
503
  if (body?.status !== 'completed') throw imageResponseError('Responses 生图未返回同步完成结果;禁止自动重提');
492
504
  const output = Array.isArray(body.output) ? body.output : [];
@@ -510,7 +522,7 @@ export async function openAiResponsesGenerateImage({ key, baseUrl, model, prompt
510
522
  * 普通 OpenAI 兼容端点维持 GET /models 与字符串 id 返回形状。
511
523
  * @returns {Promise<Array<string|{id:string,capabilities?:string[]}>>}
512
524
  */
513
- export async function listModels({ key, baseUrl, timeoutMs = T_SUBMIT, signal }) {
525
+ export async function listModels({ key, baseUrl, timeoutMs = T_SUBMIT, signal, fetchPort = inheritedProviderFetch }) {
514
526
  const base = String(baseUrl || '').trim().replace(/\/+$/, '');
515
527
  if (!base) throw new Error('iris: 供应商缺 baseUrl,无法发现模型');
516
528
  const dashscope = isDashScopeBaseUrl(base);
@@ -525,7 +537,7 @@ export async function listModels({ key, baseUrl, timeoutMs = T_SUBMIT, signal })
525
537
  url.searchParams.set('page_no', String(page));
526
538
  url.searchParams.set('page_size', '100');
527
539
  }
528
- const res = await fetch(url, { headers: authHeaders(key), signal: timeoutSignal(timeoutMs, signal) });
540
+ const res = await fetchPort(url, { headers: authHeaders(key), signal: timeoutSignal(timeoutMs, signal) });
529
541
  const body = await readJson(res);
530
542
  if (!res.ok) throw providerError(res.status, body, '模型发现失败', res.headers);
531
543
  if (!body || body.success === false) throw new ProviderContractError('模型目录响应无效,已保留上次完整目录', {
@@ -588,14 +600,17 @@ export async function listModels({ key, baseUrl, timeoutMs = T_SUBMIT, signal })
588
600
  * @param {AbortSignal} [signal] 取消传播
589
601
  * @returns {Promise<string>} 完整回答文本
590
602
  */
591
- export async function visionStream({ key, baseUrl, model = 'qwen-vl-plus', prompt, imageDataUrl, signal, timeoutMs = T_VISION }) {
603
+ export async function visionStream({ key, baseUrl, model = 'qwen-vl-plus', prompt, imageDataUrl, signal, timeoutMs = T_VISION, fetchPort = inheritedProviderFetch }) {
592
604
  const base = String(baseUrl || '').trim().replace(/\/+$/, '');
605
+ const requestSignal = timeoutSignal(timeoutMs, signal);
606
+ requestSignal.throwIfAborted();
593
607
  let res;
594
608
  try {
595
- res = await fetch(`${base}/chat/completions`, {
609
+ res = await fetchPort(`${base}/chat/completions`, {
596
610
  method: 'POST',
611
+ redirect: 'error',
597
612
  headers: authHeaders(key),
598
- signal: timeoutSignal(timeoutMs, signal),
613
+ signal: requestSignal,
599
614
  body: JSON.stringify({
600
615
  model,
601
616
  stream: true,
@@ -611,10 +626,12 @@ export async function visionStream({ key, baseUrl, model = 'qwen-vl-plus', promp
611
626
  })
612
627
  });
613
628
  } catch (err) {
629
+ requestSignal.throwIfAborted();
614
630
  throw networkError(err); // 网络层失败归类为 network
615
631
  }
632
+ if (requestSignal.aborted) { await res.body?.cancel(); requestSignal.throwIfAborted(); }
616
633
  if (!res.ok) {
617
- const text = await res.text().catch(() => '');
634
+ const text = await readProviderText(res, { maxBytes: PROVIDER_RESPONSE_LIMITS.json, stage: 'response', acceptance: 'unknown' });
618
635
  let body = null;
619
636
  try { body = JSON.parse(text); } catch (_) { /* keep null */ }
620
637
  throw providerError(res.status, body, '视觉理解失败', res.headers);
@@ -622,8 +639,8 @@ export async function visionStream({ key, baseUrl, model = 'qwen-vl-plus', promp
622
639
  let full = '';
623
640
  const decoder = new TextDecoder();
624
641
  let buffer = '';
625
- for await (const chunk of res.body) {
626
- if (signal && signal.aborted) break;
642
+ for await (const chunk of boundedProviderBody(res, { maxBytes: PROVIDER_RESPONSE_LIMITS.json, stage: 'response', acceptance: 'unknown' })) {
643
+ requestSignal.throwIfAborted();
627
644
  buffer += decoder.decode(chunk, { stream: true });
628
645
  let idx;
629
646
  while ((idx = buffer.indexOf('\n')) >= 0) {
@@ -641,6 +658,7 @@ export async function visionStream({ key, baseUrl, model = 'qwen-vl-plus', promp
641
658
  }
642
659
  }
643
660
  }
661
+ requestSignal.throwIfAborted();
644
662
  return full.trim();
645
663
  }
646
664
 
@@ -655,15 +673,19 @@ export function extFromUrl(url, fallback = 'bin') {
655
673
  return fallback;
656
674
  }
657
675
 
658
- export async function downloadTo(url, filePath, { timeoutMs = T_DOWNLOAD, signal } = {}) {
659
- const res = await fetch(url, { signal: timeoutSignal(timeoutMs, signal) });
660
- if (!res.ok) throw new Error(`下载结果失败:HTTP ${res.status}`);
661
- if (!res.body) throw new Error('下载结果失败:响应没有内容');
676
+ export async function downloadTo(url, filePath, { timeoutMs = T_DOWNLOAD, signal, maxBytes = PROVIDER_RESPONSE_LIMITS.video, fetchPort = inheritedProviderFetch } = {}) {
662
677
  ensurePrivateDir(path.dirname(filePath));
663
678
  const tmp = privateSibling(filePath, 'download');
679
+ const res = await fetchPort(url, { signal: timeoutSignal(timeoutMs, signal) });
680
+ if (!res.ok) {
681
+ await res.body?.cancel();
682
+ throw Object.assign(new Error(`下载结果失败:HTTP ${res.status}`), { stage: 'download', category: 'provider', httpStatus: res.status, acceptance: 'accepted' });
683
+ }
684
+ if (!res.body) throw new Error('下载结果失败:响应没有内容');
664
685
  try {
665
686
  const output = fs.createWriteStream(tmp, { flags: 'wx', mode: 0o600 });
666
- await pipeline(Readable.fromWeb(res.body), output);
687
+ const closed = new Promise(resolve => output.once('close', resolve));
688
+ await pipeline(Readable.from(boundedProviderBody(res, { maxBytes, stage: 'download', acceptance: 'accepted' })), output).finally(() => closed);
667
689
  const bytes = fs.statSync(tmp).size;
668
690
  fs.renameSync(tmp, filePath);
669
691
  chmodPrivateFile(filePath);
@@ -679,7 +701,7 @@ export async function downloadTo(url, filePath, { timeoutMs = T_DOWNLOAD, signal
679
701
  * @param {{key:string, model:string, audioUrl:string}} opts audioUrl 为 oss:// 或公网 URL
680
702
  * @returns {Promise<string>} remoteTaskId
681
703
  */
682
- export async function submitTranscription({ key, baseUrl, model = 'qwen-audio-3.0-asr-flash-filetrans', audioUrl, timeoutMs = T_SUBMIT, signal }) {
704
+ export async function submitTranscription({ key, baseUrl, model = 'qwen-audio-3.0-asr-flash-filetrans', audioUrl, timeoutMs = T_SUBMIT, signal, fetchPort = inheritedProviderFetch }) {
683
705
  const apiBase = dashscopeApiBase(baseUrl);
684
706
  const headers = { ...authHeaders(key), 'X-DashScope-Async': 'enable' };
685
707
  if (/^oss:\/\//.test(audioUrl || '')) headers['X-DashScope-OssResourceResolve'] = 'enable';
@@ -687,8 +709,9 @@ export async function submitTranscription({ key, baseUrl, model = 'qwen-audio-3.
687
709
  const input = /^qwen3-asr-.*filetrans/i.test(model || '')
688
710
  ? { file_url: audioUrl }
689
711
  : { file_urls: [audioUrl] };
690
- const res = await fetch(`${apiBase}/services/audio/asr/transcription`, {
712
+ const res = await fetchPort(`${apiBase}/services/audio/asr/transcription`, {
691
713
  method: 'POST',
714
+ redirect: 'error',
692
715
  headers,
693
716
  signal: timeoutSignal(timeoutMs, signal),
694
717
  body: JSON.stringify({ model, input })
@@ -719,9 +742,9 @@ export function transcriptionText(payload) {
719
742
  }
720
743
 
721
744
  /** 轮询 ASR 任务;正式结果位于 results[].transcription_url 指向的 JSON。 */
722
- export async function pollTranscriptionTask({ key, baseUrl, remoteTaskId, timeoutMs = T_POLL, signal }) {
745
+ export async function pollTranscriptionTask({ key, baseUrl, remoteTaskId, timeoutMs = T_POLL, signal, fetchPort = inheritedProviderFetch }) {
723
746
  const apiBase = dashscopeApiBase(baseUrl);
724
- const res = await fetch(`${apiBase}/tasks/${encodeURIComponent(remoteTaskId)}`, {
747
+ const res = await fetchPort(`${apiBase}/tasks/${encodeURIComponent(remoteTaskId)}`, {
725
748
  headers: authHeaders(key),
726
749
  signal: timeoutSignal(timeoutMs, signal)
727
750
  });
@@ -742,8 +765,11 @@ export async function pollTranscriptionTask({ key, baseUrl, remoteTaskId, timeou
742
765
  if (typeof result.transcription_text === 'string') text += result.transcription_text;
743
766
  else if (typeof result.text === 'string') text += result.text;
744
767
  if (status === 'SUCCEEDED' && result.subtask_status !== 'FAILED' && typeof result.transcription_url === 'string') {
745
- const transcriptRes = await fetch(result.transcription_url, { signal: timeoutSignal(timeoutMs, signal) });
746
- if (!transcriptRes.ok) throw new Error(`下载转写结果失败:HTTP ${transcriptRes.status}`);
768
+ const transcriptRes = await fetchPort(result.transcription_url, { signal: timeoutSignal(timeoutMs, signal) });
769
+ if (!transcriptRes.ok) {
770
+ await transcriptRes.body?.cancel();
771
+ throw new Error(`下载转写结果失败:HTTP ${transcriptRes.status}`);
772
+ }
747
773
  const transcriptBody = await readJson(transcriptRes);
748
774
  if (!transcriptBody) throw new Error('下载转写结果失败:响应不是 JSON');
749
775
  text += transcriptionText(transcriptBody);
package/lib/api.js CHANGED
@@ -13,6 +13,7 @@
13
13
  import fs from 'node:fs';
14
14
  import path from 'node:path';
15
15
  import crypto from 'node:crypto';
16
+ import { pipeline } from 'node:stream/promises';
16
17
  import { irisHome, capabilitiesOf, onChange as onConfigChange, providerHealthSnapshot } from './config.js';
17
18
  import { mimeOf, mediaUrl } from './media.js';
18
19
  import * as tasks from './tasks.js';
@@ -65,7 +66,7 @@ function providerPublic(p) {
65
66
  mediaBaseUrl: p.mediaBaseUrl || '',
66
67
  enabled: p.enabled !== false,
67
68
  ...selectMediaProtocol(p),
68
- apiKeyHint: p.dshProvider ? '由 DSH 管理' : keyHint(p.apiKey),
69
+ apiKeyHint: p.dshProvider ? '由 DSH 管理' : p.apiKeyEnv ? '由环境变量提供' : keyHint(p.apiKey),
69
70
  ...(p.dshProvider ? { dshProvider: p.dshProvider } : {}),
70
71
  capabilities: capabilitiesOf(p),
71
72
  // 迁移/推断结果是否与原始声明不同(UI 提示旧配置已推断)
@@ -445,19 +446,22 @@ function handleWorkbenchPost(req, res, execute, download = false, core) {
445
446
 
446
447
  async function serveCoreArtifactMedia(artifactId, method, res, core) {
447
448
  try {
448
- const result = await core.readCoreArtifactMediaForDsh(artifactId);
449
- if (res.writableEnded || res.destroyed) return;
450
- res.writeHead(200, {
451
- 'Content-Type': result.artifact.mediaType,
452
- 'Content-Length': result.bytes.length,
453
- 'Content-Disposition': 'inline',
454
- 'Cache-Control': 'private, max-age=3600',
455
- 'X-Content-Type-Options': 'nosniff',
456
- 'Cross-Origin-Resource-Policy': 'same-origin',
457
- 'Referrer-Policy': 'no-referrer'
458
- });
459
- res.end(method === 'HEAD' ? undefined : result.bytes);
449
+ await core.streamCoreArtifactMediaForDsh(artifactId, async result => {
450
+ if (res.writableEnded || res.destroyed) return;
451
+ res.writeHead(200, {
452
+ 'Content-Type': result.artifact.mediaType,
453
+ 'Content-Length': result.artifact.size,
454
+ 'Content-Disposition': 'inline',
455
+ 'Cache-Control': 'private, max-age=3600',
456
+ 'X-Content-Type-Options': 'nosniff',
457
+ 'Cross-Origin-Resource-Policy': 'same-origin',
458
+ 'Referrer-Policy': 'no-referrer'
459
+ });
460
+ if (method === 'HEAD') res.end();
461
+ else await pipeline(result.stream, res);
462
+ }, { headOnly: method === 'HEAD' });
460
463
  } catch (_) {
464
+ if (res.headersSent || res.destroyed) { res.destroy(); return; }
461
465
  return sendJson(res, 404, { error: 'not found' });
462
466
  }
463
467
  }
@@ -567,10 +571,16 @@ function handleCoreTaskRetry(taskId, req, res, core) {
567
571
  error.statusCode = 400;
568
572
  throw error;
569
573
  }
574
+ if (body.video_mode !== undefined && ['img_data_url', 'first_frame_path', 'audio_path'].some(field => Object.hasOwn(body, field))) {
575
+ throw Object.assign(new Error('DSH 视频重试仅提供明确的文生入口;图生/数字人视频请通过视频工具或 CLI 重新提供首帧和音频'), {
576
+ code: 'IRIS_COMMAND_INPUT_INVALID'
577
+ });
578
+ }
570
579
  const prompt = String(body.prompt || '');
571
580
  const result = await core.retryProviderTaskForDsh({
572
581
  taskId,
573
582
  prompt,
583
+ ...(body.video_mode !== undefined ? { videoMode: body.video_mode } : {}),
574
584
  ...(body.source_artifact_id !== undefined ? { sourceArtifactId: body.source_artifact_id } : {}),
575
585
  ...(body.text !== undefined ? { text: String(body.text) } : {}),
576
586
  ...(body.voice !== undefined ? { voice: String(body.voice) } : {}),