@mokuyoaxis/dsh-iris 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/.dsh/skills/iris-compose-media/SKILL.md +10 -5
  2. package/.dsh/skills/iris-compose-media/references/workflows.md +7 -1
  3. package/.dsh/skills/iris-verify-ui/SKILL.md +2 -1
  4. package/CHANGELOG.md +69 -2
  5. package/README.en.md +67 -31
  6. package/README.md +67 -31
  7. package/bin/dsh-iris.js +222 -21
  8. package/docs/AGENT_QUICKSTART.md +10 -6
  9. package/docs/ARCHITECTURE.md +34 -19
  10. package/docs/ARTIFACT_LIBRARY.md +5 -5
  11. package/docs/ARTIFACT_MANIFEST.md +1 -1
  12. package/docs/CLI_MANAGEMENT.md +183 -0
  13. package/docs/COMPOSITE_VISION.md +35 -0
  14. package/docs/CORE_RUNTIME_CONTRACT.md +16 -7
  15. package/docs/DSH_CORE_MIGRATION.md +48 -19
  16. package/docs/DSH_RC2_ADAPTATION.md +4 -2
  17. package/docs/FAULT_INJECTION.md +21 -2
  18. package/docs/HEADLESS_CLI.md +15 -7
  19. package/docs/HOST_ADAPTER_CONTRACT.md +10 -10
  20. package/docs/HOST_DOCTOR.md +3 -3
  21. package/docs/MODEL_PORT_CONTRACT.md +21 -14
  22. package/docs/OCR_MODEL.md +39 -0
  23. package/docs/PROMPT_OPTIMIZER.md +77 -0
  24. package/docs/PROVIDER_ADAPTER_CONTRACT.md +15 -5
  25. package/docs/PROVIDER_HEALTH.md +22 -5
  26. package/docs/PROVIDER_RUNTIME_HARNESS.md +1 -1
  27. package/docs/PROVIDER_SUBMISSION_CONTRACT.md +2 -2
  28. package/docs/ROADMAP.md +37 -19
  29. package/docs/SECURITY.md +5 -5
  30. package/docs/TASK_SEMANTICS.md +2 -2
  31. package/docs/VISION_CLI.md +132 -0
  32. package/docs/VISION_MODEL.md +51 -0
  33. package/docs/WORKBENCH_ARTIFACTS.md +67 -0
  34. package/docs/file-access-across-environments.md +8 -5
  35. package/docs/releases/0.2.1.md +56 -0
  36. package/docs/releases/0.2.2.md +31 -0
  37. package/lib/actions.js +199 -286
  38. package/lib/adapters.js +216 -41
  39. package/lib/api.js +49 -7
  40. package/lib/capability.js +23 -16
  41. package/lib/chromium-browser.js +117 -0
  42. package/lib/client.js +933 -117
  43. package/lib/command-service.js +121 -7
  44. package/lib/composite-vision-routing.js +42 -0
  45. package/lib/config.js +102 -14
  46. package/lib/core-artifact-store.js +45 -13
  47. package/lib/core-lease-recovery.js +64 -92
  48. package/lib/core-maintenance.js +213 -0
  49. package/lib/core-runtime.js +45 -47
  50. package/lib/core-tasks.js +12 -5
  51. package/lib/core-user-projection.js +1 -0
  52. package/lib/core-writer-lease.js +134 -0
  53. package/lib/dsh-core-adapter.js +63 -9
  54. package/lib/dsh-text-model-adapter.js +222 -0
  55. package/lib/dsh-vision-model-adapter.js +75 -0
  56. package/lib/generation-input.js +36 -5
  57. package/lib/headless-vision.js +181 -0
  58. package/lib/host-runtime.js +1 -0
  59. package/lib/http-vision-model-adapter.js +187 -0
  60. package/lib/image-edit-input.js +34 -0
  61. package/lib/index.js +205 -175
  62. package/lib/locate.js +86 -28
  63. package/lib/media-probe.js +2 -1
  64. package/lib/model-call-runtime.js +45 -0
  65. package/lib/model-invoker.js +4 -2
  66. package/lib/model-port-contract.js +13 -5
  67. package/lib/models.js +29 -42
  68. package/lib/ocr-model-routing.js +26 -0
  69. package/lib/ocr.js +110 -64
  70. package/lib/prompt-optimizer-config.js +21 -10
  71. package/lib/prompt-optimizer-core.js +247 -0
  72. package/lib/prompt-optimizer.js +37 -193
  73. package/lib/provider-adapters.js +87 -10
  74. package/lib/provider-catalog.js +19 -7
  75. package/lib/provider-config-service.js +235 -0
  76. package/lib/provider-contract.js +3 -2
  77. package/lib/provider-health.js +80 -1
  78. package/lib/provider-protocol.js +84 -0
  79. package/lib/provider-task-runner.js +63 -19
  80. package/lib/summarize.js +31 -20
  81. package/lib/summary-input.js +52 -0
  82. package/lib/task-wait.js +40 -0
  83. package/lib/video-input.js +30 -0
  84. package/lib/vision-core.js +42 -0
  85. package/lib/vision-image-input.js +83 -0
  86. package/lib/vision-input.js +31 -0
  87. package/lib/vision-model-routing.js +180 -0
  88. package/lib/vision.js +3 -2
  89. package/lib/workbench-artifacts.js +52 -0
  90. package/lib/workbench-management.js +151 -0
  91. package/lib/workbench-zip.js +39 -0
  92. package/package.json +1 -1
  93. package/user_guide.md +74 -30
@@ -12,16 +12,20 @@ import {
12
12
  rebuildCoreArtifactIndex
13
13
  } from './core-artifacts.js';
14
14
  import { inspectCoreTask, listCoreTasks } from './core-tasks.js';
15
- import { cropImage, pixelDiff, PixelError } from './pixels.js';
15
+ import { cropImage, pixelDiff, imageDimensions, PixelError } from './pixels.js';
16
16
  import { extractFrames, normalizeFramesOptions, probeVideo } from './media-probe.js';
17
17
  import { hasProviderOperation } from './provider-adapter.js';
18
18
  import { createProviderTaskRunner } from './provider-task-runner.js';
