@mokuyoaxis/dsh-iris 0.1.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/lib/actions.js ADDED
@@ -0,0 +1,930 @@
1
+ 'use strict';
2
+ /**
3
+ * Iris 动作执行层(阶段 5 GUI 操作面板)。
4
+ *
5
+ * 把工具与 GUI 共用的执行逻辑收敛为 action 函数,供:
6
+ * ① `POST /iris/api/actions/:name` 路由(GUI 直连,submit-only 不等待)
7
+ * ② 工具 execute 体(Agent 调用,可等待)
8
+ *
9
+ * 每个 action 是纯函数 `(ctx, args, { signal }) => result`,
10
+ * 结果只有可序列化 JSON(无 live 对象)。
11
+ */
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import * as adapters from './adapters.js';
15
+ import * as store from './config.js';
16
+ import * as cap from './capability.js';
17
+ import * as models from './models.js';
18
+ import * as tasks from './tasks.js';
19
+ import { registerMedia, mediaLinksOf, webBase } from './media.js';
20
+ import { buildVisionBackends, askWithBackends, testVisionCapability, RED_TEST_IMAGE } from './vision.js';
21
+ import { cropImage, pixelDiff, imageDimensions } from './pixels.js';
22
+ import { locateObject } from './locate.js';
23
+ import { BrowserHtmlRenderer } from './render.js';
24
+ import { longOcr } from './ocr.js';
25
+ import { ffmpegAvailable, extractFrames, probeVideo } from './media-probe.js';
26
+ import { buildContactSheet, summarizeMedia } from './summarize.js';
27
+
28
+ const MEDIA_TYPES = {
29
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
30
+ '.webp': 'image/webp', '.gif': 'image/gif'
31
+ };
32
+
33
+ /* ---------------- 视觉效果自述(辅助 iris_draw_image) ---------------- */
34
+ async function describeFirstImage(ctx, ref, absPath, originalPrompt) {
35
+ try {
36
+ const mt = MEDIA_TYPES[path.extname(absPath).toLowerCase()] || 'image/png';
37
+ const buf = fs.readFileSync(absPath);
38
+ const dataUrl = `data:${mt};base64,${buf.toString('base64')}`;
39
+ const backends = buildVisionBackends(ctx, { providers: store.pickAllFor(cap.CAPABILITIES.VISION) });
40
+ const { answer } = await askWithBackends(backends, {
41
+ question: `用不超过两句话描述这张图片的主题与构图。生成该图的提示词是:「${String(originalPrompt).slice(0, 200)}」`,
42
+ imageDataUrl: dataUrl,
43
+ signal: undefined
44
+ });
45
+ return answer.slice(0, 300);
46
+ } catch (_) { return ''; }
47
+ }
48
+
49
+ /* ---------------- IO 目录 ---------------- */
50
+ function outputDir() {
51
+ const dir = path.join(store.irisHome(), 'outputs');
52
+ fs.mkdirSync(dir, { recursive: true });
53
+ return dir;
54
+ }
55
+
56
+ function throwIfAborted(signal) {
57
+ if (signal && signal.aborted) {
58
+ const err = new Error('已取消');
59
+ err.name = 'AbortError';
60
+ throw err;
61
+ }
62
+ }
63
+
64
+ /**
65
+ * 生成类 failover 只覆盖“上传/提交/同步生成”阶段。远端一旦受理即绑定该任务,
66
+ * 不因后续轮询或结果下载失败重提,避免同一请求重复计费。
67
+ */
68
+ async function submitWithFailover(capability, label, signal, requestedModelRef, attempt) {
69
+ let providers = store.pickAllFor(capability);
70
+ const parsedRef = models.parseModelRef(String(requestedModelRef || '').trim());
71
+ if (parsedRef) {
72
+ const field = models.CAP_FIELD[capability];
73
+ providers = providers.filter((p) => p.id === parsedRef.providerId && (!field || p[field] === parsedRef.modelId));
74
+ if (!providers.length) throw new Error('iris: 指定的模型复合引用不可用或不具备' + label + '能力');
75
+ }
76
+ if (!providers.length) throw new Error('iris: 没有可用' + label + '供应商——先在 Iris 设置里添加、标注并分配模型');
77
+ const errors = [];
78
+ for (const provider of providers) {
79
+ throwIfAborted(signal);
80
+ try {
81
+ return await attempt(provider);
82
+ } catch (err) {
83
+ if (signal && signal.aborted) throwIfAborted(signal);
84
+ const field = models.CAP_FIELD[capability];
85
+ errors.push({
86
+ providerId: provider.id,
87
+ modelRef: field && provider[field] ? models.modelRef(provider.id, provider[field]) : provider.id,
88
+ message: String((err && err.message) || err)
89
+ });
90
+ }
91
+ }
92
+ const error = new Error('iris: ' + label + '提交失败;已按顺序尝试 ' + errors.length + ' 个模型:' +
93
+ errors.map((e) => e.modelRef + '(' + e.message + ')').join(';'));
94
+ error.errors = errors;
95
+ throw error;
96
+ }
97
+
98
+ function findGeneratedAttachment(attachmentId) {
99
+ for (const t of tasks.all()) {
100
+ for (const a of t.attachments || []) {
101
+ if (a.attachmentId === attachmentId) {
102
+ return { absPath: path.join(outputDir(), a.file), mediaType: a.mediaType };
103
+ }
104
+ }
105
+ }
106
+ return null;
107
+ }
108
+
109
+ function resolveFirstFrameFile(args) {
110
+ if (args.first_frame_attachment_id) {
111
+ const hit = findGeneratedAttachment(String(args.first_frame_attachment_id).trim());
112
+ if (!hit) throw new Error('iris: 找不到该 first_frame_attachment_id');
113
+ if (!fs.existsSync(hit.absPath)) throw new Error('iris: 首帧图片的本地缓存已被清理');
114
+ return hit;
115
+ }
116
+ if (args.first_frame_path) {
117
+ const p = String(args.first_frame_path).trim();
118
+ if (!path.isAbsolute(p)) throw new Error('iris: first_frame_path 必须是绝对路径');
119
+ const mediaType = MEDIA_TYPES[path.extname(p).toLowerCase()];
120
+ if (!mediaType) throw new Error('iris: 不支持的首帧图片格式');
121
+ if (!fs.existsSync(p)) throw new Error('iris: 首帧文件不存在:' + p);
122
+ return { absPath: p, mediaType };
123
+ }
124
+ return null;
125
+ }
126
+
127
+ function fileDataUrl(file) {
128
+ return 'data:' + file.mediaType + ';base64,' + fs.readFileSync(file.absPath).toString('base64');
129
+ }
130
+
131
+ /* ---------------- 动作注册表 ---------------- */
132
+
133
+ const actions = {};
134
+
135
+ /**
136
+ * 注册一个 action。
137
+ * @param {string} name 动作名(用于路由)
138
+ * @param {Function} fn (ctx, args, { signal }) => { ok, text?, imageDataUrl?, taskId?, blocks? }
139
+ */
140
+ function register(name, fn) {
141
+ actions[name] = fn;
142
+ }
143
+
144
+ /** 获取已注册的动作名列表 */
145
+ export function listActions() {
146
+ return Object.keys(actions);
147
+ }
148
+
149
+ /** 执行一个动作 */
150
+ export async function runAction(ctx, name, args, { signal } = {}) {
151
+ const fn = actions[name];
152
+ if (!fn) throw new Error(`iris: 未知动作 "${name}"`);
153
+ return fn(ctx, args, { signal });
154
+ }
155
+
156
+ /* ============ 动作实现 ============ */
157
+
158
+ /* ---------- 🖼️ 画图 ---------- */
159
+ register('image', async (ctx, args, { signal }) => {
160
+ const prompt = String(args.prompt || '').trim();
161
+ if (!prompt) throw new Error('iris: prompt 不能为空');
162
+ const submitted = await submitWithFailover(cap.CAPABILITIES.IMAGE, '图像', signal, args.model, async (provider) => {
163
+ const protocol = (provider.mediaProtocol || 'dashscope') === 'openai-images' ? 'openai-images' : 'dashscope';
164
+ const model = args.model ? (models.parseModelRef(String(args.model))?.modelId || String(args.model)) : (provider.imageModel || (protocol === 'openai-images' ? 'gpt-image-1' : 'wan2.2-t2i-flash'));
165
+ const task = tasks.create({
166
+ cap: 'image', providerId: provider.id, providerName: provider.name,
167
+ model, protocol, prompt: prompt.slice(0, 4000)
168
+ });
169
+ if (protocol === 'dashscope') {
170
+ const started = await tasks.submitGuard(task, () => adapters.startImageGeneration({
171
+ key: provider.apiKey, model, prompt, size: args.size, n: args.n, signal
172
+ }));
173
+ const remoteTaskId = started.remoteTaskId;
174
+ if (!remoteTaskId) {
175
+ try {
176
+ const names = [];
177
+ for (const url of started.urls || []) {
178
+ const name = `${task.id}-${names.length}.${adapters.extFromUrl(url, 'png')}`;
179
+ await adapters.downloadTo(url, path.join(outputDir(), name), { signal });
180
+ names.push(name);
181
+ }
182
+ tasks.update(task.id, { status: 'succeeded', files: names, finishedAt: new Date().toISOString() });
183
+ for (const name of names) registerMedia(task.id, path.join(outputDir(), name));
184
+ } catch (err) {
185
+ // 模型已经同步生成成功,转存失败不能再向下一供应商重复计费。
186
+ tasks.update(task.id, { status: 'failed', error: `图片转存失败:${String(err.message || err)}`, finishedAt: new Date().toISOString() });
187
+ }
188
+ return { task: tasks.get(task.id), provider, model, remoteTaskId: null };
189
+ }
190
+ tasks.update(task.id, { remoteTaskId });
191
+ tasks.watch(tasks.get(task.id), {
192
+ key: () => provider.apiKey,
193
+ poll: ({ key, remoteTaskId }) => adapters.pollTask({ key, remoteTaskId }),
194
+ intervalMs: 2500,
195
+ onSuccess: async (t, r) => {
196
+ const names = [];
197
+ for (const url of r.urls || []) {
198
+ const name = `${t.id}-${names.length}.${adapters.extFromUrl(url, 'png')}`;
199
+ await adapters.downloadTo(url, path.join(outputDir(), name));
200
+ names.push(name);
201
+ }
202
+ for (const n of names) registerMedia(t.id, path.join(outputDir(), n));
203
+ return names;
204
+ }
205
+ });
206
+ return { task, provider, model, remoteTaskId };
207
+ }
208
+ const files = await tasks.submitGuard(task, async () => {
209
+ const outs = await adapters.openAiGenerateImage({
210
+ key: provider.apiKey, baseUrl: provider.baseUrl, model, prompt, size: args.size, n: args.n, signal
211
+ });
212
+ const written = [];
213
+ for (let i = 0; i < outs.length; i++) {
214
+ const p = path.join(outputDir(), `${task.id}-${i}.png`);
215
+ if (outs[i].b64) fs.writeFileSync(p, Buffer.from(outs[i].b64, 'base64'));
216
+ else await adapters.downloadTo(outs[i].url, p, { signal });
217
+ written.push(p);
218
+ }
219
+ return written;
220
+ });
221
+ tasks.update(task.id, { status: 'succeeded', files: files.map((f) => path.basename(f)), finishedAt: new Date().toISOString() });
222
+ for (const f of files) registerMedia(task.id, f);
223
+ return { task, provider, model, remoteTaskId: null };
224
+ });
225
+ return {
226
+ ok: true, taskId: submitted.task.id, model: submitted.model, providerId: submitted.provider.id,
227
+ remoteTaskId: submitted.remoteTaskId,
228
+ text: submitted.remoteTaskId
229
+ ? `[iris] 图像任务已提交(${submitted.model},task: ${submitted.task.id})`
230
+ : `[iris] 图像任务已完成(${submitted.model},task: ${submitted.task.id})`
231
+ };
232
+ });
233
+
234
+ /* ---------- 🎬 视频生成 ---------- */
235
+ register('video', async (ctx, args, { signal }) => {
236
+ const prompt = String(args.prompt || '').trim();
237
+ if (!prompt && !args.audio_path) throw new Error('iris: prompt 不能为空');
238
+ const frame = resolveFirstFrameFile(args);
239
+ const submitted = await submitWithFailover(cap.CAPABILITIES.VIDEO, '视频', signal, args.model, async (provider) => {
240
+ const model = args.model ? (models.parseModelRef(String(args.model))?.modelId || String(args.model)) : (provider.videoModel || 'wan2.2-t2v-flash');
241
+ const isS2V = /s2v/i.test(model);
242
+ if (!isS2V && !prompt) throw new Error('iris: 文生/图生视频需要 prompt');
243
+ const mode = isS2V ? 's2v' : (frame ? 'i2v' : 't2v');
244
+ const task = tasks.create({
245
+ cap: 'video', providerId: provider.id, providerName: provider.name, model, mode,
246
+ ...(isS2V ? { resolution: args.resolution || '480P' } : {
247
+ size: args.size || '1280*720', ...(args.duration ? { duration: Number(args.duration) } : {})
248
+ }),
249
+ prompt: prompt.slice(0, 4000)
250
+ });
251
+ const remoteTaskId = await tasks.submitGuard(task, async () => {
252
+ let submitArgs;
253
+ if (isS2V) {
254
+ if (!frame) throw new Error('iris: s2v 数字人需要首帧');
255
+ const audioPath = String(args.audio_path || '').trim();
256
+ if (!audioPath || !path.isAbsolute(audioPath)) throw new Error('iris: s2v 的 audio_path 必须是绝对路径');
257
+ if (!fs.existsSync(audioPath)) throw new Error('iris: 音频文件不存在:' + audioPath);
258
+ const imageUrl = await adapters.uploadTempFile({ key: provider.apiKey, model, filePath: frame.absPath, signal });
259
+ const audioUrl = await adapters.uploadTempFile({ key: provider.apiKey, model, filePath: audioPath, signal });
260
+ submitArgs = { imgDataUrl: imageUrl, audioUrl, resolution: args.resolution || '480P' };
261
+ } else {
262
+ submitArgs = {
263
+ prompt,
264
+ imgDataUrl: frame ? fileDataUrl(frame) : undefined,
265
+ size: args.size || '1280*720',
266
+ duration: args.duration ? Number(args.duration) : undefined
267
+ };
268
+ }
269
+ return adapters.submitVideo({ key: provider.apiKey, model, ...submitArgs, signal });
270
+ });
271
+ tasks.update(task.id, { remoteTaskId });
272
+ tasks.watch(tasks.get(task.id), {
273
+ key: () => provider.apiKey,
274
+ poll: ({ key, remoteTaskId: id }) => adapters.pollTask({ key, remoteTaskId: id }),
275
+ intervalMs: 6000,
276
+ onSuccess: async (t, r) => {
277
+ const names = [];
278
+ for (const url of r.urls || []) {
279
+ const name = `${t.id}-${names.length}.${adapters.extFromUrl(url, 'mp4')}`;
280
+ await adapters.downloadTo(url, path.join(outputDir(), name));
281
+ names.push(name);
282
+ }
283
+ for (const n of names) registerMedia(t.id, path.join(outputDir(), n));
284
+ return names;
285
+ }
286
+ });
287
+ return { task, provider, model, remoteTaskId };
288
+ });
289
+ return {
290
+ ok: true, taskId: submitted.task.id, model: submitted.model, mode: submitted.task.mode,
291
+ providerId: submitted.provider.id, remoteTaskId: submitted.remoteTaskId,
292
+ text: `[iris] 视频任务已提交(${submitted.model},task: ${submitted.task.id})`
293
+ };
294
+ });
295
+
296
+ /* ---------- 🔊 语音合成 ---------- */
297
+ register('tts', async (ctx, args, { signal }) => {
298
+ const text = String(args.text || '').trim();
299
+ if (!text) throw new Error('iris: text 不能为空');
300
+ const submitted = await submitWithFailover(cap.CAPABILITIES.TTS, '语音', signal, args.model, async (provider) => {
301
+ const model = args.model ? (models.parseModelRef(String(args.model))?.modelId || String(args.model)) : (provider.ttsModel || 'qwen-tts-latest');
302
+ const voice = args.voice || (provider.ttsVoice || 'Cherry');
303
+ const task = tasks.create({ cap: 'tts', providerId: provider.id, providerName: provider.name, model, voice, prompt: text.slice(0, 500) });
304
+ const fileName = await tasks.submitGuard(task, async () => {
305
+ const r = await adapters.synthesizeTts({ key: provider.apiKey, model, text, voice, signal });
306
+ const name = `iris-tts-${Date.now()}.${r.audioUrl ? adapters.extFromUrl(r.audioUrl, 'wav') : 'wav'}`;
307
+ const p = path.join(outputDir(), name);
308
+ if (r.audioUrl) await adapters.downloadTo(r.audioUrl, p, { signal });
309
+ else fs.writeFileSync(p, Buffer.from(r.audioB64, 'base64'));
310
+ return name;
311
+ });
312
+ tasks.update(task.id, { status: 'succeeded', files: [fileName], finishedAt: new Date().toISOString() });
313
+ registerMedia(task.id, path.join(outputDir(), fileName));
314
+ return { task, provider, model };
315
+ });
316
+ return {
317
+ ok: true, taskId: submitted.task.id, model: submitted.model, providerId: submitted.provider.id,
318
+ text: `[iris] 语音已合成(${submitted.model},task: ${submitted.task.id})`
319
+ };
320
+ });
321
+
322
+ /* ---------- 👁 看图 ---------- */
323
+ register('look', async (ctx, args, { signal }) => {
324
+ const p = String(args.image_path || '').trim();
325
+ if (!p || !path.isAbsolute(p)) throw new Error('iris: image_path 必须是绝对路径');
326
+ const mt = MEDIA_TYPES[path.extname(p).toLowerCase()];
327
+ if (!mt || mt === 'image/gif') throw new Error('iris: 不支持的图片格式');
328
+ if (!fs.existsSync(p)) throw new Error('iris: 图片不存在');
329
+ const dataUrl = `data:${mt};base64,${fs.readFileSync(p).toString('base64')}`;
330
+ const backends = buildVisionBackends(ctx, { providers: store.pickAllFor(cap.CAPABILITIES.VISION), model: args.model });
331
+ const { answer, via, model } = await askWithBackends(backends, { question: (args.question || '详细描述这张图片。'), imageDataUrl: dataUrl, signal });
332
+ const viaLabel = via === 'selfstack' ? 'iris 自持栈' : 'DSH 全局视觉模型';
333
+ return { ok: true, text: `[iris] 看图回答(${model} · ${viaLabel}):\n${answer}` };
334
+ });
335
+
336
+ /* ---------- ✂️ 裁剪 ---------- */
337
+ register('crop', async (ctx, args, { signal }) => {
338
+ const p = String(args.image_path || '').trim();
339
+ if (!p || !path.isAbsolute(p)) throw new Error('iris: image_path 必须是绝对路径');
340
+ if (!fs.existsSync(p)) throw new Error('iris: 图片不存在');
341
+ const { buffer, width, height, mime } = await cropImage({ input: p, left: args.left, top: args.top, width: args.width, height: args.height });
342
+ const b64 = buffer.toString('base64');
343
+ return { ok: true, text: `[iris] 裁剪完成:${width}x${height}(原区域 ${args.left},${args.top},${args.width},${args.height})`, imageDataUrl: `data:${mime};base64,${b64}` };
344
+ });
345
+
346
+ /* ---------- 📷 像素 diff ---------- */
347
+ register('diff', async (ctx, args, { signal }) => {
348
+ if (!args.image_a_path || !args.image_b_path) throw new Error('iris: 需要 image_a_path 和 image_b_path');
349
+ const { ratio, diffPixels, totalPixels, width, height, worstRegions, heatmap, mime } = await pixelDiff({
350
+ inputA: args.image_a_path, inputB: args.image_b_path, grid: args.grid || 8, topRegions: args.top_regions || 3
351
+ });
352
+ const pct = (ratio * 100).toFixed(2);
353
+ const regions = worstRegions.map((r) => `(col ${r.col},row ${r.row}) ${(r.score * 100).toFixed(1)}%`).join(' ');
354
+ return {
355
+ ok: true,
356
+ text: `[iris] 像素差异 ${pct}%(${diffPixels}/${totalPixels}px,归一化 ${width}x${height})\n最差区域(${worstRegions.length} 格): ${regions}`,
357
+ imageDataUrl: `data:${mime};base64,${heatmap.toString('base64')}`
358
+ };
359
+ });
360
+
361
+ /* ---------- 📍 定位 ---------- */
362
+ register('locate', async (ctx, args, { signal }) => {
363
+ const p = String(args.image_path || '').trim();
364
+ if (!p || !path.isAbsolute(p)) throw new Error('iris: image_path 必须是绝对路径');
365
+ if (!fs.existsSync(p)) throw new Error('iris: 图片不存在');
366
+ const buf = fs.readFileSync(p);
367
+ const mt = MEDIA_TYPES[path.extname(p).toLowerCase()] || 'image/png';
368
+ const dataUrl = `data:${mt};base64,${buf.toString('base64')}`;
369
+ const { width, height } = await imageDimensions(buf);
370
+ const backends = buildVisionBackends(ctx, { providers: store.pickAllFor(cap.CAPABILITIES.VISION), model: args.model });
371
+ const r = await locateObject(backends, { target: args.target, imageDataUrl: dataUrl, width, height, signal });
372
+ if (!r.found) return { ok: true, text: `[iris] 在图片中未找到「${args.target}」` };
373
+ return { ok: true, text: `[iris] 定位「${args.target}」:bbox (${r.x1},${r.y1},${r.x2},${r.y2}) / ${width}x${height}(${r.via} · ${r.model})` };
374
+ });
375
+ /* ---------- 🖼️ HTML 截图 ---------- */
376
+ register('html', async (ctx, args, { signal }) => {
377
+ const html = String(args.html || '').trim();
378
+ if (!html) throw new Error('iris: html 不能为空');
379
+ const browser = ctx.get('browser');
380
+ if (!browser) throw new Error('iris: 浏览器服务不可用——需要启用 dsh-builtin-browser 插件');
381
+ const renderer = new BrowserHtmlRenderer({ browser });
382
+ const { png } = await renderer.render({ html, width: args.width, height: args.height, fullPage: args.fullPage !== false });
383
+ return { ok: true, text: `[iris] HTML 截图完成(fullPage: ${args.fullPage !== false})`, imageDataUrl: `data:image/png;base64,${png.toString('base64')}` };
384
+ });
385
+
386
+ /* ---------- 📄 长截图 OCR ---------- */
387
+ register('ocr', async (ctx, args, { signal }) => {
388
+ const p = String(args.image_path || '').trim();
389
+ if (!p || !path.isAbsolute(p)) throw new Error('iris: image_path 必须是绝对路径');
390
+ if (!fs.existsSync(p)) throw new Error('iris: 图片不存在');
391
+ const backends = buildVisionBackends(ctx, { providers: store.pickAllFor(cap.CAPABILITIES.VISION) });
392
+ const result = await longOcr({ input: p, backends, chunkHeight: args.chunk_height || 1200, overlap: args.overlap || 120, signal });
393
+ const errCount = result.chunks.filter((c) => c.error).length;
394
+ const head = `[iris] 长截图 OCR 完成:${result.width}x${result.height}px,${result.totalChunks} 块${errCount ? '(' + errCount + ' 块失败)' : ''}\n`;
395
+ return { ok: true, text: result.fullText ? head + result.fullText : head + '(未识别到文字)' };
396
+ });
397
+
398
+ /* ---------- 📋 任务查询 ---------- */
399
+ register('status', async (ctx, args, { signal }) => {
400
+ if (args.task_id) {
401
+ const t = tasks.get(String(args.task_id).trim());
402
+ if (!t) throw new Error('iris: 没有这个任务:' + args.task_id);
403
+ const lines = [`[${t.cap}] ${t.id} · ${t.status}${t.progress ? ' · ' + t.progress : ''} · ${t.model}`];
404
+ if (t.prompt) lines.push('prompt: ' + String(t.prompt).slice(0, 120));
405
+ if (t.error) lines.push('error: ' + t.error);
406
+ if ((t.files || []).length) lines.push('files:\n' + t.files.map((f) => path.join(outputDir(), f)).join('\n'));
407
+ const links = mediaLinksOf(t);
408
+ if (links.length) lines.push('links:\n' + links.join('\n'));
409
+ return { ok: true, text: lines.join('\n') };
410
+ }
411
+ const recent = tasks.list().slice(0, 5);
412
+ if (!recent.length) return { ok: true, text: '[iris] 还没有生成任务记录。' };
413
+ return { ok: true, text: recent.map((t) => `[${t.cap}] ${t.id} · ${t.status} · ${t.model}`).join('\n') };
414
+ });
415
+
416
+ /* ---------- 🧹 任务清理(阶段 4 续:元数据删除,产物文件默认保留) ---------- */
417
+
418
+ /** 扫描 outputs/ 里不被任何任务记录引用的孤儿文件(files[] + media[].file 并集) */
419
+ function scanOrphanArtifacts() {
420
+ const dir = outputDir();
421
+ if (!fs.existsSync(dir)) return { files: [], bytes: 0 };
422
+ const referenced = new Set();
423
+ for (const t of tasks.all()) {
424
+ for (const f of t.files || []) referenced.add(path.basename(f));
425
+ for (const m of t.media || []) referenced.add(path.basename(m.file));
426
+ for (const a of t.attachments || []) if (a.file) referenced.add(path.basename(a.file));
427
+ }
428
+ const files = [];
429
+ let bytes = 0;
430
+ for (const name of fs.readdirSync(dir)) {
431
+ const abs = path.join(dir, name);
432
+ let st;
433
+ try { st = fs.statSync(abs); } catch (_) { continue; }
434
+ if (!st.isFile()) continue;
435
+ if (referenced.has(name)) continue;
436
+ files.push({ name, bytes: st.size });
437
+ bytes += st.size;
438
+ }
439
+ return { files, bytes };
440
+ }
441
+
442
+ /** 删除单条任务记录(仅终态;运行中须先取消)。只删元数据,产物文件保留。 */
443
+ register('tasks_delete', async (ctx, args) => {
444
+ const id = String(args.task_id || '').trim();
445
+ if (!id) throw new Error('iris: task_id 不能为空');
446
+ const r = tasks.remove(id);
447
+ if (!r.ok) throw new Error('iris: ' + r.reason);
448
+ return { ok: true, removed: 1, text: `[iris] 已删除任务记录 ${id}(产物文件保留在 outputs/)` };
449
+ });
450
+
451
+ /**
452
+ * 批量清理任务记录:scope = completed(所有终态)| older_than(早于 days 天)| all(等同 completed,运行中受保护)。
453
+ * 只删元数据,绝不动运行中任务,产物文件默认保留。
454
+ */
455
+ register('tasks_clear', async (ctx, args) => {
456
+ const scope = String(args.scope || 'completed').trim();
457
+ if (!['completed', 'older_than', 'all'].includes(scope)) throw new Error('iris: scope 需为 completed/older_than/all');
458
+ let removed;
459
+ if (scope === 'older_than') {
460
+ const days = Number(args.days);
461
+ if (!Number.isFinite(days) || days < 0) throw new Error('iris: days 需为非负数字');
462
+ const cutoff = Date.now() - days * 86400000;
463
+ removed = tasks.prune((t) => new Date(t.createdAt).getTime() < cutoff);
464
+ } else {
465
+ // completed / all:删除全部终态记录(运行中永不被 prune 命中)
466
+ removed = tasks.prune(() => true);
467
+ }
468
+ return { ok: true, removed: removed.length, text: `[iris] 已清理 ${removed.length} 条任务记录(产物文件保留)` };
469
+ });
470
+
471
+ /** 孤儿产物报告:outputs/ 里无任务引用的文件 + 总占用(只读,不删) */
472
+ register('tasks_orphans', async () => {
473
+ const { files, bytes } = scanOrphanArtifacts();
474
+ const mb = (bytes / 1048576).toFixed(1);
475
+ if (!files.length) return { ok: true, count: 0, bytes: 0, text: '[iris] 没有孤儿产物,outputs/ 干净。' };
476
+ const preview = files.slice(0, 12).map((f) => ` ${f.name} (${(f.bytes / 1024).toFixed(0)}KB)`).join('\n');
477
+ return {
478
+ ok: true, count: files.length, bytes,
479
+ text: `[iris] ${files.length} 个孤儿产物,共 ${mb}MB:\n${preview}${files.length > 12 ? `\n … 另 ${files.length - 12} 个` : ''}`
480
+ };
481
+ });
482
+
483
+ /** 删除孤儿产物文件(不可逆,客户端须二次确认)。任务记录不动。 */
484
+ register('tasks_purge_orphans', async () => {
485
+ const { files, bytes } = scanOrphanArtifacts();
486
+ const dir = outputDir();
487
+ let deleted = 0;
488
+ for (const f of files) {
489
+ try { fs.rmSync(path.join(dir, f.name), { force: true }); deleted++; } catch (_) { /* 单个失败不阻断 */ }
490
+ }
491
+ return { ok: true, deleted, bytes, text: `[iris] 已删除 ${deleted} 个孤儿产物,释放 ${(bytes / 1048576).toFixed(1)}MB` };
492
+ });
493
+
494
+ /* ---------- 👁 重看(GUI:只查 iris 自己的任务产物,无会话上下文) ---------- */
495
+ register('relook', async (ctx, args, { signal }) => {
496
+ const id = String(args.attachment_id || '').trim();
497
+ if (!id) throw new Error('iris: attachment_id 不能为空');
498
+ const question = String(args.question || '').trim();
499
+ if (!question) throw new Error('iris: question 不能为空');
500
+ // GUI 无会话事件扫描能力:只在 iris 自己的任务产物里找(findOwnAttachment 逻辑)
501
+ let hit = null;
502
+ for (const t of tasks.all()) {
503
+ for (const a of t.attachments || []) {
504
+ if (a.attachmentId === id) {
505
+ const absPath = path.join(store.irisHome(), 'outputs', a.file);
506
+ if (fs.existsSync(absPath)) { hit = { absPath, mediaType: a.mediaType }; break; }
507
+ }
508
+ }
509
+ if (hit) break;
510
+ }
511
+ if (!hit) throw new Error('iris: 该 attachment 不在 iris 生成的任务产物中(GUI 重看仅支持 iris 画图产物)');
512
+ const mt = hit.mediaType || MEDIA_TYPES[path.extname(hit.absPath).toLowerCase()] || 'image/png';
513
+ const dataUrl = `data:${mt};base64,${fs.readFileSync(hit.absPath).toString('base64')}`;
514
+ const backends = buildVisionBackends(ctx, { providers: store.pickAllFor(cap.CAPABILITIES.VISION), model: args.model });
515
+ const { answer, via, model } = await askWithBackends(backends, { question, imageDataUrl: dataUrl, signal });
516
+ const viaLabel = via === 'selfstack' ? 'iris 自持栈' : 'DSH 全局视觉模型';
517
+ return { ok: true, text: `[iris] 重看回答(${model} · ${viaLabel}):\n${answer}` };
518
+ });
519
+
520
+ /* ============ 供应商管理动作(阶段 6 条目 3-5) ============ */
521
+
522
+ /** 列出全部供应商及其模型池(管理 GUI 用;apiKey 只给 hint) */
523
+ register('providers_list', async (ctx, args) => {
524
+ const list = store.allProviders();
525
+ return {
526
+ ok: true,
527
+ providers: list.map((p) => ({
528
+ id: p.id,
529
+ name: p.name || p.id,
530
+ type: p.type || 'openai',
531
+ baseUrl: p.baseUrl,
532
+ enabled: p.enabled !== false,
533
+ mediaProtocol: p.mediaProtocol || 'dashscope',
534
+ apiKeyHint: String(p.apiKey || '').length <= 8 ? '****' : String(p.apiKey).slice(0, 3) + '****' + String(p.apiKey).slice(-4),
535
+ models: models.providerModels(p).map((m) => ({ id: m.id, capabilities: m.capabilities, verified: m.verified || null, source: m.source || 'auto' })),
536
+ capabilities: store.capabilitiesOf(p)
537
+ }))
538
+ };
539
+ });
540
+
541
+ /** 新增/更新供应商 */
542
+ register('providers_upsert', async (ctx, args) => {
543
+ const id = args.id || undefined;
544
+ const patch = { id };
545
+ const allowed = ['name', 'type', 'baseUrl', 'apiKey', 'enabled', 'mediaProtocol',
546
+ 'imageModel', 'videoModel', 'ttsModel', 'transcribeModel', 'visionModel', 'models'];
547
+ for (const key of allowed) {
548
+ if (Object.prototype.hasOwnProperty.call(args, key)) patch[key] = args[key];
549
+ }
550
+ if (!id && !Object.prototype.hasOwnProperty.call(patch, 'enabled')) patch.enabled = true;
551
+ const saved = store.upsert(patch);
552
+ return { ok: true, providerId: saved.id };
553
+ });
554
+
555
+ /** 删除供应商 */
556
+ register('providers_remove', async (ctx, args) => {
557
+ const ok = store.removeProvider(String(args.id || ''));
558
+ return { ok, removed: ok };
559
+ });
560
+
561
+ /** 设置某供应商的模型池(覆盖显式 models) */
562
+ register('providers_set_models', async (ctx, args) => {
563
+ const p = store.setProviderModels(String(args.id || ''), Array.isArray(args.models) ? args.models : []);
564
+ if (!p) throw new Error('iris: 供应商不存在');
565
+ return { ok: true };
566
+ });
567
+
568
+ /**
569
+ * 模型发现(P2):GET /models 拉全量 → 按规则过滤出媒体能力模型 → 写入显式池。
570
+ * 免费只读;拉不到/不支持则原样保留现有池(优雅降级),报人话错误。
571
+ */
572
+ register('providers_discover', async (ctx, args) => {
573
+ const p = store.providerById(String(args.id || ''));
574
+ if (!p) throw new Error('iris: 供应商不存在或未启用');
575
+ let ids;
576
+ try {
577
+ ids = await adapters.listModels({ key: p.apiKey, baseUrl: p.baseUrl });
578
+ } catch (err) {
579
+ throw new Error('iris: 模型发现失败(保留现有池):' + String(err.message || err));
580
+ }
581
+ const media = [];
582
+ for (const id of ids) {
583
+ const caps = models.capabilitiesOfModel(id);
584
+ if (caps.length) media.push({ id, capabilities: caps });
585
+ }
586
+ if (!media.length) {
587
+ return { ok: true, total: ids.length, media: 0, text: `[iris] ${p.name}:拉到 ${ids.length} 个模型,无媒体能力匹配——可在模型池区手动添加并标能力` };
588
+ }
589
+ store.setProviderModels(p.id, media);
590
+ return { ok: true, total: ids.length, media: media.length, text: `[iris] ${p.name}:发现 ${ids.length} 个模型,${media.length} 个媒体能力模型已入池(按能力标注)` };
591
+ });
592
+
593
+ /** 能力实测(红色测试图):对某供应商的 vision 模型跑 testVisionCapability */
594
+ register('providers_test_vision', async (ctx, args) => {
595
+ const p = store.providerById(String(args.id || ''));
596
+ if (!p) throw new Error('iris: 供应商不存在');
597
+ const visionProviders = store.pickAllFor(cap.CAPABILITIES.VISION).filter((x) => x.id === p.id);
598
+ if (!visionProviders.length) return { ok: true, tested: false, text: '该供应商未配置 vision 模型,跳过' };
599
+ const backends = buildVisionBackends(ctx, { providers: visionProviders });
600
+ const backend = backends[0];
601
+ if (!backend) return { ok: true, tested: false, text: '该供应商的 vision 配置不可用(类型非 openai)' };
602
+ const r = await testVisionCapability(backend, { timeoutMs: 15000, signal: args._signal });
603
+ return {
604
+ ok: true,
605
+ tested: true,
606
+ passed: r.ok,
607
+ text: r.ok
608
+ ? `[iris] 视觉能力实测通过:模型认出红色测试图(${backend.model})`
609
+ : `[iris] 视觉能力实测未通过:${r.error || '未识别出红色'}`
610
+ };
611
+ });
612
+
613
+ /* ============ 模型池逐模型管理(阶段 9 P3:手动增删改 + 逐能力实测) ============ */
614
+
615
+ /** 按能力实测单个模型;返回 {ok, note}。image/video 会产生真实调用(成本已在 UI 标注)。 */
616
+ async function probeModel(ctx, p, modelId, capability) {
617
+ if (capability === cap.CAPABILITIES.VISION) {
618
+ const backend = buildVisionBackends(ctx, { providers: [p], model: modelId })[0];
619
+ if (!backend) return { ok: false, note: '非 openai 类型,无法直测' };
620
+ const r = await testVisionCapability(backend, { timeoutMs: 15000, signal: null });
621
+ return { ok: r.ok, note: r.ok ? '认出红色测试图' : (r.error || '未识别出红色') };
622
+ }
623
+ if (capability === cap.CAPABILITIES.TRANSCRIBE) {
624
+ throw new Error('iris: 转写能力请用真实音频通过“音频转写”动作验证,不执行空样本探针');
625
+ }
626
+ if (capability === cap.CAPABILITIES.TTS) {
627
+ const r = await adapters.synthesizeTts({ key: p.apiKey, model: modelId, text: '你好', voice: 'Cherry' });
628
+ const ok = !!(r.audioUrl || r.audioB64);
629
+ return { ok, note: ok ? '合成返回音频' : '无音频返回' };
630
+ }
631
+ if (capability === cap.CAPABILITIES.IMAGE) {
632
+ if ((p.mediaProtocol || 'dashscope') === 'openai-images') {
633
+ const outs = await adapters.openAiGenerateImage({ key: p.apiKey, baseUrl: p.baseUrl, model: modelId, prompt: 'red circle', size: '256x256', n: 1 });
634
+ return { ok: outs.length > 0, note: outs.length ? '生成返回图片' : '无图片返回' };
635
+ }
636
+ const started = await adapters.startImageGeneration({ key: p.apiKey, model: modelId, prompt: '红色圆形', size: '512*512', n: 1 });
637
+ if (!started.remoteTaskId) {
638
+ return { ok: (started.urls || []).length > 0, note: (started.urls || []).length ? '生成成功' : '无图片返回' };
639
+ }
640
+ // 有界轮询到终态(图像通常 5–15s)
641
+ for (let i = 0; i < 20; i++) {
642
+ await new Promise((r) => setTimeout(r, 3000));
643
+ const st = await adapters.pollTask({ key: p.apiKey, remoteTaskId: started.remoteTaskId });
644
+ if (st.done) return { ok: st.ok && (st.urls || []).length > 0, note: st.ok ? '生成成功' : (st.message || '生成失败') };
645
+ }
646
+ return { ok: false, note: '生成超时(>60s)' };
647
+ }
648
+ if (capability === cap.CAPABILITIES.VIDEO) {
649
+ // 视频渲染太慢太贵:只做提交受理(证明模型 id 有效 + 鉴权 + 参数),不等成片
650
+ const taskId = await adapters.submitVideo({ key: p.apiKey, model: modelId, prompt: 'test', size: '480*832' });
651
+ return { ok: !!taskId, note: taskId ? '提交受理(未等成片)' : '无 task_id' };
652
+ }
653
+ return { ok: false, note: '未知能力' };
654
+ }
655
+
656
+ /** 手动添加模型到池(能力可选,缺省按规则推断) */
657
+ register('providers_add_model', async (ctx, args) => {
658
+ const id = String(args.id || '');
659
+ const modelId = String(args.model_id || '').trim();
660
+ if (!modelId) throw new Error('iris: model_id 不能为空');
661
+ const caps = Array.isArray(args.capabilities) && args.capabilities.length
662
+ ? args.capabilities.filter((c) => cap.isKnownCapability(c))
663
+ : models.capabilitiesOfModel(modelId);
664
+ const entry = store.addProviderModel(id, modelId, caps);
665
+ if (!entry) throw new Error('iris: 供应商不存在');
666
+ return { ok: true, text: `[iris] 已添加模型 ${modelId}(能力:${caps.join(',') || '未标注'})` };
667
+ });
668
+
669
+ /** 从池移除模型 */
670
+ register('providers_remove_model', async (ctx, args) => {
671
+ const ok = store.removeProviderModel(String(args.id || ''), String(args.model_id || ''));
672
+ if (!ok) throw new Error('iris: 模型不在池中');
673
+ return { ok: true, text: `[iris] 已移除模型 ${args.model_id}` };
674
+ });
675
+
676
+ /** 纠正某模型的能力标签 */
677
+ register('providers_set_model_caps', async (ctx, args) => {
678
+ const caps = (Array.isArray(args.capabilities) ? args.capabilities : []).filter((c) => cap.isKnownCapability(c));
679
+ const ok = store.setModelCapabilities(String(args.id || ''), String(args.model_id || ''), caps);
680
+ if (!ok) throw new Error('iris: 模型不在池中');
681
+ return { ok: true, text: `[iris] ${args.model_id} 能力已设为:${caps.join(',') || '(空)'}` };
682
+ });
683
+
684
+ /** 逐模型实测某能力 → 存 verified("test it, don't guess it";用户手动触发,发现不自动跑) */
685
+ register('providers_test_model', async (ctx, args) => {
686
+ const p = store.providerById(String(args.id || ''));
687
+ if (!p) throw new Error('iris: 供应商不存在或未启用');
688
+ const modelId = String(args.model_id || '').trim();
689
+ const capability = String(args.capability || '').trim();
690
+ if (!modelId || !cap.isKnownCapability(capability)) throw new Error('iris: 需 model_id 与合法 capability');
691
+ let result;
692
+ try {
693
+ result = await probeModel(ctx, p, modelId, capability);
694
+ } catch (err) {
695
+ result = { ok: false, note: String(err.message || err) };
696
+ }
697
+ store.setModelVerified(p.id, modelId, capability, result);
698
+ return {
699
+ ok: true,
700
+ passed: result.ok,
701
+ text: `[iris] ${modelId} · ${capability} 实测${result.ok ? '通过' : '未通过'}:${result.note}`
702
+ };
703
+ });
704
+
705
+ /* ============ 能力分配动作(阶段 6 条目 4) ============ */
706
+
707
+ /** 查询:全部分配 + 每个能力可用的模型池(client 渲染下拉/排序) */
708
+ register('assignments_get', async (ctx, args) => {
709
+ const pool = models.modelPool(store.allProviders());
710
+ const byCap = {};
711
+ for (const m of pool) {
712
+ for (const c of m.capabilities) {
713
+ if (!byCap[c]) byCap[c] = [];
714
+ byCap[c].push({ ref: m.ref, id: m.id, providerId: m.providerId, label: m.id + ' · ' + m.providerId });
715
+ }
716
+ }
717
+ // order:归一化的有序分配列表(GUI 直接渲染 failover 顺序,无需处理单字符串旧格式)
718
+ const order = {};
719
+ for (const c of Object.keys(byCap)) order[c] = store.assignmentOrder(c);
720
+ return { ok: true, assignments: store.assignments(), order, poolByCapability: byCap };
721
+ });
722
+
723
+ /**
724
+ * 设置某能力分配:
725
+ * model_refs 数组 = Provider+模型复合引用的有序 failover 列表;
726
+ * model_ids/model_id 保留为旧客户端兼容入口。
727
+ */
728
+ register('assignments_set', async (ctx, args) => {
729
+ const capability = String(args.capability || '').trim();
730
+ if (!capability) throw new Error('iris: capability 不能为空');
731
+ const listArg = Array.isArray(args.model_refs) ? args.model_refs : args.model_ids;
732
+ if (Array.isArray(listArg)) {
733
+ const refs = listArg.map((x) => String(x || '').trim()).filter(Boolean);
734
+ if (!store.setAssignmentOrder(capability, refs)) {
735
+ throw new Error('iris: 分配列表含不在全局池中或不具备该能力的模型');
736
+ }
737
+ return { ok: true, capability, model_refs: store.assignmentOrder(capability) };
738
+ }
739
+ const ref = args.model_ref ? String(args.model_ref).trim() : (args.model_id ? String(args.model_id).trim() : '');
740
+ const ok = ref ? store.setAssignment(capability, ref) : store.clearAssignment(capability);
741
+ if (!ok) throw new Error('iris: 该模型复合引用不在全局池中或不具备此能力');
742
+ return { ok: true, capability, model_ref: store.assignmentOrder(capability)[0] || '' };
743
+ });
744
+
745
+ /* ============ 文件选择器 L1:会话附件枚举(阶段 10) ============ */
746
+
747
+ /** 深度遍历会话事件,收集所有 image 附件引用(去重 by attachmentId) */
748
+ function collectImageAttachments(nodes) {
749
+ const out = [];
750
+ const seen = new Set();
751
+ const visited = new Set();
752
+ const stack = Array.isArray(nodes) ? [...nodes] : [nodes];
753
+ let scanned = 0;
754
+ while (stack.length && scanned < 8000) {
755
+ const node = stack.pop();
756
+ scanned++;
757
+ if (!node || typeof node !== 'object' || visited.has(node)) continue;
758
+ visited.add(node);
759
+ const ref = node.attachment && typeof node.attachment === 'object' ? node.attachment
760
+ : (node.attachmentId && node.mediaType ? node : null);
761
+ if (ref && typeof ref.attachmentId === 'string' && typeof ref.mediaType === 'string' && ref.mediaType.startsWith('image/')) {
762
+ if (!seen.has(ref.attachmentId)) {
763
+ seen.add(ref.attachmentId);
764
+ out.push({ attachmentId: ref.attachmentId, mediaType: ref.mediaType, name: ref.name || '', source: 'session' });
765
+ }
766
+ }
767
+ for (const v of Object.values(node)) if (v && typeof v === 'object') stack.push(v);
768
+ }
769
+ return out;
770
+ }
771
+
772
+ /**
773
+ * 枚举可选附件:当前会话的 image 附件(readSession)+ iris 自有产物(全局,兜底)。
774
+ * 客户端传当前会话 id;读不到会话也不报错,退化为只列 iris 产物。
775
+ */
776
+ register('attachments_list', async (ctx, args) => {
777
+ const items = [];
778
+ const seen = new Set();
779
+ const sessionId = String(args.session_id || '').trim();
780
+ const sq = ctx && typeof ctx.get === 'function' ? ctx.get('sessionQuery') : undefined;
781
+ if (sessionId && sq && typeof sq.readSession === 'function') {
782
+ try {
783
+ const events = (await sq.readSession(sessionId)).events;
784
+ for (const a of collectImageAttachments(events)) {
785
+ if (!seen.has(a.attachmentId)) { seen.add(a.attachmentId); items.push(a); }
786
+ }
787
+ } catch (_) {
788
+ /* 读不到会话 → 只列 iris 产物 */
789
+ }
790
+ }
791
+ // iris 自有产物(生成的图):任何会话都能用
792
+ for (const t of tasks.all()) {
793
+ for (const a of t.attachments || []) {
794
+ if (seen.has(a.attachmentId)) continue;
795
+ seen.add(a.attachmentId);
796
+ items.push({ attachmentId: a.attachmentId, mediaType: a.mediaType || 'image/png', name: a.file || '', source: 'iris' });
797
+ }
798
+ }
799
+ return { ok: true, attachments: items };
800
+ });
801
+
802
+ /**
803
+ * 把一个附件(会话 image 或 iris 产物)导出为宿主可读的文件路径,供工具消费。
804
+ * iris 产物直接返回其 outputs 路径;会话附件读字节存 uploads/(按 attachmentId 哈希缓存,不重复落盘)。
805
+ */
806
+ register('attachment_export', async (ctx, args) => {
807
+ const id = String(args.attachment_id || '').trim();
808
+ if (!id) throw new Error('iris: attachment_id 不能为空');
809
+ // 1) iris 自有产物:文件已在 outputs/
810
+ for (const t of tasks.all()) {
811
+ for (const a of t.attachments || []) {
812
+ if (a.attachmentId === id) {
813
+ const abs = path.join(store.irisHome(), 'outputs', a.file);
814
+ if (fs.existsSync(abs)) return { ok: true, path: abs, name: a.file };
815
+ }
816
+ }
817
+ }
818
+ // 2) 会话附件:readSession 找 ref → readImage 读字节 → 存 uploads/(缓存)
819
+ const uploads = path.join(store.irisHome(), 'uploads');
820
+ const cached = path.join(uploads, 'att-' + id.replace(/[^a-z0-9]/gi, '').slice(-40));
821
+ if (fs.existsSync(cached)) return { ok: true, path: cached, name: path.basename(cached) };
822
+ const sessionId = String(args.session_id || '').trim();
823
+ const sq = ctx && typeof ctx.get === 'function' ? ctx.get('sessionQuery') : undefined;
824
+ const att = ctx && typeof ctx.get === 'function' ? ctx.get('attachments') : undefined;
825
+ if (!sessionId || !sq || !att || typeof att.readImage !== 'function') {
826
+ throw new Error('iris: 该附件不可导出(非 iris 产物,且缺会话/附件服务上下文)');
827
+ }
828
+ let ref = null;
829
+ try {
830
+ const events = (await sq.readSession(sessionId)).events;
831
+ for (const a of collectImageAttachments(events)) if (a.attachmentId === id) { ref = a; break; }
832
+ } catch (_) { /* ignore */ }
833
+ if (!ref) throw new Error('iris: 该 attachment 不在本会话中、也不是 iris 产物:' + id);
834
+ const stored = await att.readImage({ attachmentId: ref.attachmentId, mediaType: ref.mediaType }, null);
835
+ fs.mkdirSync(uploads, { recursive: true });
836
+ fs.writeFileSync(cached, Buffer.from(stored.data), { mode: 0o600 });
837
+ return { ok: true, path: cached, name: ref.name || path.basename(cached) };
838
+ });
839
+
840
+ /* ============ 音频转写(阶段 7.2) ============ */
841
+
842
+ register('transcribe', async (ctx, args, { signal }) => {
843
+ const audioPath = String(args.audio_path || '').trim();
844
+ if (!audioPath || !path.isAbsolute(audioPath)) throw new Error('iris: audio_path 必须是绝对路径');
845
+ if (!fs.existsSync(audioPath)) throw new Error('iris: 音频文件不存在');
846
+ const submitted = await submitWithFailover(cap.CAPABILITIES.TRANSCRIBE, '转写', signal, args.model, async (provider) => {
847
+ const model = args.model ? (models.parseModelRef(String(args.model))?.modelId || String(args.model)) : (provider.transcribeModel || 'qwen-audio-3.0-asr-flash-filetrans');
848
+ const task = tasks.create({
849
+ cap: 'transcribe', providerId: provider.id, providerName: provider.name,
850
+ model, prompt: 'audio transcribe: ' + path.basename(audioPath)
851
+ });
852
+ const remoteTaskId = await tasks.submitGuard(task, async () => {
853
+ const audioUrl = await adapters.uploadTempFile({ key: provider.apiKey, model, filePath: audioPath, signal });
854
+ return adapters.submitTranscription({ key: provider.apiKey, model, audioUrl, signal });
855
+ });
856
+ tasks.update(task.id, { remoteTaskId });
857
+ tasks.watch(tasks.get(task.id), {
858
+ key: () => provider.apiKey,
859
+ poll: async ({ key, remoteTaskId: id }) => {
860
+ const r = await adapters.pollTranscriptionTask({ key, remoteTaskId: id });
861
+ return { done: r.done, ok: r.ok, urls: r.text ? [r.text] : [], message: r.message };
862
+ },
863
+ intervalMs: 2500,
864
+ onSuccess: async (t, r) => {
865
+ tasks.update(t.id, { transcribeText: (r.urls && r.urls.join('')) || '' });
866
+ return [];
867
+ }
868
+ });
869
+ return { task, provider, model, remoteTaskId };
870
+ });
871
+ return {
872
+ ok: true, taskId: submitted.task.id, model: submitted.model, providerId: submitted.provider.id,
873
+ remoteTaskId: submitted.remoteTaskId,
874
+ text: `[iris] 音频转写任务已提交(${submitted.model},task: ${submitted.task.id})`
875
+ };
876
+ });
877
+
878
+ /* ============ 视频抽帧(阶段 7.1) ============ */
879
+
880
+ register('video_frames', async (ctx, args, { signal }) => {
881
+ const p = String(args.video_path || '').trim();
882
+ if (!p || !path.isAbsolute(p)) throw new Error('iris: video_path 必须是绝对路径');
883
+ if (!fs.existsSync(p)) throw new Error('iris: 视频文件不存在');
884
+ if (!ffmpegAvailable()) throw new Error('iris: ffmpeg/ffprobe 不可用,无法抽帧(可选安装 ffmpeg 后启用)');
885
+ const meta = probeVideo(p);
886
+ const frames = await extractFrames({
887
+ inputPath: p, maxFrames: args.max_frames, targetWidth: args.target_width,
888
+ format: args.format || 'jpeg', quality: args.quality, signal
889
+ });
890
+ const mime = args.format === 'png' ? 'image/png' : 'image/jpeg';
891
+ const summary = frames.map((f, i) =>
892
+ `帧${i + 1} @${f.atSec.toFixed(1)}s ${f.width}x${f.height} ${(f.buffer.length / 1024).toFixed(0)}KB`
893
+ ).join('\n');
894
+ // 首帧预览 base64
895
+ const firstFrame = `data:${mime};base64,${frames[0].buffer.toString('base64')}`;
896
+ return {
897
+ ok: true,
898
+ text: `[iris] 视频抽帧完成:${frames.length} 帧(${meta.durationSec.toFixed(1)}s ${meta.width}x${meta.height},${mime === 'image/png' ? 'PNG' : 'JPEG'})\n${summary}\n\n(预览为首帧;全部帧请用对话工具 iris_video_frames 获取 attachment)`,
899
+ imageDataUrl: firstFrame
900
+ };
901
+ });
902
+
903
+ /* ============ 多模态视频摘要(阶段 7.3,GUI 同步快速版) ============ */
904
+
905
+ register('media_summarize', async (ctx, args, { signal }) => {
906
+ const p = String(args.video_path || '').trim();
907
+ if (!p || !path.isAbsolute(p)) throw new Error('iris: video_path 必须是绝对路径');
908
+ if (!fs.existsSync(p)) throw new Error('iris: 视频文件不存在');
909
+ if (!ffmpegAvailable()) throw new Error('iris: ffmpeg/ffprobe 不可用,无法分析视频(可选安装 ffmpeg 后启用)');
910
+ const meta = probeVideo(p);
911
+ const frames = await extractFrames({
912
+ inputPath: p, maxFrames: Math.min(Number(args.max_frames) || 8, 12), targetWidth: args.target_width,
913
+ format: 'jpeg', quality: 70, signal
914
+ });
915
+ const sheet = await buildContactSheet({ frames, cols: 3 });
916
+ const backends = buildVisionBackends(ctx, { providers: store.pickAllFor(cap.CAPABILITIES.VISION), model: args.model });
917
+ const { answer, via, model } = await summarizeMedia({
918
+ backends, question: args.question,
919
+ transcript: args.transcribe_text, // GUI 同步版:不自动转写,可粘贴已有转写文本
920
+ imageDataUrl: `data:image/png;base64,${sheet.buffer.toString('base64')}`,
921
+ signal
922
+ });
923
+ const viaLabel = via === 'selfstack' ? 'iris 自持栈' : 'DSH 全局视觉模型';
924
+ const hasTrans = args.transcribe_text && String(args.transcribe_text).trim();
925
+ return {
926
+ ok: true,
927
+ text: `[iris] 视频摘要完成(${model} · ${viaLabel},${meta.durationSec.toFixed(1)}s / ${frames.length} 帧联系表${hasTrans ? ' + 转写文本' : ''}):\n${answer}`,
928
+ imageDataUrl: `data:image/png;base64,${sheet.buffer.toString('base64')}`
929
+ };
930
+ });