@simmalugnt-se/payload-editor-assistant 0.14.0 → 0.15.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 (43) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +72 -4
  3. package/dist/config/validate.d.ts +1 -0
  4. package/dist/config/validate.js +10 -0
  5. package/dist/endpoints/chat.js +2 -1
  6. package/dist/extensions/contract.d.ts +62 -0
  7. package/dist/extensions/contract.js +6 -0
  8. package/dist/extensions/input.d.ts +7 -0
  9. package/dist/extensions/input.js +81 -0
  10. package/dist/extensions/resolve.d.ts +27 -0
  11. package/dist/extensions/resolve.js +187 -0
  12. package/dist/form/relation-shape.d.ts +2 -1
  13. package/dist/form/relation-shape.js +10 -7
  14. package/dist/index.d.ts +2 -0
  15. package/dist/index.js +1 -0
  16. package/dist/plugin.js +10 -1
  17. package/dist/runtime/agent.js +16 -23
  18. package/dist/runtime/capabilities/admin-navigate.d.ts +2 -0
  19. package/dist/runtime/capabilities/admin-navigate.js +49 -0
  20. package/dist/runtime/capabilities/content-find-by-id.d.ts +2 -0
  21. package/dist/runtime/capabilities/content-find-by-id.js +25 -0
  22. package/dist/runtime/capabilities/content-find.d.ts +2 -0
  23. package/dist/runtime/capabilities/content-find.js +22 -0
  24. package/dist/runtime/capabilities/form-read.d.ts +2 -0
  25. package/dist/runtime/capabilities/form-read.js +25 -0
  26. package/dist/runtime/capabilities/index.d.ts +7 -0
  27. package/dist/runtime/capabilities/index.js +21 -0
  28. package/dist/runtime/capabilities/media-view.d.ts +2 -0
  29. package/dist/runtime/capabilities/media-view.js +48 -0
  30. package/dist/runtime/capabilities/propose.d.ts +3 -0
  31. package/dist/runtime/capabilities/propose.js +79 -0
  32. package/dist/runtime/capabilities/schema-discover.d.ts +2 -0
  33. package/dist/runtime/capabilities/schema-discover.js +17 -0
  34. package/dist/runtime/capabilities/tool.d.ts +33 -0
  35. package/dist/runtime/capabilities/tool.js +12 -0
  36. package/dist/runtime/system-prompt.d.ts +2 -1
  37. package/dist/runtime/system-prompt.js +29 -8
  38. package/dist/runtime/tools.d.ts +10 -6
  39. package/dist/runtime/tools.js +15 -243
  40. package/dist/types.d.ts +7 -0
  41. package/dist/view.d.ts +10 -2
  42. package/dist/view.js +13 -3
  43. package/package.json +2 -6
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.15.0
4
+
5
+ - Extensions: other packages, or the project, give the assistant tools, instructions and
6
+ descriptions of their Admin views through `config.custom.editorAssistantExtensions`. Off until
7
+ the host names them in the new `extensions` option. Extension tools only read; their input is
8
+ checked, their answers bounded and every call audited. See "Extensions" in the README.
9
+ - Admin views outside collections and globals are `{ kind: "custom", path }` instead of `other` in
10
+ the panel, so a shortcut with `when: ["other"]` no longer shows there.
11
+ - The built-in tools are modules in one registry; what the model is given is unchanged.
12
+
13
+ - Upload and relationship fields for several collections, such as an image or a video, take one of
14
+ them. Before, any draft or change touching such a field was refused, even when it left the field
15
+ empty: a new page with a hero could not be created.
16
+
17
+ ## 0.14.1
18
+
19
+ - npm links to simmalugnt.se instead of the private source repository.
20
+
3
21
  ## 0.14.0
4
22
 
5
23
  - A work-list link (`?ask=`) opens the panel with the prompt as a task to start or cancel, like a
package/README.md CHANGED
@@ -94,7 +94,7 @@ alt text, which the editor keeps or undoes like any other change:
94
94
 
95
95
  ```ts
96
96
  collections: {
97
- media: { capabilities: ["form.read", "form.propose", "media.view"] },
97
+ images: { capabilities: ["form.read", "form.propose", "media.view"] },
98
98
  },
99
99
  ```
100
100
 
@@ -110,7 +110,7 @@ candidates at a time (12 per turn) and proposes one it has seen, or says none fi
110
110
 
