openyida 2026.9.11 → 2026.9.13

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 (82) hide show
  1. package/README.md +7 -5
  2. package/bin/yida.js +1 -0
  3. package/lib/ai/ai.js +2 -0
  4. package/lib/app/canvas-compile.js +29 -26
  5. package/lib/app/compile.js +39 -1
  6. package/lib/app/services/canvas-page-compiler.js +3 -3
  7. package/lib/app/update-app.js +17 -2
  8. package/lib/asset/ai-image.js +37 -65
  9. package/lib/asset/asset-cmd.js +151 -110
  10. package/lib/asset/asset-plan.js +105 -0
  11. package/lib/asset/asset-resolve.js +343 -225
  12. package/lib/asset/asset-status.js +20 -31
  13. package/lib/asset/attachment-upload.js +29 -0
  14. package/lib/asset/host-capabilities.js +91 -0
  15. package/lib/asset/image-metadata.js +104 -0
  16. package/lib/asset/url-verify.js +90 -130
  17. package/lib/core/agent-capabilities.js +11 -0
  18. package/lib/core/command-manifest.js +25 -11
  19. package/lib/core/locales/en.js +6 -2
  20. package/lib/core/locales/zh.js +6 -2
  21. package/lib/design-plan/materialize.js +7 -9
  22. package/lib/design-plan/normalize.js +11 -1
  23. package/lib/process/services/process-actions.js +145 -0
  24. package/lib/process/services/process-compiler.js +4 -30
  25. package/lib/process/services/process-view-verifier.js +31 -0
  26. package/package.json +1 -1
  27. package/scripts/postinstall.js +1 -1
  28. package/yida-skills/SKILL.md +3 -2
  29. package/yida-skills/skills/yida-app/SKILL.md +1 -1
  30. package/yida-skills/skills/yida-app/workflow/step-2-design.md +11 -1
  31. package/yida-skills/skills/yida-app/workflow/step-3-create-or-reuse-app.md +21 -1
  32. package/yida-skills/skills/yida-app/workflow/step-5-seed-records.md +11 -7
  33. package/yida-skills/skills/yida-app/workflow/step-7-page-code.md +4 -0
  34. package/yida-skills/skills/yida-canvas-custom-page/SKILL.md +5 -3
  35. package/yida-skills/skills/yida-canvas-custom-page/references/component-library-guide.md +1 -1
  36. package/yida-skills/skills/yida-canvas-custom-page/references/page-generation-guide.md +3 -27
  37. package/yida-skills/skills/yida-canvas-table-form/SKILL.md +1 -1
  38. package/yida-skills/skills/yida-create-app/SKILL.md +4 -4
  39. package/yida-skills/skills/yida-create-process/SKILL.md +2 -0
  40. package/yida-skills/skills/yida-custom-page/references/assets-guide.md +9 -12
  41. package/yida-skills/skills/yida-design/SKILL.md +1 -0
  42. package/yida-skills/skills/yida-design/references/ask-human-interaction-contract.md +4 -4
  43. package/yida-skills/skills/yida-design/references/asset-workflow.md +14 -53
  44. package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/references/build-plan-compact-schema.md +1 -1
  45. package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/references/build-plan-schema.md +21 -4
  46. package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/scripts/render_build_plan.py +1 -2
  47. package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/scripts/validate_design_themes.py +1 -1
  48. package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/README.md +1 -1
  49. package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/airy-media-grid.md +1 -1
  50. package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/airy-modular-clarity.md +1 -1
  51. package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/airy-structured-clarity.md +1 -1
  52. package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/ambient-halo-layered.md +1 -1
  53. package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/dark-focus-layered.md +1 -1
  54. package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/dark-luminous-modular.md +1 -1
  55. package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/deep-stage-duotone.md +1 -1
  56. package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/hairline-runway-clarity.md +1 -1
  57. package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/high-contrast-modular.md +1 -1
  58. package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/interlocked-vivid-modules.md +1 -1
  59. package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/matrix-grid-focus.md +1 -1
  60. package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/media-rail-inspector.md +1 -1
  61. package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/mist-layered-signal.md +1 -1
  62. package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/modular-rail-signal.md +1 -1
  63. package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/mono-grid-signal.md +2 -2
  64. package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/ribbon-ledger-lift.md +1 -1
  65. package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/segmented-meter-clarity.md +1 -1
  66. package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/split-rail-precision.md +1 -1
  67. package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/status-framed-media-grid.md +1 -1
  68. package/yida-skills/skills/yida-design/workflow/output-design.md +4 -0
  69. package/yida-skills/skills/yida-design/workflow/step-4-wireframe-interaction.md +1 -1
  70. package/yida-skills/skills/yida-design/workflow/step-5-visual-states.md +2 -1
  71. package/yida-skills/skills/yida-image-assets/SKILL.md +91 -0
  72. package/yida-skills/skills/yida-image-assets/references/manifest-contract.md +87 -0
  73. package/yida-skills/skills/yida-image-assets/references/source-policy.md +19 -0
  74. package/yida-skills/skills/yida-nav-shell/references/nav-shell-patterns.md +1 -1
  75. package/yida-skills/skills/yida-prd/workflow/output-prd.md +2 -2
  76. package/yida-skills/skills/yida-process-rule/SKILL.md +6 -1
  77. package/yida-skills/skills/yida-process-rule/references/approval-actions.md +48 -0
  78. package/yida-skills/skills/yida-publish-page/SKILL.md +5 -4
  79. package/yida-skills/skills/yida-rechart/SKILL.md +1 -1
  80. package/yida-skills/skills/yida-requirement-analysis/SKILL.md +1 -1
  81. package/yida-skills/skills/yida-requirement-analysis/workflow/prepare-brief.md +22 -13
  82. package/yida-skills/skills-index.json +18 -1
