@simmalugnt-se/payload-editor-assistant 0.12.2 → 0.13.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.
Files changed (38) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +37 -0
  3. package/dist/chat-images.d.ts +20 -0
  4. package/dist/chat-images.js +61 -0
  5. package/dist/components/ChatImages.d.ts +20 -0
  6. package/dist/components/ChatImages.js +113 -0
  7. package/dist/components/EditorAssistantAside.js +30 -13
  8. package/dist/components/ProposalReview.d.ts +1 -1
  9. package/dist/components/ProposalReview.js +7 -3
  10. package/dist/components/assistant-context.d.ts +2 -0
  11. package/dist/components/chat-image-file.d.ts +3 -0
  12. package/dist/components/chat-image-file.js +48 -0
  13. package/dist/components/conversation-store.js +34 -4
  14. package/dist/components/copy.d.ts +14 -0
  15. package/dist/components/copy.js +14 -0
  16. package/dist/domain/batch.js +4 -1
  17. package/dist/domain/decide.js +9 -0
  18. package/dist/domain/new-block-review.d.ts +7 -0
  19. package/dist/domain/new-block-review.js +97 -0
  20. package/dist/domain/propose.d.ts +2 -0
  21. package/dist/domain/propose.js +33 -6
  22. package/dist/endpoints/chat.d.ts +3 -0
  23. package/dist/endpoints/chat.js +31 -2
  24. package/dist/endpoints/decide.js +27 -6
  25. package/dist/form/diff.js +3 -2
  26. package/dist/form/relation-shape.d.ts +10 -5
  27. package/dist/form/relation-shape.js +104 -28
  28. package/dist/runtime/agent.d.ts +3 -1
  29. package/dist/runtime/agent.js +100 -8
  30. package/dist/runtime/context.d.ts +10 -8
  31. package/dist/runtime/context.js +16 -10
  32. package/dist/runtime/debug.d.ts +10 -0
  33. package/dist/runtime/debug.js +132 -0
  34. package/dist/runtime/system-prompt.js +4 -2
  35. package/dist/schema/fields.d.ts +6 -0
  36. package/dist/schema/fields.js +20 -2
  37. package/dist/styles/assistant.css +46 -0
  38. package/package.json +1 -1
@@ -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
  }
@@ -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
+ }
@@ -32,6 +32,8 @@ export type ProposalLine = {
32
32
  before: unknown;
33
33
  after: unknown;
34
34
  };
35
+ /** Full read-only value of a field supplied inside a new block. */
36
+ value?: string;
35
37
  edit?: {
36
38
  label: string;
37
39
  before: string;
@@ -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, changedPaths(typedOperations));
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 labels = setIds.size
102
- ? await labelRelations(req, fields, [finalized.data, current], setIds)
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 lines = diff
112
- .filter((line) => !richTextBefore.some(({ path }) => path === line.path))
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((entry) => entry && typeof entry === "object" && "id" in entry ? idsIn(entry.id) : idsIn(entry));
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
  }
@@ -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;
@@ -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.message !== "string" || body.message.trim() === "") {
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
+ }
@@ -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 result = await decideChange(req, options, {
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
- if (!result.ok) {
28
- return Response.json({ error: result.error, message: result.message }, { status: 400 });
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`, 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.
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[]): string | null;
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 { fieldAtPath } from "../schema/fields.js";
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`, 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.
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 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();
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 path = segments.join(".");
20
- if (checked.has(path)) {
21
- continue;
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
- 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;
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
- return Array.isArray(value) ? `"${path}" takes one ${noun}: set it to a single id.` : null;
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") {
@@ -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";