@mvriu5/payload-ai 1.6.2 → 1.6.4

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 (126) hide show
  1. package/dist/components/action-toast/ActionToast.d.ts +1 -14
  2. package/dist/components/action-toast/ActionToast.js +1 -1
  3. package/dist/components/ai-input/AIInput.js +1 -1
  4. package/dist/components/audit-log-list/AuditLogList.d.ts +2 -3
  5. package/dist/components/diff-dialog/DiffDialog.d.ts +2 -6
  6. package/dist/components/diff-dialog/DiffDialog.js +1 -1
  7. package/dist/components/generate-field/GenerateField.js +2 -0
  8. package/dist/components/hooks/useAIChatStream.d.ts +1 -1
  9. package/dist/components/hooks/useAISettings.d.ts +1 -1
  10. package/dist/components/hooks/useAISettings.js +1 -1
  11. package/dist/components/hooks/useDocumentMentionSuggestions.js +1 -1
  12. package/dist/components/hooks/useMentions.js +1 -1
  13. package/dist/components/hooks/usePluginConfig.d.ts +6 -6
  14. package/dist/components/hooks/usePluginConfig.js +1 -1
  15. package/dist/{payload → features}/collectionPermissions.js +1 -1
  16. package/dist/{payload/textFieldGeneration.d.ts → features/content/fieldGeneration.d.ts} +4 -0
  17. package/dist/{payload/textFieldGeneration.js → features/content/fieldGeneration.js} +19 -3
  18. package/dist/{ai → features}/promptCaching.d.ts +2 -2
  19. package/dist/features/proposals/containerNormalization.d.ts +8 -0
  20. package/dist/features/proposals/containerNormalization.js +112 -0
  21. package/dist/{payload/proposalData.d.ts → features/proposals/data.d.ts} +5 -10
  22. package/dist/features/proposals/data.js +113 -0
  23. package/dist/features/proposals/fieldNormalization.d.ts +2 -0
  24. package/dist/features/proposals/fieldNormalization.js +69 -0
  25. package/dist/features/proposals/normalizationTypes.d.ts +31 -0
  26. package/dist/features/proposals/normalizationTypes.js +1 -0
  27. package/dist/features/proposals/optionInference.d.ts +13 -0
  28. package/dist/features/proposals/optionInference.js +46 -0
  29. package/dist/features/proposals/recordNormalization.d.ts +6 -0
  30. package/dist/features/proposals/recordNormalization.js +78 -0
  31. package/dist/features/proposals/relationshipNormalization.d.ts +8 -0
  32. package/dist/features/proposals/relationshipNormalization.js +71 -0
  33. package/dist/{ai/proposalRepair.d.ts → features/proposals/repair.d.ts} +1 -1
  34. package/dist/{ai/proposalSigning.d.ts → features/proposals/signing.d.ts} +2 -5
  35. package/dist/{ai/proposalSigning.js → features/proposals/signing.js} +1 -1
  36. package/dist/features/proposals/target.d.ts +52 -0
  37. package/dist/features/proposals/target.js +125 -0
  38. package/dist/features/proposals/types.d.ts +49 -0
  39. package/dist/features/proposals/types.js +1 -0
  40. package/dist/features/proposals/validation.d.ts +12 -0
  41. package/dist/features/proposals/validation.js +26 -0
  42. package/dist/features/providers/requestContext.d.ts +53 -0
  43. package/dist/features/providers/requestContext.js +90 -0
  44. package/dist/{ai/providerRuntime.d.ts → features/providers/runtime.d.ts} +1 -1
  45. package/dist/{ai/providerRuntime.js → features/providers/runtime.js} +1 -1
  46. package/dist/features/schema/context.d.ts +7 -0
  47. package/dist/features/schema/context.js +11 -0
  48. package/dist/{payload/schemaContext.d.ts → features/schema/description.d.ts} +6 -33
  49. package/dist/{payload/schemaContext.js → features/schema/description.js} +3 -207
  50. package/dist/{payload/toolFieldSelection.d.ts → features/schema/fieldSelection.d.ts} +1 -1
  51. package/dist/{payload/toolFieldSelection.js → features/schema/fieldSelection.js} +2 -2
  52. package/dist/features/schema/mentionDocuments.d.ts +15 -0
  53. package/dist/features/schema/mentionDocuments.js +50 -0
  54. package/dist/features/schema/mentions.d.ts +18 -0
  55. package/dist/features/schema/mentions.js +100 -0
  56. package/dist/{payload/normalizeData.js → features/schema/normalize.js} +1 -1
  57. package/dist/features/schema/registry.d.ts +30 -0
  58. package/dist/features/schema/registry.js +73 -0
  59. package/dist/{payload → features/schema}/toolSchemas.d.ts +1 -1
  60. package/dist/{payload → features/schema}/toolSchemas.js +1 -1
  61. package/dist/{ai → features}/sensitiveData.js +1 -1
  62. package/dist/{ai → features}/tokenUsage.d.ts +1 -2
  63. package/dist/handlers/apply/audit.d.ts +42 -0
  64. package/dist/handlers/apply/audit.js +150 -0
  65. package/dist/handlers/apply/operations.d.ts +18 -0
  66. package/dist/handlers/apply/operations.js +377 -0
  67. package/dist/handlers/apply/types.d.ts +33 -0
  68. package/dist/handlers/apply/types.js +1 -0
  69. package/dist/handlers/apply/validation.d.ts +45 -0
  70. package/dist/handlers/apply/validation.js +125 -0
  71. package/dist/handlers/applyActionHandler.d.ts +1 -6
  72. package/dist/handlers/applyActionHandler.js +13 -757
  73. package/dist/handlers/auditLogHandler.js +4 -8
  74. package/dist/handlers/chat/intent.d.ts +32 -0
  75. package/dist/handlers/chat/intent.js +155 -0
  76. package/dist/handlers/chat/prompt.d.ts +20 -0
  77. package/dist/handlers/chat/prompt.js +55 -0
  78. package/dist/handlers/chat/proposalTools.d.ts +96 -0
  79. package/dist/handlers/chat/proposalTools.js +451 -0
  80. package/dist/handlers/chat/streaming.d.ts +38 -0
  81. package/dist/handlers/chat/streaming.js +136 -0
  82. package/dist/handlers/chat/toolFactory.d.ts +79 -0
  83. package/dist/handlers/chat/toolFactory.js +156 -0
  84. package/dist/handlers/chat/types.d.ts +57 -0
  85. package/dist/handlers/chat/types.js +1 -0
  86. package/dist/handlers/chatHandler.d.ts +1 -44
  87. package/dist/handlers/chatHandler.js +186 -1162
  88. package/dist/handlers/generateFieldHandler.d.ts +3 -8
  89. package/dist/handlers/generateFieldHandler.js +71 -99
  90. package/dist/handlers/http.d.ts +17 -0
  91. package/dist/handlers/http.js +21 -0
  92. package/dist/handlers/mediaUploadHandler.js +12 -34
  93. package/dist/handlers/mentionSuggestionHandler.d.ts +1 -1
  94. package/dist/handlers/mentionSuggestionHandler.js +6 -10
  95. package/dist/handlers/proposalDiffHandler.d.ts +1 -1
  96. package/dist/handlers/proposalDiffHandler.js +72 -195
  97. package/dist/handlers/translateDocumentHandler.d.ts +3 -8
  98. package/dist/handlers/translateDocumentHandler.js +26 -82
  99. package/dist/index.d.ts +4 -30
  100. package/dist/index.js +31 -421
  101. package/dist/plugin/admin.d.ts +30 -0
  102. package/dist/plugin/admin.js +155 -0
  103. package/dist/plugin/collections.d.ts +3 -0
  104. package/dist/plugin/collections.js +169 -0
  105. package/dist/plugin/endpoints.d.ts +23 -0
  106. package/dist/plugin/endpoints.js +84 -0
  107. package/dist/plugin/options.d.ts +4 -0
  108. package/dist/plugin/options.js +13 -0
  109. package/dist/plugin/types.d.ts +28 -0
  110. package/dist/plugin/types.js +1 -0
  111. package/dist/{payload/shared.d.ts → utils/data.d.ts} +2 -12
  112. package/package.json +1 -1
  113. package/dist/payload/proposalData.js +0 -578
  114. /package/dist/{payload → features}/collectionPermissions.d.ts +0 -0
  115. /package/dist/{payload → features/content}/documentTranslation.d.ts +0 -0
  116. /package/dist/{payload → features/content}/documentTranslation.js +0 -0
  117. /package/dist/{ai → features}/promptCaching.js +0 -0
  118. /package/dist/{ai/proposalRepair.js → features/proposals/repair.js} +0 -0
  119. /package/dist/{ai/providerOptions.d.ts → features/providers/options.d.ts} +0 -0
  120. /package/dist/{ai/providerOptions.js → features/providers/options.js} +0 -0
  121. /package/dist/{payload/normalizeData.d.ts → features/schema/normalize.d.ts} +0 -0
  122. /package/dist/{ai → features}/sensitiveData.d.ts +0 -0
  123. /package/dist/{ai → features}/tokenUsage.js +0 -0
  124. /package/dist/{payload/shared.js → utils/data.js} +0 -0
  125. /package/dist/{payload → utils}/logging.d.ts +0 -0
  126. /package/dist/{payload → utils}/logging.js +0 -0
@@ -1,568 +1,21 @@
1
1
  import { stepCountIs, streamText } from "ai";