19
19
  import { GenerationInputError, normalizeGenerationInput } from './generation-input.js';
20
+ import { videoTaskCandidates } from './video-input.js';
21
+ import { waitForCoreTask } from './task-wait.js';
22
+ import { planCoreDeletion, planCoreCleanup, quarantineCoreFiles, inspectCoreTransactions, restoreCoreTransaction } from './core-maintenance.js';
20
23
 
21
24
  export const COMMAND_CONTRACT_VERSION = 0;
22
25
  export const CORE_COMMANDS = Object.freeze([
23
- 'crop', 'media.diff', 'media.frames', 'task.inspect', 'task.list', 'task.observe', 'task.reobserve', 'task.redeliver', 'task.cancel', 'task.retry',
24
- 'artifact.inspect', 'artifact.list', 'artifact.export', 'artifact.rebuild'
26
+ 'crop', 'media.diff', 'media.frames', 'media.html', 'task.inspect', 'task.list', 'task.wait', 'task.observe', 'task.reobserve', 'task.redeliver', 'task.cancel', 'task.retry',
27
+ 'task.inspect-many', 'artifact.inspect', 'artifact.inspect-many', 'artifact.list', 'artifact.export', 'artifact.export-many', 'artifact.rebuild',
28
+ 'core.delete', 'core.cleanup', 'core.transactions', 'core.restore'
25
29
  ]);
26
30
 
27
31
  export class CommandError extends Error {
@@ -82,11 +86,77 @@ function abortError() {
82
86
  return error;
83
87
  }
84
88
 
89
+ function idList(input, field) {
90
+ const ids = input[field];
91
+ if (!Array.isArray(ids) || !ids.length || ids.length > 200 || !ids.every(id => typeof id === 'string') || new Set(ids).size !== ids.length) {
92
+ throw commandError('IRIS_COMMAND_INPUT_INVALID', field + ' 必须是 1–200 个不重复 ID 的数组');
93
+ }
94
+ return ids;
95
+ }
96
+
97
+ function queryInput(input, fields) {
98
+ exactInput(input, ['offset', 'limit', ...fields]);
99
+ for (const [key, value] of Object.entries(input)) {
100
+ if (['offset', 'limit'].includes(key)) {
101
+ if (!Number.isSafeInteger(value) || value < (key === 'offset' ? 0 : 1) || (key === 'limit' && value > 200)) throw commandError('IRIS_COMMAND_INPUT_INVALID', 'offset 必须非负,limit 必须是 1–200 的整数');
102
+ } else if (typeof value !== 'string' || !value.trim()) throw commandError('IRIS_COMMAND_INPUT_INVALID', '过滤字段必须是非空字符串');
103
+ }
104
+ }
105
+
85
106
  export function createCommandService(runtime, ports = {}) {
86
107
  if (!runtime || typeof runtime.run !== 'function') throw new TypeError('Command Service 需要 Core Runtime');
87
108
 
88
109
  async function execute(name, input) {
89
110
  if (!CORE_COMMANDS.includes(name)) throw commandError('IRIS_COMMAND_UNKNOWN', `未知 Command:${String(name)}`);
111
+ if (name === 'core.delete' || name === 'core.cleanup') {
112
+ exactInput(input, name === 'core.delete' ? ['task_ids', 'artifact_ids', 'confirm_delete'] : ['paths', 'older_than_ms', 'confirm_delete']);
113
+ if (input.confirm_delete !== undefined && typeof input.confirm_delete !== 'boolean') throw commandError('IRIS_COMMAND_INPUT_INVALID', 'confirm_delete 必须是布尔值');
114
+ return runtime.run(input.confirm_delete === true ? 'execute' : 'inspect', ({ dataRoot, signal }) => {
115
+ if (signal.aborted) throw abortError();
116
+ let plan = name === 'core.delete' ? planCoreDeletion(dataRoot, input) : planCoreCleanup(dataRoot, input);
117
+ if (name === 'core.cleanup' && input.confirm_delete === true) {
118
+ const paths = input.paths;
119
+ if (!Array.isArray(paths) || !paths.length || paths.length > 200 || !paths.every(file => typeof file === 'string') || new Set(paths).size !== paths.length) throw commandError('IRIS_COMMAND_INPUT_INVALID', '执行清理必须明确指定 1–200 个预览中的 paths');
120
+ const entries = paths.map(file => plan.candidates.find(entry => entry.path === file));
121
+ if (entries.some(entry => !entry)) throw commandError('IRIS_CORE_CLEANUP_CHANGED', '指定路径不在当前可清理预览中');
122
+ plan = { operation: 'cleanup', allowed: true, entries };
123
+ }
124
+ const result = input.confirm_delete === true ? quarantineCoreFiles(dataRoot, plan) : { preview: true, ...plan };
125
+ return { contractVersion: COMMAND_CONTRACT_VERSION, command: name, ...result };
126
+ });
127
+ }
128
+ if (name === 'core.transactions') {
129
+ exactInput(input, []);
130
+ return runtime.run('inspect', ({ dataRoot }) => ({ contractVersion: COMMAND_CONTRACT_VERSION, command: name, transactions: inspectCoreTransactions(dataRoot) }));
131
+ }
132
+ if (name === 'core.restore') {
133
+ exactInput(input, ['transaction_id']);
134
+ return runtime.run('execute', ({ dataRoot }) => ({ contractVersion: COMMAND_CONTRACT_VERSION, command: name, ...restoreCoreTransaction(dataRoot, input.transaction_id) }));
135
+ }
136
+ if (name === 'media.html') {
137
+ exactInput(input, ['html', 'width', 'height', 'full_page']);
138
+ if (typeof input.html !== 'string' || !input.html.trim() || Buffer.byteLength(input.html) > 1024 * 1024) {
139
+ throw commandError('IRIS_COMMAND_INPUT_INVALID', 'html 必须是非空字符串,最多 1 MiB');
140
+ }
141
+ const width = input.width ?? 1280, height = input.height ?? 720;
142
+ if (![width, height].every(value => Number.isSafeInteger(value) && value > 0 && value <= 4096)
143
+ || (input.full_page !== undefined && typeof input.full_page !== 'boolean')) {
144
+ throw commandError('IRIS_COMMAND_INPUT_INVALID', 'width/height 必须是 1–4096 的整数,full_page 必须是布尔值');
145
+ }
146
+ if (typeof ports.browser?.renderHtml !== 'function') throw commandError('IRIS_COMMAND_BROWSER_REQUIRED', 'HTML 截图需要 Browser Port');
147
+ return runtime.run('execute', async ({ dataRoot, signal }) => {
148
+ const rendered = await ports.browser.renderHtml({ html: input.html, width, height, fullPage: input.full_page !== false, signal });
149
+ if (signal.aborted) throw abortError();
150
+ const bytes = Buffer.from(rendered.bytes);
151
+ if (rendered.mediaType !== 'image/png' || !bytes.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex'))) {
152
+ throw commandError('IRIS_COMMAND_PROCESSING_FAILED', '浏览器没有返回 PNG 截图');
153
+ }
154
+ const dimensions = await imageDimensions(bytes);
155
+ if (signal.aborted) throw abortError();
156
+ const artifact = createCoreArtifact(dataRoot, { bytes, mediaType: 'image/png', kind: 'html-screenshot', metadata: dimensions });
157
+ return Object.freeze({ contractVersion: COMMAND_CONTRACT_VERSION, command: name, artifact });
158
+ });
159
+ }
90
160
  if (name === 'crop') {
91
161
  exactInput(input, ['image_path', 'artifact_id', 'left', 'top', 'width', 'height']);
92
162
  const hasPath = Boolean(String(input.image_path || '').trim());
@@ -274,6 +344,12 @@ export function createCommandService(runtime, ports = {}) {
274
344
  });
275
345
  });
276
346
  }
