@mokuyoaxis/dsh-iris 0.1.2 → 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 +57 -2
- package/README.en.md +43 -7
- package/README.md +43 -7
- package/bin/dsh-iris.js +23 -0
- package/docs/ARCHITECTURE.md +95 -0
- package/docs/ARTIFACT_LIBRARY.md +24 -0
- package/docs/FAULT_INJECTION.md +26 -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 +60 -0
- package/docs/ROADMAP.md +21 -11
- package/docs/SECURITY.md +57 -0
- package/docs/TASK_SEMANTICS.md +148 -0
- 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 +860 -290
- package/lib/adapters.js +7 -7
- package/lib/api.js +110 -18
- package/lib/artifacts.js +239 -0
- package/lib/bundled-skills.js +5 -5
- package/lib/client.js +478 -68
- package/lib/config.js +192 -9
- package/lib/doctor.js +414 -0
- 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 +142 -157
- 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-contract.js +234 -0
- package/lib/provider-health.js +59 -0
- package/lib/render.js +43 -12
- package/lib/task-semantics.js +209 -0
- package/lib/tasks.js +500 -32
- package/lib/vision.js +23 -49
- package/package.json +19 -8
- package/user_guide.md +62 -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/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
|
@@ -12,12 +12,17 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import fs from 'node:fs';
|
|
14
14
|
import path from 'node:path';
|
|
15
|
-
import
|
|
15
|
+
import crypto from 'node:crypto';
|
|
16
|
+
import { irisHome, capabilitiesOf, onChange as onConfigChange, providerHealthSnapshot } from './config.js';
|
|
16
17
|
import { mimeOf, mediaUrl } from './media.js';
|
|
17
18
|
import * as tasks from './tasks.js';
|
|
19
|
+
import * as artifacts from './artifacts.js';
|
|
18
20
|
import { runAction } from './actions.js';
|
|
19
21
|
import { chmodPrivateFile, ensurePrivateDir } from './private-storage.js';
|
|
20
22
|
import { inferMediaProtocol } from './provider-protocol.js';
|
|
23
|
+
import { deriveUserState } from './task-semantics.js';
|
|
24
|
+
import { hostDoctor } from './doctor.js';
|
|
25
|
+
import { hostRuntimeEvidence, recordHostClient } from './host-runtime.js';
|
|
21
26
|
import { optimizePrompt } from './prompt-optimizer.js';
|
|
22
27
|
import {
|
|
23
28
|
importPromptOptimizerConfig,
|
|
@@ -75,6 +80,21 @@ function mediaOf(task) {
|
|
|
75
80
|
}));
|
|
76
81
|
}
|
|
77
82
|
|
|
83
|
+
const V2_FACT_FIELDS = ['phase', 'acceptance', 'watchState', 'outcome', 'deliveryState', 'cancelState'];
|
|
84
|
+
|
|
85
|
+
function isTaskV2(t) {
|
|
86
|
+
return Boolean(t && t.schemaVersion === 2 && V2_FACT_FIELDS.every((field) => Object.prototype.hasOwnProperty.call(t, field)));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 旧记录保持原四态;只有完整 v2 记录才使用事实轴派生用户态。 */
|
|
90
|
+
function taskUserState(t) {
|
|
91
|
+
if (isTaskV2(t)) return deriveUserState(t);
|
|
92
|
+
if (t && t.status === 'succeeded') return 'succeeded';
|
|
93
|
+
if (t && t.status === 'failed') return 'failed';
|
|
94
|
+
if (t && t.status === 'canceled') return 'canceled';
|
|
95
|
+
return 'running';
|
|
96
|
+
}
|
|
97
|
+
|
|
78
98
|
function taskPublic(t) {
|
|
79
99
|
const elapsedMs =
|
|
80
100
|
t.elapsedMs ||
|
|
@@ -83,6 +103,9 @@ function taskPublic(t) {
|
|
|
83
103
|
id: t.id,
|
|
84
104
|
cap: t.cap || '',
|
|
85
105
|
status: t.status || 'running',
|
|
106
|
+
userState: taskUserState(t),
|
|
107
|
+
schemaVersion: Number(t.schemaVersion || 1),
|
|
108
|
+
revision: Number(t.revision || 0),
|
|
86
109
|
progress: t.progress || '',
|
|
87
110
|
model: t.model || '',
|
|
88
111
|
providerName: t.providerName || '',
|
|
@@ -94,6 +117,8 @@ function taskPublic(t) {
|
|
|
94
117
|
saved: !!t.saved,
|
|
95
118
|
expired: !!t.expired,
|
|
96
119
|
mode: t.mode || '',
|
|
120
|
+
retryOf: t.retryOf || '',
|
|
121
|
+
attentionDisposition: tasks.attentionDisposition(t),
|
|
97
122
|
files: Array.isArray(t.files) ? t.files.map((f) => path.basename(f)) : [],
|
|
98
123
|
media: mediaOf(t)
|
|
99
124
|
};
|
|
@@ -110,21 +135,35 @@ function loadProviders() {
|
|
|
110
135
|
return Array.isArray(cache && cache.providers) ? cache.providers : [];
|
|
111
136
|
}
|
|
112
137
|
|
|
113
|
-
|
|
138
|
+
const STATE_EPOCH = crypto.randomUUID();
|
|
139
|
+
let stateRevision = 0;
|
|
140
|
+
|
|
141
|
+
/** 构建完整工作台状态(纯 JSON,可序列化);序号用于客户端拒绝迟到快照。 */
|
|
114
142
|
export function buildState() {
|
|
115
|
-
const
|
|
143
|
+
const taskRecords = loadTasks();
|
|
116
144
|
const providers = loadProviders();
|
|
117
145
|
|
|
118
|
-
const all = [...
|
|
119
|
-
const
|
|
120
|
-
const
|
|
146
|
+
const all = [...taskRecords].sort((a, b) => String(b.createdAt || '').localeCompare(String(a.createdAt || '')));
|
|
147
|
+
const stateOf = (t) => taskUserState(t);
|
|
148
|
+
const needsAttention = (t) => ['watching_paused', 'needs_attention', 'artifact_unavailable'].includes(stateOf(t));
|
|
149
|
+
const acknowledged = (t) => tasks.attentionDisposition(t).status === 'acknowledged';
|
|
150
|
+
const running = all.filter((t) => ['queued', 'running'].includes(stateOf(t)) && !t.expired);
|
|
151
|
+
const attention = all.filter((t) => needsAttention(t) && !acknowledged(t) && !t.expired);
|
|
152
|
+
const terminal = all.filter((t) => ['succeeded', 'failed', 'canceled'].includes(stateOf(t))
|
|
153
|
+
|| (needsAttention(t) && acknowledged(t)) || t.expired);
|
|
121
154
|
const recent = terminal.slice(0, 100).map(taskPublic);
|
|
155
|
+
const artifactSummary = artifacts.page({ offset: 0, limit: 6 });
|
|
122
156
|
|
|
123
157
|
return {
|
|
158
|
+
stateEpoch: STATE_EPOCH,
|
|
159
|
+
stateRevision: ++stateRevision,
|
|
124
160
|
providers: providers.map(providerPublic),
|
|
161
|
+
health: providerHealthSnapshot(),
|
|
162
|
+
artifacts: { total: artifactSummary.total, recent: artifactSummary.items },
|
|
125
163
|
tasks: {
|
|
126
164
|
recent,
|
|
127
165
|
recentTotal: terminal.length, // 泡泡「查看全部 (N)」计数用(不受 slice 截断)
|
|
166
|
+
attention: attention.slice(0, 20).map(taskPublic),
|
|
128
167
|
running: running.slice(0, 10).map(taskPublic)
|
|
129
168
|
}
|
|
130
169
|
};
|
|
@@ -138,15 +177,44 @@ export function sseClientCount() {
|
|
|
138
177
|
}
|
|
139
178
|
|
|
140
179
|
const sseClients = new Set(); // 活跃 res(长连接)
|
|
180
|
+
const sseBlocked = new Set(); // 背压中的连接
|
|
181
|
+
const sseLatest = new WeakMap(); // 每个慢连接只保留最新状态
|
|
182
|
+
const sseDrainBound = new WeakSet();
|
|
141
183
|
let sseHeartbeat = null; // 心跳定时器(15s,防代理/浏览器空闲断连)
|
|
142
184
|
let sseThrottleTimer = null; // 推送节流(400ms 合并,防盯守高频 update 刷屏)
|
|
143
185
|
let ssePending = false;
|
|
144
186
|
let sseBound = false;
|
|
145
187
|
|
|
188
|
+
function bindSseDrain(res) {
|
|
189
|
+
sseBlocked.add(res);
|
|
190
|
+
if (sseDrainBound.has(res)) return;
|
|
191
|
+
sseDrainBound.add(res);
|
|
192
|
+
res.once('drain', () => {
|
|
193
|
+
sseDrainBound.delete(res);
|
|
194
|
+
sseBlocked.delete(res);
|
|
195
|
+
const latest = sseLatest.get(res);
|
|
196
|
+
sseLatest.delete(res);
|
|
197
|
+
if (latest !== undefined) pushSse(res, latest);
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
146
201
|
function pushSse(res, data) {
|
|
147
202
|
if (!res || res.writableEnded || res.destroyed) return;
|
|
203
|
+
if (sseBlocked.has(res)) {
|
|
204
|
+
sseLatest.set(res, data);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
try {
|
|
208
|
+
if (res.write(`data: ${data}\n\n`) === false) bindSseDrain(res);
|
|
209
|
+
} catch (_) {
|
|
210
|
+
/* 连接已死,close 事件会清理 */
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function pushSseComment(res, comment) {
|
|
215
|
+
if (!res || res.writableEnded || res.destroyed || sseBlocked.has(res)) return;
|
|
148
216
|
try {
|
|
149
|
-
res.write(
|
|
217
|
+
if (res.write(`: ${comment}\n\n`) === false) bindSseDrain(res);
|
|
150
218
|
} catch (_) {
|
|
151
219
|
/* 连接已死,close 事件会清理 */
|
|
152
220
|
}
|
|
@@ -177,7 +245,7 @@ function scheduleBroadcast() {
|
|
|
177
245
|
function ensureHeartbeat() {
|
|
178
246
|
if (sseHeartbeat) return;
|
|
179
247
|
sseHeartbeat = setInterval(() => {
|
|
180
|
-
for (const res of [...sseClients])
|
|
248
|
+
for (const res of [...sseClients]) pushSseComment(res, 'ping'); // 注释帧不覆盖背压期间的最新状态
|
|
181
249
|
if (!sseClients.size) stopHeartbeat();
|
|
182
250
|
}, 15000);
|
|
183
251
|
if (sseHeartbeat && sseHeartbeat.unref) sseHeartbeat.unref();
|
|
@@ -214,7 +282,7 @@ let sseBusDisposers = [];
|
|
|
214
282
|
function bindChangeBus() {
|
|
215
283
|
if (sseBound) return;
|
|
216
284
|
sseBound = true;
|
|
217
|
-
sseBusDisposers = [tasks.onChange(scheduleBroadcast), onConfigChange(scheduleBroadcast)];
|
|
285
|
+
sseBusDisposers = [tasks.onChange(scheduleBroadcast), artifacts.onChange(scheduleBroadcast), onConfigChange(scheduleBroadcast)];
|
|
218
286
|
}
|
|
219
287
|
function unbindChangeBus() {
|
|
220
288
|
for (const d of sseBusDisposers) {
|
|
@@ -239,6 +307,8 @@ function serveSse(res) {
|
|
|
239
307
|
pushSse(res, JSON.stringify(buildState())); // 立即推当前状态
|
|
240
308
|
const onClose = () => {
|
|
241
309
|
sseClients.delete(res);
|
|
310
|
+
sseBlocked.delete(res);
|
|
311
|
+
sseLatest.delete(res);
|
|
242
312
|
if (!sseClients.size) {
|
|
243
313
|
stopHeartbeat();
|
|
244
314
|
if (sseThrottleTimer) clearTimeout(sseThrottleTimer);
|
|
@@ -252,22 +322,26 @@ function serveSse(res) {
|
|
|
252
322
|
}
|
|
253
323
|
|
|
254
324
|
/** webServer 前缀路由 handler:状态、任务、动作与提示词优化器 API。 */
|
|
255
|
-
export function serveApi(req, res,
|
|
325
|
+
export function serveApi(req, res, host) {
|
|
256
326
|
try {
|
|
257
327
|
const method = String(req.method || 'GET').toUpperCase();
|
|
258
328
|
const urlPath = String(req.url || '').split('?')[0];
|
|
259
329
|
|
|
260
330
|
// POST /iris/api/actions/:name —— GUI 直连触发工具执行
|
|
261
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
|
+
}
|
|
262
336
|
// 文件选择器 L2:上传本地文件字节 → 存 uploads/ → 返回路径(独立大 body 上限)
|
|
263
337
|
if (urlPath === '/iris/api/upload') {
|
|
264
338
|
return handleUpload(req, res);
|
|
265
339
|
}
|
|
266
|
-
const promptMatch = typeof
|
|
267
|
-
if (promptMatch) return handlePromptOptimizer(
|
|
268
|
-
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_-]+)\/?$/);
|
|
269
343
|
if (!actionMatch) { return sendJson(res, 405, { error: 'method not allowed' }); }
|
|
270
|
-
return handleAction(
|
|
344
|
+
return handleAction(host, actionMatch[1], req, res);
|
|
271
345
|
}
|
|
272
346
|
// GET/HEAD 原逻辑
|
|
273
347
|
if (method !== 'GET' && method !== 'HEAD') {
|
|
@@ -281,6 +355,15 @@ export function serveApi(req, res, ctx) {
|
|
|
281
355
|
if (urlPath === '/iris/api/prompt-optimizer/config') {
|
|
282
356
|
return sendJson(res, 200, method === 'HEAD' ? {} : loadPromptOptimizerConfig());
|
|
283
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
|
+
}
|
|
284
367
|
const taskMatch = urlPath.match(/^\/iris\/api\/task\/([^/]+)$/);
|
|
285
368
|
if (taskMatch) {
|
|
286
369
|
return serveTaskDetail(taskMatch[1], method, res);
|
|
@@ -349,13 +432,13 @@ function handleJsonPost(req, res, execute) {
|
|
|
349
432
|
req.on('error', () => finish(400, { error: 'bad request' }));
|
|
350
433
|
}
|
|
351
434
|
|
|
352
|
-
function handleAction(
|
|
353
|
-
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 }));
|
|
354
437
|
}
|
|
355
438
|
|
|
356
|
-
function handlePromptOptimizer(
|
|
439
|
+
function handlePromptOptimizer(host, operation, req, res) {
|
|
357
440
|
return handleJsonPost(req, res, async (body, signal) => {
|
|
358
|
-
if (operation === 'optimize') return optimizePrompt(
|
|
441
|
+
if (operation === 'optimize') return optimizePrompt(host, body, { signal });
|
|
359
442
|
if (operation === 'import') {
|
|
360
443
|
const imported = body && body.config !== undefined ? body.config : body;
|
|
361
444
|
return { ok: true, ...importPromptOptimizerConfig(imported) };
|
|
@@ -493,6 +576,10 @@ function serveTaskDetail(taskId, method, res) {
|
|
|
493
576
|
id: t.id,
|
|
494
577
|
cap: t.cap || '',
|
|
495
578
|
status: t.status || '',
|
|
579
|
+
userState: taskUserState(t),
|
|
580
|
+
schemaVersion: Number(t.schemaVersion || 1),
|
|
581
|
+
revision: Number(t.revision || 0),
|
|
582
|
+
...(isTaskV2(t) ? Object.fromEntries(V2_FACT_FIELDS.map((field) => [field, t[field]])) : {}),
|
|
496
583
|
progress: t.progress || '',
|
|
497
584
|
model: t.model || '',
|
|
498
585
|
providerName: t.providerName || '',
|
|
@@ -503,6 +590,11 @@ function serveTaskDetail(taskId, method, res) {
|
|
|
503
590
|
error: t.error || '',
|
|
504
591
|
mode: t.mode || '',
|
|
505
592
|
remoteTaskId: t.remoteTaskId || '',
|
|
593
|
+
retryOf: t.retryOf || '',
|
|
594
|
+
manualRetries: Array.isArray(t.manualRetries) ? t.manualRetries.filter((item) => item && item.taskId).map((item) => ({
|
|
595
|
+
taskId: String(item.taskId), createdAt: item.createdAt || ''
|
|
596
|
+
})) : [],
|
|
597
|
+
attentionDisposition: tasks.attentionDisposition(t),
|
|
506
598
|
files: Array.isArray(t.files) ? t.files.map((f) => path.basename(f)) : [],
|
|
507
599
|
media: mediaOf(t),
|
|
508
600
|
attachments: Array.isArray(t.attachments) ? t.attachments.map((a) => ({
|
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);
|