@simmalugnt-se/payload-editor-assistant 0.3.3 → 0.4.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 CHANGED
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0
4
+
5
+ - A suggestion in the form stays open while the editor writes on, so "shorter" or "another tone"
6
+ adjusts it instead of keeping it first. The preview shows the adjusted version, **Undo** takes the
7
+ form back to before the first suggestion, and **Keep** confirms every step in order on the server,
8
+ each against the form it was made for, so the audit log shows them all. Leaving the form or
9
+ starting a draft still keeps an open suggestion first.
10
+ - The conversation is kept per document: each document and global has its own thread, and lists and
11
+ the dashboard share one. Threads are stored in the browser (localStorage) per user, survive a
12
+ reload, hold the last 50 messages and are forgotten after 30 days; **New chat** clears the current
13
+ one. A new document's thread becomes the document's own when it is first saved. Switching document
14
+ stops an unfinished request and closes an open proposal card, which belongs to the other form.
15
+ - While the assistant works, the panel says what it is doing ("Läser formuläret…", "Förbereder
16
+ ändringen…", "Skriver svaret…") instead of only "Working…". A form proposal goes into the form and
17
+ Live Preview as soon as it exists, before the reply is written. The chat endpoint answers as NDJSON
18
+ events when the request asks for `stream: true`; without it, it answers JSON as before. The reply
19
+ text itself still arrives whole, after the checks it goes through.
20
+ - Everything said in the thread follows the language the editor writes in, including the panel's
21
+ own messages ("Kept", "Undone", errors). Buttons, headings and hints still follow Admin's language.
22
+ - With a form proposal the model's own short sentence is shown ("Här är en kortare rubrik.") instead
23
+ of the same fixed line every time. Sentences that say the change is saved, published or kept, or
24
+ that tell the editor to approve or click, are left out; if nothing is left, the fixed line is used.
25
+ Draft creation keeps its fixed line, since nothing exists until it is approved.
26
+ - Undo reliably discards the proposals on the server: they are sent one at a time, as a second
27
+ request at once replaced the CSRF cookie the first one needed.
28
+ - A reply that says it put a change in the form ("har lagt in", "I've put") without making one is
29
+ retried like other claimed changes.
30
+
3
31
  ## 0.3.3
4
32
 
5
33
  - 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 keeps the change and
93
- builds on it ("shorter", "now the summary").
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. A
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,20 @@ 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
+ ## While it works
116
+
117
+ The panel shows what the assistant is doing (reading the form, searching content, preparing a
118
+ change, writing the reply). A proposed form change is put in the form as soon as the assistant has
119
+ made it, before its reply is written; Keep and Undo wait until the turn is over. If a proxy in front
120
+ of Payload buffers responses, the steps arrive all at once at the end, which still works.
121
+
122
+ ## Conversations
123
+
124
+ Each document and global has its own conversation, and lists and the dashboard share one. They are
125
+ kept in the editor's browser (localStorage), per user: the last 50 messages, for 30 days. Nothing is
126
+ stored in the database, so a conversation does not follow the editor to another browser. **New
127
+ chat** clears the current one.
128
+
113
129
  ## Options
114
130
 
115
131
  | Option | |
@@ -3,10 +3,12 @@ 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
14
  const { open, setOpen, status, messages, shortcuts, collections, bridge, formAdapter, pendingApproval, selection, setSelection, resetConversation, setStatus, setMessages, setPendingApproval, stop, setAbortController, } = useAssistantState();
@@ -15,6 +17,9 @@ export function EditorAssistantAside() {
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));
@@ -23,6 +28,8 @@ export function EditorAssistantAside() {
23
28
  /** Whether the editor typed in the form while a preview was in it; undo would discard that. */
24
29
  const editedSinceRef = useRef(false);
25
30
  const [confirmUndo, setConfirmUndo] = useState(false);
31
+ /** What the assistant is doing while a turn runs, from the server's step events. */
32
+ const [step, setStep] = useState(null);
26
33
  const stickToBottomRef = useRef(true);
27
34
  const apiBase = config.routes.api;
28
35
  // Scroll when the thread grows. The values are triggers, not reads.
@@ -62,8 +69,12 @@ export function EditorAssistantAside() {
62
69
  const say = (text) => {
63
70
  setMessages((current) => [...current, { id: crypto.randomUUID(), role: "assistant", text }]);
64
71
  };
65
- /** Puts a form proposal into the form so the form and Live Preview show it before it is kept. */
66
- const applyPreview = async (pending) => {
72
+ /**
73
+ * Puts a form proposal into the form so the form and Live Preview show it before it is kept.
74
+ * `basis` is the form the proposal was made for. With a suggestion already in the form, the new
75
+ * one refines it: undo still goes back to the form from before the first.
76
+ */
77
+ const applyPreview = async (pending, basis, open) => {
67
78
  if (pending.capability !== "form.propose" ||
68
79
  !pending.preview ||
69
80
  !formAdapter ||
@@ -77,10 +88,19 @@ export function EditorAssistantAside() {
77
88
  formAdapter.setModified(true);
78
89
  }
79
90
  catch {
91
+ if (open?.applied) {
92
+ // The earlier suggestion is still what the form shows; the refinement is dropped.
93
+ void decideRequest(pending, "reject", undefined).catch(() => undefined);
94
+ say(talk.applyFailed);
95
+ return open;
96
+ }
80
97
  return pending;
81
98
  }
99
+ if (open?.applied) {
100
+ return refineProposal(open, pending, basis);
101
+ }
82
102
  editedSinceRef.current = false;
83
- return { ...pending, applied: { before, beforeModified: bridge.modified } };
103
+ return { ...pending, applied: { before, beforeModified: bridge.modified, basis } };
84
104
  };
85
105
  const restore = async (applied) => {
86
106
  if (!formAdapter) {
@@ -92,10 +112,10 @@ export function EditorAssistantAside() {
92
112
  // is also what refreshes a preview that renders from the saved draft.
93
113
  formAdapter.setModified(autosave || applied.beforeModified);
94
114
  };
95
- const decideRequest = (pending, action, formData) => assistantRequest(apiBase, "/decide", {
115
+ const decideRequest = (step, action, formData) => assistantRequest(apiBase, "/decide", {
96
116
  action,
97
- approvalId: pending.approvalId,
98
- nonce: pending.nonce,
117
+ approvalId: step.approvalId,
118
+ nonce: step.nonce,
99
119
  collection: bridge?.collectionSlug,
100
120
  global: bridge?.globalSlug,
101
121
  documentId: bridge?.documentId,
@@ -103,8 +123,8 @@ export function EditorAssistantAside() {
103
123
  formData,
104
124
  });
105
125
  /**
106
- * Keeps a previewed change. The server checks the proposal against the form it was made for
107
- * and logs it; the form already shows the change, so nothing is applied again.
126
+ * Keeps a previewed change. The server checks each step against the form it was made for and
127
+ * logs it, oldest first; the form already shows the change, so nothing is applied again.
108
128
  */
109
129
  const keepPreview = async (pending, quiet = false) => {
110
130
  const applied = pending.applied;
@@ -113,14 +133,18 @@ export function EditorAssistantAside() {
113
133
  }
114
134
  setPendingApproval(null);
115
135
  setConfirmUndo(false);
116
- const result = await decideRequest(pending, "approve", applied.before).catch(() => null);
117
- if (!result?.ok) {
118
- await restore(applied).catch(() => undefined);
119
- say(copy.keepFailed);
120
- return false;
136
+ const steps = proposalSteps(pending);
137
+ for (const [index, step] of steps.entries()) {
138
+ const result = await decideRequest(step, "approve", step.basis).catch(() => null);
139
+ if (!result?.ok) {
140
+ await restore(applied).catch(() => undefined);
141
+ void rejectSteps(steps.slice(index + 1));
142
+ say(talk.keepFailed);
143
+ return false;
144
+ }
121
145
  }
122
146
  if (!quiet) {
123
- say(autosave ? copy.keptAutosave : copy.kept);
147
+ say(autosave ? talk.keptAutosave : talk.kept);
124
148
  }
125
149
  return true;
126
150
  };
@@ -137,24 +161,33 @@ export function EditorAssistantAside() {
137
161
  setConfirmUndo(false);
138
162
  try {
139
163
  await restore(applied);
140
- say(copy.undone);
164
+ say(talk.undone);
141
165
  }
142
166
  catch {
143
- say(copy.applyFailed);
167
+ say(talk.applyFailed);
168
+ }
169
+ void rejectSteps(proposalSteps(pending));
170
+ };
171
+ /**
172
+ * One at a time: each request fetches a CSRF token and replaces the cookie, so a second request
173
+ * started alongside would leave the first with a token that no longer matches.
174
+ */
175
+ const rejectSteps = async (steps) => {
176
+ for (const step of steps) {
177
+ await decideRequest(step, "reject", undefined).catch(() => undefined);
144
178
  }
145
- void decideRequest(pending, "reject", undefined).catch(() => undefined);
146
179
  };
147
180
  const send = async (text) => {
148
181
  const trimmed = text.trim();
149
182
  if (!trimmed || status === "streaming") {
150
183
  return;
151
184
  }
152
- // Writing on is taken as keeping what is in the form; the next change builds on it.
153
- if (pendingApproval?.applied && !(await keepPreview(pendingApproval, true))) {
154
- return;
155
- }
185
+ // A suggestion in the form stays open while the editor writes on: the next one refines it.
186
+ const open = pendingApproval?.applied ? pendingApproval : null;
187
+ const basis = formAdapter?.getData();
156
188
  stickToBottomRef.current = true;
157
189
  setDraft("");
190
+ setStep(null);
158
191
  const userMessage = { id: crypto.randomUUID(), role: "user", text: trimmed };
159
192
  setMessages((current) => [...current, userMessage]);
160
193
  const controller = new AbortController();
@@ -165,16 +198,42 @@ export function EditorAssistantAside() {
165
198
  role: message.role,
166
199
  text: message.text,
167
200
  }));
168
- const result = await assistantRequest(apiBase, "/chat", {
201
+ // A form proposal arrives before the reply is written and goes into the form right away.
202
+ // Proposals are applied one after another; a later one in the same turn replaces the earlier.
203
+ let shown = null;
204
+ let applying = Promise.resolve();
205
+ const showProposal = (next) => {
206
+ applying = applying.then(async () => {
207
+ if (shown?.approvalId === next.approvalId || !basis) {
208
+ return;
209
+ }
210
+ shown = shown?.applied
211
+ ? await replacePreview(shown, next, basis)
212
+ : await applyPreview(next, basis, open);
213
+ setPendingApproval(shown);
214
+ });
215
+ return applying;
216
+ };
217
+ const onEvent = (event) => {
218
+ if (event.type === "step" && typeof event.step === "string") {
219
+ setStep(event.step);
220
+ }
221
+ if (event.type === "proposal" && isFormProposal(event.pendingApproval)) {
222
+ void showProposal(event.pendingApproval);
223
+ }
224
+ };
225
+ const result = await assistantStream(apiBase, "/chat", {
169
226
  message: trimmed,
170
227
  messages: history,
171
228
  bridge,
172
229
  view,
173
230
  locale: locale.code,
174
231
  uiLanguage: i18n.language,
175
- formData: formAdapter?.getData(),
232
+ formData: basis,
176
233
  selection: selection?.path,
177
- }, controller.signal);
234
+ openProposal: open?.preview ? { changedPaths: open.preview.changedPaths } : undefined,
235
+ }, onEvent, controller.signal);
236
+ await applying;
178
237
  if (controller.signal.aborted) {
179
238
  setStatus("aborted");
180
239
  return;
@@ -187,22 +246,35 @@ export function EditorAssistantAside() {
187
246
  {
188
247
  id: crypto.randomUUID(),
189
248
  role: "assistant",
190
- text: result.message ?? copy.requestFailed,
249
+ text: result.message ?? talk.requestFailed,
191
250
  },
192
251
  ]);
193
252
  return;
194
253
  }
195
254
  setStatus("idle");
196
- if (result.data.pendingApproval) {
197
- setPendingApproval(await applyPreview(result.data.pendingApproval));
198
- }
199
255
  const navigate = result.data.navigate;
256
+ const next = result.data.pendingApproval;
257
+ if (isFormProposal(next) && basis) {
258
+ await showProposal(next);
259
+ }
260
+ else {
261
+ // Leaving the form or starting a draft ends the open suggestion: keep it first, as before.
262
+ const chain = shown ?? open;
263
+ if (chain?.applied && (next || (navigate && "href" in navigate))) {
264
+ if (!(await keepPreview(chain, true))) {
265
+ return;
266
+ }
267
+ }
268
+ if (next) {
269
+ setPendingApproval(next);
270
+ }
271
+ }
200
272
  setMessages((current) => [
201
273
  ...current,
202
274
  {
203
275
  id: crypto.randomUUID(),
204
276
  role: "assistant",
205
- text: result.data.text ?? copy.done,
277
+ text: result.data.text ?? talk.done,
206
278
  candidates: navigate && "candidates" in navigate ? navigate.candidates : undefined,
207
279
  },
208
280
  ]);
@@ -215,7 +287,24 @@ export function EditorAssistantAside() {
215
287
  }
216
288
  finally {
217
289
  setAbortController(null);
290
+ setStep(null);
291
+ }
292
+ };
293
+ /** A second proposal in one turn takes the first one's place in the form and on the server. */
294
+ const replacePreview = async (current, next, basis) => {
295
+ if (!formAdapter || !next.preview) {
296
+ return current;
297
+ }
298
+ try {
299
+ await formAdapter.reset(next.preview.data);
300
+ formAdapter.setModified(true);
301
+ }
302
+ catch {
303
+ void decideRequest(next, "reject", undefined).catch(() => undefined);
304
+ return current;
218
305
  }
306
+ void decideRequest(current, "reject", undefined).catch(() => undefined);
307
+ return replaceLatestStep(current, next, basis);
219
308
  };
220
309
  const decide = async (action) => {
221
310
  if (!pendingApproval || status === "streaming") {
@@ -256,7 +345,7 @@ export function EditorAssistantAside() {
256
345
  {
257
346
  id: crypto.randomUUID(),
258
347
  role: "assistant",
259
- text: result.message ?? copy.proposalFailed,
348
+ text: result.message ?? talk.proposalFailed,
260
349
  },
261
350
  ]);
262
351
  return;
@@ -270,8 +359,8 @@ export function EditorAssistantAside() {
270
359
  id: crypto.randomUUID(),
271
360
  role: "assistant",
272
361
  text: pendingApproval.capability === "draft.create"
273
- ? copy.rejectedDraft
274
- : copy.rejectedForm,
362
+ ? talk.rejectedDraft
363
+ : talk.rejectedForm,
275
364
  },
276
365
  ]);
277
366
  return;
@@ -285,7 +374,7 @@ export function EditorAssistantAside() {
285
374
  {
286
375
  id: crypto.randomUUID(),
287
376
  role: "assistant",
288
- text: copy.applied,
377
+ text: talk.applied,
289
378
  },
290
379
  ]);
291
380
  }
@@ -296,7 +385,7 @@ export function EditorAssistantAside() {
296
385
  {
297
386
  id: crypto.randomUUID(),
298
387
  role: "assistant",
299
- text: copy.applyFailed,
388
+ text: talk.applyFailed,
300
389
  },
301
390
  ]);
302
391
  }
@@ -311,7 +400,7 @@ export function EditorAssistantAside() {
311
400
  {
312
401
  id: crypto.randomUUID(),
313
402
  role: "assistant",
314
- text: copy.draftCreated,
403
+ text: talk.draftCreated,
315
404
  },
316
405
  ]);
317
406
  router.push(href);
@@ -340,11 +429,11 @@ export function EditorAssistantAside() {
340
429
  }
341
430
  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
431
  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: copy.working })] })) : null] }), pendingApproval ? (_jsxs("div", { className: "ea-approval", children: [_jsx("h3", { children: pendingApproval.title }), pendingApproval.applied ? (_jsx("p", { className: "ea-approval__hint", children: autosave ? copy.previewHintAutosave : copy.previewHint })) : null, _jsx("ul", { className: "ea-approval__diff", children: pendingApproval.summary.split("\n").map((line, index) => line.startsWith(" ") ? (
432
+ }, 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
433
  // biome-ignore lint/suspicious/noArrayIndexKey: lines are static for one approval
345
434
  _jsx("li", { className: "ea-approval__sub", children: line.trim() }, index)) : (
346
435
  // 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, copy) })) : 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: () => {
436
+ _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
437
  void send(shortcut.prompt);
349
438
  }, 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
439
  // While the assistant works, Send becomes Stop, where the editor's eye already is.
@@ -7,7 +7,7 @@ import { ChangedFieldsHighlight } from "./ChangedFieldsHighlight.js";
7
7
  import { EditorAssistantAside } from "./EditorAssistantAside.js";
8
8
  export function EditorAssistantProvider({ children, shortcuts = [], collections = [], }) {
9
9
  const { user } = useAuth();
10
- return (_jsx(AssistantStateProvider, { shortcuts: shortcuts, collections: collections, children: user ? _jsx(EditorAssistantShell, { children: children }) : children }));
10
+ return (_jsx(AssistantStateProvider, { shortcuts: shortcuts, collections: collections, userId: user?.id, children: user ? _jsx(EditorAssistantShell, { children: children }) : children }));
11
11
  }
12
12
  function EditorAssistantShell({ children }) {
13
13
  const { open } = useAssistantState();
@@ -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>;
@@ -1,5 +1,99 @@
1
1
  import { CSRF_HEADER, ENDPOINT_PREFIX } from "../package-name.js";
2
2
  export async function assistantRequest(apiBase, path, body, signal) {
3
+ const response = await post(apiBase, path, body, signal);
4
+ if (!(response instanceof Response)) {
5
+ return response;
6
+ }
7
+ const payload = (await response.json().catch(() => ({})));
8
+ if (!response.ok) {
9
+ return {
10
+ ok: false,
11
+ error: payload.error ?? "provider_error",
12
+ message: payload.message,
13
+ status: response.status,
14
+ };
15
+ }
16
+ return { ok: true, data: payload };
17
+ }
18
+ /**
19
+ * Like `assistantRequest`, for a turn answered as NDJSON: `onEvent` gets each `step` and `proposal`
20
+ * event as it arrives, and the result is the final `done` (or `error`) event. A server that answers
21
+ * with plain JSON instead still works.
22
+ */
23
+ export async function assistantStream(apiBase, path, body, onEvent, signal) {
24
+ const response = await post(apiBase, path, { ...body, stream: true }, signal);
25
+ if (!(response instanceof Response)) {
26
+ return response;
27
+ }
28
+ const type = response.headers.get("content-type") ?? "";
29
+ if (!response.ok || !response.body || !type.includes("ndjson")) {
30
+ const payload = (await response.json().catch(() => ({})));
31
+ return response.ok
32
+ ? { ok: true, data: payload }
33
+ : {
34
+ ok: false,
35
+ error: payload.error ?? "provider_error",
36
+ message: payload.message,
37
+ status: response.status,
38
+ };
39
+ }
40
+ let final;
41
+ await readNdjson(response.body, (event) => {
42
+ if (event.type === "done") {
43
+ final = { ok: true, data: event.result };
44
+ }
45
+ else if (event.type === "error") {
46
+ final = {
47
+ ok: false,
48
+ error: typeof event.error === "string" ? event.error : "provider_error",
49
+ message: typeof event.message === "string" ? event.message : undefined,
50
+ status: response.status,
51
+ };
52
+ }
53
+ else {
54
+ onEvent(event);
55
+ }
56
+ });
57
+ return final ?? { ok: false, error: "provider_error", status: response.status };
58
+ }
59
+ /** Calls `handle` with each JSON object line; chunks may split a line anywhere. */
60
+ export async function readNdjson(body, handle) {
61
+ const parse = (line) => {
62
+ if (!line.trim()) {
63
+ return;
64
+ }
65
+ try {
66
+ const event = JSON.parse(line);
67
+ if (event && typeof event.type === "string") {
68
+ handle(event);
69
+ }
70
+ }
71
+ catch {
72
+ // A line that is not JSON is skipped.
73
+ }
74
+ };
75
+ const reader = body.getReader();
76
+ const decoder = new TextDecoder();
77
+ let buffered = "";
78
+ for (;;) {
79
+ const { done, value } = await reader.read();
80
+ if (done) {
81
+ break;
82
+ }
83
+ buffered += decoder.decode(value, { stream: true });
84
+ const lines = buffered.split("\n");
85
+ buffered = lines.pop() ?? "";
86
+ for (const line of lines) {
87
+ parse(line);
88
+ }
89
+ }
90
+ parse(buffered + decoder.decode());
91
+ }
92
+ /**
93
+ * Posts with a fresh CSRF token. Fetching one replaces the cookie, so requests must not overlap: the
94
+ * earlier one would carry a token that no longer matches.
95
+ */
96
+ async function post(apiBase, path, body, signal) {
3
97
  const csrfResponse = await fetch(`${apiBase}${ENDPOINT_PREFIX}/csrf`, {
4
98
  credentials: "include",
5
99
  signal,
@@ -11,7 +105,7 @@ export async function assistantRequest(apiBase, path, body, signal) {
11
105
  if (!csrf.token) {
12
106
  return { ok: false, error: "unauthorized", status: 401 };
13
107
  }
14
- const response = await fetch(`${apiBase}${ENDPOINT_PREFIX}${path}`, {
108
+ return fetch(`${apiBase}${ENDPOINT_PREFIX}${path}`, {
15
109
  method: "POST",
16
110
  credentials: "include",
17
111
  signal,
@@ -21,14 +115,4 @@ export async function assistantRequest(apiBase, path, body, signal) {
21
115
  },
22
116
  body: JSON.stringify(body),
23
117
  });
24
- const payload = (await response.json().catch(() => ({})));
25
- if (!response.ok) {
26
- return {
27
- ok: false,
28
- error: payload.error ?? "provider_error",
29
- message: payload.message,
30
- status: response.status,
31
- };
32
- }
33
- return { ok: true, data: payload };
34
118
  }
@@ -30,11 +30,21 @@ export type PendingApproval = {
30
30
  data: Record<string, unknown>;
31
31
  changedPaths: string[];
32
32
  };
33
- /** Set once the preview is in the form: what to restore on undo. */
33
+ /**
34
+ * Set once the preview is in the form: what to restore on undo (the form before the first step)
35
+ * and `basis`, the form this proposal was made for, which keeping it is checked against.
36
+ */
34
37
  applied?: {
35
38
  before: Record<string, unknown>;
36
39
  beforeModified: boolean;
40
+ basis: Record<string, unknown>;
37
41
  };
42
+ /** Earlier proposals this one refines, oldest first; kept or undone together with it. */
43
+ earlier?: Array<{
44
+ approvalId: string;
45
+ nonce: string;
46
+ basis: Record<string, unknown>;
47
+ }>;
38
48
  };
39
49
  export type AssistantMessage = {
40
50
  id: string;
@@ -70,10 +80,12 @@ type AssistantContextValue = {
70
80
  abortController: AbortController | null;
71
81
  setAbortController: (controller: AbortController | null) => void;
72
82
  };
73
- export declare function AssistantStateProvider({ children, shortcuts, collections, }: {
83
+ export declare function AssistantStateProvider({ children, shortcuts, collections, userId, }: {
74
84
  children: ReactNode;
75
85
  shortcuts: AssistantShortcut[];
76
86
  collections: string[];
87
+ /** Whose conversations the browser keeps; none are kept without a signed-in user. */
88
+ userId?: string | number;
77
89
  }): import("react").JSX.Element;
78
90
  export declare function useAssistantState(): AssistantContextValue;
79
91
  export {};
@@ -1,8 +1,9 @@
1
1
  "use client";
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
- import { createContext, useCallback, useContext, useEffect, useMemo, useState, } from "react";
3
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react";
4
+ import { carriesOver, conversationKey, readConversation, sameConversation, writeConversation, } from "./conversation-store.js";
4
5
  const AssistantContext = createContext(null);
5
- export function AssistantStateProvider({ children, shortcuts, collections, }) {
6
+ export function AssistantStateProvider({ children, shortcuts, collections, userId, }) {
6
7
  const [open, setOpenState] = useState(false);
7
8
  const [status, setStatus] = useState("idle");
8
9
  const [messages, setMessages] = useState([]);
@@ -20,6 +21,35 @@ export function AssistantStateProvider({ children, shortcuts, collections, }) {
20
21
  setOpenState(next);
21
22
  storeOpen(next);
22
23
  }, []);
24
+ // One conversation per document: switching documents stores the thread and loads the next one.
25
+ // An open proposal belongs to the form it was made for, so it does not follow; an unfinished
26
+ // request is stopped so its answer does not land in another document's thread.
27
+ const collectionSlug = bridge?.collectionSlug;
28
+ const globalSlug = bridge?.globalSlug;
29
+ const documentId = bridge?.documentId;
30
+ const hasBridge = bridge !== null;
31
+ const key = useMemo(() => conversationKey(userId, hasBridge ? { collectionSlug, globalSlug, documentId } : null), [userId, hasBridge, collectionSlug, globalSlug, documentId]);
32
+ const [loadedKey, setLoadedKey] = useState(null);
33
+ const abortRef = useRef(abortController);
34
+ abortRef.current = abortController;
35
+ useEffect(() => {
36
+ if (!key || sameConversation(key, loadedKey)) {
37
+ return;
38
+ }
39
+ abortRef.current?.abort();
40
+ setAbortController(null);
41
+ setStatus("idle");
42
+ setPendingApproval(null);
43
+ if (!carriesOver(loadedKey, key)) {
44
+ setMessages(readConversation(key));
45
+ }
46
+ setLoadedKey(key);
47
+ }, [key, loadedKey]);
48
+ useEffect(() => {
49
+ if (loadedKey && sameConversation(key, loadedKey)) {
50
+ writeConversation(loadedKey, messages);
51
+ }
52
+ }, [key, loadedKey, messages]);
23
53
  const stop = useCallback(() => {
24
54
  abortController?.abort();
25
55
  setAbortController(null);
@@ -0,0 +1,18 @@
1
+ import type { AssistantMessage, DocumentBridgeSnapshot } from "./assistant-context.tsx";
2
+ export type ConversationKey = {
3
+ kind: "stored";
4
+ key: string;
5
+ }
6
+ /** A create view: the thread lives in memory until the document is saved. */
7
+ | {
8
+ kind: "unsaved";
9
+ entity: string;
10
+ };
11
+ export declare function conversationKey(userId: string | number | undefined, bridge: Pick<DocumentBridgeSnapshot, "collectionSlug" | "globalSlug" | "documentId"> | null): ConversationKey | null;
12
+ export declare function sameConversation(a: ConversationKey | null, b: ConversationKey | null): boolean;
13
+ /** Saving a new document: the thread that created it becomes the document's own. */
14
+ export declare function carriesOver(from: ConversationKey | null, to: ConversationKey): boolean;
15
+ /** Storage can be unavailable or hold anything; a thread that cannot be read starts empty. */
16
+ export declare function readConversation(key: ConversationKey): AssistantMessage[];
17
+ export declare function writeConversation(key: ConversationKey, messages: AssistantMessage[]): void;
18
+ export declare function parseStored(raw: string, now: number): AssistantMessage[];
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Conversations are kept in the browser, one per user and document, plus one for everything outside
3
+ * a document (lists, dashboard). A document that is not saved yet has none of its own: its thread is
4
+ * carried over to the document once it gets an id.
5
+ */
6
+ const PREFIX = "payload-editor-assistant:chat";
7
+ const MAX_MESSAGES = 50;
8
+ const MAX_AGE_MS = 30 * 24 * 60 * 60_000;
9
+ export function conversationKey(userId, bridge) {
10
+ if (userId === undefined) {
11
+ return null;
12
+ }
13
+ const user = `${PREFIX}:${userId}`;
14
+ if (!bridge || (!bridge.collectionSlug && !bridge.globalSlug)) {
15
+ return { kind: "stored", key: `${user}:general` };
16
+ }
17
+ if (bridge.globalSlug) {
18
+ return { kind: "stored", key: `${user}:global:${bridge.globalSlug}` };
19
+ }
20
+ if (bridge.documentId === undefined || bridge.documentId === null || bridge.documentId === "") {
21
+ return { kind: "unsaved", entity: `collection:${bridge.collectionSlug}` };
22
+ }
23
+ return {
24
+ kind: "stored",
25
+ key: `${user}:collection:${bridge.collectionSlug}:${bridge.documentId}`,
26
+ };
27
+ }
28
+ export function sameConversation(a, b) {
29
+ if (!a || !b) {
30
+ return a === b;
31
+ }
32
+ return a.kind === "stored"
33
+ ? b.kind === "stored" && a.key === b.key
34
+ : b.kind === "unsaved" && a.entity === b.entity;
35
+ }
36
+ /** Saving a new document: the thread that created it becomes the document's own. */
37
+ export function carriesOver(from, to) {
38
+ return (from?.kind === "unsaved" &&
39
+ to.kind === "stored" &&
40
+ to.key.includes(`:${from.entity}:`) &&
41
+ readConversation(to).length === 0);
42
+ }
43
+ /** Storage can be unavailable or hold anything; a thread that cannot be read starts empty. */
44
+ export function readConversation(key) {
45
+ if (key.kind !== "stored") {
46
+ return [];
47
+ }
48
+ try {
49
+ const raw = window.localStorage.getItem(key.key);
50
+ return raw ? parseStored(raw, Date.now()) : [];
51
+ }
52
+ catch {
53
+ return [];
54
+ }
55
+ }
56
+ export function writeConversation(key, messages) {
57
+ if (key.kind !== "stored") {
58
+ return;
59
+ }
60
+ try {
61
+ if (messages.length === 0) {
62
+ window.localStorage.removeItem(key.key);
63
+ return;
64
+ }
65
+ const stored = { savedAt: Date.now(), messages: messages.slice(-MAX_MESSAGES) };
66
+ window.localStorage.setItem(key.key, JSON.stringify(stored));
67
+ }
68
+ catch {
69
+ // Not remembered (full or blocked storage); the thread still works for this page.
70
+ }
71
+ }
72
+ export function parseStored(raw, now) {
73
+ try {
74
+ const stored = JSON.parse(raw);
75
+ if (typeof stored.savedAt !== "number" || now - stored.savedAt > MAX_AGE_MS) {
76
+ return [];
77
+ }
78
+ return Array.isArray(stored.messages)
79
+ ? stored.messages.filter(isMessage).map((message) => ({
80
+ id: message.id,
81
+ role: message.role,
82
+ text: message.text,
83
+ candidates: Array.isArray(message.candidates)
84
+ ? message.candidates.filter(isCandidate)
85
+ : undefined,
86
+ }))
87
+ : [];
88
+ }
89
+ catch {
90
+ return [];
91
+ }
92
+ }
93
+ function isMessage(value) {
94
+ const message = value;
95
+ return (typeof message?.id === "string" &&
96
+ typeof message.text === "string" &&
97
+ (message.role === "user" || message.role === "assistant" || message.role === "system"));
98
+ }
99
+ function isCandidate(value) {
100
+ const candidate = value;
101
+ return typeof candidate?.href === "string" && typeof candidate.label === "string";
102
+ }
@@ -14,6 +14,16 @@ export declare const COPY: {
14
14
  readonly send: "Send";
15
15
  readonly stop: "Stop";
16
16
  readonly working: "Working…";
17
+ readonly steps: {
18
+ readonly thinking: "Thinking…";
19
+ readonly searching: "Searching the content…";
20
+ readonly reading: "Reading a document…";
21
+ readonly reading_form: "Reading the form…";
22
+ readonly proposing: "Preparing the change…";
23
+ readonly drafting: "Preparing the draft…";
24
+ readonly navigating: "Finding the page…";
25
+ readonly writing: "Writing the reply…";
26
+ };
17
27
  readonly selected: "Selected";
18
28
  readonly clearSelection: "Clear selection";
19
29
  readonly approve: "Approve";
@@ -25,6 +35,7 @@ export declare const COPY: {
25
35
  readonly previewHint: "Shown in the form and the preview. Nothing is saved yet.";
26
36
  readonly previewHintAutosave: "Shown in the form and the preview. The draft saves itself, but nothing is published.";
27
37
  readonly undoLosesEdits: "You edited the form after the suggestion. Undo removes those edits too.";
38
+ readonly refinedHint: "The list shows the latest adjustment. Undo takes the form back to before the first suggestion.";
28
39
  readonly requestFailed: "I could not complete that request.";
29
40
  readonly done: "Done.";
30
41
  readonly proposalFailed: "The proposal could not be applied.";
@@ -65,6 +76,16 @@ export declare const COPY: {
65
76
  readonly send: "Skicka";
66
77
  readonly stop: "Stoppa";
67
78
  readonly working: "Arbetar…";
79
+ readonly steps: {
80
+ readonly thinking: "Tänker…";
81
+ readonly searching: "Söker bland innehållet…";
82
+ readonly reading: "Läser ett dokument…";
83
+ readonly reading_form: "Läser formuläret…";
84
+ readonly proposing: "Förbereder ändringen…";
85
+ readonly drafting: "Förbereder utkastet…";
86
+ readonly navigating: "Letar upp sidan…";
87
+ readonly writing: "Skriver svaret…";
88
+ };
68
89
  readonly selected: "Markerat";
69
90
  readonly clearSelection: "Ta bort markering";
70
91
  readonly approve: "Godkänn";
@@ -76,6 +97,7 @@ export declare const COPY: {
76
97
  readonly previewHint: "Syns i formuläret och förhandsvisningen. Inget är sparat än.";
77
98
  readonly previewHintAutosave: "Syns i formuläret och förhandsvisningen. Utkastet sparas automatiskt, men inget publiceras.";
78
99
  readonly undoLosesEdits: "Du har ändrat i formuläret efter förslaget. Ångra tar bort de ändringarna också.";
100
+ readonly refinedHint: "Listan visar den senaste justeringen. Ångra tar formuläret tillbaka till hur det var före första förslaget.";
79
101
  readonly requestFailed: "Jag kunde inte slutföra det.";
80
102
  readonly done: "Klart.";
81
103
  readonly proposalFailed: "Förslaget kunde inte genomföras.";
@@ -15,6 +15,16 @@ export const COPY = {
15
15
  send: "Send",
16
16
  stop: "Stop",
17
17
  working: "Working…",
18
+ steps: {
19
+ thinking: "Thinking…",
20
+ searching: "Searching the content…",
21
+ reading: "Reading a document…",
22
+ reading_form: "Reading the form…",
23
+ proposing: "Preparing the change…",
24
+ drafting: "Preparing the draft…",
25
+ navigating: "Finding the page…",
26
+ writing: "Writing the reply…",
27
+ },
18
28
  selected: "Selected",
19
29
  clearSelection: "Clear selection",
20
30
  approve: "Approve",
@@ -26,6 +36,7 @@ export const COPY = {
26
36
  previewHint: "Shown in the form and the preview. Nothing is saved yet.",
27
37
  previewHintAutosave: "Shown in the form and the preview. The draft saves itself, but nothing is published.",
28
38
  undoLosesEdits: "You edited the form after the suggestion. Undo removes those edits too.",
39
+ refinedHint: "The list shows the latest adjustment. Undo takes the form back to before the first suggestion.",
29
40
  requestFailed: "I could not complete that request.",
30
41
  done: "Done.",
31
42
  proposalFailed: "The proposal could not be applied.",
@@ -66,6 +77,16 @@ export const COPY = {
66
77
  send: "Skicka",
67
78
  stop: "Stoppa",
68
79
  working: "Arbetar…",
80
+ steps: {
81
+ thinking: "Tänker…",
82
+ searching: "Söker bland innehållet…",
83
+ reading: "Läser ett dokument…",
84
+ reading_form: "Läser formuläret…",
85
+ proposing: "Förbereder ändringen…",
86
+ drafting: "Förbereder utkastet…",
87
+ navigating: "Letar upp sidan…",
88
+ writing: "Skriver svaret…",
89
+ },
69
90
  selected: "Markerat",
70
91
  clearSelection: "Ta bort markering",
71
92
  approve: "Godkänn",
@@ -77,6 +98,7 @@ export const COPY = {
77
98
  previewHint: "Syns i formuläret och förhandsvisningen. Inget är sparat än.",
78
99
  previewHintAutosave: "Syns i formuläret och förhandsvisningen. Utkastet sparas automatiskt, men inget publiceras.",
79
100
  undoLosesEdits: "Du har ändrat i formuläret efter förslaget. Ångra tar bort de ändringarna också.",
101
+ refinedHint: "Listan visar den senaste justeringen. Ångra tar formuläret tillbaka till hur det var före första förslaget.",
80
102
  requestFailed: "Jag kunde inte slutföra det.",
81
103
  done: "Klart.",
82
104
  proposalFailed: "Förslaget kunde inte genomföras.",
@@ -0,0 +1,22 @@
1
+ import type { PendingApproval } from "./assistant-context.tsx";
2
+ /** One server-side proposal and the form it was made for; keeping it is checked against that form. */
3
+ export type ProposalStep = {
4
+ approvalId: string;
5
+ nonce: string;
6
+ basis: Record<string, unknown>;
7
+ };
8
+ /**
9
+ * A suggestion the editor refined before keeping it ("shorter", "another tone"). The new proposal
10
+ * was made against the form with the earlier one in it, so the chain keeps the form from before the
11
+ * first step for undo, and every step so each can be kept in order against its own form.
12
+ */
13
+ export declare function refineProposal(open: PendingApproval, next: PendingApproval, basis: Record<string, unknown>): PendingApproval;
14
+ /** Every step of an applied proposal, oldest first. */
15
+ export declare function proposalSteps(pending: PendingApproval): ProposalStep[];
16
+ /**
17
+ * The same turn produced a second proposal. It was made against the same form as the one it
18
+ * replaces, so it takes that step's place: undo and the earlier steps stay as they were.
19
+ */
20
+ export declare function replaceLatestStep(current: PendingApproval, next: PendingApproval, basis: Record<string, unknown>): PendingApproval;
21
+ /** A form proposal with the form it would produce: the only kind shown in the form before keeping. */
22
+ export declare function isFormProposal(value: unknown): value is PendingApproval;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * A suggestion the editor refined before keeping it ("shorter", "another tone"). The new proposal
3
+ * was made against the form with the earlier one in it, so the chain keeps the form from before the
4
+ * first step for undo, and every step so each can be kept in order against its own form.
5
+ */
6
+ export function refineProposal(open, next, basis) {
7
+ if (!open.applied) {
8
+ return next;
9
+ }
10
+ const changed = new Set([
11
+ ...(open.preview?.changedPaths ?? []),
12
+ ...(next.preview?.changedPaths ?? []),
13
+ ]);
14
+ return {
15
+ ...next,
16
+ preview: next.preview && { ...next.preview, changedPaths: [...changed] },
17
+ earlier: proposalSteps(open),
18
+ applied: { ...open.applied, basis },
19
+ };
20
+ }
21
+ /** Every step of an applied proposal, oldest first. */
22
+ export function proposalSteps(pending) {
23
+ if (!pending.applied) {
24
+ return [];
25
+ }
26
+ return [
27
+ ...(pending.earlier ?? []),
28
+ { approvalId: pending.approvalId, nonce: pending.nonce, basis: pending.applied.basis },
29
+ ];
30
+ }
31
+ /**
32
+ * The same turn produced a second proposal. It was made against the same form as the one it
33
+ * replaces, so it takes that step's place: undo and the earlier steps stay as they were.
34
+ */
35
+ export function replaceLatestStep(current, next, basis) {
36
+ if (!current.applied) {
37
+ return next;
38
+ }
39
+ const changed = new Set([
40
+ ...(current.preview?.changedPaths ?? []),
41
+ ...(next.preview?.changedPaths ?? []),
42
+ ]);
43
+ return {
44
+ ...next,
45
+ preview: next.preview && { ...next.preview, changedPaths: [...changed] },
46
+ earlier: current.earlier,
47
+ applied: { ...current.applied, basis },
48
+ };
49
+ }
50
+ /** A form proposal with the form it would produce: the only kind shown in the form before keeping. */
51
+ export function isFormProposal(value) {
52
+ const pending = value;
53
+ return (pending?.capability === "form.propose" &&
54
+ typeof pending.approvalId === "string" &&
55
+ typeof pending.nonce === "string" &&
56
+ Boolean(pending.preview && typeof pending.preview.data === "object"));
57
+ }
@@ -1,3 +1,7 @@
1
1
  import type { Endpoint } from "payload";
2
2
  import type { ValidatedEditorAssistantOptions } from "../config/validate.ts";
3
3
  export declare function createChatEndpoint(options: ValidatedEditorAssistantOptions): Endpoint;
4
+ /** Only a short list of form paths; the prompt names them as data. */
5
+ export declare function sanitizeOpenProposal(value: unknown): {
6
+ changedPaths: string[];
7
+ } | undefined;
@@ -26,6 +26,8 @@ export function createChatEndpoint(options) {
26
26
  if (!beginInflight(actor)) {
27
27
  return Response.json({ error: "rate_limited" }, { status: 429 });
28
28
  }
29
+ // A streamed turn ends when its stream does, not when the handler returns.
30
+ let streaming = false;
29
31
  try {
30
32
  let body;
31
33
  try {
@@ -45,7 +47,8 @@ export function createChatEndpoint(options) {
45
47
  ? body.messages.filter(isChatMessage)
46
48
  : [{ role: "user", text: body.message }];
47
49
  const language = resolveChatLanguage(messages.filter((message) => message.role === "user").map((message) => message.text), nonEmpty(body.uiLanguage));
48
- const result = await runAgentTurn(req, options, {
50
+ const run = (onEvent) => runAgentTurn(req, options, {
51
+ onEvent,
49
52
  message: body.message,
50
53
  messages,
51
54
  bridge: {
@@ -58,9 +61,15 @@ export function createChatEndpoint(options) {
58
61
  uiLanguage: nonEmpty(body.uiLanguage)?.startsWith("sv") ? "sv" : "en",
59
62
  view: sanitizeAdminView(body.view, options),
60
63
  selection: typeof body.selection === "string" ? body.selection : undefined,
64
+ openProposal: sanitizeOpenProposal(body.openProposal),
61
65
  },
62
66
  formData: body.formData,
63
67
  });
68
+ if (body.stream === true) {
69
+ streaming = true;
70
+ return streamTurn(run, language, () => endInflight(actor));
71
+ }
72
+ const result = await run();
64
73
  if (!result.ok) {
65
74
  const status = result.error === "timeout" ? 504 : 503;
66
75
  return Response.json({ error: result.error, message: result.message }, { status });
@@ -68,11 +77,69 @@ export function createChatEndpoint(options) {
68
77
  return Response.json({ ...result, language });
69
78
  }
70
79
  finally {
71
- endInflight(actor);
80
+ if (!streaming) {
81
+ endInflight(actor);
82
+ }
72
83
  }
73
84
  },
74
85
  };
75
86
  }
87
+ /**
88
+ * One JSON object per line: `step` and `proposal` events while the turn runs, then `done` with the
89
+ * same result the JSON answer has, or `error`. Errors after the stream started cannot change the
90
+ * HTTP status, so they travel as an event.
91
+ */
92
+ function streamTurn(run, language, finished) {
93
+ const encoder = new TextEncoder();
94
+ const stream = new ReadableStream({
95
+ async start(controller) {
96
+ const send = (value) => {
97
+ try {
98
+ controller.enqueue(encoder.encode(`${JSON.stringify(value)}\n`));
99
+ }
100
+ catch {
101
+ // The editor stopped the request; the turn is aborted through the request signal.
102
+ }
103
+ };
104
+ try {
105
+ const result = await run(send);
106
+ send(result.ok
107
+ ? { type: "done", result: { ...result, language } }
108
+ : { type: "error", error: result.error, message: result.message });
109
+ }
110
+ catch {
111
+ send({ type: "error", error: "provider_error" });
112
+ }
113
+ finally {
114
+ finished();
115
+ try {
116
+ controller.close();
117
+ }
118
+ catch {
119
+ // Already closed by the client.
120
+ }
121
+ }
122
+ },
123
+ });
124
+ return new Response(stream, {
125
+ headers: {
126
+ "Content-Type": "application/x-ndjson; charset=utf-8",
127
+ "Cache-Control": "no-cache, no-transform",
128
+ "X-Accel-Buffering": "no",
129
+ },
130
+ });
131
+ }
132
+ /** Only a short list of form paths; the prompt names them as data. */
133
+ export function sanitizeOpenProposal(value) {
134
+ const paths = value?.changedPaths;
135
+ if (!Array.isArray(paths)) {
136
+ return undefined;
137
+ }
138
+ const changedPaths = paths
139
+ .filter((path) => typeof path === "string" && /^[\w.]{1,200}$/.test(path))
140
+ .slice(0, 50);
141
+ return changedPaths.length > 0 ? { changedPaths } : undefined;
142
+ }
76
143
  function nonEmpty(value) {
77
144
  return typeof value === "string" && value.trim() ? value : undefined;
78
145
  }
@@ -32,11 +32,26 @@ type ChatMessage = {
32
32
  role: "user" | "assistant" | "system";
33
33
  text: string;
34
34
  };
35
+ /** What the assistant is doing right now, for the panel to show while the turn runs. */
36
+ export type AgentStep = "thinking" | "searching" | "reading" | "reading_form" | "proposing" | "drafting" | "navigating" | "writing";
37
+ export type AgentEvent = {
38
+ type: "step";
39
+ step: AgentStep;
40
+ }
41
+ /** A form proposal, sent as soon as it exists so Admin can show it before the reply is written. */
42
+ | {
43
+ type: "proposal";
44
+ pendingApproval: NonNullable<Extract<AgentChatResult, {
45
+ ok: true;
46
+ }>["pendingApproval"]>;
47
+ };
48
+ export type AgentEventSink = (event: AgentEvent) => void;
35
49
  export declare function runAgentTurn(req: PayloadRequest, options: ValidatedEditorAssistantOptions, input: {
36
50
  message: string;
37
51
  messages?: ChatMessage[];
38
52
  bridge: ToolContext["bridge"];
39
53
  formData?: Record<string, unknown>;
54
+ onEvent?: AgentEventSink;
40
55
  }): Promise<AgentChatResult>;
41
56
  export declare function collectResult(text: string, result: {
42
57
  steps?: unknown;
@@ -7,7 +7,24 @@ import { editorFacingText } from "./voice.js";
7
7
  const MAX_MESSAGES = 20;
8
8
  const MAX_STEPS = 8;
9
9
  const TIMEOUT_MS = 60_000;
10
+ const STEP_FOR_TOOL = {
11
+ "schema.discover": "thinking",
12
+ "content.find": "searching",
13
+ "content.findById": "reading",
14
+ "form.read": "reading_form",
15
+ "form.propose": "proposing",
16
+ "draft.create": "drafting",
17
+ "admin.navigate": "navigating",
18
+ };
10
19
  export async function runAgentTurn(req, options, input) {
20
+ const emit = (event) => {
21
+ try {
22
+ input.onEvent?.(event);
23
+ }
24
+ catch {
25
+ // A listener that went away (closed stream) does not stop the turn.
26
+ }
27
+ };
11
28
  const model = await resolveModel(options, req.user?.id);
12
29
  if (!model) {
13
30
  req.payload?.logger?.warn(`${PACKAGE_NAME}: no model: the \`model\` option resolved to nothing for this user`);
@@ -29,10 +46,11 @@ export async function runAgentTurn(req, options, input) {
29
46
  const history = truncateMessages(input.messages ?? [{ role: "user", text: input.message }]);
30
47
  const captured = [];
31
48
  const previews = new Map();
32
- const tools = buildSdkTools(ai, ctx, captured, previews);
49
+ const tools = buildSdkTools(ai, ctx, captured, previews, emit);
33
50
  const turnContext = await loadTurnContext(req, options, input.bridge, input.formData);
34
51
  const system = buildSystemPrompt(options.instructions, input.bridge, turnContext);
35
52
  const sdkMessages = toSdkMessages(history);
53
+ emit({ type: "step", step: "thinking" });
36
54
  try {
37
55
  const first = await generate(ai, {
38
56
  model,
@@ -60,7 +78,7 @@ export async function runAgentTurn(req, options, input) {
60
78
  return collected;
61
79
  }
62
80
  const retryCaptured = [];
63
- const retryTools = buildSdkTools(ai, ctx, retryCaptured, previews);
81
+ const retryTools = buildSdkTools(ai, ctx, retryCaptured, previews, emit);
64
82
  const retry = await generate(ai, {
65
83
  model,
66
84
  system,
@@ -168,7 +186,7 @@ function isToolOutcome(value) {
168
186
  const record = value;
169
187
  return typeof record.ok === "boolean" && typeof record.capability === "string";
170
188
  }
171
- function buildSdkTools(ai, ctx, captured, previews) {
189
+ function buildSdkTools(ai, ctx, captured, previews, emit) {
172
190
  const tools = {};
173
191
  for (const capability of Object.keys(TOOL_JSON_SCHEMAS)) {
174
192
  const schema = TOOL_JSON_SCHEMAS[capability];
@@ -180,6 +198,7 @@ function buildSdkTools(ai, ctx, captured, previews) {
180
198
  execute: async (raw) => {
181
199
  try {
182
200
  const input = raw && typeof raw === "object" ? raw : {};
201
+ emit({ type: "step", step: STEP_FOR_TOOL[capability] ?? "thinking" });
183
202
  const executed = await executeTool(fromProviderToolName(name), input, ctx);
184
203
  // The whole form with the change applied is for Admin, not another round through the model.
185
204
  const { preview, ...outcome } = executed.ok
@@ -187,7 +206,12 @@ function buildSdkTools(ai, ctx, captured, previews) {
187
206
  : { ...executed, preview: undefined };
188
207
  if (preview && executed.ok && executed.pendingApproval) {
189
208
  previews.set(executed.pendingApproval.approvalId, preview);
209
+ emit({ type: "proposal", pendingApproval: { ...executed.pendingApproval, preview } });
190
210
  }
211
+ emit({
212
+ type: "step",
213
+ step: executed.ok && executed.pendingApproval ? "writing" : "thinking",
214
+ });
191
215
  captured.push(outcome);
192
216
  return outcome;
193
217
  }
@@ -12,9 +12,9 @@ export function offeredValue(text, request) {
12
12
  }
13
13
  // A request for a change, or a yes to one the assistant asked about. Letter lookarounds rather
14
14
  // than \b, which treats å, ä and ö as word boundaries.
15
- const ASKS_FOR_CHANGE = /(?<!\p{L})(make|change|rewrite|write|add|replace|shorten|tighten|fill|translate|update|improve|set|rename|remove|delete|yes|yeah|sure|ok|okay|do it|go ahead|gör|göra|ändra|skriv|skriva|lägg|lägga|byt|byta|korta|fyll|fylla|översätt|uppdatera|förbättra|sätt|ta bort|ja|japp|kör|gärna)(?!\p{L})/iu;
15
+ const ASKS_FOR_CHANGE = /(?<!\p{L})(make|change|rewrite|write|add|replace|shorten|tighten|fill|translate|update|improve|set|rename|shorter|longer|simpler|remove|delete|yes|yeah|sure|ok|okay|do it|go ahead|gör|göra|ändra|skriv|skriva|lägg|lägga|byt|byta|korta|fyll|fylla|översätt|uppdatera|förbättra|sätt|ta bort|kortare|längre|enklare|ja|japp|kör|gärna)(?!\p{L})/iu;
16
16
  const QUOTED = /["“”«»][^"“”«»]{2,}["“”«»]|'[^']{2,}'/;
17
- const CLAIMED_CHANGE = /godkänn|godkann|approve|rubriken är nu|the headline is now|sparas den på sidan|fälten i på sidan|pending form change|i've applied|i have applied/i;
17
+ const CLAIMED_CHANGE = /godkänn|godkann|approve|rubriken är nu|the headline is now|sparas den på sidan|fälten i på sidan|pending form change|i've applied|i have applied|har lagt in|i've put|i have put/i;
18
18
  const ASKED_TO_MUTATE = /ska jag (lägga|fylla|ändra|sätta|uppdatera)|should i (put|fill|change|set|update|apply)|shall i (put|fill|change|set|update|apply)|want me to (put|fill|change|set|update|apply)/i;
19
19
  export function needsProposeRetry(input) {
20
20
  if (!input.hasOpenDocument || input.pendingApproval || input.navigate) {
@@ -7,7 +7,7 @@ export function buildSystemPrompt(instructions, bridge, turnContext) {
7
7
  "Two to five short sentences. Use visible field labels (Title, Headline, Summary).",
8
8
  "Never ask whether you should proceed. When you have the new values, call form.propose in the same turn.",
9
9
  "If the editor confirms a change you already described, call form.propose with those values. Do not ask again.",
10
- "Do not claim the form already has the new values. Do not tell the editor to approve; the panel does that when the proposal exists.",
10
+ 'When you call form.propose, the change is shown in the form and preview for the editor to keep or undo. Then say in one short sentence what you changed ("Här är en kortare rubrik."). Never say it is saved, published or kept, and do not tell them to approve or click anything; the panel does that. Without a form.propose call, do not claim the form has new values.',
11
11
  "If form.propose fails, say you could not apply it and they can try again. Never tell them to copy-paste.",
12
12
  "When the editor asks you to create a document, call draft.create in the same turn with sensible content based on their request. Do not ask for a title or summary first; they review the draft before it is created.",
13
13
  "Never save, publish, unpublish, delete, bulk-mutate, or upload files.",
@@ -62,7 +62,7 @@ function viewLine(turnContext) {
62
62
  function thisTurnBlock(bridge, turnContext) {
63
63
  const locale = bridge?.locale ?? turnContext?.open?.locale;
64
64
  const chat = bridge?.language ?? locale;
65
- if (!chat && !turnContext?.form) {
65
+ if (!chat && !turnContext?.form && !bridge?.openProposal) {
66
66
  return "";
67
67
  }
68
68
  const lines = [
@@ -81,6 +81,9 @@ function thisTurnBlock(bridge, turnContext) {
81
81
  ? `Selected field: ${selection.field.path}.`
82
82
  : `Selected: ${selection.path}; its fields are under that path in the form.`);
83
83
  }
84
+ if (bridge?.openProposal) {
85
+ lines.push(`The form shows your earlier suggestion, not kept yet; it changed ${bridge.openProposal.changedPaths.join(", ")}. Those values are in the form now. If the editor asks to adjust it (shorter, another tone, other wording, one more field), call form.propose now with the adjusted values; it replaces the suggestion in the preview. Do not say it is saved or kept.`);
86
+ }
84
87
  const form = turnContext?.form;
85
88
  if (form) {
86
89
  const empty = listEmptyTextPaths(form);
@@ -18,6 +18,10 @@ export type ToolBridge = {
18
18
  view?: AdminView;
19
19
  /** Form path of what the editor last clicked or focused in the open form, unverified. */
20
20
  selection?: string;
21
+ /** A suggestion shown in the form that the editor has not kept yet: the paths it changed. */
22
+ openProposal?: {
23
+ changedPaths: string[];
24
+ };
21
25
  };
22
26
  export type ToolContext = {
23
27
  req: PayloadRequest;
@@ -7,5 +7,6 @@ export declare function editorFacingText(input: {
7
7
  expectedProposal?: boolean;
8
8
  }): string;
9
9
  export declare function stripInternalSpeech(text: string, locale: string): string;
10
+ export declare function withoutOverclaims(text: string): string;
10
11
  export declare function approvalCopy(locale: string, capability: "form.propose" | "draft.create"): string;
11
12
  export declare function couldNotStageCopy(locale: string): string;
@@ -3,7 +3,12 @@ const INTERNAL = /\b(form\.propose|form_propose|draft\.create|draft_create|schem
3
3
  const ENGLISH_META = /\b(despite|therefore|however|the tool|I should|I've|has been issued|appropriate turn|proposed values)\b/i;
4
4
  export function editorFacingText(input) {
5
5
  if (input.pendingApproval) {
6
- return approvalCopy(input.locale, input.pendingApproval.capability);
6
+ // A form change is in the form now, so the model's own words about it can stand, minus any
7
+ // claim the panel contradicts. A draft is not created until approved; that keeps fixed copy.
8
+ const own = input.pendingApproval.capability === "form.propose"
9
+ ? withoutOverclaims(stripInternalSpeech(input.text, input.locale))
10
+ : "";
11
+ return own || approvalCopy(input.locale, input.pendingApproval.capability);
7
12
  }
8
13
  const cleaned = stripInternalSpeech(input.text, input.locale);
9
14
  if (input.expectedProposal ||
@@ -26,6 +31,16 @@ export function stripInternalSpeech(text, locale) {
26
31
  });
27
32
  return kept.join(" ").replace(/\s+/g, " ").trim();
28
33
  }
34
+ /** Keeping, saving and publishing are the editor's; a sentence that claims or asks for them goes. */
35
+ const OVERCLAIM = /godkänn|godkann|approve|spara|sparat|sparad|sparats|publicer|\bsaved?\b|publish|behåll|\bkeep\b|klicka|\bclick/i;
36
+ const MAX_OWN_TEXT = 600;
37
+ export function withoutOverclaims(text) {
38
+ const kept = splitSentences(text)
39
+ .filter((sentence) => !OVERCLAIM.test(sentence))
40
+ .join(" ")
41
+ .trim();
42
+ return kept.length <= MAX_OWN_TEXT ? kept : "";
43
+ }
29
44
  export function approvalCopy(locale, capability) {
30
45
  if (isSwedish(locale)) {
31
46
  return capability === "draft.create"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simmalugnt-se/payload-editor-assistant",
3
- "version": "0.3.3",
3
+ "version": "0.4.0",
4
4
  "description": "Context-aware editor assistant plugin for Payload Admin",
5
5
  "keywords": [
6
6
  "payload",