@simmalugnt-se/payload-editor-assistant 0.12.2 → 0.13.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 (38) hide show
  1. package/CHANGELOG.md +26 -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 +52 -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 +49 -1
  38. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.13.1
4
+
5
+ - The chat composer starts at three lines and grows with typed or pasted text up to eight lines
6
+ (or 30% of the viewport), then scrolls. It also resizes after prefilled prompts and panel reopening.
7
+
8
+ ## 0.13.0
9
+
10
+ - Attach reference images to chat with file selection, drag-and-drop or clipboard paste. Show
11
+ removable previews, resize large images, bound recent image context, and preserve attachments
12
+ with the browser conversation without uploading them to the CMS.
13
+ - Add opt-in server-only `DEBUG_ASSISTANT=true` traces for conversations, context, tool calls and
14
+ results, provider-exposed reasoning, usage, replies, decisions and errors. Optionally persist
15
+ complete local JSONL with `DEBUG_ASSISTANT_LOG_DIR`; redact credential fields/nonces and represent
16
+ images by metadata. Chunk large server records and report server log truncation explicitly.
17
+
18
+ - The model receives the complete schema of nested groups, array rows and blocks, including
19
+ relationship formats, field defaults and conditional requirements. Structured internal links
20
+ use document references rather than rich text's `doc:` notation in a URL field.
21
+ - Relationship checks reach fields inside changed containers and refuse missing required document
22
+ references using the host's field conditions. A single-collection polymorphic reference still
23
+ requires `{ relationTo, value }`; proposals and approvals repeat these checks. Selected documents
24
+ are named in the review.
25
+ - New blocks in draft creation and form proposals use the same collapsible review as translations,
26
+ with separate schema-labelled fields and full formatted rich text. The preview follows the final
27
+ block order and values after all proposed operations, without truncating the block's content.
28
+
3
29
  ## 0.12.2
4
30
 
5
31
  - 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) {
@@ -37,6 +40,28 @@ export function EditorAssistantAside() {
37
40
  }
38
41
  }, [prefill, setPrefill]);
39
42
  const threadRef = useRef(null);
43
+ const composerRef = useRef(null);
44
+ // Grow on typing/prefill and remeasure when the panel reopens or its width changes.
45
+ // biome-ignore lint/correctness/useExhaustiveDependencies: draft changes and returning from a batch require remeasuring the DOM.
46
+ useLayoutEffect(() => {
47
+ const textarea = composerRef.current;
48
+ if (!textarea || !open)
49
+ return;
50
+ const resize = () => {
51
+ textarea.style.height = "auto";
52
+ textarea.style.height = `${textarea.scrollHeight}px`;
53
+ };
54
+ resize();
55
+ let width = textarea.clientWidth;
56
+ const observer = new ResizeObserver(() => {
57
+ if (textarea.clientWidth === width)
58
+ return;
59
+ width = textarea.clientWidth;
60
+ resize();
61
+ });
62
+ observer.observe(textarea);
63
+ return () => observer.disconnect();
64
+ }, [draft, open, batch]);
40
65
  /** Whether the editor typed in the form while a preview was in it; undo would discard that. */
41
66
  const editedSinceRef = useRef(false);
42
67
  const [confirmUndo, setConfirmUndo] = useState(false);
@@ -195,9 +220,9 @@ export function EditorAssistantAside() {
195
220
  await decideRequest(step, "reject", undefined).catch(() => undefined);
196
221
  }
197
222
  };
198
- const send = async (text) => {
223
+ const send = async (text, images = []) => {
199
224
  const trimmed = text.trim();
200
- if (!trimmed || status === "streaming") {
225
+ if ((!trimmed && images.length === 0) || status === "streaming" || attachments.preparing) {
201
226
  return;
202
227
  }
203
228
  // A suggestion in the form stays open while the editor writes on: the next one refines it.
@@ -205,16 +230,26 @@ export function EditorAssistantAside() {
205
230
  const basis = formAdapter?.getData();
206
231
  stickToBottomRef.current = true;
207
232
  setDraft("");
233
+ if (images.length)
234
+ attachments.clear();
208
235
  setStep(null);
209
- const userMessage = { id: crypto.randomUUID(), role: "user", text: trimmed };
210
- setMessages((current) => [...current, userMessage]);
236
+ const userMessage = {
237
+ id: crypto.randomUUID(),
238
+ role: "user",
239
+ text: trimmed,
240
+ ...(images.length ? { images } : {}),
241
+ };
242
+ setMessages((current) => boundChatImages([...current, userMessage]));
211
243
  const controller = new AbortController();
212
244
  setAbortController(controller);
213
245
  setStatus("streaming");
214
246
  try {
215
- const history = [...messages, userMessage].map((message) => ({
247
+ const history = boundChatImages([...messages, userMessage].slice(-20)).map((message) => ({
216
248
  role: message.role,
217
- text: message.text,
249
+ text: message.images?.some((image) => !image.data)
250
+ ? `${message.text}\n[Older reference image bytes omitted from context; filenames: ${JSON.stringify(message.images.filter((image) => !image.data).map((image) => image.name))}]`
251
+ : message.text,
252
+ images: message.images?.filter((image) => image.data),
218
253
  }));
219
254
  // A form proposal arrives before the reply is written and goes into the form right away.
220
255
  // Proposals are applied one after another; a later one in the same turn replaces the earlier.
@@ -433,19 +468,22 @@ export function EditorAssistantAside() {
433
468
  };
434
469
  const onSubmit = (event) => {
435
470
  event.preventDefault();
436
- void send(draft);
471
+ void send(draft, attachments.images);
437
472
  };
438
473
  const onKeyDown = (event) => {
439
474
  if (event.key === "Enter" && !event.shiftKey) {
440
475
  event.preventDefault();
441
- void send(draft);
476
+ void send(draft, attachments.images);
442
477
  }
443
478
  };
444
479
  // 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: () => {
480
+ 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: () => {
481
+ attachments.clear();
482
+ resetConversation();
483
+ }, 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
484
  releaseTakenBatches();
447
485
  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: () => {
486
+ } }, 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
487
  router.push(candidate.href);
450
488
  }, 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
489
  // biome-ignore lint/suspicious/noArrayIndexKey: lines are static for one approval
@@ -455,9 +493,10 @@ export function EditorAssistantAside() {
455
493
  void send(translate.prompt);
456
494
  }, size: "small", children: translate.label })) : null, visibleShortcuts.map((shortcut) => (_jsx(Button, { buttonStyle: "pill", disabled: status === "streaming", margin: false, onClick: () => {
457
495
  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 })) })] })] })] }))] }));
496
+ }, 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", { ref: composerRef, disabled: status === "streaming", placeholder: copy.placeholder, rows: 3, 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" ? (
497
+ // While the assistant works, Send becomes Stop, where the editor's eye already is.
498
+ _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 ||
499
+ (draft.trim() === "" && attachments.images.length === 0), margin: false, size: "medium", type: "submit", children: copy.send }))] })] })] })] }))] }));
461
500
  }
462
501
  /** "Page · Home", with the locale when the project has more than one. */
463
502
  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: {