dsh-model 0.20.1 → 0.21.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.
@@ -1,4 +1,4 @@
1
- // checkin/index.ts — 每日签到(设计 §14.15):WorkBuddy 国内版、Qoder;以后加 Trae、WPS 灵犀
1
+ // checkin/index.ts — 每日签到(设计 §14.15):WorkBuddy 国内版、Qoder、Trae;以后加 WPS 灵犀
2
2
  //
3
3
  // 「今天」按北京时间算:这几家都在北京时间零点换天(VPS 的系统时间是 UTC)。
4
4
  // 自动签到默认打开:守护进程每 15 分钟看一眼,过了当天随机的几分钟后签一次;失败的隔一小时再试;错过了下次开机补。
@@ -22,6 +22,8 @@ export function todayResults(file, now = new Date()) {
22
22
  /** 失败原因 → 该怎么办 */
23
23
  export function checkinAdvice(id, message) {
24
24
  if (/无效|过期|401|403|unauthor|expired/i.test(message)) {
25
+ if (id === 'trae')
26
+ return L('Trae 登录过期:打开 Trae 重新登录一次', 'Trae sign-in expired: open Trae and sign in again');
25
27
  return id === 'qoder' ? L('Qoder 个人访问令牌失效:在签到卡片里重新填一个', 'The Qoder access token no longer works: enter a new one in the check-in card') : L('WorkBuddy 登录过期:在来源里重新登录 WorkBuddy', 'WorkBuddy sign-in expired: sign in to WorkBuddy again under Sources');
26
28
  }
27
29
  if (/timeout|timed out|fetch failed|network|ECONN/i.test(message))
@@ -0,0 +1,147 @@
1
+ // checkin/trae.ts — Trae(国内版 / SOLO)每日签到
2
+ //
3
+ // 登录信息复用本机已登录的 Trae 桌面 App 或 CLI(只读,不落盘,只在内存里用):
4
+ // 1) 桌面 App:<应用数据>/{TRAE SOLO CN | Trae CN | Trae | TRAE SOLO}/User/globalStorage/storage.json
5
+ // 里的 iCubeAuthInfo://icube.cloudide(加密信封)
6
+ // 2) CLI:~/.trae-cn/trae-jwt-token、~/.trae/trae-jwt-token
7
+ // 解密算法、接口与请求头照 G57651/dsh-dual-checkin 的 trae 适配器(MIT),它又照 dsh-connect-trae 的 decrypt.ts。
8
+ // 2026-10-14 在 Trae CN 3.4.2 上核对:存储位置和格式没变。
9
+ // POST https://api.trae.cn/trae/api/v2/ug/checkin_credits/status 今天签没签、能领多少
10
+ // POST https://api.trae.cn/trae/api/v2/ug/checkin_credits/claim 签到
11
+ import { createDecipheriv, createHash, randomUUID } from 'node:crypto';
12
+ import { readFile } from 'node:fs/promises';
13
+ import { homedir } from 'node:os';
14
+ import { join } from 'node:path';
15
+ const API = 'https://api.trae.cn';
16
+ const AUTH_KEY = 'iCubeAuthInfo://icube.cloudide';
17
+ const DEVICE_PREFIX = 'iCubeAuthInfo://icube-dc:';
18
+ const APPS = ['TRAE SOLO CN', 'Trae CN', 'Trae', 'TRAE SOLO'];
19
+ const SALT_A = Uint8Array.from([82, 9, 106, 213, 48, 54, 165, 56, 191, 64, 163, 158, 129, 243, 215, 251, 124, 227, 57, 130, 155, 47, 255, 135, 52, 142, 67, 68, 196, 222, 233, 203, 84, 123, 148, 50, 166, 194, 35, 61, 238, 76, 149, 11, 66, 250, 195, 78, 8, 46, 161, 102, 40, 217, 36, 178, 118, 91, 162, 73, 109, 139, 209, 37]);
20
+ const SALT_B = Uint8Array.from([31, 221, 168, 51, 136, 7, 199, 49, 177, 18, 16, 89, 39, 128, 236, 95, 96, 81, 127, 169, 25, 181, 74, 13, 45, 229, 122, 159, 147, 201, 156, 239, 160, 224, 59, 77, 174, 42, 245, 176, 200, 235, 187, 60, 131, 83, 153, 97, 23, 43, 4, 126, 186, 119, 214, 38, 225, 105, 20, 99, 85, 33, 12, 125]);
21
+ const SALT_C = Uint8Array.from([191, 192, 216, 250, 122, 246, 220, 97, 31, 254, 98, 27, 8, 72, 71, 176, 135, 99, 96, 18, 127, 101, 203, 104, 211, 102, 191, 125, 37, 72, 150, 156, 51, 229, 121, 35, 17, 153, 141, 177, 110, 131, 150, 128, 172, 255, 254, 6, 18, 140, 55, 62, 236, 249, 135, 64, 135, 12, 117, 4, 89, 149, 168, 209]);
22
+ const SALT_D = Uint8Array.from([246, 204, 26, 232, 232, 70, 129, 109, 223, 146, 169, 242, 23, 241, 105, 145, 50, 196, 165, 42, 254, 120, 3, 54, 244, 207, 209, 85, 53, 6, 138, 106, 175, 148, 31, 204, 186, 186, 165, 182, 87, 142, 49, 10, 39, 110, 26, 154, 86, 56, 173, 125, 18, 64, 198, 225, 99, 99, 83, 82, 191, 134, 76, 170]);
23
+ const xor = (a, b) => Buffer.from(a.map((v, i) => v ^ (b[i] ?? 0)));
24
+ /** 解开 storage.json 里的加密信封。导出给测试 */
25
+ export function decryptStorageValue(encoded) {
26
+ const buf = Buffer.from(encoded, 'base64');
27
+ if (buf.length <= 102)
28
+ throw new Error('Trae 登录信息太短');
29
+ const header = buf.subarray(0, 6);
30
+ const kind = header.equals(Buffer.from([116, 99, 5, 16, 0, 0])) ? 'aes' : header.equals(Buffer.from([18, 57, 32, 32, 2, 3])) ? 'aes-private' : null;
31
+ if (!kind)
32
+ throw new Error('Trae 登录信息的加密格式不认识(Trae 可能又改版了)');
33
+ const salt = kind === 'aes-private' ? xor(SALT_C, SALT_D) : xor(SALT_A, SALT_B);
34
+ const first = createHash('sha512').update(buf.subarray(6, 38)).digest();
35
+ const derived = createHash('sha512').update(Buffer.concat([first, salt])).digest();
36
+ const decipher = createDecipheriv('aes-128-cbc', derived.subarray(0, 16), derived.subarray(16, 32));
37
+ const out = Buffer.concat([decipher.update(buf.subarray(38)), decipher.final()]);
38
+ if (out.length < 64)
39
+ throw new Error('Trae 登录信息解开后太短');
40
+ const plain = out.subarray(64);
41
+ if (!out.subarray(0, 64).equals(createHash('sha512').update(plain).digest()))
42
+ throw new Error('Trae 登录信息校验不通过');
43
+ return plain.toString('utf8');
44
+ }
45
+ /** 各平台的应用数据目录 */
46
+ function appDataRoots(home, platform = process.platform, env = process.env) {
47
+ if (platform === 'darwin')
48
+ return [join(home, 'Library', 'Application Support')];
49
+ if (platform === 'win32')
50
+ return [...new Set([env.APPDATA, join(home, 'AppData', 'Roaming')].filter((v) => Boolean(v)))];
51
+ return [env.XDG_CONFIG_HOME || join(home, '.config')];
52
+ }
53
+ /** 找本机的 Trae 登录:先桌面 App,再 CLI。找不到返回 null(不报错:没装 Trae 就不显示) */
54
+ export async function findTraeCredential(home = homedir()) {
55
+ // 测试环境不看本机真装了什么(与不发现桌面 App 同一个开关)
56
+ if (process.env.DSH_MODEL_NO_APP_DISCOVERY && home === homedir())
57
+ return null;
58
+ for (const root of appDataRoots(home)) {
59
+ for (const app of APPS) {
60
+ const file = join(root, app, 'User', 'globalStorage', 'storage.json');
61
+ let storage;
62
+ try {
63
+ storage = JSON.parse(await readFile(file, 'utf8'));
64
+ }
65
+ catch {
66
+ continue;
67
+ }
68
+ const raw = storage[AUTH_KEY];
69
+ if (typeof raw !== 'string' || !raw.trim())
70
+ continue;
71
+ const text = raw.trim().startsWith('{') ? raw : decryptStorageValue(raw);
72
+ const auth = JSON.parse(text);
73
+ const token = auth.token || auth.accessToken;
74
+ if (!token)
75
+ continue;
76
+ const dc = Object.keys(storage).find((k) => k.startsWith(DEVICE_PREFIX));
77
+ return { token, deviceId: dc ? dc.slice(DEVICE_PREFIX.length) : String(storage['telemetry.devDeviceId'] ?? ''), source: app };
78
+ }
79
+ }
80
+ for (const dir of ['.trae-cn', '.trae']) {
81
+ try {
82
+ const text = (await readFile(join(home, dir, 'trae-jwt-token'), 'utf8')).trim();
83
+ const token = text.startsWith('{') ? String(JSON.parse(text).token ?? '') : text;
84
+ if (token.split('.').length === 3)
85
+ return { token, deviceId: '', source: `~/${dir}` };
86
+ }
87
+ catch {
88
+ // 没有
89
+ }
90
+ }
91
+ return null;
92
+ }
93
+ /** 令牌还剩几天(读 JWT 的 exp);读不出来返回 null */
94
+ export function tokenDaysLeft(token, now = Date.now()) {
95
+ try {
96
+ const exp = JSON.parse(Buffer.from(token.split('.')[1] ?? '', 'base64url').toString()).exp;
97
+ return typeof exp === 'number' ? Math.floor((exp * 1000 - now) / 86_400_000) : null;
98
+ }
99
+ catch {
100
+ return null;
101
+ }
102
+ }
103
+ function headers(c) {
104
+ return {
105
+ authorization: `Cloud-IDE-JWT ${c.token}`,
106
+ 'content-type': 'application/json',
107
+ 'user-agent': 'VSCode 1.107.1 (TRAE SOLO CN)',
108
+ 'x-user-region': 'CN',
109
+ 'package-type': 'stable_cn',
110
+ 'x-lscbd-aid': '787976',
111
+ 'x-lscbd-platform': 'windows',
112
+ 'app-version': '0.1.51',
113
+ accept: '*/*',
114
+ 'x-request-id': randomUUID().replace(/-/g, ''),
115
+ 'x-device-brand': 'To be filled by O.E.M.',
116
+ 'x-device-type': 'windows',
117
+ 'x-lgw-req-sdk-type': '3',
118
+ ...(c.deviceId ? { 'x-device-id': c.deviceId } : {}),
119
+ };
120
+ }
121
+ async function post(c, path) {
122
+ const res = await fetch(`${API}${path}`, { method: 'POST', headers: headers(c), body: '{}', signal: AbortSignal.timeout(20_000) });
123
+ const text = await res.text();
124
+ if (res.status === 401 || res.status === 403)
125
+ throw new Error(`Trae 登录无效或已过期(HTTP ${res.status})`);
126
+ if (!res.ok)
127
+ throw new Error(`Trae HTTP ${res.status}: ${text.slice(0, 160)}`);
128
+ return text ? JSON.parse(text) : {};
129
+ }
130
+ /** 今天签没签、签到送多少 */
131
+ export async function traeStatus(c) {
132
+ const r = await post(c, '/trae/api/v2/ug/checkin_credits/status');
133
+ if (r.code && r.code !== 0)
134
+ throw new Error(`Trae 查询签到状态失败(code ${String(r.code)}:${String(r.message ?? '')})${/1001|authent/i.test(String(r.message ?? r.code)) ? ',令牌可能过期,请重新登录 Trae' : ''}`);
135
+ const d = (typeof r.data === 'object' && r.data !== null ? r.data : r);
136
+ const credits = (typeof d.credits === 'number' ? d.credits : 0) + (typeof d.extra_credits === 'number' ? d.extra_credits : 0);
137
+ return { checkedIn: d.checked_in === true, ...(credits ? { credits } : {}) };
138
+ }
139
+ export async function traeCheckin(c) {
140
+ const st = await traeStatus(c);
141
+ if (st.checkedIn)
142
+ return { state: 'already' };
143
+ const r = await post(c, '/trae/api/v2/ug/checkin_credits/claim');
144
+ if (r.code !== undefined && r.code !== 0)
145
+ throw new Error(`Trae 签到失败(code ${String(r.code)}:${String(r.message ?? '')})`);
146
+ return { state: 'signed', ...(st.credits ? { credit: st.credits } : {}) };
147
+ }
@@ -18,6 +18,7 @@ import { spawnDetached } from '../util/detach.js';
18
18
  import { classify, recentFailures } from './failures.js';
19
19
  import { autoDue, beijingNow, checkinAdvice, ensureJitter, loadCheckin, runCheckin, setAuto, todayResults } from '../checkin/index.js';
20
20
  import { QODER_REGIONS, exchangePat, qoderCheckin } from '../checkin/qoder.js';
21
+ import { findTraeCredential, traeCheckin } from '../checkin/trae.js';
21
22
  import { PKG_ROOT, pkgVersion } from '../util/pkg.js';
22
23
  import { randomBytes } from 'node:crypto';
23
24
  import { currentBinary } from '../engine/install.js';
@@ -725,6 +726,10 @@ export class Daemon {
725
726
  const q = (await loadSecrets(this.ctx)).checkin?.qoder;
726
727
  if (q?.pat)
727
728
  out.push({ id: 'qoder', label: QODER_REGIONS[q.region].label, run: () => qoderCheckin(q.pat, q.region) });
729
+ // Trae:本机装了并登录过 Trae 才有(读它自己的登录信息,不需要另外设置;VPS 上一般没有)
730
+ const trae = await findTraeCredential().catch(() => null);
731
+ if (trae)
732
+ out.push({ id: 'trae', label: 'Trae', run: async () => traeCheckin((await findTraeCredential()) ?? trae) });
728
733
  return out;
729
734
  }
730
735
  async checkinStatus() {
@@ -1036,8 +1041,11 @@ export class Daemon {
1036
1041
  await this.removeQoderPat();
1037
1042
  return ok(await this.checkinStatus());
1038
1043
  }
1039
- if (method === 'GET' && path === '/failures')
1040
- return ok(await recentFailures(this.ctx.paths.logs));
1044
+ if (method === 'GET' && path === '/failures') {
1045
+ // 失败之后同一个模型又成功调用过(例如限流过一会儿就好了):已经自己恢复,不再显示
1046
+ const list = await recentFailures(this.ctx.paths.logs);
1047
+ return ok(list.filter((f) => !f.model || !((this.stats.lastSuccessAt(f.model) ?? 0) > Date.parse(f.at))));
1048
+ }
1041
1049
  if (method === 'POST' && path === '/models/test')
1042
1050
  return ok(await this.testModel(String(b.model ?? '')));
1043
1051
  if (method === 'POST' && path === '/self/update')
@@ -66,6 +66,14 @@ export class Stats {
66
66
  this.recs = (saved?.recs ?? []).filter((r) => Date.now() - r.t < DAY_MS);
67
67
  }
68
68
  /** 收进一批用量记录;keyName 把客户端 key 映射成名字(未知的记为 unknown) */
69
+ /** 这个模型最近一次成功调用的时间(毫秒);24 小时内没成功过返回 undefined */
70
+ lastSuccessAt(model) {
71
+ let last;
72
+ for (const r of this.recs)
73
+ if (r.m === model && r.f === 0 && (last === undefined || r.t > last))
74
+ last = r.t;
75
+ return last;
76
+ }
69
77
  add(batch, keyName) {
70
78
  for (const r of batch) {
71
79
  const t = Date.parse(r.timestamp) || Date.now();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-model",
3
- "version": "0.20.1",
3
+ "version": "0.21.1",
4
4
  "description": "Many model sources behind one authenticated OpenAI-compatible endpoint for DeepSeek Harness (dsh) and your tools: WorkBuddy, OpenCode Zen and your CLI subscriptions (Codex, Kimi…), with a management page inside dsh. 多家模型汇成一个 OpenAI 兼容地址,dsh 和其他软件都能直接用。",
5
5
  "keywords": [
6
6
  "dsh-plugin",
package/plugin/client.js CHANGED
@@ -540,23 +540,52 @@ window.__ModuleLoader__.load({
540
540
  h('button', { type: 'button', style: S.btnPrimary, disabled: !name.trim(), onClick: add }, L('新增 key', 'Add key'))))
541
541
  }
542
542
 
543
- /** 最近 24 小时失败的请求:哪个模型、为什么、该怎么办(只有出过错才显示) */
543
+ /**
544
+ * 最近失败的请求:平时一行"最近 24 小时失败 N 次(多是…)▸",点开看明细;「知道了」之后这一批不再显示,有新的失败才重新出现。
545
+ * 已经自己恢复的(同一个模型之后又成功过)守护进程那边就去掉了。
546
+ */
547
+ const FAIL_ACK = 'dsh-model.failuresSeenAt'
548
+ const readAck = () => {
549
+ try { return Number(window.localStorage.getItem(FAIL_ACK)) || 0 } catch { return 0 }
550
+ }
551
+ const KIND_LABEL = {
552
+ rate: () => L('限流', 'rate limits'),
553
+ quota: () => L('额度或套餐不足', 'quota or plan'),
554
+ auth: () => L('登录或 key 失效', 'sign-in or key'),
555
+ model: () => L('模型不存在', 'missing models'),
556
+ upstream: () => L('上游或网络问题', 'upstream or network'),
557
+ other: () => L('其他原因', 'other causes'),
558
+ }
544
559
  function FailuresCard({ failures }) {
545
- const [all, setAll] = useState(false)
546
- if (!failures || !failures.length) return null
560
+ const [open, setOpen] = useState(false)
561
+ const [ack, setAck] = useState(readAck)
562
+ const fresh = (failures || []).filter((f) => Date.parse(f.at) > ack)
563
+ if (!fresh.length) return null
564
+ const counts = {}
565
+ for (const f of fresh) counts[f.kind] = (counts[f.kind] || 0) + 1
566
+ const top = Object.keys(counts).sort((a, b) => counts[b] - counts[a])[0]
547
567
  const color = (k) => (k === 'rate' || k === 'upstream' ? C.warn : C.err)
548
- const shown = all ? failures : failures.slice(0, 5)
568
+ const dismiss = () => {
569
+ const latest = Math.max(...fresh.map((f) => Date.parse(f.at)))
570
+ setAck(latest)
571
+ setOpen(false)
572
+ try { window.localStorage.setItem(FAIL_ACK, String(latest)) } catch { /* 无痕模式:只在本次生效 */ }
573
+ }
549
574
  return h('div', { style: S.section },
550
- h('div', { style: S.h }, L(`最近失败(24 小时内 ${failures.length} 次)`, `Recent failures (${failures.length} in 24 hours)`)),
551
- h('p', { style: S.note }, L('经统一端点失败的请求。只记录模型和报错,不记录对话内容。', 'Requests that failed through the endpoint. Only the model and the error are kept, never the conversation.')),
552
- h('div', { style: S.card }, shown.map((f, i) => h('div', { key: `${f.at}-${i}`, style: i ? S.row : S.rowFirst },
575
+ h('div', { style: { ...S.card, padding: '10px 14px' } },
553
576
  h('div', { style: S.line },
554
- h('span', { style: { ...S.label, ...S.mono, ...S.grow, fontSize: 13 } }, f.model || L('(未知模型)', '(unknown model)')),
555
- h('span', { style: { fontSize: 12, color: color(f.kind) } }, `HTTP ${f.status}`),
556
- h('span', { style: S.meta }, ago(f.at))),
557
- h('div', { style: { fontSize: 13, color: color(f.kind), marginTop: 2 } }, f.advice),
558
- f.message ? h('div', { style: { ...S.meta, ...S.mono, marginTop: 2, wordBreak: 'break-all' } }, f.message) : null))),
559
- failures.length > 5 ? h('button', { type: 'button', style: { ...S.btn, marginTop: 8 }, onClick: () => setAll(!all) }, all ? L('收起', 'Show less') : L(`全部 ${failures.length} 条`, `All ${failures.length}`)) : null)
577
+ h('button', { type: 'button', 'aria-expanded': open, onClick: () => setOpen(!open), style: { ...S.grow, textAlign: 'left', background: 'none', border: 'none', padding: 0, cursor: 'pointer', fontSize: 13, color: color(top) } },
578
+ `${open ? '▾' : '▸'} ${L(`最近 24 小时失败 ${fresh.length} 次(多是${(KIND_LABEL[top] || KIND_LABEL.other)()})`, `${fresh.length} failed request${fresh.length > 1 ? 's' : ''} in 24 hours (mostly ${(KIND_LABEL[top] || KIND_LABEL.other)()})`)}`),
579
+ h('button', { type: 'button', style: S.btn, onClick: dismiss }, L('知道了', 'Got it'))),
580
+ open ? h('div', { style: { marginTop: 8 } },
581
+ h('div', { style: { ...S.meta, marginBottom: 4 } }, L('经统一端点失败、之后也没再成功的请求。只记录模型和报错,不记录对话内容。', 'Requests that failed through the endpoint and have not succeeded since. Only the model and the error are kept, never the conversation.')),
582
+ fresh.slice(0, 20).map((f, i) => h('div', { key: `${f.at}-${i}`, style: { borderTop: i ? `1px solid ${C.border}` : 'none', padding: '6px 0' } },
583
+ h('div', { style: S.line },
584
+ h('span', { style: { ...S.mono, ...S.grow, fontSize: 12, color: C.text } }, f.model || L('(未知模型)', '(unknown model)')),
585
+ h('span', { style: { fontSize: 12, color: color(f.kind) } }, `HTTP ${f.status}`),
586
+ h('span', { style: S.meta }, ago(f.at))),
587
+ h('div', { style: { fontSize: 12, color: color(f.kind) } }, f.advice),
588
+ f.message ? h('div', { style: { ...S.meta, ...S.mono, wordBreak: 'break-all' } }, f.message) : null))) : null))
560
589
  }
561
590
 
562
591
  function ModelsCard({ stats }) {
@@ -853,7 +882,9 @@ window.__ModuleLoader__.load({
853
882
  try { await api('POST', '/checkin/qoder', { pat: pat.trim(), region }); onDone() } catch (e) { setMsg(e.message); setBusy(false) }
854
883
  }
855
884
  return h(Modal, { title: L('添加 Qoder 签到', 'Add Qoder check-in'), onClose },
856
- h('p', { style: S.note }, L('在 Qoder 的账号设置里创建一个个人访问令牌(Personal Access Token),粘贴到这里。只用来每天领签到积分,保存在本机。', 'Create a Personal Access Token in your Qoder account settings and paste it here. It is only used to claim the daily credits, and stays on this machine.')),
885
+ h('p', { style: S.note },
886
+ L('登录 Qoder 后,在「账号 → 集成」里创建一个个人访问令牌(Personal Access Token,以 pt- 开头,只在创建时显示一次),粘贴到这里。只用来每天领签到积分,保存在本机。', 'Sign in to Qoder and create a Personal Access Token under Account → Integrations (it starts with pt- and is shown only once), then paste it here. It is only used to claim the daily credits, and stays on this machine.'),
887
+ h('a', { href: region === 'cn' ? 'https://qoder.com.cn/account/integrations' : 'https://qoder.com/account/integrations', target: '_blank', rel: 'noopener noreferrer', style: { color: C.accent, marginLeft: 6 } }, region === 'cn' ? L('去 Qoder 国内版创建 ↗', 'Create on Qoder China ↗') : L('去 Qoder 国际版创建 ↗', 'Create on Qoder ↗'))),
857
888
  h('div', { style: { ...S.line, marginBottom: 8 } },
858
889
  ['cn', 'intl'].map((r) => h('label', { key: r, style: { ...S.meta, display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' } },
859
890
  h('input', { type: 'radio', name: 'qoder-region', checked: region === r, onChange: () => setRegion(r) }), r === 'cn' ? L('国内版', 'China') : L('国际版', 'International')))),
@@ -1029,9 +1060,9 @@ window.__ModuleLoader__.load({
1029
1060
  err ? h('div', { style: { fontSize: 12, color: C.err, marginBottom: 12 } }, err) : null,
1030
1061
  h(EndpointCard, { endpoints: data.endpoints }),
1031
1062
  h(SourcesCard, { sources: data.sources, reload: () => load(false), onLogin: (s, session) => setLogin({ source: s, session }), onKey: (s) => setKeyDialog(s || true) }),
1032
- h(FailuresCard, { failures: data.failures }),
1033
1063
  h(KeysCard, { keys: data.keys, reload: () => load(false) }),
1034
1064
  h(ModelsCard, { stats: data.stats }),
1065
+ h(FailuresCard, { failures: data.failures }),
1035
1066
  h(AboutCard, { onJob: setSelfJob }),
1036
1067
  login ? h(LoginDialog, { source: login.source, session: login.session, onDone: () => { setLogin(null); void load(true) }, onClose: () => { setLogin(null); void load(false) } }) : null,
1037
1068
  keyDialog ? h(KeyDialog, { source: keyDialog === true ? null : keyDialog, onDone: () => { setKeyDialog(false); void load(true) }, onClose: () => setKeyDialog(false) }) : null)