@mvriu5/payload-ai 0.5.9 → 1.1.1

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 (73) hide show
  1. package/README.md +62 -6
  2. package/dist/ai/{proposals.d.ts → proposalSigning.d.ts} +1 -1
  3. package/dist/ai/{proposals.js → proposalSigning.js} +6 -11
  4. package/dist/ai/providerOptions.d.ts +17 -1
  5. package/dist/ai/providerOptions.js +35 -7
  6. package/dist/ai/providerRuntime.d.ts +2 -2
  7. package/dist/ai/providerRuntime.js +50 -16
  8. package/dist/ai/sensitiveData.js +2 -6
  9. package/dist/components/AIInput.d.ts +2 -1
  10. package/dist/components/AIInput.js +363 -135
  11. package/dist/components/AIInput.module.css +15 -1
  12. package/dist/components/APIKeyField.d.ts +3 -0
  13. package/dist/components/APIKeyField.js +63 -0
  14. package/dist/components/ActionToast.d.ts +30 -0
  15. package/dist/components/ActionToast.js +263 -0
  16. package/dist/components/AuditLogList.d.ts +32 -0
  17. package/dist/components/AuditLogList.js +104 -0
  18. package/dist/components/{RecentChangesList.module.css → AuditLogList.module.css} +18 -1
  19. package/dist/components/DiffDialog.d.ts +10 -3
  20. package/dist/components/DiffDialog.js +284 -97
  21. package/dist/components/DiffDialog.module.css +86 -2
  22. package/dist/components/Icons.d.ts +5 -0
  23. package/dist/components/Icons.js +436 -214
  24. package/dist/components/MentionPopover.d.ts +19 -0
  25. package/dist/components/MentionPopover.js +95 -0
  26. package/dist/components/{CollectionMentionPopover.module.css → MentionPopover.module.css} +0 -2
  27. package/dist/components/hooks/useAISettings.d.ts +6 -4
  28. package/dist/components/hooks/useAISettings.js +18 -3
  29. package/dist/components/hooks/useDocumentMentionSuggestions.d.ts +3 -3
  30. package/dist/components/hooks/useDocumentMentionSuggestions.js +2 -2
  31. package/dist/exports/client.d.ts +4 -2
  32. package/dist/exports/client.js +4 -2
  33. package/dist/handlers/applyActionHandler.d.ts +8 -0
  34. package/dist/handlers/applyActionHandler.js +563 -0
  35. package/dist/handlers/auditLogHandler.d.ts +6 -0
  36. package/dist/{endpoints/aiRecentChangesEndpointHandler.js → handlers/auditLogHandler.js} +12 -8
  37. package/dist/handlers/chatHandler.d.ts +42 -0
  38. package/dist/handlers/chatHandler.js +721 -0
  39. package/dist/handlers/mentionSuggestionHandler.d.ts +7 -0
  40. package/dist/handlers/mentionSuggestionHandler.js +97 -0
  41. package/dist/handlers/proposalDiffHandler.d.ts +7 -0
  42. package/dist/handlers/proposalDiffHandler.js +208 -0
  43. package/dist/index.d.ts +6 -6
  44. package/dist/index.js +52 -20
  45. package/dist/payload/collectionPermissions.d.ts +14 -13
  46. package/dist/payload/collectionPermissions.js +3 -2
  47. package/dist/payload/normalizeData.d.ts +8 -0
  48. package/dist/payload/normalizeData.js +25 -3
  49. package/dist/payload/schemaContext.d.ts +43 -16
  50. package/dist/payload/schemaContext.js +139 -18
  51. package/dist/payload/shared.d.ts +33 -0
  52. package/dist/payload/shared.js +114 -0
  53. package/package.json +45 -9
  54. package/dist/components/AIActionProposalList.d.ts +0 -26
  55. package/dist/components/AIActionProposalList.js +0 -162
  56. package/dist/components/AIApiKeyField.d.ts +0 -2
  57. package/dist/components/AIApiKeyField.js +0 -47
  58. package/dist/components/CollectionMentionPopover.d.ts +0 -16
  59. package/dist/components/CollectionMentionPopover.js +0 -77
  60. package/dist/components/RecentChangesList.d.ts +0 -18
  61. package/dist/components/RecentChangesList.js +0 -66
  62. package/dist/components/hooks/utils.d.ts +0 -1
  63. package/dist/components/hooks/utils.js +0 -3
  64. package/dist/endpoints/aiApplyActionEndpointHandler.d.ts +0 -8
  65. package/dist/endpoints/aiApplyActionEndpointHandler.js +0 -365
  66. package/dist/endpoints/aiChatEndpointHandler.d.ts +0 -35
  67. package/dist/endpoints/aiChatEndpointHandler.js +0 -382
  68. package/dist/endpoints/aiMentionSuggestionsEndpointHandler.d.ts +0 -7
  69. package/dist/endpoints/aiMentionSuggestionsEndpointHandler.js +0 -63
  70. package/dist/endpoints/aiProposalDiffEndpointHandler.d.ts +0 -7
  71. package/dist/endpoints/aiProposalDiffEndpointHandler.js +0 -138
  72. package/dist/endpoints/aiRecentChangesEndpointHandler.d.ts +0 -6
  73. /package/dist/components/{AIActionProposalList.module.css → ActionToast.module.css} +0 -0
