@simmalugnt-se/payload-editor-assistant 0.8.0 → 0.9.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 +19 -0
- package/README.md +11 -0
- package/dist/components/EditorAssistantAside.js +15 -5
- package/dist/components/ReplyText.d.ts +4 -0
- package/dist/components/ReplyText.js +26 -0
- package/dist/components/copy.d.ts +8 -0
- package/dist/components/copy.js +18 -0
- package/dist/components/reply-markdown.d.ts +26 -0
- package/dist/components/reply-markdown.js +142 -0
- package/dist/components/translation-offer.d.ts +16 -0
- package/dist/components/translation-offer.js +38 -0
- package/dist/domain/content.js +3 -0
- package/dist/endpoints/translation.d.ts +11 -0
- package/dist/endpoints/translation.js +56 -0
- package/dist/form/empty.d.ts +0 -1
- package/dist/form/empty.js +0 -20
- package/dist/form/translation.d.ts +14 -0
- package/dist/form/translation.js +81 -0
- package/dist/plugin.js +2 -0
- package/dist/runtime/context.d.ts +7 -0
- package/dist/runtime/context.js +25 -7
- package/dist/runtime/system-prompt.js +31 -21
- package/dist/runtime/translation-source.d.ts +10 -0
- package/dist/runtime/translation-source.js +12 -0
- package/dist/runtime/voice.js +20 -7
- package/dist/schema/discover.d.ts +2 -0
- package/dist/schema/discover.js +9 -0
- package/dist/styles/assistant.css +30 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.0
|
|
4
|
+
|
|
5
|
+
- Translation: on a document with localized text that is empty in the open language but filled in
|
|
6
|
+
the default one, the panel offers "Translate from English" ("Översätt från engelska"). The
|
|
7
|
+
assistant fills every such field in one proposal, rich text with its formatting and links, and
|
|
8
|
+
leaves translated fields alone unless asked to translate them again. The model is told which
|
|
9
|
+
fields to fill and which are translated already, instead of an 80-character preview of each
|
|
10
|
+
source, and replies in the editor's language rather than the one it translates into.
|
|
11
|
+
- New endpoint `GET /api/editor-assistant/translation` says whether the open document has fields
|
|
12
|
+
to translate, and from which language.
|
|
13
|
+
- Other language versions are read without fallback, as Admin reads them, and a language with no
|
|
14
|
+
text of its own is left out of the model's context.
|
|
15
|
+
|
|
16
|
+
- Replies show their Markdown as formatting instead of asterisks: paragraphs, bullet and numbered
|
|
17
|
+
lists, bold, italic and code. Headings show as bold lines, and links as their text, with a web
|
|
18
|
+
address written out after it; the panel makes no link the model wrote clickable.
|
|
19
|
+
- A reply keeps its line breaks. Before, filtering the model's reply joined it into one line, so a
|
|
20
|
+
list ran together.
|
|
21
|
+
|
|
3
22
|
## 0.8.0
|
|
4
23
|
|
|
5
24
|
- Rich text keeps its formatting. The model reads Lexical as Markdown blocks, one per paragraph,
|
package/README.md
CHANGED
|
@@ -127,6 +127,17 @@ point at a collection the field's `LinkFeature` allows and that the editor can r
|
|
|
127
127
|
cannot express, such as an embedded image, are kept as they are. Without the package, rich text is
|
|
128
128
|
written as plain paragraphs as before.
|
|
129
129
|
|
|
130
|
+
## Translation
|
|
131
|
+
|
|
132
|
+
In a localized project, the assistant translates a document from the default language into the one
|
|
133
|
+
open in Admin. On a document with localized text that is empty here but filled in the default
|
|
134
|
+
language, the panel offers a button ("Translate from English"). The editor can also ask in the
|
|
135
|
+
chat, or start from content health's work list. It fills every empty localized field in one
|
|
136
|
+
proposal, rich text as Markdown blocks with headings, lists, bold, italic and links kept, and leaves
|
|
137
|
+
fields that already have text alone unless asked to translate them again. Fields shared between the
|
|
138
|
+
languages (blocks, images, slugs) never change. The button asks `GET /api/editor-assistant/translation`
|
|
139
|
+
whether there is anything to translate, with the editor's access.
|
|
140
|
+
|
|
130
141
|
## Keep or undo
|
|
131
142
|
|
|
132
143
|
A proposed form change goes straight into the open form, so the fields and Live Preview show it
|
|
@@ -7,9 +7,11 @@ import { resolveChatLanguage } from "../runtime/language.js";
|
|
|
7
7
|
import { parseAdminView } from "../view.js";
|
|
8
8
|
import { assistantRequest, assistantStream } from "./assistant-api.js";
|
|
9
9
|
import { useAssistantState, } from "./assistant-context.js";
|
|
10
|
-
import { COPY, uiLanguage } from "./copy.js";
|
|
10
|
+
import { COPY, translateAction, uiLanguage } from "./copy.js";
|
|
11
11
|
import { isFormProposal, proposalSteps, refineProposal, replaceLatestStep, } from "./proposal-chain.js";
|
|
12
|
+
import { ReplyText } from "./ReplyText.js";
|
|
12
13
|
import { KEEP_SELECTION_ATTR } from "./selection-dom.js";
|
|
14
|
+
import { useTranslationOffer } from "./translation-offer.js";
|
|
13
15
|
export function EditorAssistantAside() {
|
|
14
16
|
const { open, setOpen, status, messages, shortcuts, collections, bridge, formAdapter, pendingApproval, selection, setSelection, prefill, setPrefill, resetConversation, setStatus, setMessages, setPendingApproval, stop, setAbortController, } = useAssistantState();
|
|
15
17
|
const { config, getEntityConfig } = useConfig();
|
|
@@ -39,6 +41,12 @@ export function EditorAssistantAside() {
|
|
|
39
41
|
const [step, setStep] = useState(null);
|
|
40
42
|
const stickToBottomRef = useRef(true);
|
|
41
43
|
const apiBase = config.routes.api;
|
|
44
|
+
// The offer compares saved versions, so it waits while the form has unsaved changes and asks
|
|
45
|
+
// again once they are saved or after a message: a kept translation takes the button away.
|
|
46
|
+
const offer = useTranslationOffer(apiBase, bridge ?? undefined, `${messages.length}:${bridge?.modified ? 1 : 0}`);
|
|
47
|
+
const translate = offer?.source && offer.empty > 0 && !pendingApproval && !bridge?.modified
|
|
48
|
+
? translateAction(uiLanguage(i18n.language), offer.source)
|
|
49
|
+
: undefined;
|
|
42
50
|
// Scroll when the thread grows. The values are triggers, not reads.
|
|
43
51
|
// biome-ignore lint/correctness/useExhaustiveDependencies: pin to bottom after new messages, working state, or approval cards
|
|
44
52
|
useLayoutEffect(() => {
|
|
@@ -434,15 +442,17 @@ export function EditorAssistantAside() {
|
|
|
434
442
|
if (!open) {
|
|
435
443
|
return null;
|
|
436
444
|
}
|
|
437
|
-
return (_jsxs("aside", { className: "ea-aside", "aria-label": copy.assistant, [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] }), (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 })] }), _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.text, message.candidates?.length ? (_jsx("div", { className: "ea-actions", children: message.candidates.map((candidate) => (_jsx(Button, { buttonStyle: "pill", margin: false, onClick: () => {
|
|
445
|
+
return (_jsxs("aside", { className: "ea-aside", "aria-label": copy.assistant, [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] }), (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 })] }), _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: () => {
|
|
438
446
|
router.push(candidate.href);
|
|
439
447
|
}, 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, _jsx("ul", { className: "ea-approval__diff", children: pendingApproval.summary.split("\n").map((line, index) => line.startsWith(" ") ? (
|
|
440
448
|
// biome-ignore lint/suspicious/noArrayIndexKey: lines are static for one approval
|
|
441
449
|
_jsx("li", { className: "ea-approval__sub", children: line.trim() }, index)) : (
|
|
442
450
|
// biome-ignore lint/suspicious/noArrayIndexKey: lines are static for one approval
|
|
443
|
-
_jsx("li", { children: line }, index))) }), confirmUndo ? (_jsxs(_Fragment, { children: [_jsx("p", { className: "ea-approval__warn", children: copy.undoLosesEdits }), _jsxs("div", { className: "ea-actions", children: [_jsx(Button, { buttonStyle: "primary", margin: false, onClick: () => void undoPreview(pendingApproval, true), size: "medium", children: copy.undoAnyway }), _jsx(Button, { buttonStyle: "secondary", margin: false, onClick: () => setConfirmUndo(false), size: "medium", children: copy.cancel })] })] })) : (_jsxs("div", { className: "ea-actions", children: [_jsx(Button, { buttonStyle: "primary", disabled: status === "streaming", margin: false, onClick: () => void decide("approve"), size: "medium", children: pendingApproval.applied ? copy.keep : copy.approve }), _jsx(Button, { buttonStyle: "secondary", disabled: status === "streaming", margin: false, onClick: () => void decide("reject"), size: "medium", children: pendingApproval.applied ? copy.undo : copy.reject })] }))] })) : null, status !== "idle" && status !== "streaming" ? (_jsx("div", { className: `ea-status ea-status--${status}`, children: statusText(status, talk) })) : null, _jsxs("form", { className: "ea-composer", onSubmit: onSubmit, children: [visibleShortcuts.length > 0 ? (
|
|
444
|
-
|
|
445
|
-
|
|
451
|
+
_jsx("li", { children: line }, index))) }), confirmUndo ? (_jsxs(_Fragment, { children: [_jsx("p", { className: "ea-approval__warn", children: copy.undoLosesEdits }), _jsxs("div", { className: "ea-actions", children: [_jsx(Button, { buttonStyle: "primary", margin: false, onClick: () => void undoPreview(pendingApproval, true), size: "medium", children: copy.undoAnyway }), _jsx(Button, { buttonStyle: "secondary", margin: false, onClick: () => setConfirmUndo(false), size: "medium", children: copy.cancel })] })] })) : (_jsxs("div", { className: "ea-actions", children: [_jsx(Button, { buttonStyle: "primary", disabled: status === "streaming", margin: false, onClick: () => void decide("approve"), size: "medium", children: pendingApproval.applied ? copy.keep : copy.approve }), _jsx(Button, { buttonStyle: "secondary", disabled: status === "streaming", margin: false, onClick: () => void decide("reject"), size: "medium", children: pendingApproval.applied ? copy.undo : copy.reject })] }))] })) : null, status !== "idle" && status !== "streaming" ? (_jsx("div", { className: `ea-status ea-status--${status}`, children: statusText(status, talk) })) : null, _jsxs("form", { className: "ea-composer", onSubmit: onSubmit, children: [visibleShortcuts.length > 0 || translate ? (_jsxs("div", { className: "ea-shortcuts", children: [translate ? (_jsx(Button, { buttonStyle: "pill", disabled: status === "streaming", margin: false, onClick: () => {
|
|
452
|
+
void send(translate.prompt);
|
|
453
|
+
}, size: "small", children: translate.label })) : null, visibleShortcuts.map((shortcut) => (_jsx(Button, { buttonStyle: "pill", disabled: status === "streaming", margin: false, onClick: () => {
|
|
454
|
+
void send(shortcut.prompt);
|
|
455
|
+
}, 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" ? (
|
|
446
456
|
// While the assistant works, Send becomes Stop, where the editor's eye already is.
|
|
447
457
|
_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 })) })] })] })] })] }));
|
|
448
458
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { parseReply } from "./reply-markdown.js";
|
|
4
|
+
/** An assistant reply with the model's Markdown shown as formatting instead of asterisks. */
|
|
5
|
+
export function ReplyText({ text }) {
|
|
6
|
+
return parseReply(text).map((block, index) => {
|
|
7
|
+
const key = `${block.type}-${index}`;
|
|
8
|
+
if (block.type === "paragraph") {
|
|
9
|
+
return (_jsx("p", { className: "ea-reply__p", children: spans(block.spans) }, key));
|
|
10
|
+
}
|
|
11
|
+
const items = block.items.map((item, itemIndex) => _jsx("li", { children: spans(item) }, itemIndex));
|
|
12
|
+
return block.ordered ? (_jsx("ol", { className: "ea-reply__list", start: block.start, children: items }, key)) : (_jsx("ul", { className: "ea-reply__list", children: items }, key));
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
function spans(list) {
|
|
16
|
+
return list.map((span, index) => {
|
|
17
|
+
let node = span.text;
|
|
18
|
+
if (span.code)
|
|
19
|
+
node = _jsx("code", { children: node });
|
|
20
|
+
if (span.italic)
|
|
21
|
+
node = _jsx("em", { children: node });
|
|
22
|
+
if (span.bold)
|
|
23
|
+
node = _jsx("strong", { children: node });
|
|
24
|
+
return _jsx("span", { children: node }, index);
|
|
25
|
+
});
|
|
26
|
+
}
|
|
@@ -133,3 +133,11 @@ export declare const COPY: {
|
|
|
133
133
|
};
|
|
134
134
|
};
|
|
135
135
|
export type Copy = (typeof COPY)[UiLanguage];
|
|
136
|
+
/**
|
|
137
|
+
* The button the panel offers on a document with fields not translated yet. Its prompt is the one
|
|
138
|
+
* content health's work list sends for a missing translation.
|
|
139
|
+
*/
|
|
140
|
+
export declare function translateAction(language: UiLanguage, source: string): {
|
|
141
|
+
label: string;
|
|
142
|
+
prompt: string;
|
|
143
|
+
};
|
package/dist/components/copy.js
CHANGED
|
@@ -133,3 +133,21 @@ export const COPY = {
|
|
|
133
133
|
},
|
|
134
134
|
},
|
|
135
135
|
};
|
|
136
|
+
/**
|
|
137
|
+
* The button the panel offers on a document with fields not translated yet. Its prompt is the one
|
|
138
|
+
* content health's work list sends for a missing translation.
|
|
139
|
+
*/
|
|
140
|
+
export function translateAction(language, source) {
|
|
141
|
+
const name = languageName(language, source);
|
|
142
|
+
return language === "sv"
|
|
143
|
+
? { label: `Översätt från ${name}`, prompt: `Översätt de tomma fälten från ${name}.` }
|
|
144
|
+
: { label: `Translate from ${name}`, prompt: `Translate the empty fields from ${name}.` };
|
|
145
|
+
}
|
|
146
|
+
function languageName(language, code) {
|
|
147
|
+
try {
|
|
148
|
+
return new Intl.DisplayNames([language], { type: "language" }).of(code) ?? code;
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return code;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Markdown the model writes in its replies, read into a small tree the panel renders as React
|
|
3
|
+
* elements: paragraphs, lists, bold, italic and code. Headings show as bold paragraphs. Links show as
|
|
4
|
+
* their text, and a web address stays visible after it, so the panel never renders a link the model
|
|
5
|
+
* wrote. A marker that does not close stays as text, so a half-streamed reply reads the same.
|
|
6
|
+
*/
|
|
7
|
+
export type ReplySpan = {
|
|
8
|
+
text: string;
|
|
9
|
+
bold?: true;
|
|
10
|
+
italic?: true;
|
|
11
|
+
code?: true;
|
|
12
|
+
};
|
|
13
|
+
export type ReplyBlock = {
|
|
14
|
+
type: "paragraph";
|
|
15
|
+
spans: ReplySpan[];
|
|
16
|
+
} | {
|
|
17
|
+
type: "list";
|
|
18
|
+
ordered: false;
|
|
19
|
+
items: ReplySpan[][];
|
|
20
|
+
} | {
|
|
21
|
+
type: "list";
|
|
22
|
+
ordered: true;
|
|
23
|
+
start: number;
|
|
24
|
+
items: ReplySpan[][];
|
|
25
|
+
};
|
|
26
|
+
export declare function parseReply(text: string): ReplyBlock[];
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Markdown the model writes in its replies, read into a small tree the panel renders as React
|
|
3
|
+
* elements: paragraphs, lists, bold, italic and code. Headings show as bold paragraphs. Links show as
|
|
4
|
+
* their text, and a web address stays visible after it, so the panel never renders a link the model
|
|
5
|
+
* wrote. A marker that does not close stays as text, so a half-streamed reply reads the same.
|
|
6
|
+
*/
|
|
7
|
+
const BULLET = /^\s*[-*+]\s+(.*)$/;
|
|
8
|
+
const NUMBERED = /^\s*(\d{1,9})[.)]\s+(.*)$/;
|
|
9
|
+
const HEADING = /^\s*#{1,6}\s+(.*?)\s*#*\s*$/;
|
|
10
|
+
const WORD = /[\p{L}\p{N}]/u;
|
|
11
|
+
export function parseReply(text) {
|
|
12
|
+
const blocks = [];
|
|
13
|
+
let paragraph = [];
|
|
14
|
+
let list;
|
|
15
|
+
const flush = () => {
|
|
16
|
+
if (paragraph.length) {
|
|
17
|
+
blocks.push({ type: "paragraph", spans: parseInline(paragraph.join("\n")) });
|
|
18
|
+
}
|
|
19
|
+
if (list) {
|
|
20
|
+
const items = list.items.map((item) => parseInline(item));
|
|
21
|
+
blocks.push(list.ordered
|
|
22
|
+
? { type: "list", ordered: true, start: list.start, items }
|
|
23
|
+
: { type: "list", ordered: false, items });
|
|
24
|
+
}
|
|
25
|
+
paragraph = [];
|
|
26
|
+
list = undefined;
|
|
27
|
+
};
|
|
28
|
+
for (const line of text.split(/\r?\n/)) {
|
|
29
|
+
const numbered = NUMBERED.exec(line);
|
|
30
|
+
const bullet = numbered ? undefined : BULLET.exec(line);
|
|
31
|
+
if (numbered || bullet) {
|
|
32
|
+
const ordered = Boolean(numbered);
|
|
33
|
+
if (paragraph.length || (list && list.ordered !== ordered))
|
|
34
|
+
flush();
|
|
35
|
+
list ??= { ordered, start: numbered ? Number(numbered[1]) : 1, items: [] };
|
|
36
|
+
list.items.push((numbered ? numbered[2] : bullet?.[1]) ?? "");
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (list)
|
|
40
|
+
flush();
|
|
41
|
+
const heading = HEADING.exec(line);
|
|
42
|
+
if (heading) {
|
|
43
|
+
flush();
|
|
44
|
+
blocks.push({ type: "paragraph", spans: parseInline(heading[1] ?? "", { bold: true }) });
|
|
45
|
+
}
|
|
46
|
+
else if (line.trim() === "") {
|
|
47
|
+
flush();
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
paragraph.push(line);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
flush();
|
|
54
|
+
return blocks;
|
|
55
|
+
}
|
|
56
|
+
function parseInline(text, format = {}) {
|
|
57
|
+
const spans = [];
|
|
58
|
+
const push = (value, spanFormat) => {
|
|
59
|
+
if (!value)
|
|
60
|
+
return;
|
|
61
|
+
const last = spans.at(-1);
|
|
62
|
+
if (last && sameFormat(last, spanFormat))
|
|
63
|
+
last.text += value;
|
|
64
|
+
else
|
|
65
|
+
spans.push({ text: value, ...spanFormat });
|
|
66
|
+
};
|
|
67
|
+
const pushAll = (inner) => {
|
|
68
|
+
for (const { text: value, ...spanFormat } of inner)
|
|
69
|
+
push(value, spanFormat);
|
|
70
|
+
};
|
|
71
|
+
let i = 0;
|
|
72
|
+
while (i < text.length) {
|
|
73
|
+
const ch = text[i];
|
|
74
|
+
if (ch === "`") {
|
|
75
|
+
const end = text.indexOf("`", i + 1);
|
|
76
|
+
if (end > i + 1) {
|
|
77
|
+
push(text.slice(i + 1, end), { ...format, code: true });
|
|
78
|
+
i = end + 1;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
else if (ch === "[") {
|
|
83
|
+
const link = /^\[([^\]]+)\]\(([^)\s]+)\)/.exec(text.slice(i));
|
|
84
|
+
if (link) {
|
|
85
|
+
pushAll(parseInline(link[1] ?? "", format));
|
|
86
|
+
const href = link[2] ?? "";
|
|
87
|
+
if (/^https?:\/\//i.test(href))
|
|
88
|
+
push(` (${href})`, format);
|
|
89
|
+
i += link[0].length;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
else if (ch === "*" || ch === "_") {
|
|
94
|
+
const double = text[i + 1] === ch;
|
|
95
|
+
const size = double ? 2 : 1;
|
|
96
|
+
const end = opens(text, i, ch, size) ? closing(text, i + size, ch, size) : -1;
|
|
97
|
+
if (end > -1) {
|
|
98
|
+
const inner = double
|
|
99
|
+
? { ...format, bold: true }
|
|
100
|
+
: { ...format, italic: true };
|
|
101
|
+
pushAll(parseInline(text.slice(i + size, end), inner));
|
|
102
|
+
i = end + size;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
// A marker that does not open or close is text, and so is the whole run of it.
|
|
106
|
+
push(text.slice(i, i + size), format);
|
|
107
|
+
i += size;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
push(ch ?? "", format);
|
|
111
|
+
i += 1;
|
|
112
|
+
}
|
|
113
|
+
return spans;
|
|
114
|
+
}
|
|
115
|
+
function opens(text, at, ch, size) {
|
|
116
|
+
const next = text[at + size];
|
|
117
|
+
if (!next || /\s/.test(next) || (size === 1 && next === ch))
|
|
118
|
+
return false;
|
|
119
|
+
return ch !== "_" || !WORD.test(text[at - 1] ?? "");
|
|
120
|
+
}
|
|
121
|
+
/** Where the marker that closes one opened before `from` starts, or -1. */
|
|
122
|
+
function closing(text, from, ch, size) {
|
|
123
|
+
for (let i = from + 1; i < text.length; i += 1) {
|
|
124
|
+
if (text[i] !== ch)
|
|
125
|
+
continue;
|
|
126
|
+
const before = text[i - 1] ?? "";
|
|
127
|
+
const after = text[i + size] ?? "";
|
|
128
|
+
if (/\s/.test(before) || after === ch)
|
|
129
|
+
continue;
|
|
130
|
+
if (size === 2 && text[i + 1] !== ch)
|
|
131
|
+
continue;
|
|
132
|
+
if (size === 1 && before === ch)
|
|
133
|
+
continue;
|
|
134
|
+
if (ch === "_" && WORD.test(after))
|
|
135
|
+
continue;
|
|
136
|
+
return i;
|
|
137
|
+
}
|
|
138
|
+
return -1;
|
|
139
|
+
}
|
|
140
|
+
function sameFormat(a, b) {
|
|
141
|
+
return a.bold === b.bold && a.italic === b.italic && a.code === b.code;
|
|
142
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type TranslationOffer = {
|
|
2
|
+
source?: string;
|
|
3
|
+
empty: number;
|
|
4
|
+
};
|
|
5
|
+
type Target = {
|
|
6
|
+
collectionSlug?: string;
|
|
7
|
+
globalSlug?: string;
|
|
8
|
+
documentId?: string | number;
|
|
9
|
+
locale: string;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Whether the open document has fields to translate from the default language, asked again when
|
|
13
|
+
* `refresh` changes (a new message, a kept or undone proposal). Undefined until known or on error.
|
|
14
|
+
*/
|
|
15
|
+
export declare function useTranslationOffer(apiBase: string, target: Target | undefined, refresh: unknown): TranslationOffer | undefined;
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import { ENDPOINT_PREFIX } from "../package-name.js";
|
|
4
|
+
/**
|
|
5
|
+
* Whether the open document has fields to translate from the default language, asked again when
|
|
6
|
+
* `refresh` changes (a new message, a kept or undone proposal). Undefined until known or on error.
|
|
7
|
+
*/
|
|
8
|
+
export function useTranslationOffer(apiBase, target, refresh) {
|
|
9
|
+
const [offer, setOffer] = useState();
|
|
10
|
+
const collection = target?.collectionSlug;
|
|
11
|
+
const global = target?.globalSlug;
|
|
12
|
+
const id = target?.documentId;
|
|
13
|
+
const locale = target?.locale;
|
|
14
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: `refresh` only asks again.
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
setOffer(undefined);
|
|
17
|
+
if (!locale || (!global && (!collection || id === undefined))) {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const params = new URLSearchParams({ locale });
|
|
21
|
+
if (collection)
|
|
22
|
+
params.set("collection", collection);
|
|
23
|
+
if (global)
|
|
24
|
+
params.set("global", global);
|
|
25
|
+
if (id !== undefined)
|
|
26
|
+
params.set("id", String(id));
|
|
27
|
+
const controller = new AbortController();
|
|
28
|
+
fetch(`${apiBase}${ENDPOINT_PREFIX}/translation?${params}`, {
|
|
29
|
+
credentials: "include",
|
|
30
|
+
signal: controller.signal,
|
|
31
|
+
})
|
|
32
|
+
.then((response) => response.ok ? response.json() : undefined)
|
|
33
|
+
.then((result) => setOffer(result))
|
|
34
|
+
.catch(() => undefined);
|
|
35
|
+
return () => controller.abort();
|
|
36
|
+
}, [apiBase, collection, global, id, locale, refresh]);
|
|
37
|
+
return offer;
|
|
38
|
+
}
|
package/dist/domain/content.js
CHANGED
|
@@ -85,6 +85,8 @@ export async function findContentById(req, options, input) {
|
|
|
85
85
|
slug: input.global,
|
|
86
86
|
depth: 0,
|
|
87
87
|
locale: input.locale,
|
|
88
|
+
// As Admin reads it: a field nobody filled in this language is empty, not another's text.
|
|
89
|
+
fallbackLocale: false,
|
|
88
90
|
draft: true,
|
|
89
91
|
req,
|
|
90
92
|
user: req.user,
|
|
@@ -105,6 +107,7 @@ export async function findContentById(req, options, input) {
|
|
|
105
107
|
id: input.documentId,
|
|
106
108
|
depth: 0,
|
|
107
109
|
locale: input.locale,
|
|
110
|
+
fallbackLocale: false,
|
|
108
111
|
draft: true,
|
|
109
112
|
req,
|
|
110
113
|
user: req.user,
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Endpoint } from "payload";
|
|
2
|
+
import type { ValidatedEditorAssistantOptions } from "../config/validate.ts";
|
|
3
|
+
/**
|
|
4
|
+
* Whether the open document has fields to translate, so the panel can offer it: the language they
|
|
5
|
+
* come from and how many, compared on the saved version. Read only, with the editor's access.
|
|
6
|
+
*/
|
|
7
|
+
export declare function createTranslationEndpoint(options: ValidatedEditorAssistantOptions): Endpoint;
|
|
8
|
+
export type TranslationOffer = {
|
|
9
|
+
source?: string;
|
|
10
|
+
empty: number;
|
|
11
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { hasCapability, resolveAllowlist } from "../domain/allowlist.js";
|
|
2
|
+
import { findContentById } from "../domain/content.js";
|
|
3
|
+
import { loadRichText } from "../form/rich-text.js";
|
|
4
|
+
import { translationPaths } from "../form/translation.js";
|
|
5
|
+
import { ENDPOINT_PREFIX } from "../package-name.js";
|
|
6
|
+
import { sourceLocale } from "../runtime/translation-source.js";
|
|
7
|
+
import { configuredDefaultLocale, configuredLocales, entityFields } from "../schema/discover.js";
|
|
8
|
+
/**
|
|
9
|
+
* Whether the open document has fields to translate, so the panel can offer it: the language they
|
|
10
|
+
* come from and how many, compared on the saved version. Read only, with the editor's access.
|
|
11
|
+
*/
|
|
12
|
+
export function createTranslationEndpoint(options) {
|
|
13
|
+
return {
|
|
14
|
+
path: `${ENDPOINT_PREFIX}/translation`,
|
|
15
|
+
method: "get",
|
|
16
|
+
handler: async (req) => {
|
|
17
|
+
if (!req.user) {
|
|
18
|
+
return Response.json({ error: "unauthorized" }, { status: 401 });
|
|
19
|
+
}
|
|
20
|
+
if (options.enabled && !(await options.enabled({ userId: req.user.id }))) {
|
|
21
|
+
return Response.json({ error: "unavailable" }, { status: 403 });
|
|
22
|
+
}
|
|
23
|
+
const params = req.searchParams ?? new URL(req.url ?? "", "http://localhost").searchParams;
|
|
24
|
+
const target = {
|
|
25
|
+
collection: params.get("collection") ?? undefined,
|
|
26
|
+
global: params.get("global") ?? undefined,
|
|
27
|
+
documentId: params.get("id") ?? undefined,
|
|
28
|
+
locale: params.get("locale") ?? "",
|
|
29
|
+
};
|
|
30
|
+
return Response.json(await translationOffer(req, options, target));
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
async function translationOffer(req, options, target) {
|
|
35
|
+
const resolved = resolveAllowlist(options, target);
|
|
36
|
+
const source = sourceLocale({
|
|
37
|
+
open: target.locale,
|
|
38
|
+
locales: configuredLocales(req),
|
|
39
|
+
defaultLocale: configuredDefaultLocale(req),
|
|
40
|
+
});
|
|
41
|
+
if (!source ||
|
|
42
|
+
!resolved ||
|
|
43
|
+
!hasCapability(resolved.allowlist, "form.propose") ||
|
|
44
|
+
(target.collection && !target.documentId)) {
|
|
45
|
+
return { empty: 0 };
|
|
46
|
+
}
|
|
47
|
+
await loadRichText();
|
|
48
|
+
// One after the other: both reads share the request and its transaction.
|
|
49
|
+
const from = await findContentById(req, options, { ...target, locale: source });
|
|
50
|
+
const to = await findContentById(req, options, target);
|
|
51
|
+
if (!from.ok || !to.ok) {
|
|
52
|
+
return { empty: 0 };
|
|
53
|
+
}
|
|
54
|
+
const fields = entityFields(req, options, target);
|
|
55
|
+
return { source, empty: translationPaths(fields, to.data, from.data).empty.length };
|
|
56
|
+
}
|
package/dist/form/empty.d.ts
CHANGED
package/dist/form/empty.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { isDeniedFieldName } from "../config/denied-fields.js";
|
|
2
2
|
import { lexicalPlaintext } from "./lexical.js";
|
|
3
|
-
import { getAtPath, parseFieldPath } from "./path.js";
|
|
4
3
|
const SKIP = new Set(["id", "slug", "blockType", "_status", "parent"]);
|
|
5
4
|
const MAX_PATHS = 12;
|
|
6
5
|
export function listEmptyTextPaths(data) {
|
|
@@ -8,22 +7,6 @@ export function listEmptyTextPaths(data) {
|
|
|
8
7
|
walk(data, "", paths);
|
|
9
8
|
return paths;
|
|
10
9
|
}
|
|
11
|
-
export function previewAtPath(data, path) {
|
|
12
|
-
try {
|
|
13
|
-
const value = getAtPath(data, parseFieldPath(path));
|
|
14
|
-
if (typeof value === "string" && value.trim()) {
|
|
15
|
-
return truncate(value.trim());
|
|
16
|
-
}
|
|
17
|
-
if (value && typeof value === "object" && "root" in value) {
|
|
18
|
-
const text = lexicalPlaintext(value);
|
|
19
|
-
return text ? truncate(text) : undefined;
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
catch {
|
|
23
|
-
return undefined;
|
|
24
|
-
}
|
|
25
|
-
return undefined;
|
|
26
|
-
}
|
|
27
10
|
function walk(value, path, into) {
|
|
28
11
|
if (into.length >= MAX_PATHS) {
|
|
29
12
|
return;
|
|
@@ -56,6 +39,3 @@ function walk(value, path, into) {
|
|
|
56
39
|
}
|
|
57
40
|
}
|
|
58
41
|
}
|
|
59
|
-
function truncate(value) {
|
|
60
|
-
return value.length > 80 ? `${value.slice(0, 77)}…` : value;
|
|
61
|
-
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { FieldNode } from "../schema/fields.ts";
|
|
2
|
+
/** Localized text in the open language, by whether the source language has text for it. */
|
|
3
|
+
export type TranslationPaths = {
|
|
4
|
+
/** Filled in the source, empty here: what "translate" fills in. */
|
|
5
|
+
empty: string[];
|
|
6
|
+
/** Filled in both: what "translate again" replaces. */
|
|
7
|
+
translated: string[];
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Localized text, textarea and rich text fields of the open form, with array and block indexes, that
|
|
11
|
+
* the source language has text for. Rich text may be Lexical (the form) or Markdown blocks (the
|
|
12
|
+
* source as the model reads it). Blocks whose type differs between the languages are skipped.
|
|
13
|
+
*/
|
|
14
|
+
export declare function translationPaths(fields: FieldNode[], form: Record<string, unknown>, source: Record<string, unknown>): TranslationPaths;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { isLexical, lexicalPlaintext } from "./lexical.js";
|
|
2
|
+
const TEXT_TYPES = new Set(["text", "textarea", "richText"]);
|
|
3
|
+
/**
|
|
4
|
+
* Localized text, textarea and rich text fields of the open form, with array and block indexes, that
|
|
5
|
+
* the source language has text for. Rich text may be Lexical (the form) or Markdown blocks (the
|
|
6
|
+
* source as the model reads it). Blocks whose type differs between the languages are skipped.
|
|
7
|
+
*/
|
|
8
|
+
export function translationPaths(fields, form, source) {
|
|
9
|
+
const paths = { empty: [], translated: [] };
|
|
10
|
+
walkFields(fields, source, form, { data: "", template: "" }, false, paths);
|
|
11
|
+
return paths;
|
|
12
|
+
}
|
|
13
|
+
function walkFields(fields, source, target, prefix, inherited, paths) {
|
|
14
|
+
for (const field of fields) {
|
|
15
|
+
const relative = prefix.template ? field.path.slice(prefix.template.length + 1) : field.path;
|
|
16
|
+
const path = prefix.data ? `${prefix.data}.${relative}` : relative;
|
|
17
|
+
const localized = inherited || Boolean(field.localized);
|
|
18
|
+
const from = child(source, relative);
|
|
19
|
+
const to = child(target, relative);
|
|
20
|
+
const nested = (data) => ({ data, template: field.path });
|
|
21
|
+
if (TEXT_TYPES.has(field.type)) {
|
|
22
|
+
if (localized && hasText(from)) {
|
|
23
|
+
(hasText(to) ? paths.translated : paths.empty).push(path);
|
|
24
|
+
}
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (field.type === "group") {
|
|
28
|
+
walkFields(field.fields ?? [], from, to, nested(path), localized, paths);
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (field.type !== "array" && field.type !== "blocks") {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
const sourceRows = Array.isArray(from) ? from : [];
|
|
35
|
+
const targetRows = Array.isArray(to) ? to : [];
|
|
36
|
+
// A localized list that is empty here is one field to translate; its rows do not exist yet.
|
|
37
|
+
if (field.localized && sourceRows.length > 0 && targetRows.length === 0) {
|
|
38
|
+
paths.empty.push(path);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
for (const [index, sourceRow] of sourceRows.entries()) {
|
|
42
|
+
const targetRow = targetRows[index];
|
|
43
|
+
if (!targetRow || typeof targetRow !== "object") {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
const rowPath = `${path}.${index}`;
|
|
47
|
+
if (field.type === "array") {
|
|
48
|
+
walkFields(field.fields ?? [], sourceRow, targetRow, nested(rowPath), localized, paths);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
const type = sourceRow.blockType;
|
|
52
|
+
if (typeof type !== "string" || targetRow.blockType !== type) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const block = field.blocks?.find((entry) => entry.slug === type);
|
|
56
|
+
if (block) {
|
|
57
|
+
walkFields(block.fields, sourceRow, targetRow, nested(rowPath), localized, paths);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function child(value, path) {
|
|
63
|
+
let current = value;
|
|
64
|
+
for (const segment of path.split(".")) {
|
|
65
|
+
if (!current || typeof current !== "object") {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
current = current[segment];
|
|
69
|
+
}
|
|
70
|
+
return current;
|
|
71
|
+
}
|
|
72
|
+
function hasText(value) {
|
|
73
|
+
if (typeof value === "string") {
|
|
74
|
+
return value.trim().length > 0;
|
|
75
|
+
}
|
|
76
|
+
if (Array.isArray(value)) {
|
|
77
|
+
// Markdown blocks; a kept part (`{ keep }`) is not text to translate.
|
|
78
|
+
return value.some((block) => typeof block === "string" && block.trim().length > 0);
|
|
79
|
+
}
|
|
80
|
+
return isLexical(value) ? lexicalPlaintext(value).length > 0 : false;
|
|
81
|
+
}
|
package/dist/plugin.js
CHANGED
|
@@ -6,6 +6,7 @@ import { createChatEndpoint } from "./endpoints/chat.js";
|
|
|
6
6
|
import { createCsrfEndpoint } from "./endpoints/csrf.js";
|
|
7
7
|
import { createDecideEndpoint } from "./endpoints/decide.js";
|
|
8
8
|
import { createProposeEndpoint } from "./endpoints/propose.js";
|
|
9
|
+
import { createTranslationEndpoint } from "./endpoints/translation.js";
|
|
9
10
|
import { ADMIN_CUSTOM_KEY, CLIENT_ENTRY, DEFAULT_APPROVAL_SLUG } from "./package-name.js";
|
|
10
11
|
export function editorAssistantPlugin(options = {}) {
|
|
11
12
|
const validated = validateEditorAssistantOptions(options);
|
|
@@ -75,6 +76,7 @@ export function editorAssistantPlugin(options = {}) {
|
|
|
75
76
|
createChatEndpoint(validated),
|
|
76
77
|
createProposeEndpoint(validated),
|
|
77
78
|
createDecideEndpoint(validated),
|
|
79
|
+
createTranslationEndpoint(validated),
|
|
78
80
|
],
|
|
79
81
|
};
|
|
80
82
|
};
|
|
@@ -37,7 +37,14 @@ export type TurnContext = {
|
|
|
37
37
|
form?: Record<string, unknown>;
|
|
38
38
|
/** What the editor clicked or focused; "this" in their message refers to it. */
|
|
39
39
|
selection?: Selection;
|
|
40
|
+
/** Other languages of the open document that have text of their own, as the model reads them. */
|
|
40
41
|
otherLanguages?: Record<string, Record<string, unknown>>;
|
|
42
|
+
/** Localized text of the open form that the default language has text for. */
|
|
43
|
+
translation?: {
|
|
44
|
+
source: string;
|
|
45
|
+
empty: string[];
|
|
46
|
+
translated: string[];
|
|
47
|
+
};
|
|
41
48
|
documents: Record<string, unknown>[];
|
|
42
49
|
};
|
|
43
50
|
export declare function loadTurnContext(req: PayloadRequest, options: ValidatedEditorAssistantOptions, bridge: ToolBridge, formData?: Record<string, unknown>): Promise<TurnContext>;
|
package/dist/runtime/context.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { findContent, findContentById } from "../domain/content.js";
|
|
2
|
-
import {
|
|
2
|
+
import { translationPaths } from "../form/translation.js";
|
|
3
|
+
import { configuredDefaultLocale, configuredLocales, discoverSchema, entityFields, } from "../schema/discover.js";
|
|
3
4
|
import { resolveSelection } from "./selection.js";
|
|
4
5
|
import { executeTool } from "./tools.js";
|
|
6
|
+
import { sourceLocale } from "./translation-source.js";
|
|
5
7
|
export async function loadTurnContext(req, options, bridge, formData) {
|
|
6
8
|
const ctx = { req, options, bridge, formData };
|
|
7
9
|
const schema = discoverSchema(req, options);
|
|
@@ -17,15 +19,17 @@ export async function loadTurnContext(req, options, bridge, formData) {
|
|
|
17
19
|
const form = open
|
|
18
20
|
? ((await readOpenForm(ctx)) ?? (await readSavedDocument(req, options, bridge)))
|
|
19
21
|
: undefined;
|
|
20
|
-
const
|
|
21
|
-
?
|
|
22
|
-
:
|
|
22
|
+
const fields = open
|
|
23
|
+
? entityFields(req, options, { collection: bridge.collection, global: bridge.global })
|
|
24
|
+
: [];
|
|
25
|
+
const selection = open ? resolveSelection(bridge.selection, form, fields) : undefined;
|
|
23
26
|
const listed = await findContent(req, options, {
|
|
24
27
|
collection: bridge.collection ?? (bridge.view?.kind === "list" ? bridge.view.collection : undefined),
|
|
25
28
|
query: "",
|
|
26
29
|
locale: bridge.locale,
|
|
27
30
|
});
|
|
28
|
-
const otherLanguages = open ? await readOtherLanguages(req, options, bridge) : undefined;
|
|
31
|
+
const otherLanguages = open ? await readOtherLanguages(req, options, bridge, fields) : undefined;
|
|
32
|
+
const translation = form ? translationFor(req, bridge, fields, form, otherLanguages) : undefined;
|
|
29
33
|
return {
|
|
30
34
|
allowlist: schema.map(compactEntity),
|
|
31
35
|
open,
|
|
@@ -33,6 +37,7 @@ export async function loadTurnContext(req, options, bridge, formData) {
|
|
|
33
37
|
form,
|
|
34
38
|
selection,
|
|
35
39
|
otherLanguages,
|
|
40
|
+
translation,
|
|
36
41
|
documents: listed.ok ? listed.results : [],
|
|
37
42
|
};
|
|
38
43
|
}
|
|
@@ -57,7 +62,7 @@ async function readSavedDocument(req, options, bridge) {
|
|
|
57
62
|
}
|
|
58
63
|
return result.data;
|
|
59
64
|
}
|
|
60
|
-
async function readOtherLanguages(req, options, bridge) {
|
|
65
|
+
async function readOtherLanguages(req, options, bridge, fields) {
|
|
61
66
|
const locales = configuredLocales(req).filter((code) => code !== bridge.locale);
|
|
62
67
|
if (locales.length === 0) {
|
|
63
68
|
return undefined;
|
|
@@ -70,7 +75,8 @@ async function readOtherLanguages(req, options, bridge) {
|
|
|
70
75
|
documentId: bridge.documentId,
|
|
71
76
|
locale,
|
|
72
77
|
});
|
|
73
|
-
|
|
78
|
+
// A language nobody wrote in yet is left out: it has nothing to translate from.
|
|
79
|
+
if (result.ok && translationPaths(fields, {}, result.data).empty.length > 0) {
|
|
74
80
|
otherLanguages[locale] = result.data;
|
|
75
81
|
}
|
|
76
82
|
}
|
|
@@ -100,3 +106,15 @@ function compactField(field) {
|
|
|
100
106
|
relationTo: field.type === "upload" || field.type === "relationship" ? field.relationTo : undefined,
|
|
101
107
|
};
|
|
102
108
|
}
|
|
109
|
+
function translationFor(req, bridge, fields, form, otherLanguages) {
|
|
110
|
+
const source = sourceLocale({
|
|
111
|
+
open: bridge.locale,
|
|
112
|
+
locales: configuredLocales(req),
|
|
113
|
+
defaultLocale: configuredDefaultLocale(req),
|
|
114
|
+
});
|
|
115
|
+
const data = source ? otherLanguages?.[source] : undefined;
|
|
116
|
+
if (!source || !data) {
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
return { source, ...translationPaths(fields, form, data) };
|
|
120
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { listEmptyTextPaths
|
|
1
|
+
import { listEmptyTextPaths } from "../form/empty.js";
|
|
2
2
|
export function buildSystemPrompt(instructions, bridge, turnContext) {
|
|
3
3
|
const base = [
|
|
4
4
|
"You are a colleague helping a content editor. They are not a developer.",
|
|
@@ -30,7 +30,7 @@ function openDocumentLine(bridge, turnContext) {
|
|
|
30
30
|
const title = bridge.title ? `"${bridge.title}"` : "the open document";
|
|
31
31
|
const language = languageName(bridge.locale);
|
|
32
32
|
const sources = turnContext?.otherLanguages
|
|
33
|
-
? " Other language versions of this same page are in the quoted context
|
|
33
|
+
? " Other language versions of this same page are in the quoted context under otherLanguages. Do not ask the editor to paste them, and do not say a source is missing if it is there."
|
|
34
34
|
: "";
|
|
35
35
|
return `The editor is looking at ${title} in ${language}. Do not ask them which document it is.${sources}${imageLine(bridge, turnContext)}${selectionLine(turnContext)}`;
|
|
36
36
|
}
|
|
@@ -111,34 +111,44 @@ function thisTurnBlock(bridge, turnContext) {
|
|
|
111
111
|
const empty = listEmptyTextPaths(form);
|
|
112
112
|
if (empty.length > 0) {
|
|
113
113
|
lines.push(`Empty fields: ${empty.join(", ")}.`);
|
|
114
|
-
const source = sourceLine(turnContext?.otherLanguages, empty);
|
|
115
|
-
if (source) {
|
|
116
|
-
lines.push(source);
|
|
117
|
-
}
|
|
118
114
|
lines.push("Fill them only when the editor asks for that (fill in, translate, write, fyll i, översätt). A greeting, a question or small talk is not such a request: answer it and propose nothing. When they do ask, call form.propose now with text in the open language. Do not ask.");
|
|
119
115
|
}
|
|
120
116
|
}
|
|
117
|
+
const translation = translationLines(bridge, turnContext);
|
|
118
|
+
if (translation) {
|
|
119
|
+
lines.push(translation);
|
|
120
|
+
}
|
|
121
121
|
return `\n\n${lines.join(" ")}`;
|
|
122
122
|
}
|
|
123
|
-
|
|
124
|
-
|
|
123
|
+
const MAX_LISTED_PATHS = 60;
|
|
124
|
+
/** What a translation fills in, from which language, and how rich text and shared fields go. */
|
|
125
|
+
function translationLines(bridge, turnContext) {
|
|
126
|
+
const translation = turnContext?.translation;
|
|
127
|
+
if (!translation || (!translation.empty.length && !translation.translated.length)) {
|
|
125
128
|
return undefined;
|
|
126
129
|
}
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
if (
|
|
131
|
-
|
|
130
|
+
const source = languageName(translation.source);
|
|
131
|
+
const target = languageName(bridge?.locale ?? turnContext?.open?.locale);
|
|
132
|
+
const lines = [];
|
|
133
|
+
if (translation.empty.length) {
|
|
134
|
+
lines.push(`Not translated from ${source} yet (${translation.empty.length} fields): ${listPaths(translation.empty)}.`);
|
|
132
135
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
const preview = previewAtPath(data, path);
|
|
136
|
-
return preview ? [`${path}=${JSON.stringify(preview)}`] : [];
|
|
137
|
-
});
|
|
138
|
-
if (parts.length === 0) {
|
|
139
|
-
return undefined;
|
|
136
|
+
if (translation.translated.length) {
|
|
137
|
+
lines.push(`Already translated: ${listPaths(translation.translated)}.`);
|
|
140
138
|
}
|
|
141
|
-
|
|
139
|
+
lines.push(`The ${source} text is in the quoted context under otherLanguages.${translation.source}. When the editor asks to translate (translate, översätt, fyll i), translate from ${source} into ${target} and set every field not translated yet, all in one form.propose. Leave already translated fields as they are unless the editor asks to translate them again; then replace those too. Translate; do not copy the ${source} wording. Rich text: set the whole field to a list of Markdown blocks, one per ${source} block, keeping headings, lists, bold, italic and links, with link addresses unchanged. A ${source} block shown as { "keep" } cannot be copied: leave it out and say so. Never change fields that are not listed here, and never add, move or remove blocks or rows. Do not ask.`);
|
|
140
|
+
const chat = languageName(bridge?.language ?? bridge?.locale);
|
|
141
|
+
if (chat !== target) {
|
|
142
|
+
// Asked to translate into one language, models also replied in it.
|
|
143
|
+
lines.push(`Write your reply in ${chat}; only the field values are in ${target}.`);
|
|
144
|
+
}
|
|
145
|
+
return lines.join(" ");
|
|
146
|
+
}
|
|
147
|
+
function listPaths(paths) {
|
|
148
|
+
const listed = paths.slice(0, MAX_LISTED_PATHS).join(", ");
|
|
149
|
+
return paths.length > MAX_LISTED_PATHS
|
|
150
|
+
? `${listed} and ${paths.length - MAX_LISTED_PATHS} more`
|
|
151
|
+
: listed;
|
|
142
152
|
}
|
|
143
153
|
function languageName(code) {
|
|
144
154
|
if (code === "sv" || code?.startsWith("sv-")) {
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The language a translation comes from: the project's default one, unless that is the language
|
|
3
|
+
* already open. An editor who wants another source names it; the model reads it with
|
|
4
|
+
* `content.findById`.
|
|
5
|
+
*/
|
|
6
|
+
export declare function sourceLocale(input: {
|
|
7
|
+
open: string;
|
|
8
|
+
locales: string[];
|
|
9
|
+
defaultLocale: string | undefined;
|
|
10
|
+
}): string | undefined;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The language a translation comes from: the project's default one, unless that is the language
|
|
3
|
+
* already open. An editor who wants another source names it; the model reads it with
|
|
4
|
+
* `content.findById`.
|
|
5
|
+
*/
|
|
6
|
+
export function sourceLocale(input) {
|
|
7
|
+
const { defaultLocale } = input;
|
|
8
|
+
if (!defaultLocale || defaultLocale === input.open || !input.locales.includes(defaultLocale)) {
|
|
9
|
+
return undefined;
|
|
10
|
+
}
|
|
11
|
+
return defaultLocale;
|
|
12
|
+
}
|
package/dist/runtime/voice.js
CHANGED
|
@@ -20,7 +20,7 @@ export function editorFacingText(input) {
|
|
|
20
20
|
}
|
|
21
21
|
export function stripInternalSpeech(text, locale) {
|
|
22
22
|
const swedish = isSwedish(locale);
|
|
23
|
-
|
|
23
|
+
return keepSentences(text, (sentence) => {
|
|
24
24
|
if (INTERNAL.test(sentence)) {
|
|
25
25
|
return false;
|
|
26
26
|
}
|
|
@@ -29,17 +29,13 @@ export function stripInternalSpeech(text, locale) {
|
|
|
29
29
|
}
|
|
30
30
|
return true;
|
|
31
31
|
});
|
|
32
|
-
return kept.join(" ").replace(/\s+/g, " ").trim();
|
|
33
32
|
}
|
|
34
33
|
/** Keeping, saving and publishing are the editor's; a sentence that claims or asks for them goes. */
|
|
35
34
|
const OVERCLAIM = /godkänn|godkann|approve|spara|sparat|sparad|sparats|publicer|\bsaved?\b|publish|behåll|\bkeep\b|klicka|\bclick|(?:^|\s)är nu\b|\bis now\b/i;
|
|
36
35
|
const MAX_OWN_TEXT = 600;
|
|
37
36
|
export function withoutOverclaims(text) {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
.filter((sentence) => !OVERCLAIM.test(withoutQuotes(sentence)))
|
|
41
|
-
.join(" ")
|
|
42
|
-
.trim();
|
|
37
|
+
// A quoted proposal ("Spara 20 %") is the editor's content, not a claim about the panel.
|
|
38
|
+
const kept = keepSentences(text, (sentence) => !OVERCLAIM.test(withoutQuotes(sentence)));
|
|
43
39
|
return kept.length <= MAX_OWN_TEXT ? kept : "";
|
|
44
40
|
}
|
|
45
41
|
export function approvalCopy(locale, capability) {
|
|
@@ -65,6 +61,23 @@ function isSwedish(locale) {
|
|
|
65
61
|
}
|
|
66
62
|
const QUOTES = '"“”„«»';
|
|
67
63
|
const QUOTED = /["“”„«»][^"“”„«»]*["“”„«»]/g;
|
|
64
|
+
/**
|
|
65
|
+
* The sentences that pass `keep`, line by line: the panel shows the reply's Markdown, so its line
|
|
66
|
+
* breaks, lists and blank lines between paragraphs stay. A line that loses every sentence goes.
|
|
67
|
+
*/
|
|
68
|
+
function keepSentences(text, keep) {
|
|
69
|
+
return text
|
|
70
|
+
.split(/\r?\n/)
|
|
71
|
+
.flatMap((line) => {
|
|
72
|
+
if (line.trim() === "")
|
|
73
|
+
return [""];
|
|
74
|
+
const kept = splitSentences(line).filter(keep).join(" ");
|
|
75
|
+
return kept ? [kept.replace(/[ \t]+/g, " ")] : [];
|
|
76
|
+
})
|
|
77
|
+
.join("\n")
|
|
78
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
79
|
+
.trim();
|
|
80
|
+
}
|
|
68
81
|
/**
|
|
69
82
|
* Sentences, keeping a quoted proposal ("Click. Edit. Done.") inside the sentence that quotes it.
|
|
70
83
|
* A line with an unclosed quote is split as if it had none, so the quote cannot hide a claim.
|
|
@@ -32,4 +32,6 @@ export declare function entityFields(req: PayloadRequest, options: ValidatedEdit
|
|
|
32
32
|
global?: string;
|
|
33
33
|
}): FieldNode[];
|
|
34
34
|
export declare function hasDrafts(versions: unknown): boolean;
|
|
35
|
+
/** Payload's `localization.defaultLocale`, when the project is localized. */
|
|
36
|
+
export declare function configuredDefaultLocale(req: PayloadRequest): string | undefined;
|
|
35
37
|
export declare function configuredLocales(req: PayloadRequest): string[];
|
package/dist/schema/discover.js
CHANGED
|
@@ -71,6 +71,15 @@ export function hasDrafts(versions) {
|
|
|
71
71
|
function asString(value) {
|
|
72
72
|
return typeof value === "string" && value.trim() ? value : undefined;
|
|
73
73
|
}
|
|
74
|
+
/** Payload's `localization.defaultLocale`, when the project is localized. */
|
|
75
|
+
export function configuredDefaultLocale(req) {
|
|
76
|
+
const localization = req.payload.config.localization;
|
|
77
|
+
if (!localization || typeof localization !== "object") {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
const code = localization.defaultLocale;
|
|
81
|
+
return typeof code === "string" && code ? code : undefined;
|
|
82
|
+
}
|
|
74
83
|
export function configuredLocales(req) {
|
|
75
84
|
const localization = req.payload.config.localization;
|
|
76
85
|
if (!localization || typeof localization !== "object") {
|
|
@@ -108,7 +108,7 @@ html[data-theme="dark"] .ea-aside {
|
|
|
108
108
|
gap: calc(var(--base) * 0.8);
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
/* The assistant
|
|
111
|
+
/* The assistant's replies sit on the panel, the editor's messages in a quiet bubble. */
|
|
112
112
|
.ea-msg {
|
|
113
113
|
max-width: 100%;
|
|
114
114
|
line-height: 1.5;
|
|
@@ -128,6 +128,35 @@ html[data-theme="dark"] .ea-aside {
|
|
|
128
128
|
align-self: flex-start;
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
/* A reply's Markdown: paragraphs and lists spaced like the thread, a line break kept inside one. */
|
|
132
|
+
.ea-reply__p,
|
|
133
|
+
.ea-reply__list {
|
|
134
|
+
margin: 0;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
.ea-reply__p + .ea-reply__p,
|
|
138
|
+
.ea-reply__p + .ea-reply__list,
|
|
139
|
+
.ea-reply__list + .ea-reply__p,
|
|
140
|
+
.ea-reply__list + .ea-reply__list {
|
|
141
|
+
margin-top: calc(var(--base) * 0.5);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.ea-reply__list {
|
|
145
|
+
padding-inline-start: calc(var(--base) * 1.2);
|
|
146
|
+
white-space: normal;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.ea-reply__list li + li {
|
|
150
|
+
margin-top: calc(var(--base) * 0.2);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.ea-msg code {
|
|
154
|
+
font-size: 0.9em;
|
|
155
|
+
padding: 0 0.25em;
|
|
156
|
+
border-radius: var(--style-radius-s);
|
|
157
|
+
background: var(--theme-elevation-100);
|
|
158
|
+
}
|
|
159
|
+
|
|
131
160
|
.ea-msg--system {
|
|
132
161
|
color: var(--theme-elevation-500);
|
|
133
162
|
font-size: 12px;
|