@simmalugnt-se/payload-editor-assistant 0.2.0 → 0.3.1
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 +39 -0
- package/README.md +29 -3
- package/dist/collections/approvals.js +3 -2
- package/dist/collections/audit.js +1 -1
- package/dist/components/ChangedFieldsHighlight.d.ts +8 -0
- package/dist/components/ChangedFieldsHighlight.js +50 -0
- package/dist/components/EditorAssistantAside.js +171 -63
- package/dist/components/EditorAssistantDocumentBridge.js +52 -1
- package/dist/components/EditorAssistantProvider.js +2 -1
- package/dist/components/assistant-context.d.ts +14 -0
- package/dist/components/assistant-context.js +4 -0
- package/dist/components/copy.d.ts +109 -0
- package/dist/components/copy.js +109 -0
- package/dist/components/selection-dom.d.ts +16 -0
- package/dist/components/selection-dom.js +104 -0
- package/dist/domain/propose.d.ts +7 -1
- package/dist/domain/propose.js +8 -3
- package/dist/endpoints/chat.js +2 -0
- package/dist/form/changed-paths.d.ts +6 -0
- package/dist/form/changed-paths.js +26 -0
- package/dist/form/diff.js +1 -22
- package/dist/form/empty.js +1 -19
- package/dist/form/lexical.d.ts +8 -0
- package/dist/form/lexical.js +24 -0
- package/dist/runtime/agent.d.ts +4 -1
- package/dist/runtime/agent.js +21 -8
- package/dist/runtime/context.d.ts +3 -0
- package/dist/runtime/context.js +10 -3
- package/dist/runtime/propose-loop.d.ts +9 -0
- package/dist/runtime/propose-loop.js +19 -1
- package/dist/runtime/selection.d.ts +28 -0
- package/dist/runtime/selection.js +87 -0
- package/dist/runtime/system-prompt.js +18 -2
- package/dist/runtime/tools.d.ts +7 -0
- package/dist/runtime/tools.js +2 -1
- package/dist/runtime/voice.js +2 -2
- package/dist/schema/fields.d.ts +1 -0
- package/dist/schema/fields.js +1 -0
- package/dist/styles/assistant.css +218 -181
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,44 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.1
|
|
4
|
+
|
|
5
|
+
- A greeting or a question no longer fills empty fields: the assistant fills them only when asked.
|
|
6
|
+
Before, "hello" on a page with an empty field could come back as a proposal.
|
|
7
|
+
- The follow-up that makes the model stage a change it only talked about now needs a request for a
|
|
8
|
+
change (or a yes) from the editor; a question it asks after a greeting is left as a question.
|
|
9
|
+
- While the assistant works, Send turns into Stop instead of a separate button in the thread.
|
|
10
|
+
|
|
11
|
+
## 0.3.0
|
|
12
|
+
|
|
13
|
+
- Keep or undo: a proposed form change goes into the form right away, so the fields and Live Preview
|
|
14
|
+
show it, with the changed fields outlined. **Keep** and **Undo** replace Approve and Reject for
|
|
15
|
+
form changes; undo asks first if the editor edited the form since. Writing another message keeps
|
|
16
|
+
the change and builds on it. Keep is still checked and logged on the server. Draft creation is
|
|
17
|
+
unchanged.
|
|
18
|
+
- Proposals expire after 30 minutes instead of 2.
|
|
19
|
+
- Everything the panel says itself (buttons, labels, the proposal card, errors) follows Admin's
|
|
20
|
+
language, Swedish or English; the assistant's replies follow the language the editor writes in.
|
|
21
|
+
Errors read as sentences instead of codes such as `provider_error`.
|
|
22
|
+
- With a single field selected the assistant changes only that field; with a block or row selected,
|
|
23
|
+
only fields inside it.
|
|
24
|
+
- When the editor asks for a change and the model writes the new value in chat instead of staging
|
|
25
|
+
it ("a tighter version could be "…""), the assistant asks it once more to stage it. A value the
|
|
26
|
+
model volunteers while answering a question stays advice.
|
|
27
|
+
- "This" means what the editor selected. The assistant follows the field, block or array row last
|
|
28
|
+
clicked or focused in the form, or clicked in the preview through
|
|
29
|
+
`@simmalugnt-se/payload-visual-editing`, shows it above the message box and keeps changes inside
|
|
30
|
+
it unless asked for more. The server resolves the selection against the fields the assistant may
|
|
31
|
+
see, so hidden fields never reach the model.
|
|
32
|
+
- The drawer looks like the rest of Admin: Payload's buttons, icons, type and greys instead of its
|
|
33
|
+
own, the assistant's replies as plain text, and the document shown as "Page · Home" (with the
|
|
34
|
+
locale only when the project is localized). "Reset" is "New chat" and shows only when there is
|
|
35
|
+
something to clear. The selection, the proposal and the focused message box use visual editing's
|
|
36
|
+
highlight blue, so they read as the same selection as the outline in the preview.
|
|
37
|
+
- A selected rich text field reaches the model as its text rather than Lexical JSON.
|
|
38
|
+
- Selection labels leave out Payload's required and locale markers ("Headline", not "Headline*— sv").
|
|
39
|
+
- Fix: approving a proposal failed on SQLite ("The proposal could not be applied"): the approval
|
|
40
|
+
and audit access rules compared the stored text `actor` with a numeric user id.
|
|
41
|
+
|
|
3
42
|
## 0.2.0
|
|
4
43
|
|
|
5
44
|
Breaking: options that were accepted but did nothing are removed.
|
package/README.md
CHANGED
|
@@ -69,13 +69,39 @@ Nothing is reachable unless you list it. Each collection or global gets the capa
|
|
|
69
69
|
| `content.find` | search and list documents | no |
|
|
70
70
|
| `content.findById` | read one document | no |
|
|
71
71
|
| `form.read` | read the open edit form, including unsaved changes | no |
|
|
72
|
-
| `form.propose` |
|
|
72
|
+
| `form.propose` | put a change in the open form, where the editor keeps or undoes it | yes |
|
|
73
73
|
| `draft.create` | create a new draft document (collections only) | yes |
|
|
74
74
|
|
|
75
|
-
The assistant never publishes.
|
|
76
|
-
|
|
75
|
+
The assistant never publishes. Form changes land in the open form and the editor saves as usual; an
|
|
76
|
+
approved `draft.create` saves a new, unpublished draft. The `users` collection, Payload's own collections and fields that look like
|
|
77
77
|
secrets (`password`, `token`, `apiKey`, ...) are always off limits.
|
|
78
78
|
|
|
79
|
+
## Keep or undo
|
|
80
|
+
|
|
81
|
+
A proposed form change goes straight into the open form, so the fields and Live Preview show it
|
|
82
|
+
before the editor decides. The changed fields are outlined in blue, and the panel offers **Keep**
|
|
83
|
+
and **Undo**. Undo puts the form back as it was; if the editor typed in the form in the meantime,
|
|
84
|
+
the panel asks first, since undo removes that too. Writing another message keeps the change and
|
|
85
|
+
builds on it ("shorter", "now the summary").
|
|
86
|
+
|
|
87
|
+
Keep is still checked on the server against the form the change was made for, and logged. A
|
|
88
|
+
proposal expires after 30 minutes.
|
|
89
|
+
|
|
90
|
+
With autosave (`versions.drafts.autosave`) the draft is saved with the change while it is shown;
|
|
91
|
+
undo saves the draft back. A preview that renders from the saved draft (`RefreshRouteOnSave`)
|
|
92
|
+
needs autosave to show the change before it is kept; one that listens to the form
|
|
93
|
+
(`useLivePreview`) shows it either way.
|
|
94
|
+
|
|
95
|
+
## Selection
|
|
96
|
+
|
|
97
|
+
The editor can click a field, a block or an array row, in the form or in Live Preview, and then write
|
|
98
|
+
"make this shorter". The selection shows above the message box; the assistant treats it as "this"
|
|
99
|
+
and keeps its changes inside it unless asked for more. Clicking elsewhere in Admin clears it, and
|
|
100
|
+
clicks in the assistant keep it.
|
|
101
|
+
|
|
102
|
+
Preview clicks need `@simmalugnt-se/payload-visual-editing` (0.2.0 or later), which announces them
|
|
103
|
+
with a `visual-editing:select` event. Without it the selection follows the form only.
|
|
104
|
+
|
|
79
105
|
## Options
|
|
80
106
|
|
|
81
107
|
| Option | |
|
|
@@ -5,11 +5,12 @@ export function createApprovalCollection(slug = DEFAULT_APPROVAL_SLUG) {
|
|
|
5
5
|
admin: {
|
|
6
6
|
hidden: true,
|
|
7
7
|
},
|
|
8
|
+
// `actor` is stored as text; comparing it with a numeric user id matches nothing on SQLite.
|
|
8
9
|
access: {
|
|
9
10
|
create: ({ req }) => Boolean(req.user),
|
|
10
11
|
delete: () => false,
|
|
11
|
-
read: ({ req }) => (req.user ? { actor: { equals: req.user.id } } : false),
|
|
12
|
-
update: ({ req }) => (req.user ? { actor: { equals: req.user.id } } : false),
|
|
12
|
+
read: ({ req }) => (req.user ? { actor: { equals: String(req.user.id) } } : false),
|
|
13
|
+
update: ({ req }) => (req.user ? { actor: { equals: String(req.user.id) } } : false),
|
|
13
14
|
},
|
|
14
15
|
fields: [
|
|
15
16
|
{ name: "actor", type: "text", required: true, index: true },
|
|
@@ -9,7 +9,7 @@ export function createAuditCollection(slug = DEFAULT_AUDIT_SLUG) {
|
|
|
9
9
|
access: {
|
|
10
10
|
create: ({ req }) => Boolean(req.user),
|
|
11
11
|
delete: () => false,
|
|
12
|
-
read: ({ req }) => (req.user ? { actor: { equals: req.user.id } } : false),
|
|
12
|
+
read: ({ req }) => (req.user ? { actor: { equals: String(req.user.id) } } : false),
|
|
13
13
|
update: () => false,
|
|
14
14
|
},
|
|
15
15
|
fields: [
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Marks the fields a previewed proposal changed, in visual editing's highlight blue, until the
|
|
3
|
+
* editor keeps or undoes it. A stylesheet rather than classes: Payload re-renders fields freely,
|
|
4
|
+
* and ids and `data-field-path` are what stays put. Rows the change sits in get a tinted header,
|
|
5
|
+
* so a change inside a collapsed block is still visible.
|
|
6
|
+
*/
|
|
7
|
+
export declare function ChangedFieldsHighlight(): import("react").JSX.Element | null;
|
|
8
|
+
export declare function changedFieldsCss(paths: string[]): string;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { useAssistantState } from "./assistant-context.js";
|
|
4
|
+
const ACCENT = "#2563eb";
|
|
5
|
+
const TINT = "rgba(37, 99, 235, 0.08)";
|
|
6
|
+
/**
|
|
7
|
+
* Marks the fields a previewed proposal changed, in visual editing's highlight blue, until the
|
|
8
|
+
* editor keeps or undoes it. A stylesheet rather than classes: Payload re-renders fields freely,
|
|
9
|
+
* and ids and `data-field-path` are what stays put. Rows the change sits in get a tinted header,
|
|
10
|
+
* so a change inside a collapsed block is still visible.
|
|
11
|
+
*/
|
|
12
|
+
export function ChangedFieldsHighlight() {
|
|
13
|
+
const { pendingApproval } = useAssistantState();
|
|
14
|
+
const paths = pendingApproval?.applied ? (pendingApproval.preview?.changedPaths ?? []) : [];
|
|
15
|
+
if (paths.length === 0) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
return _jsx("style", { children: changedFieldsCss(paths) });
|
|
19
|
+
}
|
|
20
|
+
export function changedFieldsCss(paths) {
|
|
21
|
+
const fields = new Set();
|
|
22
|
+
const rows = new Set();
|
|
23
|
+
const headers = new Set();
|
|
24
|
+
for (const path of paths) {
|
|
25
|
+
fields.add(`#${CSS.escape(`field-${path.replace(/\./g, "__")}`)}`);
|
|
26
|
+
fields.add(`[data-field-path="${CSS.escape(path)}"]`);
|
|
27
|
+
const segments = path.split(".");
|
|
28
|
+
if (/^\d+$/.test(segments.at(-1) ?? "")) {
|
|
29
|
+
rows.add(`#${CSS.escape(rowId(segments))} > .collapsible`);
|
|
30
|
+
}
|
|
31
|
+
for (let end = 2; end < segments.length; end++) {
|
|
32
|
+
if (/^\d+$/.test(segments[end - 1] ?? "")) {
|
|
33
|
+
headers.add(`#${CSS.escape(rowId(segments.slice(0, end)))} > .collapsible > .collapsible__toggle-wrap`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const rules = [
|
|
38
|
+
fields.size > 0 &&
|
|
39
|
+
`${[...fields].join(",\n")} { outline: 2px solid ${ACCENT}; outline-offset: 2px; background-color: ${TINT}; }`,
|
|
40
|
+
rows.size > 0 &&
|
|
41
|
+
`${[...rows].join(",\n")} { outline: 2px solid ${ACCENT}; outline-offset: 2px; }`,
|
|
42
|
+
headers.size > 0 &&
|
|
43
|
+
`${[...headers].join(",\n")} { background-color: ${TINT}; box-shadow: inset 3px 0 0 ${ACCENT}; }`,
|
|
44
|
+
];
|
|
45
|
+
return rules.filter(Boolean).join("\n");
|
|
46
|
+
}
|
|
47
|
+
/** Payload's DOM id for a block or array row: ["layout", "2", "items", "0"] → "layout-2-items-row-0". */
|
|
48
|
+
function rowId(segments) {
|
|
49
|
+
return `${segments.slice(0, -1).join("-")}-row-${segments.at(-1)}`;
|
|
50
|
+
}
|
|
@@ -1,59 +1,28 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { useConfig, useLocale, useTranslation } from "@payloadcms/ui";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { Button, ChevronIcon, useConfig, useLocale, useTranslation, XIcon } from "@payloadcms/ui";
|
|
4
4
|
import { usePathname, useRouter } from "next/navigation";
|
|
5
|
-
import { useLayoutEffect, useRef, useState, } from "react";
|
|
5
|
+
import { useEffect, useLayoutEffect, useRef, useState, } from "react";
|
|
6
6
|
import { parseAdminView } from "../view.js";
|
|
7
7
|
import { assistantRequest } from "./assistant-api.js";
|
|
8
8
|
import { useAssistantState, } from "./assistant-context.js";
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
unavailable: "unavailable",
|
|
12
|
-
unauthorized: "unauthorized",
|
|
13
|
-
rate_limited: "rate_limited",
|
|
14
|
-
timeout: "timeout",
|
|
15
|
-
provider_error: "provider_error",
|
|
16
|
-
tool_denied: "tool_denied",
|
|
17
|
-
stale_context: "stale_context",
|
|
18
|
-
aborted: "aborted",
|
|
19
|
-
streaming: "Working…",
|
|
20
|
-
};
|
|
21
|
-
/** Messages the panel adds to the chat itself; they follow the editor's chat language. */
|
|
22
|
-
const CHAT_COPY = {
|
|
23
|
-
en: {
|
|
24
|
-
requestFailed: "I could not complete that request.",
|
|
25
|
-
done: "Done.",
|
|
26
|
-
proposalFailed: "The proposal could not be applied.",
|
|
27
|
-
rejectedForm: "Rejected. The form was not changed.",
|
|
28
|
-
rejectedDraft: "Rejected. No draft was created.",
|
|
29
|
-
applied: "Applied to the unsaved form. Save in Payload when you are ready.",
|
|
30
|
-
applyFailed: "The form could not be updated. It was left unchanged.",
|
|
31
|
-
draftCreated: "The draft is created and open.",
|
|
32
|
-
},
|
|
33
|
-
sv: {
|
|
34
|
-
requestFailed: "Jag kunde inte slutföra det.",
|
|
35
|
-
done: "Klart.",
|
|
36
|
-
proposalFailed: "Förslaget kunde inte genomföras.",
|
|
37
|
-
rejectedForm: "Avvisat. Formuläret ändrades inte.",
|
|
38
|
-
rejectedDraft: "Avvisat. Inget utkast skapades.",
|
|
39
|
-
applied: "Ändringen är införd i formuläret men inte sparad. Spara i Payload när du är redo.",
|
|
40
|
-
applyFailed: "Formuläret kunde inte uppdateras och lämnades orört.",
|
|
41
|
-
draftCreated: "Utkastet är skapat och öppnat.",
|
|
42
|
-
},
|
|
43
|
-
};
|
|
9
|
+
import { COPY, uiLanguage } from "./copy.js";
|
|
10
|
+
import { KEEP_SELECTION_ATTR } from "./selection-dom.js";
|
|
44
11
|
export function EditorAssistantAside() {
|
|
45
|
-
const { collapsed, setCollapsed, status, messages, shortcuts, collections, bridge, formAdapter, pendingApproval, resetConversation, setStatus, setMessages, setPendingApproval, stop, setAbortController, } = useAssistantState();
|
|
46
|
-
const { config } = useConfig();
|
|
12
|
+
const { collapsed, setCollapsed, status, messages, shortcuts, collections, bridge, formAdapter, pendingApproval, selection, setSelection, resetConversation, setStatus, setMessages, setPendingApproval, stop, setAbortController, } = useAssistantState();
|
|
13
|
+
const { config, getEntityConfig } = useConfig();
|
|
47
14
|
const router = useRouter();
|
|
48
15
|
const locale = useLocale();
|
|
49
16
|
const { i18n } = useTranslation();
|
|
50
|
-
const
|
|
51
|
-
const copy = CHAT_COPY[chatLanguage];
|
|
17
|
+
const copy = COPY[uiLanguage(i18n.language)];
|
|
52
18
|
const pathname = usePathname();
|
|
53
19
|
const view = parseAdminView(pathname ?? "", config.routes.admin);
|
|
54
20
|
const visibleShortcuts = shortcuts.filter((shortcut) => shortcutMatches(shortcut, view));
|
|
55
21
|
const [draft, setDraft] = useState("");
|
|
56
22
|
const threadRef = useRef(null);
|
|
23
|
+
/** Whether the editor typed in the form while a preview was in it; undo would discard that. */
|
|
24
|
+
const editedSinceRef = useRef(false);
|
|
25
|
+
const [confirmUndo, setConfirmUndo] = useState(false);
|
|
57
26
|
const stickToBottomRef = useRef(true);
|
|
58
27
|
const apiBase = config.routes.api;
|
|
59
28
|
// Scroll when the thread grows. The values are triggers, not reads.
|
|
@@ -70,11 +39,120 @@ export function EditorAssistantAside() {
|
|
|
70
39
|
const remaining = node.scrollHeight - node.scrollTop - node.clientHeight;
|
|
71
40
|
stickToBottomRef.current = remaining < 48;
|
|
72
41
|
};
|
|
42
|
+
const autosave = autosaves(getEntityConfig, bridge);
|
|
43
|
+
const previewing = Boolean(pendingApproval?.applied);
|
|
44
|
+
// Edits by the editor, not by autosave: autosave rewrites form metadata, so comparing the
|
|
45
|
+
// form's data would see changes nobody made.
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (!previewing) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const onEdit = (event) => {
|
|
51
|
+
if (event.target instanceof Element && !event.target.closest(".ea-aside")) {
|
|
52
|
+
editedSinceRef.current = true;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
document.addEventListener("input", onEdit, true);
|
|
56
|
+
document.addEventListener("change", onEdit, true);
|
|
57
|
+
return () => {
|
|
58
|
+
document.removeEventListener("input", onEdit, true);
|
|
59
|
+
document.removeEventListener("change", onEdit, true);
|
|
60
|
+
};
|
|
61
|
+
}, [previewing]);
|
|
62
|
+
const say = (text) => {
|
|
63
|
+
setMessages((current) => [...current, { id: crypto.randomUUID(), role: "assistant", text }]);
|
|
64
|
+
};
|
|
65
|
+
/** Puts a form proposal into the form so the form and Live Preview show it before it is kept. */
|
|
66
|
+
const applyPreview = async (pending) => {
|
|
67
|
+
if (pending.capability !== "form.propose" ||
|
|
68
|
+
!pending.preview ||
|
|
69
|
+
!formAdapter ||
|
|
70
|
+
!bridge ||
|
|
71
|
+
!canApplyForm(bridge)) {
|
|
72
|
+
return pending;
|
|
73
|
+
}
|
|
74
|
+
const before = formAdapter.getData();
|
|
75
|
+
try {
|
|
76
|
+
await formAdapter.reset(pending.preview.data);
|
|
77
|
+
formAdapter.setModified(true);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return pending;
|
|
81
|
+
}
|
|
82
|
+
editedSinceRef.current = false;
|
|
83
|
+
return { ...pending, applied: { before, beforeModified: bridge.modified } };
|
|
84
|
+
};
|
|
85
|
+
const restore = async (applied) => {
|
|
86
|
+
if (!formAdapter) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
await formAdapter.reset(applied.before);
|
|
90
|
+
// Autosave has most likely stored the previewed change in the draft already, and it only
|
|
91
|
+
// saves a modified form: marking it modified makes it store the restored values too, which
|
|
92
|
+
// is also what refreshes a preview that renders from the saved draft.
|
|
93
|
+
formAdapter.setModified(autosave || applied.beforeModified);
|
|
94
|
+
};
|
|
95
|
+
const decideRequest = (pending, action, formData) => assistantRequest(apiBase, "/decide", {
|
|
96
|
+
action,
|
|
97
|
+
approvalId: pending.approvalId,
|
|
98
|
+
nonce: pending.nonce,
|
|
99
|
+
collection: bridge?.collectionSlug,
|
|
100
|
+
global: bridge?.globalSlug,
|
|
101
|
+
documentId: bridge?.documentId,
|
|
102
|
+
locale: bridge?.locale ?? "en",
|
|
103
|
+
formData,
|
|
104
|
+
});
|
|
105
|
+
/**
|
|
106
|
+
* Keeps a previewed change. The server checks the proposal against the form it was made for
|
|
107
|
+
* and logs it; the form already shows the change, so nothing is applied again.
|
|
108
|
+
*/
|
|
109
|
+
const keepPreview = async (pending, quiet = false) => {
|
|
110
|
+
const applied = pending.applied;
|
|
111
|
+
if (!applied) {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
setPendingApproval(null);
|
|
115
|
+
setConfirmUndo(false);
|
|
116
|
+
const result = await decideRequest(pending, "approve", applied.before).catch(() => null);
|
|
117
|
+
if (!result?.ok) {
|
|
118
|
+
await restore(applied).catch(() => undefined);
|
|
119
|
+
say(copy.keepFailed);
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
if (!quiet) {
|
|
123
|
+
say(autosave ? copy.keptAutosave : copy.kept);
|
|
124
|
+
}
|
|
125
|
+
return true;
|
|
126
|
+
};
|
|
127
|
+
const undoPreview = async (pending, force = false) => {
|
|
128
|
+
const applied = pending.applied;
|
|
129
|
+
if (!applied) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (editedSinceRef.current && !force) {
|
|
133
|
+
setConfirmUndo(true);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
setPendingApproval(null);
|
|
137
|
+
setConfirmUndo(false);
|
|
138
|
+
try {
|
|
139
|
+
await restore(applied);
|
|
140
|
+
say(copy.undone);
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
say(copy.applyFailed);
|
|
144
|
+
}
|
|
145
|
+
void decideRequest(pending, "reject", undefined).catch(() => undefined);
|
|
146
|
+
};
|
|
73
147
|
const send = async (text) => {
|
|
74
148
|
const trimmed = text.trim();
|
|
75
149
|
if (!trimmed || status === "streaming") {
|
|
76
150
|
return;
|
|
77
151
|
}
|
|
152
|
+
// Writing on is taken as keeping what is in the form; the next change builds on it.
|
|
153
|
+
if (pendingApproval?.applied && !(await keepPreview(pendingApproval, true))) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
78
156
|
stickToBottomRef.current = true;
|
|
79
157
|
setDraft("");
|
|
80
158
|
const userMessage = { id: crypto.randomUUID(), role: "user", text: trimmed };
|
|
@@ -95,6 +173,7 @@ export function EditorAssistantAside() {
|
|
|
95
173
|
locale: locale.code,
|
|
96
174
|
uiLanguage: i18n.language,
|
|
97
175
|
formData: formAdapter?.getData(),
|
|
176
|
+
selection: selection?.path,
|
|
98
177
|
}, controller.signal);
|
|
99
178
|
if (controller.signal.aborted) {
|
|
100
179
|
setStatus("aborted");
|
|
@@ -114,11 +193,8 @@ export function EditorAssistantAside() {
|
|
|
114
193
|
return;
|
|
115
194
|
}
|
|
116
195
|
setStatus("idle");
|
|
117
|
-
if (result.data.language === "sv" || result.data.language === "en") {
|
|
118
|
-
setChatLanguage(result.data.language);
|
|
119
|
-
}
|
|
120
196
|
if (result.data.pendingApproval) {
|
|
121
|
-
setPendingApproval(result.data.pendingApproval);
|
|
197
|
+
setPendingApproval(await applyPreview(result.data.pendingApproval));
|
|
122
198
|
}
|
|
123
199
|
const navigate = result.data.navigate;
|
|
124
200
|
setMessages((current) => [
|
|
@@ -145,6 +221,11 @@ export function EditorAssistantAside() {
|
|
|
145
221
|
if (!pendingApproval || status === "streaming") {
|
|
146
222
|
return;
|
|
147
223
|
}
|
|
224
|
+
if (pendingApproval.applied) {
|
|
225
|
+
stickToBottomRef.current = true;
|
|
226
|
+
await (action === "approve" ? keepPreview(pendingApproval) : undoPreview(pendingApproval));
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
148
229
|
stickToBottomRef.current = true;
|
|
149
230
|
if (action === "approve" && pendingApproval.capability === "form.propose") {
|
|
150
231
|
if (!formAdapter || !bridge || !canApplyForm(bridge)) {
|
|
@@ -254,41 +335,55 @@ export function EditorAssistantAside() {
|
|
|
254
335
|
}
|
|
255
336
|
};
|
|
256
337
|
if (collapsed) {
|
|
257
|
-
return (_jsx("aside", { className: "ea-aside ea-aside--collapsed", "aria-label":
|
|
338
|
+
return (_jsx("aside", { className: "ea-aside ea-aside--collapsed", "aria-label": copy.assistant, children: _jsxs("button", { className: "ea-aside__rail", type: "button", onClick: () => setCollapsed(false), children: [_jsx(ChevronIcon, { direction: "left" }), _jsx("span", { children: copy.assistant })] }) }));
|
|
258
339
|
}
|
|
259
|
-
return (_jsxs("aside", { className: "ea-aside", "aria-label": "
|
|
340
|
+
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: () => setCollapsed(true), 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: () => {
|
|
260
341
|
router.push(candidate.href);
|
|
261
|
-
}, children: candidate.label }, candidate.href))) })) : null] }, message.id))), status === "streaming" ? (_jsxs("div", { className: "ea-msg ea-msg--working", children: [
|
|
342
|
+
}, size: "small", children: candidate.label }, candidate.href))) })) : null] }, message.id))), status === "streaming" ? (_jsxs("div", { className: "ea-msg ea-msg--working", children: [_jsxs("span", { className: "ea-dots", "aria-hidden": "true", children: [_jsx("span", {}), _jsx("span", {}), _jsx("span", {})] }), _jsx("span", { children: copy.working })] })) : null] }), pendingApproval ? (_jsxs("div", { className: "ea-approval", children: [_jsx("h3", { children: pendingApproval.title }), pendingApproval.applied ? (_jsx("p", { className: "ea-approval__hint", children: autosave ? copy.previewHintAutosave : copy.previewHint })) : null, _jsx("ul", { className: "ea-approval__diff", children: pendingApproval.summary.split("\n").map((line, index) => line.startsWith(" ") ? (
|
|
262
343
|
// biome-ignore lint/suspicious/noArrayIndexKey: lines are static for one approval
|
|
263
344
|
_jsx("li", { className: "ea-approval__sub", children: line.trim() }, index)) : (
|
|
264
345
|
// biome-ignore lint/suspicious/noArrayIndexKey: lines are static for one approval
|
|
265
|
-
_jsx("li", { children: line }, index))) }), _jsxs("div", { className: "ea-actions", children: [_jsx("
|
|
346
|
+
_jsx("li", { children: line }, index))) }), confirmUndo ? (_jsxs(_Fragment, { children: [_jsx("p", { className: "ea-approval__warn", children: copy.undoLosesEdits }), _jsxs("div", { className: "ea-actions", children: [_jsx(Button, { buttonStyle: "primary", margin: false, onClick: () => void undoPreview(pendingApproval, true), size: "medium", children: copy.undoAnyway }), _jsx(Button, { buttonStyle: "secondary", margin: false, onClick: () => setConfirmUndo(false), size: "medium", children: copy.cancel })] })] })) : (_jsxs("div", { className: "ea-actions", children: [_jsx(Button, { buttonStyle: "primary", margin: false, onClick: () => void decide("approve"), size: "medium", children: pendingApproval.applied ? copy.keep : copy.approve }), _jsx(Button, { buttonStyle: "secondary", margin: false, onClick: () => void decide("reject"), size: "medium", children: pendingApproval.applied ? copy.undo : copy.reject })] }))] })) : null, status !== "idle" && status !== "streaming" ? (_jsx("div", { className: `ea-status ea-status--${status}`, children: statusText(status, copy) })) : null, _jsxs("form", { className: "ea-composer", onSubmit: onSubmit, children: [visibleShortcuts.length > 0 ? (_jsx("div", { className: "ea-shortcuts", children: visibleShortcuts.map((shortcut) => (_jsx(Button, { buttonStyle: "pill", disabled: status === "streaming", margin: false, onClick: () => {
|
|
266
347
|
void send(shortcut.prompt);
|
|
267
|
-
}, children: shortcut.label }, shortcut.id))) })) : null, _jsxs("div", { className: "ea-
|
|
348
|
+
}, 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" ? (
|
|
349
|
+
// While the assistant works, Send becomes Stop, where the editor's eye already is.
|
|
350
|
+
_jsx(Button, { "aria-label": copy.stop, buttonStyle: "secondary", className: "ea-composer__stop", icon: _jsx("span", { className: "ea-stop-icon", "aria-hidden": "true" }), iconPosition: "left", margin: false, onClick: () => stop(), size: "medium", type: "button", children: copy.stop })) : (_jsx(Button, { buttonStyle: "primary", disabled: draft.trim() === "", margin: false, size: "medium", type: "submit", children: copy.send })) })] })] })] })] }));
|
|
351
|
+
}
|
|
352
|
+
/** "Page · Home", with the locale when the project has more than one. */
|
|
353
|
+
function contextLine(bridge, config) {
|
|
354
|
+
const collection = config.collections?.find((entry) => entry.slug === bridge.collectionSlug);
|
|
355
|
+
const global = config.globals?.find((entry) => entry.slug === bridge.globalSlug);
|
|
356
|
+
const kind = textLabel(collection?.labels?.singular) ??
|
|
357
|
+
textLabel(global?.label) ??
|
|
358
|
+
bridge.collectionSlug ??
|
|
359
|
+
bridge.globalSlug;
|
|
360
|
+
const parts = [kind, bridge.title];
|
|
361
|
+
if (config.localization) {
|
|
362
|
+
parts.push(bridge.locale.toUpperCase());
|
|
363
|
+
}
|
|
364
|
+
return parts.filter(Boolean).join(" · ");
|
|
268
365
|
}
|
|
269
|
-
function introText(view, bridge, collections, config) {
|
|
366
|
+
function introText(view, bridge, collections, config, copy) {
|
|
270
367
|
switch (view.kind) {
|
|
271
368
|
case "document":
|
|
272
|
-
case "global":
|
|
273
|
-
|
|
274
|
-
return "I can help you find content, open a page, or start a new draft. What do you need?";
|
|
275
|
-
}
|
|
276
|
-
const name = bridge.title ? `"${bridge.title}"` : "this document";
|
|
277
|
-
return `I can explain the fields on ${name}, suggest edits, or fill in empty fields. What do you need?`;
|
|
278
|
-
}
|
|
369
|
+
case "global":
|
|
370
|
+
return bridge ? copy.intro.document(bridge.title) : copy.intro.general;
|
|
279
371
|
case "list": {
|
|
280
372
|
if (!collections.includes(view.collection)) {
|
|
281
|
-
return
|
|
373
|
+
return copy.intro.notAllowed;
|
|
282
374
|
}
|
|
283
375
|
const collection = config.collections?.find((entry) => entry.slug === view.collection);
|
|
284
376
|
const plural = textLabel(collection?.labels?.plural) ?? view.collection;
|
|
285
|
-
const singular = textLabel(collection?.labels?.singular)?.toLowerCase() ??
|
|
286
|
-
return
|
|
377
|
+
const singular = textLabel(collection?.labels?.singular)?.toLowerCase() ?? copy.intro.documentFallback;
|
|
378
|
+
return copy.intro.list(plural, singular);
|
|
287
379
|
}
|
|
288
380
|
default:
|
|
289
|
-
return
|
|
381
|
+
return copy.intro.general;
|
|
290
382
|
}
|
|
291
383
|
}
|
|
384
|
+
function statusText(status, copy) {
|
|
385
|
+
return status in copy.status ? copy.status[status] : "";
|
|
386
|
+
}
|
|
292
387
|
function textLabel(value) {
|
|
293
388
|
return typeof value === "string" && value.trim() ? value : undefined;
|
|
294
389
|
}
|
|
@@ -306,6 +401,19 @@ function shortcutMatches(shortcut, view) {
|
|
|
306
401
|
: undefined;
|
|
307
402
|
return entity === undefined || shortcut.entities.includes(entity);
|
|
308
403
|
}
|
|
404
|
+
/** Autosave writes the previewed change to the draft, so the hint must not say nothing is saved. */
|
|
405
|
+
function autosaves(getEntityConfig, bridge) {
|
|
406
|
+
if (!bridge) {
|
|
407
|
+
return false;
|
|
408
|
+
}
|
|
409
|
+
const entity = bridge.collectionSlug
|
|
410
|
+
? getEntityConfig({ collectionSlug: bridge.collectionSlug })
|
|
411
|
+
: bridge.globalSlug
|
|
412
|
+
? getEntityConfig({ globalSlug: bridge.globalSlug })
|
|
413
|
+
: undefined;
|
|
414
|
+
const drafts = entity?.versions?.drafts;
|
|
415
|
+
return typeof drafts === "object" && Boolean(drafts.autosave);
|
|
416
|
+
}
|
|
309
417
|
function canApplyForm(bridge) {
|
|
310
418
|
if (bridge.disabled || bridge.locked) {
|
|
311
419
|
return false;
|
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
import { useDocumentForm, useDocumentInfo, useFormModified, useLocale } from "@payloadcms/ui";
|
|
3
3
|
import { useEffect, useRef } from "react";
|
|
4
4
|
import { useAssistantState } from "./assistant-context.js";
|
|
5
|
+
import { selectionFromElement, selectionFromPath, VISUAL_EDITING_SELECT_EVENT, } from "./selection-dom.js";
|
|
6
|
+
/** Collapsed rows render their labels only once visual editing has opened them. */
|
|
7
|
+
const RELABEL_MS = [150, 500, 1200];
|
|
5
8
|
export function EditorAssistantDocumentBridge() {
|
|
6
|
-
const { setBridge, setFormAdapter } = useAssistantState();
|
|
9
|
+
const { setBridge, setFormAdapter, setSelection } = useAssistantState();
|
|
7
10
|
const documentInfo = useDocumentInfo();
|
|
8
11
|
const locale = useLocale();
|
|
9
12
|
const modified = useFormModified();
|
|
@@ -22,6 +25,54 @@ export function EditorAssistantDocumentBridge() {
|
|
|
22
25
|
setFormAdapter(null);
|
|
23
26
|
};
|
|
24
27
|
}, [setFormAdapter]);
|
|
28
|
+
// "This" in the editor's message is what they last clicked or focused: a field, a block or a row,
|
|
29
|
+
// in the form or (through visual editing) in the preview. Clicks elsewhere in Admin clear it;
|
|
30
|
+
// clicks in the assistant itself keep it, so the editor can click, then type.
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
let relabel = [];
|
|
33
|
+
const cancelRelabel = () => {
|
|
34
|
+
for (const timer of relabel) {
|
|
35
|
+
clearTimeout(timer);
|
|
36
|
+
}
|
|
37
|
+
relabel = [];
|
|
38
|
+
};
|
|
39
|
+
const onInteract = (event) => {
|
|
40
|
+
const target = event.target;
|
|
41
|
+
if (!(target instanceof Element) || target instanceof HTMLIFrameElement) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (target.closest(".ea-aside")) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
cancelRelabel();
|
|
48
|
+
setSelection(selectionFromElement(target) ?? null);
|
|
49
|
+
};
|
|
50
|
+
const onPreviewSelect = (event) => {
|
|
51
|
+
const path = event.detail?.path;
|
|
52
|
+
if (path === null) {
|
|
53
|
+
// The editor cleared the selection in the preview.
|
|
54
|
+
cancelRelabel();
|
|
55
|
+
setSelection(null);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (typeof path !== "string" || !path) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
cancelRelabel();
|
|
62
|
+
setSelection(selectionFromPath(path));
|
|
63
|
+
relabel = RELABEL_MS.map((ms) => setTimeout(() => setSelection(selectionFromPath(path)), ms));
|
|
64
|
+
};
|
|
65
|
+
document.addEventListener("pointerdown", onInteract, true);
|
|
66
|
+
document.addEventListener("focusin", onInteract);
|
|
67
|
+
window.addEventListener(VISUAL_EDITING_SELECT_EVENT, onPreviewSelect);
|
|
68
|
+
return () => {
|
|
69
|
+
cancelRelabel();
|
|
70
|
+
document.removeEventListener("pointerdown", onInteract, true);
|
|
71
|
+
document.removeEventListener("focusin", onInteract);
|
|
72
|
+
window.removeEventListener(VISUAL_EDITING_SELECT_EVENT, onPreviewSelect);
|
|
73
|
+
setSelection(null);
|
|
74
|
+
};
|
|
75
|
+
}, [setSelection]);
|
|
25
76
|
useEffect(() => {
|
|
26
77
|
const titleValue = documentInfo.data && "title" in documentInfo.data ? documentInfo.data.title : undefined;
|
|
27
78
|
const locked = Boolean(documentInfo.documentIsLocked) ||
|
|
@@ -3,6 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
3
3
|
import { useAuth } from "@payloadcms/ui";
|
|
4
4
|
import "../styles/assistant.css";
|
|
5
5
|
import { AssistantStateProvider, useAssistantState } from "./assistant-context.js";
|
|
6
|
+
import { ChangedFieldsHighlight } from "./ChangedFieldsHighlight.js";
|
|
6
7
|
import { EditorAssistantAside } from "./EditorAssistantAside.js";
|
|
7
8
|
export function EditorAssistantProvider({ children, shortcuts = [], collections = [], }) {
|
|
8
9
|
const { user } = useAuth();
|
|
@@ -10,5 +11,5 @@ export function EditorAssistantProvider({ children, shortcuts = [], collections
|
|
|
10
11
|
}
|
|
11
12
|
function EditorAssistantShell({ children }) {
|
|
12
13
|
const { collapsed } = useAssistantState();
|
|
13
|
-
return (_jsxs("div", { className: collapsed ? "ea-root ea-root--collapsed" : "ea-root", children: [_jsx("div", { className: "ea-main", children: children }), _jsx(EditorAssistantAside, {})] }));
|
|
14
|
+
return (_jsxs("div", { className: collapsed ? "ea-root ea-root--collapsed" : "ea-root", children: [_jsx("div", { className: "ea-main", children: children }), _jsx(EditorAssistantAside, {}), _jsx(ChangedFieldsHighlight, {})] }));
|
|
14
15
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type ReactNode } from "react";
|
|
2
2
|
import type { AssistantShortcut } from "../types.ts";
|
|
3
|
+
import type { DomSelection } from "./selection-dom.ts";
|
|
3
4
|
export type AssistantChromeStatus = "idle" | "unavailable" | "unauthorized" | "rate_limited" | "timeout" | "provider_error" | "tool_denied" | "stale_context" | "aborted" | "streaming";
|
|
4
5
|
export type DocumentBridgeSnapshot = {
|
|
5
6
|
collectionSlug?: string;
|
|
@@ -24,6 +25,16 @@ export type PendingApproval = {
|
|
|
24
25
|
approvalId: string;
|
|
25
26
|
nonce: string;
|
|
26
27
|
capability: "form.propose" | "draft.create";
|
|
28
|
+
/** The form with the change applied; Admin shows it before the editor keeps it. */
|
|
29
|
+
preview?: {
|
|
30
|
+
data: Record<string, unknown>;
|
|
31
|
+
changedPaths: string[];
|
|
32
|
+
};
|
|
33
|
+
/** Set once the preview is in the form: what to restore on undo. */
|
|
34
|
+
applied?: {
|
|
35
|
+
before: Record<string, unknown>;
|
|
36
|
+
beforeModified: boolean;
|
|
37
|
+
};
|
|
27
38
|
};
|
|
28
39
|
export type AssistantMessage = {
|
|
29
40
|
id: string;
|
|
@@ -45,11 +56,14 @@ type AssistantContextValue = {
|
|
|
45
56
|
bridge: DocumentBridgeSnapshot | null;
|
|
46
57
|
formAdapter: FormAdapter | null;
|
|
47
58
|
pendingApproval: PendingApproval | null;
|
|
59
|
+
/** What the editor last clicked or focused in the open form. */
|
|
60
|
+
selection: DomSelection | null;
|
|
48
61
|
setOpen: (open: boolean) => void;
|
|
49
62
|
setCollapsed: (collapsed: boolean) => void;
|
|
50
63
|
setBridge: (bridge: DocumentBridgeSnapshot | null) => void;
|
|
51
64
|
setFormAdapter: (adapter: FormAdapter | null) => void;
|
|
52
65
|
setPendingApproval: (pending: PendingApproval | null) => void;
|
|
66
|
+
setSelection: (selection: DomSelection | null) => void;
|
|
53
67
|
setStatus: (status: AssistantChromeStatus) => void;
|
|
54
68
|
setMessages: (updater: (current: AssistantMessage[]) => AssistantMessage[]) => void;
|
|
55
69
|
resetConversation: () => void;
|