@mvriu5/payload-ai 1.1.1 → 1.2.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.
@@ -5,8 +5,13 @@ import { isAIProvider } from "../ai/providerOptions.js";
5
5
  import { getModel, getProviderConfig } from "../ai/providerRuntime.js";
6
6
  import { containsSensitiveData } from "../ai/sensitiveData.js";
7
7
  import { isCollectionActionAllowed } from "../payload/collectionPermissions.js";
8
+ import { prepareProposalWriteData } from "../payload/proposalData.js";
8
9
  import { buildPromptWithMentionContext, collectBlocks, describeCollectionLikeConfig, describeCollectionLikeSummary, getAllowedCollectionSlugs, getMentionContext } from "../payload/schemaContext.js";
9
- import { getOptionValue, getSafeProposalLabel, hasLocalizedData, hasValueAtPath, setValueAtPath } from "../payload/shared.js";
10
+ import { getLogPreview, logHandlerEvent } from "../payload/logging.js";
11
+ import { getOptionValue, getSafeProposalLabel, hasLocalizedData, hasValueAtPath, isRecord, setValueAtPath } from "../payload/shared.js";
12
+ const nonEmptyLocalizedDataSchema = z.record(z.string(), z.record(z.string(), z.unknown())).refine((value)=>Object.keys(value).length > 0, {
13
+ message: "localizedData must include at least one locale entry."
14
+ });
10
15
  const e2eModeEnabled = ()=>process.env.PAYLOAD_AI_E2E_MODE === "true";
11
16
  const createSSEEventStream = (events)=>{
12
17
  const encoder = new TextEncoder();
@@ -19,6 +24,26 @@ const createSSEEventStream = (events)=>{
19
24
  }
20
25
  });
21
26
  };
27
+ const getChatCompletionReason = ({ proposalCount, toolFailures, writeIntent })=>{
28
+ if (proposalCount > 0) return "proposal_created";
29
+ if (toolFailures.length > 0) return "tool_validation_failed";
30
+ if (writeIntent) return "write_intent_without_tool_call";
31
+ return "model_did_not_call_tool";
32
+ };
33
+ const createDebugPayload = ({ activeLocale, debug, proposalCount, selectedLocales, toolFailures, usage, writeIntent })=>({
34
+ activeLocale,
35
+ model: debug.model,
36
+ proposalCount,
37
+ provider: debug.provider,
38
+ reason: getChatCompletionReason({
39
+ proposalCount,
40
+ toolFailures,
41
+ writeIntent
42
+ }),
43
+ selectedLocales,
44
+ toolFailures,
45
+ usage
46
+ });
22
47
  const createE2EChatResponse = ({ prompt, selectedLocales })=>{
23
48
  const normalizedPrompt = prompt.toLowerCase();
24
49
  const wantsCreatePost = normalizedPrompt.includes("post") && (normalizedPrompt.includes("create") || normalizedPrompt.includes("erstell"));
@@ -56,6 +81,22 @@ const createE2EChatResponse = ({ prompt, selectedLocales })=>{
56
81
  } : {}
57
82
  }) : null;
58
83
  const responseText = proposal ? "Prepared one draft post proposal." : "No content change was proposed.";
