@kukonce/dsh-loomy 0.0.0-stage → 0.3.1

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.
@@ -0,0 +1,1092 @@
1
+ /**
2
+ * Client half: the Loomy card inside Settings.
3
+ *
4
+ * One card, one account list. Every account is an independent Loomy login with its own
5
+ * points balance, so the tab strip switches which account is being *looked at* — it
6
+ * never changes who is signed in.
7
+ *
8
+ * The card's jobs, in the order a user meets them: sign in (SMS or by adopting the
9
+ * desktop app's own session), see what the account has left (每日赠送 5000 + 长期),
10
+ * claim today's quota if the automatic run did not happen, and see which models the
11
+ * account may run.
12
+ *
13
+ * The packaged desktop app has no devtools, so each load stage also reports to
14
+ * `POST /loomy/client-log`, which lands in the host log — that is how "the bundle never
15
+ * loaded" stays distinguishable from "loaded, but apply() threw".
16
+ */
17
+
18
+ window.__ModuleLoader__.load({
19
+ id: '@loomy/dsh-loomy',
20
+ factory(require) {
21
+ const report = (stage) => {
22
+ try {
23
+ void fetch('/loomy/client-log', {
24
+ method: 'POST',
25
+ headers: { 'content-type': 'application/json' },
26
+ body: JSON.stringify({ stage }),
27
+ keepalive: true
28
+ }).catch(() => {})
29
+ } catch {
30
+ // diagnostics must never break the panel
31
+ }
32
+ }
33
+ report('module-evaluated')
34
+
35
+ const React = require('react')
36
+ const h = React.createElement
37
+
38
+ const API = '/loomy'
39
+
40
+ /**
41
+ * The card's own wording.
42
+ *
43
+ * Kept in this bundle rather than read from `locale/*.json`: those files describe
44
+ * the plugin to the inventory, while these strings are panel chrome that changes far
45
+ * more often. The host's locale service gets the same dictionary through
46
+ * `locale.register` so both paths agree.
47
+ */
48
+ const DICT = {
49
+ 'zh-CN': {
50
+ section: 'Loomy',
51
+ title: 'Loomy 模型',
52
+ intro: 'Loomy 是讯飞桌面端的模型服务,按积分计费。每个账号每天有 5000 每日赠送积分,软件启动时与每天 0 点各自动领取一次。',
53
+ accounts: '账户',
54
+ noAccount: '还没有登录任何 Loomy 账号',
55
+ noAccountHint: '用手机号加短信验证码登录,或直接导入本机 Loomy 桌面端已登录的会话。',
56
+ masked: '手机号',
57
+ useridLabel: '用户标识',
58
+ source: '来源',
59
+ sourceSms: '短信登录',
60
+ sourceDesktop: '桌面端导入',
61
+ statusSignedIn: '已登录',
62
+ statusExpired: '登录已失效',
63
+ expiredAt: (when) => `失效于 ${when}`,
64
+ dailyCredits: '每日赠送积分',
65
+ permanentCredits: '长期积分',
66
+ availableCredits: '可用合计',
67
+ creditsEmpty: '积分未读取',
68
+ refreshCredits: '刷新积分',
69
+ claim: '领取今日 5000 积分',
70
+ claiming: '领取中…',
71
+ claimedToday: '今日已领取',
72
+ claimHint: '手动触发今天的每日额度领取(服务端按天幂等,重复调用不会重复发放)',
73
+ models: '可用模型',
74
+ modelsHint: '登录后这里显示该账号能调用的模型',
75
+ modelsCount: (n) => `${n} 个模型`,
76
+ modelsCached: (when) => `本地缓存 · ${when}`,
77
+ refreshModels: '刷新模型列表',
78
+ emptyModels: '没有读取到模型',
79
+ catalogFailed: '取不到模型列表',
80
+ vision: '视觉',
81
+ tools: '工具',
82
+ reasoning: '思考',
83
+ loginTitle: '手机号登录',
84
+ phone: '手机号',
85
+ phonePlaceholder: '11 位大陆手机号',
86
+ code: '验证码',
87
+ codePlaceholder: '短信验证码',
88
+ sendCode: '获取验证码',
89
+ sending: '发送中…',
90
+ codeSent: (expire) => `验证码已发送,${expire} 秒内有效`,
91
+ verify: '登录',
92
+ verifying: '登录中…',
93
+ labelOptional: '备注(可选)',
94
+ labelPlaceholder: '例如:工作号',
95
+ importDesktop: '导入 Loomy 桌面端登录',
96
+ importing: '导入中…',
97
+ desktopMissing: '本机没有找到 Loomy 桌面端登录态',
98
+ desktopPaths: (paths) => `已查找:${paths.join('、')}`,
99
+ logout: '退出登录',
100
+ loggingOut: '退出中…',
101
+ rename: '保存备注',
102
+ renaming: '保存中…',
103
+ renamePlaceholder: '账号备注',
104
+ loading: '加载中…',
105
+ unknown: '未知',
106
+ gateway: '网关',
107
+ version: '客户端版本',
108
+ defaultModel: '默认模型',
109
+ noAccounts: '没有账号',
110
+ failed: (message) => `操作失败:${message}`,
111
+ notMounted: '(provider 路由未挂载,需重启宿主)',
112
+ pillTitle: 'Loomy 每日赠送积分:每日额度与可用合计,点击看明细',
113
+ pillPrefix: '每日赠送积分',
114
+ pillDaily: '每日剩余',
115
+ pillAvailable: '可用合计',
116
+ pillClaimed: '今日已领',
117
+ pillNotClaimed: '今日未领',
118
+ pillStale: '数据已过期',
119
+ pillLoading: '读取中…',
120
+ pillExhausted: '每日额度已用完',
121
+ pillNoAccount: '未登录',
122
+ pillNote: '余额随对话实时扣减,每 30 秒刷新;「刷新积分」后与服务器完全同步。',
123
+ pillFetched: (when) => `读取于 ${when}`,
124
+ pillError: (message) => `读取失败:${message}`
125
+ },
126
+ en: {
127
+ section: 'Loomy',
128
+ title: 'Loomy Models',
129
+ intro: 'Loomy is iFlytek’s desktop model service, metered in points. Each account gets 5,000 daily points, claimed automatically at startup and at local midnight.',
130
+ accounts: 'Accounts',
131
+ noAccount: 'No Loomy account is signed in yet',
132
+ noAccountHint: 'Sign in with a phone number and an SMS code, or import the session the Loomy desktop app already has on this machine.',
133
+ masked: 'Phone',
134
+ useridLabel: 'User id',
135
+ source: 'Source',
136
+ sourceSms: 'SMS login',
137
+ sourceDesktop: 'Desktop import',
138
+ statusSignedIn: 'Signed in',
139
+ statusExpired: 'Session expired',
140
+ expiredAt: (when) => `expired ${when}`,
141
+ dailyCredits: 'Daily points',
142
+ permanentCredits: 'Permanent points',
143
+ availableCredits: 'Available',
144
+ creditsEmpty: 'Points not read yet',
145
+ refreshCredits: 'Refresh points',
146
+ claim: 'Claim today’s 5,000',
147
+ claiming: 'Claiming…',
148
+ claimedToday: 'Claimed today',
149
+ claimHint: 'Ask for today’s daily quota now. The server is idempotent per day, so repeating it never grants twice.',
150
+ models: 'Models',
151
+ modelsHint: 'Models this account may call appear here once it is signed in',
152
+ modelsCount: (n) => `${n} model${n === 1 ? '' : 's'}`,
153
+ modelsCached: (when) => `cached · ${when}`,
154
+ refreshModels: 'Refresh models',
155
+ emptyModels: 'No models were returned',
156
+ catalogFailed: 'Could not read the model list',
157
+ vision: 'vision',
158
+ tools: 'tools',
159
+ reasoning: 'reasoning',
160
+ loginTitle: 'Sign in with a phone number',
161
+ phone: 'Phone',
162
+ phonePlaceholder: '11-digit mainland number',
163
+ code: 'Code',
164
+ codePlaceholder: 'SMS code',
165
+ sendCode: 'Send code',
166
+ sending: 'Sending…',
167
+ codeSent: (expire) => `Code sent — valid for ${expire}s`,
168
+ verify: 'Sign in',
169
+ verifying: 'Signing in…',
170
+ labelOptional: 'Label (optional)',
171
+ labelPlaceholder: 'e.g. work account',
172
+ importDesktop: 'Import the Loomy desktop session',
173
+ importing: 'Importing…',
174
+ desktopMissing: 'No Loomy desktop session was found on this machine',
175
+ desktopPaths: (paths) => `Looked in: ${paths.join(', ')}`,
176
+ logout: 'Sign out',
177
+ loggingOut: 'Signing out…',
178
+ rename: 'Save label',
179
+ renaming: 'Saving…',
180
+ renamePlaceholder: 'Account label',
181
+ loading: 'Loading…',
182
+ unknown: 'unknown',
183
+ gateway: 'Gateway',
184
+ version: 'Client version',
185
+ defaultModel: 'Default model',
186
+ noAccounts: 'No accounts',
187
+ failed: (message) => `Failed: ${message}`,
188
+ notMounted: ' (provider route is not mounted — restart the host)',
189
+ pillTitle: 'Loomy daily gift points: daily quota and available balance, click for details',
190
+ pillPrefix: 'Daily gift points',
191
+ pillDaily: 'Daily left',
192
+ pillAvailable: 'Available',
193
+ pillClaimed: 'Claimed today',
194
+ pillNotClaimed: 'Not claimed today',
195
+ pillStale: 'Stale',
196
+ pillLoading: 'Loading…',
197
+ pillExhausted: 'Daily quota exhausted',
198
+ pillNoAccount: 'Not signed in',
199
+ pillNote: 'The balance tracks chat in real time and refreshes every 30 seconds; “Refresh points” re-syncs with the server.',
200
+ pillFetched: (when) => `read ${when}`,
201
+ pillError: (message) => `Read failed: ${message}`
202
+ }
203
+ }
204
+
205
+ const dictFor = (localeId) => (String(localeId).toLowerCase().startsWith('zh') ? DICT['zh-CN'] : DICT.en)
206
+
207
+ const CSS = `
208
+ .lo-root {
209
+ --lo-accent: #2f6bd8; --lo-accent-strong: #2456b0; --lo-accent-soft: rgba(47,107,216,.12);
210
+ --lo-line: var(--dsw-alias-border-l1, rgba(128,138,150,.28));
211
+ --lo-line-soft: var(--dsw-alias-border-l2, rgba(128,138,150,.16));
212
+ --lo-muted: var(--dsw-alias-label-secondary, #9aa1ab);
213
+ --lo-text: var(--dsw-alias-label-primary, #e6e8eb);
214
+ --lo-surface: var(--dsw-alias-bg-layer-1, rgba(128,138,150,.04));
215
+ --lo-surface-hover: var(--dsw-alias-bg-layer-2, rgba(128,138,150,.09));
216
+ --lo-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
217
+ max-width: 820px; padding: 4px 0 40px; font-size: 13px; line-height: 1.6; color: var(--lo-text);
218
+ }
219
+ .lo-title { margin: 0 0 4px; font-size: 16px; font-weight: 650; letter-spacing: -.01em; }
220
+ .lo-intro { margin: 0 0 20px; font-size: 12.5px; line-height: 1.7; color: var(--lo-muted); max-width: 66ch; }
221
+ .lo-card { border: 1px solid var(--lo-line); background: var(--lo-surface); border-radius: 14px; overflow: hidden; }
222
+ .lo-card + .lo-card { margin-top: 16px; }
223
+ .lo-card-head { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; padding: 14px 16px 12px; }
224
+ .lo-card-title { font-size: 12px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: var(--lo-muted); }
225
+ .lo-head-right { margin-left: auto; display: inline-flex; align-items: center; gap: 8px; }
226
+ /* One asset, both themes: the mark is a mask painted with the accent colour. */
227
+ .lo-logo { flex: none; width: 30px; height: 30px; display: block; background: var(--lo-accent-strong);
228
+ -webkit-mask: url('/loomy/icon-mask.png') center / contain no-repeat;
229
+ mask: url('/loomy/icon-mask.png') center / contain no-repeat; }
230
+ .lo-tabs { display: flex; flex-wrap: wrap; gap: 6px; padding: 8px 16px 0; border-bottom: 1px solid var(--lo-line-soft); }
231
+ .lo-tab { font: inherit; font-size: 13px; display: inline-flex; align-items: center; gap: 8px;
232
+ padding: 8px 12px 9px; cursor: pointer; border: 0; border-bottom: 2px solid transparent;
233
+ border-radius: 9px 9px 0 0; background: transparent; color: var(--lo-muted);
234
+ transition: color .15s, background .15s, border-color .15s; }
235
+ .lo-tab:hover { background: var(--lo-surface-hover); color: var(--lo-text); }
236
+ .lo-tab-on { background: var(--lo-accent-soft); color: var(--lo-accent-strong); border-bottom-color: var(--lo-accent); font-weight: 600; }
237
+ .lo-dot { flex: none; width: 7px; height: 7px; border-radius: 50%; }
238
+ .lo-tab-sub { font-size: 11.5px; font-weight: 400; color: var(--lo-muted); }
239
+ .lo-body { padding: 14px 16px 4px; }
240
+ .lo-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 12px 28px; }
241
+ .lo-field { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
242
+ .lo-field-label { font-size: 11px; letter-spacing: .05em; text-transform: uppercase; color: var(--lo-muted); }
243
+ .lo-field-value { font-size: 13px; overflow-wrap: anywhere; }
244
+ .lo-num { font-variant-numeric: tabular-nums; }
245
+ .lo-big { font-size: 19px; font-weight: 650; color: var(--lo-accent-strong); font-variant-numeric: tabular-nums; letter-spacing: -.01em; }
246
+ .lo-mono { font-family: var(--lo-mono); font-size: 12px; }
247
+ .lo-muted { color: var(--lo-muted); }
248
+ .lo-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
249
+ .lo-btn { font: inherit; font-size: 12.5px; font-weight: 500; line-height: 1.2; padding: 7px 13px; border-radius: 8px;
250
+ cursor: pointer; border: 1px solid var(--lo-line); background: var(--lo-surface-hover); color: var(--lo-text);
251
+ transition: background .15s, border-color .15s, color .15s, opacity .15s; }
252
+ .lo-btn:hover:not(:disabled) { background: var(--lo-accent-soft); border-color: var(--lo-accent); color: var(--lo-accent-strong); }
253
+ .lo-btn:focus-visible { outline: 2px solid var(--lo-accent); outline-offset: 1px; }
254
+ .lo-btn:disabled { opacity: .38; cursor: not-allowed; }
255
+ .lo-btn-primary { border-color: var(--lo-accent); background: var(--lo-accent); color: #fff; }
256
+ .lo-btn-primary:hover:not(:disabled) { background: var(--lo-accent-strong); border-color: var(--lo-accent-strong); color: #fff; }
257
+ .lo-btn-ghost { background: transparent; color: var(--lo-muted); }
258
+ .lo-btn-ghost:hover:not(:disabled) { background: var(--lo-accent-soft); color: var(--lo-accent-strong); }
259
+ .lo-btn-danger { border-color: rgba(229,72,77,.35); background: rgba(229,72,77,.07); color: #e5484d; }
260
+ .lo-btn-danger:hover:not(:disabled) { border-color: rgba(229,72,77,.6); background: rgba(229,72,77,.14); color: #e5484d; }
261
+ .lo-icon-btn { font: inherit; font-size: 13px; line-height: 1; padding: 5px 7px; border-radius: 6px; cursor: pointer;
262
+ border: 0; background: transparent; color: var(--lo-muted); transition: background .15s, color .15s; }
263
+ .lo-icon-btn:hover:not(:disabled) { background: var(--lo-accent-soft); color: var(--lo-accent-strong); }
264
+ .lo-icon-btn:disabled { opacity: .4; cursor: not-allowed; }
265
+ .lo-input { font: inherit; font-size: 12.5px; padding: 6px 10px; border-radius: 8px;
266
+ border: 1px solid var(--lo-line); background: transparent; color: var(--lo-text); min-width: 0; }
267
+ .lo-input::placeholder { color: var(--lo-muted); }
268
+ .lo-input:focus { outline: none; border-color: var(--lo-accent); }
269
+ .lo-form { display: flex; flex-wrap: wrap; gap: 10px; align-items: flex-end; padding: 4px 16px 16px; }
270
+ .lo-form-item { display: flex; flex-direction: column; gap: 4px; }
271
+ .lo-form-item .lo-input { min-width: 150px; }
272
+ .lo-tag { display: inline-flex; align-items: center; gap: 5px; padding: 1px 7px; border-radius: 999px;
273
+ font-size: 11px; line-height: 1.55; white-space: nowrap; background: var(--lo-surface-hover);
274
+ color: var(--lo-muted); font-weight: 500; }
275
+ .lo-tag-accent { background: var(--lo-accent-soft); color: var(--lo-accent-strong); }
276
+ .lo-tag-ok { background: rgba(63,164,91,.14); color: #3fa45b; }
277
+ .lo-tag-warn { background: rgba(216,150,20,.15); color: #d89614; }
278
+ .lo-tag-bad { background: rgba(229,72,77,.13); color: #e5484d; }
279
+ .lo-error { margin: 0 0 16px; padding: 11px 14px; border: 1px solid rgba(229,72,77,.4); border-radius: 10px;
280
+ background: rgba(229,72,77,.08); color: #e5484d; font-size: 12.5px; line-height: 1.65; }
281
+ .lo-note { margin: 0 16px 14px; padding: 11px 14px; border: 1px solid var(--lo-line);
282
+ border-left: 3px solid var(--lo-accent); background: var(--lo-accent-soft); border-radius: 10px;
283
+ font-size: 12.5px; line-height: 1.7; color: var(--lo-muted); }
284
+ .lo-note code { font-family: var(--lo-mono); font-size: 12px; color: var(--lo-text); word-break: break-all; }
285
+ .lo-section { border-top: 1px solid var(--lo-line-soft); }
286
+ .lo-section-head { display: flex; flex-wrap: wrap; align-items: center; gap: 9px; padding: 13px 16px 8px; }
287
+ .lo-section-title { font-size: 12px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: var(--lo-muted); }
288
+ .lo-badge { font-size: 11.5px; line-height: 17px; padding: 0 7px; border-radius: 6px;
289
+ background: var(--lo-surface-hover); color: var(--lo-muted); font-variant-numeric: tabular-nums; }
290
+ .lo-models { max-height: 340px; overflow: auto; padding: 2px 16px 12px; scrollbar-width: thin;
291
+ scrollbar-gutter: stable; scrollbar-color: var(--lo-line) transparent; }
292
+ .lo-models::-webkit-scrollbar { width: 8px; }
293
+ .lo-models::-webkit-scrollbar-thumb { background: var(--lo-line); border-radius: 8px; }
294
+ .lo-model { display: flex; align-items: center; gap: 10px; padding: 6px 2px; font-size: 13px; }
295
+ .lo-model + .lo-model { border-top: 1px solid var(--lo-line-soft); }
296
+ .lo-model-name { flex: none; width: 170px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
297
+ .lo-model-id { flex: 1 1 auto; min-width: 0; font-family: var(--lo-mono); font-size: 11.5px; color: var(--lo-muted);
298
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
299
+ .lo-model-tail { flex: none; display: inline-flex; align-items: center; gap: 6px; }
300
+ .lo-empty { padding: 16px; text-align: center; font-size: 12.5px; color: var(--lo-muted); }
301
+ .lo-loading { font-size: 13px; color: var(--lo-muted); animation: lo-pulse 1.6s ease-in-out infinite; }
302
+ @keyframes lo-pulse { 0%, 100% { opacity: .5 } 50% { opacity: .95 } }
303
+ .lo-foot { display: flex; flex-wrap: wrap; gap: 14px; padding: 12px 16px 14px; font-size: 11.5px; color: var(--lo-muted); }
304
+ `
305
+
306
+ const formatTime = (ms, fallback) => (typeof ms === 'number' && ms > 0 ? new Date(ms).toLocaleString() : fallback)
307
+ const formatPoints = (value) => (typeof value === 'number' && Number.isFinite(value)
308
+ ? (Number.isInteger(value) ? value.toLocaleString() : value.toFixed(2))
309
+ : '—')
310
+ /** Local calendar day, matching the Host's bookkeeping key for the daily grant. */
311
+ const localDayKey = (now = new Date()) => {
312
+ const pad = (value) => String(value).padStart(2, '0')
313
+ return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
314
+ }
315
+
316
+ /**
317
+ * One panel request.
318
+ *
319
+ * The Host answers failures as `{ error }` with a non-2xx status, so the message a
320
+ * user needs is in the body, not in the status line — reading the body first is what
321
+ * turns "HTTP 400" into "验证码格式不正确".
322
+ */
323
+ async function request(path, options) {
324
+ const response = await fetch(`${API}${path}`, {
325
+ headers: {
326
+ accept: 'application/json',
327
+ ...(options?.body === undefined ? {} : { 'content-type': 'application/json' })
328
+ },
329
+ ...options
330
+ })
331
+ let json = {}
332
+ try {
333
+ json = await response.json()
334
+ } catch {
335
+ json = {}
336
+ }
337
+ if (!response.ok) throw new Error(json.error ?? `HTTP ${response.status}`)
338
+ return json
339
+ }
340
+
341
+ /** The plugin's own mark, painted through a CSS mask so it follows the accent. */
342
+ function Logomark() {
343
+ return h('span', { className: 'lo-logo', 'aria-hidden': 'true' })
344
+ }
345
+
346
+ const tag = (text, tone = '', extra = {}) => h('span', {
347
+ className: `lo-tag${tone.length === 0 ? '' : ` ${tone}`}`,
348
+ ...extra
349
+ }, text)
350
+
351
+ /** One labelled field; the whole card is built from these. */
352
+ const field = (label, value, extra = '') => h('div', { className: `lo-field${extra}` },
353
+ h('span', { className: 'lo-field-label' }, label),
354
+ typeof value === 'string' || typeof value === 'number'
355
+ ? h('span', { className: 'lo-field-value' }, value)
356
+ : value
357
+ )
358
+
359
+ function LoomySettings({ t }) {
360
+ const [state, setState] = React.useState(null)
361
+ /** Which account's details are shown; display-only, never a login action. */
362
+ const [activeId, setActiveId] = React.useState('')
363
+ const [error, setError] = React.useState('')
364
+ /** `verb:id` of whatever action is running, so the right button says so. */
365
+ const [busy, setBusy] = React.useState('')
366
+ /** Per-account live balance, from `/credits` rather than the grant ledger. */
367
+ const [creditsMap, setCreditsMap] = React.useState({})
368
+ /** Per-account model list, loaded on demand. */
369
+ const [modelMap, setModelMap] = React.useState({})
370
+ const [phone, setPhone] = React.useState('')
371
+ const [code, setCode] = React.useState('')
372
+ const [msgid, setMsgid] = React.useState('')
373
+ const [codeInfo, setCodeInfo] = React.useState('')
374
+ /** The rename box of the active account. */
375
+ const [labelDraft, setLabelDraft] = React.useState('')
376
+ /** The optional label typed into the login form — a login may create a different account. */
377
+ const [newLabel, setNewLabel] = React.useState('')
378
+
379
+ const load = React.useCallback(async () => {
380
+ try {
381
+ const next = await request('/state')
382
+ setState(next)
383
+ setError('')
384
+ setActiveId((current) => {
385
+ const accounts = Array.isArray(next?.accounts) ? next.accounts : []
386
+ // Keep the tab the user picked; fall back to the first signed-in account so
387
+ // the panel never opens on an empty one when another is usable.
388
+ if (current.length > 0 && accounts.some((entry) => entry.id === current)) return current
389
+ return (accounts.find((entry) => entry.signedIn === true) ?? accounts[0])?.id ?? ''
390
+ })
391
+ } catch (cause) {
392
+ setError(cause instanceof Error ? cause.message : String(cause))
393
+ }
394
+ }, [])
395
+
396
+ React.useEffect(() => { void load() }, [load])
397
+
398
+ const refreshCredits = React.useCallback(async (id) => {
399
+ if (id === undefined || id.length === 0) return
400
+ try {
401
+ const result = await request(`/credits?id=${encodeURIComponent(id)}`)
402
+ setCreditsMap((current) => ({ ...current, [id]: result }))
403
+ } catch (cause) {
404
+ // A failed balance read is reported inline but must not blank the card: the
405
+ // rest of the account row is still true.
406
+ setCreditsMap((current) => ({ ...current, [id]: { error: cause instanceof Error ? cause.message : String(cause) } }))
407
+ }
408
+ }, [])
409
+
410
+ const refreshModels = React.useCallback(async (id, force = false) => {
411
+ if (id === undefined || id.length === 0) return
412
+ setModelMap((current) => ({ ...current, [id]: { ...current[id], loading: true } }))
413
+ try {
414
+ const result = await request(`/models?id=${encodeURIComponent(id)}${force ? '&refresh=1' : ''}`)
415
+ setModelMap((current) => ({ ...current, [id]: { list: result.models ?? [], source: result.source, cachedAt: result.cachedAt } }))
416
+ } catch (cause) {
417
+ setModelMap((current) => ({ ...current, [id]: { error: cause instanceof Error ? cause.message : String(cause) } }))
418
+ }
419
+ }, [])
420
+
421
+ const accounts = Array.isArray(state?.accounts) ? state.accounts : []
422
+ const account = accounts.find((entry) => entry.id === activeId) ?? accounts[0]
423
+
424
+ // Load the active account's numbers once each, and again whenever the selection
425
+ // changes. Depend on the id and the login state only: re-reading on every render
426
+ // would hammer an endpoint that costs an upstream round trip.
427
+ React.useEffect(() => {
428
+ if (account === undefined || account.signedIn !== true) return
429
+ if (creditsMap[account.id] === undefined) void refreshCredits(account.id)
430
+ if (modelMap[account.id] === undefined) void refreshModels(account.id)
431
+ }, [account?.id, account?.signedIn])
432
+
433
+ React.useEffect(() => {
434
+ setLabelDraft(account?.label ?? '')
435
+ }, [account?.id, account?.label])
436
+
437
+ /** Run one action with a busy marker, an inline error, and a state re-read. */
438
+ const act = async (key, task) => {
439
+ setBusy(key)
440
+ setError('')
441
+ try {
442
+ await task()
443
+ await load()
444
+ } catch (cause) {
445
+ setError(cause instanceof Error ? cause.message : String(cause))
446
+ } finally {
447
+ setBusy('')
448
+ }
449
+ }
450
+
451
+ const sendCode = async () => {
452
+ setBusy('send')
453
+ setError('')
454
+ setCodeInfo('')
455
+ try {
456
+ const result = await request('/login/send', { method: 'POST', body: JSON.stringify({ phone }) })
457
+ setMsgid(result.msgid ?? '')
458
+ setCodeInfo(t('codeSent')(result.expire ?? 300))
459
+ } catch (cause) {
460
+ setError(cause instanceof Error ? cause.message : String(cause))
461
+ } finally {
462
+ setBusy('')
463
+ }
464
+ }
465
+
466
+ const verify = async () => {
467
+ setBusy('verify')
468
+ setError('')
469
+ try {
470
+ const result = await request('/login/verify', {
471
+ method: 'POST',
472
+ body: JSON.stringify({ phone, code, msgid, label: newLabel })
473
+ })
474
+ setCode('')
475
+ setMsgid('')
476
+ setCodeInfo('')
477
+ const id = result?.account?.id
478
+ if (typeof id === 'string' && id.length > 0) setActiveId(id)
479
+ await load()
480
+ if (typeof id === 'string') void refreshCredits(id)
481
+ } catch (cause) {
482
+ setError(cause instanceof Error ? cause.message : String(cause))
483
+ } finally {
484
+ setBusy('')
485
+ }
486
+ }
487
+
488
+ const importDesktop = async () => {
489
+ setBusy('import')
490
+ setError('')
491
+ try {
492
+ const result = await request('/login/import', { method: 'POST', body: JSON.stringify({}) })
493
+ const id = result?.account?.id
494
+ if (typeof id === 'string' && id.length > 0) setActiveId(id)
495
+ await load()
496
+ if (typeof id === 'string') void refreshCredits(id)
497
+ } catch (cause) {
498
+ setError(cause instanceof Error ? cause.message : String(cause))
499
+ } finally {
500
+ setBusy('')
501
+ }
502
+ }
503
+
504
+ const claim = async (id) => {
505
+ await act(`claim:${id}`, async () => {
506
+ await request('/credits/claim', { method: 'POST', body: JSON.stringify({ id }) })
507
+ await refreshCredits(id)
508
+ })
509
+ }
510
+
511
+ const logout = async (id) => {
512
+ await act(`logout:${id}`, async () => {
513
+ await request('/logout', { method: 'POST', body: JSON.stringify({ id }) })
514
+ setModelMap((current) => ({ ...current, [id]: undefined }))
515
+ setCreditsMap((current) => ({ ...current, [id]: undefined }))
516
+ })
517
+ }
518
+
519
+ const rename = async (id) => {
520
+ await act(`label:${id}`, async () => {
521
+ await request('/label', { method: 'POST', body: JSON.stringify({ id, label: labelDraft }) })
522
+ })
523
+ }
524
+
525
+ /** Status dot and word for one account. */
526
+ const statusOf = (entry) => {
527
+ if (entry.signedIn !== true) return { text: t('statusExpired'), tone: 'lo-tag-bad' }
528
+ if (entry.expired === true) return { text: t('statusExpired'), tone: 'lo-tag-warn' }
529
+ return { text: t('statusSignedIn'), tone: 'lo-tag-ok' }
530
+ }
531
+
532
+ const accountTabs = () => {
533
+ if (accounts.length === 0) return null
534
+ return h('div', { className: 'lo-tabs', role: 'tablist' },
535
+ accounts.map((entry) => h('button', {
536
+ key: entry.id,
537
+ type: 'button',
538
+ role: 'tab',
539
+ 'aria-selected': entry.id === account?.id,
540
+ className: entry.id === account?.id ? 'lo-tab lo-tab-on' : 'lo-tab',
541
+ onClick: () => setActiveId(entry.id)
542
+ },
543
+ h('span', {
544
+ className: 'lo-dot',
545
+ style: {
546
+ background: entry.expired === true
547
+ ? 'var(--dsw-alias-state-warn-primary, #d89614)'
548
+ : (entry.signedIn === true ? 'var(--dsw-alias-state-success-primary, #3fa45b)' : 'var(--dsw-alias-state-idle-primary, #8a8f98)')
549
+ }
550
+ }),
551
+ entry.label.length > 0 ? entry.label : (entry.phone.length > 0 ? entry.phone : entry.id),
552
+ entry.phone.length > 0 ? h('span', { className: 'lo-tab-sub' }, entry.phone) : null
553
+ ))
554
+ )
555
+ }
556
+
557
+ /** The signed-in account's numbers and actions. */
558
+ const accountDetail = (entry) => {
559
+ const status = statusOf(entry)
560
+ const live = creditsMap[entry.id]
561
+ const summary = live?.credits ?? entry.credits
562
+ const grantedToday = live?.grantedToday === true || entry.grantedToday === true
563
+ return h('div', { className: 'lo-body' },
564
+ h('div', { className: 'lo-grid' },
565
+ field(t('masked'), h('span', { className: 'lo-row' },
566
+ h('span', { className: 'lo-field-value lo-num' }, entry.phone.length > 0 ? entry.phone : t('unknown')),
567
+ tag(status.text, status.tone)
568
+ )),
569
+ field(t('useridLabel'), h('span', { className: 'lo-field-value lo-mono lo-muted' }, entry.userid || t('unknown'))),
570
+ field(t('source'), entry.source === 'desktop' ? t('sourceDesktop') : t('sourceSms')),
571
+ field(t('dailyCredits'), h('span', { className: 'lo-row' },
572
+ h('span', { className: 'lo-big' }, summary === null || summary === undefined ? '—' : formatPoints(summary.dailyBalance)),
573
+ summary === null || summary === undefined || summary.dailyQuota === undefined
574
+ ? null
575
+ : h('span', { className: 'lo-muted lo-num' }, `/ ${formatPoints(summary.dailyQuota)}`)
576
+ )),
577
+ field(t('permanentCredits'), h('span', { className: 'lo-big' },
578
+ summary === null || summary === undefined ? '—' : formatPoints(summary.permanentBalance))),
579
+ field(t('availableCredits'), h('span', { className: 'lo-field-value lo-num' },
580
+ summary === null || summary === undefined ? '—' : formatPoints(summary.availableBalance)))
581
+ ),
582
+ h('div', { className: 'lo-row', style: { margin: '14px 0 16px' } },
583
+ h('button', {
584
+ type: 'button',
585
+ className: 'lo-btn lo-btn-primary',
586
+ disabled: busy === `claim:${entry.id}` || entry.signedIn !== true,
587
+ title: t('claimHint'),
588
+ onClick: () => void claim(entry.id)
589
+ }, busy === `claim:${entry.id}` ? t('claiming') : (grantedToday ? t('claimedToday') : t('claim'))),
590
+ h('button', {
591
+ type: 'button',
592
+ className: 'lo-btn lo-btn-ghost',
593
+ disabled: busy.length > 0 || entry.signedIn !== true,
594
+ onClick: () => void refreshCredits(entry.id)
595
+ }, t('refreshCredits')),
596
+ entry.expired === true
597
+ ? tag(t('expiredAt')(formatTime(entry.expiredAt, t('unknown'))), 'lo-tag-warn', { title: entry.expiredReason })
598
+ : null,
599
+ summary?.error === undefined
600
+ ? null
601
+ : h('span', { className: 'lo-muted' }, summary.error)
602
+ ),
603
+ h('div', { className: 'lo-row', style: { margin: '0 0 16px' } },
604
+ h('input', {
605
+ className: 'lo-input',
606
+ type: 'text',
607
+ value: labelDraft,
608
+ placeholder: t('renamePlaceholder'),
609
+ 'aria-label': t('rename'),
610
+ onChange: (event) => setLabelDraft(event.target.value)
611
+ }),
612
+ h('button', {
613
+ type: 'button',
614
+ className: 'lo-btn',
615
+ disabled: busy === `label:${entry.id}`,
616
+ onClick: () => void rename(entry.id)
617
+ }, busy === `label:${entry.id}` ? t('renaming') : t('rename')),
618
+ h('button', {
619
+ type: 'button',
620
+ className: 'lo-btn lo-btn-danger',
621
+ style: { marginLeft: 'auto' },
622
+ disabled: busy === `logout:${entry.id}`,
623
+ onClick: () => void logout(entry.id)
624
+ }, busy === `logout:${entry.id}` ? t('loggingOut') : t('logout'))
625
+ )
626
+ )
627
+ }
628
+
629
+ /** The model list for the active account. */
630
+ const modelSection = (entry) => {
631
+ const data = modelMap[entry.id]
632
+ const list = Array.isArray(data?.list) ? data.list : []
633
+ return h('div', { className: 'lo-section' },
634
+ h('div', { className: 'lo-section-head' },
635
+ h('span', { className: 'lo-section-title' }, t('models')),
636
+ h('span', { className: 'lo-badge' }, t('modelsCount')(list.length)),
637
+ data?.source === 'cache-stale' || data?.source === 'cache-fresh'
638
+ ? h('span', { className: 'lo-muted' }, t('modelsCached')(formatTime(data.cachedAt, t('unknown'))))
639
+ : null,
640
+ entry.mounted === true ? null : h('span', { className: 'lo-muted' }, t('notMounted')),
641
+ h('button', {
642
+ type: 'button',
643
+ className: 'lo-icon-btn',
644
+ style: { marginLeft: 'auto' },
645
+ title: t('refreshModels'),
646
+ 'aria-label': t('refreshModels'),
647
+ disabled: data?.loading === true,
648
+ onClick: () => void refreshModels(entry.id, true)
649
+ }, data?.loading === true ? '…' : '⟳')
650
+ ),
651
+ entry.signedIn !== true
652
+ ? h('div', { className: 'lo-empty' }, t('modelsHint'))
653
+ : data?.loading === true && list.length === 0
654
+ ? h('div', { className: 'lo-empty' }, t('loading'))
655
+ : data?.error !== undefined
656
+ ? h('div', { className: 'lo-empty' }, t('catalogFailed'))
657
+ : list.length === 0
658
+ ? h('div', { className: 'lo-empty' }, t('emptyModels'))
659
+ : h('div', { className: 'lo-models' }, list.map((model) => h('div', { key: model.id, className: 'lo-model' },
660
+ h('span', { className: 'lo-model-name', title: model.name }, model.name),
661
+ h('span', { className: 'lo-model-id', title: model.id }, model.id),
662
+ h('span', { className: 'lo-model-tail' },
663
+ model.vision ? tag(t('vision'), 'lo-tag-accent') : null,
664
+ model.toolCalls ? tag(t('tools')) : null,
665
+ model.reasoning ? tag(t('reasoning')) : null
666
+ )
667
+ )))
668
+ )
669
+ }
670
+
671
+ /** SMS login plus the desktop import, always visible — it is the way in. */
672
+ const loginSection = () => h('div', { className: 'lo-card' },
673
+ h('div', { className: 'lo-card-head' },
674
+ h('span', { className: 'lo-card-title' }, t('loginTitle')),
675
+ h('div', { className: 'lo-head-right' },
676
+ h('button', {
677
+ type: 'button',
678
+ className: 'lo-btn lo-btn-ghost',
679
+ disabled: busy === 'import',
680
+ title: state?.desktop?.available === true
681
+ ? t('importDesktop')
682
+ : `${t('desktopMissing')} ${t('desktopPaths')(state?.desktop?.paths ?? [])}`,
683
+ onClick: () => void importDesktop()
684
+ }, busy === 'import' ? t('importing') : t('importDesktop'))
685
+ )
686
+ ),
687
+ h('div', { className: 'lo-form' },
688
+ h('label', { className: 'lo-form-item' },
689
+ h('span', { className: 'lo-field-label' }, t('phone')),
690
+ h('input', {
691
+ className: 'lo-input',
692
+ type: 'tel',
693
+ value: phone,
694
+ placeholder: t('phonePlaceholder'),
695
+ onChange: (event) => setPhone(event.target.value)
696
+ })
697
+ ),
698
+ h('button', {
699
+ type: 'button',
700
+ className: 'lo-btn',
701
+ disabled: busy === 'send' || phone.trim().length === 0,
702
+ onClick: () => void sendCode()
703
+ }, busy === 'send' ? t('sending') : t('sendCode')),
704
+ h('label', { className: 'lo-form-item' },
705
+ h('span', { className: 'lo-field-label' }, t('code')),
706
+ h('input', {
707
+ className: 'lo-input',
708
+ type: 'text',
709
+ inputMode: 'numeric',
710
+ value: code,
711
+ placeholder: t('codePlaceholder'),
712
+ onChange: (event) => setCode(event.target.value)
713
+ })
714
+ ),
715
+ h('label', { className: 'lo-form-item' },
716
+ h('span', { className: 'lo-field-label' }, t('labelOptional')),
717
+ h('input', {
718
+ className: 'lo-input',
719
+ type: 'text',
720
+ value: newLabel,
721
+ placeholder: t('labelPlaceholder'),
722
+ onChange: (event) => setNewLabel(event.target.value)
723
+ })
724
+ ),
725
+ h('button', {
726
+ type: 'button',
727
+ className: 'lo-btn lo-btn-primary',
728
+ disabled: busy === 'verify' || phone.trim().length === 0 || code.trim().length === 0,
729
+ onClick: () => void verify()
730
+ }, busy === 'verify' ? t('verifying') : t('verify'))
731
+ ),
732
+ codeInfo.length === 0
733
+ ? null
734
+ : h('div', { className: 'lo-note' }, codeInfo)
735
+ )
736
+
737
+ return h('section', { className: 'lo-root', 'aria-label': t('title') },
738
+ h('style', null, CSS),
739
+ h('h2', { className: 'lo-title' }, t('title')),
740
+ h('p', { className: 'lo-intro' }, t('intro')),
741
+ error.length > 0 ? h('p', { className: 'lo-error', role: 'alert' }, t('failed')(error)) : null,
742
+
743
+ h('div', { className: 'lo-card' },
744
+ h('div', { className: 'lo-card-head' },
745
+ h(Logomark, null),
746
+ h('span', { className: 'lo-card-title' }, t('accounts')),
747
+ state === null ? h('span', { className: 'lo-loading' }, t('loading')) : null,
748
+ h('div', { className: 'lo-head-right' },
749
+ h('span', { className: 'lo-badge' }, accounts.length === 0 ? t('noAccounts') : String(accounts.length)))
750
+ ),
751
+ accountTabs(),
752
+ accounts.length === 0
753
+ ? h('div', { className: 'lo-body' },
754
+ h('div', { className: 'lo-empty' }, t('noAccount')),
755
+ h('div', { className: 'lo-empty lo-muted' }, t('noAccountHint')))
756
+ : (account === undefined ? null : accountDetail(account))
757
+ ),
758
+
759
+ account === undefined || account.signedIn !== true ? null : h('div', { className: 'lo-card' }, modelSection(account)),
760
+
761
+ loginSection(),
762
+
763
+ h('div', { className: 'lo-card' },
764
+ h('div', { className: 'lo-foot' },
765
+ h('span', null, `${t('gateway')}:${state?.gateway ?? '—'}`),
766
+ h('span', null, `${t('version')}:${state?.version ?? '—'}`),
767
+ state?.defaultModel === undefined || state.defaultModel.length === 0
768
+ ? null
769
+ : h('span', null, `${t('defaultModel')}:${state.defaultModel}`),
770
+ h('span', null, `${localDayKey()}`)
771
+ )
772
+ )
773
+ )
774
+ }
775
+
776
+ /**
777
+ * The composer pill's own stylesheet, rendered with the pill itself.
778
+ *
779
+ * The settings card's <style> only exists while that page is mounted, and the pill
780
+ * lives on the conversation screen — so the pill carries its stylesheet with it.
781
+ * The --lo-* custom properties are re-declared on .lo-anchor for the same reason:
782
+ * their definitions on .lo-root do not reach this subtree.
783
+ */
784
+ const PILL_CSS = `
785
+ .lo-anchor { --lo-accent: #2f6bd8; --lo-accent-strong: #2456b0; --lo-accent-soft: rgba(47,107,216,.12);
786
+ --lo-line: var(--dsw-alias-border-l1, rgba(128,138,150,.28));
787
+ --lo-line-soft: var(--dsw-alias-border-l2, rgba(128,138,150,.16));
788
+ --lo-muted: var(--dsw-alias-label-secondary, #9aa1ab);
789
+ --lo-text: var(--dsw-alias-label-primary, #e6e8eb);
790
+ --lo-surface: var(--dsw-alias-bg-layer-1, rgba(128,138,150,.04));
791
+ --lo-surface-hover: var(--dsw-alias-bg-layer-2, rgba(128,138,150,.09));
792
+ position: relative; display: inline-flex; }
793
+ .lo-pill { display: inline-flex; align-items: center; gap: 6px; height: 26px; padding: 0 10px; border-radius: 999px;
794
+ border: 1px solid var(--lo-line); background: var(--lo-surface); color: var(--lo-text);
795
+ font: inherit; font-size: 12px; line-height: 1; cursor: pointer; white-space: nowrap;
796
+ transition: background .15s, border-color .15s; }
797
+ .lo-pill:hover { background: var(--lo-surface-hover); border-color: var(--lo-accent); }
798
+ .lo-pill:focus-visible { outline: 2px solid var(--lo-accent); outline-offset: 1px; }
799
+ .lo-pill-dot { flex: none; width: 7px; height: 7px; border-radius: 50%; }
800
+ .lo-pill-prefix { color: var(--lo-muted); }
801
+ .lo-pill-text { font-variant-numeric: tabular-nums; }
802
+ .lo-pill-text-bad { color: #e5484d; }
803
+ .lo-pill-stale { color: var(--lo-muted); }
804
+ .lo-pop { position: absolute; right: 0; bottom: calc(100% + 8px); z-index: 30; width: 264px; padding: 12px 14px;
805
+ border: 1px solid var(--lo-line); border-radius: 12px; background: var(--dsw-alias-bg-layer-1, #1c1e22);
806
+ box-shadow: 0 8px 28px rgba(0,0,0,.28); color: var(--lo-text); font-size: 12.5px; line-height: 1.6; }
807
+ .lo-pop-head { display: flex; align-items: baseline; gap: 8px; margin-bottom: 6px; }
808
+ .lo-pop-title { font-weight: 600; }
809
+ .lo-pop-stale { font-size: 11px; color: var(--lo-muted); }
810
+ .lo-pop-row { display: flex; justify-content: space-between; gap: 12px; padding: 5px 0; }
811
+ .lo-pop-row + .lo-pop-row { border-top: 1px solid var(--lo-line-soft); }
812
+ .lo-pop-label { color: var(--lo-muted); min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
813
+ .lo-pop-value { font-variant-numeric: tabular-nums; white-space: nowrap; }
814
+ .lo-pop-note { margin-top: 6px; font-size: 11.5px; color: var(--lo-muted); overflow-wrap: anywhere; }
815
+ `
816
+
817
+ /** How often the pill re-reads /state, and how many silent cycles make it stale. */
818
+ const PILL_CADENCE_MS = 30_000
819
+ const PILL_STALE_CADENCES = 3
820
+
821
+ /**
822
+ * The provider id the conversation currently points at, or undefined when neither
823
+ * side of a model switch carries one. An empty string counts as "nothing": reading
824
+ * it as a choice would hide the pill for one frame in the middle of switching.
825
+ */
826
+ const selectedProviderOf = (selection) => {
827
+ const providerOf = (candidate) => {
828
+ const provider = candidate?.provider
829
+ return typeof provider === 'string' && provider.length > 0 ? provider : undefined
830
+ }
831
+ return providerOf(selection?.next) ?? providerOf(selection?.lastUsed)
832
+ }
833
+
834
+ /**
835
+ * Poll /state while mounted: one read, then a self-chained timer so a slow answer
836
+ * never overlaps the next one. Failures keep the last reading on screen (only the
837
+ * wording and the staleness mark change) — a transient error must not blank a pill
838
+ * the user is reading. /state costs no upstream round trip, so polling it is free.
839
+ */
840
+ function useLoomyState(cadenceMs) {
841
+ const [data, setData] = React.useState(null)
842
+ const [error, setError] = React.useState(null)
843
+ const [loadedOnce, setLoadedOnce] = React.useState(false)
844
+ const [updatedAt, setUpdatedAt] = React.useState(0)
845
+ React.useEffect(() => {
846
+ let alive = true
847
+ let timer = null
848
+ const stop = () => {
849
+ alive = false
850
+ if (timer !== null) clearTimeout(timer)
851
+ }
852
+ const read = async () => {
853
+ try {
854
+ const next = await request('/state')
855
+ if (!alive) return
856
+ setData(next)
857
+ setError(null)
858
+ setUpdatedAt(Date.now())
859
+ } catch (cause) {
860
+ if (!alive) return
861
+ setError(cause instanceof Error ? cause.message : String(cause))
862
+ } finally {
863
+ if (alive) {
864
+ setLoadedOnce(true)
865
+ timer = setTimeout(() => void read(), cadenceMs)
866
+ }
867
+ }
868
+ }
869
+ void read()
870
+ return stop
871
+ }, [cadenceMs])
872
+ return { data, error, loadedOnce, updatedAt }
873
+ }
874
+
875
+ /**
876
+ * The pill's reading from one /state answer, summed over signed-in accounts.
877
+ *
878
+ * The credits behind /state are the grant ledger, not a live upstream read; the
879
+ * pill presents that as the balance and lets the settings card do a live refresh.
880
+ * `readable` is false when no signed-in account has been granted yet — the pill
881
+ * prints "—" rather than a sum of zeros, which would read as "used up".
882
+ */
883
+ const readingOf = (accounts) => {
884
+ const list = Array.isArray(accounts) ? accounts.filter((entry) => entry?.signedIn === true) : []
885
+ if (list.length === 0) return { kind: 'no-account' }
886
+ const sumOf = (pick) => list.reduce((sum, entry) => {
887
+ const value = pick(entry)
888
+ return typeof value === 'number' && Number.isFinite(value) ? sum + value : sum
889
+ }, 0)
890
+ const readable = list.some((entry) => entry.credits !== null && entry.credits !== undefined)
891
+ return {
892
+ kind: 'reading',
893
+ daily: sumOf((entry) => entry.credits?.dailyBalance),
894
+ quota: sumOf((entry) => entry.credits?.dailyQuota),
895
+ available: sumOf((entry) => entry.credits?.availableBalance),
896
+ readable,
897
+ claimedToday: list.every((entry) => entry.grantedToday === true),
898
+ accounts: list
899
+ }
900
+ }
901
+
902
+ /** Traffic light: the daily pot is what runs out, so it drives the colour. */
903
+ const tierOf = (reading) => {
904
+ if (reading.kind !== 'reading' || reading.readable !== true) return 'unknown'
905
+ return reading.daily > 0 ? 'ok' : 'exhausted'
906
+ }
907
+
908
+ const TIER_DOT = {
909
+ ok: 'var(--dsw-alias-state-success-primary, #3fa45b)',
910
+ exhausted: 'var(--dsw-alias-state-error-primary, #e5484d)',
911
+ unknown: 'var(--dsw-alias-state-idle-primary, #8a8f98)'
912
+ }
913
+
914
+ /** The popover: one row per signed-in account, plus the notes the pill omits. */
915
+ function PillPopover({ t, reading, stale, error, updatedAt }) {
916
+ return h('div', { className: 'lo-pop', role: 'dialog', 'aria-label': t('pillTitle') },
917
+ h('style', null, PILL_CSS),
918
+ h('div', { className: 'lo-pop-head' },
919
+ h('span', { className: 'lo-pop-title' }, t('pillTitle')),
920
+ stale ? h('span', { className: 'lo-pop-stale' }, t('pillStale')) : null),
921
+ reading.accounts.map((entry) => h('div', { className: 'lo-pop-row', key: entry.id },
922
+ h('span', { className: 'lo-pop-label', title: entry.label || entry.phone || entry.id },
923
+ entry.label.length > 0 ? entry.label : (entry.phone.length > 0 ? entry.phone : entry.id)),
924
+ h('span', { className: 'lo-pop-value' },
925
+ `${formatPoints(entry.credits?.dailyBalance)} / ${formatPoints(entry.credits?.dailyQuota)}` +
926
+ ` · ${t('pillAvailable')} ${formatPoints(entry.credits?.availableBalance)}`))),
927
+ error !== null && error !== undefined && error !== ''
928
+ ? h('div', { className: 'lo-pop-note' }, t('pillError')(error))
929
+ : null,
930
+ updatedAt > 0 ? h('div', { className: 'lo-pop-note' }, t('pillFetched')(new Date(updatedAt).toLocaleTimeString())) : null,
931
+ h('div', { className: 'lo-pop-note' }, t('pillNote')))
932
+ }
933
+
934
+ /**
935
+ * The pill body, mounted only while a Loomy model is selected. `open` lives here
936
+ * rather than in the outer gate: the gate must call `useProjection` unconditionally
937
+ * and may return null, so any hook added there would run in a different order on
938
+ * the frames where the pill is hidden versus shown.
939
+ */
940
+ function LoomyPillBody({ t }) {
941
+ const { data, error, loadedOnce, updatedAt } = useLoomyState(PILL_CADENCE_MS)
942
+ const [open, setOpen] = React.useState(false)
943
+ const anchorRef = React.useRef(null)
944
+ React.useEffect(() => {
945
+ if (!open) return
946
+ // pointerdown (not click): by the time click fires, the toggle has already run,
947
+ // so a listener that counts the trigger as "inside" must capture pointerdown
948
+ // or the popover would close and reopen in one gesture.
949
+ const onPointerDown = (event) => {
950
+ const anchor = anchorRef.current
951
+ if (anchor && typeof anchor.contains === 'function' && anchor.contains(event.target)) return
952
+ setOpen(false)
953
+ }
954
+ const onKeyDown = (event) => {
955
+ if (event.key === 'Escape') setOpen(false)
956
+ }
957
+ document.addEventListener('pointerdown', onPointerDown, true)
958
+ document.addEventListener('keydown', onKeyDown)
959
+ return () => {
960
+ document.removeEventListener('pointerdown', onPointerDown, true)
961
+ document.removeEventListener('keydown', onKeyDown)
962
+ }
963
+ }, [open])
964
+
965
+ const reading = readingOf(data?.accounts)
966
+ const pending = !loadedOnce && data === null
967
+ const stale = reading.kind === 'reading' && updatedAt > 0 &&
968
+ Date.now() - updatedAt > PILL_CADENCE_MS * PILL_STALE_CADENCES
969
+ const tier = tierOf(reading)
970
+ const title = [t('pillTitle'),
971
+ error !== null && error !== undefined && error !== '' ? t('pillError')(error) : null,
972
+ stale ? t('pillStale') : null].filter(Boolean).join(' · ')
973
+
974
+ let text
975
+ if (pending) text = '…'
976
+ else if (reading.kind === 'no-account') text = t('pillNoAccount')
977
+ else if (reading.readable !== true) text = '—'
978
+ else text = `${formatPoints(reading.daily)}${reading.quota > 0 ? ` / ${formatPoints(reading.quota)}` : ''}` +
979
+ ` · ${t('pillAvailable')} ${formatPoints(reading.available)}`
980
+
981
+ return h('span', { className: 'lo-anchor', ref: anchorRef },
982
+ h('style', null, PILL_CSS),
983
+ h('button', {
984
+ type: 'button',
985
+ className: 'lo-pill',
986
+ title,
987
+ 'aria-label': title,
988
+ 'aria-expanded': open,
989
+ 'aria-haspopup': 'dialog',
990
+ onClick: () => setOpen((current) => !current)
991
+ },
992
+ h('span', { className: 'lo-pill-dot', 'aria-hidden': 'true', style: { background: TIER_DOT[tier] } }),
993
+ reading.kind === 'reading' && reading.readable === true
994
+ ? h('span', { className: 'lo-pill-prefix' }, `${t('pillPrefix')} · `)
995
+ : null,
996
+ h('span', {
997
+ className: tier === 'exhausted' && reading.readable === true ? 'lo-pill-text lo-pill-text-bad' : 'lo-pill-text'
998
+ }, text),
999
+ reading.kind === 'reading' && reading.readable === true
1000
+ ? h('span', { className: 'lo-pill-prefix' }, reading.claimedToday ? ` · ${t('pillClaimed')}` : ` · ${t('pillNotClaimed')}`)
1001
+ : null,
1002
+ stale ? h('span', { className: 'lo-pill-stale' }, ` · ${t('pillStale')}`) : null),
1003
+ open ? h(PillPopover, { t, reading, stale, error, updatedAt }) : null)
1004
+ }
1005
+
1006
+ /**
1007
+ * The gate: calls `useProjection` exactly once per render, unconditionally, and
1008
+ * mounts the pill only when the conversation points at a Loomy account. Any other
1009
+ * provider renders nothing and starts nothing — the polling hook lives inside the
1010
+ * pill body, so a hidden pill also makes no requests.
1011
+ */
1012
+ function LoomyComposerPill(props) {
1013
+ const provider = selectedProviderOf(props.useProjection?.('modelSelection'))
1014
+ if (typeof provider !== 'string' || !provider.startsWith('@loomy/dsh-loomy/')) return null
1015
+ return h(LoomyPillBody, { t: props.t })
1016
+ }
1017
+
1018
+ return {
1019
+ inject: ['slots'],
1020
+ apply(ctx) {
1021
+ // A throwing apply() leaves the slot silently empty, so every optional client
1022
+ // service is probed and the whole registration is contained; the failure is
1023
+ // reported to the host log through the same beacon as the load stages.
1024
+ try {
1025
+ const get = typeof ctx.get === 'function' ? ctx.get.bind(ctx) : () => undefined
1026
+ const locale = get('locale')
1027
+
1028
+ /**
1029
+ * Translate through the host's locale service, falling back to this bundle's
1030
+ * own copy.
1031
+ *
1032
+ * The service keeps the dictionary it was first given, so a hot-reloaded client
1033
+ * asking for a key added since then gets the key itself back. Falling back here
1034
+ * keeps new strings working without an app restart.
1035
+ */
1036
+ const localise = (localeId, key) => dictFor(localeId)[key]
1037
+ const boundT = locale !== undefined && typeof locale.bind === 'function' ? locale.bind('loomy') : null
1038
+ const currentLocale = locale !== undefined && typeof locale.getLocale === 'function'
1039
+ ? String(locale.getLocale().locale ?? 'zh-CN')
1040
+ : 'zh-CN'
1041
+ const t = (key) => {
1042
+ if (boundT !== null) {
1043
+ const fromService = boundT(key)
1044
+ if (fromService !== undefined && fromService !== key) return fromService
1045
+ }
1046
+ const local = localise(currentLocale, key)
1047
+ return local === undefined ? key : local
1048
+ }
1049
+
1050
+ if (locale !== undefined && typeof locale.register === 'function' && typeof locale.getLocale === 'function') {
1051
+ const registered = []
1052
+ for (const entry of locale.getLocale().locales) {
1053
+ registered.push(locale.register('loomy', entry.id, dictFor(entry.id)))
1054
+ }
1055
+ if (typeof ctx.effect === 'function') {
1056
+ ctx.effect(() => () => {
1057
+ for (const dispose of registered) dispose()
1058
+ }, 'dsh-loomy: locale dictionaries')
1059
+ }
1060
+ }
1061
+
1062
+ ctx.slots.inject('settings.section', () => ctx.slots.register({
1063
+ name: 'settings.section',
1064
+ id: 'loomy',
1065
+ order: 16,
1066
+ label: () => t('section')
1067
+ }, (props) => h(LoomySettings, { ...props, t })))
1068
+
1069
+ /**
1070
+ * The composer pill, next to the model picker. Only mounted per-conversation
1071
+ * when a Loomy model is selected — see LoomyComposerPill's gate. A failed
1072
+ * registration is reported but must not take the settings card down with it.
1073
+ */
1074
+ try {
1075
+ ctx.slots.inject('conversation.input.right', () => ctx.slots.register({
1076
+ name: 'conversation.input.right',
1077
+ id: 'loomy-composer-pill',
1078
+ order: 4,
1079
+ inject: () => ({ t })
1080
+ }, LoomyComposerPill))
1081
+ } catch (failure) {
1082
+ report(`pill-register-failed: ${failure instanceof Error ? failure.message : String(failure)}`)
1083
+ }
1084
+
1085
+ report('applied')
1086
+ } catch (failure) {
1087
+ report(`apply-failed: ${failure instanceof Error ? failure.message : String(failure)}`)
1088
+ }
1089
+ }
1090
+ }
1091
+ }
1092
+ })