@mokuyoaxis/dsh-iris 0.2.2 → 0.2.4
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.
- package/.dsh/skills/iris-compose-media/SKILL.md +23 -73
- package/.dsh/skills/iris-compose-media/agents/openai.yaml +1 -1
- package/.dsh/skills/iris-compose-media/references/task-v2-recovery.md +23 -22
- package/.dsh/skills/iris-compose-media/references/workflows.md +25 -30
- package/.dsh/skills/iris-use/SKILL.md +42 -0
- package/.dsh/skills/iris-use/agents/openai.yaml +4 -0
- package/.dsh/skills/iris-use/references/media-contract.md +83 -0
- package/.dsh/skills/iris-verify-ui/SKILL.md +9 -7
- package/.dsh/skills/iris-verify-ui/agents/openai.yaml +1 -1
- package/.dsh/skills/iris-verify-ui/references/verdict-and-degradation.md +3 -0
- package/CHANGELOG.md +41 -2
- package/README.en.md +50 -38
- package/README.md +48 -37
- package/bin/dsh-iris.js +35 -6
- package/docs/AGENT_QUICKSTART.md +9 -9
- package/docs/ARCHITECTURE.md +4 -2
- package/docs/CLI_MANAGEMENT.md +11 -3
- package/docs/CORE_RUNTIME_CONTRACT.md +3 -1
- package/docs/DSH_COMPATIBILITY.md +43 -0
- package/docs/DSH_CORE_MIGRATION.md +6 -2
- package/docs/DSH_RC2_ADAPTATION.md +4 -2
- package/docs/FAULT_INJECTION.md +1 -0
- package/docs/FUNCTION_MODEL_ASSIGNMENTS.md +63 -0
- package/docs/HEADLESS_CLI.md +14 -8
- package/docs/HOST_ADAPTER_CONTRACT.md +10 -7
- package/docs/HOST_DOCTOR.md +6 -4
- package/docs/IMAGE_IMPORT.md +49 -0
- package/docs/MODEL_PORT_CONTRACT.md +6 -2
- package/docs/MUSIC.md +77 -0
- package/docs/PROMPT_OPTIMIZER.md +48 -10
- package/docs/PROVIDER_ADAPTER_CONTRACT.md +2 -0
- package/docs/ROADMAP.md +16 -25
- package/docs/SECURITY.md +3 -2
- package/docs/SYNC_DELIVERY_RECOVERY.md +39 -0
- package/docs/TASK_SEMANTICS.md +4 -4
- package/docs/VISION_CLI.md +3 -3
- package/docs/VISION_MODEL.md +1 -1
- package/docs/WORKBENCH_ARTIFACTS.md +4 -2
- package/docs/releases/0.2.3.md +54 -0
- package/docs/releases/0.2.4.md +27 -0
- package/lib/actions.js +71 -14
- package/lib/adapters.js +6 -2
- package/lib/api.js +14 -5
- package/lib/bundled-skills.js +1 -1
- package/lib/capability.js +2 -0
- package/lib/client.js +131 -47
- package/lib/command-service.js +28 -4
- package/lib/config.js +41 -23
- package/lib/core-artifact-store.js +44 -6
- package/lib/core-delivery-cache.js +145 -0
- package/lib/core-maintenance.js +12 -1
- package/lib/core-tasks.js +19 -5
- package/lib/core-user-projection.js +2 -2
- package/lib/doctor.js +4 -10
- package/lib/dsh-compatibility.js +10 -0
- package/lib/dsh-core-adapter.js +20 -4
- package/lib/dsh-text-model-adapter.js +15 -2
- package/lib/generation-input.js +10 -0
- package/lib/host-runtime.js +5 -2
- package/lib/image-edit-input.js +7 -5
- package/lib/image-import.js +61 -0
- package/lib/index.js +60 -17
- package/lib/models.js +12 -3
- package/lib/music-generation.js +21 -0
- package/lib/music-options.js +18 -0
- package/lib/music-transport.js +46 -0
- package/lib/ocr-model-routing.js +1 -0
- package/lib/prompt-optimizer-config.js +92 -21
- package/lib/prompt-optimizer-core.js +51 -6
- package/lib/prompt-optimizer.js +20 -14
- package/lib/provider-adapter.js +1 -0
- package/lib/provider-adapters.js +35 -3
- package/lib/provider-catalog.js +20 -11
- package/lib/provider-config-service.js +18 -6
- package/lib/provider-protocol.js +18 -0
- package/lib/provider-task-runner.js +80 -5
- package/lib/vision-model-routing.js +22 -8
- package/package.json +12 -9
- package/user_guide.md +45 -23
|
@@ -65,7 +65,11 @@ export async function prepareDshTextModelPort(textModel, binding, { signal } = {
|
|
|
65
65
|
if (signal?.aborted) throw abortError(signal, 'prepare', 'not_invoked');
|
|
66
66
|
return textModel.resolveModelInfo(route.provider, route.model, signal);
|
|
67
67
|
}), signal, 'prepare', 'not_invoked'); }
|
|
68
|
-
catch (_) {
|
|
68
|
+
catch (_) {
|
|
69
|
+
if (signal?.aborted) throw abortError(signal, 'prepare', 'not_invoked');
|
|
70
|
+
// 查询失败不同于成功查询但能力缺失;不要吞掉错误后进入付费生成。
|
|
71
|
+
throw new ModelPortError('IRIS_MODEL_UNAVAILABLE', { stage: 'prepare', invocation: 'not_invoked' });
|
|
72
|
+
}
|
|
69
73
|
}
|
|
70
74
|
if (signal?.aborted) throw abortError(signal, 'prepare', 'not_invoked');
|
|
71
75
|
return createDshTextModelPort(textModel, { ...route, sessionId }, { metadata });
|
|
@@ -73,7 +77,16 @@ export async function prepareDshTextModelPort(textModel, binding, { signal } = {
|
|
|
73
77
|
|
|
74
78
|
/** 已绑定事实的纯构造;用于缓存元数据/协议 fixture,不执行元数据查询。 */
|
|
75
79
|
export function createDshTextModelPort(textModel, binding, { metadata } = {}) {
|
|
76
|
-
|
|
80
|
+
const bound = createDshBoundModelPort(textModel, binding, { metadata });
|
|
81
|
+
const modalities = metadata?.inputModalities;
|
|
82
|
+
if (modalities !== undefined) {
|
|
83
|
+
if (!Array.isArray(modalities) || modalities.some(value => typeof value !== 'string' || !value.trim())) {
|
|
84
|
+
throw new ModelPortError('IRIS_MODEL_PROTOCOL_INVALID', { stage: 'prepare', invocation: 'not_invoked' });
|
|
85
|
+
}
|
|
86
|
+
if (!modalities.includes('text')) throw new ModelPortError('IRIS_MODEL_UNSUPPORTED', { stage: 'prepare', invocation: 'not_invoked' });
|
|
87
|
+
}
|
|
88
|
+
// 缺失能力只表示未知;宿主接受文本输入也不证明远端必定只返回文字。
|
|
89
|
+
return Object.freeze({ ...bound, textInput: modalities === undefined ? 'unknown' : 'supported' });
|
|
77
90
|
}
|
|
78
91
|
|
|
79
92
|
/** 文本与视觉共用经过验证的 DSH 事件读取器;图片桥接仅由视觉适配器注入。 */
|
package/lib/generation-input.js
CHANGED
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import fs from 'node:fs';
|
|
5
|
+
import { normalizeMusicOptions } from './music-options.js';
|
|
5
6
|
|
|
6
7
|
const FIELDS = Object.freeze({
|
|
7
8
|
image: ['prompt', 'size', 'n', 'source_artifact_id'],
|
|
8
9
|
video: ['prompt', 'size', 'duration', 'img_data_url', 'first_frame_path', 'audio_path', 'resolution'],
|
|
9
10
|
tts: ['text', 'voice'],
|
|
11
|
+
music: ['prompt', 'timeout_ms', 'retry_502', 'retry_delay_ms'],
|
|
10
12
|
transcribe: ['audio_url', 'audio_path']
|
|
11
13
|
});
|
|
12
14
|
|
|
@@ -47,6 +49,7 @@ export function normalizeGenerationInput(capability, input, { allowAudioPath = f
|
|
|
47
49
|
const providerInput = {};
|
|
48
50
|
let audioPath;
|
|
49
51
|
let sourceArtifactId;
|
|
52
|
+
let musicOptions;
|
|
50
53
|
let firstFramePath, videoMode;
|
|
51
54
|
if (capability === 'video') {
|
|
52
55
|
audioPath = String(input.audio_path || '').trim();
|
|
@@ -101,6 +104,12 @@ export function normalizeGenerationInput(capability, input, { allowAudioPath = f
|
|
|
101
104
|
const voice = text(input.voice, 'voice', 64);
|
|
102
105
|
if (voice !== undefined) providerInput.voice = voice;
|
|
103
106
|
}
|
|
107
|
+
if (capability === 'music') {
|
|
108
|
+
providerInput.prompt = text(input.prompt, 'prompt', 20000, true);
|
|
109
|
+
try { musicOptions = normalizeMusicOptions(input); }
|
|
110
|
+
catch (error) { fail(error.message); }
|
|
111
|
+
providerInput.timeoutMs = musicOptions.timeoutMs;
|
|
112
|
+
}
|
|
104
113
|
if (capability === 'transcribe') {
|
|
105
114
|
const audioUrl = String(input.audio_url || '').trim();
|
|
106
115
|
audioPath = String(input.audio_path || '').trim();
|
|
@@ -119,6 +128,7 @@ export function normalizeGenerationInput(capability, input, { allowAudioPath = f
|
|
|
119
128
|
return Object.freeze({
|
|
120
129
|
modelRef,
|
|
121
130
|
providerInput: Object.freeze(providerInput),
|
|
131
|
+
...(musicOptions ? { musicOptions } : {}),
|
|
122
132
|
...(sourceArtifactId ? { sourceArtifactId } : {}),
|
|
123
133
|
...(audioPath ? { audioPath } : {}),
|
|
124
134
|
...(capability === 'video' ? { firstFramePath, videoMode } : {})
|
package/lib/host-runtime.js
CHANGED
|
@@ -10,9 +10,11 @@ export const IRIS_PLUGIN_ID = '@mokuyoaxis/dsh-iris';
|
|
|
10
10
|
export const HOST_CLIENT_PROTOCOL_VERSION = 0;
|
|
11
11
|
|
|
12
12
|
export const EXPECTED_IRIS_TOOLS = Object.freeze([
|
|
13
|
+
'iris_import_image',
|
|
13
14
|
'iris_draw_image',
|
|
14
15
|
'iris_edit_image',
|
|
15
16
|
'iris_generate_video',
|
|
17
|
+
'iris_generate_music',
|
|
16
18
|
'iris_speak_text',
|
|
17
19
|
'iris_transcribe_audio',
|
|
18
20
|
'iris_task_status',
|
|
@@ -28,8 +30,9 @@ export const EXPECTED_IRIS_TOOLS = Object.freeze([
|
|
|
28
30
|
]);
|
|
29
31
|
|
|
30
32
|
export const EXPECTED_IRIS_SKILLS = Object.freeze([
|
|
31
|
-
'iris-
|
|
32
|
-
'iris-compose-media'
|
|
33
|
+
'iris-use',
|
|
34
|
+
'iris-compose-media',
|
|
35
|
+
'iris-verify-ui'
|
|
33
36
|
]);
|
|
34
37
|
|
|
35
38
|
export const EXPECTED_IRIS_ROUTES = Object.freeze([
|
package/lib/image-edit-input.js
CHANGED
|
@@ -1,20 +1,22 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
/**
|
|
2
|
+
/** 改图只接受当前 Core 中的静态图片;输入原字节不进入 Task 记录。 */
|
|
3
3
|
import sharp from 'sharp';
|
|
4
4
|
import { inspectCoreArtifact, readCoreArtifactBytes } from './core-artifacts.js';
|
|
5
5
|
import { waitForModelWork } from './model-call-runtime.js';
|
|
6
|
+
import { supportsImageEdit } from './provider-protocol.js';
|
|
6
7
|
|
|
7
8
|
function invalid(message) {
|
|
8
9
|
return Object.assign(new Error(message), { code: 'IRIS_IMAGE_EDIT_INPUT_INVALID' });
|
|
9
10
|
}
|
|
10
11
|
|
|
11
12
|
export function imageEditCandidates(candidates) {
|
|
13
|
+
const supported = candidate => supportsImageEdit(candidate.adapter.protocol, candidate.providerModel);
|
|
12
14
|
if (candidates.some(candidate => candidate.selectionReason === 'explicit'
|
|
13
|
-
&& candidate
|
|
14
|
-
throw invalid('
|
|
15
|
+
&& !supported(candidate))) {
|
|
16
|
+
throw invalid('指定模型的图片协议不支持此类改图,请选择聊天生图模型或 DashScope Qwen 原生改图模型');
|
|
15
17
|
}
|
|
16
|
-
const selected = candidates.filter(
|
|
17
|
-
if (!selected.length) throw invalid('
|
|
18
|
+
const selected = candidates.filter(supported);
|
|
19
|
+
if (!selected.length) throw invalid('当前生图名单没有可用的聊天生图或 DashScope Qwen 改图模型');
|
|
18
20
|
return selected;
|
|
19
21
|
}
|
|
20
22
|
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import { open } from 'node:fs/promises';
|
|
4
|
+
import sharp from 'sharp';
|
|
5
|
+
|
|
6
|
+
export const MAX_IMPORT_IMAGE_BYTES = 20 * 1024 * 1024;
|
|
7
|
+
const TYPES = Object.freeze({ png: 'image/png', jpeg: 'image/jpeg', webp: 'image/webp' });
|
|
8
|
+
|
|
9
|
+
function invalid(message) {
|
|
10
|
+
return Object.assign(new Error(message), { code: 'IRIS_IMAGE_IMPORT_INVALID' });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function checkSize(size) {
|
|
14
|
+
if (!size || size > MAX_IMPORT_IMAGE_BYTES) throw invalid('导入图片必须非空,且不超过20 MiB');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** 只读一次有界快照;后续校验和保存使用同一份字节,不再复制变化中的源路径。 */
|
|
18
|
+
export async function readImportImage({ filePath, imageBytes }, signal) {
|
|
19
|
+
signal.throwIfAborted();
|
|
20
|
+
let bytes;
|
|
21
|
+
if (filePath) {
|
|
22
|
+
let file;
|
|
23
|
+
try {
|
|
24
|
+
file = await open(filePath, 'r');
|
|
25
|
+
const stat = await file.stat();
|
|
26
|
+
if (!stat.isFile()) throw invalid('导入来源必须是普通图片文件');
|
|
27
|
+
checkSize(stat.size);
|
|
28
|
+
bytes = Buffer.alloc(stat.size);
|
|
29
|
+
let offset = 0;
|
|
30
|
+
while (offset < bytes.length) {
|
|
31
|
+
signal.throwIfAborted();
|
|
32
|
+
const { bytesRead } = await file.read(bytes, offset, Math.min(65536, bytes.length - offset), offset);
|
|
33
|
+
if (!bytesRead) throw invalid('读取期间图片发生变化,请重新导入');
|
|
34
|
+
offset += bytesRead;
|
|
35
|
+
}
|
|
36
|
+
const extra = await file.read(Buffer.alloc(1), 0, 1, offset);
|
|
37
|
+
if (extra.bytesRead) throw invalid('读取期间图片发生变化,请重新导入');
|
|
38
|
+
} catch (error) {
|
|
39
|
+
signal.throwIfAborted();
|
|
40
|
+
if (error.code === 'IRIS_IMAGE_IMPORT_INVALID') throw error;
|
|
41
|
+
throw invalid('图片无法读取,请选择可读的普通文件');
|
|
42
|
+
} finally { await file?.close(); }
|
|
43
|
+
} else {
|
|
44
|
+
if (!(imageBytes instanceof Uint8Array)) throw invalid('导入图片内容必须是字节数据');
|
|
45
|
+
checkSize(imageBytes.byteLength);
|
|
46
|
+
bytes = Buffer.from(imageBytes);
|
|
47
|
+
}
|
|
48
|
+
signal.throwIfAborted();
|
|
49
|
+
let metadata;
|
|
50
|
+
try { metadata = await sharp(bytes).metadata(); }
|
|
51
|
+
catch (_) {
|
|
52
|
+
signal.throwIfAborted();
|
|
53
|
+
throw invalid('无法读取图片,请使用有效的静态PNG、JPEG或WebP');
|
|
54
|
+
}
|
|
55
|
+
signal.throwIfAborted();
|
|
56
|
+
const mediaType = TYPES[metadata.format];
|
|
57
|
+
if (!mediaType || metadata.pages > 1 || !metadata.width || !metadata.height) {
|
|
58
|
+
throw invalid('仅支持静态PNG、JPEG或WebP,不支持动画');
|
|
59
|
+
}
|
|
60
|
+
return { bytes, mediaType, metadata: { width: metadata.width, height: metadata.height } };
|
|
61
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -263,7 +263,7 @@ export async function apply(ctx) {
|
|
|
263
263
|
// 若 webServer 已在运行,inject 回调也会触发;这里兜底立即挂载一次
|
|
264
264
|
guard('媒体路由兜底挂载', () => mountIrisRoutes(ctx));
|
|
265
265
|
|
|
266
|
-
// DSH 的 Skill registry 是可选宿主能力。存在时把 npm
|
|
266
|
+
// DSH 的 Skill registry 是可选宿主能力。存在时把 npm 包内三项 Skill 注册到
|
|
267
267
|
// 全局层;项目目录中的同名 Skill 仍由 DSH 原生优先级覆盖。
|
|
268
268
|
ctx.inject(['skills'], (skillCtx) => {
|
|
269
269
|
guard('随包 Skill 注册', () => {
|
|
@@ -309,6 +309,30 @@ export async function apply(ctx) {
|
|
|
309
309
|
return { blocks };
|
|
310
310
|
}
|
|
311
311
|
|
|
312
|
+
safeRegister(host, {
|
|
313
|
+
name: 'iris_import_image',
|
|
314
|
+
description: 'Save a local image or a current-session image attachment as a persistent Iris image Artifact for later viewing, editing and export. Provide exactly one of image_path or attachment_id. Supports static PNG/JPEG/WebP up to 20 MiB, preserves the source, and never calls a model.',
|
|
315
|
+
parameters: {
|
|
316
|
+
type: 'object',
|
|
317
|
+
properties: {
|
|
318
|
+
image_path: { type: 'string', description: 'Absolute path to an image readable by the DSH host' },
|
|
319
|
+
attachment_id: { type: 'string', description: 'Image attachment from the current session, alternative to image_path' }
|
|
320
|
+
},
|
|
321
|
+
required: []
|
|
322
|
+
},
|
|
323
|
+
output: { schema: { type: 'object' }, render: (_args, value) => value.blocks },
|
|
324
|
+
isConcurrencySafe: () => true,
|
|
325
|
+
async execute(args, exec = {}) {
|
|
326
|
+
const imagePath = String(args.image_path || '').trim();
|
|
327
|
+
const attachmentId = String(args.attachment_id || '').trim();
|
|
328
|
+
if (Boolean(imagePath) === Boolean(attachmentId)) throw new Error('图片导入须选择image_path或attachment_id之一');
|
|
329
|
+
const input = imagePath ? { image_path: imagePath } : {
|
|
330
|
+
image_bytes: (await resolveVisionImageInput(dshHost(ctx), exec, { attachment_id: attachmentId }, exec.signal)).bytes
|
|
331
|
+
};
|
|
332
|
+
return runAction(dshHost(ctx), 'image_import', input, { signal: exec.signal });
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
|
|
312
336
|
/* ---------- 🖼️ 画图 ---------- */
|
|
313
337
|
safeRegister(host, {
|
|
314
338
|
name: 'iris_draw_image',
|
|
@@ -321,7 +345,7 @@ export async function apply(ctx) {
|
|
|
321
345
|
prompt: { type: 'string', description: 'Detailed visual description of the image to create' },
|
|
322
346
|
size: { type: 'string', description: "Output size; DashScope uses 'W*H' (e.g. '1024*1024'), OpenAI uses 'WxH'. Default provider default." },
|
|
323
347
|
n: { type: 'string', description: "Number of images, default '1'" },
|
|
324
|
-
model: { type: 'string', description: '
|
|
348
|
+
model: { type: 'string', description: 'Optional assigned providerId::modelId; selects only this item (legacy bare model id also accepted)' }
|
|
325
349
|
},
|
|
326
350
|
required: ['prompt']
|
|
327
351
|
},
|
|
@@ -383,12 +407,12 @@ export async function apply(ctx) {
|
|
|
383
407
|
|
|
384
408
|
safeRegister(host, {
|
|
385
409
|
name: 'iris_edit_image',
|
|
386
|
-
description: 'Edit an existing Core image by Artifact ID and an instruction using
|
|
410
|
+
description: 'Edit an existing Core image by Artifact ID and an instruction using an assigned chat-image model or native DashScope Qwen edit model. Returns new Core Artifacts and DSH image attachments; preserves the source image and records its provenance.',
|
|
387
411
|
parameters: {
|
|
388
412
|
type: 'object', properties: {
|
|
389
413
|
source_artifact_id: { type: 'string', description: 'Core Artifact ID of the source PNG, JPEG or WebP image (up to 20 MiB)' },
|
|
390
414
|
prompt: { type: 'string', description: 'Describe the changes and what should be preserved in the source image' },
|
|
391
|
-
model: { type: 'string', description: 'Optional providerId::modelId;
|
|
415
|
+
model: { type: 'string', description: 'Optional assigned providerId::modelId; selects only this item; supports openai-chat-images or DashScope Qwen edit/max/plus and 2.0/pro models' },
|
|
392
416
|
n: { type: 'string', description: "Number of images, default '1'" }
|
|
393
417
|
}, required: ['source_artifact_id', 'prompt']
|
|
394
418
|
},
|
|
@@ -419,7 +443,7 @@ export async function apply(ctx) {
|
|
|
419
443
|
resolution: { type: 'string', description: "s2v only: output tier '480P' (default) or '720P'" },
|
|
420
444
|
size: { type: 'string', description: "t2v/i2v only: output size 'W*H', e.g. '1280*720' (default)" },
|
|
421
445
|
duration: { type: 'number', description: 't2v/i2v only: duration in seconds, if the model supports it' },
|
|
422
|
-
model: { type: 'string', description: '
|
|
446
|
+
model: { type: 'string', description: 'Optional assigned providerId::modelId; selects only this item (legacy bare model id accepted)' }
|
|
423
447
|
},
|
|
424
448
|
required: []
|
|
425
449
|
},
|
|
@@ -463,16 +487,35 @@ export async function apply(ctx) {
|
|
|
463
487
|
}
|
|
464
488
|
});
|
|
465
489
|
|
|
490
|
+
safeRegister(host, {
|
|
491
|
+
name: 'iris_generate_music',
|
|
492
|
+
description: 'Generate music using a model explicitly assigned to Music. Returns audio artifact IDs and playback links. Lyrics, vocals and duration are prompt intentions. Configured 502 retries may generate again.',
|
|
493
|
+
parameters: { type: 'object', properties: {
|
|
494
|
+
prompt: { type: 'string', description: 'Describe the music, including any lyrics, vocals, mood or intended duration' },
|
|
495
|
+
model: { type: 'string', description: 'Optional assigned providerId::modelId; selects only this item' },
|
|
496
|
+
timeout_ms: { type: 'integer', description: 'Per-attempt timeout; default 300000 ms' },
|
|
497
|
+
retry_502: { type: 'integer', description: '502 regeneration retries, 0–5; default 2; each retry consumes another request' },
|
|
498
|
+
retry_delay_ms: { type: 'integer', description: 'Delay between 502 retries; default 5000 ms' }
|
|
499
|
+
}, required: ['prompt'] },
|
|
500
|
+
output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value }] },
|
|
501
|
+
isConcurrencySafe: () => true,
|
|
502
|
+
async execute(args, exec) {
|
|
503
|
+
const submitted = await runAction(dshHost(ctx), 'music', args, { signal: exec.signal });
|
|
504
|
+
return submitted.text + '\nartifact: ' + submitted.artifactIds.join(', ') + '\n' +
|
|
505
|
+
submitted.artifactIds.map(id => '[♪ 音乐播放](/iris/api/core/artifact/' + encodeURIComponent(id) + '/media)').join('\n');
|
|
506
|
+
}
|
|
507
|
+
});
|
|
508
|
+
|
|
466
509
|
/* ---------- 🔊 语音合成 ---------- */
|
|
467
510
|
safeRegister(host, {
|
|
468
511
|
name: 'iris_speak_text',
|
|
469
|
-
description: 'Synthesize
|
|
512
|
+
description: 'Synthesize spoken audio from text using the configured TTS model. Returns Core audio Artifact IDs and playback links; export an Artifact to a host file before passing it as audio_path to another tool. Use iris_generate_music for songs or background music.',
|
|
470
513
|
parameters: {
|
|
471
514
|
type: 'object',
|
|
472
515
|
properties: {
|
|
473
516
|
text: { type: 'string', description: 'Text to speak' },
|
|
474
517
|
voice: { type: 'string', description: "Voice name, e.g. 'Cherry' (DashScope qwen-tts voices)" },
|
|
475
|
-
model: { type: 'string', description: '
|
|
518
|
+
model: { type: 'string', description: 'Optional assigned providerId::modelId; selects only this item (legacy bare model id accepted)' }
|
|
476
519
|
},
|
|
477
520
|
required: ['text']
|
|
478
521
|
},
|
|
@@ -509,7 +552,7 @@ export async function apply(ctx) {
|
|
|
509
552
|
type: 'object',
|
|
510
553
|
properties: {
|
|
511
554
|
audio_path: { type: 'string', description: 'Absolute path to the audio file (wav/mp3)' },
|
|
512
|
-
model: { type: 'string', description: '
|
|
555
|
+
model: { type: 'string', description: 'Optional assigned transcription providerId::modelId; selects only this item (legacy bare model id accepted)' }
|
|
513
556
|
},
|
|
514
557
|
required: ['audio_path']
|
|
515
558
|
},
|
|
@@ -621,7 +664,7 @@ export async function apply(ctx) {
|
|
|
621
664
|
target_width: { type: 'integer', description: 'Frame width for sampling (default 640)' },
|
|
622
665
|
transcribe: { type: 'boolean', description: 'Auto-transcribe the audio track for video_path (default true unless transcribe_text is provided). Existing frames only consume explicit text.' },
|
|
623
666
|
transcribe_text: { type: 'string', description: 'Optional existing transcript text; does not create a transcription task' },
|
|
624
|
-
model: { type: 'string', description: '
|
|
667
|
+
model: { type: 'string', description: 'Optional item from the vision assignment list (providerId::modelId or host:current-vision); selects only this item' }
|
|
625
668
|
},
|
|
626
669
|
required: []
|
|
627
670
|
},
|
|
@@ -683,7 +726,7 @@ export async function apply(ctx) {
|
|
|
683
726
|
name: `iris-sheet-${Date.now()}.png`
|
|
684
727
|
});
|
|
685
728
|
if (exec.signal?.aborted) throw exec.signal.reason;
|
|
686
|
-
const viaLabel = via === 'selfstack' ? 'iris 自持栈' : 'DSH
|
|
729
|
+
const viaLabel = via === 'selfstack' ? 'iris 自持栈' : 'DSH 当前默认模型';
|
|
687
730
|
return {
|
|
688
731
|
frames: summaryFrameRecords(frames),
|
|
689
732
|
blocks: [
|
|
@@ -712,7 +755,7 @@ export async function apply(ctx) {
|
|
|
712
755
|
image_path: { type: 'string', description: 'Absolute path to the image file (png/jpg/jpeg/webp)' },
|
|
713
756
|
artifact_id: { type: 'string', description: 'Existing Core image Artifact ID in the current profile (alternative to image_path)' },
|
|
714
757
|
question: { type: 'string', description: 'Question or extraction request about the image; defaults to a detailed description' },
|
|
715
|
-
model: { type: 'string', description: '
|
|
758
|
+
model: { type: 'string', description: 'Optional item from the vision assignment list (providerId::modelId or host:current-vision); selects only this item' }
|
|
716
759
|
},
|
|
717
760
|
required: []
|
|
718
761
|
},
|
|
@@ -750,7 +793,7 @@ export async function apply(ctx) {
|
|
|
750
793
|
properties: {
|
|
751
794
|
attachment_id: { type: 'string', description: 'The attachment_id of an image that appeared in this session or was generated by iris' },
|
|
752
795
|
question: { type: 'string', description: 'New question about the image pixels' },
|
|
753
|
-
model: { type: 'string', description: '
|
|
796
|
+
model: { type: 'string', description: 'Optional item from the vision assignment list (providerId::modelId or host:current-vision); selects only this item' }
|
|
754
797
|
},
|
|
755
798
|
required: ['attachment_id', 'question']
|
|
756
799
|
},
|
|
@@ -780,7 +823,7 @@ export async function apply(ctx) {
|
|
|
780
823
|
safeRegister(host, {
|
|
781
824
|
name: 'iris_task_status',
|
|
782
825
|
description:
|
|
783
|
-
'Query
|
|
826
|
+
'Query Iris tasks (image/video/tts/music/transcription). Pass a task_id for one task, or omit it to list recent tasks. Core results provide Artifact IDs and playback links; legacy tasks may provide file paths. Querying does not re-observe or redeliver a task.',
|
|
784
827
|
parameters: {
|
|
785
828
|
type: 'object',
|
|
786
829
|
properties: {
|
|
@@ -915,7 +958,7 @@ export async function apply(ctx) {
|
|
|
915
958
|
artifact_id: { type: 'string', description: 'Existing Core image Artifact ID in the current profile (alternative to image_path or attachment_id)' },
|
|
916
959
|
attachment_id: { type: 'string', description: 'Session attachment id of an image (alternative to image_path)' },
|
|
917
960
|
target: { type: 'string', description: 'The object or UI element to locate, e.g. "send button"' },
|
|
918
|
-
model: { type: 'string', description: '
|
|
961
|
+
model: { type: 'string', description: 'Optional item from the vision assignment list (providerId::modelId or host:current-vision); selects only this item' }
|
|
919
962
|
},
|
|
920
963
|
required: ['target']
|
|
921
964
|
},
|
|
@@ -1022,7 +1065,7 @@ export async function apply(ctx) {
|
|
|
1022
1065
|
}
|
|
1023
1066
|
});
|
|
1024
1067
|
|
|
1025
|
-
console.log('[iris] tools registered: iris_draw_image, iris_edit_image, iris_generate_video, iris_speak_text, iris_transcribe_audio, iris_task_status, iris_look_at_image, iris_relook_attachment, iris_crop, iris_pixel_diff, iris_locate, iris_html_screenshot, iris_long_ocr, iris_video_frames, iris_media_summarize');
|
|
1068
|
+
console.log('[iris] tools registered: iris_import_image, iris_draw_image, iris_edit_image, iris_generate_video, iris_generate_music, iris_speak_text, iris_transcribe_audio, iris_task_status, iris_look_at_image, iris_relook_attachment, iris_crop, iris_pixel_diff, iris_locate, iris_html_screenshot, iris_long_ocr, iris_video_frames, iris_media_summarize');
|
|
1026
1069
|
};
|
|
1027
1070
|
|
|
1028
1071
|
/* ---------------- 视觉路由(M3 补全):look/relook 共用执行器 + 降级链 ---------------- */
|
|
@@ -1132,7 +1175,7 @@ export async function sessionAttachmentRef(host, exec, attachmentId) {
|
|
|
1132
1175
|
/**
|
|
1133
1176
|
* 看图问答(阶段 1:VisionBackend 降级链)。
|
|
1134
1177
|
* 只取声明/推断出 vision 能力的自持栈 provider(严格选择),按序组装后端链,
|
|
1135
|
-
*
|
|
1178
|
+
* 仅使用视觉功能的明确分配;宿主默认模型也需显式加入,每个后端失败保留在errors里。
|
|
1136
1179
|
* @returns {{answer:string,via:'selfstack'|'global',model?:string,backendId:string}}
|
|
1137
1180
|
*/
|
|
1138
1181
|
export async function askVision(host, { question, dataUrl, image, prepareImage, signal, model, budget }) {
|
|
@@ -1155,6 +1198,6 @@ export async function runVisionTool(host, exec, { origin, model, question, dataU
|
|
|
1155
1198
|
model
|
|
1156
1199
|
});
|
|
1157
1200
|
const label = origin === 'relook' ? '重看回答' : '看图回答';
|
|
1158
|
-
const viaLabel = via === 'selfstack' ? 'iris 自持栈' : 'DSH
|
|
1201
|
+
const viaLabel = via === 'selfstack' ? 'iris 自持栈' : 'DSH 当前默认模型';
|
|
1159
1202
|
return `[iris] ${label}(${usedModel || '默认'} · ${viaLabel}):\n${answer}` + (artifactId ? `\nCore Artifact: ${artifactId}` : '');
|
|
1160
1203
|
}
|
package/lib/models.js
CHANGED
|
@@ -19,12 +19,18 @@ export const CAP_FIELD = {
|
|
|
19
19
|
[CAPABILITIES.IMAGE]: 'imageModel',
|
|
20
20
|
[CAPABILITIES.VIDEO]: 'videoModel',
|
|
21
21
|
[CAPABILITIES.TTS]: 'ttsModel',
|
|
22
|
+
[CAPABILITIES.MUSIC]: 'musicModel',
|
|
22
23
|
[CAPABILITIES.TRANSCRIBE]: 'transcribeModel',
|
|
23
24
|
[CAPABILITIES.VISION]: 'visionModel'
|
|
24
25
|
};
|
|
25
26
|
|
|
26
27
|
const MODEL_REF_SEP = '::';
|
|
27
28
|
|
|
29
|
+
/** Explicit host resource; never a Provider account or an implicit fallback. */
|
|
30
|
+
export const HOST_VISION_REF = 'host:current-vision';
|
|
31
|
+
export const HOST_VISION_MODEL = Object.freeze({ ref: HOST_VISION_REF, id: 'current-vision',
|
|
32
|
+
providerId: HOST_VISION_REF, capabilities: Object.freeze([CAPABILITIES.VISION]), source: 'host' });
|
|
33
|
+
|
|
28
34
|
/** 稳定的模型复合引用;provider/model 分别编码,避免同名模型跨账号碰撞。 */
|
|
29
35
|
export function modelRef(providerId, modelId) {
|
|
30
36
|
return encodeURIComponent(String(providerId || '')) + MODEL_REF_SEP + encodeURIComponent(String(modelId || ''));
|
|
@@ -77,6 +83,7 @@ const DISCOVERY_CAPABILITIES = Object.freeze({
|
|
|
77
83
|
IG: CAPABILITIES.IMAGE, IMAGE: CAPABILITIES.IMAGE, 'IMAGE-GEN': CAPABILITIES.IMAGE,
|
|
78
84
|
VG: CAPABILITIES.VIDEO, VIDEO: CAPABILITIES.VIDEO, 'VIDEO-GEN': CAPABILITIES.VIDEO,
|
|
79
85
|
TTS: CAPABILITIES.TTS, ASR: CAPABILITIES.TRANSCRIBE,
|
|
86
|
+
MUSIC: CAPABILITIES.MUSIC, 'MUSIC-GEN': CAPABILITIES.MUSIC,
|
|
80
87
|
TRANSCRIBE: CAPABILITIES.TRANSCRIBE, VU: CAPABILITIES.VISION, VISION: CAPABILITIES.VISION
|
|
81
88
|
});
|
|
82
89
|
|
|
@@ -115,6 +122,7 @@ export function providerModels(p) {
|
|
|
115
122
|
const caps = configuredModelCapabilities(p, m);
|
|
116
123
|
const extra = typeof m === 'object' && m ? { verified: m.verified, source: m.source,
|
|
117
124
|
...(m.imageProtocol !== undefined ? { imageProtocol: m.imageProtocol } : {}),
|
|
125
|
+
...(m.musicProtocol !== undefined ? { musicProtocol: m.musicProtocol } : {}),
|
|
118
126
|
...(m.visionInput !== undefined ? { visionInput: m.visionInput } : {}) } : {};
|
|
119
127
|
push(id, caps, extra);
|
|
120
128
|
}
|
|
@@ -139,6 +147,7 @@ export function resolveModelRef(provider, capability, requested = '') {
|
|
|
139
147
|
|
|
140
148
|
/** 共享旧分配对象、裸名称和复合引用的解析,池顺序决定同名匹配。 */
|
|
141
149
|
export function resolvePoolModel(pool, raw, capability) {
|
|
150
|
+
if (raw === HOST_VISION_REF) return pool.find(m => m.ref === raw && m.capabilities.includes(capability)) || null;
|
|
142
151
|
let providerId = '';
|
|
143
152
|
let modelId = '';
|
|
144
153
|
if (raw && typeof raw === 'object') {
|
|
@@ -153,7 +162,7 @@ export function resolvePoolModel(pool, raw, capability) {
|
|
|
153
162
|
&& m.capabilities.includes(capability)) || null;
|
|
154
163
|
}
|
|
155
164
|
|
|
156
|
-
/** DSH 与 CLI
|
|
165
|
+
/** DSH 与 CLI 共用的执行名单:只按明确分配排序并去重。 */
|
|
157
166
|
export function orderedModels(pool, capability, assignments, { includeRateLimited = false } = {}) {
|
|
158
167
|
const ordered = [];
|
|
159
168
|
const seen = new Set();
|
|
@@ -167,7 +176,6 @@ export function orderedModels(pool, capability, assignments, { includeRateLimite
|
|
|
167
176
|
for (const raw of (Array.isArray(assignments) ? assignments : (assignments ? [assignments] : []))) {
|
|
168
177
|
add(resolvePoolModel(pool, raw, capability));
|
|
169
178
|
}
|
|
170
|
-
for (const model of pool) add(model);
|
|
171
179
|
return ordered;
|
|
172
180
|
}
|
|
173
181
|
|
|
@@ -189,9 +197,10 @@ export function selectionReasonForModel(pool, capability, assignments, modelOrRe
|
|
|
189
197
|
}
|
|
190
198
|
|
|
191
199
|
/** 全局模型池:合并所有 provider 的模型 */
|
|
192
|
-
export function modelPool(providers) {
|
|
200
|
+
export function modelPool(providers, { includeHostVision = false } = {}) {
|
|
193
201
|
const pool = [];
|
|
194
202
|
for (const p of providers || []) pool.push(...providerModels(p));
|
|
203
|
+
if (includeHostVision) pool.push(HOST_VISION_MODEL);
|
|
195
204
|
return pool;
|
|
196
205
|
}
|
|
197
206
|
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
2
|
+
|
|
3
|
+
/** Explicit Music 502 regeneration policy; each resubmission is a new Core Task. */
|
|
4
|
+
export async function generateMusicTask({ runner, candidates, providerInput, options, signal }) {
|
|
5
|
+
const taskIds = [];
|
|
6
|
+
let result, retriedFrom;
|
|
7
|
+
for (let index = 0; index <= options.retry502; index++) {
|
|
8
|
+
signal?.throwIfAborted();
|
|
9
|
+
result = await runner.submit({ capability: 'music', candidates, providerInput, ...(retriedFrom ? { retriedFrom } : {}) });
|
|
10
|
+
taskIds.push(result.task.id);
|
|
11
|
+
const task = result.task;
|
|
12
|
+
if (result.localError || task.phase !== 'terminal' || task.lastError?.httpStatus !== 502
|
|
13
|
+
|| task.outcome === 'succeeded' || index === options.retry502) break;
|
|
14
|
+
// Retry the actual model, without switching to another (potentially paid) source.
|
|
15
|
+
candidates = candidates.filter(candidate => candidate.model === task.modelRef && candidate.adapter.id === task.providerId);
|
|
16
|
+
if (!candidates.length) throw new Error('Music 重试无法找到原模型绑定');
|
|
17
|
+
retriedFrom = task.id;
|
|
18
|
+
await delay(options.retryDelayMs, undefined, { signal });
|
|
19
|
+
}
|
|
20
|
+
return Object.freeze({ ...result, taskIds: Object.freeze(taskIds), retries: taskIds.length - 1 });
|
|
21
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Music caller controls; generation parameters remain in the prompt. */
|
|
2
|
+
export function normalizeMusicOptions(input = {}) {
|
|
3
|
+
const integer = (field, fallback, min, max) => {
|
|
4
|
+
if (input[field] !== undefined && !['number', 'string'].includes(typeof input[field])) {
|
|
5
|
+
throw new TypeError(`${field} 必须是整数`);
|
|
6
|
+
}
|
|
7
|
+
const value = input[field] === undefined ? fallback : Number(input[field]);
|
|
8
|
+
if (!Number.isSafeInteger(value) || value < min || value > max) {
|
|
9
|
+
throw new TypeError(`${field} 必须是 ${min}–${max} 的整数`);
|
|
10
|
+
}
|
|
11
|
+
return value;
|
|
12
|
+
};
|
|
13
|
+
return Object.freeze({
|
|
14
|
+
timeoutMs: integer('timeout_ms', 300000, 1, 3600000),
|
|
15
|
+
retry502: integer('retry_502', 2, 0, 5),
|
|
16
|
+
retryDelayMs: integer('retry_delay_ms', 5000, 0, 60000)
|
|
17
|
+
});
|
|
18
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { ProviderContractError } from './provider-contract.js';
|
|
2
|
+
|
|
3
|
+
function responseError(message) {
|
|
4
|
+
return new ProviderContractError(message, { stage: 'response', category: 'protocol', acceptance: 'unknown' });
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** Gemini web gateway's documented non-streaming Markdown audio response. */
|
|
8
|
+
export function musicArtifactsFromResponse(body) {
|
|
9
|
+
if (!Array.isArray(body?.choices)) throw responseError('Music 响应缺少 choices');
|
|
10
|
+
const artifacts = [], seen = new Set();
|
|
11
|
+
for (const choice of body.choices) {
|
|
12
|
+
const content = choice?.message?.content;
|
|
13
|
+
if (typeof content !== 'string') continue;
|
|
14
|
+
for (const match of content.matchAll(/data:(audio\/(?:mpeg|wav|x-wav));base64,([A-Za-z0-9+/=]+)/gi)) {
|
|
15
|
+
const data = match[2];
|
|
16
|
+
const bytes = Buffer.from(data, 'base64');
|
|
17
|
+
if (!bytes.length || bytes.toString('base64') !== data) throw responseError('Music 音频 base64 无效');
|
|
18
|
+
if (seen.has(data)) continue;
|
|
19
|
+
seen.add(data);
|
|
20
|
+
artifacts.push({ kind: 'inline-base64', data, mediaType: match[1].toLowerCase() === 'audio/mpeg' ? 'audio/mpeg' : 'audio/wav' });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
if (!artifacts.length) throw responseError('Music 响应没有可交付的 MP3/WAV 音频');
|
|
24
|
+
return artifacts;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function generateMusic({ key, baseUrl, model, prompt, timeoutMs = 300000, signal }) {
|
|
28
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
29
|
+
const response = await fetch(String(baseUrl).replace(/\/+$/, '') + '/chat/completions', {
|
|
30
|
+
method: 'POST',
|
|
31
|
+
headers: { 'Content-Type': 'application/json', ...(key ? { Authorization: `Bearer ${key}` } : {}) },
|
|
32
|
+
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
|
|
33
|
+
body: JSON.stringify({ model, stream: false, messages: [{ role: 'user', content: prompt }] })
|
|
34
|
+
});
|
|
35
|
+
const text = await response.text();
|
|
36
|
+
let body;
|
|
37
|
+
try { body = JSON.parse(text); }
|
|
38
|
+
catch (error) { if (response.ok) throw responseError('Music 响应不是有效 JSON'); }
|
|
39
|
+
if (!response.ok) {
|
|
40
|
+
const message = String(body?.error?.message || body?.message || `Music HTTP ${response.status}`);
|
|
41
|
+
throw new ProviderContractError(key ? message.replaceAll(key, '[REDACTED]') : message, {
|
|
42
|
+
stage: 'submit', httpStatus: response.status, category: 'provider', acceptance: 'unknown', retryable: response.status === 502
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
return musicArtifactsFromResponse(body);
|
|
46
|
+
}
|
package/lib/ocr-model-routing.js
CHANGED
|
@@ -12,6 +12,7 @@ export async function runOcrRequest(host, { providers = [], model, image, prepar
|
|
|
12
12
|
const settings = normalizeOcrSettings({ chunkHeight, overlap, maxDimension, maxInvocations });
|
|
13
13
|
const operation = createModelOperation({ signal, budget: { timeoutMs: budget.timeoutMs, maxInvocations: settings.maxInvocations } });
|
|
14
14
|
try {
|
|
15
|
+
if (operation.signal.aborted) throw new ModelPortError('IRIS_MODEL_ABORTED');
|
|
15
16
|
const inputs = new Map();
|
|
16
17
|
const candidates = buildVisionModelCandidates(host, { providers, model, onRateLimit, prepareImages: true,
|
|
17
18
|
prepareImageRequest: createVisionInputObserver(inputs) });
|