@mokuyoaxis/dsh-iris 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +72 -1
- package/README.en.md +245 -0
- package/README.md +81 -13
- package/cordis.patch.yml +1 -1
- package/docs/ROADMAP.md +49 -21
- package/docs/assets/diagrams/iris-architecture.drawio +1 -0
- package/docs/assets/diagrams/iris-architecture.png +0 -0
- package/docs/assets/diagrams/iris-architecture.svg +63 -0
- package/docs/assets/diagrams/iris-task-lifecycle.drawio +1 -0
- package/docs/assets/diagrams/iris-task-lifecycle.png +0 -0
- package/docs/assets/diagrams/iris-task-lifecycle.svg +64 -0
- package/docs/assets/diagrams/iris-workflow-compose.drawio +1 -0
- package/docs/assets/diagrams/iris-workflow-compose.png +0 -0
- package/docs/assets/diagrams/iris-workflow-compose.svg +41 -0
- package/docs/assets/examples/iris-flower-generated.webp +0 -0
- package/docs/assets/screenshots/image-task-succeeded.webp +0 -0
- package/docs/assets/screenshots/prompt-general-after.webp +0 -0
- package/docs/assets/screenshots/prompt-general-before.webp +0 -0
- package/docs/assets/screenshots/prompt-general-preview.webp +0 -0
- package/docs/assets/screenshots/prompt-image-before.webp +0 -0
- package/docs/assets/screenshots/prompt-image-preview.webp +0 -0
- package/docs/releases/0.1.1.md +51 -0
- package/docs/releases/0.1.2.md +26 -0
- package/docs/screenshots.md +34 -0
- package/lib/actions.js +33 -27
- package/lib/adapters.js +42 -26
- package/lib/api.js +73 -20
- package/lib/bundled-skills.js +62 -0
- package/lib/capability.js +1 -1
- package/lib/client.js +378 -26
- package/lib/config.js +56 -22
- package/lib/guard.js +37 -24
- package/lib/index.js +21 -9
- package/lib/locate.js +1 -1
- package/lib/media-probe.js +4 -4
- package/lib/media.js +1 -1
- package/lib/private-storage.js +77 -0
- package/lib/prompt-optimizer-config.js +170 -0
- package/lib/prompt-optimizer.js +244 -0
- package/lib/provider-protocol.js +25 -0
- package/lib/render.js +3 -2
- package/lib/tasks.js +21 -13
- package/lib/vision.js +2 -2
- package/package.json +25 -17
- package/screenshots.json +6 -0
- package/user_guide.md +97 -11
package/lib/actions.js
CHANGED
|
@@ -24,6 +24,8 @@ import { BrowserHtmlRenderer } from './render.js';
|
|
|
24
24
|
import { longOcr } from './ocr.js';
|
|
25
25
|
import { ffmpegAvailable, extractFrames, probeVideo } from './media-probe.js';
|
|
26
26
|
import { buildContactSheet, summarizeMedia } from './summarize.js';
|
|
27
|
+
import { ensurePrivateDir, writePrivateFile } from './private-storage.js';
|
|
28
|
+
import { inferMediaProtocol } from './provider-protocol.js';
|
|
27
29
|
|
|
28
30
|
const MEDIA_TYPES = {
|
|
29
31
|
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
@@ -49,8 +51,7 @@ async function describeFirstImage(ctx, ref, absPath, originalPrompt) {
|
|
|
49
51
|
/* ---------------- IO 目录 ---------------- */
|
|
50
52
|
function outputDir() {
|
|
51
53
|
const dir = path.join(store.irisHome(), 'outputs');
|
|
52
|
-
|
|
53
|
-
return dir;
|
|
54
|
+
return ensurePrivateDir(dir);
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
function throwIfAborted(signal) {
|
|
@@ -160,7 +161,7 @@ register('image', async (ctx, args, { signal }) => {
|
|
|
160
161
|
const prompt = String(args.prompt || '').trim();
|
|
161
162
|
if (!prompt) throw new Error('iris: prompt 不能为空');
|
|
162
163
|
const submitted = await submitWithFailover(cap.CAPABILITIES.IMAGE, '图像', signal, args.model, async (provider) => {
|
|
163
|
-
const protocol =
|
|
164
|
+
const protocol = provider.mediaProtocol || inferMediaProtocol(provider.baseUrl);
|
|
164
165
|
const model = args.model ? (models.parseModelRef(String(args.model))?.modelId || String(args.model)) : (provider.imageModel || (protocol === 'openai-images' ? 'gpt-image-1' : 'wan2.2-t2i-flash'));
|
|
165
166
|
const task = tasks.create({
|
|
166
167
|
cap: 'image', providerId: provider.id, providerName: provider.name,
|
|
@@ -168,7 +169,7 @@ register('image', async (ctx, args, { signal }) => {
|
|
|
168
169
|
});
|
|
169
170
|
if (protocol === 'dashscope') {
|
|
170
171
|
const started = await tasks.submitGuard(task, () => adapters.startImageGeneration({
|
|
171
|
-
key: provider.apiKey, model, prompt, size: args.size, n: args.n, signal
|
|
172
|
+
key: provider.apiKey, baseUrl: provider.baseUrl, model, prompt, size: args.size, n: args.n, signal
|
|
172
173
|
}));
|
|
173
174
|
const remoteTaskId = started.remoteTaskId;
|
|
174
175
|
if (!remoteTaskId) {
|
|
@@ -190,7 +191,7 @@ register('image', async (ctx, args, { signal }) => {
|
|
|
190
191
|
tasks.update(task.id, { remoteTaskId });
|
|
191
192
|
tasks.watch(tasks.get(task.id), {
|
|
192
193
|
key: () => provider.apiKey,
|
|
193
|
-
poll: ({ key, remoteTaskId }) => adapters.pollTask({ key, remoteTaskId }),
|
|
194
|
+
poll: ({ key, remoteTaskId }) => adapters.pollTask({ key, baseUrl: provider.baseUrl, remoteTaskId }),
|
|
194
195
|
intervalMs: 2500,
|
|
195
196
|
onSuccess: async (t, r) => {
|
|
196
197
|
const names = [];
|
|
@@ -212,7 +213,7 @@ register('image', async (ctx, args, { signal }) => {
|
|
|
212
213
|
const written = [];
|
|
213
214
|
for (let i = 0; i < outs.length; i++) {
|
|
214
215
|
const p = path.join(outputDir(), `${task.id}-${i}.png`);
|
|
215
|
-
if (outs[i].b64)
|
|
216
|
+
if (outs[i].b64) writePrivateFile(p, Buffer.from(outs[i].b64, 'base64'));
|
|
216
217
|
else await adapters.downloadTo(outs[i].url, p, { signal });
|
|
217
218
|
written.push(p);
|
|
218
219
|
}
|
|
@@ -255,8 +256,8 @@ register('video', async (ctx, args, { signal }) => {
|
|
|
255
256
|
const audioPath = String(args.audio_path || '').trim();
|
|
256
257
|
if (!audioPath || !path.isAbsolute(audioPath)) throw new Error('iris: s2v 的 audio_path 必须是绝对路径');
|
|
257
258
|
if (!fs.existsSync(audioPath)) throw new Error('iris: 音频文件不存在:' + audioPath);
|
|
258
|
-
const imageUrl = await adapters.uploadTempFile({ key: provider.apiKey, model, filePath: frame.absPath, signal });
|
|
259
|
-
const audioUrl = await adapters.uploadTempFile({ key: provider.apiKey, model, filePath: audioPath, signal });
|
|
259
|
+
const imageUrl = await adapters.uploadTempFile({ key: provider.apiKey, baseUrl: provider.baseUrl, model, filePath: frame.absPath, signal });
|
|
260
|
+
const audioUrl = await adapters.uploadTempFile({ key: provider.apiKey, baseUrl: provider.baseUrl, model, filePath: audioPath, signal });
|
|
260
261
|
submitArgs = { imgDataUrl: imageUrl, audioUrl, resolution: args.resolution || '480P' };
|
|
261
262
|
} else {
|
|
262
263
|
submitArgs = {
|
|
@@ -266,12 +267,12 @@ register('video', async (ctx, args, { signal }) => {
|
|
|
266
267
|
duration: args.duration ? Number(args.duration) : undefined
|
|
267
268
|
};
|
|
268
269
|
}
|
|
269
|
-
return adapters.submitVideo({ key: provider.apiKey, model, ...submitArgs, signal });
|
|
270
|
+
return adapters.submitVideo({ key: provider.apiKey, baseUrl: provider.baseUrl, model, ...submitArgs, signal });
|
|
270
271
|
});
|
|
271
272
|
tasks.update(task.id, { remoteTaskId });
|
|
272
273
|
tasks.watch(tasks.get(task.id), {
|
|
273
274
|
key: () => provider.apiKey,
|
|
274
|
-
poll: ({ key, remoteTaskId: id }) => adapters.pollTask({ key, remoteTaskId: id }),
|
|
275
|
+
poll: ({ key, remoteTaskId: id }) => adapters.pollTask({ key, baseUrl: provider.baseUrl, remoteTaskId: id }),
|
|
275
276
|
intervalMs: 6000,
|
|
276
277
|
onSuccess: async (t, r) => {
|
|
277
278
|
const names = [];
|
|
@@ -302,11 +303,11 @@ register('tts', async (ctx, args, { signal }) => {
|
|
|
302
303
|
const voice = args.voice || (provider.ttsVoice || 'Cherry');
|
|
303
304
|
const task = tasks.create({ cap: 'tts', providerId: provider.id, providerName: provider.name, model, voice, prompt: text.slice(0, 500) });
|
|
304
305
|
const fileName = await tasks.submitGuard(task, async () => {
|
|
305
|
-
const r = await adapters.synthesizeTts({ key: provider.apiKey, model, text, voice, signal });
|
|
306
|
+
const r = await adapters.synthesizeTts({ key: provider.apiKey, baseUrl: provider.baseUrl, model, text, voice, signal });
|
|
306
307
|
const name = `iris-tts-${Date.now()}.${r.audioUrl ? adapters.extFromUrl(r.audioUrl, 'wav') : 'wav'}`;
|
|
307
308
|
const p = path.join(outputDir(), name);
|
|
308
309
|
if (r.audioUrl) await adapters.downloadTo(r.audioUrl, p, { signal });
|
|
309
|
-
else
|
|
310
|
+
else writePrivateFile(p, Buffer.from(r.audioB64, 'base64'));
|
|
310
311
|
return name;
|
|
311
312
|
});
|
|
312
313
|
tasks.update(task.id, { status: 'succeeded', files: [fileName], finishedAt: new Date().toISOString() });
|
|
@@ -530,7 +531,7 @@ register('providers_list', async (ctx, args) => {
|
|
|
530
531
|
type: p.type || 'openai',
|
|
531
532
|
baseUrl: p.baseUrl,
|
|
532
533
|
enabled: p.enabled !== false,
|
|
533
|
-
mediaProtocol: p.mediaProtocol ||
|
|
534
|
+
mediaProtocol: p.mediaProtocol || inferMediaProtocol(p.baseUrl),
|
|
534
535
|
apiKeyHint: String(p.apiKey || '').length <= 8 ? '****' : String(p.apiKey).slice(0, 3) + '****' + String(p.apiKey).slice(-4),
|
|
535
536
|
models: models.providerModels(p).map((m) => ({ id: m.id, capabilities: m.capabilities, verified: m.verified || null, source: m.source || 'auto' })),
|
|
536
537
|
capabilities: store.capabilitiesOf(p)
|
|
@@ -599,6 +600,7 @@ register('providers_test_vision', async (ctx, args) => {
|
|
|
599
600
|
const backends = buildVisionBackends(ctx, { providers: visionProviders });
|
|
600
601
|
const backend = backends[0];
|
|
601
602
|
if (!backend) return { ok: true, tested: false, text: '该供应商的 vision 配置不可用(类型非 openai)' };
|
|
603
|
+
if (args.confirm_paid !== true) throw new Error('iris: 视觉实测会发起真实供应商请求,请先明确确认');
|
|
602
604
|
const r = await testVisionCapability(backend, { timeoutMs: 15000, signal: args._signal });
|
|
603
605
|
return {
|
|
604
606
|
ok: true,
|
|
@@ -612,7 +614,7 @@ register('providers_test_vision', async (ctx, args) => {
|
|
|
612
614
|
|
|
613
615
|
/* ============ 模型池逐模型管理(阶段 9 P3:手动增删改 + 逐能力实测) ============ */
|
|
614
616
|
|
|
615
|
-
/** 按能力实测单个模型;返回 {ok, note}
|
|
617
|
+
/** 按能力实测单个模型;返回 {ok, note}。真实调用仅在 UI 明确确认后执行。 */
|
|
616
618
|
async function probeModel(ctx, p, modelId, capability) {
|
|
617
619
|
if (capability === cap.CAPABILITIES.VISION) {
|
|
618
620
|
const backend = buildVisionBackends(ctx, { providers: [p], model: modelId })[0];
|
|
@@ -621,34 +623,32 @@ async function probeModel(ctx, p, modelId, capability) {
|
|
|
621
623
|
return { ok: r.ok, note: r.ok ? '认出红色测试图' : (r.error || '未识别出红色') };
|
|
622
624
|
}
|
|
623
625
|
if (capability === cap.CAPABILITIES.TRANSCRIBE) {
|
|
624
|
-
|
|
626
|
+
return { skipped: true, ok: false, note: '需真实音频,请通过“音频转写”动作验证' };
|
|
625
627
|
}
|
|
626
628
|
if (capability === cap.CAPABILITIES.TTS) {
|
|
627
|
-
const r = await adapters.synthesizeTts({ key: p.apiKey, model: modelId, text: '你好', voice: 'Cherry' });
|
|
629
|
+
const r = await adapters.synthesizeTts({ key: p.apiKey, baseUrl: p.baseUrl, model: modelId, text: '你好', voice: 'Cherry' });
|
|
628
630
|
const ok = !!(r.audioUrl || r.audioB64);
|
|
629
631
|
return { ok, note: ok ? '合成返回音频' : '无音频返回' };
|
|
630
632
|
}
|
|
631
633
|
if (capability === cap.CAPABILITIES.IMAGE) {
|
|
632
|
-
if ((p.mediaProtocol ||
|
|
634
|
+
if ((p.mediaProtocol || inferMediaProtocol(p.baseUrl)) === 'openai-images') {
|
|
633
635
|
const outs = await adapters.openAiGenerateImage({ key: p.apiKey, baseUrl: p.baseUrl, model: modelId, prompt: 'red circle', size: '256x256', n: 1 });
|
|
634
636
|
return { ok: outs.length > 0, note: outs.length ? '生成返回图片' : '无图片返回' };
|
|
635
637
|
}
|
|
636
|
-
const started = await adapters.startImageGeneration({ key: p.apiKey, model: modelId, prompt: '红色圆形', size: '512*512', n: 1 });
|
|
638
|
+
const started = await adapters.startImageGeneration({ key: p.apiKey, baseUrl: p.baseUrl, model: modelId, prompt: '红色圆形', size: '512*512', n: 1 });
|
|
637
639
|
if (!started.remoteTaskId) {
|
|
638
640
|
return { ok: (started.urls || []).length > 0, note: (started.urls || []).length ? '生成成功' : '无图片返回' };
|
|
639
641
|
}
|
|
640
642
|
// 有界轮询到终态(图像通常 5–15s)
|
|
641
643
|
for (let i = 0; i < 20; i++) {
|
|
642
644
|
await new Promise((r) => setTimeout(r, 3000));
|
|
643
|
-
const st = await adapters.pollTask({ key: p.apiKey, remoteTaskId: started.remoteTaskId });
|
|
645
|
+
const st = await adapters.pollTask({ key: p.apiKey, baseUrl: p.baseUrl, remoteTaskId: started.remoteTaskId });
|
|
644
646
|
if (st.done) return { ok: st.ok && (st.urls || []).length > 0, note: st.ok ? '生成成功' : (st.message || '生成失败') };
|
|
645
647
|
}
|
|
646
648
|
return { ok: false, note: '生成超时(>60s)' };
|
|
647
649
|
}
|
|
648
650
|
if (capability === cap.CAPABILITIES.VIDEO) {
|
|
649
|
-
|
|
650
|
-
const taskId = await adapters.submitVideo({ key: p.apiKey, model: modelId, prompt: 'test', size: '480*832' });
|
|
651
|
-
return { ok: !!taskId, note: taskId ? '提交受理(未等成片)' : '无 task_id' };
|
|
651
|
+
return { skipped: true, ok: false, note: '视频提交即可能计费,请通过“视频生成”动作验证' };
|
|
652
652
|
}
|
|
653
653
|
return { ok: false, note: '未知能力' };
|
|
654
654
|
}
|
|
@@ -688,12 +688,18 @@ register('providers_test_model', async (ctx, args) => {
|
|
|
688
688
|
const modelId = String(args.model_id || '').trim();
|
|
689
689
|
const capability = String(args.capability || '').trim();
|
|
690
690
|
if (!modelId || !cap.isKnownCapability(capability)) throw new Error('iris: 需 model_id 与合法 capability');
|
|
691
|
+
if (![cap.CAPABILITIES.VIDEO, cap.CAPABILITIES.TRANSCRIBE].includes(capability) && args.confirm_paid !== true) {
|
|
692
|
+
throw new Error('iris: 模型实测会发起真实供应商请求,请先明确确认');
|
|
693
|
+
}
|
|
691
694
|
let result;
|
|
692
695
|
try {
|
|
693
696
|
result = await probeModel(ctx, p, modelId, capability);
|
|
694
697
|
} catch (err) {
|
|
695
698
|
result = { ok: false, note: String(err.message || err) };
|
|
696
699
|
}
|
|
700
|
+
if (result.skipped) {
|
|
701
|
+
return { ok: true, skipped: true, passed: false, text: `[iris] ${modelId} · ${capability} 已跳过:${result.note}` };
|
|
702
|
+
}
|
|
697
703
|
store.setModelVerified(p.id, modelId, capability, result);
|
|
698
704
|
return {
|
|
699
705
|
ok: true,
|
|
@@ -832,8 +838,8 @@ register('attachment_export', async (ctx, args) => {
|
|
|
832
838
|
} catch (_) { /* ignore */ }
|
|
833
839
|
if (!ref) throw new Error('iris: 该 attachment 不在本会话中、也不是 iris 产物:' + id);
|
|
834
840
|
const stored = await att.readImage({ attachmentId: ref.attachmentId, mediaType: ref.mediaType }, null);
|
|
835
|
-
|
|
836
|
-
|
|
841
|
+
ensurePrivateDir(uploads);
|
|
842
|
+
writePrivateFile(cached, Buffer.from(stored.data));
|
|
837
843
|
return { ok: true, path: cached, name: ref.name || path.basename(cached) };
|
|
838
844
|
});
|
|
839
845
|
|
|
@@ -850,14 +856,14 @@ register('transcribe', async (ctx, args, { signal }) => {
|
|
|
850
856
|
model, prompt: 'audio transcribe: ' + path.basename(audioPath)
|
|
851
857
|
});
|
|
852
858
|
const remoteTaskId = await tasks.submitGuard(task, async () => {
|
|
853
|
-
const audioUrl = await adapters.uploadTempFile({ key: provider.apiKey, model, filePath: audioPath, signal });
|
|
854
|
-
return adapters.submitTranscription({ key: provider.apiKey, model, audioUrl, signal });
|
|
859
|
+
const audioUrl = await adapters.uploadTempFile({ key: provider.apiKey, baseUrl: provider.baseUrl, model, filePath: audioPath, signal });
|
|
860
|
+
return adapters.submitTranscription({ key: provider.apiKey, baseUrl: provider.baseUrl, model, audioUrl, signal });
|
|
855
861
|
});
|
|
856
862
|
tasks.update(task.id, { remoteTaskId });
|
|
857
863
|
tasks.watch(tasks.get(task.id), {
|
|
858
864
|
key: () => provider.apiKey,
|
|
859
865
|
poll: async ({ key, remoteTaskId: id }) => {
|
|
860
|
-
const r = await adapters.pollTranscriptionTask({ key, remoteTaskId: id });
|
|
866
|
+
const r = await adapters.pollTranscriptionTask({ key, baseUrl: provider.baseUrl, remoteTaskId: id });
|
|
861
867
|
return { done: r.done, ok: r.ok, urls: r.text ? [r.text] : [], message: r.message };
|
|
862
868
|
},
|
|
863
869
|
intervalMs: 2500,
|
package/lib/adapters.js
CHANGED
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import fs from 'node:fs';
|
|
9
9
|
import path from 'node:path';
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
import { Readable } from 'node:stream';
|
|
11
|
+
import { pipeline } from 'node:stream/promises';
|
|
12
|
+
import { dashscopeApiBase } from './provider-protocol.js';
|
|
13
|
+
import { chmodPrivateFile, ensurePrivateDir, privateSibling } from './private-storage.js';
|
|
12
14
|
|
|
13
15
|
/**
|
|
14
16
|
* 网络调用超时档位(2026-09-03 健康检查):挂死的连接绝不能冻住盯守 tick——
|
|
@@ -111,8 +113,9 @@ export function dashscopeImageMode(model) {
|
|
|
111
113
|
}
|
|
112
114
|
|
|
113
115
|
/** 新版多模态同步协议:响应中直接返回图片 URL。 */
|
|
114
|
-
export async function generateImageMultimodal({ key, model, prompt, size, n = 1, timeoutMs = T_SYNC_GEN, signal }) {
|
|
115
|
-
const
|
|
116
|
+
export async function generateImageMultimodal({ key, baseUrl, model, prompt, size, n = 1, timeoutMs = T_SYNC_GEN, signal }) {
|
|
117
|
+
const apiBase = dashscopeApiBase(baseUrl);
|
|
118
|
+
const res = await fetch(`${apiBase}/services/aigc/multimodal-generation/generation`, {
|
|
116
119
|
method: 'POST',
|
|
117
120
|
headers: authHeaders(key),
|
|
118
121
|
signal: timeoutSignal(timeoutMs, signal),
|
|
@@ -142,8 +145,9 @@ export async function startImageGeneration(opts) {
|
|
|
142
145
|
}
|
|
143
146
|
return { remoteTaskId: await submitImage(opts), urls: [] };
|
|
144
147
|
}
|
|
145
|
-
export async function submitImage({ key, model, prompt, size, n = 1, timeoutMs = T_SUBMIT, signal }) {
|
|
146
|
-
const
|
|
148
|
+
export async function submitImage({ key, baseUrl, model, prompt, size, n = 1, timeoutMs = T_SUBMIT, signal }) {
|
|
149
|
+
const apiBase = dashscopeApiBase(baseUrl);
|
|
150
|
+
const res = await fetch(`${apiBase}/services/aigc/text2image/image-synthesis`, {
|
|
147
151
|
method: 'POST',
|
|
148
152
|
headers: { ...authHeaders(key), 'X-DashScope-Async': 'enable' },
|
|
149
153
|
signal: timeoutSignal(timeoutMs, signal),
|
|
@@ -165,12 +169,13 @@ export async function submitImage({ key, model, prompt, size, n = 1, timeoutMs =
|
|
|
165
169
|
* - 其他(t2v/i2v):POST /services/aigc/video-generation/video-synthesis,
|
|
166
170
|
* imgDataUrl 支持公网 URL 或 data:image base64。
|
|
167
171
|
*/
|
|
168
|
-
export async function submitVideo({ key, model, prompt, imgDataUrl, size = '1280*720', duration, audioUrl, resolution, timeoutMs = T_SUBMIT, signal }) {
|
|
172
|
+
export async function submitVideo({ key, baseUrl, model, prompt, imgDataUrl, size = '1280*720', duration, audioUrl, resolution, timeoutMs = T_SUBMIT, signal }) {
|
|
173
|
+
const apiBase = dashscopeApiBase(baseUrl);
|
|
169
174
|
const isS2V = /s2v/i.test(model || '');
|
|
170
175
|
let url, input, parameters;
|
|
171
176
|
const headers = { ...authHeaders(key), 'X-DashScope-Async': 'enable' };
|
|
172
177
|
if (isS2V) {
|
|
173
|
-
url = `${
|
|
178
|
+
url = `${apiBase}/services/aigc/image2video/video-synthesis`;
|
|
174
179
|
input = { image_url: imgDataUrl, audio_url: audioUrl };
|
|
175
180
|
parameters = {};
|
|
176
181
|
if (resolution) parameters.resolution = String(resolution);
|
|
@@ -178,7 +183,7 @@ export async function submitVideo({ key, model, prompt, imgDataUrl, size = '1280
|
|
|
178
183
|
headers['X-DashScope-OssResourceResolve'] = 'enable';
|
|
179
184
|
}
|
|
180
185
|
} else {
|
|
181
|
-
url = `${
|
|
186
|
+
url = `${apiBase}/services/aigc/video-generation/video-synthesis`;
|
|
182
187
|
input = { prompt };
|
|
183
188
|
if (imgDataUrl) input.img_url = imgDataUrl;
|
|
184
189
|
parameters = { size };
|
|
@@ -203,8 +208,9 @@ export async function submitVideo({ key, model, prompt, imgDataUrl, size = '1280
|
|
|
203
208
|
* 返回 oss:// 形式 URL;调用模型时请求头须带 X-DashScope-OssResourceResolve: enable
|
|
204
209
|
* (submitVideo 的 s2v 分支已自动处理)。
|
|
205
210
|
*/
|
|
206
|
-
export async function uploadTempFile({ key, model, filePath, timeoutMs = T_UPLOAD, signal }) {
|
|
207
|
-
const
|
|
211
|
+
export async function uploadTempFile({ key, baseUrl, model, filePath, timeoutMs = T_UPLOAD, signal }) {
|
|
212
|
+
const apiBase = dashscopeApiBase(baseUrl);
|
|
213
|
+
const u = new URL(`${apiBase}/uploads`);
|
|
208
214
|
u.searchParams.set('action', 'getPolicy');
|
|
209
215
|
u.searchParams.set('model', model);
|
|
210
216
|
const pres = await fetch(u, { headers: authHeaders(key), signal: timeoutSignal(timeoutMs, signal) });
|
|
@@ -230,8 +236,9 @@ export async function uploadTempFile({ key, model, filePath, timeoutMs = T_UPLOA
|
|
|
230
236
|
const TERMINAL = new Set(['SUCCEEDED', 'FAILED', 'CANCELED', 'UNKNOWN']);
|
|
231
237
|
|
|
232
238
|
/** @returns {{done:boolean, ok:boolean, urls:string[], message?:string}} */
|
|
233
|
-
export async function pollTask({ key, remoteTaskId, timeoutMs = T_POLL }) {
|
|
234
|
-
const
|
|
239
|
+
export async function pollTask({ key, baseUrl, remoteTaskId, timeoutMs = T_POLL }) {
|
|
240
|
+
const apiBase = dashscopeApiBase(baseUrl);
|
|
241
|
+
const res = await fetch(`${apiBase}/tasks/${encodeURIComponent(remoteTaskId)}`, {
|
|
235
242
|
headers: authHeaders(key),
|
|
236
243
|
signal: timeoutSignal(timeoutMs)
|
|
237
244
|
});
|
|
@@ -272,8 +279,9 @@ export async function pollTask({ key, remoteTaskId, timeoutMs = T_POLL }) {
|
|
|
272
279
|
}
|
|
273
280
|
|
|
274
281
|
/* ---------------- DashScope:qwen-tts(同步) ---------------- */
|
|
275
|
-
export async function synthesizeTts({ key, model = 'qwen-tts-latest', text, voice = 'Cherry', timeoutMs = T_SYNC_GEN, signal }) {
|
|
276
|
-
const
|
|
282
|
+
export async function synthesizeTts({ key, baseUrl, model = 'qwen-tts-latest', text, voice = 'Cherry', timeoutMs = T_SYNC_GEN, signal }) {
|
|
283
|
+
const apiBase = dashscopeApiBase(baseUrl);
|
|
284
|
+
const res = await fetch(`${apiBase}/services/aigc/multimodal-generation/generation`, {
|
|
277
285
|
method: 'POST',
|
|
278
286
|
headers: authHeaders(key),
|
|
279
287
|
signal: timeoutSignal(timeoutMs, signal),
|
|
@@ -407,13 +415,19 @@ export function extFromUrl(url, fallback = 'bin') {
|
|
|
407
415
|
export async function downloadTo(url, filePath, { timeoutMs = T_DOWNLOAD, signal } = {}) {
|
|
408
416
|
const res = await fetch(url, { signal: timeoutSignal(timeoutMs, signal) });
|
|
409
417
|
if (!res.ok) throw new Error(`下载结果失败:HTTP ${res.status}`);
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
418
|
+
if (!res.body) throw new Error('下载结果失败:响应没有内容');
|
|
419
|
+
ensurePrivateDir(path.dirname(filePath));
|
|
420
|
+
const tmp = privateSibling(filePath, 'download');
|
|
421
|
+
try {
|
|
422
|
+
const output = fs.createWriteStream(tmp, { flags: 'wx', mode: 0o600 });
|
|
423
|
+
await pipeline(Readable.fromWeb(res.body), output);
|
|
424
|
+
const bytes = fs.statSync(tmp).size;
|
|
425
|
+
fs.renameSync(tmp, filePath);
|
|
426
|
+
chmodPrivateFile(filePath);
|
|
427
|
+
return bytes;
|
|
428
|
+
} finally {
|
|
429
|
+
try { fs.rmSync(tmp, { force: true }); } catch (_) { /* rename 成功后已不存在 */ }
|
|
430
|
+
}
|
|
417
431
|
}
|
|
418
432
|
|
|
419
433
|
/* ---------------- DashScope:非实时语音识别(异步任务) ---------------- */
|
|
@@ -422,14 +436,15 @@ export async function downloadTo(url, filePath, { timeoutMs = T_DOWNLOAD, signal
|
|
|
422
436
|
* @param {{key:string, model:string, audioUrl:string}} opts audioUrl 为 oss:// 或公网 URL
|
|
423
437
|
* @returns {Promise<string>} remoteTaskId
|
|
424
438
|
*/
|
|
425
|
-
export async function submitTranscription({ key, model = 'qwen-audio-3.0-asr-flash-filetrans', audioUrl, timeoutMs = T_SUBMIT, signal }) {
|
|
439
|
+
export async function submitTranscription({ key, baseUrl, model = 'qwen-audio-3.0-asr-flash-filetrans', audioUrl, timeoutMs = T_SUBMIT, signal }) {
|
|
440
|
+
const apiBase = dashscopeApiBase(baseUrl);
|
|
426
441
|
const headers = { ...authHeaders(key), 'X-DashScope-Async': 'enable' };
|
|
427
442
|
if (/^oss:\/\//.test(audioUrl || '')) headers['X-DashScope-OssResourceResolve'] = 'enable';
|
|
428
443
|
// Qwen3 Filetrans 使用单值 file_url;Qwen-Audio 3.0、Fun-ASR、Paraformer 使用 file_urls。
|
|
429
444
|
const input = /^qwen3-asr-.*filetrans/i.test(model || '')
|
|
430
445
|
? { file_url: audioUrl }
|
|
431
446
|
: { file_urls: [audioUrl] };
|
|
432
|
-
const res = await fetch(`${
|
|
447
|
+
const res = await fetch(`${apiBase}/services/audio/asr/transcription`, {
|
|
433
448
|
method: 'POST',
|
|
434
449
|
headers,
|
|
435
450
|
signal: timeoutSignal(timeoutMs, signal),
|
|
@@ -461,8 +476,9 @@ export function transcriptionText(payload) {
|
|
|
461
476
|
}
|
|
462
477
|
|
|
463
478
|
/** 轮询 ASR 任务;正式结果位于 results[].transcription_url 指向的 JSON。 */
|
|
464
|
-
export async function pollTranscriptionTask({ key, remoteTaskId, timeoutMs = T_POLL }) {
|
|
465
|
-
const
|
|
479
|
+
export async function pollTranscriptionTask({ key, baseUrl, remoteTaskId, timeoutMs = T_POLL }) {
|
|
480
|
+
const apiBase = dashscopeApiBase(baseUrl);
|
|
481
|
+
const res = await fetch(`${apiBase}/tasks/${encodeURIComponent(remoteTaskId)}`, {
|
|
466
482
|
headers: authHeaders(key),
|
|
467
483
|
signal: timeoutSignal(timeoutMs)
|
|
468
484
|
});
|
package/lib/api.js
CHANGED
|
@@ -16,6 +16,15 @@ import { irisHome, capabilitiesOf, onChange as onConfigChange } from './config.j
|
|
|
16
16
|
import { mimeOf, mediaUrl } from './media.js';
|
|
17
17
|
import * as tasks from './tasks.js';
|
|
18
18
|
import { runAction } from './actions.js';
|
|
19
|
+
import { chmodPrivateFile, ensurePrivateDir } from './private-storage.js';
|
|
20
|
+
import { inferMediaProtocol } from './provider-protocol.js';
|
|
21
|
+
import { optimizePrompt } from './prompt-optimizer.js';
|
|
22
|
+
import {
|
|
23
|
+
importPromptOptimizerConfig,
|
|
24
|
+
loadPromptOptimizerConfig,
|
|
25
|
+
resetPromptOptimizerConfig,
|
|
26
|
+
setPromptOptimizerEnabled
|
|
27
|
+
} from './prompt-optimizer-config.js';
|
|
19
28
|
|
|
20
29
|
function readJsonSafe(file) {
|
|
21
30
|
try {
|
|
@@ -42,7 +51,7 @@ function providerPublic(p) {
|
|
|
42
51
|
type: p.type || 'openai',
|
|
43
52
|
baseUrl: p.baseUrl,
|
|
44
53
|
enabled: p.enabled !== false,
|
|
45
|
-
mediaProtocol: p.mediaProtocol ||
|
|
54
|
+
mediaProtocol: p.mediaProtocol || inferMediaProtocol(p.baseUrl),
|
|
46
55
|
apiKeyHint: keyHint(p.apiKey),
|
|
47
56
|
capabilities: capabilitiesOf(p),
|
|
48
57
|
// 迁移/推断结果是否与原始声明不同(UI 提示旧配置已推断)
|
|
@@ -242,7 +251,7 @@ function serveSse(res) {
|
|
|
242
251
|
res.on('error', onClose);
|
|
243
252
|
}
|
|
244
253
|
|
|
245
|
-
/** webServer 前缀路由 handler
|
|
254
|
+
/** webServer 前缀路由 handler:状态、任务、动作与提示词优化器 API。 */
|
|
246
255
|
export function serveApi(req, res, ctx) {
|
|
247
256
|
try {
|
|
248
257
|
const method = String(req.method || 'GET').toUpperCase();
|
|
@@ -254,6 +263,8 @@ export function serveApi(req, res, ctx) {
|
|
|
254
263
|
if (urlPath === '/iris/api/upload') {
|
|
255
264
|
return handleUpload(req, res);
|
|
256
265
|
}
|
|
266
|
+
const promptMatch = typeof ctx === 'object' && urlPath.match(/^\/iris\/api\/prompt-optimizer\/(optimize|import|reset|enabled)\/?$/);
|
|
267
|
+
if (promptMatch) return handlePromptOptimizer(ctx, promptMatch[1], req, res);
|
|
257
268
|
const actionMatch = typeof ctx === 'object' && urlPath.match(/^\/iris\/api\/actions\/([A-Za-z0-9_-]+)\/?$/);
|
|
258
269
|
if (!actionMatch) { return sendJson(res, 405, { error: 'method not allowed' }); }
|
|
259
270
|
return handleAction(ctx, actionMatch[1], req, res);
|
|
@@ -267,6 +278,9 @@ export function serveApi(req, res, ctx) {
|
|
|
267
278
|
if (method === 'HEAD') return sendJson(res, 405, { error: 'method not allowed' });
|
|
268
279
|
return serveSse(res);
|
|
269
280
|
}
|
|
281
|
+
if (urlPath === '/iris/api/prompt-optimizer/config') {
|
|
282
|
+
return sendJson(res, 200, method === 'HEAD' ? {} : loadPromptOptimizerConfig());
|
|
283
|
+
}
|
|
270
284
|
const taskMatch = urlPath.match(/^\/iris\/api\/task\/([^/]+)$/);
|
|
271
285
|
if (taskMatch) {
|
|
272
286
|
return serveTaskDetail(taskMatch[1], method, res);
|
|
@@ -295,10 +309,12 @@ function sendJson(res, status, obj) {
|
|
|
295
309
|
|
|
296
310
|
/** POST /iris/api/actions/:name:读取 JSON body 调 runAction 返回结果 */
|
|
297
311
|
const MAX_BODY_BYTES = 1e6; // 1MB 上限(防滥用)
|
|
298
|
-
function
|
|
312
|
+
function handleJsonPost(req, res, execute) {
|
|
299
313
|
const chunks = [];
|
|
300
314
|
const controller = new AbortController();
|
|
301
315
|
let completed = false;
|
|
316
|
+
let totalBytes = 0;
|
|
317
|
+
let overflow = false;
|
|
302
318
|
const finish = (status, body) => {
|
|
303
319
|
if (completed) return;
|
|
304
320
|
completed = true;
|
|
@@ -307,32 +323,49 @@ function handleAction(ctx, name, req, res) {
|
|
|
307
323
|
const disconnect = () => { if (!completed) controller.abort(); };
|
|
308
324
|
if (typeof req.on === 'function') req.on('aborted', disconnect);
|
|
309
325
|
if (typeof res.on === 'function') res.on('close', disconnect);
|
|
310
|
-
|
|
311
|
-
let overflow = false;
|
|
312
|
-
req.on('data', (c) => {
|
|
326
|
+
req.on('data', (chunk) => {
|
|
313
327
|
if (overflow) return;
|
|
314
|
-
totalBytes +=
|
|
328
|
+
totalBytes += chunk.length;
|
|
315
329
|
if (totalBytes > MAX_BODY_BYTES) {
|
|
316
330
|
overflow = true;
|
|
317
|
-
chunks.length = 0;
|
|
331
|
+
chunks.length = 0;
|
|
318
332
|
return;
|
|
319
333
|
}
|
|
320
|
-
chunks.push(
|
|
334
|
+
chunks.push(chunk);
|
|
321
335
|
});
|
|
322
336
|
req.on('end', async () => {
|
|
323
337
|
if (overflow) return finish(413, { error: 'request body too large (limit 1MB)' });
|
|
324
338
|
try {
|
|
325
339
|
const raw = Buffer.concat(chunks).toString('utf8');
|
|
326
|
-
const
|
|
327
|
-
const result = await
|
|
340
|
+
const body = raw ? JSON.parse(raw) : {};
|
|
341
|
+
const result = await execute(body, controller.signal);
|
|
328
342
|
finish(200, result);
|
|
329
343
|
} catch (err) {
|
|
330
|
-
finish(controller.signal.aborted ? 499 : 400, {
|
|
344
|
+
finish(controller.signal.aborted ? 499 : 400, {
|
|
345
|
+
error: controller.signal.aborted ? 'request canceled' : String((err && err.message) || err)
|
|
346
|
+
});
|
|
331
347
|
}
|
|
332
348
|
});
|
|
333
349
|
req.on('error', () => finish(400, { error: 'bad request' }));
|
|
334
350
|
}
|
|
335
351
|
|
|
352
|
+
function handleAction(ctx, name, req, res) {
|
|
353
|
+
return handleJsonPost(req, res, (args, signal) => runAction(ctx, name, args, { signal }));
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function handlePromptOptimizer(ctx, operation, req, res) {
|
|
357
|
+
return handleJsonPost(req, res, async (body, signal) => {
|
|
358
|
+
if (operation === 'optimize') return optimizePrompt(ctx, body, { signal });
|
|
359
|
+
if (operation === 'import') {
|
|
360
|
+
const imported = body && body.config !== undefined ? body.config : body;
|
|
361
|
+
return { ok: true, ...importPromptOptimizerConfig(imported) };
|
|
362
|
+
}
|
|
363
|
+
if (operation === 'reset') return { ok: true, ...resetPromptOptimizerConfig() };
|
|
364
|
+
if (operation === 'enabled') return { ok: true, ...setPromptOptimizerEnabled(body && body.enabled) };
|
|
365
|
+
throw new Error('unknown prompt optimizer operation');
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
336
369
|
/* ---------- 文件选择器 L2:本地文件上传(阶段 10) ---------- */
|
|
337
370
|
export const MAX_UPLOAD_BYTES = 64 * 1024 * 1024;
|
|
338
371
|
export const UPLOAD_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
@@ -370,18 +403,28 @@ function safeName(name) {
|
|
|
370
403
|
function handleUpload(req, res) {
|
|
371
404
|
const q = new URL(req.url || '', 'http://x').searchParams;
|
|
372
405
|
const name = safeName(q.get('name') || 'upload');
|
|
373
|
-
|
|
406
|
+
ensurePrivateDir(uploadsDir());
|
|
374
407
|
purgeStaleUploads();
|
|
375
408
|
|
|
376
409
|
const stem = Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
|
|
377
410
|
const abs = path.join(uploadsDir(), stem + '-' + name);
|
|
378
411
|
const tmp = abs + '.part';
|
|
379
|
-
|
|
412
|
+
// 惰性建流:空上传全程不落盘。createWriteStream 的 open 是异步的,
|
|
413
|
+
// 若在 open 完成前 destroy,文件仍会被创建,同步 rmSync 删不到,留下孤儿 .part。
|
|
414
|
+
let out = null;
|
|
380
415
|
let totalBytes = 0;
|
|
381
416
|
let settled = false;
|
|
382
417
|
|
|
383
418
|
const discard = () => {
|
|
384
|
-
|
|
419
|
+
const stream = out;
|
|
420
|
+
out = null;
|
|
421
|
+
if (stream) {
|
|
422
|
+
// 兜底:等 fd 真正关闭(close 事件)后再删一次,覆盖 open 晚于 destroy 的竞态。
|
|
423
|
+
stream.once('close', () => {
|
|
424
|
+
try { fs.rmSync(tmp, { force: true }); } catch (_) { /* ignore */ }
|
|
425
|
+
});
|
|
426
|
+
try { stream.destroy(); } catch (_) { /* ignore */ }
|
|
427
|
+
}
|
|
385
428
|
try { fs.rmSync(tmp, { force: true }); } catch (_) { /* ignore */ }
|
|
386
429
|
};
|
|
387
430
|
const finish = (status, body) => {
|
|
@@ -391,7 +434,14 @@ function handleUpload(req, res) {
|
|
|
391
434
|
sendJson(res, status, body);
|
|
392
435
|
};
|
|
393
436
|
|
|
394
|
-
|
|
437
|
+
const ensureStream = () => {
|
|
438
|
+
if (!out) {
|
|
439
|
+
out = fs.createWriteStream(tmp, { mode: 0o600, flags: 'wx' });
|
|
440
|
+
out.on('error', (err) => finish(500, { error: 'upload failed: ' + String(err && err.message) }));
|
|
441
|
+
}
|
|
442
|
+
return out;
|
|
443
|
+
};
|
|
444
|
+
|
|
395
445
|
req.on('data', (chunk) => {
|
|
396
446
|
if (settled) return;
|
|
397
447
|
totalBytes += chunk.length;
|
|
@@ -400,18 +450,21 @@ function handleUpload(req, res) {
|
|
|
400
450
|
if (typeof req.resume === 'function') req.resume();
|
|
401
451
|
return;
|
|
402
452
|
}
|
|
403
|
-
|
|
453
|
+
const stream = ensureStream();
|
|
454
|
+
if (!stream.write(chunk) && typeof req.pause === 'function') {
|
|
404
455
|
req.pause();
|
|
405
|
-
|
|
456
|
+
stream.once('drain', () => { if (!settled && typeof req.resume === 'function') req.resume(); });
|
|
406
457
|
}
|
|
407
458
|
});
|
|
408
459
|
req.on('end', () => {
|
|
409
460
|
if (settled) return;
|
|
410
461
|
if (!totalBytes) return finish(400, { error: 'empty upload' });
|
|
411
|
-
|
|
462
|
+
const stream = out;
|
|
463
|
+
stream.end(() => {
|
|
412
464
|
if (settled) return;
|
|
413
465
|
try {
|
|
414
466
|
fs.renameSync(tmp, abs);
|
|
467
|
+
chmodPrivateFile(abs);
|
|
415
468
|
finish(200, { ok: true, path: abs, name, expiresInMs: UPLOAD_TTL_MS });
|
|
416
469
|
} catch (err) {
|
|
417
470
|
finish(500, { error: 'upload failed: ' + String(err && err.message) });
|
|
@@ -464,4 +517,4 @@ function serveTaskDetail(taskId, method, res) {
|
|
|
464
517
|
'X-Content-Type-Options': 'nosniff'
|
|
465
518
|
});
|
|
466
519
|
res.end(method === 'HEAD' ? undefined : body);
|
|
467
|
-
}
|
|
520
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
const SKILL_NAMES = ['iris-verify-ui', 'iris-compose-media'];
|
|
8
|
+
const SKILL_ROOT = fileURLToPath(new URL('../.dsh/skills/', import.meta.url));
|
|
9
|
+
|
|
10
|
+
function parseScalar(value, file) {
|
|
11
|
+
const trimmed = value.trim();
|
|
12
|
+
if (trimmed.startsWith('"')) {
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(trimmed);
|
|
15
|
+
} catch (error) {
|
|
16
|
+
throw new Error(`随包 Skill frontmatter 字符串无效:${file}: ${error.message}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
if (trimmed.startsWith("'") && trimmed.endsWith("'")) {
|
|
20
|
+
return trimmed.slice(1, -1).replaceAll("''", "'");
|
|
21
|
+
}
|
|
22
|
+
return trimmed;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 读取由 Iris 自己发布的固定 Skill;不把此解析器当作通用 YAML 解析器。 */
|
|
26
|
+
export function loadBundledSkills() {
|
|
27
|
+
return SKILL_NAMES.map((expectedName) => {
|
|
28
|
+
const directory = path.join(SKILL_ROOT, expectedName);
|
|
29
|
+
const file = path.join(directory, 'SKILL.md');
|
|
30
|
+
const source = fs.readFileSync(file, 'utf8');
|
|
31
|
+
const frontmatter = source.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
|
|
32
|
+
if (!frontmatter) throw new Error(`随包 Skill 缺少 frontmatter:${file}`);
|
|
33
|
+
|
|
34
|
+
const nameLine = frontmatter[1].match(/^name:\s*(.+)$/m);
|
|
35
|
+
const descriptionLine = frontmatter[1].match(/^description:\s*(.+)$/m);
|
|
36
|
+
const name = nameLine ? parseScalar(nameLine[1], file) : '';
|
|
37
|
+
const description = descriptionLine ? parseScalar(descriptionLine[1], file) : '';
|
|
38
|
+
if (name !== expectedName) throw new Error(`随包 Skill 名称不匹配:${file}`);
|
|
39
|
+
if (!description) throw new Error(`随包 Skill 缺少 description:${file}`);
|
|
40
|
+
|
|
41
|
+
return Object.freeze({
|
|
42
|
+
name,
|
|
43
|
+
description,
|
|
44
|
+
content: source.slice(frontmatter[0].length).replace(/^\r?\n/, ''),
|
|
45
|
+
source: 'bundled',
|
|
46
|
+
provider: 'iris-bundled',
|
|
47
|
+
path: file,
|
|
48
|
+
resourceBase: Object.freeze({ kind: 'directory', path: directory })
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 注册为宿主级 runtime Skill;DSH 项目目录中的同名 Skill 仍按原生优先级胜出。 */
|
|
54
|
+
export function registerBundledSkills(ctx) {
|
|
55
|
+
const registry = ctx && (ctx.skills || (typeof ctx.get === 'function' && ctx.get('skills')));
|
|
56
|
+
if (!registry || typeof registry.register !== 'function') {
|
|
57
|
+
throw new Error('DSH Skill registry 不可用');
|
|
58
|
+
}
|
|
59
|
+
const skills = loadBundledSkills();
|
|
60
|
+
for (const skill of skills) registry.register(skill);
|
|
61
|
+
return skills.map((skill) => skill.name);
|
|
62
|
+
}
|
package/lib/capability.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Iris Provider 能力系统(阶段 1)。
|
|
4
4
|
*
|
|
5
|
-
*
|
|
5
|
+
* 目标(“实际验证,不只猜测”):
|
|
6
6
|
* - 能力常量:image-gen / video-gen / tts / transcribe / vision
|
|
7
7
|
* - 严格选择:不具备某能力的 Provider 不得被兜底选中
|
|
8
8
|
* - 有序 failover:同一能力按 providers.json 声明顺序返回,调用方按序尝试
|