@mvriu5/payload-ai 0.5.6 → 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.
Files changed (39) hide show
  1. package/README.md +52 -5
  2. package/dist/ai/providerOptions.d.ts +1 -14
  3. package/dist/ai/providerOptions.js +0 -23
  4. package/dist/ai/providerRuntime.d.ts +3 -2
  5. package/dist/ai/providerRuntime.js +6 -15
  6. package/dist/components/AIActionProposalList.d.ts +2 -1
  7. package/dist/components/AIActionProposalList.js +74 -6
  8. package/dist/components/AIActionProposalList.module.css +49 -5
  9. package/dist/components/AIApiKeyField.js +0 -8
  10. package/dist/components/AIInput.d.ts +1 -1
  11. package/dist/components/AIInput.js +156 -23
  12. package/dist/components/AIInput.module.css +185 -147
  13. package/dist/components/CollectionMentionPopover.d.ts +1 -1
  14. package/dist/components/DiffDialog.d.ts +12 -0
  15. package/dist/components/DiffDialog.js +310 -0
  16. package/dist/components/DiffDialog.module.css +249 -0
  17. package/dist/components/Icons.d.ts +9 -0
  18. package/dist/components/Icons.js +266 -0
  19. package/dist/components/RecentChangesList.d.ts +18 -0
  20. package/dist/components/RecentChangesList.js +66 -0
  21. package/dist/components/RecentChangesList.module.css +135 -0
  22. package/dist/components/hooks/useAISettings.d.ts +1 -1
  23. package/dist/endpoints/aiApplyActionEndpointHandler.d.ts +3 -1
  24. package/dist/endpoints/aiApplyActionEndpointHandler.js +209 -8
  25. package/dist/endpoints/aiChatEndpointHandler.d.ts +5 -1
  26. package/dist/endpoints/aiChatEndpointHandler.js +106 -21
  27. package/dist/endpoints/aiMentionSuggestionsEndpointHandler.d.ts +2 -1
  28. package/dist/endpoints/aiMentionSuggestionsEndpointHandler.js +7 -7
  29. package/dist/endpoints/aiProposalDiffEndpointHandler.d.ts +7 -0
  30. package/dist/endpoints/aiProposalDiffEndpointHandler.js +138 -0
  31. package/dist/endpoints/aiRecentChangesEndpointHandler.d.ts +6 -0
  32. package/dist/endpoints/aiRecentChangesEndpointHandler.js +35 -0
  33. package/dist/index.d.ts +3 -1
  34. package/dist/index.js +140 -18
  35. package/dist/payload/collectionPermissions.d.ts +22 -0
  36. package/dist/payload/collectionPermissions.js +68 -0
  37. package/dist/payload/schemaContext.d.ts +46 -2
  38. package/dist/payload/schemaContext.js +105 -14
  39. package/package.json +113 -121
package/README.md CHANGED
@@ -5,7 +5,7 @@ AI assistant plugin for Payload CMS. It adds an admin dashboard assistant that c
5
5
  ## Install
6
6
 
7
7
  ```bash
8
- pnpm add payload-ai-plugin
8
+ npm add @mvriu5/payload-ai
9
9
  ```
10
10
 
11
11
  ## Usage
@@ -30,7 +30,7 @@ The plugin adds two fields to the configured Payload admin user collection:
30
30
  - `aiProvider`
31
31
  - `aiApiKey`
32
32
 
33
- Users can select their provider and store their own API key in account settings. The key is used server-side when the chat endpoint calls the selected model.
33
+ Users can select their provider and optionally store their own API key in account settings. If no account-level key is set, the chat endpoint uses provider environment variables.
34
34
 
35
35
  ## Options
36
36
 
@@ -38,8 +38,14 @@ Users can select their provider and store their own API key in account settings.
38
38
  import type { PayloadAiPluginOptions } from "payload-ai-plugin";
39
39
 