347
+ if (name === 'task.wait') {
348
+ exactInput(input, ['task_id', 'timeout_ms', 'poll_interval_ms']);
349
+ const result = await waitForCoreTask(runtime, input.task_id, { timeoutMs: input.timeout_ms, pollIntervalMs: input.poll_interval_ms,
350
+ ...(typeof ports.resolveTaskAdapter === 'function' ? { observe: () => execute('task.observe', { task_id: input.task_id }) } : {}) });
351
+ return Object.freeze({ contractVersion: COMMAND_CONTRACT_VERSION, command: name, ...result });
352
+ }
277
353
  // task.reobserve 是人工触发观察的对外命令名(DSH API/工作台),与 task.observe
278
354
  // 共用同一份门与单步 poll 实现;语义不区分,绝不 submit。
279
355
  if (name === 'task.observe' || name === 'task.reobserve') {
@@ -339,23 +415,28 @@ export function createCommandService(runtime, ports = {}) {
339
415
  throw commandError('IRIS_TASK_NOT_RETRYABLE', '只有终态且未成功交付的 Task 可以重试为新任务');
340
416
  }
341
417
  let normalized;
342
- try { normalized = normalizeGenerationInput(before.capability, input.provider_input); }
418
+ try { normalized = normalizeGenerationInput(before.capability, input.provider_input, { allowVideoPaths: before.capability === 'video' }); }
343
419
  catch (error) {
344
420
  if (error instanceof GenerationInputError) throw commandError('IRIS_COMMAND_INPUT_INVALID', error.message);
345
421
  throw error;
346
422
  }
347
423
  const providerInput = normalized.providerInput;
424
+ if (before.sourceArtifactId && !normalized.sourceArtifactId) {
425
+ throw commandError('IRIS_COMMAND_INPUT_INVALID', '改图重试必须重新提供 source_artifact_id 和修改指令');
426
+ }
348
427
  if (Object.hasOwn(input.provider_input, 'model_ref')) {
349
428
  throw commandError('IRIS_COMMAND_INPUT_INVALID', 'retry 的模型覆盖请使用外层 model_ref');
350
429
  }
351
430
  const modelRef = String(input.model_ref || '').trim();
352
431
  if (input.model_ref !== undefined && !modelRef) throw commandError('IRIS_COMMAND_INPUT_INVALID', 'model_ref 必须是 providerId::modelId 复合引用');
353
432
  if (typeof ports.resolveTaskCandidates !== 'function') throw commandError('IRIS_COMMAND_PROVIDER_REQUIRED', 'task.retry 需要宿主显式提供候选链 resolver');
354
- const candidates = await ports.resolveTaskCandidates({ capability: before.capability, modelRef });
433
+ let candidates = await ports.resolveTaskCandidates({ capability: before.capability, modelRef });
434
+ if (before.capability === 'video') candidates = videoTaskCandidates(candidates, normalized);
355
435
  const result = await createProviderTaskRunner(runtime).submit({
356
436
  capability: before.capability,
357
437
  candidates,
358
438
  providerInput,
439
+ sourceArtifactId: normalized.sourceArtifactId,
359
440
  retriedFrom: before.id
360
441
  });
361
442
  return Object.freeze({
@@ -377,7 +458,7 @@ export function createCommandService(runtime, ports = {}) {
377
458
  }));
378
459
  }
