@simmalugnt-se/payload-editor-assistant 0.3.2 → 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,40 @@
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
+
31
+ ## 0.3.3
32
+
33
+ - A `model` function may return `null` or `undefined`, for example when no API key is set. The drawer
34
+ then says that no AI model is set up instead of that the AI service did not answer, and the server
35
+ logs a warning instead of an error. Hosts no longer need a cast to keep the plugin installed
36
+ without a model.
37
+
3
38
  ## 0.3.2
4
39
 
5
40
  - One control opens and closes the assistant: the text button with a sparkles icon in Admin's
package/README.md CHANGED
@@ -57,9 +57,17 @@ with its key, e.g. `createAnthropic({ apiKey: process.env.ANTHROPIC_API_KEY })`.
57
57
  Providers also read a default environment variable (`DEEPSEEK_API_KEY`, `ANTHROPIC_API_KEY`, ...)
58
58
  when no `apiKey` is passed, but passing it explicitly keeps the connection visible in your config.
59
59
 
60
- If the key is missing or wrong, the drawer shows `provider_error` and the cause is logged on the
60
+ If the key is wrong, the drawer says the AI service did not answer and the cause is logged on the
61
61
  server through Payload's logger.
62
62
 
63
+ To keep the plugin installed without a key (its tables stay in your schema and migrations), let
64
+ `model` be a function that returns `null` or `undefined` when there is nothing to call. The drawer then
65
+ tells the editor that no AI model is set up, and a warning is logged:
66
+
67
+ ```ts
68
+ model: () => (process.env.DEEPSEEK_API_KEY ? deepSeek("deepseek-chat") : null),
69
+ ```
70
+
63
71
  ## What the assistant may do
64
72
 
65
73
  Nothing is reachable unless you list it. Each collection or global gets the capabilities it allows:
@@ -81,11 +89,13 @@ secrets (`password`, `token`, `apiKey`, ...) are always off limits.
81
89
  A proposed form change goes straight into the open form, so the fields and Live Preview show it
82
90
  before the editor decides. The changed fields are outlined in blue, and the panel offers **Keep**
83
91
  and **Undo**. Undo puts the form back as it was; if the editor typed in the form in the meantime,
84
- the panel asks first, since undo removes that too. Writing another message keeps the change and
85
- 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.
86
96
 
87
- Keep is still checked on the server against the form the change was made for, and logged. A
88
- 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.
89
99
 
90
100
  With autosave (`versions.drafts.autosave`) the draft is saved with the change while it is shown;
91
101
  undo saves the draft back. A preview that renders from the saved draft (`RefreshRouteOnSave`)
@@ -102,6 +112,20 @@ clicks in the assistant keep it.
102
112
  Preview clicks need `@simmalugnt-se/payload-visual-editing` (0.2.0 or later), which announces them
103
113
  with a `visual-editing:select` event. Without it the selection follows the form only.
104
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
+
105
129
  ## Options
106
130
 
107
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
  }
@@ -1,7 +1,7 @@
1
1
  import { type ReactNode } from "react";
2
2
  import type { AssistantShortcut } from "../types.ts";
3
3
  import type { DomSelection } from "./selection-dom.ts";
4
- export type AssistantChromeStatus = "idle" | "unavailable" | "unauthorized" | "rate_limited" | "timeout" | "provider_error" | "tool_denied" | "stale_context" | "aborted" | "streaming";
4
+ export type AssistantChromeStatus = "idle" | "unavailable" | "unauthorized" | "rate_limited" | "timeout" | "provider_error" | "no_model" | "tool_denied" | "stale_context" | "aborted" | "streaming";
5
5
  export type DocumentBridgeSnapshot = {
6
6
  collectionSlug?: string;
7
7
  globalSlug?: string;
@@ -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 {};