@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
@@ -5,6 +5,8 @@ import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import { defineHostAdapter } from './host-contract.js';
7
7
  import { BrowserHtmlRenderer } from './render.js';
8
+ import { resolveRenderAddress } from './render-address.js';
9
+ import { createDshProviderSource } from './dsh-provider-source.js';
8
10
 
9
11
  export function detectDshVersion({ entry = process.argv[1] } = {}) {
10
12
  if (!entry) return 'unknown';
@@ -159,8 +161,10 @@ export function createDshHostAdapter(ctx, { version = detectDshVersion() } = {})
159
161
 
160
162
  const browser = service(ctx, 'browser');
161
163
  if (browser && ['open', 'openUrl', 'screenshot', 'close'].every((name) => typeof browser[name] === 'function')) {
162
- const renderer = new BrowserHtmlRenderer({ browser });
164
+ const getAddress = () => resolveRenderAddress(service(ctx, 'webServer') || service(ctx, 'httpServer'));
165
+ const renderer = new BrowserHtmlRenderer({ browser, getAddress });
163
166
  ports.browser = Object.freeze({
167
+ get address() { return getAddress(); },
164
168
  async renderHtml(input) {
165
169
  const { png } = await renderer.render(input);
166
170
  return { bytes: new Uint8Array(png), mediaType: 'image/png' };
@@ -201,6 +205,15 @@ export function createDshHostAdapter(ctx, { version = detectDshVersion() } = {})
201
205
  } else unavailable.skills = unavailableFor(skills, 'DSH Skill registry 需要 register');
202
206
 
203
207
  const llm = service(ctx, 'llm');
208
+ const settings = service(ctx, 'settings');
209
+ if (typeof llm?.listProviders === 'function' && typeof llm?.listConfigurableProviders === 'function'
210
+ && typeof settings?.describe === 'function') {
211
+ ports.providerConnections = createDshProviderSource(() => ({
212
+ llm: service(ctx, 'llm'), settings: service(ctx, 'settings'), credentials: service(ctx, 'credentials')
213
+ }));
214
+ } else unavailable.providerConnections = {
215
+ kind: 'unavailable', reason: '当前 DSH 未提供供应商连接目录与设置接口,可继续手动添加供应商'
216
+ };
204
217
  const defaults = service(ctx, 'agentDefaultModel');
205
218
  if (llm && typeof llm.stream === 'function') {
206
219
  ports.textModel = Object.freeze({
@@ -0,0 +1,35 @@
1
+ import { allProviders } from './config.js';
2
+ import { hasProviderConfigScope, withProviderConfigScope } from './provider-config-scope.js';
3
+ import { DshProviderSourceError } from './dsh-provider-source.js';
4
+
5
+ export function normalizeDshProvider(value) {
6
+ if (!value || typeof value !== 'object' || Array.isArray(value)
7
+ || typeof value.provider !== 'string' || !value.provider.trim()
8
+ || typeof value.settingsNs !== 'string' || !value.settingsNs.trim()
9
+ || Object.keys(value).some(key => !['provider', 'settingsNs'].includes(key))) {
10
+ throw Object.assign(new Error('DSH 连接引用需要 provider 和 settingsNs'), { code: 'IRIS_CONFIG_INPUT_INVALID' });
11
+ }
12
+ return { provider: value.provider.trim(), settingsNs: value.settingsNs.trim() };
13
+ }
14
+
15
+ /** Existing request handlers see resolved copies; the persistent store stays raw. */
16
+ export async function withDshProviderConfig(host, callback, { signal, fresh = false } = {}) {
17
+ if (!fresh && hasProviderConfigScope()) return callback();
18
+ const providers = allProviders();
19
+ if (!providers.some(provider => provider.dshProvider)) return callback();
20
+ const resolved = await Promise.all(providers.map(async provider => {
21
+ if (!provider.dshProvider || provider.enabled === false) return provider;
22
+ try {
23
+ if (typeof host?.ports?.providerConnections?.resolve !== 'function') {
24
+ throw new DshProviderSourceError('当前宿主无法解析 DSH 连接,请检查原连接或改用手动供应商');
25
+ }
26
+ const value = await host.ports.providerConnections.resolve(provider.dshProvider, { signal });
27
+ return { ...provider, baseUrl: value.baseUrl, apiKey: value.apiKey };
28
+ } catch (error) {
29
+ if (!(error instanceof DshProviderSourceError)) throw error;
30
+ return { ...provider, enabled: false, connectionError: error.message };
31
+ }
32
+ }));
33
+ signal?.throwIfAborted();
34
+ return withProviderConfigScope(resolved, callback);
35
+ }
@@ -0,0 +1,102 @@
1
+ /** Read only public DSH services; never open profile or credential files. */
2
+ export class DshProviderSourceError extends Error {
3
+ constructor(message) {
4
+ super(message);
5
+ this.name = 'DshProviderSourceError';
6
+ this.code = 'IRIS_DSH_PROVIDER_UNAVAILABLE';
7
+ }
8
+ }
9
+
10
+ const unavailable = message => { throw new DshProviderSourceError(message); };
11
+ const refName = value => typeof value === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(value);
12
+ const recordId = value => typeof value === 'string' && /^[a-z][a-z0-9-]*$/.test(value);
13
+
14
+ async function credentialRead(read) {
15
+ try { return await read(); }
16
+ catch (_) { unavailable('DSH 凭据服务读取失败,请在宿主检查该连接'); }
17
+ }
18
+
19
+ function endpoint(value) {
20
+ let url;
21
+ try { url = new URL(value); } catch (_) { unavailable('DSH 连接没有可复用的 HTTP(S) 地址'); }
22
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
23
+ unavailable('DSH 连接地址包含不支持的协议或凭据参数');
24
+ }
25
+ return url.href.replace(/\/+$/, '');
26
+ }
27
+
28
+ export function createDshProviderSource(services) {
29
+ function entries() {
30
+ const { llm, settings, credentials } = services();
31
+ if (typeof llm?.listProviders !== 'function' || typeof llm?.listConfigurableProviders !== 'function'
32
+ || typeof settings?.describe !== 'function') {
33
+ unavailable('当前 DSH 未提供连接目录与设置接口,请手动添加 Iris 供应商');
34
+ }
35
+ const active = new Set(llm.listProviders().map(provider => provider.id));
36
+ const forms = settings.describe({ redactSecrets: true });
37
+ return llm.listConfigurableProviders().filter(entry => active.has(entry.provider)).map(entry => {
38
+ let profile = forms.find(form => form.ns === entry.settingsNs)?.value;
39
+ for (const key of entry.settingsPath) profile = profile && Object.hasOwn(profile, key) ? profile[key] : undefined;
40
+ return { entry, profile, credentials };
41
+ });
42
+ }
43
+
44
+ async function facts({ entry, profile, credentials }) {
45
+ if (!profile || typeof profile !== 'object' || Array.isArray(profile)) unavailable('DSH 连接设置不可读取');
46
+ if (profile.headers && Object.keys(profile.headers).length) unavailable('该 DSH 连接含自定义请求头,当前请手动配置 Iris 供应商');
47
+ const baseUrl = endpoint(profile.baseURL || entry.defaultBaseURL);
48
+ if (!credentials) unavailable('当前 DSH 没有凭据解析服务,请手动配置 Iris 供应商');
49
+ if (profile.apiKeyEnv !== undefined) {
50
+ if (!refName(profile.apiKeyEnv) || typeof credentials.describe !== 'function'
51
+ || typeof credentials.resolve !== 'function') unavailable('DSH 凭据引用不可用');
52
+ if (!(await credentialRead(() => credentials.describe(profile.apiKeyEnv))).configured) unavailable('DSH 连接尚未配置可用密钥');
53
+ return { baseUrl, ref: profile.apiKeyEnv };
54
+ }
55
+ // This is the documented api-key record space owned by llm-pi-ai.
56
+ if (entry.settingsNs === 'llm-pi-ai' && recordId(entry.provider)
57
+ && typeof credentials.describeRecord === 'function' && typeof credentials.readRecord === 'function') {
58
+ const key = 'llm-pi-ai/' + entry.provider;
59
+ const info = await credentialRead(() => credentials.describeRecord(key));
60
+ if (info.configured) {
61
+ if (info.kind !== 'api-key') unavailable('该 DSH 连接使用登录授权,当前不支持将其用于媒体请求');
62
+ return { baseUrl, recordKey: key };
63
+ }
64
+ }
65
+ for (const ref of entry.nativeAuth?.env || []) {
66
+ if (refName(ref) && typeof credentials.describe === 'function' && typeof credentials.resolve === 'function'
67
+ && (await credentialRead(() => credentials.describe(ref))).configured) return { baseUrl, ref };
68
+ }
69
+ unavailable('该 DSH 连接没有可复用的 API Key;登录授权和专有认证需单独适配');
70
+ }
71
+
72
+ async function list() {
73
+ return Promise.all(entries().map(async source => {
74
+ const { entry } = source;
75
+ const common = { provider: entry.provider, settingsNs: entry.settingsNs, name: entry.displayName || entry.provider };
76
+ try { return { ...common, baseUrl: (await facts(source)).baseUrl, available: true }; }
77
+ catch (error) {
78
+ if (!(error instanceof DshProviderSourceError)) throw error;
79
+ return { ...common, available: false, reason: error.message };
80
+ }
81
+ }));
82
+ }
83
+
84
+ async function resolve(reference, { signal } = {}) {
85
+ signal?.throwIfAborted();
86
+ const source = entries().find(({ entry }) => entry.provider === reference.provider && entry.settingsNs === reference.settingsNs);
87
+ if (!source) unavailable('DSH 原连接已停用、移除或改变来源,请检查宿主供应商设置');
88
+ const plan = await facts(source);
89
+ let key;
90
+ if (plan.ref) key = (await credentialRead(() => source.credentials.resolve(plan.ref)))?.value;
91
+ else {
92
+ const record = await credentialRead(() => source.credentials.readRecord(plan.recordKey));
93
+ if (record?.kind === 'api-key') key = record.key;
94
+ }
95
+ signal?.throwIfAborted();
96
+ if (typeof key !== 'string' || !key.trim() || /[\r\n]/.test(key)) unavailable('DSH 原连接没有可用于媒体请求的 API Key');
97
+ // Even accidental JSON serialization of this Host-only result excludes the key.
98
+ return Object.freeze(Object.defineProperty({ baseUrl: plan.baseUrl }, 'apiKey', { value: key }));
99
+ }
100
+
101
+ return Object.freeze({ list, resolve });
102
+ }
@@ -0,0 +1,195 @@
1
+ 'use strict';
2
+
3
+ import crypto from 'node:crypto';
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { atomicCreatePrivate, atomicWritePrivate } from './private-storage.js';
7
+ import { WRITER_LEASE_DIR, WRITER_INTENT_FILE } from './core-writer-lease.js';
8
+ import { normalizeGenerationInput } from './generation-input.js';
9
+ import { parseModelRef } from './models.js';
10
+
11
+ const RUN = /^run_[a-f0-9]{24}$/;
12
+ const TASK = /^task_[a-f0-9]{24}$/;
13
+ const ARTIFACT = /^artifact_[a-f0-9]{24}$/;
14
+ const SHA = /^[a-f0-9]{64}$/;
15
+ const BINDING = /^sha256:[a-f0-9]{64}$/;
16
+ const RECIPE = 'image-edit/v0';
17
+
18
+ export class FlowError extends Error {
19
+ constructor(code, message) { super(message); this.name = 'FlowError'; this.code = code; }
20
+ }
21
+ export function flowFail(code, message) { throw new FlowError(code, message); }
22
+
23
+ function keys(value, allowed, required = allowed) {
24
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value)
25
+ && Object.keys(value).every(key => allowed.includes(key)) && required.every(key => Object.hasOwn(value, key)));
26
+ }
27
+ function iso(value) { return typeof value === 'string' && Number.isFinite(Date.parse(value)) && new Date(value).toISOString() === value; }
28
+ const copy = value => Object.freeze(structuredClone(value));
29
+ export const flowId = prefix => prefix + '_' + crypto.randomBytes(12).toString('hex');
30
+ export const flowSha256 = bytes => crypto.createHash('sha256').update(bytes).digest('hex');
31
+
32
+ export function validFlowBinding(value) {
33
+ return keys(value, ['runId', 'stepId', 'inputDigest']) && RUN.test(value.runId)
34
+ && value.stepId === 'edit' && BINDING.test(value.inputDigest);
35
+ }
36
+ export function flowTaskBinding(run) { return { runId: run.id, stepId: 'edit', inputDigest: run.inputDigest }; }
37
+
38
+ function inputDigest(run) {
39
+ return 'sha256:' + flowSha256(JSON.stringify([RECIPE, run.source.artifactId, run.source.sha256,
40
+ run.input.model_ref, run.providerBinding, run.input.prompt, run.input.size ?? null, 1]));
41
+ }
42
+
43
+ function reference(value) { return keys(value, ['artifactId', 'sha256']) && ARTIFACT.test(value.artifactId) && SHA.test(value.sha256); }
44
+ function validate(run, expectedId) {
45
+ let normalized;
46
+ try { normalized = normalizeGenerationInput('image', run?.input); }
47
+ catch (_) { flowFail('IRIS_FLOW_RECORD_INVALID', '流程创作输入无效'); }
48
+ if (!keys(run, ['schemaVersion', 'recipe', 'id', 'revision', 'createdAt', 'updatedAt', 'source', 'input', 'providerBinding',
49
+ 'inputDigest', 'edit', 'compare', 'output', 'comparison', 'selection'],
50
+ ['schemaVersion', 'recipe', 'id', 'revision', 'createdAt', 'updatedAt', 'source', 'input', 'providerBinding', 'inputDigest', 'edit', 'compare'])
51
+ || run.schemaVersion !== 0 || run.recipe !== RECIPE || !RUN.test(run.id) || run.id !== expectedId
52
+ || !Number.isSafeInteger(run.revision) || run.revision < 1 || !iso(run.createdAt) || !iso(run.updatedAt)
53
+ || !reference(run.source) || !BINDING.test(run.providerBinding)
54
+ || !parseModelRef(run.input.model_ref) || normalized.modelRef !== run.input.model_ref
55
+ || run.input.prompt !== normalized.providerInput.prompt || run.input.n !== 1
56
+ || (run.input.size !== undefined && run.input.size !== normalized.providerInput.size)
57
+ || normalized.sourceArtifactId !== run.source.artifactId
58
+ || !keys(run.edit, ['taskId', 'claimed']) || !TASK.test(run.edit.taskId) || typeof run.edit.claimed !== 'boolean'
59
+ || !keys(run.compare, ['artifactId']) || !ARTIFACT.test(run.compare.artifactId)
60
+ || run.compare.artifactId === run.source.artifactId || run.inputDigest !== inputDigest(run)
61
+ || (run.output !== undefined && (!reference(run.output) || !run.edit.claimed
62
+ || [run.source.artifactId, run.compare.artifactId].includes(run.output.artifactId)))
63
+ || (run.comparison !== undefined && (!reference(run.comparison) || !run.output || run.comparison.artifactId !== run.compare.artifactId))
64
+ || (run.selection !== undefined && (!reference(run.selection) || !run.comparison
65
+ || ![run.source, run.output].some(item => item.artifactId === run.selection.artifactId && item.sha256 === run.selection.sha256)))) {
66
+ flowFail('IRIS_FLOW_RECORD_INVALID', '流程记录结构或绑定摘要冲突');
67
+ }
68
+ return run;
69
+ }
70
+
71
+ function directory(root, leaf, create = false) {
72
+ let current = root;
73
+ for (const segment of ['flow-store', 'v0', ...(leaf ? [leaf] : [])]) {
74
+ current = path.join(current, segment);
75
+ if (create && !fs.existsSync(current)) fs.mkdirSync(current, { mode: 0o700 });
76
+ const stat = fs.lstatSync(current);
77
+ if (!stat.isDirectory() || stat.isSymbolicLink()) flowFail('IRIS_FLOW_STORE_UNSAFE', '流程存储目录不安全');
78
+ }
79
+ return current;
80
+ }
81
+ function jsonFile(file) {
82
+ const stat = fs.lstatSync(file);
83
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 128 * 1024) flowFail('IRIS_FLOW_RECORD_INVALID', '流程记录不是受支持的普通文件');
84
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); }
85
+ catch (_) { flowFail('IRIS_FLOW_RECORD_INVALID', '流程记录不是有效 JSON'); }
86
+ }
87
+
88
+ export function inspectFlowRoot(root) {
89
+ let marker;
90
+ try { marker = jsonFile(path.join(directory(root), 'root.json')); }
91
+ catch (error) {
92
+ if (error.code === 'ENOENT') flowFail('IRIS_FLOW_ROOT_REQUIRED', '请先在新的专用数据根执行 flow init,再导入图片');
93
+ throw error;
94
+ }
95
+ if (!keys(marker, ['schemaVersion', 'kind', 'createdAt']) || marker.schemaVersion !== 0
96
+ || marker.kind !== 'iris-image-flow-root' || !iso(marker.createdAt)) flowFail('IRIS_FLOW_RECORD_INVALID', '流程根声明无效或版本不受支持');
97
+ directory(root, 'runs');
98
+ return copy(marker);
99
+ }
100
+
101
+ export function initializeFlowRoot(root) {
102
+ try { fs.lstatSync(path.join(root, 'flow-store')); return inspectFlowRoot(root); }
103
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
104
+ if (fs.readdirSync(root).some(name => ![WRITER_LEASE_DIR, WRITER_INTENT_FILE].includes(name))) {
105
+ flowFail('IRIS_FLOW_ROOT_NOT_EMPTY', '流程只在新的专用数据根启用;不迁移已有 Task/Artifact 数据根');
106
+ }
107
+ directory(root, 'runs', true);
108
+ const marker = { schemaVersion: 0, kind: 'iris-image-flow-root', createdAt: new Date().toISOString() };
109
+ atomicCreatePrivate(path.join(directory(root), 'root.json'), JSON.stringify(marker, null, 2) + '\n');
110
+ return copy(marker);
111
+ }
112
+
113
+ function recordFile(root, id) {
114
+ if (typeof id !== 'string' || !RUN.test(id)) flowFail('IRIS_FLOW_ID_INVALID', 'Run ID 格式无效');
115
+ return path.join(directory(root, 'runs'), id + '.json');
116
+ }
117
+ export function readFlowRun(root, id) {
118
+ inspectFlowRoot(root);
119
+ let record;
120
+ try { record = jsonFile(recordFile(root, id)); }
121
+ catch (error) {
122
+ if (error.code === 'ENOENT') flowFail('IRIS_FLOW_NOT_FOUND', '流程记录不存在');
123
+ throw error;
124
+ }
125
+ return copy(validate(record, id));
126
+ }
127
+
128
+ /** Internal immutable creation intent; HTTP callers cannot choose these identities. */
129
+ export function prepareFlowCreation({ source, input, providerBinding }) {
130
+ return { runId: flowId('run'), taskId: flowId('task'), comparisonArtifactId: flowId('artifact'),
131
+ createdAt: new Date().toISOString(), source, input, providerBinding };
132
+ }
133
+
134
+ export function validateFlowCreation(intent) {
135
+ if (!keys(intent, ['runId', 'taskId', 'comparisonArtifactId', 'createdAt', 'source', 'input', 'providerBinding'])) {
136
+ flowFail('IRIS_FLOW_RECORD_INVALID', '流程创建意图无效');
137
+ }
138
+ const record = { schemaVersion: 0, recipe: RECIPE, id: intent.runId, revision: 1,
139
+ createdAt: intent.createdAt, updatedAt: intent.createdAt, source: intent.source, input: intent.input,
140
+ providerBinding: intent.providerBinding, edit: { taskId: intent.taskId, claimed: false },
141
+ compare: { artifactId: intent.comparisonArtifactId } };
142
+ record.inputDigest = inputDigest(record);
143
+ return validate(record, record.id);
144
+ }
145
+
146
+ export function commitFlowCreation(root, intent, { allowCreate = true } = {}) {
147
+ inspectFlowRoot(root);
148
+ const record = validateFlowCreation(intent);
149
+ let current;
150
+ try { current = readFlowRun(root, record.id); }
151
+ catch (error) { if (error.code !== 'IRIS_FLOW_NOT_FOUND') throw error; }
152
+ if (current) {
153
+ if (current.createdAt !== record.createdAt || current.inputDigest !== record.inputDigest
154
+ || current.edit.taskId !== record.edit.taskId || current.compare.artifactId !== record.compare.artifactId) {
155
+ flowFail('IRIS_FLOW_INPUT_CONFLICT', '原流程与预留创建身份不一致');
156
+ }
157
+ return current;
158
+ }
159
+ if (!allowCreate) flowFail('IRIS_FLOW_NOT_FOUND', '原流程记录已不可用');
160
+ atomicCreatePrivate(recordFile(root, record.id), JSON.stringify(record, null, 2) + '\n');
161
+ return copy(record);
162
+ }
163
+
164
+ export function createFlowRun(root, input) {
165
+ return commitFlowCreation(root, prepareFlowCreation(input));
166
+ }
167
+
168
+ export function updateFlowRun(root, previous, patch) {
169
+ const current = readFlowRun(root, previous.id);
170
+ if (current.revision !== previous.revision) flowFail('IRIS_FLOW_REVISION_CONFLICT', '流程在本次操作期间已改变');
171
+ if (!keys(patch, ['edit', 'output', 'comparison', 'selection'], [])) throw new TypeError('未知流程更新字段');
172
+ const record = { ...current, ...patch, revision: current.revision + 1, updatedAt: new Date().toISOString() };
173
+ validate(record, current.id);
174
+ atomicWritePrivate(recordFile(root, record.id), JSON.stringify(record, null, 2) + '\n');
175
+ return copy(record);
176
+ }
177
+
178
+ export function listFlowRuns(root) {
179
+ inspectFlowRoot(root);
180
+ const records = [];
181
+ for (const name of fs.readdirSync(directory(root, 'runs'))) {
182
+ if (/^run_[a-f0-9]{24}\.json\.tmp-[1-9][0-9]*-[a-f0-9]{12}$/.test(name)) continue; // 保留未发布的原子写现场。
183
+ if (!/^run_[a-f0-9]{24}\.json$/.test(name)) flowFail('IRIS_FLOW_RECORD_INVALID', '流程目录包含未知记录,停止盘点');
184
+ records.push(readFlowRun(root, name.slice(0, -5)));
185
+ }
186
+ return records.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
187
+ }
188
+
189
+ /** 管理入口不能忽略损坏/未知的流程根,从而丢掉引用。普通 Core 根保持零写。 */
190
+ export function flowReferences(root) {
191
+ try { fs.lstatSync(path.join(root, 'flow-store')); }
192
+ catch (error) { if (error.code === 'ENOENT') return []; throw error; }
193
+ return listFlowRuns(root).map(run => ({ run, taskIds: [run.edit.taskId],
194
+ artifactIds: [...new Set([run.source.artifactId, run.compare.artifactId, run.output?.artifactId, run.selection?.artifactId].filter(Boolean))] }));
195
+ }
@@ -70,9 +70,9 @@ function completionResult(completion, routes) {
70
70
  return { ...result, ...selectedModel(completion, routes) };
71
71
  }
72
72
 
73
- async function transcribeVideo({ catalog, input, runtime, operation, onRateLimit }) {
73
+ async function transcribeVideo({ catalog, input, runtime, operation, onRateLimit, onModelAvailability }) {
74
74
  const routes = transcribeCandidatesFromCatalog(catalog, input.transcribe_model_ref);
75
- const candidates = routes.map(route => ({ adapter: createConfiguredProviderAdapter(route.provider, { onRateLimit }),
75
+ const candidates = routes.map(route => ({ adapter: createConfiguredProviderAdapter(route.provider, { onRateLimit, onModelAvailability }),
76
76
  model: route.modelRef, selectionReason: route.selectionReason, providerBinding: providerTaskBinding(route.provider) }));
77
77
  const audio = await waitForModelWork(() => extractAudioTrack({ inputPath: input.video_path, signal: operation.signal }), operation.signal);
78
78
  const abortRuntime = () => { void runtime.dispose().catch(() => {}); };
@@ -111,7 +111,7 @@ async function transcribeVideo({ catalog, input, runtime, operation, onRateLimit
111
111
  }
112
112
  }
113
113
 
114
- export async function executeVisionCommand({ command, input, catalog, modelRef = '', runtime, signal, timeoutMs = VISION_BUDGET.timeoutMs, onRateLimit }) {
114
+ export async function executeVisionCommand({ command, input, catalog, modelRef = '', runtime, signal, timeoutMs = VISION_BUDGET.timeoutMs, onRateLimit, onModelAvailability }) {
115
115
  normalizeVisionInput(command, input);
116
116
  if (modelRef && !/^[^:]+::.+$/.test(modelRef)) invalid('--model-ref 必须是 providerId::modelId');
117
117
  if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > VISION_BUDGET.timeoutMs) invalid('--timeout-ms 必须是 1–120000 的整数');
@@ -121,7 +121,7 @@ export async function executeVisionCommand({ command, input, catalog, modelRef =
121
121
  const inputs = new Map();
122
122
  const prepareImageRequest = command === 'locate' ? createLocateImageRequest(input.target, inputs)
123
123
  : command === 'ocr' ? createVisionInputObserver(inputs) : undefined;
124
- const ports = routes.map(route => createConfiguredVisionModelPort(route.provider, route.model, { onRateLimit,
124
+ const ports = routes.map(route => createConfiguredVisionModelPort(route.provider, route.model, { onRateLimit, onModelAvailability,
125
125
  prepareImages: ['look', 'ocr', 'locate'].includes(command), orientImages: command === 'locate', prepareImageRequest }));
126
126
  const budget = { ...VISION_BUDGET, timeoutMs };
127
127
  const operation = createModelOperation({ signal, budget: { timeoutMs, maxInvocations: command === 'ocr' ? input.max_invocations ?? 64 : ports.length } });
@@ -140,7 +140,7 @@ export async function executeVisionCommand({ command, input, catalog, modelRef =
140
140
  }
141
141
  let transcript = input.transcribe_text || '', transcription = { status: transcript.trim() ? 'provided' : 'disabled' };
142
142
  if (input.transcribe) {
143
- const asr = meta.hasAudio ? await transcribeVideo({ catalog, input, runtime, operation, onRateLimit }) : { report: { status: 'no_audio' }, text: '' };
143
+ const asr = meta.hasAudio ? await transcribeVideo({ catalog, input, runtime, operation, onRateLimit, onModelAvailability }) : { report: { status: 'no_audio' }, text: '' };
144
144
  transcript = asr.text; transcription = asr.report;
145
145
  }
146
146
  const completion = await summarizeMedia({ ports, frames, question: input.question, transcript, operation, budget });
@@ -13,6 +13,7 @@ export const HOST_PORTS = Object.freeze([
13
13
  'attachments',
14
14
  'browser',
15
15
  'clientSlots',
16
+ 'providerConnections',
16
17
  'routes',
17
18
  'sessions',
18
19
  'skills',
@@ -5,6 +5,7 @@ import { ModelPortError, normalizeModelDescriptor, normalizeModelCall, normalize
5
5
  import { modelCallScope, modelAbortError, waitForModelWork } from './model-call-runtime.js';
6
6
  import { createHash } from 'node:crypto';
7
7
  import { isFreeQuotaExhausted, retryAfterDelay } from './provider-health.js';
8
+ import { isModelNotFound } from './model-directory.js';
8
9
  import { prepareVisionImage } from './vision-image-input.js';
9
10
 
10
11
  const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
@@ -103,7 +104,7 @@ export function createHttpVisionModelPort({ providerId, modelId, baseUrl, apiKey
103
104
  });
104
105
  }, signal, 'invoke', 'unknown');
105
106
  if (!response.ok) {
106
- const body = [400, 403, 413, 429].includes(response.status) ? await waitForModelWork(() => response.json().catch(() => null), signal, 'read', 'rejected') : null;
107
+ const body = [400, 403, 404, 413, 429].includes(response.status) ? await waitForModelWork(() => response.json().catch(() => null), signal, 'read', 'rejected') : null;
107
108
  // 只提取可验证的数值事实,不把供应商原文或凭据带到 UI/CLI。
108
109
  const limit = [400, 413].includes(response.status)
109
110
  ? /\b(\d+) bytes exceeds the (\d+)-byte limit\b/i.exec(String(body?.error?.message || body?.message || '')) : null;
@@ -114,10 +115,12 @@ export function createHttpVisionModelPort({ providerId, modelId, baseUrl, apiKey
114
115
  imageBytes: image.bytes.byteLength, ...(maxBytes ? { imageMaxBytes: maxBytes } : {}) });
115
116
  }
116
117
  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
+ const missing = isModelNotFound({ status: response.status, ...(body?.error || body) });
119
+ const code = missing ? 'IRIS_MODEL_UNAVAILABLE' : quota ? 'IRIS_MODEL_RATE_LIMITED' : [401, 403].includes(response.status) ? 'IRIS_MODEL_AUTH_FAILED'
118
120
  : response.status === 429 ? 'IRIS_MODEL_RATE_LIMITED' : response.status === 400 ? 'IRIS_MODEL_INPUT_INVALID' : 'IRIS_MODEL_REQUEST_FAILED';
119
121
  const error = new ModelPortError(code, { stage: 'invoke', invocation: code === 'IRIS_MODEL_REQUEST_FAILED' ? 'unknown' : 'rejected', status: response.status });
120
122
  error.providerCode = (body?.error || body)?.code;
123
+ if (missing) error.providerCode = 'model_not_found';
121
124
  if (response.status === 429) error.retryAfterMs = retryAfterDelay(response.headers.get('retry-after'));
122
125
  if (quota) error.quotaExhausted = true;
123
126
  throw error;
@@ -0,0 +1,188 @@
1
+ 'use strict';
2
+
3
+ import { createCoreArtifact, exportCoreArtifact, readCoreArtifactBytes } from './core-artifacts.js';
4
+ import { assertFlowTaskMatches, claimFlowTask, findFlowTask, recoverCoreTask } from './core-tasks.js';
5
+ import { deliveryCacheNeedsProvider } from './core-delivery-cache.js';
6
+ import { normalizeGenerationInput } from './generation-input.js';
7
+ import { imageEditCandidates, readImageEditArtifact } from './image-edit-input.js';
8
+ import { parseModelRef } from './models.js';
9
+ import { pixelDiff } from './pixels.js';
10
+ import { createProviderTaskRunner } from './provider-task-runner.js';
11
+ import { commitFlowCreation, flowFail, flowSha256, initializeFlowRoot, inspectFlowRoot, listFlowRuns, prepareFlowCreation, readFlowRun, updateFlowRun } from './flow-store.js';
12
+
13
+ function exact(input, allowed) {
14
+ if (!input || typeof input !== 'object' || Array.isArray(input) || Object.keys(input).some(key => !allowed.includes(key))) {
15
+ flowFail('IRIS_FLOW_INPUT_INVALID', '流程输入包含不支持的字段');
16
+ }
17
+ }
18
+ const reference = artifact => ({ artifactId: artifact.id, sha256: artifact.digest.value });
19
+
20
+ function readReference(root, expected) {
21
+ const value = readCoreArtifactBytes(root, expected.artifactId);
22
+ if (value.artifact.digest.value !== expected.sha256) flowFail('IRIS_FLOW_ARTIFACT_CONFLICT', '流程引用的作品摘要已改变');
23
+ return value;
24
+ }
25
+
26
+ function facts(root, run) {
27
+ const source = readReference(root, run.source);
28
+ const task = findFlowTask(root, run);
29
+ if (run.output) {
30
+ if (task.deliveryState !== 'ready' || task.artifactIds.length !== 1 || task.artifactIds[0] !== run.output.artifactId) {
31
+ flowFail('IRIS_FLOW_TASK_CONFLICT', '流程输出与原 Task 的交付事实不一致');
32
+ }
33
+ const { artifact } = readReference(root, run.output);
34
+ if (artifact.metadata.taskId !== task.id || !artifact.relations.some(edge => edge.type === 'derived-from' && edge.artifactId === source.artifact.id)) {
35
+ flowFail('IRIS_FLOW_ARTIFACT_CONFLICT', '流程输出缺少原 Task 或来源关系');
36
+ }
37
+ }
38
+ let comparison;
39
+ if (run.comparison) {
40
+ comparison = readReference(root, run.comparison).artifact;
41
+ if (comparison.kind !== 'pixel-diff' || comparison.relations.length !== 2
42
+ || ![run.source.artifactId, run.output.artifactId].every(id => comparison.relations.some(edge => edge.type === 'derived-from' && edge.artifactId === id))) {
43
+ flowFail('IRIS_FLOW_ARTIFACT_CONFLICT', '比较作品与流程来源不一致');
44
+ }
45
+ }
46
+ let status = 'not_started';
47
+ if (task?.acceptance === 'unknown' || ['unknown', 'failed', 'canceled'].includes(task?.outcome)) status = 'needs_attention';
48
+ else if (task?.deliveryState === 'ready') status = run.comparison ? run.selection ? 'selected' : 'awaiting_selection' : 'needs_comparison';
49
+ else if (task?.deliveryState === 'failed') status = 'delivery_failed';
50
+ else if (task?.attempts.length) status = task.acceptance === 'accepted' ? 'running' : 'needs_attention';
51
+ return { contractVersion: 0, run, task, status, ...(comparison ? { metrics: comparison.metadata } : {}) };
52
+ }
53
+
54
+ /** Internal fixed recipe. All state/leases belong to the supplied operation-scoped runtime. */
55
+ export function createImageFlowService(runtime, { resolveImageCandidates, resolveTaskAdapter } = {}) {
56
+ function creationInput(input) {
57
+ const normalized = normalizeGenerationInput('image', input);
58
+ if (!normalized.sourceArtifactId || !parseModelRef(normalized.modelRef) || normalized.providerInput.n !== 1) {
59
+ flowFail('IRIS_FLOW_INPUT_INVALID', '首版流程需要来源 Artifact、明确 model_ref 和 n=1');
60
+ }
61
+ return { prompt: normalized.providerInput.prompt, model_ref: normalized.modelRef,
62
+ source_artifact_id: normalized.sourceArtifactId, n: 1,
63
+ ...(normalized.providerInput.size !== undefined ? { size: normalized.providerInput.size } : {}) };
64
+ }
65
+ async function prepareCreation(root, signal, input) {
66
+ const owned = creationInput(input);
67
+ inspectFlowRoot(root);
68
+ const source = await readImageEditArtifact(root, owned.source_artifact_id, signal);
69
+ const candidates = await candidatesFor(owned, signal);
70
+ return prepareFlowCreation({ source: { artifactId: owned.source_artifact_id, sha256: flowSha256(source.bytes) },
71
+ input: owned, providerBinding: candidates[0].providerBinding });
72
+ }
73
+ async function candidatesFor(runInput, signal, binding) {
74
+ if (!resolveImageCandidates) flowFail('IRIS_FLOW_PROVIDER_REQUIRED', '创建或首次提交流程需要显式 Provider 配置');
75
+ signal.throwIfAborted();
76
+ const candidates = await resolveImageCandidates({ modelRef: runInput.model_ref, signal });
77
+ signal.throwIfAborted();
78
+ const parsed = parseModelRef(runInput.model_ref);
79
+ if (!Array.isArray(candidates) || candidates.length !== 1 || candidates[0].model !== runInput.model_ref
80
+ || candidates[0].adapter?.id !== parsed.providerId || !/^sha256:[a-f0-9]{64}$/.test(candidates[0].providerBinding)) {
81
+ flowFail('IRIS_FLOW_INPUT_CONFLICT', '流程必须绑定单一明确模型和有效的连接摘要');
82
+ }
83
+ if (binding !== undefined && candidates[0].providerBinding !== binding) flowFail('IRIS_FLOW_PROVIDER_CHANGED', '连接或协议已改变;原流程不重新绑定或生成');
84
+ return imageEditCandidates([{ ...candidates[0], providerModel: parsed.modelId, selectionReason: 'explicit' }]);
85
+ }
86
+
87
+ async function adapterFor(task, signal) {
88
+ if (!resolveTaskAdapter) flowFail('IRIS_FLOW_PROVIDER_REQUIRED', '观察或远端补交付需要原 Task 的 Provider 配置');
89
+ const adapter = await resolveTaskAdapter(task, { signal });
90
+ signal.throwIfAborted();
91
+ return adapter;
92
+ }
93
+
94
+ async function continueRun(root, signal, id) {
95
+ let run = readFlowRun(root, id);
96
+ let task = facts(root, run).task;
97
+ if (task?.attempts.length && !run.edit.claimed) flowFail('IRIS_FLOW_TASK_CONFLICT', '步骤检查点与已开始的 Task 冲突');
98
+ const runner = createProviderTaskRunner(runtime);
99
+ if (!task || !task.attempts.length) {
100
+ const candidates = await candidatesFor(run.input, signal, run.providerBinding);
101
+ task = claimFlowTask(root, run.id);
102
+ if (!run.edit.claimed) run = updateFlowRun(root, run, { edit: { ...run.edit, claimed: true } });
103
+ signal.throwIfAborted();
104
+ const normalized = normalizeGenerationInput('image', run.input);
105
+ task = (await runner.submit({ capability: 'image', candidates, providerInput: normalized.providerInput,
106
+ sourceArtifactId: run.source.artifactId, flowRunId: run.id })).task;
107
+ } else {
108
+ task = recoverCoreTask(root, task.id);
109
+ assertFlowTaskMatches(task, run);
110
+ if (task.outcome === 'succeeded' && task.deliveryState === 'failed') {
111
+ const adapter = task.deliveryCache && !deliveryCacheNeedsProvider(root, task) ? undefined : await adapterFor(task, signal);
112
+ task = await runner.redeliver(task.id, adapter);
113
+ } else if (task.acceptance === 'accepted' && task.outcome === 'none' && task.remoteTaskId) {
114
+ task = await runner.observe(task.id, await adapterFor(task, signal), { resumeActive: true });
115
+ }
116
+ }
117
+ signal.throwIfAborted();
118
+ if (task.deliveryState !== 'ready') return facts(root, run);
119
+ if (task.artifactIds.length !== 1) flowFail('IRIS_FLOW_RESULT_UNSUPPORTED', '首版流程只支持一个改图结果,原 Task 与作品已保留');
120
+ const output = readCoreArtifactBytes(root, task.artifactIds[0]);
121
+ if (!run.output) run = updateFlowRun(root, run, { output: reference(output.artifact) });
122
+ facts(root, run);
123
+ if (!run.comparison) {
124
+ const source = readReference(root, run.source);
125
+ const diff = await pixelDiff({ inputA: source.bytes, inputB: output.bytes, grid: 8, topRegions: 3 });
126
+ signal.throwIfAborted();
127
+ const artifact = createCoreArtifact(root, { artifactId: run.compare.artifactId, createdAt: run.createdAt,
128
+ bytes: diff.heatmap, expectedDigest: flowSha256(diff.heatmap), expectedSize: diff.heatmap.length,
129
+ kind: 'pixel-diff', mediaType: diff.mime,
130
+ metadata: { ratio: diff.ratio, diffPixels: diff.diffPixels, totalPixels: diff.totalPixels,
131
+ width: diff.width, height: diff.height, worstRegions: diff.worstRegions },
132
+ relations: [run.source.artifactId, run.output.artifactId].map(artifactId => ({ type: 'derived-from', artifactId })) });
133
+ run = updateFlowRun(root, run, { comparison: reference(artifact) });
134
+ }
135
+ return facts(root, run);
136
+ }
137
+
138
+ async function execute(name, input = {}) {
139
+ if (name === 'flow.init') {
140
+ exact(input, []);
141
+ return runtime.run('execute', ({ dataRoot }) => ({ contractVersion: 0, root: initializeFlowRoot(dataRoot) }));
142
+ }
143
+ if (name === 'flow.create') {
144
+ const owned = creationInput(input);
145
+ return runtime.run('execute', async ({ dataRoot, signal }) => {
146
+ const run = commitFlowCreation(dataRoot, await prepareCreation(dataRoot, signal, owned));
147
+ return facts(dataRoot, run);
148
+ });
149
+ }
150
+ if (name === 'flow.list') {
151
+ exact(input, []);
152
+ return runtime.run('inspect', ({ dataRoot }) => {
153
+ const runs = listFlowRuns(dataRoot);
154
+ return { contractVersion: 0, total: runs.length, runs: runs.map(run => ({ id: run.id, recipe: run.recipe,
155
+ createdAt: run.createdAt, source: run.source, modelRef: run.input.model_ref, taskId: run.edit.taskId })) };
156
+ });
157
+ }
158
+ if (name === 'flow.inspect') {
159
+ exact(input, ['run_id']);
160
+ return runtime.run('inspect', ({ dataRoot }) => facts(dataRoot, readFlowRun(dataRoot, input.run_id)));
161
+ }
162
+ if (name === 'flow.continue') {
163
+ exact(input, ['run_id']);
164
+ return runtime.run('execute', ({ dataRoot, signal }) => continueRun(dataRoot, signal, input.run_id));
165
+ }
166
+ if (name === 'flow.select' || name === 'flow.export') {
167
+ exact(input, name === 'flow.select' ? ['run_id', 'artifact_id'] : ['run_id', 'output_path']);
168
+ return runtime.run('execute', ({ dataRoot }) => {
169
+ let run = readFlowRun(dataRoot, input.run_id);
170
+ facts(dataRoot, run);
171
+ if (!run.comparison) flowFail('IRIS_FLOW_NOT_READY', '请先完成改图与比较,再选择结果');
172
+ if (name === 'flow.select') {
173
+ const selected = [run.source, run.output].find(item => item.artifactId === input.artifact_id);
174
+ if (!selected) flowFail('IRIS_FLOW_SELECTION_INVALID', '只能选择本流程的原图或新版');
175
+ if (run.selection?.artifactId !== selected.artifactId) run = updateFlowRun(dataRoot, run, { selection: selected });
176
+ return facts(dataRoot, run);
177
+ }
178
+ if (!run.selection) flowFail('IRIS_FLOW_SELECTION_REQUIRED', '请先明确选择要导出的原图或新版');
179
+ return { ...facts(dataRoot, run), export: exportCoreArtifact(dataRoot, run.selection.artifactId, input.output_path) };
180
+ });
181
+ }
182
+ throw new TypeError('未知的内部 Flow 命令');
183
+ }
184
+ return Object.freeze({ execute, prepareCreation, inspectCreation: (root, intent, options) => {
185
+ readReference(root, intent.source);
186
+ return facts(root, commitFlowCreation(root, intent, options));
187
+ } });
188
+ }