@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/config.js CHANGED
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
  /**
3
3
  * Iris 配置存储:$DSH_HOME/iris/v1/providers.json
4
- * 结构与 ai-paint 工作台兼容(providers 数组同形),支持一键导入。
4
+ * Iris 独立持有配置;旧工作台仅作为用户显式指定的一次性导入来源。
5
5
  * Key 明文落盘(0600);接口层永远不回明文。
6
6
  */
7
7
  import fs from 'node:fs';
@@ -9,6 +9,37 @@ import path from 'node:path';
9
9
  import os from 'node:os';
10
10
  import * as cap from './capability.js';
11
11
  import * as models from './models.js';
12
+ import { atomicWritePrivate, chmodPrivateFile, privateSibling } from './private-storage.js';
13
+ import { inferMediaProtocol } from './provider-protocol.js';
14
+
15
+ function isPlainObject(value) {
16
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
17
+ }
18
+
19
+ function validateStoredConfig(value) {
20
+ if (!isPlainObject(value)) throw new Error('配置根节点必须是对象');
21
+ if (!Array.isArray(value.providers)) throw new Error('providers 必须是数组');
22
+ if (!isPlainObject(value.assignments)) throw new Error('assignments 必须是对象');
23
+ for (const provider of value.providers) {
24
+ if (!isPlainObject(provider)) throw new Error('provider 条目必须是对象');
25
+ if (provider.id !== undefined && typeof provider.id !== 'string') throw new Error('provider.id 必须是字符串');
26
+ if (provider.baseUrl !== undefined && typeof provider.baseUrl !== 'string') throw new Error('provider.baseUrl 必须是字符串');
27
+ if (provider.apiKey !== undefined && typeof provider.apiKey !== 'string') throw new Error('provider.apiKey 必须是字符串');
28
+ if (provider.models !== undefined && !Array.isArray(provider.models)) throw new Error('provider.models 必须是数组');
29
+ for (const model of provider.models || []) {
30
+ if (typeof model === 'string') continue;
31
+ if (!isPlainObject(model) || typeof model.id !== 'string' || !model.id.trim()) {
32
+ throw new Error('provider.models 条目必须是模型名或带 id 的对象');
33
+ }
34
+ }
35
+ }
36
+ }
37
+
38
+ function normalizeStoredProvider(provider) {
39
+ if (provider.mediaProtocol === 'dashscope' || provider.mediaProtocol === 'openai-images') return false;
40
+ provider.mediaProtocol = inferMediaProtocol(provider.baseUrl);
41
+ return true;
42
+ }
12
43
 
