@mvriu5/payload-ai 0.5.8 → 0.5.9

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.
@@ -0,0 +1,138 @@
1
+ import { verifyAIActionProposal } from "../ai/proposals.js";
2
+ import { redactSensitiveData } from "../ai/sensitiveData.js";
3
+ import { getCollectionFields, normalizeAuthData, normalizeDataForFields } from "../payload/normalizeData.js";
4
+ import { isCollectionActionAllowed } from "../payload/collectionPermissions.js";
5
+ const isRecord = (value)=>{
6
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
7
+ };
8
+ const isActionProposal = (proposal)=>{
9
+ if (!isRecord(proposal) || typeof proposal.label !== "string") return false;
10
+ if (proposal.action === "create") {
11
+ return typeof proposal.collection === "string" && isRecord(proposal.data);
12
+ }
13
+ if (proposal.action === "update") {
14
+ return typeof proposal.collection === "string" && typeof proposal.id === "string" && isRecord(proposal.data);
15
+ }
16
+ if (proposal.action === "delete") {
17
+ return typeof proposal.collection === "string" && typeof proposal.id === "string";
18
+ }
19
+ if (proposal.action === "updateGlobal") {
20
+ return typeof proposal.slug === "string" && isRecord(proposal.data);
21
+ }
22
+ return false;
23
+ };
24
+ const mergeData = (current, next)=>{
25
+ return Object.fromEntries(Object.entries({
26
+ ...current,
27
+ ...next
28
+ }).map(([key, value])=>{
29
+ const currentValue = current[key];
30
+ if (isRecord(currentValue) && isRecord(value)) {
31
+ return [
32
+ key,
33
+ mergeData(currentValue, value)
34
+ ];
35
+ }
36
+ return [
37
+ key,
38
+ value
39
+ ];
40
+ }));
41
+ };
42
+ export const createAIProposalDiffEndpointHandler = (options = {})=>async (req)=>{
43
+ if (!req.user) return Response.json({
44
+ error: "Unauthorized"
45
+ }, {
46
+ status: 401
47
+ });
48
+ const body = req.json ? await req.json().catch(()=>null) : null;
49
+ const proposal = body?.proposal;
50
+ if (!proposal) return Response.json({
51
+ error: "Proposal is required"
52
+ }, {
53
+ status: 400
54
+ });
55
+ if (!verifyAIActionProposal(proposal)) return Response.json({
56
+ error: "Proposal signature is invalid or expired."
57
+ }, {
58
+ status: 400
59
+ });
60
+ if (!isActionProposal(proposal)) return Response.json({
61
+ error: "Proposal is invalid."
62
+ }, {
63
+ status: 400
64
+ });
65
+ try {
66
+ if (proposal.action === "updateGlobal") {
67
+ const globalConfig = req.payload.config.globals?.find((global)=>global.slug === proposal.slug);
68
+ if (!globalConfig) return Response.json({
69
+ error: "Unknown global"
70
+ }, {
71
+ status: 400
72
+ });
73
+ const normalized = normalizeDataForFields(globalConfig.fields || [], proposal.data);
74
+ const doc = await req.payload.findGlobal({
75
+ depth: 2,
76
+ overrideAccess: false,
77
+ req,
78
+ slug: proposal.slug
79
+ });
80
+ return Response.json({
81
+ after: redactSensitiveData(mergeData(doc, normalized.data)),
82
+ before: redactSensitiveData(doc)
83
+ });
84
+ }
85
+ if (!isCollectionActionAllowed({
86
+ action: proposal.action === "delete" ? "delete" : "read",
87
+ permissions: options.collections,
88
+ req,
89
+ slug: proposal.collection
90
+ })) return Response.json({
91
+ error: "Unknown collection"
92
+ }, {
93
+ status: 400
94
+ });
95
+ if (proposal.action === "delete") {
96
+ const doc = await req.payload.findByID({
97
+ collection: proposal.collection,
98
+ depth: 2,
99
+ id: proposal.id,
100
+ overrideAccess: false,
101
+ req
102
+ });
103
+ return Response.json({
104
+ after: {},
105
+ before: redactSensitiveData(doc)
106
+ });
107
+ }
108
+ const collectionConfig = req.payload.config.collections.find((collection)=>collection.slug === proposal.collection);
109
+ const normalized = normalizeAuthData(collectionConfig, normalizeDataForFields(getCollectionFields(collectionConfig), proposal.data));
110
+ if (proposal.action === "create") {
111
+ return Response.json({
112
+ after: redactSensitiveData(normalized.data),
113
+ before: {}
114
+ });
115
+ }
116
+ const doc = await req.payload.findByID({
117
+ collection: proposal.collection,
118
+ depth: 2,
119
+ id: proposal.id,
120
+ overrideAccess: false,
121
+ req
122
+ });
123
+ return Response.json({
124
+ after: redactSensitiveData(mergeData(doc, normalized.data)),
125
+ before: redactSensitiveData(doc)
126
+ });
127
+ } catch (err) {
128
+ req.payload.logger.error({
129
+ err,
130
+ msg: "AI proposal diff failed"
131
+ });
132
+ return Response.json({
133
+ error: "Could not load proposal diff."
134
+ }, {
135
+ status: 400
136
+ });
137
+ }
138
+ };
@@ -0,0 +1,6 @@
1
+ import type { PayloadHandler } from "payload";
2
+ type AIRecentChangesEndpointOptions = {
3
+ changeLogCollection: string;
4
+ };
5
+ export declare const createAIRecentChangesEndpointHandler: (options: AIRecentChangesEndpointOptions) => PayloadHandler;
6
+ export {};
@@ -0,0 +1,35 @@
1
+ const getNumber = (value)=>{
2
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
3
+ };
4
+ const getString = (value)=>{
5
+ return typeof value === "string" ? value : null;
6
+ };
7
+ export const createAIRecentChangesEndpointHandler = (options)=>async (req)=>{
8
+ if (!req.user) return Response.json({
9
+ error: "Unauthorized"
10
+ }, {
11
+ status: 401
12
+ });
13
+ const result = await req.payload.find({
14
+ collection: options.changeLogCollection,
15
+ depth: 0,
16
+ limit: 12,
17
+ overrideAccess: false,
18
+ req,
19
+ sort: "-createdAt"
20
+ });
21
+ return Response.json({
22
+ changes: result.docs.map((doc)=>({
23
+ action: getString(doc.action),
24
+ additions: getNumber(doc.additions),
25
+ after: doc.after,
26
+ before: doc.before,
27
+ collection: getString(doc.collection),
28
+ documentID: getString(doc.documentID),
29
+ removals: getNumber(doc.removals),
30
+ slug: getString(doc.slug),
31
+ title: getString(doc.title) || "AI change",
32
+ url: getString(doc.targetURL)
33
+ }))
34
+ });
35
+ };
package/dist/index.js CHANGED
@@ -2,8 +2,101 @@ import { aiProviders, getResolvedAIModelConfig } from "./ai/providerOptions.js";
2
2
  import { createAIApplyActionEndpointHandler } from "./endpoints/aiApplyActionEndpointHandler.js";