2
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 { createCachedSystemMessages, createPromptCacheProviderOptions, splitPromptCacheContext } from "../ai/promptCaching.js";
7
- import { compactProposalRepairIssues, createProposalRepairTracker } from "../ai/proposalRepair.js";
8
- import { containsSensitiveData, isSensitiveKey, redactSensitiveData } from "../ai/sensitiveData.js";
9
- import { getExceededTokenUsageLimit, recordTokenUsage } from "../ai/tokenUsage.js";
10
- import { isCollectionActionAllowed } from "../payload/collectionPermissions.js";
11
- import { prepareProposalWriteData } from "../payload/proposalData.js";
12
- import { buildPromptWithMentionContext, collectBlocks, describeCollectionLikeConfig, describeCollectionLikeSummary, getAllowedCollectionSlugs, getMentionContext } from "../payload/schemaContext.js";
13
- import { getLogPreview, logHandlerEvent } from "../payload/logging.js";
14
- import { getOptionValue, getSafeProposalLabel, hasLocalizedData, hasValueAtPath, isRecord, setValueAtPath } from "../payload/shared.js";
15
- import { createLocalizedPayloadDataSchema, createPayloadDataSchema, genericPayloadDataSchema } from "../payload/toolSchemas.js";
16
- import { createToolFieldNamesSchema, resolveToolFieldSelection } from "../payload/toolFieldSelection.js";
3
+ import { compactProposalRepairIssues } from "../features/proposals/repair.js";
4
+ import { isSensitiveKey, redactSensitiveData } from "../features/sensitiveData.js";
5
+ import { resolveAIRequestContext } from "../features/providers/requestContext.js";
6
+ import { prepareProposalWriteData } from "../features/proposals/data.js";
7
+ import { buildPromptWithMentionContext, createSchemaContextRegistry, describeCollectionLikeConfig, describeCollectionLikeSummary, getAllowedCollectionSlugs, getMentionContext } from "../features/schema/context.js";
8
+ import { getLogPreview, logHandlerEvent } from "../utils/logging.js";
9
+ import { getSafeProposalLabel, isRecord } from "../utils/data.js";
10
+ import { createLocalizedPayloadDataSchema, createPayloadDataSchema, genericPayloadDataSchema } from "../features/schema/toolSchemas.js";
11
+ import { createToolFieldNamesSchema, resolveToolFieldSelection } from "../features/schema/fieldSelection.js";
12
+ import { createCollectionAliasMap, getChatIntent, getIntentToolChoice, getLikelyCollectionMatches, getToolNamesForIntent } from "./chat/intent.js";
13
+ import { createChatPromptContext } from "./chat/prompt.js";
14
+ import { createChatToolFactory } from "./chat/toolFactory.js";
15
+ import { jsonError, logHandlerError, readJSONBody, withAuthenticatedHandler } from "./http.js";
16
+ import { collectProposalBlockTypes, getCollectionBlockTypes, getProposalSummary, getRequestedBlockTypes, getRequiredFieldInfos, validateProposalReferences } from "./chat/proposalTools.js";
17
+ import { createDebugPayload, createE2EChatResponse, createInitialChatDebug, getChatCompletionReason } from "./chat/streaming.js";
17
18
  const e2eModeEnabled = ()=>process.env.PAYLOAD_AI_E2E_MODE === "true";
