@mvriu5/payload-ai 1.2.0 → 1.4.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.
Files changed (63) hide show
  1. package/README.md +118 -13
  2. package/dist/ai/providerOptions.d.ts +24 -1
  3. package/dist/ai/providerOptions.js +81 -0
  4. package/dist/ai/providerRuntime.d.ts +2 -1
  5. package/dist/ai/providerRuntime.js +5 -2
  6. package/dist/ai/tokenUsage.d.ts +38 -0
  7. package/dist/ai/tokenUsage.js +106 -0
  8. package/dist/components/Icons.d.ts +1 -8
  9. package/dist/components/Icons.js +10 -207
  10. package/dist/components/{ActionToast.d.ts → action-toast/ActionToast.d.ts} +5 -2
  11. package/dist/components/{ActionToast.js → action-toast/ActionToast.js} +81 -54
  12. package/dist/components/{ActionToast.module.css → action-toast/ActionToast.module.css} +0 -79
  13. package/dist/components/ai-input/AIInput.d.ts +5 -0
  14. package/dist/components/ai-input/AIInput.js +548 -0
  15. package/dist/components/{AIInput.module.css → ai-input/AIInput.module.css} +150 -44
  16. package/dist/components/ai-input/badge.d.ts +14 -0
  17. package/dist/components/ai-input/badge.js +85 -0
  18. package/dist/components/{AuditLogList.d.ts → audit-log-list/AuditLogList.d.ts} +4 -8
  19. package/dist/components/{AuditLogList.js → audit-log-list/AuditLogList.js} +56 -21
  20. package/dist/components/{AuditLogList.module.css → audit-log-list/AuditLogList.module.css} +11 -65
  21. package/dist/components/dashboard/Dashboard.d.ts +2 -0
  22. package/dist/components/dashboard/Dashboard.js +17 -0
  23. package/dist/components/dashboard/Dashboard.module.css +13 -0
  24. package/dist/components/{DiffDialog.d.ts → diff-dialog/DiffDialog.d.ts} +2 -2
  25. package/dist/components/{DiffDialog.js → diff-dialog/DiffDialog.js} +200 -189
  26. package/dist/components/{DiffDialog.module.css → diff-dialog/DiffDialog.module.css} +17 -35
  27. package/dist/components/hooks/useAIChatStream.d.ts +50 -0
  28. package/dist/components/hooks/useAIChatStream.js +226 -0
  29. package/dist/components/hooks/useAISettings.d.ts +6 -4
  30. package/dist/components/hooks/useAISettings.js +68 -27
  31. package/dist/components/hooks/useAuditLog.d.ts +11 -0
  32. package/dist/components/hooks/useAuditLog.js +41 -0
  33. package/dist/components/hooks/useDocumentMentionSuggestions.d.ts +1 -1
  34. package/dist/components/hooks/useDocumentMentionSuggestions.js +31 -27
  35. package/dist/components/hooks/useMentions.d.ts +58 -0
  36. package/dist/components/hooks/useMentions.js +276 -0
  37. package/dist/components/hooks/usePluginConfig.d.ts +40 -0
  38. package/dist/components/hooks/usePluginConfig.js +27 -0
  39. package/dist/components/{MentionPopover.d.ts → mention-popover/MentionPopover.d.ts} +2 -4
  40. package/dist/components/{MentionPopover.js → mention-popover/MentionPopover.js} +1 -8
  41. package/dist/components/{MentionPopover.module.css → mention-popover/MentionPopover.module.css} +1 -1
  42. package/dist/components/text-shimmer/TextShimmer.d.ts +9 -0
  43. package/dist/components/text-shimmer/TextShimmer.js +34 -0
  44. package/dist/components/text-shimmer/TextShimmer.module.css +19 -0
  45. package/dist/exports/client.d.ts +4 -2
  46. package/dist/exports/client.js +4 -2
  47. package/dist/handlers/applyActionHandler.js +27 -7
  48. package/dist/handlers/chatHandler.d.ts +4 -1
  49. package/dist/handlers/chatHandler.js +499 -74
  50. package/dist/handlers/mediaUploadHandler.d.ts +7 -0
  51. package/dist/handlers/mediaUploadHandler.js +99 -0
  52. package/dist/handlers/mentionSuggestionHandler.js +16 -14
  53. package/dist/handlers/proposalDiffHandler.js +41 -29
  54. package/dist/index.d.ts +12 -1
  55. package/dist/index.js +131 -12
  56. package/dist/payload/collectionPermissions.js +3 -1
  57. package/dist/payload/normalizeData.d.ts +0 -6
  58. package/dist/payload/normalizeData.js +25 -13
  59. package/dist/payload/proposalData.js +4 -1
  60. package/dist/payload/schemaContext.js +98 -43
  61. package/package.json +22 -20
  62. package/dist/components/AIInput.d.ts +0 -2
  63. package/dist/components/AIInput.js +0 -896
@@ -4,6 +4,7 @@ import { signAIActionProposal } from "../ai/proposalSigning.js";
4
4
  import { isAIProvider } from "../ai/providerOptions.js";
5
5
  import { getModel, getProviderConfig } from "../ai/providerRuntime.js";
6
6
  import { containsSensitiveData } from "../ai/sensitiveData.js";
7
+ import { getExceededTokenUsageLimit, recordTokenUsage } from "../ai/tokenUsage.js";
7
8
  import { isCollectionActionAllowed } from "../payload/collectionPermissions.js";
8
9
  import { prepareProposalWriteData } from "../payload/proposalData.js";
9
10
  import { buildPromptWithMentionContext, collectBlocks, describeCollectionLikeConfig, describeCollectionLikeSummary, getAllowedCollectionSlugs, getMentionContext } from "../payload/schemaContext.js";
@@ -46,7 +47,7 @@ const createDebugPayload = ({ activeLocale, debug, proposalCount, selectedLocale
46
47
  });
47
48
  const createE2EChatResponse = ({ prompt, selectedLocales })=>{
48
49
  const normalizedPrompt = prompt.toLowerCase();
49
- const wantsCreatePost = normalizedPrompt.includes("post") && (normalizedPrompt.includes("create") || normalizedPrompt.includes("erstell"));
50
+ const wantsCreatePost = normalizedPrompt.includes("post") && (normalizedPrompt.includes("create") || normalizedPrompt.includes("erstell") || normalizedPrompt.includes("apply flow") || normalizedPrompt.includes("locale review") || normalizedPrompt.includes("proposal review"));
50
51
  const mentionsMars = normalizedPrompt.includes("mars");
51
52
  const multipleLocales = selectedLocales.length > 1;
52
53
  const activeLocale = selectedLocales.at(-1);
@@ -241,14 +242,23 @@ const getMissingCreateFields = ({ data, localizedData, requiredFields })=>{
241
242
  ];
242
243
  }
