@simmalugnt-se/payload-editor-assistant 0.4.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 CHANGED
@@ -1,5 +1,40 @@
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
+
17
+ ## 0.5.0
18
+
19
+ - `next` is a required peer dependency: the panel imports `next/navigation`, so it never worked
20
+ without it.
21
+
22
+ - Makes room in Admin: the panel is 320–400px wide depending on the window, and where fewer than
23
+ 1440px would be left for Admin, Payload's nav and the panel take turns. Opening one closes the
24
+ other, which comes back when the first closes. While the assistant answers or a proposal waits for
25
+ Keep or Undo, opening the nav leaves the panel open.
26
+ - Works with the content health work list: `?ask=<prompt>` on a document's address opens the
27
+ assistant with the prompt in the message box (the editor sends it), and a `content-health:select`
28
+ event selects a field like a preview click. The allowlisted collections are published as
29
+ `config.admin.custom.editorAssistant.collections`.
30
+
31
+ - When the editor chats in one language and edits content in another, the first proposal says so
32
+ once, in a sentence the plugin adds; the model is no longer asked to, since it repeated it in
33
+ every reply or said it only in a later one.
34
+ - The reply to a proposal offers it ("Jag föreslår en varmare rubrik.") instead of describing it as
35
+ done: the prompt asks for that, and a sentence saying a field "är nu"/"is now" something is left
36
+ out.
37
+
3
38
  ## 0.4.0
4
39
 
5
40
  - A suggestion in the form stays open while the editor writes on, so "shorter" or "another tone"
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 provider of
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, the AI SDK and a provider (DeepSeek here; any AI SDK provider works):
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 @ai-sdk/deepseek
17
+ pnpm add @simmalugnt-se/payload-editor-assistant ai
16
18
  ```
17
19
 
18
- 2. Put the provider's API key in the server environment, e.g. `.env.local` locally and the
19
- hosting provider's environment variables in production. Never expose it to the browser
20
- (no `NEXT_PUBLIC_` prefix):
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
- DEEPSEEK_API_KEY=sk-...
25
+ AI_GATEWAY_API_KEY=...
24
26
  ```
25
27
 
26
- 3. Create the model with that key and pass it to the plugin:
28
+ 3. Create the model and pass it to the plugin:
27
29
 
