@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.
@@ -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.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,14 @@
1
1
  "use client";
2
2
  import { useConfig } from "@payloadcms/ui";
3
3
  import { formatAdminURL } from "payload/shared";
4
- import { useMemo, useRef, useState } from "react";
4
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
5
5
  import { getResolvedAIModelConfig } from "../ai/providerOptions.js";
6
6
  import { getSerializableLabel, isInternalCollection } from "../payload/shared.js";
7
7
  import { AIActionProposalList } from "./AIActionProposalList.js";
8
8
  import styles from "./AIInput.module.css";
9
9
  import { CollectionMentionPopover } from "./CollectionMentionPopover.js";
10
- import { ClaudeIcon, GoogleGeminiIcon, MistralAiIcon, OpenaiIcon } from "./Icons.js";
10
+ import { ClaudeIcon, GoogleGeminiIcon, MistralAiIcon, OpenaiIcon, Send } from "./Icons.js";
11
+ import { RecentChangesList } from "./RecentChangesList.js";
11
12
  import { useAISettings } from "./hooks/useAISettings.js";
12
13
  import { useDocumentMentionSuggestions } from "./hooks/useDocumentMentionSuggestions.js";
13
14
  const collectBlockOptions = ({ fields, parent })=>{
@@ -54,6 +55,34 @@ const getProviderIcon = (provider)=>{
54
55
  return null;
55
56
  }
56
57
  };
