@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
package/lib/actions.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
+
import { visionInputLimits } from './vision-image-input.js';
|
|
2
3
|
/**
|
|
3
4
|
* Iris 动作执行层(阶段 5 GUI 操作面板)。
|
|
4
5
|
*
|
|
@@ -11,6 +12,7 @@
|
|
|
11
12
|
*/
|
|
12
13
|
import fs from 'node:fs';
|
|
13
14
|
import path from 'node:path';
|
|
15
|
+
import crypto from 'node:crypto';
|
|
14
16
|
import * as adapters from './adapters.js';
|
|
15
17
|
import * as store from './config.js';
|
|
16
18
|
import * as cap from './capability.js';
|
|
@@ -18,28 +20,35 @@ import * as models from './models.js';
|
|
|
18
20
|
import * as tasks from './tasks.js';
|
|
19
21
|
import * as artifacts from './artifacts.js';
|
|
20
22
|
import { registerMedia, mediaLinksOf, webBase } from './media.js';
|
|
21
|
-
import {
|
|
23
|
+
import { RED_TEST_IMAGE } from './vision.js';
|
|
24
|
+
import { runVisionRequest, readVisionImageFile, probeVisionModel, visionImageFromDataUrl } from './vision-model-routing.js';
|
|
22
25
|
import { HOST_CONTRACT_VERSION, HOST_PORTS, defineHostAdapter, hasHostPort, requireHostPort } from './host-contract.js';
|
|
23
|
-
import { coreSnapshotForDsh, cropForDsh, diffForDsh, framesForDsh, inspectProviderTaskForDsh, submitProviderTaskForDsh, watchProviderTaskForDsh } from './dsh-core-adapter.js';
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
26
|
+
import { coreSnapshotForDsh, cropForDsh, diffForDsh, framesForDsh, summaryFramesForDsh, visionImageForDsh, htmlForDsh, inspectProviderTaskForDsh, submitProviderTaskForDsh, watchProviderTaskForDsh } from './dsh-core-adapter.js';
|
|
27
|
+
import { runLocateRequest, runSummaryRequest } from './composite-vision-routing.js';
|
|
28
|
+
import { normalizeSummarySource, summaryFrameRecords, summaryTimelineLabel } from './summary-input.js';
|
|
29
|
+
import { normalizeVisionImageSource } from './vision-input.js';
|
|
30
|
+
import { formatOcrResult } from './ocr.js';
|
|
31
|
+
import { runOcrRequest } from './ocr-model-routing.js';
|
|
27
32
|
import { ffmpegAvailable, extractFrames, probeVideo } from './media-probe.js';
|
|
28
|
-
import { buildContactSheet, summarizeMedia } from './summarize.js';
|
|
29
33
|
import { ensurePrivateDir, writePrivateFile } from './private-storage.js';
|
|
30
|
-
import { selectMediaProtocol } from './provider-protocol.js';
|
|
34
|
+
import { selectMediaProtocol, selectImageProtocol, providerForImageModel } from './provider-protocol.js';
|
|
31
35
|
import { providerTaskBinding } from './provider-catalog.js';
|
|
32
|
-
import { ProviderContractError, submitWithAcceptanceBoundary } from './provider-contract.js';
|
|
33
36
|
import { invokeProviderOperation, providerPollTaskView, prepareProviderInput } from './provider-adapter.js';
|
|
34
|
-
import { createConfiguredProviderAdapter } from './provider-adapters.js';
|
|
37
|
+
import { createConfiguredProviderAdapter as createProviderAdapter } from './provider-adapters.js';
|
|
35
38
|
import { deriveUserState } from './task-semantics.js';
|
|
36
39
|
import { normalizeGenerationInput } from './generation-input.js';
|
|
40
|
+
import { videoTaskCandidates } from './video-input.js';
|
|
37
41
|
|
|
38
42
|
const MEDIA_TYPES = {
|
|
39
43
|
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
40
44
|
'.webp': 'image/webp', '.gif': 'image/gif'
|
|
41
45
|
};
|
|
42
46
|
|
|
47
|
+
function createConfiguredProviderAdapter(provider, options = {}) {
|
|
48
|
+
return createProviderAdapter(provider, { ...options,
|
|
49
|
+
onRateLimit: options.allowRateLimited ? undefined : store.recordRateLimit });
|
|
50
|
+
}
|
|
51
|
+
|
|
43
52
|
const EMPTY_ACTION_HOST = defineHostAdapter({
|
|
44
53
|
id: 'iris-hostless',
|
|
45
54
|
ports: {},
|
|
@@ -59,22 +68,6 @@ function actionHost(value) {
|
|
|
59
68
|
// 工作台清空可选文本框会发送空字符串;在 Host 边界解释为未提供,保持默认值。
|
|
60
69
|
function optionalActionText(value) { return value === '' ? undefined : value; }
|
|
61
70
|
|
|
62
|
-
/* ---------------- 视觉效果自述(辅助 iris_draw_image) ---------------- */
|
|
63
|
-
async function describeFirstImage(host, ref, absPath, originalPrompt) {
|
|
64
|
-
try {
|
|
65
|
-
const mt = MEDIA_TYPES[path.extname(absPath).toLowerCase()] || 'image/png';
|
|
66
|
-
const buf = fs.readFileSync(absPath);
|
|
67
|
-
const dataUrl = `data:${mt};base64,${buf.toString('base64')}`;
|
|
68
|
-
const backends = buildVisionBackendsFromHost(host, { providers: store.pickAllFor(cap.CAPABILITIES.VISION) });
|
|
69
|
-
const { answer } = await askWithHealth(backends, {
|
|
70
|
-
question: `用不超过两句话描述这张图片的主题与构图。生成该图的提示词是:「${String(originalPrompt).slice(0, 200)}」`,
|
|
71
|
-
imageDataUrl: dataUrl,
|
|
72
|
-
signal: undefined
|
|
73
|
-
});
|
|
74
|
-
return answer.slice(0, 300);
|
|
75
|
-
} catch (_) { return ''; }
|
|
76
|
-
}
|
|
77
|
-
|
|
78
71
|
/* ---------------- IO 目录 ---------------- */
|
|
79
72
|
function outputDir() {
|
|
80
73
|
const dir = path.join(store.irisHome(), 'outputs');
|
|
@@ -99,6 +92,11 @@ function selectProviders(capability, label, requestedModelRef) {
|
|
|
99
92
|
if (requested) {
|
|
100
93
|
const pool = models.modelPool(store.providers());
|
|
101
94
|
const selected = models.resolvePoolModel(pool, requested, capability);
|
|
95
|
+
if (selected?.rateLimited) throw Object.assign(new Error(store.modelHealth(selected.providerId, selected.id, capability).retryAt
|
|
96
|
+
? 'iris: 指定模型正在限流冷却,请稍后使用或手动实测'
|
|
97
|
+
: 'iris: 指定模型额度或预算耗尽,请手动实测成功后再使用'), {
|
|
98
|
+
code: 'IRIS_PROVIDER_MODEL_RATE_LIMITED'
|
|
99
|
+
});
|
|
102
100
|
const field = models.CAP_FIELD[capability];
|
|
103
101
|
providers = selected ? providers.filter((p) => p.id === selected.providerId && p[field] === selected.id) : [];
|
|
104
102
|
if (!providers.length) throw new Error('iris: 指定的模型复合引用不可用或不具备' + label + '能力');
|
|
@@ -127,8 +125,9 @@ function routeSelectionReason(provider, capability, requested) {
|
|
|
127
125
|
}
|
|
128
126
|
|
|
129
127
|
function imageRoute(provider, args) {
|
|
130
|
-
const protocol = selectMediaProtocol(provider).mediaProtocol;
|
|
131
128
|
const model = configuredModel(provider, cap.CAPABILITIES.IMAGE, args.model);
|
|
129
|
+
provider = providerForImageModel(provider, model);
|
|
130
|
+
const protocol = selectMediaProtocol(provider).mediaProtocol;
|
|
132
131
|
return {
|
|
133
132
|
provider, protocol, model, modelRef: models.modelRef(provider.id, model),
|
|
134
133
|
selectionReason: routeSelectionReason(provider, cap.CAPABILITIES.IMAGE, args.model),
|
|
@@ -137,7 +136,8 @@ function imageRoute(provider, args) {
|
|
|
137
136
|
}
|
|
138
137
|
|
|
139
138
|
function taskActionError(taskId, message, details) {
|
|
140
|
-
const
|
|
139
|
+
const diagnostic = details?.safeMessage ? ':' + details.safeMessage : '';
|
|
140
|
+
const error = new Error('iris: ' + message + '(task: ' + taskId + ')' + diagnostic);
|
|
141
141
|
error.taskId = taskId;
|
|
142
142
|
if (details) error.details = details;
|
|
143
143
|
return error;
|
|
@@ -193,20 +193,15 @@ function recordVisionHealth(errors) {
|
|
|
193
193
|
}
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
-
async function
|
|
196
|
+
async function askSingleVisionWithHealth(host, request) {
|
|
197
197
|
try {
|
|
198
|
-
const result = await
|
|
198
|
+
const result = await runVisionRequest(host, { providers: store.pickAllFor(cap.CAPABILITIES.VISION), onRateLimit: store.recordRateLimit, ...request });
|
|
199
199
|
recordVisionHealth(result.errors);
|
|
200
|
-
if (result.via === 'selfstack') {
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
});
|
|
204
|
-
}
|
|
200
|
+
if (result.via === 'selfstack') recordProviderHealthSafe(result.backendId, result.model, cap.CAPABILITIES.VISION, {
|
|
201
|
+
ok: true, category: 'success', note: '视觉请求成功'
|
|
202
|
+
});
|
|
205
203
|
return result;
|
|
206
|
-
} catch (error) {
|
|
207
|
-
recordVisionHealth(error && error.errors);
|
|
208
|
-
throw error;
|
|
209
|
-
}
|
|
204
|
+
} catch (error) { recordVisionHealth(error?.errors); throw error; }
|
|
210
205
|
}
|
|
211
206
|
|
|
212
207
|
function recordCoreTaskAttemptHealth(task, capability) {
|
|
@@ -229,7 +224,7 @@ function recordCoreTaskAttemptHealth(task, capability) {
|
|
|
229
224
|
}
|
|
230
225
|
|
|
231
226
|
/** DSH 图片入口:同步和异步候选共用一份 Core Task/Attempt/Artifact 事实。 */
|
|
232
|
-
async function submitCoreImage(routes, providerInput, signal) {
|
|
227
|
+
async function submitCoreImage(routes, providerInput, signal, sourceArtifactId) {
|
|
233
228
|
const submitted = await submitProviderTaskForDsh({
|
|
234
229
|
capability: 'image',
|
|
235
230
|
candidates: routes.map((route) => ({
|
|
@@ -239,6 +234,7 @@ async function submitCoreImage(routes, providerInput, signal) {
|
|
|
239
234
|
providerBinding: providerTaskBinding(route.provider)
|
|
240
235
|
})),
|
|
241
236
|
providerInput,
|
|
237
|
+
sourceArtifactId,
|
|
242
238
|
signal
|
|
243
239
|
});
|
|
244
240
|
const task = submitted.task;
|
|
@@ -263,24 +259,25 @@ async function submitCoreImage(routes, providerInput, signal) {
|
|
|
263
259
|
/**
|
|
264
260
|
* DSH 视频入口(E 阶段):t2v/i2v 共用 Core Task/Attempt/Artifact 事实轴,
|
|
265
261
|
* 不共用图片状态机——视频走自己的交付 Profile(video/mp4 / generated-video)。
|
|
266
|
-
* s2v
|
|
262
|
+
* s2v 在写前 Attempt 之后按账号上传首帧和音频,交付复用视频 Profile。
|
|
267
263
|
*/
|
|
268
264
|
async function submitCoreVideo(routes, frame, prompt, args, signal) {
|
|
269
|
-
const
|
|
270
|
-
prompt,
|
|
271
|
-
|
|
265
|
+
const normalized = normalizeGenerationInput('video', {
|
|
266
|
+
...(prompt ? { prompt } : {}),
|
|
267
|
+
...(frame ? { first_frame_path: frame.absPath } : {}),
|
|
268
|
+
...(args.audio_path ? { audio_path: args.audio_path, resolution: args.resolution } : {}),
|
|
272
269
|
size: optionalActionText(args.size),
|
|
273
270
|
duration: args.duration
|
|
274
|
-
});
|
|
271
|
+
}, { allowVideoPaths: true });
|
|
275
272
|
const submitted = await submitProviderTaskForDsh({
|
|
276
273
|
capability: 'video',
|
|
277
|
-
candidates: routes.map((route) => ({
|
|
274
|
+
candidates: videoTaskCandidates(routes.map((route) => ({
|
|
278
275
|
adapter: route.adapter,
|
|
279
276
|
model: route.modelRef,
|
|
280
277
|
selectionReason: route.selectionReason,
|
|
281
278
|
providerBinding: providerTaskBinding(route.provider)
|
|
282
|
-
})),
|
|
283
|
-
providerInput,
|
|
279
|
+
})), normalized),
|
|
280
|
+
providerInput: normalized.providerInput,
|
|
284
281
|
signal
|
|
285
282
|
});
|
|
286
283
|
const task = submitted.task;
|
|
@@ -313,12 +310,26 @@ function findGeneratedAttachment(attachmentId) {
|
|
|
313
310
|
return null;
|
|
314
311
|
}
|
|
315
312
|
|
|
316
|
-
function resolveFirstFrameFile(args) {
|
|
313
|
+
async function resolveFirstFrameFile(host, args, signal) {
|
|
317
314
|
if (args.first_frame_attachment_id) {
|
|
318
315
|
const hit = findGeneratedAttachment(String(args.first_frame_attachment_id).trim());
|
|
319
|
-
if (
|
|
320
|
-
|
|
321
|
-
|
|
316
|
+
if (hit) {
|
|
317
|
+
if (!fs.existsSync(hit.absPath)) throw new Error('iris: 首帧图片的本地缓存已被清理');
|
|
318
|
+
return hit;
|
|
319
|
+
}
|
|
320
|
+
const sessions = requireHostPort(host, 'sessions', '读取首帧附件');
|
|
321
|
+
const attachments = requireHostPort(host, 'attachments', '读取首帧附件');
|
|
322
|
+
const ref = await sessions.findImageAttachment(args.session_id, String(args.first_frame_attachment_id).trim());
|
|
323
|
+
if (!ref) throw new Error('iris: 找不到本会话中的 first_frame_attachment_id');
|
|
324
|
+
const stored = await attachments.readImage(ref, signal);
|
|
325
|
+
signal?.throwIfAborted();
|
|
326
|
+
const mediaType = stored.mediaType || ref.mediaType;
|
|
327
|
+
const extension = Object.keys(MEDIA_TYPES).find(key => MEDIA_TYPES[key] === mediaType);
|
|
328
|
+
if (!extension) throw new Error('iris: 不支持的首帧图片格式');
|
|
329
|
+
const bytes = Buffer.from(stored.data);
|
|
330
|
+
const file = path.join(store.irisHome(), 'uploads', 'frame-' + crypto.createHash('sha256').update(bytes).digest('hex') + extension);
|
|
331
|
+
if (!fs.existsSync(file)) writePrivateFile(file, bytes, { flag: 'wx' });
|
|
332
|
+
return { absPath: file, mediaType };
|
|
322
333
|
}
|
|
323
334
|
if (args.first_frame_path) {
|
|
324
335
|
const p = String(args.first_frame_path).trim();
|
|
@@ -331,10 +342,6 @@ function resolveFirstFrameFile(args) {
|
|
|
331
342
|
return null;
|
|
332
343
|
}
|
|
333
344
|
|
|
334
|
-
function fileDataUrl(file) {
|
|
335
|
-
return 'data:' + file.mediaType + ';base64,' + fs.readFileSync(file.absPath).toString('base64');
|
|
336
|
-
}
|
|
337
|
-
|
|
338
345
|
function videoRoute(provider, args, frame) {
|
|
339
346
|
const model = configuredModel(provider, cap.CAPABILITIES.VIDEO, args.model);
|
|
340
347
|
const protocol = selectMediaProtocol(provider).mediaProtocol;
|
|
@@ -352,126 +359,6 @@ function videoRoute(provider, args, frame) {
|
|
|
352
359
|
};
|
|
353
360
|
}
|
|
354
361
|
|
|
355
|
-
function beforeGenerationError(error, stage) {
|
|
356
|
-
const category = error && (error.category === 'network' || error instanceof TypeError)
|
|
357
|
-
? 'network'
|
|
358
|
-
: (stage === 'validate' ? 'invalid_request' : 'local_io');
|
|
359
|
-
return new ProviderContractError(String((error && error.message) || error || '提交前准备失败'), {
|
|
360
|
-
stage,
|
|
361
|
-
category,
|
|
362
|
-
acceptance: 'not_accepted',
|
|
363
|
-
retryable: category === 'network'
|
|
364
|
-
});
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
async function submitVideoV2(routes, frame, prompt, args, signal) {
|
|
368
|
-
const task = tasks.createV2({
|
|
369
|
-
cap: 'video', prompt: prompt.slice(0, 4000),
|
|
370
|
-
...(args.model ? { requestedModelRef: String(args.model) } : {}),
|
|
371
|
-
...(args._retryOf ? { retryOf: String(args._retryOf) } : {})
|
|
372
|
-
});
|
|
373
|
-
const candidates = routes.map((route) => ({
|
|
374
|
-
id: route.provider.id,
|
|
375
|
-
model: route.modelRef,
|
|
376
|
-
route,
|
|
377
|
-
submit: async (_input, context) => {
|
|
378
|
-
const attemptId = context.attempt.id;
|
|
379
|
-
let submitArgs;
|
|
380
|
-
if (route.isS2V) {
|
|
381
|
-
if (!frame) throw beforeGenerationError(new Error('iris: s2v 数字人需要首帧'), 'validate');
|
|
382
|
-
const audioPath = String(args.audio_path || '').trim();
|
|
383
|
-
if (!audioPath || !path.isAbsolute(audioPath)) {
|
|
384
|
-
throw beforeGenerationError(new Error('iris: s2v 的 audio_path 必须是绝对路径'), 'validate');
|
|
385
|
-
}
|
|
386
|
-
if (!fs.existsSync(audioPath)) throw beforeGenerationError(new Error('iris: 音频文件不存在'), 'validate');
|
|
387
|
-
tasks.markAttemptStage(task.id, attemptId, 'upload');
|
|
388
|
-
const { url: imageUrl } = await prepareProviderInput(route.adapter, {
|
|
389
|
-
model: route.model, filePath: frame.absPath, signal
|
|
390
|
-
});
|
|
391
|
-
const { url: audioUrl } = await prepareProviderInput(route.adapter, {
|
|
392
|
-
model: route.model, filePath: audioPath, signal
|
|
393
|
-
});
|
|
394
|
-
submitArgs = { imgDataUrl: imageUrl, audioUrl, resolution: args.resolution || '480P' };
|
|
395
|
-
} else {
|
|
396
|
-
if (!prompt) throw beforeGenerationError(new Error('iris: 文生/图生视频需要 prompt'), 'validate');
|
|
397
|
-
tasks.markAttemptStage(task.id, attemptId, 'prepare');
|
|
398
|
-
try {
|
|
399
|
-
submitArgs = {
|
|
400
|
-
prompt,
|
|
401
|
-
imgDataUrl: frame ? fileDataUrl(frame) : undefined,
|
|
402
|
-
size: args.size || '1280*720',
|
|
403
|
-
duration: args.duration ? Number(args.duration) : undefined
|
|
404
|
-
};
|
|
405
|
-
} catch (error) {
|
|
406
|
-
throw beforeGenerationError(error, 'prepare');
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
tasks.markAttemptStage(task.id, attemptId, 'submit');
|
|
410
|
-
return observeProviderCapability(route, cap.CAPABILITIES.VIDEO, () =>
|
|
411
|
-
invokeProviderOperation(route.adapter, 'submit', {
|
|
412
|
-
capability: 'video', model: route.model, input: submitArgs, signal
|
|
413
|
-
}));
|
|
414
|
-
}
|
|
415
|
-
}));
|
|
416
|
-
|
|
417
|
-
const submitted = await submitWithAcceptanceBoundary(candidates, { capability: 'video' }, {
|
|
418
|
-
beforeAttempt: ({ providerId, model }) => {
|
|
419
|
-
const route = routes.find((item) => item.provider.id === providerId && item.modelRef === model);
|
|
420
|
-
if (!route) throw new Error('候选路由与 Attempt 身份不一致');
|
|
421
|
-
const attempt = tasks.beginAttempt(task.id, {
|
|
422
|
-
providerId, providerName: route.provider.name, model, protocol: route.protocol
|
|
423
|
-
});
|
|
424
|
-
tasks.update(task.id, {
|
|
425
|
-
mode: route.mode,
|
|
426
|
-
...(route.isS2V ? { resolution: args.resolution || '480P' } : {
|
|
427
|
-
size: args.size || '1280*720', ...(args.duration ? { duration: Number(args.duration) } : {})
|
|
428
|
-
})
|
|
429
|
-
});
|
|
430
|
-
return attempt;
|
|
431
|
-
},
|
|
432
|
-
afterResult: (snapshot) => tasks.recordAttemptResult(task.id, snapshot)
|
|
433
|
-
});
|
|
434
|
-
|
|
435
|
-
if (submitted.localError) {
|
|
436
|
-
throw taskActionError(task.id, '视频提交结果未能可靠落盘,已停止后续候选', submitted.localError);
|
|
437
|
-
}
|
|
438
|
-
if (!submitted.candidate) {
|
|
439
|
-
const lastError = submitted.result && submitted.result.error;
|
|
440
|
-
tasks.finalizeNoAcceptance(task.id, lastError || '所有视频候选均明确未受理');
|
|
441
|
-
throw taskActionError(task.id, '所有视频候选均明确未受理', submitted.attempts);
|
|
442
|
-
}
|
|
443
|
-
if (submitted.result.kind !== 'accepted') {
|
|
444
|
-
const safe = submitted.result.error && submitted.result.error.safeMessage;
|
|
445
|
-
throw taskActionError(task.id, '视频受理状态未知,禁止自动重提' + (safe ? ':' + safe : ''), submitted.result.error);
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
const route = submitted.candidate.route;
|
|
449
|
-
tasks.activateWatch(task.id);
|
|
450
|
-
tasks.watch(tasks.get(task.id), {
|
|
451
|
-
poll: ({ remoteTaskId: id, signal: pollSignal }) => observeProviderCapability(
|
|
452
|
-
route, cap.CAPABILITIES.VIDEO,
|
|
453
|
-
() => invokeProviderOperation(route.adapter, 'poll', {
|
|
454
|
-
capability: 'video', remoteTaskId: id, signal: pollSignal
|
|
455
|
-
}),
|
|
456
|
-
{ recordSuccess: false }
|
|
457
|
-
),
|
|
458
|
-
intervalMs: 6000,
|
|
459
|
-
onSuccess: async (record, result) => {
|
|
460
|
-
const names = [];
|
|
461
|
-
for (const url of result.urls || []) {
|
|
462
|
-
const name = record.id + '-' + names.length + '.' + adapters.extFromUrl(url, 'mp4');
|
|
463
|
-
await invokeProviderOperation(route.adapter, 'download', {
|
|
464
|
-
artifact: { kind: 'remote-url', url }, targetPath: path.join(outputDir(), name)
|
|
465
|
-
});
|
|
466
|
-
names.push(name);
|
|
467
|
-
}
|
|
468
|
-
for (const name of names) registerMedia(record.id, path.join(outputDir(), name));
|
|
469
|
-
return names;
|
|
470
|
-
}
|
|
471
|
-
});
|
|
472
|
-
return { task: tasks.get(task.id), provider: route.provider, model: route.model, remoteTaskId: submitted.result.remoteTaskId };
|
|
473
|
-
}
|
|
474
|
-
|
|
475
362
|
function simpleModelRoute(provider, args, capability) {
|
|
476
363
|
const protocol = selectMediaProtocol(provider).mediaProtocol;
|
|
477
364
|
const model = configuredModel(provider, capability, args.model);
|
|
@@ -493,7 +380,8 @@ export function taskPollDeps(provider, task) {
|
|
|
493
380
|
: 'dashscope');
|
|
494
381
|
let providerAdapter;
|
|
495
382
|
try {
|
|
496
|
-
providerAdapter = createConfiguredProviderAdapter(provider, { protocol
|
|
383
|
+
providerAdapter = createConfiguredProviderAdapter(provider, { protocol,
|
|
384
|
+
modelId: models.parseModelRef(String(task.modelRef || task.model || ''))?.modelId || String(task.model || '') });
|
|
497
385
|
} catch (_) {
|
|
498
386
|
return null;
|
|
499
387
|
}
|
|
@@ -575,14 +463,16 @@ export async function runAction(host, name, args, { signal } = {}) {
|
|
|
575
463
|
/* ============ 动作实现 ============ */
|
|
576
464
|
|
|
577
465
|
/* ---------- 🖼️ 画图 ---------- */
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
466
|
+
async function imageAction(host, args, { signal }, editing = false) {
|
|
467
|
+
if (editing && !args.source_artifact_id) throw new Error('iris: 改图必须提供 source_artifact_id');
|
|
468
|
+
const { providerInput, sourceArtifactId } = normalizeGenerationInput('image', {
|
|
469
|
+
prompt: args.prompt, size: optionalActionText(args.size), n: args.n,
|
|
470
|
+
...(editing ? { source_artifact_id: args.source_artifact_id } : {})
|
|
581
471
|
});
|
|
582
472
|
|
|
583
473
|
const imageProviders = selectProviders(cap.CAPABILITIES.IMAGE, '图像', args.model);
|
|
584
474
|
const routes = imageProviders.map((provider) => imageRoute(provider, args));
|
|
585
|
-
const submitted = await submitCoreImage(routes, providerInput, signal);
|
|
475
|
+
const submitted = await submitCoreImage(routes, providerInput, signal, sourceArtifactId);
|
|
586
476
|
const firstArtifactId = submitted.task.artifactIds[0];
|
|
587
477
|
const ready = submitted.task.outcome === 'succeeded'
|
|
588
478
|
&& submitted.task.deliveryState === 'ready' && Boolean(firstArtifactId);
|
|
@@ -590,44 +480,36 @@ register('image', async (host, args, { signal }) => {
|
|
|
590
480
|
ok: true, storage: 'core', taskId: submitted.task.id, model: submitted.model,
|
|
591
481
|
providerId: submitted.provider.id, remoteTaskId: submitted.task.remoteTaskId || null,
|
|
592
482
|
artifactIds: submitted.task.artifactIds,
|
|
483
|
+
...(sourceArtifactId ? { sourceArtifactId } : {}),
|
|
593
484
|
...(ready ? { imageUrl: '/iris/api/core/artifact/' + encodeURIComponent(firstArtifactId) + '/media' } : {}),
|
|
594
485
|
text: ready
|
|
595
486
|
? '[iris] 图像任务已完成(' + submitted.model + ',Core task: ' + submitted.task.id + ')'
|
|
596
487
|
: '[iris] 图像任务已提交并由 Core 后台观察(' + submitted.model + ',Core task: ' + submitted.task.id + ')'
|
|
597
488
|
};
|
|
598
|
-
}
|
|
489
|
+
}
|
|
490
|
+
register('image', imageAction);
|
|
491
|
+
register('image_edit', (host, args, context) => imageAction(host, args, context, true));
|
|
599
492
|
|
|
600
493
|
/* ---------- 🎬 视频生成 ---------- */
|
|
601
494
|
register('video', async (host, args, { signal }) => {
|
|
602
495
|
const prompt = String(args.prompt || '').trim();
|
|
603
|
-
const frame = resolveFirstFrameFile(args);
|
|
496
|
+
const frame = await resolveFirstFrameFile(host, args, signal);
|
|
604
497
|
const providers = selectProviders(cap.CAPABILITIES.VIDEO, '视频', args.model);
|
|
605
498
|
const routes = providers.map((provider) => videoRoute(provider, args, frame));
|
|
606
|
-
// s2v
|
|
607
|
-
// 判定看显式意图(audio_path 或 model 指定 s2v),不看候选池——池里登记 s2v 模型
|
|
608
|
-
// 不应把 t2v/i2v 请求挡回 legacy。
|
|
499
|
+
// 请求子类型决定候选过滤;池中存在 s2v 不影响普通视频。
|
|
609
500
|
const hasAudioPath = Boolean(String(args.audio_path || '').trim());
|
|
610
501
|
const s2v = hasAudioPath || (args.model && /s2v/i.test(String(args.model)));
|
|
611
502
|
// B-7:audio_path 与选中模型必须语义一致。若用户提供了音频却选中非 s2v 模型,
|
|
612
503
|
// 旧实现会落入 else 分支静默丢弃 audio_path 并生成普通视频(可能产生非预期计费)。
|
|
613
504
|
// 这里在受理边界与任何网络调用之前明确拒绝,不猜测用户意图。
|
|
614
|
-
if (hasAudioPath && routes.some((route) => !route.s2vModel)) {
|
|
505
|
+
if (hasAudioPath && args.model && routes.some((route) => !route.s2vModel)) {
|
|
615
506
|
const mismatched = routes.filter((route) => !route.s2vModel).map((route) => route.model).join(', ');
|
|
616
507
|
throw new Error('iris: audio_path 只能配合支持 s2v 数字人的模型;当前候选 ' + mismatched
|
|
617
|
-
+ ' 不是 s2v 模型。请改用 s2v 模型(如 wan2.2-s2v
|
|
618
|
-
}
|
|
619
|
-
if (s2v) {
|
|
620
|
-
if (!prompt && !args.audio_path) throw new Error('iris: prompt 不能为空');
|
|
621
|
-
const submitted = await submitVideoV2(routes, frame, prompt, args, signal);
|
|
622
|
-
return {
|
|
623
|
-
ok: true, storage: 'legacy', taskId: submitted.task.id, model: submitted.model, mode: submitted.task.mode,
|
|
624
|
-
providerId: submitted.provider.id, remoteTaskId: submitted.remoteTaskId,
|
|
625
|
-
text: '[iris] 视频任务已提交(' + submitted.model + ',task: ' + submitted.task.id + ')'
|
|
626
|
-
};
|
|
508
|
+
+ ' 不是 s2v 模型。请改用 s2v 模型(如 wan2.2-s2v),或移除 audio_path 走文生/图生视频。');
|
|
627
509
|
}
|
|
628
|
-
const coreRoutes = routes.filter((route) =>
|
|
510
|
+
const coreRoutes = routes.filter((route) => route.isS2V === Boolean(s2v));
|
|
629
511
|
if (!coreRoutes.length) {
|
|
630
|
-
throw new Error('iris: 当前视频候选只有 s2v 数字人模型;请使用 audio_path 的数字人入口');
|
|
512
|
+
throw new Error(s2v ? 'iris: 未配置可用 s2v 数字人模型' : 'iris: 当前视频候选只有 s2v 数字人模型;请使用 audio_path 的数字人入口');
|
|
631
513
|
}
|
|
632
514
|
const submitted = await submitCoreVideo(coreRoutes, frame, prompt, args, signal);
|
|
633
515
|
const firstArtifactId = submitted.task.artifactIds[0];
|
|
@@ -745,16 +627,21 @@ async function submitCoreTranscribe(routes, normalized, signal) {
|
|
|
745
627
|
|
|
746
628
|
/* ---------- 👁 看图 ---------- */
|
|
747
629
|
register('look', async (host, args, { signal }) => {
|
|
630
|
+
const source = normalizeVisionImageSource(args);
|
|
748
631
|
const p = String(args.image_path || '').trim();
|
|
749
|
-
if (!p || !path.isAbsolute(p)) throw new Error('iris: image_path 必须是绝对路径');
|
|
750
632
|
const mt = MEDIA_TYPES[path.extname(p).toLowerCase()];
|
|
751
|
-
if (
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
633
|
+
if (source === 'image_path') {
|
|
634
|
+
if (!p || !path.isAbsolute(p)) throw new Error('iris: image_path 必须是绝对路径');
|
|
635
|
+
if (!mt || mt === 'image/gif') throw new Error('iris: 不支持的图片格式');
|
|
636
|
+
if (!fs.existsSync(p)) throw new Error('iris: 图片不存在');
|
|
637
|
+
}
|
|
638
|
+
const { answer, via, model } = await askSingleVisionWithHealth(host, { model: args.model,
|
|
639
|
+
question: String(args.question || '').trim() || '详细描述这张图片。',
|
|
640
|
+
prepareImage: operationSignal => source === 'artifact_id'
|
|
641
|
+
? visionImageForDsh({ artifactId: args.artifact_id, signal: operationSignal }) : readVisionImageFile(p, mt, operationSignal), signal });
|
|
756
642
|
const viaLabel = via === 'selfstack' ? 'iris 自持栈' : 'DSH 全局视觉模型';
|
|
757
|
-
return { ok: true,
|
|
643
|
+
return { ok: true, ...(args.artifact_id ? { artifactId: args.artifact_id } : {}),
|
|
644
|
+
text: `[iris] 看图回答(${model} · ${viaLabel}):\n${answer}` };
|
|
758
645
|
});
|
|
759
646
|
|
|
760
647
|
/* ---------- ✂️ 裁剪 ---------- */
|
|
@@ -789,39 +676,47 @@ register('diff', async (host, args, { signal }) => {
|
|
|
789
676
|
|
|
790
677
|
/* ---------- 📍 定位 ---------- */
|
|
791
678
|
register('locate', async (host, args, { signal }) => {
|
|
679
|
+
const source = normalizeVisionImageSource(args);
|
|
792
680
|
const p = String(args.image_path || '').trim();
|
|
793
|
-
if (!p || !path.isAbsolute(p)) throw new Error('iris: image_path 必须是绝对路径');
|
|
794
|
-
if (!fs.existsSync(p)) throw new Error('iris: 图片不存在');
|
|
795
|
-
const buf = fs.readFileSync(p);
|
|
681
|
+
if (source === 'image_path' && (!p || !path.isAbsolute(p))) throw new Error('iris: image_path 必须是绝对路径');
|
|
796
682
|
const mt = MEDIA_TYPES[path.extname(p).toLowerCase()] || 'image/png';
|
|
797
|
-
const
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
const
|
|
801
|
-
|
|
802
|
-
return { ok: true, text: `[iris]
|
|
683
|
+
const r = await runLocateRequest(host, { providers: store.pickAllFor(cap.CAPABILITIES.VISION), onRateLimit: store.recordRateLimit, model: args.model,
|
|
684
|
+
target: args.target, prepareImage: operationSignal => source === 'artifact_id'
|
|
685
|
+
? visionImageForDsh({ artifactId: args.artifact_id, signal: operationSignal }) : readVisionImageFile(p, mt, operationSignal), signal });
|
|
686
|
+
const { width, height } = r;
|
|
687
|
+
const provenance = args.artifact_id ? { artifactId: args.artifact_id } : {};
|
|
688
|
+
if (!r.found) return { ok: true, ...provenance, text: `[iris] 在图片中未找到「${args.target}」` };
|
|
689
|
+
return { ok: true, ...provenance, text: `[iris] 定位「${args.target}」:bbox (${r.x1},${r.y1},${r.x2},${r.y2}) / ${width}x${height}(${r.via} · ${r.model})` };
|
|
803
690
|
});
|
|
804
691
|
/* ---------- 🖼️ HTML 截图 ---------- */
|
|
805
692
|
register('html', async (host, args, { signal }) => {
|
|
806
693
|
const html = String(args.html || '').trim();
|
|
807
694
|
if (!html) throw new Error('iris: html 不能为空');
|
|
808
695
|
const browser = requireHostPort(host, 'browser', 'HTML 截图');
|
|
809
|
-
const rendered = await
|
|
696
|
+
const rendered = await htmlForDsh({ browser, html, width: args.width, height: args.height,
|
|
810
697
|
fullPage: args.fullPage !== false, signal });
|
|
811
698
|
const png = Buffer.from(rendered.bytes);
|
|
812
|
-
return { ok: true, text: `[iris] HTML 截图完成(fullPage: ${args.fullPage !== false})`, imageDataUrl: `data
|
|
699
|
+
return { ok: true, storage: 'core', artifactIds: [rendered.artifact.id], text: `[iris] HTML 截图完成(fullPage: ${args.fullPage !== false})`, imageDataUrl: `data:image/png;base64,${png.toString('base64')}` };
|
|
813
700
|
});
|
|
814
701
|
|
|
815
702
|
/* ---------- 📄 长截图 OCR ---------- */
|
|
816
703
|
register('ocr', async (host, args, { signal }) => {
|
|
704
|
+
const source = normalizeVisionImageSource(args);
|
|
817
705
|
const p = String(args.image_path || '').trim();
|
|
818
|
-
|
|
819
|
-
if (
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
const
|
|
824
|
-
|
|
706
|
+
const mediaType = MEDIA_TYPES[path.extname(p).toLowerCase()];
|
|
707
|
+
if (source === 'image_path') {
|
|
708
|
+
if (!p || !path.isAbsolute(p)) throw new Error('iris: image_path 必须是绝对路径');
|
|
709
|
+
if (!mediaType || mediaType === 'image/gif') throw new Error('iris: 不支持的图片格式(png/jpg/jpeg/webp)');
|
|
710
|
+
}
|
|
711
|
+
const result = await runOcrRequest(host, { providers: store.pickAllFor(cap.CAPABILITIES.VISION),
|
|
712
|
+
onRateLimit: store.recordRateLimit,
|
|
713
|
+
prepareImage: operationSignal => source === 'artifact_id'
|
|
714
|
+
? visionImageForDsh({ artifactId: args.artifact_id, signal: operationSignal }) : readVisionImageFile(p, mediaType, operationSignal),
|
|
715
|
+
chunkHeight: args.chunk_height, overlap: args.overlap, signal });
|
|
716
|
+
return { ok: result.status !== 'failed', status: result.status,
|
|
717
|
+
totalChunks: result.totalChunks, successfulChunks: result.successfulChunks,
|
|
718
|
+
failedChunks: result.failedChunks, skippedChunks: result.skippedChunks,
|
|
719
|
+
...(args.artifact_id ? { artifactId: args.artifact_id } : {}), text: formatOcrResult(result) };
|
|
825
720
|
});
|
|
826
721
|
|
|
827
722
|
/* ---------- 📋 任务查询 ---------- */
|
|
@@ -1070,22 +965,23 @@ register('relook', async (host, args, { signal }) => {
|
|
|
1070
965
|
if (!id) throw new Error('iris: attachment_id 不能为空');
|
|
1071
966
|
const question = String(args.question || '').trim();
|
|
1072
967
|
if (!question) throw new Error('iris: question 不能为空');
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
const
|
|
1079
|
-
|
|
968
|
+
const { answer, via, model } = await askSingleVisionWithHealth(host, { question, model: args.model,
|
|
969
|
+
async prepareImage(operationSignal) {
|
|
970
|
+
// GUI 没有会话上下文;记录扫描也在预取消检查及整体预算之后执行。
|
|
971
|
+
let hit;
|
|
972
|
+
for (const task of tasks.all()) {
|
|
973
|
+
for (const attachment of task.attachments || []) {
|
|
974
|
+
if (attachment.attachmentId === id) {
|
|
975
|
+
const absPath = path.join(store.irisHome(), 'outputs', attachment.file);
|
|
976
|
+
if (fs.existsSync(absPath)) { hit = { absPath, mediaType: attachment.mediaType }; break; }
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
if (hit) break;
|
|
1080
980
|
}
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
const mt = hit.mediaType || MEDIA_TYPES[path.extname(hit.absPath).toLowerCase()] || 'image/png';
|
|
1086
|
-
const dataUrl = `data:${mt};base64,${fs.readFileSync(hit.absPath).toString('base64')}`;
|
|
1087
|
-
const backends = buildVisionBackendsFromHost(host, { providers: store.pickAllFor(cap.CAPABILITIES.VISION), model: args.model });
|
|
1088
|
-
const { answer, via, model } = await askWithHealth(backends, { question, imageDataUrl: dataUrl, signal });
|
|
981
|
+
if (!hit) throw new Error('iris: 图片引用不可用');
|
|
982
|
+
const mt = hit.mediaType || MEDIA_TYPES[path.extname(hit.absPath).toLowerCase()] || 'image/png';
|
|
983
|
+
return readVisionImageFile(hit.absPath, mt, operationSignal);
|
|
984
|
+
}, signal });
|
|
1089
985
|
const viaLabel = via === 'selfstack' ? 'iris 自持栈' : 'DSH 全局视觉模型';
|
|
1090
986
|
return { ok: true, text: `[iris] 重看回答(${model} · ${viaLabel}):\n${answer}` };
|
|
1091
987
|
});
|
|
@@ -1106,10 +1002,15 @@ register('providers_list', async (host, args) => {
|
|
|
1106
1002
|
enabled: p.enabled !== false,
|
|
1107
1003
|
auth: p.auth === 'none' ? 'none' : 'bearer',
|
|
1108
1004
|
...selectMediaProtocol(p),
|
|
1005
|
+
visionInput: p.visionInput || null,
|
|
1109
1006
|
apiKeyHint: String(p.apiKey || '').length <= 8 ? '****' : String(p.apiKey).slice(0, 3) + '****' + String(p.apiKey).slice(-4),
|
|
1110
1007
|
models: models.providerModels(p).map((m) => ({
|
|
1111
1008
|
id: m.id,
|
|
1112
1009
|
capabilities: m.capabilities,
|
|
1010
|
+
...(m.imageProtocol !== undefined ? { imageProtocol: m.imageProtocol } : {}),
|
|
1011
|
+
...(m.visionInput !== undefined ? { visionInput: m.visionInput } : {}),
|
|
1012
|
+
...(m.capabilities.includes(cap.CAPABILITIES.VISION) ? { visionInputEffective: visionInputLimits(p, m.id) } : {}),
|
|
1013
|
+
...(m.capabilities.includes(cap.CAPABILITIES.IMAGE) ? { imageRouting: selectImageProtocol(p, m.id) } : {}),
|
|
1113
1014
|
verified: m.verified || null,
|
|
1114
1015
|
health: Object.fromEntries(m.capabilities.map((capability) => [
|
|
1115
1016
|
capability, store.modelHealth(p.id, m.id, capability)
|
|
@@ -1126,7 +1027,7 @@ register('providers_upsert', async (host, args) => {
|
|
|
1126
1027
|
const id = args.id || undefined;
|
|
1127
1028
|
const patch = { id };
|
|
1128
1029
|
const allowed = ['auth', 'name', 'type', 'baseUrl', 'mediaBaseUrl', 'apiKey', 'enabled', 'mediaProtocol',
|
|
1129
|
-
'imageModel', 'videoModel', 'ttsModel', 'transcribeModel', 'visionModel', 'models'];
|
|
1030
|
+
'imageModel', 'videoModel', 'ttsModel', 'transcribeModel', 'visionModel', 'visionInput', 'models'];
|
|
1130
1031
|
for (const key of allowed) {
|
|
1131
1032
|
if (Object.prototype.hasOwnProperty.call(args, key)) patch[key] = args[key];
|
|
1132
1033
|
}
|
|
@@ -1149,7 +1050,7 @@ register('providers_set_models', async (host, args) => {
|
|
|
1149
1050
|
});
|
|
1150
1051
|
|
|
1151
1052
|
/**
|
|
1152
|
-
* 模型发现:读取供应商目录 →
|
|
1053
|
+
* 模型发现:读取供应商目录 → 明确能力元数据 → 合并模型池。
|
|
1153
1054
|
* 免费只读;拉不到/不支持则原样保留现有池(优雅降级),报人话错误。
|
|
1154
1055
|
*/
|
|
1155
1056
|
register('providers_discover', async (host, args, { signal }) => {
|
|
@@ -1163,30 +1064,23 @@ register('providers_discover', async (host, args, { signal }) => {
|
|
|
1163
1064
|
} catch (err) {
|
|
1164
1065
|
throw new Error('iris: 模型发现失败(保留现有池):' + String(err.message || err));
|
|
1165
1066
|
}
|
|
1166
|
-
const
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
if (!media.length) {
|
|
1172
|
-
return { ok: true, total: discoveredModels.length, media: 0, text: `[iris] ${p.name}:拉到 ${discoveredModels.length} 个模型,无媒体能力匹配——可在模型池区手动添加并标能力` };
|
|
1173
|
-
}
|
|
1174
|
-
store.mergeDiscoveredModels(p.id, media);
|
|
1175
|
-
return { ok: true, total: discoveredModels.length, media: media.length, text: `[iris] ${p.name}:发现 ${discoveredModels.length} 个模型,${media.length} 个媒体能力模型已入池(官方元数据优先)` };
|
|
1067
|
+
const entries = discoveredModels.map((model) => ({ id: model.id, capabilities: models.capabilitiesOfDiscoveredModel(model) }));
|
|
1068
|
+
const mediaCount = entries.filter((model) => model.capabilities.length).length;
|
|
1069
|
+
store.mergeDiscoveredModels(p.id, entries);
|
|
1070
|
+
return { ok: true, total: discoveredModels.length, media: mediaCount,
|
|
1071
|
+
text: `[iris] ${p.name}:发现 ${entries.length} 个模型,${mediaCount} 个有能力声明;未标注模型请在模型池中设置能力` };
|
|
1176
1072
|
});
|
|
1177
1073
|
|
|
1178
1074
|
/** 能力实测(红色测试图):对某供应商的 vision 模型跑 testVisionCapability */
|
|
1179
1075
|
register('providers_test_vision', async (host, args, { signal }) => {
|
|
1180
1076
|
const p = store.providerById(String(args.id || ''));
|
|
1181
1077
|
if (!p) throw new Error('iris: 供应商不存在');
|
|
1182
|
-
const visionProviders = store.pickAllFor(cap.CAPABILITIES.VISION).filter((x) => x.id === p.id);
|
|
1078
|
+
const visionProviders = store.pickAllFor(cap.CAPABILITIES.VISION, { includeRateLimited: true }).filter((x) => x.id === p.id);
|
|
1183
1079
|
if (!visionProviders.length) return { ok: true, tested: false, text: '该供应商未配置 vision 模型,跳过' };
|
|
1184
|
-
const
|
|
1185
|
-
const backend = backends[0];
|
|
1186
|
-
if (!backend) return { ok: true, tested: false, text: '该供应商的 vision 配置不可用(类型非 openai)' };
|
|
1080
|
+
const model = configuredModel(visionProviders[0], cap.CAPABILITIES.VISION);
|
|
1187
1081
|
if (args.confirm_paid !== true) throw new Error('iris: 视觉实测会发起真实供应商请求,请先明确确认');
|
|
1188
|
-
const r = await
|
|
1189
|
-
store.setModelVerified(p.id,
|
|
1082
|
+
const r = await probeVisionModel(visionProviders[0], model, visionImageFromDataUrl(RED_TEST_IMAGE), { timeoutMs: 15000, signal });
|
|
1083
|
+
store.setModelVerified(p.id, model, cap.CAPABILITIES.VISION, {
|
|
1190
1084
|
ok: r.ok,
|
|
1191
1085
|
note: r.ok ? '认出红色测试图' : (r.error || '未识别出红色'),
|
|
1192
1086
|
category: r.category,
|
|
@@ -1197,7 +1091,7 @@ register('providers_test_vision', async (host, args, { signal }) => {
|
|
|
1197
1091
|
tested: true,
|
|
1198
1092
|
passed: r.ok,
|
|
1199
1093
|
text: r.ok
|
|
1200
|
-
? `[iris] 视觉能力实测通过:模型认出红色测试图(${
|
|
1094
|
+
? `[iris] 视觉能力实测通过:模型认出红色测试图(${model})`
|
|
1201
1095
|
: `[iris] 视觉能力实测未通过:${r.error || '未识别出红色'}`
|
|
1202
1096
|
};
|
|
1203
1097
|
});
|
|
@@ -1207,9 +1101,7 @@ register('providers_test_vision', async (host, args, { signal }) => {
|
|
|
1207
1101
|
/** 按能力实测单个模型;返回 {ok, note}。真实调用仅在 UI 明确确认后执行。 */
|
|
1208
1102
|
async function probeModel(host, p, modelId, capability, signal) {
|
|
1209
1103
|
if (capability === cap.CAPABILITIES.VISION) {
|
|
1210
|
-
const
|
|
1211
|
-
if (!backend) return { ok: false, note: '非 openai 类型,无法直测' };
|
|
1212
|
-
const r = await testVisionCapability(backend, { timeoutMs: 15000, signal });
|
|
1104
|
+
const r = await probeVisionModel(p, modelId, visionImageFromDataUrl(RED_TEST_IMAGE), { timeoutMs: 15000, signal });
|
|
1213
1105
|
return {
|
|
1214
1106
|
ok: r.ok,
|
|
1215
1107
|
note: r.ok ? '认出红色测试图' : (r.error || '未识别出红色'),
|
|
@@ -1218,24 +1110,28 @@ async function probeModel(host, p, modelId, capability, signal) {
|
|
|
1218
1110
|
};
|
|
1219
1111
|
}
|
|
1220
1112
|
if (capability === cap.CAPABILITIES.TRANSCRIBE) {
|
|
1221
|
-
return { skipped: true, ok: false, note: '
|
|
1113
|
+
return { skipped: true, ok: false, note: '需真实音频;可用 CLI models test --capability transcribe --input 提供素材进行实测恢复' };
|
|
1222
1114
|
}
|
|
1223
1115
|
if (capability === cap.CAPABILITIES.TTS) {
|
|
1224
|
-
const providerAdapter = createConfiguredProviderAdapter(p);
|
|
1116
|
+
const providerAdapter = createConfiguredProviderAdapter(p, { allowRateLimited: true });
|
|
1225
1117
|
const submitted = await invokeProviderOperation(providerAdapter, 'submit', {
|
|
1226
1118
|
capability: 'tts', model: modelId, input: { text: '你好' }, signal
|
|
1227
1119
|
});
|
|
1228
1120
|
const value = submitted.kind === 'completed' ? submitted.value : null;
|
|
1229
1121
|
const ok = Boolean(value && (value.audioUrl || value.audioB64));
|
|
1230
|
-
return { ok, note: ok ? '合成返回音频' : (submitted.error?.safeMessage || '无音频返回')
|
|
1122
|
+
return { ok, note: ok ? '合成返回音频' : (submitted.error?.safeMessage || '无音频返回'),
|
|
1123
|
+
category: submitted.error?.category, httpStatus: submitted.error?.httpStatus };
|
|
1231
1124
|
}
|
|
1232
1125
|
if (capability === cap.CAPABILITIES.IMAGE) {
|
|
1233
|
-
|
|
1126
|
+
p = providerForImageModel(p, modelId);
|
|
1127
|
+
const providerAdapter = createConfiguredProviderAdapter(p, { allowRateLimited: true, modelId });
|
|
1234
1128
|
const submitted = await invokeProviderOperation(providerAdapter, 'submit', {
|
|
1235
1129
|
capability: 'image', model: modelId,
|
|
1236
1130
|
input: {
|
|
1237
1131
|
prompt: providerAdapter.protocol === 'openai-images' ? 'red circle' : '红色圆形',
|
|
1238
|
-
|
|
1132
|
+
...(['openai-chat-images', 'openai-responses-images'].includes(providerAdapter.protocol) ? {} : {
|
|
1133
|
+
size: providerAdapter.protocol === 'openai-images' ? '256x256' : '512*512'
|
|
1134
|
+
}),
|
|
1239
1135
|
n: 1
|
|
1240
1136
|
},
|
|
1241
1137
|
signal
|
|
@@ -1272,22 +1168,22 @@ async function probeModel(host, p, modelId, capability, signal) {
|
|
|
1272
1168
|
return { ok: false, note: '生成超时(>60s)' };
|
|
1273
1169
|
}
|
|
1274
1170
|
if (capability === cap.CAPABILITIES.VIDEO) {
|
|
1275
|
-
return { skipped: true, ok: false, note: '
|
|
1171
|
+
return { skipped: true, ok: false, note: '视频提交即可能计费;可用 CLI models test --capability video-gen --input 提供素材进行实测恢复' };
|
|
1276
1172
|
}
|
|
1277
1173
|
return { ok: false, note: '未知能力' };
|
|
1278
1174
|
}
|
|
1279
1175
|
|
|
1280
|
-
/**
|
|
1176
|
+
/** 手动添加模型到池;省略或清空能力时保留未标注模型。 */
|
|
1281
1177
|
register('providers_add_model', async (host, args) => {
|
|
1282
1178
|
const id = String(args.id || '');
|
|
1283
1179
|
const modelId = String(args.model_id || '').trim();
|
|
1284
1180
|
if (!modelId) throw new Error('iris: model_id 不能为空');
|
|
1285
|
-
const caps = Array.isArray(args.capabilities)
|
|
1181
|
+
const caps = Array.isArray(args.capabilities)
|
|
1286
1182
|
? args.capabilities.filter((c) => cap.isKnownCapability(c))
|
|
1287
|
-
:
|
|
1183
|
+
: undefined;
|
|
1288
1184
|
const entry = store.addProviderModel(id, modelId, caps);
|
|
1289
1185
|
if (!entry) throw new Error('iris: 供应商不存在');
|
|
1290
|
-
return { ok: true, text: `[iris] 已添加模型 ${modelId}(能力:${caps.join(',') || '未标注'})` };
|
|
1186
|
+
return { ok: true, text: `[iris] 已添加模型 ${modelId}(能力:${(caps || []).join(',') || '未标注'})` };
|
|
1291
1187
|
});
|
|
1292
1188
|
|
|
1293
1189
|
/** 从池移除模型 */
|
|
@@ -1305,6 +1201,19 @@ register('providers_set_model_caps', async (host, args) => {
|
|
|
1305
1201
|
return { ok: true, text: `[iris] ${args.model_id} 能力已设为:${caps.join(',') || '(空)'}` };
|
|
1306
1202
|
});
|
|
1307
1203
|
|
|
1204
|
+
register('providers_set_model_image_protocol', async (host, args) => {
|
|
1205
|
+
const ok = store.setModelImageProtocol(String(args.id || ''), String(args.model_id || ''), args.imageProtocol);
|
|
1206
|
+
if (!ok) throw new Error('iris: 模型不在池中');
|
|
1207
|
+
return { ok: true, text: '[iris] 模型图片协议已更新' };
|
|
1208
|
+
});
|
|
1209
|
+
|
|
1210
|
+
register('providers_set_model_vision_input', async (host, args) => {
|
|
1211
|
+
if (!Object.hasOwn(args, 'visionInput')) throw new Error('iris: 需要 visionInput 对象或 null');
|
|
1212
|
+
const ok = store.setModelVisionInput(String(args.id || ''), String(args.model_id || ''), args.visionInput);
|
|
1213
|
+
if (!ok) throw new Error('iris: 模型不在池中');
|
|
1214
|
+
return { ok: true, text: '[iris] 模型看图输入预算已更新' };
|
|
1215
|
+
});
|
|
1216
|
+
|
|
1308
1217
|
/** 逐模型实测某能力 → 存 verified("test it, don't guess it";用户手动触发,发现不自动跑) */
|
|
1309
1218
|
register('providers_test_model', async (host, args, { signal }) => {
|
|
1310
1219
|
const p = store.providerById(String(args.id || ''));
|
|
@@ -1491,28 +1400,32 @@ register('video_frames', async (host, args, { signal }) => {
|
|
|
1491
1400
|
/* ============ 多模态视频摘要(阶段 7.3,GUI 同步快速版) ============ */
|
|
1492
1401
|
|
|
1493
1402
|
register('media_summarize', async (host, args, { signal }) => {
|
|
1403
|
+
const source = normalizeSummarySource(args);
|
|
1494
1404
|
const p = String(args.video_path || '').trim();
|
|
1495
|
-
if (
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1405
|
+
if (source === 'video') {
|
|
1406
|
+
if (!p || !path.isAbsolute(p)) throw new Error('iris: video_path 必须是绝对路径');
|
|
1407
|
+
if (!fs.existsSync(p)) throw new Error('iris: 视频文件不存在');
|
|
1408
|
+
if (!ffmpegAvailable()) throw new Error('iris: ffmpeg/ffprobe 不可用,无法分析视频(可选安装 ffmpeg 后启用)');
|
|
1409
|
+
}
|
|
1410
|
+
const { answer, via, model, sheet, meta, frames } = await runSummaryRequest(host, {
|
|
1411
|
+
onRateLimit: store.recordRateLimit,
|
|
1412
|
+
providers: store.pickAllFor(cap.CAPABILITIES.VISION), model: args.model, question: args.question, signal,
|
|
1413
|
+
async prepareMedia(operationSignal) {
|
|
1414
|
+
if (source === 'core-artifacts') {
|
|
1415
|
+
return { ...await summaryFramesForDsh({ frameArtifactIds: args.frame_artifact_ids, signal: operationSignal }), transcript: args.transcribe_text };
|
|
1416
|
+
}
|
|
1417
|
+
const meta = probeVideo(p);
|
|
1418
|
+
const frames = await extractFrames({ inputPath: p, maxFrames: Math.min(Number(args.max_frames) || 8, 12),
|
|
1419
|
+
targetWidth: args.target_width, format: 'jpeg', quality: 70, signal: operationSignal });
|
|
1420
|
+
return { meta, frames, transcript: args.transcribe_text };
|
|
1421
|
+
}
|
|
1510
1422
|
});
|
|
1511
1423
|
const viaLabel = via === 'selfstack' ? 'iris 自持栈' : 'DSH 全局视觉模型';
|
|
1512
1424
|
const hasTrans = args.transcribe_text && String(args.transcribe_text).trim();
|
|
1513
1425
|
return {
|
|
1514
1426
|
ok: true,
|
|
1515
|
-
|
|
1427
|
+
frames: summaryFrameRecords(frames),
|
|
1428
|
+
text: `[iris] 视频摘要完成(${model} · ${viaLabel},${summaryTimelineLabel(meta)} / ${frames.length} 帧联系表${hasTrans ? ' + 转写文本' : ''}):\n${answer}`,
|
|
1516
1429
|
imageDataUrl: `data:image/png;base64,${sheet.buffer.toString('base64')}`
|
|
1517
1430
|
};
|
|
1518
1431
|
});
|