111
111
  ```ts
112
112
  collections: {
113
- media: { capabilities: ["content.find", "form.read", "form.propose", "media.view"] },
113
+ images: { capabilities: ["content.find", "form.read", "form.propose", "media.view"] },
114
114
  },
115
115
  ```
116
116
 
@@ -182,6 +182,23 @@ prompt as a task, and the editor starts it. A link alone never makes the assista
182
182
  The plugin publishes its allowlisted collections as `config.admin.custom.editorAssistant.collections`
183
183
  so the work list knows where to offer it; the packages do not import each other.
184
184
 
185
+ ### Asking about content health
186
+
187
+ With content health's extension turned on, the assistant reads the findings itself: "what should I
188
+ fix first?" on the dashboard, or "what is wrong with this page?" on a document, gets an answer about
189
+ real pages, and it can take the editor to the work list or to "Fix N with the assistant" for one
190
+ check. The editor still starts each fix.
191
+
192
+ ```ts
193
+ editorAssistantPlugin({
194
+ extensions: { "content-health": true },
195
+ // ...
196
+ });
197
+ ```
198
+
199
+ Needs `@simmalugnt-se/payload-content-health` 0.8.0 or later. See
200
+ [Extensions](#extensions) for how other packages add abilities.
201
+
185
202
  ## Batches
186
203
 
187
204
  With `content.update` on a collection, content health's work list offers "Fix N with the assistant"
@@ -192,7 +209,7 @@ assistant's panel in batch mode beside the list, with up to 20 documents:
192
209
  ```ts
193
210
  collections: {
194
211
  pages: { capabilities: ["content.findById", "form.read", "form.propose", "content.update"] },
195
- media: { capabilities: ["content.findById", "form.read", "form.propose", "media.view", "content.update"] },
212
+ images: { capabilities: ["content.findById", "form.read", "form.propose", "media.view", "content.update"] },
196
213
  },
197
214
  ```
198
215
 
@@ -206,7 +223,7 @@ collections: {
206
223
  - Nothing is written until **Save N selected**. Checked proposals are saved one by one, each checked
207
224
  again on the server: the editor, the one-time approval, `content.update`, the document's lock, and
208
225
  that the saved document has not changed since the proposal (otherwise the card says so and
209
- nothing is written). A collection with drafts gets a draft; one without, such as media, is saved
226
+ nothing is written). A collection with drafts gets a draft; one without, such as images, is saved
210
227
  directly, and the card says which. Unchecked proposals are rejected.
211
228
  - The audit collection logs every document with `requestId: "batch:<id>"`. When the editor
212
229
  reworded a value, the executed event's `canonicalHash` differs from the proposed one's.
@@ -258,8 +275,59 @@ chat** clears the current one.
258
275
  | `validateProposal` | `({ operations, data }) => ({ ok: true } \| { ok: false, errors })` for project rules. Runs before a proposal reaches the editor; errors go back to the assistant, which can correct itself. |
259
276
  | `trustedOrigins` | Origins allowed to call the assistant's endpoints. Default: only the origin serving Admin. When set, only the listed origins. |
260
277
  | `audit.collection` | Slug of the collection that logs assistant actions (default `editor-assistant-audit`). |
278
+ | `extensions` | Abilities other packages add, turned on by id: `{ "content-health": true }`, or `{ tools: [...] }` for some of an extension's tools. Off until named. See [Extensions](#extensions). |
261
279
  | `disabled` | Turn the plugin off without removing its config. |
262
280
 
281
+ ## Extensions
282
+
283
+ Another package, or the project itself, gives the assistant abilities with an extension: short
284
+ instructions for the prompt, tools the model may call, and descriptions of Admin views it owns. It
285
+ registers the extension in `config.custom.editorAssistantExtensions` from its own plugin, without
286
+ importing this package, and the host turns it on with `extensions`. Content health is the example
287
+ (`packages/payload-content-health/src/assistant.ts`).
288
+
289
+ ```ts
290
+ import type { AssistantExtension } from "@simmalugnt-se/payload-editor-assistant";
291
+
292
+ const notes: AssistantExtension = {
293
+ id: "site-notes",
294
+ instructions: "Site notes say what editors must remember. Read them with site-notes.list.",
295
+ views: [{ path: "/site-notes", description: "The editor is on the site notes page." }],
296
+ tools: [
297
+ {
298
+ name: "site-notes.list",
299
+ description: "Lists the site's notes, newest first.",
300
+ input: { type: "object", additionalProperties: false, properties: {} },
301
+ risk: "auto",
302
+ run: async (_input, { req }) => {
303
+ const { docs } = await req.payload.find({
304
+ collection: "notes",
305
+ overrideAccess: false,
306
+ user: req.user,
307
+ });
308
+ return { ok: true, data: docs.map((doc) => doc.text) };
309
+ },
310
+ },
311
+ ],
312
+ };
313
+
314
+ // In a plugin: (config) => ({ ...config, custom: { ...config.custom,
315
+ // editorAssistantExtensions: [...(config.custom?.editorAssistantExtensions ?? []), notes] } })
316
+ ```
317
+
318
+ - **Tools only read** for now (`risk: "auto"`): changes go through the assistant's own proposals.
319
+ `run` gets the editor's request; read with `overrideAccess: false`.
320
+ - Tool names start with the extension id and a dot. The input is checked against `input` (an object
321
+ schema: strings, numbers, booleans, arrays, `enum`, `required`) before `run`.
322
+ - The answer is quoted to the model as data and cut at 12 000 characters. A thrown error becomes a
323
+ failure the model reports. Every call is written to the audit collection.
324
+ - `navigate` in a result takes the editor to that place in Admin, as the assistant's own navigation
325
+ does.
326
+ - `views` describe Admin views by path; `"/"` adds to what the assistant knows about the dashboard.
327
+ - Extension tools are not offered in a batch, which stays on its one document.
328
+ - At startup the assistant warns about invalid extensions and about ids the host named that no
329
+ package registered.
330
+
263
331
  ## Reference images in chat
264
332
 
265
333
  Use **Add images**, drop images on the assistant panel, or paste a screenshot into the message.
@@ -9,5 +9,6 @@ export type ValidatedEditorAssistantOptions = EditorAssistantPluginOptions & {
9
9
  audit: {
10
10
  collection: string;
11
11
  };
12
+ extensions: NonNullable<EditorAssistantPluginOptions["extensions"]>;
12
13
  };
13
14
  export declare function validateEditorAssistantOptions(options: EditorAssistantPluginOptions): ValidatedEditorAssistantOptions;
@@ -10,6 +10,7 @@ export function validateEditorAssistantOptions(options) {
10
10
  }
11
11
  validateEntityMap("collections", collections, { allowDraftCreate: true });
12
12
  validateEntityMap("globals", globals, { allowDraftCreate: false });
13
+ validateExtensions(options.extensions);
13
14
  }
