@mokuyoaxis/dsh-iris 0.2.2 → 0.2.3

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 (63) hide show
  1. package/.dsh/skills/iris-compose-media/SKILL.md +23 -73
  2. package/.dsh/skills/iris-compose-media/agents/openai.yaml +1 -1
  3. package/.dsh/skills/iris-compose-media/references/task-v2-recovery.md +23 -22
  4. package/.dsh/skills/iris-compose-media/references/workflows.md +25 -30
  5. package/.dsh/skills/iris-use/SKILL.md +41 -0
  6. package/.dsh/skills/iris-use/agents/openai.yaml +4 -0
  7. package/.dsh/skills/iris-use/references/media-contract.md +73 -0
  8. package/.dsh/skills/iris-verify-ui/SKILL.md +9 -7
  9. package/.dsh/skills/iris-verify-ui/agents/openai.yaml +1 -1
  10. package/.dsh/skills/iris-verify-ui/references/verdict-and-degradation.md +3 -0
  11. package/CHANGELOG.md +26 -2
  12. package/README.en.md +39 -32
  13. package/README.md +37 -31
  14. package/bin/dsh-iris.js +27 -6
  15. package/docs/AGENT_QUICKSTART.md +9 -9
  16. package/docs/ARCHITECTURE.md +1 -1
  17. package/docs/CORE_RUNTIME_CONTRACT.md +3 -1
  18. package/docs/DSH_CORE_MIGRATION.md +6 -2
  19. package/docs/FAULT_INJECTION.md +1 -0
  20. package/docs/HEADLESS_CLI.md +8 -4
  21. package/docs/HOST_DOCTOR.md +3 -3
  22. package/docs/MODEL_PORT_CONTRACT.md +6 -2
  23. package/docs/MUSIC.md +77 -0
  24. package/docs/PROMPT_OPTIMIZER.md +48 -10
  25. package/docs/PROVIDER_ADAPTER_CONTRACT.md +2 -0
  26. package/docs/ROADMAP.md +14 -25
  27. package/docs/SECURITY.md +3 -2
  28. package/docs/SYNC_DELIVERY_RECOVERY.md +39 -0
  29. package/docs/TASK_SEMANTICS.md +4 -4
  30. package/docs/releases/0.2.3.md +54 -0
  31. package/lib/actions.js +40 -3
  32. package/lib/adapters.js +1 -0
  33. package/lib/api.js +14 -5
  34. package/lib/bundled-skills.js +1 -1
  35. package/lib/capability.js +2 -0
  36. package/lib/client.js +111 -26
  37. package/lib/command-service.js +12 -3
  38. package/lib/config.js +26 -2
  39. package/lib/core-artifact-store.js +44 -6
  40. package/lib/core-delivery-cache.js +145 -0
  41. package/lib/core-maintenance.js +12 -1
  42. package/lib/core-tasks.js +19 -5
  43. package/lib/core-user-projection.js +2 -2
  44. package/lib/dsh-core-adapter.js +14 -4
  45. package/lib/dsh-text-model-adapter.js +15 -2
  46. package/lib/generation-input.js +10 -0
  47. package/lib/host-runtime.js +4 -2
  48. package/lib/index.js +23 -4
  49. package/lib/models.js +4 -1
  50. package/lib/music-generation.js +21 -0
  51. package/lib/music-options.js +18 -0
  52. package/lib/music-transport.js +46 -0
  53. package/lib/prompt-optimizer-config.js +92 -21
  54. package/lib/prompt-optimizer-core.js +51 -6
  55. package/lib/prompt-optimizer.js +20 -14
  56. package/lib/provider-adapter.js +1 -0
  57. package/lib/provider-adapters.js +24 -1
  58. package/lib/provider-catalog.js +18 -7
  59. package/lib/provider-config-service.js +13 -1
  60. package/lib/provider-protocol.js +12 -0
  61. package/lib/provider-task-runner.js +80 -5
  62. package/package.json +4 -2
  63. package/user_guide.md +30 -16
@@ -5,7 +5,7 @@ import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import { CAPABILITIES } from './capability.js';
7
7
  import { modelPool, orderedModels, providerModels, parseModelRef, resolveModelRef, resolvePoolModel, selectionReasonForModel } from './models.js';
8
- import { dashscopeApiBase, selectMediaProtocol, providerForImageModel, providerMediaBaseUrl, isConfiguredProvider } from './provider-protocol.js';
8
+ import { dashscopeApiBase, selectMediaProtocol, providerForImageModel, providerForMusicModel, providerMediaBaseUrl, isConfiguredProvider } from './provider-protocol.js';
9
9
  import { redactProviderMessage } from './provider-contract.js';
10
10
  import { modelRateLimit } from './provider-health.js';
11
11
  import { normalizeVisionInputLimits } from './vision-image-input.js';
