@simmalugnt-se/payload-editor-assistant 0.12.2 → 0.13.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 +26 -0
- package/README.md +37 -0
- package/dist/chat-images.d.ts +20 -0
- package/dist/chat-images.js +61 -0
- package/dist/components/ChatImages.d.ts +20 -0
- package/dist/components/ChatImages.js +113 -0
- package/dist/components/EditorAssistantAside.js +52 -13
- package/dist/components/ProposalReview.d.ts +1 -1
- package/dist/components/ProposalReview.js +7 -3
- package/dist/components/assistant-context.d.ts +2 -0
- package/dist/components/chat-image-file.d.ts +3 -0
- package/dist/components/chat-image-file.js +48 -0
- package/dist/components/conversation-store.js +34 -4
- package/dist/components/copy.d.ts +14 -0
- package/dist/components/copy.js +14 -0
- package/dist/domain/batch.js +4 -1
- package/dist/domain/decide.js +9 -0
- package/dist/domain/new-block-review.d.ts +7 -0
- package/dist/domain/new-block-review.js +97 -0
- package/dist/domain/propose.d.ts +2 -0
- package/dist/domain/propose.js +33 -6
- package/dist/endpoints/chat.d.ts +3 -0
- package/dist/endpoints/chat.js +31 -2
- package/dist/endpoints/decide.js +27 -6
- package/dist/form/diff.js +3 -2
- package/dist/form/relation-shape.d.ts +10 -5
- package/dist/form/relation-shape.js +104 -28
- package/dist/runtime/agent.d.ts +3 -1
- package/dist/runtime/agent.js +100 -8
- package/dist/runtime/context.d.ts +10 -8
- package/dist/runtime/context.js +16 -10
- package/dist/runtime/debug.d.ts +10 -0
- package/dist/runtime/debug.js +132 -0
- package/dist/runtime/system-prompt.js +4 -2
- package/dist/schema/fields.d.ts +6 -0
- package/dist/schema/fields.js +20 -2
- package/dist/styles/assistant.css +49 -1
- package/package.json +1 -1
package/dist/components/copy.js
CHANGED
|
@@ -13,6 +13,13 @@ export const COPY = {
|
|
|
13
13
|
collapseLabel: "Collapse assistant",
|
|
14
14
|
placeholder: "Message the assistant",
|
|
15
15
|
send: "Send",
|
|
16
|
+
attachImage: "Add images",
|
|
17
|
+
removeImage: "Remove image",
|
|
18
|
+
imageOmitted: "Older image omitted",
|
|
19
|
+
imagePreparing: "Preparing images…",
|
|
20
|
+
imageLimit: "Attach up to three images per message.",
|
|
21
|
+
imageTooLarge: "These images are too large. Try fewer or smaller images.",
|
|
22
|
+
imageInvalid: "Choose a PNG, JPEG, WebP or GIF image up to 10 MB.",
|
|
16
23
|
stop: "Stop",
|
|
17
24
|
working: "Working…",
|
|
18
25
|
steps: {
|
|
@@ -76,6 +83,13 @@ export const COPY = {
|
|
|
76
83
|
collapseLabel: "Fäll ihop assistenten",
|
|
77
84
|
placeholder: "Skriv till assistenten",
|
|
78
85
|
send: "Skicka",
|
|
86
|
+
attachImage: "Lägg till bilder",
|
|
87
|
+
removeImage: "Ta bort bild",
|
|
88
|
+
imageOmitted: "Äldre bild utelämnad",
|
|
89
|
+
imagePreparing: "Förbereder bilder…",
|
|
90
|
+
imageLimit: "Bifoga högst tre bilder per meddelande.",
|
|
91
|
+
imageTooLarge: "Bilderna är för stora. Prova färre eller mindre bilder.",
|
|
92
|
+
imageInvalid: "Välj en PNG-, JPEG-, WebP- eller GIF-bild på högst 10 MB.",
|
|
79
93
|
stop: "Stoppa",
|
|
80
94
|
working: "Arbetar…",
|
|
81
95
|
steps: {
|
package/dist/domain/batch.js
CHANGED
|
@@ -214,7 +214,10 @@ export async function applyBatchTarget(req, options, input) {
|
|
|
214
214
|
return fail("tool_denied", finalized.error);
|
|
215
215
|
}
|
|
216
216
|
const paths = changedPaths(operations);
|
|
217
|
-
const relationError = relationValueError(fields, finalized.data, paths
|
|
217
|
+
const relationError = relationValueError(fields, finalized.data, paths, {
|
|
218
|
+
user: req.user,
|
|
219
|
+
operation: "update",
|
|
220
|
+
});
|
|
218
221
|
if (relationError) {
|
|
219
222
|
return fail("tool_denied", relationError);
|
|
220
223
|
}
|
package/dist/domain/decide.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { compileOperations } from "../form/compile.js";
|
|
2
2
|
import { hashFormRevision } from "../form/hash.js";
|
|
3
3
|
import { isFormOperation } from "../form/operations.js";
|
|
4
|
+
import { relationValueError } from "../form/relation-shape.js";
|
|
4
5
|
import { loadRichText } from "../form/rich-text.js";
|
|
5
6
|
import { finalizeCompiled, openRichText } from "../form/shape.js";
|
|
6
7
|
import { DEFAULT_APPROVAL_SLUG } from "../package-name.js";
|
|
@@ -95,6 +96,14 @@ export async function decideChange(req, options, input) {
|
|
|
95
96
|
await consume(req, record.id, "failed");
|
|
96
97
|
return { ok: false, error: "tool_denied", message: finalized.error };
|
|
97
98
|
}
|
|
99
|
+
const relationError = relationValueError(fields, finalized.data, operations.map((operation) => operation.path), {
|
|
100
|
+
user: req.user,
|
|
101
|
+
operation: record.capability === "draft.create" ? "create" : "update",
|
|
102
|
+
});
|
|
103
|
+
if (relationError) {
|
|
104
|
+
await consume(req, record.id, "failed");
|
|
105
|
+
return { ok: false, error: "tool_denied", message: relationError };
|
|
106
|
+
}
|
|
98
107
|
const relations = await verifyDocumentRelations(req, fields, finalized.data, finalized.links);
|
|
99
108
|
if (!relations.ok) {
|
|
100
109
|
await consume(req, record.id, "failed");
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { FieldNode } from "../schema/fields.ts";
|
|
2
|
+
import type { ProposalLine } from "./propose.ts";
|
|
3
|
+
/** Review block fields by ID so inserts and moves cannot mislabel other changes in the same list. */
|
|
4
|
+
export declare function newBlockReview(fields: FieldNode[], current: Record<string, unknown>, after: Record<string, unknown>, language?: "en" | "sv", labels?: Map<string, string>): {
|
|
5
|
+
lines: ProposalLine[];
|
|
6
|
+
containers: Set<string>;
|
|
7
|
+
};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { humanDiff } from "../form/diff.js";
|
|
2
|
+
import { fieldPathInfo } from "../form/field-label.js";
|
|
3
|
+
import { getAtPath, parseFieldPath } from "../form/path.js";
|
|
4
|
+
/** Review block fields by ID so inserts and moves cannot mislabel other changes in the same list. */
|
|
5
|
+
export function newBlockReview(fields, current, after, language = "en", labels) {
|
|
6
|
+
const lines = [];
|
|
7
|
+
const containers = new Set();
|
|
8
|
+
const visit = (scope, before, value, prefix = "", schemaPrefix = "", added = false, insideBlock = false) => {
|
|
9
|
+
for (const field of scope) {
|
|
10
|
+
// Named tabs are flattened in the schema but retain their name in each field path.
|
|
11
|
+
const relative = field.path.slice(schemaPrefix ? schemaPrefix.length + 1 : 0);
|
|
12
|
+
const path = prefix ? `${prefix}.${relative}` : relative;
|
|
13
|
+
const previous = getAtPath(before, parseFieldPath(relative));
|
|
14
|
+
const next = getAtPath(value, parseFieldPath(relative));
|
|
15
|
+
if (!added && !insideBlock && (next === undefined || next === null))
|
|
16
|
+
continue;
|
|
17
|
+
if ((field.type === "blocks" || field.type === "array") && Array.isArray(next)) {
|
|
18
|
+
if (field.type === "blocks")
|
|
19
|
+
containers.add(path);
|
|
20
|
+
for (const [index, row] of next.entries()) {
|
|
21
|
+
if (!isRecord(row))
|
|
22
|
+
continue;
|
|
23
|
+
const oldRow = Array.isArray(previous)
|
|
24
|
+
? row.id === undefined
|
|
25
|
+
? previous[index]
|
|
26
|
+
: previous.find((entry) => isRecord(entry) && entry.id === row.id)
|
|
27
|
+
: undefined;
|
|
28
|
+
const newBlock = field.type === "blocks" && row.id !== undefined && !oldRow;
|
|
29
|
+
const children = field.type === "blocks"
|
|
30
|
+
? field.blocks?.find((block) => block.slug === row.blockType)?.fields
|
|
31
|
+
: field.fields;
|
|
32
|
+
const start = lines.length;
|
|
33
|
+
visit(children ?? [], oldRow, row, `${path}.${index}`, field.path, added || newBlock, insideBlock || field.type === "blocks");
|
|
34
|
+
if (newBlock) {
|
|
35
|
+
if (lines.length === start) {
|
|
36
|
+
lines.push({
|
|
37
|
+
path: `${path}.${index}`,
|
|
38
|
+
groups: fieldPathInfo(fields, `${path}.${index}.__change`, after, language).groups,
|
|
39
|
+
text: language === "sv" ? "Nytt block" : "New block",
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
containers.add(path);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
else if (field.fields) {
|
|
47
|
+
visit(field.fields, previous, next, path, field.path, added, insideBlock);
|
|
48
|
+
}
|
|
49
|
+
else if ((added && next !== undefined && next !== null) ||
|
|
50
|
+
(insideBlock && !added && JSON.stringify(previous) !== JSON.stringify(next))) {
|
|
51
|
+
const { label, context, groups } = fieldPathInfo(fields, path, after, language);
|
|
52
|
+
const text = humanDiff({ value: previous }, [
|
|
53
|
+
next === undefined
|
|
54
|
+
? { op: "unset", path: "value" }
|
|
55
|
+
: { op: "set", path: "value", value: next },
|
|
56
|
+
], language, labels, () => label)[0]?.text ?? label;
|
|
57
|
+
lines.push({
|
|
58
|
+
path,
|
|
59
|
+
label,
|
|
60
|
+
context,
|
|
61
|
+
groups,
|
|
62
|
+
text,
|
|
63
|
+
...(field.type === "richText"
|
|
64
|
+
? { richText: { before: added ? undefined : previous, after: next } }
|
|
65
|
+
: added
|
|
66
|
+
? { value: displayValue(next, field, labels) }
|
|
67
|
+
: field.type === "text" || field.type === "textarea"
|
|
68
|
+
? {
|
|
69
|
+
edit: {
|
|
70
|
+
label,
|
|
71
|
+
before: typeof previous === "string" ? previous : "",
|
|
72
|
+
value: typeof next === "string" ? next : "",
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
: {}),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
visit(fields, current, after);
|
|
81
|
+
return { lines, containers };
|
|
82
|
+
}
|
|
83
|
+
function displayValue(value, field, labels) {
|
|
84
|
+
if (field.type === "relationship" || field.type === "upload") {
|
|
85
|
+
const name = (entry) => {
|
|
86
|
+
const id = isRecord(entry)
|
|
87
|
+
? (entry.id ?? (isRecord(entry.value) ? entry.value.id : entry.value))
|
|
88
|
+
: entry;
|
|
89
|
+
return labels?.get(String(id)) ?? String(id);
|
|
90
|
+
};
|
|
91
|
+
return Array.isArray(value) ? value.map(name).join(", ") : name(value);
|
|
92
|
+
}
|
|
93
|
+
return typeof value === "string" ? value : JSON.stringify(value);
|
|
94
|
+
}
|
|
95
|
+
function isRecord(value) {
|
|
96
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
97
|
+
}
|
package/dist/domain/propose.d.ts
CHANGED
package/dist/domain/propose.js
CHANGED
|
@@ -17,6 +17,7 @@ import { hasCapability, resolveAllowlist } from "./allowlist.js";
|
|
|
17
17
|
import { writeAudit } from "./audit.js";
|
|
18
18
|
import { readDocument } from "./content.js";
|
|
19
19
|
import { localApi } from "./local-api.js";
|
|
20
|
+
import { newBlockReview } from "./new-block-review.js";
|
|
20
21
|
import { labelRelations, verifyDocumentRelations } from "./relations.js";
|
|
21
22
|
/** Long enough to look at a change in the preview before keeping it. */
|
|
22
23
|
const TTL_MS = 30 * 60_000;
|
|
@@ -69,7 +70,10 @@ export async function proposeChange(req, options, input) {
|
|
|
69
70
|
if (!finalized.ok) {
|
|
70
71
|
return { ok: false, error: "tool_denied", message: finalized.error };
|
|
71
72
|
}
|
|
72
|
-
const relationError = relationValueError(fields, finalized.data,
|
|
73
|
+
const relationError = relationValueError(fields, finalized.data, typedOperations.map((operation) => operation.path), {
|
|
74
|
+
user: req.user,
|
|
75
|
+
operation: input.capability === "draft.create" ? "create" : "update",
|
|
76
|
+
});
|
|
73
77
|
if (relationError) {
|
|
74
78
|
return { ok: false, error: "tool_denied", message: relationError };
|
|
75
79
|
}
|
|
@@ -98,8 +102,9 @@ export async function proposeChange(req, options, input) {
|
|
|
98
102
|
const setIds = new Set(typedOperations.flatMap((operation) => operation.op === "set"
|
|
99
103
|
? [operation.value, getAtPath(current, parseFieldPath(operation.path))].flatMap(idsIn)
|
|
100
104
|
: []));
|
|
101
|
-
const
|
|
102
|
-
|
|
105
|
+
const addsBlocks = typedOperations.some((operation) => operation.op === "blocks.insert" || operation.op === "blocks.replace");
|
|
106
|
+
const labels = setIds.size || addsBlocks
|
|
107
|
+
? await labelRelations(req, fields, [finalized.data, current], addsBlocks ? undefined : setIds)
|
|
103
108
|
: undefined;
|
|
104
109
|
const inRichText = (operation) => "path" in operation &&
|
|
105
110
|
richTextBefore.some(({ path }) => operation.path === path || operation.path.startsWith(`${path}.`));
|
|
@@ -108,8 +113,23 @@ export async function proposeChange(req, options, input) {
|
|
|
108
113
|
...humanDiff(current, typedOperations.filter((operation) => !inRichText(operation)), input.language, labels, (path) => fieldLabel(path).full),
|
|
109
114
|
...richTextBefore.flatMap(({ path, blocks, after }) => richTextLines(path, blocks, Array.isArray(after) ? after : [after], input.language, (path) => fieldLabel(path).full)),
|
|
110
115
|
];
|
|
111
|
-
const
|
|
112
|
-
|
|
116
|
+
const added = addsBlocks
|
|
117
|
+
? newBlockReview(fields, current, finalized.data, input.language, labels)
|
|
118
|
+
: { lines: [], containers: new Set() };
|
|
119
|
+
const addedPaths = new Set(added.lines.map((line) => line.path));
|
|
120
|
+
const reviewedBlockField = (path) => {
|
|
121
|
+
const field = fieldAtPath(fields, path, finalized.data) ?? fieldAtPath(fields, path, current);
|
|
122
|
+
return (field &&
|
|
123
|
+
!field.fields &&
|
|
124
|
+
!field.blocks &&
|
|
125
|
+
[...added.containers].some((container) => path.startsWith(`${container}.`)));
|
|
126
|
+
};
|
|
127
|
+
const reviewDiff = humanDiff(current, typedOperations.filter((operation) => !inRichText(operation) &&
|
|
128
|
+
!((operation.op === "set" || operation.op === "unset") &&
|
|
129
|
+
reviewedBlockField(operation.path)) &&
|
|
130
|
+
!(operation.op === "blocks.insert" && added.containers.has(operation.path))), input.language, labels, (path) => fieldLabel(path).full);
|
|
131
|
+
const lines = reviewDiff
|
|
132
|
+
.filter((line) => !addedPaths.has(line.path))
|
|
113
133
|
.map((line) => {
|
|
114
134
|
const { context, label, groups } = fieldLabel(line.path);
|
|
115
135
|
return {
|
|
@@ -121,6 +141,8 @@ export async function proposeChange(req, options, input) {
|
|
|
121
141
|
};
|
|
122
142
|
});
|
|
123
143
|
for (const { path } of richTextBefore) {
|
|
144
|
+
if (addedPaths.has(path) || reviewedBlockField(path))
|
|
145
|
+
continue;
|
|
124
146
|
const { context, label, groups } = fieldLabel(path);
|
|
125
147
|
lines.push({
|
|
126
148
|
path,
|
|
@@ -137,6 +159,7 @@ export async function proposeChange(req, options, input) {
|
|
|
137
159
|
},
|
|
138
160
|
});
|
|
139
161
|
}
|
|
162
|
+
lines.push(...added.lines);
|
|
140
163
|
let created;
|
|
141
164
|
try {
|
|
142
165
|
created = await localApi(req).create({
|
|
@@ -310,7 +333,11 @@ function idsIn(value) {
|
|
|
310
333
|
return [String(value)];
|
|
311
334
|
}
|
|
312
335
|
if (Array.isArray(value)) {
|
|
313
|
-
return value.flatMap(
|
|
336
|
+
return value.flatMap(idsIn);
|
|
337
|
+
}
|
|
338
|
+
if (value && typeof value === "object") {
|
|
339
|
+
const relation = value;
|
|
340
|
+
return idsIn(relation.id ?? relation.value);
|
|
314
341
|
}
|
|
315
342
|
return [];
|
|
316
343
|
}
|
package/dist/endpoints/chat.d.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import type { Endpoint } from "payload";
|
|
2
2
|
import type { ValidatedEditorAssistantOptions } from "../config/validate.ts";
|
|
3
|
+
import type { ChatMessage } from "../runtime/agent.ts";
|
|
3
4
|
export declare function createChatEndpoint(options: ValidatedEditorAssistantOptions): Endpoint;
|
|
4
5
|
/** Only a short list of form paths; the prompt names them as data. */
|
|
5
6
|
export declare function sanitizeOpenProposal(value: unknown): {
|
|
6
7
|
changedPaths: string[];
|
|
7
8
|
} | undefined;
|
|
9
|
+
/** No remote fetches: only bounded, typed image bytes on user messages. */
|
|
10
|
+
export declare function validChatImages(messages: ChatMessage[]): boolean;
|
package/dist/endpoints/chat.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { imageByteLength, isChatImage, MAX_CHAT_IMAGES, MAX_CHAT_IMAGES_BYTES, } from "../chat-images.js";
|
|
1
2
|
import { ENDPOINT_PREFIX } from "../package-name.js";
|
|
2
3
|
import { runAgentTurn } from "../runtime/agent.js";
|
|
3
4
|
import { beginInflight, endInflight } from "../runtime/inflight.js";
|
|
@@ -34,18 +35,26 @@ export function createChatEndpoint(options) {
|
|
|
34
35
|
if (typeof req.json !== "function") {
|
|
35
36
|
return Response.json({ error: "tool_denied" }, { status: 400 });
|
|
36
37
|
}
|
|
38
|
+
if (Number(req.headers.get("content-length")) > 4 * 1024 * 1024)
|
|
39
|
+
return Response.json({ error: "tool_denied", message: "Chat request is too large." }, { status: 413 });
|
|
37
40
|
body = (await req.json());
|
|
41
|
+
if (Buffer.byteLength(JSON.stringify(body)) > 4 * 1024 * 1024)
|
|
42
|
+
return Response.json({ error: "tool_denied", message: "Chat request is too large." }, { status: 413 });
|
|
38
43
|
}
|
|
39
44
|
catch {
|
|
40
45
|
return Response.json({ error: "tool_denied" }, { status: 400 });
|
|
41
46
|
}
|
|
42
|
-
if (typeof body
|
|
47
|
+
if (!body || typeof body !== "object" || typeof body.message !== "string") {
|
|
43
48
|
return Response.json({ error: "tool_denied" }, { status: 400 });
|
|
44
49
|
}
|
|
45
50
|
const locale = nonEmpty(body.bridge?.locale) ?? nonEmpty(body.locale) ?? defaultLocale(req);
|
|
46
51
|
const messages = Array.isArray(body.messages)
|
|
47
|
-
? body.messages.filter(isChatMessage)
|
|
52
|
+
? body.messages.filter(isChatMessage).slice(-20)
|
|
48
53
|
: [{ role: "user", text: body.message }];
|
|
54
|
+
if (!validChatImages(messages) ||
|
|
55
|
+
(!body.message.trim() && !messages.at(-1)?.images?.length)) {
|
|
56
|
+
return Response.json({ error: "tool_denied", message: "Invalid or oversized image attachments." }, { status: 400 });
|
|
57
|
+
}
|
|
49
58
|
const language = resolveChatLanguage(messages.filter((message) => message.role === "user").map((message) => message.text), nonEmpty(body.uiLanguage));
|
|
50
59
|
const run = (onEvent) => runAgentTurn(req, options, {
|
|
51
60
|
onEvent,
|
|
@@ -155,3 +164,23 @@ function isChatMessage(value) {
|
|
|
155
164
|
return ((record.role === "user" || record.role === "assistant" || record.role === "system") &&
|
|
156
165
|
typeof record.text === "string");
|
|
157
166
|
}
|
|
167
|
+
/** No remote fetches: only bounded, typed image bytes on user messages. */
|
|
168
|
+
export function validChatImages(messages) {
|
|
169
|
+
let bytes = 0;
|
|
170
|
+
for (const message of messages) {
|
|
171
|
+
if (message.images === undefined)
|
|
172
|
+
continue;
|
|
173
|
+
if (message.role !== "user" ||
|
|
174
|
+
!Array.isArray(message.images) ||
|
|
175
|
+
message.images.length > MAX_CHAT_IMAGES)
|
|
176
|
+
return false;
|
|
177
|
+
for (const image of message.images) {
|
|
178
|
+
if (!isChatImage(image))
|
|
179
|
+
return false;
|
|
180
|
+
bytes += imageByteLength(image.data);
|
|
181
|
+
if (bytes > MAX_CHAT_IMAGES_BYTES)
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return true;
|
|
186
|
+
}
|
package/dist/endpoints/decide.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { decideChange } from "../domain/decide.js";
|
|
2
2
|
import { guardMutationRequest, readJsonBody } from "../domain/guard.js";
|
|
3
3
|
import { ENDPOINT_PREFIX } from "../package-name.js";
|
|
4
|
+
import { createAssistantDebug } from "../runtime/debug.js";
|
|
4
5
|
export function createDecideEndpoint(options) {
|
|
5
6
|
return {
|
|
6
7
|
path: `${ENDPOINT_PREFIX}/decide`,
|
|
@@ -14,20 +15,40 @@ export function createDecideEndpoint(options) {
|
|
|
14
15
|
if (!body?.action || !body.approvalId || !body.nonce || !body.locale) {
|
|
15
16
|
return Response.json({ error: "tool_denied" }, { status: 400 });
|
|
16
17
|
}
|
|
17
|
-
const
|
|
18
|
+
const debug = createAssistantDebug(req);
|
|
19
|
+
debug.write("decision.start", {
|
|
20
|
+
actor: req.user?.id,
|
|
18
21
|
action: body.action,
|
|
19
22
|
approvalId: body.approvalId,
|
|
20
|
-
nonce: body.nonce,
|
|
21
23
|
collection: body.collection,
|
|
22
24
|
global: body.global,
|
|
23
25
|
documentId: body.documentId,
|
|
24
26
|
locale: body.locale,
|
|
25
|
-
formData: body.formData,
|
|
26
27
|
});
|
|
27
|
-
|
|
28
|
-
|
|
28
|
+
try {
|
|
29
|
+
const result = await decideChange(req, options, {
|
|
30
|
+
action: body.action,
|
|
31
|
+
approvalId: body.approvalId,
|
|
32
|
+
nonce: body.nonce,
|
|
33
|
+
collection: body.collection,
|
|
34
|
+
global: body.global,
|
|
35
|
+
documentId: body.documentId,
|
|
36
|
+
locale: body.locale,
|
|
37
|
+
formData: body.formData,
|
|
38
|
+
});
|
|
39
|
+
debug.write("decision.result", result);
|
|
40
|
+
if (!result.ok) {
|
|
41
|
+
return Response.json({ error: result.error, message: result.message }, { status: 400 });
|
|
42
|
+
}
|
|
43
|
+
return Response.json(result);
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
debug.write("decision.error", error);
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
await debug.flush();
|
|
29
51
|
}
|
|
30
|
-
return Response.json(result);
|
|
31
52
|
},
|
|
32
53
|
};
|
|
33
54
|
}
|
package/dist/form/diff.js
CHANGED
|
@@ -46,8 +46,9 @@ export function humanDiff(current, operations, language = "en", labels, fieldLab
|
|
|
46
46
|
const nameOf = (value) => {
|
|
47
47
|
const id = typeof value === "string" || typeof value === "number"
|
|
48
48
|
? String(value)
|
|
49
|
-
: value && typeof value === "object" && "id" in value
|
|
50
|
-
? String(value.id
|
|
49
|
+
: value && typeof value === "object" && ("id" in value || "value" in value)
|
|
50
|
+
? String(value.id ??
|
|
51
|
+
value.value)
|
|
51
52
|
: undefined;
|
|
52
53
|
return id !== undefined ? labels?.get(id) : undefined;
|
|
53
54
|
};
|
|
@@ -1,9 +1,14 @@
|
|
|
1
|
+
import type { PayloadRequest } from "payload";
|
|
1
2
|
import { type FieldNode } from "../schema/fields.ts";
|
|
2
3
|
/**
|
|
3
|
-
* Checks the upload and relationship fields a proposal changes (`paths
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Checks the upload and relationship fields a proposal changes (`paths` from the operations),
|
|
5
|
+
* including relations inside changed groups, rows and blocks. A single-collection
|
|
6
|
+
* polymorphic relation still needs { relationTo, value }. Missing required relationships in a
|
|
7
|
+
* changed group are checked against Payload's trusted field conditions and primitive defaults.
|
|
8
|
+
* Fields pointing at several collections are refused. Unrelated fields are not checked.
|
|
7
9
|
* Whether the ids exist and are readable is `verifyDocumentRelations`' job.
|
|
8
10
|
*/
|
|
9
|
-
export declare function relationValueError(fields: FieldNode[], data: Record<string, unknown>, paths: string[]
|
|
11
|
+
export declare function relationValueError(fields: FieldNode[], data: Record<string, unknown>, paths: string[], context?: {
|
|
12
|
+
user?: PayloadRequest["user"];
|
|
13
|
+
operation?: "create" | "update";
|
|
14
|
+
}): string | null;
|
|
@@ -1,36 +1,90 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { getAtPath, parseFieldPath } from "./path.js";
|
|
1
|
+
import { fieldCondition } from "../schema/fields.js";
|
|
2
|
+
import { cloneData, getAtPath, parseFieldPath, setAtPath } from "./path.js";
|
|
3
3
|
const ID = /^[\w-]+$/;
|
|
4
4
|
/**
|
|
5
|
-
* Checks the upload and relationship fields a proposal changes (`paths
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* Checks the upload and relationship fields a proposal changes (`paths` from the operations),
|
|
6
|
+
* including relations inside changed groups, rows and blocks. A single-collection
|
|
7
|
+
* polymorphic relation still needs { relationTo, value }. Missing required relationships in a
|
|
8
|
+
* changed group are checked against Payload's trusted field conditions and primitive defaults.
|
|
9
|
+
* Fields pointing at several collections are refused. Unrelated fields are not checked.
|
|
9
10
|
* Whether the ids exist and are readable is `verifyDocumentRelations`' job.
|
|
10
11
|
*/
|
|
11
|
-
export function relationValueError(fields, data, paths) {
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
12
|
+
export function relationValueError(fields, data, paths, context = {}) {
|
|
13
|
+
const touched = (path) => paths.some((changed) => changed === path || changed.startsWith(`${path}.`) || path.startsWith(`${changed}.`));
|
|
14
|
+
const visit = (scope, value, prefix = "", schemaPrefix = "", blockData) => {
|
|
15
|
+
if (!isRecord(value))
|
|
16
|
+
return null;
|
|
17
|
+
const needsSiblings = scope.some((field) => fieldCondition(field));
|
|
18
|
+
const siblings = needsSiblings ? cloneData(value) : value;
|
|
19
|
+
for (const field of needsSiblings ? scope : []) {
|
|
20
|
+
const relative = field.path.slice(schemaPrefix ? schemaPrefix.length + 1 : 0);
|
|
21
|
+
if (field.defaultValue !== undefined &&
|
|
22
|
+
getAtPath(siblings, parseFieldPath(relative)) == null) {
|
|
23
|
+
// Only condition evaluation gets defaults; never mutate the proposed data.
|
|
24
|
+
setAtPath(siblings, parseFieldPath(relative), field.defaultValue);
|
|
25
|
+
}
|
|
18
26
|
}
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
27
|
+
for (const field of scope) {
|
|
28
|
+
const relative = field.path.slice(schemaPrefix ? schemaPrefix.length + 1 : 0);
|
|
29
|
+
const path = prefix ? `${prefix}.${relative}` : relative;
|
|
30
|
+
const next = getAtPath(value, parseFieldPath(relative));
|
|
31
|
+
const relation = field.type === "relationship" || field.type === "upload";
|
|
32
|
+
const missing = next == null || next === "" || (Array.isArray(next) && next.length === 0);
|
|
33
|
+
const parent = path.split(".").slice(0, -1).join(".");
|
|
34
|
+
const required = relation && field.required && missing && touched(parent || path);
|
|
35
|
+
if (!touched(path) && !required)
|
|
36
|
+
continue;
|
|
37
|
+
const condition = fieldCondition(field);
|
|
38
|
+
if (condition) {
|
|
39
|
+
try {
|
|
40
|
+
const relativeParent = parseFieldPath(relative).slice(0, -1);
|
|
41
|
+
const conditionSiblings = relativeParent.length
|
|
42
|
+
? getAtPath(siblings, relativeParent)
|
|
43
|
+
: siblings;
|
|
44
|
+
if (!condition(data, conditionSiblings ?? {}, {
|
|
45
|
+
blockData: blockData,
|
|
46
|
+
operation: context.operation ?? "update",
|
|
47
|
+
path: parseFieldPath(path),
|
|
48
|
+
user: context.user ?? null,
|
|
49
|
+
}))
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return `"${path}" could not be checked against its field condition.`;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (required) {
|
|
57
|
+
const format = field.polymorphic
|
|
58
|
+
? ` Set it to {"relationTo":"${field.relationTo?.[0]}","value":"<found document id>"}, not a URL.`
|
|
59
|
+
: " Set it to a found document id, not a URL.";
|
|
60
|
+
return `"${path}" is required.${format}`;
|
|
61
|
+
}
|
|
62
|
+
if (relation) {
|
|
63
|
+
const error = fieldError(field, path, next);
|
|
64
|
+
if (error)
|
|
65
|
+
return error;
|
|
66
|
+
}
|
|
67
|
+
else if ((field.type === "blocks" || field.type === "array") && Array.isArray(next)) {
|
|
68
|
+
for (const [index, row] of next.entries()) {
|
|
69
|
+
if (!isRecord(row))
|
|
70
|
+
continue;
|
|
71
|
+
const children = field.type === "blocks"
|
|
72
|
+
? field.blocks?.find((block) => block.slug === row.blockType)?.fields
|
|
73
|
+
: field.fields;
|
|
74
|
+
const error = visit(children ?? [], row, `${path}.${index}`, field.path, field.type === "blocks" ? row : blockData);
|
|
75
|
+
if (error)
|
|
76
|
+
return error;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
else if (field.fields) {
|
|
80
|
+
const error = visit(field.fields, next ?? {}, path, field.path, blockData);
|
|
81
|
+
if (error)
|
|
82
|
+
return error;
|
|
83
|
+
}
|
|
22
84
|
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
continue;
|
|
27
|
-
}
|
|
28
|
-
const error = fieldError(field, path, getAtPath(data, parseFieldPath(path)));
|
|
29
|
-
if (error) {
|
|
30
|
-
return error;
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
return null;
|
|
85
|
+
return null;
|
|
86
|
+
};
|
|
87
|
+
return visit(fields, data);
|
|
34
88
|
}
|
|
35
89
|
function fieldError(field, path, value) {
|
|
36
90
|
const noun = field.type === "upload" ? "image" : "document";
|
|
@@ -41,7 +95,16 @@ function fieldError(field, path, value) {
|
|
|
41
95
|
return null;
|
|
42
96
|
}
|
|
43
97
|
if (!field.hasMany) {
|
|
44
|
-
|
|
98
|
+
if (Array.isArray(value))
|
|
99
|
+
return `"${path}" takes one ${noun}: set it to a single id.`;
|
|
100
|
+
return entryError(field, path, value, noun);
|
|
101
|
+
}
|
|
102
|
+
if (Array.isArray(value) && field.polymorphic) {
|
|
103
|
+
for (const entry of value) {
|
|
104
|
+
const error = entryError(field, path, entry, noun);
|
|
105
|
+
if (error)
|
|
106
|
+
return error;
|
|
107
|
+
}
|
|
45
108
|
}
|
|
46
109
|
const ids = Array.isArray(value) ? value.map(idOf) : undefined;
|
|
47
110
|
if (!ids || ids.some((id) => id === undefined)) {
|
|
@@ -58,6 +121,19 @@ function fieldError(field, path, value) {
|
|
|
58
121
|
}
|
|
59
122
|
return null;
|
|
60
123
|
}
|
|
124
|
+
function entryError(field, path, value, noun) {
|
|
125
|
+
if (field.polymorphic) {
|
|
126
|
+
return isRecord(value) &&
|
|
127
|
+
value.relationTo === field.relationTo?.[0] &&
|
|
128
|
+
idOf(value.value) !== undefined
|
|
129
|
+
? null
|
|
130
|
+
: `"${path}" needs {"relationTo":"${field.relationTo?.[0]}","value":"<found ${noun} id>"}. A URL or bare id cannot fill this field.`;
|
|
131
|
+
}
|
|
132
|
+
return idOf(value) !== undefined ? null : `"${path}" needs a valid ${noun} id, not a URL.`;
|
|
133
|
+
}
|
|
134
|
+
function isRecord(value) {
|
|
135
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
136
|
+
}
|
|
61
137
|
/** An id, or a document as Admin may hold a chosen one. */
|
|
62
138
|
function idOf(entry) {
|
|
63
139
|
if (typeof entry === "number") {
|
package/dist/runtime/agent.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { PayloadRequest } from "payload";
|
|
2
|
+
import type { ChatImage } from "../chat-images.ts";
|
|
2
3
|
import type { ValidatedEditorAssistantOptions } from "../config/validate.ts";
|
|
3
4
|
import type { ProposalLine, ProposalPreview } from "../domain/propose.ts";
|
|
4
5
|
import { type ToolContext, type ToolOutcome } from "./tools.ts";
|
|
@@ -30,9 +31,10 @@ export type AgentChatResult = {
|
|
|
30
31
|
error: string;
|
|
31
32
|
message?: string;
|
|
32
33
|
};
|
|
33
|
-
type ChatMessage = {
|
|
34
|
+
export type ChatMessage = {
|
|
34
35
|
role: "user" | "assistant" | "system";
|
|
35
36
|
text: string;
|
|
37
|
+
images?: ChatImage[];
|
|
36
38
|
};
|
|
37
39
|
/** What the assistant is doing right now, for the panel to show while the turn runs. */
|
|
38
40
|
export type AgentStep = "thinking" | "searching" | "reading" | "reading_form" | "viewing" | "proposing" | "drafting" | "navigating" | "writing";
|