@mvriu5/payload-ai 0.5.10 → 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,134 @@ 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
+ });
15
+ const e2eModeEnabled = ()=>process.env.PAYLOAD_AI_E2E_MODE === "true";
16
+ const createSSEEventStream = (events)=>{
17
+ const encoder = new TextEncoder();
18
+ return new ReadableStream({
19
+ start (controller) {
20
+ for (const { data, event } of events){
21
+ controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
22
+ }
23
+ controller.close();
24
+ }
25
+ });
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
+ });
47
+ const createE2EChatResponse = ({ prompt, selectedLocales })=>{
48
+ const normalizedPrompt = prompt.toLowerCase();
49
+ const wantsCreatePost = normalizedPrompt.includes("post") && (normalizedPrompt.includes("create") || normalizedPrompt.includes("erstell"));
50
+ const mentionsMars = normalizedPrompt.includes("mars");
51
+ const multipleLocales = selectedLocales.length > 1;
52
+ const activeLocale = selectedLocales.at(-1);
53
+ const proposalLabel = multipleLocales ? normalizedPrompt.includes("locale review") ? "Create localized locale review draft post about Mars" : "Create localized draft post about Mars" : normalizedPrompt.includes("apply flow") ? "Create apply flow draft post about Mars" : normalizedPrompt.includes("proposal review") ? "Create proposal review draft post about Mars" : "Create draft post about Mars";
54
+ const proposal = wantsCreatePost && mentionsMars ? signAIActionProposal(multipleLocales ? {
55
+ action: "create",
56
+ collection: "posts",
57
+ label: proposalLabel,
58
+ localizedData: Object.fromEntries(selectedLocales.map((locale)=>[
59
+ locale,
60
+ {
61
+ content: locale === "de" ? "Mars ist der vierte Planet von der Sonne." : "Mars is the fourth planet from the Sun.",
62
+ excerpt: locale === "de" ? "Ein kurzer Entwurf ueber Mars." : "A short draft about Mars.",
63
+ title: locale === "de" ? "Mars im Ueberblick" : "Mars Overview"
64
+ }
65
+ ])),
66
+ ...activeLocale ? {
67
+ locale: activeLocale
68
+ } : {}
69
+ } : {
70
+ action: "create",
71
+ collection: "posts",
72
+ data: {
73
+ content: "Mars is the fourth planet from the Sun.",
74
+ excerpt: "A short draft about Mars.",
75
+ status: "draft",
76
+ title: "Mars Overview"
77
+ },
78
+ label: proposalLabel,
79
+ ...activeLocale ? {
80
+ locale: activeLocale
81
+ } : {}
82
+ }) : null;
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
+ });
100
+ return new Response(createSSEEventStream([
101
+ {
102
+ data: {
103
+ delta: responseText
104
+ },
105
+ event: "text"
106
+ },
107
+ {
108
+ data: {
109
+ proposals: proposal ? [
110
+ proposal
111
+ ] : [],
112
+ usage: {
113
+ inputTokens: 42,
114
+ outputTokens: 27,
115
+ totalTokens: 69
116
+ }
117
+ },
118
+ event: "proposals"
119
+ },
120
+ {
121
+ data: debugPayload,
122
+ event: "debug"
123
+ },
124
+ {
125
+ data: {},
126
+ event: "done"
127
+ }
128
+ ]), {
129
+ headers: {
130
+ "Cache-Control": "no-cache, no-transform",
131
+ Connection: "keep-alive",
132
+ "Content-Type": "text/event-stream; charset=utf-8"
133
+ }
134
+ });
135
+ };
10
136
  const getRequiredFieldInfos = (fields, titleFieldName, path = "")=>{
11
137
  return fields.flatMap((field)=>{
12
138
  const fieldPath = field.name ? path ? `${path}.${field.name}` : field.name : path;
@@ -134,6 +260,277 @@ const getMissingCreateFields = ({ data, localizedData, requiredFields })=>{
134
260
  ];
135
261
  return requiredFields.filter((field)=>!hasValueAtPath(data, field.path)).map((field)=>field.path);
136
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
+ };
137
534
  export const createChatHandler = (options = {})=>async (req)=>{
138
535
  if (!req.user) return Response.json({
139
536
  error: "Unauthorized"
@@ -147,6 +544,15 @@ export const createChatHandler = (options = {})=>async (req)=>{
147
544
  }, {
148
545
  status: 400
149
546
  });
547
+ const selectedLocales = (body?.mentions?.filter((mention)=>mention.type === "locale" && mention.slug).map((mention)=>mention.slug) || []).filter((locale, index, array)=>array.indexOf(locale) === index);
548
+ const activeLocale = selectedLocales.at(-1);
549
+ const mentionSummary = getMentionSummary(body?.mentions);
550
+ if (e2eModeEnabled()) {
551
+ return createE2EChatResponse({
552
+ prompt,
553
+ selectedLocales
554
+ });
555
+ }
150
556
  const user = req.user;
151
557
  const requestedProvider = body?.provider || user.aiProvider || "openai";
152
558
  if (!isAIProvider(requestedProvider)) return Response.json({
@@ -177,7 +583,23 @@ export const createChatHandler = (options = {})=>async (req)=>{
177
583
  "searchDocs"
178
584
  ]
179
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
+ });
180
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
+ });
181
603
  return Response.json({
182
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.`
183
605
  }, {
@@ -186,25 +608,60 @@ export const createChatHandler = (options = {})=>async (req)=>{
186
608
  }
187
609
  try {
188
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
+ };
189
633
  const addSignedProposal = (proposal)=>{
190
634
  if ("data" in proposal && proposal.data && containsSensitiveData(proposal.data)) {
191
- return {
192
- error: "Proposal contains sensitive fields and cannot be created."
193
- };
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
+ });
194
640
  }
195
641
  if ("localizedData" in proposal && hasLocalizedData(proposal.localizedData) && Object.values(proposal.localizedData).some((value)=>containsSensitiveData(value))) {
196
- return {
197
- error: "Proposal contains sensitive fields and cannot be created."
198
- };
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
+ });
199
647
  }
200
648
  const signedProposal = signAIActionProposal(proposal);
201
649
  proposals.push(signedProposal);
650
+ logHandlerEvent(req, "info", {
651
+ debug,
652
+ msg: "AI proposal created",
653
+ proposal: getProposalSummary(signedProposal)
654
+ });
202
655
  return signedProposal;
203
656
  };
204
657
  const collectionSlugs = getAllowedCollectionSlugs(req, options.collections);
205
658
  const globalSlugs = req.payload.config.globals?.map((global)=>global.slug) || [];
206
659
  const allowedCollections = req.payload.config.collections.filter((collection)=>collectionSlugs.includes(collection.slug));
207
660
  if (collectionSlugs.length === 0) {
661
+ logHandlerEvent(req, "warn", {
662
+ debug,
663
+ msg: "AI chat blocked: no AI-enabled collections configured"
664
+ });
208
665
  return Response.json({
209
666
  error: "No AI-enabled collections are configured."
210
667
  }, {
@@ -229,8 +686,6 @@ export const createChatHandler = (options = {})=>async (req)=>{
229
686
  mentions: body?.mentions,
230
687
  req
231
688
  });
232
- const selectedLocales = (body?.mentions?.filter((mention)=>mention.type === "locale" && mention.slug).map((mention)=>mention.slug) || []).filter((locale, index, array)=>array.indexOf(locale) === index);
233
- const activeLocale = selectedLocales.at(-1);
234
689
  const mentionedCollectionSlugs = (body?.mentions?.flatMap((mention)=>{
235
690
  if (mention.type === "collection" && mention.slug) return [
236
691
  mention.slug
@@ -260,6 +715,38 @@ export const createChatHandler = (options = {})=>async (req)=>{
260
715
  titleFieldByCollection[slug]
261
716
  ]
262
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
+ });
263
750
  const collectionSlugSchema = z.enum(collectionSlugs);
264
751
  const getDisallowedCollectionActionError = (collection, action)=>{
265
752
  if (isCollectionActionAllowed({
@@ -268,9 +755,11 @@ export const createChatHandler = (options = {})=>async (req)=>{
268
755
  req,
269
756
  slug: collection
270
757
  })) return null;
271
- return {
272
- error: `${action} is not enabled for collection: ${collection}`
273
- };
758
+ return createToolError({
759
+ collection,
760
+ message: `${action} is not enabled for collection: ${collection}`,
761
+ tool: "collectionPermissionCheck"
762
+ });
274
763
  };
275
764
  const tools = {
276
765
  getDoc: {
@@ -300,9 +789,13 @@ export const createChatHandler = (options = {})=>async (req)=>{
300
789
  execute: async ({ slug })=>{
301
790
  if (slug) {
302
791
  const collection = allowedCollections.find((item)=>item.slug === slug);
303
- if (!collection) return {
304
- error: `Unknown collection: ${slug}`
305
- };
792
+ if (!collection) {
793
+ return createToolError({
794
+ message: `Unknown collection: ${slug}`,
795
+ slug,
796
+ tool: "listCollections"
797
+ });
798
+ }
306
799
  return describeCollectionLikeConfig({
307
800
  config: collection,
308
801
  permissions: options.collections,
@@ -323,9 +816,13 @@ export const createChatHandler = (options = {})=>async (req)=>{
323
816
  }),
324
817
  execute: async ({ slug })=>{
325
818
  const globalConfig = req.payload.config.globals?.find((global)=>global.slug === slug);
326
- if (!globalConfig) return {
327
- error: `Unknown global: ${slug}`
328
- };
819
+ if (!globalConfig) {
820
+ return createToolError({
821
+ message: `Unknown global: ${slug}`,
822
+ slug,
823
+ tool: "getGlobal"
824
+ });
825
+ }
329
826
  return req.payload.findGlobal({
330
827
  depth: 2,
331
828
  ...activeLocale ? {
@@ -346,9 +843,13 @@ export const createChatHandler = (options = {})=>async (req)=>{
346
843
  const globals = req.payload.config.globals || [];
347
844
  if (slug) {
348
845
  const global = globals.find((item)=>item.slug === slug);
349
- if (!global) return {
350
- error: `Unknown global: ${slug}`
351
- };
846
+ if (!global) {
847
+ return createToolError({
848
+ message: `Unknown global: ${slug}`,
849
+ slug,
850
+ tool: "listGlobals"
851
+ });
852
+ }
352
853
  return describeCollectionLikeConfig({
353
854
  config: global,
354
855
  type: "global"
@@ -366,43 +867,108 @@ export const createChatHandler = (options = {})=>async (req)=>{
366
867
  collection: collectionSlugSchema,
367
868
  data: z.record(z.string(), z.unknown()).optional(),
368
869
  label: z.string().min(1),
369
- localizedData: z.record(z.string(), z.record(z.string(), z.unknown())).optional()
870
+ localizedData: nonEmptyLocalizedDataSchema.optional()
370
871
  }).refine((value)=>Boolean(value.data || value.localizedData), {
371
872
  message: "Either data or localizedData is required."
372
873
  }),
373
874
  execute: async ({ collection, data, label, localizedData })=>{
374
875
  const permissionError = getDisallowedCollectionActionError(collection, "create");
375
876
  if (permissionError) return permissionError;
376
- const completedCreatePayload = fillMissingCreateFields({
877
+ const collectionConfig = allowedCollections.find((item)=>item.slug === collection);
878
+ const collectionFields = collectionConfig?.fields || [];
879
+ const preparedData = prepareProposalWriteData({
880
+ collectionConfig: collectionConfig,
377
881
  data,
882
+ inferenceText: prompt,
378
883
  label,
379
884
  localizedData,
380
- requiredFields: createRequiredFieldsByCollection[collection] || []
381
- });
382
- const missingFields = getMissingCreateFields({
383
- data: completedCreatePayload.data,
384
- localizedData: completedCreatePayload.localizedData,
385
- requiredFields: createRequiredFieldsByCollection[collection] || []
885
+ mode: "create"
386
886
  });
387
- if (missingFields.length > 0) {
887
+ if (preparedData.issues.length > 0) {
388
888
  const titleFieldName = titleFieldByCollection[collection];
389
- const missingTitleField = titleFieldName ? missingFields.some((field)=>field === titleFieldName || field.endsWith(`:${titleFieldName}`)) : false;
390
- return {
391
- 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(", ")}`
392
- };
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
+ }
393
937
  }
394
- const proposal = completedCreatePayload.localizedData ? {
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
+ });
959
+ }
960
+ const proposal = preparedData.localizedData ? {
395
961
  action: "create",
396
962
  collection,
397
963
  label: getSafeProposalLabel(label),
398
- localizedData: completedCreatePayload.localizedData,
964
+ localizedData: preparedData.localizedData,
399
965
  ...activeLocale ? {
400
966
  locale: activeLocale
401
967
  } : {}
402
968
  } : {
403
969
  action: "create",
404
970
  collection,
405
- data: completedCreatePayload.data || {},
971
+ data: preparedData.data || {},
406
972
  label: getSafeProposalLabel(label),
407
973
  ...activeLocale ? {
408
974
  locale: activeLocale
@@ -440,20 +1006,62 @@ export const createChatHandler = (options = {})=>async (req)=>{
440
1006
  data: z.record(z.string(), z.unknown()).optional(),
441
1007
  id: z.string().min(1),
442
1008
  label: z.string().min(1),
443
- localizedData: z.record(z.string(), z.record(z.string(), z.unknown())).optional()
1009
+ localizedData: nonEmptyLocalizedDataSchema.optional()
444
1010
  }).refine((value)=>Boolean(value.data || value.localizedData), {
445
1011
  message: "Either data or localizedData is required."
446
1012
  }),
447
1013
  execute: async ({ collection, data, id, label, localizedData })=>{
448
1014
  const permissionError = getDisallowedCollectionActionError(collection, "update");
449
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
+ }
450
1058
  const proposal = {
451
1059
  action: "update",
452
1060
  collection,
453
- ...localizedData ? {
454
- localizedData
1061
+ ...preparedData.localizedData ? {
1062
+ localizedData: preparedData.localizedData
455
1063
  } : {
456
- data: data || {}
1064
+ data: preparedData.data || {}
457
1065
  },
458
1066
  id,
459
1067
  label: getSafeProposalLabel(label),
@@ -469,22 +1077,47 @@ export const createChatHandler = (options = {})=>async (req)=>{
469
1077
  inputSchema: z.object({
470
1078
  data: z.record(z.string(), z.unknown()).optional(),
471
1079
  label: z.string().min(1),
472
- localizedData: z.record(z.string(), z.record(z.string(), z.unknown())).optional(),
1080
+ localizedData: nonEmptyLocalizedDataSchema.optional(),
473
1081
  slug: z.string().min(1)
474
1082
  }).refine((value)=>Boolean(value.data || value.localizedData), {
475
1083
  message: "Either data or localizedData is required."
476
1084
  }),
477
1085
  execute: async ({ data, label, localizedData, slug })=>{
478
1086
  const globalConfig = req.payload.config.globals?.find((global)=>global.slug === slug);
479
- if (!globalConfig) return {
480
- error: `Unknown global: ${slug}`
481
- };
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
+ }
482
1115
  const proposal = {
483
1116
  action: "updateGlobal",
484
- ...localizedData ? {
485
- localizedData
1117
+ ...preparedData.localizedData ? {
1118
+ localizedData: preparedData.localizedData
486
1119
  } : {
487
- data: data || {}
1120
+ data: preparedData.data || {}
488
1121
  },
489
1122
  label: getSafeProposalLabel(label),
490
1123
  ...activeLocale ? {
@@ -551,12 +1184,22 @@ export const createChatHandler = (options = {})=>async (req)=>{
551
1184
  "You are a Payload CMS assistant. Inspect schema/content with tools before proposing writes.",
552
1185
  "Mentions define the active CMS scope. Locale mentions define active locale; multiple locales require localizedData keyed by locale.",
553
1186
  "Writes are proposals only. Never claim changes were applied before user confirmation.",
554
- "For create/update/delete use proposal tools. Put concrete field values only in tool data, not visible text.",
555
- "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)}.`,
556
1195
  `Focused required create fields: ${JSON.stringify(focusedRequiredFieldsByCollection)}.`,
557
1196
  `Focused title fields: ${JSON.stringify(focusedTitleFieldByCollection)}. Infer concise titles when needed.`,
1197
+ `Preferred proposal tool for this prompt: ${intentToolChoice?.toolName || "none"}.`,
558
1198
  "Visible response: plain text, under 40 words, no Markdown, no proposed content."
559
1199
  ].join("\n"),
1200
+ ...intentToolChoice ? {
1201
+ toolChoice: intentToolChoice
1202
+ } : {},
560
1203
  tools
561
1204
  });
562
1205
  const stream = new ReadableStream({
@@ -581,19 +1224,75 @@ export const createChatHandler = (options = {})=>async (req)=>{
581
1224
  if (part.type === "finish") {
582
1225
  const finishPart = part;
583
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
+ });
584
1254
  sendEvent(controller, "proposals", {
585
1255
  proposals,
586
1256
  usage
587
1257
  });
1258
+ sendEvent(controller, "debug", debugPayload);
588
1259
  sendEvent(controller, "done", {});
589
1260
  didSendTerminalEvent = true;
590
1261
  }
591
1262
  }
592
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
+ });
593
1291
  sendEvent(controller, "proposals", {
594
1292
  proposals,
595
1293
  usage
596
1294
  });
1295
+ sendEvent(controller, "debug", debugPayload);
597
1296
  sendEvent(controller, "done", {});
598
1297
  }
599
1298
  } catch (err) {