best-email-builder 0.5.0 → 0.6.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 (54) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +50 -10
  3. package/dist/ai-server.js +6 -4
  4. package/dist/ai.js +167 -39
  5. package/dist/ai.js.map +1 -1
  6. package/dist/builder.js +9 -9
  7. package/dist/{chunk-ECHGJZ2W.js → chunk-4U7L2NYQ.js} +32 -3
  8. package/dist/chunk-4U7L2NYQ.js.map +1 -0
  9. package/dist/{chunk-6D3SLT5W.js → chunk-5W4TUUPD.js} +4 -4
  10. package/dist/{chunk-LYMJ2TBX.js → chunk-CNJZLZRH.js} +2 -2
  11. package/dist/{chunk-DFNHICTF.js → chunk-JI36FNWE.js} +3 -1
  12. package/dist/chunk-JI36FNWE.js.map +1 -0
  13. package/dist/{chunk-LXR4QTGP.js → chunk-JU3OUO6X.js} +3 -3
  14. package/dist/{chunk-PB7YZ3WP.js → chunk-NC2YDZSP.js} +19 -65
  15. package/dist/chunk-NC2YDZSP.js.map +1 -0
  16. package/dist/{chunk-MPX5CWTZ.js → chunk-NZIQF7IW.js} +172 -29
  17. package/dist/chunk-NZIQF7IW.js.map +1 -0
  18. package/dist/{chunk-2RZHGAH5.js → chunk-OWVXKMLI.js} +59 -2
  19. package/dist/chunk-OWVXKMLI.js.map +1 -0
  20. package/dist/{chunk-7U6BAKM7.js → chunk-QPAZLCPO.js} +3 -3
  21. package/dist/{chunk-7U6BAKM7.js.map → chunk-QPAZLCPO.js.map} +1 -1
  22. package/dist/{chunk-KXJJSVN5.js → chunk-SAIW46IC.js} +2 -2
  23. package/dist/{chunk-5SASKCUC.js → chunk-W424KPCR.js} +2 -2
  24. package/dist/{chunk-3LKF4QBL.js → chunk-XEFBPP5Y.js} +4 -4
  25. package/dist/index.js +11 -11
  26. package/dist/library.js +5 -5
  27. package/dist/server.js +12 -29
  28. package/dist/server.js.map +1 -1
  29. package/dist/styles.css +1 -1
  30. package/dist/types/ai/ConnectAi.d.ts +4 -0
  31. package/dist/types/ai/claude.d.ts +30 -5
  32. package/dist/types/ai/createAi.d.ts +5 -3
  33. package/dist/types/ai/draft.d.ts +12 -0
  34. package/dist/types/ai/index.d.ts +2 -2
  35. package/dist/types/ai/keys.d.ts +15 -26
  36. package/dist/types/ai/providers.d.ts +24 -2
  37. package/dist/types/ai/server.d.ts +1 -1
  38. package/dist/types/core/constants.d.ts +1 -1
  39. package/dist/types/engine/NativeAdapter.d.ts +1 -1
  40. package/dist/types/renderer/prepareMjml.d.ts +8 -2
  41. package/dist/types/server/index.d.ts +1 -1
  42. package/dist/types/server/render.d.ts +5 -2
  43. package/package.json +1 -1
  44. package/dist/chunk-2RZHGAH5.js.map +0 -1
  45. package/dist/chunk-DFNHICTF.js.map +0 -1
  46. package/dist/chunk-ECHGJZ2W.js.map +0 -1
  47. package/dist/chunk-MPX5CWTZ.js.map +0 -1
  48. package/dist/chunk-PB7YZ3WP.js.map +0 -1
  49. /package/dist/{chunk-6D3SLT5W.js.map → chunk-5W4TUUPD.js.map} +0 -0
  50. /package/dist/{chunk-LYMJ2TBX.js.map → chunk-CNJZLZRH.js.map} +0 -0
  51. /package/dist/{chunk-LXR4QTGP.js.map → chunk-JU3OUO6X.js.map} +0 -0
  52. /package/dist/{chunk-KXJJSVN5.js.map → chunk-SAIW46IC.js.map} +0 -0
  53. /package/dist/{chunk-5SASKCUC.js.map → chunk-W424KPCR.js.map} +0 -0
  54. /package/dist/{chunk-3LKF4QBL.js.map → chunk-XEFBPP5Y.js.map} +0 -0
