@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/actions.js CHANGED
@@ -1,4 +1,5 @@
1
1
  'use strict';
2
+ import { visionInputLimits } from './vision-image-input.js';
2
3
  /**
3
4
  * Iris 动作执行层(阶段 5 GUI 操作面板)。
4
5
  *
@@ -11,6 +12,7 @@
11
12
  */
12
13
  import fs from 'node:fs';
13
14
  import path from 'node:path';
15
+ import crypto from 'node:crypto';
14
16
  import * as adapters from './adapters.js';
15
17
  import * as store from './config.js';
16
18
  import * as cap from './capability.js';
@@ -18,28 +20,35 @@ import * as models from './models.js';
18
20
  import * as tasks from './tasks.js';
19
21
  import * as artifacts from './artifacts.js';
20
22
  import { registerMedia, mediaLinksOf, webBase } from './media.js';
21
- import { buildVisionBackendsFromHost, askWithBackends, testVisionCapability, RED_TEST_IMAGE } from './vision.js';
23
+ import { RED_TEST_IMAGE } from './vision.js';
24
+ import { runVisionRequest, readVisionImageFile, probeVisionModel, visionImageFromDataUrl } from './vision-model-routing.js';
22
25
  import { HOST_CONTRACT_VERSION, HOST_PORTS, defineHostAdapter, hasHostPort, requireHostPort } from './host-contract.js';
23
- import { coreSnapshotForDsh, cropForDsh, diffForDsh, framesForDsh, inspectProviderTaskForDsh, submitProviderTaskForDsh, watchProviderTaskForDsh } from './dsh-core-adapter.js';
24
- import { imageDimensions } from './pixels.js';
25
- import { locateObject } from './locate.js';
26
- import { longOcr } from './ocr.js';
26
+ import { coreSnapshotForDsh, cropForDsh, diffForDsh, framesForDsh, summaryFramesForDsh, visionImageForDsh, htmlForDsh, inspectProviderTaskForDsh, submitProviderTaskForDsh, watchProviderTaskForDsh } from './dsh-core-adapter.js';
27
+ import { runLocateRequest, runSummaryRequest } from './composite-vision-routing.js';
28
+ import { normalizeSummarySource, summaryFrameRecords, summaryTimelineLabel } from './summary-input.js';
29
+ import { normalizeVisionImageSource } from './vision-input.js';
30
+ import { formatOcrResult } from './ocr.js';
31
+ import { runOcrRequest } from './ocr-model-routing.js';
27
32
  import { ffmpegAvailable, extractFrames, probeVideo } from './media-probe.js';
28
- import { buildContactSheet, summarizeMedia } from './summarize.js';
29
33
  import { ensurePrivateDir, writePrivateFile } from './private-storage.js';
30
- import { selectMediaProtocol } from './provider-protocol.js';
34
+ import { selectMediaProtocol, selectImageProtocol, providerForImageModel } from './provider-protocol.js';
31
35
  import { providerTaskBinding } from './provider-catalog.js';
32
- import { ProviderContractError, submitWithAcceptanceBoundary } from './provider-contract.js';
33
36
  import { invokeProviderOperation, providerPollTaskView, prepareProviderInput } from './provider-adapter.js';
34
- import { createConfiguredProviderAdapter } from './provider-adapters.js';
37
+ import { createConfiguredProviderAdapter as createProviderAdapter } from './provider-adapters.js';
35
38
  import { deriveUserState } from './task-semantics.js';
36
39
  import { normalizeGenerationInput } from './generation-input.js';
40
+ import { videoTaskCandidates } from './video-input.js';
37
41
 
38
42
  const MEDIA_TYPES = {
39
43
  '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
40
44
  '.webp': 'image/webp', '.gif': 'image/gif'
41
45
  };
42
46
 
