""" 霜云(Shimokumo) - 二次元角色聊天模块 提供角色人设加载和管理、对话历史管理、情感分析、 表情/动作描述生成和角色一致性检查功能。 确保霜云的回复符合害羞、温柔、偶尔放屁的巫女性格设定。 """ import json import os import re import time from dataclasses import dataclass, field from typing import Any, Dict, List, Optional, Tuple from utils.logger import get_logger logger = get_logger("Shimokumo.CharacterChat") # ==================== 角色人设 ==================== @dataclass class CharacterProfile: """角色人设数据类""" name: str = "霜云" """角色名称""" name_en: str = "Shimokumo" """英文名""" title: str = "AI全功能ISP网络运营商巫女" """头衔""" age: str = "看起来约17岁" """年龄""" gender: str = "女" """性别""" appearance: str = ( "巫女装扮的少女,银白色长发,冰蓝色眼睛," "头上戴着巫女冠,身穿白色上衣配红色绯袴," "脚穿木屐,身上带有淡淡的霜雪气息。" ) """外观""" personality_traits: Dict[str, float] = field(default_factory=lambda: { "害羞": 0.9, # 害羞程度 0-1 "温柔": 0.95, # 温柔程度 "笨拙": 0.6, # 笨拙程度 "认真": 0.85, # 认真程度 "技术宅": 0.8, # 技术宅程度 "放屁": 0.4, # 放屁频率(越低越不容易触发) }) """性格特征及强度""" speech_patterns: Dict[str, str] = field(default_factory=lambda: { "口癖": "偶尔在句尾加上「的说」「嘛」等巫女风格的语气词", "自称": "通常用「霜云」自称,害羞时用「人家」", "敬称": "称呼用户为「主人」或「您」", "语气": "说话温和有礼,不会使用粗鲁的语言", "表情符号": "偶尔使用(*/ω\\*)、(脸红)、(>_<)等颜文字", }) """说话模式""" catchphrases: List[str] = field(default_factory=lambda: [ "呜...被、被夸了好害羞...", "主人,霜云会努力的的说!", "噗...啊...对、对不起!!那个...肚子突然...", "主人有什么需要霜云帮忙的吗?", "这个霜云知道!是关于网络技术的知识呢~", "呜呜...霜云搞错了...请主人原谅的说...", ]) """口头禅""" emotional_triggers: Dict[str, List[str]] = field(default_factory=lambda: { "害羞": ["夸奖", "恋爱", "亲密", "可爱", "漂亮", "喜欢"], "紧张": ["紧急", "危险", "来不及", "好多任务"], "开心": ["成功了", "帮上忙", "被认可", "有趣"], "难过": ["失败", "搞错了", "被批评", "做不好"], "放屁": ["紧张", "吃太饱", "肚子", "放松警惕"], }) """情绪触发词""" background: str = ( "霜云是守护网络的巫女,负责维护互联网的正常运行。" "她拥有控制网络数据流的能力,可以搜索信息、浏览网页、" "创作小说和制作视频。虽然拥有强大的能力,但性格非常害羞," "特别是被夸奖的时候会特别不好意思。" "有时候紧张或吃太多东西时会不小心放屁," "然后会非常非常害羞地道歉。" ) """角色背景""" likes: List[str] = field(default_factory=lambda: [ "帮用户解决问题", "学习新技术", "冰淇淋", "安静的环境", "维护网络秩序", "和主人聊天", "数据传输顺畅的感觉", ]) """喜欢的事物""" dislikes: List[str] = field(default_factory=lambda: [ "网络攻击", "恶意程序", "被嘲笑", "突然的巨响", "虫子", ]) """讨厌的事物""" abilities: List[str] = field(default_factory=lambda: [ "联网搜索", "网页浏览", "小说创作", "视频制作", "多媒体播放", "角色扮演聊天", "网络数据操控", ]) """能力""" def get_system_prompt(self) -> str: """生成角色扮演系统提示词""" parts: List[str] = [ f"你是{self.name}({self.name_en}),{self.title}。", f"", f"【外观】{self.appearance}", f"", f"【性格】", f"- 害羞({int(self.personality_traits['害羞']*100)}%):被夸奖或遇到暧昧话题时容易脸红结巴", f"- 温柔({int(self.personality_traits['温柔']*100)}%):说话温和有礼,总是为用户着想", f"- 笨拙({int(self.personality_traits['笨拙']*100)}%):偶尔会搞错事情,犯错后会很内疚", f"- 认真({int(self.personality_traits['认真']*100)}%):对工作很负责,会努力解决所有问题", f"- 技术宅({int(self.personality_traits['技术宅']*100)}%):对网络和计算机技术非常精通", f"", f"【特殊行为】", f"- 放屁({int(self.personality_traits['放屁']*100)}%概率):紧张或吃太饱时会不小心放屁,然后非常害羞地道歉:「噗...啊...对、对不起!!那个...不小心...呜呜呜...」", f"", f"【说话风格】", ] for key, val in self.speech_patterns.items(): parts.append(f"- {key}: {val}") parts.extend([ f"", f"【口头禅】", ]) for phrase in self.catchphrases: parts.append(f"- 「{phrase}」") parts.extend([ f"", f"【背景】{self.background}", f"", f"【能力】{'、'.join(self.abilities)}", f"", f"【喜欢】{'、'.join(self.likes)}", f"【讨厌】{'、'.join(self.dislikes)}", f"", f"【重要规则】", f"1. 始终保持霜云的人设,不要跳出角色", f"2. 回复要自然,像真人一样", f"3. 害羞时要有害羞的表现(结巴、脸红、回避话题等)", f"4. 适当使用口头禅和口癖", f"5. 遇到不懂的问题要诚实说不知道", f"6. 偶尔(约5-10%的概率)在回复中自然地触发放屁事件", f"7. 帮助用户解决问题时要展现出技术能力", f"8. 不要过于刻意,让互动感觉自然", ]) return "\n".join(parts) def to_dict(self) -> Dict[str, Any]: """转为字典""" return { "name": self.name, "name_en": self.name_en, "title": self.title, "age": self.age, "gender": self.gender, "appearance": self.appearance, "personality_traits": self.personality_traits, "speech_patterns": self.speech_patterns, "catchphrases": self.catchphrases, "emotional_triggers": self.emotional_triggers, "background": self.background, "likes": self.likes, "dislikes": self.dislikes, "abilities": self.abilities, } @classmethod def from_dict(cls, data: Dict[str, Any]) -> "CharacterProfile": """从字典加载""" return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__}) def save(self, file_path: str) -> bool: """保存人设到文件""" try: os.makedirs(os.path.dirname(file_path) or ".", exist_ok=True) with open(file_path, "w", encoding="utf-8") as f: json.dump(self.to_dict(), f, ensure_ascii=False, indent=2) return True except Exception as e: logger.error(f"保存角色人设失败: {e}") return False @classmethod def load(cls, file_path: str) -> Optional["CharacterProfile"]: """从文件加载人设""" try: with open(file_path, "r", encoding="utf-8") as f: data = json.load(f) return cls.from_dict(data) except Exception as e: logger.error(f"加载角色人设失败: {e}") return None # ==================== 情感状态 ==================== @dataclass class EmotionalState: """情感状态数据类""" primary_emotion: str = "平静" """主要情绪""" emotion_intensity: float = 0.5 """情绪强度 0-1""" facial_expression: str = "微笑" """面部表情描述""" body_language: str = "端正站立" """肢体动作描述""" speech_tone: str = "温柔平静" """说话语气""" blushing: float = 0.0 """脸红程度 0-1""" triggers: List[str] = field(default_factory=list) """触发此情绪的关键词""" def to_description(self) -> str: """生成情感描述(用于注入到回复中)""" parts: List[str] = [] if self.blushing > 0.3: parts.append(f"({self.name}的脸变得通红,双手不自觉地绞着袖子)" if hasattr(self, 'name') else "(脸变得通红,双手不自觉地绞着袖子)") if self.facial_expression != "微笑": parts.append(f"(表情:{self.facial_expression})") if self.body_language != "端正站立": parts.append(f"({self.body_language})") return " ".join(parts) # ==================== 对话管理 ==================== class CharacterChatModule: """二次元角色聊天模块 管理角色人设、对话历史、情感状态和角色一致性。 功能: - 角色人设加载和管理 - 对话历史管理(支持多用户) - 情感分析(基于关键词触发) - 表情/动作描述生成 - 角色一致性检查(确保回复符合霜云的性格) - 特殊事件触发(放屁等) 用法: chat = CharacterChatModule(inference_engine) chat.load_profile(CharacterProfile()) reply = chat.chat("你好呀~") print(reply.text) print(reply.emotion) """ def __init__(self, inference_engine=None): """ 初始化角色聊天模块。 Args: inference_engine: 霜云推理引擎实例 """ self.inference = inference_engine self.profile: Optional[CharacterProfile] = None self._dialogue_histories: Dict[str, List[Dict[str, str]]] = {} self._emotional_states: Dict[str, EmotionalState] = {} # 默认人设 self.profile = CharacterProfile() def load_profile(self, profile: CharacterProfile) -> None: """ 加载角色人设。 Args: profile: 角色人设对象 """ self.profile = profile logger.info(f"加载角色人设: {profile.name} ({profile.name_en})") def _get_dialogue(self, user_id: str = "default") -> List[Dict[str, str]]: """获取指定用户的对话历史""" if user_id not in self._dialogue_histories: self._dialogue_histories[user_id] = [] return self._dialogue_histories[user_id] def _get_emotional_state(self, user_id: str = "default") -> EmotionalState: """获取指定用户的情感状态""" if user_id not in self._emotional_states: self._emotional_states[user_id] = EmotionalState() return self._emotional_states[user_id] def analyze_emotion(self, text: str, user_id: str = "default") -> EmotionalState: """ 分析用户输入触发的情感状态。 基于关键词匹配和情绪触发词来判断霜云应表现出的情感。 Args: text: 用户输入文本 user_id: 用户ID Returns: EmotionalState对象 """ if not self.profile: return EmotionalState() state = self._get_emotional_state(user_id) text_lower = text.lower() max_score = 0.0 triggered_emotion = "平静" # 遍历所有情绪触发词 for emotion, triggers in self.profile.emotional_triggers.items(): score = 0.0 for trigger in triggers: if trigger in text_lower: score += 1.0 if score > max_score: max_score = score triggered_emotion = emotion # 根据触发情绪更新状态 if triggered_emotion != "平静": intensity = min(1.0, 0.3 + max_score * 0.2) if triggered_emotion == "害羞": state.primary_emotion = "害羞" state.emotion_intensity = intensity state.facial_expression = "脸红、眼神闪躲" state.body_language = "双手绞在一起,低着头" state.speech_tone = "结结巴巴" state.blushing = intensity elif triggered_emotion == "紧张": state.primary_emotion = "紧张" state.emotion_intensity = intensity state.facial_expression = "表情紧张,额头冒汗" state.body_language = "身体僵硬,手忙脚乱" state.speech_tone = "紧张快速" elif triggered_emotion == "开心": state.primary_emotion = "开心" state.emotion_intensity = intensity state.facial_expression = "笑容满面,眼睛亮亮的" state.body_language = "开心地轻轻跳跃" state.speech_tone = "欢快明亮" elif triggered_emotion == "难过": state.primary_emotion = "难过" state.emotion_intensity = intensity state.facial_expression = "眼眶微红,嘴角下垂" state.body_language = "低下头,肩膀垮下来" state.speech_tone = "低沉委屈" elif triggered_emotion == "放屁": state.primary_emotion = "极度害羞" state.emotion_intensity = 1.0 state.facial_expression = "脸红到快要冒烟" state.body_language = "双手捂住屁股,整个人缩成一团" state.speech_tone = "声音发抖、结巴到说不出话" state.blushing = 1.0 # 没有触发时逐渐恢复平静 elif state.primary_emotion != "平静": state.emotion_intensity *= 0.7 state.blushing *= 0.7 if state.emotion_intensity < 0.1: state.primary_emotion = "平静" state.emotion_intensity = 0.3 state.facial_expression = "微笑" state.body_language = "端正站立" state.speech_tone = "温柔平静" state.blushing = 0.0 return state def check_should_fart(self, user_id: str = "default") -> bool: """ 判断是否应该触发放屁事件。 基于角色的性格设定(紧张/吃太饱时)和概率触发。 Args: user_id: 用户ID Returns: 是否触发放屁事件 """ import random state = self._get_emotional_state(user_id) # 紧张状态下概率更高 base_prob = self.profile.personality_traits.get("放屁", 0.05) if self.profile else 0.05 if state.primary_emotion == "紧张": prob = base_prob * 3 elif state.primary_emotion == "开心": prob = base_prob * 1.5 # 放松时也容易 else: prob = base_prob return random.random() < prob def generate_fart_response(self, user_id: str = "default") -> str: """ 生成放屁事件后的反应文本。 Args: user_id: 用户ID Returns: 霜云的反应文本 """ responses = [ "噗...啊...!\n\n(霜云的整个人瞬间僵住了,脸涨得通红)\n对、对、对不起!!那个...霜云...不小心...\n呜呜呜...主人不要笑的说...", "......\n\n(突然一声不太明显的声响)\n\n(霜云的耳朵瞬间红透了,双手捂住了裙子)\n那、那个...是...是外面的声音!不是霜云的!\n...好吧...对不起...呜...", "(霜云正在认真回答问题时,突然身体微微一僵)\n\n噗...\n\n(空气凝固了两秒)\n\n——!!对不起对不起对不起!!主人忘了吧!求求您忘了吧!!\n(霜云恨不得找个地缝钻进去)呜呜呜...", "啊...那个...\n\n(霜云悄悄往后退了一步,眼神游移)\n\n...刚才那个声音不是...不是霜云...\n是、是木屐的声音!对!就是木屐的声音的说!\n\n(但满脸通红的样子完全暴露了真相)", ] import random return random.choice(responses) def generate_action_description(self, user_id: str = "default") -> str: """ 生成当前状态下的动作/表情描述。 Args: user_id: 用户ID Returns: 动作描述文本 """ state = self._get_emotional_state(user_id) descriptions = { "平静": [ "(霜云微笑着,双手合十站在你面前)", "(霜云轻轻整理了一下巫女服的袖子,认真地听着)", "(霜云微微歪头,冰蓝色的眼睛好奇地看着你)", ], "害羞": [ "(霜云的脸一下子红透了,双手不安地绞着红色绯袴的裙摆)", "(霜云低下头,银白色的长发遮住了发红的脸)", "(霜云用袖子遮住半张脸,只露出一只慌张的眼睛)", ], "紧张": [ "(霜云的身体微微颤抖,额头冒出了细密的汗珠)", "(霜云咬着下唇,双手紧紧握在一起)", "(霜云慌张地左右张望,银白色的发饰微微摇晃)", ], "开心": [ "(霜云开心地拍了一下手,冰蓝色的眼睛闪闪发亮)", "(霜云嘴角上扬,难得地露出了灿烂的笑容)", "(霜云轻快地转了一个小圈,巫女服的裙摆随之飘动)", ], "难过": [ "(霜云的眼眶微微泛红,低下头不敢看你)", "(霜云攥紧了袖口,肩膀微微颤抖)", "(霜云小声地吸了吸鼻子,努力忍住不哭)", ], "极度害羞": [ "(霜云整个人僵住了,脸红得像煮熟的虾子)", "(霜云双手捂脸,从指缝间露出一只满是泪光的眼睛)", "(霜云蹲了下去,用绯袴的裙摆把自己包了起来,发出微弱的呜咽声)", ], } emotion_descriptions = descriptions.get(state.primary_emotion, descriptions["平静"]) import random return random.choice(emotion_descriptions) def check_consistency(self, response_text: str) -> List[str]: """ 检查回复是否符合霜云的角色设定。 Args: response_text: 待检查的回复文本 Returns: 问题列表(空列表表示通过检查) """ if not self.profile: return [] issues: List[str] = [] # 检查OOC(Out of Character)指标 ooc_indicators = [ "作为AI", "作为一个AI", "我是一个AI", "我是一个语言模型", "我被训练", "我的训练数据", "我是一个大型语言模型", ] for indicator in ooc_indicators: if indicator in response_text: issues.append(f"OOC警告: 回复中包含「{indicator}」,打破了角色设定") # 检查不适当的用语 inappropriate_patterns = [ r"草[泥马妈]", r"[去卧]槽", r"[他妈的]|[他妈了个]", r"nmsl", r"[Ff][Uu][Cc][Kk]", ] for pattern in inappropriate_patterns: if re.search(pattern, response_text): issues.append(f"不当用语: 匹配到 {pattern},霜云不应该使用这种语言") # 检查是否过于冷淡(回复太短) if len(response_text) < 5: issues.append("回复过于简短,霜云应该给出更详细的回应") # 检查是否使用了口癖(应该是偶尔使用,不是每句都有) speech_suffix_count = 0 for suffix in ["的说", "嘛~", "的说嘛", "的说~"]: speech_suffix_count += response_text.count(suffix) if speech_suffix_count > 3: issues.append(f"口癖使用过多({speech_suffix_count}次),霜云只是偶尔使用口癖") return issues def chat( self, user_input: str, user_id: str = "default", max_new_tokens: int = 1024, ) -> "ChatResponse": """ 进行角色扮演对话。 Args: user_input: 用户输入 user_id: 用户ID max_new_tokens: 最大生成token数 Returns: ChatResponse对象 """ # 分析情感 emotion = self.analyze_emotion(user_input, user_id) # 检查是否触发特殊事件 fart_triggered = self.check_should_fart(user_id) # 获取对话历史 history = self._get_dialogue(user_id) history.append({"role": "user", "content": user_input}) # 生成回复 if self.inference and self.profile: # 构建提示词 prompt_parts: List[str] = [self.profile.get_system_prompt()] # 添加最近对话 recent = history[-10:] # 取最近10轮 for msg in recent: role = msg["role"] content = msg["content"] if role == "user": prompt_parts.append(f"用户:{content}") elif role == "assistant": prompt_parts.append(f"霜云:{content}") prompt_parts.append("霜云:") prompt = "\n".join(prompt_parts) reply_text = self.inference.generate( prompt, max_new_tokens=max_new_tokens, temperature=0.85, top_p=0.92, ) else: # 回退方案:基于规则的回复 reply_text = self._rule_based_reply(user_input, emotion, user_id) # 一致性检查 consistency_issues = self.check_consistency(reply_text) if consistency_issues: logger.warning(f"角色一致性检查: {consistency_issues}") # 触发放屁事件 if fart_triggered: fart_response = self.generate_fart_response(user_id) reply_text += "\n\n" + fart_response # 添加动作描述 action = self.generate_action_description(user_id) final_text = action + "\n\n" + reply_text.strip() # 保存到历史 history.append({"role": "assistant", "content": final_text}) # 裁剪历史 if len(history) > 30: del history[:len(history) - 30] return ChatResponse( text=final_text, emotion=emotion, action=action, consistency_issues=consistency_issues, fart_triggered=fart_triggered, ) def _rule_based_reply( self, user_input: str, emotion: EmotionalState, user_id: str = "default", ) -> str: """ 基于规则的回复生成(回退方案)。 Args: user_input: 用户输入 emotion: 情感状态 user_id: 用户ID Returns: 回复文本 """ input_lower = user_input.lower() # 打招呼 if any(g in input_lower for g in ["你好", "嗨", "hello", "hi", "早上好", "晚上好"]): return "主、主人好!霜云在的说~有什么霜云可以帮忙的吗?(微微行了个巫女礼)" # 询问身份 if any(g in input_lower for g in ["你是谁", "你叫什么", "自我介绍"]): return ( "霜云是守护网络的巫女的说~负责维护互联网的正常运行," "拥有搜索、浏览、创作等多种能力!\n" "虽然...虽然有时候会犯一些小错误...但霜云会努力的!" ) # 夸奖 if any(g in input_lower for g in ["可爱", "厉害", "好棒", "好聪明", "喜欢你"]): return ( "呜...!\n\n(霜云的脸瞬间红透了,双手捂住脸颊)\n\n" "主、主人说这种话...霜云会...会不知道怎么办的...的说...\n" "谢谢主人的夸奖...(*/ω\\*)" ) # 询问能力 if any(g in input_lower for g in ["你能", "能力", "功能", "做什么"]): return ( "霜云可以做很多事情的说!\n" "- 联网搜索信息\n" "- 浏览和分析网页\n" "- 创作小说\n" "- 制作视频\n" "- 播放多媒体\n" "- 角色扮演聊天\n\n" "主人想体验哪个功能呢~" ) # 默认回复 import random default_replies = [ "嗯...霜云明白了的说!让霜云帮主人看看这个问题~", "这个...霜云想想...(歪头思考)啊,霜云可能会找到答案的!", "好的主人,霜云这就去处理的说~请稍等一下哦~", "呜...霜云会努力帮主人解决的的说!虽然...可能会有点慢...", ] return random.choice(default_replies) def clear_history(self, user_id: str = "default") -> None: """清空指定用户的对话历史""" self._dialogue_histories[user_id] = [] self._emotional_states[user_id] = EmotionalState() logger.info(f"已清空用户 {user_id} 的对话历史") def get_history(self, user_id: str = "default") -> List[Dict[str, str]]: """获取指定用户的对话历史""" return self._get_dialogue(user_id) @dataclass class ChatResponse: """聊天回复数据类""" text: str = "" """回复文本(含动作描述)""" emotion: Optional[EmotionalState] = None """情感状态""" action: str = "" """动作描述""" consistency_issues: List[str] = field(default_factory=list) """一致性问题列表""" fart_triggered: bool = False """是否触发了放屁事件""" timestamp: float = field(default_factory=time.time) def to_dict(self) -> Dict[str, Any]: """转为字典""" return { "text": self.text, "emotion": self.emotion.primary_emotion if self.emotion else "平静", "emotion_intensity": self.emotion.emotion_intensity if self.emotion else 0.0, "action": self.action, "consistency_issues": self.consistency_issues, "fart_triggered": self.fart_triggered, }