@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.
Files changed (46) hide show
  1. package/CHANGELOG.md +72 -1
  2. package/README.en.md +245 -0
  3. package/README.md +81 -13
  4. package/cordis.patch.yml +1 -1
  5. package/docs/ROADMAP.md +49 -21
  6. package/docs/assets/diagrams/iris-architecture.drawio +1 -0
  7. package/docs/assets/diagrams/iris-architecture.png +0 -0
  8. package/docs/assets/diagrams/iris-architecture.svg +63 -0
  9. package/docs/assets/diagrams/iris-task-lifecycle.drawio +1 -0
  10. package/docs/assets/diagrams/iris-task-lifecycle.png +0 -0
  11. package/docs/assets/diagrams/iris-task-lifecycle.svg +64 -0
  12. package/docs/assets/diagrams/iris-workflow-compose.drawio +1 -0
  13. package/docs/assets/diagrams/iris-workflow-compose.png +0 -0
  14. package/docs/assets/diagrams/iris-workflow-compose.svg +41 -0
  15. package/docs/assets/examples/iris-flower-generated.webp +0 -0
  16. package/docs/assets/screenshots/image-task-succeeded.webp +0 -0
  17. package/docs/assets/screenshots/prompt-general-after.webp +0 -0
  18. package/docs/assets/screenshots/prompt-general-before.webp +0 -0
  19. package/docs/assets/screenshots/prompt-general-preview.webp +0 -0
  20. package/docs/assets/screenshots/prompt-image-before.webp +0 -0
  21. package/docs/assets/screenshots/prompt-image-preview.webp +0 -0
  22. package/docs/releases/0.1.1.md +51 -0
  23. package/docs/releases/0.1.2.md +26 -0
  24. package/docs/screenshots.md +34 -0
  25. package/lib/actions.js +33 -27
  26. package/lib/adapters.js +42 -26
  27. package/lib/api.js +73 -20
  28. package/lib/bundled-skills.js +62 -0
  29. package/lib/capability.js +1 -1
  30. package/lib/client.js +378 -26
  31. package/lib/config.js +56 -22
  32. package/lib/guard.js +37 -24
  33. package/lib/index.js +21 -9
  34. package/lib/locate.js +1 -1
  35. package/lib/media-probe.js +4 -4
  36. package/lib/media.js +1 -1
  37. package/lib/private-storage.js +77 -0
  38. package/lib/prompt-optimizer-config.js +170 -0
  39. package/lib/prompt-optimizer.js +244 -0
  40. package/lib/provider-protocol.js +25 -0
  41. package/lib/render.js +3 -2
  42. package/lib/tasks.js +21 -13
  43. package/lib/vision.js +2 -2
  44. package/package.json +25 -17
  45. package/screenshots.json +6 -0
  46. 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
- fs.mkdirSync(dir, { recursive: true });
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 = (provider.mediaProtocol || 'dashscope') === 'openai-images' ? 'openai-images' : 'dashscope';
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) fs.writeFileSync(p, Buffer.from(outs[i].b64, 'base64'));
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 fs.writeFileSync(p, Buffer.from(r.audioB64, 'base64'));
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 || 'dashscope',
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}。image/video 会产生真实调用(成本已在 UI 标注)。 */
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
- throw new Error('iris: 转写能力请用真实音频通过“音频转写”动作验证,不执行空样本探针');
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 || 'dashscope') === 'openai-images') {
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
- // 视频渲染太慢太贵:只做提交受理(证明模型 id 有效 + 鉴权 + 参数),不等成片
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
- fs.mkdirSync(uploads, { recursive: true });
836
- fs.writeFileSync(cached, Buffer.from(stored.data), { mode: 0o600 });
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
- const DASHSCOPE = 'https://dashscope.aliyuncs.com/api/v1';
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 res = await fetch(`${DASHSCOPE}/services/aigc/multimodal-generation/generation`, {
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 res = await fetch(`${DASHSCOPE}/services/aigc/text2image/image-synthesis`, {
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 = `${DASHSCOPE}/services/aigc/image2video/video-synthesis`;
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 = `${DASHSCOPE}/services/aigc/video-generation/video-synthesis`;
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 u = new URL(`${DASHSCOPE}/uploads`);
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 res = await fetch(`${DASHSCOPE}/tasks/${encodeURIComponent(remoteTaskId)}`, {
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 res = await fetch(`${DASHSCOPE}/services/aigc/multimodal-generation/generation`, {
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
- const buf = Buffer.from(await res.arrayBuffer());
411
- const { writeFileSync, renameSync } = await import('node:fs');
412
- // 原子落盘:先写 .tmp 再 rename,失败/超时不留半截产物
413
- const tmp = filePath + '.tmp';
414
- writeFileSync(tmp, buf);
415
- renameSync(tmp, filePath);
416
- return buf.length;
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(`${DASHSCOPE}/services/audio/asr/transcription`, {
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 res = await fetch(`${DASHSCOPE}/tasks/${encodeURIComponent(remoteTaskId)}`, {
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 || 'dashscope',
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:GET/HEAD /iris/api/state 和 /iris/api/task/:id,POST /iris/api/actions/:name */
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 handleAction(ctx, name, req, res) {
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
- let totalBytes = 0; // 注意:必须按字节计——body 数组的 length 是块数不是字节数(旧代码的坑)
311
- let overflow = false;
312
- req.on('data', (c) => {
326
+ req.on('data', (chunk) => {
313
327
  if (overflow) return;
314
- totalBytes += c.length;
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(c);
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 args = raw ? JSON.parse(raw) : {};
327
- const result = await runAction(ctx, name, args, { signal: controller.signal });
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, { error: controller.signal.aborted ? 'request canceled' : String((err && err.message) || err) });
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
- fs.mkdirSync(uploadsDir(), { recursive: true });
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
- const out = fs.createWriteStream(tmp, { mode: 0o600, flags: 'wx' });
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
- try { out.destroy(); } catch (_) { /* ignore */ }
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
- out.on('error', (err) => finish(500, { error: 'upload failed: ' + String(err && err.message) }));
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
- if (!out.write(chunk) && typeof req.pause === 'function') {
453
+ const stream = ensureStream();
454
+ if (!stream.write(chunk) && typeof req.pause === 'function') {
404
455
  req.pause();
405
- out.once('drain', () => { if (!settled && typeof req.resume === 'function') req.resume(); });
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
- out.end(() => {
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
- * 目标(对齐 PLAN 阶段 1 与 RESEARCH 的 "test it, don't guess it"):
5
+ * 目标(“实际验证,不只猜测”):
6
6
  * - 能力常量:image-gen / video-gen / tts / transcribe / vision
7
7
  * - 严格选择:不具备某能力的 Provider 不得被兜底选中
8
8
  * - 有序 failover:同一能力按 providers.json 声明顺序返回,调用方按序尝试