@simmalugnt-se/payload-editor-assistant 0.12.2 → 0.13.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 (38) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +37 -0
  3. package/dist/chat-images.d.ts +20 -0
  4. package/dist/chat-images.js +61 -0
  5. package/dist/components/ChatImages.d.ts +20 -0
  6. package/dist/components/ChatImages.js +113 -0
  7. package/dist/components/EditorAssistantAside.js +30 -13
  8. package/dist/components/ProposalReview.d.ts +1 -1
  9. package/dist/components/ProposalReview.js +7 -3
  10. package/dist/components/assistant-context.d.ts +2 -0
  11. package/dist/components/chat-image-file.d.ts +3 -0
  12. package/dist/components/chat-image-file.js +48 -0
  13. package/dist/components/conversation-store.js +34 -4
  14. package/dist/components/copy.d.ts +14 -0
  15. package/dist/components/copy.js +14 -0
  16. package/dist/domain/batch.js +4 -1
  17. package/dist/domain/decide.js +9 -0
  18. package/dist/domain/new-block-review.d.ts +7 -0
  19. package/dist/domain/new-block-review.js +97 -0
  20. package/dist/domain/propose.d.ts +2 -0
  21. package/dist/domain/propose.js +33 -6
  22. package/dist/endpoints/chat.d.ts +3 -0
  23. package/dist/endpoints/chat.js +31 -2
  24. package/dist/endpoints/decide.js +27 -6
  25. package/dist/form/diff.js +3 -2
  26. package/dist/form/relation-shape.d.ts +10 -5
  27. package/dist/form/relation-shape.js +104 -28
  28. package/dist/runtime/agent.d.ts +3 -1
  29. package/dist/runtime/agent.js +100 -8
  30. package/dist/runtime/context.d.ts +10 -8
  31. package/dist/runtime/context.js +16 -10
  32. package/dist/runtime/debug.d.ts +10 -0
  33. package/dist/runtime/debug.js +132 -0
  34. package/dist/runtime/system-prompt.js +4 -2
  35. package/dist/schema/fields.d.ts +6 -0
  36. package/dist/schema/fields.js +20 -2
  37. package/dist/styles/assistant.css +46 -0
  38. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.13.0
4
+
5
+ - Attach reference images to chat with file selection, drag-and-drop or clipboard paste. Show
6
+ removable previews, resize large images, bound recent image context, and preserve attachments
7
+ with the browser conversation without uploading them to the CMS.
8
+ - Add opt-in server-only `DEBUG_ASSISTANT=true` traces for conversations, context, tool calls and
9
+ results, provider-exposed reasoning, usage, replies, decisions and errors. Optionally persist
10
+ complete local JSONL with `DEBUG_ASSISTANT_LOG_DIR`; redact credential fields/nonces and represent
11
+ images by metadata. Chunk large server records and report server log truncation explicitly.
12
+
13
+ - The model receives the complete schema of nested groups, array rows and blocks, including
14
+ relationship formats, field defaults and conditional requirements. Structured internal links
15
+ use document references rather than rich text's `doc:` notation in a URL field.
16
+ - Relationship checks reach fields inside changed containers and refuse missing required document
17
+ references using the host's field conditions. A single-collection polymorphic reference still
18
+ requires `{ relationTo, value }`; proposals and approvals repeat these checks. Selected documents
19
+ are named in the review.
20
+ - New blocks in draft creation and form proposals use the same collapsible review as translations,
21
+ with separate schema-labelled fields and full formatted rich text. The preview follows the final
22
+ block order and values after all proposed operations, without truncating the block's content.
23
+
3
24
  ## 0.12.2
4
25
 
5
26
  - Batch links remove their parameters from Next’s router state as well as browser history, so
package/README.md CHANGED
@@ -259,3 +259,40 @@ chat** clears the current one.
259
259
  | `trustedOrigins` | Origins allowed to call the assistant's endpoints. Default: only the origin serving Admin. When set, only the listed origins. |
260
260
  | `audit.collection` | Slug of the collection that logs assistant actions (default `editor-assistant-audit`). |
261
261
  | `disabled` | Turn the plugin off without removing its config. |
