@mokuyoaxis/dsh-iris 0.1.3 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dsh/skills/iris-compose-media/SKILL.md +23 -49
- package/.dsh/skills/iris-compose-media/references/task-v2-recovery.md +30 -0
- package/.dsh/skills/iris-compose-media/references/workflows.md +38 -0
- package/.dsh/skills/iris-verify-ui/SKILL.md +3 -25
- package/.dsh/skills/iris-verify-ui/agents/openai.yaml +4 -0
- package/.dsh/skills/iris-verify-ui/references/verdict-and-degradation.md +31 -0
- package/CHANGELOG.md +24 -2
- package/README.en.md +29 -7
- package/README.md +29 -7
- package/docs/ARCHITECTURE.md +27 -13
- package/docs/ARTIFACT_LIBRARY.md +24 -0
- package/docs/HOST_ADAPTER_CONTRACT.md +86 -0
- package/docs/HOST_DOCTOR.md +47 -0
- package/docs/PROVIDER_ADAPTER_CONTRACT.md +97 -0
- package/docs/PROVIDER_HEALTH.md +39 -0
- package/docs/PROVIDER_SUBMISSION_CONTRACT.md +2 -2
- package/docs/ROADMAP.md +18 -8
- package/docs/SECURITY.md +6 -2
- package/docs/TASK_SEMANTICS.md +3 -2
- package/docs/assets/diagrams/iris-architecture.png +0 -0
- package/docs/assets/diagrams/iris-architecture.svg +5 -5
- package/docs/releases/0.1.4.md +59 -0
- package/lib/actions.js +357 -221
- package/lib/adapters.js +7 -7
- package/lib/api.js +30 -11
- package/lib/artifacts.js +239 -0
- package/lib/bundled-skills.js +5 -5
- package/lib/client.js +370 -55
- package/lib/config.js +192 -9
- package/lib/doctor.js +175 -4
- package/lib/dsh-host-adapter.js +189 -0
- package/lib/host-contract.js +131 -0
- package/lib/host-runtime.js +156 -0
- package/lib/index.js +97 -113
- package/lib/locate.js +1 -1
- package/lib/media.js +16 -6
- package/lib/prompt-optimizer.js +10 -20
- package/lib/provider-adapter.js +371 -0
- package/lib/provider-adapters.js +279 -0
- package/lib/provider-health.js +59 -0
- package/lib/render.js +43 -12
- package/lib/tasks.js +29 -5
- package/lib/vision.js +23 -49
- package/package.json +14 -8
- package/user_guide.md +42 -2
- package/docs/assets/diagrams/iris-architecture.drawio +0 -1
- package/docs/assets/diagrams/iris-task-lifecycle.drawio +0 -1
- package/docs/assets/diagrams/iris-workflow-compose.drawio +0 -1
- package/docs/releases/0.1.1.md +0 -51
- package/docs/releases/0.1.2.md +0 -26
- package/docs/releases/0.1.3.md +0 -30
package/lib/config.js
CHANGED
|
@@ -11,6 +11,14 @@ import * as cap from './capability.js';
|
|
|
11
11
|
import * as models from './models.js';
|
|
12
12
|
import { atomicWritePrivate, chmodPrivateFile, privateSibling } from './private-storage.js';
|
|
13
13
|
import { inferMediaProtocol } from './provider-protocol.js';
|
|
14
|
+
import { redactProviderMessage } from './provider-contract.js';
|
|
15
|
+
import {
|
|
16
|
+
PROVIDER_HEALTH_FRESH_MS,
|
|
17
|
+
aggregateHealthStates,
|
|
18
|
+
healthObservationState,
|
|
19
|
+
isDefinitiveHealthFailure,
|
|
20
|
+
newestHealthEvidence
|
|
21
|
+
} from './provider-health.js';
|
|
14
22
|
|
|
15
23
|
function isPlainObject(value) {
|
|
16
24
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
@@ -293,6 +301,163 @@ export function capabilitiesOf(p) {
|
|
|
293
301
|
return [...caps];
|
|
294
302
|
}
|
|
295
303
|
|
|
304
|
+
const HEALTH_SENSITIVE_FIELDS = new Set([
|
|
305
|
+
'type', 'baseUrl', 'apiKey', 'mediaProtocol',
|
|
306
|
+
'imageModel', 'videoModel', 'ttsModel', 'transcribeModel', 'visionModel'
|
|
307
|
+
]);
|
|
308
|
+
|
|
309
|
+
function healthContainer(provider) {
|
|
310
|
+
const current = provider.health;
|
|
311
|
+
if (current && current.version === 1 && Number.isSafeInteger(current.revision)
|
|
312
|
+
&& Array.isArray(current.observations)) return current;
|
|
313
|
+
const revision = Number.isSafeInteger(current && current.revision) ? current.revision : 0;
|
|
314
|
+
provider.health = { version: 1, revision, observations: [] };
|
|
315
|
+
return provider.health;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function healthObservation(provider, modelId, capability) {
|
|
319
|
+
const health = provider && provider.health;
|
|
320
|
+
if (!health || health.version !== 1 || !Array.isArray(health.observations)) return null;
|
|
321
|
+
return health.observations.find((item) => item && item.modelId === modelId
|
|
322
|
+
&& item.capability === capability) || null;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function safeHealthNote(provider, value) {
|
|
326
|
+
const raw = String(value ?? '').trim();
|
|
327
|
+
if (!raw) return '';
|
|
328
|
+
let note = redactProviderMessage(raw, 200);
|
|
329
|
+
const key = String(provider && provider.apiKey || '');
|
|
330
|
+
if (key) note = note.split(key).join('[REDACTED]');
|
|
331
|
+
return note;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function observationEvidence(provider, result, fallbackSource) {
|
|
335
|
+
const atMs = Date.parse(String(result && result.at || ''));
|
|
336
|
+
const status = Number(result && (result.httpStatus ?? result.status));
|
|
337
|
+
const note = safeHealthNote(provider, result && (result.note || result.safeMessage || result.message));
|
|
338
|
+
return {
|
|
339
|
+
at: new Date(Number.isFinite(atMs) ? atMs : Date.now()).toISOString(),
|
|
340
|
+
source: ['probe', 'task'].includes(result && result.source) ? result.source : fallbackSource,
|
|
341
|
+
category: String(result && result.category || (result && result.ok ? 'success' : 'unknown')).slice(0, 40),
|
|
342
|
+
...(Number.isInteger(status) && status >= 100 && status <= 599 ? { httpStatus: status } : {}),
|
|
343
|
+
...(note ? { note } : {})
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function applyProviderHealth(provider, modelId, capability, result, fallbackSource = 'task') {
|
|
348
|
+
const model = String(modelId || '').trim();
|
|
349
|
+
if (!provider || !model || !cap.isKnownCapability(capability)) return false;
|
|
350
|
+
const health = healthContainer(provider);
|
|
351
|
+
let observation = health.observations.find((item) => item && item.modelId === model
|
|
352
|
+
&& item.capability === capability);
|
|
353
|
+
if (!observation) {
|
|
354
|
+
observation = { modelId: model, capability };
|
|
355
|
+
health.observations.push(observation);
|
|
356
|
+
}
|
|
357
|
+
const evidence = observationEvidence(provider, result || {}, fallbackSource);
|
|
358
|
+
if (result && result.ok === true) {
|
|
359
|
+
observation.lastSuccess = evidence;
|
|
360
|
+
} else if (isDefinitiveHealthFailure({ ...result, ...evidence })) {
|
|
361
|
+
observation.lastFailure = evidence;
|
|
362
|
+
} else {
|
|
363
|
+
observation.lastTransient = evidence;
|
|
364
|
+
}
|
|
365
|
+
return true;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function pruneHealthToEffectiveModels(provider) {
|
|
369
|
+
if (!provider) return;
|
|
370
|
+
const allowedByModel = new Map();
|
|
371
|
+
for (const entry of models.providerModels(provider)) {
|
|
372
|
+
allowedByModel.set(entry.id, new Set(entry.capabilities));
|
|
373
|
+
}
|
|
374
|
+
if (provider.health && Array.isArray(provider.health.observations)) {
|
|
375
|
+
provider.health.observations = provider.health.observations.filter((item) =>
|
|
376
|
+
item && allowedByModel.get(item.modelId)?.has(item.capability));
|
|
377
|
+
}
|
|
378
|
+
for (const entry of provider.models || []) {
|
|
379
|
+
if (!entry || typeof entry !== 'object' || !entry.verified || typeof entry.verified !== 'object') continue;
|
|
380
|
+
const allowed = allowedByModel.get(entry.id) || new Set();
|
|
381
|
+
entry.verified = Object.fromEntries(Object.entries(entry.verified).filter(([capability]) => allowed.has(capability)));
|
|
382
|
+
if (!Object.keys(entry.verified).length) delete entry.verified;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function pruneModelHealth(provider, modelId) {
|
|
387
|
+
if (!provider || !provider.health || !Array.isArray(provider.health.observations)) return;
|
|
388
|
+
provider.health.observations = provider.health.observations.filter((item) => item.modelId !== modelId);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function resetHealthOnCriticalChange(previous, next, patch) {
|
|
392
|
+
const changed = [...HEALTH_SENSITIVE_FIELDS].some((key) =>
|
|
393
|
+
Object.prototype.hasOwnProperty.call(patch, key) && previous[key] !== next[key]);
|
|
394
|
+
if (!changed) return;
|
|
395
|
+
const current = previous.health;
|
|
396
|
+
const revision = Number.isSafeInteger(current && current.revision) ? current.revision + 1 : 1;
|
|
397
|
+
next.health = { version: 1, revision, observations: [] };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** 记录一次显式实测或真实任务观察;临时错误不会覆盖近期成功。 */
|
|
401
|
+
export function recordProviderHealth(id, modelId, capability, result) {
|
|
402
|
+
const c = load();
|
|
403
|
+
const provider = c.providers.find((item) => item.id === id);
|
|
404
|
+
if (!provider || !applyProviderHealth(provider, modelId, capability, result, result && result.source || 'task')) {
|
|
405
|
+
return false;
|
|
406
|
+
}
|
|
407
|
+
persist();
|
|
408
|
+
return true;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** 返回单个 Provider × Model × Capability 的安全状态。 */
|
|
412
|
+
export function modelHealth(id, modelId, capability, { now = Date.now() } = {}) {
|
|
413
|
+
const provider = load().providers.find((item) => item.id === id);
|
|
414
|
+
if (!provider || provider.enabled === false || !provider.apiKey) return { status: 'unconfigured' };
|
|
415
|
+
const observation = healthObservation(provider, modelId, capability);
|
|
416
|
+
const status = healthObservationState(observation, { now });
|
|
417
|
+
const evidence = status === 'verified'
|
|
418
|
+
? observation && observation.lastSuccess
|
|
419
|
+
: (status === 'failed' ? observation && observation.lastFailure : newestHealthEvidence(observation));
|
|
420
|
+
return {
|
|
421
|
+
status,
|
|
422
|
+
...(evidence ? {
|
|
423
|
+
observedAt: evidence.at,
|
|
424
|
+
source: evidence.source,
|
|
425
|
+
category: evidence.category,
|
|
426
|
+
...(evidence.httpStatus ? { httpStatus: evidence.httpStatus } : {}),
|
|
427
|
+
...(evidence.note ? { note: evidence.note } : {})
|
|
428
|
+
} : {})
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** 当前有序 failover 路径的健康快照;只含脱敏标量,不触发网络。 */
|
|
433
|
+
export function providerHealthSnapshot({ now = Date.now() } = {}) {
|
|
434
|
+
const capabilities = {};
|
|
435
|
+
for (const capability of Object.values(cap.CAPABILITIES)) {
|
|
436
|
+
const candidates = pickAllFor(capability).map((provider) => {
|
|
437
|
+
const modelId = String(provider[FIELD_OF[capability]] || '');
|
|
438
|
+
const health = modelHealth(provider.id, modelId, capability, { now });
|
|
439
|
+
return { providerId: provider.id, modelId, ...health };
|
|
440
|
+
});
|
|
441
|
+
const status = aggregateHealthStates(candidates.map((item) => item.status));
|
|
442
|
+
const newest = [...candidates]
|
|
443
|
+
.filter((item) => item.status === status && item.observedAt)
|
|
444
|
+
.sort((a, b) => String(b.observedAt).localeCompare(String(a.observedAt)))[0];
|
|
445
|
+
capabilities[capability] = {
|
|
446
|
+
status,
|
|
447
|
+
candidateCount: candidates.length,
|
|
448
|
+
...(newest ? { observedAt: newest.observedAt } : {}),
|
|
449
|
+
candidates
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
return {
|
|
453
|
+
schemaVersion: 1,
|
|
454
|
+
freshForMs: PROVIDER_HEALTH_FRESH_MS,
|
|
455
|
+
generatedAt: new Date(Number(now)).toISOString(),
|
|
456
|
+
overall: aggregateHealthStates(Object.values(capabilities).map((item) => item.status)),
|
|
457
|
+
capabilities
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
|
|
296
461
|
export function upsert(provider) {
|
|
297
462
|
const c = load();
|
|
298
463
|
const { id, ...input } = provider;
|
|
@@ -301,11 +466,15 @@ export function upsert(provider) {
|
|
|
301
466
|
if (id) {
|
|
302
467
|
const i = c.providers.findIndex((p) => p.id === id);
|
|
303
468
|
if (i >= 0) {
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
469
|
+
const previous = c.providers[i];
|
|
470
|
+
if (rest.mediaProtocol === 'auto') rest.mediaProtocol = inferMediaProtocol(rest.baseUrl ?? previous.baseUrl);
|
|
471
|
+
const next = { ...previous, ...rest };
|
|
472
|
+
normalizeStoredProvider(next);
|
|
473
|
+
resetHealthOnCriticalChange(previous, next, rest);
|
|
474
|
+
if (Object.prototype.hasOwnProperty.call(rest, 'models')) pruneHealthToEffectiveModels(next);
|
|
475
|
+
c.providers[i] = next;
|
|
307
476
|
persist();
|
|
308
|
-
return
|
|
477
|
+
return next;
|
|
309
478
|
}
|
|
310
479
|
}
|
|
311
480
|
const fresh = { id: 'iris_' + Math.random().toString(36).slice(2, 8), ...rest };
|
|
@@ -352,6 +521,7 @@ export function setProviderModels(id, modelEntries) {
|
|
|
352
521
|
return entry;
|
|
353
522
|
});
|
|
354
523
|
}
|
|
524
|
+
pruneHealthToEffectiveModels(p);
|
|
355
525
|
persist();
|
|
356
526
|
return p;
|
|
357
527
|
}
|
|
@@ -376,6 +546,7 @@ export function addProviderModel(id, modelId, capabilities) {
|
|
|
376
546
|
} else if (Array.isArray(capabilities)) {
|
|
377
547
|
entry.capabilities = capabilities;
|
|
378
548
|
}
|
|
549
|
+
pruneHealthToEffectiveModels(p);
|
|
379
550
|
persist();
|
|
380
551
|
return entry;
|
|
381
552
|
}
|
|
@@ -388,6 +559,7 @@ export function removeProviderModel(id, modelId) {
|
|
|
388
559
|
const i = p.models.findIndex((m) => m && m.id === modelId);
|
|
389
560
|
if (i < 0) return false;
|
|
390
561
|
p.models.splice(i, 1);
|
|
562
|
+
pruneModelHealth(p, modelId);
|
|
391
563
|
persist();
|
|
392
564
|
return true;
|
|
393
565
|
}
|
|
@@ -399,20 +571,31 @@ export function setModelCapabilities(id, modelId, capabilities) {
|
|
|
399
571
|
if (!p) return false;
|
|
400
572
|
const entry = findModelEntry(p, modelId);
|
|
401
573
|
if (!entry) return false;
|
|
402
|
-
|
|
574
|
+
const nextCapabilities = (Array.isArray(capabilities) ? capabilities : []).filter((x) => cap.isKnownCapability(x));
|
|
575
|
+
entry.capabilities = nextCapabilities;
|
|
576
|
+
pruneHealthToEffectiveModels(p);
|
|
403
577
|
persist();
|
|
404
578
|
return true;
|
|
405
579
|
}
|
|
406
580
|
|
|
407
|
-
/**
|
|
581
|
+
/** 记录某模型的显式实测结果,并同步到新健康事实。 */
|
|
408
582
|
export function setModelVerified(id, modelId, capability, result) {
|
|
409
583
|
const c = load();
|
|
410
584
|
const p = c.providers.find((x) => x.id === id);
|
|
411
585
|
if (!p) return false;
|
|
412
586
|
const entry = findModelEntry(p, modelId);
|
|
413
|
-
|
|
414
|
-
if (
|
|
415
|
-
|
|
587
|
+
const at = new Date().toISOString();
|
|
588
|
+
if (entry) {
|
|
589
|
+
if (!entry.verified) entry.verified = {};
|
|
590
|
+
entry.verified[capability] = {
|
|
591
|
+
ok: !!result.ok,
|
|
592
|
+
at,
|
|
593
|
+
note: safeHealthNote(p, result.note || ''),
|
|
594
|
+
source: 'probe',
|
|
595
|
+
category: String(result.category || (result.ok ? 'success' : 'unknown')).slice(0, 40)
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
applyProviderHealth(p, modelId, capability, { ...result, at, source: 'probe' }, 'probe');
|
|
416
599
|
persist();
|
|
417
600
|
return true;
|
|
418
601
|
}
|
package/lib/doctor.js
CHANGED
|
@@ -5,6 +5,11 @@ import os from 'node:os';
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { spawnSync } from 'node:child_process';
|
|
7
7
|
import { attentionDisposition, deriveUserState, semanticViolations } from './task-semantics.js';
|
|
8
|
+
import { HOST_CONTRACT_VERSION, hostCapabilitySnapshot } from './host-contract.js';
|
|
9
|
+
import {
|
|
10
|
+
EXPECTED_IRIS_CLIENT_SEATS, EXPECTED_IRIS_ROUTES, EXPECTED_IRIS_SKILLS, EXPECTED_IRIS_TOOLS,
|
|
11
|
+
HOST_CLIENT_PROTOCOL_VERSION, IRIS_PLUGIN_ID
|
|
12
|
+
} from './host-runtime.js';
|
|
8
13
|
|
|
9
14
|
const PACKAGE_FILE = new URL('../package.json', import.meta.url);
|
|
10
15
|
|
|
@@ -45,7 +50,7 @@ function isGroupOrWorldAccessible(target) {
|
|
|
45
50
|
|
|
46
51
|
function privateFileModeProblems(dataDir) {
|
|
47
52
|
if (process.platform === 'win32') return [];
|
|
48
|
-
return ['providers.json', 'tasks.json', 'prompt-optimizer.json']
|
|
53
|
+
return ['providers.json', 'tasks.json', 'artifacts.json', 'prompt-optimizer.json']
|
|
49
54
|
.filter((name) => isGroupOrWorldAccessible(path.join(dataDir, name)));
|
|
50
55
|
}
|
|
51
56
|
|
|
@@ -151,6 +156,30 @@ function inspectArtifacts(dataDir, tasks, checks) {
|
|
|
151
156
|
for (const item of task.media || []) if (item && item.file) referenced.add(path.basename(String(item.file)));
|
|
152
157
|
for (const item of task.attachments || []) if (item && item.file) referenced.add(path.basename(String(item.file)));
|
|
153
158
|
}
|
|
159
|
+
|
|
160
|
+
const library = readJson(path.join(dataDir, 'artifacts.json'));
|
|
161
|
+
if (library.exists && (library.error || !plain(library.value) || !Array.isArray(library.value.artifacts))) {
|
|
162
|
+
checks.push(result('artifact-index', 'warn', 'artifacts.json 结构无效或无法解析', '保留损坏索引作为证据;重新启动 Iris 后会从 outputs/ 重建最小作品索引。'));
|
|
163
|
+
} else if (library.exists) {
|
|
164
|
+
let invalid = 0;
|
|
165
|
+
for (const item of library.value.artifacts) {
|
|
166
|
+
if (!plain(item) || !String(item.id || '').startsWith('a_') || !String(item.token || '').match(/^[a-f0-9]{32}$/)
|
|
167
|
+
|| !String(item.file || '') || path.basename(String(item.file)) !== String(item.file)) {
|
|
168
|
+
invalid++;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
const safe = String(item.file);
|
|
172
|
+
referenced.add(safe);
|
|
173
|
+
try {
|
|
174
|
+
const stat = fs.lstatSync(path.join(outputs, safe));
|
|
175
|
+
if (!stat.isFile() || stat.isSymbolicLink()) missing++;
|
|
176
|
+
} catch (_) { missing++; }
|
|
177
|
+
}
|
|
178
|
+
checks.push(invalid
|
|
179
|
+
? result('artifact-index', 'warn', `${invalid} 个作品索引条目无效`, '在工作台运行“找回本地作品”重建最小索引。')
|
|
180
|
+
: result('artifact-index', 'ok', `${library.value.artifacts.length} 个作品索引条目结构有效`));
|
|
181
|
+
}
|
|
182
|
+
|
|
154
183
|
let orphan = 0;
|
|
155
184
|
if (fs.existsSync(outputs)) {
|
|
156
185
|
for (const name of fs.readdirSync(outputs)) {
|
|
@@ -158,9 +187,9 @@ function inspectArtifacts(dataDir, tasks, checks) {
|
|
|
158
187
|
try { if (fs.lstatSync(target).isFile() && !referenced.has(name)) orphan++; } catch (_) {}
|
|
159
188
|
}
|
|
160
189
|
}
|
|
161
|
-
if (missing) checks.push(result('artifacts', 'warn', `${missing}
|
|
162
|
-
else if (orphan) checks.push(result('artifacts', 'warn', `${orphan}
|
|
163
|
-
else checks.push(result('artifacts', 'ok', `${referenced.size}
|
|
190
|
+
if (missing) checks.push(result('artifacts', 'warn', `${missing} 个任务或作品索引引用的文件不可访问;另有 ${orphan} 个孤儿文件`, '在工作台检查任务详情并运行“找回本地作品”。'));
|
|
191
|
+
else if (orphan) checks.push(result('artifacts', 'warn', `${orphan} 个输出文件既没有任务引用也未进入作品库`, '先运行“找回本地作品”;仍无法入库的文件再作为孤儿处理。'));
|
|
192
|
+
else checks.push(result('artifacts', 'ok', `${referenced.size} 个任务/作品引用可核对,未发现孤儿文件`));
|
|
164
193
|
|
|
165
194
|
const uploads = path.join(dataDir, 'uploads');
|
|
166
195
|
let temporary = 0;
|
|
@@ -231,6 +260,148 @@ export async function doctor(options = {}) {
|
|
|
231
260
|
};
|
|
232
261
|
}
|
|
233
262
|
|
|
263
|
+
|
|
264
|
+
function summarizeChecks(checks) {
|
|
265
|
+
const errors = checks.filter((item) => item.status === 'error').length;
|
|
266
|
+
const warnings = checks.filter((item) => item.status === 'warn').length;
|
|
267
|
+
return {
|
|
268
|
+
summary: { ok: checks.length - errors - warnings, warnings, errors },
|
|
269
|
+
exitCode: errors ? 2 : (warnings ? 1 : 0)
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function dshVersionSupported(value) {
|
|
274
|
+
/* 已实测的宿主窗口:>=0.1.2-rc.1 <0.1.3-0,以及 0.1.5-rc.1。
|
|
275
|
+
其他预览版没有宿主 canary 证据,不放行。 */
|
|
276
|
+
const version = String(value || '').trim().replace(/\s+/g, '');
|
|
277
|
+
if (/^0\.1\.5-rc\.1(?:\+.*)?$/.test(version)) return true;
|
|
278
|
+
const match = version.match(/^0\.1\.2(?:-rc\.(\d+))?(?:\+.*)?$/);
|
|
279
|
+
return Boolean(match && (match[1] === undefined || Number(match[1]) >= 1));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function names(value) {
|
|
283
|
+
return [...new Set((Array.isArray(value) ? value : []).map((item) => String(item || '').trim()).filter(Boolean))];
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function missing(expected, actual) {
|
|
287
|
+
const found = new Set(names(actual));
|
|
288
|
+
return expected.filter((item) => !found.has(item));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function capabilityCheck(checks, snapshot, id, port, label, severity = 'warn') {
|
|
292
|
+
const capability = snapshot.capabilities[port];
|
|
293
|
+
if (capability && capability.status === 'available') {
|
|
294
|
+
checks.push(result(id, 'ok', label + ' Host Port 可用'));
|
|
295
|
+
return true;
|
|
296
|
+
}
|
|
297
|
+
const status = severity === 'error' ? 'error' : 'warn';
|
|
298
|
+
const kind = capability?.status === 'incompatible' ? '接口不兼容' : '能力缺失';
|
|
299
|
+
checks.push(result(id, status, label + '不可用(' + kind + ')' + (capability?.reason ? ':' + capability.reason : ''),
|
|
300
|
+
capability?.status === 'incompatible'
|
|
301
|
+
? '核对当前 DSH 版本与 Iris 支持范围,必要时回退到已验证版本。'
|
|
302
|
+
: '启用对应 DSH 服务;不需要该能力时可忽略此降级。'));
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* 运行中宿主诊断。只消费安全快照和注册账本,不调用任何端口,因此默认零网络、
|
|
308
|
+
* 零计费,也不会打开 Browser、读取附件/会话或调用模型。
|
|
309
|
+
*/
|
|
310
|
+
export function hostDoctor(host, evidence = {}, options = {}) {
|
|
311
|
+
const snapshot = hostCapabilitySnapshot(host);
|
|
312
|
+
const packageVersion = String(options.packageVersion || evidence?.server?.version || readPackageVersion());
|
|
313
|
+
const checks = [];
|
|
314
|
+
|
|
315
|
+
checks.push(snapshot.contractVersion === HOST_CONTRACT_VERSION
|
|
316
|
+
? result('host-contract', 'ok', 'Host Adapter v' + snapshot.contractVersion + ' · ' + snapshot.host.id)
|
|
317
|
+
: result('host-contract', 'error', 'Host Adapter 契约版本不兼容',
|
|
318
|
+
'使用与当前 Iris 包匹配的 Host Adapter。'));
|
|
319
|
+
|
|
320
|
+
if (snapshot.host.id === 'deepseek-harness') {
|
|
321
|
+
if (snapshot.host.version === 'unknown') {
|
|
322
|
+
checks.push(result('dsh-version', 'warn', '无法从当前进程识别 DSH 版本',
|
|
323
|
+
'使用 dsh --version 核对;0.1.4 已验证 >=0.1.2-rc.1 <0.1.3-0 与 0.1.5-rc.1。'));
|
|
324
|
+
} else if (dshVersionSupported(snapshot.host.version)) {
|
|
325
|
+
checks.push(result('dsh-version', 'ok', 'DSH ' + snapshot.host.version + ' 位于已验证支持范围'));
|
|
326
|
+
} else {
|
|
327
|
+
checks.push(result('dsh-version', 'error', 'DSH ' + snapshot.host.version + ' 超出已验证支持范围',
|
|
328
|
+
'使用 >=0.1.2-rc.1 <0.1.3-0 或 0.1.5-rc.1,或先运行新的隔离 canary 再扩大支持范围。'));
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const server = evidence && typeof evidence.server === 'object' ? evidence.server : {};
|
|
333
|
+
if (server.loaded === true && server.pluginId === IRIS_PLUGIN_ID) {
|
|
334
|
+
checks.push(result('plugin', 'ok', IRIS_PLUGIN_ID + ' 服务端已装载 · ' + packageVersion));
|
|
335
|
+
} else {
|
|
336
|
+
checks.push(result('plugin', 'error', '无法确认 Iris 服务端插件身份或装载事实',
|
|
337
|
+
'检查 profile 中是否启用完整 scoped 包名 @mokuyoaxis/dsh-iris,并查看 DSH 启动日志。'));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const toolsAvailable = capabilityCheck(checks, snapshot, 'tools-port', 'tools', 'Agent 工具注册', 'error');
|
|
341
|
+
const missingTools = missing(EXPECTED_IRIS_TOOLS, evidence.tools);
|
|
342
|
+
checks.push(toolsAvailable && missingTools.length === 0
|
|
343
|
+
? result('tools', 'ok', EXPECTED_IRIS_TOOLS.length + ' 个 Iris 工具均已登记')
|
|
344
|
+
: result('tools', 'error', '缺少 ' + missingTools.length + '/' + EXPECTED_IRIS_TOOLS.length + ' 个 Iris 工具'
|
|
345
|
+
+ (missingTools.length ? ':' + missingTools.join(', ') : ''),
|
|
346
|
+
'检查单工具注册隔离日志;不要因一项失败重启或重复提交媒体任务。'));
|
|
347
|
+
|
|
348
|
+
const routesAvailable = capabilityCheck(checks, snapshot, 'routes-port', 'routes', 'Web 路由注册', 'error');
|
|
349
|
+
const missingRoutes = missing(EXPECTED_IRIS_ROUTES, evidence.routes);
|
|
350
|
+
checks.push(routesAvailable && missingRoutes.length === 0
|
|
351
|
+
? result('routes', 'ok', EXPECTED_IRIS_ROUTES.length + ' 组 Iris 路由均已登记')
|
|
352
|
+
: result('routes', 'error', '缺少 ' + missingRoutes.length + '/' + EXPECTED_IRIS_ROUTES.length + ' 组 Iris 路由'
|
|
353
|
+
+ (missingRoutes.length ? ':' + missingRoutes.join(', ') : ''),
|
|
354
|
+
'检查 webServer/httpServer 与路由挂载日志。'));
|
|
355
|
+
|
|
356
|
+
const skillsAvailable = capabilityCheck(checks, snapshot, 'skills-port', 'skills', 'Skill 注册');
|
|
357
|
+
const missingSkills = missing(EXPECTED_IRIS_SKILLS, evidence.skills);
|
|
358
|
+
checks.push(skillsAvailable && missingSkills.length === 0
|
|
359
|
+
? result('skills', 'ok', EXPECTED_IRIS_SKILLS.length + ' 项随包 Skill 均已登记')
|
|
360
|
+
: result('skills', 'warn', '缺少 ' + missingSkills.length + '/' + EXPECTED_IRIS_SKILLS.length + ' 项随包 Skill'
|
|
361
|
+
+ (missingSkills.length ? ':' + missingSkills.join(', ') : ''),
|
|
362
|
+
'工具仍可直接调用;检查 DSH Skill registry 和插件启动日志。'));
|
|
363
|
+
|
|
364
|
+
capabilityCheck(checks, snapshot, 'browser', 'browser', 'HTML Browser');
|
|
365
|
+
capabilityCheck(checks, snapshot, 'attachments', 'attachments', '附件读写');
|
|
366
|
+
capabilityCheck(checks, snapshot, 'sessions', 'sessions', '会话查询');
|
|
367
|
+
capabilityCheck(checks, snapshot, 'text-model', 'textModel', '宿主文本模型');
|
|
368
|
+
capabilityCheck(checks, snapshot, 'vision-model', 'visionModel', '宿主视觉模型');
|
|
369
|
+
|
|
370
|
+
const client = evidence && typeof evidence.client === 'object' ? evidence.client : null;
|
|
371
|
+
if (!client || client.loaded !== true) {
|
|
372
|
+
checks.push(result('client', 'warn', '尚未收到 Iris 浏览器客户端握手',
|
|
373
|
+
'在同一 DSH 实例打开或刷新 Web 页面,再重新访问 /iris/api/doctor。'));
|
|
374
|
+
} else if (client.pluginId !== IRIS_PLUGIN_ID
|
|
375
|
+
|| client.protocolVersion !== HOST_CLIENT_PROTOCOL_VERSION
|
|
376
|
+
|| client.version !== packageVersion) {
|
|
377
|
+
checks.push(result('client', 'error',
|
|
378
|
+
'Iris 服务端/客户端版本不一致:server ' + packageVersion + ' · client ' + String(client.version || 'unknown')
|
|
379
|
+
+ ' · protocol ' + String(client.protocolVersion),
|
|
380
|
+
'重新打开 DSH 输出的认证 URL并强制刷新;若仍不一致,清理该 profile 的旧插件链接后重装。'));
|
|
381
|
+
} else {
|
|
382
|
+
const missingSeats = missing(EXPECTED_IRIS_CLIENT_SEATS, client.seats);
|
|
383
|
+
checks.push(missingSeats.length
|
|
384
|
+
? result('client', 'error', '浏览器客户端缺少 ' + missingSeats.length + '/' + EXPECTED_IRIS_CLIENT_SEATS.length
|
|
385
|
+
+ ' 个 UI Slot:' + missingSeats.join(', '),
|
|
386
|
+
'检查客户端模块表和 DSH Slot API;服务端任务不受影响。')
|
|
387
|
+
: result('client', 'ok', '客户端 ' + client.version + ' 已装载,4 个 UI Slot 均已登记'));
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
checks.push(result('scope', 'ok', 'Host Doctor 只读取能力快照和 Iris 注册账本;未调用端口、Browser、模型或供应商'));
|
|
391
|
+
|
|
392
|
+
const totals = summarizeChecks(checks);
|
|
393
|
+
return {
|
|
394
|
+
schemaVersion: 1,
|
|
395
|
+
package: { name: IRIS_PLUGIN_ID, version: packageVersion },
|
|
396
|
+
mode: 'host',
|
|
397
|
+
environment: { node: process.versions.node, platform: process.platform, arch: process.arch },
|
|
398
|
+
host: snapshot.host,
|
|
399
|
+
capabilities: snapshot.capabilities,
|
|
400
|
+
checks,
|
|
401
|
+
...totals
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
234
405
|
export function formatDoctorReport(report) {
|
|
235
406
|
const mark = { ok: '✓', warn: '!', error: '✗' };
|
|
236
407
|
const lines = [`Iris Doctor ${report.package.version} · ${report.mode}`];
|
|
@@ -0,0 +1,189 @@
|
|
|
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 collectImageAttachments(nodes) {
|
|
45
|
+
const out = [];
|
|
46
|
+
const ids = new Set();
|
|
47
|
+
const seen = new Set();
|
|
48
|
+
const stack = Array.isArray(nodes) ? [...nodes] : [nodes];
|
|
49
|
+
let scanned = 0;
|
|
50
|
+
while (stack.length && scanned < 8000) {
|
|
51
|
+
const node = stack.pop();
|
|
52
|
+
scanned++;
|
|
53
|
+
if (!node || typeof node !== 'object' || seen.has(node)) continue;
|
|
54
|
+
seen.add(node);
|
|
55
|
+
const ref = node.attachment && typeof node.attachment === 'object'
|
|
56
|
+
? node.attachment
|
|
57
|
+
: (node.attachmentId && node.mediaType ? node : null);
|
|
58
|
+
if (ref && typeof ref.attachmentId === 'string'
|
|
59
|
+
&& typeof ref.mediaType === 'string' && ref.mediaType.startsWith('image/')
|
|
60
|
+
&& !ids.has(ref.attachmentId)) {
|
|
61
|
+
ids.add(ref.attachmentId);
|
|
62
|
+
out.push(Object.freeze({
|
|
63
|
+
attachmentId: ref.attachmentId,
|
|
64
|
+
mediaType: ref.mediaType,
|
|
65
|
+
name: typeof ref.name === 'string' ? ref.name : ''
|
|
66
|
+
}));
|
|
67
|
+
}
|
|
68
|
+
for (const value of Object.values(node)) if (value && typeof value === 'object') stack.push(value);
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function registerWithEffect(ctx, registry, definition) {
|
|
74
|
+
if (typeof ctx.effect === 'function') return ctx.effect(() => registry.register(definition));
|
|
75
|
+
return registry.register(definition);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function dshVisionPort(llm) {
|
|
79
|
+
return Object.freeze({
|
|
80
|
+
id: 'dsh-global',
|
|
81
|
+
model: '全局视觉模型',
|
|
82
|
+
async analyze({ question, ref, signal }) {
|
|
83
|
+
const chunks = [];
|
|
84
|
+
for await (const chunk of llm.stream({
|
|
85
|
+
sessionId: undefined,
|
|
86
|
+
provider: undefined,
|
|
87
|
+
model: undefined,
|
|
88
|
+
signal,
|
|
89
|
+
messages: [{
|
|
90
|
+
role: 'user',
|
|
91
|
+
content: [
|
|
92
|
+
{ type: 'text', text: question },
|
|
93
|
+
{ type: 'image', attachment: ref }
|
|
94
|
+
]
|
|
95
|
+
}]
|
|
96
|
+
})) {
|
|
97
|
+
const text = typeof chunk?.delta === 'string'
|
|
98
|
+
? chunk.delta
|
|
99
|
+
: (chunk?.type === 'text-delta' ? String(chunk.text || '') : '');
|
|
100
|
+
if (text) chunks.push(text);
|
|
101
|
+
if (chunks.reduce((sum, item) => sum + item.length, 0) > 6000) break;
|
|
102
|
+
}
|
|
103
|
+
return chunks.join('').trim();
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Build a fresh capability snapshot so optional DSH services can arrive after plugin apply(). */
|
|
109
|
+
export function createDshHostAdapter(ctx, { version = detectDshVersion() } = {}) {
|
|
110
|
+
if (!ctx || typeof ctx !== 'object') throw new TypeError('DSH Host Adapter 需要宿主 ctx');
|
|
111
|
+
const ports = {};
|
|
112
|
+
const unavailable = {};
|
|
113
|
+
|
|
114
|
+
const attachments = service(ctx, 'attachments');
|
|
115
|
+
if (attachments && typeof attachments.saveImage === 'function' && typeof attachments.readImage === 'function') {
|
|
116
|
+
ports.attachments = Object.freeze({
|
|
117
|
+
saveImage: (input) => attachments.saveImage(input),
|
|
118
|
+
readImage: (ref, signal) => attachments.readImage(ref, signal)
|
|
119
|
+
});
|
|
120
|
+
} else unavailable.attachments = unavailableFor(attachments, 'DSH attachments 需要 saveImage/readImage');
|
|
121
|
+
|
|
122
|
+
const browser = service(ctx, 'browser');
|
|
123
|
+
if (browser && ['open', 'openUrl', 'screenshot', 'close'].every((name) => typeof browser[name] === 'function')) {
|
|
124
|
+
const renderer = new BrowserHtmlRenderer({ browser });
|
|
125
|
+
ports.browser = Object.freeze({
|
|
126
|
+
async renderHtml(input) {
|
|
127
|
+
const { png } = await renderer.render(input);
|
|
128
|
+
return { bytes: new Uint8Array(png), mediaType: 'image/png' };
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
} else unavailable.browser = unavailableFor(browser, 'DSH browser 需要 open/openUrl/screenshot/close');
|
|
132
|
+
|
|
133
|
+
const slots = service(ctx, 'slots');
|
|
134
|
+
if (slots && typeof slots.inject === 'function' && typeof slots.register === 'function') {
|
|
135
|
+
ports.clientSlots = Object.freeze({
|
|
136
|
+
inject: (seat, callback) => slots.inject(seat, callback),
|
|
137
|
+
register: (definition, component) => slots.register(definition, component)
|
|
138
|
+
});
|
|
139
|
+
} else unavailable.clientSlots = unavailableFor(slots, 'DSH client slots 需要 inject/register');
|
|
140
|
+
|
|
141
|
+
const routes = service(ctx, 'webServer') || service(ctx, 'httpServer');
|
|
142
|
+
if (routes && typeof routes.register === 'function') {
|
|
143
|
+
ports.routes = Object.freeze({ register: (definition) => routes.register(definition) });
|
|
144
|
+
} else unavailable.routes = unavailableFor(routes, 'DSH webServer/httpServer 需要 register');
|
|
145
|
+
|
|
146
|
+
const sessions = service(ctx, 'sessionQuery');
|
|
147
|
+
if (sessions && typeof sessions.readSession === 'function') {
|
|
148
|
+
ports.sessions = Object.freeze({
|
|
149
|
+
readSession: (id) => sessions.readSession(id),
|
|
150
|
+
async listImageAttachments(id) {
|
|
151
|
+
const record = await sessions.readSession(id);
|
|
152
|
+
return collectImageAttachments(record && record.events);
|
|
153
|
+
},
|
|
154
|
+
async findImageAttachment(id, attachmentId) {
|
|
155
|
+
return (await this.listImageAttachments(id)).find((item) => item.attachmentId === attachmentId) || null;
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
} else unavailable.sessions = unavailableFor(sessions, 'DSH sessionQuery 需要 readSession');
|
|
159
|
+
|
|
160
|
+
const skills = service(ctx, 'skills');
|
|
161
|
+
if (skills && typeof skills.register === 'function') {
|
|
162
|
+
ports.skills = Object.freeze({ register: (definition) => registerWithEffect(ctx, skills, definition) });
|
|
163
|
+
} else unavailable.skills = unavailableFor(skills, 'DSH Skill registry 需要 register');
|
|
164
|
+
|
|
165
|
+
const llm = service(ctx, 'llm');
|
|
166
|
+
const defaults = service(ctx, 'agentDefaultModel');
|
|
167
|
+
if (llm && typeof llm.stream === 'function') {
|
|
168
|
+
ports.textModel = Object.freeze({
|
|
169
|
+
stream: (request) => llm.stream(request),
|
|
170
|
+
resolveModelInfo: typeof llm.resolveModelInfo === 'function'
|
|
171
|
+
? (provider, model, signal) => llm.resolveModelInfo(provider, model, signal)
|
|
172
|
+
: undefined,
|
|
173
|
+
currentSelection: defaults && typeof defaults.currentSelection === 'function'
|
|
174
|
+
? () => defaults.currentSelection()
|
|
175
|
+
: () => null
|
|
176
|
+
});
|
|
177
|
+
ports.visionModel = dshVisionPort(llm);
|
|
178
|
+
} else {
|
|
179
|
+
unavailable.textModel = unavailableFor(llm, 'DSH llm 需要 stream');
|
|
180
|
+
unavailable.visionModel = unavailableFor(llm, 'DSH llm 需要 stream');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const tools = service(ctx, 'tools');
|
|
184
|
+
if (tools && typeof tools.register === 'function') {
|
|
185
|
+
ports.tools = Object.freeze({ register: (definition) => registerWithEffect(ctx, tools, definition) });
|
|
186
|
+
} else unavailable.tools = unavailableFor(tools, 'DSH tools 需要 register');
|
|
187
|
+
|
|
188
|
+
return defineHostAdapter({ id: 'deepseek-harness', version, ports, unavailable });
|
|
189
|
+
}
|