@mokuyoaxis/dsh-iris 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dsh/skills/iris-compose-media/SKILL.md +23 -49
- package/.dsh/skills/iris-compose-media/references/task-v2-recovery.md +30 -0
- package/.dsh/skills/iris-compose-media/references/workflows.md +38 -0
- package/.dsh/skills/iris-verify-ui/SKILL.md +3 -25
- package/.dsh/skills/iris-verify-ui/agents/openai.yaml +4 -0
- package/.dsh/skills/iris-verify-ui/references/verdict-and-degradation.md +31 -0
- package/CHANGELOG.md +57 -2
- package/README.en.md +43 -7
- package/README.md +43 -7
- package/bin/dsh-iris.js +23 -0
- package/docs/ARCHITECTURE.md +95 -0
- package/docs/ARTIFACT_LIBRARY.md +24 -0
- package/docs/FAULT_INJECTION.md +26 -0
- package/docs/HOST_ADAPTER_CONTRACT.md +86 -0
- package/docs/HOST_DOCTOR.md +47 -0
- package/docs/PROVIDER_ADAPTER_CONTRACT.md +97 -0
- package/docs/PROVIDER_HEALTH.md +39 -0
- package/docs/PROVIDER_SUBMISSION_CONTRACT.md +60 -0
- package/docs/ROADMAP.md +21 -11
- package/docs/SECURITY.md +57 -0
- package/docs/TASK_SEMANTICS.md +148 -0
- package/docs/assets/diagrams/iris-architecture.png +0 -0
- package/docs/assets/diagrams/iris-architecture.svg +5 -5
- package/docs/releases/0.1.4.md +59 -0
- package/lib/actions.js +860 -290
- package/lib/adapters.js +7 -7
- package/lib/api.js +110 -18
- package/lib/artifacts.js +239 -0
- package/lib/bundled-skills.js +5 -5
- package/lib/client.js +478 -68
- package/lib/config.js +192 -9
- package/lib/doctor.js +414 -0
- package/lib/dsh-host-adapter.js +189 -0
- package/lib/host-contract.js +131 -0
- package/lib/host-runtime.js +156 -0
- package/lib/index.js +142 -157
- package/lib/locate.js +1 -1
- package/lib/media.js +16 -6
- package/lib/prompt-optimizer.js +10 -20
- package/lib/provider-adapter.js +371 -0
- package/lib/provider-adapters.js +279 -0
- package/lib/provider-contract.js +234 -0
- package/lib/provider-health.js +59 -0
- package/lib/render.js +43 -12
- package/lib/task-semantics.js +209 -0
- package/lib/tasks.js +500 -32
- package/lib/vision.js +23 -49
- package/package.json +19 -8
- package/user_guide.md +62 -2
- package/docs/assets/diagrams/iris-architecture.drawio +0 -1
- package/docs/assets/diagrams/iris-task-lifecycle.drawio +0 -1
- package/docs/assets/diagrams/iris-workflow-compose.drawio +0 -1
- package/docs/releases/0.1.1.md +0 -51
- package/docs/releases/0.1.2.md +0 -26
package/lib/index.js
CHANGED
|
@@ -14,17 +14,28 @@ import * as cap from './capability.js';
|
|
|
14
14
|
import * as tasks from './tasks.js';
|
|
15
15
|
import { registerMedia, mediaLinksOf, authorizeMedia, serveMedia } from './media.js';
|
|
16
16
|
import { serveApi, closeAllSse, purgeStaleUploads } from './api.js';
|
|
17
|
-
import {
|
|
17
|
+
import { buildVisionBackendsFromHost, askWithBackends } from './vision.js';
|
|
18
|
+
import { hasHostPort, requireHostPort } from './host-contract.js';
|
|
19
|
+
import { createDshHostAdapter } from './dsh-host-adapter.js';
|
|
18
20
|
import { cropImage, pixelDiff, imageDimensions } from './pixels.js';
|
|
19
21
|
import { locateObject } from './locate.js';
|
|
20
|
-
import { serveRender
|
|
22
|
+
import { serveRender } from './render.js';
|
|
21
23
|
import { ensurePrivateDir, hardenPrivateTree } from './private-storage.js';
|
|
22
24
|
import { guarded } from './guard.js';
|
|
23
25
|
import { longOcr } from './ocr.js';
|
|
24
|
-
import { runAction, listActions } from './actions.js';
|
|
26
|
+
import { runAction, listActions, taskPollDeps } from './actions.js';
|
|
25
27
|
import { ffmpegAvailable, extractFrames, probeVideo, extractAudioTrack } from './media-probe.js';
|
|
26
28
|
import { buildContactSheet, summarizeMedia } from './summarize.js';
|
|
27
29
|
import { registerBundledSkills } from './bundled-skills.js';
|
|
30
|
+
import {
|
|
31
|
+
EXPECTED_IRIS_ROUTES, IRIS_PLUGIN_ID, beginHostRuntime, recordHostRoutes, recordHostSkills, recordHostTool
|
|
32
|
+
} from './host-runtime.js';
|
|
33
|
+
export { doctor, formatDoctorReport, hostDoctor } from './doctor.js';
|
|
34
|
+
|
|
35
|
+
const PACKAGE_VERSION = (() => {
|
|
36
|
+
try { return JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version || 'unknown'; }
|
|
37
|
+
catch (_) { return 'unknown'; }
|
|
38
|
+
})();
|
|
28
39
|
|
|
29
40
|
const MEDIA_TYPES = {
|
|
30
41
|
'.png': 'image/png',
|
|
@@ -34,6 +45,11 @@ const MEDIA_TYPES = {
|
|
|
34
45
|
'.gif': 'image/gif'
|
|
35
46
|
};
|
|
36
47
|
|
|
48
|
+
/** DSH 私有对象只在宿主边界转换;可选服务可能晚到,因此按操作刷新快照。 */
|
|
49
|
+
function dshHost(ctx) {
|
|
50
|
+
return createDshHostAdapter(ctx);
|
|
51
|
+
}
|
|
52
|
+
|
|
37
53
|
/** 工具内同步等待上限;超过即转后台盯守,用 iris_task_status 查询 */
|
|
38
54
|
const AWAIT_MS = { image: 180000, video: 480000 };
|
|
39
55
|
|
|
@@ -46,10 +62,9 @@ function outputDir() {
|
|
|
46
62
|
|
|
47
63
|
let mediaRouteMounted = false;
|
|
48
64
|
function mountIrisRoutes(routeCtx) {
|
|
49
|
-
// Cordis
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
: undefined;
|
|
65
|
+
// Cordis 服务只在 DSH 宿主边界探测;Command 与通用工具辅助函数不接收原始 ctx
|
|
66
|
+
const host = dshHost(routeCtx);
|
|
67
|
+
const reg = hasHostPort(host, 'routes') ? host.ports.routes : undefined;
|
|
53
68
|
if (!reg || typeof reg.register !== 'function' || mediaRouteMounted) return;
|
|
54
69
|
try {
|
|
55
70
|
const disposeMedia = reg.register({
|
|
@@ -60,7 +75,7 @@ function mountIrisRoutes(routeCtx) {
|
|
|
60
75
|
const disposeApi = reg.register({
|
|
61
76
|
kind: 'prefix',
|
|
62
77
|
path: '/iris/api',
|
|
63
|
-
handler: guarded((req, res) => serveApi(req, res, routeCtx))
|
|
78
|
+
handler: guarded((req, res) => serveApi(req, res, dshHost(routeCtx)))
|
|
64
79
|
});
|
|
65
80
|
const disposeRender = reg.register({
|
|
66
81
|
kind: 'prefix',
|
|
@@ -68,6 +83,7 @@ function mountIrisRoutes(routeCtx) {
|
|
|
68
83
|
handler: guarded(serveRender)
|
|
69
84
|
});
|
|
70
85
|
mediaRouteMounted = true;
|
|
86
|
+
recordHostRoutes(EXPECTED_IRIS_ROUTES);
|
|
71
87
|
routeCtx.effect(() => () => {
|
|
72
88
|
if (typeof disposeMedia === 'function') disposeMedia();
|
|
73
89
|
if (typeof disposeApi === 'function') disposeApi();
|
|
@@ -81,48 +97,47 @@ function mountIrisRoutes(routeCtx) {
|
|
|
81
97
|
}
|
|
82
98
|
}
|
|
83
99
|
|
|
84
|
-
/**
|
|
100
|
+
/** 向后兼容导出;真实实现与人工接管共用 actions.taskPollDeps。 */
|
|
85
101
|
export function pollDeps(provider, cap) {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
+
return taskPollDeps(provider, { cap });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 旧 status 与 Task v2 事实轴共同决定工具是否应停止等待。 */
|
|
106
|
+
export function taskWaitFinished(task) {
|
|
107
|
+
if (!task) return false;
|
|
108
|
+
if (task.status !== 'running') return true;
|
|
109
|
+
return tasks.isV2Task(task) && task.phase === 'terminal';
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 把 v2 正交事实转换成不会误导用户的工具错误;空串表示已成功可交付。 */
|
|
113
|
+
export function taskTerminalProblem(task, label = '任务') {
|
|
114
|
+
if (!task) return label + '记录不存在';
|
|
115
|
+
if (!tasks.isV2Task(task)) {
|
|
116
|
+
if (task.status === 'canceled') return '已取消';
|
|
117
|
+
if (task.status !== 'succeeded') return label + '失败:' + (task.error || '未知原因');
|
|
118
|
+
return '';
|
|
102
119
|
}
|
|
103
|
-
return
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
119
|
-
};
|
|
120
|
+
if (task.outcome === 'succeeded' && task.deliveryState === 'ready') return '';
|
|
121
|
+
if (task.outcome === 'succeeded' && task.deliveryState === 'failed') {
|
|
122
|
+
return label + '已成功,但产物交付失败:' + (task.error || '可稍后重新交付;禁止重新生成');
|
|
123
|
+
}
|
|
124
|
+
if (task.acceptance === 'unknown' || task.outcome === 'unknown') {
|
|
125
|
+
return label + '的远端受理或结果状态未知:' + (task.error || '禁止自动重提,请先人工确认');
|
|
126
|
+
}
|
|
127
|
+
if (task.cancelState === 'unknown') {
|
|
128
|
+
return label + '的远端取消状态未知:' + (task.error || '请先人工确认');
|
|
129
|
+
}
|
|
130
|
+
if (task.outcome === 'canceled') return '已取消';
|
|
131
|
+
if (task.outcome === 'failed') return label + '失败:' + (task.error || '供应商明确返回失败');
|
|
132
|
+
return task.phase === 'terminal'
|
|
133
|
+
? label + '需要人工处理:' + (task.error || '状态未能自动收口')
|
|
134
|
+
: '';
|
|
120
135
|
}
|
|
121
136
|
|
|
122
137
|
/**
|
|
123
138
|
* 在工具调用内同步等待任务到终态。
|
|
124
139
|
* @returns 终态任务记录;null = 等待超时(任务已转后台继续盯守)
|
|
125
|
-
* @throws
|
|
140
|
+
* @throws 取消时抛错并记录保守取消事实
|
|
126
141
|
*/
|
|
127
142
|
async function awaitTerminal(taskId, { timeoutMs, signal }) {
|
|
128
143
|
const deadline = Date.now() + timeoutMs;
|
|
@@ -132,7 +147,7 @@ async function awaitTerminal(taskId, { timeoutMs, signal }) {
|
|
|
132
147
|
throw new Error('已取消');
|
|
133
148
|
}
|
|
134
149
|
const t = tasks.get(taskId);
|
|
135
|
-
if (t
|
|
150
|
+
if (taskWaitFinished(t)) return t;
|
|
136
151
|
if (Date.now() > deadline) return null;
|
|
137
152
|
await new Promise((r) => setTimeout(r, 1000));
|
|
138
153
|
}
|
|
@@ -174,13 +189,17 @@ function guard(label, fn) {
|
|
|
174
189
|
}
|
|
175
190
|
|
|
176
191
|
/** 单工具注册隔离:注册失败的收益是诊断日志,而不是宿主陪葬 */
|
|
177
|
-
function safeRegister(
|
|
192
|
+
function safeRegister(host, definition) {
|
|
193
|
+
let registered = false;
|
|
178
194
|
guard(`工具注册 ${definition && definition.name}`, () => {
|
|
179
|
-
|
|
195
|
+
requireHostPort(host, 'tools', '注册 Agent 工具').register(definition);
|
|
196
|
+
registered = true;
|
|
180
197
|
});
|
|
198
|
+
if (registered) recordHostTool(definition.name);
|
|
181
199
|
}
|
|
182
200
|
|
|
183
201
|
export async function apply(ctx) {
|
|
202
|
+
beginHostRuntime({ pluginId: IRIS_PLUGIN_ID, version: PACKAGE_VERSION });
|
|
184
203
|
guard('Iris 私有存储权限收紧', () => hardenPrivateTree(store.irisHome()));
|
|
185
204
|
|
|
186
205
|
guard('上传临时文件清理', () => purgeStaleUploads());
|
|
@@ -219,19 +238,20 @@ export async function apply(ctx) {
|
|
|
219
238
|
// 全局层;项目目录中的同名 Skill 仍由 DSH 原生优先级覆盖。
|
|
220
239
|
ctx.inject(['skills'], (skillCtx) => {
|
|
221
240
|
guard('随包 Skill 注册', () => {
|
|
222
|
-
const names = registerBundledSkills(skillCtx);
|
|
241
|
+
const names = registerBundledSkills(dshHost(skillCtx));
|
|
242
|
+
recordHostSkills(names);
|
|
223
243
|
console.log('[iris] 随包 Skill 已注册:' + names.join(' · '));
|
|
224
244
|
});
|
|
225
245
|
});
|
|
226
246
|
|
|
227
|
-
const
|
|
228
|
-
if (!
|
|
247
|
+
const host = dshHost(ctx);
|
|
248
|
+
if (!hasHostPort(host, 'tools')) {
|
|
229
249
|
console.error('[iris] tools 服务不可用,跳过全部工具注册(宿主不受影响)');
|
|
230
250
|
return;
|
|
231
251
|
}
|
|
232
252
|
|
|
233
253
|
/* ---------- 🖼️ 画图 ---------- */
|
|
234
|
-
safeRegister(
|
|
254
|
+
safeRegister(host, {
|
|
235
255
|
name: 'iris_draw_image',
|
|
236
256
|
description:
|
|
237
257
|
'Generate an image from a detailed prompt using the user-configured image model (DashScope wan* or OpenAI-images-compatible). ' +
|
|
@@ -254,23 +274,23 @@ export async function apply(ctx) {
|
|
|
254
274
|
async execute(args, exec) {
|
|
255
275
|
const prompt = String(args.prompt || '').trim();
|
|
256
276
|
if (!prompt) throw new Error('iris: prompt 不能为空');
|
|
257
|
-
const submitted = await runAction(ctx, 'image', args, { signal: exec.signal });
|
|
277
|
+
const submitted = await runAction(dshHost(ctx), 'image', args, { signal: exec.signal });
|
|
258
278
|
let final = tasks.get(submitted.taskId);
|
|
259
|
-
if (final && final
|
|
279
|
+
if (final && !taskWaitFinished(final)) {
|
|
260
280
|
final = await awaitTerminal(submitted.taskId, { timeoutMs: AWAIT_MS.image, signal: exec.signal });
|
|
261
281
|
}
|
|
262
282
|
if (!final) {
|
|
263
283
|
return { blocks: [{ type: 'text', text: `[iris] 图像任务仍在后台盯守(task: ${submitted.taskId})。稍后可用 iris_task_status 查询进度与文件路径。` }] };
|
|
264
284
|
}
|
|
265
|
-
|
|
266
|
-
if (
|
|
285
|
+
const terminalProblem = taskTerminalProblem(final, '图像生成');
|
|
286
|
+
if (terminalProblem) throw new Error(terminalProblem);
|
|
267
287
|
|
|
268
288
|
const files = (final.files || []).map((f) => path.join(outputDir(), f));
|
|
269
289
|
const blocks = [];
|
|
270
290
|
const refs = [];
|
|
271
291
|
for (const f of files) {
|
|
272
292
|
const mediaType = MEDIA_TYPES[path.extname(f).toLowerCase()] || 'image/png';
|
|
273
|
-
const ref = await attachmentService(
|
|
293
|
+
const ref = await attachmentService(host).saveImage({ data: new Uint8Array(fs.readFileSync(f)), mediaType, name: path.basename(f) });
|
|
274
294
|
refs.push(ref);
|
|
275
295
|
blocks.push({ type: 'image', attachment: ref });
|
|
276
296
|
}
|
|
@@ -284,7 +304,7 @@ export async function apply(ctx) {
|
|
|
284
304
|
|
|
285
305
|
let note = '';
|
|
286
306
|
try {
|
|
287
|
-
const described = await describeFirstImage(ctx, refs[0], files[0], prompt);
|
|
307
|
+
const described = await describeFirstImage(dshHost(ctx), refs[0], files[0], prompt);
|
|
288
308
|
if (described) note = `画面内容:${described}`;
|
|
289
309
|
} catch (_) { /* 增强失败不影响生成结果 */ }
|
|
290
310
|
blocks.unshift({
|
|
@@ -298,7 +318,7 @@ export async function apply(ctx) {
|
|
|
298
318
|
});
|
|
299
319
|
|
|
300
320
|
/* ---------- 🎬 视频生成 ---------- */
|
|
301
|
-
safeRegister(
|
|
321
|
+
safeRegister(host, {
|
|
302
322
|
name: 'iris_generate_video',
|
|
303
323
|
description:
|
|
304
324
|
'Generate a video with the configured DashScope video model. Three modes: ' +
|
|
@@ -323,14 +343,14 @@ export async function apply(ctx) {
|
|
|
323
343
|
output: { schema: { type: 'string' }, render: (_args, v) => [{ type: 'text', text: v }] },
|
|
324
344
|
isConcurrencySafe: () => false,
|
|
325
345
|
async execute(args, exec) {
|
|
326
|
-
const submitted = await runAction(ctx, 'video', args, { signal: exec.signal });
|
|
346
|
+
const submitted = await runAction(dshHost(ctx), 'video', args, { signal: exec.signal });
|
|
327
347
|
const final = await awaitTerminal(submitted.taskId, { timeoutMs: AWAIT_MS.video, signal: exec.signal });
|
|
328
348
|
if (!final) {
|
|
329
349
|
return `[iris] 视频任务已提交并转入后台盯守(task: ${submitted.taskId},remoteTask: ${submitted.remoteTaskId})。\n` +
|
|
330
350
|
'渲染通常需要数分钟;完成后用 iris_task_status 查询产物路径。';
|
|
331
351
|
}
|
|
332
|
-
|
|
333
|
-
if (
|
|
352
|
+
const terminalProblem = taskTerminalProblem(final, '视频生成');
|
|
353
|
+
if (terminalProblem) throw new Error(terminalProblem);
|
|
334
354
|
const paths = (final.files || []).map((f) => path.join(outputDir(), f));
|
|
335
355
|
const links = mediaLinksOf(final);
|
|
336
356
|
const secs = Math.round((Date.now() - new Date(final.createdAt).getTime()) / 1000);
|
|
@@ -342,7 +362,7 @@ export async function apply(ctx) {
|
|
|
342
362
|
});
|
|
343
363
|
|
|
344
364
|
/* ---------- 🔊 语音合成 ---------- */
|
|
345
|
-
safeRegister(
|
|
365
|
+
safeRegister(host, {
|
|
346
366
|
name: 'iris_speak_text',
|
|
347
367
|
description: 'Synthesize speech audio from text using the configured TTS model (qwen-tts / compatible). Returns the saved audio file path.',
|
|
348
368
|
parameters: {
|
|
@@ -357,9 +377,10 @@ export async function apply(ctx) {
|
|
|
357
377
|
output: { schema: { type: 'string' }, render: (_args, v) => [{ type: 'text', text: v }] },
|
|
358
378
|
isConcurrencySafe: () => true,
|
|
359
379
|
async execute(args, exec) {
|
|
360
|
-
const submitted = await runAction(ctx, 'tts', args, { signal: exec.signal });
|
|
380
|
+
const submitted = await runAction(dshHost(ctx), 'tts', args, { signal: exec.signal });
|
|
361
381
|
const final = tasks.get(submitted.taskId);
|
|
362
|
-
|
|
382
|
+
const terminalProblem = taskTerminalProblem(final, '语音合成');
|
|
383
|
+
if (terminalProblem) throw new Error(terminalProblem);
|
|
363
384
|
const fileName = (final.files || [])[0];
|
|
364
385
|
const p = path.join(outputDir(), fileName);
|
|
365
386
|
const media = (final.media || [])[0];
|
|
@@ -369,7 +390,7 @@ export async function apply(ctx) {
|
|
|
369
390
|
});
|
|
370
391
|
|
|
371
392
|
/* ---------- 🎙️ 音频转写(阶段 7.2) ---------- */
|
|
372
|
-
safeRegister(
|
|
393
|
+
safeRegister(host, {
|
|
373
394
|
name: 'iris_transcribe_audio',
|
|
374
395
|
description: 'Transcribe an audio file (wav/mp3) to text using the configured DashScope provider (qwen-audio-3.0-asr-flash-filetrans). The audio is uploaded to Bailian temp storage, then an async task is submitted. Returns the full recognized text.',
|
|
375
396
|
parameters: {
|
|
@@ -383,17 +404,17 @@ export async function apply(ctx) {
|
|
|
383
404
|
output: { schema: { type: 'string' }, render: (_args, v) => [{ type: 'text', text: v }] },
|
|
384
405
|
isConcurrencySafe: () => false,
|
|
385
406
|
async execute(args, exec) {
|
|
386
|
-
const submitted = await runAction(ctx, 'transcribe', args, { signal: exec.signal });
|
|
407
|
+
const submitted = await runAction(dshHost(ctx), 'transcribe', args, { signal: exec.signal });
|
|
387
408
|
const final = await awaitTerminal(submitted.taskId, { timeoutMs: 120000, signal: exec.signal });
|
|
388
409
|
if (!final) return `[iris] 音频转写任务已提交并转入后台(task: ${submitted.taskId})。完成后用 iris_task_status 查询。`;
|
|
389
|
-
|
|
390
|
-
if (
|
|
410
|
+
const terminalProblem = taskTerminalProblem(final, '音频转写');
|
|
411
|
+
if (terminalProblem) throw new Error(terminalProblem);
|
|
391
412
|
return `[iris] 音频转写完成(${submitted.model || final.model}):\n${final.transcribeText || ''}`;
|
|
392
413
|
}
|
|
393
414
|
});
|
|
394
415
|
|
|
395
416
|
/* ---------- 🎞️ 视频抽帧(阶段 7.1,ffmpeg 可选) ---------- */
|
|
396
|
-
safeRegister(
|
|
417
|
+
safeRegister(host, {
|
|
397
418
|
name: 'iris_video_frames',
|
|
398
419
|
description:
|
|
399
420
|
'Extract N frames (uniformly sampled across the video) from a local video file using the system ffmpeg. ' +
|
|
@@ -432,7 +453,7 @@ export async function apply(ctx) {
|
|
|
432
453
|
const refs = [];
|
|
433
454
|
for (let i = 0; i < frames.length; i++) {
|
|
434
455
|
const f = frames[i];
|
|
435
|
-
const ref = await attachmentService(
|
|
456
|
+
const ref = await attachmentService(host).saveImage({
|
|
436
457
|
data: new Uint8Array(f.buffer),
|
|
437
458
|
mediaType,
|
|
438
459
|
name: `iris-frame-${Date.now()}-${i + 1}.${ext}`
|
|
@@ -454,7 +475,7 @@ export async function apply(ctx) {
|
|
|
454
475
|
});
|
|
455
476
|
|
|
456
477
|
/* ---------- 📝 多模态视频摘要(阶段 7.3,ffmpeg 可选) ---------- */
|
|
457
|
-
safeRegister(
|
|
478
|
+
safeRegister(host, {
|
|
458
479
|
name: 'iris_media_summarize',
|
|
459
480
|
description:
|
|
460
481
|
'Summarize a local video by compressing it into a contact sheet of sampled frames (with timestamps) plus an optional ' +
|
|
@@ -498,7 +519,7 @@ export async function apply(ctx) {
|
|
|
498
519
|
try {
|
|
499
520
|
const audio = await extractAudioTrack({ inputPath: p, signal: exec.signal });
|
|
500
521
|
try {
|
|
501
|
-
const submitted = await runAction(ctx, 'transcribe', { audio_path: audio.filePath }, { signal: exec.signal });
|
|
522
|
+
const submitted = await runAction(dshHost(ctx), 'transcribe', { audio_path: audio.filePath }, { signal: exec.signal });
|
|
502
523
|
const final = await awaitTerminal(submitted.taskId, { timeoutMs: 120000, signal: exec.signal });
|
|
503
524
|
if (final && final.status === 'succeeded') transcript = final.transcribeText || '';
|
|
504
525
|
else if (!final) transcriptNote = `\n(音轨转写仍在后台:task ${submitted.taskId},本次摘要未含语音内容)`;
|
|
@@ -515,13 +536,13 @@ export async function apply(ctx) {
|
|
|
515
536
|
|
|
516
537
|
// contact sheet → attachment → 视觉摘要
|
|
517
538
|
const sheet = await buildContactSheet({ frames, cols: 3 });
|
|
518
|
-
const ref = await attachmentService(
|
|
539
|
+
const ref = await attachmentService(host).saveImage({
|
|
519
540
|
data: new Uint8Array(sheet.buffer),
|
|
520
541
|
mediaType: 'image/png',
|
|
521
542
|
name: `iris-sheet-${Date.now()}.png`
|
|
522
543
|
});
|
|
523
544
|
const visionProviders = store.pickAllFor(cap.CAPABILITIES.VISION);
|
|
524
|
-
const backends =
|
|
545
|
+
const backends = buildVisionBackendsFromHost(dshHost(ctx), { providers: visionProviders, model: args.model });
|
|
525
546
|
const { answer, via, model } = await summarizeMedia({
|
|
526
547
|
backends, question: args.question, transcript,
|
|
527
548
|
imageDataUrl: `data:image/png;base64,${sheet.buffer.toString('base64')}`,
|
|
@@ -545,7 +566,7 @@ export async function apply(ctx) {
|
|
|
545
566
|
|
|
546
567
|
/* ---------- 👁 视觉路由(M3):显式工具,自持栈为主 ---------- */
|
|
547
568
|
|
|
548
|
-
safeRegister(
|
|
569
|
+
safeRegister(host, {
|
|
549
570
|
name: 'iris_look_at_image',
|
|
550
571
|
description:
|
|
551
572
|
'Look at a local image file and answer one question about it with the configured vision model (qwen-vl via the self-hosted provider stack; falls back to the global DSH vision model). The image is saved as a durable attachment so follow-ups can reference it.',
|
|
@@ -570,12 +591,12 @@ export async function apply(ctx) {
|
|
|
570
591
|
if (exec.signal && exec.signal.aborted) throw new Error('已取消');
|
|
571
592
|
|
|
572
593
|
const dataUrl = toDataUrl(p, mt);
|
|
573
|
-
const ref = await attachmentService(
|
|
574
|
-
return runVisionTool(ctx, exec, { origin: 'tool', model: args.model, question, ref, dataUrl });
|
|
594
|
+
const ref = await attachmentService(host).saveImage({ data: new Uint8Array(fs.readFileSync(p)), mediaType: mt, name: path.basename(p) });
|
|
595
|
+
return runVisionTool(dshHost(ctx), exec, { origin: 'tool', model: args.model, question, ref, dataUrl });
|
|
575
596
|
}
|
|
576
597
|
});
|
|
577
598
|
|
|
578
|
-
safeRegister(
|
|
599
|
+
safeRegister(host, {
|
|
579
600
|
name: 'iris_relook_attachment',
|
|
580
601
|
description:
|
|
581
602
|
'Ask a NEW question about an image that was already seen in this session: pass its attachment_id (from user uploads, tool results, or any iris-generated image). Pixels are re-read via the vision model.',
|
|
@@ -598,22 +619,22 @@ export async function apply(ctx) {
|
|
|
598
619
|
if (exec.signal && exec.signal.aborted) throw new Error('已取消');
|
|
599
620
|
|
|
600
621
|
// 定位:本会话出现过的图片优先,其次本插件产物记录
|
|
601
|
-
const hit = (await sessionAttachmentRef(
|
|
622
|
+
const hit = (await sessionAttachmentRef(host, exec, id)) || findOwnAttachment(id);
|
|
602
623
|
if (!hit) throw new Error('iris: 该 attachment 不在本会话中、也不是 iris 生成的图片:' + id);
|
|
603
624
|
|
|
604
625
|
let dataUrl;
|
|
605
626
|
if (hit.absPath) {
|
|
606
627
|
dataUrl = toDataUrl(hit.absPath, hit.ref.mediaType || MEDIA_TYPES[path.extname(hit.absPath).toLowerCase()] || 'image/png');
|
|
607
628
|
} else {
|
|
608
|
-
const stored = await attachmentService(
|
|
629
|
+
const stored = await attachmentService(host).readImage(hit.ref, exec.signal);
|
|
609
630
|
dataUrl = `data:${stored.mediaType || hit.ref.mediaType};base64,${Buffer.from(stored.data).toString('base64')}`;
|
|
610
631
|
}
|
|
611
|
-
return runVisionTool(ctx, exec, { origin: 'relook', model: args.model, question, ref: hit.ref, dataUrl });
|
|
632
|
+
return runVisionTool(dshHost(ctx), exec, { origin: 'relook', model: args.model, question, ref: hit.ref, dataUrl });
|
|
612
633
|
}
|
|
613
634
|
});
|
|
614
635
|
|
|
615
636
|
/* ---------- 📋 任务查询 ---------- */
|
|
616
|
-
safeRegister(
|
|
637
|
+
safeRegister(host, {
|
|
617
638
|
name: 'iris_task_status',
|
|
618
639
|
description:
|
|
619
640
|
'Query iris generation tasks (image/video/tts). Pass a task_id for one task, or omit it to list the latest tasks with status, progress, errors and output file paths.',
|
|
@@ -639,7 +660,7 @@ export async function apply(ctx) {
|
|
|
639
660
|
});
|
|
640
661
|
|
|
641
662
|
/* ---------- ✂️ 确定性像素工具(阶段 2) ---------- */
|
|
642
|
-
safeRegister(
|
|
663
|
+
safeRegister(host, {
|
|
643
664
|
name: 'iris_crop',
|
|
644
665
|
description:
|
|
645
666
|
'Crop a rectangular region from an image (absolute local path or a session attachment_id) and save the result as a durable DSH attachment. ' +
|
|
@@ -660,7 +681,7 @@ export async function apply(ctx) {
|
|
|
660
681
|
isConcurrencySafe: () => true,
|
|
661
682
|
async execute(args, exec) {
|
|
662
683
|
if (exec.signal && exec.signal.aborted) throw new Error('已取消');
|
|
663
|
-
const input = await resolveImageInput(ctx, exec, {
|
|
684
|
+
const input = await resolveImageInput(dshHost(ctx), exec, {
|
|
664
685
|
image_path: args.image_path,
|
|
665
686
|
attachment_id: args.attachment_id
|
|
666
687
|
});
|
|
@@ -671,7 +692,7 @@ export async function apply(ctx) {
|
|
|
671
692
|
width: args.width,
|
|
672
693
|
height: args.height
|
|
673
694
|
});
|
|
674
|
-
const ref = await attachmentService(
|
|
695
|
+
const ref = await attachmentService(host).saveImage({
|
|
675
696
|
data: new Uint8Array(buffer),
|
|
676
697
|
mediaType: mime,
|
|
677
698
|
name: `iris-crop-${Date.now()}.png`
|
|
@@ -685,7 +706,7 @@ export async function apply(ctx) {
|
|
|
685
706
|
}
|
|
686
707
|
});
|
|
687
708
|
|
|
688
|
-
safeRegister(
|
|
709
|
+
safeRegister(host, {
|
|
689
710
|
name: 'iris_pixel_diff',
|
|
690
711
|
description:
|
|
691
712
|
'Compute a pixel-level difference between two images (absolute local paths or session attachment_ids). ' +
|
|
@@ -709,15 +730,15 @@ export async function apply(ctx) {
|
|
|
709
730
|
if (exec.signal && exec.signal.aborted) throw new Error('已取消');
|
|
710
731
|
if (!(args.image_a_path || args.attachment_a_id)) throw new Error('iris: 需要 image_a_path 或 attachment_a_id');
|
|
711
732
|
if (!(args.image_b_path || args.attachment_b_id)) throw new Error('iris: 需要 image_b_path 或 attachment_b_id');
|
|
712
|
-
const a = await resolveImageInput(ctx, exec, { image_path: args.image_a_path, attachment_id: args.attachment_a_id });
|
|
713
|
-
const b = await resolveImageInput(ctx, exec, { image_path: args.image_b_path, attachment_id: args.attachment_b_id });
|
|
733
|
+
const a = await resolveImageInput(dshHost(ctx), exec, { image_path: args.image_a_path, attachment_id: args.attachment_a_id });
|
|
734
|
+
const b = await resolveImageInput(dshHost(ctx), exec, { image_path: args.image_b_path, attachment_id: args.attachment_b_id });
|
|
714
735
|
const { ratio, diffPixels, totalPixels, width, height, worstRegions, heatmap, mime } = await pixelDiff({
|
|
715
736
|
inputA: a.buffer,
|
|
716
737
|
inputB: b.buffer,
|
|
717
738
|
grid: args.grid || 8,
|
|
718
739
|
topRegions: args.top_regions || 3
|
|
719
740
|
});
|
|
720
|
-
const ref = await attachmentService(
|
|
741
|
+
const ref = await attachmentService(host).saveImage({
|
|
721
742
|
data: new Uint8Array(heatmap),
|
|
722
743
|
mediaType: mime,
|
|
723
744
|
name: `iris-diff-${Date.now()}.png`
|
|
@@ -739,7 +760,7 @@ export async function apply(ctx) {
|
|
|
739
760
|
});
|
|
740
761
|
|
|
741
762
|
/* ---------- 📍 模型驱动定位(阶段 3A) ---------- */
|
|
742
|
-
safeRegister(
|
|
763
|
+
safeRegister(host, {
|
|
743
764
|
name: 'iris_locate',
|
|
744
765
|
description:
|
|
745
766
|
'Locate an object/region described by `target` in an image (absolute path or session attachment_id), ' +
|
|
@@ -759,14 +780,14 @@ export async function apply(ctx) {
|
|
|
759
780
|
isConcurrencySafe: () => true,
|
|
760
781
|
async execute(args, exec) {
|
|
761
782
|
if (exec.signal && exec.signal.aborted) throw new Error('已取消');
|
|
762
|
-
const input = await resolveImageInput(ctx, exec, {
|
|
783
|
+
const input = await resolveImageInput(dshHost(ctx), exec, {
|
|
763
784
|
image_path: args.image_path,
|
|
764
785
|
attachment_id: args.attachment_id
|
|
765
786
|
});
|
|
766
787
|
const { width, height } = await imageDimensions(input.buffer);
|
|
767
788
|
const dataUrl = `data:${input.mediaType};base64,${input.buffer.toString('base64')}`;
|
|
768
789
|
const visionProviders = store.pickAllFor(cap.CAPABILITIES.VISION);
|
|
769
|
-
const backends =
|
|
790
|
+
const backends = buildVisionBackendsFromHost(dshHost(ctx), { providers: visionProviders, model: args.model });
|
|
770
791
|
const r = await locateObject(backends, {
|
|
771
792
|
target: args.target,
|
|
772
793
|
imageDataUrl: dataUrl,
|
|
@@ -781,7 +802,7 @@ export async function apply(ctx) {
|
|
|
781
802
|
});
|
|
782
803
|
|
|
783
804
|
/* ---------- 🖼️ HTML 截图(阶段 3C,基于 dsh-builtin-browser) ---------- */
|
|
784
|
-
safeRegister(
|
|
805
|
+
safeRegister(host, {
|
|
785
806
|
name: 'iris_html_screenshot',
|
|
786
807
|
description:
|
|
787
808
|
'Render an HTML string to a page screenshot using the shared browser. ' +
|
|
@@ -803,19 +824,17 @@ export async function apply(ctx) {
|
|
|
803
824
|
isConcurrencySafe: () => false,
|
|
804
825
|
async execute(args, exec) {
|
|
805
826
|
if (exec.signal && exec.signal.aborted) throw new Error('已取消');
|
|
806
|
-
const browser = ctx
|
|
807
|
-
|
|
808
|
-
throw new Error('iris: 浏览器服务不可用(ctx.get("browser") 为空)——需要启用 dsh-builtin-browser 插件');
|
|
809
|
-
}
|
|
810
|
-
const renderer = new BrowserHtmlRenderer({ browser });
|
|
811
|
-
const { png } = await renderer.render({
|
|
827
|
+
const browser = requireHostPort(dshHost(ctx), 'browser', 'HTML 截图');
|
|
828
|
+
const rendered = await browser.renderHtml({
|
|
812
829
|
html: args.html,
|
|
813
830
|
width: args.width,
|
|
814
831
|
height: args.height,
|
|
815
|
-
fullPage: args.fullPage !== false
|
|
832
|
+
fullPage: args.fullPage !== false,
|
|
833
|
+
signal: exec.signal
|
|
816
834
|
});
|
|
835
|
+
const png = Buffer.from(rendered.bytes);
|
|
817
836
|
const { width: outW, height: outH } = await imageDimensions(png);
|
|
818
|
-
const ref = await attachmentService(
|
|
837
|
+
const ref = await attachmentService(host).saveImage({
|
|
819
838
|
data: new Uint8Array(png),
|
|
820
839
|
mediaType: 'image/png',
|
|
821
840
|
name: `iris-html-${Date.now()}.png`
|
|
@@ -835,7 +854,7 @@ export async function apply(ctx) {
|
|
|
835
854
|
});
|
|
836
855
|
|
|
837
856
|
/* ---------- 📄 长截图 OCR(阶段 3B,视觉模型分块) ---------- */
|
|
838
|
-
safeRegister(
|
|
857
|
+
safeRegister(host, {
|
|
839
858
|
name: 'iris_long_ocr',
|
|
840
859
|
description:
|
|
841
860
|
'OCR a (possibly long) image: slice it into chunks (1200px tall with 120px overlap by default) ' +
|
|
@@ -855,12 +874,12 @@ export async function apply(ctx) {
|
|
|
855
874
|
isConcurrencySafe: () => true,
|
|
856
875
|
async execute(args, exec) {
|
|
857
876
|
if (exec.signal && exec.signal.aborted) throw new Error('已取消');
|
|
858
|
-
const input = await resolveImageInput(ctx, exec, {
|
|
877
|
+
const input = await resolveImageInput(dshHost(ctx), exec, {
|
|
859
878
|
image_path: args.image_path,
|
|
860
879
|
attachment_id: args.attachment_id
|
|
861
880
|
});
|
|
862
881
|
const visionProviders = store.pickAllFor(cap.CAPABILITIES.VISION);
|
|
863
|
-
const backends =
|
|
882
|
+
const backends = buildVisionBackendsFromHost(dshHost(ctx), { providers: visionProviders });
|
|
864
883
|
const result = await longOcr({
|
|
865
884
|
input: input.buffer,
|
|
866
885
|
backends,
|
|
@@ -880,13 +899,13 @@ export async function apply(ctx) {
|
|
|
880
899
|
|
|
881
900
|
/* ---------------- 视觉路由(M3 补全):look/relook 共用执行器 + 降级链 ---------------- */
|
|
882
901
|
|
|
883
|
-
/**
|
|
884
|
-
function attachmentService(
|
|
885
|
-
const
|
|
886
|
-
if (
|
|
887
|
-
throw new Error('iris:
|
|
902
|
+
/** 通用工具只消费 Host attachments 端口。 */
|
|
903
|
+
function attachmentService(host) {
|
|
904
|
+
const attachments = requireHostPort(host, 'attachments', '处理 DSH 附件');
|
|
905
|
+
if (typeof attachments.saveImage !== 'function' || typeof attachments.readImage !== 'function') {
|
|
906
|
+
throw new Error('iris: Host attachments 端口缺少 saveImage/readImage');
|
|
888
907
|
}
|
|
889
|
-
return
|
|
908
|
+
return attachments;
|
|
890
909
|
}
|
|
891
910
|
|
|
892
911
|
/**
|
|
@@ -894,7 +913,7 @@ function attachmentService(ctx) {
|
|
|
894
913
|
* crop / pixel_diff 共用(阶段 2)。
|
|
895
914
|
* @returns {Promise<{buffer:Buffer, mediaType:string, name:string}>}
|
|
896
915
|
*/
|
|
897
|
-
async function resolveImageInput(
|
|
916
|
+
async function resolveImageInput(host, exec, { image_path, attachment_id }) {
|
|
898
917
|
if (image_path) {
|
|
899
918
|
const p = String(image_path).trim();
|
|
900
919
|
if (!path.isAbsolute(p)) throw new Error('iris: image_path 必须是绝对路径');
|
|
@@ -905,7 +924,7 @@ async function resolveImageInput(ctx, exec, { image_path, attachment_id }) {
|
|
|
905
924
|
}
|
|
906
925
|
if (attachment_id) {
|
|
907
926
|
const id = String(attachment_id).trim();
|
|
908
|
-
const hit = (await sessionAttachmentRef(
|
|
927
|
+
const hit = (await sessionAttachmentRef(host, exec, id)) || findOwnAttachment(id);
|
|
909
928
|
if (!hit) throw new Error('iris: 该 attachment 不在本会话中、也不是 iris 生成的图片:' + id);
|
|
910
929
|
if (hit.absPath) {
|
|
911
930
|
return {
|
|
@@ -914,7 +933,7 @@ async function resolveImageInput(ctx, exec, { image_path, attachment_id }) {
|
|
|
914
933
|
name: path.basename(hit.absPath)
|
|
915
934
|
};
|
|
916
935
|
}
|
|
917
|
-
const stored = await attachmentService(
|
|
936
|
+
const stored = await attachmentService(host).readImage(hit.ref, exec && exec.signal);
|
|
918
937
|
return {
|
|
919
938
|
buffer: Buffer.from(stored.data),
|
|
920
939
|
mediaType: stored.mediaType || hit.ref.mediaType || 'image/png',
|
|
@@ -946,51 +965,17 @@ export function findOwnAttachment(attachmentId) {
|
|
|
946
965
|
* 不依赖精确事件类型:递归扫任何载荷中的 attachment 块 / attachmentId 字段。
|
|
947
966
|
* @returns {Promise<{ref:object}|null>} 找到则带可直接 readImage 的 ref
|
|
948
967
|
*/
|
|
949
|
-
export async function sessionAttachmentRef(
|
|
968
|
+
export async function sessionAttachmentRef(host, exec, attachmentId) {
|
|
950
969
|
const agent = exec && exec.agent;
|
|
951
970
|
const session = agent && agent.session;
|
|
952
|
-
const
|
|
953
|
-
if (!
|
|
954
|
-
let events;
|
|
971
|
+
const wanted = String(attachmentId || '');
|
|
972
|
+
if (!session || !wanted || !host?.ports?.sessions) return null;
|
|
955
973
|
try {
|
|
956
|
-
|
|
974
|
+
const ref = await host.ports.sessions.findImageAttachment(session.id, wanted);
|
|
975
|
+
return ref ? { ref } : null;
|
|
957
976
|
} catch (_) {
|
|
958
977
|
return null; // 读不到会话记录 → 交给 findOwnAttachment 兜底
|
|
959
978
|
}
|
|
960
|
-
if (!Array.isArray(events)) return null;
|
|
961
|
-
const wanted = String(attachmentId || '');
|
|
962
|
-
if (!wanted) return null;
|
|
963
|
-
const ref = findAttachmentRefIn(events, wanted);
|
|
964
|
-
return ref ? { ref } : null;
|
|
965
|
-
}
|
|
966
|
-
|
|
967
|
-
/** 递归扫描对象/数组,返回第一个匹配 wanted 的图片附件引用(防循环、限扫描量) */
|
|
968
|
-
function findAttachmentRefIn(nodes, wanted) {
|
|
969
|
-
const seen = new Set();
|
|
970
|
-
const stack = Array.isArray(nodes) ? [...nodes] : [nodes];
|
|
971
|
-
let scanned = 0;
|
|
972
|
-
while (stack.length && scanned < 4000) {
|
|
973
|
-
const node = stack.pop();
|
|
974
|
-
scanned++;
|
|
975
|
-
if (!node || typeof node !== 'object') continue;
|
|
976
|
-
if (seen.has(node)) continue;
|
|
977
|
-
seen.add(node);
|
|
978
|
-
// 形态 1:附件块 { type:'image', attachment:{ attachmentId,... } } —— 最优先
|
|
979
|
-
if (
|
|
980
|
-
node.attachment && typeof node.attachment === 'object' &&
|
|
981
|
-
node.attachment.attachmentId === wanted && typeof node.attachment.mediaType === 'string'
|
|
982
|
-
) {
|
|
983
|
-
return node.attachment;
|
|
984
|
-
}
|
|
985
|
-
// 形态 2:引用对象本身 { attachmentId, mediaType }(兜底)
|
|
986
|
-
if (node.attachmentId === wanted && typeof node.mediaType === 'string') {
|
|
987
|
-
return node;
|
|
988
|
-
}
|
|
989
|
-
for (const v of Object.values(node)) {
|
|
990
|
-
if (v && typeof v === 'object') stack.push(v);
|
|
991
|
-
}
|
|
992
|
-
}
|
|
993
|
-
return null;
|
|
994
979
|
}
|
|
995
980
|
|
|
996
981
|
/**
|
|
@@ -999,9 +984,9 @@ function findAttachmentRefIn(nodes, wanted) {
|
|
|
999
984
|
* 全局视觉模型兜底;每个后端的失败现场保留在 errors 里。
|
|
1000
985
|
* @returns {{answer:string,via:'selfstack'|'global',model?:string,backendId:string}}
|
|
1001
986
|
*/
|
|
1002
|
-
export async function askVision(
|
|
987
|
+
export async function askVision(host, { question, ref, dataUrl, signal, model }) {
|
|
1003
988
|
const visionProviders = store.pickAllFor(cap.CAPABILITIES.VISION);
|
|
1004
|
-
const backends =
|
|
989
|
+
const backends = buildVisionBackendsFromHost(host, { providers: visionProviders, model });
|
|
1005
990
|
return askWithBackends(backends, { question, ref, imageDataUrl: dataUrl, signal });
|
|
1006
991
|
}
|
|
1007
992
|
|
|
@@ -1009,9 +994,9 @@ export async function askVision(ctx, { question, ref, dataUrl, signal, model })
|
|
|
1009
994
|
* look / relook 共用执行器:看图问答 → 人话结果(标注模型归属,方便排查走哪条链)。
|
|
1010
995
|
* @returns {Promise<string>} 直接作为工具输出
|
|
1011
996
|
*/
|
|
1012
|
-
export async function runVisionTool(
|
|
997
|
+
export async function runVisionTool(host, exec, { origin, model, question, ref, dataUrl }) {
|
|
1013
998
|
if (exec && exec.signal && exec.signal.aborted) throw new Error('已取消');
|
|
1014
|
-
const { answer, via, model: usedModel } = await askVision(
|
|
999
|
+
const { answer, via, model: usedModel } = await askVision(host, {
|
|
1015
1000
|
question,
|
|
1016
1001
|
ref,
|
|
1017
1002
|
dataUrl,
|
|
@@ -1024,10 +1009,10 @@ export async function runVisionTool(ctx, exec, { origin, model, question, ref, d
|
|
|
1024
1009
|
}
|
|
1025
1010
|
|
|
1026
1011
|
/* ---------------- 视觉自述(画图后增强项):走 askVision 降级链,失败静默 ---------------- */
|
|
1027
|
-
async function describeFirstImage(
|
|
1012
|
+
async function describeFirstImage(host, ref, absPath, originalPrompt) {
|
|
1028
1013
|
try {
|
|
1029
1014
|
const mt = MEDIA_TYPES[path.extname(absPath).toLowerCase()] || 'image/png';
|
|
1030
|
-
const { answer } = await askVision(
|
|
1015
|
+
const { answer } = await askVision(host, {
|
|
1031
1016
|
question: `用不超过两句话描述这张图片的主题与构图。生成该图的提示词是:「${String(originalPrompt).slice(0, 200)}」`,
|
|
1032
1017
|
ref,
|
|
1033
1018
|
dataUrl: toDataUrl(absPath, mt),
|