@@ -1,31 +1,3 @@
1
-
2
- /**
3
- * asset-resolve.js - 素材解析与回填引擎
4
- *
5
- * P3 素材工具化的收口层:把 spec 里声明的图片素材(Hero / 产品图,可能是
6
- * 本地路径、外链 URL 或空)解析为「可直接写进页面的稳定 URL」,并诚实地
7
- * 给出素材完成度(materialStatus)与缺口(materialGaps),供生成链路决定
8
- * 交付「最终版」还是「标注缺口的低保真草稿」。
9
- *
10
- * 解析规则(对每个候选图片):
11
- * - 本地文件(存在于磁盘):
12
- * · 有 CDN → 转存 CDN,替换为稳定 CDN URL(source: cdn);
13
- * · 无 CDN → 无法交付浏览器可访问的稳定 URL → 记为缺口。
14
- * - http(s) 外链:由 resolve 流程确认可用后保留(source: external);
15
- * 有 CDN 且 options.mirror=true 时镜像转存;不可用时记为缺口。
16
- * - 空 / 占位:记为对应槽位缺口。
17
- *
18
- * materialStatus:
19
- * - 'final' : 所需素材(含 Hero,若 requireHero)均已解析为稳定 URL;
20
- * - 'draft' : 页面可渲染但存在素材缺口(缺 Hero / 部分产品图失败);
21
- * - 'none' : 无任何可用素材且需要图片 → 只能交付低保真草稿。
22
- *
23
- * 用法:
24
- * const { resolveAssets } = require('./asset-resolve');
25
- * const r = await resolveAssets(spec.assets, { requireHero: true });
26
- * // { assets, materialStatus, materialGaps, strategy, actions, status }
27
- */
28
-
29
1
  'use strict';
30
2
 
31
3
  const fs = require('fs');
@@ -33,16 +5,24 @@ const os = require('os');
33
5
  const path = require('path');
34
6
  const https = require('https');
35
7
  const http = require('http');
36
- const { verifyImageUrl } = require('./url-verify');
37
- const { getAssetStatus, STRATEGY } = require('./asset-status');
8
+ const crypto = require('crypto');
9
+ const { minimumSize, prepareAssetPlan } = require('./asset-plan');
10
+ const { readImageMetadata, parseImageMetadata } = require('./image-metadata');
11
+ const { MAX_IMAGE_BYTES, uploadAttachment } = require('./attachment-upload');
12
+ const { getAssetStatus } = require('./asset-status');
13
+
14
+ const ALLOWED_STOCK_IMAGE_PROVIDERS = Object.freeze({
15
+ unsplash: Object.freeze(['images.unsplash.com']),
16
+ pexels: Object.freeze(['images.pexels.com']),
17
+ });
18
+ const ASSET_SOURCES = Object.freeze(['search', 'user', 'generated']);
38
19
 
39
20
  function isHttpUrl(value) {
40
21
  return typeof value === 'string' && /^https?:\/\//i.test(value.trim());
41
22
  }
42
23
 
