@oharness/server 0.7.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/dist/browser-pane.d.ts +48 -0
  2. package/dist/browser-pane.js +185 -0
  3. package/dist/browser-pane.js.map +1 -0
  4. package/dist/files.d.ts +40 -1
  5. package/dist/files.js +94 -3
  6. package/dist/files.js.map +1 -1
  7. package/dist/i18n.js +190 -0
  8. package/dist/i18n.js.map +1 -1
  9. package/dist/main.js +20 -0
  10. package/dist/main.js.map +1 -1
  11. package/dist/page/client/account.d.ts +8 -0
  12. package/dist/page/client/account.js +1895 -0
  13. package/dist/page/client/account.js.map +1 -0
  14. package/dist/page/client/artifacts.d.ts +7 -0
  15. package/dist/page/client/artifacts.js +767 -0
  16. package/dist/page/client/artifacts.js.map +1 -0
  17. package/dist/page/client/base.d.ts +8 -0
  18. package/dist/page/client/base.js +404 -0
  19. package/dist/page/client/base.js.map +1 -0
  20. package/dist/page/client/browser.d.ts +21 -0
  21. package/dist/page/client/browser.js +906 -0
  22. package/dist/page/client/browser.js.map +1 -0
  23. package/dist/page/client/composer.d.ts +8 -0
  24. package/dist/page/client/composer.js +1714 -0
  25. package/dist/page/client/composer.js.map +1 -0
  26. package/dist/page/client/dialogs.d.ts +7 -0
  27. package/dist/page/client/dialogs.js +724 -0
  28. package/dist/page/client/dialogs.js.map +1 -0
  29. package/dist/page/client/events.d.ts +7 -0
  30. package/dist/page/client/events.js +288 -0
  31. package/dist/page/client/events.js.map +1 -0
  32. package/dist/page/client/files.d.ts +7 -0
  33. package/dist/page/client/files.js +412 -0
  34. package/dist/page/client/files.js.map +1 -0
  35. package/dist/page/client/index.d.ts +16 -0
  36. package/dist/page/client/index.js +50 -0
  37. package/dist/page/client/index.js.map +1 -0
  38. package/dist/page/client/panels.d.ts +7 -0
  39. package/dist/page/client/panels.js +659 -0
  40. package/dist/page/client/panels.js.map +1 -0
  41. package/dist/page/client/pickers.d.ts +7 -0
  42. package/dist/page/client/pickers.js +624 -0
  43. package/dist/page/client/pickers.js.map +1 -0
  44. package/dist/page/client/routines.d.ts +6 -0
  45. package/dist/page/client/routines.js +928 -0
  46. package/dist/page/client/routines.js.map +1 -0
  47. package/dist/page/client/shell.d.ts +6 -0
  48. package/dist/page/client/shell.js +700 -0
  49. package/dist/page/client/shell.js.map +1 -0
  50. package/dist/page/client/state.d.ts +7 -0
  51. package/dist/page/client/state.js +1283 -0
  52. package/dist/page/client/state.js.map +1 -0
  53. package/dist/page/client/terminal.d.ts +7 -0
  54. package/dist/page/client/terminal.js +347 -0
  55. package/dist/page/client/terminal.js.map +1 -0
  56. package/dist/page/client/transcript.d.ts +8 -0
  57. package/dist/page/client/transcript.js +866 -0
  58. package/dist/page/client/transcript.js.map +1 -0
  59. package/dist/page/client/usage.d.ts +7 -0
  60. package/dist/page/client/usage.js +954 -0
  61. package/dist/page/client/usage.js.map +1 -0
  62. package/dist/page/style.d.ts +1 -0
  63. package/dist/page/style.js +3016 -0
  64. package/dist/page/style.js.map +1 -0
  65. package/dist/page.js +96 -13871
  66. package/dist/page.js.map +1 -1
  67. package/dist/server.d.ts +24 -0
  68. package/dist/server.js +492 -23
  69. package/dist/server.js.map +1 -1
  70. package/dist/terminal.d.ts +34 -0
  71. package/dist/terminal.js +211 -0
  72. package/dist/terminal.js.map +1 -0
  73. package/package.json +9 -8
@@ -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