@simmalugnt-se/payload-editor-assistant 0.4.0 → 0.5.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,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0
4
+
5
+ - `next` is a required peer dependency: the panel imports `next/navigation`, so it never worked
6
+ without it.
7
+
8
+ - Makes room in Admin: the panel is 320–400px wide depending on the window, and where fewer than
9
+ 1440px would be left for Admin, Payload's nav and the panel take turns. Opening one closes the
10
+ other, which comes back when the first closes. While the assistant answers or a proposal waits for
11
+ Keep or Undo, opening the nav leaves the panel open.
12
+ - Works with the content health work list: `?ask=<prompt>` on a document's address opens the
13
+ assistant with the prompt in the message box (the editor sends it), and a `content-health:select`
14
+ event selects a field like a preview click. The allowlisted collections are published as
15
+ `config.admin.custom.editorAssistant.collections`.
16
+
17
+ - When the editor chats in one language and edits content in another, the first proposal says so
18
+ once, in a sentence the plugin adds; the model is no longer asked to, since it repeated it in
19
+ every reply or said it only in a later one.
20
+ - The reply to a proposal offers it ("Jag föreslår en varmare rubrik.") instead of describing it as
21
+ done: the prompt asks for that, and a sentence saying a field "är nu"/"is now" something is left
22
+ out.
23
+
3
24
  ## 0.4.0
4
25
 
5
26
  - A suggestion in the form stays open while the editor writes on, so "shorter" or "another tone"
package/README.md CHANGED
@@ -112,6 +112,30 @@ clicks in the assistant keep it.
112
112
  Preview clicks need `@simmalugnt-se/payload-visual-editing` (0.2.0 or later), which announces them
113
113
  with a `visual-editing:select` event. Without it the selection follows the form only.
114
114
 
115
+ ## From the content health work list
116
+
117
+ `@simmalugnt-se/payload-content-health` lists content issues and offers "Fix with the assistant" in
118
+ the collections the assistant works in. The link opens the document with the field selected (a
119
+ `content-health:select` event) and `?ask=<prompt>` in the address: the assistant opens with the
120
+ prompt in the message box, and the editor sends it. A link alone never makes the assistant act.
121
+
122
+ The plugin publishes its allowlisted collections as `config.admin.custom.editorAssistant.collections`
123
+ so the work list knows where to offer it; the packages do not import each other.
124
+
125
+ ## Room for the panel
126
+
127
+ The panel takes 320–400px of the window, depending on its width. Payload lays out Admin by the
128
+ window's width and does not know about the panel. Where fewer than 1440px would be left for Admin
129
+ (the width at which Payload closes its nav itself), the nav and the panel take turns: opening the
130
+ panel closes the nav, opening the nav closes the panel, and the other comes back when you close it.
131
+ While the assistant is answering or a proposal waits for **Keep** or **Undo**, opening the nav leaves
132
+ the panel open, so the decision stays in view. Payload's saved nav preference and your own choice to
133
+ show the panel are not changed.
134
+
135
+ Live Preview splits the rest 40/60 between the form and the preview. From a 1440px window that leaves
136
+ a usable form beside the preview; on smaller screens, open the preview in its own window with the
137
+ external link button at the end of Live Preview's toolbar.
138
+
115
139
  ## While it works
116
140
 
