@simmalugnt-se/payload-editor-assistant 0.3.3 → 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.
@@ -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) {
@@ -1,3 +1,7 @@
1
1
  import type { Endpoint } from "payload";
2
2
  import type { ValidatedEditorAssistantOptions } from "../config/validate.ts";
3
3
  export declare function createChatEndpoint(options: ValidatedEditorAssistantOptions): Endpoint;
4
+ /** Only a short list of form paths; the prompt names them as data. */
5
+ export declare function sanitizeOpenProposal(value: unknown): {
6
+ changedPaths: string[];
7
+ } | undefined;
@@ -26,6 +26,8 @@ export function createChatEndpoint(options) {
26
26
  if (!beginInflight(actor)) {
27
27
  return Response.json({ error: "rate_limited" }, { status: 429 });
28
28
  }
29
+ // A streamed turn ends when its stream does, not when the handler returns.
30
+ let streaming = false;
29
31
  try {
30
32
  let body;
31
33
  try {
@@ -45,7 +47,8 @@ export function createChatEndpoint(options) {
45
47
  ? body.messages.filter(isChatMessage)
46
48
  : [{ role: "user", text: body.message }];
47
49
  const language = resolveChatLanguage(messages.filter((message) => message.role === "user").map((message) => message.text), nonEmpty(body.uiLanguage));
48
- const result = await runAgentTurn(req, options, {
50
+ const run = (onEvent) => runAgentTurn(req, options, {
51
+ onEvent,
49
52
  message: body.message,
50
53
  messages,
51
54
  bridge: {
@@ -58,9 +61,15 @@ export function createChatEndpoint(options) {
58
61
  uiLanguage: nonEmpty(body.uiLanguage)?.startsWith("sv") ? "sv" : "en",
59
62
  view: sanitizeAdminView(body.view, options),
60
63
  selection: typeof body.selection === "string" ? body.selection : undefined,
64
+ openProposal: sanitizeOpenProposal(body.openProposal),
61
65
  },
62
66
  formData: body.formData,
63
67
  });
68
+ if (body.stream === true) {
69
+ streaming = true;
70
+ return streamTurn(run, language, () => endInflight(actor));
71
+ }
72
+ const result = await run();
64
73
  if (!result.ok) {
65
74
  const status = result.error === "timeout" ? 504 : 503;
66
75
  return Response.json({ error: result.error, message: result.message }, { status });
@@ -68,11 +77,69 @@ export function createChatEndpoint(options) {
68
77
  return Response.json({ ...result, language });
69
78
  }
70
79
  finally {
71
- endInflight(actor);
80
+ if (!streaming) {
81
+ endInflight(actor);
82
+ }
72
83
  }
73
84
  },
74
85
  };
75
86
  }
87
+ /**
88
+ * One JSON object per line: `step` and `proposal` events while the turn runs, then `done` with the
89
+ * same result the JSON answer has, or `error`. Errors after the stream started cannot change the
90
+ * HTTP status, so they travel as an event.
91
+ */
92
+ function streamTurn(run, language, finished) {
93
+ const encoder = new TextEncoder();
94
+ const stream = new ReadableStream({
95
+ async start(controller) {
96
+ const send = (value) => {
97
+ try {
98
+ controller.enqueue(encoder.encode(`${JSON.stringify(value)}\n`));
99
+ }
100
+ catch {
101
+ // The editor stopped the request; the turn is aborted through the request signal.
102
+ }
103
+ };
104
+ try {
105
+ const result = await run(send);
106
+ send(result.ok
107
+ ? { type: "done", result: { ...result, language } }
108
+ : { type: "error", error: result.error, message: result.message });
109
+ }
110
+ catch {
111
+ send({ type: "error", error: "provider_error" });
112
+ }
113
+ finally {
114
+ finished();
115
+ try {
116
+ controller.close();
117
+ }
118
+ catch {
119
+ // Already closed by the client.
120
+ }
121
+ }
122
+ },
123
+ });
124
+ return new Response(stream, {
125
+ headers: {
126
+ "Content-Type": "application/x-ndjson; charset=utf-8",
127
+ "Cache-Control": "no-cache, no-transform",
128
+ "X-Accel-Buffering": "no",
129
+ },
130
+ });
131
+ }
132
+ /** Only a short list of form paths; the prompt names them as data. */
133
+ export function sanitizeOpenProposal(value) {
134
+ const paths = value?.changedPaths;
135
+ if (!Array.isArray(paths)) {
136
+ return undefined;
137
+ }
138
+ const changedPaths = paths
139
+ .filter((path) => typeof path === "string" && /^[\w.]{1,200}$/.test(path))
140
+ .slice(0, 50);
141
+ return changedPaths.length > 0 ? { changedPaths } : undefined;
142
+ }
76
143
  function nonEmpty(value) {
77
144
  return typeof value === "string" && value.trim() ? value : undefined;
78
145
  }
@@ -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: [
@@ -32,14 +32,32 @@ type ChatMessage = {
32
32
  role: "user" | "assistant" | "system";
33
33
  text: string;
34
34
  };
35
- export declare function runAgentTurn(req: PayloadRequest, options: ValidatedEditorAssistantOptions, input: {
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";
37
+ export type AgentEvent = {
38
+ type: "step";
39
+ step: AgentStep;
40
+ }
41
+ /** A form proposal, sent as soon as it exists so Admin can show it before the reply is written. */
42
+ | {
43
+ type: "proposal";
44
+ pendingApproval: NonNullable<Extract<AgentChatResult, {
45
+ ok: true;
46
+ }>["pendingApproval"]>;
47
+ };
48
+ export type AgentEventSink = (event: AgentEvent) => void;
49
+ type TurnInput = {
36
50
  message: string;
37
51
  messages?: ChatMessage[];
38
52
  bridge: ToolContext["bridge"];
39
53
  formData?: Record<string, unknown>;
40
- }): Promise<AgentChatResult>;
54
+ onEvent?: AgentEventSink;
55
+ };
56
+ export declare function runAgentTurn(req: PayloadRequest, options: ValidatedEditorAssistantOptions, input: TurnInput): Promise<AgentChatResult>;
41
57
  export declare function collectResult(text: string, result: {
42
58
  steps?: unknown;
43
59
  toolResults?: unknown;
44
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;
45
63
  export {};
@@ -3,11 +3,44 @@ 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;
10
+ const STEP_FOR_TOOL = {
11
+ "schema.discover": "thinking",
12
+ "content.find": "searching",
13
+ "content.findById": "reading",
14
+ "form.read": "reading_form",
15
+ "form.propose": "proposing",
16
+ "draft.create": "drafting",
17
+ "admin.navigate": "navigating",
18
+ };
10
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) {
36
+ const emit = (event) => {
37
+ try {
38
+ input.onEvent?.(event);
39
+ }
40
+ catch {
41
+ // A listener that went away (closed stream) does not stop the turn.
42
+ }
43
+ };
11
44
  const model = await resolveModel(options, req.user?.id);
12
45
  if (!model) {
13
46
  req.payload?.logger?.warn(`${PACKAGE_NAME}: no model: the \`model\` option resolved to nothing for this user`);
@@ -29,10 +62,11 @@ export async function runAgentTurn(req, options, input) {
29
62
  const history = truncateMessages(input.messages ?? [{ role: "user", text: input.message }]);
30
63
  const captured = [];
31
64
  const previews = new Map();
32
- const tools = buildSdkTools(ai, ctx, captured, previews);
65
+ const tools = buildSdkTools(ai, ctx, captured, previews, emit);
33
66
  const turnContext = await loadTurnContext(req, options, input.bridge, input.formData);
34
67
  const system = buildSystemPrompt(options.instructions, input.bridge, turnContext);
35
68
  const sdkMessages = toSdkMessages(history);
69
+ emit({ type: "step", step: "thinking" });
36
70
  try {
37
71
  const first = await generate(ai, {
38
72
  model,
@@ -60,7 +94,7 @@ export async function runAgentTurn(req, options, input) {
60
94
  return collected;
61
95
  }
62
96
  const retryCaptured = [];
63
- const retryTools = buildSdkTools(ai, ctx, retryCaptured, previews);
97
+ const retryTools = buildSdkTools(ai, ctx, retryCaptured, previews, emit);
64
98
  const retry = await generate(ai, {
65
99
  model,
66
100
  system,
@@ -168,7 +202,7 @@ function isToolOutcome(value) {
168
202
  const record = value;
169
203
  return typeof record.ok === "boolean" && typeof record.capability === "string";
170
204
  }
171
- function buildSdkTools(ai, ctx, captured, previews) {
205
+ function buildSdkTools(ai, ctx, captured, previews, emit) {
172
206
  const tools = {};
173
207
  for (const capability of Object.keys(TOOL_JSON_SCHEMAS)) {
174
208
  const schema = TOOL_JSON_SCHEMAS[capability];
@@ -180,6 +214,7 @@ function buildSdkTools(ai, ctx, captured, previews) {
180
214
  execute: async (raw) => {
181
215
  try {
182
216
  const input = raw && typeof raw === "object" ? raw : {};
217
+ emit({ type: "step", step: STEP_FOR_TOOL[capability] ?? "thinking" });
183
218
  const executed = await executeTool(fromProviderToolName(name), input, ctx);
184
219
  // The whole form with the change applied is for Admin, not another round through the model.
185
220
  const { preview, ...outcome } = executed.ok
@@ -187,7 +222,12 @@ function buildSdkTools(ai, ctx, captured, previews) {
187
222
  : { ...executed, preview: undefined };
188
223
  if (preview && executed.ok && executed.pendingApproval) {
189
224
  previews.set(executed.pendingApproval.approvalId, preview);
225
+ emit({ type: "proposal", pendingApproval: { ...executed.pendingApproval, preview } });
190
226
  }
227
+ emit({
228
+ type: "step",
229
+ step: executed.ok && executed.pendingApproval ? "writing" : "thinking",
230
+ });
191
231
  captured.push(outcome);
192
232
  return outcome;
193
233
  }
@@ -206,6 +246,16 @@ function buildSdkTools(ai, ctx, captured, previews) {
206
246
  }
207
247
  return tools;
208
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
+ }
209
259
  function truncateMessages(messages) {
210
260
  if (messages.length <= MAX_MESSAGES) {
211
261
  return messages;
@@ -12,9 +12,9 @@ export function offeredValue(text, request) {
12
12
  }
13
13
  // A request for a change, or a yes to one the assistant asked about. Letter lookarounds rather
14
14
  // than \b, which treats å, ä and ö as word boundaries.
15
- const ASKS_FOR_CHANGE = /(?<!\p{L})(make|change|rewrite|write|add|replace|shorten|tighten|fill|translate|update|improve|set|rename|remove|delete|yes|yeah|sure|ok|okay|do it|go ahead|gör|göra|ändra|skriv|skriva|lägg|lägga|byt|byta|korta|fyll|fylla|översätt|uppdatera|förbättra|sätt|ta bort|ja|japp|kör|gärna)(?!\p{L})/iu;
15
+ const ASKS_FOR_CHANGE = /(?<!\p{L})(make|change|rewrite|write|add|replace|shorten|tighten|fill|translate|update|improve|set|rename|shorter|longer|simpler|remove|delete|yes|yeah|sure|ok|okay|do it|go ahead|gör|göra|ändra|skriv|skriva|lägg|lägga|byt|byta|korta|fyll|fylla|översätt|uppdatera|förbättra|sätt|ta bort|kortare|längre|enklare|ja|japp|kör|gärna)(?!\p{L})/iu;
16
16
  const QUOTED = /["“”«»][^"“”«»]{2,}["“”«»]|'[^']{2,}'/;
17
- const CLAIMED_CHANGE = /godkänn|godkann|approve|rubriken är nu|the headline is now|sparas den på sidan|fälten i på sidan|pending form change|i've applied|i have applied/i;
17
+ const CLAIMED_CHANGE = /godkänn|godkann|approve|rubriken är nu|the headline is now|sparas den på sidan|fälten i på sidan|pending form change|i've applied|i have applied|har lagt in|i've put|i have put/i;
18
18
  const ASKED_TO_MUTATE = /ska jag (lägga|fylla|ändra|sätta|uppdatera)|should i (put|fill|change|set|update|apply)|shall i (put|fill|change|set|update|apply)|want me to (put|fill|change|set|update|apply)/i;
19
19
  export function needsProposeRetry(input) {
20
20
  if (!input.hasOpenDocument || input.pendingApproval || input.navigate) {
@@ -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
- "Do not claim the form already has the new values. Do not tell the editor to approve; the panel does that when the proposal exists.",
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.",
@@ -62,7 +62,7 @@ function viewLine(turnContext) {
62
62
  function thisTurnBlock(bridge, turnContext) {
63
63
  const locale = bridge?.locale ?? turnContext?.open?.locale;
64
64
  const chat = bridge?.language ?? locale;
65
- if (!chat && !turnContext?.form) {
65
+ if (!chat && !turnContext?.form && !bridge?.openProposal) {
66
66
  return "";
67
67
  }
68
68
  const lines = [
@@ -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) {
@@ -81,6 +78,9 @@ function thisTurnBlock(bridge, turnContext) {
81
78
  ? `Selected field: ${selection.field.path}.`
82
79
  : `Selected: ${selection.path}; its fields are under that path in the form.`);
83
80
  }
81
+ if (bridge?.openProposal) {
82
+ lines.push(`The form shows your earlier suggestion, not kept yet; it changed ${bridge.openProposal.changedPaths.join(", ")}. Those values are in the form now. If the editor asks to adjust it (shorter, another tone, other wording, one more field), call form.propose now with the adjusted values; it replaces the suggestion in the preview. Do not say it is saved or kept.`);
83
+ }
84
84
  const form = turnContext?.form;
85
85
  if (form) {
86
86
  const empty = listEmptyTextPaths(form);
@@ -18,6 +18,10 @@ export type ToolBridge = {
18
18
  view?: AdminView;
19
19
  /** Form path of what the editor last clicked or focused in the open form, unverified. */
20
20
  selection?: string;
21
+ /** A suggestion shown in the form that the editor has not kept yet: the paths it changed. */
22
+ openProposal?: {
23
+ changedPaths: string[];
24
+ };
21
25
  };
22
26
  export type ToolContext = {
23
27
  req: PayloadRequest;
@@ -7,5 +7,8 @@ export declare function editorFacingText(input: {
7
7
  expectedProposal?: boolean;
8
8
  }): string;
9
9
  export declare function stripInternalSpeech(text: string, locale: string): string;
10
+ export declare function withoutOverclaims(text: string): string;
10
11
  export declare function approvalCopy(locale: string, capability: "form.propose" | "draft.create"): string;
11
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;
@@ -3,7 +3,12 @@ const INTERNAL = /\b(form\.propose|form_propose|draft\.create|draft_create|schem
3
3
  const ENGLISH_META = /\b(despite|therefore|however|the tool|I should|I've|has been issued|appropriate turn|proposed values)\b/i;
4
4
  export function editorFacingText(input) {
5
5
  if (input.pendingApproval) {
6
- return approvalCopy(input.locale, input.pendingApproval.capability);
6
+ // A form change is in the form now, so the model's own words about it can stand, minus any
7
+ // claim the panel contradicts. A draft is not created until approved; that keeps fixed copy.
8
+ const own = input.pendingApproval.capability === "form.propose"
9
+ ? withoutOverclaims(stripInternalSpeech(input.text, input.locale))
10
+ : "";
11
+ return own || approvalCopy(input.locale, input.pendingApproval.capability);
7
12
  }
8
13
  const cleaned = stripInternalSpeech(input.text, input.locale);
9
14
  if (input.expectedProposal ||
@@ -26,6 +31,16 @@ export function stripInternalSpeech(text, locale) {
26
31
  });
27
32
  return kept.join(" ").replace(/\s+/g, " ").trim();
28
33
  }
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|(?:^|\s)är nu\b|\bis now\b/i;
36
+ const MAX_OWN_TEXT = 600;
37
+ export function withoutOverclaims(text) {
38
+ const kept = splitSentences(text)
39
+ .filter((sentence) => !OVERCLAIM.test(sentence))
40
+ .join(" ")
41
+ .trim();
42
+ return kept.length <= MAX_OWN_TEXT ? kept : "";
43
+ }
29
44
  export function approvalCopy(locale, capability) {
30
45
  if (isSwedish(locale)) {
31
46
  return capability === "draft.create"
@@ -53,3 +68,19 @@ function splitSentences(text) {
53
68
  .map((part) => part.trim())
54
69
  .filter(Boolean);
55
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.3.3",
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",