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 +21 -0
- package/README.md +3 -3
- package/dist/ai-server.js +1 -1
- package/dist/ai.js +126 -56
- package/dist/ai.js.map +1 -1
- package/dist/builder.js +4 -4
- package/dist/{chunk-FAEVYSIF.js → chunk-4IXP5YVN.js} +2 -2
- package/dist/{chunk-FAEVYSIF.js.map → chunk-4IXP5YVN.js.map} +1 -1
- package/dist/{chunk-6W3QUZR7.js → chunk-AS2EPMA6.js} +4 -4
- package/dist/{chunk-DXWIVGHP.js → chunk-D4JBPAJ5.js} +2 -2
- package/dist/{chunk-ITIG5NTJ.js → chunk-FFSSAMHH.js} +3 -3
- package/dist/{chunk-WM2EEV4Q.js → chunk-GWXY3MWQ.js} +3 -3
- package/dist/{chunk-SHHHYRPC.js → chunk-VJKF6SR4.js} +61 -7
- package/dist/chunk-VJKF6SR4.js.map +1 -0
- package/dist/index.js +5 -5
- package/dist/library.js +4 -4
- package/dist/server.js +1 -1
- package/dist/types/ai/ConnectAi.d.ts +3 -1
- package/dist/types/ai/ModelPicker.d.ts +7 -3
- package/dist/types/ai/keys.d.ts +15 -3
- package/dist/types/core/constants.d.ts +1 -1
- package/dist/types/engine/NativeAdapter.d.ts +1 -1
- package/package.json +1 -1
- package/dist/chunk-SHHHYRPC.js.map +0 -1
- /package/dist/{chunk-6W3QUZR7.js.map → chunk-AS2EPMA6.js.map} +0 -0
- /package/dist/{chunk-DXWIVGHP.js.map → chunk-D4JBPAJ5.js.map} +0 -0
- /package/dist/{chunk-ITIG5NTJ.js.map → chunk-FFSSAMHH.js.map} +0 -0
- /package/dist/{chunk-WM2EEV4Q.js.map → chunk-GWXY3MWQ.js.map} +0 -0
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
|
|
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
package/dist/ai.js
CHANGED
|
@@ -23,8 +23,8 @@ import {
|
|
|
23
23
|
createHttpCaller,
|
|
24
24
|
useSearchShortcut,
|
|
25
25
|
useUi
|
|
26
|
-
} from "./chunk-
|
|
27
|
-
import "./chunk-
|
|
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-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
129
|
-
|
|
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
|
-
|
|
167
|
-
|
|
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((
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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 (
|
|
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__ */
|
|
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__ */
|
|
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:
|
|
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
|
-
|
|
503
|
-
if (
|
|
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(
|
|
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__ */
|
|
605
|
-
|
|
606
|
-
|
|
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
|
-
|
|
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: !
|
|
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.
|
|
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 });
|