@simmalugnt-se/payload-editor-assistant 0.12.1 → 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.
- package/CHANGELOG.md +30 -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.d.ts +1 -1
- package/dist/components/EditorAssistantAside.js +31 -17
- package/dist/components/EditorAssistantDocumentBridge.js +1 -1
- 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/batch-link.js +2 -1
- 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 +10 -3
- 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 +39 -7
- 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/batch-revision.d.ts +6 -0
- package/dist/form/batch-revision.js +39 -0
- 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 +51 -0
- package/package.json +1 -1
|
@@ -12,6 +12,13 @@ export declare const COPY: {
|
|
|
12
12
|
readonly collapseLabel: "Collapse assistant";
|
|
13
13
|
readonly placeholder: "Message the assistant";
|
|
14
14
|
readonly send: "Send";
|
|
15
|
+
readonly attachImage: "Add images";
|
|
16
|
+
readonly removeImage: "Remove image";
|
|
17
|
+
readonly imageOmitted: "Older image omitted";
|
|
18
|
+
readonly imagePreparing: "Preparing images…";
|
|
19
|
+
readonly imageLimit: "Attach up to three images per message.";
|
|
20
|
+
readonly imageTooLarge: "These images are too large. Try fewer or smaller images.";
|
|
21
|
+
readonly imageInvalid: "Choose a PNG, JPEG, WebP or GIF image up to 10 MB.";
|
|
15
22
|
readonly stop: "Stop";
|
|
16
23
|
readonly working: "Working…";
|
|
17
24
|
readonly steps: {
|
|
@@ -75,6 +82,13 @@ export declare const COPY: {
|
|
|
75
82
|
readonly collapseLabel: "Fäll ihop assistenten";
|
|
76
83
|
readonly placeholder: "Skriv till assistenten";
|
|
77
84
|
readonly send: "Skicka";
|
|
85
|
+
readonly attachImage: "Lägg till bilder";
|
|
86
|
+
readonly removeImage: "Ta bort bild";
|
|
87
|
+
readonly imageOmitted: "Äldre bild utelämnad";
|
|
88
|
+
readonly imagePreparing: "Förbereder bilder…";
|
|
89
|
+
readonly imageLimit: "Bifoga högst tre bilder per meddelande.";
|
|
90
|
+
readonly imageTooLarge: "Bilderna är för stora. Prova färre eller mindre bilder.";
|
|
91
|
+
readonly imageInvalid: "Välj en PNG-, JPEG-, WebP- eller GIF-bild på högst 10 MB.";
|
|
78
92
|
readonly stop: "Stoppa";
|
|
79
93
|
readonly working: "Arbetar…";
|
|
80
94
|
readonly steps: {
|
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
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { hashBatchRevision } from "../form/batch-revision.js";
|
|
1
2
|
import { changedPaths } from "../form/changed-paths.js";
|
|
2
3
|
import { compileOperations } from "../form/compile.js";
|
|
3
4
|
import { hashFormRevision } from "../form/hash.js";
|
|
@@ -188,10 +189,13 @@ export async function applyBatchTarget(req, options, input) {
|
|
|
188
189
|
if (!saved) {
|
|
189
190
|
return fail("tool_denied", "Document is not accessible.");
|
|
190
191
|
}
|
|
191
|
-
|
|
192
|
+
const fields = entityFields(req, options, { collection: record.targetCollection });
|
|
193
|
+
const revision = record.formRevision?.startsWith("batch-v1:")
|
|
194
|
+
? await hashBatchRevision(saved.form, fields)
|
|
195
|
+
: await hashFormRevision(saved.form);
|
|
196
|
+
if (revision !== record.formRevision) {
|
|
192
197
|
return fail("stale_context", "The document changed after the proposal.");
|
|
193
198
|
}
|
|
194
|
-
const fields = entityFields(req, options, { collection: record.targetCollection });
|
|
195
199
|
const edited = withEdits(stored, input.edits, fields, saved.form);
|
|
196
200
|
if (!edited.ok) {
|
|
197
201
|
// Not the editor's approval to spend: the card can still be saved without the edit.
|
|
@@ -210,7 +214,10 @@ export async function applyBatchTarget(req, options, input) {
|
|
|
210
214
|
return fail("tool_denied", finalized.error);
|
|
211
215
|
}
|
|
212
216
|
const paths = changedPaths(operations);
|
|
213
|
-
const relationError = relationValueError(fields, finalized.data, paths
|
|
217
|
+
const relationError = relationValueError(fields, finalized.data, paths, {
|
|
218
|
+
user: req.user,
|
|
219
|
+
operation: "update",
|
|
220
|
+
});
|
|
214
221
|
if (relationError) {
|
|
215
222
|
return fail("tool_denied", relationError);
|
|
216
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
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { hashBatchRevision } from "../form/batch-revision.js";
|
|
1
2
|
import { changedPaths } from "../form/changed-paths.js";
|
|
2
3
|
import { compileOperations } from "../form/compile.js";
|
|
3
4
|
import { humanDiff, richTextLines } from "../form/diff.js";
|
|
@@ -16,6 +17,7 @@ import { hasCapability, resolveAllowlist } from "./allowlist.js";
|
|
|
16
17
|
import { writeAudit } from "./audit.js";
|
|
17
18
|
import { readDocument } from "./content.js";
|
|
18
19
|
import { localApi } from "./local-api.js";
|
|
20
|
+
import { newBlockReview } from "./new-block-review.js";
|
|
19
21
|
import { labelRelations, verifyDocumentRelations } from "./relations.js";
|
|
20
22
|
/** Long enough to look at a change in the preview before keeping it. */
|
|
21
23
|
const TTL_MS = 30 * 60_000;
|
|
@@ -68,7 +70,10 @@ export async function proposeChange(req, options, input) {
|
|
|
68
70
|
if (!finalized.ok) {
|
|
69
71
|
return { ok: false, error: "tool_denied", message: finalized.error };
|
|
70
72
|
}
|
|
71
|
-
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
|
+
});
|
|
72
77
|
if (relationError) {
|
|
73
78
|
return { ok: false, error: "tool_denied", message: relationError };
|
|
74
79
|
}
|
|
@@ -85,7 +90,11 @@ export async function proposeChange(req, options, input) {
|
|
|
85
90
|
if (!relations.ok) {
|
|
86
91
|
return { ok: false, error: "tool_denied", message: relations.message };
|
|
87
92
|
}
|
|
88
|
-
const formRevision = input.capability === "draft.create"
|
|
93
|
+
const formRevision = input.capability === "draft.create"
|
|
94
|
+
? undefined
|
|
95
|
+
: input.capability === "content.update"
|
|
96
|
+
? await hashBatchRevision(current, fields)
|
|
97
|
+
: await hashFormRevision(current);
|
|
89
98
|
const canonicalHash = await hashFormRevision(typedOperations);
|
|
90
99
|
const nonce = crypto.randomUUID();
|
|
91
100
|
const expiresAt = new Date(Date.now() + TTL_MS).toISOString();
|
|
@@ -93,8 +102,9 @@ export async function proposeChange(req, options, input) {
|
|
|
93
102
|
const setIds = new Set(typedOperations.flatMap((operation) => operation.op === "set"
|
|
94
103
|
? [operation.value, getAtPath(current, parseFieldPath(operation.path))].flatMap(idsIn)
|
|
95
104
|
: []));
|
|
96
|
-
const
|
|
97
|
-
|
|
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)
|
|
98
108
|
: undefined;
|
|
99
109
|
const inRichText = (operation) => "path" in operation &&
|
|
100
110
|
richTextBefore.some(({ path }) => operation.path === path || operation.path.startsWith(`${path}.`));
|
|
@@ -103,8 +113,23 @@ export async function proposeChange(req, options, input) {
|
|
|
103
113
|
...humanDiff(current, typedOperations.filter((operation) => !inRichText(operation)), input.language, labels, (path) => fieldLabel(path).full),
|
|
104
114
|
...richTextBefore.flatMap(({ path, blocks, after }) => richTextLines(path, blocks, Array.isArray(after) ? after : [after], input.language, (path) => fieldLabel(path).full)),
|
|
105
115
|
];
|
|
106
|
-
const
|
|
107
|
-
|
|
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))
|
|
108
133
|
.map((line) => {
|
|
109
134
|
const { context, label, groups } = fieldLabel(line.path);
|
|
110
135
|
return {
|
|
@@ -116,6 +141,8 @@ export async function proposeChange(req, options, input) {
|
|
|
116
141
|
};
|
|
117
142
|
});
|
|
118
143
|
for (const { path } of richTextBefore) {
|
|
144
|
+
if (addedPaths.has(path) || reviewedBlockField(path))
|
|
145
|
+
continue;
|
|
119
146
|
const { context, label, groups } = fieldLabel(path);
|
|
120
147
|
lines.push({
|
|
121
148
|
path,
|
|
@@ -132,6 +159,7 @@ export async function proposeChange(req, options, input) {
|
|
|
132
159
|
},
|
|
133
160
|
});
|
|
134
161
|
}
|
|
162
|
+
lines.push(...added.lines);
|
|
135
163
|
let created;
|
|
136
164
|
try {
|
|
137
165
|
created = await localApi(req).create({
|
|
@@ -305,7 +333,11 @@ function idsIn(value) {
|
|
|
305
333
|
return [String(value)];
|
|
306
334
|
}
|
|
307
335
|
if (Array.isArray(value)) {
|
|
308
|
-
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);
|
|
309
341
|
}
|
|
310
342
|
return [];
|
|
311
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
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { FieldNode } from "../schema/fields.ts";
|
|
2
|
+
/**
|
|
3
|
+
* A first SQL draft may materialize absent localized columns as null. Hash their empty values
|
|
4
|
+
* consistently, without altering saved content or relaxing checks on nonempty fields or row ids.
|
|
5
|
+
*/
|
|
6
|
+
export declare function hashBatchRevision(data: Record<string, unknown>, fields: FieldNode[]): Promise<string>;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { hashFormRevision } from "./hash.js";
|
|
2
|
+
/**
|
|
3
|
+
* A first SQL draft may materialize absent localized columns as null. Hash their empty values
|
|
4
|
+
* consistently, without altering saved content or relaxing checks on nonempty fields or row ids.
|
|
5
|
+
*/
|
|
6
|
+
export async function hashBatchRevision(data, fields) {
|
|
7
|
+
return `batch-v1:${await hashFormRevision(normalizeRecord(data, fields))}`;
|
|
8
|
+
}
|
|
9
|
+
function normalizeRecord(data, fields) {
|
|
10
|
+
const normalized = { ...data };
|
|
11
|
+
for (const field of fields) {
|
|
12
|
+
const value = data[field.name];
|
|
13
|
+
if (field.type === "group" && (value == null || isRecord(value))) {
|
|
14
|
+
normalized[field.name] = normalizeRecord(isRecord(value) ? value : {}, field.fields ?? []);
|
|
15
|
+
}
|
|
16
|
+
else if (field.type === "array" && Array.isArray(value)) {
|
|
17
|
+
normalized[field.name] = value.map((row) => isRecord(row) ? normalizeRecord(row, field.fields ?? []) : row);
|
|
18
|
+
}
|
|
19
|
+
else if (field.type === "blocks" && Array.isArray(value)) {
|
|
20
|
+
normalized[field.name] = value.map((row) => {
|
|
21
|
+
if (!isRecord(row))
|
|
22
|
+
return row;
|
|
23
|
+
const block = field.blocks?.find((entry) => entry.slug === row.blockType);
|
|
24
|
+
return block ? normalizeRecord(row, block.fields) : row;
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
normalized[field.name] =
|
|
29
|
+
value === undefined ||
|
|
30
|
+
((field.type === "text" || field.type === "textarea") && value === "")
|
|
31
|
+
? null
|
|
32
|
+
: value;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return normalized;
|
|
36
|
+
}
|
|
37
|
+
function isRecord(value) {
|
|
38
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
39
|
+
}
|
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;
|