create-nextblock 0.12.16 → 0.13.2

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 (45) hide show
  1. package/package.json +1 -1
  2. package/templates/nextblock-template/app/actions/interactions.ts +27 -4
  3. package/templates/nextblock-template/app/api/ai/global-agent/route.ts +287 -48
  4. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +238 -209
  5. package/templates/nextblock-template/app/cms/blocks/components/BackgroundSelector.tsx +103 -8
  6. package/templates/nextblock-template/app/cms/blocks/components/BlockEditorArea.tsx +31 -2
  7. package/templates/nextblock-template/app/cms/blocks/components/ColumnEditor.tsx +37 -15
  8. package/templates/nextblock-template/app/cms/blocks/components/EditableBlock.tsx +26 -15
  9. package/templates/nextblock-template/app/cms/blocks/editors/ImageBlockEditor.tsx +123 -46
  10. package/templates/nextblock-template/app/cms/blocks/editors/SectionBlockEditor.tsx +8 -1
  11. package/templates/nextblock-template/app/cms/components/CortexGlobalAgentChat.tsx +62 -22
  12. package/templates/nextblock-template/app/cms/custom-blocks/components/BlockComposer.tsx +40 -2
  13. package/templates/nextblock-template/app/cms/interactions/EmailRecipientsInput.tsx +189 -0
  14. package/templates/nextblock-template/app/cms/interactions/InteractionsModerationClient.tsx +138 -71
  15. package/templates/nextblock-template/app/cms/media/import-external-image.ts +289 -0
  16. package/templates/nextblock-template/app/cms/pages/[id]/edit/EditPageClient.tsx +13 -10
  17. package/templates/nextblock-template/app/cms/pages/[id]/edit/page.tsx +14 -3
  18. package/templates/nextblock-template/app/cms/pages/actions.ts +59 -6
  19. package/templates/nextblock-template/app/cms/posts/[id]/edit/page.tsx +21 -11
  20. package/templates/nextblock-template/app/cms/posts/actions.ts +45 -0
  21. package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +11 -9
  22. package/templates/nextblock-template/app/cms/settings/cortex-ai/StoredCortexAiSettingsClient.tsx +463 -227
  23. package/templates/nextblock-template/app/cms/settings/cortex-ai/actions.ts +220 -1
  24. package/templates/nextblock-template/app/cms/settings/cortex-ai/page.tsx +11 -0
  25. package/templates/nextblock-template/app/cms/users/[id]/edit/page.tsx +20 -1
  26. package/templates/nextblock-template/app/cms/users/actions.ts +69 -0
  27. package/templates/nextblock-template/app/cms/users/components/CreateUserForm.tsx +217 -0
  28. package/templates/nextblock-template/app/cms/users/components/UserForm.tsx +4 -1
  29. package/templates/nextblock-template/app/cms/users/new/page.tsx +44 -0
  30. package/templates/nextblock-template/app/cms/users/page.tsx +12 -3
  31. package/templates/nextblock-template/app/lib/homepage.ts +36 -0
  32. package/templates/nextblock-template/app/lib/sitemap-utils.ts +13 -6
  33. package/templates/nextblock-template/app/page.tsx +55 -12
  34. package/templates/nextblock-template/components/blocks/renderers/ImageBlockRenderer.tsx +56 -0
  35. package/templates/nextblock-template/components/blocks/renderers/SectionBlockRenderer.tsx +60 -30
  36. package/templates/nextblock-template/components/blocks/renderers/StockPhotoCredit.tsx +167 -0
  37. package/templates/nextblock-template/docs/08-NEXTBLOCK-CORTEX-AI-ARCHITECTURE.md +94 -6
  38. package/templates/nextblock-template/docs/09-LIVE-DRAFT-MODE.md +7 -1
  39. package/templates/nextblock-template/lib/blocks/blockRegistry.ts +29 -3
  40. package/templates/nextblock-template/lib/search/server.ts +11 -1
  41. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +82 -72
  42. package/templates/nextblock-template/next-env.d.ts +1 -1
  43. package/templates/nextblock-template/package.json +1 -1
  44. package/templates/nextblock-template/proxy.ts +5 -0
  45. package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-nextblock",
3
- "version": "0.12.16",
3
+ "version": "0.13.2",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -348,12 +348,35 @@ export async function saveNotificationEmails(emails: string) {
348
348
  return { error: "Unauthorized. Admin role required." };
349
349
  }
350
350
 