package/CHANGELOG.md CHANGED
@@ -6,6 +6,63 @@ All notable changes are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.6.0] — 2026-10-15
10
+
11
+ ### Added
12
+ - **The user chooses the provider.** `createAi({ provider: [...], askKey: true })`
13
+ takes a list: the Connect dialog shows the providers as cards (name, model,
14
+ free or not, a dot on those whose key this browser keeps; arrows move
15
+ through them). The choice and one key per provider are remembered, and a
16
+ provider whose key is kept needs nothing pasted. `ai.key` gains `provider`,
17
+ `providers`, `choose(id)` and `get(id)`; `ai.key.request()` opens the dialog
18
+ to change provider.
19
+ - **`claudeProvider({ client })`** — Claude as one of those providers, with
20
+ the user's own Anthropic key; the application's SDK is loaded when Claude
21
+ is chosen.
22
+ - **`failureCode(status, message)`** — the rule that turns a provider's
23
+ failure into the interface's error, exported for a model of your own.
24
+ - The live demo offers OpenAI, Claude, Mistral, Gemini and OpenRouter, says
25
+ which one is connected, and has a *Change provider* link.
26
+
27
+ ### Fixed
28
+ - **An exhausted quota said "busy, try again".** OpenAI answers a quota
29
+ spent with a 429 (`insufficient_quota`), Anthropic an empty credit balance
30
+ with a 400: both are now `MODEL_CREDITS`.
31
+ - **Claude's failures were all "could not answer".** The SDK's errors are now
32
+ read like every provider's: a refused key is `KEY_REFUSED` (asked again with
33
+ `askKey`), no credits `MODEL_CREDITS`, overloaded (529) `MODEL_BUSY`.
34
+
35
+ ## [0.5.1] — 2026-10-15
36
+
37
+ ### Fixed
38
+ - **An image with no picture yet is left out of the email.** `<mj-image>`
39
+ without `src` (AI drafts leave them for the user, so do the gallery's
40
+ newsletter and promotion) compiled to `<img src="">` — a broken image in
41
+ every inbox, a red cross in Outlook. Saving, exporting and sending now leave
42
+ it out; the canvas and the preview (`/render`) still draw the frame to fill.
43
+ Saving says how many are left out. `prepareMjml(…, { emptyImages: 'keep' })`
44
+ keeps them; `countImagesWithoutPicture(mjml)` counts them.
45
+ - **Drafts through `openrouter/free` were cut short.** Most free models
46
+ reason first — 5 000 to 12 000 tokens before the email — and hit the limit
47
+ of 16 000 (`TRUNCATED`). OpenRouter now asks for less reasoning
48
+ (`reasoning: { effort: 'low' }`) and allows 32 000 tokens.
49
+ - **AI drafts, what free models get wrong, fixed without asking again:** an
50
+ `<mj-image>` put inside a text (dead HTML there) becomes an image block in
51
+ its place; attributes MJML reports as illegal (`align` on a column) are
52
+ removed after compiling; a button whose words do not read on its fill (a
53
+ text color given, the fill forgotten) gets white or ink words. The prompt
54
+ shows the greeting's default in three languages: a French email no longer
55
+ starts with "Hello … there".
56
+ - **A 403 was always the key.** One about the model (only for some apps, not
57
+ in this region), the content (moderation) or the money now says so
58
+ (`MODEL_ERROR`, `REFUSED`, `MODEL_CREDITS`), and `askKey` keeps the user's
59
+ key. Gemini's wrong key (a 400) is `KEY_REFUSED`.
60
+
61
+ ### Added
62
+ - `providerEmailModel({ body })` and `provider: { body, maxTokens }`: fields
63
+ of your own in each request (OpenRouter's `models` fallback list, `reasoning`)
64
+ and a provider's own output limit.
65
+
9
66
  ## [0.5.0] — 2026-10-15
10
67
 
11
68
  ### Changed — the AI is a module (breaking)
package/README.md CHANGED
@@ -61,7 +61,8 @@ Best Email Builder is the second option done once, properly, and given away:
61
61
  model's MJML is cleaned and checked like any template before it gets there.
62
62
  OpenRouter, Gemini, Groq, Mistral, OpenAI, DeepSeek, Cerebras, Ollama or any
63
63
  OpenAI-compatible API by its key — free tiers included — and Claude through
64
- its SDK; on your server, or in the page with each user's own key.
64
+ its SDK; on your server, or in the page with each user's own key — and the
65
+ provider they choose.
65
66
  - **Any text rewritten in one click** — select a text or a button: *Shorter*,
66
67
  *Longer*, *Fix mistakes*, *Friendlier*, *More formal*, *Translate* into 13
67
68
  languages, or say what to change in your own words. The text changes in
@@ -363,9 +364,19 @@ the provider refuses is forgotten and asked again. Off by default:
363
364
  const ai = createAi({ provider: 'openrouter', askKey: true });
364
365
  ```
365
366
 
366
- `ai.key` reads and changes it: `get()`, `set(key)`, `forget()`,
367
- `request()`, `subscribe(fn)`, `state()` (`{ asking, refused, connected }`).
368
- This is what the live demo does.
367
+ Give a list and each user chooses: the dialog shows the providers as cards
368
+ (name, model, free or not, a dot on those whose key this browser keeps). The
369
+ choice and one key per provider are remembered; going back to a provider whose
370
+ key is kept needs nothing pasted.
371
+
372
+ ```jsx
373
+ const ai = createAi({ provider: ['openai', 'mistral', 'gemini', 'openrouter'], askKey: true });
374
+ ```
375
+
376
+ `ai.key` reads and changes it: `provider` (the one in use), `providers`,
377
+ `choose(id)`, `get(id?)`, `set(key)`, `forget()`, `request()` (opens the
378
+ dialog — to change provider, for one), `subscribe(fn)`, `state()`
379
+ (`{ asking, refused, connected }`). This is what the live demo does.
369
380
 
370
381
  **In the page, with one key for everyone** — prototypes and internal tools
371
382
  only, the key is in the page's code: `createAi({ provider: 'groq', apiKey })`.
@@ -373,8 +384,8 @@ only, the key is in the page's code: `createAi({ provider: 'groq', apiKey })`.
373
384
  #### Providers
374
385
 
375
386
  Every provider here speaks the OpenAI *chat completions* API; nothing to
376
- install. `provider` is an id below, or `{ id, model, baseUrl, headers }` to
377
- change the model or reach an endpoint of your own. `AI_PROVIDERS` lists them
387
+ install. `provider` is an id below, or `{ id, model, baseUrl, headers, maxTokens, body }`
388
+ to change the model or reach an endpoint of your own. `AI_PROVIDERS` lists them
378
389
  (name, address, default model, where to get a key, what is free).
379
390
 
380
391
  | `provider` | Default model | Free to start | Key |
@@ -396,19 +407,47 @@ providerEmailModel({ provider: { baseUrl: 'http://localhost:1234/v1', model: 'qw
396
407
 
397
408
  - Model names change often: the defaults were current at this release —
398
409
  pass `model` to be sure of yours.
410
+ - `openrouter/free` costs nothing and is slow: most free models reason before
411
+ they write, so an email takes two to six minutes, and some days a free
412
+ model is busy upstream. OpenRouter is asked for less reasoning
413
+ (`reasoning: { effort: 'low' }`) and given room for it (`maxTokens: 32000`;
414
+ 16 000 elsewhere). For drafts in seconds, use a free tier with a key of its
415
+ own (Gemini, Groq, Cerebras) or a paid model.
416
+ - `body` adds fields of your own to each request — OpenRouter's fallback list,
417
+ for one: `providerEmailModel({ provider: 'openrouter', apiKey, body: { models: [...] } })`.
418
+ It cannot change the model, the messages or the output limit.
399
419
  - The answer is asked as structured output (a JSON schema); a model that
400
420
  refuses it is asked for a JSON object, then for plain JSON — once, then
401
421
  remembered. A model whose structured output breaks the JSON (some do on the
402
422
  quotes of HTML) can be told to skip it: `provider: { …, format: 'text' }`.
403
423
  - The provider's failures become errors the interface explains, in its
404
424
  language: `KEY_MISSING`, `KEY_REFUSED`, `MODEL_CREDITS`, `MODEL_BUSY`,
405
- `MODEL_ERROR` (with the provider's own text in `detail`). None is a 401:
406
- the key is the AI's, not the user's session.
425
+ `MODEL_ERROR` (with the provider's own text in `detail`). An exhausted
426
+ quota is `MODEL_CREDITS`, not `MODEL_BUSY`, even when it comes as a 429
427
+ (OpenAI's `insufficient_quota`) or a 400 (Anthropic's credit balance);
428
+ `failureCode(status, message)` is that rule, for a model of your own. None is a 401:
429
+ the key is the AI's, not the user's session. A 403 about the model (not
430
+ available here, in this region) or the content is not `KEY_REFUSED`: with
431
+ `askKey`, the user's key is not thrown away for it.
407
432
  - **Claude**: `claudeEmailModel({ client: new Anthropic() })` with the
408
433
  Anthropic SDK (`npm install @anthropic-ai/sdk`) — `claude-opus-5-5` by
409
434
  default, `effort: 'medium'`, structured output, streamed so a long email
410
435
  does not hit a request timeout — or through OpenRouter with an
411
- `anthropic/…` model and one key for every model.
436
+ `anthropic/…` model and one key for every model. With `askKey`, Claude is
437
+ one more provider to choose, with the user's own Anthropic key and the SDK
438
+ loaded only when it is chosen:
439
+
440
+ ```js
441
+ import { claudeProvider, createAi } from 'best-email-builder/ai';
442
+
443
+ const claude = claudeProvider({
444
+ client: async (apiKey) => {
445
+ const { default: Anthropic } = await import('@anthropic-ai/sdk');
446
+ return new Anthropic({ apiKey, dangerouslyAllowBrowser: true });
447
+ },
448
+ });
449
+ createAi({ provider: ['openai', claude, 'gemini'], askKey: true });
450
+ ```
412
451
  - **Anything else** is a function `({ system, messages, schema, signal, task })
413
452
  => Promise<string>` returning the JSON text — `createEmailGenerator({ model })`
414
453
  on the server, `createAi({ model })` in the page.
@@ -463,7 +502,8 @@ another click another one, the first row brings the user's subject back.
463
502
  are exported.
464
503
 
465
504
  The live demo has no server: there, the AI runs in the page with the
466
- visitor's own OpenRouter key (`createAi({ provider: 'openrouter', askKey: true })`).
505
+ visitor's own key, for the provider they choose — OpenAI, Claude, Mistral,
506
+ Gemini or OpenRouter (`createAi({ provider: ['openai', claude, 'mistral', 'gemini', 'openrouter'], askKey: true })`).
467
507
 
468
508
  ### 5. Your brand (optional)
469
509
 
package/dist/ai-server.js CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  createRewriter,
21
21
  createSubjectSuggester,
22
22
  emailWords,
23
+ failureCode,
23
24
  normalizeGeneratedMjml,
24
25
  parseDraft,
25
26
  providerEmailModel,
@@ -29,10 +30,10 @@ import {
29
30
  reviewDraft,
30
31
  reviewRewrite,
31
32
  reviewSubjects
32
- } from "./chunk-MPX5CWTZ.js";
33
- import "./chunk-2RZHGAH5.js";
34
- import "./chunk-5SASKCUC.js";
35
- import "./chunk-DFNHICTF.js";
33
+ } from "./chunk-NZIQF7IW.js";
34
+ import "./chunk-OWVXKMLI.js";
35
+ import "./chunk-W424KPCR.js";
36
+ import "./chunk-JI36FNWE.js";
36
37
  export {
37
38
  AI_MESSAGES,
38
39
  AI_PROVIDERS,
@@ -55,6 +56,7 @@ export {
55
56
  createRewriter,
56
57
  createSubjectSuggester,
57
58
  emailWords,
59
+ failureCode,
58
60
  normalizeGeneratedMjml,
59
61
  parseDraft,
60
62
  providerEmailModel,
package/dist/ai.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  cleanRichText
3
- } from "./chunk-KXJJSVN5.js";
3
+ } from "./chunk-SAIW46IC.js";
4
4
  import {
5
5
  Button,
6
6
  Dialog,
@@ -23,8 +23,8 @@ import {
23
23
  createHttpCaller,
24
24
  useSearchShortcut,
25
25
  useUi
26
- } from "./chunk-LXR4QTGP.js";
27
- import "./chunk-7U6BAKM7.js";
26
+ } from "./chunk-JU3OUO6X.js";
27
+ import "./chunk-QPAZLCPO.js";
28
28
  import {
29
29
  AI_MESSAGES,
30
30
  AI_PROVIDERS,
@@ -38,21 +38,24 @@ import {
38
38
  REWRITE_ACTIONS,
39
39
  REWRITE_LANGUAGES,
40
40
  claudeEmailModel,
41
+ claudeProvider,
41
42
  createEmailGenerator,
42
43
  emailWords,
44
+ failureCode,
43
45
  providerEmailModel,
44
46
  purposesByCategory,
45
47
  resolveProvider
46
- } from "./chunk-MPX5CWTZ.js";
47
- import "./chunk-2RZHGAH5.js";
48
- import "./chunk-5SASKCUC.js";
48
+ } from "./chunk-NZIQF7IW.js";
49
+ import "./chunk-OWVXKMLI.js";
50
+ import "./chunk-W424KPCR.js";
49
51
  import {
50
52
  brandTemplate,
51
53
  hasBrand
52
- } from "./chunk-DFNHICTF.js";
54
+ } from "./chunk-JI36FNWE.js";
53
55
 
54
56
  // src/ai/keys.js
55
57
  var STORAGE_PREFIX = "best-email-builder:ai-key:";
58
+ var CHOICE_SLOT = "best-email-builder:ai-provider";
56
59
  function browserStorage() {
57
60
  try {
58
61
  const storage = globalThis.localStorage;
@@ -61,21 +64,31 @@ function browserStorage() {
61
64
  return null;
62
65
  }
63
66
  }
64
- function createKeyStore(provider) {
65
- const slot = STORAGE_PREFIX + provider.id;
66
- let memory = "";
67
+ var choiceOf = (provider) => Object.freeze({
68
+ id: provider.id,
69
+ name: provider.name,
70
+ model: provider.model ?? null,
71
+ keyUrl: provider.keyUrl,
72
+ keyHint: provider.keyHint,
73
+ free: provider.free
74
+ });
75
+ function createKeyStore(providerOrList) {
76
+ const providers = Object.freeze((Array.isArray(providerOrList) ? providerOrList : [providerOrList]).map(choiceOf));
77
+ if (!providers.length) throw new TypeError("createKeyStore: at least one provider");
78
+ const known = (id) => providers.some((provider) => provider.id === id);
79
+ const memory = /* @__PURE__ */ new Map();
67
80
  let refused = false;
68
81
  let pending = null;
69
82
  const listeners = /* @__PURE__ */ new Set();
70
83
  let snapshot;
71
- const read = () => {
84
+ const read = (slot) => {
72
85
  try {
73
86
  return browserStorage()?.getItem(slot) || "";
74
87
  } catch {
75
88
  return "";
76
89
  }
77
90
  };
78
- const write = (value) => {
91
+ const write = (slot, value) => {
79
92
  try {
80
93
  const storage = browserStorage();
81
94
  if (value) storage?.setItem(slot, value);
@@ -83,7 +96,14 @@ function createKeyStore(provider) {
83
96
  } catch {
84
97
  }
85
98
  };
86
- const get = () => memory || read();
99
+ const keyOf = (id) => memory.has(id) ? memory.get(id) : read(STORAGE_PREFIX + id);
100
+ const keep = (id, value) => {
101
+ memory.set(id, value);
102
+ write(STORAGE_PREFIX + id, value);
103
+ };
104
+ const kept = read(CHOICE_SLOT);
105
+ let current = known(kept) ? kept : (providers.find((provider) => keyOf(provider.id)) || providers[0]).id;
106
+ const get = (id) => keyOf(id === void 0 ? current : String(id));
87
107
  const changed = () => {
88
108
  snapshot = Object.freeze({ asking: Boolean(pending), refused, connected: Boolean(get()) });
89
109
  for (const listener of [...listeners]) listener();
@@ -92,9 +112,8 @@ function createKeyStore(provider) {
92
112
  const answer = (key) => {
93
113
  const value = String(key || "").trim();
94
114
  if (value) {
95
- memory = value;
115
+ keep(current, value);
96
116
  refused = false;
97
- write(value);
98
117
  }
99
118
  const waiting = pending;
100
119
  pending = null;
@@ -102,15 +121,28 @@ function createKeyStore(provider) {
102
121
  waiting?.resolve(value);
103
122
  };
104
123
  return Object.freeze({
105
- provider: Object.freeze({ id: provider.id, name: provider.name, keyUrl: provider.keyUrl, keyHint: provider.keyHint, free: provider.free }),
124
+ get provider() {
125
+ return (
126
+ /** @type {ProviderChoice} */
127
+ providers.find((provider) => provider.id === current)
128
+ );
129
+ },
130
+ providers,
106
131
  get,
107
132
  set: (key) => {
108
133
  if (typeof key !== "string" || !key.trim()) throw new TypeError("ai.key.set: a non-empty key");
109
134
  answer(key);
110
135
  },
111
136
  forget: () => {
112
- memory = "";
113
- write("");
137
+ keep(current, "");
138
+ changed();
139
+ },
140
+ choose: (id) => {
141
+ if (!known(id)) throw new TypeError(`ai.key.choose: one of ${providers.map((provider) => provider.id).join(", ")}`);
142
+ if (id === current) return;
143
+ current = id;
144
+ refused = false;
145
+ write(CHOICE_SLOT, id);
114
146
  changed();
115
147
  },
116
148
  request: () => {
@@ -130,8 +162,7 @@ function createKeyStore(provider) {
130
162
  state: () => snapshot,
131
163
  answer: (key) => answer(key || ""),
132
164
  refuse: () => {
133
- memory = "";
134
- write("");
165
+ keep(current, "");
135
166
  refused = true;
136
167
  changed();
137
168
  }
@@ -147,28 +178,54 @@ function ConnectAi({ aiKey }) {
147
178
  const state = useSyncExternalStore(aiKey.subscribe, aiKey.state, aiKey.state);
148
179
  const [value, setValue] = useState("");
149
180
  const [shown, setShown] = useState(false);
181
+ const [chosenId, setChosenId] = useState(aiKey.provider.id);
150
182
  const inputRef = useRef(
151
183
  /** @type {HTMLInputElement|null} */
152
184
  null
153
185
  );
186
+ const choicesRef = useRef(
187
+ /** @type {HTMLDivElement|null} */
188
+ null
189
+ );
154
190
  const inputId = useId();
155
191
  const hintId = useId();
156
- const { name, keyUrl, keyHint, free } = aiKey.provider;
192
+ const choices = aiKey.providers;
193
+ const chosen = choices.find((provider) => provider.id === chosenId) || aiKey.provider;
194
+ const { name, keyUrl, keyHint, free } = chosen;
157
195
  useEffect(() => {
158
196
  if (!state.asking) return;
197
+ setChosenId(aiKey.provider.id);
159
198
  setValue("");
160
199
  setShown(false);
161
- }, [state.asking]);
200
+ }, [state.asking, aiKey]);
162
201
  const key = value.trim();
202
+ const saved = aiKey.get(chosen.id);
163
203
  const unusual = Boolean(key && keyHint && !key.startsWith(keyHint));
204
+ const refused = state.refused && chosen.id === aiKey.provider.id;
164
205
  const submit = (event) => {
165
206
  event.preventDefault();
166
- if (key) aiKey.answer(key);
207
+ const answer = key || saved;
208
+ if (!answer) return;
209
+ if (chosen.id !== aiKey.provider.id) aiKey.choose(chosen.id);
210
+ aiKey.answer(answer);
211
+ };
212
+ const choose = (id) => {
213
+ setChosenId(id);
214
+ setValue("");
215
+ };
216
+ const onChoicesKeyDown = (event) => {
217
+ const at = choices.findIndex((provider) => provider.id === chosen.id);
218
+ const step = { ArrowDown: 1, ArrowRight: 1, ArrowUp: -1, ArrowLeft: -1 }[event.key];
219
+ const to = step ? (at + step + choices.length) % choices.length : event.key === "Home" ? 0 : event.key === "End" ? choices.length - 1 : -1;
220
+ if (to === -1) return;
221
+ event.preventDefault();
222
+ choose(choices[to].id);
223
+ choicesRef.current?.querySelectorAll('[role="radio"]')[to]?.focus();
167
224
  };
168
225
  return /* @__PURE__ */ jsx(Dialog, { open: state.asking, onOpenChange: (open) => !open && aiKey.answer(null), children: /* @__PURE__ */ jsx(
169
226
  DialogContent,
170
227
  {
171
- className: "eb:gap-0 eb:overflow-hidden eb:p-0 eb:sm:max-w-md",
228
+ className: cn("eb:gap-0 eb:overflow-hidden eb:p-0", choices.length > 1 ? "eb:sm:max-w-xl" : "eb:sm:max-w-md"),
172
229
  "data-testid": "eb-ai-connect",
173
230
  onOpenAutoFocus: (event) => {
174
231
  event.preventDefault();
@@ -183,7 +240,44 @@ function ConnectAi({ aiKey }) {
183
240
  /* @__PURE__ */ jsx(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 }) })
184
241
  ] })
185
242
  ] }),
186
- state.refused && /* @__PURE__ */ jsxs("p", { role: "alert", className: "eb:flex eb:items-start eb:gap-2 eb:rounded-md eb:border eb:border-destructive/30 eb:bg-destructive/10 eb:px-3 eb:py-2 eb:text-[12.5px] eb:text-destructive", children: [
243
+ choices.length > 1 && /* @__PURE__ */ jsx(
244
+ "div",
245
+ {
246
+ ref: choicesRef,
247
+ role: "radiogroup",
248
+ "aria-label": t("AI provider"),
249
+ className: "eb:grid eb:grid-cols-2 eb:gap-1.5 eb:sm:grid-cols-3",
250
+ onKeyDown: onChoicesKeyDown,
251
+ "data-testid": "eb-ai-connect-providers",
252
+ children: choices.map((provider) => {
253
+ const checked = provider.id === chosen.id;
254
+ return /* @__PURE__ */ jsxs(
255
+ "button",
256
+ {
257
+ type: "button",
258
+ role: "radio",
259
+ "aria-checked": checked,
260
+ tabIndex: checked ? 0 : -1,
261
+ onClick: () => choose(provider.id),
262
+ className: cn(
263
+ "eb:flex eb:min-w-0 eb:flex-col eb:gap-0.5 eb:rounded-md eb:border eb:px-2.5 eb:py-2 eb:text-left eb:transition-colors eb:outline-none eb:focus-visible:ring-[3px] eb:focus-visible:ring-ring/50",
264
+ checked ? "eb:border-primary eb:bg-primary/10" : "eb:border-border eb:hover:bg-muted"
265
+ ),
266
+ children: [
267
+ /* @__PURE__ */ jsxs("span", { className: "eb:flex eb:items-center eb:gap-1.5 eb:text-[13px] eb:leading-tight eb:font-medium eb:text-foreground", children: [
268
+ /* @__PURE__ */ jsx("span", { className: "eb:truncate", children: provider.name }),
269
+ provider.free && /* @__PURE__ */ jsx(Gift, { className: "eb:size-3 eb:shrink-0 eb:text-success", "aria-label": t(provider.free) }),
270
+ aiKey.get(provider.id) && /* @__PURE__ */ jsx("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") })
271
+ ] }),
272
+ provider.model && /* @__PURE__ */ jsx("span", { className: "eb:truncate eb:font-mono eb:text-[10.5px] eb:text-foreground-muted", title: provider.model, children: provider.model })
273
+ ]
274
+ },
275
+ provider.id
276
+ );
277
+ })
278
+ }
279
+ ),
280
+ refused && /* @__PURE__ */ jsxs("p", { role: "alert", className: "eb:flex eb:items-start eb:gap-2 eb:rounded-md eb:border eb:border-destructive/30 eb:bg-destructive/10 eb:px-3 eb:py-2 eb:text-[12.5px] eb:text-destructive", children: [
187
281
  /* @__PURE__ */ jsx(TriangleAlert, { className: "eb:mt-px eb:size-3.5 eb:shrink-0", "aria-hidden": "true" }),
188
282
  t("{provider} refused the last key. Paste another one.", { provider: name })
189
283
  ] }),
@@ -230,7 +324,7 @@ function ConnectAi({ aiKey }) {
230
324
  }
231
325
  )
232
326
  ] }),
233
- /* @__PURE__ */ jsx("p", { id: hintId, className: "eb:min-h-4 eb:text-[11.5px] eb:text-foreground-muted", children: unusual ? /* @__PURE__ */ jsx("span", { className: "eb:text-warning", children: t("{provider} keys usually start with {prefix}.", { provider: name, prefix: keyHint }) }) : keyHint ? t("{provider} keys start with {prefix}.", { provider: name, prefix: keyHint }) : null })
327
+ /* @__PURE__ */ jsx("p", { id: hintId, className: "eb:min-h-4 eb:text-[11.5px] eb:text-foreground-muted", children: unusual ? /* @__PURE__ */ jsx("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 })
234
328
  ] })
235
329
  ] }),
236
330
  /* @__PURE__ */ jsxs("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: [
@@ -239,7 +333,7 @@ function ConnectAi({ aiKey }) {
239
333
  ] }),
240
334
  /* @__PURE__ */ jsxs(DialogFooter, { className: "eb:border-t eb:border-border eb:px-6 eb:py-4", children: [
241
335
  /* @__PURE__ */ jsx(Button, { type: "button", variant: "ghost", onClick: () => aiKey.answer(null), children: t("Cancel") }),
242
- /* @__PURE__ */ jsx(Button, { type: "submit", disabled: !key, "data-testid": "eb-ai-connect-submit", children: t("Connect") })
336
+ /* @__PURE__ */ jsx(Button, { type: "submit", disabled: !key && !saved, "data-testid": "eb-ai-connect-submit", children: t("Connect") })
243
337
  ] })
244
338
  ] })
245
339
  }
@@ -753,7 +847,13 @@ function createAi(options = {}) {
753
847
  }
754
848
  if (typeof askKey !== "boolean") throw new TypeError("createAi: `askKey` must be true or false");
755
849
  if (apiKey && askKey) throw new TypeError("createAi: `apiKey` (one key for all) or `askKey` (each user's own), not both");
756
- const provider = usesProvider ? resolveProvider(providerOption) : null;
850
+ const choice = Array.isArray(providerOption);
851
+ if (choice && (!providerOption.length || !askKey)) {
852
+ throw new TypeError("createAi: a list of providers is for `askKey: true` \u2014 each user chooses one and connects their own key");
853
+ }
854
+ const providers = usesProvider ? (choice ? providerOption : [providerOption]).map(providerOf) : [];
855
+ if (new Set(providers.map((provider2) => provider2.id)).size !== providers.length) throw new TypeError("createAi: each provider once");
856
+ const [provider = null] = providers;
757
857
  if (provider && !apiKey && !askKey && !provider.keyless) {
758
858
  throw new TypeError(`createAi: ${provider.name} needs a key \u2014 \`apiKey\`, or \`askKey: true\` to let each user connect their own`);
