@simmalugnt-se/payload-editor-assistant 0.5.0 → 0.6.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 +39 -20
- package/dist/capabilities.d.ts +4 -4
- package/dist/capabilities.js +4 -0
- package/dist/components/copy.d.ts +2 -0
- package/dist/components/copy.js +2 -0
- package/dist/config/validate.js +3 -0
- package/dist/domain/media.d.ts +40 -0
- package/dist/domain/media.js +165 -0
- package/dist/plugin.js +4 -0
- package/dist/runtime/agent.d.ts +1 -1
- package/dist/runtime/agent.js +5 -2
- package/dist/runtime/system-prompt.js +13 -1
- package/dist/runtime/tools.d.ts +26 -0
- package/dist/runtime/tools.js +35 -0
- package/dist/runtime/voice.js +33 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.0
|
|
4
|
+
|
|
5
|
+
- `media.view`: the assistant can look at the image of the open upload document, so it writes alt
|
|
6
|
+
texts from what the image shows. Allowed per upload collection. The server reads the saved file
|
|
7
|
+
with the editor's access (storage adapter handlers, then the upload directory, then a public
|
|
8
|
+
address fetched without credentials) and sends the image itself to the model, never a URL; the
|
|
9
|
+
smallest `imageSizes` entry at least 768px wide is used, and JPEG, PNG, WebP and GIF up to 5 MB.
|
|
10
|
+
The panel says "Looking at the image…" meanwhile. Without `media.view`, the assistant says it
|
|
11
|
+
cannot see an open image instead of describing it from the filename.
|
|
12
|
+
|
|
13
|
+
- A reply that quotes its proposal is shown whole. A quote with a full stop or a word such as
|
|
14
|
+
"click", "save" or "spara" ("Click. Edit. Done.", "Spara 20 %") was split into sentences and
|
|
15
|
+
partly removed as if it claimed something about the panel.
|
|
16
|
+
|
|
3
17
|
## 0.5.0
|
|
4
18
|
|
|
5
19
|
- `next` is a required peer dependency: the panel imports `next/navigation`, so it never worked
|
package/README.md
CHANGED
|
@@ -4,36 +4,36 @@ Context-aware AI assistant for Payload Admin. The editor asks for changes in a d
|
|
|
4
4
|
proposes form changes the editor reviews and approves, within an allowlist of collections, globals,
|
|
5
5
|
fields and capabilities.
|
|
6
6
|
|
|
7
|
-
Requires Payload `>=3.87.1 <4`, Next 16, React 19, Node 22 and the AI SDK (`ai` 7) with a
|
|
8
|
-
your choice.
|
|
7
|
+
Requires Payload `>=3.87.1 <4`, Next 16, React 19, Node 22 and the AI SDK (`ai` 7) with a model of
|
|
8
|
+
your choice. Choose a model that reads images: the assistant is getting abilities, such as alt
|
|
9
|
+
texts, that show it the image.
|
|
9
10
|
|
|
10
11
|
## Setup
|
|
11
12
|
|
|
12
|
-
1. Install the plugin
|
|
13
|
+
1. Install the plugin and the AI SDK. The AI SDK reaches models from many companies through Vercel
|
|
14
|
+
AI Gateway without another package; any other AI SDK provider works too:
|
|
13
15
|
|
|
14
16
|
```bash
|
|
15
|
-
pnpm add @simmalugnt-se/payload-editor-assistant ai
|
|
17
|
+
pnpm add @simmalugnt-se/payload-editor-assistant ai
|
|
16
18
|
```
|
|
17
19
|
|
|
18
|
-
2. Put the
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
2. Put the gateway's API key in the server environment, e.g. `.env.local` locally. Never expose it
|
|
21
|
+
to the browser (no `NEXT_PUBLIC_` prefix). A site deployed on Vercel signs in to the gateway by
|
|
22
|
+
itself and needs no key:
|
|
21
23
|
|
|
22
24
|
```bash
|
|
23
|
-
|
|
25
|
+
AI_GATEWAY_API_KEY=...
|
|
24
26
|
```
|
|
25
27
|
|
|
26
|
-
3. Create the model
|
|
28
|
+
3. Create the model and pass it to the plugin:
|
|
27
29
|
|
|
28
30
|
```ts
|
|
29
|
-
import {
|
|
31
|
+
import { gateway } from "ai";
|
|
30
32
|
import { editorAssistantPlugin } from "@simmalugnt-se/payload-editor-assistant";
|
|
31
33
|
|
|
32
|
-
const deepSeek = createDeepSeek({ apiKey: process.env.DEEPSEEK_API_KEY });
|
|
33
|
-
|
|
34
34
|
plugins: [
|
|
35
35
|
editorAssistantPlugin({
|
|
36
|
-
model:
|
|
36
|
+
model: gateway("openai/gpt-6-luna"),
|
|
37
37
|
collections: {
|
|
38
38
|
pages: {
|
|
39
39
|
capabilities: ["content.find", "content.findById", "form.read", "form.propose", "draft.create"],
|
|
@@ -51,21 +51,22 @@ your choice.
|
|
|
51
51
|
## API keys
|
|
52
52
|
|
|
53
53
|
The plugin has no provider or key of its own: it only receives the `model` you create, and the model
|
|
54
|
-
is called on the server. To switch
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
when no `apiKey` is passed, but passing it explicitly keeps the connection visible in your config.
|
|
54
|
+
is called on the server. To switch model, change the gateway model ID (see
|
|
55
|
+
[the model list](https://vercel.com/ai-gateway/models)). To use a provider directly, install its AI
|
|
56
|
+
SDK provider and create the model with its key, e.g.
|
|
57
|
+
`createAnthropic({ apiKey: process.env.ANTHROPIC_API_KEY })`.
|
|
59
58
|
|
|
60
59
|
If the key is wrong, the drawer says the AI service did not answer and the cause is logged on the
|
|
61
60
|
server through Payload's logger.
|
|
62
61
|
|
|
63
62
|
To keep the plugin installed without a key (its tables stay in your schema and migrations), let
|
|
64
63
|
`model` be a function that returns `null` or `undefined` when there is nothing to call. The drawer then
|
|
65
|
-
tells the editor that no AI model is set up, and a warning is logged
|
|
64
|
+
tells the editor that no AI model is set up, and a warning is logged. On Vercel the gateway needs no
|
|
65
|
+
key, so check for that too:
|
|
66
66
|
|
|
67
67
|
```ts
|
|
68
|
-
model: () =>
|
|
68
|
+
model: () =>
|
|
69
|
+
process.env.AI_GATEWAY_API_KEY || process.env.VERCEL ? gateway("openai/gpt-6-luna") : null,
|
|
69
70
|
```
|
|
70
71
|
|
|
71
72
|
## What the assistant may do
|
|
@@ -79,11 +80,29 @@ Nothing is reachable unless you list it. Each collection or global gets the capa
|
|
|
79
80
|
| `form.read` | read the open edit form, including unsaved changes | no |
|
|
80
81
|
| `form.propose` | put a change in the open form, where the editor keeps or undoes it | yes |
|
|
81
82
|
| `draft.create` | create a new draft document (collections only) | yes |
|
|
83
|
+
| `media.view` | look at the image of the open upload document (upload collections only) | no |
|
|
82
84
|
|
|
83
85
|
The assistant never publishes. Form changes land in the open form and the editor saves as usual; an
|
|
84
86
|
approved `draft.create` saves a new, unpublished draft. The `users` collection, Payload's own collections and fields that look like
|
|
85
87
|
secrets (`password`, `token`, `apiKey`, ...) are always off limits.
|
|
86
88
|
|
|
89
|
+
## Images and alt texts
|
|
90
|
+
|
|
91
|
+
With `media.view` on an upload collection, the assistant can look at the open image and write its
|
|
92
|
+
alt text, which the editor keeps or undoes like any other change:
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
collections: {
|
|
96
|
+
media: { capabilities: ["form.read", "form.propose", "media.view"] },
|
|
97
|
+
},
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The server reads the saved file with the editor's access (through the storage adapter, the upload
|
|
101
|
+
directory, or the file's public address) and sends the image itself to the model, never its
|
|
102
|
+
address. It uses the smallest of the collection's `imageSizes` at least 768px wide, else the
|
|
103
|
+
original; JPEG, PNG, WebP and GIF up to 5 MB. Choose a model that reads images. Without
|
|
104
|
+
`media.view` the assistant says it cannot see the image instead of guessing from the filename.
|
|
105
|
+
|
|
87
106
|
## Keep or undo
|
|
88
107
|
|
|
89
108
|
A proposed form change goes straight into the open form, so the fields and Live Preview show it
|
package/dist/capabilities.d.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
export declare const BUILTIN_CAPABILITIES: readonly ["admin.navigate", "schema.discover", "content.find", "content.findById", "form.read", "form.propose", "draft.create"];
|
|
1
|
+
export declare const BUILTIN_CAPABILITIES: readonly ["admin.navigate", "schema.discover", "content.find", "content.findById", "form.read", "form.propose", "draft.create", "media.view"];
|
|
2
2
|
export type BuiltinCapability = (typeof BUILTIN_CAPABILITIES)[number];
|
|
3
3
|
export type CapabilityRisk = "auto" | "approval" | "forbidden";
|
|
4
4
|
export declare const BUILTIN_RISK: Record<BuiltinCapability, CapabilityRisk>;
|
|
5
|
-
export declare const EMBEDDED_ONLY_CAPABILITIES: Set<"admin.navigate" | "schema.discover" | "content.find" | "content.findById" | "form.read" | "form.propose" | "draft.create">;
|
|
6
|
-
export declare const MCP_ELIGIBLE_CAPABILITIES: Set<"admin.navigate" | "schema.discover" | "content.find" | "content.findById" | "form.read" | "form.propose" | "draft.create">;
|
|
7
|
-
export declare const ENTITY_CAPABILITIES: readonly ["content.find", "content.findById", "form.read", "form.propose", "draft.create"];
|
|
5
|
+
export declare const EMBEDDED_ONLY_CAPABILITIES: Set<"admin.navigate" | "schema.discover" | "content.find" | "content.findById" | "form.read" | "form.propose" | "draft.create" | "media.view">;
|
|
6
|
+
export declare const MCP_ELIGIBLE_CAPABILITIES: Set<"admin.navigate" | "schema.discover" | "content.find" | "content.findById" | "form.read" | "form.propose" | "draft.create" | "media.view">;
|
|
7
|
+
export declare const ENTITY_CAPABILITIES: readonly ["content.find", "content.findById", "form.read", "form.propose", "draft.create", "media.view"];
|
|
8
8
|
export type EntityCapability = (typeof ENTITY_CAPABILITIES)[number];
|
|
9
9
|
export declare function isEntityCapability(value: string): value is EntityCapability;
|
|
10
10
|
export declare function isBuiltinCapability(value: string): value is BuiltinCapability;
|
package/dist/capabilities.js
CHANGED
|
@@ -6,6 +6,7 @@ export const BUILTIN_CAPABILITIES = [
|
|
|
6
6
|
"form.read",
|
|
7
7
|
"form.propose",
|
|
8
8
|
"draft.create",
|
|
9
|
+
"media.view",
|
|
9
10
|
];
|
|
10
11
|
export const BUILTIN_RISK = {
|
|
11
12
|
"admin.navigate": "auto",
|
|
@@ -15,11 +16,13 @@ export const BUILTIN_RISK = {
|
|
|
15
16
|
"form.read": "auto",
|
|
16
17
|
"form.propose": "approval",
|
|
17
18
|
"draft.create": "approval",
|
|
19
|
+
"media.view": "auto",
|
|
18
20
|
};
|
|
19
21
|
export const EMBEDDED_ONLY_CAPABILITIES = new Set([
|
|
20
22
|
"admin.navigate",
|
|
21
23
|
"form.read",
|
|
22
24
|
"form.propose",
|
|
25
|
+
"media.view",
|
|
23
26
|
]);
|
|
24
27
|
export const MCP_ELIGIBLE_CAPABILITIES = new Set([
|
|
25
28
|
"schema.discover",
|
|
@@ -33,6 +36,7 @@ export const ENTITY_CAPABILITIES = [
|
|
|
33
36
|
"form.read",
|
|
34
37
|
"form.propose",
|
|
35
38
|
"draft.create",
|
|
39
|
+
"media.view",
|
|
36
40
|
];
|
|
37
41
|
const ENTITY_CAPABILITY_SET = new Set(ENTITY_CAPABILITIES);
|
|
38
42
|
export function isEntityCapability(value) {
|
|
@@ -19,6 +19,7 @@ export declare const COPY: {
|
|
|
19
19
|
readonly searching: "Searching the content…";
|
|
20
20
|
readonly reading: "Reading a document…";
|
|
21
21
|
readonly reading_form: "Reading the form…";
|
|
22
|
+
readonly viewing: "Looking at the image…";
|
|
22
23
|
readonly proposing: "Preparing the change…";
|
|
23
24
|
readonly drafting: "Preparing the draft…";
|
|
24
25
|
readonly navigating: "Finding the page…";
|
|
@@ -81,6 +82,7 @@ export declare const COPY: {
|
|
|
81
82
|
readonly searching: "Söker bland innehållet…";
|
|
82
83
|
readonly reading: "Läser ett dokument…";
|
|
83
84
|
readonly reading_form: "Läser formuläret…";
|
|
85
|
+
readonly viewing: "Tittar på bilden…";
|
|
84
86
|
readonly proposing: "Förbereder ändringen…";
|
|
85
87
|
readonly drafting: "Förbereder utkastet…";
|
|
86
88
|
readonly navigating: "Letar upp sidan…";
|
package/dist/components/copy.js
CHANGED
|
@@ -20,6 +20,7 @@ export const COPY = {
|
|
|
20
20
|
searching: "Searching the content…",
|
|
21
21
|
reading: "Reading a document…",
|
|
22
22
|
reading_form: "Reading the form…",
|
|
23
|
+
viewing: "Looking at the image…",
|
|
23
24
|
proposing: "Preparing the change…",
|
|
24
25
|
drafting: "Preparing the draft…",
|
|
25
26
|
navigating: "Finding the page…",
|
|
@@ -82,6 +83,7 @@ export const COPY = {
|
|
|
82
83
|
searching: "Söker bland innehållet…",
|
|
83
84
|
reading: "Läser ett dokument…",
|
|
84
85
|
reading_form: "Läser formuläret…",
|
|
86
|
+
viewing: "Tittar på bilden…",
|
|
85
87
|
proposing: "Förbereder ändringen…",
|
|
86
88
|
drafting: "Förbereder utkastet…",
|
|
87
89
|
navigating: "Letar upp sidan…",
|
package/dist/config/validate.js
CHANGED
|
@@ -39,6 +39,9 @@ function validateEntityMap(kind, map, rules) {
|
|
|
39
39
|
if (capability === "draft.create" && !rules.allowDraftCreate) {
|
|
40
40
|
throw new Error(`payload-editor-assistant: globals cannot enable draft.create ("${slug}").`);
|
|
41
41
|
}
|
|
42
|
+
if (capability === "media.view" && kind === "globals") {
|
|
43
|
+
throw new Error(`payload-editor-assistant: media.view is for upload collections, not globals ("${slug}").`);
|
|
44
|
+
}
|
|
42
45
|
}
|
|
43
46
|
validateFields(entry, `${kind} "${slug}"`);
|
|
44
47
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { PayloadRequest } from "payload";
|
|
2
|
+
import type { ValidatedEditorAssistantOptions } from "../config/validate.ts";
|
|
3
|
+
export declare const MAX_IMAGE_BYTES: number;
|
|
4
|
+
export type ImageSource = {
|
|
5
|
+
filename: string;
|
|
6
|
+
mimeType: string;
|
|
7
|
+
width?: number;
|
|
8
|
+
url?: string;
|
|
9
|
+
};
|
|
10
|
+
export type MediaViewResult = {
|
|
11
|
+
ok: true;
|
|
12
|
+
data: {
|
|
13
|
+
filename: string;
|
|
14
|
+
width?: number;
|
|
15
|
+
height?: number;
|
|
16
|
+
alt?: unknown;
|
|
17
|
+
};
|
|
18
|
+
image: {
|
|
19
|
+
mediaType: string;
|
|
20
|
+
data: string;
|
|
21
|
+
};
|
|
22
|
+
} | {
|
|
23
|
+
ok: false;
|
|
24
|
+
error: string;
|
|
25
|
+
message: string;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* The image file to show the model: the smallest of Payload's `imageSizes` at least
|
|
29
|
+
* `PREFERRED_WIDTH` wide, otherwise the original.
|
|
30
|
+
*/
|
|
31
|
+
export declare function pickImageSource(doc: Record<string, unknown>): ImageSource | null;
|
|
32
|
+
/**
|
|
33
|
+
* Reads the image of a saved upload document with the editor's access, the way Payload serves it:
|
|
34
|
+
* the storage adapter's handlers first, then the file in `staticDir`, then a public URL fetched
|
|
35
|
+
* without the editor's credentials.
|
|
36
|
+
*/
|
|
37
|
+
export declare function viewMedia(req: PayloadRequest, options: ValidatedEditorAssistantOptions, input: {
|
|
38
|
+
collection?: string;
|
|
39
|
+
documentId?: string | number;
|
|
40
|
+
}): Promise<MediaViewResult>;
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { readFile, stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { findCollectionConfig } from "../schema/discover.js";
|
|
4
|
+
import { hasCapability, resolveAllowlist } from "./allowlist.js";
|
|
5
|
+
import { localApi } from "./local-api.js";
|
|
6
|
+
/** Image types the AI SDK's providers take; SVG, AVIF and HEIC are left out. */
|
|
7
|
+
const READABLE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]);
|
|
8
|
+
/** Enough detail for an alt text without sending a full-size original. */
|
|
9
|
+
const PREFERRED_WIDTH = 768;
|
|
10
|
+
export const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
11
|
+
const FETCH_TIMEOUT_MS = 10_000;
|
|
12
|
+
/**
|
|
13
|
+
* The image file to show the model: the smallest of Payload's `imageSizes` at least
|
|
14
|
+
* `PREFERRED_WIDTH` wide, otherwise the original.
|
|
15
|
+
*/
|
|
16
|
+
export function pickImageSource(doc) {
|
|
17
|
+
const original = asSource(doc);
|
|
18
|
+
const sizes = Object.values(asRecord(doc.sizes) ?? {})
|
|
19
|
+
.map((size) => asSource(asRecord(size) ?? {}))
|
|
20
|
+
.filter((size) => size !== null && (size.width ?? 0) >= PREFERRED_WIDTH)
|
|
21
|
+
.sort((a, b) => (a.width ?? 0) - (b.width ?? 0));
|
|
22
|
+
return sizes[0] ?? original;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Reads the image of a saved upload document with the editor's access, the way Payload serves it:
|
|
26
|
+
* the storage adapter's handlers first, then the file in `staticDir`, then a public URL fetched
|
|
27
|
+
* without the editor's credentials.
|
|
28
|
+
*/
|
|
29
|
+
export async function viewMedia(req, options, input) {
|
|
30
|
+
const resolved = resolveAllowlist(options, { collection: input.collection });
|
|
31
|
+
if (!resolved || !hasCapability(resolved.allowlist, "media.view")) {
|
|
32
|
+
return {
|
|
33
|
+
ok: false,
|
|
34
|
+
error: "tool_denied",
|
|
35
|
+
message: "Images in this collection are not allowlisted.",
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
const config = input.collection ? findCollectionConfig(req, input.collection) : undefined;
|
|
39
|
+
if (!config?.upload) {
|
|
40
|
+
return { ok: false, error: "tool_denied", message: "This collection holds no uploads." };
|
|
41
|
+
}
|
|
42
|
+
if (input.documentId === undefined) {
|
|
43
|
+
return {
|
|
44
|
+
ok: false,
|
|
45
|
+
error: "unsupported_media",
|
|
46
|
+
message: "The image is not saved yet. Ask the editor to save it first.",
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
let doc;
|
|
50
|
+
try {
|
|
51
|
+
doc = await localApi(req).findByID({
|
|
52
|
+
collection: resolved.slug,
|
|
53
|
+
id: input.documentId,
|
|
54
|
+
depth: 0,
|
|
55
|
+
draft: true,
|
|
56
|
+
req,
|
|
57
|
+
user: req.user,
|
|
58
|
+
overrideAccess: false,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return { ok: false, error: "tool_denied", message: "Document is not accessible." };
|
|
63
|
+
}
|
|
64
|
+
const source = pickImageSource(doc);
|
|
65
|
+
if (!source || !READABLE_TYPES.has(source.mimeType)) {
|
|
66
|
+
return {
|
|
67
|
+
ok: false,
|
|
68
|
+
error: "unsupported_media",
|
|
69
|
+
message: "This file is not an image the assistant can look at (JPEG, PNG, WebP or GIF).",
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
const bytes = await readSource(req, resolved.slug, config.upload, source, doc.prefix);
|
|
73
|
+
if (!bytes.ok) {
|
|
74
|
+
return bytes;
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
ok: true,
|
|
78
|
+
data: {
|
|
79
|
+
filename: String(doc.filename),
|
|
80
|
+
width: asNumber(doc.width),
|
|
81
|
+
height: asNumber(doc.height),
|
|
82
|
+
alt: doc.alt,
|
|
83
|
+
},
|
|
84
|
+
image: { mediaType: source.mimeType, data: bytes.data.toString("base64") },
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
async function readSource(req, collection, uploadConfig, source, prefix) {
|
|
88
|
+
const upload = (asRecord(uploadConfig) ?? {});
|
|
89
|
+
const tooLarge = {
|
|
90
|
+
ok: false,
|
|
91
|
+
error: "unsupported_media",
|
|
92
|
+
message: "The image is too large for the assistant to look at.",
|
|
93
|
+
};
|
|
94
|
+
const unreadable = {
|
|
95
|
+
ok: false,
|
|
96
|
+
error: "unsupported_media",
|
|
97
|
+
message: "The image file could not be read.",
|
|
98
|
+
};
|
|
99
|
+
try {
|
|
100
|
+
for (const handler of upload.handlers ?? []) {
|
|
101
|
+
const response = await handler(req, {
|
|
102
|
+
doc: undefined,
|
|
103
|
+
headers: new Headers(),
|
|
104
|
+
params: {
|
|
105
|
+
collection,
|
|
106
|
+
filename: source.filename,
|
|
107
|
+
prefix: typeof prefix === "string" && prefix ? prefix : undefined,
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
if (response instanceof Response) {
|
|
111
|
+
return response.ok ? await readResponse(response, tooLarge) : unreadable;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (!upload.disableLocalStorage) {
|
|
115
|
+
const dir = path.resolve(upload.staticDir || collection);
|
|
116
|
+
const file = path.resolve(dir, source.filename);
|
|
117
|
+
if (!file.startsWith(dir + path.sep)) {
|
|
118
|
+
return unreadable;
|
|
119
|
+
}
|
|
120
|
+
const info = await stat(file).catch(() => null);
|
|
121
|
+
if (info?.isFile()) {
|
|
122
|
+
return info.size > MAX_IMAGE_BYTES ? tooLarge : { ok: true, data: await readFile(file) };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
// Storage without handlers serves files from a public address of its own.
|
|
126
|
+
if (source.url && /^https?:\/\//i.test(source.url)) {
|
|
127
|
+
const response = await fetch(source.url, {
|
|
128
|
+
credentials: "omit",
|
|
129
|
+
redirect: "error",
|
|
130
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
131
|
+
});
|
|
132
|
+
return response.ok ? await readResponse(response, tooLarge) : unreadable;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return unreadable;
|
|
137
|
+
}
|
|
138
|
+
return unreadable;
|
|
139
|
+
}
|
|
140
|
+
async function readResponse(response, tooLarge) {
|
|
141
|
+
const length = Number(response.headers.get("content-length"));
|
|
142
|
+
if (length > MAX_IMAGE_BYTES) {
|
|
143
|
+
return tooLarge;
|
|
144
|
+
}
|
|
145
|
+
const data = Buffer.from(await response.arrayBuffer());
|
|
146
|
+
return data.length > MAX_IMAGE_BYTES ? tooLarge : { ok: true, data };
|
|
147
|
+
}
|
|
148
|
+
function asSource(record) {
|
|
149
|
+
if (typeof record.filename !== "string" || !record.filename)
|
|
150
|
+
return null;
|
|
151
|
+
if (typeof record.mimeType !== "string")
|
|
152
|
+
return null;
|
|
153
|
+
return {
|
|
154
|
+
filename: record.filename,
|
|
155
|
+
mimeType: record.mimeType,
|
|
156
|
+
width: asNumber(record.width),
|
|
157
|
+
url: typeof record.url === "string" ? record.url : undefined,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
function asRecord(value) {
|
|
161
|
+
return value && typeof value === "object" ? value : undefined;
|
|
162
|
+
}
|
|
163
|
+
function asNumber(value) {
|
|
164
|
+
return typeof value === "number" ? value : undefined;
|
|
165
|
+
}
|
package/dist/plugin.js
CHANGED
|
@@ -19,6 +19,10 @@ export function editorAssistantPlugin(options = {}) {
|
|
|
19
19
|
if (collection.auth && validated.collections[collection.slug]) {
|
|
20
20
|
throw new Error(`payload-editor-assistant: auth collection "${collection.slug}" cannot be allowlisted.`);
|
|
21
21
|
}
|
|
22
|
+
if (!collection.upload &&
|
|
23
|
+
validated.collections[collection.slug]?.capabilities.includes("media.view")) {
|
|
24
|
+
throw new Error(`payload-editor-assistant: media.view needs an upload collection; "${collection.slug}" is not one.`);
|
|
25
|
+
}
|
|
22
26
|
if (validated.collections[collection.slug]) {
|
|
23
27
|
collections[index] = withDocumentBridge(collection);
|
|
24
28
|
}
|
package/dist/runtime/agent.d.ts
CHANGED
|
@@ -33,7 +33,7 @@ type ChatMessage = {
|
|
|
33
33
|
text: string;
|
|
34
34
|
};
|
|
35
35
|
/** What the assistant is doing right now, for the panel to show while the turn runs. */
|
|
36
|
-
export type AgentStep = "thinking" | "searching" | "reading" | "reading_form" | "proposing" | "drafting" | "navigating" | "writing";
|
|
36
|
+
export type AgentStep = "thinking" | "searching" | "reading" | "reading_form" | "viewing" | "proposing" | "drafting" | "navigating" | "writing";
|
|
37
37
|
export type AgentEvent = {
|
|
38
38
|
type: "step";
|
|
39
39
|
step: AgentStep;
|
package/dist/runtime/agent.js
CHANGED
|
@@ -2,7 +2,7 @@ import { PACKAGE_NAME } from "../package-name.js";
|
|
|
2
2
|
import { loadTurnContext } from "./context.js";
|
|
3
3
|
import { needsProposeRetry } from "./propose-loop.js";
|
|
4
4
|
import { buildSystemPrompt } from "./system-prompt.js";
|
|
5
|
-
import { executeTool, fromProviderToolName, TOOL_DESCRIPTIONS, TOOL_JSON_SCHEMAS, toProviderToolName, } from "./tools.js";
|
|
5
|
+
import { executeTool, fromProviderToolName, TOOL_DESCRIPTIONS, TOOL_JSON_SCHEMAS, toolModelOutput, toProviderToolName, } from "./tools.js";
|
|
6
6
|
import { contentLanguageNote, editorFacingText } from "./voice.js";
|
|
7
7
|
const MAX_MESSAGES = 20;
|
|
8
8
|
const MAX_STEPS = 8;
|
|
@@ -12,6 +12,7 @@ const STEP_FOR_TOOL = {
|
|
|
12
12
|
"content.find": "searching",
|
|
13
13
|
"content.findById": "reading",
|
|
14
14
|
"form.read": "reading_form",
|
|
15
|
+
"media.view": "viewing",
|
|
15
16
|
"form.propose": "proposing",
|
|
16
17
|
"draft.create": "drafting",
|
|
17
18
|
"admin.navigate": "navigating",
|
|
@@ -228,7 +229,8 @@ function buildSdkTools(ai, ctx, captured, previews, emit) {
|
|
|
228
229
|
type: "step",
|
|
229
230
|
step: executed.ok && executed.pendingApproval ? "writing" : "thinking",
|
|
230
231
|
});
|
|
231
|
-
|
|
232
|
+
// The image goes to the model through toModelOutput; Admin never needs the bytes.
|
|
233
|
+
captured.push(outcome.ok ? { ...outcome, image: undefined } : outcome);
|
|
232
234
|
return outcome;
|
|
233
235
|
}
|
|
234
236
|
catch {
|
|
@@ -242,6 +244,7 @@ function buildSdkTools(ai, ctx, captured, previews, emit) {
|
|
|
242
244
|
return outcome;
|
|
243
245
|
}
|
|
244
246
|
},
|
|
247
|
+
toModelOutput: ({ output }) => toolModelOutput(output),
|
|
245
248
|
});
|
|
246
249
|
}
|
|
247
250
|
return tools;
|
|
@@ -30,10 +30,22 @@ 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}${selectionLine(turnContext)}`;
|
|
33
|
+
return `The editor is looking at ${title} in ${language}. Do not ask them which document it is.${sources}${imageLine(bridge, turnContext)}${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
|
+
/** Without seeing the image, a model writes alt texts from the filename; say when it can look. */
|
|
38
|
+
function imageLine(bridge, turnContext) {
|
|
39
|
+
const entry = turnContext?.allowlist.find((entity) => entity.kind === "collection" && entity.slug === bridge.collection);
|
|
40
|
+
if (entry?.capabilities.includes("media.view")) {
|
|
41
|
+
return ' The open document may be an image. You cannot see it until you call media.view. Before writing an alt text, a caption or anything about what the image shows, call media.view in this turn; never guess from the filename. An alt text says in one sentence, under 125 characters, what the image shows and what matters in it, without starting with "image of" or "bild av".';
|
|
42
|
+
}
|
|
43
|
+
const mimeType = turnContext?.form?.mimeType;
|
|
44
|
+
if (typeof mimeType === "string" && mimeType.startsWith("image/")) {
|
|
45
|
+
return " The open document is an image you cannot see. Never write an alt text or describe what it shows; tell the editor you cannot look at images here.";
|
|
46
|
+
}
|
|
47
|
+
return "";
|
|
48
|
+
}
|
|
37
49
|
function selectionLine(turnContext) {
|
|
38
50
|
const selection = turnContext?.selection;
|
|
39
51
|
if (!selection) {
|
package/dist/runtime/tools.d.ts
CHANGED
|
@@ -51,6 +51,11 @@ export type ToolSuccess = {
|
|
|
51
51
|
};
|
|
52
52
|
/** For Admin only; the agent keeps it out of what the model sees. */
|
|
53
53
|
preview?: ProposalPreview;
|
|
54
|
+
/** For the model only, as a file part (see `toolModelOutput`); never sent to Admin. */
|
|
55
|
+
image?: {
|
|
56
|
+
mediaType: string;
|
|
57
|
+
data: string;
|
|
58
|
+
};
|
|
54
59
|
};
|
|
55
60
|
export type ToolFailure = {
|
|
56
61
|
ok: false;
|
|
@@ -65,3 +70,24 @@ export declare const TOOL_DESCRIPTIONS: Record<string, string>;
|
|
|
65
70
|
export declare function toProviderToolName(capability: string): string;
|
|
66
71
|
export declare function fromProviderToolName(name: string): string;
|
|
67
72
|
export declare function executeTool(name: string, input: Record<string, unknown>, ctx: ToolContext): Promise<ToolOutcome>;
|
|
73
|
+
/** What the model gets back from a tool: an image as a file part next to the rest as text. */
|
|
74
|
+
export declare function toolModelOutput(outcome: ToolOutcome): {
|
|
75
|
+
type: "json";
|
|
76
|
+
value: ToolOutcome;
|
|
77
|
+
} | {
|
|
78
|
+
type: "content";
|
|
79
|
+
value: ({
|
|
80
|
+
type: "text";
|
|
81
|
+
text: string;
|
|
82
|
+
mediaType?: undefined;
|
|
83
|
+
data?: undefined;
|
|
84
|
+
} | {
|
|
85
|
+
type: "file";
|
|
86
|
+
mediaType: string;
|
|
87
|
+
data: {
|
|
88
|
+
type: "data";
|
|
89
|
+
data: string;
|
|
90
|
+
};
|
|
91
|
+
text?: undefined;
|
|
92
|
+
})[];
|
|
93
|
+
};
|
package/dist/runtime/tools.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { writeAudit } from "../domain/audit.js";
|
|
2
2
|
import { findContent, findContentById } from "../domain/content.js";
|
|
3
|
+
import { viewMedia } from "../domain/media.js";
|
|
3
4
|
import { canNavigate, navigateAdmin } from "../domain/navigate.js";
|
|
4
5
|
import { proposeChange } from "../domain/propose.js";
|
|
5
6
|
import { discoverSchema, entityFields } from "../schema/discover.js";
|
|
@@ -31,6 +32,10 @@ export const TOOL_JSON_SCHEMAS = {
|
|
|
31
32
|
type: "object",
|
|
32
33
|
properties: {},
|
|
33
34
|
},
|
|
35
|
+
"media.view": {
|
|
36
|
+
type: "object",
|
|
37
|
+
properties: {},
|
|
38
|
+
},
|
|
34
39
|
"admin.navigate": {
|
|
35
40
|
type: "object",
|
|
36
41
|
additionalProperties: false,
|
|
@@ -66,6 +71,7 @@ export const TOOL_DESCRIPTIONS = {
|
|
|
66
71
|
"content.find": "Search or list allowlisted documents by title, slug, or id. Omit query to list recent documents. Returns identity only, not a summary.",
|
|
67
72
|
"content.findById": "Read one allowlisted document or global. Pass locale to read another language of the same document. For the currently open form, prefer form.read.",
|
|
68
73
|
"form.read": "Read the currently open unsaved form in the current language. No document ID needed.",
|
|
74
|
+
"media.view": "Look at the image of the open media document. Call it before writing or judging an alt text, caption or anything else about what the image shows. No document ID needed.",
|
|
69
75
|
"form.propose": 'Stage a structured patch against the open unsaved form in the current language. Call this as soon as you have the new field values. Do not ask the editor to confirm in chat; they approve in the panel. Example: {"op":"set","path":"layout.0.headline","value":"Välkommen"}. Requires editor approval.',
|
|
70
76
|
"admin.navigate": "Navigate Admin to an allowlisted collection, global, or unique document match.",
|
|
71
77
|
"draft.create": 'Stage creation of one draft document. Requires editor approval. Never publishes. The document starts empty: set top-level fields with {"op":"set","path":"title","value":"Start"}, and add each layout block with {"op":"blocks.insert","path":"layout","index":0,"blockType":"hero","data":{"headline":"Välkommen"}} using only block types and fields from the quoted context. Rich text fields take plain text.',
|
|
@@ -115,6 +121,17 @@ export async function executeTool(name, input, ctx) {
|
|
|
115
121
|
const projected = projectRecord(ctx.formData ?? {}, fields, ctx.options.redact, ctx.bridge.collection ?? ctx.bridge.global);
|
|
116
122
|
return { ok: true, capability: name, data: projected };
|
|
117
123
|
}
|
|
124
|
+
case "media.view": {
|
|
125
|
+
// Only the open document: its id comes from the bridge, never from the model.
|
|
126
|
+
const viewed = await viewMedia(ctx.req, ctx.options, {
|
|
127
|
+
collection: ctx.bridge.collection,
|
|
128
|
+
documentId: ctx.bridge.documentId,
|
|
129
|
+
});
|
|
130
|
+
if (!viewed.ok) {
|
|
131
|
+
return { ok: false, capability: name, error: viewed.error, message: viewed.message };
|
|
132
|
+
}
|
|
133
|
+
return { ok: true, capability: name, data: viewed.data, image: viewed.image };
|
|
134
|
+
}
|
|
118
135
|
case "admin.navigate": {
|
|
119
136
|
if (!canNavigate(ctx.options)) {
|
|
120
137
|
return {
|
|
@@ -179,6 +196,24 @@ export async function executeTool(name, input, ctx) {
|
|
|
179
196
|
return { ok: false, capability: name, error: "tool_denied", message: "Unknown tool." };
|
|
180
197
|
}
|
|
181
198
|
}
|
|
199
|
+
/** What the model gets back from a tool: an image as a file part next to the rest as text. */
|
|
200
|
+
export function toolModelOutput(outcome) {
|
|
201
|
+
if (!outcome.ok || !outcome.image) {
|
|
202
|
+
return { type: "json", value: outcome };
|
|
203
|
+
}
|
|
204
|
+
const { image, ...rest } = outcome;
|
|
205
|
+
return {
|
|
206
|
+
type: "content",
|
|
207
|
+
value: [
|
|
208
|
+
{ type: "text", text: JSON.stringify(rest) },
|
|
209
|
+
{
|
|
210
|
+
type: "file",
|
|
211
|
+
mediaType: image.mediaType,
|
|
212
|
+
data: { type: "data", data: image.data },
|
|
213
|
+
},
|
|
214
|
+
],
|
|
215
|
+
};
|
|
216
|
+
}
|
|
182
217
|
function wrap(capability, result) {
|
|
183
218
|
if (!result.ok) {
|
|
184
219
|
return { ok: false, capability, error: result.error, message: result.message };
|
package/dist/runtime/voice.js
CHANGED
|
@@ -36,7 +36,8 @@ const OVERCLAIM = /godkänn|godkann|approve|spara|sparat|sparad|sparats|publicer
|
|
|
36
36
|
const MAX_OWN_TEXT = 600;
|
|
37
37
|
export function withoutOverclaims(text) {
|
|
38
38
|
const kept = splitSentences(text)
|
|
39
|
-
|
|
39
|
+
// A quoted proposal ("Spara 20 %") is the editor's content, not a claim about the panel.
|
|
40
|
+
.filter((sentence) => !OVERCLAIM.test(withoutQuotes(sentence)))
|
|
40
41
|
.join(" ")
|
|
41
42
|
.trim();
|
|
42
43
|
return kept.length <= MAX_OWN_TEXT ? kept : "";
|
|
@@ -62,12 +63,42 @@ function doneCopy(locale) {
|
|
|
62
63
|
function isSwedish(locale) {
|
|
63
64
|
return locale === "sv" || locale.startsWith("sv-");
|
|
64
65
|
}
|
|
66
|
+
const QUOTES = '"“”„«»';
|
|
67
|
+
const QUOTED = /["“”„«»][^"“”„«»]*["“”„«»]/g;
|
|
68
|
+
/**
|
|
69
|
+
* Sentences, keeping a quoted proposal ("Click. Edit. Done.") inside the sentence that quotes it.
|
|
70
|
+
* A line with an unclosed quote is split as if it had none, so the quote cannot hide a claim.
|
|
71
|
+
*/
|
|
65
72
|
function splitSentences(text) {
|
|
66
73
|
return text
|
|
67
|
-
.split(
|
|
74
|
+
.split(/\n+/)
|
|
75
|
+
.flatMap((line) => {
|
|
76
|
+
const quotes = [...line].filter((char) => QUOTES.includes(char)).length;
|
|
77
|
+
if (quotes % 2 !== 0) {
|
|
78
|
+
return line.split(/(?<=[.!?])\s+/);
|
|
79
|
+
}
|
|
80
|
+
const sentences = [];
|
|
81
|
+
let start = 0;
|
|
82
|
+
let quoted = false;
|
|
83
|
+
for (let i = 0; i < line.length; i++) {
|
|
84
|
+
const char = line[i] ?? "";
|
|
85
|
+
if (QUOTES.includes(char)) {
|
|
86
|
+
quoted = !quoted;
|
|
87
|
+
}
|
|
88
|
+
else if (!quoted && /\s/.test(char) && /[.!?]["“”»]?$/.test(line.slice(start, i))) {
|
|
89
|
+
sentences.push(line.slice(start, i));
|
|
90
|
+
start = i + 1;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
sentences.push(line.slice(start));
|
|
94
|
+
return sentences;
|
|
95
|
+
})
|
|
68
96
|
.map((part) => part.trim())
|
|
69
97
|
.filter(Boolean);
|
|
70
98
|
}
|
|
99
|
+
function withoutQuotes(sentence) {
|
|
100
|
+
return sentence.replace(QUOTED, "");
|
|
101
|
+
}
|
|
71
102
|
const CONTENT_LANGUAGE = {
|
|
72
103
|
en: { sv: "engelska", en: "English" },
|
|
73
104
|
sv: { sv: "svenska", en: "Swedish" },
|