mioku-plugin-help 2.0.0 → 2.1.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,752 @@
1
+ import * as os from "node:os";
2
+ import * as path from "node:path";
3
+ import {
4
+ connectedBots,
5
+ getMiokiStatus,
6
+ systemInfo,
7
+ type ExtendedNapCat,
8
+ type MiokiStatus,
9
+ } from "mioki";
10
+ import type { AIService, MiokiContext } from "mioku";
11
+ import { getRenderVersions } from "../utils";
12
+ import { perfMonitor } from "./performance-monitor";
13
+ import { networkSampler } from "./network-sampler";
14
+ import type {
15
+ AIUsageStatsLite,
16
+ AIUsageSummary,
17
+ BiosInfo,
18
+ BotAccountStatus,
19
+ DiskEntry,
20
+ DiskInfo,
21
+ DiskStatus,
22
+ FrameworkStatus,
23
+ GraphicsData,
24
+ GpuInfo,
25
+ MemoryStick,
26
+ NetworkStatus,
27
+ NodeRuntimeStatus,
28
+ OneBotStatusData,
29
+ OneBotVersionInfoData,
30
+ ResourceStatus,
31
+ StatusSnapshot,
32
+ SystemInfo,
33
+ } from "./types";
34
+
35
+ /**
36
+ * Aggregates runtime data from mioki, the OS, the network sampler, the
37
+ * perf monitor, and the AI service into a single `StatusSnapshot`.
38
+ *
39
+ * The result is cached for `TTL_MS` to avoid duplicate collection when a
40
+ * user fires `#状态` repeatedly. Each external await is wrapped in a
41
+ * `Promise.race` with a 2s timeout so a single slow subsystem can't stall
42
+ * the whole panel.
43
+ */
44
+ const TTL_MS = 2000;
45
+ const AWAIT_TIMEOUT_MS = 2000;
46
+
47
+ let cache: { at: number; snapshot: StatusSnapshot } | null = null;
48
+
49
+ function withTimeout<T>(p: PromiseLike<T>, ms = AWAIT_TIMEOUT_MS): Promise<T> {
50
+ return new Promise<T>((resolve, reject) => {
51
+ const timer = setTimeout(() => reject(new Error("timeout")), ms);
52
+ p.then(
53
+ (value) => {
54
+ clearTimeout(timer);
55
+ resolve(value as T);
56
+ },
57
+ (err) => {
58
+ clearTimeout(timer);
59
+ reject(err);
60
+ },
61
+ );
62
+ });
63
+ }
64
+
65
+ function safeNumber(value: unknown, fallback = 0): number {
66
+ const n = Number(value);
67
+ return Number.isFinite(n) ? n : fallback;
68
+ }
69
+
70
+ /**
71
+ * Snapshot of the system's CPU tick counters at a point in time. The
72
+ * sum covers every core, so a 4-core box that's fully loaded registers
73
+ * `total = 4 * elapsed_in_ticks`. `idle` is the slice spent idle.
74
+ */
75
+ interface CpuTickSample {
76
+ idle: number;
77
+ total: number;
78
+ ts: number;
79
+ }
80
+
81
+ function sampleCpuTicks(): CpuTickSample {
82
+ let idle = 0;
83
+ let total = 0;
84
+ for (const cpu of os.cpus()) {
85
+ const t = cpu.times;
86
+ idle += t.idle;
87
+ total += t.user + t.nice + t.sys + t.idle + t.irq;
88
+ }
89
+ return { idle, total, ts: Date.now() };
90
+ }
91
+
92
+ /**
93
+ * Baseline sample taken at module load. We seed the cache eagerly so the
94
+ * first `#状态` call (whenever it happens) already has a comparison
95
+ * point — without this, the first call after a long idle period would
96
+ * show 0% even if the system is under load.
97
+ */
98
+ let prevCpuTicks: CpuTickSample = sampleCpuTicks();
99
+
100
+ /**
101
+ * If the gap between two samples is larger than this, the previous
102
+ * sample is too stale to produce a meaningful delta (e.g. the user
103
+ * called `#状态` once, then came back an hour later). Discard and
104
+ * start a fresh baseline.
105
+ */
106
+ const CPU_SAMPLE_MAX_GAP_MS = 30_000;
107
+
108
+ /**
109
+ * System-wide average CPU utilization between the previous sample and
110
+ * now. Returns 0 on the first call after a baseline reset, and a value
111
+ * in [0, 100] on subsequent calls.
112
+ *
113
+ * `os.cpus()` already sums ticks across all cores, so the result is
114
+ * the per-core average — no need to divide by `cpuCores` (the old
115
+ * `process.cpuUsage()` math did that because it summed its own usage
116
+ * across cores; same destination, different source).
117
+ */
118
+ function computeSystemCpuPercent(): number {
119
+ const current = sampleCpuTicks();
120
+ const gap = current.ts - prevCpuTicks.ts;
121
+ if (gap > CPU_SAMPLE_MAX_GAP_MS || gap <= 0) {
122
+ prevCpuTicks = current;
123
+ return 0;
124
+ }
125
+ const idleDiff = current.idle - prevCpuTicks.idle;
126
+ const totalDiff = current.total - prevCpuTicks.total;
127
+ prevCpuTicks = current;
128
+ if (totalDiff <= 0) {
129
+ return 0;
130
+ }
131
+ const percent = 100 - (idleDiff / totalDiff) * 100;
132
+ return Math.max(0, Math.min(100, percent));
133
+ }
134
+
135
+ /** Detect the active JS runtime and its version. */
136
+ function detectRuntime(): { name: string; version: string } {
137
+ if (process.versions.bun) {
138
+ return { name: "Bun", version: process.versions.bun };
139
+ }
140
+ return { name: "Node", version: process.versions.node };
141
+ }
142
+
143
+ function formatBytes(n: number): string {
144
+ if (n >= 1024 ** 3) {
145
+ return `${(n / 1024 ** 3).toFixed(2)} GB`;
146
+ }
147
+ if (n >= 1024 ** 2) {
148
+ return `${(n / 1024 ** 2).toFixed(2)} MB`;
149
+ }
150
+ if (n >= 1024) {
151
+ return `${(n / 1024).toFixed(2)} KB`;
152
+ }
153
+ return `${n} B`;
154
+ }
155
+
156
+ function formatUptime(ms: number): string {
157
+ const sec = Math.floor(ms / 1000);
158
+ const days = Math.floor(sec / 86400);
159
+ const hours = Math.floor((sec % 86400) / 3600);
160
+ const minutes = Math.floor((sec % 3600) / 60);
161
+ if (days > 0) {
162
+ return `${days}天${hours}小时`;
163
+ }
164
+ if (hours > 0) {
165
+ return `${hours}小时${minutes}分`;
166
+ }
167
+ return `${minutes}分`;
168
+ }
169
+
170
+ function formatDuration(ms: number): string {
171
+ if (!Number.isFinite(ms) || ms <= 0) {
172
+ return "—";
173
+ }
174
+ const sec = Math.floor(ms / 1000);
175
+ const days = Math.floor(sec / 86400);
176
+ const hours = Math.floor((sec % 86400) / 3600);
177
+ const minutes = Math.floor((sec % 3600) / 60);
178
+ const seconds = sec % 60;
179
+ if (days > 0) {
180
+ return `${days}天${String(hours).padStart(2, "0")}小时${String(minutes).padStart(2, "0")}分`;
181
+ }
182
+ if (hours > 0) {
183
+ return `${hours}小时${String(minutes).padStart(2, "0")}分${String(seconds).padStart(2, "0")}秒`;
184
+ }
185
+ return `${minutes}分${String(seconds).padStart(2, "0")}秒`;
186
+ }
187
+
188
+ async function collectBots(): Promise<BotAccountStatus[]> {
189
+ const bots = Array.from(connectedBots.values());
190
+ if (bots.length === 0) {
191
+ return [];
192
+ }
193
+ return collectBotStatuses(bots);
194
+ }
195
+
196
+ async function collectBotStatuses(
197
+ bots: ExtendedNapCat[],
198
+ ): Promise<BotAccountStatus[]> {
199
+ let perBotCounts = new Map<number, { send: number; receive: number }>();
200
+ try {
201
+ const miokiStatus = await withTimeout(
202
+ Promise.resolve().then(() => getMiokiStatus(bots)),
203
+ );
204
+ if (Array.isArray(miokiStatus?.bots)) {
205
+ for (const b of miokiStatus.bots) {
206
+ const u = safeNumber(b?.uin);
207
+ if (u > 0) {
208
+ perBotCounts.set(u, {
209
+ send: safeNumber(b?.send),
210
+ receive: safeNumber(b?.receive),
211
+ });
212
+ }
213
+ }
214
+ }
215
+ } catch {
216
+ // ignore
217
+ }
218
+
219
+ const results: BotAccountStatus[] = [];
220
+ for (const bot of bots) {
221
+ const uin = safeNumber(bot?.bot_id || bot?.uin || bot?.user_id);
222
+ const nickname = String(bot?.nickname || "Unknown Bot");
223
+ const framework = String(bot?.app_name || "unknown");
224
+ const avatarUrl = `https://q1.qlogo.cn/g?b=qq&nk=${uin}&s=160`;
225
+
226
+ // 并行拿 OneBot API 的 status / group / friend / version
227
+ let online = true;
228
+ let onlineDurationMs = 0;
229
+ let groupCount = 0;
230
+ let friendCount = 0;
231
+ let appVersion = "unknown";
232
+ let protocolVersion = "unknown";
233
+ const [statusResult, groupsResult, friendsResult, versionResult] =
234
+ await Promise.allSettled([
235
+ withTimeout(
236
+ Promise.resolve().then(() => bot.api<OneBotStatusData>("get_status")),
237
+ ).catch(() => null),
238
+ withTimeout(Promise.resolve().then(() => bot.getGroupList())).catch(
239
+ () => [],
240
+ ),
241
+ withTimeout(Promise.resolve().then(() => bot.getFriendList())).catch(
242
+ () => [],
243
+ ),
244
+ withTimeout(
245
+ Promise.resolve().then(() =>
246
+ bot.api<OneBotVersionInfoData>("get_version_info"),
247
+ ),
248
+ ).catch(() => null),
249
+ ]);
250
+ if (statusResult.status === "fulfilled" && statusResult.value) {
251
+ const status = statusResult.value;
252
+ // napcat-sdk 的 api() 已经把 OneBot v11 响应的 data 字段解包出来,
253
+ const startTs = safeNumber(status?.stat?.start_time);
254
+ if (startTs > 0) {
255
+ // OneBot 约定:start_time 是 unix 秒;> 1e12 视为毫秒
256
+ const startMs = startTs > 1e12 ? startTs : startTs * 1000;
257
+ onlineDurationMs = Math.max(0, Date.now() - startMs);
258
+ }
259
+ if (typeof status?.online === "boolean") {
260
+ online = status.online;
261
+ } else if (typeof status?.good === "boolean") {
262
+ // go-cqhttp / LLOneBot 用 good 表示总体健康
263
+ online = status.good;
264
+ }
265
+ }
266
+ if (versionResult.status === "fulfilled" && versionResult.value) {
267
+ const v = versionResult.value;
268
+ if (v.app_version && v.app_version.trim()) {
269
+ appVersion = v.app_version.trim();
270
+ }
271
+ if (v.protocol_version && v.protocol_version.trim()) {
272
+ protocolVersion = v.protocol_version.trim();
273
+ }
274
+ }
275
+ if (
276
+ groupsResult.status === "fulfilled" &&
277
+ Array.isArray(groupsResult.value)
278
+ ) {
279
+ groupCount = groupsResult.value.length;
280
+ }
281
+ if (
282
+ friendsResult.status === "fulfilled" &&
283
+ Array.isArray(friendsResult.value)
284
+ ) {
285
+ friendCount = friendsResult.value.length;
286
+ }
287
+
288
+ const counts = perBotCounts.get(uin) || { send: 0, receive: 0 };
289
+
290
+ results.push({
291
+ uin,
292
+ nickname,
293
+ avatarUrl,
294
+ framework,
295
+ appVersion,
296
+ protocolVersion,
297
+ online,
298
+ groupCount,
299
+ friendCount,
300
+ onlineDurationMs,
301
+ send: counts.send,
302
+ receive: counts.receive,
303
+ });
304
+ }
305
+ return results;
306
+ }
307
+
308
+ async function collectFramework(
309
+ rawBots: ExtendedNapCat[],
310
+ botStatuses: BotAccountStatus[],
311
+ ): Promise<FrameworkStatus> {
312
+ const adapters = new Set<string>();
313
+ for (const bot of botStatuses) {
314
+ if (bot.framework) {
315
+ adapters.add(bot.framework);
316
+ }
317
+ }
318
+
319
+ // 从 mioki 内部服务读 plugins / versions
320
+ let miokiStatus: MiokiStatus | null = null;
321
+ try {
322
+ miokiStatus = await withTimeout(
323
+ Promise.resolve().then(() => getMiokiStatus(rawBots)),
324
+ );
325
+ } catch {
326
+ // ignore
327
+ }
328
+
329
+ const runtime = detectRuntime();
330
+ // Read both versions from the same `getRenderVersions` helper that the
331
+ // help panel uses, so the status footer and the help footer agree even
332
+ // when `mioki` is only installed under `mioku/node_modules`.
333
+ const { miokiVersion, miokuVersion } = await getRenderVersions();
334
+ return {
335
+ miokuVersion,
336
+ miokiVersion,
337
+ napcatVersion:
338
+ miokiStatus?.versions?.napcat ?? botStatuses[0]?.framework ?? "unknown",
339
+ pluginCount: safeNumber(miokiStatus?.plugins?.total),
340
+ pluginEnabled: safeNumber(miokiStatus?.plugins?.enabled),
341
+ adapterCount: adapters.size,
342
+ onlineBotCount: botStatuses.filter((b) => b.online).length,
343
+ uptimeMs: safeNumber(process.uptime()) * 1000,
344
+ runtime: runtime.name,
345
+ runtimeVersion: runtime.version,
346
+ };
347
+ }
348
+
349
+ async function collectResources(): Promise<ResourceStatus> {
350
+ const totalMem = os.totalmem();
351
+ const freeMem = os.freemem();
352
+ const usedMem = Math.max(0, totalMem - freeMem);
353
+ const memPercent = totalMem > 0 ? (usedMem / totalMem) * 100 : 0;
354
+
355
+ const cpus = os.cpus();
356
+ const cpuInfo = cpus[0];
357
+ const cpuModel = cpuInfo?.model || "unknown";
358
+ const cpuCores = cpus.length;
359
+ let speedMhz = safeNumber(cpuInfo?.speed);
360
+ // Apple Silicon (M1/M2/M3) reports 0 from os.cpus()[0].speed because
361
+ // Apple doesn't expose the frequency. Fall back to systeminformation's
362
+ // cpuCurrentSpeed() which reads it from sysctl on macOS.
363
+ if (speedMhz <= 0) {
364
+ try {
365
+ const cur = (await withTimeout(systemInfo.cpuCurrentSpeed())) as
366
+ | { avg?: number; min?: number; max?: number }
367
+ | null
368
+ | undefined;
369
+ const ghz = safeNumber(cur?.avg);
370
+ if (ghz > 0) {
371
+ speedMhz = Math.round(ghz * 1000);
372
+ }
373
+ } catch {
374
+ // keep 0; renderer will show "0 MHz"
375
+ }
376
+ }
377
+ const cpuModelShort =
378
+ cpuModel.length > 22 ? `${cpuModel.slice(0, 21)}…` : cpuModel;
379
+ const cpuSpeedGHz =
380
+ speedMhz >= 1000
381
+ ? `${(speedMhz / 1000).toFixed(1)} GHz`
382
+ : `${Math.round(speedMhz)} MHz`;
383
+ // System-wide CPU% from a 2-sample delta. `collectSnapshot` has a 2s
384
+ // TTL, so under normal use we'll have a fresh reading each call. The
385
+ // first call after a baseline reset returns 0 (sentinel) — see
386
+ // `computeSystemCpuPercent` for the gap-discard policy.
387
+ const cpuPercent = computeSystemCpuPercent();
388
+
389
+ // systeminformation.mem() gives buffcache and swap fields that os can't.
390
+ // Wrap in withTimeout so a single slow call doesn't stall the snapshot.
391
+ let memBuffCacheGB = 0;
392
+ let swapTotalGB = 0;
393
+ let swapUsedGB = 0;
394
+ let swapPercent = 0;
395
+ try {
396
+ const memInfo = await withTimeout(systemInfo.mem());
397
+ if (memInfo && typeof memInfo === "object") {
398
+ const buffcache = safeNumber(
399
+ (memInfo as { buffcache?: number }).buffcache,
400
+ );
401
+ const swaptotal = safeNumber(
402
+ (memInfo as { swaptotal?: number }).swaptotal,
403
+ );
404
+ const swapused = safeNumber((memInfo as { swapused?: number }).swapused);
405
+ memBuffCacheGB = Number((buffcache / 1024 ** 3).toFixed(2));
406
+ swapTotalGB = Number((swaptotal / 1024 ** 3).toFixed(2));
407
+ swapUsedGB = Number((swapused / 1024 ** 3).toFixed(2));
408
+ swapPercent = swaptotal > 0 ? (swapused / swaptotal) * 100 : 0;
409
+ }
410
+ } catch {
411
+ // ignore — fall back to zeros
412
+ }
413
+
414
+ return {
415
+ cpuPercent: Number(cpuPercent.toFixed(1)),
416
+ cpuModel,
417
+ cpuModelShort,
418
+ cpuSpeedGHz,
419
+ cpuCores,
420
+ memPercent: Number(memPercent.toFixed(1)),
421
+ memUsedGB: Number((usedMem / 1024 ** 3).toFixed(2)),
422
+ memTotalGB: Number((totalMem / 1024 ** 3).toFixed(2)),
423
+ memBuffCacheGB,
424
+ swapPercent: Number(swapPercent.toFixed(1)),
425
+ swapUsedGB,
426
+ swapTotalGB,
427
+ };
428
+ }
429
+
430
+ function collectRuntime(): NodeRuntimeStatus {
431
+ const mem = process.memoryUsage();
432
+ return {
433
+ heapUsedMB: Number((mem.heapUsed / 1024 ** 2).toFixed(1)),
434
+ heapTotalMB: Number((mem.heapTotal / 1024 ** 2).toFixed(1)),
435
+ rssMB: Number((mem.rss / 1024 ** 2).toFixed(1)),
436
+ externalMB: Number((mem.external / 1024 ** 2).toFixed(1)),
437
+ arrayBuffersMB: Number((mem.arrayBuffers / 1024 ** 2).toFixed(1)),
438
+ eventLoopDelayMs: perfMonitor.getEventLoop(),
439
+ gc: perfMonitor.getGC(),
440
+ };
441
+ }
442
+
443
+ function collectNetwork(): NetworkStatus {
444
+ const speeds = networkSampler.getLastSpeeds();
445
+ const totals = networkSampler.getTotals();
446
+ return {
447
+ rxBps: speeds.rxBps,
448
+ txBps: speeds.txBps,
449
+ rxTotalBytes: totals.rxBytes,
450
+ txTotalBytes: totals.txBytes,
451
+ history: networkSampler.getRecentSeries(30 * 60 * 1000),
452
+ };
453
+ }
454
+
455
+ async function collectDisk(): Promise<DiskStatus> {
456
+ try {
457
+ const fsList = await withTimeout(systemInfo.fsSize());
458
+ if (!Array.isArray(fsList)) {
459
+ return { entries: [] };
460
+ }
461
+ const sorted = [...fsList].sort(
462
+ (a, b) => safeNumber(b?.size) - safeNumber(a?.size),
463
+ );
464
+ const top = sorted.slice(0, 3);
465
+ const entries: DiskEntry[] = top.map((entry) => {
466
+ const total = safeNumber(entry?.size);
467
+ const used = safeNumber(entry?.used);
468
+ const percent =
469
+ total > 0
470
+ ? Number(((used / total) * 100).toFixed(1))
471
+ : safeNumber(entry?.use, 0);
472
+ return {
473
+ mount: String(entry?.mount || entry?.fs || "unknown"),
474
+ usedGB: Number((used / 1024 ** 3).toFixed(2)),
475
+ totalGB: Number((total / 1024 ** 3).toFixed(2)),
476
+ percent,
477
+ };
478
+ });
479
+ return { entries };
480
+ } catch {
481
+ return { entries: [] };
482
+ }
483
+ }
484
+
485
+ async function collectSystem(): Promise<SystemInfo> {
486
+ // Seven systeminformation calls. Each gets its own 2s timeout so a single
487
+ // slow probe (e.g. memLayout on macOS) doesn't block the rest.
488
+ const [
489
+ osInfo,
490
+ graphics,
491
+ cpuData,
492
+ memLayout,
493
+ biosData,
494
+ systemData,
495
+ diskLayout,
496
+ ] = await Promise.all([
497
+ withTimeout(systemInfo.osInfo()).catch(() => null),
498
+ withTimeout(systemInfo.graphics()).catch(() => null),
499
+ withTimeout(systemInfo.cpu()).catch(() => null),
500
+ withTimeout(systemInfo.memLayout()).catch(() => []),
501
+ withTimeout(systemInfo.bios()).catch(() => null),
502
+ withTimeout(systemInfo.system()).catch(() => null),
503
+ withTimeout(systemInfo.diskLayout()).catch(() => []),
504
+ ]);
505
+
506
+ // OS: prefer the human-readable distro (e.g. "macOS Sequoia", "Ubuntu
507
+ // 24.04 LTS") with arch appended. Fall back to `os.platform() arch` if
508
+ // systeminformation doesn't return a distro (rare, mostly on exotic BSDs).
509
+ let osLabel = `${os.platform()} ${os.arch()}`;
510
+ let kernel = os.release();
511
+ if (osInfo && typeof osInfo === "object") {
512
+ const info = osInfo as {
513
+ distro?: unknown;
514
+ release?: unknown;
515
+ kernel?: unknown;
516
+ arch?: unknown;
517
+ };
518
+ const distro = String(info.distro || "").trim();
519
+ const release = String(info.release || "").trim();
520
+ const arch = String(info.arch || os.arch()).trim();
521
+ if (distro) {
522
+ osLabel =
523
+ release && release !== "0" && release !== distro
524
+ ? `${distro} ${release} (${arch})`
525
+ : `${distro} (${arch})`;
526
+ } else if (release) {
527
+ osLabel = `${release} (${arch})`;
528
+ } else {
529
+ osLabel = `${os.platform()} (${arch})`;
530
+ }
531
+ if (info.kernel) {
532
+ kernel = String(info.kernel);
533
+ }
534
+ }
535
+
536
+ // CPU: prefer the long brand string ("AMD EPYC 7542 32-Core Processor")
537
+ // from systeminformation. os.cpus()[0].model often truncates on Linux.
538
+ let cpu = os.cpus()[0]?.model || "unknown";
539
+ if (cpuData && typeof cpuData === "object") {
540
+ const brand = String((cpuData as { brand?: unknown }).brand || "").trim();
541
+ if (brand) {
542
+ cpu = brand;
543
+ }
544
+ }
545
+
546
+ // GPUs: support multiple. Many systems have an integrated + discrete pair
547
+ // (e.g. Apple Silicon + eGPU, Intel iGPU + NVIDIA dGPU). On a server with
548
+ // no GPU, this array stays empty and the renderer shows "N/A".
549
+ const gpus: GpuInfo[] = [];
550
+ const controllers = (graphics as GraphicsData | null)?.controllers;
551
+ if (Array.isArray(controllers)) {
552
+ for (const c of controllers) {
553
+ const model = String(c?.model || "").trim();
554
+ if (!model) continue;
555
+ const vramBytes = safeNumber(c?.vram);
556
+ gpus.push({
557
+ vendor: String(c?.vendor || "").trim(),
558
+ model,
559
+ vramGB: Number((vramBytes / 1024 ** 3).toFixed(2)),
560
+ });
561
+ }
562
+ }
563
+
564
+ // RAM sticks. memLayout() is Linux/Win only; on macOS this returns an
565
+ // empty array and the renderer shows "N/A". Each stick's full part number
566
+ // is the only reliable way to identify a module.
567
+ const memSticks: MemoryStick[] = [];
568
+ if (Array.isArray(memLayout)) {
569
+ for (const m of memLayout) {
570
+ const sizeBytes = safeNumber(m?.size);
571
+ if (sizeBytes <= 0) continue;
572
+ memSticks.push({
573
+ bank: String(m?.bank || "").trim(),
574
+ sizeGB: Number((sizeBytes / 1024 ** 3).toFixed(1)),
575
+ type: String(m?.type || "Unknown").trim(),
576
+ speedMTs: safeNumber(m?.clockSpeed),
577
+ manufacturer: String(m?.manufacturer || "").trim(),
578
+ partNum: String(m?.partNum || "").trim(),
579
+ });
580
+ }
581
+ }
582
+
583
+ // BIOS / UEFI. On macOS / WSL this returns empty strings — surface as "N/A".
584
+ const bios: BiosInfo =
585
+ biosData && typeof biosData === "object"
586
+ ? {
587
+ vendor:
588
+ String((biosData as { vendor?: unknown }).vendor || "N/A").trim() ||
589
+ "N/A",
590
+ version:
591
+ String(
592
+ (biosData as { version?: unknown }).version || "N/A",
593
+ ).trim() || "N/A",
594
+ releaseDate: String(
595
+ (biosData as { releaseDate?: unknown }).releaseDate || "",
596
+ ).trim(),
597
+ }
598
+ : { vendor: "N/A", version: "N/A", releaseDate: "" };
599
+
600
+ // Chassis: "Manufacturer Model" (e.g. "Supermicro H12SSL-NT", "Dell Inc.
601
+ // PowerEdge R750"). On macOS the system() call returns empty strings;
602
+ // fall back to the model identifier (e.g. "Mac15,9").
603
+ let chassis = "N/A";
604
+ if (systemData && typeof systemData === "object") {
605
+ const manufacturer = String(
606
+ (systemData as { manufacturer?: unknown }).manufacturer || "",
607
+ ).trim();
608
+ const model = String(
609
+ (systemData as { model?: unknown }).model || "",
610
+ ).trim();
611
+ if (manufacturer && model) {
612
+ chassis = `${manufacturer} ${model}`;
613
+ } else if (model) {
614
+ chassis = model;
615
+ } else if (manufacturer) {
616
+ chassis = manufacturer;
617
+ }
618
+ }
619
+
620
+ // Physical disk drives. diskLayout() is the same source as the top3 used
621
+ // by the disk-usage section, but here we keep every drive so the system
622
+ // info can show the actual hardware (vendor + model + size + interface).
623
+ const disks: DiskInfo[] = [];
624
+ if (Array.isArray(diskLayout)) {
625
+ for (const d of diskLayout) {
626
+ const sizeBytes = safeNumber(d?.size);
627
+ if (sizeBytes <= 0) continue;
628
+ disks.push({
629
+ vendor: String(d?.vendor || "").trim(),
630
+ name: String(d?.name || "").trim(),
631
+ type: String(d?.type || "Unknown").trim(),
632
+ interfaceType: String(d?.interfaceType || "").trim(),
633
+ sizeGB: Number((sizeBytes / 1024 ** 3).toFixed(0)),
634
+ });
635
+ }
636
+ }
637
+
638
+ return {
639
+ os: osLabel,
640
+ kernel,
641
+ cpu,
642
+ gpus,
643
+ memSticks,
644
+ bios,
645
+ chassis,
646
+ disks,
647
+ };
648
+ }
649
+
650
+ async function collectAI(ctx: MiokiContext): Promise<AIUsageStatsLite> {
651
+ const ai = ctx?.services?.ai as AIService | undefined;
652
+ if (!ai || typeof ai.getUsageSummary !== "function") {
653
+ return {
654
+ available: false,
655
+ totalRequests: 0,
656
+ errorRate: 0,
657
+ cacheHitRate: 0,
658
+ inputTokens: 0,
659
+ outputTokens: 0,
660
+ totalTokens: 0,
661
+ topGroups: [],
662
+ topTools: [],
663
+ };
664
+ }
665
+ try {
666
+ const summary = (await withTimeout(
667
+ Promise.resolve(ai.getUsageSummary({ range: "7d" })),
668
+ )) as AIUsageSummary | null | undefined;
669
+ const totals = summary?.totals;
670
+ const rates = summary?.rates;
671
+ return {
672
+ available: true,
673
+ totalRequests: safeNumber(totals?.requests),
674
+ errorRate: safeNumber(rates?.errorRate),
675
+ cacheHitRate: safeNumber(rates?.cacheHitRate),
676
+ inputTokens: safeNumber(totals?.inputTokens),
677
+ outputTokens: safeNumber(totals?.outputTokens),
678
+ totalTokens: safeNumber(totals?.totalTokens),
679
+ topGroups: Array.isArray(summary?.groupRanking)
680
+ ? summary!.groupRanking!.slice(0, 6).map((g) => ({
681
+ name: String(g?.groupName || `群 ${g?.groupId || "?"}`),
682
+ requests: safeNumber(g?.requests),
683
+ totalTokens: safeNumber(g?.totalTokens),
684
+ }))
685
+ : [],
686
+ topTools: Array.isArray(summary?.toolRanking)
687
+ ? summary!.toolRanking!.slice(0, 6).map((t) => ({
688
+ name: String(t?.name || "unknown"),
689
+ count: safeNumber(t?.count),
690
+ }))
691
+ : [],
692
+ };
693
+ } catch {
694
+ return {
695
+ available: false,
696
+ totalRequests: 0,
697
+ errorRate: 0,
698
+ cacheHitRate: 0,
699
+ inputTokens: 0,
700
+ outputTokens: 0,
701
+ totalTokens: 0,
702
+ topGroups: [],
703
+ topTools: [],
704
+ };
705
+ }
706
+ }
707
+
708
+ export async function collectSnapshot(
709
+ ctx: MiokiContext,
710
+ options: { isNightMode: boolean } = { isNightMode: false },
711
+ ): Promise<StatusSnapshot> {
712
+ if (cache && Date.now() - cache.at < TTL_MS) {
713
+ return { ...cache.snapshot, isNightMode: options.isNightMode };
714
+ }
715
+
716
+ const rawBots = Array.from(connectedBots.values());
717
+ const [bots, disk, system, ai, resources] = await Promise.all([
718
+ collectBots(),
719
+ collectDisk(),
720
+ collectSystem(),
721
+ collectAI(ctx),
722
+ collectResources(),
723
+ ]);
724
+
725
+ const framework = await collectFramework(rawBots, bots);
726
+
727
+ const snapshot: StatusSnapshot = {
728
+ generatedAt: Date.now(),
729
+ isNightMode: options.isNightMode,
730
+ bots,
731
+ framework,
732
+ resources,
733
+ runtime: collectRuntime(),
734
+ network: collectNetwork(),
735
+ disk,
736
+ system,
737
+ ai,
738
+ };
739
+
740
+ cache = { at: Date.now(), snapshot };
741
+ return snapshot;
742
+ }
743
+
744
+ export function clearStatusCache(): void {
745
+ cache = null;
746
+ }
747
+
748
+ export const __formatHelpers = {
749
+ formatUptime,
750
+ formatDuration,
751
+ formatBytes,
752
+ };