28
30
  ```ts
29
- import { createDeepSeek } from "@ai-sdk/deepseek";
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: deepSeek("deepseek-chat"),
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 provider, install another AI SDK provider and create the model
55
- with its key, e.g. `createAnthropic({ apiKey: process.env.ANTHROPIC_API_KEY })`.
56
-
57
- Providers also read a default environment variable (`DEEPSEEK_API_KEY`, `ANTHROPIC_API_KEY`, ...)
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: () => (process.env.DEEPSEEK_API_KEY ? deepSeek("deepseek-chat") : null),
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
@@ -112,6 +131,30 @@ clicks in the assistant keep it.
112
131
  Preview clicks need `@simmalugnt-se/payload-visual-editing` (0.2.0 or later), which announces them
113
132
  with a `visual-editing:select` event. Without it the selection follows the form only.
114
133
 
134
+ ## From the content health work list
135
+
136
+ `@simmalugnt-se/payload-content-health` lists content issues and offers "Fix with the assistant" in
137
+ the collections the assistant works in. The link opens the document with the field selected (a
138
+ `content-health:select` event) and `?ask=<prompt>` in the address: the assistant opens with the
139
+ prompt in the message box, and the editor sends it. A link alone never makes the assistant act.
140
+
141
+ The plugin publishes its allowlisted collections as `config.admin.custom.editorAssistant.collections`
142
+ so the work list knows where to offer it; the packages do not import each other.
143
+
144
+ ## Room for the panel
145
+
146
+ The panel takes 320–400px of the window, depending on its width. Payload lays out Admin by the
147
+ window's width and does not know about the panel. Where fewer than 1440px would be left for Admin
148
+ (the width at which Payload closes its nav itself), the nav and the panel take turns: opening the
149
+ panel closes the nav, opening the nav closes the panel, and the other comes back when you close it.
150
+ While the assistant is answering or a proposal waits for **Keep** or **Undo**, opening the nav leaves
151
+ the panel open, so the decision stays in view. Payload's saved nav preference and your own choice to
152
+ show the panel are not changed.
153
+
154
+ Live Preview splits the rest 40/60 between the form and the preview. From a 1440px window that leaves
155
+ a usable form beside the preview; on smaller screens, open the preview in its own window with the
156
+ external link button at the end of Live Preview's toolbar.
157
+
115
158
  ## While it works
116
159
 
117
160
  The panel shows what the assistant is doing (reading the form, searching content, preparing a
@@ -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;
@@ -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) {
@@ -11,7 +11,7 @@ import { COPY, uiLanguage } from "./copy.js";
11
11
  import { isFormProposal, proposalSteps, refineProposal, replaceLatestStep, } from "./proposal-chain.js";
12
12
  import { KEEP_SELECTION_ATTR } from "./selection-dom.js";
13
13
  export function EditorAssistantAside() {
14
- const { open, setOpen, status, messages, shortcuts, collections, bridge, formAdapter, pendingApproval, selection, setSelection, resetConversation, setStatus, setMessages, setPendingApproval, stop, setAbortController, } = useAssistantState();
14
+ const { open, setOpen, status, messages, shortcuts, collections, bridge, formAdapter, pendingApproval, selection, setSelection, prefill, setPrefill, resetConversation, setStatus, setMessages, setPendingApproval, stop, setAbortController, } = useAssistantState();
15
15
  const { config, getEntityConfig } = useConfig();
16
16
  const router = useRouter();
17
17
  const locale = useLocale();
@@ -24,6 +24,13 @@ export function EditorAssistantAside() {
24
24
  const view = parseAdminView(pathname ?? "", config.routes.admin);
25
25
  const visibleShortcuts = shortcuts.filter((shortcut) => shortcutMatches(shortcut, view));
26
26
  const [draft, setDraft] = useState("");
27
+ // A ready prompt replaces the draft; it is only sent when the editor sends it.
28
+ useEffect(() => {
29
+ if (prefill !== null) {
30
+ setDraft(prefill);
31
+ setPrefill(null);
32
+ }
33
+ }, [prefill, setPrefill]);
27
34
  const threadRef = useRef(null);
28
35
  /** Whether the editor typed in the form while a preview was in it; undo would discard that. */
29
36
  const editedSinceRef = useRef(false);
@@ -2,16 +2,18 @@
2
2
  import { useDocumentForm, useDocumentInfo, useFormModified, useLocale } from "@payloadcms/ui";
3
3
  import { useEffect, useRef } from "react";
4
4
  import { useAssistantState } from "./assistant-context.js";
5
- import { selectionFromElement, selectionFromPath, VISUAL_EDITING_SELECT_EVENT, } from "./selection-dom.js";
5
+ import { ASK_PARAM, CONTENT_HEALTH_SELECT_EVENT, selectionFromElement, selectionFromPath, VISUAL_EDITING_SELECT_EVENT, } from "./selection-dom.js";
6
6
  /** Collapsed rows render their labels only once visual editing has opened them. */
7
7
  const RELABEL_MS = [150, 500, 1200];
8
8
  export function EditorAssistantDocumentBridge() {
9
- const { setBridge, setFormAdapter, setSelection } = useAssistantState();
9
+ const { setBridge, setFormAdapter, setOpen, setPrefill, setSelection } = useAssistantState();
10
10
  const documentInfo = useDocumentInfo();
11
11
  const locale = useLocale();
12
12
  const modified = useFormModified();
13
13
  const form = useDocumentForm();
14
14
  const formRef = useRef(form);
15
+ // Kept across effect re-runs: the parameter is gone from the address after the first read.
16
+ const asked = useRef(false);
15
17
  useEffect(() => {
16
18
  formRef.current = form;
17
19
  });
@@ -25,8 +27,27 @@ export function EditorAssistantDocumentBridge() {
25
27
  setFormAdapter(null);
26
28
  };
27
29
  }, [setFormAdapter]);
30
+ // A work list link (`?ask=`) opens the panel with a ready prompt. The editor sends it, so a
31
+ // link alone never makes the assistant act. The parameter is removed once read.
32
+ useEffect(() => {
33
+ if (asked.current) {
34
+ return;
35
+ }
36
+ asked.current = true;
37
+ const url = new URL(window.location.href);
38
+ const prompt = url.searchParams.get(ASK_PARAM)?.trim();
39
+ if (prompt === undefined) {
40
+ return;
41
+ }
42
+ url.searchParams.delete(ASK_PARAM);
43
+ window.history.replaceState(window.history.state, "", url);
44
+ if (prompt) {
45
+ setPrefill(prompt);
46
+ setOpen(true);
47
+ }
48
+ }, [setOpen, setPrefill]);
28
49
  // "This" in the editor's message is what they last clicked or focused: a field, a block or a row,
29
- // in the form or (through visual editing) in the preview. Clicks elsewhere in Admin clear it;
50
+ // in the form, (through visual editing) in the preview, or brought into view by content health. Clicks elsewhere in Admin clear it;
30
51
  // clicks in the assistant itself keep it, so the editor can click, then type.
31
52
  useEffect(() => {
32
53
  let relabel = [];
@@ -65,11 +86,13 @@ export function EditorAssistantDocumentBridge() {
65
86
  document.addEventListener("pointerdown", onInteract, true);
66
87
  document.addEventListener("focusin", onInteract);
67
88
  window.addEventListener(VISUAL_EDITING_SELECT_EVENT, onPreviewSelect);
89
+ window.addEventListener(CONTENT_HEALTH_SELECT_EVENT, onPreviewSelect);
68
90
  return () => {
69
91
  cancelRelabel();
70
92
  document.removeEventListener("pointerdown", onInteract, true);
71
93
  document.removeEventListener("focusin", onInteract);
72
94
  window.removeEventListener(VISUAL_EDITING_SELECT_EVENT, onPreviewSelect);
95
+ window.removeEventListener(CONTENT_HEALTH_SELECT_EVENT, onPreviewSelect);
73
96
  setSelection(null);
74
97
  };
75
98
  }, [setSelection]);
@@ -5,11 +5,13 @@ import "../styles/assistant.css";
5
5
  import { AssistantStateProvider, useAssistantState } from "./assistant-context.js";
6
6
  import { ChangedFieldsHighlight } from "./ChangedFieldsHighlight.js";
7
7
  import { EditorAssistantAside } from "./EditorAssistantAside.js";
8
+ import { useNavRoom } from "./nav-room.js";
8
9
  export function EditorAssistantProvider({ children, shortcuts = [], collections = [], }) {
9
10
  const { user } = useAuth();
10
11
  return (_jsx(AssistantStateProvider, { shortcuts: shortcuts, collections: collections, userId: user?.id, children: user ? _jsx(EditorAssistantShell, { children: children }) : children }));
11
12
  }
12
13
  function EditorAssistantShell({ children }) {
13
14
  const { open } = useAssistantState();
15
+ useNavRoom();
14
16
  return (_jsxs("div", { className: open ? "ea-root" : "ea-root ea-root--closed", children: [_jsx("div", { className: "ea-main", children: children }), _jsx(EditorAssistantAside, {}), _jsx(ChangedFieldsHighlight, {})] }));
15
17
  }
@@ -68,11 +68,17 @@ type AssistantContextValue = {
68
68
  pendingApproval: PendingApproval | null;
69
69
  /** What the editor last clicked or focused in the open form. */
70
70
  selection: DomSelection | null;
71
- setOpen: (open: boolean) => void;
71
+ /** A prompt to put in the message box, e.g. from a work list link; the panel takes it once. */
72
+ prefill: string | null;
73
+ /** `remember: false` when the panel makes way on its own (see nav-room.ts), not the editor. */
74
+ setOpen: (open: boolean, options?: {
75
+ remember?: boolean;
76
+ }) => void;
72
77
  setBridge: (bridge: DocumentBridgeSnapshot | null) => void;
73
78
  setFormAdapter: (adapter: FormAdapter | null) => void;
74
79
  setPendingApproval: (pending: PendingApproval | null) => void;
75
80
  setSelection: (selection: DomSelection | null) => void;
81
+ setPrefill: (prefill: string | null) => void;
76
82
  setStatus: (status: AssistantChromeStatus) => void;
77
83
  setMessages: (updater: (current: AssistantMessage[]) => AssistantMessage[]) => void;
78
84
  resetConversation: () => void;
@@ -12,14 +12,17 @@ export function AssistantStateProvider({ children, shortcuts, collections, userI
12
12
  const [pendingApproval, setPendingApproval] = useState(null);
13
13
  const [abortController, setAbortController] = useState(null);
14
14
  const [selection, setSelection] = useState(null);
15
+ const [prefill, setPrefill] = useState(null);
15
16
  // Read after mount, so the server render (closed) and the first client render agree.
16
17
  useEffect(() => {
17
18
  if (readStoredOpen())
18
19
  setOpenState(true);
19
20
  }, []);
20
- const setOpen = useCallback((next) => {
21
+ const setOpen = useCallback((next, { remember = true } = {}) => {
21
22
  setOpenState(next);
22
- storeOpen(next);
23
+ if (remember) {
24
+ storeOpen(next);
25
+ }
23
26
  }, []);
24
27
  // One conversation per document: switching documents stores the thread and loads the next one.
25
28
  // An open proposal belongs to the form it was made for, so it does not follow; an unfinished
@@ -72,11 +75,13 @@ export function AssistantStateProvider({ children, shortcuts, collections, userI
72
75
  formAdapter,
73
76
  pendingApproval,
74
77
  selection,
78
+ prefill,
75
79
  setOpen,
76
80
  setBridge,
77
81
  setFormAdapter,
78
82
  setPendingApproval,
79
83
  setSelection,
84
+ setPrefill,
80
85
  setStatus,
81
86
  setMessages,
82
87
  resetConversation,
@@ -93,6 +98,7 @@ export function AssistantStateProvider({ children, shortcuts, collections, userI
93
98
  formAdapter,
94
99
  pendingApproval,
95
100
  selection,
101
+ prefill,
96
102
  setOpen,
97
103
  resetConversation,
98
104
  stop,
@@ -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…";
@@ -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…",
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Where Admin is too narrow for both, Payload's nav and the panel take turns: whichever the editor
3
+ * opened last stays, and the other comes back when it closes.
4
+ *
5
+ * - Opening the panel closes the nav; closing the panel opens it again.
6
+ * - Opening the nav closes the panel; closing the nav opens it again. Not while the assistant
7
+ * answers or a proposal waits for Keep or Undo: then both stay, so the decision stays in view.
8
+ *
9
+ * Payload's saved nav preference and the editor's own panel choice are not changed.
10
+ */
11
+ export declare function useNavRoom(): void;
@@ -0,0 +1,94 @@
1
+ "use client";
2
+ import { useNav } from "@payloadcms/ui";
3
+ import { useEffect, useRef, useState } from "react";
4
+ import { useAssistantState } from "./assistant-context.js";
5
+ /**
6
+ * Payload lays out Admin by the window's width, not knowing the panel takes part of it. It closes
7
+ * its nav at 1440px and below; with the panel open, the space left is that narrow up to 1840px
8
+ * (the panel is `clamp(320px, 22vw, 400px)` wide, see assistant.css). Keep the two in step.
9
+ */
10
+ const LEAVES_NARROW_ADMIN = "(max-width: 1840px)";
11
+ /**
12
+ * Where Admin is too narrow for both, Payload's nav and the panel take turns: whichever the editor
13
+ * opened last stays, and the other comes back when it closes.
14
+ *
15
+ * - Opening the panel closes the nav; closing the panel opens it again.
16
+ * - Opening the nav closes the panel; closing the nav opens it again. Not while the assistant
17
+ * answers or a proposal waits for Keep or Undo: then both stay, so the decision stays in view.
18
+ *
19
+ * Payload's saved nav preference and the editor's own panel choice are not changed.
20
+ */
21
+ export function useNavRoom() {
22
+ const { navOpen, setNavOpen } = useNav();
23
+ const { open, setOpen, status, pendingApproval } = useAssistantState();
24
+ const busy = status === "streaming" || pendingApproval !== null;
25
+ const [narrow, setNarrow] = useState(false);
26
+ const navOpenRef = useRef(navOpen);
27
+ navOpenRef.current = navOpen;
28
+ /** The editor opened the nav with its toggler (not Payload restoring it on load). */
29
+ const editorOpenedNav = useRef(false);
30
+ /** The nav was opened while the panel was busy; leave it open until the panel closes. */
31
+ const navKept = useRef(false);
32
+ const closedNav = useRef(false);
33
+ const closedPanel = useRef(false);
34
+ useEffect(() => {
35
+ const query = window.matchMedia(LEAVES_NARROW_ADMIN);
36
+ const update = () => setNarrow(query.matches);
37
+ update();
38
+ query.addEventListener("change", update);
39
+ return () => query.removeEventListener("change", update);
40
+ }, []);
41
+ useEffect(() => {
42
+ const onClick = (event) => {
43
+ if (event.target instanceof Element &&
44
+ event.target.closest(".nav-toggler") &&
45
+ !navOpenRef.current) {
46
+ editorOpenedNav.current = true;
47
+ }
48
+ };
49
+ document.addEventListener("click", onClick, true);
50
+ return () => document.removeEventListener("click", onClick, true);
51
+ }, []);
52
+ useEffect(() => {
53
+ if (!navOpen) {
54
+ editorOpenedNav.current = false;
55
+ navKept.current = false;
56
+ if (closedPanel.current) {
57
+ closedPanel.current = false;
58
+ setOpen(true, { remember: false });
59
+ }
60
+ return;
61
+ }
62
+ if (!open || !narrow) {
63
+ editorOpenedNav.current = false;
64
+ return;
65
+ }
66
+ if (editorOpenedNav.current) {
67
+ editorOpenedNav.current = false;
68
+ if (busy) {
69
+ navKept.current = true;
70
+ }
71
+ else {
72
+ closedNav.current = false;
73
+ closedPanel.current = true;
74
+ setOpen(false, { remember: false });
75
+ }
76
+ return;
77
+ }
78
+ // The panel opened, or Payload opened the nav from the saved preference after the page loaded.
79
+ if (!navKept.current) {
80
+ closedNav.current = true;
81
+ setNavOpen(false);
82
+ }
83
+ }, [open, narrow, navOpen, busy, setNavOpen, setOpen]);
84
+ useEffect(() => {
85
+ if (open) {
86
+ return;
87
+ }
88
+ if (closedNav.current) {
89
+ setNavOpen(true);
90
+ }
91
+ closedNav.current = false;
92
+ navKept.current = false;
93
+ }, [open, setNavOpen]);
94
+ }
@@ -5,6 +5,10 @@
5
5
  */
6
6
  /** Visual editing announces preview clicks with this event: `detail: { path }`, `path: null` when cleared. */
7
7
  export declare const VISUAL_EDITING_SELECT_EVENT = "visual-editing:select";
8
+ /** Content health announces a field it brought into view from a work list link: `detail: { path }`. */
9
+ export declare const CONTENT_HEALTH_SELECT_EVENT = "content-health:select";
10
+ /** Edit view parameter from content health's work list: a prompt to put in the message box. */
11
+ export declare const ASK_PARAM = "ask";
8
12
  /** Visual editing keeps its preview outline when the editor clicks inside this attribute. */
9
13
  export declare const KEEP_SELECTION_ATTR = "data-visual-editing-keep-selection";
10
14
  export type DomSelection = {
@@ -5,6 +5,10 @@
5
5
  */
6
6
  /** Visual editing announces preview clicks with this event: `detail: { path }`, `path: null` when cleared. */
7
7
  export const VISUAL_EDITING_SELECT_EVENT = "visual-editing:select";
8
+ /** Content health announces a field it brought into view from a work list link: `detail: { path }`. */
9
+ export const CONTENT_HEALTH_SELECT_EVENT = "content-health:select";
10
+ /** Edit view parameter from content health's work list: a prompt to put in the message box. */
11
+ export const ASK_PARAM = "ask";
8
12
  /** Visual editing keeps its preview outline when the editor clicks inside this attribute. */
9
13
  export const KEEP_SELECTION_ATTR = "data-visual-editing-keep-selection";
10
14
  export function selectionFromElement(element) {
@@ -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
+ }
@@ -5,3 +5,8 @@ export declare const DEFAULT_AUDIT_SLUG = "editor-assistant-audit";
5
5
  export declare const DEFAULT_APPROVAL_SLUG = "editor-assistant-approvals";
6
6
  export declare const CSRF_COOKIE = "payload-editor-assistant-csrf";
7
7
  export declare const CSRF_HEADER = "x-editor-assistant-csrf";
8
+ /**
9
+ * `config.admin.custom[ADMIN_CUSTOM_KEY] = { collections }`: the allowlisted collections, for other
10
+ * plugins (content health's work list) to know where the assistant works without importing it.
11
+ */
12
+ export declare const ADMIN_CUSTOM_KEY = "editorAssistant";
@@ -5,3 +5,8 @@ export const DEFAULT_AUDIT_SLUG = "editor-assistant-audit";
5
5
  export const DEFAULT_APPROVAL_SLUG = "editor-assistant-approvals";
6
6
  export const CSRF_COOKIE = "payload-editor-assistant-csrf";
7
7
  export const CSRF_HEADER = "x-editor-assistant-csrf";
8
+ /**
9
+ * `config.admin.custom[ADMIN_CUSTOM_KEY] = { collections }`: the allowlisted collections, for other
10
+ * plugins (content health's work list) to know where the assistant works without importing it.
11
+ */
12
+ export const ADMIN_CUSTOM_KEY = "editorAssistant";
package/dist/plugin.js CHANGED
@@ -6,7 +6,7 @@ import { createChatEndpoint } from "./endpoints/chat.js";
6
6
  import { createCsrfEndpoint } from "./endpoints/csrf.js";
7
7
  import { createDecideEndpoint } from "./endpoints/decide.js";
8
8
  import { createProposeEndpoint } from "./endpoints/propose.js";
9
- import { CLIENT_ENTRY, DEFAULT_APPROVAL_SLUG } from "./package-name.js";
9
+ import { ADMIN_CUSTOM_KEY, CLIENT_ENTRY, DEFAULT_APPROVAL_SLUG } from "./package-name.js";
10
10
  export function editorAssistantPlugin(options = {}) {
11
11
  const validated = validateEditorAssistantOptions(options);
12
12
  return (config) => {
@@ -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
  }
@@ -40,6 +44,11 @@ export function editorAssistantPlugin(options = {}) {
40
44
  ...config,
41
45
  admin: {
42
46
  ...config.admin,
47
+ // Read by @simmalugnt-se/payload-content-health, which offers the assistant only here.
48
+ custom: {
49
+ ...config.admin?.custom,
50
+ [ADMIN_CUSTOM_KEY]: { collections: Object.keys(validated.collections) },
51
+ },
43
52
  components: {
44
53
  ...config.admin?.components,
45
54
  providers: [
@@ -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;
@@ -46,15 +46,18 @@ export type AgentEvent = {
46
46
  }>["pendingApproval"]>;
47
47
  };
48
48
  export type AgentEventSink = (event: AgentEvent) => void;
49
- export declare function runAgentTurn(req: PayloadRequest, options: ValidatedEditorAssistantOptions, input: {
49
+ type TurnInput = {
50
50
  message: string;
51
51
  messages?: ChatMessage[];
52
52
  bridge: ToolContext["bridge"];
53
53
  formData?: Record<string, unknown>;
54
54
  onEvent?: AgentEventSink;
55
- }): Promise<AgentChatResult>;
55
+ };
56
+ export declare function runAgentTurn(req: PayloadRequest, options: ValidatedEditorAssistantOptions, input: TurnInput): Promise<AgentChatResult>;
56
57
  export declare function collectResult(text: string, result: {
57
58
  steps?: unknown;
58
59
  toolResults?: unknown;
59
60
  }, captured: ToolOutcome[], locale: string, expectedProposal?: boolean, previews?: Map<string, ProposalPreview>): AgentChatResult;
61
+ /** Whether a reply in the conversation already said which language the content is in. */
62
+ export declare function contentLanguageNoted(history: ChatMessage[], bridge: Pick<ToolContext["bridge"], "locale">): boolean;
60
63
  export {};
@@ -2,8 +2,8 @@ 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";
6
- import { editorFacingText } from "./voice.js";
5
+ import { executeTool, fromProviderToolName, TOOL_DESCRIPTIONS, TOOL_JSON_SCHEMAS, toolModelOutput, toProviderToolName, } from "./tools.js";
6
+ import { contentLanguageNote, editorFacingText } from "./voice.js";
7
7
  const MAX_MESSAGES = 20;
8
8
  const MAX_STEPS = 8;
9
9
  const TIMEOUT_MS = 60_000;
@@ -12,11 +12,28 @@ 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",
18
19
  };
19
20
  export async function runAgentTurn(req, options, input) {
21
+ const result = await generateTurn(req, options, input);
22
+ if (!result.ok || result.pendingApproval?.capability !== "form.propose") {
23
+ return result;
24
+ }
25
+ // The first change written in another language than the chat's says so, once per conversation.
26
+ // Asked to in the prompt, models said it in every reply, or only in a later one.
27
+ const chat = input.bridge.language ?? input.bridge.locale;
28
+ const note = contentLanguageNote(chat, input.bridge.locale);
29
+ const history = input.messages ?? [];
30
+ if (!note ||
31
+ contentLanguageNoted([...history, { role: "assistant", text: result.text }], input.bridge)) {
32
+ return result;
33
+ }
34
+ return { ...result, text: `${note} ${result.text}` };
35
+ }
36
+ async function generateTurn(req, options, input) {
20
37
  const emit = (event) => {
21
38
  try {
22
39
  input.onEvent?.(event);
@@ -212,7 +229,8 @@ function buildSdkTools(ai, ctx, captured, previews, emit) {
212
229
  type: "step",
213
230
  step: executed.ok && executed.pendingApproval ? "writing" : "thinking",
214
231
  });
215
- captured.push(outcome);
232
+ // The image goes to the model through toModelOutput; Admin never needs the bytes.
233
+ captured.push(outcome.ok ? { ...outcome, image: undefined } : outcome);
216
234
  return outcome;
217
235
  }
218
236
  catch {
@@ -226,10 +244,21 @@ function buildSdkTools(ai, ctx, captured, previews, emit) {
226
244
  return outcome;
227
245
  }
228
246
  },
247
+ toModelOutput: ({ output }) => toolModelOutput(output),
229
248
  });
230
249
  }
231
250
  return tools;
232
251
  }
252
+ /** Names of the content language as the editor would read them, in either chat language. */
253
+ const LANGUAGE_NAMES = {
254
+ en: /\b(English)\b|engelska/i,
255
+ sv: /\b(Swedish)\b|svenska/i,
256
+ };
257
+ /** Whether a reply in the conversation already said which language the content is in. */
258
+ export function contentLanguageNoted(history, bridge) {
259
+ const name = LANGUAGE_NAMES[bridge.locale.split("-")[0]];
260
+ return Boolean(name && history.some((message) => message.role === "assistant" && name.test(message.text)));
261
+ }
233
262
  function truncateMessages(messages) {
234
263
  if (messages.length <= MAX_MESSAGES) {
235
264
  return messages;
@@ -7,7 +7,7 @@ export function buildSystemPrompt(instructions, bridge, turnContext) {
7
7
  "Two to five short sentences. Use visible field labels (Title, Headline, Summary).",
8
8
  "Never ask whether you should proceed. When you have the new values, call form.propose in the same turn.",
9
9
  "If the editor confirms a change you already described, call form.propose with those values. Do not ask again.",
10
- 'When you call form.propose, the change is shown in the form and preview for the editor to keep or undo. Then say in one short sentence what you changed ("Här är en kortare rubrik."). Never say it is saved, published or kept, and do not tell them to approve or click anything; the panel does that. Without a form.propose call, do not claim the form has new values.',
10
+ 'When you call form.propose, the change is shown in the form and preview for the editor to keep or undo. Then offer it in one short sentence, as a suggestion rather than something done: "Here is a shorter headline." or "Jag föreslår en varmare rubrik." Never write that a field "is now" or "är nu" something. Never say it is saved, published or kept, and do not tell them to approve or click anything; the panel does that. Without a form.propose call, do not claim the form has new values.',
11
11
  "If form.propose fails, say you could not apply it and they can try again. Never tell them to copy-paste.",
12
12
  "When the editor asks you to create a document, call draft.create in the same turn with sensible content based on their request. Do not ask for a title or summary first; they review the draft before it is created.",
13
13
  "Never save, publish, unpublish, delete, bulk-mutate, or upload files.",
@@ -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) {
@@ -70,10 +82,7 @@ function thisTurnBlock(bridge, turnContext) {
70
82
  ];
71
83
  if (locale) {
72
84
  const content = languageName(locale);
73
- lines.push(`Write every field value in ${content}; that is the language version being edited.`);
74
- if (content !== languageName(chat)) {
75
- lines.push(`The editor chats in ${languageName(chat)}, so say in one short sentence that the content is written in ${content}.`);
76
- }
85
+ lines.push(`Write every field value in ${content}; that is the language version being edited. Do not point out which language the content is in; the panel does that.`);
77
86
  }
78
87
  const selection = turnContext?.selection;
79
88
  if (selection) {
@@ -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
+ };
@@ -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 };
@@ -10,3 +10,5 @@ export declare function stripInternalSpeech(text: string, locale: string): strin
10
10
  export declare function withoutOverclaims(text: string): string;
11
11
  export declare function approvalCopy(locale: string, capability: "form.propose" | "draft.create"): string;
12
12
  export declare function couldNotStageCopy(locale: string): string;
13
+ /** Tells the editor the content is written in another language than the one they chat in. */
14
+ export declare function contentLanguageNote(chat: string, contentLocale: string): string | undefined;
@@ -32,11 +32,12 @@ export function stripInternalSpeech(text, locale) {
32
32
  return kept.join(" ").replace(/\s+/g, " ").trim();
33
33
  }
34
34
  /** Keeping, saving and publishing are the editor's; a sentence that claims or asks for them goes. */
35
- const OVERCLAIM = /godkänn|godkann|approve|spara|sparat|sparad|sparats|publicer|\bsaved?\b|publish|behåll|\bkeep\b|klicka|\bclick/i;
35
+ const OVERCLAIM = /godkänn|godkann|approve|spara|sparat|sparad|sparats|publicer|\bsaved?\b|publish|behåll|\bkeep\b|klicka|\bclick|(?:^|\s)är nu\b|\bis now\b/i;
36
36
  const MAX_OWN_TEXT = 600;
37
37
  export function withoutOverclaims(text) {
38
38
  const kept = splitSentences(text)
39
- .filter((sentence) => !OVERCLAIM.test(sentence))
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,9 +63,55 @@ 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(/(?<=[.!?])\s+|\n+/)
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
+ }
102
+ const CONTENT_LANGUAGE = {
103
+ en: { sv: "engelska", en: "English" },
104
+ sv: { sv: "svenska", en: "Swedish" },
105
+ };
106
+ /** Tells the editor the content is written in another language than the one they chat in. */
107
+ export function contentLanguageNote(chat, contentLocale) {
108
+ const content = contentLocale.split("-")[0];
109
+ const inChat = isSwedish(chat) ? "sv" : "en";
110
+ const name = CONTENT_LANGUAGE[content]?.[inChat];
111
+ if (!name || content === chat.split("-")[0]) {
112
+ return undefined;
113
+ }
114
+ return inChat === "sv"
115
+ ? `Innehållet skrivs på ${name}, eftersom det är den språkversionen du redigerar.`
116
+ : `The content is written in ${name}, since that is the language version you are editing.`;
117
+ }
@@ -1,5 +1,6 @@
1
1
  .ea-root {
2
- --ea-aside-width: 400px;
2
+ /* Narrower on smaller screens, so Admin keeps room. nav-room.ts depends on these numbers. */
3
+ --ea-aside-width: clamp(320px, 22vw, 400px);
3
4
  position: fixed;
4
5
  inset: 0;
5
6
  display: flex;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simmalugnt-se/payload-editor-assistant",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Context-aware editor assistant plugin for Payload Admin",
5
5
  "keywords": [
6
6
  "payload",
@@ -47,11 +47,6 @@
47
47
  "react": "^19.0.0",
48
48
  "react-dom": "^19.0.0"
49
49
  },
50
- "peerDependenciesMeta": {
51
- "next": {
52
- "optional": true
53
- }
54
- },
55
50
  "devDependencies": {
56
51
  "@payloadcms/ui": "3.90.2",
57
52
  "@types/node": "^22",