@simmalugnt-se/payload-editor-assistant 0.2.0 → 0.3.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 (40) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +29 -3
  3. package/dist/collections/approvals.js +3 -2
  4. package/dist/collections/audit.js +1 -1
  5. package/dist/components/ChangedFieldsHighlight.d.ts +8 -0
  6. package/dist/components/ChangedFieldsHighlight.js +50 -0
  7. package/dist/components/EditorAssistantAside.js +169 -63
  8. package/dist/components/EditorAssistantDocumentBridge.js +52 -1
  9. package/dist/components/EditorAssistantProvider.js +2 -1
  10. package/dist/components/assistant-context.d.ts +14 -0
  11. package/dist/components/assistant-context.js +4 -0
  12. package/dist/components/copy.d.ts +109 -0
  13. package/dist/components/copy.js +109 -0
  14. package/dist/components/selection-dom.d.ts +16 -0
  15. package/dist/components/selection-dom.js +104 -0
  16. package/dist/domain/propose.d.ts +7 -1
  17. package/dist/domain/propose.js +8 -3
  18. package/dist/endpoints/chat.js +2 -0
  19. package/dist/form/changed-paths.d.ts +6 -0
  20. package/dist/form/changed-paths.js +26 -0
  21. package/dist/form/diff.js +1 -22
  22. package/dist/form/empty.js +1 -19
  23. package/dist/form/lexical.d.ts +8 -0
  24. package/dist/form/lexical.js +24 -0
  25. package/dist/runtime/agent.d.ts +4 -1
  26. package/dist/runtime/agent.js +21 -8
  27. package/dist/runtime/context.d.ts +3 -0
  28. package/dist/runtime/context.js +10 -3
  29. package/dist/runtime/propose-loop.d.ts +9 -0
  30. package/dist/runtime/propose-loop.js +13 -1
  31. package/dist/runtime/selection.d.ts +28 -0
  32. package/dist/runtime/selection.js +87 -0
  33. package/dist/runtime/system-prompt.js +17 -1
  34. package/dist/runtime/tools.d.ts +7 -0
  35. package/dist/runtime/tools.js +2 -1
  36. package/dist/runtime/voice.js +2 -2
  37. package/dist/schema/fields.d.ts +1 -0
  38. package/dist/schema/fields.js +1 -0
  39. package/dist/styles/assistant.css +210 -181
  40. package/package.json +1 -1
@@ -28,7 +28,8 @@ export async function runAgentTurn(req, options, input) {
28
28
  const signal = req.signal ? AbortSignal.any([req.signal, timeout]) : timeout;
29
29
  const history = truncateMessages(input.messages ?? [{ role: "user", text: input.message }]);
30
30
  const captured = [];
31
- const tools = buildSdkTools(ai, ctx, captured);
31
+ const previews = new Map();
32
+ const tools = buildSdkTools(ai, ctx, captured, previews);
32
33
  const turnContext = await loadTurnContext(req, options, input.bridge, input.formData);
33
34
  const system = buildSystemPrompt(options.instructions, input.bridge, turnContext);
34
35
  const sdkMessages = toSdkMessages(history);
@@ -45,7 +46,7 @@ export async function runAgentTurn(req, options, input) {
45
46
  logProviderError(req, "the model returned no result");
46
47
  return { ok: false, error: "provider_error" };
47
48
  }
48
- const collected = collectResult(String(first.text ?? ""), first, captured, input.bridge.language ?? input.bridge.locale);
49
+ const collected = collectResult(String(first.text ?? ""), first, captured, input.bridge.language ?? input.bridge.locale, false, previews);
49
50
  if (!collected.ok) {
50
51
  return collected;
51
52
  }
@@ -54,11 +55,12 @@ export async function runAgentTurn(req, options, input) {
54
55
  pendingApproval: collected.pendingApproval,
55
56
  navigate: collected.navigate,
56
57
  text: String(first.text ?? ""),
58
+ request: input.message,
57
59
  })) {
58
60
  return collected;
59
61
  }
60
62
  const retryCaptured = [];
