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,81 @@
1
+ import type { MiokiContext, ScreenshotService } from "mioku";
2
+ import { checkNightMode } from "../utils";
3
+ import { collectSnapshot } from "./data-collector";
4
+ import { renderStatusHtml } from "./html-generator";
5
+ import type { StatusIntent } from "./types";
6
+
7
+ const FORCE_NIGHT_MODE = false;
8
+
9
+ const RENDER_TIMEOUT_MS = 12_000;
10
+
11
+ function withTimeout<T>(p: PromiseLike<T>, ms: number): Promise<T> {
12
+ return new Promise<T>((resolve, reject) => {
13
+ const timer = setTimeout(
14
+ () => reject(new Error("status_render_timeout")),
15
+ ms,
16
+ );
17
+ p.then(
18
+ (v) => {
19
+ clearTimeout(timer);
20
+ resolve(v as T);
21
+ },
22
+ (err) => {
23
+ clearTimeout(timer);
24
+ reject(err);
25
+ },
26
+ );
27
+ });
28
+ }
29
+
30
+ export interface GenerateStatusImageOptions {
31
+ ctx: MiokiContext;
32
+ event?: any;
33
+ intent: StatusIntent;
34
+ botNickname?: string;
35
+ botAvatarUrl?: string;
36
+ }
37
+
38
+ export interface GenerateStatusImageResult {
39
+ ok: boolean;
40
+ imagePath?: string;
41
+ error?: string;
42
+ }
43
+
44
+ export async function generateStatusImage(
45
+ options: GenerateStatusImageOptions,
46
+ ): Promise<GenerateStatusImageResult> {
47
+ const { ctx, intent } = options;
48
+ const screenshotService = ctx?.services?.screenshot as
49
+ | ScreenshotService
50
+ | undefined;
51
+ if (!screenshotService) {
52
+ return { ok: false, error: "screenshot 服务未加载" };
53
+ }
54
+ if (intent.type === "none") {
55
+ return { ok: false, error: "not a status command" };
56
+ }
57
+
58
+ try {
59
+ const snapshot = await collectSnapshot(ctx, {
60
+ isNightMode: FORCE_NIGHT_MODE || checkNightMode(),
61
+ });
62
+ const html = renderStatusHtml(snapshot);
63
+ const imagePath = await withTimeout(
64
+ Promise.resolve(
65
+ screenshotService.screenshot(html, {
66
+ width: 760,
67
+ height: 200,
68
+ fullPage: true,
69
+ type: "png",
70
+ }),
71
+ ),
72
+ RENDER_TIMEOUT_MS,
73
+ );
74
+ if (!imagePath) {
75
+ return { ok: false, error: "screenshot 返回空路径" };
76
+ }
77
+ return { ok: true, imagePath };
78
+ } catch (err: any) {
79
+ return { ok: false, error: String(err?.message || err) };
80
+ }
81
+ }
@@ -0,0 +1,28 @@
1
+ export type {
2
+ AIUsageStatsLite,
3
+ BotAccountStatus,
4
+ DiskEntry,
5
+ DiskStatus,
6
+ FrameworkStatus,
7
+ NetworkSample,
8
+ NetworkStatus,
9
+ NodeRuntimeStatus,
10
+ ResourceStatus,
11
+ StatusIntent,
12
+ StatusIntentFull,
13
+ StatusIntentNone,
14
+ StatusSnapshot,
15
+ SystemInfo,
16
+ } from "./types";
17
+
18
+ export { resolveStatusIntent } from "./intent";
19
+ export {
20
+ collectSnapshot,
21
+ clearStatusCache,
22
+ __formatHelpers,
23
+ } from "./data-collector";
24
+ export { networkSampler } from "./network-sampler";
25
+ export { perfMonitor } from "./performance-monitor";
26
+ export { renderStatusHtml } from "./html-generator";
27
+ export { generateStatusImage } from "./image";
28
+ export type { GenerateStatusImageOptions, GenerateStatusImageResult } from "./image";
@@ -0,0 +1,43 @@
1
+ import type { StatusIntent } from "./types";
2
+
3
+ /**
4
+ * Parse a user message into a `StatusIntent`.
5
+ *
6
+ * Recognized prefixes: `#状态`, `/状态`, `状态`, `菜单 状态` (also the
7
+ * Latin aliases `zt` / `status`). Anything trailing the leading token is
8
+ * ignored — the panel always renders the full sheet.
9
+ *
10
+ * If the input doesn't look like a status command, returns `{ type: "none" }`
11
+ * and the help plugin's normal flow takes over.
12
+ */
13
+
14
+ function stripStopword(input: string): string {
15
+ return String(input || "")
16
+ .trim()
17
+ .replace(/^[#/\s]+/, "")
18
+ .replace(/[。.!!??,,::;;]+$/g, "");
19
+ }
20
+
21
+ function normalize(value: string): string {
22
+ return String(value || "").toLowerCase().trim();
23
+ }
24
+
25
+ export function resolveStatusIntent(text: string): StatusIntent {
26
+ const source = stripStopword(text);
27
+ if (!source) {
28
+ return { type: "none" };
29
+ }
30
+
31
+ // Match leading tokens: "菜单 状态", "状态", "#状态", etc.
32
+ const tokens = source.split(/\s+/).filter(Boolean);
33
+ if (tokens.length === 0) {
34
+ return { type: "none" };
35
+ }
36
+
37
+ const lead = normalize(tokens[0]);
38
+ if (lead !== "状态" && lead !== "zt" && lead !== "status") {
39
+ return { type: "none" };
40
+ }
41
+
42
+ return { type: "full" };
43
+ }
@@ -0,0 +1,129 @@
1
+ import { systemInfo } from "mioki";
2
+ import type { NetworkSample } from "./types";
3
+
4
+ /**
5
+ * Background sampler for network throughput.
6
+ *
7
+ * Polls `systeminformation.networkStats()` at a fixed interval, sums the
8
+ * `rx_sec` / `tx_sec` of all interfaces, and stores the result in a ring
9
+ * buffer. The first sample is always discarded because macOS reports all
10
+ * zeros on the first call.
11
+ */
12
+ class NetworkSampler {
13
+ private rxBpsRing: number[] = [];
14
+ private txBpsRing: number[] = [];
15
+ private tsRing: number[] = [];
16
+ private rxTotalBytes = 0;
17
+ private txTotalBytes = 0;
18
+ private timer: NodeJS.Timeout | null = null;
19
+ private intervalMs = 5000;
20
+ private maxSamples = 360;
21
+ private lastSampleAt: number | null = null;
22
+ private lastRxPerSec = 0;
23
+ private lastTxPerSec = 0;
24
+
25
+ start(intervalMs = 5000, maxSamples = 360): void {
26
+ if (this.timer) {
27
+ return;
28
+ }
29
+ this.intervalMs = intervalMs;
30
+ this.maxSamples = maxSamples;
31
+ void this.tick();
32
+ this.timer = setInterval(() => {
33
+ void this.tick();
34
+ }, intervalMs);
35
+ if (typeof this.timer?.unref === "function") {
36
+ this.timer.unref();
37
+ }
38
+ }
39
+
40
+ stop(): void {
41
+ if (this.timer) {
42
+ clearInterval(this.timer);
43
+ this.timer = null;
44
+ }
45
+ }
46
+
47
+ isRunning(): boolean {
48
+ return this.timer !== null;
49
+ }
50
+
51
+ getLastSpeeds(): { rxBps: number; txBps: number } {
52
+ return { rxBps: this.lastRxPerSec, txBps: this.lastTxPerSec };
53
+ }
54
+
55
+ getTotals(): { rxBytes: number; txBytes: number } {
56
+ return { rxBytes: this.rxTotalBytes, txBytes: this.txTotalBytes };
57
+ }
58
+
59
+ getRecentSeries(windowMs: number): NetworkSample[] {
60
+ const cutoff = Date.now() - windowMs;
61
+ const result: NetworkSample[] = [];
62
+ for (let i = 0; i < this.tsRing.length; i++) {
63
+ const ts = this.tsRing[i] ?? 0;
64
+ if (ts >= cutoff) {
65
+ result.push({
66
+ ts,
67
+ rxBps: this.rxBpsRing[i] ?? 0,
68
+ txBps: this.txBpsRing[i] ?? 0,
69
+ });
70
+ }
71
+ }
72
+ return result;
73
+ }
74
+
75
+ private async tick(): Promise<void> {
76
+ try {
77
+ const stats = await systemInfo.networkStats();
78
+ if (!Array.isArray(stats) || stats.length === 0) {
79
+ return;
80
+ }
81
+ let rxPerSec = 0;
82
+ let txPerSec = 0;
83
+ let rxBytes = 0;
84
+ let txBytes = 0;
85
+ for (const iface of stats) {
86
+ rxPerSec += Number(iface?.rx_sec || 0);
87
+ txPerSec += Number(iface?.tx_sec || 0);
88
+ rxBytes += Number(iface?.rx_bytes || 0);
89
+ txBytes += Number(iface?.tx_bytes || 0);
90
+ }
91
+ const now = Date.now();
92
+ // macOS returns all zeros on the first call after process start. Drop
93
+ // the first sample to avoid a giant spike in the chart.
94
+ if (this.lastSampleAt === null) {
95
+ this.lastSampleAt = now;
96
+ this.lastRxPerSec = rxPerSec;
97
+ this.lastTxPerSec = txPerSec;
98
+ this.rxTotalBytes = rxBytes;
99
+ this.txTotalBytes = txBytes;
100
+ this.pushSample(now, rxPerSec, txPerSec);
101
+ return;
102
+ }
103
+ const deltaSec = Math.max(0.001, (now - this.lastSampleAt) / 1000);
104
+ // Accumulate bytes based on per-second rates to avoid double-counting
105
+ // the `rx_bytes` field on systems where it resets on interface bounce.
106
+ this.rxTotalBytes += rxPerSec * deltaSec;
107
+ this.txTotalBytes += txPerSec * deltaSec;
108
+ this.lastSampleAt = now;
109
+ this.lastRxPerSec = rxPerSec;
110
+ this.lastTxPerSec = txPerSec;
111
+ this.pushSample(now, rxPerSec, txPerSec);
112
+ } catch {
113
+ // Ignore transient errors; sampler continues.
114
+ }
115
+ }
116
+
117
+ private pushSample(ts: number, rxBps: number, txBps: number): void {
118
+ this.tsRing.push(ts);
119
+ this.rxBpsRing.push(rxBps);
120
+ this.txBpsRing.push(txBps);
121
+ if (this.tsRing.length > this.maxSamples) {
122
+ this.tsRing.shift();
123
+ this.rxBpsRing.shift();
124
+ this.txBpsRing.shift();
125
+ }
126
+ }
127
+ }
128
+
129
+ export const networkSampler = new NetworkSampler();
@@ -0,0 +1,102 @@
1
+ import { monitorEventLoopDelay, PerformanceObserver } from "node:perf_hooks";
2
+
3
+ /**
4
+ * Captures Node event-loop delay and (best-effort) GC events.
5
+ *
6
+ * `monitorEventLoopDelay` returns a histogram that must be `reset()` after
7
+ * each read; otherwise values accumulate forever. The GC observer requires
8
+ * Node to be started with `--expose-gc`, otherwise the `gc` entry type is
9
+ * unavailable and we degrade gracefully.
10
+ */
11
+ class PerfMonitor {
12
+ private loopHist: ReturnType<typeof monitorEventLoopDelay> | null = null;
13
+ private gcObserver: PerformanceObserver | null = null;
14
+ private lastSnapshot: { mean: number; p99: number } = { mean: 0, p99: 0 };
15
+ private gcCount = 0;
16
+ private lastGCDurationMs = 0;
17
+ private readTimer: NodeJS.Timeout | null = null;
18
+
19
+ start(): void {
20
+ if (this.loopHist) {
21
+ return;
22
+ }
23
+ try {
24
+ this.loopHist = monitorEventLoopDelay({ resolution: 20 });
25
+ this.loopHist.enable();
26
+ this.readTimer = setInterval(() => {
27
+ if (!this.loopHist) {
28
+ return;
29
+ }
30
+ // nanoseconds → milliseconds
31
+ this.lastSnapshot = {
32
+ mean: this.loopHist.mean / 1e6,
33
+ p99: this.loopHist.percentile(99) / 1e6,
34
+ };
35
+ this.loopHist.reset();
36
+ }, 1000);
37
+ if (typeof this.readTimer.unref === "function") {
38
+ this.readTimer.unref();
39
+ }
40
+ } catch {
41
+ this.loopHist = null;
42
+ }
43
+
44
+ try {
45
+ this.gcObserver = new PerformanceObserver((list) => {
46
+ for (const entry of list.getEntries()) {
47
+ this.gcCount += 1;
48
+ this.lastGCDurationMs = entry.duration;
49
+ }
50
+ });
51
+ this.gcObserver.observe({ entryTypes: ["gc"] });
52
+ } catch {
53
+ this.gcObserver = null;
54
+ }
55
+ }
56
+
57
+ stop(): void {
58
+ if (this.readTimer) {
59
+ clearInterval(this.readTimer);
60
+ this.readTimer = null;
61
+ }
62
+ if (this.loopHist) {
63
+ try {
64
+ this.loopHist.disable();
65
+ } catch {
66
+ // ignore
67
+ }
68
+ this.loopHist = null;
69
+ }
70
+ if (this.gcObserver) {
71
+ try {
72
+ this.gcObserver.disconnect();
73
+ } catch {
74
+ // ignore
75
+ }
76
+ this.gcObserver = null;
77
+ }
78
+ }
79
+
80
+ isRunning(): boolean {
81
+ return this.loopHist !== null;
82
+ }
83
+
84
+ getEventLoop(): { mean: number; p99: number } {
85
+ return this.lastSnapshot;
86
+ }
87
+
88
+ getGC():
89
+ | { available: boolean; count: number; lastDurationMs?: number }
90
+ | null {
91
+ if (!this.gcObserver) {
92
+ return { available: false, count: 0 };
93
+ }
94
+ return {
95
+ available: true,
96
+ count: this.gcCount,
97
+ lastDurationMs: this.lastGCDurationMs,
98
+ };
99
+ }
100
+ }
101
+
102
+ export const perfMonitor = new PerfMonitor();
@@ -0,0 +1,264 @@
1
+ /**
2
+ * Status panel data types.
3
+ *
4
+ * The status panel is rendered from a single `StatusSnapshot` aggregated by
5
+ * `data-collector.ts`. Every field is plain data; rendering logic lives in
6
+ * `html-generator.ts`.
7
+ */
8
+
9
+ export interface BotAccountStatus {
10
+ uin: number;
11
+ nickname: string;
12
+ avatarUrl: string;
13
+ /** Underlying bot framework identifier, e.g. "QQBot" / "NapCat" / "LLOneBot". */
14
+ framework: string;
15
+ /** Adapter app version, e.g. "5.0.6" — from OneBot `get_version_info.app_version`. */
16
+ appVersion: string;
17
+ /** OneBot protocol version, e.g. "v11" — from OneBot `get_version_info.protocol_version`. */
18
+ protocolVersion: string;
19
+ online: boolean;
20
+ groupCount: number;
21
+ friendCount: number;
22
+ /** Computed from `bot.api("get_status").stat.start_time`. 0 if unavailable. */
23
+ onlineDurationMs: number;
24
+ send: number;
25
+ receive: number;
26
+ }
27
+
28
+ /**
29
+ * OneBot v11 `get_version_info` payload (already unwrapped from the
30
+ * `{ status, retcode, data, ... }` envelope by napcat-sdk's `bot.api`).
31
+ */
32
+ export interface OneBotVersionInfoData {
33
+ app_name: string;
34
+ protocol_version: string;
35
+ app_version: string;
36
+ }
37
+
38
+ /**
39
+ * OneBot v11 `get_status` payload (already unwrapped by napcat-sdk).
40
+ * Different implementations expose different subsets:
41
+ * - NapCat / go-cqhttp: `online`, `good`, `stat.start_time`
42
+ * - LLOneBot: same shape
43
+ */
44
+ export interface OneBotStatusData {
45
+ online?: boolean;
46
+ good?: boolean;
47
+ stat?: {
48
+ start_time?: number;
49
+ [key: string]: unknown;
50
+ };
51
+ [key: string]: unknown;
52
+ }
53
+
54
+ /** Subset of mioku's `AIService.getUsageSummary` payload that we actually render. */
55
+ export interface AIUsageSummary {
56
+ totals?: {
57
+ requests?: number;
58
+ inputTokens?: number;
59
+ outputTokens?: number;
60
+ totalTokens?: number;
61
+ };
62
+ rates?: {
63
+ errorRate?: number;
64
+ cacheHitRate?: number;
65
+ };
66
+ groupRanking?: Array<{
67
+ groupId?: number | string;
68
+ groupName?: string;
69
+ requests?: number;
70
+ totalTokens?: number;
71
+ }>;
72
+ toolRanking?: Array<{
73
+ name?: string;
74
+ count?: number;
75
+ }>;
76
+ }
77
+
78
+ /** Subset of `systeminformation.graphics()` payload that we actually render. */
79
+ export interface GraphicsData {
80
+ controllers?: Array<{
81
+ model?: string;
82
+ vendor?: string;
83
+ [key: string]: unknown;
84
+ }>;
85
+ [key: string]: unknown;
86
+ }
87
+
88
+ export interface FrameworkStatus {
89
+ miokuVersion: string;
90
+ miokiVersion: string;
91
+ napcatVersion: string;
92
+ /** Total discovered plugins (enabled + disabled). */
93
+ pluginCount: number;
94
+ /** Currently enabled plugins. */
95
+ pluginEnabled: number;
96
+ /** Number of distinct bot frameworks (deduplicated by app_name). */
97
+ adapterCount: number;
98
+ onlineBotCount: number;
99
+ uptimeMs: number;
100
+ /** Detected JS runtime name: "Bun" / "Node" / "Deno". */
101
+ runtime: string;
102
+ /** Detected JS runtime version. */
103
+ runtimeVersion: string;
104
+ }
105
+
106
+ export interface ResourceStatus {
107
+ cpuPercent: number;
108
+ cpuModel: string;
109
+ /** CPU brand truncated to ~22 chars with ellipsis. */
110
+ cpuModelShort: string;
111
+ /** Human-readable clock, e.g. "2.9 GHz" / "900 MHz". */
112
+ cpuSpeedGHz: string;
113
+ cpuCores: number;
114
+ memPercent: number;
115
+ memUsedGB: number;
116
+ memTotalGB: number;
117
+ /** Buffers + cache as reported by `systeminformation.mem().buffcache`. 0 if unavailable. */
118
+ memBuffCacheGB: number;
119
+ /** 0..100, or 0 if no swap configured. */
120
+ swapPercent: number;
121
+ swapUsedGB: number;
122
+ swapTotalGB: number;
123
+ }
124
+
125
+ export interface NodeRuntimeStatus {
126
+ heapUsedMB: number;
127
+ heapTotalMB: number;
128
+ rssMB: number;
129
+ externalMB: number;
130
+ arrayBuffersMB: number;
131
+ eventLoopDelayMs: { mean: number; p99: number };
132
+ /** null when `--expose-gc` is not enabled. */
133
+ gc:
134
+ | {
135
+ available: boolean;
136
+ count: number;
137
+ lastDurationMs?: number;
138
+ }
139
+ | null;
140
+ }
141
+
142
+ export interface NetworkSample {
143
+ ts: number;
144
+ rxBps: number;
145
+ txBps: number;
146
+ }
147
+
148
+ export interface NetworkStatus {
149
+ rxBps: number;
150
+ txBps: number;
151
+ rxTotalBytes: number;
152
+ txTotalBytes: number;
153
+ /** Last 30 minutes of samples, oldest first. */
154
+ history: NetworkSample[];
155
+ }
156
+
157
+ export interface DiskEntry {
158
+ mount: string;
159
+ usedGB: number;
160
+ totalGB: number;
161
+ percent: number;
162
+ }
163
+
164
+ export interface DiskStatus {
165
+ entries: DiskEntry[];
166
+ readMBps?: number;
167
+ writeMBps?: number;
168
+ iops?: number;
169
+ }
170
+
171
+ /** One GPU as reported by `systeminformation.graphics().controllers`. */
172
+ export interface GpuInfo {
173
+ vendor: string;
174
+ model: string;
175
+ /** VRAM in GB. 0 if unknown / integrated. */
176
+ vramGB: number;
177
+ }
178
+
179
+ /** One physical memory module as reported by `systeminformation.memLayout()`. */
180
+ export interface MemoryStick {
181
+ bank: string;
182
+ sizeGB: number;
183
+ /** "DDR4" / "DDR5" / "LPDDR5" / "Unknown". */
184
+ type: string;
185
+ /** Transfer rate in MT/s, e.g. 3200 / 4800. 0 if unknown. */
186
+ speedMTs: number;
187
+ manufacturer: string;
188
+ partNum: string;
189
+ }
190
+
191
+ /** BIOS / UEFI firmware info from `systeminformation.bios()`. */
192
+ export interface BiosInfo {
193
+ vendor: string;
194
+ version: string;
195
+ releaseDate: string;
196
+ }
197
+
198
+ /** One physical disk drive from `systeminformation.diskLayout()`. */
199
+ export interface DiskInfo {
200
+ vendor: string;
201
+ name: string;
202
+ /** "HDD" / "SSD" / "NVMe" / unknown. */
203
+ type: string;
204
+ /** "SATA" / "NVMe" / "USB" / unknown. */
205
+ interfaceType: string;
206
+ sizeGB: number;
207
+ }
208
+
209
+ export interface SystemInfo {
210
+ /** "macOS Sequoia 15.5 (arm64)" / "Ubuntu 24.04 LTS (x86_64)". */
211
+ os: string;
212
+ /** Kernel string, e.g. "Darwin 25.5.0" / "Linux 6.8.0-31-generic". */
213
+ kernel: string;
214
+ /** Full CPU brand from `systeminformation.cpu().brand`. */
215
+ cpu: string;
216
+ /** All GPUs detected, integrated + discrete. Empty if none. */
217
+ gpus: GpuInfo[];
218
+ /** All physical RAM modules. Empty if `memLayout()` not supported (e.g. macOS). */
219
+ memSticks: MemoryStick[];
220
+ /** BIOS / UEFI. vendor "N/A" if not supported. */
221
+ bios: BiosInfo;
222
+ /** System manufacturer + model, e.g. "Supermicro H12SSL-NT". "N/A" on macOS. */
223
+ chassis: string;
224
+ /** All physical disk drives. Empty on systems where `diskLayout()` is
225
+ * not supported (rare) or no drives are detected. */
226
+ disks: DiskInfo[];
227
+ }
228
+
229
+ export interface AIUsageStatsLite {
230
+ available: boolean;
231
+ totalRequests: number;
232
+ errorRate: number;
233
+ cacheHitRate: number;
234
+ inputTokens: number;
235
+ outputTokens: number;
236
+ totalTokens: number;
237
+ topGroups: Array<{ name: string; requests: number; totalTokens: number }>;
238
+ topTools: Array<{ name: string; count: number }>;
239
+ }
240
+
241
+ export interface StatusSnapshot {
242
+ generatedAt: number;
243
+ isNightMode: boolean;
244
+ bots: BotAccountStatus[];
245
+ framework: FrameworkStatus;
246
+ resources: ResourceStatus;
247
+ runtime: NodeRuntimeStatus;
248
+ network: NetworkStatus;
249
+ disk: DiskStatus;
250
+ system: SystemInfo;
251
+ ai: AIUsageStatsLite;
252
+ }
253
+
254
+ /** Intent returned by `resolveStatusIntent`. Only "full" or "none" — the panel
255
+ * always renders the complete status; sub-section shortcuts were removed. */
256
+ export interface StatusIntentFull {
257
+ type: "full";
258
+ }
259
+
260
+ export interface StatusIntentNone {
261
+ type: "none";
262
+ }
263
+
264
+ export type StatusIntent = StatusIntentFull | StatusIntentNone;