@mokuyoaxis/dsh-iris 0.1.3 → 0.1.4
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/.dsh/skills/iris-compose-media/SKILL.md +23 -49
- package/.dsh/skills/iris-compose-media/references/task-v2-recovery.md +30 -0
- package/.dsh/skills/iris-compose-media/references/workflows.md +38 -0
- package/.dsh/skills/iris-verify-ui/SKILL.md +3 -25
- package/.dsh/skills/iris-verify-ui/agents/openai.yaml +4 -0
- package/.dsh/skills/iris-verify-ui/references/verdict-and-degradation.md +31 -0
- package/CHANGELOG.md +24 -2
- package/README.en.md +29 -7
- package/README.md +29 -7
- package/docs/ARCHITECTURE.md +27 -13
- package/docs/ARTIFACT_LIBRARY.md +24 -0
- package/docs/HOST_ADAPTER_CONTRACT.md +86 -0
- package/docs/HOST_DOCTOR.md +47 -0
- package/docs/PROVIDER_ADAPTER_CONTRACT.md +97 -0
- package/docs/PROVIDER_HEALTH.md +39 -0
- package/docs/PROVIDER_SUBMISSION_CONTRACT.md +2 -2
- package/docs/ROADMAP.md +18 -8
- package/docs/SECURITY.md +6 -2
- package/docs/TASK_SEMANTICS.md +3 -2
- package/docs/assets/diagrams/iris-architecture.png +0 -0
- package/docs/assets/diagrams/iris-architecture.svg +5 -5
- package/docs/releases/0.1.4.md +59 -0
- package/lib/actions.js +357 -221
- package/lib/adapters.js +7 -7
- package/lib/api.js +30 -11
- package/lib/artifacts.js +239 -0
- package/lib/bundled-skills.js +5 -5
- package/lib/client.js +370 -55
- package/lib/config.js +192 -9
- package/lib/doctor.js +175 -4
- package/lib/dsh-host-adapter.js +189 -0
- package/lib/host-contract.js +131 -0
- package/lib/host-runtime.js +156 -0
- package/lib/index.js +97 -113
- package/lib/locate.js +1 -1
- package/lib/media.js +16 -6
- package/lib/prompt-optimizer.js +10 -20
- package/lib/provider-adapter.js +371 -0
- package/lib/provider-adapters.js +279 -0
- package/lib/provider-health.js +59 -0
- package/lib/render.js +43 -12
- package/lib/tasks.js +29 -5
- package/lib/vision.js +23 -49
- package/package.json +14 -8
- package/user_guide.md +42 -2
- package/docs/assets/diagrams/iris-architecture.drawio +0 -1
- package/docs/assets/diagrams/iris-task-lifecycle.drawio +0 -1
- package/docs/assets/diagrams/iris-workflow-compose.drawio +0 -1
- package/docs/releases/0.1.1.md +0 -51
- package/docs/releases/0.1.2.md +0 -26
- package/docs/releases/0.1.3.md +0 -30
package/lib/adapters.js
CHANGED
|
@@ -236,11 +236,11 @@ export async function uploadTempFile({ key, baseUrl, model, filePath, timeoutMs
|
|
|
236
236
|
const TERMINAL = new Set(['SUCCEEDED', 'FAILED', 'CANCELED', 'UNKNOWN']);
|
|
237
237
|
|
|
238
238
|
/** @returns {{done:boolean, ok:boolean, urls:string[], message?:string}} */
|
|
239
|
-
export async function pollTask({ key, baseUrl, remoteTaskId, timeoutMs = T_POLL }) {
|
|
239
|
+
export async function pollTask({ key, baseUrl, remoteTaskId, timeoutMs = T_POLL, signal }) {
|
|
240
240
|
const apiBase = dashscopeApiBase(baseUrl);
|
|
241
241
|
const res = await fetch(`${apiBase}/tasks/${encodeURIComponent(remoteTaskId)}`, {
|
|
242
242
|
headers: authHeaders(key),
|
|
243
|
-
signal: timeoutSignal(timeoutMs)
|
|
243
|
+
signal: timeoutSignal(timeoutMs, signal)
|
|
244
244
|
});
|
|
245
245
|
const body = await readJson(res);
|
|
246
246
|
if (!res.ok) throw providerError(res.status, body, '查询任务失败');
|
|
@@ -323,10 +323,10 @@ export async function openAiGenerateImage({ key, baseUrl, model, prompt, size =
|
|
|
323
323
|
* 免费只读;返回 [{ id }] 原样(分类由 models.js 规则负责)。
|
|
324
324
|
* @returns {Promise<string[]>}
|
|
325
325
|
*/
|
|
326
|
-
export async function listModels({ key, baseUrl, timeoutMs = T_SUBMIT }) {
|
|
326
|
+
export async function listModels({ key, baseUrl, timeoutMs = T_SUBMIT, signal }) {
|
|
327
327
|
const base = String(baseUrl || '').trim().replace(/\/+$/, '');
|
|
328
328
|
if (!base) throw new Error('iris: 供应商缺 baseUrl,无法发现模型');
|
|
329
|
-
const res = await fetch(`${base}/models`, { headers: authHeaders(key), signal: timeoutSignal(timeoutMs) });
|
|
329
|
+
const res = await fetch(`${base}/models`, { headers: authHeaders(key), signal: timeoutSignal(timeoutMs, signal) });
|
|
330
330
|
const body = await readJson(res);
|
|
331
331
|
if (!res.ok) throw providerError(res.status, body, '模型发现失败');
|
|
332
332
|
const items = (body && Array.isArray(body.data) && body.data) || (Array.isArray(body && body.models) ? body.models : []);
|
|
@@ -476,11 +476,11 @@ export function transcriptionText(payload) {
|
|
|
476
476
|
}
|
|
477
477
|
|
|
478
478
|
/** 轮询 ASR 任务;正式结果位于 results[].transcription_url 指向的 JSON。 */
|
|
479
|
-
export async function pollTranscriptionTask({ key, baseUrl, remoteTaskId, timeoutMs = T_POLL }) {
|
|
479
|
+
export async function pollTranscriptionTask({ key, baseUrl, remoteTaskId, timeoutMs = T_POLL, signal }) {
|
|
480
480
|
const apiBase = dashscopeApiBase(baseUrl);
|
|
481
481
|
const res = await fetch(`${apiBase}/tasks/${encodeURIComponent(remoteTaskId)}`, {
|
|
482
482
|
headers: authHeaders(key),
|
|
483
|
-
signal: timeoutSignal(timeoutMs)
|
|
483
|
+
signal: timeoutSignal(timeoutMs, signal)
|
|
484
484
|
});
|
|
485
485
|
const body = await readJson(res);
|
|
486
486
|
if (!res.ok) throw providerError(res.status, body, '查询转写任务失败');
|
|
@@ -499,7 +499,7 @@ export async function pollTranscriptionTask({ key, baseUrl, remoteTaskId, timeou
|
|
|
499
499
|
if (typeof result.transcription_text === 'string') text += result.transcription_text;
|
|
500
500
|
else if (typeof result.text === 'string') text += result.text;
|
|
501
501
|
if (status === 'SUCCEEDED' && result.subtask_status !== 'FAILED' && typeof result.transcription_url === 'string') {
|
|
502
|
-
const transcriptRes = await fetch(result.transcription_url, { signal: timeoutSignal(timeoutMs) });
|
|
502
|
+
const transcriptRes = await fetch(result.transcription_url, { signal: timeoutSignal(timeoutMs, signal) });
|
|
503
503
|
if (!transcriptRes.ok) throw new Error(`下载转写结果失败:HTTP ${transcriptRes.status}`);
|
|
504
504
|
const transcriptBody = await readJson(transcriptRes);
|
|
505
505
|
if (!transcriptBody) throw new Error('下载转写结果失败:响应不是 JSON');
|
package/lib/api.js
CHANGED
|
@@ -13,13 +13,16 @@
|
|
|
13
13
|
import fs from 'node:fs';
|
|
14
14
|
import path from 'node:path';
|
|
15
15
|
import crypto from 'node:crypto';
|
|
16
|
-
import { irisHome, capabilitiesOf, onChange as onConfigChange } from './config.js';
|
|
16
|
+
import { irisHome, capabilitiesOf, onChange as onConfigChange, providerHealthSnapshot } from './config.js';
|
|
17
17
|
import { mimeOf, mediaUrl } from './media.js';
|
|
18
18
|
import * as tasks from './tasks.js';
|
|
19
|
+
import * as artifacts from './artifacts.js';
|
|
19
20
|
import { runAction } from './actions.js';
|
|
20
21
|
import { chmodPrivateFile, ensurePrivateDir } from './private-storage.js';
|
|
21
22
|
import { inferMediaProtocol } from './provider-protocol.js';
|
|
22
23
|
import { deriveUserState } from './task-semantics.js';
|
|
24
|
+
import { hostDoctor } from './doctor.js';
|
|
25
|
+
import { hostRuntimeEvidence, recordHostClient } from './host-runtime.js';
|
|
23
26
|
import { optimizePrompt } from './prompt-optimizer.js';
|
|
24
27
|
import {
|
|
25
28
|
importPromptOptimizerConfig,
|
|
@@ -149,11 +152,14 @@ export function buildState() {
|
|
|
149
152
|
const terminal = all.filter((t) => ['succeeded', 'failed', 'canceled'].includes(stateOf(t))
|
|
150
153
|
|| (needsAttention(t) && acknowledged(t)) || t.expired);
|
|
151
154
|
const recent = terminal.slice(0, 100).map(taskPublic);
|
|
155
|
+
const artifactSummary = artifacts.page({ offset: 0, limit: 6 });
|
|
152
156
|
|
|
153
157
|
return {
|
|
154
158
|
stateEpoch: STATE_EPOCH,
|
|
155
159
|
stateRevision: ++stateRevision,
|
|
156
160
|
providers: providers.map(providerPublic),
|
|
161
|
+
health: providerHealthSnapshot(),
|
|
162
|
+
artifacts: { total: artifactSummary.total, recent: artifactSummary.items },
|
|
157
163
|
tasks: {
|
|
158
164
|
recent,
|
|
159
165
|
recentTotal: terminal.length, // 泡泡「查看全部 (N)」计数用(不受 slice 截断)
|
|
@@ -276,7 +282,7 @@ let sseBusDisposers = [];
|
|
|
276
282
|
function bindChangeBus() {
|
|
277
283
|
if (sseBound) return;
|
|
278
284
|
sseBound = true;
|
|
279
|
-
sseBusDisposers = [tasks.onChange(scheduleBroadcast), onConfigChange(scheduleBroadcast)];
|
|
285
|
+
sseBusDisposers = [tasks.onChange(scheduleBroadcast), artifacts.onChange(scheduleBroadcast), onConfigChange(scheduleBroadcast)];
|
|
280
286
|
}
|
|
281
287
|
function unbindChangeBus() {
|
|
282
288
|
for (const d of sseBusDisposers) {
|
|
@@ -316,22 +322,26 @@ function serveSse(res) {
|
|
|
316
322
|
}
|
|
317
323
|
|
|
318
324
|
/** webServer 前缀路由 handler:状态、任务、动作与提示词优化器 API。 */
|
|
319
|
-
export function serveApi(req, res,
|
|
325
|
+
export function serveApi(req, res, host) {
|
|
320
326
|
try {
|
|
321
327
|
const method = String(req.method || 'GET').toUpperCase();
|
|
322
328
|
const urlPath = String(req.url || '').split('?')[0];
|
|
323
329
|
|
|
324
330
|
// POST /iris/api/actions/:name —— GUI 直连触发工具执行
|
|
325
331
|
if (method === 'POST') {
|
|
332
|
+
if (urlPath === '/iris/api/host-client') {
|
|
333
|
+
if (!host || typeof host !== 'object') return sendJson(res, 503, { error: 'host unavailable' });
|
|
334
|
+
return handleJsonPost(req, res, (body) => recordHostClient(body));
|
|
335
|
+
}
|
|
326
336
|
// 文件选择器 L2:上传本地文件字节 → 存 uploads/ → 返回路径(独立大 body 上限)
|
|
327
337
|
if (urlPath === '/iris/api/upload') {
|
|
328
338
|
return handleUpload(req, res);
|
|
329
339
|
}
|
|
330
|
-
const promptMatch = typeof
|
|
331
|
-
if (promptMatch) return handlePromptOptimizer(
|
|
332
|
-
const actionMatch = typeof
|
|
340
|
+
const promptMatch = typeof host === 'object' && urlPath.match(/^\/iris\/api\/prompt-optimizer\/(optimize|import|reset|enabled)\/?$/);
|
|
341
|
+
if (promptMatch) return handlePromptOptimizer(host, promptMatch[1], req, res);
|
|
342
|
+
const actionMatch = typeof host === 'object' && urlPath.match(/^\/iris\/api\/actions\/([A-Za-z0-9_-]+)\/?$/);
|
|
333
343
|
if (!actionMatch) { return sendJson(res, 405, { error: 'method not allowed' }); }
|
|
334
|
-
return handleAction(
|
|
344
|
+
return handleAction(host, actionMatch[1], req, res);
|
|
335
345
|
}
|
|
336
346
|
// GET/HEAD 原逻辑
|
|
337
347
|
if (method !== 'GET' && method !== 'HEAD') {
|
|
@@ -345,6 +355,15 @@ export function serveApi(req, res, ctx) {
|
|
|
345
355
|
if (urlPath === '/iris/api/prompt-optimizer/config') {
|
|
346
356
|
return sendJson(res, 200, method === 'HEAD' ? {} : loadPromptOptimizerConfig());
|
|
347
357
|
}
|
|
358
|
+
if (urlPath === '/iris/api/doctor') {
|
|
359
|
+
if (!host || typeof host !== 'object') return sendJson(res, 503, { error: 'host unavailable' });
|
|
360
|
+
return sendJson(res, 200, method === 'HEAD' ? {} : hostDoctor(host, hostRuntimeEvidence()));
|
|
361
|
+
}
|
|
362
|
+
if (urlPath === '/iris/api/artifacts') {
|
|
363
|
+
const query = new URL(req.url || '', 'http://iris.local').searchParams;
|
|
364
|
+
const page = artifacts.page({ offset: Number(query.get('offset') || 0), limit: Number(query.get('limit') || 24) });
|
|
365
|
+
return sendJson(res, 200, method === 'HEAD' ? {} : page);
|
|
366
|
+
}
|
|
348
367
|
const taskMatch = urlPath.match(/^\/iris\/api\/task\/([^/]+)$/);
|
|
349
368
|
if (taskMatch) {
|
|
350
369
|
return serveTaskDetail(taskMatch[1], method, res);
|
|
@@ -413,13 +432,13 @@ function handleJsonPost(req, res, execute) {
|
|
|
413
432
|
req.on('error', () => finish(400, { error: 'bad request' }));
|
|
414
433
|
}
|
|
415
434
|
|
|
416
|
-
function handleAction(
|
|
417
|
-
return handleJsonPost(req, res, (args, signal) => runAction(
|
|
435
|
+
function handleAction(host, name, req, res) {
|
|
436
|
+
return handleJsonPost(req, res, (args, signal) => runAction(host, name, args, { signal }));
|
|
418
437
|
}
|
|
419
438
|
|
|
420
|
-
function handlePromptOptimizer(
|
|
439
|
+
function handlePromptOptimizer(host, operation, req, res) {
|
|
421
440
|
return handleJsonPost(req, res, async (body, signal) => {
|
|
422
|
-
if (operation === 'optimize') return optimizePrompt(
|
|
441
|
+
if (operation === 'optimize') return optimizePrompt(host, body, { signal });
|
|
423
442
|
if (operation === 'import') {
|
|
424
443
|
const imported = body && body.config !== undefined ? body.config : body;
|
|
425
444
|
return { ok: true, ...importPromptOptimizerConfig(imported) };
|
package/lib/artifacts.js
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Iris 作品库 v0。
|
|
4
|
+
*
|
|
5
|
+
* 任务历史与作品生命周期相互独立:清除 tasks.json 不会让 outputs/ 中的作品
|
|
6
|
+
* 从 UI 消失。这里只记录展示、分页和随机令牌授权所需的最小字段,不保留
|
|
7
|
+
* prompt、Provider、Model 或任务关系;完整 Artifact Manifest 留给 0.2.0。
|
|
8
|
+
*/
|
|
9
|
+
import crypto from 'node:crypto';
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { irisHome } from './config.js';
|
|
13
|
+
import { atomicWritePrivate, chmodPrivateFile, ensurePrivateDir, privateSibling } from './private-storage.js';
|
|
14
|
+
|
|
15
|
+
const FILE = () => path.join(irisHome(), 'artifacts.json');
|
|
16
|
+
const OUTPUTS = () => path.join(irisHome(), 'outputs');
|
|
17
|
+
const MIME = {
|
|
18
|
+
'.mp4': 'video/mp4', '.mov': 'video/quicktime', '.webm': 'video/webm',
|
|
19
|
+
'.mkv': 'video/x-matroska', '.avi': 'video/x-msvideo', '.mpeg': 'video/mpeg', '.mpg': 'video/mpeg',
|
|
20
|
+
'.wav': 'audio/wav', '.mp3': 'audio/mpeg', '.m4a': 'audio/mp4',
|
|
21
|
+
'.aac': 'audio/aac', '.ogg': 'audio/ogg', '.opus': 'audio/ogg', '.flac': 'audio/flac',
|
|
22
|
+
'.avif': 'image/avif', '.bmp': 'image/bmp',
|
|
23
|
+
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
24
|
+
'.webp': 'image/webp', '.gif': 'image/gif'
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
let cache = null;
|
|
28
|
+
const changeListeners = new Set();
|
|
29
|
+
|
|
30
|
+
export function onChange(fn) {
|
|
31
|
+
changeListeners.add(fn);
|
|
32
|
+
return () => changeListeners.delete(fn);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function emitChange() {
|
|
36
|
+
for (const fn of [...changeListeners]) {
|
|
37
|
+
try { fn(); } catch (_) { /* 一个监听者不能阻断作品落盘 */ }
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function persist() {
|
|
42
|
+
atomicWritePrivate(FILE(), JSON.stringify({ version: 1, artifacts: cache.artifacts }, null, 2));
|
|
43
|
+
emitChange();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function mimeFor(name) {
|
|
47
|
+
return MIME[path.extname(String(name)).toLowerCase()] || '';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function safeOutputFile(name) {
|
|
51
|
+
const value = String(name || '');
|
|
52
|
+
return value && path.basename(value) === value && value !== '.' && value !== '..' && mimeFor(value);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function outputStat(name) {
|
|
56
|
+
if (!safeOutputFile(name)) return null;
|
|
57
|
+
const abs = path.join(OUTPUTS(), name);
|
|
58
|
+
try {
|
|
59
|
+
const stat = fs.lstatSync(abs);
|
|
60
|
+
return stat.isFile() && !stat.isSymbolicLink() ? { abs, stat } : null;
|
|
61
|
+
} catch (_) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function newEntry(name, stat) {
|
|
67
|
+
return {
|
|
68
|
+
id: 'a_' + crypto.randomBytes(12).toString('hex'),
|
|
69
|
+
file: name,
|
|
70
|
+
token: crypto.randomBytes(16).toString('hex'),
|
|
71
|
+
mime: mimeFor(name),
|
|
72
|
+
size: stat.size,
|
|
73
|
+
createdAt: new Date(stat.mtimeMs || Date.now()).toISOString()
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function validateStored(value) {
|
|
78
|
+
if (!value || typeof value !== 'object' || !Array.isArray(value.artifacts)) throw new Error('artifacts 必须是数组');
|
|
79
|
+
const ids = new Set();
|
|
80
|
+
const files = new Set();
|
|
81
|
+
for (const item of value.artifacts) {
|
|
82
|
+
if (!item || typeof item !== 'object' || !String(item.id || '').startsWith('a_')
|
|
83
|
+
|| !/^[a-f0-9]{32}$/.test(String(item.token || '')) || !safeOutputFile(item.file)) {
|
|
84
|
+
throw new Error('作品条目格式无效');
|
|
85
|
+
}
|
|
86
|
+
if (ids.has(item.id) || files.has(item.file)) throw new Error('作品条目重复');
|
|
87
|
+
ids.add(item.id);
|
|
88
|
+
files.add(item.file);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function adoptOutputs() {
|
|
93
|
+
ensurePrivateDir(OUTPUTS());
|
|
94
|
+
const known = new Set(cache.artifacts.map((item) => item.file));
|
|
95
|
+
let added = 0;
|
|
96
|
+
for (const name of fs.readdirSync(OUTPUTS())) {
|
|
97
|
+
if (known.has(name)) continue;
|
|
98
|
+
const hit = outputStat(name);
|
|
99
|
+
if (!hit) continue;
|
|
100
|
+
cache.artifacts.push(newEntry(name, hit.stat));
|
|
101
|
+
known.add(name);
|
|
102
|
+
added++;
|
|
103
|
+
}
|
|
104
|
+
return added;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function load() {
|
|
108
|
+
if (cache) return cache;
|
|
109
|
+
const file = FILE();
|
|
110
|
+
if (!fs.existsSync(file)) {
|
|
111
|
+
cache = { version: 1, artifacts: [] };
|
|
112
|
+
adoptOutputs(); // 升级时一次性接回现有 outputs;不读取任务历史。
|
|
113
|
+
persist();
|
|
114
|
+
return cache;
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
118
|
+
validateStored(raw);
|
|
119
|
+
cache = { version: 1, artifacts: raw.artifacts.map((item) => ({ ...item })) };
|
|
120
|
+
} catch (error) {
|
|
121
|
+
const backup = privateSibling(file, 'corrupted');
|
|
122
|
+
try {
|
|
123
|
+
fs.renameSync(file, backup);
|
|
124
|
+
chmodPrivateFile(backup);
|
|
125
|
+
} catch (_) { /* 隔离失败仍继续用文件重建最小索引 */ }
|
|
126
|
+
console.error('[iris] artifacts.json 已损坏,已隔离为 ' + backup + ':', error && error.message);
|
|
127
|
+
cache = { version: 1, artifacts: [] };
|
|
128
|
+
adoptOutputs();
|
|
129
|
+
persist();
|
|
130
|
+
}
|
|
131
|
+
return cache;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function resetCache() {
|
|
135
|
+
cache = null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function all() {
|
|
139
|
+
return [...load().artifacts].sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function artifactUrl(entry) {
|
|
143
|
+
const base = String(process.env.DSH_WEB_BASE || 'http://127.0.0.1:3080').replace(/\/+$/, '');
|
|
144
|
+
return `${base}/iris/media/artifact/${entry.id}/${entry.token}/${encodeURIComponent(entry.file)}`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function publicEntry(item) {
|
|
148
|
+
return {
|
|
149
|
+
id: item.id,
|
|
150
|
+
file: item.file,
|
|
151
|
+
mime: item.mime || mimeFor(item.file),
|
|
152
|
+
size: Number(item.size || 0),
|
|
153
|
+
createdAt: item.createdAt || '',
|
|
154
|
+
url: artifactUrl(item)
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function page({ offset = 0, limit = 24 } = {}) {
|
|
159
|
+
const sorted = all();
|
|
160
|
+
const start = Math.max(0, Number.isFinite(Number(offset)) ? Math.trunc(Number(offset)) : 0);
|
|
161
|
+
const size = Math.max(1, Math.min(60, Number.isFinite(Number(limit)) ? Math.trunc(Number(limit)) : 24));
|
|
162
|
+
return { total: sorted.length, offset: start, limit: size, items: sorted.slice(start, start + size).map(publicEntry) };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function register(absPath) {
|
|
166
|
+
const resolved = path.resolve(String(absPath || ''));
|
|
167
|
+
if (path.dirname(resolved) !== path.resolve(OUTPUTS())) throw new Error('作品必须位于 Iris outputs/');
|
|
168
|
+
const name = path.basename(resolved);
|
|
169
|
+
const hit = outputStat(name);
|
|
170
|
+
if (!hit) throw new Error('作品文件不存在、类型不受支持或不是普通文件');
|
|
171
|
+
const data = load();
|
|
172
|
+
const existing = data.artifacts.find((item) => item.file === name);
|
|
173
|
+
if (existing) {
|
|
174
|
+
if (existing.size !== hit.stat.size || existing.mime !== mimeFor(name)) {
|
|
175
|
+
existing.size = hit.stat.size;
|
|
176
|
+
existing.mime = mimeFor(name);
|
|
177
|
+
persist();
|
|
178
|
+
}
|
|
179
|
+
return publicEntry(existing);
|
|
180
|
+
}
|
|
181
|
+
const entry = newEntry(name, hit.stat);
|
|
182
|
+
data.artifacts.push(entry);
|
|
183
|
+
persist();
|
|
184
|
+
return publicEntry(entry);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function reindex() {
|
|
188
|
+
const data = load();
|
|
189
|
+
const before = data.artifacts.length;
|
|
190
|
+
data.artifacts = data.artifacts.filter((item) => outputStat(item.file));
|
|
191
|
+
const missing = before - data.artifacts.length;
|
|
192
|
+
const added = adoptOutputs();
|
|
193
|
+
if (missing || added) persist();
|
|
194
|
+
return { added, missing, total: data.artifacts.length };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function authorize(id, token, name) {
|
|
198
|
+
const item = load().artifacts.find((entry) => entry.id === id && entry.token === token && entry.file === name);
|
|
199
|
+
if (!item) return null;
|
|
200
|
+
const hit = outputStat(item.file);
|
|
201
|
+
if (!hit) return null;
|
|
202
|
+
return { entry: item, abs: hit.abs, size: hit.stat.size };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function remove(id) {
|
|
206
|
+
const data = load();
|
|
207
|
+
const index = data.artifacts.findIndex((item) => item.id === id);
|
|
208
|
+
if (index < 0) return { ok: false, reason: '作品不存在' };
|
|
209
|
+
const item = data.artifacts[index];
|
|
210
|
+
const hit = outputStat(item.file);
|
|
211
|
+
if (hit) fs.rmSync(hit.abs);
|
|
212
|
+
data.artifacts.splice(index, 1);
|
|
213
|
+
persist();
|
|
214
|
+
return { ok: true, file: item.file };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function clear() {
|
|
218
|
+
const data = load();
|
|
219
|
+
const kept = [];
|
|
220
|
+
let deleted = 0;
|
|
221
|
+
let bytes = 0;
|
|
222
|
+
for (const item of data.artifacts) {
|
|
223
|
+
const hit = outputStat(item.file);
|
|
224
|
+
try {
|
|
225
|
+
if (hit) {
|
|
226
|
+
fs.rmSync(hit.abs);
|
|
227
|
+
bytes += hit.stat.size;
|
|
228
|
+
}
|
|
229
|
+
deleted++;
|
|
230
|
+
} catch (_) {
|
|
231
|
+
kept.push(item);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (deleted) {
|
|
235
|
+
data.artifacts = kept;
|
|
236
|
+
persist();
|
|
237
|
+
}
|
|
238
|
+
return { deleted, failed: kept.length, bytes };
|
|
239
|
+
}
|
package/lib/bundled-skills.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { requireHostPort } from './host-contract.js';
|
|
6
7
|
|
|
7
8
|
const SKILL_NAMES = ['iris-verify-ui', 'iris-compose-media'];
|
|
8
9
|
const SKILL_ROOT = fileURLToPath(new URL('../.dsh/skills/', import.meta.url));
|
|
@@ -44,6 +45,7 @@ export function loadBundledSkills() {
|
|
|
44
45
|
content: source.slice(frontmatter[0].length).replace(/^\r?\n/, ''),
|
|
45
46
|
source: 'bundled',
|
|
46
47
|
provider: 'iris-bundled',
|
|
48
|
+
invocation: Object.freeze({ modelInvocable: true, userInvocable: true }),
|
|
47
49
|
path: file,
|
|
48
50
|
resourceBase: Object.freeze({ kind: 'directory', path: directory })
|
|
49
51
|
});
|
|
@@ -51,11 +53,9 @@ export function loadBundledSkills() {
|
|
|
51
53
|
}
|
|
52
54
|
|
|
53
55
|
/** 注册为宿主级 runtime Skill;DSH 项目目录中的同名 Skill 仍按原生优先级胜出。 */
|
|
54
|
-
export function registerBundledSkills(
|
|
55
|
-
const registry =
|
|
56
|
-
if (
|
|
57
|
-
throw new Error('DSH Skill registry 不可用');
|
|
58
|
-
}
|
|
56
|
+
export function registerBundledSkills(host) {
|
|
57
|
+
const registry = requireHostPort(host, 'skills', '注册随包 Skill');
|
|
58
|
+
if (typeof registry.register !== 'function') throw new Error('Host skills 端口缺少 register');
|
|
59
59
|
const skills = loadBundledSkills();
|
|
60
60
|
for (const skill of skills) registry.register(skill);
|
|
61
61
|
return skills.map((skill) => skill.name);
|