best-email-builder 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,27 @@ All notable changes are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.9.0] — 2026-10-16
10
+
11
+ ### Added
12
+ - **An OpenAI-compatible API of the user's own** in the Connect dialog: add
13
+ `'openai-compatible'` to `createAi({ provider: [...], askKey: true })`
14
+ and the user gives its address (LM Studio, vLLM, Ollama on another
15
+ machine, a proxy…), a key only if it needs one, and a model from its
16
+ `/models` list. The address is remembered like the key. `ai.key` gains
17
+ `address(id?)`, `setAddress(url, id?)` and `ready()`; `listModels` takes
18
+ an `address`. A provider without a key of its own (Ollama) connects
19
+ without one, once a model is chosen.
20
+ - The live demo offers it too, and its language list is now a list of its
21
+ own instead of the browser's: by mouse, or by keyboard (arrows, Home/End,
22
+ type a letter, Enter, Esc).
23
+
24
+ ### Changed
25
+ - The Connect dialog and the list of models show what is free in words
26
+ only: no gift icons.
27
+ - `ai.key.state().connected` is true once the provider in use can write:
28
+ its key (unless it needs none), its address and its model.
29
+
9
30
  ## [0.8.0] — 2026-10-16
10
31
 
11
32
  ### Added
package/README.md CHANGED
@@ -69,9 +69,9 @@ once and given away.
69
69
  - *Rewrite* any text: shorter, longer, fixed, friendlier, more formal, or
70
70
  translated into 13 languages.
71
71
  - *Suggest* three subject lines drawn from the email.
72
- - OpenRouter, Gemini, Groq, Mistral, OpenAI, DeepSeek, Cerebras, Ollama,
73
- Claude or your own agent. The model runs on your server, or in the page
74
- with each user's own key.
72
+ - OpenRouter, Gemini, Groq, Mistral, OpenAI, DeepSeek, Cerebras, Ollama, any
73
+ OpenAI-compatible API, Claude or your own agent. The model runs on your
74
+ server, or in the page with each user's own key.
75
75
 
76
76
  **Your app**
77
77
  - Merge-tag variables, including ones your users create.
package/dist/ai-server.js CHANGED
@@ -34,7 +34,7 @@ import {
34
34
  reviewDraft,
35
35
  reviewRewrite,
36
36
  reviewSubjects
37
- } from "./chunk-SHHHYRPC.js";
37
+ } from "./chunk-VJKF6SR4.js";
38
38
  import "./chunk-OWVXKMLI.js";
39
39
  import "./chunk-AXIM6E7T.js";
40
40
  import "./chunk-62HBUBBI.js";
package/dist/ai.js CHANGED
@@ -23,8 +23,8 @@ import {
23
23
  createHttpCaller,
24
24
  useSearchShortcut,
25
25
  useUi
26
- } from "./chunk-DXWIVGHP.js";
27
- import "./chunk-FAEVYSIF.js";
26
+ } from "./chunk-D4JBPAJ5.js";
27
+ import "./chunk-4IXP5YVN.js";
28
28
  import {
29
29
  AGENT_ERROR_CODES,
30
30
  AGENT_PROTOCOL,
@@ -49,7 +49,7 @@ import {
49
49
  providerEmailModel,
50
50
  purposesByCategory,
51
51
  resolveProvider
52
- } from "./chunk-SHHHYRPC.js";
52
+ } from "./chunk-VJKF6SR4.js";
53
53
  import "./chunk-OWVXKMLI.js";
54
54
  import "./chunk-AXIM6E7T.js";
