create-nextblock 0.16.1 → 0.16.3

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 (264) hide show
  1. package/docker-template/.dockerignore +1 -1
  2. package/libs/db/tsconfig.lib.json +3 -3
  3. package/libs/editor/tsconfig.lib.json +3 -3
  4. package/libs/ui/tsconfig.lib.json +3 -3
  5. package/libs/utils/tsconfig.json +3 -3
  6. package/package.json +50 -50
  7. package/project.json +19 -19
  8. package/templates/nextblock-template/.dockerignore +1 -1
  9. package/templates/nextblock-template/.swcrc +30 -30
  10. package/templates/nextblock-template/AGENTS.md +9 -9
  11. package/templates/nextblock-template/CLAUDE.md +1 -1
  12. package/templates/nextblock-template/app/(auth-pages)/layout.tsx +9 -9
  13. package/templates/nextblock-template/app/(auth-pages)/post-sign-in/page.tsx +27 -27
  14. package/templates/nextblock-template/app/.well-known/ucp/route.ts +16 -16
  15. package/templates/nextblock-template/app/ToasterProvider.tsx +26 -26
  16. package/templates/nextblock-template/app/[slug]/pageClientActions.ts +7 -7
  17. package/templates/nextblock-template/app/actions/contactSellerActions.test.ts +280 -280
  18. package/templates/nextblock-template/app/actions/contactSellerActions.ts +222 -222
  19. package/templates/nextblock-template/app/actions/email-retry.test.ts +62 -62
  20. package/templates/nextblock-template/app/actions/email.ts +241 -241
  21. package/templates/nextblock-template/app/actions/formActions.ts +245 -245
  22. package/templates/nextblock-template/app/actions/interactions.ts +489 -489
  23. package/templates/nextblock-template/app/actions/threadActions.ts +166 -166
  24. package/templates/nextblock-template/app/actions/visibilityActions.ts +210 -210
  25. package/templates/nextblock-template/app/api/ai/seo/alt-text/route.ts +221 -221
  26. package/templates/nextblock-template/app/api/ai/seo/metadata/route.ts +186 -186
  27. package/templates/nextblock-template/app/api/checkout/freemius/sync/route.ts +29 -29
  28. package/templates/nextblock-template/app/api/checkout/route.ts +162 -162
  29. package/templates/nextblock-template/app/api/cron/reset-sandbox/route.ts +4 -4
  30. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +4542 -2904
  31. package/templates/nextblock-template/app/api/mcp/route.ts +415 -415
  32. package/templates/nextblock-template/app/api/media/record/route.ts +160 -160
  33. package/templates/nextblock-template/app/api/search/route.ts +43 -43
  34. package/templates/nextblock-template/app/api/view/route.ts +114 -114
  35. package/templates/nextblock-template/app/api/visual-editing/block-draft/route.ts +47 -47
  36. package/templates/nextblock-template/app/api/visual-editing/product-draft/route.ts +47 -47
  37. package/templates/nextblock-template/app/auth/callback/route.ts +31 -31
  38. package/templates/nextblock-template/app/cart/page.tsx +7 -7
  39. package/templates/nextblock-template/app/checkout/UcpCartHydrator.tsx +20 -20
  40. package/templates/nextblock-template/app/checkout/page.tsx +57 -57
  41. package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +558 -558
  42. package/templates/nextblock-template/app/cms/blocks/components/BlockEditorModal.tsx +241 -241
  43. package/templates/nextblock-template/app/cms/blocks/components/MediaLibraryModal.tsx +149 -149
  44. package/templates/nextblock-template/app/cms/blocks/editors/FormBlockEditor.tsx +304 -304
  45. package/templates/nextblock-template/app/cms/blocks/editors/ImageBlockEditor.tsx +406 -406
  46. package/templates/nextblock-template/app/cms/components/ContactReminderBanner.tsx +75 -75
  47. package/templates/nextblock-template/app/cms/components/CortexAiActiveContext.tsx +23 -23
  48. package/templates/nextblock-template/app/cms/components/CortexAiPageContext.tsx +58 -58
  49. package/templates/nextblock-template/app/cms/components/FeatureImageField.tsx +254 -254
  50. package/templates/nextblock-template/app/cms/components/FeedbackModal.tsx +36 -36
  51. package/templates/nextblock-template/app/cms/components/PaymentsReminderBanner.tsx +58 -58
  52. package/templates/nextblock-template/app/cms/components/SeoScoreBadge.tsx +40 -0
  53. package/templates/nextblock-template/app/cms/components/TablePagination.tsx +136 -0
  54. package/templates/nextblock-template/app/cms/components/VisibilityBadge.tsx +62 -62
  55. package/templates/nextblock-template/app/cms/components/VisibilityControl.tsx +542 -542
  56. package/templates/nextblock-template/app/cms/coupons/[id]/edit/page.tsx +16 -16
  57. package/templates/nextblock-template/app/cms/coupons/page.tsx +16 -16
  58. package/templates/nextblock-template/app/cms/dashboard/actions.ts +228 -228
  59. package/templates/nextblock-template/app/cms/dashboard/components/DashboardComponents.tsx +200 -200
  60. package/templates/nextblock-template/app/cms/inquiries/actions.ts +66 -66
  61. package/templates/nextblock-template/app/cms/inquiries/page.tsx +12 -12
  62. package/templates/nextblock-template/app/cms/interactions/page.tsx +12 -12
  63. package/templates/nextblock-template/app/cms/layout.tsx +101 -101
  64. package/templates/nextblock-template/app/cms/media/components/FolderNavigator.tsx +273 -273
  65. package/templates/nextblock-template/app/cms/media/components/FolderTree.tsx +122 -122
  66. package/templates/nextblock-template/app/cms/media/components/MediaGridClient.tsx +69 -69
  67. package/templates/nextblock-template/app/cms/messages/MessagesClient.tsx +661 -661
  68. package/templates/nextblock-template/app/cms/messages/actions.ts +404 -404
  69. package/templates/nextblock-template/app/cms/messages/loadInbox.ts +333 -333
  70. package/templates/nextblock-template/app/cms/messages/page.tsx +87 -87
  71. package/templates/nextblock-template/app/cms/messages/require-admin.ts +37 -37
  72. package/templates/nextblock-template/app/cms/navigation/components/NavigationMenuDnd.tsx +3 -3
  73. package/templates/nextblock-template/app/cms/pages/components/PageForm.tsx +649 -649
  74. package/templates/nextblock-template/app/cms/pages/page.tsx +273 -227
  75. package/templates/nextblock-template/app/cms/posts/[id]/edit/page.tsx +2 -0
  76. package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +624 -618
  77. package/templates/nextblock-template/app/cms/posts/page.tsx +251 -194
  78. package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +370 -370
  79. package/templates/nextblock-template/app/cms/products/attributes/page.tsx +12 -12
  80. package/templates/nextblock-template/app/cms/products/inventory/page.tsx +13 -13
  81. package/templates/nextblock-template/app/cms/products/productFormData.ts +133 -133
  82. package/templates/nextblock-template/app/cms/products/settings/page.tsx +5 -5
  83. package/templates/nextblock-template/app/cms/revisions/actions.ts +332 -332
  84. package/templates/nextblock-template/app/cms/revisions/service.test.ts +498 -498
  85. package/templates/nextblock-template/app/cms/revisions/service.ts +569 -569
  86. package/templates/nextblock-template/app/cms/revisions/utils.ts +304 -304
  87. package/templates/nextblock-template/app/cms/settings/cortex-ai/CortexAiSettingsClient.tsx +948 -948
  88. package/templates/nextblock-template/app/cms/settings/cortex-ai/McpServerSettingsCard.tsx +628 -628
  89. package/templates/nextblock-template/app/cms/settings/cortex-ai/mcp-actions.ts +230 -230
  90. package/templates/nextblock-template/app/cms/settings/cortex-ai/require-admin.ts +34 -34
  91. package/templates/nextblock-template/app/cms/settings/currencies/actions.ts +331 -331
  92. package/templates/nextblock-template/app/cms/settings/currencies/page.tsx +494 -494
  93. package/templates/nextblock-template/app/cms/settings/email/components/EmailForm.tsx +227 -227
  94. package/templates/nextblock-template/app/cms/settings/extra-translations/ExtraTranslationsWorkspace.tsx +767 -767
  95. package/templates/nextblock-template/app/cms/settings/extra-translations/actions.ts +276 -276
  96. package/templates/nextblock-template/app/cms/settings/extra-translations/page.tsx +93 -93
  97. package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeEditor.tsx +382 -382
  98. package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeManager.tsx +267 -267
  99. package/templates/nextblock-template/app/cms/settings/global-css/page.tsx +40 -40
  100. package/templates/nextblock-template/app/cms/settings/global-css/theme-actions.ts +259 -259
  101. package/templates/nextblock-template/app/cms/settings/logos/[id]/edit/page.tsx +7 -7
  102. package/templates/nextblock-template/app/cms/settings/logos/components/BrandingSettingsForm.tsx +339 -339
  103. package/templates/nextblock-template/app/cms/settings/logos/new/page.tsx +8 -8
  104. package/templates/nextblock-template/app/cms/settings/seo/RedirectsCard.tsx +514 -514
  105. package/templates/nextblock-template/app/cms/settings/seo/RobotsCard.tsx +529 -529
  106. package/templates/nextblock-template/app/cms/settings/seo/SeoSettingsClient.tsx +57 -57
  107. package/templates/nextblock-template/app/cms/settings/seo/actions.ts +448 -448
  108. package/templates/nextblock-template/app/cms/settings/seo/mappers.ts +93 -93
  109. package/templates/nextblock-template/app/cms/settings/seo/page.tsx +46 -46
  110. package/templates/nextblock-template/app/cms/settings/seo/require-admin.ts +47 -47
  111. package/templates/nextblock-template/app/cms/settings/site-scripts/page.tsx +51 -51
  112. package/templates/nextblock-template/app/cms/settings/taxes/page.tsx +21 -21
  113. package/templates/nextblock-template/app/cms/shipping/page.tsx +20 -20
  114. package/templates/nextblock-template/app/cms/users/components/DeleteUserButton.tsx +12 -12
  115. package/templates/nextblock-template/app/layout.tsx +671 -671
  116. package/templates/nextblock-template/app/lib/seo.ts +319 -319
  117. package/templates/nextblock-template/app/lib/ucp/protocol.ts +190 -190
  118. package/templates/nextblock-template/app/lib/ucp/server.test.ts +56 -56
  119. package/templates/nextblock-template/app/product/[slug]/page.tsx +502 -502
  120. package/templates/nextblock-template/app/profile/ProfilePageHeader.tsx +16 -16
  121. package/templates/nextblock-template/app/profile/ProfilePageMissingState.tsx +9 -9
  122. package/templates/nextblock-template/app/profile/account-links.ts +22 -22
  123. package/templates/nextblock-template/app/profile/orders/CustomerOrdersPageClient.tsx +124 -124
  124. package/templates/nextblock-template/app/profile/orders/page.tsx +19 -19
  125. package/templates/nextblock-template/app/profile/password/PasswordSettingsPageClient.tsx +128 -128
  126. package/templates/nextblock-template/app/profile/password/actions.ts +59 -59
  127. package/templates/nextblock-template/app/profile/password/page.tsx +27 -27
  128. package/templates/nextblock-template/app/providers.tsx +96 -96
  129. package/templates/nextblock-template/app/robots.ts +123 -123
  130. package/templates/nextblock-template/app/thread/ThreadView.tsx +164 -164
  131. package/templates/nextblock-template/app/thread/[token]/route.ts +57 -57
  132. package/templates/nextblock-template/app/thread/layout.tsx +15 -15
  133. package/templates/nextblock-template/app/thread/page.tsx +98 -98
  134. package/templates/nextblock-template/app/ucp/v1/carts/[id]/cancel/route.ts +38 -38
  135. package/templates/nextblock-template/app/ucp/v1/carts/[id]/route.ts +68 -68
  136. package/templates/nextblock-template/app/ucp/v1/carts/route.ts +35 -35
  137. package/templates/nextblock-template/app/ucp/v1/catalog/lookup/route.ts +35 -35
  138. package/templates/nextblock-template/app/ucp/v1/catalog/product/route.ts +35 -35
  139. package/templates/nextblock-template/app/ucp/v1/catalog/search/route.ts +34 -34
  140. package/templates/nextblock-template/components/BlockRenderer.tsx +312 -312
  141. package/templates/nextblock-template/components/CartDrawerLoader.tsx +7 -7
  142. package/templates/nextblock-template/components/CartTranslator.tsx +210 -210
  143. package/templates/nextblock-template/components/ContactSellerSection.tsx +188 -188
  144. package/templates/nextblock-template/components/DeferredCartDrawer.tsx +23 -23
  145. package/templates/nextblock-template/components/DeferredCartTranslator.tsx +51 -51
  146. package/templates/nextblock-template/components/DeferredGlobalSearch.tsx +68 -68
  147. package/templates/nextblock-template/components/DeferredGoogleTagManager.tsx +70 -70
  148. package/templates/nextblock-template/components/DeferredSpeedInsights.tsx +69 -69
  149. package/templates/nextblock-template/components/FooterNavigation.tsx +32 -32
  150. package/templates/nextblock-template/components/GlobalSearch.tsx +557 -557
  151. package/templates/nextblock-template/components/Header.tsx +38 -38
  152. package/templates/nextblock-template/components/HtmlScriptExecutor.tsx +47 -47
  153. package/templates/nextblock-template/components/LanguageSwitcher.tsx +2 -2
  154. package/templates/nextblock-template/components/PostCommentsSection.tsx +378 -378
  155. package/templates/nextblock-template/components/ProductReviewsSection.tsx +426 -426
  156. package/templates/nextblock-template/components/SiteScripts.tsx +56 -56
  157. package/templates/nextblock-template/components/StaffReplies.tsx +102 -102
  158. package/templates/nextblock-template/components/blocks/PostCardSkeleton.tsx +12 -12
  159. package/templates/nextblock-template/components/blocks/PostsGridBlock.tsx +12 -12
  160. package/templates/nextblock-template/components/blocks/PostsGridClient.tsx +48 -48
  161. package/templates/nextblock-template/components/blocks/TestimonialBlock.tsx +9 -9
  162. package/templates/nextblock-template/components/blocks/ecommerceRendererLoaders.ts +23 -23
  163. package/templates/nextblock-template/components/blocks/publicRendererLoaders.ts +25 -25
  164. package/templates/nextblock-template/components/blocks/renderers/ButtonBlockRenderer.tsx +92 -92
  165. package/templates/nextblock-template/components/blocks/renderers/CartBlockRenderer.tsx +18 -18
  166. package/templates/nextblock-template/components/blocks/renderers/CheckoutBlockRenderer.tsx +20 -20
  167. package/templates/nextblock-template/components/blocks/renderers/FeaturedProductBlockRenderer.tsx +25 -25
  168. package/templates/nextblock-template/components/blocks/renderers/FormBlockRenderer.tsx +385 -385
  169. package/templates/nextblock-template/components/blocks/renderers/PostsGridBlockRenderer.tsx +24 -24
  170. package/templates/nextblock-template/components/blocks/renderers/ProductDetailsBlockRenderer.tsx +157 -157
  171. package/templates/nextblock-template/components/blocks/renderers/ProductGridBlockRenderer.tsx +34 -34
  172. package/templates/nextblock-template/components/blocks/renderers/SectionBlockRenderer.tsx +612 -612
  173. package/templates/nextblock-template/components/blocks/renderers/TestimonialBlockRenderer.tsx +57 -57
  174. package/templates/nextblock-template/components/blocks/renderers/inline/AlertWidgetRenderer.tsx +2 -2
  175. package/templates/nextblock-template/components/blocks/renderers/inline/CtaWidgetRenderer.tsx +2 -2
  176. package/templates/nextblock-template/components/blocks/types.ts +7 -7
  177. package/templates/nextblock-template/components/commerce/PaymentReadinessBoundary.tsx +32 -32
  178. package/templates/nextblock-template/components/env-var-warning.tsx +3 -3
  179. package/templates/nextblock-template/components/form-message.tsx +32 -32
  180. package/templates/nextblock-template/components/seo/GenerateMetaButton.tsx +137 -137
  181. package/templates/nextblock-template/components/seo/PageSeoAuditSection.tsx +251 -244
  182. package/templates/nextblock-template/components/seo/SeoAuditPanel.tsx +749 -749
  183. package/templates/nextblock-template/components/seo/SeoIssueList.tsx +195 -195
  184. package/templates/nextblock-template/components/seo/SeoScoreDial.tsx +144 -144
  185. package/templates/nextblock-template/components/seo/SocialPreview.tsx +243 -243
  186. package/templates/nextblock-template/components/seo/SocialPreviewDialog.tsx +110 -110
  187. package/templates/nextblock-template/components/submit-button.tsx +23 -23
  188. package/templates/nextblock-template/components/theme-icon.tsx +78 -78
  189. package/templates/nextblock-template/components/theme-switcher.tsx +85 -85
  190. package/templates/nextblock-template/context/AuthContext.tsx +23 -23
  191. package/templates/nextblock-template/context/ThemeCatalogContext.tsx +44 -44
  192. package/templates/nextblock-template/docs/01-PROJECT-OVERVIEW.md +94 -94
  193. package/templates/nextblock-template/docs/03-CMS-AND-EDITOR.md +77 -77
  194. package/templates/nextblock-template/docs/13-STAYING-UP-TO-DATE.md +372 -372
  195. package/templates/nextblock-template/docs/14-MESSAGES-INBOX.md +309 -309
  196. package/templates/nextblock-template/docs/README.md +42 -42
  197. package/templates/nextblock-template/docs/TECHNICAL_SPECIFICATION.md +12506 -12506
  198. package/templates/nextblock-template/hooks/use-hotkeys.ts +21 -21
  199. package/templates/nextblock-template/hooks/useGlobalSearch.ts +101 -101
  200. package/templates/nextblock-template/index.d.ts +7 -7
  201. package/templates/nextblock-template/lib/auth-redirects.ts +46 -46
  202. package/templates/nextblock-template/lib/blocks/blockColors.test.ts +134 -134
  203. package/templates/nextblock-template/lib/blocks/blockColors.ts +175 -175
  204. package/templates/nextblock-template/lib/blocks/blockRegistry.ts +761 -761
  205. package/templates/nextblock-template/lib/blocks/inlineScriptNonce.ts +20 -20
  206. package/templates/nextblock-template/lib/cms/contact-reminder.ts +64 -64
  207. package/templates/nextblock-template/lib/cms/payments-reminder.test.ts +135 -0
  208. package/templates/nextblock-template/lib/cms/payments-reminder.ts +30 -23
  209. package/templates/nextblock-template/lib/cms/unread-messages.ts +42 -42
  210. package/templates/nextblock-template/lib/commerce/seller-contact.ts +162 -162
  211. package/templates/nextblock-template/lib/config/email-settings.ts +323 -323
  212. package/templates/nextblock-template/lib/config/email-tls.test.ts +57 -57
  213. package/templates/nextblock-template/lib/cortex-ai/alt-text-request.ts +86 -86
  214. package/templates/nextblock-template/lib/cortex-ai/sandbox-headers.ts +60 -60
  215. package/templates/nextblock-template/lib/email/placeholder-address.test.ts +59 -59
  216. package/templates/nextblock-template/lib/email/placeholder-address.ts +39 -39
  217. package/templates/nextblock-template/lib/messages/thread-reference.test.ts +70 -70
  218. package/templates/nextblock-template/lib/messages/thread-token.test.ts +93 -93
  219. package/templates/nextblock-template/lib/messages/thread-token.ts +157 -157
  220. package/templates/nextblock-template/lib/messages/threads.ts +579 -579
  221. package/templates/nextblock-template/lib/posts/readTime.ts +60 -60
  222. package/templates/nextblock-template/lib/publishing/viewUrl.ts +26 -26
  223. package/templates/nextblock-template/lib/search/types.ts +27 -27
  224. package/templates/nextblock-template/lib/seo/alt-text-write-back.test.ts +154 -154
  225. package/templates/nextblock-template/lib/seo/alt-text-write-back.ts +109 -109
  226. package/templates/nextblock-template/lib/seo/block-content.ts +123 -123
  227. package/templates/nextblock-template/lib/seo/fix-prompts.test.ts +242 -242
  228. package/templates/nextblock-template/lib/seo/fix-prompts.ts +204 -204
  229. package/templates/nextblock-template/lib/seo/page-audit-context.tsx +157 -140
  230. package/templates/nextblock-template/lib/seo/page-document.test.ts +384 -350
  231. package/templates/nextblock-template/lib/seo/page-document.ts +463 -412
  232. package/templates/nextblock-template/lib/seo/redirect-store.test.ts +479 -479
  233. package/templates/nextblock-template/lib/seo/redirect-store.ts +466 -466
  234. package/templates/nextblock-template/lib/seo/robots-settings-signature.test.ts +102 -102
  235. package/templates/nextblock-template/lib/seo/robots-settings-signature.ts +41 -41
  236. package/templates/nextblock-template/lib/seo/robots-txt.test.ts +370 -370
  237. package/templates/nextblock-template/lib/seo/robots-txt.ts +510 -510
  238. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +37 -22
  239. package/templates/nextblock-template/lib/site-scripts/revisions.ts +71 -71
  240. package/templates/nextblock-template/lib/site-scripts/types.ts +46 -46
  241. package/templates/nextblock-template/lib/site-url.test.ts +89 -89
  242. package/templates/nextblock-template/lib/site-url.ts +102 -102
  243. package/templates/nextblock-template/lib/themes/buildThemeCss.ts +124 -124
  244. package/templates/nextblock-template/lib/themes/tokenColor.ts +31 -31
  245. package/templates/nextblock-template/lib/themes/tokens.ts +143 -143
  246. package/templates/nextblock-template/lib/visual-editing/draft-content.test.ts +105 -105
  247. package/templates/nextblock-template/lib/visual-editing/draft-route.test.ts +42 -42
  248. package/templates/nextblock-template/lib/visual-editing/edit-info.test.ts +143 -143
  249. package/templates/nextblock-template/lib/visual-editing/edit-info.ts +94 -94
  250. package/templates/nextblock-template/lib/visual-editing/product-drafts.test.ts +81 -81
  251. package/templates/nextblock-template/lib/zod-config.ts +5 -5
  252. package/templates/nextblock-template/next-env.d.ts +1 -2
  253. package/templates/nextblock-template/package.json +1 -1
  254. package/templates/nextblock-template/postcss.config.js +6 -6
  255. package/templates/nextblock-template/scripts/backup.js +115 -115
  256. package/templates/nextblock-template/scripts/restore.js +385 -385
  257. package/templates/nextblock-template/scripts/validate-editor-block-schema.ts +112 -112
  258. package/templates/nextblock-template/tailwind.config.js +25 -25
  259. package/templates/nextblock-template/tools/build-migrate.mjs +102 -102
  260. package/templates/nextblock-template/tools/configure-supabase-auth.js +282 -282
  261. package/templates/nextblock-template/tools/deploy-supabase.js +159 -159
  262. package/templates/nextblock-template/tools/lib/migrate-core.mjs +569 -569
  263. package/templates/nextblock-template/tools/update.mjs +1303 -1303
  264. package/tsconfig.base.json +3 -3
