@simmalugnt-se/payload-editor-assistant 0.9.1 → 0.10.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 +12 -0
- package/README.md +5 -0
- package/dist/domain/propose.js +17 -4
- package/dist/form/diff.js +15 -2
- package/dist/form/relation-shape.d.ts +9 -0
- package/dist/form/relation-shape.js +74 -0
- package/dist/runtime/context.d.ts +4 -0
- package/dist/runtime/context.js +9 -1
- package/dist/runtime/system-prompt.js +2 -2
- package/dist/schema/fields.d.ts +3 -0
- package/dist/schema/fields.js +2 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.10.0
|
|
4
|
+
|
|
5
|
+
- Picks several images for upload fields with `hasMany`, such as a gallery: it adds after the
|
|
6
|
+
images already there, removes one when asked, and keeps to the field's `minRows` and `maxRows`,
|
|
7
|
+
saying so when the editor asks for more than fit. The quoted schema marks such fields with
|
|
8
|
+
`hasMany` and their limits.
|
|
9
|
+
- A proposal is refused before it reaches the form when an upload or relationship field it changes
|
|
10
|
+
holds the wrong shape: a list in a field for one, a repeated id, too many or too few, or a field
|
|
11
|
+
pointing at several collections.
|
|
12
|
+
- The change list names every image in a list ("img-1.png, img-3.png") instead of showing ids, and
|
|
13
|
+
shows an empty list as empty.
|
|
14
|
+
|
|
3
15
|
## 0.9.1
|
|
4
16
|
|
|
5
17
|
- The assistant no longer shows on Payload's sign-in pages (login, logout, forgot and reset
|
package/README.md
CHANGED
|
@@ -117,6 +117,11 @@ The suggested change names the image ("Image: empty → office-2024.jpg"), and t
|
|
|
117
117
|
Preview show it. Images with alt texts are easier to find. Fields with several images (`hasMany`)
|
|
118
118
|
are not picked yet.
|
|
119
119
|
|
|
120
|
+
Galleries and other upload fields with `hasMany` work the same way: the assistant adds images after
|
|
121
|
+
the ones already there, removes one when asked, and stays within the field's `minRows` and
|
|
122
|
+
`maxRows`, saying so when the editor asks for more than fit. Fields that point at several
|
|
123
|
+
collections are not filled.
|
|
124
|
+
|
|
120
125
|
## Rich text
|
|
121
126
|
|
|
122
127
|
With `@payloadcms/richtext-lexical` installed, the assistant reads Lexical rich text as Markdown,
|
package/dist/domain/propose.js
CHANGED
|
@@ -4,6 +4,7 @@ import { humanDiff, richTextLines } from "../form/diff.js";
|
|
|
4
4
|
import { hashFormRevision } from "../form/hash.js";
|
|
5
5
|
import { isFormOperation, normalizeOperations } from "../form/operations.js";
|
|
6
6
|
import { getAtPath, parseFieldPath } from "../form/path.js";
|
|
7
|
+
import { relationValueError } from "../form/relation-shape.js";
|
|
7
8
|
import { loadRichText, toBlocks } from "../form/rich-text.js";
|
|
8
9
|
import { finalizeCompiled, openRichText } from "../form/shape.js";
|
|
9
10
|
import { DEFAULT_APPROVAL_SLUG } from "../package-name.js";
|
|
@@ -55,6 +56,10 @@ export async function proposeChange(req, options, input) {
|
|
|
55
56
|
if (!finalized.ok) {
|
|
56
57
|
return { ok: false, error: "tool_denied", message: finalized.error };
|
|
57
58
|
}
|
|
59
|
+
const relationError = relationValueError(fields, finalized.data, changedPaths(typedOperations));
|
|
60
|
+
if (relationError) {
|
|
61
|
+
return { ok: false, error: "tool_denied", message: relationError };
|
|
62
|
+
}
|
|
58
63
|
if (options.validateProposal) {
|
|
59
64
|
const extra = await options.validateProposal({
|
|
60
65
|
operations: typedOperations,
|
|
@@ -72,11 +77,9 @@ export async function proposeChange(req, options, input) {
|
|
|
72
77
|
const canonicalHash = await hashFormRevision(typedOperations);
|
|
73
78
|
const nonce = crypto.randomUUID();
|
|
74
79
|
const expiresAt = new Date(Date.now() + TTL_MS).toISOString();
|
|
75
|
-
// Ids set by this change, before and after, get names in the change list.
|
|
80
|
+
// Ids set by this change, before and after, get names in the change list; a gallery has several.
|
|
76
81
|
const setIds = new Set(typedOperations.flatMap((operation) => operation.op === "set"
|
|
77
|
-
? [operation.value, getAtPath(current, parseFieldPath(operation.path))]
|
|
78
|
-
.filter((value) => typeof value === "string" || typeof value === "number")
|
|
79
|
-
.map(String)
|
|
82
|
+
? [operation.value, getAtPath(current, parseFieldPath(operation.path))].flatMap(idsIn)
|
|
80
83
|
: []));
|
|
81
84
|
const labels = setIds.size
|
|
82
85
|
? await labelRelations(req, fields, [finalized.data, current], setIds)
|
|
@@ -200,3 +203,13 @@ function storedBlocks(fields, path, current) {
|
|
|
200
203
|
const blocks = toBlocks(getAtPath(current, parseFieldPath(path)), field?.editor);
|
|
201
204
|
return Array.isArray(blocks) ? blocks : [];
|
|
202
205
|
}
|
|
206
|
+
/** The ids a set value holds: one, or a list as a `hasMany` field takes (ids or documents). */
|
|
207
|
+
function idsIn(value) {
|
|
208
|
+
if (typeof value === "string" || typeof value === "number") {
|
|
209
|
+
return [String(value)];
|
|
210
|
+
}
|
|
211
|
+
if (Array.isArray(value)) {
|
|
212
|
+
return value.flatMap((entry) => entry && typeof entry === "object" && "id" in entry ? idsIn(entry.id) : idsIn(entry));
|
|
213
|
+
}
|
|
214
|
+
return [];
|
|
215
|
+
}
|
package/dist/form/diff.js
CHANGED
|
@@ -43,9 +43,21 @@ const FIELD_PREVIEW = 300;
|
|
|
43
43
|
*/
|
|
44
44
|
export function humanDiff(current, operations, language = "en", labels) {
|
|
45
45
|
const copy = COPY[language];
|
|
46
|
+
const nameOf = (value) => {
|
|
47
|
+
const id = typeof value === "string" || typeof value === "number"
|
|
48
|
+
? String(value)
|
|
49
|
+
: value && typeof value === "object" && "id" in value
|
|
50
|
+
? String(value.id)
|
|
51
|
+
: undefined;
|
|
52
|
+
return id !== undefined ? labels?.get(id) : undefined;
|
|
53
|
+
};
|
|
54
|
+
// A chosen image reads as its name; a list of them (a gallery) as their names in order.
|
|
46
55
|
const named = (value) => {
|
|
47
|
-
|
|
48
|
-
|
|
56
|
+
if (Array.isArray(value) && value.length > 0) {
|
|
57
|
+
const names = value.map(nameOf);
|
|
58
|
+
return names.every((name) => name !== undefined) ? names.join(", ") : value;
|
|
59
|
+
}
|
|
60
|
+
return nameOf(value) ?? value;
|
|
49
61
|
};
|
|
50
62
|
return operations.map((operation) => {
|
|
51
63
|
switch (operation.op) {
|
|
@@ -154,6 +166,7 @@ function isEmpty(value) {
|
|
|
154
166
|
return (value === undefined ||
|
|
155
167
|
value === null ||
|
|
156
168
|
value === "" ||
|
|
169
|
+
(Array.isArray(value) && value.length === 0) ||
|
|
157
170
|
(isLexical(value) && !lexicalPlaintext(value)));
|
|
158
171
|
}
|
|
159
172
|
function preview(value, copy, max = HEADER_PREVIEW) {
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type FieldNode } from "../schema/fields.ts";
|
|
2
|
+
/**
|
|
3
|
+
* Checks the upload and relationship fields a proposal changes (`paths`, as `changedPaths` gives
|
|
4
|
+
* them): one id for a single field; for a `hasMany` field a list of ids without repeats, within its
|
|
5
|
+
* `minRows` and `maxRows` (an empty list is allowed). Fields pointing at several collections are
|
|
6
|
+
* refused. Untouched fields are not checked, so a value the editor saved cannot block a proposal.
|
|
7
|
+
* Whether the ids exist and are readable is `verifyDocumentRelations`' job.
|
|
8
|
+
*/
|
|
9
|
+
export declare function relationValueError(fields: FieldNode[], data: Record<string, unknown>, paths: string[]): string | null;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { fieldAtPath } from "../schema/fields.js";
|
|
2
|
+
import { getAtPath, parseFieldPath } from "./path.js";
|
|
3
|
+
const ID = /^[\w-]+$/;
|
|
4
|
+
/**
|
|
5
|
+
* Checks the upload and relationship fields a proposal changes (`paths`, as `changedPaths` gives
|
|
6
|
+
* them): one id for a single field; for a `hasMany` field a list of ids without repeats, within its
|
|
7
|
+
* `minRows` and `maxRows` (an empty list is allowed). Fields pointing at several collections are
|
|
8
|
+
* refused. Untouched fields are not checked, so a value the editor saved cannot block a proposal.
|
|
9
|
+
* Whether the ids exist and are readable is `verifyDocumentRelations`' job.
|
|
10
|
+
*/
|
|
11
|
+
export function relationValueError(fields, data, paths) {
|
|
12
|
+
const checked = new Set();
|
|
13
|
+
for (const changed of paths) {
|
|
14
|
+
const segments = changed.split(".");
|
|
15
|
+
// A change inside a list of ids (`images.2`) is checked on the whole field.
|
|
16
|
+
while (segments.length > 1 && /^\d+$/.test(segments.at(-1) ?? "")) {
|
|
17
|
+
segments.pop();
|
|
18
|
+
}
|
|
19
|
+
const path = segments.join(".");
|
|
20
|
+
if (checked.has(path)) {
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
checked.add(path);
|
|
24
|
+
const field = fieldAtPath(fields, path, data);
|
|
25
|
+
if (!field || (field.type !== "upload" && field.type !== "relationship")) {
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
const error = fieldError(field, path, getAtPath(data, parseFieldPath(path)));
|
|
29
|
+
if (error) {
|
|
30
|
+
return error;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
function fieldError(field, path, value) {
|
|
36
|
+
const noun = field.type === "upload" ? "image" : "document";
|
|
37
|
+
if ((field.relationTo?.length ?? 0) > 1) {
|
|
38
|
+
return `"${path}" points at several collections; the assistant cannot choose for it yet.`;
|
|
39
|
+
}
|
|
40
|
+
if (value === null || value === undefined) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
if (!field.hasMany) {
|
|
44
|
+
return Array.isArray(value) ? `"${path}" takes one ${noun}: set it to a single id.` : null;
|
|
45
|
+
}
|
|
46
|
+
const ids = Array.isArray(value) ? value.map(idOf) : undefined;
|
|
47
|
+
if (!ids || ids.some((id) => id === undefined)) {
|
|
48
|
+
return `"${path}" takes a list of ${noun} ids, in the order they show.`;
|
|
49
|
+
}
|
|
50
|
+
if (new Set(ids).size !== ids.length) {
|
|
51
|
+
return `"${path}" has the same ${noun} more than once.`;
|
|
52
|
+
}
|
|
53
|
+
if (field.maxRows !== undefined && ids.length > field.maxRows) {
|
|
54
|
+
return `"${path}" takes at most ${field.maxRows} ${noun}s; the proposal has ${ids.length}.`;
|
|
55
|
+
}
|
|
56
|
+
if (field.minRows !== undefined && ids.length > 0 && ids.length < field.minRows) {
|
|
57
|
+
return `"${path}" takes at least ${field.minRows} ${noun}s; the proposal has ${ids.length}.`;
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
/** An id, or a document as Admin may hold a chosen one. */
|
|
62
|
+
function idOf(entry) {
|
|
63
|
+
if (typeof entry === "number") {
|
|
64
|
+
return String(entry);
|
|
65
|
+
}
|
|
66
|
+
if (typeof entry === "string") {
|
|
67
|
+
return ID.test(entry) ? entry : undefined;
|
|
68
|
+
}
|
|
69
|
+
if (entry && typeof entry === "object") {
|
|
70
|
+
const record = entry;
|
|
71
|
+
return idOf(record.id ?? record.value);
|
|
72
|
+
}
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
@@ -10,6 +10,10 @@ type CompactField = {
|
|
|
10
10
|
type: string;
|
|
11
11
|
required?: boolean;
|
|
12
12
|
relationTo?: string[];
|
|
13
|
+
/** Upload and relationship fields that take several, and how many. */
|
|
14
|
+
hasMany?: true;
|
|
15
|
+
minRows?: number;
|
|
16
|
+
maxRows?: number;
|
|
13
17
|
};
|
|
14
18
|
export type TurnContext = {
|
|
15
19
|
allowlist: Array<{
|
package/dist/runtime/context.js
CHANGED
|
@@ -99,11 +99,19 @@ export function compactEntity(entity) {
|
|
|
99
99
|
};
|
|
100
100
|
}
|
|
101
101
|
function compactField(field) {
|
|
102
|
+
const relation = field.type === "upload" || field.type === "relationship";
|
|
102
103
|
return {
|
|
103
104
|
name: field.name,
|
|
104
105
|
type: field.type,
|
|
105
106
|
required: field.required || undefined,
|
|
106
|
-
relationTo:
|
|
107
|
+
relationTo: relation ? field.relationTo : undefined,
|
|
108
|
+
...(relation && field.hasMany
|
|
109
|
+
? {
|
|
110
|
+
hasMany: true,
|
|
111
|
+
...(field.minRows !== undefined ? { minRows: field.minRows } : {}),
|
|
112
|
+
...(field.maxRows !== undefined ? { maxRows: field.maxRows } : {}),
|
|
113
|
+
}
|
|
114
|
+
: {}),
|
|
107
115
|
};
|
|
108
116
|
}
|
|
109
117
|
function translationFor(req, bridge, fields, form, otherLanguages) {
|
|
@@ -36,7 +36,7 @@ function openDocumentLine(bridge, turnContext) {
|
|
|
36
36
|
}
|
|
37
37
|
return `${viewLine(turnContext)}No document is open. You can search, navigate, or create a draft. Form edits need an open document.`;
|
|
38
38
|
}
|
|
39
|
-
/** Where the assistant may search images and look at them, it picks
|
|
39
|
+
/** Where the assistant may search images and look at them, it picks them for upload fields. */
|
|
40
40
|
function pickingLine(turnContext) {
|
|
41
41
|
const libraries = turnContext?.allowlist.filter((entity) => entity.kind === "collection" &&
|
|
42
42
|
entity.capabilities.includes("content.find") &&
|
|
@@ -45,7 +45,7 @@ function pickingLine(turnContext) {
|
|
|
45
45
|
return "";
|
|
46
46
|
}
|
|
47
47
|
const names = libraries.map((entity) => entity.slug).join(", ");
|
|
48
|
-
return `To pick an image for an upload field (a share image, a block image), search ${names} with content.find using words from the request or the page, then look at the likely candidates with media.view and their ids before proposing one. If the search finds nothing useful, list recent images (content.find without a query) and look at those. Propose only an image you have looked at, by its id. If none fits, say so and propose nothing. A share image (delningsbild, social image) is usually the upload field among the page's meta or SEO fields; prefer a landscape image at least 1200px wide for it.`;
|
|
48
|
+
return `To pick an image for an upload field (a share image, a block image), search ${names} with content.find using words from the request or the page, then look at the likely candidates with media.view and their ids before proposing one. If the search finds nothing useful, list recent images (content.find without a query) and look at those. Propose only an image you have looked at, by its id. If none fits, say so and propose nothing. A share image (delningsbild, social image) is usually the upload field among the page's meta or SEO fields; prefer a landscape image at least 1200px wide for it. An upload field marked hasMany (a gallery) takes several: set the whole field to the list of ids in the order they show. To add images, keep the images already there and add the new ones after them; to remove one, set the list without it; replace them all only when asked. Stay within its maxRows and minRows: if the editor asks for more than fit, say how many it takes and propose no more than that. Look at every image you add.`;
|
|
49
49
|
}
|
|
50
50
|
/** Without seeing the image, a model writes alt texts from the filename; say when it can look. */
|
|
51
51
|
function imageLine(bridge, turnContext) {
|
package/dist/schema/fields.d.ts
CHANGED
package/dist/schema/fields.js
CHANGED
|
@@ -55,6 +55,8 @@ function walkField(field, parentPath, allowlist, denyFields) {
|
|
|
55
55
|
required: field.required,
|
|
56
56
|
localized: field.localized,
|
|
57
57
|
hasMany: field.hasMany,
|
|
58
|
+
...(field.hasMany && typeof field.minRows === "number" ? { minRows: field.minRows } : {}),
|
|
59
|
+
...(field.hasMany && typeof field.maxRows === "number" ? { maxRows: field.maxRows } : {}),
|
|
58
60
|
relationTo: normalizeRelationTo(field.relationTo),
|
|
59
61
|
options: optionValues(field.options),
|
|
60
62
|
label: asLabel(field.label) ?? field.name,
|