@simmalugnt-se/payload-editor-assistant 0.6.0 → 0.8.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 +25 -1
- package/dist/domain/content.js +43 -1
- package/dist/domain/decide.js +10 -8
- package/dist/domain/media.d.ts +43 -3
- package/dist/domain/media.js +82 -5
- package/dist/domain/propose.js +67 -20
- package/dist/domain/relations.d.ts +11 -1
- package/dist/domain/relations.js +39 -2
- package/dist/form/diff.d.ts +10 -1
- package/dist/form/diff.js +44 -2
- package/dist/form/rich-text.d.ts +72 -0
- package/dist/form/rich-text.js +254 -0
- package/dist/form/shape.d.ts +12 -3
- package/dist/form/shape.js +92 -13
- package/dist/runtime/agent.js +4 -1
- package/dist/runtime/context.d.ts +14 -9
- package/dist/runtime/context.js +12 -9
- package/dist/runtime/system-prompt.js +13 -0
- package/dist/runtime/tools.d.ts +15 -9
- package/dist/runtime/tools.js +50 -14
- package/dist/schema/fields.d.ts +7 -0
- package/dist/schema/fields.js +26 -0
- package/dist/schema/project.js +4 -0
- package/package.json +8 -1
package/dist/form/diff.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { isLexical, lexicalPlaintext } from "./lexical.js";
|
|
2
2
|
import { getAtPath, parseFieldPath } from "./path.js";
|
|
3
|
+
import { richTextDiff } from "./rich-text.js";
|
|
3
4
|
const COPY = {
|
|
4
5
|
en: {
|
|
5
6
|
empty: "empty",
|
|
@@ -12,6 +13,9 @@ const COPY = {
|
|
|
12
13
|
removeBlock: (n, field) => `Remove block ${n} from ${field}`,
|
|
13
14
|
moveBlock: (from, to) => `Move block ${from} to ${to}`,
|
|
14
15
|
replaceBlock: (n, block) => `Replace block ${n} with ${block}`,
|
|
16
|
+
part: (field, n) => `${field}, part ${n}`,
|
|
17
|
+
addPart: (field, n) => `${field}, new part ${n}`,
|
|
18
|
+
removePart: (field, n) => `${field}, part ${n} removed`,
|
|
15
19
|
unknown: "Unknown change",
|
|
16
20
|
},
|
|
17
21
|
sv: {
|
|
@@ -25,20 +29,31 @@ const COPY = {
|
|
|
25
29
|
removeBlock: (n, field) => `Ta bort block ${n} från ${field}`,
|
|
26
30
|
moveBlock: (from, to) => `Flytta block ${from} till ${to}`,
|
|
27
31
|
replaceBlock: (n, block) => `Ersätt block ${n} med ${block}`,
|
|
32
|
+
part: (field, n) => `${field}, del ${n}`,
|
|
33
|
+
addPart: (field, n) => `${field}, ny del ${n}`,
|
|
34
|
+
removePart: (field, n) => `${field}, del ${n} tas bort`,
|
|
28
35
|
unknown: "Okänd ändring",
|
|
29
36
|
},
|
|
30
37
|
};
|
|
31
38
|
const HEADER_PREVIEW = 80;
|
|
32
39
|
const FIELD_PREVIEW = 300;
|
|
33
|
-
|
|
40
|
+
/**
|
|
41
|
+
* `labels` names related documents by id (see `labelRelations`), so a chosen image reads as its
|
|
42
|
+
* filename or alt text instead of an id. Rich text is listed by `richTextLines` instead.
|
|
43
|
+
*/
|
|
44
|
+
export function humanDiff(current, operations, language = "en", labels) {
|
|
34
45
|
const copy = COPY[language];
|
|
46
|
+
const named = (value) => {
|
|
47
|
+
const id = typeof value === "string" || typeof value === "number" ? String(value) : undefined;
|
|
48
|
+
return id !== undefined && labels?.has(id) ? labels.get(id) : value;
|
|
49
|
+
};
|
|
35
50
|
return operations.map((operation) => {
|
|
36
51
|
switch (operation.op) {
|
|
37
52
|
case "set": {
|
|
38
53
|
const before = getAtPath(current, parseFieldPath(operation.path));
|
|
39
54
|
return {
|
|
40
55
|
path: operation.path,
|
|
41
|
-
text: `${label(operation.path)}: ${preview(before, copy)} → ${preview(operation.value, copy)}`,
|
|
56
|
+
text: `${label(operation.path)}: ${preview(named(before), copy)} → ${preview(named(operation.value), copy)}`,
|
|
42
57
|
};
|
|
43
58
|
}
|
|
44
59
|
case "unset":
|
|
@@ -85,6 +100,33 @@ export function humanDiff(current, operations, language = "en") {
|
|
|
85
100
|
}
|
|
86
101
|
});
|
|
87
102
|
}
|
|
103
|
+
/**
|
|
104
|
+
* The parts of a rich text field that changed: `before` as the model read it (Markdown blocks),
|
|
105
|
+
* `after` as compiled, where `{ keep: i }` is block `i` unchanged.
|
|
106
|
+
*/
|
|
107
|
+
export function richTextLines(path, before, after, language = "en") {
|
|
108
|
+
const copy = COPY[language];
|
|
109
|
+
const field = label(path);
|
|
110
|
+
const part = (block) => typeof block === "string"
|
|
111
|
+
? preview(block, copy, FIELD_PREVIEW)
|
|
112
|
+
: `[${block.type ?? "?"}]`;
|
|
113
|
+
return richTextDiff(before, after).map((change) => {
|
|
114
|
+
switch (change.kind) {
|
|
115
|
+
case "changed":
|
|
116
|
+
return {
|
|
117
|
+
path,
|
|
118
|
+
text: `${copy.part(field, change.index + 1)}: ${part(change.before)} → ${part(change.after)}`,
|
|
119
|
+
};
|
|
120
|
+
case "added":
|
|
121
|
+
return { path, text: `${copy.addPart(field, change.index + 1)}: ${part(change.after)}` };
|
|
122
|
+
default:
|
|
123
|
+
return {
|
|
124
|
+
path,
|
|
125
|
+
text: `${copy.removePart(field, change.index + 1)}: ${part(change.before)}`,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
}
|
|
88
130
|
/** Sub-lines are indented two spaces; the approval panel nests them under the header. */
|
|
89
131
|
export const DIFF_SUBLINE_INDENT = " ";
|
|
90
132
|
function withFields(header, value, copy) {
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rich text for the model: Lexical editor state becomes a list of Markdown blocks, one per top-level
|
|
3
|
+
* node, and back. Payload's own converters do the work, with each field's own editor config, so the
|
|
4
|
+
* model can only write what the field allows. `{ keep: i }` stands for stored block `i` as it is:
|
|
5
|
+
* the model sends it for every block it leaves alone, and gets it for what Markdown cannot say
|
|
6
|
+
* (uploads, relationships, custom blocks), which it may move or drop but never create or change.
|
|
7
|
+
* A kept block, or one sent back unchanged, reuses the stored node exactly, keeping alignment,
|
|
8
|
+
* indent and styles.
|
|
9
|
+
*
|
|
10
|
+
* `@payloadcms/richtext-lexical` is an optional peer: without it, rich text stays as it was (plain
|
|
11
|
+
* text in, paragraphs out). Call `loadRichText` before converting.
|
|
12
|
+
*/
|
|
13
|
+
export type KeptBlock = {
|
|
14
|
+
keep: number;
|
|
15
|
+
type?: string;
|
|
16
|
+
};
|
|
17
|
+
export type RichTextBlock = string | KeptBlock;
|
|
18
|
+
export type RichTextLink = {
|
|
19
|
+
collection: string;
|
|
20
|
+
id: string;
|
|
21
|
+
};
|
|
22
|
+
type LexicalNode = Record<string, unknown> & {
|
|
23
|
+
type?: unknown;
|
|
24
|
+
children?: unknown;
|
|
25
|
+
};
|
|
26
|
+
type LexicalState = {
|
|
27
|
+
root: Record<string, unknown> & {
|
|
28
|
+
children: LexicalNode[];
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
/** Loads Payload's converters once; false when `@payloadcms/richtext-lexical` is not installed. */
|
|
32
|
+
export declare function loadRichText(): Promise<boolean>;
|
|
33
|
+
export declare function isLexicalState(value: unknown): value is LexicalState;
|
|
34
|
+
export declare function isRichTextBlocks(value: unknown): value is RichTextBlock[];
|
|
35
|
+
/**
|
|
36
|
+
* The model's view of stored rich text, or null when it cannot be converted (no converters, no
|
|
37
|
+
* editor config). An empty field is `""`, so it reads as an empty text to fill.
|
|
38
|
+
*/
|
|
39
|
+
export declare function toBlocks(value: unknown, editor: unknown): RichTextBlock[] | "" | null;
|
|
40
|
+
/**
|
|
41
|
+
* Rich text from what the model sent: blocks (see `toBlocks`) or a Markdown string. `original` is
|
|
42
|
+
* the stored value the blocks were made from. `links` are the internal links in rebuilt blocks, to
|
|
43
|
+
* check against the editor's access; links in reused blocks were there already.
|
|
44
|
+
*/
|
|
45
|
+
export declare function fromBlocks(value: RichTextBlock[] | string, original: unknown, editor: unknown): {
|
|
46
|
+
ok: true;
|
|
47
|
+
state: LexicalState;
|
|
48
|
+
links: RichTextLink[];
|
|
49
|
+
} | {
|
|
50
|
+
ok: false;
|
|
51
|
+
error: string;
|
|
52
|
+
};
|
|
53
|
+
export type BlockChange = {
|
|
54
|
+
kind: "changed";
|
|
55
|
+
index: number;
|
|
56
|
+
before: RichTextBlock;
|
|
57
|
+
after: RichTextBlock;
|
|
58
|
+
} | {
|
|
59
|
+
kind: "added";
|
|
60
|
+
index: number;
|
|
61
|
+
after: RichTextBlock;
|
|
62
|
+
} | {
|
|
63
|
+
kind: "removed";
|
|
64
|
+
index: number;
|
|
65
|
+
before: RichTextBlock;
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* Which blocks changed between two block lists, by position in the new list for added and changed
|
|
69
|
+
* blocks and in the old list for removed ones. A removal next to an addition reads as a change.
|
|
70
|
+
*/
|
|
71
|
+
export declare function richTextDiff(before: unknown[], sent: unknown[]): BlockChange[];
|
|
72
|
+
export {};
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rich text for the model: Lexical editor state becomes a list of Markdown blocks, one per top-level
|
|
3
|
+
* node, and back. Payload's own converters do the work, with each field's own editor config, so the
|
|
4
|
+
* model can only write what the field allows. `{ keep: i }` stands for stored block `i` as it is:
|
|
5
|
+
* the model sends it for every block it leaves alone, and gets it for what Markdown cannot say
|
|
6
|
+
* (uploads, relationships, custom blocks), which it may move or drop but never create or change.
|
|
7
|
+
* A kept block, or one sent back unchanged, reuses the stored node exactly, keeping alignment,
|
|
8
|
+
* indent and styles.
|
|
9
|
+
*
|
|
10
|
+
* `@payloadcms/richtext-lexical` is an optional peer: without it, rich text stays as it was (plain
|
|
11
|
+
* text in, paragraphs out). Call `loadRichText` before converting.
|
|
12
|
+
*/
|
|
13
|
+
/** Top-level nodes whose Markdown round trip keeps them as they are. */
|
|
14
|
+
const MARKDOWN_NODES = new Set(["paragraph", "heading", "list", "quote", "horizontalrule"]);
|
|
15
|
+
/** Inline nodes Markdown can carry; a block holding anything else is kept whole. */
|
|
16
|
+
const MARKDOWN_INLINE = new Set(["text", "link", "autolink", "linebreak", "tab", "listitem"]);
|
|
17
|
+
/** How the model writes an internal link: `[text](doc:pages/12)`. */
|
|
18
|
+
const DOC_LINK = /^doc:([\w-]+)\/(.+)$/;
|
|
19
|
+
/**
|
|
20
|
+
* Payload's Markdown export drops unknown URL schemes, so internal links leave as this address and
|
|
21
|
+
* become `doc:` in the Markdown. Its import keeps `doc:` as written.
|
|
22
|
+
*/
|
|
23
|
+
const DOC_EXPORT = "https://doc.invalid/";
|
|
24
|
+
let converters;
|
|
25
|
+
/** Loads Payload's converters once; false when `@payloadcms/richtext-lexical` is not installed. */
|
|
26
|
+
export async function loadRichText() {
|
|
27
|
+
if (converters === undefined) {
|
|
28
|
+
try {
|
|
29
|
+
const lexical = await import("@payloadcms/richtext-lexical");
|
|
30
|
+
converters = {
|
|
31
|
+
toMarkdown: lexical.convertLexicalToMarkdown,
|
|
32
|
+
toLexical: lexical.convertMarkdownToLexical,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
converters = null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return converters !== null;
|
|
40
|
+
}
|
|
41
|
+
export function isLexicalState(value) {
|
|
42
|
+
const root = value?.root;
|
|
43
|
+
return Boolean(root && typeof root === "object" && Array.isArray(root.children));
|
|
44
|
+
}
|
|
45
|
+
export function isRichTextBlocks(value) {
|
|
46
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string" || isKept(item));
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The model's view of stored rich text, or null when it cannot be converted (no converters, no
|
|
50
|
+
* editor config). An empty field is `""`, so it reads as an empty text to fill.
|
|
51
|
+
*/
|
|
52
|
+
export function toBlocks(value, editor) {
|
|
53
|
+
const editorConfig = editorConfigOf(editor);
|
|
54
|
+
if (!converters || !editorConfig || !isLexicalState(value)) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
const blocks = value.root.children.map((node, index) => canWrite(node)
|
|
58
|
+
? nodeMarkdown(node, value.root, editorConfig)
|
|
59
|
+
: { keep: index, type: String(node.type ?? "node") });
|
|
60
|
+
const empty = blocks.every((block) => block === "");
|
|
61
|
+
return empty ? "" : blocks;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Rich text from what the model sent: blocks (see `toBlocks`) or a Markdown string. `original` is
|
|
65
|
+
* the stored value the blocks were made from. `links` are the internal links in rebuilt blocks, to
|
|
66
|
+
* check against the editor's access; links in reused blocks were there already.
|
|
67
|
+
*/
|
|
68
|
+
export function fromBlocks(value, original, editor) {
|
|
69
|
+
const editorConfig = editorConfigOf(editor);
|
|
70
|
+
if (!converters || !editorConfig) {
|
|
71
|
+
return { ok: false, error: "Rich text cannot be converted here." };
|
|
72
|
+
}
|
|
73
|
+
const stored = isLexicalState(original) ? original : undefined;
|
|
74
|
+
const storedBlocks = stored ? (toBlocks(stored, editor) ?? []) : [];
|
|
75
|
+
const blocks = typeof value === "string" ? [value] : value;
|
|
76
|
+
if (typeof value === "string" && Array.isArray(storedBlocks) && storedBlocks.some(isKept)) {
|
|
77
|
+
return {
|
|
78
|
+
ok: false,
|
|
79
|
+
error: "This rich text holds parts plain text would drop. Send it as a list of blocks, with { keep: i } for each block you leave as it is.",
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
const children = [];
|
|
83
|
+
const used = new Set();
|
|
84
|
+
const links = [];
|
|
85
|
+
const allowed = linkCollections(editorConfig);
|
|
86
|
+
for (const block of blocks) {
|
|
87
|
+
if (isKept(block)) {
|
|
88
|
+
const node = stored?.root.children[block.keep];
|
|
89
|
+
if (!node || used.has(block.keep)) {
|
|
90
|
+
return {
|
|
91
|
+
ok: false,
|
|
92
|
+
error: `{ keep: ${block.keep} } is not a block of this rich text, or appears twice.`,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
used.add(block.keep);
|
|
96
|
+
children.push(node);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const reused = Array.isArray(storedBlocks)
|
|
100
|
+
? storedBlocks.findIndex((entry, index) => entry === block && !used.has(index))
|
|
101
|
+
: -1;
|
|
102
|
+
const storedNode = reused >= 0 ? stored?.root.children[reused] : undefined;
|
|
103
|
+
if (storedNode) {
|
|
104
|
+
used.add(reused);
|
|
105
|
+
children.push(storedNode);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (!block.trim()) {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const converted = converters.toLexical({ markdown: block, editorConfig });
|
|
112
|
+
const nodes = isLexicalState(converted) ? converted.root.children : [];
|
|
113
|
+
for (const node of nodes) {
|
|
114
|
+
const error = internalLinks(node, allowed, links);
|
|
115
|
+
if (error) {
|
|
116
|
+
return { ok: false, error };
|
|
117
|
+
}
|
|
118
|
+
children.push(node);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const root = stored?.root ?? {
|
|
122
|
+
type: "root",
|
|
123
|
+
format: "",
|
|
124
|
+
indent: 0,
|
|
125
|
+
version: 1,
|
|
126
|
+
direction: "ltr",
|
|
127
|
+
};
|
|
128
|
+
return { ok: true, state: { root: { ...root, children } }, links };
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Which blocks changed between two block lists, by position in the new list for added and changed
|
|
132
|
+
* blocks and in the old list for removed ones. A removal next to an addition reads as a change.
|
|
133
|
+
*/
|
|
134
|
+
export function richTextDiff(before, sent) {
|
|
135
|
+
// { keep: i } in what the model sent stands for block i as it was.
|
|
136
|
+
const after = sent.map((block) => (isKept(block) ? (before[block.keep] ?? block) : block));
|
|
137
|
+
const key = (block) => (typeof block === "string" ? block : JSON.stringify(block));
|
|
138
|
+
const a = before.map(key);
|
|
139
|
+
const b = after.map(key);
|
|
140
|
+
// Longest common subsequence, small enough for the blocks of one field.
|
|
141
|
+
const lengths = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
|
|
142
|
+
for (let i = a.length - 1; i >= 0; i--) {
|
|
143
|
+
for (let j = b.length - 1; j >= 0; j--) {
|
|
144
|
+
lengths[i][j] =
|
|
145
|
+
a[i] === b[j] ? lengths[i + 1][j + 1] + 1 : Math.max(lengths[i + 1][j], lengths[i][j + 1]);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const changes = [];
|
|
149
|
+
let i = 0;
|
|
150
|
+
let j = 0;
|
|
151
|
+
while (i < a.length || j < b.length) {
|
|
152
|
+
if (i < a.length && j < b.length && a[i] === b[j]) {
|
|
153
|
+
i++;
|
|
154
|
+
j++;
|
|
155
|
+
}
|
|
156
|
+
else if (i < a.length &&
|
|
157
|
+
j < b.length &&
|
|
158
|
+
lengths[i + 1][j] === lengths[i][j + 1] &&
|
|
159
|
+
lengths[i + 1][j + 1] === lengths[i][j]) {
|
|
160
|
+
changes.push({
|
|
161
|
+
kind: "changed",
|
|
162
|
+
index: j,
|
|
163
|
+
before: before[i],
|
|
164
|
+
after: after[j],
|
|
165
|
+
});
|
|
166
|
+
i++;
|
|
167
|
+
j++;
|
|
168
|
+
}
|
|
169
|
+
else if (j < b.length && (i >= a.length || lengths[i][j + 1] >= lengths[i + 1][j])) {
|
|
170
|
+
changes.push({ kind: "added", index: j, after: after[j] });
|
|
171
|
+
j++;
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
changes.push({ kind: "removed", index: i, before: before[i] });
|
|
175
|
+
i++;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return changes;
|
|
179
|
+
}
|
|
180
|
+
function canWrite(node) {
|
|
181
|
+
if (!MARKDOWN_NODES.has(String(node.type))) {
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
const inline = (child) => {
|
|
185
|
+
const record = child;
|
|
186
|
+
return (MARKDOWN_INLINE.has(String(record?.type)) &&
|
|
187
|
+
(!Array.isArray(record.children) || record.children.every(inline)));
|
|
188
|
+
};
|
|
189
|
+
return !Array.isArray(node.children) || node.children.every(inline);
|
|
190
|
+
}
|
|
191
|
+
/** One top-level node as Markdown, with internal links written as `doc:` links. */
|
|
192
|
+
function nodeMarkdown(node, root, editorConfig) {
|
|
193
|
+
const data = { root: { ...root, children: [withDocLinks(node)] } };
|
|
194
|
+
const markdown = converters?.toMarkdown({ data, editorConfig }).trim() ?? "";
|
|
195
|
+
return markdown.replaceAll(`](${DOC_EXPORT}`, "](doc:");
|
|
196
|
+
}
|
|
197
|
+
function withDocLinks(node) {
|
|
198
|
+
const fields = node.fields;
|
|
199
|
+
const next = { ...node };
|
|
200
|
+
if (node.type === "link" && fields?.linkType === "internal") {
|
|
201
|
+
const doc = fields.doc;
|
|
202
|
+
const id = doc?.value && typeof doc.value === "object" ? doc.value.id : doc?.value;
|
|
203
|
+
next.fields = {
|
|
204
|
+
...fields,
|
|
205
|
+
linkType: "custom",
|
|
206
|
+
url: `${DOC_EXPORT}${String(doc?.relationTo)}/${String(id)}`,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
if (Array.isArray(node.children)) {
|
|
210
|
+
next.children = node.children.map((child) => withDocLinks(child));
|
|
211
|
+
}
|
|
212
|
+
return next;
|
|
213
|
+
}
|
|
214
|
+
/** Turns `doc:` links into Payload's internal links, collecting them; an error names the rule. */
|
|
215
|
+
function internalLinks(node, allowed, into) {
|
|
216
|
+
const fields = node.fields;
|
|
217
|
+
const match = node.type === "link" && typeof fields?.url === "string" ? DOC_LINK.exec(fields.url) : null;
|
|
218
|
+
if (match) {
|
|
219
|
+
const [, collection = "", id = ""] = match;
|
|
220
|
+
// Without enabledCollections, Payload lets links point at any collection; access decides.
|
|
221
|
+
if (allowed && !allowed.includes(collection)) {
|
|
222
|
+
return allowed.length
|
|
223
|
+
? `Links in this text can only point at ${allowed.join(", ")}.`
|
|
224
|
+
: "Links in this text cannot point at documents.";
|
|
225
|
+
}
|
|
226
|
+
node.fields = {
|
|
227
|
+
linkType: "internal",
|
|
228
|
+
doc: { relationTo: collection, value: /^\d+$/.test(id) ? Number(id) : id },
|
|
229
|
+
newTab: fields?.newTab === true,
|
|
230
|
+
};
|
|
231
|
+
into.push({ collection, id });
|
|
232
|
+
}
|
|
233
|
+
for (const child of Array.isArray(node.children) ? node.children : []) {
|
|
234
|
+
const error = internalLinks(child, allowed, into);
|
|
235
|
+
if (error) {
|
|
236
|
+
return error;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
241
|
+
function linkCollections(editorConfig) {
|
|
242
|
+
const features = editorConfig
|
|
243
|
+
.resolvedFeatureMap;
|
|
244
|
+
const link = features?.get("link");
|
|
245
|
+
const enabled = link?.sanitizedServerFeatureProps?.enabledCollections;
|
|
246
|
+
return Array.isArray(enabled) ? enabled.filter((slug) => typeof slug === "string") : undefined;
|
|
247
|
+
}
|
|
248
|
+
function editorConfigOf(editor) {
|
|
249
|
+
return editor?.editorConfig;
|
|
250
|
+
}
|
|
251
|
+
function isKept(value) {
|
|
252
|
+
const record = value;
|
|
253
|
+
return Boolean(record && typeof record === "object" && Number.isInteger(record.keep));
|
|
254
|
+
}
|
package/dist/form/shape.d.ts
CHANGED
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
import type { FieldNode } from "../schema/fields.ts";
|
|
2
|
+
import { type RichTextLink } from "./rich-text.ts";
|
|
2
3
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
4
|
+
* A copy of `data` where each rich text field the converters can read is a list of `{ keep: i }`,
|
|
5
|
+
* one per stored block, so operations can set, insert or remove single blocks (`layout.1.content.2`)
|
|
6
|
+
* while every other block stays exactly as it was. `finalizeCompiled` turns the lists back.
|
|
5
7
|
*/
|
|
6
|
-
export declare function
|
|
8
|
+
export declare function openRichText(data: Record<string, unknown>, fields: FieldNode[]): Record<string, unknown>;
|
|
9
|
+
/**
|
|
10
|
+
* Make compiled data safe to store: rich text written as Markdown blocks or text becomes editor
|
|
11
|
+
* state, reusing what `original` (the form before the change) held, then every block must be
|
|
12
|
+
* typed. Mutates `data`. `links` are internal links in rewritten rich text, to check for access.
|
|
13
|
+
*/
|
|
14
|
+
export declare function finalizeCompiled(data: Record<string, unknown>, fields: FieldNode[], original?: Record<string, unknown>): {
|
|
7
15
|
ok: true;
|
|
8
16
|
data: Record<string, unknown>;
|
|
17
|
+
links: RichTextLink[];
|
|
9
18
|
} | {
|
|
10
19
|
ok: false;
|
|
11
20
|
error: string;
|
package/dist/form/shape.js
CHANGED
|
@@ -1,11 +1,48 @@
|
|
|
1
|
+
import { fromBlocks, isLexicalState, isRichTextBlocks, toBlocks, } from "./rich-text.js";
|
|
1
2
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
3
|
+
* A copy of `data` where each rich text field the converters can read is a list of `{ keep: i }`,
|
|
4
|
+
* one per stored block, so operations can set, insert or remove single blocks (`layout.1.content.2`)
|
|
5
|
+
* while every other block stays exactly as it was. `finalizeCompiled` turns the lists back.
|
|
4
6
|
*/
|
|
5
|
-
export function
|
|
6
|
-
|
|
7
|
+
export function openRichText(data, fields) {
|
|
8
|
+
const opened = { ...data };
|
|
9
|
+
for (const field of fields) {
|
|
10
|
+
const value = data[field.name];
|
|
11
|
+
if (field.type === "richText" &&
|
|
12
|
+
isLexicalState(value) &&
|
|
13
|
+
toBlocks(value, field.editor) !== null) {
|
|
14
|
+
opened[field.name] = value.root.children.map((_, index) => ({ keep: index }));
|
|
15
|
+
}
|
|
16
|
+
else if (field.type === "blocks" && Array.isArray(value)) {
|
|
17
|
+
opened[field.name] = value.map((item) => {
|
|
18
|
+
const block = isRecord(item)
|
|
19
|
+
? field.blocks?.find((entry) => entry.slug === item.blockType)
|
|
20
|
+
: undefined;
|
|
21
|
+
return block && isRecord(item) ? openRichText(item, block.fields) : item;
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
else if (field.fields && Array.isArray(value)) {
|
|
25
|
+
opened[field.name] = value.map((item) => isRecord(item) ? openRichText(item, field.fields ?? []) : item);
|
|
26
|
+
}
|
|
27
|
+
else if (field.fields && isRecord(value)) {
|
|
28
|
+
opened[field.name] = openRichText(value, field.fields);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return opened;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Make compiled data safe to store: rich text written as Markdown blocks or text becomes editor
|
|
35
|
+
* state, reusing what `original` (the form before the change) held, then every block must be
|
|
36
|
+
* typed. Mutates `data`. `links` are internal links in rewritten rich text, to check for access.
|
|
37
|
+
*/
|
|
38
|
+
export function finalizeCompiled(data, fields, original) {
|
|
39
|
+
const links = [];
|
|
40
|
+
const richTextError = coerceRichText(data, fields, original, links, "");
|
|
41
|
+
if (richTextError) {
|
|
42
|
+
return { ok: false, error: richTextError };
|
|
43
|
+
}
|
|
7
44
|
const error = blockShapeError(data, fields);
|
|
8
|
-
return error ? { ok: false, error } : { ok: true, data };
|
|
45
|
+
return error ? { ok: false, error } : { ok: true, data, links };
|
|
9
46
|
}
|
|
10
47
|
/**
|
|
11
48
|
* Check that every blocks field in compiled data holds typed blocks the schema knows.
|
|
@@ -68,33 +105,75 @@ function checkField(field, value, path) {
|
|
|
68
105
|
}
|
|
69
106
|
return isRecord(value) ? checkFields(value, field.fields, path) : null;
|
|
70
107
|
}
|
|
71
|
-
function coerceRichText(data, fields) {
|
|
108
|
+
function coerceRichText(data, fields, original, links, prefix) {
|
|
72
109
|
for (const field of fields) {
|
|
73
110
|
const value = data[field.name];
|
|
74
|
-
|
|
75
|
-
|
|
111
|
+
const before = original?.[field.name];
|
|
112
|
+
const path = prefix ? `${prefix}.${field.name}` : field.name;
|
|
113
|
+
if (field.type === "richText" && Array.isArray(value) && !isRichTextBlocks(value)) {
|
|
114
|
+
return `"${path}" must be a list of Markdown strings and { keep: i } entries.`;
|
|
115
|
+
}
|
|
116
|
+
if (field.type === "richText" && (typeof value === "string" || isRichTextBlocks(value))) {
|
|
117
|
+
if (typeof value === "string" && !field.editor) {
|
|
118
|
+
data[field.name] = plainTextToLexical(value);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const converted = fromBlocks(value, before, field.editor);
|
|
122
|
+
if (!converted.ok) {
|
|
123
|
+
// Without the converters, text still becomes plain paragraphs as before.
|
|
124
|
+
if (typeof value === "string") {
|
|
125
|
+
data[field.name] = plainTextToLexical(value);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
return `"${path}": ${converted.error}`;
|
|
129
|
+
}
|
|
130
|
+
data[field.name] = converted.state;
|
|
131
|
+
links.push(...converted.links);
|
|
76
132
|
}
|
|
77
133
|
else if (field.type === "blocks" && Array.isArray(value)) {
|
|
78
|
-
for (const item of value) {
|
|
134
|
+
for (const [index, item] of value.entries()) {
|
|
79
135
|
const block = isRecord(item)
|
|
80
136
|
? field.blocks?.find((entry) => entry.slug === item.blockType)
|
|
81
137
|
: undefined;
|
|
82
138
|
if (block && isRecord(item)) {
|
|
83
|
-
coerceRichText(item, block.fields);
|
|
139
|
+
const error = coerceRichText(item, block.fields, sameRow(before, item, index), links, `${path}.${index}`);
|
|
140
|
+
if (error)
|
|
141
|
+
return error;
|
|
84
142
|
}
|
|
85
143
|
}
|
|
86
144
|
}
|
|
87
145
|
else if (field.fields && Array.isArray(value)) {
|
|
88
|
-
for (const item of value) {
|
|
146
|
+
for (const [index, item] of value.entries()) {
|
|
89
147
|
if (isRecord(item)) {
|
|
90
|
-
coerceRichText(item, field.fields);
|
|
148
|
+
const error = coerceRichText(item, field.fields, sameRow(before, item, index), links, `${path}.${index}`);
|
|
149
|
+
if (error)
|
|
150
|
+
return error;
|
|
91
151
|
}
|
|
92
152
|
}
|
|
93
153
|
}
|
|
94
154
|
else if (field.fields && isRecord(value)) {
|
|
95
|
-
coerceRichText(value, field.fields);
|
|
155
|
+
const error = coerceRichText(value, field.fields, isRecord(before) ? before : undefined, links, path);
|
|
156
|
+
if (error)
|
|
157
|
+
return error;
|
|
96
158
|
}
|
|
97
159
|
}
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* The row of `rows` that `item` was: by id, since rows move. Rows without ids (data not from a
|
|
164
|
+
* form) fall back to the same position with the same block type.
|
|
165
|
+
*/
|
|
166
|
+
function sameRow(rows, item, index) {
|
|
167
|
+
if (!Array.isArray(rows)) {
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
const row = item.id === undefined
|
|
171
|
+
? rows[index]
|
|
172
|
+
: rows.find((entry) => isRecord(entry) && entry.id === item.id);
|
|
173
|
+
if (!isRecord(row) || (item.id === undefined && row.id !== undefined)) {
|
|
174
|
+
return undefined;
|
|
175
|
+
}
|
|
176
|
+
return row.blockType === item.blockType ? row : undefined;
|
|
98
177
|
}
|
|
99
178
|
/** Minimal Lexical editor state: one paragraph per blank-line-separated chunk. */
|
|
100
179
|
export function plainTextToLexical(text) {
|
package/dist/runtime/agent.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { loadRichText } from "../form/rich-text.js";
|
|
1
2
|
import { PACKAGE_NAME } from "../package-name.js";
|
|
2
3
|
import { loadTurnContext } from "./context.js";
|
|
3
4
|
import { needsProposeRetry } from "./propose-loop.js";
|
|
@@ -18,6 +19,8 @@ const STEP_FOR_TOOL = {
|
|
|
18
19
|
"admin.navigate": "navigating",
|
|
19
20
|
};
|
|
20
21
|
export async function runAgentTurn(req, options, input) {
|
|
22
|
+
// Rich text reaches the model as Markdown blocks when Payload's converters are installed.
|
|
23
|
+
await loadRichText();
|
|
21
24
|
const result = await generateTurn(req, options, input);
|
|
22
25
|
if (!result.ok || result.pendingApproval?.capability !== "form.propose") {
|
|
23
26
|
return result;
|
|
@@ -230,7 +233,7 @@ function buildSdkTools(ai, ctx, captured, previews, emit) {
|
|
|
230
233
|
step: executed.ok && executed.pendingApproval ? "writing" : "thinking",
|
|
231
234
|
});
|
|
232
235
|
// The image goes to the model through toModelOutput; Admin never needs the bytes.
|
|
233
|
-
captured.push(outcome.ok ? { ...outcome,
|
|
236
|
+
captured.push(outcome.ok ? { ...outcome, images: undefined } : outcome);
|
|
234
237
|
return outcome;
|
|
235
238
|
}
|
|
236
239
|
catch {
|
|
@@ -1,25 +1,28 @@
|
|
|
1
1
|
import type { PayloadRequest } from "payload";
|
|
2
2
|
import type { ValidatedEditorAssistantOptions } from "../config/validate.ts";
|
|
3
|
+
import { type DiscoveredEntity } from "../schema/discover.ts";
|
|
3
4
|
import type { AdminView } from "../view.ts";
|
|
4
5
|
import { type Selection } from "./selection.ts";
|
|
5
6
|
import { type ToolBridge } from "./tools.ts";
|
|
7
|
+
/** A field as the model sees it; uploads and relations say which collection they point at. */
|
|
8
|
+
type CompactField = {
|
|
9
|
+
name: string;
|
|
10
|
+
type: string;
|
|
11
|
+
required?: boolean;
|
|
12
|
+
relationTo?: string[];
|
|
13
|
+
};
|
|
6
14
|
export type TurnContext = {
|
|
7
15
|
allowlist: Array<{
|
|
8
16
|
kind: "collection" | "global";
|
|
9
17
|
slug: string;
|
|
10
18
|
label?: string;
|
|
11
19
|
capabilities: string[];
|
|
12
|
-
fields: Array<{
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
required?: boolean;
|
|
20
|
+
fields: Array<CompactField & {
|
|
21
|
+
/** A group's fields, one level down (`meta.image`). */
|
|
22
|
+
fields?: CompactField[];
|
|
16
23
|
blocks?: Array<{
|
|
17
24
|
slug: string;
|
|
18
|
-
fields:
|
|
19
|
-
name: string;
|
|
20
|
-
type: string;
|
|
21
|
-
required?: boolean;
|
|
22
|
-
}>;
|
|
25
|
+
fields: CompactField[];
|
|
23
26
|
}>;
|
|
24
27
|
}>;
|
|
25
28
|
}>;
|
|
@@ -38,3 +41,5 @@ export type TurnContext = {
|
|
|
38
41
|
documents: Record<string, unknown>[];
|
|
39
42
|
};
|
|
40
43
|
export declare function loadTurnContext(req: PayloadRequest, options: ValidatedEditorAssistantOptions, bridge: ToolBridge, formData?: Record<string, unknown>): Promise<TurnContext>;
|
|
44
|
+
export declare function compactEntity(entity: DiscoveredEntity): TurnContext["allowlist"][number];
|
|
45
|
+
export {};
|
package/dist/runtime/context.js
CHANGED
|
@@ -76,24 +76,27 @@ async function readOtherLanguages(req, options, bridge) {
|
|
|
76
76
|
}
|
|
77
77
|
return Object.keys(otherLanguages).length > 0 ? otherLanguages : undefined;
|
|
78
78
|
}
|
|
79
|
-
function compactEntity(entity) {
|
|
79
|
+
export function compactEntity(entity) {
|
|
80
80
|
return {
|
|
81
81
|
kind: entity.kind,
|
|
82
82
|
slug: entity.slug,
|
|
83
83
|
label: entity.label,
|
|
84
84
|
capabilities: entity.capabilities,
|
|
85
85
|
fields: entity.fields.map((field) => ({
|
|
86
|
-
|
|
87
|
-
type: field.type,
|
|
88
|
-
required: field.required || undefined,
|
|
86
|
+
...compactField(field),
|
|
89
87
|
blocks: field.blocks?.map((block) => ({
|
|
90
88
|
slug: block.slug,
|
|
91
|
-
fields: block.fields.map(
|
|
92
|
-
name: child.name,
|
|
93
|
-
type: child.type,
|
|
94
|
-
required: child.required || undefined,
|
|
95
|
-
})),
|
|
89
|
+
fields: block.fields.map(compactField),
|
|
96
90
|
})),
|
|
91
|
+
...(field.type === "group" ? { fields: field.fields?.map(compactField) } : {}),
|
|
97
92
|
})),
|
|
98
93
|
};
|
|
99
94
|
}
|
|
95
|
+
function compactField(field) {
|
|
96
|
+
return {
|
|
97
|
+
name: field.name,
|
|
98
|
+
type: field.type,
|
|
99
|
+
required: field.required || undefined,
|
|
100
|
+
relationTo: field.type === "upload" || field.type === "relationship" ? field.relationTo : undefined,
|
|
101
|
+
};
|
|
102
|
+
}
|