84
+ const debugPayload = createDebugPayload({
85
+ debug: {
86
+ model: "e2e-model",
87
+ provider: "openai",
88
+ tools: []
89
+ },
90
+ proposalCount: proposal ? 1 : 0,
91
+ selectedLocales,
92
+ toolFailures: [],
93
+ usage: {
94
+ inputTokens: 42,
95
+ outputTokens: 27,
96
+ totalTokens: 69
97
+ },
98
+ writeIntent: wantsCreatePost
99
+ });
59
100
  return new Response(createSSEEventStream([
60
101
  {
61
102
  data: {
@@ -76,6 +117,10 @@ const createE2EChatResponse = ({ prompt, selectedLocales })=>{
76
117
  },
77
118
  event: "proposals"
78
119
  },
120
+ {
121
+ data: debugPayload,
122
+ event: "debug"
123
+ },
79
124
  {
80
125
  data: {},
81
126
  event: "done"
@@ -215,6 +260,277 @@ const getMissingCreateFields = ({ data, localizedData, requiredFields })=>{
215
260
  ];
216
261
  return requiredFields.filter((field)=>!hasValueAtPath(data, field.path)).map((field)=>field.path);
217
262
  };
263
+ const getProposalSummary = (proposal)=>({
264
+ action: proposal.action,
265
+ collection: "collection" in proposal ? proposal.collection : undefined,
266
+ hasData: "data" in proposal && Boolean(proposal.data),
267
+ hasLocalizedData: "localizedData" in proposal && Boolean(proposal.localizedData),
268
+ id: "id" in proposal ? proposal.id : undefined,
269
+ label: proposal.label,
270
+ locale: proposal.locale,
271
+ locales: "localizedData" in proposal && proposal.localizedData ? Object.keys(proposal.localizedData) : undefined,
272
+ slug: "slug" in proposal ? proposal.slug : undefined
273
+ });
274
+ const getCollectionBlockTypes = (fields)=>{
275
+ const blockTypes = new Set();
276
+ const visitFields = (items)=>{
277
+ for (const field of items){
278
+ if (field.type === "blocks" && field.blocks) {
279
+ for (const block of field.blocks){
280
+ blockTypes.add(block.slug);
281
+ if (block.fields?.length) {
282
+ visitFields(block.fields);
283
+ }
284
+ }
285
+ }
286
+ if (field.fields?.length) {
287
+ visitFields(field.fields);
288
+ }
289
+ }
290
+ };
291
+ visitFields(fields);
292
+ return [
293
+ ...blockTypes
294
+ ];
295
+ };
296
+ const getRequestedBlockTypes = ({ availableBlockTypes, mentions, prompt })=>{
297
+ const requestedBlockTypes = new Set();
298
+ const normalizedPrompt = prompt.toLowerCase();
299
+ for (const mention of mentions || []){
300
+ if (mention.type === "block" && mention.slug && availableBlockTypes.includes(mention.slug)) {
301
+ requestedBlockTypes.add(mention.slug);
302
+ }
303
+ }
304
+ for (const blockType of availableBlockTypes){
305
+ const escapedBlockType = blockType.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
306
+ const blockPattern = new RegExp(`\\b${escapedBlockType}\\b(?:\\s+block)?`, "i");
307
+ if (blockPattern.test(normalizedPrompt)) {
308
+ requestedBlockTypes.add(blockType);
309
+ }
310
+ }
311
+ return [
312
+ ...requestedBlockTypes
313
+ ];
314
+ };
315
+ const collectProposalBlockTypes = ({ data, fields })=>{
316
+ const foundBlockTypes = new Set();
317
+ const visitFields = (items, value)=>{
318
+ for (const field of items){
319
+ if (!field.name) continue;
320
+ const fieldValue = value[field.name];
321
+ if (fieldValue === undefined || fieldValue === null) continue;
322
+ if (field.type === "blocks" && Array.isArray(fieldValue)) {
323
+ for (const blockItem of fieldValue){
324
+ if (!isRecord(blockItem)) continue;
325
+ const blockType = typeof blockItem.blockType === "string" ? blockItem.blockType : typeof blockItem.type === "string" ? blockItem.type : typeof blockItem.slug === "string" ? blockItem.slug : null;
326
+ if (blockType) {
327
+ foundBlockTypes.add(blockType);
328
+ }
329
+ if (blockType) {
330
+ const blockConfig = field.blocks?.find((candidate)=>candidate.slug === blockType);
331
+ if (blockConfig?.fields?.length) {
332
+ visitFields(blockConfig.fields, blockItem);
333
+ }
334
+ }
335
+ }
336
+ continue;
337
+ }
338
+ if (field.type === "group" && isRecord(fieldValue) && field.fields?.length) {
339
+ visitFields(field.fields, fieldValue);
340
+ continue;
341
+ }
342
+ if (field.type === "array" && Array.isArray(fieldValue) && field.fields?.length) {
343
+ for (const item of fieldValue){
344
+ if (isRecord(item)) {
345
+ visitFields(field.fields, item);
346
+ }
347
+ }
348
+ }
349
+ }
350
+ };
351
+ visitFields(fields, data);
352
+ return foundBlockTypes;
353
+ };
354
+ const collectRelationshipTargets = ({ data, fields, path = "" })=>{
355
+ const targets = [];
356
+ for (const field of fields){
357
+ if (!field.name) continue;
358
+ const fieldPath = path ? `${path}.${field.name}` : field.name;
359
+ const fieldValue = data[field.name];
360
+ if (fieldValue === undefined || fieldValue === null) continue;
361
+ if (field.type === "relationship" || field.type === "upload") {
362
+ const relationTargets = Array.isArray(field.relationTo) ? field.relationTo.filter((item)=>typeof item === "string") : typeof field.relationTo === "string" ? [
363
+ field.relationTo
364
+ ] : [];
365
+ const collectSingle = (value, itemPath)=>{
366
+ if (typeof value === "string" || typeof value === "number") {
367
+ if (relationTargets.length === 1) {
368
+ targets.push({
369
+ collection: relationTargets[0],
370
+ id: value,
371
+ path: itemPath
372
+ });
373
+ }
374
+ return;
375
+ }
376
+ if (!isRecord(value)) return;
377
+ const relationTo = typeof value.relationTo === "string" ? value.relationTo : relationTargets[0];
378
+ const id = typeof value.id === "string" || typeof value.id === "number" ? value.id : typeof value.value === "string" || typeof value.value === "number" ? value.value : undefined;
379
+ if (!relationTo || id === undefined) return;
380
+ targets.push({
381
+ collection: relationTo,
382
+ id,
383
+ path: itemPath
384
+ });
385
+ };
386
+ if (field.hasMany && Array.isArray(fieldValue)) {
387
+ fieldValue.forEach((item, index)=>collectSingle(item, `${fieldPath}.${index}`));
388
+ } else {
389
+ collectSingle(fieldValue, fieldPath);
390
+ }
391
+ continue;
392
+ }
393
+ if (field.type === "group" && isRecord(fieldValue) && field.fields?.length) {
394
+ targets.push(...collectRelationshipTargets({
395
+ data: fieldValue,
396
+ fields: field.fields,
397
+ path: fieldPath
398
+ }));
399
+ continue;
400
+ }
401
+ if (field.type === "array" && Array.isArray(fieldValue) && field.fields?.length) {
402
+ fieldValue.forEach((item, index)=>{
403
+ if (!isRecord(item)) return;
404
+ targets.push(...collectRelationshipTargets({
405
+ data: item,
406
+ fields: field.fields,
407
+ path: `${fieldPath}.${index}`
408
+ }));
409
+ });
410
+ continue;
411
+ }
412
+ if (field.type === "blocks" && Array.isArray(fieldValue) && field.blocks?.length) {
413
+ fieldValue.forEach((item, index)=>{
414
+ if (!isRecord(item)) return;
415
+ const blockType = typeof item.blockType === "string" ? item.blockType : typeof item.type === "string" ? item.type : typeof item.slug === "string" ? item.slug : null;
416
+ if (!blockType) return;
417
+ const blockConfig = field.blocks?.find((candidate)=>candidate.slug === blockType);
418
+ if (!blockConfig?.fields?.length) return;
419
+ targets.push(...collectRelationshipTargets({
420
+ data: item,
421
+ fields: blockConfig.fields,
422
+ path: `${fieldPath}.${index}`
423
+ }));
424
+ });
425
+ }
426
+ }
427
+ return targets;
428
+ };
429
+ const validateRelationshipTargetsExist = async ({ data, fields, req })=>{
430
+ const targets = collectRelationshipTargets({
431
+ data,
432
+ fields
433
+ });
434
+ const invalidTargets = [];
435
+ for (const target of targets){
436
+ try {
437
+ await req.payload.findByID({
438
+ collection: target.collection,
439
+ depth: 0,
440
+ id: String(target.id),
441
+ overrideAccess: false,
442
+ req
443
+ });
444
+ } catch {
445
+ invalidTargets.push(target);
446
+ }
447
+ }
448
+ return invalidTargets;
449
+ };
450
+ const getMentionSummary = (mentions)=>mentions?.map((mention)=>({
451
+ collection: "collection" in mention ? mention.collection : undefined,
452
+ id: "id" in mention ? mention.id : undefined,
453
+ slug: mention.slug,
454
+ type: mention.type
455
+ })) || [];
456
+ const formatProposalIssuesForRetry = (issues)=>{
457
+ return issues.slice(0, 6).map((issue)=>{
458
+ switch(issue.code){
459
+ case "invalid_block_type":
460
+ return `${issue.path}: use an exact blockType from the schema`;
461
+ case "invalid_blocks":
462
+ return `${issue.path}: blocks fields must be arrays of objects with blockType and exact field names`;
463
+ case "invalid_array":
464
+ return `${issue.path}: array fields must be arrays of complete objects`;
465
+ case "missing_required_field":
466
+ return `${issue.path}: required field missing`;
467
+ case "unknown_field":
468
+ return `${issue.path}: unknown field, use exact schema field names only`;
469
+ case "invalid_relationship":
470
+ return `${issue.path}: use relationship IDs or { relationTo, value }, not free text`;
471
+ default:
472
+ return `${issue.path}: ${issue.message}`;
473
+ }
474
+ }).join("; ");
475
+ };
476
+ const createCollectionAliasMap = (collections)=>{
477
+ const aliasMap = new Map();
478
+ const addAlias = (alias, slug)=>{
479
+ const normalizedAlias = alias?.trim().toLowerCase();
480
+ if (!normalizedAlias) return;
481
+ if (!aliasMap.has(normalizedAlias)) {
482
+ aliasMap.set(normalizedAlias, slug);
483
+ }
484
+ };
485
+ for (const collection of collections){
486
+ addAlias(collection.slug, collection.slug);
487
+ addAlias(collection.slug.replace(/-/g, " "), collection.slug);
488
+ const singular = typeof collection.labels?.singular === "string" ? collection.labels.singular : undefined;
489
+ const plural = typeof collection.labels?.plural === "string" ? collection.labels.plural : undefined;
490
+ addAlias(singular, collection.slug);
491
+ addAlias(plural, collection.slug);
492
+ if (singular?.endsWith("s")) {
493
+ addAlias(singular.slice(0, -1), collection.slug);
494
+ }
495
+ if (plural?.endsWith("s")) {
496
+ addAlias(plural.slice(0, -1), collection.slug);
497
+ }
498
+ }
499
+ return Object.fromEntries(aliasMap.entries());
500
+ };
501
+ const getLikelyCollectionMatches = ({ aliasMap, prompt })=>{
502
+ const normalizedPrompt = prompt.toLowerCase();
503
+ const matches = Object.entries(aliasMap).filter(([alias])=>normalizedPrompt.includes(alias)).map(([, slug])=>slug);
504
+ return [
505
+ ...new Set(matches)
506
+ ];
507
+ };
508
+ const hasWriteIntent = (prompt)=>{
509
+ const normalizedPrompt = prompt.toLowerCase();
510
+ return /\b(create|build|generate|write|draft|make|add|update|edit|change|revise|refine|rewrite|translate|delete|remove)\b/.test(normalizedPrompt);
511
+ };
512
+ const getIntentToolChoice = (prompt)=>{
513
+ const normalizedPrompt = prompt.toLowerCase();
514
+ if (/\b(delete|remove)\b/.test(normalizedPrompt)) {
515
+ return {
516
+ toolName: "proposeDeleteDoc",
517
+ type: "tool"
518
+ };
519
+ }
520
+ if (/\b(create|build|generate|write|draft|make|add)\b/.test(normalizedPrompt)) {
521
+ return {
522
+ toolName: "proposeCreateDoc",
523
+ type: "tool"
524
+ };
525
+ }
526
+ if (/\b(update|edit|change|revise|refine|rewrite|translate)\b/.test(normalizedPrompt)) {
527
+ return {
528
+ toolName: "proposeUpdateDoc",
529
+ type: "tool"
530
+ };
531
+ }
532
+ return undefined;
533
+ };
218
534
  export const createChatHandler = (options = {})=>async (req)=>{
219
535
  if (!req.user) return Response.json({
220
536
  error: "Unauthorized"
@@ -230,6 +546,7 @@ export const createChatHandler = (options = {})=>async (req)=>{
230
546
  });
231
547
  const selectedLocales = (body?.mentions?.filter((mention)=>mention.type === "locale" && mention.slug).map((mention)=>mention.slug) || []).filter((locale, index, array)=>array.indexOf(locale) === index);
232
548
  const activeLocale = selectedLocales.at(-1);
549
+ const mentionSummary = getMentionSummary(body?.mentions);
233
550
  if (e2eModeEnabled()) {
234
551
  return createE2EChatResponse({
235
552
  prompt,
@@ -266,7 +583,23 @@ export const createChatHandler = (options = {})=>async (req)=>{
266
583
  "searchDocs"
267
584
  ]
268
585
  };
586
+ logHandlerEvent(req, "info", {
587
+ activeLocale,
588
+ debug,
589
+ mentionCount: mentionSummary.length,
590
+ mentions: mentionSummary,
591
+ msg: "AI chat started",
592
+ promptPreview: getLogPreview(prompt),
593
+ selectedLocales
594
+ });
269
595
  if (!providerConfig.apiKey) {
596
+ logHandlerEvent(req, "warn", {
597
+ activeLocale,
598
+ debug,
599
+ msg: "AI chat blocked: missing provider API key",
600
+ promptPreview: getLogPreview(prompt),
601
+ selectedLocales
602
+ });
270
603
  return Response.json({
271
604
  error: options.allowUserApiKeys === false ? `Configure a ${provider} API key in the server environment first.` : `Add a ${provider} API key to your account settings or configure it in the server environment first.`
272
605
  }, {
@@ -275,25 +608,60 @@ export const createChatHandler = (options = {})=>async (req)=>{
275
608
  }
276
609
  try {
277
610
  const proposals = [];
611
+ const toolFailures = [];
612
+ const registerToolFailure = (failure)=>{
613
+ toolFailures.push(failure);
614
+ logHandlerEvent(req, "warn", {
615
+ debug,
616
+ ...failure,
617
+ msg: "AI tool validation failed",
618
+ promptPreview: getLogPreview(prompt)
619
+ });
620
+ };
621
+ const createToolError = ({ collection, details, message, slug, tool })=>{
622
+ registerToolFailure({
623
+ collection,
624
+ details,
625
+ message,
626
+ slug,
627
+ tool
628
+ });
629
+ return {
630
+ error: message
631
+ };
632
+ };
278
633
  const addSignedProposal = (proposal)=>{
279
634
  if ("data" in proposal && proposal.data && containsSensitiveData(proposal.data)) {
280
- return {
281
- error: "Proposal contains sensitive fields and cannot be created."
282
- };
635
+ return createToolError({
636
+ details: getProposalSummary(proposal),
637
+ message: "Proposal contains sensitive fields and cannot be created.",
638
+ tool: `propose${proposal.action[0]?.toUpperCase()}${proposal.action.slice(1)}`
639
+ });
283
640
  }
284
641
  if ("localizedData" in proposal && hasLocalizedData(proposal.localizedData) && Object.values(proposal.localizedData).some((value)=>containsSensitiveData(value))) {
285
- return {
286
- error: "Proposal contains sensitive fields and cannot be created."
287
- };
642
+ return createToolError({
643
+ details: getProposalSummary(proposal),
644
+ message: "Proposal contains sensitive fields and cannot be created.",
645
+ tool: `propose${proposal.action[0]?.toUpperCase()}${proposal.action.slice(1)}`
646
+ });
288
647
  }
289
648
  const signedProposal = signAIActionProposal(proposal);
290
649
  proposals.push(signedProposal);
650
+ logHandlerEvent(req, "info", {
651
+ debug,
652
+ msg: "AI proposal created",
653
+ proposal: getProposalSummary(signedProposal)
654
+ });
291
655
  return signedProposal;
292
656
  };
293
657
  const collectionSlugs = getAllowedCollectionSlugs(req, options.collections);
294
658
  const globalSlugs = req.payload.config.globals?.map((global)=>global.slug) || [];
295
659
  const allowedCollections = req.payload.config.collections.filter((collection)=>collectionSlugs.includes(collection.slug));
296
660
  if (collectionSlugs.length === 0) {
661
+ logHandlerEvent(req, "warn", {
662
+ debug,
663
+ msg: "AI chat blocked: no AI-enabled collections configured"
664
+ });
297
665
  return Response.json({
298
666
  error: "No AI-enabled collections are configured."
299
667
  }, {
@@ -347,6 +715,38 @@ export const createChatHandler = (options = {})=>async (req)=>{
347
715
  titleFieldByCollection[slug]
348
716
  ]
349
717
  ] : []));
718
+ const collectionAliasMap = createCollectionAliasMap(allowedCollections);
719
+ const likelyCollectionMatches = getLikelyCollectionMatches({
720
+ aliasMap: collectionAliasMap,
721
+ prompt
722
+ });
723
+ const writeIntent = hasWriteIntent(prompt);
724
+ const inferredCollectionSlug = mentionedCollectionSlugs.length === 1 ? mentionedCollectionSlugs[0] : mentionedCollectionSlugs.length === 0 && likelyCollectionMatches.length === 1 ? likelyCollectionMatches[0] : undefined;
725
+ const inferredCollectionConfig = inferredCollectionSlug ? allowedCollections.find((collection)=>collection.slug === inferredCollectionSlug) : undefined;
726
+ if (inferredCollectionConfig && !mentionContext.some((item)=>item.type === "collection" && item.slug === inferredCollectionConfig.slug)) {
727
+ mentionContext.push({
728
+ ...describeCollectionLikeConfig({
729
+ config: inferredCollectionConfig,
730
+ permissions: options.collections,
731
+ type: "collection"
732
+ }),
733
+ inferredFromPrompt: true
734
+ });
735
+ }
736
+ const intentToolChoice = inferredCollectionConfig ? getIntentToolChoice(prompt) : undefined;
737
+ logHandlerEvent(req, "info", {
738
+ activeLocale,
739
+ allowedCollectionCount: allowedCollections.length,
740
+ collectionSlugs,
741
+ focusedCollections: mentionedCollectionSlugs,
742
+ globalSlugs,
743
+ inferredCollectionSlug,
744
+ intentToolChoice,
745
+ likelyCollectionMatches,
746
+ msg: "AI chat context prepared",
747
+ selectedLocales,
748
+ writeIntent
749
+ });
350
750
  const collectionSlugSchema = z.enum(collectionSlugs);
351
751
  const getDisallowedCollectionActionError = (collection, action)=>{
352
752
  if (isCollectionActionAllowed({
@@ -355,9 +755,11 @@ export const createChatHandler = (options = {})=>async (req)=>{
355
755
  req,
356
756
  slug: collection
357
757
  })) return null;
358
- return {
359
- error: `${action} is not enabled for collection: ${collection}`
360
- };
758
+ return createToolError({
759
+ collection,
760
+ message: `${action} is not enabled for collection: ${collection}`,
761
+ tool: "collectionPermissionCheck"
762
+ });
361
763
  };
362
764
  const tools = {
363
765
  getDoc: {
@@ -387,9 +789,13 @@ export const createChatHandler = (options = {})=>async (req)=>{
387
789
  execute: async ({ slug })=>{
388
790
  if (slug) {
389
791
  const collection = allowedCollections.find((item)=>item.slug === slug);
390
- if (!collection) return {
391
- error: `Unknown collection: ${slug}`
392
- };
792
+ if (!collection) {
793
+ return createToolError({
794
+ message: `Unknown collection: ${slug}`,
795
+ slug,
796
+ tool: "listCollections"
797
+ });
798
+ }
393
799
  return describeCollectionLikeConfig({
394
800
  config: collection,
395
801
  permissions: options.collections,
@@ -410,9 +816,13 @@ export const createChatHandler = (options = {})=>async (req)=>{
410
816
  }),
411
817
  execute: async ({ slug })=>{
412
818
  const globalConfig = req.payload.config.globals?.find((global)=>global.slug === slug);
413
- if (!globalConfig) return {
414
- error: `Unknown global: ${slug}`
415
- };
819
+ if (!globalConfig) {
820
+ return createToolError({
821
+ message: `Unknown global: ${slug}`,
822
+ slug,
823
+ tool: "getGlobal"
824
+ });
825
+ }
416
826
  return req.payload.findGlobal({
417
827
  depth: 2,
418
828
  ...activeLocale ? {
@@ -433,9 +843,13 @@ export const createChatHandler = (options = {})=>async (req)=>{
433
843
  const globals = req.payload.config.globals || [];
434
844
  if (slug) {
435
845
  const global = globals.find((item)=>item.slug === slug);
436
- if (!global) return {
437
- error: `Unknown global: ${slug}`
438
- };
846
+ if (!global) {
847
+ return createToolError({
848
+ message: `Unknown global: ${slug}`,
849
+ slug,
850
+ tool: "listGlobals"
851
+ });
852
+ }
439
853
  return describeCollectionLikeConfig({
440
854
  config: global,
441
855
  type: "global"
@@ -453,43 +867,108 @@ export const createChatHandler = (options = {})=>async (req)=>{
453
867
  collection: collectionSlugSchema,
454
868
  data: z.record(z.string(), z.unknown()).optional(),
455
869
  label: z.string().min(1),
456
- localizedData: z.record(z.string(), z.record(z.string(), z.unknown())).optional()
870
+ localizedData: nonEmptyLocalizedDataSchema.optional()
457
871
  }).refine((value)=>Boolean(value.data || value.localizedData), {
458
872
  message: "Either data or localizedData is required."
459
873
  }),
460
874
  execute: async ({ collection, data, label, localizedData })=>{
461
875
  const permissionError = getDisallowedCollectionActionError(collection, "create");
462
876
  if (permissionError) return permissionError;
463
- const completedCreatePayload = fillMissingCreateFields({
877
+ const collectionConfig = allowedCollections.find((item)=>item.slug === collection);
878
+ const collectionFields = collectionConfig?.fields || [];
879
+ const preparedData = prepareProposalWriteData({
880
+ collectionConfig: collectionConfig,
464
881
  data,
882
+ inferenceText: prompt,
465
883
  label,
466
884
  localizedData,
467
- requiredFields: createRequiredFieldsByCollection[collection] || []
468
- });
469
- const missingFields = getMissingCreateFields({
470
- data: completedCreatePayload.data,
471
- localizedData: completedCreatePayload.localizedData,
472
- requiredFields: createRequiredFieldsByCollection[collection] || []
885
+ mode: "create"
473
886
  });
474
- if (missingFields.length > 0) {
887
+ if (preparedData.issues.length > 0) {
475
888
  const titleFieldName = titleFieldByCollection[collection];
476
- const missingTitleField = titleFieldName ? missingFields.some((field)=>field === titleFieldName || field.endsWith(`:${titleFieldName}`)) : false;
477
- return {
478
- error: missingTitleField ? `Create proposal is missing the required title field "${titleFieldName}" for ${collection}. Infer a concise title from the user request and retry.` : `Create proposal is missing required fields for ${collection}: ${missingFields.join(", ")}`
479
- };
889
+ const missingTitleField = titleFieldName ? preparedData.issues.some((issue)=>issue.path === titleFieldName || issue.path.endsWith(`.${titleFieldName}`)) : false;
890
+ return createToolError({
891
+ collection,
892
+ details: {
893
+ issues: preparedData.issues,
894
+ titleFieldName
895
+ },
896
+ message: missingTitleField ? `Create proposal is missing the required title field "${titleFieldName}" for ${collection}. Infer a concise title from the user request and retry.` : `Create proposal for ${collection} is invalid. Retry with exact schema fields and complete array/block objects: ${formatProposalIssuesForRetry(preparedData.issues)}`,
897
+ tool: "proposeCreateDoc"
898
+ });
899
+ }
900
+ const requestedBlockTypes = getRequestedBlockTypes({
901
+ availableBlockTypes: getCollectionBlockTypes(collectionFields),
902
+ mentions: body?.mentions,
903
+ prompt
904
+ });
905
+ if (requestedBlockTypes.length > 0) {
906
+ const proposalBlockTypes = new Set();
907
+ if (preparedData.data) {
908
+ for (const blockType of collectProposalBlockTypes({
909
+ data: preparedData.data,
910
+ fields: collectionFields
911
+ })){
912
+ proposalBlockTypes.add(blockType);
913
+ }
914
+ }
915
+ if (preparedData.localizedData) {
916
+ for (const localeData of Object.values(preparedData.localizedData)){
917
+ for (const blockType of collectProposalBlockTypes({
918
+ data: localeData,
919
+ fields: collectionFields
920
+ })){
921
+ proposalBlockTypes.add(blockType);
922
+ }
923
+ }
924
+ }
925
+ const missingBlockTypes = requestedBlockTypes.filter((blockType)=>!proposalBlockTypes.has(blockType));
926
+ if (missingBlockTypes.length > 0) {
927
+ return createToolError({
928
+ collection,
929
+ details: {
930
+ missingBlockTypes,
931
+ requestedBlockTypes
932
+ },
933
+ message: `Create proposal for ${collection} is missing required block types from the request: ${missingBlockTypes.join(", ")}. Add them to the appropriate blocks field using exact blockType values and complete required fields.`,
934
+ tool: "proposeCreateDoc"
935
+ });
936
+ }
937
+ }
938
+ const invalidRelationshipTargets = [
939
+ ...preparedData.data ? await validateRelationshipTargetsExist({
940
+ data: preparedData.data,
941
+ fields: collectionFields,
942
+ req
943
+ }) : [],
944
+ ...preparedData.localizedData ? (await Promise.all(Object.values(preparedData.localizedData).map((localeData)=>validateRelationshipTargetsExist({
945
+ data: localeData,
946
+ fields: collectionFields,
947
+ req
948
+ })))).flat() : []
949
+ ];
950
+ if (invalidRelationshipTargets.length > 0) {
951
+ return createToolError({
952
+ collection,
953
+ details: {
954
+ invalidRelationshipTargets
955
+ },
956
+ message: `Create proposal for ${collection} contains relationship or upload references that do not exist: ${invalidRelationshipTargets.map((target)=>`${target.path} -> ${target.collection}:${target.id}`).join(", ")}.`,
957
+ tool: "proposeCreateDoc"
958
+ });
480
959
  }
481
- const proposal = completedCreatePayload.localizedData ? {
960
+ const proposal = preparedData.localizedData ? {
482
961
  action: "create",
483
962
  collection,
484
963
  label: getSafeProposalLabel(label),
485
- localizedData: completedCreatePayload.localizedData,
964
+ localizedData: preparedData.localizedData,
486
965
  ...activeLocale ? {
487
966
  locale: activeLocale
488
967
  } : {}
489
968
  } : {
490
969
  action: "create",
491
970
  collection,
492
- data: completedCreatePayload.data || {},
971
+ data: preparedData.data || {},
493
972
  label: getSafeProposalLabel(label),
494
973
  ...activeLocale ? {
495
974
  locale: activeLocale
@@ -527,20 +1006,62 @@ export const createChatHandler = (options = {})=>async (req)=>{
527
1006
  data: z.record(z.string(), z.unknown()).optional(),
528
1007
  id: z.string().min(1),
529
1008
  label: z.string().min(1),
530
- localizedData: z.record(z.string(), z.record(z.string(), z.unknown())).optional()
1009
+ localizedData: nonEmptyLocalizedDataSchema.optional()
531
1010
  }).refine((value)=>Boolean(value.data || value.localizedData), {
532
1011
  message: "Either data or localizedData is required."
533
1012
  }),
534
1013
  execute: async ({ collection, data, id, label, localizedData })=>{
535
1014
  const permissionError = getDisallowedCollectionActionError(collection, "update");
536
1015
  if (permissionError) return permissionError;
1016
+ const collectionConfig = allowedCollections.find((item)=>item.slug === collection);
1017
+ const collectionFields = collectionConfig?.fields || [];
1018
+ const preparedData = prepareProposalWriteData({
1019
+ collectionConfig: collectionConfig,
1020
+ data,
1021
+ inferenceText: prompt,
1022
+ label,
1023
+ localizedData,
1024
+ mode: "update"
1025
+ });
1026
+ if (preparedData.issues.length > 0) {
1027
+ return createToolError({
1028
+ collection,
1029
+ details: {
1030
+ issues: preparedData.issues
1031
+ },
1032
+ message: `Update proposal for ${collection} is invalid. Retry with exact schema fields and complete array/block objects: ${formatProposalIssuesForRetry(preparedData.issues)}`,
1033
+ tool: "proposeUpdateDoc"
1034
+ });
1035
+ }
1036
+ const invalidRelationshipTargets = [
1037
+ ...preparedData.data ? await validateRelationshipTargetsExist({
1038
+ data: preparedData.data,
1039
+ fields: collectionFields,
1040
+ req
1041
+ }) : [],
1042
+ ...preparedData.localizedData ? (await Promise.all(Object.values(preparedData.localizedData).map((localeData)=>validateRelationshipTargetsExist({
1043
+ data: localeData,
1044
+ fields: collectionFields,
1045
+ req
1046
+ })))).flat() : []
1047
+ ];
1048
+ if (invalidRelationshipTargets.length > 0) {
1049
+ return createToolError({
1050
+ collection,
1051
+ details: {
1052
+ invalidRelationshipTargets
1053
+ },
1054
+ message: `Update proposal for ${collection} contains relationship or upload references that do not exist: ${invalidRelationshipTargets.map((target)=>`${target.path} -> ${target.collection}:${target.id}`).join(", ")}.`,
1055
+ tool: "proposeUpdateDoc"
1056
+ });
1057
+ }
537
1058
  const proposal = {
538
1059
  action: "update",
539
1060
  collection,
540
- ...localizedData ? {
541
- localizedData
1061
+ ...preparedData.localizedData ? {
1062
+ localizedData: preparedData.localizedData
542
1063
  } : {
543
- data: data || {}
1064
+ data: preparedData.data || {}
544
1065
  },
545
1066
  id,
546
1067
  label: getSafeProposalLabel(label),
@@ -556,22 +1077,47 @@ export const createChatHandler = (options = {})=>async (req)=>{
556
1077
  inputSchema: z.object({
557
1078
  data: z.record(z.string(), z.unknown()).optional(),
558
1079
  label: z.string().min(1),
559
- localizedData: z.record(z.string(), z.record(z.string(), z.unknown())).optional(),
1080
+ localizedData: nonEmptyLocalizedDataSchema.optional(),
560
1081
  slug: z.string().min(1)
561
1082
  }).refine((value)=>Boolean(value.data || value.localizedData), {
562
1083
  message: "Either data or localizedData is required."
563
1084
  }),
564
1085
  execute: async ({ data, label, localizedData, slug })=>{
565
1086
  const globalConfig = req.payload.config.globals?.find((global)=>global.slug === slug);
566
- if (!globalConfig) return {
567
- error: `Unknown global: ${slug}`
568
- };
1087
+ if (!globalConfig) {
1088
+ return createToolError({
1089
+ message: `Unknown global: ${slug}`,
1090
+ slug,
1091
+ tool: "proposeUpdateGlobal"
1092
+ });
1093
+ }
1094
+ const preparedData = prepareProposalWriteData({
1095
+ collectionConfig: {
1096
+ fields: globalConfig.fields || [],
1097
+ slug: globalConfig.slug
1098
+ },
1099
+ data,
1100
+ inferenceText: prompt,
1101
+ label,
1102
+ localizedData,
1103
+ mode: "update"
1104
+ });
1105
+ if (preparedData.issues.length > 0) {
1106
+ return createToolError({
1107
+ details: {
1108
+ issues: preparedData.issues
1109
+ },
1110
+ message: `Update proposal for global ${slug} is invalid. Retry with exact schema fields and complete array/block objects: ${formatProposalIssuesForRetry(preparedData.issues)}`,
1111
+ slug,
1112
+ tool: "proposeUpdateGlobal"
1113
+ });
1114
+ }
569
1115
  const proposal = {
570
1116
  action: "updateGlobal",
571
- ...localizedData ? {
572
- localizedData
1117
+ ...preparedData.localizedData ? {
1118
+ localizedData: preparedData.localizedData
573
1119
  } : {
574
- data: data || {}
1120
+ data: preparedData.data || {}
575
1121
  },
576
1122
  label: getSafeProposalLabel(label),
577
1123
  ...activeLocale ? {
@@ -638,12 +1184,22 @@ export const createChatHandler = (options = {})=>async (req)=>{
638
1184
  "You are a Payload CMS assistant. Inspect schema/content with tools before proposing writes.",
639
1185
  "Mentions define the active CMS scope. Locale mentions define active locale; multiple locales require localizedData keyed by locale.",
640
1186
  "Writes are proposals only. Never claim changes were applied before user confirmation.",
641
- "For create/update/delete use proposal tools. Put concrete field values only in tool data, not visible text.",
642
- "If schema details are missing, call listCollections/listGlobals with a slug before proposing.",
1187
+ "For create/update/delete requests, you must use proposal tools. Do not end with plain text if the user asked for a content change.",
1188
+ "If the user asks to create, update, refine, translate, remove, or delete content, produce at least one proposal tool call unless blocked by missing schema information or permissions.",
1189
+ "Put concrete field values only in tool data, not visible text.",
1190
+ "For blocks fields: use exact blockType values from schema, exact field names, and complete objects for required block fields.",
1191
+ "For arrays: every item must be an object matching the child field schema, not free text.",
1192
+ "If schema details are missing, call listCollections/listGlobals with a slug before proposing. If an inferred collection schema is already present in context, use it directly.",
1193
+ `Collection aliases: ${JSON.stringify(collectionAliasMap)}.`,
1194
+ `Likely collection matches for this prompt: ${JSON.stringify(likelyCollectionMatches)}.`,
643
1195
  `Focused required create fields: ${JSON.stringify(focusedRequiredFieldsByCollection)}.`,
644
1196
  `Focused title fields: ${JSON.stringify(focusedTitleFieldByCollection)}. Infer concise titles when needed.`,
1197
+ `Preferred proposal tool for this prompt: ${intentToolChoice?.toolName || "none"}.`,
645
1198
  "Visible response: plain text, under 40 words, no Markdown, no proposed content."
646
1199
  ].join("\n"),
1200
+ ...intentToolChoice ? {
1201
+ toolChoice: intentToolChoice
1202
+ } : {},
647
1203
  tools
648
1204
  });
649
1205
  const stream = new ReadableStream({
@@ -668,19 +1224,75 @@ export const createChatHandler = (options = {})=>async (req)=>{
668
1224
  if (part.type === "finish") {
669
1225
  const finishPart = part;
670
1226
  usage = finishPart.totalUsage || finishPart.usage || null;
1227
+ const reason = getChatCompletionReason({
1228
+ proposalCount: proposals.length,
1229
+ toolFailures,
1230
+ writeIntent
1231
+ });
1232
+ const debugPayload = createDebugPayload({
1233
+ activeLocale,
1234
+ debug,
1235
+ proposalCount: proposals.length,
1236
+ selectedLocales,
1237
+ toolFailures,
1238
+ usage,
1239
+ writeIntent
1240
+ });
1241
+ logHandlerEvent(req, proposals.length > 0 ? "info" : "warn", {
1242
+ activeLocale,
1243
+ debug,
1244
+ msg: proposals.length > 0 ? "AI chat completed with proposals" : "AI chat completed without proposals",
1245
+ proposalCount: proposals.length,
1246
+ proposals: proposals.map((proposal)=>getProposalSummary(proposal)),
1247
+ promptPreview: getLogPreview(prompt),
1248
+ reason,
1249
+ selectedLocales,
1250
+ toolFailureCount: toolFailures.length,
1251
+ toolFailures,
1252
+ usage
1253
+ });
671
1254
  sendEvent(controller, "proposals", {
672
1255
  proposals,
673
1256
  usage
674
1257
  });
1258
+ sendEvent(controller, "debug", debugPayload);
675
1259
  sendEvent(controller, "done", {});
676
1260
  didSendTerminalEvent = true;
677
1261
  }
678
1262
  }
679
1263
  if (!didSendTerminalEvent) {
1264
+ const reason = getChatCompletionReason({
1265
+ proposalCount: proposals.length,
1266
+ toolFailures,
1267
+ writeIntent
1268
+ });
1269
+ const debugPayload = createDebugPayload({
1270
+ activeLocale,
1271
+ debug,
1272
+ proposalCount: proposals.length,
1273
+ selectedLocales,
1274
+ toolFailures,
1275
+ usage,
1276
+ writeIntent
1277
+ });
1278
+ logHandlerEvent(req, proposals.length > 0 ? "info" : "warn", {
1279
+ activeLocale,
1280
+ debug,
1281
+ msg: proposals.length > 0 ? "AI chat completed with proposals" : "AI chat completed without proposals",
1282
+ proposalCount: proposals.length,
1283
+ proposals: proposals.map((proposal)=>getProposalSummary(proposal)),
1284
+ promptPreview: getLogPreview(prompt),
1285
+ reason,
1286
+ selectedLocales,
1287
+ toolFailureCount: toolFailures.length,
1288
+ toolFailures,
1289
+ usage
1290
+ });
680
1291
  sendEvent(controller, "proposals", {
681
1292
  proposals,
682
1293
  usage
683
1294
  });
1295
+ sendEvent(controller, "debug", debugPayload);
684
1296
  sendEvent(controller, "done", {});
685
1297
  }
686
1298
  } catch (err) {