@avocadostudio-ai/orchestrator-core 0.1.0

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 (196) hide show
  1. package/LICENSE +201 -0
  2. package/dist/agent/agent-context.d.ts +19 -0
  3. package/dist/agent/agent-context.js +67 -0
  4. package/dist/agent/agent-logger.d.ts +5 -0
  5. package/dist/agent/agent-logger.js +22 -0
  6. package/dist/agent/agent-loop-openai.d.ts +8 -0
  7. package/dist/agent/agent-loop-openai.js +172 -0
  8. package/dist/agent/agent-loop.d.ts +56 -0
  9. package/dist/agent/agent-loop.js +167 -0
  10. package/dist/agent/agent-provider.d.ts +28 -0
  11. package/dist/agent/agent-provider.js +63 -0
  12. package/dist/agent/agent-tools.d.ts +28 -0
  13. package/dist/agent/agent-tools.js +899 -0
  14. package/dist/agent/context/editing-guidelines.md +46 -0
  15. package/dist/agent/context/role.md +39 -0
  16. package/dist/agent/integration-prompt.d.ts +9 -0
  17. package/dist/agent/integration-prompt.js +154 -0
  18. package/dist/agent/sites-agent-context.d.ts +12 -0
  19. package/dist/agent/sites-agent-context.js +316 -0
  20. package/dist/agent/sites-agent-shared.d.ts +161 -0
  21. package/dist/agent/sites-agent-shared.js +1101 -0
  22. package/dist/agent/sites-agent-tools.d.ts +18 -0
  23. package/dist/agent/sites-agent-tools.js +1227 -0
  24. package/dist/chat/anthropic-cache.d.ts +20 -0
  25. package/dist/chat/anthropic-cache.js +54 -0
  26. package/dist/chat/anthropic-planner.d.ts +98 -0
  27. package/dist/chat/anthropic-planner.js +1012 -0
  28. package/dist/chat/changelog-coverage-validator.d.ts +37 -0
  29. package/dist/chat/changelog-coverage-validator.js +215 -0
  30. package/dist/chat/chat-pipeline-context.d.ts +211 -0
  31. package/dist/chat/chat-pipeline-context.js +249 -0
  32. package/dist/chat/chat-pipeline-deterministic.d.ts +61 -0
  33. package/dist/chat/chat-pipeline-deterministic.js +407 -0
  34. package/dist/chat/chat-pipeline-image.d.ts +86 -0
  35. package/dist/chat/chat-pipeline-image.js +897 -0
  36. package/dist/chat/chat-pipeline-shared.d.ts +69 -0
  37. package/dist/chat/chat-pipeline-shared.js +212 -0
  38. package/dist/chat/chat-pipeline-translation.d.ts +27 -0
  39. package/dist/chat/chat-pipeline-translation.js +417 -0
  40. package/dist/chat/chat-pipeline-ui.d.ts +14 -0
  41. package/dist/chat/chat-pipeline-ui.js +244 -0
  42. package/dist/chat/chat-pipeline.d.ts +99 -0
  43. package/dist/chat/chat-pipeline.js +3999 -0
  44. package/dist/chat/decomposer.d.ts +21 -0
  45. package/dist/chat/decomposer.js +65 -0
  46. package/dist/chat/gemini-planner.d.ts +70 -0
  47. package/dist/chat/gemini-planner.js +541 -0
  48. package/dist/chat/hallucination-validator.d.ts +36 -0
  49. package/dist/chat/hallucination-validator.js +110 -0
  50. package/dist/chat/locale-strings.d.ts +47 -0
  51. package/dist/chat/locale-strings.js +100 -0
  52. package/dist/chat/plan-json-schema.d.ts +133 -0
  53. package/dist/chat/plan-json-schema.js +112 -0
  54. package/dist/chat/planner-types.d.ts +120 -0
  55. package/dist/chat/planner-types.js +66 -0
  56. package/dist/chat/planner.d.ts +148 -0
  57. package/dist/chat/planner.js +1361 -0
  58. package/dist/chat/prompts.d.ts +67 -0
  59. package/dist/chat/prompts.js +356 -0
  60. package/dist/chat/provider-routing.d.ts +14 -0
  61. package/dist/chat/provider-routing.js +27 -0
  62. package/dist/chat/variation-pipeline.d.ts +135 -0
  63. package/dist/chat/variation-pipeline.js +837 -0
  64. package/dist/chat/vision-alt-generator.d.ts +35 -0
  65. package/dist/chat/vision-alt-generator.js +152 -0
  66. package/dist/cms/adapter.d.ts +62 -0
  67. package/dist/cms/adapter.js +1 -0
  68. package/dist/cms/bootstrap.d.ts +17 -0
  69. package/dist/cms/bootstrap.js +85 -0
  70. package/dist/cms/editor-api-adapter.d.ts +23 -0
  71. package/dist/cms/editor-api-adapter.js +71 -0
  72. package/dist/cms/index.d.ts +4 -0
  73. package/dist/cms/index.js +3 -0
  74. package/dist/cms/json-file-adapter.d.ts +11 -0
  75. package/dist/cms/json-file-adapter.js +62 -0
  76. package/dist/demo-mode.d.ts +59 -0
  77. package/dist/demo-mode.js +201 -0
  78. package/dist/errors.d.ts +67 -0
  79. package/dist/errors.js +129 -0
  80. package/dist/http/chat-stream-resumable.d.ts +108 -0
  81. package/dist/http/chat-stream-resumable.js +290 -0
  82. package/dist/http/chat-stream.d.ts +99 -0
  83. package/dist/http/chat-stream.js +92 -0
  84. package/dist/image/gdrive-client.d.ts +22 -0
  85. package/dist/image/gdrive-client.js +215 -0
  86. package/dist/image/image-helpers.d.ts +95 -0
  87. package/dist/image/image-helpers.js +488 -0
  88. package/dist/index.d.ts +1 -0
  89. package/dist/index.js +1 -0
  90. package/dist/jira/jira-approval.d.ts +22 -0
  91. package/dist/jira/jira-approval.js +51 -0
  92. package/dist/jira/jira-client.d.ts +44 -0
  93. package/dist/jira/jira-client.js +313 -0
  94. package/dist/jira/jira-poller.d.ts +46 -0
  95. package/dist/jira/jira-poller.js +184 -0
  96. package/dist/jira/jira-processor.d.ts +103 -0
  97. package/dist/jira/jira-processor.js +1085 -0
  98. package/dist/jira/jira-types.d.ts +117 -0
  99. package/dist/jira/jira-types.js +38 -0
  100. package/dist/logger.d.ts +12 -0
  101. package/dist/logger.js +28 -0
  102. package/dist/migration/mcp-server-stdio.d.ts +8 -0
  103. package/dist/migration/mcp-server-stdio.js +672 -0
  104. package/dist/migration/migration-prompt.d.ts +7 -0
  105. package/dist/migration/migration-prompt.js +197 -0
  106. package/dist/migration/migration-tools.d.ts +17 -0
  107. package/dist/migration/migration-tools.js +159 -0
  108. package/dist/migration/scrape-cache.d.ts +9 -0
  109. package/dist/migration/scrape-cache.js +19 -0
  110. package/dist/nlp/deterministic-planner-context.d.ts +141 -0
  111. package/dist/nlp/deterministic-planner-context.js +362 -0
  112. package/dist/nlp/deterministic-planner-pages.d.ts +26 -0
  113. package/dist/nlp/deterministic-planner-pages.js +170 -0
  114. package/dist/nlp/deterministic-planner-patches.d.ts +80 -0
  115. package/dist/nlp/deterministic-planner-patches.js +508 -0
  116. package/dist/nlp/deterministic-planner-refs.d.ts +33 -0
  117. package/dist/nlp/deterministic-planner-refs.js +164 -0
  118. package/dist/nlp/deterministic-planner-suggestions.d.ts +49 -0
  119. package/dist/nlp/deterministic-planner-suggestions.js +579 -0
  120. package/dist/nlp/deterministic-planner.d.ts +85 -0
  121. package/dist/nlp/deterministic-planner.js +1631 -0
  122. package/dist/nlp/intent-detection.d.ts +309 -0
  123. package/dist/nlp/intent-detection.js +730 -0
  124. package/dist/nlp/intent-helpers.d.ts +15 -0
  125. package/dist/nlp/intent-helpers.js +243 -0
  126. package/dist/nlp/intent-patterns.d.ts +40 -0
  127. package/dist/nlp/intent-patterns.js +223 -0
  128. package/dist/nlp/plan-normalizer.d.ts +41 -0
  129. package/dist/nlp/plan-normalizer.js +1537 -0
  130. package/dist/ops/destructive-action-gate.d.ts +44 -0
  131. package/dist/ops/destructive-action-gate.js +90 -0
  132. package/dist/ops/ops-engine.d.ts +151 -0
  133. package/dist/ops/ops-engine.js +1394 -0
  134. package/dist/publish/diff-engine.d.ts +18 -0
  135. package/dist/publish/diff-engine.js +305 -0
  136. package/dist/publish/publish-helpers.d.ts +87 -0
  137. package/dist/publish/publish-helpers.js +521 -0
  138. package/dist/publish/publish-target-registry.d.ts +7 -0
  139. package/dist/publish/publish-target-registry.js +61 -0
  140. package/dist/publish/publish-target.d.ts +81 -0
  141. package/dist/publish/publish-target.js +1 -0
  142. package/dist/publish/targets/deploy-hook.d.ts +13 -0
  143. package/dist/publish/targets/deploy-hook.js +123 -0
  144. package/dist/publish/targets/git.d.ts +13 -0
  145. package/dist/publish/targets/git.js +55 -0
  146. package/dist/publish/targets/site-contract.d.ts +19 -0
  147. package/dist/publish/targets/site-contract.js +124 -0
  148. package/dist/state/content-source.d.ts +17 -0
  149. package/dist/state/content-source.js +1 -0
  150. package/dist/state/in-memory-content-source.d.ts +27 -0
  151. package/dist/state/in-memory-content-source.js +51 -0
  152. package/dist/state/session-lock.d.ts +13 -0
  153. package/dist/state/session-lock.js +29 -0
  154. package/dist/state/session-state.d.ts +310 -0
  155. package/dist/state/session-state.js +1083 -0
  156. package/dist/state/sqlite-store-singleton.d.ts +31 -0
  157. package/dist/state/sqlite-store-singleton.js +170 -0
  158. package/dist/state/sqlite-store.d.ts +135 -0
  159. package/dist/state/sqlite-store.js +421 -0
  160. package/dist/telemetry/chat-telemetry.d.ts +105 -0
  161. package/dist/telemetry/chat-telemetry.js +247 -0
  162. package/dist/telemetry/eval-candidate-store.d.ts +50 -0
  163. package/dist/telemetry/eval-candidate-store.js +120 -0
  164. package/dist/telemetry/feedback-store.d.ts +34 -0
  165. package/dist/telemetry/feedback-store.js +76 -0
  166. package/dist/telemetry/jira-telemetry.d.ts +57 -0
  167. package/dist/telemetry/jira-telemetry.js +68 -0
  168. package/dist/telemetry/migration-telemetry.d.ts +35 -0
  169. package/dist/telemetry/migration-telemetry.js +40 -0
  170. package/dist/telemetry/usage.d.ts +24 -0
  171. package/dist/telemetry/usage.js +80 -0
  172. package/dist/tools/builtin-registrations.d.ts +12 -0
  173. package/dist/tools/builtin-registrations.js +33 -0
  174. package/dist/tools/builtins/gdrive-browse.d.ts +3 -0
  175. package/dist/tools/builtins/gdrive-browse.js +68 -0
  176. package/dist/tools/builtins/image-generate.d.ts +3 -0
  177. package/dist/tools/builtins/image-generate.js +211 -0
  178. package/dist/tools/builtins/unsplash-get-by-id.d.ts +23 -0
  179. package/dist/tools/builtins/unsplash-get-by-id.js +119 -0
  180. package/dist/tools/builtins/unsplash-search.d.ts +3 -0
  181. package/dist/tools/builtins/unsplash-search.js +74 -0
  182. package/dist/tools/executor.d.ts +23 -0
  183. package/dist/tools/executor.js +169 -0
  184. package/dist/tools/index.d.ts +5 -0
  185. package/dist/tools/index.js +5 -0
  186. package/dist/tools/registry.d.ts +21 -0
  187. package/dist/tools/registry.js +75 -0
  188. package/dist/tools/runtime.d.ts +27 -0
  189. package/dist/tools/runtime.js +48 -0
  190. package/dist/tools/schema-validator.d.ts +24 -0
  191. package/dist/tools/schema-validator.js +88 -0
  192. package/dist/tools/types.d.ts +86 -0
  193. package/dist/tools/types.js +1 -0
  194. package/dist/variation-images.d.ts +19 -0
  195. package/dist/variation-images.js +12 -0
  196. package/package.json +78 -0
