@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.
- package/CHANGELOG.md +26 -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.js +52 -13
- 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/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 +4 -1
- 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 +33 -6
- 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/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 +49 -1
- 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 = {
|
|
210
|
-
|
|
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.
|
|
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: () =>
|
|
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:
|
|
459
|
-
|
|
460
|
-
|
|
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
|
|
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. */
|
|
@@ -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
|
+
}
|
|
@@ -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: {
|