@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.
Files changed (110) hide show
  1. package/.dsh/skills/iris-compose-media/SKILL.md +10 -5
  2. package/.dsh/skills/iris-compose-media/references/workflows.md +7 -1
  3. package/.dsh/skills/iris-verify-ui/SKILL.md +2 -1
  4. package/CHANGELOG.md +99 -1
  5. package/README.en.md +124 -31
  6. package/README.md +124 -31
  7. package/bin/dsh-iris.js +593 -13
  8. package/docs/AGENT_QUICKSTART.md +39 -0
  9. package/docs/ARCHITECTURE.md +87 -64
  10. package/docs/ARTIFACT_LIBRARY.md +5 -5
  11. package/docs/ARTIFACT_MANIFEST.md +60 -0
  12. package/docs/CLI_MANAGEMENT.md +181 -0
  13. package/docs/COMPOSITE_VISION.md +35 -0
  14. package/docs/CORE_RUNTIME_CONTRACT.md +84 -0
  15. package/docs/DSH_CORE_MIGRATION.md +135 -0
  16. package/docs/DSH_RC2_ADAPTATION.md +38 -0
  17. package/docs/FAULT_INJECTION.md +12 -2
  18. package/docs/HEADLESS_CLI.md +223 -0
  19. package/docs/HOST_ADAPTER_CONTRACT.md +36 -17
  20. package/docs/HOST_DOCTOR.md +4 -2
  21. package/docs/MODEL_PORT_CONTRACT.md +305 -0
  22. package/docs/OCR_MODEL.md +39 -0
  23. package/docs/PROMPT_OPTIMIZER.md +47 -0
  24. package/docs/PROVIDER_ADAPTER_CONTRACT.md +53 -10
  25. package/docs/PROVIDER_HEALTH.md +22 -5
  26. package/docs/PROVIDER_RUNTIME_HARNESS.md +33 -0
  27. package/docs/PROVIDER_SUBMISSION_CONTRACT.md +2 -2
  28. package/docs/ROADMAP.md +49 -83
  29. package/docs/SECURITY.md +8 -7
  30. package/docs/TASK_SEMANTICS.md +2 -2
  31. package/docs/VISION_CLI.md +132 -0
  32. package/docs/VISION_MODEL.md +51 -0
  33. package/docs/WORKBENCH_ARTIFACTS.md +67 -0
  34. package/docs/assets/logo/README.md +55 -0
  35. package/docs/assets/logo/iris-bubble-concept.svg +67 -0
  36. package/docs/assets/logo/iris-bubble.svg +14 -0
  37. package/docs/assets/logo/iris-interim-flower.png +0 -0
  38. package/docs/assets/logo/iris-logo-mono-inverse.svg +78 -0
  39. package/docs/assets/logo/iris-logo-mono.svg +78 -0
  40. package/docs/assets/logo/iris-logo.svg +78 -0
  41. package/docs/assets/logo/iris-mark-mono-inverse.svg +42 -0
  42. package/docs/assets/logo/iris-mark-mono.svg +42 -0
  43. package/docs/assets/logo/iris-mark.svg +42 -0
  44. package/docs/file-access-across-environments.md +8 -5
  45. package/docs/releases/0.2.0.md +48 -0
  46. package/docs/releases/0.2.1.md +56 -0
  47. package/lib/actions.js +520 -591
  48. package/lib/adapters.js +184 -36
  49. package/lib/api.js +200 -11
  50. package/lib/chromium-browser.js +117 -0
  51. package/lib/client.js +1390 -105
  52. package/lib/command-service.js +534 -0
  53. package/lib/composite-vision-routing.js +42 -0
  54. package/lib/config.js +143 -51
  55. package/lib/core-artifact-store.js +630 -0
  56. package/lib/core-artifacts.js +4 -0
  57. package/lib/core-attention.js +103 -0
  58. package/lib/core-contract.js +105 -0
  59. package/lib/core-doctor.js +41 -0
  60. package/lib/core-lease-recovery.js +170 -0
  61. package/lib/core-maintenance.js +213 -0
  62. package/lib/core-runtime.js +232 -0
  63. package/lib/core-tasks.js +567 -0
  64. package/lib/core-user-projection.js +193 -0
  65. package/lib/doctor.js +92 -33
  66. package/lib/dsh-core-adapter.js +653 -0
  67. package/lib/dsh-host-adapter.js +58 -7
  68. package/lib/dsh-text-model-adapter.js +222 -0
  69. package/lib/dsh-vision-model-adapter.js +75 -0
  70. package/lib/generation-input.js +126 -0
  71. package/lib/guard.js +3 -3
  72. package/lib/headless-vision.js +181 -0
  73. package/lib/host-runtime.js +1 -0
  74. package/lib/http-vision-model-adapter.js +187 -0
  75. package/lib/image-edit-input.js +34 -0
  76. package/lib/index.js +323 -188
  77. package/lib/locate.js +86 -28
  78. package/lib/media-probe.js +2 -1
  79. package/lib/model-call-runtime.js +45 -0
  80. package/lib/model-invoker.js +220 -0
  81. package/lib/model-port-contract.js +341 -0
  82. package/lib/models.js +105 -14
  83. package/lib/ocr-model-routing.js +26 -0
  84. package/lib/ocr.js +110 -64
  85. package/lib/prompt-optimizer-config.js +2 -1
  86. package/lib/prompt-optimizer-core.js +121 -0
  87. package/lib/prompt-optimizer.js +37 -193
  88. package/lib/provider-adapter.js +49 -23
  89. package/lib/provider-adapters.js +165 -26
  90. package/lib/provider-catalog.js +183 -0
  91. package/lib/provider-config-service.js +236 -0
  92. package/lib/provider-contract.js +74 -4
  93. package/lib/provider-health.js +80 -1
  94. package/lib/provider-protocol.js +74 -2
  95. package/lib/provider-task-runner.js +370 -0
  96. package/lib/summarize.js +31 -20
  97. package/lib/summary-input.js +52 -0
  98. package/lib/task-wait.js +40 -0
  99. package/lib/tasks.js +16 -4
  100. package/lib/video-input.js +30 -0
  101. package/lib/vision-core.js +42 -0
  102. package/lib/vision-image-input.js +83 -0
  103. package/lib/vision-input.js +31 -0
  104. package/lib/vision-model-routing.js +180 -0
  105. package/lib/vision.js +31 -9
  106. package/lib/workbench-artifacts.js +52 -0
  107. package/lib/workbench-management.js +151 -0
  108. package/lib/workbench-zip.js +39 -0
  109. package/package.json +8 -8
  110. package/user_guide.md +71 -26
