create-nextblock 0.13.1 → 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 (34) hide show
  1. package/package.json +1 -1
  2. package/templates/nextblock-template/app/api/ai/global-agent/route.ts +287 -48
  3. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +238 -209
  4. package/templates/nextblock-template/app/cms/blocks/components/BackgroundSelector.tsx +103 -8
  5. package/templates/nextblock-template/app/cms/blocks/components/BlockEditorArea.tsx +31 -2
  6. package/templates/nextblock-template/app/cms/blocks/components/ColumnEditor.tsx +37 -15
  7. package/templates/nextblock-template/app/cms/blocks/components/EditableBlock.tsx +26 -15
  8. package/templates/nextblock-template/app/cms/blocks/editors/ImageBlockEditor.tsx +123 -46
  9. package/templates/nextblock-template/app/cms/blocks/editors/SectionBlockEditor.tsx +8 -1
  10. package/templates/nextblock-template/app/cms/components/CortexGlobalAgentChat.tsx +62 -22
  11. package/templates/nextblock-template/app/cms/media/import-external-image.ts +289 -0
  12. package/templates/nextblock-template/app/cms/pages/[id]/edit/EditPageClient.tsx +13 -10
  13. package/templates/nextblock-template/app/cms/pages/[id]/edit/page.tsx +14 -3
  14. package/templates/nextblock-template/app/cms/pages/actions.ts +59 -6
  15. package/templates/nextblock-template/app/cms/posts/[id]/edit/page.tsx +21 -11
  16. package/templates/nextblock-template/app/cms/posts/actions.ts +45 -0
  17. package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +11 -9
  18. package/templates/nextblock-template/app/cms/settings/cortex-ai/StoredCortexAiSettingsClient.tsx +463 -227
  19. package/templates/nextblock-template/app/cms/settings/cortex-ai/actions.ts +220 -1
  20. package/templates/nextblock-template/app/cms/settings/cortex-ai/page.tsx +11 -0
  21. package/templates/nextblock-template/app/lib/homepage.ts +36 -0
  22. package/templates/nextblock-template/app/lib/sitemap-utils.ts +13 -6
  23. package/templates/nextblock-template/app/page.tsx +55 -12
  24. package/templates/nextblock-template/components/blocks/renderers/ImageBlockRenderer.tsx +56 -0
  25. package/templates/nextblock-template/components/blocks/renderers/SectionBlockRenderer.tsx +60 -30
  26. package/templates/nextblock-template/components/blocks/renderers/StockPhotoCredit.tsx +167 -0
  27. package/templates/nextblock-template/docs/08-NEXTBLOCK-CORTEX-AI-ARCHITECTURE.md +94 -6
  28. package/templates/nextblock-template/docs/09-LIVE-DRAFT-MODE.md +7 -1
  29. package/templates/nextblock-template/lib/blocks/blockRegistry.ts +29 -3
  30. package/templates/nextblock-template/lib/search/server.ts +11 -1
  31. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +82 -72
  32. package/templates/nextblock-template/next-env.d.ts +1 -1
  33. package/templates/nextblock-template/package.json +1 -1
  34. package/templates/nextblock-template/proxy.ts +5 -0
@@ -18,6 +18,7 @@ import TextBlockRenderer from "./TextBlockRenderer";
18
18
  import HeadingBlockRenderer from "./HeadingBlockRenderer";
19
19
  import ImageBlockRenderer from "./ImageBlockRenderer";
20
20
  import ButtonBlockRenderer from "./ButtonBlockRenderer";
21
+ import { StockPhotoCredit } from "./StockPhotoCredit";
21
22
 
22
23
  const R2_BASE_URL = process.env.NEXT_PUBLIC_R2_BASE_URL || "";
23
24
  const BACKGROUND_COMPOSITING_CLASSES =
@@ -38,6 +39,57 @@ function loadEcommerceBlockRenderer(blockType: string) {
38
39
  );
39
40
  }
40
41
 
