@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 +35 -0
- package/README.md +29 -5
- package/dist/components/EditorAssistantAside.js +127 -38
- package/dist/components/EditorAssistantProvider.js +1 -1
- package/dist/components/assistant-api.d.ts +20 -0
- package/dist/components/assistant-api.js +95 -11
- package/dist/components/assistant-context.d.ts +15 -3
- package/dist/components/assistant-context.js +32 -2
- package/dist/components/conversation-store.d.ts +18 -0
- package/dist/components/conversation-store.js +102 -0
- package/dist/components/copy.d.ts +24 -0
- package/dist/components/copy.js +24 -0
- package/dist/components/proposal-chain.d.ts +22 -0
- package/dist/components/proposal-chain.js +57 -0
- package/dist/endpoints/chat.d.ts +4 -0
- package/dist/endpoints/chat.js +69 -2
- package/dist/runtime/agent.d.ts +15 -0
- package/dist/runtime/agent.js +29 -5
- package/dist/runtime/propose-loop.js +2 -2
- package/dist/runtime/system-prompt.js +5 -2
- package/dist/runtime/tools.d.ts +4 -0
- package/dist/runtime/voice.d.ts +1 -0
- package/dist/runtime/voice.js +16 -1
- package/dist/types.d.ts +5 -1
- package/package.json +4 -4
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
|
|
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
|
|
85
|
-
|
|
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
|
|
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
|
-
/**
|
|
66
|
-
|
|
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 = (
|
|
115
|
+
const decideRequest = (step, action, formData) => assistantRequest(apiBase, "/decide", {
|
|
96
116
|
action,
|
|
97
|
-
approvalId:
|
|
98
|
-
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
|
|
107
|
-
*
|
|
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
|
|
117
|
-
|
|
118
|
-
await
|
|
119
|
-
|
|
120
|
-
|
|
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 ?
|
|
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(
|
|
164
|
+
say(talk.undone);
|
|
141
165
|
}
|
|
142
166
|
catch {
|
|
143
|
-
say(
|
|
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
|
-
//
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
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:
|
|
232
|
+
formData: basis,
|
|
176
233
|
selection: selection?.path,
|
|
177
|
-
|
|
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 ??
|
|
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 ??
|
|
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 ??
|
|
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
|
-
?
|
|
274
|
-
:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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,
|
|
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
|
-
|
|
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
|
-
/**
|
|
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 {};
|