43
24
  function isLocalFile(value) {
44
- if (!value || typeof value !== 'string') {return false;}
45
- if (isHttpUrl(value)) {return false;}
25
+ if (!value || typeof value !== 'string' || isHttpUrl(value)) {return false;}
46
26
  try {
47
27
  return fs.existsSync(value) && fs.statSync(value).isFile();
48
28
  } catch {
@@ -50,250 +30,383 @@ function isLocalFile(value) {
50
30
  }
51
31
  }
52
32
 
33
+ function getCollectedImageProvider(rawUrl) {
34
+ try {
35
+ const host = new URL(rawUrl).hostname.toLowerCase();
36
+ for (const [provider, hosts] of Object.entries(ALLOWED_STOCK_IMAGE_PROVIDERS)) {
37
+ if (hosts.some(allowed => host === allowed || host.endsWith(`.${allowed}`))) {return provider;}
38
+ }
39
+ } catch {
40
+ return null;
41
+ }
42
+ return null;
43
+ }
44
+
45
+ function getExternalDeliveryPolicy(rawUrl) {
46
+ return isHttpUrl(rawUrl) ? 'yida-attachment' : 'standard';
47
+ }
48
+
53
49
  function extensionFromUrl(rawUrl) {
54
50
  try {
55
- const parsed = new URL(rawUrl);
56
- const ext = path.extname(parsed.pathname || '').toLowerCase();
51
+ const ext = path.extname(new URL(rawUrl).pathname || '').toLowerCase();
57
52
  return ext && ext.length <= 8 ? ext : '.img';
58
53
  } catch {
59
54
  return '.img';
60
55
  }
61
56
  }
62
57
 
63
- function downloadUrlToTemp(rawUrl, options = {}) {
64
- const timeout = options.timeout || 15000;
65
- const tempPath = path.join(os.tmpdir(), `openyida-asset-${Date.now()}-${Math.random().toString(36).slice(2)}${extensionFromUrl(rawUrl)}`);
66
- return new Promise((resolve, reject) => {
67
- let parsed;
68
- try {
69
- parsed = new URL(rawUrl);
70
- } catch {
71
- reject(new Error('INVALID_URL'));
72
- return;
73
- }
74
- const client = parsed.protocol === 'https:' ? https : http;
75
- const req = client.get(
76
- parsed,
77
- {
78
- headers: {
79
- 'User-Agent': 'Mozilla/5.0 (compatible; OpenYidaAssetMirror/1.0)',
80
- Accept: 'image/*,*/*;q=0.8',
81
- },
82
- },
83
- (res) => {
58
+ async function downloadUrlToTemp(rawUrl, options = {}, depth = 0) {
59
+ if (depth > 4) {throw new Error('TOO_MANY_REDIRECTS');}
60
+ if (options.stockOnly && !getCollectedImageProvider(rawUrl)) {throw new Error('STOCK_PROVIDER_REDIRECT_NOT_ALLOWED');}
61
+ const parsed = new URL(rawUrl);
62
+ if (!['https:', 'http:'].includes(parsed.protocol)) {throw new Error('UNSUPPORTED_PROTOCOL');}
63
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'openyida-asset-'));
64
+ const tempPath = path.join(dir, `image${extensionFromUrl(rawUrl)}`);
65
+ let fileStream;
66
+ let fileClosed;
67
+ try {
68
+ const redirect = await new Promise((resolve, reject) => {
69
+ const client = parsed.protocol === 'https:' ? https : http;
70
+ const req = client.get(parsed, { headers: { 'User-Agent': 'OpenYidaAsset/1.0', Accept: 'image/*' } }, res => {
84
71
  if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
85
72
  res.resume();
86
- downloadUrlToTemp(new URL(res.headers.location, rawUrl).toString(), options).then(resolve, reject);
73
+ try { resolve(new URL(res.headers.location, rawUrl).toString()); } catch (error) { reject(error); }
87
74
  return;
88
75
  }
89
- if (res.statusCode < 200 || res.statusCode >= 400) {
90
- res.resume();
91
- reject(new Error(`HTTP_${res.statusCode || 0}`));
92
- return;
76
+ if (res.statusCode < 200 || res.statusCode >= 300) {
77
+ res.resume(); reject(new Error(`HTTP_${res.statusCode}`)); return;
93
78
  }
79
+ const declaredTooLarge = Number(res.headers['content-length']) > MAX_IMAGE_BYTES;
80
+ let bytes = 0;
81
+ let header = Buffer.alloc(0);
94
82
  const out = fs.createWriteStream(tempPath);
83
+ fileStream = out;
84
+ fileClosed = new Promise(resolve => out.once('close', resolve));
85
+ let failed = false;
86
+ const fail = error => {
87
+ if (failed) {return;}
88
+ failed = true;
89
+ reject(error);
90
+ out.destroy();
91
+ res.destroy();
92
+ };
93
+ res.on('data', chunk => {
94
+ bytes += chunk.length;
95
+ if (header.length < 128 * 1024) {header = Buffer.concat([header, chunk.subarray(0, 128 * 1024 - header.length)]);}
96
+ const metadata = declaredTooLarge ? parseImageMetadata(header) : null;
97
+ if (bytes > MAX_IMAGE_BYTES || (declaredTooLarge && (metadata.width > 0 || header.length >= 128 * 1024))) {
98
+ const error = new Error('ASSET_TOO_LARGE');
99
+ error.metadata = metadata || parseImageMetadata(header);
100
+ fail(error);
101
+ }
102
+ });
103
+ res.on('error', fail);
104
+ res.on('aborted', () => fail(new Error('DOWNLOAD_ABORTED')));
105
+ out.on('error', fail);
106
+ out.on('finish', () => out.close(() => resolve('')));
95
107
  res.pipe(out);
96
- out.on('finish', () => out.close(() => resolve(tempPath)));
97
- out.on('error', reject);
98
- }
99
- );
100
- req.setTimeout(timeout, () => req.destroy(new Error('TIMEOUT')));
101
- req.on('error', reject);
102
- });
108
+ });
109
+ req.setTimeout(options.timeout || 15000, () => req.destroy(new Error('TIMEOUT')));
110
+ req.on('error', reject);
111
+ });
112
+ if (redirect) {
113
+ fs.rmSync(dir, { recursive: true, force: true });
114
+ return downloadUrlToTemp(redirect, options, depth + 1);
115
+ }
116
+ // Correct missing/wrong URL extensions using the downloaded image header.
117
+ const type = readImageMetadata(tempPath).type;
118
+ const target = path.join(dir, `image.${type || 'img'}`);
119
+ if (tempPath !== target) {fs.renameSync(tempPath, target);}
120
+ return target;
121
+ } catch (error) {
122
+ if (fileStream) {
123
+ // destroy() closes the handle asynchronously; Windows cannot unlink it yet.
124
+ fileStream.destroy();
125
+ await fileClosed;
126
+ }
127
+ fs.rmSync(dir, { recursive: true, force: true });
128
+ throw error;
129
+ }
103
130
  }
