@simmalugnt-se/payload-editor-assistant 0.14.1 → 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.
- package/CHANGELOG.md +14 -0
- package/README.md +72 -4
- package/dist/config/validate.d.ts +1 -0
- package/dist/config/validate.js +10 -0
- package/dist/endpoints/chat.js +2 -1
- package/dist/extensions/contract.d.ts +62 -0
- package/dist/extensions/contract.js +6 -0
- package/dist/extensions/input.d.ts +7 -0
- package/dist/extensions/input.js +81 -0
- package/dist/extensions/resolve.d.ts +27 -0
- package/dist/extensions/resolve.js +187 -0
- package/dist/form/relation-shape.d.ts +2 -1
- package/dist/form/relation-shape.js +10 -7
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/plugin.js +10 -1
- package/dist/runtime/agent.js +16 -23
- package/dist/runtime/capabilities/admin-navigate.d.ts +2 -0
- package/dist/runtime/capabilities/admin-navigate.js +49 -0
- package/dist/runtime/capabilities/content-find-by-id.d.ts +2 -0
- package/dist/runtime/capabilities/content-find-by-id.js +25 -0
- package/dist/runtime/capabilities/content-find.d.ts +2 -0
- package/dist/runtime/capabilities/content-find.js +22 -0
- package/dist/runtime/capabilities/form-read.d.ts +2 -0
- package/dist/runtime/capabilities/form-read.js +25 -0
- package/dist/runtime/capabilities/index.d.ts +7 -0
- package/dist/runtime/capabilities/index.js +21 -0
- package/dist/runtime/capabilities/media-view.d.ts +2 -0
- package/dist/runtime/capabilities/media-view.js +48 -0
- package/dist/runtime/capabilities/propose.d.ts +3 -0
- package/dist/runtime/capabilities/propose.js +79 -0
- package/dist/runtime/capabilities/schema-discover.d.ts +2 -0
- package/dist/runtime/capabilities/schema-discover.js +17 -0
- package/dist/runtime/capabilities/tool.d.ts +33 -0
- package/dist/runtime/capabilities/tool.js +12 -0
- package/dist/runtime/system-prompt.d.ts +2 -1
- package/dist/runtime/system-prompt.js +29 -8
- package/dist/runtime/tools.d.ts +10 -6
- package/dist/runtime/tools.js +15 -243
- package/dist/types.d.ts +7 -0
- package/dist/view.d.ts +10 -2
- package/dist/view.js +13 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
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
|
+
|
|
3
17
|
## 0.14.1
|
|
4
18
|
|
|
5
19
|
- npm links to simmalugnt.se instead of the private source repository.
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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;
|
package/dist/config/validate.js
CHANGED
|
@@ -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)) {
|
package/dist/endpoints/chat.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
*
|
|
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"
|
|
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 ===
|
|
125
|
+
typeof value.relationTo === "string" &&
|
|
126
|
+
(field.relationTo ?? []).includes(value.relationTo) &&
|
|
128
127
|
idOf(value.value) !== undefined
|
|
129
128
|
? null
|
|
130
|
-
: `"${path}" needs {"relationTo"
|
|
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";
|