@@ -0,0 +1,721 @@
1
+ import { stepCountIs, streamText } from "ai";
2
+ import { z } from "zod";
3
+ import { signAIActionProposal } from "../ai/proposalSigning.js";
4
+ import { isAIProvider } from "../ai/providerOptions.js";
5
+ import { getModel, getProviderConfig } from "../ai/providerRuntime.js";
6
+ import { containsSensitiveData } from "../ai/sensitiveData.js";
7
+ import { isCollectionActionAllowed } from "../payload/collectionPermissions.js";
8
+ import { buildPromptWithMentionContext, collectBlocks, describeCollectionLikeConfig, describeCollectionLikeSummary, getAllowedCollectionSlugs, getMentionContext } from "../payload/schemaContext.js";
9
+ import { getOptionValue, getSafeProposalLabel, hasLocalizedData, hasValueAtPath, setValueAtPath } from "../payload/shared.js";
10
+ const e2eModeEnabled = ()=>process.env.PAYLOAD_AI_E2E_MODE === "true";
11
+ const createSSEEventStream = (events)=>{
12
+ const encoder = new TextEncoder();
13
+ return new ReadableStream({
14
+ start (controller) {
15
+ for (const { data, event } of events){
16
+ controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
17
+ }
18
+ controller.close();
19
+ }
20
+ });
21
+ };
22
+ const createE2EChatResponse = ({ prompt, selectedLocales })=>{
23
+ const normalizedPrompt = prompt.toLowerCase();
24
+ const wantsCreatePost = normalizedPrompt.includes("post") && (normalizedPrompt.includes("create") || normalizedPrompt.includes("erstell"));
25
+ const mentionsMars = normalizedPrompt.includes("mars");
26
+ const multipleLocales = selectedLocales.length > 1;
27
+ const activeLocale = selectedLocales.at(-1);
28
+ const proposalLabel = multipleLocales ? normalizedPrompt.includes("locale review") ? "Create localized locale review draft post about Mars" : "Create localized draft post about Mars" : normalizedPrompt.includes("apply flow") ? "Create apply flow draft post about Mars" : normalizedPrompt.includes("proposal review") ? "Create proposal review draft post about Mars" : "Create draft post about Mars";
29
+ const proposal = wantsCreatePost && mentionsMars ? signAIActionProposal(multipleLocales ? {
30
+ action: "create",
31
+ collection: "posts",
32
+ label: proposalLabel,
33
+ localizedData: Object.fromEntries(selectedLocales.map((locale)=>[
34
+ locale,
35
+ {
36
+ content: locale === "de" ? "Mars ist der vierte Planet von der Sonne." : "Mars is the fourth planet from the Sun.",
37
+ excerpt: locale === "de" ? "Ein kurzer Entwurf ueber Mars." : "A short draft about Mars.",
38
+ title: locale === "de" ? "Mars im Ueberblick" : "Mars Overview"
39
+ }
40
+ ])),
41
+ ...activeLocale ? {
42
+ locale: activeLocale
43
+ } : {}
44
+ } : {
45
+ action: "create",
46
+ collection: "posts",
47
+ data: {
48
+ content: "Mars is the fourth planet from the Sun.",
49
+ excerpt: "A short draft about Mars.",
50
+ status: "draft",
51
+ title: "Mars Overview"
52
+ },
53
+ label: proposalLabel,
54
+ ...activeLocale ? {
55
+ locale: activeLocale
56
+ } : {}
57
+ }) : null;
58
+ const responseText = proposal ? "Prepared one draft post proposal." : "No content change was proposed.";
59
+ return new Response(createSSEEventStream([
60
+ {
61
+ data: {
62
+ delta: responseText
63
+ },
64
+ event: "text"
65
+ },
66
+ {
67
+ data: {
68
+ proposals: proposal ? [
69
+ proposal
70
+ ] : [],
71
+ usage: {
72
+ inputTokens: 42,
73
+ outputTokens: 27,
74
+ totalTokens: 69
75
+ }
76
+ },
77
+ event: "proposals"
78
+ },
79
+ {
80
+ data: {},
81
+ event: "done"
82
+ }
83
+ ]), {
84
+ headers: {
85
+ "Cache-Control": "no-cache, no-transform",
86
+ Connection: "keep-alive",
87
+ "Content-Type": "text/event-stream; charset=utf-8"
88
+ }
89
+ });
90
+ };
91
+ const getRequiredFieldInfos = (fields, titleFieldName, path = "")=>{
92
+ return fields.flatMap((field)=>{
93
+ const fieldPath = field.name ? path ? `${path}.${field.name}` : field.name : path;
94
+ const requiredField = field.required && fieldPath ? [
95
+ {
96
+ defaultValue: field.defaultValue,
97
+ isTitleField: field.name === titleFieldName,
98
+ localized: Boolean(field.localized),
99
+ options: field.options,
100
+ path: fieldPath,
101
+ type: field.type
102
+ }
103
+ ] : [];
104
+ const nestedFields = field.fields?.length ? getRequiredFieldInfos(field.fields, titleFieldName, fieldPath) : [];
105
+ const nestedBlocks = field.blocks?.length ? field.blocks.flatMap((block)=>getRequiredFieldInfos(block.fields || [], titleFieldName, `${fieldPath}.${block.slug}`)) : [];
106
+ return [
107
+ ...requiredField,
108
+ ...nestedFields,
109
+ ...nestedBlocks
110
+ ];
111
+ });
112
+ };
113
+ const getCreateFallbackValue = ({ field, label })=>{
114
+ if (field.defaultValue !== undefined) return field.defaultValue;
115
+ if (field.path === "_status") return "draft";
116
+ if (field.type === "checkbox") return false;
117
+ if (field.type === "select" || field.type === "radio") {
118
+ return getOptionValue(field.options?.[0]);
119
+ }
120
+ const terminalSegment = field.path.split(".").at(-1)?.toLowerCase();
121
+ if ((field.isTitleField || [
122
+ "title",
123
+ "name",
124
+ "label",
125
+ "headline"
126
+ ].includes(terminalSegment || "")) && [
127
+ "text",
128
+ "textarea"
129
+ ].includes(field.type || "")) {
130
+ return getSafeProposalLabel(label);
131
+ }
132
+ return undefined;
133
+ };
134
+ const fillMissingCreateFields = ({ data, label, localizedData, requiredFields })=>{
135
+ if (localizedData) {
136
+ const completedLocalizedData = Object.fromEntries(Object.entries(localizedData).map(([locale, localeData])=>[
137
+ locale,
138
+ {
139
+ ...localeData
140
+ }
141
+ ]));
142
+ const localeEntries = Object.entries(completedLocalizedData);
143
+ const [firstLocale, firstLocaleData] = localeEntries[0] || [];
144
+ for (const [locale, localeData] of localeEntries){
145
+ for (const field of requiredFields.filter((item)=>item.localized)){
146
+ if (hasValueAtPath(localeData, field.path)) continue;
147
+ const fallbackValue = getCreateFallbackValue({
148
+ field,
149
+ label
150
+ });
151
+ if (fallbackValue !== undefined) {
152
+ setValueAtPath(localeData, field.path, fallbackValue);
153
+ }
154
+ }
155
+ }
156
+ if (firstLocale && firstLocaleData) {
157
+ for (const field of requiredFields.filter((item)=>!item.localized)){
158
+ if (hasValueAtPath(firstLocaleData, field.path)) continue;
159
+ const fallbackValue = getCreateFallbackValue({
160
+ field,
161
+ label
162
+ });
163
+ if (fallbackValue !== undefined) {
164
+ setValueAtPath(firstLocaleData, field.path, fallbackValue);
165
+ }
166
+ }
167
+ }
168
+ return {
169
+ localizedData: completedLocalizedData
170
+ };
171
+ }
172
+ const completedData = {
173
+ ...data || {}
174
+ };
175
+ for (const field of requiredFields){
176
+ if (hasValueAtPath(completedData, field.path)) continue;
177
+ const fallbackValue = getCreateFallbackValue({
178
+ field,
179
+ label
180
+ });
181
+ if (fallbackValue !== undefined) {
182
+ setValueAtPath(completedData, field.path, fallbackValue);
183
+ }
184
+ }
185
+ return {
186
+ data: completedData
187
+ };
188
+ };
189
+ const getMissingCreateFields = ({ data, localizedData, requiredFields })=>{
190
+ if (localizedData) {
191
+ const locales = Object.entries(localizedData);
192
+ const firstLocale = locales[0];
193
+ if (!firstLocale) {
194
+ return [
195
+ "localizedData must include at least one locale entry"
196
+ ];
197
+ }
198
+ const missing = [];
199
+ for (const [locale, localeData] of locales){
200
+ for (const field of requiredFields.filter((item)=>item.localized)){
201
+ if (!hasValueAtPath(localeData, field.path)) {
202
+ missing.push(`${locale}:${field.path}`);
203
+ }
204
+ }
205
+ }
206
+ for (const field of requiredFields.filter((item)=>!item.localized)){
207
+ if (!hasValueAtPath(firstLocale[1], field.path)) {
208
+ missing.push(`${firstLocale[0]}:${field.path}`);
209
+ }
210
+ }
211
+ return missing;
212
+ }
213
+ if (!data) return [
214
+ "data is required"
215
+ ];
216
+ return requiredFields.filter((field)=>!hasValueAtPath(data, field.path)).map((field)=>field.path);
217
+ };
218
+ export const createChatHandler = (options = {})=>async (req)=>{
219
+ if (!req.user) return Response.json({
220
+ error: "Unauthorized"
221
+ }, {
222
+ status: 401
223
+ });
224
+ const body = req.json ? await req.json().catch(()=>null) : null;
225
+ const prompt = body?.prompt?.trim();
226
+ if (!prompt) return Response.json({
227
+ error: "Prompt is required"
228
+ }, {
229
+ status: 400
230
+ });
231
+ const selectedLocales = (body?.mentions?.filter((mention)=>mention.type === "locale" && mention.slug).map((mention)=>mention.slug) || []).filter((locale, index, array)=>array.indexOf(locale) === index);
232
+ const activeLocale = selectedLocales.at(-1);
233
+ if (e2eModeEnabled()) {
234
+ return createE2EChatResponse({
235
+ prompt,
236
+ selectedLocales
237
+ });
238
+ }
239
+ const user = req.user;
240
+ const requestedProvider = body?.provider || user.aiProvider || "openai";
241
+ if (!isAIProvider(requestedProvider)) return Response.json({
242
+ error: `Unsupported AI provider: ${requestedProvider}`
243
+ }, {
244
+ status: 400
245
+ });
246
+ const provider = requestedProvider;
247
+ const userApiKey = options.allowUserApiKeys === false ? null : user.aiApiKey;
248
+ const providerConfig = getProviderConfig({
249
+ apiKey: userApiKey,
250
+ defaultModels: options.models?.defaults,
251
+ model: body?.model,
252
+ provider
253
+ });
254
+ const debug = {
255
+ model: providerConfig.modelID,
256
+ provider,
257
+ tools: [
258
+ "getDoc",
259
+ "getGlobal",
260
+ "listCollections",
261
+ "listGlobals",
262
+ "proposeCreateDoc",
263
+ "proposeDeleteDoc",
264
+ "proposeUpdateDoc",
265
+ "proposeUpdateGlobal",
266
+ "searchDocs"
267
+ ]
268
+ };
269
+ if (!providerConfig.apiKey) {
270
+ return Response.json({
271
+ 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
+ }, {
273
+ status: 400
274
+ });
275
+ }
276
+ try {
277
+ const proposals = [];
278
+ const addSignedProposal = (proposal)=>{
279
+ if ("data" in proposal && proposal.data && containsSensitiveData(proposal.data)) {
280
+ return {
281
+ error: "Proposal contains sensitive fields and cannot be created."
282
+ };
283
+ }
284
+ 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
+ };
288
+ }
289
+ const signedProposal = signAIActionProposal(proposal);
290
+ proposals.push(signedProposal);
291
+ return signedProposal;
292
+ };
293
+ 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));
296
+ if (collectionSlugs.length === 0) {
297
+ return Response.json({
298
+ error: "No AI-enabled collections are configured."
299
+ }, {
300
+ status: 400
301
+ });
302
+ }
303
+ const blockContexts = [
304
+ ...allowedCollections.flatMap((collection)=>collectBlocks({
305
+ fields: collection.fields,
306
+ parent: collection.slug
307
+ })),
308
+ ...req.payload.config.globals?.flatMap((global)=>collectBlocks({
309
+ fields: global.fields,
310
+ parent: global.slug
311
+ })) || []
312
+ ];
313
+ const mentionContext = await getMentionContext({
314
+ blockContexts,
315
+ collectionSlugs,
316
+ collections: options.collections,
317
+ globalSlugs,
318
+ mentions: body?.mentions,
319
+ req
320
+ });
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);
330
+ const createRequiredFieldsByCollection = Object.fromEntries(allowedCollections.map((collection)=>[
331
+ collection.slug,
332
+ getRequiredFieldInfos(collection.fields, collection.admin?.useAsTitle)
333
+ ]));
334
+ const titleFieldByCollection = Object.fromEntries(allowedCollections.flatMap((collection)=>collection.admin?.useAsTitle ? [
335
+ [
336
+ collection.slug,
337
+ collection.admin.useAsTitle
338
+ ]
339
+ ] : []));
340
+ const focusedRequiredFieldsByCollection = Object.fromEntries(mentionedCollectionSlugs.map((slug)=>[
341
+ slug,
342
+ createRequiredFieldsByCollection[slug] || []
343
+ ]));
344
+ const focusedTitleFieldByCollection = Object.fromEntries(mentionedCollectionSlugs.flatMap((slug)=>titleFieldByCollection[slug] ? [
345
+ [
346
+ slug,
347
+ titleFieldByCollection[slug]
348
+ ]
349
+ ] : []));
350
+ const collectionSlugSchema = z.enum(collectionSlugs);
351
+ const getDisallowedCollectionActionError = (collection, action)=>{
352
+ if (isCollectionActionAllowed({
353
+ action,
354
+ permissions: options.collections,
355
+ req,
356
+ slug: collection
357
+ })) return null;
358
+ return {
359
+ error: `${action} is not enabled for collection: ${collection}`
360
+ };
361
+ };
362
+ const tools = {
363
+ getDoc: {
364
+ description: "Read one document by collection slug and document id.",
365
+ inputSchema: z.object({
366
+ collection: collectionSlugSchema,
367
+ id: z.string().min(1)
368
+ }),
369
+ execute: async ({ collection, id })=>{
370
+ return req.payload.findByID({
371
+ collection: collection,
372
+ depth: 2,
373
+ id,
374
+ ...activeLocale ? {
375
+ locale: activeLocale
376
+ } : {},
377
+ overrideAccess: false,
378
+ req
379
+ });
380
+ }
381
+ },
382
+ listCollections: {
383
+ description: "List AI-enabled Payload collections. Omit slug for compact summaries; pass slug to get full field schema for one collection.",
384
+ inputSchema: z.object({
385
+ slug: collectionSlugSchema.optional()
386
+ }),
387
+ execute: async ({ slug })=>{
388
+ if (slug) {
389
+ const collection = allowedCollections.find((item)=>item.slug === slug);
390
+ if (!collection) return {
391
+ error: `Unknown collection: ${slug}`
392
+ };
393
+ return describeCollectionLikeConfig({
394
+ config: collection,
395
+ permissions: options.collections,
396
+ type: "collection"
397
+ });
398
+ }
399
+ return allowedCollections.map((collection)=>describeCollectionLikeSummary({
400
+ config: collection,
401
+ permissions: options.collections,
402
+ type: "collection"
403
+ }));
404
+ }
405
+ },
406
+ getGlobal: {
407
+ description: "Read one Payload CMS global by slug.",
408
+ inputSchema: z.object({
409
+ slug: z.string().min(1)
410
+ }),
411
+ 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
+ };
416
+ return req.payload.findGlobal({
417
+ depth: 2,
418
+ ...activeLocale ? {
419
+ locale: activeLocale
420
+ } : {},
421
+ overrideAccess: false,
422
+ req,
423
+ slug: slug
424
+ });
425
+ }
426
+ },
427
+ listGlobals: {
428
+ description: "List Payload globals. Omit slug for compact summaries; pass slug to get full field schema for one global.",
429
+ inputSchema: z.object({
430
+ slug: z.string().optional()
431
+ }),
432
+ execute: async ({ slug })=>{
433
+ const globals = req.payload.config.globals || [];
434
+ if (slug) {
435
+ const global = globals.find((item)=>item.slug === slug);
436
+ if (!global) return {
437
+ error: `Unknown global: ${slug}`
438
+ };
439
+ return describeCollectionLikeConfig({
440
+ config: global,
441
+ type: "global"
442
+ });
443
+ }
444
+ return globals.map((global)=>describeCollectionLikeSummary({
445
+ config: global,
446
+ type: "global"
447
+ }));
448
+ }
449
+ },
450
+ 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.",
452
+ inputSchema: z.object({
453
+ collection: collectionSlugSchema,
454
+ data: z.record(z.string(), z.unknown()).optional(),
455
+ label: z.string().min(1),
456
+ localizedData: z.record(z.string(), z.record(z.string(), z.unknown())).optional()
457
+ }).refine((value)=>Boolean(value.data || value.localizedData), {
458
+ message: "Either data or localizedData is required."
459
+ }),
460
+ execute: async ({ collection, data, label, localizedData })=>{
461
+ const permissionError = getDisallowedCollectionActionError(collection, "create");
462
+ if (permissionError) return permissionError;
463
+ const completedCreatePayload = fillMissingCreateFields({
464
+ data,
465
+ label,
466
+ localizedData,
467
+ requiredFields: createRequiredFieldsByCollection[collection] || []
468
+ });
469
+ const missingFields = getMissingCreateFields({
470
+ data: completedCreatePayload.data,
471
+ localizedData: completedCreatePayload.localizedData,
472
+ requiredFields: createRequiredFieldsByCollection[collection] || []
473
+ });
474
+ if (missingFields.length > 0) {
475
+ 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
+ };
480
+ }
481
+ const proposal = completedCreatePayload.localizedData ? {
482
+ action: "create",
483
+ collection,
484
+ label: getSafeProposalLabel(label),
485
+ localizedData: completedCreatePayload.localizedData,
486
+ ...activeLocale ? {
487
+ locale: activeLocale
488
+ } : {}
489
+ } : {
490
+ action: "create",
491
+ collection,
492
+ data: completedCreatePayload.data || {},
493
+ label: getSafeProposalLabel(label),
494
+ ...activeLocale ? {
495
+ locale: activeLocale
496
+ } : {}
497
+ };
498
+ return addSignedProposal(proposal);
499
+ }
500
+ },
501
+ proposeDeleteDoc: {
502
+ description: "Prepare a CMS document deletion proposal. This does not write to the database.",
503
+ inputSchema: z.object({
504
+ collection: collectionSlugSchema,
505
+ id: z.string().min(1),
506
+ label: z.string().min(1)
507
+ }),
508
+ execute: async ({ collection, id, label })=>{
509
+ const permissionError = getDisallowedCollectionActionError(collection, "delete");
510
+ if (permissionError) return permissionError;
511
+ const proposal = {
512
+ action: "delete",
513
+ collection,
514
+ id,
515
+ label: getSafeProposalLabel(label),
516
+ ...activeLocale ? {
517
+ locale: activeLocale
518
+ } : {}
519
+ };
520
+ return addSignedProposal(proposal);
521
+ }
522
+ },
523
+ 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.",
525
+ inputSchema: z.object({
526
+ collection: collectionSlugSchema,
527
+ data: z.record(z.string(), z.unknown()).optional(),
528
+ id: z.string().min(1),
529
+ label: z.string().min(1),
530
+ localizedData: z.record(z.string(), z.record(z.string(), z.unknown())).optional()
531
+ }).refine((value)=>Boolean(value.data || value.localizedData), {
532
+ message: "Either data or localizedData is required."
533
+ }),
534
+ execute: async ({ collection, data, id, label, localizedData })=>{
535
+ const permissionError = getDisallowedCollectionActionError(collection, "update");
536
+ if (permissionError) return permissionError;
537
+ const proposal = {
538
+ action: "update",
539
+ collection,
540
+ ...localizedData ? {
541
+ localizedData
542
+ } : {
543
+ data: data || {}
544
+ },
545
+ id,
546
+ label: getSafeProposalLabel(label),
547
+ ...activeLocale ? {
548
+ locale: activeLocale
549
+ } : {}
550
+ };
551
+ return addSignedProposal(proposal);
552
+ }
553
+ },
554
+ 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.",
556
+ inputSchema: z.object({
557
+ data: z.record(z.string(), z.unknown()).optional(),
558
+ label: z.string().min(1),
559
+ localizedData: z.record(z.string(), z.record(z.string(), z.unknown())).optional(),
560
+ slug: z.string().min(1)
561
+ }).refine((value)=>Boolean(value.data || value.localizedData), {
562
+ message: "Either data or localizedData is required."
563
+ }),
564
+ 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
+ };
569
+ const proposal = {
570
+ action: "updateGlobal",
571
+ ...localizedData ? {
572
+ localizedData
573
+ } : {
574
+ data: data || {}
575
+ },
576
+ label: getSafeProposalLabel(label),
577
+ ...activeLocale ? {
578
+ locale: activeLocale
579
+ } : {},
580
+ slug
581
+ };
582
+ return addSignedProposal(proposal);
583
+ }
584
+ },
585
+ searchDocs: {
586
+ description: "Search documents in one collection. Use query for a loose text search where possible.",
587
+ inputSchema: z.object({
588
+ collection: collectionSlugSchema,
589
+ limit: z.number().int().min(1).max(10).default(5),
590
+ query: z.string().optional()
591
+ }),
592
+ execute: async ({ collection, limit, query })=>{
593
+ const collectionConfig = allowedCollections.find((item)=>item.slug === collection);
594
+ const searchableFields = collectionConfig?.fields.filter((field)=>"name" in field && [
595
+ "email",
596
+ "text",
597
+ "textarea"
598
+ ].includes(field.type)).map((field)=>"name" in field ? field.name : null).filter(Boolean) || [];
599
+ const where = query && searchableFields.length > 0 ? {
600
+ or: searchableFields.map((field)=>({
601
+ [field]: {
602
+ contains: query
603
+ }
604
+ }))
605
+ } : undefined;
606
+ return req.payload.find({
607
+ collection: collection,
608
+ depth: 1,
609
+ limit,
610
+ ...activeLocale ? {
611
+ locale: activeLocale
612
+ } : {},
613
+ overrideAccess: false,
614
+ req,
615
+ where
616
+ });
617
+ }
618
+ }
619
+ };
620
+ const encoder = new TextEncoder();
621
+ const sendEvent = (controller, event, data)=>{
622
+ controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
623
+ };
624
+ const model = await getModel({
625
+ apiKey: providerConfig.apiKey,
626
+ model: providerConfig.modelID,
627
+ provider
628
+ });
629
+ const result = streamText({
630
+ maxOutputTokens: options.maxOutputTokens || 700,
631
+ model,
632
+ prompt: buildPromptWithMentionContext({
633
+ mentionContext,
634
+ prompt
635
+ }),
636
+ stopWhen: stepCountIs(6),
637
+ system: [
638
+ "You are a Payload CMS assistant. Inspect schema/content with tools before proposing writes.",
639
+ "Mentions define the active CMS scope. Locale mentions define active locale; multiple locales require localizedData keyed by locale.",
640
+ "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.",
643
+ `Focused required create fields: ${JSON.stringify(focusedRequiredFieldsByCollection)}.`,
644
+ `Focused title fields: ${JSON.stringify(focusedTitleFieldByCollection)}. Infer concise titles when needed.`,
645
+ "Visible response: plain text, under 40 words, no Markdown, no proposed content."
646
+ ].join("\n"),
647
+ tools
648
+ });
649
+ const stream = new ReadableStream({
650
+ start: async (controller)=>{
651
+ let didSendTerminalEvent = false;
652
+ let usage = null;
653
+ try {
654
+ for await (const part of result.fullStream){
655
+ if (part.type === "text-delta") {
656
+ sendEvent(controller, "text", {
657
+ delta: part.text
658
+ });
659
+ continue;
660
+ }
661
+ if (part.type === "error") {
662
+ didSendTerminalEvent = true;
663
+ sendEvent(controller, "error", {
664
+ error: "AI request failed."
665
+ });
666
+ break;
667
+ }
668
+ if (part.type === "finish") {
669
+ const finishPart = part;
670
+ usage = finishPart.totalUsage || finishPart.usage || null;
671
+ sendEvent(controller, "proposals", {
672
+ proposals,
673
+ usage
674
+ });
675
+ sendEvent(controller, "done", {});
676
+ didSendTerminalEvent = true;
677
+ }
678
+ }
679
+ if (!didSendTerminalEvent) {
680
+ sendEvent(controller, "proposals", {
681
+ proposals,
682
+ usage
683
+ });
684
+ sendEvent(controller, "done", {});
685
+ }
686
+ } catch (err) {
687
+ req.payload.logger.error({
688
+ debug,
689
+ err,
690
+ msg: "AI chat stream failed"
691
+ });
692
+ if (!didSendTerminalEvent) {
693
+ sendEvent(controller, "error", {
694
+ error: "AI request failed."
695
+ });
696
+ }
697
+ } finally{
698
+ controller.close();
699
+ }
700
+ }
701
+ });
702
+ return new Response(stream, {
703
+ headers: {
704
+ "Cache-Control": "no-cache, no-transform",
705
+ Connection: "keep-alive",
706
+ "Content-Type": "text/event-stream; charset=utf-8"
707
+ }
708
+ });
709
+ } catch (err) {
710
+ req.payload.logger.error({
711
+ debug,
712
+ err,
713
+ msg: "AI chat request failed"
714
+ });
715
+ return Response.json({
716
+ error: "AI request failed."
717
+ }, {
718
+ status: 500
719
+ });
720
+ }
721
+ };