@mokuyoaxis/dsh-iris 0.1.3 → 0.2.0
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 +77 -2
- package/README.en.md +98 -19
- package/README.md +98 -19
- package/bin/dsh-iris.js +392 -13
- package/docs/AGENT_QUICKSTART.md +35 -0
- package/docs/ARCHITECTURE.md +82 -52
- package/docs/ARTIFACT_LIBRARY.md +24 -0
- package/docs/ARTIFACT_MANIFEST.md +60 -0
- package/docs/CORE_RUNTIME_CONTRACT.md +83 -0
- package/docs/DSH_CORE_MIGRATION.md +106 -0
- package/docs/DSH_RC2_ADAPTATION.md +36 -0
- package/docs/HEADLESS_CLI.md +217 -0
- package/docs/HOST_ADAPTER_CONTRACT.md +105 -0
- package/docs/HOST_DOCTOR.md +49 -0
- package/docs/MODEL_PORT_CONTRACT.md +300 -0
- package/docs/PROVIDER_ADAPTER_CONTRACT.md +138 -0
- package/docs/PROVIDER_HEALTH.md +39 -0
- package/docs/PROVIDER_RUNTIME_HARNESS.md +33 -0
- package/docs/PROVIDER_SUBMISSION_CONTRACT.md +2 -2
- package/docs/ROADMAP.md +42 -76
- package/docs/SECURITY.md +10 -5
- 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/assets/logo/README.md +55 -0
- package/docs/assets/logo/iris-bubble-concept.svg +67 -0
- package/docs/assets/logo/iris-bubble.svg +14 -0
- package/docs/assets/logo/iris-interim-flower.png +0 -0
- package/docs/assets/logo/iris-logo-mono-inverse.svg +78 -0
- package/docs/assets/logo/iris-logo-mono.svg +78 -0
- package/docs/assets/logo/iris-logo.svg +78 -0
- package/docs/assets/logo/iris-mark-mono-inverse.svg +42 -0
- package/docs/assets/logo/iris-mark-mono.svg +42 -0
- package/docs/assets/logo/iris-mark.svg +42 -0
- package/docs/releases/0.1.4.md +59 -0
- package/docs/releases/0.2.0.md +48 -0
- package/lib/actions.js +673 -525
- package/lib/adapters.js +58 -21
- package/lib/api.js +182 -16
- package/lib/artifacts.js +239 -0
- package/lib/bundled-skills.js +5 -5
- package/lib/client.js +1116 -86
- package/lib/command-service.js +420 -0
- package/lib/config.js +245 -49
- package/lib/core-artifact-store.js +598 -0
- package/lib/core-artifacts.js +4 -0
- package/lib/core-attention.js +103 -0
- package/lib/core-contract.js +105 -0
- package/lib/core-doctor.js +41 -0
- package/lib/core-lease-recovery.js +170 -0
- package/lib/core-runtime.js +232 -0
- package/lib/core-tasks.js +560 -0
- package/lib/core-user-projection.js +192 -0
- package/lib/doctor.js +260 -30
- package/lib/dsh-core-adapter.js +599 -0
- package/lib/dsh-host-adapter.js +240 -0
- package/lib/generation-input.js +95 -0
- package/lib/guard.js +3 -3
- package/lib/host-contract.js +131 -0
- package/lib/host-runtime.js +156 -0
- package/lib/index.js +255 -166
- package/lib/locate.js +1 -1
- package/lib/media.js +16 -6
- package/lib/model-invoker.js +218 -0
- package/lib/model-port-contract.js +333 -0
- package/lib/models.js +97 -11
- package/lib/prompt-optimizer.js +10 -20
- package/lib/provider-adapter.js +397 -0
- package/lib/provider-adapters.js +342 -0
- package/lib/provider-catalog.js +171 -0
- package/lib/provider-contract.js +72 -3
- package/lib/provider-health.js +59 -0
- package/lib/provider-protocol.js +51 -2
- package/lib/provider-task-runner.js +328 -0
- package/lib/render.js +43 -12
- package/lib/tasks.js +45 -9
- package/lib/vision.js +51 -56
- package/package.json +20 -14
- package/user_guide.md +67 -12
- 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
|
@@ -9,7 +9,7 @@ import fs from 'node:fs';
|
|
|
9
9
|
import path from 'node:path';
|
|
10
10
|
import { Readable } from 'node:stream';
|
|
11
11
|
import { pipeline } from 'node:stream/promises';
|
|
12
|
-
import { dashscopeApiBase } from './provider-protocol.js';
|
|
12
|
+
import { dashscopeApiBase, isDashScopeBaseUrl } from './provider-protocol.js';
|
|
13
13
|
import { chmodPrivateFile, ensurePrivateDir, privateSibling } from './private-storage.js';
|
|
14
14
|
|
|
15
15
|
/**
|
|
@@ -31,7 +31,10 @@ function timeoutSignal(ms, external) {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
function authHeaders(key) {
|
|
34
|
-
return {
|
|
34
|
+
return {
|
|
35
|
+
...(typeof key === 'string' && key.trim() ? { Authorization: `Bearer ${key}` } : {}),
|
|
36
|
+
'Content-Type': 'application/json'
|
|
37
|
+
};
|
|
35
38
|
}
|
|
36
39
|
|
|
37
40
|
async function readJson(res) {
|
|
@@ -236,11 +239,11 @@ export async function uploadTempFile({ key, baseUrl, model, filePath, timeoutMs
|
|
|
236
239
|
const TERMINAL = new Set(['SUCCEEDED', 'FAILED', 'CANCELED', 'UNKNOWN']);
|
|
237
240
|
|
|
238
241
|
/** @returns {{done:boolean, ok:boolean, urls:string[], message?:string}} */
|
|
239
|
-
export async function pollTask({ key, baseUrl, remoteTaskId, timeoutMs = T_POLL }) {
|
|
242
|
+
export async function pollTask({ key, baseUrl, remoteTaskId, timeoutMs = T_POLL, signal }) {
|
|
240
243
|
const apiBase = dashscopeApiBase(baseUrl);
|
|
241
244
|
const res = await fetch(`${apiBase}/tasks/${encodeURIComponent(remoteTaskId)}`, {
|
|
242
245
|
headers: authHeaders(key),
|
|
243
|
-
signal: timeoutSignal(timeoutMs)
|
|
246
|
+
signal: timeoutSignal(timeoutMs, signal)
|
|
244
247
|
});
|
|
245
248
|
const body = await readJson(res);
|
|
246
249
|
if (!res.ok) throw providerError(res.status, body, '查询任务失败');
|
|
@@ -317,25 +320,59 @@ export async function openAiGenerateImage({ key, baseUrl, model, prompt, size =
|
|
|
317
320
|
return out;
|
|
318
321
|
}
|
|
319
322
|
|
|
320
|
-
/* ---------------- 模型发现(OpenAI
|
|
323
|
+
/* ---------------- 模型发现(OpenAI GET /models + DashScope GET /api/v1/models) ---------------- */
|
|
321
324
|
/**
|
|
322
|
-
*
|
|
323
|
-
*
|
|
324
|
-
* @returns {Promise<string[]
|
|
325
|
+
* 拉取供应商模型目录。DashScope 官方端点分页读取并保留 capabilities;
|
|
326
|
+
* 普通 OpenAI 兼容端点维持 GET /models 与字符串 id 返回形状。
|
|
327
|
+
* @returns {Promise<Array<string|{id:string,capabilities?:string[]}>>}
|
|
325
328
|
*/
|
|
326
|
-
export async function listModels({ key, baseUrl, timeoutMs = T_SUBMIT }) {
|
|
329
|
+
export async function listModels({ key, baseUrl, timeoutMs = T_SUBMIT, signal }) {
|
|
327
330
|
const base = String(baseUrl || '').trim().replace(/\/+$/, '');
|
|
328
331
|
if (!base) throw new Error('iris: 供应商缺 baseUrl,无法发现模型');
|
|
329
|
-
const
|
|
330
|
-
const
|
|
331
|
-
|
|
332
|
-
const
|
|
333
|
-
|
|
334
|
-
for (
|
|
335
|
-
const
|
|
336
|
-
if (
|
|
332
|
+
const dashscope = isDashScopeBaseUrl(base);
|
|
333
|
+
const endpoint = dashscope ? dashscopeApiBase(base) + '/models' : base + '/models';
|
|
334
|
+
const found = new Map();
|
|
335
|
+
const maxPages = dashscope ? 50 : 1;
|
|
336
|
+
let loaded = 0;
|
|
337
|
+
for (let page = 1; page <= maxPages; page++) {
|
|
338
|
+
const url = new URL(endpoint);
|
|
339
|
+
if (dashscope) {
|
|
340
|
+
url.searchParams.set('page_no', String(page));
|
|
341
|
+
url.searchParams.set('page_size', '100');
|
|
342
|
+
}
|
|
343
|
+
const res = await fetch(url, { headers: authHeaders(key), signal: timeoutSignal(timeoutMs, signal) });
|
|
344
|
+
const body = await readJson(res);
|
|
345
|
+
if (!res.ok) throw providerError(res.status, body, '模型发现失败');
|
|
346
|
+
const output = body && body.output;
|
|
347
|
+
const items = (output && Array.isArray(output.models) && output.models)
|
|
348
|
+
|| (body && Array.isArray(body.data) && body.data)
|
|
349
|
+
|| (Array.isArray(body && body.models) ? body.models : []);
|
|
350
|
+
loaded += items.length;
|
|
351
|
+
for (const item of items) {
|
|
352
|
+
const id = String(typeof item === 'string' ? item : item?.id || item?.model || '').trim();
|
|
353
|
+
if (!id || found.has(id)) continue;
|
|
354
|
+
const capabilities = Array.isArray(item?.capabilities)
|
|
355
|
+
? item.capabilities.filter((value) => typeof value === 'string' && value)
|
|
356
|
+
: [];
|
|
357
|
+
const requestModalities = Array.isArray(item?.inference_metadata?.request_modality)
|
|
358
|
+
? item.inference_metadata.request_modality.map((value) => String(value).toLowerCase()) : [];
|
|
359
|
+
const responseModalities = Array.isArray(item?.inference_metadata?.response_modality)
|
|
360
|
+
? item.inference_metadata.response_modality.map((value) => String(value).toLowerCase()) : [];
|
|
361
|
+
if (responseModalities.includes('image')) capabilities.push('IG');
|
|
362
|
+
if (responseModalities.includes('video')) capabilities.push('VG');
|
|
363
|
+
if (requestModalities.includes('audio') && responseModalities.includes('text')) capabilities.push('ASR');
|
|
364
|
+
if (requestModalities.includes('text') && responseModalities.includes('audio')) capabilities.push('TTS');
|
|
365
|
+
if (requestModalities.includes('image') && responseModalities.includes('text')) capabilities.push('VU');
|
|
366
|
+
const normalizedCapabilities = [...new Set(capabilities)];
|
|
367
|
+
found.set(id, normalizedCapabilities.length ? { id, capabilities: normalizedCapabilities } : id);
|
|
368
|
+
}
|
|
369
|
+
if (!dashscope) break;
|
|
370
|
+
const statedTotal = Number(output?.total);
|
|
371
|
+
const total = Number.isFinite(statedTotal) && statedTotal >= 0 ? statedTotal : null;
|
|
372
|
+
if (!items.length || (total !== null && loaded >= total) || items.length < 100) break;
|
|
373
|
+
if (page === maxPages) throw new Error('iris: 模型目录超过安全分页上限,已保留原模型池');
|
|
337
374
|
}
|
|
338
|
-
return
|
|
375
|
+
return [...found.values()];
|
|
339
376
|
}
|
|
340
377
|
|
|
341
378
|
/* ---------------- 视觉理解(OpenAI 兼容 SSE 流式) ---------------- */
|
|
@@ -476,11 +513,11 @@ export function transcriptionText(payload) {
|
|
|
476
513
|
}
|
|
477
514
|
|
|
478
515
|
/** 轮询 ASR 任务;正式结果位于 results[].transcription_url 指向的 JSON。 */
|
|
479
|
-
export async function pollTranscriptionTask({ key, baseUrl, remoteTaskId, timeoutMs = T_POLL }) {
|
|
516
|
+
export async function pollTranscriptionTask({ key, baseUrl, remoteTaskId, timeoutMs = T_POLL, signal }) {
|
|
480
517
|
const apiBase = dashscopeApiBase(baseUrl);
|
|
481
518
|
const res = await fetch(`${apiBase}/tasks/${encodeURIComponent(remoteTaskId)}`, {
|
|
482
519
|
headers: authHeaders(key),
|
|
483
|
-
signal: timeoutSignal(timeoutMs)
|
|
520
|
+
signal: timeoutSignal(timeoutMs, signal)
|
|
484
521
|
});
|
|
485
522
|
const body = await readJson(res);
|
|
486
523
|
if (!res.ok) throw providerError(res.status, body, '查询转写任务失败');
|
|
@@ -499,7 +536,7 @@ export async function pollTranscriptionTask({ key, baseUrl, remoteTaskId, timeou
|
|
|
499
536
|
if (typeof result.transcription_text === 'string') text += result.transcription_text;
|
|
500
537
|
else if (typeof result.text === 'string') text += result.text;
|
|
501
538
|
if (status === 'SUCCEEDED' && result.subtask_status !== 'FAILED' && typeof result.transcription_url === 'string') {
|
|
502
|
-
const transcriptRes = await fetch(result.transcription_url, { signal: timeoutSignal(timeoutMs) });
|
|
539
|
+
const transcriptRes = await fetch(result.transcription_url, { signal: timeoutSignal(timeoutMs, signal) });
|
|
503
540
|
if (!transcriptRes.ok) throw new Error(`下载转写结果失败:HTTP ${transcriptRes.status}`);
|
|
504
541
|
const transcriptBody = await readJson(transcriptRes);
|
|
505
542
|
if (!transcriptBody) throw new Error('下载转写结果失败:响应不是 JSON');
|
package/lib/api.js
CHANGED
|
@@ -7,19 +7,23 @@
|
|
|
7
7
|
*
|
|
8
8
|
* 输出纪律(与整仓一致):
|
|
9
9
|
* - 只回标量字段(字符串/数字/布尔/受限数组),apiKey 永不明文,只给 hint;
|
|
10
|
-
* - 文件路径只给相对 outputs/ 的 basename +
|
|
10
|
+
* - legacy 文件路径只给相对 outputs/ 的 basename + 128-bit token 链接;Core 媒体按随机 Artifact ID 读取,不宣称独立 token;
|
|
11
11
|
* - tasks/providers 读的是 $DSH_HOME/iris/v1 的持久 JSON,与任务盯守共用同一存储。
|
|
12
12
|
*/
|
|
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
|
-
import {
|
|
22
|
+
import { selectMediaProtocol } 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';
|
|
26
|
+
import { cancelProviderTaskForDsh, coreSnapshotForDsh, disposeCoreTaskAttention, readCoreArtifactMediaForDsh, redeliverProviderTaskForDsh, reobserveProviderTaskForDsh, retryProviderTaskForDsh } from './dsh-core-adapter.js';
|
|
23
27
|
import { optimizePrompt } from './prompt-optimizer.js';
|
|
24
28
|
import {
|
|
25
29
|
importPromptOptimizerConfig,
|
|
@@ -52,8 +56,9 @@ function providerPublic(p) {
|
|
|
52
56
|
name: p.name || p.id,
|
|
53
57
|
type: p.type || 'openai',
|
|
54
58
|
baseUrl: p.baseUrl,
|
|
59
|
+
mediaBaseUrl: p.mediaBaseUrl || '',
|
|
55
60
|
enabled: p.enabled !== false,
|
|
56
|
-
|
|
61
|
+
...selectMediaProtocol(p),
|
|
57
62
|
apiKeyHint: keyHint(p.apiKey),
|
|
58
63
|
capabilities: capabilitiesOf(p),
|
|
59
64
|
// 迁移/推断结果是否与原始声明不同(UI 提示旧配置已推断)
|
|
@@ -149,11 +154,14 @@ export function buildState() {
|
|
|
149
154
|
const terminal = all.filter((t) => ['succeeded', 'failed', 'canceled'].includes(stateOf(t))
|
|
150
155
|
|| (needsAttention(t) && acknowledged(t)) || t.expired);
|
|
151
156
|
const recent = terminal.slice(0, 100).map(taskPublic);
|
|
157
|
+
const artifactSummary = artifacts.page({ offset: 0, limit: 6 });
|
|
152
158
|
|
|
153
159
|
return {
|
|
154
160
|
stateEpoch: STATE_EPOCH,
|
|
155
161
|
stateRevision: ++stateRevision,
|
|
156
162
|
providers: providers.map(providerPublic),
|
|
163
|
+
health: providerHealthSnapshot(),
|
|
164
|
+
artifacts: { total: artifactSummary.total, recent: artifactSummary.items },
|
|
157
165
|
tasks: {
|
|
158
166
|
recent,
|
|
159
167
|
recentTotal: terminal.length, // 泡泡「查看全部 (N)」计数用(不受 slice 截断)
|
|
@@ -276,7 +284,7 @@ let sseBusDisposers = [];
|
|
|
276
284
|
function bindChangeBus() {
|
|
277
285
|
if (sseBound) return;
|
|
278
286
|
sseBound = true;
|
|
279
|
-
sseBusDisposers = [tasks.onChange(scheduleBroadcast), onConfigChange(scheduleBroadcast)];
|
|
287
|
+
sseBusDisposers = [tasks.onChange(scheduleBroadcast), artifacts.onChange(scheduleBroadcast), onConfigChange(scheduleBroadcast)];
|
|
280
288
|
}
|
|
281
289
|
function unbindChangeBus() {
|
|
282
290
|
for (const d of sseBusDisposers) {
|
|
@@ -316,22 +324,33 @@ function serveSse(res) {
|
|
|
316
324
|
}
|
|
317
325
|
|
|
318
326
|
/** webServer 前缀路由 handler:状态、任务、动作与提示词优化器 API。 */
|
|
319
|
-
export function serveApi(req, res,
|
|
327
|
+
export function serveApi(req, res, host) {
|
|
320
328
|
try {
|
|
321
329
|
const method = String(req.method || 'GET').toUpperCase();
|
|
322
330
|
const urlPath = String(req.url || '').split('?')[0];
|
|
323
331
|
|
|
324
332
|
// POST /iris/api/actions/:name —— GUI 直连触发工具执行
|
|
325
333
|
if (method === 'POST') {
|
|
334
|
+
if (urlPath === '/iris/api/host-client') {
|
|
335
|
+
if (!host || typeof host !== 'object') return sendJson(res, 503, { error: 'host unavailable' });
|
|
336
|
+
return handleJsonPost(req, res, (body) => recordHostClient(body));
|
|
337
|
+
}
|
|
326
338
|
// 文件选择器 L2:上传本地文件字节 → 存 uploads/ → 返回路径(独立大 body 上限)
|
|
327
339
|
if (urlPath === '/iris/api/upload') {
|
|
328
340
|
return handleUpload(req, res);
|
|
329
341
|
}
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
342
|
+
// D4 人工 retry as new task:产生新的真实计费,请求体必须显式 confirmBilling:true
|
|
343
|
+
const coreTaskRetry = urlPath.match(/^\/iris\/api\/core\/task\/([^/]+)\/retry\/?$/);
|
|
344
|
+
if (coreTaskRetry) return handleCoreTaskRetry(coreTaskRetry[1], req, res);
|
|
345
|
+
// D1/D2/D3 人工动作:reobserve 显式单步 poll 一次;redeliver 只对 succeeded+failed
|
|
346
|
+
// 重新取回产物;cancel 只有 Provider 明确确认才写已取消。三者绝不 submit。
|
|
347
|
+
const coreTaskAction = urlPath.match(/^\/iris\/api\/core\/task\/([^/]+)\/(reobserve|redeliver|cancel|acknowledge|restore|hide|unhide)\/?$/);
|
|
348
|
+
if (coreTaskAction) return handleCoreTaskManual(coreTaskAction[2], coreTaskAction[1], req, res);
|
|
349
|
+
const promptMatch = typeof host === 'object' && urlPath.match(/^\/iris\/api\/prompt-optimizer\/(optimize|import|reset|enabled)\/?$/);
|
|
350
|
+
if (promptMatch) return handlePromptOptimizer(host, promptMatch[1], req, res);
|
|
351
|
+
const actionMatch = typeof host === 'object' && urlPath.match(/^\/iris\/api\/actions\/([A-Za-z0-9_-]+)\/?$/);
|
|
333
352
|
if (!actionMatch) { return sendJson(res, 405, { error: 'method not allowed' }); }
|
|
334
|
-
return handleAction(
|
|
353
|
+
return handleAction(host, actionMatch[1], req, res);
|
|
335
354
|
}
|
|
336
355
|
// GET/HEAD 原逻辑
|
|
337
356
|
if (method !== 'GET' && method !== 'HEAD') {
|
|
@@ -345,6 +364,22 @@ export function serveApi(req, res, ctx) {
|
|
|
345
364
|
if (urlPath === '/iris/api/prompt-optimizer/config') {
|
|
346
365
|
return sendJson(res, 200, method === 'HEAD' ? {} : loadPromptOptimizerConfig());
|
|
347
366
|
}
|
|
367
|
+
if (urlPath === '/iris/api/doctor') {
|
|
368
|
+
if (!host || typeof host !== 'object') return sendJson(res, 503, { error: 'host unavailable' });
|
|
369
|
+
return sendJson(res, 200, method === 'HEAD' ? {} : hostDoctor(host, hostRuntimeEvidence()));
|
|
370
|
+
}
|
|
371
|
+
if (urlPath === '/iris/api/core/snapshot') {
|
|
372
|
+
if (method === 'HEAD') return sendJson(res, 200, {});
|
|
373
|
+
const query = new URL(req.url || '', 'http://iris.local').searchParams;
|
|
374
|
+
return serveCoreSnapshot(res, { limit: Number(query.get('limit') || 12) });
|
|
375
|
+
}
|
|
376
|
+
const coreMediaMatch = urlPath.match(/^\/iris\/api\/core\/artifact\/(artifact_[a-f0-9]{24})\/media$/);
|
|
377
|
+
if (coreMediaMatch) return serveCoreArtifactMedia(coreMediaMatch[1], method, res);
|
|
378
|
+
if (urlPath === '/iris/api/artifacts') {
|
|
379
|
+
const query = new URL(req.url || '', 'http://iris.local').searchParams;
|
|
380
|
+
const page = artifacts.page({ offset: Number(query.get('offset') || 0), limit: Number(query.get('limit') || 24) });
|
|
381
|
+
return sendJson(res, 200, method === 'HEAD' ? {} : page);
|
|
382
|
+
}
|
|
348
383
|
const taskMatch = urlPath.match(/^\/iris\/api\/task\/([^/]+)$/);
|
|
349
384
|
if (taskMatch) {
|
|
350
385
|
return serveTaskDetail(taskMatch[1], method, res);
|
|
@@ -365,6 +400,37 @@ export function serveApi(req, res, ctx) {
|
|
|
365
400
|
}
|
|
366
401
|
}
|
|
367
402
|
|
|
403
|
+
async function serveCoreArtifactMedia(artifactId, method, res) {
|
|
404
|
+
try {
|
|
405
|
+
const result = await readCoreArtifactMediaForDsh(artifactId);
|
|
406
|
+
if (res.writableEnded || res.destroyed) return;
|
|
407
|
+
res.writeHead(200, {
|
|
408
|
+
'Content-Type': result.artifact.mediaType,
|
|
409
|
+
'Content-Length': result.bytes.length,
|
|
410
|
+
'Content-Disposition': 'inline',
|
|
411
|
+
'Cache-Control': 'private, max-age=3600',
|
|
412
|
+
'X-Content-Type-Options': 'nosniff',
|
|
413
|
+
'Cross-Origin-Resource-Policy': 'same-origin',
|
|
414
|
+
'Referrer-Policy': 'no-referrer'
|
|
415
|
+
});
|
|
416
|
+
res.end(method === 'HEAD' ? undefined : result.bytes);
|
|
417
|
+
} catch (_) {
|
|
418
|
+
return sendJson(res, 404, { error: 'not found' });
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async function serveCoreSnapshot(res, options = {}) {
|
|
423
|
+
try {
|
|
424
|
+
return sendJson(res, 200, await coreSnapshotForDsh({ limit: options.limit || 12 }));
|
|
425
|
+
} catch (error) {
|
|
426
|
+
console.error('[iris] Core 只读快照失败:', error && error.code || 'unknown');
|
|
427
|
+
return sendJson(res, 409, {
|
|
428
|
+
schemaVersion: 0, available: true, readOnly: true,
|
|
429
|
+
error: { code: String(error && error.code || 'IRIS_CORE_SNAPSHOT_FAILED') }
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
368
434
|
function sendJson(res, status, obj) {
|
|
369
435
|
if (res.writableEnded || res.destroyed) return; // 已结束/已销毁的连接不再写(end 后二次 end 防御)
|
|
370
436
|
if (!res.headersSent) res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' });
|
|
@@ -405,21 +471,121 @@ function handleJsonPost(req, res, execute) {
|
|
|
405
471
|
const result = await execute(body, controller.signal);
|
|
406
472
|
finish(200, result);
|
|
407
473
|
} catch (err) {
|
|
408
|
-
finish(controller.signal.aborted ? 499 : 400, {
|
|
409
|
-
error: controller.signal.aborted ? 'request canceled' : String((err && err.message) || err)
|
|
474
|
+
finish(controller.signal.aborted ? 499 : (Number(err && err.statusCode) || 400), {
|
|
475
|
+
error: controller.signal.aborted ? 'request canceled' : String((err && err.message) || err),
|
|
476
|
+
...(err && err.code ? { code: String(err.code) } : {})
|
|
410
477
|
});
|
|
411
478
|
}
|
|
412
479
|
});
|
|
413
480
|
req.on('error', () => finish(400, { error: 'bad request' }));
|
|
414
481
|
}
|
|
415
482
|
|
|
416
|
-
function handleAction(
|
|
417
|
-
return handleJsonPost(req, res, (args, signal) => runAction(
|
|
483
|
+
function handleAction(host, name, req, res) {
|
|
484
|
+
return handleJsonPost(req, res, (args, signal) => runAction(host, name, args, { signal }));
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* POST /iris/api/core/task/:id/(reobserve|redeliver) —— D1/D2 人工动作。
|
|
489
|
+
* 成功返回 200 + 任务最新五类用户投影行;坏 ID/未知/不满足动作门映射为稳定 4xx。
|
|
490
|
+
* 任何拒绝都在 Provider 调用与 Task 写入之前发生;错误体不含绝对路径、Key、
|
|
491
|
+
* 供应商身份或 binding。请求体不接受参数(Task ID 只在 URL 里),客户端断开
|
|
492
|
+
* 经 AbortSignal 传入在途操作,与 GUI actions 同一约定。
|
|
493
|
+
*/
|
|
494
|
+
const CORE_TASK_MANUAL_ACTIONS = Object.freeze({
|
|
495
|
+
reobserve: { command: 'task.reobserve', run: reobserveProviderTaskForDsh },
|
|
496
|
+
redeliver: { command: 'task.redeliver', run: redeliverProviderTaskForDsh },
|
|
497
|
+
cancel: { command: 'task.cancel', run: cancelProviderTaskForDsh },
|
|
498
|
+
// 注意力处置是 Host 偏好(不写 Core 事实):acknowledge/restore/hide/unhide。
|
|
499
|
+
acknowledge: { command: 'attention.acknowledge', run: (input) => disposeCoreTaskAttention({ ...input, action: 'acknowledge' }) },
|
|
500
|
+
restore: { command: 'attention.restore', run: (input) => disposeCoreTaskAttention({ ...input, action: 'restore' }) },
|
|
501
|
+
hide: { command: 'attention.hide', run: (input) => disposeCoreTaskAttention({ ...input, action: 'hide' }) },
|
|
502
|
+
unhide: { command: 'attention.unhide', run: (input) => disposeCoreTaskAttention({ ...input, action: 'unhide' }) }
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* POST /iris/api/core/task/:id/retry —— D4 人工重试为新任务。
|
|
507
|
+
* body 必须携带 { prompt, confirmBilling: true, modelRef? }:Core 记录不持久化
|
|
508
|
+
* Prompt,只能由调用方重新提供;缺显式计费确认在读取旧 Task 之前拒绝。
|
|
509
|
+
* 成功返回 200 + 新任务的五类用户投影行与 retriedFrom 关系。
|
|
510
|
+
*/
|
|
511
|
+
function handleCoreTaskRetry(taskId, req, res) {
|
|
512
|
+
if (!/^task_[a-f0-9]{24}$/.test(taskId)) {
|
|
513
|
+
return sendJson(res, 400, { error: { code: 'IRIS_DSH_TASK_INVALID', message: 'Core Task ID 无效' } });
|
|
514
|
+
}
|
|
515
|
+
handleJsonPost(req, res, async (body, signal) => {
|
|
516
|
+
try {
|
|
517
|
+
if (!body || body.confirmBilling !== true) {
|
|
518
|
+
const error = new Error('重试会创建新任务并可能产生重复生成费用;请求体必须显式 confirmBilling:true');
|
|
519
|
+
error.code = 'IRIS_COMMAND_BILLING_CONFIRM_REQUIRED';
|
|
520
|
+
error.statusCode = 400;
|
|
521
|
+
throw error;
|
|
522
|
+
}
|
|
523
|
+
const prompt = String(body.prompt || '');
|
|
524
|
+
const result = await retryProviderTaskForDsh({
|
|
525
|
+
taskId,
|
|
526
|
+
prompt,
|
|
527
|
+
...(body.text !== undefined ? { text: String(body.text) } : {}),
|
|
528
|
+
...(body.voice !== undefined ? { voice: String(body.voice) } : {}),
|
|
529
|
+
...(body.audio_url !== undefined ? { audio_url: String(body.audio_url) } : {}),
|
|
530
|
+
modelRef: typeof body.modelRef === 'string' && body.modelRef.trim() ? body.modelRef.trim() : undefined,
|
|
531
|
+
confirmBilling: true,
|
|
532
|
+
signal
|
|
533
|
+
});
|
|
534
|
+
return {
|
|
535
|
+
ok: true,
|
|
536
|
+
contractVersion: 0,
|
|
537
|
+
command: 'task.retry',
|
|
538
|
+
retriedFrom: result.retriedFrom,
|
|
539
|
+
task: result.row
|
|
540
|
+
};
|
|
541
|
+
} catch (error) {
|
|
542
|
+
const code = String(error && error.code || '');
|
|
543
|
+
error.statusCode = Number(error.statusCode)
|
|
544
|
+
|| (code === 'IRIS_TASK_NOT_FOUND' ? 404
|
|
545
|
+
: (code === 'IRIS_TASK_NOT_RETRYABLE' || code.startsWith('IRIS_PROVIDER_')) ? 409
|
|
546
|
+
: 400);
|
|
547
|
+
throw error;
|
|
548
|
+
}
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function handleCoreTaskManual(action, taskId, req, res) {
|
|
553
|
+
if (!/^task_[a-f0-9]{24}$/.test(taskId)) {
|
|
554
|
+
return sendJson(res, 400, { error: { code: 'IRIS_DSH_TASK_INVALID', message: 'Core Task ID 无效' } });
|
|
555
|
+
}
|
|
556
|
+
const entry = CORE_TASK_MANUAL_ACTIONS[action];
|
|
557
|
+
if (!entry) return sendJson(res, 404, { error: 'not found' });
|
|
558
|
+
const controller = new AbortController();
|
|
559
|
+
let completed = false;
|
|
560
|
+
const finish = (status, body) => {
|
|
561
|
+
if (completed) return;
|
|
562
|
+
completed = true;
|
|
563
|
+
sendJson(res, status, body);
|
|
564
|
+
};
|
|
565
|
+
const disconnect = () => { if (!completed) controller.abort(); };
|
|
566
|
+
if (typeof req.on === 'function') req.on('aborted', disconnect);
|
|
567
|
+
if (typeof res.on === 'function') res.on('close', disconnect);
|
|
568
|
+
if (typeof req.resume === 'function') req.resume(); // 无 body 入参;避免小请求体滞留
|
|
569
|
+
entry.run({ taskId, signal: controller.signal })
|
|
570
|
+
.then((result) => finish(200, {
|
|
571
|
+
ok: true,
|
|
572
|
+
contractVersion: 0,
|
|
573
|
+
command: entry.command,
|
|
574
|
+
task: result.row
|
|
575
|
+
}))
|
|
576
|
+
.catch((error) => {
|
|
577
|
+
const code = String(error && error.code || 'IRIS_CORE_MANUAL_ACTION_FAILED');
|
|
578
|
+
const status = controller.signal.aborted ? 499
|
|
579
|
+
: code === 'IRIS_TASK_NOT_FOUND' ? 404
|
|
580
|
+
: (code === 'IRIS_TASK_ID_INVALID' || code === 'IRIS_DSH_TASK_INVALID') ? 400
|
|
581
|
+
: 409;
|
|
582
|
+
finish(status, { error: { code, message: String((error && error.message) || '无法执行 Core Task 动作') } });
|
|
583
|
+
});
|
|
418
584
|
}
|
|
419
585
|
|
|
420
|
-
function handlePromptOptimizer(
|
|
586
|
+
function handlePromptOptimizer(host, operation, req, res) {
|
|
421
587
|
return handleJsonPost(req, res, async (body, signal) => {
|
|
422
|
-
if (operation === 'optimize') return optimizePrompt(
|
|
588
|
+
if (operation === 'optimize') return optimizePrompt(host, body, { signal });
|
|
423
589
|
if (operation === 'import') {
|
|
424
590
|
const imported = body && body.config !== undefined ? body.config : body;
|
|
425
591
|
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
|
+
}
|