mioku-plugin-chat 2.6.0 → 2.7.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.
- package/configs/personalization.ts +1 -1
- package/context.ts +2 -0
- package/core/base.ts +1 -1
- package/core/chat-engine.ts +18 -7
- package/core/chat-turn.ts +26 -9
- package/core/media/image-analyzer.ts +63 -397
- package/core/prompt.ts +11 -20
- package/db.ts +8 -12
- package/handlers/idle-debug.ts +96 -97
- package/handlers/message.ts +35 -34
- package/handlers/poke.ts +102 -124
- package/humanize/emoji-agent.ts +208 -332
- package/humanize/index.ts +2 -3
- package/index.ts +32 -44
- package/manage/cooldown.ts +60 -100
- package/manage/idle-check.ts +163 -70
- package/manage/queue-processor.ts +73 -30
- package/manage/session-turn-scheduler.ts +130 -0
- package/package.json +1 -1
- package/runtime/chat-runtime.ts +14 -2
- package/types.ts +2 -19
- package/utils/config.ts +57 -0
- package/utils/message.ts +4 -7
- package/utils/queue.ts +5 -0
|
@@ -1,89 +1,20 @@
|
|
|
1
|
+
import * as crypto from "crypto";
|
|
1
2
|
import type { AIInstance } from "mioku";
|
|
3
|
+
import { logger } from "mioki";
|
|
2
4
|
import type { ChatDatabase } from "../../db";
|
|
3
5
|
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
6
|
import { prepareImageUrlsForModel } from "./image-compress";
|
|
13
7
|
|
|
14
|
-
/**
|
|
15
|
-
* 图片分析结果
|
|
16
|
-
*/
|
|
17
8
|
export interface ImageAnalysisResult {
|
|
18
9
|
success: boolean;
|
|
19
|
-
type?: "meme" | "image";
|
|
20
10
|
description?: string;
|
|
21
|
-
emotion?: string;
|
|
22
|
-
characters?: string[]; // 支持多个角色
|
|
23
|
-
character?: string; // 兼容旧版本
|
|
24
|
-
gifBuffer?: Buffer; // GIF 原始 buffer
|
|
25
11
|
error?: string;
|
|
26
12
|
}
|
|
27
13
|
|
|
28
14
|
export interface ImageAnalysisOptions {
|
|
29
15
|
runAIRequest?<T>(request: () => Promise<T>): Promise<T | null>;
|
|
30
|
-
/**
|
|
31
|
-
* When non-empty, only memes whose detected character is in this list
|
|
32
|
-
* get downloaded into `data/chat/meme/<character>/<emotion>/`. Other
|
|
33
|
-
* characters still get a DB record but no local file.
|
|
34
|
-
* Empty (default) = no restriction; save for every detected character.
|
|
35
|
-
*/
|
|
36
|
-
allowedCharacters?: string[];
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function formatImageRecognitionLog(record: {
|
|
40
|
-
type: "meme" | "image";
|
|
41
|
-
description: string;
|
|
42
|
-
emotion?: string | null;
|
|
43
|
-
character?: string | null;
|
|
44
|
-
characters?: string[];
|
|
45
|
-
}): string {
|
|
46
|
-
const characters =
|
|
47
|
-
record.characters && record.characters.length > 0
|
|
48
|
-
? record.characters
|
|
49
|
-
: record.character
|
|
50
|
-
? [record.character]
|
|
51
|
-
: [];
|
|
52
|
-
return `[image-analyzer] ${record.type}: ${record.description}${record.emotion ? ` [${record.emotion}]` : ""}${characters.length > 0 ? ` (${characters.join(", ")})` : ""}`;
|
|
53
16
|
}
|
|
54
17
|
|
|
55
|
-
/**
|
|
56
|
-
* 已知角色列表(用于提示词)
|
|
57
|
-
*/
|
|
58
|
-
const KNOWN_CHARACTERS = [
|
|
59
|
-
"hatsune_miku",
|
|
60
|
-
"kagamine_rin",
|
|
61
|
-
"kagamine_len",
|
|
62
|
-
"megurine_luka",
|
|
63
|
-
"kaito",
|
|
64
|
-
"meiko",
|
|
65
|
-
"unknown", // 未知角色
|
|
66
|
-
];
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* 情感标签列表
|
|
70
|
-
*/
|
|
71
|
-
const EMOTION_TAGS = [
|
|
72
|
-
"happy",
|
|
73
|
-
"sad",
|
|
74
|
-
"angry",
|
|
75
|
-
"surprised",
|
|
76
|
-
"confused",
|
|
77
|
-
"excited",
|
|
78
|
-
"tired",
|
|
79
|
-
"shy",
|
|
80
|
-
"proud",
|
|
81
|
-
"default", // 默认/不清楚
|
|
82
|
-
];
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* 计算图片内容的哈希值(仅基于下载到的图片字节;URL 不参与哈希)
|
|
86
|
-
*/
|
|
87
18
|
export async function calculateImageHash(url: string): Promise<string | null> {
|
|
88
19
|
try {
|
|
89
20
|
const response = await fetch(url);
|
|
@@ -104,140 +35,58 @@ export async function calculateImageHash(url: string): Promise<string | null> {
|
|
|
104
35
|
}
|
|
105
36
|
}
|
|
106
37
|
|
|
107
|
-
/**
|
|
108
|
-
* 下载图片到本地
|
|
109
|
-
*/
|
|
110
|
-
async function downloadImage(url: string, savePath: string): Promise<boolean> {
|
|
111
|
-
return new Promise((resolve) => {
|
|
112
|
-
const dir = path.dirname(savePath);
|
|
113
|
-
if (!existsSync(dir)) {
|
|
114
|
-
mkdirSync(dir, { recursive: true });
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
const parsedUrl = new URL(url);
|
|
118
|
-
const protocol = parsedUrl.protocol === "https:" ? https : http;
|
|
119
|
-
|
|
120
|
-
const file = createWriteStream(savePath);
|
|
121
|
-
const options = {
|
|
122
|
-
headers: {
|
|
123
|
-
"User-Agent":
|
|
124
|
-
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
125
|
-
Referer: "https://q.qq.com/",
|
|
126
|
-
},
|
|
127
|
-
};
|
|
128
|
-
|
|
129
|
-
protocol
|
|
130
|
-
.get(url, options, (response) => {
|
|
131
|
-
response.pipe(file);
|
|
132
|
-
file.on("finish", () => {
|
|
133
|
-
file.close();
|
|
134
|
-
resolve(true);
|
|
135
|
-
});
|
|
136
|
-
})
|
|
137
|
-
.on("error", (err) => {
|
|
138
|
-
unlink(savePath, () => {});
|
|
139
|
-
logger.error(`[image-analyzer] Failed to download image: ${err}`);
|
|
140
|
-
resolve(false);
|
|
141
|
-
});
|
|
142
|
-
});
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
* 获取图片扩展名
|
|
147
|
-
*/
|
|
148
|
-
function getImageExtension(url: string): string {
|
|
149
|
-
const urlPath = new URL(url).pathname;
|
|
150
|
-
const ext = path.extname(urlPath).toLowerCase();
|
|
151
|
-
if ([".jpg", ".jpeg", ".png", ".gif", ".webp"].includes(ext)) {
|
|
152
|
-
return ext;
|
|
153
|
-
}
|
|
154
|
-
return ".jpg"; // 默认
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* 分析图片内容
|
|
159
|
-
*/
|
|
160
38
|
export async function analyzeImage(
|
|
161
39
|
ai: AIInstance,
|
|
162
40
|
imageUrl: string,
|
|
163
41
|
model: string,
|
|
164
|
-
gifBuffer?: Buffer,
|
|
165
42
|
options?: ImageAnalysisOptions,
|
|
166
43
|
): Promise<ImageAnalysisResult> {
|
|
167
44
|
try {
|
|
168
|
-
// 检查是否为 GIF,如果是则提取三帧
|
|
169
45
|
const { isGifUrl, extractGifFrames } = await import("./gif-extractor");
|
|
170
|
-
let imageUrls
|
|
171
|
-
let originalGifBuffer: Buffer | undefined = gifBuffer;
|
|
46
|
+
let imageUrls = [imageUrl];
|
|
172
47
|
|
|
173
48
|
if (await isGifUrl(imageUrl)) {
|
|
174
49
|
const result = await extractGifFrames(imageUrl);
|
|
175
|
-
if (result
|
|
50
|
+
if (result?.frames.length) {
|
|
176
51
|
imageUrls = result.frames;
|
|
177
|
-
originalGifBuffer = result.buffer;
|
|
178
52
|
} else {
|
|
179
53
|
logger.warn(
|
|
180
|
-
|
|
54
|
+
"[image-analyzer] Failed to extract GIF frames, using original URL",
|
|
181
55
|
);
|
|
182
56
|
}
|
|
183
57
|
}
|
|
184
58
|
|
|
185
59
|
imageUrls = await prepareImageUrlsForModel(imageUrls);
|
|
186
60
|
|
|
187
|
-
const systemPrompt = `You are an image
|
|
61
|
+
const systemPrompt = `You are an image description assistant. Describe the image accurately for use in chat history.
|
|
188
62
|
|
|
189
63
|
Instructions:
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
2. Provide a brief description (max 30 words in Chinese):
|
|
196
|
-
- For memes: Used as the saved filename. Describe the character's pose / expression / action and any visible text.
|
|
197
|
-
- For images: Describe what you see factually, including any visible text.
|
|
198
|
-
${imageUrls.length > 1 ? "\n - You are viewing multiple frames from an animated image (GIF). Consider the overall motion and emotion across all frames." : ""}
|
|
199
|
-
|
|
200
|
-
3. For memes only:
|
|
201
|
-
- Emotion tag: ${EMOTION_TAGS.join(", ")} (choose ONLY ONE).
|
|
202
|
-
- Characters: STRICT identification — only name a character when you are CERTAIN of their identity.
|
|
203
|
-
• If you know the character's name, add their English identifier to "characters".
|
|
204
|
-
• If you are not sure, or you do not actually know the character's name, put "unknown" in the array. Do NOT guess.
|
|
205
|
-
• DO NOT invent or infer a character's name from their appearance (hair color, outfit, art style, accessories, species, etc.). Many characters look alike across different series.
|
|
206
|
-
• DO NOT name a character just because they look similar to a known one.
|
|
207
|
-
• Only name a character you can confidently identify. Otherwise use "unknown".
|
|
208
|
-
- Known identifiers: ${KNOWN_CHARACTERS.join(", ")}. You may NOT add new identifiers outside this list — any character you recognize that is not on the list still maps to "unknown".
|
|
64
|
+
- Return a concise factual description in Chinese, no more than 30 words.
|
|
65
|
+
- Include important visible subjects, actions, expressions, and text.
|
|
66
|
+
- Treat every input as a regular image. Do not classify it as a meme or sticker.
|
|
67
|
+
${imageUrls.length > 1 ? "- You are viewing frames from an animated image. Describe the overall action across the frames." : ""}
|
|
209
68
|
|
|
210
69
|
Response format (JSON):
|
|
211
|
-
{
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
: "Please analyze this image and provide the classification and description.";
|
|
222
|
-
|
|
223
|
-
// 构建消息内容
|
|
224
|
-
const contentParts: any[] = [{ type: "text", text: userPrompt }];
|
|
225
|
-
for (const url of imageUrls) {
|
|
226
|
-
contentParts.push({
|
|
70
|
+
{"description":"brief Chinese description"}`;
|
|
71
|
+
const content: any[] = [
|
|
72
|
+
{
|
|
73
|
+
type: "text",
|
|
74
|
+
text:
|
|
75
|
+
imageUrls.length > 1
|
|
76
|
+
? `Describe these ${imageUrls.length} frames from one animated image.`
|
|
77
|
+
: "Describe this image.",
|
|
78
|
+
},
|
|
79
|
+
...imageUrls.map((url) => ({
|
|
227
80
|
type: "image_url",
|
|
228
81
|
image_url: { url, detail: "auto" },
|
|
229
|
-
})
|
|
230
|
-
|
|
231
|
-
|
|
82
|
+
})),
|
|
83
|
+
];
|
|
232
84
|
const request = () =>
|
|
233
85
|
ai.complete({
|
|
234
86
|
model,
|
|
235
87
|
messages: [
|
|
236
88
|
{ role: "system", content: systemPrompt },
|
|
237
|
-
{
|
|
238
|
-
role: "user",
|
|
239
|
-
content: contentParts,
|
|
240
|
-
},
|
|
89
|
+
{ role: "user", content },
|
|
241
90
|
],
|
|
242
91
|
temperature: 0.3,
|
|
243
92
|
});
|
|
@@ -245,95 +94,26 @@ Response format (JSON):
|
|
|
245
94
|
? await options.runAIRequest(request)
|
|
246
95
|
: await request();
|
|
247
96
|
|
|
248
|
-
if (!response) {
|
|
249
|
-
return {
|
|
250
|
-
success: false,
|
|
251
|
-
error: "Request skipped due to active rate limit",
|
|
252
|
-
};
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
if (!response.content) {
|
|
97
|
+
if (!response?.content) {
|
|
256
98
|
return {
|
|
257
99
|
success: false,
|
|
258
|
-
error:
|
|
100
|
+
error: response
|
|
101
|
+
? "Model returned empty response"
|
|
102
|
+
: "Request skipped due to active rate limit",
|
|
259
103
|
};
|
|
260
104
|
}
|
|
261
105
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
if (jsonMatch) {
|
|
268
|
-
result = JSON.parse(jsonMatch[0]);
|
|
269
|
-
} else {
|
|
270
|
-
result = JSON.parse(response.content);
|
|
271
|
-
}
|
|
272
|
-
} catch {
|
|
273
|
-
logger.warn(
|
|
274
|
-
`[image-analyzer] Failed to parse JSON response: ${response.content}`,
|
|
275
|
-
);
|
|
276
|
-
return {
|
|
277
|
-
success: false,
|
|
278
|
-
error: "Failed to parse model response",
|
|
279
|
-
};
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
// 验证和规范化结果
|
|
283
|
-
const type = result.type === "meme" ? "meme" : "image";
|
|
284
|
-
const description = String(result.description || "未知");
|
|
285
|
-
const emotion = type === "meme" ? result.emotion || "default" : undefined;
|
|
286
|
-
|
|
287
|
-
// 支持多个角色
|
|
288
|
-
let characters: string[] | undefined;
|
|
289
|
-
if (type === "meme") {
|
|
290
|
-
if (Array.isArray(result.characters)) {
|
|
291
|
-
characters = result.characters
|
|
292
|
-
.map((c: string) => c.trim().toLowerCase())
|
|
293
|
-
.filter(Boolean);
|
|
294
|
-
}
|
|
295
|
-
if (characters && characters.length === 0) {
|
|
296
|
-
characters = undefined;
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
const character =
|
|
301
|
-
type === "meme"
|
|
302
|
-
? characters && characters.length > 0
|
|
303
|
-
? characters[0]
|
|
304
|
-
: "unknown"
|
|
305
|
-
: undefined;
|
|
306
|
-
|
|
307
|
-
logger.info(
|
|
308
|
-
formatImageRecognitionLog({
|
|
309
|
-
type,
|
|
310
|
-
description,
|
|
311
|
-
emotion,
|
|
312
|
-
characters,
|
|
313
|
-
}),
|
|
314
|
-
);
|
|
315
|
-
|
|
316
|
-
return {
|
|
317
|
-
success: true,
|
|
318
|
-
type,
|
|
319
|
-
description,
|
|
320
|
-
emotion,
|
|
321
|
-
characters,
|
|
322
|
-
character,
|
|
323
|
-
gifBuffer: originalGifBuffer,
|
|
324
|
-
};
|
|
106
|
+
const jsonMatch = response.content.match(/\{[\s\S]*\}/);
|
|
107
|
+
const result = JSON.parse(jsonMatch?.[0] || response.content);
|
|
108
|
+
const description = String(result.description || "未知").trim() || "未知";
|
|
109
|
+
logger.info(`[image-analyzer] image: ${description}`);
|
|
110
|
+
return { success: true, description };
|
|
325
111
|
} catch (err) {
|
|
326
112
|
logger.error(`[image-analyzer] Failed to analyze image: ${err}`);
|
|
327
|
-
return {
|
|
328
|
-
success: false,
|
|
329
|
-
error: String(err),
|
|
330
|
-
};
|
|
113
|
+
return { success: false, error: String(err) };
|
|
331
114
|
}
|
|
332
115
|
}
|
|
333
116
|
|
|
334
|
-
/**
|
|
335
|
-
* 处理图片:分析、保存到数据库、下载表情包
|
|
336
|
-
*/
|
|
337
117
|
export async function processImage(
|
|
338
118
|
ai: AIInstance,
|
|
339
119
|
imageUrl: string,
|
|
@@ -342,7 +122,6 @@ export async function processImage(
|
|
|
342
122
|
options?: ImageAnalysisOptions,
|
|
343
123
|
): Promise<ImageRecord | null> {
|
|
344
124
|
try {
|
|
345
|
-
// 计算哈希(基于图片内容)
|
|
346
125
|
const hash = await calculateImageHash(imageUrl);
|
|
347
126
|
if (!hash) {
|
|
348
127
|
logger.warn(
|
|
@@ -351,123 +130,28 @@ export async function processImage(
|
|
|
351
130
|
return null;
|
|
352
131
|
}
|
|
353
132
|
|
|
354
|
-
// 检查是否已存在
|
|
355
133
|
const existing = db.getImageByHash(hash);
|
|
356
134
|
if (existing) {
|
|
357
|
-
|
|
358
|
-
|
|
135
|
+
const normalized = asImageRecord(existing, imageUrl);
|
|
136
|
+
db.saveImage(normalized);
|
|
137
|
+
logger.info(`[image-analyzer] image: ${normalized.description}`);
|
|
138
|
+
return normalized;
|
|
359
139
|
}
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
model,
|
|
364
|
-
undefined,
|
|
365
|
-
options,
|
|
366
|
-
);
|
|
367
|
-
if (!analysis.success || !analysis.type) {
|
|
140
|
+
|
|
141
|
+
const analysis = await analyzeImage(ai, imageUrl, model, options);
|
|
142
|
+
if (!analysis.success) {
|
|
368
143
|
logger.warn(`[image-analyzer] Analysis failed: ${analysis.error}`);
|
|
369
144
|
return null;
|
|
370
145
|
}
|
|
371
146
|
|
|
372
|
-
let filePath: string | undefined;
|
|
373
|
-
|
|
374
|
-
// 如果是表情包,下载到本地
|
|
375
|
-
if (analysis.type === "meme" && analysis.emotion && analysis.description) {
|
|
376
|
-
// 确定文件扩展名:GIF 用 .gif,其他用原始扩展名
|
|
377
|
-
const ext = analysis.gifBuffer ? ".gif" : getImageExtension(imageUrl);
|
|
378
|
-
|
|
379
|
-
// 文件名使用描述,不限制长度,只替换非法字符
|
|
380
|
-
const safeDesc = analysis.description.replace(
|
|
381
|
-
/[^\u4e00-\u9fa5a-zA-Z0-9]/g,
|
|
382
|
-
"_",
|
|
383
|
-
);
|
|
384
|
-
const fileName = `${safeDesc}${ext}`;
|
|
385
|
-
|
|
386
|
-
// 获取角色列表(支持多个角色)
|
|
387
|
-
const characters =
|
|
388
|
-
analysis.characters && analysis.characters.length > 0
|
|
389
|
-
? analysis.characters
|
|
390
|
-
: [analysis.character || "unknown"];
|
|
391
|
-
|
|
392
|
-
// 应用 allowedCharacters 过滤:配置非空时只对列表内角色本地保存
|
|
393
|
-
const allowed = options?.allowedCharacters;
|
|
394
|
-
const hasRestriction = Array.isArray(allowed) && allowed.length > 0;
|
|
395
|
-
const normalizeName = (s: string) => String(s || "").trim().toLowerCase();
|
|
396
|
-
const allowedSet = hasRestriction
|
|
397
|
-
? new Set((allowed as string[]).map(normalizeName))
|
|
398
|
-
: null;
|
|
399
|
-
|
|
400
|
-
const saveableCharacters = allowedSet
|
|
401
|
-
? characters.filter((c) => allowedSet.has(normalizeName(c)))
|
|
402
|
-
: characters;
|
|
403
|
-
|
|
404
|
-
const skipped = characters.filter(
|
|
405
|
-
(c) => !saveableCharacters.includes(c),
|
|
406
|
-
);
|
|
407
|
-
if (skipped.length > 0) {
|
|
408
|
-
logger.info(
|
|
409
|
-
`[image-analyzer] Skipping local file save for characters outside allowedCharacters: ${skipped.join(", ")}`,
|
|
410
|
-
);
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
// 为每个允许保存的角色创建文件夹并保存图片
|
|
414
|
-
const savePromises = saveableCharacters.map(async (character) => {
|
|
415
|
-
const memeDir = path.join(
|
|
416
|
-
process.cwd(),
|
|
417
|
-
"data",
|
|
418
|
-
"chat",
|
|
419
|
-
"meme",
|
|
420
|
-
character,
|
|
421
|
-
analysis.emotion!,
|
|
422
|
-
);
|
|
423
|
-
const targetPath = path.join(memeDir, fileName);
|
|
424
|
-
|
|
425
|
-
// 确保目录存在
|
|
426
|
-
if (!existsSync(memeDir)) {
|
|
427
|
-
await fs.mkdir(memeDir, { recursive: true });
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
// 如果是 GIF 且有原始 buffer
|
|
431
|
-
if (analysis.gifBuffer) {
|
|
432
|
-
try {
|
|
433
|
-
await fs.writeFile(targetPath, analysis.gifBuffer);
|
|
434
|
-
return targetPath;
|
|
435
|
-
} catch (err) {
|
|
436
|
-
logger.warn(
|
|
437
|
-
`[image-analyzer] Failed to save GIF for ${character}: ${err}`,
|
|
438
|
-
);
|
|
439
|
-
return null;
|
|
440
|
-
}
|
|
441
|
-
} else {
|
|
442
|
-
// 普通图片,下载
|
|
443
|
-
const downloaded = await downloadImage(imageUrl, targetPath);
|
|
444
|
-
if (downloaded) {
|
|
445
|
-
return targetPath;
|
|
446
|
-
} else {
|
|
447
|
-
logger.warn(`[image-analyzer] Download failed for ${character}`);
|
|
448
|
-
return null;
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
});
|
|
452
|
-
|
|
453
|
-
const savedPaths = await Promise.all(savePromises);
|
|
454
|
-
filePath = savedPaths.find((p) => p !== null) || undefined;
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
// 保存到数据库
|
|
458
147
|
const record: ImageRecord = {
|
|
459
148
|
hash,
|
|
460
149
|
url: normalizeImageUrl(imageUrl) || imageUrl,
|
|
461
|
-
type:
|
|
150
|
+
type: "image",
|
|
462
151
|
description: analysis.description || "未知",
|
|
463
|
-
emotion: analysis.emotion,
|
|
464
|
-
character: analysis.character,
|
|
465
|
-
filePath,
|
|
466
152
|
createdAt: Date.now(),
|
|
467
153
|
};
|
|
468
|
-
|
|
469
154
|
db.saveImage(record);
|
|
470
|
-
|
|
471
155
|
return record;
|
|
472
156
|
} catch (err) {
|
|
473
157
|
logger.error(`[image-analyzer] Failed to process image: ${err}`);
|
|
@@ -475,69 +159,51 @@ export async function processImage(
|
|
|
475
159
|
}
|
|
476
160
|
}
|
|
477
161
|
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
*/
|
|
483
|
-
export async function getImageTag(imageUrl: string, db: ChatDatabase): Promise<string> {
|
|
162
|
+
export async function getImageTag(
|
|
163
|
+
imageUrl: string,
|
|
164
|
+
db: ChatDatabase,
|
|
165
|
+
): Promise<string> {
|
|
484
166
|
const exact = db.getImageByUrl(imageUrl);
|
|
485
|
-
if (exact) {
|
|
486
|
-
return `[${exact.type}:${exact.description}]`;
|
|
487
|
-
}
|
|
167
|
+
if (exact) return `[image:${exact.description}]`;
|
|
488
168
|
|
|
489
169
|
const normalized = normalizeImageUrl(imageUrl);
|
|
490
170
|
if (normalized && normalized !== imageUrl) {
|
|
491
171
|
const normalizedHit = db.getImageByUrl(normalized);
|
|
492
|
-
if (normalizedHit) {
|
|
493
|
-
return `[${normalizedHit.type}:${normalizedHit.description}]`;
|
|
494
|
-
}
|
|
172
|
+
if (normalizedHit) return `[image:${normalizedHit.description}]`;
|
|
495
173
|
}
|
|
496
174
|
|
|
497
175
|
const hash = await calculateImageHash(imageUrl);
|
|
498
176
|
if (hash) {
|
|
499
177
|
const byHash = db.getImageByHash(hash);
|
|
500
178
|
if (byHash) {
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
hash: byHash.hash,
|
|
504
|
-
url: normalized || imageUrl,
|
|
505
|
-
type: byHash.type,
|
|
506
|
-
description: byHash.description,
|
|
507
|
-
emotion: byHash.emotion,
|
|
508
|
-
character: byHash.character,
|
|
509
|
-
filePath: byHash.filePath,
|
|
510
|
-
createdAt: byHash.createdAt,
|
|
511
|
-
});
|
|
512
|
-
} catch {
|
|
513
|
-
// ignore — best-effort URL caching for next time
|
|
514
|
-
}
|
|
515
|
-
return `[${byHash.type}:${byHash.description}]`;
|
|
179
|
+
db.saveImage(asImageRecord(byHash, normalized || imageUrl));
|
|
180
|
+
return `[image:${byHash.description}]`;
|
|
516
181
|
}
|
|
517
182
|
}
|
|
518
183
|
|
|
519
184
|
return "[image]";
|
|
520
185
|
}
|
|
521
186
|
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
187
|
+
function asImageRecord(record: ImageRecord, url: string): ImageRecord {
|
|
188
|
+
return {
|
|
189
|
+
...record,
|
|
190
|
+
url: normalizeImageUrl(url) || url,
|
|
191
|
+
type: "image",
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
527
195
|
export function normalizeImageUrl(url: string): string {
|
|
528
196
|
if (!url || typeof url !== "string") return "";
|
|
529
197
|
const trimmed = url.trim();
|
|
530
198
|
if (!trimmed) return "";
|
|
531
199
|
try {
|
|
532
|
-
const
|
|
533
|
-
return `${
|
|
200
|
+
const parsed = new URL(trimmed);
|
|
201
|
+
return `${parsed.protocol}//${parsed.host}${parsed.pathname}`;
|
|
534
202
|
} catch {
|
|
535
|
-
const
|
|
536
|
-
const
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
else if (qIdx >= 0) cutAt = qIdx;
|
|
540
|
-
else if (hashIdx >= 0) cutAt = hashIdx;
|
|
203
|
+
const queryIndex = trimmed.indexOf("?");
|
|
204
|
+
const hashIndex = trimmed.indexOf("#");
|
|
205
|
+
const indexes = [queryIndex, hashIndex].filter((index) => index >= 0);
|
|
206
|
+
const cutAt = indexes.length > 0 ? Math.min(...indexes) : -1;
|
|
541
207
|
return cutAt >= 0 ? trimmed.slice(0, cutAt) : trimmed;
|
|
542
208
|
}
|
|
543
209
|
}
|
package/core/prompt.ts
CHANGED
|
@@ -176,10 +176,9 @@ const TOOL_INTENSITY_BLOCK: Record<Strength, string> = {
|
|
|
176
176
|
|
|
177
177
|
const EMOJI_MODE_LINE: Record<Strength, string> = {
|
|
178
178
|
high:
|
|
179
|
-
"-
|
|
180
|
-
medium:
|
|
181
|
-
|
|
182
|
-
low: "- When it helps the emotional effect of the reply, you can use a matching sticker more freely.",
|
|
179
|
+
"- Do not use stickers in consecutive chat turns. Use a sticker only when you are in an emotionally intense state.",
|
|
180
|
+
medium: "- Do not use stickers in consecutive chat turns.",
|
|
181
|
+
low: "- Feel free to use a sticker whenever you want.",
|
|
183
182
|
};
|
|
184
183
|
|
|
185
184
|
const WEB_SEARCH_LINE: Record<Strength, string> = {
|
|
@@ -281,7 +280,7 @@ function buildResponseFormatSection(
|
|
|
281
280
|
- **If your reply has multiple sentences or different points, ALWAYS use real line breaks to separate them**
|
|
282
281
|
- NEVER use "\\" or literal "\\n" to simulate a new line
|
|
283
282
|
- **MESSAGE ORDER MATTERS**: messages are sent top-to-bottom, one line at a time.
|
|
284
|
-
- For action markers like [
|
|
283
|
+
- For action markers like [] or [audio:...], put them on their own line when they are meant to be a separate action.
|
|
285
284
|
|
|
286
285
|
- **SPECIAL ACTIONS in your text (auto-parsed and removed from message):**
|
|
287
286
|
- Use [at:123456] in your text to @ someone (123456 is the QQ number)
|
|
@@ -338,23 +337,15 @@ function appendEmojiSection(
|
|
|
338
337
|
if (!emojiAgent || !ctx.config.emoji?.enabled) return;
|
|
339
338
|
|
|
340
339
|
const configChars = ctx.config.emoji.characters || [];
|
|
341
|
-
|
|
342
|
-
configChars.length > 0 ? configChars : emojiAgent.getAvailableCharacters();
|
|
343
|
-
const availableEmotions: string[] = [];
|
|
344
|
-
for (const char of chars) {
|
|
345
|
-
availableEmotions.push(...emojiAgent.getAvailableEmotions(char));
|
|
346
|
-
}
|
|
347
|
-
const uniqueEmotions = [...new Set(availableEmotions)].sort();
|
|
348
|
-
if (uniqueEmotions.length === 0) return;
|
|
340
|
+
if (!emojiAgent.hasAvailableEmojis(configChars)) return;
|
|
349
341
|
|
|
350
342
|
lines.push(`
|
|
351
343
|
### Optional Sticker / Emoji Format
|
|
352
|
-
- You MAY optionally
|
|
344
|
+
- You MAY optionally request one matching sticker by writing exactly [] on its own line
|
|
353
345
|
${EMOJI_MODE_LINE[emojiStrength]}
|
|
354
|
-
-
|
|
355
|
-
-
|
|
356
|
-
-
|
|
357
|
-
- Available emotions: ${uniqueEmotions.join(", ")}`);
|
|
346
|
+
- Never put an emotion, label, character, or any other text inside the brackets
|
|
347
|
+
- Output at most one [] block in a turn
|
|
348
|
+
- A separate sticker selection agent will read the current conversation and choose the actual sticker`);
|
|
358
349
|
}
|
|
359
350
|
|
|
360
351
|
function appendExternalSkillsSection(
|
|
@@ -636,7 +627,7 @@ Just the last few messages - don't overthink it or dig into old conversations:
|
|
|
636
627
|
|
|
637
628
|
${mergedLines.join("\n")}
|
|
638
629
|
|
|
639
|
-
Note: Messages may contain media tags like [
|
|
630
|
+
Note: Messages may contain media tags like [image:描述], [video:描述], [forward:摘要], [card:摘要], or [group_notice:摘要]. If you need detailed information about an image or video, use the view_media tool with the message ID.
|
|
640
631
|
|
|
641
632
|
-- DON'T repeat yourself or bring up old topics - focus on what's being said right now. --`;
|
|
642
633
|
}
|
|
@@ -756,4 +747,4 @@ export function buildRecallMemoryFeatureSection(config: ChatConfig): string {
|
|
|
756
747
|
- Use recall_memory ONLY when there is explicit need to recall past content and required information is clearly missing from current context.
|
|
757
748
|
- Do NOT call recall_memory for every question.
|
|
758
749
|
- The worker returns historical logs with timestamps; treat them as past records, not newly sent messages.`;
|
|
759
|
-
}
|
|
750
|
+
}
|