759
859
  }
@@ -765,18 +865,31 @@ function createAi(options = {}) {
765
865
  }
766
866
  const ownOnly = !model && !http && PARTS.some((part) => typeof options[part] === "function");
767
867
  let transport = {};
768
- const key = provider && askKey ? createKeyStore(provider) : null;
868
+ const key = provider && askKey ? createKeyStore(providers) : null;
769
869
  if (provider) {
770
- const generator = createEmailGenerator({
771
- model: providerEmailModel({
772
- provider,
773
- fetch: fetchImpl,
774
- // The user's key, asked when there is none ('' when they cancel: KEY_MISSING).
775
- apiKey: key ? async () => key.get() || await key.request() : apiKey
776
- }),
777
- maxAttempts,
778
- compile
779
- });
870
+ const models = /* @__PURE__ */ new Map();
871
+ const keyFor = () => key ? key.get() : apiKey || "";
872
+ const modelOf = (id) => {
873
+ if (!models.has(id)) {
874
+ const chosen = (
875
+ /** @type {import('./providers.js').AiProvider} */
876
+ providers.find((item) => item.id === id)
877
+ );
878
+ models.set(
879
+ id,
880
+ chosen.createModel ? chosen.createModel({ apiKey: keyFor, fetch: fetchImpl }) : providerEmailModel({ provider: chosen, fetch: fetchImpl, apiKey: keyFor })
881
+ );
882
+ }
883
+ return (
884
+ /** @type {import('./generator.js').EmailModel} */
885
+ models.get(id)
886
+ );
887
+ };
888
+ const model2 = async (request) => {
889
+ if (key && !key.get() && !providers.find((item) => item.id === key.provider.id)?.keyless) await key.request();
890
+ return modelOf(key ? key.provider.id : provider.id)(request);
891
+ };
892
+ const generator = createEmailGenerator({ model: model2, maxAttempts, compile });
780
893
  for (const part of PARTS) transport[part] = key ? askedAgain(generator[part], key) : generator[part];
781
894
  } else if (model) {
782
895
  transport = createEmailGenerator({ model, maxAttempts, compile });
@@ -810,6 +923,19 @@ function createAi(options = {}) {
810
923
  components: Object.freeze({ Generate: GenerateButton, Rewrite: RewriteText, Subjects: SubjectIdeas, Connect: key ? ConnectAi : null })
811
924
  });
812
925
  }
926
+ function providerOf(option) {
927
+ if (option && typeof option === "object" && typeof option.createModel === "function") {
928
+ if (!option.id || !option.name) throw new TypeError("createAi: a provider with `createModel` needs an `id` and a `name`");
929
+ return (
930
+ /** @type {import('./providers.js').AiProvider} */
931
+ option
932
+ );
933
+ }
934
+ return resolveProvider(
935
+ /** @type {any} */
936
+ option
937
+ );
938
+ }
813
939
  function askedAgain(run, key) {
814
940
  return async (input, options) => {
815
941
  try {
@@ -843,9 +969,11 @@ export {
843
969
  REWRITE_ACTIONS,
844
970
  REWRITE_LANGUAGES,
845
971
  claudeEmailModel,
972
+ claudeProvider,
846
973
  createAi,
847
974
  createEmailGenerator,
848
975
  emailWords,
976
+ failureCode,
849
977
  providerEmailModel,
850
978
  resolveProvider
851
979
  };