@mokuyoaxis/dsh-iris 0.2.5 → 0.2.7

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 (83) hide show
  1. package/.dsh/skills/iris-use/SKILL.md +2 -2
  2. package/.dsh/skills/iris-use/references/media-contract.md +2 -2
  3. package/CHANGELOG.md +49 -1
  4. package/README.en.md +39 -22
  5. package/README.md +40 -21
  6. package/bin/dsh-iris.js +166 -35
  7. package/docs/AGENT_QUICKSTART.md +7 -7
  8. package/docs/ARCHITECTURE.md +9 -3
  9. package/docs/CLI_MANAGEMENT.md +5 -1
  10. package/docs/CORE_RUNTIME_CONTRACT.md +36 -5
  11. package/docs/CORE_SKILLS.md +62 -0
  12. package/docs/DSH_COMPATIBILITY.md +8 -4
  13. package/docs/DSH_CORE_MIGRATION.md +4 -2
  14. package/docs/DSH_PROVIDER_CONNECTIONS.md +34 -0
  15. package/docs/DSH_RUNTIME.md +36 -0
  16. package/docs/FUNCTION_MODEL_ASSIGNMENTS.md +2 -0
  17. package/docs/FUNCTION_MODEL_POOLS.md +84 -0
  18. package/docs/HEADLESS_CLI.md +16 -8
  19. package/docs/HOST_ADAPTER_CONTRACT.md +7 -2
  20. package/docs/HOST_DOCTOR.md +14 -2
  21. package/docs/IMAGE_FLOW.md +71 -0
  22. package/docs/LOCAL_API.md +60 -0
  23. package/docs/LOCAL_API_CONTRACT.md +115 -0
  24. package/docs/LOCAL_CREATION.md +50 -0
  25. package/docs/MUSIC.md +2 -2
  26. package/docs/PROVIDER_HEALTH.md +26 -2
  27. package/docs/ROADMAP.md +18 -4
  28. package/docs/SECURITY.md +20 -2
  29. package/docs/SYNC_DELIVERY_RECOVERY.md +2 -0
  30. package/docs/VISION_CLI.md +2 -2
  31. package/docs/WEB_PROTOTYPE.md +47 -0
  32. package/docs/releases/0.2.6.md +50 -0
  33. package/docs/releases/0.2.7.md +62 -0
  34. package/lib/actions.js +208 -90
  35. package/lib/adapters.js +36 -9
  36. package/lib/api.js +40 -39
  37. package/lib/application-runtime.js +186 -0
  38. package/lib/client.js +805 -182
  39. package/lib/composite-vision-routing.js +6 -6
  40. package/lib/config.js +140 -29
  41. package/lib/core-artifact-store.js +5 -0
  42. package/lib/core-maintenance.js +25 -2
  43. package/lib/core-runtime.js +19 -0
  44. package/lib/core-tasks.js +62 -8
  45. package/lib/creation-request-store.js +159 -0
  46. package/lib/doctor.js +20 -2
  47. package/lib/dsh-core-adapter.js +669 -564
  48. package/lib/dsh-host-adapter.js +14 -1
  49. package/lib/dsh-provider-config.js +35 -0
  50. package/lib/dsh-provider-source.js +102 -0
  51. package/lib/flow-store.js +195 -0
  52. package/lib/headless-vision.js +5 -5
  53. package/lib/host-contract.js +1 -0
  54. package/lib/http-vision-model-adapter.js +5 -2
  55. package/lib/image-flow.js +188 -0
  56. package/lib/index.js +103 -78
  57. package/lib/local-api-projection.js +27 -0
  58. package/lib/local-api.js +381 -0
  59. package/lib/local-creation.js +85 -0
  60. package/lib/model-directory-service.js +53 -0
  61. package/lib/model-directory.js +103 -0
  62. package/lib/models.js +71 -6
  63. package/lib/ocr-model-routing.js +2 -2
  64. package/lib/private-storage.js +12 -0
  65. package/lib/provider-adapters.js +42 -9
  66. package/lib/provider-catalog.js +14 -9
  67. package/lib/provider-config-scope.js +16 -0
  68. package/lib/provider-config-service.js +82 -13
  69. package/lib/provider-health.js +33 -0
  70. package/lib/provider-protocol.js +30 -0
  71. package/lib/provider-task-runner.js +22 -2
  72. package/lib/render-address.js +35 -0
  73. package/lib/render.js +52 -13
  74. package/lib/skill-installation.js +199 -0
  75. package/lib/vision-model-routing.js +25 -12
  76. package/lib/workbench-artifacts.js +1 -2
  77. package/lib/workbench-management.js +8 -9
  78. package/package.json +4 -3
  79. package/skills/iris-core/SKILL.md +40 -0
  80. package/skills/iris-core/agents/openai.yaml +4 -0
  81. package/skills/iris-core/references/cli.md +95 -0
  82. package/skills/iris-core/references/tasks.md +58 -0
  83. package/user_guide.md +32 -20
