@mokuyoaxis/dsh-iris 0.1.4 → 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 +99 -1
- package/README.en.md +124 -31
- package/README.md +124 -31
- package/bin/dsh-iris.js +593 -13
- package/docs/AGENT_QUICKSTART.md +39 -0
- package/docs/ARCHITECTURE.md +87 -64
- package/docs/ARTIFACT_LIBRARY.md +5 -5
- package/docs/ARTIFACT_MANIFEST.md +60 -0
- package/docs/CLI_MANAGEMENT.md +181 -0
- package/docs/COMPOSITE_VISION.md +35 -0
- package/docs/CORE_RUNTIME_CONTRACT.md +84 -0
- package/docs/DSH_CORE_MIGRATION.md +135 -0
- package/docs/DSH_RC2_ADAPTATION.md +38 -0
- package/docs/FAULT_INJECTION.md +12 -2
- package/docs/HEADLESS_CLI.md +223 -0
- package/docs/HOST_ADAPTER_CONTRACT.md +36 -17
- package/docs/HOST_DOCTOR.md +4 -2
- package/docs/MODEL_PORT_CONTRACT.md +305 -0
- package/docs/OCR_MODEL.md +39 -0
- package/docs/PROMPT_OPTIMIZER.md +47 -0
- package/docs/PROVIDER_ADAPTER_CONTRACT.md +53 -10
- package/docs/PROVIDER_HEALTH.md +22 -5
- package/docs/PROVIDER_RUNTIME_HARNESS.md +33 -0
- package/docs/PROVIDER_SUBMISSION_CONTRACT.md +2 -2
- package/docs/ROADMAP.md +49 -83
- package/docs/SECURITY.md +8 -7
- 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/assets/logo/README.md +55 -0
- package/docs/assets/logo/iris-bubble-concept.svg +67 -0
- package/docs/assets/logo/iris-bubble.svg +14 -0
- package/docs/assets/logo/iris-interim-flower.png +0 -0
- package/docs/assets/logo/iris-logo-mono-inverse.svg +78 -0
- package/docs/assets/logo/iris-logo-mono.svg +78 -0
- package/docs/assets/logo/iris-logo.svg +78 -0
- package/docs/assets/logo/iris-mark-mono-inverse.svg +42 -0
- package/docs/assets/logo/iris-mark-mono.svg +42 -0
- package/docs/assets/logo/iris-mark.svg +42 -0
- package/docs/file-access-across-environments.md +8 -5
- package/docs/releases/0.2.0.md +48 -0
- package/docs/releases/0.2.1.md +56 -0
- package/lib/actions.js +520 -591
- package/lib/adapters.js +184 -36
- package/lib/api.js +200 -11
- package/lib/chromium-browser.js +117 -0
- package/lib/client.js +1390 -105
- package/lib/command-service.js +534 -0
- package/lib/composite-vision-routing.js +42 -0
- package/lib/config.js +143 -51
- package/lib/core-artifact-store.js +630 -0
- package/lib/core-artifacts.js +4 -0
- package/lib/core-attention.js +103 -0
- package/lib/core-contract.js +105 -0
- package/lib/core-doctor.js +41 -0
- package/lib/core-lease-recovery.js +170 -0
- package/lib/core-maintenance.js +213 -0
- package/lib/core-runtime.js +232 -0
- package/lib/core-tasks.js +567 -0
- package/lib/core-user-projection.js +193 -0
- package/lib/doctor.js +92 -33
- package/lib/dsh-core-adapter.js +653 -0
- package/lib/dsh-host-adapter.js +58 -7
- package/lib/dsh-text-model-adapter.js +222 -0
- package/lib/dsh-vision-model-adapter.js +75 -0
- package/lib/generation-input.js +126 -0
- package/lib/guard.js +3 -3
- 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 +323 -188
- 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 +220 -0
- package/lib/model-port-contract.js +341 -0
- package/lib/models.js +105 -14
- 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-adapter.js +49 -23
- package/lib/provider-adapters.js +165 -26
- package/lib/provider-catalog.js +183 -0
- package/lib/provider-config-service.js +236 -0
- package/lib/provider-contract.js +74 -4
- package/lib/provider-health.js +80 -1
- package/lib/provider-protocol.js +74 -2
- package/lib/provider-task-runner.js +370 -0
- package/lib/summarize.js +31 -20
- package/lib/summary-input.js +52 -0
- package/lib/task-wait.js +40 -0
- package/lib/tasks.js +16 -4
- 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 +31 -9
- package/lib/workbench-artifacts.js +52 -0
- package/lib/workbench-management.js +151 -0
- package/lib/workbench-zip.js +39 -0
- package/package.json +8 -8
- package/user_guide.md +71 -26
package/lib/api.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*
|
|
8
8
|
* 输出纪律(与整仓一致):
|
|
9
9
|
* - 只回标量字段(字符串/数字/布尔/受限数组),apiKey 永不明文,只给 hint;
|
|
10
|
-
* - 文件路径只给相对 outputs/ 的 basename +
|
|
10
|
+
* - legacy 文件路径只给相对 outputs/ 的 basename + 128-bit token 链接;Core 媒体按随机 Artifact ID 读取,不宣称独立 token;
|
|
11
11
|
* - tasks/providers 读的是 $DSH_HOME/iris/v1 的持久 JSON,与任务盯守共用同一存储。
|
|
12
12
|
*/
|
|
13
13
|
import fs from 'node:fs';
|
|
@@ -17,13 +17,18 @@ 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
|
-
import {
|
|
25
|
+
import { selectMediaProtocol } from './provider-protocol.js';
|
|
23
26
|
import { deriveUserState } from './task-semantics.js';
|
|
24
27
|
import { hostDoctor } from './doctor.js';
|
|
25
28
|
import { hostRuntimeEvidence, recordHostClient } from './host-runtime.js';
|
|
29
|
+
import { cancelProviderTaskForDsh, coreSnapshotForDsh, disposeCoreTaskAttention, readCoreArtifactMediaForDsh, redeliverProviderTaskForDsh, reobserveProviderTaskForDsh, retryProviderTaskForDsh } from './dsh-core-adapter.js';
|
|
26
30
|
import { optimizePrompt } from './prompt-optimizer.js';
|
|
31
|
+
import { PROMPT_OPTIMIZER_CAPABILITIES } from './prompt-optimizer-core.js';
|
|
27
32
|
import {
|
|
28
33
|
importPromptOptimizerConfig,
|
|
29
34
|
loadPromptOptimizerConfig,
|
|
@@ -55,8 +60,9 @@ function providerPublic(p) {
|
|
|
55
60
|
name: p.name || p.id,
|
|
56
61
|
type: p.type || 'openai',
|
|
57
62
|
baseUrl: p.baseUrl,
|
|
63
|
+
mediaBaseUrl: p.mediaBaseUrl || '',
|
|
58
64
|
enabled: p.enabled !== false,
|
|
59
|
-
|
|
65
|
+
...selectMediaProtocol(p),
|
|
60
66
|
apiKeyHint: keyHint(p.apiKey),
|
|
61
67
|
capabilities: capabilitiesOf(p),
|
|
62
68
|
// 迁移/推断结果是否与原始声明不同(UI 提示旧配置已推断)
|
|
@@ -329,6 +335,9 @@ export function serveApi(req, res, host) {
|
|
|
329
335
|
|
|
330
336
|
// POST /iris/api/actions/:name —— GUI 直连触发工具执行
|
|
331
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);
|
|
332
341
|
if (urlPath === '/iris/api/host-client') {
|
|
333
342
|
if (!host || typeof host !== 'object') return sendJson(res, 503, { error: 'host unavailable' });
|
|
334
343
|
return handleJsonPost(req, res, (body) => recordHostClient(body));
|
|
@@ -337,6 +346,13 @@ export function serveApi(req, res, host) {
|
|
|
337
346
|
if (urlPath === '/iris/api/upload') {
|
|
338
347
|
return handleUpload(req, res);
|
|
339
348
|
}
|
|
349
|
+
// D4 人工 retry as new task:产生新的真实计费,请求体必须显式 confirmBilling:true
|
|
350
|
+
const coreTaskRetry = urlPath.match(/^\/iris\/api\/core\/task\/([^/]+)\/retry\/?$/);
|
|
351
|
+
if (coreTaskRetry) return handleCoreTaskRetry(coreTaskRetry[1], req, res);
|
|
352
|
+
// D1/D2/D3 人工动作:reobserve 显式单步 poll 一次;redeliver 只对 succeeded+failed
|
|
353
|
+
// 重新取回产物;cancel 只有 Provider 明确确认才写已取消。三者绝不 submit。
|
|
354
|
+
const coreTaskAction = urlPath.match(/^\/iris\/api\/core\/task\/([^/]+)\/(reobserve|redeliver|cancel|acknowledge|restore|hide|unhide)\/?$/);
|
|
355
|
+
if (coreTaskAction) return handleCoreTaskManual(coreTaskAction[2], coreTaskAction[1], req, res);
|
|
340
356
|
const promptMatch = typeof host === 'object' && urlPath.match(/^\/iris\/api\/prompt-optimizer\/(optimize|import|reset|enabled)\/?$/);
|
|
341
357
|
if (promptMatch) return handlePromptOptimizer(host, promptMatch[1], req, res);
|
|
342
358
|
const actionMatch = typeof host === 'object' && urlPath.match(/^\/iris\/api\/actions\/([A-Za-z0-9_-]+)\/?$/);
|
|
@@ -353,12 +369,25 @@ export function serveApi(req, res, host) {
|
|
|
353
369
|
return serveSse(res);
|
|
354
370
|
}
|
|
355
371
|
if (urlPath === '/iris/api/prompt-optimizer/config') {
|
|
356
|
-
return sendJson(res, 200, method === 'HEAD' ? {} : loadPromptOptimizerConfig());
|
|
372
|
+
return sendJson(res, 200, method === 'HEAD' ? {} : { ...loadPromptOptimizerConfig(), capabilities: PROMPT_OPTIMIZER_CAPABILITIES });
|
|
357
373
|
}
|
|
358
374
|
if (urlPath === '/iris/api/doctor') {
|
|
359
375
|
if (!host || typeof host !== 'object') return sendJson(res, 503, { error: 'host unavailable' });
|
|
360
376
|
return sendJson(res, 200, method === 'HEAD' ? {} : hostDoctor(host, hostRuntimeEvidence()));
|
|
361
377
|
}
|
|
378
|
+
if (urlPath === '/iris/api/core/snapshot') {
|
|
379
|
+
if (method === 'HEAD') return sendJson(res, 200, {});
|
|
380
|
+
const query = new URL(req.url || '', 'http://iris.local').searchParams;
|
|
381
|
+
return serveCoreSnapshot(res, { limit: Number(query.get('limit') || 12) });
|
|
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]));
|
|
389
|
+
const coreMediaMatch = urlPath.match(/^\/iris\/api\/core\/artifact\/(artifact_[a-f0-9]{24})\/media$/);
|
|
390
|
+
if (coreMediaMatch) return serveCoreArtifactMedia(coreMediaMatch[1], method, res);
|
|
362
391
|
if (urlPath === '/iris/api/artifacts') {
|
|
363
392
|
const query = new URL(req.url || '', 'http://iris.local').searchParams;
|
|
364
393
|
const page = artifacts.page({ offset: Number(query.get('offset') || 0), limit: Number(query.get('limit') || 24) });
|
|
@@ -384,6 +413,63 @@ export function serveApi(req, res, host) {
|
|
|
384
413
|
}
|
|
385
414
|
}
|
|
386
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
|
+
|
|
442
|
+
async function serveCoreArtifactMedia(artifactId, method, res) {
|
|
443
|
+
try {
|
|
444
|
+
const result = await readCoreArtifactMediaForDsh(artifactId);
|
|
445
|
+
if (res.writableEnded || res.destroyed) return;
|
|
446
|
+
res.writeHead(200, {
|
|
447
|
+
'Content-Type': result.artifact.mediaType,
|
|
448
|
+
'Content-Length': result.bytes.length,
|
|
449
|
+
'Content-Disposition': 'inline',
|
|
450
|
+
'Cache-Control': 'private, max-age=3600',
|
|
451
|
+
'X-Content-Type-Options': 'nosniff',
|
|
452
|
+
'Cross-Origin-Resource-Policy': 'same-origin',
|
|
453
|
+
'Referrer-Policy': 'no-referrer'
|
|
454
|
+
});
|
|
455
|
+
res.end(method === 'HEAD' ? undefined : result.bytes);
|
|
456
|
+
} catch (_) {
|
|
457
|
+
return sendJson(res, 404, { error: 'not found' });
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
async function serveCoreSnapshot(res, options = {}) {
|
|
462
|
+
try {
|
|
463
|
+
return sendJson(res, 200, await coreSnapshotForDsh({ limit: options.limit || 12 }));
|
|
464
|
+
} catch (error) {
|
|
465
|
+
console.error('[iris] Core 只读快照失败:', error && error.code || 'unknown');
|
|
466
|
+
return sendJson(res, 409, {
|
|
467
|
+
schemaVersion: 0, available: true, readOnly: true,
|
|
468
|
+
error: { code: String(error && error.code || 'IRIS_CORE_SNAPSHOT_FAILED') }
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
387
473
|
function sendJson(res, status, obj) {
|
|
388
474
|
if (res.writableEnded || res.destroyed) return; // 已结束/已销毁的连接不再写(end 后二次 end 防御)
|
|
389
475
|
if (!res.headersSent) res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' });
|
|
@@ -392,7 +478,7 @@ function sendJson(res, status, obj) {
|
|
|
392
478
|
|
|
393
479
|
/** POST /iris/api/actions/:name:读取 JSON body 调 runAction 返回结果 */
|
|
394
480
|
const MAX_BODY_BYTES = 1e6; // 1MB 上限(防滥用)
|
|
395
|
-
function handleJsonPost(req, res, execute) {
|
|
481
|
+
function handleJsonPost(req, res, execute, respond = sendJson) {
|
|
396
482
|
const chunks = [];
|
|
397
483
|
const controller = new AbortController();
|
|
398
484
|
let completed = false;
|
|
@@ -401,7 +487,7 @@ function handleJsonPost(req, res, execute) {
|
|
|
401
487
|
const finish = (status, body) => {
|
|
402
488
|
if (completed) return;
|
|
403
489
|
completed = true;
|
|
404
|
-
sendJson(res, status, body);
|
|
490
|
+
(status === 200 ? respond : sendJson)(res, status, body);
|
|
405
491
|
};
|
|
406
492
|
const disconnect = () => { if (!completed) controller.abort(); };
|
|
407
493
|
if (typeof req.on === 'function') req.on('aborted', disconnect);
|
|
@@ -422,10 +508,13 @@ function handleJsonPost(req, res, execute) {
|
|
|
422
508
|
const raw = Buffer.concat(chunks).toString('utf8');
|
|
423
509
|
const body = raw ? JSON.parse(raw) : {};
|
|
424
510
|
const result = await execute(body, controller.signal);
|
|
511
|
+
if (controller.signal.aborted) return finish(499, { error: 'request canceled' });
|
|
425
512
|
finish(200, result);
|
|
426
513
|
} catch (err) {
|
|
427
|
-
finish(controller.signal.aborted ? 499 : 400, {
|
|
428
|
-
error: controller.signal.aborted ? 'request canceled' : String((err && err.message) || err)
|
|
514
|
+
finish(controller.signal.aborted ? 499 : (Number(err && err.statusCode) || 400), {
|
|
515
|
+
error: controller.signal.aborted ? 'request canceled' : String((err && err.message) || err),
|
|
516
|
+
...(err && err.code ? { code: String(err.code) } : {}),
|
|
517
|
+
...(err && /^delete_[a-f0-9]{24}$/.test(err.transactionId) ? { transactionId: err.transactionId } : {})
|
|
429
518
|
});
|
|
430
519
|
}
|
|
431
520
|
});
|
|
@@ -436,15 +525,115 @@ function handleAction(host, name, req, res) {
|
|
|
436
525
|
return handleJsonPost(req, res, (args, signal) => runAction(host, name, args, { signal }));
|
|
437
526
|
}
|
|
438
527
|
|
|
528
|
+
/**
|
|
529
|
+
* POST /iris/api/core/task/:id/(reobserve|redeliver) —— D1/D2 人工动作。
|
|
530
|
+
* 成功返回 200 + 任务最新五类用户投影行;坏 ID/未知/不满足动作门映射为稳定 4xx。
|
|
531
|
+
* 任何拒绝都在 Provider 调用与 Task 写入之前发生;错误体不含绝对路径、Key、
|
|
532
|
+
* 供应商身份或 binding。请求体不接受参数(Task ID 只在 URL 里),客户端断开
|
|
533
|
+
* 经 AbortSignal 传入在途操作,与 GUI actions 同一约定。
|
|
534
|
+
*/
|
|
535
|
+
const CORE_TASK_MANUAL_ACTIONS = Object.freeze({
|
|
536
|
+
reobserve: { command: 'task.reobserve', run: reobserveProviderTaskForDsh },
|
|
537
|
+
redeliver: { command: 'task.redeliver', run: redeliverProviderTaskForDsh },
|
|
538
|
+
cancel: { command: 'task.cancel', run: cancelProviderTaskForDsh },
|
|
539
|
+
// 注意力处置是 Host 偏好(不写 Core 事实):acknowledge/restore/hide/unhide。
|
|
540
|
+
acknowledge: { command: 'attention.acknowledge', run: (input) => disposeCoreTaskAttention({ ...input, action: 'acknowledge' }) },
|
|
541
|
+
restore: { command: 'attention.restore', run: (input) => disposeCoreTaskAttention({ ...input, action: 'restore' }) },
|
|
542
|
+
hide: { command: 'attention.hide', run: (input) => disposeCoreTaskAttention({ ...input, action: 'hide' }) },
|
|
543
|
+
unhide: { command: 'attention.unhide', run: (input) => disposeCoreTaskAttention({ ...input, action: 'unhide' }) }
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* POST /iris/api/core/task/:id/retry —— D4 人工重试为新任务。
|
|
548
|
+
* body 必须携带 { prompt, confirmBilling: true, modelRef? }:Core 记录不持久化
|
|
549
|
+
* Prompt,只能由调用方重新提供;缺显式计费确认在读取旧 Task 之前拒绝。
|
|
550
|
+
* 成功返回 200 + 新任务的五类用户投影行与 retriedFrom 关系。
|
|
551
|
+
*/
|
|
552
|
+
function handleCoreTaskRetry(taskId, req, res) {
|
|
553
|
+
if (!/^task_[a-f0-9]{24}$/.test(taskId)) {
|
|
554
|
+
return sendJson(res, 400, { error: { code: 'IRIS_DSH_TASK_INVALID', message: 'Core Task ID 无效' } });
|
|
555
|
+
}
|
|
556
|
+
handleJsonPost(req, res, async (body, signal) => {
|
|
557
|
+
try {
|
|
558
|
+
if (!body || body.confirmBilling !== true) {
|
|
559
|
+
const error = new Error('重试会创建新任务并可能产生重复生成费用;请求体必须显式 confirmBilling:true');
|
|
560
|
+
error.code = 'IRIS_COMMAND_BILLING_CONFIRM_REQUIRED';
|
|
561
|
+
error.statusCode = 400;
|
|
562
|
+
throw error;
|
|
563
|
+
}
|
|
564
|
+
const prompt = String(body.prompt || '');
|
|
565
|
+
const result = await retryProviderTaskForDsh({
|
|
566
|
+
taskId,
|
|
567
|
+
prompt,
|
|
568
|
+
...(body.source_artifact_id !== undefined ? { sourceArtifactId: body.source_artifact_id } : {}),
|
|
569
|
+
...(body.text !== undefined ? { text: String(body.text) } : {}),
|
|
570
|
+
...(body.voice !== undefined ? { voice: String(body.voice) } : {}),
|
|
571
|
+
...(body.audio_url !== undefined ? { audio_url: String(body.audio_url) } : {}),
|
|
572
|
+
modelRef: typeof body.modelRef === 'string' && body.modelRef.trim() ? body.modelRef.trim() : undefined,
|
|
573
|
+
confirmBilling: true,
|
|
574
|
+
signal
|
|
575
|
+
});
|
|
576
|
+
return {
|
|
577
|
+
ok: true,
|
|
578
|
+
contractVersion: 0,
|
|
579
|
+
command: 'task.retry',
|
|
580
|
+
retriedFrom: result.retriedFrom,
|
|
581
|
+
task: result.row
|
|
582
|
+
};
|
|
583
|
+
} catch (error) {
|
|
584
|
+
const code = String(error && error.code || '');
|
|
585
|
+
error.statusCode = Number(error.statusCode)
|
|
586
|
+
|| (code === 'IRIS_TASK_NOT_FOUND' ? 404
|
|
587
|
+
: (code === 'IRIS_TASK_NOT_RETRYABLE' || code.startsWith('IRIS_PROVIDER_')) ? 409
|
|
588
|
+
: 400);
|
|
589
|
+
throw error;
|
|
590
|
+
}
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function handleCoreTaskManual(action, taskId, req, res) {
|
|
595
|
+
if (!/^task_[a-f0-9]{24}$/.test(taskId)) {
|
|
596
|
+
return sendJson(res, 400, { error: { code: 'IRIS_DSH_TASK_INVALID', message: 'Core Task ID 无效' } });
|
|
597
|
+
}
|
|
598
|
+
const entry = CORE_TASK_MANUAL_ACTIONS[action];
|
|
599
|
+
if (!entry) return sendJson(res, 404, { error: 'not found' });
|
|
600
|
+
const controller = new AbortController();
|
|
601
|
+
let completed = false;
|
|
602
|
+
const finish = (status, body) => {
|
|
603
|
+
if (completed) return;
|
|
604
|
+
completed = true;
|
|
605
|
+
sendJson(res, status, body);
|
|
606
|
+
};
|
|
607
|
+
const disconnect = () => { if (!completed) controller.abort(); };
|
|
608
|
+
if (typeof req.on === 'function') req.on('aborted', disconnect);
|
|
609
|
+
if (typeof res.on === 'function') res.on('close', disconnect);
|
|
610
|
+
if (typeof req.resume === 'function') req.resume(); // 无 body 入参;避免小请求体滞留
|
|
611
|
+
entry.run({ taskId, signal: controller.signal })
|
|
612
|
+
.then((result) => finish(200, {
|
|
613
|
+
ok: true,
|
|
614
|
+
contractVersion: 0,
|
|
615
|
+
command: entry.command,
|
|
616
|
+
task: result.row
|
|
617
|
+
}))
|
|
618
|
+
.catch((error) => {
|
|
619
|
+
const code = String(error && error.code || 'IRIS_CORE_MANUAL_ACTION_FAILED');
|
|
620
|
+
const status = controller.signal.aborted ? 499
|
|
621
|
+
: code === 'IRIS_TASK_NOT_FOUND' ? 404
|
|
622
|
+
: (code === 'IRIS_TASK_ID_INVALID' || code === 'IRIS_DSH_TASK_INVALID') ? 400
|
|
623
|
+
: 409;
|
|
624
|
+
finish(status, { error: { code, message: String((error && error.message) || '无法执行 Core Task 动作') } });
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
|
|
439
628
|
function handlePromptOptimizer(host, operation, req, res) {
|
|
440
629
|
return handleJsonPost(req, res, async (body, signal) => {
|
|
441
630
|
if (operation === 'optimize') return optimizePrompt(host, body, { signal });
|
|
442
631
|
if (operation === 'import') {
|
|
443
632
|
const imported = body && body.config !== undefined ? body.config : body;
|
|
444
|
-
return { ok: true, ...importPromptOptimizerConfig(imported) };
|
|
633
|
+
return { ok: true, ...importPromptOptimizerConfig(imported), capabilities: PROMPT_OPTIMIZER_CAPABILITIES };
|
|
445
634
|
}
|
|
446
|
-
if (operation === 'reset') return { ok: true, ...resetPromptOptimizerConfig() };
|
|
447
|
-
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 };
|
|
448
637
|
throw new Error('unknown prompt optimizer operation');
|
|
449
638
|
});
|
|
450
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
|
+
}
|