@byokit/ui 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/README.md +66 -13
- package/dist/accounts.d.ts +148 -0
- package/dist/accounts.js +175 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/useAccounts.d.ts +48 -0
- package/dist/useAccounts.js +156 -0
- package/dist/words.json +19 -1
- package/package.json +9 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.7.0 (2026-10-12)
|
|
6
|
+
|
|
7
|
+
- Dependency update: pins @byokit/accounts 0.22.0.
|
|
8
|
+
|
|
9
|
+
- FEAT: Add `useAccounts` and `useModelPicker` to `@byokit/ui` (with the `accountsSource`/`cliSource` one-line-per-member adapters): live rows with `refresh()`, a failing room leaving only its row `Room left unknown`, a `remove()` landing mid-refresh still dropping the row, and a picker whose `select()` marks the selection at once and keeps it across refreshes. The openclaw adapter stays pending O14.
|
|
10
|
+
- FEAT: Add `accountRows`, `rowsOf`, `nameSuggestions` and the `AccountsSource`, `AccountRow`, `Account` and `Room` types (also `@byokit/ui/accounts`, no React): one plain-words row per account (`ChatGPT · Work` / `Plus · 72% left this week`), honest words for every sign-in state, `low` at or below 20%, a `sign_in`/`wait` action, and untaken account-name suggestions from an email's local part that never put an email in a title.
|
|
11
|
+
- FEAT: `herdrTreeView` groups carry `project`, the workspace folder's own name without the tab label, so rows can name an agent (computer, project, agent) without internal tab labels.
|
|
12
|
+
- FEAT: Browser sign-in sheet and live panel views: `signInSheetView(request, { name, holder })` gives the sentence, buttons and chat chip for each request state; `signInsStore(oc)` keeps requests live from sign-in pings; `livePanelView(state)` says when to draw frames, take input, show "Reconnecting…" or ask to confirm an unexpected address. Typed structurally against `@byokit/openclaw` spec 5.17.
|
|
13
|
+
- FEAT: Add `connectView(routes, step)`, `connectStep`, `signInFor` (also `@byokit/ui/connect`, no React) and `useConnect`: every account route the kits list, grouped as Plans (offered by default), Pay per use, On this computer, Your own server, Cloud account, Other ways to connect, and Speech, search and media, each row with plain billing, method and readiness words. Billing decides the group, so no API, cloud, local, server or unknown-billing row is ever in Plans; key routes reuse `keyView`, sign-in routes hand `useSignIn` their row.
|
|
14
|
+
- FEAT: Add `modelPicker` and `bestMoveTarget` to `@byokit/ui/accounts` (no React, no Node): the run picker with the `Auto — most room left` row carrying the pick's own reason, one plain row per account with its model rows and the selection marked, unavailable rows with a typed why and `until`, Auto disabled with a typed reason when no account can be named, and the Auto-ordered account a running session should move to (`Move to Work` / `Couldn't move to Work`).
|
|
15
|
+
- FEAT: plain pairing words for the phone page: `connectedWords(hostName, url?, kind?)` renders the connected line with the route just dialled (`Connected to Kitchen computer - same Wi-Fi.`), `routeShort(route)` gives the inline label, and `pairErrorWords(error)` reads a failed pairing as expired, wrong-code or the link's own sentence from @byokit/link's error code.
|
|
16
|
+
- FEAT: `describeRoute` labels in plain words: `Same Wi-Fi`, `Temporary public link`, `Away from home` (this kit's relay address) and `Your own server`. No route, choice or status sentence carries an address, a protocol name or relay jargon.
|
|
17
|
+
- The PWA example's account card now runs on the shared views: `accountRows`/`modelPicker` from `@byokit/ui/accounts` draw the rows and the run picker (Auto carries its own reason, a signed-out pick shows `Sign in first` and runs nothing), and `bestMoveTarget`/`moveWords` suggest the roomier account when the chosen one is low.
|
|
18
|
+
- `@byokit/ui-core` is now published as `@byokit/ui`, with the same API and the same entries (`.`, `./phase`, `./route`, `./link`, `./kits`, `./steps`, `./connect`); its earlier versions are the `@byokit/ui-core` releases below. **Deprecated:** the `@byokit/ui-core` name; it stays published as a shim that re-exports this package through 0.7.x and is removed in 0.8.0.
|
|
19
|
+
- Accept OpenClaw's admitted/thinking progress frames structurally without changing the existing run reducer's rendered state.
|
|
20
|
+
|
|
21
|
+
- Dependency update: pins @byokit/accounts 0.21.0.
|
|
22
|
+
|
|
5
23
|
- Run views accept structured-output failures and display the kit’s plain validation message.
|
|
6
24
|
|
|
7
25
|
## 0.6.0 (2026-10-01)
|
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ pairing sheet, the link's status, the route a phone takes home, and a runtime ki
|
|
|
12
12
|
app keeps its own look. Everything except the React hooks is framework-free.</p>
|
|
13
13
|
|
|
14
14
|
<p align="center">
|
|
15
|
-
<img src="https://raw.githubusercontent.com/umeranjum17/byokit/main/docs/images/pwa-2-code.png" width="240" alt="A phone-width page titled '
|
|
15
|
+
<img src="https://raw.githubusercontent.com/umeranjum17/byokit/main/docs/images/pwa-2-code.png" width="240" alt="A phone-width page titled Umer's AI plans, with the ChatGPT card reading 'Signing in to ChatGPT…' and 'On the ChatGPT page, type this code:', the code MOCK-10001 in large letters, 'Code expires in 14:59', and Open ChatGPT and Cancel buttons." />
|
|
16
16
|
<img src="https://raw.githubusercontent.com/umeranjum17/byokit/main/examples/herdr-kit/docs/2-compare.png" width="240" alt="A phone-width page titled 'Agents' with a 'Pair this phone' card reading 'Check your computer shows these two words, then say yes there.' above the words 'coast comet'." />
|
|
17
17
|
</p>
|
|
18
18
|
|
|
@@ -35,7 +35,7 @@ npm install @byokit/ui
|
|
|
35
35
|
```
|
|
36
36
|
|
|
37
37
|
React is an optional peer dependency, needed only for the hooks (`useSignIn`, `useRun`, `useApprovals`,
|
|
38
|
-
`useHerdrTree`, `useBlocked`).
|
|
38
|
+
`useHerdrTree`, `useBlocked`, `useConnect`, `useAccounts`, `useModelPicker`).
|
|
39
39
|
|
|
40
40
|
```ts
|
|
41
41
|
import { phaseOf, stepOf } from '@byokit/ui/phase';
|
|
@@ -123,11 +123,13 @@ export function useChatGptSheet() {
|
|
|
123
123
|
| `useRun`, `useApprovals`, `useHerdrTree`, `useBlocked` | React hooks over those stores |
|
|
124
124
|
| `connectView(routes, step?)`, `connectStep(step, action)` | Every account route the kits list, grouped by how the person pays, with the picked row and its key card |
|
|
125
125
|
| `signInFor(view, { read, start?, cancel })`, `useConnect({ routes, read, start?, cancel })` | `useSignIn`'s options for the picked sign-in row; the hook holds the step |
|
|
126
|
-
|
|
|
126
|
+
| `accountRows(accounts, room, now)`, `rowsOf(source, now)`, `nameSuggestions(email, taken, provider?)` | One plain-words row per account (`ChatGPT · Work` / `Plus · 72% left this week`), the honest words for every sign-in state, and untaken account-name suggestions |
|
|
127
|
+
| `useAccounts(source)`, `useModelPicker(source, initial?)` | React hooks: the rows live with `refresh()` and `remove()` (a failing room leaves only its row unknown; a mid-refresh remove still drops it), and the run picker with `select()` keeping the caller's selection across refreshes; `accountsSource(accounts, member, room?)` and `cliSource(cli, room?)` fill the source with one line per member (the openclaw adapter stays pending O14) |
|
|
128
|
+
| Types | `Phase`, `SignInView`, `AccountView`, `UseSignIn`, `Route`, `RouteChoice`, `RouteCode`, `PairPhase`, `Role`, `DeviceKind`, `LinkStatus`; `RouteKind` from `@byokit/ui/route`; `Account`, `AccountRow`, `AccountAction`, `AccountsSource`, `Billing`, `Room`, `RoomSpan`, `SignInState` from `@byokit/ui/accounts` |
|
|
127
129
|
|
|
128
130
|
Entry points: `@byokit/ui` (everything, including the React hooks), `@byokit/ui/phase`,
|
|
129
|
-
`@byokit/ui/route`, `@byokit/ui/link`, `@byokit/ui/kits`, `@byokit/ui/steps
|
|
130
|
-
`@byokit/ui/connect` (no React dependency).
|
|
131
|
+
`@byokit/ui/route`, `@byokit/ui/link`, `@byokit/ui/kits`, `@byokit/ui/steps`,
|
|
132
|
+
`@byokit/ui/connect` and `@byokit/ui/accounts` (no React dependency).
|
|
131
133
|
|
|
132
134
|
## Sign-in phases
|
|
133
135
|
|
|
@@ -152,14 +154,14 @@ into the phase to draw:
|
|
|
152
154
|
## Routes
|
|
153
155
|
|
|
154
156
|
`describeRoute(url, kind?)` (also `@byokit/ui/route`, no React) names the route a dial address takes, for a
|
|
155
|
-
pairing or settings screen. Pass `tailscale`, `direct`, `private`, or `lan` when known (map
|
|
156
|
-
to `direct`); without provenance, 100.64/10 reads as a private
|
|
157
|
-
other home ranges read as same Wi-Fi.
|
|
157
|
+
pairing or settings screen. Pass `tailscale`, `direct`, `private`, or `lan` when known (map
|
|
158
|
+
[`@byokit/discover`](../discover)'s `tailscale-direct` to `direct`); without provenance, 100.64/10 reads as a private
|
|
159
|
+
network rather than assumed to be Tailscale, and the other home ranges read as same Wi-Fi.
|
|
158
160
|
|
|
159
161
|
`routeChoices()` (same entry, no React) is the one word table for the onboarding route list: `{ code, title,
|
|
160
|
-
sentence, needs }` per route in recommendation order, a superset of
|
|
161
|
-
public link and your own server, which reach does not probe).
|
|
162
|
-
`needs` from here and only adds availability.
|
|
162
|
+
sentence, needs }` per route in recommendation order, a superset of `@byokit/discover`'s `Via` (it also covers the
|
|
163
|
+
temporary public link and your own server, which `reach()` there does not probe). `recommend()` from the same package
|
|
164
|
+
takes `sentence` and `needs` from here and only adds availability.
|
|
163
165
|
|
|
164
166
|
`connectedWords(hostName, url?, kind?)` (same entry) is the connected line a consumer renders: the computer with
|
|
165
167
|
the route just dialled (`Connected to Kitchen computer - same Wi-Fi.`), or plain (`Connected to Kitchen computer.`)
|
|
@@ -210,9 +212,60 @@ A key route's `chosen.key` is the existing `keyView` card, labelled for its bill
|
|
|
210
212
|
`useConnect({ routes, read, start?, cancel })` holds the step in React and returns the view with `pick`, `back`,
|
|
211
213
|
`act` and `signIn`, the options to pass to `useSignIn` in a sheet keyed by `chosen.row.key`.
|
|
212
214
|
|
|
215
|
+
## Account rows
|
|
216
|
+
|
|
217
|
+
`accountRows(accounts, room, now)` (also `@byokit/ui/accounts`, no React) turns a list of accounts and a room reading
|
|
218
|
+
into one plain row each: the provider and the person's own name for the account (`ChatGPT · Work`), then the plan and
|
|
219
|
+
the room left (`Plus · 72% left this week`). A row also carries `state`, `billing`, `low` (room left at or below 20%,
|
|
220
|
+
exactly 20 included, never true for an unknown room) and, when it helps, an `action`: `sign_in` for a signed-out or
|
|
221
|
+
lapsed account, `wait` plus the `until` refill time for a resting one. `accountRows` reads only the fields it names,
|
|
222
|
+
so a credential on an account never reaches a row. A row that is not ready shows its own honest words ("Checking…",
|
|
223
|
+
"Cooling down", "Not signed in", "Sign in again", "Not in your plan") instead of the room.
|
|
224
|
+
|
|
225
|
+
`AccountsSource` is the shape an app fills from its own back end — `accounts()` and `room(account)` — and `rowsOf(source, now)`
|
|
226
|
+
awaits both and builds the same rows; `@byokit/accounts` `list()` and a room reading fit it with one line per member.
|
|
227
|
+
`nameSuggestions(email, taken, provider?)` offers up to three untaken account names from an email's local part
|
|
228
|
+
(deduped case-insensitively against `taken`), falling back to the provider's name; the email itself is never offered.
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
import { accountRows, nameSuggestions, type Account, type Room } from '@byokit/ui/accounts';
|
|
232
|
+
|
|
233
|
+
declare const list: () => readonly Account[];
|
|
234
|
+
declare const reading: (account: Account) => Room;
|
|
235
|
+
const rows = accountRows(list(), reading, Date.now());
|
|
236
|
+
// rows → [{ title: 'ChatGPT · Work', detail: 'Plus · 72% left this week', state: 'ready', low: false, ... }]
|
|
237
|
+
console.log(nameSuggestions('umer@example.com', rows.map((r) => r.title)));
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
In React, `useAccounts(source)` keeps those rows live (`refresh()` re-reads; `remove(id)` removes through
|
|
241
|
+
the back end and drops the row even mid-refresh), and `useModelPicker(source)` keeps the run picker live
|
|
242
|
+
(`select()` marks the selection at once and keeps it across refreshes). `accountsSource(accounts, member,
|
|
243
|
+
room?)` fills the source from a live `@byokit/accounts` member and `cliSource(cli, room?)` from a live
|
|
244
|
+
`cliAccounts()` kit, each with one line per member; the openclaw adapter stays pending O14:
|
|
245
|
+
|
|
246
|
+
```ts
|
|
247
|
+
import { accountsSource, cliSource, useAccounts, useModelPicker } from '@byokit/ui';
|
|
248
|
+
import type { Accounts } from '@byokit/accounts';
|
|
249
|
+
import { cliAccounts } from '@byokit/accounts/cli';
|
|
250
|
+
import type { Account, Room } from '@byokit/ui/accounts';
|
|
251
|
+
|
|
252
|
+
declare const accounts: Accounts<any, number>;
|
|
253
|
+
declare const cli: ReturnType<typeof cliAccounts>;
|
|
254
|
+
declare const reading: (account: Account) => Room;
|
|
255
|
+
const fromMember = accountsSource(accounts, 1, reading);
|
|
256
|
+
const fromCli = cliSource(cli, reading);
|
|
257
|
+
|
|
258
|
+
export function useAccountList() {
|
|
259
|
+
return useAccounts(fromCli); // { rows, ready, error, refresh, remove }
|
|
260
|
+
}
|
|
261
|
+
export function useRunPicker() {
|
|
262
|
+
return useModelPicker(fromMember); // { rows, picker, selection, ready, error, select, refresh, remove }
|
|
263
|
+
}
|
|
264
|
+
```
|
|
265
|
+
|
|
213
266
|
## Pairing words
|
|
214
267
|
|
|
215
|
-
Pairing with `@byokit/link
|
|
268
|
+
Pairing with `@byokit/pair` (published as `@byokit/link` up to 0.8.x), from `@byokit/ui/link` (no React either):
|
|
216
269
|
|
|
217
270
|
- `qrMatrix(offer.text, { border }?)`: the pairing QR as rows of dark and light modules, with its quiet border
|
|
218
271
|
(2 unless given), to draw in any UI.
|
|
@@ -293,7 +346,7 @@ with the device client made once (`useMemo(() => herdrDevice(link), [link])`).
|
|
|
293
346
|
## Links
|
|
294
347
|
|
|
295
348
|
- [byokit](../../README.md): the monorepo and its other packages, including
|
|
296
|
-
[`@byokit/accounts`](../accounts) and [`@byokit/
|
|
349
|
+
[`@byokit/accounts`](../accounts) and [`@byokit/pair`](../pair).
|
|
297
350
|
- Examples: [`examples/pwa`](../../examples/pwa) (sign-in phases in a browser),
|
|
298
351
|
[`examples/herdr-kit`](../../examples/herdr-kit) (pairing words and `herdrStore` from a phone browser),
|
|
299
352
|
[`examples/expo`](../../examples/expo) (React Native).
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
export type Billing = 'subscription' | 'api' | 'local' | 'free' | 'unknown';
|
|
2
|
+
export type SignInState = 'ready' | 'signing' | 'resting' | 'signed_out' | 'needs_again' | 'not_included';
|
|
3
|
+
export type RoomSpan = 'session' | 'week' | 'month' | 'tightest';
|
|
4
|
+
/** A room-left reading, restated structurally from `@byokit/accounts` (multi.ts): a known percentage or 'unknown'. */
|
|
5
|
+
export type Room = {
|
|
6
|
+
left: number;
|
|
7
|
+
span: RoomSpan;
|
|
8
|
+
resetsAt?: number;
|
|
9
|
+
at?: number;
|
|
10
|
+
} | {
|
|
11
|
+
left: 'unknown';
|
|
12
|
+
at?: number;
|
|
13
|
+
};
|
|
14
|
+
/** One account as `@byokit/accounts` `list()` reports it; only these fields are read, extra ones (a token included) are ignored. */
|
|
15
|
+
export type Account = {
|
|
16
|
+
id: string;
|
|
17
|
+
provider: string;
|
|
18
|
+
name: string;
|
|
19
|
+
label?: string;
|
|
20
|
+
state: SignInState;
|
|
21
|
+
billing: Billing;
|
|
22
|
+
plan?: string;
|
|
23
|
+
until?: number;
|
|
24
|
+
};
|
|
25
|
+
/** What an app supplies: the accounts it lists and one room reading per account. `AccountsSource` is the seam between
|
|
26
|
+
* any back end and these rows; an at-most-one-line-per-member adapter fits it. */
|
|
27
|
+
export type AccountsSource = {
|
|
28
|
+
accounts: () => Promise<readonly Account[]> | readonly Account[];
|
|
29
|
+
room: (account: Account) => Promise<Room> | Room;
|
|
30
|
+
};
|
|
31
|
+
export type AccountAction = 'sign_in' | 'wait';
|
|
32
|
+
export type AccountRow = {
|
|
33
|
+
id: string;
|
|
34
|
+
title: string;
|
|
35
|
+
detail: string;
|
|
36
|
+
state: SignInState;
|
|
37
|
+
billing: Billing;
|
|
38
|
+
/** Room left is at or below 20%: true at exactly 20, false above it or when the room is unknown. */
|
|
39
|
+
low: boolean;
|
|
40
|
+
/** What the person can do: sign in again, or wait for a resting account to refill. Absent when nothing helps. */
|
|
41
|
+
action?: AccountAction;
|
|
42
|
+
/** A resting account's next refill, epoch milliseconds. */
|
|
43
|
+
until?: number;
|
|
44
|
+
};
|
|
45
|
+
/** One row per account, reading one room per account; a missing or throwing reading is 'Room left unknown'. */
|
|
46
|
+
export declare function accountRows(accounts: readonly Account[], room: (account: Account) => Room, now: number): AccountRow[];
|
|
47
|
+
/** `accountRows` over an `AccountsSource`: the app's own `accounts()` and `room()`, awaited; a failing room reads unknown. */
|
|
48
|
+
export declare function rowsOf(source: AccountsSource, now: number): Promise<AccountRow[]>;
|
|
49
|
+
/** Untaken names an app can offer for a new account, from the email's local part (never the email itself), else the
|
|
50
|
+
* provider's name. Dedupes case-insensitively against `taken`; up to three. */
|
|
51
|
+
export declare function nameSuggestions(email: string | undefined, taken: readonly string[], provider?: string): string[];
|
|
52
|
+
/** Why one of an account's models cannot be used right now, restated from `@byokit/accounts` (multi.ts). */
|
|
53
|
+
export type ModelWhy = 'plan' | 'resting' | 'signed_out';
|
|
54
|
+
/** One model as `@byokit/accounts` `models()` lists it: a strong/fast tier, whether it can run, and, when it can't, why. */
|
|
55
|
+
export type ModelInfo = {
|
|
56
|
+
id: string;
|
|
57
|
+
name: string;
|
|
58
|
+
tier?: 'strong' | 'fast';
|
|
59
|
+
available: boolean;
|
|
60
|
+
why?: ModelWhy;
|
|
61
|
+
until?: number;
|
|
62
|
+
};
|
|
63
|
+
/** What a run asks for, restated structurally: an account (or `default`/`auto`) and one model. */
|
|
64
|
+
export type RunSelection = {
|
|
65
|
+
account: string | 'default' | 'auto';
|
|
66
|
+
model?: string;
|
|
67
|
+
};
|
|
68
|
+
/** Why Auto named (or could not name) an account, restated from `@byokit/accounts` `resolveSelection`. */
|
|
69
|
+
export type PickWhy = 'chosen' | 'default' | 'first_ready' | 'only' | 'most_room' | 'earlier_reset' | 'list_order' | 'no_reading' | 'refills_first';
|
|
70
|
+
/** Why an account cannot be used, as Auto judges it, restated from `@byokit/accounts` `Considered`. */
|
|
71
|
+
export type PickOut = 'state' | 'resting' | 'billing' | 'model' | 'provider';
|
|
72
|
+
/** One account's line in the pick's explanation; only the id, the why and the refill time are read here. */
|
|
73
|
+
type ConsideredRow = {
|
|
74
|
+
id: string;
|
|
75
|
+
out?: PickOut;
|
|
76
|
+
until?: number;
|
|
77
|
+
};
|
|
78
|
+
/** `@byokit/accounts` `resolveSelection`/`pick`, restated structurally: what Auto chose, or why it could not. */
|
|
79
|
+
export type Pick = {
|
|
80
|
+
ok: true;
|
|
81
|
+
account: Account;
|
|
82
|
+
model: string;
|
|
83
|
+
how: 'chosen' | 'default' | 'auto';
|
|
84
|
+
why: PickWhy;
|
|
85
|
+
reason: string;
|
|
86
|
+
considered?: readonly ConsideredRow[];
|
|
87
|
+
} | {
|
|
88
|
+
ok: false;
|
|
89
|
+
code: 'none' | 'not_included' | 'unknown_account';
|
|
90
|
+
reason: string;
|
|
91
|
+
considered?: readonly ConsideredRow[];
|
|
92
|
+
};
|
|
93
|
+
/** One model row under an account, marked `selected` when it is the run's model. */
|
|
94
|
+
export type PickerModel = ModelInfo & {
|
|
95
|
+
selected: boolean;
|
|
96
|
+
};
|
|
97
|
+
/** One picker row: the `Auto — most room left` row first, then one per account in list order. */
|
|
98
|
+
export type PickerRow = {
|
|
99
|
+
/** `'auto'`, or the account id. */
|
|
100
|
+
key: string;
|
|
101
|
+
kind: 'auto' | 'account';
|
|
102
|
+
/** `Auto — most room left`, or the account's own plain title (`ChatGPT · Work`). */
|
|
103
|
+
title: string;
|
|
104
|
+
/** Auto's own reason in words on the Auto row; an account row's why, or its status when it can be used. */
|
|
105
|
+
detail: string;
|
|
106
|
+
/** True for the one row the run is set to use. */
|
|
107
|
+
selected: boolean;
|
|
108
|
+
/** True when the row cannot be chosen; only Auto, when it has no account to name. */
|
|
109
|
+
disabled: boolean;
|
|
110
|
+
/** Why Auto is disabled, typed from the pick's failure code; present only when `disabled`. */
|
|
111
|
+
disabledReason?: 'none' | 'not_included' | 'unknown_account';
|
|
112
|
+
/** Why the account cannot be used right now: a sign-in problem, a rest, per-use billing, a missing model, or
|
|
113
|
+
* another service. Absent when it can be used. */
|
|
114
|
+
out?: PickOut;
|
|
115
|
+
/** A resting account's next refill, epoch milliseconds. */
|
|
116
|
+
until?: number;
|
|
117
|
+
/** This account's models, each marked when it is the run's model; empty for the Auto row. */
|
|
118
|
+
models: PickerModel[];
|
|
119
|
+
};
|
|
120
|
+
/** What a run or settings sheet draws: the rows, the named selection, and what it resolves to. */
|
|
121
|
+
export type PickerView = {
|
|
122
|
+
rows: PickerRow[];
|
|
123
|
+
/** Exactly what `sel.account` named: `'auto'` or an account id. */
|
|
124
|
+
selection: string;
|
|
125
|
+
/** True when the selection resolves to a usable account and model. */
|
|
126
|
+
ok: boolean;
|
|
127
|
+
/** The account the run uses, when `ok`. */
|
|
128
|
+
account?: string;
|
|
129
|
+
/** The model the run uses, when `ok`. */
|
|
130
|
+
model?: string;
|
|
131
|
+
};
|
|
132
|
+
/** The one sentence a Move sheet shows: `Move to Work` after a tap, `Couldn't move to Work` after a refusal. */
|
|
133
|
+
export declare function moveWords(name: string, moved?: boolean): string;
|
|
134
|
+
/**
|
|
135
|
+
* The picker rows for a run selection, with the selection marked: the `Auto — most room left` row carrying Auto's
|
|
136
|
+
* own `pick.reason`, then one row per account with its model rows and, when it cannot be used, Auto's why and
|
|
137
|
+
* `until`. `auto` is the pick `@byokit/accounts` `resolveSelection` made for `{ account: 'auto' }`; a signed-out
|
|
138
|
+
* explicit pick is shown as `Sign in first` and never replaced by another account.
|
|
139
|
+
*/
|
|
140
|
+
export declare function modelPicker(accounts: readonly Account[], models: Record<string, readonly ModelInfo[]>, sel: RunSelection, auto: Pick, now?: number): PickerView;
|
|
141
|
+
/**
|
|
142
|
+
* The account a running session should move to, or undefined when there is nowhere better to go: `@byokit/accounts`
|
|
143
|
+
* `chooseAccount`'s own winner (`moveToAccount` carries the session there). Nothing to move to with one account,
|
|
144
|
+
* with only signed-out peers, when nobody has room left, or when the list is API keys only; the current account is
|
|
145
|
+
* never named back to itself. `now` defaults to the current time, as `accountRows` does.
|
|
146
|
+
*/
|
|
147
|
+
export declare function bestMoveTarget(accounts: readonly Account[], room: Record<string, Room>, current: string, now?: number): string | undefined;
|
|
148
|
+
export {};
|
package/dist/accounts.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// One row per account, in plain words: the provider and the person's own name for it, then the plan and the room
|
|
2
|
+
// left, or the honest reason it is not usable right now. Framework-free and portable: it reads only the fields it
|
|
3
|
+
// names (D3), so no credential on an account ever reaches a row. `AccountsSource` is the shape an app fills from its
|
|
4
|
+
// own back end; @byokit/accounts `list()` and a room reading fit it with at most one line per member.
|
|
5
|
+
import WORDS from './words.json' with { type: 'json' };
|
|
6
|
+
const UNKNOWN = { left: 'unknown' };
|
|
7
|
+
const LOW_AT = 20;
|
|
8
|
+
const roomWords = (r) => r.left === 'unknown' ? WORDS['account.room.unknown'] : WORDS[`account.room.${r.span}`].replace('{left}', `${Math.round(r.left)}%`);
|
|
9
|
+
const planLabel = (plan) => (plan ? plan.charAt(0).toUpperCase() + plan.slice(1) : '');
|
|
10
|
+
/** 'ChatGPT · Work', or just the provider's own name when the account keeps it. An email is never used as a name. */
|
|
11
|
+
function titleOf(a) {
|
|
12
|
+
const provider = a.label || a.provider;
|
|
13
|
+
const name = a.name && !a.name.includes('@') && a.name.toLowerCase() !== provider.toLowerCase() ? a.name : '';
|
|
14
|
+
return name ? `${provider} · ${name}` : provider;
|
|
15
|
+
}
|
|
16
|
+
/** A resting account whose refill time has passed reads as ready again, exactly as the Auto chooser treats it. */
|
|
17
|
+
const refilled = (a, now) => a.state === 'resting' && typeof a.until === 'number' && a.until <= now;
|
|
18
|
+
function rowOf(a, room, now) {
|
|
19
|
+
const state = refilled(a, now) ? 'ready' : a.state;
|
|
20
|
+
const title = titleOf(a);
|
|
21
|
+
if (state === 'ready') {
|
|
22
|
+
const words = roomWords(room);
|
|
23
|
+
return { id: a.id, title, detail: a.plan && room.left !== 'unknown' ? `${planLabel(a.plan)} · ${words}` : words, state, billing: a.billing,
|
|
24
|
+
low: room.left !== 'unknown' && room.left <= LOW_AT };
|
|
25
|
+
}
|
|
26
|
+
if (state === 'resting') {
|
|
27
|
+
return { id: a.id, title, detail: WORDS['account.resting'], state, billing: a.billing, low: false, action: 'wait',
|
|
28
|
+
...(typeof a.until === 'number' ? { until: a.until } : {}) };
|
|
29
|
+
}
|
|
30
|
+
const action = state === 'signed_out' || state === 'needs_again' ? 'sign_in' : undefined;
|
|
31
|
+
return { id: a.id, title, detail: WORDS[`account.${state}`], state, billing: a.billing, low: false, ...(action ? { action } : {}) };
|
|
32
|
+
}
|
|
33
|
+
/** One row per account, reading one room per account; a missing or throwing reading is 'Room left unknown'. */
|
|
34
|
+
export function accountRows(accounts, room, now) {
|
|
35
|
+
return accounts.map((a) => {
|
|
36
|
+
let r = UNKNOWN;
|
|
37
|
+
try {
|
|
38
|
+
r = room(a) ?? UNKNOWN;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
r = UNKNOWN;
|
|
42
|
+
}
|
|
43
|
+
return rowOf(a, r, now);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
/** `accountRows` over an `AccountsSource`: the app's own `accounts()` and `room()`, awaited; a failing room reads unknown. */
|
|
47
|
+
export async function rowsOf(source, now) {
|
|
48
|
+
const accounts = await source.accounts();
|
|
49
|
+
const rooms = new Map();
|
|
50
|
+
for (const a of accounts) {
|
|
51
|
+
try {
|
|
52
|
+
rooms.set(a.id, (await source.room(a)) ?? UNKNOWN);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
rooms.set(a.id, UNKNOWN);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return accountRows(accounts, (a) => rooms.get(a.id) ?? UNKNOWN, now);
|
|
59
|
+
}
|
|
60
|
+
/** Untaken names an app can offer for a new account, from the email's local part (never the email itself), else the
|
|
61
|
+
* provider's name. Dedupes case-insensitively against `taken`; up to three. */
|
|
62
|
+
export function nameSuggestions(email, taken, provider = 'Account') {
|
|
63
|
+
const used = new Set(taken.map((t) => t.trim().toLowerCase()).filter(Boolean));
|
|
64
|
+
const local = email?.split('@')[0]?.split(/[._-]+/).find(Boolean) ?? '';
|
|
65
|
+
const base = (local ? local.charAt(0).toUpperCase() + local.slice(1).toLowerCase() : provider).slice(0, 64) || provider;
|
|
66
|
+
const out = [];
|
|
67
|
+
for (let n = 1; out.length < 3 && n < 100; n++) {
|
|
68
|
+
const name = n === 1 ? base : `${base} ${n}`;
|
|
69
|
+
if (!used.has(name.toLowerCase())) {
|
|
70
|
+
out.push(name);
|
|
71
|
+
used.add(name.toLowerCase());
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
/** An account is usable when it is ready, or resting with its refill time already passed, exactly as a row reads. */
|
|
77
|
+
const usableNow = (a, now) => a.state === 'ready' || refilled(a, now);
|
|
78
|
+
/** The one sentence a Move sheet shows: `Move to Work` after a tap, `Couldn't move to Work` after a refusal. */
|
|
79
|
+
export function moveWords(name, moved = true) {
|
|
80
|
+
return WORDS[moved ? 'move.to' : 'move.failed'].replace('{name}', name);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* The picker rows for a run selection, with the selection marked: the `Auto — most room left` row carrying Auto's
|
|
84
|
+
* own `pick.reason`, then one row per account with its model rows and, when it cannot be used, Auto's why and
|
|
85
|
+
* `until`. `auto` is the pick `@byokit/accounts` `resolveSelection` made for `{ account: 'auto' }`; a signed-out
|
|
86
|
+
* explicit pick is shown as `Sign in first` and never replaced by another account.
|
|
87
|
+
*/
|
|
88
|
+
export function modelPicker(accounts, models, sel, auto, now = Date.now()) {
|
|
89
|
+
const considered = new Map((auto.considered ?? []).map((r) => [r.id, r]));
|
|
90
|
+
let account;
|
|
91
|
+
let model;
|
|
92
|
+
if (sel.account === 'auto') {
|
|
93
|
+
if (auto.ok) {
|
|
94
|
+
account = auto.account.id;
|
|
95
|
+
model = auto.model;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
const chosen = accounts.find((a) => a.id === sel.account);
|
|
100
|
+
if (chosen && usableNow(chosen, now)) {
|
|
101
|
+
const available = (models[chosen.id] ?? []).filter((m) => m.available);
|
|
102
|
+
const pick = sel.model && available.some((m) => m.id === sel.model) ? sel.model : available[0]?.id;
|
|
103
|
+
if (pick) {
|
|
104
|
+
account = chosen.id;
|
|
105
|
+
model = pick;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const rows = [{
|
|
110
|
+
key: 'auto', kind: 'auto', title: WORDS['picker.auto'], detail: auto.reason,
|
|
111
|
+
selected: sel.account === 'auto', disabled: !auto.ok, ...(auto.ok ? {} : { disabledReason: auto.code }), models: [],
|
|
112
|
+
}];
|
|
113
|
+
for (const a of accounts) {
|
|
114
|
+
const isSelected = sel.account === a.id;
|
|
115
|
+
const why = considered.get(a.id);
|
|
116
|
+
const out = why?.out ?? (usableNow(a, now) ? undefined : a.state === 'resting' ? 'resting' : 'state');
|
|
117
|
+
const rowModels = (models[a.id] ?? []).map((m) => ({ ...m, selected: a.id === account && m.id === model && m.available }));
|
|
118
|
+
rows.push({
|
|
119
|
+
key: a.id, kind: 'account', title: titleOf(a), detail: out ? WORDS[`picker.out.${out}`] : '', selected: isSelected, disabled: false,
|
|
120
|
+
...(out ? { out } : {}), ...(out && why?.until !== undefined ? { until: why.until } : {}), models: rowModels,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
const ok = account !== undefined && model !== undefined;
|
|
124
|
+
return { rows, selection: sel.account, ok, ...(ok ? { account, model } : {}) };
|
|
125
|
+
}
|
|
126
|
+
const MOVE_DAY_MS = 24 * 60 * 60 * 1000;
|
|
127
|
+
const TIER_ORDER = { room: 0, unknown: 1, exhausted: 2 };
|
|
128
|
+
/**
|
|
129
|
+
* Auto's winner among `accounts`, mirroring `@byokit/accounts` `chooseAccount` (multi.ts): subscriptions only, most
|
|
130
|
+
* room, unknown above exhausted, earliest reset then list order. Used by `bestMoveTarget`, and cross-checked against
|
|
131
|
+
* the real chooser over `fixtures/conformance/auto-pick-typescript.json`.
|
|
132
|
+
*/
|
|
133
|
+
function autoCandidate(accounts, room, now) {
|
|
134
|
+
const rows = accounts.map((a) => {
|
|
135
|
+
const r = room(a);
|
|
136
|
+
const age = typeof r.at === 'number' ? Math.max(0, now - r.at) : 'unknown';
|
|
137
|
+
const stale = typeof age === 'number' && age > MOVE_DAY_MS;
|
|
138
|
+
const left = r.left === 'unknown' || typeof r.left !== 'number' || stale ? 'unknown' : Math.max(0, Math.min(100, r.left));
|
|
139
|
+
let out;
|
|
140
|
+
if (a.state === 'resting' && typeof a.until === 'number' && a.until > now)
|
|
141
|
+
out = 'resting';
|
|
142
|
+
else if (a.state !== 'ready' && !(a.state === 'resting' && typeof a.until === 'number' && a.until <= now))
|
|
143
|
+
out = 'state';
|
|
144
|
+
else if (a.billing !== 'subscription')
|
|
145
|
+
out = 'billing';
|
|
146
|
+
const reset = r.left !== 'unknown' && typeof r.resetsAt === 'number' ? r.resetsAt : undefined;
|
|
147
|
+
const tier = left === 'unknown' ? 'unknown' : left > 0 || (reset !== undefined && reset <= now) ? 'room' : 'exhausted';
|
|
148
|
+
return { account: a, tier, left, ...(out ? { out } : {}), ...(reset !== undefined ? { resetsAt: reset } : {}) };
|
|
149
|
+
});
|
|
150
|
+
const ranked = rows.filter((c) => !c.out).sort((x, y) => {
|
|
151
|
+
const tier = TIER_ORDER[x.tier] - TIER_ORDER[y.tier];
|
|
152
|
+
if (tier)
|
|
153
|
+
return tier;
|
|
154
|
+
if (x.tier === 'unknown')
|
|
155
|
+
return 0;
|
|
156
|
+
if (x.tier === 'room' && x.left !== y.left)
|
|
157
|
+
return Number(y.left) - Number(x.left);
|
|
158
|
+
const rx = x.resetsAt ?? Infinity;
|
|
159
|
+
const ry = y.resetsAt ?? Infinity;
|
|
160
|
+
return rx === ry ? 0 : rx < ry ? -1 : 1;
|
|
161
|
+
});
|
|
162
|
+
return ranked[0];
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* The account a running session should move to, or undefined when there is nowhere better to go: `@byokit/accounts`
|
|
166
|
+
* `chooseAccount`'s own winner (`moveToAccount` carries the session there). Nothing to move to with one account,
|
|
167
|
+
* with only signed-out peers, when nobody has room left, or when the list is API keys only; the current account is
|
|
168
|
+
* never named back to itself. `now` defaults to the current time, as `accountRows` does.
|
|
169
|
+
*/
|
|
170
|
+
export function bestMoveTarget(accounts, room, current, now = Date.now()) {
|
|
171
|
+
const pick = autoCandidate(accounts, (a) => room[a.id] ?? { left: 'unknown' }, now);
|
|
172
|
+
if (!pick || pick.account.id === current || pick.tier === 'exhausted')
|
|
173
|
+
return undefined;
|
|
174
|
+
return pick.account.id;
|
|
175
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -7,3 +7,5 @@ export * from './kits.ts';
|
|
|
7
7
|
export { useApprovals, useBlocked, useHerdrTree, useRun } from './useKits.ts';
|
|
8
8
|
export { connectStep, connectView, signInFor, type ConnectAction, type ConnectDoes, type ConnectGroup, type ConnectGroupId, type ConnectRoute, type ConnectRow, type ConnectStep, type ConnectView, type ConnectWords, type UseConnectSignIn } from './connect.ts';
|
|
9
9
|
export { useConnect, type UseConnect } from './useConnect.ts';
|
|
10
|
+
export { accountRows, nameSuggestions, rowsOf, type Account, type AccountAction, type AccountRow, type AccountsSource, type Billing, type Room, type RoomSpan, type SignInState } from './accounts.ts';
|
|
11
|
+
export { accountsSource, cliSource, useAccounts, useModelPicker, type AccountHooksSource, type CliAccounts, type MemberAccounts, type ModelPickerSource, type UseAccounts, type UseModelPicker } from './useAccounts.ts';
|
package/dist/index.js
CHANGED
|
@@ -7,3 +7,5 @@ export * from "./kits.js";
|
|
|
7
7
|
export { useApprovals, useBlocked, useHerdrTree, useRun } from "./useKits.js";
|
|
8
8
|
export { connectStep, connectView, signInFor } from "./connect.js";
|
|
9
9
|
export { useConnect } from "./useConnect.js";
|
|
10
|
+
export { accountRows, nameSuggestions, rowsOf } from "./accounts.js";
|
|
11
|
+
export { accountsSource, cliSource, useAccounts, useModelPicker } from "./useAccounts.js";
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type Account, type AccountRow, type AccountsSource, type ModelInfo, type Pick, type PickerView, type Room, type RunSelection } from './accounts.ts';
|
|
2
|
+
/** What the hooks remove through: an `AccountsSource` plus the back end's own removal, when the app allows it. */
|
|
3
|
+
export type AccountHooksSource = AccountsSource & {
|
|
4
|
+
remove?: (id: string) => Promise<unknown>;
|
|
5
|
+
};
|
|
6
|
+
/** What the picker reads: the rows' source plus one model list per account and Auto's own pick. */
|
|
7
|
+
export type ModelPickerSource = AccountHooksSource & {
|
|
8
|
+
models: (account: Account) => Promise<readonly ModelInfo[]> | readonly ModelInfo[];
|
|
9
|
+
auto: () => Promise<Pick> | Pick;
|
|
10
|
+
};
|
|
11
|
+
/** A live `@byokit/accounts` member: `list()`/`models()`/`pick()`/`remove()` fit with one line per member. */
|
|
12
|
+
export type MemberAccounts<M> = {
|
|
13
|
+
list: (member: M) => Promise<readonly Account[]>;
|
|
14
|
+
models: (member: M, id: string) => Promise<readonly ModelInfo[]>;
|
|
15
|
+
pick: (member: M, sel: RunSelection, room?: (account: Account, demand: readonly string[]) => Room | Promise<Room>) => Promise<Pick>;
|
|
16
|
+
remove: (member: M, id: string) => Promise<unknown>;
|
|
17
|
+
};
|
|
18
|
+
/** A live `cliAccounts()` kit: `list()` fits with one line; `remove` when the app lets the person remove one. */
|
|
19
|
+
export type CliAccounts = {
|
|
20
|
+
list: () => Promise<readonly Account[]>;
|
|
21
|
+
remove?: (id: string) => Promise<unknown>;
|
|
22
|
+
};
|
|
23
|
+
/** One line per member: the rows' source (with the picker's models and Auto pick) over a live `Accounts`. */
|
|
24
|
+
export declare function accountsSource<M>(accounts: MemberAccounts<M>, member: M, room?: (account: Account) => Room | Promise<Room>): ModelPickerSource;
|
|
25
|
+
/** One line: the rows' source over a live `cliAccounts()` kit, with each provider's plain name on its rows. */
|
|
26
|
+
export declare function cliSource(cli: CliAccounts, room?: (account: Account) => Room | Promise<Room>): AccountHooksSource;
|
|
27
|
+
export type UseAccounts = {
|
|
28
|
+
rows: AccountRow[];
|
|
29
|
+
ready: boolean;
|
|
30
|
+
error: unknown;
|
|
31
|
+
refresh: () => Promise<void>;
|
|
32
|
+
remove: (id: string) => Promise<void>;
|
|
33
|
+
};
|
|
34
|
+
/** The rows live: `refresh()` re-reads the source; the caller's removal of an id sticks across refreshes. */
|
|
35
|
+
export declare function useAccounts(source: AccountHooksSource): UseAccounts;
|
|
36
|
+
export type UseModelPicker = {
|
|
37
|
+
rows: AccountRow[];
|
|
38
|
+
picker: PickerView | null;
|
|
39
|
+
selection: RunSelection;
|
|
40
|
+
ready: boolean;
|
|
41
|
+
error: unknown;
|
|
42
|
+
select: (sel: RunSelection) => void;
|
|
43
|
+
refresh: () => Promise<void>;
|
|
44
|
+
remove: (id: string) => Promise<void>;
|
|
45
|
+
};
|
|
46
|
+
/** The run picker live: `select()` marks the selection at once (kept across refreshes); `refresh()` re-reads
|
|
47
|
+
* accounts, models and Auto's pick together. `picker` is null until the first refresh resolves. */
|
|
48
|
+
export declare function useModelPicker(source: ModelPickerSource, initial?: RunSelection): UseModelPicker;
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// Live account rows and the run picker as React hooks over `./accounts.ts`. An app fills an `AccountsSource`
|
|
2
|
+
// (or the fuller `ModelPickerSource`) from its own back end — one line per member — and draws the rows its own
|
|
3
|
+
// way. A failing room leaves only its row unknown; a mid-refresh `remove()` still drops the row. No openclaw
|
|
4
|
+
// adapter yet (pending O14).
|
|
5
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
6
|
+
import { accountRows, modelPicker, rowsOf } from "./accounts.js";
|
|
7
|
+
const UNKNOWN_ROOM = { left: 'unknown' };
|
|
8
|
+
const CLI_LABEL = { claude: 'Claude', codex: 'Codex', pi: 'Pi' };
|
|
9
|
+
/** One line per member: the rows' source (with the picker's models and Auto pick) over a live `Accounts`. */
|
|
10
|
+
export function accountsSource(accounts, member, room = () => UNKNOWN_ROOM) {
|
|
11
|
+
return {
|
|
12
|
+
accounts: () => accounts.list(member),
|
|
13
|
+
room,
|
|
14
|
+
models: (a) => accounts.models(member, a.id),
|
|
15
|
+
auto: () => accounts.pick(member, { account: 'auto' }, room),
|
|
16
|
+
remove: (id) => accounts.remove(member, id),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
/** One line: the rows' source over a live `cliAccounts()` kit, with each provider's plain name on its rows. */
|
|
20
|
+
export function cliSource(cli, room = () => UNKNOWN_ROOM) {
|
|
21
|
+
const remove = cli.remove;
|
|
22
|
+
return {
|
|
23
|
+
accounts: async () => (await cli.list()).map((c) => ({ ...c, label: CLI_LABEL[c.provider] ?? c.provider })),
|
|
24
|
+
room,
|
|
25
|
+
...(remove ? { remove: (id) => remove(id) } : {}),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/** The rows live: `refresh()` re-reads the source; the caller's removal of an id sticks across refreshes. */
|
|
29
|
+
export function useAccounts(source) {
|
|
30
|
+
const [rows, setRows] = useState([]);
|
|
31
|
+
const [ready, setReady] = useState(false);
|
|
32
|
+
const [error, setError] = useState(undefined);
|
|
33
|
+
const removed = useRef(new Set());
|
|
34
|
+
const gen = useRef(0);
|
|
35
|
+
const refresh = useCallback(async () => {
|
|
36
|
+
const g = ++gen.current;
|
|
37
|
+
let next;
|
|
38
|
+
try {
|
|
39
|
+
next = await rowsOf(source, Date.now());
|
|
40
|
+
}
|
|
41
|
+
catch (e) {
|
|
42
|
+
if (gen.current === g)
|
|
43
|
+
setError(e);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (gen.current !== g)
|
|
47
|
+
return;
|
|
48
|
+
setRows(next.filter((r) => !removed.current.has(r.id)));
|
|
49
|
+
setError(undefined);
|
|
50
|
+
setReady(true);
|
|
51
|
+
}, [source]);
|
|
52
|
+
useEffect(() => { void refresh(); }, [refresh]);
|
|
53
|
+
const remove = useCallback(async (id) => {
|
|
54
|
+
removed.current.add(id);
|
|
55
|
+
gen.current++;
|
|
56
|
+
setRows((prev) => prev.filter((r) => r.id !== id));
|
|
57
|
+
try {
|
|
58
|
+
await source.remove?.(id);
|
|
59
|
+
}
|
|
60
|
+
catch (e) {
|
|
61
|
+
removed.current.delete(id);
|
|
62
|
+
throw e;
|
|
63
|
+
}
|
|
64
|
+
await refresh();
|
|
65
|
+
}, [source, refresh]);
|
|
66
|
+
return { rows, ready, error, refresh, remove };
|
|
67
|
+
}
|
|
68
|
+
/** The run picker live: `select()` marks the selection at once (kept across refreshes); `refresh()` re-reads
|
|
69
|
+
* accounts, models and Auto's pick together. `picker` is null until the first refresh resolves. */
|
|
70
|
+
export function useModelPicker(source, initial = { account: 'auto' }) {
|
|
71
|
+
const [rows, setRows] = useState([]);
|
|
72
|
+
const [picker, setPicker] = useState(null);
|
|
73
|
+
const [selection, setSelection] = useState(initial);
|
|
74
|
+
const [ready, setReady] = useState(false);
|
|
75
|
+
const [error, setError] = useState(undefined);
|
|
76
|
+
const sel = useRef(initial);
|
|
77
|
+
const cache = useRef(null);
|
|
78
|
+
const removed = useRef(new Set());
|
|
79
|
+
const gen = useRef(0);
|
|
80
|
+
const refresh = useCallback(async () => {
|
|
81
|
+
const g = ++gen.current;
|
|
82
|
+
const now = Date.now();
|
|
83
|
+
let list;
|
|
84
|
+
try {
|
|
85
|
+
list = [...(await source.accounts())];
|
|
86
|
+
}
|
|
87
|
+
catch (e) {
|
|
88
|
+
if (gen.current === g)
|
|
89
|
+
setError(e);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const live = list.filter((a) => !removed.current.has(a.id));
|
|
93
|
+
const rooms = new Map();
|
|
94
|
+
for (const a of live) {
|
|
95
|
+
try {
|
|
96
|
+
rooms.set(a.id, (await source.room(a)) ?? UNKNOWN_ROOM);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
rooms.set(a.id, UNKNOWN_ROOM);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const models = {};
|
|
103
|
+
for (const a of live) {
|
|
104
|
+
try {
|
|
105
|
+
models[a.id] = await source.models(a);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
models[a.id] = [];
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
let auto;
|
|
112
|
+
try {
|
|
113
|
+
auto = await source.auto();
|
|
114
|
+
}
|
|
115
|
+
catch (e) {
|
|
116
|
+
if (gen.current === g)
|
|
117
|
+
setError(e);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (gen.current !== g)
|
|
121
|
+
return;
|
|
122
|
+
cache.current = { accounts: live, models, auto };
|
|
123
|
+
setRows(accountRows(live, (a) => rooms.get(a.id) ?? UNKNOWN_ROOM, now).filter((r) => !removed.current.has(r.id)));
|
|
124
|
+
setPicker(modelPicker(live, models, sel.current, auto, now));
|
|
125
|
+
setError(undefined);
|
|
126
|
+
setReady(true);
|
|
127
|
+
}, [source]);
|
|
128
|
+
useEffect(() => { void refresh(); }, [refresh]);
|
|
129
|
+
const select = useCallback((next) => {
|
|
130
|
+
sel.current = next;
|
|
131
|
+
setSelection(next);
|
|
132
|
+
const c = cache.current;
|
|
133
|
+
if (c)
|
|
134
|
+
setPicker(modelPicker(c.accounts, c.models, next, c.auto, Date.now()));
|
|
135
|
+
}, []);
|
|
136
|
+
const remove = useCallback(async (id) => {
|
|
137
|
+
removed.current.add(id);
|
|
138
|
+
gen.current++;
|
|
139
|
+
setRows((prev) => prev.filter((r) => r.id !== id));
|
|
140
|
+
const c = cache.current;
|
|
141
|
+
if (c) {
|
|
142
|
+
const accounts = c.accounts.filter((a) => a.id !== id);
|
|
143
|
+
cache.current = { ...c, accounts };
|
|
144
|
+
setPicker(modelPicker(accounts, c.models, sel.current, c.auto, Date.now()));
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
await source.remove?.(id);
|
|
148
|
+
}
|
|
149
|
+
catch (e) {
|
|
150
|
+
removed.current.delete(id);
|
|
151
|
+
throw e;
|
|
152
|
+
}
|
|
153
|
+
await refresh();
|
|
154
|
+
}, [source, refresh]);
|
|
155
|
+
return { rows, picker, selection, ready, error, select, refresh, remove };
|
|
156
|
+
}
|
package/dist/words.json
CHANGED
|
@@ -44,5 +44,23 @@
|
|
|
44
44
|
"key.checking": "Checking your key…",
|
|
45
45
|
"key.ok": "Your key is ready. It is used only when you choose this option.",
|
|
46
46
|
"key.invalid": "This key could not be saved. Check it and try again.",
|
|
47
|
-
"key.notIncluded": "This option is not available here."
|
|
47
|
+
"key.notIncluded": "This option is not available here.",
|
|
48
|
+
"account.signing": "Checking…",
|
|
49
|
+
"account.resting": "Cooling down",
|
|
50
|
+
"account.signed_out": "Not signed in",
|
|
51
|
+
"account.needs_again": "Sign in again",
|
|
52
|
+
"account.not_included": "Not in your plan",
|
|
53
|
+
"account.room.unknown": "Room left unknown",
|
|
54
|
+
"account.room.session": "{left} left this session",
|
|
55
|
+
"account.room.week": "{left} left this week",
|
|
56
|
+
"account.room.month": "{left} left this month",
|
|
57
|
+
"account.room.tightest": "{left} left for now",
|
|
58
|
+
"picker.auto": "Auto — most room left",
|
|
59
|
+
"picker.out.state": "Sign in first",
|
|
60
|
+
"picker.out.resting": "Taking a break",
|
|
61
|
+
"picker.out.billing": "Pay per use, so picked only when you choose it",
|
|
62
|
+
"picker.out.model": "Doesn't include this run's model",
|
|
63
|
+
"picker.out.provider": "A different service",
|
|
64
|
+
"move.to": "Move to {name}",
|
|
65
|
+
"move.failed": "Couldn't move to {name}"
|
|
48
66
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@byokit/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Headless sign-in, pairing and runtime-kit view state for any UI, plus plain-words copy.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -40,6 +40,10 @@
|
|
|
40
40
|
"./connect": {
|
|
41
41
|
"types": "./dist/connect.d.ts",
|
|
42
42
|
"default": "./dist/connect.js"
|
|
43
|
+
},
|
|
44
|
+
"./accounts": {
|
|
45
|
+
"types": "./dist/accounts.d.ts",
|
|
46
|
+
"default": "./dist/accounts.js"
|
|
43
47
|
}
|
|
44
48
|
},
|
|
45
49
|
"files": [
|
|
@@ -58,8 +62,11 @@
|
|
|
58
62
|
}
|
|
59
63
|
},
|
|
60
64
|
"devDependencies": {
|
|
65
|
+
"@byokit/accounts": "0.22.0",
|
|
61
66
|
"@types/react": "^19.3.0",
|
|
62
|
-
"react": "^19.3.0"
|
|
67
|
+
"@types/react-test-renderer": "^19.3.0",
|
|
68
|
+
"react": "^19.3.0",
|
|
69
|
+
"react-test-renderer": "^19.3.0"
|
|
63
70
|
},
|
|
64
71
|
"publishConfig": {
|
|
65
72
|
"access": "public"
|