379
460
  if (name === 'task.list') {
380
- exactInput(input, ['offset', 'limit']);
461
+ queryInput(input, ['status', 'capability', 'provider_id', 'model_ref', 'outcome', 'delivery_state']);
381
462
  return runtime.run('inspect', ({ dataRoot }) => Object.freeze({
382
463
  contractVersion: COMMAND_CONTRACT_VERSION,
383
464
  command: name,
@@ -393,7 +474,7 @@ export function createCommandService(runtime, ports = {}) {
393
474
  }));
394
475
  }
395
476
  if (name === 'artifact.list') {
396
- exactInput(input, ['offset', 'limit']);
477
+ queryInput(input, ['kind', 'media_type', 'task_id']);
397
478
  return runtime.run('inspect', ({ dataRoot }) => Object.freeze({
398
479
  contractVersion: COMMAND_CONTRACT_VERSION,
399
480
  command: name,
@@ -408,6 +489,39 @@ export function createCommandService(runtime, ports = {}) {
408
489
  ...rebuildCoreArtifactIndex(dataRoot)
409
490
  }));
410
491
  }
492
+ if (name === 'task.inspect-many' || name === 'artifact.inspect-many') {
493
+ const task = name.startsWith('task.'), field = task ? 'task_ids' : 'artifact_ids';
494
+ exactInput(input, [field]);
495
+ const ids = idList(input, field);
496
+ return runtime.run('inspect', ({ dataRoot }) => ({ contractVersion: COMMAND_CONTRACT_VERSION, command: name,
497
+ results: ids.map(id => {
498
+ try { return { id, [task ? 'task' : 'artifact']: task ? inspectCoreTask(dataRoot, id) : inspectCoreArtifact(dataRoot, id) }; }
499
+ catch (error) { return { id, error: { code: error.code || 'IRIS_COMMAND_FAILED' } }; }
500
+ }) }));
501
+ }
502
+ if (name === 'artifact.export-many') {
503
+ exactInput(input, ['artifact_ids', 'output_directory']);
504
+ const ids = idList(input, 'artifact_ids');
505
+ const directory = input.output_directory;
506
+ if (typeof directory !== 'string' || !path.isAbsolute(directory) || !fs.existsSync(directory) || !fs.statSync(directory).isDirectory()) {
507
+ throw commandError('IRIS_COMMAND_INPUT_INVALID', 'output_directory 必须是已存在的绝对目录');
508
+ }
509
+ return runtime.run('execute', ({ dataRoot, signal }) => {
510
+ const extensions = { 'image/png': '.png', 'image/jpeg': '.jpg', 'image/webp': '.webp', 'image/gif': '.gif', 'video/mp4': '.mp4', 'video/webm': '.webm', 'audio/wav': '.wav', 'audio/mpeg': '.mp3', 'audio/ogg': '.ogg', 'audio/flac': '.flac', 'text/plain': '.txt', 'application/json': '.json' };
511
+ const outputs = ids.map(id => {
512
+ const artifact = inspectCoreArtifact(dataRoot, id);
513
+ const file = path.join(directory, id + (extensions[artifact.mediaType] || '.bin'));
514
+ if (fs.existsSync(file)) throw commandError('IRIS_ARTIFACT_EXPORT_EXISTS', '批量导出目标已存在;未导出任何文件');
515
+ return { id, file };
516
+ });
517
+ const results = outputs.map(({ id, file }) => {
518
+ if (signal.aborted) return { id, exported: false, error: { code: 'IRIS_COMMAND_ABORTED' } };
519
+ try { return { id, ...exportCoreArtifact(dataRoot, id, file) }; }
520
+ catch (error) { return { id, exported: false, error: { code: error.code || 'IRIS_ARTIFACT_EXPORT_FAILED' } }; }
521
+ });
522
+ return { contractVersion: COMMAND_CONTRACT_VERSION, command: name, results };
523
+ });
524
+ }
411
525
  exactInput(input, ['artifact_id', 'output_path']);
412
526
  return runtime.run('execute', ({ dataRoot }) => Object.freeze({
413
527
  contractVersion: COMMAND_CONTRACT_VERSION,
@@ -0,0 +1,42 @@
1
+ 'use strict';
2
+ /** Host 选型留边界,定位/拼图只消费共享模型端口;准备也属于本次预算。 */
3
+ import sharp from 'sharp';
4
+ import { runVisionOperation, runVisionRequest } from './vision-model-routing.js';
5
+ import { locateObject, normalizeLocateTarget, createLocateImageRequest } from './locate.js';
6
+ import { summarizeMedia } from './summarize.js';
7
+ import { VISION_BUDGET } from './vision-core.js';
8
+ import { normalizeModelCall } from './model-port-contract.js';
9
+ import { waitForModelWork } from './model-call-runtime.js';
10
+
11
+ export async function runLocateRequest(host, { providers = [], model, target, image, prepareImage, signal, budget = VISION_BUDGET, onRateLimit }) {
12
+ target = normalizeLocateTarget(target);
13
+ const inputs = new Map();
14
+ const result = await runVisionOperation(host, { providers, model, signal, budget, onRateLimit, prepareImages: true,
15
+ orientImages: true, prepareImageRequest: createLocateImageRequest(target, inputs) }, async (ports, operation) => {
16
+ const resolved = await waitForModelWork(() => prepareImage ? prepareImage(operation.signal) : image, operation.signal);
17
+ const source = normalizeModelCall(ports[0].describe(), { prompt: target, image: resolved }, { budget }).request.image;
18
+ const { width, height } = await waitForModelWork(() => sharp(Buffer.from(source.bytes)).metadata(), operation.signal);
19
+ const completion = await locateObject(ports, { target, image: source, width, height }, { operation, budget });
20
+ return { completion, ...completion.bbox, width, height, input: inputs.get(completion.identity.backendId) };
21
+ });
22
+ const { answer: _answer, ...location } = result;
23
+ return location;
24
+ }
25
+
26
+ export async function runSummaryRequest(host, { providers = [], model, prepareMedia, frames, question, transcript, signal, budget = VISION_BUDGET, onRateLimit }) {
27
+ return runVisionOperation(host, { providers, model, signal, budget, onRateLimit }, async (ports, operation) => {
28
+ const media = await waitForModelWork(() => prepareMedia ? prepareMedia(operation.signal) : { frames, transcript }, operation.signal);
29
+ const completion = await summarizeMedia({ ports, frames: media.frames, question, transcript: media.transcript,
30
+ operation, budget });
31
+ return { completion, sheet: completion.sheet, frames: media.frames, meta: media.meta, transcriptNote: media.transcriptNote || '' };
32
+ });
33
+ }
34
+
35
+ /** 增强描述沿用共享调用;失败不改变已经完成的媒体交付。 */
36
+ export async function describeGeneratedImage(host, { providers = [], originalPrompt, image, prepareImage, signal, onRateLimit }) {
37
+ try {
38
+ const result = await runVisionRequest(host, { providers, image, prepareImage, signal, onRateLimit,
39
+ question: `用不超过两句话描述这张图片的主题与构图。生成该图的提示词是:「${String(originalPrompt).slice(0, 200)}」` });
40
+ return result.answer.slice(0, 300);
41
+ } catch (_) { return ''; }
42
+ }
package/lib/config.js CHANGED
@@ -1,4 +1,5 @@
1
1
  'use strict';
2
+ import { normalizeVisionInputLimits } from './vision-image-input.js';
2
3
  /**
3
4
  * Iris 配置存储:$DSH_HOME/iris/v1/providers.json
4
5
  * Iris 独立持有配置;旧工作台仅作为用户显式指定的一次性导入来源。
@@ -10,14 +11,15 @@ import os from 'node:os';
10
11
  import * as cap from './capability.js';
11
12
  import * as models from './models.js';
12
13
  import { atomicWritePrivate, chmodPrivateFile, privateSibling } from './private-storage.js';
13
- import { selectMediaProtocol, isConfiguredProvider } from './provider-protocol.js';
14
+ import { selectMediaProtocol, selectImageProtocol, normalizeImageProtocol, isConfiguredProvider } from './provider-protocol.js';
14
15
  import { redactProviderMessage } from './provider-contract.js';
15
16
  import {
16
17
  PROVIDER_HEALTH_FRESH_MS,
17
18
  aggregateHealthStates,
18
19
  healthObservationState,
19
20
  isDefinitiveHealthFailure,
20
- newestHealthEvidence
21
+ newestHealthEvidence,
22
+ modelRateLimit, recordModelRateLimit, clearModelRateLimit, clearModelCapabilityHealth
21
23
  } from './provider-health.js';
22
24
 
23
25
  function isPlainObject(value) {
@@ -64,7 +66,7 @@ function normalizeStoredProvider(provider) {
64
66
  const changed = provider.mediaProtocol !== selected.mediaProtocol
65
67
  || (provider.protocolInferred === true) !== selected.protocolInferred;
66
68
  Object.assign(provider, selected);
67
- return models.migrateProviderModels(provider) || changed;
69
+ return changed;
68
70
  }
69
71
 
70
72
  export function irisHome() {
@@ -191,7 +193,7 @@ export function pickFor(capability) {
191
193
  const pool = models.modelPool(providers());
192
194
  let m = null;
193
195
  for (const ref of assignmentOrder(capability)) {
194
- m = pool.find((x) => x.ref === ref && x.capabilities.includes(capability));
196
+ m = pool.find((x) => !x.rateLimited && x.ref === ref && x.capabilities.includes(capability));
195
197
  if (m) break;
196
198
  }
197
199
  if (!m) m = models.pickModel(pool, capability);
@@ -207,9 +209,9 @@ export function pickFor(capability) {
207
209
  * 已分配顺序优先(阶段 6 条目 4),其余按池顺序补齐(去重)。
208
210
  * 一个 provider 若池里有多个该能力模型,会返回多条(各自带不同模型)。
209
211
  */
210
- export function pickAllFor(capability) {
212
+ export function pickAllFor(capability, { includeRateLimited = false } = {}) {
211
213
  const pool = models.modelPool(providers());
212
- const chosen = models.orderedModels(pool, capability, assignments()[capability]);
214
+ const chosen = models.orderedModels(pool, capability, assignments()[capability], { includeRateLimited });
213
215
  const out = [];
214
216
  for (const m of chosen) {
215
217
  const p = providerById(m.providerId);
@@ -352,8 +354,10 @@ function applyProviderHealth(provider, modelId, capability, result, fallbackSour
352
354
  health.observations.push(observation);
353
355
  }
354
356
  const evidence = observationEvidence(provider, result || {}, fallbackSource);
357
+ recordModelRateLimit(provider, model, { ...evidence, retryAfterMs: result?.retryAfterMs, until: result?.until, reason: result?.reason });
355
358
  if (result && result.ok === true) {
356
359
  observation.lastSuccess = evidence;
360
+ if (evidence.source === 'probe') clearModelRateLimit(provider, model);
357
361
  } else if (isDefinitiveHealthFailure({ ...result, ...evidence })) {
358
362
  observation.lastFailure = evidence;
359
363
  } else {
@@ -391,10 +395,11 @@ function resetHealthOnCriticalChange(previous, next, patch) {
391
395
  if (!changed) return;
392
396
  const current = previous.health;
393
397
  const revision = Number.isSafeInteger(current && current.revision) ? current.revision + 1 : 1;
394
- next.health = { version: 1, revision, observations: [] };
398
+ next.health = { version: 1, revision, observations: [],
399
+ ...(current?.rateLimits?.length ? { rateLimits: current.rateLimits } : {}) };
395
400
  }
396
401
 
397
- /** 记录一次显式实测或真实任务观察;临时错误不会覆盖近期成功。 */
402
+ /** 记录显式实测或真实任务观察;限流资格另存,保留原成功事实。 */
398
403
  export function recordProviderHealth(id, modelId, capability, result) {
399
404
  const c = load();
400
405
  const provider = c.providers.find((item) => item.id === id);
@@ -405,10 +410,24 @@ export function recordProviderHealth(id, modelId, capability, result) {
405
410
  return true;
406
411
  }
407
412
 
413
+ /** DSH 调用边界记录冷却或明确耗尽;覆盖同账号该模型的所有能力。 */
414
+ export function recordRateLimit({ providerId, modelId, capability, httpStatus = 429, category = 'rate_limit', at, until, retryAfterMs, reason }) {
415
+ const known = { image: 'image-gen', video: 'video-gen' }[capability] || capability;
416
+ return recordProviderHealth(providerId, modelId, known, {
417
+ ok: false, source: 'task', category, httpStatus, at, until, retryAfterMs, reason,
418
+ note: category === 'quota' ? '额度或预算耗尽后已停用,手动实测成功后恢复' : '429 限流冷却,暂时跳过'
419
+ });
420
+ }
421
+
408
422
  /** 返回单个 Provider × Model × Capability 的安全状态。 */
409
423
  export function modelHealth(id, modelId, capability, { now = Date.now() } = {}) {
410
424
  const provider = load().providers.find((item) => item.id === id);
411
425
  if (!provider || provider.enabled === false || missingCredentials(provider)) return { status: 'unconfigured' };
426
+ const blocked = modelRateLimit(provider, modelId, { now });
427
+ if (blocked) return { status: 'failed', rateLimited: true, observedAt: blocked.at,
428
+ source: blocked.source, category: blocked.category, httpStatus: blocked.httpStatus,
429
+ ...(blocked.until ? { retryAt: blocked.until } : { reason: blocked.reason }),
430
+ note: blocked.until ? '429 限流冷却,暂时跳过' : '额度或预算耗尽后已停用,手动实测成功后恢复' };
412
431
  const observation = healthObservation(provider, modelId, capability);
413
432
  const status = healthObservationState(observation, { now });
414
433
  const evidence = status === 'verified'
@@ -430,7 +449,7 @@ export function modelHealth(id, modelId, capability, { now = Date.now() } = {})
430
449
  export function providerHealthSnapshot({ now = Date.now() } = {}) {
431
450
  const capabilities = {};
432
451
  for (const capability of Object.values(cap.CAPABILITIES)) {
433
- const candidates = pickAllFor(capability).map((provider) => {
452
+ const candidates = pickAllFor(capability, { includeRateLimited: true }).map((provider) => {
434
453
  const modelId = String(provider[FIELD_OF[capability]] || '');
435
454
  const health = modelHealth(provider.id, modelId, capability, { now });
436
455
  return { providerId: provider.id, modelId, ...health };
@@ -443,14 +462,21 @@ export function providerHealthSnapshot({ now = Date.now() } = {}) {
443
462
  status,
444
463
  candidateCount: candidates.length,
445
464
  ...(newest ? { observedAt: newest.observedAt } : {}),
465
+ ...(status === 'failed' && candidates.every(item => item.retryAt) ? {
466
+ rateLimited: true, retryAt: candidates.map(item => item.retryAt).sort()[0]
467
+ } : {}),
446
468
  candidates
447
469
  };
448
470
  }
471
+ const overall = aggregateHealthStates(Object.values(capabilities).map((item) => item.status));
472
+ const configured = Object.values(capabilities).filter(item => item.status !== 'unconfigured');
449
473
  return {
450
474
  schemaVersion: 1,
451
475
  freshForMs: PROVIDER_HEALTH_FRESH_MS,
452
476
  generatedAt: new Date(Number(now)).toISOString(),
453
- overall: aggregateHealthStates(Object.values(capabilities).map((item) => item.status)),
477
+ overall,
478
+ ...(overall === 'failed' && configured.every(item => item.retryAt)
479
+ ? { retryAt: configured.map(item => item.retryAt).sort()[0] } : {}),
454
480
  capabilities
455
481
  };
456
482
  }
@@ -460,6 +486,10 @@ export function upsert(provider) {
460
486
  const { id, ...input } = provider;
461
487
  // PATCH 语义:缺失字段和 undefined 都不覆盖旧值;null/空串仍是显式更新。
462
488
  const rest = Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined));
489
+ if (rest.visionInput !== undefined) rest.visionInput = normalizeVisionInputLimits(rest.visionInput) || null;
490
+ if (Array.isArray(rest.models)) rest.models = rest.models.map(model => typeof model === 'string' ? model : ({ ...model,
491
+ ...(model?.imageProtocol !== undefined ? { imageProtocol: normalizeImageProtocol(model.imageProtocol) } : {}),
492
+ ...(model?.visionInput !== undefined ? { visionInput: normalizeVisionInputLimits(model.visionInput) || null } : {}) }));
463
493
  if (id) {
464
494
  const i = c.providers.findIndex((p) => p.id === id);
465
495
  if (i >= 0) {
@@ -468,6 +498,7 @@ export function upsert(provider) {
468
498
  if (Object.prototype.hasOwnProperty.call(rest, 'mediaProtocol')) next.protocolInferred = false;
469
499
  normalizeStoredProvider(next);
470
500
  resetHealthOnCriticalChange(previous, next, rest);
501
+ resetChangedImageProtocolHealth(previous, next);
471
502
  if (Object.prototype.hasOwnProperty.call(rest, 'models')) pruneHealthToEffectiveModels(next);
472
503
  c.providers[i] = next;
473
504
  persist();
@@ -502,23 +533,29 @@ export function setProviderModels(id, modelEntries) {
502
533
  const c = load();
503
534
  const p = c.providers.find((x) => x.id === id);
504
535
  if (!p) return null;
536
+ const previous = { ...p };
505
537
  if (Array.isArray(modelEntries)) {
506
538
  // 保留同名模型的实测结果;手工能力标注优先于后续自动发现。
507
539
  const prev = new Map();
508
540
  for (const m of p.models || []) if (m && m.id) prev.set(m.id, m);
509
541
  p.models = modelEntries.map((m) => {
510
- const entry = typeof m === 'string' ? { id: m } : { id: m.id, capabilities: m.capabilities };
542
+ const entry = typeof m === 'string' ? { id: m } : { id: m.id, capabilities: m.capabilities,
543
+ ...(m.imageProtocol !== undefined ? { imageProtocol: normalizeImageProtocol(m.imageProtocol) } : {}),
544
+ ...(m.visionInput !== undefined ? { visionInput: normalizeVisionInputLimits(m.visionInput) || null } : {}) };
511
545
  const old = prev.get(entry.id);
512
546
  if (old) {
513
547
  if (old.verified) entry.verified = old.verified;
514
548
  if (old.source) entry.source = old.source;
515
549
  if (old.source === 'manual' && Array.isArray(old.capabilities)) entry.capabilities = old.capabilities;
550
+ if (m?.imageProtocol === undefined && old.imageProtocol !== undefined) entry.imageProtocol = old.imageProtocol;
551
+ if (m?.visionInput === undefined && old.visionInput !== undefined) entry.visionInput = old.visionInput;
516
552
  }
517
553
  if (m && m.verified) entry.verified = m.verified;
518
554
  if (m && m.source) entry.source = m.source;
519
555
  return entry;
520
556
  });
521
557
  }
558
+ resetChangedImageProtocolHealth(previous, p);
522
559
  pruneHealthToEffectiveModels(p);
523
560
  persist();
524
561
  return p;
@@ -528,10 +565,19 @@ export function setProviderModels(id, modelEntries) {
528
565
  export function mergeDiscoveredModels(id, modelEntries) {
529
566
  const provider = load().providers.find((p) => p.id === id);
530
567
  if (!provider) return null;
531
- const merged = new Map(models.providerModels(provider).map((m) => [m.id, m]));
568
+ const merged = new Map(models.providerModels(provider).map((m) => {
569
+ const raw = provider.models?.find((entry) => String(typeof entry === 'string' ? entry : entry?.id || '').trim() === m.id);
570
+ // 读取投影的 [] 不能伪造成用户明确清空;未标注条目以后仍可接收可信元数据。
571
+ if (!Array.isArray(raw?.capabilities) && !models.hasConfiguredModelCapabilities(provider, m.id)) {
572
+ const entry = { ...m };
573
+ delete entry.capabilities;
574
+ return [m.id, entry];
575
+ }
576
+ return [m.id, m];
577
+ }));
532
578
  for (const model of modelEntries) {
533
579
  const previous = merged.get(model.id);
534
- if (previous?.source === 'manual') continue;
580
+ if (previous && models.hasConfiguredModelCapabilities(provider, model.id)) continue;
535
581
  merged.set(model.id, { ...previous, ...model, source: 'discovered' });
536
582
  }
537
583
  return setProviderModels(id, [...merged.values()]);
@@ -542,6 +588,48 @@ function findModelEntry(p, modelId) {
542
588
  return (p.models || []).find((m) => m && m.id === modelId);
543
589
  }
544
590
 
591
+ function resetChangedImageProtocolHealth(previous, next) {
592
+ for (const model of models.providerModels(previous)) {
593
+ if (selectImageProtocol(previous, model.id).mediaProtocol !== selectImageProtocol(next, model.id).mediaProtocol) {
594
+ clearModelCapabilityHealth(next, model.id, cap.CAPABILITIES.IMAGE);
595
+ }
596
+ }
597
+ }
598
+
599
+ /** 手动选择单模型图片协议;auto 恢复账号默认,不解除模型停用。 */
600
+ export function setModelImageProtocol(id, modelId, value) {
601
+ const protocol = normalizeImageProtocol(value);
602
+ const p = load().providers.find(provider => provider.id === id);
603
+ if (!p) return false;
604
+ const entries = Array.isArray(p.models) ? p.models : models.providerModels(p).map(({ id, capabilities }) => ({ id, capabilities }));
605
+ const index = entries.findIndex(model => (typeof model === 'string' ? model : model?.id)?.trim() === modelId);
606
+ if (index < 0) return false;
607
+ p.models = entries;
608
+ const entry = typeof p.models[index] === 'string' ? { id: p.models[index] } : p.models[index];
609
+ p.models[index] = entry;
610
+ const previous = selectImageProtocol(p, modelId).mediaProtocol;
611
+ if (protocol === 'auto') delete entry.imageProtocol;
612
+ else entry.imageProtocol = protocol;
613
+ if (previous !== selectImageProtocol(p, modelId).mediaProtocol) clearModelCapabilityHealth(p, modelId, cap.CAPABILITIES.IMAGE);
614
+ persist();
615
+ return true;
616
+ }
617
+
618
+ /** 看图预算变更不会伪造实测成功、解除额度停用或改写原图。 */
619
+ export function setModelVisionInput(id, modelId, value) {
620
+ const limits = normalizeVisionInputLimits(value);
621
+ const p = load().providers.find(provider => provider.id === id);
622
+ if (!p) return false;
623
+ const entries = (Array.isArray(p.models) ? p.models : models.providerModels(p)).map(model => typeof model === 'string' ? { id: model } : { ...model });
624
+ const entry = entries.find(model => model.id === modelId);
625
+ if (!entry) return false;
626
+ if (limits) entry.visionInput = limits;
627
+ else delete entry.visionInput;
628
+ p.models = entries;
629
+ persist();
630
+ return true;
631
+ }
632
+
545
633
  /** 手动添加一个模型到池(source=manual,能力由调用方给) */
546
634
  export function addProviderModel(id, modelId, capabilities) {
547
635
  const c = load();
@@ -552,7 +640,7 @@ export function addProviderModel(id, modelId, capabilities) {
552
640
  if (!Array.isArray(p.models)) p.models = [];
553
641
  let entry = findModelEntry(p, name);
554
642
  if (!entry) {
555
- entry = { id: name, capabilities: Array.isArray(capabilities) ? capabilities : [], source: 'manual' };
643
+ entry = { id: name, capabilities: Array.isArray(capabilities) ? capabilities : undefined, source: 'manual' };
556
644
  p.models.push(entry);
557
645
  } else if (Array.isArray(capabilities)) {
558
646
  entry.capabilities = capabilities;
@@ -174,11 +174,11 @@ function publicArtifact(manifest) {
174
174
  });
175
175
  }
176
176
 
177
- function verifyObject(dataRoot, manifest) {
177
+ function verifyObject(dataRoot, manifest, verifyContent = true) {
178
178
  const object = path.join(dirs(dataRoot).objects, manifest.object);
179
179
  const stat = regularFile(object);
180
180
  if (stat.size !== manifest.size) fail('IRIS_ARTIFACT_DIGEST_MISMATCH', 'Artifact 大小与 Manifest 不一致');
181
- if (hashFile(object) !== manifest.digest.value) {
181
+ if (verifyContent && hashFile(object) !== manifest.digest.value) {
182
182
  fail('IRIS_ARTIFACT_DIGEST_MISMATCH', 'Artifact 内容哈希与 Manifest 不一致');
183
183
  }
184
184
  return object;
@@ -209,7 +209,7 @@ function legacyManifest(dataRoot, record, id) {
209
209
  };
210
210
  }
211
211
 
212
- function readArtifact(dataRoot, id, { allowLegacy = true } = {}) {
212
+ function readArtifact(dataRoot, id, { allowLegacy = true, verifyContent = true } = {}) {
213
213
  checkStore(dataRoot);
214
214
  const stable = stableId(id);
215
215
  let record;
@@ -226,7 +226,7 @@ function readArtifact(dataRoot, id, { allowLegacy = true } = {}) {
226
226
  );
227
227
  } else if (allowLegacy) manifest = legacyManifest(dataRoot, record, stable);
228
228
  else fail('IRIS_ARTIFACT_INVALID', 'Artifact 提交记录无效');
229
- const object = verifyObject(dataRoot, manifest);
229
+ const object = verifyObject(dataRoot, manifest, verifyContent);
230
230
  return { manifest, object, legacy: record.schemaVersion === 0 };
231
231
  }
232
232
 
@@ -284,6 +284,14 @@ function refreshIndex(dataRoot) {
284
284
  return writeIndex(dataRoot, manifests);
285
285
  }
286
286
 
287
+ /** 只更新已提交条目的派生索引,不升级旧记录或接回孤儿。 */
288
+ export function refreshCoreArtifactInventory(dataRoot) {
289
+ checkStore(dataRoot, { manifests: true });
290
+ const manifests = committedIds(dataRoot).map(id => readArtifact(dataRoot, id).manifest);
291
+ const index = writeIndex(dataRoot, manifests);
292
+ return { revision: index.revision, total: manifests.length };
293
+ }
294
+
287
295
  function validateRelationTargets(dataRoot, relations) {
288
296
  for (const edge of relations) readArtifact(dataRoot, edge.artifactId);
289
297
  }
@@ -402,14 +410,11 @@ export function exportCoreArtifact(dataRoot, id, outputPath) {
402
410
  return Object.freeze({ artifact: publicArtifact(hit.manifest), exported: true, bytes: hit.manifest.size });
403
411
  }
404
412
 
405
- export function listCoreArtifacts(dataRoot, { offset = 0, limit = 50 } = {}) {
413
+ function readArtifactIndex(dataRoot) {
406
414
  try { fs.lstatSync(dirs(dataRoot).storeRoot); }
407
415
  catch (error) {
408
416
  if (error?.code !== 'ENOENT') fail('IRIS_ARTIFACT_STORE_INVALID', 'Artifact Store 无法读取');
409
- return Object.freeze({
410
- schemaVersion: 0, revision: 0, total: 0, offset: 0, limit: Math.max(1, Math.min(200, Math.trunc(Number(limit) || 50))),
411
- artifacts: Object.freeze([])
412
- });
417
+ return { schemaVersion: 0, revision: 0, artifacts: [], absent: true };
413
418
  }
414
419
  checkStore(dataRoot, { manifests: true });
415
420
  const index = jsonFile(dirs(dataRoot).index, 'IRIS_ARTIFACT_INDEX_INVALID');
@@ -420,10 +425,32 @@ export function listCoreArtifacts(dataRoot, { offset = 0, limit = 50 } = {}) {
420
425
  || !HASH.test(String(index.inventoryDigest || '')) || index.inventoryDigest !== inventoryDigest) {
421
426
  fail('IRIS_ARTIFACT_INDEX_INVALID', 'Artifact Index 结构或自校验无效');
422
427
  }
423
- const start = Math.max(0, Math.trunc(Number(offset) || 0));
428
+ return index;
429
+ }
430
+
431
+ /** 工作台目录只读已提交元数据和文件大小;媒体打开/识别仍须核验完整内容哈希。 */
432
+ export function listCoreArtifactMetadata(dataRoot) {
433
+ const index = readArtifactIndex(dataRoot), artifacts = [];
434
+ let dropped = 0;
435
+ for (const entry of index.artifacts) {
436
+ try {
437
+ const { manifest } = readArtifact(dataRoot, entry.id, { allowLegacy: false, verifyContent: false });
438
+ if (entry.createdAt !== manifest.createdAt || entry.mediaType !== manifest.mediaType
439
+ || entry.size !== manifest.size || entry.digest !== manifest.digest.value) throw new Error('index mismatch');
440
+ artifacts.push(publicArtifact(manifest));
441
+ } catch (_) { dropped++; }
442
+ }
443
+ return { revision: index.revision, artifacts, dropped };
444
+ }
445
+
446
+ export function listCoreArtifacts(dataRoot, { offset = 0, limit = 50, kind, media_type, task_id } = {}) {
447
+ const index = readArtifactIndex(dataRoot);
448
+ const start = index.absent ? 0 : Math.max(0, Math.trunc(Number(offset) || 0));
424
449
  const size = Math.max(1, Math.min(200, Math.trunc(Number(limit) || 50)));
425
450
  const artifacts = [];
426
- for (const entry of index.artifacts.slice(start, start + size)) {
451
+ const filtering = kind !== undefined || media_type !== undefined || task_id !== undefined;
452
+ let matchedTotal = 0;
453
+ for (const entry of (filtering ? index.artifacts : index.artifacts.slice(start, start + size))) {
427
454
  if (!entry || !ID.test(String(entry.id || ''))) fail('IRIS_ARTIFACT_INDEX_INVALID', 'Artifact Index 条目无效');
428
455
  try {
429
456
  const artifact = inspectCoreArtifact(dataRoot, entry.id);
@@ -431,7 +458,12 @@ export function listCoreArtifacts(dataRoot, { offset = 0, limit = 50 } = {}) {
431
458
  || entry.size !== artifact.size || entry.digest !== artifact.digest.value) {
432
459
  fail('IRIS_ARTIFACT_INDEX_INVALID', 'Artifact Index 条目与 Manifest 不一致');
433
460
  }
434
- artifacts.push(artifact);
461
+ if (filtering) {
462
+ if ((kind !== undefined && artifact.kind !== kind) || (media_type !== undefined && artifact.mediaType !== media_type)
463
+ || (task_id !== undefined && artifact.metadata.taskId !== task_id)) continue;
464
+ if (matchedTotal >= start && matchedTotal < start + size) artifacts.push(artifact);
465
+ matchedTotal++;
466
+ } else artifacts.push(artifact);
435
467
  } catch (error) {
436
468
  if (error?.code === 'IRIS_ARTIFACT_INDEX_INVALID') throw error;
437
469
  fail('IRIS_ARTIFACT_INDEX_INVALID', 'Artifact Index 与存储事实不一致;请显式重建');
@@ -440,7 +472,7 @@ export function listCoreArtifacts(dataRoot, { offset = 0, limit = 50 } = {}) {
440
472
  return Object.freeze({
441
473
  schemaVersion: 0,
442
474
  revision: index.revision,
443
- total: index.artifacts.length,
475
+ total: filtering ? matchedTotal : index.artifacts.length,
444
476
  offset: start,
445
477
  limit: size,
446
478
  artifacts: Object.freeze(artifacts)