best-email-builder 0.8.0 → 0.10.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 +76 -0
- package/README.md +7 -5
- package/dist/ai-server.js +21 -32
- package/dist/ai-server.js.map +1 -1
- package/dist/ai.js +432 -307
- package/dist/ai.js.map +1 -1
- package/dist/builder.js +8 -9
- package/dist/{chunk-SHHHYRPC.js → chunk-24E4GOHU.js} +518 -1505
- package/dist/chunk-24E4GOHU.js.map +1 -0
- package/dist/chunk-7G7C6G3A.js +837 -0
- package/dist/chunk-7G7C6G3A.js.map +1 -0
- package/dist/{chunk-G4U6PSF6.js → chunk-A6VTFUSW.js} +2 -2
- package/dist/{chunk-P7ABBLX6.js → chunk-KTTVBNOU.js} +2 -2
- package/dist/{chunk-6W3QUZR7.js → chunk-M4R53NYT.js} +158 -36
- package/dist/chunk-M4R53NYT.js.map +1 -0
- package/dist/{chunk-62HBUBBI.js → chunk-MBZI6NVP.js} +31 -732
- package/dist/chunk-MBZI6NVP.js.map +1 -0
- package/dist/{chunk-LUM7J5FF.js → chunk-OLTD3S3N.js} +2 -2
- package/dist/{chunk-ITIG5NTJ.js → chunk-SCQKWAXR.js} +7 -7
- package/dist/{chunk-WM2EEV4Q.js → chunk-TUA6E3BR.js} +76 -39
- package/dist/chunk-TUA6E3BR.js.map +1 -0
- package/dist/{chunk-AXIM6E7T.js → chunk-VQRVBQIB.js} +2 -2
- package/dist/{chunk-DXWIVGHP.js → chunk-XDCGYHLF.js} +4 -41
- package/dist/chunk-XDCGYHLF.js.map +1 -0
- package/dist/index.js +17 -18
- package/dist/index.js.map +1 -1
- package/dist/library.js +5 -5
- package/dist/server.js +13 -19
- package/dist/server.js.map +1 -1
- package/dist/styles.css +1 -1
- package/dist/types/ai/ConnectAi.d.ts +3 -1
- package/dist/types/ai/ModelPicker.d.ts +7 -3
- package/dist/types/ai/ProofreadButton.d.ts +4 -0
- package/dist/types/ai/SubjectIdeas.d.ts +1 -1
- package/dist/types/ai/agent.d.ts +1 -1
- package/dist/types/ai/agentEndpoint.d.ts +3 -11
- package/dist/types/ai/claude.d.ts +3 -3
- package/dist/types/ai/createAi.d.ts +9 -14
- package/dist/types/ai/diff.d.ts +24 -0
- package/dist/types/ai/{draft.d.ts → errors.d.ts} +30 -81
- package/dist/types/ai/generator.d.ts +25 -91
- package/dist/types/ai/index.d.ts +2 -2
- package/dist/types/ai/keys.d.ts +15 -3
- package/dist/types/ai/proofread.d.ts +99 -0
- package/dist/types/ai/rewrite.d.ts +27 -8
- package/dist/types/ai/server.d.ts +4 -4
- package/dist/types/ai/subjects.d.ts +6 -0
- package/dist/types/core/EmailEditor.d.ts +10 -0
- package/dist/types/core/constants.d.ts +1 -1
- package/dist/types/engine/NativeAdapter.d.ts +1 -1
- package/dist/types/server/handler.d.ts +5 -3
- package/dist/types/ui/primitives/searchShortcut.d.ts +2 -2
- package/package.json +1 -1
- package/dist/chunk-62HBUBBI.js.map +0 -1
- package/dist/chunk-6W3QUZR7.js.map +0 -1
- package/dist/chunk-DXWIVGHP.js.map +0 -1
- package/dist/chunk-FAEVYSIF.js +0 -119
- package/dist/chunk-FAEVYSIF.js.map +0 -1
- package/dist/chunk-OWVXKMLI.js +0 -121
- package/dist/chunk-OWVXKMLI.js.map +0 -1
- package/dist/chunk-SHHHYRPC.js.map +0 -1
- package/dist/chunk-WM2EEV4Q.js.map +0 -1
- package/dist/types/ai/GenerateButton.d.ts +0 -4
- package/dist/types/ai/prompt.d.ts +0 -76
- package/dist/types/ai/purposes.d.ts +0 -48
- /package/dist/{chunk-G4U6PSF6.js.map → chunk-A6VTFUSW.js.map} +0 -0
- /package/dist/{chunk-P7ABBLX6.js.map → chunk-KTTVBNOU.js.map} +0 -0
- /package/dist/{chunk-LUM7J5FF.js.map → chunk-OLTD3S3N.js.map} +0 -0
- /package/dist/{chunk-ITIG5NTJ.js.map → chunk-SCQKWAXR.js.map} +0 -0
- /package/dist/{chunk-AXIM6E7T.js.map → chunk-VQRVBQIB.js.map} +0 -0
package/dist/ai.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
cleanRichText
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-KTTVBNOU.js";
|
|
4
4
|
import {
|
|
5
5
|
Button,
|
|
6
6
|
Dialog,
|
|
@@ -15,52 +15,43 @@ import {
|
|
|
15
15
|
DropdownMenuTrigger,
|
|
16
16
|
Input,
|
|
17
17
|
Label,
|
|
18
|
-
SEARCH_KEYS,
|
|
19
|
-
SearchKeyHint,
|
|
20
18
|
SectionLabel,
|
|
21
|
-
Textarea,
|
|
22
19
|
cn,
|
|
23
20
|
createHttpCaller,
|
|
24
|
-
useSearchShortcut,
|
|
25
21
|
useUi
|
|
26
|
-
} from "./chunk-
|
|
27
|
-
import "./chunk-
|
|
22
|
+
} from "./chunk-XDCGYHLF.js";
|
|
23
|
+
import "./chunk-7G7C6G3A.js";
|
|
28
24
|
import {
|
|
29
25
|
AGENT_ERROR_CODES,
|
|
30
26
|
AGENT_PROTOCOL,
|
|
31
27
|
AI_MESSAGES,
|
|
32
28
|
AI_PROVIDERS,
|
|
33
29
|
DEFAULT_CLAUDE_MODEL,
|
|
34
|
-
EMAIL_PURPOSES,
|
|
35
30
|
EmailGenerationError,
|
|
36
31
|
GENERATION_ERRORS,
|
|
37
|
-
MAX_DETAILS_LENGTH,
|
|
38
32
|
MAX_REWRITE_INSTRUCTION,
|
|
39
|
-
PURPOSE_CATEGORIES,
|
|
40
33
|
REWRITE_ACTIONS,
|
|
41
34
|
REWRITE_LANGUAGES,
|
|
42
35
|
agentEmailModel,
|
|
43
36
|
claudeEmailModel,
|
|
44
37
|
claudeProvider,
|
|
45
38
|
createEmailGenerator,
|
|
39
|
+
diffWords,
|
|
46
40
|
emailWords,
|
|
47
41
|
failureCode,
|
|
48
42
|
listProviderModels,
|
|
49
43
|
providerEmailModel,
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
} from "./chunk-
|
|
53
|
-
import "./chunk-
|
|
54
|
-
import "./chunk-
|
|
55
|
-
import {
|
|
56
|
-
brandTemplate,
|
|
57
|
-
hasBrand
|
|
58
|
-
} from "./chunk-62HBUBBI.js";
|
|
44
|
+
resolveProvider,
|
|
45
|
+
visibleText
|
|
46
|
+
} from "./chunk-24E4GOHU.js";
|
|
47
|
+
import "./chunk-VQRVBQIB.js";
|
|
48
|
+
import "./chunk-MBZI6NVP.js";
|
|
59
49
|
|
|
60
50
|
// src/ai/keys.js
|
|
61
51
|
var STORAGE_PREFIX = "best-email-builder:ai-key:";
|
|
62
52
|
var CHOICE_SLOT = "best-email-builder:ai-provider";
|
|
63
53
|
var MODEL_PREFIX = "best-email-builder:ai-model:";
|
|
54
|
+
var ADDRESS_PREFIX = "best-email-builder:ai-address:";
|
|
64
55
|
function browserStorage() {
|
|
65
56
|
try {
|
|
66
57
|
const storage = globalThis.localStorage;
|
|
@@ -76,8 +67,11 @@ var choiceOf = (provider) => Object.freeze({
|
|
|
76
67
|
keyUrl: provider.keyUrl,
|
|
77
68
|
keyHint: provider.keyHint,
|
|
78
69
|
free: provider.free,
|
|
79
|
-
publicModels: Boolean(provider.publicModels)
|
|
70
|
+
publicModels: Boolean(provider.publicModels),
|
|
71
|
+
keyless: Boolean(provider.keyless),
|
|
72
|
+
needsAddress: !provider.createModel && !provider.baseUrl
|
|
80
73
|
});
|
|
74
|
+
var cleanAddress = (url) => String(url || "").trim().replace(/\/+$/, "");
|
|
81
75
|
function createKeyStore(providerOrList, { fetch: fetchImpl } = {}) {
|
|
82
76
|
const originals = Array.isArray(providerOrList) ? providerOrList : [providerOrList];
|
|
83
77
|
const providers = Object.freeze(originals.map(choiceOf));
|
|
@@ -117,17 +111,28 @@ function createKeyStore(providerOrList, { fetch: fetchImpl } = {}) {
|
|
|
117
111
|
return id;
|
|
118
112
|
};
|
|
119
113
|
const lists = /* @__PURE__ */ new Map();
|
|
114
|
+
const modelOf = (choice) => (memory.get(MODEL_PREFIX + choice.id) ?? read(MODEL_PREFIX + choice.id)) || choice.model || "";
|
|
115
|
+
const addressOf = (choice) => choice.needsAddress ? cleanAddress(memory.get(ADDRESS_PREFIX + choice.id) ?? read(ADDRESS_PREFIX + choice.id)) : "";
|
|
116
|
+
const ready = () => {
|
|
117
|
+
const choice = (
|
|
118
|
+
/** @type {ProviderChoice} */
|
|
119
|
+
providers.find((provider) => provider.id === current)
|
|
120
|
+
);
|
|
121
|
+
if (refused) return false;
|
|
122
|
+
if (!choice.keyless && !get()) return false;
|
|
123
|
+
if (choice.needsAddress && !addressOf(choice)) return false;
|
|
124
|
+
return Boolean(modelOf(choice));
|
|
125
|
+
};
|
|
120
126
|
const changed = () => {
|
|
121
|
-
snapshot = Object.freeze({ asking: Boolean(pending), refused, connected:
|
|
127
|
+
snapshot = Object.freeze({ asking: Boolean(pending), refused, connected: ready() });
|
|
122
128
|
for (const listener of [...listeners]) listener();
|
|
123
129
|
};
|
|
124
|
-
snapshot = Object.freeze({ asking: false, refused: false, connected:
|
|
130
|
+
snapshot = Object.freeze({ asking: false, refused: false, connected: ready() });
|
|
125
131
|
const answer = (key) => {
|
|
126
132
|
const value = String(key || "").trim();
|
|
127
|
-
if (value)
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
}
|
|
133
|
+
if (value) keep(current, value);
|
|
134
|
+
if (key !== null && (value || get() || /** @type {ProviderChoice} */
|
|
135
|
+
providers.find((provider) => provider.id === current).keyless)) refused = false;
|
|
131
136
|
const waiting = pending;
|
|
132
137
|
pending = null;
|
|
133
138
|
changed();
|
|
@@ -158,14 +163,27 @@ function createKeyStore(providerOrList, { fetch: fetchImpl } = {}) {
|
|
|
158
163
|
write(CHOICE_SLOT, id);
|
|
159
164
|
changed();
|
|
160
165
|
},
|
|
161
|
-
model: (id) =>
|
|
166
|
+
model: (id) => modelOf(
|
|
167
|
+
/** @type {ProviderChoice} */
|
|
168
|
+
providers.find((provider) => provider.id === providerId(id))
|
|
169
|
+
),
|
|
170
|
+
address: (id) => addressOf(
|
|
171
|
+
/** @type {ProviderChoice} */
|
|
172
|
+
providers.find((provider) => provider.id === providerId(id))
|
|
173
|
+
),
|
|
174
|
+
setAddress: (url, id) => {
|
|
162
175
|
const choice = (
|
|
163
176
|
/** @type {ProviderChoice} */
|
|
164
177
|
providers.find((provider) => provider.id === providerId(id))
|
|
165
178
|
);
|
|
166
|
-
|
|
167
|
-
|
|
179
|
+
if (!choice.needsAddress) throw new TypeError(`ai.key.setAddress: ${choice.name} has an address of its own`);
|
|
180
|
+
const value = cleanAddress(url);
|
|
181
|
+
if (value && !/^https?:\/\/[^\s/]+/i.test(value)) throw new TypeError("ai.key.setAddress: an http(s) address, e.g. http://localhost:1234/v1");
|
|
182
|
+
memory.set(ADDRESS_PREFIX + choice.id, value);
|
|
183
|
+
write(ADDRESS_PREFIX + choice.id, value);
|
|
184
|
+
changed();
|
|
168
185
|
},
|
|
186
|
+
ready,
|
|
169
187
|
chooseModel: (model, id) => {
|
|
170
188
|
if (typeof model !== "string" || !model.trim()) throw new TypeError("ai.key.chooseModel: a model id");
|
|
171
189
|
const choice = (
|
|
@@ -177,12 +195,16 @@ function createKeyStore(providerOrList, { fetch: fetchImpl } = {}) {
|
|
|
177
195
|
write(MODEL_PREFIX + choice.id, value);
|
|
178
196
|
changed();
|
|
179
197
|
},
|
|
180
|
-
listModels: async (id, { apiKey, signal } = {}) => {
|
|
181
|
-
const at = providers.findIndex((
|
|
198
|
+
listModels: async (id, { apiKey, address, signal } = {}) => {
|
|
199
|
+
const at = providers.findIndex((provider2) => provider2.id === providerId(id));
|
|
182
200
|
const key = String(apiKey ?? keyOf(providers[at].id)).trim();
|
|
201
|
+
const where = providers[at].needsAddress ? cleanAddress(address ?? addressOf(providers[at])) : "";
|
|
202
|
+
if (providers[at].needsAddress && !where) return [];
|
|
183
203
|
const slot = `${providers[at].id}
|
|
184
|
-
${key}
|
|
185
|
-
|
|
204
|
+
${key}
|
|
205
|
+
${where}`;
|
|
206
|
+
const provider = where ? { ...originals[at], baseUrl: where } : originals[at];
|
|
207
|
+
if (!lists.has(slot)) lists.set(slot, await listProviderModels({ provider, apiKey: key, fetch: fetchImpl, signal }));
|
|
186
208
|
return lists.get(slot);
|
|
187
209
|
},
|
|
188
210
|
request: () => {
|
|
@@ -200,7 +222,7 @@ ${key}`;
|
|
|
200
222
|
return () => listeners.delete(listener);
|
|
201
223
|
},
|
|
202
224
|
state: () => snapshot,
|
|
203
|
-
answer: (key) => answer(key
|
|
225
|
+
answer: (key) => answer(key ?? null),
|
|
204
226
|
refuse: () => {
|
|
205
227
|
keep(current, "");
|
|
206
228
|
refused = true;
|
|
@@ -211,16 +233,16 @@ ${key}`;
|
|
|
211
233
|
|
|
212
234
|
// src/ai/ConnectAi.jsx
|
|
213
235
|
import { useEffect as useEffect2, useId as useId2, useRef as useRef2, useState as useState2, useSyncExternalStore } from "react";
|
|
214
|
-
import { ArrowUpRight, Eye, EyeOff,
|
|
236
|
+
import { ArrowUpRight, Eye, EyeOff, KeyRound, ShieldCheck, TriangleAlert } from "lucide-react";
|
|
215
237
|
|
|
216
238
|
// src/ai/ModelPicker.jsx
|
|
217
239
|
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
|
218
|
-
import { Check, ChevronDown,
|
|
240
|
+
import { Check, ChevronDown, LoaderCircle, Search } from "lucide-react";
|
|
219
241
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
220
|
-
function ModelPicker({ aiKey, provider, apiKey, delay = 0, value, onChange, open, onOpenChange }) {
|
|
242
|
+
function ModelPicker({ aiKey, provider, apiKey, address = "", delay = 0, value, onChange, open, onOpenChange }) {
|
|
221
243
|
const { t } = useUi();
|
|
222
244
|
const [list, setList] = useState(
|
|
223
|
-
/** @type {{status: 'loading'|'ready'|'needs-key'|'refused'|'failed', models: import('./providers.js').ModelChoice[]}} */
|
|
245
|
+
/** @type {{status: 'loading'|'ready'|'needs-key'|'needs-address'|'refused'|'failed', models: import('./providers.js').ModelChoice[]}} */
|
|
224
246
|
{ status: "loading", models: [] }
|
|
225
247
|
);
|
|
226
248
|
const [query, setQuery] = useState("");
|
|
@@ -239,14 +261,18 @@ function ModelPicker({ aiKey, provider, apiKey, delay = 0, value, onChange, open
|
|
|
239
261
|
const listId = useId();
|
|
240
262
|
const hintId = useId();
|
|
241
263
|
useEffect(() => {
|
|
242
|
-
if (
|
|
264
|
+
if (provider.needsAddress && !address) {
|
|
265
|
+
setList({ status: "needs-address", models: [] });
|
|
266
|
+
return void 0;
|
|
267
|
+
}
|
|
268
|
+
if (!apiKey && !provider.publicModels && !provider.keyless) {
|
|
243
269
|
setList({ status: "needs-key", models: [] });
|
|
244
270
|
return void 0;
|
|
245
271
|
}
|
|
246
272
|
const controller = new AbortController();
|
|
247
273
|
setList({ status: "loading", models: [] });
|
|
248
274
|
const timer = setTimeout(() => {
|
|
249
|
-
aiKey.listModels(provider.id, { apiKey, signal: controller.signal }).then(
|
|
275
|
+
aiKey.listModels(provider.id, { apiKey, address, signal: controller.signal }).then(
|
|
250
276
|
(models2) => !controller.signal.aborted && setList({ status: "ready", models: models2 }),
|
|
251
277
|
(error) => !controller.signal.aborted && setList({ status: error?.code === "KEY_REFUSED" ? "refused" : "failed", models: [] })
|
|
252
278
|
);
|
|
@@ -255,7 +281,7 @@ function ModelPicker({ aiKey, provider, apiKey, delay = 0, value, onChange, open
|
|
|
255
281
|
clearTimeout(timer);
|
|
256
282
|
controller.abort();
|
|
257
283
|
};
|
|
258
|
-
}, [aiKey, provider.id, provider.publicModels, apiKey, delay]);
|
|
284
|
+
}, [aiKey, provider.id, provider.publicModels, provider.keyless, provider.needsAddress, apiKey, address, delay]);
|
|
259
285
|
useEffect(() => {
|
|
260
286
|
if (!open) return;
|
|
261
287
|
setQuery("");
|
|
@@ -305,10 +331,7 @@ function ModelPicker({ aiKey, provider, apiKey, delay = 0, value, onChange, open
|
|
|
305
331
|
};
|
|
306
332
|
const optionId = (at) => `${listId}-${at}`;
|
|
307
333
|
const badges = (model) => /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
308
|
-
model.free && /* @__PURE__ */
|
|
309
|
-
/* @__PURE__ */ jsx(Gift, { className: "eb:size-3", "aria-hidden": "true" }),
|
|
310
|
-
t("Free")
|
|
311
|
-
] }),
|
|
334
|
+
model.free && /* @__PURE__ */ jsx("span", { className: "eb:shrink-0 eb:rounded-full eb:bg-success/10 eb:px-1.5 eb:py-px eb:text-[10.5px] eb:font-medium eb:text-success", children: t("Free") }),
|
|
312
335
|
model.id === provider.model && /* @__PURE__ */ jsx("span", { className: "eb:shrink-0 eb:rounded-full eb:bg-muted eb:px-1.5 eb:py-px eb:text-[10.5px] eb:font-medium eb:text-foreground-lighter", children: t("Default") })
|
|
313
336
|
] });
|
|
314
337
|
const status = {
|
|
@@ -317,8 +340,9 @@ function ModelPicker({ aiKey, provider, apiKey, delay = 0, value, onChange, open
|
|
|
317
340
|
t("Loading the models of {provider}\u2026", { provider: provider.name })
|
|
318
341
|
] }),
|
|
319
342
|
"needs-key": t("Paste the key to see all the models of {provider}.", { provider: provider.name }),
|
|
343
|
+
"needs-address": t("Give the API address to see its models."),
|
|
320
344
|
refused: /* @__PURE__ */ jsx("span", { className: "eb:text-warning", children: t("{provider} refused this key: its models could not be listed.", { provider: provider.name }) }),
|
|
321
|
-
failed: t("The list of models could not be loaded. The default model works, or type the id of another."),
|
|
345
|
+
failed: provider.model ? t("The list of models could not be loaded. The default model works, or type the id of another.") : t("The list of models could not be loaded. Type the id of the model."),
|
|
322
346
|
ready: list.models.length ? hasFree ? t("{count} models, the free ones first.", { count: list.models.length }) : t("{count} models.", { count: list.models.length }) : t("{provider} lists no models here: type the id of the one you want.", { provider: provider.name })
|
|
323
347
|
}[list.status];
|
|
324
348
|
return /* @__PURE__ */ jsxs("div", { className: "eb:flex eb:flex-col eb:gap-1.5", "data-testid": "eb-ai-connect-model-field", children: [
|
|
@@ -379,20 +403,17 @@ function ModelPicker({ aiKey, provider, apiKey, delay = 0, value, onChange, open
|
|
|
379
403
|
}
|
|
380
404
|
)
|
|
381
405
|
] }),
|
|
382
|
-
hasFree && /* @__PURE__ */
|
|
406
|
+
hasFree && /* @__PURE__ */ jsx(
|
|
383
407
|
"button",
|
|
384
408
|
{
|
|
385
409
|
type: "button",
|
|
386
410
|
"aria-pressed": freeOnly,
|
|
387
411
|
onClick: () => setFreeOnly((on) => !on),
|
|
388
412
|
className: cn(
|
|
389
|
-
"eb:inline-flex eb:h-8 eb:shrink-0 eb:items-center eb:
|
|
413
|
+
"eb:inline-flex eb:h-8 eb:shrink-0 eb:items-center eb:rounded-md eb:border eb:px-2.5 eb:text-[12px] eb:font-medium eb:transition-colors eb:outline-none eb:focus-visible:ring-[3px] eb:focus-visible:ring-ring/50",
|
|
390
414
|
freeOnly ? "eb:border-success/40 eb:bg-success/10 eb:text-success" : "eb:border-border eb:text-foreground-lighter eb:hover:bg-muted"
|
|
391
415
|
),
|
|
392
|
-
children:
|
|
393
|
-
/* @__PURE__ */ jsx(Gift, { className: "eb:size-3.5", "aria-hidden": "true" }),
|
|
394
|
-
t("Free only")
|
|
395
|
-
]
|
|
416
|
+
children: t("Free only")
|
|
396
417
|
}
|
|
397
418
|
)
|
|
398
419
|
] }),
|
|
@@ -460,10 +481,12 @@ function ModelPicker({ aiKey, provider, apiKey, delay = 0, value, onChange, open
|
|
|
460
481
|
// src/ai/ConnectAi.jsx
|
|
461
482
|
import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
462
483
|
var LISTABLE_KEY = 20;
|
|
484
|
+
var ADDRESS = /^https?:\/\/[^\s/]+/i;
|
|
463
485
|
function ConnectAi({ aiKey }) {
|
|
464
486
|
const { t } = useUi();
|
|
465
487
|
const state = useSyncExternalStore(aiKey.subscribe, aiKey.state, aiKey.state);
|
|
466
488
|
const [value, setValue] = useState2("");
|
|
489
|
+
const [address, setAddress] = useState2("");
|
|
467
490
|
const [shown, setShown] = useState2(false);
|
|
468
491
|
const [chosenId, setChosenId] = useState2(aiKey.provider.id);
|
|
469
492
|
const [picks, setPicks] = useState2(
|
|
@@ -475,18 +498,25 @@ function ConnectAi({ aiKey }) {
|
|
|
475
498
|
/** @type {HTMLInputElement|null} */
|
|
476
499
|
null
|
|
477
500
|
);
|
|
501
|
+
const addressRef = useRef2(
|
|
502
|
+
/** @type {HTMLInputElement|null} */
|
|
503
|
+
null
|
|
504
|
+
);
|
|
478
505
|
const choicesRef = useRef2(
|
|
479
506
|
/** @type {HTMLDivElement|null} */
|
|
480
507
|
null
|
|
481
508
|
);
|
|
482
509
|
const inputId = useId2();
|
|
483
510
|
const hintId = useId2();
|
|
511
|
+
const addressId = useId2();
|
|
512
|
+
const addressHintId = useId2();
|
|
484
513
|
const choices = aiKey.providers;
|
|
485
514
|
const chosen = choices.find((provider) => provider.id === chosenId) || aiKey.provider;
|
|
486
|
-
const { name, keyUrl, keyHint, free } = chosen;
|
|
515
|
+
const { name, keyUrl, keyHint, free, keyless, needsAddress } = chosen;
|
|
487
516
|
useEffect2(() => {
|
|
488
517
|
if (!state.asking) return;
|
|
489
518
|
setChosenId(aiKey.provider.id);
|
|
519
|
+
setAddress(aiKey.address());
|
|
490
520
|
setValue("");
|
|
491
521
|
setShown(false);
|
|
492
522
|
setPicks({});
|
|
@@ -497,16 +527,20 @@ function ConnectAi({ aiKey }) {
|
|
|
497
527
|
const unusual = Boolean(key && keyHint && !key.startsWith(keyHint));
|
|
498
528
|
const refused = state.refused && chosen.id === aiKey.provider.id;
|
|
499
529
|
const modelFor = (id) => picks[id] ?? aiKey.model(id);
|
|
530
|
+
const where = address.trim().replace(/\/+$/, "");
|
|
531
|
+
const addressOk = !needsAddress || ADDRESS.test(where);
|
|
532
|
+
const ready = Boolean((key || saved || keyless) && (!needsAddress || where && addressOk) && modelFor(chosen.id));
|
|
500
533
|
const submit = (event) => {
|
|
501
534
|
event.preventDefault();
|
|
502
|
-
|
|
503
|
-
if (
|
|
535
|
+
if (!ready) return;
|
|
536
|
+
if (needsAddress && where !== aiKey.address(chosen.id)) aiKey.setAddress(where, chosen.id);
|
|
504
537
|
if (chosen.id !== aiKey.provider.id) aiKey.choose(chosen.id);
|
|
505
538
|
if (picks[chosen.id] && picks[chosen.id] !== aiKey.model(chosen.id)) aiKey.chooseModel(picks[chosen.id], chosen.id);
|
|
506
|
-
aiKey.answer(
|
|
539
|
+
aiKey.answer(key || saved);
|
|
507
540
|
};
|
|
508
541
|
const choose = (id) => {
|
|
509
542
|
setChosenId(id);
|
|
543
|
+
setAddress(aiKey.address(id));
|
|
510
544
|
setValue("");
|
|
511
545
|
setModelsOpen(false);
|
|
512
546
|
};
|
|
@@ -526,7 +560,7 @@ function ConnectAi({ aiKey }) {
|
|
|
526
560
|
"data-testid": "eb-ai-connect",
|
|
527
561
|
onOpenAutoFocus: (event) => {
|
|
528
562
|
event.preventDefault();
|
|
529
|
-
inputRef.current?.focus();
|
|
563
|
+
(needsAddress && !where ? addressRef : inputRef).current?.focus();
|
|
530
564
|
},
|
|
531
565
|
onEscapeKeyDown: (event) => {
|
|
532
566
|
if (!modelsOpen) return;
|
|
@@ -539,7 +573,7 @@ function ConnectAi({ aiKey }) {
|
|
|
539
573
|
/* @__PURE__ */ jsx2("span", { className: "eb:flex eb:size-10 eb:shrink-0 eb:items-center eb:justify-center eb:rounded-full eb:bg-primary/10 eb:text-primary", children: /* @__PURE__ */ jsx2(KeyRound, { className: "eb:size-5", "aria-hidden": "true" }) }),
|
|
540
574
|
/* @__PURE__ */ jsxs2("div", { className: "eb:flex eb:flex-col eb:gap-1.5", children: [
|
|
541
575
|
/* @__PURE__ */ jsx2(DialogTitle, { children: t("Connect {provider}", { provider: name }) }),
|
|
542
|
-
/* @__PURE__ */ jsx2(DialogDescription, { className: "eb:text-[13px] eb:leading-relaxed eb:text-foreground-lighter", children: t("The AI writes with your own {provider} key. Connect it once: this browser remembers it.", { provider: name }) })
|
|
576
|
+
/* @__PURE__ */ jsx2(DialogDescription, { className: "eb:text-[13px] eb:leading-relaxed eb:text-foreground-lighter", children: needsAddress ? t("The AI writes with an OpenAI-compatible API of your choice. Give its address, and its key if it needs one: this browser remembers them.") : t("The AI writes with your own {provider} key. Connect it once: this browser remembers it.", { provider: name }) })
|
|
543
577
|
] })
|
|
544
578
|
] }),
|
|
545
579
|
choices.length > 1 && /* @__PURE__ */ jsx2(
|
|
@@ -568,7 +602,6 @@ function ConnectAi({ aiKey }) {
|
|
|
568
602
|
children: [
|
|
569
603
|
/* @__PURE__ */ jsxs2("span", { className: "eb:flex eb:items-center eb:gap-1.5 eb:text-[13px] eb:leading-tight eb:font-medium eb:text-foreground", children: [
|
|
570
604
|
/* @__PURE__ */ jsx2("span", { className: "eb:truncate", children: provider.name }),
|
|
571
|
-
provider.free && /* @__PURE__ */ jsx2(Gift2, { className: "eb:size-3 eb:shrink-0 eb:text-success", "aria-label": t(provider.free) }),
|
|
572
605
|
aiKey.get(provider.id) && /* @__PURE__ */ jsx2("span", { className: "eb:ml-auto eb:size-1.5 eb:shrink-0 eb:rounded-full eb:bg-success", title: t("Key saved"), "aria-label": t("Key saved") })
|
|
573
606
|
] }),
|
|
574
607
|
modelFor(provider.id) && /* @__PURE__ */ jsx2("span", { className: "eb:truncate eb:font-mono eb:text-[10.5px] eb:text-foreground-muted", title: modelFor(provider.id), children: modelFor(provider.id) })
|
|
@@ -601,13 +634,34 @@ function ConnectAi({ aiKey }) {
|
|
|
601
634
|
]
|
|
602
635
|
}
|
|
603
636
|
) }),
|
|
604
|
-
free && /* @__PURE__ */
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
637
|
+
free && /* @__PURE__ */ jsx2("span", { className: "eb:rounded-full eb:bg-success/10 eb:px-2.5 eb:py-1 eb:text-[11.5px] eb:font-medium eb:text-success", children: t(free) })
|
|
638
|
+
] }),
|
|
639
|
+
needsAddress && /* @__PURE__ */ jsxs2("div", { className: "eb:flex eb:flex-col eb:gap-1.5", children: [
|
|
640
|
+
/* @__PURE__ */ jsx2(Label, { htmlFor: addressId, children: t("API address") }),
|
|
641
|
+
/* @__PURE__ */ jsx2(
|
|
642
|
+
Input,
|
|
643
|
+
{
|
|
644
|
+
ref: addressRef,
|
|
645
|
+
id: addressId,
|
|
646
|
+
type: "url",
|
|
647
|
+
inputMode: "url",
|
|
648
|
+
value: address,
|
|
649
|
+
onChange: (event) => setAddress(event.target.value),
|
|
650
|
+
placeholder: "http://localhost:1234/v1",
|
|
651
|
+
autoComplete: "url",
|
|
652
|
+
autoCapitalize: "off",
|
|
653
|
+
autoCorrect: "off",
|
|
654
|
+
spellCheck: false,
|
|
655
|
+
"aria-invalid": where && !addressOk ? true : void 0,
|
|
656
|
+
"aria-describedby": addressHintId,
|
|
657
|
+
className: "eb:h-9 eb:font-mono eb:text-[12.5px]",
|
|
658
|
+
"data-testid": "eb-ai-connect-address"
|
|
659
|
+
}
|
|
660
|
+
),
|
|
661
|
+
/* @__PURE__ */ jsx2("p", { id: addressHintId, className: "eb:text-[11.5px] eb:leading-relaxed eb:text-foreground-muted", children: where && !addressOk ? /* @__PURE__ */ jsx2("span", { className: "eb:text-warning", children: t("An address starts with http:// or https://.") }) : t("Any API that speaks OpenAI: LM Studio, vLLM, Ollama, a proxy of your own\u2026 It must accept requests from this page (CORS).") })
|
|
608
662
|
] }),
|
|
609
663
|
/* @__PURE__ */ jsxs2("div", { className: "eb:flex eb:flex-col eb:gap-1.5", children: [
|
|
610
|
-
/* @__PURE__ */ jsx2(Label, { htmlFor: inputId, children: t("API key") }),
|
|
664
|
+
/* @__PURE__ */ jsx2(Label, { htmlFor: inputId, children: keyless ? t("API key (optional)") : t("API key") }),
|
|
611
665
|
/* @__PURE__ */ jsxs2("div", { className: "eb:relative", children: [
|
|
612
666
|
/* @__PURE__ */ jsx2(
|
|
613
667
|
Input,
|
|
@@ -639,15 +693,16 @@ function ConnectAi({ aiKey }) {
|
|
|
639
693
|
}
|
|
640
694
|
)
|
|
641
695
|
] }),
|
|
642
|
-
/* @__PURE__ */ jsx2("p", { id: hintId, className: "eb:min-h-4 eb:text-[11.5px] eb:text-foreground-muted", children: unusual ? /* @__PURE__ */ jsx2("span", { className: "eb:text-warning", children: t("{provider} keys usually start with {prefix}.", { provider: name, prefix: keyHint }) }) : saved && !key ? t("A key for {provider} is saved in this browser: Connect uses it, or paste another one.", { provider: name }) : keyHint ? t("{provider} keys start with {prefix}.", { provider: name, prefix: keyHint }) : null })
|
|
696
|
+
/* @__PURE__ */ jsx2("p", { id: hintId, className: "eb:min-h-4 eb:text-[11.5px] eb:text-foreground-muted", children: unusual ? /* @__PURE__ */ jsx2("span", { className: "eb:text-warning", children: t("{provider} keys usually start with {prefix}.", { provider: name, prefix: keyHint }) }) : saved && !key ? t("A key for {provider} is saved in this browser: Connect uses it, or paste another one.", { provider: name }) : keyHint ? t("{provider} keys start with {prefix}.", { provider: name, prefix: keyHint }) : keyless ? t("Leave it empty if the API needs no key.") : null })
|
|
643
697
|
] }),
|
|
644
698
|
/* @__PURE__ */ jsx2(
|
|
645
699
|
ModelPicker,
|
|
646
700
|
{
|
|
647
701
|
aiKey,
|
|
648
702
|
provider: chosen,
|
|
649
|
-
apiKey: key.length >= LISTABLE_KEY ? key : saved,
|
|
650
|
-
|
|
703
|
+
apiKey: key.length >= LISTABLE_KEY || keyless && key ? key : saved,
|
|
704
|
+
address: addressOk ? where : "",
|
|
705
|
+
delay: key.length >= LISTABLE_KEY || keyless && key || where !== aiKey.address(chosen.id) ? 350 : 0,
|
|
651
706
|
value: modelFor(chosen.id),
|
|
652
707
|
onChange: (model) => setPicks((current) => ({ ...current, [chosen.id]: model })),
|
|
653
708
|
open: modelsOpen,
|
|
@@ -657,53 +712,56 @@ function ConnectAi({ aiKey }) {
|
|
|
657
712
|
] }),
|
|
658
713
|
/* @__PURE__ */ jsxs2("div", { className: "eb:flex eb:items-start eb:gap-2 eb:border-t eb:border-border eb:bg-surface-75 eb:px-6 eb:py-3 eb:text-[11.5px] eb:leading-relaxed eb:text-foreground-lighter", children: [
|
|
659
714
|
/* @__PURE__ */ jsx2(ShieldCheck, { className: "eb:mt-0.5 eb:size-3.5 eb:shrink-0 eb:text-foreground-muted", "aria-hidden": "true" }),
|
|
660
|
-
/* @__PURE__ */ jsx2("span", { children: t("The key stays in this browser and is sent only to {provider}.", { provider: name }) })
|
|
715
|
+
/* @__PURE__ */ jsx2("span", { children: needsAddress ? t("The address and the key stay in this browser; the key is sent only to that address.") : t("The key stays in this browser and is sent only to {provider}.", { provider: name }) })
|
|
661
716
|
] }),
|
|
662
717
|
/* @__PURE__ */ jsxs2(DialogFooter, { className: "eb:border-t eb:border-border eb:px-6 eb:py-4", children: [
|
|
663
718
|
/* @__PURE__ */ jsx2(Button, { type: "button", variant: "ghost", onClick: () => aiKey.answer(null), children: t("Cancel") }),
|
|
664
|
-
/* @__PURE__ */ jsx2(Button, { type: "submit", disabled: !
|
|
719
|
+
/* @__PURE__ */ jsx2(Button, { type: "submit", disabled: !ready, "data-testid": "eb-ai-connect-submit", children: t("Connect") })
|
|
665
720
|
] })
|
|
666
721
|
] })
|
|
667
722
|
}
|
|
668
723
|
) });
|
|
669
724
|
}
|
|
670
725
|
|
|
671
|
-
// src/ai/
|
|
672
|
-
import { useEffect as useEffect3,
|
|
673
|
-
import { Check as Check2,
|
|
674
|
-
|
|
675
|
-
// src/ai/chip.js
|
|
676
|
-
var chipClassName = (selected = false) => cn(
|
|
677
|
-
"eb:inline-flex eb:h-7 eb:items-center eb:gap-1 eb:rounded-full eb:border eb:px-3 eb:text-[12px] eb:font-medium eb:transition-colors eb:outline-none eb:focus-visible:ring-[3px] eb:focus-visible:ring-ring/50 eb:disabled:opacity-50",
|
|
678
|
-
selected ? "eb:border-primary eb:bg-primary eb:text-primary-foreground" : "eb:border-border eb:bg-background eb:text-foreground eb:hover:border-primary/50 eb:hover:bg-primary/5"
|
|
679
|
-
);
|
|
680
|
-
|
|
681
|
-
// src/ai/GenerateButton.jsx
|
|
726
|
+
// src/ai/ProofreadButton.jsx
|
|
727
|
+
import { useEffect as useEffect3, useRef as useRef3, useState as useState3 } from "react";
|
|
728
|
+
import { Check as Check2, CircleCheck, Loader2, SpellCheck, TriangleAlert as TriangleAlert2 } from "lucide-react";
|
|
682
729
|
import { Fragment as Fragment2, jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
|
|
683
|
-
var
|
|
684
|
-
|
|
685
|
-
|
|
730
|
+
var LABEL_TAGS = { "mj-button": "button", "mj-navbar-link": "link" };
|
|
731
|
+
var KIND_LABELS = { subject: "Subject", preheader: "Preview text", text: "Text", button: "Button", link: "Menu link" };
|
|
732
|
+
function textsOf(editor, meta) {
|
|
733
|
+
const texts = [];
|
|
734
|
+
if (meta.subject.trim()) texts.push({ id: "subject", kind: "subject", format: "plain", text: meta.subject.trim() });
|
|
735
|
+
if (meta.preheader.trim()) texts.push({ id: "preheader", kind: "preheader", format: "plain", text: meta.preheader.trim() });
|
|
736
|
+
const walk = (node) => {
|
|
737
|
+
if (!node || typeof node !== "object") return;
|
|
738
|
+
const element = node.tag === "mj-text" || LABEL_TAGS[node.tag] ? editor.getElement(node.id) : null;
|
|
739
|
+
if (node.tag === "mj-text" && element?.html) texts.push({ id: node.id, kind: "text", format: "html", text: element.html });
|
|
740
|
+
else if (LABEL_TAGS[node.tag] && element?.content) texts.push({ id: node.id, kind: LABEL_TAGS[node.tag], format: "plain", text: element.content });
|
|
741
|
+
for (const child of node.children || []) walk(child);
|
|
742
|
+
};
|
|
743
|
+
walk(editor.getDocument().project?.root);
|
|
744
|
+
return texts;
|
|
745
|
+
}
|
|
746
|
+
function currentText(editor, meta, id) {
|
|
747
|
+
if (id === "subject" || id === "preheader") return meta[id].trim();
|
|
748
|
+
const element = editor.getElement(id);
|
|
749
|
+
return (element?.tagName === "mj-text" ? element?.html : element?.content) ?? null;
|
|
750
|
+
}
|
|
751
|
+
function ProofreadButton({ context, proofread }) {
|
|
752
|
+
const { editor, ready, meta, setMeta } = context;
|
|
686
753
|
const { t, locale, notify } = useUi();
|
|
687
754
|
const [open, setOpen] = useState3(false);
|
|
688
|
-
const [purpose, setPurpose] = useState3(
|
|
689
|
-
/** @type {string|null} */
|
|
690
|
-
null
|
|
691
|
-
);
|
|
692
|
-
const [details, setDetails] = useState3("");
|
|
693
|
-
const [query, setQuery] = useState3("");
|
|
694
755
|
const [busy, setBusy] = useState3(false);
|
|
695
756
|
const [error, setError] = useState3("");
|
|
696
757
|
const [seconds, setSeconds] = useState3(0);
|
|
697
|
-
const [
|
|
758
|
+
const [fixes, setFixes] = useState3(null);
|
|
698
759
|
const abortRef = useRef3(
|
|
699
760
|
/** @type {AbortController|null} */
|
|
700
761
|
null
|
|
701
762
|
);
|
|
702
|
-
const
|
|
703
|
-
|
|
704
|
-
null
|
|
705
|
-
);
|
|
706
|
-
useSearchShortcut(searchRef);
|
|
763
|
+
const metaRef = useRef3(meta);
|
|
764
|
+
metaRef.current = meta;
|
|
707
765
|
useEffect3(() => () => abortRef.current?.abort(), []);
|
|
708
766
|
useEffect3(() => {
|
|
709
767
|
if (!busy) return void 0;
|
|
@@ -711,168 +769,178 @@ function GenerateButton({ context, generate: generateEmail }) {
|
|
|
711
769
|
const timer = setInterval(() => setSeconds((s) => s + 1), 1e3);
|
|
712
770
|
return () => clearInterval(timer);
|
|
713
771
|
}, [busy]);
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
const openDialog = () => {
|
|
725
|
-
setError("");
|
|
726
|
-
setDirty(isDirty());
|
|
727
|
-
setOpen(true);
|
|
728
|
-
};
|
|
729
|
-
const close = (next) => {
|
|
730
|
-
if (next) return;
|
|
731
|
-
abortRef.current?.abort();
|
|
732
|
-
setOpen(false);
|
|
733
|
-
};
|
|
734
|
-
const generate = async () => {
|
|
735
|
-
if (busy || !purpose && !details.trim()) return;
|
|
772
|
+
useEffect3(
|
|
773
|
+
() => editor.on("load", () => {
|
|
774
|
+
abortRef.current?.abort();
|
|
775
|
+
setOpen(false);
|
|
776
|
+
}),
|
|
777
|
+
[editor]
|
|
778
|
+
);
|
|
779
|
+
const run = async () => {
|
|
780
|
+
if (busy) return;
|
|
781
|
+
const texts = textsOf(editor, metaRef.current);
|
|
736
782
|
setError("");
|
|
783
|
+
setFixes(null);
|
|
784
|
+
if (!texts.length) {
|
|
785
|
+
setError(t("The email has no text to check yet."));
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
737
788
|
setBusy(true);
|
|
738
789
|
const controller = new AbortController();
|
|
739
790
|
abortRef.current = controller;
|
|
740
791
|
try {
|
|
741
|
-
const
|
|
742
|
-
{
|
|
743
|
-
purpose,
|
|
744
|
-
details: details.trim(),
|
|
745
|
-
language: String(locale || "en").slice(0, 2),
|
|
746
|
-
sender: sender || {},
|
|
747
|
-
...hasBrand(brand) ? { brand: { primary: brand.primary, heading: brand.heading, font: brand.font } } : {},
|
|
748
|
-
variables: (variables || []).map((variable) => ({ key: variable.key, label: variable.label }))
|
|
749
|
-
},
|
|
792
|
+
const result = await proofread(
|
|
793
|
+
{ texts: texts.map(({ id, kind, format, text }) => ({ id, kind, format, text })), language: String(locale || "en").slice(0, 2) },
|
|
750
794
|
{ signal: controller.signal }
|
|
751
795
|
);
|
|
752
796
|
if (controller.signal.aborted) return;
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
797
|
+
const sent = new Map(texts.map((text) => [text.id, text]));
|
|
798
|
+
setFixes(
|
|
799
|
+
result.fixes.filter((fix) => sent.has(fix.id)).map((fix) => {
|
|
800
|
+
const text = (
|
|
801
|
+
/** @type {(typeof texts)[number]} */
|
|
802
|
+
sent.get(fix.id)
|
|
803
|
+
);
|
|
804
|
+
const stale = currentText(editor, metaRef.current, fix.id) !== text.text;
|
|
805
|
+
return { id: fix.id, kind: text.kind, format: text.format, before: text.text, after: fix.text, state: stale ? "stale" : "open" };
|
|
806
|
+
})
|
|
807
|
+
);
|
|
763
808
|
} catch (err) {
|
|
764
|
-
if (!controller.signal.aborted) setError(err?.message ? t(err.message) : t("The email could not be
|
|
809
|
+
if (!controller.signal.aborted) setError(err?.message ? t(err.message) : t("The email could not be checked."));
|
|
765
810
|
} finally {
|
|
766
|
-
if (abortRef.current === controller)
|
|
767
|
-
|
|
811
|
+
if (abortRef.current === controller) {
|
|
812
|
+
abortRef.current = null;
|
|
813
|
+
setBusy(false);
|
|
814
|
+
}
|
|
768
815
|
}
|
|
769
816
|
};
|
|
770
|
-
|
|
817
|
+
useEffect3(() => {
|
|
818
|
+
if (!open) return void 0;
|
|
819
|
+
run();
|
|
820
|
+
return () => abortRef.current?.abort();
|
|
821
|
+
}, [open]);
|
|
822
|
+
const openDialog = () => setOpen(true);
|
|
823
|
+
const close = (next) => {
|
|
824
|
+
if (next) return;
|
|
771
825
|
abortRef.current?.abort();
|
|
826
|
+
abortRef.current = null;
|
|
772
827
|
setBusy(false);
|
|
828
|
+
setOpen(false);
|
|
773
829
|
};
|
|
774
|
-
const
|
|
830
|
+
const apply = (ids) => {
|
|
831
|
+
if (!fixes) return;
|
|
832
|
+
const wanted = new Set(ids);
|
|
833
|
+
const contents = [];
|
|
834
|
+
const metaChanges = {};
|
|
835
|
+
const next = fixes.map((fix) => {
|
|
836
|
+
if (!wanted.has(fix.id) || fix.state !== "open") return fix;
|
|
837
|
+
if (currentText(editor, metaRef.current, fix.id) !== fix.before) return { ...fix, state: (
|
|
838
|
+
/** @type {const} */
|
|
839
|
+
"stale"
|
|
840
|
+
) };
|
|
841
|
+
if (fix.id === "subject" || fix.id === "preheader") metaChanges[fix.id] = fix.after;
|
|
842
|
+
else contents.push({ id: fix.id, content: fix.format === "html" ? cleanRichText(fix.after, document) : fix.after });
|
|
843
|
+
return { ...fix, state: (
|
|
844
|
+
/** @type {const} */
|
|
845
|
+
"applied"
|
|
846
|
+
) };
|
|
847
|
+
});
|
|
848
|
+
if (contents.length) editor.setContents(contents);
|
|
849
|
+
if (Object.keys(metaChanges).length) setMeta((current) => ({ ...current, ...metaChanges }));
|
|
850
|
+
setFixes(next);
|
|
851
|
+
const applied = next.filter((fix, index) => fix.state === "applied" && fixes[index].state !== "applied").length;
|
|
852
|
+
if (applied && next.every((fix) => fix.state !== "open")) {
|
|
853
|
+
setOpen(false);
|
|
854
|
+
notify("success", t("Corrections applied: {count}.", { count: next.filter((fix) => fix.state === "applied").length }));
|
|
855
|
+
}
|
|
856
|
+
};
|
|
857
|
+
const remaining = (fixes || []).filter((fix) => fix.state === "open");
|
|
775
858
|
return /* @__PURE__ */ jsxs3(Fragment2, { children: [
|
|
776
859
|
/* @__PURE__ */ jsxs3(Button, { variant: "outline", size: "sm-compact", disabled: !ready, onClick: openDialog, children: [
|
|
777
|
-
/* @__PURE__ */ jsx3(
|
|
778
|
-
t("
|
|
860
|
+
/* @__PURE__ */ jsx3(SpellCheck, {}),
|
|
861
|
+
t("Proofread")
|
|
779
862
|
] }),
|
|
780
|
-
/* @__PURE__ */ jsx3(Dialog, { open, onOpenChange: close, children: /* @__PURE__ */ jsxs3(
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
className: "eb:
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
/* @__PURE__ */ jsx3(
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
]
|
|
831
|
-
},
|
|
832
|
-
entry.id
|
|
833
|
-
);
|
|
834
|
-
}) })
|
|
835
|
-
] }, group.id)) })
|
|
836
|
-
] }) }),
|
|
837
|
-
/* @__PURE__ */ jsxs3("div", { className: "eb:flex eb:flex-col eb:gap-3 eb:px-6 eb:pt-3 eb:pb-6", children: [
|
|
838
|
-
/* @__PURE__ */ jsx3(
|
|
839
|
-
Textarea,
|
|
840
|
-
{
|
|
841
|
-
value: details,
|
|
842
|
-
onChange: (event) => setDetails(event.target.value.slice(0, MAX_DETAILS_LENGTH)),
|
|
843
|
-
placeholder: t("Details (optional): product, dates, offer, tone, language\u2026"),
|
|
844
|
-
"aria-label": t("Details (optional)"),
|
|
845
|
-
rows: 2,
|
|
846
|
-
className: "eb:min-h-14",
|
|
847
|
-
disabled: busy,
|
|
848
|
-
onKeyDown: (event) => {
|
|
849
|
-
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) generate();
|
|
850
|
-
}
|
|
851
|
-
}
|
|
852
|
-
),
|
|
853
|
-
error ? /* @__PURE__ */ jsxs3("p", { role: "alert", className: "eb:flex eb:items-start eb:gap-1.5 eb:text-[12px] eb:text-destructive", children: [
|
|
854
|
-
/* @__PURE__ */ jsx3(TriangleAlert2, { className: "eb:mt-px eb:size-3.5 eb:shrink-0", "aria-hidden": "true" }),
|
|
855
|
-
error
|
|
856
|
-
] }) : dirty && !busy && /* @__PURE__ */ jsx3("p", { className: "eb:text-[12px] eb:text-foreground-muted", children: t("The email being edited will be replaced, unsaved changes included.") }),
|
|
857
|
-
/* @__PURE__ */ jsxs3("div", { className: "eb:flex eb:items-center eb:justify-end eb:gap-2", children: [
|
|
858
|
-
busy && /* @__PURE__ */ jsx3("p", { className: "eb:mr-auto eb:text-[12px] eb:text-foreground-muted", role: "status", "aria-live": "polite", children: t("Writing your email\u2026 {seconds} s", { seconds }) }),
|
|
859
|
-
busy && /* @__PURE__ */ jsx3(Button, { variant: "outline", size: "sm", onClick: cancel, children: t("Cancel") }),
|
|
860
|
-
/* @__PURE__ */ jsxs3(Button, { size: "sm", onClick: generate, disabled: !canGenerate || busy, "data-testid": "eb-generate-submit", children: [
|
|
861
|
-
busy ? /* @__PURE__ */ jsx3(Loader2, { className: "eb:animate-spin" }) : /* @__PURE__ */ jsx3(Sparkles, {}),
|
|
862
|
-
t("Generate")
|
|
863
|
-
] })
|
|
864
|
-
] })
|
|
865
|
-
] })
|
|
866
|
-
]
|
|
867
|
-
}
|
|
868
|
-
) })
|
|
863
|
+
/* @__PURE__ */ jsx3(Dialog, { open, onOpenChange: close, children: /* @__PURE__ */ jsxs3(DialogContent, { className: "eb:flex eb:max-h-[calc(100dvh-2rem)] eb:flex-col eb:gap-0 eb:p-0 eb:sm:max-w-xl", "data-testid": "eb-proofread", children: [
|
|
864
|
+
/* @__PURE__ */ jsxs3(DialogHeader, { className: "eb:gap-1.5 eb:px-6 eb:pt-6 eb:pb-4", children: [
|
|
865
|
+
/* @__PURE__ */ jsx3(DialogTitle, { children: t("Proofread the email") }),
|
|
866
|
+
/* @__PURE__ */ jsx3(DialogDescription, { children: t("AI looks for spelling, grammar and punctuation mistakes in every text. Nothing changes until you apply it.") })
|
|
867
|
+
] }),
|
|
868
|
+
/* @__PURE__ */ jsxs3("div", { className: "eb:min-h-0 eb:flex-1 eb:overflow-y-auto eb:border-t eb:border-border eb:px-6 eb:py-4", children: [
|
|
869
|
+
busy && /* @__PURE__ */ jsxs3("p", { className: "eb:flex eb:items-center eb:gap-2 eb:py-6 eb:text-[13px] eb:text-foreground-muted", role: "status", "aria-live": "polite", children: [
|
|
870
|
+
/* @__PURE__ */ jsx3(Loader2, { className: "eb:size-4 eb:animate-spin", "aria-hidden": "true" }),
|
|
871
|
+
t("Reading the email\u2026 {seconds} s", { seconds })
|
|
872
|
+
] }),
|
|
873
|
+
error && /* @__PURE__ */ jsxs3("p", { role: "alert", className: "eb:flex eb:items-start eb:gap-1.5 eb:py-2 eb:text-[13px] eb:text-destructive", children: [
|
|
874
|
+
/* @__PURE__ */ jsx3(TriangleAlert2, { className: "eb:mt-0.5 eb:size-3.5 eb:shrink-0", "aria-hidden": "true" }),
|
|
875
|
+
error
|
|
876
|
+
] }),
|
|
877
|
+
fixes && !fixes.length && /* @__PURE__ */ jsxs3("p", { className: "eb:flex eb:items-center eb:gap-2 eb:py-6 eb:text-[13px] eb:text-foreground", role: "status", children: [
|
|
878
|
+
/* @__PURE__ */ jsx3(CircleCheck, { className: "eb:size-4 eb:text-success", "aria-hidden": "true" }),
|
|
879
|
+
t("No mistake found.")
|
|
880
|
+
] }),
|
|
881
|
+
fixes && fixes.length > 0 && /* @__PURE__ */ jsx3("ul", { className: "eb:flex eb:flex-col eb:gap-2.5", "aria-label": t("Corrections"), children: fixes.map((fix) => /* @__PURE__ */ jsxs3(
|
|
882
|
+
"li",
|
|
883
|
+
{
|
|
884
|
+
className: cn("eb:rounded-md eb:border eb:border-border eb:p-3", fix.state !== "open" && "eb:bg-muted/40"),
|
|
885
|
+
"data-testid": "eb-proofread-fix",
|
|
886
|
+
"data-state": fix.state,
|
|
887
|
+
children: [
|
|
888
|
+
/* @__PURE__ */ jsxs3("div", { className: "eb:mb-1.5 eb:flex eb:items-center eb:gap-2", children: [
|
|
889
|
+
/* @__PURE__ */ jsx3("span", { className: "eb:text-[11px] eb:font-medium eb:tracking-wide eb:text-foreground-muted eb:uppercase", children: t(KIND_LABELS[fix.kind]) }),
|
|
890
|
+
/* @__PURE__ */ jsxs3("span", { className: "eb:ml-auto", children: [
|
|
891
|
+
fix.state === "open" && /* @__PURE__ */ jsx3(Button, { variant: "outline", size: "xs", onClick: () => apply([fix.id]), children: t("Apply") }),
|
|
892
|
+
fix.state === "applied" && /* @__PURE__ */ jsxs3("span", { className: "eb:flex eb:items-center eb:gap-1 eb:text-[12px] eb:text-success", children: [
|
|
893
|
+
/* @__PURE__ */ jsx3(Check2, { className: "eb:size-3.5", "aria-hidden": "true" }),
|
|
894
|
+
t("Applied")
|
|
895
|
+
] }),
|
|
896
|
+
fix.state === "stale" && /* @__PURE__ */ jsx3("span", { className: "eb:text-[12px] eb:text-foreground-muted", children: t("Text changed since \u2014 left as it is") })
|
|
897
|
+
] })
|
|
898
|
+
] }),
|
|
899
|
+
/* @__PURE__ */ jsx3(Changes, { before: fix.before, after: fix.after, html: fix.format === "html" })
|
|
900
|
+
]
|
|
901
|
+
},
|
|
902
|
+
fix.id
|
|
903
|
+
)) })
|
|
904
|
+
] }),
|
|
905
|
+
/* @__PURE__ */ jsx3("div", { className: "eb:flex eb:items-center eb:justify-end eb:gap-2 eb:border-t eb:border-border eb:px-6 eb:py-4", children: busy ? /* @__PURE__ */ jsx3(Button, { variant: "outline", size: "sm", onClick: () => close(false), children: t("Cancel") }) : /* @__PURE__ */ jsxs3(Fragment2, { children: [
|
|
906
|
+
/* @__PURE__ */ jsx3(Button, { variant: "ghost", size: "sm", className: "eb:mr-auto", onClick: run, children: t("Check again") }),
|
|
907
|
+
remaining.length > 0 ? /* @__PURE__ */ jsxs3(Button, { size: "sm", onClick: () => apply(remaining.map((fix) => fix.id)), "data-testid": "eb-proofread-apply-all", children: [
|
|
908
|
+
/* @__PURE__ */ jsx3(Check2, {}),
|
|
909
|
+
t("Apply all ({count})", { count: remaining.length })
|
|
910
|
+
] }) : /* @__PURE__ */ jsx3(Button, { variant: "outline", size: "sm", onClick: () => close(false), children: t("Close") })
|
|
911
|
+
] }) })
|
|
912
|
+
] }) })
|
|
869
913
|
] });
|
|
870
914
|
}
|
|
915
|
+
function Changes({ before, after, html }) {
|
|
916
|
+
const parts = diffWords(html ? visibleText(before) : before, html ? visibleText(after) : after);
|
|
917
|
+
return /* @__PURE__ */ jsx3("p", { className: "eb:text-[13px] eb:leading-relaxed eb:break-words eb:whitespace-pre-line eb:text-foreground", children: parts.map(
|
|
918
|
+
(part, index) => part.type === "same" ? /* @__PURE__ */ jsx3("span", { children: around(part.text, index === 0, index === parts.length - 1) }, index) : part.type === "removed" ? /* @__PURE__ */ jsx3("del", { className: "eb:rounded-sm eb:bg-destructive/10 eb:text-destructive eb:line-through", children: part.text }, index) : /* @__PURE__ */ jsx3("ins", { className: "eb:rounded-sm eb:bg-success/15 eb:text-success eb:no-underline", children: part.text }, index)
|
|
919
|
+
) });
|
|
920
|
+
}
|
|
921
|
+
var CONTEXT = 48;
|
|
922
|
+
function around(text, first, last) {
|
|
923
|
+
if (text.length <= CONTEXT * 2 + 8) return text;
|
|
924
|
+
const head = (value) => value.slice(0, CONTEXT).replace(/\s\S*$/, "");
|
|
925
|
+
const tail = (value) => value.slice(-CONTEXT).replace(/^\S*\s/, "");
|
|
926
|
+
if (first && last) return text;
|
|
927
|
+
if (first) return `\u2026${tail(text)}`;
|
|
928
|
+
if (last) return `${head(text)}\u2026`;
|
|
929
|
+
return `${head(text)} \u2026 ${tail(text)}`;
|
|
930
|
+
}
|
|
871
931
|
|
|
872
932
|
// src/ai/RewriteText.jsx
|
|
873
933
|
import { useEffect as useEffect4, useRef as useRef4, useState as useState4 } from "react";
|
|
874
|
-
import { ChevronDown as ChevronDown2, Languages, Loader2 as Loader22, Sparkles
|
|
875
|
-
|
|
934
|
+
import { ChevronDown as ChevronDown2, Languages, Loader2 as Loader22, PenLine, Sparkles, TriangleAlert as TriangleAlert3 } from "lucide-react";
|
|
935
|
+
|
|
936
|
+
// src/ai/chip.js
|
|
937
|
+
var chipClassName = (selected = false) => cn(
|
|
938
|
+
"eb:inline-flex eb:h-7 eb:items-center eb:gap-1 eb:rounded-full eb:border eb:px-3 eb:text-[12px] eb:font-medium eb:transition-colors eb:outline-none eb:focus-visible:ring-[3px] eb:focus-visible:ring-ring/50 eb:disabled:opacity-50",
|
|
939
|
+
selected ? "eb:border-primary eb:bg-primary eb:text-primary-foreground" : "eb:border-border eb:bg-background eb:text-foreground eb:hover:border-primary/50 eb:hover:bg-primary/5"
|
|
940
|
+
);
|
|
941
|
+
|
|
942
|
+
// src/ai/RewriteText.jsx
|
|
943
|
+
import { Fragment as Fragment3, jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
|
|
876
944
|
function nativeName(code) {
|
|
877
945
|
try {
|
|
878
946
|
const name = new Intl.DisplayNames([code], { type: "language" }).of(code) || code;
|
|
@@ -882,26 +950,27 @@ function nativeName(code) {
|
|
|
882
950
|
}
|
|
883
951
|
}
|
|
884
952
|
function RewriteText({ editor, selection, rewrite }) {
|
|
885
|
-
const { t, notify } = useUi();
|
|
953
|
+
const { t, locale, notify } = useUi();
|
|
886
954
|
const [running, setRunning] = useState4(
|
|
887
955
|
/** @type {string|null} */
|
|
888
956
|
null
|
|
889
957
|
);
|
|
890
958
|
const [error, setError] = useState4("");
|
|
891
959
|
const [instruction, setInstruction] = useState4("");
|
|
960
|
+
const [topic, setTopic] = useState4("");
|
|
892
961
|
const abortRef = useRef4(
|
|
893
962
|
/** @type {AbortController|null} */
|
|
894
963
|
null
|
|
895
964
|
);
|
|
896
965
|
const label = selection.tagName === "mj-button";
|
|
897
966
|
useEffect4(() => () => abortRef.current?.abort(), []);
|
|
898
|
-
const
|
|
967
|
+
const currentText2 = () => {
|
|
899
968
|
const element = editor.getElement(selection.id);
|
|
900
969
|
return (label ? element?.content : element?.html) ?? "";
|
|
901
970
|
};
|
|
902
971
|
const run = async (action, extra = {}) => {
|
|
903
972
|
if (running) return;
|
|
904
|
-
const text =
|
|
973
|
+
const text = currentText2();
|
|
905
974
|
setError("");
|
|
906
975
|
setRunning(action);
|
|
907
976
|
const controller = new AbortController();
|
|
@@ -909,15 +978,18 @@ function RewriteText({ editor, selection, rewrite }) {
|
|
|
909
978
|
try {
|
|
910
979
|
const result = await rewrite({ action, text, format: label ? "plain" : "html", ...extra }, { signal: controller.signal });
|
|
911
980
|
if (controller.signal.aborted) return;
|
|
912
|
-
if (
|
|
981
|
+
if (currentText2() !== text) {
|
|
913
982
|
setError(t("The text changed in the meantime, so it was left as it is."));
|
|
914
983
|
return;
|
|
915
984
|
}
|
|
916
985
|
editor.setContent(label ? result.text : cleanRichText(result.text, document), selection.id);
|
|
917
986
|
if (action === "custom") setInstruction("");
|
|
918
|
-
|
|
987
|
+
if (action === "write") setTopic("");
|
|
988
|
+
notify("success", action === "write" ? t("Text written. Ctrl+Z brings back the previous one.") : t("Text rewritten. Ctrl+Z brings back the previous one."));
|
|
919
989
|
} catch (err) {
|
|
920
|
-
if (!controller.signal.aborted)
|
|
990
|
+
if (!controller.signal.aborted) {
|
|
991
|
+
setError(err?.message ? t(err.message) : action === "write" ? t("The text could not be written.") : t("The text could not be rewritten."));
|
|
992
|
+
}
|
|
921
993
|
} finally {
|
|
922
994
|
if (abortRef.current === controller) abortRef.current = null;
|
|
923
995
|
setRunning(null);
|
|
@@ -927,65 +999,110 @@ function RewriteText({ editor, selection, rewrite }) {
|
|
|
927
999
|
abortRef.current?.abort();
|
|
928
1000
|
setRunning(null);
|
|
929
1001
|
};
|
|
1002
|
+
const write = () => {
|
|
1003
|
+
const subject = topic.trim();
|
|
1004
|
+
if (!subject) return;
|
|
1005
|
+
const size = Number.parseFloat(String(editor.getElement(selection.id)?.attributes?.["font-size"] || ""));
|
|
1006
|
+
const own = visibleText(currentText2());
|
|
1007
|
+
const words = emailWords(editor.exportMjml());
|
|
1008
|
+
run("write", {
|
|
1009
|
+
instruction: subject,
|
|
1010
|
+
context: own ? words.replace(own, "").trim() : words,
|
|
1011
|
+
role: size >= 20 ? "heading" : "text",
|
|
1012
|
+
language: String(locale || "en").slice(0, 2)
|
|
1013
|
+
});
|
|
1014
|
+
};
|
|
930
1015
|
const busy = Boolean(running);
|
|
931
1016
|
const spinner = /* @__PURE__ */ jsx4(Loader22, { className: "eb:size-3 eb:animate-spin", "aria-hidden": "true" });
|
|
932
|
-
return /* @__PURE__ */ jsxs4(
|
|
933
|
-
/* @__PURE__ */ jsxs4(
|
|
934
|
-
/* @__PURE__ */
|
|
935
|
-
|
|
1017
|
+
return /* @__PURE__ */ jsxs4(Fragment3, { children: [
|
|
1018
|
+
!label && /* @__PURE__ */ jsxs4("section", { className: "eb:space-y-2.5", "aria-label": t("Write with AI"), "data-testid": "eb-write", children: [
|
|
1019
|
+
/* @__PURE__ */ jsxs4(SectionLabel, { className: "eb:flex eb:items-center eb:gap-1.5", children: [
|
|
1020
|
+
/* @__PURE__ */ jsx4(PenLine, { className: "eb:size-3", "aria-hidden": "true" }),
|
|
1021
|
+
t("Write with AI")
|
|
1022
|
+
] }),
|
|
1023
|
+
/* @__PURE__ */ jsxs4(
|
|
1024
|
+
"form",
|
|
1025
|
+
{
|
|
1026
|
+
className: "eb:flex eb:gap-1.5",
|
|
1027
|
+
onSubmit: (event) => {
|
|
1028
|
+
event.preventDefault();
|
|
1029
|
+
write();
|
|
1030
|
+
},
|
|
1031
|
+
children: [
|
|
1032
|
+
/* @__PURE__ */ jsx4(
|
|
1033
|
+
Input,
|
|
1034
|
+
{
|
|
1035
|
+
value: topic,
|
|
1036
|
+
onChange: (event) => setTopic(event.target.value),
|
|
1037
|
+
maxLength: MAX_REWRITE_INSTRUCTION,
|
|
1038
|
+
placeholder: t("What should it say? e.g. the sale ends Sunday"),
|
|
1039
|
+
"aria-label": t("What the text is about"),
|
|
1040
|
+
disabled: busy
|
|
1041
|
+
}
|
|
1042
|
+
),
|
|
1043
|
+
/* @__PURE__ */ jsx4(Button, { type: "submit", variant: "outline", size: "icon-sm", "aria-label": t("Write"), disabled: busy || !topic.trim(), children: running === "write" ? /* @__PURE__ */ jsx4(Loader22, { className: "eb:animate-spin" }) : /* @__PURE__ */ jsx4(PenLine, {}) })
|
|
1044
|
+
]
|
|
1045
|
+
}
|
|
1046
|
+
)
|
|
936
1047
|
] }),
|
|
937
|
-
/* @__PURE__ */ jsxs4("
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
t(
|
|
941
|
-
] }
|
|
942
|
-
/* @__PURE__ */ jsxs4(
|
|
943
|
-
|
|
944
|
-
running ===
|
|
945
|
-
t(
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
1048
|
+
/* @__PURE__ */ jsxs4("section", { className: "eb:space-y-2.5", "aria-label": t("Rewrite with AI"), "data-testid": "eb-rewrite", children: [
|
|
1049
|
+
/* @__PURE__ */ jsxs4(SectionLabel, { className: "eb:flex eb:items-center eb:gap-1.5", children: [
|
|
1050
|
+
/* @__PURE__ */ jsx4(Sparkles, { className: "eb:size-3", "aria-hidden": "true" }),
|
|
1051
|
+
t("Rewrite with AI")
|
|
1052
|
+
] }),
|
|
1053
|
+
/* @__PURE__ */ jsxs4("div", { className: "eb:flex eb:flex-wrap eb:gap-1.5", children: [
|
|
1054
|
+
REWRITE_ACTIONS.map((action) => /* @__PURE__ */ jsxs4("button", { type: "button", className: chipClassName(), disabled: busy, onClick: () => run(action.id), children: [
|
|
1055
|
+
running === action.id && spinner,
|
|
1056
|
+
t(action.label)
|
|
1057
|
+
] }, action.id)),
|
|
1058
|
+
/* @__PURE__ */ jsxs4(DropdownMenu, { children: [
|
|
1059
|
+
/* @__PURE__ */ jsx4(DropdownMenuTrigger, { asChild: true, disabled: busy, children: /* @__PURE__ */ jsxs4("button", { type: "button", className: chipClassName(), children: [
|
|
1060
|
+
running === "translate" ? spinner : /* @__PURE__ */ jsx4(Languages, { className: "eb:size-3", "aria-hidden": "true" }),
|
|
1061
|
+
t("Translate"),
|
|
1062
|
+
/* @__PURE__ */ jsx4(ChevronDown2, { className: "eb:size-3", "aria-hidden": "true" })
|
|
1063
|
+
] }) }),
|
|
1064
|
+
/* @__PURE__ */ jsx4(DropdownMenuContent, { align: "start", "aria-label": t("Translate into"), children: REWRITE_LANGUAGES.map((code) => /* @__PURE__ */ jsx4(DropdownMenuItem, { lang: code, onSelect: () => run("translate", { targetLanguage: code }), children: nativeName(code) }, code)) })
|
|
1065
|
+
] })
|
|
1066
|
+
] }),
|
|
1067
|
+
/* @__PURE__ */ jsxs4(
|
|
1068
|
+
"form",
|
|
1069
|
+
{
|
|
1070
|
+
className: "eb:flex eb:gap-1.5",
|
|
1071
|
+
onSubmit: (event) => {
|
|
1072
|
+
event.preventDefault();
|
|
1073
|
+
if (instruction.trim()) run("custom", { instruction: instruction.trim() });
|
|
1074
|
+
},
|
|
1075
|
+
children: [
|
|
1076
|
+
/* @__PURE__ */ jsx4(
|
|
1077
|
+
Input,
|
|
1078
|
+
{
|
|
1079
|
+
value: instruction,
|
|
1080
|
+
onChange: (event) => setInstruction(event.target.value),
|
|
1081
|
+
maxLength: MAX_REWRITE_INSTRUCTION,
|
|
1082
|
+
placeholder: t("Or say what to change\u2026"),
|
|
1083
|
+
"aria-label": t("What to change"),
|
|
1084
|
+
disabled: busy
|
|
1085
|
+
}
|
|
1086
|
+
),
|
|
1087
|
+
/* @__PURE__ */ jsx4(Button, { type: "submit", variant: "outline", size: "icon-sm", "aria-label": t("Rewrite"), disabled: busy || !instruction.trim(), children: running === "custom" ? /* @__PURE__ */ jsx4(Loader22, { className: "eb:animate-spin" }) : /* @__PURE__ */ jsx4(Sparkles, {}) })
|
|
1088
|
+
]
|
|
1089
|
+
}
|
|
1090
|
+
),
|
|
1091
|
+
running && /* @__PURE__ */ jsxs4("p", { className: "eb:flex eb:items-center eb:gap-2 eb:text-[12px] eb:text-foreground-muted", role: "status", "aria-live": "polite", children: [
|
|
1092
|
+
running === "write" ? t("Writing\u2026") : t("Rewriting\u2026"),
|
|
1093
|
+
/* @__PURE__ */ jsx4(Button, { type: "button", variant: "link", size: "xs", className: "eb:h-auto eb:px-0", onClick: cancel, children: t("Cancel") })
|
|
1094
|
+
] }),
|
|
1095
|
+
error && /* @__PURE__ */ jsxs4("p", { role: "alert", className: "eb:flex eb:items-start eb:gap-1.5 eb:text-[12px] eb:text-destructive", children: [
|
|
1096
|
+
/* @__PURE__ */ jsx4(TriangleAlert3, { className: "eb:mt-px eb:size-3.5 eb:shrink-0", "aria-hidden": "true" }),
|
|
1097
|
+
error
|
|
949
1098
|
] })
|
|
950
|
-
] }),
|
|
951
|
-
/* @__PURE__ */ jsxs4(
|
|
952
|
-
"form",
|
|
953
|
-
{
|
|
954
|
-
className: "eb:flex eb:gap-1.5",
|
|
955
|
-
onSubmit: (event) => {
|
|
956
|
-
event.preventDefault();
|
|
957
|
-
if (instruction.trim()) run("custom", { instruction: instruction.trim() });
|
|
958
|
-
},
|
|
959
|
-
children: [
|
|
960
|
-
/* @__PURE__ */ jsx4(
|
|
961
|
-
Input,
|
|
962
|
-
{
|
|
963
|
-
value: instruction,
|
|
964
|
-
onChange: (event) => setInstruction(event.target.value),
|
|
965
|
-
maxLength: MAX_REWRITE_INSTRUCTION,
|
|
966
|
-
placeholder: t("Or say what to change\u2026"),
|
|
967
|
-
"aria-label": t("What to change"),
|
|
968
|
-
disabled: busy
|
|
969
|
-
}
|
|
970
|
-
),
|
|
971
|
-
/* @__PURE__ */ jsx4(Button, { type: "submit", variant: "outline", size: "icon-sm", "aria-label": t("Rewrite"), disabled: busy || !instruction.trim(), children: running === "custom" ? /* @__PURE__ */ jsx4(Loader22, { className: "eb:animate-spin" }) : /* @__PURE__ */ jsx4(Sparkles2, {}) })
|
|
972
|
-
]
|
|
973
|
-
}
|
|
974
|
-
),
|
|
975
|
-
running && /* @__PURE__ */ jsxs4("p", { className: "eb:flex eb:items-center eb:gap-2 eb:text-[12px] eb:text-foreground-muted", role: "status", "aria-live": "polite", children: [
|
|
976
|
-
t("Rewriting\u2026"),
|
|
977
|
-
/* @__PURE__ */ jsx4(Button, { type: "button", variant: "link", size: "xs", className: "eb:h-auto eb:px-0", onClick: cancel, children: t("Cancel") })
|
|
978
|
-
] }),
|
|
979
|
-
error && /* @__PURE__ */ jsxs4("p", { role: "alert", className: "eb:flex eb:items-start eb:gap-1.5 eb:text-[12px] eb:text-destructive", children: [
|
|
980
|
-
/* @__PURE__ */ jsx4(TriangleAlert3, { className: "eb:mt-px eb:size-3.5 eb:shrink-0", "aria-hidden": "true" }),
|
|
981
|
-
error
|
|
982
1099
|
] })
|
|
983
1100
|
] });
|
|
984
1101
|
}
|
|
985
1102
|
|
|
986
1103
|
// src/ai/SubjectIdeas.jsx
|
|
987
1104
|
import { useEffect as useEffect5, useLayoutEffect, useRef as useRef5, useState as useState5 } from "react";
|
|
988
|
-
import { Check as Check3, Loader2 as Loader23, Sparkles as
|
|
1105
|
+
import { Check as Check3, Loader2 as Loader23, Sparkles as Sparkles2, TriangleAlert as TriangleAlert4 } from "lucide-react";
|
|
989
1106
|
import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
|
|
990
1107
|
var MAX_AVOID = 12;
|
|
991
1108
|
function SubjectIdeas({ editor, meta, onSubject, suggest, variables = null }) {
|
|
@@ -1104,7 +1221,7 @@ function SubjectIdeas({ editor, meta, onSubject, suggest, variables = null }) {
|
|
|
1104
1221
|
const spinner = /* @__PURE__ */ jsx5(Loader23, { className: "eb:size-3 eb:animate-spin", "aria-hidden": "true" });
|
|
1105
1222
|
return /* @__PURE__ */ jsxs5("div", { className: "eb:space-y-2", "data-testid": "eb-subject-ideas", ref: rootRef, children: [
|
|
1106
1223
|
!ideas && /* @__PURE__ */ jsxs5("button", { type: "button", className: chipClassName(), disabled: running || !ready, onClick: run, "data-subject-ideas-button": true, children: [
|
|
1107
|
-
running ? spinner : /* @__PURE__ */ jsx5(
|
|
1224
|
+
running ? spinner : /* @__PURE__ */ jsx5(Sparkles2, { className: "eb:size-3", "aria-hidden": "true" }),
|
|
1108
1225
|
t("Suggest subjects")
|
|
1109
1226
|
] }),
|
|
1110
1227
|
ideas && /* @__PURE__ */ jsxs5("div", { className: "eb:space-y-2 eb:rounded-md eb:border eb:border-border eb:p-1.5", children: [
|
|
@@ -1133,7 +1250,7 @@ function SubjectIdeas({ editor, meta, onSubject, suggest, variables = null }) {
|
|
|
1133
1250
|
}) }),
|
|
1134
1251
|
/* @__PURE__ */ jsxs5("div", { className: "eb:flex eb:items-center eb:gap-1.5 eb:px-1 eb:pb-0.5", children: [
|
|
1135
1252
|
/* @__PURE__ */ jsxs5("button", { type: "button", className: chipClassName(), disabled: running || !ready, onClick: run, "data-subject-ideas-button": true, children: [
|
|
1136
|
-
running ? spinner : /* @__PURE__ */ jsx5(
|
|
1253
|
+
running ? spinner : /* @__PURE__ */ jsx5(Sparkles2, { className: "eb:size-3", "aria-hidden": "true" }),
|
|
1137
1254
|
t("More ideas")
|
|
1138
1255
|
] }),
|
|
1139
1256
|
/* @__PURE__ */ jsx5(Button, { type: "button", variant: "ghost", size: "xs", className: "eb:ml-auto", disabled: running, onClick: done, children: t("Done") })
|
|
@@ -1153,12 +1270,12 @@ function SubjectIdeas({ editor, meta, onSubject, suggest, variables = null }) {
|
|
|
1153
1270
|
// src/ai/createAi.js
|
|
1154
1271
|
var PARTS = (
|
|
1155
1272
|
/** @type {const} */
|
|
1156
|
-
["
|
|
1273
|
+
["proofread", "rewrite", "suggestSubjects"]
|
|
1157
1274
|
);
|
|
1158
|
-
var ROUTES = {
|
|
1275
|
+
var ROUTES = { proofread: "/proofread", rewrite: "/rewrite", suggestSubjects: "/subjects" };
|
|
1159
1276
|
function createAi(options = {}) {
|
|
1160
1277
|
if (!options || typeof options !== "object") throw new TypeError("createAi: options must be an object");
|
|
1161
|
-
const { model, maxAttempts,
|
|
1278
|
+
const { model, maxAttempts, baseUrl, headers, fetch: fetchImpl, provider: providerOption, apiKey, askKey = false } = options;
|
|
1162
1279
|
const usesProvider = providerOption !== void 0;
|
|
1163
1280
|
const http = baseUrl !== void 0 || headers !== void 0 || fetchImpl !== void 0 && !usesProvider;
|
|
1164
1281
|
if (model !== void 0 && typeof model !== "function") throw new TypeError("createAi: `model` must be a function");
|
|
@@ -1170,8 +1287,12 @@ function createAi(options = {}) {
|
|
|
1170
1287
|
throw new TypeError('createAi: `apiKey` and `askKey` go with a `provider` (e.g. provider: "openrouter")');
|
|
1171
1288
|
}
|
|
1172
1289
|
if (apiKey !== void 0 && (typeof apiKey !== "string" || !apiKey)) throw new TypeError("createAi: `apiKey` must be a non-empty string");
|
|
1173
|
-
|
|
1174
|
-
|
|
1290
|
+
const { generate } = (
|
|
1291
|
+
/** @type {any} */
|
|
1292
|
+
options
|
|
1293
|
+
);
|
|
1294
|
+
if (generate !== void 0 && generate !== false) {
|
|
1295
|
+
throw new TypeError("createAi: `generate` was removed in 0.10 \u2014 the AI checks and writes the texts of an email (`proofread`, `rewrite`), not whole emails");
|
|
1175
1296
|
}
|
|
1176
1297
|
if (typeof askKey !== "boolean") throw new TypeError("createAi: `askKey` must be true or false");
|
|
1177
1298
|
if (apiKey && askKey) throw new TypeError("createAi: `apiKey` (one key for all) or `askKey` (each user's own), not both");
|
|
@@ -1203,12 +1324,14 @@ function createAi(options = {}) {
|
|
|
1203
1324
|
providers.find((item) => item.id === id)
|
|
1204
1325
|
);
|
|
1205
1326
|
const name = key ? key.model(id) : chosen.model;
|
|
1327
|
+
const address = key && key.providers.find((item) => item.id === id)?.needsAddress ? key.address(id) : "";
|
|
1206
1328
|
const slot = `${id}
|
|
1207
|
-
${name}
|
|
1329
|
+
${name}
|
|
1330
|
+
${address}`;
|
|
1208
1331
|
if (!models.has(slot)) {
|
|
1209
1332
|
models.set(
|
|
1210
1333
|
slot,
|
|
1211
|
-
chosen.createModel ? chosen.createModel({ apiKey: keyFor, fetch: fetchImpl, ...name ? { model: name } : {} }) : providerEmailModel({ provider: chosen, fetch: fetchImpl, apiKey: keyFor, ...name ? { model: name } : {} })
|
|
1334
|
+
chosen.createModel ? chosen.createModel({ apiKey: keyFor, fetch: fetchImpl, ...name ? { model: name } : {} }) : providerEmailModel({ provider: chosen, fetch: fetchImpl, apiKey: keyFor, ...name ? { model: name } : {}, ...address ? { baseUrl: address } : {} })
|
|
1212
1335
|
);
|
|
1213
1336
|
}
|
|
1214
1337
|
return (
|
|
@@ -1217,13 +1340,16 @@ ${name}`;
|
|
|
1217
1340
|
);
|
|
1218
1341
|
};
|
|
1219
1342
|
const model2 = async (request) => {
|
|
1220
|
-
if (key && !key.
|
|
1343
|
+
if (key && !key.ready()) {
|
|
1344
|
+
await key.request();
|
|
1345
|
+
if (!key.ready()) throw new EmailGenerationError("KEY_MISSING", `${key.provider.name}: not connected`);
|
|
1346
|
+
}
|
|
1221
1347
|
return modelOf(key ? key.provider.id : provider.id)(request);
|
|
1222
1348
|
};
|
|
1223
|
-
const generator = createEmailGenerator({ model: model2, maxAttempts
|
|
1349
|
+
const generator = createEmailGenerator({ model: model2, maxAttempts });
|
|
1224
1350
|
for (const part of PARTS) transport[part] = key ? askedAgain(generator[part], key) : generator[part];
|
|
1225
1351
|
} else if (model) {
|
|
1226
|
-
transport = createEmailGenerator({ model, maxAttempts
|
|
1352
|
+
transport = createEmailGenerator({ model, maxAttempts });
|
|
1227
1353
|
} else if (!ownOnly) {
|
|
1228
1354
|
const { call } = createHttpCaller({ baseUrl, headers, fetch: fetchImpl });
|
|
1229
1355
|
for (const part of PARTS) {
|
|
@@ -1237,9 +1363,9 @@ ${name}`;
|
|
|
1237
1363
|
return transport[part] || null;
|
|
1238
1364
|
};
|
|
1239
1365
|
return Object.freeze({
|
|
1240
|
-
|
|
1241
|
-
/** @type {
|
|
1242
|
-
resolve("
|
|
1366
|
+
proofread: (
|
|
1367
|
+
/** @type {ProofreadFn|null} */
|
|
1368
|
+
resolve("proofread")
|
|
1243
1369
|
),
|
|
1244
1370
|
rewrite: (
|
|
1245
1371
|
/** @type {RewriteFn|null} */
|
|
@@ -1251,7 +1377,7 @@ ${name}`;
|
|
|
1251
1377
|
),
|
|
1252
1378
|
key,
|
|
1253
1379
|
messages: AI_MESSAGES,
|
|
1254
|
-
components: Object.freeze({
|
|
1380
|
+
components: Object.freeze({ Proofread: ProofreadButton, Rewrite: RewriteText, Subjects: SubjectIdeas, Connect: key ? ConnectAi : null })
|
|
1255
1381
|
});
|
|
1256
1382
|
}
|
|
1257
1383
|
function providerOf(option) {
|
|
@@ -1295,10 +1421,8 @@ export {
|
|
|
1295
1421
|
AI_MESSAGES,
|
|
1296
1422
|
AI_PROVIDERS,
|
|
1297
1423
|
DEFAULT_CLAUDE_MODEL,
|
|
1298
|
-
EMAIL_PURPOSES,
|
|
1299
1424
|
EmailGenerationError,
|
|
1300
1425
|
GENERATION_ERRORS,
|
|
1301
|
-
PURPOSE_CATEGORIES,
|
|
1302
1426
|
REWRITE_ACTIONS,
|
|
1303
1427
|
REWRITE_LANGUAGES,
|
|
1304
1428
|
agentEmailModel,
|
|
@@ -1306,6 +1430,7 @@ export {
|
|
|
1306
1430
|
claudeProvider,
|
|
1307
1431
|
createAi,
|
|
1308
1432
|
createEmailGenerator,
|
|
1433
|
+
diffWords,
|
|
1309
1434
|
emailWords,
|
|
1310
1435
|
failureCode,
|
|
1311
1436
|
listProviderModels,
|