14
15
  return {
15
16
  ...options,
@@ -22,8 +23,17 @@ export function validateEditorAssistantOptions(options) {
22
23
  audit: {
23
24
  collection: options.audit?.collection ?? "editor-assistant-audit",
24
25
  },
26
+ extensions: options.extensions ?? {},
25
27
  };
26
28
  }
29
+ function validateExtensions(extensions) {
30
+ for (const [id, setting] of Object.entries(extensions ?? {})) {
31
+ const tools = setting === true ? [] : setting?.tools;
32
+ if (!Array.isArray(tools) || tools.some((tool) => typeof tool !== "string")) {
33
+ throw new Error(`payload-editor-assistant: extensions "${id}" must be true or { tools: string[] }.`);
34
+ }
35
+ }
36
+ }
27
37
  function validateEntityMap(kind, map, rules) {
28
38
  for (const [slug, entry] of Object.entries(map)) {
29
39
  if (kind === "collections" && isDeniedCollectionSlug(slug)) {
@@ -1,4 +1,5 @@
1
1
  import { imageByteLength, isChatImage, MAX_CHAT_IMAGES, MAX_CHAT_IMAGES_BYTES, } from "../chat-images.js";
2
+ import { enabledExtensions } from "../extensions/resolve.js";
2
3
  import { ENDPOINT_PREFIX } from "../package-name.js";
3
4
  import { runAgentTurn } from "../runtime/agent.js";
4
5
  import { beginInflight, endInflight } from "../runtime/inflight.js";
@@ -68,7 +69,7 @@ export function createChatEndpoint(options) {
68
69
  locale,
69
70
  language,
70
71
  uiLanguage: nonEmpty(body.uiLanguage)?.startsWith("sv") ? "sv" : "en",
71
- view: sanitizeAdminView(body.view, options),
72
+ view: sanitizeAdminView(body.view, options, enabledExtensions(req.payload.config, options).flatMap((extension) => extension.views.map((view) => view.path))),
72
73
  selection: typeof body.selection === "string" ? body.selection : undefined,
73
74
  openProposal: sanitizeOpenProposal(body.openProposal),
74
75
  },
@@ -0,0 +1,62 @@
1
+ import type { PayloadRequest } from "payload";
2
+ /**
3
+ * Other packages give the assistant abilities through `config.custom[EXTENSIONS_CUSTOM_KEY]`, an
4
+ * array of extensions, without importing it. Copied in each package that contributes one; the
5
+ * contract tests compare the copies. The host turns each on with the plugin's `extensions` option.
6
+ */
7
+ export declare const EXTENSIONS_CUSTOM_KEY = "editorAssistantExtensions";
8
+ export type AssistantExtension = {
9
+ /** Unique, kebab-case: "content-health". Its tool names start with it and a dot. */
10
+ id: string;
11
+ /** Added to the prompt while the extension is on. Cannot override the assistant's rules. */
12
+ instructions?: string;
13
+ /**
14
+ * Admin views the extension owns, by path under the admin route ("/content-health"), and what
15
+ * the editor sees there. "/" is the dashboard: its description adds to the dashboard's.
16
+ */
17
+ views?: AssistantExtensionView[];
18
+ tools?: AssistantExtensionTool[];
19
+ };
20
+ export type AssistantExtensionView = {
21
+ path: string;
22
+ description: string;
23
+ };
24
+ export type AssistantExtensionTool = {
25
+ /** "content-health.findings": the extension id, a dot, then letters, digits or dashes. */
26
+ name: string;
27
+ /** When to use it and what it returns, written for the model: it chooses tools by this text. */
28
+ description: string;
29
+ /** JSON Schema of the input, an object; checked before `run` (see `input.ts` for the subset). */
30
+ input: Record<string, unknown>;
31
+ /** Only reads for now: the tool runs without the editor's decision. */
32
+ risk: "auto";
33
+ /** Runs on the server for the signed-in editor; read with their access (`overrideAccess: false`). */
34
+ run: (input: Record<string, unknown>, context: AssistantExtensionContext) => Promise<AssistantExtensionResult>;
35
+ };
36
+ export type AssistantExtensionContext = {
37
+ /** The editor's request: user, payload, locale. Shared by every tool call in one turn. */
38
+ req: PayloadRequest;
39
+ /** Where the editor is: "/" for the dashboard, else the path under the admin route, if known. */
40
+ path?: string;
41
+ /** The open document or global, when there is one. */
42
+ open?: {
43
+ collection?: string;
44
+ global?: string;
45
+ id?: string | number;
46
+ locale: string;
47
+ };
48
+ };
49
+ export type AssistantExtensionResult = {
50
+ ok: true;
51
+ /** What the model reads: compact, structured, quoted to it as data. */
52
+ data: unknown;
53
+ /** A place in Admin to take the editor to: the panel opens it, as with `admin.navigate`. */
54
+ navigate?: {
55
+ href: string;
56
+ label: string;
57
+ };
58
+ } | {
59
+ ok: false;
60
+ error: string;
61
+ message: string;
62
+ };
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Other packages give the assistant abilities through `config.custom[EXTENSIONS_CUSTOM_KEY]`, an
3
+ * array of extensions, without importing it. Copied in each package that contributes one; the
4
+ * contract tests compare the copies. The host turns each on with the plugin's `extensions` option.
5
+ */
6
+ export const EXTENSIONS_CUSTOM_KEY = "editorAssistantExtensions";
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Checks a tool's input against the subset of JSON Schema that extension tools use: an object with
3
+ * `properties`, `required` and `additionalProperties: false`; values of type string, number, integer,
4
+ * boolean or array (with `items` of such a type), with `enum`, `minimum`, `maximum` and `maxItems`.
5
+ * Returns a sentence for the model, or null when the input fits.
6
+ */
7
+ export declare function inputError(schema: Record<string, unknown>, input: unknown): string | null;
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Checks a tool's input against the subset of JSON Schema that extension tools use: an object with
3
+ * `properties`, `required` and `additionalProperties: false`; values of type string, number, integer,
4
+ * boolean or array (with `items` of such a type), with `enum`, `minimum`, `maximum` and `maxItems`.
5
+ * Returns a sentence for the model, or null when the input fits.
6
+ */
7
+ export function inputError(schema, input) {
8
+ if (!isRecord(input))
9
+ return "The input must be an object.";
10
+ const properties = isRecord(schema.properties) ? schema.properties : {};
11
+ const required = Array.isArray(schema.required) ? schema.required : [];
12
+ for (const name of required) {
13
+ if (input[name] === undefined || input[name] === null)
14
+ return `"${name}" is required.`;
15
+ }
16
+ for (const [name, value] of Object.entries(input)) {
17
+ if (value === undefined || value === null)
18
+ continue;
19
+ const property = properties[name];
20
+ if (!isRecord(property)) {
21
+ if (schema.additionalProperties === false)
22
+ return `"${name}" is not an input of this tool.`;
23
+ continue;
24
+ }
25
+ const error = valueError(name, property, value);
26
+ if (error)
27
+ return error;
28
+ }
29
+ return null;
30
+ }
31
+ function valueError(name, schema, value) {
32
+ const types = Array.isArray(schema.type) ? schema.type : [schema.type];
33
+ if (!types.some((type) => typeMatches(type, value))) {
34
+ return `"${name}" must be ${types.filter(Boolean).join(" or ")}.`;
35
+ }
36
+ if (Array.isArray(schema.enum) && !schema.enum.includes(value)) {
37
+ return `"${name}" must be one of ${schema.enum.map((option) => JSON.stringify(option)).join(", ")}.`;
38
+ }
39
+ if (typeof value === "number") {
40
+ if (typeof schema.minimum === "number" && value < schema.minimum) {
41
+ return `"${name}" must be at least ${schema.minimum}.`;
42
+ }
43
+ if (typeof schema.maximum === "number" && value > schema.maximum) {
44
+ return `"${name}" must be at most ${schema.maximum}.`;
45
+ }
46
+ }
47
+ if (Array.isArray(value)) {
48
+ if (typeof schema.maxItems === "number" && value.length > schema.maxItems) {
49
+ return `"${name}" takes at most ${schema.maxItems} items.`;
50
+ }
51
+ if (isRecord(schema.items)) {
52
+ for (const item of value) {
53
+ const error = valueError(`${name} item`, schema.items, item);
54
+ if (error)
55
+ return error;
56
+ }
57
+ }
58
+ }
59
+ return null;
60
+ }
61
+ function typeMatches(type, value) {
62
+ switch (type) {
63
+ case "string":
64
+ return typeof value === "string";
65
+ case "number":
66
+ return typeof value === "number" && Number.isFinite(value);
67
+ case "integer":
68
+ return Number.isInteger(value);
69
+ case "boolean":
70
+ return typeof value === "boolean";
71
+ case "array":
72
+ return Array.isArray(value);
73
+ case undefined:
74
+ return true;
75
+ default:
76
+ return false;
77
+ }
78
+ }
79
+ function isRecord(value) {
80
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
81
+ }
@@ -0,0 +1,27 @@
1
+ import type { SanitizedConfig } from "payload";
2
+ import type { ValidatedEditorAssistantOptions } from "../config/validate.ts";
3
+ import type { AssistantTool } from "../runtime/capabilities/tool.ts";
4
+ import type { AdminView } from "../view.ts";
5
+ import { type AssistantExtensionView } from "./contract.ts";
6
+ /** What the model reads from one extension tool call, at most; longer answers are cut. */
7
+ export declare const MAX_RESULT_CHARACTERS = 12000;
8
+ /** An extension the host turned on, with its tools in the assistant's own shape. */
9
+ export type ResolvedExtension = {
10
+ id: string;
11
+ instructions?: string;
12
+ views: AssistantExtensionView[];
13
+ tools: AssistantTool[];
14
+ };
15
+ /** The extensions packages registered, valid or not; reading the config never throws. */
16
+ export declare function registeredExtensions(config: Pick<SanitizedConfig, "custom">): unknown[];
17
+ /** Why a registered extension cannot be used, or null. */
18
+ export declare function extensionProblem(value: unknown): string | null;
19
+ /**
20
+ * Problems to report at startup: registered extensions that are invalid, and extensions the host
21
+ * named that no package registered, or tools it named that the extension does not have.
22
+ */
23
+ export declare function extensionWarnings(config: Pick<SanitizedConfig, "custom">, options: Pick<ValidatedEditorAssistantOptions, "extensions">): string[];
24
+ /** The extensions the host turned on, valid ones only, with the tools it allowed. */
25
+ export declare function enabledExtensions(config: Pick<SanitizedConfig, "custom">, options: ValidatedEditorAssistantOptions): ResolvedExtension[];
26
+ /** The editor's place in Admin as a path under the admin route. */
27
+ export declare function viewPath(view?: AdminView): string | undefined;
@@ -0,0 +1,187 @@
1
+ import { writeAudit } from "../domain/audit.js";
2
+ import { EXTENSIONS_CUSTOM_KEY, } from "./contract.js";
3
+ import { inputError } from "./input.js";
4
+ /** What the model reads from one extension tool call, at most; longer answers are cut. */
5
+ export const MAX_RESULT_CHARACTERS = 12_000;
6
+ const EXTENSION_ID = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
7
+ const TOOL_SUFFIX = /^[a-zA-Z][a-zA-Z0-9-]*$/;
8
+ /** The extensions packages registered, valid or not; reading the config never throws. */
9
+ export function registeredExtensions(config) {
10
+ const value = config.custom?.[EXTENSIONS_CUSTOM_KEY];
11
+ return Array.isArray(value) ? value : [];
12
+ }
13
+ /** Why a registered extension cannot be used, or null. */
14
+ export function extensionProblem(value) {
15
+ if (!value || typeof value !== "object")
16
+ return "an extension is not an object";
17
+ const extension = value;
18
+ if (typeof extension.id !== "string" || !EXTENSION_ID.test(extension.id)) {
19
+ return `extension id ${JSON.stringify(extension.id)} is not kebab-case`;
20
+ }
21
+ for (const tool of extension.tools ?? []) {
22
+ const name = typeof tool?.name === "string" ? tool.name : "";
23
+ const [prefix, suffix, ...rest] = name.split(".");
24
+ if (prefix !== extension.id || !suffix || rest.length > 0 || !TOOL_SUFFIX.test(suffix)) {
25
+ return `tool "${name}" of "${extension.id}" must be named "${extension.id}.<name>"`;
26
+ }
27
+ if (tool.risk !== "auto") {
28
+ return `tool "${name}" has risk ${JSON.stringify(tool.risk)}; extensions may only read ("auto")`;
29
+ }
30
+ if (typeof tool.run !== "function" || typeof tool.description !== "string") {
31
+ return `tool "${name}" needs a description and a run function`;
32
+ }
33
+ if (tool.input?.type !== "object") {
34
+ return `tool "${name}" must take an object as input`;
35
+ }
36
+ }
37
+ return null;
38
+ }
39
+ /**
40
+ * Problems to report at startup: registered extensions that are invalid, and extensions the host
41
+ * named that no package registered, or tools it named that the extension does not have.
42
+ */
43
+ export function extensionWarnings(config, options) {
44
+ const warnings = [];
45
+ const valid = new Map();
46
+ for (const value of registeredExtensions(config)) {
47
+ const problem = extensionProblem(value);
48
+ if (problem)
49
+ warnings.push(problem);
50
+ else
51
+ valid.set(value.id, value);
52
+ }
53
+ for (const [id, setting] of Object.entries(options.extensions ?? {})) {
54
+ const extension = valid.get(id);
55
+ if (!extension) {
56
+ warnings.push(`extension "${id}" is turned on but no installed package provides it`);
57
+ continue;
58
+ }
59
+ if (setting !== true) {
60
+ const names = new Set((extension.tools ?? []).map((tool) => tool.name));
61
+ for (const name of setting.tools) {
62
+ if (!names.has(name))
63
+ warnings.push(`extension "${id}" has no tool "${name}"`);
64
+ }
65
+ }
66
+ }
67
+ return warnings;
68
+ }
69
+ /** The extensions the host turned on, valid ones only, with the tools it allowed. */
70
+ export function enabledExtensions(config, options) {
71
+ const settings = options.extensions ?? {};
72
+ const resolved = [];
73
+ const seen = new Set();
74
+ for (const value of registeredExtensions(config)) {
75
+ if (extensionProblem(value))
76
+ continue;
77
+ const extension = value;
78
+ const setting = settings[extension.id];
79
+ if (!setting || seen.has(extension.id))
80
+ continue;
81
+ seen.add(extension.id);
82
+ const allowed = setting === true ? undefined : new Set(setting.tools);
83
+ resolved.push({
84
+ id: extension.id,
85
+ instructions: extension.instructions?.trim() || undefined,
86
+ views: (extension.views ?? []).filter((view) => typeof view?.path === "string" && typeof view.description === "string"),
87
+ tools: (extension.tools ?? [])
88
+ .filter((tool) => !allowed || allowed.has(tool.name))
89
+ .map((tool) => asAssistantTool(tool, options)),
90
+ });
91
+ }
92
+ return resolved;
93
+ }
94
+ /** An extension tool under the assistant's rules: input checked, errors caught, answer bounded, audited. */
95
+ function asAssistantTool(tool, options) {
96
+ return {
97
+ name: tool.name,
98
+ description: tool.description,
99
+ input: tool.input,
100
+ risk: "auto",
101
+ step: "reading",
102
+ // A batch turn stays on its one saved document.
103
+ notInBatch: true,
104
+ run: async (input, ctx) => {
105
+ const failure = (error, message) => ({
106
+ ok: false,
107
+ capability: tool.name,
108
+ error,
109
+ message,
110
+ });
111
+ const invalid = inputError(tool.input, input);
112
+ let outcome;
113
+ if (invalid) {
114
+ outcome = failure("invalid_input", invalid);
115
+ }
116
+ else {
117
+ try {
118
+ const result = await tool.run(input, {
119
+ req: ctx.req,
120
+ path: viewPath(ctx.bridge.view),
121
+ open: openDocument(ctx.bridge),
122
+ });
123
+ outcome = result.ok
124
+ ? {
125
+ ok: true,
126
+ capability: tool.name,
127
+ data: bounded(result.data),
128
+ ...(result.navigate ? { navigate: result.navigate } : {}),
129
+ }
130
+ : failure(result.error, result.message);
131
+ }
132
+ catch (error) {
133
+ ctx.req.payload.logger.error({
134
+ err: error,
135
+ msg: `editor-assistant: ${tool.name} failed`,
136
+ });
137
+ outcome = failure("tool_failed", "This tool failed. Say so; do not guess its answer.");
138
+ }
139
+ }
140
+ await writeAudit(ctx.req, options, {
141
+ capability: tool.name,
142
+ risk: "auto",
143
+ outcome: outcome.ok ? "executed" : "failed",
144
+ errorClass: outcome.ok ? undefined : outcome.error,
145
+ locale: ctx.bridge.locale,
146
+ });
147
+ return outcome;
148
+ },
149
+ };
150
+ }
151
+ /** The data, or its first characters with a note when it is too long for the model. */
152
+ function bounded(data) {
153
+ const text = JSON.stringify(data ?? null);
154
+ if (text.length <= MAX_RESULT_CHARACTERS)
155
+ return data;
156
+ return {
157
+ truncated: `Cut at ${MAX_RESULT_CHARACTERS} characters; ask with narrower input for the rest.`,
158
+ text: text.slice(0, MAX_RESULT_CHARACTERS),
159
+ };
160
+ }
161
+ /** The editor's place in Admin as a path under the admin route. */
162
+ export function viewPath(view) {
163
+ switch (view?.kind) {
164
+ case "dashboard":
165
+ return "/";
166
+ case "custom":
167
+ return view.path;
168
+ case "list":
169
+ return `/collections/${view.collection}`;
170
+ case "document":
171
+ return `/collections/${view.collection}${view.documentId ? `/${view.documentId}` : "/create"}`;
172
+ case "global":
173
+ return `/globals/${view.global}`;
174
+ default:
175
+ return undefined;
176
+ }
177
+ }
178
+ function openDocument(bridge) {
179
+ if (!bridge.collection && !bridge.global)
180
+ return undefined;
181
+ return {
182
+ collection: bridge.collection,
183
+ global: bridge.global,
184
+ id: bridge.documentId,
185
+ locale: bridge.locale,
186
+ };
187
+ }
@@ -5,7 +5,8 @@ import { type FieldNode } from "../schema/fields.ts";
5
5
  * including relations inside changed groups, rows and blocks. A single-collection
6
6
  * polymorphic relation still needs { relationTo, value }. Missing required relationships in a
7
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.
8
+ * A field for several collections takes { relationTo, value } with one of them. Unrelated fields
9
+ * are not checked.
9
10
  * Whether the ids exist and are readable is `verifyDocumentRelations`' job.
10
11
  */
11
12
  export declare function relationValueError(fields: FieldNode[], data: Record<string, unknown>, paths: string[], context?: {
@@ -6,7 +6,8 @@ const ID = /^[\w-]+$/;
6
6
  * including relations inside changed groups, rows and blocks. A single-collection
7
7
  * polymorphic relation still needs { relationTo, value }. Missing required relationships in a
8
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
+ * A field for several collections takes { relationTo, value } with one of them. Unrelated fields
10
+ * are not checked.
10
11
  * Whether the ids exist and are readable is `verifyDocumentRelations`' job.
11
12
  */
12
13
  export function relationValueError(fields, data, paths, context = {}) {
@@ -55,7 +56,7 @@ export function relationValueError(fields, data, paths, context = {}) {
55
56
  }
56
57
  if (required) {
57
58
  const format = field.polymorphic
58
- ? ` Set it to {"relationTo":"${field.relationTo?.[0]}","value":"<found document id>"}, not a URL.`
59
+ ? ` Set it to {"relationTo":${collectionsOf(field)},"value":"<found document id>"}, not a URL.`
59
60
  : " Set it to a found document id, not a URL.";
60
61
  return `"${path}" is required.${format}`;
61
62
  }
@@ -88,9 +89,6 @@ export function relationValueError(fields, data, paths, context = {}) {
88
89
  }
89
90
  function fieldError(field, path, value) {
90
91
  const noun = field.type === "upload" ? "image" : "document";
91
- if ((field.relationTo?.length ?? 0) > 1) {
92
- return `"${path}" points at several collections; the assistant cannot choose for it yet.`;
93
- }
94
92
  if (value === null || value === undefined) {
95
93
  return null;
96
94
  }
@@ -124,13 +122,18 @@ function fieldError(field, path, value) {
124
122
  function entryError(field, path, value, noun) {
125
123
  if (field.polymorphic) {
126
124
  return isRecord(value) &&
127
- value.relationTo === field.relationTo?.[0] &&
125
+ typeof value.relationTo === "string" &&
126
+ (field.relationTo ?? []).includes(value.relationTo) &&
128
127
  idOf(value.value) !== undefined
129
128
  ? null
130
- : `"${path}" needs {"relationTo":"${field.relationTo?.[0]}","value":"<found ${noun} id>"}. A URL or bare id cannot fill this field.`;
129
+ : `"${path}" needs {"relationTo":${collectionsOf(field)},"value":"<found ${noun} id>"}. A URL or bare id cannot fill this field.`;
131
130
  }
132
131
  return idOf(value) !== undefined ? null : `"${path}" needs a valid ${noun} id, not a URL.`;
133
132
  }
133
+ /** `"images"`, or `"images" or "videos"` for a field that takes several collections. */
134
+ function collectionsOf(field) {
135
+ return (field.relationTo ?? []).map((slug) => `"${slug}"`).join(" or ");
136
+ }
134
137
  function isRecord(value) {
135
138
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
136
139
  }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  export type { BuiltinCapability, CapabilityRisk, EntityCapability } from "./capabilities.ts";
2
2
  export { BUILTIN_CAPABILITIES, BUILTIN_RISK, ENTITY_CAPABILITIES } from "./capabilities.ts";
3
+ export type { AssistantExtension, AssistantExtensionContext, AssistantExtensionResult, AssistantExtensionTool, AssistantExtensionView, } from "./extensions/contract.ts";
4
+ export { EXTENSIONS_CUSTOM_KEY } from "./extensions/contract.ts";
3
5
  export { editorAssistantPlugin } from "./plugin.ts";
4
6
  export type { AssistantShortcut, EditorAssistantPluginOptions, EntityAllowlist, FieldAllowlist, HostLanguageModel, ModelResolver, ProposalValidationResult, } from "./types.ts";
5
7
  export { defineEditorAssistantConfig } from "./types.ts";
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export { BUILTIN_CAPABILITIES, BUILTIN_RISK, ENTITY_CAPABILITIES } from "./capabilities.js";
2
+ export { EXTENSIONS_CUSTOM_KEY } from "./extensions/contract.js";
2
3
  export { editorAssistantPlugin } from "./plugin.js";
3
4
  export { defineEditorAssistantConfig } from "./types.js";