@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.
- package/README.md +62 -6
- package/dist/ai/{proposals.d.ts → proposalSigning.d.ts} +1 -1
- package/dist/ai/{proposals.js → proposalSigning.js} +6 -11
- package/dist/ai/providerOptions.d.ts +17 -1
- package/dist/ai/providerOptions.js +35 -7
- package/dist/ai/providerRuntime.d.ts +2 -2
- package/dist/ai/providerRuntime.js +50 -16
- package/dist/ai/sensitiveData.js +2 -6
- package/dist/components/AIInput.d.ts +2 -1
- package/dist/components/AIInput.js +363 -135
- package/dist/components/AIInput.module.css +15 -1
- package/dist/components/APIKeyField.d.ts +3 -0
- package/dist/components/APIKeyField.js +63 -0
- package/dist/components/ActionToast.d.ts +30 -0
- package/dist/components/ActionToast.js +263 -0
- package/dist/components/AuditLogList.d.ts +32 -0
- package/dist/components/AuditLogList.js +104 -0
- package/dist/components/{RecentChangesList.module.css → AuditLogList.module.css} +18 -1
- package/dist/components/DiffDialog.d.ts +10 -3
- package/dist/components/DiffDialog.js +284 -97
- package/dist/components/DiffDialog.module.css +86 -2
- package/dist/components/Icons.d.ts +5 -0
- package/dist/components/Icons.js +436 -214
- package/dist/components/MentionPopover.d.ts +19 -0
- package/dist/components/MentionPopover.js +95 -0
- package/dist/components/{CollectionMentionPopover.module.css → MentionPopover.module.css} +0 -2
- package/dist/components/hooks/useAISettings.d.ts +6 -4
- package/dist/components/hooks/useAISettings.js +18 -3
- package/dist/components/hooks/useDocumentMentionSuggestions.d.ts +3 -3
- package/dist/components/hooks/useDocumentMentionSuggestions.js +2 -2
- package/dist/exports/client.d.ts +4 -2
- package/dist/exports/client.js +4 -2
- package/dist/handlers/applyActionHandler.d.ts +8 -0
- package/dist/handlers/applyActionHandler.js +563 -0
- package/dist/handlers/auditLogHandler.d.ts +6 -0
- package/dist/{endpoints/aiRecentChangesEndpointHandler.js → handlers/auditLogHandler.js} +12 -8
- package/dist/handlers/chatHandler.d.ts +42 -0
- package/dist/handlers/chatHandler.js +721 -0
- package/dist/handlers/mentionSuggestionHandler.d.ts +7 -0
- package/dist/handlers/mentionSuggestionHandler.js +97 -0
- package/dist/handlers/proposalDiffHandler.d.ts +7 -0
- package/dist/handlers/proposalDiffHandler.js +208 -0
- package/dist/index.d.ts +6 -6
- package/dist/index.js +52 -20
- package/dist/payload/collectionPermissions.d.ts +14 -13
- package/dist/payload/collectionPermissions.js +3 -2
- package/dist/payload/normalizeData.d.ts +8 -0
- package/dist/payload/normalizeData.js +25 -3
- package/dist/payload/schemaContext.d.ts +43 -16
- package/dist/payload/schemaContext.js +139 -18
- package/dist/payload/shared.d.ts +33 -0
- package/dist/payload/shared.js +114 -0
- package/package.json +45 -9
- package/dist/components/AIActionProposalList.d.ts +0 -26
- package/dist/components/AIActionProposalList.js +0 -162
- package/dist/components/AIApiKeyField.d.ts +0 -2
- package/dist/components/AIApiKeyField.js +0 -47
- package/dist/components/CollectionMentionPopover.d.ts +0 -16
- package/dist/components/CollectionMentionPopover.js +0 -77
- package/dist/components/RecentChangesList.d.ts +0 -18
- package/dist/components/RecentChangesList.js +0 -66
- package/dist/components/hooks/utils.d.ts +0 -1
- package/dist/components/hooks/utils.js +0 -3
- package/dist/endpoints/aiApplyActionEndpointHandler.d.ts +0 -8
- package/dist/endpoints/aiApplyActionEndpointHandler.js +0 -365
- package/dist/endpoints/aiChatEndpointHandler.d.ts +0 -35
- package/dist/endpoints/aiChatEndpointHandler.js +0 -382
- package/dist/endpoints/aiMentionSuggestionsEndpointHandler.d.ts +0 -7
- package/dist/endpoints/aiMentionSuggestionsEndpointHandler.js +0 -63
- package/dist/endpoints/aiProposalDiffEndpointHandler.d.ts +0 -7
- package/dist/endpoints/aiProposalDiffEndpointHandler.js +0 -138
- package/dist/endpoints/aiRecentChangesEndpointHandler.d.ts +0 -6
- /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
|
+
};
|