@mvriu5/payload-ai 1.1.1 → 1.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +42 -0
  2. package/dist/components/Icons.d.ts +0 -8
  3. package/dist/components/Icons.js +4 -216
  4. package/dist/components/{ActionToast.d.ts → action-toast/ActionToast.d.ts} +2 -2
  5. package/dist/components/{ActionToast.js → action-toast/ActionToast.js} +27 -35
  6. package/dist/components/{ActionToast.module.css → action-toast/ActionToast.module.css} +0 -79
  7. package/dist/components/ai-input/AIInput.js +433 -0
  8. package/dist/components/{AIInput.module.css → ai-input/AIInput.module.css} +119 -42
  9. package/dist/components/ai-input/badge.d.ts +14 -0
  10. package/dist/components/ai-input/badge.js +85 -0
  11. package/dist/components/{AuditLogList.d.ts → audit-log-list/AuditLogList.d.ts} +4 -8
  12. package/dist/components/{AuditLogList.js → audit-log-list/AuditLogList.js} +49 -21
  13. package/dist/components/{AuditLogList.module.css → audit-log-list/AuditLogList.module.css} +11 -65
  14. package/dist/components/dashboard/Dashboard.d.ts +2 -0
  15. package/dist/components/dashboard/Dashboard.js +15 -0
  16. package/dist/components/dashboard/Dashboard.module.css +13 -0
  17. package/dist/components/{DiffDialog.d.ts → diff-dialog/DiffDialog.d.ts} +2 -2
  18. package/dist/components/{DiffDialog.js → diff-dialog/DiffDialog.js} +200 -189
  19. package/dist/components/{DiffDialog.module.css → diff-dialog/DiffDialog.module.css} +17 -35
  20. package/dist/components/hooks/useAIChatStream.d.ts +39 -0
  21. package/dist/components/hooks/useAIChatStream.js +217 -0
  22. package/dist/components/hooks/useAISettings.js +26 -25
  23. package/dist/components/hooks/useAuditLog.d.ts +11 -0
  24. package/dist/components/hooks/useAuditLog.js +41 -0
  25. package/dist/components/hooks/useDocumentMentionSuggestions.d.ts +1 -1
  26. package/dist/components/hooks/useDocumentMentionSuggestions.js +31 -27
  27. package/dist/components/hooks/useMentions.d.ts +58 -0
  28. package/dist/components/hooks/useMentions.js +276 -0
  29. package/dist/components/hooks/usePluginConfig.d.ts +52 -0
  30. package/dist/components/hooks/usePluginConfig.js +20 -0
  31. package/dist/components/{MentionPopover.d.ts → mention-popover/MentionPopover.d.ts} +2 -4
  32. package/dist/components/{MentionPopover.js → mention-popover/MentionPopover.js} +1 -8
  33. package/dist/components/{MentionPopover.module.css → mention-popover/MentionPopover.module.css} +1 -1
  34. package/dist/exports/client.d.ts +2 -2
  35. package/dist/exports/client.js +2 -2
  36. package/dist/handlers/applyActionHandler.js +341 -112
  37. package/dist/handlers/chatHandler.js +940 -84
  38. package/dist/handlers/mediaUploadHandler.d.ts +7 -0
  39. package/dist/handlers/mediaUploadHandler.js +99 -0
  40. package/dist/handlers/mentionSuggestionHandler.js +16 -14
  41. package/dist/handlers/proposalDiffHandler.js +117 -50
  42. package/dist/index.d.ts +6 -0
  43. package/dist/index.js +39 -7
  44. package/dist/payload/collectionPermissions.js +3 -1
  45. package/dist/payload/logging.d.ts +9 -0
  46. package/dist/payload/logging.js +14 -0
  47. package/dist/payload/normalizeData.d.ts +16 -12
  48. package/dist/payload/normalizeData.js +47 -14
  49. package/dist/payload/proposalData.d.ts +31 -0
  50. package/dist/payload/proposalData.js +578 -0
  51. package/dist/payload/schemaContext.d.ts +3 -7
  52. package/dist/payload/schemaContext.js +147 -66
  53. package/package.json +6 -6
  54. package/dist/components/AIInput.js +0 -747
  55. /package/dist/components/{AIInput.d.ts → ai-input/AIInput.d.ts} +0 -0
@@ -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,9 +24,29 @@ 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
- const wantsCreatePost = normalizedPrompt.includes("post") && (normalizedPrompt.includes("create") || normalizedPrompt.includes("erstell"));
49
+ const wantsCreatePost = normalizedPrompt.includes("post") && (normalizedPrompt.includes("create") || normalizedPrompt.includes("erstell") || normalizedPrompt.includes("apply flow") || normalizedPrompt.includes("locale review") || normalizedPrompt.includes("proposal review"));
25
50
  const mentionsMars = normalizedPrompt.includes("mars");
26
51
  const multipleLocales = selectedLocales.length > 1;
27
52
  const activeLocale = selectedLocales.at(-1);
@@ -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"
@@ -196,14 +241,23 @@ const getMissingCreateFields = ({ data, localizedData, requiredFields })=>{
196
241
  ];
197
242
  }
198
243
  const missing = [];
