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
package/help/image.ts
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Help image orchestration.
|
|
3
|
+
*
|
|
4
|
+
* `generateHelpImage` is the high-level entry point: it takes a help
|
|
5
|
+
* service, builds the HTML, and asks the screenshot service to render
|
|
6
|
+
* the PNG. The lower-level functions cover:
|
|
7
|
+
*
|
|
8
|
+
* - Image source normalization (`normalizeImageSource`)
|
|
9
|
+
* - Bot profile resolution (`resolveHelpBotProfile`)
|
|
10
|
+
* - Two reply helpers (`replyWithImage`, `sendImageFromSkillContext`)
|
|
11
|
+
* that handle the local file → base64 fallback when the network
|
|
12
|
+
* adapter can't send a raw path.
|
|
13
|
+
*
|
|
14
|
+
* Version detection lives in `../utils#getRenderVersions` so the help
|
|
15
|
+
* panel and the status panel share the same logic.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import * as fs from "node:fs/promises";
|
|
19
|
+
import type { HelpService, ScreenshotService } from "mioku";
|
|
20
|
+
import { checkNightMode } from "../utils";
|
|
21
|
+
import { generateHelpHtml } from "./html-generator";
|
|
22
|
+
|
|
23
|
+
/** POSIX or Windows absolute path → `file://...`; anything else passes through. */
|
|
24
|
+
function isLocalFilePath(value: string): boolean {
|
|
25
|
+
return value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Prefix remote / data-URI / absolute paths so the bot framework can route them. */
|
|
29
|
+
export function normalizeImageSource(file: string): string {
|
|
30
|
+
const value = String(file || "").trim();
|
|
31
|
+
if (!value) {
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (
|
|
36
|
+
value.startsWith("file://") ||
|
|
37
|
+
value.startsWith("base64://") ||
|
|
38
|
+
value.startsWith("http://") ||
|
|
39
|
+
value.startsWith("https://")
|
|
40
|
+
) {
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (isLocalFilePath(value)) {
|
|
45
|
+
return `file://${value}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Build the help image and write it to disk via the screenshot service.
|
|
53
|
+
* Returns the resulting file path, or `null` if any required service is
|
|
54
|
+
* missing.
|
|
55
|
+
*/
|
|
56
|
+
export async function generateHelpImage(options: {
|
|
57
|
+
helpService?: HelpService;
|
|
58
|
+
screenshotService?: ScreenshotService;
|
|
59
|
+
miokiVersion?: string;
|
|
60
|
+
miokuVersion?: string;
|
|
61
|
+
botNickname?: string;
|
|
62
|
+
botAvatarUrl?: string;
|
|
63
|
+
targetPluginName?: string;
|
|
64
|
+
}): Promise<string | null> {
|
|
65
|
+
const {
|
|
66
|
+
helpService,
|
|
67
|
+
screenshotService,
|
|
68
|
+
miokiVersion,
|
|
69
|
+
miokuVersion,
|
|
70
|
+
botNickname,
|
|
71
|
+
botAvatarUrl,
|
|
72
|
+
targetPluginName,
|
|
73
|
+
} = options;
|
|
74
|
+
if (!helpService || !screenshotService) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const allHelp = helpService.getAllHelp();
|
|
79
|
+
const hasTarget =
|
|
80
|
+
Boolean(targetPluginName) && allHelp.has(String(targetPluginName));
|
|
81
|
+
|
|
82
|
+
const htmlContent = generateHelpHtml(
|
|
83
|
+
allHelp,
|
|
84
|
+
checkNightMode(),
|
|
85
|
+
miokiVersion,
|
|
86
|
+
miokuVersion,
|
|
87
|
+
botNickname,
|
|
88
|
+
botAvatarUrl,
|
|
89
|
+
hasTarget ? targetPluginName : undefined,
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
return screenshotService.screenshot(htmlContent, {
|
|
93
|
+
width: 760,
|
|
94
|
+
height: 120,
|
|
95
|
+
fullPage: true,
|
|
96
|
+
type: "png",
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Pick a bot's nickname + avatar URL. Used so the help card can greet
|
|
102
|
+
* the user with their bot's identity rather than a generic string.
|
|
103
|
+
*/
|
|
104
|
+
export function resolveHelpBotProfile(
|
|
105
|
+
ctx: any,
|
|
106
|
+
event?: any,
|
|
107
|
+
): { botNickname: string; botAvatarUrl?: string } {
|
|
108
|
+
const fallbackNickname = "Mioku Bot";
|
|
109
|
+
const selfId = event?.self_id;
|
|
110
|
+
const bot =
|
|
111
|
+
(selfId && typeof ctx?.pickBot === "function"
|
|
112
|
+
? ctx.pickBot(selfId)
|
|
113
|
+
: null) ||
|
|
114
|
+
(ctx?.bots instanceof Map ? Array.from(ctx.bots.values())[0] : null);
|
|
115
|
+
const botId = selfId || bot?.uin || bot?.user_id || bot?.self_id;
|
|
116
|
+
const botNickname = bot?.nickname || bot?.name || fallbackNickname;
|
|
117
|
+
const botAvatarUrl = botId
|
|
118
|
+
? `https://q1.qlogo.cn/g?b=qq&nk=${botId}&s=640`
|
|
119
|
+
: undefined;
|
|
120
|
+
|
|
121
|
+
return { botNickname, botAvatarUrl };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Reply to a chat event with a local image file. Falls back to base64
|
|
126
|
+
* encoding if the network adapter can't send a raw file path.
|
|
127
|
+
*/
|
|
128
|
+
export async function replyWithImage(
|
|
129
|
+
event: any,
|
|
130
|
+
segment: { image: (file: string) => any } | undefined,
|
|
131
|
+
imagePath: string,
|
|
132
|
+
): Promise<void> {
|
|
133
|
+
if (!event?.reply) {
|
|
134
|
+
throw new Error("当前上下文不支持发送图片回复");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
if (segment?.image) {
|
|
139
|
+
await event.reply(segment.image(imagePath));
|
|
140
|
+
} else {
|
|
141
|
+
await event.reply([{ type: "image", file: imagePath }]);
|
|
142
|
+
}
|
|
143
|
+
} catch {
|
|
144
|
+
const imageBuffer = await fs.readFile(imagePath);
|
|
145
|
+
const base64Image = `base64://${imageBuffer.toString("base64")}`;
|
|
146
|
+
|
|
147
|
+
if (segment?.image) {
|
|
148
|
+
await event.reply(segment.image(base64Image));
|
|
149
|
+
} else {
|
|
150
|
+
await event.reply([{ type: "image", file: base64Image }]);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Send an image from inside an AI skill handler. Picks the right bot via
|
|
157
|
+
* `ctx.pickBot(selfId)`, supports an optional quote-reply, and falls
|
|
158
|
+
* back to base64 when the adapter refuses a raw path.
|
|
159
|
+
*/
|
|
160
|
+
export async function sendImageFromSkillContext(options: {
|
|
161
|
+
ctx: any;
|
|
162
|
+
event: any;
|
|
163
|
+
imagePath: string;
|
|
164
|
+
quoteReply?: boolean;
|
|
165
|
+
}): Promise<void> {
|
|
166
|
+
const { ctx, event, imagePath, quoteReply = false } = options;
|
|
167
|
+
const selfId = event?.self_id != null ? Number(event.self_id) : undefined;
|
|
168
|
+
const bot =
|
|
169
|
+
selfId != null && typeof ctx?.pickBot === "function"
|
|
170
|
+
? ctx.pickBot(selfId)
|
|
171
|
+
: undefined;
|
|
172
|
+
|
|
173
|
+
if (!bot) {
|
|
174
|
+
throw new Error("当前上下文不支持发送图片");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const buildImageSegment = (file: string) => {
|
|
178
|
+
const normalizedFile = normalizeImageSource(file);
|
|
179
|
+
if (ctx?.segment?.image) {
|
|
180
|
+
return ctx.segment.image(normalizedFile);
|
|
181
|
+
}
|
|
182
|
+
return { type: "image", file: normalizedFile };
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const sendPayload = async (file: string) => {
|
|
186
|
+
const payload: any[] = [];
|
|
187
|
+
if (quoteReply && event?.message_id != null) {
|
|
188
|
+
payload.push({ type: "reply", id: String(event.message_id) });
|
|
189
|
+
}
|
|
190
|
+
payload.push(buildImageSegment(file));
|
|
191
|
+
|
|
192
|
+
if (event?.message_type === "group" && event?.group_id != null) {
|
|
193
|
+
await bot.sendGroupMsg(event.group_id, payload);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (event?.user_id != null) {
|
|
198
|
+
await bot.sendPrivateMsg(event.user_id, payload);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
throw new Error("当前上下文不支持发送图片");
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
await sendPayload(imagePath);
|
|
207
|
+
} catch (error) {
|
|
208
|
+
if (!isLocalFilePath(imagePath)) {
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const imageBuffer = await fs.readFile(imagePath);
|
|
213
|
+
const base64Image = `base64://${imageBuffer.toString("base64")}`;
|
|
214
|
+
await sendPayload(base64Image);
|
|
215
|
+
}
|
|
216
|
+
}
|
package/help/index.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public API for the help feature.
|
|
3
|
+
*
|
|
4
|
+
* Everything the plugin entry point and the AI skills need from the
|
|
5
|
+
* help subsystem re-exports from here. Internal modules stay private
|
|
6
|
+
* to their concerns.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export {
|
|
10
|
+
generateHelpImage,
|
|
11
|
+
resolveHelpBotProfile,
|
|
12
|
+
replyWithImage,
|
|
13
|
+
sendImageFromSkillContext,
|
|
14
|
+
normalizeImageSource,
|
|
15
|
+
} from "./image";
|
|
16
|
+
export {
|
|
17
|
+
resolveHelpImageIntent,
|
|
18
|
+
findPluginHelpByKeyword,
|
|
19
|
+
getRenderableEntries,
|
|
20
|
+
} from "./intent";
|
|
21
|
+
export { buildHelpInfoText } from "./info";
|
|
22
|
+
export { generateHelpHtml } from "./html-generator";
|
|
23
|
+
export type { HelpImageIntent, HelpRenderableEntry } from "./types";
|
|
24
|
+
export { ROLE_CONFIG, STOPWORDS } from "./role-config";
|
package/help/info.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build a plain-text version of the help registry.
|
|
3
|
+
*
|
|
4
|
+
* Used by AI skills: when the LLM is asked about a feature, the
|
|
5
|
+
* `get_help_info` tool returns this text instead of an image, so the
|
|
6
|
+
* model can read what's available without vision.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { PluginHelp } from "mioku";
|
|
10
|
+
import { ROLE_CONFIG } from "./role-config";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Render the entire help registry as a single string the AI can read.
|
|
14
|
+
* Format:
|
|
15
|
+
*
|
|
16
|
+
* === Mioku Bot 帮助信息 ===
|
|
17
|
+
*
|
|
18
|
+
* 【插件标题】描述
|
|
19
|
+
* #cmd [角色] - 描述
|
|
20
|
+
* ...
|
|
21
|
+
*/
|
|
22
|
+
export function buildHelpInfoText(
|
|
23
|
+
helpMap: Map<string, PluginHelp>,
|
|
24
|
+
): string {
|
|
25
|
+
const info: string[] = ["=== Mioku Bot 帮助信息 ===\n"];
|
|
26
|
+
|
|
27
|
+
for (const [pluginName, help] of helpMap) {
|
|
28
|
+
info.push(`【${help.title || pluginName}】${help.description || ""}`);
|
|
29
|
+
if (help.commands?.length) {
|
|
30
|
+
for (const cmd of help.commands) {
|
|
31
|
+
const roleLabel = cmd.role
|
|
32
|
+
? ` [${ROLE_CONFIG[cmd.role]?.label || cmd.role}]`
|
|
33
|
+
: "";
|
|
34
|
+
info.push(` ${cmd.cmd}${roleLabel} - ${cmd.desc}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
info.push("");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return info.join("\n");
|
|
41
|
+
}
|
package/help/intent.ts
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Help intent resolution and keyword matching.
|
|
3
|
+
*
|
|
4
|
+
* `resolveHelpImageIntent` parses a chat message into one of four actions:
|
|
5
|
+
* "show overview", "show detail for plugin X", "we don't recognize this
|
|
6
|
+
* plugin", or "not a help command at all". Matching is fuzzy: we accept
|
|
7
|
+
* plugin names, titles, command aliases, and Chinese/English substrings.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { PluginHelp } from "mioku";
|
|
11
|
+
import { STOPWORDS } from "./role-config";
|
|
12
|
+
import type {
|
|
13
|
+
HelpImageIntent,
|
|
14
|
+
HelpKeywordCandidate,
|
|
15
|
+
HelpRenderableEntry,
|
|
16
|
+
} from "./types";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Strip the leading command marker (`#`, `/`) and trailing punctuation,
|
|
20
|
+
* then collapse to a comparable form. Returns "" for empty input.
|
|
21
|
+
*/
|
|
22
|
+
function sanitizeKeyword(value: string): string {
|
|
23
|
+
return String(value || "")
|
|
24
|
+
.trim()
|
|
25
|
+
.replace(/^[#/\s]+/, "")
|
|
26
|
+
.replace(/[。.!!??,,::;;]+$/g, "");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Lowercase + strip everything that isn't a-z, 0-9, or CJK. */
|
|
30
|
+
function normalizeForMatch(value: string): string {
|
|
31
|
+
const parts = String(value || "")
|
|
32
|
+
.toLowerCase()
|
|
33
|
+
.match(/[a-z0-9\u4e00-\u9fa5]+/gi);
|
|
34
|
+
|
|
35
|
+
return parts ? parts.join("") : "";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Pull every token of length ≥ 2 that isn't a stop-word. */
|
|
39
|
+
function extractMatchTokens(text: string): string[] {
|
|
40
|
+
const rawTokens = String(text || "")
|
|
41
|
+
.toLowerCase()
|
|
42
|
+
.match(/[a-z0-9\u4e00-\u9fa5]+/gi);
|
|
43
|
+
|
|
44
|
+
if (!rawTokens) {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const tokens = rawTokens
|
|
49
|
+
.map((token) => normalizeForMatch(token))
|
|
50
|
+
.filter((token) => token.length >= 2)
|
|
51
|
+
.filter((token) => !STOPWORDS.has(token));
|
|
52
|
+
|
|
53
|
+
return Array.from(new Set(tokens));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The first whitespace-separated token of a command, with the `#` / `/`
|
|
58
|
+
* prefix removed. Used as an additional alias when matching plugin
|
|
59
|
+
* commands. Returns null if the first token is empty, contains `<>` (a
|
|
60
|
+
* placeholder), or has no alphanumeric characters.
|
|
61
|
+
*/
|
|
62
|
+
function extractCommandAlias(command: string): string | null {
|
|
63
|
+
const value = String(command || "")
|
|
64
|
+
.trim()
|
|
65
|
+
.replace(/^[#/]+/, "");
|
|
66
|
+
if (!value) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const firstToken = value.split(/\s+/)[0];
|
|
71
|
+
if (!firstToken || /[<>]/.test(firstToken)) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (!/[a-z0-9]/i.test(firstToken)) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const normalized = normalizeForMatch(firstToken);
|
|
80
|
+
if (!normalized || STOPWORDS.has(normalized)) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return normalized;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Build a sorted list of `HelpRenderableEntry` from a help map. The
|
|
89
|
+
* rendered entries are the source of truth for both keyword scoring
|
|
90
|
+
* (in this file) and the HTML renderer's overview list. Exported so
|
|
91
|
+
* `html-generator.ts` can reuse it instead of duplicating the logic.
|
|
92
|
+
*/
|
|
93
|
+
export function getRenderableEntries(
|
|
94
|
+
helpMap: Map<string, PluginHelp>,
|
|
95
|
+
): HelpRenderableEntry[] {
|
|
96
|
+
return Array.from(helpMap.entries())
|
|
97
|
+
.map(([pluginName, help]) => {
|
|
98
|
+
const title = String(help.title || pluginName).trim() || pluginName;
|
|
99
|
+
const description = String(help.description || "").trim();
|
|
100
|
+
const commands = Array.isArray(help.commands) ? help.commands : [];
|
|
101
|
+
const normalizedPluginName = normalizeForMatch(pluginName);
|
|
102
|
+
const normalizedTitle = normalizeForMatch(title);
|
|
103
|
+
|
|
104
|
+
const keys = new Set<string>();
|
|
105
|
+
if (normalizedPluginName) {
|
|
106
|
+
keys.add(normalizedPluginName);
|
|
107
|
+
}
|
|
108
|
+
if (normalizedTitle) {
|
|
109
|
+
keys.add(normalizedTitle);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
for (const token of extractMatchTokens(title)) {
|
|
113
|
+
keys.add(token);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const commandAliases = commands
|
|
117
|
+
.map((command) => extractCommandAlias(command.cmd))
|
|
118
|
+
.filter((value): value is string => Boolean(value));
|
|
119
|
+
for (const alias of commandAliases) {
|
|
120
|
+
keys.add(alias);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
pluginName,
|
|
125
|
+
title,
|
|
126
|
+
description,
|
|
127
|
+
commands,
|
|
128
|
+
normalizedPluginName,
|
|
129
|
+
normalizedTitle,
|
|
130
|
+
matchKeys: keys,
|
|
131
|
+
};
|
|
132
|
+
})
|
|
133
|
+
.sort((a, b) => a.pluginName.localeCompare(b.pluginName, "zh-Hans-CN"));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Score a query against an entry. Higher = better match. Used to break
|
|
138
|
+
* ties between fuzzy candidates.
|
|
139
|
+
*
|
|
140
|
+
* - 58: prefix overlap (e.g. "wea" matches "weather")
|
|
141
|
+
* - 46: title containment
|
|
142
|
+
* - 34: substring overlap
|
|
143
|
+
* - 0: no match
|
|
144
|
+
*/
|
|
145
|
+
function scoreKeywordMatch(
|
|
146
|
+
query: string,
|
|
147
|
+
entry: HelpRenderableEntry,
|
|
148
|
+
): number {
|
|
149
|
+
let score = 0;
|
|
150
|
+
|
|
151
|
+
if (
|
|
152
|
+
entry.normalizedTitle.includes(query) ||
|
|
153
|
+
query.includes(entry.normalizedTitle)
|
|
154
|
+
) {
|
|
155
|
+
score = Math.max(
|
|
156
|
+
score,
|
|
157
|
+
46 - Math.abs(entry.normalizedTitle.length - query.length),
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
for (const key of entry.matchKeys) {
|
|
162
|
+
if (key.startsWith(query) || query.startsWith(key)) {
|
|
163
|
+
score = Math.max(score, 58 - Math.abs(key.length - query.length));
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (key.includes(query) || query.includes(key)) {
|
|
168
|
+
score = Math.max(score, 34 - Math.abs(key.length - query.length));
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return Math.max(0, score);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Extract every possible plugin keyword from a chat message. Each
|
|
177
|
+
* candidate carries a `strictUnknown` flag: strict patterns (e.g. the
|
|
178
|
+
* user clearly asked about a specific plugin) should produce a
|
|
179
|
+
* "no plugin found" reply, loose patterns should silently fall through.
|
|
180
|
+
*/
|
|
181
|
+
function extractHelpKeywordCandidates(
|
|
182
|
+
text: string,
|
|
183
|
+
): HelpKeywordCandidate[] {
|
|
184
|
+
const source = text.trim();
|
|
185
|
+
const candidates: HelpKeywordCandidate[] = [];
|
|
186
|
+
|
|
187
|
+
const addCandidate = (value: string, strictUnknown: boolean) => {
|
|
188
|
+
const keyword = sanitizeKeyword(value);
|
|
189
|
+
if (!keyword) {
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (
|
|
193
|
+
!candidates.some(
|
|
194
|
+
(candidate) =>
|
|
195
|
+
candidate.keyword === keyword &&
|
|
196
|
+
candidate.strictUnknown === strictUnknown,
|
|
197
|
+
)
|
|
198
|
+
) {
|
|
199
|
+
candidates.push({ keyword, strictUnknown });
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const helpPrefixSeparated = source.match(
|
|
204
|
+
/^[#/]?\s*(?:help|帮助)(?:\s+|[::])\s*(.+)$/i,
|
|
205
|
+
);
|
|
206
|
+
if (helpPrefixSeparated?.[1]) {
|
|
207
|
+
addCandidate(helpPrefixSeparated[1], true);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const helpPrefixMerged = source.match(
|
|
211
|
+
/^[#/]?\s*(?:help|帮助)([a-z0-9\u4e00-\u9fa5]+)$/i,
|
|
212
|
+
);
|
|
213
|
+
if (helpPrefixMerged?.[1]) {
|
|
214
|
+
addCandidate(helpPrefixMerged[1], false);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (/^[#/]/.test(source)) {
|
|
218
|
+
return candidates;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const helpSuffixSeparated = source.match(/^(.+?)\s+(?:help|帮助)\s*$/i);
|
|
222
|
+
if (helpSuffixSeparated?.[1]) {
|
|
223
|
+
addCandidate(helpSuffixSeparated[1], true);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const helpSuffixMerged = source.match(
|
|
227
|
+
/^([a-z0-9\u4e00-\u9fa5]+)(?:help|帮助)\s*$/i,
|
|
228
|
+
);
|
|
229
|
+
if (helpSuffixMerged?.[1]) {
|
|
230
|
+
addCandidate(helpSuffixMerged[1], false);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const menuPrefix = source.match(
|
|
234
|
+
/^菜单\s*[::]?\s*([a-z0-9\u4e00-\u9fa5]+)$/i,
|
|
235
|
+
);
|
|
236
|
+
if (menuPrefix?.[1]) {
|
|
237
|
+
addCandidate(menuPrefix[1], false);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const menuSuffix = source.match(/^([a-z0-9\u4e00-\u9fa5]+)\s*菜单\s*$/i);
|
|
241
|
+
if (menuSuffix?.[1]) {
|
|
242
|
+
addCandidate(menuSuffix[1], false);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return candidates;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Resolve a keyword to a plugin help entry. Tries exact, then
|
|
250
|
+
* start-substring, then fuzzy. Returns null if the query is too short
|
|
251
|
+
* (< 2 chars), matches a stop-word, or has ambiguous results.
|
|
252
|
+
*/
|
|
253
|
+
export function findPluginHelpByKeyword(
|
|
254
|
+
helpMap: Map<string, PluginHelp>,
|
|
255
|
+
keyword: string,
|
|
256
|
+
): { pluginName: string; help: PluginHelp } | null {
|
|
257
|
+
const normalizedQuery = normalizeForMatch(sanitizeKeyword(keyword));
|
|
258
|
+
if (!normalizedQuery || STOPWORDS.has(normalizedQuery)) {
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const entries = getRenderableEntries(helpMap);
|
|
263
|
+
if (entries.length === 0) {
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const directPlugin = entries.find(
|
|
268
|
+
(entry) => entry.normalizedPluginName === normalizedQuery,
|
|
269
|
+
);
|
|
270
|
+
if (directPlugin) {
|
|
271
|
+
return {
|
|
272
|
+
pluginName: directPlugin.pluginName,
|
|
273
|
+
help: helpMap.get(directPlugin.pluginName)!,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const exactMatches = entries.filter((entry) =>
|
|
278
|
+
entry.matchKeys.has(normalizedQuery),
|
|
279
|
+
);
|
|
280
|
+
if (exactMatches.length === 1) {
|
|
281
|
+
return {
|
|
282
|
+
pluginName: exactMatches[0].pluginName,
|
|
283
|
+
help: helpMap.get(exactMatches[0].pluginName)!,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (normalizedQuery.length < 2) {
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const fuzzyMatches = entries
|
|
292
|
+
.map((entry) => ({
|
|
293
|
+
entry,
|
|
294
|
+
score: scoreKeywordMatch(normalizedQuery, entry),
|
|
295
|
+
}))
|
|
296
|
+
.filter((item) => item.score > 0)
|
|
297
|
+
.sort((a, b) => b.score - a.score);
|
|
298
|
+
|
|
299
|
+
if (fuzzyMatches.length === 0) {
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (
|
|
304
|
+
fuzzyMatches.length > 1 &&
|
|
305
|
+
fuzzyMatches[0].score === fuzzyMatches[1].score
|
|
306
|
+
) {
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const resolved = fuzzyMatches[0].entry;
|
|
311
|
+
return {
|
|
312
|
+
pluginName: resolved.pluginName,
|
|
313
|
+
help: helpMap.get(resolved.pluginName)!,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Parse a chat message into a help image action.
|
|
319
|
+
*
|
|
320
|
+
* Returns:
|
|
321
|
+
* - `{ type: "overview" }` for plain `#help` / `帮助` / `菜单`
|
|
322
|
+
* - `{ type: "detail", ... }` when a plugin name is recognized
|
|
323
|
+
* - `{ type: "unknown", keyword }` when the user clearly named a plugin
|
|
324
|
+
* but we couldn't find it (only for "strict" patterns like "X 帮助")
|
|
325
|
+
* - `{ type: "none" }` for anything else
|
|
326
|
+
*/
|
|
327
|
+
export function resolveHelpImageIntent(
|
|
328
|
+
text: string,
|
|
329
|
+
helpMap: Map<string, PluginHelp>,
|
|
330
|
+
): HelpImageIntent {
|
|
331
|
+
const source = String(text || "").trim();
|
|
332
|
+
if (!source) {
|
|
333
|
+
return { type: "none" };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (
|
|
337
|
+
/^[#/]/.test(source) &&
|
|
338
|
+
!/^[#/]\s*(?:help|帮助|菜单)/i.test(source)
|
|
339
|
+
) {
|
|
340
|
+
return { type: "none" };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (/^[#/]?\s*(?:help|帮助|菜单)\s*$/i.test(source)) {
|
|
344
|
+
return { type: "overview" };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const candidates = extractHelpKeywordCandidates(source);
|
|
348
|
+
if (candidates.length === 0) {
|
|
349
|
+
return { type: "none" };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
for (const candidate of candidates) {
|
|
353
|
+
const resolved = findPluginHelpByKeyword(helpMap, candidate.keyword);
|
|
354
|
+
if (resolved) {
|
|
355
|
+
return {
|
|
356
|
+
type: "detail",
|
|
357
|
+
pluginName: resolved.pluginName,
|
|
358
|
+
pluginHelp: resolved.help,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const strictCandidate = candidates.find(
|
|
364
|
+
(candidate) => candidate.strictUnknown,
|
|
365
|
+
);
|
|
366
|
+
if (!strictCandidate) {
|
|
367
|
+
return { type: "none" };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const fallback = sanitizeKeyword(strictCandidate.keyword);
|
|
371
|
+
if (!fallback) {
|
|
372
|
+
return { type: "none" };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return {
|
|
376
|
+
type: "unknown",
|
|
377
|
+
keyword: fallback,
|
|
378
|
+
};
|
|
379
|
+
}
|