@oharness/server 0.7.0 → 0.8.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/dist/browser-pane.d.ts +48 -0
- package/dist/browser-pane.js +185 -0
- package/dist/browser-pane.js.map +1 -0
- package/dist/i18n.js +144 -0
- package/dist/i18n.js.map +1 -1
- package/dist/main.js +20 -0
- package/dist/main.js.map +1 -1
- package/dist/page/client/account.d.ts +8 -0
- package/dist/page/client/account.js +1895 -0
- package/dist/page/client/account.js.map +1 -0
- package/dist/page/client/artifacts.d.ts +7 -0
- package/dist/page/client/artifacts.js +767 -0
- package/dist/page/client/artifacts.js.map +1 -0
- package/dist/page/client/base.d.ts +8 -0
- package/dist/page/client/base.js +400 -0
- package/dist/page/client/base.js.map +1 -0
- package/dist/page/client/browser.d.ts +21 -0
- package/dist/page/client/browser.js +904 -0
- package/dist/page/client/browser.js.map +1 -0
- package/dist/page/client/composer.d.ts +8 -0
- package/dist/page/client/composer.js +1694 -0
- package/dist/page/client/composer.js.map +1 -0
- package/dist/page/client/dialogs.d.ts +7 -0
- package/dist/page/client/dialogs.js +724 -0
- package/dist/page/client/dialogs.js.map +1 -0
- package/dist/page/client/events.d.ts +7 -0
- package/dist/page/client/events.js +288 -0
- package/dist/page/client/events.js.map +1 -0
- package/dist/page/client/index.d.ts +16 -0
- package/dist/page/client/index.js +46 -0
- package/dist/page/client/index.js.map +1 -0
- package/dist/page/client/panels.d.ts +7 -0
- package/dist/page/client/panels.js +659 -0
- package/dist/page/client/panels.js.map +1 -0
- package/dist/page/client/pickers.d.ts +7 -0
- package/dist/page/client/pickers.js +624 -0
- package/dist/page/client/pickers.js.map +1 -0
- package/dist/page/client/routines.d.ts +6 -0
- package/dist/page/client/routines.js +910 -0
- package/dist/page/client/routines.js.map +1 -0
- package/dist/page/client/shell.d.ts +6 -0
- package/dist/page/client/shell.js +690 -0
- package/dist/page/client/shell.js.map +1 -0
- package/dist/page/client/state.d.ts +7 -0
- package/dist/page/client/state.js +1275 -0
- package/dist/page/client/state.js.map +1 -0
- package/dist/page/client/transcript.d.ts +8 -0
- package/dist/page/client/transcript.js +699 -0
- package/dist/page/client/transcript.js.map +1 -0
- package/dist/page/client/usage.d.ts +7 -0
- package/dist/page/client/usage.js +954 -0
- package/dist/page/client/usage.js.map +1 -0
- package/dist/page/style.d.ts +1 -0
- package/dist/page/style.js +2793 -0
- package/dist/page/style.js.map +1 -0
- package/dist/page.js +70 -13583
- package/dist/page.js.map +1 -1
- package/dist/server.d.ts +24 -0
- package/dist/server.js +511 -25
- package/dist/server.js.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,1895 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Models, providers and the account: the model catalogue, connecting and
|
|
3
|
+
* reconnecting a provider, signing in through the website, the paywall, and
|
|
4
|
+
* the theme switch.
|
|
5
|
+
*
|
|
6
|
+
* One part of the client script; see ./index.ts for how the parts join.
|
|
7
|
+
*/
|
|
8
|
+
export const ACCOUNT = String.raw `
|
|
9
|
+
async function showModels() {
|
|
10
|
+
const body = await getJson("/models");
|
|
11
|
+
panelBody.replaceChildren();
|
|
12
|
+
if (body.error) return void panelBody.appendChild(panelError(body.error));
|
|
13
|
+
const models = body.models || [];
|
|
14
|
+
const list = body.providers || [];
|
|
15
|
+
modelsSite = body.site || modelsSite;
|
|
16
|
+
|
|
17
|
+
const panel = el("div", "panel");
|
|
18
|
+
const head = el("div", "row-head");
|
|
19
|
+
head.appendChild(el("h2", null, t("models.title")));
|
|
20
|
+
const refreshAll = el("button", "ghost", t("models.discoverAll"));
|
|
21
|
+
refreshAll.type = "button";
|
|
22
|
+
refreshAll.addEventListener("click", () => {
|
|
23
|
+
refreshAll.disabled = true;
|
|
24
|
+
refreshAll.textContent = t("models.discoverAll") + "…";
|
|
25
|
+
void api("/models/refresh", {}, 120000)
|
|
26
|
+
.then(async (result) => {
|
|
27
|
+
// Errors are shown rather than swallowed: an earlier version left a
|
|
28
|
+
// blank panel with no explanation when a /models call failed.
|
|
29
|
+
if (result && result.error) return void addNotice(result.error, "err");
|
|
30
|
+
await loadModels();
|
|
31
|
+
await showModels();
|
|
32
|
+
})
|
|
33
|
+
.finally(() => {
|
|
34
|
+
refreshAll.disabled = false;
|
|
35
|
+
refreshAll.textContent = t("models.discoverAll");
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
head.appendChild(refreshAll);
|
|
39
|
+
panel.appendChild(head);
|
|
40
|
+
|
|
41
|
+
const byProvider = new Map();
|
|
42
|
+
for (const model of models) {
|
|
43
|
+
if (!byProvider.has(model.provider)) byProvider.set(model.provider, []);
|
|
44
|
+
byProvider.get(model.provider).push(model);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* What connected a provider, in the user's terms.
|
|
49
|
+
*
|
|
50
|
+
* The engine reports env:DEEPSEEK_API_KEY, stored:api, ant-cli. The
|
|
51
|
+
* distinction matters: a provider connected only by an environment variable
|
|
52
|
+
* has nothing to sign out of, and offering a Disconnect that cannot work
|
|
53
|
+
* would be worse than saying where the credential came from.
|
|
54
|
+
*/
|
|
55
|
+
const sourceLabel = (source) => {
|
|
56
|
+
if (!source) return "connected";
|
|
57
|
+
if (source.startsWith("env:")) return "Environment";
|
|
58
|
+
if (source === "stored:api") return "API key";
|
|
59
|
+
if (source === "stored:oauth") return "Subscription";
|
|
60
|
+
if (source === "ant-cli") return "ant CLI";
|
|
61
|
+
return source;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// The recommended vendor is lifted out of whichever section it would have
|
|
65
|
+
// landed in and shown first. Under "Popular" it is one of eight; the point
|
|
66
|
+
// of recommending one is that it is not.
|
|
67
|
+
const featured = list.filter((provider) => provider.recommended);
|
|
68
|
+
const connected = list.filter((provider) => provider.configured && !provider.recommended);
|
|
69
|
+
const popular = list.filter(
|
|
70
|
+
(provider) =>
|
|
71
|
+
!provider.configured &&
|
|
72
|
+
!provider.recommended &&
|
|
73
|
+
(provider.popular ||
|
|
74
|
+
pinnedProviders.has(provider.id) ||
|
|
75
|
+
(byProvider.get(provider.id) || []).length),
|
|
76
|
+
);
|
|
77
|
+
const rest = list.filter(
|
|
78
|
+
(provider) =>
|
|
79
|
+
!featured.includes(provider) &&
|
|
80
|
+
!connected.includes(provider) &&
|
|
81
|
+
!popular.includes(provider),
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const section = (title, providers) => {
|
|
85
|
+
if (!providers.length) return;
|
|
86
|
+
panel.appendChild(el("h3", "section-title", title));
|
|
87
|
+
const group = el("div", "section");
|
|
88
|
+
for (const provider of providers) group.appendChild(providerCard(provider));
|
|
89
|
+
panel.appendChild(group);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
function providerCard(provider) {
|
|
93
|
+
const owned = byProvider.get(provider.id) || [];
|
|
94
|
+
|
|
95
|
+
// A collapsed card per provider: OpenRouter alone discovers four hundred
|
|
96
|
+
// models, and a wall of chips buries every other vendor under it.
|
|
97
|
+
const card = el("details", "provider");
|
|
98
|
+
card.dataset.provider = provider.id;
|
|
99
|
+
card.open = owned.some((model) => model.current);
|
|
100
|
+
|
|
101
|
+
const top = el("summary", "row-head");
|
|
102
|
+
top.appendChild(el("span", "chev"));
|
|
103
|
+
// The same mark the model menu puts on every row. This panel is the other
|
|
104
|
+
// place the full list of vendors is read, and reading it by name alone is
|
|
105
|
+
// the thing the mark was added to fix.
|
|
106
|
+
top.appendChild(providerMark(provider.id, provider.label || provider.id));
|
|
107
|
+
|
|
108
|
+
const naming = el("div", "naming");
|
|
109
|
+
const line = el("div", "name-line");
|
|
110
|
+
line.appendChild(el("span", "row-title", provider.label || provider.id));
|
|
111
|
+
if (provider.recommended) line.appendChild(el("span", "tag rec", t("models.recommended")));
|
|
112
|
+
if (provider.configured) line.appendChild(el("span", "tag", sourceLabel(provider.source)));
|
|
113
|
+
if (owned.length) line.appendChild(el("span", "muted count", String(owned.length)));
|
|
114
|
+
naming.appendChild(line);
|
|
115
|
+
if (provider.description) naming.appendChild(el("div", "row-desc", provider.description));
|
|
116
|
+
top.appendChild(naming);
|
|
117
|
+
|
|
118
|
+
const discover = el("button", "ghost", t("models.discover"));
|
|
119
|
+
discover.type = "button";
|
|
120
|
+
discover.disabled = !provider.configured;
|
|
121
|
+
discover.addEventListener("click", (e) => {
|
|
122
|
+
// Inside a summary, a click toggles the disclosure unless stopped.
|
|
123
|
+
e.preventDefault();
|
|
124
|
+
e.stopPropagation();
|
|
125
|
+
discover.disabled = true;
|
|
126
|
+
discover.textContent = "…";
|
|
127
|
+
void api("/models/refresh", { provider: provider.id }, 60000)
|
|
128
|
+
.then(async (result) => {
|
|
129
|
+
if (result && result.error) return void addNotice(result.error, "err");
|
|
130
|
+
await loadModels();
|
|
131
|
+
await showModels();
|
|
132
|
+
})
|
|
133
|
+
.finally(() => {
|
|
134
|
+
discover.disabled = false;
|
|
135
|
+
discover.textContent = t("models.discover");
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
top.appendChild(discover);
|
|
139
|
+
|
|
140
|
+
const cardBody = el("div", "provider-body");
|
|
141
|
+
|
|
142
|
+
if (provider.configured) {
|
|
143
|
+
// Nothing to sign out of when the key lives in the environment; the
|
|
144
|
+
// button would do nothing and the user would rightly call it broken.
|
|
145
|
+
if (!String(provider.source || "").startsWith("env:")) {
|
|
146
|
+
const out = el("button", "act", t("models.disconnect"));
|
|
147
|
+
out.type = "button";
|
|
148
|
+
out.addEventListener("click", (e) => {
|
|
149
|
+
e.preventDefault();
|
|
150
|
+
e.stopPropagation();
|
|
151
|
+
void api("/logout", { provider: provider.id }).then(async () => {
|
|
152
|
+
await loadModels();
|
|
153
|
+
await showModels();
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
top.appendChild(out);
|
|
157
|
+
} else {
|
|
158
|
+
cardBody.appendChild(
|
|
159
|
+
el("div", "muted small",
|
|
160
|
+
"Connected by " + String(provider.source).slice(4) +
|
|
161
|
+
". Unset it to disconnect, or save a key here to override it."),
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Who is signed in, when anyone is. A plan whose credential is an account
|
|
167
|
+
// has one question a "connected" tag cannot answer — whose — and it is the
|
|
168
|
+
// question a stale login gets wrong.
|
|
169
|
+
// Which key, when a key is what this machine is using. Shown for the same
|
|
170
|
+
// reason the account email is: "connected" does not say by what, and the
|
|
171
|
+
// one thing somebody needs in order to revoke it is the name they gave it.
|
|
172
|
+
if (provider.keyLabel) {
|
|
173
|
+
cardBody.appendChild(
|
|
174
|
+
el("div", "muted small", t("models.usingKey", { label: provider.keyLabel })),
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
if (provider.account) {
|
|
178
|
+
// Whose, and whether it still counts. The email comes from the stored
|
|
179
|
+
// credential and survives it going stale, so claiming "signed in" on the
|
|
180
|
+
// strength of it told people they had a session when they had the
|
|
181
|
+
// remains of one — which is exactly how a dead sign-in button came to
|
|
182
|
+
// look like a bug in the button rather than in the sentence above it.
|
|
183
|
+
cardBody.appendChild(
|
|
184
|
+
el(
|
|
185
|
+
"div",
|
|
186
|
+
"muted small",
|
|
187
|
+
provider.configured
|
|
188
|
+
? t("models.signedInAs", { email: provider.account })
|
|
189
|
+
: t("models.wasSignedInAs", { email: provider.account }),
|
|
190
|
+
),
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// An account-backed plan holds no key the subscriber could paste: the
|
|
195
|
+
// upstream credentials live on the gateway and signing in is what yields
|
|
196
|
+
// one. Offering only a key field here would be a dead end, so the way in
|
|
197
|
+
// is a sign-in — and it is the same sign-in the rest of the app opens.
|
|
198
|
+
//
|
|
199
|
+
// One button rather than the three controls that used to sit here. The
|
|
200
|
+
// dialog behind it holds both ways of signing in — the website, and the
|
|
201
|
+
// emailed code for a machine with no browser — and having a second copy of
|
|
202
|
+
// them on this card meant anything added to the sign-in had to be added
|
|
203
|
+
// twice, with the copy that was missed going quietly stale.
|
|
204
|
+
// Nothing to sign in to. The card says what is missing and how to supply
|
|
205
|
+
// it, in the same terms the CLI uses, rather than showing a button whose
|
|
206
|
+
// only possible outcome is a dialog that also cannot work.
|
|
207
|
+
const signInButton = () => {
|
|
208
|
+
const go = el("button", "signin-site", t("models.signIn"));
|
|
209
|
+
go.type = "button";
|
|
210
|
+
go.addEventListener("click", (e) => {
|
|
211
|
+
e.preventDefault();
|
|
212
|
+
e.stopPropagation();
|
|
213
|
+
openConnect(provider.id);
|
|
214
|
+
});
|
|
215
|
+
return go;
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
if (provider.readiness === "no-account-system") {
|
|
219
|
+
cardBody.appendChild(el("div", "muted small", t("models.noAccounts")));
|
|
220
|
+
cardBody.appendChild(el("div", "muted small", t("models.noAccountsHow")));
|
|
221
|
+
} else if (provider.readiness === "cannot-renew") {
|
|
222
|
+
// The engine's own message about why the renewal failed. Shown rather
|
|
223
|
+
// than summarised: this is somebody's own machine, and "could not be
|
|
224
|
+
// renewed" without the reason is a sentence they can do nothing with.
|
|
225
|
+
// The button stays, because signing in again is a real answer here —
|
|
226
|
+
// unlike the case above, where there is nothing to sign in to.
|
|
227
|
+
cardBody.appendChild(el("div", "muted small", t("models.cannotRenew")));
|
|
228
|
+
if (provider.readinessDetail) cardBody.appendChild(el("div", "muted small", provider.readinessDetail));
|
|
229
|
+
cardBody.appendChild(signInButton());
|
|
230
|
+
} else if (provider.signIn && (!provider.configured || String(provider.source || "").startsWith("env:"))) {
|
|
231
|
+
cardBody.appendChild(signInButton());
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// The key field, for every vendor that takes one — the account plans
|
|
235
|
+
// included. A key is not a second account: it resolves to the same user
|
|
236
|
+
// and spends the same balance, and CI needs one because it does not
|
|
237
|
+
// expire. This is where it belongs, next to every other vendor's key
|
|
238
|
+
// field, rather than inside the sign-in dialog.
|
|
239
|
+
if (!provider.configured || String(provider.source || "").startsWith("env:")) {
|
|
240
|
+
const row = el("div", "keyrow");
|
|
241
|
+
const field = document.createElement("input");
|
|
242
|
+
// type=password so the key is not left legible on a screen, and
|
|
243
|
+
// autocomplete off so the browser does not offer to remember it.
|
|
244
|
+
field.type = "password";
|
|
245
|
+
field.placeholder = (provider.envVars || []).join(" or ") || "API key";
|
|
246
|
+
field.autocomplete = "off";
|
|
247
|
+
field.spellcheck = false;
|
|
248
|
+
row.appendChild(field);
|
|
249
|
+
|
|
250
|
+
const save = el("button", "ghost", t("models.connect"));
|
|
251
|
+
save.type = "button";
|
|
252
|
+
const submit = () => {
|
|
253
|
+
const key = field.value.trim();
|
|
254
|
+
if (!key) return;
|
|
255
|
+
save.disabled = true;
|
|
256
|
+
save.textContent = t("models.saving");
|
|
257
|
+
void api("/auth", { provider: provider.id, key })
|
|
258
|
+
.then(async (result) => {
|
|
259
|
+
field.value = "";
|
|
260
|
+
if (result && result.error) return void addNotice(result.error, "err");
|
|
261
|
+
await loadModels();
|
|
262
|
+
await showModels();
|
|
263
|
+
addNotice("connected " + provider.id + " — discovering its models…");
|
|
264
|
+
})
|
|
265
|
+
.finally(() => {
|
|
266
|
+
// Restored on every path. Doing this only on success left the
|
|
267
|
+
// button reading "saving…" whenever anything went wrong.
|
|
268
|
+
save.disabled = false;
|
|
269
|
+
save.textContent = t("models.connect");
|
|
270
|
+
});
|
|
271
|
+
};
|
|
272
|
+
save.addEventListener("click", submit);
|
|
273
|
+
field.addEventListener("keydown", (e) => {
|
|
274
|
+
if (e.key === "Enter") submit();
|
|
275
|
+
});
|
|
276
|
+
field.addEventListener("click", (e) => e.stopPropagation());
|
|
277
|
+
row.appendChild(save);
|
|
278
|
+
|
|
279
|
+
const hint = el("div", "muted small",
|
|
280
|
+
"Stored in ~/.oharness/auth.json (0600). Or set " +
|
|
281
|
+
((provider.envVars || []).join(" or ") || "an environment variable") + " instead.");
|
|
282
|
+
if (provider.docsUrl) {
|
|
283
|
+
hint.appendChild(document.createTextNode(" "));
|
|
284
|
+
const link = el("a", "md-link", "Get a key");
|
|
285
|
+
link.href = provider.docsUrl;
|
|
286
|
+
link.target = "_blank";
|
|
287
|
+
link.rel = "noopener noreferrer";
|
|
288
|
+
hint.appendChild(link);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (provider.signIn) {
|
|
292
|
+
// Folded away on an account-backed plan. There is no key page to get
|
|
293
|
+
// one from — signing in is what yields a credential — so the field is
|
|
294
|
+
// for the environment key a CI run or a self-hosted gateway uses, and
|
|
295
|
+
// showing it beside the sign-in only ever suggested a second way in
|
|
296
|
+
// that most people do not have.
|
|
297
|
+
const advanced = el("details", "provider-advanced");
|
|
298
|
+
const summary = el("summary", null, t("models.orKey"));
|
|
299
|
+
summary.addEventListener("click", (e) => e.stopPropagation());
|
|
300
|
+
advanced.appendChild(summary);
|
|
301
|
+
advanced.appendChild(row);
|
|
302
|
+
advanced.appendChild(hint);
|
|
303
|
+
cardBody.appendChild(advanced);
|
|
304
|
+
} else {
|
|
305
|
+
cardBody.appendChild(row);
|
|
306
|
+
cardBody.appendChild(hint);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (owned.length) {
|
|
311
|
+
const grid = el("div", "chips");
|
|
312
|
+
for (const model of owned) {
|
|
313
|
+
const chip = el("button", "chip" + (model.current ? " on" : ""));
|
|
314
|
+
chip.type = "button";
|
|
315
|
+
chip.appendChild(el("span", null, model.id));
|
|
316
|
+
if (model.contextWindow) {
|
|
317
|
+
chip.appendChild(el("span", "sub", Math.round(model.contextWindow / 1000) + "k"));
|
|
318
|
+
}
|
|
319
|
+
// Only ever on a model whose vendor quoted zero for both halves.
|
|
320
|
+
// The server's isFree is the whole of the decision, so a chip without
|
|
321
|
+
// this is a model nobody has said is free, not one that is known to
|
|
322
|
+
// cost something.
|
|
323
|
+
if (model.free) chip.appendChild(el("span", "free", t("models.free")));
|
|
324
|
+
chip.addEventListener("click", () => {
|
|
325
|
+
track("model_switched", { model: model.id, via: "panel" });
|
|
326
|
+
void api("/model", { session: activeId, id: model.id }).then(async (state) => {
|
|
327
|
+
if (state && state.error) return addNotice(state.error, "err");
|
|
328
|
+
applyState(state);
|
|
329
|
+
await loadModels();
|
|
330
|
+
await showModels();
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
grid.appendChild(chip);
|
|
334
|
+
}
|
|
335
|
+
cardBody.appendChild(grid);
|
|
336
|
+
} else if (provider.configured) {
|
|
337
|
+
cardBody.appendChild(
|
|
338
|
+
el("div", "muted small", "Asking " + provider.id + " for its model list…"),
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
card.appendChild(top);
|
|
343
|
+
card.appendChild(cardBody);
|
|
344
|
+
return card;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
section(t("models.recommended"), featured);
|
|
348
|
+
section(t("models.connected"), connected);
|
|
349
|
+
section(t("models.popular"), popular);
|
|
350
|
+
|
|
351
|
+
if (rest.length) {
|
|
352
|
+
const more = el("button", "more-row");
|
|
353
|
+
more.type = "button";
|
|
354
|
+
more.appendChild(el("span", null, t("models.more")));
|
|
355
|
+
more.appendChild(el("span", "muted", String(rest.length)));
|
|
356
|
+
more.addEventListener("click", () => openProviderPicker(rest, byProvider));
|
|
357
|
+
panel.appendChild(more);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
panelBody.appendChild(panel);
|
|
361
|
+
|
|
362
|
+
// Applied on every paint rather than once by the caller: the panel repaints
|
|
363
|
+
// itself after a discovery pass, and a card opened before that lands would
|
|
364
|
+
// shut again with no one having touched it.
|
|
365
|
+
if (focusProvider) revealProvider(focusProvider);
|
|
366
|
+
|
|
367
|
+
// Signing in and then facing an empty list with a button to press is a poor
|
|
368
|
+
// answer to "which models do I have". Providers that are authenticated but
|
|
369
|
+
// have nothing discovered yet get asked once, in the background.
|
|
370
|
+
const missing = list.filter(
|
|
371
|
+
(provider) => provider.configured && !(byProvider.get(provider.id) || []).length,
|
|
372
|
+
);
|
|
373
|
+
if (missing.length && !discovering) {
|
|
374
|
+
discovering = true;
|
|
375
|
+
void Promise.all(
|
|
376
|
+
missing.map((provider) => api("/models/refresh", { provider: provider.id })),
|
|
377
|
+
)
|
|
378
|
+
.then(async () => {
|
|
379
|
+
await loadModels();
|
|
380
|
+
// Only repaint if the user is still looking at this panel.
|
|
381
|
+
if (document.getElementById("nav-models").classList.contains("active")) {
|
|
382
|
+
await showModels();
|
|
383
|
+
}
|
|
384
|
+
})
|
|
385
|
+
.finally(() => {
|
|
386
|
+
discovering = false;
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
let discovering = false;
|
|
392
|
+
|
|
393
|
+
/** The provider each paint of the panel should open; null when it opens flat. */
|
|
394
|
+
let focusProvider = null;
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* What to run when a provider finishes signing in somewhere else.
|
|
398
|
+
*
|
|
399
|
+
* A sign-in that happens in a browser tab does not resolve the promise that
|
|
400
|
+
* opened it — the answer arrives over the event stream minutes later, from a
|
|
401
|
+
* different window. One entry per provider: starting a second sign-in for the
|
|
402
|
+
* same one replaces the first, which is what a user restarting a flow means.
|
|
403
|
+
*/
|
|
404
|
+
const signedInWaiters = new Map();
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* A provider whose credential was just refused.
|
|
408
|
+
*
|
|
409
|
+
* The "configured" flag answers "is a credential stored", which a 401 has just
|
|
410
|
+
* proved is the wrong question to have been asking. It is cleared before the
|
|
411
|
+
* dialog opens or the model picker goes on treating this vendor as ready — and
|
|
412
|
+
* switching to one of its models goes on failing the same way, with the dialog
|
|
413
|
+
* that would fix it never offered because the switch never asks for one.
|
|
414
|
+
*
|
|
415
|
+
* The notice stays even though a dialog opens over it: the dialog is
|
|
416
|
+
* dismissible and says nothing about why it appeared.
|
|
417
|
+
*/
|
|
418
|
+
function openReconnect(providerId) {
|
|
419
|
+
const provider = providers.find((p) => p.id === providerId);
|
|
420
|
+
addNotice(t("auth.refused", { provider: (provider && provider.label) || providerId }), "err");
|
|
421
|
+
if (!provider) return;
|
|
422
|
+
provider.configured = false;
|
|
423
|
+
openConnect(providerId);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* A provider that was never connected, reached by trying to use it.
|
|
428
|
+
*
|
|
429
|
+
* Unlike a refusal there is nothing stale to clear — the flag was right, the
|
|
430
|
+
* vendor simply has no credential. What the two share is the answer: the same
|
|
431
|
+
* dialog, opened where the failure happened rather than described in a
|
|
432
|
+
* sentence that names a terminal command this window has no terminal for.
|
|
433
|
+
*/
|
|
434
|
+
function openSignIn(providerId) {
|
|
435
|
+
const provider = providers.find((p) => p.id === providerId);
|
|
436
|
+
addNotice(t("auth.missing", { provider: (provider && provider.label) || providerId }), "err");
|
|
437
|
+
openConnect(providerId);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Connect one vendor, over whatever is on screen.
|
|
442
|
+
*
|
|
443
|
+
* Picking a vendor that is not set up is a question with one answer — the key
|
|
444
|
+
* — so it is asked where it was raised. Sending the user to a settings panel
|
|
445
|
+
* to find the same field again is three clicks to do what they had already
|
|
446
|
+
* said they wanted.
|
|
447
|
+
*
|
|
448
|
+
* The key comes first even for the plans that can also sign in: a key is one
|
|
449
|
+
* paste and works everywhere, including where no mail is reachable. The
|
|
450
|
+
* sign-in is one line below it for the accounts that prefer it.
|
|
451
|
+
*/
|
|
452
|
+
let modelsSite = "";
|
|
453
|
+
|
|
454
|
+
function openConnect(providerId, thenModel) {
|
|
455
|
+
const provider = providers.find((p) => p.id === providerId);
|
|
456
|
+
if (!provider) return;
|
|
457
|
+
|
|
458
|
+
const dialog = document.getElementById("connect");
|
|
459
|
+
const body = document.getElementById("cn-body");
|
|
460
|
+
const save = document.getElementById("cn-save");
|
|
461
|
+
// The left column is about having an OHarness account, so it belongs only to
|
|
462
|
+
// the providers that are one. For a vendor whose credential is a key it
|
|
463
|
+
// would be an advertisement in the way of the field they came to fill in.
|
|
464
|
+
document.getElementById("cn-aside").hidden = !provider.signIn;
|
|
465
|
+
document.getElementById("cn-title").textContent = provider.label || provider.id;
|
|
466
|
+
// What this dialog is answering. A credential that ran out and one that was
|
|
467
|
+
// never created both arrive here, and telling someone to "sign in" when what
|
|
468
|
+
// happened is "your session ended" leaves them wondering whether the first
|
|
469
|
+
// one ever worked.
|
|
470
|
+
// The provider's own description advertises signing in. Left in place on a
|
|
471
|
+
// build that cannot sign in, it contradicts the sentence directly beneath it
|
|
472
|
+
// — which is what made the dialog read as an error rather than as one of two
|
|
473
|
+
// doors being shut.
|
|
474
|
+
document.getElementById("cn-desc").textContent =
|
|
475
|
+
provider.readiness === "no-account-system"
|
|
476
|
+
? t("models.keyOnlyDesc")
|
|
477
|
+
: provider.readiness === "expired"
|
|
478
|
+
? t("models.expired")
|
|
479
|
+
: provider.readiness === "cannot-renew"
|
|
480
|
+
? t("models.cannotRenew")
|
|
481
|
+
: provider.description || "";
|
|
482
|
+
|
|
483
|
+
// Nothing to sign in *to*, which is not the same as nothing to do. The
|
|
484
|
+
// official plan takes two credentials: an account, which needs a project
|
|
485
|
+
// this build does not know, and an oh- key, which is a bearer token for the
|
|
486
|
+
// gateway and needs no project at all. So the dialog explains why the first
|
|
487
|
+
// is unavailable and offers the second, rather than the Cancel button it
|
|
488
|
+
// offered at first — which told someone with a perfectly good key that they
|
|
489
|
+
// could not use this provider.
|
|
490
|
+
//
|
|
491
|
+
// An earlier version wrote a notice instead of opening anything. addNotice
|
|
492
|
+
// puts its text in the transcript when no panel is up, so the explanation
|
|
493
|
+
// arrived labelled as the assistant, reading as something the model said.
|
|
494
|
+
const noAccounts = provider.readiness === "no-account-system";
|
|
495
|
+
if (noAccounts) {
|
|
496
|
+
// The left column sells having an account. Wrong thing to show beside a
|
|
497
|
+
// sentence saying accounts do not exist in this build.
|
|
498
|
+
document.getElementById("cn-aside").hidden = true;
|
|
499
|
+
}
|
|
500
|
+
// Undone on every open rather than only where it is set: this dialog is one
|
|
501
|
+
// node reused by every provider, and a control hidden for one that stayed
|
|
502
|
+
// hidden for the next is the classic way a shared dialog goes wrong.
|
|
503
|
+
save.hidden = false;
|
|
504
|
+
|
|
505
|
+
// Which of the two ways in this dialog is for. Fixed once, not switchable:
|
|
506
|
+
// a provider whose credential is a key gets the key field, and one with an
|
|
507
|
+
// account gets the sign-in. The dialog used to offer a link down to a key
|
|
508
|
+
// field on the account plans too, which put "paste a credential" inside a
|
|
509
|
+
// window whose whole job is "sign in" — two unrelated things wearing one
|
|
510
|
+
// title. Pasting an oh- key is a settings action and lives on the
|
|
511
|
+
// provider's card, where the other vendors' key fields already are.
|
|
512
|
+
const mode = provider.signIn && !noAccounts ? "account" : "key";
|
|
513
|
+
let flow = null;
|
|
514
|
+
|
|
515
|
+
const field = document.createElement("input");
|
|
516
|
+
field.autocomplete = "off";
|
|
517
|
+
field.spellcheck = false;
|
|
518
|
+
|
|
519
|
+
const hint = el("div", "cn-note cn-tip");
|
|
520
|
+
hint.hidden = true;
|
|
521
|
+
hint.setAttribute("role", "tooltip");
|
|
522
|
+
const why = document.getElementById("cn-why");
|
|
523
|
+
// In the dialog's column rather than its body, so it floats over the body
|
|
524
|
+
// instead of taking a place in it. Left from the last opening otherwise.
|
|
525
|
+
const main = why.closest(".cn-main");
|
|
526
|
+
for (const old of main.querySelectorAll(":scope > .cn-tip")) old.remove();
|
|
527
|
+
main.appendChild(hint);
|
|
528
|
+
|
|
529
|
+
// A third-party key is the one people hesitate over pasting into a new app,
|
|
530
|
+
// so the dialog says, in plain sight, where it goes and what does not happen
|
|
531
|
+
// to it. Not for OHarness's own key, which is ours to issue. And not
|
|
532
|
+
// "secure": it is a plain file only this account can read, and claiming
|
|
533
|
+
// more would promise something the file does not do.
|
|
534
|
+
const thirdParty = provider.id !== "official";
|
|
535
|
+
// One line, the fact in bold. The path and the file mode are behind the
|
|
536
|
+
// mark with the rest of the detail, for whoever wants to check.
|
|
537
|
+
// Who the key goes to, by name: "sent only to Together AI" is a claim a
|
|
538
|
+
// person can check against the address the request goes to, where "stays
|
|
539
|
+
// on this computer" alone is not quite true — the provider has to see it.
|
|
540
|
+
const keptLocal = el("div", "cn-note cn-local");
|
|
541
|
+
keptLocal.appendChild(svgIcon("lock"));
|
|
542
|
+
const words = el("span");
|
|
543
|
+
words.appendChild(el("strong", null, t("models.keyLocalLead")));
|
|
544
|
+
words.appendChild(document.createTextNode(t("models.keyLocalRest", { provider: provider.label || provider.id })));
|
|
545
|
+
keptLocal.appendChild(words);
|
|
546
|
+
|
|
547
|
+
// Hovering shows it, clicking keeps it. Peeking is the common case and a
|
|
548
|
+
// click to see two lines and another to put them away is two clicks more
|
|
549
|
+
// than the question deserves — but a pointer that has to stay put to keep
|
|
550
|
+
// text on screen cannot be moved to the text.
|
|
551
|
+
let pinned = false;
|
|
552
|
+
const showHint = (on) => {
|
|
553
|
+
hint.hidden = !on;
|
|
554
|
+
why.setAttribute("aria-expanded", String(on));
|
|
555
|
+
};
|
|
556
|
+
why.setAttribute("aria-expanded", "false");
|
|
557
|
+
why.onmouseenter = () => showHint(true);
|
|
558
|
+
why.onmouseleave = () => showHint(pinned);
|
|
559
|
+
why.onfocus = () => showHint(true);
|
|
560
|
+
why.onblur = () => showHint(pinned);
|
|
561
|
+
why.onclick = () => {
|
|
562
|
+
pinned = !pinned;
|
|
563
|
+
showHint(pinned);
|
|
564
|
+
};
|
|
565
|
+
// Getting a key happens elsewhere and before anything else here can be
|
|
566
|
+
// filled in, so it sits with the actions rather than inside the sentence
|
|
567
|
+
// explaining where the key will be stored.
|
|
568
|
+
const docs = el("a", "quiet", t("models.getKey"));
|
|
569
|
+
docs.target = "_blank";
|
|
570
|
+
docs.rel = "noopener noreferrer";
|
|
571
|
+
|
|
572
|
+
// The way in, above the field that is the way in for someone who has no
|
|
573
|
+
// browser here. Closing the dialog on success is the whole reason it is
|
|
574
|
+
// wired to a callback: the click that opened it was a choice of model, and
|
|
575
|
+
// that model is still waiting behind the credential.
|
|
576
|
+
const site = websiteSignIn(
|
|
577
|
+
provider,
|
|
578
|
+
() => {
|
|
579
|
+
if (dialog.open) void done("signed in to " + provider.id);
|
|
580
|
+
},
|
|
581
|
+
(reason) => {
|
|
582
|
+
// The website has no way to redirect back to this address, so the tab
|
|
583
|
+
// would open on a page that dead-ends. Said here rather than as a
|
|
584
|
+
// notice, next to the field that is the way in when this one is shut.
|
|
585
|
+
hint.textContent = reason;
|
|
586
|
+
hint.hidden = false;
|
|
587
|
+
field.focus();
|
|
588
|
+
},
|
|
589
|
+
);
|
|
590
|
+
const siteButton = site.querySelector("button");
|
|
591
|
+
const orCode = el("div", "cn-note", ts("models.orCode"));
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* What the dialog opens focused.
|
|
595
|
+
*
|
|
596
|
+
* The website button in account mode: it is what most people are here to
|
|
597
|
+
* press, and a dialog that opens with the cursor in the email field reads
|
|
598
|
+
* as though the code is the way in and the button above it is an aside.
|
|
599
|
+
*/
|
|
600
|
+
const focusPrimary = () => {
|
|
601
|
+
// Whatever the way in is. In account mode that is the website button; with
|
|
602
|
+
// no account system it is the one that fetches a key, which is the same
|
|
603
|
+
// shape of thing and deserves the same treatment. The field is where the
|
|
604
|
+
// cursor goes only when pasting is what is left.
|
|
605
|
+
const opener = mode === "account" && !flow ? siteButton : noAccounts ? keyButton : undefined;
|
|
606
|
+
if (opener) opener.focus();
|
|
607
|
+
else field.focus();
|
|
608
|
+
};
|
|
609
|
+
|
|
610
|
+
const paint = () => {
|
|
611
|
+
if (mode === "key") {
|
|
612
|
+
field.type = "password";
|
|
613
|
+
field.placeholder = (provider.envVars || []).join(" or ") || "API key";
|
|
614
|
+
// Two lines, and the variable name kept whole: as one sentence it wrapped
|
|
615
|
+
// mid-identifier, which reads as a different variable than the one to set.
|
|
616
|
+
hint.replaceChildren(el("div", null, t("models.keyStored")));
|
|
617
|
+
// Why the other half of this dialog is missing, and how to get it back.
|
|
618
|
+
// Behind the mark rather than in the body: it is an answer to a question
|
|
619
|
+
// most readers do not have, and the ones who do will look for it.
|
|
620
|
+
if (noAccounts) {
|
|
621
|
+
hint.appendChild(el("div", null, t("models.noAccounts")));
|
|
622
|
+
hint.appendChild(el("div", null, t("models.noAccountsHow")));
|
|
623
|
+
}
|
|
624
|
+
if ((provider.envVars || []).length) {
|
|
625
|
+
const line = el("div");
|
|
626
|
+
line.appendChild(document.createTextNode(t("models.keyEnv") + " "));
|
|
627
|
+
provider.envVars.forEach((name, i) => {
|
|
628
|
+
if (i) line.appendChild(document.createTextNode(" / "));
|
|
629
|
+
line.appendChild(el("span", "mono", name));
|
|
630
|
+
});
|
|
631
|
+
hint.appendChild(line);
|
|
632
|
+
}
|
|
633
|
+
save.textContent = t("models.connect");
|
|
634
|
+
docs.hidden = !provider.docsUrl;
|
|
635
|
+
if (provider.docsUrl) docs.href = provider.docsUrl;
|
|
636
|
+
} else {
|
|
637
|
+
field.type = flow ? "text" : "email";
|
|
638
|
+
field.placeholder = flow ? t("models.code") : t("models.email");
|
|
639
|
+
// Nothing to explain about an email address, so the mark has nothing to
|
|
640
|
+
// open until the code has been sent and there is something to say.
|
|
641
|
+
hint.replaceChildren();
|
|
642
|
+
pinned = false;
|
|
643
|
+
showHint(false);
|
|
644
|
+
// Not "sign in": the button above already carries that word, and it is
|
|
645
|
+
// the one that opens the browser. This one submits a code out of an
|
|
646
|
+
// email, which is a different promise to make to someone who clicked.
|
|
647
|
+
save.textContent = flow ? t("models.verify") : t("models.sendCode");
|
|
648
|
+
docs.hidden = true;
|
|
649
|
+
}
|
|
650
|
+
why.hidden = mode !== "key";
|
|
651
|
+
keptLocal.hidden = !(mode === "key" && thirdParty);
|
|
652
|
+
// Hidden rather than left out, so switching modes does not rebuild a
|
|
653
|
+
// button whose click may already have a sign-in running behind it.
|
|
654
|
+
site.hidden = mode !== "account";
|
|
655
|
+
orCode.hidden = mode !== "account";
|
|
656
|
+
// Which control carries the colour. Two filled buttons in one dialog make
|
|
657
|
+
// the reader choose by position, and position would put the emphasis on
|
|
658
|
+
// the wrong one here — in account mode the fallback is what sits in the
|
|
659
|
+
// actions row, and the way in is the button above the field.
|
|
660
|
+
// The same reasoning as account mode, now that key mode can also have a
|
|
661
|
+
// button above the field: whichever control is the way in carries the
|
|
662
|
+
// colour, and the actions row holds the fallback. Two filled buttons make
|
|
663
|
+
// the reader choose by position, and position would pick the wrong one.
|
|
664
|
+
save.classList.toggle("approve", mode === "key" && !noAccounts);
|
|
665
|
+
field.value = "";
|
|
666
|
+
focusPrimary();
|
|
667
|
+
};
|
|
668
|
+
|
|
669
|
+
const done = async (message) => {
|
|
670
|
+
dialog.close();
|
|
671
|
+
// The model the user actually asked for, now that it can be called. Their
|
|
672
|
+
// click was a choice of model; the credential was a detour.
|
|
673
|
+
if (thenModel) {
|
|
674
|
+
const state = await api("/model", { session: activeId, id: thenModel });
|
|
675
|
+
if (state && state.error) addNotice(state.error, "err");
|
|
676
|
+
else applyState(state);
|
|
677
|
+
}
|
|
678
|
+
await loadModels();
|
|
679
|
+
// Who is signed in is now a different answer, and the sidebar row is the
|
|
680
|
+
// one place that shows it on every screen.
|
|
681
|
+
void loadAccount();
|
|
682
|
+
// Whatever panel is behind the dialog, not just the models one: the
|
|
683
|
+
// sign-in is reachable from the sidebar now, where settings may be what
|
|
684
|
+
// is open. Repainting is a no-op when no panel is showing, which is the
|
|
685
|
+
// case when this was opened from the composer's model picker.
|
|
686
|
+
repaintPanel();
|
|
687
|
+
addNotice(message);
|
|
688
|
+
};
|
|
689
|
+
|
|
690
|
+
const submit = () => {
|
|
691
|
+
const value = field.value.trim();
|
|
692
|
+
if (!value) return;
|
|
693
|
+
const label = save.textContent;
|
|
694
|
+
save.disabled = true;
|
|
695
|
+
save.textContent = t("models.saving");
|
|
696
|
+
const restore = () => {
|
|
697
|
+
save.disabled = false;
|
|
698
|
+
save.textContent = label;
|
|
699
|
+
};
|
|
700
|
+
|
|
701
|
+
if (mode === "key") {
|
|
702
|
+
return void api("/auth", { provider: provider.id, key: value })
|
|
703
|
+
.then(async (result) => {
|
|
704
|
+
if (!result || result.error) {
|
|
705
|
+
return void addNotice((result && result.error) || "could not save that key", "err");
|
|
706
|
+
}
|
|
707
|
+
await done("connected " + provider.id + " — discovering its models…");
|
|
708
|
+
})
|
|
709
|
+
.finally(restore);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
if (!flow) {
|
|
713
|
+
return void api("/signin", { provider: provider.id, answers: { email: value } })
|
|
714
|
+
.then((result) => {
|
|
715
|
+
if (!result || result.error) {
|
|
716
|
+
return void addNotice((result && result.error) || "sign-in failed", "err");
|
|
717
|
+
}
|
|
718
|
+
flow = result.flow;
|
|
719
|
+
paint();
|
|
720
|
+
// Shown outright, unlike the storage note: it names the address the
|
|
721
|
+
// code went to, which is the one thing that answers "did that work".
|
|
722
|
+
if (result.instructions) {
|
|
723
|
+
hint.textContent = result.instructions;
|
|
724
|
+
hint.hidden = false;
|
|
725
|
+
}
|
|
726
|
+
})
|
|
727
|
+
.finally(restore);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
void api("/signin/code", { flow, code: value })
|
|
731
|
+
.then(async (result) => {
|
|
732
|
+
if (!result || result.error) {
|
|
733
|
+
// The flow stays open on a bad code: a mistyped digit should cost a
|
|
734
|
+
// retry, not another email.
|
|
735
|
+
return void addNotice((result && result.error) || "sign-in failed", "err");
|
|
736
|
+
}
|
|
737
|
+
await done("signed in to " + provider.id + " — discovering its models…");
|
|
738
|
+
})
|
|
739
|
+
.finally(restore);
|
|
740
|
+
};
|
|
741
|
+
|
|
742
|
+
save.onclick = submit;
|
|
743
|
+
field.onkeydown = (e) => {
|
|
744
|
+
if (e.key === "Enter") submit();
|
|
745
|
+
};
|
|
746
|
+
document.getElementById("cn-cancel").onclick = () => dialog.close();
|
|
747
|
+
document.getElementById("cn-x").onclick = () => dialog.close();
|
|
748
|
+
|
|
749
|
+
// Where the key comes from. The text above says a key is the way in and then
|
|
750
|
+
// said nothing about getting one, which is half an instruction. siteUrl has a
|
|
751
|
+
// default, so this resolves even on a build that knows no account project.
|
|
752
|
+
// A button, not a link. The link left the person with a key on their
|
|
753
|
+
// clipboard and a field to find again; the browser round trip ends with the
|
|
754
|
+
// key already stored, because the site hands it back over the same loopback
|
|
755
|
+
// the sign-in uses. It is also where keys are listed and revoked, so
|
|
756
|
+
// "create one" and "revoke the one this machine was using" are the same
|
|
757
|
+
// trip.
|
|
758
|
+
//
|
|
759
|
+
// Degrades to the link when this window was not reached over loopback: the
|
|
760
|
+
// site will only redirect a credential to 127.0.0.1, so from a LAN address
|
|
761
|
+
// the round trip cannot complete and copy-and-paste is the honest offer.
|
|
762
|
+
let keyButton = null;
|
|
763
|
+
const keyLink = () => {
|
|
764
|
+
const href = (modelsSite || "https://oharness.ai") + "/" + LOCALE + "/dashboard/keys";
|
|
765
|
+
const go = el("button", "signin-site", t("models.getOhKey"));
|
|
766
|
+
go.type = "button";
|
|
767
|
+
go.onclick = (e) => {
|
|
768
|
+
e.preventDefault();
|
|
769
|
+
const inDialog = (reason) => {
|
|
770
|
+
hint.textContent = reason;
|
|
771
|
+
hint.hidden = false;
|
|
772
|
+
field.focus();
|
|
773
|
+
};
|
|
774
|
+
startWebsiteSignIn(
|
|
775
|
+
provider.id,
|
|
776
|
+
() => {
|
|
777
|
+
if (dialog.open) void done("connected " + provider.id);
|
|
778
|
+
},
|
|
779
|
+
go,
|
|
780
|
+
(reason) => {
|
|
781
|
+
// Reached over a LAN address, so the site will not redirect a
|
|
782
|
+
// credential back here. The tab still opens — copying a key across
|
|
783
|
+
// is the way in that remains — and the field below is where it goes.
|
|
784
|
+
window.open(href, "_blank", "noopener");
|
|
785
|
+
inDialog(reason);
|
|
786
|
+
},
|
|
787
|
+
"key",
|
|
788
|
+
inDialog,
|
|
789
|
+
);
|
|
790
|
+
};
|
|
791
|
+
keyButton = go;
|
|
792
|
+
// The same wrapper the website sign-in uses. signin-site carries flex:1,
|
|
793
|
+
// which is right in a row and wrong in the column this body is — there it
|
|
794
|
+
// resolves to flex-basis:0 on the height and squashes the button to 25px
|
|
795
|
+
// above a 38px field. Putting it in the row it was written for fixes that
|
|
796
|
+
// without changing a rule four other places depend on.
|
|
797
|
+
const row = el("div", "keyrow");
|
|
798
|
+
row.appendChild(go);
|
|
799
|
+
return row;
|
|
800
|
+
};
|
|
801
|
+
|
|
802
|
+
body.replaceChildren(
|
|
803
|
+
// Why the account half of this dialog is missing, above the half that
|
|
804
|
+
// still works. Absent for every provider that has nothing to explain.
|
|
805
|
+
// One line and a button. The recipe for turning sign-in back on used to sit
|
|
806
|
+
// here as a five-line paragraph, in front of everybody, for the few people
|
|
807
|
+
// building their own fork — and a dialog whose first screenful is
|
|
808
|
+
// configuration for somebody else is a dialog nobody reads. It moved
|
|
809
|
+
// behind the mark, which is what the mark is for.
|
|
810
|
+
// The button, and the sentence that makes the field beneath it an
|
|
811
|
+
// alternative rather than a step. "Signing in is not available" moved
|
|
812
|
+
// behind the mark with the rest of it: the description already says this
|
|
813
|
+
// dialog connects with a key, so the line answered a question only
|
|
814
|
+
// somebody expecting a sign-in would ask — and they are exactly who opens
|
|
815
|
+
// the mark.
|
|
816
|
+
...(noAccounts ? [keyLink(), el("div", "cn-note", t("models.orPasteKey"))] : []),
|
|
817
|
+
site,
|
|
818
|
+
orCode,
|
|
819
|
+
field,
|
|
820
|
+
keptLocal,
|
|
821
|
+
);
|
|
822
|
+
// Re-attached on every open: the actions row is markup, so a link left from
|
|
823
|
+
// the last vendor would otherwise point at their key page. The link is built
|
|
824
|
+
// fresh each time, so the previous one has to be taken out by hand — testing
|
|
825
|
+
// this node's own parent never matched, and every open stacked another copy.
|
|
826
|
+
const actions = save.parentNode;
|
|
827
|
+
const stale = actions.querySelector("a.quiet");
|
|
828
|
+
if (stale) stale.remove();
|
|
829
|
+
actions.appendChild(docs);
|
|
830
|
+
paint();
|
|
831
|
+
dialog.showModal();
|
|
832
|
+
// After showModal, not before: focus given to a node in a dialog that is
|
|
833
|
+
// not open yet is focus the browser takes straight back.
|
|
834
|
+
focusPrimary();
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* Ask a vendor for its models, from wherever the user said so.
|
|
839
|
+
*
|
|
840
|
+
* The count is reported either way. A silent zero is indistinguishable from
|
|
841
|
+
* "this vendor has no models", which is what a local server that is not
|
|
842
|
+
* running looks like — so the address that was tried goes in the message.
|
|
843
|
+
*/
|
|
844
|
+
function discoverNow(providerId) {
|
|
845
|
+
const provider = providers.find((p) => p.id === providerId);
|
|
846
|
+
return api("/models/refresh", { provider: providerId }, 60000).then(async (result) => {
|
|
847
|
+
if (!result || result.error) {
|
|
848
|
+
return void addNotice((result && result.error) || "discovery failed", "err");
|
|
849
|
+
}
|
|
850
|
+
await loadModels();
|
|
851
|
+
if (document.getElementById("nav-models").classList.contains("active")) await showModels();
|
|
852
|
+
addNotice(
|
|
853
|
+
result.discovered
|
|
854
|
+
? t("models.discovered", { id: providerId, n: result.discovered })
|
|
855
|
+
: t("models.discoveredNone", {
|
|
856
|
+
id: providerId,
|
|
857
|
+
url: (provider && provider.baseUrl) || "its endpoint",
|
|
858
|
+
}),
|
|
859
|
+
result.discovered ? "notice" : "err",
|
|
860
|
+
);
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/** A plus in a ring: adding credits. Drawn like the sidebar's other line icons. */
|
|
865
|
+
function iconTopUp() {
|
|
866
|
+
const NS = "http://www.w3.org/2000/svg";
|
|
867
|
+
const svg = document.createElementNS(NS, "svg");
|
|
868
|
+
svg.setAttribute("class", "ico");
|
|
869
|
+
svg.setAttribute("viewBox", "0 0 16 16");
|
|
870
|
+
svg.setAttribute("fill", "none");
|
|
871
|
+
svg.setAttribute("stroke", "currentColor");
|
|
872
|
+
svg.setAttribute("stroke-width", "1.5");
|
|
873
|
+
svg.setAttribute("stroke-linecap", "round");
|
|
874
|
+
svg.setAttribute("stroke-linejoin", "round");
|
|
875
|
+
svg.setAttribute("aria-hidden", "true");
|
|
876
|
+
const ring = document.createElementNS(NS, "circle");
|
|
877
|
+
ring.setAttribute("cx", "8");
|
|
878
|
+
ring.setAttribute("cy", "8");
|
|
879
|
+
ring.setAttribute("r", "6.67");
|
|
880
|
+
svg.appendChild(ring);
|
|
881
|
+
for (const d of ["M8 5.33v5.34", "M5.33 8h5.34"]) {
|
|
882
|
+
const path = document.createElementNS(NS, "path");
|
|
883
|
+
path.setAttribute("d", d);
|
|
884
|
+
svg.appendChild(path);
|
|
885
|
+
}
|
|
886
|
+
return svg;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/** A person, drawn to match the sidebar's other 16px line icons. */
|
|
890
|
+
function iconPerson() {
|
|
891
|
+
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
892
|
+
svg.setAttribute("class", "ico");
|
|
893
|
+
svg.setAttribute("viewBox", "0 0 16 16");
|
|
894
|
+
svg.setAttribute("fill", "none");
|
|
895
|
+
svg.setAttribute("stroke", "currentColor");
|
|
896
|
+
svg.setAttribute("stroke-width", "1.5");
|
|
897
|
+
svg.setAttribute("stroke-linecap", "round");
|
|
898
|
+
svg.setAttribute("stroke-linejoin", "round");
|
|
899
|
+
svg.setAttribute("aria-hidden", "true");
|
|
900
|
+
for (const d of ["M13.33 14v-1.33A2.67 2.67 0 0 0 10.67 10H5.33a2.67 2.67 0 0 0-2.66 2.67V14"]) {
|
|
901
|
+
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
|
902
|
+
path.setAttribute("d", d);
|
|
903
|
+
svg.appendChild(path);
|
|
904
|
+
}
|
|
905
|
+
const head = document.createElementNS("http://www.w3.org/2000/svg", "circle");
|
|
906
|
+
head.setAttribute("cx", "8");
|
|
907
|
+
head.setAttribute("cy", "5.33");
|
|
908
|
+
head.setAttribute("r", "2.67");
|
|
909
|
+
svg.appendChild(head);
|
|
910
|
+
return svg;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/**
|
|
914
|
+
* The account row, under the session list.
|
|
915
|
+
*
|
|
916
|
+
* Signed out it is one button, because that is the whole of what can be done
|
|
917
|
+
* from here. Signed in it says who, because the failure this prevents is a
|
|
918
|
+
* stale login: sync running quietly into somebody else's account is invisible
|
|
919
|
+
* from every other screen.
|
|
920
|
+
*
|
|
921
|
+
* It reuses websiteSignIn rather than growing a login of its own. The site's
|
|
922
|
+
* page offers a password, Google, and the way to create an account at all,
|
|
923
|
+
* and none of those can be hosted in a local window — they keep working here
|
|
924
|
+
* precisely because this does not reimplement them.
|
|
925
|
+
*/
|
|
926
|
+
/**
|
|
927
|
+
* The teams the account is on, as the last account load found them.
|
|
928
|
+
*
|
|
929
|
+
* Kept here so the paywall can name a team by its id without a second
|
|
930
|
+
* request: the id is what the billing answer carries, and the name is what
|
|
931
|
+
* a person reads.
|
|
932
|
+
*/
|
|
933
|
+
let knownTeams = [];
|
|
934
|
+
|
|
935
|
+
async function loadAccount() {
|
|
936
|
+
const host = document.getElementById("account");
|
|
937
|
+
if (!host) return;
|
|
938
|
+
const body = await getJson("/account");
|
|
939
|
+
host.replaceChildren();
|
|
940
|
+
if (!body || body.error) return;
|
|
941
|
+
|
|
942
|
+
host.classList.toggle("out", !body.signedIn);
|
|
943
|
+
|
|
944
|
+
if (!body.signedIn) {
|
|
945
|
+
// One row, not three. An empty avatar and the words "not signed in"
|
|
946
|
+
// beside a button labelled "Sign in" were the same fact said three times,
|
|
947
|
+
// and the two that were not clickable were the two taking the space. What
|
|
948
|
+
// is left reads as what it is — another way out of the sidebar, in the
|
|
949
|
+
// shape of the rows above it — with the one thing the button does not
|
|
950
|
+
// say, the state you are in meanwhile, in the aside slot they already have.
|
|
951
|
+
const go = el("button", "nav-item");
|
|
952
|
+
go.type = "button";
|
|
953
|
+
go.appendChild(iconPerson());
|
|
954
|
+
go.appendChild(el("span", null, t("account.signIn")));
|
|
955
|
+
// In the slot the nav rows already keep for a quiet aside — the model
|
|
956
|
+
// count, the search chord. It says what is true right now rather than
|
|
957
|
+
// repeating the button: nothing is leaving this machine.
|
|
958
|
+
go.appendChild(el("span", "count", t("account.localOnly")));
|
|
959
|
+
// The same dialog the models panel and the composer open. It used to go
|
|
960
|
+
// straight to the browser from here, which was one click faster and had
|
|
961
|
+
// no way back: a machine the website cannot redirect to — reached over a
|
|
962
|
+
// LAN address rather than loopback — left this button opening a tab that
|
|
963
|
+
// dead-ends, with the emailed code that would have worked nowhere in
|
|
964
|
+
// reach. The dialog is the one place all the ways in are on screen.
|
|
965
|
+
go.addEventListener("click", () => openConnect(body.provider));
|
|
966
|
+
host.appendChild(go);
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
// Who, and nothing else. "Account connected" restated the fact that a name
|
|
971
|
+
// was on screen at all, in the line under the name — and a Sign out button
|
|
972
|
+
// sitting permanently in the corner gives the rarest thing anyone does here
|
|
973
|
+
// the most reliable target in the sidebar. Both go behind the row itself,
|
|
974
|
+
// which is now the button: a name you can click is where every other app
|
|
975
|
+
// keeps the account menu, so it needs no label saying so.
|
|
976
|
+
const row = el("button", "account-btn");
|
|
977
|
+
row.type = "button";
|
|
978
|
+
const name = body.name || body.email || t("account.signedIn");
|
|
979
|
+
row.appendChild(avatarFor(name, body.avatar));
|
|
980
|
+
row.appendChild(el("span", "who-name", name));
|
|
981
|
+
row.appendChild(svgIcon("chevronsVertical", "acct-caret"));
|
|
982
|
+
|
|
983
|
+
// The balance, once it can be read. Running out mid-turn is the failure this
|
|
984
|
+
// prevents: a number that appears only in a paywall is a number nobody sees
|
|
985
|
+
// until it is too late to act on calmly. It heads the menu rather than the
|
|
986
|
+
// row — a number under a name reads as part of the name, and this one is
|
|
987
|
+
// worth reading on the way to the dashboard rather than at a glance. Asked
|
|
988
|
+
// for separately so a gateway having a bad day cannot keep the row from
|
|
989
|
+
// saying who is signed in, which is the thing it is actually for.
|
|
990
|
+
// Whether there is a shop at all. A self-hosted gateway sells nothing, and
|
|
991
|
+
// an entry that opens a paywall with no plans in it is worse than no entry.
|
|
992
|
+
let sellsCredits = false;
|
|
993
|
+
// Whether the plan is live. This decides which billing action is primary in
|
|
994
|
+
// the account card: Subscribe before a plan, Top up afterwards.
|
|
995
|
+
let subscribed = false;
|
|
996
|
+
// The team this machine bills to, once the gateway has said what it has.
|
|
997
|
+
// A team the account was removed from comes back refused, and the line
|
|
998
|
+
// says so rather than showing a number it could not read.
|
|
999
|
+
let teamLine = null;
|
|
1000
|
+
// Whether the headline is a chosen team's rather than the person's, and
|
|
1001
|
+
// whether this person may pay for it. A team is bought and topped up on its
|
|
1002
|
+
// page on the site, and only by members with billing rights.
|
|
1003
|
+
let onTeam = false;
|
|
1004
|
+
let teamBilling = false;
|
|
1005
|
+
// The card's parts, kept apart rather than joined into sentences: whose
|
|
1006
|
+
// workspace (top left), its plan and whether that plan is live (top right),
|
|
1007
|
+
// and the balance under both. Joined with middle dots they read as one
|
|
1008
|
+
// shouted caption with nothing in it standing out.
|
|
1009
|
+
let workspaceName = null;
|
|
1010
|
+
let planName = null;
|
|
1011
|
+
let planStatus = null;
|
|
1012
|
+
let credits = null;
|
|
1013
|
+
knownTeams = Array.isArray(body.teams) ? body.teams : [];
|
|
1014
|
+
const teamNameOf = (id) => {
|
|
1015
|
+
const found = knownTeams.find((team) => team.id === id);
|
|
1016
|
+
return found ? found.name : t("account.teamUnnamed");
|
|
1017
|
+
};
|
|
1018
|
+
void getJson("/billing").then((state) => {
|
|
1019
|
+
if (!state || !state.signedIn || state.unavailable) return;
|
|
1020
|
+
sellsCredits = true;
|
|
1021
|
+
// The plan, from the same answer the balance comes out of: "0 credits"
|
|
1022
|
+
// reads as a problem to someone on a plan and as a fact of life to someone
|
|
1023
|
+
// who never subscribed, and the two want different next steps.
|
|
1024
|
+
const sub = state.subscription;
|
|
1025
|
+
subscribed = Boolean(sub && (sub.status === "active" || sub.status === "trialing"));
|
|
1026
|
+
// A plan's name as the catalogue spells it ("Team"), not its id ("team");
|
|
1027
|
+
// the id stands in only for a plan the catalogue no longer lists.
|
|
1028
|
+
const planLabel = (id) => {
|
|
1029
|
+
const found = (Array.isArray(state.plans) ? state.plans : []).find((one) => one && one.id === id);
|
|
1030
|
+
return (found && found.label) || id;
|
|
1031
|
+
};
|
|
1032
|
+
workspaceName = t("account.personal");
|
|
1033
|
+
planName = sub && sub.plan ? planLabel(sub.plan) : null;
|
|
1034
|
+
planStatus = sub && sub.plan ? sub.status : null;
|
|
1035
|
+
credits = state.balanceMicro !== undefined ? creditsOf(state.balanceMicro) : null;
|
|
1036
|
+
if (state.team && state.team.refused) {
|
|
1037
|
+
teamLine = t("account.teamRefused", { team: teamNameOf(state.team.id) });
|
|
1038
|
+
} else if (state.team) {
|
|
1039
|
+
// A chosen team is whose money the next call spends, so the card is the
|
|
1040
|
+
// team's alone: its balance and its plan, which decides Subscribe or Top
|
|
1041
|
+
// up. The person's own plan used to lead instead, and a member of a
|
|
1042
|
+
// paying team with no plan of their own was told "No subscription" and
|
|
1043
|
+
// offered one. Their own balance is not shown beside it — two balances in
|
|
1044
|
+
// one card left it unclear which the next call draws on — and choosing
|
|
1045
|
+
// Personal in this menu shows it.
|
|
1046
|
+
const name = teamNameOf(state.team.id);
|
|
1047
|
+
const teamSub = state.team.subscription;
|
|
1048
|
+
teamBilling = state.team.billing === true;
|
|
1049
|
+
subscribed = Boolean(teamSub && (teamSub.status === "active" || teamSub.status === "trialing"));
|
|
1050
|
+
workspaceName = name;
|
|
1051
|
+
planName = teamSub && teamSub.plan ? planLabel(teamSub.plan) : null;
|
|
1052
|
+
planStatus = teamSub && teamSub.plan ? teamSub.status : null;
|
|
1053
|
+
credits = creditsOf(state.team.balanceMicro || 0);
|
|
1054
|
+
onTeam = true;
|
|
1055
|
+
}
|
|
1056
|
+
});
|
|
1057
|
+
|
|
1058
|
+
// Switch whose credits the next call spends. Said out loud, because the
|
|
1059
|
+
// only other sign is a different number in this menu next time it opens.
|
|
1060
|
+
const billTo = (id) => {
|
|
1061
|
+
closeRowMenu();
|
|
1062
|
+
void api("/team", { team: id }).then((result) => {
|
|
1063
|
+
if (!result || result.error) return void addNotice((result && result.error) || "could not switch", "err");
|
|
1064
|
+
addNotice(id ? t("account.usingTeam", { team: teamNameOf(id) }) : t("account.usingOwn"));
|
|
1065
|
+
void loadAccount();
|
|
1066
|
+
});
|
|
1067
|
+
};
|
|
1068
|
+
|
|
1069
|
+
row.addEventListener("click", (e) => {
|
|
1070
|
+
e.stopPropagation();
|
|
1071
|
+
if (menuRow === row) return closeRowMenu();
|
|
1072
|
+
const items = [];
|
|
1073
|
+
// The two facts, in one block with a surface under them. They were two
|
|
1074
|
+
// dim lines the size of a footnote, above the things to click — which is
|
|
1075
|
+
// backwards: the balance is why somebody opened this menu, and the plan
|
|
1076
|
+
// is what decides which of the entries below is the one they want.
|
|
1077
|
+
if (workspaceName || teamLine) {
|
|
1078
|
+
const head = el("div", "menu-head account-card");
|
|
1079
|
+
if (workspaceName) {
|
|
1080
|
+
const top = el("div", "menu-head-top");
|
|
1081
|
+
top.appendChild(el("span", "menu-head-name", workspaceName));
|
|
1082
|
+
// The plan as a pill whose dot says whether it is live: green for a
|
|
1083
|
+
// plan that pays, amber for one whose payment failed, grey for none.
|
|
1084
|
+
// The word for the status is written only when it is not the ordinary
|
|
1085
|
+
// one — "Active" beside every paying plan is a word nobody reads.
|
|
1086
|
+
const live = planStatus === "active" || planStatus === "trialing";
|
|
1087
|
+
const pill = el("span", "plan-pill" + (live ? " ok" : planStatus === "past_due" ? " warn" : ""));
|
|
1088
|
+
pill.appendChild(el("i", "plan-pill-dot"));
|
|
1089
|
+
pill.appendChild(el("span", "plan-pill-name", planName || t("account.noPlan")));
|
|
1090
|
+
if (planName && planStatus && planStatus !== "active") {
|
|
1091
|
+
pill.appendChild(el("span", "plan-pill-status", t("account.status." + planStatus)));
|
|
1092
|
+
}
|
|
1093
|
+
if (planName && planStatus) pill.title = t("account.status." + planStatus);
|
|
1094
|
+
top.appendChild(pill);
|
|
1095
|
+
head.appendChild(top);
|
|
1096
|
+
}
|
|
1097
|
+
if (credits !== null) {
|
|
1098
|
+
const amount = el("div", "menu-head-amount");
|
|
1099
|
+
amount.appendChild(el("span", "menu-head-num", credits));
|
|
1100
|
+
amount.appendChild(el("span", "menu-head-unit", t("account.creditsUnit")));
|
|
1101
|
+
head.appendChild(amount);
|
|
1102
|
+
}
|
|
1103
|
+
// A team the account was removed from: said in place of a balance the
|
|
1104
|
+
// gateway would not give.
|
|
1105
|
+
if (teamLine) head.appendChild(el("div", "menu-head-sub", teamLine));
|
|
1106
|
+
if (sellsCredits && onTeam) {
|
|
1107
|
+
// One action, on the team page: that is where a team plan is bought
|
|
1108
|
+
// and a team balance topped up. Members without billing rights get
|
|
1109
|
+
// none — a button that can only fail is worse than its absence, and
|
|
1110
|
+
// the paywall already tells them to ask when the credits run out.
|
|
1111
|
+
if (teamBilling) {
|
|
1112
|
+
const actions = el("div", "menu-billing-actions");
|
|
1113
|
+
const go = el("button", "menu-billing-action primary", t(subscribed ? "paywall.topUp" : "paywall.subscribe"));
|
|
1114
|
+
go.type = "button";
|
|
1115
|
+
go.addEventListener("click", () => {
|
|
1116
|
+
closeRowMenu();
|
|
1117
|
+
if (body.site) window.open(body.site + "/" + LOCALE + "/dashboard/team", "_blank", "noopener");
|
|
1118
|
+
});
|
|
1119
|
+
actions.appendChild(go);
|
|
1120
|
+
head.appendChild(actions);
|
|
1121
|
+
}
|
|
1122
|
+
} else if (sellsCredits) {
|
|
1123
|
+
const actions = el("div", "menu-billing-actions");
|
|
1124
|
+
if (!subscribed) {
|
|
1125
|
+
const subscribe = el("button", "menu-billing-action primary", t("paywall.subscribe"));
|
|
1126
|
+
subscribe.type = "button";
|
|
1127
|
+
subscribe.addEventListener("click", () => {
|
|
1128
|
+
closeRowMenu();
|
|
1129
|
+
openPaywall(true);
|
|
1130
|
+
});
|
|
1131
|
+
actions.appendChild(subscribe);
|
|
1132
|
+
}
|
|
1133
|
+
const topUp = el("button", "menu-billing-action" + (subscribed ? " primary" : ""), t("paywall.topUp"));
|
|
1134
|
+
topUp.type = "button";
|
|
1135
|
+
topUp.disabled = !subscribed;
|
|
1136
|
+
if (!subscribed) topUp.title = t("paywall.needsPlan");
|
|
1137
|
+
topUp.addEventListener("click", () => {
|
|
1138
|
+
closeRowMenu();
|
|
1139
|
+
openPaywall(true);
|
|
1140
|
+
});
|
|
1141
|
+
actions.appendChild(topUp);
|
|
1142
|
+
head.appendChild(actions);
|
|
1143
|
+
}
|
|
1144
|
+
items.push(head);
|
|
1145
|
+
}
|
|
1146
|
+
// The dashboard straight under the balance it explains, and above the
|
|
1147
|
+
// workspace list, which is a choice rather than a destination.
|
|
1148
|
+
items.push(
|
|
1149
|
+
menuItem(t("account.dashboard"), {
|
|
1150
|
+
run: () => {
|
|
1151
|
+
closeRowMenu();
|
|
1152
|
+
if (body.site) window.open(body.site + "/" + LOCALE + "/dashboard", "_blank", "noopener");
|
|
1153
|
+
},
|
|
1154
|
+
}),
|
|
1155
|
+
);
|
|
1156
|
+
// Whose credits the next call spends: Personal, or a team the account is
|
|
1157
|
+
// on, the current one ticked the way the model menu ticks its choice.
|
|
1158
|
+
// Only once there is a team to choose, since a list of one is no choice.
|
|
1159
|
+
if (knownTeams.length) {
|
|
1160
|
+
items.push(el("div", "menu-head menu-section", t("account.workspace")));
|
|
1161
|
+
const choice = (label, id) => {
|
|
1162
|
+
const chosen = (body.team || null) === id;
|
|
1163
|
+
const item = el("button", "menu-item" + (chosen ? " on" : ""));
|
|
1164
|
+
item.type = "button";
|
|
1165
|
+
item.appendChild(el("span", "tick", "\u2713"));
|
|
1166
|
+
item.appendChild(el("span", "menu-label", label));
|
|
1167
|
+
item.addEventListener("click", () => (chosen ? closeRowMenu() : billTo(id)));
|
|
1168
|
+
return item;
|
|
1169
|
+
};
|
|
1170
|
+
items.push(choice(t("account.personal"), null));
|
|
1171
|
+
knownTeams.forEach((team) => items.push(choice(team.name, team.id)));
|
|
1172
|
+
}
|
|
1173
|
+
// The way to a workspace at all, for everyone signed in: Create a team for an
|
|
1174
|
+
// account on none, Manage workspaces once there is one. Both are the team page
|
|
1175
|
+
// on the site, in the real browser, where a team is started, its people
|
|
1176
|
+
// invited and its seats bought.
|
|
1177
|
+
if (body.site) {
|
|
1178
|
+
items.push(
|
|
1179
|
+
menuItem(t(knownTeams.length ? "account.manageTeams" : "account.createTeam"), {
|
|
1180
|
+
run: () => {
|
|
1181
|
+
closeRowMenu();
|
|
1182
|
+
window.open(body.site + "/" + LOCALE + "/dashboard/team", "_blank", "noopener");
|
|
1183
|
+
},
|
|
1184
|
+
}),
|
|
1185
|
+
);
|
|
1186
|
+
}
|
|
1187
|
+
// The two pages this menu had no way to reach, under the workspace rows
|
|
1188
|
+
// they belong beside: what a team costs and how to ask for the parts of it
|
|
1189
|
+
// that are not self-serve, and the way to reach a person at all. Both open
|
|
1190
|
+
// in the real browser like every other outbound item here — in the desktop
|
|
1191
|
+
// build the window handler routes them there rather than loading the site
|
|
1192
|
+
// inside the window the session lives in.
|
|
1193
|
+
if (body.site) {
|
|
1194
|
+
items.push(
|
|
1195
|
+
menuItem(t("account.enterprise"), {
|
|
1196
|
+
run: () => {
|
|
1197
|
+
closeRowMenu();
|
|
1198
|
+
window.open(body.site + "/" + LOCALE + "/enterprise", "_blank", "noopener");
|
|
1199
|
+
},
|
|
1200
|
+
}),
|
|
1201
|
+
menuItem(t("account.contact"), {
|
|
1202
|
+
run: () => {
|
|
1203
|
+
closeRowMenu();
|
|
1204
|
+
window.open(body.site + "/" + LOCALE + "/contact", "_blank", "noopener");
|
|
1205
|
+
},
|
|
1206
|
+
}),
|
|
1207
|
+
);
|
|
1208
|
+
}
|
|
1209
|
+
items.push(
|
|
1210
|
+
menuItem(t("account.download"), {
|
|
1211
|
+
run: () => {
|
|
1212
|
+
closeRowMenu();
|
|
1213
|
+
if (body.site) window.open(body.site + "/" + LOCALE + "/download", "_blank", "noopener");
|
|
1214
|
+
},
|
|
1215
|
+
cls: "accent",
|
|
1216
|
+
}),
|
|
1217
|
+
themeRow(),
|
|
1218
|
+
menuItem(t("account.signOut"), {
|
|
1219
|
+
run: () => {
|
|
1220
|
+
closeRowMenu();
|
|
1221
|
+
void api("/logout", { provider: body.provider }).then(() => {
|
|
1222
|
+
void loadAccount();
|
|
1223
|
+
// Sync reads this credential, so the settings page is now wrong.
|
|
1224
|
+
repaintPanel();
|
|
1225
|
+
});
|
|
1226
|
+
},
|
|
1227
|
+
}),
|
|
1228
|
+
);
|
|
1229
|
+
openRowMenu(row, row, items);
|
|
1230
|
+
// As wide as the row it opens from, and placed again once it is: the
|
|
1231
|
+
// first placement measured the menu at its own width.
|
|
1232
|
+
rowMenu.classList.add("fit-anchor", "account-menu");
|
|
1233
|
+
rowMenu.style.width = row.getBoundingClientRect().width + "px";
|
|
1234
|
+
placeRowMenu(row);
|
|
1235
|
+
});
|
|
1236
|
+
host.appendChild(row);
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
/**
|
|
1240
|
+
* The circle at the front of the account row.
|
|
1241
|
+
*
|
|
1242
|
+
* The picture when the sign-in knew one — a Google account does — and the
|
|
1243
|
+
* initial when it did not, which is also what is on screen for the moment
|
|
1244
|
+
* before the picture arrives and forever if it never does. Drawn behind the
|
|
1245
|
+
* image rather than swapped in on failure, so a broken address degrades to a
|
|
1246
|
+
* letter instead of to a hole in the row.
|
|
1247
|
+
*/
|
|
1248
|
+
function avatarFor(name, url) {
|
|
1249
|
+
const avatar = el("div", "avatar");
|
|
1250
|
+
avatar.textContent = (name || "?").slice(0, 1).toUpperCase();
|
|
1251
|
+
if (!url) return avatar;
|
|
1252
|
+
const img = el("img");
|
|
1253
|
+
img.src = url;
|
|
1254
|
+
img.alt = "";
|
|
1255
|
+
img.referrerPolicy = "no-referrer";
|
|
1256
|
+
img.addEventListener("error", () => img.remove());
|
|
1257
|
+
avatar.appendChild(img);
|
|
1258
|
+
return avatar;
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
/**
|
|
1262
|
+
* Signing in the way the website does, in the browser the user already has.
|
|
1263
|
+
*
|
|
1264
|
+
* The site's login offers a password, Google, and the way to *create* an
|
|
1265
|
+
* account in the first place — none of which a local window can host, and all
|
|
1266
|
+
* of which stay working here for free because this button does not
|
|
1267
|
+
* reimplement any of them. It opens a tab, the tab comes back to a callback on
|
|
1268
|
+
* this server, and the panel repaints when the sign-in lands.
|
|
1269
|
+
*
|
|
1270
|
+
* Opened with window.open rather than as a link: in the desktop build the
|
|
1271
|
+
* window handler routes that to the real browser, where a link would have
|
|
1272
|
+
* loaded the site inside the app, in the window the agent's session lives in.
|
|
1273
|
+
*/
|
|
1274
|
+
function websiteSignIn(provider, onSignedIn, onUnavailable) {
|
|
1275
|
+
const row = el("div", "keyrow");
|
|
1276
|
+
const go = el("button", "signin-site", t("models.signInWithSite"));
|
|
1277
|
+
go.type = "button";
|
|
1278
|
+
go.addEventListener("click", (e) => {
|
|
1279
|
+
e.preventDefault();
|
|
1280
|
+
e.stopPropagation();
|
|
1281
|
+
startWebsiteSignIn(provider.id, onSignedIn, go, onUnavailable);
|
|
1282
|
+
});
|
|
1283
|
+
row.appendChild(go);
|
|
1284
|
+
return row;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
/**
|
|
1288
|
+
* The sign-in itself, without a button attached to it.
|
|
1289
|
+
*
|
|
1290
|
+
* Kept separate from the button so the behaviour — where the tab is opened,
|
|
1291
|
+
* which waiter is registered, what is said while it is open — has one home,
|
|
1292
|
+
* whatever the control that starts it looks like.
|
|
1293
|
+
*
|
|
1294
|
+
* onUnavailable is for the one refusal that is not an error to report and
|
|
1295
|
+
* forget: this server was reached at an address the website is not allowed to
|
|
1296
|
+
* redirect back to, so the browser route is shut and the emailed code is the
|
|
1297
|
+
* way in. The caller is the one that can put that where the user is looking.
|
|
1298
|
+
*/
|
|
1299
|
+
function startWebsiteSignIn(providerId, onSignedIn, button, onUnavailable, intent, onError) {
|
|
1300
|
+
if (button) button.disabled = true;
|
|
1301
|
+
void api("/signin/browser", { provider: providerId, locale: LOCALE, ...(intent ? { intent } : {}) })
|
|
1302
|
+
.then((result) => {
|
|
1303
|
+
if (!result || result.error) {
|
|
1304
|
+
const message = (result && result.error) || "could not start the sign-in";
|
|
1305
|
+
if (result && result.fallback === "code" && onUnavailable) return void onUnavailable(message);
|
|
1306
|
+
// A caller with a dialog open has somewhere better to put this.
|
|
1307
|
+
// addNotice writes to the transcript when no panel is up, so an error
|
|
1308
|
+
// from a button in a modal would arrive behind the modal, under an
|
|
1309
|
+
// assistant heading, reading as something the model said.
|
|
1310
|
+
if (onError) return void onError(message);
|
|
1311
|
+
return void addNotice(message, "err");
|
|
1312
|
+
}
|
|
1313
|
+
// Only now: a sign-in that never started has nothing to wait for, and a
|
|
1314
|
+
// waiter left behind would fire on the next unrelated discovery.
|
|
1315
|
+
if (onSignedIn) signedInWaiters.set(providerId, onSignedIn);
|
|
1316
|
+
window.open(result.url, "_blank", "noopener");
|
|
1317
|
+
// The tab is where the user is now, and it can take a minute. Saying so
|
|
1318
|
+
// beats a button that went quiet: the panel repaints by itself when the
|
|
1319
|
+
// callback lands, so there is nothing to come back and click.
|
|
1320
|
+
addNotice(t("models.signInOpened", { site: result.site || "the website" }));
|
|
1321
|
+
})
|
|
1322
|
+
.finally(() => {
|
|
1323
|
+
if (button) button.disabled = false;
|
|
1324
|
+
});
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
|
|
1328
|
+
/**
|
|
1329
|
+
* How often the paywall asks whether the money landed.
|
|
1330
|
+
*
|
|
1331
|
+
* Nothing pushes the answer here. Stripe tells the gateway over a webhook and
|
|
1332
|
+
* the gateway writes a ledger row; this side has no channel either of them
|
|
1333
|
+
* speaks on, so the balance is the only thing to watch. Five seconds is slow
|
|
1334
|
+
* enough to be free and fast enough that nobody goes back to the browser tab
|
|
1335
|
+
* to check whether it worked.
|
|
1336
|
+
*/
|
|
1337
|
+
const PAYWALL_POLL_MS = 5000;
|
|
1338
|
+
let paywallTimer = null;
|
|
1339
|
+
|
|
1340
|
+
/** Credits as a person reads them, from the micro-credits the ledger keeps. */
|
|
1341
|
+
/**
|
|
1342
|
+
* What a plan costs, in the currency it is sold in.
|
|
1343
|
+
*
|
|
1344
|
+
* The gateway has always sent amountCents and currency and the paywall threw
|
|
1345
|
+
* them away, so every row said how many credits and never how much money —
|
|
1346
|
+
* which is the one number somebody deciding between four rows needs.
|
|
1347
|
+
*/
|
|
1348
|
+
function moneyOf(cents, currency) {
|
|
1349
|
+
if (typeof cents !== "number") return "";
|
|
1350
|
+
return (cents / 100).toLocaleString(LOCALE, {
|
|
1351
|
+
style: "currency",
|
|
1352
|
+
currency: (currency || "usd").toUpperCase(),
|
|
1353
|
+
minimumFractionDigits: cents % 100 === 0 ? 0 : 2,
|
|
1354
|
+
maximumFractionDigits: 2,
|
|
1355
|
+
});
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
function creditsOf(micro) {
|
|
1359
|
+
return (Number(micro || 0) / 1000000).toLocaleString(LOCALE, {
|
|
1360
|
+
minimumFractionDigits: 0,
|
|
1361
|
+
maximumFractionDigits: 2,
|
|
1362
|
+
});
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
function closePaywall() {
|
|
1366
|
+
if (paywallTimer) { clearInterval(paywallTimer); paywallTimer = null; }
|
|
1367
|
+
const dialog = document.getElementById("paywall");
|
|
1368
|
+
if (dialog.open) dialog.close();
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
/**
|
|
1372
|
+
* The paywall: what this account has left, and what it could buy.
|
|
1373
|
+
*
|
|
1374
|
+
* Opened by a 402 and by nothing else — the account could not pay for the
|
|
1375
|
+
* turn that was just sent.
|
|
1376
|
+
*
|
|
1377
|
+
* The advice differs by who is asking, which is why the balance is fetched
|
|
1378
|
+
* rather than assumed: a subscriber whose period ran out tops up, someone who
|
|
1379
|
+
* never subscribed subscribes, and telling either of them the other's answer
|
|
1380
|
+
* sends them somewhere that cannot help. The gateway already makes that split
|
|
1381
|
+
* in its refusal message; it is made again here because here it can be a
|
|
1382
|
+
* button instead of a sentence.
|
|
1383
|
+
*
|
|
1384
|
+
* Paying itself happens in the browser, for the same reason signing in does:
|
|
1385
|
+
* Stripe's page runs 3-D Secure and hands off to a bank's own app, and a local
|
|
1386
|
+
* window can host neither. What comes back is a webhook to a service this app
|
|
1387
|
+
* never talks to — so the way this screen learns it worked is by watching the
|
|
1388
|
+
* balance, which is also the only number that was ever the real answer.
|
|
1389
|
+
*/
|
|
1390
|
+
/** Which interval the paywall is showing. Monthly by default: it is the
|
|
1391
|
+
* smaller commitment, and this dialog is interrupting somebody who wanted to
|
|
1392
|
+
* send a message, not shopping. */
|
|
1393
|
+
/**
|
|
1394
|
+
* Light, dark, or whatever the system says.
|
|
1395
|
+
*
|
|
1396
|
+
* Three states rather than a switch, because following the system was the
|
|
1397
|
+
* behaviour before this existed and taking it away to gain a toggle is a
|
|
1398
|
+
* trade nobody asked for. Stored per browser: it describes this screen in
|
|
1399
|
+
* this room, not the account.
|
|
1400
|
+
*/
|
|
1401
|
+
const THEMES = ["system", "light", "dark"];
|
|
1402
|
+
let theme = "system";
|
|
1403
|
+
try {
|
|
1404
|
+
const saved = localStorage.getItem("oharness-theme");
|
|
1405
|
+
if (THEMES.indexOf(saved) > 0) theme = saved;
|
|
1406
|
+
} catch {
|
|
1407
|
+
// Private windows and blocked site data both throw here. Following the
|
|
1408
|
+
// system is the right answer when the preference cannot be read.
|
|
1409
|
+
}
|
|
1410
|
+
function applyTheme() {
|
|
1411
|
+
if (theme === "system") document.documentElement.removeAttribute("data-theme");
|
|
1412
|
+
else document.documentElement.setAttribute("data-theme", theme);
|
|
1413
|
+
try {
|
|
1414
|
+
if (theme === "system") localStorage.removeItem("oharness-theme");
|
|
1415
|
+
else localStorage.setItem("oharness-theme", theme);
|
|
1416
|
+
} catch {
|
|
1417
|
+
// Unstorable is not unusable: it applies for this tab and forgets after.
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
applyTheme();
|
|
1421
|
+
|
|
1422
|
+
let pwInterval = "month";
|
|
1423
|
+
|
|
1424
|
+
/**
|
|
1425
|
+
* @param buying Opened deliberately, rather than by a turn that could not be
|
|
1426
|
+
* paid for. The screen is the same and the sentence at the top is not: a
|
|
1427
|
+
* dialog that says "this turn could not be paid for" to somebody who chose
|
|
1428
|
+
* Subscribe from a menu is describing a failure that did not happen.
|
|
1429
|
+
*/
|
|
1430
|
+
function openPaywall(buying) {
|
|
1431
|
+
const dialog = document.getElementById("paywall");
|
|
1432
|
+
const body = document.getElementById("pw-body");
|
|
1433
|
+
document.getElementById("pw-title").textContent = t(buying ? "paywall.titleBuy" : "paywall.title");
|
|
1434
|
+
document.getElementById("pw-desc").textContent = t(buying ? "paywall.descBuy" : "paywall.desc");
|
|
1435
|
+
|
|
1436
|
+
let site = "";
|
|
1437
|
+
// Whether a checkout has been opened in a browser tab. Kept as state rather
|
|
1438
|
+
// than as a line appended after the click, because the poll below repaints
|
|
1439
|
+
// the body every few seconds — an appended note lived until the first tick
|
|
1440
|
+
// and then vanished, which reads as the click having done nothing.
|
|
1441
|
+
let awaiting = false;
|
|
1442
|
+
// Assigned rather than added: this can be opened many times in a session,
|
|
1443
|
+
// and addEventListener would stack a handler per opening.
|
|
1444
|
+
document.getElementById("pw-close").onclick = () => closePaywall();
|
|
1445
|
+
document.getElementById("pw-site").onclick = () => {
|
|
1446
|
+
window.open(site ? site + "/" + LOCALE + "/dashboard" : "https://oharness.ai", "_blank", "noopener");
|
|
1447
|
+
};
|
|
1448
|
+
// Escape closes a dialog without going through the button, and a timer left
|
|
1449
|
+
// running behind a closed dialog polls a gateway forever.
|
|
1450
|
+
dialog.onclose = () => {
|
|
1451
|
+
if (paywallTimer) { clearInterval(paywallTimer); paywallTimer = null; }
|
|
1452
|
+
};
|
|
1453
|
+
|
|
1454
|
+
const buy = (plan, button) => {
|
|
1455
|
+
button.disabled = true;
|
|
1456
|
+
void api("/billing/checkout", {
|
|
1457
|
+
plan: plan.id,
|
|
1458
|
+
locale: LOCALE,
|
|
1459
|
+
...(plan.buyCents ? { amount: plan.buyCents } : {}),
|
|
1460
|
+
})
|
|
1461
|
+
.then((result) => {
|
|
1462
|
+
if (!result || result.error || !result.url) {
|
|
1463
|
+
return void addNotice((result && result.error) || "could not start the checkout", "err");
|
|
1464
|
+
}
|
|
1465
|
+
window.open(result.url, "_blank", "noopener");
|
|
1466
|
+
// The user is in a browser tab now and it can take a minute. The
|
|
1467
|
+
// dialog stays open because it is what closes itself when the credits
|
|
1468
|
+
// arrive — there is nothing to come back here and click.
|
|
1469
|
+
awaiting = true;
|
|
1470
|
+
body.appendChild(el("div", "cn-note", t("paywall.opened")));
|
|
1471
|
+
})
|
|
1472
|
+
.finally(() => {
|
|
1473
|
+
button.disabled = false;
|
|
1474
|
+
});
|
|
1475
|
+
};
|
|
1476
|
+
|
|
1477
|
+
const render = (state) => {
|
|
1478
|
+
if (!dialog.open) return;
|
|
1479
|
+
site = state.site || site;
|
|
1480
|
+
body.replaceChildren();
|
|
1481
|
+
|
|
1482
|
+
if (!state.signedIn) {
|
|
1483
|
+
// A 402 means a credential was accepted, so this is the rarer case of a
|
|
1484
|
+
// sign-out between the refusal and this fetch. Still worth answering
|
|
1485
|
+
// properly: the way out is the sign-in, not a price list.
|
|
1486
|
+
body.appendChild(el("div", "cn-note", t("paywall.signIn")));
|
|
1487
|
+
const go = el("button", "signin-site", t("models.signInWithSite"));
|
|
1488
|
+
go.type = "button";
|
|
1489
|
+
go.onclick = () => {
|
|
1490
|
+
closePaywall();
|
|
1491
|
+
openConnect(state.provider || "official");
|
|
1492
|
+
};
|
|
1493
|
+
body.appendChild(go);
|
|
1494
|
+
return;
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
// The balance first: it is the fact the rest of the screen is about, and a
|
|
1498
|
+
// paywall that does not show it is asking to be taken at its word.
|
|
1499
|
+
//
|
|
1500
|
+
// Only when it was actually read, though. A gateway that could not be
|
|
1501
|
+
// reached leaves this undefined, and rendering that as "0 credits" would
|
|
1502
|
+
// state as fact the one thing this screen just failed to find out.
|
|
1503
|
+
if (state.balanceMicro !== undefined) {
|
|
1504
|
+
const balance = el("p", "pw-balance");
|
|
1505
|
+
balance.appendChild(el("strong", null, creditsOf(state.balanceMicro)));
|
|
1506
|
+
balance.appendChild(el("span", "muted", t("paywall.unit")));
|
|
1507
|
+
body.appendChild(balance);
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
if (state.unavailable) {
|
|
1511
|
+
// A deployment that sells nothing is not broken — self-hosted gateways
|
|
1512
|
+
// grant credits by hand — so this reads as a fact rather than a failure,
|
|
1513
|
+
// and the site button below is still the way on.
|
|
1514
|
+
body.appendChild(
|
|
1515
|
+
el("div", "cn-note",
|
|
1516
|
+
t(state.unavailable === "not_for_sale" ? "paywall.notForSale" : "paywall.unreachable")),
|
|
1517
|
+
);
|
|
1518
|
+
return;
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
// A team is chosen, so the balance that ran out is the team's, and the
|
|
1522
|
+
// shop for it is on the site rather than here — only someone with billing
|
|
1523
|
+
// permission buys for the team, and they do it on the team page. What this screen
|
|
1524
|
+
// can offer is the team's number, who can fix it, and the way back to
|
|
1525
|
+
// spending one's own credits.
|
|
1526
|
+
if (state.team) {
|
|
1527
|
+
const teamName = (knownTeams.find((team) => team.id === state.team.id) || {}).name
|
|
1528
|
+
|| t("account.teamUnnamed");
|
|
1529
|
+
if (state.team.refused) {
|
|
1530
|
+
body.appendChild(el("div", "cn-note", t("account.teamRefused", { team: teamName })));
|
|
1531
|
+
} else {
|
|
1532
|
+
const pooled = el("p", "pw-balance");
|
|
1533
|
+
pooled.appendChild(el("strong", null, creditsOf(state.team.balanceMicro || 0)));
|
|
1534
|
+
pooled.appendChild(el("span", "muted", t("paywall.teamUnit", { team: teamName })));
|
|
1535
|
+
body.appendChild(pooled);
|
|
1536
|
+
body.appendChild(
|
|
1537
|
+
el("div", "cn-note",
|
|
1538
|
+
t(state.team.billing ? "paywall.teamOwner" : "paywall.teamMember", { team: teamName })),
|
|
1539
|
+
);
|
|
1540
|
+
}
|
|
1541
|
+
const actions = el("div", "pw-plans");
|
|
1542
|
+
if (state.team.billing || state.team.refused) {
|
|
1543
|
+
const page = el("button", "pw-plan", t("paywall.teamPage"));
|
|
1544
|
+
page.type = "button";
|
|
1545
|
+
page.onclick = () => {
|
|
1546
|
+
window.open((site || "https://oharness.ai") + "/" + LOCALE + "/dashboard/team", "_blank", "noopener");
|
|
1547
|
+
};
|
|
1548
|
+
actions.appendChild(page);
|
|
1549
|
+
}
|
|
1550
|
+
const own = el("button", "pw-plan", t("account.useOwn"));
|
|
1551
|
+
own.type = "button";
|
|
1552
|
+
own.onclick = () => {
|
|
1553
|
+
void api("/team", { team: null }).then(() => {
|
|
1554
|
+
void loadAccount();
|
|
1555
|
+
void check();
|
|
1556
|
+
});
|
|
1557
|
+
};
|
|
1558
|
+
actions.appendChild(own);
|
|
1559
|
+
body.appendChild(actions);
|
|
1560
|
+
return;
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
const sub = state.subscription;
|
|
1564
|
+
const subscribed = Boolean(sub && (sub.status === "active" || sub.status === "trialing"));
|
|
1565
|
+
body.appendChild(
|
|
1566
|
+
el("div", "cn-note", subscribed ? t("paywall.subscribed", { plan: sub.plan }) : t("paywall.new")),
|
|
1567
|
+
);
|
|
1568
|
+
|
|
1569
|
+
const plans = state.plans || [];
|
|
1570
|
+
// payg is configured on the site, not bought here — the gateway refuses a
|
|
1571
|
+
// checkout for it — so it is not a row. The account page button below is
|
|
1572
|
+
// where it lives.
|
|
1573
|
+
const packs = plans.filter((plan) => plan.kind === "topup");
|
|
1574
|
+
// The seat plan is a team's and is bought on the team page; on this
|
|
1575
|
+
// list it would be a rung this account cannot buy for itself.
|
|
1576
|
+
const subs = plans.filter((plan) => plan.kind === "subscription" && !plan.seat);
|
|
1577
|
+
|
|
1578
|
+
if (!subs.length && !packs.length) {
|
|
1579
|
+
body.appendChild(el("div", "cn-note", t("paywall.notForSale")));
|
|
1580
|
+
return;
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
/**
|
|
1584
|
+
* What a yearly rung saves against paying monthly for the same tier.
|
|
1585
|
+
*
|
|
1586
|
+
* The only thing on these cards nobody can work out by looking. Monthly is
|
|
1587
|
+
* one price per credit across all four tiers — buying up buys volume, not
|
|
1588
|
+
* a discount — so a rate printed on those rows would be the same number
|
|
1589
|
+
* four times. The year is where the money actually differs, and seeing it
|
|
1590
|
+
* takes multiplying a price by twelve in your head.
|
|
1591
|
+
*
|
|
1592
|
+
* Computed rather than configured, so it cannot disagree with what the
|
|
1593
|
+
* checkout charges. Zero is returned as nothing: Starter's year is twelve
|
|
1594
|
+
* months at the same price, and a "Save 0%" badge on it would be a worse
|
|
1595
|
+
* lie than silence.
|
|
1596
|
+
*/
|
|
1597
|
+
const savingOn = (plan) => {
|
|
1598
|
+
if (plan.interval !== "year" || !plan.tier) return 0;
|
|
1599
|
+
const monthly = subs.find((p) => p.tier === plan.tier && p.interval === "month");
|
|
1600
|
+
if (!monthly || typeof monthly.amountCents !== "number" || typeof plan.amountCents !== "number") return 0;
|
|
1601
|
+
const full = monthly.amountCents * 12;
|
|
1602
|
+
if (full <= 0 || plan.amountCents >= full) return 0;
|
|
1603
|
+
return Math.round((1 - plan.amountCents / full) * 100);
|
|
1604
|
+
};
|
|
1605
|
+
|
|
1606
|
+
/** One row: what it is, how many credits, and how much money. */
|
|
1607
|
+
const planRow = (plan, suffix) => {
|
|
1608
|
+
const row = el("button", "pw-plan" + (plan.popular ? " featured" : ""));
|
|
1609
|
+
row.type = "button";
|
|
1610
|
+
// Two lines rather than one, because two of these sit side by side on a
|
|
1611
|
+
// wide screen and the three-column row they used to be does not survive
|
|
1612
|
+
// being halved. The line that decides is on top.
|
|
1613
|
+
const top = el("span", "pw-plan-top");
|
|
1614
|
+
const name = el("span", "pw-plan-name");
|
|
1615
|
+
name.appendChild(document.createTextNode(plan.label || plan.id));
|
|
1616
|
+
// The catalogue marks one rung. Saying so is the whole of the advice
|
|
1617
|
+
// this dialog can give about which to pick.
|
|
1618
|
+
if (plan.popular) name.appendChild(el("span", "pw-tag", t("paywall.popular")));
|
|
1619
|
+
top.appendChild(name);
|
|
1620
|
+
const money = moneyOf(plan.amountCents, plan.currency);
|
|
1621
|
+
if (money) top.appendChild(el("span", "pw-plan-price", money + (suffix || "")));
|
|
1622
|
+
row.appendChild(top);
|
|
1623
|
+
// The pack's own label is already "2,000 credits", so repeating the
|
|
1624
|
+
// number underneath it said the same thing twice. Subscriptions need
|
|
1625
|
+
// the line — their names are tiers, not amounts.
|
|
1626
|
+
// The same sentence the pricing page prints, for the same reason it
|
|
1627
|
+
// prints it: "12,000 credits" does not say whether that arrives once or
|
|
1628
|
+
// twelve times, and the answer is the second one. A subscription is an
|
|
1629
|
+
// allowance, and a card that states a total without its period is
|
|
1630
|
+
// describing a top-up.
|
|
1631
|
+
const count = creditsOf(plan.creditsMicro);
|
|
1632
|
+
const amount =
|
|
1633
|
+
plan.kind === "subscription"
|
|
1634
|
+
? t(plan.interval === "year" ? "paywall.everyYear" : "paywall.everyMonth", { credits: count })
|
|
1635
|
+
: count + " " + t("paywall.unit");
|
|
1636
|
+
const saved = savingOn(plan);
|
|
1637
|
+
if ((plan.label || "") !== amount || saved) {
|
|
1638
|
+
const under = el("span", "pw-plan-under");
|
|
1639
|
+
if ((plan.label || "") !== amount) under.appendChild(el("span", null, amount));
|
|
1640
|
+
// Under the price it is about, not beside the name. The badge by the
|
|
1641
|
+
// name says which rung to pick; this one says what the year costs.
|
|
1642
|
+
if (saved) under.appendChild(el("span", "pw-save", t("paywall.save", { percent: saved })));
|
|
1643
|
+
row.appendChild(under);
|
|
1644
|
+
}
|
|
1645
|
+
row.onclick = () => buy(plan, row);
|
|
1646
|
+
return row;
|
|
1647
|
+
};
|
|
1648
|
+
|
|
1649
|
+
// Four tiers sold two ways came out as eight rows with four repeated
|
|
1650
|
+
// names and nothing saying which was which. They are the same four
|
|
1651
|
+
// offers; the interval is a choice about them, so it is a control rather
|
|
1652
|
+
// than eight buttons.
|
|
1653
|
+
const byInterval = { month: subs.filter((p) => p.interval === "month"),
|
|
1654
|
+
year: subs.filter((p) => p.interval === "year") };
|
|
1655
|
+
const intervals = ["month", "year"].filter((k) => byInterval[k].length > 0);
|
|
1656
|
+
const other = subs.filter((p) => p.interval !== "month" && p.interval !== "year");
|
|
1657
|
+
|
|
1658
|
+
if (subs.length) {
|
|
1659
|
+
if (intervals.length > 1) {
|
|
1660
|
+
const toggle = el("div", "pw-toggle");
|
|
1661
|
+
intervals.forEach((key) => {
|
|
1662
|
+
const tab = el("button", "pw-tab" + (key === pwInterval ? " on" : ""));
|
|
1663
|
+
tab.type = "button";
|
|
1664
|
+
tab.textContent = t(key === "year" ? "paywall.yearly" : "paywall.monthly");
|
|
1665
|
+
tab.onclick = () => {
|
|
1666
|
+
pwInterval = key;
|
|
1667
|
+
render(state);
|
|
1668
|
+
};
|
|
1669
|
+
toggle.appendChild(tab);
|
|
1670
|
+
});
|
|
1671
|
+
body.appendChild(toggle);
|
|
1672
|
+
}
|
|
1673
|
+
const showing = intervals.length > 1 ? byInterval[pwInterval] || [] : subs;
|
|
1674
|
+
const list = el("div", "pw-plans");
|
|
1675
|
+
showing.concat(other).forEach((plan) => {
|
|
1676
|
+
list.appendChild(planRow(plan, t(plan.interval === "year" ? "paywall.perYear" : "paywall.perMonth")));
|
|
1677
|
+
});
|
|
1678
|
+
body.appendChild(list);
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
// The one-off, at the bottom, for whoever already has a plan. A top-up
|
|
1682
|
+
// covers a month that ran short rather than standing in for a subscription,
|
|
1683
|
+
// and the gateway refuses one from an account with none — so without a
|
|
1684
|
+
// plan this is the sentence saying so rather than rows that end in a
|
|
1685
|
+
// refusal after the click. The rows come back the moment there is a
|
|
1686
|
+
// subscription to top up.
|
|
1687
|
+
if (packs.length && !subscribed) {
|
|
1688
|
+
body.appendChild(el("div", "cn-note", t("paywall.needsPlan")));
|
|
1689
|
+
} else if (packs.length) {
|
|
1690
|
+
body.appendChild(el("div", "pw-sep", t("paywall.orOnce")));
|
|
1691
|
+
const once = el("div", "pw-plans");
|
|
1692
|
+
packs.forEach((plan) => {
|
|
1693
|
+
// A pack is one rung of a ladder the catalogue already defines, and
|
|
1694
|
+
// the paywall was showing the bottom one as though it were the whole
|
|
1695
|
+
// offer. The presets are the amounts the catalogue means people to
|
|
1696
|
+
// pick from; the rate beside them turns each into its credits, so the
|
|
1697
|
+
// rows cannot disagree with what the checkout grants.
|
|
1698
|
+
const ladder = plan.custom;
|
|
1699
|
+
const presets = ladder && ladder.presetCents ? ladder.presetCents : [];
|
|
1700
|
+
if (!presets.length) return void once.appendChild(planRow(plan, ""));
|
|
1701
|
+
presets.forEach((cents) => {
|
|
1702
|
+
const micro = (ladder.creditsMicroPerCent || 0) * cents;
|
|
1703
|
+
once.appendChild(
|
|
1704
|
+
planRow(
|
|
1705
|
+
{
|
|
1706
|
+
...plan,
|
|
1707
|
+
// Not the pack's own label: at $50 that would still read
|
|
1708
|
+
// "2,000 credits", which is the wrong number on the button.
|
|
1709
|
+
label: creditsOf(micro) + " " + t("paywall.unit"),
|
|
1710
|
+
creditsMicro: micro,
|
|
1711
|
+
amountCents: cents,
|
|
1712
|
+
// What the checkout is told. Absent on a rung that is the
|
|
1713
|
+
// pack's own price, so that purchase stays the plain one.
|
|
1714
|
+
...(cents === plan.amountCents ? {} : { buyCents: cents }),
|
|
1715
|
+
},
|
|
1716
|
+
"",
|
|
1717
|
+
),
|
|
1718
|
+
);
|
|
1719
|
+
});
|
|
1720
|
+
});
|
|
1721
|
+
body.appendChild(once);
|
|
1722
|
+
}
|
|
1723
|
+
if (awaiting) body.appendChild(el("div", "cn-note", t("paywall.opened")));
|
|
1724
|
+
};
|
|
1725
|
+
|
|
1726
|
+
const check = () =>
|
|
1727
|
+
getJson("/billing").then((state) => {
|
|
1728
|
+
if (!dialog.open) return;
|
|
1729
|
+
if (!state || state.error) {
|
|
1730
|
+
return void render({ signedIn: true, unavailable: "unreachable" });
|
|
1731
|
+
}
|
|
1732
|
+
// Credits on the account mean the turn can be sent again, whether they
|
|
1733
|
+
// arrived a second ago or were there the whole time. Both are the same
|
|
1734
|
+
// sentence to whoever is looking at this. With a team chosen it is the
|
|
1735
|
+
// team's number that decides: the person's own credits are not what
|
|
1736
|
+
// the next call draws on.
|
|
1737
|
+
const pooled = state.team && !state.team.refused ? state.team : undefined;
|
|
1738
|
+
const spendable = pooled ? Number(pooled.balanceMicro || 0) : Number(state.balanceMicro || 0);
|
|
1739
|
+
if (state.signedIn && spendable > 0) {
|
|
1740
|
+
closePaywall();
|
|
1741
|
+
return void addNotice(t("paywall.ready", { credits: creditsOf(spendable) }));
|
|
1742
|
+
}
|
|
1743
|
+
render(state);
|
|
1744
|
+
});
|
|
1745
|
+
|
|
1746
|
+
body.replaceChildren(el("div", "cn-note", t("paywall.checking")));
|
|
1747
|
+
if (!dialog.open) dialog.showModal();
|
|
1748
|
+
void check();
|
|
1749
|
+
if (paywallTimer) clearInterval(paywallTimer);
|
|
1750
|
+
paywallTimer = setInterval(() => void check(), PAYWALL_POLL_MS);
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
/**
|
|
1754
|
+
* Bring one provider's card into view, opened and ready to be filled in.
|
|
1755
|
+
*
|
|
1756
|
+
* Every route into the panel that is about a particular vendor ends here:
|
|
1757
|
+
* scrolled to, expanded, and — when the vendor still needs a credential —
|
|
1758
|
+
* with the cursor already in the key field, so the click that asked to set it
|
|
1759
|
+
* up does not land on a list the user has to search again.
|
|
1760
|
+
*/
|
|
1761
|
+
function revealProvider(providerId) {
|
|
1762
|
+
const card = document.querySelector('[data-provider="' + providerId + '"]');
|
|
1763
|
+
if (!card) return;
|
|
1764
|
+
card.open = true;
|
|
1765
|
+
card.scrollIntoView({ block: "center" });
|
|
1766
|
+
const field = card.querySelector(".keyrow input");
|
|
1767
|
+
if (field) field.focus();
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
|
|
1771
|
+
/**
|
|
1772
|
+
* The long tail of providers, behind a search.
|
|
1773
|
+
*
|
|
1774
|
+
* Two dozen vendors listed at once is a wall that buries the four anyone
|
|
1775
|
+
* actually uses, and a flat list is only navigable by scrolling. Picking one
|
|
1776
|
+
* here promotes it into the panel above, where signing in happens.
|
|
1777
|
+
*/
|
|
1778
|
+
function openProviderPicker(rest, byProvider) {
|
|
1779
|
+
const dialog = document.getElementById("provider-picker");
|
|
1780
|
+
const field = document.getElementById("pp-search");
|
|
1781
|
+
const listEl = document.getElementById("pp-list");
|
|
1782
|
+
|
|
1783
|
+
const paint = () => {
|
|
1784
|
+
const query = field.value.trim().toLowerCase();
|
|
1785
|
+
const words = query.split(/\s+/).filter(Boolean);
|
|
1786
|
+
listEl.replaceChildren();
|
|
1787
|
+
|
|
1788
|
+
const matchesProvider = (provider) => {
|
|
1789
|
+
const hay = (provider.label + " " + provider.id + " " + (provider.description || "")).toLowerCase();
|
|
1790
|
+
return words.every((word) => hay.includes(word));
|
|
1791
|
+
};
|
|
1792
|
+
|
|
1793
|
+
let shown = 0;
|
|
1794
|
+
for (const provider of rest) {
|
|
1795
|
+
const owned = byProvider.get(provider.id) || [];
|
|
1796
|
+
// A query may match the vendor or one of its models; showing the vendor
|
|
1797
|
+
// with only its matching models is more useful than either alone.
|
|
1798
|
+
const hits = words.length
|
|
1799
|
+
? owned.filter((model) => words.every((word) => model.id.toLowerCase().includes(word)))
|
|
1800
|
+
: owned;
|
|
1801
|
+
if (words.length && !matchesProvider(provider) && !hits.length) continue;
|
|
1802
|
+
shown++;
|
|
1803
|
+
|
|
1804
|
+
const row = el("details", "pp-provider");
|
|
1805
|
+
// Searching opens what it found; browsing starts folded.
|
|
1806
|
+
row.open = Boolean(words.length && hits.length);
|
|
1807
|
+
|
|
1808
|
+
const head = el("summary", "pp-item");
|
|
1809
|
+
head.appendChild(el("span", "chev"));
|
|
1810
|
+
head.appendChild(providerMark(provider.id, provider.label || provider.id));
|
|
1811
|
+
const naming = el("div", "naming");
|
|
1812
|
+
naming.appendChild(el("div", "pp-name", provider.label || provider.id));
|
|
1813
|
+
if (provider.description) naming.appendChild(el("div", "row-desc", provider.description));
|
|
1814
|
+
head.appendChild(naming);
|
|
1815
|
+
if (provider.configured) head.appendChild(el("span", "tag", "connected"));
|
|
1816
|
+
head.appendChild(el("span", "muted", owned.length ? owned.length + " models" : "no models"));
|
|
1817
|
+
row.appendChild(head);
|
|
1818
|
+
|
|
1819
|
+
const body = el("div", "pp-body");
|
|
1820
|
+
if (hits.length) {
|
|
1821
|
+
const grid = el("div", "chips");
|
|
1822
|
+
for (const model of (words.length ? hits : owned)) {
|
|
1823
|
+
const chip = el("button", "chip" + (model.current ? " on" : ""));
|
|
1824
|
+
chip.type = "button";
|
|
1825
|
+
chip.appendChild(el("span", null, model.id));
|
|
1826
|
+
if (model.contextWindow) {
|
|
1827
|
+
chip.appendChild(el("span", "sub", Math.round(model.contextWindow / 1000) + "k"));
|
|
1828
|
+
}
|
|
1829
|
+
if (model.free) chip.appendChild(el("span", "free", t("models.free")));
|
|
1830
|
+
chip.addEventListener("click", (e) => {
|
|
1831
|
+
e.preventDefault();
|
|
1832
|
+
e.stopPropagation();
|
|
1833
|
+
dialog.close();
|
|
1834
|
+
track("model_switched", { model: model.id, via: "provider" });
|
|
1835
|
+
void api("/model", { session: activeId, id: model.id }).then(async (state) => {
|
|
1836
|
+
if (state && state.error) return addNotice(state.error, "err");
|
|
1837
|
+
applyState(state);
|
|
1838
|
+
await loadModels();
|
|
1839
|
+
});
|
|
1840
|
+
});
|
|
1841
|
+
grid.appendChild(chip);
|
|
1842
|
+
}
|
|
1843
|
+
body.appendChild(grid);
|
|
1844
|
+
} else {
|
|
1845
|
+
body.appendChild(
|
|
1846
|
+
el("div", "muted small",
|
|
1847
|
+
provider.configured
|
|
1848
|
+
? "No models discovered yet — open it in Models to fetch them."
|
|
1849
|
+
: "Not connected. Open it in Models to add a key."),
|
|
1850
|
+
);
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
const open = el("button", "ghost", t("models.openIn"));
|
|
1854
|
+
open.type = "button";
|
|
1855
|
+
open.addEventListener("click", (e) => {
|
|
1856
|
+
e.preventDefault();
|
|
1857
|
+
e.stopPropagation();
|
|
1858
|
+
dialog.close();
|
|
1859
|
+
// Pinned for this page load, so the card it opens stays reachable.
|
|
1860
|
+
pinnedProviders.add(provider.id);
|
|
1861
|
+
focusProvider = provider.id;
|
|
1862
|
+
void showModels();
|
|
1863
|
+
});
|
|
1864
|
+
body.appendChild(open);
|
|
1865
|
+
|
|
1866
|
+
row.appendChild(body);
|
|
1867
|
+
listEl.appendChild(row);
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
if (!shown) listEl.appendChild(el("div", "menu-empty", t("menu.none")));
|
|
1871
|
+
};
|
|
1872
|
+
|
|
1873
|
+
field.value = "";
|
|
1874
|
+
field.oninput = paint;
|
|
1875
|
+
paint();
|
|
1876
|
+
dialog.showModal();
|
|
1877
|
+
field.focus();
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
/** Providers promoted out of the tail by the picker, for this page load. */
|
|
1881
|
+
const pinnedProviders = new Set();
|
|
1882
|
+
|
|
1883
|
+
async function refreshModelCount() {
|
|
1884
|
+
const count = document.getElementById("model-count");
|
|
1885
|
+
count.textContent = modelOptions.length ? String(modelOptions.length) : "";
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
document.getElementById("nav-models").addEventListener("click", () => {
|
|
1889
|
+
setNav("nav-models");
|
|
1890
|
+
focusProvider = null;
|
|
1891
|
+
panelBody.replaceChildren(panelLoading());
|
|
1892
|
+
void showModels();
|
|
1893
|
+
});
|
|
1894
|
+
`;
|
|
1895
|
+
//# sourceMappingURL=account.js.map
|