mioku-plugin-help 2.3.3 → 3.0.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/config.md CHANGED
@@ -8,6 +8,12 @@ fields:
8
8
  description: 最基础的单行文本输入。
9
9
  placeholder: 输入任意文本
10
10
 
11
+ - key: status.stdinAvatar
12
+ label: stdin 头像
13
+ type: text
14
+ description: stdin 适配器在状态图中使用的头像,支持 http(s):// 或 file:// URL,也支持本地绝对路径(自动转 file://)。默认使用 uapis.cn 随机表情接口。
15
+ placeholder: https://uapis.cn/api/v1/random/image?category=bq&type=eciyuan
16
+
11
17
  - key: demo.secretValue
12
18
  label: secret 密文输入
13
19
  type: secret
@@ -192,4 +198,16 @@ config: demo
192
198
 
193
199
  用法:`mioku-file` 会直接把某个配置文件完整显示为 JSON 编辑器,适合调试或保底兜底。
194
200
 
201
+ ## 7. `status` 状态图配置
202
+
203
+ ```mioku-field
204
+ key: status.stdinAvatar
205
+ ```
206
+
207
+ `status` 配置文件(`config/help/status.json`)目前支持:
208
+
209
+ | 字段 | 类型 | 默认值 | 说明 |
210
+ | --- | --- | --- | --- |
211
+ | `stdinAvatar` | string | `https://uapis.cn/api/v1/random/image?category=bq&type=eciyuan` | stdin 适配器在状态图中的头像,支持 http(s):// / file:// URL 或本地绝对路径(自动转 file://) |
212
+
195
213
  ---
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  import type { CommandRole, PluginHelp } from "mioku";
15
- import { botConfig } from "mioki";
15
+ import { botConfig } from "mioku";
16
16
  import { escapeHtml } from "../utils";
17
17
  import { getHelpTheme, HELP_BACKGROUND_IMAGE_URL } from "../theme";
18
18
  import { getRenderableEntries } from "./intent";