117
141
  The panel shows what the assistant is doing (reading the form, searching content, preparing a
@@ -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,
@@ -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) {
@@ -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) => {
@@ -40,6 +40,11 @@ export function editorAssistantPlugin(options = {}) {
40
40
  ...config,
41
41
  admin: {
42
42
  ...config.admin,
43
+ // Read by @simmalugnt-se/payload-content-health, which offers the assistant only here.
44
+ custom: {
45
+ ...config.admin?.custom,
46
+ [ADMIN_CUSTOM_KEY]: { collections: Object.keys(validated.collections) },
47
+ },
43
48
  components: {
44
49
  ...config.admin?.components,
45
50
  providers: [
@@ -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 {};
@@ -3,7 +3,7 @@ import { loadTurnContext } from "./context.js";
3
3
  import { needsProposeRetry } from "./propose-loop.js";
4
4
  import { buildSystemPrompt } from "./system-prompt.js";
5
5
  import { executeTool, fromProviderToolName, TOOL_DESCRIPTIONS, TOOL_JSON_SCHEMAS, toProviderToolName, } from "./tools.js";
6
- import { editorFacingText } from "./voice.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;
@@ -17,6 +17,22 @@ const STEP_FOR_TOOL = {
17
17
  "admin.navigate": "navigating",
18
18
  };
19
19
  export async function runAgentTurn(req, options, input) {
20
+ const result = await generateTurn(req, options, input);
21
+ if (!result.ok || result.pendingApproval?.capability !== "form.propose") {
22
+ return result;
23
+ }
24
+ // The first change written in another language than the chat's says so, once per conversation.
25
+ // Asked to in the prompt, models said it in every reply, or only in a later one.
26
+ const chat = input.bridge.language ?? input.bridge.locale;
27
+ const note = contentLanguageNote(chat, input.bridge.locale);
28
+ const history = input.messages ?? [];
29
+ if (!note ||
30
+ contentLanguageNoted([...history, { role: "assistant", text: result.text }], input.bridge)) {
31
+ return result;
32
+ }
33
+ return { ...result, text: `${note} ${result.text}` };
34
+ }
35
+ async function generateTurn(req, options, input) {
20
36
  const emit = (event) => {
21
37
  try {
22
38
  input.onEvent?.(event);
@@ -230,6 +246,16 @@ function buildSdkTools(ai, ctx, captured, previews, emit) {
230
246
  }
231
247
  return tools;
232
248
  }
249
+ /** Names of the content language as the editor would read them, in either chat language. */
250
+ const LANGUAGE_NAMES = {
251
+ en: /\b(English)\b|engelska/i,
252
+ sv: /\b(Swedish)\b|svenska/i,
253
+ };
254
+ /** Whether a reply in the conversation already said which language the content is in. */
255
+ export function contentLanguageNoted(history, bridge) {
256
+ const name = LANGUAGE_NAMES[bridge.locale.split("-")[0]];
257
+ return Boolean(name && history.some((message) => message.role === "assistant" && name.test(message.text)));
258
+ }
233
259
  function truncateMessages(messages) {
234
260
  if (messages.length <= MAX_MESSAGES) {
235
261
  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.",
@@ -70,10 +70,7 @@ function thisTurnBlock(bridge, turnContext) {
70
70
  ];
71
71
  if (locale) {
72
72
  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
- }
73
+ 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
74
  }
78
75
  const selection = turnContext?.selection;
79
76
  if (selection) {
@@ -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,7 +32,7 @@ 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)
@@ -68,3 +68,19 @@ function splitSentences(text) {
68
68
  .map((part) => part.trim())
69
69
  .filter(Boolean);
70
70
  }
71
+ const CONTENT_LANGUAGE = {
72
+ en: { sv: "engelska", en: "English" },
73
+ sv: { sv: "svenska", en: "Swedish" },
74
+ };
75
+ /** Tells the editor the content is written in another language than the one they chat in. */
76
+ export function contentLanguageNote(chat, contentLocale) {
77
+ const content = contentLocale.split("-")[0];
78
+ const inChat = isSwedish(chat) ? "sv" : "en";
79
+ const name = CONTENT_LANGUAGE[content]?.[inChat];
80
+ if (!name || content === chat.split("-")[0]) {
81
+ return undefined;
82
+ }
83
+ return inChat === "sv"
84
+ ? `Innehållet skrivs på ${name}, eftersom det är den språkversionen du redigerar.`
85
+ : `The content is written in ${name}, since that is the language version you are editing.`;
86
+ }
@@ -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.5.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",