@simmalugnt-se/payload-editor-assistant 0.3.3 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +49 -0
- package/README.md +44 -4
- package/dist/components/EditorAssistantAside.js +135 -39
- package/dist/components/EditorAssistantDocumentBridge.js +26 -3
- package/dist/components/EditorAssistantProvider.js +3 -1
- package/dist/components/assistant-api.d.ts +20 -0
- package/dist/components/assistant-api.js +95 -11
- package/dist/components/assistant-context.d.ts +21 -3
- package/dist/components/assistant-context.js +40 -4
- package/dist/components/conversation-store.d.ts +18 -0
- package/dist/components/conversation-store.js +102 -0
- package/dist/components/copy.d.ts +22 -0
- package/dist/components/copy.js +22 -0
- package/dist/components/nav-room.d.ts +11 -0
- package/dist/components/nav-room.js +94 -0
- package/dist/components/proposal-chain.d.ts +22 -0
- package/dist/components/proposal-chain.js +57 -0
- package/dist/components/selection-dom.d.ts +4 -0
- package/dist/components/selection-dom.js +4 -0
- package/dist/endpoints/chat.d.ts +4 -0
- package/dist/endpoints/chat.js +69 -2
- package/dist/package-name.d.ts +5 -0
- package/dist/package-name.js +5 -0
- package/dist/plugin.js +6 -1
- package/dist/runtime/agent.d.ts +20 -2
- package/dist/runtime/agent.js +54 -4
- package/dist/runtime/propose-loop.js +2 -2
- package/dist/runtime/system-prompt.js +6 -6
- package/dist/runtime/tools.d.ts +4 -0
- package/dist/runtime/voice.d.ts +3 -0
- package/dist/runtime/voice.js +32 -1
- package/dist/styles/assistant.css +2 -1
- package/package.json +1 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,54 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.0
|
|
4
|
+
|
|
5
|
+
- `next` is a required peer dependency: the panel imports `next/navigation`, so it never worked
|
|
6
|
+
without it.
|
|
7
|
+
|
|
8
|
+
- Makes room in Admin: the panel is 320–400px wide depending on the window, and where fewer than
|
|
9
|
+
1440px would be left for Admin, Payload's nav and the panel take turns. Opening one closes the
|
|
10
|
+
other, which comes back when the first closes. While the assistant answers or a proposal waits for
|
|
11
|
+
Keep or Undo, opening the nav leaves the panel open.
|
|
12
|
+
- Works with the content health work list: `?ask=<prompt>` on a document's address opens the
|
|
13
|
+
assistant with the prompt in the message box (the editor sends it), and a `content-health:select`
|
|
14
|
+
event selects a field like a preview click. The allowlisted collections are published as
|
|
15
|
+
`config.admin.custom.editorAssistant.collections`.
|
|
16
|
+
|
|
17
|
+
- When the editor chats in one language and edits content in another, the first proposal says so
|
|
18
|
+
once, in a sentence the plugin adds; the model is no longer asked to, since it repeated it in
|
|
19
|
+
every reply or said it only in a later one.
|
|
20
|
+
- The reply to a proposal offers it ("Jag föreslår en varmare rubrik.") instead of describing it as
|
|
21
|
+
done: the prompt asks for that, and a sentence saying a field "är nu"/"is now" something is left
|
|
22
|
+
out.
|
|
23
|
+
|
|
24
|
+
## 0.4.0
|
|
25
|
+
|
|
26
|
+
- A suggestion in the form stays open while the editor writes on, so "shorter" or "another tone"
|
|
27
|
+
adjusts it instead of keeping it first. The preview shows the adjusted version, **Undo** takes the
|
|
28
|
+
form back to before the first suggestion, and **Keep** confirms every step in order on the server,
|
|
29
|
+
each against the form it was made for, so the audit log shows them all. Leaving the form or
|
|
30
|
+
starting a draft still keeps an open suggestion first.
|
|
31
|
+
- The conversation is kept per document: each document and global has its own thread, and lists and
|
|
32
|
+
the dashboard share one. Threads are stored in the browser (localStorage) per user, survive a
|
|
33
|
+
reload, hold the last 50 messages and are forgotten after 30 days; **New chat** clears the current
|
|
34
|
+
one. A new document's thread becomes the document's own when it is first saved. Switching document
|
|
35
|
+
stops an unfinished request and closes an open proposal card, which belongs to the other form.
|
|
36
|
+
- While the assistant works, the panel says what it is doing ("Läser formuläret…", "Förbereder
|
|
37
|
+
ändringen…", "Skriver svaret…") instead of only "Working…". A form proposal goes into the form and
|
|
38
|
+
Live Preview as soon as it exists, before the reply is written. The chat endpoint answers as NDJSON
|
|
39
|
+
events when the request asks for `stream: true`; without it, it answers JSON as before. The reply
|
|
40
|
+
text itself still arrives whole, after the checks it goes through.
|
|
41
|
+
- Everything said in the thread follows the language the editor writes in, including the panel's
|
|
42
|
+
own messages ("Kept", "Undone", errors). Buttons, headings and hints still follow Admin's language.
|
|
43
|
+
- With a form proposal the model's own short sentence is shown ("Här är en kortare rubrik.") instead
|
|
44
|
+
of the same fixed line every time. Sentences that say the change is saved, published or kept, or
|
|
45
|
+
that tell the editor to approve or click, are left out; if nothing is left, the fixed line is used.
|
|
46
|
+
Draft creation keeps its fixed line, since nothing exists until it is approved.
|
|
47
|
+
- Undo reliably discards the proposals on the server: they are sent one at a time, as a second
|
|
48
|
+
request at once replaced the CSRF cookie the first one needed.
|
|
49
|
+
- A reply that says it put a change in the form ("har lagt in", "I've put") without making one is
|
|
50
|
+
retried like other claimed changes.
|
|
51
|
+
|
|
3
52
|
## 0.3.3
|
|
4
53
|
|
|
5
54
|
- A `model` function may return `null` or `undefined`, for example when no API key is set. The drawer
|
package/README.md
CHANGED
|
@@ -89,11 +89,13 @@ secrets (`password`, `token`, `apiKey`, ...) are always off limits.
|
|
|
89
89
|
A proposed form change goes straight into the open form, so the fields and Live Preview show it
|
|
90
90
|
before the editor decides. The changed fields are outlined in blue, and the panel offers **Keep**
|
|
91
91
|
and **Undo**. Undo puts the form back as it was; if the editor typed in the form in the meantime,
|
|
92
|
-
the panel asks first, since undo removes that too. Writing another message
|
|
93
|
-
|
|
92
|
+
the panel asks first, since undo removes that too. Writing another message leaves the suggestion
|
|
93
|
+
open, so the editor can adjust it ("shorter", "now the summary too"): the adjusted version replaces
|
|
94
|
+
it in the preview, and Undo still goes back to the form from before the first suggestion. Leaving the
|
|
95
|
+
form or starting a draft keeps an open suggestion first.
|
|
94
96
|
|
|
95
|
-
Keep is still checked on the server against the form the change was made for, and logged
|
|
96
|
-
proposal expires after 30 minutes.
|
|
97
|
+
Keep is still checked on the server against the form the change was made for, and logged; an adjusted
|
|
98
|
+
suggestion is kept step by step, each step against its own form. A proposal expires after 30 minutes.
|
|
97
99
|
|
|
98
100
|
With autosave (`versions.drafts.autosave`) the draft is saved with the change while it is shown;
|
|
99
101
|
undo saves the draft back. A preview that renders from the saved draft (`RefreshRouteOnSave`)
|
|
@@ -110,6 +112,44 @@ clicks in the assistant keep it.
|
|
|
110
112
|
Preview clicks need `@simmalugnt-se/payload-visual-editing` (0.2.0 or later), which announces them
|
|
111
113
|
with a `visual-editing:select` event. Without it the selection follows the form only.
|
|
112
114
|
|
|
115
|
+
## From the content health work list
|
|
116
|
+
|
|
117
|
+
`@simmalugnt-se/payload-content-health` lists content issues and offers "Fix with the assistant" in
|
|
118
|
+
the collections the assistant works in. The link opens the document with the field selected (a
|
|
119
|
+
`content-health:select` event) and `?ask=<prompt>` in the address: the assistant opens with the
|
|
120
|
+
prompt in the message box, and the editor sends it. A link alone never makes the assistant act.
|
|
121
|
+
|
|
122
|
+
The plugin publishes its allowlisted collections as `config.admin.custom.editorAssistant.collections`
|
|
123
|
+
so the work list knows where to offer it; the packages do not import each other.
|
|
124
|
+
|
|
125
|
+
## Room for the panel
|
|
126
|
+
|
|
127
|
+
The panel takes 320–400px of the window, depending on its width. Payload lays out Admin by the
|
|
128
|
+
window's width and does not know about the panel. Where fewer than 1440px would be left for Admin
|
|
129
|
+
(the width at which Payload closes its nav itself), the nav and the panel take turns: opening the
|
|
130
|
+
panel closes the nav, opening the nav closes the panel, and the other comes back when you close it.
|
|
131
|
+
While the assistant is answering or a proposal waits for **Keep** or **Undo**, opening the nav leaves
|
|
132
|
+
the panel open, so the decision stays in view. Payload's saved nav preference and your own choice to
|
|
133
|
+
show the panel are not changed.
|
|
134
|
+
|
|
135
|
+
Live Preview splits the rest 40/60 between the form and the preview. From a 1440px window that leaves
|
|
136
|
+
a usable form beside the preview; on smaller screens, open the preview in its own window with the
|
|
137
|
+
external link button at the end of Live Preview's toolbar.
|
|
138
|
+
|
|
139
|
+
## While it works
|
|
140
|
+
|
|
141
|
+
The panel shows what the assistant is doing (reading the form, searching content, preparing a
|
|
142
|
+
change, writing the reply). A proposed form change is put in the form as soon as the assistant has
|
|
143
|
+
made it, before its reply is written; Keep and Undo wait until the turn is over. If a proxy in front
|
|
144
|
+
of Payload buffers responses, the steps arrive all at once at the end, which still works.
|
|
145
|
+
|
|
146
|
+
## Conversations
|
|
147
|
+
|
|
148
|
+
Each document and global has its own conversation, and lists and the dashboard share one. They are
|
|
149
|
+
kept in the editor's browser (localStorage), per user: the last 50 messages, for 30 days. Nothing is
|
|
150
|
+
stored in the database, so a conversation does not follow the editor to another browser. **New
|
|
151
|
+
chat** clears the current one.
|
|
152
|
+
|
|
113
153
|
## Options
|
|
114
154
|
|
|
115
155
|
| Option | |
|
|
@@ -3,26 +3,40 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
3
3
|
import { Button, ChevronIcon, useConfig, useLocale, useTranslation, XIcon } from "@payloadcms/ui";
|
|
4
4
|
import { usePathname, useRouter } from "next/navigation";
|
|
5
5
|
import { useEffect, useLayoutEffect, useRef, useState, } from "react";
|
|
6
|
+
import { resolveChatLanguage } from "../runtime/language.js";
|
|
6
7
|
import { parseAdminView } from "../view.js";
|
|
7
|
-
import { assistantRequest } from "./assistant-api.js";
|
|
8
|
+
import { assistantRequest, assistantStream } from "./assistant-api.js";
|
|
8
9
|
import { useAssistantState, } from "./assistant-context.js";
|
|
9
10
|
import { COPY, uiLanguage } from "./copy.js";
|
|
11
|
+
import { isFormProposal, proposalSteps, refineProposal, replaceLatestStep, } from "./proposal-chain.js";
|
|
10
12
|
import { KEEP_SELECTION_ATTR } from "./selection-dom.js";
|
|
11
13
|
export function EditorAssistantAside() {
|
|
12
|
-
const { open, setOpen, status, messages, shortcuts, collections, bridge, formAdapter, pendingApproval, selection, setSelection, resetConversation, setStatus, setMessages, setPendingApproval, stop, setAbortController, } = useAssistantState();
|
|
14
|
+
const { open, setOpen, status, messages, shortcuts, collections, bridge, formAdapter, pendingApproval, selection, setSelection, prefill, setPrefill, resetConversation, setStatus, setMessages, setPendingApproval, stop, setAbortController, } = useAssistantState();
|
|
13
15
|
const { config, getEntityConfig } = useConfig();
|
|
14
16
|
const router = useRouter();
|
|
15
17
|
const locale = useLocale();
|
|
16
18
|
const { i18n } = useTranslation();
|
|
17
19
|
const copy = COPY[uiLanguage(i18n.language)];
|
|
20
|
+
// What is said in the thread follows the language the editor writes in, like the replies;
|
|
21
|
+
// the panel's own controls follow Admin.
|
|
22
|
+
const talk = COPY[resolveChatLanguage(messages.filter((message) => message.role === "user").map((message) => message.text), i18n.language)];
|
|
18
23
|
const pathname = usePathname();
|
|
19
24
|
const view = parseAdminView(pathname ?? "", config.routes.admin);
|
|
20
25
|
const visibleShortcuts = shortcuts.filter((shortcut) => shortcutMatches(shortcut, view));
|
|
21
26
|
const [draft, setDraft] = useState("");
|
|
27
|
+
// A ready prompt replaces the draft; it is only sent when the editor sends it.
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (prefill !== null) {
|
|
30
|
+
setDraft(prefill);
|
|
31
|
+
setPrefill(null);
|
|
32
|
+
}
|
|
33
|
+
}, [prefill, setPrefill]);
|
|
22
34
|
const threadRef = useRef(null);
|
|
23
35
|
/** Whether the editor typed in the form while a preview was in it; undo would discard that. */
|
|
24
36
|
const editedSinceRef = useRef(false);
|
|
25
37
|
const [confirmUndo, setConfirmUndo] = useState(false);
|
|
38
|
+
/** What the assistant is doing while a turn runs, from the server's step events. */
|
|
39
|
+
const [step, setStep] = useState(null);
|
|
26
40
|
const stickToBottomRef = useRef(true);
|
|
27
41
|
const apiBase = config.routes.api;
|
|
28
42
|
// Scroll when the thread grows. The values are triggers, not reads.
|
|
@@ -62,8 +76,12 @@ export function EditorAssistantAside() {
|
|
|
62
76
|
const say = (text) => {
|
|
63
77
|
setMessages((current) => [...current, { id: crypto.randomUUID(), role: "assistant", text }]);
|
|
64
78
|
};
|
|
65
|
-
/**
|
|
66
|
-
|
|
79
|
+
/**
|
|
80
|
+
* Puts a form proposal into the form so the form and Live Preview show it before it is kept.
|
|
81
|
+
* `basis` is the form the proposal was made for. With a suggestion already in the form, the new
|
|
82
|
+
* one refines it: undo still goes back to the form from before the first.
|
|
83
|
+
*/
|
|
84
|
+
const applyPreview = async (pending, basis, open) => {
|
|
67
85
|
if (pending.capability !== "form.propose" ||
|
|
68
86
|
!pending.preview ||
|
|
69
87
|
!formAdapter ||
|
|
@@ -77,10 +95,19 @@ export function EditorAssistantAside() {
|
|
|
77
95
|
formAdapter.setModified(true);
|
|
78
96
|
}
|
|
79
97
|
catch {
|
|
98
|
+
if (open?.applied) {
|
|
99
|
+
// The earlier suggestion is still what the form shows; the refinement is dropped.
|
|
100
|
+
void decideRequest(pending, "reject", undefined).catch(() => undefined);
|
|
101
|
+
say(talk.applyFailed);
|
|
102
|
+
return open;
|
|
103
|
+
}
|
|
80
104
|
return pending;
|
|
81
105
|
}
|
|
106
|
+
if (open?.applied) {
|
|
107
|
+
return refineProposal(open, pending, basis);
|
|
108
|
+
}
|
|
82
109
|
editedSinceRef.current = false;
|
|
83
|
-
return { ...pending, applied: { before, beforeModified: bridge.modified } };
|
|
110
|
+
return { ...pending, applied: { before, beforeModified: bridge.modified, basis } };
|
|
84
111
|
};
|
|
85
112
|
const restore = async (applied) => {
|
|
86
113
|
if (!formAdapter) {
|
|
@@ -92,10 +119,10 @@ export function EditorAssistantAside() {
|
|
|
92
119
|
// is also what refreshes a preview that renders from the saved draft.
|
|
93
120
|
formAdapter.setModified(autosave || applied.beforeModified);
|
|
94
121
|
};
|
|
95
|
-
const decideRequest = (
|
|
122
|
+
const decideRequest = (step, action, formData) => assistantRequest(apiBase, "/decide", {
|
|
96
123
|
action,
|
|
97
|
-
approvalId:
|
|
98
|
-
nonce:
|
|
124
|
+
approvalId: step.approvalId,
|
|
125
|
+
nonce: step.nonce,
|
|
99
126
|
collection: bridge?.collectionSlug,
|
|
100
127
|
global: bridge?.globalSlug,
|
|
101
128
|
documentId: bridge?.documentId,
|
|
@@ -103,8 +130,8 @@ export function EditorAssistantAside() {
|
|
|
103
130
|
formData,
|
|
104
131
|
});
|
|
105
132
|
/**
|
|
106
|
-
* Keeps a previewed change. The server checks
|
|
107
|
-
*
|
|
133
|
+
* Keeps a previewed change. The server checks each step against the form it was made for and
|
|
134
|
+
* logs it, oldest first; the form already shows the change, so nothing is applied again.
|
|
108
135
|
*/
|
|
109
136
|
const keepPreview = async (pending, quiet = false) => {
|
|
110
137
|
const applied = pending.applied;
|
|
@@ -113,14 +140,18 @@ export function EditorAssistantAside() {
|
|
|
113
140
|
}
|
|
114
141
|
setPendingApproval(null);
|
|
115
142
|
setConfirmUndo(false);
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
await
|
|
119
|
-
|
|
120
|
-
|
|
143
|
+
const steps = proposalSteps(pending);
|
|
144
|
+
for (const [index, step] of steps.entries()) {
|
|
145
|
+
const result = await decideRequest(step, "approve", step.basis).catch(() => null);
|
|
146
|
+
if (!result?.ok) {
|
|
147
|
+
await restore(applied).catch(() => undefined);
|
|
148
|
+
void rejectSteps(steps.slice(index + 1));
|
|
149
|
+
say(talk.keepFailed);
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
121
152
|
}
|
|
122
153
|
if (!quiet) {
|
|
123
|
-
say(autosave ?
|
|
154
|
+
say(autosave ? talk.keptAutosave : talk.kept);
|
|
124
155
|
}
|
|
125
156
|
return true;
|
|
126
157
|
};
|
|
@@ -137,24 +168,33 @@ export function EditorAssistantAside() {
|
|
|
137
168
|
setConfirmUndo(false);
|
|
138
169
|
try {
|
|
139
170
|
await restore(applied);
|
|
140
|
-
say(
|
|
171
|
+
say(talk.undone);
|
|
141
172
|
}
|
|
142
173
|
catch {
|
|
143
|
-
say(
|
|
174
|
+
say(talk.applyFailed);
|
|
175
|
+
}
|
|
176
|
+
void rejectSteps(proposalSteps(pending));
|
|
177
|
+
};
|
|
178
|
+
/**
|
|
179
|
+
* One at a time: each request fetches a CSRF token and replaces the cookie, so a second request
|
|
180
|
+
* started alongside would leave the first with a token that no longer matches.
|
|
181
|
+
*/
|
|
182
|
+
const rejectSteps = async (steps) => {
|
|
183
|
+
for (const step of steps) {
|
|
184
|
+
await decideRequest(step, "reject", undefined).catch(() => undefined);
|
|
144
185
|
}
|
|
145
|
-
void decideRequest(pending, "reject", undefined).catch(() => undefined);
|
|
146
186
|
};
|
|
147
187
|
const send = async (text) => {
|
|
148
188
|
const trimmed = text.trim();
|
|
149
189
|
if (!trimmed || status === "streaming") {
|
|
150
190
|
return;
|
|
151
191
|
}
|
|
152
|
-
//
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
}
|
|
192
|
+
// A suggestion in the form stays open while the editor writes on: the next one refines it.
|
|
193
|
+
const open = pendingApproval?.applied ? pendingApproval : null;
|
|
194
|
+
const basis = formAdapter?.getData();
|
|
156
195
|
stickToBottomRef.current = true;
|
|
157
196
|
setDraft("");
|
|
197
|
+
setStep(null);
|
|
158
198
|
const userMessage = { id: crypto.randomUUID(), role: "user", text: trimmed };
|
|
159
199
|
setMessages((current) => [...current, userMessage]);
|
|
160
200
|
const controller = new AbortController();
|
|
@@ -165,16 +205,42 @@ export function EditorAssistantAside() {
|
|
|
165
205
|
role: message.role,
|
|
166
206
|
text: message.text,
|
|
167
207
|
}));
|
|
168
|
-
|
|
208
|
+
// A form proposal arrives before the reply is written and goes into the form right away.
|
|
209
|
+
// Proposals are applied one after another; a later one in the same turn replaces the earlier.
|
|
210
|
+
let shown = null;
|
|
211
|
+
let applying = Promise.resolve();
|
|
212
|
+
const showProposal = (next) => {
|
|
213
|
+
applying = applying.then(async () => {
|
|
214
|
+
if (shown?.approvalId === next.approvalId || !basis) {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
shown = shown?.applied
|
|
218
|
+
? await replacePreview(shown, next, basis)
|
|
219
|
+
: await applyPreview(next, basis, open);
|
|
220
|
+
setPendingApproval(shown);
|
|
221
|
+
});
|
|
222
|
+
return applying;
|
|
223
|
+
};
|
|
224
|
+
const onEvent = (event) => {
|
|
225
|
+
if (event.type === "step" && typeof event.step === "string") {
|
|
226
|
+
setStep(event.step);
|
|
227
|
+
}
|
|
228
|
+
if (event.type === "proposal" && isFormProposal(event.pendingApproval)) {
|
|
229
|
+
void showProposal(event.pendingApproval);
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
const result = await assistantStream(apiBase, "/chat", {
|
|
169
233
|
message: trimmed,
|
|
170
234
|
messages: history,
|
|
171
235
|
bridge,
|
|
172
236
|
view,
|
|
173
237
|
locale: locale.code,
|
|
174
238
|
uiLanguage: i18n.language,
|
|
175
|
-
formData:
|
|
239
|
+
formData: basis,
|
|
176
240
|
selection: selection?.path,
|
|
177
|
-
|
|
241
|
+
openProposal: open?.preview ? { changedPaths: open.preview.changedPaths } : undefined,
|
|
242
|
+
}, onEvent, controller.signal);
|
|
243
|
+
await applying;
|
|
178
244
|
if (controller.signal.aborted) {
|
|
179
245
|
setStatus("aborted");
|
|
180
246
|
return;
|
|
@@ -187,22 +253,35 @@ export function EditorAssistantAside() {
|
|
|
187
253
|
{
|
|
188
254
|
id: crypto.randomUUID(),
|
|
189
255
|
role: "assistant",
|
|
190
|
-
text: result.message ??
|
|
256
|
+
text: result.message ?? talk.requestFailed,
|
|
191
257
|
},
|
|
192
258
|
]);
|
|
193
259
|
return;
|
|
194
260
|
}
|
|
195
261
|
setStatus("idle");
|
|
196
|
-
if (result.data.pendingApproval) {
|
|
197
|
-
setPendingApproval(await applyPreview(result.data.pendingApproval));
|
|
198
|
-
}
|
|
199
262
|
const navigate = result.data.navigate;
|
|
263
|
+
const next = result.data.pendingApproval;
|
|
264
|
+
if (isFormProposal(next) && basis) {
|
|
265
|
+
await showProposal(next);
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
268
|
+
// Leaving the form or starting a draft ends the open suggestion: keep it first, as before.
|
|
269
|
+
const chain = shown ?? open;
|
|
270
|
+
if (chain?.applied && (next || (navigate && "href" in navigate))) {
|
|
271
|
+
if (!(await keepPreview(chain, true))) {
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (next) {
|
|
276
|
+
setPendingApproval(next);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
200
279
|
setMessages((current) => [
|
|
201
280
|
...current,
|
|
202
281
|
{
|
|
203
282
|
id: crypto.randomUUID(),
|
|
204
283
|
role: "assistant",
|
|
205
|
-
text: result.data.text ??
|
|
284
|
+
text: result.data.text ?? talk.done,
|
|
206
285
|
candidates: navigate && "candidates" in navigate ? navigate.candidates : undefined,
|
|
207
286
|
},
|
|
208
287
|
]);
|
|
@@ -215,7 +294,24 @@ export function EditorAssistantAside() {
|
|
|
215
294
|
}
|
|
216
295
|
finally {
|
|
217
296
|
setAbortController(null);
|
|
297
|
+
setStep(null);
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
/** A second proposal in one turn takes the first one's place in the form and on the server. */
|
|
301
|
+
const replacePreview = async (current, next, basis) => {
|
|
302
|
+
if (!formAdapter || !next.preview) {
|
|
303
|
+
return current;
|
|
304
|
+
}
|
|
305
|
+
try {
|
|
306
|
+
await formAdapter.reset(next.preview.data);
|
|
307
|
+
formAdapter.setModified(true);
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
void decideRequest(next, "reject", undefined).catch(() => undefined);
|
|
311
|
+
return current;
|
|
218
312
|
}
|
|
313
|
+
void decideRequest(current, "reject", undefined).catch(() => undefined);
|
|
314
|
+
return replaceLatestStep(current, next, basis);
|
|
219
315
|
};
|
|
220
316
|
const decide = async (action) => {
|
|
221
317
|
if (!pendingApproval || status === "streaming") {
|
|
@@ -256,7 +352,7 @@ export function EditorAssistantAside() {
|
|
|
256
352
|
{
|
|
257
353
|
id: crypto.randomUUID(),
|
|
258
354
|
role: "assistant",
|
|
259
|
-
text: result.message ??
|
|
355
|
+
text: result.message ?? talk.proposalFailed,
|
|
260
356
|
},
|
|
261
357
|
]);
|
|
262
358
|
return;
|
|
@@ -270,8 +366,8 @@ export function EditorAssistantAside() {
|
|
|
270
366
|
id: crypto.randomUUID(),
|
|
271
367
|
role: "assistant",
|
|
272
368
|
text: pendingApproval.capability === "draft.create"
|
|
273
|
-
?
|
|
274
|
-
:
|
|
369
|
+
? talk.rejectedDraft
|
|
370
|
+
: talk.rejectedForm,
|
|
275
371
|
},
|
|
276
372
|
]);
|
|
277
373
|
return;
|
|
@@ -285,7 +381,7 @@ export function EditorAssistantAside() {
|
|
|
285
381
|
{
|
|
286
382
|
id: crypto.randomUUID(),
|
|
287
383
|
role: "assistant",
|
|
288
|
-
text:
|
|
384
|
+
text: talk.applied,
|
|
289
385
|
},
|
|
290
386
|
]);
|
|
291
387
|
}
|
|
@@ -296,7 +392,7 @@ export function EditorAssistantAside() {
|
|
|
296
392
|
{
|
|
297
393
|
id: crypto.randomUUID(),
|
|
298
394
|
role: "assistant",
|
|
299
|
-
text:
|
|
395
|
+
text: talk.applyFailed,
|
|
300
396
|
},
|
|
301
397
|
]);
|
|
302
398
|
}
|
|
@@ -311,7 +407,7 @@ export function EditorAssistantAside() {
|
|
|
311
407
|
{
|
|
312
408
|
id: crypto.randomUUID(),
|
|
313
409
|
role: "assistant",
|
|
314
|
-
text:
|
|
410
|
+
text: talk.draftCreated,
|
|
315
411
|
},
|
|
316
412
|
]);
|
|
317
413
|
router.push(href);
|
|
@@ -340,11 +436,11 @@ export function EditorAssistantAside() {
|
|
|
340
436
|
}
|
|
341
437
|
return (_jsxs("aside", { className: "ea-aside", "aria-label": copy.assistant, [KEEP_SELECTION_ATTR]: "", children: [_jsxs("div", { className: "ea-aside__head", children: [_jsxs("div", { className: "ea-aside__title", children: [_jsx("h2", { children: copy.assistant }), bridge ? _jsx("p", { className: "ea-aside__context", children: contextLine(bridge, config) }) : null] }), (messages.length > 0 || pendingApproval) && !pendingApproval?.applied ? (_jsx(Button, { buttonStyle: "subtle", margin: false, onClick: () => resetConversation(), size: "medium", children: copy.newChat })) : null, _jsx(Button, { "aria-label": copy.collapseLabel, buttonStyle: "icon-label", className: "ea-aside__collapse", icon: _jsx(ChevronIcon, { direction: "right" }), margin: false, onClick: () => setOpen(false), tooltip: copy.collapse })] }), _jsxs("div", { className: "ea-aside__body", children: [_jsxs("div", { className: "ea-messages", ref: threadRef, onScroll: onThreadScroll, children: [messages.length === 0 ? (_jsx("div", { className: "ea-msg ea-msg--assistant", children: introText(view, bridge, collections, config, copy) })) : null, messages.map((message) => (_jsxs("div", { className: `ea-msg ea-msg--${message.role}`, children: [message.text, message.candidates?.length ? (_jsx("div", { className: "ea-actions", children: message.candidates.map((candidate) => (_jsx(Button, { buttonStyle: "pill", margin: false, onClick: () => {
|
|
342
438
|
router.push(candidate.href);
|
|
343
|
-
}, size: "small", children: candidate.label }, candidate.href))) })) : null] }, message.id))), status === "streaming" ? (_jsxs("div", { className: "ea-msg ea-msg--working", children: [_jsxs("span", { className: "ea-dots", "aria-hidden": "true", children: [_jsx("span", {}), _jsx("span", {}), _jsx("span", {})] }), _jsx("span", { children:
|
|
439
|
+
}, size: "small", children: candidate.label }, candidate.href))) })) : null] }, message.id))), status === "streaming" ? (_jsxs("div", { className: "ea-msg ea-msg--working", children: [_jsxs("span", { className: "ea-dots", "aria-hidden": "true", children: [_jsx("span", {}), _jsx("span", {}), _jsx("span", {})] }), _jsx("span", { children: step ? talk.steps[step] : talk.working })] })) : null] }), pendingApproval ? (_jsxs("div", { className: "ea-approval", children: [_jsx("h3", { children: pendingApproval.title }), pendingApproval.applied ? (_jsxs("p", { className: "ea-approval__hint", children: [autosave ? copy.previewHintAutosave : copy.previewHint, pendingApproval.earlier?.length ? ` ${copy.refinedHint}` : ""] })) : null, _jsx("ul", { className: "ea-approval__diff", children: pendingApproval.summary.split("\n").map((line, index) => line.startsWith(" ") ? (
|
|
344
440
|
// biome-ignore lint/suspicious/noArrayIndexKey: lines are static for one approval
|
|
345
441
|
_jsx("li", { className: "ea-approval__sub", children: line.trim() }, index)) : (
|
|
346
442
|
// biome-ignore lint/suspicious/noArrayIndexKey: lines are static for one approval
|
|
347
|
-
_jsx("li", { children: line }, index))) }), confirmUndo ? (_jsxs(_Fragment, { children: [_jsx("p", { className: "ea-approval__warn", children: copy.undoLosesEdits }), _jsxs("div", { className: "ea-actions", children: [_jsx(Button, { buttonStyle: "primary", margin: false, onClick: () => void undoPreview(pendingApproval, true), size: "medium", children: copy.undoAnyway }), _jsx(Button, { buttonStyle: "secondary", margin: false, onClick: () => setConfirmUndo(false), size: "medium", children: copy.cancel })] })] })) : (_jsxs("div", { className: "ea-actions", children: [_jsx(Button, { buttonStyle: "primary", margin: false, onClick: () => void decide("approve"), size: "medium", children: pendingApproval.applied ? copy.keep : copy.approve }), _jsx(Button, { buttonStyle: "secondary", margin: false, onClick: () => void decide("reject"), size: "medium", children: pendingApproval.applied ? copy.undo : copy.reject })] }))] })) : null, status !== "idle" && status !== "streaming" ? (_jsx("div", { className: `ea-status ea-status--${status}`, children: statusText(status,
|
|
443
|
+
_jsx("li", { children: line }, index))) }), confirmUndo ? (_jsxs(_Fragment, { children: [_jsx("p", { className: "ea-approval__warn", children: copy.undoLosesEdits }), _jsxs("div", { className: "ea-actions", children: [_jsx(Button, { buttonStyle: "primary", margin: false, onClick: () => void undoPreview(pendingApproval, true), size: "medium", children: copy.undoAnyway }), _jsx(Button, { buttonStyle: "secondary", margin: false, onClick: () => setConfirmUndo(false), size: "medium", children: copy.cancel })] })] })) : (_jsxs("div", { className: "ea-actions", children: [_jsx(Button, { buttonStyle: "primary", disabled: status === "streaming", margin: false, onClick: () => void decide("approve"), size: "medium", children: pendingApproval.applied ? copy.keep : copy.approve }), _jsx(Button, { buttonStyle: "secondary", disabled: status === "streaming", margin: false, onClick: () => void decide("reject"), size: "medium", children: pendingApproval.applied ? copy.undo : copy.reject })] }))] })) : null, status !== "idle" && status !== "streaming" ? (_jsx("div", { className: `ea-status ea-status--${status}`, children: statusText(status, talk) })) : null, _jsxs("form", { className: "ea-composer", onSubmit: onSubmit, children: [visibleShortcuts.length > 0 ? (_jsx("div", { className: "ea-shortcuts", children: visibleShortcuts.map((shortcut) => (_jsx(Button, { buttonStyle: "pill", disabled: status === "streaming", margin: false, onClick: () => {
|
|
348
444
|
void send(shortcut.prompt);
|
|
349
445
|
}, size: "small", children: shortcut.label }, shortcut.id))) })) : null, selection && bridge ? (_jsxs("div", { className: "ea-selection", children: [_jsxs("span", { className: "ea-selection__label", children: [copy.selected, ": ", _jsx("strong", { children: selection.label })] }), _jsx("button", { "aria-label": copy.clearSelection, className: "ea-selection__clear", type: "button", onClick: () => setSelection(null), children: _jsx(XIcon, {}) })] })) : null, _jsxs("div", { className: "ea-composer__box", children: [_jsx("textarea", { disabled: status === "streaming", placeholder: copy.placeholder, rows: 2, value: draft, onChange: (event) => setDraft(event.target.value), onKeyDown: onKeyDown }), _jsx("div", { className: "ea-composer__bar", children: status === "streaming" ? (
|
|
350
446
|
// While the assistant works, Send becomes Stop, where the editor's eye already is.
|
|
@@ -2,16 +2,18 @@
|
|
|
2
2
|
import { useDocumentForm, useDocumentInfo, useFormModified, useLocale } from "@payloadcms/ui";
|
|
3
3
|
import { useEffect, useRef } from "react";
|
|
4
4
|
import { useAssistantState } from "./assistant-context.js";
|
|
5
|
-
import { selectionFromElement, selectionFromPath, VISUAL_EDITING_SELECT_EVENT, } from "./selection-dom.js";
|
|
5
|
+
import { ASK_PARAM, CONTENT_HEALTH_SELECT_EVENT, selectionFromElement, selectionFromPath, VISUAL_EDITING_SELECT_EVENT, } from "./selection-dom.js";
|
|
6
6
|
/** Collapsed rows render their labels only once visual editing has opened them. */
|
|
7
7
|
const RELABEL_MS = [150, 500, 1200];
|
|
8
8
|
export function EditorAssistantDocumentBridge() {
|
|
9
|
-
const { setBridge, setFormAdapter, setSelection } = useAssistantState();
|
|
9
|
+
const { setBridge, setFormAdapter, setOpen, setPrefill, setSelection } = useAssistantState();
|
|
10
10
|
const documentInfo = useDocumentInfo();
|
|
11
11
|
const locale = useLocale();
|
|
12
12
|
const modified = useFormModified();
|
|
13
13
|
const form = useDocumentForm();
|
|
14
14
|
const formRef = useRef(form);
|
|
15
|
+
// Kept across effect re-runs: the parameter is gone from the address after the first read.
|
|
16
|
+
const asked = useRef(false);
|
|
15
17
|
useEffect(() => {
|
|
16
18
|
formRef.current = form;
|
|
17
19
|
});
|
|
@@ -25,8 +27,27 @@ export function EditorAssistantDocumentBridge() {
|
|
|
25
27
|
setFormAdapter(null);
|
|
26
28
|
};
|
|
27
29
|
}, [setFormAdapter]);
|
|
30
|
+
// A work list link (`?ask=`) opens the panel with a ready prompt. The editor sends it, so a
|
|
31
|
+
// link alone never makes the assistant act. The parameter is removed once read.
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (asked.current) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
asked.current = true;
|
|
37
|
+
const url = new URL(window.location.href);
|
|
38
|
+
const prompt = url.searchParams.get(ASK_PARAM)?.trim();
|
|
39
|
+
if (prompt === undefined) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
url.searchParams.delete(ASK_PARAM);
|
|
43
|
+
window.history.replaceState(window.history.state, "", url);
|
|
44
|
+
if (prompt) {
|
|
45
|
+
setPrefill(prompt);
|
|
46
|
+
setOpen(true);
|
|
47
|
+
}
|
|
48
|
+
}, [setOpen, setPrefill]);
|
|
28
49
|
// "This" in the editor's message is what they last clicked or focused: a field, a block or a row,
|
|
29
|
-
// in the form
|
|
50
|
+
// in the form, (through visual editing) in the preview, or brought into view by content health. Clicks elsewhere in Admin clear it;
|
|
30
51
|
// clicks in the assistant itself keep it, so the editor can click, then type.
|
|
31
52
|
useEffect(() => {
|
|
32
53
|
let relabel = [];
|
|
@@ -65,11 +86,13 @@ export function EditorAssistantDocumentBridge() {
|
|
|
65
86
|
document.addEventListener("pointerdown", onInteract, true);
|
|
66
87
|
document.addEventListener("focusin", onInteract);
|
|
67
88
|
window.addEventListener(VISUAL_EDITING_SELECT_EVENT, onPreviewSelect);
|
|
89
|
+
window.addEventListener(CONTENT_HEALTH_SELECT_EVENT, onPreviewSelect);
|
|
68
90
|
return () => {
|
|
69
91
|
cancelRelabel();
|
|
70
92
|
document.removeEventListener("pointerdown", onInteract, true);
|
|
71
93
|
document.removeEventListener("focusin", onInteract);
|
|
72
94
|
window.removeEventListener(VISUAL_EDITING_SELECT_EVENT, onPreviewSelect);
|
|
95
|
+
window.removeEventListener(CONTENT_HEALTH_SELECT_EVENT, onPreviewSelect);
|
|
73
96
|
setSelection(null);
|
|
74
97
|
};
|
|
75
98
|
}, [setSelection]);
|
|
@@ -5,11 +5,13 @@ import "../styles/assistant.css";
|
|
|
5
5
|
import { AssistantStateProvider, useAssistantState } from "./assistant-context.js";
|
|
6
6
|
import { ChangedFieldsHighlight } from "./ChangedFieldsHighlight.js";
|
|
7
7
|
import { EditorAssistantAside } from "./EditorAssistantAside.js";
|
|
8
|
+
import { useNavRoom } from "./nav-room.js";
|
|
8
9
|
export function EditorAssistantProvider({ children, shortcuts = [], collections = [], }) {
|
|
9
10
|
const { user } = useAuth();
|
|
10
|
-
return (_jsx(AssistantStateProvider, { shortcuts: shortcuts, collections: collections, children: user ? _jsx(EditorAssistantShell, { children: children }) : children }));
|
|
11
|
+
return (_jsx(AssistantStateProvider, { shortcuts: shortcuts, collections: collections, userId: user?.id, children: user ? _jsx(EditorAssistantShell, { children: children }) : children }));
|
|
11
12
|
}
|
|
12
13
|
function EditorAssistantShell({ children }) {
|
|
13
14
|
const { open } = useAssistantState();
|
|
15
|
+
useNavRoom();
|
|
14
16
|
return (_jsxs("div", { className: open ? "ea-root" : "ea-root ea-root--closed", children: [_jsx("div", { className: "ea-main", children: children }), _jsx(EditorAssistantAside, {}), _jsx(ChangedFieldsHighlight, {})] }));
|
|
15
17
|
}
|
|
@@ -7,3 +7,23 @@ export declare function assistantRequest<T>(apiBase: string, path: string, body:
|
|
|
7
7
|
message?: string;
|
|
8
8
|
status: number;
|
|
9
9
|
}>;
|
|
10
|
+
/**
|
|
11
|
+
* Like `assistantRequest`, for a turn answered as NDJSON: `onEvent` gets each `step` and `proposal`
|
|
12
|
+
* event as it arrives, and the result is the final `done` (or `error`) event. A server that answers
|
|
13
|
+
* with plain JSON instead still works.
|
|
14
|
+
*/
|
|
15
|
+
export declare function assistantStream<T>(apiBase: string, path: string, body: Record<string, unknown>, onEvent: (event: StreamEvent) => void, signal?: AbortSignal): Promise<{
|
|
16
|
+
ok: true;
|
|
17
|
+
data: T;
|
|
18
|
+
} | {
|
|
19
|
+
ok: false;
|
|
20
|
+
error: string;
|
|
21
|
+
message?: string;
|
|
22
|
+
status: number;
|
|
23
|
+
}>;
|
|
24
|
+
export type StreamEvent = {
|
|
25
|
+
type: string;
|
|
26
|
+
[key: string]: unknown;
|
|
27
|
+
};
|
|
28
|
+
/** Calls `handle` with each JSON object line; chunks may split a line anywhere. */
|
|
29
|
+
export declare function readNdjson(body: ReadableStream<Uint8Array>, handle: (event: StreamEvent) => void): Promise<void>;
|