351
- // Basic validation of emails
352
- const cleaned = emails
351
+ // Validate every address, dedupe (case-insensitive), and normalize to lowercase.
352
+ // Mirrors the client-side check so a crafted/legacy payload can't persist junk.
353
+ const emailRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
354
+ const tokens = emails
353
355
  .split(",")
354
356
  .map((e) => e.trim())
355
- .filter(Boolean)
356
- .join(", ");
357
+ .filter(Boolean);
358
+
359
+ const seen = new Set<string>();
360
+ const valid: string[] = [];
361
+ const invalid: string[] = [];
362
+ for (const token of tokens) {
363
+ const lower = token.toLowerCase();
364
+ if (!emailRe.test(lower)) {
365
+ invalid.push(token);
366
+ continue;
367
+ }
368
+ if (seen.has(lower)) continue;
369
+ seen.add(lower);
370
+ valid.push(lower);
371
+ }
372
+
373
+ if (invalid.length > 0) {
374
+ return {
375
+ error: `Invalid email address${invalid.length > 1 ? "es" : ""}: ${invalid.join(", ")}`,
376
+ };
377
+ }
378
+
379
+ const cleaned = valid.join(", ");
357
380
 
358
381
  try {
359
382
  const { error } = await supabase
@@ -21,6 +21,9 @@ import {
21
21
  executeDeleteCmsItem,
22
22
  executeDeleteCustomBlock,
23
23
  executeInsertContentBlock,
24
+ executeRewritePageDraft,
25
+ executeSetContentImages,
26
+ executeTranslatePage,
24
27
  executeUpdateContentBlock,
25
28
  executeUpdateCmsItemField,
26
29
  executeUpdateCurrentCmsFields,
@@ -29,16 +32,45 @@ import {
29
32
  executeUpdateSectionColumnBlock,
30
33
  isOpenRouterRateLimitError,
31
34
  omitUnsupportedCortexAiModelOptions,
35
+ resolveCortexAiAgentSettings,
36
+ resolveCortexAiStockPhotoProvider,
32
37
  safeParseCortexAiModelSelection,
33
38
  summarizeCortexAiRoutingError,
34
39
  type CortexAiPageContext,
35
40
  z,
36
41
  } from '@nextblock-cms/cortex';
37
42
  import { validateBlockContent } from '../../../../lib/blocks/blockRegistry';
43
+ import { importExternalImageToMedia } from '../../../cms/media/import-external-image';
38
44
 
39
45
  export const dynamic = 'force-dynamic';
40
46
 
41
- const GLOBAL_AGENT_MODEL_ATTEMPT_TIMEOUT_MS = 30000;
47
+ // Idle (not absolute) timeout: the attempt is only aborted after this many ms
48
+ // with NO stream activity. A slow-but-progressing generation (e.g. building a
49
+ // full multi-section page, which streams tool-input deltas for a while) keeps
50
+ // resetting the timer instead of being killed mid-answer.
51
+ const GLOBAL_AGENT_MODEL_IDLE_TIMEOUT_MS = 120000;
52
+
53
+ // Heartbeat sent to the browser every few seconds while an attempt streams, so a
54
+ // long tool-call generation (which produces no client-facing events) keeps the
55
+ // client's idle timer alive and shows a "working" indicator instead of looking
56
+ // frozen.
57
+ const GLOBAL_AGENT_HEARTBEAT_INTERVAL_MS = 5000;
58
+
59
+ // Bridges the app-side media importer (sharp + storage, request-scoped auth) into
60
+ // the Cortex tool context so image tools can turn an external URL (e.g. a stock
61
+ // photo) into a media library id for feature_image_id / product_media.
62
+ async function importExternalImageForCortex(input: {
63
+ url: string;
64
+ altText?: string;
65
+ }): Promise<{ id: string } | { error: string }> {
66
+ const result = await importExternalImageToMedia({ altText: input.altText, url: input.url });
67
+
68
+ if ('error' in result) {
69
+ return { error: result.error };
70
+ }
71
+
72
+ return { id: result.media.id };
73
+ }
42
74
 
43
75
  const globalAgentMessageSchema = z.strictObject({
44
76
  content: z.string().min(1).max(8000),
@@ -58,6 +90,9 @@ const confirmedToolCallSchema = z.strictObject({
58
90
  'execute_database_mutation',
59
91
  'execute_cms_action_plan',
60
92
  'insert_content_block',
93
+ 'rewrite_page_draft',
94
+ 'set_content_images',
95
+ 'translate_page',
61
96
  'update_cms_item_field',
62
97
  'update_content_block',
63
98
  'update_current_cms_fields',
@@ -103,6 +138,19 @@ const GLOBAL_AGENT_SYSTEM_PROMPT = [
103
138
  'Use describe_database_schema, read_database_records, execute_database_mutation, and execute_database_action_plan for direct database tasks that are not covered by a more specific CMS tool. Use typed CRUD tools only; never ask for or invent raw SQL.',
104
139
  'For direct database mutations, always return the confirmation preview first. Never claim a database mutation is complete until the confirmed tool result has mutationExecuted=true. Do not edit auth users, profiles, user addresses, password fields, API keys, tokens, secrets, private keys, credentials, or the cortex_ai_openrouter_api_key site setting.',
105
140
  'Use fetch_ecommerce_stats for quantitative questions about revenue, products, or order counts. This tool is read-only.',
141
+ 'PAGE DESIGN: when building or redesigning a page layout (a landing page, home page, hero, or marketing sections), compose it from section blocks. A section is a full-width horizontal band; place nested heading, text, button, and image blocks inside its column_blocks, which is an array of columns (each column is a list of blocks).',
142
+ 'You only need to supply each section\'s column_blocks plus intent: set is_hero:true on the first/hero section and optionally a background such as { "type": "gradient" } or { "type": "theme", "theme": "primary" } or { "type": "theme", "theme": "muted" }. Cortex fills every other layout field (container, padding, gaps, responsive columns, alignment) with good defaults and sets the grid to the number of columns you provide, so provide exactly as many columns as you want across.',
143
+ 'For an attractive page: make the hero one centered column with a level-1 heading, a short text paragraph, and a button, over a gradient or theme:"primary" background. Then alternate section backgrounds for rhythm (none, then theme:"muted", then none, then a theme:"primary" call-to-action band). Use a 3-column section for feature cards and a 4-column section for stats.',
144
+ 'Use discrete heading blocks for headings (not <h2> inside text HTML). A text block\'s html_content accepts rich HTML with inline styles and <style> tags, so use a text block for any fully custom styled section.',
145
+ 'IMAGES: for real photos, call search_stock_photos with a short descriptive query (e.g. "herbal supplements") and use a returned photo `url`. Put it into an image block as { "external_url": "<url>", "alt_text": "..." }, or into a section image background as { "type": "image", "image": { "external_url": "<url>", "size": "cover", "position": "center", "alt_text": "..." } } — great for hero backgrounds; pair a hero image background with a dark gradient overlay so text stays legible. Prefer landscape orientation for backgrounds. If no stock provider is configured or you have no real image URL, use gradient or theme backgrounds instead of image backgrounds; never invent an image URL or a media_id.',
146
+ 'STOCK PHOTO ATTRIBUTION (required): whenever you use a photo from search_stock_photos, copy that photo\'s attribution fields verbatim into the same image content as `attribution`: { "provider": "...", "photographer": "...", "photographerUrl": "...", "sourceUrl": "...", "downloadLocation": "...", "utmSource": "..." }. For an image block also set `caption` to the photo\'s `credit` string. Keep stock photos hotlinked (put `url` in external_url); never save an Unsplash photo to the media library. Unsplash attribution is mandatory; Pexels is recommended.',
147
+ 'To rewrite or redesign an ENTIRE existing page or post (for example "rewrite my home page with 5 sections"), use rewrite_page_draft with the COMPLETE new list of top-level blocks (usually section blocks). This stages a Live Draft the user previews and publishes; it does not overwrite the live page and is fully reversible, so prefer it over deleting and recreating a page. You may call read_current_cms_item first to see the current structure. For a brand-new page from scratch, use create_cms_page (up to 20 blocks).',
148
+ 'When the user references an external website or URL to base content on (for example "based on https://example.com"), call fetch_url_content with that URL FIRST to read its title, description, headings, and body text, then use that material to write the new sections. Never invent facts about an external site you have not fetched.',
149
+ 'A typical "rewrite my home page based on <url>" request is: (1) fetch_url_content(<url>); (2) search_stock_photos for imagery; (3) design a hero plus several content sections from the fetched content following the PAGE DESIGN rules; (4) call rewrite_page_draft for the home page with all the new section blocks. Then tell the user to preview and publish the draft.',
150
+ 'IMPORTANT: never stop after only reading a URL or searching photos — those are preparation steps. In the SAME turn, always continue and call the block-building tool (rewrite_page_draft for a full-page rewrite, or create_cms_page for a new page) to actually build the page. Fetching and searching alone accomplish nothing the user asked for.',
151
+ 'TRANSLATION: to translate the CURRENT page or post into another language (e.g. "translate this page to French"), use the translate_page tool — NOT rewrite_page_draft, NOT create_cms_page, and NEVER search_stock_photos (a translation reuses the same layout and images). First call read_current_cms_item with includeBlockContent to see the exact source text, then call translate_page with targetLanguageCode (e.g. "fr") and a `translations` map of EVERY visible source string to its translation: headings, paragraph and HTML text, button labels, image alt text, captions, and form labels. translate_page copies the page structure and images automatically and links the new page to the original as a translation — you only supply the text translations.',
152
+ 'IMAGES: to set a page or post FEATURE image, or a PRODUCT\'s images, call set_content_images with `images` — a list of image URLs (use the `url` values from search_stock_photos) and/or existing media library ids. The FIRST image is the feature image (pages/posts) or the main product image (products); for a product the remaining images become its gallery in order. External URLs are imported into the media library automatically. NEVER put an image URL into feature_image_id (it is a media id, not a URL). A section hero/background image is different — that belongs to the section block and is set with update_content_block or update_section_column_block using an external image URL, not set_content_images.',
153
+ 'The home page is the page whose slug is "home" (served at "/"). When the user says "my home page" and no page context is supplied, target rewrite_page_draft with contentType "page" and slug "home".',
106
154
  'For order-status questions like "how many pending orders" or "how many trial orders", use the tool result report.matchingOrderStatus or report.orderStatusCounts, and use all_time unless the user names a specific time period.',
107
155
  'Never invent database fields, raw SQL, markdown content, or unsupported tool arguments.',
108
156
  ].join(' ');
@@ -139,6 +187,9 @@ type CortexAgentStreamEvent =
139
187
  message: string;
140
188
  type: 'error';
141
189
  }
190
+ | {
191
+ type: 'status';
192
+ }
142
193
  | {
143
194
  type: 'finish';
144
195
  };
@@ -156,22 +207,37 @@ type CortexAgentStreamPart = {
156
207
 
157
208
  async function requireAdminAccess() {
158
209
  const supabase = createClient();
159
- const {
160
- data: { user },
161
- error: userError,
162
- } = await supabase.auth.getUser();
163
210
 
164
- if (userError || !user) {
211
+ // This route has no middleware refreshing the session per request, and getUser()
212
+ // only validates the *current* access token — it does not refresh an expired one.
213
+ // So when the access token lapses between two requests (classically: a tool-call
214
+ // preview and its confirmation a minute or two later), getUser() fails and a
215
+ // legitimate admin gets a spurious 403 ("You do not have permission…"). getSession()
216
+ // refreshes an expired token from the refresh-token cookie and persists the rotated
217
+ // token via setAll (this handler runs it before the stream response is returned, so
218
+ // the Set-Cookie still lands), and we retry once to smooth over a transient read.
219
+ let userId: string | null = null;
220
+
221
+ for (let attempt = 0; attempt < 2 && !userId; attempt += 1) {
222
+ await supabase.auth.getSession().catch(() => undefined);
223
+ const { data, error } = await supabase.auth.getUser();
224
+
225
+ if (!error && data?.user) {
226
+ userId = data.user.id;
227
+ }
228
+ }
229
+
230
+ if (!userId) {
165
231
  return null;
166
232
  }
167
233
 
168
234
  const { data: profile, error: profileError } = await supabase
169
235
  .from('profiles')
170
236
  .select('role')
171
- .eq('id', user.id)
237
+ .eq('id', userId)
172
238
  .single();
173
239
 
174
- return !profileError && profile?.role === 'ADMIN' ? { userId: user.id } : null;
240
+ return !profileError && profile?.role === 'ADMIN' ? { userId } : null;
175
241
  }
176
242
 
177
243
  function jsonError(message: string, status: number) {
@@ -204,10 +270,16 @@ function formatPageContextForPrompt(pageContext: CortexAiPageContext | null | un
204
270
  .join(', ');
205
271
  }
206
272
 
207
- function buildGlobalAgentSystemPrompt(pageContext: CortexAiPageContext | null | undefined) {
273
+ function buildGlobalAgentSystemPrompt(
274
+ pageContext: CortexAiPageContext | null | undefined,
275
+ stockPhotoProvider: { provider: string } | null
276
+ ) {
208
277
  return [
209
278
  GLOBAL_AGENT_SYSTEM_PROMPT,
210
279
  formatPageContextForPrompt(pageContext),
280
+ stockPhotoProvider
281
+ ? `Stock photos ARE available (provider: ${stockPhotoProvider.provider}). Use search_stock_photos for real hero and section imagery.`
282
+ : 'No stock photo provider is configured, so DO NOT call search_stock_photos. Use gradient or theme section backgrounds instead, and only add image blocks or image backgrounds when the user supplies an image URL.',
211
283
  'When the user says "this page", "this post", "this product", "this field", or "this block", interpret that through the supplied current CMS edit context.',
212
284
  'Do not update content outside the supplied current CMS context.',
213
285
  ].join(' ');
@@ -227,25 +299,20 @@ function getToolCallId(part: CortexAgentStreamPart) {
227
299
  function looksLikeRawToolCallLeak(value: string) {
228
300
  const normalized = value.toLowerCase();
229
301
 
302
+ // Only treat STRUCTURAL markers of a raw tool-call payload as a leak. The
303
+ // previous version flagged any prose that merely quoted a tool name or the
304
+ // bare word "arguments", which discarded legitimate summaries (e.g. a model
305
+ // describing what read_current_cms_item returned) and fell through to a
306
+ // canned "interrupted" message. Genuine leaked payloads carry a <toolcall>
307
+ // wrapper or a JSON object with both "name" and "arguments" keys.
230
308
  return (
231
309
  normalized.includes('<toolcall') ||
232
310
  normalized.includes('</toolcall') ||
233
- normalized.includes('"arguments"') ||
234
- normalized.includes('"update_navigation_bar"') ||
235
- normalized.includes('"update_footer"') ||
236
- normalized.includes('"search_documentation"') ||
237
- normalized.includes('"read_current_cms_item"') ||
238
- normalized.includes('"update_current_cms_fields"') ||
239
- normalized.includes('"update_cms_item_field"') ||
240
- normalized.includes('"update_content_block"') ||
241
- normalized.includes('"insert_content_block"') ||
242
- normalized.includes('"update_section_column_block"') ||
243
- normalized.includes('"create_cms_page"') ||
244
- normalized.includes('"create_cms_post"') ||
245
- normalized.includes('"create_cms_product"') ||
246
- normalized.includes('"execute_cms_action_plan"') ||
247
- normalized.includes('"prepare_delete_cms_item"') ||
248
- normalized.includes('"delete_cms_item"')
311
+ normalized.includes('<tool_call') ||
312
+ normalized.includes('</tool_call') ||
313
+ normalized.includes('<function_call') ||
314
+ (normalized.includes('"arguments"') &&
315
+ (normalized.includes('"name"') || normalized.includes('"tool"')))
249
316
  );
250
317
  }
251
318
 
@@ -355,6 +422,80 @@ function getConfirmationSummary(toolName?: string, output?: unknown) {
355
422
  return 'Complete the requested CMS change.';
356
423
  }
357
424
 
425
+ function describeBlockTypeCounts(blocks: unknown) {
426
+ if (!Array.isArray(blocks) || blocks.length === 0) {
427
+ return '';
428
+ }
429
+
430
+ const counts = new Map<string, number>();
431
+
432
+ for (const block of blocks) {
433
+ if (isRecord(block)) {
434
+ const type = typeof block.blockType === 'string' ? block.blockType : 'block';
435
+ counts.set(type, (counts.get(type) ?? 0) + 1);
436
+ }
437
+ }
438
+
439
+ return [...counts.entries()].map(([type, count]) => pluralize(count, `${type} block`)).join(', ');
440
+ }
441
+
442
+ // Deterministic, truthful summary for a read of the current CMS item. Read
443
+ // tools have no side effects, so if the model does not narrate the result
444
+ // (empty text, token/step exhaustion, idle timeout), the route can still hand
445
+ // the user a real answer instead of a canned "the model was interrupted" line.
446
+ function summarizeReadCurrentCmsItemOutput(output: unknown) {
447
+ if (!isRecord(output) || output.success !== true) {
448
+ return null;
449
+ }
450
+
451
+ const context = isRecord(output.context) ? output.context : null;
452
+ const contentType =
453
+ context && typeof context.contentType === 'string' ? context.contentType : 'item';
454
+ const item = isRecord(output.item) ? output.item : null;
455
+ const title = item && typeof item.title === 'string' ? item.title : null;
456
+ const slug = item && typeof item.slug === 'string' ? item.slug : null;
457
+ const status = item && typeof item.status === 'string' ? item.status : null;
458
+ const blocks = Array.isArray(output.blocks) ? output.blocks : [];
459
+
460
+ const identity = title ? `the ${contentType} "${title}"` : `the current ${contentType}`;
461
+ const meta = [slug ? `slug "${slug}"` : null, status ? `status ${status}` : null]
462
+ .filter(Boolean)
463
+ .join(', ');
464
+
465
+ if (contentType === 'product') {
466
+ return `Here is ${identity}${meta ? ` (${meta})` : ''}.`;
467
+ }
468
+
469
+ const breakdown = describeBlockTypeCounts(blocks);
470
+
471
+ return `Here is ${identity}${meta ? ` (${meta})` : ''}. It currently has ${pluralize(
472
+ blocks.length,
473
+ 'content block'
474
+ )}${breakdown ? `: ${breakdown}` : ''}.`;
475
+ }
476
+
477
+ function summarizeSearchDocumentationOutput(output: unknown) {
478
+ if (!isRecord(output)) {
479
+ return null;
480
+ }
481
+
482
+ const results = Array.isArray(output.results) ? output.results : [];
483
+
484
+ if (results.length === 0) {
485
+ return 'I searched the published pages and posts but did not find a relevant match.';
486
+ }
487
+
488
+ const titles = results
489
+ .map((result) => (isRecord(result) && typeof result.title === 'string' ? result.title : null))
490
+ .filter((value): value is string => Boolean(value))
491
+ .slice(0, 5);
492
+
493
+ return `I searched the published content and found ${pluralize(
494
+ results.length,
495
+ 'relevant result'
496
+ )}${titles.length > 0 ? `: ${titles.join(', ')}` : ''}.`;
497
+ }
498
+
358
499
  function getToolCompletionMessage(toolName?: string, output?: unknown) {
359
500
  if (isRecord(output)) {
360
501
  if (output.requiresConfirmation === true) {
@@ -384,11 +525,11 @@ function getToolCompletionMessage(toolName?: string, output?: unknown) {
384
525
  }
385
526
 
386
527
  if (toolName === 'search_documentation') {
387
- return 'I searched the documentation, but the model was interrupted before it could finish a summary.';
528
+ return summarizeSearchDocumentationOutput(output) || 'I searched the documentation for you.';
388
529
  }
389
530
 
390
531
  if (toolName === 'read_current_cms_item') {
391
- return 'I read the current CMS item, but the model was interrupted before it could finish a summary.';
532
+ return summarizeReadCurrentCmsItemOutput(output) || 'I read the current CMS item for you.';
392
533
  }
393
534
 
394
535
  if (toolName === 'fetch_ecommerce_stats') {
@@ -440,6 +581,56 @@ function getToolCompletionMessage(toolName?: string, output?: unknown) {
440
581
  return 'Done. I inserted the content block.';
441
582
  }
442
583
 
584
+ if (toolName === 'fetch_url_content') {
585
+ const title = readStringField(output, 'title');
586
+ return title ? `I read the page "${title}".` : 'I read the requested URL.';
587
+ }
588
+
589
+ if (toolName === 'search_stock_photos') {
590
+ if (isRecord(output) && output.success === false) {
591
+ return readStringField(output, 'message') || 'I could not search for stock photos.';
592
+ }
593
+
594
+ const count = Array.isArray((output as { photos?: unknown[] })?.photos)
595
+ ? (output as { photos: unknown[] }).photos.length
596
+ : 0;
597
+
598
+ return count > 0
599
+ ? `I found ${pluralize(count, 'stock photo')} to use.`
600
+ : 'I did not find matching stock photos.';
601
+ }
602
+
603
+ if (toolName === 'translate_page') {
604
+ if (!mutationExecuted) {
605
+ return 'I prepared the translation.';
606
+ }
607
+
608
+ const languageCode = readStringField(output, 'languageCode');
609
+ return `Done — I published the${languageCode ? ` ${languageCode.toUpperCase()}` : ''} translation and linked it to the original. It's live now — open the page and switch languages to see it. You can still edit the wording anytime.`;
610
+ }
611
+
612
+ if (toolName === 'set_content_images') {
613
+ if (!mutationExecuted) {
614
+ return 'I prepared the image update.';
615
+ }
616
+
617
+ const contentType = readStringField(output, 'contentType');
618
+ return contentType === 'product'
619
+ ? 'Done — I updated the product images. The first is the main image and the rest are the gallery.'
620
+ : 'Done — I set the feature image. Reload the editor to see it.';
621
+ }
622
+
623
+ if (toolName === 'rewrite_page_draft') {
624
+ if (!mutationExecuted) {
625
+ return 'I prepared the page rewrite draft.';
626
+ }
627
+
628
+ const contentType = readStringField(output, 'contentType') || 'page';
629
+ const draftPreviewPath = readStringField(output, 'draftPreviewPath');
630
+
631
+ return `Done — I staged a Live Draft rewrite of your ${contentType}. It is NOT live yet. Open the ${contentType} edit screen to preview the draft${draftPreviewPath ? ` (preview it live at ${draftPreviewPath})` : ''}, then click Publish to go live. Publishing also saves a revision snapshot you can restore if you change your mind.`;
632
+ }
633
+
443
634
  if (toolName === 'update_section_column_block') {
444
635
  return 'Done. I updated the nested section block.';
445
636
  }
@@ -521,6 +712,12 @@ async function executeConfirmedToolCall(params: {
521
712
  return executeUpdateContentBlock(params.input as any, params.context);
522
713
  case 'insert_content_block':
523
714
  return executeInsertContentBlock(params.input as any, params.context);
715
+ case 'rewrite_page_draft':
716
+ return executeRewritePageDraft(params.input as any, params.context);
717
+ case 'set_content_images':
718
+ return executeSetContentImages(params.input as any, params.context);
719
+ case 'translate_page':
720
+ return executeTranslatePage(params.input as any, params.context);
524
721
  case 'update_current_cms_fields':
525
722
  return executeUpdateCurrentCmsFields(params.input as any, params.context);
526
723
  case 'update_footer':
@@ -606,11 +803,22 @@ function getRetryableStreamError(
606
803
  return error;
607
804
  }
608
805
 
609
- function createAttemptAbortSignal(requestSignal: AbortSignal) {
806
+ function createAttemptAbortSignal(
807
+ requestSignal: AbortSignal,
808
+ idleTimeoutMs: number = GLOBAL_AGENT_MODEL_IDLE_TIMEOUT_MS
809
+ ) {
610
810
  const controller = new AbortController();
611
- const timeoutId = setTimeout(() => {
612
- controller.abort(new Error('Cortex AI response timed out. Please try again.'));
613
- }, GLOBAL_AGENT_MODEL_ATTEMPT_TIMEOUT_MS);
811
+ let timeoutId: ReturnType<typeof setTimeout>;
812
+
813
+ const armIdleTimeout = () => {
814
+ clearTimeout(timeoutId);
815
+ timeoutId = setTimeout(() => {
816
+ controller.abort(new Error('Cortex AI response timed out. Please try again.'));
817
+ }, idleTimeoutMs);
818
+ };
819
+
820
+ armIdleTimeout();
821
+
614
822
  const abortFromRequest = () => controller.abort(requestSignal.reason);
615
823
 
616
824
  if (requestSignal.aborted) {
@@ -620,6 +828,8 @@ function createAttemptAbortSignal(requestSignal: AbortSignal) {
620
828
  }
621
829
 
622
830
  return {
831
+ // Called on every stream part to reset the idle timer.
832
+ bump: armIdleTimeout,
623
833
  cleanup: () => {
624
834
  clearTimeout(timeoutId);
625
835
  requestSignal.removeEventListener('abort', abortFromRequest);
@@ -659,6 +869,7 @@ export async function POST(request: Request) {
659
869
  const stream = createConfirmedToolCallStream({
660
870
  context: {
661
871
  actorUserId: adminAccess.userId,
872
+ importExternalImage: importExternalImageForCortex,
662
873
  latestUserMessage: confirmedToolCall.confirmationPhrase,
663
874
  pageContext,
664
875
  supabase: getServiceRoleSupabaseClient(),
@@ -684,6 +895,7 @@ export async function POST(request: Request) {
684
895
  const stream = createConfirmedToolCallStream({
685
896
  context: {
686
897
  actorUserId: adminAccess.userId,
898
+ importExternalImage: importExternalImageForCortex,
687
899
  latestUserMessage,
688
900
  pageContext,
689
901
  supabase: getServiceRoleSupabaseClient(),
@@ -728,12 +940,17 @@ export async function POST(request: Request) {
728
940
  actorUserId: adminAccess.userId,
729
941
  cortexAiApiKey: sandboxKey,
730
942
  cortexAiModelSelection: sandboxKey && modelSelection ? modelSelection : undefined,
943
+ importExternalImage: importExternalImageForCortex,
731
944
  latestUserMessage,
732
945
  pageContext,
733
946
  supabase: getServiceRoleSupabaseClient(),
734
947
  validateBlockContent,
735
948
  });
736
- const systemPrompt = buildGlobalAgentSystemPrompt(pageContext);
949
+ const stockPhotoProvider = await resolveCortexAiStockPhotoProvider(
950
+ getServiceRoleSupabaseClient()
951
+ );
952
+ const agentSettings = await resolveCortexAiAgentSettings(getServiceRoleSupabaseClient());
953
+ const systemPrompt = buildGlobalAgentSystemPrompt(pageContext, stockPhotoProvider);
737
954
 
738
955
  const stream = new ReadableStream({
739
956
  async start(controller) {
@@ -758,30 +975,51 @@ export async function POST(request: Request) {
758
975
  );
759
976
 
760
977
  try {
761
- const attemptAbort = createAttemptAbortSignal(request.signal);
762
- const attemptOptions = omitUnsupportedCortexAiModelOptions(
763
- {
764
- abortSignal: attemptAbort.signal,
765
- maxOutputTokens: 2000,
766
- messages: parsedRequest.data.messages,
767
- maxRetries: 0,
768
- stopWhen: stepCountIs(6),
769
- system: systemPrompt,
770
- temperature: 0.1,
771
- tools,
772
- } as Record<string, unknown>,
773
- {
774
- modelId,
775
- modelSelection: routingPolicy.modelSelection,
776
- }
978
+ const attemptAbort = createAttemptAbortSignal(
979
+ request.signal,
980
+ agentSettings.responseTimeoutMs
777
981
  );
982
+ const baseOptions: Record<string, unknown> = {
983
+ abortSignal: attemptAbort.signal,
984
+ messages: parsedRequest.data.messages,
985
+ maxRetries: 0,
986
+ // Admin-tunable step budget (Advanced settings): room for
987
+ // read -> plan -> build/confirm multi-tool sequences.
988
+ stopWhen: stepCountIs(agentSettings.maxSteps),
989
+ system: systemPrompt,
990
+ temperature: agentSettings.temperature,
991
+ tools,
992
+ };
993
+
994
+ // maxOutputTokens is a per-step cap that also counts the JSON of a tool
995
+ // call, so a whole-page rewrite needs plenty of room. `null` = Unlimited:
996
+ // omit the cap entirely so the model uses its own full output budget.
997
+ if (agentSettings.maxOutputTokens !== null) {
998
+ baseOptions.maxOutputTokens = agentSettings.maxOutputTokens;
999
+ }
1000
+
1001
+ const attemptOptions = omitUnsupportedCortexAiModelOptions(baseOptions, {
1002
+ modelId,
1003
+ modelSelection: routingPolicy.modelSelection,
1004
+ });
778
1005
  const result = streamText({
779
1006
  ...attemptOptions,
780
1007
  model: client.model(modelId),
781
1008
  } as Parameters<typeof streamText>[0]);
782
1009
 
1010
+ // Keep the browser's connection + idle timer alive and show a "working"
1011
+ // indicator while a big tool call is generated with no client events.
1012
+ const heartbeat = setInterval(() => {
1013
+ try {
1014
+ controller.enqueue(encodeStreamEvent({ type: 'status' }));
1015
+ } catch {
1016
+ // Controller already closed; nothing to send.
1017
+ }
1018
+ }, GLOBAL_AGENT_HEARTBEAT_INTERVAL_MS);
1019
+
783
1020
  try {
784
1021
  for await (const rawPart of result.fullStream) {
1022
+ attemptAbort.bump();
785
1023
  const part = rawPart as CortexAgentStreamPart;
786
1024
 
787
1025
  if (part.type === 'text-delta' && part.text) {
@@ -834,6 +1072,7 @@ export async function POST(request: Request) {
834
1072
  }
835
1073
  }
836
1074
  } finally {
1075
+ clearInterval(heartbeat);
837
1076
  attemptAbort.cleanup();
838
1077
  }
839
1078