@@ -0,0 +1,897 @@
1
+ import { getAllBlockMeta } from "@avocadostudio-ai/shared";
2
+ import { inferTranslationScopeFromMessage } from "./chat-pipeline-translation.js";
3
+ import { heroImageQueryFromContext, imageKeywordsFromQuery, generateVariationImageWithOpenAI, resolveUnsplashImage, resolveGdriveImage, isExplicitImageGenRequest, extractImagePromptFromMessage, estimatedImageGenMs, recordImageGenDuration } from "../image/image-helpers.js";
4
+ import { isGdriveConfigured } from "../image/gdrive-client.js";
5
+ import { firstUrlFromText, looksLikeUserInstruction, preferredImageAltText } from "./chat-pipeline-ui.js";
6
+ import { isDemoModeEnabled, isDemoImageGenDisabled } from "../demo-mode.js";
7
+ /** Is demo mode active AND image gen disabled for demo? Memoized per call. */
8
+ function isDemoImageGenActive() {
9
+ return isDemoModeEnabled() && isDemoImageGenDisabled();
10
+ }
11
+ export function blockHasImageUrlProp(block) {
12
+ if (!block)
13
+ return false;
14
+ const props = block.props;
15
+ return typeof props === "object" && props !== null && Object.prototype.hasOwnProperty.call(props, "imageUrl");
16
+ }
17
+ export function parsePath(path) {
18
+ const parts = [];
19
+ for (const match of path.matchAll(/([^[.\]]+)|\[(\d+)\]/g)) {
20
+ if (match[1])
21
+ parts.push(match[1]);
22
+ if (match[2])
23
+ parts.push(Number(match[2]));
24
+ }
25
+ return parts;
26
+ }
27
+ export function getValueAtPath(root, path) {
28
+ if (!path)
29
+ return root;
30
+ let current = root;
31
+ for (const part of parsePath(path)) {
32
+ if (typeof part === "number") {
33
+ if (!Array.isArray(current))
34
+ return undefined;
35
+ current = current[part];
36
+ continue;
37
+ }
38
+ if (!current || typeof current !== "object" || Array.isArray(current))
39
+ return undefined;
40
+ current = current[part];
41
+ }
42
+ return current;
43
+ }
44
+ export function setValueAtPath(root, path, value) {
45
+ const parts = parsePath(path);
46
+ if (parts.length === 0)
47
+ return;
48
+ let current = root;
49
+ for (let idx = 0; idx < parts.length - 1; idx += 1) {
50
+ const part = parts[idx];
51
+ const next = parts[idx + 1];
52
+ if (typeof part === "number") {
53
+ if (!Array.isArray(current))
54
+ return;
55
+ if (current[part] === undefined || current[part] === null) {
56
+ current[part] = typeof next === "number" ? [] : {};
57
+ }
58
+ current = current[part];
59
+ continue;
60
+ }
61
+ if (!current || typeof current !== "object" || Array.isArray(current))
62
+ return;
63
+ const holder = current;
64
+ if (!(part in holder) || holder[part] === undefined || holder[part] === null) {
65
+ holder[part] = typeof next === "number" ? [] : {};
66
+ }
67
+ current = holder[part];
68
+ }
69
+ const leaf = parts[parts.length - 1];
70
+ if (typeof leaf === "number") {
71
+ if (!Array.isArray(current))
72
+ return;
73
+ current[leaf] = value;
74
+ return;
75
+ }
76
+ if (!current || typeof current !== "object" || Array.isArray(current))
77
+ return;
78
+ current[leaf] = value;
79
+ }
80
+ export function deleteValueAtPath(root, path) {
81
+ const parts = parsePath(path);
82
+ if (parts.length === 0)
83
+ return;
84
+ let current = root;
85
+ for (let idx = 0; idx < parts.length - 1; idx += 1) {
86
+ const part = parts[idx];
87
+ if (typeof part === "number") {
88
+ if (!Array.isArray(current))
89
+ return;
90
+ current = current[part];
91
+ continue;
92
+ }
93
+ if (!current || typeof current !== "object" || Array.isArray(current))
94
+ return;
95
+ current = current[part];
96
+ }
97
+ const leaf = parts[parts.length - 1];
98
+ if (typeof leaf === "number") {
99
+ if (!Array.isArray(current))
100
+ return;
101
+ delete current[leaf];
102
+ return;
103
+ }
104
+ if (!current || typeof current !== "object" || Array.isArray(current))
105
+ return;
106
+ delete current[leaf];
107
+ }
108
+ export function extractIndexedQueries(message) {
109
+ const out = new Map();
110
+ for (const m of message.matchAll(/\b(?:card|item|feature|tile)\s*(\d+)\s*[:=]\s*"([^"]+)"/gi)) {
111
+ const idx = Number(m[1]) - 1;
112
+ const value = m[2]?.trim();
113
+ if (Number.isFinite(idx) && idx >= 0 && value)
114
+ out.set(idx, value);
115
+ }
116
+ return out;
117
+ }
118
+ export function extractReferencedItemIndices(message) {
119
+ const numeric = new Set();
120
+ let includesLast = false;
121
+ for (const m of message.matchAll(/\b(?:card|item|feature|tile)\s*(\d+)(?:st|nd|rd|th)?\b/gi)) {
122
+ const idx = Number(m[1]) - 1;
123
+ if (Number.isFinite(idx) && idx >= 0)
124
+ numeric.add(idx);
125
+ }
126
+ for (const m of message.matchAll(/\b(\d+)(?:st|nd|rd|th)\s+(?:card|item|feature|tile)\b/gi)) {
127
+ const idx = Number(m[1]) - 1;
128
+ if (Number.isFinite(idx) && idx >= 0)
129
+ numeric.add(idx);
130
+ }
131
+ for (const m of message.matchAll(/\b(first|second|third|fourth|fifth|sixth|seventh|eighth|ninth|tenth|last)\s+(?:card|item|feature|tile)\b/gi)) {
132
+ const value = String(m[1] ?? "").toLowerCase();
133
+ if (value === "last") {
134
+ includesLast = true;
135
+ continue;
136
+ }
137
+ const idx = value === "first" ? 0 :
138
+ value === "second" ? 1 :
139
+ value === "third" ? 2 :
140
+ value === "fourth" ? 3 :
141
+ value === "fifth" ? 4 :
142
+ value === "sixth" ? 5 :
143
+ value === "seventh" ? 6 :
144
+ value === "eighth" ? 7 :
145
+ value === "ninth" ? 8 :
146
+ value === "tenth" ? 9 :
147
+ -1;
148
+ if (idx >= 0)
149
+ numeric.add(idx);
150
+ }
151
+ return { numeric, includesLast, hasConstraint: numeric.size > 0 || includesLast };
152
+ }
153
+ /**
154
+ * Check whether a block type's schema supports imageUrl at a given path.
155
+ * E.g. "imageUrl" → true for Hero, "features[0].imageUrl" → false for FeatureGrid.
156
+ */
157
+ export function blockSupportsImageAtPath(blockType, imagePath) {
158
+ const meta = getAllBlockMeta()[blockType];
159
+ if (!meta)
160
+ return true; // unknown block type → optimistic
161
+ // Top-level imageUrl (e.g. Hero)
162
+ if (imagePath === "imageUrl") {
163
+ return "imageUrl" in meta.fields;
164
+ }
165
+ // Nested: e.g. "features[0].imageUrl" or "items[2].imageUrl"
166
+ const listMatch = imagePath.match(/^([a-zA-Z_]+)\[\d+\]\.imageUrl$/);
167
+ if (listMatch) {
168
+ const listName = listMatch[1];
169
+ const listMeta = meta.listFields?.[listName];
170
+ if (!listMeta)
171
+ return false;
172
+ return "imageUrl" in listMeta.itemFields;
173
+ }
174
+ // Deeper nesting we can't check — be optimistic
175
+ return true;
176
+ }
177
+ export function detectImagePaths(value, basePath = "", acc = new Set()) {
178
+ if (Array.isArray(value)) {
179
+ value.forEach((entry, idx) => detectImagePaths(entry, `${basePath}[${idx}]`, acc));
180
+ return acc;
181
+ }
182
+ if (!value || typeof value !== "object")
183
+ return acc;
184
+ const obj = value;
185
+ for (const [key, child] of Object.entries(obj)) {
186
+ const nextPath = basePath ? `${basePath}.${key}` : key;
187
+ if (key === "imageUrl")
188
+ acc.add(nextPath);
189
+ detectImagePaths(child, nextPath, acc);
190
+ }
191
+ return acc;
192
+ }
193
+ function isRemoteHttpUrl(value) {
194
+ return typeof value === "string" && /^https?:\/\//i.test(value.trim());
195
+ }
196
+ function patchContainsResolvedImageUrl(patch) {
197
+ const imagePaths = detectImagePaths(patch);
198
+ for (const path of imagePaths) {
199
+ if (isRemoteHttpUrl(getValueAtPath(patch, path)))
200
+ return true;
201
+ }
202
+ return false;
203
+ }
204
+ export function imageQueryFromItem(item, sectionContext) {
205
+ // Skip imageAlt when it looks like a user instruction ("add white feta…",
206
+ // "change the image…") — those phrases pollute the search query.
207
+ const cleanAlt = typeof item.imageAlt === "string" && !looksLikeUserInstruction(item.imageAlt)
208
+ ? item.imageAlt
209
+ : undefined;
210
+ const candidate = [
211
+ cleanAlt,
212
+ item.title,
213
+ item.heading,
214
+ item.name,
215
+ item.description,
216
+ item.subheading,
217
+ item.quote,
218
+ item.label,
219
+ item.q
220
+ ]
221
+ .filter((value) => typeof value === "string" && value.trim().length > 0)
222
+ .join(" ");
223
+ const terms = imageKeywordsFromQuery(candidate, 4);
224
+ if (terms.length > 0) {
225
+ // Enrich short queries with section context for more specific results
226
+ if (terms.length <= 2 && sectionContext) {
227
+ const contextTerms = imageKeywordsFromQuery(sectionContext, 2);
228
+ const unique = contextTerms.filter((t) => !terms.includes(t));
229
+ return [...terms, ...unique.slice(0, 1)].join(" ");
230
+ }
231
+ return terms.join(" ");
232
+ }
233
+ return "";
234
+ }
235
+ export function shouldPopulateAllChildImages(message) {
236
+ const lower = message.toLowerCase();
237
+ return /\b(images?|photos?|pictures?)\b/.test(lower) && /\b(all|each|every)\b/.test(lower) && /\b(cards?|items?|features?|tiles?|children)\b/.test(lower);
238
+ }
239
+ export function findImageTargets(args) {
240
+ const mergedProps = {
241
+ ...(args.targetBlock.props ?? {}),
242
+ ...args.patchCandidate
243
+ };
244
+ const explicitByIndex = extractIndexedQueries(args.message);
245
+ const constrainedIndices = extractReferencedItemIndices(args.message);
246
+ const defaultQuery = heroImageQueryFromContext({
247
+ message: args.message,
248
+ currentPage: args.currentPage,
249
+ targetBlock: args.targetBlock,
250
+ patchCandidate: args.patchCandidate
251
+ });
252
+ const imagePaths = detectImagePaths(mergedProps);
253
+ if (shouldPopulateAllChildImages(args.message)) {
254
+ for (const [key, value] of Object.entries(mergedProps)) {
255
+ if (!Array.isArray(value))
256
+ continue;
257
+ value.forEach((entry, idx) => {
258
+ if (!entry || typeof entry !== "object" || Array.isArray(entry))
259
+ return;
260
+ imagePaths.add(`${key}[${idx}].imageUrl`);
261
+ });
262
+ }
263
+ }
264
+ // Build section-level context for enriching item queries
265
+ const blockProps = args.targetBlock.props ?? {};
266
+ const sectionContext = [blockProps.heading, blockProps.subheading, blockProps.title, blockProps.sectionTitle]
267
+ .filter((v) => typeof v === "string" && v.trim().length > 0)
268
+ .join(" ");
269
+ const targets = [];
270
+ for (const path of imagePaths) {
271
+ const itemMatch = path.match(/^(.*\[(\d+)\])\.imageUrl$/);
272
+ const index = itemMatch?.[2] ? Number(itemMatch[2]) : undefined;
273
+ const itemPath = itemMatch?.[1];
274
+ if (constrainedIndices.hasConstraint && itemPath && index !== undefined) {
275
+ let allowed = constrainedIndices.numeric.has(index);
276
+ if (!allowed && constrainedIndices.includesLast) {
277
+ const listPath = itemPath.replace(/\[\d+\]$/, "");
278
+ const listValue = getValueAtPath(mergedProps, listPath);
279
+ if (Array.isArray(listValue) && index === listValue.length - 1)
280
+ allowed = true;
281
+ }
282
+ if (!allowed)
283
+ continue;
284
+ }
285
+ const indexed = index !== undefined ? explicitByIndex.get(index) : undefined;
286
+ let query = indexed ?? "";
287
+ if (!query && itemPath) {
288
+ const item = getValueAtPath(mergedProps, itemPath);
289
+ if (item && typeof item === "object" && !Array.isArray(item))
290
+ query = imageQueryFromItem(item, sectionContext);
291
+ }
292
+ if (!query)
293
+ query = defaultQuery;
294
+ targets.push({ path, altPath: path.replace(/imageUrl$/, "imageAlt"), query });
295
+ }
296
+ return targets;
297
+ }
298
+ export function rewriteAddBlockToChildImageUpdate(args) {
299
+ if (args.plan.intent !== "edit_plan")
300
+ return args.plan;
301
+ const lower = args.message.toLowerCase();
302
+ const referencesContainerChildren = /\b(in|inside|within)\b/.test(lower) ||
303
+ /\b(?:to|for)\s+(?:all|each|every)\s+\w+/.test(lower) ||
304
+ /\b(?:all|each|every)\s+\w+/.test(lower);
305
+ const shouldRewrite = /\b(images?|photos?|pictures?)\b/.test(lower) &&
306
+ /\b(all|each|every)\b/.test(lower) &&
307
+ referencesContainerChildren &&
308
+ !/\b(new|another)\b/.test(lower);
309
+ if (!shouldRewrite)
310
+ return args.plan;
311
+ const rewrittenOps = [];
312
+ let changed = false;
313
+ const hasObjectArrayProp = (block) => Object.values((block.props ?? {})).some((value) => Array.isArray(value) && value.some((entry) => entry && typeof entry === "object" && !Array.isArray(entry)));
314
+ for (const op of args.plan.ops) {
315
+ // Handle update_props on container blocks — expand to include per-item images
316
+ if (op.op === "update_props") {
317
+ const existing = args.currentPage.blocks.find((block) => block.id === op.blockId);
318
+ if (existing && hasObjectArrayProp(existing)) {
319
+ const existingProps = (existing.props ?? {});
320
+ const patch = { ...(op.patch ?? {}) };
321
+ let expanded = false;
322
+ for (const [key, value] of Object.entries(existingProps)) {
323
+ if (!Array.isArray(value))
324
+ continue;
325
+ const nextItems = value.map((entry) => {
326
+ if (!entry || typeof entry !== "object" || Array.isArray(entry))
327
+ return entry;
328
+ const item = entry;
329
+ const titleLike = typeof item.title === "string" ? item.title :
330
+ typeof item.heading === "string" ? item.heading :
331
+ typeof item.name === "string" ? item.name :
332
+ "Card image";
333
+ return {
334
+ ...item,
335
+ imageUrl: typeof item.imageUrl === "string" && item.imageUrl.trim().length > 0 ? item.imageUrl : "pending",
336
+ imageAlt: typeof item.imageAlt === "string" && item.imageAlt.trim().length > 0 && !looksLikeUserInstruction(item.imageAlt) ? item.imageAlt : `Image for ${titleLike}`
337
+ };
338
+ });
339
+ patch[key] = nextItems;
340
+ expanded = true;
341
+ }
342
+ if (expanded) {
343
+ rewrittenOps.push({ op: "update_props", pageSlug: op.pageSlug, blockId: op.blockId, patch });
344
+ changed = true;
345
+ continue;
346
+ }
347
+ }
348
+ rewrittenOps.push(op);
349
+ continue;
350
+ }
351
+ if (op.op !== "add_block") {
352
+ rewrittenOps.push(op);
353
+ continue;
354
+ }
355
+ let existing = op.block.type === "Card"
356
+ ? args.currentPage.blocks.find((block) => block.type === "CardGrid") ??
357
+ args.currentPage.blocks.find((block) => block.type === "Card")
358
+ : args.currentPage.blocks.find((block) => block.type === op.block.type);
359
+ if (!existing || !hasObjectArrayProp(existing)) {
360
+ existing =
361
+ args.currentPage.blocks.find((block) => hasObjectArrayProp(block)) ??
362
+ existing;
363
+ }
364
+ if (!existing) {
365
+ rewrittenOps.push(op);
366
+ continue;
367
+ }
368
+ const existingProps = (existing.props ?? {});
369
+ const patch = {};
370
+ for (const [key, value] of Object.entries(existingProps)) {
371
+ if (!Array.isArray(value))
372
+ continue;
373
+ const nextItems = value.map((entry) => {
374
+ if (!entry || typeof entry !== "object" || Array.isArray(entry))
375
+ return entry;
376
+ const item = entry;
377
+ const titleLike = typeof item.title === "string" ? item.title :
378
+ typeof item.heading === "string" ? item.heading :
379
+ typeof item.name === "string" ? item.name :
380
+ "Card image";
381
+ return {
382
+ ...item,
383
+ imageUrl: typeof item.imageUrl === "string" && item.imageUrl.trim().length > 0 ? item.imageUrl : "pending",
384
+ imageAlt: typeof item.imageAlt === "string" && item.imageAlt.trim().length > 0 ? item.imageAlt : `Image for ${titleLike}`
385
+ };
386
+ });
387
+ patch[key] = nextItems;
388
+ }
389
+ if (Object.keys(patch).length === 0) {
390
+ rewrittenOps.push(op);
391
+ continue;
392
+ }
393
+ rewrittenOps.push({
394
+ op: "update_props",
395
+ pageSlug: args.slug,
396
+ blockId: existing.id,
397
+ patch
398
+ });
399
+ changed = true;
400
+ }
401
+ if (!changed)
402
+ return args.plan;
403
+ return {
404
+ ...args.plan,
405
+ summary_for_user: "Will update images in the existing section.",
406
+ change_log: ["Will update child images in the existing component instead of adding a duplicate section."],
407
+ ops: rewrittenOps
408
+ };
409
+ }
410
+ // ---------------------------------------------------------------------------
411
+ // Progress-tracked image generation wrapper
412
+ // ---------------------------------------------------------------------------
413
+ const IMAGE_PROGRESS_STAGES = [
414
+ { at: 0.00, pct: 0, label: "Understanding prompt\u2026" },
415
+ { at: 0.10, pct: 15, label: "Composing scene\u2026" },
416
+ { at: 0.30, pct: 40, label: "Rendering image\u2026" },
417
+ { at: 0.65, pct: 75, label: "Finalizing details\u2026" },
418
+ { at: 0.90, pct: 95, label: "Almost there\u2026" },
419
+ ];
420
+ function startImageProgressTimer(onImageProgress) {
421
+ if (!onImageProgress)
422
+ return () => { };
423
+ const estimated = estimatedImageGenMs();
424
+ const startedAt = Date.now();
425
+ let currentStageIdx = 0;
426
+ onImageProgress({ percent: 0, stage: IMAGE_PROGRESS_STAGES[0].label });
427
+ const timer = setInterval(() => {
428
+ const elapsed = Date.now() - startedAt;
429
+ const progress = Math.min(elapsed / estimated, 1);
430
+ while (currentStageIdx < IMAGE_PROGRESS_STAGES.length - 1 && progress >= IMAGE_PROGRESS_STAGES[currentStageIdx + 1].at) {
431
+ currentStageIdx++;
432
+ }
433
+ const cur = IMAGE_PROGRESS_STAGES[currentStageIdx];
434
+ const next = IMAGE_PROGRESS_STAGES[currentStageIdx + 1] ?? { at: 1, pct: 95 };
435
+ const stageProgress = (progress - cur.at) / (next.at - cur.at);
436
+ const pct = Math.min(Math.round(cur.pct + stageProgress * (next.pct - cur.pct)), 95);
437
+ onImageProgress({ percent: pct, stage: cur.label });
438
+ }, 500);
439
+ return () => {
440
+ clearInterval(timer);
441
+ onImageProgress({ percent: 100, stage: "Done" });
442
+ };
443
+ }
444
+ // ---------------------------------------------------------------------------
445
+ // Unsplash hero image rewrite
446
+ // ---------------------------------------------------------------------------
447
+ export async function withUnsplashHeroImage(args) {
448
+ const lowerMessage = args.message.toLowerCase();
449
+ if (args.plan.intent !== "edit_plan")
450
+ return args.plan;
451
+ const explicitUnsplashRequest = lowerMessage.includes("unsplash");
452
+ const explicitImageGen = isExplicitImageGenRequest(args.message);
453
+ const userImagePrompt = extractImagePromptFromMessage(args.message);
454
+ args.log.info({
455
+ event: "hero_image_rewrite_start",
456
+ chatRequestId: args.chatRequestId,
457
+ slug: args.slug,
458
+ explicitUnsplashRequest,
459
+ explicitImageGen,
460
+ hasUserImagePrompt: Boolean(userImagePrompt),
461
+ message: args.message
462
+ }, "Evaluating hero image rewrite");
463
+ const plan = rewriteAddBlockToChildImageUpdate({
464
+ plan: structuredClone(args.plan),
465
+ message: args.message,
466
+ currentPage: args.currentPage,
467
+ slug: args.slug
468
+ });
469
+ let changed = false;
470
+ let placeholderSkipped = false;
471
+ let resolvedImageCount = 0;
472
+ let skippedImageCount = 0;
473
+ const globalUsedImageUrls = new Set();
474
+ let sourceQuery;
475
+ let imageSource = "placeholder";
476
+ const preferredQueries = new Map();
477
+ for (const item of args.preferredImageOps ?? []) {
478
+ const query = typeof item.query === "string" ? item.query.trim() : "";
479
+ if (!query)
480
+ continue;
481
+ const key = `${item.pageSlug}::${item.blockId}::${item.path ?? "imageUrl"}`;
482
+ preferredQueries.set(key, query);
483
+ }
484
+ for (const op of plan.ops) {
485
+ if (op.op !== "update_props" || op.pageSlug !== args.slug)
486
+ continue;
487
+ const target = args.currentPage.blocks.find((block) => block.id === op.blockId);
488
+ if (!target)
489
+ continue;
490
+ const rawPatch = op.patch;
491
+ const patchCandidate = rawPatch && typeof rawPatch.props === "object" && rawPatch.props !== null && !Array.isArray(rawPatch.props)
492
+ ? rawPatch.props
493
+ : rawPatch;
494
+ const touchesImage = detectImagePaths(patchCandidate).size > 0 ||
495
+ args.activeEditablePath === "imageUrl" ||
496
+ /\b(images?|photos?|pictures?)\b/.test(lowerMessage);
497
+ const userProvidedExplicitUrl = Boolean(firstUrlFromText(args.message));
498
+ const targets = findImageTargets({
499
+ message: args.message,
500
+ currentPage: args.currentPage,
501
+ targetBlock: target,
502
+ patchCandidate
503
+ });
504
+ // Filter out targets whose block schema doesn't support imageUrl at that path
505
+ const supportedTargets = targets.filter((t) => blockSupportsImageAtPath(target.type, t.path));
506
+ const unresolvedTargets = supportedTargets.filter((t) => !isRemoteHttpUrl(getValueAtPath(patchCandidate, t.path)));
507
+ const hasAnyImageTarget = supportedTargets.length > 0;
508
+ const hasImageUrlInPatch = detectImagePaths(patchCandidate).size > 0;
509
+ const shouldReplace = !userProvidedExplicitUrl && touchesImage && hasAnyImageTarget && unresolvedTargets.length > 0 && (explicitUnsplashRequest || hasImageUrlInPatch || explicitImageGen);
510
+ if (!touchesImage || !shouldReplace || unresolvedTargets.length === 0)
511
+ continue;
512
+ const targetProps = target.props;
513
+ const heading = typeof targetProps.heading === "string" ? targetProps.heading : "";
514
+ const subheading = typeof targetProps.subheading === "string" ? targetProps.subheading : "";
515
+ const title = typeof targetProps.title === "string" ? targetProps.title : "";
516
+ const body = typeof targetProps.body === "string" ? targetProps.body : "";
517
+ const sectionContext = [heading, subheading, title, body].filter(Boolean).join(" — ");
518
+ for (const targetImage of unresolvedTargets) {
519
+ const preferredKey = `${op.pageSlug}::${op.blockId}::${targetImage.path}`;
520
+ const imageQuery = preferredQueries.get(preferredKey) ?? targetImage.query;
521
+ const currentImageUrl = typeof getValueAtPath(targetProps, targetImage.path) === "string"
522
+ ? String(getValueAtPath(targetProps, targetImage.path))
523
+ : "";
524
+ let resolved = null;
525
+ if (!explicitUnsplashRequest && process.env.OPENAI_API_KEY) {
526
+ args.onStatusUpdate?.("Generating image\u2026");
527
+ let generatedPrompt;
528
+ let generatedAlt;
529
+ if (userImagePrompt) {
530
+ generatedAlt = userImagePrompt.slice(0, 200);
531
+ generatedPrompt = [
532
+ "Use case: website section image",
533
+ `Page: ${args.currentPage.title} (${args.slug})`,
534
+ `Section: ${target.type} — ${sectionContext}`,
535
+ userImagePrompt,
536
+ "Constraints: no text, no logos, no watermark"
537
+ ].join("\n");
538
+ }
539
+ else {
540
+ generatedAlt = `AI-generated ${target.type} image featuring ${imageQuery}`;
541
+ generatedPrompt = [
542
+ "Use case: website section image update",
543
+ `Page: ${args.currentPage.title} (${args.slug})`,
544
+ `Section: ${target.type} — ${sectionContext}`,
545
+ `Primary subject: ${imageQuery}`,
546
+ "Style: photorealistic editorial product photography",
547
+ "Composition: clean landscape frame with clear focal subject",
548
+ "Lighting: natural and vibrant",
549
+ "Constraints: no text, no logos, no watermark"
550
+ ].join("\n");
551
+ }
552
+ const stopProgress = startImageProgressTimer(args.onImageProgress);
553
+ const genStart = Date.now();
554
+ resolved = await generateVariationImageWithOpenAI({ prompt: generatedPrompt, altText: generatedAlt, log: args.log });
555
+ recordImageGenDuration(Date.now() - genStart);
556
+ stopProgress();
557
+ if (resolved)
558
+ imageSource = "ai-generated";
559
+ }
560
+ if (!resolved && (isGdriveConfigured() || args.gdriveFolderId)) {
561
+ args.onStatusUpdate?.("Searching brand images…");
562
+ resolved = await resolveGdriveImage(imageQuery, { chatRequestId: args.chatRequestId, logger: args.log, folderId: args.gdriveFolderId });
563
+ if (resolved)
564
+ imageSource = "gdrive";
565
+ }
566
+ if (!resolved) {
567
+ args.onStatusUpdate?.("Finding a suitable image...");
568
+ if (currentImageUrl)
569
+ globalUsedImageUrls.add(currentImageUrl);
570
+ const usedImageUrls = globalUsedImageUrls.size > 0 ? globalUsedImageUrls : undefined;
571
+ resolved = await resolveUnsplashImage(imageQuery, { subjectKeywords: imageKeywordsFromQuery(imageQuery, 4), usedImageUrls }, { chatRequestId: args.chatRequestId, logger: args.log });
572
+ if (resolved)
573
+ imageSource = resolved.url.includes("unsplash") ? "unsplash" : "placeholder";
574
+ }
575
+ if (!resolved || (imageSource === "placeholder" && !explicitUnsplashRequest && !explicitImageGen && !userImagePrompt)) {
576
+ deleteValueAtPath(patchCandidate, targetImage.path);
577
+ deleteValueAtPath(patchCandidate, targetImage.altPath);
578
+ args.log.warn({ event: "image_rewrite_skip_placeholder", chatRequestId: args.chatRequestId, query: imageQuery, path: targetImage.path }, "Skipping placeholder image — no relevant image source available");
579
+ placeholderSkipped = true;
580
+ skippedImageCount++;
581
+ continue;
582
+ }
583
+ setValueAtPath(patchCandidate, targetImage.path, resolved.url);
584
+ const existingAlt = getValueAtPath(patchCandidate, targetImage.altPath);
585
+ const nextAlt = preferredImageAltText({
586
+ query: imageQuery,
587
+ resolvedAlt: resolved.alt,
588
+ existingAlt: typeof existingAlt === "string" ? existingAlt : undefined
589
+ });
590
+ if (nextAlt.trim().length > 0)
591
+ setValueAtPath(patchCandidate, targetImage.altPath, nextAlt);
592
+ sourceQuery = resolved.query;
593
+ args.log.info({
594
+ event: "image_rewrite_applied",
595
+ chatRequestId: args.chatRequestId,
596
+ slug: args.slug,
597
+ blockId: op.blockId,
598
+ query: imageQuery,
599
+ explicitUnsplashRequest,
600
+ path: targetImage.path,
601
+ nextImageUrl: resolved.url,
602
+ nextImageAlt: getValueAtPath(patchCandidate, targetImage.altPath)
603
+ }, "Applied image rewrite");
604
+ changed = true;
605
+ resolvedImageCount++;
606
+ globalUsedImageUrls.add(resolved.url);
607
+ }
608
+ op.patch = patchCandidate;
609
+ }
610
+ // For create_page ops, keep the default placeholder image and let the user
611
+ // decide whether to generate an AI hero image via a suggestion pill.
612
+ // (Previously this auto-generated an AI image which was slow and often unwanted.)
613
+ const planAlreadyHasResolvedImage = plan.ops.some((op) => {
614
+ if (op.op !== "update_props")
615
+ return false;
616
+ const patch = op.patch;
617
+ const patchCandidate = patch && typeof patch.props === "object" && patch.props !== null && !Array.isArray(patch.props)
618
+ ? patch.props
619
+ : patch;
620
+ return patchContainsResolvedImageUrl(patchCandidate);
621
+ });
622
+ if (!changed && !planAlreadyHasResolvedImage && (explicitUnsplashRequest || explicitImageGen) && /\b(images?|photos?|pictures?|hero)\b/.test(lowerMessage)) {
623
+ const selectedBlock = args.activeBlockId && args.currentPage.blocks.find((block) => block.id === args.activeBlockId)
624
+ ? args.currentPage.blocks.find((block) => block.id === args.activeBlockId)
625
+ : null;
626
+ const fallbackHero = blockHasImageUrlProp(selectedBlock)
627
+ ? selectedBlock
628
+ : args.currentPage.blocks.find((block) => blockHasImageUrlProp(block)) ?? null;
629
+ if (fallbackHero) {
630
+ const query = heroImageQueryFromContext({
631
+ message: args.message,
632
+ currentPage: args.currentPage,
633
+ targetBlock: fallbackHero
634
+ });
635
+ let resolved = null;
636
+ if (!explicitUnsplashRequest && process.env.OPENAI_API_KEY && (explicitImageGen || userImagePrompt)) {
637
+ args.onStatusUpdate?.("Generating image\u2026");
638
+ const targetProps = fallbackHero.props;
639
+ const heading = typeof targetProps.heading === "string" ? targetProps.heading : "";
640
+ const subheading = typeof targetProps.subheading === "string" ? targetProps.subheading : "";
641
+ const sectionContext = [heading, subheading].filter(Boolean).join(" — ");
642
+ let generatedPrompt;
643
+ let generatedAlt;
644
+ if (userImagePrompt) {
645
+ generatedAlt = userImagePrompt.slice(0, 200);
646
+ generatedPrompt = [
647
+ "Use case: website section image",
648
+ `Page: ${args.currentPage.title} (${args.slug})`,
649
+ `Section: ${fallbackHero.type} — ${sectionContext}`,
650
+ userImagePrompt,
651
+ "Constraints: no text, no logos, no watermark"
652
+ ].join("\n");
653
+ }
654
+ else {
655
+ generatedAlt = `AI-generated ${fallbackHero.type} image featuring ${query}`;
656
+ generatedPrompt = [
657
+ "Use case: website section image update",
658
+ `Page: ${args.currentPage.title} (${args.slug})`,
659
+ `Section: ${fallbackHero.type} — ${sectionContext}`,
660
+ `Primary subject: ${query}`,
661
+ "Style: photorealistic editorial product photography",
662
+ "Composition: clean landscape frame with clear focal subject",
663
+ "Lighting: natural and vibrant",
664
+ "Constraints: no text, no logos, no watermark"
665
+ ].join("\n");
666
+ }
667
+ const stopProgress = startImageProgressTimer(args.onImageProgress);
668
+ const genStart = Date.now();
669
+ resolved = await generateVariationImageWithOpenAI({ prompt: generatedPrompt, altText: generatedAlt, log: args.log });
670
+ recordImageGenDuration(Date.now() - genStart);
671
+ stopProgress();
672
+ if (resolved)
673
+ imageSource = "ai-generated";
674
+ }
675
+ if (!resolved && (isGdriveConfigured() || args.gdriveFolderId)) {
676
+ args.onStatusUpdate?.("Searching brand images…");
677
+ resolved = await resolveGdriveImage(query, { chatRequestId: args.chatRequestId, logger: args.log, folderId: args.gdriveFolderId });
678
+ if (resolved)
679
+ imageSource = "gdrive";
680
+ }
681
+ if (!resolved) {
682
+ args.onStatusUpdate?.("Finding a suitable image...");
683
+ const fbProps = fallbackHero.props;
684
+ const fbCurrentUrl = typeof fbProps.imageUrl === "string" ? fbProps.imageUrl : "";
685
+ const fbUsedUrls = fbCurrentUrl ? new Set([fbCurrentUrl]) : undefined;
686
+ resolved = await resolveUnsplashImage(query, { subjectKeywords: imageKeywordsFromQuery(query, 4), usedImageUrls: fbUsedUrls }, { chatRequestId: args.chatRequestId, logger: args.log });
687
+ if (resolved)
688
+ imageSource = resolved.url.includes("unsplash") ? "unsplash" : "placeholder";
689
+ }
690
+ // Don't push a new op just to insert a random placeholder
691
+ if (!resolved || imageSource === "placeholder") {
692
+ placeholderSkipped = true;
693
+ }
694
+ else {
695
+ plan.ops.push({
696
+ op: "update_props",
697
+ pageSlug: args.slug,
698
+ blockId: fallbackHero.id,
699
+ patch: { imageUrl: resolved.url, imageAlt: preferredImageAltText({ query, resolvedAlt: resolved.alt }) }
700
+ });
701
+ sourceQuery = resolved.query;
702
+ changed = true;
703
+ }
704
+ }
705
+ }
706
+ // Remove update_props ops left with empty patches after image field stripping
707
+ if (placeholderSkipped) {
708
+ plan.ops = plan.ops.filter((op) => {
709
+ if (op.op !== "update_props")
710
+ return true;
711
+ const patch = op.patch;
712
+ const inner = patch && typeof patch.props === "object" && patch.props !== null && !Array.isArray(patch.props)
713
+ ? patch.props
714
+ : patch;
715
+ return Object.keys(inner).filter((k) => k !== "props").length > 0;
716
+ });
717
+ }
718
+ if (placeholderSkipped && !changed) {
719
+ plan.change_log = [
720
+ ...plan.change_log,
721
+ "Could not find a matching image — configure UNSPLASH_ACCESS_KEY for relevant image search."
722
+ ];
723
+ }
724
+ if (changed) {
725
+ const countLabel = resolvedImageCount > 1 ? `${resolvedImageCount} matching images` : "a matching image";
726
+ const sourceLabel = imageSource === "ai-generated"
727
+ ? (resolvedImageCount > 1 ? `Generated ${resolvedImageCount} images with AI` : "Generated a new image with AI")
728
+ : imageSource === "gdrive"
729
+ ? `Found ${countLabel} from Google Drive`
730
+ : imageSource === "unsplash"
731
+ ? `Found ${countLabel} from Unsplash`
732
+ : "Set Hero image from placeholder";
733
+ plan.change_log = [...plan.change_log, `${sourceLabel}.`];
734
+ if (skippedImageCount > 0) {
735
+ plan.change_log = [...plan.change_log, `${skippedImageCount} image${skippedImageCount > 1 ? "s" : ""} could not be resolved.`];
736
+ }
737
+ // Rewrite summary to not mislead about the actual image source
738
+ plan.summary_for_user = plan.summary_for_user
739
+ .replace(/\bUnsplash\s+/gi, "")
740
+ .replace(/\bfrom unsplash\b/gi, "")
741
+ .replace(/\ban?\s+unsplash\b/gi, "a new");
742
+ }
743
+ else {
744
+ args.log.info({
745
+ event: "image_rewrite_skipped",
746
+ chatRequestId: args.chatRequestId,
747
+ slug: args.slug,
748
+ explicitUnsplashRequest,
749
+ message: args.message
750
+ }, "Skipped image rewrite");
751
+ }
752
+ return plan;
753
+ }
754
+ export function shouldResolveCreatePageHeroImage(imageUrl) {
755
+ const normalized = imageUrl.trim();
756
+ if (!normalized)
757
+ return true;
758
+ return !/^https?:\/\//i.test(normalized);
759
+ }
760
+ /**
761
+ * Resolve a Hero image for a deferred create_page op.
762
+ * Tries DALL-E first, then falls back to Unsplash.
763
+ */
764
+ export async function resolveHeroImageForCreatePage(args) {
765
+ if (process.env.OPENAI_API_KEY) {
766
+ args.onStatusUpdate?.("Generating image\u2026");
767
+ const generatedAlt = `AI-generated hero image featuring ${args.query}`;
768
+ const generatedPrompt = [
769
+ "Use case: website hero image for a new page",
770
+ `Page: ${args.pageTitle} (${args.pageSlug})`,
771
+ `Section: Hero — ${args.sectionContext}`,
772
+ `Primary subject: ${args.query}`,
773
+ "Style: photorealistic editorial product photography",
774
+ "Composition: clean landscape frame with clear focal subject",
775
+ "Lighting: natural and vibrant",
776
+ "Constraints: no text, no logos, no watermark"
777
+ ].join("\n");
778
+ const stopProgress = startImageProgressTimer(args.onImageProgress);
779
+ const genStart = Date.now();
780
+ const resolved = await generateVariationImageWithOpenAI({ prompt: generatedPrompt, altText: generatedAlt, log: args.log });
781
+ recordImageGenDuration(Date.now() - genStart);
782
+ stopProgress();
783
+ if (resolved) {
784
+ return { url: resolved.url, alt: resolved.alt, source: "ai-generated" };
785
+ }
786
+ }
787
+ if (isGdriveConfigured() || args.gdriveFolderId) {
788
+ args.onStatusUpdate?.("Searching brand images…");
789
+ const gdriveResult = await resolveGdriveImage(args.query, { chatRequestId: args.chatRequestId, logger: args.log, folderId: args.gdriveFolderId });
790
+ if (gdriveResult) {
791
+ return { url: gdriveResult.url, alt: gdriveResult.alt, source: "gdrive" };
792
+ }
793
+ }
794
+ args.onStatusUpdate?.("Finding image for new page...");
795
+ const resolved = await resolveUnsplashImage(args.query, { subjectKeywords: imageKeywordsFromQuery(args.query, 4) }, { chatRequestId: args.chatRequestId, logger: args.log });
796
+ if (resolved && resolved.url.includes("unsplash")) {
797
+ return { url: resolved.url, alt: resolved.alt, source: "unsplash" };
798
+ }
799
+ return null;
800
+ }
801
+ // ---------------------------------------------------------------------------
802
+ // Synchronous image-op detection (no API calls)
803
+ // ---------------------------------------------------------------------------
804
+ export function detectImageOps(args) {
805
+ // Demo mode: image generation is disabled entirely. A failed DALL-E call
806
+ // or a 10s Unsplash lookup is the worst possible first-impression UX, so
807
+ // we short-circuit here before any external API is touched.
808
+ if (isDemoImageGenActive())
809
+ return [];
810
+ const lowerMessage = args.message.toLowerCase();
811
+ if (args.plan.intent !== "edit_plan")
812
+ return [];
813
+ // Translation requests don't need image resolution — skip to avoid forcing approval mode
814
+ if (inferTranslationScopeFromMessage(args.message) !== "none")
815
+ return [];
816
+ const explicitUnsplashRequest = lowerMessage.includes("unsplash");
817
+ const explicitGdriveRequest = /\b(drive|brand\s+(?:assets?|images?|photos?)|our\s+(?:photos?|images?|folder)|company\s+(?:images?|photos?))\b/.test(lowerMessage);
818
+ const explicitImageGen = isExplicitImageGenRequest(args.message);
819
+ const results = [];
820
+ for (const op of args.plan.ops) {
821
+ if (op.op !== "update_props" || op.pageSlug !== args.slug)
822
+ continue;
823
+ const target = args.currentPage.blocks.find((block) => block.id === op.blockId);
824
+ if (!target)
825
+ continue;
826
+ const rawPatch = op.patch;
827
+ const patchCandidate = rawPatch && typeof rawPatch.props === "object" && rawPatch.props !== null && !Array.isArray(rawPatch.props)
828
+ ? rawPatch.props
829
+ : rawPatch;
830
+ const touchesImage = detectImagePaths(patchCandidate).size > 0 ||
831
+ args.activeEditablePath === "imageUrl" ||
832
+ /\b(images?|photos?|pictures?)\b/.test(lowerMessage);
833
+ const userProvidedExplicitUrl = Boolean(firstUrlFromText(args.message));
834
+ const targets = findImageTargets({
835
+ message: args.message,
836
+ currentPage: args.currentPage,
837
+ targetBlock: target,
838
+ patchCandidate
839
+ });
840
+ const hasImageUrlInPatch = detectImagePaths(patchCandidate).size > 0;
841
+ const shouldReplace = !userProvidedExplicitUrl &&
842
+ touchesImage &&
843
+ targets.length > 0 &&
844
+ (explicitUnsplashRequest || hasImageUrlInPatch || explicitImageGen || explicitGdriveRequest);
845
+ if (!touchesImage || !shouldReplace || targets.length === 0)
846
+ continue;
847
+ const provider = explicitGdriveRequest && isGdriveConfigured() ? "gdrive"
848
+ : explicitUnsplashRequest ? "unsplash"
849
+ : process.env.OPENAI_API_KEY ? "auto"
850
+ : "unsplash";
851
+ for (const targetImage of targets) {
852
+ if (!blockSupportsImageAtPath(target.type, targetImage.path))
853
+ continue;
854
+ if (isRemoteHttpUrl(getValueAtPath(patchCandidate, targetImage.path)))
855
+ continue;
856
+ results.push({
857
+ blockId: op.blockId,
858
+ pageSlug: op.pageSlug,
859
+ path: targetImage.path,
860
+ altPath: targetImage.altPath,
861
+ query: targetImage.query,
862
+ provider
863
+ });
864
+ }
865
+ }
866
+ // Fallback: explicit image request targeting a Hero block when no ops matched
867
+ const planAlreadyHasResolvedImage = args.plan.ops.some((op) => {
868
+ if (op.op !== "update_props")
869
+ return false;
870
+ const patch = op.patch;
871
+ const patchCandidate = patch && typeof patch.props === "object" && patch.props !== null && !Array.isArray(patch.props)
872
+ ? patch.props
873
+ : patch;
874
+ return patchContainsResolvedImageUrl(patchCandidate);
875
+ });
876
+ if (results.length === 0 && !planAlreadyHasResolvedImage && (explicitUnsplashRequest || explicitImageGen) && /\b(images?|photos?|pictures?|hero)\b/.test(lowerMessage)) {
877
+ const selectedBlock = args.activeBlockId && args.currentPage.blocks.find((block) => block.id === args.activeBlockId)
878
+ ? args.currentPage.blocks.find((block) => block.id === args.activeBlockId)
879
+ : null;
880
+ const fallbackHero = blockHasImageUrlProp(selectedBlock)
881
+ ? selectedBlock
882
+ : args.currentPage.blocks.find((block) => blockHasImageUrlProp(block)) ?? null;
883
+ if (fallbackHero) {
884
+ const query = heroImageQueryFromContext({
885
+ message: args.message,
886
+ currentPage: args.currentPage,
887
+ targetBlock: fallbackHero
888
+ });
889
+ const provider = explicitGdriveRequest && isGdriveConfigured() ? "gdrive"
890
+ : explicitUnsplashRequest ? "unsplash"
891
+ : process.env.OPENAI_API_KEY ? "auto"
892
+ : "unsplash";
893
+ results.push({ blockId: fallbackHero.id, pageSlug: args.slug, query, provider });
894
+ }
895
+ }
896
+ return results;
897
+ }