@simmalugnt-se/payload-editor-assistant 0.10.0 → 0.11.1

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.
Files changed (44) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +35 -0
  3. package/dist/batch/targets.d.ts +25 -0
  4. package/dist/batch/targets.js +47 -0
  5. package/dist/capabilities.d.ts +4 -4
  6. package/dist/capabilities.js +3 -0
  7. package/dist/components/BatchPanel.d.ts +9 -0
  8. package/dist/components/BatchPanel.js +161 -0
  9. package/dist/components/EditorAssistantAside.js +4 -3
  10. package/dist/components/EditorAssistantProvider.js +2 -0
  11. package/dist/components/assistant-context.d.ts +11 -0
  12. package/dist/components/assistant-context.js +4 -0
  13. package/dist/components/batch-link.d.ts +5 -0
  14. package/dist/components/batch-link.js +35 -0
  15. package/dist/components/batch-run.d.ts +65 -0
  16. package/dist/components/batch-run.js +67 -0
  17. package/dist/components/copy.d.ts +82 -0
  18. package/dist/components/copy.js +81 -0
  19. package/dist/config/validate.js +3 -0
  20. package/dist/domain/audit.d.ts +2 -0
  21. package/dist/domain/audit.js +1 -0
  22. package/dist/domain/batch.d.ts +74 -0
  23. package/dist/domain/batch.js +342 -0
  24. package/dist/domain/content.d.ts +16 -0
  25. package/dist/domain/content.js +14 -5
  26. package/dist/domain/decide.js +4 -0
  27. package/dist/domain/guard.d.ts +3 -1
  28. package/dist/domain/guard.js +2 -2
  29. package/dist/domain/propose.d.ts +27 -1
  30. package/dist/domain/propose.js +88 -5
  31. package/dist/endpoints/batch.d.ts +8 -0
  32. package/dist/endpoints/batch.js +102 -0
  33. package/dist/package-name.d.ts +3 -2
  34. package/dist/package-name.js +3 -2
  35. package/dist/plugin.js +12 -2
  36. package/dist/runtime/agent.d.ts +4 -2
  37. package/dist/runtime/agent.js +5 -3
  38. package/dist/runtime/system-prompt.js +12 -1
  39. package/dist/runtime/tools.d.ts +15 -2
  40. package/dist/runtime/tools.js +20 -3
  41. package/dist/runtime/voice.d.ts +2 -2
  42. package/dist/runtime/voice.js +7 -3
  43. package/dist/styles/assistant.css +201 -0
  44. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.11.1
4
+
5
+ - The batch panel shows Start and Cancel right under the text that explains the batch, instead of
6
+ at the bottom of the panel, where they were easy to miss on a tall screen. "New chat" is hidden
7
+ while a batch is open.
8
+ - Saved batch cards keep the assistant's accent blue, like proposals and selections, instead of
9
+ Payload's success colour.
10
+
11
+ ## 0.11.0
12
+
13
+ - Batches: fix many saved documents at once from content health's work list. The new
14
+ `content.update` capability (collections only, opt-in) lets the panel open in batch mode from a
15
+ `?batch=` link: the assistant proposes a change per document, one after the other, and the editor
16
+ reviews them in cards, rewords plain text if they like, and saves the ones they want. Each save is
17
+ checked again on the server (approval, access, lock, and that the document has not changed since
18
+ the proposal) and writes a draft where the collection has drafts, else saves. Every document is
19
+ logged in the audit collection with `requestId: "batch:<id>"`. See "Batches" in the README.
20
+ - `config.admin.custom.editorAssistant` also lists `batchCollections`, the collections that allow
21
+ `content.update`.
22
+ - `form.propose` answers with the number of characters in each plain text value it sets, and the
23
+ prompt asks the model to correct a text that misses a length the editor asked for. Meta titles
24
+ from the work list landed below 50 characters in about half the runs; now they keep to the range.
25
+
3
26
  ## 0.10.0
4
27
 
5
28
  - Picks several images for upload fields with `hasMany`, such as a gallery: it adds after the
package/README.md CHANGED
@@ -81,6 +81,7 @@ Nothing is reachable unless you list it. Each collection or global gets the capa
81
81
  | `form.propose` | put a change in the open form, where the editor keeps or undoes it | yes |
82
82
  | `draft.create` | create a new draft document (collections only) | yes |
83
83
  | `media.view` | look at the open image, or at images by id when picking one (upload collections only) | no |