47
+ function createConfiguredProviderAdapter(provider, options = {}) {
48
+ return createProviderAdapter(provider, { ...options,
49
+ onRateLimit: options.allowRateLimited ? undefined : store.recordRateLimit });
50
+ }
51
+
43
52
  const EMPTY_ACTION_HOST = defineHostAdapter({
44
53
  id: 'iris-hostless',
45
54
  ports: {},
@@ -59,22 +68,6 @@ function actionHost(value) {
59
68
  // 工作台清空可选文本框会发送空字符串;在 Host 边界解释为未提供,保持默认值。
60
69
  function optionalActionText(value) { return value === '' ? undefined : value; }
61
70
 
62
- /* ---------------- 视觉效果自述(辅助 iris_draw_image) ---------------- */
63
- async function describeFirstImage(host, ref, absPath, originalPrompt) {
64
- try {
65
- const mt = MEDIA_TYPES[path.extname(absPath).toLowerCase()] || 'image/png';
66
- const buf = fs.readFileSync(absPath);
67
- const dataUrl = `data:${mt};base64,${buf.toString('base64')}`;
68
- const backends = buildVisionBackendsFromHost(host, { providers: store.pickAllFor(cap.CAPABILITIES.VISION) });
69
- const { answer } = await askWithHealth(backends, {
70
- question: `用不超过两句话描述这张图片的主题与构图。生成该图的提示词是:「${String(originalPrompt).slice(0, 200)}」`,
71
- imageDataUrl: dataUrl,
72
- signal: undefined
73
- });
74
- return answer.slice(0, 300);
75
- } catch (_) { return ''; }
76
- }
77
-
78
71
  /* ---------------- IO 目录 ---------------- */
79
72
  function outputDir() {
80
73
  const dir = path.join(store.irisHome(), 'outputs');
@@ -99,6 +92,11 @@ function selectProviders(capability, label, requestedModelRef) {
99
92
  if (requested) {
100
93
  const pool = models.modelPool(store.providers());
101
94
  const selected = models.resolvePoolModel(pool, requested, capability);
95
+ if (selected?.rateLimited) throw Object.assign(new Error(store.modelHealth(selected.providerId, selected.id, capability).retryAt
96
+ ? 'iris: 指定模型正在限流冷却,请稍后使用或手动实测'
97
+ : 'iris: 指定模型额度或预算耗尽,请手动实测成功后再使用'), {
98
+ code: 'IRIS_PROVIDER_MODEL_RATE_LIMITED'
99
+ });
102
100
  const field = models.CAP_FIELD[capability];
103
101
  providers = selected ? providers.filter((p) => p.id === selected.providerId && p[field] === selected.id) : [];
104
102
  if (!providers.length) throw new Error('iris: 指定的模型复合引用不可用或不具备' + label + '能力');
@@ -127,8 +125,9 @@ function routeSelectionReason(provider, capability, requested) {
127
125
  }
128
126
 
129
127
  function imageRoute(provider, args) {
130
- const protocol = selectMediaProtocol(provider).mediaProtocol;
131
128
  const model = configuredModel(provider, cap.CAPABILITIES.IMAGE, args.model);
129
+ provider = providerForImageModel(provider, model);
130
+ const protocol = selectMediaProtocol(provider).mediaProtocol;
132
131
  return {
133
132
  provider, protocol, model, modelRef: models.modelRef(provider.id, model),
134
133
  selectionReason: routeSelectionReason(provider, cap.CAPABILITIES.IMAGE, args.model),
@@ -193,20 +192,15 @@ function recordVisionHealth(errors) {
193
192
  }
194
193
  }
195
194
 
196
- async function askWithHealth(backends, request) {
195
+ async function askSingleVisionWithHealth(host, request) {
197
196
  try {
198
- const result = await askWithBackends(backends, request);
197
+ const result = await runVisionRequest(host, { providers: store.pickAllFor(cap.CAPABILITIES.VISION), onRateLimit: store.recordRateLimit, ...request });
199
198
  recordVisionHealth(result.errors);
200
- if (result.via === 'selfstack') {
201
- recordProviderHealthSafe(result.backendId, result.model, cap.CAPABILITIES.VISION, {
202
- ok: true, category: 'success', note: '视觉请求成功'
203
- });
204
- }
199
+ if (result.via === 'selfstack') recordProviderHealthSafe(result.backendId, result.model, cap.CAPABILITIES.VISION, {
200
+ ok: true, category: 'success', note: '视觉请求成功'
201
+ });
205
202
  return result;
206
- } catch (error) {
207
- recordVisionHealth(error && error.errors);
208
- throw error;
209
- }
203
+ } catch (error) { recordVisionHealth(error?.errors); throw error; }
210
204
  }
211
205
 
212
206
  function recordCoreTaskAttemptHealth(task, capability) {
@@ -229,7 +223,7 @@ function recordCoreTaskAttemptHealth(task, capability) {
229
223
  }
230
224
 
231
225
  /** DSH 图片入口:同步和异步候选共用一份 Core Task/Attempt/Artifact 事实。 */
232
- async function submitCoreImage(routes, providerInput, signal) {
226
+ async function submitCoreImage(routes, providerInput, signal, sourceArtifactId) {
233
227
  const submitted = await submitProviderTaskForDsh({
234
228
  capability: 'image',
235
229
  candidates: routes.map((route) => ({
@@ -239,6 +233,7 @@ async function submitCoreImage(routes, providerInput, signal) {
239
233
  providerBinding: providerTaskBinding(route.provider)
240
234
  })),
241
235
  providerInput,
236
+ sourceArtifactId,
242
237
  signal
243
238
  });
244
239
  const task = submitted.task;
@@ -263,24 +258,25 @@ async function submitCoreImage(routes, providerInput, signal) {
263
258
  /**
264
259
  * DSH 视频入口(E 阶段):t2v/i2v 共用 Core Task/Attempt/Artifact 事实轴,
265
260
  * 不共用图片状态机——视频走自己的交付 Profile(video/mp4 / generated-video)。
266
- * s2v 数字人(需先上传首帧与音频到临时存储)仍走 legacy 链路,见 register('video')。
261
+ * s2v 在写前 Attempt 之后按账号上传首帧和音频,交付复用视频 Profile。
267
262
  */
268
263
  async function submitCoreVideo(routes, frame, prompt, args, signal) {
269
- const { providerInput } = normalizeGenerationInput('video', {
270
- prompt,
271
- img_data_url: frame ? fileDataUrl(frame) : undefined,
264
+ const normalized = normalizeGenerationInput('video', {
265
+ ...(prompt ? { prompt } : {}),
266
+ ...(frame ? { first_frame_path: frame.absPath } : {}),
267
+ ...(args.audio_path ? { audio_path: args.audio_path, resolution: args.resolution } : {}),
272
268
  size: optionalActionText(args.size),
273
269
  duration: args.duration
274
- });
270
+ }, { allowVideoPaths: true });
275
271
  const submitted = await submitProviderTaskForDsh({
276
272
  capability: 'video',
277
- candidates: routes.map((route) => ({
273
+ candidates: videoTaskCandidates(routes.map((route) => ({
278
274
  adapter: route.adapter,
279
275
  model: route.modelRef,
280
276
  selectionReason: route.selectionReason,
281
277
  providerBinding: providerTaskBinding(route.provider)
282
- })),
283
- providerInput,
278
+ })), normalized),
279
+ providerInput: normalized.providerInput,
284
280
  signal
285
281
  });
286
282
  const task = submitted.task;
@@ -313,12 +309,26 @@ function findGeneratedAttachment(attachmentId) {
313
309
  return null;
314
310
  }
315
311
 
316
- function resolveFirstFrameFile(args) {
312
+ async function resolveFirstFrameFile(host, args, signal) {
317
313
  if (args.first_frame_attachment_id) {
318
314
  const hit = findGeneratedAttachment(String(args.first_frame_attachment_id).trim());
319
- if (!hit) throw new Error('iris: 找不到该 first_frame_attachment_id');
320
- if (!fs.existsSync(hit.absPath)) throw new Error('iris: 首帧图片的本地缓存已被清理');
321
- return hit;
315
+ if (hit) {
316
+ if (!fs.existsSync(hit.absPath)) throw new Error('iris: 首帧图片的本地缓存已被清理');
317
+ return hit;
318
+ }
319
+ const sessions = requireHostPort(host, 'sessions', '读取首帧附件');
320
+ const attachments = requireHostPort(host, 'attachments', '读取首帧附件');
321
+ const ref = await sessions.findImageAttachment(args.session_id, String(args.first_frame_attachment_id).trim());
322
+ if (!ref) throw new Error('iris: 找不到本会话中的 first_frame_attachment_id');
323
+ const stored = await attachments.readImage(ref, signal);
324
+ signal?.throwIfAborted();
325
+ const mediaType = stored.mediaType || ref.mediaType;
326
+ const extension = Object.keys(MEDIA_TYPES).find(key => MEDIA_TYPES[key] === mediaType);
327
+ if (!extension) throw new Error('iris: 不支持的首帧图片格式');
328
+ const bytes = Buffer.from(stored.data);
329
+ const file = path.join(store.irisHome(), 'uploads', 'frame-' + crypto.createHash('sha256').update(bytes).digest('hex') + extension);
330
+ if (!fs.existsSync(file)) writePrivateFile(file, bytes, { flag: 'wx' });
331
+ return { absPath: file, mediaType };
322
332
  }
323
333
  if (args.first_frame_path) {
324
334
  const p = String(args.first_frame_path).trim();
@@ -331,10 +341,6 @@ function resolveFirstFrameFile(args) {
331
341
  return null;
332
342
  }
333
343
 
334
- function fileDataUrl(file) {
335
- return 'data:' + file.mediaType + ';base64,' + fs.readFileSync(file.absPath).toString('base64');
336
- }
337
-
338
344
  function videoRoute(provider, args, frame) {
339
345
  const model = configuredModel(provider, cap.CAPABILITIES.VIDEO, args.model);
340
346
  const protocol = selectMediaProtocol(provider).mediaProtocol;
@@ -352,126 +358,6 @@ function videoRoute(provider, args, frame) {
352
358
  };
353
359
  }
354
360
 
355
- function beforeGenerationError(error, stage) {
356
- const category = error && (error.category === 'network' || error instanceof TypeError)
357
- ? 'network'
358
- : (stage === 'validate' ? 'invalid_request' : 'local_io');
359
- return new ProviderContractError(String((error && error.message) || error || '提交前准备失败'), {
360
- stage,
361
- category,
362
- acceptance: 'not_accepted',
363
- retryable: category === 'network'
364
- });
365
- }
366
-
367
- async function submitVideoV2(routes, frame, prompt, args, signal) {
368
- const task = tasks.createV2({
369
- cap: 'video', prompt: prompt.slice(0, 4000),
370
- ...(args.model ? { requestedModelRef: String(args.model) } : {}),
371
- ...(args._retryOf ? { retryOf: String(args._retryOf) } : {})
372
- });
373
- const candidates = routes.map((route) => ({
374
- id: route.provider.id,
375
- model: route.modelRef,
376
- route,
377
- submit: async (_input, context) => {
378
- const attemptId = context.attempt.id;
379
- let submitArgs;
380
- if (route.isS2V) {
381
- if (!frame) throw beforeGenerationError(new Error('iris: s2v 数字人需要首帧'), 'validate');
382
- const audioPath = String(args.audio_path || '').trim();
383
- if (!audioPath || !path.isAbsolute(audioPath)) {
384
- throw beforeGenerationError(new Error('iris: s2v 的 audio_path 必须是绝对路径'), 'validate');
385
- }
386
- if (!fs.existsSync(audioPath)) throw beforeGenerationError(new Error('iris: 音频文件不存在'), 'validate');
387
- tasks.markAttemptStage(task.id, attemptId, 'upload');
388
- const { url: imageUrl } = await prepareProviderInput(route.adapter, {
389
- model: route.model, filePath: frame.absPath, signal
390
- });
391
- const { url: audioUrl } = await prepareProviderInput(route.adapter, {
392
- model: route.model, filePath: audioPath, signal
393
- });
394
- submitArgs = { imgDataUrl: imageUrl, audioUrl, resolution: args.resolution || '480P' };
395
- } else {
396
- if (!prompt) throw beforeGenerationError(new Error('iris: 文生/图生视频需要 prompt'), 'validate');
397
- tasks.markAttemptStage(task.id, attemptId, 'prepare');
398
- try {
399
- submitArgs = {
400
- prompt,
401
- imgDataUrl: frame ? fileDataUrl(frame) : undefined,
402
- size: args.size || '1280*720',
403
- duration: args.duration ? Number(args.duration) : undefined
404
- };
405
- } catch (error) {
406
- throw beforeGenerationError(error, 'prepare');
407
- }
408
- }
409
- tasks.markAttemptStage(task.id, attemptId, 'submit');
410
- return observeProviderCapability(route, cap.CAPABILITIES.VIDEO, () =>
411
- invokeProviderOperation(route.adapter, 'submit', {
412
- capability: 'video', model: route.model, input: submitArgs, signal
413
- }));
414
- }
415
- }));
416
-
417
- const submitted = await submitWithAcceptanceBoundary(candidates, { capability: 'video' }, {
418
- beforeAttempt: ({ providerId, model }) => {
419
- const route = routes.find((item) => item.provider.id === providerId && item.modelRef === model);
420
- if (!route) throw new Error('候选路由与 Attempt 身份不一致');
421
- const attempt = tasks.beginAttempt(task.id, {
422
- providerId, providerName: route.provider.name, model, protocol: route.protocol
423
- });
424
- tasks.update(task.id, {
425
- mode: route.mode,
426
- ...(route.isS2V ? { resolution: args.resolution || '480P' } : {
427
- size: args.size || '1280*720', ...(args.duration ? { duration: Number(args.duration) } : {})
428
- })
429
- });
430
- return attempt;
431
- },
432
- afterResult: (snapshot) => tasks.recordAttemptResult(task.id, snapshot)
433
- });
434
-
435
- if (submitted.localError) {
436
- throw taskActionError(task.id, '视频提交结果未能可靠落盘,已停止后续候选', submitted.localError);
437
- }
438
- if (!submitted.candidate) {
439
- const lastError = submitted.result && submitted.result.error;
440
- tasks.finalizeNoAcceptance(task.id, lastError || '所有视频候选均明确未受理');
441
- throw taskActionError(task.id, '所有视频候选均明确未受理', submitted.attempts);
442
- }
443
- if (submitted.result.kind !== 'accepted') {
444
- const safe = submitted.result.error && submitted.result.error.safeMessage;
445
- throw taskActionError(task.id, '视频受理状态未知,禁止自动重提' + (safe ? ':' + safe : ''), submitted.result.error);
446
- }
447
-
448
- const route = submitted.candidate.route;
449
- tasks.activateWatch(task.id);
450
- tasks.watch(tasks.get(task.id), {
451
- poll: ({ remoteTaskId: id, signal: pollSignal }) => observeProviderCapability(
452
- route, cap.CAPABILITIES.VIDEO,
453
- () => invokeProviderOperation(route.adapter, 'poll', {
454
- capability: 'video', remoteTaskId: id, signal: pollSignal
455
- }),
456
- { recordSuccess: false }
457
- ),
458
- intervalMs: 6000,
459
- onSuccess: async (record, result) => {
460
- const names = [];
461
- for (const url of result.urls || []) {
462
- const name = record.id + '-' + names.length + '.' + adapters.extFromUrl(url, 'mp4');
463
- await invokeProviderOperation(route.adapter, 'download', {
464
- artifact: { kind: 'remote-url', url }, targetPath: path.join(outputDir(), name)
465
- });
466
- names.push(name);
467
- }
468
- for (const name of names) registerMedia(record.id, path.join(outputDir(), name));
469
- return names;
470
- }
471
- });
472
- return { task: tasks.get(task.id), provider: route.provider, model: route.model, remoteTaskId: submitted.result.remoteTaskId };
473
- }
474
-
475
361
  function simpleModelRoute(provider, args, capability) {
476
362
  const protocol = selectMediaProtocol(provider).mediaProtocol;
477
363
  const model = configuredModel(provider, capability, args.model);
@@ -493,7 +379,8 @@ export function taskPollDeps(provider, task) {
493
379
  : 'dashscope');
494
380
  let providerAdapter;
495
381
  try {
496
- providerAdapter = createConfiguredProviderAdapter(provider, { protocol });
382
+ providerAdapter = createConfiguredProviderAdapter(provider, { protocol,
383
+ modelId: models.parseModelRef(String(task.modelRef || task.model || ''))?.modelId || String(task.model || '') });
497
384
  } catch (_) {
498
385
  return null;
499
386
  }
@@ -575,14 +462,16 @@ export async function runAction(host, name, args, { signal } = {}) {
575
462
  /* ============ 动作实现 ============ */
576
463
 
577
464
  /* ---------- 🖼️ 画图 ---------- */
578
- register('image', async (host, args, { signal }) => {
579
- const { providerInput } = normalizeGenerationInput('image', {
580
- prompt: args.prompt, size: optionalActionText(args.size), n: args.n
465
+ async function imageAction(host, args, { signal }, editing = false) {
466
+ if (editing && !args.source_artifact_id) throw new Error('iris: 改图必须提供 source_artifact_id');
467
+ const { providerInput, sourceArtifactId } = normalizeGenerationInput('image', {
468
+ prompt: args.prompt, size: optionalActionText(args.size), n: args.n,
469
+ ...(editing ? { source_artifact_id: args.source_artifact_id } : {})
581
470
  });
582
471
 
583
472
  const imageProviders = selectProviders(cap.CAPABILITIES.IMAGE, '图像', args.model);
584
473
  const routes = imageProviders.map((provider) => imageRoute(provider, args));
585
- const submitted = await submitCoreImage(routes, providerInput, signal);
474
+ const submitted = await submitCoreImage(routes, providerInput, signal, sourceArtifactId);
586
475
  const firstArtifactId = submitted.task.artifactIds[0];
587
476
  const ready = submitted.task.outcome === 'succeeded'
588
477
  && submitted.task.deliveryState === 'ready' && Boolean(firstArtifactId);
@@ -590,44 +479,36 @@ register('image', async (host, args, { signal }) => {
590
479
  ok: true, storage: 'core', taskId: submitted.task.id, model: submitted.model,
591
480
  providerId: submitted.provider.id, remoteTaskId: submitted.task.remoteTaskId || null,
592
481
  artifactIds: submitted.task.artifactIds,
482
+ ...(sourceArtifactId ? { sourceArtifactId } : {}),
593
483
  ...(ready ? { imageUrl: '/iris/api/core/artifact/' + encodeURIComponent(firstArtifactId) + '/media' } : {}),
594
484
  text: ready
595
485
  ? '[iris] 图像任务已完成(' + submitted.model + ',Core task: ' + submitted.task.id + ')'
596
486
  : '[iris] 图像任务已提交并由 Core 后台观察(' + submitted.model + ',Core task: ' + submitted.task.id + ')'
597
487
  };
598
- });
488
+ }
489
+ register('image', imageAction);
490
+ register('image_edit', (host, args, context) => imageAction(host, args, context, true));
599
491
 
600
492
  /* ---------- 🎬 视频生成 ---------- */
601
493
  register('video', async (host, args, { signal }) => {
602
494
  const prompt = String(args.prompt || '').trim();
603
- const frame = resolveFirstFrameFile(args);
495
+ const frame = await resolveFirstFrameFile(host, args, signal);
604
496
  const providers = selectProviders(cap.CAPABILITIES.VIDEO, '视频', args.model);
605
497
  const routes = providers.map((provider) => videoRoute(provider, args, frame));
606
- // s2v 数字人需要在受理边界前按候选上传首帧+音频,暂留 legacy 链路(见 DSH_CORE_MIGRATION)。
607
- // 判定看显式意图(audio_path 或 model 指定 s2v),不看候选池——池里登记 s2v 模型
608
- // 不应把 t2v/i2v 请求挡回 legacy。
498
+ // 请求子类型决定候选过滤;池中存在 s2v 不影响普通视频。
609
499
  const hasAudioPath = Boolean(String(args.audio_path || '').trim());
610
500
  const s2v = hasAudioPath || (args.model && /s2v/i.test(String(args.model)));
611
501
  // B-7:audio_path 与选中模型必须语义一致。若用户提供了音频却选中非 s2v 模型,
612
502
  // 旧实现会落入 else 分支静默丢弃 audio_path 并生成普通视频(可能产生非预期计费)。
613
503
  // 这里在受理边界与任何网络调用之前明确拒绝,不猜测用户意图。
614
- if (hasAudioPath && routes.some((route) => !route.s2vModel)) {
504
+ if (hasAudioPath && args.model && routes.some((route) => !route.s2vModel)) {
615
505
  const mismatched = routes.filter((route) => !route.s2vModel).map((route) => route.model).join(', ');
616
506
  throw new Error('iris: audio_path 只能配合支持 s2v 数字人的模型;当前候选 ' + mismatched
617
- + ' 不是 s2v 模型。请改用 s2v 模型(如 wan2.2-s2v-flash),或移除 audio_path 走文生/图生视频。');
618
- }
619
- if (s2v) {
620
- if (!prompt && !args.audio_path) throw new Error('iris: prompt 不能为空');
621
- const submitted = await submitVideoV2(routes, frame, prompt, args, signal);
622
- return {
623
- ok: true, storage: 'legacy', taskId: submitted.task.id, model: submitted.model, mode: submitted.task.mode,
624
- providerId: submitted.provider.id, remoteTaskId: submitted.remoteTaskId,
625
- text: '[iris] 视频任务已提交(' + submitted.model + ',task: ' + submitted.task.id + ')'
626
- };
507
+ + ' 不是 s2v 模型。请改用 s2v 模型(如 wan2.2-s2v),或移除 audio_path 走文生/图生视频。');
627
508
  }
628
- const coreRoutes = routes.filter((route) => !route.isS2V);
509
+ const coreRoutes = routes.filter((route) => route.isS2V === Boolean(s2v));
629
510
  if (!coreRoutes.length) {
630
- throw new Error('iris: 当前视频候选只有 s2v 数字人模型;请使用 audio_path 的数字人入口');
511
+ throw new Error(s2v ? 'iris: 未配置可用 s2v 数字人模型' : 'iris: 当前视频候选只有 s2v 数字人模型;请使用 audio_path 的数字人入口');
631
512
  }
632
513
  const submitted = await submitCoreVideo(coreRoutes, frame, prompt, args, signal);
633
514
  const firstArtifactId = submitted.task.artifactIds[0];
@@ -745,16 +626,21 @@ async function submitCoreTranscribe(routes, normalized, signal) {
745
626
 
746
627
  /* ---------- 👁 看图 ---------- */
747
628
  register('look', async (host, args, { signal }) => {
629
+ const source = normalizeVisionImageSource(args);
748
630
  const p = String(args.image_path || '').trim();
749
- if (!p || !path.isAbsolute(p)) throw new Error('iris: image_path 必须是绝对路径');
750
631
  const mt = MEDIA_TYPES[path.extname(p).toLowerCase()];
751
- if (!mt || mt === 'image/gif') throw new Error('iris: 不支持的图片格式');
752
- if (!fs.existsSync(p)) throw new Error('iris: 图片不存在');
753
- const dataUrl = `data:${mt};base64,${fs.readFileSync(p).toString('base64')}`;
754
- const backends = buildVisionBackendsFromHost(host, { providers: store.pickAllFor(cap.CAPABILITIES.VISION), model: args.model });
755
- const { answer, via, model } = await askWithHealth(backends, { question: (args.question || '详细描述这张图片。'), imageDataUrl: dataUrl, signal });
632
+ if (source === 'image_path') {
633
+ if (!p || !path.isAbsolute(p)) throw new Error('iris: image_path 必须是绝对路径');
634
+ if (!mt || mt === 'image/gif') throw new Error('iris: 不支持的图片格式');
635
+ if (!fs.existsSync(p)) throw new Error('iris: 图片不存在');
636
+ }
637
+ const { answer, via, model } = await askSingleVisionWithHealth(host, { model: args.model,
638
+ question: String(args.question || '').trim() || '详细描述这张图片。',
639
+ prepareImage: operationSignal => source === 'artifact_id'
640
+ ? visionImageForDsh({ artifactId: args.artifact_id, signal: operationSignal }) : readVisionImageFile(p, mt, operationSignal), signal });
756
641
  const viaLabel = via === 'selfstack' ? 'iris 自持栈' : 'DSH 全局视觉模型';
757
- return { ok: true, text: `[iris] 看图回答(${model} · ${viaLabel}):\n${answer}` };
642
+ return { ok: true, ...(args.artifact_id ? { artifactId: args.artifact_id } : {}),
643
+ text: `[iris] 看图回答(${model} · ${viaLabel}):\n${answer}` };
758
644
  });
759
645
 
760
646
  /* ---------- ✂️ 裁剪 ---------- */
@@ -789,39 +675,47 @@ register('diff', async (host, args, { signal }) => {
789
675
 
790
676
  /* ---------- 📍 定位 ---------- */
791
677
  register('locate', async (host, args, { signal }) => {
678
+ const source = normalizeVisionImageSource(args);
792
679
  const p = String(args.image_path || '').trim();
793
- if (!p || !path.isAbsolute(p)) throw new Error('iris: image_path 必须是绝对路径');
794
- if (!fs.existsSync(p)) throw new Error('iris: 图片不存在');
795
- const buf = fs.readFileSync(p);
680
+ if (source === 'image_path' && (!p || !path.isAbsolute(p))) throw new Error('iris: image_path 必须是绝对路径');
796
681
  const mt = MEDIA_TYPES[path.extname(p).toLowerCase()] || 'image/png';
797
- const dataUrl = `data:${mt};base64,${buf.toString('base64')}`;
798
- const { width, height } = await imageDimensions(buf);
799
- const backends = buildVisionBackendsFromHost(host, { providers: store.pickAllFor(cap.CAPABILITIES.VISION), model: args.model });
800
- const r = await locateObject(backends, { target: args.target, imageDataUrl: dataUrl, width, height, signal });
801
- if (!r.found) return { ok: true, text: `[iris] 在图片中未找到「${args.target}」` };
802
- return { ok: true, text: `[iris] 定位「${args.target}」:bbox (${r.x1},${r.y1},${r.x2},${r.y2}) / ${width}x${height}(${r.via} · ${r.model})` };
682
+ const r = await runLocateRequest(host, { providers: store.pickAllFor(cap.CAPABILITIES.VISION), onRateLimit: store.recordRateLimit, model: args.model,
683
+ target: args.target, prepareImage: operationSignal => source === 'artifact_id'
684
+ ? visionImageForDsh({ artifactId: args.artifact_id, signal: operationSignal }) : readVisionImageFile(p, mt, operationSignal), signal });
685
+ const { width, height } = r;
686
+ const provenance = args.artifact_id ? { artifactId: args.artifact_id } : {};
687
+ if (!r.found) return { ok: true, ...provenance, text: `[iris] 在图片中未找到「${args.target}」` };
688
+ return { ok: true, ...provenance, text: `[iris] 定位「${args.target}」:bbox (${r.x1},${r.y1},${r.x2},${r.y2}) / ${width}x${height}(${r.via} · ${r.model})` };
803
689
  });
804
690
  /* ---------- 🖼️ HTML 截图 ---------- */
805
691
  register('html', async (host, args, { signal }) => {
806
692
  const html = String(args.html || '').trim();
807
693
  if (!html) throw new Error('iris: html 不能为空');
808
694
  const browser = requireHostPort(host, 'browser', 'HTML 截图');
809
- const rendered = await browser.renderHtml({ html, width: args.width, height: args.height,
695
+ const rendered = await htmlForDsh({ browser, html, width: args.width, height: args.height,
810
696
  fullPage: args.fullPage !== false, signal });
811
697
  const png = Buffer.from(rendered.bytes);
812
- return { ok: true, text: `[iris] HTML 截图完成(fullPage: ${args.fullPage !== false})`, imageDataUrl: `data:${rendered.mediaType || 'image/png'};base64,${png.toString('base64')}` };
698
+ return { ok: true, storage: 'core', artifactIds: [rendered.artifact.id], text: `[iris] HTML 截图完成(fullPage: ${args.fullPage !== false})`, imageDataUrl: `data:image/png;base64,${png.toString('base64')}` };
813
699
  });
814
700
 
815
701
  /* ---------- 📄 长截图 OCR ---------- */
816
702
  register('ocr', async (host, args, { signal }) => {
703
+ const source = normalizeVisionImageSource(args);
817
704
  const p = String(args.image_path || '').trim();
818
- if (!p || !path.isAbsolute(p)) throw new Error('iris: image_path 必须是绝对路径');
819
- if (!fs.existsSync(p)) throw new Error('iris: 图片不存在');
820
- const backends = buildVisionBackendsFromHost(host, { providers: store.pickAllFor(cap.CAPABILITIES.VISION) });
821
- const result = await longOcr({ input: p, backends, chunkHeight: args.chunk_height || 1200, overlap: args.overlap || 120, signal });
822
- const errCount = result.chunks.filter((c) => c.error).length;
823
- const head = `[iris] 长截图 OCR 完成:${result.width}x${result.height}px,${result.totalChunks} 块${errCount ? '(' + errCount + ' 块失败)' : ''}\n`;
824
- return { ok: true, text: result.fullText ? head + result.fullText : head + '(未识别到文字)' };
705
+ const mediaType = MEDIA_TYPES[path.extname(p).toLowerCase()];
706
+ if (source === 'image_path') {
707
+ if (!p || !path.isAbsolute(p)) throw new Error('iris: image_path 必须是绝对路径');
708
+ if (!mediaType || mediaType === 'image/gif') throw new Error('iris: 不支持的图片格式(png/jpg/jpeg/webp)');
709
+ }
710
+ const result = await runOcrRequest(host, { providers: store.pickAllFor(cap.CAPABILITIES.VISION),
711
+ onRateLimit: store.recordRateLimit,
712
+ prepareImage: operationSignal => source === 'artifact_id'
713
+ ? visionImageForDsh({ artifactId: args.artifact_id, signal: operationSignal }) : readVisionImageFile(p, mediaType, operationSignal),
714
+ chunkHeight: args.chunk_height, overlap: args.overlap, signal });
715
+ return { ok: result.status !== 'failed', status: result.status,
716
+ totalChunks: result.totalChunks, successfulChunks: result.successfulChunks,
717
+ failedChunks: result.failedChunks, skippedChunks: result.skippedChunks,
718
+ ...(args.artifact_id ? { artifactId: args.artifact_id } : {}), text: formatOcrResult(result) };
825
719
  });
826
720
 
827
721
  /* ---------- 📋 任务查询 ---------- */
@@ -1070,22 +964,23 @@ register('relook', async (host, args, { signal }) => {
1070
964
  if (!id) throw new Error('iris: attachment_id 不能为空');
1071
965
  const question = String(args.question || '').trim();
1072
966
  if (!question) throw new Error('iris: question 不能为空');
1073
- // GUI 无会话事件扫描能力:只在 iris 自己的任务产物里找(findOwnAttachment 逻辑)
1074
- let hit = null;
1075
- for (const t of tasks.all()) {
1076
- for (const a of t.attachments || []) {
1077
- if (a.attachmentId === id) {
1078
- const absPath = path.join(store.irisHome(), 'outputs', a.file);
1079
- if (fs.existsSync(absPath)) { hit = { absPath, mediaType: a.mediaType }; break; }
967
+ const { answer, via, model } = await askSingleVisionWithHealth(host, { question, model: args.model,
968
+ async prepareImage(operationSignal) {
969
+ // GUI 没有会话上下文;记录扫描也在预取消检查及整体预算之后执行。
970
+ let hit;
971
+ for (const task of tasks.all()) {
972
+ for (const attachment of task.attachments || []) {
973
+ if (attachment.attachmentId === id) {
974
+ const absPath = path.join(store.irisHome(), 'outputs', attachment.file);
975
+ if (fs.existsSync(absPath)) { hit = { absPath, mediaType: attachment.mediaType }; break; }
976
+ }
977
+ }
978
+ if (hit) break;
1080
979
  }
1081
- }
1082
- if (hit) break;
1083
- }
1084
- if (!hit) throw new Error('iris: 该 attachment 不在 iris 生成的任务产物中(GUI 重看仅支持 iris 画图产物)');
1085
- const mt = hit.mediaType || MEDIA_TYPES[path.extname(hit.absPath).toLowerCase()] || 'image/png';
1086
- const dataUrl = `data:${mt};base64,${fs.readFileSync(hit.absPath).toString('base64')}`;
1087
- const backends = buildVisionBackendsFromHost(host, { providers: store.pickAllFor(cap.CAPABILITIES.VISION), model: args.model });
1088
- const { answer, via, model } = await askWithHealth(backends, { question, imageDataUrl: dataUrl, signal });
980
+ if (!hit) throw new Error('iris: 图片引用不可用');
981
+ const mt = hit.mediaType || MEDIA_TYPES[path.extname(hit.absPath).toLowerCase()] || 'image/png';
982
+ return readVisionImageFile(hit.absPath, mt, operationSignal);
983
+ }, signal });
1089
984
  const viaLabel = via === 'selfstack' ? 'iris 自持栈' : 'DSH 全局视觉模型';
1090
985
  return { ok: true, text: `[iris] 重看回答(${model} · ${viaLabel}):\n${answer}` };
1091
986
  });
@@ -1106,10 +1001,15 @@ register('providers_list', async (host, args) => {
1106
1001
  enabled: p.enabled !== false,
1107
1002
  auth: p.auth === 'none' ? 'none' : 'bearer',
1108
1003
  ...selectMediaProtocol(p),
1004
+ visionInput: p.visionInput || null,
1109
1005
  apiKeyHint: String(p.apiKey || '').length <= 8 ? '****' : String(p.apiKey).slice(0, 3) + '****' + String(p.apiKey).slice(-4),
1110
1006
  models: models.providerModels(p).map((m) => ({
1111
1007
  id: m.id,
1112
1008
  capabilities: m.capabilities,
1009
+ ...(m.imageProtocol !== undefined ? { imageProtocol: m.imageProtocol } : {}),
1010
+ ...(m.visionInput !== undefined ? { visionInput: m.visionInput } : {}),
1011
+ ...(m.capabilities.includes(cap.CAPABILITIES.VISION) ? { visionInputEffective: visionInputLimits(p, m.id) } : {}),
1012
+ ...(m.capabilities.includes(cap.CAPABILITIES.IMAGE) ? { imageRouting: selectImageProtocol(p, m.id) } : {}),
1113
1013
  verified: m.verified || null,
1114
1014
  health: Object.fromEntries(m.capabilities.map((capability) => [
1115
1015
  capability, store.modelHealth(p.id, m.id, capability)
@@ -1126,7 +1026,7 @@ register('providers_upsert', async (host, args) => {
1126
1026
  const id = args.id || undefined;
1127
1027
  const patch = { id };
1128
1028
  const allowed = ['auth', 'name', 'type', 'baseUrl', 'mediaBaseUrl', 'apiKey', 'enabled', 'mediaProtocol',
1129
- 'imageModel', 'videoModel', 'ttsModel', 'transcribeModel', 'visionModel', 'models'];
1029
+ 'imageModel', 'videoModel', 'ttsModel', 'transcribeModel', 'visionModel', 'visionInput', 'models'];
1130
1030
  for (const key of allowed) {
1131
1031
  if (Object.prototype.hasOwnProperty.call(args, key)) patch[key] = args[key];
1132
1032
  }
@@ -1179,14 +1079,12 @@ register('providers_discover', async (host, args, { signal }) => {
1179
1079
  register('providers_test_vision', async (host, args, { signal }) => {
1180
1080
  const p = store.providerById(String(args.id || ''));
1181
1081
  if (!p) throw new Error('iris: 供应商不存在');
1182
- const visionProviders = store.pickAllFor(cap.CAPABILITIES.VISION).filter((x) => x.id === p.id);
1082
+ const visionProviders = store.pickAllFor(cap.CAPABILITIES.VISION, { includeRateLimited: true }).filter((x) => x.id === p.id);
1183
1083
  if (!visionProviders.length) return { ok: true, tested: false, text: '该供应商未配置 vision 模型,跳过' };
1184
- const backends = buildVisionBackendsFromHost(host, { providers: visionProviders });
1185
- const backend = backends[0];
1186
- if (!backend) return { ok: true, tested: false, text: '该供应商的 vision 配置不可用(类型非 openai)' };
1084
+ const model = configuredModel(visionProviders[0], cap.CAPABILITIES.VISION);
1187
1085
  if (args.confirm_paid !== true) throw new Error('iris: 视觉实测会发起真实供应商请求,请先明确确认');
1188
- const r = await testVisionCapability(backend, { timeoutMs: 15000, signal });
1189
- store.setModelVerified(p.id, backend.model, cap.CAPABILITIES.VISION, {
1086
+ const r = await probeVisionModel(visionProviders[0], model, visionImageFromDataUrl(RED_TEST_IMAGE), { timeoutMs: 15000, signal });
1087
+ store.setModelVerified(p.id, model, cap.CAPABILITIES.VISION, {
1190
1088
  ok: r.ok,
1191
1089
  note: r.ok ? '认出红色测试图' : (r.error || '未识别出红色'),
1192
1090
  category: r.category,
@@ -1197,7 +1095,7 @@ register('providers_test_vision', async (host, args, { signal }) => {
1197
1095
  tested: true,
1198
1096
  passed: r.ok,
1199
1097
  text: r.ok
1200
- ? `[iris] 视觉能力实测通过:模型认出红色测试图(${backend.model})`
1098
+ ? `[iris] 视觉能力实测通过:模型认出红色测试图(${model})`
1201
1099
  : `[iris] 视觉能力实测未通过:${r.error || '未识别出红色'}`
1202
1100
  };
1203
1101
  });
@@ -1207,9 +1105,7 @@ register('providers_test_vision', async (host, args, { signal }) => {
1207
1105
  /** 按能力实测单个模型;返回 {ok, note}。真实调用仅在 UI 明确确认后执行。 */
1208
1106
  async function probeModel(host, p, modelId, capability, signal) {
1209
1107
  if (capability === cap.CAPABILITIES.VISION) {
1210
- const backend = buildVisionBackendsFromHost(host, { providers: [p], model: modelId })[0];
1211
- if (!backend) return { ok: false, note: '非 openai 类型,无法直测' };
1212
- const r = await testVisionCapability(backend, { timeoutMs: 15000, signal });
1108
+ const r = await probeVisionModel(p, modelId, visionImageFromDataUrl(RED_TEST_IMAGE), { timeoutMs: 15000, signal });
1213
1109
  return {
1214
1110
  ok: r.ok,
1215
1111
  note: r.ok ? '认出红色测试图' : (r.error || '未识别出红色'),
@@ -1218,24 +1114,28 @@ async function probeModel(host, p, modelId, capability, signal) {
1218
1114
  };
1219
1115
  }
1220
1116
  if (capability === cap.CAPABILITIES.TRANSCRIBE) {
1221
- return { skipped: true, ok: false, note: '需真实音频,请通过“音频转写”动作验证' };
1117
+ return { skipped: true, ok: false, note: '需真实音频;可用 CLI models test --capability transcribe --input 提供素材进行实测恢复' };
1222
1118
  }
1223
1119
  if (capability === cap.CAPABILITIES.TTS) {
1224
- const providerAdapter = createConfiguredProviderAdapter(p);
1120
+ const providerAdapter = createConfiguredProviderAdapter(p, { allowRateLimited: true });
1225
1121
  const submitted = await invokeProviderOperation(providerAdapter, 'submit', {
1226
1122
  capability: 'tts', model: modelId, input: { text: '你好' }, signal
1227
1123
  });
1228
1124
  const value = submitted.kind === 'completed' ? submitted.value : null;
1229
1125
  const ok = Boolean(value && (value.audioUrl || value.audioB64));
1230
- return { ok, note: ok ? '合成返回音频' : (submitted.error?.safeMessage || '无音频返回') };
1126
+ return { ok, note: ok ? '合成返回音频' : (submitted.error?.safeMessage || '无音频返回'),
1127
+ category: submitted.error?.category, httpStatus: submitted.error?.httpStatus };
1231
1128
  }
1232
1129
  if (capability === cap.CAPABILITIES.IMAGE) {
1233
- const providerAdapter = createConfiguredProviderAdapter(p);
1130
+ p = providerForImageModel(p, modelId);
1131
+ const providerAdapter = createConfiguredProviderAdapter(p, { allowRateLimited: true, modelId });
1234
1132
  const submitted = await invokeProviderOperation(providerAdapter, 'submit', {
1235
1133
  capability: 'image', model: modelId,
1236
1134
  input: {
1237
1135
  prompt: providerAdapter.protocol === 'openai-images' ? 'red circle' : '红色圆形',
1238
- size: providerAdapter.protocol === 'openai-images' ? '256x256' : '512*512',
1136
+ ...(['openai-chat-images', 'openai-responses-images'].includes(providerAdapter.protocol) ? {} : {
1137
+ size: providerAdapter.protocol === 'openai-images' ? '256x256' : '512*512'
1138
+ }),
1239
1139
  n: 1
1240
1140
  },
1241
1141
  signal
@@ -1272,7 +1172,7 @@ async function probeModel(host, p, modelId, capability, signal) {
1272
1172
  return { ok: false, note: '生成超时(>60s)' };
1273
1173
  }
1274
1174
  if (capability === cap.CAPABILITIES.VIDEO) {
1275
- return { skipped: true, ok: false, note: '视频提交即可能计费,请通过“视频生成”动作验证' };
1175
+ return { skipped: true, ok: false, note: '视频提交即可能计费;可用 CLI models test --capability video-gen --input 提供素材进行实测恢复' };
1276
1176
  }
1277
1177
  return { ok: false, note: '未知能力' };
1278
1178
  }
@@ -1305,6 +1205,19 @@ register('providers_set_model_caps', async (host, args) => {
1305
1205
  return { ok: true, text: `[iris] ${args.model_id} 能力已设为:${caps.join(',') || '(空)'}` };
1306
1206
  });
1307
1207
 
1208
+ register('providers_set_model_image_protocol', async (host, args) => {
1209
+ const ok = store.setModelImageProtocol(String(args.id || ''), String(args.model_id || ''), args.imageProtocol);
1210
+ if (!ok) throw new Error('iris: 模型不在池中');
1211
+ return { ok: true, text: '[iris] 模型图片协议已更新' };
1212
+ });
1213
+
1214
+ register('providers_set_model_vision_input', async (host, args) => {
1215
+ if (!Object.hasOwn(args, 'visionInput')) throw new Error('iris: 需要 visionInput 对象或 null');
1216
+ const ok = store.setModelVisionInput(String(args.id || ''), String(args.model_id || ''), args.visionInput);
1217
+ if (!ok) throw new Error('iris: 模型不在池中');
1218
+ return { ok: true, text: '[iris] 模型看图输入预算已更新' };
1219
+ });
1220
+
1308
1221
  /** 逐模型实测某能力 → 存 verified("test it, don't guess it";用户手动触发,发现不自动跑) */
1309
1222
  register('providers_test_model', async (host, args, { signal }) => {
1310
1223
  const p = store.providerById(String(args.id || ''));
@@ -1491,28 +1404,32 @@ register('video_frames', async (host, args, { signal }) => {
1491
1404
  /* ============ 多模态视频摘要(阶段 7.3,GUI 同步快速版) ============ */
1492
1405
 
1493
1406
  register('media_summarize', async (host, args, { signal }) => {
1407
+ const source = normalizeSummarySource(args);
1494
1408
  const p = String(args.video_path || '').trim();
1495
- if (!p || !path.isAbsolute(p)) throw new Error('iris: video_path 必须是绝对路径');
1496
- if (!fs.existsSync(p)) throw new Error('iris: 视频文件不存在');
1497
- if (!ffmpegAvailable()) throw new Error('iris: ffmpeg/ffprobe 不可用,无法分析视频(可选安装 ffmpeg 后启用)');
1498
- const meta = probeVideo(p);
1499
- const frames = await extractFrames({
1500
- inputPath: p, maxFrames: Math.min(Number(args.max_frames) || 8, 12), targetWidth: args.target_width,
1501
- format: 'jpeg', quality: 70, signal
1502
- });
1503
- const sheet = await buildContactSheet({ frames, cols: 3 });
1504
- const backends = buildVisionBackendsFromHost(host, { providers: store.pickAllFor(cap.CAPABILITIES.VISION), model: args.model });
1505
- const { answer, via, model } = await summarizeMedia({
1506
- backends, question: args.question,
1507
- transcript: args.transcribe_text, // GUI 同步版:不自动转写,可粘贴已有转写文本
1508
- imageDataUrl: `data:image/png;base64,${sheet.buffer.toString('base64')}`,
1509
- signal
1409
+ if (source === 'video') {
1410
+ if (!p || !path.isAbsolute(p)) throw new Error('iris: video_path 必须是绝对路径');
1411
+ if (!fs.existsSync(p)) throw new Error('iris: 视频文件不存在');
1412
+ if (!ffmpegAvailable()) throw new Error('iris: ffmpeg/ffprobe 不可用,无法分析视频(可选安装 ffmpeg 后启用)');
1413
+ }
1414
+ const { answer, via, model, sheet, meta, frames } = await runSummaryRequest(host, {
1415
+ onRateLimit: store.recordRateLimit,
1416
+ providers: store.pickAllFor(cap.CAPABILITIES.VISION), model: args.model, question: args.question, signal,
1417
+ async prepareMedia(operationSignal) {
1418
+ if (source === 'core-artifacts') {
1419
+ return { ...await summaryFramesForDsh({ frameArtifactIds: args.frame_artifact_ids, signal: operationSignal }), transcript: args.transcribe_text };
1420
+ }
1421
+ const meta = probeVideo(p);
1422
+ const frames = await extractFrames({ inputPath: p, maxFrames: Math.min(Number(args.max_frames) || 8, 12),
1423
+ targetWidth: args.target_width, format: 'jpeg', quality: 70, signal: operationSignal });
1424
+ return { meta, frames, transcript: args.transcribe_text };
1425
+ }
1510
1426
  });
1511
1427
  const viaLabel = via === 'selfstack' ? 'iris 自持栈' : 'DSH 全局视觉模型';
1512
1428
  const hasTrans = args.transcribe_text && String(args.transcribe_text).trim();
1513
1429
  return {
1514
1430
  ok: true,
1515
- text: `[iris] 视频摘要完成(${model} · ${viaLabel},${meta.durationSec.toFixed(1)}s / ${frames.length} 帧联系表${hasTrans ? ' + 转写文本' : ''}):\n${answer}`,
1431
+ frames: summaryFrameRecords(frames),
1432
+ text: `[iris] 视频摘要完成(${model} · ${viaLabel},${summaryTimelineLabel(meta)} / ${frames.length} 帧联系表${hasTrans ? ' + 转写文本' : ''}):\n${answer}`,
1516
1433
  imageDataUrl: `data:image/png;base64,${sheet.buffer.toString('base64')}`
1517
1434
  };
1518
1435
  });