@mokuyoaxis/dsh-iris 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dsh/skills/iris-compose-media/SKILL.md +10 -5
- package/.dsh/skills/iris-compose-media/references/workflows.md +7 -1
- package/.dsh/skills/iris-verify-ui/SKILL.md +2 -1
- package/CHANGELOG.md +47 -2
- package/README.en.md +65 -29
- package/README.md +65 -29
- package/bin/dsh-iris.js +222 -21
- package/docs/AGENT_QUICKSTART.md +10 -6
- package/docs/ARCHITECTURE.md +25 -18
- package/docs/ARTIFACT_LIBRARY.md +5 -5
- package/docs/ARTIFACT_MANIFEST.md +1 -1
- package/docs/CLI_MANAGEMENT.md +181 -0
- package/docs/COMPOSITE_VISION.md +35 -0
- package/docs/CORE_RUNTIME_CONTRACT.md +8 -7
- package/docs/DSH_CORE_MIGRATION.md +48 -19
- package/docs/DSH_RC2_ADAPTATION.md +4 -2
- package/docs/FAULT_INJECTION.md +12 -2
- package/docs/HEADLESS_CLI.md +13 -7
- package/docs/HOST_ADAPTER_CONTRACT.md +10 -10
- package/docs/HOST_DOCTOR.md +3 -3
- package/docs/MODEL_PORT_CONTRACT.md +19 -14
- package/docs/OCR_MODEL.md +39 -0
- package/docs/PROMPT_OPTIMIZER.md +47 -0
- package/docs/PROVIDER_ADAPTER_CONTRACT.md +6 -4
- package/docs/PROVIDER_HEALTH.md +22 -5
- package/docs/PROVIDER_RUNTIME_HARNESS.md +1 -1
- package/docs/PROVIDER_SUBMISSION_CONTRACT.md +2 -2
- package/docs/ROADMAP.md +29 -19
- package/docs/SECURITY.md +5 -5
- package/docs/TASK_SEMANTICS.md +2 -2
- package/docs/VISION_CLI.md +132 -0
- package/docs/VISION_MODEL.md +51 -0
- package/docs/WORKBENCH_ARTIFACTS.md +67 -0
- package/docs/file-access-across-environments.md +8 -5
- package/docs/releases/0.2.1.md +56 -0
- package/lib/actions.js +187 -270
- package/lib/adapters.js +133 -22
- package/lib/api.js +49 -7
- package/lib/chromium-browser.js +117 -0
- package/lib/client.js +671 -101
- package/lib/command-service.js +121 -7
- package/lib/composite-vision-routing.js +42 -0
- package/lib/config.js +89 -10
- package/lib/core-artifact-store.js +45 -13
- package/lib/core-maintenance.js +213 -0
- package/lib/core-tasks.js +12 -5
- package/lib/core-user-projection.js +1 -0
- package/lib/dsh-core-adapter.js +63 -9
- package/lib/dsh-text-model-adapter.js +222 -0
- package/lib/dsh-vision-model-adapter.js +75 -0
- package/lib/generation-input.js +36 -5
- package/lib/headless-vision.js +181 -0
- package/lib/host-runtime.js +1 -0
- package/lib/http-vision-model-adapter.js +187 -0
- package/lib/image-edit-input.js +34 -0
- package/lib/index.js +205 -175
- package/lib/locate.js +86 -28
- package/lib/media-probe.js +2 -1
- package/lib/model-call-runtime.js +45 -0
- package/lib/model-invoker.js +4 -2
- package/lib/model-port-contract.js +13 -5
- package/lib/models.js +10 -5
- package/lib/ocr-model-routing.js +26 -0
- package/lib/ocr.js +110 -64
- package/lib/prompt-optimizer-config.js +2 -1
- package/lib/prompt-optimizer-core.js +121 -0
- package/lib/prompt-optimizer.js +37 -193
- package/lib/provider-adapters.js +85 -9
- package/lib/provider-catalog.js +19 -7
- package/lib/provider-config-service.js +236 -0
- package/lib/provider-contract.js +3 -2
- package/lib/provider-health.js +80 -1
- package/lib/provider-protocol.js +23 -0
- package/lib/provider-task-runner.js +58 -16
- package/lib/summarize.js +31 -20
- package/lib/summary-input.js +52 -0
- package/lib/task-wait.js +40 -0
- package/lib/video-input.js +30 -0
- package/lib/vision-core.js +42 -0
- package/lib/vision-image-input.js +83 -0
- package/lib/vision-input.js +31 -0
- package/lib/vision-model-routing.js +180 -0
- package/lib/vision.js +3 -2
- package/lib/workbench-artifacts.js +52 -0
- package/lib/workbench-management.js +151 -0
- package/lib/workbench-zip.js +39 -0
- package/package.json +1 -1
- package/user_guide.md +52 -22
package/lib/adapters.js
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* Iris 媒体生成后端 —— 多协议适配器。
|
|
4
4
|
* - dashscope:阿里云百炼(图像/视频异步任务 + qwen-tts 同步)
|
|
5
5
|
* - openai-images:OpenAI Images 兼容协议(generations)
|
|
6
|
+
* - openai-chat-images:网关 Chat Completions 生图扩展(非流式)
|
|
7
|
+
* - openai-responses-images:Responses image_generation 工具及网关图片消息(同步)
|
|
6
8
|
* 所有函数只收显式 key;错误统一 throw Error(人话)。
|
|
7
9
|
*/
|
|
8
10
|
import fs from 'node:fs';
|
|
@@ -11,6 +13,8 @@ import { Readable } from 'node:stream';
|
|
|
11
13
|
import { pipeline } from 'node:stream/promises';
|
|
12
14
|
import { dashscopeApiBase, isDashScopeBaseUrl } from './provider-protocol.js';
|
|
13
15
|
import { chmodPrivateFile, ensurePrivateDir, privateSibling } from './private-storage.js';
|
|
16
|
+
import { isFreeQuotaExhausted, retryAfterDelay } from './provider-health.js';
|
|
17
|
+
import { ProviderContractError } from './provider-contract.js';
|
|
14
18
|
|
|
15
19
|
/**
|
|
16
20
|
* 网络调用超时档位(2026-09-03 健康检查):挂死的连接绝不能冻住盯守 tick——
|
|
@@ -53,26 +57,31 @@ async function readJson(res) {
|
|
|
53
57
|
* message 保持人话(向后兼容 String(err.message))。
|
|
54
58
|
*/
|
|
55
59
|
export class ProviderError extends Error {
|
|
56
|
-
constructor({ category, status, message, rootCause }) {
|
|
60
|
+
constructor({ category, status, message, rootCause, providerCode, retryAfterMs }) {
|
|
57
61
|
super(message);
|
|
58
62
|
this.name = 'ProviderError';
|
|
59
63
|
this.category = category;
|
|
60
64
|
this.status = status;
|
|
61
65
|
this.rootCause = rootCause;
|
|
66
|
+
this.providerCode = providerCode;
|
|
67
|
+
this.retryAfterMs = retryAfterMs;
|
|
62
68
|
}
|
|
63
69
|
}
|
|
64
70
|
|
|
65
|
-
function classifyHttpError(status, body, fallback) {
|
|
66
|
-
const msg =
|
|
67
|
-
const code = String(
|
|
71
|
+
function classifyHttpError(status, body, fallback, headers) {
|
|
72
|
+
const msg = body?.message || body?.output?.message || body?.error?.message || body?.code || `HTTP ${status}`;
|
|
73
|
+
const code = String(body?.code || body?.error?.code || '');
|
|
68
74
|
let category = 'unknown';
|
|
69
75
|
let text;
|
|
70
|
-
if (status
|
|
76
|
+
if (isFreeQuotaExhausted({ status, code, message: msg })) {
|
|
77
|
+
category = 'quota';
|
|
78
|
+
text = `免费额度耗尽:${msg}`;
|
|
79
|
+
} else if (status === 401 || status === 403) {
|
|
71
80
|
category = 'auth';
|
|
72
81
|
text = `鉴权失败:${msg}`;
|
|
73
82
|
} else if (status === 429) {
|
|
74
|
-
category =
|
|
75
|
-
text =
|
|
83
|
+
category = code === 'BudgetLimitExceeded' ? 'quota' : 'rate_limit';
|
|
84
|
+
text = `${category === 'quota' ? '预算额度耗尽' : '请求限流'}:${msg}`;
|
|
76
85
|
} else if (/InvalidParameter|NotFound\.Model|unsupported/i.test(code)) {
|
|
77
86
|
category = 'invalid_parameter';
|
|
78
87
|
text = `模型或参数不受支持:${msg}`;
|
|
@@ -82,7 +91,8 @@ function classifyHttpError(status, body, fallback) {
|
|
|
82
91
|
} else {
|
|
83
92
|
text = `${fallback}:${msg}`;
|
|
84
93
|
}
|
|
85
|
-
return new ProviderError({ category, status, message: text, rootCause: msg
|
|
94
|
+
return new ProviderError({ category, status, message: text, rootCause: msg, providerCode: code,
|
|
95
|
+
...(status === 429 ? { retryAfterMs: retryAfterDelay(headers?.get('retry-after')) } : {}) });
|
|
86
96
|
}
|
|
87
97
|
|
|
88
98
|
/** 包装 fetch 层的网络异常(无 HTTP 状态) */
|
|
@@ -96,8 +106,8 @@ export function networkError(err) {
|
|
|
96
106
|
return e;
|
|
97
107
|
}
|
|
98
108
|
|
|
99
|
-
function providerError(status, body, fallback) {
|
|
100
|
-
return classifyHttpError(status, body, fallback);
|
|
109
|
+
function providerError(status, body, fallback, headers) {
|
|
110
|
+
return classifyHttpError(status, body, fallback, headers);
|
|
101
111
|
}
|
|
102
112
|
|
|
103
113
|
/* ---------------- DashScope:图像(旧异步 / 新版多模态) ---------------- */
|
|
@@ -129,7 +139,7 @@ export async function generateImageMultimodal({ key, baseUrl, model, prompt, siz
|
|
|
129
139
|
})
|
|
130
140
|
});
|
|
131
141
|
const body = await readJson(res);
|
|
132
|
-
if (!res.ok) throw providerError(res.status, body, '生成失败');
|
|
142
|
+
if (!res.ok) throw providerError(res.status, body, '生成失败', res.headers);
|
|
133
143
|
const urls = [];
|
|
134
144
|
for (const choice of (body && body.output && body.output.choices) || []) {
|
|
135
145
|
for (const item of (choice && choice.message && choice.message.content) || []) {
|
|
@@ -157,7 +167,7 @@ export async function submitImage({ key, baseUrl, model, prompt, size, n = 1, ti
|
|
|
157
167
|
body: JSON.stringify({ model, input: { prompt }, parameters: { ...(size ? { size } : {}), n: Number(n) || 1 } })
|
|
158
168
|
});
|
|
159
169
|
const body = await readJson(res);
|
|
160
|
-
if (!res.ok) throw providerError(res.status, body, '提交失败');
|
|
170
|
+
if (!res.ok) throw providerError(res.status, body, '提交失败', res.headers);
|
|
161
171
|
const taskId = body && body.output && body.output.task_id;
|
|
162
172
|
if (!taskId) throw new Error('提交成功但未返回 task_id');
|
|
163
173
|
return taskId;
|
|
@@ -199,7 +209,7 @@ export async function submitVideo({ key, baseUrl, model, prompt, imgDataUrl, siz
|
|
|
199
209
|
body: JSON.stringify({ model, input, parameters })
|
|
200
210
|
});
|
|
201
211
|
const body = await readJson(res);
|
|
202
|
-
if (!res.ok) throw providerError(res.status, body, '提交失败');
|
|
212
|
+
if (!res.ok) throw providerError(res.status, body, '提交失败', res.headers);
|
|
203
213
|
const taskId = body && body.output && body.output.task_id;
|
|
204
214
|
if (!taskId) throw new Error('提交成功但未返回 task_id');
|
|
205
215
|
return taskId;
|
|
@@ -218,7 +228,7 @@ export async function uploadTempFile({ key, baseUrl, model, filePath, timeoutMs
|
|
|
218
228
|
u.searchParams.set('model', model);
|
|
219
229
|
const pres = await fetch(u, { headers: authHeaders(key), signal: timeoutSignal(timeoutMs, signal) });
|
|
220
230
|
const pbody = await readJson(pres);
|
|
221
|
-
if (!pres.ok || !pbody || !pbody.data) throw providerError(pres.status, pbody, '获取上传凭证失败');
|
|
231
|
+
if (!pres.ok || !pbody || !pbody.data) throw providerError(pres.status, pbody, '获取上传凭证失败', pres.headers);
|
|
222
232
|
const pol = pbody.data;
|
|
223
233
|
const name = path.basename(filePath);
|
|
224
234
|
const keyPath = `${pol.upload_dir}/${Date.now()}-${name}`;
|
|
@@ -246,7 +256,7 @@ export async function pollTask({ key, baseUrl, remoteTaskId, timeoutMs = T_POLL,
|
|
|
246
256
|
signal: timeoutSignal(timeoutMs, signal)
|
|
247
257
|
});
|
|
248
258
|
const body = await readJson(res);
|
|
249
|
-
if (!res.ok) throw providerError(res.status, body, '查询任务失败');
|
|
259
|
+
if (!res.ok) throw providerError(res.status, body, '查询任务失败', res.headers);
|
|
250
260
|
const out = (body && body.output) || {};
|
|
251
261
|
const status = out.task_status || 'UNKNOWN';
|
|
252
262
|
const urls = [];
|
|
@@ -291,7 +301,7 @@ export async function synthesizeTts({ key, baseUrl, model = 'qwen-tts-latest', t
|
|
|
291
301
|
body: JSON.stringify({ model, input: { text, voice } })
|
|
292
302
|
});
|
|
293
303
|
const body = await readJson(res);
|
|
294
|
-
if (!res.ok) throw providerError(res.status, body, '合成失败');
|
|
304
|
+
if (!res.ok) throw providerError(res.status, body, '合成失败', res.headers);
|
|
295
305
|
const audio = body && body.output && body.output.audio;
|
|
296
306
|
const url = audio && (audio.url || audio.audio_url);
|
|
297
307
|
const b64 = audio && typeof audio.data === 'string' ? audio.data : null;
|
|
@@ -309,17 +319,118 @@ export async function openAiGenerateImage({ key, baseUrl, model, prompt, size =
|
|
|
309
319
|
body: JSON.stringify({ model, prompt, size, n: Number(n) || 1 })
|
|
310
320
|
});
|
|
311
321
|
const body = await readJson(res);
|
|
312
|
-
if (!res.ok) throw providerError(res.status, body, '生成失败');
|
|
322
|
+
if (!res.ok) throw providerError(res.status, body, '生成失败', res.headers);
|
|
313
323
|
const items = (body && Array.isArray(body.data) && body.data) || [];
|
|
314
324
|
const out = [];
|
|
315
325
|
for (const it of items) {
|
|
316
326
|
if (it && typeof it.b64_json === 'string') out.push({ b64: it.b64_json });
|
|
317
327
|
else if (it && typeof it.url === 'string') out.push({ url: it.url });
|
|
318
328
|
}
|
|
319
|
-
if (!out.length) throw
|
|
329
|
+
if (!out.length) throw imageResponseError('生成响应未返回图片数据');
|
|
320
330
|
return out;
|
|
321
331
|
}
|
|
322
332
|
|
|
333
|
+
function imageResponseError(message) {
|
|
334
|
+
return new ProviderContractError(message, {
|
|
335
|
+
stage: 'response', category: 'protocol', acceptance: 'unknown'
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function imageBase64Item(value, mediaType, label) {
|
|
340
|
+
const b64 = typeof value === 'string' ? value.replace(/\s/g, '') : '';
|
|
341
|
+
const bytes = Buffer.from(b64, 'base64');
|
|
342
|
+
if (!bytes.length || bytes.toString('base64').replace(/=+$/, '') !== b64.replace(/=+$/, '')) {
|
|
343
|
+
throw imageResponseError(label + '响应包含无效的图片 base64');
|
|
344
|
+
}
|
|
345
|
+
return { b64: bytes.toString('base64'), ...(mediaType ? { mediaType } : {}) };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** 网关扩展:图片 Markdown/data URL、image_url 内容块或 message.images。 */
|
|
349
|
+
function chatImageItems(body, label = '聊天生图') {
|
|
350
|
+
const items = [], seen = new Set();
|
|
351
|
+
function add(source) {
|
|
352
|
+
if (typeof source !== 'string') return;
|
|
353
|
+
const value = source.trim();
|
|
354
|
+
if (seen.has(value)) return;
|
|
355
|
+
const data = /^data:(image\/[a-z0-9.+-]+);base64,([\s\S]+)$/i.exec(value);
|
|
356
|
+
if (data) {
|
|
357
|
+
items.push(imageBase64Item(data[2], data[1].toLowerCase(), label));
|
|
358
|
+
} else if (/^https?:\/\//i.test(value)) {
|
|
359
|
+
items.push({ url: value });
|
|
360
|
+
} else return;
|
|
361
|
+
seen.add(value);
|
|
362
|
+
}
|
|
363
|
+
function text(content) {
|
|
364
|
+
for (const match of content.matchAll(/!\[[^\]]*\]\(([^)]+)\)/g)) add(match[1]);
|
|
365
|
+
for (const match of content.matchAll(/data:image\/[a-z0-9.+-]+;base64,[^\s)"<>]+/gi)) add(match[0]);
|
|
366
|
+
}
|
|
367
|
+
function part(item) {
|
|
368
|
+
if (item?.type === 'text' && typeof item.text === 'string') text(item.text);
|
|
369
|
+
else if (item?.image_url) add(typeof item.image_url === 'string' ? item.image_url : item.image_url.url);
|
|
370
|
+
}
|
|
371
|
+
for (const choice of Array.isArray(body?.choices) ? body.choices : []) {
|
|
372
|
+
const message = choice?.message;
|
|
373
|
+
if (typeof message?.content === 'string') text(message.content);
|
|
374
|
+
else if (Array.isArray(message?.content)) message.content.forEach(part);
|
|
375
|
+
if (Array.isArray(message?.images)) message.images.forEach(part);
|
|
376
|
+
}
|
|
377
|
+
if (!items.length) throw imageResponseError(label + '响应未返回图片;受理结果未知,禁止自动重提');
|
|
378
|
+
return items;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export async function openAiChatGenerateImage({ key, baseUrl, model, prompt, image, size, n = 1, timeoutMs = T_SYNC_GEN, signal }) {
|
|
382
|
+
if (typeof size === 'string' && size.trim()) {
|
|
383
|
+
throw new ProviderContractError('聊天生图协议不支持 size 参数;请在提示词中描述所需尺寸', {
|
|
384
|
+
stage: 'validate', category: 'invalid_request', acceptance: 'not_accepted'
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
const base = String(baseUrl || '').replace(/\/+$/, '');
|
|
388
|
+
const content = image ? [
|
|
389
|
+
{ type: 'text', text: prompt },
|
|
390
|
+
{ type: 'image_url', image_url: { url: 'data:' + image.mediaType + ';base64,' + Buffer.from(image.bytes).toString('base64') } }
|
|
391
|
+
] : prompt;
|
|
392
|
+
const res = await fetch(`${base}/chat/completions`, {
|
|
393
|
+
method: 'POST', headers: authHeaders(key), signal: timeoutSignal(timeoutMs, signal),
|
|
394
|
+
body: JSON.stringify({ model, stream: false, messages: [{ role: 'user', content }],
|
|
395
|
+
...(Number(n) > 1 ? { n: Number(n) } : {}) })
|
|
396
|
+
});
|
|
397
|
+
const body = await readJson(res);
|
|
398
|
+
if (!res.ok) throw providerError(res.status, body, '聊天生图失败', res.headers);
|
|
399
|
+
return chatImageItems(body);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** 同步 Responses 生图:主模型由 model 指定,图片工具模型沿用服务端默认。 */
|
|
403
|
+
export async function openAiResponsesGenerateImage({ key, baseUrl, model, prompt, size, n = 1, timeoutMs = T_SYNC_GEN, signal }) {
|
|
404
|
+
if (Number(n) !== 1) {
|
|
405
|
+
throw new ProviderContractError('Responses 生图当前只支持 n=1', {
|
|
406
|
+
stage: 'validate', category: 'invalid_request', acceptance: 'not_accepted'
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
const base = String(baseUrl || '').replace(/\/+$/, '');
|
|
410
|
+
const res = await fetch(`${base}/responses`, {
|
|
411
|
+
method: 'POST', headers: authHeaders(key), signal: timeoutSignal(timeoutMs, signal),
|
|
412
|
+
body: JSON.stringify({ model, input: prompt, stream: false, store: false, background: false,
|
|
413
|
+
tools: [{ type: 'image_generation', action: 'generate', ...(size ? { size: size.replace('*', 'x') } : {}) }],
|
|
414
|
+
tool_choice: { type: 'image_generation' } })
|
|
415
|
+
});
|
|
416
|
+
const body = await readJson(res);
|
|
417
|
+
if (!res.ok) throw providerError(res.status, body, 'Responses 生图失败', res.headers);
|
|
418
|
+
if (body?.status !== 'completed') throw imageResponseError('Responses 生图未返回同步完成结果;禁止自动重提');
|
|
419
|
+
const output = Array.isArray(body.output) ? body.output : [];
|
|
420
|
+
const images = output.filter(item => item?.type === 'image_generation_call');
|
|
421
|
+
if (images.length) return images.map(item => {
|
|
422
|
+
if (item.status !== 'completed') throw imageResponseError('Responses 图片工具未完成;禁止自动重提');
|
|
423
|
+
return imageBase64Item(item.result, undefined, 'Responses 生图');
|
|
424
|
+
});
|
|
425
|
+
// 实测网关将图片作为 output_text 返回;这不证明执行了官方图片工具。
|
|
426
|
+
const messages = output.filter(item => item?.type === 'message');
|
|
427
|
+
if (messages.some(item => item.status !== 'completed')) throw imageResponseError('Responses 图片消息未完成;禁止自动重提');
|
|
428
|
+
return chatImageItems({ choices: messages.map(item => ({ message: {
|
|
429
|
+
content: (Array.isArray(item.content) ? item.content : []).map(part => part?.type === 'output_text'
|
|
430
|
+
? { type: 'text', text: part.text } : part)
|
|
431
|
+
} })) }, 'Responses 生图');
|
|
432
|
+
}
|
|
433
|
+
|
|
323
434
|
/* ---------------- 模型发现(OpenAI GET /models + DashScope GET /api/v1/models) ---------------- */
|
|
324
435
|
/**
|
|
325
436
|
* 拉取供应商模型目录。DashScope 官方端点分页读取并保留 capabilities;
|
|
@@ -342,7 +453,7 @@ export async function listModels({ key, baseUrl, timeoutMs = T_SUBMIT, signal })
|
|
|
342
453
|
}
|
|
343
454
|
const res = await fetch(url, { headers: authHeaders(key), signal: timeoutSignal(timeoutMs, signal) });
|
|
344
455
|
const body = await readJson(res);
|
|
345
|
-
if (!res.ok) throw providerError(res.status, body, '模型发现失败');
|
|
456
|
+
if (!res.ok) throw providerError(res.status, body, '模型发现失败', res.headers);
|
|
346
457
|
const output = body && body.output;
|
|
347
458
|
const items = (output && Array.isArray(output.models) && output.models)
|
|
348
459
|
|| (body && Array.isArray(body.data) && body.data)
|
|
@@ -411,7 +522,7 @@ export async function visionStream({ key, baseUrl, model = 'qwen-vl-plus', promp
|
|
|
411
522
|
const text = await res.text().catch(() => '');
|
|
412
523
|
let body = null;
|
|
413
524
|
try { body = JSON.parse(text); } catch (_) { /* keep null */ }
|
|
414
|
-
throw providerError(res.status, body, '视觉理解失败');
|
|
525
|
+
throw providerError(res.status, body, '视觉理解失败', res.headers);
|
|
415
526
|
}
|
|
416
527
|
let full = '';
|
|
417
528
|
const decoder = new TextDecoder();
|
|
@@ -488,7 +599,7 @@ export async function submitTranscription({ key, baseUrl, model = 'qwen-audio-3.
|
|
|
488
599
|
body: JSON.stringify({ model, input })
|
|
489
600
|
});
|
|
490
601
|
const body = await readJson(res);
|
|
491
|
-
if (!res.ok) throw providerError(res.status, body, '提交转写失败');
|
|
602
|
+
if (!res.ok) throw providerError(res.status, body, '提交转写失败', res.headers);
|
|
492
603
|
const taskId = body && body.output && body.output.task_id;
|
|
493
604
|
if (!taskId) throw new Error('提交成功但未返回 task_id');
|
|
494
605
|
return taskId;
|
|
@@ -520,7 +631,7 @@ export async function pollTranscriptionTask({ key, baseUrl, remoteTaskId, timeou
|
|
|
520
631
|
signal: timeoutSignal(timeoutMs, signal)
|
|
521
632
|
});
|
|
522
633
|
const body = await readJson(res);
|
|
523
|
-
if (!res.ok) throw providerError(res.status, body, '查询转写任务失败');
|
|
634
|
+
if (!res.ok) throw providerError(res.status, body, '查询转写任务失败', res.headers);
|
|
524
635
|
const out = (body && body.output) || {};
|
|
525
636
|
const status = out.task_status || 'UNKNOWN';
|
|
526
637
|
let text = '';
|
package/lib/api.js
CHANGED
|
@@ -17,6 +17,9 @@ import { irisHome, capabilitiesOf, onChange as onConfigChange, providerHealthSna
|
|
|
17
17
|
import { mimeOf, mediaUrl } from './media.js';
|
|
18
18
|
import * as tasks from './tasks.js';
|
|
19
19
|
import * as artifacts from './artifacts.js';
|
|
20
|
+
import { workbenchArtifacts } from './workbench-artifacts.js';
|
|
21
|
+
import { workbenchDetail, downloadWorkbenchSelection, deleteWorkbenchArtifacts, workbenchTransactions,
|
|
22
|
+
restoreWorkbenchTransaction, workbenchError } from './workbench-management.js';
|
|
20
23
|
import { runAction } from './actions.js';
|
|
21
24
|
import { chmodPrivateFile, ensurePrivateDir } from './private-storage.js';
|
|
22
25
|
import { selectMediaProtocol } from './provider-protocol.js';
|
|
@@ -25,6 +28,7 @@ import { hostDoctor } from './doctor.js';
|
|
|
25
28
|
import { hostRuntimeEvidence, recordHostClient } from './host-runtime.js';
|
|
26
29
|
import { cancelProviderTaskForDsh, coreSnapshotForDsh, disposeCoreTaskAttention, readCoreArtifactMediaForDsh, redeliverProviderTaskForDsh, reobserveProviderTaskForDsh, retryProviderTaskForDsh } from './dsh-core-adapter.js';
|
|
27
30
|
import { optimizePrompt } from './prompt-optimizer.js';
|
|
31
|
+
import { PROMPT_OPTIMIZER_CAPABILITIES } from './prompt-optimizer-core.js';
|
|
28
32
|
import {
|
|
29
33
|
importPromptOptimizerConfig,
|
|
30
34
|
loadPromptOptimizerConfig,
|
|
@@ -331,6 +335,9 @@ export function serveApi(req, res, host) {
|
|
|
331
335
|
|
|
332
336
|
// POST /iris/api/actions/:name —— GUI 直连触发工具执行
|
|
333
337
|
if (method === 'POST') {
|
|
338
|
+
if (urlPath === '/iris/api/works/download') return handleWorkbenchPost(req, res, downloadWorkbenchSelection, true);
|
|
339
|
+
if (urlPath === '/iris/api/works/delete') return handleWorkbenchPost(req, res, deleteWorkbenchArtifacts);
|
|
340
|
+
if (urlPath === '/iris/api/works/restore') return handleWorkbenchPost(req, res, restoreWorkbenchTransaction);
|
|
334
341
|
if (urlPath === '/iris/api/host-client') {
|
|
335
342
|
if (!host || typeof host !== 'object') return sendJson(res, 503, { error: 'host unavailable' });
|
|
336
343
|
return handleJsonPost(req, res, (body) => recordHostClient(body));
|
|
@@ -362,7 +369,7 @@ export function serveApi(req, res, host) {
|
|
|
362
369
|
return serveSse(res);
|
|
363
370
|
}
|
|
364
371
|
if (urlPath === '/iris/api/prompt-optimizer/config') {
|
|
365
|
-
return sendJson(res, 200, method === 'HEAD' ? {} : loadPromptOptimizerConfig());
|
|
372
|
+
return sendJson(res, 200, method === 'HEAD' ? {} : { ...loadPromptOptimizerConfig(), capabilities: PROMPT_OPTIMIZER_CAPABILITIES });
|
|
366
373
|
}
|
|
367
374
|
if (urlPath === '/iris/api/doctor') {
|
|
368
375
|
if (!host || typeof host !== 'object') return sendJson(res, 503, { error: 'host unavailable' });
|
|
@@ -373,6 +380,12 @@ export function serveApi(req, res, host) {
|
|
|
373
380
|
const query = new URL(req.url || '', 'http://iris.local').searchParams;
|
|
374
381
|
return serveCoreSnapshot(res, { limit: Number(query.get('limit') || 12) });
|
|
375
382
|
}
|
|
383
|
+
if (urlPath === '/iris/api/works') {
|
|
384
|
+
return serveWorkbenchArtifacts(req, method, res);
|
|
385
|
+
}
|
|
386
|
+
if (urlPath === '/iris/api/works/transactions') return serveWorkbenchRead(res, method, () => workbenchTransactions());
|
|
387
|
+
const workDetailMatch = urlPath.match(/^\/iris\/api\/works\/(core|legacy)\/([^/]+)$/);
|
|
388
|
+
if (workDetailMatch) return serveWorkbenchRead(res, method, () => workbenchDetail(workDetailMatch[1], workDetailMatch[2]));
|
|
376
389
|
const coreMediaMatch = urlPath.match(/^\/iris\/api\/core\/artifact\/(artifact_[a-f0-9]{24})\/media$/);
|
|
377
390
|
if (coreMediaMatch) return serveCoreArtifactMedia(coreMediaMatch[1], method, res);
|
|
378
391
|
if (urlPath === '/iris/api/artifacts') {
|
|
@@ -400,6 +413,32 @@ export function serveApi(req, res, host) {
|
|
|
400
413
|
}
|
|
401
414
|
}
|
|
402
415
|
|
|
416
|
+
async function serveWorkbenchArtifacts(req, method, res) {
|
|
417
|
+
const params = new URL(req.url || '', 'http://iris.local').searchParams;
|
|
418
|
+
const input = Object.fromEntries(params);
|
|
419
|
+
for (const key of ['offset', 'limit']) if (input[key] !== undefined) input[key] = Number(input[key]);
|
|
420
|
+
try { return sendJson(res, 200, method === 'HEAD' ? {} : await workbenchArtifacts(input)); }
|
|
421
|
+
catch (error) { return sendJson(res, error.statusCode || 500, { error: '作品读取失败', code: error.code || 'IRIS_GALLERY_FAILED' }); }
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
async function serveWorkbenchRead(res, method, execute) {
|
|
425
|
+
try { return sendJson(res, 200, method === 'HEAD' ? {} : await execute()); }
|
|
426
|
+
catch (error) { const safe = workbenchError(error); return sendJson(res, safe.statusCode || 400, { error: safe.message, code: safe.code }); }
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function handleWorkbenchPost(req, res, execute, download = false) {
|
|
430
|
+
return handleJsonPost(req, res, async (body, signal) => {
|
|
431
|
+
try { return await execute(body, { signal }); }
|
|
432
|
+
catch (error) { throw workbenchError(error); }
|
|
433
|
+
}, download ? (response, _status, bytes) => {
|
|
434
|
+
if (response.writableEnded || response.destroyed) return;
|
|
435
|
+
response.writeHead(200, { 'Content-Type': 'application/zip', 'Content-Length': bytes.length,
|
|
436
|
+
'Content-Disposition': 'attachment; filename="iris-works.zip"', 'Cache-Control': 'no-store',
|
|
437
|
+
'X-Content-Type-Options': 'nosniff', 'Cross-Origin-Resource-Policy': 'same-origin' });
|
|
438
|
+
response.end(bytes);
|
|
439
|
+
} : sendJson);
|
|
440
|
+
}
|
|
441
|
+
|
|
403
442
|
async function serveCoreArtifactMedia(artifactId, method, res) {
|
|
404
443
|
try {
|
|
405
444
|
const result = await readCoreArtifactMediaForDsh(artifactId);
|
|
@@ -439,7 +478,7 @@ function sendJson(res, status, obj) {
|
|
|
439
478
|
|
|
440
479
|
/** POST /iris/api/actions/:name:读取 JSON body 调 runAction 返回结果 */
|
|
441
480
|
const MAX_BODY_BYTES = 1e6; // 1MB 上限(防滥用)
|
|
442
|
-
function handleJsonPost(req, res, execute) {
|
|
481
|
+
function handleJsonPost(req, res, execute, respond = sendJson) {
|
|
443
482
|
const chunks = [];
|
|
444
483
|
const controller = new AbortController();
|
|
445
484
|
let completed = false;
|
|
@@ -448,7 +487,7 @@ function handleJsonPost(req, res, execute) {
|
|
|
448
487
|
const finish = (status, body) => {
|
|
449
488
|
if (completed) return;
|
|
450
489
|
completed = true;
|
|
451
|
-
sendJson(res, status, body);
|
|
490
|
+
(status === 200 ? respond : sendJson)(res, status, body);
|
|
452
491
|
};
|
|
453
492
|
const disconnect = () => { if (!completed) controller.abort(); };
|
|
454
493
|
if (typeof req.on === 'function') req.on('aborted', disconnect);
|
|
@@ -469,11 +508,13 @@ function handleJsonPost(req, res, execute) {
|
|
|
469
508
|
const raw = Buffer.concat(chunks).toString('utf8');
|
|
470
509
|
const body = raw ? JSON.parse(raw) : {};
|
|
471
510
|
const result = await execute(body, controller.signal);
|
|
511
|
+
if (controller.signal.aborted) return finish(499, { error: 'request canceled' });
|
|
472
512
|
finish(200, result);
|
|
473
513
|
} catch (err) {
|
|
474
514
|
finish(controller.signal.aborted ? 499 : (Number(err && err.statusCode) || 400), {
|
|
475
515
|
error: controller.signal.aborted ? 'request canceled' : String((err && err.message) || err),
|
|
476
|
-
...(err && err.code ? { code: String(err.code) } : {})
|
|
516
|
+
...(err && err.code ? { code: String(err.code) } : {}),
|
|
517
|
+
...(err && /^delete_[a-f0-9]{24}$/.test(err.transactionId) ? { transactionId: err.transactionId } : {})
|
|
477
518
|
});
|
|
478
519
|
}
|
|
479
520
|
});
|
|
@@ -524,6 +565,7 @@ function handleCoreTaskRetry(taskId, req, res) {
|
|
|
524
565
|
const result = await retryProviderTaskForDsh({
|
|
525
566
|
taskId,
|
|
526
567
|
prompt,
|
|
568
|
+
...(body.source_artifact_id !== undefined ? { sourceArtifactId: body.source_artifact_id } : {}),
|
|
527
569
|
...(body.text !== undefined ? { text: String(body.text) } : {}),
|
|
528
570
|
...(body.voice !== undefined ? { voice: String(body.voice) } : {}),
|
|
529
571
|
...(body.audio_url !== undefined ? { audio_url: String(body.audio_url) } : {}),
|
|
@@ -588,10 +630,10 @@ function handlePromptOptimizer(host, operation, req, res) {
|
|
|
588
630
|
if (operation === 'optimize') return optimizePrompt(host, body, { signal });
|
|
589
631
|
if (operation === 'import') {
|
|
590
632
|
const imported = body && body.config !== undefined ? body.config : body;
|
|
591
|
-
return { ok: true, ...importPromptOptimizerConfig(imported) };
|
|
633
|
+
return { ok: true, ...importPromptOptimizerConfig(imported), capabilities: PROMPT_OPTIMIZER_CAPABILITIES };
|
|
592
634
|
}
|
|
593
|
-
if (operation === 'reset') return { ok: true, ...resetPromptOptimizerConfig() };
|
|
594
|
-
if (operation === 'enabled') return { ok: true, ...setPromptOptimizerEnabled(body && body.enabled) };
|
|
635
|
+
if (operation === 'reset') return { ok: true, ...resetPromptOptimizerConfig(), capabilities: PROMPT_OPTIMIZER_CAPABILITIES };
|
|
636
|
+
if (operation === 'enabled') return { ok: true, ...setPromptOptimizerEnabled(body && body.enabled), capabilities: PROMPT_OPTIMIZER_CAPABILITIES };
|
|
595
637
|
throw new Error('unknown prompt optimizer operation');
|
|
596
638
|
});
|
|
597
639
|
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import http from 'node:http';
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
|
|
8
|
+
// 与 DSH render 的页面权限一致:只渲染内联样式和内嵌图片,不执行脚本或加载网络资源。
|
|
9
|
+
const CSP = "sandbox; default-src 'none'; img-src data: blob:; style-src 'unsafe-inline'; font-src data:; connect-src 'none'; form-action 'none'; base-uri 'none'; frame-src 'none'";
|
|
10
|
+
const failure = (code, message) => Object.assign(new Error(message), { code });
|
|
11
|
+
|
|
12
|
+
export function createChromiumBrowser({ executable, noSandbox = false, timeoutMs = 30000 } = {}) {
|
|
13
|
+
if (!executable || !path.isAbsolute(executable)) {
|
|
14
|
+
throw failure('IRIS_BROWSER_REQUIRED', '请通过 --browser-executable 或 IRIS_BROWSER_EXECUTABLE 提供 Chromium 的绝对路径');
|
|
15
|
+
}
|
|
16
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 120000) {
|
|
17
|
+
throw failure('IRIS_BROWSER_INPUT_INVALID', '浏览器超时必须是 1–120000 毫秒');
|
|
18
|
+
}
|
|
19
|
+
return Object.freeze({ async renderHtml({ html, width, height, fullPage, signal }) {
|
|
20
|
+
const operation = AbortSignal.any([...(signal ? [signal] : []), AbortSignal.timeout(timeoutMs)]);
|
|
21
|
+
operation.throwIfAborted();
|
|
22
|
+
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'iris-browser-'));
|
|
23
|
+
const route = '/' + crypto.randomBytes(16).toString('hex');
|
|
24
|
+
const server = http.createServer((req, res) => {
|
|
25
|
+
if (req.url !== route || !['GET', 'HEAD'].includes(req.method)) { res.writeHead(404); res.end(); return; }
|
|
26
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Content-Security-Policy': CSP,
|
|
27
|
+
'Referrer-Policy': 'no-referrer', 'X-Content-Type-Options': 'nosniff', 'Cache-Control': 'no-store' });
|
|
28
|
+
res.end(req.method === 'HEAD' ? '' : html);
|
|
29
|
+
});
|
|
30
|
+
let child;
|
|
31
|
+
let closed;
|
|
32
|
+
let nextId = 0;
|
|
33
|
+
let incoming = Buffer.alloc(0);
|
|
34
|
+
const pending = new Map();
|
|
35
|
+
const events = new Map();
|
|
36
|
+
const rejectPending = error => {
|
|
37
|
+
for (const request of pending.values()) request.reject(error);
|
|
38
|
+
pending.clear();
|
|
39
|
+
for (const event of events.values()) event.reject(error);
|
|
40
|
+
events.clear();
|
|
41
|
+
};
|
|
42
|
+
const kill = () => {
|
|
43
|
+
if (!child?.pid) return;
|
|
44
|
+
try { process.kill(-child.pid, 'SIGKILL'); } catch (_) { /* 已结束 */ }
|
|
45
|
+
};
|
|
46
|
+
const onAbort = () => { rejectPending(operation.reason); kill(); };
|
|
47
|
+
operation.addEventListener('abort', onAbort, { once: true });
|
|
48
|
+
const send = (method, params = {}, sessionId) => {
|
|
49
|
+
operation.throwIfAborted();
|
|
50
|
+
return new Promise((resolve, reject) => {
|
|
51
|
+
const id = ++nextId;
|
|
52
|
+
pending.set(id, { resolve, reject });
|
|
53
|
+
child.stdio[3].write(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }) + '\0', error => {
|
|
54
|
+
if (error) { pending.delete(id); reject(failure('IRIS_BROWSER_FAILED', '浏览器通信失败')); }
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
};
|
|
58
|
+
try {
|
|
59
|
+
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
|
|
60
|
+
operation.throwIfAborted();
|
|
61
|
+
const args = ['--headless=new', '--hide-scrollbars', '--remote-debugging-pipe', '--disable-gpu', '--disable-dev-shm-usage', '--disable-background-networking',
|
|
62
|
+
'--no-first-run', '--no-default-browser-check', '--disable-extensions', '--disable-sync',
|
|
63
|
+
'--user-data-dir=' + profile, ...(noSandbox ? ['--no-sandbox'] : [])];
|
|
64
|
+
child = spawn(executable, args, { detached: true, stdio: ['ignore', 'ignore', 'ignore', 'pipe', 'pipe'] });
|
|
65
|
+
closed = new Promise(resolve => {
|
|
66
|
+
child.once('error', () => { rejectPending(failure('IRIS_BROWSER_FAILED', '无法启动 Chromium')); resolve(); });
|
|
67
|
+
child.once('close', () => { rejectPending(failure('IRIS_BROWSER_FAILED', 'Chromium 提前退出;请检查可执行文件及运行环境')); resolve(); });
|
|
68
|
+
});
|
|
69
|
+
child.stdio[3].on('error', () => rejectPending(failure('IRIS_BROWSER_FAILED', '浏览器通信失败')));
|
|
70
|
+
child.stdio[4].on('data', chunk => {
|
|
71
|
+
incoming = Buffer.concat([incoming, chunk]);
|
|
72
|
+
let separator;
|
|
73
|
+
while ((separator = incoming.indexOf(0)) >= 0) {
|
|
74
|
+
const packet = incoming.subarray(0, separator); incoming = incoming.subarray(separator + 1);
|
|
75
|
+
let message;
|
|
76
|
+
try { message = JSON.parse(packet.toString()); } catch (_) { continue; }
|
|
77
|
+
if (message.id) {
|
|
78
|
+
const request = pending.get(message.id);
|
|
79
|
+
pending.delete(message.id);
|
|
80
|
+
if (request) message.error ? request.reject(failure('IRIS_BROWSER_FAILED', 'Chromium 无法完成截图操作')) : request.resolve(message.result);
|
|
81
|
+
} else {
|
|
82
|
+
const key = message.sessionId + ':' + message.method;
|
|
83
|
+
const event = events.get(key);
|
|
84
|
+
if (event) { events.delete(key); event.resolve(message.params); }
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
const { targetId } = await send('Target.createTarget', { url: 'about:blank' });
|
|
89
|
+
const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true });
|
|
90
|
+
await send('Page.enable', {}, sessionId);
|
|
91
|
+
await send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: false }, sessionId);
|
|
92
|
+
const loaded = new Promise((resolve, reject) => events.set(sessionId + ':Page.loadEventFired', { resolve, reject }));
|
|
93
|
+
// 导航失败时也必须消费事件 Promise 的拒绝。
|
|
94
|
+
loaded.catch(() => {});
|
|
95
|
+
const navigation = await send('Page.navigate', { url: 'http://127.0.0.1:' + server.address().port + route }, sessionId);
|
|
96
|
+
if (navigation.errorText) throw failure('IRIS_BROWSER_FAILED', 'HTML 页面无法加载');
|
|
97
|
+
await loaded;
|
|
98
|
+
const layout = await send('Page.getLayoutMetrics', {}, sessionId);
|
|
99
|
+
const size = fullPage ? layout.cssContentSize : { width, height };
|
|
100
|
+
const captureWidth = Math.max(width, Math.ceil(size.width)), captureHeight = Math.ceil(size.height);
|
|
101
|
+
if (captureWidth > 16384 || captureHeight > 16384 || captureWidth * captureHeight > 40000000) {
|
|
102
|
+
throw failure('IRIS_BROWSER_INPUT_INVALID', '截图尺寸超过限制(边长 16384,4000 万像素)');
|
|
103
|
+
}
|
|
104
|
+
const screenshot = await send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: fullPage,
|
|
105
|
+
clip: { x: 0, y: 0, width: captureWidth, height: captureHeight, scale: 1 } }, sessionId);
|
|
106
|
+
operation.throwIfAborted();
|
|
107
|
+
return { bytes: Buffer.from(screenshot.data, 'base64'), mediaType: 'image/png' };
|
|
108
|
+
} finally {
|
|
109
|
+
operation.removeEventListener('abort', onAbort);
|
|
110
|
+
kill();
|
|
111
|
+
if (closed) await closed;
|
|
112
|
+
server.closeAllConnections();
|
|
113
|
+
await new Promise(resolve => server.close(resolve));
|
|
114
|
+
fs.rmSync(profile, { recursive: true, force: true });
|
|
115
|
+
}
|
|
116
|
+
} });
|
|
117
|
+
}
|