create-nextblock 0.10.9 → 0.11.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.test.ts +301 -0
- package/templates/nextblock-template/app/actions/interactions.ts +372 -0
- package/templates/nextblock-template/app/api/ai/cortex/build-widget/route.ts +4 -4
- package/templates/nextblock-template/app/api/ai/generate-blocks/route.ts +2 -2
- package/templates/nextblock-template/app/api/ai/global-agent/route.ts +56 -57
- package/templates/nextblock-template/app/api/cron/reset-sandbox/route.ts +1 -1
- package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +837 -0
- package/templates/nextblock-template/app/article/[slug]/PostClientContent.tsx +6 -0
- package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +4 -0
- package/templates/nextblock-template/app/cms/components/ConnectGitHubButton.tsx +122 -0
- package/templates/nextblock-template/app/cms/components/github-connect-actions.ts +102 -0
- package/templates/nextblock-template/app/cms/dashboard/components/DashboardOnboarding.tsx +18 -13
- package/templates/nextblock-template/app/cms/interactions/InteractionsModerationClient.tsx +408 -0
- package/templates/nextblock-template/app/cms/interactions/page.tsx +51 -0
- package/templates/nextblock-template/app/cms/settings/cortex-ai/SandboxCortexAiSettingsClient.tsx +4 -3
- package/templates/nextblock-template/app/cms/settings/cortex-ai/StoredCortexAiSettingsClient.tsx +1 -1
- package/templates/nextblock-template/app/cms/settings/cortex-ai/actions.ts +3 -5
- package/templates/nextblock-template/app/cms/settings/cortex-ai/page.tsx +1 -1
- package/templates/nextblock-template/app/page.tsx +2 -2
- package/templates/nextblock-template/app/product/[slug]/page.tsx +2 -0
- package/templates/nextblock-template/components/AppShell.tsx +1 -1
- package/templates/nextblock-template/components/PostCommentsSection.tsx +369 -0
- package/templates/nextblock-template/components/ProductReviewsSection.tsx +419 -0
- package/templates/nextblock-template/components/blocks/renderers/ProductDetailsBlockRenderer.tsx +2 -0
- package/templates/nextblock-template/components/privacy/ConsentBanner.tsx +62 -19
- package/templates/nextblock-template/docs/08-NEXTBLOCK-CORTEX-AI-ARCHITECTURE.md +19 -19
- package/templates/nextblock-template/docs/10-CUSTOM-BLOCKS.md +4 -4
- package/templates/nextblock-template/docs/12-VERCEL-DEPLOYMENT.md +9 -8
- package/templates/nextblock-template/docs/13-STAYING-UP-TO-DATE.md +38 -9
- package/templates/nextblock-template/lib/blocks/ProductGridBlock.tsx +2 -0
- package/templates/nextblock-template/lib/onboarding/status.ts +13 -6
- package/templates/nextblock-template/lib/setup/actions.ts +3 -1
- package/templates/nextblock-template/lib/setup/migrations-bundle.ts +30 -0
- package/templates/nextblock-template/lib/updates/check-upstream.ts +44 -7
- package/templates/nextblock-template/lib/updates/github-device.ts +206 -0
- package/templates/nextblock-template/lib/updates/repo-identity.ts +11 -1
- package/templates/nextblock-template/package.json +2 -1
- package/templates/nextblock-template/scripts/verify-cortex-ai-build-widget.tsx +2 -4
- package/templates/nextblock-template/scripts/verify-cortex-ai-generate-blocks.ts +1 -1
- package/templates/nextblock-template/scripts/verify-cortex-ai-global-tools.ts +1 -1
- package/templates/nextblock-template/scripts/verify-cortex-ai-routing.ts +1 -1
- package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
- package/templates/nextblock-template/lib/ai-block-generation.ts +0 -339
- package/templates/nextblock-template/lib/ai-client.ts +0 -247
- package/templates/nextblock-template/lib/ai-config.ts +0 -98
- package/templates/nextblock-template/lib/ai-cortex-widget-builder.ts +0 -125
- package/templates/nextblock-template/lib/ai-global-agent-custom-block-tools.ts +0 -363
- package/templates/nextblock-template/lib/ai-global-agent-db-tools.test.ts +0 -405
- package/templates/nextblock-template/lib/ai-global-agent-db-tools.ts +0 -1228
- package/templates/nextblock-template/lib/ai-global-agent-ecommerce.ts +0 -5
- package/templates/nextblock-template/lib/ai-global-agent-tools-stats.test.ts +0 -223
- package/templates/nextblock-template/lib/ai-global-agent-tools.test.ts +0 -2183
- package/templates/nextblock-template/lib/ai-global-agent-tools.ts +0 -4807
- package/templates/nextblock-template/lib/ai-key-crypto.test.ts +0 -70
- package/templates/nextblock-template/lib/ai-key-crypto.ts +0 -132
- package/templates/nextblock-template/lib/ai-model-catalog.test.ts +0 -49
- package/templates/nextblock-template/lib/ai-model-catalog.ts +0 -41
- package/templates/nextblock-template/lib/ai-model-registry.test.ts +0 -231
- package/templates/nextblock-template/lib/ai-model-registry.ts +0 -522
- package/templates/nextblock-template/lib/cortex-widget-registry.test.ts +0 -199
- package/templates/nextblock-template/lib/cortex-widget-registry.ts +0 -88
- package/templates/nextblock-template/lib/cortex-widget-schema.test.tsx +0 -237
- package/templates/nextblock-template/lib/cortex-widget-schema.ts +0 -393
|
@@ -1,339 +0,0 @@
|
|
|
1
|
-
import { generateText } from 'ai';
|
|
2
|
-
import { z } from './zod-config';
|
|
3
|
-
|
|
4
|
-
import {
|
|
5
|
-
buildCortexAiRoutingPolicy,
|
|
6
|
-
createCortexAiOpenRouterClient,
|
|
7
|
-
} from './ai-client';
|
|
8
|
-
import {
|
|
9
|
-
getHttpStatusCode,
|
|
10
|
-
isOpenRouterRecoverableRoutingError,
|
|
11
|
-
omitUnsupportedCortexAiModelOptions,
|
|
12
|
-
runWithCortexAiModelFallback,
|
|
13
|
-
type CortexAiModelAttempt,
|
|
14
|
-
type CortexAiOpenRouterModelId,
|
|
15
|
-
type CortexAiStoredModelSelection,
|
|
16
|
-
} from './ai-model-registry';
|
|
17
|
-
|
|
18
|
-
export const generateEditorBlocksRequestSchema = z.strictObject({
|
|
19
|
-
context: z.string().max(2000).optional(),
|
|
20
|
-
prompt: z.string().min(3).max(4000),
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
export type GenerateEditorBlocksRequest = z.infer<typeof generateEditorBlocksRequestSchema>;
|
|
24
|
-
|
|
25
|
-
export type GenerateEditorHtmlFragmentResult = {
|
|
26
|
-
attempts: readonly CortexAiModelAttempt[];
|
|
27
|
-
credentialSource: 'env' | 'stored' | 'manual';
|
|
28
|
-
html: string;
|
|
29
|
-
modelId: CortexAiOpenRouterModelId;
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
const CORTEX_AI_HTML_GENERATION_ATTEMPT_TIMEOUT_MS = 60_000;
|
|
33
|
-
|
|
34
|
-
function buildInlineHtmlAssistantSystemPrompt() {
|
|
35
|
-
return [
|
|
36
|
-
'You are NextBlock Cortex AI, an inline rich-text assistant for a Tiptap editor.',
|
|
37
|
-
'Return ONLY an HTML fragment. Do not return markdown fences, JSON, prose explanations, or commentary.',
|
|
38
|
-
'Do not include <!doctype>, <html>, <head>, or <body>. The output is inserted inside an existing editor document.',
|
|
39
|
-
'Use semantic HTML: headings, paragraphs, unordered and ordered lists, blockquotes, pre/code blocks, horizontal rules, and tables.',
|
|
40
|
-
'Use the current editor context for continuity, but do not repeat existing copy unless the user explicitly asks for a rewrite.',
|
|
41
|
-
'For tables, use valid <table>, <thead>, <tbody>, <tr>, <th>, and <td> markup with aligned rows and non-empty cells.',
|
|
42
|
-
'Never use blank table spacer rows, blank spacer columns, empty cells, <colgroup>, colspan, or rowspan unless the user explicitly asks for a blank template.',
|
|
43
|
-
'If CSS or JavaScript is explicitly requested, use proper <style> or <script> tags. The editor preserves those tags through its source-mode HTML parser.',
|
|
44
|
-
'Keep copy production-ready, editable, and concise unless the user asks for longer content.',
|
|
45
|
-
].join(' ');
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function buildHtmlGenerationPrompt(params: GenerateEditorBlocksRequest) {
|
|
49
|
-
return [
|
|
50
|
-
'Create an HTML fragment for this inline editor request:',
|
|
51
|
-
params.prompt,
|
|
52
|
-
/\b(table|pricing table|comparison table)\b/i.test(params.prompt)
|
|
53
|
-
? [
|
|
54
|
-
'Table requirements:',
|
|
55
|
-
'- Use a short header row.',
|
|
56
|
-
'- Use only the real content columns requested by the user.',
|
|
57
|
-
'- Do not add blank spacer columns or blank spacer rows.',
|
|
58
|
-
'- Do not use <colgroup>, colspan, or rowspan.',
|
|
59
|
-
'- Put normal descriptive prose before or after the table, not inside a table cell.',
|
|
60
|
-
'- Body rows must match the header column count.',
|
|
61
|
-
'- Every header and body cell must contain meaningful text.',
|
|
62
|
-
].join('\n')
|
|
63
|
-
: null,
|
|
64
|
-
params.context ? `Current editor context:\n${params.context}` : null,
|
|
65
|
-
]
|
|
66
|
-
.filter(Boolean)
|
|
67
|
-
.join('\n\n');
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function decodeBasicHtmlEntities(value: string) {
|
|
71
|
-
return value
|
|
72
|
-
.replace(/ /gi, ' ')
|
|
73
|
-
.replace(/&/gi, '&')
|
|
74
|
-
.replace(/</gi, '<')
|
|
75
|
-
.replace(/>/gi, '>')
|
|
76
|
-
.replace(/"/gi, '"')
|
|
77
|
-
.replace(/'/gi, "'");
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function getHtmlText(value: string) {
|
|
81
|
-
return decodeBasicHtmlEntities(
|
|
82
|
-
value
|
|
83
|
-
.replace(/<script\b[\s\S]*?<\/script>/gi, '')
|
|
84
|
-
.replace(/<style\b[\s\S]*?<\/style>/gi, '')
|
|
85
|
-
.replace(/<br\s*\/?>/gi, ' ')
|
|
86
|
-
.replace(/<[^>]+>/g, ' ')
|
|
87
|
-
)
|
|
88
|
-
.replace(/\s+/g, ' ')
|
|
89
|
-
.trim();
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function isEmptyHtml(value: string) {
|
|
93
|
-
return getHtmlText(value).length === 0;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function stripEmptyTopLevelBlocks(html: string) {
|
|
97
|
-
return html
|
|
98
|
-
.replace(/<(p|h[1-6])\b[^>]*>(?:\s| |<br\s*\/?>)*<\/\1>/gi, '')
|
|
99
|
-
.trim();
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function normalizeTableHtml(tableHtml: string) {
|
|
103
|
-
const rowMatches = [...tableHtml.matchAll(/<tr\b[^>]*>([\s\S]*?)<\/tr>/gi)];
|
|
104
|
-
|
|
105
|
-
if (rowMatches.length === 0) {
|
|
106
|
-
return tableHtml;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
const rows = rowMatches
|
|
110
|
-
.map((rowMatch) => {
|
|
111
|
-
const cells = [...rowMatch[1].matchAll(/<(td|th)\b[^>]*>([\s\S]*?)<\/\1>/gi)].map(
|
|
112
|
-
(cellMatch) => ({
|
|
113
|
-
innerHtml: cellMatch[2].trim(),
|
|
114
|
-
tag: cellMatch[1].toLowerCase() as 'td' | 'th',
|
|
115
|
-
})
|
|
116
|
-
);
|
|
117
|
-
|
|
118
|
-
return { cells };
|
|
119
|
-
})
|
|
120
|
-
.filter((row) => row.cells.some((cell) => !isEmptyHtml(cell.innerHtml)));
|
|
121
|
-
|
|
122
|
-
if (rows.length === 0) {
|
|
123
|
-
return tableHtml;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
const maxColumnCount = Math.max(...rows.map((row) => row.cells.length));
|
|
127
|
-
const emptyColumnIndexes = new Set<number>();
|
|
128
|
-
|
|
129
|
-
for (let columnIndex = 0; columnIndex < maxColumnCount; columnIndex++) {
|
|
130
|
-
const isColumnEmpty = rows.every((row) => {
|
|
131
|
-
const cell = row.cells[columnIndex];
|
|
132
|
-
return !cell || isEmptyHtml(cell.innerHtml);
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
if (isColumnEmpty) {
|
|
136
|
-
emptyColumnIndexes.add(columnIndex);
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
const normalizedRows = rows
|
|
141
|
-
.map((row) => ({
|
|
142
|
-
cells: row.cells.filter((cell, cellIndex) => !emptyColumnIndexes.has(cellIndex)),
|
|
143
|
-
}))
|
|
144
|
-
.filter((row) => row.cells.length > 0);
|
|
145
|
-
|
|
146
|
-
if (normalizedRows.length === 0) {
|
|
147
|
-
return tableHtml;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
const renderRow = (row: (typeof normalizedRows)[number]) =>
|
|
151
|
-
`<tr>${row.cells.map((cell) => `<${cell.tag}>${cell.innerHtml}</${cell.tag}>`).join('')}</tr>`;
|
|
152
|
-
const hasHeaderRow = normalizedRows[0].cells.some((cell) => cell.tag === 'th');
|
|
153
|
-
|
|
154
|
-
if (!hasHeaderRow) {
|
|
155
|
-
return `<table><tbody>${normalizedRows.map(renderRow).join('')}</tbody></table>`;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
const [headerRow, ...bodyRows] = normalizedRows;
|
|
159
|
-
|
|
160
|
-
return `<table><thead>${renderRow(headerRow)}</thead><tbody>${bodyRows
|
|
161
|
-
.map(renderRow)
|
|
162
|
-
.join('')}</tbody></table>`;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
function normalizeGeneratedTables(html: string) {
|
|
166
|
-
return html.replace(/<table\b[\s\S]*?<\/table>/gi, (tableHtml) =>
|
|
167
|
-
normalizeTableHtml(tableHtml)
|
|
168
|
-
);
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
function assertTablesHaveMeaningfulCells(html: string) {
|
|
172
|
-
const tableMatches = [...html.matchAll(/<table\b[\s\S]*?<\/table>/gi)];
|
|
173
|
-
|
|
174
|
-
for (const tableMatch of tableMatches) {
|
|
175
|
-
const tableHtml = tableMatch[0];
|
|
176
|
-
const rowMatches = [...tableHtml.matchAll(/<tr\b[^>]*>([\s\S]*?)<\/tr>/gi)];
|
|
177
|
-
|
|
178
|
-
if (rowMatches.length === 0) {
|
|
179
|
-
throw new Error('Cortex AI returned a table without rows.');
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
const columnCounts: number[] = [];
|
|
183
|
-
|
|
184
|
-
for (const rowMatch of rowMatches) {
|
|
185
|
-
const cells = [...rowMatch[1].matchAll(/<(td|th)\b[^>]*>([\s\S]*?)<\/\1>/gi)];
|
|
186
|
-
|
|
187
|
-
if (cells.length === 0) {
|
|
188
|
-
throw new Error('Cortex AI returned a table row without cells.');
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
for (const cellMatch of cells) {
|
|
192
|
-
if (isEmptyHtml(cellMatch[2])) {
|
|
193
|
-
throw new Error('Cortex AI returned a table with empty cells.');
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
columnCounts.push(cells.length);
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
if (new Set(columnCounts).size > 1) {
|
|
201
|
-
throw new Error('Cortex AI returned a table with uneven row widths.');
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
function normalizeCommonHtmlWrappers(rawText: string) {
|
|
207
|
-
let html = rawText.trim();
|
|
208
|
-
|
|
209
|
-
if (
|
|
210
|
-
((html.startsWith('"') && html.endsWith('"')) ||
|
|
211
|
-
(html.startsWith("'") && html.endsWith("'"))) &&
|
|
212
|
-
html.slice(1, -1).includes('<')
|
|
213
|
-
) {
|
|
214
|
-
html = html.slice(1, -1).trim();
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
const bodyMatch = html.match(/<body\b[^>]*>([\s\S]*?)<\/body>/i);
|
|
218
|
-
if (bodyMatch?.[1]) {
|
|
219
|
-
html = bodyMatch[1].trim();
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
return stripEmptyTopLevelBlocks(normalizeGeneratedTables(html));
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
export function validateGeneratedEditorHtmlFragment(rawText: string) {
|
|
226
|
-
const html = normalizeCommonHtmlWrappers(rawText);
|
|
227
|
-
|
|
228
|
-
if (!html.trim()) {
|
|
229
|
-
throw new Error('Cortex AI returned empty HTML.');
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
if (/```/.test(html)) {
|
|
233
|
-
throw new Error('Cortex AI returned markdown code fences instead of an HTML fragment.');
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
if (/<!doctype\b|<html\b|<head\b|<body\b/i.test(html)) {
|
|
237
|
-
throw new Error('Cortex AI returned a full HTML document instead of an HTML fragment.');
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
if (/^(sure|certainly|of course|here(?:'s| is)|below is|i can)\b/i.test(html)) {
|
|
241
|
-
throw new Error('Cortex AI returned a conversational response instead of an HTML fragment.');
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
if (!/<[a-z][\w:-]*(?:\s[^>]*)?>/i.test(html)) {
|
|
245
|
-
throw new Error('Cortex AI returned plain text instead of semantic HTML.');
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
assertTablesHaveMeaningfulCells(html);
|
|
249
|
-
|
|
250
|
-
return html;
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
function isRecoverableHtmlGenerationError(error: unknown) {
|
|
254
|
-
const statusCode = getHttpStatusCode(error);
|
|
255
|
-
|
|
256
|
-
if (statusCode === 401 || statusCode === 402 || statusCode === 403) {
|
|
257
|
-
return false;
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
if (isOpenRouterRecoverableRoutingError(error)) {
|
|
261
|
-
return true;
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
if (statusCode && statusCode >= 500) {
|
|
265
|
-
return true;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
269
|
-
return /NoContentGenerated|No content generated|Provider returned error|empty HTML|markdown code fences|full HTML document|conversational response|plain text|aborted|abort|timeout|timed out/i.test(
|
|
270
|
-
message
|
|
271
|
-
);
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
export async function generateEditorHtmlFragment(
|
|
275
|
-
params: GenerateEditorBlocksRequest & {
|
|
276
|
-
apiKey?: string;
|
|
277
|
-
fallbackModelIds?: readonly CortexAiOpenRouterModelId[];
|
|
278
|
-
modelId?: CortexAiOpenRouterModelId;
|
|
279
|
-
modelSelection?: CortexAiStoredModelSelection | null;
|
|
280
|
-
}
|
|
281
|
-
): Promise<GenerateEditorHtmlFragmentResult> {
|
|
282
|
-
const { apiKey, fallbackModelIds, modelId, modelSelection, ...requestParams } = params;
|
|
283
|
-
const request = generateEditorBlocksRequestSchema.parse(requestParams);
|
|
284
|
-
const client = await createCortexAiOpenRouterClient({ apiKey, modelSelection });
|
|
285
|
-
const routingPolicy = buildCortexAiRoutingPolicy({
|
|
286
|
-
credentialSource: client.credentialSource,
|
|
287
|
-
fallbackModelIds,
|
|
288
|
-
requestedModelId: modelId,
|
|
289
|
-
selectedModel: client.modelSelection,
|
|
290
|
-
});
|
|
291
|
-
|
|
292
|
-
const generation = await runWithCortexAiModelFallback({
|
|
293
|
-
modelIds: routingPolicy.modelIds,
|
|
294
|
-
shouldRetry: isRecoverableHtmlGenerationError,
|
|
295
|
-
execute: async (attemptModelId) => {
|
|
296
|
-
const abortController = new AbortController();
|
|
297
|
-
const timeoutId = setTimeout(
|
|
298
|
-
() => abortController.abort(),
|
|
299
|
-
CORTEX_AI_HTML_GENERATION_ATTEMPT_TIMEOUT_MS
|
|
300
|
-
);
|
|
301
|
-
|
|
302
|
-
try {
|
|
303
|
-
const attemptOptions = omitUnsupportedCortexAiModelOptions(
|
|
304
|
-
{
|
|
305
|
-
abortSignal: abortController.signal,
|
|
306
|
-
maxOutputTokens: 5000,
|
|
307
|
-
maxRetries: 0,
|
|
308
|
-
prompt: buildHtmlGenerationPrompt(request),
|
|
309
|
-
system: buildInlineHtmlAssistantSystemPrompt(),
|
|
310
|
-
temperature: 0.2,
|
|
311
|
-
} as Record<string, unknown>,
|
|
312
|
-
{
|
|
313
|
-
modelId: attemptModelId,
|
|
314
|
-
modelSelection: routingPolicy.modelSelection,
|
|
315
|
-
}
|
|
316
|
-
);
|
|
317
|
-
|
|
318
|
-
const result = await generateText({
|
|
319
|
-
...attemptOptions,
|
|
320
|
-
model: client.model(attemptModelId),
|
|
321
|
-
} as Parameters<typeof generateText>[0]);
|
|
322
|
-
|
|
323
|
-
return validateGeneratedEditorHtmlFragment(result.text);
|
|
324
|
-
} finally {
|
|
325
|
-
clearTimeout(timeoutId);
|
|
326
|
-
}
|
|
327
|
-
},
|
|
328
|
-
});
|
|
329
|
-
|
|
330
|
-
return {
|
|
331
|
-
attempts: generation.attempts,
|
|
332
|
-
credentialSource: client.credentialSource,
|
|
333
|
-
html: generation.result,
|
|
334
|
-
modelId: generation.modelId,
|
|
335
|
-
};
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
export const INLINE_HTML_ASSISTANT_SYSTEM_PROMPT =
|
|
339
|
-
'Built at runtime for HTML-fragment rich-text assistance.';
|
|
@@ -1,247 +0,0 @@
|
|
|
1
|
-
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
|
|
2
|
-
import { getServiceRoleSupabaseClient } from '@nextblock-cms/db/server';
|
|
3
|
-
import { generateText, type LanguageModel } from 'ai';
|
|
4
|
-
|
|
5
|
-
import {
|
|
6
|
-
CORTEX_AI_OPENROUTER_MODEL_SELECTION_SETTING_KEY,
|
|
7
|
-
CORTEX_AI_OPENROUTER_SETTING_KEY,
|
|
8
|
-
CORTEX_AI_PACKAGE_NAME,
|
|
9
|
-
decryptStoredOpenRouterApiKey,
|
|
10
|
-
getOpenRouterEnvApiKey,
|
|
11
|
-
} from './ai-config';
|
|
12
|
-
import {
|
|
13
|
-
buildCortexAiRoutingPolicy,
|
|
14
|
-
CORTEX_AI_OPENROUTER_BASE_URL,
|
|
15
|
-
omitUnsupportedCortexAiModelOptions,
|
|
16
|
-
safeParseCortexAiModelSelection,
|
|
17
|
-
type CortexAiModelAttempt,
|
|
18
|
-
type CortexAiOpenRouterModelId,
|
|
19
|
-
type CortexAiStoredModelSelection,
|
|
20
|
-
runWithCortexAiModelFallback,
|
|
21
|
-
} from './ai-model-registry';
|
|
22
|
-
|
|
23
|
-
type AiGenerateTextOptions = Omit<Parameters<typeof generateText>[0], 'model'>;
|
|
24
|
-
type AiGenerateTextResult = Awaited<ReturnType<typeof generateText>>;
|
|
25
|
-
type FetchFunction = typeof globalThis.fetch;
|
|
26
|
-
|
|
27
|
-
const SERVER_ONLY_ERROR_MESSAGE =
|
|
28
|
-
'Cortex AI OpenRouter client can only be imported from server-side code.';
|
|
29
|
-
|
|
30
|
-
if (typeof window !== 'undefined') {
|
|
31
|
-
throw new Error(SERVER_ONLY_ERROR_MESSAGE);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export type CortexAiOpenRouterCredentialSource = 'env' | 'stored' | 'manual' | 'none';
|
|
35
|
-
|
|
36
|
-
export type CortexAiOpenRouterCredential = {
|
|
37
|
-
apiKey: string | null;
|
|
38
|
-
source: CortexAiOpenRouterCredentialSource;
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
export type CortexAiOpenRouterClient = {
|
|
42
|
-
credentialSource: Exclude<CortexAiOpenRouterCredentialSource, 'none'>;
|
|
43
|
-
model: (modelId?: CortexAiOpenRouterModelId) => LanguageModel;
|
|
44
|
-
modelSelection: CortexAiStoredModelSelection | null;
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
export type CortexAiGenerateTextOptions = AiGenerateTextOptions & {
|
|
48
|
-
apiKey?: string;
|
|
49
|
-
fallbackModelIds?: readonly CortexAiOpenRouterModelId[];
|
|
50
|
-
modelId?: CortexAiOpenRouterModelId;
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
export type CortexAiGenerateTextResult = {
|
|
54
|
-
attempts: readonly CortexAiModelAttempt[];
|
|
55
|
-
credentialSource: Exclude<CortexAiOpenRouterCredentialSource, 'none'>;
|
|
56
|
-
modelId: CortexAiOpenRouterModelId;
|
|
57
|
-
result: AiGenerateTextResult;
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
function buildOpenRouterHeaders() {
|
|
61
|
-
const referer = process.env.NEXT_PUBLIC_URL?.trim() || 'https://nextblock.dev';
|
|
62
|
-
|
|
63
|
-
return {
|
|
64
|
-
'HTTP-Referer': referer,
|
|
65
|
-
'X-Title': CORTEX_AI_PACKAGE_NAME,
|
|
66
|
-
};
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
export function createCortexAiOpenRouterProvider(params: {
|
|
70
|
-
apiKey: string;
|
|
71
|
-
fetch?: FetchFunction;
|
|
72
|
-
}) {
|
|
73
|
-
return createOpenAICompatible<string, string, string, string>({
|
|
74
|
-
apiKey: params.apiKey,
|
|
75
|
-
baseURL: CORTEX_AI_OPENROUTER_BASE_URL,
|
|
76
|
-
fetch: params.fetch,
|
|
77
|
-
headers: buildOpenRouterHeaders(),
|
|
78
|
-
includeUsage: true,
|
|
79
|
-
name: 'openrouter',
|
|
80
|
-
supportsStructuredOutputs: true,
|
|
81
|
-
});
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
async function readStoredOpenRouterApiKey() {
|
|
85
|
-
const supabase = getServiceRoleSupabaseClient();
|
|
86
|
-
const { data, error } = await supabase
|
|
87
|
-
.from('site_settings')
|
|
88
|
-
.select('value')
|
|
89
|
-
.eq('key', CORTEX_AI_OPENROUTER_SETTING_KEY)
|
|
90
|
-
.maybeSingle();
|
|
91
|
-
|
|
92
|
-
if (error) {
|
|
93
|
-
throw new Error(`Failed to load stored Cortex AI OpenRouter key: ${error.message}`);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
if (!data?.value) {
|
|
97
|
-
return null;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
return decryptStoredOpenRouterApiKey(data.value);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
export async function getStoredCortexAiModelSelection(): Promise<CortexAiStoredModelSelection | null> {
|
|
104
|
-
const supabase = getServiceRoleSupabaseClient();
|
|
105
|
-
const { data, error } = await supabase
|
|
106
|
-
.from('site_settings')
|
|
107
|
-
.select('value')
|
|
108
|
-
.eq('key', CORTEX_AI_OPENROUTER_MODEL_SELECTION_SETTING_KEY)
|
|
109
|
-
.maybeSingle();
|
|
110
|
-
|
|
111
|
-
if (error) {
|
|
112
|
-
throw new Error(`Failed to load Cortex AI OpenRouter model selection: ${error.message}`);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
return safeParseCortexAiModelSelection(data?.value);
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
export async function resolveCortexAiOpenRouterCredential(params?: {
|
|
119
|
-
apiKey?: string;
|
|
120
|
-
}): Promise<CortexAiOpenRouterCredential> {
|
|
121
|
-
const manualApiKey = params?.apiKey?.trim();
|
|
122
|
-
|
|
123
|
-
if (manualApiKey) {
|
|
124
|
-
return {
|
|
125
|
-
apiKey: manualApiKey,
|
|
126
|
-
source: 'manual',
|
|
127
|
-
};
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
const storedApiKey = await readStoredOpenRouterApiKey();
|
|
131
|
-
|
|
132
|
-
if (storedApiKey) {
|
|
133
|
-
return {
|
|
134
|
-
apiKey: storedApiKey,
|
|
135
|
-
source: 'stored',
|
|
136
|
-
};
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
const envApiKey = getOpenRouterEnvApiKey();
|
|
140
|
-
|
|
141
|
-
if (envApiKey) {
|
|
142
|
-
return {
|
|
143
|
-
apiKey: envApiKey,
|
|
144
|
-
source: 'env',
|
|
145
|
-
};
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
return {
|
|
149
|
-
apiKey: null,
|
|
150
|
-
source: 'none',
|
|
151
|
-
};
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
export async function createCortexAiOpenRouterClient(params?: {
|
|
155
|
-
apiKey?: string;
|
|
156
|
-
fetch?: FetchFunction;
|
|
157
|
-
modelSelection?: CortexAiStoredModelSelection | null;
|
|
158
|
-
}) {
|
|
159
|
-
const credential = await resolveCortexAiOpenRouterCredential({
|
|
160
|
-
apiKey: params?.apiKey,
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
if (!credential.apiKey || credential.source === 'none') {
|
|
164
|
-
throw new Error(
|
|
165
|
-
'Cortex AI requires OPENROUTER_API_KEY or an encrypted OpenRouter BYOK in site settings.'
|
|
166
|
-
);
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
const provider = createCortexAiOpenRouterProvider({
|
|
170
|
-
apiKey: credential.apiKey,
|
|
171
|
-
fetch: params?.fetch,
|
|
172
|
-
});
|
|
173
|
-
const modelSelection =
|
|
174
|
-
params?.modelSelection !== undefined
|
|
175
|
-
? params.modelSelection
|
|
176
|
-
: credential.source === 'stored'
|
|
177
|
-
? await getStoredCortexAiModelSelection()
|
|
178
|
-
: null;
|
|
179
|
-
|
|
180
|
-
return {
|
|
181
|
-
credentialSource: credential.source,
|
|
182
|
-
model: (modelId?: CortexAiOpenRouterModelId) => provider.chatModel(modelId || 'openrouter/free'),
|
|
183
|
-
modelSelection,
|
|
184
|
-
};
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
export async function generateCortexAiText({
|
|
188
|
-
apiKey,
|
|
189
|
-
fallbackModelIds,
|
|
190
|
-
modelId,
|
|
191
|
-
...options
|
|
192
|
-
}: CortexAiGenerateTextOptions & { modelSelection?: CortexAiStoredModelSelection | null }): Promise<CortexAiGenerateTextResult> {
|
|
193
|
-
const client = await createCortexAiOpenRouterClient({ apiKey, modelSelection: options.modelSelection });
|
|
194
|
-
const routingPolicy = buildCortexAiRoutingPolicy({
|
|
195
|
-
credentialSource: client.credentialSource,
|
|
196
|
-
fallbackModelIds,
|
|
197
|
-
requestedModelId: modelId,
|
|
198
|
-
selectedModel: client.modelSelection,
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
const generation = await runWithCortexAiModelFallback({
|
|
202
|
-
modelIds: routingPolicy.modelIds,
|
|
203
|
-
execute: (attemptModelId) => {
|
|
204
|
-
const attemptOptions = omitUnsupportedCortexAiModelOptions(
|
|
205
|
-
{
|
|
206
|
-
...options,
|
|
207
|
-
maxRetries: 0,
|
|
208
|
-
} as Record<string, unknown>,
|
|
209
|
-
{
|
|
210
|
-
modelId: attemptModelId,
|
|
211
|
-
modelSelection: routingPolicy.modelSelection,
|
|
212
|
-
}
|
|
213
|
-
);
|
|
214
|
-
|
|
215
|
-
return generateText({
|
|
216
|
-
...attemptOptions,
|
|
217
|
-
model: client.model(attemptModelId),
|
|
218
|
-
} as Parameters<typeof generateText>[0]);
|
|
219
|
-
},
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
return {
|
|
223
|
-
attempts: generation.attempts,
|
|
224
|
-
credentialSource: client.credentialSource,
|
|
225
|
-
modelId: generation.modelId,
|
|
226
|
-
result: generation.result,
|
|
227
|
-
};
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
export {
|
|
231
|
-
buildCortexAiModelFallbackChain,
|
|
232
|
-
buildCortexAiRoutingPolicy,
|
|
233
|
-
CORTEX_AI_FREE_MODEL_FALLBACK_REGISTRY,
|
|
234
|
-
CORTEX_AI_MODEL_REGISTRY,
|
|
235
|
-
CORTEX_AI_OPENROUTER_BASE_URL,
|
|
236
|
-
CORTEX_AI_OPENROUTER_FREE_ROUTER_MODEL,
|
|
237
|
-
CORTEX_AI_REQUIRED_MODEL_PARAMETERS,
|
|
238
|
-
CortexAiRoutingError,
|
|
239
|
-
isOpenRouterRecoverableRoutingError,
|
|
240
|
-
isOpenRouterRateLimitError,
|
|
241
|
-
omitUnsupportedCortexAiModelOptions,
|
|
242
|
-
runWithCortexAiModelFallback,
|
|
243
|
-
summarizeCortexAiRoutingError,
|
|
244
|
-
} from './ai-model-registry';
|
|
245
|
-
export {
|
|
246
|
-
listCortexAiCompatibleOpenRouterModels,
|
|
247
|
-
} from './ai-model-catalog';
|
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
encryptOpenRouterApiKey,
|
|
3
|
-
getMaskedOpenRouterKey,
|
|
4
|
-
getOpenRouterKeyEnvelopeStatus,
|
|
5
|
-
type EncryptedOpenRouterKeyEnvelope,
|
|
6
|
-
} from './ai-key-crypto';
|
|
7
|
-
import {
|
|
8
|
-
hasSecretEncryptionKey,
|
|
9
|
-
resolveSecretEncryptionKey,
|
|
10
|
-
tryDecryptWithEnvKey,
|
|
11
|
-
} from '@nextblock-cms/db/server';
|
|
12
|
-
|
|
13
|
-
const SERVER_ONLY_ERROR_MESSAGE =
|
|
14
|
-
'Cortex AI configuration can only be imported from server-side code.';
|
|
15
|
-
|
|
16
|
-
if (typeof window !== 'undefined') {
|
|
17
|
-
throw new Error(SERVER_ONLY_ERROR_MESSAGE);
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export const CORTEX_AI_PACKAGE_ID = 'cortex-ai';
|
|
21
|
-
export const CORTEX_AI_PACKAGE_NAME = 'NextBlock Cortex AI';
|
|
22
|
-
export const CORTEX_AI_OPENROUTER_SETTING_KEY = 'cortex_ai_openrouter_api_key';
|
|
23
|
-
export const CORTEX_AI_OPENROUTER_MODEL_SELECTION_SETTING_KEY =
|
|
24
|
-
'cortex_ai_openrouter_model_selection';
|
|
25
|
-
|
|
26
|
-
function readEnvValue(name: string) {
|
|
27
|
-
return process.env[name]?.trim() || null;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export function getOpenRouterEnvApiKey() {
|
|
31
|
-
return readEnvValue('OPENROUTER_API_KEY');
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export function getCortexAiEnvConfig() {
|
|
35
|
-
const openRouterApiKey = getOpenRouterEnvApiKey();
|
|
36
|
-
|
|
37
|
-
return {
|
|
38
|
-
encryptionKey: readEnvValue('CORTEX_AI_ENCRYPTION_KEY'),
|
|
39
|
-
freemiusSandboxKey: readEnvValue('FREEMIUS_AI_SANDBOX_KEY'),
|
|
40
|
-
// True when ANY usable key exists: an explicit env key OR the service-role-derived
|
|
41
|
-
// fallback — so BYOK works on a one-click Vercel deploy with no extra env var.
|
|
42
|
-
hasEncryptionKey: hasSecretEncryptionKey(),
|
|
43
|
-
hasOpenRouterEnvKey: Boolean(openRouterApiKey),
|
|
44
|
-
openRouterEnvKeyLast4: openRouterApiKey ? openRouterApiKey.slice(-4) : null,
|
|
45
|
-
};
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function requireEncryptionKey() {
|
|
49
|
-
// Resolve via the shared chain: NEXTBLOCK_ENCRYPTION_KEY -> CORTEX_AI_ENCRYPTION_KEY ->
|
|
50
|
-
// a stable key derived from the Supabase service-role key. The derived fallback lets
|
|
51
|
-
// BYOK work out-of-the-box on hosted installs (e.g. one-click Vercel).
|
|
52
|
-
const encryptionKey = resolveSecretEncryptionKey();
|
|
53
|
-
|
|
54
|
-
if (!encryptionKey) {
|
|
55
|
-
throw new Error(
|
|
56
|
-
'An encryption key (NEXTBLOCK_ENCRYPTION_KEY, CORTEX_AI_ENCRYPTION_KEY, or a Supabase service-role key) is required to manage stored OpenRouter keys.'
|
|
57
|
-
);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
return encryptionKey;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export function encryptStoredOpenRouterApiKey(apiKey: string) {
|
|
64
|
-
return encryptOpenRouterApiKey({
|
|
65
|
-
apiKey,
|
|
66
|
-
encryptionSecret: requireEncryptionKey(),
|
|
67
|
-
});
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export function decryptStoredOpenRouterApiKey(encryptedKey: unknown) {
|
|
71
|
-
// Try every candidate key (explicit env keys + the derived fallback). This keeps a key
|
|
72
|
-
// stored under one key readable if another is added later, and matches the SMTP/payment
|
|
73
|
-
// secret behaviour. The envelope is byte-compatible with the shared secret-crypto format.
|
|
74
|
-
const result = tryDecryptWithEnvKey(encryptedKey);
|
|
75
|
-
|
|
76
|
-
if (result === null) {
|
|
77
|
-
throw new Error('Failed to decrypt stored OpenRouter key.');
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
return result;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
export function getStoredOpenRouterKeyStatus(value: unknown) {
|
|
84
|
-
return getOpenRouterKeyEnvelopeStatus(value);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
export function getEnvOpenRouterKeyStatus() {
|
|
88
|
-
const env = getCortexAiEnvConfig();
|
|
89
|
-
|
|
90
|
-
return {
|
|
91
|
-
hasEnvOpenRouterKey: env.hasOpenRouterEnvKey,
|
|
92
|
-
maskedEnvOpenRouterKey: env.openRouterEnvKeyLast4
|
|
93
|
-
? getMaskedOpenRouterKey(env.openRouterEnvKeyLast4)
|
|
94
|
-
: null,
|
|
95
|
-
};
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
export type { EncryptedOpenRouterKeyEnvelope };
|