@@ -0,0 +1,181 @@
1
+ 'use strict';
2
+ /** 独立视觉入口:显式配置/媒体,已有 Core 帧只读,主动转写使用 writer。 */
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { createHash } from 'node:crypto';
6
+ import { setTimeout as delay } from 'node:timers/promises';
7
+ import sharp from 'sharp';
8
+ import { CommandError } from './command-service.js';
9
+ import { visionCandidatesFromCatalog, transcribeCandidatesFromCatalog, providerForTaskFromCatalog, providerTaskBinding } from './provider-catalog.js';
10
+ import { createConfiguredVisionModelPort, readVisionImageFile } from './vision-model-routing.js';
11
+ import { createConfiguredProviderAdapter } from './provider-adapters.js';
12
+ import { prepareProviderInput } from './provider-adapter.js';
13
+ import { createProviderTaskRunner } from './provider-task-runner.js';
14
+ import { CoreArtifactError, readCoreArtifactBytes } from './core-artifacts.js';
15
+ import { createModelOperation } from './model-invoker.js';
16
+ import { ModelPortError, modelErrorRecord } from './model-port-contract.js';
17
+ import { completeVision, VISION_BUDGET } from './vision-core.js';
18
+ import { locateObject, createLocateImageRequest } from './locate.js';
19
+ import { createVisionInputObserver } from './vision-image-input.js';
20
+ import { longOcr, normalizeOcrSettings, formatOcrResult } from './ocr.js';
21
+ import { summarizeMedia } from './summarize.js';
22
+ import { normalizeSummarySource, readSummaryFrames, summaryFrameRecords } from './summary-input.js';
23
+ import { normalizeVisionImageSource, readVisionArtifact } from './vision-input.js';
24
+ import { extractFrames, normalizeFramesOptions, probeVideo, extractAudioTrack, ffmpegAvailable } from './media-probe.js';
25
+ import { waitForModelWork } from './model-call-runtime.js';
26
+
27
+ function invalid(message) { throw new CommandError('IRIS_COMMAND_INPUT_INVALID', message); }
28
+
29
+ export function normalizeVisionInput(command, input) {
30
+ const fields = {
31
+ look: ['image_path', 'artifact_id', 'question'], locate: ['image_path', 'artifact_id', 'target'],
32
+ ocr: ['image_path', 'artifact_id', 'chunk_height', 'overlap', 'max_dimension', 'max_invocations'],
33
+ summarize: ['video_path', 'frame_artifact_ids', 'question', 'max_frames', 'target_width', 'transcribe_text', 'transcribe', 'transcribe_model_ref']
34
+ }[command];
35
+ if (!fields) invalid('未知视觉命令');
36
+ if (!input || typeof input !== 'object' || Array.isArray(input)) invalid('--input 必须是 JSON 对象');
37
+ if (Object.keys(input).some(key => !fields.includes(key))) invalid('视觉输入含不支持的字段');
38
+ const fileField = command === 'summarize' ? 'video_path' : 'image_path';
39
+ const fileSource = command === 'summarize' ? normalizeSummarySource(input) === 'video'
40
+ : normalizeVisionImageSource(input) === 'image_path';
41
+ if (fileSource) {
42
+ if (typeof input[fileField] !== 'string' || !path.isAbsolute(input[fileField])) invalid(fileField + ' 必须是绝对路径');
43
+ try { if (!fs.statSync(input[fileField]).isFile()) throw new Error(); }
44
+ catch (_) { invalid('输入媒体文件不存在或不是普通文件'); }
45
+ }
46
+ for (const field of ['question', 'target', 'transcribe_text', 'transcribe_model_ref']) {
47
+ if (input[field] !== undefined && typeof input[field] !== 'string') invalid(field + ' 必须是字符串');
48
+ }
49
+ if (command === 'locate' && !input.target?.trim()) invalid('target 必须是非空字符串');
50
+ if (input.transcribe !== undefined && typeof input.transcribe !== 'boolean') invalid('transcribe 必须是布尔值');
51
+ if (input.transcribe && input.transcribe_text?.trim()) invalid('transcribe 与 transcribe_text 不能同时使用');
52
+ if (input.transcribe_model_ref !== undefined && !input.transcribe) invalid('transcribe_model_ref 需要 transcribe=true');
53
+ if (input.transcribe_model_ref !== undefined && !/^[^:]+::.+$/.test(input.transcribe_model_ref)) invalid('transcribe_model_ref 必须是 providerId::modelId');
54
+ if (command === 'ocr') normalizeOcrSettings({ chunkHeight: input.chunk_height, overlap: input.overlap,
55
+ maxDimension: input.max_dimension, maxInvocations: input.max_invocations });
56
+ if (command === 'summarize') {
57
+ try { normalizeFramesOptions({ maxFrames: input.max_frames, targetWidth: input.target_width }); }
58
+ catch (_) { invalid('max_frames 或 target_width 无效'); }
59
+ }
60
+ return input;
61
+ }
62
+
63
+ function selectedModel(completion, routes) {
64
+ const route = routes.find(value => value.provider.id === completion.identity?.providerId && value.model === completion.identity?.modelId);
65
+ return route ? { modelRef: route.modelRef, selectionReason: route.selectionReason } : {};
66
+ }
67
+
68
+ function completionResult(completion, routes) {
69
+ const { sheet: _sheet, ...result } = completion;
70
+ return { ...result, ...selectedModel(completion, routes) };
71
+ }
72
+
73
+ async function transcribeVideo({ catalog, input, runtime, operation, onRateLimit }) {
74
+ const routes = transcribeCandidatesFromCatalog(catalog, input.transcribe_model_ref);
75
+ const candidates = routes.map(route => ({ adapter: createConfiguredProviderAdapter(route.provider, { onRateLimit }),
76
+ model: route.modelRef, selectionReason: route.selectionReason, providerBinding: providerTaskBinding(route.provider) }));
77
+ const audio = await waitForModelWork(() => extractAudioTrack({ inputPath: input.video_path, signal: operation.signal }), operation.signal);
78
+ const abortRuntime = () => { void runtime.dispose().catch(() => {}); };
79
+ operation.signal.addEventListener('abort', abortRuntime, { once: true });
80
+ let task;
81
+ try {
82
+ const prepared = await waitForModelWork(() => prepareProviderInput(candidates[0].adapter, {
83
+ model: routes[0].model, filePath: audio.filePath, signal: operation.signal
84
+ }), operation.signal);
85
+ const runner = createProviderTaskRunner(runtime);
86
+ const submitted = await waitForModelWork(() => runner.submit({ capability: 'transcribe', candidates,
87
+ providerInput: { audioUrl: prepared.url } }), operation.signal, 'invoke', 'unknown');
88
+ task = submitted.task;
89
+ if (task.acceptance === 'accepted' && task.outcome === 'none') {
90
+ const adapter = createConfiguredProviderAdapter(providerForTaskFromCatalog(catalog, task), { onRateLimit,
91
+ modelId: routes.find(route => route.modelRef === task.modelRef)?.model });
92
+ while (task.outcome === 'none') {
93
+ task = await waitForModelWork(() => runner.observe(task.id, adapter), operation.signal, 'read', 'responded');
94
+ if (task.outcome === 'none') await waitForModelWork(() => delay(1000, undefined, { signal: operation.signal }), operation.signal);
95
+ }
96
+ }
97
+ const report = { taskId: task.id, modelRef: task.modelRef, artifactIds: task.artifactIds, status: 'failed' };
98
+ if (task.outcome !== 'succeeded' || task.deliveryState !== 'ready') return { report, text: '' };
99
+ const texts = await runtime.run('inspect', ({ dataRoot }) => task.artifactIds.map(id => {
100
+ const artifact = readCoreArtifactBytes(dataRoot, id);
101
+ return artifact.artifact.mediaType.startsWith('text/') ? artifact.bytes.toString('utf8') : '';
102
+ }));
103
+ return { report: { ...report, status: 'complete' }, text: texts.filter(Boolean).join('\n') };
104
+ } catch (error) {
105
+ const record = modelErrorRecord(error);
106
+ if (['IRIS_MODEL_ABORTED', 'IRIS_MODEL_TIMEOUT'].includes(record.code)) throw error;
107
+ return { report: { status: 'failed', code: record.code, ...(task ? { taskId: task.id, modelRef: task.modelRef, artifactIds: task.artifactIds } : {}) }, text: '' };
108
+ } finally {
109
+ operation.signal.removeEventListener('abort', abortRuntime);
110
+ fs.rmSync(audio.outDir, { recursive: true, force: true });
111
+ }
112
+ }
113
+
114
+ export async function executeVisionCommand({ command, input, catalog, modelRef = '', runtime, signal, timeoutMs = VISION_BUDGET.timeoutMs, onRateLimit }) {
115
+ normalizeVisionInput(command, input);
116
+ if (modelRef && !/^[^:]+::.+$/.test(modelRef)) invalid('--model-ref 必须是 providerId::modelId');
117
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > VISION_BUDGET.timeoutMs) invalid('--timeout-ms 必须是 1–120000 的整数');
118
+ if (input.transcribe && !runtime) invalid('主动音轨转写需要 --data-root');
119
+ if ((input.artifact_id || input.frame_artifact_ids) && !runtime) invalid('读取 Core Artifact 需要 --data-root');
120
+ const routes = visionCandidatesFromCatalog(catalog, modelRef);
121
+ const inputs = new Map();
122
+ const prepareImageRequest = command === 'locate' ? createLocateImageRequest(input.target, inputs)
123
+ : command === 'ocr' ? createVisionInputObserver(inputs) : undefined;
124
+ const ports = routes.map(route => createConfiguredVisionModelPort(route.provider, route.model, { onRateLimit,
125
+ prepareImages: ['look', 'ocr', 'locate'].includes(command), orientImages: command === 'locate', prepareImageRequest }));
126
+ const budget = { ...VISION_BUDGET, timeoutMs };
127
+ const operation = createModelOperation({ signal, budget: { timeoutMs, maxInvocations: command === 'ocr' ? input.max_invocations ?? 64 : ports.length } });
128
+ const base = { schemaVersion: 1, command: 'vision.' + command, status: 'complete',
129
+ ...(input.artifact_id ? { artifactId: input.artifact_id } : {}) };
130
+ try {
131
+ if (command === 'summarize') {
132
+ let meta, frames;
133
+ if (input.frame_artifact_ids) {
134
+ ({ meta, frames } = await readSummaryFrames(runtime, input.frame_artifact_ids, operation.signal));
135
+ } else {
136
+ if (!ffmpegAvailable()) throw new CommandError('IRIS_MEDIA_TOOL_UNAVAILABLE', '视频摘要需要 PATH 中的 ffmpeg 和 ffprobe');
137
+ meta = await waitForModelWork(() => probeVideo(input.video_path), operation.signal);
138
+ frames = await waitForModelWork(() => extractFrames({ inputPath: input.video_path,
139
+ maxFrames: input.max_frames, targetWidth: input.target_width, signal: operation.signal }), operation.signal);
140
+ }
141
+ let transcript = input.transcribe_text || '', transcription = { status: transcript.trim() ? 'provided' : 'disabled' };
142
+ if (input.transcribe) {
143
+ const asr = meta.hasAudio ? await transcribeVideo({ catalog, input, runtime, operation, onRateLimit }) : { report: { status: 'no_audio' }, text: '' };
144
+ transcript = asr.text; transcription = asr.report;
145
+ }
146
+ const completion = await summarizeMedia({ ports, frames, question: input.question, transcript, operation, budget });
147
+ const sheet = completion.sheet;
148
+ const note = transcription.status === 'failed' ? '音轨转写失败,本次摘要只分析画面。' : '';
149
+ if (note) transcription.note = note;
150
+ return { result: { ...base, ...completionResult(completion, routes), meta,
151
+ frames: summaryFrameRecords(frames), transcription,
152
+ contactSheet: { mediaType: 'image/png', width: sheet.width, height: sheet.height,
153
+ sha256: createHash('sha256').update(sheet.buffer).digest('hex') } }, text: completion.text + (note ? '\n\n[iris] ' + note : ''), sheetBuffer: sheet.buffer, exitCode: 0 };
154
+ }
155
+ let image;
156
+ if (input.artifact_id) image = await readVisionArtifact(runtime, input.artifact_id, operation.signal);
157
+ else {
158
+ const mediaType = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.gif': 'image/gif' }[path.extname(input.image_path).toLowerCase()];
159
+ if (!mediaType) invalid('图片只支持 PNG、JPEG、WebP、GIF');
160
+ image = await readVisionImageFile(input.image_path, mediaType, operation.signal);
161
+ }
162
+ if (command === 'ocr') {
163
+ const ocr = await longOcr({ image, ports, chunkHeight: input.chunk_height, overlap: input.overlap,
164
+ maxDimension: input.max_dimension, maxInvocations: input.max_invocations, operation, budget, inputs });
165
+ return { result: { ...base, ...ocr, chunks: ocr.chunks.map(chunk => ({ ...chunk, ...selectedModel(chunk, routes) })) },
166
+ text: formatOcrResult(ocr), exitCode: ocr.status === 'complete' ? 0 : 1 };
167
+ }
168
+ let completion;
169
+ if (command === 'locate') {
170
+ const { width, height } = await waitForModelWork(() => sharp(Buffer.from(image.bytes)).metadata(), operation.signal);
171
+ completion = await locateObject(ports, { target: input.target, image, width, height }, { operation, budget });
172
+ return { result: { ...base, ...completionResult(completion, routes), width, height,
173
+ input: inputs.get(completion.identity.backendId) }, text: JSON.stringify(completion.bbox), exitCode: 0 };
174
+ }
175
+ completion = await completeVision(ports, { prompt: input.question?.trim() || '请用中文描述这张图片的内容。', image }, { operation, budget });
176
+ return { result: { ...base, ...completionResult(completion, routes) }, text: completion.text, exitCode: 0 };
177
+ } catch (error) {
178
+ if (error instanceof ModelPortError || error instanceof CommandError || error instanceof CoreArtifactError || error.name === 'ProviderCatalogError') throw error;
179
+ throw new ModelPortError('IRIS_MODEL_INPUT_INVALID', { stage: 'prepare', invocation: 'not_invoked' });
180
+ } finally { operation.dispose(); }
181
+ }
@@ -11,6 +11,7 @@ export const HOST_CLIENT_PROTOCOL_VERSION = 0;
11
11
 
