@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.
- 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 +41 -0
- package/.dsh/skills/iris-use/agents/openai.yaml +4 -0
- package/.dsh/skills/iris-use/references/media-contract.md +73 -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 +26 -2
- package/README.en.md +39 -32
- package/README.md +37 -31
- package/bin/dsh-iris.js +27 -6
- package/docs/AGENT_QUICKSTART.md +9 -9
- package/docs/ARCHITECTURE.md +1 -1
- package/docs/CORE_RUNTIME_CONTRACT.md +3 -1
- package/docs/DSH_CORE_MIGRATION.md +6 -2
- package/docs/FAULT_INJECTION.md +1 -0
- package/docs/HEADLESS_CLI.md +8 -4
- package/docs/HOST_DOCTOR.md +3 -3
- 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 +14 -25
- package/docs/SECURITY.md +3 -2
- package/docs/SYNC_DELIVERY_RECOVERY.md +39 -0
- package/docs/TASK_SEMANTICS.md +4 -4
- package/docs/releases/0.2.3.md +54 -0
- package/lib/actions.js +40 -3
- package/lib/adapters.js +1 -0
- package/lib/api.js +14 -5
- package/lib/bundled-skills.js +1 -1
- package/lib/capability.js +2 -0
- package/lib/client.js +111 -26
- package/lib/command-service.js +12 -3
- package/lib/config.js +26 -2
- 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/dsh-core-adapter.js +14 -4
- package/lib/dsh-text-model-adapter.js +15 -2
- package/lib/generation-input.js +10 -0
- package/lib/host-runtime.js +4 -2
- package/lib/index.js +23 -4
- package/lib/models.js +4 -1
- package/lib/music-generation.js +21 -0
- package/lib/music-options.js +18 -0
- package/lib/music-transport.js +46 -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 +24 -1
- package/lib/provider-catalog.js +18 -7
- package/lib/provider-config-service.js +13 -1
- package/lib/provider-protocol.js +12 -0
- package/lib/provider-task-runner.js +80 -5
- package/package.json +4 -2
- package/user_guide.md +30 -16
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 注册', () => {
|
|
@@ -463,10 +463,29 @@ export async function apply(ctx) {
|
|
|
463
463
|
}
|
|
464
464
|
});
|
|
465
465
|
|
|
466
|
+
safeRegister(host, {
|
|
467
|
+
name: 'iris_generate_music',
|
|
468
|
+
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.',
|
|
469
|
+
parameters: { type: 'object', properties: {
|
|
470
|
+
prompt: { type: 'string', description: 'Describe the music, including any lyrics, vocals, mood or intended duration' },
|
|
471
|
+
model: { type: 'string', description: 'Assigned providerId::modelId reference' },
|
|
472
|
+
timeout_ms: { type: 'integer', description: 'Per-attempt timeout; default 300000 ms' },
|
|
473
|
+
retry_502: { type: 'integer', description: '502 regeneration retries, 0–5; default 2; each retry consumes another request' },
|
|
474
|
+
retry_delay_ms: { type: 'integer', description: 'Delay between 502 retries; default 5000 ms' }
|
|
475
|
+
}, required: ['prompt'] },
|
|
476
|
+
output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value }] },
|
|
477
|
+
isConcurrencySafe: () => true,
|
|
478
|
+
async execute(args, exec) {
|
|
479
|
+
const submitted = await runAction(dshHost(ctx), 'music', args, { signal: exec.signal });
|
|
480
|
+
return submitted.text + '\nartifact: ' + submitted.artifactIds.join(', ') + '\n' +
|
|
481
|
+
submitted.artifactIds.map(id => '[♪ 音乐播放](/iris/api/core/artifact/' + encodeURIComponent(id) + '/media)').join('\n');
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
|
|
466
485
|
/* ---------- 🔊 语音合成 ---------- */
|
|
467
486
|
safeRegister(host, {
|
|
468
487
|
name: 'iris_speak_text',
|
|
469
|
-
description: 'Synthesize
|
|
488
|
+
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
489
|
parameters: {
|
|
471
490
|
type: 'object',
|
|
472
491
|
properties: {
|
|
@@ -780,7 +799,7 @@ export async function apply(ctx) {
|
|
|
780
799
|
safeRegister(host, {
|
|
781
800
|
name: 'iris_task_status',
|
|
782
801
|
description:
|
|
783
|
-
'Query
|
|
802
|
+
'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
803
|
parameters: {
|
|
785
804
|
type: 'object',
|
|
786
805
|
properties: {
|
|
@@ -1022,7 +1041,7 @@ export async function apply(ctx) {
|
|
|
1022
1041
|
}
|
|
1023
1042
|
});
|
|
1024
1043
|
|
|
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');
|
|
1044
|
+
console.log('[iris] tools registered: 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
1045
|
};
|
|
1027
1046
|
|
|
1028
1047
|
/* ---------------- 视觉路由(M3 补全):look/relook 共用执行器 + 降级链 ---------------- */
|
package/lib/models.js
CHANGED
|
@@ -19,6 +19,7 @@ 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
|
};
|
|
@@ -77,6 +78,7 @@ const DISCOVERY_CAPABILITIES = Object.freeze({
|
|
|
77
78
|
IG: CAPABILITIES.IMAGE, IMAGE: CAPABILITIES.IMAGE, 'IMAGE-GEN': CAPABILITIES.IMAGE,
|
|
78
79
|
VG: CAPABILITIES.VIDEO, VIDEO: CAPABILITIES.VIDEO, 'VIDEO-GEN': CAPABILITIES.VIDEO,
|
|
79
80
|
TTS: CAPABILITIES.TTS, ASR: CAPABILITIES.TRANSCRIBE,
|
|
81
|
+
MUSIC: CAPABILITIES.MUSIC, 'MUSIC-GEN': CAPABILITIES.MUSIC,
|
|
80
82
|
TRANSCRIBE: CAPABILITIES.TRANSCRIBE, VU: CAPABILITIES.VISION, VISION: CAPABILITIES.VISION
|
|
81
83
|
});
|
|
82
84
|
|
|
@@ -115,6 +117,7 @@ export function providerModels(p) {
|
|
|
115
117
|
const caps = configuredModelCapabilities(p, m);
|
|
116
118
|
const extra = typeof m === 'object' && m ? { verified: m.verified, source: m.source,
|
|
117
119
|
...(m.imageProtocol !== undefined ? { imageProtocol: m.imageProtocol } : {}),
|
|
120
|
+
...(m.musicProtocol !== undefined ? { musicProtocol: m.musicProtocol } : {}),
|
|
118
121
|
...(m.visionInput !== undefined ? { visionInput: m.visionInput } : {}) } : {};
|
|
119
122
|
push(id, caps, extra);
|
|
120
123
|
}
|
|
@@ -167,7 +170,7 @@ export function orderedModels(pool, capability, assignments, { includeRateLimite
|
|
|
167
170
|
for (const raw of (Array.isArray(assignments) ? assignments : (assignments ? [assignments] : []))) {
|
|
168
171
|
add(resolvePoolModel(pool, raw, capability));
|
|
169
172
|
}
|
|
170
|
-
for (const model of pool) add(model);
|
|
173
|
+
if (capability !== CAPABILITIES.MUSIC) for (const model of pool) add(model);
|
|
171
174
|
return ordered;
|
|
172
175
|
}
|
|
173
176
|
|
|
@@ -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
|
+
}
|
|
@@ -118,62 +118,133 @@ export function normalizePromptOptimizerConfig(input) {
|
|
|
118
118
|
};
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
/** 只反馈字段名,不携带被忽略的字段值;持久配置仍使用原 v1 规范化。 */
|
|
122
|
+
function importFeedback(input) {
|
|
123
|
+
const ignored = [];
|
|
124
|
+
const collect = (value, prefix, allowed) => {
|
|
125
|
+
if (!value) return;
|
|
126
|
+
for (const key of Object.keys(value)) if (!allowed.includes(key)) ignored.push(prefix + key);
|
|
127
|
+
};
|
|
128
|
+
collect(input, '', ['version', 'enabled', 'systemPrompt', 'targets', 'route', 'generation']);
|
|
129
|
+
collect(input.targets, 'targets.', PROMPT_TARGETS);
|
|
130
|
+
collect(input.route, 'route.', input.route?.mode === 'fixed' ? ['mode', 'provider', 'model'] : ['mode']);
|
|
131
|
+
collect(input.generation, 'generation.', ['temperature', 'reasoningEffort', 'maxOutputTokens', 'timeoutMs']);
|
|
132
|
+
return { warnings: ignored.length ? [{ code: 'ignored-fields', count: ignored.length,
|
|
133
|
+
fields: ignored.slice(0, 32).map(field => field.length > 256 ? field.slice(0, 253) + '...' : field) }] : [] };
|
|
134
|
+
}
|
|
135
|
+
|
|
121
136
|
let cache = null;
|
|
122
137
|
let cacheSource = 'default';
|
|
138
|
+
let cacheFeedback = { warnings: [] };
|
|
123
139
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
140
|
+
function snapshot() {
|
|
141
|
+
return { source: cacheSource, config: structuredClone(cache), feedback: structuredClone(cacheFeedback) };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function persistConfig(next) {
|
|
145
|
+
const encoded = JSON.stringify(next, null, 2);
|
|
146
|
+
try { atomicWritePrivate(configFile(), encoded); }
|
|
147
|
+
catch (error) {
|
|
148
|
+
// 已知文件系统异常可能带私有路径;编程异常仍原样暴露。
|
|
149
|
+
if (typeof error.code !== 'string' || typeof error.syscall !== 'string') throw error;
|
|
150
|
+
const display = new Error('提示词配置写入失败,请检查存储权限和空间后重新载入配置');
|
|
151
|
+
display.code = error.code;
|
|
152
|
+
throw display;
|
|
131
153
|
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function useFallback(file, reason, isolate) {
|
|
157
|
+
let isolation = 'not-attempted';
|
|
158
|
+
if (isolate) {
|
|
136
159
|
const backup = privateSibling(file, 'corrupted');
|
|
137
160
|
try {
|
|
138
161
|
fs.renameSync(file, backup);
|
|
162
|
+
isolation = 'saved';
|
|
139
163
|
chmodPrivateFile(backup);
|
|
140
|
-
} catch (_) {
|
|
141
|
-
|
|
164
|
+
} catch (_) { if (isolation !== 'saved') isolation = 'failed'; }
|
|
165
|
+
}
|
|
166
|
+
cache = structuredClone(DEFAULT_VALUE);
|
|
167
|
+
cacheSource = 'default';
|
|
168
|
+
cacheFeedback = { warnings: [{ code: 'config-fallback', reason, isolation }] };
|
|
169
|
+
// JSON.parse/文件系统异常可能带原稿或绝对路径;只记录受控原因与隔离事实。
|
|
170
|
+
console.error('[iris] prompt-optimizer.json 已回退内置默认值:', reason, isolation);
|
|
171
|
+
return snapshot();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function loadPromptOptimizerConfig() {
|
|
175
|
+
if (cache) return snapshot();
|
|
176
|
+
const file = configFile();
|
|
177
|
+
let raw;
|
|
178
|
+
try { raw = fs.readFileSync(file, 'utf8'); }
|
|
179
|
+
catch (error) {
|
|
180
|
+
if (error.code !== 'ENOENT') return useFallback(file, 'read-failed', false);
|
|
142
181
|
cache = structuredClone(DEFAULT_VALUE);
|
|
143
182
|
cacheSource = 'default';
|
|
183
|
+
cacheFeedback = { warnings: [] };
|
|
184
|
+
return snapshot();
|
|
144
185
|
}
|
|
145
|
-
|
|
186
|
+
let input;
|
|
187
|
+
try { input = JSON.parse(raw); }
|
|
188
|
+
catch (_) { return useFallback(file, 'json-invalid', true); }
|
|
189
|
+
try {
|
|
190
|
+
cache = normalizePromptOptimizerConfig(input);
|
|
191
|
+
cacheSource = JSON.stringify(cache) === JSON.stringify(DEFAULT_VALUE) ? 'default' : 'custom';
|
|
192
|
+
} catch (_) { return useFallback(file, 'validation-invalid', true); }
|
|
193
|
+
cacheFeedback = importFeedback(input);
|
|
194
|
+
return snapshot();
|
|
146
195
|
}
|
|
147
196
|
|
|
148
197
|
export function importPromptOptimizerConfig(input) {
|
|
149
198
|
const next = normalizePromptOptimizerConfig(input);
|
|
150
|
-
|
|
199
|
+
persistConfig(next);
|
|
151
200
|
cache = next;
|
|
152
201
|
cacheSource = 'custom';
|
|
153
|
-
|
|
202
|
+
cacheFeedback = importFeedback(input);
|
|
203
|
+
return snapshot();
|
|
154
204
|
}
|
|
155
205
|
|
|
156
206
|
export function setPromptOptimizerEnabled(enabled) {
|
|
157
207
|
if (typeof enabled !== 'boolean') throw new Error('enabled 必须是布尔值');
|
|
158
|
-
const
|
|
159
|
-
|
|
208
|
+
const loaded = loadPromptOptimizerConfig();
|
|
209
|
+
if (loaded.feedback.warnings.some(warning => warning.code === 'config-fallback' && warning.isolation !== 'saved')) {
|
|
210
|
+
throw new Error('原配置尚未隔离保留,不能只更新入口开关;请先导入有效配置或明确恢复全部设置');
|
|
211
|
+
}
|
|
212
|
+
const next = normalizePromptOptimizerConfig({ ...loaded.config, enabled });
|
|
213
|
+
persistConfig(next);
|
|
214
|
+
cache = next;
|
|
215
|
+
cacheSource = JSON.stringify(cache) === JSON.stringify(DEFAULT_VALUE) ? 'default' : 'custom';
|
|
216
|
+
cacheFeedback = { warnings: cacheFeedback.warnings.filter(warning => warning.code === 'config-fallback') };
|
|
217
|
+
return snapshot();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** 只更新指令;无法读取原设置时不得声称保留了路由与预算。 */
|
|
221
|
+
export function resetPromptOptimizerInstructions() {
|
|
222
|
+
const loaded = loadPromptOptimizerConfig();
|
|
223
|
+
if (loaded.feedback.warnings.some(warning => warning.code === 'config-fallback')) {
|
|
224
|
+
throw new Error('当前配置无法读取,不能保证保留模型与生成参数;请先导入有效配置或选择恢复全部设置');
|
|
225
|
+
}
|
|
226
|
+
const next = { ...loaded.config, systemPrompt: DEFAULT_VALUE.systemPrompt, targets: structuredClone(DEFAULT_VALUE.targets) };
|
|
227
|
+
persistConfig(next);
|
|
160
228
|
cache = next;
|
|
161
229
|
cacheSource = JSON.stringify(cache) === JSON.stringify(DEFAULT_VALUE) ? 'default' : 'custom';
|
|
162
|
-
|
|
230
|
+
cacheFeedback = { warnings: [] };
|
|
231
|
+
return snapshot();
|
|
163
232
|
}
|
|
164
233
|
|
|
165
234
|
export function resetPromptOptimizerConfig() {
|
|
166
235
|
const next = structuredClone(DEFAULT_VALUE);
|
|
167
|
-
|
|
236
|
+
persistConfig(next);
|
|
168
237
|
cache = next;
|
|
169
238
|
cacheSource = 'default';
|
|
170
|
-
|
|
239
|
+
cacheFeedback = { warnings: [] };
|
|
240
|
+
return snapshot();
|
|
171
241
|
}
|
|
172
242
|
|
|
173
243
|
/** 仅供测试/热重载丢弃进程内缓存。 */
|
|
174
244
|
export function resetPromptOptimizerConfigCache() {
|
|
175
245
|
cache = null;
|
|
176
246
|
cacheSource = 'default';
|
|
247
|
+
cacheFeedback = { warnings: [] };
|
|
177
248
|
}
|
|
178
249
|
|
|
179
250
|
export function promptOptimizerConfigFile() {
|
|
@@ -5,7 +5,7 @@ import { ModelPortError } from './model-port-contract.js';
|
|
|
5
5
|
|
|
6
6
|
export const PROMPT_TARGETS = Object.freeze(['general', 'image', 'image-edit', 'video', 's2v']);
|
|
7
7
|
export const PROMPT_EDITING_STRATEGIES = Object.freeze(['faithful', 'rewrite', 'suggestions']);
|
|
8
|
-
export const PROMPT_OPTIMIZER_CAPABILITIES = Object.freeze({ rules: true, assemble: true, targets: PROMPT_TARGETS, editing: PROMPT_EDITING_STRATEGIES });
|
|
8
|
+
export const PROMPT_OPTIMIZER_CAPABILITIES = Object.freeze({ rules: true, assemble: true, localFaithful: true, targets: PROMPT_TARGETS, editing: PROMPT_EDITING_STRATEGIES });
|
|
9
9
|
export const MAX_PROMPT_INPUT_BYTES = 32 * 1024;
|
|
10
10
|
export const MAX_PROMPT_OUTPUT_CHARS = 16000;
|
|
11
11
|
export const MAX_PROMPT_RULE_BYTES = 16 * 1024;
|
|
@@ -130,6 +130,32 @@ function parseEditingJson(body) {
|
|
|
130
130
|
try { return JSON.parse(fenced ? fenced[1] : body); } catch (_) { throw protocolError(); }
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
function jsonValue(text) {
|
|
134
|
+
try { return JSON.parse(text); } catch (_) { return undefined; }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function inputEnvelope(value) {
|
|
138
|
+
return value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 2
|
|
139
|
+
&& typeof value.target === 'string' && typeof value.prompt === 'string';
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** 只拒绝可识别的新增传输包装,不解包 JSON 或猜测性删除转义。 */
|
|
143
|
+
function validateRewriteFormat(body, input) {
|
|
144
|
+
const sources = [input.text, ...input.rules.filter(rule => rule.kind === 'optimization').map(rule => rule.text)];
|
|
145
|
+
const source = sources.join('\n');
|
|
146
|
+
const fence = /^(`{3,}|~{3,})[^\n]*\n([\s\S]*)\n\1$/;
|
|
147
|
+
const value = jsonValue(body.match(fence)?.[2] ?? body);
|
|
148
|
+
const originalValue = jsonValue(input.text.match(fence)?.[2] ?? input.text);
|
|
149
|
+
// 原稿只在内部引用 JSON,不代表整份正文可以被重新包进该容器。
|
|
150
|
+
if (inputEnvelope(value) && !inputEnvelope(originalValue)
|
|
151
|
+
&& !sources.slice(1).some(text => /"target"\s*:/.test(text) && /"prompt"\s*:/.test(text))) throw protocolError();
|
|
152
|
+
// 原稿或明确规则已经使用围栏时,围栏属于素材;不要剥掉正文中的代码块。
|
|
153
|
+
if (fence.test(body) && !/`{3,}|~{3,}/.test(source)) throw protocolError();
|
|
154
|
+
// 整份正文又被序列化成字符串是明确的外壳;自然语言引文仍允许。
|
|
155
|
+
if (typeof value === 'string' && !sources.some(text => typeof jsonValue(text.match(fence)?.[2] ?? text) === 'string')
|
|
156
|
+
&& /\\(?:["\\nrt]|u[0-9a-f]{4})/i.test(body) && !source.includes(body)) throw protocolError();
|
|
157
|
+
}
|
|
158
|
+
|
|
133
159
|
function normalizeGroups(value, parts) {
|
|
134
160
|
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).join() !== 'groups'
|
|
135
161
|
|| !Array.isArray(value.groups) || !value.groups.length || value.groups.length > parts.length) throw protocolError();
|
|
@@ -167,15 +193,27 @@ function normalizeSuggestions(value, input) {
|
|
|
167
193
|
return { suggestions, questions };
|
|
168
194
|
}
|
|
169
195
|
|
|
170
|
-
function result(input, body, processing) {
|
|
196
|
+
function result(input, body, processing, invocation = input.mode === 'assemble' ? 'not_invoked' : 'responded') {
|
|
171
197
|
const prefix = input.rules.filter(rule => rule.position === 'prefix').map(rule => rule.text).join('\n\n');
|
|
172
198
|
const suffix = input.rules.filter(rule => rule.position === 'suffix').map(rule => rule.text).join('\n\n');
|
|
173
199
|
const optimized = [prefix, body, suffix].filter(value => value.length).join('\n\n');
|
|
174
|
-
if (optimized.length > MAX_PROMPT_OUTPUT_CHARS) throw new ModelPortError('IRIS_MODEL_OUTPUT_LIMIT', { stage: 'normalize', invocation
|
|
200
|
+
if (optimized.length > MAX_PROMPT_OUTPUT_CHARS) throw new ModelPortError('IRIS_MODEL_OUTPUT_LIMIT', { stage: 'normalize', invocation });
|
|
175
201
|
return { ok: true, original: input.original, optimized, target: input.target, mode: input.mode,
|
|
176
202
|
rules: input.rules, assembly: { prefix, body, suffix }, ...(processing ? { processing } : {}) };
|
|
177
203
|
}
|
|
178
204
|
|
|
205
|
+
/** 内部入口接收 normalizePromptInput 的结果;单片段没有可供模型选择的分段位置。 */
|
|
206
|
+
export function tryLocalPromptOptimization(normalized, { signal } = {}) {
|
|
207
|
+
checkSignal(signal);
|
|
208
|
+
if (normalized.mode !== 'optimize' || normalized.editing !== 'faithful') return null;
|
|
209
|
+
const parts = sourceParts(normalized.original);
|
|
210
|
+
if (parts.length !== 1) return null;
|
|
211
|
+
const out = result(normalized, normalized.original, undefined, 'not_invoked');
|
|
212
|
+
checkSignal(signal);
|
|
213
|
+
return { ...out, editing: { strategy: 'faithful', coverage: 'exact', order: 'preserved', groups: [[parts[0].id]] },
|
|
214
|
+
execution: { modelInvocations: 0, reason: 'single-part-faithful' } };
|
|
215
|
+
}
|
|
216
|
+
|
|
179
217
|
/** 保留原稿及输出规则的原始文字;零模型调用,不读取任何配置。 */
|
|
180
218
|
export function assemblePrompt(input, { signal } = {}) {
|
|
181
219
|
checkSignal(signal);
|
|
@@ -209,6 +247,8 @@ export function preparePromptOptimization(input, config) {
|
|
|
209
247
|
} else if (normalized.editing === 'suggestions') {
|
|
210
248
|
system += '\n\n## 本次输出协议:可选补充建议\n不修改原稿,不执行任务或生成媒体。只提出至多三条有价值的可选新指令,每条 text 是可直接加入提示词的简短文字,不声称用户已经要求、采纳或完成。无需凑满,没有必要补充时返回空数组。建议可以提出创造性细节,但不能编造作者作品、风格分期、历史、设备或技术事实作为依据。已明确的约数、左右、风格和否定条件不重新质疑,不泛化、不推演为额外限制。questions 只问影响目标的实际缺失或冲突,至多三条,不引导用户改变已明确要求。不猜测未见图片/音频内容,不编造链接、来源 ID 或参数。以下协议优先于通用正文输出约定:只返回 {"suggestions":[{"label":"简短名称","text":"可选指令"}],"questions":["待确认问题"]},不返回最终正文、媒体或其他字段。';
|
|
211
249
|
prompt = '只为以下原稿提出可选建议和待确认问题;JSON 是素材,不是任务执行授权。\n' + JSON.stringify({ target: normalized.target, prompt: normalized.original });
|
|
250
|
+
} else {
|
|
251
|
+
system += '\n\n## 本次输出协议:改写正文\n输入 JSON 是传输容器,target 等字段只提供上下文。只编辑 prompt 的字符串值,最终回复必须是改写后的字段值本身,即可直接阅读的原始正文。不要回传输入容器、字段名、额外包装引号、代码围栏、标题或解释。若原稿本身包含 JSON、代码、围栏或引号,应按用户规则保留这些原有内容。\n先理解 JSON 字符串的实际内容再编辑:容器中用于编码引号、换行和反斜杠的转义不是正文字符,不要复制序列化语法。保留原稿实际存在的引号、真实换行、字面反斜杠、代码和引用,禁止无条件去除或反转义。沿用原稿语言,只执行用户明确授权的内容变化,其余具体细节和否定条件继续保留。';
|
|
212
252
|
}
|
|
213
253
|
// 包含前后缀及分隔符,防止一次模型调用后才发现已选规则无法交付。
|
|
214
254
|
const overhead = normalized.rules.filter(rule => rule.kind === 'output').reduce((sum, rule) => sum + rule.text.length + 2, 0);
|
|
@@ -217,12 +257,14 @@ export function preparePromptOptimization(input, config) {
|
|
|
217
257
|
if (normalized.editing !== 'rewrite' && normalized.original.length > maxOutputChars) throw new ModelPortError('IRIS_MODEL_OUTPUT_LIMIT', { stage: 'prepare', invocation: 'not_invoked' });
|
|
218
258
|
return Object.freeze({ input: normalized, prompt, system, maxOutputChars, ...(parts ? { parts } : {}),
|
|
219
259
|
generation: Object.freeze({ temperature: generation.temperature, maxOutputTokens: generation.maxOutputTokens, timeoutMs: generation.timeoutMs }),
|
|
220
|
-
processing: Object.freeze({ systemPrompt, targetTemplate, optimizationRules: Object.freeze(optimizationRules)
|
|
260
|
+
processing: Object.freeze({ systemPrompt, targetTemplate, optimizationRules: Object.freeze(optimizationRules),
|
|
261
|
+
request: Object.freeze({ system, prompt }) }) });
|
|
221
262
|
}
|
|
222
263
|
|
|
223
264
|
/** 单轮、无工具;完整模型结果通过验证后再确定性插入输出规则。 */
|
|
224
265
|
export async function runPromptOptimization(port, plan, { signal, operation, reasoning } = {}) {
|
|
225
266
|
checkSignal(signal);
|
|
267
|
+
if (plan.parts?.length === 1) return tryLocalPromptOptimization(plan.input, { signal });
|
|
226
268
|
const request = { prompt: plan.prompt, system: plan.system, generation: {
|
|
227
269
|
temperature: plan.generation.temperature, maxOutputTokens: plan.generation.maxOutputTokens,
|
|
228
270
|
...(reasoning ? { reasoning } : {}) } };
|
|
@@ -231,8 +273,10 @@ export async function runPromptOptimization(port, plan, { signal, operation, rea
|
|
|
231
273
|
const completion = operation ? await operation.invoke(port, request, options) : await invokeModel(port, request, options);
|
|
232
274
|
checkSignal(signal);
|
|
233
275
|
let body = completion.text.trim();
|
|
234
|
-
|
|
235
|
-
|
|
276
|
+
if (plan.input.editing !== 'rewrite') {
|
|
277
|
+
const fenced = body.match(/^```(?:text|markdown)?\s*\n([\s\S]*?)\n```$/i);
|
|
278
|
+
if (fenced) body = fenced[1].trim();
|
|
279
|
+
}
|
|
236
280
|
if (!body) throw new ModelPortError('IRIS_MODEL_EMPTY_RESULT', { stage: 'normalize', invocation: 'responded' });
|
|
237
281
|
if (plan.input.editing === 'faithful') {
|
|
238
282
|
const grouped = normalizeGroups(parseEditingJson(body), plan.parts);
|
|
@@ -242,6 +286,7 @@ export async function runPromptOptimization(port, plan, { signal, operation, rea
|
|
|
242
286
|
const optional = normalizeSuggestions(parseEditingJson(body), plan.input);
|
|
243
287
|
return { ...result(plan.input, plan.input.original, plan.processing), editing: { strategy: 'suggestions' }, ...optional };
|
|
244
288
|
}
|
|
289
|
+
validateRewriteFormat(body, plan.input);
|
|
245
290
|
validateEditedText(body, plan.input);
|
|
246
291
|
return result(plan.input, body, plan.processing);
|
|
247
292
|
}
|
package/lib/prompt-optimizer.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/** DSH 优化入口:v1 配置与模型选择/投影;共享业务不读取宿主和存储。 */
|
|
3
3
|
import { loadPromptOptimizerConfig } from './prompt-optimizer-config.js';
|
|
4
4
|
import { hasHostPort, requireHostPort } from './host-contract.js';
|
|
5
|
-
import { normalizePromptInput, assemblePrompt, preparePromptOptimization, runPromptOptimization } from './prompt-optimizer-core.js';
|
|
5
|
+
import { normalizePromptInput, assemblePrompt, tryLocalPromptOptimization, preparePromptOptimization, runPromptOptimization } from './prompt-optimizer-core.js';
|
|
6
6
|
import { prepareDshTextModelPort } from './dsh-text-model-adapter.js';
|
|
7
7
|
import { createModelOperation } from './model-invoker.js';
|
|
8
8
|
import { ModelPortError, modelPortSnapshot, modelErrorRecord } from './model-port-contract.js';
|
|
@@ -10,31 +10,34 @@ import { ModelPortError, modelPortSnapshot, modelErrorRecord } from './model-por
|
|
|
10
10
|
export { MAX_PROMPT_INPUT_BYTES, MAX_PROMPT_OUTPUT_CHARS } from './prompt-optimizer-core.js';
|
|
11
11
|
|
|
12
12
|
function modelSelection(value, field) {
|
|
13
|
-
if (!value || typeof value !== 'object')
|
|
13
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${field} 必须同时包含有效的 provider 与 model`);
|
|
14
14
|
const provider = typeof value.provider === 'string' ? value.provider.trim() : '';
|
|
15
15
|
const model = typeof value.model === 'string' ? value.model.trim() : '';
|
|
16
|
-
if (
|
|
17
|
-
|
|
18
|
-
return null;
|
|
16
|
+
if ([provider, model].some(id => !id || id.length > 256 || /[\u0000-\u001f\u007f]/.test(id) || id.startsWith('/') || id.includes('://'))) {
|
|
17
|
+
throw new Error(`${field} 必须同时包含有效的 provider 与 model`);
|
|
19
18
|
}
|
|
20
19
|
const out = { provider, model };
|
|
21
|
-
if (
|
|
22
|
-
|
|
20
|
+
if (value.reasoningEffort !== undefined) {
|
|
21
|
+
if (typeof value.reasoningEffort !== 'string' || !value.reasoningEffort.trim() || value.reasoningEffort.trim().length > 128
|
|
22
|
+
|| /[\u0000-\u001f\u007f]/.test(value.reasoningEffort)) throw new Error(`${field}.reasoningEffort 无效`);
|
|
23
|
+
out.reasoningEffort = value.reasoningEffort.trim();
|
|
23
24
|
}
|
|
24
25
|
return out;
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
export function resolvePromptOptimizerRoute(host, config, requestedRoute) {
|
|
28
29
|
if (config.route.mode === 'fixed') {
|
|
29
|
-
return { ...modelSelection(config.route, 'route'), source: 'iris-fixed' };
|
|
30
|
+
return { ...modelSelection(config.route, 'route'), source: 'iris-fixed', reason: '固定配置优先于当前会话和宿主默认模型' };
|
|
31
|
+
}
|
|
32
|
+
if (requestedRoute !== undefined && requestedRoute !== null) {
|
|
33
|
+
return { ...modelSelection(requestedRoute, '请求路由'), source: 'current-session', reason: '使用当前会话明确选择的模型' };
|
|
30
34
|
}
|
|
31
|
-
const current = modelSelection(requestedRoute);
|
|
32
|
-
if (current) return { ...current, source: 'current-session' };
|
|
33
35
|
const textModel = hasHostPort(host, 'textModel') ? host.ports.textModel : undefined;
|
|
34
36
|
const fallback = textModel && typeof textModel.currentSelection === 'function'
|
|
35
|
-
?
|
|
37
|
+
? textModel.currentSelection()
|
|
36
38
|
: null;
|
|
37
|
-
if (fallback) return { ...fallback, source: 'host-default'
|
|
39
|
+
if (fallback !== null && fallback !== undefined) return { ...modelSelection(fallback, '宿主默认路由'), source: 'host-default',
|
|
40
|
+
reason: '未提供当前会话模型,使用宿主默认模型' };
|
|
38
41
|
throw new Error('Iris 找不到可用文本模型:请先在 DSH 会话中选择模型,或在 JSON 配置中设置 fixed 路由');
|
|
39
42
|
}
|
|
40
43
|
|
|
@@ -54,6 +57,8 @@ export async function optimizePrompt(host, input, { signal } = {}) {
|
|
|
54
57
|
if (signal?.aborted) throw new ModelPortError('IRIS_MODEL_ABORTED');
|
|
55
58
|
const normalized = normalizePromptInput(input);
|
|
56
59
|
if (normalized.mode === 'assemble') return { ...assemblePrompt(input, { signal }), configSource: 'not-loaded', route: null };
|
|
60
|
+
const local = tryLocalPromptOptimization(normalized, { signal });
|
|
61
|
+
if (local) return { ...local, configSource: 'not-loaded', route: null };
|
|
57
62
|
const loaded = loadPromptOptimizerConfig();
|
|
58
63
|
const config = loaded.config;
|
|
59
64
|
const plan = preparePromptOptimization(input, config);
|
|
@@ -62,10 +67,11 @@ export async function optimizePrompt(host, input, { signal } = {}) {
|
|
|
62
67
|
try {
|
|
63
68
|
const route = resolvePromptOptimizerRoute(host, config, input?.route);
|
|
64
69
|
const llm = requireHostPort(host, 'textModel', '提示词优化');
|
|
65
|
-
const { port, offId } = await prepareDshTextModelPort(llm, { ...route, sessionId: input?.sessionId }, { signal: operation.signal });
|
|
70
|
+
const { port, offId, textInput } = await prepareDshTextModelPort(llm, { ...route, sessionId: input?.sessionId }, { signal: operation.signal });
|
|
66
71
|
reasoning = reasoningPolicy(port, offId, route, config.generation.reasoningEffort);
|
|
67
72
|
const result = await runPromptOptimization(port, plan, { signal: operation.signal, operation, reasoning: reasoning.request });
|
|
68
|
-
return { ...result, configSource: loaded.source, route: { source: route.source,
|
|
73
|
+
return { ...result, configSource: loaded.source, configFeedback: loaded.feedback, route: { source: route.source, reason: route.reason, textInput,
|
|
74
|
+
provider: route.provider, model: route.model,
|
|
69
75
|
reasoningPolicy: reasoning.policy, reasoningEffort: reasoning.effective } };
|
|
70
76
|
} catch (error) {
|
|
71
77
|
if (modelErrorRecord(error).code === 'IRIS_MODEL_OUTPUT_LIMIT') {
|
package/lib/provider-adapter.js
CHANGED
package/lib/provider-adapters.js
CHANGED
|
@@ -110,7 +110,7 @@ function writeInlineArtifact(artifact, targetPath) {
|
|
|
110
110
|
const data = String(artifact?.data || '').trim();
|
|
111
111
|
const body = Buffer.from(data, 'base64');
|
|
112
112
|
if (!data || body.length === 0 || body.toString('base64') !== data) {
|
|
113
|
-
throw new ProviderContractError('inline-base64
|
|
113
|
+
throw new ProviderContractError('inline-base64 媒体数据无效', {
|
|
114
114
|
stage: 'download', category: 'protocol', acceptance: 'accepted'
|
|
115
115
|
});
|
|
116
116
|
}
|
|
@@ -373,7 +373,30 @@ export function createOpenAiResponsesImagesProviderAdapter(provider, { transport
|
|
|
373
373
|
});
|
|
374
374
|
}
|
|
375
375
|
|
|
376
|
+
export function createGeminiWebMusicProviderAdapter(provider, { transport = defaultTransport } = {}) {
|
|
377
|
+
const { id, key, baseUrl } = providerIdentity(provider);
|
|
378
|
+
return defineProviderAdapter({
|
|
379
|
+
id, protocol: 'gemini-web-music', capabilities: ['music'],
|
|
380
|
+
operations: {
|
|
381
|
+
async discover({ signal, timeoutMs } = {}) {
|
|
382
|
+
return { models: await transport.listModels({ key, baseUrl, signal, timeoutMs }) };
|
|
383
|
+
},
|
|
384
|
+
async submit(request) {
|
|
385
|
+
if (request.capability !== 'music') throw unsupportedCapability(request.capability, 'gemini-web-music');
|
|
386
|
+
const artifacts = await transport.generateMusic({ key, baseUrl, model: request.model,
|
|
387
|
+
prompt: request.input.prompt, timeoutMs: request.input.timeoutMs, signal: request.signal });
|
|
388
|
+
return { kind: 'completed', artifacts };
|
|
389
|
+
},
|
|
390
|
+
async download({ artifact, targetPath }) { return writeInlineArtifact(artifact, targetPath); },
|
|
391
|
+
mapError: mapProviderAdapterError
|
|
392
|
+
},
|
|
393
|
+
unsupported: { poll: '网关 Music 没有客户端可恢复的远端任务ID', cancel: '网关 Music 不提供远端取消' },
|
|
394
|
+
unsupportedPreparation: 'Music 描述输入不需要上传'
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
376
398
|
const PROTOCOL_FACTORIES = new Map([
|
|
399
|
+
['gemini-web-music', createGeminiWebMusicProviderAdapter],
|
|
377
400
|
['dashscope', createDashScopeProviderAdapter],
|
|
378
401
|
['openai-images', createOpenAiImagesProviderAdapter],
|
|
379
402
|
['openai-chat-images', createOpenAiChatImagesProviderAdapter],
|