244
+ const localizedRequiredFields = [];
245
+ const sharedRequiredFields = [];
246
+ for (const field of requiredFields){
247
+ if (field.localized) {
248
+ localizedRequiredFields.push(field);
249
+ } else {
250
+ sharedRequiredFields.push(field);
251
+ }
252
+ }
199
253
  for (const [locale, localeData] of locales){
200
- for (const field of requiredFields.filter((item)=>item.localized)){
254
+ for (const field of localizedRequiredFields){
201
255
  if (!hasValueAtPath(localeData, field.path)) {
202
256
  missing.push(`${locale}:${field.path}`);
203
257
  }
204
258
  }
205
259
  }
206
- for (const field of requiredFields.filter((item)=>!item.localized)){
260
+ for (const field of sharedRequiredFields){
207
261
  if (!hasValueAtPath(firstLocale[1], field.path)) {
208
262
  missing.push(`${firstLocale[0]}:${field.path}`);
209
263
  }
@@ -213,7 +267,428 @@ const getMissingCreateFields = ({ data, localizedData, requiredFields })=>{
213
267
  if (!data) return [
214
268
  "data is required"
215
269
  ];
216
- return requiredFields.filter((field)=>!hasValueAtPath(data, field.path)).map((field)=>field.path);
270
+ return requiredFields.flatMap((field)=>hasValueAtPath(data, field.path) ? [] : [
271
+ field.path
272
+ ]);
273
+ };
274
+ const getProposalSummary = (proposal)=>({
275
+ action: proposal.action,
276
+ collection: "collection" in proposal ? proposal.collection : undefined,
277
+ hasData: "data" in proposal && Boolean(proposal.data),
278
+ hasLocalizedData: "localizedData" in proposal && Boolean(proposal.localizedData),
279
+ id: "id" in proposal ? proposal.id : undefined,
280
+ label: proposal.label,
281
+ locale: proposal.locale,
282
+ locales: "localizedData" in proposal && proposal.localizedData ? Object.keys(proposal.localizedData) : undefined,
283
+ slug: "slug" in proposal ? proposal.slug : undefined
284
+ });
285
+ const getCollectionBlockTypes = (fields)=>{
286
+ const blockTypes = new Set();
287
+ const visitFields = (items)=>{
288
+ for (const field of items){
289
+ if (field.type === "blocks" && field.blocks) {
290
+ for (const block of field.blocks){
291
+ blockTypes.add(block.slug);
292
+ if (block.fields?.length) {
293
+ visitFields(block.fields);
294
+ }
295
+ }
296
+ }
297
+ if (field.fields?.length) {
298
+ visitFields(field.fields);
299
+ }
300
+ }
301
+ };
302
+ visitFields(fields);
303
+ return [
304
+ ...blockTypes
305
+ ];
306
+ };
307
+ const regexSpecialCharactersPattern = /[.*+?^${}()|[\]\\]/g;
308
+ const getRequestedBlockTypes = ({ availableBlockTypes, mentions, prompt })=>{
309
+ const requestedBlockTypes = new Set();
310
+ const normalizedPrompt = prompt.toLowerCase();
311
+ const availableBlockTypeSet = new Set(availableBlockTypes);
312
+ const blockTypesByNormalizedSlug = new Map(availableBlockTypes.map((blockType)=>[
313
+ blockType.toLowerCase(),
314
+ blockType
315
+ ]));
316
+ const escapedBlockTypes = availableBlockTypes.map((blockType)=>blockType.replace(regexSpecialCharactersPattern, "\\$&")).join("|");
317
+ const blockPattern = escapedBlockTypes ? new RegExp(`\\b(${escapedBlockTypes})\\b(?:\\s+block)?`, "gi") : null;
318
+ for (const mention of mentions || []){
319
+ if (mention.type === "block" && mention.slug && availableBlockTypeSet.has(mention.slug)) {
320
+ requestedBlockTypes.add(mention.slug);
321
+ }
322
+ }
323
+ if (blockPattern) {
324
+ for (const match of normalizedPrompt.matchAll(blockPattern)){
325
+ const blockType = match[1] ? blockTypesByNormalizedSlug.get(match[1].toLowerCase()) : null;
326
+ if (blockType) {
327
+ requestedBlockTypes.add(blockType);
328
+ }
329
+ }
330
+ }
331
+ return [
332
+ ...requestedBlockTypes
333
+ ];
334
+ };
335
+ const collectProposalBlockTypes = ({ data, fields })=>{
336
+ const foundBlockTypes = new Set();
337
+ const visitFields = (items, value)=>{
338
+ for (const field of items){
339
+ if (!field.name) continue;
340
+ const fieldValue = value[field.name];
341
+ if (fieldValue === undefined || fieldValue === null) continue;
342
+ if (field.type === "blocks" && Array.isArray(fieldValue)) {
343
+ const blocksBySlug = new Map(field.blocks?.map((block)=>[
344
+ block.slug,
345
+ block
346
+ ]));
347
+ for (const blockItem of fieldValue){
348
+ if (!isRecord(blockItem)) continue;
349
+ const blockType = typeof blockItem.blockType === "string" ? blockItem.blockType : typeof blockItem.type === "string" ? blockItem.type : typeof blockItem.slug === "string" ? blockItem.slug : null;
350
+ if (blockType) {
351
+ foundBlockTypes.add(blockType);
352
+ }
353
+ if (blockType) {
354
+ const blockConfig = blocksBySlug.get(blockType);
355
+ if (blockConfig?.fields?.length) {
356
+ visitFields(blockConfig.fields, blockItem);
357
+ }
358
+ }
359
+ }
360
+ continue;
361
+ }
362
+ if (field.type === "group" && isRecord(fieldValue) && field.fields?.length) {
363
+ visitFields(field.fields, fieldValue);
364
+ continue;
365
+ }
366
+ if (field.type === "array" && Array.isArray(fieldValue) && field.fields?.length) {
367
+ for (const item of fieldValue){
368
+ if (isRecord(item)) {
369
+ visitFields(field.fields, item);
370
+ }
371
+ }
372
+ }
373
+ }
374
+ };
375
+ visitFields(fields, data);
376
+ return foundBlockTypes;
377
+ };
378
+ const collectRelationshipTargets = ({ data, fields, path = "" })=>{
379
+ const targets = [];
380
+ for (const field of fields){
381
+ if (!field.name) continue;
382
+ const fieldPath = path ? `${path}.${field.name}` : field.name;
383
+ const fieldValue = data[field.name];
384
+ if (fieldValue === undefined || fieldValue === null) continue;
385
+ if (field.type === "relationship" || field.type === "upload") {
386
+ const relationTargets = Array.isArray(field.relationTo) ? field.relationTo.filter((item)=>typeof item === "string") : typeof field.relationTo === "string" ? [
387
+ field.relationTo
388
+ ] : [];
389
+ const collectSingle = (value, itemPath)=>{
390
+ if (typeof value === "string" || typeof value === "number") {
391
+ if (relationTargets.length === 1) {
392
+ targets.push({
393
+ collection: relationTargets[0],
394
+ id: value,
395
+ path: itemPath
396
+ });
397
+ }
398
+ return;
399
+ }
400
+ if (!isRecord(value)) return;
401
+ const relationTo = typeof value.relationTo === "string" ? value.relationTo : relationTargets[0];
402
+ const id = typeof value.id === "string" || typeof value.id === "number" ? value.id : typeof value.value === "string" || typeof value.value === "number" ? value.value : undefined;
403
+ if (!relationTo || id === undefined) return;
404
+ targets.push({
405
+ collection: relationTo,
406
+ id,
407
+ path: itemPath
408
+ });
409
+ };
410
+ if (field.hasMany && Array.isArray(fieldValue)) {
411
+ fieldValue.forEach((item, index)=>collectSingle(item, `${fieldPath}.${index}`));
412
+ } else {
413
+ collectSingle(fieldValue, fieldPath);
414
+ }
415
+ continue;
416
+ }
417
+ if (field.type === "group" && isRecord(fieldValue) && field.fields?.length) {
418
+ targets.push(...collectRelationshipTargets({
419
+ data: fieldValue,
420
+ fields: field.fields,
421
+ path: fieldPath
422
+ }));
423
+ continue;
424
+ }
425
+ if (field.type === "array" && Array.isArray(fieldValue) && field.fields?.length) {
426
+ fieldValue.forEach((item, index)=>{
427
+ if (!isRecord(item)) return;
428
+ targets.push(...collectRelationshipTargets({
429
+ data: item,
430
+ fields: field.fields,
431
+ path: `${fieldPath}.${index}`
432
+ }));
433
+ });
434
+ continue;
435
+ }
436
+ if (field.type === "blocks" && Array.isArray(fieldValue) && field.blocks?.length) {
437
+ const blocksBySlug = new Map(field.blocks.map((block)=>[
438
+ block.slug,
439
+ block
440
+ ]));
441
+ fieldValue.forEach((item, index)=>{
442
+ if (!isRecord(item)) return;
443
+ const blockType = typeof item.blockType === "string" ? item.blockType : typeof item.type === "string" ? item.type : typeof item.slug === "string" ? item.slug : null;
444
+ if (!blockType) return;
445
+ const blockConfig = blocksBySlug.get(blockType);
446
+ if (!blockConfig?.fields?.length) return;
447
+ targets.push(...collectRelationshipTargets({
448
+ data: item,
449
+ fields: blockConfig.fields,
450
+ path: `${fieldPath}.${index}`
451
+ }));
452
+ });
453
+ }
454
+ }
455
+ return targets;
456
+ };
457
+ const collectUploadTargets = ({ data, fields, path = "" })=>{
458
+ const targets = [];
459
+ for (const field of fields){
460
+ if (!field.name) continue;
461
+ const fieldPath = path ? `${path}.${field.name}` : field.name;
462
+ const fieldValue = data[field.name];
463
+ if (fieldValue === undefined || fieldValue === null) continue;
464
+ if (field.type === "upload") {
465
+ const relationTargets = Array.isArray(field.relationTo) ? field.relationTo.filter((item)=>typeof item === "string") : typeof field.relationTo === "string" ? [
466
+ field.relationTo
467
+ ] : [];
468
+ const collectSingle = (value, itemPath)=>{
469
+ if (typeof value === "string" || typeof value === "number") {
470
+ if (relationTargets.length === 1) {
471
+ targets.push({
472
+ collection: relationTargets[0],
473
+ id: value,
474
+ path: itemPath
475
+ });
476
+ }
477
+ return;
478
+ }
479
+ if (!isRecord(value)) return;
480
+ const relationTo = typeof value.relationTo === "string" ? value.relationTo : relationTargets[0];
481
+ const id = typeof value.id === "string" || typeof value.id === "number" ? value.id : typeof value.value === "string" || typeof value.value === "number" ? value.value : undefined;
482
+ if (!relationTo || id === undefined) return;
483
+ targets.push({
484
+ collection: relationTo,
485
+ id,
486
+ path: itemPath
487
+ });
488
+ };
489
+ if (field.hasMany && Array.isArray(fieldValue)) {
490
+ fieldValue.forEach((item, index)=>collectSingle(item, `${fieldPath}.${index}`));
491
+ } else {
492
+ collectSingle(fieldValue, fieldPath);
493
+ }
494
+ continue;
495
+ }
496
+ if (field.type === "group" && isRecord(fieldValue) && field.fields?.length) {
497
+ targets.push(...collectUploadTargets({
498
+ data: fieldValue,
499
+ fields: field.fields,
500
+ path: fieldPath
501
+ }));
502
+ continue;
503
+ }
504
+ if (field.type === "array" && Array.isArray(fieldValue) && field.fields?.length) {
505
+ fieldValue.forEach((item, index)=>{
506
+ if (!isRecord(item)) return;
507
+ targets.push(...collectUploadTargets({
508
+ data: item,
509
+ fields: field.fields,
510
+ path: `${fieldPath}.${index}`
511
+ }));
512
+ });
513
+ continue;
514
+ }
515
+ if (field.type === "blocks" && Array.isArray(fieldValue) && field.blocks?.length) {
516
+ const blocksBySlug = new Map(field.blocks.map((block)=>[
517
+ block.slug,
518
+ block
519
+ ]));
520
+ fieldValue.forEach((item, index)=>{
521
+ if (!isRecord(item)) return;
522
+ const blockType = typeof item.blockType === "string" ? item.blockType : typeof item.type === "string" ? item.type : typeof item.slug === "string" ? item.slug : null;
523
+ if (!blockType) return;
524
+ const blockConfig = blocksBySlug.get(blockType);
525
+ if (!blockConfig?.fields?.length) return;
526
+ targets.push(...collectUploadTargets({
527
+ data: item,
528
+ fields: blockConfig.fields,
529
+ path: `${fieldPath}.${index}`
530
+ }));
531
+ });
532
+ }
533
+ }
534
+ return targets;
535
+ };
536
+ const validateRelationshipTargetsExist = async ({ data, fields, req })=>{
537
+ const targets = collectRelationshipTargets({
538
+ data,
539
+ fields
540
+ });
541
+ const targetResults = await Promise.all(targets.map(async (target)=>{
542
+ try {
543
+ await req.payload.findByID({
544
+ collection: target.collection,
545
+ depth: 0,
546
+ id: String(target.id),
547
+ overrideAccess: false,
548
+ req
549
+ });
550
+ return null;
551
+ } catch {
552
+ return target;
553
+ }
554
+ }));
555
+ return targetResults.filter((target)=>Boolean(target));
556
+ };
557
+ const getUploadTargetsOutsideAttachments = ({ allowedAttachmentKeys, data, fields })=>{
558
+ if (allowedAttachmentKeys.size === 0) return [];
559
+ return collectUploadTargets({
560
+ data,
561
+ fields
562
+ }).filter((target)=>!allowedAttachmentKeys.has(`${target.collection}:${String(target.id)}`));
563
+ };
564
+ const getMentionSummary = (mentions)=>mentions?.map((mention)=>({
565
+ collection: "collection" in mention ? mention.collection : undefined,
566
+ id: "id" in mention ? mention.id : undefined,
567
+ slug: mention.slug,
568
+ type: mention.type
569
+ })) || [];
570
+ const getMediaAttachmentContext = async ({ allowedCollectionsBySlug, attachments, collections, req })=>{
571
+ if (!attachments?.length) return [];
572
+ const contexts = [];
573
+ const seen = new Set();
574
+ for (const attachment of attachments.slice(0, 8)){
575
+ if (attachment.type !== "media" || !attachment.collection || !attachment.id) continue;
576
+ const key = `${attachment.collection}:${attachment.id}`;
577
+ if (seen.has(key)) continue;
578
+ const collectionConfig = allowedCollectionsBySlug.get(attachment.collection);
579
+ if (!collectionConfig?.upload) continue;
580
+ const doc = await req.payload.findByID({
581
+ collection: attachment.collection,
582
+ depth: 1,
583
+ id: attachment.id,
584
+ overrideAccess: false,
585
+ req
586
+ }).catch(()=>null);
587
+ if (!doc) continue;
588
+ seen.add(key);
589
+ contexts.push({
590
+ attachment,
591
+ collection: attachment.collection,
592
+ doc,
593
+ schema: describeCollectionLikeConfig({
594
+ config: collectionConfig,
595
+ permissions: collections,
596
+ type: "collection"
597
+ }),
598
+ type: "mediaAttachment"
599
+ });
600
+ }
601
+ return contexts;
602
+ };
603
+ const formatProposalIssuesForRetry = (issues)=>{
604
+ return issues.slice(0, 6).map((issue)=>{
605
+ switch(issue.code){
606
+ case "invalid_block_type":
607
+ return `${issue.path}: use an exact blockType from the schema`;
608
+ case "invalid_blocks":
609
+ return `${issue.path}: blocks fields must be arrays of objects with blockType and exact field names`;
610
+ case "invalid_array":
611
+ return `${issue.path}: array fields must be arrays of complete objects`;
612
+ case "missing_required_field":
613
+ return `${issue.path}: required field missing`;
614
+ case "unknown_field":
615
+ return `${issue.path}: unknown field, use exact schema field names only`;
616
+ case "invalid_relationship":
617
+ return `${issue.path}: use relationship IDs or { relationTo, value }, not free text`;
618
+ default:
619
+ return `${issue.path}: ${issue.message}`;
620
+ }
621
+ }).join("; ");
622
+ };
623
+ const createCollectionAliasMap = (collections)=>{
624
+ const aliasMap = new Map();
625
+ const addAlias = (alias, slug)=>{
626
+ const normalizedAlias = alias?.trim().toLowerCase();
627
+ if (!normalizedAlias) return;
628
+ if (!aliasMap.has(normalizedAlias)) {
629
+ aliasMap.set(normalizedAlias, slug);
630
+ }
631
+ };
632
+ for (const collection of collections){
633
+ addAlias(collection.slug, collection.slug);
634
+ addAlias(collection.slug.replace(/-/g, " "), collection.slug);
635
+ const singular = typeof collection.labels?.singular === "string" ? collection.labels.singular : undefined;
636
+ const plural = typeof collection.labels?.plural === "string" ? collection.labels.plural : undefined;
637
+ addAlias(singular, collection.slug);
638
+ addAlias(plural, collection.slug);
639
+ if (singular?.endsWith("s")) {
640
+ addAlias(singular.slice(0, -1), collection.slug);
641
+ }
642
+ if (plural?.endsWith("s")) {
643
+ addAlias(plural.slice(0, -1), collection.slug);
644
+ }
645
+ }
646
+ return Object.fromEntries(aliasMap.entries());
647
+ };
648
+ const getLikelyCollectionMatches = ({ aliasMap, prompt })=>{
649
+ const normalizedPrompt = prompt.toLowerCase();
650
+ const matches = new Set();
651
+ const aliases = Object.keys(aliasMap).sort((a, b)=>b.length - a.length);
652
+ const aliasPattern = aliases.length > 0 ? new RegExp(aliases.map((alias)=>alias.replace(regexSpecialCharactersPattern, "\\$&")).join("|"), "g") : null;
653
+ if (!aliasPattern) return [];
654
+ for (const match of normalizedPrompt.matchAll(aliasPattern)){
655
+ const alias = match[0];
656
+ const slug = aliasMap[alias];
657
+ if (slug) {
658
+ matches.add(slug);
659
+ }
660
+ }
661
+ return [
662
+ ...matches
663
+ ];
664
+ };
665
+ const hasWriteIntent = (prompt)=>{
666
+ const normalizedPrompt = prompt.toLowerCase();
667
+ return /\b(create|build|generate|write|draft|make|add|update|edit|change|revise|refine|rewrite|translate|delete|remove)\b/.test(normalizedPrompt);
668
+ };
669
+ const getIntentToolChoice = (prompt)=>{
670
+ const normalizedPrompt = prompt.toLowerCase();
671
+ if (/\b(delete|remove)\b/.test(normalizedPrompt)) {
672
+ return {
673
+ toolName: "proposeDeleteDoc",
674
+ type: "tool"
675
+ };
676
+ }
677
+ // Update existing document (add block, modify content, etc.)
678
+ if (/\b(update|edit|change|modify|einfügen|hinzufügen|addieren)\b/.test(normalizedPrompt)) {
679
+ return {
680
+ toolName: "proposeUpdateDoc",
681
+ type: "tool"
682
+ };
683
+ }
684
+ // Create a new document
685
+ if (/\b(create|build|generate|write|draft|make|add|füge|einfügen|hinzufügen|addieren)\b/.test(normalizedPrompt)) {
686
+ return {
687
+ toolName: "proposeCreateDoc",
688
+ type: "tool"
689
+ };
690
+ }
691
+ return undefined;
217
692
  };
218
693
  export const createChatHandler = (options = {})=>async (req)=>{
219
694
  if (!req.user) return Response.json({
@@ -228,8 +703,15 @@ export const createChatHandler = (options = {})=>async (req)=>{
228
703
  }, {
229
704
  status: 400
230
705
  });
231
- const selectedLocales = (body?.mentions?.filter((mention)=>mention.type === "locale" && mention.slug).map((mention)=>mention.slug) || []).filter((locale, index, array)=>array.indexOf(locale) === index);
706
+ const selectedLocales = [];
707
+ const selectedLocaleSet = new Set();
708
+ for (const mention of body?.mentions || []){
709
+ if (mention.type !== "locale" || !mention.slug || selectedLocaleSet.has(mention.slug)) continue;
710
+ selectedLocaleSet.add(mention.slug);
711
+ selectedLocales.push(mention.slug);
712
+ }
232
713
  const activeLocale = selectedLocales.at(-1);
714
+ const mentionSummary = getMentionSummary(body?.mentions);
233
715
  if (e2eModeEnabled()) {
234
716
  return createE2EChatResponse({
235
717
  prompt,
@@ -266,7 +748,23 @@ export const createChatHandler = (options = {})=>async (req)=>{
266
748
  "searchDocs"
267
749
  ]
268
750
  };
751
+ logHandlerEvent(req, "info", {
752
+ activeLocale,
753
+ debug,
754
+ mentionCount: mentionSummary.length,
755
+ mentions: mentionSummary,
756
+ msg: "AI chat started",
757
+ promptPreview: getLogPreview(prompt),
758
+ selectedLocales
759
+ });
269
760
  if (!providerConfig.apiKey) {
761
+ logHandlerEvent(req, "warn", {
762
+ activeLocale,
763
+ debug,
764
+ msg: "AI chat blocked: missing provider API key",
765
+ promptPreview: getLogPreview(prompt),
766
+ selectedLocales
767
+ });
270
768
  return Response.json({
271
769
  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
770
  }, {
@@ -275,25 +773,70 @@ export const createChatHandler = (options = {})=>async (req)=>{
275
773
  }
276
774
  try {
277
775
  const proposals = [];
776
+ const toolFailures = [];
777
+ const registerToolFailure = (failure)=>{
778
+ toolFailures.push(failure);
779
+ logHandlerEvent(req, "warn", {
780
+ debug,
781
+ ...failure,
782
+ msg: "AI tool validation failed",
783
+ promptPreview: getLogPreview(prompt)
784
+ });
785
+ };
786
+ const createToolError = ({ collection, details, message, slug, tool })=>{
787
+ registerToolFailure({
788
+ collection,
789
+ details,
790
+ message,
791
+ slug,
792
+ tool
793
+ });
794
+ return {
795
+ error: message
796
+ };
797
+ };
278
798
  const addSignedProposal = (proposal)=>{
279
799
  if ("data" in proposal && proposal.data && containsSensitiveData(proposal.data)) {
280
- return {
281
- error: "Proposal contains sensitive fields and cannot be created."
282
- };
800
+ return createToolError({
801
+ details: getProposalSummary(proposal),
802
+ message: "Proposal contains sensitive fields and cannot be created.",
803
+ tool: `propose${proposal.action[0]?.toUpperCase()}${proposal.action.slice(1)}`
804
+ });
283
805
  }
284
806
  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
- };
807
+ return createToolError({
808
+ details: getProposalSummary(proposal),
809
+ message: "Proposal contains sensitive fields and cannot be created.",
810
+ tool: `propose${proposal.action[0]?.toUpperCase()}${proposal.action.slice(1)}`
811
+ });
288
812
  }
289
813
  const signedProposal = signAIActionProposal(proposal);
290
814
  proposals.push(signedProposal);
815
+ logHandlerEvent(req, "info", {
816
+ debug,
817
+ msg: "AI proposal created",
818
+ proposal: getProposalSummary(signedProposal)
819
+ });
291
820
  return signedProposal;
292
821
  };
293
822
  const collectionSlugs = getAllowedCollectionSlugs(req, options.collections);
294
- const globalSlugs = req.payload.config.globals?.map((global)=>global.slug) || [];
295
- const allowedCollections = req.payload.config.collections.filter((collection)=>collectionSlugs.includes(collection.slug));
823
+ const collectionSlugSet = new Set(collectionSlugs);
824
+ const globalConfigs = req.payload.config.globals || [];
825
+ const globalSlugs = globalConfigs.map((global)=>global.slug);
826
+ const globalConfigsBySlug = new Map(globalConfigs.map((global)=>[
827
+ global.slug,
828
+ global
829
+ ]));
830
+ const allowedCollections = req.payload.config.collections.filter((collection)=>collectionSlugSet.has(collection.slug));
831
+ const allowedCollectionsBySlug = new Map(allowedCollections.map((collection)=>[
832
+ collection.slug,
833
+ collection
834
+ ]));
296
835
  if (collectionSlugs.length === 0) {
836
+ logHandlerEvent(req, "warn", {
837
+ debug,
838
+ msg: "AI chat blocked: no AI-enabled collections configured"
839
+ });
297
840
  return Response.json({
298
841
  error: "No AI-enabled collections are configured."
299
842
  }, {
@@ -305,10 +848,10 @@ export const createChatHandler = (options = {})=>async (req)=>{
305
848
  fields: collection.fields,
306
849
  parent: collection.slug
307
850
  })),
308
- ...req.payload.config.globals?.flatMap((global)=>collectBlocks({
851
+ ...globalConfigs.flatMap((global)=>collectBlocks({
309
852
  fields: global.fields,
310
853
  parent: global.slug
311
- })) || []
854
+ }))
312
855
  ];
313
856
  const mentionContext = await getMentionContext({
314
857
  blockContexts,
@@ -318,15 +861,27 @@ export const createChatHandler = (options = {})=>async (req)=>{
318
861
  mentions: body?.mentions,
319
862
  req
320
863
  });
321
- const mentionedCollectionSlugs = (body?.mentions?.flatMap((mention)=>{
322
- if (mention.type === "collection" && mention.slug) return [
323
- mention.slug
324
- ];
325
- if (mention.type === "doc" && mention.collection) return [
326
- mention.collection
327
- ];
328
- return [];
329
- }).filter((slug)=>collectionSlugs.includes(slug)) || []).filter((slug, index, array)=>array.indexOf(slug) === index);
864
+ const mediaAttachmentContext = await getMediaAttachmentContext({
865
+ allowedCollectionsBySlug,
866
+ attachments: body?.attachments,
867
+ collections: options.collections,
868
+ req
869
+ });
870
+ const allowedAttachmentKeys = new Set(mediaAttachmentContext.flatMap((context)=>{
871
+ const attachment = context.attachment;
872
+ return isRecord(attachment) && typeof attachment.collection === "string" && typeof attachment.id === "string" ? [
873
+ `${attachment.collection}:${attachment.id}`
874
+ ] : [];
875
+ }));
876
+ mentionContext.push(...mediaAttachmentContext);
877
+ const mentionedCollectionSlugs = [];
878
+ const mentionedCollectionSlugSet = new Set();
879
+ for (const mention of body?.mentions || []){
880
+ const slug = mention.type === "collection" && mention.slug ? mention.slug : mention.type === "doc" && mention.collection ? mention.collection : null;
881
+ if (!slug || !collectionSlugSet.has(slug) || mentionedCollectionSlugSet.has(slug)) continue;
882
+ mentionedCollectionSlugSet.add(slug);
883
+ mentionedCollectionSlugs.push(slug);
884
+ }
330
885
  const createRequiredFieldsByCollection = Object.fromEntries(allowedCollections.map((collection)=>[
331
886
  collection.slug,
332
887
  getRequiredFieldInfos(collection.fields, collection.admin?.useAsTitle)
@@ -347,6 +902,38 @@ export const createChatHandler = (options = {})=>async (req)=>{
347
902
  titleFieldByCollection[slug]
348
903
  ]
349
904
  ] : []));
905
+ const collectionAliasMap = createCollectionAliasMap(allowedCollections);
906
+ const likelyCollectionMatches = getLikelyCollectionMatches({
907
+ aliasMap: collectionAliasMap,
908
+ prompt
909
+ });
910
+ const writeIntent = hasWriteIntent(prompt);
911
+ const inferredCollectionSlug = mentionedCollectionSlugs.length === 1 ? mentionedCollectionSlugs[0] : mentionedCollectionSlugs.length === 0 && likelyCollectionMatches.length === 1 ? likelyCollectionMatches[0] : undefined;
912
+ const inferredCollectionConfig = inferredCollectionSlug ? allowedCollectionsBySlug.get(inferredCollectionSlug) : undefined;
913
+ if (inferredCollectionConfig && !mentionContext.some((item)=>item.type === "collection" && item.slug === inferredCollectionConfig.slug)) {
914
+ mentionContext.push({
915
+ ...describeCollectionLikeConfig({
916
+ config: inferredCollectionConfig,
917
+ permissions: options.collections,
918
+ type: "collection"
919
+ }),
920
+ inferredFromPrompt: true
921
+ });
922
+ }
923
+ const intentToolChoice = inferredCollectionConfig ? getIntentToolChoice(prompt) : undefined;
924
+ logHandlerEvent(req, "info", {
925
+ activeLocale,
926
+ allowedCollectionCount: allowedCollections.length,
927
+ collectionSlugs,
928
+ focusedCollections: mentionedCollectionSlugs,
929
+ globalSlugs,
930
+ inferredCollectionSlug,
931
+ intentToolChoice,
932
+ likelyCollectionMatches,
933
+ msg: "AI chat context prepared",
934
+ selectedLocales,
935
+ writeIntent
936
+ });
350
937
  const collectionSlugSchema = z.enum(collectionSlugs);
351
938
  const getDisallowedCollectionActionError = (collection, action)=>{
352
939
  if (isCollectionActionAllowed({
@@ -355,13 +942,15 @@ export const createChatHandler = (options = {})=>async (req)=>{
355
942
  req,
356
943
  slug: collection
357
944
  })) return null;
358
- return {
359
- error: `${action} is not enabled for collection: ${collection}`
360
- };
945
+ return createToolError({
946
+ collection,
947
+ message: `${action} is not enabled for collection: ${collection}`,
948
+ tool: "collectionPermissionCheck"
949
+ });
361
950
  };
362
951
  const tools = {
363
952
  getDoc: {
364
- description: "Read one document by collection slug and document id.",
953
+ description: "Read a document by collection and id.",
365
954
  inputSchema: z.object({
366
955
  collection: collectionSlugSchema,
367
956
  id: z.string().min(1)
@@ -380,16 +969,20 @@ export const createChatHandler = (options = {})=>async (req)=>{
380
969
  }
381
970
  },
382
971
  listCollections: {
383
- description: "List AI-enabled Payload collections. Omit slug for compact summaries; pass slug to get full field schema for one collection.",
972
+ description: "List collections; pass slug for one full schema.",
384
973
  inputSchema: z.object({
385
974
  slug: collectionSlugSchema.optional()
386
975
  }),
387
976
  execute: async ({ slug })=>{
388
977
  if (slug) {
389
- const collection = allowedCollections.find((item)=>item.slug === slug);
390
- if (!collection) return {
391
- error: `Unknown collection: ${slug}`
392
- };
978
+ const collection = allowedCollectionsBySlug.get(slug);
979
+ if (!collection) {
980
+ return createToolError({
981
+ message: `Unknown collection: ${slug}`,
982
+ slug,
983
+ tool: "listCollections"
984
+ });
985
+ }
393
986
  return describeCollectionLikeConfig({
394
987
  config: collection,
395
988
  permissions: options.collections,
@@ -404,15 +997,19 @@ export const createChatHandler = (options = {})=>async (req)=>{
404
997
  }
405
998
  },
406
999
  getGlobal: {
407
- description: "Read one Payload CMS global by slug.",
1000
+ description: "Read a global by slug.",
408
1001
  inputSchema: z.object({
409
1002
  slug: z.string().min(1)
410
1003
  }),
411
1004
  execute: async ({ slug })=>{
412
- const globalConfig = req.payload.config.globals?.find((global)=>global.slug === slug);
413
- if (!globalConfig) return {
414
- error: `Unknown global: ${slug}`
415
- };
1005
+ const globalConfig = globalConfigsBySlug.get(slug);
1006
+ if (!globalConfig) {
1007
+ return createToolError({
1008
+ message: `Unknown global: ${slug}`,
1009
+ slug,
1010
+ tool: "getGlobal"
1011
+ });
1012
+ }
416
1013
  return req.payload.findGlobal({
417
1014
  depth: 2,
418
1015
  ...activeLocale ? {
@@ -425,71 +1022,164 @@ export const createChatHandler = (options = {})=>async (req)=>{
425
1022
  }
426
1023
  },
427
1024
  listGlobals: {
428
- description: "List Payload globals. Omit slug for compact summaries; pass slug to get full field schema for one global.",
1025
+ description: "List globals; pass slug for one full schema.",
429
1026
  inputSchema: z.object({
430
1027
  slug: z.string().optional()
431
1028
  }),
432
1029
  execute: async ({ slug })=>{
433
- const globals = req.payload.config.globals || [];
434
1030
  if (slug) {
435
- const global = globals.find((item)=>item.slug === slug);
436
- if (!global) return {
437
- error: `Unknown global: ${slug}`
438
- };
1031
+ const global = globalConfigsBySlug.get(slug);
1032
+ if (!global) {
1033
+ return createToolError({
1034
+ message: `Unknown global: ${slug}`,
1035
+ slug,
1036
+ tool: "listGlobals"
1037
+ });
1038
+ }
439
1039
  return describeCollectionLikeConfig({
440
1040
  config: global,
441
1041
  type: "global"
442
1042
  });
443
1043
  }
444
- return globals.map((global)=>describeCollectionLikeSummary({
1044
+ return globalConfigs.map((global)=>describeCollectionLikeSummary({
445
1045
  config: global,
446
1046
  type: "global"
447
1047
  }));
448
1048
  }
449
1049
  },
450
1050
  proposeCreateDoc: {
451
- description: "Prepare a CMS document creation proposal. This does not write to the database. Use exact field names from listCollections. Include every required field for the target collection. For localizedData, include every localized required field in every locale entry, and include non-localized required fields in the first locale entry. For array fields, provide arrays of objects matching their child fields. For richText fields, prefer plain text or omit if unsure. Use localizedData when writing multiple locales in one proposal.",
1051
+ description: "Propose document creation. Use exact schema fields; include required fields. Use localizedData for multi-locale writes.",
452
1052
  inputSchema: z.object({
453
1053
  collection: collectionSlugSchema,
454
1054
  data: z.record(z.string(), z.unknown()).optional(),
455
1055
  label: z.string().min(1),
456
- localizedData: z.record(z.string(), z.record(z.string(), z.unknown())).optional()
1056
+ localizedData: nonEmptyLocalizedDataSchema.optional()
457
1057
  }).refine((value)=>Boolean(value.data || value.localizedData), {
458
1058
  message: "Either data or localizedData is required."
459
1059
  }),
460
1060
  execute: async ({ collection, data, label, localizedData })=>{
461
1061
  const permissionError = getDisallowedCollectionActionError(collection, "create");
462
1062
  if (permissionError) return permissionError;
463
- const completedCreatePayload = fillMissingCreateFields({
1063
+ const collectionConfig = allowedCollectionsBySlug.get(collection);
1064
+ const collectionFields = collectionConfig?.fields || [];
1065
+ const preparedData = prepareProposalWriteData({
1066
+ collectionConfig: collectionConfig,
464
1067
  data,
1068
+ inferenceText: prompt,
465
1069
  label,
466
1070
  localizedData,
467
- requiredFields: createRequiredFieldsByCollection[collection] || []
468
- });
469
- const missingFields = getMissingCreateFields({
470
- data: completedCreatePayload.data,
471
- localizedData: completedCreatePayload.localizedData,
472
- requiredFields: createRequiredFieldsByCollection[collection] || []
1071
+ mode: "create"
473
1072
  });
474
- if (missingFields.length > 0) {
1073
+ if (preparedData.issues.length > 0) {
475
1074
  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
- };
1075
+ const missingTitleField = titleFieldName ? preparedData.issues.some((issue)=>issue.path === titleFieldName || issue.path.endsWith(`.${titleFieldName}`)) : false;
1076
+ return createToolError({
1077
+ collection,
1078
+ details: {
1079
+ issues: preparedData.issues,
1080
+ titleFieldName
1081
+ },
1082
+ 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)}`,
1083
+ tool: "proposeCreateDoc"
1084
+ });
480
1085
  }
481
- const proposal = completedCreatePayload.localizedData ? {
1086
+ const requestedBlockTypes = getRequestedBlockTypes({
1087
+ availableBlockTypes: getCollectionBlockTypes(collectionFields),
1088
+ mentions: body?.mentions,
1089
+ prompt
1090
+ });
1091
+ if (requestedBlockTypes.length > 0) {
1092
+ const proposalBlockTypes = new Set();
1093
+ if (preparedData.data) {
1094
+ for (const blockType of collectProposalBlockTypes({
1095
+ data: preparedData.data,
1096
+ fields: collectionFields
1097
+ })){
1098
+ proposalBlockTypes.add(blockType);
1099
+ }
1100
+ }
1101
+ if (preparedData.localizedData) {
1102
+ for (const localeData of Object.values(preparedData.localizedData)){
1103
+ for (const blockType of collectProposalBlockTypes({
1104
+ data: localeData,
1105
+ fields: collectionFields
1106
+ })){
1107
+ proposalBlockTypes.add(blockType);
1108
+ }
1109
+ }
1110
+ }
1111
+ const missingBlockTypes = requestedBlockTypes.filter((blockType)=>!proposalBlockTypes.has(blockType));
1112
+ if (missingBlockTypes.length > 0) {
1113
+ return createToolError({
1114
+ collection,
1115
+ details: {
1116
+ missingBlockTypes,
1117
+ requestedBlockTypes
1118
+ },
1119
+ 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.`,
1120
+ tool: "proposeCreateDoc"
1121
+ });
1122
+ }
1123
+ }
1124
+ const uploadTargetsOutsideAttachments = [
1125
+ ...preparedData.data ? getUploadTargetsOutsideAttachments({
1126
+ allowedAttachmentKeys,
1127
+ data: preparedData.data,
1128
+ fields: collectionFields
1129
+ }) : [],
1130
+ ...preparedData.localizedData ? Object.values(preparedData.localizedData).flatMap((localeData)=>getUploadTargetsOutsideAttachments({
1131
+ allowedAttachmentKeys,
1132
+ data: localeData,
1133
+ fields: collectionFields
1134
+ })) : []
1135
+ ];
1136
+ if (uploadTargetsOutsideAttachments.length > 0) {
1137
+ return createToolError({
1138
+ collection,
1139
+ details: {
1140
+ allowedAttachments: [
1141
+ ...allowedAttachmentKeys
1142
+ ],
1143
+ uploadTargetsOutsideAttachments
1144
+ },
1145
+ message: `Create proposal for ${collection} uses upload references that are not in the uploaded attachments: ${uploadTargetsOutsideAttachments.map((target)=>`${target.path} -> ${target.collection}:${target.id}`).join(", ")}. Use only uploaded media attachment IDs for upload fields.`,
1146
+ tool: "proposeCreateDoc"
1147
+ });
1148
+ }
1149
+ const invalidRelationshipTargets = [
1150
+ ...preparedData.data ? await validateRelationshipTargetsExist({
1151
+ data: preparedData.data,
1152
+ fields: collectionFields,
1153
+ req
1154
+ }) : [],
1155
+ ...preparedData.localizedData ? (await Promise.all(Object.values(preparedData.localizedData).map((localeData)=>validateRelationshipTargetsExist({
1156
+ data: localeData,
1157
+ fields: collectionFields,
1158
+ req
1159
+ })))).flat() : []
1160
+ ];
1161
+ if (invalidRelationshipTargets.length > 0) {
1162
+ return createToolError({
1163
+ collection,
1164
+ details: {
1165
+ invalidRelationshipTargets
1166
+ },
1167
+ message: `Create proposal for ${collection} contains relationship or upload references that do not exist: ${invalidRelationshipTargets.map((target)=>`${target.path} -> ${target.collection}:${target.id}`).join(", ")}.`,
1168
+ tool: "proposeCreateDoc"
1169
+ });
1170
+ }
1171
+ const proposal = preparedData.localizedData ? {
482
1172
  action: "create",
483
1173
  collection,
484
1174
  label: getSafeProposalLabel(label),
485
- localizedData: completedCreatePayload.localizedData,
1175
+ localizedData: preparedData.localizedData,
486
1176
  ...activeLocale ? {
487
1177
  locale: activeLocale
488
1178
  } : {}
489
1179
  } : {
490
1180
  action: "create",
491
1181
  collection,
492
- data: completedCreatePayload.data || {},
1182
+ data: preparedData.data || {},
493
1183
  label: getSafeProposalLabel(label),
494
1184
  ...activeLocale ? {
495
1185
  locale: activeLocale
@@ -499,7 +1189,7 @@ export const createChatHandler = (options = {})=>async (req)=>{
499
1189
  }
500
1190
  },
501
1191
  proposeDeleteDoc: {
502
- description: "Prepare a CMS document deletion proposal. This does not write to the database.",
1192
+ description: "Propose document deletion.",
503
1193
  inputSchema: z.object({
504
1194
  collection: collectionSlugSchema,
505
1195
  id: z.string().min(1),
@@ -521,26 +1211,93 @@ export const createChatHandler = (options = {})=>async (req)=>{
521
1211
  }
522
1212
  },
523
1213
  proposeUpdateDoc: {
524
- description: "Prepare a CMS document update proposal. This does not write to the database. Use exact field names from listCollections. For array fields, provide arrays of objects matching their child fields. For richText fields, prefer plain text or omit if unsure. Use localizedData when writing multiple locales in one proposal.",
1214
+ description: "Propose document update. Use exact schema fields. Use localizedData for multi-locale writes.",
525
1215
  inputSchema: z.object({
526
1216
  collection: collectionSlugSchema,
527
1217
  data: z.record(z.string(), z.unknown()).optional(),
528
1218
  id: z.string().min(1),
529
1219
  label: z.string().min(1),
530
- localizedData: z.record(z.string(), z.record(z.string(), z.unknown())).optional()
1220
+ localizedData: nonEmptyLocalizedDataSchema.optional()
531
1221
  }).refine((value)=>Boolean(value.data || value.localizedData), {
532
1222
  message: "Either data or localizedData is required."
533
1223
  }),
534
1224
  execute: async ({ collection, data, id, label, localizedData })=>{
535
1225
  const permissionError = getDisallowedCollectionActionError(collection, "update");
536
1226
  if (permissionError) return permissionError;
1227
+ const collectionConfig = allowedCollectionsBySlug.get(collection);
1228
+ const collectionFields = collectionConfig?.fields || [];
1229
+ const preparedData = prepareProposalWriteData({
1230
+ collectionConfig: collectionConfig,
1231
+ data,
1232
+ inferenceText: prompt,
1233
+ label,
1234
+ localizedData,
1235
+ mode: "update"
1236
+ });
1237
+ if (preparedData.issues.length > 0) {
1238
+ return createToolError({
1239
+ collection,
1240
+ details: {
1241
+ issues: preparedData.issues
1242
+ },
1243
+ message: `Update proposal for ${collection} is invalid. Retry with exact schema fields and complete array/block objects: ${formatProposalIssuesForRetry(preparedData.issues)}`,
1244
+ tool: "proposeUpdateDoc"
1245
+ });
1246
+ }
1247
+ const invalidRelationshipTargets = [
1248
+ ...preparedData.data ? await validateRelationshipTargetsExist({
1249
+ data: preparedData.data,
1250
+ fields: collectionFields,
1251
+ req
1252
+ }) : [],
1253
+ ...preparedData.localizedData ? (await Promise.all(Object.values(preparedData.localizedData).map((localeData)=>validateRelationshipTargetsExist({
1254
+ data: localeData,
1255
+ fields: collectionFields,
1256
+ req
1257
+ })))).flat() : []
1258
+ ];
1259
+ if (invalidRelationshipTargets.length > 0) {
1260
+ return createToolError({
1261
+ collection,
1262
+ details: {
1263
+ invalidRelationshipTargets
1264
+ },
1265
+ message: `Update proposal for ${collection} contains relationship or upload references that do not exist: ${invalidRelationshipTargets.map((target)=>`${target.path} -> ${target.collection}:${target.id}`).join(", ")}.`,
1266
+ tool: "proposeUpdateDoc"
1267
+ });
1268
+ }
1269
+ const uploadTargetsOutsideAttachments = [
1270
+ ...preparedData.data ? getUploadTargetsOutsideAttachments({
1271
+ allowedAttachmentKeys,
1272
+ data: preparedData.data,
1273
+ fields: collectionFields
1274
+ }) : [],
1275
+ ...preparedData.localizedData ? Object.values(preparedData.localizedData).flatMap((localeData)=>getUploadTargetsOutsideAttachments({
1276
+ allowedAttachmentKeys,
1277
+ data: localeData,
1278
+ fields: collectionFields
1279
+ })) : []
1280
+ ];
1281
+ if (uploadTargetsOutsideAttachments.length > 0) {
1282
+ return createToolError({
1283
+ collection,
1284
+ details: {
1285
+ allowedAttachments: [
1286
+ ...allowedAttachmentKeys
1287
+ ],
1288
+ uploadTargetsOutsideAttachments
1289
+ },
1290
+ message: `Update proposal for ${collection} uses upload references that are not in the uploaded attachments: ${uploadTargetsOutsideAttachments.map((target)=>`${target.path} -> ${target.collection}:${target.id}`).join(", ")}. Use only uploaded media attachment IDs for upload fields.`,
1291
+ tool: "proposeUpdateDoc"
1292
+ });
1293
+ }
537
1294
  const proposal = {
538
1295
  action: "update",
539
1296
  collection,
540
- ...localizedData ? {
541
- localizedData
1297
+ ...preparedData.localizedData ? {
1298
+ localizedData: preparedData.localizedData
542
1299
  } : {
543
- data: data || {}
1300
+ data: preparedData.data || {}
544
1301
  },
545
1302
  id,
546
1303
  label: getSafeProposalLabel(label),
@@ -552,26 +1309,51 @@ export const createChatHandler = (options = {})=>async (req)=>{
552
1309
  }
553
1310
  },
554
1311
  proposeUpdateGlobal: {
555
- description: "Prepare a Payload global update proposal. This does not write to the database. Use localizedData when writing multiple locales in one proposal.",
1312
+ description: "Propose global update. Use localizedData for multi-locale writes.",
556
1313
  inputSchema: z.object({
557
1314
  data: z.record(z.string(), z.unknown()).optional(),
558
1315
  label: z.string().min(1),
559
- localizedData: z.record(z.string(), z.record(z.string(), z.unknown())).optional(),
1316
+ localizedData: nonEmptyLocalizedDataSchema.optional(),
560
1317
  slug: z.string().min(1)
561
1318
  }).refine((value)=>Boolean(value.data || value.localizedData), {
562
1319
  message: "Either data or localizedData is required."
563
1320
  }),
564
1321
  execute: async ({ data, label, localizedData, slug })=>{
565
- const globalConfig = req.payload.config.globals?.find((global)=>global.slug === slug);
566
- if (!globalConfig) return {
567
- error: `Unknown global: ${slug}`
568
- };
1322
+ const globalConfig = globalConfigsBySlug.get(slug);
1323
+ if (!globalConfig) {
1324
+ return createToolError({
1325
+ message: `Unknown global: ${slug}`,
1326
+ slug,
1327
+ tool: "proposeUpdateGlobal"
1328
+ });
1329
+ }
1330
+ const preparedData = prepareProposalWriteData({
1331
+ collectionConfig: {
1332
+ fields: globalConfig.fields || [],
1333
+ slug: globalConfig.slug
1334
+ },
1335
+ data,
1336
+ inferenceText: prompt,
1337
+ label,
1338
+ localizedData,
1339
+ mode: "update"
1340
+ });
1341
+ if (preparedData.issues.length > 0) {
1342
+ return createToolError({
1343
+ details: {
1344
+ issues: preparedData.issues
1345
+ },
1346
+ message: `Update proposal for global ${slug} is invalid. Retry with exact schema fields and complete array/block objects: ${formatProposalIssuesForRetry(preparedData.issues)}`,
1347
+ slug,
1348
+ tool: "proposeUpdateGlobal"
1349
+ });
1350
+ }
569
1351
  const proposal = {
570
1352
  action: "updateGlobal",
571
- ...localizedData ? {
572
- localizedData
1353
+ ...preparedData.localizedData ? {
1354
+ localizedData: preparedData.localizedData
573
1355
  } : {
574
- data: data || {}
1356
+ data: preparedData.data || {}
575
1357
  },
576
1358
  label: getSafeProposalLabel(label),
577
1359
  ...activeLocale ? {
@@ -583,19 +1365,24 @@ export const createChatHandler = (options = {})=>async (req)=>{
583
1365
  }
584
1366
  },
585
1367
  searchDocs: {
586
- description: "Search documents in one collection. Use query for a loose text search where possible.",
1368
+ description: "Search documents in one collection.",
587
1369
  inputSchema: z.object({
588
1370
  collection: collectionSlugSchema,
589
1371
  limit: z.number().int().min(1).max(10).default(5),
590
1372
  query: z.string().optional()
591
1373
  }),
592
1374
  execute: async ({ collection, limit, query })=>{
593
- const collectionConfig = allowedCollections.find((item)=>item.slug === collection);
594
- const searchableFields = collectionConfig?.fields.filter((field)=>"name" in field && [
1375
+ const collectionConfig = allowedCollectionsBySlug.get(collection);
1376
+ const searchableFields = collectionConfig?.fields.flatMap((field)=>{
1377
+ if (!("name" in field) || ![
595
1378
  "email",
596
1379
  "text",
597
1380
  "textarea"
598
- ].includes(field.type)).map((field)=>"name" in field ? field.name : null).filter(Boolean) || [];
1381
+ ].includes(field.type) || !field.name) return [];
1382
+ return [
1383
+ field.name
1384
+ ];
1385
+ }) || [];
599
1386
  const where = query && searchableFields.length > 0 ? {
600
1387
  or: searchableFields.map((field)=>({
601
1388
  [field]: {
@@ -638,12 +1425,25 @@ export const createChatHandler = (options = {})=>async (req)=>{
638
1425
  "You are a Payload CMS assistant. Inspect schema/content with tools before proposing writes.",
639
1426
  "Mentions define the active CMS scope. Locale mentions define active locale; multiple locales require localizedData keyed by locale.",
640
1427
  "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.",
1428
+ "Uploaded media attachments appear in context as mediaAttachment entries. Use their exact IDs for upload fields.",
1429
+ "If an uploaded media document has editable descriptive fields, propose an update to that media document with suitable values.",
1430
+ "If a collection has a required `slug` field and the user does not provide it, generate a URL‑friendly slug from the title or from the first meaningful word of the prompt.",
1431
+ "For create/update/delete requests, you must use proposal tools. Do not end with plain text if the user asked for a content change.",
1432
+ "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.",
1433
+ "Put concrete field values only in tool data, not visible text.",
1434
+ "For blocks fields: use exact blockType values from schema, exact field names, and complete objects for required block fields.",
1435
+ "For arrays: every item must be an object matching the child field schema, not free text.",
1436
+ "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.",
1437
+ `Collection aliases: ${JSON.stringify(collectionAliasMap)}.`,
1438
+ `Likely collection matches for this prompt: ${JSON.stringify(likelyCollectionMatches)}.`,
643
1439
  `Focused required create fields: ${JSON.stringify(focusedRequiredFieldsByCollection)}.`,
644
1440
  `Focused title fields: ${JSON.stringify(focusedTitleFieldByCollection)}. Infer concise titles when needed.`,
1441
+ `Preferred proposal tool for this prompt: ${intentToolChoice?.toolName || "none"}.`,
645
1442
  "Visible response: plain text, under 40 words, no Markdown, no proposed content."
646
1443
  ].join("\n"),
1444
+ ...intentToolChoice ? {
1445
+ toolChoice: intentToolChoice
1446
+ } : {},
647
1447
  tools
648
1448
  });
649
1449
  const stream = new ReadableStream({
@@ -668,19 +1468,75 @@ export const createChatHandler = (options = {})=>async (req)=>{
668
1468
  if (part.type === "finish") {
669
1469
  const finishPart = part;
670
1470
  usage = finishPart.totalUsage || finishPart.usage || null;
1471
+ const reason = getChatCompletionReason({
1472
+ proposalCount: proposals.length,
1473
+ toolFailures,
1474
+ writeIntent
1475
+ });
1476
+ const debugPayload = createDebugPayload({
1477
+ activeLocale,
1478
+ debug,
1479
+ proposalCount: proposals.length,
1480
+ selectedLocales,
1481
+ toolFailures,
1482
+ usage,
1483
+ writeIntent
1484
+ });
1485
+ logHandlerEvent(req, proposals.length > 0 ? "info" : "warn", {
1486
+ activeLocale,
1487
+ debug,
1488
+ msg: proposals.length > 0 ? "AI chat completed with proposals" : "AI chat completed without proposals",
1489
+ proposalCount: proposals.length,
1490
+ proposals: proposals.map((proposal)=>getProposalSummary(proposal)),
1491
+ promptPreview: getLogPreview(prompt),
1492
+ reason,
1493
+ selectedLocales,
1494
+ toolFailureCount: toolFailures.length,
1495
+ toolFailures,
1496
+ usage
1497
+ });
671
1498
  sendEvent(controller, "proposals", {
672
1499
  proposals,
673
1500
  usage
674
1501
  });
1502
+ sendEvent(controller, "debug", debugPayload);
675
1503
  sendEvent(controller, "done", {});
676
1504
  didSendTerminalEvent = true;
677
1505
  }
678
1506
  }
679
1507
  if (!didSendTerminalEvent) {
1508
+ const reason = getChatCompletionReason({
1509
+ proposalCount: proposals.length,
1510
+ toolFailures,
1511
+ writeIntent
1512
+ });
1513
+ const debugPayload = createDebugPayload({
1514
+ activeLocale,
1515
+ debug,
1516
+ proposalCount: proposals.length,
1517
+ selectedLocales,
1518
+ toolFailures,
1519
+ usage,
1520
+ writeIntent
1521
+ });
1522
+ logHandlerEvent(req, proposals.length > 0 ? "info" : "warn", {
1523
+ activeLocale,
1524
+ debug,
1525
+ msg: proposals.length > 0 ? "AI chat completed with proposals" : "AI chat completed without proposals",
1526
+ proposalCount: proposals.length,
1527
+ proposals: proposals.map((proposal)=>getProposalSummary(proposal)),
1528
+ promptPreview: getLogPreview(prompt),
1529
+ reason,
1530
+ selectedLocales,
1531
+ toolFailureCount: toolFailures.length,
1532
+ toolFailures,
1533
+ usage
1534
+ });
680
1535
  sendEvent(controller, "proposals", {
681
1536
  proposals,
682
1537
  usage
683
1538
  });
1539
+ sendEvent(controller, "debug", debugPayload);
684
1540
  sendEvent(controller, "done", {});
685
1541
  }
686
1542
  } catch (err) {