12
12
  export const EXPECTED_IRIS_TOOLS = Object.freeze([
13
13
  'iris_draw_image',
14
+ 'iris_edit_image',
14
15
  'iris_generate_video',
15
16
  'iris_speak_text',
16
17
  'iris_transcribe_audio',
@@ -0,0 +1,187 @@
1
+ 'use strict';
2
+ /** OpenAI-compatible Chat Completions SSE;凭据/端点只留在闭包内。 */
3
+ import { ModelPortError, normalizeModelDescriptor, normalizeModelCall, normalizeModelCompletion,
4
+ createModelTextCollector } from './model-port-contract.js';
5
+ import { modelCallScope, modelAbortError, waitForModelWork } from './model-call-runtime.js';
6
+ import { createHash } from 'node:crypto';
7
+ import { isFreeQuotaExhausted, retryAfterDelay } from './provider-health.js';
8
+ import { prepareVisionImage } from './vision-image-input.js';
9
+
10
+ const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
11
+ const FRAME_LIMIT = 1024 * 1024;
12
+
13
+ function usageSnapshot(value) {
14
+ if (!object(value)) return undefined;
15
+ const usage = {};
16
+ for (const [source, target] of [['prompt_tokens', 'inputTokens'], ['completion_tokens', 'outputTokens'], ['total_tokens', 'totalTokens']]) {
17
+ if (Number.isSafeInteger(value[source]) && value[source] >= 0) usage[target] = value[source];
18
+ }
19
+ const reasoning = value.completion_tokens_details?.reasoning_tokens;
20
+ if (Number.isSafeInteger(reasoning) && reasoning >= 0) usage.reasoningTokens = reasoning;
21
+ return Object.keys(usage).length ? usage : undefined;
22
+ }
23
+
24
+ export function createHttpVisionModelPort({ providerId, modelId, baseUrl, apiKey = '', imageInput, orientImages = false,
25
+ prepareImageRequest, fetch: fetchImpl = globalThis.fetch }) {
26
+ const descriptor = normalizeModelDescriptor({ contractVersion: 0, kind: 'vision',
27
+ identity: { origin: 'provider', backendId: 'iris-provider:' + createHash('sha256').update(JSON.stringify([providerId, modelId])).digest('hex'), providerId, modelId },
28
+ availability: 'available', features: { system: 'supported', temperature: 'supported', maxOutputTokens: 'supported', reasoning: 'unknown' } });
29
+ let endpoint;
30
+ try {
31
+ const url = new URL(String(baseUrl).replace(/\/+$/, '') + '/chat/completions');
32
+ if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password || url.hash || url.search) throw new Error();
33
+ endpoint = url.href;
34
+ } catch (_) { throw new ModelPortError('IRIS_MODEL_INPUT_INVALID'); }
35
+ return Object.freeze({
36
+ describe: () => normalizeModelDescriptor(descriptor),
37
+ async complete(request, options) {
38
+ const input = normalizeModelCall(descriptor, request, options);
39
+ if (input.options.signal?.aborted) throw modelAbortError(input.options.signal);
40
+ const scope = modelCallScope(input.options.signal, input.options.budget.timeoutMs);
41
+ const { signal } = scope;
42
+ const collector = createModelTextCollector({ maxOutputChars: input.options.budget.maxOutputChars, signal,
43
+ backendId: descriptor.identity.backendId });
44
+ let response, iterator, ended = false, invoked = false, responded = false, finished = false, done = false, usage;
45
+ const protocol = () => new ModelPortError('IRIS_MODEL_PROTOCOL_INVALID', { stage: 'read', invocation: responded ? 'responded' : 'unknown' });
46
+ const consume = data => {
47
+ if (!data) return;
48
+ if (done) throw protocol();
49
+ if (data === '[DONE]') {
50
+ if (!finished) throw new ModelPortError('IRIS_MODEL_INCOMPLETE', { stage: 'read', invocation: responded ? 'responded' : 'unknown' });
51
+ done = true; return;
52
+ }
53
+ let event;
54
+ try { event = JSON.parse(data); } catch (_) { throw protocol(); }
55
+ if (!object(event)) throw protocol();
56
+ if (event.error) throw new ModelPortError('IRIS_MODEL_REQUEST_FAILED', { stage: 'read', invocation: responded ? 'responded' : 'unknown' });
57
+ if (!Array.isArray(event.choices)) throw protocol();
58
+ if (event.usage !== undefined && event.usage !== null) {
59
+ if (!object(event.usage)) throw protocol();
60
+ usage = usageSnapshot(event.usage);
61
+ }
62
+ if (event.choices.length === 0 && object(event.usage)) return;
63
+ if (event.choices.length !== 1 || finished) throw protocol();
64
+ const choice = event.choices[0];
65
+ if (!object(choice) || choice.index !== undefined && choice.index !== 0 || !object(choice.delta)) throw protocol();
66
+ const delta = choice.delta;
67
+ if (delta.tool_calls !== undefined || delta.function_call !== undefined) collector.finish('tool-calls');
68
+ if (delta.refusal) collector.finish('content-blocked');
69
+ if (delta.content !== undefined && delta.content !== null) {
70
+ if (typeof delta.content !== 'string') throw protocol();
71
+ if (delta.content.length) responded = true;
72
+ collector.append(delta.content);
73
+ }
74
+ if (choice.finish_reason !== undefined && choice.finish_reason !== null) {
75
+ const reason = { stop: 'stop', length: 'length', tool_calls: 'tool-calls', function_call: 'tool-calls', content_filter: 'content-blocked' }[choice.finish_reason];
76
+ responded = true; collector.finish(reason || 'unknown'); finished = true;
77
+ }
78
+ };
79
+ try {
80
+ const image = imageInput ? await prepareVisionImage(input.request.image, imageInput, { signal, orient: orientImages }) : input.request.image;
81
+ const prepared = await waitForModelWork(() => prepareImageRequest?.({ request: input.request, image, signal, identity: descriptor.identity }), signal);
82
+ const prompt = prepared?.prompt ?? input.request.prompt;
83
+ normalizeModelCall(descriptor, { ...input.request, image, prompt }, input.options);
84
+ const body = { model: modelId, stream: true, messages: [
85
+ ...(input.request.system === undefined ? [] : [{ role: 'system', content: input.request.system }]),
86
+ { role: 'user', content: [{ type: 'text', text: prompt }, { type: 'image_url', image_url: {
87
+ url: `data:${image.mediaType};base64,${Buffer.from(image.bytes).toString('base64')}`
88
+ } }] }
89
+ ] };
90
+ if (input.request.generation?.temperature !== undefined) body.temperature = input.request.generation.temperature;
91
+ if (input.request.generation?.maxOutputTokens !== undefined) body.max_tokens = input.request.generation.maxOutputTokens;
92
+ response = await waitForModelWork(() => {
93
+ invoked = true;
94
+ const pending = Promise.resolve(fetchImpl(endpoint, { method: 'POST', redirect: 'error', signal,
95
+ headers: { 'Content-Type': 'application/json', ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}) }, body: JSON.stringify(body) }));
96
+ // fetch 不合作时,取消之后才到达的 body 也必须被释放。
97
+ return pending.then(value => {
98
+ if (signal.aborted) {
99
+ Promise.resolve(value?.body?.cancel?.()).catch(() => {});
100
+ throw modelAbortError(signal, 'invoke', 'unknown');
101
+ }
102
+ return value;
103
+ });
104
+ }, signal, 'invoke', 'unknown');
105
+ if (!response.ok) {
106
+ const body = [400, 403, 413, 429].includes(response.status) ? await waitForModelWork(() => response.json().catch(() => null), signal, 'read', 'rejected') : null;
107
+ // 只提取可验证的数值事实,不把供应商原文或凭据带到 UI/CLI。
108
+ const limit = [400, 413].includes(response.status)
109
+ ? /\b(\d+) bytes exceeds the (\d+)-byte limit\b/i.exec(String(body?.error?.message || body?.message || '')) : null;
110
+ const maxBytes = limit && Number(limit[1]) === image.bytes.byteLength && Number.isSafeInteger(Number(limit[2])) && Number(limit[2]) > 0
111
+ ? Number(limit[2]) : undefined;
112
+ if (maxBytes || response.status === 413) {
113
+ throw new ModelPortError('IRIS_MODEL_IMAGE_TOO_LARGE', { stage: 'invoke', invocation: 'rejected', status: response.status,
114
+ imageBytes: image.bytes.byteLength, ...(maxBytes ? { imageMaxBytes: maxBytes } : {}) });
115
+ }
116
+ const quota = isFreeQuotaExhausted({ status: response.status, ...(body?.error || body) });
117
+ const code = quota ? 'IRIS_MODEL_RATE_LIMITED' : [401, 403].includes(response.status) ? 'IRIS_MODEL_AUTH_FAILED'
118
+ : response.status === 429 ? 'IRIS_MODEL_RATE_LIMITED' : response.status === 400 ? 'IRIS_MODEL_INPUT_INVALID' : 'IRIS_MODEL_REQUEST_FAILED';
119
+ const error = new ModelPortError(code, { stage: 'invoke', invocation: code === 'IRIS_MODEL_REQUEST_FAILED' ? 'unknown' : 'rejected', status: response.status });
120
+ error.providerCode = (body?.error || body)?.code;
121
+ if (response.status === 429) error.retryAfterMs = retryAfterDelay(response.headers.get('retry-after'));
122
+ if (quota) error.quotaExhausted = true;
123
+ throw error;
124
+ }
125
+ if (!response.body || typeof response.body[Symbol.asyncIterator] !== 'function'
126
+ || !/^text\/event-stream(?:\s*;|$)/i.test(response.headers.get('content-type') || '')) throw protocol();
127
+ iterator = response.body[Symbol.asyncIterator]();
128
+ const decoder = new TextDecoder('utf-8', { fatal: true });
129
+ let buffer = '', data = [], frameLength = 0;
130
+ const line = value => {
131
+ if (!value) { consume(data.join('\n')); data = []; frameLength = 0; return; }
132
+ frameLength += value.length;
133
+ if (frameLength > FRAME_LIMIT) throw protocol();
134
+ if (value.startsWith(':')) return;
135
+ const split = value.indexOf(':');
136
+ const field = split < 0 ? value : value.slice(0, split);
137
+ let fieldValue = split < 0 ? '' : value.slice(split + 1);
138
+ if (fieldValue.startsWith(' ')) fieldValue = fieldValue.slice(1);
139
+ if (field === 'data') data.push(fieldValue);
140
+ else if (field === 'event' && fieldValue && fieldValue !== 'message') throw protocol();
141
+ };
142
+ const drain = final => {
143
+ while (true) {
144
+ const index = buffer.search(/[\r\n]/);
145
+ if (index < 0 || !final && index === buffer.length - 1 && buffer[index] === '\r') break;
146
+ const width = buffer[index] === '\r' && buffer[index + 1] === '\n' ? 2 : 1;
147
+ line(buffer.slice(0, index)); buffer = buffer.slice(index + width);
148
+ }
149
+ if (buffer.length > FRAME_LIMIT) throw protocol();
150
+ };
151
+ while (true) {
152
+ const next = await waitForModelWork(() => iterator.next(), signal, 'read', responded ? 'responded' : 'unknown');
153
+ if (next.done) { ended = true; break; }
154
+ if (!(next.value instanceof Uint8Array)) throw protocol();
155
+ // 分段解码限制未换行的帧内存,不依赖底层 chunk 大小。
156
+ for (let offset = 0; offset < next.value.length; offset += 16384) {
157
+ try { buffer += decoder.decode(next.value.subarray(offset, offset + 16384), { stream: true }); }
158
+ catch (_) { throw protocol(); }
159
+ drain(false);
160
+ }
161
+ }
162
+ try { buffer += decoder.decode(); } catch (_) { throw protocol(); }
163
+ drain(true);
164
+ // SSE 未以空行提交的尾帧不算有效终态。
165
+ if (buffer.trim() || data.length) throw new ModelPortError('IRIS_MODEL_INCOMPLETE', { stage: 'read', invocation: responded ? 'responded' : 'unknown' });
166
+ const collected = collector.complete();
167
+ const text = prepared?.mapText ? prepared.mapText(collected) : collected;
168
+ return normalizeModelCompletion(descriptor, { contractVersion: 0, text, finishReason: 'stop', identity: descriptor.identity,
169
+ ...(usage ? { usage } : {}) }, input.options.budget);
170
+ } catch (error) {
171
+ if (signal.aborted) throw modelAbortError(signal, invoked ? 'read' : 'prepare', responded ? 'responded' : invoked ? 'unknown' : 'not_invoked');
172
+ // 保留明确 HTTP 拒绝事实;其余错误让 collector 保留已收到部分正文的事实。
173
+ if (error instanceof ModelPortError && (error.invocation === 'rejected' || !invoked)) throw error;
174
+ collector.fail(error);
175
+ } finally {
176
+ if (!ended) {
177
+ scope.abort(new ModelPortError('IRIS_MODEL_ABORTED'));
178
+ const closing = iterator?.return ? () => iterator.return() : () => response?.body?.cancel?.();
179
+ // 主动启动清理,并处理不合作的 return 的晚到失败。
180
+ const pending = Promise.resolve().then(closing);
181
+ await Promise.race([pending, Promise.resolve()]).catch(() => {});
182
+ }
183
+ scope.dispose();
184
+ }
185
+ }
186
+ });
187
+ }
@@ -0,0 +1,34 @@
1
+ 'use strict';
2
+ /** 聊天改图只接受当前 Core 中的静态图片;输入原字节不进入 Task 记录。 */
3
+ import sharp from 'sharp';
4
+ import { inspectCoreArtifact, readCoreArtifactBytes } from './core-artifacts.js';
5
+ import { waitForModelWork } from './model-call-runtime.js';
6
+
7
+ function invalid(message) {
8
+ return Object.assign(new Error(message), { code: 'IRIS_IMAGE_EDIT_INPUT_INVALID' });
9
+ }
10
+
11
+ export function imageEditCandidates(candidates) {
12
+ if (candidates.some(candidate => candidate.selectionReason === 'explicit'
13
+ && candidate.adapter.protocol !== 'openai-chat-images')) {
14
+ throw invalid('指定模型的图片协议不支持聊天改图,请选择 openai-chat-images 模型');
15
+ }
16
+ const selected = candidates.filter(candidate => candidate.adapter.protocol === 'openai-chat-images');
17
+ if (!selected.length) throw invalid('未配置可用的 openai-chat-images 生图模型,无法编辑原图');
18
+ return selected;
19
+ }
20
+
21
+ export async function readImageEditArtifact(dataRoot, artifactId, signal) {
22
+ if (typeof artifactId !== 'string' || !/^artifact_[a-f0-9]{24}$/.test(artifactId)) {
23
+ throw invalid('source_artifact_id 必须是有效的 Core Artifact ID');
24
+ }
25
+ const artifact = inspectCoreArtifact(dataRoot, artifactId);
26
+ if (!['image/png', 'image/jpeg', 'image/webp'].includes(artifact.mediaType) || artifact.size > 20 * 1024 * 1024) {
27
+ throw invalid('改图来源必须是最多 20 MiB 的静态 PNG、JPEG 或 WebP Artifact');
28
+ }
29
+ const { bytes } = readCoreArtifactBytes(dataRoot, artifactId);
30
+ const metadata = await waitForModelWork(() => sharp(bytes).metadata(), signal);
31
+ if ({ png: 'image/png', jpeg: 'image/jpeg', webp: 'image/webp' }[metadata.format] !== artifact.mediaType
32
+ || metadata.pages > 1) throw invalid('改图来源的真实格式必须与 MIME 一致,且不能是动画');
33
+ return { bytes: new Uint8Array(bytes), mediaType: artifact.mediaType };
34
+ }