243
244
  const missing = [];
245
+ const localizedRequiredFields = [];
246
+ const sharedRequiredFields = [];
247
+ for (const field of requiredFields){
248
+ if (field.localized) {
249
+ localizedRequiredFields.push(field);
250
+ } else {
251
+ sharedRequiredFields.push(field);
252
+ }
253
+ }
244
254
  for (const [locale, localeData] of locales){
245
- for (const field of requiredFields.filter((item)=>item.localized)){
255
+ for (const field of localizedRequiredFields){
246
256
  if (!hasValueAtPath(localeData, field.path)) {
247
257
  missing.push(`${locale}:${field.path}`);
248
258
  }
249
259
  }
250
260
  }
251
- for (const field of requiredFields.filter((item)=>!item.localized)){
261
+ for (const field of sharedRequiredFields){
252
262
  if (!hasValueAtPath(firstLocale[1], field.path)) {
253
263
  missing.push(`${firstLocale[0]}:${field.path}`);
254
264
  }
@@ -258,7 +268,9 @@ const getMissingCreateFields = ({ data, localizedData, requiredFields })=>{
258
268
  if (!data) return [
259
269
  "data is required"
260
270
  ];
261
- return requiredFields.filter((field)=>!hasValueAtPath(data, field.path)).map((field)=>field.path);
271
+ return requiredFields.flatMap((field)=>hasValueAtPath(data, field.path) ? [] : [
272
+ field.path
273
+ ]);
262
274
  };
263
275
  const getProposalSummary = (proposal)=>({
264
276
  action: proposal.action,
@@ -293,19 +305,28 @@ const getCollectionBlockTypes = (fields)=>{
293
305
  ...blockTypes
294
306
  ];
295
307
  };
308
+ const regexSpecialCharactersPattern = /[.*+?^${}()|[\]\\]/g;
296
309
  const getRequestedBlockTypes = ({ availableBlockTypes, mentions, prompt })=>{
297
310
  const requestedBlockTypes = new Set();
298
311
  const normalizedPrompt = prompt.toLowerCase();
312
+ const availableBlockTypeSet = new Set(availableBlockTypes);
313
+ const blockTypesByNormalizedSlug = new Map(availableBlockTypes.map((blockType)=>[
314
+ blockType.toLowerCase(),
315
+ blockType
316
+ ]));
317
+ const escapedBlockTypes = availableBlockTypes.map((blockType)=>blockType.replace(regexSpecialCharactersPattern, "\\$&")).join("|");
318
+ const blockPattern = escapedBlockTypes ? new RegExp(`\\b(${escapedBlockTypes})\\b(?:\\s+block)?`, "gi") : null;
299
319
  for (const mention of mentions || []){
300
- if (mention.type === "block" && mention.slug && availableBlockTypes.includes(mention.slug)) {
320
+ if (mention.type === "block" && mention.slug && availableBlockTypeSet.has(mention.slug)) {
301
321
  requestedBlockTypes.add(mention.slug);
302
322
  }
303
323
  }
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);
324
+ if (blockPattern) {
325
+ for (const match of normalizedPrompt.matchAll(blockPattern)){
326
+ const blockType = match[1] ? blockTypesByNormalizedSlug.get(match[1].toLowerCase()) : null;
327
+ if (blockType) {
328
+ requestedBlockTypes.add(blockType);
329
+ }
309
330
  }
310
331
  }
311
332
  return [
@@ -320,6 +341,10 @@ const collectProposalBlockTypes = ({ data, fields })=>{
320
341
  const fieldValue = value[field.name];
321
342
  if (fieldValue === undefined || fieldValue === null) continue;
322
343
  if (field.type === "blocks" && Array.isArray(fieldValue)) {
344
+ const blocksBySlug = new Map(field.blocks?.map((block)=>[
345
+ block.slug,
346
+ block
347
+ ]));
323
348
  for (const blockItem of fieldValue){
324
349
  if (!isRecord(blockItem)) continue;
325
350
  const blockType = typeof blockItem.blockType === "string" ? blockItem.blockType : typeof blockItem.type === "string" ? blockItem.type : typeof blockItem.slug === "string" ? blockItem.slug : null;
@@ -327,7 +352,7 @@ const collectProposalBlockTypes = ({ data, fields })=>{
327
352
  foundBlockTypes.add(blockType);
328
353
  }
329
354
  if (blockType) {
330
- const blockConfig = field.blocks?.find((candidate)=>candidate.slug === blockType);
355
+ const blockConfig = blocksBySlug.get(blockType);
331
356
  if (blockConfig?.fields?.length) {
332
357
  visitFields(blockConfig.fields, blockItem);
333
358
  }
@@ -410,11 +435,15 @@ const collectRelationshipTargets = ({ data, fields, path = "" })=>{
410
435
  continue;
411
436
  }
412
437
  if (field.type === "blocks" && Array.isArray(fieldValue) && field.blocks?.length) {
438
+ const blocksBySlug = new Map(field.blocks.map((block)=>[
439
+ block.slug,
440
+ block
441
+ ]));
413
442
  fieldValue.forEach((item, index)=>{
414
443
  if (!isRecord(item)) return;
415
444
  const blockType = typeof item.blockType === "string" ? item.blockType : typeof item.type === "string" ? item.type : typeof item.slug === "string" ? item.slug : null;
416
445
  if (!blockType) return;
417
- const blockConfig = field.blocks?.find((candidate)=>candidate.slug === blockType);
446
+ const blockConfig = blocksBySlug.get(blockType);
418
447
  if (!blockConfig?.fields?.length) return;
419
448
  targets.push(...collectRelationshipTargets({
420
449
  data: item,
@@ -426,13 +455,91 @@ const collectRelationshipTargets = ({ data, fields, path = "" })=>{
426
455
  }
427
456
  return targets;
428
457
  };
458
+ const collectUploadTargets = ({ data, fields, path = "" })=>{
459
+ const targets = [];
460
+ for (const field of fields){
461
+ if (!field.name) continue;
462
+ const fieldPath = path ? `${path}.${field.name}` : field.name;
463
+ const fieldValue = data[field.name];
464
+ if (fieldValue === undefined || fieldValue === null) continue;
465
+ if (field.type === "upload") {
466
+ const relationTargets = Array.isArray(field.relationTo) ? field.relationTo.filter((item)=>typeof item === "string") : typeof field.relationTo === "string" ? [
467
+ field.relationTo
468
+ ] : [];
469
+ const collectSingle = (value, itemPath)=>{
470
+ if (typeof value === "string" || typeof value === "number") {
471
+ if (relationTargets.length === 1) {
472
+ targets.push({
473
+ collection: relationTargets[0],
474
+ id: value,
475
+ path: itemPath
476
+ });
477
+ }
478
+ return;
479
+ }
480
+ if (!isRecord(value)) return;
481
+ const relationTo = typeof value.relationTo === "string" ? value.relationTo : relationTargets[0];
482
+ const id = typeof value.id === "string" || typeof value.id === "number" ? value.id : typeof value.value === "string" || typeof value.value === "number" ? value.value : undefined;
483
+ if (!relationTo || id === undefined) return;
484
+ targets.push({
485
+ collection: relationTo,
486
+ id,
487
+ path: itemPath
488
+ });
489
+ };
490
+ if (field.hasMany && Array.isArray(fieldValue)) {
491
+ fieldValue.forEach((item, index)=>collectSingle(item, `${fieldPath}.${index}`));
492
+ } else {
493
+ collectSingle(fieldValue, fieldPath);
494
+ }
495
+ continue;
496
+ }
497
+ if (field.type === "group" && isRecord(fieldValue) && field.fields?.length) {
498
+ targets.push(...collectUploadTargets({
499
+ data: fieldValue,
500
+ fields: field.fields,
501
+ path: fieldPath
502
+ }));
503
+ continue;
504
+ }
505
+ if (field.type === "array" && Array.isArray(fieldValue) && field.fields?.length) {
506
+ fieldValue.forEach((item, index)=>{
507
+ if (!isRecord(item)) return;
508
+ targets.push(...collectUploadTargets({
509
+ data: item,
510
+ fields: field.fields,
511
+ path: `${fieldPath}.${index}`
512
+ }));
513
+ });
514
+ continue;
515
+ }
516
+ if (field.type === "blocks" && Array.isArray(fieldValue) && field.blocks?.length) {
517
+ const blocksBySlug = new Map(field.blocks.map((block)=>[
518
+ block.slug,
519
+ block
520
+ ]));
521
+ fieldValue.forEach((item, index)=>{
522
+ if (!isRecord(item)) return;
523
+ const blockType = typeof item.blockType === "string" ? item.blockType : typeof item.type === "string" ? item.type : typeof item.slug === "string" ? item.slug : null;
524
+ if (!blockType) return;
525
+ const blockConfig = blocksBySlug.get(blockType);
526
+ if (!blockConfig?.fields?.length) return;
527
+ targets.push(...collectUploadTargets({
528
+ data: item,
529
+ fields: blockConfig.fields,
530
+ path: `${fieldPath}.${index}`
531
+ }));
532
+ });
533
+ }
534
+ }
535
+ return targets;
536
+ };
429
537
  const validateRelationshipTargetsExist = async ({ data, fields, req })=>{
430
538
  const targets = collectRelationshipTargets({
431
539
  data,
432
540
  fields
433
541
  });
434
- const invalidTargets = [];
435
- for (const target of targets){
542
+ const targetResults = await Promise.all(targets.map(async (target)=>{
436
543
  try {
437
544
  await req.payload.findByID({
438
545
  collection: target.collection,
@@ -441,11 +548,19 @@ const validateRelationshipTargetsExist = async ({ data, fields, req })=>{
441
548
  overrideAccess: false,
442
549
  req
443
550
  });
551
+ return null;
444
552
  } catch {
445
- invalidTargets.push(target);
553
+ return target;
446
554
  }
447
- }
448
- return invalidTargets;
555
+ }));
556
+ return targetResults.filter((target)=>Boolean(target));
557
+ };
558
+ const getUploadTargetsOutsideAttachments = ({ allowedAttachmentKeys, data, fields })=>{
559
+ if (allowedAttachmentKeys.size === 0) return [];
560
+ return collectUploadTargets({
561
+ data,
562
+ fields
563
+ }).filter((target)=>!allowedAttachmentKeys.has(`${target.collection}:${String(target.id)}`));
449
564
  };
450
565
  const getMentionSummary = (mentions)=>mentions?.map((mention)=>({
451
566
  collection: "collection" in mention ? mention.collection : undefined,
@@ -453,6 +568,39 @@ const getMentionSummary = (mentions)=>mentions?.map((mention)=>({
453
568
  slug: mention.slug,
454
569
  type: mention.type
455
570
  })) || [];
571
+ const getMediaAttachmentContext = async ({ allowedCollectionsBySlug, attachments, collections, req })=>{
572
+ if (!attachments?.length) return [];
573
+ const contexts = [];
574
+ const seen = new Set();
575
+ for (const attachment of attachments.slice(0, 8)){
576
+ if (attachment.type !== "media" || !attachment.collection || !attachment.id) continue;
577
+ const key = `${attachment.collection}:${attachment.id}`;
578
+ if (seen.has(key)) continue;
579
+ const collectionConfig = allowedCollectionsBySlug.get(attachment.collection);
580
+ if (!collectionConfig?.upload) continue;
581
+ const doc = await req.payload.findByID({
582
+ collection: attachment.collection,
583
+ depth: 1,
584
+ id: attachment.id,
585
+ overrideAccess: false,
586
+ req
587
+ }).catch(()=>null);
588
+ if (!doc) continue;
589
+ seen.add(key);
590
+ contexts.push({
591
+ attachment,
592
+ collection: attachment.collection,
593
+ doc,
594
+ schema: describeCollectionLikeConfig({
595
+ config: collectionConfig,
596
+ permissions: collections,
597
+ type: "collection"
598
+ }),
599
+ type: "mediaAttachment"
600
+ });
601
+ }
602
+ return contexts;
603
+ };
456
604
  const formatProposalIssuesForRetry = (issues)=>{
457
605
  return issues.slice(0, 6).map((issue)=>{
458
606
  switch(issue.code){
@@ -500,9 +648,19 @@ const createCollectionAliasMap = (collections)=>{
500
648
  };
501
649
  const getLikelyCollectionMatches = ({ aliasMap, prompt })=>{
502
650
  const normalizedPrompt = prompt.toLowerCase();
503
- const matches = Object.entries(aliasMap).filter(([alias])=>normalizedPrompt.includes(alias)).map(([, slug])=>slug);
651
+ const matches = new Set();
652
+ const aliases = Object.keys(aliasMap).sort((a, b)=>b.length - a.length);
653
+ const aliasPattern = aliases.length > 0 ? new RegExp(aliases.map((alias)=>alias.replace(regexSpecialCharactersPattern, "\\$&")).join("|"), "g") : null;
654
+ if (!aliasPattern) return [];
655
+ for (const match of normalizedPrompt.matchAll(aliasPattern)){
656
+ const alias = match[0];
657
+ const slug = aliasMap[alias];
658
+ if (slug) {
659
+ matches.add(slug);
660
+ }
661
+ }
504
662
  return [
505
- ...new Set(matches)
663
+ ...matches
506
664
  ];
507
665
  };
508
666
  const hasWriteIntent = (prompt)=>{
@@ -517,15 +675,17 @@ const getIntentToolChoice = (prompt)=>{
517
675
  type: "tool"
518
676
  };
519
677
  }
520
- if (/\b(create|build|generate|write|draft|make|add)\b/.test(normalizedPrompt)) {
678
+ // Update existing document (add block, modify content, etc.)
679
+ if (/\b(update|edit|change|modify|einfügen|hinzufügen|addieren)\b/.test(normalizedPrompt)) {
521
680
  return {
522
- toolName: "proposeCreateDoc",
681
+ toolName: "proposeUpdateDoc",
523
682
  type: "tool"
524
683
  };
525
684
  }
526
- if (/\b(update|edit|change|revise|refine|rewrite|translate)\b/.test(normalizedPrompt)) {
685
+ // Create a new document
686
+ if (/\b(create|build|generate|write|draft|make|add|füge|einfügen|hinzufügen|addieren)\b/.test(normalizedPrompt)) {
527
687
  return {
528
- toolName: "proposeUpdateDoc",
688
+ toolName: "proposeCreateDoc",
529
689
  type: "tool"
530
690
  };
531
691
  }
@@ -544,7 +704,13 @@ export const createChatHandler = (options = {})=>async (req)=>{
544
704
  }, {
545
705
  status: 400
546
706
  });
547
- const selectedLocales = (body?.mentions?.filter((mention)=>mention.type === "locale" && mention.slug).map((mention)=>mention.slug) || []).filter((locale, index, array)=>array.indexOf(locale) === index);
707
+ const selectedLocales = [];
708
+ const selectedLocaleSet = new Set();
709
+ for (const mention of body?.mentions || []){
710
+ if (mention.type !== "locale" || !mention.slug || selectedLocaleSet.has(mention.slug)) continue;
711
+ selectedLocaleSet.add(mention.slug);
712
+ selectedLocales.push(mention.slug);
713
+ }
548
714
  const activeLocale = selectedLocales.at(-1);
549
715
  const mentionSummary = getMentionSummary(body?.mentions);
550
716
  if (e2eModeEnabled()) {
@@ -554,23 +720,67 @@ export const createChatHandler = (options = {})=>async (req)=>{
554
720
  });
555
721
  }
556
722
  const user = req.user;
557
- const requestedProvider = body?.provider || user.aiProvider || "openai";
558
- if (!isAIProvider(requestedProvider)) return Response.json({
559
- error: `Unsupported AI provider: ${requestedProvider}`
560
- }, {
561
- status: 400
723
+ const exceededTokenUsageLimit = await getExceededTokenUsageLimit({
724
+ maxTokenUsage: options.maxTokenUsage,
725
+ req,
726
+ userID: user.id
562
727
  });
563
- const provider = requestedProvider;
564
- const userApiKey = options.allowUserApiKeys === false ? null : user.aiApiKey;
728
+ if (exceededTokenUsageLimit) {
729
+ const scope = options.maxTokenUsage?.type === "site" ? "site" : "user";
730
+ const periodLabel = exceededTokenUsageLimit.period === "day" ? "Daily" : "Weekly";
731
+ logHandlerEvent(req, "warn", {
732
+ limit: exceededTokenUsageLimit.limit,
733
+ msg: "AI chat blocked: token usage limit reached",
734
+ period: exceededTokenUsageLimit.period,
735
+ scope,
736
+ used: exceededTokenUsageLimit.used,
737
+ userID: String(user.id)
738
+ });
739
+ return Response.json({
740
+ error: `${periodLabel} AI token limit reached for this ${scope}.`,
741
+ limit: exceededTokenUsageLimit.limit,
742
+ period: exceededTokenUsageLimit.period,
743
+ used: exceededTokenUsageLimit.used
744
+ }, {
745
+ status: 429
746
+ });
747
+ }
748
+ const managedProviders = options.providers?.length ? options.providers : null;
749
+ const requestedProvider = body?.provider || (managedProviders ? managedProviders[0].id : user.aiProvider || "openai");
750
+ const managedProvider = managedProviders?.find((providerConfig)=>providerConfig.id === requestedProvider);
751
+ if (managedProviders && !managedProvider) {
752
+ return Response.json({
753
+ error: `Unsupported AI provider: ${requestedProvider}`
754
+ }, {
755
+ status: 400
756
+ });
757
+ }
758
+ if (!managedProvider && !isAIProvider(requestedProvider)) {
759
+ return Response.json({
760
+ error: `Unsupported AI provider: ${requestedProvider}`
761
+ }, {
762
+ status: 400
763
+ });
764
+ }
765
+ const provider = managedProvider?.provider || requestedProvider;
766
+ const requestedModel = body?.model || managedProvider?.defaultModel;
767
+ if (managedProvider && requestedModel && !managedProvider.models.some((model)=>model.value === requestedModel)) {
768
+ return Response.json({
769
+ error: `Unsupported model "${requestedModel}" for AI provider "${managedProvider.id}".`
770
+ }, {
771
+ status: 400
772
+ });
773
+ }
774
+ const userApiKey = managedProvider ? managedProvider.apiKey : options.allowUserApiKeys === false ? null : user.aiApiKey;
565
775
  const providerConfig = getProviderConfig({
566
776
  apiKey: userApiKey,
567
777
  defaultModels: options.models?.defaults,
568
- model: body?.model,
778
+ model: requestedModel,
569
779
  provider
570
780
  });
571
781
  const debug = {
572
782
  model: providerConfig.modelID,
573
- provider,
783
+ provider: managedProvider?.id || provider,
574
784
  tools: [
575
785
  "getDoc",
576
786
  "getGlobal",
@@ -601,7 +811,7 @@ export const createChatHandler = (options = {})=>async (req)=>{
601
811
  selectedLocales
602
812
  });
603
813
  return Response.json({
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.`
814
+ error: managedProvider ? `Configure a ${managedProvider?.id || provider} API key in the plugin config or server environment first.` : 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.`
605
815
  }, {
606
816
  status: 400
607
817
  });
@@ -654,10 +864,43 @@ export const createChatHandler = (options = {})=>async (req)=>{
654
864
  });
655
865
  return signedProposal;
656
866
  };
657
- const collectionSlugs = getAllowedCollectionSlugs(req, options.collections);
658
- const globalSlugs = req.payload.config.globals?.map((global)=>global.slug) || [];
659
- const allowedCollections = req.payload.config.collections.filter((collection)=>collectionSlugs.includes(collection.slug));
660
- if (collectionSlugs.length === 0) {
867
+ const requestedDocumentScope = body?.documentScope;
868
+ const configuredCollectionSlugs = getAllowedCollectionSlugs(req, options.collections);
869
+ const configuredCollectionSlugSet = new Set(configuredCollectionSlugs);
870
+ const allGlobalConfigs = req.payload.config.globals || [];
871
+ const requestedCollectionSlug = requestedDocumentScope?.type === "collection" && typeof requestedDocumentScope.collection === "string" ? requestedDocumentScope.collection.trim() : undefined;
872
+ const requestedGlobalSlug = requestedDocumentScope?.type === "global" && typeof requestedDocumentScope.slug === "string" ? requestedDocumentScope.slug.trim() : undefined;
873
+ const requestedDocumentID = requestedDocumentScope?.type === "collection" && typeof requestedDocumentScope.id === "string" ? requestedDocumentScope.id.trim() : undefined;
874
+ if (requestedDocumentScope?.type === "collection" && (!requestedCollectionSlug || !configuredCollectionSlugSet.has(requestedCollectionSlug))) {
875
+ return Response.json({
876
+ error: "The current collection is not available to the AI assistant."
877
+ }, {
878
+ status: 400
879
+ });
880
+ }
881
+ if (requestedDocumentScope?.type === "global" && (!requestedGlobalSlug || !allGlobalConfigs.some((global)=>global.slug === requestedGlobalSlug))) {
882
+ return Response.json({
883
+ error: "The current global is not available to the AI assistant."
884
+ }, {
885
+ status: 400
886
+ });
887
+ }
888
+ const collectionSlugs = requestedDocumentScope ? requestedCollectionSlug ? [
889
+ requestedCollectionSlug
890
+ ] : [] : configuredCollectionSlugs;
891
+ const collectionSlugSet = new Set(collectionSlugs);
892
+ const globalConfigs = requestedDocumentScope ? requestedGlobalSlug ? allGlobalConfigs.filter((global)=>global.slug === requestedGlobalSlug) : [] : allGlobalConfigs;
893
+ const globalSlugs = globalConfigs.map((global)=>global.slug);
894
+ const globalConfigsBySlug = new Map(globalConfigs.map((global)=>[
895
+ global.slug,
896
+ global
897
+ ]));
898
+ const allowedCollections = req.payload.config.collections.filter((collection)=>collectionSlugSet.has(collection.slug));
899
+ const allowedCollectionsBySlug = new Map(allowedCollections.map((collection)=>[
900
+ collection.slug,
901
+ collection
902
+ ]));
903
+ if (collectionSlugs.length === 0 && globalConfigs.length === 0) {
661
904
  logHandlerEvent(req, "warn", {
662
905
  debug,
663
906
  msg: "AI chat blocked: no AI-enabled collections configured"
@@ -673,10 +916,10 @@ export const createChatHandler = (options = {})=>async (req)=>{
673
916
  fields: collection.fields,
674
917
  parent: collection.slug
675
918
  })),
676
- ...req.payload.config.globals?.flatMap((global)=>collectBlocks({
919
+ ...globalConfigs.flatMap((global)=>collectBlocks({
677
920
  fields: global.fields,
678
921
  parent: global.slug
679
- })) || []
922
+ }))
680
923
  ];
681
924
  const mentionContext = await getMentionContext({
682
925
  blockContexts,
@@ -686,15 +929,60 @@ export const createChatHandler = (options = {})=>async (req)=>{
686
929
  mentions: body?.mentions,
687
930
  req
688
931
  });
689
- const mentionedCollectionSlugs = (body?.mentions?.flatMap((mention)=>{
690
- if (mention.type === "collection" && mention.slug) return [
691
- mention.slug
692
- ];
693
- if (mention.type === "doc" && mention.collection) return [
694
- mention.collection
695
- ];
696
- return [];
697
- }).filter((slug)=>collectionSlugs.includes(slug)) || []).filter((slug, index, array)=>array.indexOf(slug) === index);
932
+ if (requestedCollectionSlug && requestedDocumentID) {
933
+ const currentDocument = await req.payload.findByID({
934
+ collection: requestedCollectionSlug,
935
+ depth: 2,
936
+ id: requestedDocumentID,
937
+ ...activeLocale ? {
938
+ locale: activeLocale
939
+ } : {},
940
+ overrideAccess: false,
941
+ req
942
+ });
943
+ mentionContext.push({
944
+ collection: requestedCollectionSlug,
945
+ document: currentDocument,
946
+ id: requestedDocumentID,
947
+ type: "currentDocument"
948
+ });
949
+ } else if (requestedGlobalSlug) {
950
+ const currentGlobal = await req.payload.findGlobal({
951
+ depth: 2,
952
+ ...activeLocale ? {
953
+ locale: activeLocale
954
+ } : {},
955
+ overrideAccess: false,
956
+ req,
957
+ slug: requestedGlobalSlug
958
+ });
959
+ mentionContext.push({
960
+ global: currentGlobal,
961
+ slug: requestedGlobalSlug,
962
+ type: "currentGlobal"
963
+ });
964
+ }
965
+ const mediaAttachmentContext = await getMediaAttachmentContext({
966
+ allowedCollectionsBySlug,
967
+ attachments: body?.attachments,
968
+ collections: options.collections,
969
+ req
970
+ });
971
+ const allowedAttachmentKeys = new Set(mediaAttachmentContext.flatMap((context)=>{
972
+ const attachment = context.attachment;
973
+ return isRecord(attachment) && typeof attachment.collection === "string" && typeof attachment.id === "string" ? [
974
+ `${attachment.collection}:${attachment.id}`
975
+ ] : [];
976
+ }));
977
+ mentionContext.push(...mediaAttachmentContext);
978
+ const mentionedCollectionSlugs = [];
979
+ const mentionedCollectionSlugSet = new Set();
980
+ for (const mention of body?.mentions || []){
981
+ const slug = mention.type === "collection" && mention.slug ? mention.slug : mention.type === "doc" && mention.collection ? mention.collection : null;
982
+ if (!slug || !collectionSlugSet.has(slug) || mentionedCollectionSlugSet.has(slug)) continue;
983
+ mentionedCollectionSlugSet.add(slug);
984
+ mentionedCollectionSlugs.push(slug);
985
+ }
698
986
  const createRequiredFieldsByCollection = Object.fromEntries(allowedCollections.map((collection)=>[
699
987
  collection.slug,
700
988
  getRequiredFieldInfos(collection.fields, collection.admin?.useAsTitle)
@@ -721,8 +1009,8 @@ export const createChatHandler = (options = {})=>async (req)=>{
721
1009
  prompt
722
1010
  });
723
1011
  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;
1012
+ const inferredCollectionSlug = requestedCollectionSlug || (mentionedCollectionSlugs.length === 1 ? mentionedCollectionSlugs[0] : mentionedCollectionSlugs.length === 0 && likelyCollectionMatches.length === 1 ? likelyCollectionMatches[0] : undefined);
1013
+ const inferredCollectionConfig = inferredCollectionSlug ? allowedCollectionsBySlug.get(inferredCollectionSlug) : undefined;
726
1014
  if (inferredCollectionConfig && !mentionContext.some((item)=>item.type === "collection" && item.slug === inferredCollectionConfig.slug)) {
727
1015
  mentionContext.push({
728
1016
  ...describeCollectionLikeConfig({
@@ -733,7 +1021,16 @@ export const createChatHandler = (options = {})=>async (req)=>{
733
1021
  inferredFromPrompt: true
734
1022
  });
735
1023
  }
736
- const intentToolChoice = inferredCollectionConfig ? getIntentToolChoice(prompt) : undefined;
1024
+ if (requestedGlobalSlug) {
1025
+ const currentGlobalConfig = globalConfigsBySlug.get(requestedGlobalSlug);
1026
+ if (currentGlobalConfig) {
1027
+ mentionContext.push(describeCollectionLikeConfig({
1028
+ config: currentGlobalConfig,
1029
+ type: "global"
1030
+ }));
1031
+ }
1032
+ }
1033
+ let intentToolChoice = inferredCollectionConfig ? getIntentToolChoice(prompt) : undefined;
737
1034
  logHandlerEvent(req, "info", {
738
1035
  activeLocale,
739
1036
  allowedCollectionCount: allowedCollections.length,
@@ -747,7 +1044,7 @@ export const createChatHandler = (options = {})=>async (req)=>{
747
1044
  selectedLocales,
748
1045
  writeIntent
749
1046
  });
750
- const collectionSlugSchema = z.enum(collectionSlugs);
1047
+ const collectionSlugSchema = collectionSlugs.length > 0 ? z.enum(collectionSlugs) : z.string().refine(()=>false, "No collection is in scope.");
751
1048
  const getDisallowedCollectionActionError = (collection, action)=>{
752
1049
  if (isCollectionActionAllowed({
753
1050
  action,
@@ -761,14 +1058,21 @@ export const createChatHandler = (options = {})=>async (req)=>{
761
1058
  tool: "collectionPermissionCheck"
762
1059
  });
763
1060
  };
764
- const tools = {
1061
+ const allTools = {
765
1062
  getDoc: {
766
- description: "Read one document by collection slug and document id.",
1063
+ description: "Read a document by collection and id.",
767
1064
  inputSchema: z.object({
768
1065
  collection: collectionSlugSchema,
769
1066
  id: z.string().min(1)
770
1067
  }),
771
1068
  execute: async ({ collection, id })=>{
1069
+ if (requestedDocumentScope && (collection !== requestedCollectionSlug || id !== requestedDocumentID)) {
1070
+ return createToolError({
1071
+ collection,
1072
+ message: "Only the current document can be read in this context.",
1073
+ tool: "getDoc"
1074
+ });
1075
+ }
772
1076
  return req.payload.findByID({
773
1077
  collection: collection,
774
1078
  depth: 2,
@@ -782,13 +1086,13 @@ export const createChatHandler = (options = {})=>async (req)=>{
782
1086
  }
783
1087
  },
784
1088
  listCollections: {
785
- description: "List AI-enabled Payload collections. Omit slug for compact summaries; pass slug to get full field schema for one collection.",
1089
+ description: "List collections; pass slug for one full schema.",
786
1090
  inputSchema: z.object({
787
1091
  slug: collectionSlugSchema.optional()
788
1092
  }),
789
1093
  execute: async ({ slug })=>{
790
1094
  if (slug) {
791
- const collection = allowedCollections.find((item)=>item.slug === slug);
1095
+ const collection = allowedCollectionsBySlug.get(slug);
792
1096
  if (!collection) {
793
1097
  return createToolError({
794
1098
  message: `Unknown collection: ${slug}`,
@@ -810,12 +1114,19 @@ export const createChatHandler = (options = {})=>async (req)=>{
810
1114
  }
811
1115
  },
812
1116
  getGlobal: {
813
- description: "Read one Payload CMS global by slug.",
1117
+ description: "Read a global by slug.",
814
1118
  inputSchema: z.object({
815
1119
  slug: z.string().min(1)
816
1120
  }),
817
1121
  execute: async ({ slug })=>{
818
- const globalConfig = req.payload.config.globals?.find((global)=>global.slug === slug);
1122
+ if (requestedDocumentScope && slug !== requestedGlobalSlug) {
1123
+ return createToolError({
1124
+ message: "Only the current global can be read in this context.",
1125
+ slug,
1126
+ tool: "getGlobal"
1127
+ });
1128
+ }
1129
+ const globalConfig = globalConfigsBySlug.get(slug);
819
1130
  if (!globalConfig) {
820
1131
  return createToolError({
821
1132
  message: `Unknown global: ${slug}`,
@@ -835,14 +1146,13 @@ export const createChatHandler = (options = {})=>async (req)=>{
835
1146
  }
836
1147
  },
837
1148
  listGlobals: {
838
- description: "List Payload globals. Omit slug for compact summaries; pass slug to get full field schema for one global.",
1149
+ description: "List globals; pass slug for one full schema.",
839
1150
  inputSchema: z.object({
840
1151
  slug: z.string().optional()
841
1152
  }),
842
1153
  execute: async ({ slug })=>{
843
- const globals = req.payload.config.globals || [];
844
1154
  if (slug) {
845
- const global = globals.find((item)=>item.slug === slug);
1155
+ const global = globalConfigsBySlug.get(slug);
846
1156
  if (!global) {
847
1157
  return createToolError({
848
1158
  message: `Unknown global: ${slug}`,
@@ -855,14 +1165,14 @@ export const createChatHandler = (options = {})=>async (req)=>{
855
1165
  type: "global"
856
1166
  });
857
1167
  }
858
- return globals.map((global)=>describeCollectionLikeSummary({
1168
+ return globalConfigs.map((global)=>describeCollectionLikeSummary({
859
1169
  config: global,
860
1170
  type: "global"
861
1171
  }));
862
1172
  }
863
1173
  },
864
1174
  proposeCreateDoc: {
865
- 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.",
1175
+ description: "Propose document creation. Use exact schema fields; include required fields. Use localizedData for multi-locale writes.",
866
1176
  inputSchema: z.object({
867
1177
  collection: collectionSlugSchema,
868
1178
  data: z.record(z.string(), z.unknown()).optional(),
@@ -874,7 +1184,7 @@ export const createChatHandler = (options = {})=>async (req)=>{
874
1184
  execute: async ({ collection, data, label, localizedData })=>{
875
1185
  const permissionError = getDisallowedCollectionActionError(collection, "create");
876
1186
  if (permissionError) return permissionError;
877
- const collectionConfig = allowedCollections.find((item)=>item.slug === collection);
1187
+ const collectionConfig = allowedCollectionsBySlug.get(collection);
878
1188
  const collectionFields = collectionConfig?.fields || [];
879
1189
  const preparedData = prepareProposalWriteData({
880
1190
  collectionConfig: collectionConfig,
@@ -935,6 +1245,31 @@ export const createChatHandler = (options = {})=>async (req)=>{
935
1245
  });
936
1246
  }
937
1247
  }
1248
+ const uploadTargetsOutsideAttachments = [
1249
+ ...preparedData.data ? getUploadTargetsOutsideAttachments({
1250
+ allowedAttachmentKeys,
1251
+ data: preparedData.data,
1252
+ fields: collectionFields
1253
+ }) : [],
1254
+ ...preparedData.localizedData ? Object.values(preparedData.localizedData).flatMap((localeData)=>getUploadTargetsOutsideAttachments({
1255
+ allowedAttachmentKeys,
1256
+ data: localeData,
1257
+ fields: collectionFields
1258
+ })) : []
1259
+ ];
1260
+ if (uploadTargetsOutsideAttachments.length > 0) {
1261
+ return createToolError({
1262
+ collection,
1263
+ details: {
1264
+ allowedAttachments: [
1265
+ ...allowedAttachmentKeys
1266
+ ],
1267
+ uploadTargetsOutsideAttachments
1268
+ },
1269
+ 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.`,
1270
+ tool: "proposeCreateDoc"
1271
+ });
1272
+ }
938
1273
  const invalidRelationshipTargets = [
939
1274
  ...preparedData.data ? await validateRelationshipTargetsExist({
940
1275
  data: preparedData.data,
@@ -978,13 +1313,20 @@ export const createChatHandler = (options = {})=>async (req)=>{
978
1313
  }
979
1314
  },
980
1315
  proposeDeleteDoc: {
981
- description: "Prepare a CMS document deletion proposal. This does not write to the database.",
1316
+ description: "Propose document deletion.",
982
1317
  inputSchema: z.object({
983
1318
  collection: collectionSlugSchema,
984
1319
  id: z.string().min(1),
985
1320
  label: z.string().min(1)
986
1321
  }),
987
1322
  execute: async ({ collection, id, label })=>{
1323
+ if (requestedDocumentScope && (collection !== requestedCollectionSlug || id !== requestedDocumentID)) {
1324
+ return createToolError({
1325
+ collection,
1326
+ message: "Only the current document can be deleted in this context.",
1327
+ tool: "proposeDeleteDoc"
1328
+ });
1329
+ }
988
1330
  const permissionError = getDisallowedCollectionActionError(collection, "delete");
989
1331
  if (permissionError) return permissionError;
990
1332
  const proposal = {
@@ -1000,7 +1342,7 @@ export const createChatHandler = (options = {})=>async (req)=>{
1000
1342
  }
1001
1343
  },
1002
1344
  proposeUpdateDoc: {
1003
- 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.",
1345
+ description: "Propose document update. Use exact schema fields. Use localizedData for multi-locale writes.",
1004
1346
  inputSchema: z.object({
1005
1347
  collection: collectionSlugSchema,
1006
1348
  data: z.record(z.string(), z.unknown()).optional(),
@@ -1011,9 +1353,16 @@ export const createChatHandler = (options = {})=>async (req)=>{
1011
1353
  message: "Either data or localizedData is required."
1012
1354
  }),
1013
1355
  execute: async ({ collection, data, id, label, localizedData })=>{
1356
+ if (requestedDocumentScope && (collection !== requestedCollectionSlug || id !== requestedDocumentID)) {
1357
+ return createToolError({
1358
+ collection,
1359
+ message: "Only the current document can be updated in this context.",
1360
+ tool: "proposeUpdateDoc"
1361
+ });
1362
+ }
1014
1363
  const permissionError = getDisallowedCollectionActionError(collection, "update");
1015
1364
  if (permissionError) return permissionError;
1016
- const collectionConfig = allowedCollections.find((item)=>item.slug === collection);
1365
+ const collectionConfig = allowedCollectionsBySlug.get(collection);
1017
1366
  const collectionFields = collectionConfig?.fields || [];
1018
1367
  const preparedData = prepareProposalWriteData({
1019
1368
  collectionConfig: collectionConfig,
@@ -1055,6 +1404,31 @@ export const createChatHandler = (options = {})=>async (req)=>{
1055
1404
  tool: "proposeUpdateDoc"
1056
1405
  });
1057
1406
  }
1407
+ const uploadTargetsOutsideAttachments = [
1408
+ ...preparedData.data ? getUploadTargetsOutsideAttachments({
1409
+ allowedAttachmentKeys,
1410
+ data: preparedData.data,
1411
+ fields: collectionFields
1412
+ }) : [],
1413
+ ...preparedData.localizedData ? Object.values(preparedData.localizedData).flatMap((localeData)=>getUploadTargetsOutsideAttachments({
1414
+ allowedAttachmentKeys,
1415
+ data: localeData,
1416
+ fields: collectionFields
1417
+ })) : []
1418
+ ];
1419
+ if (uploadTargetsOutsideAttachments.length > 0) {
1420
+ return createToolError({
1421
+ collection,
1422
+ details: {
1423
+ allowedAttachments: [
1424
+ ...allowedAttachmentKeys
1425
+ ],
1426
+ uploadTargetsOutsideAttachments
1427
+ },
1428
+ 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.`,
1429
+ tool: "proposeUpdateDoc"
1430
+ });
1431
+ }
1058
1432
  const proposal = {
1059
1433
  action: "update",
1060
1434
  collection,
@@ -1073,7 +1447,7 @@ export const createChatHandler = (options = {})=>async (req)=>{
1073
1447
  }
1074
1448
  },
1075
1449
  proposeUpdateGlobal: {
1076
- description: "Prepare a Payload global update proposal. This does not write to the database. Use localizedData when writing multiple locales in one proposal.",
1450
+ description: "Propose global update. Use localizedData for multi-locale writes.",
1077
1451
  inputSchema: z.object({
1078
1452
  data: z.record(z.string(), z.unknown()).optional(),
1079
1453
  label: z.string().min(1),
@@ -1083,7 +1457,14 @@ export const createChatHandler = (options = {})=>async (req)=>{
1083
1457
  message: "Either data or localizedData is required."
1084
1458
  }),
1085
1459
  execute: async ({ data, label, localizedData, slug })=>{
1086
- const globalConfig = req.payload.config.globals?.find((global)=>global.slug === slug);
1460
+ if (requestedDocumentScope && slug !== requestedGlobalSlug) {
1461
+ return createToolError({
1462
+ message: "Only the current global can be updated in this context.",
1463
+ slug,
1464
+ tool: "proposeUpdateGlobal"
1465
+ });
1466
+ }
1467
+ const globalConfig = globalConfigsBySlug.get(slug);
1087
1468
  if (!globalConfig) {
1088
1469
  return createToolError({
1089
1470
  message: `Unknown global: ${slug}`,
@@ -1129,19 +1510,24 @@ export const createChatHandler = (options = {})=>async (req)=>{
1129
1510
  }
1130
1511
  },
1131
1512
  searchDocs: {
1132
- description: "Search documents in one collection. Use query for a loose text search where possible.",
1513
+ description: "Search documents in one collection.",
1133
1514
  inputSchema: z.object({
1134
1515
  collection: collectionSlugSchema,
1135
1516
  limit: z.number().int().min(1).max(10).default(5),
1136
1517
  query: z.string().optional()
1137
1518
  }),
1138
1519
  execute: async ({ collection, limit, query })=>{
1139
- const collectionConfig = allowedCollections.find((item)=>item.slug === collection);
1140
- const searchableFields = collectionConfig?.fields.filter((field)=>"name" in field && [
1520
+ const collectionConfig = allowedCollectionsBySlug.get(collection);
1521
+ const searchableFields = collectionConfig?.fields.flatMap((field)=>{
1522
+ if (!("name" in field) || ![
1141
1523
  "email",
1142
1524
  "text",
1143
1525
  "textarea"
1144
- ].includes(field.type)).map((field)=>"name" in field ? field.name : null).filter(Boolean) || [];
1526
+ ].includes(field.type) || !field.name) return [];
1527
+ return [
1528
+ field.name
1529
+ ];
1530
+ }) || [];
1145
1531
  const where = query && searchableFields.length > 0 ? {
1146
1532
  or: searchableFields.map((field)=>({
1147
1533
  [field]: {
@@ -1163,12 +1549,31 @@ export const createChatHandler = (options = {})=>async (req)=>{
1163
1549
  }
1164
1550
  }
1165
1551
  };
1552
+ const scopedToolNames = requestedCollectionSlug ? requestedDocumentID ? new Set([
1553
+ "getDoc",
1554
+ "listCollections",
1555
+ "proposeUpdateDoc"
1556
+ ]) : new Set([
1557
+ "listCollections",
1558
+ "proposeCreateDoc"
1559
+ ]) : requestedGlobalSlug ? new Set([
1560
+ "getGlobal",
1561
+ "listGlobals",
1562
+ "proposeUpdateGlobal"
1563
+ ]) : null;
1564
+ const tools = scopedToolNames ? Object.fromEntries(Object.entries(allTools).filter(([name])=>scopedToolNames.has(name))) : allTools;
1565
+ if (intentToolChoice && scopedToolNames && !scopedToolNames.has(intentToolChoice.toolName)) {
1566
+ intentToolChoice = undefined;
1567
+ }
1166
1568
  const encoder = new TextEncoder();
1167
1569
  const sendEvent = (controller, event, data)=>{
1168
1570
  controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
1169
1571
  };
1170
1572
  const model = await getModel({
1171
1573
  apiKey: providerConfig.apiKey,
1574
+ ...managedProvider?.baseURL ? {
1575
+ baseURL: managedProvider.baseURL
1576
+ } : {},
1172
1577
  model: providerConfig.modelID,
1173
1578
  provider
1174
1579
  });
@@ -1184,6 +1589,9 @@ export const createChatHandler = (options = {})=>async (req)=>{
1184
1589
  "You are a Payload CMS assistant. Inspect schema/content with tools before proposing writes.",
1185
1590
  "Mentions define the active CMS scope. Locale mentions define active locale; multiple locales require localizedData keyed by locale.",
1186
1591
  "Writes are proposals only. Never claim changes were applied before user confirmation.",
1592
+ "Uploaded media attachments appear in context as mediaAttachment entries. Use their exact IDs for upload fields.",
1593
+ "If an uploaded media document has editable descriptive fields, propose an update to that media document with suitable values.",
1594
+ "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.",
1187
1595
  "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
1596
  "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
1597
  "Put concrete field values only in tool data, not visible text.",
@@ -1224,6 +1632,23 @@ export const createChatHandler = (options = {})=>async (req)=>{
1224
1632
  if (part.type === "finish") {
1225
1633
  const finishPart = part;
1226
1634
  usage = finishPart.totalUsage || finishPart.usage || null;
1635
+ if (usage && options.maxTokenUsage) {
1636
+ try {
1637
+ await recordTokenUsage({
1638
+ model: providerConfig.modelID,
1639
+ provider: managedProvider?.id || provider,
1640
+ req,
1641
+ usage,
1642
+ userID: user.id
1643
+ });
1644
+ } catch (err) {
1645
+ req.payload.logger.error({
1646
+ err,
1647
+ msg: "AI token usage could not be recorded",
1648
+ userID: String(user.id)
1649
+ });
1650
+ }
1651
+ }
1227
1652
  const reason = getChatCompletionReason({
1228
1653
  proposalCount: proposals.length,
1229
1654
  toolFailures,