@@ -1,948 +1,948 @@
1
- 'use client';
2
-
3
- import React, { useEffect, useState } from 'react';
4
- import {
5
- Alert,
6
- AlertDescription,
7
- AlertTitle,
8
- Badge,
9
- Button,
10
- Card,
11
- CardContent,
12
- CardDescription,
13
- CardHeader,
14
- CardTitle,
15
- Input,
16
- Label,
17
- SearchableSelect,
18
- } from '@nextblock-cms/ui';
19
- import {
20
- AlertTriangle,
21
- Brain,
22
- CheckCircle2,
23
- ChevronDown,
24
- ChevronRight,
25
- Cpu,
26
- ImageIcon,
27
- Info,
28
- KeyRound,
29
- Lock,
30
- RotateCcw,
31
- SlidersHorizontal,
32
- Trash2,
33
- } from 'lucide-react';
34
-
35
- import {
36
- createCortexAiStoredModelSelection,
37
- type CortexAiStoredModelSelection,
38
- } from '@nextblock-cms/cortex/client';
39
- import type {
40
- CortexAiAgentSettings,
41
- CortexAiCompatibleOpenRouterModel,
42
- } from '@nextblock-cms/cortex';
43
- import {
44
- clearCortexAiModelSelectionAction,
45
- clearOpenRouterApiKeyAction,
46
- clearStockPhotoKeysAction,
47
- resetCortexAiAgentSettingsAction,
48
- saveCortexAiAgentSettingsAction,
49
- saveCortexAiModelSelectionAction,
50
- saveOpenRouterApiKeyAction,
51
- saveStockPhotoKeysAction,
52
- } from './actions';
53
-
54
- /**
55
- * The one Cortex AI settings UI — production and sandbox render the same tree.
56
- *
57
- * This page used to be two forked components (`StoredCortexAiSettingsClient` and
58
- * `SandboxCortexAiSettingsClient`) that shared a layout by copy-paste. Every design
59
- * change had to be made twice, and twice it wasn't: the sandbox drifted behind and
60
- * never gained the MCP card at all. So there is exactly one component now, and
61
- * `isSandbox` is a prop rather than a second file.
62
- *
63
- * The rule for anything the sandbox cannot do: **disable it, never hide it.** A
64
- * visitor evaluating NextBlock should be able to see that stock-photo keys, agent
65
- * tuning, and MCP access exist and what they look like — a hidden control teaches
66
- * them the feature doesn't exist. Only the two settings that have a per-visitor
67
- * channel (the OpenRouter key and model, which live in this browser's localStorage
68
- * and travel as `x-sandbox-openrouter-*` headers) stay writable in the sandbox.
69
- */
70
-
71
- const CORTEX_AI_SANDBOX_KEY_LOCAL_STORAGE = 'cortex_ai_sandbox_openrouter_api_key';
72
- const CORTEX_AI_SANDBOX_MODEL_LOCAL_STORAGE = 'cortex_ai_sandbox_openrouter_model_selection';
73
- const CORTEX_AI_SETTINGS_CHANGED_EVENT = 'nextblock:cortex-ai-settings-changed';
74
-
75
- type CortexAiSettingsClientProps = {
76
- /**
77
- * Shared-sandbox mode. Server-backed settings become read-only because the
78
- * settings actions refuse to write to the shared sandbox DB anyway; showing an
79
- * editable control that always errors is worse than showing a locked one.
80
- */
81
- isSandbox: boolean;
82
- compatibleModels: CortexAiCompatibleOpenRouterModel[];
83
- isPackageActive: boolean;
84
- hasEnvOpenRouterKey: boolean;
85
- maskedEnvOpenRouterKey: string | null;
86
- hasStoredOpenRouterKey: boolean;
87
- maskedStoredOpenRouterKey: string | null;
88
- selectedModel: CortexAiStoredModelSelection | null;
89
- hasEncryptionKey: boolean;
90
- modelCatalogError: string | null;
91
- activeStockProvider: 'pexels' | 'unsplash' | null;
92
- hasStoredPexelsKey: boolean;
93
- maskedStoredPexelsKey: string | null;
94
- hasStoredUnsplashKey: boolean;
95
- maskedStoredUnsplashKey: string | null;
96
- hasEnvPexelsKey: boolean;
97
- hasEnvUnsplashKey: boolean;
98
- unsplashAppName: string | null;
99
- agentSettings: CortexAiAgentSettings;
100
- /** Slot for server-rendered cards (currently the MCP server access card). */
101
- children?: React.ReactNode;
102
- successMessage?: string;
103
- errorMessage?: string;
104
- };
105
-
106
- function formatTokenPrice(value: string | undefined) {
107
- const amount = Number(value);
108
- if (!Number.isFinite(amount)) return null;
109
- if (amount === 0) return '$0';
110
- const perMillion = amount * 1_000_000;
111
- return `$${perMillion < 0.01 ? perMillion.toFixed(4) : perMillion.toFixed(2)}`;
112
- }
113
-
114
- function formatModelPricing(pricing: Record<string, string>) {
115
- const promptPrice = formatTokenPrice(pricing.prompt);
116
- const completionPrice = formatTokenPrice(pricing.completion);
117
- if (promptPrice === '$0' && completionPrice === '$0') return 'Free';
118
- if (promptPrice && completionPrice) return `${promptPrice}/1M input - ${completionPrice}/1M output`;
119
- return 'Pricing varies';
120
- }
121
-
122
- function getMaskedKey(key: string) {
123
- if (key.length <= 8) return '****';
124
- return `**** ${key.slice(-4)}`;
125
- }
126
-
127
- function notifyCortexAiSettingsChanged() {
128
- window.dispatchEvent(new Event(CORTEX_AI_SETTINGS_CHANGED_EVENT));
129
- }
130
-
131
- function StatusPill({
132
- label,
133
- value,
134
- active,
135
- detail,
136
- }: {
137
- label: string;
138
- value: string;
139
- active: boolean;
140
- detail?: string | null;
141
- }) {
142
- return (
143
- <div className="flex min-w-[8rem] flex-col gap-1 rounded-md border bg-muted/30 px-3 py-2">
144
- <span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
145
- {label}
146
- </span>
147
- <div className="flex items-center gap-2">
148
- <span className={`h-2 w-2 rounded-full ${active ? 'bg-emerald-500' : 'bg-muted-foreground/40'}`} />
149
- <span className="text-sm font-medium">{value}</span>
150
- </div>
151
- {detail && <span className="truncate font-mono text-[11px] text-muted-foreground">{detail}</span>}
152
- </div>
153
- );
154
- }
155
-
156
- /** The marker every locked-in-sandbox card carries, so "disabled" never reads as "broken". */
157
- function ReadOnlyBadge({ className }: { className?: string }) {
158
- return (
159
- <Badge variant="outline" className={`gap-1 font-normal ${className || ''}`}>
160
- <Lock className="h-3 w-3" />
161
- Read-only
162
- </Badge>
163
- );
164
- }
165
-
166
- /**
167
- * "Clear" affordance for the key / model cards.
168
- *
169
- * Production posts to a server action so the row is deleted from `site_settings`;
170
- * the sandbox drops the value from localStorage. Same button either way.
171
- */
172
- function ClearButton({
173
- isSandbox,
174
- onSandboxClear,
175
- serverAction,
176
- }: {
177
- isSandbox: boolean;
178
- onSandboxClear: () => void;
179
- serverAction: () => void | Promise<void>;
180
- }) {
181
- const className = 'h-7 text-destructive hover:text-destructive';
182
-
183
- if (isSandbox) {
184
- return (
185
- <Button type="button" onClick={onSandboxClear} variant="ghost" size="sm" className={className}>
186
- <Trash2 className="mr-1.5 h-3.5 w-3.5" />
187
- Clear
188
- </Button>
189
- );
190
- }
191
-
192
- return (
193
- <form action={serverAction} onSubmit={notifyCortexAiSettingsChanged}>
194
- <Button type="submit" variant="ghost" size="sm" className={className}>
195
- <Trash2 className="mr-1.5 h-3.5 w-3.5" />
196
- Clear
197
- </Button>
198
- </form>
199
- );
200
- }
201
-
202
- export function CortexAiSettingsClient({
203
- isSandbox,
204
- compatibleModels,
205
- isPackageActive,
206
- hasEnvOpenRouterKey,
207
- maskedEnvOpenRouterKey,
208
- hasStoredOpenRouterKey,
209
- maskedStoredOpenRouterKey,
210
- selectedModel,
211
- hasEncryptionKey,
212
- modelCatalogError,
213
- activeStockProvider,
214
- hasStoredPexelsKey,
215
- maskedStoredPexelsKey,
216
- hasStoredUnsplashKey,
217
- maskedStoredUnsplashKey,
218
- hasEnvPexelsKey,
219
- hasEnvUnsplashKey,
220
- unsplashAppName,
221
- agentSettings,
222
- children,
223
- successMessage,
224
- errorMessage,
225
- }: CortexAiSettingsClientProps) {
226
- const [apiKeyInput, setApiKeyInput] = useState('');
227
- // In the sandbox the stored selection belongs to the shared DB, not to this
228
- // visitor — the effect below fills the field in from localStorage instead.
229
- const [modelInput, setModelInput] = useState<string>(isSandbox ? '' : selectedModel?.modelId || '');
230
- const [pexelsInput, setPexelsInput] = useState('');
231
- const [unsplashInput, setUnsplashInput] = useState('');
232
- const [appNameInput, setAppNameInput] = useState(unsplashAppName || '');
233
- const [showAdvanced, setShowAdvanced] = useState(false);
234
- const [unlimitedTokens, setUnlimitedTokens] = useState(agentSettings.maxOutputTokens === null);
235
- const [maxTokensInput, setMaxTokensInput] = useState(String(agentSettings.maxOutputTokens ?? 16000));
236
- const [maxStepsInput, setMaxStepsInput] = useState(String(agentSettings.maxSteps));
237
- const [temperatureInput, setTemperatureInput] = useState(String(agentSettings.temperature));
238
- const [timeoutInput, setTimeoutInput] = useState(String(Math.round(agentSettings.responseTimeoutMs / 1000)));
239
-
240
- // Sandbox-only, per-browser credentials. Read after mount so the first client
241
- // render still matches the server HTML.
242
- const [sandboxKey, setSandboxKey] = useState<string | null>(null);
243
- const [sandboxModel, setSandboxModel] = useState<CortexAiStoredModelSelection | null>(null);
244
- const [sandboxMessage, setSandboxMessage] = useState<string | null>(null);
245
-
246
- useEffect(() => {
247
- if (!isSandbox) return;
248
-
249
- try {
250
- const storedKey = window.localStorage.getItem(CORTEX_AI_SANDBOX_KEY_LOCAL_STORAGE);
251
- if (storedKey) {
252
- setSandboxKey(storedKey);
253
- }
254
-
255
- const storedModel = window.localStorage.getItem(CORTEX_AI_SANDBOX_MODEL_LOCAL_STORAGE);
256
- if (storedModel) {
257
- const parsed = JSON.parse(storedModel) as CortexAiStoredModelSelection;
258
- setSandboxModel(parsed);
259
- setModelInput(parsed.modelId);
260
- }
261
- } catch (error) {
262
- console.error('Failed to read Cortex AI sandbox settings from localStorage', error);
263
- }
264
- }, [isSandbox]);
265
-
266
- function flashSandboxMessage(message: string) {
267
- setSandboxMessage(message);
268
- setTimeout(() => setSandboxMessage(null), 3000);
269
- }
270
-
271
- function handleSandboxSaveKey(event: React.FormEvent) {
272
- event.preventDefault();
273
- const key = apiKeyInput.trim();
274
- if (!key) return;
275
-
276
- try {
277
- window.localStorage.setItem(CORTEX_AI_SANDBOX_KEY_LOCAL_STORAGE, key);
278
- setSandboxKey(key);
279
- setApiKeyInput('');
280
- notifyCortexAiSettingsChanged();
281
- flashSandboxMessage('Sandbox OpenRouter key saved to your browser.');
282
- } catch (error) {
283
- console.error('Failed to save sandbox key', error);
284
- }
285
- }
286
-
287
- function handleSandboxClearKey() {
288
- try {
289
- window.localStorage.removeItem(CORTEX_AI_SANDBOX_KEY_LOCAL_STORAGE);
290
- window.localStorage.removeItem(CORTEX_AI_SANDBOX_MODEL_LOCAL_STORAGE);
291
- setSandboxKey(null);
292
- setSandboxModel(null);
293
- setModelInput('');
294
- notifyCortexAiSettingsChanged();
295
- flashSandboxMessage('Sandbox OpenRouter key cleared from your browser.');
296
- } catch (error) {
297
- console.error('Failed to clear sandbox key', error);
298
- }
299
- }
300
-
301
- function handleSandboxSaveModel(event: React.FormEvent<HTMLFormElement>) {
302
- event.preventDefault();
303
- const formData = new FormData(event.currentTarget);
304
- const modelId = String(formData.get('openrouter_model_id') || '').trim();
305
- if (!modelId) return;
306
-
307
- const model = compatibleModels.find((candidate) => candidate.id === modelId);
308
- if (!model) return;
309
-
310
- try {
311
- const storedSelection = createCortexAiStoredModelSelection(model);
312
- window.localStorage.setItem(
313
- CORTEX_AI_SANDBOX_MODEL_LOCAL_STORAGE,
314
- JSON.stringify(storedSelection)
315
- );
316
- setSandboxModel(storedSelection);
317
- notifyCortexAiSettingsChanged();
318
- flashSandboxMessage('Sandbox Cortex AI model selection saved to your browser.');
319
- } catch (error) {
320
- console.error('Failed to save sandbox model', error);
321
- }
322
- }
323
-
324
- function handleSandboxClearModel() {
325
- try {
326
- window.localStorage.removeItem(CORTEX_AI_SANDBOX_MODEL_LOCAL_STORAGE);
327
- setSandboxModel(null);
328
- setModelInput('');
329
- notifyCortexAiSettingsChanged();
330
- flashSandboxMessage('Sandbox Cortex AI model selection cleared.');
331
- } catch (error) {
332
- console.error('Failed to clear sandbox model', error);
333
- }
334
- }
335
-
336
- // One set of derived values, sourced from localStorage in the sandbox and from
337
- // the database everywhere else. Everything below renders off these, so the two
338
- // environments cannot drift apart visually.
339
- const hasKey = isSandbox ? Boolean(sandboxKey) : hasStoredOpenRouterKey;
340
- const maskedKey = isSandbox
341
- ? sandboxKey
342
- ? getMaskedKey(sandboxKey)
343
- : null
344
- : maskedStoredOpenRouterKey;
345
- const activeModel = isSandbox ? sandboxModel : selectedModel;
346
-
347
- const selectedModelIsInCatalog = compatibleModels.some((model) => model.id === activeModel?.modelId);
348
- const modelOptions: CortexAiCompatibleOpenRouterModel[] =
349
- activeModel && !selectedModelIsInCatalog
350
- ? [
351
- {
352
- contextLength: activeModel.contextLength,
353
- created: null,
354
- expirationDate: null,
355
- id: activeModel.modelId,
356
- name: `${activeModel.name} (saved)`,
357
- pricing: activeModel.pricing,
358
- supportedParameters: activeModel.supportedParameters,
359
- },
360
- ...compatibleModels,
361
- ]
362
- : compatibleModels;
363
-
364
- const canSelectModel = hasKey && compatibleModels.length > 0;
365
-
366
- const searchableOptions = modelOptions.map((model) => ({
367
- value: model.id,
368
- label: model.name,
369
- description: `${model.id} - ${formatModelPricing(model.pricing)}`,
370
- }));
371
-
372
- const isKeyDirty = apiKeyInput.trim().length > 0;
373
- const isModelDirty = modelInput !== (activeModel?.modelId || '');
374
- const isStockDirty =
375
- pexelsInput.trim().length > 0 ||
376
- unsplashInput.trim().length > 0 ||
377
- appNameInput.trim() !== (unsplashAppName || '');
378
-
379
- const keySourceValue = hasKey
380
- ? isSandbox
381
- ? 'Sandbox BYOK'
382
- : 'Stored BYOK'
383
- : hasEnvOpenRouterKey
384
- ? 'Environment'
385
- : 'None';
386
-
387
- // Ordered by preference (Pexels primary, Unsplash fallback). Configured = a
388
- // stored DB key OR an env var for that provider.
389
- const configuredStockProviders = [
390
- hasStoredPexelsKey || hasEnvPexelsKey ? { name: 'Pexels', stored: hasStoredPexelsKey } : null,
391
- hasStoredUnsplashKey || hasEnvUnsplashKey ? { name: 'Unsplash', stored: hasStoredUnsplashKey } : null,
392
- ].filter(Boolean) as Array<{ name: string; stored: boolean }>;
393
- const stockValue =
394
- configuredStockProviders.length > 0
395
- ? configuredStockProviders.map((provider) => provider.name).join(' + ')
396
- : 'Off';
397
-
398
- function stockKeyPlaceholder(hasStored: boolean, hasEnv: boolean, fallback: string) {
399
- if (!isSandbox) {
400
- return hasStored ? 'Enter new key to overwrite...' : fallback;
401
- }
402
- // Disabled password inputs render empty, so the placeholder has to carry the state.
403
- return hasStored ? 'Configured (stored)' : hasEnv ? 'Configured (env)' : 'Not set';
404
- }
405
-
406
- // Server actions in the sandbox would be rejected by the guards in `actions.ts`,
407
- // so sandbox forms are wired to local handlers (key/model) or neutered (the rest).
408
- const keyFormProps = isSandbox
409
- ? { onSubmit: handleSandboxSaveKey }
410
- : { action: saveOpenRouterApiKeyAction, onSubmit: notifyCortexAiSettingsChanged };
411
- const modelFormProps = isSandbox
412
- ? { onSubmit: handleSandboxSaveModel }
413
- : { action: saveCortexAiModelSelectionAction, onSubmit: notifyCortexAiSettingsChanged };
414
- const stockFormProps = isSandbox
415
- ? { onSubmit: (event: React.FormEvent) => event.preventDefault() }
416
- : { action: saveStockPhotoKeysAction, onSubmit: notifyCortexAiSettingsChanged };
417
- const agentFormProps = isSandbox
418
- ? { onSubmit: (event: React.FormEvent) => event.preventDefault() }
419
- : { action: saveCortexAiAgentSettingsAction, onSubmit: notifyCortexAiSettingsChanged };
420
-
421
- const banner = sandboxMessage
422
- ? { message: sandboxMessage, variant: 'success' as const }
423
- : successMessage
424
- ? { message: successMessage, variant: 'success' as const }
425
- : null;
426
-
427
- return (
428
- <div className="mx-auto w-full max-w-5xl space-y-4 px-4 py-6">
429
- <div className="flex items-center gap-2.5">
430
- <Brain className="h-6 w-6 text-primary" />
431
- <div>
432
- <h1 className="text-xl font-semibold leading-tight">
433
- NextBlock Cortex AI
434
- {isSandbox && (
435
- <Badge variant="secondary" className="ml-2 align-middle font-normal">
436
- Sandbox
437
- </Badge>
438
- )}
439
- </h1>
440
- <p className="text-xs text-muted-foreground">
441
- {isSandbox
442
- ? 'Set the OpenRouter key and model for your own browser session. Everything else is shown as configured by the sandbox host.'
443
- : 'Manage activation, the OpenRouter model key, stock-photo providers, and MCP access.'}
444
- </p>
445
- </div>
446
- </div>
447
-
448
- {banner && (
449
- <Alert variant={banner.variant}>
450
- <CheckCircle2 className="h-4 w-4" />
451
- <AlertTitle>Saved</AlertTitle>
452
- <AlertDescription>{banner.message}</AlertDescription>
453
- </Alert>
454
- )}
455
-
456
- {errorMessage && (
457
- <Alert variant="destructive">
458
- <AlertTriangle className="h-4 w-4" />
459
- <AlertTitle>Unable to save</AlertTitle>
460
- <AlertDescription>{errorMessage}</AlertDescription>
461
- </Alert>
462
- )}
463
-
464
- {/* Compact status strip */}
465
- <div className="flex flex-wrap gap-2">
466
- <StatusPill label="Package" value={isPackageActive ? 'Active' : 'Inactive'} active={isPackageActive} />
467
- <StatusPill
468
- label="Model key"
469
- value={keySourceValue}
470
- active={hasKey || hasEnvOpenRouterKey}
471
- detail={maskedKey || maskedEnvOpenRouterKey}
472
- />
473
- <StatusPill
474
- label="Model"
475
- value={activeModel ? activeModel.name : 'Free registry'}
476
- active={Boolean(activeModel)}
477
- />
478
- <StatusPill label="Stock photos" value={stockValue} active={Boolean(activeStockProvider)} />
479
- </div>
480
-
481
- {isSandbox && (
482
- <Alert
483
- variant="warning"
484
- className="border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-200"
485
- >
486
- <Info className="h-4 w-4" />
487
- <AlertTitle>Sandbox environment active</AlertTitle>
488
- <AlertDescription>
489
- The key and model you set here are stored{' '}
490
- <strong>only in your own browser (localStorage)</strong> and are never written to the
491
- shared sandbox database. Everything else on this page is shown exactly as it appears on a
492
- real install, but locked — the sandbox is shared by every visitor.
493
- </AlertDescription>
494
- </Alert>
495
- )}
496
-
497
- {isSandbox && hasEnvOpenRouterKey && !hasKey && (
498
- <Alert>
499
- <KeyRound className="h-4 w-4" />
500
- <AlertTitle>Free-model lock active</AlertTitle>
501
- <AlertDescription>
502
- Cortex AI will only use the configured free OpenRouter models until you save a sandbox key
503
- to your browser.
504
- </AlertDescription>
505
- </Alert>
506
- )}
507
-
508
- {!isSandbox && !hasEncryptionKey && (
509
- <Alert variant="warning">
510
- <AlertTriangle className="h-4 w-4" />
511
- <AlertTitle>Encryption key missing</AlertTitle>
512
- <AlertDescription>
513
- Set CORTEX_AI_ENCRYPTION_KEY (or rely on the Supabase service-role fallback) before saving keys here.
514
- </AlertDescription>
515
- </Alert>
516
- )}
517
-
518
- {/* OpenRouter BYOK + Model side by side */}
519
- <div className="grid gap-4 lg:grid-cols-2">
520
- <Card>
521
- <CardHeader className="flex flex-row items-start justify-between space-y-0 pb-3">
522
- <div>
523
- <CardTitle className="text-base">OpenRouter key</CardTitle>
524
- <CardDescription className="text-xs">
525
- {isSandbox ? 'Saved to your browser only, never uploaded.' : 'Encrypted, masked after saving.'}
526
- </CardDescription>
527
- </div>
528
- {hasKey && (
529
- <ClearButton
530
- isSandbox={isSandbox}
531
- onSandboxClear={handleSandboxClearKey}
532
- serverAction={clearOpenRouterApiKeyAction}
533
- />
534
- )}
535
- </CardHeader>
536
- <CardContent className="pt-0">
537
- <form {...keyFormProps} className="flex items-end gap-2">
538
- <div className="flex-1 space-y-1.5">
539
- <Label htmlFor="openrouter_api_key" className="text-xs">
540
- API key
541
- </Label>
542
- <Input
543
- id="openrouter_api_key"
544
- name="openrouter_api_key"
545
- type="password"
546
- autoComplete="off"
547
- minLength={12}
548
- placeholder={hasKey ? 'Enter new key to overwrite...' : 'sk-or-v1-...'}
549
- value={apiKeyInput}
550
- onChange={(e) => setApiKeyInput(e.target.value)}
551
- required
552
- />
553
- </div>
554
- <Button type="submit" disabled={!isKeyDirty} size="sm">
555
- <KeyRound className="mr-1.5 h-3.5 w-3.5" />
556
- Save
557
- </Button>
558
- </form>
559
- </CardContent>
560
- </Card>
561
-
562
- <Card>
563
- <CardHeader className="flex flex-row items-start justify-between space-y-0 pb-3">
564
- <div>
565
- <CardTitle className="text-base">OpenRouter model</CardTitle>
566
- <CardDescription className="text-xs">
567
- Needs {isSandbox ? 'a sandbox key' : 'a stored key'}; supports tools + structured output.
568
- </CardDescription>
569
- </div>
570
- {activeModel && (
571
- <ClearButton
572
- isSandbox={isSandbox}
573
- onSandboxClear={handleSandboxClearModel}
574
- serverAction={clearCortexAiModelSelectionAction}
575
- />
576
- )}
577
- </CardHeader>
578
- <CardContent className="space-y-2 pt-0">
579
- {modelCatalogError && (
580
- <Alert variant="warning">
581
- <AlertTriangle className="h-4 w-4" />
582
- <AlertTitle>Model catalog unavailable</AlertTitle>
583
- <AlertDescription>{modelCatalogError}</AlertDescription>
584
- </Alert>
585
- )}
586
- <form {...modelFormProps} className="flex items-end gap-2">
587
- <input type="hidden" name="openrouter_model_id" value={modelInput} />
588
- <div className="flex-1 space-y-1.5">
589
- <Label htmlFor="openrouter_model_id_select" className="text-xs">
590
- Model
591
- </Label>
592
- <SearchableSelect
593
- options={searchableOptions}
594
- value={modelInput}
595
- onChange={(val) => setModelInput(val)}
596
- disabled={!canSelectModel}
597
- placeholder="Select a compatible model..."
598
- />
599
- </div>
600
- <Button type="submit" disabled={!canSelectModel || !isModelDirty} size="sm">
601
- <Cpu className="mr-1.5 h-3.5 w-3.5" />
602
- Save
603
- </Button>
604
- </form>
605
- <p className="text-[11px] text-muted-foreground">
606
- {canSelectModel
607
- ? `${compatibleModels.length} compatible models available.`
608
- : `Cortex AI uses the free registry until a ${isSandbox ? 'sandbox' : 'stored'} key + model are set.`}
609
- </p>
610
- </CardContent>
611
- </Card>
612
- </div>
613
-
614
- {/* Stock photos */}
615
- <Card>
616
- <CardHeader className="flex flex-row items-start justify-between space-y-0 pb-3">
617
- <div>
618
- <CardTitle className="flex flex-wrap items-center gap-2 text-base">
619
- <ImageIcon className="h-4 w-4" />
620
- Stock photos
621
- {configuredStockProviders.length > 0 ? (
622
- configuredStockProviders.map((provider, index) => (
623
- <Badge
624
- key={provider.name}
625
- variant={index === 0 ? 'default' : 'secondary'}
626
- className="ml-0.5 font-normal"
627
- >
628
- {provider.name} · {index === 0 ? 'primary' : 'fallback'}
629
- {!provider.stored && ' (env)'}
630
- </Badge>
631
- ))
632
- ) : (
633
- <Badge variant="outline" className="ml-0.5">
634
- Not configured
635
- </Badge>
636
- )}
637
- {isSandbox && <ReadOnlyBadge />}
638
- </CardTitle>
639
- <CardDescription className="text-xs">
640
- Free Pexels/Unsplash key so Cortex inserts real photos into pages. Pexels is used first; Cortex
641
- automatically falls back to Unsplash if Pexels is rate-limited. Optional but recommended.
642
- </CardDescription>
643
- </div>
644
- {(hasStoredPexelsKey || hasStoredUnsplashKey) &&
645
- (isSandbox ? (
646
- <Button
647
- type="button"
648
- variant="ghost"
649
- size="sm"
650
- className="h-7 text-destructive hover:text-destructive"
651
- disabled
652
- >
653
- <Trash2 className="mr-1.5 h-3.5 w-3.5" />
654
- Clear
655
- </Button>
656
- ) : (
657
- <form action={clearStockPhotoKeysAction} onSubmit={notifyCortexAiSettingsChanged}>
658
- <Button type="submit" variant="ghost" size="sm" className="h-7 text-destructive hover:text-destructive">
659
- <Trash2 className="mr-1.5 h-3.5 w-3.5" />
660
- Clear
661
- </Button>
662
- </form>
663
- ))}
664
- </CardHeader>
665
- <CardContent className="space-y-4 pt-0">
666
- <div className="grid gap-4 md:grid-cols-2">
667
- {/* Why + how */}
668
- <div className="rounded-md border bg-muted/20 p-3 text-sm">
669
- <p className="font-medium">Why add a key?</p>
670
- <p className="mt-1 text-xs text-muted-foreground">
671
- When you ask Cortex to build or revamp a page, a stock key lets it fetch relevant, high-quality
672
- photos for the hero and sections automatically — instant, zero image-generation cost, and you can
673
- save any photo into your media library with one click. Without a key Cortex still builds pages, but
674
- uses gradient/theme backgrounds instead of photos, and it will not call the photo tool at all.
675
- </p>
676
- <p className="mt-3 font-medium">Get a free key (pick one):</p>
677
- <ol className="mt-1 list-decimal space-y-1 pl-4 text-xs text-muted-foreground">
678
- <li>
679
- <span className="font-medium text-foreground">Pexels</span> — open{' '}
680
- <span className="font-mono">pexels.com/api</span>, sign in, click “Get Started / Your API Key”,
681
- copy the key.
682
- </li>
683
- <li>
684
- <span className="font-medium text-foreground">or Unsplash</span> — open{' '}
685
- <span className="font-mono">unsplash.com/developers</span>, create a New Application, copy its
686
- “Access Key”.
687
- </li>
688
- <li>Paste it on the right and Save. You only need one provider.</li>
689
- </ol>
690
- <p className="mt-2 text-[11px] text-muted-foreground">
691
- {isSandbox
692
- ? 'On your own NextBlock install you paste the key here and it is encrypted into your database. In this shared sandbox the keys come from the host environment and cannot be changed.'
693
- : 'Keys are encrypted and stored in your database, readable only by admins. Pexels is used first when both are set.'}
694
- </p>
695
- </div>
696
-
697
- {/* Key form */}
698
- <form {...stockFormProps} className="space-y-3">
699
- <div className="space-y-1.5">
700
- <Label htmlFor="pexels_api_key" className="text-xs">
701
- Pexels API key{' '}
702
- {hasStoredPexelsKey && maskedStoredPexelsKey && (
703
- <span className="font-mono text-[11px] text-muted-foreground">({maskedStoredPexelsKey})</span>
704
- )}
705
- {!hasStoredPexelsKey && hasEnvPexelsKey && (
706
- <span className="text-[11px] text-muted-foreground">(set via env)</span>
707
- )}
708
- </Label>
709
- <Input
710
- id="pexels_api_key"
711
- name="pexels_api_key"
712
- type="password"
713
- autoComplete="off"
714
- disabled={isSandbox}
715
- placeholder={stockKeyPlaceholder(hasStoredPexelsKey, hasEnvPexelsKey, 'Paste Pexels API key')}
716
- value={pexelsInput}
717
- onChange={(e) => setPexelsInput(e.target.value)}
718
- />
719
- </div>
720
- <div className="space-y-1.5">
721
- <Label htmlFor="unsplash_access_key" className="text-xs">
722
- Unsplash Access key{' '}
723
- {hasStoredUnsplashKey && maskedStoredUnsplashKey && (
724
- <span className="font-mono text-[11px] text-muted-foreground">({maskedStoredUnsplashKey})</span>
725
- )}
726
- {!hasStoredUnsplashKey && hasEnvUnsplashKey && (
727
- <span className="text-[11px] text-muted-foreground">(set via env)</span>
728
- )}
729
- </Label>
730
- <Input
731
- id="unsplash_access_key"
732
- name="unsplash_access_key"
733
- type="password"
734
- autoComplete="off"
735
- disabled={isSandbox}
736
- placeholder={stockKeyPlaceholder(
737
- hasStoredUnsplashKey,
738
- hasEnvUnsplashKey,
739
- 'Paste Unsplash Access key'
740
- )}
741
- value={unsplashInput}
742
- onChange={(e) => setUnsplashInput(e.target.value)}
743
- />
744
- </div>
745
- <div className="space-y-1.5">
746
- <Label htmlFor="unsplash_app_name" className="text-xs">
747
- Unsplash app name{' '}
748
- <span className="text-[11px] text-muted-foreground">
749
- (for attribution links — must match your registered Unsplash app)
750
- </span>
751
- </Label>
752
- <Input
753
- id="unsplash_app_name"
754
- name="unsplash_app_name"
755
- type="text"
756
- autoComplete="off"
757
- disabled={isSandbox}
758
- placeholder={isSandbox ? 'Not set' : 'e.g. My Site Name'}
759
- value={appNameInput}
760
- onChange={(e) => setAppNameInput(e.target.value)}
761
- />
762
- </div>
763
- <div className="flex items-center justify-between">
764
- <span className="text-[11px] text-muted-foreground">
765
- {configuredStockProviders.length > 0
766
- ? `Using ${configuredStockProviders.map((provider) => provider.name).join(', ')}.`
767
- : isSandbox
768
- ? 'No provider configured in this sandbox.'
769
- : 'No provider configured yet.'}
770
- </span>
771
- <Button type="submit" disabled={isSandbox || !isStockDirty} size="sm">
772
- <ImageIcon className="mr-1.5 h-3.5 w-3.5" />
773
- Save
774
- </Button>
775
- </div>
776
- </form>
777
- </div>
778
- </CardContent>
779
- </Card>
780
-
781
- {/* MCP server access — rendered by the server page so it can read token state. */}
782
- {children}
783
-
784
- {/* Advanced settings (collapsed by default) */}
785
- <div>
786
- <button
787
- type="button"
788
- onClick={() => setShowAdvanced((open) => !open)}
789
- className="flex w-full items-center gap-2 rounded-md border bg-muted/20 px-3 py-2 text-left text-sm font-medium hover:bg-muted/40"
790
- >
791
- <SlidersHorizontal className="h-4 w-4" />
792
- Advanced settings
793
- <span className="ml-auto text-xs text-muted-foreground">
794
- {agentSettings.maxOutputTokens === null ? 'Unlimited output' : `${agentSettings.maxOutputTokens} tokens`} · {agentSettings.maxSteps} steps
795
- </span>
796
- {showAdvanced ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
797
- </button>
798
-
799
- {showAdvanced && (
800
- <Card className="mt-2">
801
- <CardHeader className="pb-3">
802
- <CardTitle className="flex items-center gap-2 text-base">
803
- Agent tuning
804
- {isSandbox && <ReadOnlyBadge />}
805
- </CardTitle>
806
- <CardDescription className="text-xs">
807
- {isSandbox
808
- ? 'Controls how much room the page-building agent has. These are set by the sandbox host and shared by every visitor, so they cannot be edited here — on your own install they are editable.'
809
- : 'Controls how much room the page-building agent has. Leave the defaults unless a big build gets cut off — then raise the output tokens (or set Unlimited) and steps.'}
810
- </CardDescription>
811
- </CardHeader>
812
- <CardContent className="space-y-4 pt-0">
813
- <form {...agentFormProps} className="space-y-4">
814
- <div className="grid gap-4 sm:grid-cols-2">
815
- <div className="space-y-1.5">
816
- <Label
817
- htmlFor="max_output_tokens"
818
- className="text-xs"
819
- title="Range 256–200,000 tokens, or Unlimited. The per-step output budget; also counts a tool call's JSON, so raise it (or use Unlimited) if a big page rewrite gets cut off. Default 16,000."
820
- >
821
- Max output tokens per step
822
- </Label>
823
- <Input
824
- id="max_output_tokens"
825
- name="max_output_tokens"
826
- type="number"
827
- min={256}
828
- max={200000}
829
- step={256}
830
- value={maxTokensInput}
831
- onChange={(e) => setMaxTokensInput(e.target.value)}
832
- disabled={unlimitedTokens || isSandbox}
833
- />
834
- <label className="flex items-center gap-2 text-xs text-muted-foreground">
835
- <input
836
- type="checkbox"
837
- name="max_output_unlimited"
838
- checked={unlimitedTokens}
839
- onChange={(e) => setUnlimitedTokens(e.target.checked)}
840
- disabled={isSandbox}
841
- className="h-3.5 w-3.5"
842
- />
843
- Unlimited (use the model&apos;s full output budget)
844
- </label>
845
- <p className="text-[11px] text-muted-foreground">Range 256–200,000, or Unlimited. Default 16,000.</p>
846
- </div>
847
- <div className="space-y-1.5">
848
- <Label
849
- htmlFor="max_steps"
850
- className="text-xs"
851
- title="Range 2–100. Each step is one full model call (a page rewrite is ~3–4). This is also the runaway-loop backstop, so a high value can cost more. Default 8."
852
- >
853
- Max tool steps
854
- </Label>
855
- <Input
856
- id="max_steps"
857
- name="max_steps"
858
- type="number"
859
- min={2}
860
- max={100}
861
- step={1}
862
- value={maxStepsInput}
863
- onChange={(e) => setMaxStepsInput(e.target.value)}
864
- disabled={isSandbox}
865
- />
866
- <p className="text-[11px] text-muted-foreground">
867
- Range 2–100 tool-call rounds. Default 8; each step is one model call.
868
- </p>
869
- </div>
870
- <div className="space-y-1.5">
871
- <Label
872
- htmlFor="temperature"
873
- className="text-xs"
874
- title="Range 0–2. This is Cortex's default (0.1), not the model's universal default (usually ~0.7–1.0). Low keeps structured tool-calls reliable; raise for more variety in copy."
875
- >
876
- Temperature
877
- </Label>
878
- <Input
879
- id="temperature"
880
- name="temperature"
881
- type="number"
882
- min={0}
883
- max={2}
884
- step={0.1}
885
- value={temperatureInput}
886
- onChange={(e) => setTemperatureInput(e.target.value)}
887
- disabled={isSandbox}
888
- />
889
- <p className="text-[11px] text-muted-foreground">
890
- Range 0–2. Cortex default 0.1 (low = reliable; most models default higher).
891
- </p>
892
- </div>
893
- <div className="space-y-1.5">
894
- <Label
895
- htmlFor="response_timeout_seconds"
896
- className="text-xs"
897
- title="Range 15–600 seconds. Aborts an attempt only after this long with NO stream activity — not a hard cap on total time. Default 120."
898
- >
899
- Response timeout (seconds)
900
- </Label>
901
- <Input
902
- id="response_timeout_seconds"
903
- name="response_timeout_seconds"
904
- type="number"
905
- min={15}
906
- max={600}
907
- step={5}
908
- value={timeoutInput}
909
- onChange={(e) => setTimeoutInput(e.target.value)}
910
- disabled={isSandbox}
911
- />
912
- <p className="text-[11px] text-muted-foreground">
913
- Range 15–600s. Default 120; aborts only after this long with no activity.
914
- </p>
915
- </div>
916
- </div>
917
- <div className="flex items-center justify-between">
918
- <span className="text-[11px] text-muted-foreground">
919
- {isSandbox
920
- ? 'Applies to the global page-building agent. Editable on a self-hosted install.'
921
- : 'Values are clamped to safe ranges. Applies to the global page-building agent.'}
922
- </span>
923
- <Button type="submit" size="sm" disabled={isSandbox}>
924
- <SlidersHorizontal className="mr-1.5 h-3.5 w-3.5" />
925
- Save
926
- </Button>
927
- </div>
928
- </form>
929
- {isSandbox ? (
930
- <Button type="button" variant="ghost" size="sm" className="h-7 text-muted-foreground" disabled>
931
- <RotateCcw className="mr-1.5 h-3.5 w-3.5" />
932
- Reset to defaults
933
- </Button>
934
- ) : (
935
- <form action={resetCortexAiAgentSettingsAction} onSubmit={notifyCortexAiSettingsChanged}>
936
- <Button type="submit" variant="ghost" size="sm" className="h-7 text-muted-foreground">
937
- <RotateCcw className="mr-1.5 h-3.5 w-3.5" />
938
- Reset to defaults
939
- </Button>
940
- </form>
941
- )}
942
- </CardContent>
943
- </Card>
944
- )}
945
- </div>
946
- </div>
947
- );
948
- }
1
+ 'use client';
2
+
3
+ import React, { useEffect, useState } from 'react';
4
+ import {
5
+ Alert,
6
+ AlertDescription,
7
+ AlertTitle,
8
+ Badge,
9
+ Button,
10
+ Card,
11
+ CardContent,
12
+ CardDescription,
13
+ CardHeader,
14
+ CardTitle,
15
+ Input,
16
+ Label,
17
+ SearchableSelect,
18
+ } from '@nextblock-cms/ui';
19
+ import {
20
+ AlertTriangle,
21
+ Brain,
22
+ CheckCircle2,
23
+ ChevronDown,
24
+ ChevronRight,
25
+ Cpu,
26
+ ImageIcon,
27
+ Info,
28
+ KeyRound,
29
+ Lock,
30
+ RotateCcw,
31
+ SlidersHorizontal,
32
+ Trash2,
33
+ } from 'lucide-react';
34
+
35
+ import {
36
+ createCortexAiStoredModelSelection,
37
+ type CortexAiStoredModelSelection,
38
+ } from '@nextblock-cms/cortex/client';
39
+ import type {
40
+ CortexAiAgentSettings,
41
+ CortexAiCompatibleOpenRouterModel,
42
+ } from '@nextblock-cms/cortex';
43
+ import {
44
+ clearCortexAiModelSelectionAction,
45
+ clearOpenRouterApiKeyAction,
46
+ clearStockPhotoKeysAction,
47
+ resetCortexAiAgentSettingsAction,
48
+ saveCortexAiAgentSettingsAction,
49
+ saveCortexAiModelSelectionAction,
50
+ saveOpenRouterApiKeyAction,
51
+ saveStockPhotoKeysAction,
52
+ } from './actions';
53
+
54
+ /**
55
+ * The one Cortex AI settings UI — production and sandbox render the same tree.
56
+ *
57
+ * This page used to be two forked components (`StoredCortexAiSettingsClient` and
58
+ * `SandboxCortexAiSettingsClient`) that shared a layout by copy-paste. Every design
59
+ * change had to be made twice, and twice it wasn't: the sandbox drifted behind and
60
+ * never gained the MCP card at all. So there is exactly one component now, and
61
+ * `isSandbox` is a prop rather than a second file.
62
+ *
63
+ * The rule for anything the sandbox cannot do: **disable it, never hide it.** A
64
+ * visitor evaluating NextBlock should be able to see that stock-photo keys, agent
65
+ * tuning, and MCP access exist and what they look like — a hidden control teaches
66
+ * them the feature doesn't exist. Only the two settings that have a per-visitor
67
+ * channel (the OpenRouter key and model, which live in this browser's localStorage
68
+ * and travel as `x-sandbox-openrouter-*` headers) stay writable in the sandbox.
69
+ */
70
+
71
+ const CORTEX_AI_SANDBOX_KEY_LOCAL_STORAGE = 'cortex_ai_sandbox_openrouter_api_key';
72
+ const CORTEX_AI_SANDBOX_MODEL_LOCAL_STORAGE = 'cortex_ai_sandbox_openrouter_model_selection';
73
+ const CORTEX_AI_SETTINGS_CHANGED_EVENT = 'nextblock:cortex-ai-settings-changed';
74
+
75
+ type CortexAiSettingsClientProps = {
76
+ /**
77
+ * Shared-sandbox mode. Server-backed settings become read-only because the
78
+ * settings actions refuse to write to the shared sandbox DB anyway; showing an
79
+ * editable control that always errors is worse than showing a locked one.
80
+ */
81
+ isSandbox: boolean;
82
+ compatibleModels: CortexAiCompatibleOpenRouterModel[];
83
+ isPackageActive: boolean;
84
+ hasEnvOpenRouterKey: boolean;
85
+ maskedEnvOpenRouterKey: string | null;
86
+ hasStoredOpenRouterKey: boolean;
87
+ maskedStoredOpenRouterKey: string | null;
88
+ selectedModel: CortexAiStoredModelSelection | null;
89
+ hasEncryptionKey: boolean;
90
+ modelCatalogError: string | null;
91
+ activeStockProvider: 'pexels' | 'unsplash' | null;
92
+ hasStoredPexelsKey: boolean;
93
+ maskedStoredPexelsKey: string | null;
94
+ hasStoredUnsplashKey: boolean;
95
+ maskedStoredUnsplashKey: string | null;
96
+ hasEnvPexelsKey: boolean;
97
+ hasEnvUnsplashKey: boolean;
98
+ unsplashAppName: string | null;
99
+ agentSettings: CortexAiAgentSettings;
100
+ /** Slot for server-rendered cards (currently the MCP server access card). */
101
+ children?: React.ReactNode;
102
+ successMessage?: string;
103
+ errorMessage?: string;
104
+ };
105
+
106
+ function formatTokenPrice(value: string | undefined) {
107
+ const amount = Number(value);
108
+ if (!Number.isFinite(amount)) return null;
109
+ if (amount === 0) return '$0';
110
+ const perMillion = amount * 1_000_000;
111
+ return `$${perMillion < 0.01 ? perMillion.toFixed(4) : perMillion.toFixed(2)}`;
112
+ }
113
+
114
+ function formatModelPricing(pricing: Record<string, string>) {
115
+ const promptPrice = formatTokenPrice(pricing.prompt);
116
+ const completionPrice = formatTokenPrice(pricing.completion);
117
+ if (promptPrice === '$0' && completionPrice === '$0') return 'Free';
118
+ if (promptPrice && completionPrice) return `${promptPrice}/1M input - ${completionPrice}/1M output`;
119
+ return 'Pricing varies';
120
+ }
121
+
122
+ function getMaskedKey(key: string) {
123
+ if (key.length <= 8) return '****';
124
+ return `**** ${key.slice(-4)}`;
125
+ }
126
+
127
+ function notifyCortexAiSettingsChanged() {
128
+ window.dispatchEvent(new Event(CORTEX_AI_SETTINGS_CHANGED_EVENT));
129
+ }
130
+
131
+ function StatusPill({
132
+ label,
133
+ value,
134
+ active,
135
+ detail,
136
+ }: {
137
+ label: string;
138
+ value: string;
139
+ active: boolean;
140
+ detail?: string | null;
141
+ }) {
142
+ return (
143
+ <div className="flex min-w-[8rem] flex-col gap-1 rounded-md border bg-muted/30 px-3 py-2">
144
+ <span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
145
+ {label}
146
+ </span>
147
+ <div className="flex items-center gap-2">
148
+ <span className={`h-2 w-2 rounded-full ${active ? 'bg-emerald-500' : 'bg-muted-foreground/40'}`} />
149
+ <span className="text-sm font-medium">{value}</span>
150
+ </div>
151
+ {detail && <span className="truncate font-mono text-[11px] text-muted-foreground">{detail}</span>}
152
+ </div>
153
+ );
154
+ }
155
+
156
+ /** The marker every locked-in-sandbox card carries, so "disabled" never reads as "broken". */
157
+ function ReadOnlyBadge({ className }: { className?: string }) {
158
+ return (
159
+ <Badge variant="outline" className={`gap-1 font-normal ${className || ''}`}>
160
+ <Lock className="h-3 w-3" />
161
+ Read-only
162
+ </Badge>
163
+ );
164
+ }
165
+
166
+ /**
167
+ * "Clear" affordance for the key / model cards.
168
+ *
169
+ * Production posts to a server action so the row is deleted from `site_settings`;
170
+ * the sandbox drops the value from localStorage. Same button either way.
171
+ */
172
+ function ClearButton({
173
+ isSandbox,
174
+ onSandboxClear,
175
+ serverAction,
176
+ }: {
177
+ isSandbox: boolean;
178
+ onSandboxClear: () => void;
179
+ serverAction: () => void | Promise<void>;
180
+ }) {
181
+ const className = 'h-7 text-destructive hover:text-destructive';
182
+
183
+ if (isSandbox) {
184
+ return (
185
+ <Button type="button" onClick={onSandboxClear} variant="ghost" size="sm" className={className}>
186
+ <Trash2 className="mr-1.5 h-3.5 w-3.5" />
187
+ Clear
188
+ </Button>
189
+ );
190
+ }
191
+
192
+ return (
193
+ <form action={serverAction} onSubmit={notifyCortexAiSettingsChanged}>
194
+ <Button type="submit" variant="ghost" size="sm" className={className}>
195
+ <Trash2 className="mr-1.5 h-3.5 w-3.5" />
196
+ Clear
197
+ </Button>
198
+ </form>
199
+ );
200
+ }
201
+
202
+ export function CortexAiSettingsClient({
203
+ isSandbox,
204
+ compatibleModels,
205
+ isPackageActive,
206
+ hasEnvOpenRouterKey,
207
+ maskedEnvOpenRouterKey,
208
+ hasStoredOpenRouterKey,
209
+ maskedStoredOpenRouterKey,
210
+ selectedModel,
211
+ hasEncryptionKey,
212
+ modelCatalogError,
213
+ activeStockProvider,
214
+ hasStoredPexelsKey,
215
+ maskedStoredPexelsKey,
216
+ hasStoredUnsplashKey,
217
+ maskedStoredUnsplashKey,
218
+ hasEnvPexelsKey,
219
+ hasEnvUnsplashKey,
220
+ unsplashAppName,
221
+ agentSettings,
222
+ children,
223
+ successMessage,
224
+ errorMessage,
225
+ }: CortexAiSettingsClientProps) {
226
+ const [apiKeyInput, setApiKeyInput] = useState('');
227
+ // In the sandbox the stored selection belongs to the shared DB, not to this
228
+ // visitor — the effect below fills the field in from localStorage instead.
229
+ const [modelInput, setModelInput] = useState<string>(isSandbox ? '' : selectedModel?.modelId || '');
230
+ const [pexelsInput, setPexelsInput] = useState('');
231
+ const [unsplashInput, setUnsplashInput] = useState('');
232
+ const [appNameInput, setAppNameInput] = useState(unsplashAppName || '');
233
+ const [showAdvanced, setShowAdvanced] = useState(false);
234
+ const [unlimitedTokens, setUnlimitedTokens] = useState(agentSettings.maxOutputTokens === null);
235
+ const [maxTokensInput, setMaxTokensInput] = useState(String(agentSettings.maxOutputTokens ?? 16000));
236
+ const [maxStepsInput, setMaxStepsInput] = useState(String(agentSettings.maxSteps));
237
+ const [temperatureInput, setTemperatureInput] = useState(String(agentSettings.temperature));
238
+ const [timeoutInput, setTimeoutInput] = useState(String(Math.round(agentSettings.responseTimeoutMs / 1000)));
239
+
240
+ // Sandbox-only, per-browser credentials. Read after mount so the first client
241
+ // render still matches the server HTML.
242
+ const [sandboxKey, setSandboxKey] = useState<string | null>(null);
243
+ const [sandboxModel, setSandboxModel] = useState<CortexAiStoredModelSelection | null>(null);
244
+ const [sandboxMessage, setSandboxMessage] = useState<string | null>(null);
245
+
246
+ useEffect(() => {
247
+ if (!isSandbox) return;
248
+
249
+ try {
250
+ const storedKey = window.localStorage.getItem(CORTEX_AI_SANDBOX_KEY_LOCAL_STORAGE);
251
+ if (storedKey) {
252
+ setSandboxKey(storedKey);
253
+ }
254
+
255
+ const storedModel = window.localStorage.getItem(CORTEX_AI_SANDBOX_MODEL_LOCAL_STORAGE);
256
+ if (storedModel) {
257
+ const parsed = JSON.parse(storedModel) as CortexAiStoredModelSelection;
258
+ setSandboxModel(parsed);
259
+ setModelInput(parsed.modelId);
260
+ }
261
+ } catch (error) {
262
+ console.error('Failed to read Cortex AI sandbox settings from localStorage', error);
263
+ }
264
+ }, [isSandbox]);
265
+
266
+ function flashSandboxMessage(message: string) {
267
+ setSandboxMessage(message);
268
+ setTimeout(() => setSandboxMessage(null), 3000);
269
+ }
270
+
271
+ function handleSandboxSaveKey(event: React.FormEvent) {
272
+ event.preventDefault();
273
+ const key = apiKeyInput.trim();
274
+ if (!key) return;
275
+
276
+ try {
277
+ window.localStorage.setItem(CORTEX_AI_SANDBOX_KEY_LOCAL_STORAGE, key);
278
+ setSandboxKey(key);
279
+ setApiKeyInput('');
280
+ notifyCortexAiSettingsChanged();
281
+ flashSandboxMessage('Sandbox OpenRouter key saved to your browser.');
282
+ } catch (error) {
283
+ console.error('Failed to save sandbox key', error);
284
+ }
285
+ }
286
+
287
+ function handleSandboxClearKey() {
288
+ try {
289
+ window.localStorage.removeItem(CORTEX_AI_SANDBOX_KEY_LOCAL_STORAGE);
290
+ window.localStorage.removeItem(CORTEX_AI_SANDBOX_MODEL_LOCAL_STORAGE);
291
+ setSandboxKey(null);
292
+ setSandboxModel(null);
293
+ setModelInput('');
294
+ notifyCortexAiSettingsChanged();
295
+ flashSandboxMessage('Sandbox OpenRouter key cleared from your browser.');
296
+ } catch (error) {
297
+ console.error('Failed to clear sandbox key', error);
298
+ }
299
+ }
300
+
301
+ function handleSandboxSaveModel(event: React.FormEvent<HTMLFormElement>) {
302
+ event.preventDefault();
303
+ const formData = new FormData(event.currentTarget);
304
+ const modelId = String(formData.get('openrouter_model_id') || '').trim();
305
+ if (!modelId) return;
306
+
307
+ const model = compatibleModels.find((candidate) => candidate.id === modelId);
308
+ if (!model) return;
309
+
310
+ try {
311
+ const storedSelection = createCortexAiStoredModelSelection(model);
312
+ window.localStorage.setItem(
313
+ CORTEX_AI_SANDBOX_MODEL_LOCAL_STORAGE,
314
+ JSON.stringify(storedSelection)
315
+ );
316
+ setSandboxModel(storedSelection);
317
+ notifyCortexAiSettingsChanged();
318
+ flashSandboxMessage('Sandbox Cortex AI model selection saved to your browser.');
319
+ } catch (error) {
320
+ console.error('Failed to save sandbox model', error);
321
+ }
322
+ }
323
+
324
+ function handleSandboxClearModel() {
325
+ try {
326
+ window.localStorage.removeItem(CORTEX_AI_SANDBOX_MODEL_LOCAL_STORAGE);
327
+ setSandboxModel(null);
328
+ setModelInput('');
329
+ notifyCortexAiSettingsChanged();
330
+ flashSandboxMessage('Sandbox Cortex AI model selection cleared.');
331
+ } catch (error) {
332
+ console.error('Failed to clear sandbox model', error);
333
+ }
334
+ }
335
+
336
+ // One set of derived values, sourced from localStorage in the sandbox and from
337
+ // the database everywhere else. Everything below renders off these, so the two
338
+ // environments cannot drift apart visually.
339
+ const hasKey = isSandbox ? Boolean(sandboxKey) : hasStoredOpenRouterKey;
340
+ const maskedKey = isSandbox
341
+ ? sandboxKey
342
+ ? getMaskedKey(sandboxKey)
343
+ : null
344
+ : maskedStoredOpenRouterKey;
345
+ const activeModel = isSandbox ? sandboxModel : selectedModel;
346
+
347
+ const selectedModelIsInCatalog = compatibleModels.some((model) => model.id === activeModel?.modelId);
348
+ const modelOptions: CortexAiCompatibleOpenRouterModel[] =
349
+ activeModel && !selectedModelIsInCatalog
350
+ ? [
351
+ {
352
+ contextLength: activeModel.contextLength,
353
+ created: null,
354
+ expirationDate: null,
355
+ id: activeModel.modelId,
356
+ name: `${activeModel.name} (saved)`,
357
+ pricing: activeModel.pricing,
358
+ supportedParameters: activeModel.supportedParameters,
359
+ },
360
+ ...compatibleModels,
361
+ ]
362
+ : compatibleModels;
363
+
364
+ const canSelectModel = hasKey && compatibleModels.length > 0;
365
+
366
+ const searchableOptions = modelOptions.map((model) => ({
367
+ value: model.id,
368
+ label: model.name,
369
+ description: `${model.id} - ${formatModelPricing(model.pricing)}`,
370
+ }));
371
+
372
+ const isKeyDirty = apiKeyInput.trim().length > 0;
373
+ const isModelDirty = modelInput !== (activeModel?.modelId || '');
374
+ const isStockDirty =
375
+ pexelsInput.trim().length > 0 ||
376
+ unsplashInput.trim().length > 0 ||
377
+ appNameInput.trim() !== (unsplashAppName || '');
378
+
379
+ const keySourceValue = hasKey
380
+ ? isSandbox
381
+ ? 'Sandbox BYOK'
382
+ : 'Stored BYOK'
383
+ : hasEnvOpenRouterKey
384
+ ? 'Environment'
385
+ : 'None';
386
+
387
+ // Ordered by preference (Pexels primary, Unsplash fallback). Configured = a
388
+ // stored DB key OR an env var for that provider.
389
+ const configuredStockProviders = [
390
+ hasStoredPexelsKey || hasEnvPexelsKey ? { name: 'Pexels', stored: hasStoredPexelsKey } : null,
391
+ hasStoredUnsplashKey || hasEnvUnsplashKey ? { name: 'Unsplash', stored: hasStoredUnsplashKey } : null,
392
+ ].filter(Boolean) as Array<{ name: string; stored: boolean }>;
393
+ const stockValue =
394
+ configuredStockProviders.length > 0
395
+ ? configuredStockProviders.map((provider) => provider.name).join(' + ')
396
+ : 'Off';
397
+
398
+ function stockKeyPlaceholder(hasStored: boolean, hasEnv: boolean, fallback: string) {
399
+ if (!isSandbox) {
400
+ return hasStored ? 'Enter new key to overwrite...' : fallback;
401
+ }
402
+ // Disabled password inputs render empty, so the placeholder has to carry the state.
403
+ return hasStored ? 'Configured (stored)' : hasEnv ? 'Configured (env)' : 'Not set';
404
+ }
405
+
406
+ // Server actions in the sandbox would be rejected by the guards in `actions.ts`,
407
+ // so sandbox forms are wired to local handlers (key/model) or neutered (the rest).
408
+ const keyFormProps = isSandbox
409
+ ? { onSubmit: handleSandboxSaveKey }
410
+ : { action: saveOpenRouterApiKeyAction, onSubmit: notifyCortexAiSettingsChanged };
411
+ const modelFormProps = isSandbox
412
+ ? { onSubmit: handleSandboxSaveModel }
413
+ : { action: saveCortexAiModelSelectionAction, onSubmit: notifyCortexAiSettingsChanged };
414
+ const stockFormProps = isSandbox
415
+ ? { onSubmit: (event: React.FormEvent) => event.preventDefault() }
416
+ : { action: saveStockPhotoKeysAction, onSubmit: notifyCortexAiSettingsChanged };
417
+ const agentFormProps = isSandbox
418
+ ? { onSubmit: (event: React.FormEvent) => event.preventDefault() }
419
+ : { action: saveCortexAiAgentSettingsAction, onSubmit: notifyCortexAiSettingsChanged };
420
+
421
+ const banner = sandboxMessage
422
+ ? { message: sandboxMessage, variant: 'success' as const }
423
+ : successMessage
424
+ ? { message: successMessage, variant: 'success' as const }
425
+ : null;
426
+
427
+ return (
428
+ <div className="mx-auto w-full max-w-5xl space-y-4 px-4 py-6">
429
+ <div className="flex items-center gap-2.5">
430
+ <Brain className="h-6 w-6 text-primary" />
431
+ <div>
432
+ <h1 className="text-xl font-semibold leading-tight">
433
+ NextBlock Cortex AI
434
+ {isSandbox && (
435
+ <Badge variant="secondary" className="ml-2 align-middle font-normal">
436
+ Sandbox
437
+ </Badge>
438
+ )}
439
+ </h1>
440
+ <p className="text-xs text-muted-foreground">
441
+ {isSandbox
442
+ ? 'Set the OpenRouter key and model for your own browser session. Everything else is shown as configured by the sandbox host.'
443
+ : 'Manage activation, the OpenRouter model key, stock-photo providers, and MCP access.'}
444
+ </p>
445
+ </div>
446
+ </div>
447
+
448
+ {banner && (
449
+ <Alert variant={banner.variant}>
450
+ <CheckCircle2 className="h-4 w-4" />
451
+ <AlertTitle>Saved</AlertTitle>
452
+ <AlertDescription>{banner.message}</AlertDescription>
453
+ </Alert>
454
+ )}
455
+
456
+ {errorMessage && (
457
+ <Alert variant="destructive">
458
+ <AlertTriangle className="h-4 w-4" />
459
+ <AlertTitle>Unable to save</AlertTitle>
460
+ <AlertDescription>{errorMessage}</AlertDescription>
461
+ </Alert>
462
+ )}
463
+
464
+ {/* Compact status strip */}
465
+ <div className="flex flex-wrap gap-2">
466
+ <StatusPill label="Package" value={isPackageActive ? 'Active' : 'Inactive'} active={isPackageActive} />
467
+ <StatusPill
468
+ label="Model key"
469
+ value={keySourceValue}
470
+ active={hasKey || hasEnvOpenRouterKey}
471
+ detail={maskedKey || maskedEnvOpenRouterKey}
472
+ />
473
+ <StatusPill
474
+ label="Model"
475
+ value={activeModel ? activeModel.name : 'Free registry'}
476
+ active={Boolean(activeModel)}
477
+ />
478
+ <StatusPill label="Stock photos" value={stockValue} active={Boolean(activeStockProvider)} />
479
+ </div>
480
+
481
+ {isSandbox && (
482
+ <Alert
483
+ variant="warning"
484
+ className="border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-200"
485
+ >
486
+ <Info className="h-4 w-4" />
487
+ <AlertTitle>Sandbox environment active</AlertTitle>
488
+ <AlertDescription>
489
+ The key and model you set here are stored{' '}
490
+ <strong>only in your own browser (localStorage)</strong> and are never written to the
491
+ shared sandbox database. Everything else on this page is shown exactly as it appears on a
492
+ real install, but locked — the sandbox is shared by every visitor.
493
+ </AlertDescription>
494
+ </Alert>
495
+ )}
496
+
497
+ {isSandbox && hasEnvOpenRouterKey && !hasKey && (
498
+ <Alert>
499
+ <KeyRound className="h-4 w-4" />
500
+ <AlertTitle>Free-model lock active</AlertTitle>
501
+ <AlertDescription>
502
+ Cortex AI will only use the configured free OpenRouter models until you save a sandbox key
503
+ to your browser.
504
+ </AlertDescription>
505
+ </Alert>
506
+ )}
507
+
508
+ {!isSandbox && !hasEncryptionKey && (
509
+ <Alert variant="warning">
510
+ <AlertTriangle className="h-4 w-4" />
511
+ <AlertTitle>Encryption key missing</AlertTitle>
512
+ <AlertDescription>
513
+ Set CORTEX_AI_ENCRYPTION_KEY (or rely on the Supabase service-role fallback) before saving keys here.
514
+ </AlertDescription>
515
+ </Alert>
516
+ )}
517
+
518
+ {/* OpenRouter BYOK + Model side by side */}
519
+ <div className="grid gap-4 lg:grid-cols-2">
520
+ <Card>
521
+ <CardHeader className="flex flex-row items-start justify-between space-y-0 pb-3">
522
+ <div>
523
+ <CardTitle className="text-base">OpenRouter key</CardTitle>
524
+ <CardDescription className="text-xs">
525
+ {isSandbox ? 'Saved to your browser only, never uploaded.' : 'Encrypted, masked after saving.'}
526
+ </CardDescription>
527
+ </div>
528
+ {hasKey && (
529
+ <ClearButton
530
+ isSandbox={isSandbox}
531
+ onSandboxClear={handleSandboxClearKey}
532
+ serverAction={clearOpenRouterApiKeyAction}
533
+ />
534
+ )}
535
+ </CardHeader>
536
+ <CardContent className="pt-0">
537
+ <form {...keyFormProps} className="flex items-end gap-2">
538
+ <div className="flex-1 space-y-1.5">
539
+ <Label htmlFor="openrouter_api_key" className="text-xs">
540
+ API key
541
+ </Label>
542
+ <Input
543
+ id="openrouter_api_key"
544
+ name="openrouter_api_key"
545
+ type="password"
546
+ autoComplete="off"
547
+ minLength={12}
548
+ placeholder={hasKey ? 'Enter new key to overwrite...' : 'sk-or-v1-...'}
549
+ value={apiKeyInput}
550
+ onChange={(e) => setApiKeyInput(e.target.value)}
551
+ required
552
+ />
553
+ </div>
554
+ <Button type="submit" disabled={!isKeyDirty} size="sm">
555
+ <KeyRound className="mr-1.5 h-3.5 w-3.5" />
556
+ Save
557
+ </Button>
558
+ </form>
559
+ </CardContent>
560
+ </Card>
561
+
562
+ <Card>
563
+ <CardHeader className="flex flex-row items-start justify-between space-y-0 pb-3">
564
+ <div>
565
+ <CardTitle className="text-base">OpenRouter model</CardTitle>
566
+ <CardDescription className="text-xs">
567
+ Needs {isSandbox ? 'a sandbox key' : 'a stored key'}; supports tools + structured output.
568
+ </CardDescription>
569
+ </div>
570
+ {activeModel && (
571
+ <ClearButton
572
+ isSandbox={isSandbox}
573
+ onSandboxClear={handleSandboxClearModel}
574
+ serverAction={clearCortexAiModelSelectionAction}
575
+ />
576
+ )}
577
+ </CardHeader>
578
+ <CardContent className="space-y-2 pt-0">
579
+ {modelCatalogError && (
580
+ <Alert variant="warning">
581
+ <AlertTriangle className="h-4 w-4" />
582
+ <AlertTitle>Model catalog unavailable</AlertTitle>
583
+ <AlertDescription>{modelCatalogError}</AlertDescription>
584
+ </Alert>
585
+ )}
586
+ <form {...modelFormProps} className="flex items-end gap-2">
587
+ <input type="hidden" name="openrouter_model_id" value={modelInput} />
588
+ <div className="flex-1 space-y-1.5">
589
+ <Label htmlFor="openrouter_model_id_select" className="text-xs">
590
+ Model
591
+ </Label>
592
+ <SearchableSelect
593
+ options={searchableOptions}
594
+ value={modelInput}
595
+ onChange={(val) => setModelInput(val)}
596
+ disabled={!canSelectModel}
597
+ placeholder="Select a compatible model..."
598
+ />
599
+ </div>
600
+ <Button type="submit" disabled={!canSelectModel || !isModelDirty} size="sm">
601
+ <Cpu className="mr-1.5 h-3.5 w-3.5" />
602
+ Save
603
+ </Button>
604
+ </form>
605
+ <p className="text-[11px] text-muted-foreground">
606
+ {canSelectModel
607
+ ? `${compatibleModels.length} compatible models available.`
608
+ : `Cortex AI uses the free registry until a ${isSandbox ? 'sandbox' : 'stored'} key + model are set.`}
609
+ </p>
610
+ </CardContent>
611
+ </Card>
612
+ </div>
613
+
614
+ {/* Stock photos */}
615
+ <Card>
616
+ <CardHeader className="flex flex-row items-start justify-between space-y-0 pb-3">
617
+ <div>
618
+ <CardTitle className="flex flex-wrap items-center gap-2 text-base">
619
+ <ImageIcon className="h-4 w-4" />
620
+ Stock photos
621
+ {configuredStockProviders.length > 0 ? (
622
+ configuredStockProviders.map((provider, index) => (
623
+ <Badge
624
+ key={provider.name}
625
+ variant={index === 0 ? 'default' : 'secondary'}
626
+ className="ml-0.5 font-normal"
627
+ >
628
+ {provider.name} · {index === 0 ? 'primary' : 'fallback'}
629
+ {!provider.stored && ' (env)'}
630
+ </Badge>
631
+ ))
632
+ ) : (
633
+ <Badge variant="outline" className="ml-0.5">
634
+ Not configured
635
+ </Badge>
636
+ )}
637
+ {isSandbox && <ReadOnlyBadge />}
638
+ </CardTitle>
639
+ <CardDescription className="text-xs">
640
+ Free Pexels/Unsplash key so Cortex inserts real photos into pages. Pexels is used first; Cortex
641
+ automatically falls back to Unsplash if Pexels is rate-limited. Optional but recommended.
642
+ </CardDescription>
643
+ </div>
644
+ {(hasStoredPexelsKey || hasStoredUnsplashKey) &&
645
+ (isSandbox ? (
646
+ <Button
647
+ type="button"
648
+ variant="ghost"
649
+ size="sm"
650
+ className="h-7 text-destructive hover:text-destructive"
651
+ disabled
652
+ >
653
+ <Trash2 className="mr-1.5 h-3.5 w-3.5" />
654
+ Clear
655
+ </Button>
656
+ ) : (
657
+ <form action={clearStockPhotoKeysAction} onSubmit={notifyCortexAiSettingsChanged}>
658
+ <Button type="submit" variant="ghost" size="sm" className="h-7 text-destructive hover:text-destructive">
659
+ <Trash2 className="mr-1.5 h-3.5 w-3.5" />
660
+ Clear
661
+ </Button>
662
+ </form>
663
+ ))}
664
+ </CardHeader>
665
+ <CardContent className="space-y-4 pt-0">
666
+ <div className="grid gap-4 md:grid-cols-2">
667
+ {/* Why + how */}
668
+ <div className="rounded-md border bg-muted/20 p-3 text-sm">
669
+ <p className="font-medium">Why add a key?</p>
670
+ <p className="mt-1 text-xs text-muted-foreground">
671
+ When you ask Cortex to build or revamp a page, a stock key lets it fetch relevant, high-quality
672
+ photos for the hero and sections automatically — instant, zero image-generation cost, and you can
673
+ save any photo into your media library with one click. Without a key Cortex still builds pages, but
674
+ uses gradient/theme backgrounds instead of photos, and it will not call the photo tool at all.
675
+ </p>
676
+ <p className="mt-3 font-medium">Get a free key (pick one):</p>
677
+ <ol className="mt-1 list-decimal space-y-1 pl-4 text-xs text-muted-foreground">
678
+ <li>
679
+ <span className="font-medium text-foreground">Pexels</span> — open{' '}
680
+ <span className="font-mono">pexels.com/api</span>, sign in, click “Get Started / Your API Key”,
681
+ copy the key.
682
+ </li>
683
+ <li>
684
+ <span className="font-medium text-foreground">or Unsplash</span> — open{' '}
685
+ <span className="font-mono">unsplash.com/developers</span>, create a New Application, copy its
686
+ “Access Key”.
687
+ </li>
688
+ <li>Paste it on the right and Save. You only need one provider.</li>
689
+ </ol>
690
+ <p className="mt-2 text-[11px] text-muted-foreground">
691
+ {isSandbox
692
+ ? 'On your own NextBlock install you paste the key here and it is encrypted into your database. In this shared sandbox the keys come from the host environment and cannot be changed.'
693
+ : 'Keys are encrypted and stored in your database, readable only by admins. Pexels is used first when both are set.'}
694
+ </p>
695
+ </div>
696
+
697
+ {/* Key form */}
698
+ <form {...stockFormProps} className="space-y-3">
699
+ <div className="space-y-1.5">
700
+ <Label htmlFor="pexels_api_key" className="text-xs">
701
+ Pexels API key{' '}
702
+ {hasStoredPexelsKey && maskedStoredPexelsKey && (
703
+ <span className="font-mono text-[11px] text-muted-foreground">({maskedStoredPexelsKey})</span>
704
+ )}
705
+ {!hasStoredPexelsKey && hasEnvPexelsKey && (
706
+ <span className="text-[11px] text-muted-foreground">(set via env)</span>
707
+ )}
708
+ </Label>
709
+ <Input
710
+ id="pexels_api_key"
711
+ name="pexels_api_key"
712
+ type="password"
713
+ autoComplete="off"
714
+ disabled={isSandbox}
715
+ placeholder={stockKeyPlaceholder(hasStoredPexelsKey, hasEnvPexelsKey, 'Paste Pexels API key')}
716
+ value={pexelsInput}
717
+ onChange={(e) => setPexelsInput(e.target.value)}
718
+ />
719
+ </div>
720
+ <div className="space-y-1.5">
721
+ <Label htmlFor="unsplash_access_key" className="text-xs">
722
+ Unsplash Access key{' '}
723
+ {hasStoredUnsplashKey && maskedStoredUnsplashKey && (
724
+ <span className="font-mono text-[11px] text-muted-foreground">({maskedStoredUnsplashKey})</span>
725
+ )}
726
+ {!hasStoredUnsplashKey && hasEnvUnsplashKey && (
727
+ <span className="text-[11px] text-muted-foreground">(set via env)</span>
728
+ )}
729
+ </Label>
730
+ <Input
731
+ id="unsplash_access_key"
732
+ name="unsplash_access_key"
733
+ type="password"
734
+ autoComplete="off"
735
+ disabled={isSandbox}
736
+ placeholder={stockKeyPlaceholder(
737
+ hasStoredUnsplashKey,
738
+ hasEnvUnsplashKey,
739
+ 'Paste Unsplash Access key'
740
+ )}
741
+ value={unsplashInput}
742
+ onChange={(e) => setUnsplashInput(e.target.value)}
743
+ />
744
+ </div>
745
+ <div className="space-y-1.5">
746
+ <Label htmlFor="unsplash_app_name" className="text-xs">
747
+ Unsplash app name{' '}
748
+ <span className="text-[11px] text-muted-foreground">
749
+ (for attribution links — must match your registered Unsplash app)
750
+ </span>
751
+ </Label>
752
+ <Input
753
+ id="unsplash_app_name"
754
+ name="unsplash_app_name"
755
+ type="text"
756
+ autoComplete="off"
757
+ disabled={isSandbox}
758
+ placeholder={isSandbox ? 'Not set' : 'e.g. My Site Name'}
759
+ value={appNameInput}
760
+ onChange={(e) => setAppNameInput(e.target.value)}
761
+ />
762
+ </div>
763
+ <div className="flex items-center justify-between">
764
+ <span className="text-[11px] text-muted-foreground">
765
+ {configuredStockProviders.length > 0
766
+ ? `Using ${configuredStockProviders.map((provider) => provider.name).join(', ')}.`
767
+ : isSandbox
768
+ ? 'No provider configured in this sandbox.'
769
+ : 'No provider configured yet.'}
770
+ </span>
771
+ <Button type="submit" disabled={isSandbox || !isStockDirty} size="sm">
772
+ <ImageIcon className="mr-1.5 h-3.5 w-3.5" />
773
+ Save
774
+ </Button>
775
+ </div>
776
+ </form>
777
+ </div>
778
+ </CardContent>
779
+ </Card>
780
+
781
+ {/* MCP server access — rendered by the server page so it can read token state. */}
782
+ {children}
783
+
784
+ {/* Advanced settings (collapsed by default) */}
785
+ <div>
786
+ <button
787
+ type="button"
788
+ onClick={() => setShowAdvanced((open) => !open)}
789
+ className="flex w-full items-center gap-2 rounded-md border bg-muted/20 px-3 py-2 text-left text-sm font-medium hover:bg-muted/40"
790
+ >
791
+ <SlidersHorizontal className="h-4 w-4" />
792
+ Advanced settings
793
+ <span className="ml-auto text-xs text-muted-foreground">
794
+ {agentSettings.maxOutputTokens === null ? 'Unlimited output' : `${agentSettings.maxOutputTokens} tokens`} · {agentSettings.maxSteps} steps
795
+ </span>
796
+ {showAdvanced ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
797
+ </button>
798
+
799
+ {showAdvanced && (
800
+ <Card className="mt-2">
801
+ <CardHeader className="pb-3">
802
+ <CardTitle className="flex items-center gap-2 text-base">
803
+ Agent tuning
804
+ {isSandbox && <ReadOnlyBadge />}
805
+ </CardTitle>
806
+ <CardDescription className="text-xs">
807
+ {isSandbox
808
+ ? 'Controls how much room the page-building agent has. These are set by the sandbox host and shared by every visitor, so they cannot be edited here — on your own install they are editable.'
809
+ : 'Controls how much room the page-building agent has. Leave the defaults unless a big build gets cut off — then raise the output tokens (or set Unlimited) and steps.'}
810
+ </CardDescription>
811
+ </CardHeader>
812
+ <CardContent className="space-y-4 pt-0">
813
+ <form {...agentFormProps} className="space-y-4">
814
+ <div className="grid gap-4 sm:grid-cols-2">
815
+ <div className="space-y-1.5">
816
+ <Label
817
+ htmlFor="max_output_tokens"
818
+ className="text-xs"
819
+ title="Range 256–200,000 tokens, or Unlimited. The per-step output budget; also counts a tool call's JSON, so raise it (or use Unlimited) if a big page rewrite gets cut off. Default 16,000."
820
+ >
821
+ Max output tokens per step
822
+ </Label>
823
+ <Input
824
+ id="max_output_tokens"
825
+ name="max_output_tokens"
826
+ type="number"
827
+ min={256}
828
+ max={200000}
829
+ step={256}
830
+ value={maxTokensInput}
831
+ onChange={(e) => setMaxTokensInput(e.target.value)}
832
+ disabled={unlimitedTokens || isSandbox}
833
+ />
834
+ <label className="flex items-center gap-2 text-xs text-muted-foreground">
835
+ <input
836
+ type="checkbox"
837
+ name="max_output_unlimited"
838
+ checked={unlimitedTokens}
839
+ onChange={(e) => setUnlimitedTokens(e.target.checked)}
840
+ disabled={isSandbox}
841
+ className="h-3.5 w-3.5"
842
+ />
843
+ Unlimited (use the model&apos;s full output budget)
844
+ </label>
845
+ <p className="text-[11px] text-muted-foreground">Range 256–200,000, or Unlimited. Default 16,000.</p>
846
+ </div>
847
+ <div className="space-y-1.5">
848
+ <Label
849
+ htmlFor="max_steps"
850
+ className="text-xs"
851
+ title="Range 2–100. Each step is one full model call (a page rewrite is ~3–4). This is also the runaway-loop backstop, so a high value can cost more. Default 8."
852
+ >
853
+ Max tool steps
854
+ </Label>
855
+ <Input
856
+ id="max_steps"
857
+ name="max_steps"
858
+ type="number"
859
+ min={2}
860
+ max={100}
861
+ step={1}
862
+ value={maxStepsInput}
863
+ onChange={(e) => setMaxStepsInput(e.target.value)}
864
+ disabled={isSandbox}
865
+ />
866
+ <p className="text-[11px] text-muted-foreground">
867
+ Range 2–100 tool-call rounds. Default 8; each step is one model call.
868
+ </p>
869
+ </div>
870
+ <div className="space-y-1.5">
871
+ <Label
872
+ htmlFor="temperature"
873
+ className="text-xs"
874
+ title="Range 0–2. This is Cortex's default (0.1), not the model's universal default (usually ~0.7–1.0). Low keeps structured tool-calls reliable; raise for more variety in copy."
875
+ >
876
+ Temperature
877
+ </Label>
878
+ <Input
879
+ id="temperature"
880
+ name="temperature"
881
+ type="number"
882
+ min={0}
883
+ max={2}
884
+ step={0.1}
885
+ value={temperatureInput}
886
+ onChange={(e) => setTemperatureInput(e.target.value)}
887
+ disabled={isSandbox}
888
+ />
889
+ <p className="text-[11px] text-muted-foreground">
890
+ Range 0–2. Cortex default 0.1 (low = reliable; most models default higher).
891
+ </p>
892
+ </div>
893
+ <div className="space-y-1.5">
894
+ <Label
895
+ htmlFor="response_timeout_seconds"
896
+ className="text-xs"
897
+ title="Range 15–600 seconds. Aborts an attempt only after this long with NO stream activity — not a hard cap on total time. Default 120."
898
+ >
899
+ Response timeout (seconds)
900
+ </Label>
901
+ <Input
902
+ id="response_timeout_seconds"
903
+ name="response_timeout_seconds"
904
+ type="number"
905
+ min={15}
906
+ max={600}
907
+ step={5}
908
+ value={timeoutInput}
909
+ onChange={(e) => setTimeoutInput(e.target.value)}
910
+ disabled={isSandbox}
911
+ />
912
+ <p className="text-[11px] text-muted-foreground">
913
+ Range 15–600s. Default 120; aborts only after this long with no activity.
914
+ </p>
915
+ </div>
916
+ </div>
917
+ <div className="flex items-center justify-between">
918
+ <span className="text-[11px] text-muted-foreground">
919
+ {isSandbox
920
+ ? 'Applies to the global page-building agent. Editable on a self-hosted install.'
921
+ : 'Values are clamped to safe ranges. Applies to the global page-building agent.'}
922
+ </span>
923
+ <Button type="submit" size="sm" disabled={isSandbox}>
924
+ <SlidersHorizontal className="mr-1.5 h-3.5 w-3.5" />
925
+ Save
926
+ </Button>
927
+ </div>
928
+ </form>
929
+ {isSandbox ? (
930
+ <Button type="button" variant="ghost" size="sm" className="h-7 text-muted-foreground" disabled>
931
+ <RotateCcw className="mr-1.5 h-3.5 w-3.5" />
932
+ Reset to defaults
933
+ </Button>
934
+ ) : (
935
+ <form action={resetCortexAiAgentSettingsAction} onSubmit={notifyCortexAiSettingsChanged}>
936
+ <Button type="submit" variant="ghost" size="sm" className="h-7 text-muted-foreground">
937
+ <RotateCcw className="mr-1.5 h-3.5 w-3.5" />
938
+ Reset to defaults
939
+ </Button>
940
+ </form>
941
+ )}
942
+ </CardContent>
943
+ </Card>
944
+ )}
945
+ </div>
946
+ </div>
947
+ );
948
+ }