@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/config.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
/**
|
|
3
3
|
* Iris 配置存储:$DSH_HOME/iris/v1/providers.json
|
|
4
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
if (
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
124
|
+
const list = Array.isArray(raw?.providers) ? raw.providers : [];
|
|
95
125
|
let n = 0;
|
|
96
126
|
for (const p of list) {
|
|
97
|
-
if (!p ||
|
|
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
|
-
|
|
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
|
|
3
|
+
* Iris 请求守卫。
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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 (
|
|
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 };
|
|
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
|
-
|
|
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 =
|
|
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
package/lib/media-probe.js
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Iris 媒体分析探测(阶段 7.1)—— 视频抽帧。
|
|
4
4
|
*
|
|
5
|
-
* ffmpeg
|
|
5
|
+
* ffmpeg 是可选系统可执行文件:启动时直接执行版本探测,缺失则 `ffmpegAvailable()` 返回
|
|
6
6
|
* false,抽帧工具在执行时报清晰的人话错误(其余功能不受影响)。静态版 ffmpeg
|
|
7
7
|
* (johnvansickle)同样可用。本模块为纯后端:零 npm 依赖,可离线单测。
|
|
8
8
|
*
|
|
9
|
-
*
|
|
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('
|
|
28
|
-
const fp = spawnSync('
|
|
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
|
@@ -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
|
+
}
|