262
+
263
+ ## Reference images in chat
264
+
265
+ Use **Add images**, drop images on the assistant panel, or paste a screenshot into the message.
266
+ Each message can include up to three PNG, JPEG, WebP or GIF images (source files up to 10 MB).
267
+ Large images are resized to at most 2048px on their longest edge and compressed before sending;
268
+ each attachment is at most 1 MiB and the complete image context is at most 2 MiB. Use a model
269
+ that supports images. Attachments are reference context, not files in the CMS media library.
270
+
271
+ Recent attachments are remembered with the browser conversation. Older image bytes are omitted
272
+ when the context/storage budget is reached, keeping their filenames. If browser storage is full,
273
+ the transcript is saved without image bytes. Attach the image again when it is needed later.
274
+
275
+ ## Assistant debug traces
276
+
277
+ Set server-only `DEBUG_ASSISTANT=true` to log turns through Payload's logger. No client build flag
278
+ is needed. Logs include the user message and history, the allowlisted context and system prompt,
279
+ tool inputs/results, each model step's text and provider-exposed reasoning, usage, finish reason,
280
+ errors and the final reply/proposal. Decisions on Keep/Undo and draft approval are logged separately,
281
+ linked by `approvalId`. Every run has a `traceId` and ordered `sequence` numbers. Reasoning is
282
+ available only when the provider returns it; hidden model reasoning is not accessible.
283
+
284
+ This is separate from the value-free audit trail and off by default. It intentionally contains
285
+ conversation and CMS content. Request headers, environment variables and provider configuration
286
+ are never logged; known credential keys and approval nonces are removed. Image bytes are replaced
287
+ with size/hash metadata. Large server records are split into base64 `trace.chunk` entries; a
288
+ `trace.truncated` marker reports reaching the server log budget (850 KiB / 220 lines per run).
289
+
290
+ For complete local JSONL files, also set `DEBUG_ASSISTANT_LOG_DIR=.assistant-debug`. One file is
291
+ written per turn/decision, and flushed before the request finishes. File logging failures never
292
+ block the assistant. Keep this directory out of Git. Do not set this directory on Vercel: function
293
+ storage is temporary. Use server logs and export them before the plan's retention window expires.
294
+ For longer automatic retention, configure a host log drain.
295
+
296
+ The boilerplate provides `pnpm assistant:logs --deployment <URL> --since 1h` to download Vercel
297
+ logs and reconstruct the records in `.assistant-debug/`. Other hosts can use the same server logger
298
+ records with their own logging/export tools. Logging never changes the audit collection schema.
@@ -0,0 +1,20 @@
1
+ /** Shared browser/server limits for reference images, separate from CMS uploads. */
2
+ export declare const MAX_CHAT_IMAGES = 3;
3
+ export declare const MAX_CHAT_IMAGE_BYTES: number;
4
+ export declare const MAX_CHAT_IMAGES_BYTES: number;
5
+ export declare const CHAT_IMAGE_TYPES: readonly ["image/png", "image/jpeg", "image/webp", "image/gif"];
6
+ export type ChatImage = {
7
+ name: string;
8
+ mediaType: (typeof CHAT_IMAGE_TYPES)[number];
9
+ /** Base64 bytes, never an address to fetch. Absent on an older image omitted from storage. */
10
+ data?: string;
11
+ };
12
+ export declare function imageByteLength(data: string): number;
13
+ /** Reject URL references, mismatched signatures and oversized bytes on the server as well. */
14
+ export declare function isChatImage(value: unknown): value is ChatImage & {
15
+ data: string;
16
+ };
17
+ /** Keep the newest image bytes within the request/storage budget; retain older filenames. */
18
+ export declare function boundChatImages<T extends {
19
+ images?: ChatImage[];
20
+ }>(messages: T[]): T[];
@@ -0,0 +1,61 @@
1
+ /** Shared browser/server limits for reference images, separate from CMS uploads. */
2
+ export const MAX_CHAT_IMAGES = 3;
3
+ export const MAX_CHAT_IMAGE_BYTES = 1024 * 1024;
4
+ export const MAX_CHAT_IMAGES_BYTES = 2 * 1024 * 1024;
5
+ export const CHAT_IMAGE_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"];
6
+ export function imageByteLength(data) {
7
+ return (data.length / 4) * 3 - (data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0);
8
+ }
9
+ /** Reject URL references, mismatched signatures and oversized bytes on the server as well. */
10
+ export function isChatImage(value) {
11
+ const image = value;
12
+ if (!image ||
13
+ typeof image.name !== "string" ||
14
+ image.name.length > 200 ||
15
+ !CHAT_IMAGE_TYPES.includes(image.mediaType) ||
16
+ typeof image.data !== "string" ||
17
+ !image.data ||
18
+ image.data.length > Math.ceil(MAX_CHAT_IMAGE_BYTES / 3) * 4 ||
19
+ image.data.length % 4 !== 0 ||
20
+ !/^[A-Za-z0-9+/]*={0,2}$/.test(image.data) ||
21
+ imageByteLength(image.data) > MAX_CHAT_IMAGE_BYTES)
22
+ return false;
23
+ const header = atob(image.data.slice(0, 24));
24
+ switch (image.mediaType) {
25
+ case "image/png":
26
+ return header.startsWith("\x89PNG\r\n\x1a\n");
27
+ case "image/jpeg":
28
+ return header.startsWith("\xff\xd8\xff");
29
+ case "image/gif":
30
+ return header.startsWith("GIF87a") || header.startsWith("GIF89a");
31
+ case "image/webp":
32
+ return header.startsWith("RIFF") && header.slice(8, 12) === "WEBP";
33
+ default:
34
+ return false;
35
+ }
36
+ }
37
+ /** Keep the newest image bytes within the request/storage budget; retain older filenames. */
38
+ export function boundChatImages(messages) {
39
+ let bytes = 0;
40
+ return [...messages]
41
+ .reverse()
42
+ .map((message) => ({
43
+ ...message,
44
+ ...(message.images
45
+ ? {
46
+ images: message.images.map((image) => {
47
+ if (!image.data)
48
+ return image;
49
+ const size = imageByteLength(image.data);
50
+ if (bytes + size > MAX_CHAT_IMAGES_BYTES) {
51
+ const { data: _data, ...metadata } = image;
52
+ return metadata;
53
+ }
54
+ bytes += size;
55
+ return image;
56
+ }),
57
+ }
58
+ : {}),
59
+ }))
60
+ .reverse();
61
+ }
@@ -0,0 +1,20 @@
1
+ import { type ClipboardEvent, type DragEvent } from "react";
2
+ import { type ChatImage } from "../chat-images.ts";
3
+ import type { Copy } from "./copy.ts";
4
+ export declare function ChatImages({ images, copy, onRemove, }: {
5
+ images?: ChatImage[];
6
+ copy: Copy;
7
+ onRemove?: (index: number) => void;
8
+ }): import("react").JSX.Element | null;
9
+ /** Preparation is bounded and cannot deliver a late attachment into another conversation. */
10
+ export declare function useChatImages(identity: string, disabled: boolean, copy: Copy): {
11
+ images: ChatImage[];
12
+ preparing: boolean;
13
+ clear: () => void;
14
+ onDrop: (event: DragEvent) => void;
15
+ onDragOver: (event: DragEvent) => void;
16
+ onPaste: (event: ClipboardEvent) => void;
17
+ input: import("react").JSX.Element;
18
+ choose: () => void | undefined;
19
+ preview: import("react").JSX.Element;
20
+ };
@@ -0,0 +1,113 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { useEffect, useRef, useState } from "react";
4
+ import { CHAT_IMAGE_TYPES, imageByteLength, MAX_CHAT_IMAGES, MAX_CHAT_IMAGES_BYTES, } from "../chat-images.js";
5
+ import { prepareChatImage } from "./chat-image-file.js";
6
+ export function ChatImages({ images, copy, onRemove, }) {
7
+ if (!images?.length)
8
+ return null;
9
+ return (_jsx("div", { className: "ea-images", children: images.map((image, index) => (_jsxs("figure", { className: "ea-image", children: [image.data ? (
10
+ // biome-ignore lint/performance/noImgElement: bounded data URLs, no server image optimization needed
11
+ _jsx("img", { src: `data:${image.mediaType};base64,${image.data}`, alt: image.name, width: 80, height: 64 })) : (_jsx("span", { children: copy.imageOmitted })), _jsx("figcaption", { title: image.name, children: image.name }), onRemove ? (_jsx("button", { type: "button", "aria-label": `${copy.removeImage}: ${image.name}`, onClick: () => onRemove(index), children: "\u00D7" })) : null] }, `${index}:${image.name}`))) }));
12
+ }
13
+ /** Preparation is bounded and cannot deliver a late attachment into another conversation. */
14
+ export function useChatImages(identity, disabled, copy) {
15
+ const [images, setImages] = useState([]);
16
+ const [preparing, setPreparing] = useState(false);
17
+ const [error, setError] = useState("");
18
+ const inputRef = useRef(null);
19
+ const generation = useRef(0);
20
+ const busy = useRef(false);
21
+ const clear = () => {
22
+ generation.current++;
23
+ busy.current = false;
24
+ setImages([]);
25
+ setPreparing(false);
26
+ setError("");
27
+ };
28
+ // The identity is the trigger; pending preparation belongs to that conversation only.
29
+ // biome-ignore lint/correctness/useExhaustiveDependencies: reset when the conversation identity changes
30
+ useEffect(() => {
31
+ generation.current++;
32
+ busy.current = false;
33
+ setImages([]);
34
+ setPreparing(false);
35
+ setError("");
36
+ return () => {
37
+ generation.current++;
38
+ };
39
+ }, [identity]);
40
+ const add = async (files) => {
41
+ if (disabled || busy.current || !files.length)
42
+ return;
43
+ if (files.length + images.length > MAX_CHAT_IMAGES) {
44
+ setError(copy.imageLimit);
45
+ return;
46
+ }
47
+ busy.current = true;
48
+ setPreparing(true);
49
+ setError("");
50
+ const version = generation.current;
51
+ try {
52
+ const prepared = [];
53
+ for (const file of files)
54
+ prepared.push(await prepareChatImage(file));
55
+ if (generation.current !== version)
56
+ return;
57
+ if ([...images, ...prepared].reduce((sum, image) => sum + imageByteLength(image.data ?? ""), 0) > MAX_CHAT_IMAGES_BYTES)
58
+ throw new Error("image_too_large");
59
+ setImages([...images, ...prepared]);
60
+ }
61
+ catch (cause) {
62
+ if (generation.current === version)
63
+ setError(cause instanceof Error && cause.message === "image_too_large"
64
+ ? copy.imageTooLarge
65
+ : copy.imageInvalid);
66
+ }
67
+ finally {
68
+ if (generation.current === version) {
69
+ busy.current = false;
70
+ setPreparing(false);
71
+ }
72
+ }
73
+ };
74
+ const onDrop = (event) => {
75
+ if (!event.dataTransfer.types.includes("Files"))
76
+ return;
77
+ event.preventDefault();
78
+ void add(Array.from(event.dataTransfer.files));
79
+ };
80
+ const onDragOver = (event) => {
81
+ if (event.dataTransfer.types.includes("Files"))
82
+ event.preventDefault();
83
+ };
84
+ const onPaste = (event) => {
85
+ const files = Array.from(event.clipboardData.items)
86
+ .filter((item) => item.type.startsWith("image/"))
87
+ .flatMap((item) => {
88
+ const file = item.getAsFile();
89
+ return file ? [file] : [];
90
+ });
91
+ if (files.length) {
92
+ event.preventDefault();
93
+ void add(files);
94
+ }
95
+ };
96
+ return {
97
+ images,
98
+ preparing,
99
+ clear,
100
+ onDrop,
101
+ onDragOver,
102
+ onPaste,
103
+ input: (_jsx("input", { ref: inputRef, type: "file", hidden: true, multiple: true, accept: CHAT_IMAGE_TYPES.join(","), disabled: disabled || preparing, onChange: (event) => {
104
+ void add(Array.from(event.target.files ?? []));
105
+ event.target.value = "";
106
+ } })),
107
+ choose: () => inputRef.current?.click(),
108
+ preview: (_jsxs(_Fragment, { children: [_jsx(ChatImages, { images: images, copy: copy, onRemove: (index) => {
109
+ if (!preparing)
110
+ setImages((current) => current.filter((_, i) => i !== index));
111
+ } }), preparing ? (_jsx("p", { className: "ea-image-note", role: "status", children: copy.imagePreparing })) : null, error ? (_jsx("p", { className: "ea-image-note", role: "alert", children: error })) : null] })),
112
+ };
113
+ }
@@ -3,12 +3,14 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
3
3
  import { Button, ChevronIcon, useConfig, useLocale, useTranslation, XIcon } from "@payloadcms/ui";
