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.
Files changed (70) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/README.md +7 -5
  3. package/dist/ai-server.js +21 -32
  4. package/dist/ai-server.js.map +1 -1
  5. package/dist/ai.js +432 -307
  6. package/dist/ai.js.map +1 -1
  7. package/dist/builder.js +8 -9
  8. package/dist/{chunk-SHHHYRPC.js → chunk-24E4GOHU.js} +518 -1505
  9. package/dist/chunk-24E4GOHU.js.map +1 -0
  10. package/dist/chunk-7G7C6G3A.js +837 -0
  11. package/dist/chunk-7G7C6G3A.js.map +1 -0
  12. package/dist/{chunk-G4U6PSF6.js → chunk-A6VTFUSW.js} +2 -2
  13. package/dist/{chunk-P7ABBLX6.js → chunk-KTTVBNOU.js} +2 -2
  14. package/dist/{chunk-6W3QUZR7.js → chunk-M4R53NYT.js} +158 -36
  15. package/dist/chunk-M4R53NYT.js.map +1 -0
  16. package/dist/{chunk-62HBUBBI.js → chunk-MBZI6NVP.js} +31 -732
  17. package/dist/chunk-MBZI6NVP.js.map +1 -0
  18. package/dist/{chunk-LUM7J5FF.js → chunk-OLTD3S3N.js} +2 -2
  19. package/dist/{chunk-ITIG5NTJ.js → chunk-SCQKWAXR.js} +7 -7
  20. package/dist/{chunk-WM2EEV4Q.js → chunk-TUA6E3BR.js} +76 -39
  21. package/dist/chunk-TUA6E3BR.js.map +1 -0
  22. package/dist/{chunk-AXIM6E7T.js → chunk-VQRVBQIB.js} +2 -2
  23. package/dist/{chunk-DXWIVGHP.js → chunk-XDCGYHLF.js} +4 -41
  24. package/dist/chunk-XDCGYHLF.js.map +1 -0
  25. package/dist/index.js +17 -18
  26. package/dist/index.js.map +1 -1
  27. package/dist/library.js +5 -5
  28. package/dist/server.js +13 -19
  29. package/dist/server.js.map +1 -1
  30. package/dist/styles.css +1 -1
  31. package/dist/types/ai/ConnectAi.d.ts +3 -1
  32. package/dist/types/ai/ModelPicker.d.ts +7 -3
  33. package/dist/types/ai/ProofreadButton.d.ts +4 -0
  34. package/dist/types/ai/SubjectIdeas.d.ts +1 -1
  35. package/dist/types/ai/agent.d.ts +1 -1
  36. package/dist/types/ai/agentEndpoint.d.ts +3 -11
  37. package/dist/types/ai/claude.d.ts +3 -3
  38. package/dist/types/ai/createAi.d.ts +9 -14
  39. package/dist/types/ai/diff.d.ts +24 -0
  40. package/dist/types/ai/{draft.d.ts → errors.d.ts} +30 -81
  41. package/dist/types/ai/generator.d.ts +25 -91
  42. package/dist/types/ai/index.d.ts +2 -2
  43. package/dist/types/ai/keys.d.ts +15 -3
  44. package/dist/types/ai/proofread.d.ts +99 -0
  45. package/dist/types/ai/rewrite.d.ts +27 -8
  46. package/dist/types/ai/server.d.ts +4 -4
  47. package/dist/types/ai/subjects.d.ts +6 -0
  48. package/dist/types/core/EmailEditor.d.ts +10 -0
  49. package/dist/types/core/constants.d.ts +1 -1
  50. package/dist/types/engine/NativeAdapter.d.ts +1 -1
  51. package/dist/types/server/handler.d.ts +5 -3
  52. package/dist/types/ui/primitives/searchShortcut.d.ts +2 -2
  53. package/package.json +1 -1
  54. package/dist/chunk-62HBUBBI.js.map +0 -1
  55. package/dist/chunk-6W3QUZR7.js.map +0 -1
  56. package/dist/chunk-DXWIVGHP.js.map +0 -1
  57. package/dist/chunk-FAEVYSIF.js +0 -119
  58. package/dist/chunk-FAEVYSIF.js.map +0 -1
  59. package/dist/chunk-OWVXKMLI.js +0 -121
  60. package/dist/chunk-OWVXKMLI.js.map +0 -1
  61. package/dist/chunk-SHHHYRPC.js.map +0 -1
  62. package/dist/chunk-WM2EEV4Q.js.map +0 -1
  63. package/dist/types/ai/GenerateButton.d.ts +0 -4
  64. package/dist/types/ai/prompt.d.ts +0 -76
  65. package/dist/types/ai/purposes.d.ts +0 -48
  66. /package/dist/{chunk-G4U6PSF6.js.map → chunk-A6VTFUSW.js.map} +0 -0
  67. /package/dist/{chunk-P7ABBLX6.js.map → chunk-KTTVBNOU.js.map} +0 -0
  68. /package/dist/{chunk-LUM7J5FF.js.map → chunk-OLTD3S3N.js.map} +0 -0
  69. /package/dist/{chunk-ITIG5NTJ.js.map → chunk-SCQKWAXR.js.map} +0 -0
  70. /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-P7ABBLX6.js";
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-DXWIVGHP.js";
27
- import "./chunk-FAEVYSIF.js";
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
- purposesByCategory,
51
- resolveProvider
52
- } from "./chunk-SHHHYRPC.js";
53
- import "./chunk-OWVXKMLI.js";
54
- import "./chunk-AXIM6E7T.js";
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: Boolean(get()) });
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: Boolean(get()) });
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
- keep(current, value);
129
- refused = false;
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
- const kept2 = memory.get(MODEL_PREFIX + choice.id) ?? read(MODEL_PREFIX + choice.id);
167
- return kept2 || choice.model || "";
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((provider) => provider.id === providerId(id));
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
- if (!lists.has(slot)) lists.set(slot, await listProviderModels({ provider: originals[at], apiKey: key, fetch: fetchImpl, signal }));
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, Gift as Gift2, KeyRound, ShieldCheck, TriangleAlert } from "lucide-react";
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, Gift, LoaderCircle, Search } from "lucide-react";
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 (!apiKey && !provider.publicModels) {
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__ */ jsxs("span", { className: "eb:inline-flex eb:shrink-0 eb:items-center eb:gap-1 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: [
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__ */ jsxs(
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:gap-1.5 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",
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
- const answer = key || saved;
503
- if (!answer) return;
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(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__ */ jsxs2("span", { className: "eb:inline-flex eb:items-center eb:gap-1.5 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: [
605
- /* @__PURE__ */ jsx2(Gift2, { className: "eb:size-3.5", "aria-hidden": "true" }),
606
- t(free)
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
- delay: key.length >= LISTABLE_KEY ? 350 : 0,
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: !key && !saved, "data-testid": "eb-ai-connect-submit", children: t("Connect") })
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/GenerateButton.jsx
672
- import { useEffect as useEffect3, useMemo as useMemo2, useRef as useRef3, useState as useState3 } from "react";
673
- import { Check as Check2, Loader2, Search as Search2, Sparkles, TriangleAlert as TriangleAlert2 } from "lucide-react";
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 fold = (text) => text.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase();
684
- function GenerateButton({ context, generate: generateEmail }) {
685
- const { editor, ready, isDirty, markDirty, template, setMeta, variables, sender, brand } = context;
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 [dirty, setDirty] = useState3(false);
758
+ const [fixes, setFixes] = useState3(null);
698
759
  const abortRef = useRef3(
699
760
  /** @type {AbortController|null} */
700
761
  null
701
762
  );
702
- const searchRef = useRef3(
703
- /** @type {HTMLInputElement|null} */
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
- const groups = useMemo2(() => {
715
- const words = fold(query.trim()).split(/\s+/).filter(Boolean);
716
- return purposesByCategory().map((group) => ({
717
- ...group,
718
- purposes: group.purposes.filter((entry) => {
719
- const label = fold(`${t(entry.label)} ${entry.label} ${t(group.label)}`);
720
- return words.every((word) => label.includes(word));
721
- })
722
- })).filter((group) => group.purposes.length > 0);
723
- }, [query, t]);
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 email = await generateEmail(
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
- editor.loadMjml(hasBrand(brand) ? brandTemplate(email.mjml, brand, { sender, t }) : email.mjml);
754
- setMeta((meta) => ({
755
- ...meta,
756
- name: meta.name || email.name,
757
- subject: email.subject || meta.subject,
758
- preheader: email.preheader || meta.preheader
759
- }));
760
- if (template.id) markDirty();
761
- setOpen(false);
762
- notify("success", t("Your email is ready. Edit anything, then save."));
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 generated."));
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) abortRef.current = null;
767
- setBusy(false);
811
+ if (abortRef.current === controller) {
812
+ abortRef.current = null;
813
+ setBusy(false);
814
+ }
768
815
  }
769
816
  };
770
- const cancel = () => {
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 canGenerate = Boolean(purpose || details.trim());
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(Sparkles, {}),
778
- t("Generate")
860
+ /* @__PURE__ */ jsx3(SpellCheck, {}),
861
+ t("Proofread")
779
862
  ] }),
780
- /* @__PURE__ */ jsx3(Dialog, { open, onOpenChange: close, children: /* @__PURE__ */ jsxs3(
781
- DialogContent,
782
- {
783
- className: "eb:flex eb:max-h-[calc(100dvh-2rem)] eb:flex-col eb:gap-0 eb:p-0 eb:sm:max-w-2xl",
784
- "data-testid": "eb-generate",
785
- onOpenAutoFocus: (event) => {
786
- event.preventDefault();
787
- searchRef.current?.focus();
788
- },
789
- children: [
790
- /* @__PURE__ */ jsxs3(DialogHeader, { className: "eb:gap-1.5 eb:px-6 eb:pt-6 eb:pb-3", children: [
791
- /* @__PURE__ */ jsx3(DialogTitle, { children: t("Generate an email") }),
792
- /* @__PURE__ */ jsx3(DialogDescription, { children: t("Pick what it is for. AI writes a complete email that opens here, ready to edit.") })
793
- ] }),
794
- /* @__PURE__ */ jsx3("div", { className: "eb:px-6 eb:pb-3", children: /* @__PURE__ */ jsxs3("div", { className: "eb:relative", children: [
795
- /* @__PURE__ */ jsx3(Search2, { className: "eb:pointer-events-none eb:absolute eb:top-1/2 eb:left-2.5 eb:size-3.5 eb:-translate-y-1/2 eb:text-foreground-muted", "aria-hidden": "true" }),
796
- /* @__PURE__ */ jsx3(
797
- Input,
798
- {
799
- ref: searchRef,
800
- type: "search",
801
- value: query,
802
- onChange: (event) => setQuery(event.target.value),
803
- placeholder: t("Search: sale, password, birthday\u2026"),
804
- "aria-label": t("Search"),
805
- "aria-keyshortcuts": SEARCH_KEYS,
806
- className: "eb:pr-16 eb:pl-8 eb:[&::-webkit-search-cancel-button]:hidden",
807
- disabled: busy
808
- }
809
- ),
810
- /* @__PURE__ */ jsx3(SearchKeyHint, { className: "eb:absolute eb:top-1/2 eb:right-2 eb:-translate-y-1/2" })
811
- ] }) }),
812
- /* @__PURE__ */ jsx3("div", { className: "eb:min-h-0 eb:flex-1 eb:overflow-y-auto eb:border-y eb:border-border eb:px-6 eb:py-4", "data-testid": "eb-generate-purposes", children: /* @__PURE__ */ jsxs3("fieldset", { disabled: busy, className: "eb:m-0 eb:min-w-0 eb:border-0 eb:p-0", children: [
813
- /* @__PURE__ */ jsx3("legend", { className: "eb:sr-only", children: t("What is the email for?") }),
814
- groups.length === 0 && /* @__PURE__ */ jsx3("p", { className: "eb:py-6 eb:text-center eb:text-[13px] eb:text-foreground-muted", children: t("Nothing matches. Describe your email in the details below.") }),
815
- /* @__PURE__ */ jsx3("div", { className: "eb:flex eb:flex-col eb:gap-4", children: groups.map((group) => /* @__PURE__ */ jsxs3("section", { className: "eb:flex eb:flex-col eb:gap-2", "aria-label": t(group.label), children: [
816
- /* @__PURE__ */ jsx3(SectionLabel, { children: t(group.label) }),
817
- /* @__PURE__ */ jsx3("div", { className: "eb:flex eb:flex-wrap eb:gap-1.5", children: group.purposes.map((entry) => {
818
- const selected = purpose === entry.id;
819
- return /* @__PURE__ */ jsxs3(
820
- "button",
821
- {
822
- type: "button",
823
- "aria-pressed": selected,
824
- "data-purpose": entry.id,
825
- onClick: () => setPurpose(selected ? null : entry.id),
826
- className: chipClassName(selected),
827
- children: [
828
- selected && /* @__PURE__ */ jsx3(Check2, { className: "eb:size-3", "aria-hidden": "true" }),
829
- t(entry.label)
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 as Sparkles2, TriangleAlert as TriangleAlert3 } from "lucide-react";
875
- import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
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 currentText = () => {
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 = currentText();
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 (currentText() !== text) {
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
- notify("success", t("Text rewritten. Ctrl+Z brings back the previous one."));
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) setError(err?.message ? t(err.message) : t("The text could not be rewritten."));
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("section", { className: "eb:space-y-2.5", "aria-label": t("Rewrite with AI"), "data-testid": "eb-rewrite", children: [
933
- /* @__PURE__ */ jsxs4(SectionLabel, { className: "eb:flex eb:items-center eb:gap-1.5", children: [
934
- /* @__PURE__ */ jsx4(Sparkles2, { className: "eb:size-3", "aria-hidden": "true" }),
935
- t("Rewrite with AI")
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("div", { className: "eb:flex eb:flex-wrap eb:gap-1.5", children: [
938
- REWRITE_ACTIONS.map((action) => /* @__PURE__ */ jsxs4("button", { type: "button", className: chipClassName(), disabled: busy, onClick: () => run(action.id), children: [
939
- running === action.id && spinner,
940
- t(action.label)
941
- ] }, action.id)),
942
- /* @__PURE__ */ jsxs4(DropdownMenu, { children: [
943
- /* @__PURE__ */ jsx4(DropdownMenuTrigger, { asChild: true, disabled: busy, children: /* @__PURE__ */ jsxs4("button", { type: "button", className: chipClassName(), children: [
944
- running === "translate" ? spinner : /* @__PURE__ */ jsx4(Languages, { className: "eb:size-3", "aria-hidden": "true" }),
945
- t("Translate"),
946
- /* @__PURE__ */ jsx4(ChevronDown2, { className: "eb:size-3", "aria-hidden": "true" })
947
- ] }) }),
948
- /* @__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)) })
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 Sparkles3, TriangleAlert as TriangleAlert4 } from "lucide-react";
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(Sparkles3, { className: "eb:size-3", "aria-hidden": "true" }),
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(Sparkles3, { className: "eb:size-3", "aria-hidden": "true" }),
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
- ["generate", "rewrite", "suggestSubjects"]
1273
+ ["proofread", "rewrite", "suggestSubjects"]
1157
1274
  );
1158
- var ROUTES = { generate: "/generate", rewrite: "/rewrite", suggestSubjects: "/subjects" };
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, compile, baseUrl, headers, fetch: fetchImpl, provider: providerOption, apiKey, askKey = false } = options;
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
- if (compile !== void 0 && (typeof compile !== "function" || !model && !usesProvider)) {
1174
- throw new TypeError("createAi: `compile` must be a function, and goes with `provider` or `model` (the AI in the page)");
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.get() && !providers.find((item) => item.id === key.provider.id)?.keyless) await key.request();
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, compile });
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, compile });
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
- generate: (
1241
- /** @type {GenerateFn|null} */
1242
- resolve("generate")
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({ Generate: GenerateButton, Rewrite: RewriteText, Subjects: SubjectIdeas, Connect: key ? ConnectAi : null })
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,