58
+ const parseSSEEvent = (chunk)=>{
59
+ const lines = chunk.split("\n");
60
+ let eventName = "";
61
+ const dataLines = [];
62
+ for (const line of lines){
63
+ if (line.startsWith("event:")) {
64
+ eventName = line.slice(6).trim();
65
+ continue;
66
+ }
67
+ if (line.startsWith("data:")) {
68
+ dataLines.push(line.slice(5).trim());
69
+ }
70
+ }
71
+ if (!eventName) return null;
72
+ try {
73
+ const data = JSON.parse(dataLines.join("\n"));
74
+ if (eventName !== "text" && eventName !== "proposals" && eventName !== "error" && eventName !== "done") {
75
+ return null;
76
+ }
77
+ return {
78
+ data,
79
+ event: eventName
80
+ };
81
+ } catch {
82
+ return null;
83
+ }
84
+ };
85
+ const sanitizeResponseText = (value)=>value.replace(/\*\*/g, "");
57
86
  export const AIInput = ()=>{
58
87
  const { config } = useConfig();
59
88
  const editorRef = useRef(null);
@@ -75,6 +104,7 @@ export const AIInput = ()=>{
75
104
  const [response, setResponse] = useState("");
76
105
  const [error, setError] = useState("");
77
106
  const [proposals, setProposals] = useState([]);
107
+ const [appliedChanges, setAppliedChanges] = useState([]);
78
108
  const [isLoading, setIsLoading] = useState(false);
79
109
  const [isApplying, setIsApplying] = useState(false);
80
110
  const enabledCollectionSlugs = config.admin?.custom?.payloadAiPlugin?.collectionSlugs;
@@ -82,6 +112,39 @@ export const AIInput = ()=>{
82
112
  enabledCollectionSlugs
83
113
  ]);
84
114
  const isCollectionMentionEnabled = (slug)=>!enabledCollectionSlugSet || enabledCollectionSlugSet.has(slug);
115
+ const recentChangesEndpoint = useMemo(()=>formatAdminURL({
116
+ apiRoute: config.routes.api,
117
+ path: "/ai-recent-changes"
118
+ }), [
119
+ config.routes.api
120
+ ]);
121
+ const loadRecentChanges = useCallback(async ()=>{
122
+ const res = await fetch(recentChangesEndpoint);
123
+ const result = await res.json().catch(()=>null);
124
+ if (res.ok && result?.changes) {
125
+ setAppliedChanges(result.changes);
126
+ }
127
+ }, [
128
+ recentChangesEndpoint
129
+ ]);
130
+ useEffect(()=>{
131
+ void loadRecentChanges().catch(()=>undefined);
132
+ }, [
133
+ loadRecentChanges
134
+ ]);
135
+ useEffect(()=>{
136
+ if (isLoading || error || proposals.length > 0 || !response) return;
137
+ const timeout = window.setTimeout(()=>{
138
+ setResponse("");
139
+ clearInput();
140
+ }, 5000);
141
+ return ()=>window.clearTimeout(timeout);
142
+ }, [
143
+ error,
144
+ isLoading,
145
+ proposals.length,
146
+ response
147
+ ]);
85
148
  const collections = config.collections.filter((collection)=>!isInternalCollection(collection.slug)).filter((collection)=>isCollectionMentionEnabled(collection.slug)).map((collection)=>({
86
149
  label: getSerializableLabel(collection.labels?.singular, collection.slug),
87
150
  slug: collection.slug,
@@ -247,6 +310,9 @@ export const AIInput = ()=>{
247
310
  if (!trimmedPrompt) return;
248
311
  setIsLoading(true);
249
312
  setAppliedProposalIndexes([]);
313
+ setError("");
314
+ setProposals([]);
315
+ setResponse("");
250
316
  try {
251
317
  const res = await fetch(formatAdminURL({
252
318
  apiRoute: config.routes.api,
@@ -262,15 +328,49 @@ export const AIInput = ()=>{
262
328
  },
263
329
  method: "POST"
264
330
  });
265
- const result = await res.json();
266
331
  if (!res.ok) {
267
- setProposals([]);
268
- setResponse("");
269
- throw new Error(result.error || "AI request failed");
332
+ const result = await res.json().catch(()=>null);
333
+ throw new Error(result?.error || "AI request failed");
334
+ }
335
+ if (!res.body) {
336
+ throw new Error("AI response stream is unavailable");
337
+ }
338
+ const reader = res.body.getReader();
339
+ const decoder = new TextDecoder();
340
+ let buffer = "";
341
+ while(true){
342
+ const { done, value } = await reader.read();
343
+ if (done) break;
344
+ buffer += decoder.decode(value, {
345
+ stream: true
346
+ });
347
+ const chunks = buffer.split("\n\n");
348
+ buffer = chunks.pop() || "";
349
+ for (const chunk of chunks){
350
+ const event = parseSSEEvent(chunk);
351
+ if (!event) continue;
352
+ if (event.event === "text") {
353
+ if (event.data.delta) {
354
+ setResponse((current)=>current + sanitizeResponseText(event.data.delta || ""));
355
+ }
356
+ continue;
357
+ }
358
+ if (event.event === "proposals") {
359
+ setProposals(event.data.proposals || []);
360
+ continue;
361
+ }
362
+ if (event.event === "error") {
363
+ throw new Error(event.data.error || "AI request failed");
364
+ }
365
+ }
366
+ }
367
+ const finalEvent = buffer.trim() ? parseSSEEvent(buffer.trim()) : null;
368
+ if (finalEvent?.event === "proposals") {
369
+ setProposals(finalEvent.data.proposals || []);
370
+ }
371
+ if (finalEvent?.event === "error") {
372
+ throw new Error(finalEvent.data.error || "AI request failed");
270
373
  }
271
- setError("");
272
- setResponse(result.text || "");
273
- setProposals(result.proposals || []);
274
374
  } catch (err) {
275
375
  setProposals([]);
276
376
  setResponse("");
@@ -305,6 +405,13 @@ export const AIInput = ()=>{
305
405
  setError("");
306
406
  setProposals([]);
307
407
  setResponse("");
408
+ if (result.change) {
409
+ setAppliedChanges((current)=>[
410
+ result.change,
411
+ ...current
412
+ ].slice(0, 12));
413
+ }
414
+ void loadRecentChanges().catch(()=>undefined);
308
415
  clearInput();
309
416
  } catch (err) {
310
417
  setError(err instanceof Error ? err.message : "Could not apply proposal");
@@ -313,6 +420,8 @@ export const AIInput = ()=>{
313
420
  }
314
421
  };
315
422
  return /*#__PURE__*/ React.createElement("div", {
423
+ className: styles.chatLayout
424
+ }, /*#__PURE__*/ React.createElement("div", {
316
425
  className: styles.chat
317
426
  }, /*#__PURE__*/ React.createElement("div", {
318
427
  className: styles.chatHeader
@@ -382,7 +491,11 @@ export const AIInput = ()=>{
382
491
  disabled: !prompt.trim() || !settingsProvider || !selectedModel || isLoading,
383
492
  onClick: ()=>void handleSubmit(),
384
493
  type: "button"
385
- }, isLoading ? "Sending..." : "Send")), /*#__PURE__*/ React.createElement(AIActionProposalList, {
494
+ }, /*#__PURE__*/ React.createElement(Send, {
495
+ width: 14,
496
+ height: 14
497
+ }), isLoading ? "Sending..." : "Send")), /*#__PURE__*/ React.createElement(AIActionProposalList, {
498
+ apiRoute: config.routes.api,
386
499
  appliedProposalIndexes: appliedProposalIndexes,
387
500
  description: response,
388
501
  error: error,
@@ -400,5 +513,7 @@ export const AIInput = ()=>{
400
513
  },
401
514
  onApply: (proposal, _index)=>void handleApplyProposal(proposal),
402
515
  proposals: proposals
516
+ })), /*#__PURE__*/ React.createElement(RecentChangesList, {
517
+ changes: appliedChanges
403
518
  }));
404
519
  };
@@ -1,8 +1,15 @@
1
+ .chatLayout {
2
+ align-items: stretch;
3
+ display: grid;
4
+ gap: 16px;
5
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
6
+ margin-bottom: 24px;
7
+ max-width: 1280px;
8
+ }
9
+
1
10
  .chat {
2
11
  border: 1px solid var(--theme-elevation-150);
3
12
  border-radius: 8px;
4
- margin-bottom: 24px;
5
- max-width: 920px;
6
13
  padding: 20px;
7
14
  }
8
15
 
@@ -52,7 +59,7 @@
52
59
 
53
60
  .selectProviderIcon {
54
61
  color: var(--theme-text);
55
- height: 16px;
62
+ height: 12px;
56
63
  left: 10px;
57
64
  pointer-events: none;
58
65
  position: absolute;
@@ -67,7 +74,7 @@
67
74
  border-radius: 4px;
68
75
  color: var(--theme-text);
69
76
  font: inherit;
70
- min-height: 34px;
77
+ min-height: 24px;
71
78
  min-width: 160px;
72
79
  padding: 0 28px 0 34px;
73
80
  }
@@ -173,14 +180,23 @@
173
180
  }
174
181
 
175
182
  .chatButton {
183
+ align-items: center;
176
184
  background: var(--theme-text);
177
185
  border: 0;
178
186
  border-radius: 4px;
179
187
  color: var(--theme-bg);
180
188
  cursor: pointer;
189
+ display: inline-flex;
181
190
  font: inherit;
182
- min-height: 40px;
183
- padding: 0 18px;
191
+ gap: 6px;
192
+ justify-content: center;
193
+ line-height: 24px;
194
+ min-height: 24px;
195
+ padding: 0 14px;
196
+ }
197
+
198
+ .chatButton svg {
199
+ flex: 0 0 auto;
184
200
  }
185
201
 
186
202
  .chatButton:disabled {
@@ -220,6 +236,10 @@
220
236
  }
221
237
 
222
238
  @media (max-width: 768px) {
239
+ .chatLayout {
240
+ grid-template-columns: minmax(0, 1fr);
241
+ }
242
+
223
243
  .chatHeader {
224
244
  flex-direction: column;
225
245
  gap: 12px;
@@ -251,4 +271,5 @@
251
271
  .chatButton {
252
272
  align-self: stretch;
253
273
  }
274
+
254
275
  }
@@ -0,0 +1,12 @@
1
+ import type { AIActionProposal } from "./AIActionProposalList.js";
2
+ export type ProposalDiff = {
3
+ after: unknown;
4
+ before: unknown;
5
+ };
6
+ type DiffDialogProps = {
7
+ diff: ProposalDiff;
8
+ onClose: () => void;
9
+ proposal: AIActionProposal;
10
+ };
11
+ export declare const DiffDialog: ({ diff, onClose, proposal }: DiffDialogProps) => import("react").JSX.Element;
12
+ export {};