104
131
 
105
- /**
106
- * 解析单个候选图片
107
- * @param {string} candidate 本地路径或外链 URL
108
- * @param {object} ctx { canUpload, online, uploadFn }
109
- * @returns {Promise<{ resolved:boolean, url:string, source:string, reason:string }>}
110
- */
111
- async function resolveOne(candidate, ctx) {
112
- const raw = (candidate || '').trim();
113
- if (!raw) {
114
- return { resolved: false, url: '', source: 'empty', reason: 'EMPTY' };
132
+ function sizeFailure(metadata, ctx) {
133
+ if (!metadata.type) {return 'NOT_IMAGE_FILE';}
134
+ if (![metadata.width, metadata.height].every(value => Number.isFinite(value) && value > 0)) {
135
+ return 'DIMENSIONS_UNAVAILABLE';
115
136
  }
137
+ if (ctx.minWidth > 0 && metadata.width < ctx.minWidth) {return 'WIDTH_TOO_SMALL';}
138
+ if (ctx.minHeight > 0 && metadata.height < ctx.minHeight) {return 'HEIGHT_TOO_SMALL';}
139
+ return '';
140
+ }
141
+
142
+ function fileHash(file) {
143
+ const hash = crypto.createHash('sha256');
144
+ const fd = fs.openSync(file, 'r');
145
+ try {
146
+ const buffer = Buffer.alloc(64 * 1024);
147
+ let bytes;
148
+ while ((bytes = fs.readSync(fd, buffer, 0, buffer.length, null)) > 0) {hash.update(buffer.subarray(0, bytes));}
149
+ return hash.digest('hex');
150
+ } finally { fs.closeSync(fd); }
151
+ }
152
+
153
+ async function reuseDelivery(input, metadata, ctx) {
154
+ const previous = ctx.previous || {};
155
+ const delivery = previous.delivery || {};
156
+ if (ctx.online === false || delivery.source !== 'yida-attachment' || delivery.input !== input ||
157
+ delivery.assetSource !== ctx.assetSource || !metadata.contentHash || delivery.contentHash !== metadata.contentHash ||
158
+ !isHttpUrl(previous.url)) {return null;}
159
+ return { ...metadata, resolved: true, url: previous.url, source: 'yida-attachment', reason: 'OK_REUSED' };
160
+ }
161
+
162
+ async function resolveOne(candidate, ctx = {}) {
163
+ const raw = String(candidate || '').trim();
164
+ if (!raw) {return { resolved: false, url: '', source: 'empty', reason: 'EMPTY' };}
116
165
 
117
- // 外链素材:由 resolve 流程统一确认并按需镜像
118
166
  if (isHttpUrl(raw)) {
119
- const v = await (ctx.verifyFn || verifyImageUrl)(raw, { timeout: ctx.timeout });
120
- if (v.ok) {
121
- if (ctx.mirrorExternal && ctx.canUpload) {
122
- let tempPath = '';
123
- try {
124
- tempPath = await (ctx.downloadFn || downloadUrlToTemp)(v.finalUrl || raw, { timeout: ctx.timeout });
125
- const results = await ctx.uploadFn([tempPath]);
126
- const hit = (results || []).find((r) => r.success && r.originalPath === tempPath) ||
127
- (results || []).find((r) => r.success);
128
- if (hit && hit.cdnUrl) {
129
- return { resolved: true, url: hit.cdnUrl, source: 'cdn-mirrored', reason: 'OK' };
130
- }
131
- const failed = (results || [])[0];
132
- return {
133
- resolved: false,
134
- url: raw,
135
- source: 'external',
136
- reason: (failed && failed.error) || 'MIRROR_UPLOAD_FAILED',
137
- };
138
- } catch (err) {
139
- return { resolved: false, url: raw, source: 'external', reason: (err && err.message) || 'MIRROR_FAILED' };
140
- } finally {
141
- if (tempPath) {
142
- fs.rmSync(tempPath, { force: true });
143
- }
144
- }
145
- }
167
+ if (ctx.online === false) {
168
+ return { resolved: false, url: raw, source: 'external', reason: 'OFFLINE_EXTERNAL_URL' };
169
+ }
170
+ const assetSource = ASSET_SOURCES.includes(ctx.assetSource) ? ctx.assetSource : 'search';
171
+ const inputProvider = getCollectedImageProvider(raw);
172
+ if (assetSource === 'search' && !inputProvider) {
146
173
  return {
147
- resolved: true,
148
- url: v.finalUrl || raw,
174
+ resolved: false,
175
+ url: raw,
149
176
  source: 'external',
150
- reason: 'OK',
177
+ reason: 'STOCK_PROVIDER_NOT_ALLOWED',
178
+ deliveryPolicy: 'blocked',
179
+ provider: null,
180
+ allowedProviders: Object.keys(ALLOWED_STOCK_IMAGE_PROVIDERS),
151
181
  };
152
182
  }
153
- return { resolved: false, url: raw, source: 'external', reason: v.reason || 'ASSET_UNAVAILABLE' };
183
+ const deliveryPolicy = getExternalDeliveryPolicy(raw);
184
+ const common = { provider: inputProvider || ctx.provider || assetSource };
185
+ const original = metadata => {
186
+ const failure = sizeFailure(metadata, ctx);
187
+ if (failure) {return { ...common, resolved: false, reason: failure };}
188
+ return { ...common, ...metadata, imageType: metadata.type, resolved: true, url: raw,
189
+ source: 'external', reason: 'ASSET_TOO_LARGE', deliveryPolicy: 'original-url' };
190
+ };
191
+ let tempPath = '';
192
+ try {
193
+ tempPath = await (ctx.downloadFn || downloadUrlToTemp)(raw, { timeout: ctx.timeout, stockOnly: assetSource === 'search' });
194
+ if (fs.statSync(tempPath).size > MAX_IMAGE_BYTES) {return original(readImageMetadata(tempPath));}
195
+ const downloaded = readImageMetadata(tempPath);
196
+ const failure = sizeFailure(downloaded, ctx);
197
+ if (failure) {return { resolved: false, url: raw, source: 'external', reason: failure };}
198
+ Object.assign(common, downloaded, { contentHash: fileHash(tempPath), imageType: downloaded.type });
199
+ const reused = await reuseDelivery(raw, common, ctx);
200
+ if (reused) {return reused;}
201
+ if (!ctx.canUpload) {return { resolved: false, url: raw, source: 'external', reason: 'YIDA_ATTACHMENT_UNAVAILABLE' };}
202
+ const results = await ctx.uploadFn([tempPath]);
203
+ const hit = (results || []).find(result => result.success && result.originalPath === tempPath)
204
+ || (results || []).find(result => result.success);
205
+ if (hit && isHttpUrl(hit.cdnUrl)) {
206
+ return { ...common, resolved: true, url: hit.cdnUrl, source: 'yida-attachment', reason: 'OK', deliveryPolicy };
207
+ }
208
+ return { ...common, resolved: false, url: raw, source: 'external', reason: ((results || [])[0] || {}).error || 'UPLOAD_FAILED' };
209
+ } catch (error) {
210
+ if (error.message === 'ASSET_TOO_LARGE') {return original(error.metadata || {});}
211
+ return { ...common, resolved: false, url: raw, source: 'external', reason: error.code || error.message || 'UPLOAD_FAILED' };
212
+ } finally {
213
+ if (tempPath) {
214
+ fs.rmSync(tempPath, { force: true });
215
+ if (!ctx.downloadFn) {fs.rmSync(path.dirname(tempPath), { recursive: true, force: true });}
216
+ }
217
+ }
154
218
  }
155
219
 
156
- // 本地文件:需要 CDN 才能转存为浏览器可访问的稳定 URL
157
220
  if (isLocalFile(raw)) {
158
- if (!ctx.canUpload) {
221
+ const assetSource = ASSET_SOURCES.includes(ctx.assetSource) ? ctx.assetSource : '';
222
+ const provider = String(ctx.provider || '').trim().toLowerCase();
223
+ if (assetSource === 'search' && !ALLOWED_STOCK_IMAGE_PROVIDERS[provider]) {
159
224
  return {
160
225
  resolved: false,
161
226
  url: raw,
162
227
  source: 'local',
163
- reason: 'LOCAL_NO_CDN',
228
+ reason: 'STOCK_PROVIDER_NOT_ALLOWED',
229
+ provider: provider || null,
230
+ allowedProviders: Object.keys(ALLOWED_STOCK_IMAGE_PROVIDERS),
164
231
  };
165
232
  }
233
+ const metadata = readImageMetadata(raw);
234
+ const failure = sizeFailure(metadata, ctx);
235
+ if (failure) {
236
+ return { ...metadata, resolved: false, url: raw, source: 'local', reason: failure };
237
+ }
238
+ if (ctx.online === false) {
239
+ return { ...metadata, resolved: false, url: raw, source: 'local', reason: 'OFFLINE_UPLOAD_BLOCKED' };
240
+ }
241
+ if (fs.statSync(raw).size > MAX_IMAGE_BYTES) {return { ...metadata, resolved: false, url: '', source: 'local', reason: 'ASSET_TOO_LARGE_NO_ORIGINAL_URL' };}
242
+ metadata.contentHash = fileHash(raw);
243
+ const reused = await reuseDelivery(raw, metadata, ctx);
244
+ if (reused) {return reused;}
245
+ if (!ctx.canUpload) {
246
+ return { ...metadata, resolved: false, url: raw, source: 'local', reason: 'YIDA_ATTACHMENT_UNAVAILABLE' };
247
+ }
166
248
  try {
167
249
  const results = await ctx.uploadFn([raw]);
168
- const hit = (results || []).find((r) => r.success && r.originalPath === raw) ||
169
- (results || []).find((r) => r.success);
170
- if (hit && hit.cdnUrl) {
171
- return { resolved: true, url: hit.cdnUrl, source: 'cdn', reason: 'OK' };
250
+ const hit = (results || []).find(result => result.success && result.originalPath === raw)
251
+ || (results || []).find(result => result.success);
252
+ if (hit && isHttpUrl(hit.cdnUrl)) {
253
+ return { ...metadata, resolved: true, url: hit.cdnUrl, source: 'yida-attachment', reason: 'OK' };
172
254
  }
173
- const failed = (results || [])[0];
174
- return {
175
- resolved: false,
176
- url: raw,
177
- source: 'local',
178
- reason: (failed && failed.error) || 'UPLOAD_FAILED',
179
- };
180
- } catch (err) {
181
- return { resolved: false, url: raw, source: 'local', reason: (err && err.message) || 'UPLOAD_ERROR' };
255
+ return { ...metadata, resolved: false, url: raw, source: 'local', reason: ((results || [])[0] || {}).error || 'UPLOAD_FAILED' };
256
+ } catch (error) {
257
+ return { ...metadata, resolved: false, url: raw, source: 'local', reason: error.code || error.message || 'UPLOAD_FAILED' };
182
258
  }
183
259
  }
184
260
 
185
- // 既不是外链也不是磁盘文件 → 视为未落地的占位引用
186
261
  return { resolved: false, url: raw, source: 'unknown', reason: 'NOT_FOUND' };
187
262
  }
188
263
 
189
- /**
190
- * 解析并回填一组素材
191
- * @param {object} assets { heroImage, heroImageAlt, productImages, materialStrategy }
192
- * @param {object} [options]
193
- * @param {boolean} [options.requireHero] 页面是否必须有 Hero 图(官网/落地页通常 true)
194
- * @param {boolean} [options.online] 是否可联网处理外链(默认 true)
195
- * @param {number} [options.timeout] 单个外链处理超时
196
- * @param {function} [options.uploadFn] 自定义上传函数(默认懒加载 cdn-upload.performUpload)
197
- * @returns {Promise<{
198
- * assets: object,
199
- * materialStatus: 'final'|'draft'|'none',
200
- * materialGaps: string[],
201
- * strategy: string,
202
- * actions: Array,
203
- * status: object
204
- * }>}
205
- */
206
- async function resolveAssets(assets = {}, options = {}) {
207
- const online = options.online !== false;
208
- const requireHero = !!options.requireHero;
209
- const status = getAssetStatus({ online });
210
- const hasInjectedUpload = typeof options.uploadFn === 'function';
211
-
212
- const ctx = {
213
- canUpload: status.canUpload || hasInjectedUpload,
214
- online,
215
- mirrorExternal: !!options.mirrorExternal,
216
- timeout: options.timeout,
217
- verifyFn: options.verifyFn,
218
- downloadFn: options.downloadFn,
219
- uploadFn:
220
- options.uploadFn ||
221
- ((files) => {
222
- // 懒加载,避免无 CDN 场景加载 ali-oss
223
- const { performUpload } = require('../cdn/cdn-upload');
224
- return performUpload({ files });
225
- }),
226
- };
227
-
228
- const inputHero = assets.heroImage || '';
229
- const inputProducts = Array.isArray(assets.productImages) ? assets.productImages : [];
230
-
231
- const actions = [];
232
- const materialGaps = [];
233
-
234
- // Hero
235
- let heroUrl = '';
236
- if (inputHero) {
237
- const r = await resolveOne(inputHero, ctx);
238
- actions.push({ slot: 'heroImage', input: inputHero, ...r });
239
- if (r.resolved) {
240
- heroUrl = r.url;
241
- } else {
242
- materialGaps.push(`Hero 图未落地(${r.reason}):${inputHero}`);
243
- }
244
- } else if (requireHero) {
245
- materialGaps.push('缺少 Hero 图:官网/落地页应提供一张品牌主视觉大图。');
246
- }
264
+ function normalizeSource(source, fallback) {
265
+ return ASSET_SOURCES.includes(source) ? source : fallback;
266
+ }
247
267
 
248
- // 产品/场景图
249
- const productUrls = [];
250
- for (let i = 0; i < inputProducts.length; i++) {
251
- const item = inputProducts[i];
252
- const r = await resolveOne(item, ctx);
253
- actions.push({ slot: `productImages[${i}]`, input: item, ...r });
254
- if (r.resolved) {
255
- productUrls.push(r.url);
256
- } else {
257
- materialGaps.push(`产品图 #${i + 1} 未落地(${r.reason}):${item}`);
268
+ function metadataGaps(asset) {
269
+ const missing = [];
270
+ ['slotId', 'usage', 'alt'].forEach((field) => {
271
+ if (typeof asset[field] !== 'string' || !asset[field].trim()) {missing.push(field);}
272
+ });
273
+ if (![asset.width, asset.height].every(value => Number.isFinite(value) && value > 0)) {missing.push('width/height');}
274
+ if (asset.source === 'search') {
275
+ ['provider', 'sourcePage', 'creator', 'license', 'attribution'].forEach((field) => {
276
+ if (typeof asset[field] !== 'string' || !asset[field].trim()) {missing.push(field);}
277
+ });
278
+ if (asset.provider === 'unsplash') {
279
+ if (!asset.downloadLocation) {missing.push('downloadLocation');}
280
+ if (asset.downloadTracked !== true) {missing.push('downloadTracked');}
258
281
  }
259
282
  }
283
+ return missing;
284
+ }
260
285
 
261
- // 计算 materialStatus
262
- const resolvedCount = (heroUrl ? 1 : 0) + productUrls.length;
263
- let materialStatus;
264
- if (materialGaps.length === 0) {
265
- // 没有缺口:要么素材齐全,要么本就不需要图片
266
- materialStatus = 'final';
267
- } else if (resolvedCount > 0) {
268
- // 有部分素材落地,但仍有缺口 → 草稿
269
- materialStatus = 'draft';
270
- } else if (requireHero) {
271
- // 需要图片却一张都没落地 → 低保真
272
- materialStatus = 'none';
273
- } else {
274
- materialStatus = 'draft';
275
- }
286
+ async function resolveAssets(assets = [], options = {}) {
287
+ const plan = prepareAssetPlan(assets, options.assetStrategy);
288
+ const entries = plan.entries;
289
+ const online = options.online !== false;
290
+ const status = options.status || getAssetStatus({ online, env: options.env, runtime: options.runtime });
291
+ const canUpload = online;
292
+ const defaultSource = normalizeSource(options.assetSource, 'search');
293
+ const uploadFn = options.uploadFn || (files => uploadAttachment(files, options));
276
294
 
277
- // strategy:以实际落地来源反推,优先反映真实能力
278
- let strategy = status.recommendedStrategy;
279
- if (materialStatus === 'none') {
280
- strategy = STRATEGY.LOW_FIDELITY;
281
- }
295
+ const actions = await Promise.all(entries.map(async (asset, index) => {
296
+ const declaredSource = String(asset.source || '').trim();
297
+ const source = declaredSource || defaultSource;
298
+ const input = String(asset.input || asset.localPath || asset.url || '').trim();
299
+ const size = minimumSize(asset);
300
+ if (!ASSET_SOURCES.includes(source) || (source === 'generated' && asset.generationAllowed === false)) {
301
+ return {
302
+ index,
303
+ asset,
304
+ input,
305
+ source,
306
+ result: { resolved: false, url: input, source: 'unknown', reason: !ASSET_SOURCES.includes(source) ? 'INVALID_SOURCE' : 'GENERATION_NOT_ALLOWED' },
307
+ };
308
+ }
309
+ let result;
310
+ try {
311
+ result = await resolveOne(input, {
312
+ previous: asset,
313
+ assetSource: source,
314
+ provider: asset.provider,
315
+ online,
316
+ canUpload,
317
+ timeout: options.timeout,
318
+ downloadFn: options.downloadFn,
319
+ uploadFn,
320
+ ...size,
321
+ });
322
+ } catch (error) {
323
+ result = { resolved: false, url: input, reason: error.code || 'ASSET_READ_FAILED' };
324
+ }
325
+ return { index, asset, input, source, result };
326
+ }));
282
327
 
283
- const resolvedAssets = {
284
- heroImage: heroUrl,
285
- heroImageAlt: assets.heroImageAlt || '',
286
- productImages: productUrls,
287
- materialStrategy: strategy,
288
- };
328
+ const outputAssets = [];
329
+ const gaps = [];
330
+ actions.forEach(({ asset, input, source, result }, index) => {
331
+ const slotId = String(asset.slotId || '').trim();
332
+ const provider = result.provider || asset.provider || '';
333
+ const output = {
334
+ slotId,
335
+ pageId: asset.pageId || '',
336
+ input,
337
+ ...minimumSize(asset),
338
+ ...(asset.minSize !== undefined ? { minSize: asset.minSize } : {}),
339
+ usage: asset.usage || '',
340
+ required: asset.required !== false,
341
+ url: result.resolved ? result.url : '',
342
+ localPath: isLocalFile(input) ? input : '',
343
+ source,
344
+ provider,
345
+ sourcePage: asset.sourcePage || '',
346
+ creator: asset.creator || '',
347
+ license: asset.license || '',
348
+ attribution: asset.attribution || '',
349
+ downloadLocation: asset.downloadLocation || '',
350
+ downloadTracked: asset.downloadTracked === true,
351
+ width: result.width || 0,
352
+ height: result.height || 0,
353
+ imageType: result.imageType || result.type || '',
354
+ ...(result.resolved ? { delivery: { source: result.source, input, assetSource: source, contentHash: result.contentHash || '', reason: result.reason } } : {}),
355
+ alt: asset.alt || '',
356
+ isIllustrative: asset.isIllustrative === true || source === 'generated',
357
+ };
358
+ output.materialStatus = 'draft';
359
+ output.gaps = [];
360
+ outputAssets.push(output);
361
+ if (!result.resolved) {
362
+ output.gaps.push({
363
+ slotId: slotId || `slot-${index + 1}`,
364
+ pageId: output.pageId,
365
+ code: result.reason,
366
+ message: `图片未落地:${result.reason}`,
367
+ });
368
+ gaps.push(...output.gaps.map(gap => ({ ...gap })));
369
+ return;
370
+ }
371
+ const missing = metadataGaps(output);
372
+ if (missing.length > 0) {
373
+ output.gaps.push({
374
+ slotId: slotId || `slot-${index + 1}`,
375
+ pageId: output.pageId,
376
+ code: 'MISSING_METADATA',
377
+ message: `缺少字段:${missing.join(', ')}`,
378
+ });
379
+ }
380
+ output.materialStatus = output.gaps.length ? 'draft' : 'final';
381
+ gaps.push(...output.gaps.map(gap => ({ ...gap })));
382
+ });
289
383
 
384
+ const pages = plan.pages.map(page => {
385
+ const pageAssets = outputAssets.filter(asset => asset.pageId === page.pageId);
386
+ const pageGaps = gaps.filter(gap => gap.pageId === page.pageId).map(gap => ({ ...gap }));
387
+ if (page.imageNeed === 'required' && page.slotIds.length === 0) {
388
+ const gap = { pageId: page.pageId, code: 'MISSING_PAGE_SLOTS', message: 'MISSING_PAGE_SLOTS' };
389
+ pageGaps.push(gap);
390
+ gaps.push({ ...gap });
391
+ }
392
+ const blocked = pageGaps.some(gap => !gap.slotId || pageAssets.some(asset => asset.slotId === gap.slotId && asset.required));
393
+ return { ...page, materialStatus: blocked ? 'draft' : pageAssets.length ? 'final' : 'none', gaps: pageGaps };
394
+ });
395
+ const materialStatus = gaps.length ? 'draft' : entries.length ? 'final' : 'none';
290
396
  return {
291
- assets: resolvedAssets,
397
+ schemaVersion: 2,
292
398
  materialStatus,
293
- materialGaps,
294
- strategy,
295
- actions,
296
- status,
399
+ ...(plan.assetStrategy !== undefined ? { assetStrategy: plan.assetStrategy } : {}),
400
+ pages,
401
+ capabilityEvidence: {
402
+ host: status.hostCapabilities,
403
+ cdnConfigured: status.cdnConfigured,
404
+ uploadTarget: 'yida-attachment',
405
+ maxUploadBytes: MAX_IMAGE_BYTES,
406
+ online,
407
+ },
408
+ assets: outputAssets,
409
+ gaps,
297
410
  };
298
411
  }
299
412
 
@@ -302,4 +415,9 @@ module.exports = {
302
415
  resolveOne,
303
416
  isHttpUrl,
304
417
  isLocalFile,
418
+ getExternalDeliveryPolicy,
419
+ getCollectedImageProvider,
420
+ ALLOWED_STOCK_IMAGE_PROVIDERS,
421
+ ASSET_SOURCES,
422
+ downloadUrlToTemp,
305
423
  };