mioku-plugin-chat 2.5.0 → 2.6.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/groups.ts +5 -0
- package/context.ts +5 -3
- package/core/chat-turn.ts +2 -1
- package/core/media/image-analyzer.ts +87 -7
- package/core/tools/web.ts +1 -1
- package/db.ts +4 -1
- package/handlers/message.ts +7 -3
- package/handlers/poke.ts +2 -2
- package/humanize/emoji-agent.ts +20 -7
- package/humanize/emotion-agent.ts +33 -26
- package/humanize/expression.ts +24 -16
- package/humanize/index.ts +10 -8
- package/humanize/memory.ts +10 -7
- package/humanize/planner.ts +11 -16
- package/humanize/topic.ts +22 -15
- package/index.ts +115 -28
- package/manage/cooldown.ts +28 -21
- package/manage/idle-check.ts +14 -9
- package/manage/queue-processor.ts +22 -17
- package/manage/rate-limiter.ts +56 -80
- package/package.json +1 -1
- package/runtime/chat-runtime.ts +1 -1
- package/types.ts +23 -0
- package/utils/group-config.ts +70 -0
- package/utils/message.ts +78 -12
package/context.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { MiokiContext } from "mioki";
|
|
|
2
2
|
import type { AIInstance, AIService } from "mioku";
|
|
3
3
|
import type { ChatConfig } from "./types";
|
|
4
4
|
import type { ChatDatabase } from "./db";
|
|
5
|
-
import type { HumanizeEngine } from "./humanize";
|
|
5
|
+
import type { HumanizeEngine, ChatConfigProvider } from "./humanize";
|
|
6
6
|
import type { SessionManager } from "./manage/session";
|
|
7
7
|
import type { SkillSessionManager } from "./manage/skill-session";
|
|
8
8
|
import type { RateLimiter } from "./manage/rate-limiter";
|
|
@@ -39,7 +39,9 @@ import type { ChatMessage } from "./types";
|
|
|
39
39
|
export interface ChatPluginContext {
|
|
40
40
|
// Core context
|
|
41
41
|
ctx: MiokiContext;
|
|
42
|
-
|
|
42
|
+
defaultConfig: ChatConfig;
|
|
43
|
+
configProvider: ChatConfigProvider;
|
|
44
|
+
getConfig: (groupId?: number) => Promise<ChatConfig>;
|
|
43
45
|
db: ChatDatabase;
|
|
44
46
|
aiInstance: AIInstance;
|
|
45
47
|
workAIInstance: AIInstance;
|
|
@@ -98,7 +100,7 @@ export interface ChatRuntimeState {
|
|
|
98
100
|
* Closure state shared with the extracted message/poke handlers.
|
|
99
101
|
*/
|
|
100
102
|
export interface ChatHandlerState {
|
|
101
|
-
getConfig: () => Promise<ChatConfig>;
|
|
103
|
+
getConfig: (groupId?: number) => Promise<ChatConfig>;
|
|
102
104
|
matchMessageCommands?: (
|
|
103
105
|
text: string,
|
|
104
106
|
) => Array<{ plugin: string; command: string }>;
|
package/core/chat-turn.ts
CHANGED
|
@@ -190,11 +190,12 @@ export async function processChat(
|
|
|
190
190
|
pluginCtx: ChatPluginContext,
|
|
191
191
|
runtimeState: ChatRuntimeState,
|
|
192
192
|
): Promise<void> {
|
|
193
|
-
const { ctx
|
|
193
|
+
const { ctx } = pluginCtx;
|
|
194
194
|
const isGroup = e.message_type === "group";
|
|
195
195
|
const groupId: number | undefined = isGroup ? e.group_id : undefined;
|
|
196
196
|
const userId: number = e.user_id || e.sender?.user_id;
|
|
197
197
|
const selfId = e.self_id;
|
|
198
|
+
const cfg = await pluginCtx.getConfig(groupId);
|
|
198
199
|
|
|
199
200
|
const personalSessionId = `personal:${userId}`;
|
|
200
201
|
const groupSessionId = groupId ? `group:${groupId}` : personalSessionId;
|
|
@@ -27,6 +27,13 @@ export interface ImageAnalysisResult {
|
|
|
27
27
|
|
|
28
28
|
export interface ImageAnalysisOptions {
|
|
29
29
|
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[];
|
|
30
37
|
}
|
|
31
38
|
|
|
32
39
|
function formatImageRecognitionLog(record: {
|
|
@@ -382,8 +389,29 @@ export async function processImage(
|
|
|
382
389
|
? analysis.characters
|
|
383
390
|
: [analysis.character || "unknown"];
|
|
384
391
|
|
|
385
|
-
//
|
|
386
|
-
const
|
|
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) => {
|
|
387
415
|
const memeDir = path.join(
|
|
388
416
|
process.cwd(),
|
|
389
417
|
"data",
|
|
@@ -429,7 +457,7 @@ export async function processImage(
|
|
|
429
457
|
// 保存到数据库
|
|
430
458
|
const record: ImageRecord = {
|
|
431
459
|
hash,
|
|
432
|
-
url: imageUrl,
|
|
460
|
+
url: normalizeImageUrl(imageUrl) || imageUrl,
|
|
433
461
|
type: analysis.type,
|
|
434
462
|
description: analysis.description || "未知",
|
|
435
463
|
emotion: analysis.emotion,
|
|
@@ -452,12 +480,64 @@ export async function processImage(
|
|
|
452
480
|
* 命中数据库记录时返回 [meme:描述] 或 [image:描述],否则返回 [image]。
|
|
453
481
|
* 收集聊天历史时用它,之前没识别到的图片不会再次触发请求。
|
|
454
482
|
*/
|
|
455
|
-
export function getImageTag(imageUrl: string, db: ChatDatabase): string {
|
|
456
|
-
const
|
|
483
|
+
export async function getImageTag(imageUrl: string, db: ChatDatabase): Promise<string> {
|
|
484
|
+
const exact = db.getImageByUrl(imageUrl);
|
|
485
|
+
if (exact) {
|
|
486
|
+
return `[${exact.type}:${exact.description}]`;
|
|
487
|
+
}
|
|
457
488
|
|
|
458
|
-
|
|
459
|
-
|
|
489
|
+
const normalized = normalizeImageUrl(imageUrl);
|
|
490
|
+
if (normalized && normalized !== imageUrl) {
|
|
491
|
+
const normalizedHit = db.getImageByUrl(normalized);
|
|
492
|
+
if (normalizedHit) {
|
|
493
|
+
return `[${normalizedHit.type}:${normalizedHit.description}]`;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const hash = await calculateImageHash(imageUrl);
|
|
498
|
+
if (hash) {
|
|
499
|
+
const byHash = db.getImageByHash(hash);
|
|
500
|
+
if (byHash) {
|
|
501
|
+
try {
|
|
502
|
+
db.saveImage({
|
|
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}]`;
|
|
516
|
+
}
|
|
460
517
|
}
|
|
461
518
|
|
|
462
519
|
return "[image]";
|
|
463
520
|
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Strip query string and hash so URLs from different NapCat code paths
|
|
524
|
+
* (live vs. get_group_msg_history) still match. QQ gchat.qpic.cn URLs
|
|
525
|
+
* include varying `?term=2`, `?spec=0`, signed tokens, etc.
|
|
526
|
+
*/
|
|
527
|
+
export function normalizeImageUrl(url: string): string {
|
|
528
|
+
if (!url || typeof url !== "string") return "";
|
|
529
|
+
const trimmed = url.trim();
|
|
530
|
+
if (!trimmed) return "";
|
|
531
|
+
try {
|
|
532
|
+
const u = new URL(trimmed);
|
|
533
|
+
return `${u.protocol}//${u.host}${u.pathname}`;
|
|
534
|
+
} catch {
|
|
535
|
+
const qIdx = trimmed.indexOf("?");
|
|
536
|
+
const hashIdx = trimmed.indexOf("#");
|
|
537
|
+
let cutAt = -1;
|
|
538
|
+
if (qIdx >= 0 && hashIdx >= 0) cutAt = Math.min(qIdx, hashIdx);
|
|
539
|
+
else if (qIdx >= 0) cutAt = qIdx;
|
|
540
|
+
else if (hashIdx >= 0) cutAt = hashIdx;
|
|
541
|
+
return cutAt >= 0 ? trimmed.slice(0, cutAt) : trimmed;
|
|
542
|
+
}
|
|
543
|
+
}
|
package/core/tools/web.ts
CHANGED
|
@@ -287,7 +287,7 @@ export function createRecallMemoryTool(toolCtx: ToolContext): AITool {
|
|
|
287
287
|
messages: toolCtx.db.getMessagesByUser(userId, toolCtx.sessionId, userHistoryLimit),
|
|
288
288
|
}));
|
|
289
289
|
|
|
290
|
-
const retriever = new MemoryRetrieval(ai, toolCtx.config, toolCtx.db);
|
|
290
|
+
const retriever = new MemoryRetrieval(ai, () => toolCtx.config, toolCtx.db);
|
|
291
291
|
const answer = await retriever.retrieveByQuestion({
|
|
292
292
|
sessionId: toolCtx.sessionId,
|
|
293
293
|
question,
|
package/db.ts
CHANGED
|
@@ -377,8 +377,11 @@ export async function initDatabase(): Promise<ChatDatabase> {
|
|
|
377
377
|
`),
|
|
378
378
|
// 图片记录
|
|
379
379
|
insertImage: db.prepare(`
|
|
380
|
-
INSERT
|
|
380
|
+
INSERT INTO images (hash, url, type, description, emotion, character, file_path, created_at)
|
|
381
381
|
VALUES ($hash, $url, $type, $description, $emotion, $character, $filePath, $createdAt)
|
|
382
|
+
ON CONFLICT(hash) DO UPDATE SET
|
|
383
|
+
url = excluded.url,
|
|
384
|
+
created_at = excluded.created_at
|
|
382
385
|
`),
|
|
383
386
|
getImageByHash: db.prepare(`SELECT * FROM images WHERE hash = $hash`),
|
|
384
387
|
getImageByUrl: db.prepare(`SELECT * FROM images WHERE url = $url`),
|
package/handlers/message.ts
CHANGED
|
@@ -33,13 +33,13 @@ export function createMessageHandler(
|
|
|
33
33
|
const { getConfig, matchMessageCommands, runtimeState } = state;
|
|
34
34
|
|
|
35
35
|
return async (e: any) => {
|
|
36
|
-
const
|
|
36
|
+
const isGroup = e.message_type === "group";
|
|
37
|
+
const groupId: number | undefined = isGroup ? e.group_id : undefined;
|
|
38
|
+
const cfg = await getConfig(groupId);
|
|
37
39
|
if (!cfg.apiKey) return;
|
|
38
40
|
if (!e?.message || !Array.isArray(e.message)) return;
|
|
39
41
|
|
|
40
42
|
const text = ctx.text(e) || "";
|
|
41
|
-
const isGroup = e.message_type === "group";
|
|
42
|
-
const groupId: number | undefined = isGroup ? e.group_id : undefined;
|
|
43
43
|
const userId: number = e.user_id || e.sender?.user_id;
|
|
44
44
|
|
|
45
45
|
if (userId === e.self_id) return;
|
|
@@ -104,6 +104,10 @@ export function createMessageHandler(
|
|
|
104
104
|
groupId,
|
|
105
105
|
label: "image-analysis",
|
|
106
106
|
}),
|
|
107
|
+
allowedCharacters:
|
|
108
|
+
cfg.emoji?.characters && cfg.emoji.characters.length > 0
|
|
109
|
+
? cfg.emoji.characters
|
|
110
|
+
: undefined,
|
|
107
111
|
}).catch((err) =>
|
|
108
112
|
ctx.logger.error(`[image-analyzer] Failed: ${err}`),
|
|
109
113
|
);
|
package/handlers/poke.ts
CHANGED
|
@@ -13,9 +13,9 @@ export function createPokeHandler(
|
|
|
13
13
|
|
|
14
14
|
return async (e: any) => {
|
|
15
15
|
if (e.target_id !== e.self_id) return;
|
|
16
|
-
const cfg = await getConfig();
|
|
17
|
-
if (!cfg.apiKey) return;
|
|
18
16
|
const groupId = e.group_id;
|
|
17
|
+
const cfg = groupId ? await getConfig(groupId) : await getConfig();
|
|
18
|
+
if (!cfg.apiKey) return;
|
|
19
19
|
if (!groupId || !isGroupAllowed(groupId, cfg)) return;
|
|
20
20
|
|
|
21
21
|
const lastPoke = pokeCooldowns.get(groupId) ?? 0;
|
package/humanize/emoji-agent.ts
CHANGED
|
@@ -5,6 +5,8 @@ import { logger } from "mioki";
|
|
|
5
5
|
import * as fs from "fs/promises";
|
|
6
6
|
import { existsSync, readdirSync } from "fs";
|
|
7
7
|
import * as path from "path";
|
|
8
|
+
import { extractGroupIdFromSession } from "../utils/group-config";
|
|
9
|
+
import type { ChatConfigProvider } from "./index";
|
|
8
10
|
|
|
9
11
|
export interface EmojiPickResult {
|
|
10
12
|
success: boolean;
|
|
@@ -33,13 +35,13 @@ const AVAILABLE_EMOTIONS = [
|
|
|
33
35
|
|
|
34
36
|
export class EmojiAgent {
|
|
35
37
|
private ai: AIInstance;
|
|
36
|
-
private
|
|
38
|
+
private getConfig: ChatConfigProvider;
|
|
37
39
|
private db: ChatDatabase;
|
|
38
40
|
private readonly memeBaseDir: string;
|
|
39
41
|
|
|
40
|
-
constructor(ai: AIInstance,
|
|
42
|
+
constructor(ai: AIInstance, configProvider: ChatConfigProvider, db: ChatDatabase) {
|
|
41
43
|
this.ai = ai;
|
|
42
|
-
this.
|
|
44
|
+
this.getConfig = configProvider;
|
|
43
45
|
this.db = db;
|
|
44
46
|
this.memeBaseDir = path.join(process.cwd(), "data", "chat", "meme");
|
|
45
47
|
}
|
|
@@ -77,6 +79,14 @@ export class EmojiAgent {
|
|
|
77
79
|
aiResponseText: string,
|
|
78
80
|
sessionId: string,
|
|
79
81
|
): Promise<EmojiPickResult> {
|
|
82
|
+
const cfg = this.getConfig(extractGroupIdFromSession(sessionId));
|
|
83
|
+
if (!cfg.emoji?.enabled) {
|
|
84
|
+
return {
|
|
85
|
+
success: false,
|
|
86
|
+
error: "emoji disabled",
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
80
90
|
const intents = this.parseAllMemeIntents(aiResponseText);
|
|
81
91
|
if (intents.length === 0) {
|
|
82
92
|
return {
|
|
@@ -88,7 +98,7 @@ export class EmojiAgent {
|
|
|
88
98
|
const chatHistory = this.db.getMessages(sessionId, 20);
|
|
89
99
|
|
|
90
100
|
// 获取配置中的角色列表
|
|
91
|
-
const configChars =
|
|
101
|
+
const configChars = cfg.emoji?.characters || [];
|
|
92
102
|
|
|
93
103
|
// 决定使用哪些角色
|
|
94
104
|
let targetCharacters: string[];
|
|
@@ -112,6 +122,7 @@ export class EmojiAgent {
|
|
|
112
122
|
targetCharacters,
|
|
113
123
|
intent.emotion,
|
|
114
124
|
chatHistory,
|
|
125
|
+
cfg,
|
|
115
126
|
);
|
|
116
127
|
|
|
117
128
|
if (!emojiResult.success || !emojiResult.emojiPath) {
|
|
@@ -135,6 +146,7 @@ export class EmojiAgent {
|
|
|
135
146
|
characters: string[],
|
|
136
147
|
emotion: string,
|
|
137
148
|
chatHistory: ChatMessage[],
|
|
149
|
+
cfg: ChatConfig = this.getConfig(),
|
|
138
150
|
): Promise<{
|
|
139
151
|
success: boolean;
|
|
140
152
|
emojiPath?: string;
|
|
@@ -228,7 +240,7 @@ export class EmojiAgent {
|
|
|
228
240
|
}
|
|
229
241
|
|
|
230
242
|
// 检查是否使用 AI 选择
|
|
231
|
-
const useAI =
|
|
243
|
+
const useAI = cfg.emoji?.useAISelection;
|
|
232
244
|
if (!useAI) {
|
|
233
245
|
// 不使用 AI,直接随机选择
|
|
234
246
|
logger.info(`[emoji-agent] useAISelection=false, random pick`);
|
|
@@ -236,7 +248,7 @@ export class EmojiAgent {
|
|
|
236
248
|
}
|
|
237
249
|
|
|
238
250
|
// 使用 AI 选择最合适的表情包
|
|
239
|
-
return this.selectByAI(allEmojis, normalizedEmotion, chatHistory);
|
|
251
|
+
return this.selectByAI(allEmojis, normalizedEmotion, chatHistory, cfg);
|
|
240
252
|
} catch (err) {
|
|
241
253
|
logger.error(`[emoji-agent] Failed to pick emoji: ${err}`);
|
|
242
254
|
return {
|
|
@@ -274,13 +286,14 @@ export class EmojiAgent {
|
|
|
274
286
|
emojis: { path: string; character: string; file: string }[],
|
|
275
287
|
emotion: string,
|
|
276
288
|
chatHistory: ChatMessage[],
|
|
289
|
+
cfg: ChatConfig = this.getConfig(),
|
|
277
290
|
): Promise<{
|
|
278
291
|
success: boolean;
|
|
279
292
|
emojiPath?: string;
|
|
280
293
|
description?: string;
|
|
281
294
|
error?: string;
|
|
282
295
|
}> {
|
|
283
|
-
const model =
|
|
296
|
+
const model = cfg.workingModel || cfg.model;
|
|
284
297
|
|
|
285
298
|
const systemPrompt = `You are an emoji/sticker selection assistant. Your task is to select the most appropriate emoji/sticker from a given list based on the chat context.
|
|
286
299
|
|
|
@@ -2,6 +2,8 @@ import type { AIInstance } from "mioku";
|
|
|
2
2
|
import { logger } from "mioki";
|
|
3
3
|
import { extractJsonObject } from "../utils/json";
|
|
4
4
|
import type { ChatConfig, ChatMessage, TargetMessage } from "../types";
|
|
5
|
+
import { extractGroupIdFromSession } from "../utils/group-config";
|
|
6
|
+
import type { ChatConfigProvider } from "./index";
|
|
5
7
|
|
|
6
8
|
export interface EmotionState {
|
|
7
9
|
current: string;
|
|
@@ -18,41 +20,43 @@ export interface EmotionAnalysisInput {
|
|
|
18
20
|
|
|
19
21
|
export class EmotionAgent {
|
|
20
22
|
private readonly ai: AIInstance;
|
|
21
|
-
private readonly
|
|
23
|
+
private readonly getConfig: ChatConfigProvider;
|
|
22
24
|
private readonly states = new Map<string, EmotionState>();
|
|
23
25
|
|
|
24
|
-
constructor(ai: AIInstance,
|
|
26
|
+
constructor(ai: AIInstance, configProvider: ChatConfigProvider) {
|
|
25
27
|
this.ai = ai;
|
|
26
|
-
this.
|
|
28
|
+
this.getConfig = configProvider;
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
getCurrent(sessionId: string): EmotionState {
|
|
30
32
|
const existing = this.states.get(sessionId);
|
|
31
33
|
if (existing) return existing;
|
|
32
34
|
|
|
33
|
-
const current = this.getDefaultEmotion();
|
|
35
|
+
const current = this.getDefaultEmotion(extractGroupIdFromSession(sessionId));
|
|
34
36
|
const state = { current, updatedAt: 0 };
|
|
35
37
|
this.states.set(sessionId, state);
|
|
36
38
|
return state;
|
|
37
39
|
}
|
|
38
40
|
|
|
39
|
-
getAvailableEmotions(): string[] {
|
|
40
|
-
const
|
|
41
|
+
getAvailableEmotions(groupId?: number): string[] {
|
|
42
|
+
const cfg = this.getConfig(groupId);
|
|
43
|
+
const emotions = Object.keys(cfg.emotion?.emotions || {})
|
|
41
44
|
.map((name) => name.trim().toLowerCase())
|
|
42
45
|
.filter(Boolean);
|
|
43
46
|
return Array.from(new Set(["default", ...emotions]));
|
|
44
47
|
}
|
|
45
48
|
|
|
46
|
-
getReferenceExamples(emotion: string): string[] {
|
|
49
|
+
getReferenceExamples(emotion: string, groupId?: number): string[] {
|
|
50
|
+
const cfg = this.getConfig(groupId);
|
|
47
51
|
const normalized = this.normalizeEmotionName(emotion);
|
|
48
|
-
const emotions =
|
|
52
|
+
const emotions = cfg.emotion?.emotions || {};
|
|
49
53
|
const examples = this.normalizeExamples(emotions[normalized]?.examples);
|
|
50
54
|
if (examples.length > 0) return examples;
|
|
51
|
-
return this.normalizeExamples(emotions[this.getDefaultEmotion()]?.examples);
|
|
55
|
+
return this.normalizeExamples(emotions[this.getDefaultEmotion(groupId)]?.examples);
|
|
52
56
|
}
|
|
53
57
|
|
|
54
|
-
setEmotion(sessionId: string, emotion: string): EmotionState {
|
|
55
|
-
const current = this.resolveEmotion(emotion);
|
|
58
|
+
setEmotion(sessionId: string, emotion: string, groupId?: number): EmotionState {
|
|
59
|
+
const current = this.resolveEmotion(emotion, groupId);
|
|
56
60
|
const state = { current, updatedAt: Date.now() };
|
|
57
61
|
this.states.set(sessionId, state);
|
|
58
62
|
return state;
|
|
@@ -69,28 +73,30 @@ export class EmotionAgent {
|
|
|
69
73
|
}
|
|
70
74
|
|
|
71
75
|
async refreshIfNeeded(input: EmotionAnalysisInput): Promise<EmotionState> {
|
|
76
|
+
const groupId = extractGroupIdFromSession(input.sessionId);
|
|
77
|
+
const cfg = this.getConfig(groupId);
|
|
72
78
|
const current = this.getCurrent(input.sessionId);
|
|
73
|
-
const intervalMs = Number(
|
|
79
|
+
const intervalMs = Number(cfg.emotion?.updateIntervalMs ?? 60 * 60_000);
|
|
74
80
|
const shouldRefresh =
|
|
75
81
|
Boolean(input.force) || current.updatedAt <= 0 || Date.now() - current.updatedAt >= intervalMs;
|
|
76
82
|
|
|
77
83
|
if (!shouldRefresh) return current;
|
|
78
84
|
|
|
79
85
|
try {
|
|
80
|
-
const nextEmotion = await this.analyzeEmotion(input);
|
|
81
|
-
return this.setEmotion(input.sessionId, nextEmotion);
|
|
86
|
+
const nextEmotion = await this.analyzeEmotion(input, cfg);
|
|
87
|
+
return this.setEmotion(input.sessionId, nextEmotion, groupId);
|
|
82
88
|
} catch (err) {
|
|
83
89
|
logger.warn(`[emotion-agent] emotion analysis failed: ${err}`);
|
|
84
90
|
if (current.updatedAt <= 0) {
|
|
85
|
-
return this.setEmotion(input.sessionId, this.getDefaultEmotion());
|
|
91
|
+
return this.setEmotion(input.sessionId, this.getDefaultEmotion(groupId), groupId);
|
|
86
92
|
}
|
|
87
93
|
return current;
|
|
88
94
|
}
|
|
89
95
|
}
|
|
90
96
|
|
|
91
|
-
private async analyzeEmotion(input: EmotionAnalysisInput): Promise<string> {
|
|
92
|
-
const availableEmotions = this.getAvailableEmotions();
|
|
93
|
-
const model =
|
|
97
|
+
private async analyzeEmotion(input: EmotionAnalysisInput, cfg: ChatConfig = this.getConfig(extractGroupIdFromSession(input.sessionId))): Promise<string> {
|
|
98
|
+
const availableEmotions = this.getAvailableEmotions(extractGroupIdFromSession(input.sessionId));
|
|
99
|
+
const model = cfg.workingModel || cfg.model;
|
|
94
100
|
const systemPrompt = `You are an emotion state selector for a chat bot.
|
|
95
101
|
|
|
96
102
|
Task:
|
|
@@ -134,20 +140,21 @@ ${input.targetMessage.userName}: ${input.targetMessage.content}`;
|
|
|
134
140
|
|
|
135
141
|
const content = response.content || "";
|
|
136
142
|
const parsed = extractJsonObject(content);
|
|
137
|
-
if (parsed === undefined) return this.getDefaultEmotion();
|
|
138
|
-
return this.resolveEmotion(parsed?.emotion);
|
|
143
|
+
if (parsed === undefined) return this.getDefaultEmotion(extractGroupIdFromSession(input.sessionId));
|
|
144
|
+
return this.resolveEmotion(parsed?.emotion, extractGroupIdFromSession(input.sessionId));
|
|
139
145
|
}
|
|
140
146
|
|
|
141
|
-
private resolveEmotion(emotion: unknown): string {
|
|
147
|
+
private resolveEmotion(emotion: unknown, groupId?: number): string {
|
|
142
148
|
const normalized = this.normalizeEmotionName(emotion);
|
|
143
|
-
const available = this.getAvailableEmotions();
|
|
149
|
+
const available = this.getAvailableEmotions(groupId);
|
|
144
150
|
if (available.includes(normalized)) return normalized;
|
|
145
|
-
return this.getDefaultEmotion();
|
|
151
|
+
return this.getDefaultEmotion(groupId);
|
|
146
152
|
}
|
|
147
153
|
|
|
148
|
-
private getDefaultEmotion(): string {
|
|
149
|
-
const
|
|
150
|
-
const
|
|
154
|
+
private getDefaultEmotion(groupId?: number): string {
|
|
155
|
+
const cfg = this.getConfig(groupId);
|
|
156
|
+
const configured = this.normalizeEmotionName(cfg.emotion?.defaultEmotion);
|
|
157
|
+
const available = this.getAvailableEmotions(groupId);
|
|
151
158
|
return available.includes(configured) ? configured : "default";
|
|
152
159
|
}
|
|
153
160
|
|
package/humanize/expression.ts
CHANGED
|
@@ -2,23 +2,25 @@ import type { AIInstance } from "mioku";
|
|
|
2
2
|
import { logger } from "mioki";
|
|
3
3
|
import { extractJsonObject } from "../utils/json";
|
|
4
4
|
import type { ChatDatabase } from "../db";
|
|
5
|
-
import type {
|
|
5
|
+
import type { ChatMessage } from "../types";
|
|
6
|
+
import type { ChatConfigProvider } from "./index";
|
|
6
7
|
|
|
7
8
|
export class ExpressionLearner {
|
|
8
9
|
private ai: AIInstance;
|
|
9
|
-
private
|
|
10
|
+
private getConfig: ChatConfigProvider;
|
|
10
11
|
private db: ChatDatabase;
|
|
11
12
|
private pendingMessagesByUser: Map<number, ChatMessage[]> = new Map();
|
|
12
13
|
private learningUsers: Set<number> = new Set();
|
|
13
14
|
|
|
14
|
-
constructor(ai: AIInstance,
|
|
15
|
+
constructor(ai: AIInstance, configProvider: ChatConfigProvider, db: ChatDatabase) {
|
|
15
16
|
this.ai = ai;
|
|
16
|
-
this.
|
|
17
|
+
this.getConfig = configProvider;
|
|
17
18
|
this.db = db;
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
async onMessage(message: ChatMessage): Promise<void> {
|
|
21
|
-
|
|
22
|
+
const cfg = this.getConfig(message.groupId);
|
|
23
|
+
if (!cfg.expression?.enabled) return;
|
|
22
24
|
if (message.role !== "user") return;
|
|
23
25
|
if (!message.content || message.content.length < 4) return;
|
|
24
26
|
if (!message.userId) return;
|
|
@@ -27,13 +29,18 @@ export class ExpressionLearner {
|
|
|
27
29
|
pending.push(message);
|
|
28
30
|
this.pendingMessagesByUser.set(message.userId, pending);
|
|
29
31
|
|
|
30
|
-
await this.tryLearn(message.userId);
|
|
32
|
+
await this.tryLearn(message.userId, cfg);
|
|
31
33
|
}
|
|
32
34
|
|
|
33
|
-
getExpressionContextForUser(
|
|
34
|
-
|
|
35
|
+
getExpressionContextForUser(
|
|
36
|
+
userId: number,
|
|
37
|
+
userName: string,
|
|
38
|
+
groupId?: number,
|
|
39
|
+
): string {
|
|
40
|
+
const cfg = this.getConfig(groupId);
|
|
41
|
+
if (!cfg.expression?.enabled) return "";
|
|
35
42
|
|
|
36
|
-
const sampleSize =
|
|
43
|
+
const sampleSize = cfg.expression?.sampleSize ?? 8;
|
|
37
44
|
const expressions = this.db.getExpressionsByUser(userId, sampleSize);
|
|
38
45
|
if (expressions.length === 0) {
|
|
39
46
|
logger.info(
|
|
@@ -55,12 +62,12 @@ export class ExpressionLearner {
|
|
|
55
62
|
return context;
|
|
56
63
|
}
|
|
57
64
|
|
|
58
|
-
private async tryLearn(userId: number): Promise<void> {
|
|
65
|
+
private async tryLearn(userId: number, cfg = this.getConfig()): Promise<void> {
|
|
59
66
|
if (this.learningUsers.has(userId)) return;
|
|
60
67
|
|
|
61
68
|
const threshold = Math.max(
|
|
62
69
|
1,
|
|
63
|
-
|
|
70
|
+
cfg.expression?.learnAfterMessages ?? 100,
|
|
64
71
|
);
|
|
65
72
|
const pending = this.pendingMessagesByUser.get(userId) ?? [];
|
|
66
73
|
logger.debug(
|
|
@@ -76,7 +83,7 @@ export class ExpressionLearner {
|
|
|
76
83
|
|
|
77
84
|
const batch = current.slice(0, threshold);
|
|
78
85
|
this.pendingMessagesByUser.set(userId, current.slice(threshold));
|
|
79
|
-
await this.learnForUser(userId, batch);
|
|
86
|
+
await this.learnForUser(userId, batch, cfg);
|
|
80
87
|
}
|
|
81
88
|
} catch (err) {
|
|
82
89
|
logger.warn(
|
|
@@ -90,10 +97,11 @@ export class ExpressionLearner {
|
|
|
90
97
|
private async learnForUser(
|
|
91
98
|
userId: number,
|
|
92
99
|
messages: ChatMessage[],
|
|
100
|
+
cfg = this.getConfig(),
|
|
93
101
|
): Promise<void> {
|
|
94
102
|
if (messages.length === 0) return;
|
|
95
103
|
|
|
96
|
-
const maxHabits = Math.max(1,
|
|
104
|
+
const maxHabits = Math.max(1, cfg.expression?.sampleSize ?? 8);
|
|
97
105
|
const userName = messages[messages.length - 1].userName || `User${userId}`;
|
|
98
106
|
const msgTexts = messages.map((m) => m.content).join("\n");
|
|
99
107
|
|
|
@@ -128,7 +136,7 @@ Output strictly in JSON:
|
|
|
128
136
|
|
|
129
137
|
If nothing reliable can be extracted, keep stable previous habits when possible. If still nothing, output {"expressions":[]}.`,
|
|
130
138
|
messages: [],
|
|
131
|
-
model:
|
|
139
|
+
model: cfg.workingModel || cfg.model,
|
|
132
140
|
temperature: 0.2,
|
|
133
141
|
max_tokens: 600,
|
|
134
142
|
});
|
|
@@ -154,7 +162,7 @@ If nothing reliable can be extracted, keep stable previous habits when possible.
|
|
|
154
162
|
|
|
155
163
|
const keepCount = Math.max(
|
|
156
164
|
1,
|
|
157
|
-
|
|
165
|
+
cfg.retention?.expressionKeepPerUser ?? maxHabits,
|
|
158
166
|
);
|
|
159
167
|
const pruned = this.db.pruneExpressionsBySession(
|
|
160
168
|
`user:${userId}`,
|
|
@@ -170,4 +178,4 @@ If nothing reliable can be extracted, keep stable previous habits when possible.
|
|
|
170
178
|
`[ExpressionLearner] Updated ${normalized.length} habits for ${userName} (${userId}): ${JSON.stringify(normalized)}`,
|
|
171
179
|
);
|
|
172
180
|
}
|
|
173
|
-
}
|
|
181
|
+
}
|
package/humanize/index.ts
CHANGED
|
@@ -16,6 +16,8 @@ import { TopicTracker } from "./topic";
|
|
|
16
16
|
import { ActionPlanner } from "./planner";
|
|
17
17
|
import { ExpressionLearner } from "./expression";
|
|
18
18
|
|
|
19
|
+
export type ChatConfigProvider = (groupId?: number) => ChatConfig;
|
|
20
|
+
|
|
19
21
|
export class HumanizeEngine {
|
|
20
22
|
readonly memoryRetrieval: MemoryRetrieval;
|
|
21
23
|
readonly topicTracker: TopicTracker;
|
|
@@ -27,18 +29,18 @@ export class HumanizeEngine {
|
|
|
27
29
|
constructor(
|
|
28
30
|
mainAI: AIInstance,
|
|
29
31
|
workAI: AIInstance,
|
|
30
|
-
config: ChatConfig,
|
|
31
32
|
db: ChatDatabase,
|
|
33
|
+
configProvider: ChatConfigProvider,
|
|
32
34
|
) {
|
|
33
|
-
this.memoryRetrieval = new MemoryRetrieval(workAI,
|
|
34
|
-
this.topicTracker = new TopicTracker(workAI,
|
|
35
|
-
this.actionPlanner = new ActionPlanner(workAI,
|
|
36
|
-
this.emotionAgent = new EmotionAgent(workAI,
|
|
37
|
-
this.emojiAgent = new EmojiAgent(workAI,
|
|
38
|
-
this.expressionLearner = new ExpressionLearner(workAI,
|
|
35
|
+
this.memoryRetrieval = new MemoryRetrieval(workAI, configProvider, db);
|
|
36
|
+
this.topicTracker = new TopicTracker(workAI, configProvider, db);
|
|
37
|
+
this.actionPlanner = new ActionPlanner(workAI, configProvider);
|
|
38
|
+
this.emotionAgent = new EmotionAgent(workAI, configProvider);
|
|
39
|
+
this.emojiAgent = new EmojiAgent(workAI, configProvider, db);
|
|
40
|
+
this.expressionLearner = new ExpressionLearner(workAI, configProvider, db);
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
async init(): Promise<void> {
|
|
42
44
|
// emojiAgent 不需要初始化,它直接从文件系统读取
|
|
43
45
|
}
|
|
44
|
-
}
|
|
46
|
+
}
|
package/humanize/memory.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type { AIInstance } from "mioku";
|
|
2
2
|
import { logger } from "mioki";
|
|
3
3
|
import type { ChatDatabase } from "../db";
|
|
4
|
-
import type {
|
|
4
|
+
import type { ChatMessage } from "../types";
|
|
5
|
+
import { extractGroupIdFromSession } from "../utils/group-config";
|
|
6
|
+
import type { ChatConfigProvider } from "./index";
|
|
5
7
|
|
|
6
8
|
export interface MemoryUserHistoryChunk {
|
|
7
9
|
userId: number;
|
|
@@ -18,17 +20,18 @@ export interface MemoryRecallInput {
|
|
|
18
20
|
|
|
19
21
|
export class MemoryRetrieval {
|
|
20
22
|
private ai: AIInstance;
|
|
21
|
-
private
|
|
23
|
+
private getConfig: ChatConfigProvider;
|
|
22
24
|
private db: ChatDatabase;
|
|
23
25
|
|
|
24
|
-
constructor(ai: AIInstance,
|
|
26
|
+
constructor(ai: AIInstance, configProvider: ChatConfigProvider, db: ChatDatabase) {
|
|
25
27
|
this.ai = ai;
|
|
26
|
-
this.
|
|
28
|
+
this.getConfig = configProvider;
|
|
27
29
|
this.db = db;
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
async retrieveByQuestion(input: MemoryRecallInput): Promise<string | null> {
|
|
31
|
-
|
|
33
|
+
const cfg = this.getConfig(extractGroupIdFromSession(input.sessionId));
|
|
34
|
+
if (!cfg.memory?.enabled) return null;
|
|
32
35
|
|
|
33
36
|
const question = String(input.question || "").trim();
|
|
34
37
|
if (!question) return null;
|
|
@@ -68,7 +71,7 @@ Output requirements:
|
|
|
68
71
|
- If useful evidence exists, summarize key facts with timestamps.
|
|
69
72
|
- If no useful memory exists, output exactly: NO_USEFUL_MEMORY_FOUND`,
|
|
70
73
|
messages: [],
|
|
71
|
-
model:
|
|
74
|
+
model: cfg.workingModel || cfg.model,
|
|
72
75
|
temperature: 0.2,
|
|
73
76
|
max_tokens: 700,
|
|
74
77
|
});
|
|
@@ -128,4 +131,4 @@ Output requirements:
|
|
|
128
131
|
private formatTimestamp(timestamp: number): string {
|
|
129
132
|
return new Date(timestamp).toLocaleString("zh-CN", { hour12: false });
|
|
130
133
|
}
|
|
131
|
-
}
|
|
134
|
+
}
|