42
+ type SectionBackgroundImage = NonNullable<
43
+ NonNullable<SectionBlockContent["background"]>["image"]
44
+ >;
45
+
46
+ // Renders a section's background image. External https URLs (e.g. AI-inserted
47
+ // stock photos) use a plain <img> so any allowlisted host works without Next
48
+ // image remotePatterns config; stored R2 media keeps the optimized next/image
49
+ // path. Returns null when neither a URL nor an object key is present.
50
+ function renderBackgroundImageElement(image: SectionBackgroundImage, priority: boolean) {
51
+ const externalUrl =
52
+ typeof image.external_url === "string" && /^https?:\/\//i.test(image.external_url.trim())
53
+ ? image.external_url.trim()
54
+ : null;
55
+ const objectFit: "cover" | "contain" = image.size === "contain" ? "contain" : "cover";
56
+ const objectPosition = image.position || "center";
57
+
58
+ if (externalUrl) {
59
+ return (
60
+ // eslint-disable-next-line @next/next/no-img-element
61
+ <img
62
+ src={externalUrl}
63
+ alt={image.alt_text || ""}
64
+ loading={priority ? "eager" : "lazy"}
65
+ fetchPriority={priority ? "high" : "auto"}
66
+ decoding="async"
67
+ className="absolute inset-0 h-full w-full"
68
+ style={{ objectFit, objectPosition }}
69
+ />
70
+ );
71
+ }
72
+
73
+ if (!image.object_key) {
74
+ return null;
75
+ }
76
+
77
+ return (
78
+ <Image
79
+ src={`${R2_BASE_URL}/${image.object_key}`}
80
+ alt={image.alt_text || ""}
81
+ fill
82
+ priority={priority}
83
+ fetchPriority={priority ? "high" : "auto"}
84
+ placeholder={image.blur_data_url ? "blur" : "empty"}
85
+ blurDataURL={image.blur_data_url || undefined}
86
+ quality={image.quality || 80}
87
+ sizes="100vw"
88
+ style={{ objectFit, objectPosition }}
89
+ />
90
+ );
91
+ }
92
+
41
93
  interface SectionBlockRendererProps {
42
94
  content: SectionBlockContent;
43
95
  languageId: number;
@@ -410,21 +462,7 @@ export default async function SectionBlockRenderer({
410
462
  {/* Background image Layer for slide */}
411
463
  {slideBackground.type === 'image' && slideBackground.image && (
412
464
  <div className={ABSOLUTE_BACKGROUND_CLASSES}>
413
- <Image
414
- src={`${R2_BASE_URL}/${slideBackground.image.object_key}`}
415
- alt={slideBackground.image.alt_text || ""}
416
- fill
417
- priority={slidePriority}
418
- fetchPriority={slidePriority ? "high" : "auto"}
419
- placeholder={slideBackground.image.blur_data_url ? "blur" : "empty"}
420
- blurDataURL={slideBackground.image.blur_data_url || undefined}
421
- quality={slideBackground.image.quality || 80}
422
- sizes="100vw"
423
- style={{
424
- objectFit: slideBackground.image.size === 'contain' ? 'contain' : 'cover',
425
- objectPosition: slideBackground.image.position || 'center',
426
- }}
427
- />
465
+ {renderBackgroundImageElement(slideBackground.image, slidePriority)}
428
466
  {slideBackground.image.overlay && slideBackground.image.overlay.gradient && (
429
467
  <div
430
468
  className="absolute inset-0 transform-gpu [backface-visibility:hidden]"
@@ -525,23 +563,15 @@ export default async function SectionBlockRenderer({
525
563
  {...visualEditAttributes}
526
564
  >
527
565
  {/* Background image Layer */}
566
+ {content.background?.type === 'image' && content.background.image?.attribution && (
567
+ <StockPhotoCredit
568
+ attribution={content.background.image.attribution}
569
+ className="pointer-events-auto absolute bottom-1 right-2 z-10 rounded bg-black/40 px-1.5 py-0.5 text-[10px] text-white/90 backdrop-blur-sm [&_a]:text-white"
570
+ />
571
+ )}
528
572
  {content.background?.type === 'image' && content.background.image && (
529
573
  <div className={ABSOLUTE_BACKGROUND_CLASSES}>
530
- <Image
531
- src={`${R2_BASE_URL}/${content.background.image.object_key}`}
532
- alt={content.background.image.alt_text || ""}
533
- fill
534
- priority={isHero}
535
- fetchPriority={isHero ? "high" : "auto"}
536
- placeholder={content.background.image.blur_data_url ? "blur" : "empty"}
537
- blurDataURL={content.background.image.blur_data_url || undefined}
538
- quality={content.background.image.quality || 80}
539
- sizes="100vw"
540
- style={{
541
- objectFit: content.background.image.size === 'contain' ? 'contain' : 'cover',
542
- objectPosition: content.background.image.position || 'center',
543
- }}
544
- />
574
+ {renderBackgroundImageElement(content.background.image, isHero)}
545
575
  {content.background.image.overlay && content.background.image.overlay.gradient && (
546
576
  <div
547
577
  className="absolute inset-0 transform-gpu [backface-visibility:hidden]"
@@ -0,0 +1,167 @@
1
+ import React from "react";
2
+
3
+ // Stock-photo attribution. Field names match the search_stock_photos result +
4
+ // the ImageAttribution schema; snake_case is tolerated defensively.
5
+ export type StockPhotoAttribution = {
6
+ provider?: string | null;
7
+ photographer?: string | null;
8
+ photographerUrl?: string | null;
9
+ sourceUrl?: string | null;
10
+ downloadLocation?: string | null;
11
+ // The operator's registered Unsplash app name, resolved from CMS settings at
12
+ // search time and carried on the attribution — never hardcoded.
13
+ utmSource?: string | null;
14
+ photographer_url?: string | null;
15
+ source_url?: string | null;
16
+ };
17
+
18
+ function isUnsplash(attribution: StockPhotoAttribution) {
19
+ return (
20
+ attribution.provider === "unsplash" ||
21
+ /unsplash\.com/i.test(attribution.sourceUrl || attribution.source_url || "")
22
+ );
23
+ }
24
+
25
+ // Unsplash requires attribution links to carry utm params identifying your app.
26
+ // Without the operator's registered app name (set in /cms/settings/cortex-ai) we
27
+ // omit utm rather than tag a wrong app — the links still work.
28
+ function buildUnsplashUtm(attribution: StockPhotoAttribution) {
29
+ const source = attribution.utmSource?.trim();
30
+ if (!source) {
31
+ return null;
32
+ }
33
+ return `utm_source=${encodeURIComponent(source)}&utm_medium=referral`;
34
+ }
35
+
36
+ function withUtm(url: string, attribution: StockPhotoAttribution) {
37
+ if (!url || !isUnsplash(attribution)) {
38
+ return url;
39
+ }
40
+ const utm = buildUnsplashUtm(attribution);
41
+ if (!utm) {
42
+ return url;
43
+ }
44
+ return url.includes("?") ? `${url}&${utm}` : `${url}?${utm}`;
45
+ }
46
+
47
+ function resolveProvider(attribution: StockPhotoAttribution) {
48
+ const source = attribution.sourceUrl || attribution.source_url || "";
49
+ if (attribution.provider === "unsplash" || /unsplash\.com/i.test(source)) {
50
+ return { home: "https://unsplash.com", name: "Unsplash" };
51
+ }
52
+ if (attribution.provider === "pexels" || /pexels\.com/i.test(source)) {
53
+ return { home: "https://www.pexels.com", name: "Pexels" };
54
+ }
55
+ return null;
56
+ }
57
+
58
+ /**
59
+ * The plain-text form of the credit that {@link StockPhotoCredit} renders
60
+ * ("Photo by {photographer} on {Provider}"). Used to detect — and suppress — a
61
+ * caption that merely duplicates the attribution credit.
62
+ */
63
+ export function stockPhotoCreditText(
64
+ attribution: StockPhotoAttribution | null | undefined
65
+ ): string | null {
66
+ if (!attribution) {
67
+ return null;
68
+ }
69
+
70
+ const provider = resolveProvider(attribution);
71
+ const photographer = attribution.photographer;
72
+
73
+ if (!photographer && !provider) {
74
+ return null;
75
+ }
76
+
77
+ const who = photographer || "a photographer";
78
+ return provider ? `Photo by ${who} on ${provider.name}` : `Photo by ${who}`;
79
+ }
80
+
81
+ /**
82
+ * True when `caption` is just an attribution credit for `attribution` (so it
83
+ * would duplicate the rendered {@link StockPhotoCredit}). Covers the normal
84
+ * "Photo by {name} on {Provider}" form and the no-photographer fallback that
85
+ * search_stock_photos historically stored, "Photo on {Provider}".
86
+ */
87
+ export function isStockPhotoCreditCaption(
88
+ caption: string | null | undefined,
89
+ attribution: StockPhotoAttribution | null | undefined
90
+ ): boolean {
91
+ const text = (caption || "").trim().toLowerCase();
92
+ if (!text) {
93
+ return false;
94
+ }
95
+
96
+ const credit = stockPhotoCreditText(attribution);
97
+ if (credit && text === credit.toLowerCase()) {
98
+ return true;
99
+ }
100
+
101
+ const provider = resolveProvider(attribution || {});
102
+ return Boolean(provider && text === `photo on ${provider.name.toLowerCase()}`);
103
+ }
104
+
105
+ /**
106
+ * Renders "Photo by {photographer} on {Provider}" with the photographer's profile
107
+ * and the provider properly linked — satisfying the Unsplash API attribution
108
+ * requirement (also shown for Pexels as a courtesy). Renders nothing without a
109
+ * usable attribution.
110
+ */
111
+ export function StockPhotoCredit({
112
+ attribution,
113
+ className,
114
+ }: {
115
+ attribution: StockPhotoAttribution | null | undefined;
116
+ className?: string;
117
+ }) {
118
+ if (!attribution) {
119
+ return null;
120
+ }
121
+
122
+ const photographer = attribution.photographer;
123
+ const photographerUrl = attribution.photographerUrl || attribution.photographer_url;
124
+ const sourceUrl = attribution.sourceUrl || attribution.source_url;
125
+ const provider = resolveProvider(attribution);
126
+
127
+ if (!photographer && !provider) {
128
+ return null;
129
+ }
130
+
131
+ const linkClass = "underline underline-offset-2 hover:opacity-80";
132
+
133
+ return (
134
+ <span className={className}>
135
+ Photo by{" "}
136
+ {photographer ? (
137
+ photographerUrl ? (
138
+ <a
139
+ href={withUtm(photographerUrl, attribution)}
140
+ target="_blank"
141
+ rel="noopener noreferrer nofollow"
142
+ className={linkClass}
143
+ >
144
+ {photographer}
145
+ </a>
146
+ ) : (
147
+ photographer
148
+ )
149
+ ) : (
150
+ "a photographer"
151
+ )}
152
+ {provider ? (
153
+ <>
154
+ {" on "}
155
+ <a
156
+ href={withUtm(sourceUrl || provider.home, attribution)}
157
+ target="_blank"
158
+ rel="noopener noreferrer nofollow"
159
+ className={linkClass}
160
+ >
161
+ {provider.name}
162
+ </a>
163
+ </>
164
+ ) : null}
165
+ </span>
166
+ );
167
+ }
@@ -64,7 +64,7 @@ Known incomplete or future work:
64
64
  - Footer link updates currently replace footer links for the selected locale. Footer append mode is not yet implemented.
65
65
  - Documentation search is keyword/scored search over `posts` and `pages`, not a vector embedding RAG system yet.
66
66
  - The sandbox should eventually seed a visible product/package item for Cortex AI, similar to ecommerce. The preferred image asset is `apps/nextblock/public/images/cortex-ai-square.webp`.
67
- - Block insertion is intentionally left for a follow-up pass with explicit idempotency keys.
67
+ - Block insertion, creation (`create_cms_page/post/product`), deletion (`delete_cms_item`), multi-step plans (`execute_cms_action_plan`), direct typed DB CRUD, external URL ingestion (`fetch_url_content`), and whole-page rewrites staged into Live Draft Mode (`rewrite_page_draft`) are all implemented (this "future work" note is stale see the tool inventory in `createCortexGlobalAgentTools` and the "External URL Ingestion and Live-Draft Page Rewrites" section below). Per-block mutations still write directly to live `blocks` via service role (no draft/snapshot); only `rewrite_page_draft` goes through `content_drafts`.
68
68
 
69
69
  ## Important Files
70
70
 
@@ -909,10 +909,15 @@ Model orchestration:
909
909
 
910
910
  - Uses `streamText`.
911
911
  - Uses `buildCortexAiRoutingPolicy`.
912
- - Uses `stepCountIs(6)`.
912
+ - Uses `stepCountIs(8)` (raised from 6 to allow read -> plan -> build/confirm multi-tool sequences such as rewriting a full page).
913
913
  - Temperature is `0.1`.
914
- - Max output tokens is `2000`.
915
- - Per-model attempt timeout is `30000ms`.
914
+ - Max output tokens is `4000` (raised from 2000; this is a per-step cap that also counts reasoning/tool-argument tokens, so a low value could starve the post-tool summary step and produce empty text).
915
+ - Per-attempt timeout is **idle-based** (`GLOBAL_AGENT_MODEL_IDLE_TIMEOUT_MS = 60000`): the attempt aborts only after 60s with no stream activity, and the timer resets on every stream part. A slow-but-progressing generation is not killed mid-answer.
916
+
917
+ Read-only tool summaries:
918
+
919
+ - After a successful `read_current_cms_item` or `search_documentation`, if the model emits no follow-up text, the route now returns a **deterministic, truthful summary built from the tool output** (`summarizeReadCurrentCmsItemOutput` / `summarizeSearchDocumentationOutput`) instead of the old canned "the model was interrupted before it could finish a summary" line. Read tools have no side effects, so the answer never depends on the model narrating them.
920
+ - `looksLikeRawToolCallLeak` only flags **structural** markers (`<toolcall>`/`<tool_call>`/`<function_call>` wrappers, or a JSON object carrying both `"name"`/`"tool"` and `"arguments"`). It no longer discards legitimate prose that merely quotes a tool name or the bare word "arguments".
916
921
 
917
922
  System prompt:
918
923
 
@@ -983,6 +988,89 @@ This was added after a real issue where:
983
988
 
984
989
  The current implementation treats the DB tool result as the source of truth once a mutation succeeds.
985
990
 
991
+ ## Section Design Intelligence
992
+
993
+ Section blocks are the layout primitive for multi-section pages (heroes, landing/marketing pages). The strict `section` schema requires every layout field, so cheap models used to either fail validation or emit bland sections. Two mechanisms now make section authoring reliable:
994
+
995
+ 1. **Server-side section normalizer** (`normalizeSectionContent` in `libs/cortex/src/lib/ai-global-agent-tools.ts`). Runs on every create/insert of a `section` block (via `normalizeBlockContentForType`). It:
996
+ - Fills all required layout fields with sensible defaults: `container_type` `container`, `column_gap` `lg`, `padding` `{top:'xl',bottom:'xl'}`, `vertical_alignment` `center` for heroes / `start` otherwise.
997
+ - Keeps the grid in sync: `responsive_columns.desktop` is derived from the number of columns actually provided in `column_blocks` (clamped 1-4), so the grid never has empty trailing tracks or overflowing cells.
998
+ - Completes background intent: a bare `{type:'gradient'}` gets real color stops; a `theme` background without a theme defaults to `muted`; an `image` background without a real `media_id` is downgraded to `none` (the AI cannot invent media).
999
+ - Deep-normalizes and validates each nested column block (also fixes a prior bug where nested blocks were only shallow-validated on CREATE).
1000
+ - Tolerates a model that flattens columns into a single list (`[blockA, blockB]`) by treating them as one column.
1001
+
1002
+ Net effect: a model can emit a section with just `column_blocks` plus intent (`is_hero`, an optional `background`) and the server produces a valid, well-styled section.
1003
+
1004
+ 2. **Design recipe in the global-agent system prompt** (`route.ts`, the `PAGE DESIGN` bullets). Tells the model to compose pages from `section` blocks, supply one column per desired grid track, make the first section a hero, alternate `none`/`theme:'muted'`/`theme:'primary'` backgrounds for rhythm, use discrete heading blocks (not `<h2>` inside text HTML), and prefer gradient/theme backgrounds unless a real `media_id` exists. A single `text` block's `html_content` still accepts fully custom HTML/CSS for bespoke sections.
1005
+
1006
+ ## External URL Ingestion and Live-Draft Page Rewrites
1007
+
1008
+ Two tools power the "rewrite my home page based on `<url>`" use case. Both live in `libs/cortex/src/lib/ai-global-agent-tools.ts` and are registered in `createCortexGlobalAgentTools`.
1009
+
1010
+ ### fetch_url_content (read-only)
1011
+
1012
+ - Input: `{ url: string (http/https), maxChars?: number (500-20000, default 8000) }`.
1013
+ - Fetches an external page and returns `{ title, description, headings[], text, finalUrl, truncated }` (scripts/styles/svg stripped, HTML reduced to readable text).
1014
+ - Safety: rejects non-http(s) URLs and blocks local/loopback/private/link-local hosts and cloud metadata endpoints (`isBlockedFetchHost`), re-checks the host after redirects, enforces a 12s timeout and a ~2MB read cap, and only processes `text/html`/`text/plain` responses.
1015
+ - No confirmation, no DB access. The agent calls it FIRST when a prompt references an external site, then writes new sections from the returned material.
1016
+
1017
+ ### rewrite_page_draft (mutating, staged into Live Draft Mode)
1018
+
1019
+ - Input: `cmsTarget (contentType/entityId/slug/title)` + `blocks: CreateCmsBlock[] (1-20)` + optional `meta` overrides (title/slug/status/meta_title/meta_description).
1020
+ - Replaces ALL blocks of a page/post with the supplied set, but writes them into a `content_drafts` row (via `context.supabase` service role) instead of the live `blocks` table. It seeds `meta` from the current published item (so metadata is preserved) and carries `base_version` from the item version.
1021
+ - Nothing goes live: the user previews the draft (`/api/draft/start?path=/<slug>`), then Publishes from the edit screen. Publishing runs the existing draft-publish path, which applies the blocks live AND calls `createPageRevision`/`createPostRevision` — so the rewrite is previewable and reversible.
1022
+ - Blocks are normalized through the same `normalizeCreateBlocks` pipeline as `create_cms_page` (section defaults, column-count sync, nested validation).
1023
+ - Two-step confirmation like other mutating tools; the confirmation payload hash excludes non-deterministic nested `temp_id`s so the confirm phrase is stable.
1024
+ - Result: `{ mutationExecuted, contentType, entityId, slug, blockCount, editPath, draftPreviewPath, isDraft: true }`. The chat treats it as mutating (`MUTATING_TOOL_NAMES`) and navigates to `editPath`, where the "Unpublished Draft → Publish/Discard" toolbar (`DraftStatusActions`) appears.
1025
+
1026
+ Typical flow for "rewrite my home page with 5 sections based on `<url>`": `fetch_url_content(url)` → design a hero + 4 sections following the PAGE DESIGN recipe → `rewrite_page_draft(home, blocks)` → user previews and publishes.
1027
+
1028
+ ## Stock Photos and External Images
1029
+
1030
+ Cortex can insert real photos into pages at zero inference cost, and external image URLs are supported natively across the block system.
1031
+
1032
+ ### search_stock_photos (read-only)
1033
+
1034
+ - In `libs/cortex/src/lib/ai-global-agent-tools.ts`; registered in `createCortexGlobalAgentTools`.
1035
+ - Input: `{ query: string, count?: 1-15 (default 6), orientation?: 'landscape'|'portrait'|'square' }`.
1036
+ - Key resolution: `resolveCortexAiStockPhotoProvider(supabase)` prefers an admin-stored, encrypted key in `site_settings` (`cortex_ai_pexels_api_key` / `cortex_ai_unsplash_access_key`, read via the service-role client), then falls back to the `PEXELS_API_KEY` / `UNSPLASH_ACCESS_KEY` env vars. Pexels wins when both exist. Returns a clear "not configured" message if neither is set. Both are free API keys.
1037
+ - The stored keys are protected by migration `00000000000012_cortex_ai_stock_photo_settings.sql`, which adds them to the `site_settings` sensitive-keys RLS group (admin-only read/write, never anon-readable), and encrypted with the same envelope as the OpenRouter BYOK key.
1038
+ - **The model is told up front whether stock photos are available.** The global-agent route resolves the provider and injects it into the system prompt: available → "use search_stock_photos"; not configured → "do NOT call search_stock_photos; use gradient/theme backgrounds." So a missing key never wastes a tool call, and the keys are never mandatory — Cortex builds pages either way.
1039
+ - Admin UI: `/cms/settings/cortex-ai` has a Stock Photos card (save/clear Pexels + Unsplash keys, step-by-step, and why) via `saveStockPhotoKeysAction` / `clearStockPhotoKeysAction`.
1040
+ - Rate-limit fallback: `resolveCortexAiStockPhotoProviders` returns ALL configured providers ordered Pexels→Unsplash; `executeSearchStockPhotos` tries them in order, falling through to the next on error/HTTP 429/empty results, and returns `attemptedProviders`.
1041
+ - Returns `{ photos: [{ url, thumbnailUrl, alt, width, height, photographer, photographerUrl, sourceUrl, downloadLocation, credit, provider }], provider, usageGuidance, attemptedProviders, success }`. The agent drops a photo `url` into an image block's `external_url` or a section background's `image.external_url`, and copies the photo's attribution fields into the image content's `attribution`.
1042
+
1043
+ ### Provider compliance (Unsplash API Guidelines)
1044
+
1045
+ Unsplash has strict usage rules; Pexels' license is permissive (attribution optional, re-hosting allowed, no download trigger). Handled:
1046
+
1047
+ - **Hotlink**: external stock URLs render via a plain `<img>` from the provider host (never proxied). `importExternalImageToMedia` **refuses to re-host `*.unsplash.com` images** (Pexels re-host is allowed).
1048
+ - **Trigger downloads**: `maybeTriggerStockPhotoDownloads(blocks, supabase)` fires each Unsplash `download_location` (with the resolved Unsplash key) when a photo is committed to a page. Wired into the create (`insertContentBlocks`), `rewrite_page_draft`, `insert_content_block`, and `update_content_block` persist paths. Best-effort/fire-and-forget; depends on the agent copying `attribution.downloadLocation` from the search result.
1049
+ - **Attribution**: `ImageAttributionSchema` on the image block + section background image carries `{ provider, photographer, photographerUrl, sourceUrl, downloadLocation }`. The shared `StockPhotoCredit` component renders "Photo by {photographer} on {Provider}" with the photographer + provider linked and `utm_source`/`utm_medium` params on Unsplash links. The system prompt requires the agent to set `attribution` (and the image caption) from the search result.
1050
+ - **App name/branding**: dashboard-side (the operator's Unsplash app registration); NextBlock uses no Unsplash branding. The `utm_source` in `StockPhotoCredit.tsx` defaults to `nextblock` — change it to the registered app name if needed.
1051
+
1052
+ ### External image URLs in blocks
1053
+
1054
+ - `ImageBlockSchema` (`external_url`) and the section `BackgroundSchema.image` (`external_url`, with `media_id`/`object_key` now optional) accept a direct https URL. The cortex fallback schemas mirror this.
1055
+ - Renderers: `ImageBlockRenderer` and `SectionBlockRenderer` render an external URL with a plain `<img>` (so any allowlisted host works without Next `remotePatterns`), and keep the optimized `next/image` path for stored R2 media. `normalizeSectionContent` accepts image backgrounds with an `external_url` (filling `size`/`position`) instead of downgrading them.
1056
+ - Security: the CSP `img-src` allows `https:` (images only — see `apps/nextblock/proxy.ts`), so trusted ADMIN/WRITER authors can embed any https image. script/style/connect stay strict.
1057
+
1058
+ ### Persist to media library
1059
+
1060
+ - `importExternalImageToMedia` (`apps/nextblock/app/cms/media/import-external-image.ts`, ADMIN/WRITER) downloads an external image (SSRF-guarded, 15MB/15s caps), measures it with `sharp`, generates a blur placeholder, uploads to R2/Supabase Storage via the shared storage provider, and records it with `recordMediaUpload`. Returns `{ media_id, object_key, width, height, url, blur_data_url }`.
1061
+ - Editor UX: `ImageBlockEditor` and `BackgroundSelector` accept a pasted image URL and show a **Save to media library** action that swaps the external URL for a permanent optimized media reference (or the author can replace it with their own uploaded asset).
1062
+
1063
+ ## Advanced Agent Settings
1064
+
1065
+ The global agent's model limits are admin-tunable from `/cms/settings/cortex-ai` (collapsible "Advanced settings"), stored as a non-secret JSON `site_settings` row `cortex_ai_agent_settings` and read by the route via `resolveCortexAiAgentSettings(supabase)` (defaults + clamping in `normalizeCortexAiAgentSettings`, `libs/cortex/src/lib/ai-config.ts`):
1066
+
1067
+ - `maxOutputTokens` — per-step output cap. **`null` = Unlimited** (the route omits the cap so the model uses its own full budget). Default 16000. This is the main lever when a large `rewrite_page_draft` gets truncated.
1068
+ - `maxSteps` — `stepCountIs(n)` tool-call rounds. Default 8.
1069
+ - `temperature` — default 0.1.
1070
+ - `responseTimeoutMs` — the per-attempt idle abort. Default 120000.
1071
+
1072
+ All values are clamped to safe bounds (`CORTEX_AI_AGENT_SETTINGS_BOUNDS`). Actions: `saveCortexAiAgentSettingsAction` / `resetCortexAiAgentSettingsAction`. The route applies them per attempt (omitting `maxOutputTokens` entirely when Unlimited).
1073
+
986
1074
  ## Dashboard Chat UI
987
1075
 
988
1076
  File:
@@ -1121,8 +1209,8 @@ Notes:
1121
1209
 
1122
1210
  Current protections:
1123
1211
 
1124
- - Server-side per-model timeout: 30 seconds.
1125
- - Client request timeout: 45 seconds.
1212
+ - Server-side **idle** timeout: 60 seconds with no stream activity (resets on each stream part).
1213
+ - Client **idle** timeout: 90 seconds with no stream activity (`IDLE_TIMEOUT_MS`, resets on each chunk), so a long multi-section build is not aborted at a fixed wall-clock deadline.
1126
1214
  - Client stops reading on `finish`.
1127
1215
 
1128
1216
  If it still happens:
@@ -84,7 +84,13 @@ Products support two kinds of visual editing:
84
84
 
85
85
  ---
86
86
 
87
- ## 5. Local Verification
87
+ ## 5. Cortex AI Integration
88
+
89
+ The Cortex AI global agent's `rewrite_page_draft` tool writes into `content_drafts` rather than the live `blocks` table, so an AI-generated whole-page rewrite (e.g. "rewrite my home page with 5 sections") lands as an unpublished draft. It seeds the draft `meta` from the current published item and carries `base_version` from the item's version. The user then previews via `/api/draft/start?path=/<slug>` and publishes with the normal `publishVisualEditingDraft` flow, which applies the blocks live and auto-creates a revision snapshot. See [08-NEXTBLOCK-CORTEX-AI-ARCHITECTURE.md](./08-NEXTBLOCK-CORTEX-AI-ARCHITECTURE.md#external-url-ingestion-and-live-draft-page-rewrites).
90
+
91
+ ---
92
+
93
+ ## 6. Local Verification
88
94
 
89
95
  To test Live Draft Mode locally:
90
96
 
@@ -27,13 +27,37 @@ export const HeadingBlockSchema = z.object({
27
27
  });
28
28
  export type HeadingBlockContent = z.infer<typeof HeadingBlockSchema>;
29
29
 
30
+ // Stock-photo attribution (required for Unsplash: photographer + Unsplash must be
31
+ // credited and linked). Optional; set when an external_url is a stock photo. Field
32
+ // names match the search_stock_photos result so the agent can copy them verbatim.
33
+ // All fields are nullable + optional: the search_stock_photos result carries
34
+ // explicit nulls for missing values (e.g. Pexels has no downloadLocation, and
35
+ // utmSource is null until the Unsplash app name is set), and the agent copies the
36
+ // object verbatim — so the schema must accept null, not just undefined.
37
+ export const ImageAttributionSchema = z.object({
38
+ provider: z.string().nullable().optional().describe('e.g. "unsplash" or "pexels"'),
39
+ photographer: z.string().nullable().optional(),
40
+ photographerUrl: z.string().nullable().optional(),
41
+ sourceUrl: z.string().nullable().optional().describe('Link to the photo on the provider'),
42
+ downloadLocation: z.string().nullable().optional().describe('Unsplash download-trigger endpoint'),
43
+ utmSource: z.string().nullable().optional().describe('Unsplash app name for attribution utm_source'),
44
+ });
45
+ export type ImageAttribution = z.infer<typeof ImageAttributionSchema>;
46
+
30
47
  export const ImageBlockSchema = z.object({
31
- media_id: z.string().nullable().describe('UUID of the media item'),
48
+ media_id: z.string().nullable().optional().describe('UUID of the media item'),
32
49
  object_key: z.string().nullable().optional().describe('The actual R2 object key'),
50
+ external_url: z
51
+ .string()
52
+ .nullable()
53
+ .optional()
54
+ .describe('Direct external image URL (e.g. a stock photo). Rendered as-is; media_id/object_key are not required when this is set.'),
55
+ attribution: ImageAttributionSchema.optional().describe('Stock-photo credit (required for Unsplash).'),
33
56
  alt_text: z.string().optional().describe('Alternative text'),
34
57
  caption: z.string().optional().describe('Optional caption'),
35
58
  width: z.number().nullable().optional().describe('Image width'),
36
59
  height: z.number().nullable().optional().describe('Image height'),
60
+ blur_data_url: z.string().nullable().optional().describe('Base64 blur placeholder'),
37
61
  });
38
62
  export type ImageBlockContent = z.infer<typeof ImageBlockSchema>;
39
63
 
@@ -77,8 +101,10 @@ const BackgroundSchema = z.object({
77
101
  min_height: z.string().optional(),
78
102
  gradient: GradientSchema.optional(),
79
103
  image: z.object({
80
- media_id: z.string(),
81
- object_key: z.string(),
104
+ media_id: z.string().optional(),
105
+ object_key: z.string().optional(),
106
+ external_url: z.string().optional(),
107
+ attribution: ImageAttributionSchema.optional(),
82
108
  alt_text: z.string().optional(),
83
109
  width: z.number().optional(),
84
110
  height: z.number().optional(),
@@ -2,6 +2,7 @@ import 'server-only';
2
2
 
3
3
  import { getSsgSupabaseClient, verifyPackageOnline } from '@nextblock-cms/db/server';
4
4
  import { resolveMediaUrl } from '../media/resolveMediaUrl';
5
+ import { getHomepageTranslationGroupId } from '../../app/lib/homepage';
5
6
  import type {
6
7
  GlobalSearchFilter,
7
8
  GlobalSearchResponse,
@@ -340,6 +341,7 @@ async function fetchPages(languageId: number | null): Promise<SearchCandidate[]>
340
341
  meta_description,
341
342
  updated_at,
342
343
  language_id,
344
+ translation_group_id,
343
345
  languages!inner(code),
344
346
  media:feature_image_id(object_key, blur_data_url, width, height),
345
347
  blocks(content, block_type, order)
@@ -360,10 +362,18 @@ async function fetchPages(languageId: number | null): Promise<SearchCandidate[]>
360
362
  return [];
361
363
  }
362
364
 
365
+ // Every language variation of the homepage (its translation group, any slug)
366
+ // is served at "/", so link those results there rather than at "/{slug}".
367
+ const homepageGroupId = await getHomepageTranslationGroupId(supabase);
368
+
363
369
  return data.map((page: any) => {
364
370
  const bodyText = buildBodyFromBlocks(page.blocks);
365
371
  const description = page.meta_description || null;
366
- const href = page.slug === 'home' || page.slug === 'accueil' ? '/' : `/${page.slug}`;
372
+ const isHomepage =
373
+ (homepageGroupId && page.translation_group_id === homepageGroupId) ||
374
+ page.slug === 'home' ||
375
+ page.slug === 'accueil';
376
+ const href = isHomepage ? '/' : `/${page.slug}`;
367
377
  const media = getFirstRelation(page.media as { object_key?: string | null } | { object_key?: string | null }[] | null);
368
378
 
369
379
  return {