@simmalugnt-se/payload-editor-assistant 0.12.1 → 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.
- package/CHANGELOG.md +30 -0
- package/README.md +37 -0
- package/dist/chat-images.d.ts +20 -0
- package/dist/chat-images.js +61 -0
- package/dist/components/ChatImages.d.ts +20 -0
- package/dist/components/ChatImages.js +113 -0
- package/dist/components/EditorAssistantAside.d.ts +1 -1
- package/dist/components/EditorAssistantAside.js +31 -17
- package/dist/components/EditorAssistantDocumentBridge.js +1 -1
- package/dist/components/ProposalReview.d.ts +1 -1
- package/dist/components/ProposalReview.js +7 -3
- package/dist/components/assistant-context.d.ts +2 -0
- package/dist/components/batch-link.js +2 -1
- package/dist/components/chat-image-file.d.ts +3 -0
- package/dist/components/chat-image-file.js +48 -0
- package/dist/components/conversation-store.js +34 -4
- package/dist/components/copy.d.ts +14 -0
- package/dist/components/copy.js +14 -0
- package/dist/domain/batch.js +10 -3
- package/dist/domain/decide.js +9 -0
- package/dist/domain/new-block-review.d.ts +7 -0
- package/dist/domain/new-block-review.js +97 -0
- package/dist/domain/propose.d.ts +2 -0
- package/dist/domain/propose.js +39 -7
- package/dist/endpoints/chat.d.ts +3 -0
- package/dist/endpoints/chat.js +31 -2
- package/dist/endpoints/decide.js +27 -6
- package/dist/form/batch-revision.d.ts +6 -0
- package/dist/form/batch-revision.js +39 -0
- package/dist/form/diff.js +3 -2
- package/dist/form/relation-shape.d.ts +10 -5
- package/dist/form/relation-shape.js +104 -28
- package/dist/runtime/agent.d.ts +3 -1
- package/dist/runtime/agent.js +100 -8
- package/dist/runtime/context.d.ts +10 -8
- package/dist/runtime/context.js +16 -10
- package/dist/runtime/debug.d.ts +10 -0
- package/dist/runtime/debug.js +132 -0
- package/dist/runtime/system-prompt.js +4 -2
- package/dist/schema/fields.d.ts +6 -0
- package/dist/schema/fields.js +20 -2
- package/dist/styles/assistant.css +51 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,35 @@
|
|
|
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
|
+
|
|
24
|
+
## 0.12.2
|
|
25
|
+
|
|
26
|
+
- Batch links remove their parameters from Next’s router state as well as browser history, so
|
|
27
|
+
refreshing cannot reopen a finished batch. Field prompts use the same URL integration.
|
|
28
|
+
- Collapsing the assistant preserves batch progress instead of resetting completed cards.
|
|
29
|
+
- Batch revision checks treat schema-defined empty values consistently, so the first draft saved
|
|
30
|
+
in one language does not falsely invalidate another language’s proposal on Postgres. Real content
|
|
31
|
+
changes anywhere in the allowlisted form still refuse the save.
|
|
32
|
+
|
|
3
33
|
## 0.12.1
|
|
4
34
|
|
|
5
35
|
- The review lists groups in the schema's order (a group's own position, blocks and rows by their
|
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
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare function EditorAssistantAside(): import("react").JSX.Element
|
|
1
|
+
export declare function EditorAssistantAside(): import("react").JSX.Element;
|
|
@@ -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 = {
|
|
210
|
-
|
|
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.
|
|
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,22 +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
|
-
//
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
457
|
+
// Keep batch cards mounted while collapsed, so reopening never resets saved cards to tasks.
|
|
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: () => {
|
|
449
462
|
releaseTakenBatches();
|
|
450
463
|
setBatch(null);
|
|
451
|
-
} }, 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: () => {
|
|
452
465
|
router.push(candidate.href);
|
|
453
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(" ") ? (
|
|
454
467
|
// biome-ignore lint/suspicious/noArrayIndexKey: lines are static for one approval
|
|
@@ -458,9 +471,10 @@ export function EditorAssistantAside() {
|
|
|
458
471
|
void send(translate.prompt);
|
|
459
472
|
}, size: "small", children: translate.label })) : null, visibleShortcuts.map((shortcut) => (_jsx(Button, { buttonStyle: "pill", disabled: status === "streaming", margin: false, onClick: () => {
|
|
460
473
|
void send(shortcut.prompt);
|
|
461
|
-
}, 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 }),
|
|
462
|
-
|
|
463
|
-
|
|
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 }))] })] })] })] }))] }));
|
|
464
478
|
}
|
|
465
479
|
/** "Page · Home", with the locale when the project has more than one. */
|
|
466
480
|
function contextLine(bridge, config) {
|
|
@@ -40,7 +40,7 @@ export function EditorAssistantDocumentBridge() {
|
|
|
40
40
|
return;
|
|
41
41
|
}
|
|
42
42
|
url.searchParams.delete(ASK_PARAM);
|
|
43
|
-
window.history.replaceState(
|
|
43
|
+
window.history.replaceState(null, "", url);
|
|
44
44
|
if (prompt) {
|
|
45
45
|
setPrefill(prompt);
|
|
46
46
|
setOpen(true);
|
|
@@ -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
|
|
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
|
|
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
|
|
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. */
|
|
@@ -32,7 +32,8 @@ export function useBatchLink() {
|
|
|
32
32
|
for (const name of [BATCH_PARAM, BATCH_SCOPE_PARAM, ASK_PARAM]) {
|
|
33
33
|
url.searchParams.delete(name);
|
|
34
34
|
}
|
|
35
|
-
|
|
35
|
+
// Next copies its internal state itself; passing it back skips the router URL update.
|
|
36
|
+
window.history.replaceState(null, "", url.toString());
|
|
36
37
|
if (repeated) {
|
|
37
38
|
// The batch is still in the panel, which the editor closed: show it again.
|
|
38
39
|
setOpen(true);
|
|
@@ -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 = {
|
|
66
|
-
|
|
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
|
|
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
|
+
}
|