13
44
  export function irisHome() {
14
45
  const home = process.env.DSH_HOME || path.join(os.homedir(), '.dsh');
@@ -35,16 +66,7 @@ function emitChange() {
35
66
  }
36
67
 
37
68
  function persist() {
38
- fs.mkdirSync(path.dirname(storeFile()), { recursive: true });
39
- const tmp = storeFile() + '.tmp';
40
- // 0600 从创建时生效(含临时文件);rename 保留 mode,chmod 兜底不依赖 umask
41
- fs.writeFileSync(tmp, JSON.stringify(cache, null, 2), { mode: 0o600 });
42
- fs.renameSync(tmp, storeFile());
43
- try {
44
- fs.chmodSync(storeFile(), 0o600);
45
- } catch (_) {
46
- /* ignore */
47
- }
69
+ atomicWritePrivate(storeFile(), JSON.stringify(cache, null, 2));
48
70
  emitChange(); // 任何配置落盘 = 状态变了 → SSE 推送
49
71
  }
50
72
 
@@ -58,14 +80,22 @@ export function load() {
58
80
  return cache;
59
81
  }
60
82
  try {
61
- cache = JSON.parse(fs.readFileSync(file, 'utf8'));
62
- if (!Array.isArray(cache.providers)) cache.providers = [];
63
- if (!cache.assignments || typeof cache.assignments !== 'object') cache.assignments = {};
83
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
84
+ let normalized = false;
85
+ if (isPlainObject(parsed) && parsed.assignments === undefined) {
86
+ parsed.assignments = {};
87
+ normalized = true;
88
+ }
89
+ validateStoredConfig(parsed);
90
+ cache = parsed;
91
+ for (const provider of cache.providers) normalized = normalizeStoredProvider(provider) || normalized;
92
+ if (normalized) persist();
64
93
  } catch (err) {
65
94
  // 文件存在但损坏:隔离,绝不静默覆盖证据
66
- const backup = file + '.corrupted-' + Date.now();
95
+ const backup = privateSibling(file, 'corrupted');
67
96
  try {
68
97
  fs.renameSync(file, backup);
98
+ chmodPrivateFile(backup);
69
99
  } catch (_) {
70
100
  /* 隔离失败也继续 */
71
101
  }
@@ -81,7 +111,7 @@ export function resetCache() {
81
111
  cache = null;
82
112
  }
83
113
 
84
- /** 首次运行且 Iris 为空时,从工作台配置导入(迁移动作,非耦合) */
114
+ /** 用户显式指定来源且 Iris 为空时,从工作台配置导入。 */
85
115
  export function importFromWorkbench(workbenchConfigPath) {
86
116
  const c = load();
87
117
  if (c.providers.length) return { imported: 0, reason: '已有配置,跳过导入' };
@@ -89,12 +119,13 @@ export function importFromWorkbench(workbenchConfigPath) {
89
119
  try {
90
120
  raw = JSON.parse(fs.readFileSync(workbenchConfigPath, 'utf8'));
91
121
  } catch (_) {
92
- return { imported: 0, reason: '未找到工作台配置' };
122
+ return { imported: 0, reason: '来源文件不可读或 JSON 无效' };
93
123
  }
94
- const list = Array.isArray(raw.providers) ? raw.providers : [];
124
+ const list = Array.isArray(raw?.providers) ? raw.providers : [];
95
125
  let n = 0;
96
126
  for (const p of list) {
97
- if (!p || !p.apiKey || !p.baseUrl) continue;
127
+ if (!p || typeof p.apiKey !== 'string' || !p.apiKey.trim() ||
128
+ typeof p.baseUrl !== 'string' || !p.baseUrl.trim()) continue;
98
129
  c.providers.push({
99
130
  id: 'iris_' + Math.random().toString(36).slice(2, 8),
100
131
  name: p.name || p.id,
@@ -102,13 +133,12 @@ export function importFromWorkbench(workbenchConfigPath) {
102
133
  baseUrl: p.baseUrl,
103
134
  apiKey: p.apiKey,
104
135
  enabled: p.enabled !== false,
105
- // 媒体生成默认走百炼协议;OpenAI Images 兼容后端由用户按需切换
106
- mediaProtocol: /dashscope/i.test(p.baseUrl) ? 'dashscope' : 'openai-images'
136
+ mediaProtocol: inferMediaProtocol(p.baseUrl)
107
137
  });
108
138
  n++;
109
139
  }
110
140
  if (n) persist();
111
- return { imported: n };
141
+ return n ? { imported: n } : { imported: 0, reason: '来源没有可导入的供应商' };
112
142
  }
113
143
 
114
144
  export function providers() {
@@ -271,12 +301,16 @@ export function upsert(provider) {
271
301
  if (id) {
272
302
  const i = c.providers.findIndex((p) => p.id === id);
273
303
  if (i >= 0) {
304
+ if (rest.mediaProtocol === 'auto') rest.mediaProtocol = inferMediaProtocol(rest.baseUrl ?? c.providers[i].baseUrl);
274
305
  c.providers[i] = { ...c.providers[i], ...rest };
306
+ normalizeStoredProvider(c.providers[i]);
275
307
  persist();
276
308
  return c.providers[i];
277
309
  }
278
310
  }
279
311
  const fresh = { id: 'iris_' + Math.random().toString(36).slice(2, 8), ...rest };
312
+ if (fresh.mediaProtocol === 'auto') delete fresh.mediaProtocol;
313
+ normalizeStoredProvider(fresh);
280
314
  c.providers.push(fresh);
281
315
  persist();
282
316
  return fresh;
package/lib/guard.js CHANGED
@@ -1,41 +1,54 @@
1
1
  'use strict';
2
2
  /**
3
- * Iris 请求守卫(O2 授权边界,2026-09-03 修订为"发布安全优先")。
3
+ * Iris 请求守卫。
4
4
  *
5
- * 调研宿主结论:DSH 在 `dsh-client-connection` 里有一套权威信任栅栏
6
- * `isTrustedApiRequest`(回环∪trustedHosts + Sec-Fetch-Site + Origin==Host),
7
- * 但它**只挂在 `/api` 前缀**。iris 注册的是 `/iris/*` 裸 prefix 路由,在栅栏之外,
8
- * 所以 iris 需自备一道——但必须**对齐宿主里"永不锁死合法用户"的那半,
9
- * 舍弃"会锁死 LAN/反代用户"的那半**。
10
- *
11
- * 威胁与处置(按发布安全排序):
12
- * ① 驱动式 CSRF(真实、高价值):恶意网页对 `/iris/api/actions/*` 发跨站 POST,
13
- * 触发付费生成/写盘。→ **挡**:POST 时 `Sec-Fetch-Site === cross-site` 拒绝;
14
- * 有 `Origin` 且 `Origin.host !== 请求 Host` 拒绝。合法同源用户(不管在回环/LAN/
15
- * 反代域名上访问,浏览器 Origin 总与 Host 同源)永不误伤;无 Origin 的 CLI/curl 放行。
16
- * ② DNS 重绑定读 state / 驱动 POST(低价值):需要 Host 白名单才能挡,但白名单正是
17
- * 锁死 LAN/反代用户的元凶。用户裁定:iris 开源、反代场景无所谓 → **不挡**,
18
- * 读接口(GET/HEAD/SSE)完全放开(跨源读本就受 CORS 阻挡,媒体另有 token 能力凭证)。
19
- *
20
- * 净效果:**任何部署拓扑都不拦合法用户**,只挡跨站 CSRF 驱动付费动作。
5
+ * `/iris/*` 不经过 DSH 的 `/api` 信任栅栏,因此默认只接受回环 Host。
6
+ * LAN/反代场景需用 IRIS_TRUSTED_HOSTS 显式列出 host 或 host:port;这只是
7
+ * Host 边界,不是身份认证。POST 另外校验 Sec-Fetch-Site 与 Origin==Host。
21
8
  */
22
9
 
23
10
  /** 请求 Host 头 → host:port 规范化(小写,去 IPv6 方括号保留) */
24
11
  function authority(header) {
25
- const h = String(header || '').trim().toLowerCase();
26
- if (!h) return '';
27
- return h;
12
+ return String(header || '').trim().toLowerCase();
13
+ }
14
+
15
+ function hostnameOf(value) {
16
+ try { return new URL('http://' + authority(value)).hostname.replace(/^\[|\]$/g, '').replace(/\.$/, '').toLowerCase(); }
17
+ catch (_) { return ''; }
18
+ }
19
+
20
+ function isLoopbackHost(hostname) {
21
+ if (hostname === 'localhost' || hostname === '::1') return true;
22
+ const match = hostname.match(/^127\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
23
+ return !!match && match.slice(1).every((part) => Number(part) <= 255);
24
+ }
25
+
26
+ /** Host 是否属于默认回环或显式信任列表。列表格式:逗号分隔 host / host:port。 */
27
+ export function isTrustedHost(hostHeader, configured = process.env.IRIS_TRUSTED_HOSTS || '') {
28
+ const host = authority(hostHeader);
29
+ const hostname = hostnameOf(host);
30
+ if (!host || !hostname) return false;
31
+ if (isLoopbackHost(hostname)) return true;
32
+ for (const raw of String(configured || '').split(',')) {
33
+ const entry = authority(raw);
34
+ if (!entry) continue;
35
+ if (entry === host) return true;
36
+ if (!entry.includes(':') && entry.replace(/\.$/, '') === hostname) return true;
37
+ }
38
+ return false;
28
39
  }
29
40
 
30
41
  /**
31
42
  * 纯函数裁决:{ok:true} 放行;否则 {ok:false, status, error}。
32
- * 非 POST 一律放行;POST 仅挡跨站/跨源(同源浏览器与无 Origin 的 CLI 放行)。
33
43
  * @param {{method?:string, headers?:object}} req node:http IncomingMessage 的最小面
34
44
  */
35
- export function checkRequest(req) {
45
+ export function checkRequest(req, options = {}) {
36
46
  const headers = req.headers || {};
37
47
  const method = String(req.method || 'GET').toUpperCase();
38
- if (method !== 'POST') return { ok: true }; // 读接口放开:跨源读受 CORS 挡,媒体有 token
48
+ if (!isTrustedHost(headers.host, options.trustedHosts)) {
49
+ return { ok: false, status: 403, error: 'untrusted host' };
50
+ }
51
+ if (method !== 'POST') return { ok: true };
39
52
 
40
53
  const sfs = String(headers['sec-fetch-site'] || '').toLowerCase();
41
54
  if (sfs === 'cross-site') return { ok: false, status: 403, error: 'cross-site request denied' };
@@ -50,7 +63,7 @@ export function checkRequest(req) {
50
63
  }
51
64
  if (oh !== authority(headers.host)) return { ok: false, status: 403, error: 'cross-origin request denied' };
52
65
  }
53
- return { ok: true }; // 同源浏览器,或无 Origin 的本机 CLI/curl
66
+ return { ok: true };
54
67
  }
55
68
 
56
69
  /** 包裹路由 handler:未授权请求统一 403 人话 JSON,不进业务 handler */
package/lib/index.js CHANGED
@@ -7,7 +7,6 @@
7
7
  * 架构纪律:无独立后台,与 DSH 俱荣俱损——定时器归 Fiber,产物落 $DSH_HOME。
8
8
  */
9
9
  import fs from 'node:fs';
10
- import os from 'node:os';
11
10
  import path from 'node:path';
12
11
  import * as adapters from './adapters.js';
13
12
  import * as store from './config.js';
@@ -19,11 +18,13 @@ import { buildVisionBackends, askWithBackends } from './vision.js';
19
18
  import { cropImage, pixelDiff, imageDimensions } from './pixels.js';
20
19
  import { locateObject } from './locate.js';
21
20
  import { serveRender, BrowserHtmlRenderer } from './render.js';
21
+ import { ensurePrivateDir, hardenPrivateTree } from './private-storage.js';
22
22
  import { guarded } from './guard.js';
23
23
  import { longOcr } from './ocr.js';
24
24
  import { runAction, listActions } from './actions.js';
25
25
  import { ffmpegAvailable, extractFrames, probeVideo, extractAudioTrack } from './media-probe.js';
26
26
  import { buildContactSheet, summarizeMedia } from './summarize.js';
27
+ import { registerBundledSkills } from './bundled-skills.js';
27
28
 
28
29
  const MEDIA_TYPES = {
29
30
  '.png': 'image/png',
@@ -38,8 +39,7 @@ const AWAIT_MS = { image: 180000, video: 480000 };
38
39
 
39
40
  function outputDir() {
40
41
  const dir = tasks.outputsDir();
41
- fs.mkdirSync(dir, { recursive: true });
42
- return dir;
42
+ return ensurePrivateDir(dir);
43
43
  }
44
44
 
45
45
  /* ---------------- 媒体路由:/iris/media/:taskId/:token/:name ---------------- */
@@ -90,7 +90,7 @@ export function pollDeps(provider, cap) {
90
90
  return {
91
91
  key: () => provider.apiKey,
92
92
  poll: async ({ key, remoteTaskId }) => {
93
- const r = await adapters.pollTranscriptionTask({ key, remoteTaskId });
93
+ const r = await adapters.pollTranscriptionTask({ key, baseUrl: provider.baseUrl, remoteTaskId });
94
94
  return { done: r.done, ok: r.ok, urls: r.text ? [r.text] : [], message: r.message };
95
95
  },
96
96
  intervalMs: 2500,
@@ -102,7 +102,7 @@ export function pollDeps(provider, cap) {
102
102
  }
103
103
  return {
104
104
  key: () => provider.apiKey,
105
- poll: ({ key, remoteTaskId }) => adapters.pollTask({ key, remoteTaskId }),
105
+ poll: ({ key, remoteTaskId }) => adapters.pollTask({ key, baseUrl: provider.baseUrl, remoteTaskId }),
106
106
  intervalMs: cap === 'video' ? 6000 : 2500,
107
107
  onSuccess: async (t, r) => {
108
108
  fs.mkdirSync(outputDir(), { recursive: true });
@@ -156,7 +156,7 @@ function fmtTask(t) {
156
156
  return lines.join('\n');
157
157
  }
158
158
 
159
- export const name = 'dsh-iris';
159
+ export const name = 'mokuyoaxis-dsh-iris';
160
160
  export const inject = ['tools'];
161
161
 
162
162
  /**
@@ -181,14 +181,18 @@ function safeRegister(ctx, definition) {
181
181
  }
182
182
 
183
183
  export async function apply(ctx) {
184
+ guard('Iris 私有存储权限收紧', () => hardenPrivateTree(store.irisHome()));
184
185
 
185
186
  guard('上传临时文件清理', () => purgeStaleUploads());
186
187
 
187
- // 首次运行:从工作台导入(幂等)
188
+ // 仅按用户显式指定的路径导入旧配置;不扫描其他项目。
188
189
  guard('工作台导入', () => {
189
- const wb = path.join(process.env.HOME || os.homedir(), 'projects', 'ai-paint', 'data', 'config.json');
190
+ const wb = process.env.IRIS_IMPORT_WORKBENCH_CONFIG;
191
+ if (!wb) return;
192
+ if (!path.isAbsolute(wb)) throw new Error('IRIS_IMPORT_WORKBENCH_CONFIG 必须是绝对路径');
190
193
  const imported = store.importFromWorkbench(wb);
191
194
  if (imported.imported) console.log(`[iris] 已从工作台导入 ${imported.imported} 个服务商`);
195
+ else console.log('[iris] 工作台导入:' + imported.reason);
192
196
  });
193
197
 
194
198
  // 接管上次进程未跑完的异步任务(恢复循环本身在 tasks.js 里逐任务防御)
@@ -211,6 +215,15 @@ export async function apply(ctx) {
211
215
  // 若 webServer 已在运行,inject 回调也会触发;这里兜底立即挂载一次
212
216
  guard('媒体路由兜底挂载', () => mountIrisRoutes(ctx));
213
217
 
218
+ // DSH 的 Skill registry 是可选宿主能力。存在时把 npm 包内两项 Skill 注册到
219
+ // 全局层;项目目录中的同名 Skill 仍由 DSH 原生优先级覆盖。
220
+ ctx.inject(['skills'], (skillCtx) => {
221
+ guard('随包 Skill 注册', () => {
222
+ const names = registerBundledSkills(skillCtx);
223
+ console.log('[iris] 随包 Skill 已注册:' + names.join(' · '));
224
+ });
225
+ });
226
+
214
227
  const tools = ctx.tools;
215
228
  if (!tools || typeof tools.register !== 'function') {
216
229
  console.error('[iris] tools 服务不可用,跳过全部工具注册(宿主不受影响)');
@@ -1025,4 +1038,3 @@ async function describeFirstImage(ctx, ref, absPath, originalPrompt) {
1025
1038
  return ''; // 自述是增强项,失败不影响交付
1026
1039
  }
1027
1040
  }
1028
-
package/lib/locate.js CHANGED
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * Iris 模型驱动定位(阶段 3A):iris_locate。
4
4
  *
5
- * 对齐 RESEARCH 4.2a:返回**原图像素 bbox**(x1/y1/x2/y2),不是相对或归一化坐标,
5
+ * 返回**原图像素 bbox**(x1/y1/x2/y2),不是相对或归一化坐标,
6
6
  * 保证 iris_crop 可以无缝接力裁剪(vision-router 的 vision_ground 同款契约)。
7
7
  *
8
8
  * 实现:
@@ -2,11 +2,11 @@
2
2
  /**
3
3
  * Iris 媒体分析探测(阶段 7.1)—— 视频抽帧。
4
4
  *
5
- * ffmpeg 是可选系统可执行文件:启动时用 `which` 探测,缺失则 `ffmpegAvailable()` 返回
5
+ * ffmpeg 是可选系统可执行文件:启动时直接执行版本探测,缺失则 `ffmpegAvailable()` 返回
6
6
  * false,抽帧工具在执行时报清晰的人话错误(其余功能不受影响)。静态版 ffmpeg
7
7
  * (johnvansickle)同样可用。本模块为纯后端:零 npm 依赖,可离线单测。
8
8
  *
9
- * 契约(对齐 RESEARCH §9.2):
9
+ * 抽帧契约:
10
10
  * - 最多帧数上限 20(覆盖 5 分钟短视频,不超 LLM 上下文窗口);
11
11
  * - 最大宽度 4096(含),缩放到 targetWidth,高度按比例取偶数;
12
12
  * - 帧数 = 请求数(时间均匀采样),短视频也能取到整 N 帧;
@@ -24,8 +24,8 @@ export const MIN_WIDTH = 16;
24
24
  /** 探测 ffmpeg / ffprobe 是否可用 */
25
25
  export function ffmpegAvailable() {
26
26
  try {
27
- const ff = spawnSync('which', ['ffmpeg'], { encoding: 'utf8' });
28
- const fp = spawnSync('which', ['ffprobe'], { encoding: 'utf8' });
27
+ const ff = spawnSync('ffmpeg', ['-version'], { stdio: 'ignore', windowsHide: true });
28
+ const fp = spawnSync('ffprobe', ['-version'], { stdio: 'ignore', windowsHide: true });
29
29
  return ff.status === 0 && fp.status === 0;
30
30
  } catch (_) {
31
31
  return false;
package/lib/media.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * Iris 媒体通道 —— 让视频/音频像图片一样在对话流里可点可播。
4
4
  *
5
5
  * 背景:DSH 附件服务只收图片(saveImage/readImage),mp4/wav 无原生通路。
6
- * 方案(学 vision-mix):宿内 /iris/media 路由 + 工具结果返回播放链接。
6
+ * 方案:宿内 /iris/media 路由 + 工具结果返回播放链接。
7
7
  *
8
8
  * 安全边界:
9
9
  * - token 是能力凭证:crypto 随机 128bit,只存在任务记录里,URL 不可枚举
@@ -0,0 +1,77 @@
1
+ 'use strict';
2
+ /**
3
+ * Iris 私有存储原语。
4
+ *
5
+ * POSIX 上目录统一 0700、文件统一 0600;Windows 的 mode 不能替代 ACL,
6
+ * 仍在创建时传入最小 mode,但公开文档不会把它描述成 Windows 访问控制。
7
+ */
8
+ import crypto from 'node:crypto';
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+
12
+ const HAS_POSIX_MODE = process.platform !== 'win32';
13
+
14
+ function tryChmod(target, mode) {
15
+ if (!HAS_POSIX_MODE) return;
16
+ try { fs.chmodSync(target, mode); } catch (_) { /* 调用方仍可继续使用宿主文件系统 */ }
17
+ }
18
+
19
+ export function ensurePrivateDir(dir) {
20
+ const existed = fs.existsSync(dir);
21
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
22
+ // 不擅自 chmod 调用方传入的既有父目录(例如适配器单测使用 os.tmpdir)。
23
+ // Iris 自有既有树由启动时 hardenPrivateTree() 明确收紧。
24
+ if (!existed) tryChmod(dir, 0o700);
25
+ return dir;
26
+ }
27
+
28
+ export function chmodPrivateFile(file) {
29
+ tryChmod(file, 0o600);
30
+ }
31
+
32
+ export function writePrivateFile(file, data, options = {}) {
33
+ ensurePrivateDir(path.dirname(file));
34
+ fs.writeFileSync(file, data, { ...options, mode: 0o600 });
35
+ chmodPrivateFile(file);
36
+ }
37
+
38
+ export function privateSibling(file, label = 'tmp') {
39
+ return `${file}.${label}-${process.pid}-${crypto.randomBytes(6).toString('hex')}`;
40
+ }
41
+
42
+ export function atomicWritePrivate(file, data) {
43
+ ensurePrivateDir(path.dirname(file));
44
+ const tmp = privateSibling(file);
45
+ try {
46
+ fs.writeFileSync(tmp, data, { mode: 0o600, flag: 'wx' });
47
+ fs.renameSync(tmp, file);
48
+ chmodPrivateFile(file);
49
+ } finally {
50
+ try { fs.rmSync(tmp, { force: true }); } catch (_) { /* rename 成功后临时文件已不存在 */ }
51
+ }
52
+ }
53
+
54
+ /** 收紧既有 Iris 树;不跟随符号链接,也不修改文件内容。 */
55
+ export function hardenPrivateTree(root) {
56
+ ensurePrivateDir(root);
57
+ if (!HAS_POSIX_MODE) return { directories: 0, files: 0, skipped: true };
58
+ let directories = 0;
59
+ let files = 0;
60
+ const walk = (target) => {
61
+ let st;
62
+ try { st = fs.lstatSync(target); } catch (_) { return; }
63
+ if (st.isSymbolicLink()) return;
64
+ if (st.isDirectory()) {
65
+ tryChmod(target, 0o700);
66
+ directories++;
67
+ for (const name of fs.readdirSync(target)) walk(path.join(target, name));
68
+ return;
69
+ }
70
+ if (st.isFile()) {
71
+ tryChmod(target, 0o600);
72
+ files++;
73
+ }
74
+ };
75
+ walk(root);
76
+ return { directories, files, skipped: false };
77
+ }
@@ -0,0 +1,170 @@
1
+ 'use strict';
2
+ /**
3
+ * Iris 提示词优化器配置。
4
+ *
5
+ * 与 providers.json 分离保存:这里不含凭据,只记录用户可导入/导出的 Prompt、
6
+ * 目标媒介要求与模型路由偏好。损坏配置会被隔离,运行时回退到内置默认值。
7
+ */
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { irisHome } from './config.js';
11
+ import { atomicWritePrivate, chmodPrivateFile, privateSibling } from './private-storage.js';
12
+
13
+ export const PROMPT_TARGETS = ['general', 'image', 'video', 's2v'];
14
+
15
+ const DEFAULT_VALUE = {
16
+ version: 1,
17
+ enabled: true,
18
+ systemPrompt: [
19
+ '你是 Iris 提示词编辑器。把用户草稿改写成一段可直接交给目标 AI 的高质量提示词。',
20
+ '必须保留原始意图、事实、专有名词、引号内原文、硬性约束和否定条件;不得臆造用户没有提供的事实。',
21
+ '只补充能提高可执行性、结构清晰度、风格一致性和验收明确度的具体信息。信息不足时保持克制,不替用户作关键决定。',
22
+ '默认沿用草稿的语言。只输出优化后的提示词正文,不解释、不加标题、不使用 Markdown 代码块。'
23
+ ].join('\n'),
24
+ targets: {
25
+ general: '适用于一般对话任务:明确目标、必要背景、约束、期望输出和验收标准;避免无意义地拉长文本。',
26
+ image: '适用于图像生成:在原意允许的范围内明确主体、环境、构图、视角、光线、色彩、材质、风格和负面约束。',
27
+ video: '适用于视频生成:在原意允许的范围内明确主体动作、场景变化、镜头运动、景别、节奏、时长感、连续性和负面约束。',
28
+ s2v: '适用于首尾帧视频:明确首帧到尾帧之间的动作、镜头、空间关系和连续性,避免引入与两端画面冲突的新主体。'
29
+ },
30
+ route: {
31
+ mode: 'session'
32
+ },
33
+ generation: {
34
+ temperature: 0.3,
35
+ reasoningEffort: 'off-if-supported',
36
+ maxOutputTokens: 1200,
37
+ timeoutMs: 45000
38
+ }
39
+ };
40
+
41
+ export const DEFAULT_PROMPT_OPTIMIZER_CONFIG = Object.freeze(structuredClone(DEFAULT_VALUE));
42
+
43
+ function configFile() {
44
+ return path.join(irisHome(), 'prompt-optimizer.json');
45
+ }
46
+
47
+ function plain(value) {
48
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
49
+ }
50
+
51
+ function text(value, fallback, field, maxLength) {
52
+ if (value === undefined) return fallback;
53
+ if (typeof value !== 'string' || !value.trim()) throw new Error(`${field} 必须是非空字符串`);
54
+ if (value.length > maxLength) throw new Error(`${field} 不能超过 ${maxLength} 个字符`);
55
+ return value.trim();
56
+ }
57
+
58
+ function boundedNumber(value, fallback, field, min, max) {
59
+ if (value === undefined) return fallback;
60
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < min || value > max) {
61
+ throw new Error(`${field} 必须是 ${min}–${max} 之间的数字`);
62
+ }
63
+ return value;
64
+ }
65
+
66
+ /** 规范化用户导入的完整或局部 JSON,不保留未知字段。 */
67
+ export function normalizePromptOptimizerConfig(input) {
68
+ if (!plain(input)) throw new Error('提示词优化器配置根节点必须是对象');
69
+ if (input.version !== undefined && input.version !== 1) throw new Error('仅支持 version: 1');
70
+
71
+ const targetInput = input.targets === undefined ? {} : input.targets;
72
+ if (!plain(targetInput)) throw new Error('targets 必须是对象');
73
+ const targets = {};
74
+ for (const target of PROMPT_TARGETS) {
75
+ targets[target] = text(targetInput[target], DEFAULT_VALUE.targets[target], `targets.${target}`, 8000);
76
+ }
77
+
78
+ const routeInput = input.route === undefined ? {} : input.route;
79
+ if (!plain(routeInput)) throw new Error('route 必须是对象');
80
+ const mode = routeInput.mode === undefined ? DEFAULT_VALUE.route.mode : routeInput.mode;
81
+ if (mode !== 'session' && mode !== 'fixed') throw new Error('route.mode 只能是 session 或 fixed');
82
+ const route = { mode };
83
+ if (mode === 'fixed') {
84
+ route.provider = text(routeInput.provider, undefined, 'route.provider', 256);
85
+ route.model = text(routeInput.model, undefined, 'route.model', 256);
86
+ if (!route.provider || !route.model) throw new Error('fixed 路由必须同时提供 route.provider 与 route.model');
87
+ }
88
+
89
+ const generationInput = input.generation === undefined ? {} : input.generation;
90
+ if (!plain(generationInput)) throw new Error('generation 必须是对象');
91
+ const generation = {
92
+ temperature: boundedNumber(generationInput.temperature, DEFAULT_VALUE.generation.temperature, 'generation.temperature', 0, 2),
93
+ reasoningEffort: text(generationInput.reasoningEffort, DEFAULT_VALUE.generation.reasoningEffort, 'generation.reasoningEffort', 128),
94
+ maxOutputTokens: Math.round(boundedNumber(generationInput.maxOutputTokens, DEFAULT_VALUE.generation.maxOutputTokens, 'generation.maxOutputTokens', 64, 4096)),
95
+ timeoutMs: Math.round(boundedNumber(generationInput.timeoutMs, DEFAULT_VALUE.generation.timeoutMs, 'generation.timeoutMs', 1000, 120000))
96
+ };
97
+
98
+ if (input.enabled !== undefined && typeof input.enabled !== 'boolean') throw new Error('enabled 必须是布尔值');
99
+
100
+ return {
101
+ version: 1,
102
+ enabled: input.enabled === undefined ? DEFAULT_VALUE.enabled : input.enabled,
103
+ systemPrompt: text(input.systemPrompt, DEFAULT_VALUE.systemPrompt, 'systemPrompt', 16000),
104
+ targets,
105
+ route,
106
+ generation
107
+ };
108
+ }
109
+
110
+ let cache = null;
111
+ let cacheSource = 'default';
112
+
113
+ export function loadPromptOptimizerConfig() {
114
+ if (cache) return { source: cacheSource, config: structuredClone(cache) };
115
+ const file = configFile();
116
+ if (!fs.existsSync(file)) {
117
+ cache = structuredClone(DEFAULT_VALUE);
118
+ cacheSource = 'default';
119
+ return { source: cacheSource, config: structuredClone(cache) };
120
+ }
121
+ try {
122
+ cache = normalizePromptOptimizerConfig(JSON.parse(fs.readFileSync(file, 'utf8')));
123
+ cacheSource = JSON.stringify(cache) === JSON.stringify(DEFAULT_VALUE) ? 'default' : 'custom';
124
+ } catch (err) {
125
+ const backup = privateSibling(file, 'corrupted');
126
+ try {
127
+ fs.renameSync(file, backup);
128
+ chmodPrivateFile(backup);
129
+ } catch (_) { /* 隔离失败也必须能使用内置默认值 */ }
130
+ console.error('[iris] prompt-optimizer.json 已损坏,已回退内置默认值:', err && err.message);
131
+ cache = structuredClone(DEFAULT_VALUE);
132
+ cacheSource = 'default';
133
+ }
134
+ return { source: cacheSource, config: structuredClone(cache) };
135
+ }
136
+
137
+ export function importPromptOptimizerConfig(input) {
138
+ const next = normalizePromptOptimizerConfig(input);
139
+ atomicWritePrivate(configFile(), JSON.stringify(next, null, 2));
140
+ cache = next;
141
+ cacheSource = 'custom';
142
+ return { source: cacheSource, config: structuredClone(cache) };
143
+ }
144
+
145
+ export function setPromptOptimizerEnabled(enabled) {
146
+ if (typeof enabled !== 'boolean') throw new Error('enabled 必须是布尔值');
147
+ const next = normalizePromptOptimizerConfig({ ...loadPromptOptimizerConfig().config, enabled });
148
+ atomicWritePrivate(configFile(), JSON.stringify(next, null, 2));
149
+ cache = next;
150
+ cacheSource = JSON.stringify(cache) === JSON.stringify(DEFAULT_VALUE) ? 'default' : 'custom';
151
+ return { source: cacheSource, config: structuredClone(cache) };
152
+ }
153
+
154
+ export function resetPromptOptimizerConfig() {
155
+ const next = structuredClone(DEFAULT_VALUE);
156
+ atomicWritePrivate(configFile(), JSON.stringify(next, null, 2));
157
+ cache = next;
158
+ cacheSource = 'default';
159
+ return { source: cacheSource, config: structuredClone(cache) };
160
+ }
161
+
162
+ /** 仅供测试/热重载丢弃进程内缓存。 */
163
+ export function resetPromptOptimizerConfigCache() {
164
+ cache = null;
165
+ cacheSource = 'default';
166
+ }
167
+
168
+ export function promptOptimizerConfigFile() {
169
+ return configFile();
170
+ }