@simmalugnt-se/payload-editor-assistant 0.1.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.
Files changed (102) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +32 -0
  3. package/dist/capabilities.d.ts +10 -0
  4. package/dist/capabilities.js +43 -0
  5. package/dist/collections/approvals.d.ts +2 -0
  6. package/dist/collections/approvals.js +36 -0
  7. package/dist/collections/audit.d.ts +2 -0
  8. package/dist/collections/audit.js +40 -0
  9. package/dist/components/EditorAssistantAction.d.ts +1 -0
  10. package/dist/components/EditorAssistantAction.js +17 -0
  11. package/dist/components/EditorAssistantAside.d.ts +1 -0
  12. package/dist/components/EditorAssistantAside.js +335 -0
  13. package/dist/components/EditorAssistantDocumentBridge.d.ts +1 -0
  14. package/dist/components/EditorAssistantDocumentBridge.js +48 -0
  15. package/dist/components/EditorAssistantProvider.d.ts +8 -0
  16. package/dist/components/EditorAssistantProvider.js +14 -0
  17. package/dist/components/assistant-api.d.ts +9 -0
  18. package/dist/components/assistant-api.js +34 -0
  19. package/dist/components/assistant-context.d.ts +66 -0
  20. package/dist/components/assistant-context.js +69 -0
  21. package/dist/config/denied-fields.d.ts +3 -0
  22. package/dist/config/denied-fields.js +34 -0
  23. package/dist/config/validate.d.ts +15 -0
  24. package/dist/config/validate.js +86 -0
  25. package/dist/domain/allowlist.d.ts +12 -0
  26. package/dist/domain/allowlist.js +14 -0
  27. package/dist/domain/audit.d.ts +17 -0
  28. package/dist/domain/audit.js +25 -0
  29. package/dist/domain/content.d.ts +30 -0
  30. package/dist/domain/content.js +117 -0
  31. package/dist/domain/decide.d.ts +30 -0
  32. package/dist/domain/decide.js +162 -0
  33. package/dist/domain/guard.d.ts +13 -0
  34. package/dist/domain/guard.js +29 -0
  35. package/dist/domain/local-api.d.ts +16 -0
  36. package/dist/domain/local-api.js +3 -0
  37. package/dist/domain/navigate.d.ts +25 -0
  38. package/dist/domain/navigate.js +85 -0
  39. package/dist/domain/propose.d.ts +26 -0
  40. package/dist/domain/propose.js +150 -0
  41. package/dist/domain/relations.d.ts +12 -0
  42. package/dist/domain/relations.js +103 -0
  43. package/dist/endpoints/chat.d.ts +3 -0
  44. package/dist/endpoints/chat.js +88 -0
  45. package/dist/endpoints/csrf.d.ts +6 -0
  46. package/dist/endpoints/csrf.js +57 -0
  47. package/dist/endpoints/decide.d.ts +3 -0
  48. package/dist/endpoints/decide.js +33 -0
  49. package/dist/endpoints/propose.d.ts +3 -0
  50. package/dist/endpoints/propose.js +32 -0
  51. package/dist/exports/client.d.ts +4 -0
  52. package/dist/exports/client.js +4 -0
  53. package/dist/form/compile.d.ts +17 -0
  54. package/dist/form/compile.js +168 -0
  55. package/dist/form/diff.d.ts +9 -0
  56. package/dist/form/diff.js +155 -0
  57. package/dist/form/empty.d.ts +2 -0
  58. package/dist/form/empty.js +79 -0
  59. package/dist/form/hash.d.ts +2 -0
  60. package/dist/form/hash.js +22 -0
  61. package/dist/form/operations.d.ts +52 -0
  62. package/dist/form/operations.js +52 -0
  63. package/dist/form/path.d.ts +5 -0
  64. package/dist/form/path.js +79 -0
  65. package/dist/form/shape.d.ts +19 -0
  66. package/dist/form/shape.js +137 -0
  67. package/dist/index.d.ts +5 -0
  68. package/dist/index.js +3 -0
  69. package/dist/package-name.d.ts +7 -0
  70. package/dist/package-name.js +7 -0
  71. package/dist/plugin.d.ts +3 -0
  72. package/dist/plugin.js +110 -0
  73. package/dist/runtime/agent.d.ts +42 -0
  74. package/dist/runtime/agent.js +262 -0
  75. package/dist/runtime/context.d.ts +37 -0
  76. package/dist/runtime/context.js +92 -0
  77. package/dist/runtime/inflight.d.ts +2 -0
  78. package/dist/runtime/inflight.js +11 -0
  79. package/dist/runtime/language.d.ts +10 -0
  80. package/dist/runtime/language.js +27 -0
  81. package/dist/runtime/propose-loop.d.ts +7 -0
  82. package/dist/runtime/propose-loop.js +11 -0
  83. package/dist/runtime/rate-limit.d.ts +1 -0
  84. package/dist/runtime/rate-limit.js +14 -0
  85. package/dist/runtime/system-prompt.d.ts +3 -0
  86. package/dist/runtime/system-prompt.js +110 -0
  87. package/dist/runtime/tools.d.ts +56 -0
  88. package/dist/runtime/tools.js +189 -0
  89. package/dist/runtime/voice.d.ts +11 -0
  90. package/dist/runtime/voice.js +55 -0
  91. package/dist/schema/discover.d.ts +35 -0
  92. package/dist/schema/discover.js +93 -0
  93. package/dist/schema/fields.d.ts +21 -0
  94. package/dist/schema/fields.js +139 -0
  95. package/dist/schema/project.d.ts +15 -0
  96. package/dist/schema/project.js +111 -0
  97. package/dist/styles/assistant.css +362 -0
  98. package/dist/types.d.ts +61 -0
  99. package/dist/types.js +3 -0
  100. package/dist/view.d.ts +22 -0
  101. package/dist/view.js +72 -0
  102. package/package.json +73 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Simmalugnt AB
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,32 @@
1
+ # @simmalugnt-se/payload-editor-assistant
2
+
3
+ Context-aware AI assistant for Payload Admin. The editor asks for changes in a drawer; the assistant
4
+ proposes form changes the editor reviews and approves, within an allowlist of collections, globals,
5
+ fields and capabilities.
6
+
7
+ Requires Payload `>=3.87.1 <4`, Next 16, React 19, Node 22 and the AI SDK (`ai` 7) with a provider of
8
+ your choice.
9
+
10
+ ## Setup
11
+
12
+ ```ts
13
+ import { deepSeek } from "@ai-sdk/deepseek";
14
+ import { editorAssistantPlugin } from "@simmalugnt-se/payload-editor-assistant";
15
+
16
+ plugins: [
17
+ editorAssistantPlugin({
18
+ model: deepSeek("deepseek-chat"),
19
+ collections: {
20
+ pages: {
21
+ capabilities: ["content.find", "content.findById", "form.read", "form.propose", "draft.create"],
22
+ },
23
+ },
24
+ globals: {
25
+ header: { capabilities: ["content.findById", "form.read", "form.propose"] },
26
+ },
27
+ }),
28
+ ];
29
+ ```
30
+
31
+ Then regenerate the import map (`pnpm payload generate:importmap`). See `EditorAssistantPluginOptions`
32
+ for allowlists, host tools and instructions. Design notes live in `docs/research` and `.wayfinder`.
@@ -0,0 +1,10 @@
1
+ export declare const BUILTIN_CAPABILITIES: readonly ["admin.navigate", "schema.discover", "content.find", "content.findById", "form.read", "form.propose", "draft.create"];
2
+ export type BuiltinCapability = (typeof BUILTIN_CAPABILITIES)[number];
3
+ export type CapabilityRisk = "auto" | "approval" | "forbidden";
4
+ export declare const BUILTIN_RISK: Record<BuiltinCapability, CapabilityRisk>;
5
+ export declare const EMBEDDED_ONLY_CAPABILITIES: Set<"admin.navigate" | "schema.discover" | "content.find" | "content.findById" | "form.read" | "form.propose" | "draft.create">;
6
+ export declare const MCP_ELIGIBLE_CAPABILITIES: Set<"admin.navigate" | "schema.discover" | "content.find" | "content.findById" | "form.read" | "form.propose" | "draft.create">;
7
+ export declare const ENTITY_CAPABILITIES: readonly ["content.find", "content.findById", "form.read", "form.propose", "draft.create"];
8
+ export type EntityCapability = (typeof ENTITY_CAPABILITIES)[number];
9
+ export declare function isEntityCapability(value: string): value is EntityCapability;
10
+ export declare function isBuiltinCapability(value: string): value is BuiltinCapability;
@@ -0,0 +1,43 @@
1
+ export const BUILTIN_CAPABILITIES = [
2
+ "admin.navigate",
3
+ "schema.discover",
4
+ "content.find",
5
+ "content.findById",
6
+ "form.read",
7
+ "form.propose",
8
+ "draft.create",
9
+ ];
10
+ export const BUILTIN_RISK = {
11
+ "admin.navigate": "auto",
12
+ "schema.discover": "auto",
13
+ "content.find": "auto",
14
+ "content.findById": "auto",
15
+ "form.read": "auto",
16
+ "form.propose": "approval",
17
+ "draft.create": "approval",
18
+ };
19
+ export const EMBEDDED_ONLY_CAPABILITIES = new Set([
20
+ "admin.navigate",
21
+ "form.read",
22
+ "form.propose",
23
+ ]);
24
+ export const MCP_ELIGIBLE_CAPABILITIES = new Set([
25
+ "schema.discover",
26
+ "content.find",
27
+ "content.findById",
28
+ "draft.create",
29
+ ]);
30
+ export const ENTITY_CAPABILITIES = [
31
+ "content.find",
32
+ "content.findById",
33
+ "form.read",
34
+ "form.propose",
35
+ "draft.create",
36
+ ];
37
+ const ENTITY_CAPABILITY_SET = new Set(ENTITY_CAPABILITIES);
38
+ export function isEntityCapability(value) {
39
+ return ENTITY_CAPABILITY_SET.has(value);
40
+ }
41
+ export function isBuiltinCapability(value) {
42
+ return BUILTIN_CAPABILITIES.includes(value);
43
+ }
@@ -0,0 +1,2 @@
1
+ import type { CollectionConfig } from "payload";
2
+ export declare function createApprovalCollection(slug?: string): CollectionConfig;
@@ -0,0 +1,36 @@
1
+ import { DEFAULT_APPROVAL_SLUG } from "../package-name.js";
2
+ export function createApprovalCollection(slug = DEFAULT_APPROVAL_SLUG) {
3
+ return {
4
+ slug,
5
+ admin: {
6
+ hidden: true,
7
+ },
8
+ access: {
9
+ create: ({ req }) => Boolean(req.user),
10
+ delete: () => false,
11
+ read: ({ req }) => (req.user ? { actor: { equals: req.user.id } } : false),
12
+ update: ({ req }) => (req.user ? { actor: { equals: req.user.id } } : false),
13
+ },
14
+ fields: [
15
+ { name: "actor", type: "text", required: true, index: true },
16
+ { name: "capability", type: "text", required: true },
17
+ { name: "targetCollection", type: "text" },
18
+ { name: "targetGlobal", type: "text" },
19
+ { name: "documentId", type: "text" },
20
+ { name: "locale", type: "text", required: true },
21
+ { name: "canonicalHash", type: "text", required: true },
22
+ { name: "formRevision", type: "text" },
23
+ { name: "nonce", type: "text", required: true, unique: true },
24
+ { name: "expiresAt", type: "date", required: true, index: true },
25
+ {
26
+ name: "status",
27
+ type: "select",
28
+ required: true,
29
+ defaultValue: "proposed",
30
+ options: ["proposed", "approved", "rejected", "executed", "failed", "expired"],
31
+ },
32
+ { name: "operations", type: "json", required: true },
33
+ ],
34
+ timestamps: true,
35
+ };
36
+ }
@@ -0,0 +1,2 @@
1
+ import type { CollectionConfig } from "payload";
2
+ export declare function createAuditCollection(slug?: string): CollectionConfig;
@@ -0,0 +1,40 @@
1
+ import { DEFAULT_AUDIT_SLUG } from "../package-name.js";
2
+ export function createAuditCollection(slug = DEFAULT_AUDIT_SLUG) {
3
+ return {
4
+ slug,
5
+ admin: {
6
+ hidden: true,
7
+ useAsTitle: "capability",
8
+ },
9
+ access: {
10
+ create: ({ req }) => Boolean(req.user),
11
+ delete: () => false,
12
+ read: ({ req }) => (req.user ? { actor: { equals: req.user.id } } : false),
13
+ update: () => false,
14
+ },
15
+ fields: [
16
+ { name: "actor", type: "text", required: true, index: true },
17
+ { name: "sessionId", type: "text" },
18
+ { name: "requestId", type: "text" },
19
+ { name: "capability", type: "text", required: true },
20
+ { name: "risk", type: "text", required: true },
21
+ { name: "targetCollection", type: "text" },
22
+ { name: "targetGlobal", type: "text" },
23
+ { name: "documentId", type: "text" },
24
+ { name: "locale", type: "text" },
25
+ { name: "fieldPaths", type: "json" },
26
+ { name: "approvalId", type: "text" },
27
+ { name: "nonceId", type: "text" },
28
+ { name: "canonicalHash", type: "text" },
29
+ { name: "baseRevision", type: "text" },
30
+ {
31
+ name: "outcome",
32
+ type: "select",
33
+ required: true,
34
+ options: ["proposed", "approved", "rejected", "executed", "failed", "denied", "expired"],
35
+ },
36
+ { name: "errorClass", type: "text" },
37
+ ],
38
+ timestamps: true,
39
+ };
40
+ }
@@ -0,0 +1 @@
1
+ export declare function EditorAssistantAction(): import("react").JSX.Element | null;
@@ -0,0 +1,17 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { Button, useAuth } from "@payloadcms/ui";
4
+ import { useAssistantState } from "./assistant-context.js";
5
+ export function EditorAssistantAction() {
6
+ const { user } = useAuth();
7
+ const { collapsed, setCollapsed, setOpen } = useAssistantState();
8
+ if (!user) {
9
+ return null;
10
+ }
11
+ return (_jsx(Button, { buttonStyle: "pill", className: "ea-trigger", onClick: (event) => {
12
+ event.preventDefault();
13
+ event.stopPropagation();
14
+ setOpen(true);
15
+ setCollapsed(false);
16
+ }, size: "medium", type: "button", children: collapsed ? "Open assistant" : "Assistant" }));
17
+ }
@@ -0,0 +1 @@
1
+ export declare function EditorAssistantAside(): import("react").JSX.Element;
@@ -0,0 +1,335 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useConfig, useLocale, useTranslation } from "@payloadcms/ui";
4
+ import { usePathname, useRouter } from "next/navigation";
5
+ import { useLayoutEffect, useRef, useState, } from "react";
6
+ import { parseAdminView } from "../view.js";
7
+ import { assistantRequest } from "./assistant-api.js";
8
+ import { useAssistantState, } from "./assistant-context.js";
9
+ const STATUS_LABEL = {
10
+ idle: "",
11
+ unavailable: "unavailable",
12
+ unauthorized: "unauthorized",
13
+ rate_limited: "rate_limited",
14
+ timeout: "timeout",
15
+ provider_error: "provider_error",
16
+ tool_denied: "tool_denied",
17
+ stale_context: "stale_context",
18
+ aborted: "aborted",
19
+ streaming: "Working…",
20
+ };
21
+ /** Messages the panel adds to the chat itself; they follow the editor's chat language. */
22
+ const CHAT_COPY = {
23
+ en: {
24
+ requestFailed: "I could not complete that request.",
25
+ done: "Done.",
26
+ proposalFailed: "The proposal could not be applied.",
27
+ rejectedForm: "Rejected. The form was not changed.",
28
+ rejectedDraft: "Rejected. No draft was created.",
29
+ applied: "Applied to the unsaved form. Save in Payload when you are ready.",
30
+ applyFailed: "The form could not be updated. It was left unchanged.",
31
+ draftCreated: "The draft is created and open.",
32
+ },
33
+ sv: {
34
+ requestFailed: "Jag kunde inte slutföra det.",
35
+ done: "Klart.",
36
+ proposalFailed: "Förslaget kunde inte genomföras.",
37
+ rejectedForm: "Avvisat. Formuläret ändrades inte.",
38
+ rejectedDraft: "Avvisat. Inget utkast skapades.",
39
+ applied: "Ändringen är införd i formuläret men inte sparad. Spara i Payload när du är redo.",
40
+ applyFailed: "Formuläret kunde inte uppdateras och lämnades orört.",
41
+ draftCreated: "Utkastet är skapat och öppnat.",
42
+ },
43
+ };
44
+ export function EditorAssistantAside() {
45
+ const { collapsed, setCollapsed, status, messages, shortcuts, collections, bridge, formAdapter, pendingApproval, resetConversation, setStatus, setMessages, setPendingApproval, stop, setAbortController, } = useAssistantState();
46
+ const { config } = useConfig();
47
+ const router = useRouter();
48
+ const locale = useLocale();
49
+ const { i18n } = useTranslation();
50
+ const [chatLanguage, setChatLanguage] = useState(i18n.language?.startsWith("sv") ? "sv" : "en");
51
+ const copy = CHAT_COPY[chatLanguage];
52
+ const pathname = usePathname();
53
+ const view = parseAdminView(pathname ?? "", config.routes.admin);
54
+ const visibleShortcuts = shortcuts.filter((shortcut) => shortcutMatches(shortcut, view));
55
+ const [draft, setDraft] = useState("");
56
+ const threadRef = useRef(null);
57
+ const stickToBottomRef = useRef(true);
58
+ const apiBase = config.routes.api;
59
+ // Scroll when the thread grows. The values are triggers, not reads.
60
+ // biome-ignore lint/correctness/useExhaustiveDependencies: pin to bottom after new messages, working state, or approval cards
61
+ useLayoutEffect(() => {
62
+ const node = threadRef.current;
63
+ if (!node || !stickToBottomRef.current) {
64
+ return;
65
+ }
66
+ node.scrollTop = node.scrollHeight;
67
+ }, [messages, status, pendingApproval]);
68
+ const onThreadScroll = (event) => {
69
+ const node = event.currentTarget;
70
+ const remaining = node.scrollHeight - node.scrollTop - node.clientHeight;
71
+ stickToBottomRef.current = remaining < 48;
72
+ };
73
+ const send = async (text) => {
74
+ const trimmed = text.trim();
75
+ if (!trimmed || status === "streaming") {
76
+ return;
77
+ }
78
+ stickToBottomRef.current = true;
79
+ setDraft("");
80
+ const userMessage = { id: crypto.randomUUID(), role: "user", text: trimmed };
81
+ setMessages((current) => [...current, userMessage]);
82
+ const controller = new AbortController();
83
+ setAbortController(controller);
84
+ setStatus("streaming");
85
+ try {
86
+ const history = [...messages, userMessage].map((message) => ({
87
+ role: message.role,
88
+ text: message.text,
89
+ }));
90
+ const result = await assistantRequest(apiBase, "/chat", {
91
+ message: trimmed,
92
+ messages: history,
93
+ bridge,
94
+ view,
95
+ locale: locale.code,
96
+ uiLanguage: i18n.language,
97
+ formData: formAdapter?.getData(),
98
+ }, controller.signal);
99
+ if (controller.signal.aborted) {
100
+ setStatus("aborted");
101
+ return;
102
+ }
103
+ if (!result.ok) {
104
+ const error = (result.error ?? statusFromHttp(result.status));
105
+ setStatus(error);
106
+ setMessages((current) => [
107
+ ...current,
108
+ {
109
+ id: crypto.randomUUID(),
110
+ role: "assistant",
111
+ text: result.message ?? copy.requestFailed,
112
+ },
113
+ ]);
114
+ return;
115
+ }
116
+ setStatus("idle");
117
+ if (result.data.language === "sv" || result.data.language === "en") {
118
+ setChatLanguage(result.data.language);
119
+ }
120
+ if (result.data.pendingApproval) {
121
+ setPendingApproval(result.data.pendingApproval);
122
+ }
123
+ const navigate = result.data.navigate;
124
+ setMessages((current) => [
125
+ ...current,
126
+ {
127
+ id: crypto.randomUUID(),
128
+ role: "assistant",
129
+ text: result.data.text ?? copy.done,
130
+ candidates: navigate && "candidates" in navigate ? navigate.candidates : undefined,
131
+ },
132
+ ]);
133
+ if (navigate && "href" in navigate) {
134
+ router.push(navigate.href);
135
+ }
136
+ }
137
+ catch {
138
+ setStatus(controller.signal.aborted ? "aborted" : "provider_error");
139
+ }
140
+ finally {
141
+ setAbortController(null);
142
+ }
143
+ };
144
+ const decide = async (action) => {
145
+ if (!pendingApproval || status === "streaming") {
146
+ return;
147
+ }
148
+ stickToBottomRef.current = true;
149
+ if (action === "approve" && pendingApproval.capability === "form.propose") {
150
+ if (!formAdapter || !bridge || !canApplyForm(bridge)) {
151
+ setStatus("stale_context");
152
+ return;
153
+ }
154
+ }
155
+ const controller = new AbortController();
156
+ setAbortController(controller);
157
+ setStatus("streaming");
158
+ try {
159
+ const result = await assistantRequest(apiBase, "/decide", {
160
+ action,
161
+ approvalId: pendingApproval.approvalId,
162
+ nonce: pendingApproval.nonce,
163
+ collection: bridge?.collectionSlug,
164
+ global: bridge?.globalSlug,
165
+ documentId: bridge?.documentId,
166
+ locale: bridge?.locale ?? "en",
167
+ formData: formAdapter?.getData(),
168
+ }, controller.signal);
169
+ if (!result.ok) {
170
+ const error = (result.error ?? statusFromHttp(result.status));
171
+ setStatus(error);
172
+ setPendingApproval(null);
173
+ setMessages((current) => [
174
+ ...current,
175
+ {
176
+ id: crypto.randomUUID(),
177
+ role: "assistant",
178
+ text: result.message ?? copy.proposalFailed,
179
+ },
180
+ ]);
181
+ return;
182
+ }
183
+ setPendingApproval(null);
184
+ setStatus("idle");
185
+ if (result.data.rejected) {
186
+ setMessages((current) => [
187
+ ...current,
188
+ {
189
+ id: crypto.randomUUID(),
190
+ role: "assistant",
191
+ text: pendingApproval.capability === "draft.create"
192
+ ? copy.rejectedDraft
193
+ : copy.rejectedForm,
194
+ },
195
+ ]);
196
+ return;
197
+ }
198
+ if (result.data.capability === "form.propose" && result.data.patchedData && formAdapter) {
199
+ try {
200
+ await formAdapter.reset(result.data.patchedData);
201
+ formAdapter.setModified(true);
202
+ setMessages((current) => [
203
+ ...current,
204
+ {
205
+ id: crypto.randomUUID(),
206
+ role: "assistant",
207
+ text: copy.applied,
208
+ },
209
+ ]);
210
+ }
211
+ catch {
212
+ setStatus("stale_context");
213
+ setMessages((current) => [
214
+ ...current,
215
+ {
216
+ id: crypto.randomUUID(),
217
+ role: "assistant",
218
+ text: copy.applyFailed,
219
+ },
220
+ ]);
221
+ }
222
+ return;
223
+ }
224
+ if (result.data.capability === "draft.create" &&
225
+ result.data.collection &&
226
+ result.data.documentId) {
227
+ const href = `${config.routes.admin}/collections/${result.data.collection}/${result.data.documentId}`;
228
+ setMessages((current) => [
229
+ ...current,
230
+ {
231
+ id: crypto.randomUUID(),
232
+ role: "assistant",
233
+ text: copy.draftCreated,
234
+ },
235
+ ]);
236
+ router.push(href);
237
+ }
238
+ }
239
+ catch {
240
+ setStatus(controller.signal.aborted ? "aborted" : "provider_error");
241
+ }
242
+ finally {
243
+ setAbortController(null);
244
+ }
245
+ };
246
+ const onSubmit = (event) => {
247
+ event.preventDefault();
248
+ void send(draft);
249
+ };
250
+ const onKeyDown = (event) => {
251
+ if (event.key === "Enter" && !event.shiftKey) {
252
+ event.preventDefault();
253
+ void send(draft);
254
+ }
255
+ };
256
+ if (collapsed) {
257
+ return (_jsx("aside", { className: "ea-aside ea-aside--collapsed", "aria-label": "Assistant", children: _jsx("button", { className: "ea-aside__rail", type: "button", onClick: () => setCollapsed(false), children: "Open assistant" }) }));
258
+ }
259
+ return (_jsxs("aside", { className: "ea-aside", "aria-label": "Assistant", children: [_jsxs("div", { className: "ea-aside__head", children: [_jsx("h2", { children: "Assistant" }), _jsx("button", { className: "ea-btn ea-btn--ghost", type: "button", onClick: () => resetConversation(), children: "Reset" }), _jsx("button", { className: "ea-btn ea-btn--ghost", type: "button", onClick: () => setCollapsed(true), children: "Collapse" })] }), _jsxs("div", { className: "ea-aside__body", children: [bridge ? (_jsxs("div", { className: "ea-context", children: [_jsx("span", { children: bridge.collectionSlug ?? bridge.globalSlug }), _jsx("span", { children: bridge.title ?? "document" }), _jsx("span", { children: bridge.locale }), _jsx("span", { className: bridge.modified ? "ea-context__warn" : "ea-context__ok", children: bridge.modified ? "unsaved" : "saved" })] })) : null, _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) })) : null, messages.map((message) => (_jsxs("div", { className: `ea-msg ea-msg--${message.role}`, children: [message.text, message.candidates?.length ? (_jsx("div", { className: "ea-actions", children: message.candidates.map((candidate) => (_jsx("button", { className: "ea-btn", type: "button", onClick: () => {
260
+ router.push(candidate.href);
261
+ }, children: candidate.label }, candidate.href))) })) : null] }, message.id))), status === "streaming" ? (_jsxs("div", { className: "ea-msg ea-msg--working", children: [_jsx("span", { children: STATUS_LABEL.streaming }), _jsx("button", { className: "ea-btn ea-btn--stop", type: "button", onClick: () => stop(), children: "Stop" })] })) : null] }), pendingApproval ? (_jsxs("div", { className: "ea-approval", children: [_jsx("h3", { children: pendingApproval.title }), _jsx("ul", { className: "ea-approval__diff", children: pendingApproval.summary.split("\n").map((line, index) => line.startsWith(" ") ? (
262
+ // biome-ignore lint/suspicious/noArrayIndexKey: lines are static for one approval
263
+ _jsx("li", { className: "ea-approval__sub", children: line.trim() }, index)) : (
264
+ // biome-ignore lint/suspicious/noArrayIndexKey: lines are static for one approval
265
+ _jsx("li", { children: line }, index))) }), _jsxs("div", { className: "ea-actions", children: [_jsx("button", { className: "ea-btn ea-btn--primary", type: "button", onClick: () => void decide("approve"), children: "Approve" }), _jsx("button", { className: "ea-btn", type: "button", onClick: () => void decide("reject"), children: "Reject" })] })] })) : null, status !== "idle" && status !== "streaming" ? (_jsx("div", { className: `ea-status ea-status--${status}`, children: STATUS_LABEL[status] })) : null, _jsxs("form", { className: "ea-composer", onSubmit: onSubmit, children: [visibleShortcuts.length > 0 ? (_jsx("div", { className: "ea-shortcuts", children: visibleShortcuts.map((shortcut) => (_jsx("button", { className: "ea-btn ea-btn--chip", disabled: status === "streaming", type: "button", onClick: () => {
266
+ void send(shortcut.prompt);
267
+ }, children: shortcut.label }, shortcut.id))) })) : null, _jsxs("div", { className: "ea-composer__box", children: [_jsx("textarea", { disabled: status === "streaming", placeholder: "Message the assistant", rows: 2, value: draft, onChange: (event) => setDraft(event.target.value), onKeyDown: onKeyDown }), _jsx("div", { className: "ea-composer__bar", children: _jsx("button", { className: "ea-btn ea-btn--primary", disabled: status === "streaming" || draft.trim() === "", type: "submit", children: "Send" }) })] })] })] })] }));
268
+ }
269
+ function introText(view, bridge, collections, config) {
270
+ switch (view.kind) {
271
+ case "document":
272
+ case "global": {
273
+ if (!bridge) {
274
+ return "I can help you find content, open a page, or start a new draft. What do you need?";
275
+ }
276
+ const name = bridge.title ? `"${bridge.title}"` : "this document";
277
+ return `I can explain the fields on ${name}, suggest edits, or fill in empty fields. What do you need?`;
278
+ }
279
+ case "list": {
280
+ if (!collections.includes(view.collection)) {
281
+ return "I can't work with this list, but I can help you find content, open a page, or start a new draft.";
282
+ }
283
+ const collection = config.collections?.find((entry) => entry.slug === view.collection);
284
+ const plural = textLabel(collection?.labels?.plural) ?? view.collection;
285
+ const singular = textLabel(collection?.labels?.singular)?.toLowerCase() ?? "document";
286
+ return `You are in ${plural}. I can find and open a ${singular}, or start a new draft for you. What do you need?`;
287
+ }
288
+ default:
289
+ return "I can help you find content, open a page, or start a new draft. What do you need?";
290
+ }
291
+ }
292
+ function textLabel(value) {
293
+ return typeof value === "string" && value.trim() ? value : undefined;
294
+ }
295
+ function shortcutMatches(shortcut, view) {
296
+ if (shortcut.when && !shortcut.when.includes(view.kind)) {
297
+ return false;
298
+ }
299
+ if (!shortcut.entities) {
300
+ return true;
301
+ }
302
+ const entity = view.kind === "list" || view.kind === "document"
303
+ ? view.collection
304
+ : view.kind === "global"
305
+ ? view.global
306
+ : undefined;
307
+ return entity === undefined || shortcut.entities.includes(entity);
308
+ }
309
+ function canApplyForm(bridge) {
310
+ if (bridge.disabled || bridge.locked) {
311
+ return false;
312
+ }
313
+ if (bridge.hasSavePermission === false) {
314
+ return false;
315
+ }
316
+ if (bridge.uploadStatus && bridge.uploadStatus !== "idle") {
317
+ return false;
318
+ }
319
+ return true;
320
+ }
321
+ function statusFromHttp(status) {
322
+ if (status === 401 || status === 403) {
323
+ return "unauthorized";
324
+ }
325
+ if (status === 429) {
326
+ return "rate_limited";
327
+ }
328
+ if (status === 504) {
329
+ return "timeout";
330
+ }
331
+ if (status === 503) {
332
+ return "provider_error";
333
+ }
334
+ return "provider_error";
335
+ }
@@ -0,0 +1 @@
1
+ export declare function EditorAssistantDocumentBridge(): null;
@@ -0,0 +1,48 @@
1
+ "use client";
2
+ import { useDocumentForm, useDocumentInfo, useFormModified, useLocale } from "@payloadcms/ui";
3
+ import { useEffect, useRef } from "react";
4
+ import { useAssistantState } from "./assistant-context.js";
5
+ export function EditorAssistantDocumentBridge() {
6
+ const { setBridge, setFormAdapter } = useAssistantState();
7
+ const documentInfo = useDocumentInfo();
8
+ const locale = useLocale();
9
+ const modified = useFormModified();
10
+ const form = useDocumentForm();
11
+ const formRef = useRef(form);
12
+ useEffect(() => {
13
+ formRef.current = form;
14
+ });
15
+ useEffect(() => {
16
+ setFormAdapter({
17
+ getData: () => formRef.current.getData(),
18
+ reset: (data) => formRef.current.reset(data),
19
+ setModified: (value) => formRef.current.setModified(value),
20
+ });
21
+ return () => {
22
+ setFormAdapter(null);
23
+ };
24
+ }, [setFormAdapter]);
25
+ useEffect(() => {
26
+ const titleValue = documentInfo.data && "title" in documentInfo.data ? documentInfo.data.title : undefined;
27
+ const locked = Boolean(documentInfo.documentIsLocked) ||
28
+ Boolean(documentInfo.isLocked);
29
+ const hasSavePermission = documentInfo.hasSavePermission;
30
+ const uploadStatus = documentInfo.uploadStatus;
31
+ setBridge({
32
+ collectionSlug: documentInfo.collectionSlug,
33
+ globalSlug: documentInfo.globalSlug,
34
+ documentId: documentInfo.id,
35
+ locale: locale.code,
36
+ title: typeof titleValue === "string" ? titleValue : undefined,
37
+ modified: Boolean(modified),
38
+ locked,
39
+ hasSavePermission,
40
+ uploadStatus,
41
+ disabled: Boolean(form.disabled),
42
+ });
43
+ return () => {
44
+ setBridge(null);
45
+ };
46
+ }, [documentInfo, locale.code, modified, form.disabled, setBridge]);
47
+ return null;
48
+ }
@@ -0,0 +1,8 @@
1
+ import type { ReactNode } from "react";
2
+ import "../styles/assistant.css";
3
+ import type { AssistantShortcut } from "../types.ts";
4
+ export declare function EditorAssistantProvider({ children, shortcuts, collections, }: {
5
+ children: ReactNode;
6
+ shortcuts?: AssistantShortcut[];
7
+ collections?: string[];
8
+ }): import("react").JSX.Element;
@@ -0,0 +1,14 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useAuth } from "@payloadcms/ui";
4
+ import "../styles/assistant.css";
5
+ import { AssistantStateProvider, useAssistantState } from "./assistant-context.js";
6
+ import { EditorAssistantAside } from "./EditorAssistantAside.js";
7
+ export function EditorAssistantProvider({ children, shortcuts = [], collections = [], }) {
8
+ const { user } = useAuth();
9
+ return (_jsx(AssistantStateProvider, { shortcuts: shortcuts, collections: collections, children: user ? _jsx(EditorAssistantShell, { children: children }) : children }));
10
+ }
11
+ function EditorAssistantShell({ children }) {
12
+ const { collapsed } = useAssistantState();
13
+ return (_jsxs("div", { className: collapsed ? "ea-root ea-root--collapsed" : "ea-root", children: [_jsx("div", { className: "ea-main", children: children }), _jsx(EditorAssistantAside, {})] }));
14
+ }
@@ -0,0 +1,9 @@
1
+ export declare function assistantRequest<T>(apiBase: string, path: string, body: unknown, signal?: AbortSignal): Promise<{
2
+ ok: true;
3
+ data: T;
4
+ } | {
5
+ ok: false;
6
+ error: string;
7
+ message?: string;
8
+ status: number;
9
+ }>;