@mokuyoaxis/dsh-iris 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +75 -1
- package/README.en.md +259 -0
- package/README.md +70 -7
- package/bin/dsh-iris.js +23 -0
- package/cordis.patch.yml +1 -1
- package/docs/ARCHITECTURE.md +81 -0
- package/docs/FAULT_INJECTION.md +26 -0
- package/docs/PROVIDER_SUBMISSION_CONTRACT.md +60 -0
- package/docs/ROADMAP.md +25 -8
- package/docs/SECURITY.md +53 -0
- package/docs/TASK_SEMANTICS.md +147 -0
- 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.2.md +26 -0
- package/docs/releases/0.1.3.md +30 -0
- package/docs/screenshots.md +34 -0
- package/lib/actions.js +609 -175
- package/lib/api.js +122 -18
- package/lib/bundled-skills.js +62 -0
- package/lib/client.js +460 -22
- package/lib/doctor.js +243 -0
- package/lib/index.js +57 -46
- package/lib/prompt-optimizer-config.js +170 -0
- package/lib/prompt-optimizer.js +244 -0
- package/lib/provider-contract.js +234 -0
- package/lib/task-semantics.js +209 -0
- package/lib/tasks.js +473 -29
- package/package.json +13 -4
- package/screenshots.json +6 -0
- package/user_guide.md +84 -3
package/lib/doctor.js
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
7
|
+
import { attentionDisposition, deriveUserState, semanticViolations } from './task-semantics.js';
|
|
8
|
+
|
|
9
|
+
const PACKAGE_FILE = new URL('../package.json', import.meta.url);
|
|
10
|
+
|
|
11
|
+
function readPackageVersion() {
|
|
12
|
+
try { return JSON.parse(fs.readFileSync(PACKAGE_FILE, 'utf8')).version || 'unknown'; } catch (_) { return 'unknown'; }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function result(id, status, summary, suggestion) {
|
|
16
|
+
return { id, status, summary, ...(suggestion ? { suggestion } : {}) };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function parseVersion(value) {
|
|
20
|
+
const match = String(value || '').match(/^(\d+)\.(\d+)\.(\d+)/);
|
|
21
|
+
return match ? match.slice(1).map(Number) : [0, 0, 0];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function atLeast(actual, required) {
|
|
25
|
+
for (let i = 0; i < 3; i++) {
|
|
26
|
+
if (actual[i] !== required[i]) return actual[i] > required[i];
|
|
27
|
+
}
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function readJson(file) {
|
|
32
|
+
if (!fs.existsSync(file)) return { exists: false, value: null };
|
|
33
|
+
try { return { exists: true, value: JSON.parse(fs.readFileSync(file, 'utf8')) }; }
|
|
34
|
+
catch (_) { return { exists: true, value: null, error: 'JSON 无法解析' }; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function plain(value) {
|
|
38
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isGroupOrWorldAccessible(target) {
|
|
42
|
+
if (process.platform === 'win32' || !fs.existsSync(target)) return false;
|
|
43
|
+
try { return (fs.lstatSync(target).mode & 0o077) !== 0; } catch (_) { return false; }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function privateFileModeProblems(dataDir) {
|
|
47
|
+
if (process.platform === 'win32') return [];
|
|
48
|
+
return ['providers.json', 'tasks.json', 'prompt-optimizer.json']
|
|
49
|
+
.filter((name) => isGroupOrWorldAccessible(path.join(dataDir, name)));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function commandAvailable(command, runner) {
|
|
53
|
+
try {
|
|
54
|
+
const run = runner(command, ['-version']);
|
|
55
|
+
return Boolean(run && run.status === 0 && !run.error);
|
|
56
|
+
} catch (_) { return false; }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function nearestExisting(target) {
|
|
60
|
+
let current = target;
|
|
61
|
+
for (;;) {
|
|
62
|
+
if (fs.existsSync(current)) return current;
|
|
63
|
+
const parent = path.dirname(current);
|
|
64
|
+
if (parent === current) return null;
|
|
65
|
+
current = parent;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function writableProbe(dataDir) {
|
|
70
|
+
const base = fs.existsSync(dataDir) ? dataDir : nearestExisting(path.dirname(dataDir));
|
|
71
|
+
if (!base) return false;
|
|
72
|
+
const probe = path.join(base, `.iris-doctor-${process.pid}-${crypto.randomBytes(4).toString('hex')}`);
|
|
73
|
+
try {
|
|
74
|
+
fs.writeFileSync(probe, 'probe', { flag: 'wx', mode: 0o600 });
|
|
75
|
+
fs.rmSync(probe);
|
|
76
|
+
return true;
|
|
77
|
+
} catch (_) {
|
|
78
|
+
try { fs.rmSync(probe, { force: true }); } catch (_) {}
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function inspectConfig(dataDir, checks) {
|
|
84
|
+
const config = readJson(path.join(dataDir, 'providers.json'));
|
|
85
|
+
if (!config.exists) {
|
|
86
|
+
checks.push(result('config', 'warn', '尚未创建供应商配置', '启动 Iris 后在工作台添加供应商,或准备好 DSH_HOME 后再运行 doctor。'));
|
|
87
|
+
return { providers: [], assignments: {} };
|
|
88
|
+
}
|
|
89
|
+
if (config.error || !plain(config.value) || !Array.isArray(config.value.providers)
|
|
90
|
+
|| !plain(config.value.assignments || {})) {
|
|
91
|
+
checks.push(result('config', 'error', 'providers.json 结构无效或无法解析', '先备份该文件,再从 Iris 工作台重新配置;不要把密钥粘贴到诊断输出。'));
|
|
92
|
+
return { providers: [], assignments: {} };
|
|
93
|
+
}
|
|
94
|
+
const providers = config.value.providers;
|
|
95
|
+
let incomplete = 0;
|
|
96
|
+
let enabled = 0;
|
|
97
|
+
let models = 0;
|
|
98
|
+
for (const provider of providers) {
|
|
99
|
+
if (!plain(provider)) { incomplete++; continue; }
|
|
100
|
+
if (provider.enabled !== false) enabled++;
|
|
101
|
+
if (provider.enabled !== false && (!String(provider.baseUrl || '').trim() || !String(provider.apiKey || '').trim())) incomplete++;
|
|
102
|
+
models += Array.isArray(provider.models) ? provider.models.length : 0;
|
|
103
|
+
}
|
|
104
|
+
if (incomplete) checks.push(result('providers', 'warn', `${incomplete} 个启用供应商缺少 Base URL、API Key 或结构无效`, '在 Iris 工作台补全或停用对应供应商。'));
|
|
105
|
+
else if (!enabled) checks.push(result('providers', 'warn', '没有启用的供应商', '在 Iris 工作台启用至少一个供应商。'));
|
|
106
|
+
else checks.push(result('providers', 'ok', `${enabled} 个供应商已启用;凭据未输出`));
|
|
107
|
+
const assignments = config.value.assignments || {};
|
|
108
|
+
const assignmentCount = Object.keys(assignments).length;
|
|
109
|
+
if (!models) checks.push(result('models', 'warn', '模型池为空或尚未发现模型', '在工作台对供应商执行“发现模型”,再核对能力标签。'));
|
|
110
|
+
else checks.push(result('models', 'ok', `模型池记录 ${models} 个模型,能力分配 ${assignmentCount} 项`));
|
|
111
|
+
return { providers, assignments };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function inspectTasks(dataDir, checks) {
|
|
115
|
+
const stored = readJson(path.join(dataDir, 'tasks.json'));
|
|
116
|
+
if (!stored.exists) {
|
|
117
|
+
checks.push(result('tasks', 'ok', '尚无任务记录'));
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
120
|
+
if (stored.error || !plain(stored.value) || !Array.isArray(stored.value.tasks)) {
|
|
121
|
+
checks.push(result('tasks', 'error', 'tasks.json 结构无效或无法解析', '保留损坏文件作为证据;重新启动 Iris 会尝试隔离并建立新任务表。'));
|
|
122
|
+
return [];
|
|
123
|
+
}
|
|
124
|
+
let invalid = 0;
|
|
125
|
+
let attention = 0;
|
|
126
|
+
for (const task of stored.value.tasks) {
|
|
127
|
+
if (!plain(task) || !String(task.id || '').trim()) { invalid++; continue; }
|
|
128
|
+
if (task.schemaVersion === 2) {
|
|
129
|
+
if (semanticViolations(task).length) invalid++;
|
|
130
|
+
const userState = deriveUserState(task);
|
|
131
|
+
if (['watching_paused', 'needs_attention', 'artifact_unavailable'].includes(userState)
|
|
132
|
+
&& attentionDisposition(task).status !== 'acknowledged') attention++;
|
|
133
|
+
} else if (task.status === 'running' && !task.remoteTaskId) attention++;
|
|
134
|
+
}
|
|
135
|
+
if (invalid) checks.push(result('tasks', 'error', `${invalid} 条任务记录语义或结构无效`, '不要手工改写任务事实;先备份 tasks.json 并保留损坏证据。'));
|
|
136
|
+
else if (attention) checks.push(result('tasks', 'warn', `${stored.value.tasks.length} 条任务中有 ${attention} 条需要人工确认`, '在 Iris 工作台的“需要处理”分区查看,并选择重新观察、重新交付、标为已读或知情重试。'));
|
|
137
|
+
else checks.push(result('tasks', 'ok', `${stored.value.tasks.length} 条任务记录结构有效`));
|
|
138
|
+
return stored.value.tasks.filter(plain);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function inspectArtifacts(dataDir, tasks, checks) {
|
|
142
|
+
const outputs = path.join(dataDir, 'outputs');
|
|
143
|
+
const referenced = new Set();
|
|
144
|
+
let missing = 0;
|
|
145
|
+
for (const task of tasks) {
|
|
146
|
+
for (const name of task.files || []) {
|
|
147
|
+
const safe = path.basename(String(name));
|
|
148
|
+
referenced.add(safe);
|
|
149
|
+
try { if (!fs.statSync(path.join(outputs, safe)).isFile()) missing++; } catch (_) { missing++; }
|
|
150
|
+
}
|
|
151
|
+
for (const item of task.media || []) if (item && item.file) referenced.add(path.basename(String(item.file)));
|
|
152
|
+
for (const item of task.attachments || []) if (item && item.file) referenced.add(path.basename(String(item.file)));
|
|
153
|
+
}
|
|
154
|
+
let orphan = 0;
|
|
155
|
+
if (fs.existsSync(outputs)) {
|
|
156
|
+
for (const name of fs.readdirSync(outputs)) {
|
|
157
|
+
const target = path.join(outputs, name);
|
|
158
|
+
try { if (fs.lstatSync(target).isFile() && !referenced.has(name)) orphan++; } catch (_) {}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (missing) checks.push(result('artifacts', 'warn', `${missing} 个任务产物不可访问;另有 ${orphan} 个孤儿文件`, '在工作台检查任务详情并运行孤儿产物扫描。'));
|
|
162
|
+
else if (orphan) checks.push(result('artifacts', 'warn', `${orphan} 个产物文件没有任务引用`, '先在工作台运行孤儿扫描,确认后再删除。'));
|
|
163
|
+
else checks.push(result('artifacts', 'ok', `${referenced.size} 个已引用产物可核对,未发现孤儿文件`));
|
|
164
|
+
|
|
165
|
+
const uploads = path.join(dataDir, 'uploads');
|
|
166
|
+
let temporary = 0;
|
|
167
|
+
if (fs.existsSync(uploads)) {
|
|
168
|
+
for (const name of fs.readdirSync(uploads)) if (name.endsWith('.part')) temporary++;
|
|
169
|
+
}
|
|
170
|
+
checks.push(temporary
|
|
171
|
+
? result('temporary-files', 'warn', `${temporary} 个上传临时 .part 文件残留`, '确认没有上传进行中后重启 Iris,由启动清理逻辑处理。')
|
|
172
|
+
: result('temporary-files', 'ok', '未发现上传临时文件残留'));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* 零网络离线诊断。仅创建并立即删除一个写入探针;不启动 DSH、不读取或输出 API Key。
|
|
177
|
+
*/
|
|
178
|
+
export async function doctor(options = {}) {
|
|
179
|
+
const dshHome = path.resolve(options.dshHome || process.env.DSH_HOME || path.join(os.homedir(), '.dsh'));
|
|
180
|
+
const dataDir = path.join(dshHome, 'iris', 'v1');
|
|
181
|
+
const runner = options.commandRunner || ((command, args) => spawnSync(command, args, { stdio: 'ignore', shell: false }));
|
|
182
|
+
const sharpLoader = options.sharpLoader || (() => import('sharp'));
|
|
183
|
+
const nodeVersion = options.nodeVersion || process.versions.node;
|
|
184
|
+
const checks = [];
|
|
185
|
+
|
|
186
|
+
checks.push(atLeast(parseVersion(nodeVersion), [20, 10, 0])
|
|
187
|
+
? result('node', 'ok', `Node.js ${nodeVersion}`)
|
|
188
|
+
: result('node', 'error', `Node.js ${nodeVersion} 低于最低要求 20.10.0`, '升级 Node.js 后再运行 Iris。'));
|
|
189
|
+
try { await sharpLoader(); checks.push(result('sharp', 'ok', 'sharp 可加载')); }
|
|
190
|
+
catch (_) { checks.push(result('sharp', 'error', 'sharp 无法加载', '在当前平台重新安装 @mokuyoaxis/dsh-iris,确认 sharp 有可用的 ARM64/系统构建。')); }
|
|
191
|
+
for (const command of ['ffmpeg', 'ffprobe']) {
|
|
192
|
+
checks.push(commandAvailable(command, runner)
|
|
193
|
+
? result(command, 'ok', `${command} 可用`)
|
|
194
|
+
: result(command, 'warn', `${command} 不可用`, '视频抽帧与摘要需要 ffmpeg/ffprobe;其他 Iris 能力不受影响。'));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const dataExists = fs.existsSync(dataDir);
|
|
198
|
+
if (dataExists) {
|
|
199
|
+
let symlink = false;
|
|
200
|
+
try { symlink = fs.lstatSync(dataDir).isSymbolicLink(); } catch (_) {}
|
|
201
|
+
if (symlink) checks.push(result('storage', 'error', 'Iris 数据目录是符号链接,私有边界无法保证', '改用真实的私有目录并人工迁移数据。'));
|
|
202
|
+
else if (!writableProbe(dataDir)) checks.push(result('storage', 'error', 'Iris 数据目录不可写', '修复当前用户对 $DSH_HOME/iris/v1 的写权限。'));
|
|
203
|
+
else if (isGroupOrWorldAccessible(dataDir)) checks.push(result('storage', 'warn', 'Iris 数据目录允许组或其他用户访问', '在 POSIX 上将目录权限收紧为 0700、文件收紧为 0600。'));
|
|
204
|
+
else {
|
|
205
|
+
const exposedFiles = privateFileModeProblems(dataDir);
|
|
206
|
+
checks.push(exposedFiles.length
|
|
207
|
+
? result('storage', 'warn', `${exposedFiles.length} 个私有数据文件允许组或其他用户访问`, '在 POSIX 上将 providers.json、tasks.json 与提示词配置权限收紧为 0600。')
|
|
208
|
+
: result('storage', 'ok', 'Iris 数据目录可写、私有文件权限合格,临时探针已清理'));
|
|
209
|
+
}
|
|
210
|
+
} else {
|
|
211
|
+
checks.push(writableProbe(dataDir)
|
|
212
|
+
? result('storage', 'ok', 'Iris 数据目录尚未创建;父目录可写,临时探针已清理')
|
|
213
|
+
: result('storage', 'error', 'Iris 数据目录不存在且父目录不可写', '设置一个当前用户可写的 DSH_HOME。'));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
inspectConfig(dataDir, checks);
|
|
217
|
+
const taskRecords = inspectTasks(dataDir, checks);
|
|
218
|
+
inspectArtifacts(dataDir, taskRecords, checks);
|
|
219
|
+
checks.push(result('scope', 'ok', '离线模式未启动 DSH、未检查浏览器/工具注册,也未发送供应商请求'));
|
|
220
|
+
|
|
221
|
+
const errors = checks.filter((item) => item.status === 'error').length;
|
|
222
|
+
const warnings = checks.filter((item) => item.status === 'warn').length;
|
|
223
|
+
return {
|
|
224
|
+
schemaVersion: 1,
|
|
225
|
+
package: { name: '@mokuyoaxis/dsh-iris', version: options.packageVersion || readPackageVersion() },
|
|
226
|
+
mode: 'offline',
|
|
227
|
+
environment: { node: nodeVersion, platform: process.platform, arch: process.arch },
|
|
228
|
+
checks,
|
|
229
|
+
summary: { ok: checks.length - errors - warnings, warnings, errors },
|
|
230
|
+
exitCode: errors ? 2 : (warnings ? 1 : 0)
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function formatDoctorReport(report) {
|
|
235
|
+
const mark = { ok: '✓', warn: '!', error: '✗' };
|
|
236
|
+
const lines = [`Iris Doctor ${report.package.version} · ${report.mode}`];
|
|
237
|
+
for (const check of report.checks) {
|
|
238
|
+
lines.push(`${mark[check.status] || '-'} ${check.id}: ${check.summary}`);
|
|
239
|
+
if (check.suggestion) lines.push(` 建议:${check.suggestion}`);
|
|
240
|
+
}
|
|
241
|
+
lines.push(`结果:${report.summary.errors} 错误,${report.summary.warnings} 警告`);
|
|
242
|
+
return lines.join('\n');
|
|
243
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -21,9 +21,11 @@ import { serveRender, BrowserHtmlRenderer } from './render.js';
|
|
|
21
21
|
import { ensurePrivateDir, hardenPrivateTree } from './private-storage.js';
|
|
22
22
|
import { guarded } from './guard.js';
|
|
23
23
|
import { longOcr } from './ocr.js';
|
|
24
|
-
import { runAction, listActions } from './actions.js';
|
|
24
|
+
import { runAction, listActions, taskPollDeps } 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';
|
|
28
|
+
export { doctor, formatDoctorReport } from './doctor.js';
|
|
27
29
|
|
|
28
30
|
const MEDIA_TYPES = {
|
|
29
31
|
'.png': 'image/png',
|
|
@@ -80,48 +82,47 @@ function mountIrisRoutes(routeCtx) {
|
|
|
80
82
|
}
|
|
81
83
|
}
|
|
82
84
|
|
|
83
|
-
/**
|
|
85
|
+
/** 向后兼容导出;真实实现与人工接管共用 actions.taskPollDeps。 */
|
|
84
86
|
export function pollDeps(provider, cap) {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
87
|
+
return taskPollDeps(provider, { cap });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 旧 status 与 Task v2 事实轴共同决定工具是否应停止等待。 */
|
|
91
|
+
export function taskWaitFinished(task) {
|
|
92
|
+
if (!task) return false;
|
|
93
|
+
if (task.status !== 'running') return true;
|
|
94
|
+
return tasks.isV2Task(task) && task.phase === 'terminal';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 把 v2 正交事实转换成不会误导用户的工具错误;空串表示已成功可交付。 */
|
|
98
|
+
export function taskTerminalProblem(task, label = '任务') {
|
|
99
|
+
if (!task) return label + '记录不存在';
|
|
100
|
+
if (!tasks.isV2Task(task)) {
|
|
101
|
+
if (task.status === 'canceled') return '已取消';
|
|
102
|
+
if (task.status !== 'succeeded') return label + '失败:' + (task.error || '未知原因');
|
|
103
|
+
return '';
|
|
101
104
|
}
|
|
102
|
-
return
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
}
|
|
118
|
-
};
|
|
105
|
+
if (task.outcome === 'succeeded' && task.deliveryState === 'ready') return '';
|
|
106
|
+
if (task.outcome === 'succeeded' && task.deliveryState === 'failed') {
|
|
107
|
+
return label + '已成功,但产物交付失败:' + (task.error || '可稍后重新交付;禁止重新生成');
|
|
108
|
+
}
|
|
109
|
+
if (task.acceptance === 'unknown' || task.outcome === 'unknown') {
|
|
110
|
+
return label + '的远端受理或结果状态未知:' + (task.error || '禁止自动重提,请先人工确认');
|
|
111
|
+
}
|
|
112
|
+
if (task.cancelState === 'unknown') {
|
|
113
|
+
return label + '的远端取消状态未知:' + (task.error || '请先人工确认');
|
|
114
|
+
}
|
|
115
|
+
if (task.outcome === 'canceled') return '已取消';
|
|
116
|
+
if (task.outcome === 'failed') return label + '失败:' + (task.error || '供应商明确返回失败');
|
|
117
|
+
return task.phase === 'terminal'
|
|
118
|
+
? label + '需要人工处理:' + (task.error || '状态未能自动收口')
|
|
119
|
+
: '';
|
|
119
120
|
}
|
|
120
121
|
|
|
121
122
|
/**
|
|
122
123
|
* 在工具调用内同步等待任务到终态。
|
|
123
124
|
* @returns 终态任务记录;null = 等待超时(任务已转后台继续盯守)
|
|
124
|
-
* @throws
|
|
125
|
+
* @throws 取消时抛错并记录保守取消事实
|
|
125
126
|
*/
|
|
126
127
|
async function awaitTerminal(taskId, { timeoutMs, signal }) {
|
|
127
128
|
const deadline = Date.now() + timeoutMs;
|
|
@@ -131,7 +132,7 @@ async function awaitTerminal(taskId, { timeoutMs, signal }) {
|
|
|
131
132
|
throw new Error('已取消');
|
|
132
133
|
}
|
|
133
134
|
const t = tasks.get(taskId);
|
|
134
|
-
if (t
|
|
135
|
+
if (taskWaitFinished(t)) return t;
|
|
135
136
|
if (Date.now() > deadline) return null;
|
|
136
137
|
await new Promise((r) => setTimeout(r, 1000));
|
|
137
138
|
}
|
|
@@ -155,7 +156,7 @@ function fmtTask(t) {
|
|
|
155
156
|
return lines.join('\n');
|
|
156
157
|
}
|
|
157
158
|
|
|
158
|
-
export const name = 'dsh-iris';
|
|
159
|
+
export const name = 'mokuyoaxis-dsh-iris';
|
|
159
160
|
export const inject = ['tools'];
|
|
160
161
|
|
|
161
162
|
/**
|
|
@@ -214,6 +215,15 @@ export async function apply(ctx) {
|
|
|
214
215
|
// 若 webServer 已在运行,inject 回调也会触发;这里兜底立即挂载一次
|
|
215
216
|
guard('媒体路由兜底挂载', () => mountIrisRoutes(ctx));
|
|
216
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
|
+
|
|
217
227
|
const tools = ctx.tools;
|
|
218
228
|
if (!tools || typeof tools.register !== 'function') {
|
|
219
229
|
console.error('[iris] tools 服务不可用,跳过全部工具注册(宿主不受影响)');
|
|
@@ -246,14 +256,14 @@ export async function apply(ctx) {
|
|
|
246
256
|
if (!prompt) throw new Error('iris: prompt 不能为空');
|
|
247
257
|
const submitted = await runAction(ctx, 'image', args, { signal: exec.signal });
|
|
248
258
|
let final = tasks.get(submitted.taskId);
|
|
249
|
-
if (final && final
|
|
259
|
+
if (final && !taskWaitFinished(final)) {
|
|
250
260
|
final = await awaitTerminal(submitted.taskId, { timeoutMs: AWAIT_MS.image, signal: exec.signal });
|
|
251
261
|
}
|
|
252
262
|
if (!final) {
|
|
253
263
|
return { blocks: [{ type: 'text', text: `[iris] 图像任务仍在后台盯守(task: ${submitted.taskId})。稍后可用 iris_task_status 查询进度与文件路径。` }] };
|
|
254
264
|
}
|
|
255
|
-
|
|
256
|
-
if (
|
|
265
|
+
const terminalProblem = taskTerminalProblem(final, '图像生成');
|
|
266
|
+
if (terminalProblem) throw new Error(terminalProblem);
|
|
257
267
|
|
|
258
268
|
const files = (final.files || []).map((f) => path.join(outputDir(), f));
|
|
259
269
|
const blocks = [];
|
|
@@ -319,8 +329,8 @@ export async function apply(ctx) {
|
|
|
319
329
|
return `[iris] 视频任务已提交并转入后台盯守(task: ${submitted.taskId},remoteTask: ${submitted.remoteTaskId})。\n` +
|
|
320
330
|
'渲染通常需要数分钟;完成后用 iris_task_status 查询产物路径。';
|
|
321
331
|
}
|
|
322
|
-
|
|
323
|
-
if (
|
|
332
|
+
const terminalProblem = taskTerminalProblem(final, '视频生成');
|
|
333
|
+
if (terminalProblem) throw new Error(terminalProblem);
|
|
324
334
|
const paths = (final.files || []).map((f) => path.join(outputDir(), f));
|
|
325
335
|
const links = mediaLinksOf(final);
|
|
326
336
|
const secs = Math.round((Date.now() - new Date(final.createdAt).getTime()) / 1000);
|
|
@@ -349,7 +359,8 @@ export async function apply(ctx) {
|
|
|
349
359
|
async execute(args, exec) {
|
|
350
360
|
const submitted = await runAction(ctx, 'tts', args, { signal: exec.signal });
|
|
351
361
|
const final = tasks.get(submitted.taskId);
|
|
352
|
-
|
|
362
|
+
const terminalProblem = taskTerminalProblem(final, '语音合成');
|
|
363
|
+
if (terminalProblem) throw new Error(terminalProblem);
|
|
353
364
|
const fileName = (final.files || [])[0];
|
|
354
365
|
const p = path.join(outputDir(), fileName);
|
|
355
366
|
const media = (final.media || [])[0];
|
|
@@ -376,8 +387,8 @@ export async function apply(ctx) {
|
|
|
376
387
|
const submitted = await runAction(ctx, 'transcribe', args, { signal: exec.signal });
|
|
377
388
|
const final = await awaitTerminal(submitted.taskId, { timeoutMs: 120000, signal: exec.signal });
|
|
378
389
|
if (!final) return `[iris] 音频转写任务已提交并转入后台(task: ${submitted.taskId})。完成后用 iris_task_status 查询。`;
|
|
379
|
-
|
|
380
|
-
if (
|
|
390
|
+
const terminalProblem = taskTerminalProblem(final, '音频转写');
|
|
391
|
+
if (terminalProblem) throw new Error(terminalProblem);
|
|
381
392
|
return `[iris] 音频转写完成(${submitted.model || final.model}):\n${final.transcribeText || ''}`;
|
|
382
393
|
}
|
|
383
394
|
});
|
|
@@ -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
|
+
}
|