18
- const createSSEEventStream = (events)=>{
19
- const encoder = new TextEncoder();
20
- return new ReadableStream({
21
- start (controller) {
22
- for (const { data, event } of events){
23
- controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
24
- }
25
- controller.close();
26
- }
27
- });
28
- };
29
- const getChatCompletionReason = ({ proposalCount, toolFailures, writeIntent })=>{
30
- if (proposalCount > 0) return "proposal_created";
31
- if (toolFailures.length > 0) return "tool_validation_failed";
32
- if (writeIntent) return "write_intent_without_tool_call";
33
- return "model_did_not_call_tool";
34
- };
35
- const createDebugPayload = ({ activeLocale, debug, proposalCount, selectedLocales, toolFailures, usage, writeIntent })=>({
36
- activeLocale,
37
- model: debug.model,
38
- proposalCount,
39
- provider: debug.provider,
40
- reason: getChatCompletionReason({
41
- proposalCount,
42
- toolFailures,
43
- writeIntent
44
- }),
45
- selectedLocales,
46
- toolFailures,
47
- usage
48
- });
49
- const createE2EChatResponse = ({ prompt, selectedLocales })=>{
50
- const normalizedPrompt = prompt.toLowerCase();
51
- const wantsCreatePost = normalizedPrompt.includes("post") && (normalizedPrompt.includes("create") || normalizedPrompt.includes("erstell") || normalizedPrompt.includes("apply flow") || normalizedPrompt.includes("locale review") || normalizedPrompt.includes("proposal review"));
52
- const mentionsMars = normalizedPrompt.includes("mars");
53
- const multipleLocales = selectedLocales.length > 1;
54
- const activeLocale = selectedLocales.at(-1);
55
- 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";
56
- const proposal = wantsCreatePost && mentionsMars ? signAIActionProposal(multipleLocales ? {
57
- action: "create",
58
- collection: "posts",
59
- label: proposalLabel,
60
- localizedData: Object.fromEntries(selectedLocales.map((locale)=>[
61
- locale,
62
- {
63
- content: locale === "de" ? "Mars ist der vierte Planet von der Sonne." : "Mars is the fourth planet from the Sun.",
64
- excerpt: locale === "de" ? "Ein kurzer Entwurf ueber Mars." : "A short draft about Mars.",
65
- title: locale === "de" ? "Mars im Ueberblick" : "Mars Overview"
66
- }
67
- ])),
68
- ...activeLocale ? {
69
- locale: activeLocale
70
- } : {}
71
- } : {
72
- action: "create",
73
- collection: "posts",
74
- data: {
75
- content: "Mars is the fourth planet from the Sun.",
76
- excerpt: "A short draft about Mars.",
77
- status: "draft",
78
- title: "Mars Overview"
79
- },
80
- label: proposalLabel,
81
- ...activeLocale ? {
82
- locale: activeLocale
83
- } : {}
84
- }) : null;
85
- const responseText = proposal ? "Prepared one draft post proposal." : "No content change was proposed.";
86
- const debugPayload = createDebugPayload({
87
- debug: {
88
- model: "e2e-model",
89
- provider: "openai",
90
- tools: []
91
- },
92
- proposalCount: proposal ? 1 : 0,
93
- selectedLocales,
94
- toolFailures: [],
95
- usage: {
96
- inputTokens: 42,
97
- outputTokens: 27,
98
- totalTokens: 69
99
- },
100
- writeIntent: wantsCreatePost
101
- });
102
- return new Response(createSSEEventStream([
103
- {
104
- data: {
105
- delta: responseText
106
- },
107
- event: "text"
108
- },
109
- {
110
- data: {
111
- proposals: proposal ? [
112
- proposal
113
- ] : [],
114
- usage: {
115
- inputTokens: 42,
116
- outputTokens: 27,
117
- totalTokens: 69
118
- }
119
- },
120
- event: "proposals"
121
- },
122
- {
123
- data: debugPayload,
124
- event: "debug"
125
- },
126
- {
127
- data: {},
128
- event: "done"
129
- }
130
- ]), {
131
- headers: {
132
- "Cache-Control": "no-cache, no-transform",
133
- Connection: "keep-alive",
134
- "Content-Type": "text/event-stream; charset=utf-8"
135
- }
136
- });
137
- };
138
- const getRequiredFieldInfos = (fields, titleFieldName, path = "")=>{
139
- return fields.flatMap((field)=>{
140
- const fieldPath = field.name ? path ? `${path}.${field.name}` : field.name : path;
141
- const requiredField = field.required && fieldPath ? [
142
- {
143
- defaultValue: field.defaultValue,
144
- isTitleField: field.name === titleFieldName,
145
- localized: Boolean(field.localized),
146
- options: field.options,
147
- path: fieldPath,
148
- type: field.type
149
- }
150
- ] : [];
151
- const nestedFields = field.fields?.length ? getRequiredFieldInfos(field.fields, titleFieldName, fieldPath) : [];
152
- const nestedBlocks = field.blocks?.length ? field.blocks.flatMap((block)=>getRequiredFieldInfos(block.fields || [], titleFieldName, `${fieldPath}.${block.slug}`)) : [];
153
- return [
154
- ...requiredField,
155
- ...nestedFields,
156
- ...nestedBlocks
157
- ];
158
- });
159
- };
160
- const getCreateFallbackValue = ({ field, label })=>{
161
- if (field.defaultValue !== undefined) return field.defaultValue;
162
- if (field.path === "_status") return "draft";
163
- if (field.type === "checkbox") return false;
164
- if (field.type === "select" || field.type === "radio") {
165
- return getOptionValue(field.options?.[0]);
166
- }
167
- const terminalSegment = field.path.split(".").at(-1)?.toLowerCase();
168
- if ((field.isTitleField || [
169
- "title",
170
- "name",
171
- "label",
172
- "headline"
173
- ].includes(terminalSegment || "")) && [
174
- "text",
175
- "textarea"
176
- ].includes(field.type || "")) {
177
- return getSafeProposalLabel(label);
178
- }
179
- return undefined;
180
- };
181
- const fillMissingCreateFields = ({ data, label, localizedData, requiredFields })=>{
182
- if (localizedData) {
183
- const completedLocalizedData = Object.fromEntries(Object.entries(localizedData).map(([locale, localeData])=>[
184
- locale,
185
- {
186
- ...localeData
187
- }
188
- ]));
189
- const localeEntries = Object.entries(completedLocalizedData);
190
- const [firstLocale, firstLocaleData] = localeEntries[0] || [];
191
- for (const [locale, localeData] of localeEntries){
192
- for (const field of requiredFields.filter((item)=>item.localized)){
193
- if (hasValueAtPath(localeData, field.path)) continue;
194
- const fallbackValue = getCreateFallbackValue({
195
- field,
196
- label
197
- });
198
- if (fallbackValue !== undefined) {
199
- setValueAtPath(localeData, field.path, fallbackValue);
200
- }
201
- }
202
- }
203
- if (firstLocale && firstLocaleData) {
204
- for (const field of requiredFields.filter((item)=>!item.localized)){
205
- if (hasValueAtPath(firstLocaleData, field.path)) continue;
206
- const fallbackValue = getCreateFallbackValue({
207
- field,
208
- label
209
- });
210
- if (fallbackValue !== undefined) {
211
- setValueAtPath(firstLocaleData, field.path, fallbackValue);
212
- }
213
- }
214
- }
215
- return {
216
- localizedData: completedLocalizedData
217
- };
218
- }
219
- const completedData = {
220
- ...data || {}
221
- };
222
- for (const field of requiredFields){
223
- if (hasValueAtPath(completedData, field.path)) continue;
224
- const fallbackValue = getCreateFallbackValue({
225
- field,
226
- label
227
- });
228
- if (fallbackValue !== undefined) {
229
- setValueAtPath(completedData, field.path, fallbackValue);
230
- }
231
- }
232
- return {
233
- data: completedData
234
- };
235
- };
236
- const getMissingCreateFields = ({ data, localizedData, requiredFields })=>{
237
- if (localizedData) {
238
- const locales = Object.entries(localizedData);
239
- const firstLocale = locales[0];
240
- if (!firstLocale) {
241
- return [
242
- "localizedData must include at least one locale entry"
243
- ];
244
- }
245
- const missing = [];
246
- const localizedRequiredFields = [];
247
- const sharedRequiredFields = [];
248
- for (const field of requiredFields){
249
- if (field.localized) {
250
- localizedRequiredFields.push(field);
251
- } else {
252
- sharedRequiredFields.push(field);
253
- }
254
- }
255
- for (const [locale, localeData] of locales){
256
- for (const field of localizedRequiredFields){
257
- if (!hasValueAtPath(localeData, field.path)) {
258
- missing.push(`${locale}:${field.path}`);
259
- }
260
- }
261
- }
262
- for (const field of sharedRequiredFields){
263
- if (!hasValueAtPath(firstLocale[1], field.path)) {
264
- missing.push(`${firstLocale[0]}:${field.path}`);
265
- }
266
- }
267
- return missing;
268
- }
269
- if (!data) return [
270
- "data is required"
271
- ];
272
- return requiredFields.flatMap((field)=>hasValueAtPath(data, field.path) ? [] : [
273
- field.path
274
- ]);
275
- };
276
- const getProposalSummary = (proposal)=>({
277
- action: proposal.action,
278
- collection: "collection" in proposal ? proposal.collection : undefined,
279
- hasData: "data" in proposal && Boolean(proposal.data),
280
- hasLocalizedData: "localizedData" in proposal && Boolean(proposal.localizedData),
281
- id: "id" in proposal ? proposal.id : undefined,
282
- label: proposal.label,
283
- locale: proposal.locale,
284
- locales: "localizedData" in proposal && proposal.localizedData ? Object.keys(proposal.localizedData) : undefined,
285
- slug: "slug" in proposal ? proposal.slug : undefined
286
- });
287
- const getCollectionBlockTypes = (fields)=>{
288
- const blockTypes = new Set();
289
- const visitFields = (items)=>{
290
- for (const field of items){
291
- if (field.type === "blocks" && field.blocks) {
292
- for (const block of field.blocks){
293
- blockTypes.add(block.slug);
294
- if (block.fields?.length) {
295
- visitFields(block.fields);
296
- }
297
- }
298
- }
299
- if (field.fields?.length) {
300
- visitFields(field.fields);
301
- }
302
- }
303
- };
304
- visitFields(fields);
305
- return [
306
- ...blockTypes
307
- ];
308
- };
309
- const regexSpecialCharactersPattern = /[.*+?^${}()|[\]\\]/g;
310
- const getRequestedBlockTypes = ({ availableBlockTypes, mentions, prompt })=>{
311
- const requestedBlockTypes = new Set();
312
- const normalizedPrompt = prompt.toLowerCase();
313
- const availableBlockTypeSet = new Set(availableBlockTypes);
314
- const blockTypesByNormalizedSlug = new Map(availableBlockTypes.map((blockType)=>[
315
- blockType.toLowerCase(),
316
- blockType
317
- ]));
318
- const escapedBlockTypes = availableBlockTypes.map((blockType)=>blockType.replace(regexSpecialCharactersPattern, "\\$&")).join("|");
319
- const blockPattern = escapedBlockTypes ? new RegExp(`\\b(${escapedBlockTypes})\\b(?:\\s+block)?`, "gi") : null;
320
- for (const mention of mentions || []){
321
- if (mention.type === "block" && mention.slug && availableBlockTypeSet.has(mention.slug)) {
322
- requestedBlockTypes.add(mention.slug);
323
- }
324
- }
325
- if (blockPattern) {
326
- for (const match of normalizedPrompt.matchAll(blockPattern)){
327
- const blockType = match[1] ? blockTypesByNormalizedSlug.get(match[1].toLowerCase()) : null;
328
- if (blockType) {
329
- requestedBlockTypes.add(blockType);
330
- }
331
- }
332
- }
333
- return [
334
- ...requestedBlockTypes
335
- ];
336
- };
337
- const collectProposalBlockTypes = ({ data, fields })=>{
338
- const foundBlockTypes = new Set();
339
- const visitFields = (items, value)=>{
340
- for (const field of items){
341
- if (!field.name) continue;
342
- const fieldValue = value[field.name];
343
- if (fieldValue === undefined || fieldValue === null) continue;
344
- if (field.type === "blocks" && Array.isArray(fieldValue)) {
345
- const blocksBySlug = new Map(field.blocks?.map((block)=>[
346
- block.slug,
347
- block
348
- ]));
349
- for (const blockItem of fieldValue){
350
- if (!isRecord(blockItem)) continue;
351
- const blockType = typeof blockItem.blockType === "string" ? blockItem.blockType : typeof blockItem.type === "string" ? blockItem.type : typeof blockItem.slug === "string" ? blockItem.slug : null;
352
- if (blockType) {
353
- foundBlockTypes.add(blockType);
354
- }
355
- if (blockType) {
356
- const blockConfig = blocksBySlug.get(blockType);
357
- if (blockConfig?.fields?.length) {
358
- visitFields(blockConfig.fields, blockItem);
359
- }
360
- }
361
- }
362
- continue;
363
- }
364
- if (field.type === "group" && isRecord(fieldValue) && field.fields?.length) {
365
- visitFields(field.fields, fieldValue);
366
- continue;
367
- }
368
- if (field.type === "array" && Array.isArray(fieldValue) && field.fields?.length) {
369
- for (const item of fieldValue){
370
- if (isRecord(item)) {
371
- visitFields(field.fields, item);
372
- }
373
- }
374
- }
375
- }
376
- };
377
- visitFields(fields, data);
378
- return foundBlockTypes;
379
- };
380
- const collectRelationshipTargets = ({ data, fields, path = "" })=>{
381
- const targets = [];
382
- for (const field of fields){
383
- if (!field.name) continue;
384
- const fieldPath = path ? `${path}.${field.name}` : field.name;
385
- const fieldValue = data[field.name];
386
- if (fieldValue === undefined || fieldValue === null) continue;
387
- if (field.type === "relationship" || field.type === "upload") {
388
- const relationTargets = Array.isArray(field.relationTo) ? field.relationTo.filter((item)=>typeof item === "string") : typeof field.relationTo === "string" ? [
389
- field.relationTo
390
- ] : [];
391
- const collectSingle = (value, itemPath)=>{
392
- if (typeof value === "string" || typeof value === "number") {
393
- if (relationTargets.length === 1) {
394
- targets.push({
395
- collection: relationTargets[0],
396
- id: value,
397
- path: itemPath
398
- });
399
- }
400
- return;
401
- }
402
- if (!isRecord(value)) return;
403
- const relationTo = typeof value.relationTo === "string" ? value.relationTo : relationTargets[0];
404
- const id = typeof value.id === "string" || typeof value.id === "number" ? value.id : typeof value.value === "string" || typeof value.value === "number" ? value.value : undefined;
405
- if (!relationTo || id === undefined) return;
406
- targets.push({
407
- collection: relationTo,
408
- id,
409
- path: itemPath
410
- });
411
- };
412
- if (field.hasMany && Array.isArray(fieldValue)) {
413
- fieldValue.forEach((item, index)=>collectSingle(item, `${fieldPath}.${index}`));
414
- } else {
415
- collectSingle(fieldValue, fieldPath);
416
- }
417
- continue;
418
- }
419
- if (field.type === "group" && isRecord(fieldValue) && field.fields?.length) {
420
- targets.push(...collectRelationshipTargets({
421
- data: fieldValue,
422
- fields: field.fields,
423
- path: fieldPath
424
- }));
425
- continue;
426
- }
427
- if (field.type === "array" && Array.isArray(fieldValue) && field.fields?.length) {
428
- fieldValue.forEach((item, index)=>{
429
- if (!isRecord(item)) return;
430
- targets.push(...collectRelationshipTargets({
431
- data: item,
432
- fields: field.fields,
433
- path: `${fieldPath}.${index}`
434
- }));
435
- });
436
- continue;
437
- }
438
- if (field.type === "blocks" && Array.isArray(fieldValue) && field.blocks?.length) {
439
- const blocksBySlug = new Map(field.blocks.map((block)=>[
440
- block.slug,
441
- block
442
- ]));
443
- fieldValue.forEach((item, index)=>{
444
- if (!isRecord(item)) return;
445
- const blockType = typeof item.blockType === "string" ? item.blockType : typeof item.type === "string" ? item.type : typeof item.slug === "string" ? item.slug : null;
446
- if (!blockType) return;
447
- const blockConfig = blocksBySlug.get(blockType);
448
- if (!blockConfig?.fields?.length) return;
449
- targets.push(...collectRelationshipTargets({
450
- data: item,
451
- fields: blockConfig.fields,
452
- path: `${fieldPath}.${index}`
453
- }));
454
- });
455
- }
456
- }
457
- return targets;
458
- };
459
- const collectUploadTargets = ({ data, fields, path = "" })=>{
460
- const targets = [];
461
- for (const field of fields){
462
- if (!field.name) continue;
463
- const fieldPath = path ? `${path}.${field.name}` : field.name;
464
- const fieldValue = data[field.name];
465
- if (fieldValue === undefined || fieldValue === null) continue;
466
- if (field.type === "upload") {
467
- const relationTargets = Array.isArray(field.relationTo) ? field.relationTo.filter((item)=>typeof item === "string") : typeof field.relationTo === "string" ? [
468
- field.relationTo
469
- ] : [];
470
- const collectSingle = (value, itemPath)=>{
471
- if (typeof value === "string" || typeof value === "number") {
472
- if (relationTargets.length === 1) {
473
- targets.push({
474
- collection: relationTargets[0],
475
- id: value,
476
- path: itemPath
477
- });
478
- }
479
- return;
480
- }
481
- if (!isRecord(value)) return;
482
- const relationTo = typeof value.relationTo === "string" ? value.relationTo : relationTargets[0];
483
- const id = typeof value.id === "string" || typeof value.id === "number" ? value.id : typeof value.value === "string" || typeof value.value === "number" ? value.value : undefined;
484
- if (!relationTo || id === undefined) return;
485
- targets.push({
486
- collection: relationTo,
487
- id,
488
- path: itemPath
489
- });
490
- };
491
- if (field.hasMany && Array.isArray(fieldValue)) {
492
- fieldValue.forEach((item, index)=>collectSingle(item, `${fieldPath}.${index}`));
493
- } else {
494
- collectSingle(fieldValue, fieldPath);
495
- }
496
- continue;
497
- }
498
- if (field.type === "group" && isRecord(fieldValue) && field.fields?.length) {
499
- targets.push(...collectUploadTargets({
500
- data: fieldValue,
501
- fields: field.fields,
502
- path: fieldPath
503
- }));
504
- continue;
505
- }
506
- if (field.type === "array" && Array.isArray(fieldValue) && field.fields?.length) {
507
- fieldValue.forEach((item, index)=>{
508
- if (!isRecord(item)) return;
509
- targets.push(...collectUploadTargets({
510
- data: item,
511
- fields: field.fields,
512
- path: `${fieldPath}.${index}`
513
- }));
514
- });
515
- continue;
516
- }
517
- if (field.type === "blocks" && Array.isArray(fieldValue) && field.blocks?.length) {
518
- const blocksBySlug = new Map(field.blocks.map((block)=>[
519
- block.slug,
520
- block
521
- ]));
522
- fieldValue.forEach((item, index)=>{
523
- if (!isRecord(item)) return;
524
- const blockType = typeof item.blockType === "string" ? item.blockType : typeof item.type === "string" ? item.type : typeof item.slug === "string" ? item.slug : null;
525
- if (!blockType) return;
526
- const blockConfig = blocksBySlug.get(blockType);
527
- if (!blockConfig?.fields?.length) return;
528
- targets.push(...collectUploadTargets({
529
- data: item,
530
- fields: blockConfig.fields,
531
- path: `${fieldPath}.${index}`
532
- }));
533
- });
534
- }
535
- }
536
- return targets;
537
- };
538
- const validateRelationshipTargetsExist = async ({ data, fields, req })=>{
539
- const targets = collectRelationshipTargets({
540
- data,
541
- fields
542
- });
543
- const targetResults = await Promise.all(targets.map(async (target)=>{
544
- try {
545
- await req.payload.findByID({
546
- collection: target.collection,
547
- depth: 0,
548
- id: String(target.id),
549
- overrideAccess: false,
550
- req
551
- });
552
- return null;
553
- } catch {
554
- return target;
555
- }
556
- }));
557
- return targetResults.filter((target)=>Boolean(target));
558
- };
559
- const getUploadTargetsOutsideAttachments = ({ allowedAttachmentKeys, data, fields })=>{
560
- if (allowedAttachmentKeys.size === 0) return [];
561
- return collectUploadTargets({
562
- data,
563
- fields
564
- }).filter((target)=>!allowedAttachmentKeys.has(`${target.collection}:${String(target.id)}`));
565
- };
566
19
  const getMentionSummary = (mentions)=>mentions?.map((mention)=>({
567
20
  collection: "collection" in mention ? mention.collection : undefined,
568
21
  id: "id" in mention ? mention.id : undefined,
@@ -606,194 +59,10 @@ const getMediaAttachmentContext = async ({ allowedCollectionsBySlug, attachments
606
59
  }
607
60
  return contexts;
608
61
  };
609
- const createCollectionAliasMap = (collections)=>{
610
- const aliasMap = new Map();
611
- const addAlias = (alias, slug)=>{
612
- const normalizedAlias = alias?.trim().toLowerCase();
613
- if (!normalizedAlias) return;
614
- if (!aliasMap.has(normalizedAlias)) {
615
- aliasMap.set(normalizedAlias, slug);
616
- }
617
- };
618
- for (const collection of collections){
619
- addAlias(collection.slug, collection.slug);
620
- addAlias(collection.slug.replace(/-/g, " "), collection.slug);
621
- const singular = typeof collection.labels?.singular === "string" ? collection.labels.singular : undefined;
622
- const plural = typeof collection.labels?.plural === "string" ? collection.labels.plural : undefined;
623
- addAlias(singular, collection.slug);
624
- addAlias(plural, collection.slug);
625
- if (singular?.endsWith("s")) {
626
- addAlias(singular.slice(0, -1), collection.slug);
627
- }
628
- if (plural?.endsWith("s")) {
629
- addAlias(plural.slice(0, -1), collection.slug);
630
- }
631
- }
632
- return Object.fromEntries(aliasMap.entries());
633
- };
634
- const getLikelyCollectionMatches = ({ aliasMap, prompt })=>{
635
- const normalizedPrompt = prompt.toLowerCase();
636
- const matches = new Set();
637
- const aliases = Object.keys(aliasMap).sort((a, b)=>b.length - a.length);
638
- const aliasPattern = aliases.length > 0 ? new RegExp(aliases.map((alias)=>alias.replace(regexSpecialCharactersPattern, "\\$&")).join("|"), "g") : null;
639
- if (!aliasPattern) return [];
640
- for (const match of normalizedPrompt.matchAll(aliasPattern)){
641
- const alias = match[0];
642
- const slug = aliasMap[alias];
643
- if (slug) {
644
- matches.add(slug);
645
- }
646
- }
647
- return [
648
- ...matches
649
- ];
650
- };
651
- const normalizeIntentText = (prompt)=>prompt.toLowerCase().replace(/ä/g, "ae").replace(/ö/g, "oe").replace(/ü/g, "ue").replace(/ß/g, "ss").normalize("NFKD").replace(/[\u0300-\u036f]/g, "");
652
- const deleteIntentPattern = /\b(?:delete|remove|erase|destroy|discard|purge|loesch\w*|losch\w*|entfern\w*|rausnehm\w*|wegnehm\w*|verwerf\w*)\b/;
653
- const createIntentPattern = /\b(?:create|build|generate|draft|compose|author|write|make|new|erstell\w*|anleg\w*|erzeug\w*|generier\w*|entwerf\w*|verfass\w*|schreib\w*|neu)\b/;
654
- const updateIntentPattern = /\b(?:update|edit|change|modify|revise|refine|rewrite|translate|replace|rename|correct|fix|set|publish|unpublish|aktualisier\w*|aender\w*|bearbeit\w*|anpass\w*|ueberarbeit\w*|uberarbeit\w*|umschreib\w*|uebersetz\w*|ubersetz\w*|ersetz\w*|umbenenn\w*|korrigier\w*|reparier\w*|setz\w*|veroeffentlich\w*|veroffentlich\w*|zurueckzieh\w*|zuruckzieh\w*)\b/;
655
- const additiveIntentPattern = /\b(?:add|insert|append|attach|assign|link|include|extend|ergaenz\w*|erganz\w*|hinzufueg\w*|hinzufug\w*|einfueg\w*|einfug\w*|anhaeng\w*|anhang\w*|zuweis\w*|verknuepf\w*|verknupf\w*)\b/;
656
- const searchIntentPattern = /\b(?:search|find|lookup|locate|list|show|fetch|browse|query|such\w*|find\w*|nachschlag\w*|auflist\w*|anzeig\w*|zeig\w*|hol\w*|durchsuch\w*)\b/;
657
- const separableDeleteIntentPattern = /\b(?:nimm|nehm\w*)\b(?:\s+\w+){0,8}\s+(?:raus|weg)\b/;
658
- const separableUpdateIntentPattern = /\b(?:pass(?:e|t|en|st)?\b(?:\s+\w+){0,8}\s+an|fueg\w*\b(?:\s+\w+){0,8}\s+(?:ein|hinzu)|haeng\w*\b(?:\s+\w+){0,8}\s+an|weis\w*\b(?:\s+\w+){0,8}\s+zu)\b/;
659
- const getChatIntent = ({ hasCurrentDocument, hasCurrentGlobal, prompt })=>{
660
- const normalizedPrompt = normalizeIntentText(prompt);
661
- if (deleteIntentPattern.test(normalizedPrompt) || separableDeleteIntentPattern.test(normalizedPrompt)) {
662
- return hasCurrentGlobal ? "read" : "delete";
663
- }
664
- if (createIntentPattern.test(normalizedPrompt)) {
665
- if (hasCurrentGlobal) return "updateGlobal";
666
- if (hasCurrentDocument) return "update";
667
- return "create";
668
- }
669
- if (updateIntentPattern.test(normalizedPrompt) || additiveIntentPattern.test(normalizedPrompt) || separableUpdateIntentPattern.test(normalizedPrompt)) {
670
- return hasCurrentGlobal ? "updateGlobal" : "update";
671
- }
672
- if (searchIntentPattern.test(normalizedPrompt)) {
673
- return "search";
674
- }
675
- return "read";
676
- };
677
- const getIntentToolChoice = ({ hasKnownCollection, hasKnownDocument, hasKnownGlobal, intent })=>{
678
- if (intent === "create" && hasKnownCollection) {
679
- return {
680
- toolName: "proposeCreateDoc",
681
- type: "tool"
682
- };
683
- }
684
- if (intent === "update" && hasKnownDocument) {
685
- return {
686
- toolName: "proposeUpdateDoc",
687
- type: "tool"
688
- };
689
- }
690
- if (intent === "delete" && hasKnownDocument) {
691
- return {
692
- toolName: "proposeDeleteDoc",
693
- type: "tool"
694
- };
695
- }
696
- if (intent === "updateGlobal" && hasKnownGlobal) {
697
- return {
698
- toolName: "proposeUpdateGlobal",
699
- type: "tool"
700
- };
701
- }
702
- return undefined;
703
- };
704
- const getToolNamesForIntent = ({ hasAttachments, hasKnownCollection, hasCurrentDocument, hasCurrentGlobal, intent })=>{
705
- if (hasCurrentGlobal) {
706
- return new Set(intent === "updateGlobal" ? [
707
- "getGlobal",
708
- "listGlobals",
709
- "proposeUpdateGlobal"
710
- ] : [
711
- "getGlobal",
712
- "listGlobals"
713
- ]);
714
- }
715
- if (hasCurrentDocument) {
716
- if (intent === "delete") return new Set([
717
- "getDoc",
718
- "proposeDeleteDoc"
719
- ]);
720
- if (intent === "update" || intent === "create") return new Set([
721
- "getDoc",
722
- "listCollections",
723
- "proposeUpdateDoc"
724
- ]);
725
- return new Set([
726
- "getDoc",
727
- "listCollections"
728
- ]);
729
- }
730
- if (hasKnownCollection) {
731
- if (intent === "create") return new Set([
732
- "getDoc",
733
- "listCollections",
734
- "proposeCreateDoc",
735
- "searchDocs"
736
- ]);
737
- if (intent === "update") return new Set([
738
- "getDoc",
739
- "listCollections",
740
- "proposeUpdateDoc",
741
- "searchDocs"
742
- ]);
743
- if (intent === "delete") return new Set([
744
- "getDoc",
745
- "proposeDeleteDoc",
746
- "searchDocs"
747
- ]);
748
- return new Set([
749
- "getDoc",
750
- "listCollections",
751
- "searchDocs"
752
- ]);
753
- }
754
- const toolNames = intent === "create" ? new Set([
755
- "getDoc",
756
- "listCollections",
757
- "proposeCreateDoc",
758
- "searchDocs"
759
- ]) : intent === "update" ? new Set([
760
- "getDoc",
761
- "getGlobal",
762
- "listCollections",
763
- "listGlobals",
764
- "proposeUpdateDoc",
765
- "proposeUpdateGlobal",
766
- "searchDocs"
767
- ]) : intent === "delete" ? new Set([
768
- "getDoc",
769
- "listCollections",
770
- "proposeDeleteDoc",
771
- "searchDocs"
772
- ]) : new Set([
773
- "getDoc",
774
- "getGlobal",
775
- "listCollections",
776
- "listGlobals",
777
- "searchDocs"
778
- ]);
779
- if (hasAttachments && (intent === "create" || intent === "update")) {
780
- toolNames.add("proposeUpdateDoc");
781
- }
782
- return toolNames;
783
- };
784
- export const createChatHandler = (options = {})=>async (req)=>{
785
- if (!req.user) return Response.json({
786
- error: "Unauthorized"
787
- }, {
788
- status: 401
789
- });
790
- const body = req.json ? await req.json().catch(()=>null) : null;
62
+ export const createChatHandler = (options = {})=>withAuthenticatedHandler(async (req)=>{
63
+ const body = await readJSONBody(req);
791
64
  const prompt = body?.prompt?.trim();
792
- if (!prompt) return Response.json({
793
- error: "Prompt is required"
794
- }, {
795
- status: 400
796
- });
65
+ if (!prompt) return jsonError("Prompt is required");
797
66
  const selectedLocales = [];
798
67
  const selectedLocaleSet = new Set();
799
68
  for (const mention of body?.mentions || []){
@@ -810,79 +79,68 @@ export const createChatHandler = (options = {})=>async (req)=>{
810
79
  });
811
80
  }
812
81
  const user = req.user;
813
- const exceededTokenUsageLimit = await getExceededTokenUsageLimit({
814
- maxTokenUsage: options.maxTokenUsage,
82
+ const aiRequestResolution = await resolveAIRequestContext({
83
+ options,
815
84
  req,
816
- userID: user.id
85
+ requestedModel: body?.model,
86
+ requestedProvider: body?.provider,
87
+ user
817
88
  });
818
- if (exceededTokenUsageLimit) {
819
- const scope = options.maxTokenUsage?.type === "site" ? "site" : "user";
820
- const periodLabel = exceededTokenUsageLimit.period === "day" ? "Daily" : "Weekly";
821
- logHandlerEvent(req, "warn", {
822
- limit: exceededTokenUsageLimit.limit,
823
- msg: "AI chat blocked: token usage limit reached",
824
- period: exceededTokenUsageLimit.period,
825
- scope,
826
- used: exceededTokenUsageLimit.used,
827
- userID: String(user.id)
828
- });
829
- return Response.json({
830
- error: `${periodLabel} AI token limit reached for this ${scope}.`,
831
- limit: exceededTokenUsageLimit.limit,
832
- period: exceededTokenUsageLimit.period,
833
- used: exceededTokenUsageLimit.used
834
- }, {
835
- status: 429
836
- });
837
- }
838
- const managedProviders = options.providers?.length ? options.providers : null;
839
- const requestedProvider = body?.provider || (managedProviders ? managedProviders[0].id : user.aiProvider || "openai");
840
- const managedProvider = managedProviders?.find((providerConfig)=>providerConfig.id === requestedProvider);
841
- if (managedProviders && !managedProvider) {
842
- return Response.json({
843
- error: `Unsupported AI provider: ${requestedProvider}`
844
- }, {
845
- status: 400
89
+ if (!aiRequestResolution.ok) {
90
+ if (aiRequestResolution.error.code === "token_limit") {
91
+ const exceededTokenUsageLimit = aiRequestResolution.error.tokenLimit;
92
+ const scope = options.maxTokenUsage?.type === "site" ? "site" : "user";
93
+ const periodLabel = exceededTokenUsageLimit.period === "day" ? "Daily" : "Weekly";
94
+ logHandlerEvent(req, "warn", {
95
+ limit: exceededTokenUsageLimit.limit,
96
+ msg: "AI chat blocked: token usage limit reached",
97
+ period: exceededTokenUsageLimit.period,
98
+ scope,
99
+ used: exceededTokenUsageLimit.used,
100
+ userID: String(user.id)
101
+ });
102
+ return Response.json({
103
+ error: `${periodLabel} AI token limit reached for this ${scope}.`,
104
+ limit: exceededTokenUsageLimit.limit,
105
+ period: exceededTokenUsageLimit.period,
106
+ used: exceededTokenUsageLimit.used
107
+ }, {
108
+ status: 429
109
+ });
110
+ }
111
+ if (aiRequestResolution.error.code !== "missing_api_key") {
112
+ return jsonError(aiRequestResolution.error.message);
113
+ }
114
+ const missingKeyError = aiRequestResolution.error;
115
+ const debug = createInitialChatDebug({
116
+ model: missingKeyError.modelID,
117
+ provider: missingKeyError.providerID
846
118
  });
847
- }
848
- if (!managedProvider && !isAIProvider(requestedProvider)) {
849
- return Response.json({
850
- error: `Unsupported AI provider: ${requestedProvider}`
851
- }, {
852
- status: 400
119
+ logHandlerEvent(req, "info", {
120
+ activeLocale,
121
+ debug,
122
+ mentionCount: mentionSummary.length,
123
+ mentions: mentionSummary,
124
+ msg: "AI chat started",
125
+ promptPreview: getLogPreview(prompt),
126
+ selectedLocales
853
127
  });
854
- }
855
- const provider = managedProvider?.provider || requestedProvider;
856
- const requestedModel = body?.model || managedProvider?.defaultModel;
857
- if (managedProvider && requestedModel && !managedProvider.models.some((model)=>model.value === requestedModel)) {
858
- return Response.json({
859
- error: `Unsupported model "${requestedModel}" for AI provider "${managedProvider.id}".`
860
- }, {
861
- status: 400
128
+ logHandlerEvent(req, "warn", {
129
+ activeLocale,
130
+ debug,
131
+ msg: "AI chat blocked: missing provider API key",
132
+ promptPreview: getLogPreview(prompt),
133
+ selectedLocales
862
134
  });
135
+ return jsonError(missingKeyError.managedProvider ? `Configure a ${missingKeyError.providerID} API key in the plugin config or server environment first.` : options.allowUserApiKeys === false ? `Configure a ${missingKeyError.provider} API key in the server environment first.` : `Add a ${missingKeyError.provider} API key to your account settings or configure it in the server environment first.`);
863
136
  }
864
- const userApiKey = managedProvider ? managedProvider.apiKey : options.allowUserApiKeys === false ? null : user.aiApiKey;
865
- const providerConfig = getProviderConfig({
866
- apiKey: userApiKey,
867
- defaultModels: options.models?.defaults,
868
- model: requestedModel,
869
- provider
137
+ const resolvedAIRequest = aiRequestResolution.context;
138
+ const provider = resolvedAIRequest.provider;
139
+ const managedProvider = resolvedAIRequest.managedProvider;
140
+ const debug = createInitialChatDebug({
141
+ model: resolvedAIRequest.modelID,
142
+ provider: resolvedAIRequest.providerID
870
143
  });
871
- const debug = {
872
- model: providerConfig.modelID,
873
- provider: managedProvider?.id || provider,
874
- tools: [
875
- "getDoc",
876
- "getGlobal",
877
- "listCollections",
878
- "listGlobals",
879
- "proposeCreateDoc",
880
- "proposeDeleteDoc",
881
- "proposeUpdateDoc",
882
- "proposeUpdateGlobal",
883
- "searchDocs"
884
- ]
885
- };
886
144
  logHandlerEvent(req, "info", {
887
145
  activeLocale,
888
146
  debug,
@@ -892,117 +150,7 @@ export const createChatHandler = (options = {})=>async (req)=>{
892
150
  promptPreview: getLogPreview(prompt),
893
151
  selectedLocales
894
152
  });
895
- if (!providerConfig.apiKey) {
896
- logHandlerEvent(req, "warn", {
897
- activeLocale,
898
- debug,
899
- msg: "AI chat blocked: missing provider API key",
900
- promptPreview: getLogPreview(prompt),
901
- selectedLocales
902
- });
903
- return Response.json({
904
- 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.`
905
- }, {
906
- status: 400
907
- });
908
- }
909
153
  try {
910
- const proposals = [];
911
- const toolFailures = [];
912
- const proposalRepairTracker = createProposalRepairTracker();
913
- const registerToolFailure = (failure)=>{
914
- toolFailures.push(failure);
915
- logHandlerEvent(req, "warn", {
916
- debug,
917
- ...failure,
918
- msg: "AI tool validation failed",
919
- promptPreview: getLogPreview(prompt)
920
- });
921
- };
922
- const createToolError = ({ collection, details, errorCode = "NON_RETRYABLE_TOOL_ERROR", message, slug, tool })=>{
923
- registerToolFailure({
924
- collection,
925
- details,
926
- errorCode,
927
- message,
928
- retryable: false,
929
- slug,
930
- tool
931
- });
932
- return {
933
- error: message,
934
- errorCode,
935
- retryable: false
936
- };
937
- };
938
- const createRepairableToolError = ({ collection, details, id, issues, message, slug, tool })=>{
939
- const repair = proposalRepairTracker.registerFailure({
940
- collection,
941
- id,
942
- slug,
943
- tool
944
- });
945
- const responseMessage = repair.retryable ? message : "The single proposal repair attempt failed. Do not call the same proposal tool for this target again.";
946
- registerToolFailure({
947
- collection,
948
- details,
949
- errorCode: repair.errorCode,
950
- message: responseMessage,
951
- retryable: repair.retryable,
952
- slug,
953
- tool
954
- });
955
- return {
956
- error: responseMessage,
957
- errorCode: repair.errorCode,
958
- repair: {
959
- attempt: repair.attempt,
960
- issues,
961
- maxAttempts: repair.maxAttempts
962
- },
963
- retryable: repair.retryable
964
- };
965
- };
966
- const getProposalRepairLimitError = ({ collection, id, slug, tool })=>{
967
- const callState = proposalRepairTracker.beginCall({
968
- collection,
969
- id,
970
- slug,
971
- tool
972
- });
973
- if (callState !== "blocked") return null;
974
- return createToolError({
975
- collection,
976
- errorCode: "REPAIR_EXHAUSTED",
977
- message: "The single proposal repair attempt was already used. Do not call this proposal tool for the same target again.",
978
- slug,
979
- tool
980
- });
981
- };
982
- const addSignedProposal = (proposal)=>{
983
- if ("data" in proposal && proposal.data && containsSensitiveData(proposal.data)) {
984
- return createToolError({
985
- details: getProposalSummary(proposal),
986
- message: "Proposal contains sensitive fields and cannot be created.",
987
- tool: `propose${proposal.action[0]?.toUpperCase()}${proposal.action.slice(1)}`
988
- });
989
- }
990
- if ("localizedData" in proposal && hasLocalizedData(proposal.localizedData) && Object.values(proposal.localizedData).some((value)=>containsSensitiveData(value))) {
991
- return createToolError({
992
- details: getProposalSummary(proposal),
993
- message: "Proposal contains sensitive fields and cannot be created.",
994
- tool: `propose${proposal.action[0]?.toUpperCase()}${proposal.action.slice(1)}`
995
- });
996
- }
997
- const signedProposal = signAIActionProposal(proposal);
998
- proposals.push(signedProposal);
999
- logHandlerEvent(req, "info", {
1000
- debug,
1001
- msg: "AI proposal created",
1002
- proposal: getProposalSummary(signedProposal)
1003
- });
1004
- return signedProposal;
1005
- };
1006
154
  const requestedDocumentScope = body?.documentScope;
1007
155
  const configuredCollectionSlugs = getAllowedCollectionSlugs(req, options.collections);
1008
156
  const configuredCollectionSlugSet = new Set(configuredCollectionSlugs);
@@ -1011,18 +159,10 @@ export const createChatHandler = (options = {})=>async (req)=>{
1011
159
  const requestedGlobalSlug = requestedDocumentScope?.type === "global" && typeof requestedDocumentScope.slug === "string" ? requestedDocumentScope.slug.trim() : undefined;
1012
160
  const requestedDocumentID = requestedDocumentScope?.type === "collection" && typeof requestedDocumentScope.id === "string" ? requestedDocumentScope.id.trim() : undefined;
1013
161
  if (requestedDocumentScope?.type === "collection" && (!requestedCollectionSlug || !configuredCollectionSlugSet.has(requestedCollectionSlug))) {
1014
- return Response.json({
1015
- error: "The current collection is not available to the AI assistant."
1016
- }, {
1017
- status: 400
1018
- });
162
+ return jsonError("The current collection is not available to the AI assistant.");
1019
163
  }
1020
164
  if (requestedDocumentScope?.type === "global" && (!requestedGlobalSlug || !allGlobalConfigs.some((global)=>global.slug === requestedGlobalSlug))) {
1021
- return Response.json({
1022
- error: "The current global is not available to the AI assistant."
1023
- }, {
1024
- status: 400
1025
- });
165
+ return jsonError("The current global is not available to the AI assistant.");
1026
166
  }
1027
167
  const collectionSlugs = requestedDocumentScope ? requestedCollectionSlug ? [
1028
168
  requestedCollectionSlug
@@ -1030,36 +170,20 @@ export const createChatHandler = (options = {})=>async (req)=>{
1030
170
  const collectionSlugSet = new Set(collectionSlugs);
1031
171
  const globalConfigs = requestedDocumentScope ? requestedGlobalSlug ? allGlobalConfigs.filter((global)=>global.slug === requestedGlobalSlug) : [] : allGlobalConfigs;
1032
172
  const globalSlugs = globalConfigs.map((global)=>global.slug);
1033
- const globalConfigsBySlug = new Map(globalConfigs.map((global)=>[
1034
- global.slug,
1035
- global
1036
- ]));
1037
173
  const allowedCollections = req.payload.config.collections.filter((collection)=>collectionSlugSet.has(collection.slug));
1038
- const allowedCollectionsBySlug = new Map(allowedCollections.map((collection)=>[
1039
- collection.slug,
1040
- collection
1041
- ]));
1042
174
  if (collectionSlugs.length === 0 && globalConfigs.length === 0) {
1043
175
  logHandlerEvent(req, "warn", {
1044
176
  debug,
1045
177
  msg: "AI chat blocked: no AI-enabled collections configured"
1046
178
  });
1047
- return Response.json({
1048
- error: "No AI-enabled collections are configured."
1049
- }, {
1050
- status: 400
1051
- });
179
+ return jsonError("No AI-enabled collections are configured.");
1052
180
  }
1053
- const blockContexts = [
1054
- ...allowedCollections.flatMap((collection)=>collectBlocks({
1055
- fields: collection.fields,
1056
- parent: collection.slug
1057
- })),
1058
- ...globalConfigs.flatMap((global)=>collectBlocks({
1059
- fields: global.fields,
1060
- parent: global.slug
1061
- }))
1062
- ];
181
+ const schemaRegistry = createSchemaContextRegistry({
182
+ collections: allowedCollections,
183
+ globals: globalConfigs,
184
+ req
185
+ });
186
+ const { collectionConfigsBySlug: allowedCollectionsBySlug, globalConfigsBySlug } = schemaRegistry;
1063
187
  const explicitlyMentionedDocumentKeys = new Set((body?.mentions || []).flatMap((mention)=>mention.type === "doc" && mention.collection && mention.id ? [
1064
188
  `${mention.collection}:${mention.id}`
1065
189
  ] : []));
@@ -1067,11 +191,9 @@ export const createChatHandler = (options = {})=>async (req)=>{
1067
191
  mention.slug
1068
192
  ] : []));
1069
193
  const mentionContext = await getMentionContext({
1070
- blockContexts,
1071
- collectionSlugs,
1072
194
  collections: options.collections,
1073
- globalSlugs,
1074
195
  mentions: body?.mentions,
196
+ registry: schemaRegistry,
1075
197
  req
1076
198
  });
1077
199
  if (requestedCollectionSlug && requestedDocumentID) {
@@ -1247,35 +369,29 @@ export const createChatHandler = (options = {})=>async (req)=>{
1247
369
  const updateLocalizedDataSchema = createLocalizedPayloadDataSchema(updateDataSchema);
1248
370
  const globalDataSchema = focusedGlobalConfig ? focusedGlobalDataSchema : genericPayloadDataSchema;
1249
371
  const globalLocalizedDataSchema = createLocalizedPayloadDataSchema(globalDataSchema);
1250
- const getDisallowedCollectionActionError = (collection, action)=>{
1251
- if (isCollectionActionAllowed({
1252
- action,
1253
- permissions: options.collections,
1254
- req,
1255
- slug: collection
1256
- })) return null;
1257
- return createToolError({
1258
- collection,
1259
- message: `${action} is not enabled for collection: ${collection}`,
1260
- tool: "collectionPermissionCheck"
1261
- });
1262
- };
1263
- const allTools = {
1264
- getDoc: {
372
+ const { addProposal: addSignedProposal, collectionTool, error: createToolError, proposalCollectionTool, proposalTool, proposals, repairableError: createRepairableToolError, toolFailures } = createChatToolFactory({
373
+ collections: options.collections,
374
+ currentDocument: requestedCollectionSlug && requestedDocumentID ? {
375
+ collection: requestedCollectionSlug,
376
+ id: requestedDocumentID
377
+ } : undefined,
378
+ debug,
379
+ prompt,
380
+ req
381
+ });
382
+ const toolRegistry = {
383
+ getDoc: collectionTool({
384
+ action: "read",
385
+ currentDocumentOnly: Boolean(requestedDocumentScope),
1265
386
  description: "Read a compact document by collection and id. Optionally request up to 12 exact top-level schema fields. Explicitly @-mentioned documents remain complete.",
387
+ getDocumentID: ({ id })=>id,
1266
388
  inputSchema: z.object({
1267
389
  collection: collectionSlugSchema,
1268
390
  fields: collectionReadFieldsSchema,
1269
391
  id: z.string().min(1)
1270
392
  }),
393
+ name: "getDoc",
1271
394
  execute: async ({ collection, fields, id })=>{
1272
- if (requestedDocumentScope && (collection !== requestedCollectionSlug || id !== requestedDocumentID)) {
1273
- return createToolError({
1274
- collection,
1275
- message: "Only the current document can be read in this context.",
1276
- tool: "getDoc"
1277
- });
1278
- }
1279
395
  const explicitlyMentioned = explicitlyMentionedDocumentKeys.has(`${collection}:${id}`);
1280
396
  const collectionConfig = allowedCollectionsBySlug.get(collection);
1281
397
  const fieldSelection = resolveToolFieldSelection({
@@ -1308,7 +424,7 @@ export const createChatHandler = (options = {})=>async (req)=>{
1308
424
  });
1309
425
  return redactSensitiveData(document);
1310
426
  }
1311
- },
427
+ }),
1312
428
  listCollections: {
1313
429
  description: "List collections; pass slug for one full schema.",
1314
430
  inputSchema: z.object({
@@ -1416,8 +532,13 @@ export const createChatHandler = (options = {})=>async (req)=>{
1416
532
  }));
1417
533
  }
1418
534
  },
1419
- proposeCreateDoc: {
535
+ proposeCreateDoc: proposalCollectionTool({
536
+ action: "create",
1420
537
  description: "Propose document creation. Use exact schema fields; include required fields. Use localizedData for multi-locale writes.",
538
+ getRepairTarget: ({ collection, label })=>({
539
+ collection,
540
+ id: getSafeProposalLabel(label)
541
+ }),
1421
542
  inputSchema: z.object({
1422
543
  collection: focusedCollectionSlugSchema,
1423
544
  data: createDataSchema.optional(),
@@ -1426,16 +547,9 @@ export const createChatHandler = (options = {})=>async (req)=>{
1426
547
  }).refine((value)=>Boolean(value.data || value.localizedData), {
1427
548
  message: "Either data or localizedData is required."
1428
549
  }),
550
+ name: "proposeCreateDoc",
1429
551
  execute: async ({ collection, data, label, localizedData })=>{
1430
- const permissionError = getDisallowedCollectionActionError(collection, "create");
1431
- if (permissionError) return permissionError;
1432
552
  const repairTargetID = getSafeProposalLabel(label);
1433
- const repairLimitError = getProposalRepairLimitError({
1434
- collection,
1435
- id: repairTargetID,
1436
- tool: "proposeCreateDoc"
1437
- });
1438
- if (repairLimitError) return repairLimitError;
1439
553
  const collectionConfig = allowedCollectionsBySlug.get(collection);
1440
554
  const collectionFields = collectionConfig?.fields || [];
1441
555
  const preparedData = prepareProposalWriteData({
@@ -1505,18 +619,14 @@ export const createChatHandler = (options = {})=>async (req)=>{
1505
619
  });
1506
620
  }
1507
621
  }
1508
- const uploadTargetsOutsideAttachments = [
1509
- ...preparedData.data ? getUploadTargetsOutsideAttachments({
1510
- allowedAttachmentKeys,
1511
- data: preparedData.data,
1512
- fields: collectionFields
1513
- }) : [],
1514
- ...preparedData.localizedData ? Object.values(preparedData.localizedData).flatMap((localeData)=>getUploadTargetsOutsideAttachments({
1515
- allowedAttachmentKeys,
1516
- data: localeData,
1517
- fields: collectionFields
1518
- })) : []
1519
- ];
622
+ const { invalidRelationshipTargets, uploadTargetsOutsideAttachments } = await validateProposalReferences({
623
+ allowedAttachmentKeys,
624
+ data: preparedData.data,
625
+ fields: collectionFields,
626
+ localizedData: preparedData.localizedData,
627
+ priority: "attachments",
628
+ req
629
+ });
1520
630
  if (uploadTargetsOutsideAttachments.length > 0) {
1521
631
  return createToolError({
1522
632
  collection,
@@ -1530,18 +640,6 @@ export const createChatHandler = (options = {})=>async (req)=>{
1530
640
  tool: "proposeCreateDoc"
1531
641
  });
1532
642
  }
1533
- const invalidRelationshipTargets = [
1534
- ...preparedData.data ? await validateRelationshipTargetsExist({
1535
- data: preparedData.data,
1536
- fields: collectionFields,
1537
- req
1538
- }) : [],
1539
- ...preparedData.localizedData ? (await Promise.all(Object.values(preparedData.localizedData).map((localeData)=>validateRelationshipTargetsExist({
1540
- data: localeData,
1541
- fields: collectionFields,
1542
- req
1543
- })))).flat() : []
1544
- ];
1545
643
  if (invalidRelationshipTargets.length > 0) {
1546
644
  return createToolError({
1547
645
  collection,
@@ -1571,24 +669,19 @@ export const createChatHandler = (options = {})=>async (req)=>{
1571
669
  };
1572
670
  return addSignedProposal(proposal);
1573
671
  }
1574
- },
1575
- proposeDeleteDoc: {
672
+ }),
673
+ proposeDeleteDoc: collectionTool({
674
+ action: "delete",
675
+ currentDocumentOnly: Boolean(requestedDocumentScope),
1576
676
  description: "Propose document deletion.",
677
+ getDocumentID: ({ id })=>id,
1577
678
  inputSchema: z.object({
1578
679
  collection: collectionSlugSchema,
1579
680
  id: z.string().min(1),
1580
681
  label: z.string().min(1)
1581
682
  }),
683
+ name: "proposeDeleteDoc",
1582
684
  execute: async ({ collection, id, label })=>{
1583
- if (requestedDocumentScope && (collection !== requestedCollectionSlug || id !== requestedDocumentID)) {
1584
- return createToolError({
1585
- collection,
1586
- message: "Only the current document can be deleted in this context.",
1587
- tool: "proposeDeleteDoc"
1588
- });
1589
- }
1590
- const permissionError = getDisallowedCollectionActionError(collection, "delete");
1591
- if (permissionError) return permissionError;
1592
685
  const proposal = {
1593
686
  action: "delete",
1594
687
  collection,
@@ -1600,9 +693,16 @@ export const createChatHandler = (options = {})=>async (req)=>{
1600
693
  };
1601
694
  return addSignedProposal(proposal);
1602
695
  }
1603
- },
1604
- proposeUpdateDoc: {
696
+ }),
697
+ proposeUpdateDoc: proposalCollectionTool({
698
+ action: "update",
699
+ currentDocumentOnly: Boolean(requestedDocumentScope),
1605
700
  description: "Propose document update. Use exact schema fields. Use localizedData for multi-locale writes.",
701
+ getDocumentID: ({ id })=>id,
702
+ getRepairTarget: ({ collection, id })=>({
703
+ collection,
704
+ id
705
+ }),
1606
706
  inputSchema: z.object({
1607
707
  collection: updateHasMultipleTargets ? collectionSlugSchema : focusedCollectionSlugSchema,
1608
708
  data: updateDataSchema.optional(),
@@ -1612,22 +712,8 @@ export const createChatHandler = (options = {})=>async (req)=>{
1612
712
  }).refine((value)=>Boolean(value.data || value.localizedData), {
1613
713
  message: "Either data or localizedData is required."
1614
714
  }),
715
+ name: "proposeUpdateDoc",
1615
716
  execute: async ({ collection, data, id, label, localizedData })=>{
1616
- if (requestedDocumentScope && (collection !== requestedCollectionSlug || id !== requestedDocumentID)) {
1617
- return createToolError({
1618
- collection,
1619
- message: "Only the current document can be updated in this context.",
1620
- tool: "proposeUpdateDoc"
1621
- });
1622
- }
1623
- const permissionError = getDisallowedCollectionActionError(collection, "update");
1624
- if (permissionError) return permissionError;
1625
- const repairLimitError = getProposalRepairLimitError({
1626
- collection,
1627
- id,
1628
- tool: "proposeUpdateDoc"
1629
- });
1630
- if (repairLimitError) return repairLimitError;
1631
717
  const collectionConfig = allowedCollectionsBySlug.get(collection);
1632
718
  const collectionFields = collectionConfig?.fields || [];
1633
719
  const preparedData = prepareProposalWriteData({
@@ -1650,18 +736,14 @@ export const createChatHandler = (options = {})=>async (req)=>{
1650
736
  tool: "proposeUpdateDoc"
1651
737
  });
1652
738
  }
1653
- const invalidRelationshipTargets = [
1654
- ...preparedData.data ? await validateRelationshipTargetsExist({
1655
- data: preparedData.data,
1656
- fields: collectionFields,
1657
- req
1658
- }) : [],
1659
- ...preparedData.localizedData ? (await Promise.all(Object.values(preparedData.localizedData).map((localeData)=>validateRelationshipTargetsExist({
1660
- data: localeData,
1661
- fields: collectionFields,
1662
- req
1663
- })))).flat() : []
1664
- ];
739
+ const { invalidRelationshipTargets, uploadTargetsOutsideAttachments } = await validateProposalReferences({
740
+ allowedAttachmentKeys,
741
+ data: preparedData.data,
742
+ fields: collectionFields,
743
+ localizedData: preparedData.localizedData,
744
+ priority: "relationships",
745
+ req
746
+ });
1665
747
  if (invalidRelationshipTargets.length > 0) {
1666
748
  return createToolError({
1667
749
  collection,
@@ -1672,18 +754,6 @@ export const createChatHandler = (options = {})=>async (req)=>{
1672
754
  tool: "proposeUpdateDoc"
1673
755
  });
1674
756
  }
1675
- const uploadTargetsOutsideAttachments = [
1676
- ...preparedData.data ? getUploadTargetsOutsideAttachments({
1677
- allowedAttachmentKeys,
1678
- data: preparedData.data,
1679
- fields: collectionFields
1680
- }) : [],
1681
- ...preparedData.localizedData ? Object.values(preparedData.localizedData).flatMap((localeData)=>getUploadTargetsOutsideAttachments({
1682
- allowedAttachmentKeys,
1683
- data: localeData,
1684
- fields: collectionFields
1685
- })) : []
1686
- ];
1687
757
  if (uploadTargetsOutsideAttachments.length > 0) {
1688
758
  return createToolError({
1689
759
  collection,
@@ -1713,18 +783,9 @@ export const createChatHandler = (options = {})=>async (req)=>{
1713
783
  };
1714
784
  return addSignedProposal(proposal);
1715
785
  }
1716
- },
1717
- proposeUpdateGlobal: {
1718
- description: "Propose global update. Use localizedData for multi-locale writes.",
1719
- inputSchema: z.object({
1720
- data: globalDataSchema.optional(),
1721
- label: z.string().min(1),
1722
- localizedData: globalLocalizedDataSchema.optional(),
1723
- slug: requestedGlobalSlug ? z.literal(requestedGlobalSlug) : z.string().min(1)
1724
- }).refine((value)=>Boolean(value.data || value.localizedData), {
1725
- message: "Either data or localizedData is required."
1726
- }),
1727
- execute: async ({ data, label, localizedData, slug })=>{
786
+ }),
787
+ proposeUpdateGlobal: proposalTool({
788
+ beforeRepair: ({ slug })=>{
1728
789
  if (requestedDocumentScope && slug !== requestedGlobalSlug) {
1729
790
  return createToolError({
1730
791
  message: "Only the current global can be updated in this context.",
@@ -1732,19 +793,31 @@ export const createChatHandler = (options = {})=>async (req)=>{
1732
793
  tool: "proposeUpdateGlobal"
1733
794
  });
1734
795
  }
1735
- const globalConfig = globalConfigsBySlug.get(slug);
1736
- if (!globalConfig) {
796
+ if (!globalConfigsBySlug.has(slug)) {
1737
797
  return createToolError({
1738
798
  message: `Unknown global: ${slug}`,
1739
799
  slug,
1740
800
  tool: "proposeUpdateGlobal"
1741
801
  });
1742
802
  }
1743
- const repairLimitError = getProposalRepairLimitError({
1744
- slug,
1745
- tool: "proposeUpdateGlobal"
1746
- });
1747
- if (repairLimitError) return repairLimitError;
803
+ return null;
804
+ },
805
+ description: "Propose global update. Use localizedData for multi-locale writes.",
806
+ getRepairTarget: ({ slug })=>({
807
+ slug
808
+ }),
809
+ inputSchema: z.object({
810
+ data: globalDataSchema.optional(),
811
+ label: z.string().min(1),
812
+ localizedData: globalLocalizedDataSchema.optional(),
813
+ slug: requestedGlobalSlug ? z.literal(requestedGlobalSlug) : z.string().min(1)
814
+ }).refine((value)=>Boolean(value.data || value.localizedData), {
815
+ message: "Either data or localizedData is required."
816
+ }),
817
+ name: "proposeUpdateGlobal",
818
+ execute: async ({ data, label, localizedData, slug })=>{
819
+ const globalConfig = globalConfigsBySlug.get(slug);
820
+ if (!globalConfig) return null;
1748
821
  const preparedData = prepareProposalWriteData({
1749
822
  collectionConfig: {
1750
823
  fields: globalConfig.fields || [],
@@ -1782,7 +855,7 @@ export const createChatHandler = (options = {})=>async (req)=>{
1782
855
  };
1783
856
  return addSignedProposal(proposal);
1784
857
  }
1785
- },
858
+ }),
1786
859
  searchDocs: {
1787
860
  description: "Search compact documents in one collection. Returns default identity fields or up to 12 requested exact top-level schema fields.",
1788
861
  inputSchema: z.object({
@@ -1852,7 +925,7 @@ export const createChatHandler = (options = {})=>async (req)=>{
1852
925
  hasCurrentGlobal: Boolean(requestedGlobalSlug),
1853
926
  intent: chatIntent
1854
927
  });
1855
- const tools = Object.fromEntries(Object.entries(allTools).filter(([name])=>scopedToolNames.has(name)));
928
+ const tools = Object.fromEntries(Object.entries(toolRegistry).filter(([name])=>scopedToolNames.has(name)));
1856
929
  debug.tools = Object.keys(tools);
1857
930
  if (intentToolChoice && !scopedToolNames.has(intentToolChoice.toolName)) {
1858
931
  intentToolChoice = undefined;
@@ -1861,63 +934,21 @@ export const createChatHandler = (options = {})=>async (req)=>{
1861
934
  const sendEvent = (controller, event, data)=>{
1862
935
  controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
1863
936
  };
1864
- const model = await getModel({
1865
- apiKey: providerConfig.apiKey,
1866
- ...managedProvider?.baseURL ? {
1867
- baseURL: managedProvider.baseURL
1868
- } : {},
1869
- model: providerConfig.modelID,
1870
- provider
1871
- });
937
+ const model = await resolvedAIRequest.loadModel();
1872
938
  const promptCaching = options.promptCaching !== false;
1873
939
  const explicitPromptCaching = promptCaching && !managedProvider?.baseURL;
1874
- const sanitizedMentionContext = redactSensitiveData(mentionContext);
1875
- const { cacheable: cacheableMentionContext, dynamic: dynamicMentionContext } = splitPromptCacheContext(sanitizedMentionContext);
1876
- const staticSystemInstructions = [
1877
- "You are a Payload CMS assistant. Inspect schema/content with tools before proposing writes.",
1878
- "Mentions define the active CMS scope. Locale mentions define active locale; multiple locales require localizedData keyed by locale.",
1879
- "Writes are proposals only. Never claim changes were applied before user confirmation.",
1880
- "Uploaded media attachments appear in context as mediaAttachment entries. Use their exact IDs for upload fields.",
1881
- "If an uploaded media document has editable descriptive fields, propose an update to that media document with suitable values.",
1882
- "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.",
1883
- "For create/update/delete requests, you must use proposal tools. Do not end with plain text if the user asked for a content change.",
1884
- "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.",
1885
- "Put concrete field values only in tool data, not visible text.",
1886
- "For blocks fields: use exact blockType values from schema, exact field names, and complete objects for required block fields.",
1887
- "For arrays: every item must be an object matching the child field schema, not free text.",
1888
- "If schema details are missing, call listCollections/listGlobals with a slug before proposing. If an inferred collection schema is already present in context, use it directly.",
1889
- "Read tools return compact identity fields by default. Request only the additional fields needed for the current task; full schemas remain available separately.",
1890
- "If a proposal tool returns retryable=true, correct only repair.issues and call the same proposal tool once more. If retryable=false, do not retry that tool target."
1891
- ];
1892
- const cacheableSystemInstructions = [
1893
- `Collection aliases: ${JSON.stringify(collectionAliasMap)}.`,
1894
- `Focused required create fields: ${JSON.stringify(focusedRequiredFieldsByCollection)}.`,
1895
- `Focused title fields: ${JSON.stringify(focusedTitleFieldByCollection)}. Infer concise titles when needed.`
1896
- ];
1897
- const dynamicSystemInstructions = [
1898
- `Likely collection matches for this prompt: ${JSON.stringify(likelyCollectionMatches)}.`,
1899
- `Request intent: ${chatIntent}. Preferred proposal tool: ${intentToolChoice?.toolName || "none"}.`,
1900
- "Visible response: plain text, under 40 words, no Markdown, no proposed content."
1901
- ];
1902
- const system = createCachedSystemMessages({
1903
- cacheableContext: cacheableMentionContext,
1904
- cacheableInstructions: cacheableSystemInstructions,
1905
- dynamicInstructions: dynamicSystemInstructions,
1906
- enabled: explicitPromptCaching,
940
+ const { dynamicMentionContext, promptCacheProviderOptions, system } = createChatPromptContext({
941
+ chatIntent,
942
+ collectionAliasMap,
943
+ focusedRequiredFieldsByCollection,
944
+ focusedTitleFieldByCollection,
945
+ intentToolName: intentToolChoice?.toolName,
946
+ likelyCollectionMatches,
947
+ mentionContext,
948
+ modelID: resolvedAIRequest.modelID,
949
+ promptCaching: explicitPromptCaching,
1907
950
  provider,
1908
- staticInstructions: staticSystemInstructions
1909
- });
1910
- const promptCacheProviderOptions = createPromptCacheProviderOptions({
1911
- cacheKeyParts: [
1912
- cacheableMentionContext,
1913
- cacheableSystemInstructions,
1914
- [
1915
- ...scopedToolNames
1916
- ].sort()
1917
- ],
1918
- enabled: explicitPromptCaching,
1919
- model: providerConfig.modelID,
1920
- provider
951
+ scopedToolNames
1921
952
  });
1922
953
  const result = streamText({
1923
954
  maxOutputTokens: options.maxOutputTokens || 700,
@@ -1958,15 +989,9 @@ export const createChatHandler = (options = {})=>async (req)=>{
1958
989
  if (part.type === "finish") {
1959
990
  const finishPart = part;
1960
991
  usage = finishPart.totalUsage || finishPart.usage || null;
1961
- if (usage && options.maxTokenUsage) {
992
+ if (usage) {
1962
993
  try {
1963
- await recordTokenUsage({
1964
- model: providerConfig.modelID,
1965
- provider: managedProvider?.id || provider,
1966
- req,
1967
- usage,
1968
- userID: user.id
1969
- });
994
+ await resolvedAIRequest.recordUsage(usage);
1970
995
  } catch (err) {
1971
996
  req.payload.logger.error({
1972
997
  err,
@@ -2070,15 +1095,14 @@ export const createChatHandler = (options = {})=>async (req)=>{
2070
1095
  }
2071
1096
  });
2072
1097
  } catch (err) {
2073
- req.payload.logger.error({
2074
- debug,
2075
- err,
2076
- msg: "AI chat request failed"
2077
- });
2078
- return Response.json({
2079
- error: "AI request failed."
2080
- }, {
2081
- status: 500
1098
+ return logHandlerError({
1099
+ details: {
1100
+ debug
1101
+ },
1102
+ error: err,
1103
+ logMessage: "AI chat request failed",
1104
+ publicMessage: "AI request failed.",
1105
+ req
2082
1106
  });
2083
1107
  }
2084
- };
1108
+ });