4
4
  import { usePathname, useRouter } from "next/navigation";
5
5
  import { useEffect, useLayoutEffect, useRef, useState, } from "react";
6
+ import { boundChatImages } from "../chat-images.js";
6
7
  import { resolveChatLanguage } from "../runtime/language.js";
7
8
  import { parseAdminView } from "../view.js";
8
9
  import { assistantRequest, assistantStream } from "./assistant-api.js";
9
10
  import { useAssistantState, } from "./assistant-context.js";
10
11
  import { BatchPanel } from "./BatchPanel.js";
11
12
  import { releaseTakenBatches } from "./batch-link.js";
13
+ import { ChatImages, useChatImages } from "./ChatImages.js";
12
14
  import { COPY, translateAction, uiLanguage } from "./copy.js";
13
15
  import { ProposalReview } from "./ProposalReview.js";
14
16
  import { isFormProposal, proposalSteps, refineProposal, replaceLatestStep, } from "./proposal-chain.js";
@@ -29,6 +31,7 @@ export function EditorAssistantAside() {
29
31
  const view = parseAdminView(pathname ?? "", config.routes.admin);
30
32
  const visibleShortcuts = shortcuts.filter((shortcut) => shortcutMatches(shortcut, view));
31
33
  const [draft, setDraft] = useState("");
34
+ const attachments = useChatImages(`${pathname}:${locale.code}`, status === "streaming" || Boolean(batch), copy);
32
35
  // A ready prompt replaces the draft; it is only sent when the editor sends it.
33
36
  useEffect(() => {
34
37
  if (prefill !== null) {
@@ -195,9 +198,9 @@ export function EditorAssistantAside() {
195
198
  await decideRequest(step, "reject", undefined).catch(() => undefined);
196
199
  }
197
200
  };
198
- const send = async (text) => {
201
+ const send = async (text, images = []) => {
199
202
  const trimmed = text.trim();
200
- if (!trimmed || status === "streaming") {
203
+ if ((!trimmed && images.length === 0) || status === "streaming" || attachments.preparing) {
201
204
  return;
202
205
  }
203
206
  // A suggestion in the form stays open while the editor writes on: the next one refines it.
@@ -205,16 +208,26 @@ export function EditorAssistantAside() {
205
208
  const basis = formAdapter?.getData();
206
209
  stickToBottomRef.current = true;
207
210
  setDraft("");
211
+ if (images.length)
212
+ attachments.clear();
208
213
  setStep(null);
209
- const userMessage = { id: crypto.randomUUID(), role: "user", text: trimmed };
210
- setMessages((current) => [...current, userMessage]);
214
+ const userMessage = {
215
+ id: crypto.randomUUID(),
216
+ role: "user",
217
+ text: trimmed,
218
+ ...(images.length ? { images } : {}),
219
+ };
220
+ setMessages((current) => boundChatImages([...current, userMessage]));
211
221
  const controller = new AbortController();
212
222
  setAbortController(controller);
213
223
  setStatus("streaming");
214
224
  try {
215
- const history = [...messages, userMessage].map((message) => ({
225
+ const history = boundChatImages([...messages, userMessage].slice(-20)).map((message) => ({
216
226
  role: message.role,
217
- text: message.text,
227
+ text: message.images?.some((image) => !image.data)
228
+ ? `${message.text}\n[Older reference image bytes omitted from context; filenames: ${JSON.stringify(message.images.filter((image) => !image.data).map((image) => image.name))}]`
229
+ : message.text,
230
+ images: message.images?.filter((image) => image.data),
218
231
  }));
219
232
  // A form proposal arrives before the reply is written and goes into the form right away.
220
233
  // Proposals are applied one after another; a later one in the same turn replaces the earlier.
@@ -433,19 +446,22 @@ export function EditorAssistantAside() {
433
446
  };
434
447
  const onSubmit = (event) => {
435
448
  event.preventDefault();
436
- void send(draft);
449
+ void send(draft, attachments.images);
437
450
  };
438
451
  const onKeyDown = (event) => {
439
452
  if (event.key === "Enter" && !event.shiftKey) {
440
453
  event.preventDefault();
441
- void send(draft);
454
+ void send(draft, attachments.images);
442
455
  }
443
456
  };
444
457
  // Keep batch cards mounted while collapsed, so reopening never resets saved cards to tasks.
445
- return (_jsxs("aside", { className: "ea-aside", "aria-label": copy.assistant, hidden: !open, [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: () => {
458
+ return (_jsxs("aside", { className: "ea-aside", "aria-label": copy.assistant, hidden: !open, onDrop: attachments.onDrop, onDragOver: attachments.onDragOver, [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: () => {
459
+ attachments.clear();
460
+ resetConversation();
461
+ }, 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: () => {
446
462
  releaseTakenBatches();
447
463
  setBatch(null);
448
- } }, 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: () => {
464
+ } }, 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, _jsx(ChatImages, { images: message.images, copy: copy }), message.candidates?.length ? (_jsx("div", { className: "ea-actions", children: message.candidates.map((candidate) => (_jsx(Button, { buttonStyle: "pill", margin: false, onClick: () => {
449
465
  router.push(candidate.href);
450
466
  }, 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, pendingApproval.lines?.length ? (_jsx("div", { className: "ea-approval__review", children: _jsx(ProposalReview, { lines: pendingApproval.lines, language: uiLanguage(i18n.language) }) })) : (_jsx("ul", { className: "ea-approval__diff", children: pendingApproval.summary.split("\n").map((line, index) => line.startsWith(" ") ? (
451
467
  // biome-ignore lint/suspicious/noArrayIndexKey: lines are static for one approval
@@ -455,9 +471,10 @@ export function EditorAssistantAside() {
455
471
  void send(translate.prompt);
456
472
  }, size: "small", children: translate.label })) : null, visibleShortcuts.map((shortcut) => (_jsx(Button, { buttonStyle: "pill", disabled: status === "streaming", margin: false, onClick: () => {
457
473
  void send(shortcut.prompt);
458
- }, 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" ? (
459
- // While the assistant works, Send becomes Stop, where the editor's eye already is.
460
- _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 })) })] })] })] }))] }));
474
+ }, 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: [attachments.input, attachments.preview, _jsx("textarea", { disabled: status === "streaming", placeholder: copy.placeholder, rows: 2, value: draft, onChange: (event) => setDraft(event.target.value), onKeyDown: onKeyDown, onPaste: attachments.onPaste }), _jsxs("div", { className: "ea-composer__bar", children: [_jsx(Button, { buttonStyle: "subtle", type: "button", margin: false, size: "small", disabled: status === "streaming" || attachments.preparing, onClick: attachments.choose, children: copy.attachImage }), status === "streaming" ? (
475
+ // While the assistant works, Send becomes Stop, where the editor's eye already is.
476
+ _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: attachments.preparing ||
477
+ (draft.trim() === "" && attachments.images.length === 0), margin: false, size: "medium", type: "submit", children: copy.send }))] })] })] })] }))] }));
461
478
  }
