create-nextblock 0.11.1 → 0.11.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 (59) hide show
  1. package/package.json +1 -1
  2. package/templates/nextblock-template/app/actions/interactions.test.ts +301 -0
  3. package/templates/nextblock-template/app/actions/interactions.ts +372 -0
  4. package/templates/nextblock-template/app/api/ai/cortex/build-widget/route.ts +4 -4
  5. package/templates/nextblock-template/app/api/ai/generate-blocks/route.ts +2 -2
  6. package/templates/nextblock-template/app/api/ai/global-agent/route.ts +56 -57
  7. package/templates/nextblock-template/app/api/cron/reset-sandbox/route.ts +1 -1
  8. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +951 -0
  9. package/templates/nextblock-template/app/article/[slug]/PostClientContent.tsx +6 -0
  10. package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +4 -0
  11. package/templates/nextblock-template/app/cms/components/ConnectGitHubButton.tsx +7 -2
  12. package/templates/nextblock-template/app/cms/components/github-connect-actions.ts +4 -0
  13. package/templates/nextblock-template/app/cms/interactions/InteractionsModerationClient.tsx +408 -0
  14. package/templates/nextblock-template/app/cms/interactions/page.tsx +51 -0
  15. package/templates/nextblock-template/app/cms/settings/cortex-ai/SandboxCortexAiSettingsClient.tsx +4 -3
  16. package/templates/nextblock-template/app/cms/settings/cortex-ai/StoredCortexAiSettingsClient.tsx +1 -1
  17. package/templates/nextblock-template/app/cms/settings/cortex-ai/actions.ts +3 -5
  18. package/templates/nextblock-template/app/cms/settings/cortex-ai/page.tsx +1 -1
  19. package/templates/nextblock-template/app/page.tsx +2 -2
  20. package/templates/nextblock-template/app/product/[slug]/page.tsx +2 -0
  21. package/templates/nextblock-template/components/AppShell.tsx +1 -1
  22. package/templates/nextblock-template/components/PostCommentsSection.tsx +369 -0
  23. package/templates/nextblock-template/components/ProductReviewsSection.tsx +419 -0
  24. package/templates/nextblock-template/components/blocks/renderers/ProductDetailsBlockRenderer.tsx +2 -0
  25. package/templates/nextblock-template/components/privacy/ConsentBanner.tsx +62 -19
  26. package/templates/nextblock-template/docs/08-NEXTBLOCK-CORTEX-AI-ARCHITECTURE.md +19 -19
  27. package/templates/nextblock-template/docs/10-CUSTOM-BLOCKS.md +4 -4
  28. package/templates/nextblock-template/docs/13-STAYING-UP-TO-DATE.md +7 -0
  29. package/templates/nextblock-template/lib/blocks/ProductGridBlock.tsx +2 -0
  30. package/templates/nextblock-template/lib/setup/actions.ts +3 -1
  31. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +40 -0
  32. package/templates/nextblock-template/lib/updates/check-upstream.ts +38 -4
  33. package/templates/nextblock-template/package.json +2 -1
  34. package/templates/nextblock-template/scripts/verify-cortex-ai-build-widget.tsx +2 -4
  35. package/templates/nextblock-template/scripts/verify-cortex-ai-generate-blocks.ts +1 -1
  36. package/templates/nextblock-template/scripts/verify-cortex-ai-global-tools.ts +1 -1
  37. package/templates/nextblock-template/scripts/verify-cortex-ai-routing.ts +1 -1
  38. package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
  39. package/templates/nextblock-template/lib/ai-block-generation.ts +0 -339
  40. package/templates/nextblock-template/lib/ai-client.ts +0 -247
  41. package/templates/nextblock-template/lib/ai-config.ts +0 -98
  42. package/templates/nextblock-template/lib/ai-cortex-widget-builder.ts +0 -125
  43. package/templates/nextblock-template/lib/ai-global-agent-custom-block-tools.ts +0 -363
  44. package/templates/nextblock-template/lib/ai-global-agent-db-tools.test.ts +0 -405
  45. package/templates/nextblock-template/lib/ai-global-agent-db-tools.ts +0 -1228
  46. package/templates/nextblock-template/lib/ai-global-agent-ecommerce.ts +0 -5
  47. package/templates/nextblock-template/lib/ai-global-agent-tools-stats.test.ts +0 -223
  48. package/templates/nextblock-template/lib/ai-global-agent-tools.test.ts +0 -2183
  49. package/templates/nextblock-template/lib/ai-global-agent-tools.ts +0 -4807
  50. package/templates/nextblock-template/lib/ai-key-crypto.test.ts +0 -70
  51. package/templates/nextblock-template/lib/ai-key-crypto.ts +0 -132
  52. package/templates/nextblock-template/lib/ai-model-catalog.test.ts +0 -49
  53. package/templates/nextblock-template/lib/ai-model-catalog.ts +0 -41
  54. package/templates/nextblock-template/lib/ai-model-registry.test.ts +0 -231
  55. package/templates/nextblock-template/lib/ai-model-registry.ts +0 -522
  56. package/templates/nextblock-template/lib/cortex-widget-registry.test.ts +0 -199
  57. package/templates/nextblock-template/lib/cortex-widget-registry.ts +0 -88
  58. package/templates/nextblock-template/lib/cortex-widget-schema.test.tsx +0 -237
  59. 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(/&nbsp;/gi, ' ')
73
- .replace(/&amp;/gi, '&')
74
- .replace(/&lt;/gi, '<')
75
- .replace(/&gt;/gi, '>')
76
- .replace(/&quot;/gi, '"')
77
- .replace(/&#39;/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|&nbsp;|<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 };