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.
- package/help/html-generator.ts +556 -0
- package/help/image.ts +216 -0
- package/help/index.ts +24 -0
- package/help/info.ts +41 -0
- package/help/intent.ts +379 -0
- package/help/role-config.ts +67 -0
- package/help/types.ts +40 -0
- package/index.ts +69 -11
- package/package.json +4 -3
- package/skills.ts +132 -77
- package/status/data-collector.ts +752 -0
- package/status/html-generator.ts +1202 -0
- package/status/image.ts +81 -0
- package/status/index.ts +28 -0
- package/status/intent.ts +43 -0
- package/status/network-sampler.ts +129 -0
- package/status/performance-monitor.ts +102 -0
- package/status/types.ts +264 -0
- package/theme.ts +138 -0
- package/utils.ts +129 -0
- package/shared.ts +0 -1276
|
@@ -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
|
+
};
|