462
479
  /** "Page · Home", with the locale when the project has more than one. */
463
480
  function contextLine(bridge, config) {
@@ -6,6 +6,6 @@ type Props = {
6
6
  edits?: Record<string, string>;
7
7
  onEdit?: (path: string, value: string) => void;
8
8
  };
9
- /** One review layout for batch proposals and changes in the open form. */
9
+ /** One review layout for batches, new drafts and changes in the open form. */
10
10
  export declare function ProposalReview(props: Props): import("react").JSX.Element;
11
11
  export {};
@@ -19,7 +19,7 @@ const COPY = {
19
19
  block: "Block",
20
20
  },
21
21
  };
22
- /** One review layout for batch proposals and changes in the open form. */
22
+ /** One review layout for batches, new drafts and changes in the open form. */
23
23
  export function ProposalReview(props) {
24
24
  return (_jsx("div", { className: "ea-review", children: groupProposalLines(props.lines).map((node) => (_jsx(ReviewNodeItem, { node: node, props: props }, node.path))) }));
25
25
  }
@@ -40,7 +40,11 @@ function FieldReview({ lines, props }) {
40
40
  const copy = COPY[props.language];
41
41
  const label = line.label ?? line.edit?.label;
42
42
  const rich = line.richText;
43
- const added = rich ? !hasRichText(rich.before) : line.edit ? !line.edit.before : false;
43
+ const added = rich
44
+ ? !hasRichText(rich.before)
45
+ : line.edit
46
+ ? !line.edit.before
47
+ : line.value !== undefined;
44
48
  const value = props.edits?.[line.path] ?? line.edit?.value ?? "";
45
- return (_jsxs("div", { className: "ea-review__field", children: [label ? (_jsxs("div", { className: "ea-review__field-head", children: [_jsx("label", { htmlFor: line.edit && props.onEdit ? id : undefined, children: label }), added ? _jsx("span", { className: "ea-review__new", children: copy.added }) : null] })) : null, rich ? (_jsxs(_Fragment, { children: [hasRichText(rich.before) ? (_jsxs("details", { className: "ea-review__current", children: [_jsx("summary", { children: copy.current }), _jsx(RichTextPreview, { value: rich.before, language: props.language })] })) : null, _jsx("div", { className: "ea-review__preview", role: "region", "aria-label": `${label}: ${copy.suggested}`, children: hasRichText(rich.after) ? (_jsx(RichTextPreview, { value: rich.after, language: props.language })) : (_jsx("span", { children: copy.empty })) })] })) : line.edit ? (_jsxs(_Fragment, { children: [line.edit.before ? (_jsxs("details", { className: "ea-review__current", children: [_jsx("summary", { children: copy.current }), _jsx("p", { children: line.edit.before })] })) : null, props.onEdit ? (_jsx("textarea", { id: id, "aria-label": [line.context, label].filter(Boolean).join(" › "), disabled: !props.editable, onChange: (event) => props.onEdit?.(line.path, event.target.value), rows: 2, value: value })) : (_jsx("p", { className: "ea-review__value", children: value || copy.empty })), props.onEdit ? _jsx("span", { className: "ea-review__count", children: [...value].length }) : null] })) : (_jsx("div", { className: "ea-review__change", children: lines.map((line, index) => (_jsx("p", { children: line.text }, `${line.path}:${index}`))) }))] }));
49
+ return (_jsxs("div", { className: "ea-review__field", children: [label ? (_jsxs("div", { className: "ea-review__field-head", children: [_jsx("label", { htmlFor: line.edit && props.onEdit ? id : undefined, children: label }), added ? _jsx("span", { className: "ea-review__new", children: copy.added }) : null] })) : null, rich ? (_jsxs(_Fragment, { children: [hasRichText(rich.before) ? (_jsxs("details", { className: "ea-review__current", children: [_jsx("summary", { children: copy.current }), _jsx(RichTextPreview, { value: rich.before, language: props.language })] })) : null, _jsx("div", { className: "ea-review__preview", role: "region", "aria-label": `${label}: ${copy.suggested}`, children: hasRichText(rich.after) ? (_jsx(RichTextPreview, { value: rich.after, language: props.language })) : (_jsx("span", { children: copy.empty })) })] })) : line.edit ? (_jsxs(_Fragment, { children: [line.edit.before ? (_jsxs("details", { className: "ea-review__current", children: [_jsx("summary", { children: copy.current }), _jsx("p", { children: line.edit.before })] })) : null, props.onEdit ? (_jsx("textarea", { id: id, "aria-label": [line.context, label].filter(Boolean).join(" › "), disabled: !props.editable, onChange: (event) => props.onEdit?.(line.path, event.target.value), rows: 2, value: value })) : (_jsx("p", { className: "ea-review__value", children: value || copy.empty })), props.onEdit ? _jsx("span", { className: "ea-review__count", children: [...value].length }) : null] })) : line.value !== undefined ? (_jsx("p", { className: "ea-review__value", children: line.value || copy.empty })) : (_jsx("div", { className: "ea-review__change", children: lines.map((line, index) => (_jsx("p", { children: line.text }, `${line.path}:${index}`))) }))] }));
46
50
  }
