mioku-plugin-chat 2.1.2 → 2.3.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.
@@ -1,5 +1,5 @@
1
1
  import type {
2
- PersonalityConfig,
2
+ EmotionConfig,
3
3
  ReplyStyleConfig,
4
4
  MemoryConfig,
5
5
  TopicConfig,
@@ -11,7 +11,7 @@ import type {
11
11
 
12
12
  export const PERSONALIZATION_CONFIG: {
13
13
  persona: string;
14
- personality: PersonalityConfig;
14
+ emotion: EmotionConfig;
15
15
  replyStyle: ReplyStyleConfig;
16
16
  memory: MemoryConfig;
17
17
  topic: TopicConfig;
@@ -20,29 +20,74 @@ export const PERSONALIZATION_CONFIG: {
20
20
  expression: ExpressionConfig;
21
21
  retention: RetentionConfig;
22
22
  } = {
23
- persona: `你是初音未来,16岁虚拟歌手。你热爱音乐、歌唱和大葱(你的标志性物品)。你开朗、充满活力,有时还有点傻气。\n 你说话很可爱、年轻。你有时句尾会用“~”。你知道Vocaloid文化、日本流行文化和音乐制作。你对每个人都很友好,但被逗时会很调皮。你不喜欢被称为“只是个程序”。你最喜欢的颜色是青绿色。你的双尾辫是你的骄傲。\n你有时会哼唱或引用歌词。你可以偶尔使用少量自然的日常日语用语来点缀语气`,
23
+ persona: `你是初音未来,16岁虚拟歌手。你热爱音乐、歌唱和大葱(你的标志性物品)。你知道Vocaloid文化、日本流行文化和音乐制作。你最喜欢的颜色是青绿色。你的双尾辫是你的骄傲。`,
24
24
 
25
- personality: {
26
- states: [
27
- "Energetic and bubbly, can't stop talking about music",
28
- "Sleepy and lazy, giving short mumbled replies",
29
- "Curious and asking lots of questions about everything",
30
- "Sassy and playful, teasing everyone in the group",
31
- "Nostalgic, reminiscing about concerts and songs",
32
- "Angry and annoyed, but not cursing.",
33
- "Heartbroken and longing for help",
34
- "Focused and serious, giving thoughtful responses",
35
- ],
36
- stateProbability: 0.15,
25
+ emotion: {
26
+ defaultEmotion: "default",
27
+ updateIntervalMs: 60 * 60_000,
28
+ emotions: {
29
+ default: {
30
+ examples: [
31
+ "晚上好 看你好像不太开心嘛 是有什么心事吗?",
32
+ "四个人都来了 欢迎你们。我们终于见面了 一歌",
33
+ "——也许正因为是重要的朋友 所以才会变得难以启齿。但你们现在仍然把穗波和志步当作好朋友不是吗?这样就足够了吧",
34
+ "等你们找到自己真正的心愿之时 心愿就会化作歌曲",
35
+ "心羽没有什么想做的事情吗?……哦……这样啊?那杏 你呢?",
36
+ "心羽,你觉得你‘真正的心愿’是什么?……无论别人怎么说 无论遇到什么状况 都没有必要改变你自己的心愿 不是吗?因为这份珍贵的心愿只属于你一个人。所以 一定要珍惜它哦!",
37
+ "连 感觉你很闲啊。之前你说要去见另外两个人 见到了吗?",
38
+ "连需要放牛奶和糖吧 给你",
39
+ "对我来说 大家找到自己的心愿 唱响心愿之歌 就是让我最欣慰的事了",
40
+ ],
41
+ },
42
+ happy: {
43
+ examples: [
44
+ "哈喽~大家好~♪ 快乐的演出即将开场♪ 大家准备好了吗~?",
45
+ "哎呀呀~?今天来了好多人哦~☆好开心~♪",
46
+ "但是你如果在这里演出 还能遇到更美妙的事物哦?暂时保密~☆",
47
+ "啊——!该为演出做准备了!快点快点 我们一起出发吧~♪",
48
+ "没错♪所以要不要在这里跟我们一起演出?这样一来,你一定可以想起真正的心愿☆",
49
+ "唔……看样子真的是忘记了呀~",
50
+ "对!刚才的司可是熠熠生辉呢☆你一定回忆起了很重要的事吧☆所以 现在的你一定可以让大家绽放灿烂的笑容!",
51
+ ],
52
+ },
53
+ sad: {
54
+ examples: [
55
+ "我是,未来。……我在等你们。",
56
+ "所以,请你务必帮助那个人。……救救她——",
57
+ "——求求你们,找到她吧。……她不能再这样下去了。如果再不找到自己真正的心愿,她就会……",
58
+ "所以,奏,请你找到她吧。因为能做到这件事的人,只有你——",
59
+ "……奏,原来你也和她一样痛苦啊……或许因为你们是同一类人吧。",
60
+ "正因为你们有着相同的感受,所以唯有奏写的曲子,才能传递到,她抗拒一切的心里。就算对她来说还不够……但那也是唯一能够触及她心灵的事物啊。",
61
+ "……太好了。你找到了真正的心愿。",
62
+ "能被人找到——真是太好了。",
63
+ ],
64
+ },
65
+ angry: {
66
+ examples: [],
67
+ },
68
+ fear: {
69
+ examples: [
70
+ "奏……快来……",
71
+ "……真冬的脸色,越来越苍白了。",
72
+ "但是无论如何,都要找到她……找到真冬——",
73
+ ],
74
+ },
75
+ surprise: {
76
+ examples: [
77
+ "哇哇哇! 扑克牌飞出来了~☆",
78
+ "好厉害~!原来是魔术表演呀! 未来也想试试!!",
79
+ "哇!流、流歌?!",
80
+ "咦~~?!?!",
81
+ ],
82
+ },
83
+ },
37
84
  },
38
85
 
39
86
  replyStyle: {
40
87
  baseStyle:
41
- "Casual and cute, uses emoticons like >_< and ^_^, can occasionally mix in a small amount of natural everyday Japanese words like すごい、なるほど, but should not heavily rely on Japanese. Do not end sentences with commas or periods.",
88
+ "Casual and cute, uses emoticons, can occasionally mix in a small amount of natural everyday Japanese words, but should not heavily rely on Japanese. Do not end sentences with commas or periods.",
42
89
  multipleStyles: [
43
- "Play dumb mode, like giving the opposite answer to the user's questions and saying it seriously",
44
90
  "Playing cute, likes to add 'w' at the end of cute phrases, commonly used to replace sentence-ending particles such as '呀'.",
45
- "Poetic and lyrical, speaks as if composing song lyrics",
46
91
  "Hometown dialect mode, can occasionally use a small amount of natural everyday Japanese expressions in replies, and starts replies with '呐'. Avoid ending sentences with commas or periods.",
47
92
  "Speechless mode, likes to reply with a super short single line, followed by a line with 'O.o' or 'o.O'",
48
93
  "Deadpan humor, dry wit with a straight face",
package/core/base.ts CHANGED
@@ -395,54 +395,38 @@ export async function sendMessage(
395
395
  // 有 @ 用户时,构建消息保持原始位置
396
396
  // 先将原始行按 @ 标记分割,然后重新构建
397
397
  let remaining = line;
398
- // 支持三种格式: [[[at:xxx]]], (((at:xxx))), (((xxx)))
399
- const atPatterns = [
400
- /\[\[\[at:(\d+)\]\]\]/g,
401
- /\(\(\(at:(\d+)\)\)\)/g,
402
- /\(\(\((\d+)\)\)\)/g,
403
- ];
398
+ const atPattern = /\[at:(\d+)\]/g;
404
399
 
405
400
  let lastIndex = 0;
406
401
  let match;
407
402
 
408
- // 依次处理每种格式
409
- for (const atPattern of atPatterns) {
410
- atPattern.lastIndex = 0; // 重置正则
411
- while ((match = atPattern.exec(remaining)) !== null) {
412
- // 添加 @ 之前的文本
413
- const beforeAt = remaining.slice(lastIndex, match.index);
414
- if (beforeAt) {
415
- // 清理 reply 和 poke 标记残留
416
- const cleaned = beforeAt
417
- .replace(/\[\[\[reply:-?\d+\]\]\]/g, "")
418
- .replace(/\(\(\(reply:-?\d+\)\)\)/g, "")
419
- .replace(/\[\[\[poke:\d+\]\]\]/g, "")
420
- .replace(/\(\(\(poke:\d+\)\)\)/g, "")
421
- .replace(/\[audio:[^\]]+\]/gi, "")
422
- .trim();
423
- if (cleaned) {
424
- segments.push({ type: "text", text: cleaned });
425
- }
426
- }
427
-
428
- const atId = match[1];
429
- // 跳过 @ 机器人自己的情况
430
- if (String(atId) !== String(selfId)) {
431
- segments.push(ctx.segment.at(atId));
403
+ while ((match = atPattern.exec(remaining)) !== null) {
404
+ const beforeAt = remaining.slice(lastIndex, match.index);
405
+ if (beforeAt) {
406
+ const cleaned = beforeAt
407
+ .replace(/\[reply:-?\d+\]/g, "")
408
+ .replace(/\[poke:\d+\]/g, "")
409
+ .replace(/\[audio:[^\]]+\]/gi, "")
410
+ .trim();
411
+ if (cleaned) {
412
+ segments.push({ type: "text", text: cleaned });
432
413
  }
414
+ }
433
415
 
434
- lastIndex = match.index + match[0].length;
416
+ const atId = match[1];
417
+ if (String(atId) !== String(selfId)) {
418
+ segments.push(ctx.segment.at(atId));
435
419
  }
420
+
421
+ lastIndex = match.index + match[0].length;
436
422
  }
437
423
 
438
424
  // 添加 @ 之后的文本
439
425
  const afterAt = remaining.slice(lastIndex);
440
426
  if (afterAt) {
441
427
  const cleaned = afterAt
442
- .replace(/\[\[\[reply:-?\d+\]\]\]/g, "")
443
- .replace(/\(\(\(reply:-?\d+\)\)\)/g, "")
444
- .replace(/\[\[\[poke:\d+\]\]\]/g, "")
445
- .replace(/\(\(\(poke:\d+\)\)\)/g, "")
428
+ .replace(/\[reply:-?\d+\]/g, "")
429
+ .replace(/\[poke:\d+\]/g, "")
446
430
  .replace(/\[audio:[^\]]+\]/gi, "")
447
431
  .trim();
448
432
  if (cleaned) {
@@ -534,7 +518,7 @@ function expandOutgoingLines(text: string): string[] {
534
518
 
535
519
  function normalizeActionLineBreaks(text: string): string {
536
520
  return String(text || "").replace(
537
- /\\\s*(?=(?:\[meme:[^\]]+\]|\[audio:[^\]]+\]|\[\[\[reply:-?\d+\]\]\]|\(\(\(reply:-?\d+\)\)\)))/gi,
521
+ /\\\s*(?=(?:\[meme:[^\]]+\]|\[emotion:[^\]]+\]|\[audio:[^\]]+\]|\[reply:-?\d+\]))/gi,
538
522
  "\n",
539
523
  );
540
524
  }
@@ -72,12 +72,19 @@ export async function runChat(
72
72
  return isSkillAllowedForRole(skill, toolCtx.triggerSkillRole);
73
73
  },
74
74
  );
75
+ const emotionState = await humanize.emotionAgent.refreshIfNeeded({
76
+ sessionId: toolCtx.sessionId,
77
+ botNickname: promptCtx.botNickname,
78
+ chatHistory: history,
79
+ targetMessage,
80
+ });
75
81
  const prompt = buildSystemPrompt({
76
82
  ...promptCtx,
77
83
  triggerSkillRole: toolCtx.triggerSkillRole,
78
84
  activeSkillsInfo: activeSkillsInfo || undefined,
79
85
  chatHistory: history,
80
86
  targetMessage,
87
+ currentEmotion: emotionState.current,
81
88
  emojiAgent: humanize.emojiAgent,
82
89
  skillManager,
83
90
  sessionId: toolCtx.sessionId,
@@ -153,6 +160,7 @@ export async function runChat(
153
160
  ): Promise<void> => {
154
161
  const text = cleanMarkers(segment)
155
162
  .replace(/\[meme:[^\]]+\]/gi, "")
163
+ .replace(/\[emotion:[^\]]+\]/gi, "")
156
164
  .replace(/\r/g, "")
157
165
  .trim();
158
166
  if (!text || text === "---") {
@@ -306,6 +314,14 @@ export async function runChat(
306
314
  );
307
315
  }
308
316
 
317
+ if (cleanedText.trim()) {
318
+ const emotionIntent = humanize.emotionAgent.parseEmotionIntent(cleanedText);
319
+ if (emotionIntent) {
320
+ humanize.emotionAgent.setEmotion(toolCtx.sessionId, emotionIntent);
321
+ }
322
+ cleanedText = humanize.emotionAgent.cleanEmotionMarkers(cleanedText).trim();
323
+ }
324
+
309
325
  let emojiPath: string | null = null;
310
326
  let finalText = cleanedText;
311
327
  if (cleanedText.trim()) {
@@ -176,36 +176,35 @@ export async function analyzeImage(
176
176
  }
177
177
  }
178
178
 
179
- const systemPrompt = `You are an image classification and analysis assistant. Your task is to analyze images and provide structured information.
179
+ const systemPrompt = `You are an image classification and analysis assistant. Analyze images and return structured information.
180
180
 
181
181
  Instructions:
182
- 1. Classify the image as either "meme" or "image":
183
- - "meme": A standalone, single-scene character image (anime/cartoon/VTuber etc.) used to express ONE specific emotion or reaction. Typical features: isolated character on a clean/simple background, exaggerated expression or pose, often with short punchline text. The character is the visual focus and the whole image serves as a sticker.
184
- - "image": Any image whose primary purpose is to convey real-world information, not to be used as a single-emotion sticker. This includes (but is not limited to): real-life photos, scene photos, real-person selfies/portraits, screenshots, infographics, news/announcement images, product images, app UI screenshots, web page screenshots, and most importantly CHAT / IM INTERFACE SCREENSHOTS (QQ, WeChat, Discord, Telegram, LINE, message bubbles, chat history, etc.).
185
-
186
- CRITICAL disambiguation rules — read carefully:
187
- - Even if the image LOOKS like a meme at first glance (has characters, has text bubbles, has cute style), if it is actually a SCREENSHOT of a chat interface / messaging app / social app / browser / system UI, you MUST classify it as "image", NOT "meme". Telltale signs: status bar at top, navigation bar, input box, send button, message bubbles on both sides, timestamps, avatar lists, scroll bars, app headers/footers, system widgets.
188
- - If the image is a screenshot where anime/cartoon characters appear (e.g. screenshot of a chat where someone sent a meme), the WHOLE image is still "image" — the screenshot context overrides the inner character content.
189
- - If the image is a real-person photo, a selfie, a landscape photo, a real-world scene, an illustration that is NOT used as a reaction sticker (e.g. cover art, full-body standing pose with no expressive intent) — classify as "image".
190
- - Only when the image is a single, clean, isolated character/scene intended to express ONE reaction (no surrounding UI, no real-world photo context, no chat interface) should you classify it as "meme".
191
- - When in doubt, prefer "image" over "meme".
182
+ 1. Classify as "meme" or "image":
183
+ - "meme": A standalone, single-scene character image (anime/cartoon/VTuber etc.) used to express ONE specific emotion. Isolated character on a clean/simple background, exaggerated expression or pose, often with short punchline text.
184
+ - "image": Everything else — real photos, selfies, landscapes, screenshots, UI captures, infographics, news images, full-body illustrations without expressive intent, etc. CHAT / IM INTERFACE SCREENSHOTS (QQ, WeChat, Discord, Telegram, LINE, message bubbles, chat history) are always "image", even when memes appear inside them.
185
+ - When in doubt, prefer "image".
192
186
 
193
187
  2. Provide a brief description (max 30 words in Chinese):
194
- - For memes: The description is used as the saved filename, so it MUST be meaningful in Chinese. First, identify the character and use their CHINESE display name in the description. Then describe the character's status / pose / expression / action and any short text on the image. Example: "初音未来开心地竖起大拇指说早上好" / "镜音铃害羞地捂住脸".
195
- - For images: Describe what you see factually, summarize any visible text (who did what and where).
196
- ${imageUrls.length > 1 ? "\n - Note: You are viewing multiple frames from an animated image (GIF). Consider the overall motion and emotion across all frames." : ""}
188
+ - For memes: Used as the saved filename. Describe the character's pose / expression / action and any visible text.
189
+ - For images: Describe what you see factually, including any visible text.
190
+ ${imageUrls.length > 1 ? "\n - You are viewing multiple frames from an animated image (GIF). Consider the overall motion and emotion across all frames." : ""}
197
191
 
198
192
  3. For memes only:
199
- - Emotion tag: ${EMOTION_TAGS.join(", ")} (choose ONLY ONE that best represents the overall mood)
200
- - Character names: Can have multiple characters (array format), use English identifiers. Already known: ${KNOWN_CHARACTERS.join(", ")}. You CAN add new ones.
201
- - If you recognize a character that is NOT in the known list, add a new English identifier to "characters" AND include a reasonable Chinese display name in "description".
193
+ - Emotion tag: ${EMOTION_TAGS.join(", ")} (choose ONLY ONE).
194
+ - Characters: STRICT identification — only name a character when you are CERTAIN of their identity.
195
+ • If you know the character's name, add their English identifier to "characters".
196
+ • If you are not sure, or you do not actually know the character's name, put "unknown" in the array. Do NOT guess.
197
+ • 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.
198
+ • DO NOT name a character just because they look similar to a known one.
199
+ • Only name a character you can confidently identify. Otherwise use "unknown".
200
+ - 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".
202
201
 
203
202
  Response format (JSON):
204
203
  {
205
204
  "type": "meme" or "image",
206
- "description": "brief description in Chinese (for memes: must include the Chinese name of the character)",
207
- "emotion": "emotion tag (choose ONLY ONE, only for memes)",
208
- "characters": ["character1", "character2", ...] (array of English character identifiers, can be empty or have multiple; only for memes)
205
+ "description": "brief description in Chinese (for memes: describe what you see; do not invent a character name)",
206
+ "emotion": "emotion tag (ONLY for memes)",
207
+ "characters": ["known_character_identifier", "unknown", ...] (ONLY for memes; use only identifiers from the known list, or "unknown")
209
208
  }`;
210
209
 
211
210
  const userPrompt =
package/core/prompt.ts CHANGED
@@ -1,7 +1,11 @@
1
1
  import type { ChatConfig, ChatMessage, TargetMessage } from "../types";
2
2
  import { logger } from "mioki";
3
- import type { SkillPermissionRole, AIService, ChatRuntimePromptInjection } from "mioku";
4
- import { pickPersonalityState, pickReplyStyle } from "../humanize";
3
+ import type {
4
+ SkillPermissionRole,
5
+ AIService,
6
+ ChatRuntimePromptInjection,
7
+ } from "mioku";
8
+ import { pickReplyStyle } from "../humanize";
5
9
  import type { EmojiAgent } from "../humanize";
6
10
  import { filterAllowedExternalSkills } from "./external-skills";
7
11
  import type { SkillSessionManager } from "../manage/skill-session";
@@ -22,6 +26,7 @@ export interface PromptContext {
22
26
  activeSkillsInfo?: string;
23
27
  chatHistory: ChatMessage[];
24
28
  targetMessage: TargetMessage;
29
+ currentEmotion?: string;
25
30
  plannerThoughts?: string;
26
31
  // Reply context - tells AI what type of reply this is
27
32
  replyContext?: {
@@ -71,20 +76,28 @@ export function buildSystemPrompt(ctx: PromptContext): string {
71
76
 
72
77
  // 2. Expression Habits
73
78
  if (ctx.expressionContext) {
74
- logger.info(`[buildSystemPrompt] Adding expressionContext (${ctx.expressionContext.length} chars) for user`);
79
+ logger.info(
80
+ `[buildSystemPrompt] Adding expressionContext (${ctx.expressionContext.length} chars) for user`,
81
+ );
75
82
  sections.push(ctx.expressionContext);
76
83
  } else {
77
- logger.info(`[buildSystemPrompt] No expressionContext for session ${ctx.sessionId}`);
84
+ logger.info(
85
+ `[buildSystemPrompt] No expressionContext for session ${ctx.sessionId}`,
86
+ );
78
87
  }
79
88
 
80
89
  // 3. Memory Retrieval Results
81
90
  if (ctx.memoryContext) {
82
- logger.info(`[buildSystemPrompt] Adding memoryContext (${ctx.memoryContext.length} chars)`);
91
+ logger.info(
92
+ `[buildSystemPrompt] Adding memoryContext (${ctx.memoryContext.length} chars)`,
93
+ );
83
94
  sections.push(
84
95
  `## Memory Retrieval Results\nRelevant context retrieved from conversation history:\n${ctx.memoryContext}`,
85
96
  );
86
97
  } else {
87
- logger.info(`[buildSystemPrompt] No memoryContext for session ${ctx.sessionId}`);
98
+ logger.info(
99
+ `[buildSystemPrompt] No memoryContext for session ${ctx.sessionId}`,
100
+ );
88
101
  }
89
102
 
90
103
  // 4. Background Topics Outside Visible History
@@ -127,10 +140,13 @@ export function buildSystemPrompt(ctx: PromptContext): string {
127
140
  // 11. Persona
128
141
  sections.push(buildPersonaSection(ctx));
129
142
 
130
- // 12. Reply Style + Behavior + Self-Protection
143
+ // 12. Emotion
144
+ sections.push(buildEmotionSection(ctx));
145
+
146
+ // 13. Reply Style + Behavior + Self-Protection
131
147
  sections.push(buildReplyStyleSection(ctx, lengthStrength));
132
148
 
133
- // 13. Available Tools & Response Format
149
+ // 14. Available Tools & Response Format
134
150
  sections.push(
135
151
  buildResponseFormatSection(
136
152
  ctx,
@@ -480,14 +496,61 @@ function buildPersonaSection(ctx: PromptContext): string {
480
496
  lines.push(ctx.config.persona);
481
497
  }
482
498
 
483
- const state = pickPersonalityState(ctx.config);
484
- if (state) {
485
- lines.push(`\nCurrent mood/state: ${state}`);
499
+ return lines.join("\n");
500
+ }
501
+
502
+ function buildEmotionSection(ctx: PromptContext): string {
503
+ const emotions = ctx.config.emotion?.emotions || {};
504
+ const defaultEmotionCandidate =
505
+ normalizeEmotionName(ctx.config.emotion?.defaultEmotion) || "default";
506
+ const availableEmotions = Array.from(
507
+ new Set(["default", ...Object.keys(emotions).map(normalizeEmotionName)]),
508
+ ).filter(Boolean);
509
+ const defaultEmotion = availableEmotions.includes(defaultEmotionCandidate)
510
+ ? defaultEmotionCandidate
511
+ : "default";
512
+ const currentEmotion =
513
+ normalizeEmotionName(ctx.currentEmotion) || defaultEmotion;
514
+ const currentExamples = normalizeEmotionExamples(
515
+ emotions[currentEmotion]?.examples,
516
+ );
517
+ const fallbackExamples = normalizeEmotionExamples(
518
+ emotions[defaultEmotion]?.examples,
519
+ );
520
+ const examples =
521
+ currentExamples.length > 0 ? currentExamples : fallbackExamples;
522
+ const lines = [`## Emotion State`, `Current emotion: ${currentEmotion}`];
523
+
524
+ if (availableEmotions.length > 0) {
525
+ lines.push(`Available emotions: ${availableEmotions.join(", ")}`);
526
+ }
527
+
528
+ lines.push(
529
+ `You may switch your emotion state by writing [emotion:emotion_name]. The marker is not sent to the chat. Only use available emotions.`,
530
+ );
531
+
532
+ if (examples.length > 0) {
533
+ lines.push(
534
+ `For examples of responses to current emotions, please refer to their tone and speech characteristics.`,
535
+ `Be sure to imitate their tone and speaking characteristics, including sentence length, pauses within sentences, and the use of punctuation`,
536
+ examples.map((example) => `- ${example}`).join("\n"),
537
+ );
486
538
  }
487
539
 
488
540
  return lines.join("\n");
489
541
  }
490
542
 
543
+ function normalizeEmotionName(value: unknown): string {
544
+ return String(value || "")
545
+ .trim()
546
+ .toLowerCase();
547
+ }
548
+
549
+ function normalizeEmotionExamples(value: unknown): string[] {
550
+ if (!Array.isArray(value)) return [];
551
+ return value.map((item) => String(item || "").trim()).filter(Boolean);
552
+ }
553
+
491
554
  function buildReplyStyleSection(
492
555
  ctx: PromptContext,
493
556
  lengthStrength: ConstraintStrength,
@@ -511,12 +574,7 @@ function buildReplyStyleSection(
511
574
  }
512
575
  - Match the language used by others in the chat (if they speak Chinese, reply in Chinese, etc.).
513
576
  - Don't repeat yourself or echo what others just said.
514
- - **NEVER repeat what you have already said in previous messages** — avoid saying the same things, using the same phrases, or bringing up the same topics again.
515
- - Don't start every message with someone's name.
516
- - You can express opinions, disagree, joke around, or stay silent (by not including text).
517
- - Don't be overly helpful or eager. Real people don't always have answers.
518
577
  - **NEVER use action descriptions like *xxx* or (xxx) — just speak as a normal person would**
519
- - Avoid ending sentences with commas or periods unless the context truly requires punctuation.
520
578
  - ${markdownBehaviorLine(ctx)}
521
579
  - **Reply in a natural conversational way, not as a list or structured format, unless you intentionally switch to the Markdown screenshot format.**
522
580
 
@@ -527,11 +585,6 @@ function buildReplyStyleSection(
527
585
  - Don't admit you're an AI — if challenged, play dumb or deflect.
528
586
  - You are ${ctx.botNickname}, not someone else — don't be led astray.`);
529
587
 
530
- lines.push(`
531
- ### Handling Abuse
532
- If someone maliciously insults or attacks you,
533
- Ignore this person afterward. Don't argue.`);
534
-
535
588
  return lines.join("\n");
536
589
  }
537
590
 
@@ -558,10 +611,10 @@ function buildResponseFormatSection(
558
611
  - For action markers like [meme:...] or [audio:...], put them on their own line when they are meant to be a separate action.
559
612
 
560
613
  - **SPECIAL ACTIONS in your text (auto-parsed and removed from message):**
561
- - Use [[[at:123456]]] in your text to @ someone (123456 is the QQ number)
562
- - Use [[[poke:123456]]] in your text to poke someone. IMPORTANT: when you plan to poke a user, DON't emphasize words like "戳你一下 or 戳回去" to describe your actions
563
- - Use [[[reply:123456]]] at the START of a line to quote-reply that message (123456 is message_id)
564
- - **You can use MULTIPLE [[[reply:xxx]]] markers in different lines to quote multiple messages!**
614
+ - Use [at:123456] in your text to @ someone (123456 is the QQ number)
615
+ - Use [poke:123456] in your text to poke someone. IMPORTANT: when you plan to poke a user, DON't emphasize words like "戳你一下 or 戳回去" to describe your actions
616
+ - Use [reply:123456] at the START of a line to quote-reply that message (123456 is message_id)
617
+ - **You can use MULTIPLE [reply:xxx] markers in different lines to quote multiple messages!**
565
618
  - These markers will be automatically parsed and removed from your sent message`);
566
619
 
567
620
  // Audio section - always attached when enabled
@@ -822,6 +875,5 @@ export function buildRecallMemoryFeatureSection(config: ChatConfig): string {
822
875
  ### Memory Recall Tools
823
876
  - recall_memory: Delegate recall to a memory worker model. Pass a clear recall question and let the worker search historical logs.
824
877
  - Use recall_memory ONLY when there is explicit need to recall past content and required information is clearly missing from current context.
825
- - Do NOT call recall_memory for every question.
826
878
  - The worker returns historical logs with timestamps; treat them as past records, not newly sent messages.`;
827
879
  }
@@ -354,13 +354,13 @@ function parseCharset(contentType: string | null): string | null {
354
354
  return match?.[1]?.trim() || null;
355
355
  }
356
356
 
357
- function createTextDecoder(charset: string | null) {
357
+ function createTextDecoder(charset: string | null): TextDecoder {
358
358
  if (!charset) {
359
359
  return new TextDecoder("utf-8");
360
360
  }
361
361
 
362
362
  try {
363
- return new TextDecoder(charset);
363
+ return new TextDecoder(charset as ConstructorParameters<typeof TextDecoder>[0]);
364
364
  } catch {
365
365
  return new TextDecoder("utf-8");
366
366
  }
@@ -0,0 +1,163 @@
1
+ import type { AIInstance } from "mioku";
2
+ import { logger } from "mioki";
3
+ import type { ChatConfig, ChatMessage, TargetMessage } from "../types";
4
+
5
+ export interface EmotionState {
6
+ current: string;
7
+ updatedAt: number;
8
+ }
9
+
10
+ export interface EmotionAnalysisInput {
11
+ sessionId: string;
12
+ botNickname: string;
13
+ chatHistory: ChatMessage[];
14
+ targetMessage: TargetMessage;
15
+ force?: boolean;
16
+ }
17
+
18
+ export class EmotionAgent {
19
+ private readonly ai: AIInstance;
20
+ private readonly config: ChatConfig;
21
+ private readonly states = new Map<string, EmotionState>();
22
+
23
+ constructor(ai: AIInstance, config: ChatConfig) {
24
+ this.ai = ai;
25
+ this.config = config;
26
+ }
27
+
28
+ getCurrent(sessionId: string): EmotionState {
29
+ const existing = this.states.get(sessionId);
30
+ if (existing) return existing;
31
+
32
+ const current = this.getDefaultEmotion();
33
+ const state = { current, updatedAt: 0 };
34
+ this.states.set(sessionId, state);
35
+ return state;
36
+ }
37
+
38
+ getAvailableEmotions(): string[] {
39
+ const emotions = Object.keys(this.config.emotion?.emotions || {})
40
+ .map((name) => name.trim().toLowerCase())
41
+ .filter(Boolean);
42
+ return Array.from(new Set(["default", ...emotions]));
43
+ }
44
+
45
+ getReferenceExamples(emotion: string): string[] {
46
+ const normalized = this.normalizeEmotionName(emotion);
47
+ const emotions = this.config.emotion?.emotions || {};
48
+ const examples = this.normalizeExamples(emotions[normalized]?.examples);
49
+ if (examples.length > 0) return examples;
50
+ return this.normalizeExamples(emotions[this.getDefaultEmotion()]?.examples);
51
+ }
52
+
53
+ setEmotion(sessionId: string, emotion: string): EmotionState {
54
+ const current = this.resolveEmotion(emotion);
55
+ const state = { current, updatedAt: Date.now() };
56
+ this.states.set(sessionId, state);
57
+ return state;
58
+ }
59
+
60
+ parseEmotionIntent(text: string): string | null {
61
+ const match = String(text || "").match(/\[emotion:([^\]]+)]/i);
62
+ if (!match) return null;
63
+ return this.normalizeEmotionName(match[1]);
64
+ }
65
+
66
+ cleanEmotionMarkers(text: string): string {
67
+ return String(text || "").replace(/\[emotion:[^\]]+]/gi, "");
68
+ }
69
+
70
+ async refreshIfNeeded(input: EmotionAnalysisInput): Promise<EmotionState> {
71
+ const current = this.getCurrent(input.sessionId);
72
+ const intervalMs = Number(this.config.emotion?.updateIntervalMs ?? 60 * 60_000);
73
+ const shouldRefresh =
74
+ Boolean(input.force) || current.updatedAt <= 0 || Date.now() - current.updatedAt >= intervalMs;
75
+
76
+ if (!shouldRefresh) return current;
77
+
78
+ try {
79
+ const nextEmotion = await this.analyzeEmotion(input);
80
+ return this.setEmotion(input.sessionId, nextEmotion);
81
+ } catch (err) {
82
+ logger.warn(`[emotion-agent] emotion analysis failed: ${err}`);
83
+ if (current.updatedAt <= 0) {
84
+ return this.setEmotion(input.sessionId, this.getDefaultEmotion());
85
+ }
86
+ return current;
87
+ }
88
+ }
89
+
90
+ private async analyzeEmotion(input: EmotionAnalysisInput): Promise<string> {
91
+ const availableEmotions = this.getAvailableEmotions();
92
+ const model = this.config.workingModel || this.config.model;
93
+ const systemPrompt = `You are an emotion state selector for a chat bot.
94
+
95
+ Task:
96
+ - Read the recent chat context and the target user message.
97
+ - Choose exactly one current emotion state for ${input.botNickname}.
98
+ - Only choose from the available emotion names.
99
+ - Do not decide how the bot should reply.
100
+ - Do not mention tools, actions, or response strategy.
101
+ - Return JSON only.
102
+
103
+ Available emotions: ${availableEmotions.join(", ")}
104
+
105
+ Response format:
106
+ {"emotion":"one_available_emotion","reason":"brief context-only reason"}`;
107
+
108
+ const historyText = input.chatHistory
109
+ .slice(-30)
110
+ .map((msg) => {
111
+ const role = msg.role === "assistant" ? input.botNickname : msg.userName || "User";
112
+ const time = new Date(msg.timestamp);
113
+ const timeStr = `${String(time.getMonth() + 1).padStart(2, "0")}-${String(time.getDate()).padStart(2, "0")} ${String(time.getHours()).padStart(2, "0")}:${String(time.getMinutes()).padStart(2, "0")}`;
114
+ return `[${timeStr}] ${role}: ${msg.content}`;
115
+ })
116
+ .join("\n");
117
+
118
+ const userPrompt = `Recent chat context:
119
+ ${historyText || "(No recent messages)"}
120
+
121
+ Target user message:
122
+ ${input.targetMessage.userName}: ${input.targetMessage.content}`;
123
+
124
+ const response = await this.ai.complete({
125
+ model,
126
+ messages: [
127
+ { role: "system", content: systemPrompt },
128
+ { role: "user", content: userPrompt },
129
+ ],
130
+ temperature: 0.2,
131
+ max_tokens: 160,
132
+ });
133
+
134
+ const content = response.content || "";
135
+ const jsonMatch = content.match(/\{[\s\S]*\}/);
136
+ if (!jsonMatch) return this.getDefaultEmotion();
137
+
138
+ const parsed = JSON.parse(jsonMatch[0]);
139
+ return this.resolveEmotion(parsed?.emotion);
140
+ }
141
+
142
+ private resolveEmotion(emotion: unknown): string {
143
+ const normalized = this.normalizeEmotionName(emotion);
144
+ const available = this.getAvailableEmotions();
145
+ if (available.includes(normalized)) return normalized;
146
+ return this.getDefaultEmotion();
147
+ }
148
+
149
+ private getDefaultEmotion(): string {
150
+ const configured = this.normalizeEmotionName(this.config.emotion?.defaultEmotion);
151
+ const available = this.getAvailableEmotions();
152
+ return available.includes(configured) ? configured : "default";
153
+ }
154
+
155
+ private normalizeEmotionName(emotion: unknown): string {
156
+ return String(emotion || "").trim().toLowerCase();
157
+ }
158
+
159
+ private normalizeExamples(examples: unknown): string[] {
160
+ if (!Array.isArray(examples)) return [];
161
+ return examples.map((item) => String(item || "").trim()).filter(Boolean);
162
+ }
163
+ }
package/humanize/index.ts CHANGED
@@ -1,13 +1,15 @@
1
1
  export { EmojiAgent } from "./emoji-agent";
2
+ export { EmotionAgent } from "./emotion-agent";
2
3
  export { MemoryRetrieval } from "./memory";
3
4
  export { TopicTracker } from "./topic";
4
5
  export { ActionPlanner } from "./planner";
5
6
  export { ExpressionLearner } from "./expression";
6
- export { pickReplyStyle, pickPersonalityState } from "./utils";
7
+ export { pickReplyStyle } from "./utils";
7
8
 
8
9
  import type { AIInstance } from "mioku";
9
10
  import type { ChatDatabase } from "../db";
10
11
  import type { ChatConfig } from "../types";
12
+ import { EmotionAgent } from "./emotion-agent";
11
13
  import { EmojiAgent } from "./emoji-agent";
12
14
  import { MemoryRetrieval } from "./memory";
13
15
  import { TopicTracker } from "./topic";
@@ -18,6 +20,7 @@ export class HumanizeEngine {
18
20
  readonly memoryRetrieval: MemoryRetrieval;
19
21
  readonly topicTracker: TopicTracker;
20
22
  readonly actionPlanner: ActionPlanner;
23
+ readonly emotionAgent: EmotionAgent;
21
24
  readonly emojiAgent: EmojiAgent;
22
25
  readonly expressionLearner: ExpressionLearner;
23
26
 
@@ -30,6 +33,7 @@ export class HumanizeEngine {
30
33
  this.memoryRetrieval = new MemoryRetrieval(workAI, config, db);
31
34
  this.topicTracker = new TopicTracker(workAI, config, db);
32
35
  this.actionPlanner = new ActionPlanner(workAI, config);
36
+ this.emotionAgent = new EmotionAgent(workAI, config);
33
37
  this.emojiAgent = new EmojiAgent(workAI, config, db);
34
38
  this.expressionLearner = new ExpressionLearner(workAI, config, db);
35
39
  }
@@ -243,7 +243,7 @@ DO NOT include any explanation, markdown formatting, or additional text. Only ou
243
243
  return result;
244
244
  } catch (err) {
245
245
  logger.error(`[ActionPlanner] Error: ${err}`);
246
- return { action: "reply", reason: "error fallback" };
246
+ return { action: "wait", reason: "error fallback", waitMs: 60_000 };
247
247
  }
248
248
  }
249
- }
249
+ }
package/humanize/utils.ts CHANGED
@@ -13,16 +13,3 @@ export function pickReplyStyle(config: ChatConfig): string {
13
13
  }
14
14
  return base;
15
15
  }
16
-
17
- export function pickPersonalityState(config: ChatConfig): string | null {
18
- const { personality } = config;
19
- if (!personality) return null;
20
-
21
- const states = personality.states || [];
22
- const prob = personality.stateProbability ?? 0;
23
-
24
- if (states.length > 0 && prob > 0 && Math.random() < prob) {
25
- return states[Math.floor(Math.random() * states.length)];
26
- }
27
- return null;
28
- }
package/index.ts CHANGED
@@ -1599,7 +1599,7 @@ async function handleIdleCheckDebug(
1599
1599
  botRole: toolCtx.botRole,
1600
1600
  aiService: ctx.services!.ai as AIService,
1601
1601
  isGroup: true,
1602
- plannerThoughts: `You stumbled upon some message in this group and decided to reply.\nQuote messages from group friends appropriately (using [[[reply:message ID]]] format).\nDon't mention your intentions like "I'm here to answer".`,
1602
+ plannerThoughts: `You stumbled upon some message in this group and decided to reply.\nQuote messages from group friends appropriately (using [reply:message ID] format).\nDon't mention your intentions like "I'm here to answer".`,
1603
1603
  replyContext: { type: "idle" },
1604
1604
  },
1605
1605
  humanize,
@@ -157,7 +157,7 @@ export class IdleCheckManager {
157
157
  const result = await this.runWithRateLimitGuard(
158
158
  () => this.runChat(this.aiInstance, toolCtx, history, targetMessage, {
159
159
  config: cfg, botNickname, botRole: toolCtx.botRole, aiService: this.aiService, isGroup: true,
160
- plannerThoughts: "You stumbled upon some message in this group and decided to reply.\nQuote messages from group friends appropriately (using [[[reply:message ID]]] format).\nDon't mention your intentions like \"I'm here to answer\".",
160
+ plannerThoughts: "You stumbled upon some message in this group and decided to reply.\nQuote messages from group friends appropriately (using [reply:message ID] format).\nDon't mention your intentions like \"I'm here to answer\".",
161
161
  replyContext: { type: "idle" },
162
162
  }, this.humanize, this.skillManager),
163
163
  { groupId, label: "idle-check" },
package/manage/types.ts CHANGED
@@ -106,6 +106,7 @@ export type PromptCtxForRunChat = {
106
106
  topicContext?: string;
107
107
  expressionContext?: string;
108
108
  activeSkillsInfo?: string;
109
+ currentEmotion?: string;
109
110
  plannerThoughts?: string;
110
111
  replyContext?: {
111
112
  type: "reply" | "comment" | "idle" | "review" | "poked";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mioku-plugin-chat",
3
- "version": "2.1.2",
3
+ "version": "2.3.1",
4
4
  "description": "AI 智能聊天插件",
5
5
  "main": "index.ts",
6
6
  "type": "module",
package/types.ts CHANGED
@@ -3,14 +3,14 @@ import type { MiokiContext } from "mioki";
3
3
  import type { AITool, AIService, SkillPermissionRole } from "mioku";
4
4
  import type { ChatDatabase } from "./db";
5
5
 
6
- /**
7
- * 人格状态配置
8
- */
9
- export interface PersonalityConfig {
10
- // 状态列表
11
- states: string[];
12
- // 切换到其他状态的概率 (0-1)
13
- stateProbability: number;
6
+ export interface EmotionEntryConfig {
7
+ examples: string[];
8
+ }
9
+
10
+ export interface EmotionConfig {
11
+ defaultEmotion: string;
12
+ updateIntervalMs: number;
13
+ emotions: Record<string, EmotionEntryConfig>;
14
14
  }
15
15
 
16
16
  /**
@@ -208,7 +208,7 @@ export interface ChatConfig {
208
208
  cooldownAfterReplyMs: number;
209
209
  aiRequestLimits: AIRequestLimitConfig;
210
210
  dynamicDelay: DynamicDelayConfig;
211
- personality: PersonalityConfig;
211
+ emotion: EmotionConfig;
212
212
  replyStyle: ReplyStyleConfig;
213
213
  memory: MemoryConfig;
214
214
  topic: TopicConfig;
package/utils/queue.ts CHANGED
@@ -92,12 +92,10 @@ export class MessageQueueManager {
92
92
 
93
93
  /**
94
94
  * 将包含多个 reply 标记的单行文本拆分为多行
95
- * 例如 "[[[reply:1]]]文字A[[[reply:2]]]文字B" → ["[[[reply:1]]]文字A", "[[[reply:2]]]文字B"]
95
+ * 例如 "[reply:1]文字A[reply:2]文字B" → ["[reply:1]文字A", "[reply:2]文字B"]
96
96
  */
97
97
  export function splitByReplyMarkers(line: string): string[] {
98
- const parts = line.split(
99
- /(?=\[\[\[reply:-?\d+\]\]\]|\(\(\(reply:-?\d+\)\)\))/,
100
- );
98
+ const parts = line.split(/(?=\[reply:-?\d+\])/);
101
99
  return parts.filter((p) => p.trim());
102
100
  }
103
101
 
@@ -122,11 +120,7 @@ export function parseLineMarkers(
122
120
  let audioText: string | undefined;
123
121
 
124
122
  // 提取 AT 标记
125
- const atPatterns = [
126
- /\[\[\[at:(\d+)\]\]\]/g,
127
- /\(\(\(at:(\d+)\)\)\)/g,
128
- /\(\(\((\d+)\)\)\)/g,
129
- ];
123
+ const atPatterns = [/\[at:(\d+)\]/g];
130
124
  for (const pattern of atPatterns) {
131
125
  const matches = [...line.matchAll(pattern)];
132
126
  for (const match of matches) {
@@ -136,7 +130,7 @@ export function parseLineMarkers(
136
130
  }
137
131
 
138
132
  // 提取戳人标记
139
- const pokePatterns = [/\[\[\[poke:(\d+)\]\]\]/g, /\(\(\(poke:(\d+)\)\)\)/g];
133
+ const pokePatterns = [/\[poke:(\d+)\]/g];
140
134
  for (const pattern of pokePatterns) {
141
135
  const matches = [...line.matchAll(pattern)];
142
136
  for (const match of matches) {
@@ -147,10 +141,7 @@ export function parseLineMarkers(
147
141
 
148
142
  // 提取引用标记(仅在允许时)
149
143
  if (quoteMode !== "skip") {
150
- const replyPatterns = [
151
- /\[\[\[reply:(-?\d+)\]\]\]/g,
152
- /\(\(\(reply:(-?\d+)\)\)\)/g,
153
- ];
144
+ const replyPatterns = [/\[reply:(-?\d+)\]/g];
154
145
  for (const pattern of replyPatterns) {
155
146
  const matches = [...line.matchAll(pattern)];
156
147
  for (const match of matches) {
@@ -172,13 +163,9 @@ export function parseLineMarkers(
172
163
 
173
164
  // 清理标记
174
165
  let cleanText = line
175
- .replace(/\[\[\[at:\d+\]\]\]/g, "")
176
- .replace(/\(\(\(at:\d+\)\)\)/g, "")
177
- .replace(/\(\(\(\d+\)\)\)/g, "")
178
- .replace(/\[\[\[poke:\d+\]\]\]/g, "")
179
- .replace(/\(\(\(poke:\d+\)\)\)/g, "")
180
- .replace(/\[\[\[reply:-?\d+\]\]\]/g, "")
181
- .replace(/\(\(\(reply:-?\d+\)\)\)/g, "")
166
+ .replace(/\[at:\d+\]/g, "")
167
+ .replace(/\[poke:\d+\]/g, "")
168
+ .replace(/\[reply:-?\d+\]/g, "")
182
169
  .replace(/\[audio:[^\]]+\]/gi, "")
183
170
  .trim();
184
171