package/help/image.ts CHANGED
@@ -115,11 +115,9 @@ export function resolveHelpBotProfile(
115
115
  const fallbackNickname = "Mioku Bot";
116
116
  const selfId = event?.self_id;
117
117
  const bot =
118
- (selfId && typeof ctx?.pickBot === "function"
119
- ? ctx.pickBot(selfId)
120
- : null) ||
118
+ event?.bot ??
121
119
  (ctx?.bots instanceof Map ? Array.from(ctx.bots.values())[0] : null);
122
- const botId = selfId || bot?.uin || bot?.user_id || bot?.self_id;
120
+ const botId = selfId || bot?.bot_id;
123
121
  const botNickname = bot?.nickname || bot?.name || fallbackNickname;
124
122
  const botAvatarUrl = botId
125
123
  ? `https://q1.qlogo.cn/g?b=qq&nk=${botId}&s=640`
@@ -160,8 +158,8 @@ export async function replyWithImage(
160
158
  }
161
159
 
162
160
  /**
163
- * Send an image from inside an AI skill handler. Picks the right bot via
164
- * `ctx.pickBot(selfId)`, supports an optional quote-reply, and falls
161
+ * Send an image from inside an AI skill handler. Uses the event-bound bot
162
+ * (`event.bot`), supports an optional quote-reply, and falls
165
163
  * back to base64 when the adapter refuses a raw path.
166
164
  */
167
165
  export async function sendImageFromSkillContext(options: {
@@ -171,28 +169,20 @@ export async function sendImageFromSkillContext(options: {
171
169
  quoteReply?: boolean;
172
170
  }): Promise<void> {
173
171
  const { ctx, event, imagePath, quoteReply = false } = options;
174
- const selfId = event?.self_id != null ? Number(event.self_id) : undefined;
175
- const bot =
176
- selfId != null && typeof ctx?.pickBot === "function"
177
- ? ctx.pickBot(selfId)
178
- : undefined;
172
+ const bot = event?.bot;
179
173
 
180
174
  if (!bot) {
181
175
  throw new Error("当前上下文不支持发送图片");
182
176
  }
183
177
 
184
178
  const buildImageSegment = (file: string) => {
185
- const normalizedFile = normalizeImageSource(file);
186
- if (ctx?.segment?.image) {
187
- return ctx.segment.image(normalizedFile);
188
- }
189
- return { type: "image", file: normalizedFile };
179
+ return ctx.segment.image(normalizeImageSource(file));
190
180
  };
191
181
 
192
182
  const sendPayload = async (file: string) => {
193
183
  const payload: any[] = [];
194
184
  if (quoteReply && event?.message_id != null) {
195
- payload.push({ type: "reply", id: String(event.message_id) });
185
+ payload.push(ctx.segment.reply(event.message_id));
196
186
  }
197
187
  payload.push(buildImageSegment(file));
198
188
 
package/help/intent.ts CHANGED
@@ -24,7 +24,7 @@ function sanitizeKeyword(value: string): string {
24
24
  return String(value || "")
25
25
  .trim()
26
26
  .replace(/^[#/\s]+/, "")
27
- .replace(/[。.!!??,,::;;]+$/g, "");
27
+ .replace(/[。.!!??,,::;);]+$/g, "");
28
28
  }
29
29
 
30
30
  /** Lowercase + strip everything that isn't a-z, 0-9, or CJK. */
package/help/role.ts CHANGED
@@ -66,15 +66,10 @@ export async function resolveViewerRole(
66
66
  }
67
67
 
68
68
  if (event?.group_id != null && event?.user_id != null) {
69
- const selfId =
70
- event?.self_id != null ? Number(event.self_id) : undefined;
71
- const bot =
72
- selfId != null && typeof ctx?.pickBot === "function"
73
- ? ctx.pickBot(selfId)
74
- : undefined;
75
- if (bot && typeof bot.getGroupMemberInfo === "function") {
69
+ const bot = event?.bot;
70
+ if (bot) {
76
71
  try {
77
- const info = await bot.getGroupMemberInfo(
72
+ const info = await bot.getMemberInfo(
78
73
  event.group_id,
79
74
  event.user_id,
80
75
  );
package/index.ts CHANGED
@@ -1,7 +1,8 @@
1
- import { definePlugin, type MiokiContext } from "mioki";
1
+ import { definePlugin, type MiokuContext } from "mioku";
2
2
  import { getService, Services } from "mioku";
3
3
  import * as path from "path";
4
4
  import { HELP_DEMO_CONFIG } from "./demo-config";
5
+ import { HELP_STATUS_DEFAULT_CONFIG } from "./status/config";
5
6
  import {
6
7
  generateHelpImage,
7
8
  replyWithImage,
@@ -23,7 +24,7 @@ const helpPlugin = definePlugin({
23
24
  version: "2.1.0",
24
25
  description: "帮助插件,生成帮助图片,并提供 #状态 指令",
25
26
 
26
- async setup(ctx: MiokiContext) {
27
+ async setup(ctx: MiokuContext) {
27
28
  // 启动后台采样器
28
29
  networkSampler.start();
29
30
  perfMonitor.start();
@@ -48,6 +49,11 @@ const helpPlugin = definePlugin({
48
49
 
49
50
  if (configService) {
50
51
  await configService.registerConfig("help", "demo", HELP_DEMO_CONFIG.demo);
52
+ await configService.registerConfig(
53
+ "help",
54
+ "status",
55
+ HELP_STATUS_DEFAULT_CONFIG,
56
+ );
51
57
  }
52
58
 
53
59
  // 注册 help manifest,让 `#help 状态` 能命中
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mioku-plugin-help",
3
- "version": "2.3.3",
3
+ "version": "3.0.1",
4
4
  "description": "帮助插件,使用截图服务生成美观的帮助图片,并提供 #状态 指令",
5
5
  "main": "index.ts",
6
6
  "type": "module",
@@ -32,11 +32,12 @@
32
32
  ]
33
33
  },
34
34
  "peerDependencies": {
35
- "mioku": "^0.8.0",
36
- "mioki": "^0.16.0"
35
+ "mioku": "^1.0.0"
36
+ },
37
+ "dependencies": {
38
+ "systeminformation": "^5.31.6"
37
39
  },
38
40
  "devDependencies": {
39
- "mioku": "workspace:*",
40
- "mioki": "^0.16.0"
41
+ "mioku": "workspace:*"
41
42
  }
42
43
  }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * help 插件状态图配置(config-service 注册名为 "help/status")。
3
+ * 配置文件位于 `<项目>/config/help/status.json`,可热重载。
4
+ */
5
+ export interface HelpStatusConfig {
6
+ /**
7
+ * stdin 适配器在状态图中使用的头像。
8
+ * 支持 http(s):// 或 file:// URL,也支持本地绝对路径(自动转换为 file://)。
9
+ * 默认使用 uapis.cn 的随机表情接口。
10
+ */
11
+ stdinAvatar?: string;
12
+ }
13
+
14
+ export const HELP_STATUS_DEFAULT_CONFIG: HelpStatusConfig = {
15
+ // 默认使用 uapis.cn 的二次元表情随机接口(每次刷新头像都不同)
16
+ stdinAvatar: "https://uapis.cn/api/v1/random/image?category=bq&type=eciyuan",
17
+ };
@@ -1,16 +1,23 @@
1
1
  import * as os from "node:os";
2
2
  import * as path from "node:path";
3
+ import systemInfo from "systeminformation";
3
4
  import {
5
+ buildAdapterReport,
4
6
  connectedBots,
5
- getMiokiStatus,
6
- systemInfo,
7
- type ExtendedNapCat,
8
- type MiokiStatus,
9
- } from "mioki";
10
- import type { AIService, MiokiContext } from "mioku";
7
+ getPluginMetadataList,
8
+ getService,
9
+ Services,
10
+ } from "mioku";
11
+ import type {
12
+ AdapterInstanceStatus,
13
+ AIService,
14
+ Bot,
15
+ MiokuContext,
16
+ } from "mioku";
11
17
  import { getRenderVersions } from "../utils";
12
18
  import { perfMonitor } from "./performance-monitor";
13
19
  import { networkSampler } from "./network-sampler";
20
+ import type { HelpStatusConfig } from "./config";
14
21
  import type {
15
22
  AIUsageStatsLite,
16
23
  AIUsageSummary,
@@ -25,8 +32,6 @@ import type {
25
32
  MemoryStick,
26
33
  NetworkStatus,
27
34
  NodeRuntimeStatus,
28
- OneBotStatusData,
29
- OneBotVersionInfoData,
30
35
  ResourceStatus,
31
36
  StatusSnapshot,
32
37
  SystemInfo,
@@ -185,113 +190,89 @@ function formatDuration(ms: number): string {
185
190
  return `${minutes}分${String(seconds).padStart(2, "0")}秒`;
186
191
  }
187
192
 
188
- async function collectBots(): Promise<BotAccountStatus[]> {
193
+ async function collectBots(
194
+ ctx: MiokuContext,
195
+ stdinAvatar?: string,
196
+ ): Promise<BotAccountStatus[]> {
189
197
  const bots = Array.from(connectedBots.values());
190
198
  if (bots.length === 0) {
191
199
  return [];
192
200
  }
193
- return collectBotStatuses(bots);
201
+ // stdin 适配器恒排第一位(终端是本机主人通道,优先展示)
202
+ const sorted = [...bots].sort(
203
+ (a, b) =>
204
+ Number(b.adapter === "stdin") - Number(a.adapter === "stdin"),
205
+ );
206
+ return collectBotStatuses(sorted, ctx, stdinAvatar);
207
+ }
208
+
209
+ /** 把本地绝对路径 / http(s) / file:// 统一成可被截图服务加载的图片地址 */
210
+ function toImageSrc(value: string): string {
211
+ if (/^(https?:|file:|data:)/i.test(value)) return value;
212
+ if (value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value)) {
213
+ return `file://${value}`;
214
+ }
215
+ return value;
194
216
  }
195
217
 
196
218
  async function collectBotStatuses(
197
- bots: ExtendedNapCat[],
219
+ bots: readonly Bot[],
220
+ ctx: MiokuContext,
221
+ stdinAvatar?: string,
198
222
  ): 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
- }
223
+ // 统一走框架的适配器上报,不再按 OneBot 的 get_version_info / get_status 猜
224
+ const adapters = ctx.adapters.map((adapter) => ({
225
+ name: adapter.name,
226
+ version: adapter.version,
227
+ impl: adapter.impl,
228
+ }));
229
+ const report = await withTimeout(buildAdapterReport({ bots, adapters })).catch(
230
+ () => null,
231
+ );
232
+ const instances = new Map<string, AdapterInstanceStatus>();
233
+ const implOf = new Map<string, { name: string; version?: string } | undefined>();
234
+ for (const entry of report?.adapters ?? []) {
235
+ implOf.set(entry.name, entry.impl);
236
+ for (const instance of entry.instances) {
237
+ instances.set(`${entry.name}:${instance.bot_id}`, instance);
214
238
  }
215
- } catch {
216
- // ignore
217
239
  }
218
240
 
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 groupCount = 0;
229
- let friendCount = 0;
230
- let appVersion = "unknown";
231
- let protocolVersion = "unknown";
232
- const [statusResult, groupsResult, friendsResult, versionResult] =
233
- await Promise.allSettled([
234
- withTimeout(
235
- Promise.resolve().then(() => bot.api<OneBotStatusData>("get_status")),
236
- ).catch(() => null),
237
- withTimeout(Promise.resolve().then(() => bot.getGroupList())).catch(
238
- () => [],
239
- ),
240
- withTimeout(Promise.resolve().then(() => bot.getFriendList())).catch(
241
- () => [],
242
- ),
243
- withTimeout(
244
- Promise.resolve().then(() =>
245
- bot.api<OneBotVersionInfoData>("get_version_info"),
246
- ),
247
- ).catch(() => null),
248
- ]);
249
- if (statusResult.status === "fulfilled" && statusResult.value) {
250
- online = statusResult.value.online;
251
- }
252
- if (versionResult.status === "fulfilled" && versionResult.value) {
253
- const v = versionResult.value;
254
- if (v.app_version && v.app_version.trim()) {
255
- appVersion = v.app_version.trim();
256
- }
257
- if (v.protocol_version && v.protocol_version.trim()) {
258
- protocolVersion = v.protocol_version.trim();
259
- }
260
- }
261
- if (
262
- groupsResult.status === "fulfilled" &&
263
- Array.isArray(groupsResult.value)
264
- ) {
265
- groupCount = groupsResult.value.length;
266
- }
267
- if (
268
- friendsResult.status === "fulfilled" &&
269
- Array.isArray(friendsResult.value)
270
- ) {
271
- friendCount = friendsResult.value.length;
272
- }
273
-
274
- const counts = perBotCounts.get(uin) || { send: 0, receive: 0 };
241
+ return bots.map((bot) => {
242
+ const uin = String(bot.bot_id);
243
+ const adapter = String(bot.adapter);
244
+ const isStdin = adapter === "stdin";
245
+ const instance = instances.get(`${adapter}:${uin}`);
246
+ const lib = implOf.get(adapter);
247
+ // 实现端优先用平台自报的(NapCat / LLOneBot),否则退回适配器的底层库(ICQQ)
248
+ const framework = instance?.impl || lib?.name || adapter;
249
+ const appVersion = instance?.version || lib?.version || "";
275
250
 
276
- results.push({
251
+ return {
277
252
  uin,
278
- nickname,
279
- avatarUrl,
280
- framework,
281
- appVersion,
282
- protocolVersion,
283
- online,
284
- groupCount,
285
- friendCount,
286
- send: counts.send,
287
- receive: counts.receive,
288
- });
289
- }
290
- return results;
253
+ nickname: String(bot.nickname || "Unknown Bot"),
254
+ avatarUrl: isStdin
255
+ ? toImageSrc(stdinAvatar || "")
256
+ : `https://q1.qlogo.cn/g?b=qq&nk=${uin}&s=160`,
257
+ adapter,
258
+ framework: isStdin ? adapter : framework,
259
+ appVersion: isStdin
260
+ ? String(ctx.getAdapter(adapter)?.version ?? "")
261
+ : appVersion,
262
+ protocolVersion: instance?.protocol ?? "",
263
+ platform: instance?.platform ?? "",
264
+ platformVersion: instance?.platformVersion ?? "",
265
+ online: bot.online,
266
+ groupCount: instance?.stats.groups ?? 0,
267
+ friendCount: instance?.stats.friends ?? 0,
268
+ send: instance?.stats.sent ?? 0,
269
+ receive: instance?.stats.received ?? 0,
270
+ };
271
+ });
291
272
  }
292
273
 
293
274
  async function collectFramework(
294
- rawBots: ExtendedNapCat[],
275
+ rawBots: readonly Bot[],
295
276
  botStatuses: BotAccountStatus[],
296
277
  ): Promise<FrameworkStatus> {
297
278
  const adapters = new Set<string>();
@@ -301,28 +282,22 @@ async function collectFramework(
301
282
  }
302
283
  }
303
284
 
304
- // 从 mioki 内部服务读 plugins / versions
305
- let miokiStatus: MiokiStatus | null = null;
306
- try {
307
- miokiStatus = await withTimeout(
308
- Promise.resolve().then(() => getMiokiStatus(rawBots)),
309
- );
310
- } catch {
311
- // ignore
312
- }
313
-
314
285
  const runtime = detectRuntime();
315
286
  // Read both versions from the same `getRenderVersions` helper that the
316
- // help panel uses, so the status footer and the help footer agree even
317
- // when `mioki` is only installed under `mioku/node_modules`.
287
+ // help panel uses.
318
288
  const { miokiVersion, miokuVersion } = await getRenderVersions();
289
+ const pluginCount = getPluginMetadataList().length;
290
+ // 优先取第一个非 stdin 的 bot 作为平台框架版本(stdin 是本地终端,无 NapCat)
291
+ const primaryFramework =
292
+ botStatuses.find((b) => b.adapter !== "stdin")?.framework ??
293
+ botStatuses[0]?.framework ??
294
+ "unknown";
319
295
  return {
320
296
  miokuVersion,
321
297
  miokiVersion,
322
- napcatVersion:
323
- miokiStatus?.versions?.napcat ?? botStatuses[0]?.framework ?? "unknown",
324
- pluginCount: safeNumber(miokiStatus?.plugins?.total),
325
- pluginEnabled: safeNumber(miokiStatus?.plugins?.enabled),
298
+ napcatVersion: primaryFramework,
299
+ pluginCount,
300
+ pluginEnabled: pluginCount,
326
301
  adapterCount: adapters.size,
327
302
  onlineBotCount: botStatuses.filter((b) => b.online).length,
328
303
  uptimeMs: safeNumber(process.uptime()) * 1000,
@@ -632,7 +607,7 @@ async function collectSystem(): Promise<SystemInfo> {
632
607
  };
633
608
  }
634
609
 
635
- async function collectAI(ctx: MiokiContext): Promise<AIUsageStatsLite> {
610
+ async function collectAI(ctx: MiokuContext): Promise<AIUsageStatsLite> {
636
611
  const ai = ctx?.services?.ai as AIService | undefined;
637
612
  if (!ai || typeof ai.getUsageSummary !== "function") {
638
613
  return {
@@ -691,16 +666,29 @@ async function collectAI(ctx: MiokiContext): Promise<AIUsageStatsLite> {
691
666
  }
692
667
 
693
668
  export async function collectSnapshot(
694
- ctx: MiokiContext,
669
+ ctx: MiokuContext,
695
670
  options: { isNightMode: boolean } = { isNightMode: false },
696
671
  ): Promise<StatusSnapshot> {
697
672
  if (cache && Date.now() - cache.at < TTL_MS) {
698
673
  return { ...cache.snapshot, isNightMode: options.isNightMode };
699
674
  }
700
675
 
676
+ // 读取 help/status 配置(stdin 头像等),失败时回退到默认值
677
+ let statusConfig: HelpStatusConfig = {};
678
+ try {
679
+ const configService = getService(ctx, Services.Config);
680
+ statusConfig =
681
+ ((await configService?.getConfig("help", "status")) as
682
+ | HelpStatusConfig
683
+ | null
684
+ | undefined) ?? {};
685
+ } catch {
686
+ statusConfig = {};
687
+ }
688
+
701
689
  const rawBots = Array.from(connectedBots.values());
702
690
  const [bots, disk, system, ai, resources] = await Promise.all([
703
- collectBots(),
691
+ collectBots(ctx, statusConfig.stdinAvatar),
704
692
  collectDisk(),
705
693
  collectSystem(),
706
694
  collectAI(ctx),
@@ -1,10 +1,5 @@
1
- import {
2
- escapeHtml,
3
- } from "../utils";
4
- import {
5
- getHelpTheme,
6
- HELP_BACKGROUND_IMAGE_URL,
7
- } from "../theme";
1
+ import { escapeHtml } from "../utils";
2
+ import { getHelpTheme, HELP_BACKGROUND_IMAGE_URL } from "../theme";
8
3
  import type {
9
4
  AIUsageStatsLite,
10
5
  BotAccountStatus,
@@ -76,7 +71,10 @@ function progressBar(percent: number, color: string): string {
76
71
  return `<div class="status-bar"><div class="status-bar__fill" style="width:${clamped}%;background:${color};"></div></div>`;
77
72
  }
78
73
 
79
- function progressColor(percent: number, theme: ReturnType<typeof getHelpTheme>): string {
74
+ function progressColor(
75
+ percent: number,
76
+ theme: ReturnType<typeof getHelpTheme>,
77
+ ): string {
80
78
  if (percent >= 85) {
81
79
  return "linear-gradient(90deg, #ef4444, #f97316)";
82
80
  }
@@ -86,7 +84,10 @@ function progressColor(percent: number, theme: ReturnType<typeof getHelpTheme>):
86
84
  return `linear-gradient(90deg, ${theme.eyebrow}, ${theme.commandTitle})`;
87
85
  }
88
86
 
89
- function pieColor(percent: number, theme: ReturnType<typeof getHelpTheme>): string {
87
+ function pieColor(
88
+ percent: number,
89
+ theme: ReturnType<typeof getHelpTheme>,
90
+ ): string {
90
91
  if (percent >= 85) {
91
92
  return "#ef4444";
92
93
  }
@@ -96,7 +97,10 @@ function pieColor(percent: number, theme: ReturnType<typeof getHelpTheme>): stri
96
97
  return theme.eyebrow;
97
98
  }
98
99
 
99
- function renderPieChart(percent: number, theme: ReturnType<typeof getHelpTheme>): string {
100
+ function renderPieChart(
101
+ percent: number,
102
+ theme: ReturnType<typeof getHelpTheme>,
103
+ ): string {
100
104
  const clamped = Math.max(0, Math.min(100, percent));
101
105
  const filled = (clamped / 100) * PIE_CIRCUMFERENCE;
102
106
  const rest = PIE_CIRCUMFERENCE - filled;
@@ -128,33 +132,41 @@ function renderHero(
128
132
  ): string {
129
133
  const bots = snapshot.bots;
130
134
 
131
- const accountRows = bots.length === 0
132
- ? `<div class="status-hero__empty">当前没有在线账号</div>`
133
- : bots
134
- .map((bot) => {
135
- const statusText = bot.online ? "在线" : "离线";
136
- const statusColor = bot.online ? "#10b981" : "#ef4444";
137
- const frameworkText =
138
- bot.appVersion && bot.appVersion !== "unknown"
139
- ? `${bot.framework} ${bot.appVersion} · 协议 ${bot.protocolVersion}`
140
- : bot.framework;
141
- // Nickname is rendered as a bold large heading above the avatar
142
- // row (not as a chip). Chips below only carry the metadata.
143
- const tags = [
144
- { text: String(bot.uin || "—"), kind: "data" },
145
- { text: statusText, kind: bot.online ? "ok" : "danger" },
146
- { text: `好友 ${fmtNumber(bot.friendCount)}`, kind: "data" },
147
- { text: `群聊 ${fmtNumber(bot.groupCount)}`, kind: "data" },
148
- { text: `收 ${fmtNumber(bot.receive)}`, kind: "data" },
149
- { text: `发 ${fmtNumber(bot.send)}`, kind: "data" },
150
- { text: frameworkText, kind: "data" },
151
- ];
152
- const tagsHtml = tags
153
- .map(
154
- (t) => `<span class="status-chip status-chip--${t.kind}">${escapeHtml(t.text)}</span>`,
155
- )
156
- .join("");
157
- return `
135
+ const accountRows =
136
+ bots.length === 0
137
+ ? `<div class="status-hero__empty">当前没有在线账号</div>`
138
+ : bots
139
+ .map((bot) => {
140
+ const statusText = bot.online ? "在线" : "离线";
141
+ const statusColor = bot.online ? "#10b981" : "#ef4444";
142
+ const implText =
143
+ bot.adapter === "stdin"
144
+ ? `${bot.adapter}@${bot.appVersion}`
145
+ : bot.appVersion
146
+ ? `${bot.framework} ${bot.appVersion}${bot.protocolVersion ? ` · 协议 ${bot.protocolVersion}` : ""}`
147
+ : bot.framework;
148
+ const platformText = bot.platform
149
+ ? `${bot.platform}${bot.platformVersion ? ` v${bot.platformVersion}` : ""}`
150
+ : "";
151
+ // Nickname is rendered as a bold large heading above the avatar
152
+ // row (not as a chip). Chips below only carry the metadata.
153
+ const tags = [
154
+ { text: String(bot.uin || "—"), kind: "data" },
155
+ { text: statusText, kind: bot.online ? "ok" : "danger" },
156
+ { text: `好友 ${fmtNumber(bot.friendCount)}`, kind: "data" },
157
+ { text: `群聊 ${fmtNumber(bot.groupCount)}`, kind: "data" },
158
+ { text: `收 ${fmtNumber(bot.receive)}`, kind: "data" },
159
+ { text: `发 ${fmtNumber(bot.send)}`, kind: "data" },
160
+ { text: implText, kind: "data" },
161
+ ...(platformText ? [{ text: platformText, kind: "data" }] : []),
162
+ ];
163
+ const tagsHtml = tags
164
+ .map(
165
+ (t) =>
166
+ `<span class="status-chip status-chip--${t.kind}">${escapeHtml(t.text)}</span>`,
167
+ )
168
+ .join("");
169
+ return `
158
170
  <div class="status-hero__account">
159
171
  <div class="status-hero__account-name">${escapeHtml(bot.nickname)}</div>
160
172
  <div class="status-hero__account-body">
@@ -166,8 +178,8 @@ function renderHero(
166
178
  </div>
167
179
  </div>
168
180
  `;
169
- })
170
- .join("");
181
+ })
182
+ .join("");
171
183
 
172
184
  return `
173
185
  <header class="status-hero">
@@ -187,8 +199,7 @@ function renderResourceCard(
187
199
  ): string {
188
200
  const linesHtml = lines
189
201
  .map(
190
- (line) =>
191
- `<div class="status-pie-card__line">${escapeHtml(line)}</div>`,
202
+ (line) => `<div class="status-pie-card__line">${escapeHtml(line)}</div>`,
192
203
  )
193
204
  .join("");
194
205
  return `
@@ -228,7 +239,10 @@ function renderResourcesSection(
228
239
  "SWAP",
229
240
  hasSwap ? r.swapPercent : 0,
230
241
  hasSwap
231
- ? [`${r.swapUsedGB} / ${r.swapTotalGB} GB`, `可用 ${Math.max(0, r.swapTotalGB - r.swapUsedGB).toFixed(2)} GB`]
242
+ ? [
243
+ `${r.swapUsedGB} / ${r.swapTotalGB} GB`,
244
+ `可用 ${Math.max(0, r.swapTotalGB - r.swapUsedGB).toFixed(2)} GB`,
245
+ ]
232
246
  : ["未配置", ""],
233
247
  theme,
234
248
  ),
@@ -296,10 +310,10 @@ function buildSmoothPath(points: Array<{ x: number; y: number }>): string {
296
310
  const p1 = points[i];
297
311
  const p2 = points[i + 1];
298
312
  const p3 = points[Math.min(points.length - 1, i + 2)];
299
- const cp1x = p1.x + (p2.x - p0.x) * tension / 3;
300
- const cp1y = p1.y + (p2.y - p0.y) * tension / 3;
301
- const cp2x = p2.x - (p3.x - p1.x) * tension / 3;
302
- const cp2y = p2.y - (p3.y - p1.y) * tension / 3;
313
+ const cp1x = p1.x + ((p2.x - p0.x) * tension) / 3;
314
+ const cp1y = p1.y + ((p2.y - p0.y) * tension) / 3;
315
+ const cp2x = p2.x - ((p3.x - p1.x) * tension) / 3;
316
+ const cp2y = p2.y - ((p3.y - p1.y) * tension) / 3;
303
317
  d += ` C ${cp1x.toFixed(2)} ${cp1y.toFixed(2)}, ${cp2x.toFixed(2)} ${cp2y.toFixed(2)}, ${p2.x.toFixed(2)} ${p2.y.toFixed(2)}`;
304
318
  }
305
319
  return d;
@@ -319,10 +333,7 @@ function renderNetworkChart(history: NetworkSample[]): string {
319
333
  return `<svg viewBox="0 0 ${w} ${h}" class="status-chart"><text x="${w / 2}" y="${h / 2}" text-anchor="middle" fill="#94a3b8" font-size="11">数据采集中…</text></svg>`;
320
334
  }
321
335
 
322
- const maxVal = Math.max(
323
- 1,
324
- ...history.map((s) => Math.max(s.rxBps, s.txBps)),
325
- );
336
+ const maxVal = Math.max(1, ...history.map((s) => Math.max(s.rxBps, s.txBps)));
326
337
  const yMax = maxVal * 1.2;
327
338
  const t0 = history[0].ts;
328
339
  const t1 = history[history.length - 1].ts;
@@ -484,9 +495,7 @@ function renderSystemSection(
484
495
  } else {
485
496
  s.memSticks.forEach((m, i) => {
486
497
  const size =
487
- m.sizeGB >= 1
488
- ? `${m.sizeGB} GB`
489
- : `${(m.sizeGB * 1024).toFixed(0)} MB`;
498
+ m.sizeGB >= 1 ? `${m.sizeGB} GB` : `${(m.sizeGB * 1024).toFixed(0)} MB`;
490
499
  const speed = m.speedMTs > 0 ? ` · ${m.speedMTs} MT/s` : "";
491
500
  const manu =
492
501
  m.manufacturer && m.manufacturer !== "Manufacturer"
@@ -524,7 +533,10 @@ function renderSystemSection(
524
533
  : `${d.sizeGB} GB`;
525
534
  const vendor = d.vendor && d.vendor !== d.name ? `${d.vendor} ` : "";
526
535
  const name = d.name || d.type || "Unknown";
527
- const iface = d.interfaceType && d.interfaceType !== "Unknown" ? ` · ${d.interfaceType}` : "";
536
+ const iface =
537
+ d.interfaceType && d.interfaceType !== "Unknown"
538
+ ? ` · ${d.interfaceType}`
539
+ : "";
528
540
  items.push({
529
541
  label: `硬盘 ${i + 1}`,
530
542
  value: `${vendor}${name} · ${sizeLabel}${iface}`.trim(),
package/status/image.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { MiokiContext, ScreenshotService } from "mioku";
1
+ import type { MiokuContext, ScreenshotService } from "mioku";
2
2
  import { checkNightMode } from "../utils";
3
3
  import { collectSnapshot } from "./data-collector";
4
4
  import { renderStatusHtml } from "./html-generator";
@@ -28,7 +28,7 @@ function withTimeout<T>(p: PromiseLike<T>, ms: number): Promise<T> {
28
28
  }
29
29
 
30
30
  export interface GenerateStatusImageOptions {
31
- ctx: MiokiContext;
31
+ ctx: MiokuContext;
32
32
  event?: any;
33
33
  intent: StatusIntent;
34
34
  botNickname?: string;
package/status/intent.ts CHANGED
@@ -15,7 +15,7 @@ function stripStopword(input: string): string {
15
15
  return String(input || "")
16
16
  .trim()
17
17
  .replace(/^[#/\s]+/, "")
18
- .replace(/[。.!!??,,::;;]+$/g, "");
18
+ .replace(/[。.!!??,,::;);]+$/g, "");
19
19
  }
20
20
 
21
21
  function normalize(value: string): string {
@@ -1,4 +1,4 @@
1
- import { systemInfo } from "mioki";
1
+ import systemInfo from "systeminformation";
2
2
  import type { NetworkSample } from "./types";
3
3
 
4
4
  /**
package/status/types.ts CHANGED
@@ -7,15 +7,22 @@
7
7
  */
8
8
 
9
9
  export interface BotAccountStatus {
10
- uin: number;
10
+ /** 平台账号标识。QQ 机器人是数字字符串,stdin 等本地适配器是 "stdin"。 */
11
+ uin: string;
11
12
  nickname: string;
12
13
  avatarUrl: string;
13
- /** Underlying bot framework identifier, e.g. "QQBot" / "NapCat" / "LLOneBot". */
14
+ /** 适配器名,如 "stdin" / "onebotv11" / "icqq"。 */
15
+ adapter: string;
16
+ /** 实现端名称,如 "NapCat" / "LLOneBot" / "ICQQ",取不到时为空串 */
14
17
  framework: string;
15
- /** Adapter app version, e.g. "5.0.6" — from OneBot `get_version_info.app_version`. */
18
+ /** 实现端版本,如 "5.0.6"*/
16
19
  appVersion: string;
17
- /** OneBot protocol version, e.g. "v11" — from OneBot `get_version_info.protocol_version`. */
20
+ /** 协议版本,如 OneBot 的 "v11",没有则为空串 */
18
21
  protocolVersion: string;
22
+ /** 登录设备,如 icqq 的 "aPad",没有则为空串 */
23
+ platform: string;
24
+ /** 登录设备对应的客户端版本,如 "9.3.50.40225",没有则为空串 */
25
+ platformVersion: string;
19
26
  online: boolean;
20
27
  groupCount: number;
21
28
  friendCount: number;
@@ -23,27 +30,6 @@ export interface BotAccountStatus {
23
30
  receive: number;
24
31
  }
25
32
 
26
- /**
27
- * OneBot v11 `get_version_info` payload (already unwrapped from the
28
- * `{ status, retcode, data, ... }` envelope by napcat-sdk's `bot.api`).
29
- */
30
- export interface OneBotVersionInfoData {
31
- app_name: string;
32
- protocol_version: string;
33
- app_version: string;
34
- }
35
-
36
- /**
37
- * OneBot v11 `get_status` payload (already unwrapped by napcat-sdk).
38
- * Per napcat 官方文档: `{ online, good, stat }` —— `stat` 是空对象,
39
- * 不包含 `start_time` 或任何统计字段。本接口只声明文档承诺的字段。
40
- */
41
- export interface OneBotStatusData {
42
- online: boolean;
43
- good: boolean;
44
- stat: Record<string, never>;
45
- }
46
-
47
33
  /** Subset of mioku's `AIService.getUsageSummary` payload that we actually render. */
48
34
  export interface AIUsageSummary {
49
35
  totals?: {
@@ -123,13 +109,11 @@ export interface NodeRuntimeStatus {
123
109
  arrayBuffersMB: number;
124
110
  eventLoopDelayMs: { mean: number; p99: number };
125
111
  /** null when `--expose-gc` is not enabled. */
126
- gc:
127
- | {
128
- available: boolean;
129
- count: number;
130
- lastDurationMs?: number;
131
- }
132
- | null;
112
+ gc: {
113
+ available: boolean;
114
+ count: number;
115
+ lastDurationMs?: number;
116
+ } | null;
133
117
  }
134
118
 
135
119
  export interface NetworkSample {
package/utils.ts CHANGED
@@ -96,9 +96,9 @@ export async function getRenderVersions(): Promise<{
96
96
 
97
97
  const miokiCandidates = [
98
98
  // Nested under mioku (the typical workspace case).
99
- path.join(process.cwd(), "node_modules", "mioku", "node_modules", "mioki", "package.json"),
99
+ path.join(process.cwd(), "node_modules", "mioku", "node_modules", "mioku", "package.json"),
100
100
  // Hoisted top-level (a single-package install).
101
- path.join(process.cwd(), "node_modules", "mioki", "package.json"),
101
+ path.join(process.cwd(), "node_modules", "mioku", "package.json"),
102
102
  ];
103
103
  const miokuCandidates = [
104
104
  path.join(process.cwd(), "node_modules", "mioku", "package.json"),
@@ -107,7 +107,7 @@ export async function getRenderVersions(): Promise<{
107
107
 
108
108
  let miokiVersion = "unknown";
109
109
  for (const candidate of miokiCandidates) {
110
- const v = await readNamedPackageVersion(candidate, "mioki");
110
+ const v = await readNamedPackageVersion(candidate, "mioku");
111
111
  if (v !== "unknown") {
112
112
  miokiVersion = v;
113
113
  break;