@mokuyoaxis/dsh-iris 0.2.0 → 0.2.2
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 +10 -5
- package/.dsh/skills/iris-compose-media/references/workflows.md +7 -1
- package/.dsh/skills/iris-verify-ui/SKILL.md +2 -1
- package/CHANGELOG.md +69 -2
- package/README.en.md +67 -31
- package/README.md +67 -31
- package/bin/dsh-iris.js +222 -21
- package/docs/AGENT_QUICKSTART.md +10 -6
- package/docs/ARCHITECTURE.md +34 -19
- package/docs/ARTIFACT_LIBRARY.md +5 -5
- package/docs/ARTIFACT_MANIFEST.md +1 -1
- package/docs/CLI_MANAGEMENT.md +183 -0
- package/docs/COMPOSITE_VISION.md +35 -0
- package/docs/CORE_RUNTIME_CONTRACT.md +16 -7
- package/docs/DSH_CORE_MIGRATION.md +48 -19
- package/docs/DSH_RC2_ADAPTATION.md +4 -2
- package/docs/FAULT_INJECTION.md +21 -2
- package/docs/HEADLESS_CLI.md +15 -7
- package/docs/HOST_ADAPTER_CONTRACT.md +10 -10
- package/docs/HOST_DOCTOR.md +3 -3
- package/docs/MODEL_PORT_CONTRACT.md +21 -14
- package/docs/OCR_MODEL.md +39 -0
- package/docs/PROMPT_OPTIMIZER.md +77 -0
- package/docs/PROVIDER_ADAPTER_CONTRACT.md +15 -5
- package/docs/PROVIDER_HEALTH.md +22 -5
- package/docs/PROVIDER_RUNTIME_HARNESS.md +1 -1
- package/docs/PROVIDER_SUBMISSION_CONTRACT.md +2 -2
- package/docs/ROADMAP.md +37 -19
- package/docs/SECURITY.md +5 -5
- package/docs/TASK_SEMANTICS.md +2 -2
- package/docs/VISION_CLI.md +132 -0
- package/docs/VISION_MODEL.md +51 -0
- package/docs/WORKBENCH_ARTIFACTS.md +67 -0
- package/docs/file-access-across-environments.md +8 -5
- package/docs/releases/0.2.1.md +56 -0
- package/docs/releases/0.2.2.md +31 -0
- package/lib/actions.js +199 -286
- package/lib/adapters.js +216 -41
- package/lib/api.js +49 -7
- package/lib/capability.js +23 -16
- package/lib/chromium-browser.js +117 -0
- package/lib/client.js +933 -117
- package/lib/command-service.js +121 -7
- package/lib/composite-vision-routing.js +42 -0
- package/lib/config.js +102 -14
- package/lib/core-artifact-store.js +45 -13
- package/lib/core-lease-recovery.js +64 -92
- package/lib/core-maintenance.js +213 -0
- package/lib/core-runtime.js +45 -47
- package/lib/core-tasks.js +12 -5
- package/lib/core-user-projection.js +1 -0
- package/lib/core-writer-lease.js +134 -0
- package/lib/dsh-core-adapter.js +63 -9
- package/lib/dsh-text-model-adapter.js +222 -0
- package/lib/dsh-vision-model-adapter.js +75 -0
- package/lib/generation-input.js +36 -5
- package/lib/headless-vision.js +181 -0
- package/lib/host-runtime.js +1 -0
- package/lib/http-vision-model-adapter.js +187 -0
- package/lib/image-edit-input.js +34 -0
- package/lib/index.js +205 -175
- package/lib/locate.js +86 -28
- package/lib/media-probe.js +2 -1
- package/lib/model-call-runtime.js +45 -0
- package/lib/model-invoker.js +4 -2
- package/lib/model-port-contract.js +13 -5
- package/lib/models.js +29 -42
- package/lib/ocr-model-routing.js +26 -0
- package/lib/ocr.js +110 -64
- package/lib/prompt-optimizer-config.js +21 -10
- package/lib/prompt-optimizer-core.js +247 -0
- package/lib/prompt-optimizer.js +37 -193
- package/lib/provider-adapters.js +87 -10
- package/lib/provider-catalog.js +19 -7
- package/lib/provider-config-service.js +235 -0
- package/lib/provider-contract.js +3 -2
- package/lib/provider-health.js +80 -1
- package/lib/provider-protocol.js +84 -0
- package/lib/provider-task-runner.js +63 -19
- package/lib/summarize.js +31 -20
- package/lib/summary-input.js +52 -0
- package/lib/task-wait.js +40 -0
- package/lib/video-input.js +30 -0
- package/lib/vision-core.js +42 -0
- package/lib/vision-image-input.js +83 -0
- package/lib/vision-input.js +31 -0
- package/lib/vision-model-routing.js +180 -0
- package/lib/vision.js +3 -2
- package/lib/workbench-artifacts.js +52 -0
- package/lib/workbench-management.js +151 -0
- package/lib/workbench-zip.js +39 -0
- package/package.json +1 -1
- package/user_guide.md +74 -30
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { CORE_CONTRACT_VERSION } from './core-contract.js';
|
|
7
|
+
import { privateSibling } from './private-storage.js';
|
|
8
|
+
|
|
9
|
+
export const WRITER_LEASE_DIR = '.iris-runtime-writer-v0';
|
|
10
|
+
export const WRITER_INTENT_FILE = '.iris-runtime-writer-v0.owner.json';
|
|
11
|
+
export function recoveryClaimFile(root, owner) {
|
|
12
|
+
return path.join(root, WRITER_LEASE_DIR + '.recovery-' + owner.instanceId + '.json');
|
|
13
|
+
}
|
|
14
|
+
const UUID = /^[a-f0-9-]{36}$/;
|
|
15
|
+
const STALE = /^\.owner\.stale-([a-f0-9-]{36})\.json$/;
|
|
16
|
+
|
|
17
|
+
export function writerOwnerStatus(pid) {
|
|
18
|
+
try { process.kill(pid, 0); return 'alive'; }
|
|
19
|
+
catch (error) { return error?.code === 'ESRCH' ? 'missing' : 'unknown'; }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function newWriterOwner() {
|
|
23
|
+
return { contractVersion: CORE_CONTRACT_VERSION, instanceId: crypto.randomUUID(),
|
|
24
|
+
pid: process.pid, createdAt: new Date().toISOString() };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function checkedOwner(owner) {
|
|
28
|
+
if (owner?.contractVersion !== CORE_CONTRACT_VERSION || !Number.isSafeInteger(owner.pid) || owner.pid <= 0
|
|
29
|
+
|| !UUID.test(String(owner.instanceId || '')) || !Number.isFinite(Date.parse(owner.createdAt))) {
|
|
30
|
+
throw new Error('incomplete owner');
|
|
31
|
+
}
|
|
32
|
+
return owner;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function readRecord(file) {
|
|
36
|
+
const stat = fs.lstatSync(file);
|
|
37
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 16384) throw new Error('invalid lease record');
|
|
38
|
+
const serialized = fs.readFileSync(file, 'utf8');
|
|
39
|
+
return { serialized, value: JSON.parse(serialized) };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function optionalRecord(file) {
|
|
43
|
+
try { return readRecord(file); }
|
|
44
|
+
catch (error) { if (error?.code === 'ENOENT') return null; throw error; }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 完整私有记录先在根中准备,再以 hard link 原子发布;已有目标绝不覆盖。 */
|
|
48
|
+
export function publishLeaseRecord(root, target, value) {
|
|
49
|
+
const prepared = privateSibling(path.join(root, WRITER_INTENT_FILE), 'prepare');
|
|
50
|
+
let created = false;
|
|
51
|
+
try {
|
|
52
|
+
fs.writeFileSync(prepared, JSON.stringify(value, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
|
|
53
|
+
created = true;
|
|
54
|
+
fs.linkSync(prepared, target);
|
|
55
|
+
} finally {
|
|
56
|
+
if (created) { try { fs.unlinkSync(prepared); } catch (_) { /* 未发布的准备文件不占有租约 */ } }
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function withdrawLeaseRecord(target, value) {
|
|
61
|
+
if (readRecord(target).serialized !== JSON.stringify(value, null, 2) + '\n') throw new Error('published record changed');
|
|
62
|
+
fs.unlinkSync(target);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function retireLeaseDirectory(source, target) {
|
|
66
|
+
try { fs.lstatSync(target); throw new Error('retirement destination already exists'); }
|
|
67
|
+
catch (error) { if (error?.code !== 'ENOENT') throw error; }
|
|
68
|
+
fs.renameSync(source, target);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 只读识别完整 owner、创建/释放意图及恢复链;未知内容和缺失证据拒绝。 */
|
|
72
|
+
export function readWriterLease(root) {
|
|
73
|
+
const directory = path.join(root, WRITER_LEASE_DIR);
|
|
74
|
+
const intentFile = path.join(root, WRITER_INTENT_FILE);
|
|
75
|
+
const intent = optionalRecord(intentFile);
|
|
76
|
+
if (intent) checkedOwner(intent.value);
|
|
77
|
+
let stat;
|
|
78
|
+
try { stat = fs.lstatSync(directory); }
|
|
79
|
+
catch (error) { if (error?.code !== 'ENOENT') throw error; }
|
|
80
|
+
if (!stat && !intent) return null;
|
|
81
|
+
if (stat && (!stat.isDirectory() || stat.isSymbolicLink())) throw new Error('invalid lease directory');
|
|
82
|
+
const entries = stat ? fs.readdirSync(directory).sort() : [];
|
|
83
|
+
const records = new Map();
|
|
84
|
+
for (const name of entries) {
|
|
85
|
+
if (name !== 'owner.json' && !STALE.test(name)) throw new Error('unexpected lease contents');
|
|
86
|
+
records.set(name, readRecord(path.join(directory, name)));
|
|
87
|
+
}
|
|
88
|
+
const ownerRecord = records.get('owner.json');
|
|
89
|
+
if (ownerRecord) checkedOwner(ownerRecord.value);
|
|
90
|
+
const staleRecords = entries.filter(name => STALE.test(name));
|
|
91
|
+
if (staleRecords.length > 1) throw new Error('ambiguous legacy recovery');
|
|
92
|
+
const staleRecord = staleRecords.length ? records.get(staleRecords[0]) : null;
|
|
93
|
+
if (staleRecord) checkedOwner(staleRecord.value);
|
|
94
|
+
if (staleRecord && !ownerRecord) throw new Error('legacy recovery PID unknown');
|
|
95
|
+
if (intent && ownerRecord && ownerRecord.serialized !== intent.serialized) {
|
|
96
|
+
throw new Error('intent owner mismatch');
|
|
97
|
+
}
|
|
98
|
+
if (staleRecord && ownerRecord && STALE.exec(staleRecords[0])[1] !== ownerRecord.value.instanceId) {
|
|
99
|
+
throw new Error('legacy recovery owner mismatch');
|
|
100
|
+
}
|
|
101
|
+
const original = staleRecord || ownerRecord || intent;
|
|
102
|
+
if (!original) throw new Error('owner evidence missing');
|
|
103
|
+
let owner = (ownerRecord || original).value;
|
|
104
|
+
const owners = [original.value];
|
|
105
|
+
if (owner.instanceId !== original.value.instanceId) owners.push(owner);
|
|
106
|
+
const claims = [];
|
|
107
|
+
for (;;) {
|
|
108
|
+
const file = recoveryClaimFile(root, owner);
|
|
109
|
+
const record = optionalRecord(file);
|
|
110
|
+
if (!record) break;
|
|
111
|
+
const claim = record.value;
|
|
112
|
+
if (claim?.schemaVersion !== 1 || claim.previousInstanceId !== owner.instanceId) throw new Error('invalid recovery claim');
|
|
113
|
+
claims.push({ file, ...record });
|
|
114
|
+
owner = checkedOwner(claim.owner);
|
|
115
|
+
if (owners.some(previous => previous.instanceId === owner.instanceId)) throw new Error('cyclic recovery chain');
|
|
116
|
+
owners.push(owner);
|
|
117
|
+
if (owners.length > 64) throw new Error('recovery chain too long');
|
|
118
|
+
}
|
|
119
|
+
const phase = owners.length > 1 || staleRecord ? 'recovering' : ownerRecord ? 'owned' : 'pending';
|
|
120
|
+
const fingerprint = crypto.createHash('sha256').update(JSON.stringify({
|
|
121
|
+
identity: stat ? [stat.dev, stat.ino] : null, intent: intent?.serialized,
|
|
122
|
+
records: [...records].map(([name, record]) => [name, record.serialized]),
|
|
123
|
+
claims: claims.map(record => [path.basename(record.file), record.serialized])
|
|
124
|
+
})).digest('hex');
|
|
125
|
+
return { directory, intentFile, intent, stat, entries, records, claims, owner, owners,
|
|
126
|
+
originalOwner: original.value, phase, fingerprint };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function removeWriterIntent(lease) {
|
|
130
|
+
if (!lease.intent) return;
|
|
131
|
+
const actual = readRecord(lease.intentFile);
|
|
132
|
+
if (actual.serialized !== lease.intent.serialized) throw new Error('intent changed');
|
|
133
|
+
fs.unlinkSync(lease.intentFile);
|
|
134
|
+
}
|
package/lib/dsh-core-adapter.js
CHANGED
|
@@ -2,16 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
-
import { allProviders, irisHome } from './config.js';
|
|
5
|
+
import { allProviders, irisHome, recordRateLimit } from './config.js';
|
|
6
6
|
import { createCommandService } from './command-service.js';
|
|
7
|
-
import { createCoreArtifact, inspectCoreArtifact, listCoreArtifacts, readCoreArtifactBytes } from './core-artifacts.js';
|
|
7
|
+
import { createCoreArtifact, inspectCoreArtifact, listCoreArtifacts, listCoreArtifactMetadata, readCoreArtifactBytes } from './core-artifacts.js';
|
|
8
8
|
import { createCoreRuntime } from './core-runtime.js';
|
|
9
|
+
import { readSummaryFrames } from './summary-input.js';
|
|
10
|
+
import { readVisionArtifact } from './vision-input.js';
|
|
9
11
|
import { inspectCoreTask, listCoreTasks, recoverCoreTask } from './core-tasks.js';
|
|
10
12
|
import { applyCoreAttentionAction, attentionDispositionOf, readCoreAttentionPrefs } from './core-attention.js';
|
|
11
13
|
import { projectCoreTaskUserRow } from './core-user-projection.js';
|
|
12
14
|
import { requireHostPort } from './host-contract.js';
|
|
13
15
|
import { createProviderTaskRunner } from './provider-task-runner.js';
|
|
14
|
-
import { createConfiguredProviderAdapter } from './provider-adapters.js';
|
|
16
|
+
import { createConfiguredProviderAdapter as createProviderAdapter } from './provider-adapters.js';
|
|
17
|
+
import { parseModelRef } from './models.js';
|
|
15
18
|
import { imageCandidatesFromCatalog, providerForTaskFromCatalog, providerTaskBinding, transcribeCandidatesFromCatalog, ttsCandidatesFromCatalog, videoCandidatesFromCatalog } from './provider-catalog.js';
|
|
16
19
|
|
|
17
20
|
let writerTail = Promise.resolve();
|
|
@@ -20,6 +23,10 @@ const DEFAULT_POLL_INTERVAL_MS = 2500;
|
|
|
20
23
|
const DEFAULT_MAX_WATCH_MS = 20 * 60 * 1000;
|
|
21
24
|
const POLL_ERROR_TOLERANCE = 5;
|
|
22
25
|
|
|
26
|
+
function createConfiguredProviderAdapter(provider, options = {}) {
|
|
27
|
+
return createProviderAdapter(provider, { ...options, onRateLimit: recordRateLimit });
|
|
28
|
+
}
|
|
29
|
+
|
|
23
30
|
export class DshCoreAdapterError extends Error {
|
|
24
31
|
constructor(code, message) {
|
|
25
32
|
super(message);
|
|
@@ -99,6 +106,16 @@ export async function cropForDsh(input = {}) {
|
|
|
99
106
|
}, input.signal);
|
|
100
107
|
}
|
|
101
108
|
|
|
109
|
+
/** Browser 只提供截图字节;DSH/CLI 共用 HTML Command 和 Artifact 提交。 */
|
|
110
|
+
export function htmlForDsh(input = {}) {
|
|
111
|
+
return withWriter(async runtime => {
|
|
112
|
+
const commands = createCommandService(runtime, { browser: input.browser });
|
|
113
|
+
const result = await commands.execute('media.html', { html: input.html, width: input.width,
|
|
114
|
+
height: input.height, full_page: input.fullPage });
|
|
115
|
+
return runtime.run('inspect', ({ dataRoot }) => readCoreArtifactBytes(dataRoot, result.artifact.id));
|
|
116
|
+
}, input.signal);
|
|
117
|
+
}
|
|
118
|
+
|
|
102
119
|
/** DSH 只把图片来源转换为 Command 输入,差异算法与 Artifact 提交由 Core 执行。 */
|
|
103
120
|
export function diffForDsh(input = {}) {
|
|
104
121
|
return withWriter(async (runtime, commands) => {
|
|
@@ -142,12 +159,23 @@ export function framesForDsh(input = {}) {
|
|
|
142
159
|
}, input.signal);
|
|
143
160
|
}
|
|
144
161
|
|
|
162
|
+
/** 已有抽帧 Artifact 直接进入共享摘要;reader 不取得写者租约。 */
|
|
163
|
+
export function summaryFramesForDsh(input = {}) {
|
|
164
|
+
return withReader(runtime => readSummaryFrames(runtime, input.frameArtifactIds, input.signal), input.signal);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** 共享单图读取器用于 look/locate/OCR,Host 不解释 Core 对象路径。 */
|
|
168
|
+
export function visionImageForDsh(input = {}) {
|
|
169
|
+
return withReader(runtime => readVisionArtifact(runtime, input.artifactId, input.signal), input.signal);
|
|
170
|
+
}
|
|
171
|
+
|
|
145
172
|
/** 提交一个 Core Provider Task;Provider Adapter 由 DSH 配置边界选择后显式传入。 */
|
|
146
173
|
export function submitProviderTaskForDsh(input = {}) {
|
|
147
174
|
return withWriter((runtime) => createProviderTaskRunner(runtime).submit({
|
|
148
175
|
capability: input.capability,
|
|
149
176
|
candidates: input.candidates,
|
|
150
|
-
providerInput: input.providerInput
|
|
177
|
+
providerInput: input.providerInput,
|
|
178
|
+
sourceArtifactId: input.sourceArtifactId
|
|
151
179
|
}), input.signal);
|
|
152
180
|
}
|
|
153
181
|
|
|
@@ -272,7 +300,7 @@ export function retryProviderTaskForDsh(input = {}) {
|
|
|
272
300
|
? { text: input.text, ...(input.voice !== undefined ? { voice: input.voice } : {}) }
|
|
273
301
|
: input.audio_url !== undefined
|
|
274
302
|
? { audio_url: input.audio_url }
|
|
275
|
-
: { prompt: input.prompt },
|
|
303
|
+
: { prompt: input.prompt, ...(input.sourceArtifactId !== undefined ? { source_artifact_id: input.sourceArtifactId } : {}) },
|
|
276
304
|
model_ref: input.modelRef,
|
|
277
305
|
confirm_billing: input.confirmBilling
|
|
278
306
|
});
|
|
@@ -304,7 +332,7 @@ function observableProviderTask(task) {
|
|
|
304
332
|
|
|
305
333
|
function configuredAdapterForTask(task) {
|
|
306
334
|
const provider = providerForTaskFromCatalog({ providers: allProviders() }, task);
|
|
307
|
-
return createConfiguredProviderAdapter(provider);
|
|
335
|
+
return createConfiguredProviderAdapter(provider, { modelId: parseModelRef(task.modelRef)?.modelId });
|
|
308
336
|
}
|
|
309
337
|
|
|
310
338
|
function stopProviderWatch(taskId, expectedState) {
|
|
@@ -444,6 +472,30 @@ export function inspectProviderTaskForDsh(taskId, options = {}) {
|
|
|
444
472
|
inspectCoreTask(dataRoot, taskId)), options.signal);
|
|
445
473
|
}
|
|
446
474
|
|
|
475
|
+
/** 作品目录与任务 snapshot 分开查询,目录不加载媒体字节、不取得 writer 租约。 */
|
|
476
|
+
export function galleryArtifactsForDsh(options = {}) {
|
|
477
|
+
if (!fs.existsSync(dshCoreDataRoot())) return Promise.resolve({ artifacts: [], revision: 0, dropped: 0 });
|
|
478
|
+
return withReader((runtime) => runtime.run('inspect', ({ dataRoot }) =>
|
|
479
|
+
listCoreArtifactMetadata(dataRoot)), options.signal);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
export function inspectWorkbenchArtifactForDsh(artifactId, options = {}) {
|
|
483
|
+
return withReader(runtime => runtime.run('inspect', ({ dataRoot }) =>
|
|
484
|
+
inspectCoreArtifact(dataRoot, artifactId)), options.signal);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** 工作台管理沿用 CLI 的维护命令;预览/事务列表只读,执行使用既有串行 writer。 */
|
|
488
|
+
export function maintainWorkbenchForDsh(command, input, options = {}) {
|
|
489
|
+
if (!['core.delete', 'core.transactions', 'core.restore'].includes(command)) {
|
|
490
|
+
throw new DshCoreAdapterError('IRIS_COMMAND_INPUT_INVALID', '不支持的作品管理命令');
|
|
491
|
+
}
|
|
492
|
+
if (command === 'core.transactions' && !fs.existsSync(dshCoreDataRoot())) return Promise.resolve({ transactions: [] });
|
|
493
|
+
if (command === 'core.restore' || input.confirm_delete === true) {
|
|
494
|
+
return withWriter((_runtime, commands) => commands.execute(command, input), options.signal);
|
|
495
|
+
}
|
|
496
|
+
return withReader(runtime => createCommandService(runtime).execute(command, input), options.signal);
|
|
497
|
+
}
|
|
498
|
+
|
|
447
499
|
/**
|
|
448
500
|
* 工作台只读候选快照;数据根不存在时不创建任何目录。
|
|
449
501
|
* userTasks 是用户任务区消费的五类状态安全投影(lib/core-user-projection.js),
|
|
@@ -533,8 +585,9 @@ export async function readCoreArtifactMediaForDsh(artifactId, options = {}) {
|
|
|
533
585
|
const result = await withReader((runtime) => runtime.run('inspect', ({ dataRoot }) =>
|
|
534
586
|
readCoreArtifactBytes(dataRoot, id)), options.signal);
|
|
535
587
|
if (!result.artifact.mediaType.startsWith('image/') && result.artifact.mediaType !== 'video/mp4'
|
|
536
|
-
&&
|
|
537
|
-
|
|
588
|
+
&& result.artifact.mediaType !== 'video/webm' && !result.artifact.mediaType.startsWith('audio/')
|
|
589
|
+
&& result.artifact.mediaType !== 'text/plain' && result.artifact.mediaType !== 'application/json') {
|
|
590
|
+
throw new DshCoreAdapterError('IRIS_DSH_ARTIFACT_UNSUPPORTED', '当前 DSH 媒体预览不支持此 Artifact 类型');
|
|
538
591
|
}
|
|
539
592
|
return result;
|
|
540
593
|
} catch (error) {
|
|
@@ -568,10 +621,11 @@ export async function projectCoreTaskForDsh(host, taskId, options = {}) {
|
|
|
568
621
|
}
|
|
569
622
|
let attachment;
|
|
570
623
|
try {
|
|
624
|
+
const extension = item.artifact.mediaType === 'image/jpeg' ? 'jpg' : item.artifact.mediaType.slice(6);
|
|
571
625
|
attachment = await attachments.saveImage({
|
|
572
626
|
data: new Uint8Array(item.bytes),
|
|
573
627
|
mediaType: item.artifact.mediaType,
|
|
574
|
-
name: 'iris-core-' + item.artifact.id + '.
|
|
628
|
+
name: 'iris-core-' + item.artifact.id + '.' + extension
|
|
575
629
|
});
|
|
576
630
|
} catch (_) {
|
|
577
631
|
throw new DshCoreAdapterError('IRIS_DSH_PROJECTION_FAILED', 'Core 产物无法保存为 DSH attachment');
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/** DSH textModel 的单轮协议映射;宿主路由/会话封闭在适配器,不进入共享请求。 */
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { ModelPortError, modelErrorRecord, normalizeModelDescriptor, normalizeModelCall,
|
|
5
|
+
normalizeModelCompletion, createModelTextCollector } from './model-port-contract.js';
|
|
6
|
+
|
|
7
|
+
const OFF_IDS = new Set(['off', 'none', 'disabled', 'disable', 'no-thinking']);
|
|
8
|
+
const validId = value => typeof value === 'string' && value.length > 0 && value.length <= 256
|
|
9
|
+
&& value === value.trim() && !/[\u0000-\u001f\u007f]/.test(value) && !value.startsWith('/') && !value.includes('://');
|
|
10
|
+
|
|
11
|
+
function abortError(signal, stage, invocation) {
|
|
12
|
+
const code = modelErrorRecord(signal?.reason).code === 'IRIS_MODEL_TIMEOUT' ? 'IRIS_MODEL_TIMEOUT' : 'IRIS_MODEL_ABORTED';
|
|
13
|
+
return new ModelPortError(code, { stage, invocation });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** 信号传入底层;race 仅保证不合作的元数据/迭代器不会让本地永远挂住。 */
|
|
17
|
+
async function wait(work, signal, stage, invocation) {
|
|
18
|
+
if (!signal) return work;
|
|
19
|
+
let onAbort;
|
|
20
|
+
const canceled = new Promise((_, reject) => {
|
|
21
|
+
onAbort = () => reject(abortError(signal, stage, invocation));
|
|
22
|
+
if (signal.aborted) onAbort();
|
|
23
|
+
else signal.addEventListener('abort', onAbort, { once: true });
|
|
24
|
+
});
|
|
25
|
+
try { return await Promise.race([work, canceled]); }
|
|
26
|
+
finally { signal.removeEventListener('abort', onAbort); }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function descriptorFor(binding, metadata, kind = 'text') {
|
|
30
|
+
const efforts = metadata?.reasoning?.efforts;
|
|
31
|
+
const ids = Array.isArray(efforts) ? [...new Set(efforts.map(value => value?.id).filter(validId))] : [];
|
|
32
|
+
const offId = ids.find(id => OFF_IDS.has(id.toLowerCase()));
|
|
33
|
+
const known = Array.isArray(efforts) && efforts.every(value => validId(value?.id));
|
|
34
|
+
return { descriptor: normalizeModelDescriptor({ contractVersion: 0, kind,
|
|
35
|
+
identity: { origin: 'host', backendId: 'dsh-' + kind + ':' + crypto.createHash('sha256').update(JSON.stringify([binding.provider, binding.model])).digest('hex'),
|
|
36
|
+
providerId: binding.provider, modelId: binding.model }, availability: 'available',
|
|
37
|
+
// 这些字段有明确的 DSH GenerateOptions 映射,不代表已证明远端模型能力。
|
|
38
|
+
features: { system: 'supported', temperature: 'supported', maxOutputTokens: 'supported', reasoning: known ? 'supported' : 'unknown' },
|
|
39
|
+
...(known ? { reasoning: { off: offId ? 'supported' : 'unsupported', effortIds: ids } } : {}) }), offId: known ? offId : undefined };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function usageSnapshot(value) {
|
|
43
|
+
if (!value || typeof value !== 'object') return undefined;
|
|
44
|
+
const usage = {};
|
|
45
|
+
for (const key of ['outputTokens', 'totalTokens', 'reasoningTokens']) {
|
|
46
|
+
if (Number.isSafeInteger(value[key]) && value[key] >= 0) usage[key] = value[key];
|
|
47
|
+
}
|
|
48
|
+
// rc.2 inputTokens 不含缓存;这里只把权威的三个不重叠输入计数相加。
|
|
49
|
+
const inputs = [value.inputTokens, value.cacheReadTokens ?? 0, value.cacheWriteTokens ?? 0];
|
|
50
|
+
const inputTotal = inputs.reduce((sum, count) => sum + count, 0);
|
|
51
|
+
if (inputs.every(count => Number.isSafeInteger(count) && count >= 0) && Number.isSafeInteger(inputTotal)) usage.inputTokens = inputTotal;
|
|
52
|
+
return Object.keys(usage).length ? usage : undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 元数据解析显式进行且受同一操作信号约束;describe 永远只复制已绑定事实。 */
|
|
56
|
+
export async function prepareDshTextModelPort(textModel, binding, { signal } = {}) {
|
|
57
|
+
if (signal?.aborted) throw abortError(signal, 'prepare', 'not_invoked');
|
|
58
|
+
if (!textModel || typeof textModel.stream !== 'function') throw new ModelPortError('IRIS_MODEL_UNAVAILABLE');
|
|
59
|
+
if (!validId(binding?.provider) || !validId(binding?.model)) throw new ModelPortError('IRIS_MODEL_INPUT_INVALID');
|
|
60
|
+
const route = { provider: binding.provider, model: binding.model };
|
|
61
|
+
const sessionId = typeof binding.sessionId === 'string' && binding.sessionId.trim() && binding.sessionId.length <= 256 ? binding.sessionId.trim() : undefined;
|
|
62
|
+
let metadata;
|
|
63
|
+
if (typeof textModel.resolveModelInfo === 'function') {
|
|
64
|
+
try { metadata = await wait(Promise.resolve().then(() => {
|
|
65
|
+
if (signal?.aborted) throw abortError(signal, 'prepare', 'not_invoked');
|
|
66
|
+
return textModel.resolveModelInfo(route.provider, route.model, signal);
|
|
67
|
+
}), signal, 'prepare', 'not_invoked'); }
|
|
68
|
+
catch (_) { if (signal?.aborted) throw abortError(signal, 'prepare', 'not_invoked'); }
|
|
69
|
+
}
|
|
70
|
+
if (signal?.aborted) throw abortError(signal, 'prepare', 'not_invoked');
|
|
71
|
+
return createDshTextModelPort(textModel, { ...route, sessionId }, { metadata });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 已绑定事实的纯构造;用于缓存元数据/协议 fixture,不执行元数据查询。 */
|
|
75
|
+
export function createDshTextModelPort(textModel, binding, { metadata } = {}) {
|
|
76
|
+
return createDshBoundModelPort(textModel, binding, { metadata });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 文本与视觉共用经过验证的 DSH 事件读取器;图片桥接仅由视觉适配器注入。 */
|
|
80
|
+
export function createDshBoundModelPort(textModel, binding, { metadata, prepareImage, prepareImageRequest, kind = 'text' } = {}) {
|
|
81
|
+
if (!textModel || typeof textModel.stream !== 'function') throw new ModelPortError('IRIS_MODEL_UNAVAILABLE');
|
|
82
|
+
if (!validId(binding?.provider) || !validId(binding?.model)) throw new ModelPortError('IRIS_MODEL_INPUT_INVALID');
|
|
83
|
+
const route = { provider: binding.provider, model: binding.model };
|
|
84
|
+
// 不接受绑定身份与解析出的模型漂移;旧 fixture 未提供身份时保持 unknown。
|
|
85
|
+
if (metadata && (metadata.provider !== undefined && metadata.provider !== route.provider || metadata.id !== undefined && metadata.id !== route.model)) {
|
|
86
|
+
throw new ModelPortError('IRIS_MODEL_INCOMPATIBLE', { stage: 'prepare', invocation: 'not_invoked' });
|
|
87
|
+
}
|
|
88
|
+
const sessionId = typeof binding.sessionId === 'string' && binding.sessionId.trim() && binding.sessionId.length <= 256 ? binding.sessionId.trim() : undefined;
|
|
89
|
+
const { descriptor, offId } = descriptorFor(route, metadata, kind);
|
|
90
|
+
const port = Object.freeze({
|
|
91
|
+
describe() { return normalizeModelDescriptor(descriptor); },
|
|
92
|
+
async complete(request, options) {
|
|
93
|
+
const input = normalizeModelCall(descriptor, request, options);
|
|
94
|
+
if (input.options.signal?.aborted) throw abortError(input.options.signal, 'invoke', 'not_invoked');
|
|
95
|
+
const controller = new AbortController();
|
|
96
|
+
const parent = input.options.signal;
|
|
97
|
+
const onAbort = () => controller.abort(parent.reason);
|
|
98
|
+
parent?.addEventListener('abort', onAbort, { once: true });
|
|
99
|
+
const deadline = performance.now() + input.options.budget.timeoutMs;
|
|
100
|
+
let timer;
|
|
101
|
+
const scheduleDeadline = () => {
|
|
102
|
+
const remaining = deadline - performance.now();
|
|
103
|
+
if (remaining <= 0) controller.abort(new ModelPortError('IRIS_MODEL_TIMEOUT'));
|
|
104
|
+
else timer = setTimeout(scheduleDeadline, Math.min(2147483647, Math.ceil(remaining)));
|
|
105
|
+
};
|
|
106
|
+
scheduleDeadline();
|
|
107
|
+
const signal = controller.signal;
|
|
108
|
+
const collector = createModelTextCollector({ maxOutputChars: input.options.budget.maxOutputChars, signal, backendId: descriptor.identity.backendId });
|
|
109
|
+
const sourceRequest = { ...route, messages: [{ role: 'user', content: [{ type: 'text', text: input.request.prompt }] }],
|
|
110
|
+
...(input.request.system === undefined ? {} : { system: input.request.system }), signal,
|
|
111
|
+
...(sessionId ? { sessionId } : {}) };
|
|
112
|
+
if (input.request.generation?.temperature !== undefined) sourceRequest.temperature = input.request.generation.temperature;
|
|
113
|
+
if (input.request.generation?.maxOutputTokens !== undefined) sourceRequest.maxTokens = input.request.generation.maxOutputTokens;
|
|
114
|
+
const reasoning = input.request.generation?.reasoning;
|
|
115
|
+
if (reasoning?.mode === 'off') sourceRequest.reasoningEffort = offId;
|
|
116
|
+
if (reasoning?.mode === 'effort') sourceRequest.reasoningEffort = reasoning.effortId;
|
|
117
|
+
let iterator;
|
|
118
|
+
let usage;
|
|
119
|
+
let finished = false;
|
|
120
|
+
let ended = false;
|
|
121
|
+
let invoked = false;
|
|
122
|
+
let responded = false;
|
|
123
|
+
let prepared;
|
|
124
|
+
const blocks = new Map();
|
|
125
|
+
const blockId = index => {
|
|
126
|
+
if (!Number.isSafeInteger(index) || index < 0) throw new ModelPortError('IRIS_MODEL_PROTOCOL_INVALID', { stage: 'read', invocation: 'unknown' });
|
|
127
|
+
return String(index);
|
|
128
|
+
};
|
|
129
|
+
try {
|
|
130
|
+
if (kind === 'vision') {
|
|
131
|
+
if (typeof prepareImage !== 'function') throw new ModelPortError('IRIS_MODEL_UNAVAILABLE', { stage: 'prepare' });
|
|
132
|
+
const preparedImage = await wait(Promise.resolve().then(() => {
|
|
133
|
+
if (signal.aborted) throw abortError(signal, 'prepare', 'not_invoked');
|
|
134
|
+
return prepareImage(input.request.image, signal);
|
|
135
|
+
}), signal, 'prepare', 'not_invoked');
|
|
136
|
+
if (signal.aborted) throw abortError(signal, 'prepare', 'not_invoked');
|
|
137
|
+
prepared = await wait(Promise.resolve().then(() => prepareImageRequest?.({ request: input.request,
|
|
138
|
+
image: preparedImage.image, signal, identity: descriptor.identity })), signal, 'prepare', 'not_invoked');
|
|
139
|
+
const prompt = prepared?.prompt ?? input.request.prompt;
|
|
140
|
+
normalizeModelCall(descriptor, { ...input.request, image: preparedImage.image, prompt }, input.options);
|
|
141
|
+
sourceRequest.messages[0].content[0].text = prompt;
|
|
142
|
+
sourceRequest.messages[0].content.push({ type: 'image', attachment: preparedImage.attachment });
|
|
143
|
+
}
|
|
144
|
+
invoked = true;
|
|
145
|
+
const stream = textModel.stream(sourceRequest);
|
|
146
|
+
if (!stream || typeof stream[Symbol.asyncIterator] !== 'function') throw new ModelPortError('IRIS_MODEL_PROTOCOL_INVALID', { stage: 'invoke', invocation: 'unknown' });
|
|
147
|
+
iterator = stream[Symbol.asyncIterator]();
|
|
148
|
+
while (true) {
|
|
149
|
+
const next = await wait(Promise.resolve().then(() => iterator.next()), signal, 'read', responded ? 'responded' : 'unknown');
|
|
150
|
+
if (signal.aborted) throw abortError(signal, 'read', responded ? 'responded' : 'unknown');
|
|
151
|
+
if (next.done) { ended = true; break; }
|
|
152
|
+
const chunk = next.value;
|
|
153
|
+
if (!chunk || typeof chunk !== 'object' || finished) throw new ModelPortError('IRIS_MODEL_PROTOCOL_INVALID', { stage: 'read', invocation: 'responded' });
|
|
154
|
+
if (chunk.type === 'block-start') {
|
|
155
|
+
const id = blockId(chunk.index);
|
|
156
|
+
if (!['text', 'reasoning'].includes(chunk.blockType)) collector.finish(chunk.blockType === 'tool-call' ? 'tool-calls' : 'unknown');
|
|
157
|
+
if (blocks.has(id)) collector.finish('unknown');
|
|
158
|
+
blocks.set(id, chunk.blockType);
|
|
159
|
+
if (chunk.blockType === 'text') collector.append('', id);
|
|
160
|
+
} else if (chunk.type === 'text-delta') {
|
|
161
|
+
const id = blockId(chunk.index);
|
|
162
|
+
if (blocks.has(id) && blocks.get(id) !== 'text') collector.finish('unknown');
|
|
163
|
+
blocks.set(id, 'text');
|
|
164
|
+
if (typeof chunk.text === 'string' && chunk.text.length) responded = true;
|
|
165
|
+
collector.append(chunk.text, id);
|
|
166
|
+
} else if (chunk.type === 'reasoning-delta') {
|
|
167
|
+
const id = blockId(chunk.index);
|
|
168
|
+
if (typeof chunk.text !== 'string' || blocks.has(id) && blocks.get(id) !== 'reasoning') collector.finish('unknown');
|
|
169
|
+
blocks.set(id, 'reasoning');
|
|
170
|
+
} else if (chunk.type === 'block-end') {
|
|
171
|
+
const id = blockId(chunk.index);
|
|
172
|
+
if (chunk.block?.type === 'text') {
|
|
173
|
+
if (blocks.has(id) && blocks.get(id) !== 'text') collector.finish('unknown');
|
|
174
|
+
blocks.set(id, 'text');
|
|
175
|
+
if (typeof chunk.block.text === 'string' && chunk.block.text.length) responded = true;
|
|
176
|
+
collector.replace(chunk.block.text, id);
|
|
177
|
+
} else if (chunk.block?.type === 'reasoning') {
|
|
178
|
+
if (typeof chunk.block.text !== 'string' || blocks.has(id) && blocks.get(id) !== 'reasoning') collector.finish('unknown');
|
|
179
|
+
blocks.set(id, 'reasoning');
|
|
180
|
+
} else collector.finish(chunk.block?.type === 'tool-call' ? 'tool-calls' : 'unknown');
|
|
181
|
+
} else if (chunk.type === 'tool-call-delta') collector.finish('tool-calls');
|
|
182
|
+
else if (chunk.type === 'usage') usage = usageSnapshot(chunk.usage);
|
|
183
|
+
else if (chunk.type === 'finish') {
|
|
184
|
+
const kind = chunk.reason?.kind;
|
|
185
|
+
if (kind === 'aborted') throw new ModelPortError('IRIS_MODEL_ABORTED', { stage: 'read', invocation: responded ? 'responded' : 'unknown' });
|
|
186
|
+
if (kind === 'error') collector.fail(new ModelPortError('IRIS_MODEL_REQUEST_FAILED', { stage: 'read', invocation: 'unknown' }));
|
|
187
|
+
collector.finish(kind); finished = true;
|
|
188
|
+
} else if (chunk.type === 'error') collector.fail(chunk.error);
|
|
189
|
+
else if (chunk.type === undefined && typeof chunk.delta === 'string') {
|
|
190
|
+
if (chunk.delta.length) responded = true;
|
|
191
|
+
collector.append(chunk.delta, '0');
|
|
192
|
+
}
|
|
193
|
+
else collector.finish('unknown');
|
|
194
|
+
}
|
|
195
|
+
const collected = collector.complete();
|
|
196
|
+
const text = prepared?.mapText ? prepared.mapText(collected) : collected;
|
|
197
|
+
return normalizeModelCompletion(descriptor, { contractVersion: 0, text, finishReason: 'stop', identity: descriptor.identity,
|
|
198
|
+
...(usage ? { usage } : {}) }, input.options.budget);
|
|
199
|
+
} catch (error) {
|
|
200
|
+
if (signal.aborted) throw abortError(signal, invoked ? 'read' : 'prepare', responded ? 'responded' : invoked ? 'unknown' : 'not_invoked');
|
|
201
|
+
if (!invoked) {
|
|
202
|
+
controller.abort(error);
|
|
203
|
+
const record = modelErrorRecord(error);
|
|
204
|
+
throw new ModelPortError(record.code, { stage: 'prepare', invocation: 'not_invoked',
|
|
205
|
+
...(record.imageBytes ? { imageBytes: record.imageBytes } : {}),
|
|
206
|
+
...(record.imageMaxBytes ? { imageMaxBytes: record.imageMaxBytes } : {}) });
|
|
207
|
+
}
|
|
208
|
+
// 只有受控模型错误可以离开协议边界;供应商原文不进入 UI/日志。
|
|
209
|
+
try { collector.fail(error); }
|
|
210
|
+
finally { controller.abort(error); }
|
|
211
|
+
} finally {
|
|
212
|
+
try {
|
|
213
|
+
if (!ended && iterator && typeof iterator.return === 'function') {
|
|
214
|
+
const closing = Promise.resolve().then(() => iterator.return());
|
|
215
|
+
await wait(closing, signal, 'read', 'unknown').catch(() => {});
|
|
216
|
+
}
|
|
217
|
+
} finally { clearTimeout(timer); parent?.removeEventListener('abort', onAbort); }
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
return Object.freeze({ port, offId });
|
|
222
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
import { ModelPortError } from './model-port-contract.js';
|
|
3
|
+
import { createDshBoundModelPort } from './dsh-text-model-adapter.js';
|
|
4
|
+
import { waitForModelWork } from './model-call-runtime.js';
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
import sharp from 'sharp';
|
|
7
|
+
import { prepareVisionImage } from './vision-image-input.js';
|
|
8
|
+
|
|
9
|
+
/** rc.2 只接受附件引用;业务钩子使用核验后真正发给宿主的图片。 */
|
|
10
|
+
export function createDshVisionModelPort(textModel, attachments, binding, { metadata, allowImageNormalization = false,
|
|
11
|
+
imageInput, orientImages = false, prepareImageRequest } = {}) {
|
|
12
|
+
if (!attachments || typeof attachments.saveImage !== 'function' || typeof attachments.readImage !== 'function') {
|
|
13
|
+
throw new ModelPortError('IRIS_MODEL_UNAVAILABLE', { stage: 'prepare' });
|
|
14
|
+
}
|
|
15
|
+
if (!metadata || metadata.provider !== binding?.provider || metadata.id !== binding?.model
|
|
16
|
+
|| !Array.isArray(metadata.inputModalities) || !metadata.inputModalities.includes('image')) {
|
|
17
|
+
throw new ModelPortError('IRIS_MODEL_INCOMPATIBLE', { stage: 'prepare' });
|
|
18
|
+
}
|
|
19
|
+
return createDshBoundModelPort(textModel, binding, { metadata, kind: 'vision', prepareImageRequest,
|
|
20
|
+
async prepareImage(image, signal) {
|
|
21
|
+
if (imageInput) image = await prepareVisionImage(image, imageInput, { signal, orient: orientImages });
|
|
22
|
+
const data = new Uint8Array(image.bytes);
|
|
23
|
+
// saveImage 在 rc.2 无取消参数;等待有界,迟到保存可留下 Host 附件,但绝不启动生成。
|
|
24
|
+
const ref = await waitForModelWork(() => attachments.saveImage({ data, mediaType: image.mediaType, name: 'iris-vision' }), signal);
|
|
25
|
+
if (!ref || typeof ref.attachmentId !== 'string' || !ref.attachmentId) {
|
|
26
|
+
throw new ModelPortError('IRIS_MODEL_INCOMPATIBLE', { stage: 'prepare' });
|
|
27
|
+
}
|
|
28
|
+
const stored = await waitForModelWork(() => attachments.readImage(ref, signal), signal);
|
|
29
|
+
if (!(stored?.data instanceof Uint8Array) || stored.mediaType !== undefined && stored.mediaType !== ref.mediaType) {
|
|
30
|
+
throw new ModelPortError('IRIS_MODEL_INCOMPATIBLE', { stage: 'prepare' });
|
|
31
|
+
}
|
|
32
|
+
const unchanged = ref.mediaType === image.mediaType && Buffer.from(stored.data).equals(Buffer.from(image.bytes));
|
|
33
|
+
if (!unchanged) {
|
|
34
|
+
// 正规宿主附件用内容哈希绑定读回字节;任意旧引用/损坏数据不能冒充压缩副本。
|
|
35
|
+
if (!allowImageNormalization || ref.attachmentId !== 'sha256:' + createHash('sha256').update(stored.data).digest('hex')
|
|
36
|
+
|| ref.bytes !== stored.data.byteLength) throw new ModelPortError('IRIS_MODEL_INCOMPATIBLE', { stage: 'prepare' });
|
|
37
|
+
const [source, normalized] = await waitForModelWork(() => Promise.all([
|
|
38
|
+
sharp(Buffer.from(image.bytes)).metadata(), sharp(Buffer.from(stored.data)).metadata()
|
|
39
|
+
]), signal);
|
|
40
|
+
const original = ref.originalDimensions || ref;
|
|
41
|
+
const width = source.autoOrient?.width || source.width, height = source.autoOrient?.height || source.height;
|
|
42
|
+
if (original.width !== width || original.height !== height || ref.width !== normalized.width || ref.height !== normalized.height
|
|
43
|
+
|| ref.width > width || ref.height > height
|
|
44
|
+
|| ref.mediaType !== { jpeg: 'image/jpeg', png: 'image/png', webp: 'image/webp', gif: 'image/gif' }[normalized.format]) {
|
|
45
|
+
throw new ModelPortError('IRIS_MODEL_INCOMPATIBLE', { stage: 'prepare' });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return { attachment: ref, image: { bytes: new Uint8Array(stored.data), mediaType: ref.mediaType } };
|
|
49
|
+
}
|
|
50
|
+
}).port;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 只在显式调用时读取默认选择/精确元数据;不把默认文本模型猜成视觉模型。 */
|
|
54
|
+
export async function prepareDshVisionModelPort(textModel, attachments, { signal, ...options } = {}) {
|
|
55
|
+
try { return await waitForModelWork(async () => {
|
|
56
|
+
if (typeof textModel?.currentSelection !== 'function' || typeof textModel?.resolveModelInfo !== 'function') {
|
|
57
|
+
throw new ModelPortError('IRIS_MODEL_UNAVAILABLE', { stage: 'prepare' });
|
|
58
|
+
}
|
|
59
|
+
if (typeof attachments?.saveImage !== 'function' || typeof attachments?.readImage !== 'function') {
|
|
60
|
+
throw new ModelPortError('IRIS_MODEL_UNAVAILABLE', { stage: 'prepare' });
|
|
61
|
+
}
|
|
62
|
+
const selection = textModel.currentSelection();
|
|
63
|
+
if (typeof selection?.provider !== 'string' || !selection.provider.trim()
|
|
64
|
+
|| typeof selection?.model !== 'string' || !selection.model.trim()) {
|
|
65
|
+
throw new ModelPortError('IRIS_MODEL_UNAVAILABLE', { stage: 'prepare' });
|
|
66
|
+
}
|
|
67
|
+
const binding = { provider: selection.provider, model: selection.model };
|
|
68
|
+
const metadata = await waitForModelWork(() => textModel.resolveModelInfo(binding.provider, binding.model, signal), signal);
|
|
69
|
+
return createDshVisionModelPort(textModel, attachments, binding, { metadata, ...options });
|
|
70
|
+
}, signal); }
|
|
71
|
+
catch (error) {
|
|
72
|
+
if (error instanceof ModelPortError) throw error;
|
|
73
|
+
throw new ModelPortError('IRIS_MODEL_REQUEST_FAILED', { stage: 'prepare', invocation: 'not_invoked' });
|
|
74
|
+
}
|
|
75
|
+
}
|
package/lib/generation-input.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import fs from 'node:fs';
|
|
4
5
|
|
|
5
6
|
const FIELDS = Object.freeze({
|
|
6
|
-
image: ['prompt', 'size', 'n'],
|
|
7
|
-
video: ['prompt', 'size', 'duration', 'img_data_url'],
|
|
7
|
+
image: ['prompt', 'size', 'n', 'source_artifact_id'],
|
|
8
|
+
video: ['prompt', 'size', 'duration', 'img_data_url', 'first_frame_path', 'audio_path', 'resolution'],
|
|
8
9
|
tts: ['text', 'voice'],
|
|
9
10
|
transcribe: ['audio_url', 'audio_path']
|
|
10
11
|
});
|
|
@@ -33,7 +34,7 @@ function text(value, field, maxLength, required = false) {
|
|
|
33
34
|
* 不读取配置或文件,不上传,不从旧 Task 恢复 Prompt/媒体地址。
|
|
34
35
|
* Host 附件解析和本地音频上传仍由入口负责。
|
|
35
36
|
*/
|
|
36
|
-
export function normalizeGenerationInput(capability, input, { allowAudioPath = false } = {}) {
|
|
37
|
+
export function normalizeGenerationInput(capability, input, { allowAudioPath = false, allowVideoPaths = false } = {}) {
|
|
37
38
|
const fields = Object.hasOwn(FIELDS, capability) ? FIELDS[capability] : null;
|
|
38
39
|
if (!fields) fail('不支持的生成能力');
|
|
39
40
|
if (!input || typeof input !== 'object' || Array.isArray(input)) fail('生成输入必须是 JSON 对象');
|
|
@@ -45,12 +46,40 @@ export function normalizeGenerationInput(capability, input, { allowAudioPath = f
|
|
|
45
46
|
const modelRef = String(input.model_ref || '').trim();
|
|
46
47
|
const providerInput = {};
|
|
47
48
|
let audioPath;
|
|
49
|
+
let sourceArtifactId;
|
|
50
|
+
let firstFramePath, videoMode;
|
|
51
|
+
if (capability === 'video') {
|
|
52
|
+
audioPath = String(input.audio_path || '').trim();
|
|
53
|
+
firstFramePath = String(input.first_frame_path || '').trim();
|
|
54
|
+
if ((audioPath || firstFramePath) && !allowVideoPaths) fail('本地视频输入需要支持上传的入口');
|
|
55
|
+
if (firstFramePath && input.img_data_url !== undefined) fail('首帧路径与 img_data_url 不能同时提供');
|
|
56
|
+
for (const file of [firstFramePath, audioPath].filter(Boolean)) {
|
|
57
|
+
if (!path.isAbsolute(file)) fail('首帧和音频路径必须是绝对路径');
|
|
58
|
+
try { if (!fs.statSync(file).isFile()) throw new Error(); }
|
|
59
|
+
catch (_) { fail('首帧或音频文件不存在或不是普通文件'); }
|
|
60
|
+
}
|
|
61
|
+
if (audioPath && !firstFramePath) fail('s2v 需要 first_frame_path 和 audio_path');
|
|
62
|
+
videoMode = audioPath ? 's2v' : firstFramePath || input.img_data_url ? 'i2v' : 't2v';
|
|
63
|
+
if (videoMode === 's2v') {
|
|
64
|
+
if (input.size !== undefined || input.duration !== undefined || input.img_data_url !== undefined) fail('s2v 不支持 size、duration 或 img_data_url');
|
|
65
|
+
const resolution = input.resolution ?? '480P';
|
|
66
|
+
if (!['480P', '720P'].includes(resolution)) fail('s2v resolution 只支持 480P 或 720P');
|
|
67
|
+
providerInput.resolution = resolution;
|
|
68
|
+
} else if (input.resolution !== undefined) fail('resolution 仅适用于 s2v');
|
|
69
|
+
}
|
|
48
70
|
if (capability === 'image' || capability === 'video') {
|
|
49
|
-
|
|
71
|
+
const prompt = text(input.prompt, 'prompt', 20000, videoMode !== 's2v');
|
|
72
|
+
if (prompt !== undefined) providerInput.prompt = prompt;
|
|
50
73
|
const size = text(input.size, 'size', 64);
|
|
51
74
|
if (size !== undefined) providerInput.size = size;
|
|
52
75
|
}
|
|
53
76
|
if (capability === 'image') {
|
|
77
|
+
if (input.source_artifact_id !== undefined) {
|
|
78
|
+
if (typeof input.source_artifact_id !== 'string' || !/^artifact_[a-f0-9]{24}$/.test(input.source_artifact_id)) {
|
|
79
|
+
fail('source_artifact_id 必须是有效的 Core Artifact ID');
|
|
80
|
+
}
|
|
81
|
+
sourceArtifactId = input.source_artifact_id;
|
|
82
|
+
}
|
|
54
83
|
const n = input.n === undefined ? 1 : Number(input.n);
|
|
55
84
|
if (!Number.isSafeInteger(n) || n < 1 || n > 4) fail('n 必须为 1–4 的整数');
|
|
56
85
|
providerInput.n = n;
|
|
@@ -90,6 +119,8 @@ export function normalizeGenerationInput(capability, input, { allowAudioPath = f
|
|
|
90
119
|
return Object.freeze({
|
|
91
120
|
modelRef,
|
|
92
121
|
providerInput: Object.freeze(providerInput),
|
|
93
|
-
...(
|
|
122
|
+
...(sourceArtifactId ? { sourceArtifactId } : {}),
|
|
123
|
+
...(audioPath ? { audioPath } : {}),
|
|
124
|
+
...(capability === 'video' ? { firstFramePath, videoMode } : {})
|
|
94
125
|
});
|
|
95
126
|
}
|