@actuate-media/cms-core 0.15.0 → 0.17.0
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/dist/__tests__/api/collections-ai-create.test.d.ts +2 -0
- package/dist/__tests__/api/collections-ai-create.test.d.ts.map +1 -0
- package/dist/__tests__/api/collections-ai-create.test.js +313 -0
- package/dist/__tests__/api/collections-ai-create.test.js.map +1 -0
- package/dist/__tests__/collections/presets.test.d.ts +2 -0
- package/dist/__tests__/collections/presets.test.d.ts.map +1 -0
- package/dist/__tests__/collections/presets.test.js +141 -0
- package/dist/__tests__/collections/presets.test.js.map +1 -0
- package/dist/__tests__/fields/presets.test.d.ts +2 -0
- package/dist/__tests__/fields/presets.test.d.ts.map +1 -0
- package/dist/__tests__/fields/presets.test.js +99 -0
- package/dist/__tests__/fields/presets.test.js.map +1 -0
- package/dist/api/handlers.d.ts.map +1 -1
- package/dist/api/handlers.js +174 -0
- package/dist/api/handlers.js.map +1 -1
- package/dist/collections/index.d.ts +2 -0
- package/dist/collections/index.d.ts.map +1 -1
- package/dist/collections/index.js +1 -0
- package/dist/collections/index.js.map +1 -1
- package/dist/collections/presets.d.ts +71 -0
- package/dist/collections/presets.d.ts.map +1 -0
- package/dist/collections/presets.js +504 -0
- package/dist/collections/presets.js.map +1 -0
- package/dist/fields/index.d.ts +1 -0
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +1 -0
- package/dist/fields/index.js.map +1 -1
- package/dist/fields/presets.d.ts +90 -0
- package/dist/fields/presets.d.ts.map +1 -0
- package/dist/fields/presets.js +253 -0
- package/dist/fields/presets.js.map +1 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/page-builder/schema.d.ts +8 -8
- package/dist/page-builder/templates.d.ts.map +1 -1
- package/dist/page-builder/templates.js +312 -0
- package/dist/page-builder/templates.js.map +1 -1
- package/package.json +1 -1
package/dist/api/handlers.js
CHANGED
|
@@ -4710,6 +4710,7 @@ export function registerCMSRoutes(router) {
|
|
|
4710
4710
|
return modelNotAvailable('PageTemplate');
|
|
4711
4711
|
const url = new URL(request.url, 'http://localhost');
|
|
4712
4712
|
const category = url.searchParams.get('category');
|
|
4713
|
+
const collection = url.searchParams.get('collection');
|
|
4713
4714
|
const builtInCount = await safeCount(d.pageTemplate, { builtIn: true });
|
|
4714
4715
|
if (builtInCount === 0) {
|
|
4715
4716
|
await seedBuiltInTemplates(d);
|
|
@@ -4721,6 +4722,37 @@ export function registerCMSRoutes(router) {
|
|
|
4721
4722
|
where,
|
|
4722
4723
|
orderBy: [{ builtIn: 'desc' }, { updatedAt: 'desc' }],
|
|
4723
4724
|
});
|
|
4725
|
+
// When a `collection` filter is supplied, reorder the response so the
|
|
4726
|
+
// suggested templates for that collection bubble to the top. The admin
|
|
4727
|
+
// "Create new" picker uses this so blog posts default to `blog-post`,
|
|
4728
|
+
// case studies to `case-study`, etc., without forcing the user to scan
|
|
4729
|
+
// every template alphabetically.
|
|
4730
|
+
if (collection) {
|
|
4731
|
+
const { getTemplatesForCollection } = await import('../collections/presets.js');
|
|
4732
|
+
const { primary, alternates } = getTemplatesForCollection(collection);
|
|
4733
|
+
const suggestionOrder = new Map();
|
|
4734
|
+
if (primary)
|
|
4735
|
+
suggestionOrder.set(`builtin-${primary}`, 0);
|
|
4736
|
+
alternates.forEach((alt, i) => suggestionOrder.set(`builtin-${alt}`, i + 1));
|
|
4737
|
+
templates.sort((a, b) => {
|
|
4738
|
+
const ai = suggestionOrder.has(a.id) ? suggestionOrder.get(a.id) : 100;
|
|
4739
|
+
const bi = suggestionOrder.has(b.id) ? suggestionOrder.get(b.id) : 100;
|
|
4740
|
+
return ai - bi;
|
|
4741
|
+
});
|
|
4742
|
+
const annotated = templates.map((t) => ({
|
|
4743
|
+
...t,
|
|
4744
|
+
suggested: suggestionOrder.has(t.id),
|
|
4745
|
+
suggestionRank: suggestionOrder.get(t.id) ?? null,
|
|
4746
|
+
}));
|
|
4747
|
+
return json({
|
|
4748
|
+
data: annotated,
|
|
4749
|
+
suggestion: {
|
|
4750
|
+
collection,
|
|
4751
|
+
primary: primary ? `builtin-${primary}` : null,
|
|
4752
|
+
alternates: alternates.map((a) => `builtin-${a}`),
|
|
4753
|
+
},
|
|
4754
|
+
});
|
|
4755
|
+
}
|
|
4724
4756
|
return json({ data: templates });
|
|
4725
4757
|
}
|
|
4726
4758
|
catch (err) {
|
|
@@ -5301,6 +5333,148 @@ export function registerCMSRoutes(router) {
|
|
|
5301
5333
|
return internalError(err, 'page-builder create');
|
|
5302
5334
|
}
|
|
5303
5335
|
});
|
|
5336
|
+
/**
|
|
5337
|
+
* Generic AI content authoring for ANY collection.
|
|
5338
|
+
*
|
|
5339
|
+
* `POST /collections/:slug/ai-create` is the non-page counterpart to
|
|
5340
|
+
* `/page-builder/create`. It takes a free-form prompt, looks up the
|
|
5341
|
+
* collection's field schema from the running config, asks the AI provider
|
|
5342
|
+
* to produce structured JSON matching that schema, validates / coerces the
|
|
5343
|
+
* output, and persists the result as a draft (or published) document.
|
|
5344
|
+
*
|
|
5345
|
+
* Used by:
|
|
5346
|
+
* - the admin "Generate from prompt" affordance on the collection list
|
|
5347
|
+
* - the MCP server's `create_in_collection` / `create_blog_post` / etc.
|
|
5348
|
+
* - AI agents calling the REST API directly
|
|
5349
|
+
*/
|
|
5350
|
+
router.post('/collections/:slug/ai-create', async (request, params) => {
|
|
5351
|
+
try {
|
|
5352
|
+
const auth = await requireAuth(request);
|
|
5353
|
+
if (auth.error)
|
|
5354
|
+
return auth.error;
|
|
5355
|
+
const targetCollection = params.slug;
|
|
5356
|
+
const scopeErr = requireCollectionScope(auth.session, targetCollection, 'create');
|
|
5357
|
+
if (scopeErr)
|
|
5358
|
+
return scopeErr;
|
|
5359
|
+
if (!(await checkRateLimitAsync(aiGenerateLimiter, `ai-gen:${auth.session.userId}`))) {
|
|
5360
|
+
return errorResponse('AI generation rate limit reached. Try again in an hour.', 429);
|
|
5361
|
+
}
|
|
5362
|
+
const body = (await request.json());
|
|
5363
|
+
if (!body.prompt || typeof body.prompt !== 'string') {
|
|
5364
|
+
return errorResponse('prompt is required', 400);
|
|
5365
|
+
}
|
|
5366
|
+
if (body.prompt.length > AI_PROMPT_MAX_CHARS) {
|
|
5367
|
+
return errorResponse(`prompt exceeds ${AI_PROMPT_MAX_CHARS} character limit`, 400);
|
|
5368
|
+
}
|
|
5369
|
+
if (body.context && body.context.length > AI_CONTEXT_MAX_CHARS) {
|
|
5370
|
+
return errorResponse(`context exceeds ${AI_CONTEXT_MAX_CHARS} character limit`, 400);
|
|
5371
|
+
}
|
|
5372
|
+
const cfg = getActuateConfig();
|
|
5373
|
+
const collection = cfg?.collections?.[targetCollection];
|
|
5374
|
+
if (!collection) {
|
|
5375
|
+
return errorResponse(`Collection "${targetCollection}" not found`, 404);
|
|
5376
|
+
}
|
|
5377
|
+
// Refuse page-builder-driven collections — those have a layout tree
|
|
5378
|
+
// (not flat field data) and should use `/page-builder/create` instead.
|
|
5379
|
+
const hasLayoutField = Object.values(collection.fields).some((f) => f.type === 'blocks');
|
|
5380
|
+
if (hasLayoutField) {
|
|
5381
|
+
return errorResponse(`Collection "${targetCollection}" uses the page builder. Use POST /api/cms/page-builder/create instead.`, 400);
|
|
5382
|
+
}
|
|
5383
|
+
let generateDocumentContent = null;
|
|
5384
|
+
try {
|
|
5385
|
+
const aiModule = await importAIPlugin();
|
|
5386
|
+
generateDocumentContent = aiModule.generateDocumentContent;
|
|
5387
|
+
}
|
|
5388
|
+
catch {
|
|
5389
|
+
return errorResponse('AI plugin is not installed. Install @actuate-media/plugin-ai to use content authoring.', 501);
|
|
5390
|
+
}
|
|
5391
|
+
if (!generateDocumentContent) {
|
|
5392
|
+
return errorResponse('AI plugin missing `generateDocumentContent`. Upgrade @actuate-media/plugin-ai to >= 0.2.0.', 501);
|
|
5393
|
+
}
|
|
5394
|
+
const result = await generateDocumentContent({
|
|
5395
|
+
prompt: body.prompt,
|
|
5396
|
+
collection: {
|
|
5397
|
+
slug: collection.slug,
|
|
5398
|
+
labels: collection.labels,
|
|
5399
|
+
type: collection.type,
|
|
5400
|
+
fields: collection.fields,
|
|
5401
|
+
seo: collection.seo,
|
|
5402
|
+
},
|
|
5403
|
+
context: { existingContent: body.context, targetAudience: body.targetAudience },
|
|
5404
|
+
tone: body.tone,
|
|
5405
|
+
...(typeof body.maxTokens === 'number' ? { maxTokens: body.maxTokens } : {}),
|
|
5406
|
+
...(typeof body.temperature === 'number' ? { temperature: body.temperature } : {}),
|
|
5407
|
+
});
|
|
5408
|
+
const shouldPublish = body.status === 'PUBLISHED' || body.publish === true;
|
|
5409
|
+
const finalTitle = body.title ?? result.title;
|
|
5410
|
+
const finalSlug = body.slug ?? result.slug;
|
|
5411
|
+
// Spread AI data FIRST so explicit caller overrides (title, slug) win.
|
|
5412
|
+
// We don't use `pageSettings` here — that's a page-builder-specific
|
|
5413
|
+
// wire shape. For non-page collections SEO copy lives on regular
|
|
5414
|
+
// fields (metaTitle / metaDescription) declared via the seoFields
|
|
5415
|
+
// preset, and the AI generator already populates those inside
|
|
5416
|
+
// result.data when they exist on the schema.
|
|
5417
|
+
const docPayload = {
|
|
5418
|
+
...result.data,
|
|
5419
|
+
title: finalTitle,
|
|
5420
|
+
slug: finalSlug,
|
|
5421
|
+
};
|
|
5422
|
+
const ctx = buildActionContext(auth.session, db());
|
|
5423
|
+
let doc = await createDocument(targetCollection, docPayload, ctx);
|
|
5424
|
+
// Optional publish: createDocument intentionally always returns DRAFT
|
|
5425
|
+
// (only EDITOR+ can publish). If the caller asked for an immediate
|
|
5426
|
+
// publish we do a follow-up updateDocument that runs through the
|
|
5427
|
+
// standard publish access check. Callers without `update` scope on the
|
|
5428
|
+
// collection will get a 403 here even though the create succeeded —
|
|
5429
|
+
// they end up with a draft, which is the safest fallback.
|
|
5430
|
+
if (shouldPublish && doc?.id) {
|
|
5431
|
+
try {
|
|
5432
|
+
doc = await updateDocument(targetCollection, doc.id, { status: 'PUBLISHED' }, ctx);
|
|
5433
|
+
}
|
|
5434
|
+
catch (publishErr) {
|
|
5435
|
+
// Surface a 207-style partial success: the draft was created but
|
|
5436
|
+
// publish was denied. We return 201 with a `warning` field so the
|
|
5437
|
+
// agent knows the doc landed but needs human publishing.
|
|
5438
|
+
return json({
|
|
5439
|
+
data: {
|
|
5440
|
+
document: doc,
|
|
5441
|
+
generation: {
|
|
5442
|
+
usage: result.usage,
|
|
5443
|
+
durationMs: result.durationMs,
|
|
5444
|
+
seo: result.seo,
|
|
5445
|
+
},
|
|
5446
|
+
},
|
|
5447
|
+
warning: `Document created as DRAFT — publish failed: ${publishErr instanceof Error ? publishErr.message : 'unknown'}`,
|
|
5448
|
+
}, 201);
|
|
5449
|
+
}
|
|
5450
|
+
}
|
|
5451
|
+
await logEvent({
|
|
5452
|
+
event: 'settings_changed',
|
|
5453
|
+
userId: auth.session.userId,
|
|
5454
|
+
details: {
|
|
5455
|
+
action: 'collection_ai_create',
|
|
5456
|
+
collection: targetCollection,
|
|
5457
|
+
documentId: doc?.id,
|
|
5458
|
+
prompt: redactSecrets(body.prompt).slice(0, 500),
|
|
5459
|
+
tokensUsed: result.usage,
|
|
5460
|
+
durationMs: result.durationMs,
|
|
5461
|
+
},
|
|
5462
|
+
});
|
|
5463
|
+
return json({
|
|
5464
|
+
data: {
|
|
5465
|
+
document: doc,
|
|
5466
|
+
generation: {
|
|
5467
|
+
usage: result.usage,
|
|
5468
|
+
durationMs: result.durationMs,
|
|
5469
|
+
seo: result.seo,
|
|
5470
|
+
},
|
|
5471
|
+
},
|
|
5472
|
+
}, 201);
|
|
5473
|
+
}
|
|
5474
|
+
catch (err) {
|
|
5475
|
+
return internalError(err, 'collections/:slug/ai-create');
|
|
5476
|
+
}
|
|
5477
|
+
});
|
|
5304
5478
|
router.post('/page-builder/audit-a11y', async (request) => {
|
|
5305
5479
|
try {
|
|
5306
5480
|
const auth = await requireAuth(request);
|