@@ -1,5 +1,6 @@
1
1
  import { type ReactNode } from "react";
2
2
  import type { BatchScope, BatchTarget } from "../batch/targets.ts";
3
+ import type { ChatImage } from "../chat-images.ts";
3
4
  import type { ProposalLine } from "../domain/propose.ts";
4
5
  import type { AssistantShortcut } from "../types.ts";
5
6
  import type { DomSelection } from "./selection-dom.ts";
@@ -65,6 +66,7 @@ export type AssistantMessage = {
65
66
  href: string;
66
67
  label: string;
67
68
  }>;
69
+ images?: ChatImage[];
68
70
  };
69
71
  type AssistantContextValue = {
70
72
  /** Whether the panel is shown. Closed at first; the browser remembers the choice. */
@@ -0,0 +1,3 @@
1
+ import { type ChatImage } from "../chat-images.ts";
2
+ /** Decode before sending; resize large screenshots and keep all network bytes bounded. */
3
+ export declare function prepareChatImage(file: File): Promise<ChatImage>;
@@ -0,0 +1,48 @@
1
+ import { CHAT_IMAGE_TYPES, MAX_CHAT_IMAGE_BYTES } from "../chat-images.js";
2
+ /** Decode before sending; resize large screenshots and keep all network bytes bounded. */
3
+ export async function prepareChatImage(file) {
4
+ if (!CHAT_IMAGE_TYPES.includes(file.type) ||
5
+ file.size > 10 * 1024 * 1024) {
6
+ throw new Error("unsupported_image");
7
+ }
8
+ const bitmap = await createImageBitmap(file);
9
+ try {
10
+ if (file.size <= MAX_CHAT_IMAGE_BYTES && Math.max(bitmap.width, bitmap.height) <= 2048) {
11
+ return {
12
+ name: file.name.slice(0, 200),
13
+ mediaType: file.type,
14
+ data: await base64(file),
15
+ };
16
+ }
17
+ const scale = Math.min(1, 2048 / Math.max(bitmap.width, bitmap.height));
18
+ const canvas = document.createElement("canvas");
19
+ canvas.width = Math.max(1, Math.round(bitmap.width * scale));
20
+ canvas.height = Math.max(1, Math.round(bitmap.height * scale));
21
+ const context = canvas.getContext("2d");
22
+ if (!context)
23
+ throw new Error("unsupported_image");
24
+ context.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
25
+ for (const quality of [0.9, 0.75, 0.55]) {
26
+ const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/webp", quality));
27
+ if (blob && blob.size <= MAX_CHAT_IMAGE_BYTES) {
28
+ return {
29
+ name: file.name.slice(0, 200),
30
+ mediaType: blob.type,
31
+ data: await base64(blob),
32
+ };
33
+ }
34
+ }
35
+ throw new Error("image_too_large");
36
+ }
37
+ finally {
38
+ bitmap.close();
39
+ }
40
+ }
41
+ function base64(blob) {
42
+ return new Promise((resolve, reject) => {
43
+ const reader = new FileReader();
44
+ reader.onload = () => resolve(String(reader.result).split(",")[1]);
45
+ reader.onerror = () => reject(new Error("unsupported_image"));
46
+ reader.readAsDataURL(blob);
47
+ });
48
+ }
@@ -1,3 +1,4 @@
1
+ import { boundChatImages, CHAT_IMAGE_TYPES, isChatImage, MAX_CHAT_IMAGES, } from "../chat-images.js";
1
2
  /**
2
3
  * Conversations are kept in the browser, one per user and document, plus one for everything outside
3
4
  * a document (lists, dashboard). A document that is not saved yet has none of its own: its thread is
@@ -62,8 +63,21 @@ export function writeConversation(key, messages) {
62
63
  window.localStorage.removeItem(key.key);
63
64
  return;
64
65
  }
65
- const stored = { savedAt: Date.now(), messages: messages.slice(-MAX_MESSAGES) };
66
- window.localStorage.setItem(key.key, JSON.stringify(stored));
66
+ const stored = {
67
+ savedAt: Date.now(),
68
+ messages: boundChatImages(messages.slice(-MAX_MESSAGES)),
69
+ };
70
+ try {
71
+ window.localStorage.setItem(key.key, JSON.stringify(stored));
72
+ }
73
+ catch {
74
+ // Preserve text when other conversations have filled the browser's storage quota.
75
+ stored.messages = stored.messages.map((message) => ({
76
+ ...message,
77
+ images: message.images?.map(({ data: _data, ...metadata }) => metadata),
78
+ }));
79
+ window.localStorage.setItem(key.key, JSON.stringify(stored));
80
+ }
67
81
  }
68
82
  catch {
69
83
  // Not remembered (full or blocked storage); the thread still works for this page.
@@ -76,14 +90,20 @@ export function parseStored(raw, now) {
76
90
  return [];
77
91
  }
78
92
  return Array.isArray(stored.messages)
79
- ? stored.messages.filter(isMessage).map((message) => ({
93
+ ? boundChatImages(stored.messages
94
+ .filter(isMessage)
95
+ .slice(-MAX_MESSAGES)
96
+ .map((message) => ({
80
97
  id: message.id,
81
98
  role: message.role,
82
99
  text: message.text,
100
+ ...(message.role === "user" && Array.isArray(message.images)
101
+ ? { images: message.images.filter(isStoredImage).slice(0, MAX_CHAT_IMAGES) }
102
+ : {}),
83
103
  candidates: Array.isArray(message.candidates)
84
104
  ? message.candidates.filter(isCandidate)
85
105
  : undefined,
86
- }))
106
+ })))
87
107
  : [];
88
108
  }
89
109
  catch {
@@ -100,3 +120,13 @@ function isCandidate(value) {
100
120
  const candidate = value;
101
121
  return typeof candidate?.href === "string" && typeof candidate.label === "string";
102
122
  }
123
+ function isStoredImage(value) {
124
+ if (isChatImage(value))
125
+ return true;
126
+ const image = value;
127
+ return Boolean(image &&
128
+ image.data === undefined &&
129
+ typeof image.name === "string" &&
130
+ image.name.length <= 200 &&
131
+ CHAT_IMAGE_TYPES.includes(image.mediaType));
132
+ }
@@ -12,6 +12,13 @@ export declare const COPY: {
12
12
  readonly collapseLabel: "Collapse assistant";
13
13
  readonly placeholder: "Message the assistant";
14
14
  readonly send: "Send";
15
+ readonly attachImage: "Add images";
16
+ readonly removeImage: "Remove image";
17
+ readonly imageOmitted: "Older image omitted";
18
+ readonly imagePreparing: "Preparing images…";
19
+ readonly imageLimit: "Attach up to three images per message.";
20
+ readonly imageTooLarge: "These images are too large. Try fewer or smaller images.";
21
+ readonly imageInvalid: "Choose a PNG, JPEG, WebP or GIF image up to 10 MB.";
15
22
  readonly stop: "Stop";
16
23
  readonly working: "Working…";
17
24
  readonly steps: {
@@ -75,6 +82,13 @@ export declare const COPY: {
75
82
  readonly collapseLabel: "Fäll ihop assistenten";
76
83
  readonly placeholder: "Skriv till assistenten";
77
84
  readonly send: "Skicka";
85
+ readonly attachImage: "Lägg till bilder";
86
+ readonly removeImage: "Ta bort bild";
87
+ readonly imageOmitted: "Äldre bild utelämnad";
88
+ readonly imagePreparing: "Förbereder bilder…";
89
+ readonly imageLimit: "Bifoga högst tre bilder per meddelande.";
90
+ readonly imageTooLarge: "Bilderna är för stora. Prova färre eller mindre bilder.";
91
+ readonly imageInvalid: "Välj en PNG-, JPEG-, WebP- eller GIF-bild på högst 10 MB.";
78
92
  readonly stop: "Stoppa";
79
93
  readonly working: "Arbetar…";
80
94
  readonly steps: {
@@ -13,6 +13,13 @@ export const COPY = {
13
13
  collapseLabel: "Collapse assistant",
14
14
  placeholder: "Message the assistant",
15
15
  send: "Send",
16
+ attachImage: "Add images",
17
+ removeImage: "Remove image",
18
+ imageOmitted: "Older image omitted",
19
+ imagePreparing: "Preparing images…",
20
+ imageLimit: "Attach up to three images per message.",
21
+ imageTooLarge: "These images are too large. Try fewer or smaller images.",
22
+ imageInvalid: "Choose a PNG, JPEG, WebP or GIF image up to 10 MB.",
16
23
  stop: "Stop",
17
24
  working: "Working…",
18
25
  steps: {
@@ -76,6 +83,13 @@ export const COPY = {
76
83
  collapseLabel: "Fäll ihop assistenten",
77
84
  placeholder: "Skriv till assistenten",
78
85
  send: "Skicka",
86
+ attachImage: "Lägg till bilder",
87
+ removeImage: "Ta bort bild",
88
+ imageOmitted: "Äldre bild utelämnad",
89
+ imagePreparing: "Förbereder bilder…",
90
+ imageLimit: "Bifoga högst tre bilder per meddelande.",
91
+ imageTooLarge: "Bilderna är för stora. Prova färre eller mindre bilder.",
92
+ imageInvalid: "Välj en PNG-, JPEG-, WebP- eller GIF-bild på högst 10 MB.",
79
93
  stop: "Stoppa",
80
94
  working: "Arbetar…",
81
95
  steps: {