84
+ | `content.update` | fix many saved documents at once from the content health work list (collections only; see [Batches](#batches)) | yes, per document |
84
85
 
85
86
  The assistant never publishes. Form changes land in the open form and the editor saves as usual; an
86
87
  approved `draft.create` saves a new, unpublished draft. The `users` collection, Payload's own collections and fields that look like
@@ -181,6 +182,40 @@ prompt in the message box, and the editor sends it. A link alone never makes the
181
182
  The plugin publishes its allowlisted collections as `config.admin.custom.editorAssistant.collections`
182
183
  so the work list knows where to offer it; the packages do not import each other.
183
184
 
185
+ ## Batches
186
+
187
+ With `content.update` on a collection, content health's work list offers "Fix N with the assistant"
188
+ when it is filtered to a check the assistant can fix in many documents: missing alt texts, meta
189
+ titles and descriptions (missing or the wrong length), and missing translations. The link opens the
190
+ assistant's panel in batch mode beside the list, with up to 20 documents:
191
+
192
+ ```ts
193
+ collections: {
194
+ pages: { capabilities: ["content.findById", "form.read", "form.propose", "content.update"] },
195
+ media: { capabilities: ["content.findById", "form.read", "form.propose", "media.view", "content.update"] },
196
+ },
197
+ ```
198
+
199
+ - **Start** asks the model about one document after the other, each on its saved version (the
200
+ latest draft) with the finding's field selected: one request per document, and **Stop** ends it.
201
+ A proposal may only set the finding's field, or for a translation the fields not translated yet;
202
+ rows and blocks stay as they are.
203
+ - Each document gets a card with the change. Plain text values (alt texts, meta titles and
204
+ descriptions) can be reworded in the card, with a character count; rich text shows as a change
205
+ list only. **Try again** asks once more, optionally with what should be different ("shorter").
206
+ - Nothing is written until **Save N selected**. Checked proposals are saved one by one, each checked
207
+ again on the server: the editor, the one-time approval, `content.update`, the document's lock, and
208
+ that the saved document has not changed since the proposal (otherwise the card says so and
209
+ nothing is written). A collection with drafts gets a draft; one without, such as media, is saved
210
+ directly, and the card says which. Unchecked proposals are rejected.
211
+ - The audit collection logs every document with `requestId: "batch:<id>"`. When the editor
212
+ reworded a value, the executed event's `canonicalHash` differs from the proposed one's.
213
+
214
+ `content.update` also works without content health: the panel reads
215
+ `?batch=<collection:id:locale:path,…>&batchScope=field|translation&ask=<prompt>` on any Admin
216
+ address. The plugin publishes the collections that allow it as
217
+ `config.admin.custom.editorAssistant.batchCollections`.
218
+
184
219
  ## Room for the panel
185
220
 
186
221
  The panel takes 320–400px of the window, depending on its width. Payload lays out Admin by the
@@ -0,0 +1,25 @@
1
+ /**
2
+ * How a batch reaches the assistant from content health's work list, without an import: parameters on
3
+ * any Admin address. The panel opens in batch mode and removes them. Pure, for the panel and the
4
+ * server alike.
5
+ */
6
+ /** The documents: `collection:id:locale:path`, comma separated; `locale` is empty when unlocalized. */
7
+ export declare const BATCH_PARAM = "batch";
8
+ /** `translation` lets each document fill every field not translated yet; anything else, the field. */
9
+ export declare const BATCH_SCOPE_PARAM = "batchScope";
10
+ /** Documents per batch: one model turn each, run one after the other. */
11
+ export declare const MAX_BATCH_TARGETS = 20;
12
+ export type BatchScope = "field" | "translation";
13
+ export type BatchTarget = {
14
+ collection: string;
15
+ id: string;
16
+ /** Unset when the collection is not localized. */
17
+ locale?: string;
18
+ /** Form path of the finding's field, e.g. `meta.title`. */
19
+ path: string;
20
+ };
21
+ /** Valid targets in order, without repeats, at most `MAX_BATCH_TARGETS`. */
22
+ export declare function parseBatchTargets(value: string | null | undefined): BatchTarget[];
23
+ export declare function formatBatchTargets(targets: BatchTarget[]): string;
24
+ export declare function parseBatchScope(value: string | null | undefined): BatchScope;
25
+ export declare function batchTargetKey(target: BatchTarget): string;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * How a batch reaches the assistant from content health's work list, without an import: parameters on
3
+ * any Admin address. The panel opens in batch mode and removes them. Pure, for the panel and the
4
+ * server alike.
5
+ */
6
+ /** The documents: `collection:id:locale:path`, comma separated; `locale` is empty when unlocalized. */
7
+ export const BATCH_PARAM = "batch";
8
+ /** `translation` lets each document fill every field not translated yet; anything else, the field. */
9
+ export const BATCH_SCOPE_PARAM = "batchScope";
10
+ /** Documents per batch: one model turn each, run one after the other. */
11
+ export const MAX_BATCH_TARGETS = 20;
12
+ const SLUG = /^[\w-]{1,100}$/;
13
+ const LOCALE = /^[\w-]{0,20}$/;
14
+ const PATH = /^[\w.]{1,200}$/;
15
+ /** Valid targets in order, without repeats, at most `MAX_BATCH_TARGETS`. */
16
+ export function parseBatchTargets(value) {
17
+ const targets = [];
18
+ const seen = new Set();
19
+ for (const entry of (value ?? "").split(",")) {
20
+ const parts = entry.split(":");
21
+ if (parts.length !== 4)
22
+ continue;
23
+ const [collection = "", id = "", locale = "", path = ""] = parts;
24
+ if (!SLUG.test(collection) || !SLUG.test(id) || !LOCALE.test(locale) || !PATH.test(path)) {
25
+ continue;
26
+ }
27
+ const key = parts.join(":");
28
+ if (seen.has(key))
29
+ continue;
30
+ seen.add(key);
31
+ targets.push({ collection, id, ...(locale ? { locale } : {}), path });
32
+ if (targets.length === MAX_BATCH_TARGETS)
33
+ break;
34
+ }
35
+ return targets;
36
+ }
37
+ export function formatBatchTargets(targets) {
38
+ return targets
39
+ .map((target) => [target.collection, target.id, target.locale ?? "", target.path].join(":"))
40
+ .join(",");
41
+ }
42
+ export function parseBatchScope(value) {
43
+ return value === "translation" ? "translation" : "field";
44
+ }
45
+ export function batchTargetKey(target) {
46
+ return formatBatchTargets([target]);
47
+ }
@@ -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", "media.view"];
1
+ export declare const BUILTIN_CAPABILITIES: readonly ["admin.navigate", "schema.discover", "content.find", "content.findById", "form.read", "form.propose", "draft.create", "media.view", "content.update"];
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" | "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"];
5
+ export declare const EMBEDDED_ONLY_CAPABILITIES: Set<"admin.navigate" | "schema.discover" | "content.find" | "content.findById" | "form.read" | "form.propose" | "draft.create" | "media.view" | "content.update">;
6
+ export declare const MCP_ELIGIBLE_CAPABILITIES: Set<"admin.navigate" | "schema.discover" | "content.find" | "content.findById" | "form.read" | "form.propose" | "draft.create" | "media.view" | "content.update">;
7
+ export declare const ENTITY_CAPABILITIES: readonly ["content.find", "content.findById", "form.read", "form.propose", "draft.create", "media.view", "content.update"];
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;
@@ -7,6 +7,7 @@ export const BUILTIN_CAPABILITIES = [
7
7
  "form.propose",
8
8
  "draft.create",
9
9
  "media.view",
10
+ "content.update",
10
11
  ];
11
12
  export const BUILTIN_RISK = {
12
13
  "admin.navigate": "auto",
@@ -17,6 +18,7 @@ export const BUILTIN_RISK = {
17
18
  "form.propose": "approval",
18
19
  "draft.create": "approval",
19
20
  "media.view": "auto",
21
+ "content.update": "approval",
20
22
  };
21
23
  export const EMBEDDED_ONLY_CAPABILITIES = new Set([
22
24
  "admin.navigate",
@@ -37,6 +39,7 @@ export const ENTITY_CAPABILITIES = [
37
39
  "form.propose",
38
40
  "draft.create",
39
41
  "media.view",
42
+ "content.update",
40
43
  ];
41
44
  const ENTITY_CAPABILITY_SET = new Set(ENTITY_CAPABILITIES);
42
45
  export function isEntityCapability(value) {
@@ -0,0 +1,9 @@
1
+ import type { BatchRequest } from "./assistant-context.tsx";
2
+ /**
3
+ * Many documents at once, from a work list link: the assistant suggests a change for each, one
4
+ * request after the other, and the editor saves the ones they want. Nothing is written before that.
5
+ */
6
+ export declare function BatchPanel({ batch, onClose }: {
7
+ batch: BatchRequest;
8
+ onClose: () => void;
9
+ }): import("react").JSX.Element;
@@ -0,0 +1,161 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { Button, useConfig, useTranslation } from "@payloadcms/ui";
4
+ import { useRouter } from "next/navigation";
5
+ import { useCallback, useEffect, useRef, useState } from "react";
6
+ import { formatBatchTargets } from "../batch/targets.js";
7
+ import { ENDPOINT_PREFIX } from "../package-name.js";
8
+ import { assistantRequest } from "./assistant-api.js";
9
+ import { cardAfterApply, cardAfterPropose, changedEdits, initialCards, savePlan, waitingKeys, } from "./batch-run.js";
10
+ import { BATCH_COPY, uiLanguage } from "./copy.js";
11
+ /**
12
+ * Many documents at once, from a work list link: the assistant suggests a change for each, one
13
+ * request after the other, and the editor saves the ones they want. Nothing is written before that.
14
+ */
15
+ export function BatchPanel({ batch, onClose }) {
16
+ const { config } = useConfig();
17
+ const { i18n } = useTranslation();
18
+ const router = useRouter();
19
+ const language = uiLanguage(i18n.language);
20
+ const copy = BATCH_COPY[language];
21
+ const apiBase = config.routes.api;
22
+ const [cards, setCards] = useState(() => initialCards(batch.targets));
23
+ const [phase, setPhase] = useState("ready");
24
+ const cardsRef = useRef(cards);
25
+ cardsRef.current = cards;
26
+ const abortRef = useRef(null);
27
+ const stoppedRef = useRef(false);
28
+ // Titles, images and access first, so the editor sees what the batch covers before it runs.
29
+ useEffect(() => {
30
+ const controller = new AbortController();
31
+ const query = new URLSearchParams({ targets: formatBatchTargets(batch.targets) });
32
+ fetch(`${apiBase}${ENDPOINT_PREFIX}/batch?${query}`, {
33
+ credentials: "include",
34
+ signal: controller.signal,
35
+ })
36
+ .then((response) => (response.ok ? response.json() : { targets: [] }))
37
+ .then((data) => {
38
+ setCards(initialCards(batch.targets, data.targets));
39
+ })
40
+ .catch(() => {
41
+ // Titles stay ids; the batch still runs and the server checks every document itself.
42
+ });
43
+ return () => controller.abort();
44
+ }, [apiBase, batch.targets]);
45
+ const update = useCallback((key, change) => {
46
+ setCards((current) => current.map((card) => (card.key === key ? change(card) : card)));
47
+ }, []);
48
+ const propose = useCallback(async (card, instruction) => {
49
+ const controller = new AbortController();
50
+ abortRef.current = controller;
51
+ update(card.key, (current) => ({ ...current, status: "working" }));
52
+ const answer = await assistantRequest(apiBase, "/batch/propose", {
53
+ target: card.key,
54
+ scope: batch.scope,
55
+ prompt: batch.prompt,
56
+ instruction,
57
+ language,
58
+ batchId: batch.id,
59
+ }, controller.signal).catch(() => ({ ok: false, error: "aborted", message: undefined, status: 0 }));
60
+ abortRef.current = null;
61
+ if (controller.signal.aborted) {
62
+ update(card.key, (current) => ({ ...current, status: "waiting" }));
63
+ return;
64
+ }
65
+ update(card.key, (current) => cardAfterPropose(current, answer.ok ? answer.data : { ok: false, error: answer.error, message: answer.message }));
66
+ }, [apiBase, batch, language, update]);
67
+ const run = useCallback(async () => {
68
+ stoppedRef.current = false;
69
+ setPhase("running");
70
+ for (const key of waitingKeys(cardsRef.current)) {
71
+ if (stoppedRef.current)
72
+ break;
73
+ const card = cardsRef.current.find((entry) => entry.key === key);
74
+ if (card)
75
+ await propose(card);
76
+ }
77
+ setPhase("review");
78
+ }, [propose]);
79
+ const stop = () => {
80
+ stoppedRef.current = true;
81
+ abortRef.current?.abort();
82
+ };
83
+ const apply = useCallback(async (card, action) => {
84
+ if (!card.proposal)
85
+ return;
86
+ update(card.key, (current) => ({ ...current, status: "saving" }));
87
+ const answer = await assistantRequest(apiBase, "/batch/apply", {
88
+ action,
89
+ approvalId: card.proposal.approvalId,
90
+ nonce: card.proposal.nonce,
91
+ batchId: batch.id,
92
+ ...(action === "apply" ? { edits: changedEdits(card) } : {}),
93
+ }).catch(() => ({
94
+ ok: false,
95
+ error: "provider_error",
96
+ message: undefined,
97
+ status: 0,
98
+ }));
99
+ update(card.key, (current) => cardAfterApply(current, answer.ok ? answer.data : { ok: false, error: answer.error, message: answer.message }));
100
+ }, [apiBase, batch.id, update]);
101
+ const save = async () => {
102
+ setPhase("saving");
103
+ for (const step of savePlan(cardsRef.current)) {
104
+ const card = cardsRef.current.find((entry) => entry.key === step.key);
105
+ if (card)
106
+ await apply(card, step.action);
107
+ }
108
+ setPhase("done");
109
+ // The work list reads saved versions; show what is fixed now.
110
+ router.refresh();
111
+ };
112
+ /** Leaves the batch; suggestions nobody saved are rejected so none stays open. */
113
+ const close = async () => {
114
+ stop();
115
+ if (phase === "review") {
116
+ setPhase("saving");
117
+ for (const card of cardsRef.current) {
118
+ if (card.status === "proposed")
119
+ await apply(card, "reject");
120
+ }
121
+ }
122
+ onClose();
123
+ };
124
+ const retry = async (card, instruction) => {
125
+ setPhase("running");
126
+ if (card.status === "proposed") {
127
+ await apply(card, "reject");
128
+ }
129
+ const latest = cardsRef.current.find((entry) => entry.key === card.key) ?? card;
130
+ await propose(latest, instruction.trim() || undefined);
131
+ setPhase("review");
132
+ };
133
+ const total = cards.filter((card) => card.status !== "unavailable").length;
134
+ const finished = cards.filter((card) => card.status !== "waiting" && card.status !== "working" && card.status !== "unavailable").length;
135
+ const selected = cards.filter((card) => card.status === "proposed" && card.checked).length;
136
+ const waiting = waitingKeys(cards).length;
137
+ const savedCount = cards.filter((card) => card.status === "saved" || card.status === "draft").length;
138
+ return (_jsxs("div", { className: "ea-batch", children: [_jsxs("div", { className: "ea-batch__head", children: [_jsx("h3", { children: copy.title(batch.targets.length) }), _jsxs("p", { className: "ea-batch__task", children: [_jsxs("span", { children: [copy.task, ":"] }), " ", batch.prompt] }), phase === "ready" ? (_jsxs(_Fragment, { children: [_jsx("p", { children: copy.intro(waiting) }), _jsxs("div", { className: "ea-actions", children: [_jsx(Button, { buttonStyle: "primary", disabled: waiting === 0, margin: false, onClick: () => void run(), size: "medium", children: copy.start }), _jsx(Button, { buttonStyle: "secondary", margin: false, onClick: onClose, size: "medium", children: copy.cancel })] })] })) : null, phase === "running" || phase === "saving" ? (_jsx("p", { "aria-live": "polite", children: phase === "saving" ? copy.saving : copy.progress(finished, total) })) : null, phase === "review" && selected > 0 ? _jsx("p", { children: copy.review }) : null, phase === "done" ? (_jsx("p", { "aria-live": "polite", children: copy.summary(savedCount, total - savedCount) })) : null] }), _jsx("ul", { className: "ea-batch__cards", children: cards.map((card) => (_jsx(BatchCardItem, { adminRoute: config.routes.admin, card: card, copy: copy, editable: phase === "review" || phase === "done", onCheck: (checked) => update(card.key, (current) => ({ ...current, checked })), onEdit: (path, value) => update(card.key, (current) => ({
139
+ ...current,
140
+ edits: { ...current.edits, [path]: value },
141
+ })), onRetry: (instruction) => void retry(card, instruction) }, card.key))) }), phase === "ready" ? null : (_jsxs("div", { className: "ea-actions ea-batch__actions", children: [phase === "running" ? (_jsx(Button, { buttonStyle: "secondary", margin: false, onClick: stop, size: "medium", children: copy.stop })) : null, phase === "review" ? (_jsxs(_Fragment, { children: [_jsx(Button, { buttonStyle: "primary", disabled: selected === 0, margin: false, onClick: () => void save(), size: "medium", children: copy.save(selected) }), waiting > 0 ? (_jsx(Button, { buttonStyle: "secondary", margin: false, onClick: () => void run(), size: "medium", children: copy.continue })) : null, _jsx(Button, { buttonStyle: "secondary", margin: false, onClick: () => void close(), size: "medium", children: copy.cancel })] })) : null, phase === "done" ? (_jsx(Button, { buttonStyle: "primary", margin: false, onClick: onClose, size: "medium", children: copy.close })) : null] }))] }));
142
+ }
143
+ function BatchCardItem({ adminRoute, card, copy, editable, onCheck, onEdit, onRetry, }) {
144
+ const [retrying, setRetrying] = useState(false);
145
+ const [instruction, setInstruction] = useState("");
146
+ const { target } = card;
147
+ const href = `${adminRoute}/collections/${target.collection}/${encodeURIComponent(target.id)}${target.locale ? `?locale=${encodeURIComponent(target.locale)}` : ""}`;
148
+ const proposed = card.status === "proposed";
149
+ const canRetry = editable &&
150
+ (proposed || card.status === "nothing" || card.status === "failed" || card.status === "stale");
151
+ const checkboxId = `ea-batch-${card.key.replace(/[^\w-]/g, "-")}`;
152
+ return (_jsxs("li", { className: `ea-batch__card ea-batch__card--${card.status}`, children: [_jsxs("div", { className: "ea-batch__card-head", children: [proposed ? (_jsx("input", { "aria-label": copy.select(card.title), checked: card.checked, disabled: !editable, id: checkboxId, onChange: (event) => onCheck(event.target.checked), type: "checkbox" })) : null, card.thumbnail ? (
153
+ // A plugin cannot set up next/image for the project's media addresses.
154
+ // biome-ignore lint/performance/noImgElement: a small thumbnail as Payload serves it
155
+ _jsx("img", { alt: "", className: "ea-batch__thumb", src: card.thumbnail })) : null, _jsxs("label", { className: "ea-batch__title", htmlFor: proposed ? checkboxId : undefined, children: [card.title, target.locale ? _jsx("span", { className: "ea-batch__pill", children: target.locale }) : null] }), _jsx("span", { className: "ea-batch__status", children: copy.status[card.status] })] }), card.proposal ? (_jsx("ul", { className: "ea-batch__lines", children: card.proposal.lines.map((line, index) => line.edit ? (_jsxs("li", { className: "ea-batch__edit", children: [_jsx("span", { className: "ea-batch__label", children: line.edit.label }), _jsxs("span", { className: "ea-batch__before", children: [copy.before, ": ", line.edit.before || copy.empty] }), _jsx("textarea", { "aria-label": line.edit.label, disabled: !editable || !proposed, onChange: (event) => onEdit(line.path, event.target.value), rows: 2, value: card.edits[line.path] ?? line.edit.value }), _jsx("span", { className: "ea-batch__count", children: [...(card.edits[line.path] ?? line.edit.value)].length })] }, line.path)) : (_jsx("li", { children: line.text }, `${line.path}:${index}`))) })) : null, card.text && (card.status === "nothing" || card.status === "failed") ? (_jsx("p", { className: "ea-batch__text", children: card.text })) : null, proposed ? (_jsx("p", { className: "ea-batch__hint", children: card.drafts ? copy.savesDraft : copy.savesDirectly })) : null, card.status === "stale" ? _jsx("p", { className: "ea-batch__hint", children: copy.staleHint }) : null, retrying ? (_jsxs("form", { className: "ea-batch__retry", onSubmit: (event) => {
156
+ event.preventDefault();
157
+ setRetrying(false);
158
+ onRetry(instruction);
159
+ setInstruction("");
160
+ }, children: [_jsx("input", { "aria-label": copy.retryWith, onChange: (event) => setInstruction(event.target.value), placeholder: copy.retryWith, value: instruction }), _jsx(Button, { buttonStyle: "secondary", margin: false, size: "small", type: "submit", children: copy.retrySend })] })) : null, _jsxs("div", { className: "ea-batch__card-actions", children: [canRetry && !retrying ? (_jsx(Button, { buttonStyle: "subtle", margin: false, onClick: () => setRetrying(true), size: "small", children: copy.retry })) : null, _jsx("a", { href: href, rel: "noopener", target: "_blank", children: copy.open })] })] }));
161
+ }
@@ -7,13 +7,14 @@ import { resolveChatLanguage } from "../runtime/language.js";
7
7
  import { parseAdminView } from "../view.js";
8
8
  import { assistantRequest, assistantStream } from "./assistant-api.js";
9
9
  import { useAssistantState, } from "./assistant-context.js";
10
+ import { BatchPanel } from "./BatchPanel.js";
10
11
  import { COPY, translateAction, uiLanguage } from "./copy.js";
11
12
  import { isFormProposal, proposalSteps, refineProposal, replaceLatestStep, } from "./proposal-chain.js";
12
13
  import { ReplyText } from "./ReplyText.js";
13
14
  import { KEEP_SELECTION_ATTR } from "./selection-dom.js";
14
15
  import { useTranslationOffer } from "./translation-offer.js";
15
16
  export function EditorAssistantAside() {
16
- const { open, setOpen, status, messages, shortcuts, collections, bridge, formAdapter, pendingApproval, selection, setSelection, prefill, setPrefill, resetConversation, setStatus, setMessages, setPendingApproval, stop, setAbortController, } = useAssistantState();
17
+ const { open, setOpen, status, messages, shortcuts, collections, bridge, formAdapter, pendingApproval, selection, setSelection, prefill, setPrefill, resetConversation, setStatus, setMessages, setPendingApproval, stop, setAbortController, batch, setBatch, } = useAssistantState();
17
18
  const { config, getEntityConfig } = useConfig();
18
19
  const router = useRouter();
19
20
  const locale = useLocale();
@@ -442,7 +443,7 @@ export function EditorAssistantAside() {
442
443
  if (!open) {
443
444
  return null;
444
445
  }
445
- return (_jsxs("aside", { className: "ea-aside", "aria-label": copy.assistant, [KEEP_SELECTION_ATTR]: "", children: [_jsxs("div", { className: "ea-aside__head", children: [_jsxs("div", { className: "ea-aside__title", children: [_jsx("h2", { children: copy.assistant }), bridge ? _jsx("p", { className: "ea-aside__context", children: contextLine(bridge, config) }) : null] }), (messages.length > 0 || pendingApproval) && !pendingApproval?.applied ? (_jsx(Button, { buttonStyle: "subtle", margin: false, onClick: () => resetConversation(), size: "medium", children: copy.newChat })) : null, _jsx(Button, { "aria-label": copy.collapseLabel, buttonStyle: "icon-label", className: "ea-aside__collapse", icon: _jsx(ChevronIcon, { direction: "right" }), margin: false, onClick: () => setOpen(false), tooltip: copy.collapse })] }), _jsxs("div", { className: "ea-aside__body", children: [_jsxs("div", { className: "ea-messages", ref: threadRef, onScroll: onThreadScroll, children: [messages.length === 0 ? (_jsx("div", { className: "ea-msg ea-msg--assistant", children: introText(view, bridge, collections, config, copy) })) : null, messages.map((message) => (_jsxs("div", { className: `ea-msg ea-msg--${message.role}`, children: [message.role === "assistant" ? _jsx(ReplyText, { text: message.text }) : message.text, message.candidates?.length ? (_jsx("div", { className: "ea-actions", children: message.candidates.map((candidate) => (_jsx(Button, { buttonStyle: "pill", margin: false, onClick: () => {
446
+ return (_jsxs("aside", { className: "ea-aside", "aria-label": copy.assistant, [KEEP_SELECTION_ATTR]: "", children: [_jsxs("div", { className: "ea-aside__head", children: [_jsxs("div", { className: "ea-aside__title", children: [_jsx("h2", { children: copy.assistant }), bridge ? _jsx("p", { className: "ea-aside__context", children: contextLine(bridge, config) }) : null] }), !batch && (messages.length > 0 || pendingApproval) && !pendingApproval?.applied ? (_jsx(Button, { buttonStyle: "subtle", margin: false, onClick: () => resetConversation(), size: "medium", children: copy.newChat })) : null, _jsx(Button, { "aria-label": copy.collapseLabel, buttonStyle: "icon-label", className: "ea-aside__collapse", icon: _jsx(ChevronIcon, { direction: "right" }), margin: false, onClick: () => setOpen(false), tooltip: copy.collapse })] }), batch ? (_jsx("div", { className: "ea-aside__body", children: _jsx(BatchPanel, { batch: batch, onClose: () => setBatch(null) }, batch.id) })) : (_jsxs("div", { className: "ea-aside__body", children: [_jsxs("div", { className: "ea-messages", ref: threadRef, onScroll: onThreadScroll, children: [messages.length === 0 ? (_jsx("div", { className: "ea-msg ea-msg--assistant", children: introText(view, bridge, collections, config, copy) })) : null, messages.map((message) => (_jsxs("div", { className: `ea-msg ea-msg--${message.role}`, children: [message.role === "assistant" ? _jsx(ReplyText, { text: message.text }) : message.text, message.candidates?.length ? (_jsx("div", { className: "ea-actions", children: message.candidates.map((candidate) => (_jsx(Button, { buttonStyle: "pill", margin: false, onClick: () => {
446
447
  router.push(candidate.href);
447
448
  }, size: "small", children: candidate.label }, candidate.href))) })) : null] }, message.id))), status === "streaming" ? (_jsxs("div", { className: "ea-msg ea-msg--working", children: [_jsxs("span", { className: "ea-dots", "aria-hidden": "true", children: [_jsx("span", {}), _jsx("span", {}), _jsx("span", {})] }), _jsx("span", { children: step ? talk.steps[step] : talk.working })] })) : null] }), pendingApproval ? (_jsxs("div", { className: "ea-approval", children: [_jsx("h3", { children: pendingApproval.title }), pendingApproval.applied ? (_jsxs("p", { className: "ea-approval__hint", children: [autosave ? copy.previewHintAutosave : copy.previewHint, pendingApproval.earlier?.length ? ` ${copy.refinedHint}` : ""] })) : null, _jsx("ul", { className: "ea-approval__diff", children: pendingApproval.summary.split("\n").map((line, index) => line.startsWith(" ") ? (
448
449
  // biome-ignore lint/suspicious/noArrayIndexKey: lines are static for one approval
@@ -454,7 +455,7 @@ export function EditorAssistantAside() {
454
455
  void send(shortcut.prompt);
455
456
  }, size: "small", children: shortcut.label }, shortcut.id)))] })) : null, selection && bridge ? (_jsxs("div", { className: "ea-selection", children: [_jsxs("span", { className: "ea-selection__label", children: [copy.selected, ": ", _jsx("strong", { children: selection.label })] }), _jsx("button", { "aria-label": copy.clearSelection, className: "ea-selection__clear", type: "button", onClick: () => setSelection(null), children: _jsx(XIcon, {}) })] })) : null, _jsxs("div", { className: "ea-composer__box", children: [_jsx("textarea", { disabled: status === "streaming", placeholder: copy.placeholder, rows: 2, value: draft, onChange: (event) => setDraft(event.target.value), onKeyDown: onKeyDown }), _jsx("div", { className: "ea-composer__bar", children: status === "streaming" ? (
456
457
  // While the assistant works, Send becomes Stop, where the editor's eye already is.
457
- _jsx(Button, { "aria-label": copy.stop, buttonStyle: "secondary", className: "ea-composer__stop", icon: _jsx("span", { className: "ea-stop-icon", "aria-hidden": "true" }), iconPosition: "left", margin: false, onClick: () => stop(), size: "medium", type: "button", children: copy.stop })) : (_jsx(Button, { buttonStyle: "primary", disabled: draft.trim() === "", margin: false, size: "medium", type: "submit", children: copy.send })) })] })] })] })] }));
458
+ _jsx(Button, { "aria-label": copy.stop, buttonStyle: "secondary", className: "ea-composer__stop", icon: _jsx("span", { className: "ea-stop-icon", "aria-hidden": "true" }), iconPosition: "left", margin: false, onClick: () => stop(), size: "medium", type: "button", children: copy.stop })) : (_jsx(Button, { buttonStyle: "primary", disabled: draft.trim() === "", margin: false, size: "medium", type: "submit", children: copy.send })) })] })] })] }))] }));
458
459
  }
459
460
  /** "Page · Home", with the locale when the project has more than one. */
460
461
  function contextLine(bridge, config) {
@@ -5,6 +5,7 @@ import { usePathname } from "next/navigation";
5
5
  import "../styles/assistant.css";
6
6
  import { isAuthView } from "../view.js";
7
7
  import { AssistantStateProvider, useAssistantState } from "./assistant-context.js";
8
+ import { useBatchLink } from "./batch-link.js";
8
9
  import { ChangedFieldsHighlight } from "./ChangedFieldsHighlight.js";
9
10
  import { EditorAssistantAside } from "./EditorAssistantAside.js";
10
11
  import { useNavRoom } from "./nav-room.js";
@@ -18,5 +19,6 @@ export function EditorAssistantProvider({ children, shortcuts = [], collections
18
19
  function EditorAssistantShell({ children }) {
19
20
  const { open } = useAssistantState();
20
21
  useNavRoom();
22
+ useBatchLink();
21
23
  return (_jsxs("div", { className: open ? "ea-root" : "ea-root ea-root--closed", children: [_jsx("div", { className: "ea-main", children: children }), _jsx(EditorAssistantAside, {}), _jsx(ChangedFieldsHighlight, {})] }));
22
24
  }
@@ -1,4 +1,5 @@
1
1
  import { type ReactNode } from "react";
2
+ import type { BatchScope, BatchTarget } from "../batch/targets.ts";
2
3
  import type { AssistantShortcut } from "../types.ts";
3
4
  import type { DomSelection } from "./selection-dom.ts";
4
5
  export type AssistantChromeStatus = "idle" | "unavailable" | "unauthorized" | "rate_limited" | "timeout" | "provider_error" | "no_model" | "tool_denied" | "stale_context" | "aborted" | "streaming";
@@ -46,6 +47,14 @@ export type PendingApproval = {
46
47
  basis: Record<string, unknown>;
47
48
  }>;
48
49
  };
50
+ /** Documents to fix at once, from a work list link; the panel shows them instead of the chat. */
51
+ export type BatchRequest = {
52
+ /** Groups the batch's audit events. */
53
+ id: string;
54
+ targets: BatchTarget[];
55
+ scope: BatchScope;
56
+ prompt: string;
57
+ };
49
58
  export type AssistantMessage = {
50
59
  id: string;
51
60
  role: "user" | "assistant" | "system";
@@ -70,6 +79,8 @@ type AssistantContextValue = {
70
79
  selection: DomSelection | null;
71
80
  /** A prompt to put in the message box, e.g. from a work list link; the panel takes it once. */
72
81
  prefill: string | null;
82
+ batch: BatchRequest | null;
83
+ setBatch: (batch: BatchRequest | null) => void;
73
84
  /** `remember: false` when the panel makes way on its own (see nav-room.ts), not the editor. */
74
85
  setOpen: (open: boolean, options?: {
75
86
  remember?: boolean;
@@ -13,6 +13,7 @@ export function AssistantStateProvider({ children, shortcuts, collections, userI
13
13
  const [abortController, setAbortController] = useState(null);
14
14
  const [selection, setSelection] = useState(null);
15
15
  const [prefill, setPrefill] = useState(null);
16
+ const [batch, setBatch] = useState(null);
16
17
  // Read after mount, so the server render (closed) and the first client render agree.
17
18
  useEffect(() => {
18
19
  if (readStoredOpen())
@@ -76,6 +77,8 @@ export function AssistantStateProvider({ children, shortcuts, collections, userI
76
77
  pendingApproval,
77
78
  selection,
78
79
  prefill,
80
+ batch,
81
+ setBatch,
79
82
  setOpen,
80
83
  setBridge,
81
84
  setFormAdapter,
@@ -99,6 +102,7 @@ export function AssistantStateProvider({ children, shortcuts, collections, userI
99
102
  pendingApproval,
100
103
  selection,
101
104
  prefill,
105
+ batch,
102
106
  setOpen,
103
107
  resetConversation,
104
108
  stop,
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Opens the panel in batch mode when the address carries a batch from content health's work list,
3
+ * and takes the parameters out of it, so a reload does not start the batch again.
4
+ */
5
+ export declare function useBatchLink(): void;
@@ -0,0 +1,35 @@
1
+ "use client";
2
+ import { useSearchParams } from "next/navigation";
3
+ import { useEffect } from "react";
4
+ import { BATCH_PARAM, BATCH_SCOPE_PARAM, parseBatchScope, parseBatchTargets, } from "../batch/targets.js";
5
+ import { useAssistantState } from "./assistant-context.js";
6
+ import { ASK_PARAM } from "./selection-dom.js";
7
+ /** Batches already taken this session: a view that writes its old address back must not repeat one. */
8
+ const taken = new Set();
9
+ /**
10
+ * Opens the panel in batch mode when the address carries a batch from content health's work list,
11
+ * and takes the parameters out of it, so a reload does not start the batch again.
12
+ */
13
+ export function useBatchLink() {
14
+ const params = useSearchParams();
15
+ const { setBatch, setOpen } = useAssistantState();
16
+ const value = params?.get(BATCH_PARAM);
17
+ useEffect(() => {
18
+ if (!value || taken.has(value)) {
19
+ return;
20
+ }
21
+ taken.add(value);
22
+ const url = new URL(window.location.href);
23
+ const targets = parseBatchTargets(url.searchParams.get(BATCH_PARAM));
24
+ const prompt = url.searchParams.get(ASK_PARAM)?.trim().slice(0, 2000);
25
+ const scope = parseBatchScope(url.searchParams.get(BATCH_SCOPE_PARAM));
26
+ for (const name of [BATCH_PARAM, BATCH_SCOPE_PARAM, ASK_PARAM]) {
27
+ url.searchParams.delete(name);
28
+ }
29
+ window.history.replaceState(window.history.state, "", url.toString());
30
+ if (targets.length > 0 && prompt) {
31
+ setBatch({ id: crypto.randomUUID(), targets, scope, prompt });
32
+ setOpen(true);
33
+ }
34
+ }, [value, setBatch, setOpen]);
35
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The batch panel's cards and what each step does to them. Pure: the panel runs the requests one
3
+ * after the other and feeds the answers in here.
4
+ */
5
+ import { type BatchTarget } from "../batch/targets.ts";
6
+ import type { ProposalLine } from "../domain/propose.ts";
7
+ export type BatchCardStatus = "waiting" | "working" | "proposed" | "nothing" | "failed" | "saving" | "saved" | "draft" | "stale" | "locked" | "rejected" | "unavailable";
8
+ export type BatchProposal = {
9
+ approvalId: string;
10
+ nonce: string;
11
+ lines: ProposalLine[];
12
+ };
13
+ export type BatchCard = {
14
+ key: string;
15
+ target: BatchTarget;
16
+ title: string;
17
+ thumbnail?: string;
18
+ /** The collection has drafts: saving writes a draft. */
19
+ drafts: boolean;
20
+ status: BatchCardStatus;
21
+ /** The assistant's sentence, or why nothing came of it. */
22
+ text?: string;
23
+ error?: string;
24
+ proposal?: BatchProposal;
25
+ /** Saved when the editor saves the batch; unchecked proposals are rejected. */
26
+ checked: boolean;
27
+ /** The editor's wording for plain text values, by path. */
28
+ edits: Record<string, string>;
29
+ };
30
+ export type TargetInfo = {
31
+ key: string;
32
+ title: string;
33
+ thumbnail?: string;
34
+ drafts: boolean;
35
+ allowed: boolean;
36
+ };
37
+ export type ProposeAnswer = {
38
+ ok: true;
39
+ text: string;
40
+ proposal?: BatchProposal;
41
+ } | {
42
+ ok: false;
43
+ error: string;
44
+ message?: string;
45
+ };
46
+ export type ApplyAnswer = {
47
+ ok: true;
48
+ status: "draft" | "saved" | "rejected";
49
+ } | {
50
+ ok: false;
51
+ error: string;
52
+ message?: string;
53
+ };
54
+ export declare function initialCards(targets: BatchTarget[], infos?: TargetInfo[]): BatchCard[];
55
+ /** Documents still to ask the assistant about, in order. */
56
+ export declare function waitingKeys(cards: BatchCard[]): string[];
57
+ export declare function cardAfterPropose(card: BatchCard, answer: ProposeAnswer): BatchCard;
58
+ /** What saving the batch does per card: apply the checked proposals, reject the others. */
59
+ export declare function savePlan(cards: BatchCard[]): Array<{
60
+ key: string;
61
+ action: "apply" | "reject";
62
+ }>;
63
+ /** The editor's wording where it differs from the proposal's. */
64
+ export declare function changedEdits(card: BatchCard): Record<string, string>;
65
+ export declare function cardAfterApply(card: BatchCard, answer: ApplyAnswer): BatchCard;