@@ -54,7 +54,8 @@ export function loadProviderCatalog(configPath) {
54
54
 
55
55
  const CANDIDATE_CAP_LABEL = Object.freeze({
56
56
  [CAPABILITIES.IMAGE]: '图片', [CAPABILITIES.VIDEO]: '视频',
57
- [CAPABILITIES.TTS]: '语音', [CAPABILITIES.TRANSCRIBE]: '转写', [CAPABILITIES.VISION]: '视觉'
57
+ [CAPABILITIES.TTS]: '语音', [CAPABILITIES.TRANSCRIBE]: '转写', [CAPABILITIES.VISION]: '视觉',
58
+ [CAPABILITIES.MUSIC]: '音乐'
58
59
  });
59
60
 
60
61
  /** 与稳定配置层相同的“手工顺序优先、池序补齐”候选规则。 */
@@ -62,7 +63,9 @@ function candidatesFromCatalog(catalog, requestedModelRef, capability) {
62
63
  const label = CANDIDATE_CAP_LABEL[capability];
63
64
  if (!label) fail('IRIS_PROVIDER_TASK_OBSERVE_UNSUPPORTED', '当前 Headless 候选链只开放图片、视频、语音与转写能力');
64
65
  const providers = catalog.providers.filter(isConfiguredProvider);
65
- const pool = modelPool(providers).filter((item) => item.capabilities.includes(capability));
66
+ const configuredPool = modelPool(providers).filter((item) => item.capabilities.includes(capability));
67
+ const pool = capability === CAPABILITIES.MUSIC
68
+ ? orderedModels(configuredPool, capability, catalog.assignments?.[capability], { includeRateLimited: true }) : configuredPool;
66
69
  const requested = String(requestedModelRef || '').trim();
67
70
  if (requested) {
68
71
  const parsed = parseModelRef(requested);
@@ -80,7 +83,8 @@ function candidatesFromCatalog(catalog, requestedModelRef, capability) {
80
83
  }
81
84
  const assignment = catalog.assignments?.[capability];
82
85
  const ordered = orderedModels(pool, capability, assignment);
83
- if (!ordered.length) fail('IRIS_PROVIDER_MODEL_UNAVAILABLE', `provider-config 中没有可用${label}模型`);
86
+ if (!ordered.length) fail('IRIS_PROVIDER_MODEL_UNAVAILABLE', capability === CAPABILITIES.MUSIC
87
+ ? '请先给 Music 功能分配可用音乐模型' : `provider-config 中没有可用${label}模型`);
84
88
  return Object.freeze(ordered.map((item) => Object.freeze({
85
89
  provider: providers.find((provider) => provider.id === item.providerId),
86
90
  model: item.id,
@@ -102,6 +106,11 @@ export function ttsCandidatesFromCatalog(catalog, requestedModelRef = '') {
102
106
  return candidatesFromCatalog(catalog, requestedModelRef, CAPABILITIES.TTS);
103
107
  }
104
108
 
109
+ export function musicCandidatesFromCatalog(catalog, requestedModelRef = '') {
110
+ return Object.freeze(candidatesFromCatalog(catalog, requestedModelRef, CAPABILITIES.MUSIC).map(route =>
111
+ Object.freeze({ ...route, provider: providerForMusicModel(route.provider, route.model) })));
112
+ }
113
+
105
114
  export function transcribeCandidatesFromCatalog(catalog, requestedModelRef = '') {
106
115
  return candidatesFromCatalog(catalog, requestedModelRef, CAPABILITIES.TRANSCRIBE);
107
116
  }
@@ -152,7 +161,7 @@ export function providerTaskBinding(provider) {
152
161
  try {
153
162
  const url = new URL(baseUrl);
154
163
  if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw new Error('unsafe endpoint');
155
- if (!['dashscope', 'openai-images', 'openai-chat-images', 'openai-responses-images'].includes(protocol)) throw new Error('unknown protocol');
164
+ if (!['dashscope', 'openai-images', 'openai-chat-images', 'openai-responses-images', 'gemini-web-music'].includes(protocol)) throw new Error('unknown protocol');
156
165
  endpoint = protocol === 'dashscope' ? dashscopeApiBase(baseUrl) : url.href.replace(/\/+$/, '');
157
166
  } catch (_) { fail('IRIS_PROVIDER_CONFIG_INVALID', 'Provider 媒体端点或协议无效'); }
158
167
  return 'sha256:' + crypto.createHash('sha256').update(JSON.stringify([1, protocol, endpoint])).digest('hex');
@@ -164,17 +173,19 @@ export function providerForTaskFromCatalog(catalog, task) {
164
173
  task?.capability === 'video' ? 'VIDEO'
165
174
  : task?.capability === 'image' ? 'IMAGE'
166
175
  : task?.capability === 'tts' ? 'TTS'
176
+ : task?.capability === 'music' ? 'MUSIC'
167
177
  : task?.capability === 'transcribe' ? 'TRANSCRIBE' : ''
168
178
  ];
169
179
  if (!capability) fail('IRIS_PROVIDER_TASK_OBSERVE_UNSUPPORTED', '当前 Headless 恢复解析只开放图片、视频、语音与转写 Task');
170
- const label = { video: '视频', tts: '语音', transcribe: '转写' }[task.capability] || '图片';
180
+ const label = { video: '视频', tts: '语音', music: '音乐', transcribe: '转写' }[task.capability] || '图片';
171
181
  const parsed = parseModelRef(task.modelRef);
172
182
  if (!parsed || parsed.providerId !== task.providerId) fail('IRIS_PROVIDER_TASK_IDENTITY_MISMATCH', 'Task 的 Provider 与模型身份不一致');
173
183
  const matches = catalog.providers.filter((item) => item && item.id === task.providerId);
174
184
  if (matches.length > 1) fail('IRIS_PROVIDER_CONFIG_AMBIGUOUS', 'provider-config 存在重复 Provider ID');
175
185
  const configured = matches[0];
176
186
  if (!configured || configured.enabled === false || (configured.auth !== 'none' && (typeof configured.apiKey !== 'string' || !configured.apiKey.trim()))) fail('IRIS_PROVIDER_TASK_PROVIDER_UNAVAILABLE', 'Task 原 Provider 已移除、停用或缺少凭据');
177
- const provider = task.capability === 'image' ? providerForImageModel(configured, parsed.modelId) : configured;
187
+ const provider = task.capability === 'image' ? providerForImageModel(configured, parsed.modelId)
188
+ : task.capability === 'music' ? providerForMusicModel(configured, parsed.modelId) : configured;
178
189
  if (!task.providerBinding) fail('IRIS_PROVIDER_TASK_BINDING_MISSING', '旧 Task 缺少提交端点绑定;仅支持只读查看,不猜测恢复配置');
179
190
  if (providerTaskBinding(provider) !== task.providerBinding) fail('IRIS_PROVIDER_TASK_BINDING_MISMATCH', 'Task 提交端点或协议已改变;拒绝向新端点观察原任务');
180
191
  const model = modelPool([provider]).find((item) => item.providerId === parsed.providerId && item.id === parsed.modelId && item.capabilities.includes(capability));
@@ -6,7 +6,7 @@ import { isKnownCapability } from './capability.js';
6
6
  import { providerModels, modelPool, parseModelRef, capabilitiesOfDiscoveredModel, hasConfiguredModelCapabilities, resolvePoolModel } from './models.js';
7
7
  import { atomicWritePrivate, privateSibling, ensurePrivateDir } from './private-storage.js';
8
8
  import { createConfiguredProviderAdapter } from './provider-adapters.js';
9
- import { selectImageProtocol, normalizeImageProtocol } from './provider-protocol.js';
9
+ import { selectImageProtocol, normalizeImageProtocol, normalizeMusicProtocol } from './provider-protocol.js';
10
10
  import { invokeProviderOperation } from './provider-adapter.js';
11
11
  import { redactProviderMessage } from './provider-contract.js';
12
12
  import { modelRateLimit, recordModelRateLimit, clearModelRateLimit, clearModelCapabilityHealth, isDefinitiveHealthFailure } from './provider-health.js';
@@ -33,6 +33,7 @@ export function configSnapshot(catalog) {
33
33
  return { ...providerCatalogSnapshot(catalog), models: catalog.providers.flatMap(provider => providerModels(provider).map(model => ({
34
34
  ref: safe(model.ref), providerId: safe(provider.id), id: safe(model.id), capabilities: model.capabilities,
35
35
  ...(model.imageProtocol !== undefined ? { imageProtocol: model.imageProtocol } : {}),
36
+ ...(model.musicProtocol !== undefined ? { musicProtocol: model.musicProtocol } : {}),
36
37
  ...(model.visionInput !== undefined ? { visionInput: model.visionInput } : {}),
37
38
  ...(model.capabilities.includes('vision') ? { visionInputEffective: visionInputLimits(provider, model.id) } : {}),
38
39
  ...(model.capabilities.includes('image-gen') ? { imageRouting: selectImageProtocol(provider, model.id) } : {}),
@@ -62,6 +63,9 @@ export async function executeConfigCommand(configPath, command, input = {}, { si
62
63
  for (const provider of catalog.providers) {
63
64
  try { normalizeVisionInputLimits(provider.visionInput); } catch (_) { issues.push('invalid_vision_input'); }
64
65
  for (const model of provider.models || []) {
66
+ if (model?.musicProtocol !== undefined) {
67
+ try { normalizeMusicProtocol(model.musicProtocol); } catch (_) { issues.push('invalid_model_music_protocol'); }
68
+ }
65
69
  try { normalizeVisionInputLimits(model?.visionInput); } catch (_) { issues.push('invalid_model_vision_input'); }
66
70
  if (model?.imageProtocol === undefined) continue;
67
71
  try { normalizeImageProtocol(model.imageProtocol); } catch (_) { issues.push('invalid_model_image_protocol'); }
@@ -103,6 +107,7 @@ export async function executeConfigCommand(configPath, command, input = {}, { si
103
107
  }
104
108
  updated.models = updated.models.map(model => typeof model === 'string' ? model : ({ ...model,
105
109
  ...(model?.imageProtocol !== undefined ? { imageProtocol: normalizeImageProtocol(model.imageProtocol) } : {}),
110
+ ...(model?.musicProtocol !== undefined ? { musicProtocol: normalizeMusicProtocol(model.musicProtocol) } : {}),
106
111
  ...(model?.visionInput !== undefined ? { visionInput: normalizeVisionInputLimits(model.visionInput) || null } : {}) }));
107
112
  }
108
113
  if (provider) {
@@ -140,6 +145,7 @@ export async function executeConfigCommand(configPath, command, input = {}, { si
140
145
  if (entry) fail('模型已存在;请用 models caps 修改能力');
141
146
  entries.push({ id: modelId, capabilities, source: 'manual',
142
147
  ...(input.imageProtocol !== undefined ? { imageProtocol: normalizeImageProtocol(input.imageProtocol) } : {}),
148
+ ...(input.musicProtocol !== undefined ? { musicProtocol: normalizeMusicProtocol(input.musicProtocol) } : {}),
143
149
  ...(input.visionInput !== undefined ? { visionInput: normalizeVisionInputLimits(input.visionInput) || null } : {}) });
144
150
  } else if (command === 'models.remove') {
145
151
  if (!entry) fail('模型不存在');
@@ -156,6 +162,12 @@ export async function executeConfigCommand(configPath, command, input = {}, { si
156
162
  provider.models = entries;
157
163
  if (previous !== selectImageProtocol(provider, modelId).mediaProtocol) clearModelCapabilityHealth(provider, modelId, 'image-gen');
158
164
  result = { ...result, model_ref: safe(input.model_ref), imageRouting: selectImageProtocol(provider, modelId) };
165
+ } else if (command === 'models.music-protocol') {
166
+ if (!entry) fail('模型不存在');
167
+ const protocol = normalizeMusicProtocol(input.musicProtocol);
168
+ if (entry.musicProtocol !== protocol) clearModelCapabilityHealth(provider, modelId, 'music-gen');
169
+ entry.musicProtocol = protocol;
170
+ result = { ...result, model_ref: safe(input.model_ref), musicProtocol: protocol };
159
171
  } else if (command === 'models.vision-input') {
160
172
  if (!entry || !Object.hasOwn(input, 'visionInput')) fail('需要已存在的模型和 visionInput 对象或 null');
161
173
  const limits = normalizeVisionInputLimits(input.visionInput);
@@ -68,6 +68,18 @@ export function providerForImageModel(provider, modelId) {
68
68
  return { ...provider, ...selectImageProtocol(provider, modelId) };
69
69
  }
70
70
 
71
+ export function normalizeMusicProtocol(value) {
72
+ if (value !== 'gemini-web-music') throw Object.assign(new TypeError('musicProtocol 必须是 gemini-web-music'), {
73
+ code: 'IRIS_PROVIDER_MUSIC_PROTOCOL_INVALID'
74
+ });
75
+ return value;
76
+ }
77
+
78
+ export function providerForMusicModel(provider, modelId) {
79
+ const entry = provider.models?.find(model => model?.id?.trim() === modelId);
80
+ return { ...provider, mediaProtocol: normalizeMusicProtocol(entry?.musicProtocol), protocolInferred: false };
81
+ }
82
+
71
83
  export function unsupportedProtocolError(protocol, kind = 'media') {
72
84
  const safeName = /^[a-z][a-z0-9-]{0,63}$/.test(String(protocol)) ? protocol : '(invalid)';
73
85
  const error = new TypeError('iris: 不支持的 ' + (kind === 'vision' ? 'Vision' : 'Provider') + ' 协议:' + safeName);
@@ -31,6 +31,8 @@ import {
31
31
  } from './provider-adapter.js';
32
32
  import { ProviderContractError, providerErrorRecord, submitWithAcceptanceBoundary } from './provider-contract.js';
33
33
  import { imageEditCandidates, readImageEditArtifact } from './image-edit-input.js';
34
+ import { createDeliveryCache, newDeliveryPayloadPath, readDeliveryCache,
35
+ readDeliveryPayload, recordDeliveryPayload } from './core-delivery-cache.js';
34
36
 
35
37
  export const PROVIDER_TASK_RUNNER_VERSION = 0;
36
38
 
@@ -44,6 +46,7 @@ export const DELIVERY_PROFILES = Object.freeze({
44
46
  video: Object.freeze({ kind: 'generated-video', mediaTypes: Object.freeze(['video/mp4']), defaultMediaType: 'video/mp4' }),
45
47
  // TTS 同步完成型:submit 内 completed → 同一次调用直接交付(无 remote 长轮询)。
46
48
  tts: Object.freeze({ kind: 'generated-audio', mediaTypes: Object.freeze(['audio/mpeg', 'audio/wav']), defaultMediaType: 'audio/mpeg' }),
49
+ music: Object.freeze({ kind: 'generated-audio', mediaTypes: Object.freeze(['audio/mpeg', 'audio/wav']), defaultMediaType: 'audio/mpeg' }),
47
50
  // 转写上传型异步:受理 + remoteTaskId → 长轮询 → 文本物化为 UTF-8 Artifact。
48
51
  transcribe: Object.freeze({ kind: 'transcript', mediaTypes: Object.freeze(['text/plain']), defaultMediaType: 'text/plain' })
49
52
  });
@@ -195,6 +198,62 @@ async function observeWithinRoot(dataRoot, taskId, adapter, signal) {
195
198
  return deliverArtifacts(dataRoot, taskId, adapter, result.artifacts, signal);
196
199
  }
197
200
 
201
+ /** 同步结果按已提交缓存补交付;已下载项不再访问 Provider,稳定 Artifact ID 防重复。 */
202
+ async function deliverCachedArtifacts(dataRoot, taskId, adapter, signal) {
203
+ beginCoreDelivery(dataRoot, taskId);
204
+ try {
205
+ const task = inspectCoreTask(dataRoot, taskId);
206
+ const profile = DELIVERY_PROFILES[task.capability];
207
+ if (!profile) throw new Error('Task 没有可用的交付 Profile');
208
+ const cache = readDeliveryCache(dataRoot, task);
209
+ for (let index = 0; index < cache.items.length; index++) {
210
+ if (signal.aborted) throw new Error('本地交付已取消');
211
+ const item = cache.items[index];
212
+ if (!item.payload) {
213
+ const declaredMediaType = item.source.mediaType || profile.defaultMediaType;
214
+ if (task.capability !== 'image' && !profile.mediaTypes.includes(declaredMediaType)) {
215
+ throw new Error('交付媒体类型不在 Profile 白名单');
216
+ }
217
+ checkAdapterForTask(task, adapter);
218
+ const target = newDeliveryPayloadPath(dataRoot, cache);
219
+ const downloaded = await invokeProviderOperation(adapter, 'download', {
220
+ capability: task.capability, artifact: item.source, targetPath: target, signal
221
+ }, { signal, taskId });
222
+ const stat = fs.lstatSync(target);
223
+ if (!stat.isFile() || stat.isSymbolicLink() || !stat.size || stat.size !== downloaded.bytes) {
224
+ throw new Error('Provider 物化结果与文件证据不一致');
225
+ }
226
+ const mediaType = task.capability === 'image'
227
+ ? await generatedImageMediaType(fs.readFileSync(target))
228
+ : declaredMediaType;
229
+ if (!profile.mediaTypes.includes(mediaType)) throw new Error('交付媒体类型不在 Profile 白名单');
230
+ recordDeliveryPayload(dataRoot, cache, index, target, mediaType);
231
+ }
232
+ }
233
+ recordCoreDownloadCompleted(dataRoot, taskId);
234
+ const artifactIds = [];
235
+ for (let index = 0; index < cache.items.length; index++) {
236
+ if (signal.aborted) throw new Error('本地交付已取消');
237
+ const item = cache.items[index];
238
+ const sourcePath = readDeliveryPayload(dataRoot, cache, index);
239
+ const mediaType = task.capability === 'image'
240
+ ? await generatedImageMediaType(fs.readFileSync(sourcePath))
241
+ : (item.payload.mediaType || profile.defaultMediaType);
242
+ if (!profile.mediaTypes.includes(mediaType)) throw new Error('交付媒体类型不在 Profile 白名单');
243
+ const artifact = adoptCoreArtifactFile(dataRoot, {
244
+ artifactId: item.artifactId, sourcePath, createdAt: cache.createdAt,
245
+ kind: profile.kind, mediaType, metadata: { taskId, capability: task.capability },
246
+ ...(task.sourceArtifactId ? { relations: [{ type: 'derived-from', artifactId: task.sourceArtifactId }] } : {})
247
+ });
248
+ artifactIds.push(artifact.id);
249
+ }
250
+ return completeCoreDelivery(dataRoot, taskId, artifactIds);
251
+ } catch (error) {
252
+ return failCoreDelivery(dataRoot, taskId, error);
253
+ }
254
+ // 完整结果与未提交文件留在私有缓存,失败、取消和重启都不自动清掉恢复证据。
255
+ }
256
+
198
257
  /**
199
258
  * FakeProvider 与未来真实 Host 共用的生命周期调度候选。它不拥有 timer,也不会
200
259
  * 自动循环轮询;每次 observe 只消费一个 poll 结果,使重启和故障边界可重复验证。
@@ -236,6 +295,7 @@ export function createProviderTaskRunner(runtime) {
236
295
  providerInput = { ...providerInput, image };
237
296
  }
238
297
  const task = createCoreTask(dataRoot, { capability, retriedFrom: input.retriedFrom, sourceArtifactId: input.sourceArtifactId });
298
+ const completedResults = new Map();
239
299
  const wrapped = candidates.map(({ adapter, model, providerModel, prepareInput }) => ({
240
300
  id: adapter.id,
241
301
  model,
@@ -248,12 +308,14 @@ export function createProviderTaskRunner(runtime) {
248
308
  }); }
249
309
  }
250
310
  if (signal.aborted) throw new ProviderContractError('提交已取消', { stage: 'prepare', category: 'aborted', acceptance: 'not_accepted' });
251
- return invokeProviderOperation(
311
+ const result = await invokeProviderOperation(
252
312
  adapter,
253
313
  'submit',
254
314
  { capability, model: providerModel, input: providerInput, signal },
255
315
  { ...context, signal, taskId: task.id }
256
316
  );
317
+ if (result.kind === 'completed') completedResults.set(context.attempt.id, result.artifacts);
318
+ return result;
257
319
  }
258
320
  }));
259
321
  const submitted = await submitWithAcceptanceBoundary(wrapped, providerInput, {
@@ -266,7 +328,20 @@ export function createProviderTaskRunner(runtime) {
266
328
  ...(candidate.selectionReason !== undefined ? { selectionReason: candidate.selectionReason } : {})
267
329
  });
268
330
  },
269
- afterResult: async (attempt) => recordCoreAttemptResult(dataRoot, task.id, attempt)
331
+ afterResult: async (attempt) => {
332
+ let cacheError;
333
+ if (attempt.resultKind === 'completed') {
334
+ try {
335
+ createDeliveryCache(dataRoot, inspectCoreTask(dataRoot, task.id), completedResults.get(attempt.id));
336
+ attempt.deliveryCache = true;
337
+ } catch (error) { cacheError = error; }
338
+ }
339
+ recordCoreAttemptResult(dataRoot, task.id, attempt);
340
+ if (cacheError) {
341
+ failCoreDelivery(dataRoot, task.id, cacheError);
342
+ throw cacheError;
343
+ }
344
+ }
270
345
  });
271
346
  if (submitted.localError) {
272
347
  return Object.freeze({ taskId: task.id, task: inspectCoreTask(dataRoot, task.id), localError: submitted.localError });
@@ -275,10 +350,9 @@ export function createProviderTaskRunner(runtime) {
275
350
  if (submitted.result.kind === 'not_accepted') {
276
351
  finalizeCoreNoAcceptance(dataRoot, task.id, submitted.result.error);
277
352
  } else if (submitted.result.kind === 'completed') {
278
- const artifacts = submitted.result.artifacts;
279
353
  return Object.freeze({
280
354
  taskId: task.id,
281
- task: await deliverArtifacts(dataRoot, task.id, submitted.candidate.adapter, artifacts, signal),
355
+ task: await deliverCachedArtifacts(dataRoot, task.id, submitted.candidate.adapter, signal),
282
356
  attempts: submitted.attempts
283
357
  });
284
358
  }
@@ -308,8 +382,9 @@ export function createProviderTaskRunner(runtime) {
308
382
  function redeliver(taskId, adapter) {
309
383
  return runExclusive(taskId, 'execute', async ({ dataRoot, signal }) => {
310
384
  const task = inspectCoreTask(dataRoot, taskId);
311
- checkAdapterForTask(task, adapter);
385
+ if (adapter || !task.deliveryCache) checkAdapterForTask(task, adapter);
312
386
  prepareCoreRedelivery(dataRoot, taskId);
387
+ if (task.deliveryCache) return deliverCachedArtifacts(dataRoot, taskId, adapter, signal);
313
388
  let result;
314
389
  try {
315
390
  result = await invokeProviderOperation(adapter, 'poll', {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mokuyoaxis/dsh-iris",
3
- "version": "0.2.2",
4
- "description": "Multi-provider image, video, and speech generation, transcription, visual understanding, and prompt optimization with an integrated Iris workbench for DeepSeek Harness.",
3
+ "version": "0.2.3",
4
+ "description": "Multi-provider image, video, music, and speech generation, transcription, visual understanding, and prompt optimization with an integrated Iris workbench for DeepSeek Harness.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -28,6 +28,7 @@
28
28
  "!docs/releases/0.1.1.md",
29
29
  "!docs/releases/0.1.2.md",
30
30
  "!docs/releases/0.1.3.md",
31
+ ".dsh/skills/iris-use",
31
32
  ".dsh/skills/iris-verify-ui",
32
33
  ".dsh/skills/iris-compose-media"
33
34
  ],
@@ -54,6 +55,7 @@
54
55
  "dsh-plugin",
55
56
  "image-generation",
56
57
  "video-generation",
58
+ "music-generation",
57
59
  "text-to-speech",
58
60
  "transcription",
59
61
  "vision",
package/user_guide.md CHANGED
@@ -1,12 +1,12 @@
1
1
  # dsh-iris 用户指南
2
2
 
3
- 本指南说明如何安装、配置和使用 Iris 0.2.2。升级范围见 [CHANGELOG](CHANGELOG.md) 和 [发布说明](docs/releases/0.2.2.md)。
3
+ 本指南说明如何安装、配置和使用 Iris 0.2.3。升级范围见 [CHANGELOG](CHANGELOG.md) 和 [发布说明](docs/releases/0.2.3.md)。
4
4
 
5
5
  0.2.2 的能力来自明确功能标签、旧能力字段绑定或供应商能力元数据,账号和名称不再自动赋能。已有明确配置保留;仅有裸账号的配置需要先给实际模型设置功能,再加入有序候选池。
6
6
 
7
7
  ## 安装
8
8
 
9
- 先运行 `dsh --version`。Iris `0.2.2` 仅声明支持 DSH `0.2.0-rc.2`;旧 DSH `>=0.1.2-rc.1 <0.1.3-0` 或 `0.1.5-rc.1` 使用固定版本 Iris `0.1.4`。验证边界见[适配说明](docs/DSH_RC2_ADAPTATION.md)。还需要 PATH 中的 `pnpm`,以及至少一个媒体或视觉服务供应商。Iris 0.2.2 要求 Node.js 22.0.0 或更高版本,旧 npm 0.1.4 的历史要求为 20.10.0;如果所用 DSH 版本要求更高,以 DSH 为准。Iris 0.1.1 及后续 0.1.x 不兼容 DSH 0.1.0/0.1.1 的旧客户端 Runtime。
9
+ 先运行 `dsh --version`。Iris `0.2.3` 仅声明支持 DSH `0.2.0-rc.2`;旧 DSH `>=0.1.2-rc.1 <0.1.3-0` 或 `0.1.5-rc.1` 使用固定版本 Iris `0.1.4`。验证边界见[适配说明](docs/DSH_RC2_ADAPTATION.md)。还需要 PATH 中的 `pnpm`,以及至少一个媒体或视觉服务供应商。Iris 0.2.x 要求 Node.js 22.0.0 或更高版本,旧 npm 0.1.4 的历史要求为 20.10.0;如果所用 DSH 版本要求更高,以 DSH 为准。Iris 0.1.1 及后续 0.1.x 不兼容 DSH 0.1.0/0.1.1 的旧客户端 Runtime。
10
10
 
11
11
  在上述旧 DSH 范围内,从 npm 安装固定稳定版到 Web profile:
12
12
 
@@ -17,12 +17,12 @@ dsh web
17
17
 
18
18
  npm 上无 scope 的 `dsh-iris` 是另一款插件;安装或更新 Iris Media 时必须使用完整 scoped 包名。
19
19
 
20
- 只下载旧稳定包可运行 `npm pack @mokuyoaxis/dsh-iris@0.1.4`,安装为普通 npm 依赖可运行 `npm install @mokuyoaxis/dsh-iris@0.1.4`;后者不会自动加入 DSH profile。固定版本仍可在 latest 更新后安装,0.1.4 不包含新的 Core 媒体 CLI。DSH rc.2 使用 0.2.2。
20
+ 只下载旧稳定包可运行 `npm pack @mokuyoaxis/dsh-iris@0.1.4`,安装为普通 npm 依赖可运行 `npm install @mokuyoaxis/dsh-iris@0.1.4`;后者不会自动加入 DSH profile。固定版本仍可在 latest 更新后安装,0.1.4 不包含新的 Core 媒体 CLI。DSH rc.2 使用 0.2.3。
21
21
 
22
22
  在 DSH `0.2.0-rc.2` 上,从 npm 安装:
23
23
 
24
24
  ```bash
25
- dsh plugin --profile web add @mokuyoaxis/dsh-iris@0.2.2
25
+ dsh plugin --profile web add @mokuyoaxis/dsh-iris@0.2.3
26
26
  dsh web
27
27
  ```
28
28
 
@@ -30,7 +30,7 @@ dsh web
30
30
 
31
31
  ### 独立 CLI
32
32
 
33
- 无需 DSH,在自己的项目安装 `npm install @mokuyoaxis/dsh-iris@0.2.2`,通过 `npx dsh-iris --help` 查看入口。详见 [Headless CLI](docs/HEADLESS_CLI.md) 和 [视觉 CLI](docs/VISION_CLI.md)。
33
+ 无需 DSH,在自己的项目安装 `npm install @mokuyoaxis/dsh-iris@0.2.3`,通过 `npx dsh-iris --help` 查看入口。详见 [Headless CLI](docs/HEADLESS_CLI.md) 和 [视觉 CLI](docs/VISION_CLI.md)。
34
34
 
35
35
  其他 DSH 版本,包括 `0.2.0` 正式版与其他 rc,须另行验收。让 Agent 协助安装时,可使用[简要 Prompt](docs/AGENT_QUICKSTART.md)。
36
36
 
@@ -129,22 +129,30 @@ Iris 先按画图、视频、语音、转写和视觉功能组织候选池,再
129
129
 
130
130
  每项能力都可以设置一个有序的“供应商 + 模型”列表。界面显示的是模型名和供应商,配置中保存为 `providerId::modelId` 复合引用,因此不同供应商的同名模型不会冲突。
131
131
 
132
- - 未手工分配时,每个新请求都从模型池第一个匹配项开始;这不是 round-robin。
132
+ - 图片、视频、语音、转写与视觉未手工分配时,从模型池第一个匹配项开始;音乐仅使用明确分配名单。
133
133
  - 手工列表中的模型优先,并按列表顺序尝试。
134
- - 模型池中其余具备该能力的模型仍会作为后续候选。
135
- - 点击“恢复自动”会清除手工顺序。
134
+ - 除音乐外,模型池中其余具备该能力的模型仍会作为后续候选。
135
+ - 点击“恢复自动”会清除手工顺序;音乐清空名单后需重新分配才能生成。
136
136
 
137
137
  生成类故障转移只覆盖上传、提交和同步生成阶段。远端一旦受理异步任务,Iris 就会继续跟踪这个任务,不会因轮询或下载失败自动重提,以免重复生成和计费。
138
138
 
139
+ ## 音乐生成
140
+
141
+ 为音乐服务添加实际模型,设置“音乐”标签,选择 **Chat Completions(内嵌音频)** 协议,再将模型加入音乐能力分配名单。音乐必须明确分配,不从其他模型池自动补齐;这个接口要求服务在聊天响应正文中返回MP3/WAV音频data URL。
142
+
143
+ 配置后可在DSH中说“用Iris生成一首复古摇滚配乐”,或使用工作台音乐卡片。生成结果可试听、下载与导出;音乐和TTS分别配置,配乐按需加入多步骤流程。
144
+
145
+ 音乐入口默认在明确HTTP502后额外重试两次,可能消耗额外额度;可用 `retry_502: 0` 关闭。时长、歌词和人声通过描述表达,精确控制取决于模型。完整配置与CLI示例见 [音乐生成](docs/MUSIC.md)。
146
+
139
147
  ## 从旧工作台显式导入
140
148
 
141
- `ai-paint` 是 Iris 维护者本机未公开的前身项目,不是公开依赖。普通用户不需要、也不应该为了使用 Iris 去下载、安装或自行创建 ai-paint 目录;直接在 Iris 工作台添加供应商即可。
149
+ 首次使用请直接在 Iris 工作台添加供应商。已有旧 ai-paint 配置时,可使用下面的一次性导入入口;无需安装旧项目。
142
150
 
143
- 旧版读取路径同时承担了本机迁移和密钥保护作用:由运行 DSH 的宿主进程直接读取维护者已有的本地配置,可以避免为了迁移把 API Key 重新粘贴到 Agent 会话、命令行参数或文档中。它不是一个配置服务,也不意味着 Iris 运行时应该长期依赖另一个项目。
151
+ 导入由宿主进程读取本地配置,API Key 不需要经过 Agent 会话或命令行参数。
144
152
 
145
153
  从 0.1.1 起,Iris 独立保存供应商、密钥、模型池和能力分配,位置为 `$DSH_HOME/iris/v1/providers.json`(未设置 `DSH_HOME` 时使用用户目录下的 `.dsh`)。修改 Iris 配置不会影响 ai-paint,反之亦然。
146
154
 
147
- 默认启动不会扫描 ai-paint 或其他项目。推荐通过工作台手动添加供应商;需要迁移旧 ai-paint 配置时,先停止目标 DSH 实例并备份其 Iris 配置,再显式指定来源文件的绝对路径。Bash 示例:
155
+ 默认启动不会扫描其他项目。推荐通过工作台手动添加供应商;需要迁移旧 ai-paint 配置时,先停止目标 DSH 实例并备份其 Iris 配置,再显式指定来源文件的绝对路径。Bash 示例:
148
156
 
149
157
  ```bash
150
158
  IRIS_IMPORT_WORKBENCH_CONFIG="/absolute/path/ai-paint/data/config.json" dsh web
@@ -243,11 +251,15 @@ Iris 工作台中的文件字段提供三种方式:
243
251
 
244
252
  直接写工具名最明确;自然语言也可以,由 Agent 判断需要调用哪个 Iris 工具。视频、图片和语音生成可能产生供应商费用。
245
253
 
246
- 从 0.1.2 起,插件会在 DSH Skill registry 可用时自动注册 `iris-verify-ui` 和 `iris-compose-media`,不要求会话工作区位于本仓库,也不需要用户复制 `.dsh/skills/`。项目目录中存在同名 Skill 时,DSH 仍优先使用项目版本;宿主未提供 Skill registry 时不影响 Iris 工具(0.2.1共 15 个)。
254
+ 插件自动注册三项随包 Skill:单步操作用 `iris-use`,多步骤创作用 `iris-compose-media`,界面验收用 `iris-verify-ui`。无需复制 Skill 或切换到仓库目录;项目中的同名 Skill 优先,宿主未提供 registry 时工具仍可使用。
255
+
256
+ 三项共用[能力与产物说明](.dsh/skills/iris-use/references/media-contract.md)。Core音频返回Artifact ID与播放链接;需要audio_path时先导出成宿主文件。音乐独立于TTS,组合流程仅在用户要求时加入;分别交付音视频素材不代表已经混音成片。
247
257
 
248
258
  用户想稳定指定方法时,可直接用 DSH 的 `/name` 形式:
249
259
 
250
260
  ```text
261
+ /iris-use 用已经分配的音乐模型生成一首摇滚配乐
262
+
251
263
  /iris-compose-media 分析我上传的产品照,生成同构图海报,再检查是否符合要求
252
264
 
253
265
  /iris-verify-ui 对比当前截图和参考图,定位偏差并给出有证据的结论
@@ -265,10 +277,12 @@ Iris 工作台中的文件字段提供三种方式:
265
277
 
266
278
  1. 在对话框写入草稿,点击“🫧”。
267
279
  2. 在一般功能中选择“通用、画图、改图、视频、数字人”(旧 0.2.1 的四目标调用保持兼容)。
268
- 3. 0.2.2 默认“忠实整理”,保持原文与句序,只调整分组/分段。需要润色或明确改变指定内容时选择“改写表达”,再展开本次规则;只组装仍原样拼接草稿与前后缀,不调用模型。旧后端保留“智能优化”。
280
+ 3. 默认“忠实整理”,保持原文与句序,只调整分组/分段;单片段直接保留原文,不调用模型。需要润色或明确改变指定内容时选择“改写表达”,再展开本次规则;只组装仍原样拼接草稿与前后缀,不调用模型。旧后端保留“智能优化”。
269
281
  4. 编辑预览、检查原文与改动,然后选择“写回输入框、复制、重新生成预览”或“恢复原文”。需要额外创意时展开“可选补充与待确认”,点击获取才调用一次模型;勾选后还需点击采纳才加入预览,可以撤回上次采纳。手动编辑会清除旧建议绑定;建议与待确认问题不代表已验证事实或用户已有要求。折叠不清空选择和结果,Iris 不会自动发送消息。
270
282
 
271
- 默认路由是当前会话模型;当前会话没有选择时使用 DSH 默认模型。0.2.2 的“指令与模型设置”可查看当前默认或自定义指令,导出/导入 JSON,或恢复内置默认值。配置单独保存在 `$DSH_HOME/iris/v1/prompt-optimizer.json`,不包含供应商 API Key,也不与 `providers.json` 混合。
283
+ 默认路由是当前会话模型;当前会话没有选择时使用 DSH 默认模型,固定配置优先于这两项。预览显示实际选择原因。“指令与模型设置”可查看指令、导出/导入 JSON,选择“仅恢复默认指令”或“恢复全部设置”;前者保留模型、生成参数和入口开关。配置单独保存在 `$DSH_HOME/iris/v1/prompt-optimizer.json`,不包含供应商 API Key,也不与 `providers.json` 混合。
284
+
285
+ 再次优化失败或取消会保留已有预览,等待期间继续编辑时,新结果不会覆盖手工改动。配置异常会说明默认值和原文件保留状态;导入前可先导出备份。
272
286
 
273
287
  导出文件格式:
274
288
 
@@ -367,11 +381,11 @@ npx @mokuyoaxis/dsh-iris doctor --json
367
381
 
368
382
  已安装包也可以直接运行 `dsh-iris doctor`。退出码为 `0`(正常)、`1`(警告)和 `2`(硬错误)。Doctor 会执行一个随后立即删除的本地写入探针,并检查配置、任务、能力分配、临时文件和产物引用;输出不含完整 API Key。离线模式无法检查 DSH 工具/客户端是否已经装载,也不会用付费请求验证 Provider。
369
383
 
370
- **0.2.0** 另提供 `doctor --data-root <绝对路径> --json`:只读盘点指定 Core 数据根的 Task、Artifact、Index 和租约,不执行上述写入探针,也不自动修复。`providers list` 与 `capabilities list` 可用 `--provider-config <私有配置绝对路径>` 只读查看脱敏供应商摘要和按配置排序的候选链;它们不代表远端能力已实测。异常退出留下租约时,先用显式 Doctor 查看 owner,再按 [Headless CLI 的恢复步骤](docs/HEADLESS_CLI.md#异常退出后的写者租约)人工确认并运行 `runtime recover`,不要手动删除租约。安装 npm 0.2.1 后使用 `dsh-iris` 命令;临时运行可用 `npx --package @mokuyoaxis/dsh-iris@0.2.2 dsh-iris ...`。旧版 0.1.4 不包含这些 Core 命令。
384
+ **0.2.0** 另提供 `doctor --data-root <绝对路径> --json`:只读盘点指定 Core 数据根的 Task、Artifact、Index 和租约,不执行上述写入探针,也不自动修复。`providers list` 与 `capabilities list` 可用 `--provider-config <私有配置绝对路径>` 只读查看脱敏供应商摘要和按配置排序的候选链;它们不代表远端能力已实测。异常退出留下租约时,先用显式 Doctor 查看 owner,再按 [Headless CLI 的恢复步骤](docs/HEADLESS_CLI.md#异常退出后的写者租约)人工确认并运行 `runtime recover`,不要手动删除租约。安装 Iris 后使用 `dsh-iris` 命令;临时运行可用 `npx --package @mokuyoaxis/dsh-iris@0.2.3 dsh-iris ...`。旧版 0.1.4 不包含这些 Core 命令。
371
385
 
372
386
  ## Host Doctor
373
387
 
374
- DSH 运行后,打开“设置 → Iris 工作台 → 宿主诊断”。卡片会自动检查当前 DSH 版本、Iris 服务端和客户端版本、工具注册(0.2.1为 15 个)、两项随包 Skill、四组 Web 路由、四个 UI Slot,以及 Browser、附件、会话和宿主模型端口;点击“刷新”可重新读取,展开项可查看全部结果。需要原始 JSON 时,可在已经完成 DSH Web 认证的同一浏览器访问 `/iris/api/doctor`。
388
+ DSH运行后打开“设置 → Iris工作台 → 宿主诊断”。卡片检查宿主/服务端/客户端版本、当前工具和随包Skill、四组路由与四个UI Slot。0.2.3 包含 16 个工具(含音乐)和三项 Skill;升级后需重启服务端、刷新页面,并为要使用的功能分配模型。点击“刷新”查看结果,原始JSON可在已认证的同一浏览器访问 `/iris/api/doctor`。
375
389
 
376
390
  Host Doctor 不打开 Browser、不读取附件或会话、不调用模型,也不请求供应商。报告中的 `exitCode` 是诊断严重度,不是 HTTP 状态:`0` 全部健康,`1` 有可降级警告,`2` 有硬错误。未安装 HTML Browser 时出现一条 Browser 警告是预期降级;如果提示尚未收到客户端握手,刷新 DSH 页面后再点“刷新”。若显示 `not found`,通常是前端已更新而 DSH 后端仍运行旧插件代码,应重启 DSH 后再刷新页面。
377
391