@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
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/** DSH/Cordis runtime objects are normalized here and nowhere in Iris commands. */
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { defineHostAdapter } from './host-contract.js';
|
|
7
|
+
import { BrowserHtmlRenderer } from './render.js';
|
|
8
|
+
|
|
9
|
+
export function detectDshVersion({ entry = process.argv[1] } = {}) {
|
|
10
|
+
if (!entry) return 'unknown';
|
|
11
|
+
let resolved = path.resolve(String(entry));
|
|
12
|
+
try { resolved = fs.realpathSync(resolved); } catch (_) { /* 入口可能尚未物化 */ }
|
|
13
|
+
let current = path.dirname(resolved);
|
|
14
|
+
for (let depth = 0; depth < 6; depth++) {
|
|
15
|
+
try {
|
|
16
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(current, 'package.json'), 'utf8'));
|
|
17
|
+
if (manifest.name === '@deepseek-ai/dsh') return String(manifest.version || 'unknown');
|
|
18
|
+
} catch (_) { /* 当前层不是 DSH 包根目录 */ }
|
|
19
|
+
const parent = path.dirname(current);
|
|
20
|
+
if (parent === current) break;
|
|
21
|
+
current = parent;
|
|
22
|
+
}
|
|
23
|
+
return 'unknown';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function property(ctx, name) {
|
|
27
|
+
try { return ctx && ctx[name]; } catch (_) { return undefined; }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function service(ctx, name) {
|
|
31
|
+
if (ctx && typeof ctx.get === 'function') {
|
|
32
|
+
try {
|
|
33
|
+
const value = ctx.get(name);
|
|
34
|
+
if (value) return value;
|
|
35
|
+
} catch (_) { /* optional DSH service was not injected */ }
|
|
36
|
+
}
|
|
37
|
+
return property(ctx, name);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function unavailableFor(value, reason) {
|
|
41
|
+
return { kind: value ? 'incompatible' : 'unavailable', reason };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function imageAttachmentDto(ref) {
|
|
45
|
+
const out = {
|
|
46
|
+
attachmentId: ref.attachmentId,
|
|
47
|
+
mediaType: ref.mediaType,
|
|
48
|
+
name: typeof ref.name === 'string' ? ref.name : ''
|
|
49
|
+
};
|
|
50
|
+
// readImage 会核对这三项完整性事实;省略未知值,不从 ID/文件名猜测。
|
|
51
|
+
for (const key of ['bytes', 'width', 'height']) {
|
|
52
|
+
if (Number.isSafeInteger(ref[key]) && ref[key] > 0) out[key] = ref[key];
|
|
53
|
+
}
|
|
54
|
+
const original = ref.originalDimensions;
|
|
55
|
+
if (original && Number.isSafeInteger(original.width) && original.width > 0
|
|
56
|
+
&& Number.isSafeInteger(original.height) && original.height > 0) {
|
|
57
|
+
out.originalDimensions = Object.freeze({ width: original.width, height: original.height });
|
|
58
|
+
}
|
|
59
|
+
return Object.freeze(out);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function collectImageAttachments(nodes) {
|
|
63
|
+
const out = [];
|
|
64
|
+
const ids = new Set();
|
|
65
|
+
const seen = new Set();
|
|
66
|
+
const stack = Array.isArray(nodes) ? [...nodes] : [nodes];
|
|
67
|
+
let scanned = 0;
|
|
68
|
+
while (stack.length && scanned < 8000) {
|
|
69
|
+
const node = stack.pop();
|
|
70
|
+
scanned++;
|
|
71
|
+
if (!node || typeof node !== 'object' || seen.has(node)) continue;
|
|
72
|
+
seen.add(node);
|
|
73
|
+
const ref = node.attachment && typeof node.attachment === 'object'
|
|
74
|
+
? node.attachment
|
|
75
|
+
: (node.attachmentId && node.mediaType ? node : null);
|
|
76
|
+
if (ref && typeof ref.attachmentId === 'string'
|
|
77
|
+
&& typeof ref.mediaType === 'string' && ref.mediaType.startsWith('image/')
|
|
78
|
+
&& !ids.has(ref.attachmentId)) {
|
|
79
|
+
ids.add(ref.attachmentId);
|
|
80
|
+
out.push(imageAttachmentDto(ref));
|
|
81
|
+
}
|
|
82
|
+
for (const value of Object.values(node)) if (value && typeof value === 'object') stack.push(value);
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function registerWithEffect(ctx, registry, definition) {
|
|
88
|
+
if (typeof ctx.effect === 'function') return ctx.effect(() => registry.register(definition));
|
|
89
|
+
return registry.register(definition);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function visionRouteError(message) {
|
|
93
|
+
return Object.assign(new Error(message), { code: 'IRIS_HOST_CAPABILITY_INCOMPATIBLE' });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function dshVisionPort(llm, { defaults, requiresRoute }) {
|
|
97
|
+
return Object.freeze({
|
|
98
|
+
id: 'dsh-global',
|
|
99
|
+
model: '全局视觉模型',
|
|
100
|
+
async analyze({ question, ref, signal }) {
|
|
101
|
+
let route = {};
|
|
102
|
+
if (requiresRoute) {
|
|
103
|
+
// rc.2 路由检查前就停止取消请求;旧桥接继续由宿主处理 signal,
|
|
104
|
+
// 其 fallback 语义待共享调用控制迁移时统一。
|
|
105
|
+
if (signal?.aborted) throw signal.reason instanceof Error ? signal.reason : new Error('已取消');
|
|
106
|
+
const selection = defaults.currentSelection();
|
|
107
|
+
if (typeof selection?.provider !== 'string' || !selection.provider.trim()
|
|
108
|
+
|| typeof selection?.model !== 'string' || !selection.model.trim()) {
|
|
109
|
+
throw visionRouteError('DSH 默认模型没有明确路由;请在 Iris 配置视觉模型');
|
|
110
|
+
}
|
|
111
|
+
// 默认文本选择只有明确声明 image 输入时才可成为视觉候选。
|
|
112
|
+
const { provider, model } = selection;
|
|
113
|
+
const info = await llm.resolveModelInfo(provider, model, signal);
|
|
114
|
+
if (signal?.aborted) throw signal.reason instanceof Error ? signal.reason : new Error('已取消');
|
|
115
|
+
if (info?.provider !== provider || info?.id !== model || !Array.isArray(info.inputModalities)
|
|
116
|
+
|| !info.inputModalities.includes('image')) {
|
|
117
|
+
throw visionRouteError('DSH 默认模型未确认支持图片;请在 Iris 配置视觉模型或选择已声明图片输入的宿主模型');
|
|
118
|
+
}
|
|
119
|
+
route = { provider, model };
|
|
120
|
+
}
|
|
121
|
+
const chunks = [];
|
|
122
|
+
for await (const chunk of llm.stream({
|
|
123
|
+
sessionId: undefined,
|
|
124
|
+
provider: undefined,
|
|
125
|
+
model: undefined,
|
|
126
|
+
...route,
|
|
127
|
+
signal,
|
|
128
|
+
messages: [{
|
|
129
|
+
role: 'user',
|
|
130
|
+
content: [
|
|
131
|
+
{ type: 'text', text: question },
|
|
132
|
+
{ type: 'image', attachment: ref }
|
|
133
|
+
]
|
|
134
|
+
}]
|
|
135
|
+
})) {
|
|
136
|
+
if (chunk?.type === 'error') throw visionRouteError('DSH 视觉调用失败;请检查宿主模型状态');
|
|
137
|
+
// rc.2 的 Runtime 把 Adapter 异常转换为 finish,而不是单独的 error 事件。
|
|
138
|
+
if (requiresRoute && chunk?.type === 'finish') {
|
|
139
|
+
if (chunk.reason?.kind === 'error') throw visionRouteError('DSH 视觉调用失败;请检查宿主模型状态');
|
|
140
|
+
if (chunk.reason?.kind === 'aborted') throw visionRouteError('DSH 视觉调用已取消');
|
|
141
|
+
}
|
|
142
|
+
const text = typeof chunk?.delta === 'string'
|
|
143
|
+
? chunk.delta
|
|
144
|
+
: (chunk?.type === 'text-delta' ? String(chunk.text || '') : '');
|
|
145
|
+
if (text) chunks.push(text);
|
|
146
|
+
if (chunks.reduce((sum, item) => sum + item.length, 0) > 6000) break;
|
|
147
|
+
}
|
|
148
|
+
return chunks.join('').trim();
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Build a fresh capability snapshot so optional DSH services can arrive after plugin apply(). */
|
|
154
|
+
export function createDshHostAdapter(ctx, { version = detectDshVersion() } = {}) {
|
|
155
|
+
if (!ctx || typeof ctx !== 'object') throw new TypeError('DSH Host Adapter 需要宿主 ctx');
|
|
156
|
+
const ports = {};
|
|
157
|
+
const unavailable = {};
|
|
158
|
+
|
|
159
|
+
const attachments = service(ctx, 'attachments');
|
|
160
|
+
if (attachments && typeof attachments.saveImage === 'function' && typeof attachments.readImage === 'function') {
|
|
161
|
+
ports.attachments = Object.freeze({
|
|
162
|
+
saveImage: (input) => attachments.saveImage(input),
|
|
163
|
+
readImage: (ref, signal) => attachments.readImage(ref, signal)
|
|
164
|
+
});
|
|
165
|
+
} else unavailable.attachments = unavailableFor(attachments, 'DSH attachments 需要 saveImage/readImage');
|
|
166
|
+
|
|
167
|
+
const browser = service(ctx, 'browser');
|
|
168
|
+
if (browser && ['open', 'openUrl', 'screenshot', 'close'].every((name) => typeof browser[name] === 'function')) {
|
|
169
|
+
const renderer = new BrowserHtmlRenderer({ browser });
|
|
170
|
+
ports.browser = Object.freeze({
|
|
171
|
+
async renderHtml(input) {
|
|
172
|
+
const { png } = await renderer.render(input);
|
|
173
|
+
return { bytes: new Uint8Array(png), mediaType: 'image/png' };
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
} else unavailable.browser = unavailableFor(browser, 'DSH browser 需要 open/openUrl/screenshot/close');
|
|
177
|
+
|
|
178
|
+
const slots = service(ctx, 'slots');
|
|
179
|
+
if (slots && typeof slots.inject === 'function' && typeof slots.register === 'function') {
|
|
180
|
+
ports.clientSlots = Object.freeze({
|
|
181
|
+
inject: (seat, callback) => slots.inject(seat, callback),
|
|
182
|
+
register: (definition, component) => slots.register(definition, component)
|
|
183
|
+
});
|
|
184
|
+
} else unavailable.clientSlots = unavailableFor(slots, 'DSH client slots 需要 inject/register');
|
|
185
|
+
|
|
186
|
+
const routes = service(ctx, 'webServer') || service(ctx, 'httpServer');
|
|
187
|
+
if (routes && typeof routes.register === 'function') {
|
|
188
|
+
ports.routes = Object.freeze({ register: (definition) => routes.register(definition) });
|
|
189
|
+
} else unavailable.routes = unavailableFor(routes, 'DSH webServer/httpServer 需要 register');
|
|
190
|
+
|
|
191
|
+
const sessions = service(ctx, 'sessionQuery');
|
|
192
|
+
if (sessions && typeof sessions.readSession === 'function') {
|
|
193
|
+
ports.sessions = Object.freeze({
|
|
194
|
+
readSession: (id) => sessions.readSession(id),
|
|
195
|
+
async listImageAttachments(id) {
|
|
196
|
+
const record = await sessions.readSession(id);
|
|
197
|
+
return collectImageAttachments(record && record.events);
|
|
198
|
+
},
|
|
199
|
+
async findImageAttachment(id, attachmentId) {
|
|
200
|
+
return (await this.listImageAttachments(id)).find((item) => item.attachmentId === attachmentId) || null;
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
} else unavailable.sessions = unavailableFor(sessions, 'DSH sessionQuery 需要 readSession');
|
|
204
|
+
|
|
205
|
+
const skills = service(ctx, 'skills');
|
|
206
|
+
if (skills && typeof skills.register === 'function') {
|
|
207
|
+
ports.skills = Object.freeze({ register: (definition) => registerWithEffect(ctx, skills, definition) });
|
|
208
|
+
} else unavailable.skills = unavailableFor(skills, 'DSH Skill registry 需要 register');
|
|
209
|
+
|
|
210
|
+
const llm = service(ctx, 'llm');
|
|
211
|
+
const defaults = service(ctx, 'agentDefaultModel');
|
|
212
|
+
if (llm && typeof llm.stream === 'function') {
|
|
213
|
+
ports.textModel = Object.freeze({
|
|
214
|
+
stream: (request) => llm.stream(request),
|
|
215
|
+
resolveModelInfo: typeof llm.resolveModelInfo === 'function'
|
|
216
|
+
? (provider, model, signal) => llm.resolveModelInfo(provider, model, signal)
|
|
217
|
+
: undefined,
|
|
218
|
+
currentSelection: defaults && typeof defaults.currentSelection === 'function'
|
|
219
|
+
? () => defaults.currentSelection()
|
|
220
|
+
: () => null
|
|
221
|
+
});
|
|
222
|
+
const requiresRoute = /^0\.2\./.test(version);
|
|
223
|
+
if (!requiresRoute || defaults && typeof defaults.currentSelection === 'function'
|
|
224
|
+
&& typeof llm.resolveModelInfo === 'function') {
|
|
225
|
+
ports.visionModel = dshVisionPort(llm, { defaults, requiresRoute });
|
|
226
|
+
} else unavailable.visionModel = {
|
|
227
|
+
kind: 'unavailable', reason: 'DSH 0.2 视觉桥接需要默认模型选择和精确图片能力元数据'
|
|
228
|
+
};
|
|
229
|
+
} else {
|
|
230
|
+
unavailable.textModel = unavailableFor(llm, 'DSH llm 需要 stream');
|
|
231
|
+
unavailable.visionModel = unavailableFor(llm, 'DSH llm 需要 stream');
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const tools = service(ctx, 'tools');
|
|
235
|
+
if (tools && typeof tools.register === 'function') {
|
|
236
|
+
ports.tools = Object.freeze({ register: (definition) => registerWithEffect(ctx, tools, definition) });
|
|
237
|
+
} else unavailable.tools = unavailableFor(tools, 'DSH tools 需要 register');
|
|
238
|
+
|
|
239
|
+
return defineHostAdapter({ id: 'deepseek-harness', version, ports, unavailable });
|
|
240
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
const FIELDS = Object.freeze({
|
|
6
|
+
image: ['prompt', 'size', 'n'],
|
|
7
|
+
video: ['prompt', 'size', 'duration', 'img_data_url'],
|
|
8
|
+
tts: ['text', 'voice'],
|
|
9
|
+
transcribe: ['audio_url', 'audio_path']
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export class GenerationInputError extends Error {
|
|
13
|
+
constructor(message) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = 'GenerationInputError';
|
|
16
|
+
this.code = 'IRIS_GENERATION_INPUT_INVALID';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function fail(message) { throw new GenerationInputError(message); }
|
|
21
|
+
|
|
22
|
+
function text(value, field, maxLength, required = false) {
|
|
23
|
+
if (value === undefined && !required) return undefined;
|
|
24
|
+
const normalized = String(value || '').trim();
|
|
25
|
+
if (!normalized || normalized.length > maxLength) {
|
|
26
|
+
fail(`${field} 必须为 1–${maxLength} 字符`);
|
|
27
|
+
}
|
|
28
|
+
return normalized;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* CLI、DSH 与知情重试共用的纯输入边界。只校验/转换调用方重新提供的参数,
|
|
33
|
+
* 不读取配置或文件,不上传,不从旧 Task 恢复 Prompt/媒体地址。
|
|
34
|
+
* Host 附件解析和本地音频上传仍由入口负责。
|
|
35
|
+
*/
|
|
36
|
+
export function normalizeGenerationInput(capability, input, { allowAudioPath = false } = {}) {
|
|
37
|
+
const fields = Object.hasOwn(FIELDS, capability) ? FIELDS[capability] : null;
|
|
38
|
+
if (!fields) fail('不支持的生成能力');
|
|
39
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) fail('生成输入必须是 JSON 对象');
|
|
40
|
+
for (const field of Object.keys(input)) {
|
|
41
|
+
if (!fields.includes(field) && field !== 'model_ref') {
|
|
42
|
+
fail('生成输入不支持字段:' + field + ';允许 ' + fields.join(', ') + ', model_ref');
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const modelRef = String(input.model_ref || '').trim();
|
|
46
|
+
const providerInput = {};
|
|
47
|
+
let audioPath;
|
|
48
|
+
if (capability === 'image' || capability === 'video') {
|
|
49
|
+
providerInput.prompt = text(input.prompt, 'prompt', 20000, true);
|
|
50
|
+
const size = text(input.size, 'size', 64);
|
|
51
|
+
if (size !== undefined) providerInput.size = size;
|
|
52
|
+
}
|
|
53
|
+
if (capability === 'image') {
|
|
54
|
+
const n = input.n === undefined ? 1 : Number(input.n);
|
|
55
|
+
if (!Number.isSafeInteger(n) || n < 1 || n > 4) fail('n 必须为 1–4 的整数');
|
|
56
|
+
providerInput.n = n;
|
|
57
|
+
}
|
|
58
|
+
if (capability === 'video') {
|
|
59
|
+
if (input.duration !== undefined) {
|
|
60
|
+
const duration = Number(input.duration);
|
|
61
|
+
if (!Number.isFinite(duration) || duration < 1 || duration > 60) fail('duration 必须为 1–60 的数字');
|
|
62
|
+
providerInput.duration = duration;
|
|
63
|
+
}
|
|
64
|
+
if (input.img_data_url !== undefined) {
|
|
65
|
+
const imgDataUrl = String(input.img_data_url).trim();
|
|
66
|
+
if (!imgDataUrl.startsWith('data:image/')) fail('img_data_url 必须是 data:image/ 开头的 data URL');
|
|
67
|
+
providerInput.imgDataUrl = imgDataUrl;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (capability === 'tts') {
|
|
71
|
+
providerInput.text = text(input.text, 'text', 20000, true);
|
|
72
|
+
const voice = text(input.voice, 'voice', 64);
|
|
73
|
+
if (voice !== undefined) providerInput.voice = voice;
|
|
74
|
+
}
|
|
75
|
+
if (capability === 'transcribe') {
|
|
76
|
+
const audioUrl = String(input.audio_url || '').trim();
|
|
77
|
+
audioPath = String(input.audio_path || '').trim();
|
|
78
|
+
if (Boolean(audioUrl) === Boolean(audioPath)) {
|
|
79
|
+
fail('转写必须且只能提供 audio_url(公网/oss://)或 audio_path(本地绝对路径)之一');
|
|
80
|
+
}
|
|
81
|
+
if (audioUrl) {
|
|
82
|
+
if (audioUrl.length > 20000) fail('audio_url 最多为 20000 字符');
|
|
83
|
+
if (!/^(https:\/\/|oss:\/\/)/.test(audioUrl)) fail('audio_url 必须是 https:// 或 oss:// 地址');
|
|
84
|
+
providerInput.audioUrl = audioUrl;
|
|
85
|
+
} else {
|
|
86
|
+
if (!allowAudioPath) fail('retry 必须重新提供 audio_url;本地音频上传由调用入口负责');
|
|
87
|
+
if (!path.isAbsolute(audioPath)) fail('audio_path 必须是绝对路径');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return Object.freeze({
|
|
91
|
+
modelRef,
|
|
92
|
+
providerInput: Object.freeze(providerInput),
|
|
93
|
+
...(audioPath ? { audioPath } : {})
|
|
94
|
+
});
|
|
95
|
+
}
|
package/lib/guard.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* `/iris/*` 不经过 DSH 的 `/api` 信任栅栏,因此默认只接受回环 Host。
|
|
6
6
|
* LAN/反代场景需用 IRIS_TRUSTED_HOSTS 显式列出 host 或 host:port;这只是
|
|
7
|
-
* Host
|
|
7
|
+
* Host 边界,不是身份认证。浏览器请求另校验 Sec-Fetch-Site 与 Origin==Host。
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
/** 请求 Host 头 → host:port 规范化(小写,去 IPv6 方括号保留) */
|
|
@@ -44,12 +44,12 @@ export function isTrustedHost(hostHeader, configured = process.env.IRIS_TRUSTED_
|
|
|
44
44
|
*/
|
|
45
45
|
export function checkRequest(req, options = {}) {
|
|
46
46
|
const headers = req.headers || {};
|
|
47
|
-
const method = String(req.method || 'GET').toUpperCase();
|
|
48
47
|
if (!isTrustedHost(headers.host, options.trustedHosts)) {
|
|
49
48
|
return { ok: false, status: 403, error: 'untrusted host' };
|
|
50
49
|
}
|
|
51
|
-
if (method !== 'POST') return { ok: true };
|
|
52
50
|
|
|
51
|
+
// 浏览器的读请求同样可能泄露媒体与状态;明确的跨站/跨源请求一律拒绝。
|
|
52
|
+
// curl/CLI 等不发送 Origin/Sec-Fetch-Site 的非浏览器客户端仍可在受信 Host 上使用。
|
|
53
53
|
const sfs = String(headers['sec-fetch-site'] || '').toLowerCase();
|
|
54
54
|
if (sfs === 'cross-site') return { ok: false, status: 403, error: 'cross-site request denied' };
|
|
55
55
|
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Iris Host Adapter v0 的纯契约原语。
|
|
5
|
+
*
|
|
6
|
+
* 本模块不导入 DSH/Cordis,也不读取宿主服务。具体 Adapter 只能把宿主能力
|
|
7
|
+
* 映射到这些命名端口;Command 不得接收原始 ctx。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const HOST_CONTRACT_VERSION = 0;
|
|
11
|
+
|
|
12
|
+
export const HOST_PORTS = Object.freeze([
|
|
13
|
+
'attachments',
|
|
14
|
+
'browser',
|
|
15
|
+
'clientSlots',
|
|
16
|
+
'routes',
|
|
17
|
+
'sessions',
|
|
18
|
+
'skills',
|
|
19
|
+
'textModel',
|
|
20
|
+
'tools',
|
|
21
|
+
'visionModel'
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
const HOST_PORT_SET = new Set(HOST_PORTS);
|
|
25
|
+
const HOST_ID = /^[a-z0-9][a-z0-9._-]*$/;
|
|
26
|
+
const UNAVAILABLE_KINDS = new Set(['unavailable', 'incompatible']);
|
|
27
|
+
|
|
28
|
+
function isPlainObject(value) {
|
|
29
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function validatePortName(name) {
|
|
33
|
+
if (!HOST_PORT_SET.has(name)) throw new Error(`未知 Host Port:${name}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 建立只读 Host Adapter 描述。ports 中只放真实可调用的能力;缺失或版本不兼容
|
|
38
|
+
* 的能力放入 unavailable,供 Doctor 和用户错误复用同一份原因。
|
|
39
|
+
*/
|
|
40
|
+
export function defineHostAdapter(input) {
|
|
41
|
+
if (!isPlainObject(input)) throw new TypeError('Host Adapter 必须是对象');
|
|
42
|
+
const allowed = new Set(['contractVersion', 'id', 'version', 'ports', 'unavailable']);
|
|
43
|
+
for (const key of Object.keys(input)) {
|
|
44
|
+
if (!allowed.has(key)) throw new Error(`Host Adapter 不允许顶层字段:${key}`);
|
|
45
|
+
}
|
|
46
|
+
const contractVersion = input.contractVersion ?? HOST_CONTRACT_VERSION;
|
|
47
|
+
if (contractVersion !== HOST_CONTRACT_VERSION) {
|
|
48
|
+
throw new Error(`不支持的 Host Adapter 契约版本:${String(contractVersion)}`);
|
|
49
|
+
}
|
|
50
|
+
const id = String(input.id || '').trim();
|
|
51
|
+
if (!HOST_ID.test(id)) throw new Error('Host Adapter id 必须是小写稳定标识');
|
|
52
|
+
const version = String(input.version || 'unknown').trim() || 'unknown';
|
|
53
|
+
const supplied = input.ports ?? {};
|
|
54
|
+
const missing = input.unavailable ?? {};
|
|
55
|
+
if (!isPlainObject(supplied) || !isPlainObject(missing)) {
|
|
56
|
+
throw new TypeError('Host Adapter ports/unavailable 必须是对象');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const ports = {};
|
|
60
|
+
for (const [name, value] of Object.entries(supplied)) {
|
|
61
|
+
validatePortName(name);
|
|
62
|
+
if (!isPlainObject(value)) throw new TypeError(`Host Port ${name} 必须是方法对象`);
|
|
63
|
+
ports[name] = value;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const unavailable = {};
|
|
67
|
+
for (const [name, value] of Object.entries(missing)) {
|
|
68
|
+
validatePortName(name);
|
|
69
|
+
if (Object.prototype.hasOwnProperty.call(ports, name)) {
|
|
70
|
+
throw new Error(`Host Port ${name} 不能同时可用和不可用`);
|
|
71
|
+
}
|
|
72
|
+
if (!isPlainObject(value) || !UNAVAILABLE_KINDS.has(value.kind) || !String(value.reason || '').trim()) {
|
|
73
|
+
throw new TypeError(`Host Port ${name} 的不可用记录必须包含 kind 和 reason`);
|
|
74
|
+
}
|
|
75
|
+
unavailable[name] = Object.freeze({ kind: value.kind, reason: String(value.reason).trim() });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return Object.freeze({
|
|
79
|
+
contractVersion,
|
|
80
|
+
id,
|
|
81
|
+
version,
|
|
82
|
+
ports: Object.freeze(ports),
|
|
83
|
+
unavailable: Object.freeze(unavailable)
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function hasHostPort(adapter, name) {
|
|
88
|
+
validatePortName(name);
|
|
89
|
+
return Boolean(adapter && adapter.ports && Object.prototype.hasOwnProperty.call(adapter.ports, name));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export class HostCapabilityError extends Error {
|
|
93
|
+
constructor(adapter, name, operation) {
|
|
94
|
+
validatePortName(name);
|
|
95
|
+
const detail = adapter && adapter.unavailable && adapter.unavailable[name];
|
|
96
|
+
const host = adapter && adapter.id ? adapter.id : 'unknown-host';
|
|
97
|
+
const action = String(operation || '当前操作');
|
|
98
|
+
const suffix = detail && detail.reason ? `:${detail.reason}` : '';
|
|
99
|
+
super(`Iris ${action}需要 Host Port ${name},但 ${host} 未提供${suffix}`);
|
|
100
|
+
this.name = 'HostCapabilityError';
|
|
101
|
+
this.code = detail && detail.kind === 'incompatible'
|
|
102
|
+
? 'IRIS_HOST_CAPABILITY_INCOMPATIBLE'
|
|
103
|
+
: 'IRIS_HOST_CAPABILITY_UNAVAILABLE';
|
|
104
|
+
this.hostId = host;
|
|
105
|
+
this.capability = name;
|
|
106
|
+
this.operation = action;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function requireHostPort(adapter, name, operation) {
|
|
111
|
+
if (!hasHostPort(adapter, name)) throw new HostCapabilityError(adapter, name, operation);
|
|
112
|
+
return adapter.ports[name];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 只返回可序列化能力事实;不得把宿主 live object 暴露给 Doctor/API。 */
|
|
116
|
+
export function hostCapabilitySnapshot(adapter) {
|
|
117
|
+
const capabilities = {};
|
|
118
|
+
for (const name of HOST_PORTS) {
|
|
119
|
+
const available = hasHostPort(adapter, name);
|
|
120
|
+
const detail = !available && adapter && adapter.unavailable ? adapter.unavailable[name] : undefined;
|
|
121
|
+
capabilities[name] = available
|
|
122
|
+
? Object.freeze({ status: 'available' })
|
|
123
|
+
: Object.freeze({ status: detail && detail.kind === 'incompatible' ? 'incompatible' : 'unavailable',
|
|
124
|
+
...(detail && detail.reason ? { reason: detail.reason } : {}) });
|
|
125
|
+
}
|
|
126
|
+
return Object.freeze({
|
|
127
|
+
contractVersion: HOST_CONTRACT_VERSION,
|
|
128
|
+
host: Object.freeze({ id: adapter && adapter.id || 'unknown-host', version: adapter && adapter.version || 'unknown' }),
|
|
129
|
+
capabilities: Object.freeze(capabilities)
|
|
130
|
+
});
|
|
131
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Host Doctor 的运行时证据账本。
|
|
5
|
+
*
|
|
6
|
+
* 只记录 Iris 自己完成的注册事实和浏览器握手标量;不保存 ctx、service、
|
|
7
|
+
* API Key、会话、附件或其他 live object。
|
|
8
|
+
*/
|
|
9
|
+
export const IRIS_PLUGIN_ID = '@mokuyoaxis/dsh-iris';
|
|
10
|
+
export const HOST_CLIENT_PROTOCOL_VERSION = 0;
|
|
11
|
+
|
|
12
|
+
export const EXPECTED_IRIS_TOOLS = Object.freeze([
|
|
13
|
+
'iris_draw_image',
|
|
14
|
+
'iris_generate_video',
|
|
15
|
+
'iris_speak_text',
|
|
16
|
+
'iris_transcribe_audio',
|
|
17
|
+
'iris_task_status',
|
|
18
|
+
'iris_look_at_image',
|
|
19
|
+
'iris_relook_attachment',
|
|
20
|
+
'iris_crop',
|
|
21
|
+
'iris_pixel_diff',
|
|
22
|
+
'iris_locate',
|
|
23
|
+
'iris_html_screenshot',
|
|
24
|
+
'iris_long_ocr',
|
|
25
|
+
'iris_video_frames',
|
|
26
|
+
'iris_media_summarize'
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
export const EXPECTED_IRIS_SKILLS = Object.freeze([
|
|
30
|
+
'iris-verify-ui',
|
|
31
|
+
'iris-compose-media'
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
export const EXPECTED_IRIS_ROUTES = Object.freeze([
|
|
35
|
+
'/iris/media',
|
|
36
|
+
'/iris/api',
|
|
37
|
+
'/iris/api/actions',
|
|
38
|
+
'/iris/render'
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
export const EXPECTED_IRIS_CLIENT_SEATS = Object.freeze([
|
|
42
|
+
'settings.section',
|
|
43
|
+
'conversation.input.right',
|
|
44
|
+
'conversation.input.dock',
|
|
45
|
+
'shell.overlay'
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
function fresh() {
|
|
49
|
+
return {
|
|
50
|
+
server: {
|
|
51
|
+
loaded: false,
|
|
52
|
+
pluginId: IRIS_PLUGIN_ID,
|
|
53
|
+
version: 'unknown',
|
|
54
|
+
startedAt: ''
|
|
55
|
+
},
|
|
56
|
+
tools: new Set(),
|
|
57
|
+
skills: new Set(),
|
|
58
|
+
routes: new Set(),
|
|
59
|
+
client: null
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let runtime = fresh();
|
|
64
|
+
|
|
65
|
+
function safeText(value, max = 160) {
|
|
66
|
+
return String(value || '').trim().slice(0, max);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function record(set, values) {
|
|
70
|
+
for (const value of Array.isArray(values) ? values : [values]) {
|
|
71
|
+
const text = safeText(value);
|
|
72
|
+
if (text && set.size < 100) set.add(text);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function beginHostRuntime({ pluginId = IRIS_PLUGIN_ID, version = 'unknown' } = {}) {
|
|
77
|
+
runtime = fresh();
|
|
78
|
+
runtime.server = {
|
|
79
|
+
loaded: true,
|
|
80
|
+
pluginId: safeText(pluginId) || IRIS_PLUGIN_ID,
|
|
81
|
+
version: safeText(version) || 'unknown',
|
|
82
|
+
startedAt: new Date().toISOString()
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function recordHostTool(name) {
|
|
87
|
+
record(runtime.tools, name);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function recordHostSkills(names) {
|
|
91
|
+
record(runtime.skills, names);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function recordHostRoutes(paths) {
|
|
95
|
+
record(runtime.routes, paths);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 接受同源客户端报告。严格限制字段和枚举;报告只是诊断证据,不参与认证。
|
|
100
|
+
*/
|
|
101
|
+
export function recordHostClient(input) {
|
|
102
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
|
103
|
+
throw new TypeError('Host client report 必须是对象');
|
|
104
|
+
}
|
|
105
|
+
const allowed = new Set(['pluginId', 'version', 'protocolVersion', 'seats']);
|
|
106
|
+
for (const key of Object.keys(input)) {
|
|
107
|
+
if (!allowed.has(key)) throw new TypeError('Host client report 不允许字段:' + key);
|
|
108
|
+
}
|
|
109
|
+
const pluginId = safeText(input.pluginId);
|
|
110
|
+
const version = safeText(input.version);
|
|
111
|
+
const protocolVersion = Number(input.protocolVersion);
|
|
112
|
+
if (pluginId !== IRIS_PLUGIN_ID) throw new TypeError('Host client report 插件身份不匹配');
|
|
113
|
+
if (!version) throw new TypeError('Host client report 缺少版本');
|
|
114
|
+
if (!Number.isSafeInteger(protocolVersion) || protocolVersion < 0) {
|
|
115
|
+
throw new TypeError('Host client report protocolVersion 无效');
|
|
116
|
+
}
|
|
117
|
+
if (!Array.isArray(input.seats) || input.seats.length > 20) {
|
|
118
|
+
throw new TypeError('Host client report seats 无效');
|
|
119
|
+
}
|
|
120
|
+
const incomingSeats = [...new Set(input.seats.map((value) => safeText(value)).filter(Boolean))];
|
|
121
|
+
const invalidSeat = incomingSeats.find((seat) => !EXPECTED_IRIS_CLIENT_SEATS.includes(seat));
|
|
122
|
+
if (invalidSeat) throw new TypeError('Host client report 未知 seat:' + invalidSeat);
|
|
123
|
+
const previous = runtime.client
|
|
124
|
+
&& runtime.client.pluginId === pluginId
|
|
125
|
+
&& runtime.client.version === version
|
|
126
|
+
&& runtime.client.protocolVersion === protocolVersion
|
|
127
|
+
? runtime.client.seats : [];
|
|
128
|
+
// 同一客户端版本的 Slot 回调可能分别发出请求且乱序完成,只允许单调并集。
|
|
129
|
+
const seats = [...new Set(previous.concat(incomingSeats))];
|
|
130
|
+
runtime.client = {
|
|
131
|
+
loaded: true,
|
|
132
|
+
pluginId,
|
|
133
|
+
version,
|
|
134
|
+
protocolVersion,
|
|
135
|
+
seats,
|
|
136
|
+
reportedAt: new Date().toISOString()
|
|
137
|
+
};
|
|
138
|
+
return { ok: true, acceptedSeats: seats.length };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function hostRuntimeEvidence() {
|
|
142
|
+
return Object.freeze({
|
|
143
|
+
server: Object.freeze({ ...runtime.server }),
|
|
144
|
+
tools: Object.freeze([...runtime.tools]),
|
|
145
|
+
skills: Object.freeze([...runtime.skills]),
|
|
146
|
+
routes: Object.freeze([...runtime.routes]),
|
|
147
|
+
client: runtime.client
|
|
148
|
+
? Object.freeze({ ...runtime.client, seats: Object.freeze([...runtime.client.seats]) })
|
|
149
|
+
: null
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** 仅供隔离测试复位;生产装载使用 beginHostRuntime。 */
|
|
154
|
+
export function resetHostRuntimeForTests() {
|
|
155
|
+
runtime = fresh();
|
|
156
|
+
}
|