@@ -0,0 +1,381 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import http from 'node:http';
4
+ import path from 'node:path';
5
+ import { createApplicationRuntime } from './application-runtime.js';
6
+ import { normalizeCoreOptions } from './core-contract.js';
7
+ import { createCoreRuntime } from './core-runtime.js';
8
+ import { inspectFlowRoot } from './flow-store.js';
9
+ import { inspectCoreArtifactMetadata, readCoreArtifactBytes } from './core-artifacts.js';
10
+ import { imageCandidatesFromCatalog, loadProviderCatalog, providerTaskBinding } from './provider-catalog.js';
11
+ import { supportsImageEdit } from './provider-protocol.js';
12
+ import { projectLocalApiFlow, projectLocalApiRunSummary } from './local-api-projection.js';
13
+ import { inspectCreationRoot } from './creation-request-store.js';
14
+ import { MAX_IMPORT_IMAGE_BYTES } from './image-import.js';
15
+
16
+ export const MAX_LOCAL_API_IMAGE_BYTES = 64 * 1024 * 1024;
17
+ const RUN = /^run_[a-f0-9]{24}$/;
18
+ const ARTIFACT = /^artifact_[a-f0-9]{24}$/;
19
+ const KEY = /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/;
20
+ const IMAGE_TYPES = new Map([['image/png', 'png'], ['image/jpeg', 'jpg'], ['image/webp', 'webp']]);
21
+ const digest = value => crypto.createHash('sha256').update(value).digest();
22
+ const CSP = "default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'";
23
+
24
+ export class LocalApiError extends Error {
25
+ constructor(code, message, status = 400) { super(message); this.name = 'LocalApiError'; this.code = code; this.status = status; }
26
+ }
27
+ function fail(code, message, status) { throw new LocalApiError(code, message, status); }
28
+
29
+ function httpError(error) {
30
+ if (error instanceof LocalApiError) return error;
31
+ if (error?.code === 'IRIS_CORE_DATA_ROOT_BUSY') return new LocalApiError('IRIS_API_ROOT_BUSY', '该数据根已有活动写者,请稍后显式重试', 409);
32
+ if (error?.code === 'IRIS_CREATION_RESULT_UNAVAILABLE') return new LocalApiError('IRIS_API_RESULT_UNAVAILABLE', '原创建结果已不可用;同 Key 不会重新创建或复活作品', 409);
33
+ if (error?.code === 'IRIS_CREATION_KEY_CONFLICT') return new LocalApiError('IRIS_API_KEY_CONFLICT', '该 Key 已绑定其他输入', 409);
34
+ if (['IRIS_CREATION_KEY_INVALID', 'IRIS_CREATION_INPUT_INVALID', 'IRIS_GENERATION_INPUT_INVALID',
35
+ 'IRIS_IMAGE_IMPORT_INVALID', 'IRIS_IMAGE_EDIT_INPUT_INVALID', 'IRIS_FLOW_INPUT_INVALID'].includes(error?.code)) {
36
+ return new LocalApiError('IRIS_API_INPUT_INVALID', '创作输入或静态图片无效,请检查来源、模型和参数', 400);
37
+ }
38
+ if (error?.code === 'IRIS_CREATION_MEDIA_MISMATCH') return new LocalApiError('IRIS_API_MEDIA_UNSUPPORTED', '图片真实格式与 Content-Type 不一致', 415);
39
+ if (/^IRIS_CREATION_/.test(error?.code || '')) return new LocalApiError('IRIS_API_RECORD_CONFLICT', '创建请求证据不一致,请在本机检查原记录', 409);
40
+ if (['IRIS_FLOW_NOT_FOUND', 'IRIS_ARTIFACT_NOT_FOUND'].includes(error?.code)) {
41
+ return new LocalApiError('IRIS_API_NOT_FOUND', '该实例中没有此流程或图片', 404);
42
+ }
43
+ if (/^IRIS_PROVIDER_/.test(error?.code || '')) {
44
+ return new LocalApiError('IRIS_API_CONFIG_UNAVAILABLE', '模型配置不可用,请在本机检查显式配置', 500);
45
+ }
46
+ if (error?.code === 'IRIS_APP_CLOSED' || error?.name === 'AbortError' || /^IRIS_CORE_DATA_ROOT_/.test(error?.code || '')) {
47
+ return new LocalApiError('IRIS_API_UNAVAILABLE', '服务正在关闭或数据根不可用', 503);
48
+ }
49
+ if (/^IRIS_(?:FLOW|ARTIFACT|TASK)_/.test(error?.code || '')) {
50
+ return new LocalApiError('IRIS_API_RECORD_CONFLICT', '流程或作品证据不一致,请在本机核对原记录', 409);
51
+ }
52
+ return new LocalApiError('IRIS_API_INTERNAL', '读取失败,请使用关联 ID 核对本机诊断', 500);
53
+ }
54
+
55
+ function reportUnexpected(error, requestId) {
56
+ // Keep call sites for diagnosis, without the potentially private exception message or directory names.
57
+ const frames = String(error?.stack || '').split('\n').slice(1, 7)
58
+ .map(line => line.replace(/(?:file:\/\/)?(?:\/[^\s()]+)+(?=:\d+:\d+)/g, value => path.basename(value)));
59
+ console.error(JSON.stringify({ event: 'iris_local_api_error', requestId, frames }));
60
+ }
61
+
62
+ function headerCount(request, name) {
63
+ let count = 0;
64
+ for (let index = 0; index < request.rawHeaders.length; index += 2) {
65
+ if (request.rawHeaders[index].toLowerCase() === name) count++;
66
+ }
67
+ return count;
68
+ }
69
+
70
+ function query(url, allowed = []) {
71
+ for (const name of url.searchParams.keys()) {
72
+ if (!allowed.includes(name) || url.searchParams.getAll(name).length !== 1) {
73
+ fail('IRIS_API_INPUT_INVALID', '查询参数无效或重复');
74
+ }
75
+ }
76
+ }
77
+
78
+ function send(request, response, status, bytes, headers = {}) {
79
+ if (response.destroyed) return Promise.resolve();
80
+ return new Promise(resolve => {
81
+ let cleanupRequest = () => {};
82
+ const finish = () => { cleanupRequest(); response.off('finish', finish); response.off('close', finish); resolve(); };
83
+ response.once('finish', finish); response.once('close', finish);
84
+ response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': bytes.length,
85
+ 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', 'Content-Security-Policy': CSP,
86
+ 'Referrer-Policy': 'no-referrer', ...headers });
87
+ if (status >= 400 && !request.complete && !request.destroyed) {
88
+ // Deliver the complete error before closing an upload with unread bytes. Closing immediately
89
+ // can reset the socket on Windows before the client receives the response. Discard the rest
90
+ // without buffering; the server's request/idle timeouts and dispose still bound the connection.
91
+ const end = () => { cleanupRequest(); response.end(); };
92
+ cleanupRequest = () => { request.off('end', end); request.off('close', end); };
93
+ request.once('end', end); request.once('close', end);
94
+ if (request.method === 'HEAD') response.flushHeaders();
95
+ else response.write(bytes);
96
+ request.resume();
97
+ } else response.end(request.method === 'HEAD' ? undefined : bytes);
98
+ });
99
+ }
100
+ const json = (request, response, value, status = 200) => send(request, response, status, Buffer.from(JSON.stringify(value)));
101
+
102
+ /** Internal Host, read-only by default. Construction performs no I/O and never binds a port. */
103
+ export function createLocalReadApi({ dataRoot, providerConfigPath, allowLocalWrites = false } = {}) {
104
+ if (typeof allowLocalWrites !== 'boolean') throw new TypeError('allowLocalWrites 必须是布尔值');
105
+ const suppliedRoot = normalizeCoreOptions({ dataRoot, mode: 'reader' }).dataRoot;
106
+ if (providerConfigPath !== undefined && (typeof providerConfigPath !== 'string' || !path.isAbsolute(providerConfigPath))) {
107
+ throw new LocalApiError('IRIS_API_CONFIG_INVALID', 'Provider 配置必须是显式绝对路径');
108
+ }
109
+ let state = 'new', origin = null, tokenDigest = null, application, server, physicalRoot;
110
+ let startPromise, disposal, closeRequested = false, activeReads = 0, activeUploads = 0, localWrites = 0;
111
+ const jobs = new Set(), instanceId = crypto.randomUUID();
112
+ let resolveClosed;
113
+ const closed = new Promise(resolve => { resolveClosed = resolve; });
114
+ const snapshot = () => Object.freeze({ contractVersion: 0, state, origin, readOnly: !allowLocalWrites, activeReads });
115
+
116
+ function authorize(request) {
117
+ const headers = request.headers;
118
+ if (headerCount(request, 'host') !== 1 || headers.host !== new URL(origin).host) {
119
+ fail('IRIS_API_HOST_DENIED', '请求地址不可信,请使用启动时给出的本机地址', 403);
120
+ }
121
+ if (headerCount(request, 'origin') > 1 || (headers.origin !== undefined && headers.origin !== origin)
122
+ || (headers['sec-fetch-site'] !== undefined && !['same-origin', 'none'].includes(headers['sec-fetch-site']))) {
123
+ fail('IRIS_API_ORIGIN_DENIED', '请求来源不可信', 403);
124
+ }
125
+ const match = headerCount(request, 'authorization') === 1 && /^Bearer +([A-Za-z0-9_-]{43})$/i.exec(headers.authorization || '');
126
+ if (!match || !crypto.timingSafeEqual(digest(match[1]), tokenDigest)) {
127
+ fail('IRIS_API_AUTH_REQUIRED', '需要有效的本机会话 Token', 401);
128
+ }
129
+ }
130
+
131
+ async function image(artifactId) {
132
+ // Use an operation-owned reader, with no writer lease or local export path.
133
+ const reader = createCoreRuntime({ dataRoot: physicalRoot, mode: 'reader' });
134
+ try {
135
+ reader.start();
136
+ return await reader.run('inspect', ({ dataRoot: physicalRoot }) => {
137
+ const metadata = inspectCoreArtifactMetadata(physicalRoot, artifactId);
138
+ if (!IMAGE_TYPES.has(metadata.mediaType)) fail('IRIS_API_MEDIA_UNSUPPORTED', '此接口只提供 PNG、JPEG 或 WebP 图片', 415);
139
+ if (metadata.size > MAX_LOCAL_API_IMAGE_BYTES) fail('IRIS_API_MEDIA_TOO_LARGE', '图片超过只读接口的 64 MiB 上限', 413);
140
+ const result = readCoreArtifactBytes(physicalRoot, artifactId);
141
+ if (result.bytes.length > MAX_LOCAL_API_IMAGE_BYTES || result.bytes.length !== result.artifact.size
142
+ || digest(result.bytes).toString('hex') !== result.artifact.digest.value) {
143
+ fail('IRIS_API_RECORD_CONFLICT', '图片字节与已保存记录不一致', 409);
144
+ }
145
+ return result;
146
+ });
147
+ } finally { await reader.dispose(); }
148
+ }
149
+
150
+ function models() {
151
+ if (!providerConfigPath) return { contractVersion: 0, configured: false, models: [], total: 0 };
152
+ const catalog = loadProviderCatalog(providerConfigPath);
153
+ let candidates;
154
+ try { candidates = imageCandidatesFromCatalog(catalog); }
155
+ catch (error) { if (error.code !== 'IRIS_PROVIDER_MODEL_UNAVAILABLE') throw error; candidates = []; }
156
+ const entries = candidates.map(route => ({ modelRef: route.modelRef, displayName: route.model,
157
+ supportsImageEdit: supportsImageEdit(route.provider.mediaProtocol, route.model) }));
158
+ return { contractVersion: 0, configured: true, models: entries, total: entries.length };
159
+ }
160
+
161
+ async function create(request, response, url) {
162
+ query(url);
163
+ if (!['/api/v0/images', '/api/v0/runs'].includes(url.pathname)) fail('IRIS_API_NOT_FOUND', '没有此本地创建接口', 404);
164
+ const isImage = url.pathname === '/api/v0/images';
165
+ if (headerCount(request, 'idempotency-key') !== 1 || !KEY.test(request.headers['idempotency-key'] || '')) {
166
+ fail('IRIS_API_INPUT_INVALID', 'Idempotency-Key 须为单个小写 UUIDv4');
167
+ }
168
+ if (headerCount(request, 'content-type') !== 1 || request.headers['content-encoding'] !== undefined) {
169
+ fail('IRIS_API_INPUT_INVALID', '正文需要单个 Content-Type,且不接受 Content-Encoding');
170
+ }
171
+ const mediaType = request.headers['content-type'];
172
+ if (isImage ? !IMAGE_TYPES.has(mediaType) : !/^application\/json(?:;\s*charset=utf-8)?$/i.test(mediaType)) {
173
+ fail('IRIS_API_MEDIA_UNSUPPORTED', '请提供静态 PNG/JPEG/WebP 原字节或 UTF-8 JSON', 415);
174
+ }
175
+ const limit = isImage ? MAX_IMPORT_IMAGE_BYTES : 64 * 1024;
176
+ if (request.headers['content-length'] !== undefined && Number(request.headers['content-length']) > limit) {
177
+ fail('IRIS_API_INPUT_TOO_LARGE', '正文超过允许的大小', 413);
178
+ }
179
+ if (isImage && activeUploads >= 1 || localWrites >= 9) fail('IRIS_API_BUSY', '本地创建繁忙,请使用原 Key 稍后重试', 429);
180
+ if (isImage) activeUploads++;
181
+ localWrites++;
182
+ const controller = new AbortController();
183
+ let prepared = false;
184
+ const disconnect = () => { if (!prepared) controller.abort(); };
185
+ response.once('close', disconnect);
186
+ try {
187
+ const chunks = [];
188
+ let size = 0;
189
+ for await (const chunk of request.iterator({ destroyOnReturn: false })) {
190
+ size += chunk.length;
191
+ if (size > limit) fail('IRIS_API_INPUT_TOO_LARGE', '正文超过允许的大小', 413);
192
+ chunks.push(chunk);
193
+ }
194
+ if (Object.keys(request.trailers).length || size === 0) fail('IRIS_API_INPUT_INVALID', '正文不能为空或包含尾部头字段');
195
+ if (state !== 'open') fail('IRIS_API_UNAVAILABLE', '服务正在关闭', 503);
196
+ const bytes = Buffer.concat(chunks, size);
197
+ let value;
198
+ if (!isImage) {
199
+ try { value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)); }
200
+ catch (_) { fail('IRIS_API_INPUT_INVALID', '需要有效的 UTF-8 JSON'); }
201
+ }
202
+ const result = await application.execute(isImage ? 'creation.image' : 'creation.flow', {
203
+ key: request.headers['idempotency-key'], ...(isImage ? { bytes, mediaType } : { value })
204
+ }, { signal: controller.signal, onPrepared: () => { prepared = true; } });
205
+ const body = { contractVersion: 0, idempotentReplay: result.idempotentReplay,
206
+ ...(isImage ? { artifact: { artifactId: result.artifact.id, mediaType: result.artifact.mediaType,
207
+ size: result.artifact.size, width: result.artifact.metadata.width, height: result.artifact.metadata.height } }
208
+ : { flow: projectLocalApiFlow(result.flow) }) };
209
+ return await json(request, response, body, result.idempotentReplay ? 200 : 201);
210
+ } catch (error) {
211
+ if (request.aborted && !prepared) fail('IRIS_API_REQUEST_INTERRUPTED', '上传连接已中断,未接受本次创建');
212
+ throw error;
213
+ } finally {
214
+ response.off('close', disconnect); localWrites--;
215
+ if (isImage) activeUploads--;
216
+ }
217
+ }
218
+
219
+ async function route(request, response) {
220
+ if (state !== 'open') fail('IRIS_API_UNAVAILABLE', '服务正在关闭', 503);
221
+ authorize(request);
222
+ if (!['GET', 'HEAD'].includes(request.method) && !(allowLocalWrites && request.method === 'POST')) {
223
+ fail('IRIS_API_READ_ONLY', allowLocalWrites ? '仅支持 GET/HEAD 与明确的本地创建 POST' : '当前接口仅支持只读 GET/HEAD', 405);
224
+ }
225
+ if (request.method !== 'POST' && (request.headers['transfer-encoding'] !== undefined || (request.headers['content-length'] !== undefined && request.headers['content-length'] !== '0'))) {
226
+ fail('IRIS_API_INPUT_INVALID', '只读请求不接受正文');
227
+ }
228
+ if (!request.url.startsWith('/') || request.url.startsWith('//')) fail('IRIS_API_INPUT_INVALID', '请求路径无效');
229
+ const url = new URL(request.url, origin);
230
+ if (url.pathname !== request.url.split('?')[0]) fail('IRIS_API_INPUT_INVALID', '请求路径无效');
231
+ if (request.method === 'POST') return create(request, response, url);
232
+ const matchRun = /^\/api\/v0\/runs\/([^/]+)(\/download)?$/.exec(url.pathname);
233
+ const matchImage = /^\/api\/v0\/artifacts\/([^/]+)\/content$/.exec(url.pathname);
234
+ query(url, url.pathname === '/api/v0/runs' ? ['limit', 'cursor'] : []);
235
+ if (matchRun && !RUN.test(matchRun[1]) || matchImage && !ARTIFACT.test(matchImage[1])) {
236
+ fail('IRIS_API_INPUT_INVALID', '流程或图片 ID 格式无效');
237
+ }
238
+ if (activeReads >= 2) fail('IRIS_API_BUSY', '读取繁忙,请稍后再试', 429);
239
+ activeReads++;
240
+ try {
241
+ if (url.pathname === '/api/v0/session') {
242
+ return await json(request, response, { contractVersion: 0, instanceId, readOnly: !allowLocalWrites,
243
+ operations: ['models.list', 'runs.list', 'runs.inspect', 'artifacts.content', 'runs.download',
244
+ ...(allowLocalWrites ? ['images.create', 'runs.create'] : [])],
245
+ limits: { runPageSize: 30, imageBytes: MAX_LOCAL_API_IMAGE_BYTES, activeReads: 2,
246
+ ...(allowLocalWrites ? { uploadBytes: MAX_IMPORT_IMAGE_BYTES, jsonBytes: 64 * 1024, activeUploads: 1, localWrites: 9 } : {}) } });
247
+ }
248
+ if (url.pathname === '/api/v0/models') return await json(request, response, models());
249
+ if (url.pathname === '/api/v0/runs') {
250
+ const limitText = url.searchParams.get('limit') ?? '20';
251
+ if (!/^(?:[1-9]|[12][0-9]|30)$/.test(limitText)) fail('IRIS_API_INPUT_INVALID', 'limit 必须是 1–30 的整数');
252
+ const cursor = url.searchParams.get('cursor');
253
+ if (cursor !== null && !RUN.test(cursor)) fail('IRIS_API_INPUT_INVALID', '游标无效');
254
+ const result = await application.execute('flow.list', {});
255
+ const ordered = [...result.runs].sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id));
256
+ const index = cursor === null ? 0 : ordered.findIndex(run => run.id === cursor) + 1;
257
+ if (cursor !== null && index === 0) fail('IRIS_API_INPUT_INVALID', '游标已失效,请重新读取列表');
258
+ const page = ordered.slice(index, index + Number(limitText));
259
+ return await json(request, response, { contractVersion: 0, total: ordered.length,
260
+ runs: page.map(projectLocalApiRunSummary), nextCursor: index + page.length < ordered.length ? page.at(-1).id : null });
261
+ }
262
+ let artifactId;
263
+ if (matchRun) {
264
+ const view = projectLocalApiFlow(await application.execute('flow.inspect', { run_id: matchRun[1] }));
265
+ if (!matchRun[2]) return await json(request, response, view);
266
+ if (!view.selection) fail('IRIS_API_SELECTION_REQUIRED', '请先明确选择要下载的作品', 409);
267
+ artifactId = view.selection.artifactId;
268
+ } else if (matchImage) artifactId = matchImage[1];
269
+ else fail('IRIS_API_NOT_FOUND', '没有此只读接口', 404);
270
+ const result = await image(artifactId);
271
+ return await send(request, response, 200, result.bytes, { 'Content-Type': result.artifact.mediaType,
272
+ ...(matchRun ? { 'Content-Disposition': `attachment; filename="iris-${artifactId}.${IMAGE_TYPES.get(result.artifact.mediaType)}"` } : {}) });
273
+ } finally { activeReads--; }
274
+ }
275
+
276
+ async function handle(request, response) {
277
+ const requestId = crypto.randomUUID();
278
+ try { await route(request, response); }
279
+ catch (error) {
280
+ const safe = httpError(error);
281
+ if (safe.code === 'IRIS_API_INTERNAL') reportUnexpected(error, requestId);
282
+ await send(request, response, safe.status, Buffer.from(JSON.stringify({ contractVersion: 0,
283
+ error: { code: safe.code, message: safe.message, requestId } })), {
284
+ Connection: 'close', ...(safe.status === 401 ? { 'WWW-Authenticate': 'Bearer realm="iris-local"' } : {}),
285
+ ...(safe.status === 405 ? { Allow: allowLocalWrites ? 'GET, HEAD, POST' : 'GET, HEAD' } : {}) });
286
+ }
287
+ }
288
+
289
+ async function closeServer() {
290
+ if (!server?.listening) return;
291
+ const completion = new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve()));
292
+ server.closeAllConnections();
293
+ await completion;
294
+ }
295
+
296
+ function start({ port = 0 } = {}) {
297
+ if (state !== 'new') return Promise.reject(new LocalApiError('IRIS_API_LIFECYCLE', '服务只能显式启动一次', 409));
298
+ if (!Number.isInteger(port) || port < 0 || port > 65535) return Promise.reject(new LocalApiError('IRIS_API_INPUT_INVALID', '端口必须是 0–65535 的整数'));
299
+ state = 'starting';
300
+ const result = (async () => {
301
+ try {
302
+ const reader = createCoreRuntime({ dataRoot: suppliedRoot, mode: 'reader' });
303
+ try {
304
+ reader.start();
305
+ physicalRoot = await reader.run('inspect', ({ dataRoot: root }) => {
306
+ inspectFlowRoot(root);
307
+ if (allowLocalWrites) inspectCreationRoot(root);
308
+ return root;
309
+ });
310
+ } finally { await reader.dispose(); }
311
+ if (closeRequested) fail('IRIS_API_UNAVAILABLE', '启动已取消', 503);
312
+ application = createApplicationRuntime({ dataRoot: physicalRoot,
313
+ ...(allowLocalWrites && providerConfigPath ? { resolveImageCandidates: ({ modelRef }) =>
314
+ imageCandidatesFromCatalog(loadProviderCatalog(providerConfigPath), modelRef).map(route => ({
315
+ adapter: { id: route.provider.id, protocol: route.provider.mediaProtocol }, model: route.modelRef,
316
+ providerBinding: providerTaskBinding(route.provider), selectionReason: 'explicit'
317
+ })) } : {}) });
318
+ server = http.createServer({ maxHeaderSize: 8192, headersTimeout: 10000, requestTimeout: 15000 }, (request, response) => {
319
+ const job = handle(request, response).catch(error => {
320
+ reportUnexpected(error, crypto.randomUUID()); response.destroy();
321
+ });
322
+ jobs.add(job); void job.then(() => jobs.delete(job));
323
+ });
324
+ server.keepAliveTimeout = 1000; server.maxConnections = 32;
325
+ server.setTimeout(15000, socket => socket.destroy());
326
+ server.on('upgrade', (_request, socket) => socket.destroy());
327
+ server.on('connect', (_request, socket) => socket.destroy());
328
+ await new Promise((resolve, reject) => {
329
+ const onError = error => { server.off('listening', onListening); reject(error); };
330
+ const onListening = () => { server.off('error', onError); resolve(); };
331
+ server.once('error', onError); server.once('listening', onListening); server.listen(port, '127.0.0.1');
332
+ });
333
+ origin = `http://127.0.0.1:${server.address().port}`;
334
+ const accessToken = crypto.randomBytes(32).toString('base64url');
335
+ tokenDigest = digest(accessToken);
336
+ if (closeRequested) fail('IRIS_API_UNAVAILABLE', '启动已取消', 503);
337
+ state = 'open';
338
+ return Object.freeze({ origin, accessToken });
339
+ } catch (error) {
340
+ await closeServer(); await application?.dispose();
341
+ state = closeRequested ? 'closing' : 'closed';
342
+ if (!closeRequested) resolveClosed(snapshot());
343
+ throw error;
344
+ }
345
+ })();
346
+ // Retain only completion for lifecycle coordination; not the private start result containing the token.
347
+ startPromise = result.then(() => undefined, () => undefined);
348
+ return result;
349
+ }
350
+
351
+ function dispose() {
352
+ if (disposal) return disposal;
353
+ closeRequested = true; state = 'closing'; tokenDigest?.fill(0);
354
+ disposal = (async () => {
355
+ if (startPromise) await startPromise.catch(() => {}); // start's caller retains the original failure.
356
+ await closeServer(); await application?.dispose(); await Promise.all([...jobs]);
357
+ state = 'closed'; resolveClosed(snapshot());
358
+ return snapshot();
359
+ })();
360
+ return disposal;
361
+ }
362
+ return Object.freeze({ start, dispose, snapshot, closed });
363
+ }
364
+
365
+ /** Explicit credential output, outside Core. Existing files and parent permissions are untouched. */
366
+ export function writeLocalApiTokenFile(file, token, dataRoot) {
367
+ if (typeof file !== 'string' || !path.isAbsolute(file)) fail('IRIS_API_TOKEN_FILE_INVALID', '会话 Token 文件必须是新的绝对路径');
368
+ let fd;
369
+ try {
370
+ const root = fs.realpathSync(dataRoot), parent = fs.realpathSync(path.dirname(file));
371
+ const destination = path.join(parent, path.basename(file)), relative = path.relative(root, destination);
372
+ if (!relative || !relative.startsWith('..' + path.sep) && !path.isAbsolute(relative)) {
373
+ fail('IRIS_API_TOKEN_FILE_INVALID', '会话 Token 文件必须放在 Core 数据根之外');
374
+ }
375
+ fd = fs.openSync(destination, 'wx', 0o600);
376
+ fs.writeFileSync(fd, token + '\n', 'utf8');
377
+ } catch (error) {
378
+ if (error instanceof LocalApiError) throw error;
379
+ fail('IRIS_API_TOKEN_FILE_UNAVAILABLE', '会话 Token 文件不可写或已存在,请指定新的根外文件');
380
+ } finally { if (fd !== undefined) fs.closeSync(fd); }
381
+ }
@@ -0,0 +1,85 @@
1
+ // Internal, non-generating creation service. One supplied writer owns the complete intent/commit sequence.
2
+ import crypto from 'node:crypto';
3
+ import { createCoreArtifact, readCoreArtifactBytes } from './core-artifacts.js';
4
+ import { createImageFlowService } from './image-flow.js';
5
+ import { readImportImage } from './image-import.js';
6
+ import { normalizeGenerationInput } from './generation-input.js';
7
+ import { initializeCreationRoot, inspectCreationRoot, creationRequestId, creationInputDigest,
8
+ readCreationRequest, publishCreationRequest, completeCreationRequest, creationFail } from './creation-request-store.js';
9
+
10
+ const sha = bytes => crypto.createHash('sha256').update(bytes).digest('hex');
11
+ function flowInput(input) {
12
+ if (!input || typeof input !== 'object' || Array.isArray(input)
13
+ || Object.keys(input).some(key => !['sourceArtifactId', 'modelRef', 'prompt', 'size'].includes(key))
14
+ || !['sourceArtifactId', 'modelRef', 'prompt'].every(key => typeof input[key] === 'string')
15
+ || input.size !== undefined && typeof input.size !== 'string') {
16
+ creationFail('IRIS_CREATION_INPUT_INVALID', '流程创建只接受来源作品、明确模型、Prompt 和可选 size');
17
+ }
18
+ const normalized = normalizeGenerationInput('image', { source_artifact_id: input.sourceArtifactId,
19
+ model_ref: input.modelRef, prompt: input.prompt, ...(input.size !== undefined ? { size: input.size } : {}) });
20
+ return { source_artifact_id: normalized.sourceArtifactId, model_ref: normalized.modelRef,
21
+ ...normalized.providerInput };
22
+ }
23
+ export function createLocalCreationService(runtime, { resolveImageCandidates, onPrepared } = {}) {
24
+ const flows = createImageFlowService(runtime, { resolveImageCandidates });
25
+ function execute(name, input) {
26
+ return runtime.run('execute', async ({ dataRoot, signal }) => {
27
+ if (name === 'creation.init') return { contractVersion: 0, root: initializeCreationRoot(dataRoot) };
28
+ if (!['creation.image', 'creation.flow'].includes(name)) throw new TypeError('未知本地创建操作');
29
+ const kind = name === 'creation.image' ? 'image-import' : 'flow-create';
30
+ const marker = inspectCreationRoot(dataRoot), id = creationRequestId(marker, kind, input.key);
31
+ let source, owned;
32
+ if (kind === 'image-import') {
33
+ source = await readImportImage({ imageBytes: input.bytes }, signal);
34
+ if (source.mediaType !== input.mediaType) creationFail('IRIS_CREATION_MEDIA_MISMATCH', '图片真实格式与 Content-Type 不一致');
35
+ owned = { mediaType: source.mediaType, size: source.bytes.length, sha256: sha(source.bytes) };
36
+ } else owned = flowInput(input.value);
37
+ const inputDigest = creationInputDigest(kind, owned);
38
+ let request = readCreationRequest(dataRoot, id, marker);
39
+ const replay = Boolean(request);
40
+ if (request && request.inputDigest !== inputDigest) creationFail('IRIS_CREATION_KEY_CONFLICT', '该 Key 已绑定其他输入,请保留原请求或为新动作使用新 Key');
41
+ if (!request) {
42
+ const intent = kind === 'image-import'
43
+ ? { artifactId: 'artifact_' + crypto.randomBytes(12).toString('hex'), ...owned,
44
+ metadata: source.metadata, createdAt: new Date().toISOString() }
45
+ : await flows.prepareCreation(dataRoot, signal, owned);
46
+ signal.throwIfAborted();
47
+ request = publishCreationRequest(dataRoot, { schemaVersion: 0, id, rootId: marker.rootId, kind,
48
+ inputDigest, createdAt: intent.createdAt, intent, state: 'prepared' });
49
+ }
50
+ onPrepared?.(); // The Host now detaches HTTP disconnect cancellation; shutdown still reaches this runtime.
51
+ signal.throwIfAborted();
52
+ let result;
53
+ const intent = request.intent;
54
+ if (kind === 'image-import') {
55
+ if (request.state === 'prepared') {
56
+ result = createCoreArtifact(dataRoot, { artifactId: intent.artifactId, bytes: source.bytes,
57
+ mediaType: intent.mediaType, kind: 'imported-image', metadata: intent.metadata,
58
+ expectedDigest: intent.sha256, expectedSize: intent.size, createdAt: intent.createdAt });
59
+ } else {
60
+ try { result = readCoreArtifactBytes(dataRoot, intent.artifactId).artifact; }
61
+ catch (error) {
62
+ if (error.code !== 'IRIS_ARTIFACT_NOT_FOUND') throw error;
63
+ creationFail('IRIS_CREATION_RESULT_UNAVAILABLE', '该请求的原结果已不可用;不会重新创建或恢复已删除作品');
64
+ }
65
+ }
66
+ if (result.digest.value !== intent.sha256 || result.size !== intent.size || result.mediaType !== intent.mediaType
67
+ || result.kind !== 'imported-image' || result.createdAt !== intent.createdAt
68
+ || JSON.stringify(result.metadata) !== JSON.stringify(intent.metadata) || result.relations.length) {
69
+ creationFail('IRIS_CREATION_RESULT_CONFLICT', '原作品与请求意图不一致');
70
+ }
71
+ result = { artifact: result };
72
+ } else {
73
+ try { result = { flow: flows.inspectCreation(dataRoot, intent, { allowCreate: request.state === 'prepared' }) }; }
74
+ catch (error) {
75
+ if (request.state !== 'succeeded' || !['IRIS_FLOW_NOT_FOUND', 'IRIS_ARTIFACT_NOT_FOUND'].includes(error.code)) throw error;
76
+ creationFail('IRIS_CREATION_RESULT_UNAVAILABLE', '该请求的原流程或来源已不可用,不重新创建');
77
+ }
78
+ }
79
+ signal.throwIfAborted();
80
+ if (request.state === 'prepared') completeCreationRequest(dataRoot, request);
81
+ return { contractVersion: 0, idempotentReplay: replay, ...result };
82
+ });
83
+ }
84
+ return Object.freeze({ execute });
85
+ }
@@ -0,0 +1,53 @@
1
+ import { listModels } from './adapters.js';
2
+ import { capabilitiesOfDiscoveredModel } from './models.js';
3
+ import { providerApiKey, providerMediaBaseUrl, selectMediaProtocol } from './provider-protocol.js';
4
+ import { redactProviderMessage } from './provider-contract.js';
5
+ import { MODEL_DIRECTORY_TTL_MS, MODEL_DIRECTORY_RETRY_MS, currentModelDirectory, modelDirectoryBinding } from './model-directory.js';
6
+
7
+ const pending = new Map();
8
+
9
+ /** 只有完整发现成功才替换快照;相同连接合并网络读取,各调用方保存自己的配置。 */
10
+ export async function refreshModelDirectory(provider, { force = false, signal, save, discover = listModels, now = Date.now() } = {}) {
11
+ if (!['dashscope', 'openai-images', 'openai-chat-images', 'openai-responses-images', 'gemini-web-music'].includes(selectMediaProtocol(provider).mediaProtocol)) {
12
+ return { refreshed: false, error: '当前协议未提供模型目录发现' };
13
+ }
14
+ const previous = currentModelDirectory(provider);
15
+ if (!force && previous) {
16
+ const stale = Boolean(previous.lastError) || !previous.checkedAt || now - Date.parse(previous.checkedAt) >= MODEL_DIRECTORY_TTL_MS
17
+ || Date.parse(previous.invalidatedAt || '') >= Date.parse(previous.checkedAt);
18
+ if (!stale || (previous.failedAt && now - Date.parse(previous.failedAt) < MODEL_DIRECTORY_RETRY_MS)) {
19
+ return { refreshed: false, directory: previous, ...(previous.lastError ? { error: previous.lastError } : {}) };
20
+ }
21
+ }
22
+ signal?.throwIfAborted();
23
+ const binding = modelDirectoryBinding(provider);
24
+ let job = pending.get(binding);
25
+ if (!job) {
26
+ const budget = AbortSignal.timeout(15000);
27
+ job = discover({ key: providerApiKey(provider), baseUrl: providerMediaBaseUrl(provider),
28
+ signal: signal ? AbortSignal.any([signal, budget]) : budget, timeoutMs: 15000 });
29
+ pending.set(binding, job);
30
+ job.finally(() => { if (pending.get(binding) === job) pending.delete(binding); }).catch(() => {});
31
+ }
32
+ let discovered;
33
+ try {
34
+ discovered = await job;
35
+ } catch (error) {
36
+ signal?.throwIfAborted();
37
+ const key = providerApiKey(provider);
38
+ const message = String(error?.message || error);
39
+ const directory = { version: 1, binding, ...(previous || {}), failedAt: new Date(now).toISOString(),
40
+ lastError: redactProviderMessage(key ? message.split(key).join('[REDACTED]') : message) };
41
+ if (await save(directory) === false) return { refreshed: false, error: '连接在目录同步期间发生变化,请重新同步' };
42
+ return { refreshed: false, directory, error: directory.lastError };
43
+ }
44
+ signal?.throwIfAborted();
45
+ const models = discovered.map(model => ({ id: typeof model === 'string' ? model : model.id,
46
+ capabilities: capabilitiesOfDiscoveredModel(model) }));
47
+ const known = new Set(previous?.models?.map(model => model.id)
48
+ || provider.models?.map(model => typeof model === 'string' ? model : model.id) || []);
49
+ const directory = { version: 1, binding, checkedAt: new Date(now).toISOString(), models,
50
+ added: models.filter(model => !known.has(model.id)).map(model => model.id) };
51
+ if (await save(directory) === false) return { refreshed: false, error: '连接在目录同步期间发生变化,请重新同步' };
52
+ return { refreshed: true, directory };
53
+ }
@@ -0,0 +1,103 @@
1
+ import crypto from 'node:crypto';
2
+ import { isKnownCapability } from './capability.js';
3
+ import { providerApiKey, providerMediaBaseUrl, selectMediaProtocol, providerForImageModel, providerForMusicModel } from './provider-protocol.js';
4
+
5
+ export const MODEL_DIRECTORY_TTL_MS = 10 * 60 * 1000;
6
+ export const MODEL_DIRECTORY_RETRY_MS = 30 * 1000;
7
+
8
+ /** 配置是外部输入,目录只接受不含连接凭据的固定数据形状。 */
9
+ export function validateModelDirectory(value) {
10
+ if (value === undefined) return;
11
+ if (!value || value.version !== 1 || !/^[a-f0-9]{64}$/.test(value.binding)
12
+ || (value.checkedAt !== undefined && !Number.isFinite(Date.parse(value.checkedAt)))
13
+ || (value.checkedAt && !Array.isArray(value.models))
14
+ || (value.models !== undefined && (!Array.isArray(value.models) || value.models.some(model =>
15
+ !model || typeof model.id !== 'string' || !model.id.trim() || !Array.isArray(model.capabilities)
16
+ || !model.capabilities.every(isKnownCapability))))
17
+ || (value.added !== undefined && (!Array.isArray(value.added) || !value.added.every(id => typeof id === 'string')))) {
18
+ throw new TypeError('模型目录快照结构无效');
19
+ }
20
+ }
21
+
22
+ /** 摘要绑定实际连接,不保存或回传密钥。 */
23
+ export function modelDirectoryBinding(provider) {
24
+ return crypto.createHash('sha256').update(JSON.stringify([
25
+ provider.id, provider.type || '', provider.baseUrl || '', providerMediaBaseUrl(provider), selectMediaProtocol(provider).mediaProtocol,
26
+ provider.auth || 'bearer', providerApiKey(provider)
27
+ ])).digest('hex');
28
+ }
29
+
30
+ export function currentModelDirectory(provider, { allowUnresolvedConnections = false } = {}) {
31
+ const directory = provider?.modelDirectory;
32
+ if (!directory || directory.version !== 1) return null;
33
+ if (directory.binding === modelDirectoryBinding(provider)) return directory;
34
+ return allowUnresolvedConnections && provider.dshProvider && !provider.baseUrl ? directory : null;
35
+ }
36
+
37
+ export function modelAvailabilityBinding(provider, modelId, capability) {
38
+ if (capability === 'image-gen') return modelDirectoryBinding(providerForImageModel(provider, modelId));
39
+ if (capability === 'music-gen' && provider.models?.some(model => model?.id === modelId && model.musicProtocol)) {
40
+ return modelDirectoryBinding(providerForMusicModel(provider, modelId));
41
+ }
42
+ return modelDirectoryBinding(provider);
43
+ }
44
+
45
+ export function modelDirectoryEntry(provider, modelId, options) {
46
+ return currentModelDirectory(provider, options)?.models?.find(model => model.id === modelId) || null;
47
+ }
48
+
49
+ export function modelDirectoryStatus(provider, modelId) {
50
+ const directory = currentModelDirectory(provider, { allowUnresolvedConnections: true });
51
+ if (!directory?.checkedAt) return 'unknown';
52
+ return directory.models.some(model => model.id === modelId) ? 'listed' : 'unlisted';
53
+ }
54
+
55
+ /** 普通请求路径404或参数400不代表模型不存在。兼容已有精确404记录。 */
56
+ export function isModelNotFound(result) {
57
+ if (result?.stage === 'poll') return false;
58
+ const status = Number(result?.httpStatus ?? result?.status);
59
+ if (status !== 404) return false;
60
+ const code = String(result?.providerCode || result?.code || '');
61
+ const message = String(result?.note || result?.safeMessage || result?.message || '');
62
+ return /^(?:NotFound\.Model|model_not_found|ModelNotFound)$/i.test(code)
63
+ || /\bModel not exist\./i.test(message);
64
+ }
65
+
66
+ /** 当前可选性与历史成功分开;旧Task成功不能解除新提交已确认的阻断。 */
67
+ export function modelUnavailable(provider, modelId, capability, { allowUnresolvedConnections = false } = {}) {
68
+ const observation = provider?.health?.observations?.find(item => item.modelId === modelId && item.capability === capability);
69
+ const availability = observation?.availability;
70
+ if (availability) {
71
+ if (availability.binding !== modelAvailabilityBinding(provider, modelId, capability)
72
+ && !(allowUnresolvedConnections && provider.dshProvider && !provider.baseUrl)) return null;
73
+ return availability.state === 'blocked' ? availability : null;
74
+ }
75
+ const failure = [observation?.lastTransient, observation?.lastFailure].filter(isModelNotFound)
76
+ .sort((a, b) => String(b.at).localeCompare(String(a.at)))[0];
77
+ if (!failure) return null;
78
+ if (observation.lastSuccess?.source === 'probe' && observation.lastSuccess.at >= failure.at) return null;
79
+ return { ...failure, state: 'blocked' };
80
+ }
81
+
82
+ export function modelUnavailableReason() {
83
+ return '当前连接已返回模型不存在,已暂停自动选用;请核对目录后单独实测恢复';
84
+ }
85
+
86
+ export function modelDirectorySummary(provider) {
87
+ const directory = currentModelDirectory(provider, { allowUnresolvedConnections: true });
88
+ if (!directory) return { status: 'unknown', note: '尚未同步当前连接的模型目录' };
89
+ const models = directory.models || [];
90
+ const configuredIds = Array.isArray(provider.models)
91
+ ? provider.models.map(model => typeof model === 'string' ? model : model.id)
92
+ : [...new Set(['imageModel', 'videoModel', 'ttsModel', 'musicModel', 'transcribeModel', 'visionModel']
93
+ .map(field => provider[field]).filter(id => typeof id === 'string' && id.trim()).map(id => id.trim()))];
94
+ const missing = directory.checkedAt ? configuredIds.filter(id => !models.some(model => model.id === id)) : [];
95
+ const stale = Boolean(directory.lastError) || Date.now() - Date.parse(directory.checkedAt || '') >= MODEL_DIRECTORY_TTL_MS
96
+ || Date.parse(directory.invalidatedAt || '') >= Date.parse(directory.checkedAt || '');
97
+ return {
98
+ status: directory.checkedAt ? stale ? 'stale' : 'current' : 'unknown',
99
+ checkedAt: directory.checkedAt || null, modelCount: models.length,
100
+ added: directory.added || [], missing,
101
+ ...(directory.lastError ? { error: directory.lastError, failedAt: directory.failedAt } : {})
102
+ };
103
+ }