@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 +28 -0
- package/README.md +20 -4
- 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 +14 -2
- 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 +22 -0
- package/dist/components/copy.js +22 -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 +27 -3
- 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/package.json +1 -1
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
|
|
93
|
-
|
|
92
|
+
the panel asks first, since undo removes that too. Writing another message leaves the suggestion
|
|
93
|
+
open, so the editor can adjust it ("shorter", "now the summary too"): the adjusted version replaces
|
|
94
|
+
it in the preview, and Undo still goes back to the form from before the first suggestion. Leaving the
|
|
95
|
+
form or starting a draft keeps an open suggestion first.
|
|
94
96
|
|
|
95
|
-
Keep is still checked on the server against the form the change was made for, and logged
|
|
96
|
-
proposal expires after 30 minutes.
|
|
97
|
+
Keep is still checked on the server against the form the change was made for, and logged; an adjusted
|
|
98
|
+
suggestion is kept step by step, each step against its own form. A proposal expires after 30 minutes.
|
|
97
99
|
|
|
98
100
|
With autosave (`versions.drafts.autosave`) the draft is saved with the change while it is shown;
|
|
99
101
|
undo saves the draft back. A preview that renders from the saved draft (`RefreshRouteOnSave`)
|
|
@@ -110,6 +112,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
|
-
/**
|
|
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
|
}
|
|
@@ -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 {};
|
|
@@ -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.";
|
package/dist/components/copy.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/endpoints/chat.d.ts
CHANGED
|
@@ -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;
|
package/dist/endpoints/chat.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
}
|
package/dist/runtime/agent.d.ts
CHANGED
|
@@ -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;
|
package/dist/runtime/agent.js
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/dist/runtime/tools.d.ts
CHANGED
|
@@ -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;
|
package/dist/runtime/voice.d.ts
CHANGED
|
@@ -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;
|
package/dist/runtime/voice.js
CHANGED
|
@@ -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
|
-
|
|
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"
|