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