@mokuyoaxis/dsh-iris 0.1.4 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +54 -1
- package/README.en.md +72 -15
- package/README.md +72 -15
- package/bin/dsh-iris.js +392 -13
- package/docs/AGENT_QUICKSTART.md +35 -0
- package/docs/ARCHITECTURE.md +80 -64
- package/docs/ARTIFACT_MANIFEST.md +60 -0
- package/docs/CORE_RUNTIME_CONTRACT.md +83 -0
- package/docs/DSH_CORE_MIGRATION.md +106 -0
- package/docs/DSH_RC2_ADAPTATION.md +36 -0
- package/docs/HEADLESS_CLI.md +217 -0
- package/docs/HOST_ADAPTER_CONTRACT.md +31 -12
- package/docs/HOST_DOCTOR.md +2 -0
- package/docs/MODEL_PORT_CONTRACT.md +300 -0
- package/docs/PROVIDER_ADAPTER_CONTRACT.md +51 -10
- package/docs/PROVIDER_RUNTIME_HARNESS.md +33 -0
- package/docs/PROVIDER_SUBMISSION_CONTRACT.md +1 -1
- package/docs/ROADMAP.md +42 -86
- package/docs/SECURITY.md +4 -3
- package/docs/assets/logo/README.md +55 -0
- package/docs/assets/logo/iris-bubble-concept.svg +67 -0
- package/docs/assets/logo/iris-bubble.svg +14 -0
- package/docs/assets/logo/iris-interim-flower.png +0 -0
- package/docs/assets/logo/iris-logo-mono-inverse.svg +78 -0
- package/docs/assets/logo/iris-logo-mono.svg +78 -0
- package/docs/assets/logo/iris-logo.svg +78 -0
- package/docs/assets/logo/iris-mark-mono-inverse.svg +42 -0
- package/docs/assets/logo/iris-mark-mono.svg +42 -0
- package/docs/assets/logo/iris-mark.svg +42 -0
- package/docs/releases/0.2.0.md +48 -0
- package/lib/actions.js +374 -362
- package/lib/adapters.js +52 -15
- package/lib/api.js +152 -5
- package/lib/client.js +763 -48
- package/lib/command-service.js +420 -0
- package/lib/config.js +56 -43
- package/lib/core-artifact-store.js +598 -0
- package/lib/core-artifacts.js +4 -0
- package/lib/core-attention.js +103 -0
- package/lib/core-contract.js +105 -0
- package/lib/core-doctor.js +41 -0
- package/lib/core-lease-recovery.js +170 -0
- package/lib/core-runtime.js +232 -0
- package/lib/core-tasks.js +560 -0
- package/lib/core-user-projection.js +192 -0
- package/lib/doctor.js +92 -33
- package/lib/dsh-core-adapter.js +599 -0
- package/lib/dsh-host-adapter.js +58 -7
- package/lib/generation-input.js +95 -0
- package/lib/guard.js +3 -3
- package/lib/index.js +159 -54
- package/lib/model-invoker.js +218 -0
- package/lib/model-port-contract.js +333 -0
- package/lib/models.js +97 -11
- package/lib/provider-adapter.js +49 -23
- package/lib/provider-adapters.js +84 -21
- package/lib/provider-catalog.js +171 -0
- package/lib/provider-contract.js +72 -3
- package/lib/provider-protocol.js +51 -2
- package/lib/provider-task-runner.js +328 -0
- package/lib/tasks.js +16 -4
- package/lib/vision.js +28 -7
- package/package.json +8 -8
- package/user_guide.md +27 -12
package/lib/dsh-host-adapter.js
CHANGED
|
@@ -41,6 +41,24 @@ function unavailableFor(value, reason) {
|
|
|
41
41
|
return { kind: value ? 'incompatible' : 'unavailable', reason };
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
function imageAttachmentDto(ref) {
|
|
45
|
+
const out = {
|
|
46
|
+
attachmentId: ref.attachmentId,
|
|
47
|
+
mediaType: ref.mediaType,
|
|
48
|
+
name: typeof ref.name === 'string' ? ref.name : ''
|
|
49
|
+
};
|
|
50
|
+
// readImage 会核对这三项完整性事实;省略未知值,不从 ID/文件名猜测。
|
|
51
|
+
for (const key of ['bytes', 'width', 'height']) {
|
|
52
|
+
if (Number.isSafeInteger(ref[key]) && ref[key] > 0) out[key] = ref[key];
|
|
53
|
+
}
|
|
54
|
+
const original = ref.originalDimensions;
|
|
55
|
+
if (original && Number.isSafeInteger(original.width) && original.width > 0
|
|
56
|
+
&& Number.isSafeInteger(original.height) && original.height > 0) {
|
|
57
|
+
out.originalDimensions = Object.freeze({ width: original.width, height: original.height });
|
|
58
|
+
}
|
|
59
|
+
return Object.freeze(out);
|
|
60
|
+
}
|
|
61
|
+
|
|
44
62
|
function collectImageAttachments(nodes) {
|
|
45
63
|
const out = [];
|
|
46
64
|
const ids = new Set();
|
|
@@ -59,11 +77,7 @@ function collectImageAttachments(nodes) {
|
|
|
59
77
|
&& typeof ref.mediaType === 'string' && ref.mediaType.startsWith('image/')
|
|
60
78
|
&& !ids.has(ref.attachmentId)) {
|
|
61
79
|
ids.add(ref.attachmentId);
|
|
62
|
-
out.push(
|
|
63
|
-
attachmentId: ref.attachmentId,
|
|
64
|
-
mediaType: ref.mediaType,
|
|
65
|
-
name: typeof ref.name === 'string' ? ref.name : ''
|
|
66
|
-
}));
|
|
80
|
+
out.push(imageAttachmentDto(ref));
|
|
67
81
|
}
|
|
68
82
|
for (const value of Object.values(node)) if (value && typeof value === 'object') stack.push(value);
|
|
69
83
|
}
|
|
@@ -75,16 +89,41 @@ function registerWithEffect(ctx, registry, definition) {
|
|
|
75
89
|
return registry.register(definition);
|
|
76
90
|
}
|
|
77
91
|
|
|
78
|
-
function
|
|
92
|
+
function visionRouteError(message) {
|
|
93
|
+
return Object.assign(new Error(message), { code: 'IRIS_HOST_CAPABILITY_INCOMPATIBLE' });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function dshVisionPort(llm, { defaults, requiresRoute }) {
|
|
79
97
|
return Object.freeze({
|
|
80
98
|
id: 'dsh-global',
|
|
81
99
|
model: '全局视觉模型',
|
|
82
100
|
async analyze({ question, ref, signal }) {
|
|
101
|
+
let route = {};
|
|
102
|
+
if (requiresRoute) {
|
|
103
|
+
// rc.2 路由检查前就停止取消请求;旧桥接继续由宿主处理 signal,
|
|
104
|
+
// 其 fallback 语义待共享调用控制迁移时统一。
|
|
105
|
+
if (signal?.aborted) throw signal.reason instanceof Error ? signal.reason : new Error('已取消');
|
|
106
|
+
const selection = defaults.currentSelection();
|
|
107
|
+
if (typeof selection?.provider !== 'string' || !selection.provider.trim()
|
|
108
|
+
|| typeof selection?.model !== 'string' || !selection.model.trim()) {
|
|
109
|
+
throw visionRouteError('DSH 默认模型没有明确路由;请在 Iris 配置视觉模型');
|
|
110
|
+
}
|
|
111
|
+
// 默认文本选择只有明确声明 image 输入时才可成为视觉候选。
|
|
112
|
+
const { provider, model } = selection;
|
|
113
|
+
const info = await llm.resolveModelInfo(provider, model, signal);
|
|
114
|
+
if (signal?.aborted) throw signal.reason instanceof Error ? signal.reason : new Error('已取消');
|
|
115
|
+
if (info?.provider !== provider || info?.id !== model || !Array.isArray(info.inputModalities)
|
|
116
|
+
|| !info.inputModalities.includes('image')) {
|
|
117
|
+
throw visionRouteError('DSH 默认模型未确认支持图片;请在 Iris 配置视觉模型或选择已声明图片输入的宿主模型');
|
|
118
|
+
}
|
|
119
|
+
route = { provider, model };
|
|
120
|
+
}
|
|
83
121
|
const chunks = [];
|
|
84
122
|
for await (const chunk of llm.stream({
|
|
85
123
|
sessionId: undefined,
|
|
86
124
|
provider: undefined,
|
|
87
125
|
model: undefined,
|
|
126
|
+
...route,
|
|
88
127
|
signal,
|
|
89
128
|
messages: [{
|
|
90
129
|
role: 'user',
|
|
@@ -94,6 +133,12 @@ function dshVisionPort(llm) {
|
|
|
94
133
|
]
|
|
95
134
|
}]
|
|
96
135
|
})) {
|
|
136
|
+
if (chunk?.type === 'error') throw visionRouteError('DSH 视觉调用失败;请检查宿主模型状态');
|
|
137
|
+
// rc.2 的 Runtime 把 Adapter 异常转换为 finish,而不是单独的 error 事件。
|
|
138
|
+
if (requiresRoute && chunk?.type === 'finish') {
|
|
139
|
+
if (chunk.reason?.kind === 'error') throw visionRouteError('DSH 视觉调用失败;请检查宿主模型状态');
|
|
140
|
+
if (chunk.reason?.kind === 'aborted') throw visionRouteError('DSH 视觉调用已取消');
|
|
141
|
+
}
|
|
97
142
|
const text = typeof chunk?.delta === 'string'
|
|
98
143
|
? chunk.delta
|
|
99
144
|
: (chunk?.type === 'text-delta' ? String(chunk.text || '') : '');
|
|
@@ -174,7 +219,13 @@ export function createDshHostAdapter(ctx, { version = detectDshVersion() } = {})
|
|
|
174
219
|
? () => defaults.currentSelection()
|
|
175
220
|
: () => null
|
|
176
221
|
});
|
|
177
|
-
|
|
222
|
+
const requiresRoute = /^0\.2\./.test(version);
|
|
223
|
+
if (!requiresRoute || defaults && typeof defaults.currentSelection === 'function'
|
|
224
|
+
&& typeof llm.resolveModelInfo === 'function') {
|
|
225
|
+
ports.visionModel = dshVisionPort(llm, { defaults, requiresRoute });
|
|
226
|
+
} else unavailable.visionModel = {
|
|
227
|
+
kind: 'unavailable', reason: 'DSH 0.2 视觉桥接需要默认模型选择和精确图片能力元数据'
|
|
228
|
+
};
|
|
178
229
|
} else {
|
|
179
230
|
unavailable.textModel = unavailableFor(llm, 'DSH llm 需要 stream');
|
|
180
231
|
unavailable.visionModel = unavailableFor(llm, 'DSH llm 需要 stream');
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
const FIELDS = Object.freeze({
|
|
6
|
+
image: ['prompt', 'size', 'n'],
|
|
7
|
+
video: ['prompt', 'size', 'duration', 'img_data_url'],
|
|
8
|
+
tts: ['text', 'voice'],
|
|
9
|
+
transcribe: ['audio_url', 'audio_path']
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export class GenerationInputError extends Error {
|
|
13
|
+
constructor(message) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = 'GenerationInputError';
|
|
16
|
+
this.code = 'IRIS_GENERATION_INPUT_INVALID';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function fail(message) { throw new GenerationInputError(message); }
|
|
21
|
+
|
|
22
|
+
function text(value, field, maxLength, required = false) {
|
|
23
|
+
if (value === undefined && !required) return undefined;
|
|
24
|
+
const normalized = String(value || '').trim();
|
|
25
|
+
if (!normalized || normalized.length > maxLength) {
|
|
26
|
+
fail(`${field} 必须为 1–${maxLength} 字符`);
|
|
27
|
+
}
|
|
28
|
+
return normalized;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* CLI、DSH 与知情重试共用的纯输入边界。只校验/转换调用方重新提供的参数,
|
|
33
|
+
* 不读取配置或文件,不上传,不从旧 Task 恢复 Prompt/媒体地址。
|
|
34
|
+
* Host 附件解析和本地音频上传仍由入口负责。
|
|
35
|
+
*/
|
|
36
|
+
export function normalizeGenerationInput(capability, input, { allowAudioPath = false } = {}) {
|
|
37
|
+
const fields = Object.hasOwn(FIELDS, capability) ? FIELDS[capability] : null;
|
|
38
|
+
if (!fields) fail('不支持的生成能力');
|
|
39
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) fail('生成输入必须是 JSON 对象');
|
|
40
|
+
for (const field of Object.keys(input)) {
|
|
41
|
+
if (!fields.includes(field) && field !== 'model_ref') {
|
|
42
|
+
fail('生成输入不支持字段:' + field + ';允许 ' + fields.join(', ') + ', model_ref');
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const modelRef = String(input.model_ref || '').trim();
|
|
46
|
+
const providerInput = {};
|
|
47
|
+
let audioPath;
|
|
48
|
+
if (capability === 'image' || capability === 'video') {
|
|
49
|
+
providerInput.prompt = text(input.prompt, 'prompt', 20000, true);
|
|
50
|
+
const size = text(input.size, 'size', 64);
|
|
51
|
+
if (size !== undefined) providerInput.size = size;
|
|
52
|
+
}
|
|
53
|
+
if (capability === 'image') {
|
|
54
|
+
const n = input.n === undefined ? 1 : Number(input.n);
|
|
55
|
+
if (!Number.isSafeInteger(n) || n < 1 || n > 4) fail('n 必须为 1–4 的整数');
|
|
56
|
+
providerInput.n = n;
|
|
57
|
+
}
|
|
58
|
+
if (capability === 'video') {
|
|
59
|
+
if (input.duration !== undefined) {
|
|
60
|
+
const duration = Number(input.duration);
|
|
61
|
+
if (!Number.isFinite(duration) || duration < 1 || duration > 60) fail('duration 必须为 1–60 的数字');
|
|
62
|
+
providerInput.duration = duration;
|
|
63
|
+
}
|
|
64
|
+
if (input.img_data_url !== undefined) {
|
|
65
|
+
const imgDataUrl = String(input.img_data_url).trim();
|
|
66
|
+
if (!imgDataUrl.startsWith('data:image/')) fail('img_data_url 必须是 data:image/ 开头的 data URL');
|
|
67
|
+
providerInput.imgDataUrl = imgDataUrl;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (capability === 'tts') {
|
|
71
|
+
providerInput.text = text(input.text, 'text', 20000, true);
|
|
72
|
+
const voice = text(input.voice, 'voice', 64);
|
|
73
|
+
if (voice !== undefined) providerInput.voice = voice;
|
|
74
|
+
}
|
|
75
|
+
if (capability === 'transcribe') {
|
|
76
|
+
const audioUrl = String(input.audio_url || '').trim();
|
|
77
|
+
audioPath = String(input.audio_path || '').trim();
|
|
78
|
+
if (Boolean(audioUrl) === Boolean(audioPath)) {
|
|
79
|
+
fail('转写必须且只能提供 audio_url(公网/oss://)或 audio_path(本地绝对路径)之一');
|
|
80
|
+
}
|
|
81
|
+
if (audioUrl) {
|
|
82
|
+
if (audioUrl.length > 20000) fail('audio_url 最多为 20000 字符');
|
|
83
|
+
if (!/^(https:\/\/|oss:\/\/)/.test(audioUrl)) fail('audio_url 必须是 https:// 或 oss:// 地址');
|
|
84
|
+
providerInput.audioUrl = audioUrl;
|
|
85
|
+
} else {
|
|
86
|
+
if (!allowAudioPath) fail('retry 必须重新提供 audio_url;本地音频上传由调用入口负责');
|
|
87
|
+
if (!path.isAbsolute(audioPath)) fail('audio_path 必须是绝对路径');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return Object.freeze({
|
|
91
|
+
modelRef,
|
|
92
|
+
providerInput: Object.freeze(providerInput),
|
|
93
|
+
...(audioPath ? { audioPath } : {})
|
|
94
|
+
});
|
|
95
|
+
}
|
package/lib/guard.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* `/iris/*` 不经过 DSH 的 `/api` 信任栅栏,因此默认只接受回环 Host。
|
|
6
6
|
* LAN/反代场景需用 IRIS_TRUSTED_HOSTS 显式列出 host 或 host:port;这只是
|
|
7
|
-
* Host
|
|
7
|
+
* Host 边界,不是身份认证。浏览器请求另校验 Sec-Fetch-Site 与 Origin==Host。
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
/** 请求 Host 头 → host:port 规范化(小写,去 IPv6 方括号保留) */
|
|
@@ -44,12 +44,12 @@ export function isTrustedHost(hostHeader, configured = process.env.IRIS_TRUSTED_
|
|
|
44
44
|
*/
|
|
45
45
|
export function checkRequest(req, options = {}) {
|
|
46
46
|
const headers = req.headers || {};
|
|
47
|
-
const method = String(req.method || 'GET').toUpperCase();
|
|
48
47
|
if (!isTrustedHost(headers.host, options.trustedHosts)) {
|
|
49
48
|
return { ok: false, status: 403, error: 'untrusted host' };
|
|
50
49
|
}
|
|
51
|
-
if (method !== 'POST') return { ok: true };
|
|
52
50
|
|
|
51
|
+
// 浏览器的读请求同样可能泄露媒体与状态;明确的跨站/跨源请求一律拒绝。
|
|
52
|
+
// curl/CLI 等不发送 Origin/Sec-Fetch-Site 的非浏览器客户端仍可在受信 Host 上使用。
|
|
53
53
|
const sfs = String(headers['sec-fetch-site'] || '').toLowerCase();
|
|
54
54
|
if (sfs === 'cross-site') return { ok: false, status: 403, error: 'cross-site request denied' };
|
|
55
55
|
|
package/lib/index.js
CHANGED
|
@@ -17,7 +17,8 @@ import { serveApi, closeAllSse, purgeStaleUploads } from './api.js';
|
|
|
17
17
|
import { buildVisionBackendsFromHost, askWithBackends } from './vision.js';
|
|
18
18
|
import { hasHostPort, requireHostPort } from './host-contract.js';
|
|
19
19
|
import { createDshHostAdapter } from './dsh-host-adapter.js';
|
|
20
|
-
import {
|
|
20
|
+
import { cropForDsh, diffForDsh, framesForDsh, inspectProviderTaskForDsh, projectCoreTaskForDsh, readCoreArtifactMediaForDsh, resumeProviderTaskWatchesForDsh, stopProviderTaskWatchesForDsh } from './dsh-core-adapter.js';
|
|
21
|
+
import { imageDimensions } from './pixels.js';
|
|
21
22
|
import { locateObject } from './locate.js';
|
|
22
23
|
import { serveRender } from './render.js';
|
|
23
24
|
import { ensurePrivateDir, hardenPrivateTree } from './private-storage.js';
|
|
@@ -51,7 +52,7 @@ function dshHost(ctx) {
|
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
/** 工具内同步等待上限;超过即转后台盯守,用 iris_task_status 查询 */
|
|
54
|
-
const AWAIT_MS = { image: 180000, video: 480000 };
|
|
55
|
+
const AWAIT_MS = { image: 180000, video: 480000, transcribe: 120000 };
|
|
55
56
|
|
|
56
57
|
function outputDir() {
|
|
57
58
|
const dir = tasks.outputsDir();
|
|
@@ -153,6 +154,39 @@ async function awaitTerminal(taskId, { timeoutMs, signal }) {
|
|
|
153
154
|
}
|
|
154
155
|
}
|
|
155
156
|
|
|
157
|
+
function coreTaskWaitFinished(task) {
|
|
158
|
+
return Boolean(task && (task.phase === 'terminal'
|
|
159
|
+
|| (task.outcome === 'succeeded' && ['ready', 'failed'].includes(task.deliveryState))));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function coreTaskTerminalProblem(task, label = '任务') {
|
|
163
|
+
if (!task) return label + '记录不存在';
|
|
164
|
+
if (task.outcome === 'succeeded' && task.deliveryState === 'ready') return '';
|
|
165
|
+
if (task.outcome === 'succeeded' && task.deliveryState === 'failed') {
|
|
166
|
+
return label + '已成功,但 Core Artifact 交付失败:'
|
|
167
|
+
+ (task.lastError?.safeMessage || '可稍后重新交付;禁止重新生成');
|
|
168
|
+
}
|
|
169
|
+
if (task.acceptance === 'unknown' || task.outcome === 'unknown') {
|
|
170
|
+
return label + '的远端受理或结果状态未知:'
|
|
171
|
+
+ (task.lastError?.safeMessage || '禁止自动重提,请先人工确认');
|
|
172
|
+
}
|
|
173
|
+
if (task.outcome === 'canceled') return label + '已取消';
|
|
174
|
+
if (task.outcome === 'failed') return label + '失败:'
|
|
175
|
+
+ (task.lastError?.safeMessage || '供应商明确返回失败');
|
|
176
|
+
return label + '需要人工处理';
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function awaitCoreTerminal(taskId, { timeoutMs, signal }) {
|
|
180
|
+
const deadline = Date.now() + timeoutMs;
|
|
181
|
+
for (;;) {
|
|
182
|
+
if (signal?.aborted) throw new Error('已取消;Core 任务仍保留远端受理事实,可稍后查询');
|
|
183
|
+
const task = await inspectProviderTaskForDsh(taskId, { signal });
|
|
184
|
+
if (coreTaskWaitFinished(task)) return task;
|
|
185
|
+
if (Date.now() > deadline) return null;
|
|
186
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
156
190
|
/* ---------------- 图片 data URL(视觉工具共用) ---------------- */
|
|
157
191
|
|
|
158
192
|
function toDataUrl(absPath, mediaType) {
|
|
@@ -160,17 +194,6 @@ function toDataUrl(absPath, mediaType) {
|
|
|
160
194
|
return `data:${mediaType};base64,${buf.toString('base64')}`;
|
|
161
195
|
}
|
|
162
196
|
|
|
163
|
-
function fmtTask(t) {
|
|
164
|
-
const lines = [`[${t.cap}] ${t.id} · ${t.status}${t.progress ? ' · ' + t.progress : ''} · ${t.model}`];
|
|
165
|
-
lines.push('prompt: ' + String(t.prompt || '').slice(0, 120));
|
|
166
|
-
if (t.error) lines.push('error: ' + t.error);
|
|
167
|
-
if ((t.files || []).length) lines.push('files:\n' + t.files.map((f) => path.join(tasks.outputsDir(), f)).join('\n'));
|
|
168
|
-
const links = mediaLinksOf(t);
|
|
169
|
-
if (links.length) lines.push('links:\n' + links.join('\n'));
|
|
170
|
-
if (t.remoteTaskId) lines.push('remoteTask: ' + t.remoteTaskId);
|
|
171
|
-
return lines.join('\n');
|
|
172
|
-
}
|
|
173
|
-
|
|
174
197
|
export const name = 'mokuyoaxis-dsh-iris';
|
|
175
198
|
export const inject = ['tools'];
|
|
176
199
|
|
|
@@ -223,9 +246,19 @@ export async function apply(ctx) {
|
|
|
223
246
|
if (resumed.length) console.log(`[iris] 已恢复接管 ${resumed.length} 个后台任务`);
|
|
224
247
|
});
|
|
225
248
|
|
|
226
|
-
|
|
249
|
+
try {
|
|
250
|
+
const resumed = await resumeProviderTaskWatchesForDsh();
|
|
251
|
+
if (resumed.length) console.log(`[iris] 已恢复接管 ${resumed.length} 个 Core 图片任务`);
|
|
252
|
+
} catch (error) {
|
|
253
|
+
console.error('[iris] 恢复 Core 图片后台任务失败(已隔离,不影响宿主):', error?.message || error);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Fiber 清理:插件停用/更新时停掉 legacy 与 Core 的全部 Host 盯守句柄
|
|
227
257
|
guard('盯守句柄注册', () => {
|
|
228
|
-
ctx.effect(() => () =>
|
|
258
|
+
ctx.effect(() => () => {
|
|
259
|
+
tasks.stopWatchAll();
|
|
260
|
+
stopProviderTaskWatchesForDsh();
|
|
261
|
+
});
|
|
229
262
|
});
|
|
230
263
|
|
|
231
264
|
// 媒体路由(视频/音频在对话流里的可播通路;图片仍走原生附件)
|
|
@@ -273,8 +306,35 @@ export async function apply(ctx) {
|
|
|
273
306
|
isConcurrencySafe: () => false,
|
|
274
307
|
async execute(args, exec) {
|
|
275
308
|
const prompt = String(args.prompt || '').trim();
|
|
276
|
-
if (!prompt) throw new Error('iris: prompt 不能为空');
|
|
277
309
|
const submitted = await runAction(dshHost(ctx), 'image', args, { signal: exec.signal });
|
|
310
|
+
if (submitted.storage === 'core') {
|
|
311
|
+
let final = await inspectProviderTaskForDsh(submitted.taskId, { signal: exec.signal });
|
|
312
|
+
if (!coreTaskWaitFinished(final)) {
|
|
313
|
+
final = await awaitCoreTerminal(submitted.taskId, { timeoutMs: AWAIT_MS.image, signal: exec.signal });
|
|
314
|
+
}
|
|
315
|
+
if (!final) {
|
|
316
|
+
return { blocks: [{ type: 'text', text: `[iris] Core 图片任务仍在后台观察(task: ${submitted.taskId})。稍后可用 iris_task_status 查询进度与 Artifact。` }] };
|
|
317
|
+
}
|
|
318
|
+
const terminalProblem = coreTaskTerminalProblem(final, '图像生成');
|
|
319
|
+
if (terminalProblem) throw new Error(terminalProblem);
|
|
320
|
+
const projected = await projectCoreTaskForDsh(dshHost(ctx), submitted.taskId, { signal: exec.signal });
|
|
321
|
+
const refs = projected.artifacts.map((item) => item.attachment);
|
|
322
|
+
const blocks = projected.artifacts.map((item) => ({ type: 'image', attachment: item.attachment }));
|
|
323
|
+
let note = '';
|
|
324
|
+
try {
|
|
325
|
+
const first = await readCoreArtifactMediaForDsh(projected.artifacts[0].artifact.id, { signal: exec.signal });
|
|
326
|
+
const described = await describeImageData(dshHost(ctx), refs[0],
|
|
327
|
+
`data:${first.artifact.mediaType};base64,${first.bytes.toString('base64')}`, prompt);
|
|
328
|
+
if (described) note = `画面内容:${described}`;
|
|
329
|
+
} catch (_) { /* 自述是增强项,失败不影响 Core 交付 */ }
|
|
330
|
+
blocks.unshift({ type: 'text', text:
|
|
331
|
+
`[iris] 图像已生成(${submitted.model},Core task: ${submitted.taskId})。` +
|
|
332
|
+
(note ? `\n${note}` : '') +
|
|
333
|
+
`\nartifact: ${projected.artifacts.map((item) => item.artifact.id).join(', ')}` +
|
|
334
|
+
`\nattachment: ${refs.map((ref) => ref.attachmentId).join(', ')}`
|
|
335
|
+
});
|
|
336
|
+
return { blocks };
|
|
337
|
+
}
|
|
278
338
|
let final = tasks.get(submitted.taskId);
|
|
279
339
|
if (final && !taskWaitFinished(final)) {
|
|
280
340
|
final = await awaitTerminal(submitted.taskId, { timeoutMs: AWAIT_MS.image, signal: exec.signal });
|
|
@@ -344,6 +404,25 @@ export async function apply(ctx) {
|
|
|
344
404
|
isConcurrencySafe: () => false,
|
|
345
405
|
async execute(args, exec) {
|
|
346
406
|
const submitted = await runAction(dshHost(ctx), 'video', args, { signal: exec.signal });
|
|
407
|
+
if (submitted.storage === 'core') {
|
|
408
|
+
let final = await inspectProviderTaskForDsh(submitted.taskId, { signal: exec.signal });
|
|
409
|
+
if (!coreTaskWaitFinished(final)) {
|
|
410
|
+
final = await awaitCoreTerminal(submitted.taskId, { timeoutMs: AWAIT_MS.video, signal: exec.signal });
|
|
411
|
+
}
|
|
412
|
+
if (!final) {
|
|
413
|
+
return `[iris] Core 视频任务已提交并转入后台观察(task: ${submitted.taskId},remoteTask: ${submitted.remoteTaskId})。\n` +
|
|
414
|
+
'渲染通常需要数分钟;完成后用 iris_task_status 查询进度与 Artifact,或在工作台「重新观察」。';
|
|
415
|
+
}
|
|
416
|
+
const terminalProblem = coreTaskTerminalProblem(final, '视频生成');
|
|
417
|
+
if (terminalProblem) throw new Error(terminalProblem);
|
|
418
|
+
const secs = Math.round((Date.now() - new Date(final.createdAt).getTime()) / 1000);
|
|
419
|
+
const links = final.artifactIds.map((artifactId) =>
|
|
420
|
+
'/iris/api/core/artifact/' + encodeURIComponent(artifactId) + '/media');
|
|
421
|
+
return `[iris] 视频已生成(${submitted.model || final.modelRef},${submitted.mode || 't2v'},约 ${secs}s,Core task: ${final.id}):\n` +
|
|
422
|
+
`artifact: ${final.artifactIds.join(', ')}\n` +
|
|
423
|
+
links.join('\n') +
|
|
424
|
+
'\n(链接受 DSH Host 与浏览器同源守卫保护;Artifact ID 持有者可访问,请勿公开)';
|
|
425
|
+
}
|
|
347
426
|
const final = await awaitTerminal(submitted.taskId, { timeoutMs: AWAIT_MS.video, signal: exec.signal });
|
|
348
427
|
if (!final) {
|
|
349
428
|
return `[iris] 视频任务已提交并转入后台盯守(task: ${submitted.taskId},remoteTask: ${submitted.remoteTaskId})。\n` +
|
|
@@ -378,6 +457,16 @@ export async function apply(ctx) {
|
|
|
378
457
|
isConcurrencySafe: () => true,
|
|
379
458
|
async execute(args, exec) {
|
|
380
459
|
const submitted = await runAction(dshHost(ctx), 'tts', args, { signal: exec.signal });
|
|
460
|
+
if (submitted.storage === 'core') {
|
|
461
|
+
const final = await inspectProviderTaskForDsh(submitted.taskId, { signal: exec.signal });
|
|
462
|
+
const terminalProblem = coreTaskTerminalProblem(final, '语音合成');
|
|
463
|
+
if (terminalProblem) throw new Error(terminalProblem);
|
|
464
|
+
const links = final.artifactIds.map((artifactId) =>
|
|
465
|
+
'/iris/api/core/artifact/' + encodeURIComponent(artifactId) + '/media');
|
|
466
|
+
return `[iris] 语音已合成(${submitted.model || final.modelRef},Core task: ${final.id}):\n` +
|
|
467
|
+
`artifact: ${final.artifactIds.join(', ')}\n` +
|
|
468
|
+
links.map((link) => `[♪ 音频播放](${link})`).join('\n');
|
|
469
|
+
}
|
|
381
470
|
const final = tasks.get(submitted.taskId);
|
|
382
471
|
const terminalProblem = taskTerminalProblem(final, '语音合成');
|
|
383
472
|
if (terminalProblem) throw new Error(terminalProblem);
|
|
@@ -405,6 +494,25 @@ export async function apply(ctx) {
|
|
|
405
494
|
isConcurrencySafe: () => false,
|
|
406
495
|
async execute(args, exec) {
|
|
407
496
|
const submitted = await runAction(dshHost(ctx), 'transcribe', args, { signal: exec.signal });
|
|
497
|
+
if (submitted.storage === 'core') {
|
|
498
|
+
let final = await inspectProviderTaskForDsh(submitted.taskId, { signal: exec.signal });
|
|
499
|
+
if (!coreTaskWaitFinished(final)) {
|
|
500
|
+
final = await awaitCoreTerminal(submitted.taskId, { timeoutMs: AWAIT_MS.transcribe, signal: exec.signal });
|
|
501
|
+
}
|
|
502
|
+
if (!final) {
|
|
503
|
+
return `[iris] Core 转写任务已提交并转入后台观察(task: ${submitted.taskId})。完成后用 iris_task_status 查询进度与 Artifact。`;
|
|
504
|
+
}
|
|
505
|
+
const terminalProblem = coreTaskTerminalProblem(final, '音频转写');
|
|
506
|
+
if (terminalProblem) throw new Error(terminalProblem);
|
|
507
|
+
const texts = [];
|
|
508
|
+
for (const artifactId of final.artifactIds) {
|
|
509
|
+
const media = await readCoreArtifactMediaForDsh(artifactId, { signal: exec.signal });
|
|
510
|
+
texts.push(Buffer.from(media.bytes).toString('utf8'));
|
|
511
|
+
}
|
|
512
|
+
return `[iris] 音频转写完成(${submitted.model || final.modelRef},Core task: ${final.id}):\n` +
|
|
513
|
+
texts.join('\n') +
|
|
514
|
+
`\nartifact: ${final.artifactIds.join(', ')}`;
|
|
515
|
+
}
|
|
408
516
|
const final = await awaitTerminal(submitted.taskId, { timeoutMs: 120000, signal: exec.signal });
|
|
409
517
|
if (!final) return `[iris] 音频转写任务已提交并转入后台(task: ${submitted.taskId})。完成后用 iris_task_status 查询。`;
|
|
410
518
|
const terminalProblem = taskTerminalProblem(final, '音频转写');
|
|
@@ -435,26 +543,22 @@ export async function apply(ctx) {
|
|
|
435
543
|
isConcurrencySafe: () => false,
|
|
436
544
|
async execute(args, exec) {
|
|
437
545
|
if (exec.signal && exec.signal.aborted) throw new Error('已取消');
|
|
438
|
-
const
|
|
439
|
-
|
|
440
|
-
if (!ffmpegAvailable()) throw new Error('iris: ffmpeg 不可用,无法抽帧(可选安装 ffmpeg/ffprobe 后启用;不影响其他 iris 工具)');
|
|
441
|
-
const meta = probeVideo(p);
|
|
442
|
-
const frames = await extractFrames({
|
|
443
|
-
inputPath: p,
|
|
546
|
+
const { media: meta, frames } = await framesForDsh({
|
|
547
|
+
videoPath: args.video_path,
|
|
444
548
|
maxFrames: args.max_frames,
|
|
445
549
|
targetWidth: args.target_width,
|
|
446
550
|
format: args.format,
|
|
447
551
|
quality: args.quality,
|
|
448
552
|
signal: exec.signal
|
|
449
553
|
});
|
|
450
|
-
const mediaType =
|
|
451
|
-
const ext =
|
|
554
|
+
const mediaType = frames[0].mediaType;
|
|
555
|
+
const ext = mediaType === 'image/png' ? 'png' : 'jpg';
|
|
452
556
|
const blocks = [];
|
|
453
557
|
const refs = [];
|
|
454
558
|
for (let i = 0; i < frames.length; i++) {
|
|
455
559
|
const f = frames[i];
|
|
456
560
|
const ref = await attachmentService(host).saveImage({
|
|
457
|
-
data: new Uint8Array(f.
|
|
561
|
+
data: new Uint8Array(f.bytes),
|
|
458
562
|
mediaType,
|
|
459
563
|
name: `iris-frame-${Date.now()}-${i + 1}.${ext}`
|
|
460
564
|
});
|
|
@@ -470,7 +574,7 @@ export async function apply(ctx) {
|
|
|
470
574
|
`时间戳:${ats}\n` +
|
|
471
575
|
`attachments: ${refs.map((r) => r.attachmentId).join(', ')}`
|
|
472
576
|
});
|
|
473
|
-
return { blocks };
|
|
577
|
+
return { blocks, artifactIds: frames.map((frame) => frame.artifact.id) };
|
|
474
578
|
}
|
|
475
579
|
});
|
|
476
580
|
|
|
@@ -648,14 +752,8 @@ export async function apply(ctx) {
|
|
|
648
752
|
output: { schema: { type: 'string' }, render: (_args, v) => [{ type: 'text', text: v }] },
|
|
649
753
|
isConcurrencySafe: () => true,
|
|
650
754
|
async execute(args) {
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
if (!t) throw new Error('iris: 没有这个任务:' + args.task_id);
|
|
654
|
-
return fmtTask(t);
|
|
655
|
-
}
|
|
656
|
-
const recent = tasks.list().slice(0, 5);
|
|
657
|
-
if (!recent.length) return '[iris] 还没有生成任务记录。';
|
|
658
|
-
return recent.map(fmtTask).join('\n---\n');
|
|
755
|
+
const result = await runAction(dshHost(ctx), 'status', args || {});
|
|
756
|
+
return result.text;
|
|
659
757
|
}
|
|
660
758
|
});
|
|
661
759
|
|
|
@@ -685,21 +783,25 @@ export async function apply(ctx) {
|
|
|
685
783
|
image_path: args.image_path,
|
|
686
784
|
attachment_id: args.attachment_id
|
|
687
785
|
});
|
|
688
|
-
const
|
|
689
|
-
|
|
786
|
+
const result = await cropForDsh({
|
|
787
|
+
...(args.image_path
|
|
788
|
+
? { imagePath: String(args.image_path).trim() }
|
|
789
|
+
: { bytes: input.buffer, mediaType: input.mediaType }),
|
|
690
790
|
left: args.left,
|
|
691
791
|
top: args.top,
|
|
692
792
|
width: args.width,
|
|
693
|
-
height: args.height
|
|
793
|
+
height: args.height,
|
|
794
|
+
signal: exec.signal
|
|
694
795
|
});
|
|
796
|
+
if (exec.signal && exec.signal.aborted) throw new Error('已取消');
|
|
695
797
|
const ref = await attachmentService(host).saveImage({
|
|
696
|
-
data: new Uint8Array(
|
|
697
|
-
mediaType:
|
|
798
|
+
data: new Uint8Array(result.bytes),
|
|
799
|
+
mediaType: result.mediaType,
|
|
698
800
|
name: `iris-crop-${Date.now()}.png`
|
|
699
801
|
});
|
|
700
802
|
return {
|
|
701
803
|
blocks: [
|
|
702
|
-
{ type: 'text', text: `[iris] 裁剪完成:${width}x${height}(原区域 ${args.left},${args.top},${args.width},${args.height})\nattachment: ${ref.attachmentId}` },
|
|
804
|
+
{ type: 'text', text: `[iris] 裁剪完成:${result.width}x${result.height}(原区域 ${args.left},${args.top},${args.width},${args.height})\nattachment: ${ref.attachmentId}` },
|
|
703
805
|
{ type: 'image', attachment: ref }
|
|
704
806
|
]
|
|
705
807
|
};
|
|
@@ -732,20 +834,21 @@ export async function apply(ctx) {
|
|
|
732
834
|
if (!(args.image_b_path || args.attachment_b_id)) throw new Error('iris: 需要 image_b_path 或 attachment_b_id');
|
|
733
835
|
const a = await resolveImageInput(dshHost(ctx), exec, { image_path: args.image_a_path, attachment_id: args.attachment_a_id });
|
|
734
836
|
const b = await resolveImageInput(dshHost(ctx), exec, { image_path: args.image_b_path, attachment_id: args.attachment_b_id });
|
|
735
|
-
const
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
grid: args.grid
|
|
739
|
-
topRegions: args.top_regions || 3
|
|
837
|
+
const result = await diffForDsh({
|
|
838
|
+
imageA: { bytes: a.buffer, mediaType: a.mediaType },
|
|
839
|
+
imageB: { bytes: b.buffer, mediaType: b.mediaType },
|
|
840
|
+
grid: args.grid, topRegions: args.top_regions, signal: exec.signal
|
|
740
841
|
});
|
|
842
|
+
const { ratio, diffPixels, totalPixels, width, height, worstRegions } = result.metrics;
|
|
741
843
|
const ref = await attachmentService(host).saveImage({
|
|
742
|
-
data: new Uint8Array(
|
|
743
|
-
mediaType:
|
|
844
|
+
data: new Uint8Array(result.bytes),
|
|
845
|
+
mediaType: result.mediaType,
|
|
744
846
|
name: `iris-diff-${Date.now()}.png`
|
|
745
847
|
});
|
|
746
848
|
const pct = (ratio * 100).toFixed(2);
|
|
747
849
|
const regions = worstRegions.map((r) => `(col ${r.col},row ${r.row}) ${(r.score * 100).toFixed(1)}%`).join(' ');
|
|
748
850
|
return {
|
|
851
|
+
artifactIds: [result.artifact.id],
|
|
749
852
|
blocks: [
|
|
750
853
|
{
|
|
751
854
|
type: 'text',
|
|
@@ -1012,14 +1115,16 @@ export async function runVisionTool(host, exec, { origin, model, question, ref,
|
|
|
1012
1115
|
async function describeFirstImage(host, ref, absPath, originalPrompt) {
|
|
1013
1116
|
try {
|
|
1014
1117
|
const mt = MEDIA_TYPES[path.extname(absPath).toLowerCase()] || 'image/png';
|
|
1015
|
-
|
|
1016
|
-
question: `用不超过两句话描述这张图片的主题与构图。生成该图的提示词是:「${String(originalPrompt).slice(0, 200)}」`,
|
|
1017
|
-
ref,
|
|
1018
|
-
dataUrl: toDataUrl(absPath, mt),
|
|
1019
|
-
signal: undefined
|
|
1020
|
-
});
|
|
1021
|
-
return answer.slice(0, 300);
|
|
1118
|
+
return await describeImageData(host, ref, toDataUrl(absPath, mt), originalPrompt);
|
|
1022
1119
|
} catch (_) {
|
|
1023
1120
|
return ''; // 自述是增强项,失败不影响交付
|
|
1024
1121
|
}
|
|
1025
1122
|
}
|
|
1123
|
+
|
|
1124
|
+
async function describeImageData(host, ref, dataUrl, originalPrompt) {
|
|
1125
|
+
const { answer } = await askVision(host, {
|
|
1126
|
+
question: `用不超过两句话描述这张图片的主题与构图。生成该图的提示词是:「${String(originalPrompt).slice(0, 200)}」`,
|
|
1127
|
+
ref, dataUrl, signal: undefined
|
|
1128
|
+
});
|
|
1129
|
+
return answer.slice(0, 300);
|
|
1130
|
+
}
|