mioku-plugin-chat 2.3.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,284 @@
1
+ import { logger } from "mioki";
2
+ import type { AITool } from "mioku";
3
+ import { TOOL_RESULT_FOLLOWUP_KEY } from "mioku";
4
+ import type { ToolContext } from "../../types";
5
+
6
+ async function createImageFollowupResult(
7
+ imageUrl: string,
8
+ text: string,
9
+ note: string,
10
+ ): Promise<Record<string, any>> {
11
+ let imageUrls = [imageUrl];
12
+ let gifFrameNote = "";
13
+
14
+ try {
15
+ const { isGifUrl, extractGifFrames } = await import("../media/gif-extractor");
16
+ if (await isGifUrl(imageUrl)) {
17
+ const result = await extractGifFrames(imageUrl);
18
+ if (result && result.frames.length > 0) {
19
+ imageUrls = result.frames;
20
+ gifFrameNote = ` The original image is an animated GIF; ${result.frames.length} extracted frame(s) are attached in order.`;
21
+ } else {
22
+ logger.warn("[view_media] Failed to extract GIF frames, attaching original image");
23
+ }
24
+ }
25
+ } catch (err) {
26
+ logger.warn(`[view_media] Failed to prepare image attachment: ${err}`);
27
+ }
28
+
29
+ const { prepareImageUrlsForModel } = await import("../media/image-compress");
30
+ const compressedImageUrls = await prepareImageUrlsForModel(imageUrls);
31
+
32
+ return {
33
+ success: true,
34
+ image_attached: true,
35
+ note: `${note}${gifFrameNote}`,
36
+ [TOOL_RESULT_FOLLOWUP_KEY]: {
37
+ text: `${text}${gifFrameNote}`,
38
+ images: compressedImageUrls.map((url) => ({ url, detail: "auto" })),
39
+ },
40
+ };
41
+ }
42
+
43
+ function createVideoFollowupResult(
44
+ videoUrl: string,
45
+ text: string,
46
+ note: string,
47
+ ): Record<string, any> {
48
+ return {
49
+ success: true,
50
+ video_attached: true,
51
+ note,
52
+ [TOOL_RESULT_FOLLOWUP_KEY]: {
53
+ text,
54
+ videos: [{ url: videoUrl, detail: "auto" }],
55
+ },
56
+ };
57
+ }
58
+
59
+ export function createInfoTools(toolCtx: ToolContext): AITool[] {
60
+ const tools: AITool[] = [];
61
+
62
+ if (toolCtx.groupId) {
63
+ tools.push({
64
+ name: "get_group_member_info",
65
+ description:
66
+ "Get detailed info about a group member,including gender, age, QQ rating, group level, group title, etc",
67
+ parameters: {
68
+ type: "object",
69
+ properties: {
70
+ user_id: { type: "number", description: "QQ number of the member" },
71
+ },
72
+ required: ["user_id"],
73
+ },
74
+ handler: async (args) => {
75
+ try {
76
+ const info = await toolCtx.ctx
77
+ .pickBot(toolCtx.event.self_id)
78
+ .getGroupMemberInfo(toolCtx.groupId!, args.user_id);
79
+ return {
80
+ nickname: info.nickname,
81
+ card: info.card,
82
+ sex: info.sex,
83
+ age: info.age,
84
+ area: info.area,
85
+ level: info.level,
86
+ qq_level: info.qq_level,
87
+ title: info.title,
88
+ };
89
+ } catch (err) {
90
+ return { error: `Failed to get member info: ${err}` };
91
+ }
92
+ },
93
+ });
94
+
95
+ tools.push({
96
+ name: "get_group_member_list",
97
+ description: "Get the list of group members (returns name and role only)",
98
+ parameters: { type: "object", properties: {} },
99
+ handler: async () => {
100
+ try {
101
+ const list = await toolCtx.ctx
102
+ .pickBot(toolCtx.event.self_id)
103
+ .getGroupMemberList(toolCtx.groupId!);
104
+ const members = (list as any[]).map((m) => ({
105
+ user_id: m.user_id,
106
+ nickname: m.card || m.nickname,
107
+ role: m.role,
108
+ }));
109
+ return { members: members.slice(0, 50), total: members.length };
110
+ } catch (err) {
111
+ return { error: `Failed to get member list: ${err}` };
112
+ }
113
+ },
114
+ });
115
+ }
116
+
117
+ tools.push({
118
+ name: "view_media",
119
+ description:
120
+ "View and analyze an image or video by its message ID. Use this when you need to see what's in an image or video to answer the user's question. The media will be analyzed and described to you.",
121
+ parameters: {
122
+ type: "object",
123
+ properties: {
124
+ message_id: {
125
+ type: "number",
126
+ description:
127
+ "The message ID (message_id) of the image or video. You can get this from the original message that contains the media.",
128
+ },
129
+ },
130
+ required: ["message_id"],
131
+ },
132
+ handler: async (args) => {
133
+ try {
134
+ const { getMediaByMessageId, describeImage } = await import("../multimodal");
135
+ const media = await getMediaByMessageId(toolCtx.ctx, args.message_id, toolCtx.event);
136
+
137
+ if (!media) {
138
+ return { error: "Image or video not found in the specified message" };
139
+ }
140
+
141
+ if (media.kind === "image") {
142
+ if (toolCtx.config.isMultimodal) {
143
+ return await createImageFollowupResult(
144
+ media.url,
145
+ `The image from message #${args.message_id} is attached. Inspect it directly and answer the user's question from the visual content.`,
146
+ "The image has been attached to the next main model request. Inspect it directly instead of relying on a worker-model description.",
147
+ );
148
+ }
149
+
150
+ const ai = toolCtx.aiService.getDefault();
151
+ if (!ai) return { error: "AI instance not available" };
152
+
153
+ const result = await describeImage(
154
+ ai,
155
+ media.url,
156
+ toolCtx.config.multimodalWorkingModel,
157
+ toolCtx.event?.raw_message || undefined,
158
+ );
159
+
160
+ if (!result.success) {
161
+ return { error: result.error || "Failed to analyze image" };
162
+ }
163
+
164
+ return {
165
+ success: true,
166
+ description: result.description,
167
+ note: "The image has been analyzed. Use the description above to answer the user's question.",
168
+ };
169
+ }
170
+
171
+ // 视频:超阈值一律交多模态工作模型概括(抽帧),否则按主模型是否多模态决定直接附加或概括。
172
+ const {
173
+ downloadVideoForAnalysis,
174
+ summarizeVideoContent,
175
+ probeVideoMimeType,
176
+ VIDEO_FULL_UPLOAD_MAX_BYTES,
177
+ } = await import("../media/history-media");
178
+
179
+ const videoFile = await downloadVideoForAnalysis(media.sources, {
180
+ logger: {
181
+ info: (m) => logger.info(m),
182
+ warn: (m) => logger.warn(m),
183
+ error: (m) => logger.error(m),
184
+ },
185
+ });
186
+
187
+ try {
188
+ const attachFullVideo =
189
+ toolCtx.config.isMultimodal && videoFile.byteSize <= VIDEO_FULL_UPLOAD_MAX_BYTES;
190
+
191
+ if (attachFullVideo) {
192
+ const fs = await import("fs/promises");
193
+ const mimeType = await probeVideoMimeType(videoFile.path);
194
+ const buffer = await fs.readFile(videoFile.path);
195
+ const dataUrl = `data:${mimeType};base64,${buffer.toString("base64")}`;
196
+ return createVideoFollowupResult(
197
+ dataUrl,
198
+ `The video from message #${args.message_id} is attached. Inspect it directly and answer the user's question from the visual content.`,
199
+ "The video has been attached to the next main model request. Inspect it directly instead of relying on a worker-model description.",
200
+ );
201
+ }
202
+
203
+ const ai = toolCtx.aiService.getDefault();
204
+ if (!ai) return { error: "AI instance not available" };
205
+
206
+ const summary = await summarizeVideoContent(videoFile.path, videoFile.byteSize, {
207
+ ai,
208
+ multimodalWorkingModel: toolCtx.config.multimodalWorkingModel,
209
+ logger: {
210
+ info: (m) => logger.info(m),
211
+ warn: (m) => logger.warn(m),
212
+ error: (m) => logger.error(m),
213
+ },
214
+ });
215
+
216
+ return {
217
+ success: true,
218
+ description: summary,
219
+ note: "The video has been summarized. Use the description above to answer the user's question.",
220
+ };
221
+ } finally {
222
+ await videoFile.cleanup();
223
+ }
224
+ } catch (err) {
225
+ return { error: `Failed to analyze media: ${err}` };
226
+ }
227
+ },
228
+ });
229
+
230
+ tools.push({
231
+ name: "view_member_avatar",
232
+ description:
233
+ "View and analyze a group member's QQ avatar. Use this when you need to see what someone's avatar looks like. The avatar will be analyzed and described to you.",
234
+ parameters: {
235
+ type: "object",
236
+ properties: {
237
+ user_id: {
238
+ type: "number",
239
+ description: "QQ number of the member whose avatar you want to view",
240
+ },
241
+ },
242
+ required: ["user_id"],
243
+ },
244
+ handler: async (args) => {
245
+ try {
246
+ const avatarUrl = `https://q1.qlogo.cn/g?b=qq&nk=${args.user_id}&s=640`;
247
+ logger.info(`[view_member_avatar] Analyzing avatar: ${avatarUrl}`);
248
+
249
+ if (toolCtx.config.isMultimodal) {
250
+ return await createImageFollowupResult(
251
+ avatarUrl,
252
+ `User ${args.user_id}'s QQ avatar is attached. Inspect it directly and answer the user's question from the visual content.`,
253
+ "The avatar has been attached to the next main model request. Inspect it directly instead of relying on a worker-model description.",
254
+ );
255
+ }
256
+
257
+ const { describeImage } = await import("../multimodal");
258
+ const ai = toolCtx.aiService.getDefault();
259
+ if (!ai) return { error: "AI instance not available" };
260
+
261
+ const result = await describeImage(
262
+ ai,
263
+ avatarUrl,
264
+ toolCtx.config.multimodalWorkingModel,
265
+ `User ${args.user_id}'s QQ avatar`,
266
+ );
267
+
268
+ if (!result.success) {
269
+ return { error: result.error || "Failed to analyze avatar" };
270
+ }
271
+
272
+ return {
273
+ success: true,
274
+ description: result.description,
275
+ note: "The avatar has been analyzed. Use the description above to answer the user's question.",
276
+ };
277
+ } catch (err) {
278
+ return { error: `Failed to analyze avatar: ${err}` };
279
+ }
280
+ },
281
+ });
282
+
283
+ return tools;
284
+ }
@@ -0,0 +1,149 @@
1
+ import type { AITool } from "mioku";
2
+ import type { ToolContext } from "../../types";
3
+ import type { SkillSessionManager } from "../../manage/skill-session";
4
+ import {
5
+ filterAllowedExternalSkills,
6
+ getSkillRequiredPermissionRole,
7
+ isExternalSkillAllowed,
8
+ hasSkillPermission,
9
+ } from "../external-skills";
10
+ import {
11
+ isFeatureEnabled,
12
+ isBuiltinFeature,
13
+ getFeatureMeta,
14
+ type FeatureName,
15
+ } from "../feature-prompts";
16
+ import {
17
+ buildWebSearchFeatureSection,
18
+ buildWebReadFeatureSection,
19
+ buildRecallMemoryFeatureSection,
20
+ } from "../prompt";
21
+ import { createWebSearchTool, createWebReadPageTool, createRecallMemoryTool } from "./web";
22
+
23
+ type ConstraintStrength = "low" | "medium" | "high";
24
+
25
+ function normalizeConstraintStrength(value: unknown): ConstraintStrength {
26
+ if (value === "low" || value === "high" || value === "medium") return value;
27
+ return "medium";
28
+ }
29
+
30
+ function createFeatureTools(
31
+ toolCtx: ToolContext,
32
+ featureName: FeatureName,
33
+ ): AITool[] {
34
+ switch (featureName) {
35
+ case "web_search":
36
+ return [createWebSearchTool(toolCtx)];
37
+ case "web_read_page":
38
+ return [createWebReadPageTool(toolCtx)];
39
+ case "recall_memory":
40
+ return [createRecallMemoryTool(toolCtx)];
41
+ default:
42
+ return [];
43
+ }
44
+ }
45
+
46
+ export function createLoadSkillTool(
47
+ toolCtx: ToolContext,
48
+ skillManager: SkillSessionManager,
49
+ ): AITool {
50
+ return {
51
+ name: "load_skill",
52
+ description:
53
+ "Load an external skill's tools into the current session. Tools will be available for 1 hour.",
54
+ parameters: {
55
+ type: "object",
56
+ properties: {
57
+ skill_name: { type: "string", description: "Skill name to load" },
58
+ },
59
+ required: ["skill_name"],
60
+ },
61
+ handler: async (args) => {
62
+ const skillName = String(args?.skill_name || "").trim();
63
+
64
+ if (isBuiltinFeature(skillName)) {
65
+ const feature = getFeatureMeta(skillName);
66
+ if (!feature) return { error: `Feature "${skillName}" not found` };
67
+ if (!isFeatureEnabled(toolCtx.config, feature)) {
68
+ return { error: `Feature "${skillName}" is not enabled in config` };
69
+ }
70
+
71
+ const featureTools: AITool[] = feature.hasTools
72
+ ? createFeatureTools(toolCtx, skillName)
73
+ : [];
74
+ if (featureTools.length > 0) {
75
+ skillManager.loadSkill(toolCtx.sessionId, skillName, featureTools);
76
+ } else {
77
+ skillManager.loadFeature(toolCtx.sessionId, skillName, 60 * 60 * 1000);
78
+ }
79
+
80
+ const toolStrength = normalizeConstraintStrength(toolCtx.config.toolCallConstraintStrength);
81
+ const sections: string[] = [];
82
+ if (skillName === "web_search") {
83
+ const s = buildWebSearchFeatureSection(toolCtx.config, toolStrength);
84
+ if (s) sections.push(s);
85
+ } else if (skillName === "web_read_page") {
86
+ const s = buildWebReadFeatureSection(toolCtx.config, toolStrength);
87
+ if (s) sections.push(s);
88
+ } else if (skillName === "recall_memory") {
89
+ const s = buildRecallMemoryFeatureSection(toolCtx.config);
90
+ if (s) sections.push(s);
91
+ }
92
+ const usageHint = sections.length > 0 ? sections.join("\n") : "";
93
+
94
+ return {
95
+ success: true,
96
+ skill_name: skillName,
97
+ feature: true,
98
+ expires_in: "1 hour",
99
+ tools: featureTools.map((t) => ({
100
+ name: `${skillName}.${t.name}`,
101
+ description: t.description,
102
+ parameters: t.parameters,
103
+ })),
104
+ ...(usageHint ? { usage: usageHint } : {}),
105
+ };
106
+ }
107
+
108
+ if (!isExternalSkillAllowed(toolCtx.config, skillName)) {
109
+ const allSkills = toolCtx.aiService.getAllSkills?.();
110
+ const allowedSkills = allSkills
111
+ ? filterAllowedExternalSkills(
112
+ toolCtx.config,
113
+ [...allSkills.values()],
114
+ toolCtx.triggerSkillRole,
115
+ )
116
+ : [];
117
+ const allowedNames = allowedSkills.map((skill) => skill.name);
118
+ return {
119
+ error:
120
+ allowedNames.length > 0
121
+ ? `Skill "${skillName}" is not allowed. Allowed skills: ${allowedNames.join(", ")}`
122
+ : "No external skills are allowed in current config",
123
+ };
124
+ }
125
+
126
+ const skill = toolCtx.aiService.getSkill(skillName);
127
+ if (!skill) return { error: `Skill "${skillName}" does not exist` };
128
+ const requiredRole = getSkillRequiredPermissionRole(skill);
129
+ if (!hasSkillPermission(toolCtx.triggerSkillRole, requiredRole)) {
130
+ return {
131
+ error: `Permission denied: loading skill "${skill.name}" requires role "${requiredRole}", current role is "${toolCtx.triggerSkillRole}"`,
132
+ };
133
+ }
134
+
135
+ skillManager.loadSkill(toolCtx.sessionId, skill.name, skill.tools);
136
+
137
+ return {
138
+ success: true,
139
+ skill_name: skill.name,
140
+ expires_in: "1 hour",
141
+ tools: skill.tools.map((t) => ({
142
+ name: `${skill.name}.${t.name}`,
143
+ description: t.description,
144
+ parameters: t.parameters,
145
+ })),
146
+ };
147
+ },
148
+ };
149
+ }