40
40
  const options: PayloadAiPluginOptions = {
41
+ allowUserApiKeys: false,
41
42
  collections: {
42
- posts: true,
43
+ posts: {
44
+ read: true,
45
+ create: true,
46
+ update: true,
47
+ delete: false,
48
+ },
43
49
  },
44
50
  models: {
45
51
  defaults: {
@@ -59,22 +65,63 @@ const options: PayloadAiPluginOptions = {
59
65
 
60
66
  Restricts AI read and write proposals to enabled collection slugs. If omitted, all non-internal Payload collections are available.
61
67
 
68
+ Use `true` to enable all AI actions for a collection:
69
+
70
+ ```ts
71
+ payloadAiPlugin({
72
+ collections: {
73
+ posts: true,
74
+ },
75
+ })
76
+ ```
77
+
78
+ Use granular permissions to control each action:
79
+
80
+ ```ts
81
+ payloadAiPlugin({
82
+ collections: {
83
+ posts: {
84
+ read: true,
85
+ create: true,
86
+ update: true,
87
+ delete: false,
88
+ },
89
+ },
90
+ })
91
+ ```
92
+
93
+ `read` controls schema/context access, document search, and mentions. `create`, `update`, and `delete` control AI action proposals and server-side apply permissions.
94
+
62
95
  ### `models`
63
96
 
64
97
  Overrides the model list shown in the admin UI and the default model per provider.
65
98
 
99
+ ### `allowUserApiKeys`
100
+
101
+ Controls whether the plugin adds an `aiApiKey` field to the admin user collection.
102
+
103
+ ```ts
104
+ payloadAiPlugin({
105
+ allowUserApiKeys: false,
106
+ })
107
+ ```
108
+
109
+ When disabled, users can still select an AI provider, but API keys must come from environment variables.
110
+
66
111
  ### `disabled`
67
112
 
68
113
  Disables endpoint and UI registration while keeping the plugin call in your config.
69
114
 
70
115
  ## Provider Environment Variables
71
116
 
72
- Account-level API keys take priority. If a user has no key configured, the server falls back to provider environment variables:
117
+ API key priority is:
118
+
119
+ 1. account-level API key, unless `allowUserApiKeys: false`
120
+ 2. provider environment variables
73
121
 
74
122
  - `OPENAI_API_KEY`, `OPENAI_MODEL`
75
123
  - `ANTHROPIC_API_KEY`, `ANTHROPIC_MODEL`
76
124
  - `GOOGLE_GENERATIVE_AI_API_KEY`, `GOOGLE_GENERATIVE_AI_MODEL`
77
- - `GROQ_API_KEY`, `GROQ_MODEL`
78
125
  - `MISTRAL_API_KEY`, `MISTRAL_MODEL`
79
126
 
80
127
  `PAYLOAD_SECRET` is required for signing AI action proposals.
@@ -26,19 +26,6 @@ declare const aiProviderModels: {
26
26
  readonly label: "Gemini 2.5 Pro";
27
27
  readonly value: "gemini-2.5-pro";
28
28
  }];
29
- readonly groq: readonly [{
30
- readonly label: "Llama 3.3 70B Versatile";
31
- readonly value: "llama-3.3-70b-versatile";
32
- }, {
33
- readonly label: "Llama 3.1 8B Instant";
34
- readonly value: "llama-3.1-8b-instant";
35
- }, {
36
- readonly label: "GPT OSS 120B";
37
- readonly value: "openai/gpt-oss-120b";
38
- }, {
39
- readonly label: "GPT OSS 20B";
40
- readonly value: "openai/gpt-oss-20b";
41
- }];
42
29
  readonly mistral: readonly [{
43
30
  readonly label: "Mistral Small";
44
31
  readonly value: "mistral-small-latest";
@@ -78,7 +65,7 @@ export type AIModelConfig = {
78
65
  providers?: Partial<Record<AIProvider, AIProviderModelOption[]>>;
79
66
  };
80
67
  export declare const getResolvedAIModelConfig: (modelConfig?: AIModelConfig) => {
81
- defaults: Record<"claude" | "google" | "groq" | "mistral" | "openai", string>;
68
+ defaults: Record<"claude" | "google" | "mistral" | "openai", string>;
82
69
  providers: AIProviderModels;
83
70
  };
84
71
  export declare const isAIProvider: (provider: string) => provider is AIProvider;
@@ -31,24 +31,6 @@ const aiProviderModels = {
31
31
  value: "gemini-2.5-pro"
32
32
  }
33
33
  ],
34
- groq: [
35
- {
36
- label: "Llama 3.3 70B Versatile",
37
- value: "llama-3.3-70b-versatile"
38
- },
39
- {
40
- label: "Llama 3.1 8B Instant",
41
- value: "llama-3.1-8b-instant"
42
- },
43
- {
44
- label: "GPT OSS 120B",
45
- value: "openai/gpt-oss-120b"
46
- },
47
- {
48
- label: "GPT OSS 20B",
49
- value: "openai/gpt-oss-20b"
50
- }
51
- ],
52
34
  mistral: [
53
35
  {
54
36
  label: "Mistral Small",
@@ -95,10 +77,6 @@ export const aiProviders = [
95
77
  label: "Google Gemini",
96
78
  value: "google"
97
79
  },
98
- {
99
- label: "Groq",
100
- value: "groq"
101
- },
102
80
  {
103
81
  label: "Mistral",
104
82
  value: "mistral"
@@ -111,7 +89,6 @@ export const aiProviders = [
111
89
  export const defaultAIModels = {
112
90
  claude: aiProviderModels.claude[0].value,
113
91
  google: aiProviderModels.google[0].value,
114
- groq: aiProviderModels.groq[0].value,
115
92
  mistral: aiProviderModels.mistral[0].value,
116
93
  openai: aiProviderModels.openai[0].value
117
94
  };
@@ -1,7 +1,8 @@
1
1
  import type { LanguageModel } from "ai";
2
- import { type AIProvider } from "./providerOptions.js";
2
+ import { type AIProvider, type AIModelConfig } from "./providerOptions.js";
3
3
  type ProviderConfig = {
4
4
  apiKey?: string | null;
5
+ defaultModels?: AIModelConfig["defaults"];
5
6
  model?: string | null;
6
7
  provider: AIProvider;
7
8
  };
@@ -10,7 +11,7 @@ type ModelConfig = {
10
11
  model: string;
11
12
  provider: AIProvider;
12
13
  };
13
- export declare const getProviderConfig: ({ apiKey, model, provider, }: ProviderConfig) => {
14
+ export declare const getProviderConfig: ({ apiKey, defaultModels, model, provider, }: ProviderConfig) => {
14
15
  apiKey: string | undefined;
15
16
  modelID: string;
16
17
  };
@@ -1,37 +1,31 @@
1
1
  import { createAnthropic } from "@ai-sdk/anthropic";
2
2
  import { createGoogleGenerativeAI } from "@ai-sdk/google";
3
- import { createGroq } from "@ai-sdk/groq";
4
3
  import { createMistral } from "@ai-sdk/mistral";
5
4
  import { createOpenAI } from "@ai-sdk/openai";
6
5
  import { defaultAIModels } from "./providerOptions.js";
7
- export const getProviderConfig = ({ apiKey, model, provider })=>{
6
+ export const getProviderConfig = ({ apiKey, defaultModels, model, provider })=>{
7
+ const defaultModel = defaultModels?.[provider] || defaultAIModels[provider];
8
8
  if (provider === "claude") {
9
9
  return {
10
10
  apiKey: apiKey || process.env.ANTHROPIC_API_KEY,
11
- modelID: model || process.env.ANTHROPIC_MODEL || defaultAIModels.claude
11
+ modelID: model || process.env.ANTHROPIC_MODEL || defaultModel
12
12
  };
13
13
  }
14
14
  if (provider === "google") {
15
15
  return {
16
16
  apiKey: apiKey || process.env.GOOGLE_GENERATIVE_AI_API_KEY,
17
- modelID: model || process.env.GOOGLE_GENERATIVE_AI_MODEL || defaultAIModels.google
18
- };
19
- }
20
- if (provider === "groq") {
21
- return {
22
- apiKey: apiKey || process.env.GROQ_API_KEY,
23
- modelID: model || process.env.GROQ_MODEL || defaultAIModels.groq
17
+ modelID: model || process.env.GOOGLE_GENERATIVE_AI_MODEL || defaultModel
24
18
  };
25
19
  }
26
20
  if (provider === "mistral") {
27
21
  return {
28
22
  apiKey: apiKey || process.env.MISTRAL_API_KEY,
29
- modelID: model || process.env.MISTRAL_MODEL || defaultAIModels.mistral
23
+ modelID: model || process.env.MISTRAL_MODEL || defaultModel
30
24
  };
31
25
  }
32
26
  return {
33
27
  apiKey: apiKey || process.env.OPENAI_API_KEY,
34
- modelID: model || process.env.OPENAI_MODEL || defaultAIModels.openai
28
+ modelID: model || process.env.OPENAI_MODEL || defaultModel
35
29
  };
36
30
  };
37
31
  export const getModel = ({ apiKey, model, provider })=>{
@@ -41,9 +35,6 @@ export const getModel = ({ apiKey, model, provider })=>{
41
35
  if (provider === "google") return createGoogleGenerativeAI({
42
36
  apiKey
43
37
  })(model);
44
- if (provider === "groq") return createGroq({
45
- apiKey
46
- })(model);
47
38
  if (provider === "mistral") return createMistral({
48
39
  apiKey
49
40
  })(model);
@@ -11,6 +11,7 @@ export type AIActionProposal = {
11
11
  slug?: string;
12
12
  };
13
13
  type AIActionProposalListProps = {
14
+ apiRoute: string;
14
15
  appliedProposalIndexes: number[];
15
16
  description?: string;
16
17
  error?: string;
@@ -21,5 +22,5 @@ type AIActionProposalListProps = {
21
22
  onApply: (proposal: AIActionProposal, index: number) => void;
22
23
  proposals: AIActionProposal[];
23
24
  };
24
- export declare const AIActionProposalList: ({ appliedProposalIndexes, description, error, getViewURL, isApplying, onDismiss, onDismissError, onApply, proposals, }: AIActionProposalListProps) => import("react/jsx-runtime").JSX.Element | null;
25
+ export declare const AIActionProposalList: ({ apiRoute, appliedProposalIndexes, description, error, getViewURL, isApplying, onDismiss, onDismissError, onApply, proposals }: AIActionProposalListProps) => import("react").JSX.Element | null;
25
26
  export {};
@@ -1,5 +1,9 @@
1
1
  import styles from "./AIActionProposalList.module.css";
2
2
  import { redactSensitiveData } from "../ai/sensitiveData.js";
3
+ import { formatAdminURL } from "payload/shared";
4
+ import { useState } from "react";
5
+ import { DiffDialog } from "./DiffDialog.js";
6
+ import { Apply, FileDiff, Reject } from "./Icons.js";
3
7
  const maxDescriptionLength = 220;
4
8
  const getDescriptionPreview = (description)=>{
5
9
  if (description.length <= maxDescriptionLength) return description;
@@ -15,10 +19,46 @@ const getSafeProposalDetails = (proposal)=>{
15
19
  }
16
20
  return redactedProposal;
17
21
  };
18
- export const AIActionProposalList = ({ appliedProposalIndexes, description, error, getViewURL, isApplying, onDismiss, onDismissError, onApply, proposals })=>{
22
+ export const AIActionProposalList = ({ apiRoute, appliedProposalIndexes, description, error, getViewURL, isApplying, onDismiss, onDismissError, onApply, proposals })=>{
23
+ const [activeDiff, setActiveDiff] = useState(null);
24
+ const [diffError, setDiffError] = useState("");
25
+ const [loadingDiffIndex, setLoadingDiffIndex] = useState(null);
19
26
  if (proposals.length === 0 && !error && !description) return null;
20
27
  const descriptionPreview = description ? getDescriptionPreview(description) : "";
21
28
  const isDescriptionTruncated = Boolean(description) && descriptionPreview !== description;
29
+ const openDiff = async (proposal, index)=>{
30
+ setDiffError("");
31
+ setLoadingDiffIndex(index);
32
+ try {
33
+ const res = await fetch(formatAdminURL({
34
+ apiRoute,
35
+ path: "/ai-proposal-diff"
36
+ }), {
37
+ body: JSON.stringify({
38
+ proposal
39
+ }),
40
+ headers: {
41
+ "Content-Type": "application/json"
42
+ },
43
+ method: "POST"
44
+ });
45
+ const result = await res.json().catch(()=>null);
46
+ if (!res.ok || !result) {
47
+ throw new Error(result?.error || "Could not load proposal diff.");
48
+ }
49
+ setActiveDiff({
50
+ diff: {
51
+ after: result.after,
52
+ before: result.before
53
+ },
54
+ proposal
55
+ });
56
+ } catch (err) {
57
+ setDiffError(err instanceof Error ? err.message : "Could not load proposal diff.");
58
+ } finally{
59
+ setLoadingDiffIndex(null);
60
+ }
61
+ };
22
62
  return /*#__PURE__*/ React.createElement("div", {
23
63
  className: styles.list
24
64
  }, error && /*#__PURE__*/ React.createElement("div", {
@@ -73,22 +113,50 @@ export const AIActionProposalList = ({ appliedProposalIndexes, description, erro
73
113
  className: styles.footer
74
114
  }, /*#__PURE__*/ React.createElement("div", {
75
115
  className: styles.viewAction
76
- }, viewURL && /*#__PURE__*/ React.createElement("a", {
116
+ }, /*#__PURE__*/ React.createElement("button", {
77
117
  className: styles.secondaryButton,
118
+ disabled: loadingDiffIndex === index,
119
+ onClick: ()=>void openDiff(proposal, index),
120
+ type: "button"
121
+ }, /*#__PURE__*/ React.createElement(FileDiff, {
122
+ height: 16,
123
+ width: 16
124
+ }), loadingDiffIndex === index ? "Loading" : "Review"), viewURL && /*#__PURE__*/ React.createElement("a", {
125
+ className: styles.ghostButton,
78
126
  href: viewURL,
79
127
  rel: "noreferrer noopener",
80
128
  target: "_blank"
81
- }, "View")), /*#__PURE__*/ React.createElement("div", {
129
+ }, "Go to source")), /*#__PURE__*/ React.createElement("div", {
82
130
  className: styles.actions
83
131
  }, onDismiss && /*#__PURE__*/ React.createElement("button", {
84
132
  className: styles.secondaryButton,
85
133
  onClick: onDismiss,
86
134
  type: "button"
87
- }, "Dismiss"), /*#__PURE__*/ React.createElement("button", {
135
+ }, /*#__PURE__*/ React.createElement(Reject, {
136
+ height: 16,
137
+ width: 16
138
+ })), /*#__PURE__*/ React.createElement("button", {
88
139
  className: styles.button,
89
140
  disabled: isApplying || isApplied,
90
141
  onClick: ()=>onApply(proposal, index),
91
142
  type: "button"
92
- }, isApplied ? "Applied" : "Apply"))));
93
- }));
143
+ }, /*#__PURE__*/ React.createElement(Apply, {
144
+ height: 16,
145
+ width: 16
146
+ })))));
147
+ }), diffError ? /*#__PURE__*/ React.createElement("div", {
148
+ className: `${styles.item} ${styles.errorItem}`
149
+ }, /*#__PURE__*/ React.createElement("div", null, /*#__PURE__*/ React.createElement("div", {
150
+ className: styles.label
151
+ }, "Diff review failed"), /*#__PURE__*/ React.createElement("div", {
152
+ className: styles.description
153
+ }, diffError)), /*#__PURE__*/ React.createElement("button", {
154
+ className: styles.button,
155
+ onClick: ()=>setDiffError(""),
156
+ type: "button"
157
+ }, "Dismiss")) : null, activeDiff ? /*#__PURE__*/ React.createElement(DiffDialog, {
158
+ diff: activeDiff.diff,
159
+ onClose: ()=>setActiveDiff(null),
160
+ proposal: activeDiff.proposal
161
+ }) : null);
94
162
  };
@@ -94,6 +94,7 @@
94
94
  .viewAction {
95
95
  align-items: center;
96
96
  display: flex;
97
+ gap: 8px;
97
98
  min-width: 0;
98
99
  }
99
100
 
@@ -106,37 +107,74 @@
106
107
  }
107
108
 
108
109
  .button {
110
+ align-items: center;
109
111
  background: var(--theme-text);
110
112
  border: 0;
111
113
  border-radius: 4px;
112
114
  color: var(--theme-bg);
113
115
  cursor: pointer;
116
+ display: inline-flex;
114
117
  flex: 0 0 auto;
115
118
  font: inherit;
116
- min-height: 34px;
119
+ gap: 6px;
120
+ justify-content: center;
121
+ line-height: 24px;
122
+ min-height: 24px;
117
123
  padding: 0 14px;
118
124
  }
119
125
 
120
126
  .secondaryButton {
127
+ align-items: center;
121
128
  background: transparent;
122
129
  border: 1px solid var(--theme-elevation-150);
123
130
  border-radius: 4px;
124
131
  color: var(--theme-text);
125
132
  cursor: pointer;
133
+ display: inline-flex;
126
134
  flex: 0 0 auto;
127
135
  font: inherit;
128
- line-height: 34px;
129
- min-height: 34px;
136
+ gap: 6px;
137
+ justify-content: center;
138
+ line-height: 24px;
139
+ min-height: 24px;
130
140
  padding: 0 14px;
131
141
  text-decoration: none;
132
142
  }
133
143
 
144
+ .secondaryButton svg {
145
+ color: var(--theme-elevation-500);
146
+ flex: 0 0 auto;
147
+ }
148
+
149
+ .button svg {
150
+ flex: 0 0 auto;
151
+ }
152
+
153
+ .ghostButton {
154
+ background: transparent;
155
+ border: 0;
156
+ border-radius: 4px;
157
+ color: var(--theme-elevation-600);
158
+ cursor: pointer;
159
+ flex: 0 0 auto;
160
+ font: inherit;
161
+ line-height: 24px;
162
+ min-height: 24px;
163
+ padding: 0 10px;
164
+ text-decoration: none;
165
+ }
166
+
167
+ .ghostButton:hover {
168
+ text-decoration: underline;
169
+ }
170
+
134
171
  .button:disabled {
135
172
  cursor: not-allowed;
136
173
  opacity: 0.45;
137
174
  }
138
175
 
139
- .secondaryButton:disabled {
176
+ .secondaryButton:disabled,
177
+ .ghostButton:disabled {
140
178
  cursor: not-allowed;
141
179
  opacity: 0.45;
142
180
  }
@@ -163,13 +201,19 @@
163
201
  flex-direction: column;
164
202
  }
165
203
 
204
+ .viewAction {
205
+ align-self: stretch;
206
+ flex-direction: column;
207
+ }
208
+
166
209
  .footer {
167
210
  align-items: stretch;
168
211
  flex-direction: column;
169
212
  }
170
213
 
171
214
  .button,
172
- .secondaryButton {
215
+ .secondaryButton,
216
+ .ghostButton {
173
217
  width: 100%;
174
218
  }
175
219
  }
@@ -1,13 +1,5 @@
1
1
  "use client";
2
2
  import { useField } from "@payloadcms/ui";
3
- const getLabel = (label, fallback)=>{
4
- if (typeof label === "string") return label;
5
- if (label && typeof label === "object") {
6
- const firstLabel = Object.values(label)[0];
7
- if (typeof firstLabel === "string") return firstLabel;
8
- }
9
- return fallback;
10
- };
11
3
  const getFieldClassName = ({ className, isReadOnly, showError })=>[
12
4
  "field-type",
13
5
  "password",
@@ -1 +1 @@
1
- export declare const AIInput: () => import("react/jsx-runtime").JSX.Element;
1
+ export declare const AIInput: () => import("react").JSX.Element;