55
55
  import {
@@ -61,6 +61,7 @@ import {
61
61
  var STORAGE_PREFIX = "best-email-builder:ai-key:";
62
62
  var CHOICE_SLOT = "best-email-builder:ai-provider";
63
63
  var MODEL_PREFIX = "best-email-builder:ai-model:";
64
+ var ADDRESS_PREFIX = "best-email-builder:ai-address:";
64
65
  function browserStorage() {
65
66
  try {
66
67
  const storage = globalThis.localStorage;
@@ -76,8 +77,11 @@ var choiceOf = (provider) => Object.freeze({
76
77
  keyUrl: provider.keyUrl,
77
78
  keyHint: provider.keyHint,
78
79
  free: provider.free,
79
- publicModels: Boolean(provider.publicModels)
80
+ publicModels: Boolean(provider.publicModels),
81
+ keyless: Boolean(provider.keyless),
82
+ needsAddress: !provider.createModel && !provider.baseUrl
80
83
  });
84
+ var cleanAddress = (url) => String(url || "").trim().replace(/\/+$/, "");
81
85
  function createKeyStore(providerOrList, { fetch: fetchImpl } = {}) {
82
86
  const originals = Array.isArray(providerOrList) ? providerOrList : [providerOrList];
83
87
  const providers = Object.freeze(originals.map(choiceOf));
@@ -117,17 +121,28 @@ function createKeyStore(providerOrList, { fetch: fetchImpl } = {}) {
117
121
  return id;
118
122
  };
119
123
  const lists = /* @__PURE__ */ new Map();
124
+ const modelOf = (choice) => (memory.get(MODEL_PREFIX + choice.id) ?? read(MODEL_PREFIX + choice.id)) || choice.model || "";
125
+ const addressOf = (choice) => choice.needsAddress ? cleanAddress(memory.get(ADDRESS_PREFIX + choice.id) ?? read(ADDRESS_PREFIX + choice.id)) : "";
126
+ const ready = () => {
127
+ const choice = (
128
+ /** @type {ProviderChoice} */
129
+ providers.find((provider) => provider.id === current)
130
+ );
131
+ if (refused) return false;
132
+ if (!choice.keyless && !get()) return false;
133
+ if (choice.needsAddress && !addressOf(choice)) return false;
134
+ return Boolean(modelOf(choice));
135
+ };
120
136
  const changed = () => {
121
- snapshot = Object.freeze({ asking: Boolean(pending), refused, connected: Boolean(get()) });
137
+ snapshot = Object.freeze({ asking: Boolean(pending), refused, connected: ready() });
122
138
  for (const listener of [...listeners]) listener();
123
139
  };
124
- snapshot = Object.freeze({ asking: false, refused: false, connected: Boolean(get()) });
140
+ snapshot = Object.freeze({ asking: false, refused: false, connected: ready() });
125
141
  const answer = (key) => {
126
142
  const value = String(key || "").trim();
127
- if (value) {
128
- keep(current, value);
129
- refused = false;
130
- }
143
+ if (value) keep(current, value);
144
+ if (key !== null && (value || get() || /** @type {ProviderChoice} */
145
+ providers.find((provider) => provider.id === current).keyless)) refused = false;
131
146
  const waiting = pending;
132
147
  pending = null;
133
148
  changed();
@@ -158,14 +173,27 @@ function createKeyStore(providerOrList, { fetch: fetchImpl } = {}) {
158
173
  write(CHOICE_SLOT, id);
159
174
  changed();
160
175
  },
161
- model: (id) => {
176
+ model: (id) => modelOf(
177
+ /** @type {ProviderChoice} */
178
+ providers.find((provider) => provider.id === providerId(id))
179
+ ),
180
+ address: (id) => addressOf(
181
+ /** @type {ProviderChoice} */
182
+ providers.find((provider) => provider.id === providerId(id))
183
+ ),
184
+ setAddress: (url, id) => {
162
185
  const choice = (
163
186
  /** @type {ProviderChoice} */
164
187
  providers.find((provider) => provider.id === providerId(id))
165
188
  );
166
- const kept2 = memory.get(MODEL_PREFIX + choice.id) ?? read(MODEL_PREFIX + choice.id);
167
- return kept2 || choice.model || "";
189
+ if (!choice.needsAddress) throw new TypeError(`ai.key.setAddress: ${choice.name} has an address of its own`);
190
+ const value = cleanAddress(url);
191
+ if (value && !/^https?:\/\/[^\s/]+/i.test(value)) throw new TypeError("ai.key.setAddress: an http(s) address, e.g. http://localhost:1234/v1");
192
+ memory.set(ADDRESS_PREFIX + choice.id, value);
193
+ write(ADDRESS_PREFIX + choice.id, value);
194
+ changed();
168
195
  },
196
+ ready,
169
197
  chooseModel: (model, id) => {
170
198
  if (typeof model !== "string" || !model.trim()) throw new TypeError("ai.key.chooseModel: a model id");
171
199
  const choice = (
@@ -177,12 +205,16 @@ function createKeyStore(providerOrList, { fetch: fetchImpl } = {}) {
177
205
  write(MODEL_PREFIX + choice.id, value);
178
206
  changed();
179
207
  },
180
- listModels: async (id, { apiKey, signal } = {}) => {
181
- const at = providers.findIndex((provider) => provider.id === providerId(id));
208
+ listModels: async (id, { apiKey, address, signal } = {}) => {
209
+ const at = providers.findIndex((provider2) => provider2.id === providerId(id));
182
210
  const key = String(apiKey ?? keyOf(providers[at].id)).trim();
211
+ const where = providers[at].needsAddress ? cleanAddress(address ?? addressOf(providers[at])) : "";
212
+ if (providers[at].needsAddress && !where) return [];
183
213
  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 }));
214
+ ${key}
215
+ ${where}`;
216
+ const provider = where ? { ...originals[at], baseUrl: where } : originals[at];
217
+ if (!lists.has(slot)) lists.set(slot, await listProviderModels({ provider, apiKey: key, fetch: fetchImpl, signal }));
186
218
  return lists.get(slot);
187
219
  },
188
220
  request: () => {
@@ -200,7 +232,7 @@ ${key}`;
200
232
  return () => listeners.delete(listener);
201
233
  },
202
234
  state: () => snapshot,
203
- answer: (key) => answer(key || ""),
235
+ answer: (key) => answer(key ?? null),
204
236
  refuse: () => {
205
237
  keep(current, "");
206
238
  refused = true;
@@ -211,16 +243,16 @@ ${key}`;
211
243
 
212
244
  // src/ai/ConnectAi.jsx
213
245
  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";
246
+ import { ArrowUpRight, Eye, EyeOff, KeyRound, ShieldCheck, TriangleAlert } from "lucide-react";
215
247
 
216
248
  // src/ai/ModelPicker.jsx
217
249
  import { useEffect, useId, useMemo, useRef, useState } from "react";
218
- import { Check, ChevronDown, Gift, LoaderCircle, Search } from "lucide-react";
250
+ import { Check, ChevronDown, LoaderCircle, Search } from "lucide-react";
219
251
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
220
- function ModelPicker({ aiKey, provider, apiKey, delay = 0, value, onChange, open, onOpenChange }) {
252
+ function ModelPicker({ aiKey, provider, apiKey, address = "", delay = 0, value, onChange, open, onOpenChange }) {
221
253
  const { t } = useUi();
222
254
  const [list, setList] = useState(
223
- /** @type {{status: 'loading'|'ready'|'needs-key'|'refused'|'failed', models: import('./providers.js').ModelChoice[]}} */
255
+ /** @type {{status: 'loading'|'ready'|'needs-key'|'needs-address'|'refused'|'failed', models: import('./providers.js').ModelChoice[]}} */
224
256
  { status: "loading", models: [] }
225
257
  );
226
258
  const [query, setQuery] = useState("");
@@ -239,14 +271,18 @@ function ModelPicker({ aiKey, provider, apiKey, delay = 0, value, onChange, open
239
271
  const listId = useId();
240
272
  const hintId = useId();
241
273
  useEffect(() => {
242
- if (!apiKey && !provider.publicModels) {
274
+ if (provider.needsAddress && !address) {
275
+ setList({ status: "needs-address", models: [] });
276
+ return void 0;
277
+ }
278
+ if (!apiKey && !provider.publicModels && !provider.keyless) {
243
279
  setList({ status: "needs-key", models: [] });
244
280
  return void 0;
245
281
  }
246
282
  const controller = new AbortController();
247
283
  setList({ status: "loading", models: [] });
248
284
  const timer = setTimeout(() => {
249
- aiKey.listModels(provider.id, { apiKey, signal: controller.signal }).then(
285
+ aiKey.listModels(provider.id, { apiKey, address, signal: controller.signal }).then(
250
286
  (models2) => !controller.signal.aborted && setList({ status: "ready", models: models2 }),
251
287
  (error) => !controller.signal.aborted && setList({ status: error?.code === "KEY_REFUSED" ? "refused" : "failed", models: [] })
252
288
  );
@@ -255,7 +291,7 @@ function ModelPicker({ aiKey, provider, apiKey, delay = 0, value, onChange, open
255
291
  clearTimeout(timer);
256
292
  controller.abort();
257
293
  };
258
- }, [aiKey, provider.id, provider.publicModels, apiKey, delay]);
294
+ }, [aiKey, provider.id, provider.publicModels, provider.keyless, provider.needsAddress, apiKey, address, delay]);
259
295
  useEffect(() => {
260
296
  if (!open) return;
261
297
  setQuery("");
@@ -305,10 +341,7 @@ function ModelPicker({ aiKey, provider, apiKey, delay = 0, value, onChange, open
305
341
  };
306
342
  const optionId = (at) => `${listId}-${at}`;
307
343
  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
- ] }),
344
+ 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
345
  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
346
  ] });
314
347
  const status = {
@@ -317,8 +350,9 @@ function ModelPicker({ aiKey, provider, apiKey, delay = 0, value, onChange, open
317
350
  t("Loading the models of {provider}\u2026", { provider: provider.name })
318
351
  ] }),
319
352
  "needs-key": t("Paste the key to see all the models of {provider}.", { provider: provider.name }),
353
+ "needs-address": t("Give the API address to see its models."),
320
354
  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."),
355
+ 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
356
  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
357
  }[list.status];
324
358
  return /* @__PURE__ */ jsxs("div", { className: "eb:flex eb:flex-col eb:gap-1.5", "data-testid": "eb-ai-connect-model-field", children: [
@@ -379,20 +413,17 @@ function ModelPicker({ aiKey, provider, apiKey, delay = 0, value, onChange, open
379
413
  }
380
414
  )
381
415
  ] }),
382
- hasFree && /* @__PURE__ */ jsxs(
416
+ hasFree && /* @__PURE__ */ jsx(
383
417
  "button",
384
418
  {
385
419
  type: "button",
386
420
  "aria-pressed": freeOnly,
387
421
  onClick: () => setFreeOnly((on) => !on),
388
422
  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",
423
+ "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
424
  freeOnly ? "eb:border-success/40 eb:bg-success/10 eb:text-success" : "eb:border-border eb:text-foreground-lighter eb:hover:bg-muted"
391
425
  ),
392
- children: [
393
- /* @__PURE__ */ jsx(Gift, { className: "eb:size-3.5", "aria-hidden": "true" }),
394
- t("Free only")
395
- ]
426
+ children: t("Free only")
396
427
  }
397
428
  )
398
429
  ] }),
@@ -460,10 +491,12 @@ function ModelPicker({ aiKey, provider, apiKey, delay = 0, value, onChange, open
460
491
  // src/ai/ConnectAi.jsx
461
492
  import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
462
493
  var LISTABLE_KEY = 20;
494
+ var ADDRESS = /^https?:\/\/[^\s/]+/i;
463
495
  function ConnectAi({ aiKey }) {
464
496
  const { t } = useUi();
465
497
  const state = useSyncExternalStore(aiKey.subscribe, aiKey.state, aiKey.state);
466
498
  const [value, setValue] = useState2("");
499
+ const [address, setAddress] = useState2("");
467
500
  const [shown, setShown] = useState2(false);
468
501
  const [chosenId, setChosenId] = useState2(aiKey.provider.id);
469
502
  const [picks, setPicks] = useState2(
@@ -475,18 +508,25 @@ function ConnectAi({ aiKey }) {
475
508
  /** @type {HTMLInputElement|null} */
476
509
  null
477
510
  );
511
+ const addressRef = useRef2(
512
+ /** @type {HTMLInputElement|null} */
513
+ null
514
+ );
478
515
  const choicesRef = useRef2(
479
516
  /** @type {HTMLDivElement|null} */
480
517
  null
481
518
  );
482
519
  const inputId = useId2();
483
520
  const hintId = useId2();
521
+ const addressId = useId2();
522
+ const addressHintId = useId2();
484
523
  const choices = aiKey.providers;
485
524
  const chosen = choices.find((provider) => provider.id === chosenId) || aiKey.provider;
486
- const { name, keyUrl, keyHint, free } = chosen;
525
+ const { name, keyUrl, keyHint, free, keyless, needsAddress } = chosen;
487
526
  useEffect2(() => {
488
527
  if (!state.asking) return;
489
528
  setChosenId(aiKey.provider.id);
529
+ setAddress(aiKey.address());
490
530
  setValue("");
491
531
  setShown(false);
492
532
  setPicks({});
@@ -497,16 +537,20 @@ function ConnectAi({ aiKey }) {
497
537
  const unusual = Boolean(key && keyHint && !key.startsWith(keyHint));
498
538
  const refused = state.refused && chosen.id === aiKey.provider.id;
499
539
  const modelFor = (id) => picks[id] ?? aiKey.model(id);
540
+ const where = address.trim().replace(/\/+$/, "");
541
+ const addressOk = !needsAddress || ADDRESS.test(where);
542
+ const ready = Boolean((key || saved || keyless) && (!needsAddress || where && addressOk) && modelFor(chosen.id));
500
543
  const submit = (event) => {
501
544
  event.preventDefault();
502
- const answer = key || saved;
503
- if (!answer) return;
545
+ if (!ready) return;
546
+ if (needsAddress && where !== aiKey.address(chosen.id)) aiKey.setAddress(where, chosen.id);
504
547
  if (chosen.id !== aiKey.provider.id) aiKey.choose(chosen.id);
505
548
  if (picks[chosen.id] && picks[chosen.id] !== aiKey.model(chosen.id)) aiKey.chooseModel(picks[chosen.id], chosen.id);
506
- aiKey.answer(answer);
549
+ aiKey.answer(key || saved);
507
550
  };
508
551
  const choose = (id) => {
509
552
  setChosenId(id);
553
+ setAddress(aiKey.address(id));
510
554
  setValue("");
511
555
  setModelsOpen(false);
512
556
  };
@@ -526,7 +570,7 @@ function ConnectAi({ aiKey }) {
526
570
  "data-testid": "eb-ai-connect",
527
571
  onOpenAutoFocus: (event) => {
528
572
  event.preventDefault();
529
- inputRef.current?.focus();
573
+ (needsAddress && !where ? addressRef : inputRef).current?.focus();
530
574
  },
531
575
  onEscapeKeyDown: (event) => {
532
576
  if (!modelsOpen) return;
@@ -539,7 +583,7 @@ function ConnectAi({ aiKey }) {
539
583
  /* @__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
584
  /* @__PURE__ */ jsxs2("div", { className: "eb:flex eb:flex-col eb:gap-1.5", children: [
541
585
  /* @__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 }) })
586
+ /* @__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
587
  ] })
544
588
  ] }),
545
589
  choices.length > 1 && /* @__PURE__ */ jsx2(
@@ -568,7 +612,6 @@ function ConnectAi({ aiKey }) {
568
612
  children: [
569
613
  /* @__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
614
  /* @__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
615
  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
616
  ] }),
574
617
  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 +644,34 @@ function ConnectAi({ aiKey }) {
601
644
  ]
602
645
  }
603
646
  ) }),
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
- ] })
647
+ 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) })
648
+ ] }),
649
+ needsAddress && /* @__PURE__ */ jsxs2("div", { className: "eb:flex eb:flex-col eb:gap-1.5", children: [
650
+ /* @__PURE__ */ jsx2(Label, { htmlFor: addressId, children: t("API address") }),
651
+ /* @__PURE__ */ jsx2(
652
+ Input,
653
+ {
654
+ ref: addressRef,
655
+ id: addressId,
656
+ type: "url",
657
+ inputMode: "url",
658
+ value: address,
659
+ onChange: (event) => setAddress(event.target.value),
660
+ placeholder: "http://localhost:1234/v1",
661
+ autoComplete: "url",
662
+ autoCapitalize: "off",
663
+ autoCorrect: "off",
664
+ spellCheck: false,
665
+ "aria-invalid": where && !addressOk ? true : void 0,
666
+ "aria-describedby": addressHintId,
667
+ className: "eb:h-9 eb:font-mono eb:text-[12.5px]",
668
+ "data-testid": "eb-ai-connect-address"
669
+ }
670
+ ),
671
+ /* @__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
672
  ] }),
609
673
  /* @__PURE__ */ jsxs2("div", { className: "eb:flex eb:flex-col eb:gap-1.5", children: [
610
- /* @__PURE__ */ jsx2(Label, { htmlFor: inputId, children: t("API key") }),
674
+ /* @__PURE__ */ jsx2(Label, { htmlFor: inputId, children: keyless ? t("API key (optional)") : t("API key") }),
611
675
  /* @__PURE__ */ jsxs2("div", { className: "eb:relative", children: [
612
676
  /* @__PURE__ */ jsx2(
613
677
  Input,
@@ -639,15 +703,16 @@ function ConnectAi({ aiKey }) {
639
703
  }
640
704
  )
641
705
  ] }),
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 })
706
+ /* @__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
707
  ] }),
644
708
  /* @__PURE__ */ jsx2(
645
709
  ModelPicker,
646
710
  {
647
711
  aiKey,
648
712
  provider: chosen,
649
- apiKey: key.length >= LISTABLE_KEY ? key : saved,
650
- delay: key.length >= LISTABLE_KEY ? 350 : 0,
713
+ apiKey: key.length >= LISTABLE_KEY || keyless && key ? key : saved,
714
+ address: addressOk ? where : "",
715
+ delay: key.length >= LISTABLE_KEY || keyless && key || where !== aiKey.address(chosen.id) ? 350 : 0,
651
716
  value: modelFor(chosen.id),
652
717
  onChange: (model) => setPicks((current) => ({ ...current, [chosen.id]: model })),
653
718
  open: modelsOpen,
@@ -657,11 +722,11 @@ function ConnectAi({ aiKey }) {
657
722
  ] }),
658
723
  /* @__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
724
  /* @__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 }) })
725
+ /* @__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
726
  ] }),
662
727
  /* @__PURE__ */ jsxs2(DialogFooter, { className: "eb:border-t eb:border-border eb:px-6 eb:py-4", children: [
663
728
  /* @__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") })
729
+ /* @__PURE__ */ jsx2(Button, { type: "submit", disabled: !ready, "data-testid": "eb-ai-connect-submit", children: t("Connect") })
665
730
  ] })
666
731
  ] })
667
732
  }
@@ -1203,12 +1268,14 @@ function createAi(options = {}) {
1203
1268
  providers.find((item) => item.id === id)
1204
1269
  );
1205
1270
  const name = key ? key.model(id) : chosen.model;
1271
+ const address = key && key.providers.find((item) => item.id === id)?.needsAddress ? key.address(id) : "";
1206
1272
  const slot = `${id}
1207
- ${name}`;
1273
+ ${name}
1274
+ ${address}`;
1208
1275
  if (!models.has(slot)) {
1209
1276
  models.set(
1210
1277
  slot,
1211
- chosen.createModel ? chosen.createModel({ apiKey: keyFor, fetch: fetchImpl, ...name ? { model: name } : {} }) : providerEmailModel({ provider: chosen, fetch: fetchImpl, apiKey: keyFor, ...name ? { model: name } : {} })
1278
+ 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
1279
  );
1213
1280
  }
1214
1281
  return (
@@ -1217,7 +1284,10 @@ ${name}`;
1217
1284
  );
1218
1285
  };
1219
1286
  const model2 = async (request) => {
1220
- if (key && !key.get() && !providers.find((item) => item.id === key.provider.id)?.keyless) await key.request();
1287
+ if (key && !key.ready()) {
1288
+ await key.request();
1289
+ if (!key.ready()) throw new EmailGenerationError("KEY_MISSING", `${key.provider.name}: not connected`);
1290
+ }
1221
1291
  return modelOf(key ? key.provider.id : provider.id)(request);
1222
1292
  };
1223
1293
  const generator = createEmailGenerator({ model: model2, maxAttempts, compile });