61
- const retryTools = buildSdkTools(ai, ctx, retryCaptured);
63
+ const retryTools = buildSdkTools(ai, ctx, retryCaptured, previews);
62
64
  const retry = await generate(ai, {
63
65
  model,
64
66
  system,
@@ -75,9 +77,9 @@ export async function runAgentTurn(req, options, input) {
75
77
  signal,
76
78
  });
77
79
  if (!retry) {
78
- return collectResult(String(first.text ?? ""), first, captured, input.bridge.language ?? input.bridge.locale, true);
80
+ return collectResult(String(first.text ?? ""), first, captured, input.bridge.language ?? input.bridge.locale, true, previews);
79
81
  }
80
- return collectResult(String(retry.text ?? ""), retry, retryCaptured, input.bridge.language ?? input.bridge.locale, true);
82
+ return collectResult(String(retry.text ?? ""), retry, retryCaptured, input.bridge.language ?? input.bridge.locale, true, previews);
81
83
  }
82
84
  catch (error) {
83
85
  if (signal.aborted) {
@@ -91,7 +93,7 @@ export async function runAgentTurn(req, options, input) {
91
93
  };
92
94
  }
93
95
  }
94
- export function collectResult(text, result, captured, locale, expectedProposal = false) {
96
+ export function collectResult(text, result, captured, locale, expectedProposal = false, previews) {
95
97
  const outcomes = [...captured];
96
98
  harvestOutcomes(outcomes, result.toolResults);
97
99
  if (Array.isArray(result.steps)) {
@@ -116,6 +118,10 @@ export function collectResult(text, result, captured, locale, expectedProposal =
116
118
  proposeFailed = true;
117
119
  }
118
120
  }
121
+ const preview = pendingApproval && previews?.get(pendingApproval.approvalId);
122
+ if (pendingApproval && preview) {
123
+ pendingApproval = { ...pendingApproval, preview };
124
+ }
119
125
  return {
120
126
  ok: true,
121
127
  text: editorFacingText({
@@ -162,7 +168,7 @@ function isToolOutcome(value) {
162
168
  const record = value;
163
169
  return typeof record.ok === "boolean" && typeof record.capability === "string";
164
170
  }
165
- function buildSdkTools(ai, ctx, captured) {
171
+ function buildSdkTools(ai, ctx, captured, previews) {
166
172
  const tools = {};
167
173
  for (const capability of Object.keys(TOOL_JSON_SCHEMAS)) {
168
174
  const schema = TOOL_JSON_SCHEMAS[capability];
@@ -174,7 +180,14 @@ function buildSdkTools(ai, ctx, captured) {
174
180
  execute: async (raw) => {
175
181
  try {
176
182
  const input = raw && typeof raw === "object" ? raw : {};
177
- const outcome = await executeTool(fromProviderToolName(name), input, ctx);
183
+ const executed = await executeTool(fromProviderToolName(name), input, ctx);
184
+ // The whole form with the change applied is for Admin, not another round through the model.
185
+ const { preview, ...outcome } = executed.ok
186
+ ? executed
187
+ : { ...executed, preview: undefined };
188
+ if (preview && executed.ok && executed.pendingApproval) {
189
+ previews.set(executed.pendingApproval.approvalId, preview);
190
+ }
178
191
  captured.push(outcome);
179
192
  return outcome;
180
193
  }
@@ -1,6 +1,7 @@
1
1
  import type { PayloadRequest } from "payload";
2
2
  import type { ValidatedEditorAssistantOptions } from "../config/validate.ts";
3
3
  import type { AdminView } from "../view.ts";
4
+ import { type Selection } from "./selection.ts";
4
5
  import { type ToolBridge } from "./tools.ts";
5
6
  export type TurnContext = {
6
7
  allowlist: Array<{
@@ -31,6 +32,8 @@ export type TurnContext = {
31
32
  } | null;
32
33
  view?: AdminView;
33
34
  form?: Record<string, unknown>;
35
+ /** What the editor clicked or focused; "this" in their message refers to it. */
36
+ selection?: Selection;
34
37
  otherLanguages?: Record<string, Record<string, unknown>>;
35
38
  documents: Record<string, unknown>[];
36
39
  };
@@ -1,5 +1,6 @@
1
1
  import { findContent, findContentById } from "../domain/content.js";
2
- import { configuredLocales, discoverSchema } from "../schema/discover.js";
2
+ import { configuredLocales, discoverSchema, entityFields, } from "../schema/discover.js";
3
+ import { resolveSelection } from "./selection.js";
3
4
  import { executeTool } from "./tools.js";
4
5
  export async function loadTurnContext(req, options, bridge, formData) {
5
6
  const ctx = { req, options, bridge, formData };
@@ -13,7 +14,12 @@ export async function loadTurnContext(req, options, bridge, formData) {
13
14
  locale: bridge.locale,
14
15
  }
15
16
  : null;
16
- const form = open ? await readOpenForm(ctx) : undefined;
17
+ const form = open
18
+ ? ((await readOpenForm(ctx)) ?? (await readSavedDocument(req, options, bridge)))
19
+ : undefined;
20
+ const selection = open
21
+ ? resolveSelection(bridge.selection, form, entityFields(req, options, { collection: bridge.collection, global: bridge.global }))
22
+ : undefined;
17
23
  const listed = await findContent(req, options, {
18
24
  collection: bridge.collection ?? (bridge.view?.kind === "list" ? bridge.view.collection : undefined),
19
25
  query: "",
@@ -24,7 +30,8 @@ export async function loadTurnContext(req, options, bridge, formData) {
24
30
  allowlist: schema.map(compactEntity),
25
31
  open,
26
32
  view: bridge.view,
27
- form: form ?? (open ? await readSavedDocument(req, options, bridge) : undefined),
33
+ form,
34
+ selection,
28
35
  otherLanguages,
29
36
  documents: listed.ok ? listed.results : [],
30
37
  };
@@ -1,7 +1,16 @@
1
1
  export declare function claimedUnstagedChange(text: string): boolean;
2
+ /**
3
+ * The editor asked for a change and the reply writes a value out in chat ("a tighter version could
4
+ * be "Visual editing, live"") instead of staging it. Phrasings vary too much to list, so this only
5
+ * needs the request and a quoted value. A question ("what is this for?") is not a request, so a
6
+ * value the model volunteers while answering it stays advice.
7
+ */
8
+ export declare function offeredValue(text: string, request: string): boolean;
2
9
  export declare function needsProposeRetry(input: {
3
10
  hasOpenDocument: boolean;
4
11
  pendingApproval?: unknown;
5
12
  navigate?: unknown;
6
13
  text: string;
14
+ /** The editor's latest message. */
15
+ request?: string;
7
16
  }): boolean;
@@ -1,11 +1,23 @@
1
1
  export function claimedUnstagedChange(text) {
2
2
  return CLAIMED_CHANGE.test(text) || ASKED_TO_MUTATE.test(text);
3
3
  }
4
+ /**
5
+ * The editor asked for a change and the reply writes a value out in chat ("a tighter version could
6
+ * be "Visual editing, live"") instead of staging it. Phrasings vary too much to list, so this only
7
+ * needs the request and a quoted value. A question ("what is this for?") is not a request, so a
8
+ * value the model volunteers while answering it stays advice.
9
+ */
10
+ export function offeredValue(text, request) {
11
+ return QUOTED.test(text) && ASKS_FOR_CHANGE.test(request);
12
+ }
13
+ // Letter lookarounds rather than \b, which treats å, ä and ö as word boundaries.
14
+ const ASKS_FOR_CHANGE = /(?<!\p{L})(make|change|rewrite|write|add|replace|shorten|tighten|fill|translate|update|improve|set|rename|remove|delete|gör|göra|ändra|skriv|skriva|lägg|lägga|byt|byta|korta|fyll|fylla|översätt|uppdatera|förbättra|sätt|ta bort)(?!\p{L})/iu;
15
+ const QUOTED = /["“”«»][^"“”«»]{2,}["“”«»]|'[^']{2,}'/;
4
16
  const CLAIMED_CHANGE = /godkänn|godkann|approve|rubriken är nu|the headline is now|sparas den på sidan|fälten i på sidan|pending form change|i've applied|i have applied/i;
5
17
  const ASKED_TO_MUTATE = /ska jag (lägga|fylla|ändra|sätta|uppdatera)|should i (put|fill|change|set|update|apply)|shall i (put|fill|change|set|update|apply)|want me to (put|fill|change|set|update|apply)/i;
6
18
  export function needsProposeRetry(input) {
7
19
  if (!input.hasOpenDocument || input.pendingApproval || input.navigate) {
8
20
  return false;
9
21
  }
10
- return claimedUnstagedChange(input.text);
22
+ return claimedUnstagedChange(input.text) || offeredValue(input.text, input.request ?? "");
11
23
  }
@@ -0,0 +1,28 @@
1
+ import type { FieldNode } from "../schema/fields.ts";
2
+ /** What the editor last clicked or focused in the open form, resolved against the visible fields. */
3
+ export type Selection = {
4
+ /** Form path, e.g. `layout.2.headline` or `layout.2` for a whole block. */
5
+ path: string;
6
+ /** Visible labels from the top down, e.g. `["Hero 3", "Headline"]`. */
7
+ trail: string[];
8
+ /** Innermost block or array row the selection sits in. */
9
+ scope?: {
10
+ path: string;
11
+ kind: "block" | "row";
12
+ label: string;
13
+ blockType?: string;
14
+ };
15
+ /** The selected field when the selection is a single value rather than a whole row. */
16
+ field?: {
17
+ path: string;
18
+ label: string;
19
+ type: string;
20
+ value?: unknown;
21
+ };
22
+ };
23
+ /**
24
+ * Resolves a path the browser sent against the projected form and the allowlisted fields. Anything
25
+ * the assistant may not see (denied, excluded, missing) resolves to nothing, so a crafted path
26
+ * cannot pull hidden values into the prompt.
27
+ */
28
+ export declare function resolveSelection(raw: unknown, form: Record<string, unknown> | undefined, fields: FieldNode[]): Selection | undefined;
@@ -0,0 +1,87 @@
1
+ import { isLexical, lexicalPlaintext } from "../form/lexical.js";
2
+ import { getAtPath, parseFieldPath } from "../form/path.js";
3
+ const PATH = /^[A-Za-z0-9_]+(\.[A-Za-z0-9_]+)*$/;
4
+ const MAX_VALUE_CHARS = 600;
5
+ const CONTAINER_TYPES = new Set(["group", "array", "blocks"]);
6
+ /**
7
+ * Resolves a path the browser sent against the projected form and the allowlisted fields. Anything
8
+ * the assistant may not see (denied, excluded, missing) resolves to nothing, so a crafted path
9
+ * cannot pull hidden values into the prompt.
10
+ */
11
+ export function resolveSelection(raw, form, fields) {
12
+ if (typeof raw !== "string" || !PATH.test(raw) || !form) {
13
+ return undefined;
14
+ }
15
+ const segments = parseFieldPath(raw);
16
+ const trail = [];
17
+ let nodes = fields;
18
+ let node;
19
+ let scope;
20
+ let data = form;
21
+ const walked = [];
22
+ for (const segment of segments) {
23
+ walked.push(segment);
24
+ if (typeof segment === "number") {
25
+ if (!node || (node.type !== "array" && node.type !== "blocks")) {
26
+ return undefined;
27
+ }
28
+ const item = getAtPath(data, [segment]);
29
+ if (!item || typeof item !== "object") {
30
+ return undefined;
31
+ }
32
+ const path = walked.join(".");
33
+ if (node.type === "blocks") {
34
+ const blockType = item.blockType;
35
+ const block = node.blocks?.find((entry) => entry.slug === blockType);
36
+ if (!block) {
37
+ return undefined;
38
+ }
39
+ const label = `${block.label ?? block.slug} ${segment + 1}`;
40
+ scope = { path, kind: "block", label, blockType: block.slug };
41
+ nodes = block.fields;
42
+ trail.push(label);
43
+ }
44
+ else {
45
+ const label = `${node.label ?? node.name} ${segment + 1}`;
46
+ scope = { path, kind: "row", label };
47
+ nodes = node.fields ?? [];
48
+ trail.push(label);
49
+ }
50
+ data = item;
51
+ node = undefined;
52
+ continue;
53
+ }
54
+ node = nodes.find((entry) => entry.name === segment);
55
+ if (!node) {
56
+ return undefined;
57
+ }
58
+ // A list's own label repeats in its rows' labels ("Cards 2"), so only rows name it.
59
+ if (node.type !== "array" && node.type !== "blocks") {
60
+ trail.push(node.label ?? node.name);
61
+ }
62
+ data = getAtPath(data, [segment]);
63
+ if (node.type === "group") {
64
+ nodes = node.fields ?? [];
65
+ }
66
+ }
67
+ const field = node && !CONTAINER_TYPES.has(node.type)
68
+ ? { path: raw, label: node.label ?? node.name, type: node.type, value: preview(data) }
69
+ : undefined;
70
+ return { path: raw, trail, scope, field };
71
+ }
72
+ function preview(value) {
73
+ if (value === undefined || value === null) {
74
+ return undefined;
75
+ }
76
+ if (isLexical(value)) {
77
+ return preview(lexicalPlaintext(value));
78
+ }
79
+ if (typeof value === "string") {
80
+ return value.length <= MAX_VALUE_CHARS ? value : `${value.slice(0, MAX_VALUE_CHARS)}…`;
81
+ }
82
+ const json = JSON.stringify(value);
83
+ if (json === undefined || json.length <= MAX_VALUE_CHARS) {
84
+ return value;
85
+ }
86
+ return `${json.slice(0, MAX_VALUE_CHARS)}…`;
87
+ }
@@ -30,10 +30,20 @@ function openDocumentLine(bridge, turnContext) {
30
30
  const sources = turnContext?.otherLanguages
31
31
  ? " Other language versions of this same page are in the quoted context. If the editor asks to fill or translate empty fields, call form.propose with text written in the open language. Translate from the source language; do not copy the source wording unless they asked to copy it unchanged. Do not ask them to paste. Do not say the source is missing if it is in the context. If a layout block already exists, set its empty text fields; do not insert a new block."
32
32
  : "";
33
- return `The editor is looking at ${title} in ${language}. Do not ask them which document it is.${sources}`;
33
+ return `The editor is looking at ${title} in ${language}. Do not ask them which document it is.${sources}${selectionLine(turnContext)}`;
34
34
  }
35
35
  return `${viewLine(turnContext)}No document is open. You can search, navigate, or create a draft. Form edits need an open document.`;
36
36
  }
37
+ function selectionLine(turnContext) {
38
+ const selection = turnContext?.selection;
39
+ if (!selection) {
40
+ return "";
41
+ }
42
+ const scope = selection.field
43
+ ? "change only that field unless they ask for more"
44
+ : "change only fields inside it unless they ask for more";
45
+ return ` The editor has selected ${selection.trail.join(" › ")} in the form or preview. When they say "this", "here", "den här", "det här" or name no place, they mean that selection: ${scope}. Refer to it by its labels.`;
46
+ }
37
47
  function viewLine(turnContext) {
38
48
  const view = turnContext?.view;
39
49
  if (view?.kind === "dashboard") {
@@ -65,6 +75,12 @@ function thisTurnBlock(bridge, turnContext) {
65
75
  lines.push(`The editor chats in ${languageName(chat)}, so say in one short sentence that the content is written in ${content}.`);
66
76
  }
67
77
  }
78
+ const selection = turnContext?.selection;
79
+ if (selection) {
80
+ lines.push(selection.field
81
+ ? `Selected field: ${selection.field.path}.`
82
+ : `Selected: ${selection.path}; its fields are under that path in the form.`);
83
+ }
68
84
  const form = turnContext?.form;
69
85
  if (form) {
70
86
  const empty = listEmptyTextPaths(form);
@@ -1,5 +1,6 @@
1
1
  import type { PayloadRequest } from "payload";
2
2
  import type { ValidatedEditorAssistantOptions } from "../config/validate.ts";
3
+ import { type ProposalPreview } from "../domain/propose.ts";
3
4
  import type { AdminView } from "../view.ts";
4
5
  import type { ChatLanguage } from "./language.ts";
5
6
  export type ToolBridge = {
@@ -11,8 +12,12 @@ export type ToolBridge = {
11
12
  locale: string;
12
13
  /** Language the editor chats in; may differ from `locale`. */
13
14
  language?: ChatLanguage;
15
+ /** Admin's language, for what the panel shows around a proposal (its title and change list). */
16
+ uiLanguage?: ChatLanguage;
14
17
  /** Where the editor is in Admin. Context only; tools act on the open document fields above. */
15
18
  view?: AdminView;
19
+ /** Form path of what the editor last clicked or focused in the open form, unverified. */
20
+ selection?: string;
16
21
  };
17
22
  export type ToolContext = {
18
23
  req: PayloadRequest;
@@ -40,6 +45,8 @@ export type ToolSuccess = {
40
45
  label: string;
41
46
  }>;
42
47
  };
48
+ /** For Admin only; the agent keeps it out of what the model sees. */
49
+ preview?: ProposalPreview;
43
50
  };
44
51
  export type ToolFailure = {
45
52
  ok: false;
@@ -155,7 +155,7 @@ export async function executeTool(name, input, ctx) {
155
155
  global: name === "form.propose" ? ctx.bridge.global : undefined,
156
156
  documentId: name === "form.propose" ? ctx.bridge.documentId : undefined,
157
157
  locale: ctx.bridge.locale,
158
- language: ctx.bridge.language,
158
+ language: ctx.bridge.uiLanguage ?? ctx.bridge.language,
159
159
  operations: input.operations ?? input.patches ?? input.changes ?? input.ops,
160
160
  formData: name === "form.propose" ? ctx.formData : undefined,
161
161
  });
@@ -172,6 +172,7 @@ export async function executeTool(name, input, ctx) {
172
172
  nonce: proposed.nonce,
173
173
  capability: name,
174
174
  },
175
+ preview: proposed.preview,
175
176
  };
176
177
  }
177
178
  default:
@@ -30,11 +30,11 @@ export function approvalCopy(locale, capability) {
30
30
  if (isSwedish(locale)) {
31
31
  return capability === "draft.create"
32
32
  ? "Utkastet ligger i panelen. Godkänn det så skapas det."
33
- : "Förslaget ligger i panelen. Godkänn det så fylls fälten i. Spara sedan som vanligt.";
33
+ : "Jag har lagt in förslaget i formuläret. Behåll det eller ångra.";
34
34
  }
35
35
  return capability === "draft.create"
36
36
  ? "The draft is ready in the panel. Approve it to create it."
37
- : "The change is ready in the panel. Approve it to fill the fields, then save as usual.";
37
+ : "I've put the change in the form. Keep it or undo it.";
38
38
  }
39
39
  export function couldNotStageCopy(locale) {
40
40
  return isSwedish(locale)
@@ -13,6 +13,7 @@ export type FieldNode = {
13
13
  fields?: FieldNode[];
14
14
  blocks?: Array<{
15
15
  slug: string;
16
+ label?: string;
16
17
  fields: FieldNode[];
17
18
  }>;
18
19
  };
@@ -68,6 +68,7 @@ function walkField(field, parentPath, allowlist, denyFields) {
68
68
  .filter((block) => Boolean(block.slug))
69
69
  .map((block) => ({
70
70
  slug: block.slug,
71
+ label: asLabel(block.labels?.singular),
71
72
  fields: walkFields(block.fields, path, allowlist, denyFields),
72
73
  }));
73
74
  }