mioku-plugin-help 2.0.0 → 2.1.0

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