mioku-plugin-chat 2.0.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,447 @@
1
+ import type { AIInstance } from "mioku";
2
+ import type { ChatDatabase } from "../../db";
3
+ import type { ImageRecord } from "../../types";
4
+ import { logger } from "mioki";
5
+ import * as crypto from "crypto";
6
+ import * as path from "path";
7
+ import * as fs from "fs/promises";
8
+ import { existsSync, mkdirSync, createWriteStream, unlink } from "fs";
9
+ import * as https from "https";
10
+ import * as http from "http";
11
+ import { URL } from "url";
12
+
13
+ /**
14
+ * 图片分析结果
15
+ */
16
+ export interface ImageAnalysisResult {
17
+ success: boolean;
18
+ type?: "meme" | "image";
19
+ description?: string;
20
+ emotion?: string;
21
+ characters?: string[]; // 支持多个角色
22
+ character?: string; // 兼容旧版本
23
+ gifBuffer?: Buffer; // GIF 原始 buffer
24
+ error?: string;
25
+ }
26
+
27
+ export interface ImageAnalysisOptions {
28
+ runAIRequest?<T>(request: () => Promise<T>): Promise<T | null>;
29
+ }
30
+
31
+ function formatImageRecognitionLog(record: {
32
+ type: "meme" | "image";
33
+ description: string;
34
+ emotion?: string | null;
35
+ character?: string | null;
36
+ characters?: string[];
37
+ }): string {
38
+ const characters =
39
+ record.characters && record.characters.length > 0
40
+ ? record.characters
41
+ : record.character
42
+ ? [record.character]
43
+ : [];
44
+ return `[image-analyzer] ${record.type}: ${record.description}${record.emotion ? ` [${record.emotion}]` : ""}${characters.length > 0 ? ` (${characters.join(", ")})` : ""}`;
45
+ }
46
+
47
+ /**
48
+ * 已知角色列表(用于提示词)
49
+ */
50
+ const KNOWN_CHARACTERS = [
51
+ "hatsune_miku",
52
+ "kagamine_rin",
53
+ "kagamine_len",
54
+ "megurine_luka",
55
+ "kaito",
56
+ "meiko",
57
+ "unknown", // 未知角色
58
+ ];
59
+
60
+ /**
61
+ * 情感标签列表
62
+ */
63
+ const EMOTION_TAGS = [
64
+ "happy",
65
+ "sad",
66
+ "angry",
67
+ "surprised",
68
+ "confused",
69
+ "excited",
70
+ "tired",
71
+ "shy",
72
+ "proud",
73
+ "default", // 默认/不清楚
74
+ ];
75
+
76
+ /**
77
+ * 计算图片内容的哈希值
78
+ */
79
+ export async function calculateImageHash(url: string): Promise<string> {
80
+ try {
81
+ // 下载图片内容
82
+ const response = await fetch(url);
83
+ if (!response.ok) {
84
+ // 如果下载失败,降级为 URL 哈希
85
+ return crypto.createHash("md5").update(url).digest("hex");
86
+ }
87
+
88
+ const buffer = Buffer.from(await response.arrayBuffer());
89
+
90
+ // 基于图片内容计算哈希
91
+ return crypto.createHash("md5").update(buffer).digest("hex");
92
+ } catch (err) {
93
+ logger.warn(
94
+ `[image-analyzer] Failed to calculate content hash, using URL hash: ${err}`,
95
+ );
96
+ // 降级为 URL 哈希
97
+ return crypto.createHash("md5").update(url).digest("hex");
98
+ }
99
+ }
100
+
101
+ /**
102
+ * 下载图片到本地
103
+ */
104
+ async function downloadImage(url: string, savePath: string): Promise<boolean> {
105
+ return new Promise((resolve) => {
106
+ const dir = path.dirname(savePath);
107
+ if (!existsSync(dir)) {
108
+ mkdirSync(dir, { recursive: true });
109
+ }
110
+
111
+ const parsedUrl = new URL(url);
112
+ const protocol = parsedUrl.protocol === "https:" ? https : http;
113
+
114
+ const file = createWriteStream(savePath);
115
+ const options = {
116
+ headers: {
117
+ "User-Agent":
118
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
119
+ Referer: "https://q.qq.com/",
120
+ },
121
+ };
122
+
123
+ protocol
124
+ .get(url, options, (response) => {
125
+ response.pipe(file);
126
+ file.on("finish", () => {
127
+ file.close();
128
+ resolve(true);
129
+ });
130
+ })
131
+ .on("error", (err) => {
132
+ unlink(savePath, () => {});
133
+ logger.error(`[image-analyzer] Failed to download image: ${err}`);
134
+ resolve(false);
135
+ });
136
+ });
137
+ }
138
+
139
+ /**
140
+ * 获取图片扩展名
141
+ */
142
+ function getImageExtension(url: string): string {
143
+ const urlPath = new URL(url).pathname;
144
+ const ext = path.extname(urlPath).toLowerCase();
145
+ if ([".jpg", ".jpeg", ".png", ".gif", ".webp"].includes(ext)) {
146
+ return ext;
147
+ }
148
+ return ".jpg"; // 默认
149
+ }
150
+
151
+ /**
152
+ * 分析图片内容
153
+ */
154
+ export async function analyzeImage(
155
+ ai: AIInstance,
156
+ imageUrl: string,
157
+ model: string,
158
+ gifBuffer?: Buffer,
159
+ options?: ImageAnalysisOptions,
160
+ ): Promise<ImageAnalysisResult> {
161
+ try {
162
+ // 检查是否为 GIF,如果是则提取三帧
163
+ const { isGifUrl, extractGifFrames } = await import("./gif-extractor");
164
+ let imageUrls: string[] = [imageUrl];
165
+ let originalGifBuffer: Buffer | undefined = gifBuffer;
166
+
167
+ if (await isGifUrl(imageUrl)) {
168
+ const result = await extractGifFrames(imageUrl);
169
+ if (result && result.frames.length > 0) {
170
+ imageUrls = result.frames;
171
+ originalGifBuffer = result.buffer;
172
+ } else {
173
+ logger.warn(
174
+ `[image-analyzer] Failed to extract GIF frames, using original URL`,
175
+ );
176
+ }
177
+ }
178
+
179
+ const systemPrompt = `You are an image classification and analysis assistant. Your task is to analyze images and provide structured information.
180
+
181
+ Instructions:
182
+ 1. Classify the image as either "meme" or "image":
183
+ - "meme": Images with anime/cartoon characters, usually expressing emotions or reactions
184
+ - "image": Regular images conveying information
185
+
186
+ 2. Provide a brief description (max 30 words in Chinese):
187
+ - For memes: First identify the character's name, then describe the character's status, actions, and the text near the image.
188
+ - For images: Describe what you see, summarize the text you see (who did what and where).
189
+ ${imageUrls.length > 1 ? "\n - Note: You are viewing multiple frames from an animated image (GIF). Consider the overall motion and emotion across all frames." : ""}
190
+
191
+ 3. For memes only:
192
+ - Emotion tag: ${EMOTION_TAGS.join(", ")} (choose ONLY ONE that best represents the overall mood)
193
+ - Character names: Can have multiple characters (array format), use English names. Already known: ${KNOWN_CHARACTERS.join(", ")}. You CAN add new ones.
194
+
195
+ Response format (JSON):
196
+ {
197
+ "type": "meme" or "image",
198
+ "description": "brief description in Chinese",
199
+ "emotion": "emotion tag (choose ONLY ONE)",
200
+ "characters": ["character1", "character2", ...] (array of character names, can be empty or have multiple)
201
+ }`;
202
+
203
+ const userPrompt =
204
+ imageUrls.length > 1
205
+ ? `Please analyze these ${imageUrls.length} frames from an animated image and provide the classification and description.`
206
+ : "Please analyze this image and provide the classification and description.";
207
+
208
+ // 构建消息内容
209
+ const contentParts: any[] = [{ type: "text", text: userPrompt }];
210
+ for (const url of imageUrls) {
211
+ contentParts.push({
212
+ type: "image_url",
213
+ image_url: { url, detail: "auto" },
214
+ });
215
+ }
216
+
217
+ const request = () =>
218
+ ai.complete({
219
+ model,
220
+ messages: [
221
+ { role: "system", content: systemPrompt },
222
+ {
223
+ role: "user",
224
+ content: contentParts,
225
+ },
226
+ ],
227
+ temperature: 0.3,
228
+ });
229
+ const response = options?.runAIRequest
230
+ ? await options.runAIRequest(request)
231
+ : await request();
232
+
233
+ if (!response) {
234
+ return {
235
+ success: false,
236
+ error: "Request skipped due to active rate limit",
237
+ };
238
+ }
239
+
240
+ if (!response.content) {
241
+ return {
242
+ success: false,
243
+ error: "Model returned empty response",
244
+ };
245
+ }
246
+
247
+ // 解析 JSON 响应
248
+ let result: any;
249
+ try {
250
+ // 尝试提取 JSON(可能包含在 markdown 代码块中)
251
+ const jsonMatch = response.content.match(/\{[\s\S]*\}/);
252
+ if (jsonMatch) {
253
+ result = JSON.parse(jsonMatch[0]);
254
+ } else {
255
+ result = JSON.parse(response.content);
256
+ }
257
+ } catch {
258
+ logger.warn(
259
+ `[image-analyzer] Failed to parse JSON response: ${response.content}`,
260
+ );
261
+ return {
262
+ success: false,
263
+ error: "Failed to parse model response",
264
+ };
265
+ }
266
+
267
+ // 验证和规范化结果
268
+ const type = result.type === "meme" ? "meme" : "image";
269
+ const description = String(result.description || "未知");
270
+ const emotion = type === "meme" ? result.emotion || "default" : undefined;
271
+
272
+ // 支持多个角色
273
+ let characters: string[] | undefined;
274
+ if (type === "meme") {
275
+ if (Array.isArray(result.characters)) {
276
+ characters = result.characters
277
+ .map((c: string) => c.trim().toLowerCase())
278
+ .filter(Boolean);
279
+ }
280
+ if (characters && characters.length === 0) {
281
+ characters = undefined;
282
+ }
283
+ }
284
+
285
+ const character =
286
+ type === "meme"
287
+ ? characters && characters.length > 0
288
+ ? characters[0]
289
+ : "unknown"
290
+ : undefined;
291
+
292
+ logger.info(
293
+ formatImageRecognitionLog({
294
+ type,
295
+ description,
296
+ emotion,
297
+ characters,
298
+ }),
299
+ );
300
+
301
+ return {
302
+ success: true,
303
+ type,
304
+ description,
305
+ emotion,
306
+ characters,
307
+ character,
308
+ gifBuffer: originalGifBuffer,
309
+ };
310
+ } catch (err) {
311
+ logger.error(`[image-analyzer] Failed to analyze image: ${err}`);
312
+ return {
313
+ success: false,
314
+ error: String(err),
315
+ };
316
+ }
317
+ }
318
+
319
+ /**
320
+ * 处理图片:分析、保存到数据库、下载表情包
321
+ */
322
+ export async function processImage(
323
+ ai: AIInstance,
324
+ imageUrl: string,
325
+ model: string,
326
+ db: ChatDatabase,
327
+ options?: ImageAnalysisOptions,
328
+ ): Promise<ImageRecord | null> {
329
+ try {
330
+ // 计算哈希(基于图片内容)
331
+ const hash = await calculateImageHash(imageUrl);
332
+
333
+ // 检查是否已存在
334
+ const existing = db.getImageByHash(hash);
335
+ if (existing) {
336
+ logger.info(formatImageRecognitionLog(existing));
337
+ return existing;
338
+ }
339
+ const analysis = await analyzeImage(ai, imageUrl, model, undefined, options);
340
+ if (!analysis.success || !analysis.type) {
341
+ logger.warn(`[image-analyzer] Analysis failed: ${analysis.error}`);
342
+ return null;
343
+ }
344
+
345
+ let filePath: string | undefined;
346
+
347
+ // 如果是表情包,下载到本地
348
+ if (analysis.type === "meme" && analysis.emotion && analysis.description) {
349
+ // 确定文件扩展名:GIF 用 .gif,其他用原始扩展名
350
+ const ext = analysis.gifBuffer ? ".gif" : getImageExtension(imageUrl);
351
+
352
+ // 文件名使用描述,不限制长度,只替换非法字符
353
+ const safeDesc = analysis.description.replace(
354
+ /[^\u4e00-\u9fa5a-zA-Z0-9]/g,
355
+ "_",
356
+ );
357
+ const fileName = `${safeDesc}${ext}`;
358
+
359
+ // 获取角色列表(支持多个角色)
360
+ const characters =
361
+ analysis.characters && analysis.characters.length > 0
362
+ ? analysis.characters
363
+ : [analysis.character || "unknown"];
364
+
365
+ // 为每个角色创建文件夹并保存图片
366
+ const savePromises = characters.map(async (character) => {
367
+ const memeDir = path.join(
368
+ process.cwd(),
369
+ "data",
370
+ "chat",
371
+ "meme",
372
+ character,
373
+ analysis.emotion!,
374
+ );
375
+ const targetPath = path.join(memeDir, fileName);
376
+
377
+ // 确保目录存在
378
+ if (!existsSync(memeDir)) {
379
+ await fs.mkdir(memeDir, { recursive: true });
380
+ }
381
+
382
+ // 如果是 GIF 且有原始 buffer
383
+ if (analysis.gifBuffer) {
384
+ try {
385
+ await fs.writeFile(targetPath, analysis.gifBuffer);
386
+ return targetPath;
387
+ } catch (err) {
388
+ logger.warn(
389
+ `[image-analyzer] Failed to save GIF for ${character}: ${err}`,
390
+ );
391
+ return null;
392
+ }
393
+ } else {
394
+ // 普通图片,下载
395
+ const downloaded = await downloadImage(imageUrl, targetPath);
396
+ if (downloaded) {
397
+ return targetPath;
398
+ } else {
399
+ logger.warn(`[image-analyzer] Download failed for ${character}`);
400
+ return null;
401
+ }
402
+ }
403
+ });
404
+
405
+ const savedPaths = await Promise.all(savePromises);
406
+ filePath = savedPaths.find((p) => p !== null) || undefined;
407
+ }
408
+
409
+ // 保存到数据库
410
+ const record: ImageRecord = {
411
+ hash,
412
+ url: imageUrl,
413
+ type: analysis.type,
414
+ description: analysis.description || "未知",
415
+ emotion: analysis.emotion,
416
+ character: analysis.character,
417
+ filePath,
418
+ createdAt: Date.now(),
419
+ };
420
+
421
+ db.saveImage(record);
422
+
423
+ return record;
424
+ } catch (err) {
425
+ logger.error(`[image-analyzer] Failed to process image: ${err}`);
426
+ return null;
427
+ }
428
+ }
429
+
430
+ /**
431
+ * 从图片 URL 获取描述标签
432
+ * 如果图片已在数据库中,返回 [meme:描述] 或 [image:描述]
433
+ * 否则返回 [image]
434
+ */
435
+ export async function getImageTag(
436
+ imageUrl: string,
437
+ db: ChatDatabase,
438
+ ): Promise<string> {
439
+ const hash = await calculateImageHash(imageUrl);
440
+ const record = db.getImageByHash(hash);
441
+
442
+ if (record) {
443
+ return `[${record.type}:${record.description}]`;
444
+ }
445
+
446
+ return "[image]";
447
+ }
@@ -0,0 +1,190 @@
1
+ export const MARKDOWN_OPEN_TAG = "<MARKDOWN>";
2
+ export const MARKDOWN_CLOSE_TAG = "</MARKDOWN>";
3
+
4
+ export function splitOutgoingUnits(text: string): string[] {
5
+ const normalized = String(text || "").replace(/\r/g, "");
6
+ const result: string[] = [];
7
+ let buffer = "";
8
+ let insideMarkdown = false;
9
+
10
+ for (let index = 0; index < normalized.length; ) {
11
+ if (!insideMarkdown && normalized.startsWith(MARKDOWN_OPEN_TAG, index)) {
12
+ if (buffer.trim()) {
13
+ result.push(buffer.trim());
14
+ }
15
+ buffer = MARKDOWN_OPEN_TAG;
16
+ insideMarkdown = true;
17
+ index += MARKDOWN_OPEN_TAG.length;
18
+ continue;
19
+ }
20
+
21
+ if (insideMarkdown && normalized.startsWith(MARKDOWN_CLOSE_TAG, index)) {
22
+ buffer += MARKDOWN_CLOSE_TAG;
23
+ if (buffer.trim()) {
24
+ result.push(buffer.trim());
25
+ }
26
+ buffer = "";
27
+ insideMarkdown = false;
28
+ index += MARKDOWN_CLOSE_TAG.length;
29
+ continue;
30
+ }
31
+
32
+ const char = normalized[index];
33
+ if (!insideMarkdown && char === "\n") {
34
+ if (buffer.trim()) {
35
+ result.push(buffer.trim());
36
+ }
37
+ buffer = "";
38
+ index += 1;
39
+ continue;
40
+ }
41
+
42
+ buffer += char;
43
+ index += 1;
44
+ }
45
+
46
+ if (buffer.trim()) {
47
+ result.push(buffer.trim());
48
+ }
49
+
50
+ return result;
51
+ }
52
+
53
+ export function consumeCompleteStreamUnits(
54
+ buffer: string,
55
+ force: boolean,
56
+ ): { units: string[]; rest: string } {
57
+ let rest = String(buffer || "").replace(/\r/g, "");
58
+ const units: string[] = [];
59
+
60
+ while (true) {
61
+ while (rest.startsWith("\n")) {
62
+ rest = rest.slice(1);
63
+ }
64
+
65
+ if (!rest) {
66
+ break;
67
+ }
68
+
69
+ const next = takeNextStreamUnit(rest, force);
70
+ if (!next) {
71
+ break;
72
+ }
73
+
74
+ units.push(next.unit);
75
+ rest = next.rest;
76
+ }
77
+
78
+ return { units, rest };
79
+ }
80
+
81
+ export function extractStandaloneMarkdownBlock(text: string): string | null {
82
+ const trimmed = String(text || "").trim();
83
+ if (
84
+ !trimmed.startsWith(MARKDOWN_OPEN_TAG) ||
85
+ !trimmed.endsWith(MARKDOWN_CLOSE_TAG)
86
+ ) {
87
+ return null;
88
+ }
89
+
90
+ const inner = trimmed.slice(
91
+ MARKDOWN_OPEN_TAG.length,
92
+ trimmed.length - MARKDOWN_CLOSE_TAG.length,
93
+ );
94
+ return inner.trim() || null;
95
+ }
96
+
97
+ export function summarizeMarkdown(markdown: string): string {
98
+ const lines = String(markdown || "")
99
+ .replace(/\r/g, "")
100
+ .split("\n")
101
+ .map((line) => line.trim())
102
+ .filter(Boolean);
103
+
104
+ const heading = lines.find((line) => /^#{1,6}\s+/.test(line));
105
+ if (heading) {
106
+ return heading
107
+ .replace(/^#{1,6}\s+/, "")
108
+ .trim()
109
+ .slice(0, 40);
110
+ }
111
+
112
+ const firstLine = lines.find((line) => !line.startsWith("```"));
113
+ if (!firstLine) {
114
+ return "Markdown";
115
+ }
116
+
117
+ return (
118
+ firstLine
119
+ .replace(/^[>*\-\d.\s`]+/u, "")
120
+ .slice(0, 40)
121
+ .trim() || "Markdown"
122
+ );
123
+ }
124
+
125
+ function takeNextStreamUnit(
126
+ input: string,
127
+ force: boolean,
128
+ ): { unit: string; rest: string } | null {
129
+ const text = input;
130
+ const openIndex = text.indexOf(MARKDOWN_OPEN_TAG);
131
+ const newlineIndex = text.indexOf("\n");
132
+
133
+ if (openIndex === -1) {
134
+ if (newlineIndex >= 0) {
135
+ return {
136
+ unit: text.slice(0, newlineIndex).trim(),
137
+ rest: text.slice(newlineIndex + 1),
138
+ };
139
+ }
140
+
141
+ if (force && text.trim()) {
142
+ return {
143
+ unit: text.trim(),
144
+ rest: "",
145
+ };
146
+ }
147
+
148
+ return null;
149
+ }
150
+
151
+ if (newlineIndex >= 0 && newlineIndex < openIndex) {
152
+ return {
153
+ unit: text.slice(0, newlineIndex).trim(),
154
+ rest: text.slice(newlineIndex + 1),
155
+ };
156
+ }
157
+
158
+ if (openIndex > 0) {
159
+ const prefix = text.slice(0, openIndex).trim();
160
+ return prefix
161
+ ? {
162
+ unit: prefix,
163
+ rest: text.slice(openIndex),
164
+ }
165
+ : {
166
+ unit: "",
167
+ rest: text.slice(openIndex),
168
+ };
169
+ }
170
+
171
+ const closeIndex = text.indexOf(MARKDOWN_CLOSE_TAG, MARKDOWN_OPEN_TAG.length);
172
+ if (closeIndex < 0) {
173
+ if (force && text.trim()) {
174
+ return {
175
+ unit: text.trim(),
176
+ rest: "",
177
+ };
178
+ }
179
+ return null;
180
+ }
181
+
182
+ const endIndex = closeIndex + MARKDOWN_CLOSE_TAG.length;
183
+ const unit = text.slice(0, endIndex).trim();
184
+ let rest = text.slice(endIndex);
185
+ while (rest.startsWith("\n")) {
186
+ rest = rest.slice(1);
187
+ }
188
+
189
+ return { unit, rest };
190
+ }