@mokuyoaxis/dsh-iris 0.2.5 → 0.2.7
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-use/SKILL.md +2 -2
- package/.dsh/skills/iris-use/references/media-contract.md +2 -2
- package/CHANGELOG.md +49 -1
- package/README.en.md +39 -22
- package/README.md +40 -21
- package/bin/dsh-iris.js +166 -35
- package/docs/AGENT_QUICKSTART.md +7 -7
- package/docs/ARCHITECTURE.md +9 -3
- package/docs/CLI_MANAGEMENT.md +5 -1
- package/docs/CORE_RUNTIME_CONTRACT.md +36 -5
- package/docs/CORE_SKILLS.md +62 -0
- package/docs/DSH_COMPATIBILITY.md +8 -4
- package/docs/DSH_CORE_MIGRATION.md +4 -2
- package/docs/DSH_PROVIDER_CONNECTIONS.md +34 -0
- package/docs/DSH_RUNTIME.md +36 -0
- package/docs/FUNCTION_MODEL_ASSIGNMENTS.md +2 -0
- package/docs/FUNCTION_MODEL_POOLS.md +84 -0
- package/docs/HEADLESS_CLI.md +16 -8
- package/docs/HOST_ADAPTER_CONTRACT.md +7 -2
- package/docs/HOST_DOCTOR.md +14 -2
- package/docs/IMAGE_FLOW.md +71 -0
- package/docs/LOCAL_API.md +60 -0
- package/docs/LOCAL_API_CONTRACT.md +115 -0
- package/docs/LOCAL_CREATION.md +50 -0
- package/docs/MUSIC.md +2 -2
- package/docs/PROVIDER_HEALTH.md +26 -2
- package/docs/ROADMAP.md +18 -4
- package/docs/SECURITY.md +20 -2
- package/docs/SYNC_DELIVERY_RECOVERY.md +2 -0
- package/docs/VISION_CLI.md +2 -2
- package/docs/WEB_PROTOTYPE.md +47 -0
- package/docs/releases/0.2.6.md +50 -0
- package/docs/releases/0.2.7.md +62 -0
- package/lib/actions.js +208 -90
- package/lib/adapters.js +36 -9
- package/lib/api.js +40 -39
- package/lib/application-runtime.js +186 -0
- package/lib/client.js +805 -182
- package/lib/composite-vision-routing.js +6 -6
- package/lib/config.js +140 -29
- package/lib/core-artifact-store.js +5 -0
- package/lib/core-maintenance.js +25 -2
- package/lib/core-runtime.js +19 -0
- package/lib/core-tasks.js +62 -8
- package/lib/creation-request-store.js +159 -0
- package/lib/doctor.js +20 -2
- package/lib/dsh-core-adapter.js +669 -564
- package/lib/dsh-host-adapter.js +14 -1
- package/lib/dsh-provider-config.js +35 -0
- package/lib/dsh-provider-source.js +102 -0
- package/lib/flow-store.js +195 -0
- package/lib/headless-vision.js +5 -5
- package/lib/host-contract.js +1 -0
- package/lib/http-vision-model-adapter.js +5 -2
- package/lib/image-flow.js +188 -0
- package/lib/index.js +103 -78
- package/lib/local-api-projection.js +27 -0
- package/lib/local-api.js +381 -0
- package/lib/local-creation.js +85 -0
- package/lib/model-directory-service.js +53 -0
- package/lib/model-directory.js +103 -0
- package/lib/models.js +71 -6
- package/lib/ocr-model-routing.js +2 -2
- package/lib/private-storage.js +12 -0
- package/lib/provider-adapters.js +42 -9
- package/lib/provider-catalog.js +14 -9
- package/lib/provider-config-scope.js +16 -0
- package/lib/provider-config-service.js +82 -13
- package/lib/provider-health.js +33 -0
- package/lib/provider-protocol.js +30 -0
- package/lib/provider-task-runner.js +22 -2
- package/lib/render-address.js +35 -0
- package/lib/render.js +52 -13
- package/lib/skill-installation.js +199 -0
- package/lib/vision-model-routing.js +25 -12
- package/lib/workbench-artifacts.js +1 -2
- package/lib/workbench-management.js +8 -9
- package/package.json +4 -3
- package/skills/iris-core/SKILL.md +40 -0
- package/skills/iris-core/agents/openai.yaml +4 -0
- package/skills/iris-core/references/cli.md +95 -0
- package/skills/iris-core/references/tasks.md +58 -0
- package/user_guide.md +32 -20
package/lib/provider-protocol.js
CHANGED
|
@@ -114,6 +114,36 @@ export function isConfiguredProvider(provider) {
|
|
|
114
114
|
&& (provider.auth === 'none' || (typeof provider.apiKey === 'string' && provider.apiKey.trim())));
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
/** 已知需要其他接口的语音系列;自定义模型仍以明确配置和实测为准。 */
|
|
118
|
+
export function dashscopeTtsProtocolIssue(modelId) {
|
|
119
|
+
const model = String(modelId || '');
|
|
120
|
+
if (/^qwen(?:\d+(?:\.\d+)?)?-(?:tts|omni|audio)-.*realtime(?:-|$)/i.test(model)) {
|
|
121
|
+
return '该实时语音模型需要 WebSocket,Iris 尚未接入该调用方式';
|
|
122
|
+
}
|
|
123
|
+
if (/^(?:qwen-audio-|cosyvoice(?:-|$)|sambert-|MiniMax\/speech-)/i.test(model)) {
|
|
124
|
+
return 'Iris 尚未接入该语音系列的合成接口;当前使用 Qwen-TTS 普通 HTTP 接口';
|
|
125
|
+
}
|
|
126
|
+
return '';
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 复刻/设计模型不能沿用普通模型的默认音色。 */
|
|
130
|
+
export function dashscopeTtsInputIssue(modelId, voice) {
|
|
131
|
+
return dashscopeTtsProtocolIssue(modelId)
|
|
132
|
+
|| (/^qwen(?:\d+)?-tts-(?:vc|vd)(?:-|$)/i.test(String(modelId || '')) && !String(voice || '').trim()
|
|
133
|
+
? '该模型需要先创建复刻或设计音色;请在语音功能填写与该模型匹配的音色 ID 后验证'
|
|
134
|
+
: '');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Protocol support is independent from model capability declarations and health. */
|
|
138
|
+
export function supportsModelCapability(provider, modelId, capability, { checkEndpoint = true } = {}) {
|
|
139
|
+
if (capability === 'vision') return true;
|
|
140
|
+
if (capability === 'music-gen') return provider?.models?.some(model => model?.id === modelId && model.musicProtocol === 'gemini-web-music') === true;
|
|
141
|
+
const protocol = capability === 'image-gen' ? selectImageProtocol(provider, modelId).mediaProtocol : selectMediaProtocol(provider).mediaProtocol;
|
|
142
|
+
if (protocol === 'dashscope') return (capability !== 'tts' || !dashscopeTtsProtocolIssue(modelId))
|
|
143
|
+
&& (!checkEndpoint || isDashScopeBaseUrl(providerMediaBaseUrl(provider)));
|
|
144
|
+
return capability === 'image-gen' && ['openai-images', 'openai-chat-images', 'openai-responses-images'].includes(protocol);
|
|
145
|
+
}
|
|
146
|
+
|
|
117
147
|
/**
|
|
118
148
|
* 生图尺寸的协议差异集中在这里,调用点不再自带厂商假设。
|
|
119
149
|
*
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
beginCoreDelivery,
|
|
12
12
|
completeCoreDelivery,
|
|
13
13
|
createCoreTask,
|
|
14
|
+
claimFlowTask,
|
|
14
15
|
failCoreDelivery,
|
|
15
16
|
finalizeCoreNoAcceptance,
|
|
16
17
|
inspectCoreTask,
|
|
@@ -33,6 +34,8 @@ import { ProviderContractError, providerErrorRecord, submitWithAcceptanceBoundar
|
|
|
33
34
|
import { imageEditCandidates, readImageEditArtifact } from './image-edit-input.js';
|
|
34
35
|
import { createDeliveryCache, newDeliveryPayloadPath, readDeliveryCache,
|
|
35
36
|
readDeliveryPayload, recordDeliveryPayload } from './core-delivery-cache.js';
|
|
37
|
+
import { readFlowRun, flowFail } from './flow-store.js';
|
|
38
|
+
import { normalizeGenerationInput } from './generation-input.js';
|
|
36
39
|
|
|
37
40
|
export const PROVIDER_TASK_RUNNER_VERSION = 0;
|
|
38
41
|
|
|
@@ -193,8 +196,11 @@ async function observeWithinRoot(dataRoot, taskId, adapter, signal) {
|
|
|
193
196
|
} catch (error) {
|
|
194
197
|
return recordCoreWatchError(dataRoot, taskId, error);
|
|
195
198
|
}
|
|
196
|
-
const
|
|
199
|
+
const cachedFlowResult = Boolean(before.flowBinding && result.kind === 'succeeded');
|
|
200
|
+
if (cachedFlowResult) createDeliveryCache(dataRoot, before, result.artifacts);
|
|
201
|
+
const recorded = recordCorePollResult(dataRoot, taskId, result, { deliveryCache: cachedFlowResult });
|
|
197
202
|
if (result.kind !== 'succeeded') return recorded;
|
|
203
|
+
if (cachedFlowResult) return deliverCachedArtifacts(dataRoot, taskId, adapter, signal);
|
|
198
204
|
return deliverArtifacts(dataRoot, taskId, adapter, result.artifacts, signal);
|
|
199
205
|
}
|
|
200
206
|
|
|
@@ -288,13 +294,27 @@ export function createProviderTaskRunner(runtime) {
|
|
|
288
294
|
const capability = String(input.capability || '').trim();
|
|
289
295
|
let candidates = normalizedCandidates(capability, input.candidates);
|
|
290
296
|
let providerInput = input.providerInput ?? {};
|
|
297
|
+
let flow;
|
|
298
|
+
if (input.flowRunId !== undefined) {
|
|
299
|
+
flow = readFlowRun(dataRoot, input.flowRunId);
|
|
300
|
+
const expected = normalizeGenerationInput('image', flow.input).providerInput;
|
|
301
|
+
if (capability !== 'image' || !flow.edit.claimed || input.sourceArtifactId !== flow.source.artifactId
|
|
302
|
+
|| Object.keys(providerInput).length !== Object.keys(expected).length
|
|
303
|
+
|| Object.keys(expected).some(key => providerInput[key] !== expected[key])
|
|
304
|
+
|| candidates.length !== 1 || candidates[0].model !== flow.input.model_ref
|
|
305
|
+
|| candidates[0].providerBinding !== flow.providerBinding) {
|
|
306
|
+
flowFail('IRIS_FLOW_INPUT_CONFLICT', '提交与流程已保存的输入或模型绑定冲突');
|
|
307
|
+
}
|
|
308
|
+
}
|
|
291
309
|
if (input.sourceArtifactId !== undefined) {
|
|
292
310
|
if (capability !== 'image') throw new TypeError('sourceArtifactId 仅用于图片编辑');
|
|
293
311
|
candidates = imageEditCandidates(candidates);
|
|
294
312
|
const image = await readImageEditArtifact(dataRoot, input.sourceArtifactId, signal);
|
|
295
313
|
providerInput = { ...providerInput, image };
|
|
296
314
|
}
|
|
297
|
-
const task =
|
|
315
|
+
const task = flow ? claimFlowTask(dataRoot, flow.id)
|
|
316
|
+
: createCoreTask(dataRoot, { capability, retriedFrom: input.retriedFrom, sourceArtifactId: input.sourceArtifactId });
|
|
317
|
+
if (flow && task.attempts.length) flowFail('IRIS_FLOW_TASK_ALREADY_STARTED', '该流程 Task 已有提交意图,禁止重新 submit');
|
|
298
318
|
const completedResults = new Map();
|
|
299
319
|
const wrapped = candidates.map(({ adapter, model, providerModel, prepareInput }) => ({
|
|
300
320
|
id: adapter.id,
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** Local browser navigation address. Public media links keep their separate webBase() contract. */
|
|
2
|
+
const DEFAULT_BASE = 'http://127.0.0.1:3080';
|
|
3
|
+
|
|
4
|
+
function invalid(source, message) {
|
|
5
|
+
return Object.freeze({ source, status: 'invalid', code: 'IRIS_RENDER_ADDRESS_INVALID', message });
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function checkedBase(value, source) {
|
|
9
|
+
let url;
|
|
10
|
+
try { url = new URL(value); }
|
|
11
|
+
catch (_) { return invalid(source, '截图地址无法解析;请设置有效的 DSH_WEB_BASE HTTP(S) 地址。'); }
|
|
12
|
+
if (!['http:', 'https:'].includes(url.protocol) || !url.hostname || url.username || url.password || url.search || url.hash) {
|
|
13
|
+
return invalid(source, '截图地址必须为 HTTP(S),且不能包含登录凭据、查询参数或片段;请核对 DSH_WEB_BASE。');
|
|
14
|
+
}
|
|
15
|
+
return Object.freeze({ source, status: 'resolved', baseUrl: url.href.replace(/\/+$/, '') });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Only documented host/port/protocol properties are read; no request headers or private config. */
|
|
19
|
+
export function resolveRenderAddress(listener, override = process.env.DSH_WEB_BASE) {
|
|
20
|
+
if (override) return checkedBase(String(override).trim(), 'environment');
|
|
21
|
+
const port = listener?.port;
|
|
22
|
+
const host = listener?.host;
|
|
23
|
+
if (typeof host !== 'string' || !host || !Number.isSafeInteger(port) || port < 1 || port > 65535) {
|
|
24
|
+
return Object.freeze({ source: 'default', status: 'fallback', baseUrl: DEFAULT_BASE,
|
|
25
|
+
message: '宿主未提供实际监听地址,暂用 127.0.0.1:3080;非默认部署请设置 DSH_WEB_BASE。' });
|
|
26
|
+
}
|
|
27
|
+
// Older supported DSH listeners are HTTP-only and have no protocol property.
|
|
28
|
+
const protocol = listener.protocol ?? 'http:';
|
|
29
|
+
if (!['http:', 'https:'].includes(protocol) || /[\s/@?#\\%]/.test(host)) {
|
|
30
|
+
return invalid('host', '宿主监听地址不能直接用于截图;请通过 DSH_WEB_BASE 指定浏览器可访问的 HTTP(S) 地址。');
|
|
31
|
+
}
|
|
32
|
+
const localHost = host === '0.0.0.0' ? '127.0.0.1' : (host === '::' || host === '[::]' ? '::1' : host);
|
|
33
|
+
const urlHost = localHost.includes(':') && !localHost.startsWith('[') ? '[' + localHost + ']' : localHost;
|
|
34
|
+
return checkedBase(protocol + '//' + urlHost + ':' + port, 'host');
|
|
35
|
+
}
|
package/lib/render.js
CHANGED
|
@@ -15,8 +15,41 @@ import crypto from 'node:crypto';
|
|
|
15
15
|
import fs from 'node:fs';
|
|
16
16
|
import path from 'node:path';
|
|
17
17
|
import { irisHome } from './config.js';
|
|
18
|
-
import {
|
|
18
|
+
import { mimeOf } from './media.js';
|
|
19
|
+
import { resolveRenderAddress } from './render-address.js';
|
|
19
20
|
import { ensurePrivateDir, writePrivateFile } from './private-storage.js';
|
|
21
|
+
import { imageDimensions } from './pixels.js';
|
|
22
|
+
|
|
23
|
+
const PNG_DATA_PREFIX = 'data:image/png;base64,';
|
|
24
|
+
const MAX_SCREENSHOT_BYTES = 64 * 1024 * 1024;
|
|
25
|
+
|
|
26
|
+
async function screenshotPng(result) {
|
|
27
|
+
const dataUrl = result?.dataUrl;
|
|
28
|
+
if (typeof dataUrl !== 'string' || !dataUrl.startsWith(PNG_DATA_PREFIX)) {
|
|
29
|
+
throw new RenderError('iris_html_screenshot: 浏览器没有返回 PNG 图片数据');
|
|
30
|
+
}
|
|
31
|
+
if (dataUrl.length > PNG_DATA_PREFIX.length + Math.ceil(MAX_SCREENSHOT_BYTES / 3) * 4) {
|
|
32
|
+
throw new RenderError('iris_html_screenshot: 截图超过 64 MiB,请缩短页面后重试');
|
|
33
|
+
}
|
|
34
|
+
const encoded = dataUrl.slice(PNG_DATA_PREFIX.length);
|
|
35
|
+
const png = Buffer.from(encoded, 'base64');
|
|
36
|
+
if (!png.length || png.length > MAX_SCREENSHOT_BYTES || png.toString('base64') !== encoded
|
|
37
|
+
|| !png.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex'))) {
|
|
38
|
+
throw new RenderError('iris_html_screenshot: 浏览器返回的 PNG 图片数据无效');
|
|
39
|
+
}
|
|
40
|
+
let dimensions;
|
|
41
|
+
try { dimensions = await imageDimensions(png); }
|
|
42
|
+
catch (cause) {
|
|
43
|
+
const error = new RenderError('iris_html_screenshot: 浏览器返回的 PNG 图片无法读取');
|
|
44
|
+
error.cause = cause;
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
// 与独立 Chromium 后端保持相同的截图尺寸上限,采用实际图片尺寸而非浏览器声明值。
|
|
48
|
+
if (dimensions.width > 16384 || dimensions.height > 16384 || dimensions.width * dimensions.height > 40000000) {
|
|
49
|
+
throw new RenderError('iris_html_screenshot: 截图尺寸超过限制(边长 16384,4000 万像素)');
|
|
50
|
+
}
|
|
51
|
+
return png;
|
|
52
|
+
}
|
|
20
53
|
|
|
21
54
|
export class RenderError extends Error {
|
|
22
55
|
constructor(message) {
|
|
@@ -60,22 +93,27 @@ export function renderRoot() {
|
|
|
60
93
|
|
|
61
94
|
/**
|
|
62
95
|
* 基于 DSH Adapter 持有的原生 browser 服务完成 HTML → PNG。
|
|
63
|
-
* @param {{browser?: object, waitMs?: number}} opts browser 只在 DSH Adapter 内构造
|
|
96
|
+
* @param {{browser?: object, waitMs?: number, getAddress?: function}} opts browser 只在 DSH Adapter 内构造
|
|
64
97
|
*/
|
|
65
98
|
export class BrowserHtmlRenderer {
|
|
66
|
-
constructor({ browser, waitMs = 1200 }) {
|
|
99
|
+
constructor({ browser, waitMs = 1200, getAddress = resolveRenderAddress }) {
|
|
67
100
|
this.browser = browser;
|
|
68
101
|
this.waitMs = waitMs;
|
|
102
|
+
this.getAddress = getAddress;
|
|
69
103
|
}
|
|
70
104
|
|
|
71
105
|
async render({ html, width, height, fullPage, signal }) {
|
|
72
106
|
const b = this.browser;
|
|
73
|
-
if (!b ||
|
|
107
|
+
if (!b || !['open', 'openUrl', 'screenshot', 'close'].every((name) => typeof b[name] === 'function')) {
|
|
74
108
|
throw new RenderError('iris: 浏览器服务不可用——需要启用兼容的 dsh-builtin-browser 插件');
|
|
75
109
|
}
|
|
76
110
|
const text = String(html || '').trim();
|
|
77
111
|
if (!text) throw new RenderError('iris_html_screenshot: html 不能为空');
|
|
78
112
|
throwIfAborted(signal);
|
|
113
|
+
const address = this.getAddress();
|
|
114
|
+
if (address.status === 'invalid') {
|
|
115
|
+
throw Object.assign(new RenderError(address.message), { code: address.code });
|
|
116
|
+
}
|
|
79
117
|
|
|
80
118
|
// 每次调用独立子目录(随机名),写 index.html
|
|
81
119
|
const dir = path.join(renderRoot(), crypto.randomBytes(6).toString('hex'));
|
|
@@ -88,24 +126,25 @@ export class BrowserHtmlRenderer {
|
|
|
88
126
|
}
|
|
89
127
|
const htmlPath = path.join(dir, 'index.html');
|
|
90
128
|
writePrivateFile(htmlPath, content);
|
|
91
|
-
const url = `${
|
|
92
|
-
const pngPath = path.join(dir, 'render.png');
|
|
93
|
-
|
|
129
|
+
const url = `${address.baseUrl}/iris/render/${path.basename(dir)}/index.html`;
|
|
94
130
|
try {
|
|
95
|
-
|
|
96
|
-
|
|
131
|
+
// open 只接受可选的会话名称;取消发生在打开期间时,取得会话后仍须关闭。
|
|
132
|
+
const session = await b.open();
|
|
97
133
|
try {
|
|
98
|
-
|
|
134
|
+
throwIfAborted(signal);
|
|
135
|
+
await b.openUrl(session, { url, newTab: true }, signal);
|
|
99
136
|
throwIfAborted(signal);
|
|
100
137
|
await waitForRender(this.waitMs, signal); // 等页面加载(无加载完成事件可用的折中)
|
|
101
138
|
throwIfAborted(signal);
|
|
102
|
-
|
|
139
|
+
// 不要求浏览器写入 Iris 私有目录,兼容 downloadDir 内的保存路径限制。
|
|
140
|
+
const result = await b.screenshot(session, { fullPage: fullPage !== false, format: 'png' }, signal);
|
|
141
|
+
throwIfAborted(signal);
|
|
142
|
+
const png = await screenshotPng(result);
|
|
103
143
|
throwIfAborted(signal);
|
|
144
|
+
return { png };
|
|
104
145
|
} finally {
|
|
105
146
|
try { await b.close(session); } catch (_) { /* 幂等 */ }
|
|
106
147
|
}
|
|
107
|
-
if (!fs.existsSync(pngPath)) throw new RenderError('iris_html_screenshot: 浏览器未产出截图文件');
|
|
108
|
-
return { png: fs.readFileSync(pngPath) };
|
|
109
148
|
} finally {
|
|
110
149
|
fs.rmSync(dir, { recursive: true, force: true }); // 用完即清
|
|
111
150
|
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/** Explicit, copy-based installation of the bundled Core skill. No host/model I/O. */
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
|
|
8
|
+
const PACKAGE_ROOT = fileURLToPath(new URL('../', import.meta.url));
|
|
9
|
+
const SKILL = 'iris-core';
|
|
10
|
+
const RECEIPT = '.iris-install.json';
|
|
11
|
+
const digest = value => crypto.createHash('sha256').update(value).digest('hex');
|
|
12
|
+
const error = (code, message) => Object.assign(new Error(message), { code });
|
|
13
|
+
const isWithin = (parent, child) => {
|
|
14
|
+
const relative = path.relative(parent, child);
|
|
15
|
+
return !relative || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith('..' + path.sep));
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function stat(file) {
|
|
19
|
+
try { return fs.lstatSync(file); }
|
|
20
|
+
catch (cause) { if (cause.code === 'ENOENT') return null; throw cause; }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Resolve existing ancestors without creating them, including for dry-run/status.
|
|
24
|
+
function physicalPath(file) {
|
|
25
|
+
const entry = stat(file);
|
|
26
|
+
if (entry) return fs.realpathSync(file);
|
|
27
|
+
return path.join(physicalPath(path.dirname(file)), path.basename(file));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function readTree(root, { installed = false, expected } = {}) {
|
|
31
|
+
const files = Object.create(null), bytes = Object.create(null), directories = [];
|
|
32
|
+
const walk = (directory, prefix = '') => {
|
|
33
|
+
for (const name of fs.readdirSync(directory).sort()) {
|
|
34
|
+
if (installed && !prefix && name === RECEIPT) continue;
|
|
35
|
+
const relative = prefix + name, file = path.join(directory, name), entry = fs.lstatSync(file);
|
|
36
|
+
if (entry.isSymbolicLink() || (!entry.isDirectory() && !entry.isFile())) {
|
|
37
|
+
throw error('IRIS_SKILL_UNSAFE_PATH', 'Skill 中存在软链或特殊文件:' + relative);
|
|
38
|
+
}
|
|
39
|
+
if (entry.isDirectory()) {
|
|
40
|
+
directories.push(relative); walk(file, relative + '/');
|
|
41
|
+
} else {
|
|
42
|
+
// Added/size-changed user files need not be read to establish a conflict.
|
|
43
|
+
const content = !expected || expected[relative]?.size === entry.size ? fs.readFileSync(file) : null;
|
|
44
|
+
files[relative] = { size: entry.size, sha256: content ? digest(content) : null };
|
|
45
|
+
if (!installed) bytes[relative] = content;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
walk(root);
|
|
50
|
+
return { files, bytes, directories: directories.sort() };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function sameTree(tree, recorded) {
|
|
54
|
+
const names = Object.keys(tree.files).sort(), other = Object.keys(recorded.files).sort();
|
|
55
|
+
return JSON.stringify(names) === JSON.stringify(other)
|
|
56
|
+
&& names.every(name => tree.files[name].size === recorded.files[name].size
|
|
57
|
+
&& tree.files[name].sha256 === recorded.files[name].sha256)
|
|
58
|
+
&& JSON.stringify(tree.directories) === JSON.stringify([...recorded.directories].sort());
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function validReceipt(value, packageName) {
|
|
62
|
+
const relative = name => typeof name === 'string' && name.length > 0 && !name.includes('\\')
|
|
63
|
+
&& !name.includes('\0') && !name.split('/').some(part => !part || part === '.' || part === '..');
|
|
64
|
+
return value?.schemaVersion === 1 && value.packageName === packageName && value.skillName === SKILL
|
|
65
|
+
&& typeof value.packageVersion === 'string' && value.packageVersion.length > 0
|
|
66
|
+
&& value.files && !Array.isArray(value.files) && typeof value.files === 'object'
|
|
67
|
+
&& Object.hasOwn(value.files, 'SKILL.md') && !Object.hasOwn(value.files, RECEIPT)
|
|
68
|
+
&& Object.entries(value.files).every(([name, item]) => relative(name)
|
|
69
|
+
&& Number.isSafeInteger(item?.size) && item.size >= 0 && /^[a-f0-9]{64}$/.test(item.sha256))
|
|
70
|
+
&& Array.isArray(value.directories) && value.directories.every(relative);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function packageResources(packageRoot) {
|
|
74
|
+
const metadata = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8'));
|
|
75
|
+
const source = fs.realpathSync(path.join(packageRoot, 'skills', SKILL));
|
|
76
|
+
const tree = readTree(source);
|
|
77
|
+
if (!tree.files['SKILL.md'] || tree.files[RECEIPT]) {
|
|
78
|
+
throw error('IRIS_SKILL_PACKAGE_INVALID', '包内缺少 Skill 入口,或含有不应发布的安装记录');
|
|
79
|
+
}
|
|
80
|
+
return { source, metadata, ...tree };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function inspect(destination, resources) {
|
|
84
|
+
const entry = stat(destination);
|
|
85
|
+
if (!entry) return { status: 'not_installed' };
|
|
86
|
+
if (entry.isSymbolicLink() || !entry.isDirectory()) return { status: 'unmanaged', reason: 'not_a_plain_directory' };
|
|
87
|
+
const receiptFile = path.join(destination, RECEIPT), receiptStat = stat(receiptFile);
|
|
88
|
+
if (!receiptStat?.isFile() || receiptStat.isSymbolicLink()) return { status: 'unmanaged', reason: 'no_valid_receipt' };
|
|
89
|
+
let receipt;
|
|
90
|
+
try { receipt = JSON.parse(fs.readFileSync(receiptFile, 'utf8')); }
|
|
91
|
+
catch (cause) {
|
|
92
|
+
if (!(cause instanceof SyntaxError)) throw cause;
|
|
93
|
+
return { status: 'unmanaged', reason: 'invalid_receipt' };
|
|
94
|
+
}
|
|
95
|
+
if (!validReceipt(receipt, resources.metadata.name)) return { status: 'unmanaged', reason: 'invalid_receipt' };
|
|
96
|
+
const base = { installedVersion: receipt.packageVersion };
|
|
97
|
+
let tree;
|
|
98
|
+
try { tree = readTree(destination, { installed: true, expected: receipt.files }); }
|
|
99
|
+
catch (cause) {
|
|
100
|
+
if (cause.code !== 'IRIS_SKILL_UNSAFE_PATH') throw cause;
|
|
101
|
+
return { ...base, status: 'modified', reason: 'unsafe_entry' };
|
|
102
|
+
}
|
|
103
|
+
if (!sameTree(tree, receipt)) return { ...base, status: 'modified', reason: 'local_changes' };
|
|
104
|
+
return { ...base, status: receipt.packageVersion === resources.metadata.version && sameTree(tree, resources)
|
|
105
|
+
? 'current' : 'update_available' };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function resolveDirectory({ target, directory, env, home }) {
|
|
109
|
+
if (target !== undefined && target !== 'kimi') throw error('IRIS_SKILL_OPTIONS_INVALID', '当前仅支持 --target kimi;其他宿主请用 --directory');
|
|
110
|
+
if (directory === undefined) {
|
|
111
|
+
if (target !== 'kimi') throw error('IRIS_SKILL_OPTIONS_INVALID', '请明确指定 --target kimi 或 --directory');
|
|
112
|
+
const kimiHome = env.KIMI_CODE_HOME || path.join(home, '.kimi-code');
|
|
113
|
+
if (!path.isAbsolute(kimiHome)) throw error('IRIS_SKILL_OPTIONS_INVALID', 'KIMI_CODE_HOME 必须是绝对路径');
|
|
114
|
+
directory = path.join(kimiHome, 'skills');
|
|
115
|
+
}
|
|
116
|
+
if (typeof directory !== 'string' || !path.isAbsolute(directory)) throw error('IRIS_SKILL_OPTIONS_INVALID', 'directory 必须是绝对路径');
|
|
117
|
+
return physicalPath(path.resolve(directory));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function ensureDirectory(directory) {
|
|
121
|
+
const entry = stat(directory);
|
|
122
|
+
if (entry && (!entry.isDirectory() || entry.isSymbolicLink())) throw error('IRIS_SKILL_UNSAFE_PATH', '控制目录必须是普通目录:' + directory);
|
|
123
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function archive(destination, control, action) {
|
|
127
|
+
const transaction = fs.mkdtempSync(path.join(control, 'backup-'));
|
|
128
|
+
const backupPath = path.join(transaction, SKILL);
|
|
129
|
+
fs.writeFileSync(path.join(transaction, 'move.json'), JSON.stringify({ schemaVersion: 1, action,
|
|
130
|
+
at: new Date().toISOString(), origin: destination, backupPath }, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
|
|
131
|
+
fs.renameSync(destination, backupPath);
|
|
132
|
+
return backupPath;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function copyResources(destination, resources) {
|
|
136
|
+
fs.mkdirSync(destination, { mode: 0o700 }); // Exclusive claim; never replace even an empty user directory.
|
|
137
|
+
for (const relative of resources.directories) fs.mkdirSync(path.join(destination, relative), { recursive: true, mode: 0o700 });
|
|
138
|
+
for (const [relative, bytes] of Object.entries(resources.bytes)) fs.writeFileSync(path.join(destination, relative), bytes, { flag: 'wx', mode: 0o600 });
|
|
139
|
+
fs.writeFileSync(path.join(destination, RECEIPT), JSON.stringify({ schemaVersion: 1,
|
|
140
|
+
packageName: resources.metadata.name, packageVersion: resources.metadata.version, skillName: SKILL,
|
|
141
|
+
installedAt: new Date().toISOString(), files: resources.files, directories: resources.directories
|
|
142
|
+
}, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Internal CLI service. Explicit mutations only; no postinstall or public SDK export. */
|
|
146
|
+
export function manageCoreSkill({ action, target, directory, dryRun = false }, {
|
|
147
|
+
packageRoot = PACKAGE_ROOT, env = process.env, home = os.homedir()
|
|
148
|
+
} = {}) {
|
|
149
|
+
if (!['install', 'status', 'uninstall'].includes(action) || action === 'status' && dryRun) {
|
|
150
|
+
throw error('IRIS_SKILL_OPTIONS_INVALID', '使用 skills install/status/uninstall;status 不接受 --dry-run');
|
|
151
|
+
}
|
|
152
|
+
const resources = packageResources(packageRoot);
|
|
153
|
+
const resolved = resolveDirectory({ target, directory, env, home }), destination = path.join(resolved, SKILL);
|
|
154
|
+
if (isWithin(resources.source, destination) || isWithin(destination, resources.source)) {
|
|
155
|
+
throw error('IRIS_SKILL_OPTIONS_INVALID', '安装目标不能与包内 Skill 源目录重叠');
|
|
156
|
+
}
|
|
157
|
+
const base = { skill: SKILL, target: target || 'directory', directory: resolved, destination,
|
|
158
|
+
packageVersion: resources.metadata.version, discovery: 'not_checked' };
|
|
159
|
+
const before = inspect(destination, resources);
|
|
160
|
+
const result = { ...base, ...before };
|
|
161
|
+
if (action === 'status') return result;
|
|
162
|
+
const conflict = () => error('IRIS_SKILL_CONFLICT', '保留现有 Skill;目标没有有效归属记录、包含本地修改或软链。请先检查:' + destination);
|
|
163
|
+
if (['modified', 'unmanaged'].includes(before.status)) throw conflict();
|
|
164
|
+
if (action === 'install' && before.status === 'current'
|
|
165
|
+
|| action === 'uninstall' && before.status === 'not_installed') return { ...result, action: 'unchanged', dryRun };
|
|
166
|
+
const operation = action === 'uninstall' ? 'uninstall' : before.status === 'not_installed' ? 'install' : 'update';
|
|
167
|
+
if (dryRun) return { ...result, action: operation, dryRun: true };
|
|
168
|
+
const controls = path.join(path.dirname(resolved), '.iris-skill-installs');
|
|
169
|
+
const control = path.join(controls, digest(resolved).slice(0, 16));
|
|
170
|
+
if (isWithin(resolved, control)) throw error('IRIS_SKILL_OPTIONS_INVALID', '安装目录必须允许在扫描目录之外保存备份');
|
|
171
|
+
ensureDirectory(controls); ensureDirectory(control);
|
|
172
|
+
const lock = path.join(control, 'lock.json');
|
|
173
|
+
let fd;
|
|
174
|
+
try { fd = fs.openSync(lock, 'wx', 0o600); }
|
|
175
|
+
catch (cause) {
|
|
176
|
+
if (cause.code !== 'EEXIST') throw cause;
|
|
177
|
+
throw error('IRIS_SKILL_BUSY', '已有安装操作或未核实的锁,保留现场:' + lock);
|
|
178
|
+
}
|
|
179
|
+
let backupPath;
|
|
180
|
+
try {
|
|
181
|
+
fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, destination }) + '\n');
|
|
182
|
+
const current = inspect(destination, resources);
|
|
183
|
+
if (current.status !== before.status || current.installedVersion !== before.installedVersion) {
|
|
184
|
+
throw error('IRIS_SKILL_CONFLICT', '安装状态已变化,请重新检查:' + destination);
|
|
185
|
+
}
|
|
186
|
+
ensureDirectory(resolved);
|
|
187
|
+
if (before.status !== 'not_installed') backupPath = archive(destination, control, operation);
|
|
188
|
+
if (action === 'uninstall') return { ...base, status: 'not_installed', action: 'uninstalled', backupPath };
|
|
189
|
+
copyResources(destination, resources);
|
|
190
|
+
return { ...base, status: 'current', installedVersion: resources.metadata.version,
|
|
191
|
+
action: operation === 'update' ? 'updated' : 'installed', ...(backupPath ? { backupPath } : {}) };
|
|
192
|
+
} catch (cause) {
|
|
193
|
+
if (backupPath) cause.message += '\n原版保存在:' + backupPath;
|
|
194
|
+
throw cause;
|
|
195
|
+
} finally {
|
|
196
|
+
fs.closeSync(fd);
|
|
197
|
+
fs.unlinkSync(lock); // Only this invocation's exclusively created coordination file.
|
|
198
|
+
}
|
|
199
|
+
}
|
|
@@ -7,7 +7,8 @@ import { prepareDshVisionModelPort } from './dsh-vision-model-adapter.js';
|
|
|
7
7
|
import { createModelOperation } from './model-invoker.js';
|
|
8
8
|
import { ModelPortError, normalizeModelDescriptor, normalizeModelCall, modelErrorRecord } from './model-port-contract.js';
|
|
9
9
|
import { waitForModelWork } from './model-call-runtime.js';
|
|
10
|
-
import { modelRateLimit, reportModelRateLimit } from './provider-health.js';
|
|
10
|
+
import { modelRateLimit, reportModelRateLimit, recordModelAvailability } from './provider-health.js';
|
|
11
|
+
import { modelAvailabilityBinding, modelUnavailable } from './model-directory.js';
|
|
11
12
|
import { completeVision, VISION_BUDGET } from './vision-core.js';
|
|
12
13
|
import { open } from 'node:fs/promises';
|
|
13
14
|
import { DEFAULT_VISION_INPUT, visionInputLimits } from './vision-image-input.js';
|
|
@@ -47,7 +48,7 @@ function deferredDshPort(host, { prepareImages, orientImages, prepareImageReques
|
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
/** 配置只绑定一个自持模型;CLI 不附加宿主候选。 */
|
|
50
|
-
export function createConfiguredVisionModelPort(provider, model, { allowRateLimited = false, onRateLimit, prepareImages = false,
|
|
51
|
+
export function createConfiguredVisionModelPort(provider, model, { allowRateLimited = false, onRateLimit, onModelAvailability, prepareImages = false,
|
|
51
52
|
orientImages = false, prepareImageRequest } = {}) {
|
|
52
53
|
const parsed = parseModelRef(resolveModelRef(provider, 'vision', model));
|
|
53
54
|
const id = 'iris-provider:' + encodeURIComponent(provider.id);
|
|
@@ -59,13 +60,25 @@ export function createConfiguredVisionModelPort(provider, model, { allowRateLimi
|
|
|
59
60
|
baseUrl: provider.baseUrl, apiKey: providerApiKey(provider),
|
|
60
61
|
orientImages, prepareImageRequest,
|
|
61
62
|
...(prepareImages ? { imageInput: visionInputLimits(provider, parsed.modelId) } : {}) });
|
|
63
|
+
const availability = result => {
|
|
64
|
+
const event = { ...result, stage: 'submit', source: allowRateLimited ? 'probe' : 'task',
|
|
65
|
+
binding: modelAvailabilityBinding(provider, parsed.modelId, 'vision') };
|
|
66
|
+
if (!recordModelAvailability(provider, parsed.modelId, 'vision', event)) return;
|
|
67
|
+
try { onModelAvailability?.({ providerId: provider.id, modelId: parsed.modelId, capability: 'vision', ...event }); }
|
|
68
|
+
catch (_) { console.warn('[iris] 模型可用性未能持久化,请检查配置写入权限'); }
|
|
69
|
+
};
|
|
62
70
|
return Object.freeze({ describe: port.describe,
|
|
63
71
|
async complete(request, options) {
|
|
64
|
-
if (!allowRateLimited && modelRateLimit(provider, parsed.modelId)) {
|
|
72
|
+
if (!allowRateLimited && (modelRateLimit(provider, parsed.modelId) || modelUnavailable(provider, parsed.modelId, 'vision'))) {
|
|
65
73
|
throw new ModelPortError('IRIS_MODEL_UNAVAILABLE', { stage: 'prepare', invocation: 'not_invoked' });
|
|
66
74
|
}
|
|
67
|
-
try {
|
|
75
|
+
try {
|
|
76
|
+
const result = await port.complete(request, options);
|
|
77
|
+
availability({ ok: true });
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
68
80
|
catch (error) {
|
|
81
|
+
availability({ ok: false, httpStatus: error.status, providerCode: error.providerCode });
|
|
69
82
|
reportModelRateLimit(provider, parsed.modelId, { status: error.status, code: error.code,
|
|
70
83
|
providerCode: error.providerCode, retryAfterMs: error.retryAfterMs,
|
|
71
84
|
...(error.quotaExhausted ? { reason: 'free_quota' } : {}),
|
|
@@ -76,7 +89,7 @@ export function createConfiguredVisionModelPort(provider, model, { allowRateLimi
|
|
|
76
89
|
});
|
|
77
90
|
}
|
|
78
91
|
|
|
79
|
-
export function buildVisionModelCandidates(host, { providers = [], model, allowRateLimited = false, onRateLimit, prepareImages = false,
|
|
92
|
+
export function buildVisionModelCandidates(host, { providers = [], model, allowRateLimited = false, onRateLimit, onModelAvailability, prepareImages = false,
|
|
80
93
|
orientImages = false, prepareImageRequest } = {}) {
|
|
81
94
|
const candidates = [], seen = new Set();
|
|
82
95
|
let selected = providers.filter(Boolean);
|
|
@@ -100,7 +113,7 @@ export function buildVisionModelCandidates(host, { providers = [], model, allowR
|
|
|
100
113
|
// 显式模型会把同账号的多份池记录解析为同一复合引用,只保留首次候选。
|
|
101
114
|
if (parsed && seen.has(ref)) continue;
|
|
102
115
|
if (parsed) seen.add(ref);
|
|
103
|
-
const port = createConfiguredVisionModelPort(provider, provider.visionModel, { allowRateLimited, onRateLimit, prepareImages, orientImages, prepareImageRequest });
|
|
116
|
+
const port = createConfiguredVisionModelPort(provider, provider.visionModel, { allowRateLimited, onRateLimit, onModelAvailability, prepareImages, orientImages, prepareImageRequest });
|
|
104
117
|
const id = port.describe().identity.backendId;
|
|
105
118
|
candidates.push({ port, id, via: 'selfstack', backendId: provider.id, model: parsed?.modelId || '' });
|
|
106
119
|
}
|
|
@@ -148,10 +161,10 @@ function projectError(record, candidates) {
|
|
|
148
161
|
}
|
|
149
162
|
|
|
150
163
|
/** prepareImage 的信号覆盖文件/会话读取;函数开始时即建立整体预算。 */
|
|
151
|
-
export async function runVisionOperation(host, { providers, model, signal, budget = VISION_BUDGET, onRateLimit, prepareImages = false,
|
|
164
|
+
export async function runVisionOperation(host, { providers, model, signal, budget = VISION_BUDGET, onRateLimit, onModelAvailability, prepareImages = false,
|
|
152
165
|
orientImages = false, prepareImageRequest }, consume) {
|
|
153
166
|
if (signal?.aborted) throw new ModelPortError('IRIS_MODEL_ABORTED');
|
|
154
|
-
const candidates = buildVisionModelCandidates(host, { providers, model, onRateLimit, prepareImages, orientImages, prepareImageRequest });
|
|
167
|
+
const candidates = buildVisionModelCandidates(host, { providers, model, onRateLimit, onModelAvailability, prepareImages, orientImages, prepareImageRequest });
|
|
155
168
|
const operation = createModelOperation({ signal, budget: { timeoutMs: budget.timeoutMs, maxInvocations: candidates.length } });
|
|
156
169
|
try {
|
|
157
170
|
const { completion: result, ...details } = await consume(candidates.map(candidate => candidate.port), operation);
|
|
@@ -168,8 +181,8 @@ export async function runVisionOperation(host, { providers, model, signal, budge
|
|
|
168
181
|
}
|
|
169
182
|
|
|
170
183
|
/** 单图与复合业务共用同一候选、整体预算和结果投影。 */
|
|
171
|
-
export async function runVisionRequest(host, { providers, model, question, image, dataUrl, prepareImage, signal, budget = VISION_BUDGET, onRateLimit }) {
|
|
172
|
-
return runVisionOperation(host, { providers, model, signal, budget, onRateLimit, prepareImages: true }, async (ports, operation) => {
|
|
184
|
+
export async function runVisionRequest(host, { providers, model, question, image, dataUrl, prepareImage, signal, budget = VISION_BUDGET, onRateLimit, onModelAvailability }) {
|
|
185
|
+
return runVisionOperation(host, { providers, model, signal, budget, onRateLimit, onModelAvailability, prepareImages: true }, async (ports, operation) => {
|
|
173
186
|
normalizeModelCall(ports[0].describe(), { prompt: question,
|
|
174
187
|
image: { bytes: new Uint8Array([0]), mediaType: 'image/png' } }, { budget });
|
|
175
188
|
const resolved = await waitForModelWork(() => prepareImage ? prepareImage(operation.signal)
|
|
@@ -179,9 +192,9 @@ export async function runVisionRequest(host, { providers, model, question, image
|
|
|
179
192
|
}
|
|
180
193
|
|
|
181
194
|
/** 付费确认在动作入口;红图实测只有一个供应商,不退到 Host 冒充其能力。 */
|
|
182
|
-
export async function probeVisionModel(provider, model, image, { signal, timeoutMs = 15000 } = {}) {
|
|
195
|
+
export async function probeVisionModel(provider, model, image, { signal, timeoutMs = 15000, onModelAvailability } = {}) {
|
|
183
196
|
try {
|
|
184
|
-
const port = createConfiguredVisionModelPort(provider, model, { allowRateLimited: true });
|
|
197
|
+
const port = createConfiguredVisionModelPort(provider, model, { allowRateLimited: true, onModelAvailability });
|
|
185
198
|
const result = await completeVision([port], { prompt: '这张图片是什么颜色?请只回答颜色名称(中文)。', image },
|
|
186
199
|
{ signal, budget: { ...VISION_BUDGET, timeoutMs } });
|
|
187
200
|
return { ok: /红|red/i.test(result.text), answer: result.text };
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
/** Host 作品目录:两种既有存储保持原样,在展示边界统一过滤、排序与分页。 */
|
|
3
3
|
import * as legacy from './artifacts.js';
|
|
4
|
-
import { galleryArtifactsForDsh } from './dsh-core-adapter.js';
|
|
5
4
|
import { mimeOf } from './media.js';
|
|
6
5
|
|
|
7
6
|
export function normalizeGalleryQuery(input = {}) {
|
|
@@ -22,7 +21,7 @@ export async function workbenchArtifacts(input, options = {}) {
|
|
|
22
21
|
let droppedCore = 0, coreError = '';
|
|
23
22
|
if (query.source !== 'legacy') {
|
|
24
23
|
try {
|
|
25
|
-
const core = await galleryArtifactsForDsh(options);
|
|
24
|
+
const core = await options.core.galleryArtifactsForDsh(options);
|
|
26
25
|
droppedCore = core.dropped;
|
|
27
26
|
for (const artifact of core.artifacts) {
|
|
28
27
|
if (artifact.kind === 'host-input') continue;
|
|
@@ -5,7 +5,6 @@ import crypto from 'node:crypto';
|
|
|
5
5
|
import { setImmediate } from 'node:timers/promises';
|
|
6
6
|
import * as legacy from './artifacts.js';
|
|
7
7
|
import { mimeOf } from './media.js';
|
|
8
|
-
import { inspectWorkbenchArtifactForDsh, inspectProviderTaskForDsh, maintainWorkbenchForDsh, readCoreArtifactMediaForDsh } from './dsh-core-adapter.js';
|
|
9
8
|
import { workbenchZip } from './workbench-zip.js';
|
|
10
9
|
|
|
11
10
|
export const MAX_WORKBENCH_SELECTION = 200;
|
|
@@ -56,7 +55,7 @@ function safeMetadata(metadata) {
|
|
|
56
55
|
export async function workbenchDetail(source, id, options = {}) {
|
|
57
56
|
validItem({ source, id }); active(options.signal);
|
|
58
57
|
if (source === 'core') {
|
|
59
|
-
const artifact = await inspectWorkbenchArtifactForDsh(id, options);
|
|
58
|
+
const artifact = await options.core.inspectWorkbenchArtifactForDsh(id, options);
|
|
60
59
|
return { item: { id, source, file: id + EXTENSIONS[artifact.mediaType], mime: artifact.mediaType,
|
|
61
60
|
kind: artifact.kind, size: artifact.size, createdAt: artifact.createdAt, digest: artifact.digest.value,
|
|
62
61
|
relations: artifact.relations, metadata: safeMetadata(artifact.metadata), integrity: 'verified',
|
|
@@ -82,7 +81,7 @@ export async function downloadWorkbenchSelection(input, options = {}) {
|
|
|
82
81
|
bytes = 0;
|
|
83
82
|
for (const detail of details) {
|
|
84
83
|
active(options.signal);
|
|
85
|
-
const data = detail.source === 'core' ? (await readCoreArtifactMediaForDsh(detail.id, options)).bytes : fs.readFileSync(legacyFile(detail.id).abs);
|
|
84
|
+
const data = detail.source === 'core' ? (await options.core.readCoreArtifactMediaForDsh(detail.id, options)).bytes : fs.readFileSync(legacyFile(detail.id).abs);
|
|
86
85
|
const digest = crypto.createHash('sha256').update(data).digest('hex');
|
|
87
86
|
if (detail.source === 'core' && digest !== detail.digest) fail('IRIS_ARTIFACT_DIGEST_MISMATCH', '选中作品内容已改变', 409);
|
|
88
87
|
bytes += data.length;
|
|
@@ -106,15 +105,15 @@ export async function deleteWorkbenchArtifacts(input, options = {}) {
|
|
|
106
105
|
|| new Set(taskIds).size !== taskIds.length) fail('IRIS_COMMAND_INPUT_INVALID', '关联任务 ID 无效');
|
|
107
106
|
active(options.signal);
|
|
108
107
|
const artifactIds = chosen.map(item => item.id);
|
|
109
|
-
const base = await maintainWorkbenchForDsh('core.delete', { artifact_ids: artifactIds }, options);
|
|
108
|
+
const base = await options.core.maintainWorkbenchForDsh('core.delete', { artifact_ids: artifactIds }, options);
|
|
110
109
|
const referencing = [...new Set(base.blockers.filter(blocker => ['task_artifact_reference', 'task_input_reference'].includes(blocker.reason)).map(blocker => blocker.referencedBy))];
|
|
111
110
|
if (taskIds.some(id => !referencing.includes(id))) fail('IRIS_COMMAND_INPUT_INVALID', '只能显式选择这些作品直接关联的任务');
|
|
112
|
-
if (input.confirm_delete === true) return { ok: true, ...await maintainWorkbenchForDsh('core.delete', {
|
|
111
|
+
if (input.confirm_delete === true) return { ok: true, ...await options.core.maintainWorkbenchForDsh('core.delete', {
|
|
113
112
|
artifact_ids: artifactIds, task_ids: taskIds, confirm_delete: true }, options) };
|
|
114
|
-
const plan = taskIds.length ? await maintainWorkbenchForDsh('core.delete', { artifact_ids: artifactIds, task_ids: taskIds }, options) : base;
|
|
113
|
+
const plan = taskIds.length ? await options.core.maintainWorkbenchForDsh('core.delete', { artifact_ids: artifactIds, task_ids: taskIds }, options) : base;
|
|
115
114
|
const tasks = [];
|
|
116
115
|
for (const id of referencing) {
|
|
117
|
-
const task = await inspectProviderTaskForDsh(id, options);
|
|
116
|
+
const task = await options.core.inspectProviderTaskForDsh(id, options);
|
|
118
117
|
tasks.push({ id, settled: task.phase === 'terminal' && task.acceptance !== 'unknown' && task.outcome !== 'unknown' });
|
|
119
118
|
}
|
|
120
119
|
return { ok: true, preview: true, allowed: plan.allowed, artifactIds, taskIds, files: plan.entries.length,
|
|
@@ -122,7 +121,7 @@ export async function deleteWorkbenchArtifacts(input, options = {}) {
|
|
|
122
121
|
}
|
|
123
122
|
|
|
124
123
|
export async function workbenchTransactions(options = {}) {
|
|
125
|
-
return { ok: true, ...await maintainWorkbenchForDsh('core.transactions', {}, options) };
|
|
124
|
+
return { ok: true, ...await options.core.maintainWorkbenchForDsh('core.transactions', {}, options) };
|
|
126
125
|
}
|
|
127
126
|
|
|
128
127
|
export async function restoreWorkbenchTransaction(input, options = {}) {
|
|
@@ -130,7 +129,7 @@ export async function restoreWorkbenchTransaction(input, options = {}) {
|
|
|
130
129
|
if (input.confirm_restore !== true) fail('IRIS_WORK_RESTORE_CONFIRM_REQUIRED', '请明确确认恢复此隔离事务');
|
|
131
130
|
if (typeof input.transaction_id !== 'string' || !/^delete_[a-f0-9]{24}$/.test(input.transaction_id)) fail('IRIS_COMMAND_INPUT_INVALID', '隔离事务 ID 无效');
|
|
132
131
|
active(options.signal);
|
|
133
|
-
return { ok: true, ...await maintainWorkbenchForDsh('core.restore', { transaction_id: input.transaction_id }, options) };
|
|
132
|
+
return { ok: true, ...await options.core.maintainWorkbenchForDsh('core.restore', { transaction_id: input.transaction_id }, options) };
|
|
134
133
|
}
|
|
135
134
|
|
|
136
135
|
/** API 边界只返回固定提示和稳定码,避免底层 ENOENT/JSON 错误带出文件路径。 */
|