3
3
  import { createAIChatEndpointHandler } from "./endpoints/aiChatEndpointHandler.js";
4
4
  import { createAIMentionSuggestionsEndpointHandler } from "./endpoints/aiMentionSuggestionsEndpointHandler.js";
5
+ import { createAIProposalDiffEndpointHandler } from "./endpoints/aiProposalDiffEndpointHandler.js";
6
+ import { createAIRecentChangesEndpointHandler } from "./endpoints/aiRecentChangesEndpointHandler.js";
5
7
  import { resolveCollectionPermissions } from "./payload/collectionPermissions.js";
6
8
  import { isInternalCollection } from "./payload/shared.js";
9
+ const aiChangesCollectionSlug = "payload-ai-changes";
10
+ const createAIChangesCollection = ()=>({
11
+ slug: aiChangesCollectionSlug,
12
+ access: {
13
+ create: ()=>false,
14
+ delete: ()=>false,
15
+ read: ({ req })=>Boolean(req.user),
16
+ update: ()=>false
17
+ },
18
+ admin: {
19
+ defaultColumns: [
20
+ "title",
21
+ "action",
22
+ "additions",
23
+ "removals",
24
+ "createdAt"
25
+ ],
26
+ group: "AI",
27
+ useAsTitle: "title"
28
+ },
29
+ fields: [
30
+ {
31
+ name: "title",
32
+ type: "text",
33
+ required: true
34
+ },
35
+ {
36
+ name: "action",
37
+ type: "select",
38
+ options: [
39
+ "create",
40
+ "update",
41
+ "delete",
42
+ "updateGlobal"
43
+ ],
44
+ required: true
45
+ },
46
+ {
47
+ name: "targetType",
48
+ type: "select",
49
+ options: [
50
+ "collection",
51
+ "global"
52
+ ],
53
+ required: true
54
+ },
55
+ {
56
+ name: "collection",
57
+ type: "text"
58
+ },
59
+ {
60
+ name: "slug",
61
+ type: "text"
62
+ },
63
+ {
64
+ name: "documentID",
65
+ type: "text"
66
+ },
67
+ {
68
+ name: "targetURL",
69
+ type: "text"
70
+ },
71
+ {
72
+ name: "additions",
73
+ type: "number",
74
+ defaultValue: 0
75
+ },
76
+ {
77
+ name: "removals",
78
+ type: "number",
79
+ defaultValue: 0
80
+ },
81
+ {
82
+ name: "before",
83
+ type: "json"
84
+ },
85
+ {
86
+ name: "after",
87
+ type: "json"
88
+ },
89
+ {
90
+ name: "proposal",
91
+ type: "json"
92
+ },
93
+ {
94
+ name: "userID",
95
+ type: "text"
96
+ }
97
+ ],
98
+ timestamps: true
99
+ });
7
100
  const addAccountFields = ({ allowUserApiKeys, config })=>{
8
101
  const adminUserSlug = config.admin?.user;
9
102
  if (!adminUserSlug || !config.collections) return;
@@ -34,6 +127,9 @@ export const payloadAiPlugin = (pluginOptions)=>(config)=>{
34
127
  const allowUserApiKeys = pluginOptions.allowUserApiKeys !== false;
35
128
  const modelConfig = getResolvedAIModelConfig(pluginOptions.models);
36
129
  if (!config.collections) config.collections = [];
130
+ if (!config.collections.some((collection)=>collection.slug === aiChangesCollectionSlug)) {
131
+ config.collections.push(createAIChangesCollection());
132
+ }
37
133
  addAccountFields({
38
134
  allowUserApiKeys,
39
135
  config
@@ -65,11 +161,26 @@ export const payloadAiPlugin = (pluginOptions)=>(config)=>{
65
161
  });
66
162
  config.endpoints.push({
67
163
  handler: createAIApplyActionEndpointHandler({
164
+ changeLogCollection: aiChangesCollectionSlug,
68
165
  collections: collectionPermissions
69
166
  }),
70
167
  method: "post",
71
168
  path: "/ai-apply-action"
72
169
  });
170
+ config.endpoints.push({
171
+ handler: createAIRecentChangesEndpointHandler({
172
+ changeLogCollection: aiChangesCollectionSlug
173
+ }),
174
+ method: "get",
175
+ path: "/ai-recent-changes"
176
+ });
177
+ config.endpoints.push({
178
+ handler: createAIProposalDiffEndpointHandler({
179
+ collections: collectionPermissions
180
+ }),
181
+ method: "post",
182
+ path: "/ai-proposal-diff"
183
+ });
73
184
  config.endpoints.push({
74
185
  handler: createAIMentionSuggestionsEndpointHandler({
75
186
  collections: collectionPermissions
@@ -9,11 +9,19 @@ export type AIChatMention = {
9
9
  type?: "block" | "collection" | "doc" | "global";
10
10
  };
11
11
  export type FieldConfig = {
12
+ admin?: {
13
+ condition?: unknown;
14
+ };
12
15
  blocks?: BlockConfig[];
13
16
  fields?: FieldConfig[];
14
17
  hasMany?: boolean;
15
18
  label?: unknown;
19
+ localized?: boolean;
16
20
  name?: string;
21
+ options?: (string | {
22
+ label?: unknown;
23
+ value?: string;
24
+ })[];
17
25
  relationTo?: unknown;
18
26
  required?: boolean;
19
27
  type?: string;
@@ -26,16 +34,51 @@ type BlockConfig = {
26
34
  };
27
35
  slug: string;
28
36
  };
37
+ type VersionConfig = boolean | {
38
+ drafts?: boolean | Record<string, unknown>;
39
+ };
40
+ type CollectionLikeConfig = {
41
+ access?: Record<string, unknown>;
42
+ auth?: unknown;
43
+ fields?: FieldConfig[];
44
+ label?: unknown;
45
+ labels?: {
46
+ plural?: unknown;
47
+ singular?: unknown;
48
+ };
49
+ slug: string;
50
+ versions?: VersionConfig;
51
+ };
29
52
  type MentionContext = {
30
53
  blockContexts: (Record<string, unknown> & {
31
54
  parent: string;
32
55
  slug: string;
33
56
  })[];
34
57
  collectionSlugs: string[];
58
+ collections?: ResolvedAICollectionPermissionMap;
35
59
  globalSlugs: string[];
36
60
  mentions?: AIChatMention[];
37
61
  req: Parameters<PayloadHandler>[0];
38
62
  };
63
+ export declare const describeCollectionLikeConfig: ({ config, permissions, type, }: {
64
+ config: CollectionLikeConfig;
65
+ permissions?: ResolvedAICollectionPermissionMap;
66
+ type: "collection" | "global";
67
+ }) => {
68
+ access: {
69
+ aiPermissions: Record<import("./collectionPermissions.js").AICollectionAction, boolean> | {
70
+ read: boolean;
71
+ update: boolean;
72
+ };
73
+ hasPayloadAccessControl: boolean;
74
+ };
75
+ fields: Record<string, unknown>[];
76
+ hasAuth: true | undefined;
77
+ hasDrafts: true | undefined;
78
+ label: {};
79
+ slug: string;
80
+ type: "collection" | "global";
81
+ };
39
82
  export declare const describeField: (field: FieldConfig) => Record<string, unknown>;
40
83
  export declare const collectBlocks: ({ fields, parent, }: {
41
84
  fields: FieldConfig[];
@@ -45,7 +88,7 @@ export declare const collectBlocks: ({ fields, parent, }: {
45
88
  slug: string;
46
89
  })[];
47
90
  export declare const getAllowedCollectionSlugs: (req: Parameters<PayloadHandler>[0], collections?: ResolvedAICollectionPermissionMap) => string[];
48
- export declare const getMentionContext: ({ blockContexts, collectionSlugs, globalSlugs, mentions, req, }: MentionContext) => Promise<Record<string, unknown>[]>;
91
+ export declare const getMentionContext: ({ blockContexts, collectionSlugs, collections, globalSlugs, mentions, req, }: MentionContext) => Promise<Record<string, unknown>[]>;
49
92
  export declare const buildPromptWithMentionContext: ({ mentionContext, prompt, }: {
50
93
  mentionContext: Record<string, unknown>[];
51
94
  prompt: string;
@@ -1,4 +1,5 @@
1
- import { getCollectionSlugsForAction } from "./collectionPermissions.js";
1
+ import { getCollectionPermissions, getCollectionSlugsForAction } from "./collectionPermissions.js";
2
+ import { getCollectionFields, isAuthCollection } from "./normalizeData.js";
2
3
  import { getSerializableLabel, isInternalCollection } from "./shared.js";
3
4
  const getSerializableRelationTo = (relationTo)=>{
4
5
  if (typeof relationTo === "string") {
@@ -9,9 +10,88 @@ const getSerializableRelationTo = (relationTo)=>{
9
10
  }
10
11
  return undefined;
11
12
  };
13
+ const getSerializableOptions = (options)=>{
14
+ if (!Array.isArray(options)) return undefined;
15
+ const serializedOptions = options.map((option)=>{
16
+ if (typeof option === "string") {
17
+ return {
18
+ label: option,
19
+ value: option
20
+ };
21
+ }
22
+ if (!option || typeof option !== "object") return null;
23
+ const label = "label" in option ? getSerializableLabel(option.label) : undefined;
24
+ const value = "value" in option && typeof option.value === "string" ? option.value : undefined;
25
+ if (!label && !value) return null;
26
+ return {
27
+ ...label ? {
28
+ label
29
+ } : {},
30
+ ...value ? {
31
+ value
32
+ } : {}
33
+ };
34
+ }).filter(Boolean);
35
+ return serializedOptions.length > 0 ? serializedOptions : undefined;
36
+ };
37
+ const hasDrafts = (config)=>{
38
+ const versions = config?.versions;
39
+ if (!versions || versions === true) return false;
40
+ if (typeof versions !== "object") return false;
41
+ return Boolean(versions.drafts);
42
+ };
43
+ const toNormalizeCollectionConfig = (config)=>{
44
+ if (!config) return undefined;
45
+ return {
46
+ auth: config.auth,
47
+ fields: config.fields || [],
48
+ slug: config.slug
49
+ };
50
+ };
51
+ const getSchemaFields = (config)=>{
52
+ const normalizedConfig = toNormalizeCollectionConfig(config);
53
+ const fields = isAuthCollection(normalizedConfig) ? getCollectionFields(normalizedConfig) : [
54
+ ...config?.fields || []
55
+ ];
56
+ if (hasDrafts(config)) {
57
+ fields.push({
58
+ label: "Status",
59
+ name: "_status",
60
+ options: [
61
+ "draft",
62
+ "published"
63
+ ],
64
+ required: true,
65
+ type: "select"
66
+ });
67
+ }
68
+ return fields;
69
+ };
70
+ export const describeCollectionLikeConfig = ({ config, permissions, type })=>{
71
+ const slug = config.slug;
72
+ return {
73
+ access: {
74
+ aiPermissions: type === "collection" ? getCollectionPermissions({
75
+ permissions,
76
+ slug
77
+ }) : {
78
+ read: true,
79
+ update: true
80
+ },
81
+ hasPayloadAccessControl: Boolean(config.access)
82
+ },
83
+ fields: getSchemaFields(config).map(describeField),
84
+ hasAuth: isAuthCollection(toNormalizeCollectionConfig(config)) || undefined,
85
+ hasDrafts: hasDrafts(config) || undefined,
86
+ label: type === "collection" ? config.labels?.plural || config.labels?.singular || slug : getSerializableLabel(config.label) || slug,
87
+ slug,
88
+ type
89
+ };
90
+ };
12
91
  export const describeField = (field)=>{
13
92
  const label = getSerializableLabel(field.label);
14
93
  const relationTo = getSerializableRelationTo(field.relationTo);
94
+ const options = getSerializableOptions(field.options);
15
95
  return {
16
96
  ...label ? {
17
97
  label
@@ -28,9 +108,18 @@ export const describeField = (field)=>{
28
108
  ...field.hasMany ? {
29
109
  hasMany: field.hasMany
30
110
  } : {},
111
+ ...field.localized ? {
112
+ localized: field.localized
113
+ } : {},
31
114
  ...relationTo ? {
32
115
  relationTo
33
116
  } : {},
117
+ ...options ? {
118
+ options
119
+ } : {},
120
+ ...field.admin?.condition ? {
121
+ hasCondition: true
122
+ } : {},
34
123
  ...field.fields ? {
35
124
  fields: field.fields.map(describeField)
36
125
  } : {},
@@ -78,7 +167,7 @@ export const getAllowedCollectionSlugs = (req, collections)=>{
78
167
  req
79
168
  });
80
169
  };
81
- export const getMentionContext = async ({ blockContexts, collectionSlugs, globalSlugs, mentions, req })=>{
170
+ export const getMentionContext = async ({ blockContexts, collectionSlugs, collections, globalSlugs, mentions, req })=>{
82
171
  if (!mentions || mentions.length === 0) return [];
83
172
  const context = [];
84
173
  const seen = new Set();
@@ -90,12 +179,11 @@ export const getMentionContext = async ({ blockContexts, collectionSlugs, global
90
179
  const collectionConfig = req.payload.config.collections.find((collection)=>collection.slug === slug);
91
180
  if (!collectionConfig) continue;
92
181
  seen.add(key);
93
- context.push({
94
- fields: collectionConfig.fields.map(describeField),
95
- label: collectionConfig.labels?.plural || collectionConfig.labels?.singular || slug,
96
- slug,
182
+ context.push(describeCollectionLikeConfig({
183
+ config: collectionConfig,
184
+ permissions: collections,
97
185
  type: "collection"
98
- });
186
+ }));
99
187
  }
100
188
  if (mention.type === "global" && mention.slug) {
101
189
  const slug = mention.slug;
@@ -111,11 +199,11 @@ export const getMentionContext = async ({ blockContexts, collectionSlugs, global
111
199
  }).catch(()=>null);
112
200
  seen.add(key);
113
201
  context.push({
114
- doc: globalDoc,
115
- fields: globalConfig.fields.map(describeField),
116
- label: globalConfig.label || slug,
117
- slug,
118
- type: "global"
202
+ ...describeCollectionLikeConfig({
203
+ config: globalConfig,
204
+ type: "global"
205
+ }),
206
+ doc: globalDoc
119
207
  });
120
208
  }
121
209
  if (mention.type === "block" && mention.slug) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mvriu5/payload-ai",
3
- "version": "0.5.8",
3
+ "version": "0.5.9",
4
4
  "description": "AI assistant plugin for Payload CMS with provider selection, CMS mentions, and signed action proposals.",
5
5
  "keywords": [
6
6
  "payload",