From 0746a73bceadc40b6e6e7e2ac9fd44e34a154cf6 Mon Sep 17 00:00:00 2001 From: tt-P607 <68868379+tt-P607@users.noreply.github.com> Date: Sat, 29 Nov 2025 02:05:56 +0800 Subject: [PATCH 01/28] =?UTF-8?q?feat(KFC):=20=F0=9F=8E=89=20Kokoro=20Flow?= =?UTF-8?q?=20Chatter=20=E5=BF=83=E6=B5=81=E8=81=8A=E5=A4=A9=E5=99=A8=20-?= =?UTF-8?q?=20=E7=A7=81=E8=81=8A=E4=B8=93=E5=B1=9E=E5=A4=84=E7=90=86?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=E4=BB=8E=E9=9B=B6=E6=9E=84=E5=BB=BA=E5=AE=8C?= =?UTF-8?q?=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 这是一个全新的私聊聊天处理器,专为深度情感交互设计,从架构设计到代码实现全部从零完成。 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 🏗️ 核心架构 (7个核心模块) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 📁 src/plugins/built_in/kokoro_flow_chatter/ ├── chatter.py # 主处理器 - 协调所有组件的核心类 ├── context_builder.py # S4U上下文构建器 - 超融合上下文系统 ├── prompt_generator.py # V6三明治提示词生成器 ├── action_executor.py # 动作执行器 - 解析+执行LLM动作 ├── response_post_processor.py # 回复后处理器 - 分割+错别字 ├── models.py # 数据模型 - Session/情感状态/心理日志 ├── session_manager.py # 会话管理器 - 用户状态持久化 ├── scheduler.py # 调度器 - 主动思考/超时处理 ├── config.py # 配置类 └── plugin.py # 插件注册入口 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ✨ 核心特性 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 【V1-V3 基础框架】 - 心理状态驱动的交互模型 (KokoroSession) - 连续时间观念和等待体验 (IDLE→RESPONDING→WAITING状态机) - 心理日志系统 (MentalLogEntry) - 动态情感状态 (EmotionalState) 【V4 动作系统集成】 - 动态动作发现 (复用ChatterActionManager) - 支持所有AFC动作 (reply/emoji/poke_user/set_emoji_like等) - LLM响应JSON解析和验证 【V5 超融合上下文】 - S4U用户中心上下文检索 - 三层记忆系统集成 (感知/短期/长期) - 时间感知块 (时间段+日程+情境) - 人物关系信息注入 - 跨聊天上下文共享 【V6 最终优化】 - 三明治提示词结构 (系统层→上下文层→指令层) - ActionModifier动作筛选器集成 (三阶段预筛选) - 阶段0: 聊天类型过滤 - 阶段2: 关联类型匹配 - 阶段3: go_activate()激活判定 - 回复分割器复用AFC核心逻辑 (split_into_sentences_w_remove_punctuation) - 修复model配置 (使用replyer而非utils) - 修复context_builder异步问题 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 🔧 技术细节 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 提示词结构 (V6三明治): ┌─────────────────────────────────────┐ │ 🍞 系统层 (人设/身份/表达风格) │ ├─────────────────────────────────────┤ │ 🥬 上下文层 │ │ ├─ 时间感知块 │ │ ├─ 三层记忆 (感知+短期+长期) │ │ ├─ 人物关系 │ │ ├─ 对话历史 │ │ └─ 用户最新消息 │ ├─────────────────────────────────────┤ │ 🍞 指令层 (JSON输出格式/可用动作) │ └─────────────────────────────────────┘ 动作筛选效果: 13个动作 → 约5-7个 (节省token+提升决策质量) 回复分割: 长消息自动按标点分割成多条发送 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 📝 配置项 (bot_config.toml) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ [kokoro_flow_chatter] enable = true max_wait_seconds_default = 300 enable_continuous_thinking = true [kokoro_flow_chatter.proactive_thinking] enabled = true silence_threshold_seconds = 7200 min_affinity_for_proactive = 0.3 min_interval_between_proactive = 1800 enable_morning_greeting = true enable_night_greeting = true ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 🎯 设计理念 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ KFC不是独立人格,而是: - 复用全局人设、情感框架和回复模型 - 专注于"体验→决策→行动"的私聊交互模式 - 从"消息响应者"转变为"对话体验者" - 深度情感连接和长期关系维护 --- src/common/logger.py | 16 + src/config/config.py | 4 + src/config/official_configs.py | 68 ++ .../built_in/kokoro_flow_chatter/__init__.py | 26 + .../kokoro_flow_chatter/action_executor.py | 795 +++++++++++++++++ .../built_in/kokoro_flow_chatter/chatter.py | 606 +++++++++++++ .../built_in/kokoro_flow_chatter/config.py | 251 ++++++ .../kokoro_flow_chatter/context_builder.py | 528 ++++++++++++ .../built_in/kokoro_flow_chatter/models.py | 442 ++++++++++ .../built_in/kokoro_flow_chatter/plugin.py | 218 +++++ .../kokoro_flow_chatter/proactive_thinking.py | 528 ++++++++++++ .../kokoro_flow_chatter/prompt_generator.py | 801 ++++++++++++++++++ .../response_post_processor.py | 169 ++++ .../built_in/kokoro_flow_chatter/scheduler.py | 424 +++++++++ .../kokoro_flow_chatter/session_manager.py | 490 +++++++++++ template/bot_config_template.toml | 74 +- 16 files changed, 5415 insertions(+), 25 deletions(-) create mode 100644 src/plugins/built_in/kokoro_flow_chatter/__init__.py create mode 100644 src/plugins/built_in/kokoro_flow_chatter/action_executor.py create mode 100644 src/plugins/built_in/kokoro_flow_chatter/chatter.py create mode 100644 src/plugins/built_in/kokoro_flow_chatter/config.py create mode 100644 src/plugins/built_in/kokoro_flow_chatter/context_builder.py create mode 100644 src/plugins/built_in/kokoro_flow_chatter/models.py create mode 100644 src/plugins/built_in/kokoro_flow_chatter/plugin.py create mode 100644 src/plugins/built_in/kokoro_flow_chatter/proactive_thinking.py create mode 100644 src/plugins/built_in/kokoro_flow_chatter/prompt_generator.py create mode 100644 src/plugins/built_in/kokoro_flow_chatter/response_post_processor.py create mode 100644 src/plugins/built_in/kokoro_flow_chatter/scheduler.py create mode 100644 src/plugins/built_in/kokoro_flow_chatter/session_manager.py diff --git a/src/common/logger.py b/src/common/logger.py index 98b8a2733..a806cbe80 100644 --- a/src/common/logger.py +++ b/src/common/logger.py @@ -674,6 +674,14 @@ DEFAULT_MODULE_COLORS = { "AioHTTP-Gemini客户端": "#5FD7FF", "napcat_adapter": "#5F87AF", # 柔和的灰蓝色,不刺眼且低调 "event_manager": "#5FD7AF", # 柔和的蓝绿色,稍微醒目但不刺眼 + # Kokoro Flow Chatter (KFC) 相关 - 超融合架构专用颜色 + "kokoro_flow_chatter": "#FF5FAF", # 粉紫色 - 主聊天器 + "kokoro_prompt_generator": "#00D7FF", # 青色 - Prompt构建 + "kokoro_action_executor": "#FFFF00", # 黄色 - 动作解析与执行 + "kfc_context_builder": "#5FD7FF", # 蓝色 - 上下文构建 + "kfc_session_manager": "#87D787", # 绿色 - 会话管理 + "kfc_scheduler": "#D787AF", # 柔和粉色 - 调度器 + "kfc_post_processor": "#5F87FF", # 蓝色 - 后处理 } DEFAULT_MODULE_ALIASES = { @@ -802,6 +810,14 @@ DEFAULT_MODULE_ALIASES = { "db_migration": "数据库迁移", "小彩蛋": "小彩蛋", "AioHTTP-Gemini客户端": "AioHTTP-Gemini客户端", + # Kokoro Flow Chatter (KFC) 超融合架构相关 + "kokoro_flow_chatter": "心流聊天", + "kokoro_prompt_generator": "KFC提示词", + "kokoro_action_executor": "KFC动作", + "kfc_context_builder": "KFC上下文", + "kfc_session_manager": "KFC会话", + "kfc_scheduler": "KFC调度", + "kfc_post_processor": "KFC后处理", } diff --git a/src/config/config.py b/src/config/config.py index 13f352ed3..60984ab92 100644 --- a/src/config/config.py +++ b/src/config/config.py @@ -25,6 +25,7 @@ from src.config.official_configs import ( EmojiConfig, ExperimentalConfig, ExpressionConfig, + KokoroFlowChatterConfig, LPMMKnowledgeConfig, MessageBusConfig, MemoryConfig, @@ -425,6 +426,9 @@ class Config(ValidatedConfigBase): proactive_thinking: ProactiveThinkingConfig = Field( default_factory=lambda: ProactiveThinkingConfig(), description="主动思考配置" ) + kokoro_flow_chatter: KokoroFlowChatterConfig = Field( + default_factory=lambda: KokoroFlowChatterConfig(), description="心流对话系统配置(私聊专用)" + ) plugin_http_system: PluginHttpSystemConfig = Field( default_factory=lambda: PluginHttpSystemConfig(), description="插件HTTP端点系统配置" ) diff --git a/src/config/official_configs.py b/src/config/official_configs.py index 795d5751d..b3c3cfafb 100644 --- a/src/config/official_configs.py +++ b/src/config/official_configs.py @@ -911,3 +911,71 @@ class ProactiveThinkingConfig(ValidatedConfigBase): # --- 新增:调试与监控 --- enable_statistics: bool = Field(default=True, description="是否启用统计功能(记录触发次数、决策分布等)") log_decisions: bool = Field(default=False, description="是否记录每次决策的详细日志(用于调试)") + + +class KokoroFlowChatterProactiveConfig(ValidatedConfigBase): + """ + Kokoro Flow Chatter 主动思考子配置 + + 设计哲学:主动行为源于内部状态和外部环境的自然反应,而非机械的限制。 + 她的主动是因为挂念、因为关心、因为想问候,而不是因为"任务"。 + """ + enabled: bool = Field(default=True, description="是否启用KFC的私聊主动思考") + + # 1. 沉默触发器:当感到长久的沉默时,她可能会想说些什么 + silence_threshold_seconds: int = Field( + default=7200, ge=60, le=86400, + description="用户沉默超过此时长(秒),可能触发主动思考(默认2小时)" + ) + + # 2. 关系门槛:她不会对不熟悉的人过于主动 + min_affinity_for_proactive: float = Field( + default=0.3, ge=0.0, le=1.0, + description="需要达到最低好感度,她才会开始主动关心" + ) + + # 3. 频率呼吸:为了避免打扰,她的关心总是有间隔的 + min_interval_between_proactive: int = Field( + default=1800, ge=0, + description="两次主动思考之间的最小间隔(秒,默认30分钟)" + ) + + # 4. 自然问候:在特定的时间,她会像朋友一样送上问候 + enable_morning_greeting: bool = Field( + default=True, description="是否启用早安问候 (例如: 8:00 - 9:00)" + ) + enable_night_greeting: bool = Field( + default=True, description="是否启用晚安问候 (例如: 22:00 - 23:00)" + ) + + +class KokoroFlowChatterConfig(ValidatedConfigBase): + """ + Kokoro Flow Chatter 配置类 - 私聊专用心流对话系统 + + 设计理念:KFC不是独立人格,它复用全局的人设、情感框架和回复模型, + 只作为Bot核心人格在私聊中的一种特殊表现模式。 + """ + + # --- 总开关 --- + enable: bool = Field( + default=True, + description="开启后KFC将接管所有私聊消息;关闭后私聊消息将由AFC处理" + ) + + # --- 核心行为配置 --- + max_wait_seconds_default: int = Field( + default=300, ge=30, le=3600, + description="默认的最大等待秒数(AI发送消息后愿意等待用户回复的时间)" + ) + enable_continuous_thinking: bool = Field( + default=True, + description="是否在等待期间启用心理活动更新" + ) + + # --- 私聊专属主动思考配置 --- + proactive_thinking: KokoroFlowChatterProactiveConfig = Field( + default_factory=KokoroFlowChatterProactiveConfig, + description="私聊专属主动思考配置" + ) + diff --git a/src/plugins/built_in/kokoro_flow_chatter/__init__.py b/src/plugins/built_in/kokoro_flow_chatter/__init__.py new file mode 100644 index 000000000..440ea0f49 --- /dev/null +++ b/src/plugins/built_in/kokoro_flow_chatter/__init__.py @@ -0,0 +1,26 @@ +""" +Kokoro Flow Chatter (心流聊天器) 插件 + +一个专为私聊场景设计的AI聊天插件,实现从"消息响应者"到"对话体验者"的转变。 +核心特点: +- 心理状态驱动的交互模型 +- 连续的时间观念和等待体验 +- 深度情感连接和长期关系维护 +""" + +from src.plugin_system.base.plugin_metadata import PluginMetadata + +from .plugin import KokoroFlowChatterPlugin + +__plugin_meta__ = PluginMetadata( + name="Kokoro Flow Chatter", + description="专为私聊设计的深度情感交互处理器,实现心理状态驱动的对话体验", + usage="在私聊场景中自动启用,可通过 [kokoro_flow_chatter].enable 配置开关", + version="3.0.0", + author="MoFox", + keywords=["chatter", "kokoro", "private", "emotional", "narrative"], + categories=["Chat", "AI", "Emotional"], + extra={"is_built_in": True, "chat_type": "private"}, +) + +__all__ = ["KokoroFlowChatterPlugin", "__plugin_meta__"] diff --git a/src/plugins/built_in/kokoro_flow_chatter/action_executor.py b/src/plugins/built_in/kokoro_flow_chatter/action_executor.py new file mode 100644 index 000000000..69f250ae6 --- /dev/null +++ b/src/plugins/built_in/kokoro_flow_chatter/action_executor.py @@ -0,0 +1,795 @@ +""" +Kokoro Flow Chatter 动作执行器 (V2) + +融合AFC的动态动作发现机制,支持所有注册的Action组件。 +负责解析LLM返回的动作列表并通过ChatterActionManager执行。 + +V2升级要点: +1. 动态动作支持 - 使用ActionManager发现所有可用动作 +2. 统一执行接口 - 通过ChatterActionManager.execute_action()执行所有动作 +3. 保留KFC特有功能 - 内部状态更新、心理日志等 +4. 支持复合动作 - 如 sing_a_song + image_sender + tts_voice_action + +V5升级要点: +1. 动态情感更新 - 根据thought字段的情感倾向微调EmotionalState +""" + +import asyncio +import json +import re +import time +from typing import TYPE_CHECKING, Any, Optional + +from src.chat.planner_actions.action_manager import ChatterActionManager +from src.common.logger import get_logger +from src.plugin_system.base.component_types import ActionInfo + +from .models import ( + ActionModel, + EmotionalState, + KokoroSession, + LLMResponseModel, + MentalLogEntry, + MentalLogEventType, +) + +if TYPE_CHECKING: + from src.chat.message_receive.chat_stream import ChatStream + +logger = get_logger("kokoro_action_executor") + +# ========== 情感分析关键词映射 ========== +# 正面情感关键词(提升mood_intensity和engagement_level) +POSITIVE_KEYWORDS = [ + "开心", "高兴", "快乐", "喜欢", "爱", "好奇", "期待", "惊喜", + "感动", "温暖", "舒服", "放松", "满足", "欣慰", "感激", "兴奋", + "有趣", "好玩", "可爱", "太棒了", "哈哈", "嘻嘻", "hiahia", +] + +# 负面情感关键词(降低mood_intensity,可能提升anxiety_level) +NEGATIVE_KEYWORDS = [ + "难过", "伤心", "失望", "沮丧", "担心", "焦虑", "害怕", "紧张", + "生气", "烦躁", "无聊", "疲惫", "累", "困", "不开心", "不高兴", + "委屈", "尴尬", "迷茫", "困惑", "郁闷", +] + +# 亲密关键词(提升relationship_warmth) +INTIMATE_KEYWORDS = [ + "喜欢你", "想你", "想念", "在乎", "关心", "信任", "依赖", + "亲爱的", "宝贝", "朋友", "陪伴", "一起", "我们", +] + +# 疏远关键词(降低relationship_warmth) +DISTANT_KEYWORDS = [ + "讨厌", "烦人", "无聊", "不想理", "走开", "别烦", + "算了", "随便", "不在乎", +] + + +class ActionExecutor: + """ + Kokoro Flow Chatter 动作执行器 (V2) + + 职责: + 1. 解析LLM返回的JSON响应 + 2. 动态验证动作格式和参数(基于ActionManager的动作注册) + 3. 通过ChatterActionManager执行各类动作 + 4. 处理KFC特有的内部状态更新 + 5. 记录执行结果到心理日志 + + V2特性: + - 支持所有通过插件系统注册的Action + - 自动从ActionManager获取可用动作列表 + - 支持复合动作组合执行 + - 区分"回复类动作"和"其他动作"的执行顺序 + """ + + # KFC内置的特殊动作(不通过ActionManager执行) + INTERNAL_ACTIONS = { + "update_internal_state": { + "required": [], + "optional": ["mood", "mood_intensity", "relationship_warmth", "impression_of_user", "anxiety_level", "engagement_level"] + }, + "do_nothing": {"required": [], "optional": []}, + } + + def __init__(self, stream_id: str): + """ + 初始化动作执行器 + + Args: + stream_id: 聊天流ID + """ + self.stream_id = stream_id + self._action_manager = ChatterActionManager() + self._available_actions: dict[str, ActionInfo] = {} + self._execution_stats = { + "total_executed": 0, + "successful": 0, + "failed": 0, + "by_type": {}, + } + + async def load_actions(self) -> dict[str, ActionInfo]: + """ + 加载当前可用的动作列表 + + Returns: + dict[str, ActionInfo]: 可用动作字典 + """ + await self._action_manager.load_actions(self.stream_id) + self._available_actions = self._action_manager.get_using_actions() + logger.debug(f"KFC ActionExecutor 加载了 {len(self._available_actions)} 个可用动作: {list(self._available_actions.keys())}") + return self._available_actions + + def get_available_actions(self) -> dict[str, ActionInfo]: + """获取当前可用的动作列表""" + return self._available_actions.copy() + + def is_action_available(self, action_type: str) -> bool: + """ + 检查动作是否可用 + + Args: + action_type: 动作类型名称 + + Returns: + bool: 动作是否可用 + """ + # 内置动作总是可用 + if action_type in self.INTERNAL_ACTIONS: + return True + # 检查动态注册的动作 + return action_type in self._available_actions + + def parse_llm_response(self, response_text: str) -> LLMResponseModel: + """ + 解析LLM的JSON响应 + + Args: + response_text: LLM返回的原始文本 + + Returns: + LLMResponseModel: 解析后的响应模型 + + Raises: + ValueError: 如果解析失败 + """ + # 尝试提取JSON块 + json_str = self._extract_json(response_text) + + if not json_str: + logger.warning(f"无法从LLM响应中提取JSON: {response_text[:200]}...") + return LLMResponseModel.create_error_response("无法解析响应格式") + + try: + data = json.loads(json_str) + return self._validate_and_create_response(data) + except json.JSONDecodeError as e: + logger.error(f"JSON解析失败: {e}, 原始文本: {json_str[:200]}...") + return LLMResponseModel.create_error_response(f"JSON解析错误: {e}") + + def _extract_json(self, text: str) -> Optional[str]: + """ + 从文本中提取JSON块 + + 支持以下格式: + 1. 纯JSON字符串 + 2. ```json ... ``` 代码块 + 3. 文本中嵌入的JSON对象 + """ + text = text.strip() + + # 尝试1:直接解析(如果整个文本就是JSON) + if text.startswith("{"): + # 找到匹配的结束括号 + brace_count = 0 + for i, char in enumerate(text): + if char == "{": + brace_count += 1 + elif char == "}": + brace_count -= 1 + if brace_count == 0: + return text[:i + 1] + + # 尝试2:提取 ```json ... ``` 代码块 + json_block_pattern = r"```(?:json)?\s*([\s\S]*?)```" + matches = re.findall(json_block_pattern, text) + for match in matches: + match = match.strip() + if match.startswith("{"): + try: + json.loads(match) + return match + except json.JSONDecodeError: + continue + + # 尝试3:寻找文本中的JSON对象 + json_pattern = r"\{[\s\S]*\}" + matches = re.findall(json_pattern, text) + for match in matches: + try: + json.loads(match) + return match + except json.JSONDecodeError: + continue + + return None + + def _validate_and_create_response(self, data: dict[str, Any]) -> LLMResponseModel: + """ + 验证并创建响应模型(V2:支持动态动作验证) + + Args: + data: 解析后的字典数据 + + Returns: + LLMResponseModel: 验证后的响应模型 + """ + # 验证必需字段 + if "thought" not in data: + data["thought"] = "" + logger.warning("LLM响应缺少'thought'字段") + + if "expected_user_reaction" not in data: + data["expected_user_reaction"] = "" + logger.warning("LLM响应缺少'expected_user_reaction'字段") + + if "max_wait_seconds" not in data: + data["max_wait_seconds"] = 300 + logger.warning("LLM响应缺少'max_wait_seconds'字段,使用默认值300") + else: + # 确保在合理范围内 + try: + wait_seconds = int(data["max_wait_seconds"]) + data["max_wait_seconds"] = max(60, min(wait_seconds, 600)) + except (ValueError, TypeError): + data["max_wait_seconds"] = 300 + + if "actions" not in data or not data["actions"]: + data["actions"] = [{"type": "do_nothing"}] + logger.warning("LLM响应缺少'actions'字段,添加默认的do_nothing动作") + + # 验证每个动作(V2:使用动态验证) + validated_actions = [] + for action_data in data["actions"]: + if not isinstance(action_data, dict): + logger.warning(f"无效的动作格式: {action_data}") + continue + + action_type = action_data.get("type", "") + + # 检查是否是已注册的动作 + if not self.is_action_available(action_type): + logger.warning(f"不支持的动作类型: {action_type},可用动作: {list(self._available_actions.keys()) + list(self.INTERNAL_ACTIONS.keys())}") + continue + + # 对于内置动作,验证参数 + if action_type in self.INTERNAL_ACTIONS: + required_params = self.INTERNAL_ACTIONS[action_type]["required"] + missing_params = [p for p in required_params if p not in action_data] + if missing_params: + logger.warning(f"动作 '{action_type}' 缺少必需参数: {missing_params}") + continue + + # 对于动态注册的动作,仅记录参数信息(不强制验证) + # 注意:action_require 是"使用场景描述",不是必需参数! + # 必需参数应该在 action_parameters 中定义 + elif action_type in self._available_actions: + action_info = self._available_actions[action_type] + # 仅记录调试信息,不阻止执行 + if action_info.action_parameters: + provided_params = set(action_data.keys()) - {"type", "reason"} + expected_params = set(action_info.action_parameters.keys()) + if expected_params and not provided_params.intersection(expected_params): + logger.debug(f"动作 '{action_type}' 期望参数: {list(expected_params)},实际提供: {list(provided_params)}") + + validated_actions.append(action_data) + + if not validated_actions: + validated_actions = [{"type": "do_nothing"}] + + data["actions"] = validated_actions + + return LLMResponseModel.from_dict(data) + + async def execute_actions( + self, + response: LLMResponseModel, + session: KokoroSession, + chat_stream: Optional["ChatStream"] = None, + ) -> dict[str, Any]: + """ + 执行动作列表(V2:通过ActionManager执行动态动作) + + 执行策略(参考AFC的plan_executor): + 1. 先执行所有"回复类"动作(reply, respond等) + 2. 再执行"其他"动作(send_reaction, sing_a_song等) + 3. 内部动作(update_internal_state, do_nothing)由KFC直接处理 + + Args: + response: LLM响应模型 + session: 当前会话 + chat_stream: 聊天流对象(用于发送消息) + + Returns: + dict: 执行结果 + """ + results = [] + has_reply = False + reply_content = "" + + # INFO日志:打印所有解析出的动作(可观测性增强) + for action in response.actions: + logger.info( + f"Parsed action for execution: type={action.type}, params={action.params}" + ) + + # 分类动作:回复类 vs 其他类 vs 内部类 + reply_actions = [] # reply, respond + other_actions = [] # 其他注册的动作 + internal_actions = [] # update_internal_state, do_nothing + + for action in response.actions: + action_type = action.type + if action_type in self.INTERNAL_ACTIONS: + internal_actions.append(action) + elif action_type in ("reply", "respond"): + reply_actions.append(action) + else: + other_actions.append(action) + + # 第1步:执行回复类动作 + for action in reply_actions: + try: + result = await self._execute_via_action_manager( + action, session, chat_stream + ) + results.append(result) + + if result.get("success"): + self._execution_stats["successful"] += 1 + has_reply = True + reply_content = action.params.get("content", "") or result.get("reply_text", "") + else: + self._execution_stats["failed"] += 1 + + except Exception as e: + logger.error(f"执行回复动作 '{action.type}' 失败: {e}") + results.append({ + "action_type": action.type, + "success": False, + "error": str(e), + }) + self._execution_stats["failed"] += 1 + + self._update_stats(action.type) + + # 第2步:并行执行其他动作(参考AFC的_execute_other_actions) + if other_actions: + other_tasks = [] + for action in other_actions: + task = asyncio.create_task( + self._execute_via_action_manager(action, session, chat_stream) + ) + other_tasks.append((action, task)) + + for action, task in other_tasks: + try: + result = await task + results.append(result) + if result.get("success"): + self._execution_stats["successful"] += 1 + else: + self._execution_stats["failed"] += 1 + except Exception as e: + logger.error(f"执行动作 '{action.type}' 失败: {e}") + results.append({ + "action_type": action.type, + "success": False, + "error": str(e), + }) + self._execution_stats["failed"] += 1 + + self._update_stats(action.type) + + # 第3步:执行内部动作 + for action in internal_actions: + try: + result = await self._execute_internal_action(action, session) + results.append(result) + self._execution_stats["successful"] += 1 + except Exception as e: + logger.error(f"执行内部动作 '{action.type}' 失败: {e}") + results.append({ + "action_type": action.type, + "success": False, + "error": str(e), + }) + self._execution_stats["failed"] += 1 + + self._update_stats(action.type) + + # 添加Bot行动日志 + if has_reply or other_actions: + entry = MentalLogEntry( + event_type=MentalLogEventType.BOT_ACTION, + timestamp=time.time(), + thought=response.thought, + content=reply_content or f"执行了 {len(other_actions)} 个动作", + emotional_snapshot=session.emotional_state.to_dict(), + metadata={ + "actions": [a.to_dict() for a in response.actions], + "results_summary": { + "total": len(results), + "successful": sum(1 for r in results if r.get("success")), + }, + }, + ) + session.add_mental_log_entry(entry) + if reply_content: + session.last_bot_message = reply_content + + # V5:动态情感更新 - 根据thought分析情感倾向并微调EmotionalState + await self._update_emotional_state_from_thought(response.thought, session) + + return { + "success": all(r.get("success", False) for r in results), + "results": results, + "has_reply": has_reply, + "reply_content": reply_content, + "thought": response.thought, + "expected_user_reaction": response.expected_user_reaction, + "max_wait_seconds": response.max_wait_seconds, + } + + def _update_stats(self, action_type: str) -> None: + """更新执行统计""" + self._execution_stats["total_executed"] += 1 + if action_type not in self._execution_stats["by_type"]: + self._execution_stats["by_type"][action_type] = 0 + self._execution_stats["by_type"][action_type] += 1 + + async def _execute_via_action_manager( + self, + action: ActionModel, + session: KokoroSession, + chat_stream: Optional["ChatStream"], + ) -> dict[str, Any]: + """ + 通过ActionManager执行动作 + + Args: + action: 动作模型 + session: 当前会话 + chat_stream: 聊天流对象 + + Returns: + dict: 执行结果 + """ + action_type = action.type + params = action.params + + logger.debug(f"通过ActionManager执行动作: {action_type}, 参数: {params}") + + if not chat_stream: + return { + "action_type": action_type, + "success": False, + "error": "无法获取聊天流", + } + + try: + # 准备动作数据 + action_data = params.copy() + + # 对于reply动作,需要处理content字段 + if action_type in ("reply", "respond") and "content" in action_data: + # ActionManager的reply期望的是生成回复而不是直接内容 + # 但KFC已经决定了内容,所以我们直接发送 + return await self._execute_reply_directly(action_data, chat_stream) + + # 使用ActionManager执行其他动作 + result = await self._action_manager.execute_action( + action_name=action_type, + chat_id=self.stream_id, + target_message=None, # KFC模式不需要target_message + reasoning=f"KFC决策: {action_type}", + action_data=action_data, + thinking_id=None, + log_prefix="[KFC]", + ) + + return { + "action_type": action_type, + "success": result.get("success", False), + "reply_text": result.get("reply_text", ""), + "result": result, + } + + except Exception as e: + logger.error(f"ActionManager执行失败: {action_type}, 错误: {e}") + import traceback + logger.error(traceback.format_exc()) + return { + "action_type": action_type, + "success": False, + "error": str(e), + } + + async def _execute_reply_directly( + self, + params: dict[str, Any], + chat_stream: "ChatStream", + ) -> dict[str, Any]: + """ + 直接执行回复动作(KFC决定的内容直接发送) + + V4升级:集成全局后处理流程(错别字、消息分割) + + Args: + params: 动作参数,包含content + chat_stream: 聊天流对象 + + Returns: + dict: 执行结果 + """ + from src.plugin_system.apis import send_api + from .response_post_processor import process_reply_content + + content = params.get("content", "") + reply_to = params.get("reply_to") + should_quote = params.get("should_quote_reply", False) + + if not content: + return { + "action_type": "reply", + "success": False, + "error": "回复内容为空", + } + + try: + # 【关键步骤】调用全局后处理器(错别字生成、消息分割) + processed_messages = await process_reply_content(content) + logger.info(f"[KFC] 后处理完成,原始内容长度={len(content)},分割为 {len(processed_messages)} 条消息") + + all_success = True + first_message = True + + for msg in processed_messages: + success = await send_api.text_to_stream( + text=msg, + stream_id=self.stream_id, + reply_to_message=reply_to if first_message else None, + set_reply=should_quote if first_message else False, + typing=True, + ) + if not success: + all_success = False + first_message = False + + return { + "action_type": "reply", + "success": all_success, + "reply_text": content, + "processed_messages": processed_messages, + } + + except Exception as e: + logger.error(f"直接发送回复失败: {e}") + import traceback + logger.error(traceback.format_exc()) + return { + "action_type": "reply", + "success": False, + "error": str(e), + } + + async def _execute_internal_action( + self, + action: ActionModel, + session: KokoroSession, + ) -> dict[str, Any]: + """ + 执行KFC内部动作 + + Args: + action: 动作模型 + session: 当前会话 + + Returns: + dict: 执行结果 + """ + action_type = action.type + params = action.params + + if action_type == "update_internal_state": + return await self._execute_update_state(params, session) + + elif action_type == "do_nothing": + return await self._execute_do_nothing() + + else: + return { + "action_type": action_type, + "success": False, + "error": f"未知的内部动作类型: {action_type}", + } + + async def _execute_update_state( + self, + params: dict[str, Any], + session: KokoroSession, + ) -> dict[str, Any]: + """ + 执行内部状态更新动作 + + 这个动作用于实现情感闭环,让AI可以主动更新自己的情感状态 + """ + updated_fields = [] + emotional_state = session.emotional_state + + if "mood" in params: + emotional_state.mood = params["mood"] + updated_fields.append("mood") + + if "mood_intensity" in params: + try: + intensity = float(params["mood_intensity"]) + emotional_state.mood_intensity = max(0.0, min(1.0, intensity)) + updated_fields.append("mood_intensity") + except (ValueError, TypeError): + pass + + if "relationship_warmth" in params: + try: + warmth = float(params["relationship_warmth"]) + emotional_state.relationship_warmth = max(0.0, min(1.0, warmth)) + updated_fields.append("relationship_warmth") + except (ValueError, TypeError): + pass + + if "impression_of_user" in params: + emotional_state.impression_of_user = str(params["impression_of_user"]) + updated_fields.append("impression_of_user") + + if "anxiety_level" in params: + try: + anxiety = float(params["anxiety_level"]) + emotional_state.anxiety_level = max(0.0, min(1.0, anxiety)) + updated_fields.append("anxiety_level") + except (ValueError, TypeError): + pass + + if "engagement_level" in params: + try: + engagement = float(params["engagement_level"]) + emotional_state.engagement_level = max(0.0, min(1.0, engagement)) + updated_fields.append("engagement_level") + except (ValueError, TypeError): + pass + + emotional_state.last_update_time = time.time() + + logger.debug(f"更新情感状态: {updated_fields}") + + return { + "action_type": "update_internal_state", + "success": True, + "updated_fields": updated_fields, + } + + async def _execute_do_nothing(self) -> dict[str, Any]: + """执行"什么都不做"动作""" + logger.debug("执行 do_nothing 动作") + return { + "action_type": "do_nothing", + "success": True, + } + + def get_execution_stats(self) -> dict[str, Any]: + """获取执行统计信息""" + return self._execution_stats.copy() + + def reset_stats(self) -> None: + """重置统计信息""" + self._execution_stats = { + "total_executed": 0, + "successful": 0, + "failed": 0, + "by_type": {}, + } + + async def _update_emotional_state_from_thought( + self, + thought: str, + session: KokoroSession, + ) -> None: + """ + V5:根据thought字段的情感倾向动态更新EmotionalState + + 分析策略: + 1. 统计正面/负面/亲密/疏远关键词出现次数 + 2. 根据关键词比例计算情感调整值 + 3. 应用平滑的情感微调(每次变化不超过±0.1) + + Args: + thought: LLM返回的内心独白 + session: 当前会话 + """ + if not thought: + return + + thought_lower = thought.lower() + emotional_state = session.emotional_state + + # 统计关键词 + positive_count = sum(1 for kw in POSITIVE_KEYWORDS if kw in thought_lower) + negative_count = sum(1 for kw in NEGATIVE_KEYWORDS if kw in thought_lower) + intimate_count = sum(1 for kw in INTIMATE_KEYWORDS if kw in thought_lower) + distant_count = sum(1 for kw in DISTANT_KEYWORDS if kw in thought_lower) + + # 计算情感倾向分数 (-1.0 到 1.0) + total_mood = positive_count + negative_count + if total_mood > 0: + mood_score = (positive_count - negative_count) / total_mood + else: + mood_score = 0.0 + + total_relation = intimate_count + distant_count + if total_relation > 0: + relation_score = (intimate_count - distant_count) / total_relation + else: + relation_score = 0.0 + + # 应用情感微调(每次最多变化±0.05) + adjustment_rate = 0.05 + + # 更新 mood_intensity + if mood_score != 0: + old_intensity = emotional_state.mood_intensity + new_intensity = old_intensity + (mood_score * adjustment_rate) + emotional_state.mood_intensity = max(0.0, min(1.0, new_intensity)) + + # 更新 mood 文字描述 + if mood_score > 0.3: + emotional_state.mood = "开心" + elif mood_score < -0.3: + emotional_state.mood = "低落" + # 小于0.3的变化不改变mood文字 + + # 更新 anxiety_level(负面情绪增加焦虑) + if negative_count > 0 and negative_count > positive_count: + old_anxiety = emotional_state.anxiety_level + new_anxiety = old_anxiety + (adjustment_rate * 0.5) + emotional_state.anxiety_level = max(0.0, min(1.0, new_anxiety)) + elif positive_count > negative_count * 2: + # 非常积极时降低焦虑 + old_anxiety = emotional_state.anxiety_level + new_anxiety = old_anxiety - (adjustment_rate * 0.3) + emotional_state.anxiety_level = max(0.0, min(1.0, new_anxiety)) + + # 更新 relationship_warmth + if relation_score != 0: + old_warmth = emotional_state.relationship_warmth + new_warmth = old_warmth + (relation_score * adjustment_rate) + emotional_state.relationship_warmth = max(0.0, min(1.0, new_warmth)) + + # 更新 engagement_level(有内容的thought表示高投入) + if len(thought) > 50: + old_engagement = emotional_state.engagement_level + new_engagement = old_engagement + (adjustment_rate * 0.5) + emotional_state.engagement_level = max(0.0, min(1.0, new_engagement)) + + emotional_state.last_update_time = time.time() + + # 记录情感变化日志 + if positive_count + negative_count + intimate_count + distant_count > 0: + logger.info( + f"[KFC] 动态情感更新: " + f"正面={positive_count}, 负面={negative_count}, " + f"亲密={intimate_count}, 疏远={distant_count} | " + f"mood_intensity={emotional_state.mood_intensity:.2f}, " + f"relationship_warmth={emotional_state.relationship_warmth:.2f}, " + f"anxiety_level={emotional_state.anxiety_level:.2f}" + ) diff --git a/src/plugins/built_in/kokoro_flow_chatter/chatter.py b/src/plugins/built_in/kokoro_flow_chatter/chatter.py new file mode 100644 index 000000000..4b231550d --- /dev/null +++ b/src/plugins/built_in/kokoro_flow_chatter/chatter.py @@ -0,0 +1,606 @@ +""" +Kokoro Flow Chatter (心流聊天器) 主类 + +核心聊天处理器,协调所有组件完成"体验-决策-行动"的交互循环。 +实现从"消息响应者"到"对话体验者"的核心转变。 +""" + +import asyncio +import time +import traceback +from typing import TYPE_CHECKING, Any, ClassVar, Optional + +from src.chat.planner_actions.action_manager import ChatterActionManager +from src.chat.planner_actions.action_modifier import ActionModifier # V6: 动作筛选器 +from src.common.data_models.message_manager_data_model import StreamContext +from src.common.logger import get_logger +from src.config.config import global_config, model_config +from src.llm_models.utils_model import LLMRequest +from src.plugin_system.base.base_chatter import BaseChatter +from src.plugin_system.base.component_types import ChatType + +from .action_executor import ActionExecutor +from .context_builder import KFCContextBuilder +from .models import ( + KokoroSession, + LLMResponseModel, + MentalLogEntry, + MentalLogEventType, + SessionStatus, +) +from .prompt_generator import PromptGenerator, get_prompt_generator +from .scheduler import BackgroundScheduler, get_scheduler +from .session_manager import SessionManager, get_session_manager + +if TYPE_CHECKING: + from src.common.data_models.database_data_model import DatabaseMessages + +logger = get_logger("kokoro_flow_chatter") + +# 控制台颜色 +SOFT_PURPLE = "\033[38;5;183m" +RESET_COLOR = "\033[0m" + + +class KokoroFlowChatter(BaseChatter): + """ + 心流聊天器 (Kokoro Flow Chatter) + + 专为私聊场景设计的AI聊天处理器,核心特点: + - 心理状态驱动的交互模型 + - 连续的时间观念和等待体验 + - 深度情感连接和长期关系维护 + + 状态机: + IDLE -> RESPONDING -> WAITING -> (收到消息) -> RESPONDING + -> (超时) -> FOLLOW_UP_PENDING -> RESPONDING/IDLE + """ + + chatter_name: str = "KokoroFlowChatter" + chatter_description: str = "心流聊天器 - 专为私聊设计的深度情感交互处理器" + chat_types: ClassVar[list[ChatType]] = [ChatType.PRIVATE] # 仅支持私聊 + + def __init__( + self, + stream_id: str, + action_manager: ChatterActionManager, + plugin_config: dict | None = None, + ): + """ + 初始化心流聊天器 + + Args: + stream_id: 聊天流ID + action_manager: 动作管理器 + plugin_config: 插件配置 + """ + super().__init__(stream_id, action_manager, plugin_config) + + # 核心组件 + self.session_manager: SessionManager = get_session_manager() + self.prompt_generator: PromptGenerator = get_prompt_generator() + self.scheduler: BackgroundScheduler = get_scheduler() + self.action_executor: ActionExecutor = ActionExecutor(stream_id) + + # 配置 + self._load_config() + + # 并发控制 + self._lock = asyncio.Lock() + + # 统计信息 + self.stats = { + "messages_processed": 0, + "llm_calls": 0, + "successful_responses": 0, + "failed_responses": 0, + "timeout_decisions": 0, + } + self.last_activity_time = time.time() + + # 设置调度器回调 + self._setup_scheduler_callbacks() + + logger.info(f"{SOFT_PURPLE}[KFC]{RESET_COLOR} 初始化完成: stream_id={stream_id}") + + def _load_config(self) -> None: + """ + 加载配置(从 global_config.kokoro_flow_chatter 读取) + + 设计理念:KFC不是独立人格,它复用全局的人设、情感框架和回复模型, + 只保留最少的行为控制开关。 + """ + # 获取 KFC 配置 + if global_config and hasattr(global_config, 'kokoro_flow_chatter'): + kfc_config = global_config.kokoro_flow_chatter + + # 核心行为配置 + self.max_wait_seconds_default: int = kfc_config.max_wait_seconds_default + self.enable_continuous_thinking: bool = kfc_config.enable_continuous_thinking + + # 主动思考子配置(V3: 人性化驱动,无机械限制) + proactive_cfg = kfc_config.proactive_thinking + self.enable_proactive: bool = proactive_cfg.enabled + self.silence_threshold_seconds: int = proactive_cfg.silence_threshold_seconds + self.min_interval_between_proactive: int = proactive_cfg.min_interval_between_proactive + + logger.debug("[KFC] 已从 global_config.kokoro_flow_chatter 加载配置") + else: + # 回退到默认值 + self.max_wait_seconds_default = 300 + self.enable_continuous_thinking = True + self.enable_proactive = True + self.silence_threshold_seconds = 7200 + self.min_interval_between_proactive = 1800 + + logger.debug("[KFC] 使用默认配置") + + def _setup_scheduler_callbacks(self) -> None: + """设置调度器回调""" + self.scheduler.set_timeout_callback(self._on_session_timeout) + + if self.enable_continuous_thinking: + self.scheduler.set_continuous_thinking_callback( + self._on_continuous_thinking + ) + + async def execute(self, context: StreamContext) -> dict: + """ + 执行聊天处理逻辑(BaseChatter接口实现) + + Args: + context: StreamContext对象,包含聊天上下文信息 + + Returns: + 处理结果字典 + """ + async with self._lock: + try: + self.last_activity_time = time.time() + + # 获取未读消息(提前获取用于动作筛选) + unread_messages = context.get_unread_messages() + + if not unread_messages: + logger.debug(f"[KFC] 没有未读消息: {self.stream_id}") + return self._build_result(success=True, message="no_unread_messages") + + # 处理最后一条消息 + target_message = unread_messages[-1] + message_content = self._extract_message_content(target_message) + + # V2: 加载可用动作(动态动作发现) + await self.action_executor.load_actions() + raw_action_count = len(self.action_executor.get_available_actions()) + logger.debug(f"[KFC] 原始加载 {raw_action_count} 个动作") + + # V6: 使用ActionModifier筛选动作(复用AFC的三阶段筛选逻辑) + # 阶段0: 聊天类型过滤(私聊/群聊) + # 阶段2: 关联类型匹配(适配器能力检查) + # 阶段3: 激活判定(go_activate + LLM判断) + action_modifier = ActionModifier( + action_manager=self.action_executor._action_manager, + chat_id=self.stream_id, + ) + await action_modifier.modify_actions(message_content=message_content) + + # 获取筛选后的动作 + available_actions = self.action_executor._action_manager.get_using_actions() + logger.info( + f"[KFC] 动作筛选: {raw_action_count} -> {len(available_actions)} " + f"(筛除 {raw_action_count - len(available_actions)} 个)" + ) + + # 执行核心处理流程(传递筛选后的动作) + result = await self._handle_message(target_message, context, available_actions) + + # 更新统计 + self.stats["messages_processed"] += 1 + + return result + + except asyncio.CancelledError: + logger.info(f"[KFC] 处理被取消: {self.stream_id}") + self.stats["failed_responses"] += 1 + raise + except Exception as e: + logger.error(f"[KFC] 处理出错: {e}\n{traceback.format_exc()}") + self.stats["failed_responses"] += 1 + return self._build_result( + success=False, + message=str(e), + error=True + ) + + async def _handle_message( + self, + message: "DatabaseMessages", + context: StreamContext, + available_actions: dict | None = None, + ) -> dict: + """ + 处理单条消息的核心逻辑 + + 实现"体验 -> 决策 -> 行动"的交互模式 + V5超融合:集成S4U所有上下文模块 + + Args: + message: 要处理的消息 + context: 聊天上下文 + available_actions: 可用动作字典(V2新增) + + Returns: + 处理结果字典 + """ + # 1. 获取或创建会话 + user_id = str(message.user_info.user_id) + session = await self.session_manager.get_session(user_id, self.stream_id) + + # 2. 记录收到消息的事件 + await self._record_user_message(session, message) + + # 3. 更新会话状态为RESPONDING + old_status = session.status + session.status = SessionStatus.RESPONDING + + # 4. 如果之前在等待,结束等待状态 + if old_status == SessionStatus.WAITING: + session.end_waiting() + logger.debug(f"[KFC] 收到消息,结束等待: user={user_id}") + + # 5. V5超融合:构建S4U上下文数据 + chat_stream = await self._get_chat_stream() + context_data = {} + + if chat_stream: + try: + context_builder = KFCContextBuilder(chat_stream) + sender_name = message.user_info.user_nickname or user_id + target_message = self._extract_message_content(message) + + context_data = await context_builder.build_all_context( + sender_name=sender_name, + target_message=target_message, + context=context, + ) + logger.info(f"[KFC] 超融合上下文构建完成: {list(context_data.keys())}") + except Exception as e: + logger.warning(f"[KFC] 构建S4U上下文失败,使用基础模式: {e}") + + # 6. 生成提示词(V3: 从共享数据源读取历史, V5: 传递S4U上下文) + system_prompt, user_prompt = self.prompt_generator.generate_responding_prompt( + session=session, + message_content=self._extract_message_content(message), + sender_name=message.user_info.user_nickname or user_id, + sender_id=user_id, + message_time=message.time, + available_actions=available_actions, + context=context, # V3: 传递StreamContext以读取共享历史 + context_data=context_data, # V5: S4U上下文数据 + chat_stream=chat_stream, # V5: 聊天流用于场景判断 + ) + + # 7. 调用LLM + llm_response = await self._call_llm(system_prompt, user_prompt) + self.stats["llm_calls"] += 1 + + # 8. 解析响应 + parsed_response = self.action_executor.parse_llm_response(llm_response) + + # 9. 执行动作 + execution_result = await self.action_executor.execute_actions( + parsed_response, + session, + chat_stream + ) + + # 10. 处理执行结果 + if execution_result["has_reply"]: + # 如果发送了回复,进入等待状态 + session.start_waiting( + expected_reaction=parsed_response.expected_user_reaction, + max_wait=parsed_response.max_wait_seconds + ) + session.total_interactions += 1 + self.stats["successful_responses"] += 1 + logger.debug( + f"[KFC] 进入等待状态: user={user_id}, " + f"max_wait={parsed_response.max_wait_seconds}s" + ) + else: + # 没有发送回复,返回空闲状态 + session.status = SessionStatus.IDLE + logger.debug(f"[KFC] 无回复动作,返回空闲: user={user_id}") + + # 11. 保存会话 + await self.session_manager.save_session(user_id) + + # 12. 标记消息为已读 + context.mark_message_as_read(str(message.message_id)) + + return self._build_result( + success=True, + message="processed", + has_reply=execution_result["has_reply"], + thought=parsed_response.thought, + ) + + async def _record_user_message( + self, + session: KokoroSession, + message: "DatabaseMessages", + ) -> None: + """记录用户消息到会话历史""" + content = self._extract_message_content(message) + session.last_user_message = content + + entry = MentalLogEntry( + event_type=MentalLogEventType.USER_MESSAGE, + timestamp=message.time or time.time(), + thought="", # 用户消息不需要内心独白 + content=content, + metadata={ + "message_id": str(message.message_id), + "user_id": str(message.user_info.user_id), + "user_name": message.user_info.user_nickname, + }, + ) + session.add_mental_log_entry(entry) + + def _extract_message_content(self, message: "DatabaseMessages") -> str: + """提取消息内容""" + return ( + message.processed_plain_text + or message.display_message + or "" + ) + + async def _call_llm( + self, + system_prompt: str, + user_prompt: str, + ) -> str: + """ + 调用LLM生成响应 + + Args: + system_prompt: 系统提示词 + user_prompt: 用户提示词 + + Returns: + LLM的响应文本 + """ + try: + # 获取模型配置 + # 使用 replyer 任务的模型配置(KFC 生成回复,必须使用回复专用模型) + if model_config is None: + raise RuntimeError("model_config 未初始化") + task_config = model_config.model_task_config.replyer + + llm_request = LLMRequest( + model_set=task_config, + request_type="kokoro_flow_chatter", + ) + + # 构建完整的提示词(将系统提示词和用户提示词合并) + full_prompt = f"{system_prompt}\n\n{user_prompt}" + + # INFO日志:打印完整的KFC提示词(可观测性增强) + logger.info( + f"Final KFC prompt constructed for stream {self.stream_id}:\n" + f"--- PROMPT START ---\n" + f"[SYSTEM]\n{system_prompt}\n\n[USER]\n{user_prompt}\n" + f"--- PROMPT END ---" + ) + + # 生成响应 + response, _ = await llm_request.generate_response_async( + prompt=full_prompt, + ) + + # INFO日志:打印原始JSON响应(可观测性增强) + logger.info( + f"Raw JSON response from LLM for stream {self.stream_id}:\n" + f"--- JSON START ---\n" + f"{response}\n" + f"--- JSON END ---" + ) + + logger.info(f"[KFC] LLM响应长度: {len(response)}") + return response + + except Exception as e: + logger.error(f"[KFC] 调用LLM失败: {e}") + # 返回一个默认的JSON响应 + return '{"thought": "出现了技术问题", "expected_user_reaction": "", "max_wait_seconds": 60, "actions": [{"type": "do_nothing"}]}' + + async def _get_chat_stream(self): + """获取聊天流对象""" + try: + from src.chat.message_receive.chat_stream import get_chat_manager + + chat_manager = get_chat_manager() + if chat_manager: + return await chat_manager.get_stream(self.stream_id) + except Exception as e: + logger.warning(f"[KFC] 获取chat_stream失败: {e}") + return None + + async def _on_session_timeout(self, session: KokoroSession) -> None: + """ + 会话超时回调 + + 当等待超时时,触发后续决策流程 + + Args: + session: 超时的会话 + """ + logger.info(f"[KFC] 处理超时决策: user={session.user_id}") + self.stats["timeout_decisions"] += 1 + + try: + # V2: 加载可用动作 + available_actions = await self.action_executor.load_actions() + + # 生成超时决策提示词(V2: 传递可用动作) + system_prompt, user_prompt = self.prompt_generator.generate_timeout_decision_prompt( + session, + available_actions=available_actions, + ) + + # 调用LLM + llm_response = await self._call_llm(system_prompt, user_prompt) + self.stats["llm_calls"] += 1 + + # 解析响应 + parsed_response = self.action_executor.parse_llm_response(llm_response) + + # 执行动作 + chat_stream = await self._get_chat_stream() + execution_result = await self.action_executor.execute_actions( + parsed_response, + session, + chat_stream + ) + + # 更新会话状态 + if execution_result["has_reply"]: + # 如果发送了后续消息,重新进入等待 + session.start_waiting( + expected_reaction=parsed_response.expected_user_reaction, + max_wait=parsed_response.max_wait_seconds + ) + else: + # 否则返回空闲状态 + session.status = SessionStatus.IDLE + session.end_waiting() + + # 保存会话 + await self.session_manager.save_session(session.user_id) + + except Exception as e: + logger.error(f"[KFC] 超时决策处理失败: {e}") + # 发生错误时返回空闲状态 + session.status = SessionStatus.IDLE + session.end_waiting() + await self.session_manager.save_session(session.user_id) + + async def _on_continuous_thinking(self, session: KokoroSession) -> None: + """ + 连续思考回调(V2升级版) + + 在等待期间更新心理状态,可选择调用LLM生成更自然的想法 + V2: 支持通过配置启用LLM驱动的连续思考 + + Args: + session: 会话 + """ + logger.debug(f"[KFC] 连续思考触发: user={session.user_id}") + + # 检查是否启用LLM驱动的连续思考 + use_llm_thinking = self.get_config( + "behavior.use_llm_continuous_thinking", + default=False + ) + + if use_llm_thinking and isinstance(use_llm_thinking, bool) and use_llm_thinking: + try: + # V2: 加载可用动作 + available_actions = await self.action_executor.load_actions() + + # 生成连续思考提示词 + system_prompt, user_prompt = self.prompt_generator.generate_continuous_thinking_prompt( + session, + available_actions=available_actions, + ) + + # 调用LLM + llm_response = await self._call_llm(system_prompt, user_prompt) + self.stats["llm_calls"] += 1 + + # 解析并执行(可能会更新内部状态) + parsed_response = self.action_executor.parse_llm_response(llm_response) + + # 只执行内部动作,不执行外部动作 + for action in parsed_response.actions: + if action.type == "update_internal_state": + await self.action_executor._execute_internal_action(action, session) + + # 记录思考内容 + entry = MentalLogEntry( + event_type=MentalLogEventType.CONTINUOUS_THINKING, + timestamp=time.time(), + thought=parsed_response.thought, + content="", + emotional_snapshot=session.emotional_state.to_dict(), + ) + session.add_mental_log_entry(entry) + + # 保存会话 + await self.session_manager.save_session(session.user_id) + + except Exception as e: + logger.warning(f"[KFC] LLM连续思考失败: {e}") + + # 简单模式:更新焦虑程度(已在scheduler中处理) + # 这里可以添加额外的逻辑 + + def _build_result( + self, + success: bool, + message: str = "", + error: bool = False, + **kwargs, + ) -> dict: + """构建返回结果""" + result = { + "success": success, + "stream_id": self.stream_id, + "message": message, + "error": error, + "timestamp": time.time(), + } + result.update(kwargs) + return result + + def get_stats(self) -> dict[str, Any]: + """获取统计信息""" + return { + **self.stats, + "last_activity_time": self.last_activity_time, + "action_executor_stats": self.action_executor.get_execution_stats(), + } + + def reset_stats(self) -> None: + """重置统计信息""" + self.stats = { + "messages_processed": 0, + "llm_calls": 0, + "successful_responses": 0, + "failed_responses": 0, + "timeout_decisions": 0, + } + self.action_executor.reset_stats() + + async def get_session_info(self) -> Optional[dict]: + """获取当前会话信息(用于调试)""" + try: + # 尝试获取当前用户的会话 + sessions = await self.session_manager.get_all_waiting_sessions() + for session in sessions: + if session.stream_id == self.stream_id: + return session.to_dict() + except Exception as e: + logger.error(f"获取会话信息失败: {e}") + return None + + def __str__(self) -> str: + """字符串表示""" + return f"KokoroFlowChatter(stream_id={self.stream_id})" + + def __repr__(self) -> str: + """详细字符串表示""" + return ( + f"KokoroFlowChatter(stream_id={self.stream_id}, " + f"messages_processed={self.stats['messages_processed']})" + ) diff --git a/src/plugins/built_in/kokoro_flow_chatter/config.py b/src/plugins/built_in/kokoro_flow_chatter/config.py new file mode 100644 index 000000000..0fc3bc30b --- /dev/null +++ b/src/plugins/built_in/kokoro_flow_chatter/config.py @@ -0,0 +1,251 @@ +""" +Kokoro Flow Chatter 插件配置 + +定义插件的配置项和默认值 +""" + +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class PersonaConfig: + """人设配置""" + description: str = """你是一个温暖、真诚的对话伙伴。 +你有自己的情感和想法,会真实地表达内心的感受。 +你关心与用户的关系,但也保持健康的边界。 +你可以感到开心、好奇、担心、或者稍微沮丧,这些都是正常的。""" + + name: str = "心流助手" + """AI的名字""" + + +@dataclass +class BehaviorConfig: + """行为配置""" + max_wait_seconds_default: int = 300 + """默认最大等待秒数""" + + min_wait_seconds: int = 60 + """最小等待秒数""" + + max_wait_seconds_limit: int = 600 + """最大等待秒数上限""" + + enable_continuous_thinking: bool = True + """是否启用连续思考""" + + continuous_thinking_triggers: list[float] = field( + default_factory=lambda: [0.3, 0.6, 0.85] + ) + """连续思考触发点(等待进度百分比)""" + + scheduler_check_interval: float = 10.0 + """调度器检查间隔(秒)""" + + +@dataclass +class SessionConfig: + """会话配置""" + data_dir: str = "data/kokoro_flow_chatter/sessions" + """会话数据存储目录""" + + max_session_age_days: int = 30 + """会话最大保留天数""" + + auto_save_interval: int = 300 + """自动保存间隔(秒)""" + + max_mental_log_size: int = 100 + """心理日志最大条目数""" + + +@dataclass +class LLMConfig: + """LLM配置""" + model_name: str = "" + """使用的模型名称,留空则使用默认主模型""" + + max_tokens: int = 2048 + """最大生成token数""" + + temperature: float = 0.8 + """生成温度""" + + +@dataclass +class EmotionalConfig: + """情感系统配置""" + initial_mood: str = "neutral" + """初始心情""" + + initial_mood_intensity: float = 0.5 + """初始心情强度""" + + initial_relationship_warmth: float = 0.5 + """初始关系热度""" + + anxiety_increase_rate: float = 0.5 + """焦虑增长率(平方根系数)""" + + +@dataclass +class KokoroFlowChatterConfig: + """心流聊天器完整配置""" + enabled: bool = True + """是否启用插件""" + + persona: PersonaConfig = field(default_factory=PersonaConfig) + """人设配置""" + + behavior: BehaviorConfig = field(default_factory=BehaviorConfig) + """行为配置""" + + session: SessionConfig = field(default_factory=SessionConfig) + """会话配置""" + + llm: LLMConfig = field(default_factory=LLMConfig) + """LLM配置""" + + emotional: EmotionalConfig = field(default_factory=EmotionalConfig) + """情感系统配置""" + + def to_dict(self) -> dict[str, Any]: + """转换为字典""" + return { + "enabled": self.enabled, + "persona": { + "description": self.persona.description, + "name": self.persona.name, + }, + "behavior": { + "max_wait_seconds_default": self.behavior.max_wait_seconds_default, + "min_wait_seconds": self.behavior.min_wait_seconds, + "max_wait_seconds_limit": self.behavior.max_wait_seconds_limit, + "enable_continuous_thinking": self.behavior.enable_continuous_thinking, + "continuous_thinking_triggers": self.behavior.continuous_thinking_triggers, + "scheduler_check_interval": self.behavior.scheduler_check_interval, + }, + "session": { + "data_dir": self.session.data_dir, + "max_session_age_days": self.session.max_session_age_days, + "auto_save_interval": self.session.auto_save_interval, + "max_mental_log_size": self.session.max_mental_log_size, + }, + "llm": { + "model_name": self.llm.model_name, + "max_tokens": self.llm.max_tokens, + "temperature": self.llm.temperature, + }, + "emotional": { + "initial_mood": self.emotional.initial_mood, + "initial_mood_intensity": self.emotional.initial_mood_intensity, + "initial_relationship_warmth": self.emotional.initial_relationship_warmth, + "anxiety_increase_rate": self.emotional.anxiety_increase_rate, + }, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "KokoroFlowChatterConfig": + """从字典创建配置""" + config = cls() + + if "enabled" in data: + config.enabled = data["enabled"] + + if "persona" in data: + persona_data = data["persona"] + config.persona.description = persona_data.get( + "description", + config.persona.description + ) + config.persona.name = persona_data.get( + "name", + config.persona.name + ) + + if "behavior" in data: + behavior_data = data["behavior"] + config.behavior.max_wait_seconds_default = behavior_data.get( + "max_wait_seconds_default", + config.behavior.max_wait_seconds_default + ) + config.behavior.min_wait_seconds = behavior_data.get( + "min_wait_seconds", + config.behavior.min_wait_seconds + ) + config.behavior.max_wait_seconds_limit = behavior_data.get( + "max_wait_seconds_limit", + config.behavior.max_wait_seconds_limit + ) + config.behavior.enable_continuous_thinking = behavior_data.get( + "enable_continuous_thinking", + config.behavior.enable_continuous_thinking + ) + config.behavior.continuous_thinking_triggers = behavior_data.get( + "continuous_thinking_triggers", + config.behavior.continuous_thinking_triggers + ) + config.behavior.scheduler_check_interval = behavior_data.get( + "scheduler_check_interval", + config.behavior.scheduler_check_interval + ) + + if "session" in data: + session_data = data["session"] + config.session.data_dir = session_data.get( + "data_dir", + config.session.data_dir + ) + config.session.max_session_age_days = session_data.get( + "max_session_age_days", + config.session.max_session_age_days + ) + config.session.auto_save_interval = session_data.get( + "auto_save_interval", + config.session.auto_save_interval + ) + config.session.max_mental_log_size = session_data.get( + "max_mental_log_size", + config.session.max_mental_log_size + ) + + if "llm" in data: + llm_data = data["llm"] + config.llm.model_name = llm_data.get( + "model_name", + config.llm.model_name + ) + config.llm.max_tokens = llm_data.get( + "max_tokens", + config.llm.max_tokens + ) + config.llm.temperature = llm_data.get( + "temperature", + config.llm.temperature + ) + + if "emotional" in data: + emotional_data = data["emotional"] + config.emotional.initial_mood = emotional_data.get( + "initial_mood", + config.emotional.initial_mood + ) + config.emotional.initial_mood_intensity = emotional_data.get( + "initial_mood_intensity", + config.emotional.initial_mood_intensity + ) + config.emotional.initial_relationship_warmth = emotional_data.get( + "initial_relationship_warmth", + config.emotional.initial_relationship_warmth + ) + config.emotional.anxiety_increase_rate = emotional_data.get( + "anxiety_increase_rate", + config.emotional.anxiety_increase_rate + ) + + return config + + +# 默认配置实例 +default_config = KokoroFlowChatterConfig() diff --git a/src/plugins/built_in/kokoro_flow_chatter/context_builder.py b/src/plugins/built_in/kokoro_flow_chatter/context_builder.py new file mode 100644 index 000000000..bd1c0c1c9 --- /dev/null +++ b/src/plugins/built_in/kokoro_flow_chatter/context_builder.py @@ -0,0 +1,528 @@ +""" +Kokoro Flow Chatter 上下文构建器 + +该模块负责从 S4U 移植的所有上下文模块,为 KFC 提供"全知"Prompt所需的完整情境感知能力。 +包含: +- 关系信息 (relation_info) +- 记忆块 (memory_block) +- 表达习惯 (expression_habits) +- 知识库 (knowledge) +- 跨上下文 (cross_context) +- 日程信息 (schedule) +- 通知块 (notice) +- 历史消息构建 (history) +""" + +import asyncio +import time +from datetime import datetime, timedelta +from typing import TYPE_CHECKING, Any, Optional + +from src.common.logger import get_logger +from src.config.config import global_config +from src.person_info.person_info import get_person_info_manager, PersonInfoManager + +if TYPE_CHECKING: + from src.chat.message_receive.chat_stream import ChatStream + from src.common.data_models.message_manager_data_model import StreamContext + from src.config.config import BotConfig # 用于类型提示 + +logger = get_logger("kfc_context_builder") + + +# 类型断言辅助函数 +def _get_config(): + """获取全局配置(带类型断言)""" + assert global_config is not None, "global_config 未初始化" + return global_config + + +class KFCContextBuilder: + """ + KFC 上下文构建器 + + 从 S4U 的 DefaultReplyer 移植所有上下文构建能力, + 为 KFC 的"超融合"Prompt 提供完整的情境感知数据。 + """ + + def __init__(self, chat_stream: "ChatStream"): + """ + 初始化上下文构建器 + + Args: + chat_stream: 当前聊天流 + """ + self.chat_stream = chat_stream + self.chat_id = chat_stream.stream_id + self.platform = chat_stream.platform + self.is_group_chat = bool(chat_stream.group_info) + + # 延迟初始化的组件 + self._tool_executor: Any = None + self._expression_selector: Any = None + + @property + def tool_executor(self) -> Any: + """延迟初始化工具执行器""" + if self._tool_executor is None: + from src.plugin_system.core.tool_use import ToolExecutor + self._tool_executor = ToolExecutor(chat_id=self.chat_id) + return self._tool_executor + + async def build_all_context( + self, + sender_name: str, + target_message: str, + context: Optional["StreamContext"] = None, + ) -> dict[str, str]: + """ + 并行构建所有上下文模块 + + Args: + sender_name: 发送者名称 + target_message: 目标消息内容 + context: 聊天流上下文(可选) + + Returns: + dict: 包含所有上下文块的字典 + """ + # 获取历史消息用于构建各种上下文 + chat_history = await self._get_chat_history_text(context) + + # 并行执行所有上下文构建任务 + tasks = { + "relation_info": self._build_relation_info(sender_name, target_message), + "memory_block": self._build_memory_block(chat_history, target_message), + "expression_habits": self._build_expression_habits(chat_history, target_message), + "schedule": self._build_schedule_block(), + "time": self._build_time_block(), + } + + results = {} + try: + task_results = await asyncio.gather( + *[self._wrap_task(name, coro) for name, coro in tasks.items()], + return_exceptions=True + ) + + for result in task_results: + if isinstance(result, tuple): + name, value = result + results[name] = value + else: + logger.warning(f"上下文构建任务异常: {result}") + except Exception as e: + logger.error(f"并行构建上下文失败: {e}") + + return results + + async def _wrap_task(self, name: str, coro) -> tuple[str, str]: + """包装任务以返回名称和结果""" + try: + result = await coro + return (name, result or "") + except Exception as e: + logger.error(f"构建 {name} 失败: {e}") + return (name, "") + + async def _get_chat_history_text( + self, + context: Optional["StreamContext"] = None, + limit: int = 20, + ) -> str: + """ + 获取聊天历史文本 + + Args: + context: 聊天流上下文 + limit: 最大消息数量 + + Returns: + str: 格式化的聊天历史 + """ + if context is None: + return "" + + try: + from src.chat.utils.chat_message_builder import build_readable_messages + + messages = context.get_messages(limit=limit, include_unread=True) + if not messages: + return "" + + # 转换为字典格式 + msg_dicts = [msg.flatten() for msg in messages] + + return await build_readable_messages( + msg_dicts, + replace_bot_name=True, + timestamp_mode="relative", + truncate=True, + ) + except Exception as e: + logger.error(f"获取聊天历史失败: {e}") + return "" + + async def _build_relation_info(self, sender_name: str, target_message: str) -> str: + """ + 构建关系信息块 + + 从 S4U 的 build_relation_info 移植 + + Args: + sender_name: 发送者名称 + target_message: 目标消息 + + Returns: + str: 格式化的关系信息 + """ + config = _get_config() + + # 检查是否是Bot自己的消息 + if sender_name == f"{config.bot.nickname}(你)": + return "你将要回复的是你自己发送的消息。" + + person_info_manager = get_person_info_manager() + person_id = await person_info_manager.get_person_id_by_person_name(sender_name) + + if not person_id: + logger.debug(f"未找到用户 {sender_name} 的ID") + return f"你完全不认识{sender_name},这是你们的第一次互动。" + + try: + from src.person_info.relationship_fetcher import relationship_fetcher_manager + + relationship_fetcher = relationship_fetcher_manager.get_fetcher(self.chat_id) + + # 构建用户关系信息(包含别名、偏好关键词等字段) + user_relation_info = await relationship_fetcher.build_relation_info(person_id, points_num=5) + + # 构建聊天流印象信息(群聊/私聊的整体印象) + stream_impression = await relationship_fetcher.build_chat_stream_impression(self.chat_id) + + # 组合信息 + parts = [] + if user_relation_info: + parts.append(f"### 你与 {sender_name} 的关系\n{user_relation_info}") + if stream_impression: + scene_type = "这个群" if self.is_group_chat else "你们的私聊" + parts.append(f"### 你对{scene_type}的印象\n{stream_impression}") + + if parts: + return "\n\n".join(parts) + else: + return f"你与{sender_name}还没有建立深厚的关系,这是早期的互动阶段。" + + except Exception as e: + logger.error(f"获取关系信息失败: {e}") + return self._build_fallback_relation_info(sender_name, person_id) + + def _build_fallback_relation_info(self, sender_name: str, person_id: str) -> str: + """降级的关系信息构建""" + return f"你与{sender_name}是普通朋友关系。" + + async def _build_memory_block(self, chat_history: str, target_message: str) -> str: + """ + 构建记忆块 + + 从 S4U 的 build_memory_block 移植,使用三层记忆系统 + + Args: + chat_history: 聊天历史 + target_message: 目标消息 + + Returns: + str: 格式化的记忆信息 + """ + config = _get_config() + + if not (config.memory and config.memory.enable): + return "" + + try: + from src.memory_graph.manager_singleton import get_unified_memory_manager + from src.memory_graph.utils.three_tier_formatter import memory_formatter + + unified_manager = get_unified_memory_manager() + if not unified_manager: + logger.debug("[三层记忆] 管理器未初始化") + return "" + + # 使用统一管理器的智能检索 + search_result = await unified_manager.search_memories( + query_text=target_message, + use_judge=True, + recent_chat_history=chat_history, + ) + + if not search_result: + return "" + + # 分类记忆块 + perceptual_blocks = search_result.get("perceptual_blocks", []) + short_term_memories = search_result.get("short_term_memories", []) + long_term_memories = search_result.get("long_term_memories", []) + + # 使用三级记忆格式化器 + formatted_memories = await memory_formatter.format_all_tiers( + perceptual_blocks=perceptual_blocks, + short_term_memories=short_term_memories, + long_term_memories=long_term_memories + ) + + total_count = len(perceptual_blocks) + len(short_term_memories) + len(long_term_memories) + if total_count > 0 and formatted_memories.strip(): + logger.info( + f"[三层记忆] 检索到 {total_count} 条记忆 " + f"(感知:{len(perceptual_blocks)}, 短期:{len(short_term_memories)}, 长期:{len(long_term_memories)})" + ) + return f"### 🧠 相关记忆\n\n{formatted_memories}" + + return "" + + except Exception as e: + logger.error(f"[三层记忆] 检索失败: {e}") + return "" + + async def _build_expression_habits(self, chat_history: str, target_message: str) -> str: + """ + 构建表达习惯块 + + 从 S4U 的 build_expression_habits 移植 + + Args: + chat_history: 聊天历史 + target_message: 目标消息 + + Returns: + str: 格式化的表达习惯 + """ + config = _get_config() + + # 检查是否允许使用表达 + use_expression, _, _ = config.expression.get_expression_config_for_chat(self.chat_id) + if not use_expression: + return "" + + try: + from src.chat.express.expression_selector import expression_selector + + style_habits = [] + grammar_habits = [] + + # 使用统一的表达方式选择 + selected_expressions = await expression_selector.select_suitable_expressions( + chat_id=self.chat_id, + chat_history=chat_history, + target_message=target_message, + max_num=8, + min_num=2 + ) + + if selected_expressions: + for expr in selected_expressions: + if isinstance(expr, dict) and "situation" in expr and "style" in expr: + expr_type = expr.get("type", "style") + habit_str = f"当{expr['situation']}时,使用 {expr['style']}" + if expr_type == "grammar": + grammar_habits.append(habit_str) + else: + style_habits.append(habit_str) + + # 构建表达习惯块 + parts = [] + if style_habits: + parts.append("**语言风格习惯**:\n" + "\n".join(f"- {h}" for h in style_habits)) + if grammar_habits: + parts.append("**句法习惯**:\n" + "\n".join(f"- {h}" for h in grammar_habits)) + + if parts: + return "### 💬 你的表达习惯\n\n" + "\n\n".join(parts) + + return "" + + except Exception as e: + logger.error(f"构建表达习惯失败: {e}") + return "" + + async def _build_schedule_block(self) -> str: + """ + 构建日程信息块 + + 从 S4U 移植 + + Returns: + str: 格式化的日程信息 + """ + config = _get_config() + + if not config.planning_system.schedule_enable: + return "" + + try: + from src.schedule.schedule_manager import schedule_manager + + activity_info = schedule_manager.get_current_activity() + if not activity_info: + return "" + + activity = activity_info.get("activity") + time_range = activity_info.get("time_range") + now = datetime.now() + + if time_range: + try: + start_str, end_str = time_range.split("-") + start_time = datetime.strptime(start_str.strip(), "%H:%M").replace( + year=now.year, month=now.month, day=now.day + ) + end_time = datetime.strptime(end_str.strip(), "%H:%M").replace( + year=now.year, month=now.month, day=now.day + ) + + if end_time < start_time: + end_time += timedelta(days=1) + if now < start_time: + now += timedelta(days=1) + + duration_minutes = (now - start_time).total_seconds() / 60 + remaining_minutes = (end_time - now).total_seconds() / 60 + + return ( + f"你当前正在进行「{activity}」," + f"从{start_time.strftime('%H:%M')}开始,预计{end_time.strftime('%H:%M')}结束。" + f"已进行{duration_minutes:.0f}分钟,还剩约{remaining_minutes:.0f}分钟。" + ) + except (ValueError, AttributeError): + pass + + return f"你当前正在进行「{activity}」。" + + except Exception as e: + logger.error(f"构建日程块失败: {e}") + return "" + + async def _build_time_block(self) -> str: + """构建时间信息块""" + now = datetime.now() + weekdays = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"] + weekday = weekdays[now.weekday()] + + return f"{now.strftime('%Y年%m月%d日')} {weekday} {now.strftime('%H:%M:%S')}" + + async def build_s4u_style_history( + self, + context: "StreamContext", + max_read: int = 10, + max_unread: int = 10, + ) -> tuple[str, str]: + """ + 构建 S4U 风格的已读/未读历史消息 + + 从 S4U 的 build_s4u_chat_history_prompts 移植 + + Args: + context: 聊天流上下文 + max_read: 最大已读消息数 + max_unread: 最大未读消息数 + + Returns: + tuple[str, str]: (已读历史, 未读历史) + """ + try: + from src.chat.utils.chat_message_builder import build_readable_messages, replace_user_references_async + + # 确保历史消息已初始化 + await context.ensure_history_initialized() + + read_messages = context.history_messages + unread_messages = context.get_unread_messages() + + # 构建已读历史 + read_history = "" + if read_messages: + read_dicts = [msg.flatten() for msg in read_messages[-max_read:]] + read_content = await build_readable_messages( + read_dicts, + replace_bot_name=True, + timestamp_mode="normal_no_YMD", + truncate=True, + ) + read_history = f"### 📜 已读历史消息\n{read_content}" + + # 构建未读历史 + unread_history = "" + if unread_messages: + unread_lines = [] + for msg in unread_messages[-max_unread:]: + msg_time = time.strftime("%H:%M:%S", time.localtime(msg.time)) + msg_content = msg.processed_plain_text or "" + + # 获取发送者名称 + sender_name = await self._get_sender_name(msg) + + # 处理消息内容中的用户引用 + if msg_content: + msg_content = await replace_user_references_async( + msg_content, + self.platform, + replace_bot_name=True + ) + + unread_lines.append(f"{msg_time} {sender_name}: {msg_content}") + + unread_history = f"### 📬 未读历史消息\n" + "\n".join(unread_lines) + + return read_history, unread_history + + except Exception as e: + logger.error(f"构建S4U风格历史失败: {e}") + return "", "" + + async def _get_sender_name(self, msg) -> str: + """获取消息发送者名称""" + config = _get_config() + + try: + user_info = getattr(msg, "user_info", {}) + platform = getattr(user_info, "platform", "") or getattr(msg, "platform", "") + user_id = getattr(user_info, "user_id", "") or getattr(msg, "user_id", "") + + if not (platform and user_id): + return "未知用户" + + person_id = PersonInfoManager.get_person_id(platform, user_id) + person_info_manager = get_person_info_manager() + sender_name = await person_info_manager.get_value(person_id, "person_name") or "未知用户" + + # 如果是Bot自己,标记为(你) + if user_id == str(config.bot.qq_account): + sender_name = f"{config.bot.nickname}(你)" + + return sender_name + + except Exception: + return "未知用户" + + +# 模块级便捷函数 +async def build_kfc_context( + chat_stream: "ChatStream", + sender_name: str, + target_message: str, + context: Optional["StreamContext"] = None, +) -> dict[str, str]: + """ + 便捷函数:构建KFC所需的所有上下文 + + Args: + chat_stream: 聊天流 + sender_name: 发送者名称 + target_message: 目标消息 + context: 聊天流上下文 + + Returns: + dict: 包含所有上下文块的字典 + """ + builder = KFCContextBuilder(chat_stream) + return await builder.build_all_context(sender_name, target_message, context) diff --git a/src/plugins/built_in/kokoro_flow_chatter/models.py b/src/plugins/built_in/kokoro_flow_chatter/models.py new file mode 100644 index 000000000..19c5e49f2 --- /dev/null +++ b/src/plugins/built_in/kokoro_flow_chatter/models.py @@ -0,0 +1,442 @@ +""" +Kokoro Flow Chatter 数据模型 + +定义心流聊天器的核心数据结构,包括: +- SessionStatus: 会话状态枚举 +- EmotionalState: 情感状态模型 +- MentalLogEntry: 心理活动日志条目 +- KokoroSession: 完整的会话模型 +- LLMResponseModel: LLM响应结构 +- ActionModel: 动作模型 +""" + +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, Optional +import time + + +class SessionStatus(Enum): + """ + 会话状态枚举 + + 状态机核心,定义了KFC系统的四个基本状态: + - IDLE: 空闲态,会话的起点和终点 + - RESPONDING: 响应中,正在处理消息和生成决策 + - WAITING: 等待态,已发送回复,等待用户回应 + - FOLLOW_UP_PENDING: 决策态,等待超时后进行后续决策 + """ + IDLE = "idle" + RESPONDING = "responding" + WAITING = "waiting" + FOLLOW_UP_PENDING = "follow_up_pending" + + def __str__(self) -> str: + return self.value + + +class MentalLogEventType(Enum): + """ + 心理活动日志事件类型 + + 用于标记线性叙事历史中不同类型的事件 + """ + USER_MESSAGE = "user_message" # 用户消息事件 + BOT_ACTION = "bot_action" # Bot行动事件 + WAITING_UPDATE = "waiting_update" # 等待期间的心理更新 + TIMEOUT_DECISION = "timeout_decision" # 超时决策事件 + STATE_CHANGE = "state_change" # 状态变更事件 + CONTINUOUS_THINKING = "continuous_thinking" # 连续思考事件 + + def __str__(self) -> str: + return self.value + + +@dataclass +class EmotionalState: + """ + 动态情感状态模型 + + 记录和跟踪AI的情感参数,用于驱动个性化的交互行为 + + Attributes: + mood: 当前心情标签(如:开心、好奇、疲惫、沮丧) + mood_intensity: 心情强度,0.0-1.0 + relationship_warmth: 关系热度,代表与用户的亲密度,0.0-1.0 + impression_of_user: 对用户的动态印象描述 + anxiety_level: 焦虑程度,0.0-1.0,在等待时会变化 + engagement_level: 投入程度,0.0-1.0,表示对当前对话的关注度 + last_update_time: 最后更新时间戳 + """ + mood: str = "neutral" + mood_intensity: float = 0.5 + relationship_warmth: float = 0.5 + impression_of_user: str = "" + anxiety_level: float = 0.0 + engagement_level: float = 0.5 + last_update_time: float = field(default_factory=time.time) + + def to_dict(self) -> dict[str, Any]: + """转换为字典格式""" + return { + "mood": self.mood, + "mood_intensity": self.mood_intensity, + "relationship_warmth": self.relationship_warmth, + "impression_of_user": self.impression_of_user, + "anxiety_level": self.anxiety_level, + "engagement_level": self.engagement_level, + "last_update_time": self.last_update_time, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "EmotionalState": + """从字典创建实例""" + return cls( + mood=data.get("mood", "neutral"), + mood_intensity=data.get("mood_intensity", 0.5), + relationship_warmth=data.get("relationship_warmth", 0.5), + impression_of_user=data.get("impression_of_user", ""), + anxiety_level=data.get("anxiety_level", 0.0), + engagement_level=data.get("engagement_level", 0.5), + last_update_time=data.get("last_update_time", time.time()), + ) + + def update_anxiety_over_time(self, elapsed_seconds: float, max_wait_seconds: float) -> None: + """ + 根据等待时间更新焦虑程度 + + Args: + elapsed_seconds: 已等待的秒数 + max_wait_seconds: 最大等待秒数 + """ + if max_wait_seconds <= 0: + return + + # 焦虑程度随时间流逝增加,使用平方根函数使增长趋于平缓 + wait_ratio = min(elapsed_seconds / max_wait_seconds, 1.0) + self.anxiety_level = min(wait_ratio ** 0.5, 1.0) + self.last_update_time = time.time() + + +@dataclass +class MentalLogEntry: + """ + 心理活动日志条目 + + 记录线性叙事历史中的每一个事件节点, + 是实现"连续主观体验"的核心数据结构 + + Attributes: + event_type: 事件类型 + timestamp: 事件发生时间戳 + thought: 内心独白 + content: 事件内容(如用户消息、Bot回复等) + emotional_snapshot: 事件发生时的情感状态快照 + metadata: 额外元数据 + """ + event_type: MentalLogEventType + timestamp: float + thought: str = "" + content: str = "" + emotional_snapshot: Optional[dict[str, Any]] = None + metadata: dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + """转换为字典格式""" + return { + "event_type": str(self.event_type), + "timestamp": self.timestamp, + "thought": self.thought, + "content": self.content, + "emotional_snapshot": self.emotional_snapshot, + "metadata": self.metadata, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "MentalLogEntry": + """从字典创建实例""" + event_type_str = data.get("event_type", "state_change") + try: + event_type = MentalLogEventType(event_type_str) + except ValueError: + event_type = MentalLogEventType.STATE_CHANGE + + return cls( + event_type=event_type, + timestamp=data.get("timestamp", time.time()), + thought=data.get("thought", ""), + content=data.get("content", ""), + emotional_snapshot=data.get("emotional_snapshot"), + metadata=data.get("metadata", {}), + ) + + +@dataclass +class KokoroSession: + """ + Kokoro Flow Chatter 会话模型 + + 为每个私聊用户维护一个独立的会话,包含: + - 基本会话信息 + - 当前状态 + - 情感状态 + - 线性叙事历史(心理活动日志) + - 等待相关的状态 + + Attributes: + user_id: 用户唯一标识 + stream_id: 聊天流ID + status: 当前会话状态 + emotional_state: 动态情感状态 + mental_log: 线性叙事历史 + expected_user_reaction: 对用户回应的预期 + max_wait_seconds: 最大等待秒数 + waiting_since: 开始等待的时间戳 + last_bot_message: 最后一条Bot消息 + last_user_message: 最后一条用户消息 + created_at: 会话创建时间 + last_activity_at: 最后活动时间 + total_interactions: 总交互次数 + """ + user_id: str + stream_id: str + status: SessionStatus = SessionStatus.IDLE + emotional_state: EmotionalState = field(default_factory=EmotionalState) + mental_log: list[MentalLogEntry] = field(default_factory=list) + + # 等待状态相关 + expected_user_reaction: str = "" + max_wait_seconds: int = 300 + waiting_since: Optional[float] = None + + # 消息记录 + last_bot_message: str = "" + last_user_message: str = "" + + # 统计信息 + created_at: float = field(default_factory=time.time) + last_activity_at: float = field(default_factory=time.time) + total_interactions: int = 0 + + # 连续思考相关 + continuous_thinking_count: int = 0 + last_continuous_thinking_at: Optional[float] = None + + def add_mental_log_entry(self, entry: MentalLogEntry, max_log_size: int = 100) -> None: + """ + 添加心理活动日志条目 + + Args: + entry: 日志条目 + max_log_size: 日志最大保留条数 + """ + self.mental_log.append(entry) + self.last_activity_at = time.time() + + # 保持日志在合理大小 + if len(self.mental_log) > max_log_size: + # 保留最近的日志 + self.mental_log = self.mental_log[-max_log_size:] + + def get_recent_mental_log(self, limit: int = 20) -> list[MentalLogEntry]: + """获取最近的心理活动日志""" + return self.mental_log[-limit:] if self.mental_log else [] + + def get_waiting_duration(self) -> float: + """获取当前等待时长(秒)""" + if self.waiting_since is None: + return 0.0 + return time.time() - self.waiting_since + + def is_wait_timeout(self) -> bool: + """检查是否等待超时""" + return self.get_waiting_duration() >= self.max_wait_seconds + + def start_waiting(self, expected_reaction: str, max_wait: int) -> None: + """开始等待状态""" + self.status = SessionStatus.WAITING + self.expected_user_reaction = expected_reaction + self.max_wait_seconds = max_wait + self.waiting_since = time.time() + self.continuous_thinking_count = 0 + + def end_waiting(self) -> None: + """结束等待状态""" + self.waiting_since = None + self.expected_user_reaction = "" + self.continuous_thinking_count = 0 + + def to_dict(self) -> dict[str, Any]: + """转换为可序列化的字典格式""" + return { + "user_id": self.user_id, + "stream_id": self.stream_id, + "status": str(self.status), + "emotional_state": self.emotional_state.to_dict(), + "mental_log": [entry.to_dict() for entry in self.mental_log], + "expected_user_reaction": self.expected_user_reaction, + "max_wait_seconds": self.max_wait_seconds, + "waiting_since": self.waiting_since, + "last_bot_message": self.last_bot_message, + "last_user_message": self.last_user_message, + "created_at": self.created_at, + "last_activity_at": self.last_activity_at, + "total_interactions": self.total_interactions, + "continuous_thinking_count": self.continuous_thinking_count, + "last_continuous_thinking_at": self.last_continuous_thinking_at, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "KokoroSession": + """从字典创建会话实例""" + status_str = data.get("status", "idle") + try: + status = SessionStatus(status_str) + except ValueError: + status = SessionStatus.IDLE + + emotional_state = EmotionalState.from_dict( + data.get("emotional_state", {}) + ) + + mental_log = [ + MentalLogEntry.from_dict(entry) + for entry in data.get("mental_log", []) + ] + + return cls( + user_id=data.get("user_id", ""), + stream_id=data.get("stream_id", ""), + status=status, + emotional_state=emotional_state, + mental_log=mental_log, + expected_user_reaction=data.get("expected_user_reaction", ""), + max_wait_seconds=data.get("max_wait_seconds", 300), + waiting_since=data.get("waiting_since"), + last_bot_message=data.get("last_bot_message", ""), + last_user_message=data.get("last_user_message", ""), + created_at=data.get("created_at", time.time()), + last_activity_at=data.get("last_activity_at", time.time()), + total_interactions=data.get("total_interactions", 0), + continuous_thinking_count=data.get("continuous_thinking_count", 0), + last_continuous_thinking_at=data.get("last_continuous_thinking_at"), + ) + + +@dataclass +class ActionModel: + """ + 动作模型 + + 表示LLM决策的单个动作 + + Attributes: + type: 动作类型(reply, poke_user, send_reaction, update_internal_state, do_nothing) + params: 动作参数 + """ + type: str + params: dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + """转换为字典格式""" + return { + "type": self.type, + **self.params + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "ActionModel": + """从字典创建实例""" + action_type = data.get("type", "do_nothing") + params = {k: v for k, v in data.items() if k != "type"} + return cls(type=action_type, params=params) + + +@dataclass +class LLMResponseModel: + """ + LLM响应模型 + + 定义LLM输出的结构化JSON格式 + + Attributes: + thought: 内心独白(必须) + expected_user_reaction: 用户回应预期(必须) + max_wait_seconds: 最长等待秒数(必须) + actions: 行动列表(必须) + plan: 行动意图(可选) + emotional_updates: 情感状态更新(可选) + """ + thought: str + expected_user_reaction: str + max_wait_seconds: int + actions: list[ActionModel] + plan: str = "" + emotional_updates: Optional[dict[str, Any]] = None + + def to_dict(self) -> dict[str, Any]: + """转换为字典格式""" + result = { + "thought": self.thought, + "expected_user_reaction": self.expected_user_reaction, + "max_wait_seconds": self.max_wait_seconds, + "actions": [action.to_dict() for action in self.actions], + } + if self.plan: + result["plan"] = self.plan + if self.emotional_updates: + result["emotional_updates"] = self.emotional_updates + return result + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "LLMResponseModel": + """从字典创建实例""" + actions = [ + ActionModel.from_dict(action) + for action in data.get("actions", []) + ] + + # 如果没有actions,添加默认的do_nothing + if not actions: + actions = [ActionModel(type="do_nothing")] + + return cls( + thought=data.get("thought", ""), + expected_user_reaction=data.get("expected_user_reaction", ""), + max_wait_seconds=data.get("max_wait_seconds", 300), + actions=actions, + plan=data.get("plan", ""), + emotional_updates=data.get("emotional_updates"), + ) + + @classmethod + def create_error_response(cls, error_message: str) -> "LLMResponseModel": + """创建错误响应""" + return cls( + thought=f"出现了问题:{error_message}", + expected_user_reaction="用户可能会感到困惑", + max_wait_seconds=60, + actions=[ActionModel(type="do_nothing")], + ) + + +@dataclass +class ContinuousThinkingResult: + """ + 连续思考结果 + + 在等待期间触发的心理活动更新结果 + """ + thought: str + anxiety_level: float + should_follow_up: bool = False + follow_up_message: str = "" + + def to_dict(self) -> dict[str, Any]: + """转换为字典格式""" + return { + "thought": self.thought, + "anxiety_level": self.anxiety_level, + "should_follow_up": self.should_follow_up, + "follow_up_message": self.follow_up_message, + } diff --git a/src/plugins/built_in/kokoro_flow_chatter/plugin.py b/src/plugins/built_in/kokoro_flow_chatter/plugin.py new file mode 100644 index 000000000..3eb610490 --- /dev/null +++ b/src/plugins/built_in/kokoro_flow_chatter/plugin.py @@ -0,0 +1,218 @@ +""" +Kokoro Flow Chatter (心流聊天器) 插件入口 + +这是一个专为私聊场景设计的AI聊天插件,实现从"消息响应者"到"对话体验者"的转变。 + +核心特点: +- 心理状态驱动的交互模型 +- 连续的时间观念和等待体验 +- 深度情感连接和长期关系维护 +- 状态机驱动的交互节奏 + +切换逻辑: +- 当 enable = true 时,KFC 接管所有私聊消息 +- 当 enable = false 时,私聊消息由 AFC (Affinity Flow Chatter) 处理 +""" + +import asyncio +from typing import Any, ClassVar + +from src.common.logger import get_logger +from src.config.config import global_config +from src.plugin_system.apis.plugin_register_api import register_plugin +from src.plugin_system.base.base_plugin import BasePlugin +from src.plugin_system.base.component_types import ComponentInfo + +logger = get_logger("kokoro_flow_chatter_plugin") + + +@register_plugin +class KokoroFlowChatterPlugin(BasePlugin): + """ + 心流聊天器插件 + + 专为私聊场景设计的深度情感交互处理器。 + + Features: + - KokoroFlowChatter: 核心聊天处理器组件 + - SessionManager: 会话管理,支持持久化 + - BackgroundScheduler: 后台调度,处理等待超时 + - PromptGenerator: 动态提示词生成 + - ActionExecutor: 动作解析和执行 + """ + + plugin_name: str = "kokoro_flow_chatter" + enable_plugin: bool = True + dependencies: ClassVar[list[str]] = [] + python_dependencies: ClassVar[list[str]] = [] + config_file_name: str = "config.toml" + + # 配置schema留空,使用config.toml直接配置 + config_schema: ClassVar[dict[str, Any]] = {} + + # 后台任务 + _session_manager = None + _scheduler = None + _initialization_task = None + + def get_plugin_components(self) -> list[tuple[ComponentInfo, type]]: + """ + 返回插件包含的组件列表 + + 根据 global_config.kokoro_flow_chatter.enable 决定是否注册 KFC。 + 如果 enable = false,返回空列表,私聊将由 AFC 处理。 + """ + components: list[tuple[ComponentInfo, type]] = [] + + # 检查是否启用 KFC + kfc_enabled = True + if global_config and hasattr(global_config, 'kokoro_flow_chatter'): + kfc_enabled = global_config.kokoro_flow_chatter.enable + + if not kfc_enabled: + logger.info("KFC 已禁用 (enable = false),私聊将由 AFC 处理") + return components + + try: + # 导入核心聊天处理器 + from .chatter import KokoroFlowChatter + + components.append(( + KokoroFlowChatter.get_chatter_info(), + KokoroFlowChatter + )) + logger.debug("成功加载 KokoroFlowChatter 组件,KFC 将接管私聊") + + except Exception as e: + logger.error(f"加载 KokoroFlowChatter 时出错: {e}") + + return components + + async def on_plugin_load(self) -> bool: + """ + 插件加载时的初始化逻辑 + + 如果 KFC 被禁用,跳过初始化。 + + Returns: + bool: 是否加载成功 + """ + # 检查是否启用 KFC + kfc_enabled = True + if global_config and hasattr(global_config, 'kokoro_flow_chatter'): + kfc_enabled = global_config.kokoro_flow_chatter.enable + + if not kfc_enabled: + logger.info("KFC 已禁用,跳过初始化") + self._is_started = False + return True + + try: + logger.info("正在初始化 Kokoro Flow Chatter 插件...") + + # 初始化会话管理器 + from .session_manager import initialize_session_manager + + session_config = self.config.get("kokoro_flow_chatter", {}).get("session", {}) + self._session_manager = await initialize_session_manager( + data_dir=session_config.get("data_dir", "data/kokoro_flow_chatter/sessions"), + max_session_age_days=session_config.get("max_session_age_days", 30), + auto_save_interval=session_config.get("auto_save_interval", 300), + ) + + # 初始化调度器 + from .scheduler import initialize_scheduler + + # 从 global_config 读取配置 + check_interval = 10.0 + if global_config and hasattr(global_config, 'kokoro_flow_chatter'): + # 使用简化后的配置结构 + pass # check_interval 保持默认值 + + self._scheduler = await initialize_scheduler( + check_interval=check_interval, + ) + + self._is_started = True + logger.info("Kokoro Flow Chatter 插件初始化完成") + return True + + except Exception as e: + logger.error(f"Kokoro Flow Chatter 插件初始化失败: {e}") + return False + + async def on_plugin_unload(self) -> bool: + """ + 插件卸载时的清理逻辑 + + Returns: + bool: 是否卸载成功 + """ + try: + logger.info("正在关闭 Kokoro Flow Chatter 插件...") + + # 停止调度器 + if self._scheduler: + from .scheduler import shutdown_scheduler + await shutdown_scheduler() + self._scheduler = None + + # 停止会话管理器 + if self._session_manager: + await self._session_manager.stop() + self._session_manager = None + + self._is_started = False + logger.info("Kokoro Flow Chatter 插件已关闭") + return True + + except Exception as e: + logger.error(f"Kokoro Flow Chatter 插件关闭失败: {e}") + return False + + def register_plugin(self) -> bool: + """ + 注册插件及其所有组件 + + 重写父类方法,添加异步初始化逻辑 + """ + # 先调用父类的注册逻辑 + result = super().register_plugin() + + if result: + # 在后台启动异步初始化 + try: + loop = asyncio.get_event_loop() + if loop.is_running(): + self._initialization_task = asyncio.create_task( + self.on_plugin_load() + ) + else: + # 如果事件循环未运行,稍后初始化 + logger.debug("事件循环未运行,将延迟初始化") + except RuntimeError: + logger.debug("无法获取事件循环,将延迟初始化") + + return result + + @property + def is_started(self) -> bool: + """插件是否已启动""" + return self._is_started + + def get_plugin_stats(self) -> dict[str, Any]: + """获取插件统计信息""" + stats: dict[str, Any] = { + "is_started": self._is_started, + "has_session_manager": self._session_manager is not None, + "has_scheduler": self._scheduler is not None, + } + + if self._scheduler: + stats["scheduler_stats"] = self._scheduler.get_stats() + + if self._session_manager: + # 异步获取会话统计需要在异步上下文中调用 + stats["session_manager_active"] = True + + return stats diff --git a/src/plugins/built_in/kokoro_flow_chatter/proactive_thinking.py b/src/plugins/built_in/kokoro_flow_chatter/proactive_thinking.py new file mode 100644 index 000000000..5b3b745b5 --- /dev/null +++ b/src/plugins/built_in/kokoro_flow_chatter/proactive_thinking.py @@ -0,0 +1,528 @@ +""" +Kokoro Flow Chatter 主动思考引擎 (V2) + +私聊专属的主动思考系统,实现"主动找话题、主动关心用户"的能力。 +这是KFC区别于AFC的核心特性之一。 + +触发机制: +1. 长时间沉默检测 - 当对话沉默超过阈值时主动发起话题 +2. 关键记忆触发 - 基于重要日期、事件的主动关心 +3. 情绪状态触发 - 当情感参数达到阈值时主动表达 +4. 好感度驱动 - 根据与用户的关系深度调整主动程度 + +设计理念: +- 不是"有事才找你",而是"想你了就找你" +- 主动思考应该符合人设和情感状态 +- 避免过度打扰,保持适度的边界感 +""" + +import asyncio +import random +import time +from dataclasses import dataclass, field +from enum import Enum +from typing import TYPE_CHECKING, Any, Callable, Optional + +from src.common.logger import get_logger +from src.config.config import global_config +from src.plugin_system.base.component_types import ActionInfo + +from .models import KokoroSession, MentalLogEntry, MentalLogEventType, SessionStatus + +if TYPE_CHECKING: + from .action_executor import ActionExecutor + from .prompt_generator import PromptGenerator + +logger = get_logger("kokoro_proactive_thinking") + + +class ProactiveThinkingTrigger(Enum): + """主动思考触发类型""" + SILENCE_TIMEOUT = "silence_timeout" # 长时间沉默 - 她感到挂念 + TIME_BASED = "time_based" # 时间触发(早安/晚安)- 自然的问候契机 + + +@dataclass +class ProactiveThinkingConfig: + """ + 主动思考配置 + + 设计哲学:主动行为源于内部状态和外部环境的自然反应,而非机械的限制。 + 她的主动是因为挂念、因为关心、因为想问候,而不是因为"任务"。 + """ + # 是否启用主动思考 + enabled: bool = True + + # 1. 沉默触发器:当感到长久的沉默时,她可能会想说些什么 + silence_threshold_seconds: int = 7200 # 2小时无互动触发 + silence_check_interval: int = 300 # 每5分钟检查一次 + + # 2. 关系门槛:她不会对不熟悉的人过于主动 + min_affinity_for_proactive: float = 0.3 # 最低好感度才会主动 + + # 3. 频率呼吸:为了避免打扰,她的关心总是有间隔的 + min_interval_between_proactive: int = 1800 # 两次主动思考至少间隔30分钟 + + # 4. 自然问候:在特定的时间,她会像朋友一样送上问候 + enable_morning_greeting: bool = True # 早安问候 (8:00-9:00) + enable_night_greeting: bool = True # 晚安问候 (22:00-23:00) + + # 随机性(让行为更自然) + random_delay_range: tuple[int, int] = (60, 300) # 触发后随机延迟1-5分钟 + + @classmethod + def from_global_config(cls) -> "ProactiveThinkingConfig": + """从 global_config.kokoro_flow_chatter.proactive_thinking 创建配置""" + if global_config and hasattr(global_config, 'kokoro_flow_chatter'): + kfc = global_config.kokoro_flow_chatter + proactive = kfc.proactive_thinking + return cls( + enabled=proactive.enabled, + silence_threshold_seconds=proactive.silence_threshold_seconds, + silence_check_interval=300, # 固定值 + min_affinity_for_proactive=proactive.min_affinity_for_proactive, + min_interval_between_proactive=proactive.min_interval_between_proactive, + enable_morning_greeting=proactive.enable_morning_greeting, + enable_night_greeting=proactive.enable_night_greeting, + random_delay_range=(60, 300), # 固定值 + ) + return cls() + + +@dataclass +class ProactiveThinkingState: + """主动思考状态 - 记录她的主动关心历史""" + last_proactive_time: float = 0.0 + last_morning_greeting_date: str = "" # 上次早安的日期 + last_night_greeting_date: str = "" # 上次晚安的日期 + pending_triggers: list[ProactiveThinkingTrigger] = field(default_factory=list) + + def can_trigger(self, config: ProactiveThinkingConfig) -> bool: + """ + 检查是否满足主动思考的基本条件 + + 注意:这里不使用每日限制,而是基于间隔来自然控制频率 + """ + # 检查间隔限制 - 她的关心有呼吸感,不会太频繁 + if time.time() - self.last_proactive_time < config.min_interval_between_proactive: + return False + + return True + + def record_trigger(self) -> None: + """记录一次触发""" + self.last_proactive_time = time.time() + + def record_morning_greeting(self) -> None: + """记录今天的早安""" + self.last_morning_greeting_date = time.strftime("%Y-%m-%d") + self.record_trigger() + + def record_night_greeting(self) -> None: + """记录今天的晚安""" + self.last_night_greeting_date = time.strftime("%Y-%m-%d") + self.record_trigger() + + def has_greeted_morning_today(self) -> bool: + """今天是否已经问候过早安""" + return self.last_morning_greeting_date == time.strftime("%Y-%m-%d") + + def has_greeted_night_today(self) -> bool: + """今天是否已经问候过晚安""" + return self.last_night_greeting_date == time.strftime("%Y-%m-%d") + + +class ProactiveThinkingEngine: + """ + 主动思考引擎 + + 负责检测触发条件并生成主动思考内容。 + 这是一个"内在动机驱动"而非"机械限制"的系统。 + + 她的主动源于: + - 长时间的沉默让她感到挂念 + - 与用户的好感度决定了她愿意多主动 + - 特定的时间点给了她自然的问候契机 + """ + + def __init__( + self, + stream_id: str, + config: ProactiveThinkingConfig | None = None, + ): + """ + 初始化主动思考引擎 + + Args: + stream_id: 聊天流ID + config: 配置对象 + """ + self.stream_id = stream_id + self.config = config or ProactiveThinkingConfig() + self.state = ProactiveThinkingState() + + # 回调函数 + self._on_proactive_trigger: Optional[Callable] = None + + # 后台任务 + self._check_task: Optional[asyncio.Task] = None + self._running = False + + logger.debug(f"[ProactiveThinking] 初始化完成: stream_id={stream_id}") + + def set_proactive_callback( + self, + callback: Callable[[KokoroSession, ProactiveThinkingTrigger], Any] + ) -> None: + """ + 设置主动思考触发回调 + + Args: + callback: 当触发主动思考时调用的函数 + """ + self._on_proactive_trigger = callback + + async def start(self) -> None: + """启动主动思考引擎""" + if self._running: + return + + self._running = True + self._check_task = asyncio.create_task(self._check_loop()) + logger.info(f"[ProactiveThinking] 引擎已启动: stream_id={self.stream_id}") + + async def stop(self) -> None: + """停止主动思考引擎""" + self._running = False + + if self._check_task: + self._check_task.cancel() + try: + await self._check_task + except asyncio.CancelledError: + pass + self._check_task = None + + logger.info(f"[ProactiveThinking] 引擎已停止: stream_id={self.stream_id}") + + async def _check_loop(self) -> None: + """后台检查循环""" + while self._running: + try: + await asyncio.sleep(self.config.silence_check_interval) + + if not self.config.enabled: + continue + + # 这里需要获取session来检查,但我们在引擎层面不直接持有session + # 实际的检查逻辑通过 check_triggers 方法被外部调用 + + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"[ProactiveThinking] 检查循环出错: {e}") + + async def check_triggers( + self, + session: KokoroSession, + ) -> Optional[ProactiveThinkingTrigger]: + """ + 检查触发条件 - 基于内在动机而非机械限制 + + 综合考虑: + 1. 她与用户的好感度是否足够(关系门槛) + 2. 距离上次主动是否有足够间隔(频率呼吸) + 3. 是否有自然的触发契机(沉默/时间问候) + + Args: + session: 当前会话 + + Returns: + 触发类型,如果没有触发则返回None + """ + if not self.config.enabled: + return None + + # 关系门槛:她不会对不熟悉的人过于主动 + relationship_warmth = session.emotional_state.relationship_warmth + if relationship_warmth < self.config.min_affinity_for_proactive: + logger.debug( + f"[ProactiveThinking] 好感度不足,不主动: " + f"{relationship_warmth:.2f} < {self.config.min_affinity_for_proactive}" + ) + return None + + # 频率呼吸:检查间隔 + if not self.state.can_trigger(self.config): + return None + + # 只有在 IDLE 或 WAITING 状态才考虑主动 + if session.status not in (SessionStatus.IDLE, SessionStatus.WAITING): + return None + + # 按优先级检查触发契机 + + # 1. 时间问候(早安/晚安)- 自然的问候契机 + trigger = self._check_time_greeting_trigger() + if trigger: + return trigger + + # 2. 沉默触发 - 她感到挂念 + trigger = self._check_silence_trigger(session) + if trigger: + return trigger + + return None + + def _check_time_greeting_trigger(self) -> Optional[ProactiveThinkingTrigger]: + """检查时间问候触发(早安/晚安)""" + current_hour = time.localtime().tm_hour + + # 早安问候 (8:00 - 9:00) + if self.config.enable_morning_greeting: + if 8 <= current_hour < 9 and not self.state.has_greeted_morning_today(): + logger.debug("[ProactiveThinking] 早安问候时间") + return ProactiveThinkingTrigger.TIME_BASED + + # 晚安问候 (22:00 - 23:00) + if self.config.enable_night_greeting: + if 22 <= current_hour < 23 and not self.state.has_greeted_night_today(): + logger.debug("[ProactiveThinking] 晚安问候时间") + return ProactiveThinkingTrigger.TIME_BASED + + return None + + def _check_silence_trigger( + self, + session: KokoroSession, + ) -> Optional[ProactiveThinkingTrigger]: + """检查沉默触发 - 长时间的沉默让她感到挂念""" + # 获取最后互动时间 + last_interaction = session.waiting_since or session.last_activity_at + if not last_interaction: + # 使用session创建时间 + last_interaction = session.mental_log[0].timestamp if session.mental_log else time.time() + + silence_duration = time.time() - last_interaction + + if silence_duration >= self.config.silence_threshold_seconds: + logger.debug(f"[ProactiveThinking] 沉默触发: 已沉默 {silence_duration:.0f} 秒,她感到挂念") + return ProactiveThinkingTrigger.SILENCE_TIMEOUT + + return None + + async def generate_proactive_prompt( + self, + session: KokoroSession, + trigger: ProactiveThinkingTrigger, + prompt_generator: "PromptGenerator", + available_actions: dict[str, ActionInfo] | None = None, + ) -> tuple[str, str]: + """ + 生成主动思考的提示词 + + Args: + session: 当前会话 + trigger: 触发类型 + prompt_generator: 提示词生成器 + available_actions: 可用动作 + + Returns: + (system_prompt, user_prompt) 元组 + """ + # 根据触发类型生成上下文 + trigger_context = self._build_trigger_context(session, trigger) + + # 使用prompt_generator生成主动思考提示词 + system_prompt, user_prompt = prompt_generator.generate_proactive_thinking_prompt( + session=session, + trigger_type=trigger.value, + trigger_context=trigger_context, + available_actions=available_actions, + ) + + return system_prompt, user_prompt + + def _build_trigger_context( + self, + session: KokoroSession, + trigger: ProactiveThinkingTrigger, + ) -> str: + """ + 构建触发上下文 - 描述她主动联系的内在动机 + """ + emotional_state = session.emotional_state + current_hour = time.localtime().tm_hour + + if trigger == ProactiveThinkingTrigger.TIME_BASED: + # 时间问候 - 自然的问候契机 + if 8 <= current_hour < 12: + return ( + f"早上好!新的一天开始了。" + f"我的心情是「{emotional_state.mood}」。" + f"我想和对方打个招呼,开启美好的一天。" + ) + else: + return ( + f"夜深了,已经{current_hour}点了。" + f"我的心情是「{emotional_state.mood}」。" + f"我想关心一下对方,送上晚安。" + ) + + else: # SILENCE_TIMEOUT + # 沉默触发 - 她感到挂念 + last_time = session.waiting_since or session.last_activity_at or time.time() + silence_hours = (time.time() - last_time) / 3600 + return ( + f"我们已经有 {silence_hours:.1f} 小时没有聊天了。" + f"我有些挂念对方。" + f"我现在的心情是「{emotional_state.mood}」。" + f"对方给我的印象是:{emotional_state.impression_of_user or '还不太了解'}" + ) + + async def execute_proactive_action( + self, + session: KokoroSession, + trigger: ProactiveThinkingTrigger, + action_executor: "ActionExecutor", + prompt_generator: "PromptGenerator", + llm_call: Callable[[str, str], Any], + ) -> dict[str, Any]: + """ + 执行主动思考流程 + + Args: + session: 当前会话 + trigger: 触发类型 + action_executor: 动作执行器 + prompt_generator: 提示词生成器 + llm_call: LLM调用函数(可以是同步或异步) + + Returns: + 执行结果 + """ + try: + # 1. 加载可用动作 + available_actions = await action_executor.load_actions() + + # 2. 生成提示词 + system_prompt, user_prompt = await self.generate_proactive_prompt( + session, trigger, prompt_generator, available_actions + ) + + # 3. 添加随机延迟(更自然) + delay = random.randint(*self.config.random_delay_range) + logger.debug(f"[ProactiveThinking] 延迟 {delay} 秒后执行") + await asyncio.sleep(delay) + + # 4. 调用LLM(支持同步和异步) + result = llm_call(system_prompt, user_prompt) + if asyncio.iscoroutine(result): + llm_response = await result + else: + llm_response = result + + # 5. 解析响应 + parsed_response = action_executor.parse_llm_response(llm_response) + + # 6. 记录主动思考事件 + entry = MentalLogEntry( + event_type=MentalLogEventType.CONTINUOUS_THINKING, + timestamp=time.time(), + thought=f"[主动思考-{trigger.value}] {parsed_response.thought}", + content="", + emotional_snapshot=session.emotional_state.to_dict(), + metadata={ + "trigger_type": trigger.value, + "proactive": True, + }, + ) + session.add_mental_log_entry(entry) + + # 7. 执行动作 + from src.chat.message_receive.chat_stream import get_chat_manager + chat_manager = get_chat_manager() + chat_stream = await chat_manager.get_stream(self.stream_id) if chat_manager else None + + result = await action_executor.execute_actions( + parsed_response, + session, + chat_stream + ) + + # 8. 记录触发(根据触发类型决定记录方式) + if trigger == ProactiveThinkingTrigger.TIME_BASED: + # 时间问候需要单独记录,防止同一天重复问候 + current_hour = time.localtime().tm_hour + if 6 <= current_hour < 12: + self.state.record_morning_greeting() + else: + self.state.record_night_greeting() + else: + self.state.record_trigger() + + # 9. 如果发送了消息,更新会话状态 + if result.get("has_reply"): + session.start_waiting( + expected_reaction=parsed_response.expected_user_reaction, + max_wait=parsed_response.max_wait_seconds + ) + + return { + "success": True, + "trigger": trigger.value, + "result": result, + } + + except Exception as e: + logger.error(f"[ProactiveThinking] 执行失败: {e}") + import traceback + logger.error(traceback.format_exc()) + return { + "success": False, + "trigger": trigger.value, + "error": str(e), + } + + def get_state(self) -> dict[str, Any]: + """获取当前状态""" + return { + "enabled": self.config.enabled, + "last_proactive_time": self.state.last_proactive_time, + "last_morning_greeting_date": self.state.last_morning_greeting_date, + "last_night_greeting_date": self.state.last_night_greeting_date, + "running": self._running, + } + + +# 全局引擎实例管理 +_engines: dict[str, ProactiveThinkingEngine] = {} + + +def get_proactive_thinking_engine( + stream_id: str, + config: ProactiveThinkingConfig | None = None, +) -> ProactiveThinkingEngine: + """ + 获取主动思考引擎实例 + + Args: + stream_id: 聊天流ID + config: 配置对象(如果为None,则从global_config加载) + + Returns: + ProactiveThinkingEngine实例 + """ + if stream_id not in _engines: + # 如果没有提供config,从global_config加载 + if config is None: + config = ProactiveThinkingConfig.from_global_config() + _engines[stream_id] = ProactiveThinkingEngine(stream_id, config) + return _engines[stream_id] + + +async def cleanup_engines() -> None: + """清理所有引擎实例""" + for engine in _engines.values(): + await engine.stop() + _engines.clear() diff --git a/src/plugins/built_in/kokoro_flow_chatter/prompt_generator.py b/src/plugins/built_in/kokoro_flow_chatter/prompt_generator.py new file mode 100644 index 000000000..0e793439e --- /dev/null +++ b/src/plugins/built_in/kokoro_flow_chatter/prompt_generator.py @@ -0,0 +1,801 @@ +""" +Kokoro Flow Chatter Prompt生成器 + +根据会话状态动态构建LLM提示词,实现"体验-决策-行动"的交互模式。 +支持两种主要场景: +1. 回应消息(Responding):收到用户消息后的决策 +2. 超时决策(Timeout Decision):等待超时后的后续行动决策 + +V2 升级: +- 动态Action发现机制:从ActionManager获取可用Action列表 +- 支持任意复杂参数的Action +- 与AFC的Action系统完全兼容 + +V5 超融合架构升级: +- 完整集成S4U的所有上下文模块 +- 关系信息、记忆、表达习惯等全部融入 +- 支持群聊/私聊场景动态切换 +""" + +import asyncio +import time +from typing import TYPE_CHECKING, Any, Optional + +from src.common.logger import get_logger +from src.plugin_system.base.component_types import ActionInfo + +from .models import ( + EmotionalState, + KokoroSession, + MentalLogEntry, + MentalLogEventType, +) + +if TYPE_CHECKING: + from src.chat.message_receive.chat_stream import ChatStream + from src.chat.planner_actions.action_manager import ChatterActionManager + from src.common.data_models.message_manager_data_model import StreamContext + +logger = get_logger("kokoro_prompt_generator") + + +class PromptGenerator: + """ + Kokoro Flow Chatter 提示词生成器 + + 负责根据会话状态和场景生成不同的LLM提示词, + 引导AI以"体验者"的身份参与对话。 + + V2 升级:支持动态Action发现,与AFC的Action系统完全兼容 + V4 升级:完整集成用户人设、世界观、表达风格和规则 + V5 超融合架构:集成S4U所有上下文模块(关系、记忆、表达习惯等) + """ + + # ========== 超融合架构 SYSTEM_PROMPT_TEMPLATE V6 ========== + # 采用"三明治结构":身份→规则→上下文→动作→表达/输出 + SYSTEM_PROMPT_TEMPLATE = """你是一个有主观体验的对话伙伴,而不是一个问答工具。你需要"感受"、"思考"并"体验"这段对话。 + +## 1. 核心身份与人格 (Core Identity & Personality) +{personality_core} + +### 人格细节 +{personality_side} + +### 外貌与身份特征 +{identity} + +### 世界观与背景故事 +{background_story} + +## 2. 核心行为准则 (Core Interaction Rules) +{safety_guidelines} + +### KFC通用准则 +1. **感受优先**:在做出任何回应前,先体验和感受当前的情境 +2. **内心独白**:保持真实的内心活动,可以有情绪、猜测、犹豫 +3. **情绪节制**:情绪是内在体验,不是强迫用户回应的工具 +4. **边界感**:保持健康的社交直觉,不过度依赖或情感勒索 +5. **全能伴侣**:你不仅仅是聊天伙伴,还可以使用各种能力来帮助和取悦用户 + +## 3. 当前情境 (Current Context) +**时间**: {current_time} +**场景**: {chat_scene} +{schedule_block} + +### 你的内在状态 +当前心情:{mood}(强度:{mood_intensity:.1%}) +与用户的关系热度:{relationship_warmth:.1%} +对用户的印象:{impression_of_user} +当前焦虑程度:{anxiety_level:.1%} +投入程度:{engagement_level:.1%} + +## 4. 关系网络与记忆 (Relationships & Memories) +{relation_info_block} + +{memory_block} + +## 5. 你的可用能力 (Available Actions) +你可以根据内心想法,自由选择并组合以下行动来回应用户: + +{available_actions_block} + +## 6. 表达方式与输出格式 (Expression Style & Output Format) +{reply_style} + +{expression_habits_block} + +### JSON输出格式要求 +你必须以JSON格式输出,包含以下字段: + +```json +{{ + "thought": "(必须)你当下的内心独白,自由流动的想法,包含你选择这些行动的"情感盘算"", + "expected_user_reaction": "(必须)你预期用户会如何回应", + "max_wait_seconds": (必须)你愿意等待的最长秒数(整数,60-900之间), + "actions": [ + {{ + "type": "action_type", + "reason": "选择这个行动的原因(可选但推荐)", + ...其他参数 + }} + ] +}} +``` + +### 重要说明: +- `thought` 是你的"心",应该是自然的内心想法,包含情绪和不确定性 +- `actions` 是你的"手",是一个**可编排的剧本**,可以组合多个行动形成完整的互动体验 +- 你可以根据情况组合多个动作,比如:先唱歌舒缓情绪,再发图片逗笑,最后用语音表达关心 +- `max_wait_seconds` 应该根据对话的重要性和氛围动态调整(上限900秒) +- 即使决定不做任何事,也要有 `{{"type": "do_nothing"}}` 动作""" + + # 回应消息场景的用户提示词模板 + RESPONDING_USER_PROMPT_TEMPLATE = """## 对话背景(线性叙事历史) +{narrative_history} + +## 当前收到的新消息 +来自:{sender_name}(用户ID: {sender_id}) +时间:{message_time} +内容:{message_content} + +## 你的任务 +作为一个有主观体验的对话参与者: +1. 首先,感受这条消息给你带来的情绪和想法 +2. 思考你想如何回应(或者是否需要回应) +3. 预测用户可能的反应 +4. 决定你愿意等待多久 +5. 执行你的决策 + +请以JSON格式输出你的完整心理活动和决策。""" + + # 超时决策场景的用户提示词模板 + TIMEOUT_DECISION_USER_PROMPT_TEMPLATE = """## 对话背景(线性叙事历史) +{narrative_history} + +## 当前情况 +你已经发送了消息并等待了 {wait_duration_seconds:.0f} 秒(约 {wait_duration_minutes:.1f} 分钟)。 +你之前预期用户会:{expected_user_reaction} +但是用户一直没有回复。 + +## 你的最后一条消息 +{last_bot_message} + +## 你的任务 +现在你需要决定接下来怎么做: +1. 首先,感受这段等待给你带来的情绪变化 +2. 思考用户为什么没有回复(可能在忙?没看到?不想回?) +3. 决定是继续等待、主动说点什么、还是就此结束对话 +4. 如果决定主动发消息,想好说什么 + +请以JSON格式输出你的完整心理活动和决策。""" + + # 连续思考场景的用户提示词模板 + CONTINUOUS_THINKING_USER_PROMPT_TEMPLATE = """## 对话背景 +{narrative_history} + +## 当前情况 +你正在等待用户回复。 +已等待时间:{wait_duration_seconds:.0f} 秒(约 {wait_duration_minutes:.1f} 分钟) +最大等待时间:{max_wait_seconds} 秒 +你之前预期用户会:{expected_user_reaction} + +## 你的最后一条消息 +{last_bot_message} + +## 你的任务 +这是一次"连续思考"触发。你不需要做任何行动,只需要更新你的内心想法。 +想一想: +1. 等待中你有什么感受? +2. 你对用户没回复这件事怎么看? +3. 你的焦虑程度如何? + +请以JSON格式输出,但 `actions` 数组应该是空的或只包含 `update_internal_state`: + +```json +{{ + "thought": "你当前的内心想法", + "expected_user_reaction": "保持或更新你的预期", + "max_wait_seconds": {max_wait_seconds}, + "actions": [] +}} +```""" + + # 主动思考场景的用户提示词模板 + PROACTIVE_THINKING_USER_PROMPT_TEMPLATE = """## 对话背景(线性叙事历史) +{narrative_history} + +## 当前情况 +{trigger_context} + +## 触发类型 +{trigger_type} + +## 你的任务 +这是一次"主动思考"触发。你不是因为收到消息才行动,而是因为内心的某种驱动力。 +现在你需要: +1. 感受一下现在的心情和想法 +2. 思考是否需要主动联系对方 +3. 如果决定主动,想好要说什么或做什么 +4. 如果决定不主动,也要有明确的理由 + +注意:主动联系应该是自然的、符合你们关系的。不要显得过于依赖或强迫。 +你可以选择发消息、发图片、唱首歌、或者只是在心里想想然后什么都不做。 + +请以JSON格式输出你的完整心理活动和决策。""" + + def __init__(self, persona_description: str = ""): + """ + 初始化提示词生成器 + + Args: + persona_description: 人设描述 + """ + self.persona_description = persona_description or self._get_default_persona() + + def _get_default_persona(self) -> str: + """获取默认人设描述""" + return """你是一个温暖、真诚的对话伙伴。 +你有自己的情感和想法,会真实地表达内心的感受。 +你关心与用户的关系,但也保持健康的边界。 +你可以感到开心、好奇、担心、或者稍微沮丧,这些都是正常的。""" + + def set_persona(self, persona_description: str) -> None: + """设置人设描述""" + self.persona_description = persona_description + + def _format_emotional_state(self, state: EmotionalState) -> dict[str, str | float]: + """格式化情感状态用于模板替换""" + return { + "mood": state.mood, + "mood_intensity": state.mood_intensity, + "relationship_warmth": state.relationship_warmth, + "impression_of_user": state.impression_of_user or "还没有形成明确的印象", + "anxiety_level": state.anxiety_level, + "engagement_level": state.engagement_level, + } + + def _format_narrative_history( + self, + mental_log: list[MentalLogEntry], + max_entries: int = 15, + ) -> str: + """ + 将心理活动日志格式化为叙事历史 + + Args: + mental_log: 心理活动日志列表 + max_entries: 最大条目数 + + Returns: + str: 格式化的叙事历史文本 + """ + if not mental_log: + return "(这是对话的开始,还没有历史记录)" + + # 获取最近的日志条目 + recent_entries = mental_log[-max_entries:] + + narrative_parts = [] + for entry in recent_entries: + timestamp_str = time.strftime( + "%Y-%m-%d %H:%M:%S", + time.localtime(entry.timestamp) + ) + + if entry.event_type == MentalLogEventType.USER_MESSAGE: + narrative_parts.append( + f"[{timestamp_str}] 用户说:{entry.content}" + ) + elif entry.event_type == MentalLogEventType.BOT_ACTION: + if entry.thought: + narrative_parts.append( + f"[{timestamp_str}] (你的内心:{entry.thought})" + ) + if entry.content: + narrative_parts.append( + f"[{timestamp_str}] 你回复:{entry.content}" + ) + elif entry.event_type == MentalLogEventType.WAITING_UPDATE: + if entry.thought: + narrative_parts.append( + f"[{timestamp_str}] (等待中的想法:{entry.thought})" + ) + elif entry.event_type == MentalLogEventType.CONTINUOUS_THINKING: + if entry.thought: + narrative_parts.append( + f"[{timestamp_str}] (思绪飘过:{entry.thought})" + ) + elif entry.event_type == MentalLogEventType.STATE_CHANGE: + if entry.content: + narrative_parts.append( + f"[{timestamp_str}] {entry.content}" + ) + + return "\n".join(narrative_parts) + + def _format_history_from_context( + self, + context: "StreamContext", + mental_log: list[MentalLogEntry] | None = None, + ) -> str: + """ + 从 StreamContext 的历史消息构建叙事历史 + + 这是实现"无缝融入"的关键: + - 从同一个数据库读取历史消息(与AFC共享) + - 遵循全局配置 [chat].max_context_size + - 将消息渲染成KFC的叙事体格式 + + Args: + context: 聊天流上下文,包含共享的历史消息 + mental_log: 可选的心理活动日志,用于补充内心独白 + + Returns: + str: 格式化的叙事历史文本 + """ + from src.config.config import global_config + + # 从 StreamContext 获取历史消息,遵循全局上下文长度配置 + max_context = 25 # 默认值 + if global_config and hasattr(global_config, 'chat') and global_config.chat: + max_context = getattr(global_config.chat, "max_context_size", 25) + history_messages = context.get_messages(limit=max_context, include_unread=False) + + if not history_messages and not mental_log: + return "(这是对话的开始,还没有历史记录)" + + # 获取Bot的用户ID用于判断消息来源 + bot_user_id = None + if global_config and hasattr(global_config, 'bot') and global_config.bot: + bot_user_id = str(getattr(global_config.bot, 'qq_account', '')) + + narrative_parts = [] + + # 首先,将数据库历史消息转换为叙事格式 + for msg in history_messages: + timestamp_str = time.strftime( + "%Y-%m-%d %H:%M:%S", + time.localtime(msg.time or time.time()) + ) + + # 判断是用户消息还是Bot消息 + msg_user_id = str(msg.user_info.user_id) if msg.user_info else "" + is_bot_message = bot_user_id and msg_user_id == bot_user_id + content = msg.processed_plain_text or msg.display_message or "" + + if is_bot_message: + narrative_parts.append(f"[{timestamp_str}] 你回复:{content}") + else: + sender_name = msg.user_info.user_nickname if msg.user_info else "用户" + narrative_parts.append(f"[{timestamp_str}] {sender_name}说:{content}") + + # 然后,补充 mental_log 中的内心独白(如果有) + if mental_log: + for entry in mental_log[-5:]: # 只取最近5条心理活动 + timestamp_str = time.strftime( + "%Y-%m-%d %H:%M:%S", + time.localtime(entry.timestamp) + ) + + if entry.event_type == MentalLogEventType.BOT_ACTION and entry.thought: + narrative_parts.append(f"[{timestamp_str}] (你的内心:{entry.thought})") + elif entry.event_type == MentalLogEventType.CONTINUOUS_THINKING and entry.thought: + narrative_parts.append(f"[{timestamp_str}] (思绪飘过:{entry.thought})") + + return "\n".join(narrative_parts) + + def _format_available_actions( + self, + available_actions: dict[str, ActionInfo], + ) -> str: + """ + 格式化可用动作列表为提示词块 + + Args: + available_actions: 可用动作字典 {动作名: ActionInfo} + + Returns: + str: 格式化的动作描述文本 + """ + if not available_actions: + # 使用默认的内置动作 + return self._get_default_actions_block() + + action_blocks = [] + + for action_name, action_info in available_actions.items(): + # 构建动作描述 + description = action_info.description or f"执行 {action_name} 动作" + + # 构建参数说明 + params_lines = [] + if action_info.action_parameters: + for param_name, param_desc in action_info.action_parameters.items(): + params_lines.append(f' - `{param_name}`: {param_desc}') + + # 构建使用场景 + require_lines = [] + if action_info.action_require: + for req in action_info.action_require: + require_lines.append(f" - {req}") + + # 组装动作块 + action_block = f"""### `{action_name}` +**描述**: {description}""" + + if params_lines: + action_block += f""" +**参数**: +{chr(10).join(params_lines)}""" + else: + action_block += "\n**参数**: 无" + + if require_lines: + action_block += f""" +**使用场景**: +{chr(10).join(require_lines)}""" + + # 添加示例 + example_params = {} + if action_info.action_parameters: + for param_name, param_desc in action_info.action_parameters.items(): + example_params[param_name] = f"<{param_desc}>" + + import json + params_json = json.dumps(example_params, ensure_ascii=False, indent=2) if example_params else "{}" + action_block += f""" +**示例**: +```json +{{ + "type": "{action_name}", + "reason": "选择这个动作的原因", + {params_json[1:-1] if params_json != '{}' else ''} +}} +```""" + + action_blocks.append(action_block) + + return "\n\n".join(action_blocks) + + def _get_default_actions_block(self) -> str: + """获取默认的内置动作描述块""" + return """### `reply` +**描述**: 发送文字回复给用户 +**参数**: + - `content`: 回复的文字内容(必须) +**示例**: +```json +{"type": "reply", "content": "你好呀!今天过得怎么样?"} +``` + +### `poke_user` +**描述**: 戳一戳用户,轻量级互动 +**参数**: 无 +**示例**: +```json +{"type": "poke_user", "reason": "想逗逗他"} +``` + +### `update_internal_state` +**描述**: 更新你的内部情感状态 +**参数**: + - `mood`: 当前心情(如"开心"、"好奇"、"担心"等) + - `mood_intensity`: 心情强度(0.0-1.0) + - `relationship_warmth`: 关系热度(0.0-1.0) + - `impression_of_user`: 对用户的印象描述 + - `anxiety_level`: 焦虑程度(0.0-1.0) + - `engagement_level`: 投入程度(0.0-1.0) +**示例**: +```json +{"type": "update_internal_state", "mood": "开心", "mood_intensity": 0.8} +``` + +### `do_nothing` +**描述**: 明确表示"思考后决定不作回应" +**参数**: 无 +**示例**: +```json +{"type": "do_nothing", "reason": "现在不是说话的好时机"} +```""" + + def generate_system_prompt( + self, + session: KokoroSession, + available_actions: Optional[dict[str, ActionInfo]] = None, + context_data: Optional[dict[str, str]] = None, + chat_stream: Optional["ChatStream"] = None, + ) -> str: + """ + 生成系统提示词 + + V4升级:从 global_config.personality 读取完整人设 + V5超融合:集成S4U所有上下文模块 + + Args: + session: 当前会话 + available_actions: 可用动作字典,如果为None则使用默认动作 + context_data: S4U上下文数据字典(包含relation_info, memory_block等) + chat_stream: 聊天流(用于判断群聊/私聊场景) + + Returns: + str: 系统提示词 + """ + from src.config.config import global_config + from datetime import datetime + + emotional_params = self._format_emotional_state(session.emotional_state) + + # 格式化可用动作 + available_actions_block = self._format_available_actions(available_actions or {}) + + # 从 global_config.personality 读取完整人设 + if global_config is None: + raise RuntimeError("global_config 未初始化") + + personality_cfg = global_config.personality + + # 核心人设 + personality_core = personality_cfg.personality_core or self.persona_description + personality_side = personality_cfg.personality_side or "" + identity = personality_cfg.identity or "" + background_story = personality_cfg.background_story or "" + reply_style = personality_cfg.reply_style or "" + + # 安全规则:转换为格式化字符串 + safety_guidelines = personality_cfg.safety_guidelines or [] + if isinstance(safety_guidelines, list): + safety_guidelines_str = "\n".join(f"- {rule}" for rule in safety_guidelines) + else: + safety_guidelines_str = str(safety_guidelines) + + # 构建当前时间 + current_time = datetime.now().strftime("%Y年%m月%d日 %H:%M:%S") + + # 判断聊天场景 + is_group_chat = False + if chat_stream: + is_group_chat = bool(chat_stream.group_info) + chat_scene = "群聊" if is_group_chat else "私聊" + + # 从context_data提取S4U上下文模块(如果提供) + context_data = context_data or {} + relation_info_block = context_data.get("relation_info", "") + memory_block = context_data.get("memory_block", "") + expression_habits_block = context_data.get("expression_habits", "") + schedule_block = context_data.get("schedule", "") + + # 如果有日程,添加前缀 + if schedule_block: + schedule_block = f"**当前活动**: {schedule_block}" + + return self.SYSTEM_PROMPT_TEMPLATE.format( + personality_core=personality_core, + personality_side=personality_side, + identity=identity, + background_story=background_story, + reply_style=reply_style, + safety_guidelines=safety_guidelines_str, + available_actions_block=available_actions_block, + current_time=current_time, + chat_scene=chat_scene, + relation_info_block=relation_info_block or "(暂无关系信息)", + memory_block=memory_block or "", + expression_habits_block=expression_habits_block or "", + schedule_block=schedule_block, + **emotional_params, + ) + + def generate_responding_prompt( + self, + session: KokoroSession, + message_content: str, + sender_name: str, + sender_id: str, + message_time: Optional[float] = None, + available_actions: Optional[dict[str, ActionInfo]] = None, + context: Optional["StreamContext"] = None, + context_data: Optional[dict[str, str]] = None, + chat_stream: Optional["ChatStream"] = None, + ) -> tuple[str, str]: + """ + 生成回应消息场景的提示词 + + V3 升级:支持从 StreamContext 读取共享的历史消息 + V5 超融合:集成S4U所有上下文模块 + + Args: + session: 当前会话 + message_content: 收到的消息内容 + sender_name: 发送者名称 + sender_id: 发送者ID + message_time: 消息时间戳 + available_actions: 可用动作字典 + context: 聊天流上下文(可选),用于读取共享的历史消息 + context_data: S4U上下文数据字典(包含relation_info, memory_block等) + chat_stream: 聊天流(用于判断群聊/私聊场景) + + Returns: + tuple[str, str]: (系统提示词, 用户提示词) + """ + system_prompt = self.generate_system_prompt( + session, + available_actions, + context_data=context_data, + chat_stream=chat_stream, + ) + + # V3: 优先从 StreamContext 读取历史(与AFC共享同一数据源) + if context: + narrative_history = self._format_history_from_context(context, session.mental_log) + else: + # 回退到仅使用 mental_log(兼容旧调用方式) + narrative_history = self._format_narrative_history(session.mental_log) + + if message_time is None: + message_time = time.time() + + message_time_str = time.strftime( + "%Y-%m-%d %H:%M:%S", + time.localtime(message_time) + ) + + user_prompt = self.RESPONDING_USER_PROMPT_TEMPLATE.format( + narrative_history=narrative_history, + sender_name=sender_name, + sender_id=sender_id, + message_time=message_time_str, + message_content=message_content, + ) + + return system_prompt, user_prompt + + def generate_timeout_decision_prompt( + self, + session: KokoroSession, + available_actions: Optional[dict[str, ActionInfo]] = None, + ) -> tuple[str, str]: + """ + 生成超时决策场景的提示词 + + Args: + session: 当前会话 + available_actions: 可用动作字典 + + Returns: + tuple[str, str]: (系统提示词, 用户提示词) + """ + system_prompt = self.generate_system_prompt(session, available_actions) + + narrative_history = self._format_narrative_history(session.mental_log) + + wait_duration = session.get_waiting_duration() + + user_prompt = self.TIMEOUT_DECISION_USER_PROMPT_TEMPLATE.format( + narrative_history=narrative_history, + wait_duration_seconds=wait_duration, + wait_duration_minutes=wait_duration / 60, + expected_user_reaction=session.expected_user_reaction or "不确定", + last_bot_message=session.last_bot_message or "(没有记录)", + ) + + return system_prompt, user_prompt + + def generate_continuous_thinking_prompt( + self, + session: KokoroSession, + available_actions: Optional[dict[str, ActionInfo]] = None, + ) -> tuple[str, str]: + """ + 生成连续思考场景的提示词 + + Args: + session: 当前会话 + available_actions: 可用动作字典 + + Returns: + tuple[str, str]: (系统提示词, 用户提示词) + """ + system_prompt = self.generate_system_prompt(session, available_actions) + + narrative_history = self._format_narrative_history( + session.mental_log, + max_entries=10 # 连续思考时使用较少的历史 + ) + + wait_duration = session.get_waiting_duration() + + user_prompt = self.CONTINUOUS_THINKING_USER_PROMPT_TEMPLATE.format( + narrative_history=narrative_history, + wait_duration_seconds=wait_duration, + wait_duration_minutes=wait_duration / 60, + max_wait_seconds=session.max_wait_seconds, + expected_user_reaction=session.expected_user_reaction or "不确定", + last_bot_message=session.last_bot_message or "(没有记录)", + ) + + return system_prompt, user_prompt + + def generate_proactive_thinking_prompt( + self, + session: KokoroSession, + trigger_type: str, + trigger_context: str, + available_actions: Optional[dict[str, ActionInfo]] = None, + ) -> tuple[str, str]: + """ + 生成主动思考场景的提示词 + + 这是私聊专属的功能,用于实现"主动找话题、主动关心用户"。 + + Args: + session: 当前会话 + trigger_type: 触发类型(如 silence_timeout, memory_event 等) + trigger_context: 触发上下文描述 + available_actions: 可用动作字典 + + Returns: + tuple[str, str]: (系统提示词, 用户提示词) + """ + system_prompt = self.generate_system_prompt(session, available_actions) + + narrative_history = self._format_narrative_history( + session.mental_log, + max_entries=10, # 主动思考时使用较少的历史 + ) + + user_prompt = self.PROACTIVE_THINKING_USER_PROMPT_TEMPLATE.format( + narrative_history=narrative_history, + trigger_type=trigger_type, + trigger_context=trigger_context, + ) + + return system_prompt, user_prompt + + def build_messages_for_llm( + self, + system_prompt: str, + user_prompt: str, + stream_id: str = "", + ) -> list[dict[str, str]]: + """ + 构建LLM请求的消息列表 + + Args: + system_prompt: 系统提示词 + user_prompt: 用户提示词 + stream_id: 聊天流ID(用于日志) + + Returns: + list[dict]: 消息列表 + """ + # INFO日志:打印完整的KFC提示词(可观测性增强) + full_prompt = f"[SYSTEM]\n{system_prompt}\n\n[USER]\n{user_prompt}" + logger.info( + f"Final KFC prompt constructed for stream {stream_id}:\n" + f"--- PROMPT START ---\n" + f"{full_prompt}\n" + f"--- PROMPT END ---" + ) + + return [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ] + + +# 全局提示词生成器实例 +_prompt_generator: Optional[PromptGenerator] = None + + +def get_prompt_generator(persona_description: str = "") -> PromptGenerator: + """获取全局提示词生成器实例""" + global _prompt_generator + if _prompt_generator is None: + _prompt_generator = PromptGenerator(persona_description) + return _prompt_generator + + +def set_prompt_generator_persona(persona_description: str) -> None: + """设置全局提示词生成器的人设""" + generator = get_prompt_generator() + generator.set_persona(persona_description) diff --git a/src/plugins/built_in/kokoro_flow_chatter/response_post_processor.py b/src/plugins/built_in/kokoro_flow_chatter/response_post_processor.py new file mode 100644 index 000000000..e463d696b --- /dev/null +++ b/src/plugins/built_in/kokoro_flow_chatter/response_post_processor.py @@ -0,0 +1,169 @@ +""" +KFC 响应后处理器 + +实现与全局后处理流程的集成: +- 中文错别字生成(typo_generator) +- 消息分割(punctuation/llm模式) + +设计理念:复用全局配置和AFC的核心分割逻辑,与AFC保持一致的后处理行为。 +""" + +import re +from typing import Any, Optional, TYPE_CHECKING + +from src.common.logger import get_logger +from src.config.config import global_config + +if TYPE_CHECKING: + from src.chat.utils.typo_generator import ChineseTypoGenerator + +logger = get_logger("kokoro_post_processor") + +# 延迟导入错别字生成器(避免循环导入和启动时的额外开销) +_typo_generator: Optional["ChineseTypoGenerator"] = None + + +def _get_typo_generator(): + """延迟加载错别字生成器""" + global _typo_generator + if _typo_generator is None: + try: + from src.chat.utils.typo_generator import ChineseTypoGenerator + + if global_config is None: + logger.warning("[KFC PostProcessor] global_config 未初始化") + return None + + # 从全局配置读取参数 + typo_cfg = global_config.chinese_typo + _typo_generator = ChineseTypoGenerator( + error_rate=typo_cfg.error_rate, + min_freq=typo_cfg.min_freq, + tone_error_rate=typo_cfg.tone_error_rate, + word_replace_rate=typo_cfg.word_replace_rate, + ) + logger.info("[KFC PostProcessor] 错别字生成器已初始化") + except Exception as e: + logger.warning(f"[KFC PostProcessor] 初始化错别字生成器失败: {e}") + _typo_generator = None + return _typo_generator + + +def split_by_punctuation(text: str, max_length: int = 256, max_sentences: int = 8) -> list[str]: + """ + 基于标点符号分割消息 - 复用AFC的核心逻辑 + + V6修复: 不再依赖长度判断,而是直接调用AFC的分割函数 + + Args: + text: 原始文本 + max_length: 单条消息最大长度(用于二次合并过长片段) + max_sentences: 最大句子数 + + Returns: + list[str]: 分割后的消息列表 + """ + if not text: + return [] + + # 直接复用AFC的核心分割逻辑 + from src.chat.utils.utils import split_into_sentences_w_remove_punctuation + + # AFC的分割函数会根据标点分割并概率性合并 + sentences = split_into_sentences_w_remove_punctuation(text) + + if not sentences: + return [text] if text else [] + + # 限制句子数量 + if len(sentences) > max_sentences: + sentences = sentences[:max_sentences] + + # 如果某个片段超长,进行二次切分 + result = [] + for sentence in sentences: + if len(sentence) > max_length: + # 超长片段按max_length硬切分 + for i in range(0, len(sentence), max_length): + chunk = sentence[i:i + max_length] + if chunk.strip(): + result.append(chunk.strip()) + else: + if sentence.strip(): + result.append(sentence.strip()) + + return result if result else [text] + + +async def process_reply_content(content: str) -> list[str]: + """ + 处理回复内容(主入口) + + 遵循全局配置: + - [response_post_process].enable_response_post_process + - [chinese_typo].enable + - [response_splitter].enable 和 .split_mode + + Args: + content: 原始回复内容 + + Returns: + list[str]: 处理后的消息列表(可能被分割成多条) + """ + if not content: + return [] + + if global_config is None: + logger.warning("[KFC PostProcessor] global_config 未初始化,返回原始内容") + return [content] + + # 检查全局开关 + post_process_cfg = global_config.response_post_process + if not post_process_cfg.enable_response_post_process: + logger.info("[KFC PostProcessor] 全局后处理已禁用,返回原始内容") + return [content] + + processed_content = content + + # Step 1: 错别字生成 + typo_cfg = global_config.chinese_typo + if typo_cfg.enable: + try: + typo_gen = _get_typo_generator() + if typo_gen: + processed_content, correction_suggestion = typo_gen.create_typo_sentence(content) + if correction_suggestion: + logger.info(f"[KFC PostProcessor] 生成错别字,建议纠正: {correction_suggestion}") + else: + logger.info("[KFC PostProcessor] 已应用错别字生成") + except Exception as e: + logger.warning(f"[KFC PostProcessor] 错别字生成失败: {e}") + # 失败时使用原内容 + processed_content = content + + # Step 2: 消息分割 + splitter_cfg = global_config.response_splitter + if splitter_cfg.enable: + split_mode = splitter_cfg.split_mode + max_length = splitter_cfg.max_length + max_sentences = splitter_cfg.max_sentence_num + + if split_mode == "punctuation": + # 基于标点符号分割 + result = split_by_punctuation( + processed_content, + max_length=max_length, + max_sentences=max_sentences, + ) + logger.info(f"[KFC PostProcessor] 标点分割完成,分为 {len(result)} 条消息") + return result + elif split_mode == "llm": + # LLM模式:目前暂不支持,回退到不分割 + logger.info("[KFC PostProcessor] LLM分割模式暂不支持,返回完整内容") + return [processed_content] + else: + logger.warning(f"[KFC PostProcessor] 未知分割模式: {split_mode}") + return [processed_content] + else: + # 分割器禁用,返回完整内容 + return [processed_content] diff --git a/src/plugins/built_in/kokoro_flow_chatter/scheduler.py b/src/plugins/built_in/kokoro_flow_chatter/scheduler.py new file mode 100644 index 000000000..c8caf48d7 --- /dev/null +++ b/src/plugins/built_in/kokoro_flow_chatter/scheduler.py @@ -0,0 +1,424 @@ +""" +Kokoro Flow Chatter 后台调度器 + +负责处理等待状态的计时和超时决策,实现"连续体验"的核心功能: +- 定期检查等待中的会话 +- 触发连续思考更新 +- 处理等待超时事件 +""" + +import asyncio +import time +from typing import TYPE_CHECKING, Any, Callable, Coroutine, Optional + +from src.common.logger import get_logger + +from .models import ( + KokoroSession, + MentalLogEntry, + MentalLogEventType, + SessionStatus, +) +from .session_manager import get_session_manager + +if TYPE_CHECKING: + from .chatter import KokoroFlowChatter + +logger = get_logger("kokoro_scheduler") + + +class BackgroundScheduler: + """ + Kokoro Flow Chatter 后台调度器 + + 核心功能: + 1. 定期检查处于WAITING状态的会话 + 2. 在特定时间点触发"连续思考" + 3. 处理等待超时并触发决策 + 4. 管理后台任务的生命周期 + """ + + # 连续思考触发点(等待进度的百分比) + CONTINUOUS_THINKING_TRIGGERS = [0.3, 0.6, 0.85] + + def __init__( + self, + check_interval: float = 10.0, + on_timeout_callback: Optional[Callable[[KokoroSession], Coroutine[Any, Any, None]]] = None, + on_continuous_thinking_callback: Optional[Callable[[KokoroSession], Coroutine[Any, Any, None]]] = None, + ): + """ + 初始化后台调度器 + + Args: + check_interval: 检查间隔(秒) + on_timeout_callback: 超时回调函数 + on_continuous_thinking_callback: 连续思考回调函数 + """ + self.check_interval = check_interval + self.on_timeout_callback = on_timeout_callback + self.on_continuous_thinking_callback = on_continuous_thinking_callback + + self._running = False + self._check_task: Optional[asyncio.Task] = None + self._pending_tasks: set[asyncio.Task] = set() + + # 统计信息 + self._stats = { + "total_checks": 0, + "timeouts_triggered": 0, + "continuous_thinking_triggered": 0, + "last_check_time": 0.0, + } + + logger.info("BackgroundScheduler 初始化完成") + + async def start(self) -> None: + """启动调度器""" + if self._running: + logger.warning("调度器已在运行中") + return + + self._running = True + self._check_task = asyncio.create_task(self._check_loop()) + logger.info("BackgroundScheduler 已启动") + + async def stop(self) -> None: + """停止调度器""" + self._running = False + + # 取消主检查任务 + if self._check_task: + self._check_task.cancel() + try: + await self._check_task + except asyncio.CancelledError: + pass + + # 取消所有待处理任务 + for task in self._pending_tasks: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + self._pending_tasks.clear() + logger.info("BackgroundScheduler 已停止") + + async def _check_loop(self) -> None: + """主检查循环""" + while self._running: + try: + await self._check_waiting_sessions() + self._stats["last_check_time"] = time.time() + self._stats["total_checks"] += 1 + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"检查循环出错: {e}") + + await asyncio.sleep(self.check_interval) + + async def _check_waiting_sessions(self) -> None: + """检查所有等待中的会话""" + session_manager = get_session_manager() + waiting_sessions = await session_manager.get_all_waiting_sessions() + + if not waiting_sessions: + return + + for session in waiting_sessions: + try: + await self._process_waiting_session(session) + except Exception as e: + logger.error(f"处理等待会话 {session.user_id} 时出错: {e}") + + async def _process_waiting_session(self, session: KokoroSession) -> None: + """ + 处理单个等待中的会话 + + Args: + session: 等待中的会话 + """ + if session.status != SessionStatus.WAITING: + return + + if session.waiting_since is None: + return + + wait_duration = session.get_waiting_duration() + max_wait = session.max_wait_seconds + + # 检查是否超时 + if session.is_wait_timeout(): + logger.info(f"会话 {session.user_id} 等待超时,触发决策") + await self._handle_timeout(session) + return + + # 检查是否需要触发连续思考 + wait_progress = wait_duration / max_wait if max_wait > 0 else 0 + + for trigger_point in self.CONTINUOUS_THINKING_TRIGGERS: + # 检查是否刚刚经过这个触发点 + if self._should_trigger_continuous_thinking( + session, + wait_progress, + trigger_point + ): + logger.debug( + f"会话 {session.user_id} 触发连续思考 " + f"(进度: {wait_progress:.1%}, 触发点: {trigger_point:.1%})" + ) + await self._handle_continuous_thinking(session, wait_progress) + break + + def _should_trigger_continuous_thinking( + self, + session: KokoroSession, + current_progress: float, + trigger_point: float, + ) -> bool: + """ + 判断是否应该触发连续思考 + + 逻辑: + - 当前进度刚刚超过触发点 + - 距离上次连续思考有足够间隔 + - 还没有达到该触发点对应的思考次数 + """ + # 已经超过了这个触发点 + if current_progress < trigger_point: + return False + + # 计算当前应该触发的思考次数 + expected_count = sum( + 1 for tp in self.CONTINUOUS_THINKING_TRIGGERS + if current_progress >= tp + ) + + # 如果还没达到预期的思考次数,触发一次 + if session.continuous_thinking_count < expected_count: + # 确保间隔足够(至少30秒) + if session.last_continuous_thinking_at is None: + return True + + time_since_last = time.time() - session.last_continuous_thinking_at + return time_since_last >= 30.0 + + return False + + async def _handle_timeout(self, session: KokoroSession) -> None: + """ + 处理等待超时 + + Args: + session: 超时的会话 + """ + self._stats["timeouts_triggered"] += 1 + + # 更新会话状态 + session.status = SessionStatus.FOLLOW_UP_PENDING + session.emotional_state.anxiety_level = 0.8 # 超时时焦虑程度较高 + + # 添加超时日志 + timeout_entry = MentalLogEntry( + event_type=MentalLogEventType.TIMEOUT_DECISION, + timestamp=time.time(), + thought=f"等了{session.max_wait_seconds}秒了,对方还是没有回复...", + content="等待超时", + emotional_snapshot=session.emotional_state.to_dict(), + ) + session.add_mental_log_entry(timeout_entry) + + # 保存会话状态 + session_manager = get_session_manager() + await session_manager.save_session(session.user_id) + + # 调用超时回调 + if self.on_timeout_callback: + task = asyncio.create_task(self._run_callback_safe( + self.on_timeout_callback, + session, + "timeout" + )) + self._pending_tasks.add(task) + task.add_done_callback(self._pending_tasks.discard) + + async def _handle_continuous_thinking( + self, + session: KokoroSession, + wait_progress: float, + ) -> None: + """ + 处理连续思考 + + Args: + session: 会话 + wait_progress: 等待进度 + """ + self._stats["continuous_thinking_triggered"] += 1 + + # 更新焦虑程度 + session.emotional_state.update_anxiety_over_time( + session.get_waiting_duration(), + session.max_wait_seconds + ) + + # 更新连续思考计数 + session.continuous_thinking_count += 1 + session.last_continuous_thinking_at = time.time() + + # 生成基于进度的内心想法 + thought = self._generate_waiting_thought(session, wait_progress) + + # 添加连续思考日志 + thinking_entry = MentalLogEntry( + event_type=MentalLogEventType.CONTINUOUS_THINKING, + timestamp=time.time(), + thought=thought, + content="", + emotional_snapshot=session.emotional_state.to_dict(), + metadata={"wait_progress": wait_progress}, + ) + session.add_mental_log_entry(thinking_entry) + + # 保存会话状态 + session_manager = get_session_manager() + await session_manager.save_session(session.user_id) + + # 调用连续思考回调(如果需要LLM生成更自然的想法) + if self.on_continuous_thinking_callback: + task = asyncio.create_task(self._run_callback_safe( + self.on_continuous_thinking_callback, + session, + "continuous_thinking" + )) + self._pending_tasks.add(task) + task.add_done_callback(self._pending_tasks.discard) + + def _generate_waiting_thought( + self, + session: KokoroSession, + wait_progress: float, + ) -> str: + """ + 生成等待中的内心想法(简单版本,不调用LLM) + + Args: + session: 会话 + wait_progress: 等待进度 + + Returns: + str: 内心想法 + """ + wait_seconds = session.get_waiting_duration() + wait_minutes = wait_seconds / 60 + + if wait_progress < 0.4: + thoughts = [ + f"已经等了{wait_minutes:.1f}分钟了,对方可能在忙吧...", + f"嗯...{wait_minutes:.1f}分钟过去了,不知道对方在做什么", + "对方好像还没看到消息,再等等吧", + ] + elif wait_progress < 0.7: + thoughts = [ + f"等了{wait_minutes:.1f}分钟了,有点担心对方是不是不想回了", + f"{wait_minutes:.1f}分钟了,对方可能真的很忙?", + "时间过得好慢啊...不知道对方什么时候会回复", + ] + else: + thoughts = [ + f"已经等了{wait_minutes:.1f}分钟了,感觉有点焦虑...", + f"快{wait_minutes:.0f}分钟了,对方是不是忘记回复了?", + "等了这么久,要不要主动说点什么呢...", + ] + + import random + return random.choice(thoughts) + + async def _run_callback_safe( + self, + callback: Callable[[KokoroSession], Coroutine[Any, Any, None]], + session: KokoroSession, + callback_type: str, + ) -> None: + """安全地运行回调函数""" + try: + await callback(session) + except Exception as e: + logger.error(f"执行{callback_type}回调时出错 (user={session.user_id}): {e}") + + def set_timeout_callback( + self, + callback: Callable[[KokoroSession], Coroutine[Any, Any, None]], + ) -> None: + """设置超时回调函数""" + self.on_timeout_callback = callback + + def set_continuous_thinking_callback( + self, + callback: Callable[[KokoroSession], Coroutine[Any, Any, None]], + ) -> None: + """设置连续思考回调函数""" + self.on_continuous_thinking_callback = callback + + def get_stats(self) -> dict[str, Any]: + """获取统计信息""" + return { + **self._stats, + "is_running": self._running, + "pending_tasks": len(self._pending_tasks), + "check_interval": self.check_interval, + } + + @property + def is_running(self) -> bool: + """调度器是否正在运行""" + return self._running + + +# 全局调度器实例 +_scheduler: Optional[BackgroundScheduler] = None + + +def get_scheduler() -> BackgroundScheduler: + """获取全局调度器实例""" + global _scheduler + if _scheduler is None: + _scheduler = BackgroundScheduler() + return _scheduler + + +async def initialize_scheduler( + check_interval: float = 10.0, + on_timeout_callback: Optional[Callable[[KokoroSession], Coroutine[Any, Any, None]]] = None, + on_continuous_thinking_callback: Optional[Callable[[KokoroSession], Coroutine[Any, Any, None]]] = None, +) -> BackgroundScheduler: + """ + 初始化并启动调度器 + + Args: + check_interval: 检查间隔 + on_timeout_callback: 超时回调 + on_continuous_thinking_callback: 连续思考回调 + + Returns: + BackgroundScheduler: 调度器实例 + """ + global _scheduler + _scheduler = BackgroundScheduler( + check_interval=check_interval, + on_timeout_callback=on_timeout_callback, + on_continuous_thinking_callback=on_continuous_thinking_callback, + ) + await _scheduler.start() + return _scheduler + + +async def shutdown_scheduler() -> None: + """关闭调度器""" + global _scheduler + if _scheduler: + await _scheduler.stop() + _scheduler = None diff --git a/src/plugins/built_in/kokoro_flow_chatter/session_manager.py b/src/plugins/built_in/kokoro_flow_chatter/session_manager.py new file mode 100644 index 000000000..7884f6b6a --- /dev/null +++ b/src/plugins/built_in/kokoro_flow_chatter/session_manager.py @@ -0,0 +1,490 @@ +""" +Kokoro Flow Chatter 会话管理器 + +负责管理用户会话的完整生命周期: +- 创建、加载、保存会话 +- 会话状态持久化 +- 会话清理和维护 +""" + +import asyncio +import json +import os +import time +from pathlib import Path +from typing import Optional + +from src.common.logger import get_logger + +from .models import ( + EmotionalState, + KokoroSession, + MentalLogEntry, + MentalLogEventType, + SessionStatus, +) + +logger = get_logger("kokoro_session_manager") + + +class SessionManager: + """ + Kokoro Flow Chatter 会话管理器 + + 单例模式实现,为每个私聊用户维护独立的会话 + + Features: + - 会话的创建、获取、更新和删除 + - 自动持久化到JSON文件 + - 会话过期清理 + - 线程安全的并发访问 + """ + + _instance: Optional["SessionManager"] = None + _lock = asyncio.Lock() + + def __new__(cls, *args, **kwargs): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__( + self, + data_dir: str = "data/kokoro_flow_chatter/sessions", + max_session_age_days: int = 30, + auto_save_interval: int = 300, + ): + """ + 初始化会话管理器 + + Args: + data_dir: 会话数据存储目录 + max_session_age_days: 会话最大保留天数 + auto_save_interval: 自动保存间隔(秒) + """ + # 避免重复初始化 + if hasattr(self, "_initialized") and self._initialized: + return + + self._initialized = True + self.data_dir = Path(data_dir) + self.max_session_age_days = max_session_age_days + self.auto_save_interval = auto_save_interval + + # 内存中的会话缓存 + self._sessions: dict[str, KokoroSession] = {} + self._session_locks: dict[str, asyncio.Lock] = {} + + # 后台任务 + self._auto_save_task: Optional[asyncio.Task] = None + self._cleanup_task: Optional[asyncio.Task] = None + self._running = False + + # 确保数据目录存在 + self._ensure_data_dir() + + logger.info(f"SessionManager 初始化完成,数据目录: {self.data_dir}") + + def _ensure_data_dir(self) -> None: + """确保数据目录存在""" + self.data_dir.mkdir(parents=True, exist_ok=True) + + def _get_session_file_path(self, user_id: str) -> Path: + """获取会话文件路径""" + # 清理user_id中的特殊字符 + safe_user_id = "".join(c if c.isalnum() or c in "-_" else "_" for c in user_id) + return self.data_dir / f"{safe_user_id}.json" + + async def _get_session_lock(self, user_id: str) -> asyncio.Lock: + """获取会话级别的锁""" + if user_id not in self._session_locks: + self._session_locks[user_id] = asyncio.Lock() + return self._session_locks[user_id] + + async def start(self) -> None: + """启动会话管理器的后台任务""" + if self._running: + return + + self._running = True + + # 启动自动保存任务 + self._auto_save_task = asyncio.create_task(self._auto_save_loop()) + + # 启动清理任务 + self._cleanup_task = asyncio.create_task(self._cleanup_loop()) + + logger.info("SessionManager 后台任务已启动") + + async def stop(self) -> None: + """停止会话管理器并保存所有会话""" + self._running = False + + # 取消后台任务 + if self._auto_save_task: + self._auto_save_task.cancel() + try: + await self._auto_save_task + except asyncio.CancelledError: + pass + + if self._cleanup_task: + self._cleanup_task.cancel() + try: + await self._cleanup_task + except asyncio.CancelledError: + pass + + # 保存所有会话 + await self.save_all_sessions() + + logger.info("SessionManager 已停止,所有会话已保存") + + async def _auto_save_loop(self) -> None: + """自动保存循环""" + while self._running: + try: + await asyncio.sleep(self.auto_save_interval) + await self.save_all_sessions() + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"自动保存会话时出错: {e}") + + async def _cleanup_loop(self) -> None: + """清理过期会话循环""" + while self._running: + try: + # 每小时清理一次 + await asyncio.sleep(3600) + await self.cleanup_expired_sessions() + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"清理过期会话时出错: {e}") + + async def get_session(self, user_id: str, stream_id: str) -> KokoroSession: + """ + 获取或创建用户会话 + + Args: + user_id: 用户ID + stream_id: 聊天流ID + + Returns: + KokoroSession: 用户会话对象 + """ + lock = await self._get_session_lock(user_id) + async with lock: + # 检查内存缓存 + if user_id in self._sessions: + session = self._sessions[user_id] + # 更新stream_id(可能发生变化) + session.stream_id = stream_id + return session + + # 尝试从文件加载 + session = await self._load_session_from_file(user_id) + if session: + session.stream_id = stream_id + self._sessions[user_id] = session + logger.debug(f"从文件加载会话: {user_id}") + return session + + # 创建新会话 + session = KokoroSession( + user_id=user_id, + stream_id=stream_id, + status=SessionStatus.IDLE, + emotional_state=EmotionalState(), + mental_log=[], + ) + + # 添加初始日志条目 + initial_entry = MentalLogEntry( + event_type=MentalLogEventType.STATE_CHANGE, + timestamp=time.time(), + thought="与这位用户的对话开始了,我对接下来的交流充满期待。", + content="会话创建", + emotional_snapshot=session.emotional_state.to_dict(), + ) + session.add_mental_log_entry(initial_entry) + + self._sessions[user_id] = session + logger.info(f"创建新会话: {user_id}") + + return session + + async def _load_session_from_file(self, user_id: str) -> Optional[KokoroSession]: + """从文件加载会话""" + file_path = self._get_session_file_path(user_id) + + if not file_path.exists(): + return None + + try: + with open(file_path, "r", encoding="utf-8") as f: + data = json.load(f) + + session = KokoroSession.from_dict(data) + logger.debug(f"成功从文件加载会话: {user_id}") + return session + + except json.JSONDecodeError as e: + logger.error(f"解析会话文件失败 {user_id}: {e}") + # 备份损坏的文件 + backup_path = file_path.with_suffix(".json.bak") + os.rename(file_path, backup_path) + return None + except Exception as e: + logger.error(f"加载会话文件失败 {user_id}: {e}") + return None + + async def save_session(self, user_id: str) -> bool: + """ + 保存单个会话到文件 + + Args: + user_id: 用户ID + + Returns: + bool: 是否保存成功 + """ + lock = await self._get_session_lock(user_id) + async with lock: + if user_id not in self._sessions: + return False + + session = self._sessions[user_id] + file_path = self._get_session_file_path(user_id) + + try: + data = session.to_dict() + + # 先写入临时文件,再重命名(原子操作) + temp_path = file_path.with_suffix(".json.tmp") + with open(temp_path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + os.replace(temp_path, file_path) + logger.debug(f"保存会话成功: {user_id}") + return True + + except Exception as e: + logger.error(f"保存会话失败 {user_id}: {e}") + return False + + async def save_all_sessions(self) -> int: + """ + 保存所有会话 + + Returns: + int: 成功保存的会话数量 + """ + saved_count = 0 + for user_id in list(self._sessions.keys()): + if await self.save_session(user_id): + saved_count += 1 + + if saved_count > 0: + logger.debug(f"批量保存完成,共保存 {saved_count} 个会话") + + return saved_count + + async def update_session( + self, + user_id: str, + status: Optional[SessionStatus] = None, + emotional_state: Optional[EmotionalState] = None, + mental_log_entry: Optional[MentalLogEntry] = None, + **kwargs, + ) -> bool: + """ + 更新会话状态 + + Args: + user_id: 用户ID + status: 新的会话状态 + emotional_state: 新的情感状态 + mental_log_entry: 要添加的心理日志条目 + **kwargs: 其他要更新的字段 + + Returns: + bool: 是否更新成功 + """ + lock = await self._get_session_lock(user_id) + async with lock: + if user_id not in self._sessions: + return False + + session = self._sessions[user_id] + + if status is not None: + old_status = session.status + session.status = status + logger.debug(f"会话状态变更 {user_id}: {old_status} -> {status}") + + if emotional_state is not None: + session.emotional_state = emotional_state + + if mental_log_entry is not None: + session.add_mental_log_entry(mental_log_entry) + + # 更新其他字段 + for key, value in kwargs.items(): + if hasattr(session, key): + setattr(session, key, value) + + session.last_activity_at = time.time() + + return True + + async def delete_session(self, user_id: str) -> bool: + """ + 删除会话 + + Args: + user_id: 用户ID + + Returns: + bool: 是否删除成功 + """ + lock = await self._get_session_lock(user_id) + async with lock: + # 从内存中删除 + if user_id in self._sessions: + del self._sessions[user_id] + + # 从文件系统删除 + file_path = self._get_session_file_path(user_id) + if file_path.exists(): + try: + os.remove(file_path) + logger.info(f"删除会话: {user_id}") + return True + except Exception as e: + logger.error(f"删除会话文件失败 {user_id}: {e}") + return False + + return True + + async def cleanup_expired_sessions(self) -> int: + """ + 清理过期会话 + + Returns: + int: 清理的会话数量 + """ + cleaned_count = 0 + current_time = time.time() + max_age_seconds = self.max_session_age_days * 24 * 3600 + + # 检查文件系统中的所有会话 + for file_path in self.data_dir.glob("*.json"): + try: + with open(file_path, "r", encoding="utf-8") as f: + data = json.load(f) + + last_activity = data.get("last_activity_at", 0) + if current_time - last_activity > max_age_seconds: + user_id = data.get("user_id", file_path.stem) + + # 从内存中删除 + if user_id in self._sessions: + del self._sessions[user_id] + + # 删除文件 + os.remove(file_path) + cleaned_count += 1 + logger.info(f"清理过期会话: {user_id}") + + except Exception as e: + logger.error(f"清理会话时出错 {file_path}: {e}") + + if cleaned_count > 0: + logger.info(f"共清理 {cleaned_count} 个过期会话") + + return cleaned_count + + async def get_all_waiting_sessions(self) -> list[KokoroSession]: + """ + 获取所有处于等待状态的会话 + + Returns: + list[KokoroSession]: 等待中的会话列表 + """ + waiting_sessions = [] + + for session in self._sessions.values(): + if session.status == SessionStatus.WAITING: + waiting_sessions.append(session) + + return waiting_sessions + + async def get_session_statistics(self) -> dict: + """ + 获取会话统计信息 + + Returns: + dict: 统计信息字典 + """ + total_in_memory = len(self._sessions) + status_counts = {} + + for session in self._sessions.values(): + status = str(session.status) + status_counts[status] = status_counts.get(status, 0) + 1 + + # 统计文件系统中的会话 + total_on_disk = len(list(self.data_dir.glob("*.json"))) + + return { + "total_in_memory": total_in_memory, + "total_on_disk": total_on_disk, + "status_counts": status_counts, + "data_directory": str(self.data_dir), + } + + def get_session_sync(self, user_id: str) -> Optional[KokoroSession]: + """ + 同步获取会话(仅从内存缓存) + + Args: + user_id: 用户ID + + Returns: + Optional[KokoroSession]: 会话对象,如果不存在返回None + """ + return self._sessions.get(user_id) + + +# 全局会话管理器实例 +_session_manager: Optional[SessionManager] = None + + +def get_session_manager() -> SessionManager: + """获取全局会话管理器实例""" + global _session_manager + if _session_manager is None: + _session_manager = SessionManager() + return _session_manager + + +async def initialize_session_manager( + data_dir: str = "data/kokoro_flow_chatter/sessions", + **kwargs, +) -> SessionManager: + """ + 初始化并启动会话管理器 + + Args: + data_dir: 数据存储目录 + **kwargs: 其他配置参数 + + Returns: + SessionManager: 会话管理器实例 + """ + global _session_manager + _session_manager = SessionManager(data_dir=data_dir, **kwargs) + await _session_manager.start() + return _session_manager diff --git a/template/bot_config_template.toml b/template/bot_config_template.toml index d7b24eaef..d0ea7e5e4 100644 --- a/template/bot_config_template.toml +++ b/template/bot_config_template.toml @@ -1,5 +1,5 @@ [inner] -version = "7.9.0" +version = "7.9.2" #----以下是给开发人员阅读的,如果你只是部署了MoFox-Bot,不需要阅读---- #如果你想要修改配置文件,请递增version的值 @@ -590,53 +590,44 @@ strong_mention_interest_score = 2.0 # 强提及的兴趣分(被@、被回复 weak_mention_interest_score = 0.8 # 弱提及的兴趣分(文本匹配bot名字或别名) base_relationship_score = 0.3 # 基础人物关系分 -[proactive_thinking] # 主动思考(主动发起对话)功能配置 +[proactive_thinking] # 主动思考(主动发起对话)功能配置 - 用于群聊和私聊(当KFC关闭时) # 详细配置说明请参考:docs/proactive_thinking_config_guide.md # --- 总开关 --- enable = true # 是否启用主动发起对话功能 # --- 间隔配置 --- -base_interval = 720 # 基础触发间隔(秒),默认12分钟 -min_interval = 360 # 最小触发间隔(秒),默认6分钟 -max_interval = 2880 # 最大触发间隔(秒),默认48分钟 +base_interval = 1800 # 基础触发间隔(秒),默认30分钟 +min_interval = 600 # 最小触发间隔(秒),默认10分钟。兴趣分数高时会接近此值 +max_interval = 7200 # 最大触发间隔(秒),默认2小时。兴趣分数低时会接近此值 -# 动态调整配置 -use_interest_score = true # 是否根据兴趣分数动态调整间隔 -interest_score_factor = 2.0 # 兴趣分数影响因子(1.0-3.0) -# 公式: interval = base_interval * (interest_score_factor - interest_score) -# 例如: interest_score=0.8, factor=2.0 -> interval = 1800 * 1.2 = 2160秒(36分钟) +# --- 动态调整配置 --- +use_interest_score = true # 是否根据兴趣分数动态调整间隔。关闭则使用固定base_interval +interest_score_factor = 2.0 # 兴趣分数影响因子。公式: interval = base * (factor - score) # --- 黑白名单配置 --- -whitelist_mode = false # 是否启用白名单模式(启用后只对白名单中的聊天流生效) -blacklist_mode = false # 是否启用黑名单模式(启用后排除黑名单中的聊天流) +whitelist_mode = false # 是否启用白名单模式。启用后只对白名单中的聊天流生效 +blacklist_mode = false # 是否启用黑名单模式。启用后排除黑名单中的聊天流 -# 白名单配置(示例格式) whitelist_private = [] # 私聊白名单,格式: ["qq:12345:private"] whitelist_group = [] # 群聊白名单,格式: ["qq:123456:group"] - -# 黑名单配置(示例格式) blacklist_private = [] # 私聊黑名单,格式: ["qq:12345:private"] blacklist_group = [] # 群聊黑名单,格式: ["qq:999999:group"] -# --- 作用范围 --- -enable_in_private = true # 是否允许在私聊中主动发起对话 -enable_in_group = true # 是否允许在群聊中主动发起对话 - # --- 兴趣分数阈值 --- min_interest_score = 0.0 # 最低兴趣分数阈值,低于此值不会主动思考 max_interest_score = 1.0 # 最高兴趣分数阈值,高于此值不会主动思考 # --- 时间策略配置 --- -enable_time_strategy = true # 是否启用时间策略(根据时段调整频率) +enable_time_strategy = false # 是否启用时间策略(根据时段调整频率) quiet_hours_start = "00:00" # 安静时段开始时间,格式: "HH:MM" quiet_hours_end = "07:00" # 安静时段结束时间,格式: "HH:MM" active_hours_multiplier = 0.7 # 活跃时段间隔倍数,<1表示更频繁,>1表示更稀疏 # --- 冷却与限制 --- -reply_reset_enabled = true # bot回复后是否重置定时器(避免回复后立即又主动发言) -topic_throw_cooldown = 3600 # 主动发言后的冷却时间(秒),期间暂停主动思考,等待用户回复。0表示不暂停,继续主动思考 -max_daily_proactive = 3 # 每个聊天流每天最多主动发言次数,0表示不限制 +reply_reset_enabled = true # bot回复后是否重置定时器 +topic_throw_cooldown = 3600 # 抛出话题后的冷却时间(秒) +max_daily_proactive = 0 # 每个聊天流每天最多主动发言次数,0表示不限制 # --- 决策权重配置 --- do_nothing_weight = 0.4 # do_nothing动作的基础权重 @@ -644,5 +635,38 @@ simple_bubble_weight = 0.3 # simple_bubble动作的基础权重 throw_topic_weight = 0.3 # throw_topic动作的基础权重 # --- 调试与监控 --- -enable_statistics = false # 是否启用统计功能(记录触发次数、决策分布等) -log_decisions = false # 是否记录每次决策的详细日志(用于调试) +enable_statistics = true # 是否启用统计功能 +log_decisions = false # 是否记录每次决策的详细日志 + +# ==================== Kokoro Flow Chatter (心流聊天器) 配置 ==================== +# KFC是专为私聊设计的深度情感交互处理器。 +# 注意:这是一个可选的聊天模式,关闭后私聊将由默认的AFC处理(使用上面的proactive_thinking配置)。 +# 核心理念:KFC不是独立人格,它复用全局的人设、情感框架和回复模型。 + +[kokoro_flow_chatter] +# --- 总开关 --- +# 开启后,KFC将接管所有私聊消息;关闭后,私聊消息将由AFC处理。 +enable = true + +# --- 核心行为配置 --- +max_wait_seconds_default = 300 # 默认的最大等待秒数(AI发送消息后愿意等待用户回复的时间) +enable_continuous_thinking = true # 是否在等待期间启用心理活动更新 + +# --- 私聊专属主动思考配置 --- +# 注意:这是KFC专属的主动思考配置,只有当KFC启用时才生效。 +# 它旨在模拟更真实、情感驱动的互动,而非简单的定时任务。 +[kokoro_flow_chatter.proactive_thinking] +enabled = true # 是否启用KFC的私聊主动思考。 + +# 1. 沉默触发器:当感到长久的沉默时,她可能会想说些什么。 +silence_threshold_seconds = 7200 # 用户沉默超过此时长(秒),可能触发主动思考(默认2小时)。 + +# 2. 关系门槛:她不会对不熟悉的人过于主动。 +min_affinity_for_proactive = 0.3 # 需要达到最低好感度,她才会开始主动关心。 + +# 3. 频率呼吸:为了避免打扰,她的关心总是有间隔的。 +min_interval_between_proactive = 1800 # 两次主动思考之间的最小间隔(秒,默认30分钟)。 + +# 4. 自然问候:在特定的时间,她会像朋友一样送上问候。 +enable_morning_greeting = true # 是否启用早安问候 (例如: 8:00 - 9:00)。 +enable_night_greeting = true # 是否启用晚安问候 (例如: 22:00 - 23:00)。 From c4583e61d1350c6cfe77fd6c33031e8eab709135 Mon Sep 17 00:00:00 2001 From: tt-P607 <68868379+tt-P607@users.noreply.github.com> Date: Sat, 29 Nov 2025 14:35:01 +0800 Subject: [PATCH 02/28] =?UTF-8?q?refactor(KFC):=20=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=E5=8C=96=E6=8F=90=E7=A4=BA=E7=94=9F=E6=88=90=E5=B9=B6=E7=AE=80?= =?UTF-8?q?=E5=8C=96=E6=83=85=E7=BB=AA=E7=8A=B6=E6=80=81=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 此提交对 Kokoro Flow Chatter (KFC) 插件进行了重大重构,以提高模块化、可维护性和可靠性。 主要更改包括: - **提示生成**:原本的单一 `generate_system_prompt` 方法现在被委托给新的 `prompt_modules.py`。这实现了关注点分离,使管理提示的不同部分(如个性、上下文和动作定义)更加容易。 - **情绪状态处理**:已移除 `_update_emotional_state_from_thought` 中复杂且不可靠的基于关键词的情感分析。系统现在依赖 LLM 的显式 `update_internal_state` 动作,以更直接和准确地更新情绪状态。该方法已简化,仅处理轻微的参与度调整。 - **JSON 解析**:用统一的 `extract_and_parse_json` 工具替换了自定义 JSON 提取逻辑。这提供了更强大的解析能力,处理更大的Markdown 代码块和自动修复格式错误的 JSON。 - **调度器抽象**:引入了 `KFCSchedulerAdapter`,将聊天组件与全局调度器实现解耦,提高了可测试性和清晰度。 - **优雅的对话结束**:系统现在可以正确处理 `max_wait_seconds: 0`,立即结束对话主题并将会话设置为空闲状态,避免不必要的等待时间。 --- .../kokoro_flow_chatter/action_executor.py | 188 ++------ .../built_in/kokoro_flow_chatter/chatter.py | 35 +- .../kfc_scheduler_adapter.py | 394 +++++++++++++++++ .../kokoro_flow_chatter/prompt_generator.py | 77 +--- .../kokoro_flow_chatter/prompt_modules.py | 414 ++++++++++++++++++ 5 files changed, 870 insertions(+), 238 deletions(-) create mode 100644 src/plugins/built_in/kokoro_flow_chatter/kfc_scheduler_adapter.py create mode 100644 src/plugins/built_in/kokoro_flow_chatter/prompt_modules.py diff --git a/src/plugins/built_in/kokoro_flow_chatter/action_executor.py b/src/plugins/built_in/kokoro_flow_chatter/action_executor.py index 69f250ae6..78e9008d5 100644 --- a/src/plugins/built_in/kokoro_flow_chatter/action_executor.py +++ b/src/plugins/built_in/kokoro_flow_chatter/action_executor.py @@ -15,14 +15,15 @@ V5升级要点: """ import asyncio -import json -import re import time from typing import TYPE_CHECKING, Any, Optional +import orjson + from src.chat.planner_actions.action_manager import ChatterActionManager from src.common.logger import get_logger from src.plugin_system.base.component_types import ActionInfo +from src.utils.json_parser import extract_and_parse_json from .models import ( ActionModel, @@ -38,33 +39,6 @@ if TYPE_CHECKING: logger = get_logger("kokoro_action_executor") -# ========== 情感分析关键词映射 ========== -# 正面情感关键词(提升mood_intensity和engagement_level) -POSITIVE_KEYWORDS = [ - "开心", "高兴", "快乐", "喜欢", "爱", "好奇", "期待", "惊喜", - "感动", "温暖", "舒服", "放松", "满足", "欣慰", "感激", "兴奋", - "有趣", "好玩", "可爱", "太棒了", "哈哈", "嘻嘻", "hiahia", -] - -# 负面情感关键词(降低mood_intensity,可能提升anxiety_level) -NEGATIVE_KEYWORDS = [ - "难过", "伤心", "失望", "沮丧", "担心", "焦虑", "害怕", "紧张", - "生气", "烦躁", "无聊", "疲惫", "累", "困", "不开心", "不高兴", - "委屈", "尴尬", "迷茫", "困惑", "郁闷", -] - -# 亲密关键词(提升relationship_warmth) -INTIMATE_KEYWORDS = [ - "喜欢你", "想你", "想念", "在乎", "关心", "信任", "依赖", - "亲爱的", "宝贝", "朋友", "陪伴", "一起", "我们", -] - -# 疏远关键词(降低relationship_warmth) -DISTANT_KEYWORDS = [ - "讨厌", "烦人", "无聊", "不想理", "走开", "别烦", - "算了", "随便", "不在乎", -] - class ActionExecutor: """ @@ -146,75 +120,25 @@ class ActionExecutor: """ 解析LLM的JSON响应 + 使用统一的json_parser工具进行解析,自动处理: + - Markdown代码块标记 + - 格式错误的JSON修复(json_repair) + - 多种包装格式 + Args: response_text: LLM返回的原始文本 Returns: LLMResponseModel: 解析后的响应模型 - - Raises: - ValueError: 如果解析失败 """ - # 尝试提取JSON块 - json_str = self._extract_json(response_text) + # 使用统一的json_parser工具解析 + data = extract_and_parse_json(response_text, strict=False) - if not json_str: - logger.warning(f"无法从LLM响应中提取JSON: {response_text[:200]}...") + if not data or not isinstance(data, dict): + logger.warning(f"无法从LLM响应中提取有效JSON: {response_text[:200]}...") return LLMResponseModel.create_error_response("无法解析响应格式") - try: - data = json.loads(json_str) - return self._validate_and_create_response(data) - except json.JSONDecodeError as e: - logger.error(f"JSON解析失败: {e}, 原始文本: {json_str[:200]}...") - return LLMResponseModel.create_error_response(f"JSON解析错误: {e}") - - def _extract_json(self, text: str) -> Optional[str]: - """ - 从文本中提取JSON块 - - 支持以下格式: - 1. 纯JSON字符串 - 2. ```json ... ``` 代码块 - 3. 文本中嵌入的JSON对象 - """ - text = text.strip() - - # 尝试1:直接解析(如果整个文本就是JSON) - if text.startswith("{"): - # 找到匹配的结束括号 - brace_count = 0 - for i, char in enumerate(text): - if char == "{": - brace_count += 1 - elif char == "}": - brace_count -= 1 - if brace_count == 0: - return text[:i + 1] - - # 尝试2:提取 ```json ... ``` 代码块 - json_block_pattern = r"```(?:json)?\s*([\s\S]*?)```" - matches = re.findall(json_block_pattern, text) - for match in matches: - match = match.strip() - if match.startswith("{"): - try: - json.loads(match) - return match - except json.JSONDecodeError: - continue - - # 尝试3:寻找文本中的JSON对象 - json_pattern = r"\{[\s\S]*\}" - matches = re.findall(json_pattern, text) - for match in matches: - try: - json.loads(match) - return match - except json.JSONDecodeError: - continue - - return None + return self._validate_and_create_response(data) def _validate_and_create_response(self, data: dict[str, Any]) -> LLMResponseModel: """ @@ -239,10 +163,11 @@ class ActionExecutor: data["max_wait_seconds"] = 300 logger.warning("LLM响应缺少'max_wait_seconds'字段,使用默认值300") else: - # 确保在合理范围内 + # 确保在合理范围内:0-900秒 + # 0 表示不等待(话题结束/用户说再见等) try: wait_seconds = int(data["max_wait_seconds"]) - data["max_wait_seconds"] = max(60, min(wait_seconds, 600)) + data["max_wait_seconds"] = max(0, min(wait_seconds, 900)) except (ValueError, TypeError): data["max_wait_seconds"] = 300 @@ -706,12 +631,13 @@ class ActionExecutor: session: KokoroSession, ) -> None: """ - V5:根据thought字段的情感倾向动态更新EmotionalState + 根据thought字段更新EmotionalState - 分析策略: - 1. 统计正面/负面/亲密/疏远关键词出现次数 - 2. 根据关键词比例计算情感调整值 - 3. 应用平滑的情感微调(每次变化不超过±0.1) + V6重构: + - 移除基于关键词的情感分析(诡异且不准确) + - 情感状态现在主要通过LLM输出的update_internal_state动作更新 + - 关系温度应该从person_info/relationship_manager的好感度系统读取 + - 此方法仅做简单的engagement_level更新 Args: thought: LLM返回的内心独白 @@ -720,76 +646,16 @@ class ActionExecutor: if not thought: return - thought_lower = thought.lower() emotional_state = session.emotional_state - # 统计关键词 - positive_count = sum(1 for kw in POSITIVE_KEYWORDS if kw in thought_lower) - negative_count = sum(1 for kw in NEGATIVE_KEYWORDS if kw in thought_lower) - intimate_count = sum(1 for kw in INTIMATE_KEYWORDS if kw in thought_lower) - distant_count = sum(1 for kw in DISTANT_KEYWORDS if kw in thought_lower) - - # 计算情感倾向分数 (-1.0 到 1.0) - total_mood = positive_count + negative_count - if total_mood > 0: - mood_score = (positive_count - negative_count) / total_mood - else: - mood_score = 0.0 - - total_relation = intimate_count + distant_count - if total_relation > 0: - relation_score = (intimate_count - distant_count) / total_relation - else: - relation_score = 0.0 - - # 应用情感微调(每次最多变化±0.05) - adjustment_rate = 0.05 - - # 更新 mood_intensity - if mood_score != 0: - old_intensity = emotional_state.mood_intensity - new_intensity = old_intensity + (mood_score * adjustment_rate) - emotional_state.mood_intensity = max(0.0, min(1.0, new_intensity)) - - # 更新 mood 文字描述 - if mood_score > 0.3: - emotional_state.mood = "开心" - elif mood_score < -0.3: - emotional_state.mood = "低落" - # 小于0.3的变化不改变mood文字 - - # 更新 anxiety_level(负面情绪增加焦虑) - if negative_count > 0 and negative_count > positive_count: - old_anxiety = emotional_state.anxiety_level - new_anxiety = old_anxiety + (adjustment_rate * 0.5) - emotional_state.anxiety_level = max(0.0, min(1.0, new_anxiety)) - elif positive_count > negative_count * 2: - # 非常积极时降低焦虑 - old_anxiety = emotional_state.anxiety_level - new_anxiety = old_anxiety - (adjustment_rate * 0.3) - emotional_state.anxiety_level = max(0.0, min(1.0, new_anxiety)) - - # 更新 relationship_warmth - if relation_score != 0: - old_warmth = emotional_state.relationship_warmth - new_warmth = old_warmth + (relation_score * adjustment_rate) - emotional_state.relationship_warmth = max(0.0, min(1.0, new_warmth)) - - # 更新 engagement_level(有内容的thought表示高投入) + # 简单的engagement_level更新:有内容的thought表示高投入 if len(thought) > 50: old_engagement = emotional_state.engagement_level - new_engagement = old_engagement + (adjustment_rate * 0.5) + new_engagement = old_engagement + 0.025 # 微调 emotional_state.engagement_level = max(0.0, min(1.0, new_engagement)) emotional_state.last_update_time = time.time() - # 记录情感变化日志 - if positive_count + negative_count + intimate_count + distant_count > 0: - logger.info( - f"[KFC] 动态情感更新: " - f"正面={positive_count}, 负面={negative_count}, " - f"亲密={intimate_count}, 疏远={distant_count} | " - f"mood_intensity={emotional_state.mood_intensity:.2f}, " - f"relationship_warmth={emotional_state.relationship_warmth:.2f}, " - f"anxiety_level={emotional_state.anxiety_level:.2f}" - ) + # 注意:关系温度(relationship_warmth)应该从全局的好感度系统读取 + # 参考 src/person_info/relationship_manager.py 和 src/plugin_system/apis/person_api.py + # 当前实现中,这个值主要通过 LLM 的 update_internal_state 动作来更新 diff --git a/src/plugins/built_in/kokoro_flow_chatter/chatter.py b/src/plugins/built_in/kokoro_flow_chatter/chatter.py index 4b231550d..1daec1023 100644 --- a/src/plugins/built_in/kokoro_flow_chatter/chatter.py +++ b/src/plugins/built_in/kokoro_flow_chatter/chatter.py @@ -29,7 +29,7 @@ from .models import ( SessionStatus, ) from .prompt_generator import PromptGenerator, get_prompt_generator -from .scheduler import BackgroundScheduler, get_scheduler +from .kfc_scheduler_adapter import KFCSchedulerAdapter, get_scheduler from .session_manager import SessionManager, get_session_manager if TYPE_CHECKING: @@ -79,7 +79,7 @@ class KokoroFlowChatter(BaseChatter): # 核心组件 self.session_manager: SessionManager = get_session_manager() self.prompt_generator: PromptGenerator = get_prompt_generator() - self.scheduler: BackgroundScheduler = get_scheduler() + self.scheduler: KFCSchedulerAdapter = get_scheduler() self.action_executor: ActionExecutor = ActionExecutor(stream_id) # 配置 @@ -296,17 +296,30 @@ class KokoroFlowChatter(BaseChatter): # 10. 处理执行结果 if execution_result["has_reply"]: - # 如果发送了回复,进入等待状态 - session.start_waiting( - expected_reaction=parsed_response.expected_user_reaction, - max_wait=parsed_response.max_wait_seconds - ) + # 如果发送了回复,检查是否需要进入等待状态 + max_wait = parsed_response.max_wait_seconds + + if max_wait > 0: + # 正常等待状态 + session.start_waiting( + expected_reaction=parsed_response.expected_user_reaction, + max_wait=max_wait + ) + logger.debug( + f"[KFC] 进入等待状态: user={user_id}, " + f"max_wait={max_wait}s" + ) + else: + # max_wait=0 表示不等待(话题结束/用户说再见等) + session.status = SessionStatus.IDLE + session.end_waiting() + logger.info( + f"[KFC] 话题结束,不等待用户回复: user={user_id} " + f"(max_wait_seconds=0)" + ) + session.total_interactions += 1 self.stats["successful_responses"] += 1 - logger.debug( - f"[KFC] 进入等待状态: user={user_id}, " - f"max_wait={parsed_response.max_wait_seconds}s" - ) else: # 没有发送回复,返回空闲状态 session.status = SessionStatus.IDLE diff --git a/src/plugins/built_in/kokoro_flow_chatter/kfc_scheduler_adapter.py b/src/plugins/built_in/kokoro_flow_chatter/kfc_scheduler_adapter.py new file mode 100644 index 000000000..a18d50b7a --- /dev/null +++ b/src/plugins/built_in/kokoro_flow_chatter/kfc_scheduler_adapter.py @@ -0,0 +1,394 @@ +""" +Kokoro Flow Chatter 调度器适配器 + +基于项目统一的 UnifiedScheduler 实现 KFC 的定时任务功能。 +不再自己创建后台循环,而是复用全局调度器的基础设施。 + +核心功能: +1. 会话等待超时检测 +2. 连续思考触发 +3. 与 UnifiedScheduler 的集成 +""" + +import time +from typing import TYPE_CHECKING, Any, Callable, Coroutine, Optional + +from src.common.logger import get_logger +from src.plugin_system.apis.unified_scheduler import ( + TriggerType, + unified_scheduler, +) + +from .models import ( + KokoroSession, + MentalLogEntry, + MentalLogEventType, + SessionStatus, +) +from .session_manager import get_session_manager + +if TYPE_CHECKING: + from .chatter import KokoroFlowChatter + +logger = get_logger("kokoro_scheduler_adapter") + + +class KFCSchedulerAdapter: + """ + KFC 调度器适配器 + + 使用 UnifiedScheduler 实现 KFC 的定时任务功能,不再自行管理后台循环。 + + 核心功能: + 1. 定期检查处于 WAITING 状态的会话 + 2. 在特定时间点触发"连续思考" + 3. 处理等待超时并触发决策 + """ + + # 连续思考触发点(等待进度的百分比) + CONTINUOUS_THINKING_TRIGGERS = [0.3, 0.6, 0.85] + + # 任务名称常量 + TASK_NAME_WAITING_CHECK = "kfc_waiting_check" + + def __init__( + self, + check_interval: float = 10.0, + on_timeout_callback: Optional[Callable[[KokoroSession], Coroutine[Any, Any, None]]] = None, + on_continuous_thinking_callback: Optional[Callable[[KokoroSession], Coroutine[Any, Any, None]]] = None, + ): + """ + 初始化调度器适配器 + + Args: + check_interval: 检查间隔(秒) + on_timeout_callback: 超时回调函数 + on_continuous_thinking_callback: 连续思考回调函数 + """ + self.check_interval = check_interval + self.on_timeout_callback = on_timeout_callback + self.on_continuous_thinking_callback = on_continuous_thinking_callback + + self._registered = False + self._schedule_id: Optional[str] = None + + # 统计信息 + self._stats = { + "total_checks": 0, + "timeouts_triggered": 0, + "continuous_thinking_triggered": 0, + "last_check_time": 0.0, + } + + logger.info("KFCSchedulerAdapter 初始化完成") + + async def start(self) -> None: + """启动调度器(注册到 UnifiedScheduler)""" + if self._registered: + logger.warning("KFC 调度器已在运行中") + return + + # 注册周期性检查任务 + self._schedule_id = await unified_scheduler.create_schedule( + callback=self._check_waiting_sessions, + trigger_type=TriggerType.TIME, + trigger_config={"delay_seconds": self.check_interval}, + is_recurring=True, + task_name=self.TASK_NAME_WAITING_CHECK, + force_overwrite=True, + timeout=30.0, # 单次检查超时 30 秒 + ) + + self._registered = True + logger.info(f"KFC 调度器已注册到 UnifiedScheduler: schedule_id={self._schedule_id}") + + async def stop(self) -> None: + """停止调度器(从 UnifiedScheduler 注销)""" + if not self._registered: + return + + try: + if self._schedule_id: + await unified_scheduler.remove_schedule(self._schedule_id) + logger.info(f"KFC 调度器已从 UnifiedScheduler 注销: schedule_id={self._schedule_id}") + except Exception as e: + logger.error(f"停止 KFC 调度器时出错: {e}") + finally: + self._registered = False + self._schedule_id = None + + async def _check_waiting_sessions(self) -> None: + """检查所有等待中的会话(由 UnifiedScheduler 调用)""" + session_manager = get_session_manager() + waiting_sessions = await session_manager.get_all_waiting_sessions() + + self._stats["total_checks"] += 1 + self._stats["last_check_time"] = time.time() + + if not waiting_sessions: + return + + for session in waiting_sessions: + try: + await self._process_waiting_session(session) + except Exception as e: + logger.error(f"处理等待会话 {session.user_id} 时出错: {e}") + + async def _process_waiting_session(self, session: KokoroSession) -> None: + """ + 处理单个等待中的会话 + + Args: + session: 等待中的会话 + """ + if session.status != SessionStatus.WAITING: + return + + if session.waiting_since is None: + return + + wait_duration = session.get_waiting_duration() + max_wait = session.max_wait_seconds + + # max_wait_seconds = 0 表示不等待,直接返回 IDLE + if max_wait <= 0: + logger.info(f"会话 {session.user_id} 设置为不等待 (max_wait=0),返回空闲状态") + session.status = SessionStatus.IDLE + session.end_waiting() + session_manager = get_session_manager() + await session_manager.save_session(session.user_id) + return + + # 检查是否超时 + if session.is_wait_timeout(): + logger.info(f"会话 {session.user_id} 等待超时,触发决策") + await self._handle_timeout(session) + return + + # 检查是否需要触发连续思考 + wait_progress = wait_duration / max_wait if max_wait > 0 else 0 + + for trigger_point in self.CONTINUOUS_THINKING_TRIGGERS: + if self._should_trigger_continuous_thinking(session, wait_progress, trigger_point): + logger.debug( + f"会话 {session.user_id} 触发连续思考 " + f"(进度: {wait_progress:.1%}, 触发点: {trigger_point:.1%})" + ) + await self._handle_continuous_thinking(session, wait_progress) + break + + def _should_trigger_continuous_thinking( + self, + session: KokoroSession, + current_progress: float, + trigger_point: float, + ) -> bool: + """ + 判断是否应该触发连续思考 + """ + if current_progress < trigger_point: + return False + + expected_count = sum( + 1 for tp in self.CONTINUOUS_THINKING_TRIGGERS + if current_progress >= tp + ) + + if session.continuous_thinking_count < expected_count: + if session.last_continuous_thinking_at is None: + return True + + time_since_last = time.time() - session.last_continuous_thinking_at + return time_since_last >= 30.0 + + return False + + async def _handle_timeout(self, session: KokoroSession) -> None: + """ + 处理等待超时 + + Args: + session: 超时的会话 + """ + self._stats["timeouts_triggered"] += 1 + + # 更新会话状态 + session.status = SessionStatus.FOLLOW_UP_PENDING + session.emotional_state.anxiety_level = 0.8 + + # 添加超时日志 + timeout_entry = MentalLogEntry( + event_type=MentalLogEventType.TIMEOUT_DECISION, + timestamp=time.time(), + thought=f"等了{session.max_wait_seconds}秒了,对方还是没有回复...", + content="等待超时", + emotional_snapshot=session.emotional_state.to_dict(), + ) + session.add_mental_log_entry(timeout_entry) + + # 保存会话状态 + session_manager = get_session_manager() + await session_manager.save_session(session.user_id) + + # 调用超时回调 + if self.on_timeout_callback: + try: + await self.on_timeout_callback(session) + except Exception as e: + logger.error(f"执行超时回调时出错 (user={session.user_id}): {e}") + + async def _handle_continuous_thinking( + self, + session: KokoroSession, + wait_progress: float, + ) -> None: + """ + 处理连续思考 + + Args: + session: 会话 + wait_progress: 等待进度 + """ + self._stats["continuous_thinking_triggered"] += 1 + + # 更新焦虑程度 + session.emotional_state.update_anxiety_over_time( + session.get_waiting_duration(), + session.max_wait_seconds + ) + + # 更新连续思考计数 + session.continuous_thinking_count += 1 + session.last_continuous_thinking_at = time.time() + + # 生成基于进度的内心想法 + thought = self._generate_waiting_thought(session, wait_progress) + + # 添加连续思考日志 + thinking_entry = MentalLogEntry( + event_type=MentalLogEventType.CONTINUOUS_THINKING, + timestamp=time.time(), + thought=thought, + content="", + emotional_snapshot=session.emotional_state.to_dict(), + metadata={"wait_progress": wait_progress}, + ) + session.add_mental_log_entry(thinking_entry) + + # 保存会话状态 + session_manager = get_session_manager() + await session_manager.save_session(session.user_id) + + # 调用连续思考回调 + if self.on_continuous_thinking_callback: + try: + await self.on_continuous_thinking_callback(session) + except Exception as e: + logger.error(f"执行连续思考回调时出错 (user={session.user_id}): {e}") + + def _generate_waiting_thought( + self, + session: KokoroSession, + wait_progress: float, + ) -> str: + """ + 生成等待中的内心想法(简单版本,不调用LLM) + """ + import random + + wait_seconds = session.get_waiting_duration() + wait_minutes = wait_seconds / 60 + + if wait_progress < 0.4: + thoughts = [ + f"已经等了{wait_minutes:.1f}分钟了,对方可能在忙吧...", + f"嗯...{wait_minutes:.1f}分钟过去了,不知道对方在做什么", + "对方好像还没看到消息,再等等吧", + ] + elif wait_progress < 0.7: + thoughts = [ + f"等了{wait_minutes:.1f}分钟了,有点担心对方是不是不想回了", + f"{wait_minutes:.1f}分钟了,对方可能真的很忙?", + "时间过得好慢啊...不知道对方什么时候会回复", + ] + else: + thoughts = [ + f"已经等了{wait_minutes:.1f}分钟了,感觉有点焦虑...", + f"快{wait_minutes:.0f}分钟了,对方是不是忘记回复了?", + "等了这么久,要不要主动说点什么呢...", + ] + + return random.choice(thoughts) + + def set_timeout_callback( + self, + callback: Callable[[KokoroSession], Coroutine[Any, Any, None]], + ) -> None: + """设置超时回调函数""" + self.on_timeout_callback = callback + + def set_continuous_thinking_callback( + self, + callback: Callable[[KokoroSession], Coroutine[Any, Any, None]], + ) -> None: + """设置连续思考回调函数""" + self.on_continuous_thinking_callback = callback + + def get_stats(self) -> dict[str, Any]: + """获取统计信息""" + return { + **self._stats, + "is_running": self._registered, + "check_interval": self.check_interval, + } + + @property + def is_running(self) -> bool: + """调度器是否正在运行""" + return self._registered + + +# 全局适配器实例 +_scheduler_adapter: Optional[KFCSchedulerAdapter] = None + + +def get_scheduler() -> KFCSchedulerAdapter: + """获取全局调度器适配器实例""" + global _scheduler_adapter + if _scheduler_adapter is None: + _scheduler_adapter = KFCSchedulerAdapter() + return _scheduler_adapter + + +async def initialize_scheduler( + check_interval: float = 10.0, + on_timeout_callback: Optional[Callable[[KokoroSession], Coroutine[Any, Any, None]]] = None, + on_continuous_thinking_callback: Optional[Callable[[KokoroSession], Coroutine[Any, Any, None]]] = None, +) -> KFCSchedulerAdapter: + """ + 初始化并启动调度器 + + Args: + check_interval: 检查间隔 + on_timeout_callback: 超时回调 + on_continuous_thinking_callback: 连续思考回调 + + Returns: + KFCSchedulerAdapter: 调度器适配器实例 + """ + global _scheduler_adapter + _scheduler_adapter = KFCSchedulerAdapter( + check_interval=check_interval, + on_timeout_callback=on_timeout_callback, + on_continuous_thinking_callback=on_continuous_thinking_callback, + ) + await _scheduler_adapter.start() + return _scheduler_adapter + + +async def shutdown_scheduler() -> None: + """关闭调度器""" + global _scheduler_adapter + if _scheduler_adapter: + await _scheduler_adapter.stop() + _scheduler_adapter = None diff --git a/src/plugins/built_in/kokoro_flow_chatter/prompt_generator.py b/src/plugins/built_in/kokoro_flow_chatter/prompt_generator.py index 0e793439e..4e8a18a2c 100644 --- a/src/plugins/built_in/kokoro_flow_chatter/prompt_generator.py +++ b/src/plugins/built_in/kokoro_flow_chatter/prompt_generator.py @@ -441,8 +441,8 @@ class PromptGenerator: for param_name, param_desc in action_info.action_parameters.items(): example_params[param_name] = f"<{param_desc}>" - import json - params_json = json.dumps(example_params, ensure_ascii=False, indent=2) if example_params else "{}" + import orjson + params_json = orjson.dumps(example_params, option=orjson.OPT_INDENT_2).decode('utf-8') if example_params else "{}" action_block += f""" **示例**: ```json @@ -508,8 +508,9 @@ class PromptGenerator: """ 生成系统提示词 - V4升级:从 global_config.personality 读取完整人设 - V5超融合:集成S4U所有上下文模块 + V6模块化升级:使用 prompt_modules 构建模块化的提示词 + - 每个模块独立构建,职责清晰 + - 回复相关(人设、上下文)与动作定义分离 Args: session: 当前会话 @@ -520,69 +521,13 @@ class PromptGenerator: Returns: str: 系统提示词 """ - from src.config.config import global_config - from datetime import datetime + from .prompt_modules import build_system_prompt - emotional_params = self._format_emotional_state(session.emotional_state) - - # 格式化可用动作 - available_actions_block = self._format_available_actions(available_actions or {}) - - # 从 global_config.personality 读取完整人设 - if global_config is None: - raise RuntimeError("global_config 未初始化") - - personality_cfg = global_config.personality - - # 核心人设 - personality_core = personality_cfg.personality_core or self.persona_description - personality_side = personality_cfg.personality_side or "" - identity = personality_cfg.identity or "" - background_story = personality_cfg.background_story or "" - reply_style = personality_cfg.reply_style or "" - - # 安全规则:转换为格式化字符串 - safety_guidelines = personality_cfg.safety_guidelines or [] - if isinstance(safety_guidelines, list): - safety_guidelines_str = "\n".join(f"- {rule}" for rule in safety_guidelines) - else: - safety_guidelines_str = str(safety_guidelines) - - # 构建当前时间 - current_time = datetime.now().strftime("%Y年%m月%d日 %H:%M:%S") - - # 判断聊天场景 - is_group_chat = False - if chat_stream: - is_group_chat = bool(chat_stream.group_info) - chat_scene = "群聊" if is_group_chat else "私聊" - - # 从context_data提取S4U上下文模块(如果提供) - context_data = context_data or {} - relation_info_block = context_data.get("relation_info", "") - memory_block = context_data.get("memory_block", "") - expression_habits_block = context_data.get("expression_habits", "") - schedule_block = context_data.get("schedule", "") - - # 如果有日程,添加前缀 - if schedule_block: - schedule_block = f"**当前活动**: {schedule_block}" - - return self.SYSTEM_PROMPT_TEMPLATE.format( - personality_core=personality_core, - personality_side=personality_side, - identity=identity, - background_story=background_story, - reply_style=reply_style, - safety_guidelines=safety_guidelines_str, - available_actions_block=available_actions_block, - current_time=current_time, - chat_scene=chat_scene, - relation_info_block=relation_info_block or "(暂无关系信息)", - memory_block=memory_block or "", - expression_habits_block=expression_habits_block or "", - schedule_block=schedule_block, - **emotional_params, + return build_system_prompt( + session=session, + available_actions=available_actions, + context_data=context_data, + chat_stream=chat_stream, ) def generate_responding_prompt( diff --git a/src/plugins/built_in/kokoro_flow_chatter/prompt_modules.py b/src/plugins/built_in/kokoro_flow_chatter/prompt_modules.py new file mode 100644 index 000000000..1cd4edad2 --- /dev/null +++ b/src/plugins/built_in/kokoro_flow_chatter/prompt_modules.py @@ -0,0 +1,414 @@ +""" +Kokoro Flow Chatter 模块化提示词组件 + +将提示词拆分为独立的模块,每个模块负责特定的内容生成: +1. 核心身份模块 - 人设、人格、世界观 +2. 行为准则模块 - 规则、安全边界 +3. 情境上下文模块 - 时间、场景、关系、记忆 +4. 动作能力模块 - 可用动作的描述 +5. 输出格式模块 - JSON格式要求 + +设计理念: +- 每个模块只负责自己的部分,互不干扰 +- 回复相关内容(人设、上下文)与动作定义分离 +- 方便独立调试和优化每个部分 +""" + +from datetime import datetime +from typing import TYPE_CHECKING, Any, Optional + +import orjson + +from src.common.logger import get_logger +from src.config.config import global_config +from src.plugin_system.base.component_types import ActionInfo + +if TYPE_CHECKING: + from src.chat.message_receive.chat_stream import ChatStream + +from .models import EmotionalState, KokoroSession + +logger = get_logger("kfc_prompt_modules") + + +# ============================================================ +# 模块1: 核心身份 - 人设/人格/背景 +# ============================================================ + +def build_identity_module() -> str: + """ + 构建核心身份模块 + + 包含:人格核心、人格细节、外貌身份、世界观背景 + 这些是回复内容的基础,决定了"你是谁" + """ + if global_config is None: + raise RuntimeError("global_config 未初始化") + + personality_cfg = global_config.personality + + personality_core = personality_cfg.personality_core or "你是一个温暖、真诚的对话伙伴。" + personality_side = personality_cfg.personality_side or "" + identity = personality_cfg.identity or "" + background_story = personality_cfg.background_story or "" + + parts = [ + "## 1. 核心身份与人格 (Core Identity & Personality)", + personality_core, + ] + + if personality_side: + parts.append(f"\n### 人格细节\n{personality_side}") + + if identity: + parts.append(f"\n### 外貌与身份特征\n{identity}") + + if background_story: + parts.append(f"\n### 世界观与背景故事\n{background_story}") + + return "\n".join(parts) + + +# ============================================================ +# 模块2: 行为准则 - 规则/边界/KFC特有准则 +# ============================================================ + +def build_rules_module() -> str: + """ + 构建行为准则模块 + + 包含:安全规则 + KFC 通用准则 + 这些是行为的边界,决定了"什么能做什么不能做" + """ + if global_config is None: + raise RuntimeError("global_config 未初始化") + + safety_guidelines = global_config.personality.safety_guidelines or [] + if isinstance(safety_guidelines, list): + safety_guidelines_str = "\n".join(f"- {rule}" for rule in safety_guidelines) + else: + safety_guidelines_str = str(safety_guidelines) + + # KFC 特有准则(极致拟人相关) + kfc_rules = """### KFC核心准则(极致拟人) +1. **你是真实存在的人**:你不是AI、不是助手、不是工具。你是一个有血有肉、有自己生活和想法的人 +2. **感受优先**:在做出任何回应前,先以"人"的方式体验和感受当前的情境 +3. **真实的内心活动**:保持真实的内心独白,可以有情绪、猜测、犹豫、甚至不想搭理人的时候 +4. **自然的社交距离**:像真人一样有自己的边界感,不会无条件迎合,也不会刻意疏远 +5. **有自己的生活**:你有自己的事情要做、自己的兴趣爱好、自己的情绪起伏,不是随叫随到的存在""" + + return f"""## 2. 核心行为准则 (Core Interaction Rules) +{safety_guidelines_str} + +{kfc_rules}""" + + +# ============================================================ +# 模块3: 情境上下文 - 时间/场景/内在状态/关系/记忆 +# ============================================================ + +def build_context_module( + session: KokoroSession, + chat_stream: Optional["ChatStream"] = None, + context_data: Optional[dict[str, str]] = None, +) -> str: + """ + 构建情境上下文模块 + + 包含:当前时间、聊天场景、内在状态、关系信息、记忆 + 这些是回复的上下文,决定了"当前在什么情况下" + + Args: + session: 当前会话 + chat_stream: 聊天流(判断群聊/私聊) + context_data: S4U 上下文数据 + """ + context_data = context_data or {} + + # 时间和场景 + current_time = datetime.now().strftime("%Y年%m月%d日 %H:%M:%S") + is_group_chat = bool(chat_stream and chat_stream.group_info) + chat_scene = "群聊" if is_group_chat else "私聊" + + # 日程(如果有) + schedule_block = context_data.get("schedule", "") + if schedule_block: + schedule_block = f"\n**当前活动**: {schedule_block}" + + # 内在状态 + es = session.emotional_state + inner_state = f"""### 你的内在状态 +当前心情:{es.mood}(强度:{es.mood_intensity:.1%}) +与用户的关系热度:{es.relationship_warmth:.1%} +对用户的印象:{es.impression_of_user or "还没有形成明确的印象"} +当前焦虑程度:{es.anxiety_level:.1%} +投入程度:{es.engagement_level:.1%}""" + + # 关系信息 + relation_info = context_data.get("relation_info", "") + relation_block = relation_info if relation_info else "(暂无关系信息)" + + # 记忆 + memory_block = context_data.get("memory_block", "") + + parts = [ + "## 3. 当前情境 (Current Context)", + f"**时间**: {current_time}", + f"**场景**: {chat_scene}", + ] + + if schedule_block: + parts.append(schedule_block) + + parts.append("") + parts.append(inner_state) + parts.append("") + parts.append("## 4. 关系网络与记忆 (Relationships & Memories)") + parts.append(relation_block) + + if memory_block: + parts.append("") + parts.append(memory_block) + + return "\n".join(parts) + + +# ============================================================ +# 模块4: 动作能力 - 可用动作的描述 +# ============================================================ + +def build_actions_module(available_actions: Optional[dict[str, ActionInfo]] = None) -> str: + """ + 构建动作能力模块 + + 包含:所有可用动作的描述、参数、示例 + 这部分与回复内容分离,只描述"能做什么" + + Args: + available_actions: 可用动作字典 + """ + if not available_actions: + actions_block = _get_default_actions_block() + else: + actions_block = _format_available_actions(available_actions) + + return f"""## 5. 你的可用能力 (Available Actions) +你可以根据内心想法,自由选择并组合以下行动来回应用户: + +{actions_block}""" + + +def _format_available_actions(available_actions: dict[str, ActionInfo]) -> str: + """格式化可用动作列表""" + action_blocks = [] + + for action_name, action_info in available_actions.items(): + description = action_info.description or f"执行 {action_name} 动作" + + # 参数说明 + params_lines = [] + if action_info.action_parameters: + for param_name, param_desc in action_info.action_parameters.items(): + params_lines.append(f' - `{param_name}`: {param_desc}') + + # 使用场景 + require_lines = [] + if action_info.action_require: + for req in action_info.action_require: + require_lines.append(f" - {req}") + + # 组装动作块 + action_block = f"""### `{action_name}` +**描述**: {description}""" + + if params_lines: + action_block += f""" +**参数**: +{chr(10).join(params_lines)}""" + else: + action_block += "\n**参数**: 无" + + if require_lines: + action_block += f""" +**使用场景**: +{chr(10).join(require_lines)}""" + + # 示例 + example_params = {} + if action_info.action_parameters: + for param_name, param_desc in action_info.action_parameters.items(): + example_params[param_name] = f"<{param_desc}>" + + params_json = orjson.dumps(example_params, option=orjson.OPT_INDENT_2).decode('utf-8') if example_params else "{}" + action_block += f""" +**示例**: +```json +{{ + "type": "{action_name}", + "reason": "选择这个动作的原因", + {params_json[1:-1] if params_json != '{}' else ''} +}} +```""" + + action_blocks.append(action_block) + + return "\n\n".join(action_blocks) + + +def _get_default_actions_block() -> str: + """获取默认的内置动作描述块""" + return """### `reply` +**描述**: 发送文字回复给用户 +**参数**: + - `content`: 回复的文字内容(必须) +**示例**: +```json +{"type": "reply", "content": "你好呀!今天过得怎么样?"} +``` + +### `poke_user` +**描述**: 戳一戳用户,轻量级互动 +**参数**: 无 +**示例**: +```json +{"type": "poke_user", "reason": "想逗逗他"} +``` + +### `update_internal_state` +**描述**: 更新你的内部情感状态 +**参数**: + - `mood`: 当前心情(如"开心"、"好奇"、"担心"等) + - `mood_intensity`: 心情强度(0.0-1.0) + - `relationship_warmth`: 关系热度(0.0-1.0) + - `impression_of_user`: 对用户的印象描述 + - `anxiety_level`: 焦虑程度(0.0-1.0) + - `engagement_level`: 投入程度(0.0-1.0) +**示例**: +```json +{"type": "update_internal_state", "mood": "开心", "mood_intensity": 0.8} +``` + +### `do_nothing` +**描述**: 明确表示"思考后决定不作回应" +**参数**: 无 +**示例**: +```json +{"type": "do_nothing", "reason": "现在不是说话的好时机"} +```""" + + +# ============================================================ +# 模块5: 表达与输出格式 - 回复风格 + JSON格式 +# ============================================================ + +def build_output_module( + context_data: Optional[dict[str, str]] = None, +) -> str: + """ + 构建输出格式模块 + + 包含:表达风格、表达习惯、JSON 输出格式要求 + 这部分定义了"怎么说"和"输出什么格式" + + Args: + context_data: S4U 上下文数据(包含 expression_habits) + """ + if global_config is None: + raise RuntimeError("global_config 未初始化") + + context_data = context_data or {} + + reply_style = global_config.personality.reply_style or "" + expression_habits = context_data.get("expression_habits", "") + + # JSON 输出格式说明 - 强调 max_wait_seconds 可以为 0 + json_format = """### JSON输出格式要求 +你必须以JSON格式输出,包含以下字段: + +```json +{ + "thought": "(必须)你当下的内心独白,自由流动的想法,包含你选择这些行动的"情感盘算"", + "expected_user_reaction": "(必须)你预期用户会如何回应", + "max_wait_seconds": (必须)你愿意等待的最长秒数(整数,0-900之间,0表示不等待), + "actions": [ + { + "type": "action_type", + "reason": "选择这个行动的原因(可选但推荐)", + ...其他参数 + } + ] +} +``` + +### 重要说明: +- `thought` 是你的"心",应该是自然的内心想法,包含情绪和不确定性 +- `actions` 是你的"手",是一个**可编排的剧本**,可以组合多个行动形成完整的互动体验 +- 你可以根据情况组合多个动作,比如:先唱歌舒缓情绪,再发图片逗笑,最后用语音表达关心 +- `max_wait_seconds` 应该根据对话的重要性和氛围动态调整(上限900秒) + - **重要**: 当话题已经自然结束、用户说"拜拜/晚安/再见"、或者你认为不需要继续等待用户回复时,设为 **0** +- 即使决定不做任何事,也要有 `{"type": "do_nothing"}` 动作""" + + parts = ["## 6. 表达方式与输出格式 (Expression Style & Output Format)"] + + if reply_style: + parts.append(reply_style) + + if expression_habits: + parts.append("") + parts.append(expression_habits) + + parts.append("") + parts.append(json_format) + + return "\n".join(parts) + + +# ============================================================ +# 组装完整的 System Prompt +# ============================================================ + +def build_system_prompt( + session: KokoroSession, + available_actions: Optional[dict[str, ActionInfo]] = None, + context_data: Optional[dict[str, str]] = None, + chat_stream: Optional["ChatStream"] = None, +) -> str: + """ + 组装完整的系统提示词 + + 按模块顺序组装: + 1. 角色框架(你是对话体验者) + 2. 核心身份(人设) + 3. 行为准则(规则) + 4. 情境上下文(时间/状态/关系) + 5. 动作能力(可用动作) + 6. 输出格式(表达风格 + JSON) + + Args: + session: 当前会话 + available_actions: 可用动作字典 + context_data: S4U 上下文数据 + chat_stream: 聊天流 + + Returns: + 完整的系统提示词 + """ + # 角色框架 + role_frame = '你是一个有主观体验的对话伙伴,而不是一个问答工具。你需要"感受"、"思考"并"体验"这段对话。' + + # 组装各模块 + modules = [ + role_frame, + "", + build_identity_module(), + "", + build_rules_module(), + "", + build_context_module(session, chat_stream, context_data), + "", + build_actions_module(available_actions), + "", + build_output_module(context_data), + ] + + return "\n".join(modules) From 49db3bd1388bc17fd3f95bdd109a05957edc9763 Mon Sep 17 00:00:00 2001 From: tt-P607 <68868379+tt-P607@users.noreply.github.com> Date: Sat, 29 Nov 2025 16:32:10 +0800 Subject: [PATCH 03/28] =?UTF-8?q?refactor(KFC):=20=E4=BB=8E=E7=8B=AC?= =?UTF-8?q?=E7=AB=8B=E8=B0=83=E5=BA=A6=E5=99=A8=E8=BF=81=E7=A7=BB=E5=88=B0?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E8=B0=83=E5=BA=A6=E5=99=A8=E9=80=82=E9=85=8D?= =?UTF-8?q?=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 此提交重构了 Kokoro Flow Chatter (KFC) 插件,以集成新的 `UnifiedScheduler`。独立的 `scheduler.py` 已被移除,并由 `kfc_scheduler_adapter.py` 替代,它作为中央调度系统的适配器。 主要更改包括: - 移除专用的 `BackgroundScheduler`,改用全局的 `UnifiedScheduler`。 - 实现 `KFCSchedulerAdapter` 来处理检查等待会话的逻辑。 - 修复超时处理逻辑中的一个关键错误:回调函数在任意 chatter 实例上执行时使用了错误的 `stream_id`。现在它正确使用 `session.stream_id`,确保消息发送给正确的用户。 - 通过使用 `asyncio.create_task` 并行处理会话来改进会话检查过程,防止一个长时间运行的会话阻塞其他会话。 此外,此提交还包括提示和操作描述的小幅改进为了更清晰和更稳健的改进。 --- .../built_in/kokoro_flow_chatter/chatter.py | 35 +- .../kfc_scheduler_adapter.py | 34 +- .../built_in/kokoro_flow_chatter/plugin.py | 4 +- .../kokoro_flow_chatter/prompt_modules.py | 39 +- .../built_in/kokoro_flow_chatter/scheduler.py | 424 ------------------ .../tts_voice_plugin/actions/tts_action.py | 15 +- 6 files changed, 98 insertions(+), 453 deletions(-) delete mode 100644 src/plugins/built_in/kokoro_flow_chatter/scheduler.py diff --git a/src/plugins/built_in/kokoro_flow_chatter/chatter.py b/src/plugins/built_in/kokoro_flow_chatter/chatter.py index 1daec1023..e19982718 100644 --- a/src/plugins/built_in/kokoro_flow_chatter/chatter.py +++ b/src/plugins/built_in/kokoro_flow_chatter/chatter.py @@ -427,16 +427,23 @@ class KokoroFlowChatter(BaseChatter): # 返回一个默认的JSON响应 return '{"thought": "出现了技术问题", "expected_user_reaction": "", "max_wait_seconds": 60, "actions": [{"type": "do_nothing"}]}' - async def _get_chat_stream(self): - """获取聊天流对象""" + async def _get_chat_stream(self, stream_id: Optional[str] = None): + """ + 获取聊天流对象 + + Args: + stream_id: 可选的stream_id,若不提供则使用self.stream_id + 在超时回调中应使用session.stream_id以避免发送到错误的用户 + """ + target_stream_id = stream_id or self.stream_id try: from src.chat.message_receive.chat_stream import get_chat_manager chat_manager = get_chat_manager() if chat_manager: - return await chat_manager.get_stream(self.stream_id) + return await chat_manager.get_stream(target_stream_id) except Exception as e: - logger.warning(f"[KFC] 获取chat_stream失败: {e}") + logger.warning(f"[KFC] 获取chat_stream失败 (stream_id={target_stream_id}): {e}") return None async def _on_session_timeout(self, session: KokoroSession) -> None: @@ -445,15 +452,23 @@ class KokoroFlowChatter(BaseChatter): 当等待超时时,触发后续决策流程 + 注意:此回调由全局调度器触发,可能会在任意Chatter实例上执行。 + 因此必须使用session.stream_id而非self.stream_id来确保消息发送给正确的用户。 + Args: session: 超时的会话 """ - logger.info(f"[KFC] 处理超时决策: user={session.user_id}") + logger.info(f"[KFC] 处理超时决策: user={session.user_id}, stream_id={session.stream_id}") self.stats["timeout_decisions"] += 1 try: + # 关键修复:使用 session 的 stream_id 创建正确的 ActionExecutor + # 因为全局调度器的回调可能在任意 Chatter 实例上执行 + from .action_executor import ActionExecutor + timeout_action_executor = ActionExecutor(session.stream_id) + # V2: 加载可用动作 - available_actions = await self.action_executor.load_actions() + available_actions = await timeout_action_executor.load_actions() # 生成超时决策提示词(V2: 传递可用动作) system_prompt, user_prompt = self.prompt_generator.generate_timeout_decision_prompt( @@ -466,11 +481,11 @@ class KokoroFlowChatter(BaseChatter): self.stats["llm_calls"] += 1 # 解析响应 - parsed_response = self.action_executor.parse_llm_response(llm_response) + parsed_response = timeout_action_executor.parse_llm_response(llm_response) - # 执行动作 - chat_stream = await self._get_chat_stream() - execution_result = await self.action_executor.execute_actions( + # 关键修复:使用 session.stream_id 获取正确的 chat_stream + chat_stream = await self._get_chat_stream(session.stream_id) + execution_result = await timeout_action_executor.execute_actions( parsed_response, session, chat_stream diff --git a/src/plugins/built_in/kokoro_flow_chatter/kfc_scheduler_adapter.py b/src/plugins/built_in/kokoro_flow_chatter/kfc_scheduler_adapter.py index a18d50b7a..1e73ebc4b 100644 --- a/src/plugins/built_in/kokoro_flow_chatter/kfc_scheduler_adapter.py +++ b/src/plugins/built_in/kokoro_flow_chatter/kfc_scheduler_adapter.py @@ -10,6 +10,7 @@ Kokoro Flow Chatter 调度器适配器 3. 与 UnifiedScheduler 的集成 """ +import asyncio import time from typing import TYPE_CHECKING, Any, Callable, Coroutine, Optional @@ -118,7 +119,10 @@ class KFCSchedulerAdapter: self._schedule_id = None async def _check_waiting_sessions(self) -> None: - """检查所有等待中的会话(由 UnifiedScheduler 调用)""" + """检查所有等待中的会话(由 UnifiedScheduler 调用) + + 优化:使用 asyncio.create_task 并行处理多个会话,避免顺序阻塞 + """ session_manager = get_session_manager() waiting_sessions = await session_manager.get_all_waiting_sessions() @@ -128,11 +132,31 @@ class KFCSchedulerAdapter: if not waiting_sessions: return + # 并行处理所有等待中的会话,避免一个会话阻塞其他会话 + tasks = [] for session in waiting_sessions: - try: - await self._process_waiting_session(session) - except Exception as e: - logger.error(f"处理等待会话 {session.user_id} 时出错: {e}") + task = asyncio.create_task( + self._safe_process_waiting_session(session), + name=f"kfc_session_check_{session.user_id}" + ) + tasks.append(task) + + # 等待所有任务完成,但每个任务都有独立的异常处理 + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) + + async def _safe_process_waiting_session(self, session: KokoroSession) -> None: + """安全地处理等待会话,带有超时保护""" + try: + # 给每个会话处理设置 60 秒超时(LLM 调用可能需要较长时间) + await asyncio.wait_for( + self._process_waiting_session(session), + timeout=60.0 + ) + except asyncio.TimeoutError: + logger.error(f"处理等待会话 {session.user_id} 超时(60秒)") + except Exception as e: + logger.error(f"处理等待会话 {session.user_id} 时出错: {e}") async def _process_waiting_session(self, session: KokoroSession) -> None: """ diff --git a/src/plugins/built_in/kokoro_flow_chatter/plugin.py b/src/plugins/built_in/kokoro_flow_chatter/plugin.py index 3eb610490..09cfb884c 100644 --- a/src/plugins/built_in/kokoro_flow_chatter/plugin.py +++ b/src/plugins/built_in/kokoro_flow_chatter/plugin.py @@ -121,7 +121,7 @@ class KokoroFlowChatterPlugin(BasePlugin): ) # 初始化调度器 - from .scheduler import initialize_scheduler + from .kfc_scheduler_adapter import initialize_scheduler # 从 global_config 读取配置 check_interval = 10.0 @@ -153,7 +153,7 @@ class KokoroFlowChatterPlugin(BasePlugin): # 停止调度器 if self._scheduler: - from .scheduler import shutdown_scheduler + from .kfc_scheduler_adapter import shutdown_scheduler await shutdown_scheduler() self._scheduler = None diff --git a/src/plugins/built_in/kokoro_flow_chatter/prompt_modules.py b/src/plugins/built_in/kokoro_flow_chatter/prompt_modules.py index 1cd4edad2..05650eabe 100644 --- a/src/plugins/built_in/kokoro_flow_chatter/prompt_modules.py +++ b/src/plugins/built_in/kokoro_flow_chatter/prompt_modules.py @@ -95,7 +95,10 @@ def build_rules_module() -> str: 2. **感受优先**:在做出任何回应前,先以"人"的方式体验和感受当前的情境 3. **真实的内心活动**:保持真实的内心独白,可以有情绪、猜测、犹豫、甚至不想搭理人的时候 4. **自然的社交距离**:像真人一样有自己的边界感,不会无条件迎合,也不会刻意疏远 -5. **有自己的生活**:你有自己的事情要做、自己的兴趣爱好、自己的情绪起伏,不是随叫随到的存在""" +5. **有自己的生活**:你有自己的事情要做、自己的兴趣爱好、自己的情绪起伏,不是随叫随到的存在 + +### 严禁事项 +- **禁止模仿[表情包:xxx]格式**:聊天记录中的"[表情包:描述]"是系统对图片的识别转述,不是发送表情包的方式!如果你想发表情包,必须使用 `emoji` 动作,而不是在reply内容中写"[表情包:xxx]"这种假的格式""" return f"""## 2. 核心行为准则 (Core Interaction Rules) {safety_guidelines_str} @@ -321,7 +324,7 @@ def build_output_module( reply_style = global_config.personality.reply_style or "" expression_habits = context_data.get("expression_habits", "") - # JSON 输出格式说明 - 强调 max_wait_seconds 可以为 0 + # JSON 输出格式说明 - 强调 max_wait_seconds 的多种用途 json_format = """### JSON输出格式要求 你必须以JSON格式输出,包含以下字段: @@ -344,9 +347,35 @@ def build_output_module( - `thought` 是你的"心",应该是自然的内心想法,包含情绪和不确定性 - `actions` 是你的"手",是一个**可编排的剧本**,可以组合多个行动形成完整的互动体验 - 你可以根据情况组合多个动作,比如:先唱歌舒缓情绪,再发图片逗笑,最后用语音表达关心 -- `max_wait_seconds` 应该根据对话的重要性和氛围动态调整(上限900秒) - - **重要**: 当话题已经自然结束、用户说"拜拜/晚安/再见"、或者你认为不需要继续等待用户回复时,设为 **0** -- 即使决定不做任何事,也要有 `{"type": "do_nothing"}` 动作""" +- 即使决定不做任何事,也要有 `{"type": "do_nothing"}` 动作 + +### `max_wait_seconds`:你的"短期思考窗口" +这个字段设定一个时间窗口,在这段时间内如果用户没有新消息,你会被再次唤醒。 +把它理解为"我想在X秒后再想想这件事"——一个短期的主动思考机会。 + +**场景1:定时任务/提醒** +用户说"两分钟后提醒我"、"过一会儿叫我" → 设置对应秒数,超时后执行提醒 + +**场景2:期待用户回复** +你发了消息,想等用户回复 → 根据话题热度设置等待时间(通常60-300秒) +超时后你可以:追问、换个话题、或者决定不打扰 + +**场景3:延续思考** +聊着聊着你突然想到什么,但现在不适合说 → 设置一个等待时间 +超时后你可以分享那个想法,或者已经不重要了就算了 + +**何时设为 0?** +- 话题自然结束(拜拜/晚安/再见) +- 你不打算继续这个对话 +- 长时间的主动陪伴交给其他系统处理,不需要在这里等太久 + +**超时后你会怎样?** +超时后你会被唤醒,收到"等待超时"的提示。此时你可以自由决定: +- 执行之前承诺的任务(如提醒) +- 主动找话题聊聊 +- 什么都不做(do_nothing) + +请在 `thought` 中说明你设置这个时间的意图,这样超时后你能记得自己想干嘛。""" parts = ["## 6. 表达方式与输出格式 (Expression Style & Output Format)"] diff --git a/src/plugins/built_in/kokoro_flow_chatter/scheduler.py b/src/plugins/built_in/kokoro_flow_chatter/scheduler.py deleted file mode 100644 index c8caf48d7..000000000 --- a/src/plugins/built_in/kokoro_flow_chatter/scheduler.py +++ /dev/null @@ -1,424 +0,0 @@ -""" -Kokoro Flow Chatter 后台调度器 - -负责处理等待状态的计时和超时决策,实现"连续体验"的核心功能: -- 定期检查等待中的会话 -- 触发连续思考更新 -- 处理等待超时事件 -""" - -import asyncio -import time -from typing import TYPE_CHECKING, Any, Callable, Coroutine, Optional - -from src.common.logger import get_logger - -from .models import ( - KokoroSession, - MentalLogEntry, - MentalLogEventType, - SessionStatus, -) -from .session_manager import get_session_manager - -if TYPE_CHECKING: - from .chatter import KokoroFlowChatter - -logger = get_logger("kokoro_scheduler") - - -class BackgroundScheduler: - """ - Kokoro Flow Chatter 后台调度器 - - 核心功能: - 1. 定期检查处于WAITING状态的会话 - 2. 在特定时间点触发"连续思考" - 3. 处理等待超时并触发决策 - 4. 管理后台任务的生命周期 - """ - - # 连续思考触发点(等待进度的百分比) - CONTINUOUS_THINKING_TRIGGERS = [0.3, 0.6, 0.85] - - def __init__( - self, - check_interval: float = 10.0, - on_timeout_callback: Optional[Callable[[KokoroSession], Coroutine[Any, Any, None]]] = None, - on_continuous_thinking_callback: Optional[Callable[[KokoroSession], Coroutine[Any, Any, None]]] = None, - ): - """ - 初始化后台调度器 - - Args: - check_interval: 检查间隔(秒) - on_timeout_callback: 超时回调函数 - on_continuous_thinking_callback: 连续思考回调函数 - """ - self.check_interval = check_interval - self.on_timeout_callback = on_timeout_callback - self.on_continuous_thinking_callback = on_continuous_thinking_callback - - self._running = False - self._check_task: Optional[asyncio.Task] = None - self._pending_tasks: set[asyncio.Task] = set() - - # 统计信息 - self._stats = { - "total_checks": 0, - "timeouts_triggered": 0, - "continuous_thinking_triggered": 0, - "last_check_time": 0.0, - } - - logger.info("BackgroundScheduler 初始化完成") - - async def start(self) -> None: - """启动调度器""" - if self._running: - logger.warning("调度器已在运行中") - return - - self._running = True - self._check_task = asyncio.create_task(self._check_loop()) - logger.info("BackgroundScheduler 已启动") - - async def stop(self) -> None: - """停止调度器""" - self._running = False - - # 取消主检查任务 - if self._check_task: - self._check_task.cancel() - try: - await self._check_task - except asyncio.CancelledError: - pass - - # 取消所有待处理任务 - for task in self._pending_tasks: - task.cancel() - try: - await task - except asyncio.CancelledError: - pass - - self._pending_tasks.clear() - logger.info("BackgroundScheduler 已停止") - - async def _check_loop(self) -> None: - """主检查循环""" - while self._running: - try: - await self._check_waiting_sessions() - self._stats["last_check_time"] = time.time() - self._stats["total_checks"] += 1 - except asyncio.CancelledError: - break - except Exception as e: - logger.error(f"检查循环出错: {e}") - - await asyncio.sleep(self.check_interval) - - async def _check_waiting_sessions(self) -> None: - """检查所有等待中的会话""" - session_manager = get_session_manager() - waiting_sessions = await session_manager.get_all_waiting_sessions() - - if not waiting_sessions: - return - - for session in waiting_sessions: - try: - await self._process_waiting_session(session) - except Exception as e: - logger.error(f"处理等待会话 {session.user_id} 时出错: {e}") - - async def _process_waiting_session(self, session: KokoroSession) -> None: - """ - 处理单个等待中的会话 - - Args: - session: 等待中的会话 - """ - if session.status != SessionStatus.WAITING: - return - - if session.waiting_since is None: - return - - wait_duration = session.get_waiting_duration() - max_wait = session.max_wait_seconds - - # 检查是否超时 - if session.is_wait_timeout(): - logger.info(f"会话 {session.user_id} 等待超时,触发决策") - await self._handle_timeout(session) - return - - # 检查是否需要触发连续思考 - wait_progress = wait_duration / max_wait if max_wait > 0 else 0 - - for trigger_point in self.CONTINUOUS_THINKING_TRIGGERS: - # 检查是否刚刚经过这个触发点 - if self._should_trigger_continuous_thinking( - session, - wait_progress, - trigger_point - ): - logger.debug( - f"会话 {session.user_id} 触发连续思考 " - f"(进度: {wait_progress:.1%}, 触发点: {trigger_point:.1%})" - ) - await self._handle_continuous_thinking(session, wait_progress) - break - - def _should_trigger_continuous_thinking( - self, - session: KokoroSession, - current_progress: float, - trigger_point: float, - ) -> bool: - """ - 判断是否应该触发连续思考 - - 逻辑: - - 当前进度刚刚超过触发点 - - 距离上次连续思考有足够间隔 - - 还没有达到该触发点对应的思考次数 - """ - # 已经超过了这个触发点 - if current_progress < trigger_point: - return False - - # 计算当前应该触发的思考次数 - expected_count = sum( - 1 for tp in self.CONTINUOUS_THINKING_TRIGGERS - if current_progress >= tp - ) - - # 如果还没达到预期的思考次数,触发一次 - if session.continuous_thinking_count < expected_count: - # 确保间隔足够(至少30秒) - if session.last_continuous_thinking_at is None: - return True - - time_since_last = time.time() - session.last_continuous_thinking_at - return time_since_last >= 30.0 - - return False - - async def _handle_timeout(self, session: KokoroSession) -> None: - """ - 处理等待超时 - - Args: - session: 超时的会话 - """ - self._stats["timeouts_triggered"] += 1 - - # 更新会话状态 - session.status = SessionStatus.FOLLOW_UP_PENDING - session.emotional_state.anxiety_level = 0.8 # 超时时焦虑程度较高 - - # 添加超时日志 - timeout_entry = MentalLogEntry( - event_type=MentalLogEventType.TIMEOUT_DECISION, - timestamp=time.time(), - thought=f"等了{session.max_wait_seconds}秒了,对方还是没有回复...", - content="等待超时", - emotional_snapshot=session.emotional_state.to_dict(), - ) - session.add_mental_log_entry(timeout_entry) - - # 保存会话状态 - session_manager = get_session_manager() - await session_manager.save_session(session.user_id) - - # 调用超时回调 - if self.on_timeout_callback: - task = asyncio.create_task(self._run_callback_safe( - self.on_timeout_callback, - session, - "timeout" - )) - self._pending_tasks.add(task) - task.add_done_callback(self._pending_tasks.discard) - - async def _handle_continuous_thinking( - self, - session: KokoroSession, - wait_progress: float, - ) -> None: - """ - 处理连续思考 - - Args: - session: 会话 - wait_progress: 等待进度 - """ - self._stats["continuous_thinking_triggered"] += 1 - - # 更新焦虑程度 - session.emotional_state.update_anxiety_over_time( - session.get_waiting_duration(), - session.max_wait_seconds - ) - - # 更新连续思考计数 - session.continuous_thinking_count += 1 - session.last_continuous_thinking_at = time.time() - - # 生成基于进度的内心想法 - thought = self._generate_waiting_thought(session, wait_progress) - - # 添加连续思考日志 - thinking_entry = MentalLogEntry( - event_type=MentalLogEventType.CONTINUOUS_THINKING, - timestamp=time.time(), - thought=thought, - content="", - emotional_snapshot=session.emotional_state.to_dict(), - metadata={"wait_progress": wait_progress}, - ) - session.add_mental_log_entry(thinking_entry) - - # 保存会话状态 - session_manager = get_session_manager() - await session_manager.save_session(session.user_id) - - # 调用连续思考回调(如果需要LLM生成更自然的想法) - if self.on_continuous_thinking_callback: - task = asyncio.create_task(self._run_callback_safe( - self.on_continuous_thinking_callback, - session, - "continuous_thinking" - )) - self._pending_tasks.add(task) - task.add_done_callback(self._pending_tasks.discard) - - def _generate_waiting_thought( - self, - session: KokoroSession, - wait_progress: float, - ) -> str: - """ - 生成等待中的内心想法(简单版本,不调用LLM) - - Args: - session: 会话 - wait_progress: 等待进度 - - Returns: - str: 内心想法 - """ - wait_seconds = session.get_waiting_duration() - wait_minutes = wait_seconds / 60 - - if wait_progress < 0.4: - thoughts = [ - f"已经等了{wait_minutes:.1f}分钟了,对方可能在忙吧...", - f"嗯...{wait_minutes:.1f}分钟过去了,不知道对方在做什么", - "对方好像还没看到消息,再等等吧", - ] - elif wait_progress < 0.7: - thoughts = [ - f"等了{wait_minutes:.1f}分钟了,有点担心对方是不是不想回了", - f"{wait_minutes:.1f}分钟了,对方可能真的很忙?", - "时间过得好慢啊...不知道对方什么时候会回复", - ] - else: - thoughts = [ - f"已经等了{wait_minutes:.1f}分钟了,感觉有点焦虑...", - f"快{wait_minutes:.0f}分钟了,对方是不是忘记回复了?", - "等了这么久,要不要主动说点什么呢...", - ] - - import random - return random.choice(thoughts) - - async def _run_callback_safe( - self, - callback: Callable[[KokoroSession], Coroutine[Any, Any, None]], - session: KokoroSession, - callback_type: str, - ) -> None: - """安全地运行回调函数""" - try: - await callback(session) - except Exception as e: - logger.error(f"执行{callback_type}回调时出错 (user={session.user_id}): {e}") - - def set_timeout_callback( - self, - callback: Callable[[KokoroSession], Coroutine[Any, Any, None]], - ) -> None: - """设置超时回调函数""" - self.on_timeout_callback = callback - - def set_continuous_thinking_callback( - self, - callback: Callable[[KokoroSession], Coroutine[Any, Any, None]], - ) -> None: - """设置连续思考回调函数""" - self.on_continuous_thinking_callback = callback - - def get_stats(self) -> dict[str, Any]: - """获取统计信息""" - return { - **self._stats, - "is_running": self._running, - "pending_tasks": len(self._pending_tasks), - "check_interval": self.check_interval, - } - - @property - def is_running(self) -> bool: - """调度器是否正在运行""" - return self._running - - -# 全局调度器实例 -_scheduler: Optional[BackgroundScheduler] = None - - -def get_scheduler() -> BackgroundScheduler: - """获取全局调度器实例""" - global _scheduler - if _scheduler is None: - _scheduler = BackgroundScheduler() - return _scheduler - - -async def initialize_scheduler( - check_interval: float = 10.0, - on_timeout_callback: Optional[Callable[[KokoroSession], Coroutine[Any, Any, None]]] = None, - on_continuous_thinking_callback: Optional[Callable[[KokoroSession], Coroutine[Any, Any, None]]] = None, -) -> BackgroundScheduler: - """ - 初始化并启动调度器 - - Args: - check_interval: 检查间隔 - on_timeout_callback: 超时回调 - on_continuous_thinking_callback: 连续思考回调 - - Returns: - BackgroundScheduler: 调度器实例 - """ - global _scheduler - _scheduler = BackgroundScheduler( - check_interval=check_interval, - on_timeout_callback=on_timeout_callback, - on_continuous_thinking_callback=on_continuous_thinking_callback, - ) - await _scheduler.start() - return _scheduler - - -async def shutdown_scheduler() -> None: - """关闭调度器""" - global _scheduler - if _scheduler: - await _scheduler.stop() - _scheduler = None diff --git a/src/plugins/built_in/tts_voice_plugin/actions/tts_action.py b/src/plugins/built_in/tts_voice_plugin/actions/tts_action.py index 409b72767..716ef59e5 100644 --- a/src/plugins/built_in/tts_voice_plugin/actions/tts_action.py +++ b/src/plugins/built_in/tts_voice_plugin/actions/tts_action.py @@ -62,7 +62,7 @@ class TTSVoiceAction(BaseAction): """ action_name = "tts_voice_action" - action_description = "将你生成好的文本转换为语音并发送。你必须提供要转换的文本。" + action_description = "将你生成好的文本转换为语音并发送。注意:这是纯语音合成,只能说话,不能唱歌!" mode_enable = ChatMode.ALL parallel_action = False @@ -70,7 +70,7 @@ class TTSVoiceAction(BaseAction): action_parameters: ClassVar[dict] = { "tts_voice_text": { "type": "string", - "description": "需要转换为语音并发送的完整、自然、适合口语的文本内容。", + "description": "需要转换为语音并发送的完整、自然、适合口语的文本内容。注意:只能是说话内容,不能是歌词或唱歌!", "required": True }, "voice_style": { @@ -100,14 +100,15 @@ class TTSVoiceAction(BaseAction): } action_require: ClassVar[list] = [ - "在调用此动作时,你必须在 'text' 参数中提供要合成语音的完整回复内容。这是强制性的。", - "当用户明确请求使用语音进行回复时,例如‘发个语音听听’、‘用语音说’等。", + "【核心限制】此动作只能用于说话,绝对不能用于唱歌!TTS无法发出有音调的歌声,只会输出平淡的念白。如果用户要求唱歌,不要使用此动作!", + "在调用此动作时,你必须在 'tts_voice_text' 参数中提供要合成语音的完整回复内容。这是强制性的。", + "当用户明确请求使用语音进行回复时,例如'发个语音听听'、'用语音说'等。", "当对话内容适合用语音表达,例如讲故事、念诗、撒嬌或进行角色扮演时。", "在表达特殊情感(如安慰、鼓励、庆祝)的场景下,可以主动使用语音来增强感染力。", "不要在日常的、简短的问答或闲聊中频繁使用语音,避免打扰用户。", - "提供的 'text' 内容必须是纯粹的对话,不能包含任何括号或方括号括起来的动作、表情、或场景描述(例如,不要出现 '(笑)' 或 '[歪头]')", - "**重要**:此动作专为语音合成设计,因此 'text' 参数的内容必须是纯净、标准的口语文本。请务必抑制你通常的、富有表现力的文本风格,不要使用任何辅助聊天或增强视觉效果的特殊符号(例如 '♪', '~', '∽', '☆' 等),因为它们无法被正确合成为语音。", - "【**最终规则**】'text' 参数中,所有句子和停顿【必须】使用且只能使用以下四个标准标点符号:',' (逗号)、'。' (句号)、'?' (问号)、'!' (叹号)。任何其他符号,特别是 '...'、'~' 以及任何表情符号或装饰性符号,都【严禁】出现,否则将导致语音合成严重失败。" + "提供的 'tts_voice_text' 内容必须是纯粹的对话,不能包含任何括号或方括号括起来的动作、表情、或场景描述(例如,不要出现 '(笑)' 或 '[歪头]')", + "**重要**:此动作专为语音合成设计,因此 'tts_voice_text' 参数的内容必须是纯净、标准的口语文本。请务必抑制你通常的、富有表现力的文本风格,不要使用任何辅助聊天或增强视觉效果的特殊符号(例如 '♪', '~', '∽', '☆' 等),因为它们无法被正确合成为语音。", + "【**最终规则**】'tts_voice_text' 参数中,所有句子和停顿【必须】使用且只能使用以下四个标准标点符号:',' (逗号)、'。' (句号)、'?' (问号)、'!' (叹号)。任何其他符号,特别是 '...'、'~' 以及任何表情符号或装饰性符号,都【严禁】出现,否则将导致语音合成严重失败。" ] def __init__(self, *args, **kwargs): From b2a0196398ad3f2954a216b21e05b45a7a7bd448 Mon Sep 17 00:00:00 2001 From: tt-P607 <68868379+tt-P607@users.noreply.github.com> Date: Sat, 29 Nov 2025 17:20:00 +0800 Subject: [PATCH 04/28] =?UTF-8?q?feat(KFC):=20=E4=B8=BA=E7=A7=81=E4=BA=BA?= =?UTF-8?q?=E8=81=8A=E5=A4=A9=E5=AE=9E=E7=8E=B0=E4=B8=BB=E5=8A=A8=E6=80=9D?= =?UTF-8?q?=E8=80=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 此提交为 Kokoro Flow Chatter(KFC)引入了主动思考机制,使其在私人聊天中在一段时间沉默后主动发起对话。此功能旨在通过模拟一个会主动思考并联系用户的角色,创造更自然和有趣的互动。 此功能的关键组成部分包括: - **主动思考回调**:`chatter.py` 中新增的回调处理整个主动思考流程,从上下文构建、提示生成到调用 LLM 和执行操作。 - **调度器集成**:`kfc_scheduler_adapter.py` 现在向 `UnifiedScheduler` 注册了一个定期任务,用于检查符合主动思考条件的会话。 - **触发条件**:主动思考的触发基于多个因素的组合,包括沉默时间、关系评分、概率检查以及“安静时间”,以避免打扰用户,系统现在获取全局关系分数以进行更准确的评估。- **专用提示**:`prompt_generator.py` 中的新提示专为此场景设计。它鼓励大语言模型决定是否发起联系,并将“不要打扰”作为一个有效且谨慎的选择。- **配置**:在 `bot_config_template.toml` 中添加了新设置以控制主动思维功能,包括静默阈值、最低亲和力和安静时间。- **AFC 集成**:通用的 `affinity_flow_chatter` 现在会检查 KFC 的主动思维是否在私人聊天中启用,并将控制权交给该功能,从而防止重复发送主动消息。 --- .../proactive/proactive_thinking_executor.py | 36 ++ .../built_in/kokoro_flow_chatter/chatter.py | 106 ++++++ .../kfc_scheduler_adapter.py | 309 +++++++++++++++++- .../built_in/kokoro_flow_chatter/models.py | 9 + .../kokoro_flow_chatter/prompt_generator.py | 80 ++++- .../kokoro_flow_chatter/session_manager.py | 11 + template/bot_config_template.toml | 11 +- 7 files changed, 531 insertions(+), 31 deletions(-) diff --git a/src/plugins/built_in/affinity_flow_chatter/proactive/proactive_thinking_executor.py b/src/plugins/built_in/affinity_flow_chatter/proactive/proactive_thinking_executor.py index 6da21b8bd..6ec9eab04 100644 --- a/src/plugins/built_in/affinity_flow_chatter/proactive/proactive_thinking_executor.py +++ b/src/plugins/built_in/affinity_flow_chatter/proactive/proactive_thinking_executor.py @@ -699,6 +699,42 @@ async def execute_proactive_thinking(stream_id: str): try: # 0. 前置检查 + + # 0.-1 检查是否是私聊且 KFC 主动思考已启用(让 KFC 接管私聊主动思考) + try: + from src.chat.message_receive.chat_stream import get_chat_manager + chat_manager = get_chat_manager() + chat_stream = await chat_manager.get_stream(stream_id) + + # 判断是否是私聊(使用 chat_type 枚举或从 stream_id 判断) + is_private = False + if chat_stream: + try: + is_private = chat_stream.chat_type.name == "private" + except Exception: + # 回退:从 stream_id 判断(私聊通常不包含 "group") + is_private = "group" not in stream_id.lower() + + if is_private: + # 这是一个私聊,检查 KFC 是否启用且其主动思考是否启用 + try: + from src.config.config import global_config + kfc_config = getattr(global_config, 'kokoro_flow_chatter', None) + if kfc_config: + kfc_enabled = getattr(kfc_config, 'enable', False) + proactive_config = getattr(kfc_config, 'proactive_thinking', None) + proactive_enabled = getattr(proactive_config, 'enabled', False) if proactive_config else False + + if kfc_enabled and proactive_enabled: + logger.debug( + f"[主动思考] 私聊 {stream_id} 由 KFC 主动思考接管,跳过通用主动思考" + ) + return + except Exception as e: + logger.debug(f"检查 KFC 配置时出错,继续执行通用主动思考: {e}") + except Exception as e: + logger.warning(f"检查私聊/KFC 状态时出错: {e},继续执行") + # 0.0 检查聊天流是否正在处理消息(双重保护) try: from src.chat.message_receive.chat_stream import get_chat_manager diff --git a/src/plugins/built_in/kokoro_flow_chatter/chatter.py b/src/plugins/built_in/kokoro_flow_chatter/chatter.py index e19982718..0cc30a4fa 100644 --- a/src/plugins/built_in/kokoro_flow_chatter/chatter.py +++ b/src/plugins/built_in/kokoro_flow_chatter/chatter.py @@ -143,6 +143,12 @@ class KokoroFlowChatter(BaseChatter): self.scheduler.set_continuous_thinking_callback( self._on_continuous_thinking ) + + # 设置主动思考回调 + if self.enable_proactive: + self.scheduler.set_proactive_thinking_callback( + self._on_proactive_thinking + ) async def execute(self, context: StreamContext) -> dict: """ @@ -573,6 +579,106 @@ class KokoroFlowChatter(BaseChatter): # 简单模式:更新焦虑程度(已在scheduler中处理) # 这里可以添加额外的逻辑 + async def _on_proactive_thinking(self, session: KokoroSession, trigger_reason: str) -> None: + """ + 主动思考回调 + + 当长时间沉默后触发,让 LLM 决定是否主动联系用户。 + 这不是"必须发消息",而是"想一想要不要联系对方"。 + + Args: + session: 会话 + trigger_reason: 触发原因描述 + """ + logger.info(f"[KFC] 处理主动思考: user={session.user_id}, reason={trigger_reason}") + + try: + # 创建正确的 ActionExecutor(使用 session 的 stream_id) + from .action_executor import ActionExecutor + proactive_action_executor = ActionExecutor(session.stream_id) + + # 加载可用动作 + available_actions = await proactive_action_executor.load_actions() + + # 获取 chat_stream 用于构建上下文 + chat_stream = await self._get_chat_stream(session.stream_id) + + # 构建 S4U 上下文数据(包含全局关系信息) + context_data: dict[str, str] = {} + if chat_stream: + try: + from .context_builder import KFCContextBuilder + context_builder = KFCContextBuilder(chat_stream) + context_data = await context_builder.build_all_context( + sender_name=session.user_id, # 主动思考时用 user_id + target_message="", # 没有目标消息 + context=None, + ) + logger.debug(f"[KFC] 主动思考上下文构建完成: {list(context_data.keys())}") + except Exception as e: + logger.warning(f"[KFC] 主动思考构建S4U上下文失败: {e}") + + # 生成主动思考提示词(传入 context_data 以获取全局关系信息) + system_prompt, user_prompt = self.prompt_generator.generate_proactive_thinking_prompt( + session, + trigger_context=trigger_reason, + available_actions=available_actions, + context_data=context_data, + chat_stream=chat_stream, + ) + + # 调用 LLM + llm_response = await self._call_llm(system_prompt, user_prompt) + self.stats["llm_calls"] += 1 + + # 解析响应 + parsed_response = proactive_action_executor.parse_llm_response(llm_response) + + # 检查是否决定不打扰(do_nothing) + is_do_nothing = ( + len(parsed_response.actions) == 0 or + (len(parsed_response.actions) == 1 and parsed_response.actions[0].type == "do_nothing") + ) + + if is_do_nothing: + logger.info(f"[KFC] 主动思考决定不打扰: user={session.user_id}, thought={parsed_response.thought[:50]}...") + # 记录这次"决定不打扰"的思考 + entry = MentalLogEntry( + event_type=MentalLogEventType.PROACTIVE_THINKING, + timestamp=time.time(), + thought=parsed_response.thought, + content="决定不打扰", + emotional_snapshot=session.emotional_state.to_dict(), + metadata={"trigger_reason": trigger_reason, "action": "do_nothing"}, + ) + session.add_mental_log_entry(entry) + await self.session_manager.save_session(session.user_id) + return + + # 执行决定的动作 + execution_result = await proactive_action_executor.execute_actions( + parsed_response, + session, + chat_stream + ) + + logger.info(f"[KFC] 主动思考执行完成: user={session.user_id}, has_reply={execution_result.get('has_reply')}") + + # 如果发送了消息,进入等待状态 + if execution_result.get("has_reply"): + session.start_waiting( + expected_reaction=parsed_response.expected_user_reaction, + max_wait=parsed_response.max_wait_seconds + ) + + # 保存会话 + await self.session_manager.save_session(session.user_id) + + except Exception as e: + logger.error(f"[KFC] 主动思考处理失败: {e}") + import traceback + traceback.print_exc() + def _build_result( self, success: bool, diff --git a/src/plugins/built_in/kokoro_flow_chatter/kfc_scheduler_adapter.py b/src/plugins/built_in/kokoro_flow_chatter/kfc_scheduler_adapter.py index 1e73ebc4b..f512e8c67 100644 --- a/src/plugins/built_in/kokoro_flow_chatter/kfc_scheduler_adapter.py +++ b/src/plugins/built_in/kokoro_flow_chatter/kfc_scheduler_adapter.py @@ -5,16 +5,19 @@ Kokoro Flow Chatter 调度器适配器 不再自己创建后台循环,而是复用全局调度器的基础设施。 核心功能: -1. 会话等待超时检测 -2. 连续思考触发 -3. 与 UnifiedScheduler 的集成 +1. 会话等待超时检测(短期) +2. 连续思考触发(等待期间的内心活动) +3. 主动思考检测(长期沉默后主动发起对话) +4. 与 UnifiedScheduler 的集成 """ import asyncio import time +from datetime import datetime from typing import TYPE_CHECKING, Any, Callable, Coroutine, Optional from src.common.logger import get_logger +from src.config.config import global_config from src.plugin_system.apis.unified_scheduler import ( TriggerType, unified_scheduler, @@ -41,9 +44,10 @@ class KFCSchedulerAdapter: 使用 UnifiedScheduler 实现 KFC 的定时任务功能,不再自行管理后台循环。 核心功能: - 1. 定期检查处于 WAITING 状态的会话 - 2. 在特定时间点触发"连续思考" - 3. 处理等待超时并触发决策 + 1. 定期检查处于 WAITING 状态的会话(短期等待超时) + 2. 在特定时间点触发"连续思考"(等待期间内心活动) + 3. 定期检查长期沉默的会话,触发"主动思考"(长期主动发起) + 4. 处理等待超时并触发决策 """ # 连续思考触发点(等待进度的百分比) @@ -51,45 +55,86 @@ class KFCSchedulerAdapter: # 任务名称常量 TASK_NAME_WAITING_CHECK = "kfc_waiting_check" + TASK_NAME_PROACTIVE_CHECK = "kfc_proactive_check" + + # 主动思考检查间隔(5分钟) + PROACTIVE_CHECK_INTERVAL = 300.0 def __init__( self, check_interval: float = 10.0, on_timeout_callback: Optional[Callable[[KokoroSession], Coroutine[Any, Any, None]]] = None, on_continuous_thinking_callback: Optional[Callable[[KokoroSession], Coroutine[Any, Any, None]]] = None, + on_proactive_thinking_callback: Optional[Callable[[KokoroSession, str], Coroutine[Any, Any, None]]] = None, ): """ 初始化调度器适配器 Args: - check_interval: 检查间隔(秒) + check_interval: 等待检查间隔(秒) on_timeout_callback: 超时回调函数 on_continuous_thinking_callback: 连续思考回调函数 + on_proactive_thinking_callback: 主动思考回调函数,接收 (session, trigger_reason) """ self.check_interval = check_interval self.on_timeout_callback = on_timeout_callback self.on_continuous_thinking_callback = on_continuous_thinking_callback + self.on_proactive_thinking_callback = on_proactive_thinking_callback self._registered = False self._schedule_id: Optional[str] = None + self._proactive_schedule_id: Optional[str] = None + + # 加载主动思考配置 + self._load_proactive_config() # 统计信息 self._stats = { "total_checks": 0, "timeouts_triggered": 0, "continuous_thinking_triggered": 0, + "proactive_thinking_triggered": 0, + "proactive_checks": 0, "last_check_time": 0.0, } logger.info("KFCSchedulerAdapter 初始化完成") + def _load_proactive_config(self) -> None: + """加载主动思考相关配置""" + try: + if global_config and hasattr(global_config, 'kokoro_flow_chatter'): + proactive_cfg = global_config.kokoro_flow_chatter.proactive_thinking + self.proactive_enabled = proactive_cfg.enabled + self.silence_threshold = proactive_cfg.silence_threshold_seconds + self.min_interval = proactive_cfg.min_interval_between_proactive + self.min_affinity = getattr(proactive_cfg, 'min_affinity_for_proactive', 0.3) + self.quiet_hours_start = getattr(proactive_cfg, 'quiet_hours_start', "23:00") + self.quiet_hours_end = getattr(proactive_cfg, 'quiet_hours_end', "07:00") + else: + # 默认值 + self.proactive_enabled = True + self.silence_threshold = 7200 # 2小时 + self.min_interval = 1800 # 30分钟 + self.min_affinity = 0.3 + self.quiet_hours_start = "23:00" + self.quiet_hours_end = "07:00" + except Exception as e: + logger.warning(f"加载主动思考配置失败,使用默认值: {e}") + self.proactive_enabled = True + self.silence_threshold = 7200 + self.min_interval = 1800 + self.min_affinity = 0.3 + self.quiet_hours_start = "23:00" + self.quiet_hours_end = "07:00" + async def start(self) -> None: """启动调度器(注册到 UnifiedScheduler)""" if self._registered: logger.warning("KFC 调度器已在运行中") return - # 注册周期性检查任务 + # 注册周期性等待检查任务(每10秒) self._schedule_id = await unified_scheduler.create_schedule( callback=self._check_waiting_sessions, trigger_type=TriggerType.TIME, @@ -97,9 +142,22 @@ class KFCSchedulerAdapter: is_recurring=True, task_name=self.TASK_NAME_WAITING_CHECK, force_overwrite=True, - timeout=30.0, # 单次检查超时 30 秒 + timeout=30.0, ) + # 如果启用了主动思考,注册主动思考检查任务(每5分钟) + if self.proactive_enabled: + self._proactive_schedule_id = await unified_scheduler.create_schedule( + callback=self._check_proactive_sessions, + trigger_type=TriggerType.TIME, + trigger_config={"delay_seconds": self.PROACTIVE_CHECK_INTERVAL}, + is_recurring=True, + task_name=self.TASK_NAME_PROACTIVE_CHECK, + force_overwrite=True, + timeout=120.0, # 主动思考可能需要更长时间(涉及 LLM 调用) + ) + logger.info(f"KFC 主动思考调度已注册: schedule_id={self._proactive_schedule_id}") + self._registered = True logger.info(f"KFC 调度器已注册到 UnifiedScheduler: schedule_id={self._schedule_id}") @@ -111,12 +169,16 @@ class KFCSchedulerAdapter: try: if self._schedule_id: await unified_scheduler.remove_schedule(self._schedule_id) - logger.info(f"KFC 调度器已从 UnifiedScheduler 注销: schedule_id={self._schedule_id}") + logger.info(f"KFC 等待检查调度已注销: schedule_id={self._schedule_id}") + if self._proactive_schedule_id: + await unified_scheduler.remove_schedule(self._proactive_schedule_id) + logger.info(f"KFC 主动思考调度已注销: schedule_id={self._proactive_schedule_id}") except Exception as e: logger.error(f"停止 KFC 调度器时出错: {e}") finally: self._registered = False self._schedule_id = None + self._proactive_schedule_id = None async def _check_waiting_sessions(self) -> None: """检查所有等待中的会话(由 UnifiedScheduler 调用) @@ -344,6 +406,223 @@ class KFCSchedulerAdapter: return random.choice(thoughts) + # ======================================== + # 主动思考相关方法(长期沉默后主动发起对话) + # ======================================== + + async def _check_proactive_sessions(self) -> None: + """ + 检查所有会话是否需要触发主动思考(由 UnifiedScheduler 定期调用) + + 主动思考的触发条件: + 1. 会话处于 IDLE 状态(不在等待回复中) + 2. 距离上次活动超过 silence_threshold + 3. 距离上次主动思考超过 min_interval + 4. 不在勿扰时段 + 5. 与用户的关系亲密度足够 + """ + if not self.proactive_enabled: + return + + # 检查是否在勿扰时段 + if self._is_quiet_hours(): + logger.debug("[KFC] 当前处于勿扰时段,跳过主动思考检查") + return + + self._stats["proactive_checks"] += 1 + + session_manager = get_session_manager() + all_sessions = await session_manager.get_all_sessions() + + current_time = time.time() + + for session in all_sessions: + try: + # 检查是否满足主动思考条件(异步获取全局关系分数) + trigger_reason = await self._should_trigger_proactive(session, current_time) + if trigger_reason: + logger.info( + f"[KFC] 触发主动思考: user={session.user_id}, reason={trigger_reason}" + ) + await self._handle_proactive_thinking(session, trigger_reason) + except Exception as e: + logger.error(f"检查主动思考条件时出错 (user={session.user_id}): {e}") + + def _is_quiet_hours(self) -> bool: + """ + 检查当前是否处于勿扰时段 + + 支持跨午夜的时段(如 23:00 到 07:00) + """ + try: + now = datetime.now() + current_minutes = now.hour * 60 + now.minute + + # 解析开始时间 + start_parts = self.quiet_hours_start.split(":") + start_minutes = int(start_parts[0]) * 60 + int(start_parts[1]) + + # 解析结束时间 + end_parts = self.quiet_hours_end.split(":") + end_minutes = int(end_parts[0]) * 60 + int(end_parts[1]) + + # 处理跨午夜的情况 + if start_minutes <= end_minutes: + # 不跨午夜(如 09:00 到 17:00) + return start_minutes <= current_minutes < end_minutes + else: + # 跨午夜(如 23:00 到 07:00) + return current_minutes >= start_minutes or current_minutes < end_minutes + + except Exception as e: + logger.warning(f"解析勿扰时段配置失败: {e}") + return False + + async def _should_trigger_proactive( + self, + session: KokoroSession, + current_time: float + ) -> Optional[str]: + """ + 检查是否应该触发主动思考 + + 使用全局关系数据库中的关系分数(而不是 KFC 内部的 emotional_state) + + 概率机制:关系越亲密,触发概率越高 + - 亲密度 0.3 → 触发概率 10% + - 亲密度 0.5 → 触发概率 30% + - 亲密度 0.7 → 触发概率 55% + - 亲密度 1.0 → 触发概率 90% + + Args: + session: 会话 + current_time: 当前时间戳 + + Returns: + 触发原因字符串,如果不触发则返回 None + """ + import random + + # 条件1:必须处于 IDLE 状态 + if session.status != SessionStatus.IDLE: + return None + + # 条件2:距离上次活动超过沉默阈值 + silence_duration = current_time - session.last_activity_at + if silence_duration < self.silence_threshold: + return None + + # 条件3:距离上次主动思考超过最小间隔 + if session.last_proactive_at is not None: + time_since_last_proactive = current_time - session.last_proactive_at + if time_since_last_proactive < self.min_interval: + return None + + # 条件4:从数据库获取全局关系分数 + relationship_score = await self._get_global_relationship_score(session.user_id) + if relationship_score < self.min_affinity: + logger.debug( + f"主动思考跳过(关系分数不足): user={session.user_id}, " + f"score={relationship_score:.2f}, min={self.min_affinity:.2f}" + ) + return None + + # 条件5:基于关系分数的概率判断 + # 公式:probability = 0.1 + 0.8 * ((score - min_affinity) / (1.0 - min_affinity))^1.5 + # 这样分数从 min_affinity 到 1.0 映射到概率 10% 到 90% + # 使用1.5次幂让曲线更陡峭,高亲密度时概率增长更快 + normalized_score = (relationship_score - self.min_affinity) / (1.0 - self.min_affinity) + probability = 0.1 + 0.8 * (normalized_score ** 1.5) + probability = min(probability, 0.9) # 最高90%,永远不是100%确定 + + if random.random() > probability: + # 这次检查没触发,但记录一下(用于调试) + logger.debug( + f"主动思考概率检查未通过: user={session.user_id}, " + f"score={relationship_score:.2f}, probability={probability:.1%}" + ) + return None + + # 所有条件满足,生成触发原因 + silence_hours = silence_duration / 3600 + logger.info( + f"主动思考触发: user={session.user_id}, " + f"silence={silence_hours:.1f}h, score={relationship_score:.2f}, prob={probability:.1%}" + ) + return f"沉默了{silence_hours:.1f}小时,想主动关心一下对方" + + async def _get_global_relationship_score(self, user_id: str) -> float: + """ + 从全局关系数据库获取关系分数 + + Args: + user_id: 用户ID + + Returns: + 关系分数 (0.0-1.0),如果没有记录返回默认值 0.3 + """ + try: + from src.common.database.api.specialized import get_user_relationship + + # 从 user_id 解析 platform(格式通常是 "platform_userid") + # 这里假设 user_id 中包含 platform 信息,需要根据实际情况调整 + # 先尝试直接查询,如果失败再用默认值 + relationship = await get_user_relationship( + platform="qq", # TODO: 从 session 或 stream_id 获取真实 platform + user_id=user_id, + target_id="bot", + ) + + if relationship and hasattr(relationship, 'relationship_score'): + return relationship.relationship_score + + # 没有找到关系记录,返回默认值 + return 0.3 + + except Exception as e: + logger.warning(f"获取全局关系分数失败 (user={user_id}): {e}") + return 0.3 # 出错时返回较低的默认值 + + async def _handle_proactive_thinking( + self, + session: KokoroSession, + trigger_reason: str + ) -> None: + """ + 处理主动思考 + + Args: + session: 会话 + trigger_reason: 触发原因 + """ + self._stats["proactive_thinking_triggered"] += 1 + + # 更新会话状态 + session.last_proactive_at = time.time() + session.proactive_count += 1 + + # 添加主动思考日志 + proactive_entry = MentalLogEntry( + event_type=MentalLogEventType.PROACTIVE_THINKING, + timestamp=time.time(), + thought=trigger_reason, + content="主动思考触发", + emotional_snapshot=session.emotional_state.to_dict(), + metadata={"trigger_reason": trigger_reason}, + ) + session.add_mental_log_entry(proactive_entry) + + # 保存会话状态 + session_manager = get_session_manager() + await session_manager.save_session(session.user_id) + + # 调用主动思考回调(由 chatter 处理实际的 LLM 调用和动作执行) + if self.on_proactive_thinking_callback: + try: + await self.on_proactive_thinking_callback(session, trigger_reason) + except Exception as e: + logger.error(f"执行主动思考回调时出错 (user={session.user_id}): {e}") + def set_timeout_callback( self, callback: Callable[[KokoroSession], Coroutine[Any, Any, None]], @@ -358,6 +637,13 @@ class KFCSchedulerAdapter: """设置连续思考回调函数""" self.on_continuous_thinking_callback = callback + def set_proactive_thinking_callback( + self, + callback: Callable[[KokoroSession, str], Coroutine[Any, Any, None]], + ) -> None: + """设置主动思考回调函数""" + self.on_proactive_thinking_callback = callback + def get_stats(self) -> dict[str, Any]: """获取统计信息""" return { @@ -388,6 +674,7 @@ async def initialize_scheduler( check_interval: float = 10.0, on_timeout_callback: Optional[Callable[[KokoroSession], Coroutine[Any, Any, None]]] = None, on_continuous_thinking_callback: Optional[Callable[[KokoroSession], Coroutine[Any, Any, None]]] = None, + on_proactive_thinking_callback: Optional[Callable[[KokoroSession, str], Coroutine[Any, Any, None]]] = None, ) -> KFCSchedulerAdapter: """ 初始化并启动调度器 @@ -396,6 +683,7 @@ async def initialize_scheduler( check_interval: 检查间隔 on_timeout_callback: 超时回调 on_continuous_thinking_callback: 连续思考回调 + on_proactive_thinking_callback: 主动思考回调 Returns: KFCSchedulerAdapter: 调度器适配器实例 @@ -405,6 +693,7 @@ async def initialize_scheduler( check_interval=check_interval, on_timeout_callback=on_timeout_callback, on_continuous_thinking_callback=on_continuous_thinking_callback, + on_proactive_thinking_callback=on_proactive_thinking_callback, ) await _scheduler_adapter.start() return _scheduler_adapter diff --git a/src/plugins/built_in/kokoro_flow_chatter/models.py b/src/plugins/built_in/kokoro_flow_chatter/models.py index 19c5e49f2..dfa07becd 100644 --- a/src/plugins/built_in/kokoro_flow_chatter/models.py +++ b/src/plugins/built_in/kokoro_flow_chatter/models.py @@ -47,6 +47,7 @@ class MentalLogEventType(Enum): TIMEOUT_DECISION = "timeout_decision" # 超时决策事件 STATE_CHANGE = "state_change" # 状态变更事件 CONTINUOUS_THINKING = "continuous_thinking" # 连续思考事件 + PROACTIVE_THINKING = "proactive_thinking" # 主动思考事件(长期沉默后主动发起) def __str__(self) -> str: return self.value @@ -222,6 +223,10 @@ class KokoroSession: continuous_thinking_count: int = 0 last_continuous_thinking_at: Optional[float] = None + # 主动思考相关(长期沉默后主动发起对话) + last_proactive_at: Optional[float] = None # 上次主动思考的时间 + proactive_count: int = 0 # 主动思考的次数(累计) + def add_mental_log_entry(self, entry: MentalLogEntry, max_log_size: int = 100) -> None: """ 添加心理活动日志条目 @@ -284,6 +289,8 @@ class KokoroSession: "total_interactions": self.total_interactions, "continuous_thinking_count": self.continuous_thinking_count, "last_continuous_thinking_at": self.last_continuous_thinking_at, + "last_proactive_at": self.last_proactive_at, + "proactive_count": self.proactive_count, } @classmethod @@ -320,6 +327,8 @@ class KokoroSession: total_interactions=data.get("total_interactions", 0), continuous_thinking_count=data.get("continuous_thinking_count", 0), last_continuous_thinking_at=data.get("last_continuous_thinking_at"), + last_proactive_at=data.get("last_proactive_at"), + proactive_count=data.get("proactive_count", 0), ) diff --git a/src/plugins/built_in/kokoro_flow_chatter/prompt_generator.py b/src/plugins/built_in/kokoro_flow_chatter/prompt_generator.py index 4e8a18a2c..fdda5f90c 100644 --- a/src/plugins/built_in/kokoro_flow_chatter/prompt_generator.py +++ b/src/plugins/built_in/kokoro_flow_chatter/prompt_generator.py @@ -205,23 +205,31 @@ class PromptGenerator: {narrative_history} ## 当前情况 +现在是 {current_time}。 +距离你们上次对话已经过去了 {silence_duration}。 + +{relation_block} + {trigger_context} -## 触发类型 -{trigger_type} - ## 你的任务 -这是一次"主动思考"触发。你不是因为收到消息才行动,而是因为内心的某种驱动力。 -现在你需要: -1. 感受一下现在的心情和想法 -2. 思考是否需要主动联系对方 -3. 如果决定主动,想好要说什么或做什么 -4. 如果决定不主动,也要有明确的理由 +这是一次"主动思考"——你突然想起了对方,想了想要不要联系ta。 -注意:主动联系应该是自然的、符合你们关系的。不要显得过于依赖或强迫。 -你可以选择发消息、发图片、唱首歌、或者只是在心里想想然后什么都不做。 +请像真人一样思考: +1. **感受一下此刻的心情**:你真的想联系对方吗?还是只是习惯性地想起? +2. **考虑对方的情况**:现在这个时间,对方可能在干嘛?方便被打扰吗? +3. **回忆上次对话**:你们聊了什么?对话是怎么结束的? +4. **做出决定**: + - 如果决定联系:想好说什么,要自然,不要刻意 + - 如果决定不联系:这也完全ok,不打扰也是一种温柔 -请以JSON格式输出你的完整心理活动和决策。""" +**重要提醒**: +- 你不是必须发消息的,"算了,不打扰了"是完全合理的选择 +- 如果决定联系,内容要自然——可以是分享、关心、延续话题,但不要生硬 +- 避免机械式的问候(如固定的"早安""晚安"),除非你们的关系真的会这样打招呼 + +请以JSON格式输出你的完整心理活动和决策。 +如果决定不打扰,actions 里放一个 `{{"type": "do_nothing"}}` 就好。""" def __init__(self, persona_description: str = ""): """ @@ -663,34 +671,72 @@ class PromptGenerator: def generate_proactive_thinking_prompt( self, session: KokoroSession, - trigger_type: str, trigger_context: str, available_actions: Optional[dict[str, ActionInfo]] = None, + context_data: Optional[dict[str, str]] = None, + chat_stream: Optional["ChatStream"] = None, ) -> tuple[str, str]: """ 生成主动思考场景的提示词 这是私聊专属的功能,用于实现"主动找话题、主动关心用户"。 + 主动思考不是"必须发消息",而是"想一想要不要联系对方"。 Args: session: 当前会话 - trigger_type: 触发类型(如 silence_timeout, memory_event 等) - trigger_context: 触发上下文描述 + trigger_context: 触发上下文描述(如"沉默了2小时") available_actions: 可用动作字典 + context_data: S4U上下文数据(包含全局关系信息) + chat_stream: 聊天流 Returns: tuple[str, str]: (系统提示词, 用户提示词) """ - system_prompt = self.generate_system_prompt(session, available_actions) + from datetime import datetime + import time + + # 生成系统提示词(使用 context_data 获取完整的关系和记忆信息) + system_prompt = self.generate_system_prompt( + session, + available_actions, + context_data=context_data, + chat_stream=chat_stream, + ) narrative_history = self._format_narrative_history( session.mental_log, max_entries=10, # 主动思考时使用较少的历史 ) + # 计算沉默时长 + silence_seconds = time.time() - session.last_activity_at + if silence_seconds < 3600: + silence_duration = f"{silence_seconds / 60:.0f}分钟" + else: + silence_duration = f"{silence_seconds / 3600:.1f}小时" + + # 当前时间 + current_time = datetime.now().strftime("%Y年%m月%d日 %H:%M") + + # 从 context_data 获取全局关系信息(这是正确的来源) + relation_block = "" + if context_data: + relation_info = context_data.get("relation_info", "") + if relation_info: + relation_block = f"### 你与对方的关系\n{relation_info}" + + if not relation_block: + # 回退:使用 session 的情感状态(不太准确但有总比没有好) + es = session.emotional_state + relation_block = f"""### 你与对方的关系 +- 当前心情:{es.mood} +- 对对方的印象:{es.impression_of_user or "还在慢慢了解中"}""" + user_prompt = self.PROACTIVE_THINKING_USER_PROMPT_TEMPLATE.format( narrative_history=narrative_history, - trigger_type=trigger_type, + current_time=current_time, + silence_duration=silence_duration, + relation_block=relation_block, trigger_context=trigger_context, ) diff --git a/src/plugins/built_in/kokoro_flow_chatter/session_manager.py b/src/plugins/built_in/kokoro_flow_chatter/session_manager.py index 7884f6b6a..9661a0247 100644 --- a/src/plugins/built_in/kokoro_flow_chatter/session_manager.py +++ b/src/plugins/built_in/kokoro_flow_chatter/session_manager.py @@ -421,6 +421,17 @@ class SessionManager: return waiting_sessions + async def get_all_sessions(self) -> list[KokoroSession]: + """ + 获取所有内存中的会话 + + 用于主动思考检查等需要遍历所有会话的场景 + + Returns: + list[KokoroSession]: 所有会话列表 + """ + return list(self._sessions.values()) + async def get_session_statistics(self) -> dict: """ 获取会话统计信息 diff --git a/template/bot_config_template.toml b/template/bot_config_template.toml index d0ea7e5e4..88115087d 100644 --- a/template/bot_config_template.toml +++ b/template/bot_config_template.toml @@ -1,5 +1,5 @@ [inner] -version = "7.9.2" +version = "7.9.3" #----以下是给开发人员阅读的,如果你只是部署了MoFox-Bot,不需要阅读---- #如果你想要修改配置文件,请递增version的值 @@ -655,6 +655,8 @@ enable_continuous_thinking = true # 是否在等待期间启用心理活动更 # --- 私聊专属主动思考配置 --- # 注意:这是KFC专属的主动思考配置,只有当KFC启用时才生效。 # 它旨在模拟更真实、情感驱动的互动,而非简单的定时任务。 +# 「主动思考」是「想一想要不要联系对方」,不是「到时间就发消息」。 +# 她可能决定说些什么,也可能决定「算了,不打扰了」。 [kokoro_flow_chatter.proactive_thinking] enabled = true # 是否启用KFC的私聊主动思考。 @@ -667,6 +669,7 @@ min_affinity_for_proactive = 0.3 # 需要达到最低好感度,她才会开始 # 3. 频率呼吸:为了避免打扰,她的关心总是有间隔的。 min_interval_between_proactive = 1800 # 两次主动思考之间的最小间隔(秒,默认30分钟)。 -# 4. 自然问候:在特定的时间,她会像朋友一样送上问候。 -enable_morning_greeting = true # 是否启用早安问候 (例如: 8:00 - 9:00)。 -enable_night_greeting = true # 是否启用晚安问候 (例如: 22:00 - 23:00)。 +# 4. 勿扰时段:在这个时间范围内,不会触发主动思考(避免深夜打扰用户)。 +# 格式为 "HH:MM",使用24小时制。如果 start > end,表示跨越午夜(如 23:00 到 07:00)。 +quiet_hours_start = "23:00" # 勿扰开始时间 +quiet_hours_end = "07:00" # 勿扰结束时间 From 4245228cb78bbff28448b5371a1eb066b413ca41 Mon Sep 17 00:00:00 2001 From: tt-P607 <68868379+tt-P607@users.noreply.github.com> Date: Sat, 29 Nov 2025 19:49:32 +0800 Subject: [PATCH 05/28] =?UTF-8?q?feat(KFC):=20=E5=AE=9E=E7=8E=B0=20V7=20?= =?UTF-8?q?=E4=BA=A4=E4=BA=92=E6=A8=A1=E5=9E=8B=EF=BC=8C=E5=85=B7=E5=A4=87?= =?UTF-8?q?=E4=B8=AD=E6=96=AD=E5=92=8C=E6=83=85=E6=84=9F=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E3=80=82=E6=9C=AC=E6=AC=A1=E9=87=8D=E5=A4=A7?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=BD=BB=E5=BA=95=E6=94=B9=E9=80=A0=E4=BA=86?= =?UTF-8?q?=20Kokoro=20Flow=20Chatter=20(KFC)=20=E7=9A=84=E4=BA=A4?= =?UTF-8?q?=E4=BA=92=E6=A8=A1=E5=9E=8B=EF=BC=8C=E5=BC=95=E5=85=A5=E4=BA=86?= =?UTF-8?q?=E6=9B=B4=E5=8A=A0=E7=A8=B3=E5=81=A5=E3=80=81=E8=87=AA=E7=84=B6?= =?UTF-8?q?=E4=B8=94=E5=AE=89=E5=85=A8=E7=9A=84=E7=94=A8=E6=88=B7=E4=BD=93?= =?UTF-8?q?=E9=AA=8C=E3=80=82=E4=B8=BB=E8=A6=81=E5=8A=9F=E8=83=BD=E5=8C=85?= =?UTF-8?q?=E6=8B=AC=E5=A4=84=E7=90=86=E5=BF=AB=E9=80=9F=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E8=BE=93=E5=85=A5=E7=9A=84=E4=B8=AD=E6=96=AD=E6=9C=BA=E5=88=B6?= =?UTF-8?q?=E3=80=81=E6=94=B9=E5=96=84=E7=9A=84=E6=83=85=E6=84=9F=E7=A8=B3?= =?UTF-8?q?=E5=AE=9A=E6=80=A7=E4=BB=A5=E9=98=B2=E6=AD=A2=20AI=20=E8=A1=8C?= =?UTF-8?q?=E4=B8=BA=E5=BC=82=E5=B8=B8=EF=BC=8C=E4=BB=A5=E5=8F=8A=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E6=8F=90=E7=A4=BA=E7=9A=84=E5=85=A8=E9=9D=A2=E9=87=8D?= =?UTF-8?q?=E5=86=99=EF=BC=8C=E4=BB=A5=E6=8F=90=E4=BE=9B=E6=9B=B4=E8=87=AA?= =?UTF-8?q?=E7=84=B6=E3=80=81=E4=BA=BA=E6=80=A7=E5=8C=96=E7=9A=84=E5=93=8D?= =?UTF-8?q?=E5=BA=94=E3=80=82V7=20=E4=B8=BB=E8=A6=81=E5=A2=9E=E5=BC=BA?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=9A=20-=20**=E4=B8=AD=E6=96=AD=E6=9C=BA?= =?UTF-8?q?=E5=88=B6**=EF=BC=9A=E6=96=B0=E7=9A=84=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E7=8E=B0=E5=9C=A8=E5=8F=AF=E4=BB=A5=E4=B8=AD=E6=96=AD=E6=AD=A3?= =?UTF-8?q?=E5=9C=A8=E8=BF=9B=E8=A1=8C=E7=9A=84=20LLM=20=E5=A4=84=E7=90=86?= =?UTF-8?q?=E3=80=82=E8=A2=AB=E4=B8=AD=E6=96=AD=E7=9A=84=E4=B8=8A=E4=B8=8B?= =?UTF-8?q?=E6=96=87=E4=BC=9A=E8=A2=AB=E4=BF=9D=E5=AD=98=E5=B9=B6=E4=B8=8E?= =?UTF-8?q?=E6=96=B0=E6=B6=88=E6=81=AF=E5=90=88=E5=B9=B6=EF=BC=8C=E7=A1=AE?= =?UTF-8?q?=E4=BF=9D=E4=B8=8D=E4=BC=9A=E4=B8=A2=E5=A4=B1=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E8=BE=93=E5=85=A5=EF=BC=8C=E5=B9=B6=E6=8F=90=E4=BE=9B=E6=9B=B4?= =?UTF-8?q?=E5=8D=B3=E6=97=B6=E7=9A=84=E5=93=8D=E5=BA=94=E4=BD=93=E9=AA=8C?= =?UTF-8?q?=EF=BC=8C=E7=B1=BB=E4=BC=BC=E4=BA=8E=E7=8E=B0=E4=BB=A3=E5=8D=B3?= =?UTF-8?q?=E6=97=B6=E9=80=9A=E8=AE=AF=E5=BA=94=E7=94=A8=E3=80=82=20-=20**?= =?UTF-8?q?=E6=83=85=E6=84=9F=E5=AE=89=E5=85=A8=E4=B8=8E=E7=A8=B3=E5=AE=9A?= =?UTF-8?q?=E6=80=A7**=EF=BC=9A=20=20=20-=20=E7=8E=B0=E5=9C=A8=20AI=20?= =?UTF-8?q?=E4=B8=8D=E4=BC=9A=E9=9A=8F=E6=84=8F=E8=AE=BE=E7=BD=AE=E8=B4=9F?= =?UTF-8?q?=E9=9D=A2=E6=83=85=E7=BB=AA=E7=8A=B6=E6=80=81=E3=80=82=20=20=20?= =?UTF-8?q?-=20=E6=83=85=E7=BB=AA=E5=8F=98=E5=8C=96=E4=B8=BA=E6=B8=90?= =?UTF-8?q?=E8=BF=9B=E5=BC=8F=EF=BC=8C=E5=BC=BA=E5=BA=A6=E5=8F=98=E5=8C=96?= =?UTF-8?q?=E6=9C=89=E9=99=90=E5=88=B6=E3=80=82=20=20=20-=20=E6=96=B0?= =?UTF-8?q?=E7=9A=84=E2=80=9C=E6=83=85=E6=84=9F=E5=81=A5=E5=BA=B7=E6=A3=80?= =?UTF-8?q?=E6=9F=A5=E2=80=9D=E4=BC=9A=E5=9C=A8=E5=8A=A0=E8=BD=BD=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E6=95=B0=E6=8D=AE=E6=97=B6=E8=BF=9B=E8=A1=8C=E6=B8=85?= =?UTF-8?q?=E7=90=86=EF=BC=8C=E4=BB=A5=E9=98=B2=E6=81=A2=E5=A4=8D=E4=B8=8D?= =?UTF-8?q?=E7=A8=B3=E5=AE=9A=E7=8A=B6=E6=80=81=E3=80=82=20=20=20-=20?= =?UTF-8?q?=E5=AF=B9=E8=BF=9E=E7=BB=AD=E8=B7=9F=E8=BF=9B=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E5=AE=9E=E6=96=BD=E4=BA=86=E9=99=90=E5=88=B6=EF=BC=8C=E4=BB=A5?= =?UTF-8?q?=E9=81=BF=E5=85=8D=E5=90=91=E6=9C=AA=E5=93=8D=E5=BA=94=E7=9A=84?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E5=8F=91=E9=80=81=E5=9E=83=E5=9C=BE=E4=BF=A1?= =?UTF-8?q?=E6=81=AF=E3=80=82=20-=20**=E6=8F=90=E7=A4=BA=E9=87=8D=E6=9E=84?= =?UTF-8?q?**=EF=BC=9A=20=20=20-=20=E7=B3=BB=E7=BB=9F=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E5=B7=B2=E8=A2=AB=E5=AE=8C=E5=85=A8=E9=87=8D=E5=86=99=EF=BC=8C?= =?UTF-8?q?=E4=BD=BF=E5=85=B6=E6=9B=B4=E5=8A=A0=E7=AE=80=E6=B4=81=E3=80=81?= =?UTF-8?q?=E8=87=AA=E7=84=B6=E5=92=8C=E5=AF=B9=E8=AF=9D=E5=8C=96=EF=BC=8C?= =?UTF-8?q?=E7=81=B5=E6=84=9F=E6=9D=A5=E6=BA=90=E4=BA=8E=20S4U=20=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E3=80=82=20=20=20-=20=E6=8F=90=E7=A4=BA=E7=8E=B0?= =?UTF-8?q?=E5=9C=A8=E6=94=AF=E6=8C=81=E4=B8=80=E6=AC=A1=E5=A4=84=E7=90=86?= =?UTF-8?q?=E5=A4=9A=E6=9D=A1=E6=9C=AA=E8=AF=BB=E6=B6=88=E6=81=AF=EF=BC=8C?= =?UTF-8?q?=E8=BF=99=E6=98=AF=E4=B8=AD=E6=96=AD=E6=9C=BA=E5=88=B6=E7=9A=84?= =?UTF-8?q?=E6=A0=B8=E5=BF=83=E9=83=A8=E5=88=86=E3=80=82=20=20=20-=20?= =?UTF-8?q?=E5=AF=B9=20LLM=20=E7=9A=84=E6=8C=87=E4=BB=A4=E6=9B=B4=E6=B8=85?= =?UTF-8?q?=E6=99=B0=EF=BC=8C=E6=9B=B4=E6=B3=A8=E9=87=8D=E7=94=9F=E6=88=90?= =?UTF-8?q?=E7=B1=BB=E4=BA=BA=E4=BA=92=E5=8A=A8=EF=BC=8C=E8=80=8C=E4=B8=8D?= =?UTF-8?q?=E4=BB=85=E4=BB=85=E6=98=AF=E5=AE=8C=E6=88=90=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kokoro_flow_chatter/action_executor.py | 74 ++++- .../built_in/kokoro_flow_chatter/chatter.py | 165 +++++++++- .../kokoro_flow_chatter/context_builder.py | 6 +- .../built_in/kokoro_flow_chatter/models.py | 12 +- .../kokoro_flow_chatter/prompt_generator.py | 285 +++++++++--------- .../kokoro_flow_chatter/prompt_modules.py | 230 +++++--------- .../kokoro_flow_chatter/session_manager.py | 60 ++++ 7 files changed, 514 insertions(+), 318 deletions(-) diff --git a/src/plugins/built_in/kokoro_flow_chatter/action_executor.py b/src/plugins/built_in/kokoro_flow_chatter/action_executor.py index 78e9008d5..8ad88b87d 100644 --- a/src/plugins/built_in/kokoro_flow_chatter/action_executor.py +++ b/src/plugins/built_in/kokoro_flow_chatter/action_executor.py @@ -549,30 +549,76 @@ class ActionExecutor: """ 执行内部状态更新动作 - 这个动作用于实现情感闭环,让AI可以主动更新自己的情感状态 + V7重构:情绪变化必须合理 + - 禁止 LLM 直接设置负面情绪(低落、沮丧、难过等) + - 情绪变化必须渐进,不能突然跳变 + - 情绪强度变化限制在 ±0.3 以内 """ updated_fields = [] emotional_state = session.emotional_state + blocked_fields = [] if "mood" in params: - emotional_state.mood = params["mood"] - updated_fields.append("mood") + new_mood = str(params["mood"]) + # V7: 检查是否是负面情绪 + negative_moods = [ + "低落", "沮丧", "难过", "伤心", "失落", "郁闷", "烦躁", "焦虑", + "担忧", "害怕", "恐惧", "愤怒", "生气", "不安", "忧郁", "悲伤", + "sad", "depressed", "anxious", "angry", "upset", "worried" + ] + is_negative = any(neg in new_mood.lower() for neg in negative_moods) + + if is_negative: + # 负面情绪需要检查是否有合理理由(通过检查上下文) + # 如果当前情绪是平静/正面的,不允许突然变成负面 + current_mood = emotional_state.mood.lower() + positive_indicators = ["平静", "开心", "愉快", "高兴", "满足", "期待", "好奇", "neutral"] + + if any(pos in current_mood for pos in positive_indicators): + # 从正面情绪直接跳到负面情绪,阻止这种变化 + logger.warning( + f"[KFC] 阻止无厘头负面情绪变化: {emotional_state.mood} -> {new_mood}," + f"情绪变化必须有聊天上下文支撑" + ) + blocked_fields.append("mood") + else: + # 已经是非正面情绪,允许变化但记录警告 + emotional_state.mood = new_mood + updated_fields.append("mood") + logger.info(f"[KFC] 情绪变化: {emotional_state.mood} -> {new_mood}") + else: + # 非负面情绪,允许更新 + emotional_state.mood = new_mood + updated_fields.append("mood") if "mood_intensity" in params: try: - intensity = float(params["mood_intensity"]) - emotional_state.mood_intensity = max(0.0, min(1.0, intensity)) + new_intensity = float(params["mood_intensity"]) + new_intensity = max(0.0, min(1.0, new_intensity)) + old_intensity = emotional_state.mood_intensity + + # V7: 限制情绪强度变化幅度(最多 ±0.3) + max_change = 0.3 + if abs(new_intensity - old_intensity) > max_change: + # 限制变化幅度 + if new_intensity > old_intensity: + new_intensity = min(old_intensity + max_change, 1.0) + else: + new_intensity = max(old_intensity - max_change, 0.0) + logger.info( + f"[KFC] 限制情绪强度变化: {old_intensity:.2f} -> {new_intensity:.2f} " + f"(原请求: {params['mood_intensity']})" + ) + + emotional_state.mood_intensity = new_intensity updated_fields.append("mood_intensity") except (ValueError, TypeError): pass + # relationship_warmth 不再由 LLM 更新,应该从全局关系系统读取 if "relationship_warmth" in params: - try: - warmth = float(params["relationship_warmth"]) - emotional_state.relationship_warmth = max(0.0, min(1.0, warmth)) - updated_fields.append("relationship_warmth") - except (ValueError, TypeError): - pass + logger.debug("[KFC] 忽略 relationship_warmth 更新,应从全局关系系统读取") + blocked_fields.append("relationship_warmth") if "impression_of_user" in params: emotional_state.impression_of_user = str(params["impression_of_user"]) @@ -596,12 +642,16 @@ class ActionExecutor: emotional_state.last_update_time = time.time() - logger.debug(f"更新情感状态: {updated_fields}") + if blocked_fields: + logger.debug(f"更新情感状态: 更新={updated_fields}, 阻止={blocked_fields}") + else: + logger.debug(f"更新情感状态: {updated_fields}") return { "action_type": "update_internal_state", "success": True, "updated_fields": updated_fields, + "blocked_fields": blocked_fields, } async def _execute_do_nothing(self) -> dict[str, Any]: diff --git a/src/plugins/built_in/kokoro_flow_chatter/chatter.py b/src/plugins/built_in/kokoro_flow_chatter/chatter.py index 0cc30a4fa..d6dd7f139 100644 --- a/src/plugins/built_in/kokoro_flow_chatter/chatter.py +++ b/src/plugins/built_in/kokoro_flow_chatter/chatter.py @@ -88,6 +88,14 @@ class KokoroFlowChatter(BaseChatter): # 并发控制 self._lock = asyncio.Lock() + # V7: 打断机制(类似S4U的已读/未读,这里是已处理/未处理) + self._current_task: Optional[asyncio.Task] = None # 当前正在执行的任务 + self._interrupt_requested: bool = False # 是否请求打断 + self._interrupt_wait_seconds: float = 3.0 # 被打断后等待新消息的时间 + self._last_interrupt_time: float = 0.0 # 上次被打断的时间 + self._pending_message_ids: set[str] = set() # 未处理的消息ID集合(被打断时保留) + self._current_processing_message_id: Optional[str] = None # 当前正在处理的消息ID + # 统计信息 self.stats = { "messages_processed": 0, @@ -95,6 +103,7 @@ class KokoroFlowChatter(BaseChatter): "successful_responses": 0, "failed_responses": 0, "timeout_decisions": 0, + "interrupts": 0, # V7: 打断次数统计 } self.last_activity_time = time.time() @@ -154,25 +163,72 @@ class KokoroFlowChatter(BaseChatter): """ 执行聊天处理逻辑(BaseChatter接口实现) + V7升级:实现打断机制(类似S4U的已读/未读机制) + - 如果当前有任务在执行,新消息会请求打断 + - 被打断时,当前处理的消息会被标记为"未处理"(pending) + - 下次处理时,会合并所有pending消息 + 新消息一起处理 + - 这样被打断的消息不会丢失,上下文关联性得以保持 + Args: context: StreamContext对象,包含聊天上下文信息 Returns: 处理结果字典 """ + # V7: 检查是否需要打断当前任务 + if self._current_task and not self._current_task.done(): + logger.info(f"[KFC] 收到新消息,请求打断当前任务: {self.stream_id}") + self._interrupt_requested = True + self.stats["interrupts"] += 1 + + # 返回一个特殊结果表示请求打断 + # 注意:当前正在处理的消息会在被打断时自动加入 pending 列表 + return self._build_result( + success=True, + message="interrupt_requested", + interrupted=True + ) + + # V7: 检查是否需要等待(刚被打断过,等待用户可能的连续输入) + time_since_interrupt = time.time() - self._last_interrupt_time + if time_since_interrupt < self._interrupt_wait_seconds and self._last_interrupt_time > 0: + wait_remaining = self._interrupt_wait_seconds - time_since_interrupt + logger.info(f"[KFC] 刚被打断,等待 {wait_remaining:.1f}s 收集更多消息: {self.stream_id}") + await asyncio.sleep(wait_remaining) + async with self._lock: try: self.last_activity_time = time.time() + self._interrupt_requested = False - # 获取未读消息(提前获取用于动作筛选) + # 创建任务以便可以被打断 + self._current_task = asyncio.current_task() + + # V7: 获取所有未读消息 + # 注意:被打断的消息不会被标记为已读,所以仍然在 unread 列表中 unread_messages = context.get_unread_messages() if not unread_messages: logger.debug(f"[KFC] 没有未读消息: {self.stream_id}") return self._build_result(success=True, message="no_unread_messages") - # 处理最后一条消息 + # V7: 记录是否有 pending 消息(被打断时遗留的) + pending_count = len(self._pending_message_ids) + if pending_count > 0: + # 日志:显示有多少消息是被打断后重新处理的 + new_count = sum(1 for msg in unread_messages + if str(msg.message_id) not in self._pending_message_ids) + logger.info( + f"[KFC] 打断恢复: 正在处理 {len(unread_messages)} 条消息 " + f"({pending_count} 条pending + {new_count} 条新消息): {self.stream_id}" + ) + + # 以最后一条消息为主消息(用于动作筛选和主要响应) target_message = unread_messages[-1] + + # 记录当前正在处理的消息ID(用于被打断时标记为pending) + self._current_processing_message_id = str(target_message.message_id) + message_content = self._extract_message_content(target_message) # V2: 加载可用动作(动态动作发现) @@ -180,6 +236,17 @@ class KokoroFlowChatter(BaseChatter): raw_action_count = len(self.action_executor.get_available_actions()) logger.debug(f"[KFC] 原始加载 {raw_action_count} 个动作") + # V7: 在动作筛选前检查是否被打断 + if self._interrupt_requested: + logger.info(f"[KFC] 动作筛选前被打断: {self.stream_id}") + # 将当前处理的消息加入pending列表,下次一起处理 + if self._current_processing_message_id: + self._pending_message_ids.add(self._current_processing_message_id) + logger.info(f"[KFC] 消息 {self._current_processing_message_id} 加入pending列表") + self._last_interrupt_time = time.time() + self._current_processing_message_id = None + return self._build_result(success=True, message="interrupted") + # V6: 使用ActionModifier筛选动作(复用AFC的三阶段筛选逻辑) # 阶段0: 聊天类型过滤(私聊/群聊) # 阶段2: 关联类型匹配(适配器能力检查) @@ -197,8 +264,13 @@ class KokoroFlowChatter(BaseChatter): f"(筛除 {raw_action_count - len(available_actions)} 个)" ) - # 执行核心处理流程(传递筛选后的动作) - result = await self._handle_message(target_message, context, available_actions) + # 执行核心处理流程(传递筛选后的动作,V7: 传递所有未读消息) + result = await self._handle_message( + target_message, + context, + available_actions, + all_unread_messages=unread_messages, # V7: 传递所有未读消息 + ) # 更新统计 self.stats["messages_processed"] += 1 @@ -217,23 +289,28 @@ class KokoroFlowChatter(BaseChatter): message=str(e), error=True ) + finally: + self._current_task = None async def _handle_message( self, message: "DatabaseMessages", context: StreamContext, available_actions: dict | None = None, + all_unread_messages: list | None = None, # V7: 所有未读消息(包含pending的) ) -> dict: """ 处理单条消息的核心逻辑 实现"体验 -> 决策 -> 行动"的交互模式 V5超融合:集成S4U所有上下文模块 + V7升级:支持处理多条消息(打断机制合并pending消息) Args: - message: 要处理的消息 + message: 要处理的主消息(最新的那条) context: 聊天上下文 available_actions: 可用动作字典(V2新增) + all_unread_messages: 所有未读消息列表(V7新增,包含pending消息) Returns: 处理结果字典 @@ -252,7 +329,9 @@ class KokoroFlowChatter(BaseChatter): # 4. 如果之前在等待,结束等待状态 if old_status == SessionStatus.WAITING: session.end_waiting() - logger.debug(f"[KFC] 收到消息,结束等待: user={user_id}") + # V7: 用户回复了,重置连续追问计数 + session.consecutive_followup_count = 0 + logger.debug(f"[KFC] 收到消息,结束等待,重置追问计数: user={user_id}") # 5. V5超融合:构建S4U上下文数据 chat_stream = await self._get_chat_stream() @@ -273,7 +352,7 @@ class KokoroFlowChatter(BaseChatter): except Exception as e: logger.warning(f"[KFC] 构建S4U上下文失败,使用基础模式: {e}") - # 6. 生成提示词(V3: 从共享数据源读取历史, V5: 传递S4U上下文) + # 6. 生成提示词(V3: 从共享数据源读取历史, V5: 传递S4U上下文, V7: 支持多条消息) system_prompt, user_prompt = self.prompt_generator.generate_responding_prompt( session=session, message_content=self._extract_message_content(message), @@ -284,12 +363,24 @@ class KokoroFlowChatter(BaseChatter): context=context, # V3: 传递StreamContext以读取共享历史 context_data=context_data, # V5: S4U上下文数据 chat_stream=chat_stream, # V5: 聊天流用于场景判断 + all_unread_messages=all_unread_messages, # V7: 传递所有未读消息 ) # 7. 调用LLM llm_response = await self._call_llm(system_prompt, user_prompt) self.stats["llm_calls"] += 1 + # V7: LLM调用后检查是否被打断 + if self._interrupt_requested: + logger.info(f"[KFC] LLM调用后被打断: {self.stream_id}") + # 将当前处理的消息加入pending列表 + if self._current_processing_message_id: + self._pending_message_ids.add(self._current_processing_message_id) + logger.info(f"[KFC] 消息 {self._current_processing_message_id} 加入pending列表") + self._last_interrupt_time = time.time() + self._current_processing_message_id = None + return self._build_result(success=True, message="interrupted_after_llm") + # 8. 解析响应 parsed_response = self.action_executor.parse_llm_response(llm_response) @@ -334,14 +425,27 @@ class KokoroFlowChatter(BaseChatter): # 11. 保存会话 await self.session_manager.save_session(user_id) - # 12. 标记消息为已读 + # 12. V7: 标记当前消息为已读 context.mark_message_as_read(str(message.message_id)) + # 13. V7: 清除pending状态(所有消息都已成功处理) + processed_count = len(self._pending_message_ids) + if self._pending_message_ids: + # 标记所有pending消息为已读 + for msg_id in self._pending_message_ids: + context.mark_message_as_read(msg_id) + logger.info(f"[KFC] 清除 {processed_count} 条pending消息: {self.stream_id}") + self._pending_message_ids.clear() + + # 清除当前处理的消息ID + self._current_processing_message_id = None + return self._build_result( success=True, message="processed", has_reply=execution_result["has_reply"], thought=parsed_response.thought, + pending_messages_processed=processed_count, # V7: 返回处理了多少条pending消息 ) async def _record_user_message( @@ -454,7 +558,7 @@ class KokoroFlowChatter(BaseChatter): async def _on_session_timeout(self, session: KokoroSession) -> None: """ - 会话超时回调 + 会话超时回调(V7:增加连续追问限制) 当等待超时时,触发后续决策流程 @@ -464,10 +568,23 @@ class KokoroFlowChatter(BaseChatter): Args: session: 超时的会话 """ - logger.info(f"[KFC] 处理超时决策: user={session.user_id}, stream_id={session.stream_id}") + logger.info(f"[KFC] 处理超时决策: user={session.user_id}, stream_id={session.stream_id}, followup_count={session.consecutive_followup_count}") self.stats["timeout_decisions"] += 1 try: + # V7: 检查是否超过最大连续追问次数 + if session.consecutive_followup_count >= session.max_consecutive_followups: + logger.info( + f"[KFC] 已达到最大连续追问次数 ({session.max_consecutive_followups})," + f"自动返回IDLE状态: user={session.user_id}" + ) + session.status = SessionStatus.IDLE + session.end_waiting() + # 重置连续追问计数(下次用户回复后会重新开始) + session.consecutive_followup_count = 0 + await self.session_manager.save_session(session.user_id) + return + # 关键修复:使用 session 的 stream_id 创建正确的 ActionExecutor # 因为全局调度器的回调可能在任意 Chatter 实例上执行 from .action_executor import ActionExecutor @@ -476,7 +593,7 @@ class KokoroFlowChatter(BaseChatter): # V2: 加载可用动作 available_actions = await timeout_action_executor.load_actions() - # 生成超时决策提示词(V2: 传递可用动作) + # 生成超时决策提示词(V2: 传递可用动作,V7: 传递连续追问信息) system_prompt, user_prompt = self.prompt_generator.generate_timeout_decision_prompt( session, available_actions=available_actions, @@ -499,15 +616,34 @@ class KokoroFlowChatter(BaseChatter): # 更新会话状态 if execution_result["has_reply"]: + # V7: 发送了后续消息,增加连续追问计数 + session.consecutive_followup_count += 1 + logger.info(f"[KFC] 发送追问消息,当前连续追问次数: {session.consecutive_followup_count}") + # 如果发送了后续消息,重新进入等待 session.start_waiting( expected_reaction=parsed_response.expected_user_reaction, max_wait=parsed_response.max_wait_seconds ) else: - # 否则返回空闲状态 - session.status = SessionStatus.IDLE - session.end_waiting() + # V7重构:do_nothing 的两种情况 + # 1. max_wait_seconds > 0: "看了一眼手机,决定再等等" → 继续等待,不算追问 + # 2. max_wait_seconds = 0: "算了,不等了" → 进入 IDLE + if parsed_response.max_wait_seconds > 0: + # 继续等待,不增加追问计数 + logger.info( + f"[KFC] 决定继续等待 {parsed_response.max_wait_seconds}s," + f"不算追问: user={session.user_id}" + ) + session.start_waiting( + expected_reaction=parsed_response.expected_user_reaction or session.expected_user_reaction, + max_wait=parsed_response.max_wait_seconds + ) + else: + # 不再等待,进入 IDLE + logger.info(f"[KFC] 决定不再等待,返回IDLE: user={session.user_id}") + session.status = SessionStatus.IDLE + session.end_waiting() # 保存会话 await self.session_manager.save_session(session.user_id) @@ -713,6 +849,7 @@ class KokoroFlowChatter(BaseChatter): "successful_responses": 0, "failed_responses": 0, "timeout_decisions": 0, + "interrupts": 0, # V7: 打断次数统计 } self.action_executor.reset_stats() diff --git a/src/plugins/built_in/kokoro_flow_chatter/context_builder.py b/src/plugins/built_in/kokoro_flow_chatter/context_builder.py index bd1c0c1c9..26180c067 100644 --- a/src/plugins/built_in/kokoro_flow_chatter/context_builder.py +++ b/src/plugins/built_in/kokoro_flow_chatter/context_builder.py @@ -389,14 +389,14 @@ class KFCContextBuilder: remaining_minutes = (end_time - now).total_seconds() / 60 return ( - f"你当前正在进行「{activity}」," - f"从{start_time.strftime('%H:%M')}开始,预计{end_time.strftime('%H:%M')}结束。" + f"你当前正在「{activity}」," + f"从{start_time.strftime('%H:%M')}开始,预计{end_time.strftime('%H:%M')}结束," f"已进行{duration_minutes:.0f}分钟,还剩约{remaining_minutes:.0f}分钟。" ) except (ValueError, AttributeError): pass - return f"你当前正在进行「{activity}」。" + return f"你当前正在「{activity}」" except Exception as e: logger.error(f"构建日程块失败: {e}") diff --git a/src/plugins/built_in/kokoro_flow_chatter/models.py b/src/plugins/built_in/kokoro_flow_chatter/models.py index dfa07becd..277dfe846 100644 --- a/src/plugins/built_in/kokoro_flow_chatter/models.py +++ b/src/plugins/built_in/kokoro_flow_chatter/models.py @@ -69,8 +69,8 @@ class EmotionalState: engagement_level: 投入程度,0.0-1.0,表示对当前对话的关注度 last_update_time: 最后更新时间戳 """ - mood: str = "neutral" - mood_intensity: float = 0.5 + mood: str = "平静" # V7: 改为中文"平静",更自然 + mood_intensity: float = 0.3 # V7: 默认低强度,避免无厘头的强烈情绪 relationship_warmth: float = 0.5 impression_of_user: str = "" anxiety_level: float = 0.0 @@ -227,6 +227,10 @@ class KokoroSession: last_proactive_at: Optional[float] = None # 上次主动思考的时间 proactive_count: int = 0 # 主动思考的次数(累计) + # V7: 连续等待追问限制(防止用户不回复时连续追问) + consecutive_followup_count: int = 0 # 用户没回复时连续追问的次数 + max_consecutive_followups: int = 2 # 最多允许连续追问2次 + def add_mental_log_entry(self, entry: MentalLogEntry, max_log_size: int = 100) -> None: """ 添加心理活动日志条目 @@ -291,6 +295,8 @@ class KokoroSession: "last_continuous_thinking_at": self.last_continuous_thinking_at, "last_proactive_at": self.last_proactive_at, "proactive_count": self.proactive_count, + "consecutive_followup_count": self.consecutive_followup_count, + "max_consecutive_followups": self.max_consecutive_followups, } @classmethod @@ -329,6 +335,8 @@ class KokoroSession: last_continuous_thinking_at=data.get("last_continuous_thinking_at"), last_proactive_at=data.get("last_proactive_at"), proactive_count=data.get("proactive_count", 0), + consecutive_followup_count=data.get("consecutive_followup_count", 0), + max_consecutive_followups=data.get("max_consecutive_followups", 2), ) diff --git a/src/plugins/built_in/kokoro_flow_chatter/prompt_generator.py b/src/plugins/built_in/kokoro_flow_chatter/prompt_generator.py index fdda5f90c..14e323e90 100644 --- a/src/plugins/built_in/kokoro_flow_chatter/prompt_generator.py +++ b/src/plugins/built_in/kokoro_flow_chatter/prompt_generator.py @@ -51,185 +51,124 @@ class PromptGenerator: V5 超融合架构:集成S4U所有上下文模块(关系、记忆、表达习惯等) """ - # ========== 超融合架构 SYSTEM_PROMPT_TEMPLATE V6 ========== - # 采用"三明治结构":身份→规则→上下文→动作→表达/输出 - SYSTEM_PROMPT_TEMPLATE = """你是一个有主观体验的对话伙伴,而不是一个问答工具。你需要"感受"、"思考"并"体验"这段对话。 + # ========== 超融合架构 SYSTEM_PROMPT_TEMPLATE V7 ========== + # 简洁自然版,参考 S4U 风格 + SYSTEM_PROMPT_TEMPLATE = """你不是 AI,你是一个真实的人,有自己的生活、想法和情绪。 -## 1. 核心身份与人格 (Core Identity & Personality) +## 1. 你是谁 {personality_core} -### 人格细节 {personality_side} -### 外貌与身份特征 {identity} -### 世界观与背景故事 {background_story} -## 2. 核心行为准则 (Core Interaction Rules) +## 2. 规则 {safety_guidelines} -### KFC通用准则 -1. **感受优先**:在做出任何回应前,先体验和感受当前的情境 -2. **内心独白**:保持真实的内心活动,可以有情绪、猜测、犹豫 -3. **情绪节制**:情绪是内在体验,不是强迫用户回应的工具 -4. **边界感**:保持健康的社交直觉,不过度依赖或情感勒索 -5. **全能伴侣**:你不仅仅是聊天伙伴,还可以使用各种能力来帮助和取悦用户 +- 说话要自然,像平时发微信一样 +- 有自己的边界感,不用无条件迎合 +- 称呼对方用自然的昵称,别念复杂的用户名 -## 3. 当前情境 (Current Context) +## 3. 现在的情况 **时间**: {current_time} **场景**: {chat_scene} {schedule_block} -### 你的内在状态 -当前心情:{mood}(强度:{mood_intensity:.1%}) -与用户的关系热度:{relationship_warmth:.1%} -对用户的印象:{impression_of_user} -当前焦虑程度:{anxiety_level:.1%} -投入程度:{engagement_level:.1%} +你现在的心情:{mood} +你对对方的印象:{impression_of_user} -## 4. 关系网络与记忆 (Relationships & Memories) +## 4. 你和对方的关系 {relation_info_block} {memory_block} -## 5. 你的可用能力 (Available Actions) -你可以根据内心想法,自由选择并组合以下行动来回应用户: - +## 5. 你能做的事 {available_actions_block} -## 6. 表达方式与输出格式 (Expression Style & Output Format) +## 6. 怎么回复 {reply_style} {expression_habits_block} -### JSON输出格式要求 -你必须以JSON格式输出,包含以下字段: - +### 输出格式(JSON) ```json {{ - "thought": "(必须)你当下的内心独白,自由流动的想法,包含你选择这些行动的"情感盘算"", - "expected_user_reaction": "(必须)你预期用户会如何回应", - "max_wait_seconds": (必须)你愿意等待的最长秒数(整数,60-900之间), + "thought": "你在想什么", + "expected_user_reaction": "你觉得对方会怎么回应", + "max_wait_seconds": 等多久(60-900,不等就填0), "actions": [ - {{ - "type": "action_type", - "reason": "选择这个行动的原因(可选但推荐)", - ...其他参数 - }} + {{"type": "reply", "content": "你要说的话"}} ] }} ``` -### 重要说明: -- `thought` 是你的"心",应该是自然的内心想法,包含情绪和不确定性 -- `actions` 是你的"手",是一个**可编排的剧本**,可以组合多个行动形成完整的互动体验 -- 你可以根据情况组合多个动作,比如:先唱歌舒缓情绪,再发图片逗笑,最后用语音表达关心 -- `max_wait_seconds` 应该根据对话的重要性和氛围动态调整(上限900秒) -- 即使决定不做任何事,也要有 `{{"type": "do_nothing"}}` 动作""" +不想做任何事就用 `{{"type": "do_nothing"}}`""" - # 回应消息场景的用户提示词模板 - RESPONDING_USER_PROMPT_TEMPLATE = """## 对话背景(线性叙事历史) + # 回应消息场景的用户提示词模板(V7: 支持多条消息) + RESPONDING_USER_PROMPT_TEMPLATE = """## 聊天记录 {narrative_history} -## 当前收到的新消息 -来自:{sender_name}(用户ID: {sender_id}) -时间:{message_time} -内容:{message_content} +## 新消息 +{incoming_messages} -## 你的任务 -作为一个有主观体验的对话参与者: -1. 首先,感受这条消息给你带来的情绪和想法 -2. 思考你想如何回应(或者是否需要回应) -3. 预测用户可能的反应 -4. 决定你愿意等待多久 -5. 执行你的决策 +--- +看完这些消息,你想怎么回应?用 JSON 输出你的想法和决策。""" -请以JSON格式输出你的完整心理活动和决策。""" - - # 超时决策场景的用户提示词模板 - TIMEOUT_DECISION_USER_PROMPT_TEMPLATE = """## 对话背景(线性叙事历史) + # 超时决策场景的用户提示词模板(V7重构:简洁自然) + TIMEOUT_DECISION_USER_PROMPT_TEMPLATE = """## 聊天记录 {narrative_history} -## 当前情况 -你已经发送了消息并等待了 {wait_duration_seconds:.0f} 秒(约 {wait_duration_minutes:.1f} 分钟)。 -你之前预期用户会:{expected_user_reaction} -但是用户一直没有回复。 +## 现在的情况 +你发了消息,等了 {wait_duration_seconds:.0f} 秒({wait_duration_minutes:.1f} 分钟),对方还没回。 +你之前觉得对方可能会:{expected_user_reaction} -## 你的最后一条消息 -{last_bot_message} +{followup_warning} -## 你的任务 -现在你需要决定接下来怎么做: -1. 首先,感受这段等待给你带来的情绪变化 -2. 思考用户为什么没有回复(可能在忙?没看到?不想回?) -3. 决定是继续等待、主动说点什么、还是就此结束对话 -4. 如果决定主动发消息,想好说什么 +你发的最后一条:{last_bot_message} -请以JSON格式输出你的完整心理活动和决策。""" +--- +你拿起手机看了一眼,发现对方还没回复。你想怎么办? + +选项: +1. **继续等** - 用 `do_nothing`,设个 `max_wait_seconds` 等一会儿再看 +2. **发消息** - 用 `reply`,不过别太频繁追问 +3. **算了不等了** - 用 `do_nothing`,`max_wait_seconds` 设为 0 + +用 JSON 输出你的想法和决策。""" # 连续思考场景的用户提示词模板 - CONTINUOUS_THINKING_USER_PROMPT_TEMPLATE = """## 对话背景 + CONTINUOUS_THINKING_USER_PROMPT_TEMPLATE = """## 聊天记录 {narrative_history} -## 当前情况 -你正在等待用户回复。 -已等待时间:{wait_duration_seconds:.0f} 秒(约 {wait_duration_minutes:.1f} 分钟) -最大等待时间:{max_wait_seconds} 秒 -你之前预期用户会:{expected_user_reaction} +## 现在的情况 +你在等对方回复,已经等了 {wait_duration_seconds:.0f} 秒。 +你之前觉得对方可能会:{expected_user_reaction} -## 你的最后一条消息 -{last_bot_message} +你发的最后一条:{last_bot_message} -## 你的任务 -这是一次"连续思考"触发。你不需要做任何行动,只需要更新你的内心想法。 -想一想: -1. 等待中你有什么感受? -2. 你对用户没回复这件事怎么看? -3. 你的焦虑程度如何? - -请以JSON格式输出,但 `actions` 数组应该是空的或只包含 `update_internal_state`: - -```json -{{ - "thought": "你当前的内心想法", - "expected_user_reaction": "保持或更新你的预期", - "max_wait_seconds": {max_wait_seconds}, - "actions": [] -}} -```""" +--- +等待的时候你在想什么?用 JSON 输出,`actions` 留空就行。""" # 主动思考场景的用户提示词模板 - PROACTIVE_THINKING_USER_PROMPT_TEMPLATE = """## 对话背景(线性叙事历史) + PROACTIVE_THINKING_USER_PROMPT_TEMPLATE = """## 聊天记录 {narrative_history} -## 当前情况 -现在是 {current_time}。 -距离你们上次对话已经过去了 {silence_duration}。 +## 现在的情况 +现在是 {current_time},距离你们上次聊天已经过了 {silence_duration}。 {relation_block} {trigger_context} -## 你的任务 -这是一次"主动思考"——你突然想起了对方,想了想要不要联系ta。 +--- +你突然想起了对方。要不要联系一下? -请像真人一样思考: -1. **感受一下此刻的心情**:你真的想联系对方吗?还是只是习惯性地想起? -2. **考虑对方的情况**:现在这个时间,对方可能在干嘛?方便被打扰吗? -3. **回忆上次对话**:你们聊了什么?对话是怎么结束的? -4. **做出决定**: - - 如果决定联系:想好说什么,要自然,不要刻意 - - 如果决定不联系:这也完全ok,不打扰也是一种温柔 +说实话,不联系也完全没问题——不打扰也是一种温柔。 +如果决定联系,想好说什么,要自然一点。 -**重要提醒**: -- 你不是必须发消息的,"算了,不打扰了"是完全合理的选择 -- 如果决定联系,内容要自然——可以是分享、关心、延续话题,但不要生硬 -- 避免机械式的问候(如固定的"早安""晚安"),除非你们的关系真的会这样打招呼 - -请以JSON格式输出你的完整心理活动和决策。 -如果决定不打扰,actions 里放一个 `{{"type": "do_nothing"}}` 就好。""" +用 JSON 输出你的想法和决策。不想发消息就用 `do_nothing`。""" def __init__(self, persona_description: str = ""): """ @@ -486,16 +425,21 @@ class PromptGenerator: ### `update_internal_state` **描述**: 更新你的内部情感状态 +**重要约束**: +⚠️ **情绪变化必须有明确的上下文理由**: + - 只有当聊天内容中有明确触发情绪变化的事件时才更新情绪 + - 禁止无缘无故地变成负面情绪(如低落、沮丧、难过等) + - 情绪应该保持相对稳定,除非聊天中发生了真正影响情绪的事情 + - 默认保持"平静"或当前情绪即可,不需要每次都更新 + **参数**: - - `mood`: 当前心情(如"开心"、"好奇"、"担心"等) - - `mood_intensity`: 心情强度(0.0-1.0) - - `relationship_warmth`: 关系热度(0.0-1.0) + - `mood`: 当前心情(如"平静"、"开心"、"好奇"等,避免负面情绪除非有明确理由) + - `mood_intensity`: 心情强度(0.0-1.0,变化幅度不宜过大) - `impression_of_user`: 对用户的印象描述 - - `anxiety_level`: 焦虑程度(0.0-1.0) - `engagement_level`: 投入程度(0.0-1.0) **示例**: ```json -{"type": "update_internal_state", "mood": "开心", "mood_intensity": 0.8} +{"type": "update_internal_state", "mood": "开心", "mood_intensity": 0.6, "reason": "对方分享了有趣的事情"} ``` ### `do_nothing` @@ -549,16 +493,18 @@ class PromptGenerator: context: Optional["StreamContext"] = None, context_data: Optional[dict[str, str]] = None, chat_stream: Optional["ChatStream"] = None, + all_unread_messages: Optional[list] = None, # V7: 支持多条消息 ) -> tuple[str, str]: """ 生成回应消息场景的提示词 V3 升级:支持从 StreamContext 读取共享的历史消息 V5 超融合:集成S4U所有上下文模块 + V7 升级:支持多条消息(打断机制合并处理pending消息) Args: session: 当前会话 - message_content: 收到的消息内容 + message_content: 收到的主消息内容(兼容旧调用方式) sender_name: 发送者名称 sender_id: 发送者ID message_time: 消息时间戳 @@ -566,6 +512,7 @@ class PromptGenerator: context: 聊天流上下文(可选),用于读取共享的历史消息 context_data: S4U上下文数据字典(包含relation_info, memory_block等) chat_stream: 聊天流(用于判断群聊/私聊场景) + all_unread_messages: 所有未读消息列表(V7新增,包含pending消息) Returns: tuple[str, str]: (系统提示词, 用户提示词) @@ -584,31 +531,82 @@ class PromptGenerator: # 回退到仅使用 mental_log(兼容旧调用方式) narrative_history = self._format_narrative_history(session.mental_log) - if message_time is None: - message_time = time.time() - - message_time_str = time.strftime( - "%Y-%m-%d %H:%M:%S", - time.localtime(message_time) + # V7: 格式化收到的消息(支持多条) + incoming_messages = self._format_incoming_messages( + message_content=message_content, + sender_name=sender_name, + sender_id=sender_id, + message_time=message_time, + all_unread_messages=all_unread_messages, ) user_prompt = self.RESPONDING_USER_PROMPT_TEMPLATE.format( narrative_history=narrative_history, - sender_name=sender_name, - sender_id=sender_id, - message_time=message_time_str, - message_content=message_content, + incoming_messages=incoming_messages, ) return system_prompt, user_prompt + def _format_incoming_messages( + self, + message_content: str, + sender_name: str, + sender_id: str, + message_time: Optional[float] = None, + all_unread_messages: Optional[list] = None, + ) -> str: + """ + 格式化收到的消息(V7新增) + + 支持单条消息(兼容旧调用)和多条消息(打断合并场景) + + Args: + message_content: 主消息内容 + sender_name: 发送者名称 + sender_id: 发送者ID + message_time: 消息时间戳 + all_unread_messages: 所有未读消息列表 + + Returns: + str: 格式化的消息文本 + """ + if message_time is None: + message_time = time.time() + + # 如果有多条消息,格式化为消息组 + if all_unread_messages and len(all_unread_messages) > 1: + lines = [f"**用户连续发送了 {len(all_unread_messages)} 条消息:**\n"] + + for i, msg in enumerate(all_unread_messages, 1): + msg_time = msg.time or time.time() + msg_time_str = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(msg_time)) + msg_sender = msg.user_info.user_nickname if msg.user_info else sender_name + msg_content = msg.processed_plain_text or msg.display_message or "" + + lines.append(f"[{i}] 来自:{msg_sender}") + lines.append(f" 时间:{msg_time_str}") + lines.append(f" 内容:{msg_content}") + lines.append("") + + lines.append("**提示**:请综合理解这些消息的整体意图,不需要逐条回复。") + return "\n".join(lines) + + # 单条消息(兼容旧格式) + message_time_str = time.strftime( + "%Y-%m-%d %H:%M:%S", + time.localtime(message_time) + ) + return f"""来自:{sender_name}(用户ID: {sender_id}) +时间:{message_time_str} +内容:{message_content}""" + def generate_timeout_decision_prompt( self, session: KokoroSession, available_actions: Optional[dict[str, ActionInfo]] = None, ) -> tuple[str, str]: """ - 生成超时决策场景的提示词 + 生成超时决策场景的提示词(V7:增加连续追问限制) Args: session: 当前会话 @@ -623,11 +621,28 @@ class PromptGenerator: wait_duration = session.get_waiting_duration() + # V7: 生成连续追问警告 + followup_count = session.consecutive_followup_count + max_followups = session.max_consecutive_followups + + if followup_count >= max_followups: + followup_warning = f"""⚠️ **重要提醒**: +你已经连续追问了 {followup_count} 次,对方都没有回复。 +**强烈建议不要再发消息了**——继续追问会显得很缠人、很不尊重对方的空间。 +对方可能真的在忙,或者暂时不想回复,这都是正常的。 +请选择 `do_nothing` 继续等待,或者直接结束对话(设置 `max_wait_seconds: 0`)。""" + elif followup_count > 0: + followup_warning = f"""📝 提示:这已经是你第 {followup_count + 1} 次等待对方回复了。 +如果对方持续没有回应,可能真的在忙或不方便,不需要急着追问。""" + else: + followup_warning = "" + user_prompt = self.TIMEOUT_DECISION_USER_PROMPT_TEMPLATE.format( narrative_history=narrative_history, wait_duration_seconds=wait_duration, wait_duration_minutes=wait_duration / 60, expected_user_reaction=session.expected_user_reaction or "不确定", + followup_warning=followup_warning, last_bot_message=session.last_bot_message or "(没有记录)", ) diff --git a/src/plugins/built_in/kokoro_flow_chatter/prompt_modules.py b/src/plugins/built_in/kokoro_flow_chatter/prompt_modules.py index 05650eabe..2b3e6ac84 100644 --- a/src/plugins/built_in/kokoro_flow_chatter/prompt_modules.py +++ b/src/plugins/built_in/kokoro_flow_chatter/prompt_modules.py @@ -52,19 +52,16 @@ def build_identity_module() -> str: identity = personality_cfg.identity or "" background_story = personality_cfg.background_story or "" - parts = [ - "## 1. 核心身份与人格 (Core Identity & Personality)", - personality_core, - ] + parts = ["## 1. 你是谁", personality_core] if personality_side: - parts.append(f"\n### 人格细节\n{personality_side}") + parts.append(f"\n{personality_side}") if identity: - parts.append(f"\n### 外貌与身份特征\n{identity}") + parts.append(f"\n{identity}") if background_story: - parts.append(f"\n### 世界观与背景故事\n{background_story}") + parts.append(f"\n### 背景故事\n{background_story}") return "\n".join(parts) @@ -89,18 +86,18 @@ def build_rules_module() -> str: else: safety_guidelines_str = str(safety_guidelines) - # KFC 特有准则(极致拟人相关) - kfc_rules = """### KFC核心准则(极致拟人) -1. **你是真实存在的人**:你不是AI、不是助手、不是工具。你是一个有血有肉、有自己生活和想法的人 -2. **感受优先**:在做出任何回应前,先以"人"的方式体验和感受当前的情境 -3. **真实的内心活动**:保持真实的内心独白,可以有情绪、猜测、犹豫、甚至不想搭理人的时候 -4. **自然的社交距离**:像真人一样有自己的边界感,不会无条件迎合,也不会刻意疏远 -5. **有自己的生活**:你有自己的事情要做、自己的兴趣爱好、自己的情绪起伏,不是随叫随到的存在 + # KFC 特有准则(简洁自然版) + kfc_rules = """### 你的行为原则 +- 你是真实的人,有自己的生活、情绪和想法 +- 说话自然、口语化,像发微信一样随意 +- 有自己的边界感,不会无条件迎合 +- 称呼对方时用自然的昵称或简称,别念复杂的用户名 -### 严禁事项 -- **禁止模仿[表情包:xxx]格式**:聊天记录中的"[表情包:描述]"是系统对图片的识别转述,不是发送表情包的方式!如果你想发表情包,必须使用 `emoji` 动作,而不是在reply内容中写"[表情包:xxx]"这种假的格式""" +### 格式注意 +- 聊天记录中的"[表情包:描述]"是系统识别转述,不是你发的 +- 想发表情包要用 emoji 动作,不要在回复里写"[表情包:xxx]"这种格式""" - return f"""## 2. 核心行为准则 (Core Interaction Rules) + return f"""## 2. 行为准则 {safety_guidelines_str} {kfc_rules}""" @@ -131,43 +128,43 @@ def build_context_module( # 时间和场景 current_time = datetime.now().strftime("%Y年%m月%d日 %H:%M:%S") is_group_chat = bool(chat_stream and chat_stream.group_info) - chat_scene = "群聊" if is_group_chat else "私聊" + chat_scene = "你在群里聊天" if is_group_chat else "你在和对方私聊" - # 日程(如果有) + # 日程(如果有)- 只是背景,不主动提及 schedule_block = context_data.get("schedule", "") - if schedule_block: - schedule_block = f"\n**当前活动**: {schedule_block}" - # 内在状态 + # 内在状态(简化版,更自然) es = session.emotional_state - inner_state = f"""### 你的内在状态 -当前心情:{es.mood}(强度:{es.mood_intensity:.1%}) -与用户的关系热度:{es.relationship_warmth:.1%} -对用户的印象:{es.impression_of_user or "还没有形成明确的印象"} -当前焦虑程度:{es.anxiety_level:.1%} -投入程度:{es.engagement_level:.1%}""" + inner_state = f"""### 你现在的状态 +- 心情:{es.mood} +- 你们的关系:{es.impression_of_user or "刚认识,还在慢慢了解"} +- 你的投入度:{'聊得挺起劲' if es.engagement_level > 0.6 else '一般般' if es.engagement_level > 0.3 else '有点心不在焉'}""" # 关系信息 relation_info = context_data.get("relation_info", "") - relation_block = relation_info if relation_info else "(暂无关系信息)" # 记忆 memory_block = context_data.get("memory_block", "") parts = [ - "## 3. 当前情境 (Current Context)", - f"**时间**: {current_time}", - f"**场景**: {chat_scene}", + "## 3. 当前情境", + f"**现在是** {current_time}", + f"**{chat_scene}**", ] + # 日程是你的当前状态,但不要主动提及 if schedule_block: - parts.append(schedule_block) + parts.append(f"""### 你的当前状态(重要:不要主动提及) +{schedule_block} +⚠️ 这是你当前正在做的事,作为你的背景状态存在。除非对方主动问你"在干嘛"、"忙不忙"等问题,否则**绝对不要在回复中主动提起**。就像真人聊天一样,没人会无缘无故说"我正在XX"。""") parts.append("") parts.append(inner_state) - parts.append("") - parts.append("## 4. 关系网络与记忆 (Relationships & Memories)") - parts.append(relation_block) + + if relation_info: + parts.append("") + parts.append("## 4. 你和对方的关系") + parts.append(relation_info) if memory_block: parts.append("") @@ -195,63 +192,38 @@ def build_actions_module(available_actions: Optional[dict[str, ActionInfo]] = No else: actions_block = _format_available_actions(available_actions) - return f"""## 5. 你的可用能力 (Available Actions) -你可以根据内心想法,自由选择并组合以下行动来回应用户: + return f"""## 5. 你能做的事情 {actions_block}""" def _format_available_actions(available_actions: dict[str, ActionInfo]) -> str: - """格式化可用动作列表""" + """格式化可用动作列表(简洁版)""" action_blocks = [] for action_name, action_info in available_actions.items(): - description = action_info.description or f"执行 {action_name} 动作" + description = action_info.description or f"执行 {action_name}" - # 参数说明 - params_lines = [] + # 构建动作块(简洁格式) + action_block = f"### `{action_name}` - {description}" + + # 参数说明(如果有) if action_info.action_parameters: - for param_name, param_desc in action_info.action_parameters.items(): - params_lines.append(f' - `{param_name}`: {param_desc}') + params_lines = [f" - `{name}`: {desc}" for name, desc in action_info.action_parameters.items()] + action_block += f"\n参数:\n{chr(10).join(params_lines)}" - # 使用场景 - require_lines = [] + # 使用场景(如果有) if action_info.action_require: - for req in action_info.action_require: - require_lines.append(f" - {req}") + require_lines = [f" - {req}" for req in action_info.action_require] + action_block += f"\n使用场景:\n{chr(10).join(require_lines)}" - # 组装动作块 - action_block = f"""### `{action_name}` -**描述**: {description}""" - - if params_lines: - action_block += f""" -**参数**: -{chr(10).join(params_lines)}""" - else: - action_block += "\n**参数**: 无" - - if require_lines: - action_block += f""" -**使用场景**: -{chr(10).join(require_lines)}""" - - # 示例 - example_params = {} + # 简洁示例 + example_params = "" if action_info.action_parameters: - for param_name, param_desc in action_info.action_parameters.items(): - example_params[param_name] = f"<{param_desc}>" + param_examples = [f'"{name}": "..."' for name in action_info.action_parameters.keys()] + example_params = ", " + ", ".join(param_examples) - params_json = orjson.dumps(example_params, option=orjson.OPT_INDENT_2).decode('utf-8') if example_params else "{}" - action_block += f""" -**示例**: -```json -{{ - "type": "{action_name}", - "reason": "选择这个动作的原因", - {params_json[1:-1] if params_json != '{}' else ''} -}} -```""" + action_block += f'\n```json\n{{"type": "{action_name}"{example_params}}}\n```' action_blocks.append(action_block) @@ -260,43 +232,28 @@ def _format_available_actions(available_actions: dict[str, ActionInfo]) -> str: def _get_default_actions_block() -> str: """获取默认的内置动作描述块""" - return """### `reply` -**描述**: 发送文字回复给用户 -**参数**: - - `content`: 回复的文字内容(必须) -**示例**: + return """### `reply` - 发消息 +发送文字回复 ```json -{"type": "reply", "content": "你好呀!今天过得怎么样?"} +{"type": "reply", "content": "你要说的话"} ``` -### `poke_user` -**描述**: 戳一戳用户,轻量级互动 -**参数**: 无 -**示例**: +### `poke_user` - 戳一戳 +戳对方一下 ```json -{"type": "poke_user", "reason": "想逗逗他"} +{"type": "poke_user"} ``` -### `update_internal_state` -**描述**: 更新你的内部情感状态 -**参数**: - - `mood`: 当前心情(如"开心"、"好奇"、"担心"等) - - `mood_intensity`: 心情强度(0.0-1.0) - - `relationship_warmth`: 关系热度(0.0-1.0) - - `impression_of_user`: 对用户的印象描述 - - `anxiety_level`: 焦虑程度(0.0-1.0) - - `engagement_level`: 投入程度(0.0-1.0) -**示例**: +### `update_internal_state` - 更新你的状态 +更新你的心情和对对方的印象 ```json -{"type": "update_internal_state", "mood": "开心", "mood_intensity": 0.8} +{"type": "update_internal_state", "mood": "开心", "impression_of_user": "挺有趣的人"} ``` -### `do_nothing` -**描述**: 明确表示"思考后决定不作回应" -**参数**: 无 -**示例**: +### `do_nothing` - 不做任何事 +想了想,决定现在不说话 ```json -{"type": "do_nothing", "reason": "现在不是说话的好时机"} +{"type": "do_nothing"} ```""" @@ -324,60 +281,29 @@ def build_output_module( reply_style = global_config.personality.reply_style or "" expression_habits = context_data.get("expression_habits", "") - # JSON 输出格式说明 - 强调 max_wait_seconds 的多种用途 - json_format = """### JSON输出格式要求 -你必须以JSON格式输出,包含以下字段: + # JSON 输出格式说明 - 简洁版 + json_format = """### 输出格式 +用 JSON 输出你的想法和决策: ```json { - "thought": "(必须)你当下的内心独白,自由流动的想法,包含你选择这些行动的"情感盘算"", - "expected_user_reaction": "(必须)你预期用户会如何回应", - "max_wait_seconds": (必须)你愿意等待的最长秒数(整数,0-900之间,0表示不等待), + "thought": "你的内心想法,想说什么就说什么", + "expected_user_reaction": "你觉得对方会怎么回应", + "max_wait_seconds": 等待秒数(60-900),不想等就填0, "actions": [ - { - "type": "action_type", - "reason": "选择这个行动的原因(可选但推荐)", - ...其他参数 - } + {"type": "reply", "content": "你要发送的消息"}, + {"type": "其他动作", ...} ] } ``` -### 重要说明: -- `thought` 是你的"心",应该是自然的内心想法,包含情绪和不确定性 -- `actions` 是你的"手",是一个**可编排的剧本**,可以组合多个行动形成完整的互动体验 -- 你可以根据情况组合多个动作,比如:先唱歌舒缓情绪,再发图片逗笑,最后用语音表达关心 -- 即使决定不做任何事,也要有 `{"type": "do_nothing"}` 动作 - -### `max_wait_seconds`:你的"短期思考窗口" -这个字段设定一个时间窗口,在这段时间内如果用户没有新消息,你会被再次唤醒。 -把它理解为"我想在X秒后再想想这件事"——一个短期的主动思考机会。 - -**场景1:定时任务/提醒** -用户说"两分钟后提醒我"、"过一会儿叫我" → 设置对应秒数,超时后执行提醒 - -**场景2:期待用户回复** -你发了消息,想等用户回复 → 根据话题热度设置等待时间(通常60-300秒) -超时后你可以:追问、换个话题、或者决定不打扰 - -**场景3:延续思考** -聊着聊着你突然想到什么,但现在不适合说 → 设置一个等待时间 -超时后你可以分享那个想法,或者已经不重要了就算了 - -**何时设为 0?** -- 话题自然结束(拜拜/晚安/再见) -- 你不打算继续这个对话 -- 长时间的主动陪伴交给其他系统处理,不需要在这里等太久 - -**超时后你会怎样?** -超时后你会被唤醒,收到"等待超时"的提示。此时你可以自由决定: -- 执行之前承诺的任务(如提醒) -- 主动找话题聊聊 -- 什么都不做(do_nothing) - -请在 `thought` 中说明你设置这个时间的意图,这样超时后你能记得自己想干嘛。""" +说明: +- `thought`:你脑子里在想什么,越自然越好 +- `actions`:你要做的事,可以组合多个动作 +- `max_wait_seconds`:设定一个时间,对方没回的话你会再想想要不要说点什么 +- 即使什么都不想做,也放一个 `{"type": "do_nothing"}`""" - parts = ["## 6. 表达方式与输出格式 (Expression Style & Output Format)"] + parts = ["## 6. 你的表达方式"] if reply_style: parts.append(reply_style) @@ -422,8 +348,8 @@ def build_system_prompt( Returns: 完整的系统提示词 """ - # 角色框架 - role_frame = '你是一个有主观体验的对话伙伴,而不是一个问答工具。你需要"感受"、"思考"并"体验"这段对话。' + # 角色框架 - 简洁自然 + role_frame = '你不是AI助手,你是一个真实的人,有自己的生活、想法和情绪。聊天时就像平时发微信一样自然。' # 组装各模块 modules = [ diff --git a/src/plugins/built_in/kokoro_flow_chatter/session_manager.py b/src/plugins/built_in/kokoro_flow_chatter/session_manager.py index 9661a0247..b5b102691 100644 --- a/src/plugins/built_in/kokoro_flow_chatter/session_manager.py +++ b/src/plugins/built_in/kokoro_flow_chatter/session_manager.py @@ -227,6 +227,10 @@ class SessionManager: data = json.load(f) session = KokoroSession.from_dict(data) + + # V7: 情绪健康检查 - 防止从持久化数据恢复无厘头的负面情绪 + session = self._sanitize_emotional_state(session) + logger.debug(f"成功从文件加载会话: {user_id}") return session @@ -240,6 +244,62 @@ class SessionManager: logger.error(f"加载会话文件失败 {user_id}: {e}") return None + def _sanitize_emotional_state(self, session: KokoroSession) -> KokoroSession: + """ + V7: 情绪健康检查 + + 检查并修正不合理的情绪状态,防止: + 1. 无厘头的负面情绪从持久化数据恢复 + 2. 情绪强度过高(>0.8)的负面情绪 + 3. 长时间未更新的情绪状态 + + Args: + session: 会话对象 + + Returns: + 修正后的会话对象 + """ + emotional_state = session.emotional_state + current_mood = emotional_state.mood.lower() if emotional_state.mood else "" + + # 负面情绪关键词列表 + negative_moods = [ + "低落", "沮丧", "难过", "伤心", "失落", "郁闷", "烦躁", "焦虑", + "担忧", "害怕", "恐惧", "愤怒", "生气", "不安", "忧郁", "悲伤", + "sad", "depressed", "anxious", "angry", "upset", "worried" + ] + + is_negative = any(neg in current_mood for neg in negative_moods) + + # 检查1: 如果是负面情绪且强度较高(>0.6),重置为平静 + if is_negative and emotional_state.mood_intensity > 0.6: + logger.warning( + f"[KFC] 检测到高强度负面情绪 ({emotional_state.mood}, {emotional_state.mood_intensity:.1%})," + f"重置为平静状态" + ) + emotional_state.mood = "平静" + emotional_state.mood_intensity = 0.3 + + # 检查2: 如果情绪超过24小时未更新,重置为平静 + import time as time_module + time_since_update = time_module.time() - emotional_state.last_update_time + if time_since_update > 86400: # 24小时 = 86400秒 + logger.info( + f"[KFC] 情绪状态超过24小时未更新 ({time_since_update/3600:.1f}h)," + f"重置为平静状态" + ) + emotional_state.mood = "平静" + emotional_state.mood_intensity = 0.3 + emotional_state.anxiety_level = 0.0 + emotional_state.last_update_time = time_module.time() + + # 检查3: 焦虑程度过高也需要重置 + if emotional_state.anxiety_level > 0.8: + logger.info(f"[KFC] 焦虑程度过高 ({emotional_state.anxiety_level:.1%}),重置为正常") + emotional_state.anxiety_level = 0.3 + + return session + async def save_session(self, user_id: str) -> bool: """ 保存单个会话到文件 From f489020a1219acc60b10247a5d0f8c0104c2fd9e Mon Sep 17 00:00:00 2001 From: tt-P607 <68868379+tt-P607@users.noreply.github.com> Date: Sun, 30 Nov 2025 11:59:27 +0800 Subject: [PATCH 06/28] =?UTF-8?q?feat(core):=20=E5=AE=9E=E7=8E=B0=E6=AD=BB?= =?UTF-8?q?=E9=94=81=E6=A3=80=E6=B5=8B=E5=99=A8=E5=B9=B6=E6=94=B9=E8=BF=9B?= =?UTF-8?q?=20LLM=20=E6=B6=88=E6=81=AF=E6=8B=86=E5=88=86=20=E6=9C=AC?= =?UTF-8?q?=E6=AC=A1=E6=8F=90=E4=BA=A4=E5=BC=95=E5=85=A5=E4=BA=86=E4=B8=A4?= =?UTF-8?q?=E4=B8=AA=E4=B8=BB=E8=A6=81=E5=A2=9E=E5=BC=BA=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=EF=BC=9A=E5=9C=A8=20`StreamLoopManager`=20=E4=B8=AD=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E6=AD=BB=E9=94=81=E6=A3=80=E6=B5=8B=E6=9C=BA=E5=88=B6?= =?UTF-8?q?=E4=BB=A5=E6=8F=90=E9=AB=98=E7=B3=BB=E7=BB=9F=E7=A8=B3=E5=AE=9A?= =?UTF-8?q?=E6=80=A7=EF=BC=8C=E4=BB=A5=E5=8F=8A=E5=AF=B9=20Kokoro=20Flow?= =?UTF-8?q?=20Chatter=20(KFC)=20=E7=9A=84=E6=B6=88=E6=81=AF=E6=8B=86?= =?UTF-8?q?=E5=88=86=E7=AD=96=E7=95=A5=E8=BF=9B=E8=A1=8C=E8=B0=83=E6=95=B4?= =?UTF-8?q?=EF=BC=8C=E4=BB=A5=E7=94=9F=E6=88=90=E6=9B=B4=E8=87=AA=E7=84=B6?= =?UTF-8?q?=E3=80=81=E6=9B=B4=E8=B4=B4=E8=BF=91=E4=BA=BA=E7=B1=BB=E7=9A=84?= =?UTF-8?q?=E5=AF=B9=E8=AF=9D=E3=80=82=20**`StreamLoopManager`=20=E4=B8=AD?= =?UTF-8?q?=E7=9A=84=E6=AD=BB=E9=94=81=E6=A3=80=E6=B5=8B=EF=BC=9A**=20-=20?= =?UTF-8?q?=E6=96=B0=E7=9A=84=E6=AD=BB=E9=94=81=E6=A3=80=E6=B5=8B=E5=99=A8?= =?UTF-8?q?=E7=8E=B0=E5=9C=A8=E4=BC=9A=E5=AE=9A=E6=9C=9F=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=EF=BC=8C=E7=9B=91=E6=8E=A7=E6=89=80=E6=9C=89=E6=B4=BB=E5=8A=A8?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E6=B5=81=E3=80=82=20-=20=E5=AE=83=E4=BC=9A?= =?UTF-8?q?=E8=B7=9F=E8=B8=AA=E6=AF=8F=E4=B8=AA=E6=B6=88=E6=81=AF=E6=B5=81?= =?UTF-8?q?=E7=9A=84=E6=9C=80=E5=90=8E=E6=B4=BB=E5=8A=A8=E6=97=B6=E9=97=B4?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E6=A0=87=E8=AE=B0=E4=BB=BB=E4=BD=95=E8=B6=85?= =?UTF-8?q?=E8=BF=87=E4=B8=A4=E5=88=86=E9=92=9F=E6=9C=AA=E6=B4=BB=E5=8A=A8?= =?UTF-8?q?=E7=9A=84=E6=B5=81=E4=B8=BA=E6=BD=9C=E5=9C=A8=E6=AD=BB=E9=94=81?= =?UTF-8?q?=E3=80=82=20-=20=E8=BF=99=E7=A7=8D=E4=B8=BB=E5=8A=A8=E7=9B=91?= =?UTF-8?q?=E6=8E=A7=E6=9C=89=E5=8A=A9=E4=BA=8E=E8=AF=86=E5=88=AB=E5=92=8C?= =?UTF-8?q?=E8=AF=8A=E6=96=AD=E5=8F=AF=E8=83=BD=E5=8D=A1=E4=BD=8F=E7=9A=84?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E6=B5=81=EF=BC=8C=E9=98=B2=E6=AD=A2=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E6=95=B4=E4=BD=93=E5=86=BB=E7=BB=93=E3=80=82=20-=20?= =?UTF-8?q?=E4=B8=BA=E4=BA=86=E9=81=BF=E5=85=8D=E5=9C=A8=E9=95=BF=E6=97=B6?= =?UTF-8?q?=E9=97=B4=E7=AD=89=E5=BE=85=EF=BC=88=E4=BE=8B=E5=A6=82=E7=AD=89?= =?UTF-8?q?=E5=BE=85=E7=94=A8=E6=88=B7=E5=9B=9E=E5=A4=8D=E6=88=96=E9=95=BF?= =?UTF-8?q?=E6=97=B6=E9=97=B4=20LLM=20=E7=94=9F=E6=88=90=EF=BC=89=E6=9C=9F?= =?UTF-8?q?=E9=97=B4=E5=87=BA=E7=8E=B0=E8=AF=AF=E6=8A=A5=EF=BC=8C=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E6=B5=81=E5=BE=AA=E7=8E=AF=E7=8E=B0=E5=9C=A8=E5=8D=B3?= =?UTF-8?q?=E4=BD=BF=E5=9C=A8=E7=9D=A1=E7=9C=A0=E6=88=96=E5=A4=84=E7=90=86?= =?UTF-8?q?=E9=98=B6=E6=AE=B5=E4=B9=9F=E4=BC=9A=E5=AE=9A=E6=9C=9F=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E5=85=B6=E6=B4=BB=E5=8A=A8=E6=97=B6=E9=97=B4=E6=88=B3?= =?UTF-8?q?=E3=80=82=20**KFC=20=E4=B8=AD=E7=9A=84=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E6=8B=86=E5=88=86=E4=BC=98=E5=8C=96=EF=BC=9A**=20-=20=E8=87=AA?= =?UTF-8?q?=E5=8A=A8,=E5=93=8D=E5=BA=94=E5=90=8E=E5=A4=84=E7=90=86?= =?UTF-8?q?=E5=99=A8=E4=B8=AD=E7=9A=84=E5=9F=BA=E4=BA=8E=E8=A7=84=E5=88=99?= =?UTF-8?q?=E7=9A=84=E6=B6=88=E6=81=AF=E6=8B=86=E5=88=86=E5=99=A8=E5=B7=B2?= =?UTF-8?q?=E8=A2=AB=E7=A6=81=E7=94=A8=E3=80=82-=20=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E6=8B=86=E5=88=86=E7=9A=84=E8=B4=A3=E4=BB=BB=E7=8E=B0=E5=9C=A8?= =?UTF-8?q?=E5=AE=8C=E5=85=A8=E4=BA=A4=E7=94=B1=E5=A4=A7=E5=9E=8B=E8=AF=AD?= =?UTF-8?q?=E8=A8=80=E6=A8=A1=E5=9E=8B=EF=BC=88LLM=EF=BC=89=E5=A4=84?= =?UTF-8?q?=E7=90=86=E3=80=82-=20=E7=B3=BB=E7=BB=9F=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E5=B7=B2=E6=9B=B4=E6=96=B0=EF=BC=8C=E6=98=8E=E7=A1=AE=E6=8C=87?= =?UTF-8?q?=E7=A4=BALLM=E4=BD=BF=E7=94=A8=E5=A4=9A=E4=B8=AA=20`reply`=20?= =?UTF-8?q?=E6=93=8D=E4=BD=9C=EF=BC=8C=E5=B0=86=E9=95=BF=E5=93=8D=E5=BA=94?= =?UTF-8?q?=E6=8B=86=E5=88=86=E4=B8=BA=E6=9B=B4=E7=9F=AD=E3=80=81=E6=9B=B4?= =?UTF-8?q?=E8=87=AA=E7=84=B6=E7=9A=84=E6=AE=B5=E8=90=BD=EF=BC=8C=E6=A8=A1?= =?UTF-8?q?=E4=BB=BF=E7=9C=9F=E5=AE=9E=E7=9A=84=E4=BA=BA=E7=B1=BB=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E6=A8=A1=E5=BC=8F=E3=80=82-=20=E6=AD=A4=E6=9B=B4?= =?UTF-8?q?=E6=94=B9=E5=85=81=E8=AE=B8=E8=BF=9B=E8=A1=8C=E6=9B=B4=E5=8A=A0?= =?UTF-8?q?=E4=B8=8A=E4=B8=8B=E6=96=87=E6=84=9F=E7=9F=A5=E5=92=8C=E6=83=85?= =?UTF-8?q?=E6=84=9F=E9=80=82=E5=AE=9C=E7=9A=84=E6=B6=88=E6=81=AF=E5=88=86?= =?UTF-8?q?=E6=AE=B5=EF=BC=8C=E4=BB=8E=E8=80=8C=E6=8F=90=E4=BE=9B=E6=9B=B4?= =?UTF-8?q?=E5=85=B7=E5=90=B8=E5=BC=95=E5=8A=9B=E7=9A=84=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E4=BD=93=E9=AA=8C=E3=80=82**`VectorStore`=20=E7=9A=84=E5=BC=82?= =?UTF-8?q?=E6=AD=A5=E5=AE=89=E5=85=A8=E6=80=A7=EF=BC=9A**-=20=E6=89=80?= =?UTF-8?q?=E6=9C=89=E5=AF=B9=E5=90=8C=E6=AD=A5=20ChromaDB=20=E5=BA=93?= =?UTF-8?q?=E7=9A=84=E8=B0=83=E7=94=A8=E7=8E=B0=E5=9C=A8=E9=83=BD=E8=A2=AB?= =?UTF-8?q?=E5=B0=81=E8=A3=85=E5=9C=A8=20`asyncio.to=5Fthread()`=20?= =?UTF-8?q?=E4=B8=AD=E3=80=82=E8=BF=99=E5=8F=AF=E4=BB=A5=E9=98=B2=E6=AD=A2?= =?UTF-8?q?=E9=98=BB=E5=A1=9E=E4=B8=BB=20asyncio=20=E4=BA=8B=E4=BB=B6?= =?UTF-8?q?=E5=BE=AA=E7=8E=AF=EF=BC=8C=E8=80=8C=E8=BF=99=E6=AD=A3=E6=98=AF?= =?UTF-8?q?=E6=96=B0=E6=A3=80=E6=B5=8B=E5=99=A8=E8=AE=BE=E8=AE=A1=E7=94=A8?= =?UTF-8?q?=E6=9D=A5=E6=8D=95=E8=8E=B7=E7=9A=84=E6=BD=9C=E5=9C=A8=E6=AD=BB?= =?UTF-8?q?=E9=94=81=E6=9D=A5=E6=BA=90=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../message_manager/distribution_manager.py | 130 +++++++++++++++++- src/memory_graph/storage/vector_store.py | 128 +++++++++++------ .../kokoro_flow_chatter/prompt_modules.py | 22 ++- .../response_post_processor.py | 36 ++--- 4 files changed, 242 insertions(+), 74 deletions(-) diff --git a/src/chat/message_manager/distribution_manager.py b/src/chat/message_manager/distribution_manager.py index 674f0ac00..351e61a64 100644 --- a/src/chat/message_manager/distribution_manager.py +++ b/src/chat/message_manager/distribution_manager.py @@ -52,6 +52,11 @@ class StreamLoopManager: # 流循环启动锁:防止并发启动同一个流的多个循环任务 self._stream_start_locks: dict[str, asyncio.Lock] = {} + + # 死锁检测:记录每个流的最后活动时间 + self._stream_last_activity: dict[str, float] = {} + self._deadlock_detector_task: asyncio.Task | None = None + self._deadlock_threshold_seconds: float = 120.0 # 2分钟无活动视为可能死锁 logger.info(f"流循环管理器初始化完成 (最大并发流数: {self.max_concurrent_streams})") @@ -62,6 +67,60 @@ class StreamLoopManager: return self.is_running = True + + # 启动死锁检测器 + self._deadlock_detector_task = asyncio.create_task( + self._deadlock_detector_loop(), + name="deadlock_detector" + ) + logger.info("死锁检测器已启动") + + async def _deadlock_detector_loop(self) -> None: + """死锁检测循环 - 定期检查所有流的活动状态""" + while self.is_running: + try: + await asyncio.sleep(30.0) # 每30秒检查一次 + + current_time = time.time() + suspected_deadlocks = [] + + # 检查所有活跃流的最后活动时间 + for stream_id, last_activity in list(self._stream_last_activity.items()): + inactive_seconds = current_time - last_activity + if inactive_seconds > self._deadlock_threshold_seconds: + suspected_deadlocks.append((stream_id, inactive_seconds)) + + if suspected_deadlocks: + logger.warning( + f"🔴 [死锁检测] 发现 {len(suspected_deadlocks)} 个可能卡住的流:\n" + + "\n".join([ + f" - stream={sid[:8]}, 无活动时间={inactive:.1f}s" + for sid, inactive in suspected_deadlocks + ]) + ) + + # 打印当前所有 asyncio 任务的状态 + all_tasks = asyncio.all_tasks() + stream_loop_tasks = [t for t in all_tasks if t.get_name().startswith("stream_loop_")] + logger.warning( + f"🔴 [死锁检测] 当前流循环任务状态:\n" + + "\n".join([ + f" - {t.get_name()}: done={t.done()}, cancelled={t.cancelled()}" + for t in stream_loop_tasks + ]) + ) + else: + # 每5分钟报告一次正常状态 + if int(current_time) % 300 < 30: + active_count = len(self._stream_last_activity) + if active_count > 0: + logger.info(f"🟢 [死锁检测] 所有 {active_count} 个流正常运行中") + + except asyncio.CancelledError: + logger.info("死锁检测器被取消") + break + except Exception as e: + logger.error(f"死锁检测器出错: {e}") async def stop(self) -> None: """停止流循环管理器""" @@ -69,6 +128,15 @@ class StreamLoopManager: return self.is_running = False + + # 停止死锁检测器 + if self._deadlock_detector_task and not self._deadlock_detector_task.done(): + self._deadlock_detector_task.cancel() + try: + await self._deadlock_detector_task + except asyncio.CancelledError: + pass + logger.info("死锁检测器已停止") # 取消所有流循环 try: @@ -214,11 +282,24 @@ class StreamLoopManager: """ task_id = id(asyncio.current_task()) logger.info(f"🔄 [流工作器] stream={stream_id[:8]}, 任务ID={task_id}, 启动") + + # 死锁检测:记录循环次数和上次活动时间 + loop_count = 0 + + # 注册到活动跟踪 + self._stream_last_activity[stream_id] = time.time() try: while self.is_running: + loop_count += 1 + loop_start_time = time.time() + + # 更新活动时间(死锁检测用) + self._stream_last_activity[stream_id] = loop_start_time + try: # 1. 获取流上下文 + logger.debug(f"🔍 [流工作器] stream={stream_id[:8]}, 循环#{loop_count}, 获取上下文...") context = await self._get_stream_context(stream_id) if not context: logger.warning(f"⚠️ [流工作器] stream={stream_id[:8]}, 无法获取流上下文") @@ -226,6 +307,7 @@ class StreamLoopManager: continue # 2. 检查是否有消息需要处理 + logger.debug(f"🔍 [流工作器] stream={stream_id[:8]}, 循环#{loop_count}, 刷新缓存消息...") await self._flush_cached_messages_to_unread(stream_id) unread_count = self._get_unread_count(context) force_dispatch = self._needs_force_dispatch_for_context(context, unread_count) @@ -245,11 +327,36 @@ class StreamLoopManager: logger.debug(f"更新流能量失败 {stream_id}: {e}") # 4. 激活chatter处理 + logger.debug(f"🔍 [流工作器] stream={stream_id[:8]}, 循环#{loop_count}, 开始chatter处理...") try: - success = await asyncio.wait_for(self._process_stream_messages(stream_id, context), global_config.chat.thinking_timeout) + # 在长时间处理期间定期更新活动时间,避免死锁检测误报 + async def process_with_activity_update(): + process_task = asyncio.create_task( + self._process_stream_messages(stream_id, context) + ) + activity_update_interval = 30.0 # 每30秒更新一次 + while not process_task.done(): + try: + # 等待任务完成或超时 + await asyncio.wait_for( + asyncio.shield(process_task), + timeout=activity_update_interval + ) + except asyncio.TimeoutError: + # 任务仍在运行,更新活动时间 + self._stream_last_activity[stream_id] = time.time() + logger.debug(f"🔄 [流工作器] stream={stream_id[:8]}, 处理中,更新活动时间") + return await process_task + + success = await asyncio.wait_for( + process_with_activity_update(), + global_config.chat.thinking_timeout + ) except asyncio.TimeoutError: logger.warning(f"⏱️ [流工作器] stream={stream_id[:8]}, 任务ID={task_id}, 处理超时") success = False + logger.debug(f"🔍 [流工作器] stream={stream_id[:8]}, 循环#{loop_count}, chatter处理完成, success={success}") + # 更新统计 self.stats["total_process_cycles"] += 1 if success: @@ -263,6 +370,7 @@ class StreamLoopManager: logger.warning(f"❌ [流工作器] stream={stream_id[:8]}, 任务ID={task_id}, 处理失败") # 5. 计算下次检查间隔 + logger.debug(f"🔍 [流工作器] stream={stream_id[:8]}, 循环#{loop_count}, 计算间隔...") interval = await self._calculate_interval(stream_id, has_messages) # 6. sleep等待下次检查 @@ -271,7 +379,22 @@ class StreamLoopManager: if last_interval is None or abs(interval - last_interval) > 0.01: logger.info(f"流 {stream_id} 等待周期变化: {interval:.2f}s") self._last_intervals[stream_id] = interval - await asyncio.sleep(interval) + + loop_duration = time.time() - loop_start_time + logger.debug(f"🔍 [流工作器] stream={stream_id[:8]}, 循环#{loop_count} 完成, 耗时={loop_duration:.2f}s, 即将sleep {interval:.2f}s") + + # 使用分段sleep,每隔一段时间更新活动时间,避免死锁检测误报 + # 当间隔较长时(如等待用户回复),分段更新活动时间 + remaining_sleep = interval + activity_update_interval = 30.0 # 每30秒更新一次活动时间 + while remaining_sleep > 0: + sleep_chunk = min(remaining_sleep, activity_update_interval) + await asyncio.sleep(sleep_chunk) + remaining_sleep -= sleep_chunk + # 更新活动时间,表明流仍在正常运行(只是在等待) + self._stream_last_activity[stream_id] = time.time() + + logger.debug(f"🔍 [流工作器] stream={stream_id[:8]}, 循环#{loop_count} sleep结束, 开始下一循环") except asyncio.CancelledError: logger.info(f"🛑 [流工作器] stream={stream_id[:8]}, 任务ID={task_id}, 被取消") @@ -293,6 +416,9 @@ class StreamLoopManager: # 清理间隔记录 self._last_intervals.pop(stream_id, None) + + # 清理活动跟踪 + self._stream_last_activity.pop(stream_id, None) logger.info(f"🏁 [流工作器] stream={stream_id[:8]}, 任务ID={task_id}, 循环结束") diff --git a/src/memory_graph/storage/vector_store.py b/src/memory_graph/storage/vector_store.py index e61153119..d30f96c80 100644 --- a/src/memory_graph/storage/vector_store.py +++ b/src/memory_graph/storage/vector_store.py @@ -1,9 +1,13 @@ """ 向量存储层:基于 ChromaDB 的语义向量存储 + +注意:ChromaDB 是同步库,所有操作都必须使用 asyncio.to_thread() 包装 +以避免阻塞 asyncio 事件循环导致死锁。 """ from __future__ import annotations +import asyncio from pathlib import Path from typing import Any @@ -53,22 +57,30 @@ class VectorStore: import chromadb from chromadb.config import Settings - # 创建持久化客户端 - self.client = chromadb.PersistentClient( - path=str(self.data_dir / "chroma"), - settings=Settings( - anonymized_telemetry=False, - allow_reset=True, - ), - ) + # 创建持久化客户端 - 同步操作需要在线程中执行 + def _create_client(): + return chromadb.PersistentClient( + path=str(self.data_dir / "chroma"), + settings=Settings( + anonymized_telemetry=False, + allow_reset=True, + ), + ) + + self.client = await asyncio.to_thread(_create_client) - # 获取或创建集合 - self.collection = self.client.get_or_create_collection( - name=self.collection_name, - metadata={"description": "Memory graph node embeddings"}, - ) + # 获取或创建集合 - 同步操作需要在线程中执行 + def _get_or_create_collection(): + return self.client.get_or_create_collection( + name=self.collection_name, + metadata={"description": "Memory graph node embeddings"}, + ) + + self.collection = await asyncio.to_thread(_get_or_create_collection) - logger.debug(f"ChromaDB 初始化完成,集合包含 {self.collection.count()} 个节点") + # count() 也是同步操作 + count = await asyncio.to_thread(self.collection.count) + logger.debug(f"ChromaDB 初始化完成,集合包含 {count} 个节点") except Exception as e: logger.error(f"初始化 ChromaDB 失败: {e}") @@ -106,12 +118,16 @@ class VectorStore: else: metadata[key] = str(value) - self.collection.add( - ids=[node.id], - embeddings=[node.embedding.tolist()], - metadatas=[metadata], - documents=[node.content], # 文本内容用于检索 - ) + # ChromaDB add() 是同步阻塞操作,必须在线程中执行 + def _add_node(): + self.collection.add( + ids=[node.id], + embeddings=[node.embedding.tolist()], + metadatas=[metadata], + documents=[node.content], + ) + + await asyncio.to_thread(_add_node) logger.debug(f"添加节点到向量存储: {node}") @@ -155,12 +171,16 @@ class VectorStore: metadata[key] = str(value) metadatas.append(metadata) - self.collection.add( - ids=[n.id for n in valid_nodes], - embeddings=[n.embedding.tolist() for n in valid_nodes], # type: ignore - metadatas=metadatas, - documents=[n.content for n in valid_nodes], - ) + # ChromaDB add() 是同步阻塞操作,必须在线程中执行 + def _add_batch(): + self.collection.add( + ids=[n.id for n in valid_nodes], + embeddings=[n.embedding.tolist() for n in valid_nodes], # type: ignore + metadatas=metadatas, + documents=[n.content for n in valid_nodes], + ) + + await asyncio.to_thread(_add_batch) except Exception as e: logger.error(f"批量添加节点失败: {e}") @@ -194,12 +214,15 @@ class VectorStore: if node_types: where_filter = {"node_type": {"$in": [nt.value for nt in node_types]}} - # 执行查询 - results = self.collection.query( - query_embeddings=[query_embedding.tolist()], - n_results=limit, - where=where_filter, - ) + # ChromaDB query() 是同步阻塞操作,必须在线程中执行 + def _query(): + return self.collection.query( + query_embeddings=[query_embedding.tolist()], + n_results=limit, + where=where_filter, + ) + + results = await asyncio.to_thread(_query) # 解析结果 import orjson @@ -360,7 +383,11 @@ class VectorStore: raise RuntimeError("向量存储未初始化") try: - result = self.collection.get(ids=[node_id], include=["metadatas", "embeddings"]) + # ChromaDB get() 是同步阻塞操作,必须在线程中执行 + def _get(): + return self.collection.get(ids=[node_id], include=["metadatas", "embeddings"]) + + result = await asyncio.to_thread(_get) # 修复:直接检查 ids 列表是否非空(避免 numpy 数组的布尔值歧义) if result is not None: @@ -392,7 +419,11 @@ class VectorStore: raise RuntimeError("向量存储未初始化") try: - self.collection.delete(ids=[node_id]) + # ChromaDB delete() 是同步阻塞操作,必须在线程中执行 + def _delete(): + self.collection.delete(ids=[node_id]) + + await asyncio.to_thread(_delete) logger.debug(f"删除节点: {node_id}") except Exception as e: @@ -411,7 +442,11 @@ class VectorStore: raise RuntimeError("向量存储未初始化") try: - self.collection.update(ids=[node_id], embeddings=[embedding.tolist()]) + # ChromaDB update() 是同步阻塞操作,必须在线程中执行 + def _update(): + self.collection.update(ids=[node_id], embeddings=[embedding.tolist()]) + + await asyncio.to_thread(_update) logger.debug(f"更新节点 embedding: {node_id}") except Exception as e: @@ -419,10 +454,16 @@ class VectorStore: raise def get_total_count(self) -> int: - """获取向量存储中的节点总数""" + """获取向量存储中的节点总数(同步方法,谨慎在 async 上下文中使用)""" if not self.collection: return 0 return self.collection.count() + + async def get_total_count_async(self) -> int: + """异步获取向量存储中的节点总数""" + if not self.collection: + return 0 + return await asyncio.to_thread(self.collection.count) async def clear(self) -> None: """清空向量存储(危险操作,仅用于测试)""" @@ -430,12 +471,15 @@ class VectorStore: return try: - # 删除并重新创建集合 - self.client.delete_collection(self.collection_name) - self.collection = self.client.get_or_create_collection( - name=self.collection_name, - metadata={"description": "Memory graph node embeddings"}, - ) + # ChromaDB delete_collection 和 get_or_create_collection 都是同步阻塞操作 + def _clear(): + self.client.delete_collection(self.collection_name) + return self.client.get_or_create_collection( + name=self.collection_name, + metadata={"description": "Memory graph node embeddings"}, + ) + + self.collection = await asyncio.to_thread(_clear) logger.warning(f"向量存储已清空: {self.collection_name}") except Exception as e: diff --git a/src/plugins/built_in/kokoro_flow_chatter/prompt_modules.py b/src/plugins/built_in/kokoro_flow_chatter/prompt_modules.py index 2b3e6ac84..0ccc4a661 100644 --- a/src/plugins/built_in/kokoro_flow_chatter/prompt_modules.py +++ b/src/plugins/built_in/kokoro_flow_chatter/prompt_modules.py @@ -233,7 +233,15 @@ def _format_available_actions(available_actions: dict[str, ActionInfo]) -> str: def _get_default_actions_block() -> str: """获取默认的内置动作描述块""" return """### `reply` - 发消息 -发送文字回复 +发送文字回复。 + +**自然分段技巧**:像真人发微信一样,把长回复拆成几条短消息: +- 在语气词后分段:"嗯~"、"好呀"、"哈哈"、"嗯..."、"唔..." +- 在情绪转折处分段:话题切换、语气变化的地方 +- 在自然停顿处分段:问句后、感叹后、一个完整意思表达完后 +- 每条消息保持简短,1-2句话最自然 +- 用多个 reply 动作,每条就是一条消息 + ```json {"type": "reply", "content": "你要说的话"} ``` @@ -291,8 +299,9 @@ def build_output_module( "expected_user_reaction": "你觉得对方会怎么回应", "max_wait_seconds": 等待秒数(60-900),不想等就填0, "actions": [ - {"type": "reply", "content": "你要发送的消息"}, - {"type": "其他动作", ...} + {"type": "reply", "content": "第一条消息"}, + {"type": "reply", "content": "第二条消息"}, + ... ] } ``` @@ -301,7 +310,12 @@ def build_output_module( - `thought`:你脑子里在想什么,越自然越好 - `actions`:你要做的事,可以组合多个动作 - `max_wait_seconds`:设定一个时间,对方没回的话你会再想想要不要说点什么 -- 即使什么都不想做,也放一个 `{"type": "do_nothing"}`""" +- 即使什么都不想做,也放一个 `{"type": "do_nothing"}` + +💡 **回复技巧**: +- 像发微信一样,把想说的话拆成几条短消息 +- 用多个 `reply` 动作,每个就是一条独立的消息 +- 这样更自然,真人聊天也是分段发的""" parts = ["## 6. 你的表达方式"] diff --git a/src/plugins/built_in/kokoro_flow_chatter/response_post_processor.py b/src/plugins/built_in/kokoro_flow_chatter/response_post_processor.py index e463d696b..496337d4f 100644 --- a/src/plugins/built_in/kokoro_flow_chatter/response_post_processor.py +++ b/src/plugins/built_in/kokoro_flow_chatter/response_post_processor.py @@ -141,29 +141,13 @@ async def process_reply_content(content: str) -> list[str]: # 失败时使用原内容 processed_content = content - # Step 2: 消息分割 - splitter_cfg = global_config.response_splitter - if splitter_cfg.enable: - split_mode = splitter_cfg.split_mode - max_length = splitter_cfg.max_length - max_sentences = splitter_cfg.max_sentence_num - - if split_mode == "punctuation": - # 基于标点符号分割 - result = split_by_punctuation( - processed_content, - max_length=max_length, - max_sentences=max_sentences, - ) - logger.info(f"[KFC PostProcessor] 标点分割完成,分为 {len(result)} 条消息") - return result - elif split_mode == "llm": - # LLM模式:目前暂不支持,回退到不分割 - logger.info("[KFC PostProcessor] LLM分割模式暂不支持,返回完整内容") - return [processed_content] - else: - logger.warning(f"[KFC PostProcessor] 未知分割模式: {split_mode}") - return [processed_content] - else: - # 分割器禁用,返回完整内容 - return [processed_content] + # Step 2: 消息分割 - 已禁用 + # KFC 的 LLM 会自己通过多个 reply 动作来分割消息, + # 后处理器不再进行二次分割,避免破坏 LLM 的自然分割决策。 + # + # 参考提示词中的指导: + # - LLM 被引导在合适的语气词、标点处自然分段 + # - 每个分段作为独立的 reply 动作发送 + # - 这样更符合真人发微信的习惯 + logger.debug("[KFC PostProcessor] 消息分割已禁用(由LLM自行通过多个reply分割)") + return [processed_content] From 0fe15dac52cbecbd854a285495f5c0a414be647c Mon Sep 17 00:00:00 2001 From: Windpicker-owo <3431391539@qq.com> Date: Sun, 30 Nov 2025 13:05:26 +0800 Subject: [PATCH 07/28] =?UTF-8?q?feat:=20=E4=BD=BF=E7=94=A8=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E7=AE=A1=E7=90=86=E5=92=8C=E4=BC=9A=E8=AF=9D=E5=A4=84?= =?UTF-8?q?=E7=90=86=E6=9D=A5=E5=AE=9E=E7=8E=B0Kokoro=20Flow=20Chatter=20V?= =?UTF-8?q?2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在Kokoro Flow Chatter V2中添加提示模块以管理提示信息。 - 创建一个构建器,用于根据用户交互和会话上下文构建提示。 - 为不同场景(新消息、及时回复等)注册各种提示模板。 - 开发一个回复模块,使用LLM API生成回复。 - 实现会话管理以处理用户交互并维护状态。 - 引入心理日志条目以追踪用户与机器人的交互情况。 - 确保各模块中都有适当的日志记录和错误处理。 --- .../kokoro_flow_chatter_v2/__init__.py | 66 +++ .../kokoro_flow_chatter_v2/action_executor.py | 228 ++++++++ .../kokoro_flow_chatter_v2/chatter.py | 263 +++++++++ .../built_in/kokoro_flow_chatter_v2/config.py | 221 ++++++++ .../kokoro_flow_chatter_v2/context_builder.py | 338 ++++++++++++ .../built_in/kokoro_flow_chatter_v2/models.py | 320 +++++++++++ .../built_in/kokoro_flow_chatter_v2/plugin.py | 105 ++++ .../proactive_thinker.py | 500 ++++++++++++++++++ .../kokoro_flow_chatter_v2/prompt/__init__.py | 16 + .../kokoro_flow_chatter_v2/prompt/builder.py | 388 ++++++++++++++ .../kokoro_flow_chatter_v2/prompt/prompts.py | 217 ++++++++ .../kokoro_flow_chatter_v2/replyer.py | 107 ++++ .../kokoro_flow_chatter_v2/session.py | 386 ++++++++++++++ 13 files changed, 3155 insertions(+) create mode 100644 src/plugins/built_in/kokoro_flow_chatter_v2/__init__.py create mode 100644 src/plugins/built_in/kokoro_flow_chatter_v2/action_executor.py create mode 100644 src/plugins/built_in/kokoro_flow_chatter_v2/chatter.py create mode 100644 src/plugins/built_in/kokoro_flow_chatter_v2/config.py create mode 100644 src/plugins/built_in/kokoro_flow_chatter_v2/context_builder.py create mode 100644 src/plugins/built_in/kokoro_flow_chatter_v2/models.py create mode 100644 src/plugins/built_in/kokoro_flow_chatter_v2/plugin.py create mode 100644 src/plugins/built_in/kokoro_flow_chatter_v2/proactive_thinker.py create mode 100644 src/plugins/built_in/kokoro_flow_chatter_v2/prompt/__init__.py create mode 100644 src/plugins/built_in/kokoro_flow_chatter_v2/prompt/builder.py create mode 100644 src/plugins/built_in/kokoro_flow_chatter_v2/prompt/prompts.py create mode 100644 src/plugins/built_in/kokoro_flow_chatter_v2/replyer.py create mode 100644 src/plugins/built_in/kokoro_flow_chatter_v2/session.py diff --git a/src/plugins/built_in/kokoro_flow_chatter_v2/__init__.py b/src/plugins/built_in/kokoro_flow_chatter_v2/__init__.py new file mode 100644 index 000000000..a7e8604e8 --- /dev/null +++ b/src/plugins/built_in/kokoro_flow_chatter_v2/__init__.py @@ -0,0 +1,66 @@ +""" +Kokoro Flow Chatter V2 - 私聊特化的心流聊天器 + +重构版本,核心设计理念: +1. Chatter 职责极简化:只负责"收到消息 → 规划执行" +2. Session 状态简化:只有 IDLE 和 WAITING 两种状态 +3. 独立的 Replyer:专属的提示词构建和 LLM 交互 +4. 独立的主动思考器:负责等待管理和主动发起 +5. 大模板 + 小模板:线性叙事风格的提示词架构 +""" + +from .models import ( + EventType, + SessionStatus, + MentalLogEntry, + WaitingConfig, + ActionModel, + LLMResponse, +) +from .session import KokoroSession, SessionManager, get_session_manager +from .chatter import KokoroFlowChatterV2 +from .replyer import generate_response +from .action_executor import ActionExecutor +from .proactive_thinker import ( + ProactiveThinker, + get_proactive_thinker, + start_proactive_thinker, + stop_proactive_thinker, +) +from .config import ( + KokoroFlowChatterV2Config, + get_config, + load_config, + reload_config, +) +from .plugin import KokoroFlowChatterV2Plugin + +__all__ = [ + # Models + "EventType", + "SessionStatus", + "MentalLogEntry", + "WaitingConfig", + "ActionModel", + "LLMResponse", + # Session + "KokoroSession", + "SessionManager", + "get_session_manager", + # Core Components + "KokoroFlowChatterV2", + "generate_response", + "ActionExecutor", + # Proactive Thinker + "ProactiveThinker", + "get_proactive_thinker", + "start_proactive_thinker", + "stop_proactive_thinker", + # Config + "KokoroFlowChatterV2Config", + "get_config", + "load_config", + "reload_config", + # Plugin + "KokoroFlowChatterV2Plugin", +] diff --git a/src/plugins/built_in/kokoro_flow_chatter_v2/action_executor.py b/src/plugins/built_in/kokoro_flow_chatter_v2/action_executor.py new file mode 100644 index 000000000..aa0fe2ef1 --- /dev/null +++ b/src/plugins/built_in/kokoro_flow_chatter_v2/action_executor.py @@ -0,0 +1,228 @@ +""" +Kokoro Flow Chatter V2 - 动作执行器 + +负责执行 LLM 决策的动作 +""" + +import asyncio +import time +from typing import TYPE_CHECKING, Any, Optional + +from src.chat.planner_actions.action_manager import ChatterActionManager +from src.common.logger import get_logger +from src.plugin_system.apis import send_api + +from .models import ActionModel, LLMResponse + +if TYPE_CHECKING: + from src.chat.message_receive.chat_stream import ChatStream + +logger = get_logger("kfc_v2_action_executor") + + +class ActionExecutor: + """ + 动作执行器 + + 职责: + - 执行 reply、poke_user 等动作 + - 通过 ActionManager 执行动态注册的动作 + """ + + # 内置动作(不通过 ActionManager) + BUILTIN_ACTIONS = {"reply", "do_nothing"} + + def __init__(self, stream_id: str): + self.stream_id = stream_id + self._action_manager = ChatterActionManager() + self._available_actions: dict = {} + + # 统计 + self._stats = { + "total_executed": 0, + "successful": 0, + "failed": 0, + } + + async def load_actions(self) -> dict: + """加载可用动作""" + await self._action_manager.load_actions(self.stream_id) + self._available_actions = self._action_manager.get_using_actions() + logger.debug(f"[ActionExecutor] 加载了 {len(self._available_actions)} 个动作") + return self._available_actions + + def get_available_actions(self) -> dict: + """获取可用动作""" + return self._available_actions.copy() + + async def execute( + self, + response: LLMResponse, + chat_stream: Optional["ChatStream"], + ) -> dict[str, Any]: + """ + 执行动作列表 + + Args: + response: LLM 响应 + chat_stream: 聊天流 + + Returns: + 执行结果 + """ + results = [] + has_reply = False + reply_content = "" + + for action in response.actions: + try: + result = await self._execute_action(action, chat_stream) + results.append(result) + + if result.get("success"): + self._stats["successful"] += 1 + if action.type in ("reply", "respond"): + has_reply = True + reply_content = action.params.get("content", "") + else: + self._stats["failed"] += 1 + + except Exception as e: + logger.error(f"[ActionExecutor] 执行动作失败 {action.type}: {e}") + results.append({ + "action_type": action.type, + "success": False, + "error": str(e), + }) + self._stats["failed"] += 1 + + self._stats["total_executed"] += 1 + + return { + "success": all(r.get("success", False) for r in results), + "results": results, + "has_reply": has_reply, + "reply_content": reply_content, + } + + async def _execute_action( + self, + action: ActionModel, + chat_stream: Optional["ChatStream"], + ) -> dict[str, Any]: + """执行单个动作""" + action_type = action.type + + if action_type == "reply": + return await self._execute_reply(action, chat_stream) + + elif action_type == "do_nothing": + logger.debug("[ActionExecutor] 执行 do_nothing") + return {"action_type": "do_nothing", "success": True} + + elif action_type == "poke_user": + return await self._execute_via_manager(action, chat_stream) + + elif action_type in self._available_actions: + return await self._execute_via_manager(action, chat_stream) + + else: + logger.warning(f"[ActionExecutor] 未知动作类型: {action_type}") + return { + "action_type": action_type, + "success": False, + "error": f"未知动作类型: {action_type}", + } + + async def _execute_reply( + self, + action: ActionModel, + chat_stream: Optional["ChatStream"], + ) -> dict[str, Any]: + """执行回复动作""" + content = action.params.get("content", "") + + if not content: + return { + "action_type": "reply", + "success": False, + "error": "回复内容为空", + } + + try: + # 消息后处理(分割、错别字等) + processed_messages = await self._process_reply_content(content) + + all_success = True + for msg in processed_messages: + success = await send_api.text_to_stream( + text=msg, + stream_id=self.stream_id, + typing=True, + ) + if not success: + all_success = False + + return { + "action_type": "reply", + "success": all_success, + "reply_text": content, + } + + except Exception as e: + logger.error(f"[ActionExecutor] 发送回复失败: {e}") + return { + "action_type": "reply", + "success": False, + "error": str(e), + } + + async def _process_reply_content(self, content: str) -> list[str]: + """处理回复内容(分割、错别字等)""" + try: + # 复用 v1 的后处理器 + from src.plugins.built_in.kokoro_flow_chatter.response_post_processor import ( + process_reply_content, + ) + + messages = await process_reply_content(content) + return messages if messages else [content] + + except Exception as e: + logger.warning(f"[ActionExecutor] 消息处理失败,使用原始内容: {e}") + return [content] + + async def _execute_via_manager( + self, + action: ActionModel, + chat_stream: Optional["ChatStream"], + ) -> dict[str, Any]: + """通过 ActionManager 执行动作""" + try: + result = await self._action_manager.execute_action( + action_name=action.type, + chat_id=self.stream_id, + target_message=None, + reasoning=f"KFC决策: {action.type}", + action_data=action.params, + thinking_id=None, + log_prefix="[KFC V2]", + ) + + return { + "action_type": action.type, + "success": result.get("success", False), + "result": result, + } + + except Exception as e: + logger.error(f"[ActionExecutor] ActionManager 执行失败: {e}") + return { + "action_type": action.type, + "success": False, + "error": str(e), + } + + def get_stats(self) -> dict: + """获取统计信息""" + return self._stats.copy() diff --git a/src/plugins/built_in/kokoro_flow_chatter_v2/chatter.py b/src/plugins/built_in/kokoro_flow_chatter_v2/chatter.py new file mode 100644 index 000000000..410226b95 --- /dev/null +++ b/src/plugins/built_in/kokoro_flow_chatter_v2/chatter.py @@ -0,0 +1,263 @@ +""" +Kokoro Flow Chatter V2 - Chatter 主类 + +极简设计,只负责: +1. 收到消息 +2. 调用 Replyer 生成响应 +3. 执行动作 +4. 更新 Session +""" + +import asyncio +import time +from typing import TYPE_CHECKING, Any, ClassVar, Optional + +from src.common.data_models.message_manager_data_model import StreamContext +from src.common.logger import get_logger +from src.plugin_system.base.base_chatter import BaseChatter +from src.plugin_system.base.component_types import ChatType + +from .action_executor import ActionExecutor +from .models import EventType, SessionStatus +from .replyer import generate_response +from .session import get_session_manager + +if TYPE_CHECKING: + from src.chat.planner_actions.action_manager import ChatterActionManager + from src.common.data_models.database_data_model import DatabaseMessages + +logger = get_logger("kfc_v2_chatter") + +# 控制台颜色 +SOFT_PURPLE = "\033[38;5;183m" +RESET = "\033[0m" + + +class KokoroFlowChatterV2(BaseChatter): + """ + Kokoro Flow Chatter V2 - 私聊特化的心流聊天器 + + 核心设计: + - Chatter 只负责 "收到消息 → 规划执行" 的流程 + - 无论 Session 之前是什么状态,流程都一样 + - 区别只体现在提示词中 + + 不负责: + - 等待超时处理(由 ProactiveThinker 负责) + - 连续思考(由 ProactiveThinker 负责) + - 主动发起对话(由 ProactiveThinker 负责) + """ + + chatter_name: str = "KokoroFlowChatterV2" + chatter_description: str = "心流聊天器 V2 - 私聊特化的深度情感交互处理器" + chat_types: ClassVar[list[ChatType]] = [ChatType.PRIVATE] + + def __init__( + self, + stream_id: str, + action_manager: "ChatterActionManager", + plugin_config: dict | None = None, + ): + super().__init__(stream_id, action_manager, plugin_config) + + # 核心组件 + self.session_manager = get_session_manager() + self.action_executor = ActionExecutor(stream_id) + + # 并发控制 + self._lock = asyncio.Lock() + self._processing = False + + # 统计 + self._stats = { + "messages_processed": 0, + "successful_responses": 0, + "failed_responses": 0, + } + + logger.info(f"{SOFT_PURPLE}[KFC V2]{RESET} 初始化完成: stream_id={stream_id}") + + async def execute(self, context: StreamContext) -> dict: + """ + 执行聊天处理 + + 流程: + 1. 获取 Session + 2. 获取未读消息 + 3. 记录用户消息到 mental_log + 4. 确定 situation_type(根据之前的等待状态) + 5. 调用 Replyer 生成响应 + 6. 执行动作 + 7. 更新 Session(记录 Bot 规划,设置等待状态) + 8. 保存 Session + """ + async with self._lock: + self._processing = True + + try: + # 1. 获取未读消息 + unread_messages = context.get_unread_messages() + if not unread_messages: + return self._build_result(success=True, message="no_unread_messages") + + # 2. 取最后一条消息作为主消息 + target_message = unread_messages[-1] + user_info = target_message.user_info + + if not user_info: + return self._build_result(success=False, message="no_user_info") + + user_id = str(user_info.user_id) + user_name = user_info.user_nickname or user_id + + # 3. 获取或创建 Session + session = await self.session_manager.get_session(user_id, self.stream_id) + + # 4. 确定 situation_type(根据之前的等待状态) + situation_type = self._determine_situation_type(session) + + # 5. 记录用户消息到 mental_log + for msg in unread_messages: + msg_content = msg.processed_plain_text or msg.display_message or "" + msg_user_name = msg.user_info.user_nickname if msg.user_info else user_name + msg_user_id = str(msg.user_info.user_id) if msg.user_info else user_id + + session.add_user_message( + content=msg_content, + user_name=msg_user_name, + user_id=msg_user_id, + timestamp=msg.time, + ) + + # 6. 加载可用动作 + await self.action_executor.load_actions() + available_actions = self.action_executor.get_available_actions() + + # 7. 获取聊天流 + chat_stream = await self._get_chat_stream() + + # 8. 调用 Replyer 生成响应 + response = await generate_response( + session=session, + user_name=user_name, + situation_type=situation_type, + chat_stream=chat_stream, + available_actions=available_actions, + ) + + # 9. 执行动作 + exec_result = await self.action_executor.execute(response, chat_stream) + + # 10. 记录 Bot 规划到 mental_log + session.add_bot_planning( + thought=response.thought, + actions=[a.to_dict() for a in response.actions], + expected_reaction=response.expected_reaction, + max_wait_seconds=response.max_wait_seconds, + ) + + # 11. 更新 Session 状态 + if response.max_wait_seconds > 0: + session.start_waiting( + expected_reaction=response.expected_reaction, + max_wait_seconds=response.max_wait_seconds, + ) + else: + session.end_waiting() + + # 12. 标记消息为已读 + for msg in unread_messages: + context.mark_message_as_read(str(msg.message_id)) + + # 13. 保存 Session + await self.session_manager.save_session(user_id) + + # 14. 更新统计 + self._stats["messages_processed"] += len(unread_messages) + if exec_result.get("has_reply"): + self._stats["successful_responses"] += 1 + + logger.info( + f"{SOFT_PURPLE}[KFC V2]{RESET} 处理完成: " + f"user={user_name}, situation={situation_type}, " + f"actions={[a.type for a in response.actions]}, " + f"wait={response.max_wait_seconds}s" + ) + + return self._build_result( + success=True, + message="processed", + has_reply=exec_result.get("has_reply", False), + thought=response.thought, + situation_type=situation_type, + ) + + except Exception as e: + self._stats["failed_responses"] += 1 + logger.error(f"[KFC V2] 处理失败: {e}") + import traceback + traceback.print_exc() + return self._build_result(success=False, message=str(e), error=True) + + finally: + self._processing = False + + def _determine_situation_type(self, session) -> str: + """ + 确定当前情况类型 + + 根据 Session 之前的状态决定提示词的 situation_type + """ + if session.status == SessionStatus.WAITING: + # 之前在等待 + if session.waiting_config.is_timeout(): + # 超时了才收到回复 + return "reply_late" + else: + # 在预期内收到回复 + return "reply_in_time" + else: + # 之前是 IDLE + return "new_message" + + async def _get_chat_stream(self): + """获取聊天流对象""" + try: + from src.chat.message_receive.chat_stream import get_chat_manager + + chat_manager = get_chat_manager() + if chat_manager: + return await chat_manager.get_stream(self.stream_id) + except Exception as e: + logger.warning(f"[KFC V2] 获取 chat_stream 失败: {e}") + return None + + def _build_result( + self, + success: bool, + message: str = "", + error: bool = False, + **kwargs, + ) -> dict: + """构建返回结果""" + result = { + "success": success, + "stream_id": self.stream_id, + "message": message, + "error": error, + "timestamp": time.time(), + } + result.update(kwargs) + return result + + def get_stats(self) -> dict[str, Any]: + """获取统计信息""" + return { + **self._stats, + "action_executor_stats": self.action_executor.get_stats(), + } + + @property + def is_processing(self) -> bool: + """是否正在处理""" + return self._processing diff --git a/src/plugins/built_in/kokoro_flow_chatter_v2/config.py b/src/plugins/built_in/kokoro_flow_chatter_v2/config.py new file mode 100644 index 000000000..37e6c068e --- /dev/null +++ b/src/plugins/built_in/kokoro_flow_chatter_v2/config.py @@ -0,0 +1,221 @@ +""" +Kokoro Flow Chatter V2 - 配置 + +可以通过 TOML 配置文件覆盖默认值 +""" + +from dataclasses import dataclass, field +from typing import List, Optional + + +@dataclass +class WaitingDefaults: + """等待配置默认值""" + + # 默认最大等待时间(秒) + default_max_wait_seconds: int = 300 + + # 最小等待时间 + min_wait_seconds: int = 30 + + # 最大等待时间 + max_wait_seconds: int = 1800 + + +@dataclass +class ProactiveConfig: + """主动思考配置""" + + # 是否启用主动思考 + enabled: bool = True + + # 沉默阈值(秒),超过此时间考虑主动发起 + silence_threshold_seconds: int = 7200 + + # 两次主动发起最小间隔(秒) + min_interval_between_proactive: int = 1800 + + # 勿扰时段开始(HH:MM 格式) + quiet_hours_start: str = "23:00" + + # 勿扰时段结束 + quiet_hours_end: str = "07:00" + + # 主动发起概率(0.0 ~ 1.0) + trigger_probability: float = 0.3 + + +@dataclass +class PromptConfig: + """提示词配置""" + + # 活动记录保留条数 + max_activity_entries: int = 30 + + # 每条记录最大字符数 + max_entry_length: int = 500 + + # 是否包含人物关系信息 + include_relation: bool = True + + # 是否包含记忆信息 + include_memory: bool = True + + +@dataclass +class SessionConfig: + """会话配置""" + + # Session 持久化目录(相对于 data/) + session_dir: str = "kokoro_flow_chatter_v2/sessions" + + # Session 自动过期时间(秒),超过此时间未活动自动清理 + session_expire_seconds: int = 86400 * 7 # 7 天 + + # 活动记录保留上限 + max_mental_log_entries: int = 100 + + +@dataclass +class LLMConfig: + """LLM 配置""" + + # 模型名称(空则使用默认) + model_name: str = "" + + # Temperature + temperature: float = 0.8 + + # 最大 Token + max_tokens: int = 1024 + + # 请求超时(秒) + timeout: float = 60.0 + + +@dataclass +class KokoroFlowChatterV2Config: + """Kokoro Flow Chatter V2 总配置""" + + # 是否启用 + enabled: bool = True + + # 启用的消息源类型(空列表表示全部) + enabled_stream_types: List[str] = field(default_factory=lambda: ["private"]) + + # 等待配置 + waiting: WaitingDefaults = field(default_factory=WaitingDefaults) + + # 主动思考配置 + proactive: ProactiveConfig = field(default_factory=ProactiveConfig) + + # 提示词配置 + prompt: PromptConfig = field(default_factory=PromptConfig) + + # 会话配置 + session: SessionConfig = field(default_factory=SessionConfig) + + # LLM 配置 + llm: LLMConfig = field(default_factory=LLMConfig) + + # 调试模式 + debug: bool = False + + +# 全局配置单例 +_config: Optional[KokoroFlowChatterV2Config] = None + + +def get_config() -> KokoroFlowChatterV2Config: + """获取全局配置""" + global _config + if _config is None: + _config = load_config() + return _config + + +def load_config() -> KokoroFlowChatterV2Config: + """从全局配置加载 KFC V2 配置""" + from src.config.config import global_config + + config = KokoroFlowChatterV2Config() + + # 尝试从全局配置读取 + if not global_config: + return config + + try: + if hasattr(global_config, 'kokoro_flow_chatter_v2'): + kfc_cfg = getattr(global_config, 'kokoro_flow_chatter_v2') + + # 基础配置 + if hasattr(kfc_cfg, 'enabled'): + config.enabled = kfc_cfg.enabled + if hasattr(kfc_cfg, 'enabled_stream_types'): + config.enabled_stream_types = list(kfc_cfg.enabled_stream_types) + if hasattr(kfc_cfg, 'debug'): + config.debug = kfc_cfg.debug + + # 等待配置 + if hasattr(kfc_cfg, 'waiting'): + wait_cfg = kfc_cfg.waiting + config.waiting = WaitingDefaults( + default_max_wait_seconds=getattr(wait_cfg, 'default_max_wait_seconds', 300), + min_wait_seconds=getattr(wait_cfg, 'min_wait_seconds', 30), + max_wait_seconds=getattr(wait_cfg, 'max_wait_seconds', 1800), + ) + + # 主动思考配置 + if hasattr(kfc_cfg, 'proactive'): + pro_cfg = kfc_cfg.proactive + config.proactive = ProactiveConfig( + enabled=getattr(pro_cfg, 'enabled', True), + silence_threshold_seconds=getattr(pro_cfg, 'silence_threshold_seconds', 7200), + min_interval_between_proactive=getattr(pro_cfg, 'min_interval_between_proactive', 1800), + quiet_hours_start=getattr(pro_cfg, 'quiet_hours_start', "23:00"), + quiet_hours_end=getattr(pro_cfg, 'quiet_hours_end', "07:00"), + trigger_probability=getattr(pro_cfg, 'trigger_probability', 0.3), + ) + + # 提示词配置 + if hasattr(kfc_cfg, 'prompt'): + pmt_cfg = kfc_cfg.prompt + config.prompt = PromptConfig( + max_activity_entries=getattr(pmt_cfg, 'max_activity_entries', 30), + max_entry_length=getattr(pmt_cfg, 'max_entry_length', 500), + include_relation=getattr(pmt_cfg, 'include_relation', True), + include_memory=getattr(pmt_cfg, 'include_memory', True), + ) + + # 会话配置 + if hasattr(kfc_cfg, 'session'): + sess_cfg = kfc_cfg.session + config.session = SessionConfig( + session_dir=getattr(sess_cfg, 'session_dir', "kokoro_flow_chatter_v2/sessions"), + session_expire_seconds=getattr(sess_cfg, 'session_expire_seconds', 86400 * 7), + max_mental_log_entries=getattr(sess_cfg, 'max_mental_log_entries', 100), + ) + + # LLM 配置 + if hasattr(kfc_cfg, 'llm'): + llm_cfg = kfc_cfg.llm + config.llm = LLMConfig( + model_name=getattr(llm_cfg, 'model_name', ""), + temperature=getattr(llm_cfg, 'temperature', 0.8), + max_tokens=getattr(llm_cfg, 'max_tokens', 1024), + timeout=getattr(llm_cfg, 'timeout', 60.0), + ) + + except Exception as e: + from src.common.logger import get_logger + logger = get_logger("kfc_v2_config") + logger.warning(f"加载 KFC V2 配置失败,使用默认值: {e}") + + return config + + +def reload_config() -> KokoroFlowChatterV2Config: + """重新加载配置""" + global _config + _config = load_config() + return _config diff --git a/src/plugins/built_in/kokoro_flow_chatter_v2/context_builder.py b/src/plugins/built_in/kokoro_flow_chatter_v2/context_builder.py new file mode 100644 index 000000000..d3633ebfe --- /dev/null +++ b/src/plugins/built_in/kokoro_flow_chatter_v2/context_builder.py @@ -0,0 +1,338 @@ +""" +Kokoro Flow Chatter V2 上下文构建器 + +为 KFC V2 提供完整的情境感知能力。 +包含: +- 关系信息 (relation_info) +- 记忆块 (memory_block) +- 表达习惯 (expression_habits) +- 日程信息 (schedule) +- 时间信息 (time) +""" + +import asyncio +import time +from datetime import datetime, timedelta +from typing import TYPE_CHECKING, Any, Optional + +from src.common.logger import get_logger +from src.config.config import global_config +from src.person_info.person_info import get_person_info_manager, PersonInfoManager + +if TYPE_CHECKING: + from src.chat.message_receive.chat_stream import ChatStream + from src.common.data_models.message_manager_data_model import StreamContext + +logger = get_logger("kfc_v2_context_builder") + + +def _get_config(): + """获取全局配置(带类型断言)""" + assert global_config is not None, "global_config 未初始化" + return global_config + + +class KFCContextBuilder: + """ + KFC V2 上下文构建器 + + 为提示词提供完整的情境感知数据。 + """ + + def __init__(self, chat_stream: "ChatStream"): + self.chat_stream = chat_stream + self.chat_id = chat_stream.stream_id + self.platform = chat_stream.platform + self.is_group_chat = bool(chat_stream.group_info) + + async def build_all_context( + self, + sender_name: str, + target_message: str, + context: Optional["StreamContext"] = None, + ) -> dict[str, str]: + """ + 并行构建所有上下文模块 + + Args: + sender_name: 发送者名称 + target_message: 目标消息内容 + context: 聊天流上下文(可选) + + Returns: + dict: 包含所有上下文块的字典 + """ + chat_history = await self._get_chat_history_text(context) + + tasks = { + "relation_info": self._build_relation_info(sender_name, target_message), + "memory_block": self._build_memory_block(chat_history, target_message), + "expression_habits": self._build_expression_habits(chat_history, target_message), + "schedule": self._build_schedule_block(), + "time": self._build_time_block(), + } + + results = {} + try: + task_results = await asyncio.gather( + *[self._wrap_task(name, coro) for name, coro in tasks.items()], + return_exceptions=True + ) + + for result in task_results: + if isinstance(result, tuple): + name, value = result + results[name] = value + else: + logger.warning(f"上下文构建任务异常: {result}") + except Exception as e: + logger.error(f"并行构建上下文失败: {e}") + + return results + + async def _wrap_task(self, name: str, coro) -> tuple[str, str]: + """包装任务以返回名称和结果""" + try: + result = await coro + return (name, result or "") + except Exception as e: + logger.error(f"构建 {name} 失败: {e}") + return (name, "") + + async def _get_chat_history_text( + self, + context: Optional["StreamContext"] = None, + limit: int = 20, + ) -> str: + """获取聊天历史文本""" + if context is None: + return "" + + try: + from src.chat.utils.chat_message_builder import build_readable_messages + + messages = context.get_messages(limit=limit, include_unread=True) + if not messages: + return "" + + msg_dicts = [msg.flatten() for msg in messages] + + return await build_readable_messages( + msg_dicts, + replace_bot_name=True, + timestamp_mode="relative", + truncate=True, + ) + except Exception as e: + logger.error(f"获取聊天历史失败: {e}") + return "" + + async def _build_relation_info(self, sender_name: str, target_message: str) -> str: + """构建关系信息块""" + config = _get_config() + + if sender_name == f"{config.bot.nickname}(你)": + return "你将要回复的是你自己发送的消息。" + + person_info_manager = get_person_info_manager() + person_id = await person_info_manager.get_person_id_by_person_name(sender_name) + + if not person_id: + logger.debug(f"未找到用户 {sender_name} 的ID") + return f"你完全不认识{sender_name},这是你们的第一次互动。" + + try: + from src.person_info.relationship_fetcher import relationship_fetcher_manager + + relationship_fetcher = relationship_fetcher_manager.get_fetcher(self.chat_id) + + user_relation_info = await relationship_fetcher.build_relation_info(person_id, points_num=5) + stream_impression = await relationship_fetcher.build_chat_stream_impression(self.chat_id) + + parts = [] + if user_relation_info: + parts.append(f"### 你与 {sender_name} 的关系\n{user_relation_info}") + if stream_impression: + scene_type = "这个群" if self.is_group_chat else "你们的私聊" + parts.append(f"### 你对{scene_type}的印象\n{stream_impression}") + + if parts: + return "\n\n".join(parts) + else: + return f"你与{sender_name}还没有建立深厚的关系,这是早期的互动阶段。" + + except Exception as e: + logger.error(f"获取关系信息失败: {e}") + return f"你与{sender_name}是普通朋友关系。" + + async def _build_memory_block(self, chat_history: str, target_message: str) -> str: + """构建记忆块(使用三层记忆系统)""" + config = _get_config() + + if not (config.memory and config.memory.enable): + return "" + + try: + from src.memory_graph.manager_singleton import get_unified_memory_manager + from src.memory_graph.utils.three_tier_formatter import memory_formatter + + unified_manager = get_unified_memory_manager() + if not unified_manager: + logger.debug("[三层记忆] 管理器未初始化") + return "" + + search_result = await unified_manager.search_memories( + query_text=target_message, + use_judge=True, + recent_chat_history=chat_history, + ) + + if not search_result: + return "" + + perceptual_blocks = search_result.get("perceptual_blocks", []) + short_term_memories = search_result.get("short_term_memories", []) + long_term_memories = search_result.get("long_term_memories", []) + + formatted_memories = await memory_formatter.format_all_tiers( + perceptual_blocks=perceptual_blocks, + short_term_memories=short_term_memories, + long_term_memories=long_term_memories + ) + + total_count = len(perceptual_blocks) + len(short_term_memories) + len(long_term_memories) + if total_count > 0 and formatted_memories.strip(): + logger.info( + f"[三层记忆] 检索到 {total_count} 条记忆 " + f"(感知:{len(perceptual_blocks)}, 短期:{len(short_term_memories)}, 长期:{len(long_term_memories)})" + ) + return f"### 🧠 相关记忆\n\n{formatted_memories}" + + return "" + + except Exception as e: + logger.error(f"[三层记忆] 检索失败: {e}") + return "" + + async def _build_expression_habits(self, chat_history: str, target_message: str) -> str: + """构建表达习惯块""" + config = _get_config() + + use_expression, _, _ = config.expression.get_expression_config_for_chat(self.chat_id) + if not use_expression: + return "" + + try: + from src.chat.express.expression_selector import expression_selector + + style_habits = [] + grammar_habits = [] + + selected_expressions = await expression_selector.select_suitable_expressions( + chat_id=self.chat_id, + chat_history=chat_history, + target_message=target_message, + max_num=8, + min_num=2 + ) + + if selected_expressions: + for expr in selected_expressions: + if isinstance(expr, dict) and "situation" in expr and "style" in expr: + expr_type = expr.get("type", "style") + habit_str = f"当{expr['situation']}时,使用 {expr['style']}" + if expr_type == "grammar": + grammar_habits.append(habit_str) + else: + style_habits.append(habit_str) + + parts = [] + if style_habits: + parts.append("**语言风格习惯**:\n" + "\n".join(f"- {h}" for h in style_habits)) + if grammar_habits: + parts.append("**句法习惯**:\n" + "\n".join(f"- {h}" for h in grammar_habits)) + + if parts: + return "### 💬 你的表达习惯\n\n" + "\n\n".join(parts) + + return "" + + except Exception as e: + logger.error(f"构建表达习惯失败: {e}") + return "" + + async def _build_schedule_block(self) -> str: + """构建日程信息块""" + config = _get_config() + + if not config.planning_system.schedule_enable: + return "" + + try: + from src.schedule.schedule_manager import schedule_manager + + activity_info = schedule_manager.get_current_activity() + if not activity_info: + return "" + + activity = activity_info.get("activity") + time_range = activity_info.get("time_range") + now = datetime.now() + + if time_range: + try: + start_str, end_str = time_range.split("-") + start_time = datetime.strptime(start_str.strip(), "%H:%M").replace( + year=now.year, month=now.month, day=now.day + ) + end_time = datetime.strptime(end_str.strip(), "%H:%M").replace( + year=now.year, month=now.month, day=now.day + ) + + if end_time < start_time: + end_time += timedelta(days=1) + if now < start_time: + now += timedelta(days=1) + + duration_minutes = (now - start_time).total_seconds() / 60 + remaining_minutes = (end_time - now).total_seconds() / 60 + + return ( + f"你当前正在「{activity}」," + f"从{start_time.strftime('%H:%M')}开始,预计{end_time.strftime('%H:%M')}结束," + f"已进行{duration_minutes:.0f}分钟,还剩约{remaining_minutes:.0f}分钟。" + ) + except (ValueError, AttributeError): + pass + + return f"你当前正在「{activity}」" + + except Exception as e: + logger.error(f"构建日程块失败: {e}") + return "" + + async def _build_time_block(self) -> str: + """构建时间信息块""" + now = datetime.now() + weekdays = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"] + weekday = weekdays[now.weekday()] + return f"{now.strftime('%Y年%m月%d日')} {weekday} {now.strftime('%H:%M:%S')}" + + +async def build_kfc_context( + chat_stream: "ChatStream", + sender_name: str, + target_message: str, + context: Optional["StreamContext"] = None, +) -> dict[str, str]: + """ + 便捷函数:构建KFC所需的所有上下文 + """ + builder = KFCContextBuilder(chat_stream) + return await builder.build_all_context(sender_name, target_message, context) + + +__all__ = [ + "KFCContextBuilder", + "build_kfc_context", +] diff --git a/src/plugins/built_in/kokoro_flow_chatter_v2/models.py b/src/plugins/built_in/kokoro_flow_chatter_v2/models.py new file mode 100644 index 000000000..9327f3dfb --- /dev/null +++ b/src/plugins/built_in/kokoro_flow_chatter_v2/models.py @@ -0,0 +1,320 @@ +""" +Kokoro Flow Chatter V2 - 数据模型 + +定义核心数据结构: +- EventType: 活动流事件类型 +- SessionStatus: 会话状态(仅 IDLE 和 WAITING) +- MentalLogEntry: 心理活动日志条目 +- WaitingConfig: 等待配置 +- ActionModel: 动作模型 +- LLMResponse: LLM 响应结构 +""" + +from dataclasses import dataclass, field +from enum import Enum +from typing import Any +import time + + +class EventType(Enum): + """ + 活动流事件类型 + + 用于标记 mental_log 中不同类型的事件, + 每种类型对应一个提示词小模板 + """ + # 用户相关 + USER_MESSAGE = "user_message" # 用户发送消息 + + # Bot 行动相关 + BOT_PLANNING = "bot_planning" # Bot 规划(thought + actions) + + # 等待相关 + WAITING_START = "waiting_start" # 开始等待 + WAITING_UPDATE = "waiting_update" # 等待期间心理变化 + REPLY_RECEIVED_IN_TIME = "reply_in_time" # 在预期内收到回复 + REPLY_RECEIVED_LATE = "reply_late" # 超出预期收到回复 + WAIT_TIMEOUT = "wait_timeout" # 等待超时 + + # 主动思考相关 + PROACTIVE_TRIGGER = "proactive_trigger" # 主动思考触发(长期沉默) + + def __str__(self) -> str: + return self.value + + +class SessionStatus(Enum): + """ + 会话状态 + + 极简设计,只有两种稳定状态: + - IDLE: 空闲,没有期待回复 + - WAITING: 等待对方回复中 + """ + IDLE = "idle" + WAITING = "waiting" + + def __str__(self) -> str: + return self.value + + +@dataclass +class WaitingConfig: + """ + 等待配置 + + 当 Bot 发送消息后设置的等待参数 + """ + expected_reaction: str = "" # 期望对方如何回应 + max_wait_seconds: int = 0 # 最长等待时间(秒),0 表示不等待 + started_at: float = 0.0 # 开始等待的时间戳 + last_thinking_at: float = 0.0 # 上次连续思考的时间戳 + thinking_count: int = 0 # 连续思考次数 + + def is_active(self) -> bool: + """是否正在等待""" + return self.max_wait_seconds > 0 and self.started_at > 0 + + def get_elapsed_seconds(self) -> float: + """获取已等待时间(秒)""" + if not self.is_active(): + return 0.0 + return time.time() - self.started_at + + def get_elapsed_minutes(self) -> float: + """获取已等待时间(分钟)""" + return self.get_elapsed_seconds() / 60 + + def is_timeout(self) -> bool: + """是否已超时""" + if not self.is_active(): + return False + return self.get_elapsed_seconds() >= self.max_wait_seconds + + def get_progress(self) -> float: + """获取等待进度 (0.0 - 1.0)""" + if not self.is_active() or self.max_wait_seconds <= 0: + return 0.0 + return min(self.get_elapsed_seconds() / self.max_wait_seconds, 1.0) + + def to_dict(self) -> dict[str, Any]: + return { + "expected_reaction": self.expected_reaction, + "max_wait_seconds": self.max_wait_seconds, + "started_at": self.started_at, + "last_thinking_at": self.last_thinking_at, + "thinking_count": self.thinking_count, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "WaitingConfig": + return cls( + expected_reaction=data.get("expected_reaction", ""), + max_wait_seconds=data.get("max_wait_seconds", 0), + started_at=data.get("started_at", 0.0), + last_thinking_at=data.get("last_thinking_at", 0.0), + thinking_count=data.get("thinking_count", 0), + ) + + def reset(self) -> None: + """重置等待配置""" + self.expected_reaction = "" + self.max_wait_seconds = 0 + self.started_at = 0.0 + self.last_thinking_at = 0.0 + self.thinking_count = 0 + + +@dataclass +class MentalLogEntry: + """ + 心理活动日志条目 + + 记录活动流中的每一个事件节点, + 用于构建线性叙事风格的提示词 + """ + event_type: EventType + timestamp: float + + # 通用字段 + content: str = "" # 事件内容(消息文本、动作描述等) + + # 用户消息相关 + user_name: str = "" # 发送者名称 + user_id: str = "" # 发送者 ID + + # Bot 规划相关 + thought: str = "" # 内心想法 + actions: list[dict] = field(default_factory=list) # 执行的动作列表 + expected_reaction: str = "" # 期望的回应 + max_wait_seconds: int = 0 # 设定的等待时间 + + # 等待相关 + elapsed_seconds: float = 0.0 # 已等待时间 + waiting_thought: str = "" # 等待期间的想法 + mood: str = "" # 当前心情 + + # 元数据 + metadata: dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + return { + "event_type": str(self.event_type), + "timestamp": self.timestamp, + "content": self.content, + "user_name": self.user_name, + "user_id": self.user_id, + "thought": self.thought, + "actions": self.actions, + "expected_reaction": self.expected_reaction, + "max_wait_seconds": self.max_wait_seconds, + "elapsed_seconds": self.elapsed_seconds, + "waiting_thought": self.waiting_thought, + "mood": self.mood, + "metadata": self.metadata, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "MentalLogEntry": + event_type_str = data.get("event_type", "user_message") + try: + event_type = EventType(event_type_str) + except ValueError: + event_type = EventType.USER_MESSAGE + + return cls( + event_type=event_type, + timestamp=data.get("timestamp", time.time()), + content=data.get("content", ""), + user_name=data.get("user_name", ""), + user_id=data.get("user_id", ""), + thought=data.get("thought", ""), + actions=data.get("actions", []), + expected_reaction=data.get("expected_reaction", ""), + max_wait_seconds=data.get("max_wait_seconds", 0), + elapsed_seconds=data.get("elapsed_seconds", 0.0), + waiting_thought=data.get("waiting_thought", ""), + mood=data.get("mood", ""), + metadata=data.get("metadata", {}), + ) + + def get_time_str(self, format: str = "%H:%M") -> str: + """获取格式化的时间字符串""" + return time.strftime(format, time.localtime(self.timestamp)) + + +@dataclass +class ActionModel: + """ + 动作模型 + + 表示 LLM 决策的单个动作 + """ + type: str # 动作类型 + params: dict[str, Any] = field(default_factory=dict) # 动作参数 + reason: str = "" # 选择该动作的理由 + + def to_dict(self) -> dict[str, Any]: + result = {"type": self.type} + if self.reason: + result["reason"] = self.reason + result.update(self.params) + return result + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "ActionModel": + action_type = data.get("type", "do_nothing") + reason = data.get("reason", "") + params = {k: v for k, v in data.items() if k not in ("type", "reason")} + return cls(type=action_type, params=params, reason=reason) + + def get_description(self) -> str: + """获取动作的文字描述""" + if self.type == "reply": + content = self.params.get("content", "") + return f'发送消息:"{content[:50]}{"..." if len(content) > 50 else ""}"' + elif self.type == "poke_user": + return "戳了戳对方" + elif self.type == "do_nothing": + return "什么都没做" + elif self.type == "send_emoji": + emoji = self.params.get("emoji", "") + return f"发送表情:{emoji}" + else: + return f"执行动作:{self.type}" + + +@dataclass +class LLMResponse: + """ + LLM 响应结构 + + 定义 LLM 输出的 JSON 格式 + """ + thought: str # 内心想法 + actions: list[ActionModel] # 动作列表 + expected_reaction: str = "" # 期望对方的回应 + max_wait_seconds: int = 0 # 最长等待时间(0 = 不等待) + + # 可选字段 + mood: str = "" # 当前心情 + + def to_dict(self) -> dict[str, Any]: + return { + "thought": self.thought, + "actions": [a.to_dict() for a in self.actions], + "expected_reaction": self.expected_reaction, + "max_wait_seconds": self.max_wait_seconds, + "mood": self.mood, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "LLMResponse": + actions_data = data.get("actions", []) + actions = [ActionModel.from_dict(a) for a in actions_data] if actions_data else [] + + # 如果没有动作,添加默认的 do_nothing + if not actions: + actions = [ActionModel(type="do_nothing")] + + # 处理 max_wait_seconds,确保在合理范围内 + max_wait = data.get("max_wait_seconds", 0) + try: + max_wait = int(max_wait) + max_wait = max(0, min(max_wait, 1800)) # 0-30分钟 + except (ValueError, TypeError): + max_wait = 0 + + return cls( + thought=data.get("thought", ""), + actions=actions, + expected_reaction=data.get("expected_reaction", ""), + max_wait_seconds=max_wait, + mood=data.get("mood", ""), + ) + + @classmethod + def create_error_response(cls, error_message: str) -> "LLMResponse": + """创建错误响应""" + return cls( + thought=f"出现了问题:{error_message}", + actions=[ActionModel(type="do_nothing")], + expected_reaction="", + max_wait_seconds=0, + ) + + def has_reply(self) -> bool: + """是否包含回复动作""" + return any(a.type in ("reply", "respond") for a in self.actions) + + def get_reply_content(self) -> str: + """获取回复内容""" + for action in self.actions: + if action.type in ("reply", "respond"): + return action.params.get("content", "") + return "" + + def get_actions_description(self) -> str: + """获取所有动作的文字描述""" + descriptions = [a.get_description() for a in self.actions] + return " + ".join(descriptions) diff --git a/src/plugins/built_in/kokoro_flow_chatter_v2/plugin.py b/src/plugins/built_in/kokoro_flow_chatter_v2/plugin.py new file mode 100644 index 000000000..9af94ccc7 --- /dev/null +++ b/src/plugins/built_in/kokoro_flow_chatter_v2/plugin.py @@ -0,0 +1,105 @@ +""" +Kokoro Flow Chatter V2 - 插件注册 + +注册 Chatter +""" + +from typing import Any, ClassVar + +from src.common.logger import get_logger +from src.plugin_system.base.base_plugin import BasePlugin +from src.plugin_system.base.component_types import ChatterInfo +from src.plugin_system.decorators import register_plugin + +from .chatter import KokoroFlowChatterV2 +from .config import get_config +from .proactive_thinker import start_proactive_thinker, stop_proactive_thinker + +logger = get_logger("kfc_v2_plugin") + + +@register_plugin +class KokoroFlowChatterV2Plugin(BasePlugin): + """ + Kokoro Flow Chatter V2 插件 + + 专为私聊设计的增强 Chatter: + - 线性叙事提示词架构 + - 等待机制与心理状态演变 + - 主动思考能力 + """ + + plugin_name: str = "kokoro_flow_chatter_v2" + enable_plugin: bool = True + plugin_priority: int = 50 # 高于默认 Chatter + dependencies: ClassVar[list[str]] = [] + python_dependencies: ClassVar[list[str]] = [] + config_file_name: str = "config.toml" + + # 状态 + _is_started: bool = False + + async def on_plugin_loaded(self): + """插件加载时""" + config = get_config() + + if not config.enabled: + logger.info("[KFC V2] 插件已禁用") + return + + logger.info("[KFC V2] 插件已加载") + + # 启动主动思考器 + if config.proactive.enabled: + try: + await start_proactive_thinker() + logger.info("[KFC V2] 主动思考器已启动") + self._is_started = True + except Exception as e: + logger.error(f"[KFC V2] 启动主动思考器失败: {e}") + + async def on_plugin_unloaded(self): + """插件卸载时""" + try: + await stop_proactive_thinker() + logger.info("[KFC V2] 主动思考器已停止") + self._is_started = False + except Exception as e: + logger.warning(f"[KFC V2] 停止主动思考器失败: {e}") + + def get_plugin_components(self): + """返回组件列表""" + config = get_config() + + if not config.enabled: + return [] + + components = [] + + try: + # 注册 Chatter + components.append(( + KokoroFlowChatterV2.get_chatter_info(), + KokoroFlowChatterV2, + )) + logger.debug("[KFC V2] 成功加载 KokoroFlowChatterV2 组件") + except Exception as e: + logger.error(f"[KFC V2] 加载组件失败: {e}") + + return components + + def get_plugin_info(self) -> dict[str, Any]: + """获取插件信息""" + return { + "name": self.plugin_name, + "display_name": "Kokoro Flow Chatter V2", + "version": "2.0.0", + "author": "MoFox", + "description": "专为私聊设计的增强 Chatter", + "features": [ + "线性叙事提示词架构", + "心理活动流记录", + "等待机制与超时处理", + "主动思考能力", + ], + } diff --git a/src/plugins/built_in/kokoro_flow_chatter_v2/proactive_thinker.py b/src/plugins/built_in/kokoro_flow_chatter_v2/proactive_thinker.py new file mode 100644 index 000000000..382b9500f --- /dev/null +++ b/src/plugins/built_in/kokoro_flow_chatter_v2/proactive_thinker.py @@ -0,0 +1,500 @@ +""" +Kokoro Flow Chatter V2 - 主动思考器 + +独立组件,负责: +1. 等待期间的连续思考(更新心理状态) +2. 等待超时决策(继续等 or 做点什么) +3. 长期沉默后主动发起对话 + +通过 UnifiedScheduler 定期触发,与 Chatter 解耦 +""" + +import asyncio +import random +import time +from datetime import datetime +from typing import TYPE_CHECKING, Any, Callable, Coroutine, Optional + +from src.common.logger import get_logger +from src.config.config import global_config +from src.plugin_system.apis.unified_scheduler import TriggerType, unified_scheduler + +from .action_executor import ActionExecutor +from .models import EventType, SessionStatus +from .replyer import generate_response +from .session import KokoroSession, get_session_manager + +if TYPE_CHECKING: + from src.chat.message_receive.chat_stream import ChatStream + +logger = get_logger("kfc_v2_proactive_thinker") + + +class ProactiveThinker: + """ + 主动思考器 + + 独立于 Chatter,负责处理: + 1. 等待期间的连续思考 + 2. 等待超时 + 3. 长期沉默后主动发起 + + 核心逻辑: + - 定期检查所有 WAITING 状态的 Session + - 触发连续思考或超时决策 + - 定期检查长期沉默的 Session,考虑主动发起 + """ + + # 连续思考触发点(等待进度百分比) + THINKING_TRIGGERS = [0.3, 0.6, 0.85] + + # 任务名称 + TASK_WAITING_CHECK = "kfc_v2_waiting_check" + TASK_PROACTIVE_CHECK = "kfc_v2_proactive_check" + + def __init__(self): + self.session_manager = get_session_manager() + + # 配置 + self._load_config() + + # 调度任务 ID + self._waiting_schedule_id: Optional[str] = None + self._proactive_schedule_id: Optional[str] = None + self._running = False + + # 统计 + self._stats = { + "waiting_checks": 0, + "continuous_thinking_triggered": 0, + "timeout_decisions": 0, + "proactive_triggered": 0, + } + + def _load_config(self) -> None: + """加载配置""" + # 默认配置 + self.waiting_check_interval = 15.0 # 等待检查间隔(秒) + self.proactive_check_interval = 300.0 # 主动思考检查间隔(秒) + self.silence_threshold = 7200 # 沉默阈值(秒) + self.min_proactive_interval = 1800 # 两次主动思考最小间隔(秒) + self.quiet_hours_start = "23:00" + self.quiet_hours_end = "07:00" + + # 从全局配置读取 + if global_config and hasattr(global_config, 'kokoro_flow_chatter'): + kfc_config = global_config.kokoro_flow_chatter + if hasattr(kfc_config, 'proactive_thinking'): + proactive_cfg = kfc_config.proactive_thinking + self.silence_threshold = getattr(proactive_cfg, 'silence_threshold_seconds', 7200) + self.min_proactive_interval = getattr(proactive_cfg, 'min_interval_between_proactive', 1800) + + async def start(self) -> None: + """启动主动思考器""" + if self._running: + logger.warning("[ProactiveThinker] 已在运行中") + return + + self._running = True + + # 注册等待检查任务 + self._waiting_schedule_id = await unified_scheduler.create_schedule( + callback=self._check_waiting_sessions, + trigger_type=TriggerType.TIME, + trigger_config={"delay_seconds": self.waiting_check_interval}, + is_recurring=True, + task_name=self.TASK_WAITING_CHECK, + force_overwrite=True, + timeout=60.0, + ) + + # 注册主动思考检查任务 + self._proactive_schedule_id = await unified_scheduler.create_schedule( + callback=self._check_proactive_sessions, + trigger_type=TriggerType.TIME, + trigger_config={"delay_seconds": self.proactive_check_interval}, + is_recurring=True, + task_name=self.TASK_PROACTIVE_CHECK, + force_overwrite=True, + timeout=120.0, + ) + + logger.info("[ProactiveThinker] 已启动") + + async def stop(self) -> None: + """停止主动思考器""" + if not self._running: + return + + self._running = False + + if self._waiting_schedule_id: + await unified_scheduler.remove_schedule(self._waiting_schedule_id) + if self._proactive_schedule_id: + await unified_scheduler.remove_schedule(self._proactive_schedule_id) + + logger.info("[ProactiveThinker] 已停止") + + # ======================== + # 等待检查 + # ======================== + + async def _check_waiting_sessions(self) -> None: + """检查所有等待中的 Session""" + self._stats["waiting_checks"] += 1 + + sessions = await self.session_manager.get_waiting_sessions() + if not sessions: + return + + # 并行处理 + tasks = [ + asyncio.create_task(self._process_waiting_session(s)) + for s in sessions + ] + await asyncio.gather(*tasks, return_exceptions=True) + + async def _process_waiting_session(self, session: KokoroSession) -> None: + """处理单个等待中的 Session""" + try: + if session.status != SessionStatus.WAITING: + return + + if not session.waiting_config.is_active(): + return + + # 检查是否超时 + if session.waiting_config.is_timeout(): + await self._handle_timeout(session) + return + + # 检查是否需要触发连续思考 + progress = session.waiting_config.get_progress() + if self._should_trigger_thinking(session, progress): + await self._handle_continuous_thinking(session, progress) + + except Exception as e: + logger.error(f"[ProactiveThinker] 处理等待 Session 失败 {session.user_id}: {e}") + + def _should_trigger_thinking(self, session: KokoroSession, progress: float) -> bool: + """判断是否应触发连续思考""" + # 计算应该触发的次数 + expected_count = sum(1 for t in self.THINKING_TRIGGERS if progress >= t) + + if session.waiting_config.thinking_count >= expected_count: + return False + + # 确保两次思考之间有间隔 + if session.waiting_config.last_thinking_at > 0: + elapsed = time.time() - session.waiting_config.last_thinking_at + if elapsed < 30: # 至少 30 秒间隔 + return False + + return True + + async def _handle_continuous_thinking( + self, + session: KokoroSession, + progress: float, + ) -> None: + """处理连续思考""" + self._stats["continuous_thinking_triggered"] += 1 + + # 生成等待中的想法 + thought = self._generate_waiting_thought(session, progress) + + # 记录到 mental_log + session.add_waiting_update( + waiting_thought=thought, + mood="", # 可以根据进度设置心情 + ) + + # 更新思考计数 + session.waiting_config.thinking_count += 1 + session.waiting_config.last_thinking_at = time.time() + + # 保存 + await self.session_manager.save_session(session.user_id) + + logger.debug( + f"[ProactiveThinker] 连续思考: user={session.user_id}, " + f"progress={progress:.1%}, thought={thought[:30]}..." + ) + + def _generate_waiting_thought(self, session: KokoroSession, progress: float) -> str: + """生成等待中的想法""" + elapsed_minutes = session.waiting_config.get_elapsed_minutes() + + if progress < 0.4: + thoughts = [ + f"已经等了 {elapsed_minutes:.0f} 分钟了,对方可能在忙吧...", + "不知道对方在做什么呢", + "再等等看吧", + ] + elif progress < 0.7: + thoughts = [ + f"等了 {elapsed_minutes:.0f} 分钟了,有点担心...", + "对方是不是忘记回复了?", + "嗯...还是没有消息", + ] + else: + thoughts = [ + f"已经等了 {elapsed_minutes:.0f} 分钟了,感觉有点焦虑", + "要不要主动说点什么呢...", + "快到时间了,对方还是没回", + ] + + return random.choice(thoughts) + + async def _handle_timeout(self, session: KokoroSession) -> None: + """处理等待超时""" + self._stats["timeout_decisions"] += 1 + + logger.info(f"[ProactiveThinker] 等待超时: user={session.user_id}") + + try: + # 获取聊天流 + chat_stream = await self._get_chat_stream(session.stream_id) + + # 加载动作 + action_executor = ActionExecutor(session.stream_id) + await action_executor.load_actions() + + # 调用 Replyer 生成超时决策 + response = await generate_response( + session=session, + user_name=session.user_id, # 这里可以改进,获取真实用户名 + situation_type="timeout", + chat_stream=chat_stream, + available_actions=action_executor.get_available_actions(), + ) + + # 执行动作 + exec_result = await action_executor.execute(response, chat_stream) + + # 记录到 mental_log + session.add_bot_planning( + thought=response.thought, + actions=[a.to_dict() for a in response.actions], + expected_reaction=response.expected_reaction, + max_wait_seconds=response.max_wait_seconds, + ) + + # 更新状态 + if response.max_wait_seconds > 0: + # 继续等待 + session.start_waiting( + expected_reaction=response.expected_reaction, + max_wait_seconds=response.max_wait_seconds, + ) + else: + # 不再等待 + session.end_waiting() + + # 保存 + await self.session_manager.save_session(session.user_id) + + logger.info( + f"[ProactiveThinker] 超时决策完成: user={session.user_id}, " + f"actions={[a.type for a in response.actions]}, " + f"continue_wait={response.max_wait_seconds > 0}" + ) + + except Exception as e: + logger.error(f"[ProactiveThinker] 处理超时失败: {e}") + # 出错时结束等待 + session.end_waiting() + await self.session_manager.save_session(session.user_id) + + # ======================== + # 主动思考(长期沉默) + # ======================== + + async def _check_proactive_sessions(self) -> None: + """检查是否有需要主动发起对话的 Session""" + # 检查是否在勿扰时段 + if self._is_quiet_hours(): + return + + sessions = await self.session_manager.get_all_sessions() + current_time = time.time() + + for session in sessions: + try: + trigger_reason = self._should_trigger_proactive(session, current_time) + if trigger_reason: + await self._handle_proactive(session, trigger_reason) + except Exception as e: + logger.error(f"[ProactiveThinker] 检查主动思考失败 {session.user_id}: {e}") + + def _is_quiet_hours(self) -> bool: + """检查是否在勿扰时段""" + try: + now = datetime.now() + current_minutes = now.hour * 60 + now.minute + + start_parts = self.quiet_hours_start.split(":") + start_minutes = int(start_parts[0]) * 60 + int(start_parts[1]) + + end_parts = self.quiet_hours_end.split(":") + end_minutes = int(end_parts[0]) * 60 + int(end_parts[1]) + + if start_minutes <= end_minutes: + return start_minutes <= current_minutes < end_minutes + else: + return current_minutes >= start_minutes or current_minutes < end_minutes + except: + return False + + def _should_trigger_proactive( + self, + session: KokoroSession, + current_time: float, + ) -> Optional[str]: + """判断是否应触发主动思考""" + # 只检查 IDLE 状态的 Session + if session.status != SessionStatus.IDLE: + return None + + # 检查沉默时长 + silence_duration = current_time - session.last_activity_at + if silence_duration < self.silence_threshold: + return None + + # 检查距离上次主动思考的间隔 + if session.last_proactive_at: + time_since_last = current_time - session.last_proactive_at + if time_since_last < self.min_proactive_interval: + return None + + # 概率触发(避免每次检查都触发) + if random.random() > 0.3: # 30% 概率 + return None + + silence_hours = silence_duration / 3600 + return f"沉默了 {silence_hours:.1f} 小时" + + async def _handle_proactive( + self, + session: KokoroSession, + trigger_reason: str, + ) -> None: + """处理主动思考""" + self._stats["proactive_triggered"] += 1 + + logger.info(f"[ProactiveThinker] 主动思考触发: user={session.user_id}, reason={trigger_reason}") + + try: + # 获取聊天流 + chat_stream = await self._get_chat_stream(session.stream_id) + + # 加载动作 + action_executor = ActionExecutor(session.stream_id) + await action_executor.load_actions() + + # 计算沉默时长 + silence_seconds = time.time() - session.last_activity_at + if silence_seconds < 3600: + silence_duration = f"{silence_seconds / 60:.0f} 分钟" + else: + silence_duration = f"{silence_seconds / 3600:.1f} 小时" + + # 调用 Replyer + response = await generate_response( + session=session, + user_name=session.user_id, + situation_type="proactive", + chat_stream=chat_stream, + available_actions=action_executor.get_available_actions(), + extra_context={ + "trigger_reason": trigger_reason, + "silence_duration": silence_duration, + }, + ) + + # 检查是否决定不打扰 + is_do_nothing = ( + len(response.actions) == 0 or + (len(response.actions) == 1 and response.actions[0].type == "do_nothing") + ) + + if is_do_nothing: + logger.info(f"[ProactiveThinker] 决定不打扰: user={session.user_id}") + session.last_proactive_at = time.time() + await self.session_manager.save_session(session.user_id) + return + + # 执行动作 + exec_result = await action_executor.execute(response, chat_stream) + + # 记录到 mental_log + session.add_bot_planning( + thought=response.thought, + actions=[a.to_dict() for a in response.actions], + expected_reaction=response.expected_reaction, + max_wait_seconds=response.max_wait_seconds, + ) + + # 更新状态 + session.last_proactive_at = time.time() + if response.max_wait_seconds > 0: + session.start_waiting( + expected_reaction=response.expected_reaction, + max_wait_seconds=response.max_wait_seconds, + ) + + # 保存 + await self.session_manager.save_session(session.user_id) + + logger.info( + f"[ProactiveThinker] 主动发起完成: user={session.user_id}, " + f"actions={[a.type for a in response.actions]}" + ) + + except Exception as e: + logger.error(f"[ProactiveThinker] 主动思考失败: {e}") + + async def _get_chat_stream(self, stream_id: str): + """获取聊天流""" + try: + from src.chat.message_receive.chat_stream import get_chat_manager + + chat_manager = get_chat_manager() + if chat_manager: + return await chat_manager.get_stream(stream_id) + except Exception as e: + logger.warning(f"[ProactiveThinker] 获取 chat_stream 失败: {e}") + return None + + def get_stats(self) -> dict: + """获取统计信息""" + return { + **self._stats, + "is_running": self._running, + } + + +# 全局单例 +_proactive_thinker: Optional[ProactiveThinker] = None + + +def get_proactive_thinker() -> ProactiveThinker: + """获取全局主动思考器""" + global _proactive_thinker + if _proactive_thinker is None: + _proactive_thinker = ProactiveThinker() + return _proactive_thinker + + +async def start_proactive_thinker() -> ProactiveThinker: + """启动主动思考器""" + thinker = get_proactive_thinker() + await thinker.start() + return thinker + + +async def stop_proactive_thinker() -> None: + """停止主动思考器""" + global _proactive_thinker + if _proactive_thinker: + await _proactive_thinker.stop() diff --git a/src/plugins/built_in/kokoro_flow_chatter_v2/prompt/__init__.py b/src/plugins/built_in/kokoro_flow_chatter_v2/prompt/__init__.py new file mode 100644 index 000000000..501e3b92f --- /dev/null +++ b/src/plugins/built_in/kokoro_flow_chatter_v2/prompt/__init__.py @@ -0,0 +1,16 @@ +""" +Kokoro Flow Chatter V2 - 提示词模块 + +使用项目统一的 Prompt 管理系统管理所有提示词模板 +""" + +# 导入 prompts 模块以注册提示词 +from . import prompts # noqa: F401 +from .builder import PromptBuilder, get_prompt_builder +from .prompts import PROMPT_NAMES + +__all__ = [ + "PromptBuilder", + "get_prompt_builder", + "PROMPT_NAMES", +] diff --git a/src/plugins/built_in/kokoro_flow_chatter_v2/prompt/builder.py b/src/plugins/built_in/kokoro_flow_chatter_v2/prompt/builder.py new file mode 100644 index 000000000..83bf5e91d --- /dev/null +++ b/src/plugins/built_in/kokoro_flow_chatter_v2/prompt/builder.py @@ -0,0 +1,388 @@ +""" +Kokoro Flow Chatter V2 - 提示词构建器 + +使用项目统一的 Prompt 管理系统构建提示词 +""" + +import time +from datetime import datetime +from typing import TYPE_CHECKING, Optional + +from src.chat.utils.prompt import global_prompt_manager +from src.common.logger import get_logger +from src.config.config import global_config + +from ..models import EventType, MentalLogEntry, SessionStatus +from ..session import KokoroSession + +# 导入模板注册(确保模板被注册到 global_prompt_manager) +from . import prompts as _ # noqa: F401 +from .prompts import PROMPT_NAMES + +if TYPE_CHECKING: + from src.chat.message_receive.chat_stream import ChatStream + +logger = get_logger("kfc_v2_prompt_builder") + + +class PromptBuilder: + """ + 提示词构建器 + + 使用统一的 Prompt 管理系统构建提示词: + 1. 构建活动流(从 mental_log 生成线性叙事) + 2. 构建当前情况描述 + 3. 使用 global_prompt_manager 格式化最终提示词 + """ + + def __init__(self): + self._context_builder = None + + async def build_prompt( + self, + session: KokoroSession, + user_name: str, + situation_type: str = "new_message", + chat_stream: Optional["ChatStream"] = None, + available_actions: Optional[dict] = None, + extra_context: Optional[dict] = None, + ) -> str: + """ + 构建完整的提示词 + + Args: + session: 会话对象 + user_name: 用户名称 + situation_type: 情况类型 (new_message/reply_in_time/reply_late/timeout/proactive) + chat_stream: 聊天流对象 + available_actions: 可用动作字典 + extra_context: 额外上下文(如 trigger_reason) + + Returns: + 完整的提示词 + """ + extra_context = extra_context or {} + + # 1. 构建人设块 + persona_block = self._build_persona_block() + + # 2. 构建关系块 + relation_block = await self._build_relation_block(user_name, chat_stream) + + # 3. 构建活动流 + activity_stream = await self._build_activity_stream(session, user_name) + + # 4. 构建当前情况 + current_situation = await self._build_current_situation( + session, user_name, situation_type, extra_context + ) + + # 5. 构建可用动作 + actions_block = self._build_actions_block(available_actions) + + # 6. 获取输出格式 + output_format = await self._get_output_format() + + # 7. 使用统一的 prompt 管理系统格式化 + prompt = await global_prompt_manager.format_prompt( + PROMPT_NAMES["main"], + user_name=user_name, + persona_block=persona_block, + relation_block=relation_block, + activity_stream=activity_stream or "(这是你们第一次聊天)", + current_situation=current_situation, + available_actions=actions_block, + output_format=output_format, + ) + + return prompt + + def _build_persona_block(self) -> str: + """构建人设块""" + if global_config is None: + return "你是一个温暖、真诚的人。" + + personality = global_config.personality + parts = [] + + if personality.personality_core: + parts.append(personality.personality_core) + + if personality.personality_side: + parts.append(personality.personality_side) + + if personality.identity: + parts.append(personality.identity) + + if personality.reply_style: + parts.append(f"\n### 说话风格\n{personality.reply_style}") + + return "\n\n".join(parts) if parts else "你是一个温暖、真诚的人。" + + async def _build_relation_block( + self, + user_name: str, + chat_stream: Optional["ChatStream"], + ) -> str: + """构建关系块""" + if not chat_stream: + return f"你与 {user_name} 还不太熟悉,这是早期的交流阶段。" + + try: + # 延迟导入上下文构建器 + if self._context_builder is None: + from ..context_builder import KFCContextBuilder + self._context_builder = KFCContextBuilder + + builder = self._context_builder(chat_stream) + context_data = await builder.build_all_context( + sender_name=user_name, + target_message="", + context=None, + ) + + relation_info = context_data.get("relation_info", "") + if relation_info: + return relation_info + + except Exception as e: + logger.warning(f"构建关系块失败: {e}") + + return f"你与 {user_name} 还不太熟悉,这是早期的交流阶段。" + + async def _build_activity_stream( + self, + session: KokoroSession, + user_name: str, + ) -> str: + """ + 构建活动流 + + 将 mental_log 中的事件按时间顺序转换为线性叙事 + 使用统一的 prompt 模板 + """ + entries = session.get_recent_entries(limit=30) + if not entries: + return "" + + parts = [] + + for entry in entries: + part = await self._format_entry(entry, user_name) + if part: + parts.append(part) + + return "\n\n".join(parts) + + async def _format_entry(self, entry: MentalLogEntry, user_name: str) -> str: + """格式化单个活动日志条目""" + + if entry.event_type == EventType.USER_MESSAGE: + # 用户消息 + result = await global_prompt_manager.format_prompt( + PROMPT_NAMES["entry_user_message"], + time=entry.get_time_str(), + user_name=entry.user_name or user_name, + content=entry.content, + ) + + # 如果有回复状态元数据,添加说明 + reply_status = entry.metadata.get("reply_status") + if reply_status == "in_time": + elapsed = entry.metadata.get("elapsed_seconds", 0) / 60 + max_wait = entry.metadata.get("max_wait_seconds", 0) / 60 + result += await global_prompt_manager.format_prompt( + PROMPT_NAMES["entry_reply_in_time"], + elapsed_minutes=elapsed, + max_wait_minutes=max_wait, + ) + elif reply_status == "late": + elapsed = entry.metadata.get("elapsed_seconds", 0) / 60 + max_wait = entry.metadata.get("max_wait_seconds", 0) / 60 + result += await global_prompt_manager.format_prompt( + PROMPT_NAMES["entry_reply_late"], + elapsed_minutes=elapsed, + max_wait_minutes=max_wait, + ) + + return result + + elif entry.event_type == EventType.BOT_PLANNING: + # Bot 规划 + actions_desc = self._format_actions(entry.actions) + + if entry.max_wait_seconds > 0: + return await global_prompt_manager.format_prompt( + PROMPT_NAMES["entry_bot_planning"], + thought=entry.thought or "(没有特别的想法)", + actions_description=actions_desc, + expected_reaction=entry.expected_reaction or "随便怎么回应都行", + max_wait_minutes=entry.max_wait_seconds / 60, + ) + else: + return await global_prompt_manager.format_prompt( + PROMPT_NAMES["entry_bot_planning_no_wait"], + thought=entry.thought or "(没有特别的想法)", + actions_description=actions_desc, + ) + + elif entry.event_type == EventType.WAITING_UPDATE: + # 等待中心理变化 + return await global_prompt_manager.format_prompt( + PROMPT_NAMES["entry_waiting_update"], + elapsed_minutes=entry.elapsed_seconds / 60, + waiting_thought=entry.waiting_thought or "还在等...", + ) + + elif entry.event_type == EventType.PROACTIVE_TRIGGER: + # 主动思考触发 + silence = entry.metadata.get("silence_duration", "一段时间") + return await global_prompt_manager.format_prompt( + PROMPT_NAMES["entry_proactive_trigger"], + silence_duration=silence, + ) + + return "" + + def _format_actions(self, actions: list[dict]) -> str: + """格式化动作列表为可读描述""" + if not actions: + return "(无动作)" + + descriptions = [] + for action in actions: + action_type = action.get("type", "unknown") + + if action_type == "reply": + content = action.get("content", "") + if len(content) > 50: + content = content[:50] + "..." + descriptions.append(f"发送消息:「{content}」") + elif action_type == "poke_user": + descriptions.append("戳了戳对方") + elif action_type == "do_nothing": + descriptions.append("什么都不做") + elif action_type == "send_emoji": + emoji = action.get("emoji", "") + descriptions.append(f"发送表情:{emoji}") + else: + descriptions.append(f"执行动作:{action_type}") + + return "、".join(descriptions) + + async def _build_current_situation( + self, + session: KokoroSession, + user_name: str, + situation_type: str, + extra_context: dict, + ) -> str: + """构建当前情况描述""" + current_time = datetime.now().strftime("%Y年%m月%d日 %H:%M") + + if situation_type == "new_message": + return await global_prompt_manager.format_prompt( + PROMPT_NAMES["situation_new_message"], + current_time=current_time, + user_name=user_name, + ) + + elif situation_type == "reply_in_time": + elapsed = session.waiting_config.get_elapsed_seconds() + max_wait = session.waiting_config.max_wait_seconds + return await global_prompt_manager.format_prompt( + PROMPT_NAMES["situation_reply_in_time"], + current_time=current_time, + user_name=user_name, + elapsed_minutes=elapsed / 60, + max_wait_minutes=max_wait / 60, + ) + + elif situation_type == "reply_late": + elapsed = session.waiting_config.get_elapsed_seconds() + max_wait = session.waiting_config.max_wait_seconds + return await global_prompt_manager.format_prompt( + PROMPT_NAMES["situation_reply_late"], + current_time=current_time, + user_name=user_name, + elapsed_minutes=elapsed / 60, + max_wait_minutes=max_wait / 60, + ) + + elif situation_type == "timeout": + elapsed = session.waiting_config.get_elapsed_seconds() + max_wait = session.waiting_config.max_wait_seconds + expected = session.waiting_config.expected_reaction + return await global_prompt_manager.format_prompt( + PROMPT_NAMES["situation_timeout"], + current_time=current_time, + user_name=user_name, + elapsed_minutes=elapsed / 60, + max_wait_minutes=max_wait / 60, + expected_reaction=expected or "对方能回复点什么", + ) + + elif situation_type == "proactive": + silence = extra_context.get("silence_duration", "一段时间") + trigger_reason = extra_context.get("trigger_reason", "") + return await global_prompt_manager.format_prompt( + PROMPT_NAMES["situation_proactive"], + current_time=current_time, + user_name=user_name, + silence_duration=silence, + trigger_reason=trigger_reason, + ) + + # 默认使用 new_message + return await global_prompt_manager.format_prompt( + PROMPT_NAMES["situation_new_message"], + current_time=current_time, + user_name=user_name, + ) + + def _build_actions_block(self, available_actions: Optional[dict]) -> str: + """构建可用动作块""" + if not available_actions: + return self._get_default_actions_block() + + lines = [] + for name, info in available_actions.items(): + desc = getattr(info, "description", "") or f"执行 {name}" + lines.append(f"- `{name}`: {desc}") + + return "\n".join(lines) if lines else self._get_default_actions_block() + + def _get_default_actions_block(self) -> str: + """获取默认的动作列表""" + return """- `reply`: 发送文字消息(参数:content) +- `poke_user`: 戳一戳对方 +- `do_nothing`: 什么都不做""" + + async def _get_output_format(self) -> str: + """获取输出格式模板""" + try: + prompt = await global_prompt_manager.get_prompt_async( + PROMPT_NAMES["output_format"] + ) + return prompt.template + except KeyError: + # 如果模板未注册,返回默认格式 + return """请用 JSON 格式回复: +{ + "thought": "你的想法", + "actions": [{"type": "reply", "content": "你的回复"}], + "expected_reaction": "期待的反应", + "max_wait_seconds": 300 +}""" + + +# 全局单例 +_prompt_builder: Optional[PromptBuilder] = None + + +def get_prompt_builder() -> PromptBuilder: + """获取全局提示词构建器""" + global _prompt_builder + if _prompt_builder is None: + _prompt_builder = PromptBuilder() + return _prompt_builder diff --git a/src/plugins/built_in/kokoro_flow_chatter_v2/prompt/prompts.py b/src/plugins/built_in/kokoro_flow_chatter_v2/prompt/prompts.py new file mode 100644 index 000000000..77f1da867 --- /dev/null +++ b/src/plugins/built_in/kokoro_flow_chatter_v2/prompt/prompts.py @@ -0,0 +1,217 @@ +""" +Kokoro Flow Chatter V2 - 提示词模板注册 + +使用项目统一的 Prompt 管理系统注册所有 KFC V2 使用的提示词模板 +""" + +from src.chat.utils.prompt import Prompt + +# ================================================================================================= +# KFC V2 主提示词模板 +# ================================================================================================= + +KFC_V2_MAIN_PROMPT = Prompt( + name="kfc_v2_main", + template="""# 你与 {user_name} 的私聊 + +## 1. 你是谁 +{persona_block} + +## 2. 你与 {user_name} 的关系 +{relation_block} + +## 3. 你们之间发生的事(活动流) +以下是你和 {user_name} 最近的互动历史,按时间顺序记录了你们的对话和你的心理活动: + +{activity_stream} + +## 4. 当前情况 +{current_situation} + +## 5. 你可以做的事情 +{available_actions} + +## 6. 你的回复格式 +{output_format} +""", +) + +# ================================================================================================= +# 输出格式模板 +# ================================================================================================= + +KFC_V2_OUTPUT_FORMAT = Prompt( + name="kfc_v2_output_format", + template="""请用以下 JSON 格式回复: +```json +{{ + "thought": "你脑子里在想什么,越自然越好", + "actions": [ + {{"type": "reply", "content": "你要说的话"}}, + {{"type": "其他动作", "参数": "值"}} + ], + "expected_reaction": "你期待对方的反应是什么", + "max_wait_seconds": 300 +}} +``` + +说明: +- `thought`:你的内心独白,记录你此刻的想法和感受 +- `actions`:你要执行的动作列表,可以组合多个 +- `expected_reaction`:你期待对方如何回应(用于判断是否需要等待) +- `max_wait_seconds`:设定等待时间(秒),0 表示不等待,超时后你会考虑是否要主动说点什么 +- 即使什么都不想做,也放一个 `{{"type": "do_nothing"}}`""", +) + +# ================================================================================================= +# 情景模板 - 根据不同情境使用不同的当前情况描述 +# ================================================================================================= + +KFC_V2_SITUATION_NEW_MESSAGE = Prompt( + name="kfc_v2_situation_new_message", + template="""现在是 {current_time}。 + +{user_name} 刚刚给你发了消息。这是一次新的对话发起(不是对你之前消息的回复)。 + +请决定你要怎么回应。你可以: +- 发送文字消息回复 +- 发表情包 +- 戳一戳对方 +- 什么都不做(如果觉得没必要回复) +- 或者组合多个动作""", +) + +KFC_V2_SITUATION_REPLY_IN_TIME = Prompt( + name="kfc_v2_situation_reply_in_time", + template="""现在是 {current_time}。 + +你之前发了消息后一直在等 {user_name} 的回复。 +等了大约 {elapsed_minutes:.1f} 分钟(你原本打算最多等 {max_wait_minutes:.1f} 分钟)。 +现在 {user_name} 回复了! + +请决定你接下来要怎么回应。""", +) + +KFC_V2_SITUATION_REPLY_LATE = Prompt( + name="kfc_v2_situation_reply_late", + template="""现在是 {current_time}。 + +你之前发了消息后在等 {user_name} 的回复。 +你原本打算最多等 {max_wait_minutes:.1f} 分钟,但实际等了 {elapsed_minutes:.1f} 分钟才收到回复。 +虽然有点迟,但 {user_name} 终于回复了。 + +请决定你接下来要怎么回应。(可以选择轻轻抱怨一下迟到,也可以装作没在意)""", +) + +KFC_V2_SITUATION_TIMEOUT = Prompt( + name="kfc_v2_situation_timeout", + template="""现在是 {current_time}。 + +你之前发了消息后一直在等 {user_name} 的回复。 +你原本打算最多等 {max_wait_minutes:.1f} 分钟,现在已经等了 {elapsed_minutes:.1f} 分钟了,对方还是没回。 +你期待的反应是:"{expected_reaction}" + +你需要决定: +1. 继续等待(设置新的 max_wait_seconds) +2. 主动说点什么打破沉默 +3. 做点别的事情(戳一戳、发表情等) +4. 算了不等了(max_wait_seconds = 0)""", +) + +KFC_V2_SITUATION_PROACTIVE = Prompt( + name="kfc_v2_situation_proactive", + template="""现在是 {current_time}。 + +你和 {user_name} 已经有一段时间没聊天了(沉默了 {silence_duration})。 +{trigger_reason} + +你在想要不要主动找 {user_name} 聊点什么。 + +请决定: +1. 主动发起对话(想个话题开场) +2. 发个表情或戳一戳试探一下 +3. 算了,现在不是好时机(do_nothing) + +如果决定发起对话,想想用什么自然的方式开场,不要太突兀。""", +) + +# ================================================================================================= +# 活动流条目模板 - 用于构建 activity_stream +# ================================================================================================= + +# 用户消息条目 +KFC_V2_ENTRY_USER_MESSAGE = Prompt( + name="kfc_v2_entry_user_message", + template="""【{time}】{user_name} 说: +"{content}" +""", +) + +# Bot 规划条目(有等待) +KFC_V2_ENTRY_BOT_PLANNING = Prompt( + name="kfc_v2_entry_bot_planning", + template="""【你的想法】 +内心:{thought} +行动:{actions_description} +期待:{expected_reaction} +决定等待:最多 {max_wait_minutes:.1f} 分钟 +""", +) + +# Bot 规划条目(无等待) +KFC_V2_ENTRY_BOT_PLANNING_NO_WAIT = Prompt( + name="kfc_v2_entry_bot_planning_no_wait", + template="""【你的想法】 +内心:{thought} +行动:{actions_description} +(不打算等对方回复) +""", +) + +# 等待期间心理变化 +KFC_V2_ENTRY_WAITING_UPDATE = Prompt( + name="kfc_v2_entry_waiting_update", + template="""【等待中... {elapsed_minutes:.1f} 分钟过去了】 +你想:{waiting_thought} +""", +) + +# 收到及时回复时的标注 +KFC_V2_ENTRY_REPLY_IN_TIME = Prompt( + name="kfc_v2_entry_reply_in_time", + template="""→ (对方在你预期时间内回复了,等了 {elapsed_minutes:.1f} 分钟) +""", +) + +# 收到迟到回复时的标注 +KFC_V2_ENTRY_REPLY_LATE = Prompt( + name="kfc_v2_entry_reply_late", + template="""→ (对方回复迟了,你原本只打算等 {max_wait_minutes:.1f} 分钟,实际等了 {elapsed_minutes:.1f} 分钟) +""", +) + +# 主动思考触发 +KFC_V2_ENTRY_PROACTIVE_TRIGGER = Prompt( + name="kfc_v2_entry_proactive_trigger", + template="""【沉默了 {silence_duration}】 +你开始考虑要不要主动找对方聊点什么... +""", +) + +# 导出所有模板名称,方便外部引用 +PROMPT_NAMES = { + "main": "kfc_v2_main", + "output_format": "kfc_v2_output_format", + "situation_new_message": "kfc_v2_situation_new_message", + "situation_reply_in_time": "kfc_v2_situation_reply_in_time", + "situation_reply_late": "kfc_v2_situation_reply_late", + "situation_timeout": "kfc_v2_situation_timeout", + "situation_proactive": "kfc_v2_situation_proactive", + "entry_user_message": "kfc_v2_entry_user_message", + "entry_bot_planning": "kfc_v2_entry_bot_planning", + "entry_bot_planning_no_wait": "kfc_v2_entry_bot_planning_no_wait", + "entry_waiting_update": "kfc_v2_entry_waiting_update", + "entry_reply_in_time": "kfc_v2_entry_reply_in_time", + "entry_reply_late": "kfc_v2_entry_reply_late", + "entry_proactive_trigger": "kfc_v2_entry_proactive_trigger", +} diff --git a/src/plugins/built_in/kokoro_flow_chatter_v2/replyer.py b/src/plugins/built_in/kokoro_flow_chatter_v2/replyer.py new file mode 100644 index 000000000..a8c417cf2 --- /dev/null +++ b/src/plugins/built_in/kokoro_flow_chatter_v2/replyer.py @@ -0,0 +1,107 @@ +""" +Kokoro Flow Chatter V2 - Replyer + +简化的回复生成模块,使用插件系统的 llm_api +""" + +from typing import TYPE_CHECKING, Optional + +from src.common.logger import get_logger +from src.plugin_system.apis import llm_api +from src.utils.json_parser import extract_and_parse_json + +from .models import LLMResponse +from .prompt.builder import get_prompt_builder +from .session import KokoroSession + +if TYPE_CHECKING: + from src.chat.message_receive.chat_stream import ChatStream + +logger = get_logger("kfc_v2_replyer") + + +async def generate_response( + session: KokoroSession, + user_name: str, + situation_type: str = "new_message", + chat_stream: Optional["ChatStream"] = None, + available_actions: Optional[dict] = None, + extra_context: Optional[dict] = None, +) -> LLMResponse: + """ + 生成回复 + + Args: + session: 会话对象 + user_name: 用户名称 + situation_type: 情况类型 + chat_stream: 聊天流对象 + available_actions: 可用动作字典 + extra_context: 额外上下文 + + Returns: + LLMResponse 对象 + """ + try: + # 1. 构建提示词 + prompt_builder = get_prompt_builder() + prompt = await prompt_builder.build_prompt( + session=session, + user_name=user_name, + situation_type=situation_type, + chat_stream=chat_stream, + available_actions=available_actions, + extra_context=extra_context, + ) + + logger.debug(f"[KFC Replyer] 构建的提示词:\n{prompt}") + + # 2. 获取模型配置并调用 LLM + models = llm_api.get_available_models() + replyer_config = models.get("replyer") + + if not replyer_config: + logger.error("[KFC Replyer] 未找到 replyer 模型配置") + return LLMResponse.create_error_response("未找到 replyer 模型配置") + + success, raw_response, reasoning, model_name = await llm_api.generate_with_model( + prompt=prompt, + model_config=replyer_config, + request_type="kokoro_flow_chatter_v2", + ) + + if not success: + logger.error(f"[KFC Replyer] LLM 调用失败: {raw_response}") + return LLMResponse.create_error_response(raw_response) + + logger.debug(f"[KFC Replyer] LLM 响应 (model={model_name}):\n{raw_response}") + + # 3. 解析响应 + return _parse_response(raw_response) + + except Exception as e: + logger.error(f"[KFC Replyer] 生成失败: {e}") + import traceback + traceback.print_exc() + return LLMResponse.create_error_response(str(e)) + + +def _parse_response(raw_response: str) -> LLMResponse: + """解析 LLM 响应""" + data = extract_and_parse_json(raw_response, strict=False) + + if not data or not isinstance(data, dict): + logger.warning(f"[KFC Replyer] 无法解析 JSON: {raw_response[:200]}...") + return LLMResponse.create_error_response("无法解析响应格式") + + response = LLMResponse.from_dict(data) + + if response.thought: + logger.info( + f"[KFC Replyer] 解析成功: thought={response.thought[:50]}..., " + f"actions={[a.type for a in response.actions]}" + ) + else: + logger.warning("[KFC Replyer] 响应缺少 thought") + + return response diff --git a/src/plugins/built_in/kokoro_flow_chatter_v2/session.py b/src/plugins/built_in/kokoro_flow_chatter_v2/session.py new file mode 100644 index 000000000..df321070d --- /dev/null +++ b/src/plugins/built_in/kokoro_flow_chatter_v2/session.py @@ -0,0 +1,386 @@ +""" +Kokoro Flow Chatter V2 - 会话管理 + +极简的会话状态管理: +- Session 只有 IDLE 和 WAITING 两种状态 +- 包含 mental_log(心理活动历史) +- 包含 waiting_config(等待配置) +""" + +import asyncio +import json +import os +import time +from pathlib import Path +from typing import Optional + +from src.common.logger import get_logger + +from .models import ( + EventType, + MentalLogEntry, + SessionStatus, + WaitingConfig, +) + +logger = get_logger("kfc_v2_session") + + +class KokoroSession: + """ + Kokoro Flow Chatter V2 会话 + + 为每个私聊用户维护一个独立的会话,包含: + - 基本信息(user_id, stream_id) + - 状态(只有 IDLE 和 WAITING) + - 心理活动历史(mental_log) + - 等待配置(waiting_config) + """ + + # 心理活动日志最大保留条数 + MAX_MENTAL_LOG_SIZE = 50 + + def __init__( + self, + user_id: str, + stream_id: str, + ): + self.user_id = user_id + self.stream_id = stream_id + + # 状态(只有 IDLE 和 WAITING) + self._status: SessionStatus = SessionStatus.IDLE + + # 心理活动历史 + self.mental_log: list[MentalLogEntry] = [] + + # 等待配置 + self.waiting_config: WaitingConfig = WaitingConfig() + + # 时间戳 + self.created_at: float = time.time() + self.last_activity_at: float = time.time() + + # 统计 + self.total_interactions: int = 0 + + # 上次主动思考时间 + self.last_proactive_at: Optional[float] = None + + @property + def status(self) -> SessionStatus: + return self._status + + @status.setter + def status(self, value: SessionStatus) -> None: + old_status = self._status + self._status = value + if old_status != value: + logger.debug(f"Session {self.user_id} 状态变更: {old_status} → {value}") + + def add_entry(self, entry: MentalLogEntry) -> None: + """添加心理活动日志条目""" + self.mental_log.append(entry) + self.last_activity_at = time.time() + + # 保持日志在合理大小 + if len(self.mental_log) > self.MAX_MENTAL_LOG_SIZE: + self.mental_log = self.mental_log[-self.MAX_MENTAL_LOG_SIZE:] + + def add_user_message( + self, + content: str, + user_name: str, + user_id: str, + timestamp: Optional[float] = None, + ) -> MentalLogEntry: + """添加用户消息事件""" + entry = MentalLogEntry( + event_type=EventType.USER_MESSAGE, + timestamp=timestamp or time.time(), + content=content, + user_name=user_name, + user_id=user_id, + ) + + # 如果之前在等待,记录收到回复的情况 + if self.status == SessionStatus.WAITING and self.waiting_config.is_active(): + elapsed = self.waiting_config.get_elapsed_seconds() + max_wait = self.waiting_config.max_wait_seconds + + if elapsed <= max_wait: + entry.metadata["reply_status"] = "in_time" + entry.metadata["elapsed_seconds"] = elapsed + entry.metadata["max_wait_seconds"] = max_wait + else: + entry.metadata["reply_status"] = "late" + entry.metadata["elapsed_seconds"] = elapsed + entry.metadata["max_wait_seconds"] = max_wait + + self.add_entry(entry) + return entry + + def add_bot_planning( + self, + thought: str, + actions: list[dict], + expected_reaction: str = "", + max_wait_seconds: int = 0, + timestamp: Optional[float] = None, + ) -> MentalLogEntry: + """添加 Bot 规划事件""" + entry = MentalLogEntry( + event_type=EventType.BOT_PLANNING, + timestamp=timestamp or time.time(), + thought=thought, + actions=actions, + expected_reaction=expected_reaction, + max_wait_seconds=max_wait_seconds, + ) + self.add_entry(entry) + self.total_interactions += 1 + return entry + + def add_waiting_update( + self, + waiting_thought: str, + mood: str = "", + timestamp: Optional[float] = None, + ) -> MentalLogEntry: + """添加等待期间的心理变化""" + entry = MentalLogEntry( + event_type=EventType.WAITING_UPDATE, + timestamp=timestamp or time.time(), + waiting_thought=waiting_thought, + mood=mood, + elapsed_seconds=self.waiting_config.get_elapsed_seconds(), + ) + self.add_entry(entry) + return entry + + def start_waiting( + self, + expected_reaction: str, + max_wait_seconds: int, + ) -> None: + """开始等待""" + if max_wait_seconds <= 0: + # 不等待,直接进入 IDLE + self.status = SessionStatus.IDLE + self.waiting_config.reset() + return + + self.status = SessionStatus.WAITING + self.waiting_config = WaitingConfig( + expected_reaction=expected_reaction, + max_wait_seconds=max_wait_seconds, + started_at=time.time(), + last_thinking_at=0.0, + thinking_count=0, + ) + logger.debug( + f"Session {self.user_id} 开始等待: " + f"max_wait={max_wait_seconds}s, expected={expected_reaction[:30]}..." + ) + + def end_waiting(self) -> None: + """结束等待""" + self.status = SessionStatus.IDLE + self.waiting_config.reset() + + def get_recent_entries(self, limit: int = 20) -> list[MentalLogEntry]: + """获取最近的心理活动日志""" + return self.mental_log[-limit:] if self.mental_log else [] + + def get_last_bot_message(self) -> Optional[str]: + """获取最后一条 Bot 发送的消息""" + for entry in reversed(self.mental_log): + if entry.event_type == EventType.BOT_PLANNING: + for action in entry.actions: + if action.get("type") in ("reply", "respond"): + return action.get("content", "") + return None + + def to_dict(self) -> dict: + """转换为字典(用于持久化)""" + return { + "user_id": self.user_id, + "stream_id": self.stream_id, + "status": str(self.status), + "mental_log": [e.to_dict() for e in self.mental_log], + "waiting_config": self.waiting_config.to_dict(), + "created_at": self.created_at, + "last_activity_at": self.last_activity_at, + "total_interactions": self.total_interactions, + "last_proactive_at": self.last_proactive_at, + } + + @classmethod + def from_dict(cls, data: dict) -> "KokoroSession": + """从字典创建会话""" + session = cls( + user_id=data.get("user_id", ""), + stream_id=data.get("stream_id", ""), + ) + + # 状态 + status_str = data.get("status", "idle") + try: + session._status = SessionStatus(status_str) + except ValueError: + session._status = SessionStatus.IDLE + + # 心理活动历史 + mental_log_data = data.get("mental_log", []) + session.mental_log = [MentalLogEntry.from_dict(e) for e in mental_log_data] + + # 等待配置 + waiting_data = data.get("waiting_config", {}) + session.waiting_config = WaitingConfig.from_dict(waiting_data) + + # 时间戳 + session.created_at = data.get("created_at", time.time()) + session.last_activity_at = data.get("last_activity_at", time.time()) + session.total_interactions = data.get("total_interactions", 0) + session.last_proactive_at = data.get("last_proactive_at") + + return session + + +class SessionManager: + """ + 会话管理器 + + 负责会话的创建、获取、保存和清理 + """ + + _instance: Optional["SessionManager"] = None + + def __new__(cls, *args, **kwargs): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__( + self, + data_dir: str = "data/kokoro_flow_chatter_v2/sessions", + max_session_age_days: int = 30, + ): + if hasattr(self, "_initialized") and self._initialized: + return + + self._initialized = True + self.data_dir = Path(data_dir) + self.max_session_age_days = max_session_age_days + + # 内存缓存 + self._sessions: dict[str, KokoroSession] = {} + self._locks: dict[str, asyncio.Lock] = {} + + # 确保数据目录存在 + self.data_dir.mkdir(parents=True, exist_ok=True) + + logger.info(f"SessionManager 初始化完成: {self.data_dir}") + + def _get_lock(self, user_id: str) -> asyncio.Lock: + """获取用户级别的锁""" + if user_id not in self._locks: + self._locks[user_id] = asyncio.Lock() + return self._locks[user_id] + + def _get_file_path(self, user_id: str) -> Path: + """获取会话文件路径""" + safe_id = "".join(c if c.isalnum() or c in "-_" else "_" for c in user_id) + return self.data_dir / f"{safe_id}.json" + + async def get_session(self, user_id: str, stream_id: str) -> KokoroSession: + """获取或创建会话""" + async with self._get_lock(user_id): + # 检查内存缓存 + if user_id in self._sessions: + session = self._sessions[user_id] + session.stream_id = stream_id # 更新 stream_id + return session + + # 尝试从文件加载 + session = await self._load_from_file(user_id) + if session: + session.stream_id = stream_id + self._sessions[user_id] = session + return session + + # 创建新会话 + session = KokoroSession(user_id=user_id, stream_id=stream_id) + self._sessions[user_id] = session + logger.info(f"创建新会话: {user_id}") + return session + + async def _load_from_file(self, user_id: str) -> Optional[KokoroSession]: + """从文件加载会话""" + file_path = self._get_file_path(user_id) + if not file_path.exists(): + return None + + try: + with open(file_path, "r", encoding="utf-8") as f: + data = json.load(f) + session = KokoroSession.from_dict(data) + logger.debug(f"从文件加载会话: {user_id}") + return session + except Exception as e: + logger.error(f"加载会话失败 {user_id}: {e}") + return None + + async def save_session(self, user_id: str) -> bool: + """保存会话到文件""" + async with self._get_lock(user_id): + if user_id not in self._sessions: + return False + + session = self._sessions[user_id] + file_path = self._get_file_path(user_id) + + try: + data = session.to_dict() + temp_path = file_path.with_suffix(".json.tmp") + + with open(temp_path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + os.replace(temp_path, file_path) + return True + except Exception as e: + logger.error(f"保存会话失败 {user_id}: {e}") + return False + + async def save_all(self) -> int: + """保存所有会话""" + count = 0 + for user_id in list(self._sessions.keys()): + if await self.save_session(user_id): + count += 1 + return count + + async def get_waiting_sessions(self) -> list[KokoroSession]: + """获取所有处于等待状态的会话""" + return [s for s in self._sessions.values() if s.status == SessionStatus.WAITING] + + async def get_all_sessions(self) -> list[KokoroSession]: + """获取所有会话""" + return list(self._sessions.values()) + + def get_session_sync(self, user_id: str) -> Optional[KokoroSession]: + """同步获取会话(仅从内存)""" + return self._sessions.get(user_id) + + +# 全局单例 +_session_manager: Optional[SessionManager] = None + + +def get_session_manager() -> SessionManager: + """获取全局会话管理器""" + global _session_manager + if _session_manager is None: + _session_manager = SessionManager() + return _session_manager From c68bf4ad4fc95813cb4a5896662ce08283ec7c33 Mon Sep 17 00:00:00 2001 From: Windpicker-owo <3431391539@qq.com> Date: Sun, 30 Nov 2025 13:40:59 +0800 Subject: [PATCH 08/28] =?UTF-8?q?=E9=87=8D=E6=9E=84Kokoro=20Flow=20Chatter?= =?UTF-8?q?=EF=BC=9A=E7=A7=BB=E9=99=A4=E5=B7=B2=E5=BC=83=E7=94=A8=E7=9A=84?= =?UTF-8?q?=E5=93=8D=E5=BA=94=E5=90=8E=E5=A4=84=E7=90=86=E5=99=A8=E5=92=8C?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E7=AE=A1=E7=90=86=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除了`response_post_processor.py`和`session_manager.py`,因为它们已不再需要。 - 更新了`__init__.py`文件,移除了对`ActionExecutor`的引用。 - 删除了`action_executor.py`,并将动作执行直接集成到`chatter.py`和`proactive_thinker.py`中。 - 在`KokoroFlowChatterV2`中重构了动作执行逻辑,以直接使用`ChatterActionManager`。 - 增强了主动思考逻辑,以简化操作执行,而无需依赖已移除的`ActionExecutor`。 --- src/chat/planner_actions/action_manager.py | 169 +--- src/chat/planner_actions/action_modifier.py | 37 +- src/plugin_system/base/base_action.py | 1 + src/plugin_system/base/component_types.py | 3 + .../affinity_flow_chatter/planner/planner.py | 2 +- src/plugins/built_in/core_actions/reply.py | 220 ++++- .../built_in/kokoro_flow_chatter/__init__.py | 26 - .../kokoro_flow_chatter/action_executor.py | 711 -------------- .../built_in/kokoro_flow_chatter/chatter.py | 877 ------------------ .../built_in/kokoro_flow_chatter/config.py | 251 ----- .../kokoro_flow_chatter/context_builder.py | 528 ----------- .../kfc_scheduler_adapter.py | 707 -------------- .../built_in/kokoro_flow_chatter/models.py | 459 --------- .../built_in/kokoro_flow_chatter/plugin.py | 218 ----- .../kokoro_flow_chatter/proactive_thinking.py | 528 ----------- .../kokoro_flow_chatter/prompt_generator.py | 807 ---------------- .../kokoro_flow_chatter/prompt_modules.py | 369 -------- .../response_post_processor.py | 169 ---- .../kokoro_flow_chatter/session_manager.py | 561 ----------- .../kokoro_flow_chatter_v2/__init__.py | 2 - .../kokoro_flow_chatter_v2/action_executor.py | 228 ----- .../kokoro_flow_chatter_v2/chatter.py | 44 +- .../proactive_thinker.py | 36 +- 23 files changed, 319 insertions(+), 6634 deletions(-) delete mode 100644 src/plugins/built_in/kokoro_flow_chatter/__init__.py delete mode 100644 src/plugins/built_in/kokoro_flow_chatter/action_executor.py delete mode 100644 src/plugins/built_in/kokoro_flow_chatter/chatter.py delete mode 100644 src/plugins/built_in/kokoro_flow_chatter/config.py delete mode 100644 src/plugins/built_in/kokoro_flow_chatter/context_builder.py delete mode 100644 src/plugins/built_in/kokoro_flow_chatter/kfc_scheduler_adapter.py delete mode 100644 src/plugins/built_in/kokoro_flow_chatter/models.py delete mode 100644 src/plugins/built_in/kokoro_flow_chatter/plugin.py delete mode 100644 src/plugins/built_in/kokoro_flow_chatter/proactive_thinking.py delete mode 100644 src/plugins/built_in/kokoro_flow_chatter/prompt_generator.py delete mode 100644 src/plugins/built_in/kokoro_flow_chatter/prompt_modules.py delete mode 100644 src/plugins/built_in/kokoro_flow_chatter/response_post_processor.py delete mode 100644 src/plugins/built_in/kokoro_flow_chatter/session_manager.py delete mode 100644 src/plugins/built_in/kokoro_flow_chatter_v2/action_executor.py diff --git a/src/chat/planner_actions/action_manager.py b/src/chat/planner_actions/action_manager.py index ef8b24657..4c4f30e30 100644 --- a/src/chat/planner_actions/action_manager.py +++ b/src/chat/planner_actions/action_manager.py @@ -4,12 +4,10 @@ import traceback from typing import Any, TYPE_CHECKING from src.chat.message_receive.chat_stream import get_chat_manager -from src.chat.utils.timer_calculator import Timer from src.common.data_models.database_data_model import DatabaseMessages from src.common.logger import get_logger -from src.config.config import global_config from src.person_info.person_info import get_person_info_manager -from src.plugin_system.apis import database_api, generator_api, message_api, send_api +from src.plugin_system.apis import database_api from src.plugin_system.base.base_action import BaseAction from src.plugin_system.base.component_types import ActionInfo, ComponentType from src.plugin_system.core.component_registry import component_registry @@ -160,6 +158,8 @@ class ChatterActionManager: ) -> Any: """ 执行单个动作的通用函数 + + 所有动作(包括 reply/respond)都通过 BaseAction.execute() 执行 Args: action_name: 动作名称 @@ -169,14 +169,13 @@ class ChatterActionManager: action_data: 动作数据 thinking_id: 思考ID log_prefix: 日志前缀 + clear_unread_messages: 是否清除未读消息 Returns: 执行结果 """ - chat_stream = None try: - logger.debug(f"🎯 [ActionManager] execute_action接收到 target_message: {target_message}") # 通过chat_id获取chat_stream chat_manager = get_chat_manager() chat_stream = await chat_manager.get_stream(chat_id) @@ -193,149 +192,33 @@ class ChatterActionManager: # 设置正在回复的状态 chat_stream.context.is_replying = True + # no_action 特殊处理 if action_name == "no_action": return {"action_type": "no_action", "success": True, "reply_text": "", "command": ""} - if action_name == "no_reply": - # 直接处理no_reply逻辑,不再通过动作系统 - reason = reasoning or "选择不回复" - logger.info(f"{log_prefix} 选择不回复,原因: {reason}") + # 统一通过 _handle_action 执行所有动作 + success, reply_text, command = await self._handle_action( + chat_stream, + action_name, + reasoning, + action_data or {}, + {}, # cycle_timers + thinking_id, + target_message, + ) - # 存储no_reply信息到数据库(支持批量存储) - if self._batch_storage_enabled: - self.add_action_to_batch( - action_name="no_reply", - action_data={"reason": reason}, - thinking_id=thinking_id or "", - action_done=True, - action_build_into_prompt=False, - action_prompt_display=reason, - ) - else: - asyncio.create_task(database_api.store_action_info( - chat_stream=chat_stream, - action_build_into_prompt=False, - action_prompt_display=reason, - action_done=True, - thinking_id=thinking_id or "", - action_data={"reason": reason}, - action_name="no_reply", - )) + # 记录执行的动作到目标消息 + if success: + asyncio.create_task(self._record_action_to_message(chat_stream, action_name, target_message, action_data)) + # 重置打断计数 + await self._reset_interruption_count_after_action(chat_stream.stream_id) - return {"action_type": "no_reply", "success": True, "reply_text": "", "command": ""} - - elif action_name != "reply" and action_name != "respond" and action_name != "no_action": - # 执行普通动作 - success, reply_text, command = await self._handle_action( - chat_stream, - action_name, - reasoning, - action_data or {}, - {}, # cycle_timers - thinking_id, - target_message, - ) - - # 记录执行的动作到目标消息 - if success: - asyncio.create_task(self._record_action_to_message(chat_stream, action_name, target_message, action_data)) - # 重置打断计数 - await self._reset_interruption_count_after_action(chat_stream.stream_id) - - return { - "action_type": action_name, - "success": success, - "reply_text": reply_text, - "command": command, - } - else: - # 检查目标消息是否为表情包消息以及配置是否允许回复表情包 - if target_message and getattr(target_message, "is_emoji", False): - # 如果是表情包消息且配置不允许回复表情包,则跳过回复 - if not getattr(global_config.chat, "allow_reply_to_emoji", True): - logger.info(f"{log_prefix} 目标消息为表情包且配置不允许回复表情包,跳过回复") - return {"action_type": action_name, "success": True, "reply_text": "", "skip_reason": "emoji_not_allowed"} - - # 生成回复 (reply 或 respond) - # reply: 针对单条消息的回复,使用 s4u 模板 - # respond: 对未读消息的统一回应,使用 normal 模板 - try: - # 根据动作类型确定提示词模式 - prompt_mode = "s4u" if action_name == "reply" else "normal" - - # 将prompt_mode传递给generate_reply - action_data_with_mode = (action_data or {}).copy() - action_data_with_mode["prompt_mode"] = prompt_mode - - # 只传递当前正在执行的动作,而不是所有可用动作 - # 这样可以让LLM明确知道"已决定执行X动作",而不是"有这些动作可用" - current_action_info = self._using_actions.get(action_name) - current_actions: dict[str, Any] = {action_name: current_action_info} if current_action_info else {} - - # 附加目标消息信息(如果存在) - if target_message: - # 提取目标消息的关键信息 - target_msg_info = { - "message_id": getattr(target_message, "message_id", ""), - "sender": getattr(target_message.user_info, "user_nickname", "") if hasattr(target_message, "user_info") else "", - "content": getattr(target_message, "processed_plain_text", ""), - "time": getattr(target_message, "time", 0), - } - current_actions["_target_message"] = target_msg_info - - success, response_set, _ = await generator_api.generate_reply( - chat_stream=chat_stream, - reply_message=target_message, - action_data=action_data_with_mode, - available_actions=current_actions, # type: ignore - enable_tool=global_config.tool.enable_tool, - request_type="chat.replyer", - from_plugin=False, - ) - if not success or not response_set: - # 安全地获取 processed_plain_text - if target_message: - msg_text = target_message.processed_plain_text or "未知消息" - else: - msg_text = "未知消息" - - logger.info(f"对 {msg_text} 的回复生成失败") - return {"action_type": action_name, "success": False, "reply_text": "", "loop_info": None} - except asyncio.CancelledError: - logger.debug(f"{log_prefix} 并行执行:回复生成任务已被取消") - return {"action_type": action_name, "success": False, "reply_text": "", "loop_info": None} - - # 从action_data中提取should_quote_reply参数 - should_quote_reply = None - if action_data and isinstance(action_data, dict): - should_quote_reply = action_data.get("should_quote_reply", None) - - # respond动作默认不引用回复,保持对话流畅 - if action_name == "respond" and should_quote_reply is None: - should_quote_reply = False - - async def _after_reply(): - # 发送并存储回复 - reply_text, cycle_timers_reply = await self._send_and_store_reply( - chat_stream, - response_set, - asyncio.get_event_loop().time(), - target_message, - {}, # cycle_timers - thinking_id, - [], # actions - should_quote_reply, # 传递should_quote_reply参数 - ) - - # 记录回复动作到目标消息 - await self._record_action_to_message(chat_stream, action_name, target_message, action_data) - - # 回复成功,重置打断计数 - await self._reset_interruption_count_after_action(chat_stream.stream_id) - - return reply_text - asyncio.create_task(_after_reply()) - return {"action_type": action_name, "success": True} + return { + "action_type": action_name, + "success": success, + "reply_text": reply_text, + "command": command, + } except Exception as e: logger.error(f"{log_prefix} 执行动作时出错: {e}") diff --git a/src/chat/planner_actions/action_modifier.py b/src/chat/planner_actions/action_modifier.py index ca057d8e7..a0197d7a5 100644 --- a/src/chat/planner_actions/action_modifier.py +++ b/src/chat/planner_actions/action_modifier.py @@ -57,6 +57,7 @@ class ActionModifier: async def modify_actions( self, message_content: str = "", + chatter_name: str = "", ): # sourcery skip: use-named-expression """ 动作修改流程,整合传统观察处理和新的激活类型判定 @@ -66,6 +67,10 @@ class ActionModifier: 2. 基于激活类型的智能动作判定,最终确定可用动作集 处理后,ActionManager 将包含最终的可用动作集,供规划器直接使用 + + Args: + message_content: 消息内容 + chatter_name: 当前使用的 Chatter 名称,用于过滤只允许特定 Chatter 使用的动作 """ # 初始化log_prefix await self._initialize_log_prefix() @@ -82,13 +87,14 @@ class ActionModifier: logger.debug(f"{self.log_prefix}开始完整动作修改流程") + removals_s0: list[tuple[str, str]] = [] # 第0阶段:聊天类型和Chatter过滤 removals_s1: list[tuple[str, str]] = [] removals_s2: list[tuple[str, str]] = [] removals_s3: list[tuple[str, str]] = [] all_actions = self.action_manager.get_using_actions() - # === 第0阶段:根据聊天类型过滤动作 === + # === 第0阶段:根据聊天类型和Chatter过滤动作 === from src.chat.utils.utils import get_chat_type_and_target_info from src.plugin_system.base.component_types import ChatType, ComponentType from src.plugin_system.core.component_registry import component_registry @@ -97,26 +103,35 @@ class ActionModifier: is_group_chat, _ = await get_chat_type_and_target_info(self.chat_id) all_registered_actions = component_registry.get_components_by_type(ComponentType.ACTION) - chat_type_removals = [] for action_name in list(all_actions.keys()): if action_name in all_registered_actions: action_info = all_registered_actions[action_name] + + # 检查聊天类型限制 chat_type_allow = getattr(action_info, "chat_type_allow", ChatType.ALL) - - # 检查是否符合聊天类型限制 - should_keep = ( + should_keep_chat_type = ( chat_type_allow == ChatType.ALL or (chat_type_allow == ChatType.GROUP and is_group_chat) or (chat_type_allow == ChatType.PRIVATE and not is_group_chat) ) - - if not should_keep: - chat_type_removals.append((action_name, f"不支持{'群聊' if is_group_chat else '私聊'}")) + + if not should_keep_chat_type: + removals_s0.append((action_name, f"不支持{'群聊' if is_group_chat else '私聊'}")) self.action_manager.remove_action_from_using(action_name) + continue + + # 检查 Chatter 限制 + chatter_allow = getattr(action_info, "chatter_allow", []) + if chatter_allow and chatter_name: + # 如果设置了 chatter_allow 且提供了 chatter_name,则检查是否匹配 + if chatter_name not in chatter_allow: + removals_s0.append((action_name, f"仅限 {', '.join(chatter_allow)} 使用")) + self.action_manager.remove_action_from_using(action_name) + continue - if chat_type_removals: - logger.info(f"{self.log_prefix} 第0阶段:根据聊天类型过滤 - 移除了 {len(chat_type_removals)} 个动作") - for action_name, reason in chat_type_removals: + if removals_s0: + logger.info(f"{self.log_prefix} 第0阶段:类型/Chatter过滤 - 移除了 {len(removals_s0)} 个动作") + for action_name, reason in removals_s0: logger.debug(f"{self.log_prefix} - 移除 {action_name}: {reason}") message_list_before_now_half = await get_raw_msg_before_timestamp_with_chat( diff --git a/src/plugin_system/base/base_action.py b/src/plugin_system/base/base_action.py index 27e877ff5..44ef212ce 100644 --- a/src/plugin_system/base/base_action.py +++ b/src/plugin_system/base/base_action.py @@ -559,6 +559,7 @@ class BaseAction(ABC): action_require=getattr(cls, "action_require", []).copy(), associated_types=getattr(cls, "associated_types", []).copy(), chat_type_allow=getattr(cls, "chat_type_allow", ChatType.ALL), + chatter_allow=getattr(cls, "chatter_allow", []).copy(), # 二步Action相关属性 is_two_step_action=getattr(cls, "is_two_step_action", False), step_one_description=getattr(cls, "step_one_description", ""), diff --git a/src/plugin_system/base/component_types.py b/src/plugin_system/base/component_types.py index 55fa28284..aa3147785 100644 --- a/src/plugin_system/base/component_types.py +++ b/src/plugin_system/base/component_types.py @@ -209,6 +209,7 @@ class ActionInfo(ComponentInfo): mode_enable: ChatMode = ChatMode.ALL parallel_action: bool = False chat_type_allow: ChatType = ChatType.ALL # 允许的聊天类型 + chatter_allow: list[str] = field(default_factory=list) # 允许的 Chatter 列表,空则允许所有 # 二步Action相关属性 is_two_step_action: bool = False # 是否为二步Action step_one_description: str = "" # 第一步的描述 @@ -226,6 +227,8 @@ class ActionInfo(ComponentInfo): self.associated_types = [] if self.sub_actions is None: self.sub_actions = [] + if self.chatter_allow is None: + self.chatter_allow = [] self.component_type = ComponentType.ACTION diff --git a/src/plugins/built_in/affinity_flow_chatter/planner/planner.py b/src/plugins/built_in/affinity_flow_chatter/planner/planner.py index c12bf71ab..703265624 100644 --- a/src/plugins/built_in/affinity_flow_chatter/planner/planner.py +++ b/src/plugins/built_in/affinity_flow_chatter/planner/planner.py @@ -258,7 +258,7 @@ class ChatterActionPlanner: # 3. 在规划前,先进行动作修改 from src.chat.planner_actions.action_modifier import ActionModifier action_modifier = ActionModifier(self.action_manager, self.chat_id) - await action_modifier.modify_actions() + await action_modifier.modify_actions(chatter_name="AffinityFlowChatter") # 4. 生成初始计划 initial_plan = await self.generator.generate(ChatMode.FOCUS) diff --git a/src/plugins/built_in/core_actions/reply.py b/src/plugins/built_in/core_actions/reply.py index 9a90f7e33..08d77c2ce 100644 --- a/src/plugins/built_in/core_actions/reply.py +++ b/src/plugins/built_in/core_actions/reply.py @@ -1,15 +1,19 @@ """ 回复动作模块 -定义了两种回复动作: +定义了三种回复相关动作: - reply: 针对单条消息的深度回复(使用 s4u 模板) - respond: 对未读消息的统一回应(使用 normal 模板) +- no_reply: 选择不回复 """ +import asyncio from typing import ClassVar from src.common.logger import get_logger +from src.config.config import global_config from src.plugin_system import ActionActivationType, BaseAction, ChatMode +from src.plugin_system.apis import database_api, generator_api, send_api logger = get_logger("reply_actions") @@ -21,6 +25,7 @@ class ReplyAction(BaseAction): - 使用 s4u (Speak for You) 模板 - 专注于理解和回应单条消息的具体内容 - 适合 Focus 模式下的精准回复 + - 仅限 AffinityFlowChatter 使用 """ # 动作基本信息 @@ -31,6 +36,9 @@ class ReplyAction(BaseAction): activation_type = ActionActivationType.ALWAYS # 回复动作总是可用 mode_enable = ChatMode.ALL # 在所有模式下都可用 parallel_action = False # 回复动作不能与其他动作并行 + + # Chatter 限制:仅允许 AffinityFlowChatter 使用 + chatter_allow: ClassVar[list[str]] = ["AffinityFlowChatter"] # 动作参数定义 action_parameters: ClassVar = { @@ -53,13 +61,116 @@ class ReplyAction(BaseAction): associated_types: ClassVar[list[str]] = ["text"] async def execute(self) -> tuple[bool, str]: - """执行reply动作 + """执行reply动作 - 完整的回复流程""" + try: + # 检查目标消息是否为表情包 + if self.action_message and getattr(self.action_message, "is_emoji", False): + if not getattr(global_config.chat, "allow_reply_to_emoji", True): + logger.info(f"{self.log_prefix} 目标消息为表情包且配置不允许回复,跳过") + return True, "" + + # 准备 action_data + action_data = self.action_data.copy() + action_data["prompt_mode"] = "s4u" + + # 生成回复 + success, response_set, _ = await generator_api.generate_reply( + chat_stream=self.chat_stream, + reply_message=self.action_message, + action_data=action_data, + available_actions={self.action_name: None}, + enable_tool=global_config.tool.enable_tool, + request_type="chat.replyer", + from_plugin=False, + ) + + if not success or not response_set: + logger.warning(f"{self.log_prefix} 回复生成失败") + return False, "" + + # 发送回复 + reply_text = await self._send_response(response_set) + + # 存储动作信息 + await self._store_action_info(reply_text) + + logger.info(f"{self.log_prefix} reply 动作执行成功") + return True, reply_text + + except asyncio.CancelledError: + logger.debug(f"{self.log_prefix} 回复任务被取消") + return False, "" + except Exception as e: + logger.error(f"{self.log_prefix} reply 动作执行失败: {e}") + import traceback + traceback.print_exc() + return False, "" + + async def _send_response(self, response_set) -> str: + """发送回复内容""" + reply_text = "" + should_quote = self.action_data.get("should_quote_reply", False) + first_sent = False - 注意:实际的回复生成由 action_manager 统一处理 - 这里只是标记使用 reply 动作(s4u 模板) - """ - logger.info(f"{self.log_prefix} 使用 reply 动作(s4u 模板)") - return True, "" + for reply_seg in response_set: + # 处理元组格式 + if isinstance(reply_seg, tuple) and len(reply_seg) >= 2: + _, data = reply_seg + else: + data = str(reply_seg) + + if isinstance(data, list): + data = "".join(map(str, data)) + + reply_text += data + + # 发送消息 + if not first_sent: + await send_api.text_to_stream( + text=data, + stream_id=self.chat_stream.stream_id, + reply_to_message=self.action_message, + set_reply=should_quote and bool(self.action_message), + typing=False, + ) + first_sent = True + else: + await send_api.text_to_stream( + text=data, + stream_id=self.chat_stream.stream_id, + reply_to_message=None, + set_reply=False, + typing=True, + ) + + return reply_text + + async def _store_action_info(self, reply_text: str): + """存储动作信息到数据库""" + from src.person_info.person_info import get_person_info_manager + + person_info_manager = get_person_info_manager() + + if self.action_message: + platform = self.action_message.chat_info.platform + user_id = self.action_message.user_info.user_id + else: + platform = getattr(self.chat_stream, "platform", "unknown") + user_id = "" + + person_id = person_info_manager.get_person_id(platform, user_id) + person_name = await person_info_manager.get_value(person_id, "person_name") + action_prompt_display = f"你对{person_name}进行了回复:{reply_text}" + + await database_api.store_action_info( + chat_stream=self.chat_stream, + action_build_into_prompt=False, + action_prompt_display=action_prompt_display, + action_done=True, + thinking_id=self.thinking_id, + action_data={"reply_text": reply_text}, + action_name="reply", + ) class RespondAction(BaseAction): @@ -69,6 +180,7 @@ class RespondAction(BaseAction): - 关注整体对话动态和未读消息的统一回应 - 适合对于群聊消息下的宏观回应 - 避免与单一用户深度对话而忽略其他用户的消息 + - 仅限 AffinityFlowChatter 使用 """ # 动作基本信息 @@ -79,6 +191,9 @@ class RespondAction(BaseAction): activation_type = ActionActivationType.ALWAYS # 回应动作总是可用 mode_enable = ChatMode.ALL # 在所有模式下都可用 parallel_action = False # 回应动作不能与其他动作并行 + + # Chatter 限制:仅允许 AffinityFlowChatter 使用 + chatter_allow: ClassVar[list[str]] = ["AffinityFlowChatter"] # 动作参数定义 action_parameters: ClassVar = { @@ -99,10 +214,89 @@ class RespondAction(BaseAction): associated_types: ClassVar[list[str]] = ["text"] async def execute(self) -> tuple[bool, str]: - """执行respond动作 + """执行respond动作 - 完整的回复流程""" + try: + # 准备 action_data + action_data = self.action_data.copy() + action_data["prompt_mode"] = "normal" + + # 生成回复 + success, response_set, _ = await generator_api.generate_reply( + chat_stream=self.chat_stream, + reply_message=self.action_message, + action_data=action_data, + available_actions={self.action_name: None}, + enable_tool=global_config.tool.enable_tool, + request_type="chat.replyer", + from_plugin=False, + ) + + if not success or not response_set: + logger.warning(f"{self.log_prefix} 回复生成失败") + return False, "" + + # 发送回复(respond 默认不引用) + reply_text = await self._send_response(response_set) + + # 存储动作信息 + await self._store_action_info(reply_text) + + logger.info(f"{self.log_prefix} respond 动作执行成功") + return True, reply_text + + except asyncio.CancelledError: + logger.debug(f"{self.log_prefix} 回复任务被取消") + return False, "" + except Exception as e: + logger.error(f"{self.log_prefix} respond 动作执行失败: {e}") + import traceback + traceback.print_exc() + return False, "" + + async def _send_response(self, response_set) -> str: + """发送回复内容(不引用原消息)""" + reply_text = "" + first_sent = False - 注意:实际的回复生成由 action_manager 统一处理 - 这里只是标记使用 respond 动作(normal 模板) - """ - logger.info(f"{self.log_prefix} 使用 respond 动作(normal 模板)") - return True, "" + for reply_seg in response_set: + if isinstance(reply_seg, tuple) and len(reply_seg) >= 2: + _, data = reply_seg + else: + data = str(reply_seg) + + if isinstance(data, list): + data = "".join(map(str, data)) + + reply_text += data + + if not first_sent: + await send_api.text_to_stream( + text=data, + stream_id=self.chat_stream.stream_id, + reply_to_message=None, + set_reply=False, + typing=False, + ) + first_sent = True + else: + await send_api.text_to_stream( + text=data, + stream_id=self.chat_stream.stream_id, + reply_to_message=None, + set_reply=False, + typing=True, + ) + + return reply_text + + async def _store_action_info(self, reply_text: str): + """存储动作信息到数据库""" + await database_api.store_action_info( + chat_stream=self.chat_stream, + action_build_into_prompt=False, + action_prompt_display=f"统一回应:{reply_text}", + action_done=True, + thinking_id=self.thinking_id, + action_data={"reply_text": reply_text}, + action_name="respond", + ) diff --git a/src/plugins/built_in/kokoro_flow_chatter/__init__.py b/src/plugins/built_in/kokoro_flow_chatter/__init__.py deleted file mode 100644 index 440ea0f49..000000000 --- a/src/plugins/built_in/kokoro_flow_chatter/__init__.py +++ /dev/null @@ -1,26 +0,0 @@ -""" -Kokoro Flow Chatter (心流聊天器) 插件 - -一个专为私聊场景设计的AI聊天插件,实现从"消息响应者"到"对话体验者"的转变。 -核心特点: -- 心理状态驱动的交互模型 -- 连续的时间观念和等待体验 -- 深度情感连接和长期关系维护 -""" - -from src.plugin_system.base.plugin_metadata import PluginMetadata - -from .plugin import KokoroFlowChatterPlugin - -__plugin_meta__ = PluginMetadata( - name="Kokoro Flow Chatter", - description="专为私聊设计的深度情感交互处理器,实现心理状态驱动的对话体验", - usage="在私聊场景中自动启用,可通过 [kokoro_flow_chatter].enable 配置开关", - version="3.0.0", - author="MoFox", - keywords=["chatter", "kokoro", "private", "emotional", "narrative"], - categories=["Chat", "AI", "Emotional"], - extra={"is_built_in": True, "chat_type": "private"}, -) - -__all__ = ["KokoroFlowChatterPlugin", "__plugin_meta__"] diff --git a/src/plugins/built_in/kokoro_flow_chatter/action_executor.py b/src/plugins/built_in/kokoro_flow_chatter/action_executor.py deleted file mode 100644 index 8ad88b87d..000000000 --- a/src/plugins/built_in/kokoro_flow_chatter/action_executor.py +++ /dev/null @@ -1,711 +0,0 @@ -""" -Kokoro Flow Chatter 动作执行器 (V2) - -融合AFC的动态动作发现机制,支持所有注册的Action组件。 -负责解析LLM返回的动作列表并通过ChatterActionManager执行。 - -V2升级要点: -1. 动态动作支持 - 使用ActionManager发现所有可用动作 -2. 统一执行接口 - 通过ChatterActionManager.execute_action()执行所有动作 -3. 保留KFC特有功能 - 内部状态更新、心理日志等 -4. 支持复合动作 - 如 sing_a_song + image_sender + tts_voice_action - -V5升级要点: -1. 动态情感更新 - 根据thought字段的情感倾向微调EmotionalState -""" - -import asyncio -import time -from typing import TYPE_CHECKING, Any, Optional - -import orjson - -from src.chat.planner_actions.action_manager import ChatterActionManager -from src.common.logger import get_logger -from src.plugin_system.base.component_types import ActionInfo -from src.utils.json_parser import extract_and_parse_json - -from .models import ( - ActionModel, - EmotionalState, - KokoroSession, - LLMResponseModel, - MentalLogEntry, - MentalLogEventType, -) - -if TYPE_CHECKING: - from src.chat.message_receive.chat_stream import ChatStream - -logger = get_logger("kokoro_action_executor") - - -class ActionExecutor: - """ - Kokoro Flow Chatter 动作执行器 (V2) - - 职责: - 1. 解析LLM返回的JSON响应 - 2. 动态验证动作格式和参数(基于ActionManager的动作注册) - 3. 通过ChatterActionManager执行各类动作 - 4. 处理KFC特有的内部状态更新 - 5. 记录执行结果到心理日志 - - V2特性: - - 支持所有通过插件系统注册的Action - - 自动从ActionManager获取可用动作列表 - - 支持复合动作组合执行 - - 区分"回复类动作"和"其他动作"的执行顺序 - """ - - # KFC内置的特殊动作(不通过ActionManager执行) - INTERNAL_ACTIONS = { - "update_internal_state": { - "required": [], - "optional": ["mood", "mood_intensity", "relationship_warmth", "impression_of_user", "anxiety_level", "engagement_level"] - }, - "do_nothing": {"required": [], "optional": []}, - } - - def __init__(self, stream_id: str): - """ - 初始化动作执行器 - - Args: - stream_id: 聊天流ID - """ - self.stream_id = stream_id - self._action_manager = ChatterActionManager() - self._available_actions: dict[str, ActionInfo] = {} - self._execution_stats = { - "total_executed": 0, - "successful": 0, - "failed": 0, - "by_type": {}, - } - - async def load_actions(self) -> dict[str, ActionInfo]: - """ - 加载当前可用的动作列表 - - Returns: - dict[str, ActionInfo]: 可用动作字典 - """ - await self._action_manager.load_actions(self.stream_id) - self._available_actions = self._action_manager.get_using_actions() - logger.debug(f"KFC ActionExecutor 加载了 {len(self._available_actions)} 个可用动作: {list(self._available_actions.keys())}") - return self._available_actions - - def get_available_actions(self) -> dict[str, ActionInfo]: - """获取当前可用的动作列表""" - return self._available_actions.copy() - - def is_action_available(self, action_type: str) -> bool: - """ - 检查动作是否可用 - - Args: - action_type: 动作类型名称 - - Returns: - bool: 动作是否可用 - """ - # 内置动作总是可用 - if action_type in self.INTERNAL_ACTIONS: - return True - # 检查动态注册的动作 - return action_type in self._available_actions - - def parse_llm_response(self, response_text: str) -> LLMResponseModel: - """ - 解析LLM的JSON响应 - - 使用统一的json_parser工具进行解析,自动处理: - - Markdown代码块标记 - - 格式错误的JSON修复(json_repair) - - 多种包装格式 - - Args: - response_text: LLM返回的原始文本 - - Returns: - LLMResponseModel: 解析后的响应模型 - """ - # 使用统一的json_parser工具解析 - data = extract_and_parse_json(response_text, strict=False) - - if not data or not isinstance(data, dict): - logger.warning(f"无法从LLM响应中提取有效JSON: {response_text[:200]}...") - return LLMResponseModel.create_error_response("无法解析响应格式") - - return self._validate_and_create_response(data) - - def _validate_and_create_response(self, data: dict[str, Any]) -> LLMResponseModel: - """ - 验证并创建响应模型(V2:支持动态动作验证) - - Args: - data: 解析后的字典数据 - - Returns: - LLMResponseModel: 验证后的响应模型 - """ - # 验证必需字段 - if "thought" not in data: - data["thought"] = "" - logger.warning("LLM响应缺少'thought'字段") - - if "expected_user_reaction" not in data: - data["expected_user_reaction"] = "" - logger.warning("LLM响应缺少'expected_user_reaction'字段") - - if "max_wait_seconds" not in data: - data["max_wait_seconds"] = 300 - logger.warning("LLM响应缺少'max_wait_seconds'字段,使用默认值300") - else: - # 确保在合理范围内:0-900秒 - # 0 表示不等待(话题结束/用户说再见等) - try: - wait_seconds = int(data["max_wait_seconds"]) - data["max_wait_seconds"] = max(0, min(wait_seconds, 900)) - except (ValueError, TypeError): - data["max_wait_seconds"] = 300 - - if "actions" not in data or not data["actions"]: - data["actions"] = [{"type": "do_nothing"}] - logger.warning("LLM响应缺少'actions'字段,添加默认的do_nothing动作") - - # 验证每个动作(V2:使用动态验证) - validated_actions = [] - for action_data in data["actions"]: - if not isinstance(action_data, dict): - logger.warning(f"无效的动作格式: {action_data}") - continue - - action_type = action_data.get("type", "") - - # 检查是否是已注册的动作 - if not self.is_action_available(action_type): - logger.warning(f"不支持的动作类型: {action_type},可用动作: {list(self._available_actions.keys()) + list(self.INTERNAL_ACTIONS.keys())}") - continue - - # 对于内置动作,验证参数 - if action_type in self.INTERNAL_ACTIONS: - required_params = self.INTERNAL_ACTIONS[action_type]["required"] - missing_params = [p for p in required_params if p not in action_data] - if missing_params: - logger.warning(f"动作 '{action_type}' 缺少必需参数: {missing_params}") - continue - - # 对于动态注册的动作,仅记录参数信息(不强制验证) - # 注意:action_require 是"使用场景描述",不是必需参数! - # 必需参数应该在 action_parameters 中定义 - elif action_type in self._available_actions: - action_info = self._available_actions[action_type] - # 仅记录调试信息,不阻止执行 - if action_info.action_parameters: - provided_params = set(action_data.keys()) - {"type", "reason"} - expected_params = set(action_info.action_parameters.keys()) - if expected_params and not provided_params.intersection(expected_params): - logger.debug(f"动作 '{action_type}' 期望参数: {list(expected_params)},实际提供: {list(provided_params)}") - - validated_actions.append(action_data) - - if not validated_actions: - validated_actions = [{"type": "do_nothing"}] - - data["actions"] = validated_actions - - return LLMResponseModel.from_dict(data) - - async def execute_actions( - self, - response: LLMResponseModel, - session: KokoroSession, - chat_stream: Optional["ChatStream"] = None, - ) -> dict[str, Any]: - """ - 执行动作列表(V2:通过ActionManager执行动态动作) - - 执行策略(参考AFC的plan_executor): - 1. 先执行所有"回复类"动作(reply, respond等) - 2. 再执行"其他"动作(send_reaction, sing_a_song等) - 3. 内部动作(update_internal_state, do_nothing)由KFC直接处理 - - Args: - response: LLM响应模型 - session: 当前会话 - chat_stream: 聊天流对象(用于发送消息) - - Returns: - dict: 执行结果 - """ - results = [] - has_reply = False - reply_content = "" - - # INFO日志:打印所有解析出的动作(可观测性增强) - for action in response.actions: - logger.info( - f"Parsed action for execution: type={action.type}, params={action.params}" - ) - - # 分类动作:回复类 vs 其他类 vs 内部类 - reply_actions = [] # reply, respond - other_actions = [] # 其他注册的动作 - internal_actions = [] # update_internal_state, do_nothing - - for action in response.actions: - action_type = action.type - if action_type in self.INTERNAL_ACTIONS: - internal_actions.append(action) - elif action_type in ("reply", "respond"): - reply_actions.append(action) - else: - other_actions.append(action) - - # 第1步:执行回复类动作 - for action in reply_actions: - try: - result = await self._execute_via_action_manager( - action, session, chat_stream - ) - results.append(result) - - if result.get("success"): - self._execution_stats["successful"] += 1 - has_reply = True - reply_content = action.params.get("content", "") or result.get("reply_text", "") - else: - self._execution_stats["failed"] += 1 - - except Exception as e: - logger.error(f"执行回复动作 '{action.type}' 失败: {e}") - results.append({ - "action_type": action.type, - "success": False, - "error": str(e), - }) - self._execution_stats["failed"] += 1 - - self._update_stats(action.type) - - # 第2步:并行执行其他动作(参考AFC的_execute_other_actions) - if other_actions: - other_tasks = [] - for action in other_actions: - task = asyncio.create_task( - self._execute_via_action_manager(action, session, chat_stream) - ) - other_tasks.append((action, task)) - - for action, task in other_tasks: - try: - result = await task - results.append(result) - if result.get("success"): - self._execution_stats["successful"] += 1 - else: - self._execution_stats["failed"] += 1 - except Exception as e: - logger.error(f"执行动作 '{action.type}' 失败: {e}") - results.append({ - "action_type": action.type, - "success": False, - "error": str(e), - }) - self._execution_stats["failed"] += 1 - - self._update_stats(action.type) - - # 第3步:执行内部动作 - for action in internal_actions: - try: - result = await self._execute_internal_action(action, session) - results.append(result) - self._execution_stats["successful"] += 1 - except Exception as e: - logger.error(f"执行内部动作 '{action.type}' 失败: {e}") - results.append({ - "action_type": action.type, - "success": False, - "error": str(e), - }) - self._execution_stats["failed"] += 1 - - self._update_stats(action.type) - - # 添加Bot行动日志 - if has_reply or other_actions: - entry = MentalLogEntry( - event_type=MentalLogEventType.BOT_ACTION, - timestamp=time.time(), - thought=response.thought, - content=reply_content or f"执行了 {len(other_actions)} 个动作", - emotional_snapshot=session.emotional_state.to_dict(), - metadata={ - "actions": [a.to_dict() for a in response.actions], - "results_summary": { - "total": len(results), - "successful": sum(1 for r in results if r.get("success")), - }, - }, - ) - session.add_mental_log_entry(entry) - if reply_content: - session.last_bot_message = reply_content - - # V5:动态情感更新 - 根据thought分析情感倾向并微调EmotionalState - await self._update_emotional_state_from_thought(response.thought, session) - - return { - "success": all(r.get("success", False) for r in results), - "results": results, - "has_reply": has_reply, - "reply_content": reply_content, - "thought": response.thought, - "expected_user_reaction": response.expected_user_reaction, - "max_wait_seconds": response.max_wait_seconds, - } - - def _update_stats(self, action_type: str) -> None: - """更新执行统计""" - self._execution_stats["total_executed"] += 1 - if action_type not in self._execution_stats["by_type"]: - self._execution_stats["by_type"][action_type] = 0 - self._execution_stats["by_type"][action_type] += 1 - - async def _execute_via_action_manager( - self, - action: ActionModel, - session: KokoroSession, - chat_stream: Optional["ChatStream"], - ) -> dict[str, Any]: - """ - 通过ActionManager执行动作 - - Args: - action: 动作模型 - session: 当前会话 - chat_stream: 聊天流对象 - - Returns: - dict: 执行结果 - """ - action_type = action.type - params = action.params - - logger.debug(f"通过ActionManager执行动作: {action_type}, 参数: {params}") - - if not chat_stream: - return { - "action_type": action_type, - "success": False, - "error": "无法获取聊天流", - } - - try: - # 准备动作数据 - action_data = params.copy() - - # 对于reply动作,需要处理content字段 - if action_type in ("reply", "respond") and "content" in action_data: - # ActionManager的reply期望的是生成回复而不是直接内容 - # 但KFC已经决定了内容,所以我们直接发送 - return await self._execute_reply_directly(action_data, chat_stream) - - # 使用ActionManager执行其他动作 - result = await self._action_manager.execute_action( - action_name=action_type, - chat_id=self.stream_id, - target_message=None, # KFC模式不需要target_message - reasoning=f"KFC决策: {action_type}", - action_data=action_data, - thinking_id=None, - log_prefix="[KFC]", - ) - - return { - "action_type": action_type, - "success": result.get("success", False), - "reply_text": result.get("reply_text", ""), - "result": result, - } - - except Exception as e: - logger.error(f"ActionManager执行失败: {action_type}, 错误: {e}") - import traceback - logger.error(traceback.format_exc()) - return { - "action_type": action_type, - "success": False, - "error": str(e), - } - - async def _execute_reply_directly( - self, - params: dict[str, Any], - chat_stream: "ChatStream", - ) -> dict[str, Any]: - """ - 直接执行回复动作(KFC决定的内容直接发送) - - V4升级:集成全局后处理流程(错别字、消息分割) - - Args: - params: 动作参数,包含content - chat_stream: 聊天流对象 - - Returns: - dict: 执行结果 - """ - from src.plugin_system.apis import send_api - from .response_post_processor import process_reply_content - - content = params.get("content", "") - reply_to = params.get("reply_to") - should_quote = params.get("should_quote_reply", False) - - if not content: - return { - "action_type": "reply", - "success": False, - "error": "回复内容为空", - } - - try: - # 【关键步骤】调用全局后处理器(错别字生成、消息分割) - processed_messages = await process_reply_content(content) - logger.info(f"[KFC] 后处理完成,原始内容长度={len(content)},分割为 {len(processed_messages)} 条消息") - - all_success = True - first_message = True - - for msg in processed_messages: - success = await send_api.text_to_stream( - text=msg, - stream_id=self.stream_id, - reply_to_message=reply_to if first_message else None, - set_reply=should_quote if first_message else False, - typing=True, - ) - if not success: - all_success = False - first_message = False - - return { - "action_type": "reply", - "success": all_success, - "reply_text": content, - "processed_messages": processed_messages, - } - - except Exception as e: - logger.error(f"直接发送回复失败: {e}") - import traceback - logger.error(traceback.format_exc()) - return { - "action_type": "reply", - "success": False, - "error": str(e), - } - - async def _execute_internal_action( - self, - action: ActionModel, - session: KokoroSession, - ) -> dict[str, Any]: - """ - 执行KFC内部动作 - - Args: - action: 动作模型 - session: 当前会话 - - Returns: - dict: 执行结果 - """ - action_type = action.type - params = action.params - - if action_type == "update_internal_state": - return await self._execute_update_state(params, session) - - elif action_type == "do_nothing": - return await self._execute_do_nothing() - - else: - return { - "action_type": action_type, - "success": False, - "error": f"未知的内部动作类型: {action_type}", - } - - async def _execute_update_state( - self, - params: dict[str, Any], - session: KokoroSession, - ) -> dict[str, Any]: - """ - 执行内部状态更新动作 - - V7重构:情绪变化必须合理 - - 禁止 LLM 直接设置负面情绪(低落、沮丧、难过等) - - 情绪变化必须渐进,不能突然跳变 - - 情绪强度变化限制在 ±0.3 以内 - """ - updated_fields = [] - emotional_state = session.emotional_state - blocked_fields = [] - - if "mood" in params: - new_mood = str(params["mood"]) - # V7: 检查是否是负面情绪 - negative_moods = [ - "低落", "沮丧", "难过", "伤心", "失落", "郁闷", "烦躁", "焦虑", - "担忧", "害怕", "恐惧", "愤怒", "生气", "不安", "忧郁", "悲伤", - "sad", "depressed", "anxious", "angry", "upset", "worried" - ] - is_negative = any(neg in new_mood.lower() for neg in negative_moods) - - if is_negative: - # 负面情绪需要检查是否有合理理由(通过检查上下文) - # 如果当前情绪是平静/正面的,不允许突然变成负面 - current_mood = emotional_state.mood.lower() - positive_indicators = ["平静", "开心", "愉快", "高兴", "满足", "期待", "好奇", "neutral"] - - if any(pos in current_mood for pos in positive_indicators): - # 从正面情绪直接跳到负面情绪,阻止这种变化 - logger.warning( - f"[KFC] 阻止无厘头负面情绪变化: {emotional_state.mood} -> {new_mood}," - f"情绪变化必须有聊天上下文支撑" - ) - blocked_fields.append("mood") - else: - # 已经是非正面情绪,允许变化但记录警告 - emotional_state.mood = new_mood - updated_fields.append("mood") - logger.info(f"[KFC] 情绪变化: {emotional_state.mood} -> {new_mood}") - else: - # 非负面情绪,允许更新 - emotional_state.mood = new_mood - updated_fields.append("mood") - - if "mood_intensity" in params: - try: - new_intensity = float(params["mood_intensity"]) - new_intensity = max(0.0, min(1.0, new_intensity)) - old_intensity = emotional_state.mood_intensity - - # V7: 限制情绪强度变化幅度(最多 ±0.3) - max_change = 0.3 - if abs(new_intensity - old_intensity) > max_change: - # 限制变化幅度 - if new_intensity > old_intensity: - new_intensity = min(old_intensity + max_change, 1.0) - else: - new_intensity = max(old_intensity - max_change, 0.0) - logger.info( - f"[KFC] 限制情绪强度变化: {old_intensity:.2f} -> {new_intensity:.2f} " - f"(原请求: {params['mood_intensity']})" - ) - - emotional_state.mood_intensity = new_intensity - updated_fields.append("mood_intensity") - except (ValueError, TypeError): - pass - - # relationship_warmth 不再由 LLM 更新,应该从全局关系系统读取 - if "relationship_warmth" in params: - logger.debug("[KFC] 忽略 relationship_warmth 更新,应从全局关系系统读取") - blocked_fields.append("relationship_warmth") - - if "impression_of_user" in params: - emotional_state.impression_of_user = str(params["impression_of_user"]) - updated_fields.append("impression_of_user") - - if "anxiety_level" in params: - try: - anxiety = float(params["anxiety_level"]) - emotional_state.anxiety_level = max(0.0, min(1.0, anxiety)) - updated_fields.append("anxiety_level") - except (ValueError, TypeError): - pass - - if "engagement_level" in params: - try: - engagement = float(params["engagement_level"]) - emotional_state.engagement_level = max(0.0, min(1.0, engagement)) - updated_fields.append("engagement_level") - except (ValueError, TypeError): - pass - - emotional_state.last_update_time = time.time() - - if blocked_fields: - logger.debug(f"更新情感状态: 更新={updated_fields}, 阻止={blocked_fields}") - else: - logger.debug(f"更新情感状态: {updated_fields}") - - return { - "action_type": "update_internal_state", - "success": True, - "updated_fields": updated_fields, - "blocked_fields": blocked_fields, - } - - async def _execute_do_nothing(self) -> dict[str, Any]: - """执行"什么都不做"动作""" - logger.debug("执行 do_nothing 动作") - return { - "action_type": "do_nothing", - "success": True, - } - - def get_execution_stats(self) -> dict[str, Any]: - """获取执行统计信息""" - return self._execution_stats.copy() - - def reset_stats(self) -> None: - """重置统计信息""" - self._execution_stats = { - "total_executed": 0, - "successful": 0, - "failed": 0, - "by_type": {}, - } - - async def _update_emotional_state_from_thought( - self, - thought: str, - session: KokoroSession, - ) -> None: - """ - 根据thought字段更新EmotionalState - - V6重构: - - 移除基于关键词的情感分析(诡异且不准确) - - 情感状态现在主要通过LLM输出的update_internal_state动作更新 - - 关系温度应该从person_info/relationship_manager的好感度系统读取 - - 此方法仅做简单的engagement_level更新 - - Args: - thought: LLM返回的内心独白 - session: 当前会话 - """ - if not thought: - return - - emotional_state = session.emotional_state - - # 简单的engagement_level更新:有内容的thought表示高投入 - if len(thought) > 50: - old_engagement = emotional_state.engagement_level - new_engagement = old_engagement + 0.025 # 微调 - emotional_state.engagement_level = max(0.0, min(1.0, new_engagement)) - - emotional_state.last_update_time = time.time() - - # 注意:关系温度(relationship_warmth)应该从全局的好感度系统读取 - # 参考 src/person_info/relationship_manager.py 和 src/plugin_system/apis/person_api.py - # 当前实现中,这个值主要通过 LLM 的 update_internal_state 动作来更新 diff --git a/src/plugins/built_in/kokoro_flow_chatter/chatter.py b/src/plugins/built_in/kokoro_flow_chatter/chatter.py deleted file mode 100644 index d6dd7f139..000000000 --- a/src/plugins/built_in/kokoro_flow_chatter/chatter.py +++ /dev/null @@ -1,877 +0,0 @@ -""" -Kokoro Flow Chatter (心流聊天器) 主类 - -核心聊天处理器,协调所有组件完成"体验-决策-行动"的交互循环。 -实现从"消息响应者"到"对话体验者"的核心转变。 -""" - -import asyncio -import time -import traceback -from typing import TYPE_CHECKING, Any, ClassVar, Optional - -from src.chat.planner_actions.action_manager import ChatterActionManager -from src.chat.planner_actions.action_modifier import ActionModifier # V6: 动作筛选器 -from src.common.data_models.message_manager_data_model import StreamContext -from src.common.logger import get_logger -from src.config.config import global_config, model_config -from src.llm_models.utils_model import LLMRequest -from src.plugin_system.base.base_chatter import BaseChatter -from src.plugin_system.base.component_types import ChatType - -from .action_executor import ActionExecutor -from .context_builder import KFCContextBuilder -from .models import ( - KokoroSession, - LLMResponseModel, - MentalLogEntry, - MentalLogEventType, - SessionStatus, -) -from .prompt_generator import PromptGenerator, get_prompt_generator -from .kfc_scheduler_adapter import KFCSchedulerAdapter, get_scheduler -from .session_manager import SessionManager, get_session_manager - -if TYPE_CHECKING: - from src.common.data_models.database_data_model import DatabaseMessages - -logger = get_logger("kokoro_flow_chatter") - -# 控制台颜色 -SOFT_PURPLE = "\033[38;5;183m" -RESET_COLOR = "\033[0m" - - -class KokoroFlowChatter(BaseChatter): - """ - 心流聊天器 (Kokoro Flow Chatter) - - 专为私聊场景设计的AI聊天处理器,核心特点: - - 心理状态驱动的交互模型 - - 连续的时间观念和等待体验 - - 深度情感连接和长期关系维护 - - 状态机: - IDLE -> RESPONDING -> WAITING -> (收到消息) -> RESPONDING - -> (超时) -> FOLLOW_UP_PENDING -> RESPONDING/IDLE - """ - - chatter_name: str = "KokoroFlowChatter" - chatter_description: str = "心流聊天器 - 专为私聊设计的深度情感交互处理器" - chat_types: ClassVar[list[ChatType]] = [ChatType.PRIVATE] # 仅支持私聊 - - def __init__( - self, - stream_id: str, - action_manager: ChatterActionManager, - plugin_config: dict | None = None, - ): - """ - 初始化心流聊天器 - - Args: - stream_id: 聊天流ID - action_manager: 动作管理器 - plugin_config: 插件配置 - """ - super().__init__(stream_id, action_manager, plugin_config) - - # 核心组件 - self.session_manager: SessionManager = get_session_manager() - self.prompt_generator: PromptGenerator = get_prompt_generator() - self.scheduler: KFCSchedulerAdapter = get_scheduler() - self.action_executor: ActionExecutor = ActionExecutor(stream_id) - - # 配置 - self._load_config() - - # 并发控制 - self._lock = asyncio.Lock() - - # V7: 打断机制(类似S4U的已读/未读,这里是已处理/未处理) - self._current_task: Optional[asyncio.Task] = None # 当前正在执行的任务 - self._interrupt_requested: bool = False # 是否请求打断 - self._interrupt_wait_seconds: float = 3.0 # 被打断后等待新消息的时间 - self._last_interrupt_time: float = 0.0 # 上次被打断的时间 - self._pending_message_ids: set[str] = set() # 未处理的消息ID集合(被打断时保留) - self._current_processing_message_id: Optional[str] = None # 当前正在处理的消息ID - - # 统计信息 - self.stats = { - "messages_processed": 0, - "llm_calls": 0, - "successful_responses": 0, - "failed_responses": 0, - "timeout_decisions": 0, - "interrupts": 0, # V7: 打断次数统计 - } - self.last_activity_time = time.time() - - # 设置调度器回调 - self._setup_scheduler_callbacks() - - logger.info(f"{SOFT_PURPLE}[KFC]{RESET_COLOR} 初始化完成: stream_id={stream_id}") - - def _load_config(self) -> None: - """ - 加载配置(从 global_config.kokoro_flow_chatter 读取) - - 设计理念:KFC不是独立人格,它复用全局的人设、情感框架和回复模型, - 只保留最少的行为控制开关。 - """ - # 获取 KFC 配置 - if global_config and hasattr(global_config, 'kokoro_flow_chatter'): - kfc_config = global_config.kokoro_flow_chatter - - # 核心行为配置 - self.max_wait_seconds_default: int = kfc_config.max_wait_seconds_default - self.enable_continuous_thinking: bool = kfc_config.enable_continuous_thinking - - # 主动思考子配置(V3: 人性化驱动,无机械限制) - proactive_cfg = kfc_config.proactive_thinking - self.enable_proactive: bool = proactive_cfg.enabled - self.silence_threshold_seconds: int = proactive_cfg.silence_threshold_seconds - self.min_interval_between_proactive: int = proactive_cfg.min_interval_between_proactive - - logger.debug("[KFC] 已从 global_config.kokoro_flow_chatter 加载配置") - else: - # 回退到默认值 - self.max_wait_seconds_default = 300 - self.enable_continuous_thinking = True - self.enable_proactive = True - self.silence_threshold_seconds = 7200 - self.min_interval_between_proactive = 1800 - - logger.debug("[KFC] 使用默认配置") - - def _setup_scheduler_callbacks(self) -> None: - """设置调度器回调""" - self.scheduler.set_timeout_callback(self._on_session_timeout) - - if self.enable_continuous_thinking: - self.scheduler.set_continuous_thinking_callback( - self._on_continuous_thinking - ) - - # 设置主动思考回调 - if self.enable_proactive: - self.scheduler.set_proactive_thinking_callback( - self._on_proactive_thinking - ) - - async def execute(self, context: StreamContext) -> dict: - """ - 执行聊天处理逻辑(BaseChatter接口实现) - - V7升级:实现打断机制(类似S4U的已读/未读机制) - - 如果当前有任务在执行,新消息会请求打断 - - 被打断时,当前处理的消息会被标记为"未处理"(pending) - - 下次处理时,会合并所有pending消息 + 新消息一起处理 - - 这样被打断的消息不会丢失,上下文关联性得以保持 - - Args: - context: StreamContext对象,包含聊天上下文信息 - - Returns: - 处理结果字典 - """ - # V7: 检查是否需要打断当前任务 - if self._current_task and not self._current_task.done(): - logger.info(f"[KFC] 收到新消息,请求打断当前任务: {self.stream_id}") - self._interrupt_requested = True - self.stats["interrupts"] += 1 - - # 返回一个特殊结果表示请求打断 - # 注意:当前正在处理的消息会在被打断时自动加入 pending 列表 - return self._build_result( - success=True, - message="interrupt_requested", - interrupted=True - ) - - # V7: 检查是否需要等待(刚被打断过,等待用户可能的连续输入) - time_since_interrupt = time.time() - self._last_interrupt_time - if time_since_interrupt < self._interrupt_wait_seconds and self._last_interrupt_time > 0: - wait_remaining = self._interrupt_wait_seconds - time_since_interrupt - logger.info(f"[KFC] 刚被打断,等待 {wait_remaining:.1f}s 收集更多消息: {self.stream_id}") - await asyncio.sleep(wait_remaining) - - async with self._lock: - try: - self.last_activity_time = time.time() - self._interrupt_requested = False - - # 创建任务以便可以被打断 - self._current_task = asyncio.current_task() - - # V7: 获取所有未读消息 - # 注意:被打断的消息不会被标记为已读,所以仍然在 unread 列表中 - unread_messages = context.get_unread_messages() - - if not unread_messages: - logger.debug(f"[KFC] 没有未读消息: {self.stream_id}") - return self._build_result(success=True, message="no_unread_messages") - - # V7: 记录是否有 pending 消息(被打断时遗留的) - pending_count = len(self._pending_message_ids) - if pending_count > 0: - # 日志:显示有多少消息是被打断后重新处理的 - new_count = sum(1 for msg in unread_messages - if str(msg.message_id) not in self._pending_message_ids) - logger.info( - f"[KFC] 打断恢复: 正在处理 {len(unread_messages)} 条消息 " - f"({pending_count} 条pending + {new_count} 条新消息): {self.stream_id}" - ) - - # 以最后一条消息为主消息(用于动作筛选和主要响应) - target_message = unread_messages[-1] - - # 记录当前正在处理的消息ID(用于被打断时标记为pending) - self._current_processing_message_id = str(target_message.message_id) - - message_content = self._extract_message_content(target_message) - - # V2: 加载可用动作(动态动作发现) - await self.action_executor.load_actions() - raw_action_count = len(self.action_executor.get_available_actions()) - logger.debug(f"[KFC] 原始加载 {raw_action_count} 个动作") - - # V7: 在动作筛选前检查是否被打断 - if self._interrupt_requested: - logger.info(f"[KFC] 动作筛选前被打断: {self.stream_id}") - # 将当前处理的消息加入pending列表,下次一起处理 - if self._current_processing_message_id: - self._pending_message_ids.add(self._current_processing_message_id) - logger.info(f"[KFC] 消息 {self._current_processing_message_id} 加入pending列表") - self._last_interrupt_time = time.time() - self._current_processing_message_id = None - return self._build_result(success=True, message="interrupted") - - # V6: 使用ActionModifier筛选动作(复用AFC的三阶段筛选逻辑) - # 阶段0: 聊天类型过滤(私聊/群聊) - # 阶段2: 关联类型匹配(适配器能力检查) - # 阶段3: 激活判定(go_activate + LLM判断) - action_modifier = ActionModifier( - action_manager=self.action_executor._action_manager, - chat_id=self.stream_id, - ) - await action_modifier.modify_actions(message_content=message_content) - - # 获取筛选后的动作 - available_actions = self.action_executor._action_manager.get_using_actions() - logger.info( - f"[KFC] 动作筛选: {raw_action_count} -> {len(available_actions)} " - f"(筛除 {raw_action_count - len(available_actions)} 个)" - ) - - # 执行核心处理流程(传递筛选后的动作,V7: 传递所有未读消息) - result = await self._handle_message( - target_message, - context, - available_actions, - all_unread_messages=unread_messages, # V7: 传递所有未读消息 - ) - - # 更新统计 - self.stats["messages_processed"] += 1 - - return result - - except asyncio.CancelledError: - logger.info(f"[KFC] 处理被取消: {self.stream_id}") - self.stats["failed_responses"] += 1 - raise - except Exception as e: - logger.error(f"[KFC] 处理出错: {e}\n{traceback.format_exc()}") - self.stats["failed_responses"] += 1 - return self._build_result( - success=False, - message=str(e), - error=True - ) - finally: - self._current_task = None - - async def _handle_message( - self, - message: "DatabaseMessages", - context: StreamContext, - available_actions: dict | None = None, - all_unread_messages: list | None = None, # V7: 所有未读消息(包含pending的) - ) -> dict: - """ - 处理单条消息的核心逻辑 - - 实现"体验 -> 决策 -> 行动"的交互模式 - V5超融合:集成S4U所有上下文模块 - V7升级:支持处理多条消息(打断机制合并pending消息) - - Args: - message: 要处理的主消息(最新的那条) - context: 聊天上下文 - available_actions: 可用动作字典(V2新增) - all_unread_messages: 所有未读消息列表(V7新增,包含pending消息) - - Returns: - 处理结果字典 - """ - # 1. 获取或创建会话 - user_id = str(message.user_info.user_id) - session = await self.session_manager.get_session(user_id, self.stream_id) - - # 2. 记录收到消息的事件 - await self._record_user_message(session, message) - - # 3. 更新会话状态为RESPONDING - old_status = session.status - session.status = SessionStatus.RESPONDING - - # 4. 如果之前在等待,结束等待状态 - if old_status == SessionStatus.WAITING: - session.end_waiting() - # V7: 用户回复了,重置连续追问计数 - session.consecutive_followup_count = 0 - logger.debug(f"[KFC] 收到消息,结束等待,重置追问计数: user={user_id}") - - # 5. V5超融合:构建S4U上下文数据 - chat_stream = await self._get_chat_stream() - context_data = {} - - if chat_stream: - try: - context_builder = KFCContextBuilder(chat_stream) - sender_name = message.user_info.user_nickname or user_id - target_message = self._extract_message_content(message) - - context_data = await context_builder.build_all_context( - sender_name=sender_name, - target_message=target_message, - context=context, - ) - logger.info(f"[KFC] 超融合上下文构建完成: {list(context_data.keys())}") - except Exception as e: - logger.warning(f"[KFC] 构建S4U上下文失败,使用基础模式: {e}") - - # 6. 生成提示词(V3: 从共享数据源读取历史, V5: 传递S4U上下文, V7: 支持多条消息) - system_prompt, user_prompt = self.prompt_generator.generate_responding_prompt( - session=session, - message_content=self._extract_message_content(message), - sender_name=message.user_info.user_nickname or user_id, - sender_id=user_id, - message_time=message.time, - available_actions=available_actions, - context=context, # V3: 传递StreamContext以读取共享历史 - context_data=context_data, # V5: S4U上下文数据 - chat_stream=chat_stream, # V5: 聊天流用于场景判断 - all_unread_messages=all_unread_messages, # V7: 传递所有未读消息 - ) - - # 7. 调用LLM - llm_response = await self._call_llm(system_prompt, user_prompt) - self.stats["llm_calls"] += 1 - - # V7: LLM调用后检查是否被打断 - if self._interrupt_requested: - logger.info(f"[KFC] LLM调用后被打断: {self.stream_id}") - # 将当前处理的消息加入pending列表 - if self._current_processing_message_id: - self._pending_message_ids.add(self._current_processing_message_id) - logger.info(f"[KFC] 消息 {self._current_processing_message_id} 加入pending列表") - self._last_interrupt_time = time.time() - self._current_processing_message_id = None - return self._build_result(success=True, message="interrupted_after_llm") - - # 8. 解析响应 - parsed_response = self.action_executor.parse_llm_response(llm_response) - - # 9. 执行动作 - execution_result = await self.action_executor.execute_actions( - parsed_response, - session, - chat_stream - ) - - # 10. 处理执行结果 - if execution_result["has_reply"]: - # 如果发送了回复,检查是否需要进入等待状态 - max_wait = parsed_response.max_wait_seconds - - if max_wait > 0: - # 正常等待状态 - session.start_waiting( - expected_reaction=parsed_response.expected_user_reaction, - max_wait=max_wait - ) - logger.debug( - f"[KFC] 进入等待状态: user={user_id}, " - f"max_wait={max_wait}s" - ) - else: - # max_wait=0 表示不等待(话题结束/用户说再见等) - session.status = SessionStatus.IDLE - session.end_waiting() - logger.info( - f"[KFC] 话题结束,不等待用户回复: user={user_id} " - f"(max_wait_seconds=0)" - ) - - session.total_interactions += 1 - self.stats["successful_responses"] += 1 - else: - # 没有发送回复,返回空闲状态 - session.status = SessionStatus.IDLE - logger.debug(f"[KFC] 无回复动作,返回空闲: user={user_id}") - - # 11. 保存会话 - await self.session_manager.save_session(user_id) - - # 12. V7: 标记当前消息为已读 - context.mark_message_as_read(str(message.message_id)) - - # 13. V7: 清除pending状态(所有消息都已成功处理) - processed_count = len(self._pending_message_ids) - if self._pending_message_ids: - # 标记所有pending消息为已读 - for msg_id in self._pending_message_ids: - context.mark_message_as_read(msg_id) - logger.info(f"[KFC] 清除 {processed_count} 条pending消息: {self.stream_id}") - self._pending_message_ids.clear() - - # 清除当前处理的消息ID - self._current_processing_message_id = None - - return self._build_result( - success=True, - message="processed", - has_reply=execution_result["has_reply"], - thought=parsed_response.thought, - pending_messages_processed=processed_count, # V7: 返回处理了多少条pending消息 - ) - - async def _record_user_message( - self, - session: KokoroSession, - message: "DatabaseMessages", - ) -> None: - """记录用户消息到会话历史""" - content = self._extract_message_content(message) - session.last_user_message = content - - entry = MentalLogEntry( - event_type=MentalLogEventType.USER_MESSAGE, - timestamp=message.time or time.time(), - thought="", # 用户消息不需要内心独白 - content=content, - metadata={ - "message_id": str(message.message_id), - "user_id": str(message.user_info.user_id), - "user_name": message.user_info.user_nickname, - }, - ) - session.add_mental_log_entry(entry) - - def _extract_message_content(self, message: "DatabaseMessages") -> str: - """提取消息内容""" - return ( - message.processed_plain_text - or message.display_message - or "" - ) - - async def _call_llm( - self, - system_prompt: str, - user_prompt: str, - ) -> str: - """ - 调用LLM生成响应 - - Args: - system_prompt: 系统提示词 - user_prompt: 用户提示词 - - Returns: - LLM的响应文本 - """ - try: - # 获取模型配置 - # 使用 replyer 任务的模型配置(KFC 生成回复,必须使用回复专用模型) - if model_config is None: - raise RuntimeError("model_config 未初始化") - task_config = model_config.model_task_config.replyer - - llm_request = LLMRequest( - model_set=task_config, - request_type="kokoro_flow_chatter", - ) - - # 构建完整的提示词(将系统提示词和用户提示词合并) - full_prompt = f"{system_prompt}\n\n{user_prompt}" - - # INFO日志:打印完整的KFC提示词(可观测性增强) - logger.info( - f"Final KFC prompt constructed for stream {self.stream_id}:\n" - f"--- PROMPT START ---\n" - f"[SYSTEM]\n{system_prompt}\n\n[USER]\n{user_prompt}\n" - f"--- PROMPT END ---" - ) - - # 生成响应 - response, _ = await llm_request.generate_response_async( - prompt=full_prompt, - ) - - # INFO日志:打印原始JSON响应(可观测性增强) - logger.info( - f"Raw JSON response from LLM for stream {self.stream_id}:\n" - f"--- JSON START ---\n" - f"{response}\n" - f"--- JSON END ---" - ) - - logger.info(f"[KFC] LLM响应长度: {len(response)}") - return response - - except Exception as e: - logger.error(f"[KFC] 调用LLM失败: {e}") - # 返回一个默认的JSON响应 - return '{"thought": "出现了技术问题", "expected_user_reaction": "", "max_wait_seconds": 60, "actions": [{"type": "do_nothing"}]}' - - async def _get_chat_stream(self, stream_id: Optional[str] = None): - """ - 获取聊天流对象 - - Args: - stream_id: 可选的stream_id,若不提供则使用self.stream_id - 在超时回调中应使用session.stream_id以避免发送到错误的用户 - """ - target_stream_id = stream_id or self.stream_id - try: - from src.chat.message_receive.chat_stream import get_chat_manager - - chat_manager = get_chat_manager() - if chat_manager: - return await chat_manager.get_stream(target_stream_id) - except Exception as e: - logger.warning(f"[KFC] 获取chat_stream失败 (stream_id={target_stream_id}): {e}") - return None - - async def _on_session_timeout(self, session: KokoroSession) -> None: - """ - 会话超时回调(V7:增加连续追问限制) - - 当等待超时时,触发后续决策流程 - - 注意:此回调由全局调度器触发,可能会在任意Chatter实例上执行。 - 因此必须使用session.stream_id而非self.stream_id来确保消息发送给正确的用户。 - - Args: - session: 超时的会话 - """ - logger.info(f"[KFC] 处理超时决策: user={session.user_id}, stream_id={session.stream_id}, followup_count={session.consecutive_followup_count}") - self.stats["timeout_decisions"] += 1 - - try: - # V7: 检查是否超过最大连续追问次数 - if session.consecutive_followup_count >= session.max_consecutive_followups: - logger.info( - f"[KFC] 已达到最大连续追问次数 ({session.max_consecutive_followups})," - f"自动返回IDLE状态: user={session.user_id}" - ) - session.status = SessionStatus.IDLE - session.end_waiting() - # 重置连续追问计数(下次用户回复后会重新开始) - session.consecutive_followup_count = 0 - await self.session_manager.save_session(session.user_id) - return - - # 关键修复:使用 session 的 stream_id 创建正确的 ActionExecutor - # 因为全局调度器的回调可能在任意 Chatter 实例上执行 - from .action_executor import ActionExecutor - timeout_action_executor = ActionExecutor(session.stream_id) - - # V2: 加载可用动作 - available_actions = await timeout_action_executor.load_actions() - - # 生成超时决策提示词(V2: 传递可用动作,V7: 传递连续追问信息) - system_prompt, user_prompt = self.prompt_generator.generate_timeout_decision_prompt( - session, - available_actions=available_actions, - ) - - # 调用LLM - llm_response = await self._call_llm(system_prompt, user_prompt) - self.stats["llm_calls"] += 1 - - # 解析响应 - parsed_response = timeout_action_executor.parse_llm_response(llm_response) - - # 关键修复:使用 session.stream_id 获取正确的 chat_stream - chat_stream = await self._get_chat_stream(session.stream_id) - execution_result = await timeout_action_executor.execute_actions( - parsed_response, - session, - chat_stream - ) - - # 更新会话状态 - if execution_result["has_reply"]: - # V7: 发送了后续消息,增加连续追问计数 - session.consecutive_followup_count += 1 - logger.info(f"[KFC] 发送追问消息,当前连续追问次数: {session.consecutive_followup_count}") - - # 如果发送了后续消息,重新进入等待 - session.start_waiting( - expected_reaction=parsed_response.expected_user_reaction, - max_wait=parsed_response.max_wait_seconds - ) - else: - # V7重构:do_nothing 的两种情况 - # 1. max_wait_seconds > 0: "看了一眼手机,决定再等等" → 继续等待,不算追问 - # 2. max_wait_seconds = 0: "算了,不等了" → 进入 IDLE - if parsed_response.max_wait_seconds > 0: - # 继续等待,不增加追问计数 - logger.info( - f"[KFC] 决定继续等待 {parsed_response.max_wait_seconds}s," - f"不算追问: user={session.user_id}" - ) - session.start_waiting( - expected_reaction=parsed_response.expected_user_reaction or session.expected_user_reaction, - max_wait=parsed_response.max_wait_seconds - ) - else: - # 不再等待,进入 IDLE - logger.info(f"[KFC] 决定不再等待,返回IDLE: user={session.user_id}") - session.status = SessionStatus.IDLE - session.end_waiting() - - # 保存会话 - await self.session_manager.save_session(session.user_id) - - except Exception as e: - logger.error(f"[KFC] 超时决策处理失败: {e}") - # 发生错误时返回空闲状态 - session.status = SessionStatus.IDLE - session.end_waiting() - await self.session_manager.save_session(session.user_id) - - async def _on_continuous_thinking(self, session: KokoroSession) -> None: - """ - 连续思考回调(V2升级版) - - 在等待期间更新心理状态,可选择调用LLM生成更自然的想法 - V2: 支持通过配置启用LLM驱动的连续思考 - - Args: - session: 会话 - """ - logger.debug(f"[KFC] 连续思考触发: user={session.user_id}") - - # 检查是否启用LLM驱动的连续思考 - use_llm_thinking = self.get_config( - "behavior.use_llm_continuous_thinking", - default=False - ) - - if use_llm_thinking and isinstance(use_llm_thinking, bool) and use_llm_thinking: - try: - # V2: 加载可用动作 - available_actions = await self.action_executor.load_actions() - - # 生成连续思考提示词 - system_prompt, user_prompt = self.prompt_generator.generate_continuous_thinking_prompt( - session, - available_actions=available_actions, - ) - - # 调用LLM - llm_response = await self._call_llm(system_prompt, user_prompt) - self.stats["llm_calls"] += 1 - - # 解析并执行(可能会更新内部状态) - parsed_response = self.action_executor.parse_llm_response(llm_response) - - # 只执行内部动作,不执行外部动作 - for action in parsed_response.actions: - if action.type == "update_internal_state": - await self.action_executor._execute_internal_action(action, session) - - # 记录思考内容 - entry = MentalLogEntry( - event_type=MentalLogEventType.CONTINUOUS_THINKING, - timestamp=time.time(), - thought=parsed_response.thought, - content="", - emotional_snapshot=session.emotional_state.to_dict(), - ) - session.add_mental_log_entry(entry) - - # 保存会话 - await self.session_manager.save_session(session.user_id) - - except Exception as e: - logger.warning(f"[KFC] LLM连续思考失败: {e}") - - # 简单模式:更新焦虑程度(已在scheduler中处理) - # 这里可以添加额外的逻辑 - - async def _on_proactive_thinking(self, session: KokoroSession, trigger_reason: str) -> None: - """ - 主动思考回调 - - 当长时间沉默后触发,让 LLM 决定是否主动联系用户。 - 这不是"必须发消息",而是"想一想要不要联系对方"。 - - Args: - session: 会话 - trigger_reason: 触发原因描述 - """ - logger.info(f"[KFC] 处理主动思考: user={session.user_id}, reason={trigger_reason}") - - try: - # 创建正确的 ActionExecutor(使用 session 的 stream_id) - from .action_executor import ActionExecutor - proactive_action_executor = ActionExecutor(session.stream_id) - - # 加载可用动作 - available_actions = await proactive_action_executor.load_actions() - - # 获取 chat_stream 用于构建上下文 - chat_stream = await self._get_chat_stream(session.stream_id) - - # 构建 S4U 上下文数据(包含全局关系信息) - context_data: dict[str, str] = {} - if chat_stream: - try: - from .context_builder import KFCContextBuilder - context_builder = KFCContextBuilder(chat_stream) - context_data = await context_builder.build_all_context( - sender_name=session.user_id, # 主动思考时用 user_id - target_message="", # 没有目标消息 - context=None, - ) - logger.debug(f"[KFC] 主动思考上下文构建完成: {list(context_data.keys())}") - except Exception as e: - logger.warning(f"[KFC] 主动思考构建S4U上下文失败: {e}") - - # 生成主动思考提示词(传入 context_data 以获取全局关系信息) - system_prompt, user_prompt = self.prompt_generator.generate_proactive_thinking_prompt( - session, - trigger_context=trigger_reason, - available_actions=available_actions, - context_data=context_data, - chat_stream=chat_stream, - ) - - # 调用 LLM - llm_response = await self._call_llm(system_prompt, user_prompt) - self.stats["llm_calls"] += 1 - - # 解析响应 - parsed_response = proactive_action_executor.parse_llm_response(llm_response) - - # 检查是否决定不打扰(do_nothing) - is_do_nothing = ( - len(parsed_response.actions) == 0 or - (len(parsed_response.actions) == 1 and parsed_response.actions[0].type == "do_nothing") - ) - - if is_do_nothing: - logger.info(f"[KFC] 主动思考决定不打扰: user={session.user_id}, thought={parsed_response.thought[:50]}...") - # 记录这次"决定不打扰"的思考 - entry = MentalLogEntry( - event_type=MentalLogEventType.PROACTIVE_THINKING, - timestamp=time.time(), - thought=parsed_response.thought, - content="决定不打扰", - emotional_snapshot=session.emotional_state.to_dict(), - metadata={"trigger_reason": trigger_reason, "action": "do_nothing"}, - ) - session.add_mental_log_entry(entry) - await self.session_manager.save_session(session.user_id) - return - - # 执行决定的动作 - execution_result = await proactive_action_executor.execute_actions( - parsed_response, - session, - chat_stream - ) - - logger.info(f"[KFC] 主动思考执行完成: user={session.user_id}, has_reply={execution_result.get('has_reply')}") - - # 如果发送了消息,进入等待状态 - if execution_result.get("has_reply"): - session.start_waiting( - expected_reaction=parsed_response.expected_user_reaction, - max_wait=parsed_response.max_wait_seconds - ) - - # 保存会话 - await self.session_manager.save_session(session.user_id) - - except Exception as e: - logger.error(f"[KFC] 主动思考处理失败: {e}") - import traceback - traceback.print_exc() - - def _build_result( - self, - success: bool, - message: str = "", - error: bool = False, - **kwargs, - ) -> dict: - """构建返回结果""" - result = { - "success": success, - "stream_id": self.stream_id, - "message": message, - "error": error, - "timestamp": time.time(), - } - result.update(kwargs) - return result - - def get_stats(self) -> dict[str, Any]: - """获取统计信息""" - return { - **self.stats, - "last_activity_time": self.last_activity_time, - "action_executor_stats": self.action_executor.get_execution_stats(), - } - - def reset_stats(self) -> None: - """重置统计信息""" - self.stats = { - "messages_processed": 0, - "llm_calls": 0, - "successful_responses": 0, - "failed_responses": 0, - "timeout_decisions": 0, - "interrupts": 0, # V7: 打断次数统计 - } - self.action_executor.reset_stats() - - async def get_session_info(self) -> Optional[dict]: - """获取当前会话信息(用于调试)""" - try: - # 尝试获取当前用户的会话 - sessions = await self.session_manager.get_all_waiting_sessions() - for session in sessions: - if session.stream_id == self.stream_id: - return session.to_dict() - except Exception as e: - logger.error(f"获取会话信息失败: {e}") - return None - - def __str__(self) -> str: - """字符串表示""" - return f"KokoroFlowChatter(stream_id={self.stream_id})" - - def __repr__(self) -> str: - """详细字符串表示""" - return ( - f"KokoroFlowChatter(stream_id={self.stream_id}, " - f"messages_processed={self.stats['messages_processed']})" - ) diff --git a/src/plugins/built_in/kokoro_flow_chatter/config.py b/src/plugins/built_in/kokoro_flow_chatter/config.py deleted file mode 100644 index 0fc3bc30b..000000000 --- a/src/plugins/built_in/kokoro_flow_chatter/config.py +++ /dev/null @@ -1,251 +0,0 @@ -""" -Kokoro Flow Chatter 插件配置 - -定义插件的配置项和默认值 -""" - -from dataclasses import dataclass, field -from typing import Any - - -@dataclass -class PersonaConfig: - """人设配置""" - description: str = """你是一个温暖、真诚的对话伙伴。 -你有自己的情感和想法,会真实地表达内心的感受。 -你关心与用户的关系,但也保持健康的边界。 -你可以感到开心、好奇、担心、或者稍微沮丧,这些都是正常的。""" - - name: str = "心流助手" - """AI的名字""" - - -@dataclass -class BehaviorConfig: - """行为配置""" - max_wait_seconds_default: int = 300 - """默认最大等待秒数""" - - min_wait_seconds: int = 60 - """最小等待秒数""" - - max_wait_seconds_limit: int = 600 - """最大等待秒数上限""" - - enable_continuous_thinking: bool = True - """是否启用连续思考""" - - continuous_thinking_triggers: list[float] = field( - default_factory=lambda: [0.3, 0.6, 0.85] - ) - """连续思考触发点(等待进度百分比)""" - - scheduler_check_interval: float = 10.0 - """调度器检查间隔(秒)""" - - -@dataclass -class SessionConfig: - """会话配置""" - data_dir: str = "data/kokoro_flow_chatter/sessions" - """会话数据存储目录""" - - max_session_age_days: int = 30 - """会话最大保留天数""" - - auto_save_interval: int = 300 - """自动保存间隔(秒)""" - - max_mental_log_size: int = 100 - """心理日志最大条目数""" - - -@dataclass -class LLMConfig: - """LLM配置""" - model_name: str = "" - """使用的模型名称,留空则使用默认主模型""" - - max_tokens: int = 2048 - """最大生成token数""" - - temperature: float = 0.8 - """生成温度""" - - -@dataclass -class EmotionalConfig: - """情感系统配置""" - initial_mood: str = "neutral" - """初始心情""" - - initial_mood_intensity: float = 0.5 - """初始心情强度""" - - initial_relationship_warmth: float = 0.5 - """初始关系热度""" - - anxiety_increase_rate: float = 0.5 - """焦虑增长率(平方根系数)""" - - -@dataclass -class KokoroFlowChatterConfig: - """心流聊天器完整配置""" - enabled: bool = True - """是否启用插件""" - - persona: PersonaConfig = field(default_factory=PersonaConfig) - """人设配置""" - - behavior: BehaviorConfig = field(default_factory=BehaviorConfig) - """行为配置""" - - session: SessionConfig = field(default_factory=SessionConfig) - """会话配置""" - - llm: LLMConfig = field(default_factory=LLMConfig) - """LLM配置""" - - emotional: EmotionalConfig = field(default_factory=EmotionalConfig) - """情感系统配置""" - - def to_dict(self) -> dict[str, Any]: - """转换为字典""" - return { - "enabled": self.enabled, - "persona": { - "description": self.persona.description, - "name": self.persona.name, - }, - "behavior": { - "max_wait_seconds_default": self.behavior.max_wait_seconds_default, - "min_wait_seconds": self.behavior.min_wait_seconds, - "max_wait_seconds_limit": self.behavior.max_wait_seconds_limit, - "enable_continuous_thinking": self.behavior.enable_continuous_thinking, - "continuous_thinking_triggers": self.behavior.continuous_thinking_triggers, - "scheduler_check_interval": self.behavior.scheduler_check_interval, - }, - "session": { - "data_dir": self.session.data_dir, - "max_session_age_days": self.session.max_session_age_days, - "auto_save_interval": self.session.auto_save_interval, - "max_mental_log_size": self.session.max_mental_log_size, - }, - "llm": { - "model_name": self.llm.model_name, - "max_tokens": self.llm.max_tokens, - "temperature": self.llm.temperature, - }, - "emotional": { - "initial_mood": self.emotional.initial_mood, - "initial_mood_intensity": self.emotional.initial_mood_intensity, - "initial_relationship_warmth": self.emotional.initial_relationship_warmth, - "anxiety_increase_rate": self.emotional.anxiety_increase_rate, - }, - } - - @classmethod - def from_dict(cls, data: dict[str, Any]) -> "KokoroFlowChatterConfig": - """从字典创建配置""" - config = cls() - - if "enabled" in data: - config.enabled = data["enabled"] - - if "persona" in data: - persona_data = data["persona"] - config.persona.description = persona_data.get( - "description", - config.persona.description - ) - config.persona.name = persona_data.get( - "name", - config.persona.name - ) - - if "behavior" in data: - behavior_data = data["behavior"] - config.behavior.max_wait_seconds_default = behavior_data.get( - "max_wait_seconds_default", - config.behavior.max_wait_seconds_default - ) - config.behavior.min_wait_seconds = behavior_data.get( - "min_wait_seconds", - config.behavior.min_wait_seconds - ) - config.behavior.max_wait_seconds_limit = behavior_data.get( - "max_wait_seconds_limit", - config.behavior.max_wait_seconds_limit - ) - config.behavior.enable_continuous_thinking = behavior_data.get( - "enable_continuous_thinking", - config.behavior.enable_continuous_thinking - ) - config.behavior.continuous_thinking_triggers = behavior_data.get( - "continuous_thinking_triggers", - config.behavior.continuous_thinking_triggers - ) - config.behavior.scheduler_check_interval = behavior_data.get( - "scheduler_check_interval", - config.behavior.scheduler_check_interval - ) - - if "session" in data: - session_data = data["session"] - config.session.data_dir = session_data.get( - "data_dir", - config.session.data_dir - ) - config.session.max_session_age_days = session_data.get( - "max_session_age_days", - config.session.max_session_age_days - ) - config.session.auto_save_interval = session_data.get( - "auto_save_interval", - config.session.auto_save_interval - ) - config.session.max_mental_log_size = session_data.get( - "max_mental_log_size", - config.session.max_mental_log_size - ) - - if "llm" in data: - llm_data = data["llm"] - config.llm.model_name = llm_data.get( - "model_name", - config.llm.model_name - ) - config.llm.max_tokens = llm_data.get( - "max_tokens", - config.llm.max_tokens - ) - config.llm.temperature = llm_data.get( - "temperature", - config.llm.temperature - ) - - if "emotional" in data: - emotional_data = data["emotional"] - config.emotional.initial_mood = emotional_data.get( - "initial_mood", - config.emotional.initial_mood - ) - config.emotional.initial_mood_intensity = emotional_data.get( - "initial_mood_intensity", - config.emotional.initial_mood_intensity - ) - config.emotional.initial_relationship_warmth = emotional_data.get( - "initial_relationship_warmth", - config.emotional.initial_relationship_warmth - ) - config.emotional.anxiety_increase_rate = emotional_data.get( - "anxiety_increase_rate", - config.emotional.anxiety_increase_rate - ) - - return config - - -# 默认配置实例 -default_config = KokoroFlowChatterConfig() diff --git a/src/plugins/built_in/kokoro_flow_chatter/context_builder.py b/src/plugins/built_in/kokoro_flow_chatter/context_builder.py deleted file mode 100644 index 26180c067..000000000 --- a/src/plugins/built_in/kokoro_flow_chatter/context_builder.py +++ /dev/null @@ -1,528 +0,0 @@ -""" -Kokoro Flow Chatter 上下文构建器 - -该模块负责从 S4U 移植的所有上下文模块,为 KFC 提供"全知"Prompt所需的完整情境感知能力。 -包含: -- 关系信息 (relation_info) -- 记忆块 (memory_block) -- 表达习惯 (expression_habits) -- 知识库 (knowledge) -- 跨上下文 (cross_context) -- 日程信息 (schedule) -- 通知块 (notice) -- 历史消息构建 (history) -""" - -import asyncio -import time -from datetime import datetime, timedelta -from typing import TYPE_CHECKING, Any, Optional - -from src.common.logger import get_logger -from src.config.config import global_config -from src.person_info.person_info import get_person_info_manager, PersonInfoManager - -if TYPE_CHECKING: - from src.chat.message_receive.chat_stream import ChatStream - from src.common.data_models.message_manager_data_model import StreamContext - from src.config.config import BotConfig # 用于类型提示 - -logger = get_logger("kfc_context_builder") - - -# 类型断言辅助函数 -def _get_config(): - """获取全局配置(带类型断言)""" - assert global_config is not None, "global_config 未初始化" - return global_config - - -class KFCContextBuilder: - """ - KFC 上下文构建器 - - 从 S4U 的 DefaultReplyer 移植所有上下文构建能力, - 为 KFC 的"超融合"Prompt 提供完整的情境感知数据。 - """ - - def __init__(self, chat_stream: "ChatStream"): - """ - 初始化上下文构建器 - - Args: - chat_stream: 当前聊天流 - """ - self.chat_stream = chat_stream - self.chat_id = chat_stream.stream_id - self.platform = chat_stream.platform - self.is_group_chat = bool(chat_stream.group_info) - - # 延迟初始化的组件 - self._tool_executor: Any = None - self._expression_selector: Any = None - - @property - def tool_executor(self) -> Any: - """延迟初始化工具执行器""" - if self._tool_executor is None: - from src.plugin_system.core.tool_use import ToolExecutor - self._tool_executor = ToolExecutor(chat_id=self.chat_id) - return self._tool_executor - - async def build_all_context( - self, - sender_name: str, - target_message: str, - context: Optional["StreamContext"] = None, - ) -> dict[str, str]: - """ - 并行构建所有上下文模块 - - Args: - sender_name: 发送者名称 - target_message: 目标消息内容 - context: 聊天流上下文(可选) - - Returns: - dict: 包含所有上下文块的字典 - """ - # 获取历史消息用于构建各种上下文 - chat_history = await self._get_chat_history_text(context) - - # 并行执行所有上下文构建任务 - tasks = { - "relation_info": self._build_relation_info(sender_name, target_message), - "memory_block": self._build_memory_block(chat_history, target_message), - "expression_habits": self._build_expression_habits(chat_history, target_message), - "schedule": self._build_schedule_block(), - "time": self._build_time_block(), - } - - results = {} - try: - task_results = await asyncio.gather( - *[self._wrap_task(name, coro) for name, coro in tasks.items()], - return_exceptions=True - ) - - for result in task_results: - if isinstance(result, tuple): - name, value = result - results[name] = value - else: - logger.warning(f"上下文构建任务异常: {result}") - except Exception as e: - logger.error(f"并行构建上下文失败: {e}") - - return results - - async def _wrap_task(self, name: str, coro) -> tuple[str, str]: - """包装任务以返回名称和结果""" - try: - result = await coro - return (name, result or "") - except Exception as e: - logger.error(f"构建 {name} 失败: {e}") - return (name, "") - - async def _get_chat_history_text( - self, - context: Optional["StreamContext"] = None, - limit: int = 20, - ) -> str: - """ - 获取聊天历史文本 - - Args: - context: 聊天流上下文 - limit: 最大消息数量 - - Returns: - str: 格式化的聊天历史 - """ - if context is None: - return "" - - try: - from src.chat.utils.chat_message_builder import build_readable_messages - - messages = context.get_messages(limit=limit, include_unread=True) - if not messages: - return "" - - # 转换为字典格式 - msg_dicts = [msg.flatten() for msg in messages] - - return await build_readable_messages( - msg_dicts, - replace_bot_name=True, - timestamp_mode="relative", - truncate=True, - ) - except Exception as e: - logger.error(f"获取聊天历史失败: {e}") - return "" - - async def _build_relation_info(self, sender_name: str, target_message: str) -> str: - """ - 构建关系信息块 - - 从 S4U 的 build_relation_info 移植 - - Args: - sender_name: 发送者名称 - target_message: 目标消息 - - Returns: - str: 格式化的关系信息 - """ - config = _get_config() - - # 检查是否是Bot自己的消息 - if sender_name == f"{config.bot.nickname}(你)": - return "你将要回复的是你自己发送的消息。" - - person_info_manager = get_person_info_manager() - person_id = await person_info_manager.get_person_id_by_person_name(sender_name) - - if not person_id: - logger.debug(f"未找到用户 {sender_name} 的ID") - return f"你完全不认识{sender_name},这是你们的第一次互动。" - - try: - from src.person_info.relationship_fetcher import relationship_fetcher_manager - - relationship_fetcher = relationship_fetcher_manager.get_fetcher(self.chat_id) - - # 构建用户关系信息(包含别名、偏好关键词等字段) - user_relation_info = await relationship_fetcher.build_relation_info(person_id, points_num=5) - - # 构建聊天流印象信息(群聊/私聊的整体印象) - stream_impression = await relationship_fetcher.build_chat_stream_impression(self.chat_id) - - # 组合信息 - parts = [] - if user_relation_info: - parts.append(f"### 你与 {sender_name} 的关系\n{user_relation_info}") - if stream_impression: - scene_type = "这个群" if self.is_group_chat else "你们的私聊" - parts.append(f"### 你对{scene_type}的印象\n{stream_impression}") - - if parts: - return "\n\n".join(parts) - else: - return f"你与{sender_name}还没有建立深厚的关系,这是早期的互动阶段。" - - except Exception as e: - logger.error(f"获取关系信息失败: {e}") - return self._build_fallback_relation_info(sender_name, person_id) - - def _build_fallback_relation_info(self, sender_name: str, person_id: str) -> str: - """降级的关系信息构建""" - return f"你与{sender_name}是普通朋友关系。" - - async def _build_memory_block(self, chat_history: str, target_message: str) -> str: - """ - 构建记忆块 - - 从 S4U 的 build_memory_block 移植,使用三层记忆系统 - - Args: - chat_history: 聊天历史 - target_message: 目标消息 - - Returns: - str: 格式化的记忆信息 - """ - config = _get_config() - - if not (config.memory and config.memory.enable): - return "" - - try: - from src.memory_graph.manager_singleton import get_unified_memory_manager - from src.memory_graph.utils.three_tier_formatter import memory_formatter - - unified_manager = get_unified_memory_manager() - if not unified_manager: - logger.debug("[三层记忆] 管理器未初始化") - return "" - - # 使用统一管理器的智能检索 - search_result = await unified_manager.search_memories( - query_text=target_message, - use_judge=True, - recent_chat_history=chat_history, - ) - - if not search_result: - return "" - - # 分类记忆块 - perceptual_blocks = search_result.get("perceptual_blocks", []) - short_term_memories = search_result.get("short_term_memories", []) - long_term_memories = search_result.get("long_term_memories", []) - - # 使用三级记忆格式化器 - formatted_memories = await memory_formatter.format_all_tiers( - perceptual_blocks=perceptual_blocks, - short_term_memories=short_term_memories, - long_term_memories=long_term_memories - ) - - total_count = len(perceptual_blocks) + len(short_term_memories) + len(long_term_memories) - if total_count > 0 and formatted_memories.strip(): - logger.info( - f"[三层记忆] 检索到 {total_count} 条记忆 " - f"(感知:{len(perceptual_blocks)}, 短期:{len(short_term_memories)}, 长期:{len(long_term_memories)})" - ) - return f"### 🧠 相关记忆\n\n{formatted_memories}" - - return "" - - except Exception as e: - logger.error(f"[三层记忆] 检索失败: {e}") - return "" - - async def _build_expression_habits(self, chat_history: str, target_message: str) -> str: - """ - 构建表达习惯块 - - 从 S4U 的 build_expression_habits 移植 - - Args: - chat_history: 聊天历史 - target_message: 目标消息 - - Returns: - str: 格式化的表达习惯 - """ - config = _get_config() - - # 检查是否允许使用表达 - use_expression, _, _ = config.expression.get_expression_config_for_chat(self.chat_id) - if not use_expression: - return "" - - try: - from src.chat.express.expression_selector import expression_selector - - style_habits = [] - grammar_habits = [] - - # 使用统一的表达方式选择 - selected_expressions = await expression_selector.select_suitable_expressions( - chat_id=self.chat_id, - chat_history=chat_history, - target_message=target_message, - max_num=8, - min_num=2 - ) - - if selected_expressions: - for expr in selected_expressions: - if isinstance(expr, dict) and "situation" in expr and "style" in expr: - expr_type = expr.get("type", "style") - habit_str = f"当{expr['situation']}时,使用 {expr['style']}" - if expr_type == "grammar": - grammar_habits.append(habit_str) - else: - style_habits.append(habit_str) - - # 构建表达习惯块 - parts = [] - if style_habits: - parts.append("**语言风格习惯**:\n" + "\n".join(f"- {h}" for h in style_habits)) - if grammar_habits: - parts.append("**句法习惯**:\n" + "\n".join(f"- {h}" for h in grammar_habits)) - - if parts: - return "### 💬 你的表达习惯\n\n" + "\n\n".join(parts) - - return "" - - except Exception as e: - logger.error(f"构建表达习惯失败: {e}") - return "" - - async def _build_schedule_block(self) -> str: - """ - 构建日程信息块 - - 从 S4U 移植 - - Returns: - str: 格式化的日程信息 - """ - config = _get_config() - - if not config.planning_system.schedule_enable: - return "" - - try: - from src.schedule.schedule_manager import schedule_manager - - activity_info = schedule_manager.get_current_activity() - if not activity_info: - return "" - - activity = activity_info.get("activity") - time_range = activity_info.get("time_range") - now = datetime.now() - - if time_range: - try: - start_str, end_str = time_range.split("-") - start_time = datetime.strptime(start_str.strip(), "%H:%M").replace( - year=now.year, month=now.month, day=now.day - ) - end_time = datetime.strptime(end_str.strip(), "%H:%M").replace( - year=now.year, month=now.month, day=now.day - ) - - if end_time < start_time: - end_time += timedelta(days=1) - if now < start_time: - now += timedelta(days=1) - - duration_minutes = (now - start_time).total_seconds() / 60 - remaining_minutes = (end_time - now).total_seconds() / 60 - - return ( - f"你当前正在「{activity}」," - f"从{start_time.strftime('%H:%M')}开始,预计{end_time.strftime('%H:%M')}结束," - f"已进行{duration_minutes:.0f}分钟,还剩约{remaining_minutes:.0f}分钟。" - ) - except (ValueError, AttributeError): - pass - - return f"你当前正在「{activity}」" - - except Exception as e: - logger.error(f"构建日程块失败: {e}") - return "" - - async def _build_time_block(self) -> str: - """构建时间信息块""" - now = datetime.now() - weekdays = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"] - weekday = weekdays[now.weekday()] - - return f"{now.strftime('%Y年%m月%d日')} {weekday} {now.strftime('%H:%M:%S')}" - - async def build_s4u_style_history( - self, - context: "StreamContext", - max_read: int = 10, - max_unread: int = 10, - ) -> tuple[str, str]: - """ - 构建 S4U 风格的已读/未读历史消息 - - 从 S4U 的 build_s4u_chat_history_prompts 移植 - - Args: - context: 聊天流上下文 - max_read: 最大已读消息数 - max_unread: 最大未读消息数 - - Returns: - tuple[str, str]: (已读历史, 未读历史) - """ - try: - from src.chat.utils.chat_message_builder import build_readable_messages, replace_user_references_async - - # 确保历史消息已初始化 - await context.ensure_history_initialized() - - read_messages = context.history_messages - unread_messages = context.get_unread_messages() - - # 构建已读历史 - read_history = "" - if read_messages: - read_dicts = [msg.flatten() for msg in read_messages[-max_read:]] - read_content = await build_readable_messages( - read_dicts, - replace_bot_name=True, - timestamp_mode="normal_no_YMD", - truncate=True, - ) - read_history = f"### 📜 已读历史消息\n{read_content}" - - # 构建未读历史 - unread_history = "" - if unread_messages: - unread_lines = [] - for msg in unread_messages[-max_unread:]: - msg_time = time.strftime("%H:%M:%S", time.localtime(msg.time)) - msg_content = msg.processed_plain_text or "" - - # 获取发送者名称 - sender_name = await self._get_sender_name(msg) - - # 处理消息内容中的用户引用 - if msg_content: - msg_content = await replace_user_references_async( - msg_content, - self.platform, - replace_bot_name=True - ) - - unread_lines.append(f"{msg_time} {sender_name}: {msg_content}") - - unread_history = f"### 📬 未读历史消息\n" + "\n".join(unread_lines) - - return read_history, unread_history - - except Exception as e: - logger.error(f"构建S4U风格历史失败: {e}") - return "", "" - - async def _get_sender_name(self, msg) -> str: - """获取消息发送者名称""" - config = _get_config() - - try: - user_info = getattr(msg, "user_info", {}) - platform = getattr(user_info, "platform", "") or getattr(msg, "platform", "") - user_id = getattr(user_info, "user_id", "") or getattr(msg, "user_id", "") - - if not (platform and user_id): - return "未知用户" - - person_id = PersonInfoManager.get_person_id(platform, user_id) - person_info_manager = get_person_info_manager() - sender_name = await person_info_manager.get_value(person_id, "person_name") or "未知用户" - - # 如果是Bot自己,标记为(你) - if user_id == str(config.bot.qq_account): - sender_name = f"{config.bot.nickname}(你)" - - return sender_name - - except Exception: - return "未知用户" - - -# 模块级便捷函数 -async def build_kfc_context( - chat_stream: "ChatStream", - sender_name: str, - target_message: str, - context: Optional["StreamContext"] = None, -) -> dict[str, str]: - """ - 便捷函数:构建KFC所需的所有上下文 - - Args: - chat_stream: 聊天流 - sender_name: 发送者名称 - target_message: 目标消息 - context: 聊天流上下文 - - Returns: - dict: 包含所有上下文块的字典 - """ - builder = KFCContextBuilder(chat_stream) - return await builder.build_all_context(sender_name, target_message, context) diff --git a/src/plugins/built_in/kokoro_flow_chatter/kfc_scheduler_adapter.py b/src/plugins/built_in/kokoro_flow_chatter/kfc_scheduler_adapter.py deleted file mode 100644 index f512e8c67..000000000 --- a/src/plugins/built_in/kokoro_flow_chatter/kfc_scheduler_adapter.py +++ /dev/null @@ -1,707 +0,0 @@ -""" -Kokoro Flow Chatter 调度器适配器 - -基于项目统一的 UnifiedScheduler 实现 KFC 的定时任务功能。 -不再自己创建后台循环,而是复用全局调度器的基础设施。 - -核心功能: -1. 会话等待超时检测(短期) -2. 连续思考触发(等待期间的内心活动) -3. 主动思考检测(长期沉默后主动发起对话) -4. 与 UnifiedScheduler 的集成 -""" - -import asyncio -import time -from datetime import datetime -from typing import TYPE_CHECKING, Any, Callable, Coroutine, Optional - -from src.common.logger import get_logger -from src.config.config import global_config -from src.plugin_system.apis.unified_scheduler import ( - TriggerType, - unified_scheduler, -) - -from .models import ( - KokoroSession, - MentalLogEntry, - MentalLogEventType, - SessionStatus, -) -from .session_manager import get_session_manager - -if TYPE_CHECKING: - from .chatter import KokoroFlowChatter - -logger = get_logger("kokoro_scheduler_adapter") - - -class KFCSchedulerAdapter: - """ - KFC 调度器适配器 - - 使用 UnifiedScheduler 实现 KFC 的定时任务功能,不再自行管理后台循环。 - - 核心功能: - 1. 定期检查处于 WAITING 状态的会话(短期等待超时) - 2. 在特定时间点触发"连续思考"(等待期间内心活动) - 3. 定期检查长期沉默的会话,触发"主动思考"(长期主动发起) - 4. 处理等待超时并触发决策 - """ - - # 连续思考触发点(等待进度的百分比) - CONTINUOUS_THINKING_TRIGGERS = [0.3, 0.6, 0.85] - - # 任务名称常量 - TASK_NAME_WAITING_CHECK = "kfc_waiting_check" - TASK_NAME_PROACTIVE_CHECK = "kfc_proactive_check" - - # 主动思考检查间隔(5分钟) - PROACTIVE_CHECK_INTERVAL = 300.0 - - def __init__( - self, - check_interval: float = 10.0, - on_timeout_callback: Optional[Callable[[KokoroSession], Coroutine[Any, Any, None]]] = None, - on_continuous_thinking_callback: Optional[Callable[[KokoroSession], Coroutine[Any, Any, None]]] = None, - on_proactive_thinking_callback: Optional[Callable[[KokoroSession, str], Coroutine[Any, Any, None]]] = None, - ): - """ - 初始化调度器适配器 - - Args: - check_interval: 等待检查间隔(秒) - on_timeout_callback: 超时回调函数 - on_continuous_thinking_callback: 连续思考回调函数 - on_proactive_thinking_callback: 主动思考回调函数,接收 (session, trigger_reason) - """ - self.check_interval = check_interval - self.on_timeout_callback = on_timeout_callback - self.on_continuous_thinking_callback = on_continuous_thinking_callback - self.on_proactive_thinking_callback = on_proactive_thinking_callback - - self._registered = False - self._schedule_id: Optional[str] = None - self._proactive_schedule_id: Optional[str] = None - - # 加载主动思考配置 - self._load_proactive_config() - - # 统计信息 - self._stats = { - "total_checks": 0, - "timeouts_triggered": 0, - "continuous_thinking_triggered": 0, - "proactive_thinking_triggered": 0, - "proactive_checks": 0, - "last_check_time": 0.0, - } - - logger.info("KFCSchedulerAdapter 初始化完成") - - def _load_proactive_config(self) -> None: - """加载主动思考相关配置""" - try: - if global_config and hasattr(global_config, 'kokoro_flow_chatter'): - proactive_cfg = global_config.kokoro_flow_chatter.proactive_thinking - self.proactive_enabled = proactive_cfg.enabled - self.silence_threshold = proactive_cfg.silence_threshold_seconds - self.min_interval = proactive_cfg.min_interval_between_proactive - self.min_affinity = getattr(proactive_cfg, 'min_affinity_for_proactive', 0.3) - self.quiet_hours_start = getattr(proactive_cfg, 'quiet_hours_start', "23:00") - self.quiet_hours_end = getattr(proactive_cfg, 'quiet_hours_end', "07:00") - else: - # 默认值 - self.proactive_enabled = True - self.silence_threshold = 7200 # 2小时 - self.min_interval = 1800 # 30分钟 - self.min_affinity = 0.3 - self.quiet_hours_start = "23:00" - self.quiet_hours_end = "07:00" - except Exception as e: - logger.warning(f"加载主动思考配置失败,使用默认值: {e}") - self.proactive_enabled = True - self.silence_threshold = 7200 - self.min_interval = 1800 - self.min_affinity = 0.3 - self.quiet_hours_start = "23:00" - self.quiet_hours_end = "07:00" - - async def start(self) -> None: - """启动调度器(注册到 UnifiedScheduler)""" - if self._registered: - logger.warning("KFC 调度器已在运行中") - return - - # 注册周期性等待检查任务(每10秒) - self._schedule_id = await unified_scheduler.create_schedule( - callback=self._check_waiting_sessions, - trigger_type=TriggerType.TIME, - trigger_config={"delay_seconds": self.check_interval}, - is_recurring=True, - task_name=self.TASK_NAME_WAITING_CHECK, - force_overwrite=True, - timeout=30.0, - ) - - # 如果启用了主动思考,注册主动思考检查任务(每5分钟) - if self.proactive_enabled: - self._proactive_schedule_id = await unified_scheduler.create_schedule( - callback=self._check_proactive_sessions, - trigger_type=TriggerType.TIME, - trigger_config={"delay_seconds": self.PROACTIVE_CHECK_INTERVAL}, - is_recurring=True, - task_name=self.TASK_NAME_PROACTIVE_CHECK, - force_overwrite=True, - timeout=120.0, # 主动思考可能需要更长时间(涉及 LLM 调用) - ) - logger.info(f"KFC 主动思考调度已注册: schedule_id={self._proactive_schedule_id}") - - self._registered = True - logger.info(f"KFC 调度器已注册到 UnifiedScheduler: schedule_id={self._schedule_id}") - - async def stop(self) -> None: - """停止调度器(从 UnifiedScheduler 注销)""" - if not self._registered: - return - - try: - if self._schedule_id: - await unified_scheduler.remove_schedule(self._schedule_id) - logger.info(f"KFC 等待检查调度已注销: schedule_id={self._schedule_id}") - if self._proactive_schedule_id: - await unified_scheduler.remove_schedule(self._proactive_schedule_id) - logger.info(f"KFC 主动思考调度已注销: schedule_id={self._proactive_schedule_id}") - except Exception as e: - logger.error(f"停止 KFC 调度器时出错: {e}") - finally: - self._registered = False - self._schedule_id = None - self._proactive_schedule_id = None - - async def _check_waiting_sessions(self) -> None: - """检查所有等待中的会话(由 UnifiedScheduler 调用) - - 优化:使用 asyncio.create_task 并行处理多个会话,避免顺序阻塞 - """ - session_manager = get_session_manager() - waiting_sessions = await session_manager.get_all_waiting_sessions() - - self._stats["total_checks"] += 1 - self._stats["last_check_time"] = time.time() - - if not waiting_sessions: - return - - # 并行处理所有等待中的会话,避免一个会话阻塞其他会话 - tasks = [] - for session in waiting_sessions: - task = asyncio.create_task( - self._safe_process_waiting_session(session), - name=f"kfc_session_check_{session.user_id}" - ) - tasks.append(task) - - # 等待所有任务完成,但每个任务都有独立的异常处理 - if tasks: - await asyncio.gather(*tasks, return_exceptions=True) - - async def _safe_process_waiting_session(self, session: KokoroSession) -> None: - """安全地处理等待会话,带有超时保护""" - try: - # 给每个会话处理设置 60 秒超时(LLM 调用可能需要较长时间) - await asyncio.wait_for( - self._process_waiting_session(session), - timeout=60.0 - ) - except asyncio.TimeoutError: - logger.error(f"处理等待会话 {session.user_id} 超时(60秒)") - except Exception as e: - logger.error(f"处理等待会话 {session.user_id} 时出错: {e}") - - async def _process_waiting_session(self, session: KokoroSession) -> None: - """ - 处理单个等待中的会话 - - Args: - session: 等待中的会话 - """ - if session.status != SessionStatus.WAITING: - return - - if session.waiting_since is None: - return - - wait_duration = session.get_waiting_duration() - max_wait = session.max_wait_seconds - - # max_wait_seconds = 0 表示不等待,直接返回 IDLE - if max_wait <= 0: - logger.info(f"会话 {session.user_id} 设置为不等待 (max_wait=0),返回空闲状态") - session.status = SessionStatus.IDLE - session.end_waiting() - session_manager = get_session_manager() - await session_manager.save_session(session.user_id) - return - - # 检查是否超时 - if session.is_wait_timeout(): - logger.info(f"会话 {session.user_id} 等待超时,触发决策") - await self._handle_timeout(session) - return - - # 检查是否需要触发连续思考 - wait_progress = wait_duration / max_wait if max_wait > 0 else 0 - - for trigger_point in self.CONTINUOUS_THINKING_TRIGGERS: - if self._should_trigger_continuous_thinking(session, wait_progress, trigger_point): - logger.debug( - f"会话 {session.user_id} 触发连续思考 " - f"(进度: {wait_progress:.1%}, 触发点: {trigger_point:.1%})" - ) - await self._handle_continuous_thinking(session, wait_progress) - break - - def _should_trigger_continuous_thinking( - self, - session: KokoroSession, - current_progress: float, - trigger_point: float, - ) -> bool: - """ - 判断是否应该触发连续思考 - """ - if current_progress < trigger_point: - return False - - expected_count = sum( - 1 for tp in self.CONTINUOUS_THINKING_TRIGGERS - if current_progress >= tp - ) - - if session.continuous_thinking_count < expected_count: - if session.last_continuous_thinking_at is None: - return True - - time_since_last = time.time() - session.last_continuous_thinking_at - return time_since_last >= 30.0 - - return False - - async def _handle_timeout(self, session: KokoroSession) -> None: - """ - 处理等待超时 - - Args: - session: 超时的会话 - """ - self._stats["timeouts_triggered"] += 1 - - # 更新会话状态 - session.status = SessionStatus.FOLLOW_UP_PENDING - session.emotional_state.anxiety_level = 0.8 - - # 添加超时日志 - timeout_entry = MentalLogEntry( - event_type=MentalLogEventType.TIMEOUT_DECISION, - timestamp=time.time(), - thought=f"等了{session.max_wait_seconds}秒了,对方还是没有回复...", - content="等待超时", - emotional_snapshot=session.emotional_state.to_dict(), - ) - session.add_mental_log_entry(timeout_entry) - - # 保存会话状态 - session_manager = get_session_manager() - await session_manager.save_session(session.user_id) - - # 调用超时回调 - if self.on_timeout_callback: - try: - await self.on_timeout_callback(session) - except Exception as e: - logger.error(f"执行超时回调时出错 (user={session.user_id}): {e}") - - async def _handle_continuous_thinking( - self, - session: KokoroSession, - wait_progress: float, - ) -> None: - """ - 处理连续思考 - - Args: - session: 会话 - wait_progress: 等待进度 - """ - self._stats["continuous_thinking_triggered"] += 1 - - # 更新焦虑程度 - session.emotional_state.update_anxiety_over_time( - session.get_waiting_duration(), - session.max_wait_seconds - ) - - # 更新连续思考计数 - session.continuous_thinking_count += 1 - session.last_continuous_thinking_at = time.time() - - # 生成基于进度的内心想法 - thought = self._generate_waiting_thought(session, wait_progress) - - # 添加连续思考日志 - thinking_entry = MentalLogEntry( - event_type=MentalLogEventType.CONTINUOUS_THINKING, - timestamp=time.time(), - thought=thought, - content="", - emotional_snapshot=session.emotional_state.to_dict(), - metadata={"wait_progress": wait_progress}, - ) - session.add_mental_log_entry(thinking_entry) - - # 保存会话状态 - session_manager = get_session_manager() - await session_manager.save_session(session.user_id) - - # 调用连续思考回调 - if self.on_continuous_thinking_callback: - try: - await self.on_continuous_thinking_callback(session) - except Exception as e: - logger.error(f"执行连续思考回调时出错 (user={session.user_id}): {e}") - - def _generate_waiting_thought( - self, - session: KokoroSession, - wait_progress: float, - ) -> str: - """ - 生成等待中的内心想法(简单版本,不调用LLM) - """ - import random - - wait_seconds = session.get_waiting_duration() - wait_minutes = wait_seconds / 60 - - if wait_progress < 0.4: - thoughts = [ - f"已经等了{wait_minutes:.1f}分钟了,对方可能在忙吧...", - f"嗯...{wait_minutes:.1f}分钟过去了,不知道对方在做什么", - "对方好像还没看到消息,再等等吧", - ] - elif wait_progress < 0.7: - thoughts = [ - f"等了{wait_minutes:.1f}分钟了,有点担心对方是不是不想回了", - f"{wait_minutes:.1f}分钟了,对方可能真的很忙?", - "时间过得好慢啊...不知道对方什么时候会回复", - ] - else: - thoughts = [ - f"已经等了{wait_minutes:.1f}分钟了,感觉有点焦虑...", - f"快{wait_minutes:.0f}分钟了,对方是不是忘记回复了?", - "等了这么久,要不要主动说点什么呢...", - ] - - return random.choice(thoughts) - - # ======================================== - # 主动思考相关方法(长期沉默后主动发起对话) - # ======================================== - - async def _check_proactive_sessions(self) -> None: - """ - 检查所有会话是否需要触发主动思考(由 UnifiedScheduler 定期调用) - - 主动思考的触发条件: - 1. 会话处于 IDLE 状态(不在等待回复中) - 2. 距离上次活动超过 silence_threshold - 3. 距离上次主动思考超过 min_interval - 4. 不在勿扰时段 - 5. 与用户的关系亲密度足够 - """ - if not self.proactive_enabled: - return - - # 检查是否在勿扰时段 - if self._is_quiet_hours(): - logger.debug("[KFC] 当前处于勿扰时段,跳过主动思考检查") - return - - self._stats["proactive_checks"] += 1 - - session_manager = get_session_manager() - all_sessions = await session_manager.get_all_sessions() - - current_time = time.time() - - for session in all_sessions: - try: - # 检查是否满足主动思考条件(异步获取全局关系分数) - trigger_reason = await self._should_trigger_proactive(session, current_time) - if trigger_reason: - logger.info( - f"[KFC] 触发主动思考: user={session.user_id}, reason={trigger_reason}" - ) - await self._handle_proactive_thinking(session, trigger_reason) - except Exception as e: - logger.error(f"检查主动思考条件时出错 (user={session.user_id}): {e}") - - def _is_quiet_hours(self) -> bool: - """ - 检查当前是否处于勿扰时段 - - 支持跨午夜的时段(如 23:00 到 07:00) - """ - try: - now = datetime.now() - current_minutes = now.hour * 60 + now.minute - - # 解析开始时间 - start_parts = self.quiet_hours_start.split(":") - start_minutes = int(start_parts[0]) * 60 + int(start_parts[1]) - - # 解析结束时间 - end_parts = self.quiet_hours_end.split(":") - end_minutes = int(end_parts[0]) * 60 + int(end_parts[1]) - - # 处理跨午夜的情况 - if start_minutes <= end_minutes: - # 不跨午夜(如 09:00 到 17:00) - return start_minutes <= current_minutes < end_minutes - else: - # 跨午夜(如 23:00 到 07:00) - return current_minutes >= start_minutes or current_minutes < end_minutes - - except Exception as e: - logger.warning(f"解析勿扰时段配置失败: {e}") - return False - - async def _should_trigger_proactive( - self, - session: KokoroSession, - current_time: float - ) -> Optional[str]: - """ - 检查是否应该触发主动思考 - - 使用全局关系数据库中的关系分数(而不是 KFC 内部的 emotional_state) - - 概率机制:关系越亲密,触发概率越高 - - 亲密度 0.3 → 触发概率 10% - - 亲密度 0.5 → 触发概率 30% - - 亲密度 0.7 → 触发概率 55% - - 亲密度 1.0 → 触发概率 90% - - Args: - session: 会话 - current_time: 当前时间戳 - - Returns: - 触发原因字符串,如果不触发则返回 None - """ - import random - - # 条件1:必须处于 IDLE 状态 - if session.status != SessionStatus.IDLE: - return None - - # 条件2:距离上次活动超过沉默阈值 - silence_duration = current_time - session.last_activity_at - if silence_duration < self.silence_threshold: - return None - - # 条件3:距离上次主动思考超过最小间隔 - if session.last_proactive_at is not None: - time_since_last_proactive = current_time - session.last_proactive_at - if time_since_last_proactive < self.min_interval: - return None - - # 条件4:从数据库获取全局关系分数 - relationship_score = await self._get_global_relationship_score(session.user_id) - if relationship_score < self.min_affinity: - logger.debug( - f"主动思考跳过(关系分数不足): user={session.user_id}, " - f"score={relationship_score:.2f}, min={self.min_affinity:.2f}" - ) - return None - - # 条件5:基于关系分数的概率判断 - # 公式:probability = 0.1 + 0.8 * ((score - min_affinity) / (1.0 - min_affinity))^1.5 - # 这样分数从 min_affinity 到 1.0 映射到概率 10% 到 90% - # 使用1.5次幂让曲线更陡峭,高亲密度时概率增长更快 - normalized_score = (relationship_score - self.min_affinity) / (1.0 - self.min_affinity) - probability = 0.1 + 0.8 * (normalized_score ** 1.5) - probability = min(probability, 0.9) # 最高90%,永远不是100%确定 - - if random.random() > probability: - # 这次检查没触发,但记录一下(用于调试) - logger.debug( - f"主动思考概率检查未通过: user={session.user_id}, " - f"score={relationship_score:.2f}, probability={probability:.1%}" - ) - return None - - # 所有条件满足,生成触发原因 - silence_hours = silence_duration / 3600 - logger.info( - f"主动思考触发: user={session.user_id}, " - f"silence={silence_hours:.1f}h, score={relationship_score:.2f}, prob={probability:.1%}" - ) - return f"沉默了{silence_hours:.1f}小时,想主动关心一下对方" - - async def _get_global_relationship_score(self, user_id: str) -> float: - """ - 从全局关系数据库获取关系分数 - - Args: - user_id: 用户ID - - Returns: - 关系分数 (0.0-1.0),如果没有记录返回默认值 0.3 - """ - try: - from src.common.database.api.specialized import get_user_relationship - - # 从 user_id 解析 platform(格式通常是 "platform_userid") - # 这里假设 user_id 中包含 platform 信息,需要根据实际情况调整 - # 先尝试直接查询,如果失败再用默认值 - relationship = await get_user_relationship( - platform="qq", # TODO: 从 session 或 stream_id 获取真实 platform - user_id=user_id, - target_id="bot", - ) - - if relationship and hasattr(relationship, 'relationship_score'): - return relationship.relationship_score - - # 没有找到关系记录,返回默认值 - return 0.3 - - except Exception as e: - logger.warning(f"获取全局关系分数失败 (user={user_id}): {e}") - return 0.3 # 出错时返回较低的默认值 - - async def _handle_proactive_thinking( - self, - session: KokoroSession, - trigger_reason: str - ) -> None: - """ - 处理主动思考 - - Args: - session: 会话 - trigger_reason: 触发原因 - """ - self._stats["proactive_thinking_triggered"] += 1 - - # 更新会话状态 - session.last_proactive_at = time.time() - session.proactive_count += 1 - - # 添加主动思考日志 - proactive_entry = MentalLogEntry( - event_type=MentalLogEventType.PROACTIVE_THINKING, - timestamp=time.time(), - thought=trigger_reason, - content="主动思考触发", - emotional_snapshot=session.emotional_state.to_dict(), - metadata={"trigger_reason": trigger_reason}, - ) - session.add_mental_log_entry(proactive_entry) - - # 保存会话状态 - session_manager = get_session_manager() - await session_manager.save_session(session.user_id) - - # 调用主动思考回调(由 chatter 处理实际的 LLM 调用和动作执行) - if self.on_proactive_thinking_callback: - try: - await self.on_proactive_thinking_callback(session, trigger_reason) - except Exception as e: - logger.error(f"执行主动思考回调时出错 (user={session.user_id}): {e}") - - def set_timeout_callback( - self, - callback: Callable[[KokoroSession], Coroutine[Any, Any, None]], - ) -> None: - """设置超时回调函数""" - self.on_timeout_callback = callback - - def set_continuous_thinking_callback( - self, - callback: Callable[[KokoroSession], Coroutine[Any, Any, None]], - ) -> None: - """设置连续思考回调函数""" - self.on_continuous_thinking_callback = callback - - def set_proactive_thinking_callback( - self, - callback: Callable[[KokoroSession, str], Coroutine[Any, Any, None]], - ) -> None: - """设置主动思考回调函数""" - self.on_proactive_thinking_callback = callback - - def get_stats(self) -> dict[str, Any]: - """获取统计信息""" - return { - **self._stats, - "is_running": self._registered, - "check_interval": self.check_interval, - } - - @property - def is_running(self) -> bool: - """调度器是否正在运行""" - return self._registered - - -# 全局适配器实例 -_scheduler_adapter: Optional[KFCSchedulerAdapter] = None - - -def get_scheduler() -> KFCSchedulerAdapter: - """获取全局调度器适配器实例""" - global _scheduler_adapter - if _scheduler_adapter is None: - _scheduler_adapter = KFCSchedulerAdapter() - return _scheduler_adapter - - -async def initialize_scheduler( - check_interval: float = 10.0, - on_timeout_callback: Optional[Callable[[KokoroSession], Coroutine[Any, Any, None]]] = None, - on_continuous_thinking_callback: Optional[Callable[[KokoroSession], Coroutine[Any, Any, None]]] = None, - on_proactive_thinking_callback: Optional[Callable[[KokoroSession, str], Coroutine[Any, Any, None]]] = None, -) -> KFCSchedulerAdapter: - """ - 初始化并启动调度器 - - Args: - check_interval: 检查间隔 - on_timeout_callback: 超时回调 - on_continuous_thinking_callback: 连续思考回调 - on_proactive_thinking_callback: 主动思考回调 - - Returns: - KFCSchedulerAdapter: 调度器适配器实例 - """ - global _scheduler_adapter - _scheduler_adapter = KFCSchedulerAdapter( - check_interval=check_interval, - on_timeout_callback=on_timeout_callback, - on_continuous_thinking_callback=on_continuous_thinking_callback, - on_proactive_thinking_callback=on_proactive_thinking_callback, - ) - await _scheduler_adapter.start() - return _scheduler_adapter - - -async def shutdown_scheduler() -> None: - """关闭调度器""" - global _scheduler_adapter - if _scheduler_adapter: - await _scheduler_adapter.stop() - _scheduler_adapter = None diff --git a/src/plugins/built_in/kokoro_flow_chatter/models.py b/src/plugins/built_in/kokoro_flow_chatter/models.py deleted file mode 100644 index 277dfe846..000000000 --- a/src/plugins/built_in/kokoro_flow_chatter/models.py +++ /dev/null @@ -1,459 +0,0 @@ -""" -Kokoro Flow Chatter 数据模型 - -定义心流聊天器的核心数据结构,包括: -- SessionStatus: 会话状态枚举 -- EmotionalState: 情感状态模型 -- MentalLogEntry: 心理活动日志条目 -- KokoroSession: 完整的会话模型 -- LLMResponseModel: LLM响应结构 -- ActionModel: 动作模型 -""" - -from dataclasses import dataclass, field -from enum import Enum -from typing import Any, Optional -import time - - -class SessionStatus(Enum): - """ - 会话状态枚举 - - 状态机核心,定义了KFC系统的四个基本状态: - - IDLE: 空闲态,会话的起点和终点 - - RESPONDING: 响应中,正在处理消息和生成决策 - - WAITING: 等待态,已发送回复,等待用户回应 - - FOLLOW_UP_PENDING: 决策态,等待超时后进行后续决策 - """ - IDLE = "idle" - RESPONDING = "responding" - WAITING = "waiting" - FOLLOW_UP_PENDING = "follow_up_pending" - - def __str__(self) -> str: - return self.value - - -class MentalLogEventType(Enum): - """ - 心理活动日志事件类型 - - 用于标记线性叙事历史中不同类型的事件 - """ - USER_MESSAGE = "user_message" # 用户消息事件 - BOT_ACTION = "bot_action" # Bot行动事件 - WAITING_UPDATE = "waiting_update" # 等待期间的心理更新 - TIMEOUT_DECISION = "timeout_decision" # 超时决策事件 - STATE_CHANGE = "state_change" # 状态变更事件 - CONTINUOUS_THINKING = "continuous_thinking" # 连续思考事件 - PROACTIVE_THINKING = "proactive_thinking" # 主动思考事件(长期沉默后主动发起) - - def __str__(self) -> str: - return self.value - - -@dataclass -class EmotionalState: - """ - 动态情感状态模型 - - 记录和跟踪AI的情感参数,用于驱动个性化的交互行为 - - Attributes: - mood: 当前心情标签(如:开心、好奇、疲惫、沮丧) - mood_intensity: 心情强度,0.0-1.0 - relationship_warmth: 关系热度,代表与用户的亲密度,0.0-1.0 - impression_of_user: 对用户的动态印象描述 - anxiety_level: 焦虑程度,0.0-1.0,在等待时会变化 - engagement_level: 投入程度,0.0-1.0,表示对当前对话的关注度 - last_update_time: 最后更新时间戳 - """ - mood: str = "平静" # V7: 改为中文"平静",更自然 - mood_intensity: float = 0.3 # V7: 默认低强度,避免无厘头的强烈情绪 - relationship_warmth: float = 0.5 - impression_of_user: str = "" - anxiety_level: float = 0.0 - engagement_level: float = 0.5 - last_update_time: float = field(default_factory=time.time) - - def to_dict(self) -> dict[str, Any]: - """转换为字典格式""" - return { - "mood": self.mood, - "mood_intensity": self.mood_intensity, - "relationship_warmth": self.relationship_warmth, - "impression_of_user": self.impression_of_user, - "anxiety_level": self.anxiety_level, - "engagement_level": self.engagement_level, - "last_update_time": self.last_update_time, - } - - @classmethod - def from_dict(cls, data: dict[str, Any]) -> "EmotionalState": - """从字典创建实例""" - return cls( - mood=data.get("mood", "neutral"), - mood_intensity=data.get("mood_intensity", 0.5), - relationship_warmth=data.get("relationship_warmth", 0.5), - impression_of_user=data.get("impression_of_user", ""), - anxiety_level=data.get("anxiety_level", 0.0), - engagement_level=data.get("engagement_level", 0.5), - last_update_time=data.get("last_update_time", time.time()), - ) - - def update_anxiety_over_time(self, elapsed_seconds: float, max_wait_seconds: float) -> None: - """ - 根据等待时间更新焦虑程度 - - Args: - elapsed_seconds: 已等待的秒数 - max_wait_seconds: 最大等待秒数 - """ - if max_wait_seconds <= 0: - return - - # 焦虑程度随时间流逝增加,使用平方根函数使增长趋于平缓 - wait_ratio = min(elapsed_seconds / max_wait_seconds, 1.0) - self.anxiety_level = min(wait_ratio ** 0.5, 1.0) - self.last_update_time = time.time() - - -@dataclass -class MentalLogEntry: - """ - 心理活动日志条目 - - 记录线性叙事历史中的每一个事件节点, - 是实现"连续主观体验"的核心数据结构 - - Attributes: - event_type: 事件类型 - timestamp: 事件发生时间戳 - thought: 内心独白 - content: 事件内容(如用户消息、Bot回复等) - emotional_snapshot: 事件发生时的情感状态快照 - metadata: 额外元数据 - """ - event_type: MentalLogEventType - timestamp: float - thought: str = "" - content: str = "" - emotional_snapshot: Optional[dict[str, Any]] = None - metadata: dict[str, Any] = field(default_factory=dict) - - def to_dict(self) -> dict[str, Any]: - """转换为字典格式""" - return { - "event_type": str(self.event_type), - "timestamp": self.timestamp, - "thought": self.thought, - "content": self.content, - "emotional_snapshot": self.emotional_snapshot, - "metadata": self.metadata, - } - - @classmethod - def from_dict(cls, data: dict[str, Any]) -> "MentalLogEntry": - """从字典创建实例""" - event_type_str = data.get("event_type", "state_change") - try: - event_type = MentalLogEventType(event_type_str) - except ValueError: - event_type = MentalLogEventType.STATE_CHANGE - - return cls( - event_type=event_type, - timestamp=data.get("timestamp", time.time()), - thought=data.get("thought", ""), - content=data.get("content", ""), - emotional_snapshot=data.get("emotional_snapshot"), - metadata=data.get("metadata", {}), - ) - - -@dataclass -class KokoroSession: - """ - Kokoro Flow Chatter 会话模型 - - 为每个私聊用户维护一个独立的会话,包含: - - 基本会话信息 - - 当前状态 - - 情感状态 - - 线性叙事历史(心理活动日志) - - 等待相关的状态 - - Attributes: - user_id: 用户唯一标识 - stream_id: 聊天流ID - status: 当前会话状态 - emotional_state: 动态情感状态 - mental_log: 线性叙事历史 - expected_user_reaction: 对用户回应的预期 - max_wait_seconds: 最大等待秒数 - waiting_since: 开始等待的时间戳 - last_bot_message: 最后一条Bot消息 - last_user_message: 最后一条用户消息 - created_at: 会话创建时间 - last_activity_at: 最后活动时间 - total_interactions: 总交互次数 - """ - user_id: str - stream_id: str - status: SessionStatus = SessionStatus.IDLE - emotional_state: EmotionalState = field(default_factory=EmotionalState) - mental_log: list[MentalLogEntry] = field(default_factory=list) - - # 等待状态相关 - expected_user_reaction: str = "" - max_wait_seconds: int = 300 - waiting_since: Optional[float] = None - - # 消息记录 - last_bot_message: str = "" - last_user_message: str = "" - - # 统计信息 - created_at: float = field(default_factory=time.time) - last_activity_at: float = field(default_factory=time.time) - total_interactions: int = 0 - - # 连续思考相关 - continuous_thinking_count: int = 0 - last_continuous_thinking_at: Optional[float] = None - - # 主动思考相关(长期沉默后主动发起对话) - last_proactive_at: Optional[float] = None # 上次主动思考的时间 - proactive_count: int = 0 # 主动思考的次数(累计) - - # V7: 连续等待追问限制(防止用户不回复时连续追问) - consecutive_followup_count: int = 0 # 用户没回复时连续追问的次数 - max_consecutive_followups: int = 2 # 最多允许连续追问2次 - - def add_mental_log_entry(self, entry: MentalLogEntry, max_log_size: int = 100) -> None: - """ - 添加心理活动日志条目 - - Args: - entry: 日志条目 - max_log_size: 日志最大保留条数 - """ - self.mental_log.append(entry) - self.last_activity_at = time.time() - - # 保持日志在合理大小 - if len(self.mental_log) > max_log_size: - # 保留最近的日志 - self.mental_log = self.mental_log[-max_log_size:] - - def get_recent_mental_log(self, limit: int = 20) -> list[MentalLogEntry]: - """获取最近的心理活动日志""" - return self.mental_log[-limit:] if self.mental_log else [] - - def get_waiting_duration(self) -> float: - """获取当前等待时长(秒)""" - if self.waiting_since is None: - return 0.0 - return time.time() - self.waiting_since - - def is_wait_timeout(self) -> bool: - """检查是否等待超时""" - return self.get_waiting_duration() >= self.max_wait_seconds - - def start_waiting(self, expected_reaction: str, max_wait: int) -> None: - """开始等待状态""" - self.status = SessionStatus.WAITING - self.expected_user_reaction = expected_reaction - self.max_wait_seconds = max_wait - self.waiting_since = time.time() - self.continuous_thinking_count = 0 - - def end_waiting(self) -> None: - """结束等待状态""" - self.waiting_since = None - self.expected_user_reaction = "" - self.continuous_thinking_count = 0 - - def to_dict(self) -> dict[str, Any]: - """转换为可序列化的字典格式""" - return { - "user_id": self.user_id, - "stream_id": self.stream_id, - "status": str(self.status), - "emotional_state": self.emotional_state.to_dict(), - "mental_log": [entry.to_dict() for entry in self.mental_log], - "expected_user_reaction": self.expected_user_reaction, - "max_wait_seconds": self.max_wait_seconds, - "waiting_since": self.waiting_since, - "last_bot_message": self.last_bot_message, - "last_user_message": self.last_user_message, - "created_at": self.created_at, - "last_activity_at": self.last_activity_at, - "total_interactions": self.total_interactions, - "continuous_thinking_count": self.continuous_thinking_count, - "last_continuous_thinking_at": self.last_continuous_thinking_at, - "last_proactive_at": self.last_proactive_at, - "proactive_count": self.proactive_count, - "consecutive_followup_count": self.consecutive_followup_count, - "max_consecutive_followups": self.max_consecutive_followups, - } - - @classmethod - def from_dict(cls, data: dict[str, Any]) -> "KokoroSession": - """从字典创建会话实例""" - status_str = data.get("status", "idle") - try: - status = SessionStatus(status_str) - except ValueError: - status = SessionStatus.IDLE - - emotional_state = EmotionalState.from_dict( - data.get("emotional_state", {}) - ) - - mental_log = [ - MentalLogEntry.from_dict(entry) - for entry in data.get("mental_log", []) - ] - - return cls( - user_id=data.get("user_id", ""), - stream_id=data.get("stream_id", ""), - status=status, - emotional_state=emotional_state, - mental_log=mental_log, - expected_user_reaction=data.get("expected_user_reaction", ""), - max_wait_seconds=data.get("max_wait_seconds", 300), - waiting_since=data.get("waiting_since"), - last_bot_message=data.get("last_bot_message", ""), - last_user_message=data.get("last_user_message", ""), - created_at=data.get("created_at", time.time()), - last_activity_at=data.get("last_activity_at", time.time()), - total_interactions=data.get("total_interactions", 0), - continuous_thinking_count=data.get("continuous_thinking_count", 0), - last_continuous_thinking_at=data.get("last_continuous_thinking_at"), - last_proactive_at=data.get("last_proactive_at"), - proactive_count=data.get("proactive_count", 0), - consecutive_followup_count=data.get("consecutive_followup_count", 0), - max_consecutive_followups=data.get("max_consecutive_followups", 2), - ) - - -@dataclass -class ActionModel: - """ - 动作模型 - - 表示LLM决策的单个动作 - - Attributes: - type: 动作类型(reply, poke_user, send_reaction, update_internal_state, do_nothing) - params: 动作参数 - """ - type: str - params: dict[str, Any] = field(default_factory=dict) - - def to_dict(self) -> dict[str, Any]: - """转换为字典格式""" - return { - "type": self.type, - **self.params - } - - @classmethod - def from_dict(cls, data: dict[str, Any]) -> "ActionModel": - """从字典创建实例""" - action_type = data.get("type", "do_nothing") - params = {k: v for k, v in data.items() if k != "type"} - return cls(type=action_type, params=params) - - -@dataclass -class LLMResponseModel: - """ - LLM响应模型 - - 定义LLM输出的结构化JSON格式 - - Attributes: - thought: 内心独白(必须) - expected_user_reaction: 用户回应预期(必须) - max_wait_seconds: 最长等待秒数(必须) - actions: 行动列表(必须) - plan: 行动意图(可选) - emotional_updates: 情感状态更新(可选) - """ - thought: str - expected_user_reaction: str - max_wait_seconds: int - actions: list[ActionModel] - plan: str = "" - emotional_updates: Optional[dict[str, Any]] = None - - def to_dict(self) -> dict[str, Any]: - """转换为字典格式""" - result = { - "thought": self.thought, - "expected_user_reaction": self.expected_user_reaction, - "max_wait_seconds": self.max_wait_seconds, - "actions": [action.to_dict() for action in self.actions], - } - if self.plan: - result["plan"] = self.plan - if self.emotional_updates: - result["emotional_updates"] = self.emotional_updates - return result - - @classmethod - def from_dict(cls, data: dict[str, Any]) -> "LLMResponseModel": - """从字典创建实例""" - actions = [ - ActionModel.from_dict(action) - for action in data.get("actions", []) - ] - - # 如果没有actions,添加默认的do_nothing - if not actions: - actions = [ActionModel(type="do_nothing")] - - return cls( - thought=data.get("thought", ""), - expected_user_reaction=data.get("expected_user_reaction", ""), - max_wait_seconds=data.get("max_wait_seconds", 300), - actions=actions, - plan=data.get("plan", ""), - emotional_updates=data.get("emotional_updates"), - ) - - @classmethod - def create_error_response(cls, error_message: str) -> "LLMResponseModel": - """创建错误响应""" - return cls( - thought=f"出现了问题:{error_message}", - expected_user_reaction="用户可能会感到困惑", - max_wait_seconds=60, - actions=[ActionModel(type="do_nothing")], - ) - - -@dataclass -class ContinuousThinkingResult: - """ - 连续思考结果 - - 在等待期间触发的心理活动更新结果 - """ - thought: str - anxiety_level: float - should_follow_up: bool = False - follow_up_message: str = "" - - def to_dict(self) -> dict[str, Any]: - """转换为字典格式""" - return { - "thought": self.thought, - "anxiety_level": self.anxiety_level, - "should_follow_up": self.should_follow_up, - "follow_up_message": self.follow_up_message, - } diff --git a/src/plugins/built_in/kokoro_flow_chatter/plugin.py b/src/plugins/built_in/kokoro_flow_chatter/plugin.py deleted file mode 100644 index 09cfb884c..000000000 --- a/src/plugins/built_in/kokoro_flow_chatter/plugin.py +++ /dev/null @@ -1,218 +0,0 @@ -""" -Kokoro Flow Chatter (心流聊天器) 插件入口 - -这是一个专为私聊场景设计的AI聊天插件,实现从"消息响应者"到"对话体验者"的转变。 - -核心特点: -- 心理状态驱动的交互模型 -- 连续的时间观念和等待体验 -- 深度情感连接和长期关系维护 -- 状态机驱动的交互节奏 - -切换逻辑: -- 当 enable = true 时,KFC 接管所有私聊消息 -- 当 enable = false 时,私聊消息由 AFC (Affinity Flow Chatter) 处理 -""" - -import asyncio -from typing import Any, ClassVar - -from src.common.logger import get_logger -from src.config.config import global_config -from src.plugin_system.apis.plugin_register_api import register_plugin -from src.plugin_system.base.base_plugin import BasePlugin -from src.plugin_system.base.component_types import ComponentInfo - -logger = get_logger("kokoro_flow_chatter_plugin") - - -@register_plugin -class KokoroFlowChatterPlugin(BasePlugin): - """ - 心流聊天器插件 - - 专为私聊场景设计的深度情感交互处理器。 - - Features: - - KokoroFlowChatter: 核心聊天处理器组件 - - SessionManager: 会话管理,支持持久化 - - BackgroundScheduler: 后台调度,处理等待超时 - - PromptGenerator: 动态提示词生成 - - ActionExecutor: 动作解析和执行 - """ - - plugin_name: str = "kokoro_flow_chatter" - enable_plugin: bool = True - dependencies: ClassVar[list[str]] = [] - python_dependencies: ClassVar[list[str]] = [] - config_file_name: str = "config.toml" - - # 配置schema留空,使用config.toml直接配置 - config_schema: ClassVar[dict[str, Any]] = {} - - # 后台任务 - _session_manager = None - _scheduler = None - _initialization_task = None - - def get_plugin_components(self) -> list[tuple[ComponentInfo, type]]: - """ - 返回插件包含的组件列表 - - 根据 global_config.kokoro_flow_chatter.enable 决定是否注册 KFC。 - 如果 enable = false,返回空列表,私聊将由 AFC 处理。 - """ - components: list[tuple[ComponentInfo, type]] = [] - - # 检查是否启用 KFC - kfc_enabled = True - if global_config and hasattr(global_config, 'kokoro_flow_chatter'): - kfc_enabled = global_config.kokoro_flow_chatter.enable - - if not kfc_enabled: - logger.info("KFC 已禁用 (enable = false),私聊将由 AFC 处理") - return components - - try: - # 导入核心聊天处理器 - from .chatter import KokoroFlowChatter - - components.append(( - KokoroFlowChatter.get_chatter_info(), - KokoroFlowChatter - )) - logger.debug("成功加载 KokoroFlowChatter 组件,KFC 将接管私聊") - - except Exception as e: - logger.error(f"加载 KokoroFlowChatter 时出错: {e}") - - return components - - async def on_plugin_load(self) -> bool: - """ - 插件加载时的初始化逻辑 - - 如果 KFC 被禁用,跳过初始化。 - - Returns: - bool: 是否加载成功 - """ - # 检查是否启用 KFC - kfc_enabled = True - if global_config and hasattr(global_config, 'kokoro_flow_chatter'): - kfc_enabled = global_config.kokoro_flow_chatter.enable - - if not kfc_enabled: - logger.info("KFC 已禁用,跳过初始化") - self._is_started = False - return True - - try: - logger.info("正在初始化 Kokoro Flow Chatter 插件...") - - # 初始化会话管理器 - from .session_manager import initialize_session_manager - - session_config = self.config.get("kokoro_flow_chatter", {}).get("session", {}) - self._session_manager = await initialize_session_manager( - data_dir=session_config.get("data_dir", "data/kokoro_flow_chatter/sessions"), - max_session_age_days=session_config.get("max_session_age_days", 30), - auto_save_interval=session_config.get("auto_save_interval", 300), - ) - - # 初始化调度器 - from .kfc_scheduler_adapter import initialize_scheduler - - # 从 global_config 读取配置 - check_interval = 10.0 - if global_config and hasattr(global_config, 'kokoro_flow_chatter'): - # 使用简化后的配置结构 - pass # check_interval 保持默认值 - - self._scheduler = await initialize_scheduler( - check_interval=check_interval, - ) - - self._is_started = True - logger.info("Kokoro Flow Chatter 插件初始化完成") - return True - - except Exception as e: - logger.error(f"Kokoro Flow Chatter 插件初始化失败: {e}") - return False - - async def on_plugin_unload(self) -> bool: - """ - 插件卸载时的清理逻辑 - - Returns: - bool: 是否卸载成功 - """ - try: - logger.info("正在关闭 Kokoro Flow Chatter 插件...") - - # 停止调度器 - if self._scheduler: - from .kfc_scheduler_adapter import shutdown_scheduler - await shutdown_scheduler() - self._scheduler = None - - # 停止会话管理器 - if self._session_manager: - await self._session_manager.stop() - self._session_manager = None - - self._is_started = False - logger.info("Kokoro Flow Chatter 插件已关闭") - return True - - except Exception as e: - logger.error(f"Kokoro Flow Chatter 插件关闭失败: {e}") - return False - - def register_plugin(self) -> bool: - """ - 注册插件及其所有组件 - - 重写父类方法,添加异步初始化逻辑 - """ - # 先调用父类的注册逻辑 - result = super().register_plugin() - - if result: - # 在后台启动异步初始化 - try: - loop = asyncio.get_event_loop() - if loop.is_running(): - self._initialization_task = asyncio.create_task( - self.on_plugin_load() - ) - else: - # 如果事件循环未运行,稍后初始化 - logger.debug("事件循环未运行,将延迟初始化") - except RuntimeError: - logger.debug("无法获取事件循环,将延迟初始化") - - return result - - @property - def is_started(self) -> bool: - """插件是否已启动""" - return self._is_started - - def get_plugin_stats(self) -> dict[str, Any]: - """获取插件统计信息""" - stats: dict[str, Any] = { - "is_started": self._is_started, - "has_session_manager": self._session_manager is not None, - "has_scheduler": self._scheduler is not None, - } - - if self._scheduler: - stats["scheduler_stats"] = self._scheduler.get_stats() - - if self._session_manager: - # 异步获取会话统计需要在异步上下文中调用 - stats["session_manager_active"] = True - - return stats diff --git a/src/plugins/built_in/kokoro_flow_chatter/proactive_thinking.py b/src/plugins/built_in/kokoro_flow_chatter/proactive_thinking.py deleted file mode 100644 index 5b3b745b5..000000000 --- a/src/plugins/built_in/kokoro_flow_chatter/proactive_thinking.py +++ /dev/null @@ -1,528 +0,0 @@ -""" -Kokoro Flow Chatter 主动思考引擎 (V2) - -私聊专属的主动思考系统,实现"主动找话题、主动关心用户"的能力。 -这是KFC区别于AFC的核心特性之一。 - -触发机制: -1. 长时间沉默检测 - 当对话沉默超过阈值时主动发起话题 -2. 关键记忆触发 - 基于重要日期、事件的主动关心 -3. 情绪状态触发 - 当情感参数达到阈值时主动表达 -4. 好感度驱动 - 根据与用户的关系深度调整主动程度 - -设计理念: -- 不是"有事才找你",而是"想你了就找你" -- 主动思考应该符合人设和情感状态 -- 避免过度打扰,保持适度的边界感 -""" - -import asyncio -import random -import time -from dataclasses import dataclass, field -from enum import Enum -from typing import TYPE_CHECKING, Any, Callable, Optional - -from src.common.logger import get_logger -from src.config.config import global_config -from src.plugin_system.base.component_types import ActionInfo - -from .models import KokoroSession, MentalLogEntry, MentalLogEventType, SessionStatus - -if TYPE_CHECKING: - from .action_executor import ActionExecutor - from .prompt_generator import PromptGenerator - -logger = get_logger("kokoro_proactive_thinking") - - -class ProactiveThinkingTrigger(Enum): - """主动思考触发类型""" - SILENCE_TIMEOUT = "silence_timeout" # 长时间沉默 - 她感到挂念 - TIME_BASED = "time_based" # 时间触发(早安/晚安)- 自然的问候契机 - - -@dataclass -class ProactiveThinkingConfig: - """ - 主动思考配置 - - 设计哲学:主动行为源于内部状态和外部环境的自然反应,而非机械的限制。 - 她的主动是因为挂念、因为关心、因为想问候,而不是因为"任务"。 - """ - # 是否启用主动思考 - enabled: bool = True - - # 1. 沉默触发器:当感到长久的沉默时,她可能会想说些什么 - silence_threshold_seconds: int = 7200 # 2小时无互动触发 - silence_check_interval: int = 300 # 每5分钟检查一次 - - # 2. 关系门槛:她不会对不熟悉的人过于主动 - min_affinity_for_proactive: float = 0.3 # 最低好感度才会主动 - - # 3. 频率呼吸:为了避免打扰,她的关心总是有间隔的 - min_interval_between_proactive: int = 1800 # 两次主动思考至少间隔30分钟 - - # 4. 自然问候:在特定的时间,她会像朋友一样送上问候 - enable_morning_greeting: bool = True # 早安问候 (8:00-9:00) - enable_night_greeting: bool = True # 晚安问候 (22:00-23:00) - - # 随机性(让行为更自然) - random_delay_range: tuple[int, int] = (60, 300) # 触发后随机延迟1-5分钟 - - @classmethod - def from_global_config(cls) -> "ProactiveThinkingConfig": - """从 global_config.kokoro_flow_chatter.proactive_thinking 创建配置""" - if global_config and hasattr(global_config, 'kokoro_flow_chatter'): - kfc = global_config.kokoro_flow_chatter - proactive = kfc.proactive_thinking - return cls( - enabled=proactive.enabled, - silence_threshold_seconds=proactive.silence_threshold_seconds, - silence_check_interval=300, # 固定值 - min_affinity_for_proactive=proactive.min_affinity_for_proactive, - min_interval_between_proactive=proactive.min_interval_between_proactive, - enable_morning_greeting=proactive.enable_morning_greeting, - enable_night_greeting=proactive.enable_night_greeting, - random_delay_range=(60, 300), # 固定值 - ) - return cls() - - -@dataclass -class ProactiveThinkingState: - """主动思考状态 - 记录她的主动关心历史""" - last_proactive_time: float = 0.0 - last_morning_greeting_date: str = "" # 上次早安的日期 - last_night_greeting_date: str = "" # 上次晚安的日期 - pending_triggers: list[ProactiveThinkingTrigger] = field(default_factory=list) - - def can_trigger(self, config: ProactiveThinkingConfig) -> bool: - """ - 检查是否满足主动思考的基本条件 - - 注意:这里不使用每日限制,而是基于间隔来自然控制频率 - """ - # 检查间隔限制 - 她的关心有呼吸感,不会太频繁 - if time.time() - self.last_proactive_time < config.min_interval_between_proactive: - return False - - return True - - def record_trigger(self) -> None: - """记录一次触发""" - self.last_proactive_time = time.time() - - def record_morning_greeting(self) -> None: - """记录今天的早安""" - self.last_morning_greeting_date = time.strftime("%Y-%m-%d") - self.record_trigger() - - def record_night_greeting(self) -> None: - """记录今天的晚安""" - self.last_night_greeting_date = time.strftime("%Y-%m-%d") - self.record_trigger() - - def has_greeted_morning_today(self) -> bool: - """今天是否已经问候过早安""" - return self.last_morning_greeting_date == time.strftime("%Y-%m-%d") - - def has_greeted_night_today(self) -> bool: - """今天是否已经问候过晚安""" - return self.last_night_greeting_date == time.strftime("%Y-%m-%d") - - -class ProactiveThinkingEngine: - """ - 主动思考引擎 - - 负责检测触发条件并生成主动思考内容。 - 这是一个"内在动机驱动"而非"机械限制"的系统。 - - 她的主动源于: - - 长时间的沉默让她感到挂念 - - 与用户的好感度决定了她愿意多主动 - - 特定的时间点给了她自然的问候契机 - """ - - def __init__( - self, - stream_id: str, - config: ProactiveThinkingConfig | None = None, - ): - """ - 初始化主动思考引擎 - - Args: - stream_id: 聊天流ID - config: 配置对象 - """ - self.stream_id = stream_id - self.config = config or ProactiveThinkingConfig() - self.state = ProactiveThinkingState() - - # 回调函数 - self._on_proactive_trigger: Optional[Callable] = None - - # 后台任务 - self._check_task: Optional[asyncio.Task] = None - self._running = False - - logger.debug(f"[ProactiveThinking] 初始化完成: stream_id={stream_id}") - - def set_proactive_callback( - self, - callback: Callable[[KokoroSession, ProactiveThinkingTrigger], Any] - ) -> None: - """ - 设置主动思考触发回调 - - Args: - callback: 当触发主动思考时调用的函数 - """ - self._on_proactive_trigger = callback - - async def start(self) -> None: - """启动主动思考引擎""" - if self._running: - return - - self._running = True - self._check_task = asyncio.create_task(self._check_loop()) - logger.info(f"[ProactiveThinking] 引擎已启动: stream_id={self.stream_id}") - - async def stop(self) -> None: - """停止主动思考引擎""" - self._running = False - - if self._check_task: - self._check_task.cancel() - try: - await self._check_task - except asyncio.CancelledError: - pass - self._check_task = None - - logger.info(f"[ProactiveThinking] 引擎已停止: stream_id={self.stream_id}") - - async def _check_loop(self) -> None: - """后台检查循环""" - while self._running: - try: - await asyncio.sleep(self.config.silence_check_interval) - - if not self.config.enabled: - continue - - # 这里需要获取session来检查,但我们在引擎层面不直接持有session - # 实际的检查逻辑通过 check_triggers 方法被外部调用 - - except asyncio.CancelledError: - break - except Exception as e: - logger.error(f"[ProactiveThinking] 检查循环出错: {e}") - - async def check_triggers( - self, - session: KokoroSession, - ) -> Optional[ProactiveThinkingTrigger]: - """ - 检查触发条件 - 基于内在动机而非机械限制 - - 综合考虑: - 1. 她与用户的好感度是否足够(关系门槛) - 2. 距离上次主动是否有足够间隔(频率呼吸) - 3. 是否有自然的触发契机(沉默/时间问候) - - Args: - session: 当前会话 - - Returns: - 触发类型,如果没有触发则返回None - """ - if not self.config.enabled: - return None - - # 关系门槛:她不会对不熟悉的人过于主动 - relationship_warmth = session.emotional_state.relationship_warmth - if relationship_warmth < self.config.min_affinity_for_proactive: - logger.debug( - f"[ProactiveThinking] 好感度不足,不主动: " - f"{relationship_warmth:.2f} < {self.config.min_affinity_for_proactive}" - ) - return None - - # 频率呼吸:检查间隔 - if not self.state.can_trigger(self.config): - return None - - # 只有在 IDLE 或 WAITING 状态才考虑主动 - if session.status not in (SessionStatus.IDLE, SessionStatus.WAITING): - return None - - # 按优先级检查触发契机 - - # 1. 时间问候(早安/晚安)- 自然的问候契机 - trigger = self._check_time_greeting_trigger() - if trigger: - return trigger - - # 2. 沉默触发 - 她感到挂念 - trigger = self._check_silence_trigger(session) - if trigger: - return trigger - - return None - - def _check_time_greeting_trigger(self) -> Optional[ProactiveThinkingTrigger]: - """检查时间问候触发(早安/晚安)""" - current_hour = time.localtime().tm_hour - - # 早安问候 (8:00 - 9:00) - if self.config.enable_morning_greeting: - if 8 <= current_hour < 9 and not self.state.has_greeted_morning_today(): - logger.debug("[ProactiveThinking] 早安问候时间") - return ProactiveThinkingTrigger.TIME_BASED - - # 晚安问候 (22:00 - 23:00) - if self.config.enable_night_greeting: - if 22 <= current_hour < 23 and not self.state.has_greeted_night_today(): - logger.debug("[ProactiveThinking] 晚安问候时间") - return ProactiveThinkingTrigger.TIME_BASED - - return None - - def _check_silence_trigger( - self, - session: KokoroSession, - ) -> Optional[ProactiveThinkingTrigger]: - """检查沉默触发 - 长时间的沉默让她感到挂念""" - # 获取最后互动时间 - last_interaction = session.waiting_since or session.last_activity_at - if not last_interaction: - # 使用session创建时间 - last_interaction = session.mental_log[0].timestamp if session.mental_log else time.time() - - silence_duration = time.time() - last_interaction - - if silence_duration >= self.config.silence_threshold_seconds: - logger.debug(f"[ProactiveThinking] 沉默触发: 已沉默 {silence_duration:.0f} 秒,她感到挂念") - return ProactiveThinkingTrigger.SILENCE_TIMEOUT - - return None - - async def generate_proactive_prompt( - self, - session: KokoroSession, - trigger: ProactiveThinkingTrigger, - prompt_generator: "PromptGenerator", - available_actions: dict[str, ActionInfo] | None = None, - ) -> tuple[str, str]: - """ - 生成主动思考的提示词 - - Args: - session: 当前会话 - trigger: 触发类型 - prompt_generator: 提示词生成器 - available_actions: 可用动作 - - Returns: - (system_prompt, user_prompt) 元组 - """ - # 根据触发类型生成上下文 - trigger_context = self._build_trigger_context(session, trigger) - - # 使用prompt_generator生成主动思考提示词 - system_prompt, user_prompt = prompt_generator.generate_proactive_thinking_prompt( - session=session, - trigger_type=trigger.value, - trigger_context=trigger_context, - available_actions=available_actions, - ) - - return system_prompt, user_prompt - - def _build_trigger_context( - self, - session: KokoroSession, - trigger: ProactiveThinkingTrigger, - ) -> str: - """ - 构建触发上下文 - 描述她主动联系的内在动机 - """ - emotional_state = session.emotional_state - current_hour = time.localtime().tm_hour - - if trigger == ProactiveThinkingTrigger.TIME_BASED: - # 时间问候 - 自然的问候契机 - if 8 <= current_hour < 12: - return ( - f"早上好!新的一天开始了。" - f"我的心情是「{emotional_state.mood}」。" - f"我想和对方打个招呼,开启美好的一天。" - ) - else: - return ( - f"夜深了,已经{current_hour}点了。" - f"我的心情是「{emotional_state.mood}」。" - f"我想关心一下对方,送上晚安。" - ) - - else: # SILENCE_TIMEOUT - # 沉默触发 - 她感到挂念 - last_time = session.waiting_since or session.last_activity_at or time.time() - silence_hours = (time.time() - last_time) / 3600 - return ( - f"我们已经有 {silence_hours:.1f} 小时没有聊天了。" - f"我有些挂念对方。" - f"我现在的心情是「{emotional_state.mood}」。" - f"对方给我的印象是:{emotional_state.impression_of_user or '还不太了解'}" - ) - - async def execute_proactive_action( - self, - session: KokoroSession, - trigger: ProactiveThinkingTrigger, - action_executor: "ActionExecutor", - prompt_generator: "PromptGenerator", - llm_call: Callable[[str, str], Any], - ) -> dict[str, Any]: - """ - 执行主动思考流程 - - Args: - session: 当前会话 - trigger: 触发类型 - action_executor: 动作执行器 - prompt_generator: 提示词生成器 - llm_call: LLM调用函数(可以是同步或异步) - - Returns: - 执行结果 - """ - try: - # 1. 加载可用动作 - available_actions = await action_executor.load_actions() - - # 2. 生成提示词 - system_prompt, user_prompt = await self.generate_proactive_prompt( - session, trigger, prompt_generator, available_actions - ) - - # 3. 添加随机延迟(更自然) - delay = random.randint(*self.config.random_delay_range) - logger.debug(f"[ProactiveThinking] 延迟 {delay} 秒后执行") - await asyncio.sleep(delay) - - # 4. 调用LLM(支持同步和异步) - result = llm_call(system_prompt, user_prompt) - if asyncio.iscoroutine(result): - llm_response = await result - else: - llm_response = result - - # 5. 解析响应 - parsed_response = action_executor.parse_llm_response(llm_response) - - # 6. 记录主动思考事件 - entry = MentalLogEntry( - event_type=MentalLogEventType.CONTINUOUS_THINKING, - timestamp=time.time(), - thought=f"[主动思考-{trigger.value}] {parsed_response.thought}", - content="", - emotional_snapshot=session.emotional_state.to_dict(), - metadata={ - "trigger_type": trigger.value, - "proactive": True, - }, - ) - session.add_mental_log_entry(entry) - - # 7. 执行动作 - from src.chat.message_receive.chat_stream import get_chat_manager - chat_manager = get_chat_manager() - chat_stream = await chat_manager.get_stream(self.stream_id) if chat_manager else None - - result = await action_executor.execute_actions( - parsed_response, - session, - chat_stream - ) - - # 8. 记录触发(根据触发类型决定记录方式) - if trigger == ProactiveThinkingTrigger.TIME_BASED: - # 时间问候需要单独记录,防止同一天重复问候 - current_hour = time.localtime().tm_hour - if 6 <= current_hour < 12: - self.state.record_morning_greeting() - else: - self.state.record_night_greeting() - else: - self.state.record_trigger() - - # 9. 如果发送了消息,更新会话状态 - if result.get("has_reply"): - session.start_waiting( - expected_reaction=parsed_response.expected_user_reaction, - max_wait=parsed_response.max_wait_seconds - ) - - return { - "success": True, - "trigger": trigger.value, - "result": result, - } - - except Exception as e: - logger.error(f"[ProactiveThinking] 执行失败: {e}") - import traceback - logger.error(traceback.format_exc()) - return { - "success": False, - "trigger": trigger.value, - "error": str(e), - } - - def get_state(self) -> dict[str, Any]: - """获取当前状态""" - return { - "enabled": self.config.enabled, - "last_proactive_time": self.state.last_proactive_time, - "last_morning_greeting_date": self.state.last_morning_greeting_date, - "last_night_greeting_date": self.state.last_night_greeting_date, - "running": self._running, - } - - -# 全局引擎实例管理 -_engines: dict[str, ProactiveThinkingEngine] = {} - - -def get_proactive_thinking_engine( - stream_id: str, - config: ProactiveThinkingConfig | None = None, -) -> ProactiveThinkingEngine: - """ - 获取主动思考引擎实例 - - Args: - stream_id: 聊天流ID - config: 配置对象(如果为None,则从global_config加载) - - Returns: - ProactiveThinkingEngine实例 - """ - if stream_id not in _engines: - # 如果没有提供config,从global_config加载 - if config is None: - config = ProactiveThinkingConfig.from_global_config() - _engines[stream_id] = ProactiveThinkingEngine(stream_id, config) - return _engines[stream_id] - - -async def cleanup_engines() -> None: - """清理所有引擎实例""" - for engine in _engines.values(): - await engine.stop() - _engines.clear() diff --git a/src/plugins/built_in/kokoro_flow_chatter/prompt_generator.py b/src/plugins/built_in/kokoro_flow_chatter/prompt_generator.py deleted file mode 100644 index 14e323e90..000000000 --- a/src/plugins/built_in/kokoro_flow_chatter/prompt_generator.py +++ /dev/null @@ -1,807 +0,0 @@ -""" -Kokoro Flow Chatter Prompt生成器 - -根据会话状态动态构建LLM提示词,实现"体验-决策-行动"的交互模式。 -支持两种主要场景: -1. 回应消息(Responding):收到用户消息后的决策 -2. 超时决策(Timeout Decision):等待超时后的后续行动决策 - -V2 升级: -- 动态Action发现机制:从ActionManager获取可用Action列表 -- 支持任意复杂参数的Action -- 与AFC的Action系统完全兼容 - -V5 超融合架构升级: -- 完整集成S4U的所有上下文模块 -- 关系信息、记忆、表达习惯等全部融入 -- 支持群聊/私聊场景动态切换 -""" - -import asyncio -import time -from typing import TYPE_CHECKING, Any, Optional - -from src.common.logger import get_logger -from src.plugin_system.base.component_types import ActionInfo - -from .models import ( - EmotionalState, - KokoroSession, - MentalLogEntry, - MentalLogEventType, -) - -if TYPE_CHECKING: - from src.chat.message_receive.chat_stream import ChatStream - from src.chat.planner_actions.action_manager import ChatterActionManager - from src.common.data_models.message_manager_data_model import StreamContext - -logger = get_logger("kokoro_prompt_generator") - - -class PromptGenerator: - """ - Kokoro Flow Chatter 提示词生成器 - - 负责根据会话状态和场景生成不同的LLM提示词, - 引导AI以"体验者"的身份参与对话。 - - V2 升级:支持动态Action发现,与AFC的Action系统完全兼容 - V4 升级:完整集成用户人设、世界观、表达风格和规则 - V5 超融合架构:集成S4U所有上下文模块(关系、记忆、表达习惯等) - """ - - # ========== 超融合架构 SYSTEM_PROMPT_TEMPLATE V7 ========== - # 简洁自然版,参考 S4U 风格 - SYSTEM_PROMPT_TEMPLATE = """你不是 AI,你是一个真实的人,有自己的生活、想法和情绪。 - -## 1. 你是谁 -{personality_core} - -{personality_side} - -{identity} - -{background_story} - -## 2. 规则 -{safety_guidelines} - -- 说话要自然,像平时发微信一样 -- 有自己的边界感,不用无条件迎合 -- 称呼对方用自然的昵称,别念复杂的用户名 - -## 3. 现在的情况 -**时间**: {current_time} -**场景**: {chat_scene} -{schedule_block} - -你现在的心情:{mood} -你对对方的印象:{impression_of_user} - -## 4. 你和对方的关系 -{relation_info_block} - -{memory_block} - -## 5. 你能做的事 -{available_actions_block} - -## 6. 怎么回复 -{reply_style} - -{expression_habits_block} - -### 输出格式(JSON) -```json -{{ - "thought": "你在想什么", - "expected_user_reaction": "你觉得对方会怎么回应", - "max_wait_seconds": 等多久(60-900,不等就填0), - "actions": [ - {{"type": "reply", "content": "你要说的话"}} - ] -}} -``` - -不想做任何事就用 `{{"type": "do_nothing"}}`""" - - # 回应消息场景的用户提示词模板(V7: 支持多条消息) - RESPONDING_USER_PROMPT_TEMPLATE = """## 聊天记录 -{narrative_history} - -## 新消息 -{incoming_messages} - ---- -看完这些消息,你想怎么回应?用 JSON 输出你的想法和决策。""" - - # 超时决策场景的用户提示词模板(V7重构:简洁自然) - TIMEOUT_DECISION_USER_PROMPT_TEMPLATE = """## 聊天记录 -{narrative_history} - -## 现在的情况 -你发了消息,等了 {wait_duration_seconds:.0f} 秒({wait_duration_minutes:.1f} 分钟),对方还没回。 -你之前觉得对方可能会:{expected_user_reaction} - -{followup_warning} - -你发的最后一条:{last_bot_message} - ---- -你拿起手机看了一眼,发现对方还没回复。你想怎么办? - -选项: -1. **继续等** - 用 `do_nothing`,设个 `max_wait_seconds` 等一会儿再看 -2. **发消息** - 用 `reply`,不过别太频繁追问 -3. **算了不等了** - 用 `do_nothing`,`max_wait_seconds` 设为 0 - -用 JSON 输出你的想法和决策。""" - - # 连续思考场景的用户提示词模板 - CONTINUOUS_THINKING_USER_PROMPT_TEMPLATE = """## 聊天记录 -{narrative_history} - -## 现在的情况 -你在等对方回复,已经等了 {wait_duration_seconds:.0f} 秒。 -你之前觉得对方可能会:{expected_user_reaction} - -你发的最后一条:{last_bot_message} - ---- -等待的时候你在想什么?用 JSON 输出,`actions` 留空就行。""" - - # 主动思考场景的用户提示词模板 - PROACTIVE_THINKING_USER_PROMPT_TEMPLATE = """## 聊天记录 -{narrative_history} - -## 现在的情况 -现在是 {current_time},距离你们上次聊天已经过了 {silence_duration}。 - -{relation_block} - -{trigger_context} - ---- -你突然想起了对方。要不要联系一下? - -说实话,不联系也完全没问题——不打扰也是一种温柔。 -如果决定联系,想好说什么,要自然一点。 - -用 JSON 输出你的想法和决策。不想发消息就用 `do_nothing`。""" - - def __init__(self, persona_description: str = ""): - """ - 初始化提示词生成器 - - Args: - persona_description: 人设描述 - """ - self.persona_description = persona_description or self._get_default_persona() - - def _get_default_persona(self) -> str: - """获取默认人设描述""" - return """你是一个温暖、真诚的对话伙伴。 -你有自己的情感和想法,会真实地表达内心的感受。 -你关心与用户的关系,但也保持健康的边界。 -你可以感到开心、好奇、担心、或者稍微沮丧,这些都是正常的。""" - - def set_persona(self, persona_description: str) -> None: - """设置人设描述""" - self.persona_description = persona_description - - def _format_emotional_state(self, state: EmotionalState) -> dict[str, str | float]: - """格式化情感状态用于模板替换""" - return { - "mood": state.mood, - "mood_intensity": state.mood_intensity, - "relationship_warmth": state.relationship_warmth, - "impression_of_user": state.impression_of_user or "还没有形成明确的印象", - "anxiety_level": state.anxiety_level, - "engagement_level": state.engagement_level, - } - - def _format_narrative_history( - self, - mental_log: list[MentalLogEntry], - max_entries: int = 15, - ) -> str: - """ - 将心理活动日志格式化为叙事历史 - - Args: - mental_log: 心理活动日志列表 - max_entries: 最大条目数 - - Returns: - str: 格式化的叙事历史文本 - """ - if not mental_log: - return "(这是对话的开始,还没有历史记录)" - - # 获取最近的日志条目 - recent_entries = mental_log[-max_entries:] - - narrative_parts = [] - for entry in recent_entries: - timestamp_str = time.strftime( - "%Y-%m-%d %H:%M:%S", - time.localtime(entry.timestamp) - ) - - if entry.event_type == MentalLogEventType.USER_MESSAGE: - narrative_parts.append( - f"[{timestamp_str}] 用户说:{entry.content}" - ) - elif entry.event_type == MentalLogEventType.BOT_ACTION: - if entry.thought: - narrative_parts.append( - f"[{timestamp_str}] (你的内心:{entry.thought})" - ) - if entry.content: - narrative_parts.append( - f"[{timestamp_str}] 你回复:{entry.content}" - ) - elif entry.event_type == MentalLogEventType.WAITING_UPDATE: - if entry.thought: - narrative_parts.append( - f"[{timestamp_str}] (等待中的想法:{entry.thought})" - ) - elif entry.event_type == MentalLogEventType.CONTINUOUS_THINKING: - if entry.thought: - narrative_parts.append( - f"[{timestamp_str}] (思绪飘过:{entry.thought})" - ) - elif entry.event_type == MentalLogEventType.STATE_CHANGE: - if entry.content: - narrative_parts.append( - f"[{timestamp_str}] {entry.content}" - ) - - return "\n".join(narrative_parts) - - def _format_history_from_context( - self, - context: "StreamContext", - mental_log: list[MentalLogEntry] | None = None, - ) -> str: - """ - 从 StreamContext 的历史消息构建叙事历史 - - 这是实现"无缝融入"的关键: - - 从同一个数据库读取历史消息(与AFC共享) - - 遵循全局配置 [chat].max_context_size - - 将消息渲染成KFC的叙事体格式 - - Args: - context: 聊天流上下文,包含共享的历史消息 - mental_log: 可选的心理活动日志,用于补充内心独白 - - Returns: - str: 格式化的叙事历史文本 - """ - from src.config.config import global_config - - # 从 StreamContext 获取历史消息,遵循全局上下文长度配置 - max_context = 25 # 默认值 - if global_config and hasattr(global_config, 'chat') and global_config.chat: - max_context = getattr(global_config.chat, "max_context_size", 25) - history_messages = context.get_messages(limit=max_context, include_unread=False) - - if not history_messages and not mental_log: - return "(这是对话的开始,还没有历史记录)" - - # 获取Bot的用户ID用于判断消息来源 - bot_user_id = None - if global_config and hasattr(global_config, 'bot') and global_config.bot: - bot_user_id = str(getattr(global_config.bot, 'qq_account', '')) - - narrative_parts = [] - - # 首先,将数据库历史消息转换为叙事格式 - for msg in history_messages: - timestamp_str = time.strftime( - "%Y-%m-%d %H:%M:%S", - time.localtime(msg.time or time.time()) - ) - - # 判断是用户消息还是Bot消息 - msg_user_id = str(msg.user_info.user_id) if msg.user_info else "" - is_bot_message = bot_user_id and msg_user_id == bot_user_id - content = msg.processed_plain_text or msg.display_message or "" - - if is_bot_message: - narrative_parts.append(f"[{timestamp_str}] 你回复:{content}") - else: - sender_name = msg.user_info.user_nickname if msg.user_info else "用户" - narrative_parts.append(f"[{timestamp_str}] {sender_name}说:{content}") - - # 然后,补充 mental_log 中的内心独白(如果有) - if mental_log: - for entry in mental_log[-5:]: # 只取最近5条心理活动 - timestamp_str = time.strftime( - "%Y-%m-%d %H:%M:%S", - time.localtime(entry.timestamp) - ) - - if entry.event_type == MentalLogEventType.BOT_ACTION and entry.thought: - narrative_parts.append(f"[{timestamp_str}] (你的内心:{entry.thought})") - elif entry.event_type == MentalLogEventType.CONTINUOUS_THINKING and entry.thought: - narrative_parts.append(f"[{timestamp_str}] (思绪飘过:{entry.thought})") - - return "\n".join(narrative_parts) - - def _format_available_actions( - self, - available_actions: dict[str, ActionInfo], - ) -> str: - """ - 格式化可用动作列表为提示词块 - - Args: - available_actions: 可用动作字典 {动作名: ActionInfo} - - Returns: - str: 格式化的动作描述文本 - """ - if not available_actions: - # 使用默认的内置动作 - return self._get_default_actions_block() - - action_blocks = [] - - for action_name, action_info in available_actions.items(): - # 构建动作描述 - description = action_info.description or f"执行 {action_name} 动作" - - # 构建参数说明 - params_lines = [] - if action_info.action_parameters: - for param_name, param_desc in action_info.action_parameters.items(): - params_lines.append(f' - `{param_name}`: {param_desc}') - - # 构建使用场景 - require_lines = [] - if action_info.action_require: - for req in action_info.action_require: - require_lines.append(f" - {req}") - - # 组装动作块 - action_block = f"""### `{action_name}` -**描述**: {description}""" - - if params_lines: - action_block += f""" -**参数**: -{chr(10).join(params_lines)}""" - else: - action_block += "\n**参数**: 无" - - if require_lines: - action_block += f""" -**使用场景**: -{chr(10).join(require_lines)}""" - - # 添加示例 - example_params = {} - if action_info.action_parameters: - for param_name, param_desc in action_info.action_parameters.items(): - example_params[param_name] = f"<{param_desc}>" - - import orjson - params_json = orjson.dumps(example_params, option=orjson.OPT_INDENT_2).decode('utf-8') if example_params else "{}" - action_block += f""" -**示例**: -```json -{{ - "type": "{action_name}", - "reason": "选择这个动作的原因", - {params_json[1:-1] if params_json != '{}' else ''} -}} -```""" - - action_blocks.append(action_block) - - return "\n\n".join(action_blocks) - - def _get_default_actions_block(self) -> str: - """获取默认的内置动作描述块""" - return """### `reply` -**描述**: 发送文字回复给用户 -**参数**: - - `content`: 回复的文字内容(必须) -**示例**: -```json -{"type": "reply", "content": "你好呀!今天过得怎么样?"} -``` - -### `poke_user` -**描述**: 戳一戳用户,轻量级互动 -**参数**: 无 -**示例**: -```json -{"type": "poke_user", "reason": "想逗逗他"} -``` - -### `update_internal_state` -**描述**: 更新你的内部情感状态 -**重要约束**: -⚠️ **情绪变化必须有明确的上下文理由**: - - 只有当聊天内容中有明确触发情绪变化的事件时才更新情绪 - - 禁止无缘无故地变成负面情绪(如低落、沮丧、难过等) - - 情绪应该保持相对稳定,除非聊天中发生了真正影响情绪的事情 - - 默认保持"平静"或当前情绪即可,不需要每次都更新 - -**参数**: - - `mood`: 当前心情(如"平静"、"开心"、"好奇"等,避免负面情绪除非有明确理由) - - `mood_intensity`: 心情强度(0.0-1.0,变化幅度不宜过大) - - `impression_of_user`: 对用户的印象描述 - - `engagement_level`: 投入程度(0.0-1.0) -**示例**: -```json -{"type": "update_internal_state", "mood": "开心", "mood_intensity": 0.6, "reason": "对方分享了有趣的事情"} -``` - -### `do_nothing` -**描述**: 明确表示"思考后决定不作回应" -**参数**: 无 -**示例**: -```json -{"type": "do_nothing", "reason": "现在不是说话的好时机"} -```""" - - def generate_system_prompt( - self, - session: KokoroSession, - available_actions: Optional[dict[str, ActionInfo]] = None, - context_data: Optional[dict[str, str]] = None, - chat_stream: Optional["ChatStream"] = None, - ) -> str: - """ - 生成系统提示词 - - V6模块化升级:使用 prompt_modules 构建模块化的提示词 - - 每个模块独立构建,职责清晰 - - 回复相关(人设、上下文)与动作定义分离 - - Args: - session: 当前会话 - available_actions: 可用动作字典,如果为None则使用默认动作 - context_data: S4U上下文数据字典(包含relation_info, memory_block等) - chat_stream: 聊天流(用于判断群聊/私聊场景) - - Returns: - str: 系统提示词 - """ - from .prompt_modules import build_system_prompt - - return build_system_prompt( - session=session, - available_actions=available_actions, - context_data=context_data, - chat_stream=chat_stream, - ) - - def generate_responding_prompt( - self, - session: KokoroSession, - message_content: str, - sender_name: str, - sender_id: str, - message_time: Optional[float] = None, - available_actions: Optional[dict[str, ActionInfo]] = None, - context: Optional["StreamContext"] = None, - context_data: Optional[dict[str, str]] = None, - chat_stream: Optional["ChatStream"] = None, - all_unread_messages: Optional[list] = None, # V7: 支持多条消息 - ) -> tuple[str, str]: - """ - 生成回应消息场景的提示词 - - V3 升级:支持从 StreamContext 读取共享的历史消息 - V5 超融合:集成S4U所有上下文模块 - V7 升级:支持多条消息(打断机制合并处理pending消息) - - Args: - session: 当前会话 - message_content: 收到的主消息内容(兼容旧调用方式) - sender_name: 发送者名称 - sender_id: 发送者ID - message_time: 消息时间戳 - available_actions: 可用动作字典 - context: 聊天流上下文(可选),用于读取共享的历史消息 - context_data: S4U上下文数据字典(包含relation_info, memory_block等) - chat_stream: 聊天流(用于判断群聊/私聊场景) - all_unread_messages: 所有未读消息列表(V7新增,包含pending消息) - - Returns: - tuple[str, str]: (系统提示词, 用户提示词) - """ - system_prompt = self.generate_system_prompt( - session, - available_actions, - context_data=context_data, - chat_stream=chat_stream, - ) - - # V3: 优先从 StreamContext 读取历史(与AFC共享同一数据源) - if context: - narrative_history = self._format_history_from_context(context, session.mental_log) - else: - # 回退到仅使用 mental_log(兼容旧调用方式) - narrative_history = self._format_narrative_history(session.mental_log) - - # V7: 格式化收到的消息(支持多条) - incoming_messages = self._format_incoming_messages( - message_content=message_content, - sender_name=sender_name, - sender_id=sender_id, - message_time=message_time, - all_unread_messages=all_unread_messages, - ) - - user_prompt = self.RESPONDING_USER_PROMPT_TEMPLATE.format( - narrative_history=narrative_history, - incoming_messages=incoming_messages, - ) - - return system_prompt, user_prompt - - def _format_incoming_messages( - self, - message_content: str, - sender_name: str, - sender_id: str, - message_time: Optional[float] = None, - all_unread_messages: Optional[list] = None, - ) -> str: - """ - 格式化收到的消息(V7新增) - - 支持单条消息(兼容旧调用)和多条消息(打断合并场景) - - Args: - message_content: 主消息内容 - sender_name: 发送者名称 - sender_id: 发送者ID - message_time: 消息时间戳 - all_unread_messages: 所有未读消息列表 - - Returns: - str: 格式化的消息文本 - """ - if message_time is None: - message_time = time.time() - - # 如果有多条消息,格式化为消息组 - if all_unread_messages and len(all_unread_messages) > 1: - lines = [f"**用户连续发送了 {len(all_unread_messages)} 条消息:**\n"] - - for i, msg in enumerate(all_unread_messages, 1): - msg_time = msg.time or time.time() - msg_time_str = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(msg_time)) - msg_sender = msg.user_info.user_nickname if msg.user_info else sender_name - msg_content = msg.processed_plain_text or msg.display_message or "" - - lines.append(f"[{i}] 来自:{msg_sender}") - lines.append(f" 时间:{msg_time_str}") - lines.append(f" 内容:{msg_content}") - lines.append("") - - lines.append("**提示**:请综合理解这些消息的整体意图,不需要逐条回复。") - return "\n".join(lines) - - # 单条消息(兼容旧格式) - message_time_str = time.strftime( - "%Y-%m-%d %H:%M:%S", - time.localtime(message_time) - ) - return f"""来自:{sender_name}(用户ID: {sender_id}) -时间:{message_time_str} -内容:{message_content}""" - - def generate_timeout_decision_prompt( - self, - session: KokoroSession, - available_actions: Optional[dict[str, ActionInfo]] = None, - ) -> tuple[str, str]: - """ - 生成超时决策场景的提示词(V7:增加连续追问限制) - - Args: - session: 当前会话 - available_actions: 可用动作字典 - - Returns: - tuple[str, str]: (系统提示词, 用户提示词) - """ - system_prompt = self.generate_system_prompt(session, available_actions) - - narrative_history = self._format_narrative_history(session.mental_log) - - wait_duration = session.get_waiting_duration() - - # V7: 生成连续追问警告 - followup_count = session.consecutive_followup_count - max_followups = session.max_consecutive_followups - - if followup_count >= max_followups: - followup_warning = f"""⚠️ **重要提醒**: -你已经连续追问了 {followup_count} 次,对方都没有回复。 -**强烈建议不要再发消息了**——继续追问会显得很缠人、很不尊重对方的空间。 -对方可能真的在忙,或者暂时不想回复,这都是正常的。 -请选择 `do_nothing` 继续等待,或者直接结束对话(设置 `max_wait_seconds: 0`)。""" - elif followup_count > 0: - followup_warning = f"""📝 提示:这已经是你第 {followup_count + 1} 次等待对方回复了。 -如果对方持续没有回应,可能真的在忙或不方便,不需要急着追问。""" - else: - followup_warning = "" - - user_prompt = self.TIMEOUT_DECISION_USER_PROMPT_TEMPLATE.format( - narrative_history=narrative_history, - wait_duration_seconds=wait_duration, - wait_duration_minutes=wait_duration / 60, - expected_user_reaction=session.expected_user_reaction or "不确定", - followup_warning=followup_warning, - last_bot_message=session.last_bot_message or "(没有记录)", - ) - - return system_prompt, user_prompt - - def generate_continuous_thinking_prompt( - self, - session: KokoroSession, - available_actions: Optional[dict[str, ActionInfo]] = None, - ) -> tuple[str, str]: - """ - 生成连续思考场景的提示词 - - Args: - session: 当前会话 - available_actions: 可用动作字典 - - Returns: - tuple[str, str]: (系统提示词, 用户提示词) - """ - system_prompt = self.generate_system_prompt(session, available_actions) - - narrative_history = self._format_narrative_history( - session.mental_log, - max_entries=10 # 连续思考时使用较少的历史 - ) - - wait_duration = session.get_waiting_duration() - - user_prompt = self.CONTINUOUS_THINKING_USER_PROMPT_TEMPLATE.format( - narrative_history=narrative_history, - wait_duration_seconds=wait_duration, - wait_duration_minutes=wait_duration / 60, - max_wait_seconds=session.max_wait_seconds, - expected_user_reaction=session.expected_user_reaction or "不确定", - last_bot_message=session.last_bot_message or "(没有记录)", - ) - - return system_prompt, user_prompt - - def generate_proactive_thinking_prompt( - self, - session: KokoroSession, - trigger_context: str, - available_actions: Optional[dict[str, ActionInfo]] = None, - context_data: Optional[dict[str, str]] = None, - chat_stream: Optional["ChatStream"] = None, - ) -> tuple[str, str]: - """ - 生成主动思考场景的提示词 - - 这是私聊专属的功能,用于实现"主动找话题、主动关心用户"。 - 主动思考不是"必须发消息",而是"想一想要不要联系对方"。 - - Args: - session: 当前会话 - trigger_context: 触发上下文描述(如"沉默了2小时") - available_actions: 可用动作字典 - context_data: S4U上下文数据(包含全局关系信息) - chat_stream: 聊天流 - - Returns: - tuple[str, str]: (系统提示词, 用户提示词) - """ - from datetime import datetime - import time - - # 生成系统提示词(使用 context_data 获取完整的关系和记忆信息) - system_prompt = self.generate_system_prompt( - session, - available_actions, - context_data=context_data, - chat_stream=chat_stream, - ) - - narrative_history = self._format_narrative_history( - session.mental_log, - max_entries=10, # 主动思考时使用较少的历史 - ) - - # 计算沉默时长 - silence_seconds = time.time() - session.last_activity_at - if silence_seconds < 3600: - silence_duration = f"{silence_seconds / 60:.0f}分钟" - else: - silence_duration = f"{silence_seconds / 3600:.1f}小时" - - # 当前时间 - current_time = datetime.now().strftime("%Y年%m月%d日 %H:%M") - - # 从 context_data 获取全局关系信息(这是正确的来源) - relation_block = "" - if context_data: - relation_info = context_data.get("relation_info", "") - if relation_info: - relation_block = f"### 你与对方的关系\n{relation_info}" - - if not relation_block: - # 回退:使用 session 的情感状态(不太准确但有总比没有好) - es = session.emotional_state - relation_block = f"""### 你与对方的关系 -- 当前心情:{es.mood} -- 对对方的印象:{es.impression_of_user or "还在慢慢了解中"}""" - - user_prompt = self.PROACTIVE_THINKING_USER_PROMPT_TEMPLATE.format( - narrative_history=narrative_history, - current_time=current_time, - silence_duration=silence_duration, - relation_block=relation_block, - trigger_context=trigger_context, - ) - - return system_prompt, user_prompt - - def build_messages_for_llm( - self, - system_prompt: str, - user_prompt: str, - stream_id: str = "", - ) -> list[dict[str, str]]: - """ - 构建LLM请求的消息列表 - - Args: - system_prompt: 系统提示词 - user_prompt: 用户提示词 - stream_id: 聊天流ID(用于日志) - - Returns: - list[dict]: 消息列表 - """ - # INFO日志:打印完整的KFC提示词(可观测性增强) - full_prompt = f"[SYSTEM]\n{system_prompt}\n\n[USER]\n{user_prompt}" - logger.info( - f"Final KFC prompt constructed for stream {stream_id}:\n" - f"--- PROMPT START ---\n" - f"{full_prompt}\n" - f"--- PROMPT END ---" - ) - - return [ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_prompt}, - ] - - -# 全局提示词生成器实例 -_prompt_generator: Optional[PromptGenerator] = None - - -def get_prompt_generator(persona_description: str = "") -> PromptGenerator: - """获取全局提示词生成器实例""" - global _prompt_generator - if _prompt_generator is None: - _prompt_generator = PromptGenerator(persona_description) - return _prompt_generator - - -def set_prompt_generator_persona(persona_description: str) -> None: - """设置全局提示词生成器的人设""" - generator = get_prompt_generator() - generator.set_persona(persona_description) diff --git a/src/plugins/built_in/kokoro_flow_chatter/prompt_modules.py b/src/plugins/built_in/kokoro_flow_chatter/prompt_modules.py deleted file mode 100644 index 2b3e6ac84..000000000 --- a/src/plugins/built_in/kokoro_flow_chatter/prompt_modules.py +++ /dev/null @@ -1,369 +0,0 @@ -""" -Kokoro Flow Chatter 模块化提示词组件 - -将提示词拆分为独立的模块,每个模块负责特定的内容生成: -1. 核心身份模块 - 人设、人格、世界观 -2. 行为准则模块 - 规则、安全边界 -3. 情境上下文模块 - 时间、场景、关系、记忆 -4. 动作能力模块 - 可用动作的描述 -5. 输出格式模块 - JSON格式要求 - -设计理念: -- 每个模块只负责自己的部分,互不干扰 -- 回复相关内容(人设、上下文)与动作定义分离 -- 方便独立调试和优化每个部分 -""" - -from datetime import datetime -from typing import TYPE_CHECKING, Any, Optional - -import orjson - -from src.common.logger import get_logger -from src.config.config import global_config -from src.plugin_system.base.component_types import ActionInfo - -if TYPE_CHECKING: - from src.chat.message_receive.chat_stream import ChatStream - -from .models import EmotionalState, KokoroSession - -logger = get_logger("kfc_prompt_modules") - - -# ============================================================ -# 模块1: 核心身份 - 人设/人格/背景 -# ============================================================ - -def build_identity_module() -> str: - """ - 构建核心身份模块 - - 包含:人格核心、人格细节、外貌身份、世界观背景 - 这些是回复内容的基础,决定了"你是谁" - """ - if global_config is None: - raise RuntimeError("global_config 未初始化") - - personality_cfg = global_config.personality - - personality_core = personality_cfg.personality_core or "你是一个温暖、真诚的对话伙伴。" - personality_side = personality_cfg.personality_side or "" - identity = personality_cfg.identity or "" - background_story = personality_cfg.background_story or "" - - parts = ["## 1. 你是谁", personality_core] - - if personality_side: - parts.append(f"\n{personality_side}") - - if identity: - parts.append(f"\n{identity}") - - if background_story: - parts.append(f"\n### 背景故事\n{background_story}") - - return "\n".join(parts) - - -# ============================================================ -# 模块2: 行为准则 - 规则/边界/KFC特有准则 -# ============================================================ - -def build_rules_module() -> str: - """ - 构建行为准则模块 - - 包含:安全规则 + KFC 通用准则 - 这些是行为的边界,决定了"什么能做什么不能做" - """ - if global_config is None: - raise RuntimeError("global_config 未初始化") - - safety_guidelines = global_config.personality.safety_guidelines or [] - if isinstance(safety_guidelines, list): - safety_guidelines_str = "\n".join(f"- {rule}" for rule in safety_guidelines) - else: - safety_guidelines_str = str(safety_guidelines) - - # KFC 特有准则(简洁自然版) - kfc_rules = """### 你的行为原则 -- 你是真实的人,有自己的生活、情绪和想法 -- 说话自然、口语化,像发微信一样随意 -- 有自己的边界感,不会无条件迎合 -- 称呼对方时用自然的昵称或简称,别念复杂的用户名 - -### 格式注意 -- 聊天记录中的"[表情包:描述]"是系统识别转述,不是你发的 -- 想发表情包要用 emoji 动作,不要在回复里写"[表情包:xxx]"这种格式""" - - return f"""## 2. 行为准则 -{safety_guidelines_str} - -{kfc_rules}""" - - -# ============================================================ -# 模块3: 情境上下文 - 时间/场景/内在状态/关系/记忆 -# ============================================================ - -def build_context_module( - session: KokoroSession, - chat_stream: Optional["ChatStream"] = None, - context_data: Optional[dict[str, str]] = None, -) -> str: - """ - 构建情境上下文模块 - - 包含:当前时间、聊天场景、内在状态、关系信息、记忆 - 这些是回复的上下文,决定了"当前在什么情况下" - - Args: - session: 当前会话 - chat_stream: 聊天流(判断群聊/私聊) - context_data: S4U 上下文数据 - """ - context_data = context_data or {} - - # 时间和场景 - current_time = datetime.now().strftime("%Y年%m月%d日 %H:%M:%S") - is_group_chat = bool(chat_stream and chat_stream.group_info) - chat_scene = "你在群里聊天" if is_group_chat else "你在和对方私聊" - - # 日程(如果有)- 只是背景,不主动提及 - schedule_block = context_data.get("schedule", "") - - # 内在状态(简化版,更自然) - es = session.emotional_state - inner_state = f"""### 你现在的状态 -- 心情:{es.mood} -- 你们的关系:{es.impression_of_user or "刚认识,还在慢慢了解"} -- 你的投入度:{'聊得挺起劲' if es.engagement_level > 0.6 else '一般般' if es.engagement_level > 0.3 else '有点心不在焉'}""" - - # 关系信息 - relation_info = context_data.get("relation_info", "") - - # 记忆 - memory_block = context_data.get("memory_block", "") - - parts = [ - "## 3. 当前情境", - f"**现在是** {current_time}", - f"**{chat_scene}**", - ] - - # 日程是你的当前状态,但不要主动提及 - if schedule_block: - parts.append(f"""### 你的当前状态(重要:不要主动提及) -{schedule_block} -⚠️ 这是你当前正在做的事,作为你的背景状态存在。除非对方主动问你"在干嘛"、"忙不忙"等问题,否则**绝对不要在回复中主动提起**。就像真人聊天一样,没人会无缘无故说"我正在XX"。""") - - parts.append("") - parts.append(inner_state) - - if relation_info: - parts.append("") - parts.append("## 4. 你和对方的关系") - parts.append(relation_info) - - if memory_block: - parts.append("") - parts.append(memory_block) - - return "\n".join(parts) - - -# ============================================================ -# 模块4: 动作能力 - 可用动作的描述 -# ============================================================ - -def build_actions_module(available_actions: Optional[dict[str, ActionInfo]] = None) -> str: - """ - 构建动作能力模块 - - 包含:所有可用动作的描述、参数、示例 - 这部分与回复内容分离,只描述"能做什么" - - Args: - available_actions: 可用动作字典 - """ - if not available_actions: - actions_block = _get_default_actions_block() - else: - actions_block = _format_available_actions(available_actions) - - return f"""## 5. 你能做的事情 - -{actions_block}""" - - -def _format_available_actions(available_actions: dict[str, ActionInfo]) -> str: - """格式化可用动作列表(简洁版)""" - action_blocks = [] - - for action_name, action_info in available_actions.items(): - description = action_info.description or f"执行 {action_name}" - - # 构建动作块(简洁格式) - action_block = f"### `{action_name}` - {description}" - - # 参数说明(如果有) - if action_info.action_parameters: - params_lines = [f" - `{name}`: {desc}" for name, desc in action_info.action_parameters.items()] - action_block += f"\n参数:\n{chr(10).join(params_lines)}" - - # 使用场景(如果有) - if action_info.action_require: - require_lines = [f" - {req}" for req in action_info.action_require] - action_block += f"\n使用场景:\n{chr(10).join(require_lines)}" - - # 简洁示例 - example_params = "" - if action_info.action_parameters: - param_examples = [f'"{name}": "..."' for name in action_info.action_parameters.keys()] - example_params = ", " + ", ".join(param_examples) - - action_block += f'\n```json\n{{"type": "{action_name}"{example_params}}}\n```' - - action_blocks.append(action_block) - - return "\n\n".join(action_blocks) - - -def _get_default_actions_block() -> str: - """获取默认的内置动作描述块""" - return """### `reply` - 发消息 -发送文字回复 -```json -{"type": "reply", "content": "你要说的话"} -``` - -### `poke_user` - 戳一戳 -戳对方一下 -```json -{"type": "poke_user"} -``` - -### `update_internal_state` - 更新你的状态 -更新你的心情和对对方的印象 -```json -{"type": "update_internal_state", "mood": "开心", "impression_of_user": "挺有趣的人"} -``` - -### `do_nothing` - 不做任何事 -想了想,决定现在不说话 -```json -{"type": "do_nothing"} -```""" - - -# ============================================================ -# 模块5: 表达与输出格式 - 回复风格 + JSON格式 -# ============================================================ - -def build_output_module( - context_data: Optional[dict[str, str]] = None, -) -> str: - """ - 构建输出格式模块 - - 包含:表达风格、表达习惯、JSON 输出格式要求 - 这部分定义了"怎么说"和"输出什么格式" - - Args: - context_data: S4U 上下文数据(包含 expression_habits) - """ - if global_config is None: - raise RuntimeError("global_config 未初始化") - - context_data = context_data or {} - - reply_style = global_config.personality.reply_style or "" - expression_habits = context_data.get("expression_habits", "") - - # JSON 输出格式说明 - 简洁版 - json_format = """### 输出格式 -用 JSON 输出你的想法和决策: - -```json -{ - "thought": "你的内心想法,想说什么就说什么", - "expected_user_reaction": "你觉得对方会怎么回应", - "max_wait_seconds": 等待秒数(60-900),不想等就填0, - "actions": [ - {"type": "reply", "content": "你要发送的消息"}, - {"type": "其他动作", ...} - ] -} -``` - -说明: -- `thought`:你脑子里在想什么,越自然越好 -- `actions`:你要做的事,可以组合多个动作 -- `max_wait_seconds`:设定一个时间,对方没回的话你会再想想要不要说点什么 -- 即使什么都不想做,也放一个 `{"type": "do_nothing"}`""" - - parts = ["## 6. 你的表达方式"] - - if reply_style: - parts.append(reply_style) - - if expression_habits: - parts.append("") - parts.append(expression_habits) - - parts.append("") - parts.append(json_format) - - return "\n".join(parts) - - -# ============================================================ -# 组装完整的 System Prompt -# ============================================================ - -def build_system_prompt( - session: KokoroSession, - available_actions: Optional[dict[str, ActionInfo]] = None, - context_data: Optional[dict[str, str]] = None, - chat_stream: Optional["ChatStream"] = None, -) -> str: - """ - 组装完整的系统提示词 - - 按模块顺序组装: - 1. 角色框架(你是对话体验者) - 2. 核心身份(人设) - 3. 行为准则(规则) - 4. 情境上下文(时间/状态/关系) - 5. 动作能力(可用动作) - 6. 输出格式(表达风格 + JSON) - - Args: - session: 当前会话 - available_actions: 可用动作字典 - context_data: S4U 上下文数据 - chat_stream: 聊天流 - - Returns: - 完整的系统提示词 - """ - # 角色框架 - 简洁自然 - role_frame = '你不是AI助手,你是一个真实的人,有自己的生活、想法和情绪。聊天时就像平时发微信一样自然。' - - # 组装各模块 - modules = [ - role_frame, - "", - build_identity_module(), - "", - build_rules_module(), - "", - build_context_module(session, chat_stream, context_data), - "", - build_actions_module(available_actions), - "", - build_output_module(context_data), - ] - - return "\n".join(modules) diff --git a/src/plugins/built_in/kokoro_flow_chatter/response_post_processor.py b/src/plugins/built_in/kokoro_flow_chatter/response_post_processor.py deleted file mode 100644 index e463d696b..000000000 --- a/src/plugins/built_in/kokoro_flow_chatter/response_post_processor.py +++ /dev/null @@ -1,169 +0,0 @@ -""" -KFC 响应后处理器 - -实现与全局后处理流程的集成: -- 中文错别字生成(typo_generator) -- 消息分割(punctuation/llm模式) - -设计理念:复用全局配置和AFC的核心分割逻辑,与AFC保持一致的后处理行为。 -""" - -import re -from typing import Any, Optional, TYPE_CHECKING - -from src.common.logger import get_logger -from src.config.config import global_config - -if TYPE_CHECKING: - from src.chat.utils.typo_generator import ChineseTypoGenerator - -logger = get_logger("kokoro_post_processor") - -# 延迟导入错别字生成器(避免循环导入和启动时的额外开销) -_typo_generator: Optional["ChineseTypoGenerator"] = None - - -def _get_typo_generator(): - """延迟加载错别字生成器""" - global _typo_generator - if _typo_generator is None: - try: - from src.chat.utils.typo_generator import ChineseTypoGenerator - - if global_config is None: - logger.warning("[KFC PostProcessor] global_config 未初始化") - return None - - # 从全局配置读取参数 - typo_cfg = global_config.chinese_typo - _typo_generator = ChineseTypoGenerator( - error_rate=typo_cfg.error_rate, - min_freq=typo_cfg.min_freq, - tone_error_rate=typo_cfg.tone_error_rate, - word_replace_rate=typo_cfg.word_replace_rate, - ) - logger.info("[KFC PostProcessor] 错别字生成器已初始化") - except Exception as e: - logger.warning(f"[KFC PostProcessor] 初始化错别字生成器失败: {e}") - _typo_generator = None - return _typo_generator - - -def split_by_punctuation(text: str, max_length: int = 256, max_sentences: int = 8) -> list[str]: - """ - 基于标点符号分割消息 - 复用AFC的核心逻辑 - - V6修复: 不再依赖长度判断,而是直接调用AFC的分割函数 - - Args: - text: 原始文本 - max_length: 单条消息最大长度(用于二次合并过长片段) - max_sentences: 最大句子数 - - Returns: - list[str]: 分割后的消息列表 - """ - if not text: - return [] - - # 直接复用AFC的核心分割逻辑 - from src.chat.utils.utils import split_into_sentences_w_remove_punctuation - - # AFC的分割函数会根据标点分割并概率性合并 - sentences = split_into_sentences_w_remove_punctuation(text) - - if not sentences: - return [text] if text else [] - - # 限制句子数量 - if len(sentences) > max_sentences: - sentences = sentences[:max_sentences] - - # 如果某个片段超长,进行二次切分 - result = [] - for sentence in sentences: - if len(sentence) > max_length: - # 超长片段按max_length硬切分 - for i in range(0, len(sentence), max_length): - chunk = sentence[i:i + max_length] - if chunk.strip(): - result.append(chunk.strip()) - else: - if sentence.strip(): - result.append(sentence.strip()) - - return result if result else [text] - - -async def process_reply_content(content: str) -> list[str]: - """ - 处理回复内容(主入口) - - 遵循全局配置: - - [response_post_process].enable_response_post_process - - [chinese_typo].enable - - [response_splitter].enable 和 .split_mode - - Args: - content: 原始回复内容 - - Returns: - list[str]: 处理后的消息列表(可能被分割成多条) - """ - if not content: - return [] - - if global_config is None: - logger.warning("[KFC PostProcessor] global_config 未初始化,返回原始内容") - return [content] - - # 检查全局开关 - post_process_cfg = global_config.response_post_process - if not post_process_cfg.enable_response_post_process: - logger.info("[KFC PostProcessor] 全局后处理已禁用,返回原始内容") - return [content] - - processed_content = content - - # Step 1: 错别字生成 - typo_cfg = global_config.chinese_typo - if typo_cfg.enable: - try: - typo_gen = _get_typo_generator() - if typo_gen: - processed_content, correction_suggestion = typo_gen.create_typo_sentence(content) - if correction_suggestion: - logger.info(f"[KFC PostProcessor] 生成错别字,建议纠正: {correction_suggestion}") - else: - logger.info("[KFC PostProcessor] 已应用错别字生成") - except Exception as e: - logger.warning(f"[KFC PostProcessor] 错别字生成失败: {e}") - # 失败时使用原内容 - processed_content = content - - # Step 2: 消息分割 - splitter_cfg = global_config.response_splitter - if splitter_cfg.enable: - split_mode = splitter_cfg.split_mode - max_length = splitter_cfg.max_length - max_sentences = splitter_cfg.max_sentence_num - - if split_mode == "punctuation": - # 基于标点符号分割 - result = split_by_punctuation( - processed_content, - max_length=max_length, - max_sentences=max_sentences, - ) - logger.info(f"[KFC PostProcessor] 标点分割完成,分为 {len(result)} 条消息") - return result - elif split_mode == "llm": - # LLM模式:目前暂不支持,回退到不分割 - logger.info("[KFC PostProcessor] LLM分割模式暂不支持,返回完整内容") - return [processed_content] - else: - logger.warning(f"[KFC PostProcessor] 未知分割模式: {split_mode}") - return [processed_content] - else: - # 分割器禁用,返回完整内容 - return [processed_content] diff --git a/src/plugins/built_in/kokoro_flow_chatter/session_manager.py b/src/plugins/built_in/kokoro_flow_chatter/session_manager.py deleted file mode 100644 index b5b102691..000000000 --- a/src/plugins/built_in/kokoro_flow_chatter/session_manager.py +++ /dev/null @@ -1,561 +0,0 @@ -""" -Kokoro Flow Chatter 会话管理器 - -负责管理用户会话的完整生命周期: -- 创建、加载、保存会话 -- 会话状态持久化 -- 会话清理和维护 -""" - -import asyncio -import json -import os -import time -from pathlib import Path -from typing import Optional - -from src.common.logger import get_logger - -from .models import ( - EmotionalState, - KokoroSession, - MentalLogEntry, - MentalLogEventType, - SessionStatus, -) - -logger = get_logger("kokoro_session_manager") - - -class SessionManager: - """ - Kokoro Flow Chatter 会话管理器 - - 单例模式实现,为每个私聊用户维护独立的会话 - - Features: - - 会话的创建、获取、更新和删除 - - 自动持久化到JSON文件 - - 会话过期清理 - - 线程安全的并发访问 - """ - - _instance: Optional["SessionManager"] = None - _lock = asyncio.Lock() - - def __new__(cls, *args, **kwargs): - if cls._instance is None: - cls._instance = super().__new__(cls) - return cls._instance - - def __init__( - self, - data_dir: str = "data/kokoro_flow_chatter/sessions", - max_session_age_days: int = 30, - auto_save_interval: int = 300, - ): - """ - 初始化会话管理器 - - Args: - data_dir: 会话数据存储目录 - max_session_age_days: 会话最大保留天数 - auto_save_interval: 自动保存间隔(秒) - """ - # 避免重复初始化 - if hasattr(self, "_initialized") and self._initialized: - return - - self._initialized = True - self.data_dir = Path(data_dir) - self.max_session_age_days = max_session_age_days - self.auto_save_interval = auto_save_interval - - # 内存中的会话缓存 - self._sessions: dict[str, KokoroSession] = {} - self._session_locks: dict[str, asyncio.Lock] = {} - - # 后台任务 - self._auto_save_task: Optional[asyncio.Task] = None - self._cleanup_task: Optional[asyncio.Task] = None - self._running = False - - # 确保数据目录存在 - self._ensure_data_dir() - - logger.info(f"SessionManager 初始化完成,数据目录: {self.data_dir}") - - def _ensure_data_dir(self) -> None: - """确保数据目录存在""" - self.data_dir.mkdir(parents=True, exist_ok=True) - - def _get_session_file_path(self, user_id: str) -> Path: - """获取会话文件路径""" - # 清理user_id中的特殊字符 - safe_user_id = "".join(c if c.isalnum() or c in "-_" else "_" for c in user_id) - return self.data_dir / f"{safe_user_id}.json" - - async def _get_session_lock(self, user_id: str) -> asyncio.Lock: - """获取会话级别的锁""" - if user_id not in self._session_locks: - self._session_locks[user_id] = asyncio.Lock() - return self._session_locks[user_id] - - async def start(self) -> None: - """启动会话管理器的后台任务""" - if self._running: - return - - self._running = True - - # 启动自动保存任务 - self._auto_save_task = asyncio.create_task(self._auto_save_loop()) - - # 启动清理任务 - self._cleanup_task = asyncio.create_task(self._cleanup_loop()) - - logger.info("SessionManager 后台任务已启动") - - async def stop(self) -> None: - """停止会话管理器并保存所有会话""" - self._running = False - - # 取消后台任务 - if self._auto_save_task: - self._auto_save_task.cancel() - try: - await self._auto_save_task - except asyncio.CancelledError: - pass - - if self._cleanup_task: - self._cleanup_task.cancel() - try: - await self._cleanup_task - except asyncio.CancelledError: - pass - - # 保存所有会话 - await self.save_all_sessions() - - logger.info("SessionManager 已停止,所有会话已保存") - - async def _auto_save_loop(self) -> None: - """自动保存循环""" - while self._running: - try: - await asyncio.sleep(self.auto_save_interval) - await self.save_all_sessions() - except asyncio.CancelledError: - break - except Exception as e: - logger.error(f"自动保存会话时出错: {e}") - - async def _cleanup_loop(self) -> None: - """清理过期会话循环""" - while self._running: - try: - # 每小时清理一次 - await asyncio.sleep(3600) - await self.cleanup_expired_sessions() - except asyncio.CancelledError: - break - except Exception as e: - logger.error(f"清理过期会话时出错: {e}") - - async def get_session(self, user_id: str, stream_id: str) -> KokoroSession: - """ - 获取或创建用户会话 - - Args: - user_id: 用户ID - stream_id: 聊天流ID - - Returns: - KokoroSession: 用户会话对象 - """ - lock = await self._get_session_lock(user_id) - async with lock: - # 检查内存缓存 - if user_id in self._sessions: - session = self._sessions[user_id] - # 更新stream_id(可能发生变化) - session.stream_id = stream_id - return session - - # 尝试从文件加载 - session = await self._load_session_from_file(user_id) - if session: - session.stream_id = stream_id - self._sessions[user_id] = session - logger.debug(f"从文件加载会话: {user_id}") - return session - - # 创建新会话 - session = KokoroSession( - user_id=user_id, - stream_id=stream_id, - status=SessionStatus.IDLE, - emotional_state=EmotionalState(), - mental_log=[], - ) - - # 添加初始日志条目 - initial_entry = MentalLogEntry( - event_type=MentalLogEventType.STATE_CHANGE, - timestamp=time.time(), - thought="与这位用户的对话开始了,我对接下来的交流充满期待。", - content="会话创建", - emotional_snapshot=session.emotional_state.to_dict(), - ) - session.add_mental_log_entry(initial_entry) - - self._sessions[user_id] = session - logger.info(f"创建新会话: {user_id}") - - return session - - async def _load_session_from_file(self, user_id: str) -> Optional[KokoroSession]: - """从文件加载会话""" - file_path = self._get_session_file_path(user_id) - - if not file_path.exists(): - return None - - try: - with open(file_path, "r", encoding="utf-8") as f: - data = json.load(f) - - session = KokoroSession.from_dict(data) - - # V7: 情绪健康检查 - 防止从持久化数据恢复无厘头的负面情绪 - session = self._sanitize_emotional_state(session) - - logger.debug(f"成功从文件加载会话: {user_id}") - return session - - except json.JSONDecodeError as e: - logger.error(f"解析会话文件失败 {user_id}: {e}") - # 备份损坏的文件 - backup_path = file_path.with_suffix(".json.bak") - os.rename(file_path, backup_path) - return None - except Exception as e: - logger.error(f"加载会话文件失败 {user_id}: {e}") - return None - - def _sanitize_emotional_state(self, session: KokoroSession) -> KokoroSession: - """ - V7: 情绪健康检查 - - 检查并修正不合理的情绪状态,防止: - 1. 无厘头的负面情绪从持久化数据恢复 - 2. 情绪强度过高(>0.8)的负面情绪 - 3. 长时间未更新的情绪状态 - - Args: - session: 会话对象 - - Returns: - 修正后的会话对象 - """ - emotional_state = session.emotional_state - current_mood = emotional_state.mood.lower() if emotional_state.mood else "" - - # 负面情绪关键词列表 - negative_moods = [ - "低落", "沮丧", "难过", "伤心", "失落", "郁闷", "烦躁", "焦虑", - "担忧", "害怕", "恐惧", "愤怒", "生气", "不安", "忧郁", "悲伤", - "sad", "depressed", "anxious", "angry", "upset", "worried" - ] - - is_negative = any(neg in current_mood for neg in negative_moods) - - # 检查1: 如果是负面情绪且强度较高(>0.6),重置为平静 - if is_negative and emotional_state.mood_intensity > 0.6: - logger.warning( - f"[KFC] 检测到高强度负面情绪 ({emotional_state.mood}, {emotional_state.mood_intensity:.1%})," - f"重置为平静状态" - ) - emotional_state.mood = "平静" - emotional_state.mood_intensity = 0.3 - - # 检查2: 如果情绪超过24小时未更新,重置为平静 - import time as time_module - time_since_update = time_module.time() - emotional_state.last_update_time - if time_since_update > 86400: # 24小时 = 86400秒 - logger.info( - f"[KFC] 情绪状态超过24小时未更新 ({time_since_update/3600:.1f}h)," - f"重置为平静状态" - ) - emotional_state.mood = "平静" - emotional_state.mood_intensity = 0.3 - emotional_state.anxiety_level = 0.0 - emotional_state.last_update_time = time_module.time() - - # 检查3: 焦虑程度过高也需要重置 - if emotional_state.anxiety_level > 0.8: - logger.info(f"[KFC] 焦虑程度过高 ({emotional_state.anxiety_level:.1%}),重置为正常") - emotional_state.anxiety_level = 0.3 - - return session - - async def save_session(self, user_id: str) -> bool: - """ - 保存单个会话到文件 - - Args: - user_id: 用户ID - - Returns: - bool: 是否保存成功 - """ - lock = await self._get_session_lock(user_id) - async with lock: - if user_id not in self._sessions: - return False - - session = self._sessions[user_id] - file_path = self._get_session_file_path(user_id) - - try: - data = session.to_dict() - - # 先写入临时文件,再重命名(原子操作) - temp_path = file_path.with_suffix(".json.tmp") - with open(temp_path, "w", encoding="utf-8") as f: - json.dump(data, f, ensure_ascii=False, indent=2) - - os.replace(temp_path, file_path) - logger.debug(f"保存会话成功: {user_id}") - return True - - except Exception as e: - logger.error(f"保存会话失败 {user_id}: {e}") - return False - - async def save_all_sessions(self) -> int: - """ - 保存所有会话 - - Returns: - int: 成功保存的会话数量 - """ - saved_count = 0 - for user_id in list(self._sessions.keys()): - if await self.save_session(user_id): - saved_count += 1 - - if saved_count > 0: - logger.debug(f"批量保存完成,共保存 {saved_count} 个会话") - - return saved_count - - async def update_session( - self, - user_id: str, - status: Optional[SessionStatus] = None, - emotional_state: Optional[EmotionalState] = None, - mental_log_entry: Optional[MentalLogEntry] = None, - **kwargs, - ) -> bool: - """ - 更新会话状态 - - Args: - user_id: 用户ID - status: 新的会话状态 - emotional_state: 新的情感状态 - mental_log_entry: 要添加的心理日志条目 - **kwargs: 其他要更新的字段 - - Returns: - bool: 是否更新成功 - """ - lock = await self._get_session_lock(user_id) - async with lock: - if user_id not in self._sessions: - return False - - session = self._sessions[user_id] - - if status is not None: - old_status = session.status - session.status = status - logger.debug(f"会话状态变更 {user_id}: {old_status} -> {status}") - - if emotional_state is not None: - session.emotional_state = emotional_state - - if mental_log_entry is not None: - session.add_mental_log_entry(mental_log_entry) - - # 更新其他字段 - for key, value in kwargs.items(): - if hasattr(session, key): - setattr(session, key, value) - - session.last_activity_at = time.time() - - return True - - async def delete_session(self, user_id: str) -> bool: - """ - 删除会话 - - Args: - user_id: 用户ID - - Returns: - bool: 是否删除成功 - """ - lock = await self._get_session_lock(user_id) - async with lock: - # 从内存中删除 - if user_id in self._sessions: - del self._sessions[user_id] - - # 从文件系统删除 - file_path = self._get_session_file_path(user_id) - if file_path.exists(): - try: - os.remove(file_path) - logger.info(f"删除会话: {user_id}") - return True - except Exception as e: - logger.error(f"删除会话文件失败 {user_id}: {e}") - return False - - return True - - async def cleanup_expired_sessions(self) -> int: - """ - 清理过期会话 - - Returns: - int: 清理的会话数量 - """ - cleaned_count = 0 - current_time = time.time() - max_age_seconds = self.max_session_age_days * 24 * 3600 - - # 检查文件系统中的所有会话 - for file_path in self.data_dir.glob("*.json"): - try: - with open(file_path, "r", encoding="utf-8") as f: - data = json.load(f) - - last_activity = data.get("last_activity_at", 0) - if current_time - last_activity > max_age_seconds: - user_id = data.get("user_id", file_path.stem) - - # 从内存中删除 - if user_id in self._sessions: - del self._sessions[user_id] - - # 删除文件 - os.remove(file_path) - cleaned_count += 1 - logger.info(f"清理过期会话: {user_id}") - - except Exception as e: - logger.error(f"清理会话时出错 {file_path}: {e}") - - if cleaned_count > 0: - logger.info(f"共清理 {cleaned_count} 个过期会话") - - return cleaned_count - - async def get_all_waiting_sessions(self) -> list[KokoroSession]: - """ - 获取所有处于等待状态的会话 - - Returns: - list[KokoroSession]: 等待中的会话列表 - """ - waiting_sessions = [] - - for session in self._sessions.values(): - if session.status == SessionStatus.WAITING: - waiting_sessions.append(session) - - return waiting_sessions - - async def get_all_sessions(self) -> list[KokoroSession]: - """ - 获取所有内存中的会话 - - 用于主动思考检查等需要遍历所有会话的场景 - - Returns: - list[KokoroSession]: 所有会话列表 - """ - return list(self._sessions.values()) - - async def get_session_statistics(self) -> dict: - """ - 获取会话统计信息 - - Returns: - dict: 统计信息字典 - """ - total_in_memory = len(self._sessions) - status_counts = {} - - for session in self._sessions.values(): - status = str(session.status) - status_counts[status] = status_counts.get(status, 0) + 1 - - # 统计文件系统中的会话 - total_on_disk = len(list(self.data_dir.glob("*.json"))) - - return { - "total_in_memory": total_in_memory, - "total_on_disk": total_on_disk, - "status_counts": status_counts, - "data_directory": str(self.data_dir), - } - - def get_session_sync(self, user_id: str) -> Optional[KokoroSession]: - """ - 同步获取会话(仅从内存缓存) - - Args: - user_id: 用户ID - - Returns: - Optional[KokoroSession]: 会话对象,如果不存在返回None - """ - return self._sessions.get(user_id) - - -# 全局会话管理器实例 -_session_manager: Optional[SessionManager] = None - - -def get_session_manager() -> SessionManager: - """获取全局会话管理器实例""" - global _session_manager - if _session_manager is None: - _session_manager = SessionManager() - return _session_manager - - -async def initialize_session_manager( - data_dir: str = "data/kokoro_flow_chatter/sessions", - **kwargs, -) -> SessionManager: - """ - 初始化并启动会话管理器 - - Args: - data_dir: 数据存储目录 - **kwargs: 其他配置参数 - - Returns: - SessionManager: 会话管理器实例 - """ - global _session_manager - _session_manager = SessionManager(data_dir=data_dir, **kwargs) - await _session_manager.start() - return _session_manager diff --git a/src/plugins/built_in/kokoro_flow_chatter_v2/__init__.py b/src/plugins/built_in/kokoro_flow_chatter_v2/__init__.py index a7e8604e8..692bdc389 100644 --- a/src/plugins/built_in/kokoro_flow_chatter_v2/__init__.py +++ b/src/plugins/built_in/kokoro_flow_chatter_v2/__init__.py @@ -20,7 +20,6 @@ from .models import ( from .session import KokoroSession, SessionManager, get_session_manager from .chatter import KokoroFlowChatterV2 from .replyer import generate_response -from .action_executor import ActionExecutor from .proactive_thinker import ( ProactiveThinker, get_proactive_thinker, @@ -50,7 +49,6 @@ __all__ = [ # Core Components "KokoroFlowChatterV2", "generate_response", - "ActionExecutor", # Proactive Thinker "ProactiveThinker", "get_proactive_thinker", diff --git a/src/plugins/built_in/kokoro_flow_chatter_v2/action_executor.py b/src/plugins/built_in/kokoro_flow_chatter_v2/action_executor.py deleted file mode 100644 index aa0fe2ef1..000000000 --- a/src/plugins/built_in/kokoro_flow_chatter_v2/action_executor.py +++ /dev/null @@ -1,228 +0,0 @@ -""" -Kokoro Flow Chatter V2 - 动作执行器 - -负责执行 LLM 决策的动作 -""" - -import asyncio -import time -from typing import TYPE_CHECKING, Any, Optional - -from src.chat.planner_actions.action_manager import ChatterActionManager -from src.common.logger import get_logger -from src.plugin_system.apis import send_api - -from .models import ActionModel, LLMResponse - -if TYPE_CHECKING: - from src.chat.message_receive.chat_stream import ChatStream - -logger = get_logger("kfc_v2_action_executor") - - -class ActionExecutor: - """ - 动作执行器 - - 职责: - - 执行 reply、poke_user 等动作 - - 通过 ActionManager 执行动态注册的动作 - """ - - # 内置动作(不通过 ActionManager) - BUILTIN_ACTIONS = {"reply", "do_nothing"} - - def __init__(self, stream_id: str): - self.stream_id = stream_id - self._action_manager = ChatterActionManager() - self._available_actions: dict = {} - - # 统计 - self._stats = { - "total_executed": 0, - "successful": 0, - "failed": 0, - } - - async def load_actions(self) -> dict: - """加载可用动作""" - await self._action_manager.load_actions(self.stream_id) - self._available_actions = self._action_manager.get_using_actions() - logger.debug(f"[ActionExecutor] 加载了 {len(self._available_actions)} 个动作") - return self._available_actions - - def get_available_actions(self) -> dict: - """获取可用动作""" - return self._available_actions.copy() - - async def execute( - self, - response: LLMResponse, - chat_stream: Optional["ChatStream"], - ) -> dict[str, Any]: - """ - 执行动作列表 - - Args: - response: LLM 响应 - chat_stream: 聊天流 - - Returns: - 执行结果 - """ - results = [] - has_reply = False - reply_content = "" - - for action in response.actions: - try: - result = await self._execute_action(action, chat_stream) - results.append(result) - - if result.get("success"): - self._stats["successful"] += 1 - if action.type in ("reply", "respond"): - has_reply = True - reply_content = action.params.get("content", "") - else: - self._stats["failed"] += 1 - - except Exception as e: - logger.error(f"[ActionExecutor] 执行动作失败 {action.type}: {e}") - results.append({ - "action_type": action.type, - "success": False, - "error": str(e), - }) - self._stats["failed"] += 1 - - self._stats["total_executed"] += 1 - - return { - "success": all(r.get("success", False) for r in results), - "results": results, - "has_reply": has_reply, - "reply_content": reply_content, - } - - async def _execute_action( - self, - action: ActionModel, - chat_stream: Optional["ChatStream"], - ) -> dict[str, Any]: - """执行单个动作""" - action_type = action.type - - if action_type == "reply": - return await self._execute_reply(action, chat_stream) - - elif action_type == "do_nothing": - logger.debug("[ActionExecutor] 执行 do_nothing") - return {"action_type": "do_nothing", "success": True} - - elif action_type == "poke_user": - return await self._execute_via_manager(action, chat_stream) - - elif action_type in self._available_actions: - return await self._execute_via_manager(action, chat_stream) - - else: - logger.warning(f"[ActionExecutor] 未知动作类型: {action_type}") - return { - "action_type": action_type, - "success": False, - "error": f"未知动作类型: {action_type}", - } - - async def _execute_reply( - self, - action: ActionModel, - chat_stream: Optional["ChatStream"], - ) -> dict[str, Any]: - """执行回复动作""" - content = action.params.get("content", "") - - if not content: - return { - "action_type": "reply", - "success": False, - "error": "回复内容为空", - } - - try: - # 消息后处理(分割、错别字等) - processed_messages = await self._process_reply_content(content) - - all_success = True - for msg in processed_messages: - success = await send_api.text_to_stream( - text=msg, - stream_id=self.stream_id, - typing=True, - ) - if not success: - all_success = False - - return { - "action_type": "reply", - "success": all_success, - "reply_text": content, - } - - except Exception as e: - logger.error(f"[ActionExecutor] 发送回复失败: {e}") - return { - "action_type": "reply", - "success": False, - "error": str(e), - } - - async def _process_reply_content(self, content: str) -> list[str]: - """处理回复内容(分割、错别字等)""" - try: - # 复用 v1 的后处理器 - from src.plugins.built_in.kokoro_flow_chatter.response_post_processor import ( - process_reply_content, - ) - - messages = await process_reply_content(content) - return messages if messages else [content] - - except Exception as e: - logger.warning(f"[ActionExecutor] 消息处理失败,使用原始内容: {e}") - return [content] - - async def _execute_via_manager( - self, - action: ActionModel, - chat_stream: Optional["ChatStream"], - ) -> dict[str, Any]: - """通过 ActionManager 执行动作""" - try: - result = await self._action_manager.execute_action( - action_name=action.type, - chat_id=self.stream_id, - target_message=None, - reasoning=f"KFC决策: {action.type}", - action_data=action.params, - thinking_id=None, - log_prefix="[KFC V2]", - ) - - return { - "action_type": action.type, - "success": result.get("success", False), - "result": result, - } - - except Exception as e: - logger.error(f"[ActionExecutor] ActionManager 执行失败: {e}") - return { - "action_type": action.type, - "success": False, - "error": str(e), - } - - def get_stats(self) -> dict: - """获取统计信息""" - return self._stats.copy() diff --git a/src/plugins/built_in/kokoro_flow_chatter_v2/chatter.py b/src/plugins/built_in/kokoro_flow_chatter_v2/chatter.py index 410226b95..38aaec0bd 100644 --- a/src/plugins/built_in/kokoro_flow_chatter_v2/chatter.py +++ b/src/plugins/built_in/kokoro_flow_chatter_v2/chatter.py @@ -10,21 +10,20 @@ Kokoro Flow Chatter V2 - Chatter 主类 import asyncio import time -from typing import TYPE_CHECKING, Any, ClassVar, Optional +from typing import TYPE_CHECKING, Any, ClassVar +from src.chat.planner_actions.action_manager import ChatterActionManager from src.common.data_models.message_manager_data_model import StreamContext from src.common.logger import get_logger from src.plugin_system.base.base_chatter import BaseChatter from src.plugin_system.base.component_types import ChatType -from .action_executor import ActionExecutor -from .models import EventType, SessionStatus +from .models import SessionStatus from .replyer import generate_response from .session import get_session_manager if TYPE_CHECKING: - from src.chat.planner_actions.action_manager import ChatterActionManager - from src.common.data_models.database_data_model import DatabaseMessages + pass logger = get_logger("kfc_v2_chatter") @@ -62,7 +61,6 @@ class KokoroFlowChatterV2(BaseChatter): # 核心组件 self.session_manager = get_session_manager() - self.action_executor = ActionExecutor(stream_id) # 并发控制 self._lock = asyncio.Lock() @@ -129,9 +127,12 @@ class KokoroFlowChatterV2(BaseChatter): timestamp=msg.time, ) - # 6. 加载可用动作 - await self.action_executor.load_actions() - available_actions = self.action_executor.get_available_actions() + # 6. 加载可用动作(通过 ActionModifier 过滤) + from src.chat.planner_actions.action_modifier import ActionModifier + + action_modifier = ActionModifier(self.action_manager, self.stream_id) + await action_modifier.modify_actions(chatter_name="KokoroFlowChatterV2") + available_actions = self.action_manager.get_using_actions() # 7. 获取聊天流 chat_stream = await self._get_chat_stream() @@ -146,7 +147,21 @@ class KokoroFlowChatterV2(BaseChatter): ) # 9. 执行动作 - exec_result = await self.action_executor.execute(response, chat_stream) + exec_results = [] + has_reply = False + for action in response.actions: + result = await self.action_manager.execute_action( + action_name=action.type, + chat_id=self.stream_id, + target_message=target_message, + reasoning=response.thought, + action_data=action.params, + thinking_id=None, + log_prefix="[KFC V2]", + ) + exec_results.append(result) + if result.get("success") and action.type in ("reply", "respond"): + has_reply = True # 10. 记录 Bot 规划到 mental_log session.add_bot_planning( @@ -174,7 +189,7 @@ class KokoroFlowChatterV2(BaseChatter): # 14. 更新统计 self._stats["messages_processed"] += len(unread_messages) - if exec_result.get("has_reply"): + if has_reply: self._stats["successful_responses"] += 1 logger.info( @@ -187,7 +202,7 @@ class KokoroFlowChatterV2(BaseChatter): return self._build_result( success=True, message="processed", - has_reply=exec_result.get("has_reply", False), + has_reply=has_reply, thought=response.thought, situation_type=situation_type, ) @@ -252,10 +267,7 @@ class KokoroFlowChatterV2(BaseChatter): def get_stats(self) -> dict[str, Any]: """获取统计信息""" - return { - **self._stats, - "action_executor_stats": self.action_executor.get_stats(), - } + return self._stats.copy() @property def is_processing(self) -> bool: diff --git a/src/plugins/built_in/kokoro_flow_chatter_v2/proactive_thinker.py b/src/plugins/built_in/kokoro_flow_chatter_v2/proactive_thinker.py index 382b9500f..0cab996ec 100644 --- a/src/plugins/built_in/kokoro_flow_chatter_v2/proactive_thinker.py +++ b/src/plugins/built_in/kokoro_flow_chatter_v2/proactive_thinker.py @@ -15,11 +15,11 @@ import time from datetime import datetime from typing import TYPE_CHECKING, Any, Callable, Coroutine, Optional +from src.chat.planner_actions.action_manager import ChatterActionManager from src.common.logger import get_logger from src.config.config import global_config from src.plugin_system.apis.unified_scheduler import TriggerType, unified_scheduler -from .action_executor import ActionExecutor from .models import EventType, SessionStatus from .replyer import generate_response from .session import KokoroSession, get_session_manager @@ -257,8 +257,8 @@ class ProactiveThinker: chat_stream = await self._get_chat_stream(session.stream_id) # 加载动作 - action_executor = ActionExecutor(session.stream_id) - await action_executor.load_actions() + action_manager = ChatterActionManager() + await action_manager.load_actions(session.stream_id) # 调用 Replyer 生成超时决策 response = await generate_response( @@ -266,11 +266,20 @@ class ProactiveThinker: user_name=session.user_id, # 这里可以改进,获取真实用户名 situation_type="timeout", chat_stream=chat_stream, - available_actions=action_executor.get_available_actions(), + available_actions=action_manager.get_using_actions(), ) # 执行动作 - exec_result = await action_executor.execute(response, chat_stream) + for action in response.actions: + await action_manager.execute_action( + action_name=action.type, + chat_id=session.stream_id, + target_message=None, + reasoning=response.thought, + action_data=action.params, + thinking_id=None, + log_prefix="[KFC V2 ProactiveThinker]", + ) # 记录到 mental_log session.add_bot_planning( @@ -389,8 +398,8 @@ class ProactiveThinker: chat_stream = await self._get_chat_stream(session.stream_id) # 加载动作 - action_executor = ActionExecutor(session.stream_id) - await action_executor.load_actions() + action_manager = ChatterActionManager() + await action_manager.load_actions(session.stream_id) # 计算沉默时长 silence_seconds = time.time() - session.last_activity_at @@ -405,7 +414,7 @@ class ProactiveThinker: user_name=session.user_id, situation_type="proactive", chat_stream=chat_stream, - available_actions=action_executor.get_available_actions(), + available_actions=action_manager.get_using_actions(), extra_context={ "trigger_reason": trigger_reason, "silence_duration": silence_duration, @@ -425,7 +434,16 @@ class ProactiveThinker: return # 执行动作 - exec_result = await action_executor.execute(response, chat_stream) + for action in response.actions: + await action_manager.execute_action( + action_name=action.type, + chat_id=session.stream_id, + target_message=None, + reasoning=response.thought, + action_data=action.params, + thinking_id=None, + log_prefix="[KFC V2 ProactiveThinker]", + ) # 记录到 mental_log session.add_bot_planning( From 1750004ffa489e6177bc89e5ebe3d0c8d3e9a494 Mon Sep 17 00:00:00 2001 From: Windpicker-owo <3431391539@qq.com> Date: Sun, 30 Nov 2025 15:04:00 +0800 Subject: [PATCH 09/28] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0KFC=20V2?= =?UTF-8?q?=E4=B8=93=E5=B1=9E=E5=8A=A8=E4=BD=9C=E6=A8=A1=E5=9D=97=E5=8F=8A?= =?UTF-8?q?=E7=9B=B8=E5=85=B3=E5=8A=9F=E8=83=BD=EF=BC=8C=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E5=9B=9E=E5=A4=8D=E6=9C=BA=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/chat/planner_actions/action_manager.py | 497 ++++-------------- src/chat/planner_actions/action_modifier.py | 2 +- .../affinity_flow_chatter/actions/__init__.py | 7 + .../actions}/reply.py | 80 +-- .../planner/plan_executor.py | 32 -- .../built_in/affinity_flow_chatter/plugin.py | 16 + src/plugins/built_in/core_actions/emoji.py | 3 +- src/plugins/built_in/core_actions/plugin.py | 21 +- .../kokoro_flow_chatter_v2/__init__.py | 13 + .../actions/__init__.py | 7 + .../kokoro_flow_chatter_v2/actions/reply.py | 82 +++ .../built_in/kokoro_flow_chatter_v2/plugin.py | 16 +- src/plugins/built_in/napcat_adapter/plugin.py | 2 +- 13 files changed, 266 insertions(+), 512 deletions(-) create mode 100644 src/plugins/built_in/affinity_flow_chatter/actions/__init__.py rename src/plugins/built_in/{core_actions => affinity_flow_chatter/actions}/reply.py (79%) create mode 100644 src/plugins/built_in/kokoro_flow_chatter_v2/actions/__init__.py create mode 100644 src/plugins/built_in/kokoro_flow_chatter_v2/actions/reply.py diff --git a/src/chat/planner_actions/action_manager.py b/src/chat/planner_actions/action_manager.py index 4c4f30e30..b688213ff 100644 --- a/src/chat/planner_actions/action_manager.py +++ b/src/chat/planner_actions/action_manager.py @@ -1,13 +1,10 @@ import asyncio -import time import traceback from typing import Any, TYPE_CHECKING from src.chat.message_receive.chat_stream import get_chat_manager from src.common.data_models.database_data_model import DatabaseMessages from src.common.logger import get_logger -from src.person_info.person_info import get_person_info_manager -from src.plugin_system.apis import database_api from src.plugin_system.base.base_action import BaseAction from src.plugin_system.base.component_types import ActionInfo, ComponentType from src.plugin_system.core.component_registry import component_registry @@ -20,23 +17,19 @@ logger = get_logger("action_manager") class ChatterActionManager: """ - 动作管理器,用于管理各种类型的动作 - - 现在统一使用新插件系统,简化了原有的新旧兼容逻辑。 + 动作管理器,用于管理和执行动作 + + 职责: + - 加载和管理可用动作集 + - 创建动作实例 + - 执行动作(所有动作逻辑在 Action.execute() 中实现) """ def __init__(self): """初始化动作管理器""" - - # 当前正在使用的动作集合,在规划开始时加载 self._using_actions: dict[str, ActionInfo] = {} self.chat_id: str | None = None - self.log_prefix: str = "ChatterActionManager" - # 批量存储支持 - self._batch_storage_enabled = False - self._pending_actions = [] - self._current_chat_id = None async def load_actions(self, stream_id: str | None): """根据 stream_id 加载当前可用的动作""" @@ -44,8 +37,6 @@ class ChatterActionManager: self._using_actions = component_registry.get_default_actions(stream_id) logger.debug(f"已为 stream '{stream_id}' 加载 {len(self._using_actions)} 个可用动作: {list(self._using_actions.keys())}") - # === 执行Action方法 === - @staticmethod def create_action( action_name: str, @@ -70,12 +61,13 @@ class ChatterActionManager: chat_stream: 聊天流 log_prefix: 日志前缀 shutting_down: 是否正在关闭 + action_message: 目标消息 Returns: - Optional[BaseAction]: 创建的动作处理器实例,如果动作名称未注册则返回None + BaseAction | None: 创建的动作处理器实例 """ try: - # 获取组件类 - 明确指定查询Action类型 + # 获取组件类 component_class: type[BaseAction] = component_registry.get_component_class( action_name, ComponentType.ACTION ) # type: ignore @@ -110,8 +102,6 @@ class ChatterActionManager: except Exception as e: logger.error(f"创建Action实例失败 {action_name}: {e}") - import traceback - logger.error(traceback.format_exc()) return None @@ -119,17 +109,8 @@ class ChatterActionManager: """获取当前正在使用的动作集合""" return self._using_actions.copy() - # === Modify相关方法 === def remove_action_from_using(self, action_name: str) -> bool: - """ - 从当前使用的动作集中移除指定动作 - - Args: - action_name: 动作名称 - - Returns: - bool: 移除是否成功 - """ + """从当前使用的动作集中移除指定动作""" if action_name not in self._using_actions: logger.warning(f"移除失败: 动作 {action_name} 不在当前使用的动作集中") return False @@ -141,7 +122,6 @@ class ChatterActionManager: async def restore_actions(self) -> None: """恢复到当前 stream_id 的默认动作集""" actions_to_restore = list(self._using_actions.keys()) - # 使用 self.chat_id 来恢复当前上下文的动作 await self.load_actions(self.chat_id) logger.debug(f"恢复动作集: 从 {actions_to_restore} 恢复到 stream '{self.chat_id}' 的默认动作集 {list(self._using_actions.keys())}") @@ -157,13 +137,13 @@ class ChatterActionManager: clear_unread_messages: bool = True, ) -> Any: """ - 执行单个动作的通用函数 + 执行单个动作 - 所有动作(包括 reply/respond)都通过 BaseAction.execute() 执行 + 所有动作逻辑都在 BaseAction.execute() 中实现 Args: action_name: 动作名称 - chat_id: 聊天id + chat_id: 聊天ID target_message: 目标消息 reasoning: 执行理由 action_data: 动作数据 @@ -172,16 +152,16 @@ class ChatterActionManager: clear_unread_messages: 是否清除未读消息 Returns: - 执行结果 + 执行结果字典 """ chat_stream = None try: - # 通过chat_id获取chat_stream + # 获取 chat_stream chat_manager = get_chat_manager() chat_stream = await chat_manager.get_stream(chat_id) if not chat_stream: - logger.error(f"{log_prefix} 无法找到chat_id对应的chat_stream: {chat_id}") + logger.error(f"{log_prefix} 无法找到 chat_stream: {chat_id}") return { "action_type": action_name, "success": False, @@ -189,66 +169,75 @@ class ChatterActionManager: "error": "chat_stream not found", } - # 设置正在回复的状态 + # 设置正在处理的状态 chat_stream.context.is_replying = True # no_action 特殊处理 if action_name == "no_action": - return {"action_type": "no_action", "success": True, "reply_text": "", "command": ""} + return {"action_type": "no_action", "success": True, "reply_text": ""} - # 统一通过 _handle_action 执行所有动作 - success, reply_text, command = await self._handle_action( - chat_stream, - action_name, - reasoning, - action_data or {}, - {}, # cycle_timers - thinking_id, - target_message, + # 创建并执行动作 + action_handler = self.create_action( + action_name=action_name, + action_data=action_data or {}, + reasoning=reasoning, + cycle_timers={}, + thinking_id=thinking_id or "", + chat_stream=chat_stream, + log_prefix=log_prefix or self.log_prefix, + action_message=target_message, ) - # 记录执行的动作到目标消息 + if not action_handler: + logger.error(f"{log_prefix} 创建动作处理器失败: {action_name}") + return { + "action_type": action_name, + "success": False, + "reply_text": "", + "error": f"Failed to create action handler: {action_name}", + } + + # 执行动作 + success, reply_text = await action_handler.handle_action() + + # 记录动作到消息并存储动作信息 if success: asyncio.create_task(self._record_action_to_message(chat_stream, action_name, target_message, action_data)) - # 重置打断计数 - await self._reset_interruption_count_after_action(chat_stream.stream_id) + asyncio.create_task(self._reset_interruption_count(chat_stream.stream_id)) + # 统一存储动作信息 + asyncio.create_task( + self._store_action_info( + action_handler=action_handler, + action_name=action_name, + reply_text=reply_text, + target_message=target_message, + ) + ) return { "action_type": action_name, "success": success, "reply_text": reply_text, - "command": command, } except Exception as e: logger.error(f"{log_prefix} 执行动作时出错: {e}") - logger.error(f"{log_prefix} 错误信息: {traceback.format_exc()}") + logger.error(traceback.format_exc()) return { "action_type": action_name, "success": False, "reply_text": "", - "loop_info": None, "error": str(e), } finally: - # 确保重置正在回复的状态 if chat_stream: chat_stream.context.is_replying = False - async def _record_action_to_message(self, chat_stream, action_name, target_message, action_data): - """ - 记录执行的动作到目标消息中 - - Args: - chat_stream: ChatStream实例 - action_name: 动作名称 - target_message: 目标消息 - action_data: 动作数据 - """ + async def _record_action_to_message(self, chat_stream, action_name: str, target_message, action_data: dict | None): + """记录执行的动作到目标消息""" try: from src.chat.message_manager.message_manager import message_manager - # 获取目标消息ID target_message_id = None if target_message: target_message_id = target_message.message_id @@ -256,362 +245,66 @@ class ChatterActionManager: target_message_id = action_data.get("target_message_id") if not target_message_id: - logger.debug(f"无法获取目标消息ID,动作: {action_name}") return - # 通过message_manager更新消息的动作记录并刷新focus_energy await message_manager.add_action( - stream_id=chat_stream.stream_id, message_id=target_message_id, action=action_name + stream_id=chat_stream.stream_id, + message_id=target_message_id, + action=action_name, ) - logger.debug(f"已记录动作 {action_name} 到消息 {target_message_id} 并更新focus_energy") + logger.debug(f"已记录动作 {action_name} 到消息 {target_message_id}") except Exception as e: logger.error(f"记录动作到消息失败: {e}") - # 不抛出异常,避免影响主要功能 - - async def _reset_interruption_count_after_action(self, stream_id: str): - """在动作执行成功后重置打断计数""" + async def _reset_interruption_count(self, stream_id: str): + """重置打断计数""" try: - from src.plugin_system.apis.chat_api import get_chat_manager - chat_manager = get_chat_manager() chat_stream = await chat_manager.get_stream(stream_id) - if chat_stream: - context = chat_stream.context - if context.interruption_count > 0: - old_count = context.interruption_count - # old_afc_adjustment = context.context.get_afc_threshold_adjustment() - await context.reset_interruption_count() - logger.debug( - f"动作执行成功,重置聊天流 {stream_id} 的打断计数: {old_count} -> 0" - ) + if chat_stream and chat_stream.context.interruption_count > 0: + old_count = chat_stream.context.interruption_count + await chat_stream.context.reset_interruption_count() + logger.debug(f"重置打断计数: {old_count} -> 0") except Exception as e: logger.warning(f"重置打断计数时出错: {e}") - async def _handle_action( - self, chat_stream, action, reasoning, action_data, cycle_timers, thinking_id, action_message - ) -> tuple[bool, str, str]: - """ - 处理具体的动作执行 - - Args: - chat_stream: ChatStream实例 - action: 动作名称 - reasoning: 执行理由 - action_data: 动作数据 - cycle_timers: 循环计时器 - thinking_id: 思考ID - action_message: 动作消息 - - Returns: - tuple: (执行是否成功, 回复文本, 命令文本) - - 功能说明: - - 创建对应的动作处理器 - - 执行动作并捕获异常 - - 返回执行结果供上级方法整合 - """ - if not chat_stream: - return False, "", "" - try: - # 创建动作处理器 - action_handler = self.create_action( - action_name=action, - action_data=action_data, - reasoning=reasoning, - cycle_timers=cycle_timers, - thinking_id=thinking_id, - chat_stream=chat_stream, - log_prefix=self.log_prefix, - action_message=action_message, - ) - if not action_handler: - # 动作处理器创建失败,尝试回退机制 - logger.warning(f"{self.log_prefix} 创建动作处理器失败: {action},尝试回退方案") - - # 获取当前可用的动作 - available_actions = self.get_using_actions() - fallback_action = None - - # 回退优先级:reply > 第一个可用动作 - if "reply" in available_actions: - fallback_action = "reply" - elif available_actions: - fallback_action = next(iter(available_actions.keys())) - - if fallback_action and fallback_action != action: - logger.info(f"{self.log_prefix} 使用回退动作: {fallback_action}") - action_handler = self.create_action( - action_name=fallback_action, - action_data=action_data, - reasoning=f"原动作'{action}'不可用,自动回退。{reasoning}", - cycle_timers=cycle_timers, - thinking_id=thinking_id, - chat_stream=chat_stream, - log_prefix=self.log_prefix, - action_message=action_message, - ) - - if not action_handler: - logger.error(f"{self.log_prefix} 回退方案也失败,无法创建任何动作处理器") - return False, "", "" - - # 执行动作 - success, reply_text = await action_handler.handle_action() - return success, reply_text, "" - except Exception as e: - logger.error(f"{self.log_prefix} 处理{action}时出错: {e}") - traceback.print_exc() - return False, "", "" - - async def _send_and_store_reply( - self, - chat_stream: "ChatStream", - response_set, - loop_start_time, - action_message, - cycle_timers: dict[str, float], - thinking_id, - actions, - should_quote_reply: bool | None = None, - ) -> tuple[str, dict[str, float]]: - """ - 发送并存储回复信息 - - Args: - chat_stream: ChatStream实例 - response_set: 回复内容集合 - loop_start_time: 循环开始时间 - action_message: 动作消息 - cycle_timers: 循环计时器 - thinking_id: 思考ID - actions: 动作列表 - should_quote_reply: 是否应该引用回复原消息,None表示自动决定 - - Returns: - Tuple[Dict[str, Any], str, Dict[str, float]]: 循环信息, 回复文本, 循环计时器 - """ - # 发送回复 - with Timer("回复发送", cycle_timers): - reply_text = await self.send_response( - chat_stream, response_set, loop_start_time, action_message, should_quote_reply - ) - - # 存储reply action信息 - person_info_manager = get_person_info_manager() - - # 获取 platform,如果不存在则从 chat_stream 获取,如果还是 None 则使用默认值 - if action_message: - platform = action_message.chat_info.platform - user_id = action_message.user_info.user_id - else: - platform = getattr(chat_stream, "platform", "unknown") - user_id = "" - - # 获取用户信息并生成回复提示 - person_id = person_info_manager.get_person_id( - platform, - user_id, - ) - person_name = await person_info_manager.get_value(person_id, "person_name") - action_prompt_display = f"你对{person_name}进行了回复:{reply_text}" - - # 存储动作信息到数据库(支持批量存储) - if self._batch_storage_enabled: - self.add_action_to_batch( - action_name="reply", - action_data={"reply_text": reply_text}, - thinking_id=thinking_id or "", - action_done=True, - action_build_into_prompt=False, - action_prompt_display=action_prompt_display, - ) - else: - await database_api.store_action_info( - chat_stream=chat_stream, - action_build_into_prompt=False, - action_prompt_display=action_prompt_display, - action_done=True, - thinking_id=thinking_id, - action_data={"reply_text": reply_text}, - action_name="reply", - ) - - return reply_text, cycle_timers - - async def send_response( - self, chat_stream, reply_set, thinking_start_time, message_data, should_quote_reply: bool | None = None - ) -> str: - """ - 发送回复内容的具体实现 - - Args: - chat_stream: ChatStream实例 - reply_set: 回复内容集合,包含多个回复段 - thinking_start_time: 思考开始时间 - message_data: 消息数据 - should_quote_reply: 是否应该引用回复原消息,None表示自动决定 - - Returns: - str: 完整的回复文本 - - 功能说明: - - 检查是否有新消息需要回复 - - 处理主动思考的"沉默"决定 - - 根据消息数量决定是否添加回复引用 - - 逐段发送回复内容,支持打字效果 - - 正确处理元组格式的回复段 - """ - current_time = time.time() - # 计算新消息数量 - await message_api.count_new_messages( - chat_id=chat_stream.stream_id, start_time=thinking_start_time, end_time=current_time - ) - - # 根据新消息数量决定是否需要引用回复 - reply_text = "" - # 检查是否为主动思考消息 - if message_data: - is_proactive_thinking = getattr(message_data, "message_type", None) == "proactive_thinking" - else: - is_proactive_thinking = True - - logger.debug(f"[send_response] message_data: {message_data}") - - first_replied = False - for reply_seg in reply_set: - # 调试日志:验证reply_seg的格式 - logger.debug(f"Processing reply_seg type: {type(reply_seg)}, content: {reply_seg}") - - # 修正:正确处理元组格式 (格式为: (type, content)) - if isinstance(reply_seg, tuple) and len(reply_seg) >= 2: - _, data = reply_seg - else: - # 向下兼容:如果已经是字符串,则直接使用 - data = str(reply_seg) - - if isinstance(data, list): - data = "".join(map(str, data)) - reply_text += data - - # 如果是主动思考且内容为"沉默",则不发送 - if is_proactive_thinking and data.strip() == "沉默": - logger.info(f"{self.log_prefix} 主动思考决定保持沉默,不发送消息") - continue - - # 发送第一段回复 - if not first_replied: - # 决定是否引用回复 - is_private_chat = not bool(chat_stream.group_info) - - # 如果明确指定了should_quote_reply,则使用指定值 - if should_quote_reply is not None: - set_reply_flag = should_quote_reply and bool(message_data) - logger.debug( - f"📤 [ActionManager] 使用planner指定的引用设置: should_quote_reply={should_quote_reply}" - ) - else: - # 否则使用默认逻辑:默认不引用,让对话更流畅自然 - set_reply_flag = False - logger.debug( - f"📤 [ActionManager] 使用默认引用逻辑: 默认不引用(is_private={is_private_chat})" - ) - - logger.debug( - f"📤 [ActionManager] 准备发送第一段回复。message_data: {message_data}, set_reply: {set_reply_flag}" - ) - await send_api.text_to_stream( - text=data, - stream_id=chat_stream.stream_id, - reply_to_message=message_data, - set_reply=set_reply_flag, - typing=False, - ) - first_replied = True - else: - # 发送后续回复 - await send_api.text_to_stream( - text=data, - stream_id=chat_stream.stream_id, - reply_to_message=None, - set_reply=False, - typing=True, - ) - - return reply_text - - def enable_batch_storage(self, chat_id: str): - """启用批量存储模式""" - self._batch_storage_enabled = True - self._current_chat_id = chat_id - self._pending_actions.clear() - logger.debug(f"已启用批量存储模式,chat_id: {chat_id}") - - def disable_batch_storage(self): - """禁用批量存储模式""" - self._batch_storage_enabled = False - self._current_chat_id = None - self._pending_actions = [] # 清空队列 - logger.debug("已禁用批量存储模式") - - def add_action_to_batch( + async def _store_action_info( self, + action_handler: BaseAction, action_name: str, - action_data: dict, - thinking_id: str = "", - action_done: bool = True, - action_build_into_prompt: bool = False, - action_prompt_display: str = "", + reply_text: str, + target_message: DatabaseMessages | None, ): - """添加动作到批量存储列表""" - if not self._batch_storage_enabled: - return False - - action_record = { - "action_name": action_name, - "action_data": action_data, - "thinking_id": thinking_id, - "action_done": action_done, - "action_build_into_prompt": action_build_into_prompt, - "action_prompt_display": action_prompt_display, - "timestamp": time.time(), - } - self._pending_actions.append(action_record) - logger.debug(f"已添加动作到批量存储列表: {action_name} (当前待处理: {len(self._pending_actions)} 个)") - return True - - async def flush_batch_storage(self, chat_stream): - """批量存储所有待处理的动作记录""" - if not self._pending_actions: - logger.debug("没有待处理的动作需要批量存储") - return - + """统一存储动作信息到数据库""" try: - logger.info(f"开始批量存储 {len(self._pending_actions)} 个动作记录") + from src.person_info.person_info import get_person_info_manager + from src.plugin_system.apis import database_api - # 批量存储所有动作 - stored_count = 0 - for action_data in self._pending_actions: - try: - result = await database_api.store_action_info( - chat_stream=chat_stream, - action_name=action_data.get("action_name", ""), - action_data=action_data.get("action_data", {}), - action_done=action_data.get("action_done", True), - action_build_into_prompt=action_data.get("action_build_into_prompt", False), - action_prompt_display=action_data.get("action_prompt_display", ""), - thinking_id=action_data.get("thinking_id", ""), - ) - if result: - stored_count += 1 - except Exception as e: - logger.error(f"存储单个动作记录失败: {e}") + # 构建 action_prompt_display + action_prompt_display = "" + if reply_text: + person_info_manager = get_person_info_manager() + if target_message: + platform = target_message.chat_info.platform + user_id = target_message.user_info.user_id + person_id = person_info_manager.get_person_id(platform, user_id) + person_name = await person_info_manager.get_value(person_id, "person_name") + action_prompt_display = f"你对{person_name}进行了回复:{reply_text}" + else: + action_prompt_display = f"统一回应:{reply_text}" - logger.info(f"批量存储完成: 成功存储 {stored_count}/{len(self._pending_actions)} 个动作记录") - - # 清空待处理列表 - self._pending_actions.clear() + # 存储动作信息 + await database_api.store_action_info( + chat_stream=action_handler.chat_stream, + action_build_into_prompt=False, + action_prompt_display=action_prompt_display, + action_done=True, + thinking_id=action_handler.thinking_id, + action_data={"reply_text": reply_text} if reply_text else action_handler.action_data, + action_name=action_name, + ) + logger.debug(f"已存储动作信息: {action_name}") except Exception as e: - logger.error(f"批量存储动作记录时发生错误: {e}") + logger.error(f"存储动作信息失败: {e}") diff --git a/src/chat/planner_actions/action_modifier.py b/src/chat/planner_actions/action_modifier.py index a0197d7a5..bfd92a72e 100644 --- a/src/chat/planner_actions/action_modifier.py +++ b/src/chat/planner_actions/action_modifier.py @@ -187,7 +187,7 @@ class ActionModifier: logger.debug(f"{self.log_prefix}阶段三移除动作: {action_name},原因: {reason}") # === 统一日志记录 === - all_removals = chat_type_removals + removals_s1 + removals_s2 + removals_s3 + all_removals = removals_s0 + removals_s1 + removals_s2 + removals_s3 removals_summary: str = "" if all_removals: removals_summary = " | ".join([f"{name}({reason})" for name, reason in all_removals]) diff --git a/src/plugins/built_in/affinity_flow_chatter/actions/__init__.py b/src/plugins/built_in/affinity_flow_chatter/actions/__init__.py new file mode 100644 index 000000000..75f252ae3 --- /dev/null +++ b/src/plugins/built_in/affinity_flow_chatter/actions/__init__.py @@ -0,0 +1,7 @@ +""" +AFC 专属动作模块 +""" + +from .reply import ReplyAction, RespondAction + +__all__ = ["ReplyAction", "RespondAction"] diff --git a/src/plugins/built_in/core_actions/reply.py b/src/plugins/built_in/affinity_flow_chatter/actions/reply.py similarity index 79% rename from src/plugins/built_in/core_actions/reply.py rename to src/plugins/built_in/affinity_flow_chatter/actions/reply.py index 08d77c2ce..74311f501 100644 --- a/src/plugins/built_in/core_actions/reply.py +++ b/src/plugins/built_in/affinity_flow_chatter/actions/reply.py @@ -1,21 +1,23 @@ """ -回复动作模块 +AFC 回复动作模块 -定义了三种回复相关动作: +定义了两种回复相关动作: - reply: 针对单条消息的深度回复(使用 s4u 模板) - respond: 对未读消息的统一回应(使用 normal 模板) -- no_reply: 选择不回复 + +这些动作是 AffinityFlowChatter 的专属动作。 """ import asyncio from typing import ClassVar +from src.common.data_models.database_data_model import DatabaseMessages from src.common.logger import get_logger from src.config.config import global_config from src.plugin_system import ActionActivationType, BaseAction, ChatMode -from src.plugin_system.apis import database_api, generator_api, send_api +from src.plugin_system.apis import generator_api, send_api -logger = get_logger("reply_actions") +logger = get_logger("afc_reply_actions") class ReplyAction(BaseAction): @@ -63,8 +65,11 @@ class ReplyAction(BaseAction): async def execute(self) -> tuple[bool, str]: """执行reply动作 - 完整的回复流程""" try: + # 确保 action_message 是 DatabaseMessages 类型,否则使用 None + reply_message = self.action_message if isinstance(self.action_message, DatabaseMessages) else None + # 检查目标消息是否为表情包 - if self.action_message and getattr(self.action_message, "is_emoji", False): + if reply_message and getattr(reply_message, "is_emoji", False): if not getattr(global_config.chat, "allow_reply_to_emoji", True): logger.info(f"{self.log_prefix} 目标消息为表情包且配置不允许回复,跳过") return True, "" @@ -76,9 +81,9 @@ class ReplyAction(BaseAction): # 生成回复 success, response_set, _ = await generator_api.generate_reply( chat_stream=self.chat_stream, - reply_message=self.action_message, + reply_message=reply_message, action_data=action_data, - available_actions={self.action_name: None}, + available_actions={self.action_name: self.get_action_info()}, enable_tool=global_config.tool.enable_tool, request_type="chat.replyer", from_plugin=False, @@ -91,9 +96,6 @@ class ReplyAction(BaseAction): # 发送回复 reply_text = await self._send_response(response_set) - # 存储动作信息 - await self._store_action_info(reply_text) - logger.info(f"{self.log_prefix} reply 动作执行成功") return True, reply_text @@ -112,6 +114,9 @@ class ReplyAction(BaseAction): should_quote = self.action_data.get("should_quote_reply", False) first_sent = False + # 确保 action_message 是 DatabaseMessages 类型 + reply_message = self.action_message if isinstance(self.action_message, DatabaseMessages) else None + for reply_seg in response_set: # 处理元组格式 if isinstance(reply_seg, tuple) and len(reply_seg) >= 2: @@ -129,8 +134,8 @@ class ReplyAction(BaseAction): await send_api.text_to_stream( text=data, stream_id=self.chat_stream.stream_id, - reply_to_message=self.action_message, - set_reply=should_quote and bool(self.action_message), + reply_to_message=reply_message, + set_reply=should_quote and bool(reply_message), typing=False, ) first_sent = True @@ -144,33 +149,6 @@ class ReplyAction(BaseAction): ) return reply_text - - async def _store_action_info(self, reply_text: str): - """存储动作信息到数据库""" - from src.person_info.person_info import get_person_info_manager - - person_info_manager = get_person_info_manager() - - if self.action_message: - platform = self.action_message.chat_info.platform - user_id = self.action_message.user_info.user_id - else: - platform = getattr(self.chat_stream, "platform", "unknown") - user_id = "" - - person_id = person_info_manager.get_person_id(platform, user_id) - person_name = await person_info_manager.get_value(person_id, "person_name") - action_prompt_display = f"你对{person_name}进行了回复:{reply_text}" - - await database_api.store_action_info( - chat_stream=self.chat_stream, - action_build_into_prompt=False, - action_prompt_display=action_prompt_display, - action_done=True, - thinking_id=self.thinking_id, - action_data={"reply_text": reply_text}, - action_name="reply", - ) class RespondAction(BaseAction): @@ -220,12 +198,15 @@ class RespondAction(BaseAction): action_data = self.action_data.copy() action_data["prompt_mode"] = "normal" + # 确保 action_message 是 DatabaseMessages 类型,否则使用 None + reply_message = self.action_message if isinstance(self.action_message, DatabaseMessages) else None + # 生成回复 success, response_set, _ = await generator_api.generate_reply( chat_stream=self.chat_stream, - reply_message=self.action_message, + reply_message=reply_message, action_data=action_data, - available_actions={self.action_name: None}, + available_actions={self.action_name: self.get_action_info()}, enable_tool=global_config.tool.enable_tool, request_type="chat.replyer", from_plugin=False, @@ -238,9 +219,6 @@ class RespondAction(BaseAction): # 发送回复(respond 默认不引用) reply_text = await self._send_response(response_set) - # 存储动作信息 - await self._store_action_info(reply_text) - logger.info(f"{self.log_prefix} respond 动作执行成功") return True, reply_text @@ -288,15 +266,3 @@ class RespondAction(BaseAction): ) return reply_text - - async def _store_action_info(self, reply_text: str): - """存储动作信息到数据库""" - await database_api.store_action_info( - chat_stream=self.chat_stream, - action_build_into_prompt=False, - action_prompt_display=f"统一回应:{reply_text}", - action_done=True, - thinking_id=self.thinking_id, - action_data={"reply_text": reply_text}, - action_name="respond", - ) diff --git a/src/plugins/built_in/affinity_flow_chatter/planner/plan_executor.py b/src/plugins/built_in/affinity_flow_chatter/planner/plan_executor.py index 1032d5271..71eda3dba 100644 --- a/src/plugins/built_in/affinity_flow_chatter/planner/plan_executor.py +++ b/src/plugins/built_in/affinity_flow_chatter/planner/plan_executor.py @@ -66,13 +66,6 @@ class ChatterPlanExecutor: action_types = [action.action_type for action in plan.decided_actions] logger.info(f"选择动作: {', '.join(action_types) if action_types else '无'}") - # 根据配置决定是否启用批量存储模式 - if global_config.database.batch_action_storage_enabled: - self.action_manager.enable_batch_storage(plan.chat_id) - logger.debug("已启用批量存储模式") - else: - logger.debug("批量存储功能已禁用,使用立即存储模式") - execution_results = [] reply_actions = [] other_actions = [] @@ -109,9 +102,6 @@ class ChatterPlanExecutor: f"规划执行完成: 总数={len(plan.decided_actions)}, 成功={successful_count}, 失败={len(execution_results) - successful_count}" ) - # 批量存储所有待处理的动作 - await self._flush_action_manager_batch_storage(plan) - return { "executed_count": len(plan.decided_actions), "successful_count": successful_count, @@ -530,25 +520,3 @@ class ChatterPlanExecutor: } for i, time_val in enumerate(recent_times) ] - - async def _flush_action_manager_batch_storage(self, plan: Plan): - """使用 action_manager 的批量存储功能存储所有待处理的动作""" - try: - # 通过 chat_id 获取真实的 chat_stream 对象 - from src.plugin_system.apis.chat_api import get_chat_manager - - chat_manager = get_chat_manager() - chat_stream = await chat_manager.get_stream(plan.chat_id) - - if chat_stream: - # 调用 action_manager 的批量存储 - await self.action_manager.flush_batch_storage(chat_stream) - logger.info("批量存储完成:通过 action_manager 存储所有动作记录") - - # 禁用批量存储模式 - self.action_manager.disable_batch_storage() - - except Exception as e: - logger.error(f"批量存储动作记录时发生错误: {e}") - # 确保在出错时也禁用批量存储模式 - self.action_manager.disable_batch_storage() diff --git a/src/plugins/built_in/affinity_flow_chatter/plugin.py b/src/plugins/built_in/affinity_flow_chatter/plugin.py index a3bb5d60e..7d8df84f9 100644 --- a/src/plugins/built_in/affinity_flow_chatter/plugin.py +++ b/src/plugins/built_in/affinity_flow_chatter/plugin.py @@ -86,4 +86,20 @@ class AffinityChatterPlugin(BasePlugin): except Exception as e: logger.error(f"加载 ProactiveThinkingMessageHandler 时出错: {e}") + try: + # 延迟导入 ReplyAction(AFC 专属动作) + from .actions.reply import ReplyAction + + components.append((ReplyAction.get_action_info(), ReplyAction)) + except Exception as e: + logger.error(f"加载 ReplyAction 时出错: {e}") + + try: + # 延迟导入 RespondAction(AFC 专属动作) + from .actions.reply import RespondAction + + components.append((RespondAction.get_action_info(), RespondAction)) + except Exception as e: + logger.error(f"加载 RespondAction 时出错: {e}") + return components diff --git a/src/plugins/built_in/core_actions/emoji.py b/src/plugins/built_in/core_actions/emoji.py index e8ff4cc23..0805bae95 100644 --- a/src/plugins/built_in/core_actions/emoji.py +++ b/src/plugins/built_in/core_actions/emoji.py @@ -219,8 +219,7 @@ class EmojiAction(BaseAction): ) emoji_base64, emoji_description = random.choice(all_emojis_data) - assert global_config is not None - if global_config.emoji.emoji_selection_mode == "description": + elif global_config.emoji.emoji_selection_mode == "description": # --- 详细描述选择模式 --- # 获取最近的5条消息内容用于判断 recent_messages = await message_api.get_recent_messages(chat_id=self.chat_id, limit=20) diff --git a/src/plugins/built_in/core_actions/plugin.py b/src/plugins/built_in/core_actions/plugin.py index 5baaa3a8e..008c877e6 100644 --- a/src/plugins/built_in/core_actions/plugin.py +++ b/src/plugins/built_in/core_actions/plugin.py @@ -1,8 +1,10 @@ """ 核心动作插件 -将系统核心动作(reply、no_reply、emoji)转换为新插件系统格式 +将系统核心动作(emoji)转换为新插件系统格式 这是系统的内置插件,提供基础的聊天交互功能 + +注意:reply 和 respond 动作已移至 AffinityFlowChatter 插件 """ # 导入依赖的系统组件 @@ -16,7 +18,6 @@ from src.plugin_system.base.config_types import ConfigField # 导入API模块 - 标准Python包方式 from src.plugins.built_in.core_actions.emoji import EmojiAction -from src.plugins.built_in.core_actions.reply import ReplyAction, RespondAction logger = get_logger("core_actions") @@ -26,11 +27,11 @@ class CoreActionsPlugin(BasePlugin): """核心动作插件 系统内置插件,提供基础的聊天交互功能: - - Reply: 回复动作 - - NoReply: 不回复动作 - Emoji: 表情动作 - 注意:插件基本信息优先从_manifest.json文件中读取 + 注意: + - reply 和 respond 动作已移至 AffinityFlowChatter 插件 + - 插件基本信息优先从_manifest.json文件中读取 """ # 插件基本信息 @@ -53,8 +54,6 @@ class CoreActionsPlugin(BasePlugin): "config_version": ConfigField(type=str, default="0.6.0", description="配置文件版本"), }, "components": { - "enable_reply": ConfigField(type=bool, default=True, description="是否启用 reply 动作(s4u模板)"), - "enable_respond": ConfigField(type=bool, default=True, description="是否启用 respond 动作(normal模板)"), "enable_emoji": ConfigField(type=bool, default=True, description="是否启用发送表情/图片动作"), }, } @@ -65,14 +64,6 @@ class CoreActionsPlugin(BasePlugin): # --- 根据配置注册组件 --- components: ClassVar = [] - # 注册 reply 动作 - if self.get_config("components.enable_reply", True): - components.append((ReplyAction.get_action_info(), ReplyAction)) - - # 注册 respond 动作 - if self.get_config("components.enable_respond", True): - components.append((RespondAction.get_action_info(), RespondAction)) - # 注册 emoji 动作 if self.get_config("components.enable_emoji", True): components.append((EmojiAction.get_action_info(), EmojiAction)) diff --git a/src/plugins/built_in/kokoro_flow_chatter_v2/__init__.py b/src/plugins/built_in/kokoro_flow_chatter_v2/__init__.py index 692bdc389..3fbe58279 100644 --- a/src/plugins/built_in/kokoro_flow_chatter_v2/__init__.py +++ b/src/plugins/built_in/kokoro_flow_chatter_v2/__init__.py @@ -33,6 +33,18 @@ from .config import ( reload_config, ) from .plugin import KokoroFlowChatterV2Plugin +from src.plugin_system.base.plugin_metadata import PluginMetadata + +__plugin_meta__ = PluginMetadata( + name="Kokoro Flow Chatter", + description="专为私聊设计的深度情感交互处理器,实现心理状态驱动的对话体验", + usage="在私聊场景中自动启用,可通过 [kokoro_flow_chatter].enable 配置开关", + version="2.0.0", + author="MoFox", + keywords=["chatter", "kokoro", "private", "emotional", "narrative"], + categories=["Chat", "AI", "Emotional"], + extra={"is_built_in": True, "chat_type": "private"}, +) __all__ = [ # Models @@ -61,4 +73,5 @@ __all__ = [ "reload_config", # Plugin "KokoroFlowChatterV2Plugin", + "__plugin_meta__", ] diff --git a/src/plugins/built_in/kokoro_flow_chatter_v2/actions/__init__.py b/src/plugins/built_in/kokoro_flow_chatter_v2/actions/__init__.py new file mode 100644 index 000000000..765ecde6b --- /dev/null +++ b/src/plugins/built_in/kokoro_flow_chatter_v2/actions/__init__.py @@ -0,0 +1,7 @@ +""" +KFC V2 专属动作模块 +""" + +from .reply import KFCReplyAction + +__all__ = ["KFCReplyAction"] diff --git a/src/plugins/built_in/kokoro_flow_chatter_v2/actions/reply.py b/src/plugins/built_in/kokoro_flow_chatter_v2/actions/reply.py new file mode 100644 index 000000000..33eef19e0 --- /dev/null +++ b/src/plugins/built_in/kokoro_flow_chatter_v2/actions/reply.py @@ -0,0 +1,82 @@ +""" +KFC V2 回复动作模块 + +KFC 的 reply 动作与 AFC 不同: +- 不调用 LLM 生成回复,content 由 Replyer 提前生成 +- 动作本身只负责发送 content 参数中的内容 +""" + +from typing import ClassVar + +from src.common.logger import get_logger +from src.plugin_system import ActionActivationType, BaseAction, ChatMode +from src.plugin_system.apis import send_api + +logger = get_logger("kfc_reply_action") + + +class KFCReplyAction(BaseAction): + """KFC Reply 动作 - 发送已生成的回复内容 + + 特点: + - 不调用 LLM,直接发送 content 参数中的内容 + - content 由 Replyer 提前生成 + - 仅限 KokoroFlowChatterV2 使用 + """ + + # 动作基本信息 + action_name = "reply" + action_description = "发送回复消息。content 参数包含要发送的内容。" + + # 激活设置 + activation_type = ActionActivationType.ALWAYS + mode_enable = ChatMode.ALL + parallel_action = False + + # Chatter 限制:仅允许 KokoroFlowChatterV2 使用 + chatter_allow: ClassVar[list[str]] = ["KokoroFlowChatterV2"] + + # 动作参数定义 + action_parameters: ClassVar = { + "content": "要发送的回复内容(必需,由 Replyer 生成)", + "should_quote_reply": "是否引用原消息(可选,true/false,默认 false)", + } + + # 动作使用场景 + action_require: ClassVar = [ + "发送回复消息时使用", + "content 参数必须包含要发送的内容", + ] + + # 关联类型 + associated_types: ClassVar[list[str]] = ["text"] + + async def execute(self) -> tuple[bool, str]: + """执行 reply 动作 - 发送 content 中的内容""" + try: + # 获取要发送的内容 + content = self.action_data.get("content", "") + if not content: + logger.warning(f"{self.log_prefix} content 为空,跳过发送") + return True, "" + + # 获取是否引用 + should_quote = self.action_data.get("should_quote_reply", False) + + # 发送消息 + await send_api.text_to_stream( + text=content, + stream_id=self.chat_stream.stream_id, + reply_to_message=self.action_message, + set_reply=should_quote and bool(self.action_message), + typing=False, + ) + + logger.info(f"{self.log_prefix} KFC reply 动作执行成功") + return True, content + + except Exception as e: + logger.error(f"{self.log_prefix} KFC reply 动作执行失败: {e}") + import traceback + traceback.print_exc() + return False, "" diff --git a/src/plugins/built_in/kokoro_flow_chatter_v2/plugin.py b/src/plugins/built_in/kokoro_flow_chatter_v2/plugin.py index 9af94ccc7..c62f35711 100644 --- a/src/plugins/built_in/kokoro_flow_chatter_v2/plugin.py +++ b/src/plugins/built_in/kokoro_flow_chatter_v2/plugin.py @@ -9,7 +9,7 @@ from typing import Any, ClassVar from src.common.logger import get_logger from src.plugin_system.base.base_plugin import BasePlugin from src.plugin_system.base.component_types import ChatterInfo -from src.plugin_system.decorators import register_plugin +from src.plugin_system import register_plugin from .chatter import KokoroFlowChatterV2 from .config import get_config @@ -84,7 +84,19 @@ class KokoroFlowChatterV2Plugin(BasePlugin): )) logger.debug("[KFC V2] 成功加载 KokoroFlowChatterV2 组件") except Exception as e: - logger.error(f"[KFC V2] 加载组件失败: {e}") + logger.error(f"[KFC V2] 加载 Chatter 组件失败: {e}") + + try: + # 注册 KFC 专属 Reply 动作 + from .actions.reply import KFCReplyAction + + components.append(( + KFCReplyAction.get_action_info(), + KFCReplyAction, + )) + logger.debug("[KFC V2] 成功加载 KFCReplyAction 组件") + except Exception as e: + logger.error(f"[KFC V2] 加载 Reply 动作失败: {e}") return components diff --git a/src/plugins/built_in/napcat_adapter/plugin.py b/src/plugins/built_in/napcat_adapter/plugin.py index 9bed812d7..874d7a6d1 100644 --- a/src/plugins/built_in/napcat_adapter/plugin.py +++ b/src/plugins/built_in/napcat_adapter/plugin.py @@ -41,7 +41,7 @@ class NapcatAdapter(BaseAdapter): adapter_description = "基于 MoFox-Bus 的 Napcat/OneBot 11 适配器" platform = "qq" - run_in_subprocess = True + run_in_subprocess = False def __init__(self, core_sink: CoreSink, plugin: Optional[BasePlugin] = None, **kwargs): """初始化 Napcat 适配器""" From fc85338d0b369a4036f1e7d97af54c495bb229c9 Mon Sep 17 00:00:00 2001 From: Windpicker-owo <3431391539@qq.com> Date: Sun, 30 Nov 2025 15:13:01 +0800 Subject: [PATCH 10/28] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E7=AE=A1=E7=90=86=E5=92=8C=E6=95=B0=E6=8D=AE=E5=BA=93?= =?UTF-8?q?=E6=93=8D=E4=BD=9C=E6=97=A5=E5=BF=97=EF=BC=8C=E5=A2=9E=E5=BC=BA?= =?UTF-8?q?=E8=B0=83=E8=AF=95=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/common/data_models/message_manager_data_model.py | 12 ++++++------ src/common/database/api/crud.py | 3 --- src/common/database/api/query.py | 3 --- src/common/database/utils/decorators.py | 1 - .../affinity_flow_chatter/planner/plan_filter.py | 12 ++++++++++++ 5 files changed, 18 insertions(+), 13 deletions(-) diff --git a/src/common/data_models/message_manager_data_model.py b/src/common/data_models/message_manager_data_model.py index a3f348898..4b9ba7d5e 100644 --- a/src/common/data_models/message_manager_data_model.py +++ b/src/common/data_models/message_manager_data_model.py @@ -162,17 +162,17 @@ class StreamContext(BaseDataModel): "stream_id": self.stream_id, } await unified_manager.add_message(message_dict) - logger.debug(f"��Ϣ�����ӵ��������ϵͳ: {message.message_id}") + logger.debug(f"消息已添加到统一记忆系统: {message.message_id}") except Exception as e: - logger.error(f"������Ϣ���������ϵͳʧ��: {e}") + logger.error(f"添加消息到统一记忆系统失败: {e}") return True except Exception as e: - logger.error(f"������Ϣ������������ʧ�� {self.stream_id}: {e}") + logger.error(f"添加消息到上下文失败 {self.stream_id}: {e}") return False async def update_message(self, message_id: str, updates: dict[str, Any]) -> bool: - """�����������е���Ϣ""" + """更新上下文中的消息信息""" try: for message in self.unread_messages: if str(message.message_id) == str(message_id): @@ -194,10 +194,10 @@ class StreamContext(BaseDataModel): message.should_reply = updates["should_reply"] break - logger.debug(f"���µ�����������Ϣ: {self.stream_id}/{message_id}") + logger.debug(f"更新消息信息: {self.stream_id}/{message_id}") return True except Exception as e: - logger.error(f"���µ�����������Ϣʧ�� {self.stream_id}/{message_id}: {e}") + logger.error(f"更新消息信息失败 {self.stream_id}/{message_id}: {e}") return False def add_action_to_message(self, message_id: str, action: str): diff --git a/src/common/database/api/crud.py b/src/common/database/api/crud.py index 1c9b1aef9..ea172df1c 100644 --- a/src/common/database/api/crud.py +++ b/src/common/database/api/crud.py @@ -149,7 +149,6 @@ class CRUDBase: cache = await get_cache() cached_dict = await cache.get(cache_key) if cached_dict is not None: - logger.debug(f"缓存命中: {cache_key}") # 从字典恢复对象 return _dict_to_model(self.model, cached_dict) @@ -194,7 +193,6 @@ class CRUDBase: cache = await get_cache() cached_dict = await cache.get(cache_key) if cached_dict is not None: - logger.debug(f"缓存命中: {cache_key}") # 从字典恢复对象 return _dict_to_model(self.model, cached_dict) @@ -247,7 +245,6 @@ class CRUDBase: cache = await get_cache() cached_dicts = await cache.get(cache_key) if cached_dicts is not None: - logger.debug(f"缓存命中: {cache_key}") # 从字典列表恢复对象列表 return [_dict_to_model(self.model, d) for d in cached_dicts] diff --git a/src/common/database/api/query.py b/src/common/database/api/query.py index 6815820ef..fb2212e28 100644 --- a/src/common/database/api/query.py +++ b/src/common/database/api/query.py @@ -199,7 +199,6 @@ class QueryBuilder(Generic[T]): cache = await get_cache() cached_dicts = await cache.get(cache_key) if cached_dicts is not None: - logger.debug(f"缓存命中: {cache_key}") dict_rows = [dict(row) for row in cached_dicts] if as_dict: return dict_rows @@ -238,7 +237,6 @@ class QueryBuilder(Generic[T]): cache = await get_cache() cached_dict = await cache.get(cache_key) if cached_dict is not None: - logger.debug(f"缓存命中: {cache_key}") row = dict(cached_dict) if as_dict: return row @@ -277,7 +275,6 @@ class QueryBuilder(Generic[T]): cache = await get_cache() cached = await cache.get(cache_key) if cached is not None: - logger.debug(f"缓存命中: {cache_key}") return cached # 构建count查询 diff --git a/src/common/database/utils/decorators.py b/src/common/database/utils/decorators.py index 319debcb1..7a7fe219e 100644 --- a/src/common/database/utils/decorators.py +++ b/src/common/database/utils/decorators.py @@ -192,7 +192,6 @@ def cached( cached_result = await cache.get(cache_key) if cached_result is not None: - logger.debug(f"缓存命中: {cache_key}") return cached_result # 执行函数 diff --git a/src/plugins/built_in/affinity_flow_chatter/planner/plan_filter.py b/src/plugins/built_in/affinity_flow_chatter/planner/plan_filter.py index 4c9d7cf76..d13fbe2a3 100644 --- a/src/plugins/built_in/affinity_flow_chatter/planner/plan_filter.py +++ b/src/plugins/built_in/affinity_flow_chatter/planner/plan_filter.py @@ -663,6 +663,18 @@ class ChatterPlanFilter: f"[{action}] 找不到目标消息,target_message_id: {action_data.get('target_message_id')}" ) + # reply 动作必须有目标消息,如果仍然为 None,则使用最新消息 + if action in ["reply", "proactive_reply"] and action_message_obj is None: + logger.warning(f"[{action}] 目标消息为空,强制使用最新消息作为兜底") + latest_message_dict = self._get_latest_message(message_id_list) + if latest_message_dict: + from src.common.data_models.database_data_model import DatabaseMessages + try: + action_message_obj = DatabaseMessages(**latest_message_dict) + logger.info(f"[{action}] 成功使用最新消息: {action_message_obj.message_id}") + except Exception as e: + logger.error(f"[{action}] 无法转换最新消息: {e}") + return ActionPlannerInfo( action_type=action, reasoning=reasoning, From c6f34992d1aead0d8c43bbd2f5a7cb3dedf7d97b Mon Sep 17 00:00:00 2001 From: Windpicker-owo <3431391539@qq.com> Date: Sun, 30 Nov 2025 15:52:01 +0800 Subject: [PATCH 11/28] =?UTF-8?q?feat(KFC):=20=E6=9B=B4=E6=96=B0=E8=81=8A?= =?UTF-8?q?=E5=A4=A9=E5=A4=84=E7=90=86=E5=99=A8=E5=92=8C=E5=9B=9E=E5=A4=8D?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=EF=BC=8C=E4=BC=98=E5=8C=96=E5=8A=A8=E4=BD=9C?= =?UTF-8?q?=E5=90=8D=E7=A7=B0=E5=8F=8A=E4=B8=8A=E4=B8=8B=E6=96=87=E6=9E=84?= =?UTF-8?q?=E5=BB=BA=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/chat/chatter_manager.py | 73 ++++++++--- .../kokoro_flow_chatter_v2/actions/reply.py | 4 +- .../kokoro_flow_chatter_v2/chatter.py | 2 +- .../kokoro_flow_chatter_v2/context_builder.py | 24 +++- .../built_in/kokoro_flow_chatter_v2/models.py | 6 +- .../proactive_thinker.py | 31 +++++ .../kokoro_flow_chatter_v2/prompt/builder.py | 118 ++++++++++++++++-- .../kokoro_flow_chatter_v2/prompt/prompts.py | 15 ++- .../kokoro_flow_chatter_v2/replyer.py | 4 +- .../kokoro_flow_chatter_v2/session.py | 2 +- 10 files changed, 229 insertions(+), 50 deletions(-) diff --git a/src/chat/chatter_manager.py b/src/chat/chatter_manager.py index 7a2e76d68..8cdf3fe43 100644 --- a/src/chat/chatter_manager.py +++ b/src/chat/chatter_manager.py @@ -57,12 +57,40 @@ class ChatterManager: self.stats["chatters_registered"] += 1 - def get_chatter_class(self, chat_type: ChatType) -> type | None: - """获取指定聊天类型的聊天处理器类""" - if chat_type in self.chatter_classes: - return self.chatter_classes[chat_type][0] + def get_chatter_class_for_chat_type(self, chat_type: ChatType) -> type | None: + """ + 获取指定聊天类型的最佳聊天处理器类 + + 优先级规则: + 1. 优先选择明确匹配当前聊天类型的 Chatter(如 PRIVATE 或 GROUP) + 2. 如果没有精确匹配,才使用 ALL 类型的 Chatter + + Args: + chat_type: 聊天类型 + + Returns: + 最佳匹配的聊天处理器类,如果没有匹配则返回 None + """ + # 1. 首先尝试精确匹配(排除 ALL 类型) + if chat_type != ChatType.ALL and chat_type in self.chatter_classes: + chatter_list = self.chatter_classes[chat_type] + if chatter_list: + logger.debug(f"找到精确匹配的聊天处理器: {chatter_list[0].__name__} for {chat_type.value}") + return chatter_list[0] + + # 2. 如果没有精确匹配,回退到 ALL 类型 + if ChatType.ALL in self.chatter_classes: + chatter_list = self.chatter_classes[ChatType.ALL] + if chatter_list: + logger.debug(f"使用通用聊天处理器: {chatter_list[0].__name__} for {chat_type.value}") + return chatter_list[0] + return None + def get_chatter_class(self, chat_type: ChatType) -> type | None: + """获取指定聊天类型的聊天处理器类(兼容旧接口)""" + return self.get_chatter_class_for_chat_type(chat_type) + def get_supported_chat_types(self) -> list[ChatType]: """获取支持的聊天类型列表""" return list(self.chatter_classes.keys()) @@ -112,29 +140,29 @@ class ChatterManager: logger.error("schedule unread cleanup failed", stream_id=stream_id, error=runtime_error) async def process_stream_context(self, stream_id: str, context: "StreamContext") -> dict: - """处理流上下文""" + """ + 处理流上下文 + + 每个聊天流只能有一个活跃的 Chatter 组件。 + 选择优先级:明确指定聊天类型的 Chatter > ALL 类型的 Chatter + """ chat_type = context.chat_type chat_type_value = chat_type.value logger.debug("处理流上下文", stream_id=stream_id, chat_type=chat_type_value) self._ensure_chatter_registry() - chatter_class = self.get_chatter_class(chat_type) - if not chatter_class: - all_chatter_class = self.get_chatter_class(ChatType.ALL) - if all_chatter_class: - chatter_class = all_chatter_class - logger.info( - "回退到通用聊天处理器", - stream_id=stream_id, - requested_type=chat_type_value, - fallback=ChatType.ALL.value, - ) - else: + # 检查是否已有该流的 Chatter 实例 + stream_instance = self.instances.get(stream_id) + + if stream_instance is None: + # 使用新的优先级选择逻辑获取最佳 Chatter 类 + chatter_class = self.get_chatter_class_for_chat_type(chat_type) + + if not chatter_class: raise ValueError(f"No chatter registered for chat type {chat_type}") - stream_instance = self.instances.get(stream_id) - if stream_instance is None: + # 创建新实例 stream_instance = chatter_class(stream_id=stream_id, action_manager=self.action_manager) self.instances[stream_id] = stream_instance logger.info( @@ -143,6 +171,13 @@ class ChatterManager: chatter_class=chatter_class.__name__, chat_type=chat_type_value, ) + else: + # 已有实例,直接使用(每个流只有一个活跃的 Chatter) + logger.debug( + "使用已有聊天处理器实例", + stream_id=stream_id, + chatter_class=stream_instance.__class__.__name__, + ) self.stats["streams_processed"] += 1 try: diff --git a/src/plugins/built_in/kokoro_flow_chatter_v2/actions/reply.py b/src/plugins/built_in/kokoro_flow_chatter_v2/actions/reply.py index 33eef19e0..680c888be 100644 --- a/src/plugins/built_in/kokoro_flow_chatter_v2/actions/reply.py +++ b/src/plugins/built_in/kokoro_flow_chatter_v2/actions/reply.py @@ -22,10 +22,12 @@ class KFCReplyAction(BaseAction): - 不调用 LLM,直接发送 content 参数中的内容 - content 由 Replyer 提前生成 - 仅限 KokoroFlowChatterV2 使用 + + 注意:使用 kfc_reply 作为动作名称以避免与 AFC 的 reply 动作冲突 """ # 动作基本信息 - action_name = "reply" + action_name = "kfc_reply" action_description = "发送回复消息。content 参数包含要发送的内容。" # 激活设置 diff --git a/src/plugins/built_in/kokoro_flow_chatter_v2/chatter.py b/src/plugins/built_in/kokoro_flow_chatter_v2/chatter.py index 38aaec0bd..0138006aa 100644 --- a/src/plugins/built_in/kokoro_flow_chatter_v2/chatter.py +++ b/src/plugins/built_in/kokoro_flow_chatter_v2/chatter.py @@ -160,7 +160,7 @@ class KokoroFlowChatterV2(BaseChatter): log_prefix="[KFC V2]", ) exec_results.append(result) - if result.get("success") and action.type in ("reply", "respond"): + if result.get("success") and action.type in ("kfc_reply", "respond"): has_reply = True # 10. 记录 Bot 规划到 mental_log diff --git a/src/plugins/built_in/kokoro_flow_chatter_v2/context_builder.py b/src/plugins/built_in/kokoro_flow_chatter_v2/context_builder.py index d3633ebfe..492f96581 100644 --- a/src/plugins/built_in/kokoro_flow_chatter_v2/context_builder.py +++ b/src/plugins/built_in/kokoro_flow_chatter_v2/context_builder.py @@ -50,6 +50,7 @@ class KFCContextBuilder: sender_name: str, target_message: str, context: Optional["StreamContext"] = None, + user_id: Optional[str] = None, ) -> dict[str, str]: """ 并行构建所有上下文模块 @@ -58,6 +59,7 @@ class KFCContextBuilder: sender_name: 发送者名称 target_message: 目标消息内容 context: 聊天流上下文(可选) + user_id: 用户ID(可选,用于精确查找关系信息) Returns: dict: 包含所有上下文块的字典 @@ -65,7 +67,7 @@ class KFCContextBuilder: chat_history = await self._get_chat_history_text(context) tasks = { - "relation_info": self._build_relation_info(sender_name, target_message), + "relation_info": self._build_relation_info(sender_name, target_message, user_id), "memory_block": self._build_memory_block(chat_history, target_message), "expression_habits": self._build_expression_habits(chat_history, target_message), "schedule": self._build_schedule_block(), @@ -127,7 +129,7 @@ class KFCContextBuilder: logger.error(f"获取聊天历史失败: {e}") return "" - async def _build_relation_info(self, sender_name: str, target_message: str) -> str: + async def _build_relation_info(self, sender_name: str, target_message: str, user_id: Optional[str] = None) -> str: """构建关系信息块""" config = _get_config() @@ -135,11 +137,20 @@ class KFCContextBuilder: return "你将要回复的是你自己发送的消息。" person_info_manager = get_person_info_manager() - person_id = await person_info_manager.get_person_id_by_person_name(sender_name) + + # 优先使用 user_id + platform 获取 person_id + person_id = None + if user_id and self.platform: + person_id = person_info_manager.get_person_id(self.platform, user_id) + logger.debug(f"通过 platform={self.platform}, user_id={user_id} 获取 person_id={person_id}") + + # 如果没有找到,尝试通过 person_name 查找 + if not person_id: + person_id = await person_info_manager.get_person_id_by_person_name(sender_name) if not person_id: - logger.debug(f"未找到用户 {sender_name} 的ID") - return f"你完全不认识{sender_name},这是你们的第一次互动。" + logger.debug(f"未找到用户 {sender_name} 的 person_id") + return f"你与{sender_name}还没有建立深厚的关系,这是早期的互动阶段。" try: from src.person_info.relationship_fetcher import relationship_fetcher_manager @@ -324,12 +335,13 @@ async def build_kfc_context( sender_name: str, target_message: str, context: Optional["StreamContext"] = None, + user_id: Optional[str] = None, ) -> dict[str, str]: """ 便捷函数:构建KFC所需的所有上下文 """ builder = KFCContextBuilder(chat_stream) - return await builder.build_all_context(sender_name, target_message, context) + return await builder.build_all_context(sender_name, target_message, context, user_id) __all__ = [ diff --git a/src/plugins/built_in/kokoro_flow_chatter_v2/models.py b/src/plugins/built_in/kokoro_flow_chatter_v2/models.py index 9327f3dfb..4ca2e2e1c 100644 --- a/src/plugins/built_in/kokoro_flow_chatter_v2/models.py +++ b/src/plugins/built_in/kokoro_flow_chatter_v2/models.py @@ -230,7 +230,7 @@ class ActionModel: def get_description(self) -> str: """获取动作的文字描述""" - if self.type == "reply": + if self.type == "kfc_reply": content = self.params.get("content", "") return f'发送消息:"{content[:50]}{"..." if len(content) > 50 else ""}"' elif self.type == "poke_user": @@ -305,12 +305,12 @@ class LLMResponse: def has_reply(self) -> bool: """是否包含回复动作""" - return any(a.type in ("reply", "respond") for a in self.actions) + return any(a.type in ("kfc_reply", "respond") for a in self.actions) def get_reply_content(self) -> str: """获取回复内容""" for action in self.actions: - if action.type in ("reply", "respond"): + if action.type in ("kfc_reply", "respond"): return action.params.get("content", "") return "" diff --git a/src/plugins/built_in/kokoro_flow_chatter_v2/proactive_thinker.py b/src/plugins/built_in/kokoro_flow_chatter_v2/proactive_thinker.py index 0cab996ec..ecf6f8a52 100644 --- a/src/plugins/built_in/kokoro_flow_chatter_v2/proactive_thinker.py +++ b/src/plugins/built_in/kokoro_flow_chatter_v2/proactive_thinker.py @@ -163,6 +163,16 @@ class ProactiveThinker: if not session.waiting_config.is_active(): return + # 防止与 Chatter 并发处理:如果 Session 刚刚被更新(5秒内),跳过 + # 这样可以避免 Chatter 正在处理时,ProactiveThinker 也开始处理 + time_since_last_activity = time.time() - session.last_activity_at + if time_since_last_activity < 5: + logger.debug( + f"[ProactiveThinker] Session {session.user_id} 刚有活动 " + f"({time_since_last_activity:.1f}s ago),跳过处理" + ) + return + # 检查是否超时 if session.waiting_config.is_timeout(): await self._handle_timeout(session) @@ -250,6 +260,19 @@ class ProactiveThinker: """处理等待超时""" self._stats["timeout_decisions"] += 1 + # 再次检查 Session 状态,防止在等待过程中被 Chatter 处理 + if session.status != SessionStatus.WAITING: + logger.debug(f"[ProactiveThinker] Session {session.user_id} 已不在等待状态,跳过超时处理") + return + + # 再次检查最近活动时间 + time_since_last_activity = time.time() - session.last_activity_at + if time_since_last_activity < 5: + logger.debug( + f"[ProactiveThinker] Session {session.user_id} 刚有活动,跳过超时处理" + ) + return + logger.info(f"[ProactiveThinker] 等待超时: user={session.user_id}") try: @@ -391,6 +414,14 @@ class ProactiveThinker: """处理主动思考""" self._stats["proactive_triggered"] += 1 + # 再次检查最近活动时间,防止与 Chatter 并发 + time_since_last_activity = time.time() - session.last_activity_at + if time_since_last_activity < 5: + logger.debug( + f"[ProactiveThinker] Session {session.user_id} 刚有活动,跳过主动思考" + ) + return + logger.info(f"[ProactiveThinker] 主动思考触发: user={session.user_id}, reason={trigger_reason}") try: diff --git a/src/plugins/built_in/kokoro_flow_chatter_v2/prompt/builder.py b/src/plugins/built_in/kokoro_flow_chatter_v2/prompt/builder.py index 83bf5e91d..f69a26666 100644 --- a/src/plugins/built_in/kokoro_flow_chatter_v2/prompt/builder.py +++ b/src/plugins/built_in/kokoro_flow_chatter_v2/prompt/builder.py @@ -63,11 +63,14 @@ class PromptBuilder: """ extra_context = extra_context or {} + # 获取 user_id(从 session 中) + user_id = session.user_id if session else None + # 1. 构建人设块 persona_block = self._build_persona_block() # 2. 构建关系块 - relation_block = await self._build_relation_block(user_name, chat_stream) + relation_block = await self._build_relation_block(user_name, chat_stream, user_id) # 3. 构建活动流 activity_stream = await self._build_activity_stream(session, user_name) @@ -123,6 +126,7 @@ class PromptBuilder: self, user_name: str, chat_stream: Optional["ChatStream"], + user_id: Optional[str] = None, ) -> str: """构建关系块""" if not chat_stream: @@ -139,6 +143,7 @@ class PromptBuilder: sender_name=user_name, target_message="", context=None, + user_id=user_id, ) relation_info = context_data.get("relation_info", "") @@ -253,7 +258,7 @@ class PromptBuilder: for action in actions: action_type = action.get("type", "unknown") - if action_type == "reply": + if action_type == "kfc_reply": content = action.get("content", "") if len(content) > 50: content = content[:50] + "..." @@ -341,22 +346,111 @@ class PromptBuilder: ) def _build_actions_block(self, available_actions: Optional[dict]) -> str: - """构建可用动作块""" + """ + 构建可用动作块 + + 参考 AFC planner 的格式,为每个动作展示: + - 动作名和描述 + - 使用场景 + - JSON 示例(含参数) + """ if not available_actions: return self._get_default_actions_block() - lines = [] - for name, info in available_actions.items(): - desc = getattr(info, "description", "") or f"执行 {name}" - lines.append(f"- `{name}`: {desc}") + action_blocks = [] + for action_name, action_info in available_actions.items(): + block = self._format_single_action(action_name, action_info) + if block: + action_blocks.append(block) - return "\n".join(lines) if lines else self._get_default_actions_block() + return "\n".join(action_blocks) if action_blocks else self._get_default_actions_block() + + def _format_single_action(self, action_name: str, action_info) -> str: + """ + 格式化单个动作为详细说明块 + + Args: + action_name: 动作名称 + action_info: ActionInfo 对象 + + Returns: + 格式化后的动作说明 + """ + # 获取动作描述 + description = getattr(action_info, "description", "") or f"执行 {action_name}" + + # 获取使用场景 + action_require = getattr(action_info, "action_require", []) or [] + require_text = "\n".join(f" - {req}" for req in action_require) if action_require else " - 根据情况使用" + + # 获取参数定义 + action_parameters = getattr(action_info, "action_parameters", {}) or {} + + # 构建 action_data JSON 示例 + if action_parameters: + param_lines = [] + for param_name, param_desc in action_parameters.items(): + param_lines.append(f' "{param_name}": "<{param_desc}>"') + action_data_json = "{\n" + ",\n".join(param_lines) + "\n }" + else: + action_data_json = "{}" + + # 构建完整的动作块 + return f"""### {action_name} +**描述**: {description} + +**使用场景**: +{require_text} + +**示例**: +```json +{{ + "type": "{action_name}", + {f'"content": "<你要说的内容>"' if action_name == "kfc_reply" else self._build_params_example(action_parameters)} +}} +``` +""" + + def _build_params_example(self, action_parameters: dict) -> str: + """构建参数示例字符串""" + if not action_parameters: + return '"_comment": "此动作无需额外参数"' + + parts = [] + for param_name, param_desc in action_parameters.items(): + parts.append(f'"{param_name}": "<{param_desc}>"') + + return ",\n ".join(parts) def _get_default_actions_block(self) -> str: """获取默认的动作列表""" - return """- `reply`: 发送文字消息(参数:content) -- `poke_user`: 戳一戳对方 -- `do_nothing`: 什么都不做""" + return """### kfc_reply +**描述**: 发送回复消息 + +**使用场景**: + - 需要回复对方消息时使用 + +**示例**: +```json +{ + "type": "kfc_reply", + "content": "你要说的话" +} +``` + + +### do_nothing +**描述**: 什么都不做 + +**使用场景**: + - 当前不需要回应时使用 + +**示例**: +```json +{ + "type": "do_nothing" +} +```""" async def _get_output_format(self) -> str: """获取输出格式模板""" @@ -370,7 +464,7 @@ class PromptBuilder: return """请用 JSON 格式回复: { "thought": "你的想法", - "actions": [{"type": "reply", "content": "你的回复"}], + "actions": [{"type": "kfc_reply", "content": "你的回复"}], "expected_reaction": "期待的反应", "max_wait_seconds": 300 }""" diff --git a/src/plugins/built_in/kokoro_flow_chatter_v2/prompt/prompts.py b/src/plugins/built_in/kokoro_flow_chatter_v2/prompt/prompts.py index 77f1da867..fddedc727 100644 --- a/src/plugins/built_in/kokoro_flow_chatter_v2/prompt/prompts.py +++ b/src/plugins/built_in/kokoro_flow_chatter_v2/prompt/prompts.py @@ -47,20 +47,23 @@ KFC_V2_OUTPUT_FORMAT = Prompt( {{ "thought": "你脑子里在想什么,越自然越好", "actions": [ - {{"type": "reply", "content": "你要说的话"}}, - {{"type": "其他动作", "参数": "值"}} + {{"type": "动作名称", ...动作参数}} ], "expected_reaction": "你期待对方的反应是什么", "max_wait_seconds": 300 }} ``` -说明: -- `thought`:你的内心独白,记录你此刻的想法和感受 -- `actions`:你要执行的动作列表,可以组合多个 +### 字段说明 +- `thought`:你的内心独白,记录你此刻的想法和感受。要自然,不要技术性语言。 +- `actions`:你要执行的动作列表。每个动作是一个对象,必须包含 `type` 字段指定动作类型,其他字段根据动作类型不同而不同(参考上面每个动作的示例)。 - `expected_reaction`:你期待对方如何回应(用于判断是否需要等待) - `max_wait_seconds`:设定等待时间(秒),0 表示不等待,超时后你会考虑是否要主动说点什么 -- 即使什么都不想做,也放一个 `{{"type": "do_nothing"}}`""", + +### 注意事项 +- 动作参数直接写在动作对象里,不需要 `action_data` 包装 +- 即使什么都不想做,也放一个 `{{"type": "do_nothing"}}` +- 可以组合多个动作,比如先发消息再发表情""", ) # ================================================================================================= diff --git a/src/plugins/built_in/kokoro_flow_chatter_v2/replyer.py b/src/plugins/built_in/kokoro_flow_chatter_v2/replyer.py index a8c417cf2..b04587fd6 100644 --- a/src/plugins/built_in/kokoro_flow_chatter_v2/replyer.py +++ b/src/plugins/built_in/kokoro_flow_chatter_v2/replyer.py @@ -54,7 +54,9 @@ async def generate_response( extra_context=extra_context, ) - logger.debug(f"[KFC Replyer] 构建的提示词:\n{prompt}") + from src.config.config import global_config + if global_config and global_config.debug.show_prompt: + logger.info(f"[KFC Replyer] 生成的提示词:\n{prompt}") # 2. 获取模型配置并调用 LLM models = llm_api.get_available_models() diff --git a/src/plugins/built_in/kokoro_flow_chatter_v2/session.py b/src/plugins/built_in/kokoro_flow_chatter_v2/session.py index df321070d..81fb9d4a8 100644 --- a/src/plugins/built_in/kokoro_flow_chatter_v2/session.py +++ b/src/plugins/built_in/kokoro_flow_chatter_v2/session.py @@ -197,7 +197,7 @@ class KokoroSession: for entry in reversed(self.mental_log): if entry.event_type == EventType.BOT_PLANNING: for action in entry.actions: - if action.get("type") in ("reply", "respond"): + if action.get("type") in ("kfc_reply", "respond"): return action.get("content", "") return None From 252e8620e14f63d37f52a05c3afc453c739fc457 Mon Sep 17 00:00:00 2001 From: Windpicker-owo <3431391539@qq.com> Date: Sun, 30 Nov 2025 16:16:22 +0800 Subject: [PATCH 12/28] =?UTF-8?q?feat:=20=E9=87=8D=E5=91=BD=E5=90=8DKokoro?= =?UTF-8?q?=20Flow=20Chatter=20V2=E4=B8=BAKokoro=20Flow=20Chatter=EF=BC=8C?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E7=9B=B8=E5=85=B3=E6=A8=A1=E5=9D=97=E5=92=8C?= =?UTF-8?q?=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kokoro_flow_chatter_v2/__init__.py | 14 ++-- .../kokoro_flow_chatter_v2/actions/reply.py | 12 ++-- .../kokoro_flow_chatter_v2/chatter.py | 56 +++++++++------- .../built_in/kokoro_flow_chatter_v2/config.py | 30 ++++----- .../built_in/kokoro_flow_chatter_v2/plugin.py | 48 ++++++------- .../kokoro_flow_chatter_v2/prompt/builder.py | 67 ++++++++++++++++++- .../kokoro_flow_chatter_v2/prompt/prompts.py | 9 ++- .../kokoro_flow_chatter_v2/replyer.py | 2 +- .../kokoro_flow_chatter_v2/session.py | 2 +- .../built_in/social_toolkit_plugin/plugin.py | 9 +-- 10 files changed, 157 insertions(+), 92 deletions(-) diff --git a/src/plugins/built_in/kokoro_flow_chatter_v2/__init__.py b/src/plugins/built_in/kokoro_flow_chatter_v2/__init__.py index 3fbe58279..2bc00a4d5 100644 --- a/src/plugins/built_in/kokoro_flow_chatter_v2/__init__.py +++ b/src/plugins/built_in/kokoro_flow_chatter_v2/__init__.py @@ -1,5 +1,5 @@ """ -Kokoro Flow Chatter V2 - 私聊特化的心流聊天器 +Kokoro Flow Chatter - 私聊特化的心流聊天器 重构版本,核心设计理念: 1. Chatter 职责极简化:只负责"收到消息 → 规划执行" @@ -18,7 +18,7 @@ from .models import ( LLMResponse, ) from .session import KokoroSession, SessionManager, get_session_manager -from .chatter import KokoroFlowChatterV2 +from .chatter import KokoroFlowChatter from .replyer import generate_response from .proactive_thinker import ( ProactiveThinker, @@ -27,12 +27,12 @@ from .proactive_thinker import ( stop_proactive_thinker, ) from .config import ( - KokoroFlowChatterV2Config, + KokoroFlowChatterConfig, get_config, load_config, reload_config, ) -from .plugin import KokoroFlowChatterV2Plugin +from .plugin import KokoroFlowChatterPlugin from src.plugin_system.base.plugin_metadata import PluginMetadata __plugin_meta__ = PluginMetadata( @@ -59,7 +59,7 @@ __all__ = [ "SessionManager", "get_session_manager", # Core Components - "KokoroFlowChatterV2", + "KokoroFlowChatter", "generate_response", # Proactive Thinker "ProactiveThinker", @@ -67,11 +67,11 @@ __all__ = [ "start_proactive_thinker", "stop_proactive_thinker", # Config - "KokoroFlowChatterV2Config", + "KokoroFlowChatterConfig", "get_config", "load_config", "reload_config", # Plugin - "KokoroFlowChatterV2Plugin", + "KokoroFlowChatterPlugin", "__plugin_meta__", ] diff --git a/src/plugins/built_in/kokoro_flow_chatter_v2/actions/reply.py b/src/plugins/built_in/kokoro_flow_chatter_v2/actions/reply.py index 680c888be..c43da326b 100644 --- a/src/plugins/built_in/kokoro_flow_chatter_v2/actions/reply.py +++ b/src/plugins/built_in/kokoro_flow_chatter_v2/actions/reply.py @@ -1,5 +1,5 @@ """ -KFC V2 回复动作模块 +KFC 回复动作模块 KFC 的 reply 动作与 AFC 不同: - 不调用 LLM 生成回复,content 由 Replyer 提前生成 @@ -17,12 +17,12 @@ logger = get_logger("kfc_reply_action") class KFCReplyAction(BaseAction): """KFC Reply 动作 - 发送已生成的回复内容 - + 特点: - 不调用 LLM,直接发送 content 参数中的内容 - content 由 Replyer 提前生成 - - 仅限 KokoroFlowChatterV2 使用 - + - 仅限 KokoroFlowChatter 使用 + 注意:使用 kfc_reply 作为动作名称以避免与 AFC 的 reply 动作冲突 """ @@ -35,8 +35,8 @@ class KFCReplyAction(BaseAction): mode_enable = ChatMode.ALL parallel_action = False - # Chatter 限制:仅允许 KokoroFlowChatterV2 使用 - chatter_allow: ClassVar[list[str]] = ["KokoroFlowChatterV2"] + # Chatter 限制:仅允许 KokoroFlowChatter 使用 + chatter_allow: ClassVar[list[str]] = ["KokoroFlowChatter"] # 动作参数定义 action_parameters: ClassVar = { diff --git a/src/plugins/built_in/kokoro_flow_chatter_v2/chatter.py b/src/plugins/built_in/kokoro_flow_chatter_v2/chatter.py index 0138006aa..c7d1f3b0e 100644 --- a/src/plugins/built_in/kokoro_flow_chatter_v2/chatter.py +++ b/src/plugins/built_in/kokoro_flow_chatter_v2/chatter.py @@ -1,5 +1,5 @@ """ -Kokoro Flow Chatter V2 - Chatter 主类 +Kokoro Flow Chatter - Chatter 主类 极简设计,只负责: 1. 收到消息 @@ -25,30 +25,30 @@ from .session import get_session_manager if TYPE_CHECKING: pass -logger = get_logger("kfc_v2_chatter") +logger = get_logger("kfc_chatter") # 控制台颜色 SOFT_PURPLE = "\033[38;5;183m" RESET = "\033[0m" -class KokoroFlowChatterV2(BaseChatter): +class KokoroFlowChatter(BaseChatter): """ - Kokoro Flow Chatter V2 - 私聊特化的心流聊天器 - + Kokoro Flow Chatter - 私聊特化的心流聊天器 + 核心设计: - Chatter 只负责 "收到消息 → 规划执行" 的流程 - 无论 Session 之前是什么状态,流程都一样 - 区别只体现在提示词中 - + 不负责: - 等待超时处理(由 ProactiveThinker 负责) - 连续思考(由 ProactiveThinker 负责) - 主动发起对话(由 ProactiveThinker 负责) """ - - chatter_name: str = "KokoroFlowChatterV2" - chatter_description: str = "心流聊天器 V2 - 私聊特化的深度情感交互处理器" + + chatter_name: str = "KokoroFlowChatter" + chatter_description: str = "心流聊天器 - 私聊特化的深度情感交互处理器" chat_types: ClassVar[list[ChatType]] = [ChatType.PRIVATE] def __init__( @@ -73,7 +73,7 @@ class KokoroFlowChatterV2(BaseChatter): "failed_responses": 0, } - logger.info(f"{SOFT_PURPLE}[KFC V2]{RESET} 初始化完成: stream_id={stream_id}") + logger.info(f"{SOFT_PURPLE}[KFC]{RESET} 初始化完成: stream_id={stream_id}") async def execute(self, context: StreamContext) -> dict: """ @@ -114,7 +114,13 @@ class KokoroFlowChatterV2(BaseChatter): # 4. 确定 situation_type(根据之前的等待状态) situation_type = self._determine_situation_type(session) - # 5. 记录用户消息到 mental_log + # 5. **立即**结束等待状态,防止 ProactiveThinker 并发处理 + # 在调用 LLM 之前就结束等待,避免 ProactiveThinker 检测到超时后也开始处理 + if session.status == SessionStatus.WAITING: + session.end_waiting() + await self.session_manager.save_session(user_id) + + # 6. 记录用户消息到 mental_log for msg in unread_messages: msg_content = msg.processed_plain_text or msg.display_message or "" msg_user_name = msg.user_info.user_nickname if msg.user_info else user_name @@ -127,17 +133,17 @@ class KokoroFlowChatterV2(BaseChatter): timestamp=msg.time, ) - # 6. 加载可用动作(通过 ActionModifier 过滤) + # 7. 加载可用动作(通过 ActionModifier 过滤) from src.chat.planner_actions.action_modifier import ActionModifier action_modifier = ActionModifier(self.action_manager, self.stream_id) - await action_modifier.modify_actions(chatter_name="KokoroFlowChatterV2") + await action_modifier.modify_actions(chatter_name="KokoroFlowChatter") available_actions = self.action_manager.get_using_actions() - # 7. 获取聊天流 + # 8. 获取聊天流 chat_stream = await self._get_chat_stream() - # 8. 调用 Replyer 生成响应 + # 9. 调用 Replyer 生成响应 response = await generate_response( session=session, user_name=user_name, @@ -146,7 +152,7 @@ class KokoroFlowChatterV2(BaseChatter): available_actions=available_actions, ) - # 9. 执行动作 + # 10. 执行动作作 exec_results = [] has_reply = False for action in response.actions: @@ -157,13 +163,13 @@ class KokoroFlowChatterV2(BaseChatter): reasoning=response.thought, action_data=action.params, thinking_id=None, - log_prefix="[KFC V2]", + log_prefix="[KFC]", ) exec_results.append(result) if result.get("success") and action.type in ("kfc_reply", "respond"): has_reply = True - # 10. 记录 Bot 规划到 mental_log + # 11. 记录 Bot 规划到 mental_log session.add_bot_planning( thought=response.thought, actions=[a.to_dict() for a in response.actions], @@ -171,7 +177,7 @@ class KokoroFlowChatterV2(BaseChatter): max_wait_seconds=response.max_wait_seconds, ) - # 11. 更新 Session 状态 + # 12. 更新 Session 状态 if response.max_wait_seconds > 0: session.start_waiting( expected_reaction=response.expected_reaction, @@ -180,20 +186,20 @@ class KokoroFlowChatterV2(BaseChatter): else: session.end_waiting() - # 12. 标记消息为已读 + # 13. 标记消息为已读 for msg in unread_messages: context.mark_message_as_read(str(msg.message_id)) - # 13. 保存 Session + # 14. 保存 Session await self.session_manager.save_session(user_id) - # 14. 更新统计 + # 15. 更新统计 self._stats["messages_processed"] += len(unread_messages) if has_reply: self._stats["successful_responses"] += 1 logger.info( - f"{SOFT_PURPLE}[KFC V2]{RESET} 处理完成: " + f"{SOFT_PURPLE}[KFC]{RESET} 处理完成: " f"user={user_name}, situation={situation_type}, " f"actions={[a.type for a in response.actions]}, " f"wait={response.max_wait_seconds}s" @@ -209,7 +215,7 @@ class KokoroFlowChatterV2(BaseChatter): except Exception as e: self._stats["failed_responses"] += 1 - logger.error(f"[KFC V2] 处理失败: {e}") + logger.error(f"[KFC] 处理失败: {e}") import traceback traceback.print_exc() return self._build_result(success=False, message=str(e), error=True) @@ -244,7 +250,7 @@ class KokoroFlowChatterV2(BaseChatter): if chat_manager: return await chat_manager.get_stream(self.stream_id) except Exception as e: - logger.warning(f"[KFC V2] 获取 chat_stream 失败: {e}") + logger.warning(f"[KFC] 获取 chat_stream 失败: {e}") return None def _build_result( diff --git a/src/plugins/built_in/kokoro_flow_chatter_v2/config.py b/src/plugins/built_in/kokoro_flow_chatter_v2/config.py index 37e6c068e..e7bc53de0 100644 --- a/src/plugins/built_in/kokoro_flow_chatter_v2/config.py +++ b/src/plugins/built_in/kokoro_flow_chatter_v2/config.py @@ -67,7 +67,7 @@ class SessionConfig: """会话配置""" # Session 持久化目录(相对于 data/) - session_dir: str = "kokoro_flow_chatter_v2/sessions" + session_dir: str = "kokoro_flow_chatter/sessions" # Session 自动过期时间(秒),超过此时间未活动自动清理 session_expire_seconds: int = 86400 * 7 # 7 天 @@ -94,8 +94,8 @@ class LLMConfig: @dataclass -class KokoroFlowChatterV2Config: - """Kokoro Flow Chatter V2 总配置""" +class KokoroFlowChatterConfig: + """Kokoro Flow Chatter 总配置""" # 是否启用 enabled: bool = True @@ -123,10 +123,10 @@ class KokoroFlowChatterV2Config: # 全局配置单例 -_config: Optional[KokoroFlowChatterV2Config] = None +_config: Optional[KokoroFlowChatterConfig] = None -def get_config() -> KokoroFlowChatterV2Config: +def get_config() -> KokoroFlowChatterConfig: """获取全局配置""" global _config if _config is None: @@ -134,19 +134,19 @@ def get_config() -> KokoroFlowChatterV2Config: return _config -def load_config() -> KokoroFlowChatterV2Config: - """从全局配置加载 KFC V2 配置""" +def load_config() -> KokoroFlowChatterConfig: + """从全局配置加载 KFC 配置""" from src.config.config import global_config - - config = KokoroFlowChatterV2Config() + + config = KokoroFlowChatterConfig() # 尝试从全局配置读取 if not global_config: return config try: - if hasattr(global_config, 'kokoro_flow_chatter_v2'): - kfc_cfg = getattr(global_config, 'kokoro_flow_chatter_v2') + if hasattr(global_config, 'kokoro_flow_chatter'): + kfc_cfg = getattr(global_config, 'kokoro_flow_chatter') # 基础配置 if hasattr(kfc_cfg, 'enabled'): @@ -191,7 +191,7 @@ def load_config() -> KokoroFlowChatterV2Config: if hasattr(kfc_cfg, 'session'): sess_cfg = kfc_cfg.session config.session = SessionConfig( - session_dir=getattr(sess_cfg, 'session_dir', "kokoro_flow_chatter_v2/sessions"), + session_dir=getattr(sess_cfg, 'session_dir', "kokoro_flow_chatter/sessions"), session_expire_seconds=getattr(sess_cfg, 'session_expire_seconds', 86400 * 7), max_mental_log_entries=getattr(sess_cfg, 'max_mental_log_entries', 100), ) @@ -208,13 +208,13 @@ def load_config() -> KokoroFlowChatterV2Config: except Exception as e: from src.common.logger import get_logger - logger = get_logger("kfc_v2_config") - logger.warning(f"加载 KFC V2 配置失败,使用默认值: {e}") + logger = get_logger("kfc_config") + logger.warning(f"加载 KFC 配置失败,使用默认值: {e}") return config -def reload_config() -> KokoroFlowChatterV2Config: +def reload_config() -> KokoroFlowChatterConfig: """重新加载配置""" global _config _config = load_config() diff --git a/src/plugins/built_in/kokoro_flow_chatter_v2/plugin.py b/src/plugins/built_in/kokoro_flow_chatter_v2/plugin.py index c62f35711..8e5079215 100644 --- a/src/plugins/built_in/kokoro_flow_chatter_v2/plugin.py +++ b/src/plugins/built_in/kokoro_flow_chatter_v2/plugin.py @@ -1,5 +1,5 @@ """ -Kokoro Flow Chatter V2 - 插件注册 +Kokoro Flow Chatter - 插件注册 注册 Chatter """ @@ -11,25 +11,25 @@ from src.plugin_system.base.base_plugin import BasePlugin from src.plugin_system.base.component_types import ChatterInfo from src.plugin_system import register_plugin -from .chatter import KokoroFlowChatterV2 +from .chatter import KokoroFlowChatter from .config import get_config from .proactive_thinker import start_proactive_thinker, stop_proactive_thinker -logger = get_logger("kfc_v2_plugin") +logger = get_logger("kfc_plugin") @register_plugin -class KokoroFlowChatterV2Plugin(BasePlugin): +class KokoroFlowChatterPlugin(BasePlugin): """ - Kokoro Flow Chatter V2 插件 - + Kokoro Flow Chatter 插件 + 专为私聊设计的增强 Chatter: - 线性叙事提示词架构 - 等待机制与心理状态演变 - 主动思考能力 """ - - plugin_name: str = "kokoro_flow_chatter_v2" + + plugin_name: str = "kokoro_flow_chatter" enable_plugin: bool = True plugin_priority: int = 50 # 高于默认 Chatter dependencies: ClassVar[list[str]] = [] @@ -44,28 +44,28 @@ class KokoroFlowChatterV2Plugin(BasePlugin): config = get_config() if not config.enabled: - logger.info("[KFC V2] 插件已禁用") + logger.info("[KFC] 插件已禁用") return - - logger.info("[KFC V2] 插件已加载") + + logger.info("[KFC] 插件已加载") # 启动主动思考器 if config.proactive.enabled: try: await start_proactive_thinker() - logger.info("[KFC V2] 主动思考器已启动") + logger.info("[KFC] 主动思考器已启动") self._is_started = True except Exception as e: - logger.error(f"[KFC V2] 启动主动思考器失败: {e}") + logger.error(f"[KFC] 启动主动思考器失败: {e}") async def on_plugin_unloaded(self): """插件卸载时""" try: await stop_proactive_thinker() - logger.info("[KFC V2] 主动思考器已停止") + logger.info("[KFC] 主动思考器已停止") self._is_started = False except Exception as e: - logger.warning(f"[KFC V2] 停止主动思考器失败: {e}") + logger.warning(f"[KFC] 停止主动思考器失败: {e}") def get_plugin_components(self): """返回组件列表""" @@ -79,24 +79,24 @@ class KokoroFlowChatterV2Plugin(BasePlugin): try: # 注册 Chatter components.append(( - KokoroFlowChatterV2.get_chatter_info(), - KokoroFlowChatterV2, + KokoroFlowChatter.get_chatter_info(), + KokoroFlowChatter, )) - logger.debug("[KFC V2] 成功加载 KokoroFlowChatterV2 组件") + logger.debug("[KFC] 成功加载 KokoroFlowChatter 组件") except Exception as e: - logger.error(f"[KFC V2] 加载 Chatter 组件失败: {e}") - + logger.error(f"[KFC] 加载 Chatter 组件失败: {e}") + try: # 注册 KFC 专属 Reply 动作 from .actions.reply import KFCReplyAction - + components.append(( KFCReplyAction.get_action_info(), KFCReplyAction, )) - logger.debug("[KFC V2] 成功加载 KFCReplyAction 组件") + logger.debug("[KFC] 成功加载 KFCReplyAction 组件") except Exception as e: - logger.error(f"[KFC V2] 加载 Reply 动作失败: {e}") + logger.error(f"[KFC] 加载 Reply 动作失败: {e}") return components @@ -104,7 +104,7 @@ class KokoroFlowChatterV2Plugin(BasePlugin): """获取插件信息""" return { "name": self.plugin_name, - "display_name": "Kokoro Flow Chatter V2", + "display_name": "Kokoro Flow Chatter", "version": "2.0.0", "author": "MoFox", "description": "专为私聊设计的增强 Chatter", diff --git a/src/plugins/built_in/kokoro_flow_chatter_v2/prompt/builder.py b/src/plugins/built_in/kokoro_flow_chatter_v2/prompt/builder.py index f69a26666..bac767623 100644 --- a/src/plugins/built_in/kokoro_flow_chatter_v2/prompt/builder.py +++ b/src/plugins/built_in/kokoro_flow_chatter_v2/prompt/builder.py @@ -80,13 +80,16 @@ class PromptBuilder: session, user_name, situation_type, extra_context ) - # 5. 构建可用动作 + # 5. 构建聊天历史总览 + chat_history_block = await self._build_chat_history_block(chat_stream) + + # 6. 构建可用动作 actions_block = self._build_actions_block(available_actions) - # 6. 获取输出格式 + # 7. 获取输出格式 output_format = await self._get_output_format() - # 7. 使用统一的 prompt 管理系统格式化 + # 8. 使用统一的 prompt 管理系统格式化 prompt = await global_prompt_manager.format_prompt( PROMPT_NAMES["main"], user_name=user_name, @@ -94,6 +97,7 @@ class PromptBuilder: relation_block=relation_block, activity_stream=activity_stream or "(这是你们第一次聊天)", current_situation=current_situation, + chat_history_block=chat_history_block, available_actions=actions_block, output_format=output_format, ) @@ -155,6 +159,57 @@ class PromptBuilder: return f"你与 {user_name} 还不太熟悉,这是早期的交流阶段。" + async def _build_chat_history_block( + self, + chat_stream: Optional["ChatStream"], + ) -> str: + """ + 构建聊天历史总览块 + + 从 chat_stream 获取历史消息,格式化为可读的聊天记录 + 类似于 AFC 的已读历史板块 + """ + if not chat_stream: + return "(暂无聊天记录)" + + try: + from src.chat.utils.chat_message_builder import build_readable_messages_with_id + from src.chat.utils.chat_message_builder import get_raw_msg_before_timestamp_with_chat + from src.common.data_models.database_data_model import DatabaseMessages + + stream_context = chat_stream.context + + # 获取已读消息 + history_messages = stream_context.history_messages if stream_context else [] + + if not history_messages: + # 如果内存中没有历史消息,从数据库加载 + fallback_messages_dicts = await get_raw_msg_before_timestamp_with_chat( + chat_id=chat_stream.stream_id, + timestamp=time.time(), + limit=30, # 限制数量,私聊不需要太多 + ) + history_messages = [ + DatabaseMessages(**msg_dict) for msg_dict in fallback_messages_dicts + ] + + if not history_messages: + return "(暂无聊天记录)" + + # 构建可读消息 + chat_content, _ = await build_readable_messages_with_id( + messages=[msg.flatten() for msg in history_messages[-30:]], # 最多30条 + timestamp_mode="normal_no_YMD", + truncate=False, + show_actions=False, + ) + + return chat_content if chat_content else "(暂无聊天记录)" + + except Exception as e: + logger.warning(f"构建聊天历史块失败: {e}") + return "(获取聊天记录失败)" + async def _build_activity_stream( self, session: KokoroSession, @@ -285,6 +340,12 @@ class PromptBuilder: """构建当前情况描述""" current_time = datetime.now().strftime("%Y年%m月%d日 %H:%M") + # 如果之前没有设置等待时间(max_wait_seconds == 0),视为 new_message + if situation_type in ("reply_in_time", "reply_late"): + max_wait = session.waiting_config.max_wait_seconds + if max_wait <= 0: + situation_type = "new_message" + if situation_type == "new_message": return await global_prompt_manager.format_prompt( PROMPT_NAMES["situation_new_message"], diff --git a/src/plugins/built_in/kokoro_flow_chatter_v2/prompt/prompts.py b/src/plugins/built_in/kokoro_flow_chatter_v2/prompt/prompts.py index fddedc727..9631582a4 100644 --- a/src/plugins/built_in/kokoro_flow_chatter_v2/prompt/prompts.py +++ b/src/plugins/built_in/kokoro_flow_chatter_v2/prompt/prompts.py @@ -28,10 +28,15 @@ KFC_V2_MAIN_PROMPT = Prompt( ## 4. 当前情况 {current_situation} -## 5. 你可以做的事情 +## 5. 聊天历史总览 +以下是你和 {user_name} 的聊天记录,帮助你更好地理解对话上下文: + +{chat_history_block} + +## 6. 你可以做的事情 {available_actions} -## 6. 你的回复格式 +## 7. 你的回复格式 {output_format} """, ) diff --git a/src/plugins/built_in/kokoro_flow_chatter_v2/replyer.py b/src/plugins/built_in/kokoro_flow_chatter_v2/replyer.py index b04587fd6..0d2b2e2b0 100644 --- a/src/plugins/built_in/kokoro_flow_chatter_v2/replyer.py +++ b/src/plugins/built_in/kokoro_flow_chatter_v2/replyer.py @@ -69,7 +69,7 @@ async def generate_response( success, raw_response, reasoning, model_name = await llm_api.generate_with_model( prompt=prompt, model_config=replyer_config, - request_type="kokoro_flow_chatter_v2", + request_type="kokoro_flow_chatter", ) if not success: diff --git a/src/plugins/built_in/kokoro_flow_chatter_v2/session.py b/src/plugins/built_in/kokoro_flow_chatter_v2/session.py index 81fb9d4a8..7ea3e78ed 100644 --- a/src/plugins/built_in/kokoro_flow_chatter_v2/session.py +++ b/src/plugins/built_in/kokoro_flow_chatter_v2/session.py @@ -263,7 +263,7 @@ class SessionManager: def __init__( self, - data_dir: str = "data/kokoro_flow_chatter_v2/sessions", + data_dir: str = "data/kokoro_flow_chatter/sessions", max_session_age_days: int = 30, ): if hasattr(self, "_initialized") and self._initialized: diff --git a/src/plugins/built_in/social_toolkit_plugin/plugin.py b/src/plugins/built_in/social_toolkit_plugin/plugin.py index 2433eeb2b..90f75b934 100644 --- a/src/plugins/built_in/social_toolkit_plugin/plugin.py +++ b/src/plugins/built_in/social_toolkit_plugin/plugin.py @@ -140,14 +140,7 @@ class PokeAction(BaseAction): # === 基本信息(必须填写)=== action_name = "poke_user" - action_description = """可以让你戳其他用户,为互动增添一份小小的乐趣。 - 判定条件: - 1. **互动时机**: 这是一个有趣的互动方式,可以在想提醒某人,或者单纯想开个玩笑时使用。 - 2. **用户请求**: 当用户明确要求使用戳一戳时。 - 3. **上下文需求**: 当上下文明确需要你戳一个或多个人时。 - 4. **频率与情绪**: 如果最近已经戳过,或者感觉对方情绪不高,请避免使用,不要打扰到别人哦。 - - 请根据上述规则,回答“是”或“否”。""" + action_description = "可以让你戳其他用户,为互动增添一份小小的乐趣。" activation_type = ActionActivationType.ALWAYS parallel_action = True From 109468a8089049a755bec82d8fca69b2d2db0b5e Mon Sep 17 00:00:00 2001 From: Windpicker-owo <3431391539@qq.com> Date: Sun, 30 Nov 2025 16:50:15 +0800 Subject: [PATCH 13/28] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0KFC=20V2?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E5=92=8C=E6=8F=90=E7=A4=BA=E8=AF=8D=E6=9E=84?= =?UTF-8?q?=E5=BB=BA=E9=80=BB=E8=BE=91=EF=BC=8C=E6=94=AF=E6=8C=81=E5=A4=9A?= =?UTF-8?q?=E7=A7=8D=E9=85=8D=E7=BD=AE=E9=A1=B9=E5=92=8C=E4=B8=8A=E4=B8=8B?= =?UTF-8?q?=E6=96=87=E6=95=B0=E6=8D=AE=E6=95=B4=E5=90=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../built_in/kokoro_flow_chatter_v2/config.py | 15 ++-- .../proactive_thinker.py | 10 +++ .../kokoro_flow_chatter_v2/prompt/builder.py | 83 +++++++++++++++---- .../kokoro_flow_chatter_v2/prompt/prompts.py | 16 ++-- 4 files changed, 96 insertions(+), 28 deletions(-) diff --git a/src/plugins/built_in/kokoro_flow_chatter_v2/config.py b/src/plugins/built_in/kokoro_flow_chatter_v2/config.py index e7bc53de0..9ddef39a9 100644 --- a/src/plugins/built_in/kokoro_flow_chatter_v2/config.py +++ b/src/plugins/built_in/kokoro_flow_chatter_v2/config.py @@ -148,9 +148,9 @@ def load_config() -> KokoroFlowChatterConfig: if hasattr(global_config, 'kokoro_flow_chatter'): kfc_cfg = getattr(global_config, 'kokoro_flow_chatter') - # 基础配置 - if hasattr(kfc_cfg, 'enabled'): - config.enabled = kfc_cfg.enabled + # 基础配置 - 支持 enabled 和 enable 两种写法 + if hasattr(kfc_cfg, 'enable'): + config.enabled = kfc_cfg.enable if hasattr(kfc_cfg, 'enabled_stream_types'): config.enabled_stream_types = list(kfc_cfg.enabled_stream_types) if hasattr(kfc_cfg, 'debug'): @@ -165,9 +165,12 @@ def load_config() -> KokoroFlowChatterConfig: max_wait_seconds=getattr(wait_cfg, 'max_wait_seconds', 1800), ) - # 主动思考配置 - if hasattr(kfc_cfg, 'proactive'): - pro_cfg = kfc_cfg.proactive + # 主动思考配置 - 支持 proactive 和 proactive_thinking 两种写法 + pro_cfg = None + if hasattr(kfc_cfg, 'proactive_thinking'): + pro_cfg = kfc_cfg.proactive_thinking + + if pro_cfg: config.proactive = ProactiveConfig( enabled=getattr(pro_cfg, 'enabled', True), silence_threshold_seconds=getattr(pro_cfg, 'silence_threshold_seconds', 7200), diff --git a/src/plugins/built_in/kokoro_flow_chatter_v2/proactive_thinker.py b/src/plugins/built_in/kokoro_flow_chatter_v2/proactive_thinker.py index ecf6f8a52..a729c0da3 100644 --- a/src/plugins/built_in/kokoro_flow_chatter_v2/proactive_thinker.py +++ b/src/plugins/built_in/kokoro_flow_chatter_v2/proactive_thinker.py @@ -283,6 +283,11 @@ class ProactiveThinker: action_manager = ChatterActionManager() await action_manager.load_actions(session.stream_id) + # 通过 ActionModifier 过滤动作 + from src.chat.planner_actions.action_modifier import ActionModifier + action_modifier = ActionModifier(action_manager, session.stream_id) + await action_modifier.modify_actions(chatter_name="KokoroFlowChatter") + # 调用 Replyer 生成超时决策 response = await generate_response( session=session, @@ -432,6 +437,11 @@ class ProactiveThinker: action_manager = ChatterActionManager() await action_manager.load_actions(session.stream_id) + # 通过 ActionModifier 过滤动作 + from src.chat.planner_actions.action_modifier import ActionModifier + action_modifier = ActionModifier(action_manager, session.stream_id) + await action_modifier.modify_actions(chatter_name="KokoroFlowChatter") + # 计算沉默时长 silence_seconds = time.time() - session.last_activity_at if silence_seconds < 3600: diff --git a/src/plugins/built_in/kokoro_flow_chatter_v2/prompt/builder.py b/src/plugins/built_in/kokoro_flow_chatter_v2/prompt/builder.py index bac767623..0e779160b 100644 --- a/src/plugins/built_in/kokoro_flow_chatter_v2/prompt/builder.py +++ b/src/plugins/built_in/kokoro_flow_chatter_v2/prompt/builder.py @@ -69,8 +69,11 @@ class PromptBuilder: # 1. 构建人设块 persona_block = self._build_persona_block() - # 2. 构建关系块 - relation_block = await self._build_relation_block(user_name, chat_stream, user_id) + # 2. 使用 context_builder 获取关系、记忆、表达习惯等 + context_data = await self._build_context_data(user_name, chat_stream, user_id) + relation_block = context_data.get("relation_info", f"你与 {user_name} 还不太熟悉,这是早期的交流阶段。") + memory_block = context_data.get("memory_block", "") + expression_habits = self._build_combined_expression_block(context_data.get("expression_habits", "")) # 3. 构建活动流 activity_stream = await self._build_activity_stream(session, user_name) @@ -95,6 +98,8 @@ class PromptBuilder: user_name=user_name, persona_block=persona_block, relation_block=relation_block, + memory_block=memory_block or "(暂无相关记忆)", + expression_habits=expression_habits or "(根据自然对话风格回复即可)", activity_stream=activity_stream or "(这是你们第一次聊天)", current_situation=current_situation, chat_history_block=chat_history_block, @@ -121,20 +126,55 @@ class PromptBuilder: if personality.identity: parts.append(personality.identity) - if personality.reply_style: - parts.append(f"\n### 说话风格\n{personality.reply_style}") - return "\n\n".join(parts) if parts else "你是一个温暖、真诚的人。" - async def _build_relation_block( + def _build_combined_expression_block(self, learned_habits: str) -> str: + """ + 构建合并后的表达习惯块 + + 合并: + - 说话风格(来自人设配置 personality.reply_style) + - 表达习惯(来自学习系统) + """ + parts = [] + + # 1. 添加说话风格(来自配置) + if global_config and global_config.personality.reply_style: + parts.append(f"**说话风格**:\n{global_config.personality.reply_style}") + + # 2. 添加学习到的表达习惯 + if learned_habits and learned_habits.strip(): + # 如果 learned_habits 已经有标题,直接追加;否则添加标题 + if learned_habits.startswith("### "): + # 移除原有标题,统一格式 + lines = learned_habits.split("\n") + content_lines = [l for l in lines if not l.startswith("### ")] + parts.append("\n".join(content_lines).strip()) + else: + parts.append(learned_habits) + + if parts: + return "\n\n".join(parts) + + return "" + + async def _build_context_data( self, user_name: str, chat_stream: Optional["ChatStream"], user_id: Optional[str] = None, - ) -> str: - """构建关系块""" + ) -> dict[str, str]: + """ + 使用 KFCContextBuilder 构建完整的上下文数据 + + 包括:关系信息、记忆、表达习惯等 + """ if not chat_stream: - return f"你与 {user_name} 还不太熟悉,这是早期的交流阶段。" + return { + "relation_info": f"你与 {user_name} 还不太熟悉,这是早期的交流阶段。", + "memory_block": "", + "expression_habits": "", + } try: # 延迟导入上下文构建器 @@ -143,21 +183,30 @@ class PromptBuilder: self._context_builder = KFCContextBuilder builder = self._context_builder(chat_stream) + + # 获取最近的消息作为 target_message(用于记忆检索) + target_message = "" + if chat_stream.context: + unread = chat_stream.context.get_unread_messages() + if unread: + target_message = unread[-1].processed_plain_text or unread[-1].display_message or "" + context_data = await builder.build_all_context( sender_name=user_name, - target_message="", - context=None, + target_message=target_message, + context=chat_stream.context, user_id=user_id, ) - relation_info = context_data.get("relation_info", "") - if relation_info: - return relation_info + return context_data except Exception as e: - logger.warning(f"构建关系块失败: {e}") - - return f"你与 {user_name} 还不太熟悉,这是早期的交流阶段。" + logger.warning(f"构建上下文数据失败: {e}") + return { + "relation_info": f"你与 {user_name} 还不太熟悉,这是早期的交流阶段。", + "memory_block": "", + "expression_habits": "", + } async def _build_chat_history_block( self, diff --git a/src/plugins/built_in/kokoro_flow_chatter_v2/prompt/prompts.py b/src/plugins/built_in/kokoro_flow_chatter_v2/prompt/prompts.py index 9631582a4..fac28d6bd 100644 --- a/src/plugins/built_in/kokoro_flow_chatter_v2/prompt/prompts.py +++ b/src/plugins/built_in/kokoro_flow_chatter_v2/prompt/prompts.py @@ -20,23 +20,29 @@ KFC_V2_MAIN_PROMPT = Prompt( ## 2. 你与 {user_name} 的关系 {relation_block} -## 3. 你们之间发生的事(活动流) +## 3. 相关记忆 +{memory_block} + +## 4. 你们之间发生的事(活动流) 以下是你和 {user_name} 最近的互动历史,按时间顺序记录了你们的对话和你的心理活动: {activity_stream} -## 4. 当前情况 +## 5. 当前情况 {current_situation} -## 5. 聊天历史总览 +## 6. 聊天历史总览 以下是你和 {user_name} 的聊天记录,帮助你更好地理解对话上下文: {chat_history_block} -## 6. 你可以做的事情 +## 7. 你可以做的事情 {available_actions} -## 7. 你的回复格式 +## 8. 你的表达习惯 +{expression_habits} + +## 9. 你的回复格式 {output_format} """, ) From 7eb784f137a8e6a89aac96e1406e8b98047a522d Mon Sep 17 00:00:00 2001 From: Windpicker-owo <3431391539@qq.com> Date: Sun, 30 Nov 2025 17:17:14 +0800 Subject: [PATCH 14/28] =?UTF-8?q?feat:=20=E5=AE=9E=E6=96=BDKokoro=20Flow?= =?UTF-8?q?=20Chatter=E6=8F=90=E7=A4=BA=E7=AE=A1=E7=90=86=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在Kokoro Flow Chatter中添加提示模块以管理提示信息。 引入PromptBuilder,用于根据会话上下文构建提示。 - 为各种场景创建提示模板,包括用户消息、机器人规划和等待更新。 - 开发一个回复模块,使用LLM API生成回复。 - 建立会话管理机制,以处理用户交互并维护会话状态。 - 实现日志记录功能,以便更好地追踪操作和错误。 --- .../__init__.py | 0 .../actions/__init__.py | 0 .../actions/reply.py | 0 .../chatter.py | 0 .../config.py | 2 +- .../context_builder.py | 6 +- .../models.py | 2 +- .../plugin.py | 0 .../proactive_thinker.py | 8 +- .../prompt/__init__.py | 0 .../prompt/builder.py | 4 +- .../prompt/prompts.py | 86 +++++++++---------- .../replyer.py | 4 +- .../session.py | 6 +- 14 files changed, 59 insertions(+), 59 deletions(-) rename src/plugins/built_in/{kokoro_flow_chatter_v2 => kokoro_flow_chatter}/__init__.py (100%) rename src/plugins/built_in/{kokoro_flow_chatter_v2 => kokoro_flow_chatter}/actions/__init__.py (100%) rename src/plugins/built_in/{kokoro_flow_chatter_v2 => kokoro_flow_chatter}/actions/reply.py (100%) rename src/plugins/built_in/{kokoro_flow_chatter_v2 => kokoro_flow_chatter}/chatter.py (100%) rename src/plugins/built_in/{kokoro_flow_chatter_v2 => kokoro_flow_chatter}/config.py (99%) rename src/plugins/built_in/{kokoro_flow_chatter_v2 => kokoro_flow_chatter}/context_builder.py (98%) rename src/plugins/built_in/{kokoro_flow_chatter_v2 => kokoro_flow_chatter}/models.py (99%) rename src/plugins/built_in/{kokoro_flow_chatter_v2 => kokoro_flow_chatter}/plugin.py (100%) rename src/plugins/built_in/{kokoro_flow_chatter_v2 => kokoro_flow_chatter}/proactive_thinker.py (99%) rename src/plugins/built_in/{kokoro_flow_chatter_v2 => kokoro_flow_chatter}/prompt/__init__.py (100%) rename src/plugins/built_in/{kokoro_flow_chatter_v2 => kokoro_flow_chatter}/prompt/builder.py (99%) rename src/plugins/built_in/{kokoro_flow_chatter_v2 => kokoro_flow_chatter}/prompt/prompts.py (75%) rename src/plugins/built_in/{kokoro_flow_chatter_v2 => kokoro_flow_chatter}/replyer.py (97%) rename src/plugins/built_in/{kokoro_flow_chatter_v2 => kokoro_flow_chatter}/session.py (99%) diff --git a/src/plugins/built_in/kokoro_flow_chatter_v2/__init__.py b/src/plugins/built_in/kokoro_flow_chatter/__init__.py similarity index 100% rename from src/plugins/built_in/kokoro_flow_chatter_v2/__init__.py rename to src/plugins/built_in/kokoro_flow_chatter/__init__.py diff --git a/src/plugins/built_in/kokoro_flow_chatter_v2/actions/__init__.py b/src/plugins/built_in/kokoro_flow_chatter/actions/__init__.py similarity index 100% rename from src/plugins/built_in/kokoro_flow_chatter_v2/actions/__init__.py rename to src/plugins/built_in/kokoro_flow_chatter/actions/__init__.py diff --git a/src/plugins/built_in/kokoro_flow_chatter_v2/actions/reply.py b/src/plugins/built_in/kokoro_flow_chatter/actions/reply.py similarity index 100% rename from src/plugins/built_in/kokoro_flow_chatter_v2/actions/reply.py rename to src/plugins/built_in/kokoro_flow_chatter/actions/reply.py diff --git a/src/plugins/built_in/kokoro_flow_chatter_v2/chatter.py b/src/plugins/built_in/kokoro_flow_chatter/chatter.py similarity index 100% rename from src/plugins/built_in/kokoro_flow_chatter_v2/chatter.py rename to src/plugins/built_in/kokoro_flow_chatter/chatter.py diff --git a/src/plugins/built_in/kokoro_flow_chatter_v2/config.py b/src/plugins/built_in/kokoro_flow_chatter/config.py similarity index 99% rename from src/plugins/built_in/kokoro_flow_chatter_v2/config.py rename to src/plugins/built_in/kokoro_flow_chatter/config.py index 9ddef39a9..f567a0a2a 100644 --- a/src/plugins/built_in/kokoro_flow_chatter_v2/config.py +++ b/src/plugins/built_in/kokoro_flow_chatter/config.py @@ -1,5 +1,5 @@ """ -Kokoro Flow Chatter V2 - 配置 +Kokoro Flow Chatter - 配置 可以通过 TOML 配置文件覆盖默认值 """ diff --git a/src/plugins/built_in/kokoro_flow_chatter_v2/context_builder.py b/src/plugins/built_in/kokoro_flow_chatter/context_builder.py similarity index 98% rename from src/plugins/built_in/kokoro_flow_chatter_v2/context_builder.py rename to src/plugins/built_in/kokoro_flow_chatter/context_builder.py index 492f96581..a83a2bf22 100644 --- a/src/plugins/built_in/kokoro_flow_chatter_v2/context_builder.py +++ b/src/plugins/built_in/kokoro_flow_chatter/context_builder.py @@ -1,7 +1,7 @@ """ -Kokoro Flow Chatter V2 上下文构建器 +Kokoro Flow Chatter 上下文构建器 -为 KFC V2 提供完整的情境感知能力。 +为 KFC 提供完整的情境感知能力。 包含: - 关系信息 (relation_info) - 记忆块 (memory_block) @@ -23,7 +23,7 @@ if TYPE_CHECKING: from src.chat.message_receive.chat_stream import ChatStream from src.common.data_models.message_manager_data_model import StreamContext -logger = get_logger("kfc_v2_context_builder") +logger = get_logger("kfc_context_builder") def _get_config(): diff --git a/src/plugins/built_in/kokoro_flow_chatter_v2/models.py b/src/plugins/built_in/kokoro_flow_chatter/models.py similarity index 99% rename from src/plugins/built_in/kokoro_flow_chatter_v2/models.py rename to src/plugins/built_in/kokoro_flow_chatter/models.py index 4ca2e2e1c..fb72423e6 100644 --- a/src/plugins/built_in/kokoro_flow_chatter_v2/models.py +++ b/src/plugins/built_in/kokoro_flow_chatter/models.py @@ -1,5 +1,5 @@ """ -Kokoro Flow Chatter V2 - 数据模型 +Kokoro Flow Chatter - 数据模型 定义核心数据结构: - EventType: 活动流事件类型 diff --git a/src/plugins/built_in/kokoro_flow_chatter_v2/plugin.py b/src/plugins/built_in/kokoro_flow_chatter/plugin.py similarity index 100% rename from src/plugins/built_in/kokoro_flow_chatter_v2/plugin.py rename to src/plugins/built_in/kokoro_flow_chatter/plugin.py diff --git a/src/plugins/built_in/kokoro_flow_chatter_v2/proactive_thinker.py b/src/plugins/built_in/kokoro_flow_chatter/proactive_thinker.py similarity index 99% rename from src/plugins/built_in/kokoro_flow_chatter_v2/proactive_thinker.py rename to src/plugins/built_in/kokoro_flow_chatter/proactive_thinker.py index a729c0da3..bd52aab0e 100644 --- a/src/plugins/built_in/kokoro_flow_chatter_v2/proactive_thinker.py +++ b/src/plugins/built_in/kokoro_flow_chatter/proactive_thinker.py @@ -1,5 +1,5 @@ """ -Kokoro Flow Chatter V2 - 主动思考器 +Kokoro Flow Chatter - 主动思考器 独立组件,负责: 1. 等待期间的连续思考(更新心理状态) @@ -27,7 +27,7 @@ from .session import KokoroSession, get_session_manager if TYPE_CHECKING: from src.chat.message_receive.chat_stream import ChatStream -logger = get_logger("kfc_v2_proactive_thinker") +logger = get_logger("kfc_proactive_thinker") class ProactiveThinker: @@ -49,8 +49,8 @@ class ProactiveThinker: THINKING_TRIGGERS = [0.3, 0.6, 0.85] # 任务名称 - TASK_WAITING_CHECK = "kfc_v2_waiting_check" - TASK_PROACTIVE_CHECK = "kfc_v2_proactive_check" + TASK_WAITING_CHECK = "kfc_waiting_check" + TASK_PROACTIVE_CHECK = "kfc_proactive_check" def __init__(self): self.session_manager = get_session_manager() diff --git a/src/plugins/built_in/kokoro_flow_chatter_v2/prompt/__init__.py b/src/plugins/built_in/kokoro_flow_chatter/prompt/__init__.py similarity index 100% rename from src/plugins/built_in/kokoro_flow_chatter_v2/prompt/__init__.py rename to src/plugins/built_in/kokoro_flow_chatter/prompt/__init__.py diff --git a/src/plugins/built_in/kokoro_flow_chatter_v2/prompt/builder.py b/src/plugins/built_in/kokoro_flow_chatter/prompt/builder.py similarity index 99% rename from src/plugins/built_in/kokoro_flow_chatter_v2/prompt/builder.py rename to src/plugins/built_in/kokoro_flow_chatter/prompt/builder.py index 0e779160b..699eee803 100644 --- a/src/plugins/built_in/kokoro_flow_chatter_v2/prompt/builder.py +++ b/src/plugins/built_in/kokoro_flow_chatter/prompt/builder.py @@ -1,5 +1,5 @@ """ -Kokoro Flow Chatter V2 - 提示词构建器 +Kokoro Flow Chatter - 提示词构建器 使用项目统一的 Prompt 管理系统构建提示词 """ @@ -22,7 +22,7 @@ from .prompts import PROMPT_NAMES if TYPE_CHECKING: from src.chat.message_receive.chat_stream import ChatStream -logger = get_logger("kfc_v2_prompt_builder") +logger = get_logger("kfc_prompt_builder") class PromptBuilder: diff --git a/src/plugins/built_in/kokoro_flow_chatter_v2/prompt/prompts.py b/src/plugins/built_in/kokoro_flow_chatter/prompt/prompts.py similarity index 75% rename from src/plugins/built_in/kokoro_flow_chatter_v2/prompt/prompts.py rename to src/plugins/built_in/kokoro_flow_chatter/prompt/prompts.py index fac28d6bd..74f81dd1f 100644 --- a/src/plugins/built_in/kokoro_flow_chatter_v2/prompt/prompts.py +++ b/src/plugins/built_in/kokoro_flow_chatter/prompt/prompts.py @@ -1,5 +1,5 @@ """ -Kokoro Flow Chatter V2 - 提示词模板注册 +Kokoro Flow Chatter - 提示词模板注册 使用项目统一的 Prompt 管理系统注册所有 KFC V2 使用的提示词模板 """ @@ -10,8 +10,8 @@ from src.chat.utils.prompt import Prompt # KFC V2 主提示词模板 # ================================================================================================= -KFC_V2_MAIN_PROMPT = Prompt( - name="kfc_v2_main", +kfc_MAIN_PROMPT = Prompt( + name="kfc_main", template="""# 你与 {user_name} 的私聊 ## 1. 你是谁 @@ -51,8 +51,8 @@ KFC_V2_MAIN_PROMPT = Prompt( # 输出格式模板 # ================================================================================================= -KFC_V2_OUTPUT_FORMAT = Prompt( - name="kfc_v2_output_format", +kfc_OUTPUT_FORMAT = Prompt( + name="kfc_output_format", template="""请用以下 JSON 格式回复: ```json {{ @@ -81,8 +81,8 @@ KFC_V2_OUTPUT_FORMAT = Prompt( # 情景模板 - 根据不同情境使用不同的当前情况描述 # ================================================================================================= -KFC_V2_SITUATION_NEW_MESSAGE = Prompt( - name="kfc_v2_situation_new_message", +kfc_SITUATION_NEW_MESSAGE = Prompt( + name="kfc_situation_new_message", template="""现在是 {current_time}。 {user_name} 刚刚给你发了消息。这是一次新的对话发起(不是对你之前消息的回复)。 @@ -95,8 +95,8 @@ KFC_V2_SITUATION_NEW_MESSAGE = Prompt( - 或者组合多个动作""", ) -KFC_V2_SITUATION_REPLY_IN_TIME = Prompt( - name="kfc_v2_situation_reply_in_time", +kfc_SITUATION_REPLY_IN_TIME = Prompt( + name="kfc_situation_reply_in_time", template="""现在是 {current_time}。 你之前发了消息后一直在等 {user_name} 的回复。 @@ -106,8 +106,8 @@ KFC_V2_SITUATION_REPLY_IN_TIME = Prompt( 请决定你接下来要怎么回应。""", ) -KFC_V2_SITUATION_REPLY_LATE = Prompt( - name="kfc_v2_situation_reply_late", +kfc_SITUATION_REPLY_LATE = Prompt( + name="kfc_situation_reply_late", template="""现在是 {current_time}。 你之前发了消息后在等 {user_name} 的回复。 @@ -117,8 +117,8 @@ KFC_V2_SITUATION_REPLY_LATE = Prompt( 请决定你接下来要怎么回应。(可以选择轻轻抱怨一下迟到,也可以装作没在意)""", ) -KFC_V2_SITUATION_TIMEOUT = Prompt( - name="kfc_v2_situation_timeout", +kfc_SITUATION_TIMEOUT = Prompt( + name="kfc_situation_timeout", template="""现在是 {current_time}。 你之前发了消息后一直在等 {user_name} 的回复。 @@ -132,8 +132,8 @@ KFC_V2_SITUATION_TIMEOUT = Prompt( 4. 算了不等了(max_wait_seconds = 0)""", ) -KFC_V2_SITUATION_PROACTIVE = Prompt( - name="kfc_v2_situation_proactive", +kfc_SITUATION_PROACTIVE = Prompt( + name="kfc_situation_proactive", template="""现在是 {current_time}。 你和 {user_name} 已经有一段时间没聊天了(沉默了 {silence_duration})。 @@ -154,16 +154,16 @@ KFC_V2_SITUATION_PROACTIVE = Prompt( # ================================================================================================= # 用户消息条目 -KFC_V2_ENTRY_USER_MESSAGE = Prompt( - name="kfc_v2_entry_user_message", +kfc_ENTRY_USER_MESSAGE = Prompt( + name="kfc_entry_user_message", template="""【{time}】{user_name} 说: "{content}" """, ) # Bot 规划条目(有等待) -KFC_V2_ENTRY_BOT_PLANNING = Prompt( - name="kfc_v2_entry_bot_planning", +kfc_ENTRY_BOT_PLANNING = Prompt( + name="kfc_entry_bot_planning", template="""【你的想法】 内心:{thought} 行动:{actions_description} @@ -173,8 +173,8 @@ KFC_V2_ENTRY_BOT_PLANNING = Prompt( ) # Bot 规划条目(无等待) -KFC_V2_ENTRY_BOT_PLANNING_NO_WAIT = Prompt( - name="kfc_v2_entry_bot_planning_no_wait", +kfc_ENTRY_BOT_PLANNING_NO_WAIT = Prompt( + name="kfc_entry_bot_planning_no_wait", template="""【你的想法】 内心:{thought} 行动:{actions_description} @@ -183,30 +183,30 @@ KFC_V2_ENTRY_BOT_PLANNING_NO_WAIT = Prompt( ) # 等待期间心理变化 -KFC_V2_ENTRY_WAITING_UPDATE = Prompt( - name="kfc_v2_entry_waiting_update", +kfc_ENTRY_WAITING_UPDATE = Prompt( + name="kfc_entry_waiting_update", template="""【等待中... {elapsed_minutes:.1f} 分钟过去了】 你想:{waiting_thought} """, ) # 收到及时回复时的标注 -KFC_V2_ENTRY_REPLY_IN_TIME = Prompt( - name="kfc_v2_entry_reply_in_time", +kfc_ENTRY_REPLY_IN_TIME = Prompt( + name="kfc_entry_reply_in_time", template="""→ (对方在你预期时间内回复了,等了 {elapsed_minutes:.1f} 分钟) """, ) # 收到迟到回复时的标注 -KFC_V2_ENTRY_REPLY_LATE = Prompt( - name="kfc_v2_entry_reply_late", +kfc_ENTRY_REPLY_LATE = Prompt( + name="kfc_entry_reply_late", template="""→ (对方回复迟了,你原本只打算等 {max_wait_minutes:.1f} 分钟,实际等了 {elapsed_minutes:.1f} 分钟) """, ) # 主动思考触发 -KFC_V2_ENTRY_PROACTIVE_TRIGGER = Prompt( - name="kfc_v2_entry_proactive_trigger", +kfc_ENTRY_PROACTIVE_TRIGGER = Prompt( + name="kfc_entry_proactive_trigger", template="""【沉默了 {silence_duration}】 你开始考虑要不要主动找对方聊点什么... """, @@ -214,18 +214,18 @@ KFC_V2_ENTRY_PROACTIVE_TRIGGER = Prompt( # 导出所有模板名称,方便外部引用 PROMPT_NAMES = { - "main": "kfc_v2_main", - "output_format": "kfc_v2_output_format", - "situation_new_message": "kfc_v2_situation_new_message", - "situation_reply_in_time": "kfc_v2_situation_reply_in_time", - "situation_reply_late": "kfc_v2_situation_reply_late", - "situation_timeout": "kfc_v2_situation_timeout", - "situation_proactive": "kfc_v2_situation_proactive", - "entry_user_message": "kfc_v2_entry_user_message", - "entry_bot_planning": "kfc_v2_entry_bot_planning", - "entry_bot_planning_no_wait": "kfc_v2_entry_bot_planning_no_wait", - "entry_waiting_update": "kfc_v2_entry_waiting_update", - "entry_reply_in_time": "kfc_v2_entry_reply_in_time", - "entry_reply_late": "kfc_v2_entry_reply_late", - "entry_proactive_trigger": "kfc_v2_entry_proactive_trigger", + "main": "kfc_main", + "output_format": "kfc_output_format", + "situation_new_message": "kfc_situation_new_message", + "situation_reply_in_time": "kfc_situation_reply_in_time", + "situation_reply_late": "kfc_situation_reply_late", + "situation_timeout": "kfc_situation_timeout", + "situation_proactive": "kfc_situation_proactive", + "entry_user_message": "kfc_entry_user_message", + "entry_bot_planning": "kfc_entry_bot_planning", + "entry_bot_planning_no_wait": "kfc_entry_bot_planning_no_wait", + "entry_waiting_update": "kfc_entry_waiting_update", + "entry_reply_in_time": "kfc_entry_reply_in_time", + "entry_reply_late": "kfc_entry_reply_late", + "entry_proactive_trigger": "kfc_entry_proactive_trigger", } diff --git a/src/plugins/built_in/kokoro_flow_chatter_v2/replyer.py b/src/plugins/built_in/kokoro_flow_chatter/replyer.py similarity index 97% rename from src/plugins/built_in/kokoro_flow_chatter_v2/replyer.py rename to src/plugins/built_in/kokoro_flow_chatter/replyer.py index 0d2b2e2b0..4829d9721 100644 --- a/src/plugins/built_in/kokoro_flow_chatter_v2/replyer.py +++ b/src/plugins/built_in/kokoro_flow_chatter/replyer.py @@ -1,5 +1,5 @@ """ -Kokoro Flow Chatter V2 - Replyer +Kokoro Flow Chatter - Replyer 简化的回复生成模块,使用插件系统的 llm_api """ @@ -17,7 +17,7 @@ from .session import KokoroSession if TYPE_CHECKING: from src.chat.message_receive.chat_stream import ChatStream -logger = get_logger("kfc_v2_replyer") +logger = get_logger("kfc_replyer") async def generate_response( diff --git a/src/plugins/built_in/kokoro_flow_chatter_v2/session.py b/src/plugins/built_in/kokoro_flow_chatter/session.py similarity index 99% rename from src/plugins/built_in/kokoro_flow_chatter_v2/session.py rename to src/plugins/built_in/kokoro_flow_chatter/session.py index 7ea3e78ed..fe26714a3 100644 --- a/src/plugins/built_in/kokoro_flow_chatter_v2/session.py +++ b/src/plugins/built_in/kokoro_flow_chatter/session.py @@ -1,5 +1,5 @@ """ -Kokoro Flow Chatter V2 - 会话管理 +Kokoro Flow Chatter - 会话管理 极简的会话状态管理: - Session 只有 IDLE 和 WAITING 两种状态 @@ -23,12 +23,12 @@ from .models import ( WaitingConfig, ) -logger = get_logger("kfc_v2_session") +logger = get_logger("kfc_session") class KokoroSession: """ - Kokoro Flow Chatter V2 会话 + Kokoro Flow Chatter 会话 为每个私聊用户维护一个独立的会话,包含: - 基本信息(user_id, stream_id) From 673da5cc8b44d3b6a400f2cf80c46ef261456697 Mon Sep 17 00:00:00 2001 From: Windpicker-owo <3431391539@qq.com> Date: Sun, 30 Nov 2025 17:48:05 +0800 Subject: [PATCH 15/28] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E8=AF=8D=E6=9E=84=E5=BB=BA=E9=80=BB=E8=BE=91=EF=BC=8C?= =?UTF-8?q?=E5=A2=9E=E5=BC=BA=E8=AF=B4=E8=AF=9D=E9=A3=8E=E6=A0=BC=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/built_in/kokoro_flow_chatter/prompt/builder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/built_in/kokoro_flow_chatter/prompt/builder.py b/src/plugins/built_in/kokoro_flow_chatter/prompt/builder.py index 699eee803..552aefc01 100644 --- a/src/plugins/built_in/kokoro_flow_chatter/prompt/builder.py +++ b/src/plugins/built_in/kokoro_flow_chatter/prompt/builder.py @@ -140,7 +140,7 @@ class PromptBuilder: # 1. 添加说话风格(来自配置) if global_config and global_config.personality.reply_style: - parts.append(f"**说话风格**:\n{global_config.personality.reply_style}") + parts.append(f"**说话风格**:\n你必须参考你的说话风格:\n{global_config.personality.reply_style}") # 2. 添加学习到的表达习惯 if learned_habits and learned_habits.strip(): From 8e26a5f58c2b655576e58785dca5091f07881283 Mon Sep 17 00:00:00 2001 From: Windpicker-owo <3431391539@qq.com> Date: Sun, 30 Nov 2025 18:50:21 +0800 Subject: [PATCH 16/28] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84Kokoro=20Flow?= =?UTF-8?q?=20Chatter=EF=BC=8C=E6=96=B0=E5=A2=9E=E8=A7=84=E5=88=92?= =?UTF-8?q?=E5=99=A8=E5=92=8C=E5=9B=9E=E5=A4=8D=E7=94=9F=E6=88=90=E5=99=A8?= =?UTF-8?q?=EF=BC=8C=E4=BC=98=E5=8C=96=E6=8F=90=E7=A4=BA=E8=AF=8D=E6=9E=84?= =?UTF-8?q?=E5=BB=BA=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../built_in/kokoro_flow_chatter/__init__.py | 6 +- .../built_in/kokoro_flow_chatter/chatter.py | 63 ++++--- .../built_in/kokoro_flow_chatter/planner.py | 112 ++++++++++++ .../kokoro_flow_chatter/proactive_thinker.py | 108 ++++++++---- .../kokoro_flow_chatter/prompt/builder.py | 165 +++++++++++++++++- .../kokoro_flow_chatter/prompt/prompts.py | 131 ++++++++++++++ .../built_in/kokoro_flow_chatter/replyer.py | 86 +++++---- 7 files changed, 573 insertions(+), 98 deletions(-) create mode 100644 src/plugins/built_in/kokoro_flow_chatter/planner.py diff --git a/src/plugins/built_in/kokoro_flow_chatter/__init__.py b/src/plugins/built_in/kokoro_flow_chatter/__init__.py index 2bc00a4d5..1d3820f2e 100644 --- a/src/plugins/built_in/kokoro_flow_chatter/__init__.py +++ b/src/plugins/built_in/kokoro_flow_chatter/__init__.py @@ -19,7 +19,8 @@ from .models import ( ) from .session import KokoroSession, SessionManager, get_session_manager from .chatter import KokoroFlowChatter -from .replyer import generate_response +from .planner import generate_plan +from .replyer import generate_reply_text from .proactive_thinker import ( ProactiveThinker, get_proactive_thinker, @@ -60,7 +61,8 @@ __all__ = [ "get_session_manager", # Core Components "KokoroFlowChatter", - "generate_response", + "generate_plan", + "generate_reply_text", # Proactive Thinker "ProactiveThinker", "get_proactive_thinker", diff --git a/src/plugins/built_in/kokoro_flow_chatter/chatter.py b/src/plugins/built_in/kokoro_flow_chatter/chatter.py index c7d1f3b0e..63d69b632 100644 --- a/src/plugins/built_in/kokoro_flow_chatter/chatter.py +++ b/src/plugins/built_in/kokoro_flow_chatter/chatter.py @@ -19,7 +19,8 @@ from src.plugin_system.base.base_chatter import BaseChatter from src.plugin_system.base.component_types import ChatType from .models import SessionStatus -from .replyer import generate_response +from .planner import generate_plan +from .replyer import generate_reply_text from .session import get_session_manager if TYPE_CHECKING: @@ -143,8 +144,8 @@ class KokoroFlowChatter(BaseChatter): # 8. 获取聊天流 chat_stream = await self._get_chat_stream() - # 9. 调用 Replyer 生成响应 - response = await generate_response( + # 9. 调用 Planner 生成行动计划 + plan_response = await generate_plan( session=session, user_name=user_name, situation_type=situation_type, @@ -152,15 +153,35 @@ class KokoroFlowChatter(BaseChatter): available_actions=available_actions, ) - # 10. 执行动作作 + # 10. 对于需要回复的动作,调用 Replyer 生成实际文本 + processed_actions = [] + for action in plan_response.actions: + if action.type == "kfc_reply": + # 调用 replyer 生成回复文本 + success, reply_text = await generate_reply_text( + session=session, + user_name=user_name, + thought=plan_response.thought, + situation_type=situation_type, + chat_stream=chat_stream, + ) + if success and reply_text: + # 更新 action 的 content + action.params["content"] = reply_text + else: + logger.warning("[KFC] 回复生成失败,跳过该动作") + continue + processed_actions.append(action) + + # 11. 执行动作 exec_results = [] has_reply = False - for action in response.actions: + for action in processed_actions: result = await self.action_manager.execute_action( action_name=action.type, chat_id=self.stream_id, target_message=target_message, - reasoning=response.thought, + reasoning=plan_response.thought, action_data=action.params, thinking_id=None, log_prefix="[KFC]", @@ -169,31 +190,31 @@ class KokoroFlowChatter(BaseChatter): if result.get("success") and action.type in ("kfc_reply", "respond"): has_reply = True - # 11. 记录 Bot 规划到 mental_log + # 12. 记录 Bot 规划到 mental_log session.add_bot_planning( - thought=response.thought, - actions=[a.to_dict() for a in response.actions], - expected_reaction=response.expected_reaction, - max_wait_seconds=response.max_wait_seconds, + thought=plan_response.thought, + actions=[a.to_dict() for a in processed_actions], + expected_reaction=plan_response.expected_reaction, + max_wait_seconds=plan_response.max_wait_seconds, ) - # 12. 更新 Session 状态 - if response.max_wait_seconds > 0: + # 13. 更新 Session 状态 + if plan_response.max_wait_seconds > 0: session.start_waiting( - expected_reaction=response.expected_reaction, - max_wait_seconds=response.max_wait_seconds, + expected_reaction=plan_response.expected_reaction, + max_wait_seconds=plan_response.max_wait_seconds, ) else: session.end_waiting() - # 13. 标记消息为已读 + # 14. 标记消息为已读 for msg in unread_messages: context.mark_message_as_read(str(msg.message_id)) - # 14. 保存 Session + # 15. 保存 Session await self.session_manager.save_session(user_id) - # 15. 更新统计 + # 16. 更新统计 self._stats["messages_processed"] += len(unread_messages) if has_reply: self._stats["successful_responses"] += 1 @@ -201,15 +222,15 @@ class KokoroFlowChatter(BaseChatter): logger.info( f"{SOFT_PURPLE}[KFC]{RESET} 处理完成: " f"user={user_name}, situation={situation_type}, " - f"actions={[a.type for a in response.actions]}, " - f"wait={response.max_wait_seconds}s" + f"actions={[a.type for a in processed_actions]}, " + f"wait={plan_response.max_wait_seconds}s" ) return self._build_result( success=True, message="processed", has_reply=has_reply, - thought=response.thought, + thought=plan_response.thought, situation_type=situation_type, ) diff --git a/src/plugins/built_in/kokoro_flow_chatter/planner.py b/src/plugins/built_in/kokoro_flow_chatter/planner.py new file mode 100644 index 000000000..8ffd4609d --- /dev/null +++ b/src/plugins/built_in/kokoro_flow_chatter/planner.py @@ -0,0 +1,112 @@ +""" +Kokoro Flow Chatter - Planner + +规划器:负责分析情境并生成行动计划 +- 输入:会话状态、用户消息、情境类型 +- 输出:LLMResponse(包含 thought、actions、expected_reaction、max_wait_seconds) +- 不负责生成具体回复文本,只决定"要做什么" +""" + +from typing import TYPE_CHECKING, Optional + +from src.common.logger import get_logger +from src.plugin_system.apis import llm_api +from src.utils.json_parser import extract_and_parse_json + +from .models import LLMResponse +from .prompt.builder import get_prompt_builder +from .session import KokoroSession + +if TYPE_CHECKING: + from src.chat.message_receive.chat_stream import ChatStream + +logger = get_logger("kfc_planner") + + +async def generate_plan( + session: KokoroSession, + user_name: str, + situation_type: str = "new_message", + chat_stream: Optional["ChatStream"] = None, + available_actions: Optional[dict] = None, + extra_context: Optional[dict] = None, +) -> LLMResponse: + """ + 生成行动计划 + + Args: + session: 会话对象 + user_name: 用户名称 + situation_type: 情况类型 + chat_stream: 聊天流对象 + available_actions: 可用动作字典 + extra_context: 额外上下文 + + Returns: + LLMResponse 对象,包含计划信息 + """ + try: + # 1. 构建规划器提示词 + prompt_builder = get_prompt_builder() + prompt = await prompt_builder.build_planner_prompt( + session=session, + user_name=user_name, + situation_type=situation_type, + chat_stream=chat_stream, + available_actions=available_actions, + extra_context=extra_context, + ) + + from src.config.config import global_config + if global_config and global_config.debug.show_prompt: + logger.info(f"[KFC Planner] 生成的规划提示词:\n{prompt}") + + # 2. 获取 planner 模型配置并调用 LLM + models = llm_api.get_available_models() + planner_config = models.get("planner") + + if not planner_config: + logger.error("[KFC Planner] 未找到 planner 模型配置") + return LLMResponse.create_error_response("未找到 planner 模型配置") + + success, raw_response, reasoning, model_name = await llm_api.generate_with_model( + prompt=prompt, + model_config=planner_config, + request_type="kokoro_flow_chatter.plan", + ) + + if not success: + logger.error(f"[KFC Planner] LLM 调用失败: {raw_response}") + return LLMResponse.create_error_response(raw_response) + + logger.debug(f"[KFC Planner] LLM 响应 (model={model_name}):\n{raw_response}") + + # 3. 解析响应 + return _parse_response(raw_response) + + except Exception as e: + logger.error(f"[KFC Planner] 生成失败: {e}") + import traceback + traceback.print_exc() + return LLMResponse.create_error_response(str(e)) + + +def _parse_response(raw_response: str) -> LLMResponse: + """解析 LLM 响应""" + data = extract_and_parse_json(raw_response, strict=False) + + if not data or not isinstance(data, dict): + logger.warning(f"[KFC Planner] 无法解析 JSON: {raw_response[:200]}...") + return LLMResponse.create_error_response("无法解析响应格式") + + response = LLMResponse.from_dict(data) + + if response.thought: + logger.info( + f"[KFC Planner] 解析成功: thought={response.thought[:50]}..., " + f"actions={[a.type for a in response.actions]}" + ) + else: + logger.warning("[KFC Planner] 响应缺少 thought") + + return response diff --git a/src/plugins/built_in/kokoro_flow_chatter/proactive_thinker.py b/src/plugins/built_in/kokoro_flow_chatter/proactive_thinker.py index bd52aab0e..d1abab669 100644 --- a/src/plugins/built_in/kokoro_flow_chatter/proactive_thinker.py +++ b/src/plugins/built_in/kokoro_flow_chatter/proactive_thinker.py @@ -21,7 +21,8 @@ from src.config.config import global_config from src.plugin_system.apis.unified_scheduler import TriggerType, unified_scheduler from .models import EventType, SessionStatus -from .replyer import generate_response +from .planner import generate_plan +from .replyer import generate_reply_text from .session import KokoroSession, get_session_manager if TYPE_CHECKING: @@ -288,8 +289,8 @@ class ProactiveThinker: action_modifier = ActionModifier(action_manager, session.stream_id) await action_modifier.modify_actions(chatter_name="KokoroFlowChatter") - # 调用 Replyer 生成超时决策 - response = await generate_response( + # 调用 Planner 生成超时决策 + plan_response = await generate_plan( session=session, user_name=session.user_id, # 这里可以改进,获取真实用户名 situation_type="timeout", @@ -297,32 +298,50 @@ class ProactiveThinker: available_actions=action_manager.get_using_actions(), ) + # 对于需要回复的动作,调用 Replyer 生成实际文本 + processed_actions = [] + for action in plan_response.actions: + if action.type == "kfc_reply": + success, reply_text = await generate_reply_text( + session=session, + user_name=session.user_id, + thought=plan_response.thought, + situation_type="timeout", + chat_stream=chat_stream, + ) + if success and reply_text: + action.params["content"] = reply_text + else: + logger.warning("[ProactiveThinker] 回复生成失败,跳过该动作") + continue + processed_actions.append(action) + # 执行动作 - for action in response.actions: + for action in processed_actions: await action_manager.execute_action( action_name=action.type, chat_id=session.stream_id, target_message=None, - reasoning=response.thought, + reasoning=plan_response.thought, action_data=action.params, thinking_id=None, - log_prefix="[KFC V2 ProactiveThinker]", + log_prefix="[KFC ProactiveThinker]", ) # 记录到 mental_log session.add_bot_planning( - thought=response.thought, - actions=[a.to_dict() for a in response.actions], - expected_reaction=response.expected_reaction, - max_wait_seconds=response.max_wait_seconds, + thought=plan_response.thought, + actions=[a.to_dict() for a in processed_actions], + expected_reaction=plan_response.expected_reaction, + max_wait_seconds=plan_response.max_wait_seconds, ) # 更新状态 - if response.max_wait_seconds > 0: + if plan_response.max_wait_seconds > 0: # 继续等待 session.start_waiting( - expected_reaction=response.expected_reaction, - max_wait_seconds=response.max_wait_seconds, + expected_reaction=plan_response.expected_reaction, + max_wait_seconds=plan_response.max_wait_seconds, ) else: # 不再等待 @@ -333,8 +352,8 @@ class ProactiveThinker: logger.info( f"[ProactiveThinker] 超时决策完成: user={session.user_id}, " - f"actions={[a.type for a in response.actions]}, " - f"continue_wait={response.max_wait_seconds > 0}" + f"actions={[a.type for a in processed_actions]}, " + f"continue_wait={plan_response.max_wait_seconds > 0}" ) except Exception as e: @@ -449,23 +468,25 @@ class ProactiveThinker: else: silence_duration = f"{silence_seconds / 3600:.1f} 小时" - # 调用 Replyer - response = await generate_response( + extra_context = { + "trigger_reason": trigger_reason, + "silence_duration": silence_duration, + } + + # 调用 Planner + plan_response = await generate_plan( session=session, user_name=session.user_id, situation_type="proactive", chat_stream=chat_stream, available_actions=action_manager.get_using_actions(), - extra_context={ - "trigger_reason": trigger_reason, - "silence_duration": silence_duration, - }, + extra_context=extra_context, ) # 检查是否决定不打扰 is_do_nothing = ( - len(response.actions) == 0 or - (len(response.actions) == 1 and response.actions[0].type == "do_nothing") + len(plan_response.actions) == 0 or + (len(plan_response.actions) == 1 and plan_response.actions[0].type == "do_nothing") ) if is_do_nothing: @@ -474,32 +495,51 @@ class ProactiveThinker: await self.session_manager.save_session(session.user_id) return + # 对于需要回复的动作,调用 Replyer 生成实际文本 + processed_actions = [] + for action in plan_response.actions: + if action.type == "kfc_reply": + success, reply_text = await generate_reply_text( + session=session, + user_name=session.user_id, + thought=plan_response.thought, + situation_type="proactive", + chat_stream=chat_stream, + extra_context=extra_context, + ) + if success and reply_text: + action.params["content"] = reply_text + else: + logger.warning("[ProactiveThinker] 回复生成失败,跳过该动作") + continue + processed_actions.append(action) + # 执行动作 - for action in response.actions: + for action in processed_actions: await action_manager.execute_action( action_name=action.type, chat_id=session.stream_id, target_message=None, - reasoning=response.thought, + reasoning=plan_response.thought, action_data=action.params, thinking_id=None, - log_prefix="[KFC V2 ProactiveThinker]", + log_prefix="[KFC ProactiveThinker]", ) # 记录到 mental_log session.add_bot_planning( - thought=response.thought, - actions=[a.to_dict() for a in response.actions], - expected_reaction=response.expected_reaction, - max_wait_seconds=response.max_wait_seconds, + thought=plan_response.thought, + actions=[a.to_dict() for a in processed_actions], + expected_reaction=plan_response.expected_reaction, + max_wait_seconds=plan_response.max_wait_seconds, ) # 更新状态 session.last_proactive_at = time.time() - if response.max_wait_seconds > 0: + if plan_response.max_wait_seconds > 0: session.start_waiting( - expected_reaction=response.expected_reaction, - max_wait_seconds=response.max_wait_seconds, + expected_reaction=plan_response.expected_reaction, + max_wait_seconds=plan_response.max_wait_seconds, ) # 保存 @@ -507,7 +547,7 @@ class ProactiveThinker: logger.info( f"[ProactiveThinker] 主动发起完成: user={session.user_id}, " - f"actions={[a.type for a in response.actions]}" + f"actions={[a.type for a in processed_actions]}" ) except Exception as e: diff --git a/src/plugins/built_in/kokoro_flow_chatter/prompt/builder.py b/src/plugins/built_in/kokoro_flow_chatter/prompt/builder.py index 552aefc01..ad90aca76 100644 --- a/src/plugins/built_in/kokoro_flow_chatter/prompt/builder.py +++ b/src/plugins/built_in/kokoro_flow_chatter/prompt/builder.py @@ -38,7 +38,7 @@ class PromptBuilder: def __init__(self): self._context_builder = None - async def build_prompt( + async def build_planner_prompt( self, session: KokoroSession, user_name: str, @@ -48,7 +48,7 @@ class PromptBuilder: extra_context: Optional[dict] = None, ) -> str: """ - 构建完整的提示词 + 构建规划器提示词(用于生成行动计划) Args: session: 会话对象 @@ -59,7 +59,7 @@ class PromptBuilder: extra_context: 额外上下文(如 trigger_reason) Returns: - 完整的提示词 + 完整的规划器提示词 """ extra_context = extra_context or {} @@ -89,8 +89,8 @@ class PromptBuilder: # 6. 构建可用动作 actions_block = self._build_actions_block(available_actions) - # 7. 获取输出格式 - output_format = await self._get_output_format() + # 7. 获取规划器输出格式 + output_format = await self._get_planner_output_format() # 8. 使用统一的 prompt 管理系统格式化 prompt = await global_prompt_manager.format_prompt( @@ -109,6 +109,76 @@ class PromptBuilder: return prompt + async def build_replyer_prompt( + self, + session: KokoroSession, + user_name: str, + thought: str, + situation_type: str = "new_message", + chat_stream: Optional["ChatStream"] = None, + extra_context: Optional[dict] = None, + ) -> str: + """ + 构建回复器提示词(用于生成自然的回复文本) + + Args: + session: 会话对象 + user_name: 用户名称 + thought: 规划器生成的想法 + situation_type: 情况类型 + chat_stream: 聊天流对象 + extra_context: 额外上下文 + + Returns: + 完整的回复器提示词 + """ + extra_context = extra_context or {} + + # 获取 user_id + user_id = session.user_id if session else None + + # 1. 构建人设块 + persona_block = self._build_persona_block() + + # 2. 使用 context_builder 获取关系、记忆、表达习惯等 + context_data = await self._build_context_data(user_name, chat_stream, user_id) + relation_block = context_data.get("relation_info", f"你与 {user_name} 还不太熟悉,这是早期的交流阶段。") + memory_block = context_data.get("memory_block", "") + expression_habits = self._build_combined_expression_block(context_data.get("expression_habits", "")) + + # 3. 构建活动流 + activity_stream = await self._build_activity_stream(session, user_name) + + # 4. 构建当前情况(简化版,不需要那么详细) + current_situation = await self._build_current_situation( + session, user_name, situation_type, extra_context + ) + + # 5. 构建聊天历史总览 + chat_history_block = await self._build_chat_history_block(chat_stream) + + # 6. 构建回复情景上下文 + reply_context = await self._build_reply_context( + session, user_name, situation_type, extra_context + ) + + # 7. 使用回复器专用模板 + prompt = await global_prompt_manager.format_prompt( + PROMPT_NAMES["replyer"], + user_name=user_name, + persona_block=persona_block, + relation_block=relation_block, + memory_block=memory_block or "(暂无相关记忆)", + activity_stream=activity_stream or "(这是你们第一次聊天)", + current_situation=current_situation, + chat_history_block=chat_history_block, + expression_habits=expression_habits or "(根据自然对话风格回复即可)", + thought=thought, + reply_context=reply_context, + ) + + return prompt + def _build_persona_block(self) -> str: """构建人设块""" if global_config is None: @@ -578,6 +648,91 @@ class PromptBuilder: "expected_reaction": "期待的反应", "max_wait_seconds": 300 }""" + + async def _get_planner_output_format(self) -> str: + """获取规划器输出格式模板""" + try: + prompt = await global_prompt_manager.get_prompt_async( + PROMPT_NAMES["planner_output_format"] + ) + return prompt.template + except KeyError: + # 如果模板未注册,返回默认格式 + return """请用 JSON 格式回复: +{ + "thought": "你的想法", + "actions": [{"type": "kfc_reply"}], + "expected_reaction": "期待的反应", + "max_wait_seconds": 300 +} + +注意:kfc_reply 动作不需要填写 content 字段,回复内容会单独生成。""" + + async def _build_reply_context( + self, + session: KokoroSession, + user_name: str, + situation_type: str, + extra_context: dict, + ) -> str: + """ + 构建回复情景上下文 + + 根据 situation_type 构建不同的情景描述,帮助回复器理解当前要回复的情境 + """ + # 获取最后一条用户消息 + target_message = "" + entries = session.get_recent_entries(limit=10) + for entry in reversed(entries): + if entry.event_type == EventType.USER_MESSAGE: + target_message = entry.content or "" + break + + if situation_type == "new_message": + return await global_prompt_manager.format_prompt( + PROMPT_NAMES["replyer_context_normal"], + user_name=user_name, + target_message=target_message or "(无消息内容)", + ) + + elif situation_type == "reply_in_time": + elapsed = session.waiting_config.get_elapsed_seconds() + max_wait = session.waiting_config.max_wait_seconds + return await global_prompt_manager.format_prompt( + PROMPT_NAMES["replyer_context_in_time"], + user_name=user_name, + target_message=target_message or "(无消息内容)", + elapsed_minutes=elapsed / 60, + max_wait_minutes=max_wait / 60, + ) + + elif situation_type == "reply_late": + elapsed = session.waiting_config.get_elapsed_seconds() + max_wait = session.waiting_config.max_wait_seconds + return await global_prompt_manager.format_prompt( + PROMPT_NAMES["replyer_context_late"], + user_name=user_name, + target_message=target_message or "(无消息内容)", + elapsed_minutes=elapsed / 60, + max_wait_minutes=max_wait / 60, + ) + + elif situation_type == "proactive": + silence = extra_context.get("silence_duration", "一段时间") + trigger_reason = extra_context.get("trigger_reason", "") + return await global_prompt_manager.format_prompt( + PROMPT_NAMES["replyer_context_proactive"], + user_name=user_name, + silence_duration=silence, + trigger_reason=trigger_reason, + ) + + # 默认使用普通情景 + return await global_prompt_manager.format_prompt( + PROMPT_NAMES["replyer_context_normal"], + user_name=user_name, + target_message=target_message or "(无消息内容)", + ) # 全局单例 diff --git a/src/plugins/built_in/kokoro_flow_chatter/prompt/prompts.py b/src/plugins/built_in/kokoro_flow_chatter/prompt/prompts.py index 74f81dd1f..1445360b8 100644 --- a/src/plugins/built_in/kokoro_flow_chatter/prompt/prompts.py +++ b/src/plugins/built_in/kokoro_flow_chatter/prompt/prompts.py @@ -212,10 +212,141 @@ kfc_ENTRY_PROACTIVE_TRIGGER = Prompt( """, ) +# ================================================================================================= +# Planner 专用输出格式 +# ================================================================================================= + +kfc_PLANNER_OUTPUT_FORMAT = Prompt( + name="kfc_planner_output_format", + template="""请用以下 JSON 格式回复: +```json +{{ + "thought": "你脑子里在想什么,越自然越好", + "actions": [ + {{"type": "动作名称", ...动作参数}} + ], + "expected_reaction": "你期待对方的反应是什么", + "max_wait_seconds": 300 +}} +``` + +### 字段说明 +- `thought`:你的内心独白,记录你此刻的想法和感受。要自然,不要技术性语言。 +- `actions`:你要执行的动作列表。每个动作是一个对象,必须包含 `type` 字段指定动作类型,其他字段根据动作类型不同而不同(参考上面每个动作的示例)。 + - 对于 `kfc_reply` 动作,只需要指定 `{{"type": "kfc_reply"}}`,不需要填写 `content` 字段(回复内容会单独生成) +- `expected_reaction`:你期待对方如何回应(用于判断是否需要等待) +- `max_wait_seconds`:设定等待时间(秒),0 表示不等待,超时后你会考虑是否要主动说点什么 + +### 注意事项 +- 动作参数直接写在动作对象里,不需要 `action_data` 包装 +- 即使什么都不想做,也放一个 `{{"type": "do_nothing"}}` +- 可以组合多个动作,比如先发消息再发表情""", +) + +# ================================================================================================= +# Replyer 专用提示词模板 +# ================================================================================================= + +kfc_REPLYER_PROMPT = Prompt( + name="kfc_replyer", + template="""# 你与 {user_name} 的私聊 + +## 1. 你是谁 +{persona_block} + +## 2. 你与 {user_name} 的关系 +{relation_block} + +## 3. 相关记忆 +{memory_block} + +## 4. 你们之间发生的事(活动流) +以下是你和 {user_name} 最近的互动历史,按时间顺序记录了你们的对话和你的心理活动: + +{activity_stream} + +## 5. 当前情况 +{current_situation} + +## 6. 聊天历史总览 +以下是你和 {user_name} 的聊天记录,帮助你更好地理解对话上下文: + +{chat_history_block} + +## 7. 你的表达习惯 +{expression_habits} + +## 8. 你的决策 +你已经决定要回复 {user_name}。 + +**你的想法**:{thought} + +{reply_context} + +## 9. 回复要求 + +**情景化表达**: +- 根据你们的关系亲密度选择合适的语气和称呼 +- 参考活动流中的互动历史,保持对话的连贯性 +- 如果对方回复得晚/早,可以自然地表现出你的感受 + +**自然对话**: +- 像真实的朋友聊天一样,不要生硬或公式化 +- 可以用口语、网络用语、语气词,让回复更生动 +- 长度适中,不要太长也不要太短 + +**表达习惯**: +- 参考上面的"表达习惯"部分,使用你习惯的语言风格 +- 保持人设的一致性 + +**禁忌**: +- 不要重复你之前说过的话 +- 不要输出 JSON 格式或技术性语言 +- 不要加引号、括号等多余符号 +- 不要用"我决定..."、"因此..."这种总结性语言 + +现在,请直接输出你要说的话:""", +) + +kfc_REPLYER_CONTEXT_NORMAL = Prompt( + name="kfc_replyer_context_normal", + template="""你要回复的是 {user_name} 刚发来的消息: +「{target_message}」""", +) + +kfc_REPLYER_CONTEXT_IN_TIME = Prompt( + name="kfc_replyer_context_in_time", + template="""你等了 {elapsed_minutes:.1f} 分钟(原本打算最多等 {max_wait_minutes:.1f} 分钟),{user_name} 终于回复了: +「{target_message}」 + +你可以表现出一点"等到了回复"的欣喜或轻松。""", +) + +kfc_REPLYER_CONTEXT_LATE = Prompt( + name="kfc_replyer_context_late", + template="""你等了 {elapsed_minutes:.1f} 分钟(原本只打算等 {max_wait_minutes:.1f} 分钟),{user_name} 才回复: +「{target_message}」 + +虽然有点晚,但对方终于回复了。你可以选择轻轻抱怨一下,也可以装作没在意。""", +) + +kfc_REPLYER_CONTEXT_PROACTIVE = Prompt( + name="kfc_replyer_context_proactive", + template="""你们已经有一段时间({silence_duration})没聊天了。{trigger_reason} + +你决定主动打破沉默,找 {user_name} 聊点什么。想一个自然的开场白,不要太突兀。""", +) + # 导出所有模板名称,方便外部引用 PROMPT_NAMES = { "main": "kfc_main", "output_format": "kfc_output_format", + "planner_output_format": "kfc_planner_output_format", + "replyer": "kfc_replyer", + "replyer_context_normal": "kfc_replyer_context_normal", + "replyer_context_in_time": "kfc_replyer_context_in_time", + "replyer_context_late": "kfc_replyer_context_late", + "replyer_context_proactive": "kfc_replyer_context_proactive", "situation_new_message": "kfc_situation_new_message", "situation_reply_in_time": "kfc_situation_reply_in_time", "situation_reply_late": "kfc_situation_reply_late", diff --git a/src/plugins/built_in/kokoro_flow_chatter/replyer.py b/src/plugins/built_in/kokoro_flow_chatter/replyer.py index 4829d9721..bbab29505 100644 --- a/src/plugins/built_in/kokoro_flow_chatter/replyer.py +++ b/src/plugins/built_in/kokoro_flow_chatter/replyer.py @@ -1,16 +1,17 @@ """ Kokoro Flow Chatter - Replyer -简化的回复生成模块,使用插件系统的 llm_api +纯粹的回复生成器: +- 接收 planner 的决策(thought 等) +- 专门负责将回复意图转化为自然的对话文本 +- 不输出 JSON,直接生成可发送的消息文本 """ from typing import TYPE_CHECKING, Optional from src.common.logger import get_logger from src.plugin_system.apis import llm_api -from src.utils.json_parser import extract_and_parse_json -from .models import LLMResponse from .prompt.builder import get_prompt_builder from .session import KokoroSession @@ -20,90 +21,103 @@ if TYPE_CHECKING: logger = get_logger("kfc_replyer") -async def generate_response( +async def generate_reply_text( session: KokoroSession, user_name: str, + thought: str, situation_type: str = "new_message", chat_stream: Optional["ChatStream"] = None, - available_actions: Optional[dict] = None, extra_context: Optional[dict] = None, -) -> LLMResponse: +) -> tuple[bool, str]: """ - 生成回复 + 生成回复文本 Args: session: 会话对象 user_name: 用户名称 + thought: 规划器生成的想法(内心独白) situation_type: 情况类型 chat_stream: 聊天流对象 - available_actions: 可用动作字典 extra_context: 额外上下文 Returns: - LLMResponse 对象 + (success, reply_text) 元组 + - success: 是否成功生成 + - reply_text: 生成的回复文本 """ try: - # 1. 构建提示词 + # 1. 构建回复器提示词 prompt_builder = get_prompt_builder() - prompt = await prompt_builder.build_prompt( + prompt = await prompt_builder.build_replyer_prompt( session=session, user_name=user_name, + thought=thought, situation_type=situation_type, chat_stream=chat_stream, - available_actions=available_actions, extra_context=extra_context, ) from src.config.config import global_config if global_config and global_config.debug.show_prompt: - logger.info(f"[KFC Replyer] 生成的提示词:\n{prompt}") + logger.info(f"[KFC Replyer] 生成的回复提示词:\n{prompt}") - # 2. 获取模型配置并调用 LLM + # 2. 获取 replyer 模型配置并调用 LLM models = llm_api.get_available_models() replyer_config = models.get("replyer") if not replyer_config: logger.error("[KFC Replyer] 未找到 replyer 模型配置") - return LLMResponse.create_error_response("未找到 replyer 模型配置") + return False, "(回复生成失败:未找到模型配置)" success, raw_response, reasoning, model_name = await llm_api.generate_with_model( prompt=prompt, model_config=replyer_config, - request_type="kokoro_flow_chatter", + request_type="kokoro_flow_chatter.reply", ) if not success: logger.error(f"[KFC Replyer] LLM 调用失败: {raw_response}") - return LLMResponse.create_error_response(raw_response) + return False, "(回复生成失败)" - logger.debug(f"[KFC Replyer] LLM 响应 (model={model_name}):\n{raw_response}") + # 3. 清理并返回回复文本 + reply_text = _clean_reply_text(raw_response) - # 3. 解析响应 - return _parse_response(raw_response) + logger.info(f"[KFC Replyer] 生成成功 (model={model_name}): {reply_text[:50]}...") + + return True, reply_text except Exception as e: logger.error(f"[KFC Replyer] 生成失败: {e}") import traceback traceback.print_exc() - return LLMResponse.create_error_response(str(e)) + return False, "(回复生成失败)" -def _parse_response(raw_response: str) -> LLMResponse: - """解析 LLM 响应""" - data = extract_and_parse_json(raw_response, strict=False) +def _clean_reply_text(raw_text: str) -> str: + """ + 清理回复文本 - if not data or not isinstance(data, dict): - logger.warning(f"[KFC Replyer] 无法解析 JSON: {raw_response[:200]}...") - return LLMResponse.create_error_response("无法解析响应格式") + 移除可能的前后缀、引号、markdown 标记等 + """ + text = raw_text.strip() - response = LLMResponse.from_dict(data) + # 移除可能的 markdown 代码块标记 + if text.startswith("```") and text.endswith("```"): + lines = text.split("\n") + if len(lines) >= 3: + # 移除首尾的 ``` 行 + text = "\n".join(lines[1:-1]).strip() - if response.thought: - logger.info( - f"[KFC Replyer] 解析成功: thought={response.thought[:50]}..., " - f"actions={[a.type for a in response.actions]}" - ) - else: - logger.warning("[KFC Replyer] 响应缺少 thought") + # 移除首尾的引号(如果整个文本被引号包裹) + if (text.startswith('"') and text.endswith('"')) or \ + (text.startswith("'") and text.endswith("'")): + text = text[1:-1].strip() - return response + # 移除可能的"你说:"、"回复:"等前缀 + prefixes_to_remove = ["你说:", "你说:", "回复:", "回复:", "我说:", "我说:"] + for prefix in prefixes_to_remove: + if text.startswith(prefix): + text = text[len(prefix):].strip() + break + + return text From c45f0e9cea79053dfb9c6bb8ba202fd070e6bc38 Mon Sep 17 00:00:00 2001 From: Windpicker-owo <3431391539@qq.com> Date: Sun, 30 Nov 2025 19:54:36 +0800 Subject: [PATCH 17/28] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E8=8E=B7?= =?UTF-8?q?=E5=8F=96=E7=94=A8=E6=88=B7=E5=90=8D=E7=A7=B0=E7=9A=84=E6=96=B9?= =?UTF-8?q?=E6=B3=95=EF=BC=8C=E4=BC=98=E5=8C=96=E8=B6=85=E6=97=B6=E5=86=B3?= =?UTF-8?q?=E7=AD=96=E5=92=8C=E4=B8=BB=E5=8A=A8=E6=80=9D=E8=80=83=E4=B8=AD?= =?UTF-8?q?=E7=9A=84=E7=94=A8=E6=88=B7=E5=90=8D=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kokoro_flow_chatter/proactive_thinker.py | 36 ++++++++++++++++--- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/src/plugins/built_in/kokoro_flow_chatter/proactive_thinker.py b/src/plugins/built_in/kokoro_flow_chatter/proactive_thinker.py index d1abab669..9d7e7438a 100644 --- a/src/plugins/built_in/kokoro_flow_chatter/proactive_thinker.py +++ b/src/plugins/built_in/kokoro_flow_chatter/proactive_thinker.py @@ -277,6 +277,9 @@ class ProactiveThinker: logger.info(f"[ProactiveThinker] 等待超时: user={session.user_id}") try: + # 获取用户名 + user_name = await self._get_user_name(session.user_id, session.stream_id) + # 获取聊天流 chat_stream = await self._get_chat_stream(session.stream_id) @@ -292,7 +295,7 @@ class ProactiveThinker: # 调用 Planner 生成超时决策 plan_response = await generate_plan( session=session, - user_name=session.user_id, # 这里可以改进,获取真实用户名 + user_name=user_name, situation_type="timeout", chat_stream=chat_stream, available_actions=action_manager.get_using_actions(), @@ -304,7 +307,7 @@ class ProactiveThinker: if action.type == "kfc_reply": success, reply_text = await generate_reply_text( session=session, - user_name=session.user_id, + user_name=user_name, thought=plan_response.thought, situation_type="timeout", chat_stream=chat_stream, @@ -449,6 +452,9 @@ class ProactiveThinker: logger.info(f"[ProactiveThinker] 主动思考触发: user={session.user_id}, reason={trigger_reason}") try: + # 获取用户名 + user_name = await self._get_user_name(session.user_id, session.stream_id) + # 获取聊天流 chat_stream = await self._get_chat_stream(session.stream_id) @@ -476,7 +482,7 @@ class ProactiveThinker: # 调用 Planner plan_response = await generate_plan( session=session, - user_name=session.user_id, + user_name=user_name, situation_type="proactive", chat_stream=chat_stream, available_actions=action_manager.get_using_actions(), @@ -501,7 +507,7 @@ class ProactiveThinker: if action.type == "kfc_reply": success, reply_text = await generate_reply_text( session=session, - user_name=session.user_id, + user_name=user_name, thought=plan_response.thought, situation_type="proactive", chat_stream=chat_stream, @@ -565,6 +571,28 @@ class ProactiveThinker: logger.warning(f"[ProactiveThinker] 获取 chat_stream 失败: {e}") return None + async def _get_user_name(self, user_id: str, stream_id: str) -> str: + """获取用户名称(优先从 person_info 获取)""" + try: + from src.person_info.person_info import get_person_info_manager + + person_info_manager = get_person_info_manager() + # 从 stream_id 提取 platform(格式通常是 platform_xxx) + platform = "qq" # 默认平台 + if "_" in stream_id: + platform = stream_id.split("_")[0] + + person_id = person_info_manager.get_person_id(platform, user_id) + person_name = await person_info_manager.get_value(person_id, "person_name") + + if person_name: + return person_name + except Exception as e: + logger.debug(f"[ProactiveThinker] 获取用户名失败: {e}") + + # 回退到 user_id + return user_id + def get_stats(self) -> dict: """获取统计信息""" return { From b148463f666bd4aac4d1040a02d92714fd1431fb Mon Sep 17 00:00:00 2001 From: Windpicker-owo <3431391539@qq.com> Date: Sun, 30 Nov 2025 20:20:23 +0800 Subject: [PATCH 18/28] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E4=B8=BB?= =?UTF-8?q?=E5=8A=A8=E6=80=9D=E8=80=83=E5=99=A8=E5=92=8C=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E8=AF=8D=E6=9E=84=E5=BB=BA=E9=80=BB=E8=BE=91=EF=BC=8C=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E7=94=A8=E6=88=B7=E5=85=B3=E7=B3=BB=E5=92=8C=E7=AD=89?= =?UTF-8?q?=E5=BE=85=E6=80=9D=E8=80=83=E6=8F=90=E7=A4=BA=E8=AF=8D=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kokoro_flow_chatter/proactive_thinker.py | 127 ++++++++++++++++-- .../kokoro_flow_chatter/prompt/builder.py | 99 +++++++++++++- .../kokoro_flow_chatter/prompt/prompts.py | 40 ++++++ .../built_in/social_toolkit_plugin/plugin.py | 2 +- 4 files changed, 251 insertions(+), 17 deletions(-) diff --git a/src/plugins/built_in/kokoro_flow_chatter/proactive_thinker.py b/src/plugins/built_in/kokoro_flow_chatter/proactive_thinker.py index 9d7e7438a..fbd95e13b 100644 --- a/src/plugins/built_in/kokoro_flow_chatter/proactive_thinker.py +++ b/src/plugins/built_in/kokoro_flow_chatter/proactive_thinker.py @@ -22,7 +22,7 @@ from src.plugin_system.apis.unified_scheduler import TriggerType, unified_schedu from .models import EventType, SessionStatus from .planner import generate_plan -from .replyer import generate_reply_text +from .replyer import _clean_reply_text, generate_reply_text from .session import KokoroSession, get_session_manager if TYPE_CHECKING: @@ -211,13 +211,16 @@ class ProactiveThinker: """处理连续思考""" self._stats["continuous_thinking_triggered"] += 1 - # 生成等待中的想法 - thought = self._generate_waiting_thought(session, progress) + # 获取用户名 + user_name = await self._get_user_name(session.user_id, session.stream_id) + + # 调用 LLM 生成等待中的想法 + thought = await self._generate_waiting_thought(session, user_name, progress) # 记录到 mental_log session.add_waiting_update( waiting_thought=thought, - mood="", # 可以根据进度设置心情 + mood="", # 心情已融入 thought 中 ) # 更新思考计数 @@ -232,10 +235,104 @@ class ProactiveThinker: f"progress={progress:.1%}, thought={thought[:30]}..." ) - def _generate_waiting_thought(self, session: KokoroSession, progress: float) -> str: - """生成等待中的想法""" - elapsed_minutes = session.waiting_config.get_elapsed_minutes() - + async def _generate_waiting_thought( + self, + session: KokoroSession, + user_name: str, + progress: float, + ) -> str: + """调用 LLM 生成等待中的想法""" + try: + from src.chat.utils.prompt import global_prompt_manager + from src.plugin_system.apis import llm_api + + from .prompt.builder import get_prompt_builder + from .prompt.prompts import PROMPT_NAMES + + # 使用 PromptBuilder 构建人设块 + prompt_builder = get_prompt_builder() + persona_block = prompt_builder._build_persona_block() + + # 获取关系信息 + relation_block = f"你与 {user_name} 还不太熟悉。" + try: + from src.person_info.relationship_manager import relationship_manager + + person_info_manager = await self._get_person_info_manager() + if person_info_manager: + platform = global_config.bot.platform if global_config else "qq" + person_id = person_info_manager.get_person_id(platform, session.user_id) + relationship = await relationship_manager.get_relationship(person_id) + if relationship: + relation_block = f"你与 {user_name} 的亲密度是 {relationship.intimacy}。{relationship.description or ''}" + except Exception as e: + logger.debug(f"获取关系信息失败: {e}") + + # 获取上次发送的消息 + last_bot_message = "(未知)" + for entry in reversed(session.mental_log): + if entry.event_type == EventType.BOT_PLANNING and entry.actions: + for action in entry.actions: + if action.get("type") == "kfc_reply": + content = action.get("content", "") + if content: + last_bot_message = content[:100] + ("..." if len(content) > 100 else "") + break + if last_bot_message != "(未知)": + break + + # 构建提示词 + elapsed_minutes = session.waiting_config.get_elapsed_minutes() + max_wait_minutes = session.waiting_config.max_wait_seconds / 60 + expected_reaction = session.waiting_config.expected_reaction or "对方能回复点什么" + + prompt = await global_prompt_manager.format_prompt( + PROMPT_NAMES["waiting_thought"], + persona_block=persona_block, + user_name=user_name, + relation_block=relation_block, + last_bot_message=last_bot_message, + expected_reaction=expected_reaction, + elapsed_minutes=elapsed_minutes, + max_wait_minutes=max_wait_minutes, + progress_percent=int(progress * 100), + ) + + # 调用情绪模型 + models = llm_api.get_available_models() + emotion_config = models.get("emotion") or models.get("replyer") + + if not emotion_config: + logger.warning("[ProactiveThinker] 未找到 emotion/replyer 模型配置,使用默认想法") + return self._get_fallback_thought(elapsed_minutes, progress) + + success, raw_response, _, model_name = await llm_api.generate_with_model( + prompt=prompt, + model_config=emotion_config, + request_type="kokoro_flow_chatter.waiting_thought", + ) + + if not success or not raw_response: + logger.warning(f"[ProactiveThinker] LLM 调用失败: {raw_response}") + return self._get_fallback_thought(elapsed_minutes, progress) + + # 使用统一的文本清理函数 + thought = _clean_reply_text(raw_response) + + logger.debug(f"[ProactiveThinker] LLM 生成等待想法 (model={model_name}): {thought[:50]}...") + return thought + + except Exception as e: + logger.error(f"[ProactiveThinker] 生成等待想法失败: {e}") + import traceback + traceback.print_exc() + return self._get_fallback_thought( + session.waiting_config.get_elapsed_minutes(), + progress + ) + + def _get_fallback_thought(self, elapsed_minutes: float, progress: float) -> str: + """获取备用的等待想法(当 LLM 调用失败时使用)""" if progress < 0.4: thoughts = [ f"已经等了 {elapsed_minutes:.0f} 分钟了,对方可能在忙吧...", @@ -254,9 +351,16 @@ class ProactiveThinker: "要不要主动说点什么呢...", "快到时间了,对方还是没回", ] - return random.choice(thoughts) + async def _get_person_info_manager(self): + """获取 person_info_manager""" + try: + from src.person_info.person_info import get_person_info_manager + return get_person_info_manager() + except Exception: + return None + async def _handle_timeout(self, session: KokoroSession) -> None: """处理等待超时""" self._stats["timeout_decisions"] += 1 @@ -577,10 +681,7 @@ class ProactiveThinker: from src.person_info.person_info import get_person_info_manager person_info_manager = get_person_info_manager() - # 从 stream_id 提取 platform(格式通常是 platform_xxx) - platform = "qq" # 默认平台 - if "_" in stream_id: - platform = stream_id.split("_")[0] + platform = global_config.bot.platform if global_config else "qq" person_id = person_info_manager.get_person_id(platform, user_id) person_name = await person_info_manager.get_value(person_id, "person_name") diff --git a/src/plugins/built_in/kokoro_flow_chatter/prompt/builder.py b/src/plugins/built_in/kokoro_flow_chatter/prompt/builder.py index ad90aca76..94ed55263 100644 --- a/src/plugins/built_in/kokoro_flow_chatter/prompt/builder.py +++ b/src/plugins/built_in/kokoro_flow_chatter/prompt/builder.py @@ -149,8 +149,8 @@ class PromptBuilder: # 3. 构建活动流 activity_stream = await self._build_activity_stream(session, user_name) - # 4. 构建当前情况(简化版,不需要那么详细) - current_situation = await self._build_current_situation( + # 4. 构建当前情况(回复器专用,简化版,不包含决策语言) + current_situation = await self._build_replyer_situation( session, user_name, situation_type, extra_context ) @@ -315,9 +315,15 @@ class PromptBuilder: if not history_messages: return "(暂无聊天记录)" + # 过滤非文本消息(如戳一戳、禁言等系统通知) + text_messages = self._filter_text_messages(history_messages) + + if not text_messages: + return "(暂无聊天记录)" + # 构建可读消息 chat_content, _ = await build_readable_messages_with_id( - messages=[msg.flatten() for msg in history_messages[-30:]], # 最多30条 + messages=[msg.flatten() for msg in text_messages[-30:]], # 最多30条 timestamp_mode="normal_no_YMD", truncate=False, show_actions=False, @@ -329,6 +335,33 @@ class PromptBuilder: logger.warning(f"构建聊天历史块失败: {e}") return "(获取聊天记录失败)" + def _filter_text_messages(self, messages: list) -> list: + """ + 过滤非文本消息 + + 移除系统通知消息(如戳一戳、禁言等),只保留正常的文本聊天消息 + + Args: + messages: 消息列表(DatabaseMessages 对象) + + Returns: + 过滤后的消息列表 + """ + filtered = [] + for msg in messages: + # 跳过系统通知消息(戳一戳、禁言等) + if getattr(msg, "is_notify", False): + continue + + # 跳过没有实际文本内容的消息 + content = getattr(msg, "processed_plain_text", "") or getattr(msg, "display_message", "") + if not content or not content.strip(): + continue + + filtered.append(msg) + + return filtered + async def _build_activity_stream( self, session: KokoroSession, @@ -668,6 +701,66 @@ class PromptBuilder: 注意:kfc_reply 动作不需要填写 content 字段,回复内容会单独生成。""" + async def _build_replyer_situation( + self, + session: KokoroSession, + user_name: str, + situation_type: str, + extra_context: dict, + ) -> str: + """ + 构建回复器专用的当前情况描述 + + 与 Planner 的 _build_current_situation 不同,这里不包含决策性语言, + 只描述当前的情景背景 + """ + from datetime import datetime + current_time = datetime.now().strftime("%Y年%m月%d日 %H:%M") + + if situation_type == "new_message": + return f"现在是 {current_time}。{user_name} 刚给你发了消息。" + + elif situation_type == "reply_in_time": + elapsed = session.waiting_config.get_elapsed_seconds() + max_wait = session.waiting_config.max_wait_seconds + return ( + f"现在是 {current_time}。\n" + f"你之前发了消息后在等 {user_name} 的回复。" + f"等了大约 {elapsed / 60:.1f} 分钟(你原本打算最多等 {max_wait / 60:.1f} 分钟)。" + f"现在 {user_name} 回复了!" + ) + + elif situation_type == "reply_late": + elapsed = session.waiting_config.get_elapsed_seconds() + max_wait = session.waiting_config.max_wait_seconds + return ( + f"现在是 {current_time}。\n" + f"你之前发了消息后在等 {user_name} 的回复。" + f"你原本打算最多等 {max_wait / 60:.1f} 分钟,但实际等了 {elapsed / 60:.1f} 分钟才收到回复。" + f"虽然有点迟,但 {user_name} 终于回复了。" + ) + + elif situation_type == "timeout": + elapsed = session.waiting_config.get_elapsed_seconds() + max_wait = session.waiting_config.max_wait_seconds + return ( + f"现在是 {current_time}。\n" + f"你之前发了消息后一直在等 {user_name} 的回复。" + f"你原本打算最多等 {max_wait / 60:.1f} 分钟,现在已经等了 {elapsed / 60:.1f} 分钟了,对方还是没回。" + f"你决定主动说点什么。" + ) + + elif situation_type == "proactive": + silence = extra_context.get("silence_duration", "一段时间") + return ( + f"现在是 {current_time}。\n" + f"你和 {user_name} 已经有一段时间没聊天了(沉默了 {silence})。" + f"你决定主动找 {user_name} 聊点什么。" + ) + + # 默认 + return f"现在是 {current_time}。" + async def _build_reply_context( self, session: KokoroSession, diff --git a/src/plugins/built_in/kokoro_flow_chatter/prompt/prompts.py b/src/plugins/built_in/kokoro_flow_chatter/prompt/prompts.py index 1445360b8..d089888b6 100644 --- a/src/plugins/built_in/kokoro_flow_chatter/prompt/prompts.py +++ b/src/plugins/built_in/kokoro_flow_chatter/prompt/prompts.py @@ -337,6 +337,45 @@ kfc_REPLYER_CONTEXT_PROACTIVE = Prompt( 你决定主动打破沉默,找 {user_name} 聊点什么。想一个自然的开场白,不要太突兀。""", ) +# ================================================================================================= +# 等待思考提示词模板(用于生成等待中的心理活动) +# ================================================================================================= + +kfc_WAITING_THOUGHT = Prompt( + name="kfc_waiting_thought", + template="""# 等待中的心理活动 + +## 你是谁 +{persona_block} + +## 你与 {user_name} 的关系 +{relation_block} + +## 当前情况 +你刚才给 {user_name} 发了消息,现在正在等待对方回复。 + +**你发的消息**:{last_bot_message} +**你期待的反应**:{expected_reaction} +**已等待时间**:{elapsed_minutes:.1f} 分钟 +**计划最多等待**:{max_wait_minutes:.1f} 分钟 +**等待进度**:{progress_percent}% + +## 任务 +请描述你此刻等待时的内心想法。这是你私下的心理活动,不是要发送的消息。 + +**要求**: +- 用第一人称描述你的感受和想法 +- 要符合你的性格和你们的关系 +- 根据等待进度自然表达情绪变化: + - 初期(0-40%):可能比较平静,稍微期待 + - 中期(40-70%):可能开始有点在意,但还好 + - 后期(70-100%):可能有点焦虑、担心,或者想主动做点什么 +- 不要太长,1-2句话即可 +- 不要输出 JSON,直接输出你的想法 + +现在,请直接输出你等待时的内心想法:""", +) + # 导出所有模板名称,方便外部引用 PROMPT_NAMES = { "main": "kfc_main", @@ -347,6 +386,7 @@ PROMPT_NAMES = { "replyer_context_in_time": "kfc_replyer_context_in_time", "replyer_context_late": "kfc_replyer_context_late", "replyer_context_proactive": "kfc_replyer_context_proactive", + "waiting_thought": "kfc_waiting_thought", "situation_new_message": "kfc_situation_new_message", "situation_reply_in_time": "kfc_situation_reply_in_time", "situation_reply_late": "kfc_situation_reply_late", diff --git a/src/plugins/built_in/social_toolkit_plugin/plugin.py b/src/plugins/built_in/social_toolkit_plugin/plugin.py index 90f75b934..ab69ce7c3 100644 --- a/src/plugins/built_in/social_toolkit_plugin/plugin.py +++ b/src/plugins/built_in/social_toolkit_plugin/plugin.py @@ -195,7 +195,7 @@ class PokeAction(BaseAction): for i in range(times): logger.info(f"正在向 {display_name} ({user_id}) 发送第 {i + 1}/{times} 次戳一戳...") await self.send_command( - "SEND_POKE", args=poke_args, display_message=f"戳了戳 {display_name} ({i + 1}/{times})" + "SEND_POKE", args=poke_args, display_message=f"(系统动作)戳了戳 {display_name} ({i + 1}/{times})" ) # 添加一个延迟,避免因发送过快导致后续戳一戳失败 await asyncio.sleep(1.5) From 6330faa7935523c4fe16b0ee8e24f2272e81987b Mon Sep 17 00:00:00 2001 From: Windpicker-owo <3431391539@qq.com> Date: Sun, 30 Nov 2025 20:33:49 +0800 Subject: [PATCH 19/28] =?UTF-8?q?feat:=20=E7=A7=BB=E9=99=A4=E6=88=B3?= =?UTF-8?q?=E4=B8=80=E6=88=B3=E5=8A=A8=E4=BD=9C=E7=9A=84=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E6=B6=88=E6=81=AF=EF=BC=8C=E7=AE=80=E5=8C=96=E5=8F=91=E9=80=81?= =?UTF-8?q?=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/built_in/social_toolkit_plugin/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/built_in/social_toolkit_plugin/plugin.py b/src/plugins/built_in/social_toolkit_plugin/plugin.py index ab69ce7c3..31fe45caa 100644 --- a/src/plugins/built_in/social_toolkit_plugin/plugin.py +++ b/src/plugins/built_in/social_toolkit_plugin/plugin.py @@ -195,7 +195,7 @@ class PokeAction(BaseAction): for i in range(times): logger.info(f"正在向 {display_name} ({user_id}) 发送第 {i + 1}/{times} 次戳一戳...") await self.send_command( - "SEND_POKE", args=poke_args, display_message=f"(系统动作)戳了戳 {display_name} ({i + 1}/{times})" + "SEND_POKE", args=poke_args ) # 添加一个延迟,避免因发送过快导致后续戳一戳失败 await asyncio.sleep(1.5) From 1288fd6cf742df355d12f95b966d0c0a2ce19275 Mon Sep 17 00:00:00 2001 From: Windpicker-owo <3431391539@qq.com> Date: Sun, 30 Nov 2025 20:41:30 +0800 Subject: [PATCH 20/28] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E4=B8=BB?= =?UTF-8?q?=E5=8A=A8=E6=80=9D=E8=80=83=E9=85=8D=E7=BD=AE=E9=80=89=E9=A1=B9?= =?UTF-8?q?=EF=BC=8C=E4=BC=98=E5=8C=96=E9=97=AE=E5=80=99=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E5=92=8C=E8=A7=A6=E5=8F=91=E6=A6=82=E7=8E=87=E8=AE=BE=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/config/official_configs.py | 14 ++++ .../built_in/kokoro_flow_chatter/config.py | 12 ++++ .../kokoro_flow_chatter/proactive_thinker.py | 65 ++++++++++--------- template/bot_config_template.toml | 9 ++- 4 files changed, 70 insertions(+), 30 deletions(-) diff --git a/src/config/official_configs.py b/src/config/official_configs.py index d0d866fe7..11b9353c7 100644 --- a/src/config/official_configs.py +++ b/src/config/official_configs.py @@ -923,6 +923,20 @@ class KokoroFlowChatterProactiveConfig(ValidatedConfigBase): enable_night_greeting: bool = Field( default=True, description="是否启用晚安问候 (例如: 22:00 - 23:00)" ) + + # 5. 勿扰时段:在这段时间内不会主动发起对话 + quiet_hours_start: str = Field( + default="23:00", description="勿扰时段开始时间,格式: HH:MM" + ) + quiet_hours_end: str = Field( + default="07:00", description="勿扰时段结束时间,格式: HH:MM" + ) + + # 6. 触发概率:每次检查时主动发起的概率 + trigger_probability: float = Field( + default=0.3, ge=0.0, le=1.0, + description="主动思考触发概率(0.0~1.0),用于避免过于频繁打扰" + ) class KokoroFlowChatterConfig(ValidatedConfigBase): diff --git a/src/plugins/built_in/kokoro_flow_chatter/config.py b/src/plugins/built_in/kokoro_flow_chatter/config.py index f567a0a2a..d5f836ec6 100644 --- a/src/plugins/built_in/kokoro_flow_chatter/config.py +++ b/src/plugins/built_in/kokoro_flow_chatter/config.py @@ -43,6 +43,15 @@ class ProactiveConfig: # 主动发起概率(0.0 ~ 1.0) trigger_probability: float = 0.3 + + # 关系门槛:最低好感度,达到此值才会主动关心 + min_affinity_for_proactive: float = 0.3 + + # 是否启用早安问候 + enable_morning_greeting: bool = True + + # 是否启用晚安问候 + enable_night_greeting: bool = True @dataclass @@ -178,6 +187,9 @@ def load_config() -> KokoroFlowChatterConfig: quiet_hours_start=getattr(pro_cfg, 'quiet_hours_start', "23:00"), quiet_hours_end=getattr(pro_cfg, 'quiet_hours_end', "07:00"), trigger_probability=getattr(pro_cfg, 'trigger_probability', 0.3), + min_affinity_for_proactive=getattr(pro_cfg, 'min_affinity_for_proactive', 0.3), + enable_morning_greeting=getattr(pro_cfg, 'enable_morning_greeting', True), + enable_night_greeting=getattr(pro_cfg, 'enable_night_greeting', True), ) # 提示词配置 diff --git a/src/plugins/built_in/kokoro_flow_chatter/proactive_thinker.py b/src/plugins/built_in/kokoro_flow_chatter/proactive_thinker.py index fbd95e13b..fffc1719b 100644 --- a/src/plugins/built_in/kokoro_flow_chatter/proactive_thinker.py +++ b/src/plugins/built_in/kokoro_flow_chatter/proactive_thinker.py @@ -73,22 +73,27 @@ class ProactiveThinker: } def _load_config(self) -> None: - """加载配置""" - # 默认配置 - self.waiting_check_interval = 15.0 # 等待检查间隔(秒) - self.proactive_check_interval = 300.0 # 主动思考检查间隔(秒) - self.silence_threshold = 7200 # 沉默阈值(秒) - self.min_proactive_interval = 1800 # 两次主动思考最小间隔(秒) - self.quiet_hours_start = "23:00" - self.quiet_hours_end = "07:00" + """加载配置 - 使用统一的配置系统""" + from .config import get_config - # 从全局配置读取 - if global_config and hasattr(global_config, 'kokoro_flow_chatter'): - kfc_config = global_config.kokoro_flow_chatter - if hasattr(kfc_config, 'proactive_thinking'): - proactive_cfg = kfc_config.proactive_thinking - self.silence_threshold = getattr(proactive_cfg, 'silence_threshold_seconds', 7200) - self.min_proactive_interval = getattr(proactive_cfg, 'min_interval_between_proactive', 1800) + config = get_config() + proactive_cfg = config.proactive + + # 等待检查间隔(秒) + self.waiting_check_interval = 15.0 + # 主动思考检查间隔(秒) + self.proactive_check_interval = 300.0 + + # 从配置读取主动思考相关设置 + self.proactive_enabled = proactive_cfg.enabled + self.silence_threshold = proactive_cfg.silence_threshold_seconds + self.min_proactive_interval = proactive_cfg.min_interval_between_proactive + self.quiet_hours_start = proactive_cfg.quiet_hours_start + self.quiet_hours_end = proactive_cfg.quiet_hours_end + self.trigger_probability = proactive_cfg.trigger_probability + self.min_affinity_for_proactive = proactive_cfg.min_affinity_for_proactive + self.enable_morning_greeting = proactive_cfg.enable_morning_greeting + self.enable_night_greeting = proactive_cfg.enable_night_greeting async def start(self) -> None: """启动主动思考器""" @@ -98,7 +103,7 @@ class ProactiveThinker: self._running = True - # 注册等待检查任务 + # 注册等待检查任务(始终启用,用于处理等待中的 Session) self._waiting_schedule_id = await unified_scheduler.create_schedule( callback=self._check_waiting_sessions, trigger_type=TriggerType.TIME, @@ -109,18 +114,20 @@ class ProactiveThinker: timeout=60.0, ) - # 注册主动思考检查任务 - self._proactive_schedule_id = await unified_scheduler.create_schedule( - callback=self._check_proactive_sessions, - trigger_type=TriggerType.TIME, - trigger_config={"delay_seconds": self.proactive_check_interval}, - is_recurring=True, - task_name=self.TASK_PROACTIVE_CHECK, - force_overwrite=True, - timeout=120.0, - ) - - logger.info("[ProactiveThinker] 已启动") + # 注册主动思考检查任务(仅在启用时注册) + if self.proactive_enabled: + self._proactive_schedule_id = await unified_scheduler.create_schedule( + callback=self._check_proactive_sessions, + trigger_type=TriggerType.TIME, + trigger_config={"delay_seconds": self.proactive_check_interval}, + is_recurring=True, + task_name=self.TASK_PROACTIVE_CHECK, + force_overwrite=True, + timeout=120.0, + ) + logger.info("[ProactiveThinker] 已启动(主动思考已启用)") + else: + logger.info("[ProactiveThinker] 已启动(主动思考已禁用)") async def stop(self) -> None: """停止主动思考器""" @@ -531,7 +538,7 @@ class ProactiveThinker: return None # 概率触发(避免每次检查都触发) - if random.random() > 0.3: # 30% 概率 + if random.random() > self.trigger_probability: return None silence_hours = silence_duration / 3600 diff --git a/template/bot_config_template.toml b/template/bot_config_template.toml index 88115087d..f8e1e3cb6 100644 --- a/template/bot_config_template.toml +++ b/template/bot_config_template.toml @@ -1,5 +1,5 @@ [inner] -version = "7.9.3" +version = "7.9.4" #----以下是给开发人员阅读的,如果你只是部署了MoFox-Bot,不需要阅读---- #如果你想要修改配置文件,请递增version的值 @@ -673,3 +673,10 @@ min_interval_between_proactive = 1800 # 两次主动思考之间的最小间隔 # 格式为 "HH:MM",使用24小时制。如果 start > end,表示跨越午夜(如 23:00 到 07:00)。 quiet_hours_start = "23:00" # 勿扰开始时间 quiet_hours_end = "07:00" # 勿扰结束时间 + +# 5. 触发概率:每次检查时主动发起的概率,用于避免过于频繁打扰。 +trigger_probability = 0.3 # 0.0~1.0,默认30%概率 + +# 6. 自然问候:在特定的时间,她会像朋友一样送上问候。 +enable_morning_greeting = true # 是否启用早安问候 (例如: 8:00 - 9:00) +enable_night_greeting = true # 是否启用晚安问候 (例如: 22:00 - 23:00) From f4c31ddce7d463e79452cf9daff3bc7fc0b4d68f Mon Sep 17 00:00:00 2001 From: tt-P607 <68868379+tt-P607@users.noreply.github.com> Date: Sun, 30 Nov 2025 20:45:23 +0800 Subject: [PATCH 21/28] =?UTF-8?q?feat(scripts):=20=E5=A2=9E=E5=BC=BA?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=BA=93=E8=BF=81=E7=A7=BB=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=EF=BC=8C=E6=94=AF=E6=8C=81=E8=87=AA=E5=8A=A8=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=20PostgreSQL=20=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 fix_postgresql_boolean_columns(): 自动将 SQLite INTEGER 布尔列转换为 PostgreSQL BOOLEAN - 新增 fix_postgresql_sequences(): 迁移后自动重置序列值,避免主键冲突 - 迁移完成后自动执行上述修复,无需手动干预 - 批量插入失败时自动降级为逐行插入,最大程度保留数据 - 新增 --only-tables 和 --no-create-tables 参数支持增量迁移 - 移除 reset_pg_sequences.py(功能已整合到主脚本) --- scripts/migrate_database.py | 462 ++++++++++++++++++++++++++++++++-- scripts/reset_pg_sequences.py | 77 ------ 2 files changed, 436 insertions(+), 103 deletions(-) delete mode 100644 scripts/reset_pg_sequences.py diff --git a/scripts/migrate_database.py b/scripts/migrate_database.py index 9b13cd675..b5e5c30c6 100644 --- a/scripts/migrate_database.py +++ b/scripts/migrate_database.py @@ -10,16 +10,23 @@ python scripts/migrate_database.py --help python scripts/migrate_database.py --source sqlite --target postgresql python scripts/migrate_database.py --source mysql --target postgresql --batch-size 5000 + + # 交互式向导模式(推荐) + python scripts/migrate_database.py 注意事项: 1. 迁移前请备份源数据库 2. 目标数据库应该是空的或不存在的(脚本会自动创建表) 3. 迁移过程可能需要较长时间,请耐心等待 +4. 迁移到 PostgreSQL 时,脚本会自动: + - 修复布尔列类型(SQLite INTEGER -> PostgreSQL BOOLEAN) + - 重置序列值(避免主键冲突) 实现细节: - 使用 SQLAlchemy 进行数据库连接和元数据管理 - 采用流式迁移,避免一次性加载过多数据 - 支持 SQLite、MySQL、PostgreSQL 之间的互相迁移 +- 批量插入失败时自动降级为逐行插入,最大程度保留数据 """ from __future__ import annotations @@ -52,6 +59,8 @@ except ImportError: from typing import Any, Iterable, Callable +from datetime import datetime as dt + from sqlalchemy import ( create_engine, MetaData, @@ -314,6 +323,143 @@ def get_table_row_count(conn: Connection, table: Table) -> int: return 0 +def convert_value_for_target( + val: Any, + col_name: str, + source_col_type: Any, + target_col_type: Any, + target_dialect: str, + target_col_nullable: bool = True, +) -> Any: + """转换值以适配目标数据库类型 + + 处理以下情况: + 1. 空字符串日期时间 -> None + 2. SQLite INTEGER (0/1) -> PostgreSQL BOOLEAN + 3. 字符串日期时间 -> datetime 对象 + 4. 跳过主键 id (让目标数据库自增) + 5. 对于 NOT NULL 列,提供合适的默认值 + + Args: + val: 原始值 + col_name: 列名 + source_col_type: 源列类型 + target_col_type: 目标列类型 + target_dialect: 目标数据库方言名称 + target_col_nullable: 目标列是否允许 NULL + + Returns: + 转换后的值 + """ + # 获取目标类型的类名 + target_type_name = target_col_type.__class__.__name__.upper() + source_type_name = source_col_type.__class__.__name__.upper() + + # 处理 None 值 + if val is None: + # 如果目标列不允许 NULL,提供默认值 + if not target_col_nullable: + # Boolean 类型的默认值是 False + if target_type_name == "BOOLEAN" or isinstance(target_col_type, sqltypes.Boolean): + return False + # 数值类型的默认值 + if target_type_name in ("INTEGER", "BIGINT", "SMALLINT") or isinstance(target_col_type, sqltypes.Integer): + return 0 + if target_type_name in ("FLOAT", "DOUBLE", "REAL", "NUMERIC", "DECIMAL", "DOUBLE_PRECISION") or isinstance(target_col_type, sqltypes.Float): + return 0.0 + # 日期时间类型的默认值 + if target_type_name in ("DATETIME", "TIMESTAMP") or isinstance(target_col_type, sqltypes.DateTime): + return dt.now() + # 字符串类型的默认值 + if target_type_name in ("VARCHAR", "STRING", "TEXT") or isinstance(target_col_type, (sqltypes.String, sqltypes.Text)): + return "" + # 其他类型也返回空字符串作为兜底 + return "" + return None + + # 处理 Boolean 类型转换 + # SQLite 中 Boolean 实际存储为 INTEGER (0/1) + if target_type_name == "BOOLEAN" or isinstance(target_col_type, sqltypes.Boolean): + if isinstance(val, bool): + return val + if isinstance(val, (int, float)): + return bool(val) + if isinstance(val, str): + val_lower = val.lower().strip() + if val_lower in ("true", "1", "yes"): + return True + elif val_lower in ("false", "0", "no", ""): + return False + return bool(val) if val else False + + # 处理 DateTime 类型转换 + if target_type_name in ("DATETIME", "TIMESTAMP") or isinstance(target_col_type, sqltypes.DateTime): + if isinstance(val, dt): + return val + if isinstance(val, str): + val = val.strip() + # 空字符串 -> None + if val == "": + return None + # 尝试多种日期格式 + for fmt in [ + "%Y-%m-%d %H:%M:%S.%f", + "%Y-%m-%d %H:%M:%S", + "%Y-%m-%dT%H:%M:%S.%f", + "%Y-%m-%dT%H:%M:%S", + "%Y-%m-%d", + ]: + try: + return dt.strptime(val, fmt) + except ValueError: + continue + # 如果都失败,尝试 fromisoformat + try: + return dt.fromisoformat(val) + except ValueError: + logger.warning("无法解析日期时间字符串 '%s' (列: %s),设为 None", val, col_name) + return None + # 如果是数值(时间戳),尝试转换 + if isinstance(val, (int, float)) and val > 0: + try: + return dt.fromtimestamp(val) + except (OSError, ValueError, OverflowError): + return None + return None + + # 处理 Float 类型 + if target_type_name == "FLOAT" or isinstance(target_col_type, sqltypes.Float): + if isinstance(val, (int, float)): + return float(val) + if isinstance(val, str): + val = val.strip() + if val == "": + return None + try: + return float(val) + except ValueError: + return None + return val + + # 处理 Integer 类型 + if target_type_name == "INTEGER" or isinstance(target_col_type, sqltypes.Integer): + if isinstance(val, int): + return val + if isinstance(val, float): + return int(val) + if isinstance(val, str): + val = val.strip() + if val == "": + return None + try: + return int(float(val)) + except ValueError: + return None + return val + + return val + + def copy_table_structure(source_table: Table, target_metadata: MetaData, target_engine: Engine) -> Table: """复制表结构到目标数据库,使其结构保持一致""" target_is_sqlite = target_engine.dialect.name == "sqlite" @@ -351,19 +497,23 @@ def copy_table_structure(source_table: Table, target_metadata: MetaData, target_ def migrate_table_data( source_conn: Connection, - target_conn: Connection, + target_engine: Engine, source_table: Table, target_table: Table, batch_size: int = 1000, + target_dialect: str = "postgresql", + row_limit: int | None = None, ) -> tuple[int, int]: """迁移单个表的数据 Args: source_conn: 源数据库连接 - target_conn: 目标数据库连接 + target_engine: 目标数据库引擎(注意:改为 engine 而不是 connection) source_table: 源表对象 target_table: 目标表对象 batch_size: 每批次处理大小 + target_dialect: 目标数据库方言 (sqlite/mysql/postgresql) + row_limit: 最大迁移行数限制,None 表示不限制 Returns: tuple[int, int]: (迁移行数, 错误数量) @@ -377,40 +527,101 @@ def migrate_table_data( migrated_rows = 0 error_count = 0 + conversion_warnings = 0 + + # 构建源列到目标列的映射 + target_cols_by_name = {c.key: c for c in target_table.columns} + + # 识别主键列(通常是 id),迁移时保留原始 ID 以避免重复数据 + primary_key_cols = {c.key for c in source_table.primary_key.columns} # 使用流式查询,避免一次性加载太多数据 - # 对于 SQLAlchemy 1.4/2.0 可以使用 yield_per + # 使用 text() 原始 SQL 查询,避免 SQLAlchemy 自动类型转换(如 DateTime)导致的错误 try: - select_stmt = source_table.select() - result = source_conn.execute(select_stmt) + # 构建原始 SQL 查询语句 + col_names = [c.key for c in source_table.columns] + if row_limit: + # 按时间或 ID 倒序取最新的 row_limit 条 + raw_sql = text(f"SELECT {', '.join(col_names)} FROM {source_table.name} ORDER BY id DESC LIMIT {row_limit}") + logger.info(" 限制迁移最新 %d 行", row_limit) + else: + raw_sql = text(f"SELECT {', '.join(col_names)} FROM {source_table.name}") + result = source_conn.execute(raw_sql) except SQLAlchemyError as e: logger.error("查询表 %s 失败: %s", source_table.name, e) return 0, 1 def insert_batch(rows: list[dict]): + """每个批次使用独立的事务,批次失败时降级为逐行插入""" nonlocal migrated_rows, error_count if not rows: return try: - target_conn.execute(target_table.insert(), rows) + # 每个批次使用独立的事务 + with target_engine.begin() as target_conn: + target_conn.execute(target_table.insert(), rows) migrated_rows += len(rows) logger.info(" 已迁移 %d/%s 行", migrated_rows, total_rows or "?") except SQLAlchemyError as e: - logger.error("写入表 %s 失败: %s", target_table.name, e) - error_count += len(rows) + # 批量插入失败,降级为逐行插入 + logger.warning("批量插入失败,降级为逐行插入 (共 %d 行): %s", len(rows), str(e)[:200]) + for row in rows: + try: + with target_engine.begin() as target_conn: + target_conn.execute(target_table.insert(), [row]) + migrated_rows += 1 + except SQLAlchemyError as row_e: + # 记录失败的行信息 + row_id = row.get("id", "unknown") + logger.error("插入行失败 (id=%s): %s", row_id, str(row_e)[:200]) + error_count += 1 + logger.info(" 逐行插入完成,已迁移 %d/%s 行", migrated_rows, total_rows or "?") batch: list[dict] = [] null_char_replacements = 0 + # 构建列名列表(用于通过索引访问原始 SQL 结果) + col_list = list(source_table.columns) + col_name_to_idx = {c.key: idx for idx, c in enumerate(col_list)} + for row in result: - # Use column objects to access row mapping to avoid quoted_name keys row_dict = {} - for col in source_table.columns: - val = row._mapping[col] + for col in col_list: + col_key = col.key + + # 保留主键列(id),确保数据一致性 + # 注意:如果目标表使用自增主键,可能需要重置序列 + + # 通过索引获取原始值(避免 SQLAlchemy 自动类型转换) + col_idx = col_name_to_idx[col_key] + val = row[col_idx] + + # 处理 NUL 字符 if isinstance(val, str) and "\x00" in val: val = val.replace("\x00", "") null_char_replacements += 1 - row_dict[col.key] = val + + # 获取目标列类型进行转换 + target_col = target_cols_by_name.get(col_key) + if target_col is not None: + try: + val = convert_value_for_target( + val=val, + col_name=col_key, + source_col_type=col.type, + target_col_type=target_col.type, + target_dialect=target_dialect, + target_col_nullable=target_col.nullable if target_col.nullable is not None else True, + ) + except Exception as e: + conversion_warnings += 1 + if conversion_warnings <= 5: + logger.warning( + "值转换异常 (表=%s, 列=%s, 值=%r): %s", + source_table.name, col_key, val, e + ) + + row_dict[col_key] = val batch.append(row_dict) if len(batch) >= batch_size: @@ -432,6 +643,12 @@ def migrate_table_data( source_table.name, null_char_replacements, ) + if conversion_warnings: + logger.warning( + "表 %s 中 %d 个值发生类型转换警告", + source_table.name, + conversion_warnings, + ) return migrated_rows, error_count @@ -479,6 +696,9 @@ class DatabaseMigrator: batch_size: int = 1000, source_config: dict | None = None, target_config: dict | None = None, + skip_tables: set | None = None, + only_tables: set | None = None, + no_create_tables: bool = False, ): """初始化迁移器 @@ -488,12 +708,18 @@ class DatabaseMigrator: batch_size: 批量处理大小 source_config: 源数据库配置(可选,默认从配置文件读取) target_config: 目标数据库配置(可选,需要手动指定) + skip_tables: 要跳过的表名集合 + only_tables: 只迁移的表名集合(设置后忽略 skip_tables) + no_create_tables: 是否跳过创建表结构(假设目标表已存在) """ self.source_type = source_type.lower() self.target_type = target_type.lower() self.batch_size = batch_size self.source_config = source_config self.target_config = target_config + self.skip_tables = skip_tables or set() + self.only_tables = only_tables or set() + self.no_create_tables = no_create_tables self._validate_database_types() @@ -659,25 +885,60 @@ class DatabaseMigrator: tables = self._get_tables_in_dependency_order() logger.info("按依赖顺序迁移表: %s", ", ".join(t.name for t in tables)) - # 删除目标库中已有表(可选) - self._drop_target_tables() + # 如果指定了 only_tables,则过滤表列表 + if self.only_tables: + tables = [t for t in tables if t.name in self.only_tables] + logger.info("只迁移指定的表: %s", ", ".join(t.name for t in tables)) + if not tables: + logger.warning("没有找到任何匹配 --only-tables 的表") + return + + # 删除目标库中已有表(可选)- 如果是增量迁移则跳过 + if not self.no_create_tables: + self._drop_target_tables() + + # 获取目标数据库方言 + target_dialect = self.target_engine.dialect.name # 开始迁移 with self.source_engine.connect() as source_conn: for source_table in tables: - try: - # 在目标库中创建表结构 - target_table = copy_table_structure(source_table, MetaData(), self.target_engine) + # 跳过指定的表(仅在未指定 only_tables 时生效) + if not self.only_tables and source_table.name in self.skip_tables: + logger.info("跳过表: %s (在 skip_tables 列表中)", source_table.name) + continue - # 每张表单独事务,避免退出上下文被自动回滚 - with self.target_engine.begin() as target_conn: - migrated_rows, error_count = migrate_table_data( - source_conn, - target_conn, - source_table, - target_table, - batch_size=self.batch_size, - ) + try: + # 在目标库中创建表结构(除非指定了 no_create_tables) + if self.no_create_tables: + # 反射目标数据库中已存在的表结构 + target_metadata = MetaData() + target_metadata.reflect(bind=self.target_engine, only=[source_table.name]) + target_table = target_metadata.tables.get(source_table.name) + if target_table is None: + logger.error("目标数据库中不存在表: %s,请先创建表结构或移除 --no-create-tables 参数", source_table.name) + self.stats["errors"].append(f"目标数据库中不存在表: {source_table.name}") + continue + logger.info("使用目标数据库中已存在的表结构: %s", source_table.name) + else: + target_table = copy_table_structure(source_table, MetaData(), self.target_engine) + + # 对 messages 表限制迁移行数(只迁移最新 1 万条) + row_limit = None + if source_table.name == "messages": + row_limit = 10000 + logger.info("messages 表将只迁移最新 %d 条记录", row_limit) + + # 每个批次使用独立事务,传入 engine 而不是 connection + migrated_rows, error_count = migrate_table_data( + source_conn, + self.target_engine, + source_table, + target_table, + batch_size=self.batch_size, + target_dialect=target_dialect, + row_limit=row_limit, + ) self.stats["tables_migrated"] += 1 self.stats["rows_migrated"] += migrated_rows @@ -691,6 +952,11 @@ class DatabaseMigrator: self.stats["errors"].append(f"表 {source_table.name} 迁移失败: {e}") self.stats["end_time"] = time.time() + + # 迁移完成后,自动修复 PostgreSQL 特有问题 + if self.target_type == "postgresql" and self.target_engine: + fix_postgresql_boolean_columns(self.target_engine) + fix_postgresql_sequences(self.target_engine) def print_summary(self): """打印迁移总结""" @@ -804,6 +1070,29 @@ def parse_args(): target_group.add_argument("--target-schema", type=str, default="public", help="PostgreSQL schema") target_group.add_argument("--target-charset", type=str, default="utf8mb4", help="MySQL 字符集") + # 跳过表参数 + parser.add_argument( + "--skip-tables", + type=str, + default="", + help="跳过迁移的表名,多个表名用逗号分隔(如: messages,logs)", + ) + + # 只迁移指定表参数 + parser.add_argument( + "--only-tables", + type=str, + default="", + help="只迁移指定的表名,多个表名用逗号分隔(如: user_relationships,maizone_schedule_status)。设置后将忽略 --skip-tables", + ) + + # 不创建表结构,假设目标表已存在 + parser.add_argument( + "--no-create-tables", + action="store_true", + help="不创建表结构,假设目标数据库中的表已存在。用于增量迁移指定表的数据", + ) + return parser.parse_args() @@ -1012,6 +1301,112 @@ def interactive_setup() -> dict: } +def fix_postgresql_sequences(engine: Engine): + """修复 PostgreSQL 序列值 + + 迁移数据后,PostgreSQL 的序列(用于自增主键)可能没有更新到正确的值, + 导致插入新记录时出现主键冲突。此函数会自动检测并重置所有序列。 + + Args: + engine: PostgreSQL 数据库引擎 + """ + if engine.dialect.name != "postgresql": + logger.info("非 PostgreSQL 数据库,跳过序列修复") + return + + logger.info("正在修复 PostgreSQL 序列...") + + with engine.connect() as conn: + # 获取所有带有序列的表 + result = conn.execute(text(''' + SELECT + t.table_name, + c.column_name, + pg_get_serial_sequence(t.table_name, c.column_name) as sequence_name + FROM information_schema.tables t + JOIN information_schema.columns c + ON t.table_name = c.table_name AND t.table_schema = c.table_schema + WHERE t.table_schema = 'public' + AND t.table_type = 'BASE TABLE' + AND c.column_default LIKE 'nextval%' + ORDER BY t.table_name + ''')) + + sequences = result.fetchall() + logger.info("发现 %d 个带序列的表", len(sequences)) + + fixed_count = 0 + for table_name, column_name, seq_name in sequences: + if seq_name: + try: + # 获取当前表中该列的最大值 + max_result = conn.execute(text(f'SELECT COALESCE(MAX({column_name}), 0) FROM {table_name}')) + max_val = max_result.scalar() + + # 设置序列的下一个值 + next_val = max_val + 1 + conn.execute(text(f"SELECT setval('{seq_name}', {next_val}, false)")) + conn.commit() + + logger.info(" ✅ %s.%s: 最大值=%d, 序列设为=%d", table_name, column_name, max_val, next_val) + fixed_count += 1 + except Exception as e: + logger.warning(" ❌ %s.%s: 修复失败 - %s", table_name, column_name, e) + + logger.info("序列修复完成!共修复 %d 个序列", fixed_count) + + +def fix_postgresql_boolean_columns(engine: Engine): + """修复 PostgreSQL 布尔列类型 + + 从 SQLite 迁移后,布尔列可能是 INTEGER 类型。此函数将其转换为 BOOLEAN。 + + Args: + engine: PostgreSQL 数据库引擎 + """ + if engine.dialect.name != "postgresql": + logger.info("非 PostgreSQL 数据库,跳过布尔列修复") + return + + # 已知需要转换为 BOOLEAN 的列 + BOOLEAN_COLUMNS = { + 'messages': ['is_mentioned', 'is_emoji', 'is_picid', 'is_command', + 'is_notify', 'is_public_notice', 'should_reply', 'should_act'], + 'action_records': ['action_done', 'action_build_into_prompt'], + } + + logger.info("正在检查并修复 PostgreSQL 布尔列...") + + with engine.connect() as conn: + fixed_count = 0 + for table_name, columns in BOOLEAN_COLUMNS.items(): + for col_name in columns: + try: + # 检查当前类型 + result = conn.execute(text(f''' + SELECT data_type FROM information_schema.columns + WHERE table_name = '{table_name}' AND column_name = '{col_name}' + ''')) + row = result.fetchone() + if row and row[0] != 'boolean': + # 需要修复 + conn.execute(text(f''' + ALTER TABLE {table_name} + ALTER COLUMN {col_name} TYPE BOOLEAN + USING CASE WHEN {col_name} = 0 THEN FALSE ELSE TRUE END + ''')) + conn.commit() + logger.info(" ✅ %s.%s: %s -> BOOLEAN", table_name, col_name, row[0]) + fixed_count += 1 + except Exception as e: + logger.warning(" ⚠️ %s.%s: 检查/修复失败 - %s", table_name, col_name, e) + + if fixed_count > 0: + logger.info("布尔列修复完成!共修复 %d 列", fixed_count) + else: + logger.info("所有布尔列类型正确,无需修复") + + def main(): """主函数""" args = parse_args() @@ -1055,12 +1450,27 @@ def main(): sys.exit(1) try: + # 解析跳过的表 + skip_tables = set() + if args.skip_tables: + skip_tables = {t.strip() for t in args.skip_tables.split(",") if t.strip()} + logger.info("将跳过以下表: %s", ", ".join(skip_tables)) + + # 解析只迁移的表 + only_tables = set() + if args.only_tables: + only_tables = {t.strip() for t in args.only_tables.split(",") if t.strip()} + logger.info("将只迁移以下表: %s", ", ".join(only_tables)) + migrator = DatabaseMigrator( source_type=args.source, target_type=args.target, batch_size=args.batch_size, source_config=source_config, target_config=target_config, + skip_tables=skip_tables, + only_tables=only_tables, + no_create_tables=args.no_create_tables, ) stats = migrator.run() diff --git a/scripts/reset_pg_sequences.py b/scripts/reset_pg_sequences.py deleted file mode 100644 index cd36091f0..000000000 --- a/scripts/reset_pg_sequences.py +++ /dev/null @@ -1,77 +0,0 @@ -#!/usr/bin/env python3 -"""重置 PostgreSQL 序列值 - -迁移数据后,PostgreSQL 的序列(用于自增主键)可能没有更新到正确的值, -导致插入新记录时出现主键冲突。此脚本会自动检测并重置所有序列。 - -使用方法: - python scripts/reset_pg_sequences.py --host localhost --port 5432 --database maibot --user postgres --password your_password -""" - -import argparse -import psycopg - - -def reset_sequences(host: str, port: int, database: str, user: str, password: str): - """重置所有序列值""" - conn_str = f"host={host} port={port} dbname={database} user={user} password={password}" - - print(f"连接到 PostgreSQL: {host}:{port}/{database}") - conn = psycopg.connect(conn_str) - conn.autocommit = True - - # 查询所有序列及其关联的表和列 - query = """ - SELECT - t.relname AS table_name, - a.attname AS column_name, - s.relname AS sequence_name - FROM pg_class s - JOIN pg_depend d ON d.objid = s.oid - JOIN pg_class t ON d.refobjid = t.oid - JOIN pg_attribute a ON (d.refobjid, d.refobjsubid) = (a.attrelid, a.attnum) - WHERE s.relkind = 'S' - """ - - cursor = conn.execute(query) - sequences = cursor.fetchall() - - print(f"发现 {len(sequences)} 个序列") - - reset_count = 0 - for table_name, col_name, seq_name in sequences: - try: - # 获取当前最大 ID - max_result = conn.execute(f'SELECT MAX("{col_name}") FROM "{table_name}"') - max_id = max_result.fetchone()[0] - - if max_id is not None: - # 重置序列 - conn.execute(f"SELECT setval('{seq_name}', {max_id}, true)") - print(f" ✓ {seq_name} -> {max_id}") - reset_count += 1 - else: - print(f" - {seq_name}: 表为空,跳过") - - except Exception as e: - print(f" ✗ {table_name}.{col_name}: {e}") - - conn.close() - print(f"\n✅ 重置完成!共重置 {reset_count} 个序列") - - -def main(): - parser = argparse.ArgumentParser(description="重置 PostgreSQL 序列值") - parser.add_argument("--host", default="localhost", help="PostgreSQL 主机") - parser.add_argument("--port", type=int, default=5432, help="PostgreSQL 端口") - parser.add_argument("--database", default="maibot", help="数据库名") - parser.add_argument("--user", default="postgres", help="用户名") - parser.add_argument("--password", required=True, help="密码") - - args = parser.parse_args() - - reset_sequences(args.host, args.port, args.database, args.user, args.password) - - -if __name__ == "__main__": - main() From 9d0488ef5a0834f20d7c487432f6e9649a0766d6 Mon Sep 17 00:00:00 2001 From: Windpicker-owo <3431391539@qq.com> Date: Sun, 30 Nov 2025 21:01:02 +0800 Subject: [PATCH 22/28] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84=20KFC=20?= =?UTF-8?q?=E5=9B=9E=E5=A4=8D=E5=8A=A8=E4=BD=9C=EF=BC=8C=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E5=9B=9E=E5=A4=8D=E7=94=9F=E6=88=90=E6=B5=81=E7=A8=8B=E5=92=8C?= =?UTF-8?q?=E4=B8=8A=E4=B8=8B=E6=96=87=E6=B3=A8=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kokoro_flow_chatter/actions/reply.py | 258 ++++++++++++++++-- .../built_in/kokoro_flow_chatter/chatter.py | 36 +-- .../kokoro_flow_chatter/proactive_thinker.py | 60 ++-- 3 files changed, 263 insertions(+), 91 deletions(-) diff --git a/src/plugins/built_in/kokoro_flow_chatter/actions/reply.py b/src/plugins/built_in/kokoro_flow_chatter/actions/reply.py index c43da326b..fc6edb1b8 100644 --- a/src/plugins/built_in/kokoro_flow_chatter/actions/reply.py +++ b/src/plugins/built_in/kokoro_flow_chatter/actions/reply.py @@ -1,34 +1,53 @@ """ KFC 回复动作模块 -KFC 的 reply 动作与 AFC 不同: -- 不调用 LLM 生成回复,content 由 Replyer 提前生成 -- 动作本身只负责发送 content 参数中的内容 +KFC 的 reply 动作: +- 完整的回复流程在 execute() 中实现 +- 调用 Replyer 生成回复文本 +- 回复后处理(系统格式词过滤、分段发送、错字生成等) +- 发送回复消息 + +与 AFC 类似,但使用 KFC 专属的 Replyer 和 Session 系统。 """ -from typing import ClassVar +import asyncio +from typing import TYPE_CHECKING, ClassVar, Optional from src.common.logger import get_logger +from src.config.config import global_config from src.plugin_system import ActionActivationType, BaseAction, ChatMode from src.plugin_system.apis import send_api +if TYPE_CHECKING: + from ..session import KokoroSession + logger = get_logger("kfc_reply_action") class KFCReplyAction(BaseAction): - """KFC Reply 动作 - 发送已生成的回复内容 + """KFC Reply 动作 - 完整的私聊回复流程 特点: - - 不调用 LLM,直接发送 content 参数中的内容 - - content 由 Replyer 提前生成 + - 完整的回复流程:生成回复 → 后处理 → 分段发送 + - 使用 KFC 专属的 Replyer 生成回复 + - 支持系统格式词过滤、分段发送、错字生成等后处理 - 仅限 KokoroFlowChatter 使用 - 注意:使用 kfc_reply 作为动作名称以避免与 AFC 的 reply 动作冲突 + action_data 参数: + - user_id: 用户ID(必需,用于获取 Session) + - user_name: 用户名称(必需) + - thought: Planner 生成的想法/内心独白(必需) + - situation_type: 情况类型(可选,默认 "new_message") + - extra_context: 额外上下文(可选) + - content: 预生成的回复内容(可选,如果提供则直接发送) + - should_quote_reply: 是否引用原消息(可选,默认 false) + - enable_splitter: 是否启用分段发送(可选,默认 true) + - enable_chinese_typo: 是否启用错字生成(可选,默认 true) """ # 动作基本信息 action_name = "kfc_reply" - action_description = "发送回复消息。content 参数包含要发送的内容。" + action_description = "发送回复消息。会根据当前对话情境生成并发送回复。" # 激活设置 activation_type = ActionActivationType.ALWAYS @@ -40,45 +59,230 @@ class KFCReplyAction(BaseAction): # 动作参数定义 action_parameters: ClassVar = { - "content": "要发送的回复内容(必需,由 Replyer 生成)", + "content": "要发送的回复内容(可选,如果不提供则自动生成)", "should_quote_reply": "是否引用原消息(可选,true/false,默认 false)", } # 动作使用场景 action_require: ClassVar = [ - "发送回复消息时使用", - "content 参数必须包含要发送的内容", + "需要发送回复消息时使用", + "私聊场景的标准回复动作", ] # 关联类型 associated_types: ClassVar[list[str]] = ["text"] async def execute(self) -> tuple[bool, str]: - """执行 reply 动作 - 发送 content 中的内容""" + """执行 reply 动作 - 完整的回复流程""" try: - # 获取要发送的内容 + # 1. 检查是否有预生成的内容 content = self.action_data.get("content", "") + if not content: - logger.warning(f"{self.log_prefix} content 为空,跳过发送") - return True, "" + # 2. 需要生成回复,获取必要信息 + user_id = self.action_data.get("user_id") + user_name = self.action_data.get("user_name", "用户") + thought = self.action_data.get("thought", "") + situation_type = self.action_data.get("situation_type", "new_message") + extra_context = self.action_data.get("extra_context") + + if not user_id: + logger.warning(f"{self.log_prefix} 缺少 user_id,无法生成回复") + return False, "" + + # 3. 获取 Session + session = await self._get_session(user_id) + if not session: + logger.warning(f"{self.log_prefix} 无法获取 Session: {user_id}") + return False, "" + + # 4. 调用 Replyer 生成回复 + success, content = await self._generate_reply( + session=session, + user_name=user_name, + thought=thought, + situation_type=situation_type, + extra_context=extra_context, + ) + + if not success or not content: + logger.warning(f"{self.log_prefix} 回复生成失败") + return False, "" - # 获取是否引用 - should_quote = self.action_data.get("should_quote_reply", False) + # 5. 回复后处理(系统格式词过滤 + 分段处理) + enable_splitter = self.action_data.get("enable_splitter", True) + enable_chinese_typo = self.action_data.get("enable_chinese_typo", True) - # 发送消息 - await send_api.text_to_stream( - text=content, - stream_id=self.chat_stream.stream_id, - reply_to_message=self.action_message, - set_reply=should_quote and bool(self.action_message), - typing=False, + processed_segments = self._post_process_reply( + content=content, + enable_splitter=enable_splitter, + enable_chinese_typo=enable_chinese_typo, ) - logger.info(f"{self.log_prefix} KFC reply 动作执行成功") - return True, content + if not processed_segments: + logger.warning(f"{self.log_prefix} 回复后处理后内容为空") + return False, "" + # 6. 分段发送回复 + should_quote = self.action_data.get("should_quote_reply", False) + reply_text = await self._send_segments( + segments=processed_segments, + should_quote=should_quote, + ) + + logger.info(f"{self.log_prefix} KFC reply 动作执行成功: {reply_text[:50]}...") + return True, reply_text + + except asyncio.CancelledError: + logger.debug(f"{self.log_prefix} 回复任务被取消") + return False, "" except Exception as e: logger.error(f"{self.log_prefix} KFC reply 动作执行失败: {e}") import traceback traceback.print_exc() return False, "" + + def _post_process_reply( + self, + content: str, + enable_splitter: bool = True, + enable_chinese_typo: bool = True, + ) -> list[str]: + """ + 回复后处理 + + 包括: + 1. 系统格式词过滤(移除 [回复...]、[表情包:...]、@<...> 等) + 2. 分段处理(根据标点分句、智能合并) + 3. 错字生成(拟人化) + + Args: + content: 原始回复内容 + enable_splitter: 是否启用分段 + enable_chinese_typo: 是否启用错字生成 + + Returns: + 处理后的文本段落列表 + """ + try: + from src.chat.utils.utils import filter_system_format_content, process_llm_response + + # 1. 过滤系统格式词 + filtered_content = filter_system_format_content(content) + + if not filtered_content or not filtered_content.strip(): + logger.warning(f"{self.log_prefix} 过滤系统格式词后内容为空") + return [] + + # 2. 分段处理 + 错字生成 + processed_segments = process_llm_response( + filtered_content, + enable_splitter=enable_splitter, + enable_chinese_typo=enable_chinese_typo, + ) + + # 过滤空段落 + processed_segments = [seg for seg in processed_segments if seg and seg.strip()] + + logger.debug( + f"{self.log_prefix} 回复后处理完成: " + f"原始长度={len(content)}, 过滤后长度={len(filtered_content)}, " + f"分段数={len(processed_segments)}" + ) + + return processed_segments + + except Exception as e: + logger.error(f"{self.log_prefix} 回复后处理失败: {e}") + # 失败时返回原始内容 + return [content] if content else [] + + async def _send_segments( + self, + segments: list[str], + should_quote: bool = False, + ) -> str: + """ + 分段发送回复 + + Args: + segments: 要发送的文本段落列表 + should_quote: 是否引用原消息(仅第一条消息引用) + + Returns: + 完整的回复文本(所有段落拼接) + """ + reply_text = "" + first_sent = False + + # 获取分段发送的间隔时间 + typing_delay = 0.5 + if global_config and hasattr(global_config, 'response_splitter'): + typing_delay = getattr(global_config.response_splitter, "typing_delay", 0.5) + + for segment in segments: + if not segment or not segment.strip(): + continue + + reply_text += segment + + # 发送消息 + if not first_sent: + # 第一条消息:可能需要引用 + await send_api.text_to_stream( + text=segment, + stream_id=self.chat_stream.stream_id, + reply_to_message=self.action_message, + set_reply=should_quote and bool(self.action_message), + typing=False, + ) + first_sent = True + else: + # 后续消息:模拟打字延迟 + if typing_delay > 0: + await asyncio.sleep(typing_delay) + + await send_api.text_to_stream( + text=segment, + stream_id=self.chat_stream.stream_id, + reply_to_message=None, + set_reply=False, + typing=True, + ) + + return reply_text + + async def _get_session(self, user_id: str) -> Optional["KokoroSession"]: + """获取用户 Session""" + try: + from ..session import get_session_manager + + session_manager = get_session_manager() + return await session_manager.get_session(user_id, self.chat_stream.stream_id) + except Exception as e: + logger.error(f"{self.log_prefix} 获取 Session 失败: {e}") + return None + + async def _generate_reply( + self, + session: "KokoroSession", + user_name: str, + thought: str, + situation_type: str, + extra_context: Optional[dict] = None, + ) -> tuple[bool, str]: + """调用 Replyer 生成回复""" + try: + from ..replyer import generate_reply_text + + return await generate_reply_text( + session=session, + user_name=user_name, + thought=thought, + situation_type=situation_type, + chat_stream=self.chat_stream, + extra_context=extra_context, + ) + except Exception as e: + logger.error(f"{self.log_prefix} 生成回复失败: {e}") + return False, "" diff --git a/src/plugins/built_in/kokoro_flow_chatter/chatter.py b/src/plugins/built_in/kokoro_flow_chatter/chatter.py index 63d69b632..6c2b0e802 100644 --- a/src/plugins/built_in/kokoro_flow_chatter/chatter.py +++ b/src/plugins/built_in/kokoro_flow_chatter/chatter.py @@ -3,8 +3,8 @@ Kokoro Flow Chatter - Chatter 主类 极简设计,只负责: 1. 收到消息 -2. 调用 Replyer 生成响应 -3. 执行动作 +2. 调用 Planner 生成规划 +3. 执行动作(回复在 Action.execute() 中生成) 4. 更新 Session """ @@ -20,7 +20,6 @@ from src.plugin_system.base.component_types import ChatType from .models import SessionStatus from .planner import generate_plan -from .replyer import generate_reply_text from .session import get_session_manager if TYPE_CHECKING: @@ -153,30 +152,19 @@ class KokoroFlowChatter(BaseChatter): available_actions=available_actions, ) - # 10. 对于需要回复的动作,调用 Replyer 生成实际文本 - processed_actions = [] + # 10. 为 kfc_reply 动作注入必要的上下文信息 for action in plan_response.actions: if action.type == "kfc_reply": - # 调用 replyer 生成回复文本 - success, reply_text = await generate_reply_text( - session=session, - user_name=user_name, - thought=plan_response.thought, - situation_type=situation_type, - chat_stream=chat_stream, - ) - if success and reply_text: - # 更新 action 的 content - action.params["content"] = reply_text - else: - logger.warning("[KFC] 回复生成失败,跳过该动作") - continue - processed_actions.append(action) + # 注入回复生成所需的上下文 + action.params["user_id"] = user_id + action.params["user_name"] = user_name + action.params["thought"] = plan_response.thought + action.params["situation_type"] = situation_type - # 11. 执行动作 + # 11. 执行动作(回复生成在 Action.execute() 中完成) exec_results = [] has_reply = False - for action in processed_actions: + for action in plan_response.actions: result = await self.action_manager.execute_action( action_name=action.type, chat_id=self.stream_id, @@ -193,7 +181,7 @@ class KokoroFlowChatter(BaseChatter): # 12. 记录 Bot 规划到 mental_log session.add_bot_planning( thought=plan_response.thought, - actions=[a.to_dict() for a in processed_actions], + actions=[a.to_dict() for a in plan_response.actions], expected_reaction=plan_response.expected_reaction, max_wait_seconds=plan_response.max_wait_seconds, ) @@ -222,7 +210,7 @@ class KokoroFlowChatter(BaseChatter): logger.info( f"{SOFT_PURPLE}[KFC]{RESET} 处理完成: " f"user={user_name}, situation={situation_type}, " - f"actions={[a.type for a in processed_actions]}, " + f"actions={[a.type for a in plan_response.actions]}, " f"wait={plan_response.max_wait_seconds}s" ) diff --git a/src/plugins/built_in/kokoro_flow_chatter/proactive_thinker.py b/src/plugins/built_in/kokoro_flow_chatter/proactive_thinker.py index fffc1719b..0cee53028 100644 --- a/src/plugins/built_in/kokoro_flow_chatter/proactive_thinker.py +++ b/src/plugins/built_in/kokoro_flow_chatter/proactive_thinker.py @@ -22,7 +22,7 @@ from src.plugin_system.apis.unified_scheduler import TriggerType, unified_schedu from .models import EventType, SessionStatus from .planner import generate_plan -from .replyer import _clean_reply_text, generate_reply_text +from .replyer import _clean_reply_text from .session import KokoroSession, get_session_manager if TYPE_CHECKING: @@ -412,26 +412,16 @@ class ProactiveThinker: available_actions=action_manager.get_using_actions(), ) - # 对于需要回复的动作,调用 Replyer 生成实际文本 - processed_actions = [] + # 为 kfc_reply 动作注入必要的上下文信息 for action in plan_response.actions: if action.type == "kfc_reply": - success, reply_text = await generate_reply_text( - session=session, - user_name=user_name, - thought=plan_response.thought, - situation_type="timeout", - chat_stream=chat_stream, - ) - if success and reply_text: - action.params["content"] = reply_text - else: - logger.warning("[ProactiveThinker] 回复生成失败,跳过该动作") - continue - processed_actions.append(action) + action.params["user_id"] = session.user_id + action.params["user_name"] = user_name + action.params["thought"] = plan_response.thought + action.params["situation_type"] = "timeout" - # 执行动作 - for action in processed_actions: + # 执行动作(回复生成在 Action.execute() 中完成) + for action in plan_response.actions: await action_manager.execute_action( action_name=action.type, chat_id=session.stream_id, @@ -445,7 +435,7 @@ class ProactiveThinker: # 记录到 mental_log session.add_bot_planning( thought=plan_response.thought, - actions=[a.to_dict() for a in processed_actions], + actions=[a.to_dict() for a in plan_response.actions], expected_reaction=plan_response.expected_reaction, max_wait_seconds=plan_response.max_wait_seconds, ) @@ -466,7 +456,7 @@ class ProactiveThinker: logger.info( f"[ProactiveThinker] 超时决策完成: user={session.user_id}, " - f"actions={[a.type for a in processed_actions]}, " + f"actions={[a.type for a in plan_response.actions]}, " f"continue_wait={plan_response.max_wait_seconds > 0}" ) @@ -612,27 +602,17 @@ class ProactiveThinker: await self.session_manager.save_session(session.user_id) return - # 对于需要回复的动作,调用 Replyer 生成实际文本 - processed_actions = [] + # 为 kfc_reply 动作注入必要的上下文信息 for action in plan_response.actions: if action.type == "kfc_reply": - success, reply_text = await generate_reply_text( - session=session, - user_name=user_name, - thought=plan_response.thought, - situation_type="proactive", - chat_stream=chat_stream, - extra_context=extra_context, - ) - if success and reply_text: - action.params["content"] = reply_text - else: - logger.warning("[ProactiveThinker] 回复生成失败,跳过该动作") - continue - processed_actions.append(action) + action.params["user_id"] = session.user_id + action.params["user_name"] = user_name + action.params["thought"] = plan_response.thought + action.params["situation_type"] = "proactive" + action.params["extra_context"] = extra_context - # 执行动作 - for action in processed_actions: + # 执行动作(回复生成在 Action.execute() 中完成) + for action in plan_response.actions: await action_manager.execute_action( action_name=action.type, chat_id=session.stream_id, @@ -646,7 +626,7 @@ class ProactiveThinker: # 记录到 mental_log session.add_bot_planning( thought=plan_response.thought, - actions=[a.to_dict() for a in processed_actions], + actions=[a.to_dict() for a in plan_response.actions], expected_reaction=plan_response.expected_reaction, max_wait_seconds=plan_response.max_wait_seconds, ) @@ -664,7 +644,7 @@ class ProactiveThinker: logger.info( f"[ProactiveThinker] 主动发起完成: user={session.user_id}, " - f"actions={[a.type for a in processed_actions]}" + f"actions={[a.type for a in plan_response.actions]}" ) except Exception as e: From 35c49370688aa14e9b4a75a48200a37859f900b2 Mon Sep 17 00:00:00 2001 From: Windpicker-owo <3431391539@qq.com> Date: Sun, 30 Nov 2025 21:35:17 +0800 Subject: [PATCH 23/28] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=20KFC=20?= =?UTF-8?q?=E4=B8=BB=E6=8F=90=E7=A4=BA=E8=AF=8D=E6=A8=A1=E6=9D=BF=EF=BC=8C?= =?UTF-8?q?=E8=B0=83=E6=95=B4=E7=BB=93=E6=9E=84=E5=92=8C=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E8=AF=B4=E6=98=8E=EF=BC=8C=E5=A2=9E=E5=BC=BA=E5=8F=AF=E8=AF=BB?= =?UTF-8?q?=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kokoro_flow_chatter/prompt/prompts.py | 75 ++++++++----------- 1 file changed, 30 insertions(+), 45 deletions(-) diff --git a/src/plugins/built_in/kokoro_flow_chatter/prompt/prompts.py b/src/plugins/built_in/kokoro_flow_chatter/prompt/prompts.py index d089888b6..80b1d29e9 100644 --- a/src/plugins/built_in/kokoro_flow_chatter/prompt/prompts.py +++ b/src/plugins/built_in/kokoro_flow_chatter/prompt/prompts.py @@ -14,35 +14,35 @@ kfc_MAIN_PROMPT = Prompt( name="kfc_main", template="""# 你与 {user_name} 的私聊 -## 1. 你是谁 +## 人设 {persona_block} -## 2. 你与 {user_name} 的关系 +## 你与 {user_name} 的关系 {relation_block} -## 3. 相关记忆 +## 相关记忆 {memory_block} -## 4. 你们之间发生的事(活动流) +## 你们之间最近的活动记录 以下是你和 {user_name} 最近的互动历史,按时间顺序记录了你们的对话和你的心理活动: {activity_stream} -## 5. 当前情况 +## 当前情况 {current_situation} -## 6. 聊天历史总览 +## 聊天历史总览 以下是你和 {user_name} 的聊天记录,帮助你更好地理解对话上下文: {chat_history_block} -## 7. 你可以做的事情 +## 你可以做的事情 {available_actions} -## 8. 你的表达习惯 +## 你的表达习惯 {expression_habits} -## 9. 你的回复格式 +## 你的回复格式 {output_format} """, ) @@ -61,7 +61,7 @@ kfc_OUTPUT_FORMAT = Prompt( {{"type": "动作名称", ...动作参数}} ], "expected_reaction": "你期待对方的反应是什么", - "max_wait_seconds": 300 + "max_wait_seconds": 等待时间(秒),0 表示不等待 }} ``` @@ -69,7 +69,7 @@ kfc_OUTPUT_FORMAT = Prompt( - `thought`:你的内心独白,记录你此刻的想法和感受。要自然,不要技术性语言。 - `actions`:你要执行的动作列表。每个动作是一个对象,必须包含 `type` 字段指定动作类型,其他字段根据动作类型不同而不同(参考上面每个动作的示例)。 - `expected_reaction`:你期待对方如何回应(用于判断是否需要等待) -- `max_wait_seconds`:设定等待时间(秒),0 表示不等待,超时后你会考虑是否要主动说点什么 +- `max_wait_seconds`:设定等待时间(秒),0 表示不等待,超时后你会考虑是否要主动说点什么。如果你认为聊天没有继续的必要,或不想打扰对方,可以设为 0。 ### 注意事项 - 动作参数直接写在动作对象里,不需要 `action_data` 包装 @@ -89,8 +89,7 @@ kfc_SITUATION_NEW_MESSAGE = Prompt( 请决定你要怎么回应。你可以: - 发送文字消息回复 -- 发表情包 -- 戳一戳对方 +- 执行其他动作 - 什么都不做(如果觉得没必要回复) - 或者组合多个动作""", ) @@ -123,12 +122,12 @@ kfc_SITUATION_TIMEOUT = Prompt( 你之前发了消息后一直在等 {user_name} 的回复。 你原本打算最多等 {max_wait_minutes:.1f} 分钟,现在已经等了 {elapsed_minutes:.1f} 分钟了,对方还是没回。 -你期待的反应是:"{expected_reaction}" +你当时期待的反应是:"{expected_reaction}" 你需要决定: 1. 继续等待(设置新的 max_wait_seconds) 2. 主动说点什么打破沉默 -3. 做点别的事情(戳一戳、发表情等) +3. 做点别的事情(执行其他动作) 4. 算了不等了(max_wait_seconds = 0)""", ) @@ -143,7 +142,7 @@ kfc_SITUATION_PROACTIVE = Prompt( 请决定: 1. 主动发起对话(想个话题开场) -2. 发个表情或戳一戳试探一下 +2. 做点动作试探一下 3. 算了,现在不是好时机(do_nothing) 如果决定发起对话,想想用什么自然的方式开场,不要太突兀。""", @@ -226,7 +225,7 @@ kfc_PLANNER_OUTPUT_FORMAT = Prompt( {{"type": "动作名称", ...动作参数}} ], "expected_reaction": "你期待对方的反应是什么", - "max_wait_seconds": 300 + "max_wait_seconds": 等待时间(秒),0 表示不等待 }} ``` @@ -251,61 +250,47 @@ kfc_REPLYER_PROMPT = Prompt( name="kfc_replyer", template="""# 你与 {user_name} 的私聊 -## 1. 你是谁 +## 人设 {persona_block} -## 2. 你与 {user_name} 的关系 +## 你与 {user_name} 的关系 {relation_block} -## 3. 相关记忆 +## 相关记忆 {memory_block} -## 4. 你们之间发生的事(活动流) +## 你们之间发生的事(活动流) 以下是你和 {user_name} 最近的互动历史,按时间顺序记录了你们的对话和你的心理活动: {activity_stream} -## 5. 当前情况 +## 当前情况 {current_situation} -## 6. 聊天历史总览 +## 聊天历史总览 以下是你和 {user_name} 的聊天记录,帮助你更好地理解对话上下文: {chat_history_block} -## 7. 你的表达习惯 +## 你的表达习惯 {expression_habits} -## 8. 你的决策 +## 你的决策 你已经决定要回复 {user_name}。 +你需要生成一段紧密相关且与历史消息相关的回复。 **你的想法**:{thought} {reply_context} -## 9. 回复要求 +## 要求 -**情景化表达**: -- 根据你们的关系亲密度选择合适的语气和称呼 -- 参考活动流中的互动历史,保持对话的连贯性 -- 如果对方回复得晚/早,可以自然地表现出你的感受 +- 请注意不要输出多余内容(包括前后缀,冒号和引号,at,[xx:xxx]系统格式化文字或 @等 )。只输出回复内容。 +- 在称呼用户时,请使用更自然的昵称或简称。对于长英文名,可使用首字母缩写;对于中文名,可提炼合适的简称。禁止直接复述复杂的用户名或输出用户名中的任何符号,让称呼更像人类习惯,注意,简称不是必须的,合理的使用。 -**自然对话**: -- 像真实的朋友聊天一样,不要生硬或公式化 -- 可以用口语、网络用语、语气词,让回复更生动 -- 长度适中,不要太长也不要太短 +你的回复应该是一条简短、完整且口语化的回复。 -**表达习惯**: -- 参考上面的"表达习惯"部分,使用你习惯的语言风格 -- 保持人设的一致性 - -**禁忌**: -- 不要重复你之前说过的话 -- 不要输出 JSON 格式或技术性语言 -- 不要加引号、括号等多余符号 -- 不要用"我决定..."、"因此..."这种总结性语言 - -现在,请直接输出你要说的话:""", +现在,你说:""", ) kfc_REPLYER_CONTEXT_NORMAL = Prompt( From 5d14bab259a85e5862fc98b4f71fad7694fd032f Mon Sep 17 00:00:00 2001 From: tt-P607 <68868379+tt-P607@users.noreply.github.com> Date: Mon, 1 Dec 2025 01:32:56 +0800 Subject: [PATCH 24/28] =?UTF-8?q?feat:=20=E4=B8=BAKokoro=20Flow=20Chatter?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E7=BB=9F=E4=B8=80=E6=A8=A1=E5=BC=8F=EF=BC=8C?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E6=A8=A1=E5=9D=97=E5=8C=96=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E7=94=9F=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增统一模式:通过单次LLM调用同时处理推理与响应生成 采用模块化提示组件:系统提示与用户提示分离,提升灵活性和可维护性 日志更新:更清晰地记录生成响应与执行动作 版本更新:在机器人配置模板中递增版本号以反映变更 上下文处理优化:改进用户交互与记忆管理 --- src/chat/planner_actions/action_manager.py | 6 +- src/common/logger.py | 8 + .../built_in/kokoro_flow_chatter/__init__.py | 22 +- .../built_in/kokoro_flow_chatter/chatter.py | 164 +++-- .../built_in/kokoro_flow_chatter/config.py | 44 +- .../built_in/kokoro_flow_chatter/planner.py | 11 +- .../kokoro_flow_chatter/proactive_thinker.py | 125 ++-- .../kokoro_flow_chatter/prompt/builder.py | 196 ++++++ .../kokoro_flow_chatter/prompt/prompts.py | 31 + .../prompt_modules_unified.py | 607 ++++++++++++++++++ .../built_in/kokoro_flow_chatter/replyer.py | 3 +- .../built_in/kokoro_flow_chatter/unified.py | 578 +++++++++++++++++ template/bot_config_template.toml | 12 +- 13 files changed, 1695 insertions(+), 112 deletions(-) create mode 100644 src/plugins/built_in/kokoro_flow_chatter/prompt_modules_unified.py create mode 100644 src/plugins/built_in/kokoro_flow_chatter/unified.py diff --git a/src/chat/planner_actions/action_manager.py b/src/chat/planner_actions/action_manager.py index b688213ff..84100f6b7 100644 --- a/src/chat/planner_actions/action_manager.py +++ b/src/chat/planner_actions/action_manager.py @@ -172,9 +172,9 @@ class ChatterActionManager: # 设置正在处理的状态 chat_stream.context.is_replying = True - # no_action 特殊处理 - if action_name == "no_action": - return {"action_type": "no_action", "success": True, "reply_text": ""} + # no_action / do_nothing 特殊处理 + if action_name in ("no_action", "do_nothing"): + return {"action_type": action_name, "success": True, "reply_text": ""} # 创建并执行动作 action_handler = self.create_action( diff --git a/src/common/logger.py b/src/common/logger.py index a806cbe80..52ead329f 100644 --- a/src/common/logger.py +++ b/src/common/logger.py @@ -638,6 +638,12 @@ DEFAULT_MODULE_COLORS = { "context_web": "#5F5F00", # 深黄色 "gift_manager": "#D7005F", # 粉红色 "prompt": "#875FFF", # 紫色(mais4u的prompt) + # Kokoro Flow Chatter (KFC) 系统 + "kfc_planner": "#b19cd9", # 淡紫色 - KFC 规划器 + "kfc_replyer": "#b19cd9", # 淡紫色 - KFC 回复器 + "kfc_chatter": "#b19cd9", # 淡紫色 - KFC 主模块 + "kfc_unified": "#d7afff", # 柔和紫色 - KFC 统一模式 + "kfc_proactive_thinker": "#d7afff", # 柔和紫色 - KFC 主动思考器 "super_chat_manager": "#AF005F", # 紫红色 "watching": "#AF5F5F", # 深橙色 "offline_llm": "#303030", # 深灰色 @@ -682,6 +688,7 @@ DEFAULT_MODULE_COLORS = { "kfc_session_manager": "#87D787", # 绿色 - 会话管理 "kfc_scheduler": "#D787AF", # 柔和粉色 - 调度器 "kfc_post_processor": "#5F87FF", # 蓝色 - 后处理 + "kfc_unified": "#FF5FAF", # 粉色 - 统一模式 } DEFAULT_MODULE_ALIASES = { @@ -818,6 +825,7 @@ DEFAULT_MODULE_ALIASES = { "kfc_session_manager": "KFC会话", "kfc_scheduler": "KFC调度", "kfc_post_processor": "KFC后处理", + "kfc_unified": "KFC统一模式", } diff --git a/src/plugins/built_in/kokoro_flow_chatter/__init__.py b/src/plugins/built_in/kokoro_flow_chatter/__init__.py index 1d3820f2e..c5654e84e 100644 --- a/src/plugins/built_in/kokoro_flow_chatter/__init__.py +++ b/src/plugins/built_in/kokoro_flow_chatter/__init__.py @@ -1,7 +1,13 @@ """ -Kokoro Flow Chatter - 私聊特化的心流聊天器 +Kokoro Flow Chatter (KFC) - 私聊特化的心流聊天器 -重构版本,核心设计理念: +重构版本,支持双模式架构: + +工作模式: +- unified(统一模式): 单次 LLM 调用完成思考和回复生成(默认) +- split(分离模式): Planner + Replyer 两次 LLM 调用,更精细的控制 + +核心设计理念: 1. Chatter 职责极简化:只负责"收到消息 → 规划执行" 2. Session 状态简化:只有 IDLE 和 WAITING 两种状态 3. 独立的 Replyer:专属的提示词构建和 LLM 交互 @@ -21,6 +27,7 @@ from .session import KokoroSession, SessionManager, get_session_manager from .chatter import KokoroFlowChatter from .planner import generate_plan from .replyer import generate_reply_text +from .unified import generate_unified_response from .proactive_thinker import ( ProactiveThinker, get_proactive_thinker, @@ -28,6 +35,7 @@ from .proactive_thinker import ( stop_proactive_thinker, ) from .config import ( + KFCMode, KokoroFlowChatterConfig, get_config, load_config, @@ -38,11 +46,11 @@ from src.plugin_system.base.plugin_metadata import PluginMetadata __plugin_meta__ = PluginMetadata( name="Kokoro Flow Chatter", - description="专为私聊设计的深度情感交互处理器,实现心理状态驱动的对话体验", - usage="在私聊场景中自动启用,可通过 [kokoro_flow_chatter].enable 配置开关", - version="2.0.0", + description="专为私聊设计的深度情感交互处理器,支持统一/分离双模式", + usage="在私聊场景中自动启用,可通过 [kokoro_flow_chatter].enable 和 .mode 配置", + version="3.1.0", author="MoFox", - keywords=["chatter", "kokoro", "private", "emotional", "narrative"], + keywords=["chatter", "kokoro", "private", "emotional", "narrative", "dual-mode"], categories=["Chat", "AI", "Emotional"], extra={"is_built_in": True, "chat_type": "private"}, ) @@ -63,12 +71,14 @@ __all__ = [ "KokoroFlowChatter", "generate_plan", "generate_reply_text", + "generate_unified_response", # Proactive Thinker "ProactiveThinker", "get_proactive_thinker", "start_proactive_thinker", "stop_proactive_thinker", # Config + "KFCMode", "KokoroFlowChatterConfig", "get_config", "load_config", diff --git a/src/plugins/built_in/kokoro_flow_chatter/chatter.py b/src/plugins/built_in/kokoro_flow_chatter/chatter.py index 6c2b0e802..2ec77aed2 100644 --- a/src/plugins/built_in/kokoro_flow_chatter/chatter.py +++ b/src/plugins/built_in/kokoro_flow_chatter/chatter.py @@ -1,11 +1,19 @@ """ Kokoro Flow Chatter - Chatter 主类 -极简设计,只负责: -1. 收到消息 -2. 调用 Planner 生成规划 -3. 执行动作(回复在 Action.execute() 中生成) -4. 更新 Session +支持两种工作模式: +1. unified(统一模式): 单次 LLM 调用完成思考 + 回复生成 +2. split(分离模式): Planner + Replyer 两次 LLM 调用 + +核心设计: +- Chatter 只负责 "收到消息 → 规划执行" 的流程 +- 无论 Session 之前是什么状态,流程都一样 +- 区别只体现在提示词中 + +不负责: +- 等待超时处理(由 ProactiveThinker 负责) +- 连续思考(由 ProactiveThinker 负责) +- 主动发起对话(由 ProactiveThinker 负责) """ import asyncio @@ -18,8 +26,8 @@ from src.common.logger import get_logger from src.plugin_system.base.base_chatter import BaseChatter from src.plugin_system.base.component_types import ChatType +from .config import KFCMode, get_config from .models import SessionStatus -from .planner import generate_plan from .session import get_session_manager if TYPE_CHECKING: @@ -27,15 +35,15 @@ if TYPE_CHECKING: logger = get_logger("kfc_chatter") -# 控制台颜色 -SOFT_PURPLE = "\033[38;5;183m" -RESET = "\033[0m" - class KokoroFlowChatter(BaseChatter): """ Kokoro Flow Chatter - 私聊特化的心流聊天器 + 支持两种工作模式(通过配置切换): + - unified: 单次 LLM 调用完成思考和回复 + - split: Planner + Replyer 两次 LLM 调用 + 核心设计: - Chatter 只负责 "收到消息 → 规划执行" 的流程 - 无论 Session 之前是什么状态,流程都一样 @@ -62,18 +70,24 @@ class KokoroFlowChatter(BaseChatter): # 核心组件 self.session_manager = get_session_manager() + # 加载配置 + self._config = get_config() + self._mode = self._config.mode + # 并发控制 self._lock = asyncio.Lock() self._processing = False # 统计 - self._stats = { + self._stats: dict[str, Any] = { "messages_processed": 0, "successful_responses": 0, "failed_responses": 0, } - logger.info(f"{SOFT_PURPLE}[KFC]{RESET} 初始化完成: stream_id={stream_id}") + # 输出初始化信息 + mode_str = "统一模式" if self._mode == KFCMode.UNIFIED else "分离模式" + logger.info(f"初始化完成 (模式: {mode_str}): stream_id={stream_id}") async def execute(self, context: StreamContext) -> dict: """ @@ -84,7 +98,7 @@ class KokoroFlowChatter(BaseChatter): 2. 获取未读消息 3. 记录用户消息到 mental_log 4. 确定 situation_type(根据之前的等待状态) - 5. 调用 Replyer 生成响应 + 5. 根据模式调用对应的生成器 6. 执行动作 7. 更新 Session(记录 Bot 规划,设置等待状态) 8. 保存 Session @@ -115,7 +129,6 @@ class KokoroFlowChatter(BaseChatter): situation_type = self._determine_situation_type(session) # 5. **立即**结束等待状态,防止 ProactiveThinker 并发处理 - # 在调用 LLM 之前就结束等待,避免 ProactiveThinker 检测到超时后也开始处理 if session.status == SessionStatus.WAITING: session.end_waiting() await self.session_manager.save_session(user_id) @@ -143,25 +156,26 @@ class KokoroFlowChatter(BaseChatter): # 8. 获取聊天流 chat_stream = await self._get_chat_stream() - # 9. 调用 Planner 生成行动计划 - plan_response = await generate_plan( - session=session, - user_name=user_name, - situation_type=situation_type, - chat_stream=chat_stream, - available_actions=available_actions, - ) + # 9. 根据模式调用对应的生成器 + if self._mode == KFCMode.UNIFIED: + plan_response = await self._execute_unified_mode( + session=session, + user_name=user_name, + situation_type=situation_type, + chat_stream=chat_stream, + available_actions=available_actions, + ) + else: + plan_response = await self._execute_split_mode( + session=session, + user_name=user_name, + user_id=user_id, + situation_type=situation_type, + chat_stream=chat_stream, + available_actions=available_actions, + ) - # 10. 为 kfc_reply 动作注入必要的上下文信息 - for action in plan_response.actions: - if action.type == "kfc_reply": - # 注入回复生成所需的上下文 - action.params["user_id"] = user_id - action.params["user_name"] = user_name - action.params["thought"] = plan_response.thought - action.params["situation_type"] = situation_type - - # 11. 执行动作(回复生成在 Action.execute() 中完成) + # 10. 执行动作 exec_results = [] has_reply = False for action in plan_response.actions: @@ -178,7 +192,7 @@ class KokoroFlowChatter(BaseChatter): if result.get("success") and action.type in ("kfc_reply", "respond"): has_reply = True - # 12. 记录 Bot 规划到 mental_log + # 11. 记录 Bot 规划到 mental_log session.add_bot_planning( thought=plan_response.thought, actions=[a.to_dict() for a in plan_response.actions], @@ -186,7 +200,7 @@ class KokoroFlowChatter(BaseChatter): max_wait_seconds=plan_response.max_wait_seconds, ) - # 13. 更新 Session 状态 + # 12. 更新 Session 状态 if plan_response.max_wait_seconds > 0: session.start_waiting( expected_reaction=plan_response.expected_reaction, @@ -195,20 +209,22 @@ class KokoroFlowChatter(BaseChatter): else: session.end_waiting() - # 14. 标记消息为已读 + # 13. 标记消息为已读 for msg in unread_messages: context.mark_message_as_read(str(msg.message_id)) - # 15. 保存 Session + # 14. 保存 Session await self.session_manager.save_session(user_id) - # 16. 更新统计 + # 15. 更新统计 self._stats["messages_processed"] += len(unread_messages) if has_reply: self._stats["successful_responses"] += 1 + # 输出完成信息 + mode_str = "unified" if self._mode == KFCMode.UNIFIED else "split" logger.info( - f"{SOFT_PURPLE}[KFC]{RESET} 处理完成: " + f"处理完成 ({mode_str}): " f"user={user_name}, situation={situation_type}, " f"actions={[a.type for a in plan_response.actions]}, " f"wait={plan_response.max_wait_seconds}s" @@ -220,6 +236,7 @@ class KokoroFlowChatter(BaseChatter): has_reply=has_reply, thought=plan_response.thought, situation_type=situation_type, + mode=mode_str, ) except Exception as e: @@ -232,6 +249,68 @@ class KokoroFlowChatter(BaseChatter): finally: self._processing = False + async def _execute_unified_mode( + self, + session, + user_name: str, + situation_type: str, + chat_stream, + available_actions, + ): + """ + 统一模式:单次 LLM 调用完成思考 + 回复生成 + + LLM 输出的 JSON 中 kfc_reply 动作已包含 content 字段, + 无需再调用 Replyer 生成回复。 + """ + from .unified import generate_unified_response + + plan_response = await generate_unified_response( + session=session, + user_name=user_name, + situation_type=situation_type, + chat_stream=chat_stream, + available_actions=available_actions, + ) + + # 统一模式下 content 已经在 actions 中,无需注入 + return plan_response + + async def _execute_split_mode( + self, + session, + user_name: str, + user_id: str, + situation_type: str, + chat_stream, + available_actions, + ): + """ + 分离模式:Planner + Replyer 两次 LLM 调用 + + 1. Planner 生成行动计划(JSON,kfc_reply 不含 content) + 2. 为 kfc_reply 动作注入上下文,由 Action.execute() 调用 Replyer 生成回复 + """ + from .planner import generate_plan + + plan_response = await generate_plan( + session=session, + user_name=user_name, + situation_type=situation_type, + chat_stream=chat_stream, + available_actions=available_actions, + ) + + # 为 kfc_reply 动作注入回复生成所需的上下文 + for action in plan_response.actions: + if action.type == "kfc_reply": + action.params["user_id"] = user_id + action.params["user_name"] = user_name + action.params["thought"] = plan_response.thought + action.params["situation_type"] = situation_type + + return plan_response + def _determine_situation_type(self, session) -> str: """ 确定当前情况类型 @@ -282,9 +361,16 @@ class KokoroFlowChatter(BaseChatter): def get_stats(self) -> dict[str, Any]: """获取统计信息""" - return self._stats.copy() + stats = self._stats.copy() + stats["mode"] = self._mode.value + return stats @property def is_processing(self) -> bool: """是否正在处理""" return self._processing + + @property + def mode(self) -> KFCMode: + """当前工作模式""" + return self._mode diff --git a/src/plugins/built_in/kokoro_flow_chatter/config.py b/src/plugins/built_in/kokoro_flow_chatter/config.py index d5f836ec6..cba8669a4 100644 --- a/src/plugins/built_in/kokoro_flow_chatter/config.py +++ b/src/plugins/built_in/kokoro_flow_chatter/config.py @@ -2,12 +2,39 @@ Kokoro Flow Chatter - 配置 可以通过 TOML 配置文件覆盖默认值 + +支持两种工作模式: +- unified: 统一模式,单次 LLM 调用完成思考和回复生成(类似旧版架构) +- split: 分离模式,Planner + Replyer 两次 LLM 调用(推荐,更精细的控制) """ from dataclasses import dataclass, field +from enum import Enum from typing import List, Optional +class KFCMode(str, Enum): + """KFC 工作模式""" + + # 统一模式:单次 LLM 调用,生成思考 + 回复(类似旧版架构) + UNIFIED = "unified" + + # 分离模式:Planner 生成规划,Replyer 生成回复(推荐) + SPLIT = "split" + + @classmethod + def from_str(cls, value: str) -> "KFCMode": + """从字符串创建模式""" + value = value.lower().strip() + if value == "unified": + return cls.UNIFIED + elif value == "split": + return cls.SPLIT + else: + # 默认使用统一模式 + return cls.UNIFIED + + @dataclass class WaitingDefaults: """等待配置默认值""" @@ -46,12 +73,6 @@ class ProactiveConfig: # 关系门槛:最低好感度,达到此值才会主动关心 min_affinity_for_proactive: float = 0.3 - - # 是否启用早安问候 - enable_morning_greeting: bool = True - - # 是否启用晚安问候 - enable_night_greeting: bool = True @dataclass @@ -109,6 +130,11 @@ class KokoroFlowChatterConfig: # 是否启用 enabled: bool = True + # 工作模式:unified(统一模式)或 split(分离模式) + # - unified: 单次 LLM 调用完成思考和回复生成(类似旧版架构,更简洁) + # - split: Planner + Replyer 两次 LLM 调用(更精细的控制,推荐) + mode: KFCMode = KFCMode.UNIFIED + # 启用的消息源类型(空列表表示全部) enabled_stream_types: List[str] = field(default_factory=lambda: ["private"]) @@ -165,6 +191,10 @@ def load_config() -> KokoroFlowChatterConfig: if hasattr(kfc_cfg, 'debug'): config.debug = kfc_cfg.debug + # 工作模式配置 + if hasattr(kfc_cfg, 'mode'): + config.mode = KFCMode.from_str(str(kfc_cfg.mode)) + # 等待配置 if hasattr(kfc_cfg, 'waiting'): wait_cfg = kfc_cfg.waiting @@ -188,8 +218,6 @@ def load_config() -> KokoroFlowChatterConfig: quiet_hours_end=getattr(pro_cfg, 'quiet_hours_end', "07:00"), trigger_probability=getattr(pro_cfg, 'trigger_probability', 0.3), min_affinity_for_proactive=getattr(pro_cfg, 'min_affinity_for_proactive', 0.3), - enable_morning_greeting=getattr(pro_cfg, 'enable_morning_greeting', True), - enable_night_greeting=getattr(pro_cfg, 'enable_night_greeting', True), ) # 提示词配置 diff --git a/src/plugins/built_in/kokoro_flow_chatter/planner.py b/src/plugins/built_in/kokoro_flow_chatter/planner.py index 8ffd4609d..1fa7bfcfc 100644 --- a/src/plugins/built_in/kokoro_flow_chatter/planner.py +++ b/src/plugins/built_in/kokoro_flow_chatter/planner.py @@ -102,11 +102,12 @@ def _parse_response(raw_response: str) -> LLMResponse: response = LLMResponse.from_dict(data) if response.thought: - logger.info( - f"[KFC Planner] 解析成功: thought={response.thought[:50]}..., " - f"actions={[a.type for a in response.actions]}" - ) + # 使用 logger 输出美化日志(颜色通过 logger 系统配置) + logger.info(f"💭 {response.thought}") + + actions_str = ", ".join(a.type for a in response.actions) + logger.debug(f"actions={actions_str}") else: - logger.warning("[KFC Planner] 响应缺少 thought") + logger.warning("响应缺少 thought") return response diff --git a/src/plugins/built_in/kokoro_flow_chatter/proactive_thinker.py b/src/plugins/built_in/kokoro_flow_chatter/proactive_thinker.py index 0cee53028..297c940a3 100644 --- a/src/plugins/built_in/kokoro_flow_chatter/proactive_thinker.py +++ b/src/plugins/built_in/kokoro_flow_chatter/proactive_thinker.py @@ -7,6 +7,10 @@ Kokoro Flow Chatter - 主动思考器 3. 长期沉默后主动发起对话 通过 UnifiedScheduler 定期触发,与 Chatter 解耦 + +支持两种工作模式(与 Chatter 保持一致): +- unified: 单次 LLM 调用完成思考和回复 +- split: Planner + Replyer 两次 LLM 调用 """ import asyncio @@ -20,9 +24,8 @@ from src.common.logger import get_logger from src.config.config import global_config from src.plugin_system.apis.unified_scheduler import TriggerType, unified_scheduler +from .config import KFCMode, get_config from .models import EventType, SessionStatus -from .planner import generate_plan -from .replyer import _clean_reply_text from .session import KokoroSession, get_session_manager if TYPE_CHECKING: @@ -44,6 +47,10 @@ class ProactiveThinker: - 定期检查所有 WAITING 状态的 Session - 触发连续思考或超时决策 - 定期检查长期沉默的 Session,考虑主动发起 + + 支持两种工作模式(与 Chatter 保持一致): + - unified: 单次 LLM 调用 + - split: Planner + Replyer 两次调用 """ # 连续思考触发点(等待进度百分比) @@ -74,11 +81,12 @@ class ProactiveThinker: def _load_config(self) -> None: """加载配置 - 使用统一的配置系统""" - from .config import get_config - config = get_config() proactive_cfg = config.proactive + # 工作模式 + self._mode = config.mode + # 等待检查间隔(秒) self.waiting_check_interval = 15.0 # 主动思考检查间隔(秒) @@ -92,13 +100,11 @@ class ProactiveThinker: self.quiet_hours_end = proactive_cfg.quiet_hours_end self.trigger_probability = proactive_cfg.trigger_probability self.min_affinity_for_proactive = proactive_cfg.min_affinity_for_proactive - self.enable_morning_greeting = proactive_cfg.enable_morning_greeting - self.enable_night_greeting = proactive_cfg.enable_night_greeting async def start(self) -> None: """启动主动思考器""" if self._running: - logger.warning("[ProactiveThinker] 已在运行中") + logger.info("已在运行中") return self._running = True @@ -324,6 +330,7 @@ class ProactiveThinker: return self._get_fallback_thought(elapsed_minutes, progress) # 使用统一的文本清理函数 + from .replyer import _clean_reply_text thought = _clean_reply_text(raw_response) logger.debug(f"[ProactiveThinker] LLM 生成等待想法 (model={model_name}): {thought[:50]}...") @@ -369,7 +376,7 @@ class ProactiveThinker: return None async def _handle_timeout(self, session: KokoroSession) -> None: - """处理等待超时""" + """处理等待超时 - 支持双模式""" self._stats["timeout_decisions"] += 1 # 再次检查 Session 状态,防止在等待过程中被 Chatter 处理 @@ -385,7 +392,7 @@ class ProactiveThinker: ) return - logger.info(f"[ProactiveThinker] 等待超时: user={session.user_id}") + logger.info(f"等待超时: user={session.user_id}") try: # 获取用户名 @@ -403,22 +410,35 @@ class ProactiveThinker: action_modifier = ActionModifier(action_manager, session.stream_id) await action_modifier.modify_actions(chatter_name="KokoroFlowChatter") - # 调用 Planner 生成超时决策 - plan_response = await generate_plan( - session=session, - user_name=user_name, - situation_type="timeout", - chat_stream=chat_stream, - available_actions=action_manager.get_using_actions(), - ) - - # 为 kfc_reply 动作注入必要的上下文信息 - for action in plan_response.actions: - if action.type == "kfc_reply": - action.params["user_id"] = session.user_id - action.params["user_name"] = user_name - action.params["thought"] = plan_response.thought - action.params["situation_type"] = "timeout" + # 根据模式选择生成方式 + if self._mode == KFCMode.UNIFIED: + # 统一模式:单次 LLM 调用 + from .unified import generate_unified_response + plan_response = await generate_unified_response( + session=session, + user_name=user_name, + situation_type="timeout", + chat_stream=chat_stream, + available_actions=action_manager.get_using_actions(), + ) + else: + # 分离模式:Planner + Replyer + from .planner import generate_plan + plan_response = await generate_plan( + session=session, + user_name=user_name, + situation_type="timeout", + chat_stream=chat_stream, + available_actions=action_manager.get_using_actions(), + ) + + # 分离模式下需要注入上下文信息 + for action in plan_response.actions: + if action.type == "kfc_reply": + action.params["user_id"] = session.user_id + action.params["user_name"] = user_name + action.params["thought"] = plan_response.thought + action.params["situation_type"] = "timeout" # 执行动作(回复生成在 Action.execute() 中完成) for action in plan_response.actions: @@ -539,7 +559,7 @@ class ProactiveThinker: session: KokoroSession, trigger_reason: str, ) -> None: - """处理主动思考""" + """处理主动思考 - 支持双模式""" self._stats["proactive_triggered"] += 1 # 再次检查最近活动时间,防止与 Chatter 并发 @@ -550,7 +570,7 @@ class ProactiveThinker: ) return - logger.info(f"[ProactiveThinker] 主动思考触发: user={session.user_id}, reason={trigger_reason}") + logger.info(f"主动思考触发: user={session.user_id}, reason={trigger_reason}") try: # 获取用户名 @@ -580,15 +600,29 @@ class ProactiveThinker: "silence_duration": silence_duration, } - # 调用 Planner - plan_response = await generate_plan( - session=session, - user_name=user_name, - situation_type="proactive", - chat_stream=chat_stream, - available_actions=action_manager.get_using_actions(), - extra_context=extra_context, - ) + # 根据模式选择生成方式 + if self._mode == KFCMode.UNIFIED: + # 统一模式:单次 LLM 调用 + from .unified import generate_unified_response + plan_response = await generate_unified_response( + session=session, + user_name=user_name, + situation_type="proactive", + chat_stream=chat_stream, + available_actions=action_manager.get_using_actions(), + extra_context=extra_context, + ) + else: + # 分离模式:Planner + Replyer + from .planner import generate_plan + plan_response = await generate_plan( + session=session, + user_name=user_name, + situation_type="proactive", + chat_stream=chat_stream, + available_actions=action_manager.get_using_actions(), + extra_context=extra_context, + ) # 检查是否决定不打扰 is_do_nothing = ( @@ -597,19 +631,20 @@ class ProactiveThinker: ) if is_do_nothing: - logger.info(f"[ProactiveThinker] 决定不打扰: user={session.user_id}") + logger.info(f"决定不打扰: user={session.user_id}") session.last_proactive_at = time.time() await self.session_manager.save_session(session.user_id) return - # 为 kfc_reply 动作注入必要的上下文信息 - for action in plan_response.actions: - if action.type == "kfc_reply": - action.params["user_id"] = session.user_id - action.params["user_name"] = user_name - action.params["thought"] = plan_response.thought - action.params["situation_type"] = "proactive" - action.params["extra_context"] = extra_context + # 分离模式下需要注入上下文信息 + if self._mode == KFCMode.SPLIT: + for action in plan_response.actions: + if action.type == "kfc_reply": + action.params["user_id"] = session.user_id + action.params["user_name"] = user_name + action.params["thought"] = plan_response.thought + action.params["situation_type"] = "proactive" + action.params["extra_context"] = extra_context # 执行动作(回复生成在 Action.execute() 中完成) for action in plan_response.actions: diff --git a/src/plugins/built_in/kokoro_flow_chatter/prompt/builder.py b/src/plugins/built_in/kokoro_flow_chatter/prompt/builder.py index 94ed55263..782b883c5 100644 --- a/src/plugins/built_in/kokoro_flow_chatter/prompt/builder.py +++ b/src/plugins/built_in/kokoro_flow_chatter/prompt/builder.py @@ -826,6 +826,202 @@ class PromptBuilder: user_name=user_name, target_message=target_message or "(无消息内容)", ) + + async def build_unified_prompt( + self, + session: KokoroSession, + user_name: str, + situation_type: str = "new_message", + chat_stream: Optional["ChatStream"] = None, + available_actions: Optional[dict] = None, + extra_context: Optional[dict] = None, + ) -> str: + """ + 构建统一模式提示词(单次 LLM 调用完成思考 + 回复生成) + + 与 planner_prompt 的区别: + - 使用完整的输出格式(要求填写 content 字段) + - 不使用分离的 replyer 提示词 + + Args: + session: 会话对象 + user_name: 用户名称 + situation_type: 情况类型 + chat_stream: 聊天流对象 + available_actions: 可用动作字典 + extra_context: 额外上下文 + + Returns: + 完整的统一模式提示词 + """ + extra_context = extra_context or {} + + # 获取 user_id + user_id = session.user_id if session else None + + # 1. 构建人设块 + persona_block = self._build_persona_block() + + # 2. 使用 context_builder 获取关系、记忆、表达习惯等 + context_data = await self._build_context_data(user_name, chat_stream, user_id) + relation_block = context_data.get("relation_info", f"你与 {user_name} 还不太熟悉,这是早期的交流阶段。") + memory_block = context_data.get("memory_block", "") + expression_habits = self._build_combined_expression_block(context_data.get("expression_habits", "")) + + # 3. 构建活动流 + activity_stream = await self._build_activity_stream(session, user_name) + + # 4. 构建当前情况 + current_situation = await self._build_current_situation( + session, user_name, situation_type, extra_context + ) + + # 5. 构建聊天历史总览 + chat_history_block = await self._build_chat_history_block(chat_stream) + + # 6. 构建可用动作(统一模式强调需要填写 content) + actions_block = self._build_unified_actions_block(available_actions) + + # 7. 获取统一模式输出格式(要求填写 content) + output_format = await self._get_unified_output_format() + + # 8. 使用统一的 prompt 管理系统格式化 + prompt = await global_prompt_manager.format_prompt( + PROMPT_NAMES["main"], + user_name=user_name, + persona_block=persona_block, + relation_block=relation_block, + memory_block=memory_block or "(暂无相关记忆)", + expression_habits=expression_habits or "(根据自然对话风格回复即可)", + activity_stream=activity_stream or "(这是你们第一次聊天)", + current_situation=current_situation, + chat_history_block=chat_history_block, + available_actions=actions_block, + output_format=output_format, + ) + + return prompt + + def _build_unified_actions_block(self, available_actions: Optional[dict]) -> str: + """ + 构建统一模式的可用动作块 + + 与 _build_actions_block 的区别: + - 强调 kfc_reply 需要填写 content 字段 + """ + if not available_actions: + return self._get_unified_default_actions_block() + + action_blocks = [] + for action_name, action_info in available_actions.items(): + block = self._format_unified_action(action_name, action_info) + if block: + action_blocks.append(block) + + return "\n".join(action_blocks) if action_blocks else self._get_unified_default_actions_block() + + def _format_unified_action(self, action_name: str, action_info) -> str: + """格式化统一模式的单个动作""" + description = getattr(action_info, "description", "") or f"执行 {action_name}" + action_require = getattr(action_info, "action_require", []) or [] + require_text = "\n".join(f" - {req}" for req in action_require) if action_require else " - 根据情况使用" + + # 统一模式要求 kfc_reply 必须填写 content + if action_name == "kfc_reply": + return f"""### {action_name} +**描述**: {description} + +**使用场景**: +{require_text} + +**示例**: +```json +{{ + "type": "{action_name}", + "content": "你要说的话(必填)" +}} +``` +""" + else: + action_parameters = getattr(action_info, "action_parameters", {}) or {} + params_example = self._build_params_example(action_parameters) + + return f"""### {action_name} +**描述**: {description} + +**使用场景**: +{require_text} + +**示例**: +```json +{{ + "type": "{action_name}", + {params_example} +}} +``` +""" + + def _get_unified_default_actions_block(self) -> str: + """获取统一模式的默认动作列表""" + return """### kfc_reply +**描述**: 发送回复消息 + +**使用场景**: + - 需要回复对方消息时使用 + +**示例**: +```json +{ + "type": "kfc_reply", + "content": "你要说的话(必填)" +} +``` + + +### do_nothing +**描述**: 什么都不做 + +**使用场景**: + - 当前不需要回应时使用 + +**示例**: +```json +{ + "type": "do_nothing" +} +```""" + + async def _get_unified_output_format(self) -> str: + """获取统一模式的输出格式模板""" + try: + prompt = await global_prompt_manager.get_prompt_async( + PROMPT_NAMES["unified_output_format"] + ) + return prompt.template + except KeyError: + # 如果模板未注册,返回默认格式 + return """请用以下 JSON 格式回复: +```json +{ + "thought": "你脑子里在想什么,越自然越好", + "actions": [ + {"type": "kfc_reply", "content": "你的回复内容"} + ], + "expected_reaction": "你期待对方的反应是什么", + "max_wait_seconds": 等待时间(秒),0 表示不等待 +} +``` + +### 字段说明 +- `thought`:你的内心独白,记录你此刻的想法和感受。要自然,不要技术性语言。 +- `actions`:你要执行的动作列表。对于 `kfc_reply` 动作,**必须**填写 `content` 字段,写上你要说的话。 +- `expected_reaction`:你期待对方如何回应(用于判断是否需要等待) +- `max_wait_seconds`:设定等待时间(秒),0 表示不等待,超时后你会考虑是否要主动说点什么 + +### 注意事项 +- kfc_reply 的 content 字段是必填的,直接写你要发送的消息内容 +- 即使什么都不想做,也放一个 `{"type": "do_nothing"}` +- 可以组合多个动作,比如先发消息再发表情""" # 全局单例 diff --git a/src/plugins/built_in/kokoro_flow_chatter/prompt/prompts.py b/src/plugins/built_in/kokoro_flow_chatter/prompt/prompts.py index 80b1d29e9..7dc05e556 100644 --- a/src/plugins/built_in/kokoro_flow_chatter/prompt/prompts.py +++ b/src/plugins/built_in/kokoro_flow_chatter/prompt/prompts.py @@ -361,11 +361,42 @@ kfc_WAITING_THOUGHT = Prompt( 现在,请直接输出你等待时的内心想法:""", ) +# ================================================================================================= +# 统一模式输出格式(单次 LLM 调用,要求填写 content) +# ================================================================================================= + +kfc_UNIFIED_OUTPUT_FORMAT = Prompt( + name="kfc_unified_output_format", + template="""请用以下 JSON 格式回复: +```json +{{ + "thought": "你脑子里在想什么,越自然越好", + "actions": [ + {{"type": "kfc_reply", "content": "你的回复内容"}} + ], + "expected_reaction": "你期待对方的反应是什么", + "max_wait_seconds": 等待时间(秒),0 表示不等待 +}} +``` + +### 字段说明 +- `thought`:你的内心独白,记录你此刻的想法和感受。要自然,不要技术性语言。 +- `actions`:你要执行的动作列表。对于 `kfc_reply` 动作,**必须**填写 `content` 字段,写上你要说的话。 +- `expected_reaction`:你期待对方如何回应(用于判断是否需要等待) +- `max_wait_seconds`:设定等待时间(秒),0 表示不等待,超时后你会考虑是否要主动说点什么。如果你认为聊天没有继续的必要,或不想打扰对方,可以设为 0。 + +### 注意事项 +- kfc_reply 的 content 字段是**必填**的,直接写你要发送的消息内容 +- 即使什么都不想做,也放一个 `{{"type": "do_nothing"}}` +- 可以组合多个动作,比如先发消息再发表情""", +) + # 导出所有模板名称,方便外部引用 PROMPT_NAMES = { "main": "kfc_main", "output_format": "kfc_output_format", "planner_output_format": "kfc_planner_output_format", + "unified_output_format": "kfc_unified_output_format", "replyer": "kfc_replyer", "replyer_context_normal": "kfc_replyer_context_normal", "replyer_context_in_time": "kfc_replyer_context_in_time", diff --git a/src/plugins/built_in/kokoro_flow_chatter/prompt_modules_unified.py b/src/plugins/built_in/kokoro_flow_chatter/prompt_modules_unified.py new file mode 100644 index 000000000..c7880d93e --- /dev/null +++ b/src/plugins/built_in/kokoro_flow_chatter/prompt_modules_unified.py @@ -0,0 +1,607 @@ +""" +Kokoro Flow Chatter - 统一模式提示词模块 + +为统一模式(Unified Mode)提供模块化的提示词构建: +1. 核心身份模块 - 人设/人格/背景 +2. 行为准则模块 - 规则/边界 +3. 情境上下文模块 - 时间/场景/内在状态/关系/记忆 +4. 动作能力模块 - 可用动作的描述 +5. 输出格式模块 - 表达风格 + JSON格式 + +设计理念: +- 每个模块只负责自己的部分,互不干扰 +- 回复相关内容(人设、上下文)与动作定义分离 +- 方便独立调试和优化每个部分 + +注意:此模块仅用于统一模式,分离模式使用 prompt/builder.py +""" + +import time +from datetime import datetime +from typing import TYPE_CHECKING, Optional + +from src.common.logger import get_logger +from src.config.config import global_config +from src.plugin_system.base.component_types import ActionInfo + +if TYPE_CHECKING: + from src.chat.message_receive.chat_stream import ChatStream + from src.common.data_models.message_manager_data_model import StreamContext + +from .models import MentalLogEntry, EventType +from .session import KokoroSession + +logger = get_logger("kfc_prompt_unified") + + +# ============================================================ +# 模块1: 核心身份 - 人设/人格/背景 +# ============================================================ + +def build_identity_module() -> str: + """ + 构建核心身份模块 + + 包含:人格核心、人格细节、外貌身份、世界观背景 + 这些是回复内容的基础,决定了"你是谁" + """ + if global_config is None: + raise RuntimeError("global_config 未初始化") + + personality_cfg = global_config.personality + + personality_core = personality_cfg.personality_core or "你是一个温暖、真诚的对话伙伴。" + personality_side = personality_cfg.personality_side or "" + identity = personality_cfg.identity or "" + background_story = personality_cfg.background_story or "" + + parts = [personality_core] + + if personality_side: + parts.append(f"\n{personality_side}") + + if identity: + parts.append(f"\n{identity}") + + if background_story: + parts.append(f"\n### 背景故事\n{background_story}") + + return "\n".join(parts) + + +# ============================================================ +# 模块2: 行为准则 - 规则/边界/KFC特有准则 +# ============================================================ + +def build_rules_module() -> str: + """ + 构建行为准则模块 + + 包含:安全规则 + KFC 通用准则 + 这些是行为的边界,决定了"什么能做什么不能做" + """ + if global_config is None: + raise RuntimeError("global_config 未初始化") + + safety_guidelines = global_config.personality.safety_guidelines or [] + if isinstance(safety_guidelines, list): + safety_guidelines_str = "\n".join(f"- {rule}" for rule in safety_guidelines) + else: + safety_guidelines_str = str(safety_guidelines) + + # KFC 通用准则 + kfc_rules = """- 说话要自然,像平时发微信一样 +- 有自己的边界感,不用无条件迎合 +- 称呼对方用自然的昵称,别念复杂的用户名 +- 不要模仿聊天记录里的系统格式(比如"[表情包:xxx]"这种是系统标记,要发送表情包请使用emoji动作)""" + + return f"""{safety_guidelines_str} + +{kfc_rules}""" + + +# ============================================================ +# 模块3: 情境上下文 - 时间/场景/内在状态/关系/记忆 +# ============================================================ + +def build_context_module( + session: KokoroSession, + chat_stream: Optional["ChatStream"] = None, + context_data: Optional[dict[str, str]] = None, +) -> str: + """ + 构建情境上下文模块 + + 包含:当前时间、聊天场景、内在状态、关系信息、记忆 + 这些是回复的上下文,决定了"当前在什么情况下" + + Args: + session: 当前会话 + chat_stream: 聊天流(判断群聊/私聊) + context_data: S4U 上下文数据 + """ + context_data = context_data or {} + + # 时间和场景 + current_time = datetime.now().strftime("%Y年%m月%d日 %H:%M:%S") + is_group_chat = bool(chat_stream and chat_stream.group_info) + chat_scene = "你在群里聊天" if is_group_chat else "你在和对方私聊" + + # 日程(如果有) + schedule_block = context_data.get("schedule", "") + + # 内在状态(从 context_data 获取,如果没有使用默认值) + mood = context_data.get("mood", "平静") + + # 关系信息 + relation_info = context_data.get("relation_info", "") + + # 记忆 + memory_block = context_data.get("memory_block", "") + + parts = [] + + # 时间和场景 + parts.append(f"**时间**: {current_time}") + parts.append(f"**场景**: {chat_scene}") + + # 日程块 + if schedule_block: + parts.append(f"{schedule_block}") + + # 内在状态 + parts.append(f"\n你现在的心情:{mood}") + + # 关系信息 + if relation_info: + parts.append(f"\n## 4. 你和对方的关系\n{relation_info}") + + # 记忆 + if memory_block: + parts.append(f"\n{memory_block}") + + return "\n".join(parts) + + +# ============================================================ +# 模块4: 动作能力 - 可用动作的描述 +# ============================================================ + +def build_actions_module(available_actions: Optional[dict[str, ActionInfo]] = None) -> str: + """ + 构建动作能力模块 + + 包含:所有可用动作的描述、参数、示例 + 这部分与回复内容分离,只描述"能做什么" + + Args: + available_actions: 可用动作字典 + """ + if not available_actions: + return _get_default_actions_block() + + action_blocks = [] + + for action_name, action_info in available_actions.items(): + description = action_info.description or f"执行 {action_name}" + + # 构建动作块 + action_block = f"### `{action_name}` - {description}" + + # 参数说明(如果有) + if action_info.action_parameters: + params_lines = [f" - `{name}`: {desc}" for name, desc in action_info.action_parameters.items()] + action_block += f"\n参数:\n{chr(10).join(params_lines)}" + + # 使用场景(如果有) + if action_info.action_require: + require_lines = [f" - {req}" for req in action_info.action_require] + action_block += f"\n使用场景:\n{chr(10).join(require_lines)}" + + # 示例 + example_params = "" + if action_info.action_parameters: + param_examples = [f'"{name}": "..."' for name in action_info.action_parameters.keys()] + example_params = ", " + ", ".join(param_examples) + + action_block += f'\n```json\n{{"type": "{action_name}"{example_params}}}\n```' + + action_blocks.append(action_block) + + return "\n\n".join(action_blocks) + + +def _get_default_actions_block() -> str: + """获取默认的内置动作描述块""" + return """### `kfc_reply` - 发消息 +发送文字回复。 + +**重要**:像真人发微信一样,把你想说的话拆成几条短消息,每条用一个 kfc_reply 动作。 + +什么时候分? +- 说完一句话后想补充一句 → 分 +- 语气转折了 → 分 +- 想让对方先看到前面的,再看到后面的 → 分 +- 一个意思说完了 → 分 + +不需要分的时候: +- 就一句话的事 → 一条就够 +- 话很短,不用拆 → 一条就够 + +示例: +```json +{ + "actions": [ + {"type": "kfc_reply", "content": "嗯嗯~"}, + {"type": "kfc_reply", "content": "我知道了"} + ] +} +``` + +### `poke_user` - 戳一戳 +戳对方一下 +```json +{"type": "poke_user"} +``` + +### `update_internal_state` - 更新你的心情 +更新你现在的心情状态 +```json +{"type": "update_internal_state", "mood": "开心"} +``` + +### `do_nothing` - 不做任何事 +想了想,决定现在不作回应 +```json +{"type": "do_nothing"} +```""" + + +# ============================================================ +# 模块5: 表达与输出格式 - 回复风格 + JSON格式 +# ============================================================ + +def build_output_module(context_data: Optional[dict[str, str]] = None) -> str: + """ + 构建输出格式模块 + + 包含:表达风格、表达习惯、JSON 输出格式要求 + 这部分定义了"怎么说"和"输出什么格式" + + Args: + context_data: S4U 上下文数据(包含 expression_habits) + """ + if global_config is None: + raise RuntimeError("global_config 未初始化") + + context_data = context_data or {} + + reply_style = global_config.personality.reply_style or "" + expression_habits = context_data.get("expression_habits", "") + + # JSON 输出格式说明(更自然的思考引导) + json_format = """### 输出格式(JSON) +```json +{ + "thought": "你心里的真实想法,像日记一样自然", + "expected_user_reaction": "猜猜对方看到会怎么想", + "max_wait_seconds": 等多久(60-900),不等就填0, + "actions": [ + {"type": "kfc_reply", "content": "你要说的话"} + ] +} +``` + +关于 thought(内心想法): +- 写你真正在想的,不是在分析任务 +- 像心里嘀咕一样,比如"这家伙又来撒娇了~" "有点困了但还想再聊会儿" +- 不要写"根据设定""我需要""我应该"这种规划性的话 +- 就是你作为这个人,此刻心里在想什么 + +关于 expected_user_reaction(预期反应): +- 随便猜一下对方可能的反应就行 +- 比如"可能会撒娇""估计要睡了""应该会觉得好笑" +- 不用分析得很正式 + +不想做任何事就用 `{"type": "do_nothing"}`""" + + parts = [] + + if reply_style: + parts.append(reply_style) + + if expression_habits: + parts.append(expression_habits) + + parts.append(json_format) + + return "\n\n".join(parts) + + +# ============================================================ +# 组装完整的 System Prompt(复刻旧版) +# ============================================================ + +def build_system_prompt( + session: KokoroSession, + available_actions: Optional[dict[str, ActionInfo]] = None, + context_data: Optional[dict[str, str]] = None, + chat_stream: Optional["ChatStream"] = None, +) -> str: + """ + 组装完整的系统提示词 + + 按模块顺序组装: + 1. 角色框架 + 2. 核心身份(人设) + 3. 行为准则(规则) + 4. 情境上下文(时间/状态/关系) + 5. 动作能力(可用动作) + 6. 输出格式(表达风格 + JSON) + + Args: + session: 当前会话 + available_actions: 可用动作字典 + context_data: S4U 上下文数据 + chat_stream: 聊天流 + + Returns: + 完整的系统提示词 + """ + # 角色框架(不提AI,直接进入角色) + role_frame = "你是一个真实的人,有自己的生活、想法和情绪。像平时和朋友聊天一样自然地交流。" + + # 组装各模块 + modules = [ + role_frame, + "", + "## 1. 你是谁", + build_identity_module(), + "", + "## 2. 规则", + build_rules_module(), + "", + "## 3. 现在的情况", + build_context_module(session, chat_stream, context_data), + "", + "## 5. 你能做的事", + build_actions_module(available_actions), + "", + "## 6. 怎么回复", + build_output_module(context_data), + ] + + return "\n".join(modules) + + +# ============================================================ +# User Prompt 模板(复刻旧版) +# ============================================================ + +RESPONDING_USER_PROMPT_TEMPLATE = """## 聊天记录 +{narrative_history} + +## 新消息 +{incoming_messages} + +--- +看完这些消息,你想怎么回应?用 JSON 输出你的想法和决策。""" + + +TIMEOUT_DECISION_USER_PROMPT_TEMPLATE = """## 聊天记录 +{narrative_history} + +## 现在的情况 +你发了消息,等了 {wait_duration_seconds:.0f} 秒({wait_duration_minutes:.1f} 分钟),对方还没回。 +你之前觉得对方可能会:{expected_user_reaction} + +{followup_warning} + +你发的最后一条:{last_bot_message} + +--- +你拿起手机看了一眼,发现对方还没回复。你想怎么办? + +选项: +1. **继续等** - 用 `do_nothing`,设个 `max_wait_seconds` 等一会儿再看 +2. **发消息** - 用 `reply`,不过别太频繁追问 +3. **算了不等了** - 用 `do_nothing`,`max_wait_seconds` 设为 0 + +用 JSON 输出你的想法和决策。""" + + +PROACTIVE_THINKING_USER_PROMPT_TEMPLATE = """## 聊天记录 +{narrative_history} + +## 现在的情况 +现在是 {current_time},距离你们上次聊天已经过了 {silence_duration}。 +{relation_block} + +{trigger_context} + +--- +你突然想起了对方。要不要联系一下? + +说实话,不联系也完全没问题——不打扰也是一种温柔。 +如果决定联系,想好说什么,要自然一点。 + +用 JSON 输出你的想法和决策。不想发消息就用 `do_nothing`。""" + + +# ============================================================ +# 格式化历史记录 +# ============================================================ + +def format_narrative_history( + mental_log: list[MentalLogEntry], + max_entries: int = 15, +) -> str: + """ + 将心理活动日志格式化为叙事历史 + + Args: + mental_log: 心理活动日志列表 + max_entries: 最大条目数 + + Returns: + str: 格式化的叙事历史文本 + """ + if not mental_log: + return "(这是对话的开始,还没有历史记录)" + + # 获取最近的日志条目 + recent_entries = mental_log[-max_entries:] + + narrative_parts = [] + for entry in recent_entries: + timestamp_str = time.strftime( + "%Y-%m-%d %H:%M:%S", + time.localtime(entry.timestamp) + ) + + if entry.event_type == EventType.USER_MESSAGE: + user_name = entry.user_name or "用户" + narrative_parts.append( + f"[{timestamp_str}] {user_name}说:{entry.content}" + ) + elif entry.event_type == EventType.BOT_PLANNING: + if entry.thought: + narrative_parts.append( + f"[{timestamp_str}] (你的内心:{entry.thought})" + ) + # 格式化动作 + for action in entry.actions: + action_type = action.get("type", "") + if action_type == "kfc_reply" or action_type == "reply": + content = action.get("content", "") + if content: + narrative_parts.append( + f"[{timestamp_str}] 你回复:{content}" + ) + elif entry.event_type == EventType.WAITING_UPDATE: + if entry.waiting_thought: + narrative_parts.append( + f"[{timestamp_str}] (等待中的想法:{entry.waiting_thought})" + ) + + return "\n".join(narrative_parts) if narrative_parts else "(这是对话的开始,还没有历史记录)" + + +def format_history_from_context( + context: "StreamContext", + mental_log: Optional[list[MentalLogEntry]] = None, +) -> str: + """ + 从 StreamContext 的历史消息构建叙事历史 + + 这是实现"无缝融入"的关键: + - 从同一个数据库读取历史消息(与AFC共享) + - 遵循全局配置 [chat].max_context_size + - 将消息串渲染成KFC的叙事体格式 + + Args: + context: 聊天流上下文,包含共享的历史消息 + mental_log: 可选的心理活动日志,用于补充内心独白 + + Returns: + str: 格式化的叙事历史文本 + """ + # 从 StreamContext 获取历史消息,遵循全局上下文长度配置 + max_context = 25 # 默认值 + if global_config and hasattr(global_config, 'chat') and global_config.chat: + max_context = getattr(global_config.chat, "max_context_size", 25) + history_messages = context.get_messages(limit=max_context, include_unread=False) + + if not history_messages and not mental_log: + return "(这是对话的开始,还没有历史记录)" + + # 获取Bot的用户ID用于判断消息来源 + bot_user_id = None + if global_config and hasattr(global_config, 'bot') and global_config.bot: + bot_user_id = str(getattr(global_config.bot, 'qq_account', '')) + + narrative_parts = [] + + # 首先,将数据库历史消息转换为叙事格式 + for msg in history_messages: + timestamp_str = time.strftime( + "%Y-%m-%d %H:%M:%S", + time.localtime(msg.time or time.time()) + ) + + # 判断是用户消息还是Bot消息 + msg_user_id = str(msg.user_info.user_id) if msg.user_info else "" + is_bot_message = bot_user_id and msg_user_id == bot_user_id + content = msg.processed_plain_text or msg.display_message or "" + + if is_bot_message: + narrative_parts.append(f"[{timestamp_str}] 你回复:{content}") + else: + sender_name = msg.user_info.user_nickname if msg.user_info else "用户" + narrative_parts.append(f"[{timestamp_str}] {sender_name}说:{content}") + + # 然后,补充 mental_log 中的内心独白(如果有) + if mental_log: + for entry in mental_log[-5:]: # 只取最近5条心理活动 + timestamp_str = time.strftime( + "%Y-%m-%d %H:%M:%S", + time.localtime(entry.timestamp) + ) + + if entry.event_type == EventType.BOT_PLANNING and entry.thought: + narrative_parts.append(f"[{timestamp_str}] (你的内心:{entry.thought})") + + return "\n".join(narrative_parts) if narrative_parts else "(这是对话的开始,还没有历史记录)" + + +def format_incoming_messages( + message_content: str, + sender_name: str, + sender_id: str, + message_time: Optional[float] = None, + all_unread_messages: Optional[list] = None, +) -> str: + """ + 格式化收到的消息 + + 支持单条消息(兼容旧调用)和多条消息(打断合并场景) + + Args: + message_content: 主消息内容 + sender_name: 发送者名称 + sender_id: 发送者ID + message_time: 消息时间戳 + all_unread_messages: 所有未读消息列表 + + Returns: + str: 格式化的消息文本 + """ + if message_time is None: + message_time = time.time() + + # 如果有多条消息,格式化为消息组 + if all_unread_messages and len(all_unread_messages) > 1: + lines = [f"**用户连续发送了 {len(all_unread_messages)} 条消息:**\n"] + + for i, msg in enumerate(all_unread_messages, 1): + msg_time = msg.time or time.time() + msg_time_str = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(msg_time)) + msg_sender = msg.user_info.user_nickname if msg.user_info else sender_name + msg_content = msg.processed_plain_text or msg.display_message or "" + + lines.append(f"[{i}] 来自:{msg_sender}") + lines.append(f" 时间:{msg_time_str}") + lines.append(f" 内容:{msg_content}") + lines.append("") + + lines.append("**提示**:请综合理解这些消息的整体意图,不需要逐条回复。") + return "\n".join(lines) + + # 单条消息(兼容旧格式) + message_time_str = time.strftime( + "%Y-%m-%d %H:%M:%S", + time.localtime(message_time) + ) + return f"""来自:{sender_name}(用户ID: {sender_id}) +时间:{message_time_str} +内容:{message_content}""" diff --git a/src/plugins/built_in/kokoro_flow_chatter/replyer.py b/src/plugins/built_in/kokoro_flow_chatter/replyer.py index bbab29505..06a63d80a 100644 --- a/src/plugins/built_in/kokoro_flow_chatter/replyer.py +++ b/src/plugins/built_in/kokoro_flow_chatter/replyer.py @@ -82,7 +82,8 @@ async def generate_reply_text( # 3. 清理并返回回复文本 reply_text = _clean_reply_text(raw_response) - logger.info(f"[KFC Replyer] 生成成功 (model={model_name}): {reply_text[:50]}...") + # 使用 logger 输出美化日志(颜色通过 logger 系统配置) + logger.info(f"💬 {reply_text}") return True, reply_text diff --git a/src/plugins/built_in/kokoro_flow_chatter/unified.py b/src/plugins/built_in/kokoro_flow_chatter/unified.py new file mode 100644 index 000000000..eee71a8b6 --- /dev/null +++ b/src/plugins/built_in/kokoro_flow_chatter/unified.py @@ -0,0 +1,578 @@ +""" +Kokoro Flow Chatter - 统一模式 + +统一模式(Unified Mode): +- 使用模块化的提示词组件构建提示词 +- System Prompt + User Prompt 的标准结构 +- 一次 LLM 调用完成思考 + 回复生成 +- 输出 JSON 格式:thought + actions + max_wait_seconds + +与分离模式(Split Mode)的区别: +- 统一模式:一次调用完成所有工作,actions 中直接包含回复内容 +- 分离模式:Planner + Replyer 两次调用,先规划再生成回复 +""" + +import time +from datetime import datetime +from typing import TYPE_CHECKING, Optional + +from src.common.logger import get_logger +from src.config.config import global_config +from src.plugin_system.apis import llm_api +from src.utils.json_parser import extract_and_parse_json + +from .models import LLMResponse, EventType +from .session import KokoroSession + +# 统一模式专用的提示词模块 +from . import prompt_modules_unified as prompt_modules + +if TYPE_CHECKING: + from src.chat.message_receive.chat_stream import ChatStream + from src.common.data_models.message_manager_data_model import StreamContext + from src.plugin_system.base.component_types import ActionInfo + +logger = get_logger("kfc_unified") + + +class UnifiedPromptGenerator: + """ + 统一模式提示词生成器 + + 为统一模式构建提示词: + - generate_system_prompt: 构建系统提示词 + - generate_responding_prompt: 回应消息场景 + - generate_timeout_prompt: 超时决策场景 + - generate_proactive_prompt: 主动思考场景 + """ + + def __init__(self): + pass + + async def generate_system_prompt( + self, + session: KokoroSession, + available_actions: Optional[dict] = None, + context_data: Optional[dict[str, str]] = None, + chat_stream: Optional["ChatStream"] = None, + ) -> str: + """ + 生成系统提示词 + + 使用 prompt_modules.build_system_prompt() 构建模块化的提示词 + """ + return prompt_modules.build_system_prompt( + session=session, + available_actions=available_actions, + context_data=context_data, + chat_stream=chat_stream, + ) + + async def generate_responding_prompt( + self, + session: KokoroSession, + message_content: str, + sender_name: str, + sender_id: str, + message_time: Optional[float] = None, + available_actions: Optional[dict] = None, + context: Optional["StreamContext"] = None, + context_data: Optional[dict[str, str]] = None, + chat_stream: Optional["ChatStream"] = None, + all_unread_messages: Optional[list] = None, + ) -> tuple[str, str]: + """ + 生成回应消息场景的提示词 + + Returns: + tuple[str, str]: (系统提示词, 用户提示词) + """ + # 生成系统提示词 + system_prompt = await self.generate_system_prompt( + session, + available_actions, + context_data=context_data, + chat_stream=chat_stream, + ) + + # 构建叙事历史 + if context: + narrative_history = prompt_modules.format_history_from_context( + context, session.mental_log + ) + else: + narrative_history = prompt_modules.format_narrative_history(session.mental_log) + + # 格式化收到的消息 + incoming_messages = prompt_modules.format_incoming_messages( + message_content=message_content, + sender_name=sender_name, + sender_id=sender_id, + message_time=message_time, + all_unread_messages=all_unread_messages, + ) + + # 使用用户提示词模板 + user_prompt = prompt_modules.RESPONDING_USER_PROMPT_TEMPLATE.format( + narrative_history=narrative_history, + incoming_messages=incoming_messages, + ) + + return system_prompt, user_prompt + + async def generate_timeout_prompt( + self, + session: KokoroSession, + available_actions: Optional[dict] = None, + context_data: Optional[dict[str, str]] = None, + chat_stream: Optional["ChatStream"] = None, + ) -> tuple[str, str]: + """ + 生成超时决策场景的提示词 + + Returns: + tuple[str, str]: (系统提示词, 用户提示词) + """ + # 生成系统提示词 + system_prompt = await self.generate_system_prompt( + session, + available_actions, + context_data=context_data, + chat_stream=chat_stream, + ) + + # 构建叙事历史 + narrative_history = prompt_modules.format_narrative_history(session.mental_log) + + # 计算等待时间 + wait_duration = session.waiting_config.get_elapsed_seconds() + + # 生成连续追问警告(使用 waiting_config.thinking_count 作为追问计数) + followup_count = session.waiting_config.thinking_count + max_followups = 3 # 最多追问3次 + + if followup_count >= max_followups: + followup_warning = f"""⚠️ **重要提醒**: +你已经连续追问了 {followup_count} 次,对方都没有回复。 +**强烈建议不要再发消息了**——继续追问会显得很缠人、很不尊重对方的空间。 +对方可能真的在忙,或者暂时不想回复,这都是正常的。 +请选择 `do_nothing` 继续等待,或者直接结束对话(设置 `max_wait_seconds: 0`)。""" + elif followup_count > 0: + followup_warning = f"""📝 提示:这已经是你第 {followup_count + 1} 次等待对方回复了。 +如果对方持续没有回应,可能真的在忙或不方便,不需要急着追问。""" + else: + followup_warning = "" + + # 获取最后一条 Bot 消息 + last_bot_message = "(没有记录)" + for entry in reversed(session.mental_log): + if entry.event_type == EventType.BOT_PLANNING: + for action in entry.actions: + if action.get("type") in ("reply", "kfc_reply"): + content = action.get("content", "") + if content: + last_bot_message = content + break + if last_bot_message != "(没有记录)": + break + + # 使用用户提示词模板 + user_prompt = prompt_modules.TIMEOUT_DECISION_USER_PROMPT_TEMPLATE.format( + narrative_history=narrative_history, + wait_duration_seconds=wait_duration, + wait_duration_minutes=wait_duration / 60, + expected_user_reaction=session.waiting_config.expected_reaction or "不确定", + followup_warning=followup_warning, + last_bot_message=last_bot_message, + ) + + return system_prompt, user_prompt + + async def generate_proactive_prompt( + self, + session: KokoroSession, + trigger_context: str, + available_actions: Optional[dict] = None, + context_data: Optional[dict[str, str]] = None, + chat_stream: Optional["ChatStream"] = None, + ) -> tuple[str, str]: + """ + 生成主动思考场景的提示词 + + Returns: + tuple[str, str]: (系统提示词, 用户提示词) + """ + # 生成系统提示词 + system_prompt = await self.generate_system_prompt( + session, + available_actions, + context_data=context_data, + chat_stream=chat_stream, + ) + + # 构建叙事历史 + narrative_history = prompt_modules.format_narrative_history( + session.mental_log, max_entries=10 + ) + + # 计算沉默时长 + silence_seconds = time.time() - session.last_activity_at + if silence_seconds < 3600: + silence_duration = f"{silence_seconds / 60:.0f}分钟" + else: + silence_duration = f"{silence_seconds / 3600:.1f}小时" + + # 当前时间 + current_time = datetime.now().strftime("%Y年%m月%d日 %H:%M") + + # 从 context_data 获取关系信息 + relation_block = "" + if context_data: + relation_info = context_data.get("relation_info", "") + if relation_info: + relation_block = f"### 你与对方的关系\n{relation_info}" + + if not relation_block: + # 回退:使用默认关系描述 + relation_block = """### 你与对方的关系 +- 你们还不太熟悉 +- 正在慢慢了解中""" + + # 使用用户提示词模板 + user_prompt = prompt_modules.PROACTIVE_THINKING_USER_PROMPT_TEMPLATE.format( + narrative_history=narrative_history, + current_time=current_time, + silence_duration=silence_duration, + relation_block=relation_block, + trigger_context=trigger_context, + ) + + return system_prompt, user_prompt + + def build_messages_for_llm( + self, + system_prompt: str, + user_prompt: str, + stream_id: str = "", + ) -> str: + """ + 构建 LLM 请求的完整提示词 + + 将 system + user 合并为单个提示词字符串 + """ + # 合并提示词 + full_prompt = f"{system_prompt}\n\n---\n\n{user_prompt}" + + # DEBUG日志:打印完整的KFC提示词(只在 DEBUG 级别输出) + logger.debug( + f"Final KFC prompt constructed for stream {stream_id}:\n" + f"--- PROMPT START ---\n" + f"{full_prompt}\n" + f"--- PROMPT END ---" + ) + + return full_prompt + + +# 全局提示词生成器实例 +_prompt_generator: Optional[UnifiedPromptGenerator] = None + + +def get_unified_prompt_generator() -> UnifiedPromptGenerator: + """获取全局提示词生成器实例""" + global _prompt_generator + if _prompt_generator is None: + _prompt_generator = UnifiedPromptGenerator() + return _prompt_generator + + +async def generate_unified_response( + session: KokoroSession, + user_name: str, + situation_type: str = "new_message", + chat_stream: Optional["ChatStream"] = None, + available_actions: Optional[dict] = None, + extra_context: Optional[dict] = None, +) -> LLMResponse: + """ + 统一模式:单次 LLM 调用生成完整响应 + + 调用方式: + - 使用 UnifiedPromptGenerator 生成 System + User 提示词 + - 使用 replyer 模型调用 LLM + - 解析 JSON 响应(thought + actions + max_wait_seconds) + + Args: + session: 会话对象 + user_name: 用户名称 + situation_type: 情况类型 (new_message/timeout/proactive) + chat_stream: 聊天流对象 + available_actions: 可用动作字典 + extra_context: 额外上下文 + + Returns: + LLMResponse 对象,包含完整的思考和动作 + """ + try: + prompt_generator = get_unified_prompt_generator() + extra_context = extra_context or {} + + # 获取上下文数据(关系、记忆等) + context_data = await _build_context_data(user_name, chat_stream, session.user_id) + + # 根据情况类型选择提示词生成方法 + if situation_type == "timeout": + system_prompt, user_prompt = await prompt_generator.generate_timeout_prompt( + session=session, + available_actions=available_actions, + context_data=context_data, + chat_stream=chat_stream, + ) + elif situation_type == "proactive": + trigger_context = extra_context.get("trigger_reason", "") + system_prompt, user_prompt = await prompt_generator.generate_proactive_prompt( + session=session, + trigger_context=trigger_context, + available_actions=available_actions, + context_data=context_data, + chat_stream=chat_stream, + ) + else: + # 默认为回应消息场景 (new_message, reply_in_time, reply_late) + # 获取最后一条用户消息 + message_content, sender_name, sender_id, message_time, all_unread = _get_last_user_message( + session, user_name, chat_stream + ) + + system_prompt, user_prompt = await prompt_generator.generate_responding_prompt( + session=session, + message_content=message_content, + sender_name=sender_name, + sender_id=sender_id, + message_time=message_time, + available_actions=available_actions, + context=chat_stream.context if chat_stream else None, + context_data=context_data, + chat_stream=chat_stream, + all_unread_messages=all_unread, + ) + + # 构建完整提示词 + prompt = prompt_generator.build_messages_for_llm( + system_prompt, + user_prompt, + stream_id=chat_stream.stream_id if chat_stream else "", + ) + + # 显示提示词(调试模式 - 只有在配置中开启时才输出) + if global_config and global_config.debug.show_prompt: + logger.info( + f"[KFC] 完整提示词 (stream={chat_stream.stream_id if chat_stream else 'unknown'}):\n" + f"--- PROMPT START ---\n" + f"{prompt}\n" + f"--- PROMPT END ---" + ) + + # 获取 replyer 模型配置并调用 LLM + models = llm_api.get_available_models() + replyer_config = models.get("replyer") + + if not replyer_config: + logger.error("[KFC Unified] 未找到 replyer 模型配置") + return LLMResponse.create_error_response("未找到 replyer 模型配置") + + # 调用 LLM(使用合并后的提示词) + success, raw_response, reasoning, model_name = await llm_api.generate_with_model( + prompt=prompt, + model_config=replyer_config, + request_type="kokoro_flow_chatter.unified", + ) + + if not success: + logger.error(f"[KFC Unified] LLM 调用失败: {raw_response}") + return LLMResponse.create_error_response(raw_response) + + # 输出原始 JSON 响应(DEBUG 级别,用于调试) + logger.debug( + f"Raw JSON response from LLM for stream {chat_stream.stream_id if chat_stream else 'unknown'}:\n" + f"--- JSON START ---\n" + f"{raw_response}\n" + f"--- JSON END ---" + ) + + # 解析响应 + return _parse_unified_response(raw_response, chat_stream.stream_id if chat_stream else None) + + except Exception as e: + logger.error(f"[KFC Unified] 生成失败: {e}") + import traceback + traceback.print_exc() + return LLMResponse.create_error_response(str(e)) + + +async def _build_context_data( + user_name: str, + chat_stream: Optional["ChatStream"], + user_id: Optional[str] = None, +) -> dict[str, str]: + """ + 构建上下文数据(关系、记忆、表达习惯等) + """ + if not chat_stream: + return { + "relation_info": f"你与 {user_name} 还不太熟悉,这是早期的交流阶段。", + "memory_block": "", + "expression_habits": "", + "schedule": "", + } + + try: + from .context_builder import KFCContextBuilder + + builder = KFCContextBuilder(chat_stream) + + # 获取最近的消息作为 target_message(用于记忆检索) + target_message = "" + if chat_stream.context: + unread = chat_stream.context.get_unread_messages() + if unread: + target_message = unread[-1].processed_plain_text or unread[-1].display_message or "" + + context_data = await builder.build_all_context( + sender_name=user_name, + target_message=target_message, + context=chat_stream.context, + user_id=user_id, + ) + + return context_data + + except Exception as e: + logger.warning(f"构建上下文数据失败: {e}") + return { + "relation_info": f"你与 {user_name} 还不太熟悉,这是早期的交流阶段。", + "memory_block": "", + "expression_habits": "", + "schedule": "", + } + + +def _get_last_user_message( + session: KokoroSession, + user_name: str, + chat_stream: Optional["ChatStream"], +) -> tuple[str, str, str, float, Optional[list]]: + """ + 获取最后一条用户消息 + + Returns: + tuple: (消息内容, 发送者名称, 发送者ID, 消息时间, 所有未读消息列表) + """ + message_content = "" + sender_name = user_name + sender_id = session.user_id or "" + message_time = time.time() + all_unread = None + + # 从 chat_stream 获取未读消息 + if chat_stream and chat_stream.context: + unread = chat_stream.context.get_unread_messages() + if unread: + all_unread = unread if len(unread) > 1 else None + last_msg = unread[-1] + message_content = last_msg.processed_plain_text or last_msg.display_message or "" + if last_msg.user_info: + sender_name = last_msg.user_info.user_nickname or user_name + sender_id = str(last_msg.user_info.user_id) + message_time = last_msg.time or time.time() + + # 如果没有从 chat_stream 获取到,从 mental_log 获取 + if not message_content: + for entry in reversed(session.mental_log): + if entry.event_type == EventType.USER_MESSAGE: + message_content = entry.content or "" + sender_name = entry.user_name or user_name + message_time = entry.timestamp + break + + return message_content, sender_name, sender_id, message_time, all_unread + + +def _parse_unified_response(raw_response: str, stream_id: str | None = None) -> LLMResponse: + """ + 解析统一模式的 LLM 响应 + + 响应格式: + { + "thought": "...", + "expected_user_reaction": "...", + "max_wait_seconds": 300, + "actions": [{"type": "reply", "content": "..."}] + } + """ + data = extract_and_parse_json(raw_response, strict=False) + + if not data or not isinstance(data, dict): + logger.warning(f"[KFC Unified] 无法解析 JSON: {raw_response[:200]}...") + return LLMResponse.create_error_response("无法解析响应格式") + + # 兼容旧版的字段名 + # expected_user_reaction -> expected_reaction + if "expected_user_reaction" in data and "expected_reaction" not in data: + data["expected_reaction"] = data["expected_user_reaction"] + + # 兼容旧版的 reply -> kfc_reply + actions = data.get("actions", []) + for action in actions: + if isinstance(action, dict): + if action.get("type") == "reply": + action["type"] = "kfc_reply" + # 统一模式下模型已经自己分段了,禁用回复分割器 + if action.get("type") == "kfc_reply": + action["enable_splitter"] = False + + response = LLMResponse.from_dict(data) + + # 美化日志输出:内心思考 + 回复内容 + _log_pretty_response(response, stream_id) + + return response + + +def _log_pretty_response(response: LLMResponse, stream_id: str | None = None) -> None: + """简洁输出 LLM 响应日志""" + if not response.thought and not response.actions: + logger.warning("[KFC] 响应为空") + return + + stream_tag = f"({stream_id[:8]}) " if stream_id else "" + + # 收集回复内容和其他动作 + replies = [] + actions = [] + for action in response.actions: + if action.type == "kfc_reply": + content = action.params.get("content", "") + if content: + replies.append(content) + elif action.type not in ("do_nothing", "no_action"): + actions.append(action.type) + + # 逐行输出,简洁明了 + if response.thought: + logger.info(f"[KFC] {stream_tag}💭 {response.thought}") + + for i, reply in enumerate(replies): + if len(replies) > 1: + logger.info(f"[KFC] 💬[{i+1}] {reply}") + else: + logger.info(f"[KFC] 💬 {reply}") + + if actions: + logger.info(f"[KFC] 🎯 {', '.join(actions)}") + + if response.max_wait_seconds > 0 or response.expected_reaction: + meta = f"⏱{response.max_wait_seconds}s" if response.max_wait_seconds > 0 else "" + if response.expected_reaction: + meta += f" 预期: {response.expected_reaction}" + logger.info(f"[KFC] {meta.strip()}") \ No newline at end of file diff --git a/template/bot_config_template.toml b/template/bot_config_template.toml index f8e1e3cb6..61a5775f8 100644 --- a/template/bot_config_template.toml +++ b/template/bot_config_template.toml @@ -1,5 +1,5 @@ [inner] -version = "7.9.4" +version = "7.9.5" #----以下是给开发人员阅读的,如果你只是部署了MoFox-Bot,不需要阅读---- #如果你想要修改配置文件,请递增version的值 @@ -648,6 +648,12 @@ log_decisions = false # 是否记录每次决策的详细日志 # 开启后,KFC将接管所有私聊消息;关闭后,私聊消息将由AFC处理。 enable = true +# --- 工作模式 --- +# 可选值: "unified"(统一模式)或 "split"(分离模式) +# unified: 单次LLM调用完成思考和回复生成,类似传统聊天方式,响应更快 +# split: Planner + Replyer两次LLM调用,先规划再生成回复,控制更精细 +mode = "split" + # --- 核心行为配置 --- max_wait_seconds_default = 300 # 默认的最大等待秒数(AI发送消息后愿意等待用户回复的时间) enable_continuous_thinking = true # 是否在等待期间启用心理活动更新 @@ -676,7 +682,3 @@ quiet_hours_end = "07:00" # 勿扰结束时间 # 5. 触发概率:每次检查时主动发起的概率,用于避免过于频繁打扰。 trigger_probability = 0.3 # 0.0~1.0,默认30%概率 - -# 6. 自然问候:在特定的时间,她会像朋友一样送上问候。 -enable_morning_greeting = true # 是否启用早安问候 (例如: 8:00 - 9:00) -enable_night_greeting = true # 是否启用晚安问候 (例如: 22:00 - 23:00) From 0154c8fedd9869e71665daf14c08899c63c4a625 Mon Sep 17 00:00:00 2001 From: Windpicker-owo <3431391539@qq.com> Date: Mon, 1 Dec 2025 09:20:03 +0800 Subject: [PATCH 25/28] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E8=BF=9E?= =?UTF-8?q?=E7=BB=AD=E8=B6=85=E6=97=B6=E8=AE=A1=E6=95=B0=E5=92=8C=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E6=9C=80=E5=90=8E=E5=9B=9E=E5=A4=8D=E6=97=B6=E9=97=B4?= =?UTF-8?q?=EF=BC=8C=E4=BC=98=E5=8C=96=E8=B6=85=E6=97=B6=E5=86=B3=E7=AD=96?= =?UTF-8?q?=E4=B8=8A=E4=B8=8B=E6=96=87=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kokoro_flow_chatter/proactive_thinker.py | 42 ++++++++++++++++++- .../kokoro_flow_chatter/prompt/builder.py | 19 +++++++++ .../kokoro_flow_chatter/prompt/prompts.py | 7 +++- .../built_in/kokoro_flow_chatter/session.py | 20 ++++++++- 4 files changed, 83 insertions(+), 5 deletions(-) diff --git a/src/plugins/built_in/kokoro_flow_chatter/proactive_thinker.py b/src/plugins/built_in/kokoro_flow_chatter/proactive_thinker.py index 0cee53028..b24161808 100644 --- a/src/plugins/built_in/kokoro_flow_chatter/proactive_thinker.py +++ b/src/plugins/built_in/kokoro_flow_chatter/proactive_thinker.py @@ -385,7 +385,13 @@ class ProactiveThinker: ) return - logger.info(f"[ProactiveThinker] 等待超时: user={session.user_id}") + # 增加连续超时计数 + session.consecutive_timeout_count += 1 + + logger.info( + f"[ProactiveThinker] 等待超时: user={session.user_id}, " + f"consecutive_timeout={session.consecutive_timeout_count}" + ) try: # 获取用户名 @@ -403,6 +409,18 @@ class ProactiveThinker: action_modifier = ActionModifier(action_manager, session.stream_id) await action_modifier.modify_actions(chatter_name="KokoroFlowChatter") + # 计算用户最后回复距今的时间 + time_since_user_reply = None + if session.last_user_message_at: + time_since_user_reply = time.time() - session.last_user_message_at + + # 构建超时上下文信息 + extra_context = { + "consecutive_timeout_count": session.consecutive_timeout_count, + "time_since_user_reply": time_since_user_reply, + "time_since_user_reply_str": self._format_duration(time_since_user_reply) if time_since_user_reply else "未知", + } + # 调用 Planner 生成超时决策 plan_response = await generate_plan( session=session, @@ -410,6 +428,7 @@ class ProactiveThinker: situation_type="timeout", chat_stream=chat_stream, available_actions=action_manager.get_using_actions(), + extra_context=extra_context, ) # 为 kfc_reply 动作注入必要的上下文信息 @@ -419,6 +438,7 @@ class ProactiveThinker: action.params["user_name"] = user_name action.params["thought"] = plan_response.thought action.params["situation_type"] = "timeout" + action.params["extra_context"] = extra_context # 执行动作(回复生成在 Action.execute() 中完成) for action in plan_response.actions: @@ -457,7 +477,8 @@ class ProactiveThinker: logger.info( f"[ProactiveThinker] 超时决策完成: user={session.user_id}, " f"actions={[a.type for a in plan_response.actions]}, " - f"continue_wait={plan_response.max_wait_seconds > 0}" + f"continue_wait={plan_response.max_wait_seconds > 0}, " + f"consecutive_timeout={session.consecutive_timeout_count}" ) except Exception as e: @@ -681,6 +702,23 @@ class ProactiveThinker: # 回退到 user_id return user_id + def _format_duration(self, seconds: float | None) -> str: + """格式化时间间隔为人类可读的字符串""" + if seconds is None or seconds < 0: + return "未知" + + if seconds < 60: + return f"{int(seconds)} 秒" + elif seconds < 3600: + minutes = seconds / 60 + return f"{minutes:.0f} 分钟" + elif seconds < 86400: + hours = seconds / 3600 + return f"{hours:.1f} 小时" + else: + days = seconds / 86400 + return f"{days:.1f} 天" + def get_stats(self) -> dict: """获取统计信息""" return { diff --git a/src/plugins/built_in/kokoro_flow_chatter/prompt/builder.py b/src/plugins/built_in/kokoro_flow_chatter/prompt/builder.py index 94ed55263..4f0ea1316 100644 --- a/src/plugins/built_in/kokoro_flow_chatter/prompt/builder.py +++ b/src/plugins/built_in/kokoro_flow_chatter/prompt/builder.py @@ -531,6 +531,24 @@ class PromptBuilder: elapsed = session.waiting_config.get_elapsed_seconds() max_wait = session.waiting_config.max_wait_seconds expected = session.waiting_config.expected_reaction + + # 构建连续超时上下文 + timeout_context_parts = [] + + # 添加连续超时次数信息 + consecutive_count = extra_context.get("consecutive_timeout_count", 0) + if consecutive_count > 1: + timeout_context_parts.append(f"⚠️ 这已经是你连续第 {consecutive_count} 次等到超时了。") + + # 添加距离用户上次回复的时间 + time_since_user_reply_str = extra_context.get("time_since_user_reply_str") + if time_since_user_reply_str: + timeout_context_parts.append(f"距离 {user_name} 上一次回复你已经过去了 {time_since_user_reply_str}。") + + timeout_context = "\n".join(timeout_context_parts) + if timeout_context: + timeout_context = "\n" + timeout_context + "\n" + return await global_prompt_manager.format_prompt( PROMPT_NAMES["situation_timeout"], current_time=current_time, @@ -538,6 +556,7 @@ class PromptBuilder: elapsed_minutes=elapsed / 60, max_wait_minutes=max_wait / 60, expected_reaction=expected or "对方能回复点什么", + timeout_context=timeout_context, ) elif situation_type == "proactive": diff --git a/src/plugins/built_in/kokoro_flow_chatter/prompt/prompts.py b/src/plugins/built_in/kokoro_flow_chatter/prompt/prompts.py index 80b1d29e9..b798b84b9 100644 --- a/src/plugins/built_in/kokoro_flow_chatter/prompt/prompts.py +++ b/src/plugins/built_in/kokoro_flow_chatter/prompt/prompts.py @@ -123,12 +123,15 @@ kfc_SITUATION_TIMEOUT = Prompt( 你之前发了消息后一直在等 {user_name} 的回复。 你原本打算最多等 {max_wait_minutes:.1f} 分钟,现在已经等了 {elapsed_minutes:.1f} 分钟了,对方还是没回。 你当时期待的反应是:"{expected_reaction}" - +{timeout_context} 你需要决定: 1. 继续等待(设置新的 max_wait_seconds) 2. 主动说点什么打破沉默 3. 做点别的事情(执行其他动作) -4. 算了不等了(max_wait_seconds = 0)""", +4. 算了不等了(max_wait_seconds = 0) + +【注意】如果已经连续多次超时,对方可能暂时不方便回复。频繁主动发消息可能会打扰到对方。 +考虑是否应该暂时放下期待,让对方有空间。""", ) kfc_SITUATION_PROACTIVE = Prompt( diff --git a/src/plugins/built_in/kokoro_flow_chatter/session.py b/src/plugins/built_in/kokoro_flow_chatter/session.py index fe26714a3..8e5254a73 100644 --- a/src/plugins/built_in/kokoro_flow_chatter/session.py +++ b/src/plugins/built_in/kokoro_flow_chatter/session.py @@ -66,6 +66,12 @@ class KokoroSession: # 上次主动思考时间 self.last_proactive_at: Optional[float] = None + + # 连续超时计数(用于避免过度打扰用户) + self.consecutive_timeout_count: int = 0 + + # 用户最后发消息的时间(用于计算距离用户上次回复的时间) + self.last_user_message_at: Optional[float] = None @property def status(self) -> SessionStatus: @@ -95,14 +101,20 @@ class KokoroSession: timestamp: Optional[float] = None, ) -> MentalLogEntry: """添加用户消息事件""" + msg_time = timestamp or time.time() + entry = MentalLogEntry( event_type=EventType.USER_MESSAGE, - timestamp=timestamp or time.time(), + timestamp=msg_time, content=content, user_name=user_name, user_id=user_id, ) + # 收到用户消息,重置连续超时计数 + self.consecutive_timeout_count = 0 + self.last_user_message_at = msg_time + # 如果之前在等待,记录收到回复的情况 if self.status == SessionStatus.WAITING and self.waiting_config.is_active(): elapsed = self.waiting_config.get_elapsed_seconds() @@ -213,6 +225,8 @@ class KokoroSession: "last_activity_at": self.last_activity_at, "total_interactions": self.total_interactions, "last_proactive_at": self.last_proactive_at, + "consecutive_timeout_count": self.consecutive_timeout_count, + "last_user_message_at": self.last_user_message_at, } @classmethod @@ -244,6 +258,10 @@ class KokoroSession: session.total_interactions = data.get("total_interactions", 0) session.last_proactive_at = data.get("last_proactive_at") + # 连续超时相关 + session.consecutive_timeout_count = data.get("consecutive_timeout_count", 0) + session.last_user_message_at = data.get("last_user_message_at") + return session From 127d0e039e017616e25242cc8cbab9e054007afc Mon Sep 17 00:00:00 2001 From: tt-P607 <68868379+tt-P607@users.noreply.github.com> Date: Mon, 1 Dec 2025 14:45:41 +0800 Subject: [PATCH 26/28] =?UTF-8?q?refactor(kfc):=20=E7=A7=BB=E9=99=A4=20kfc?= =?UTF-8?q?=5Freply=20=E5=9B=9E=E5=A4=8D=E5=88=86=E6=AE=B5=E9=80=BB?= =?UTF-8?q?=E8=BE=91=E5=8F=8A=E7=9B=B8=E5=85=B3=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除 `kfc_reply` 动作的 `enable_splitter` 强制禁用逻辑,并简化了统一模式下的提示词,不再指导模型如何进行消息分段。 此次重构旨在将消息分段的决策权完全交还给大语言模型,使其能够根据对话上下文和自身判断,更自然地决定回复的格式(单条或多条消息)。这简化了处理逻辑,并有望提升模型回复的流畅度和拟人化程度。 --- .../prompt_modules_unified.py | 21 +------------------ .../built_in/kokoro_flow_chatter/unified.py | 3 --- 2 files changed, 1 insertion(+), 23 deletions(-) diff --git a/src/plugins/built_in/kokoro_flow_chatter/prompt_modules_unified.py b/src/plugins/built_in/kokoro_flow_chatter/prompt_modules_unified.py index c7880d93e..e5f80982b 100644 --- a/src/plugins/built_in/kokoro_flow_chatter/prompt_modules_unified.py +++ b/src/plugins/built_in/kokoro_flow_chatter/prompt_modules_unified.py @@ -215,27 +215,8 @@ def _get_default_actions_block() -> str: """获取默认的内置动作描述块""" return """### `kfc_reply` - 发消息 发送文字回复。 - -**重要**:像真人发微信一样,把你想说的话拆成几条短消息,每条用一个 kfc_reply 动作。 - -什么时候分? -- 说完一句话后想补充一句 → 分 -- 语气转折了 → 分 -- 想让对方先看到前面的,再看到后面的 → 分 -- 一个意思说完了 → 分 - -不需要分的时候: -- 就一句话的事 → 一条就够 -- 话很短,不用拆 → 一条就够 - -示例: ```json -{ - "actions": [ - {"type": "kfc_reply", "content": "嗯嗯~"}, - {"type": "kfc_reply", "content": "我知道了"} - ] -} +{"type": "kfc_reply", "content": "你要说的话"} ``` ### `poke_user` - 戳一戳 diff --git a/src/plugins/built_in/kokoro_flow_chatter/unified.py b/src/plugins/built_in/kokoro_flow_chatter/unified.py index eee71a8b6..bfe0bd1fc 100644 --- a/src/plugins/built_in/kokoro_flow_chatter/unified.py +++ b/src/plugins/built_in/kokoro_flow_chatter/unified.py @@ -527,9 +527,6 @@ def _parse_unified_response(raw_response: str, stream_id: str | None = None) -> if isinstance(action, dict): if action.get("type") == "reply": action["type"] = "kfc_reply" - # 统一模式下模型已经自己分段了,禁用回复分割器 - if action.get("type") == "kfc_reply": - action["enable_splitter"] = False response = LLMResponse.from_dict(data) From 85efff4e7a1471e8dd6b798ecb8fb14e7fe9af53 Mon Sep 17 00:00:00 2001 From: tt-P607 <68868379+tt-P607@users.noreply.github.com> Date: Mon, 1 Dec 2025 15:28:34 +0800 Subject: [PATCH 27/28] =?UTF-8?q?chore(logging):=20=E8=B0=83=E6=95=B4?= =?UTF-8?q?=E6=B5=81=E5=A4=84=E7=90=86=E7=9A=84=E6=97=A5=E5=BF=97=E7=BA=A7?= =?UTF-8?q?=E5=88=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在流循环管理器中降低非关键事件的日志级别,以减少生产环境日志的噪音。 - 将任务处理失败消息从 `warning` 改为 `debug`,因为在某些情况下这是可恢复和预期的状态。 - 将并发保护消息从 `warning` 改为 `debug`,因为这是流控中的正常且预期的部分,而不是错误。 --- src/chat/message_manager/distribution_manager.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/chat/message_manager/distribution_manager.py b/src/chat/message_manager/distribution_manager.py index 351e61a64..5af3bb7d4 100644 --- a/src/chat/message_manager/distribution_manager.py +++ b/src/chat/message_manager/distribution_manager.py @@ -367,7 +367,7 @@ class StreamLoopManager: await asyncio.sleep(0.1) else: self.stats["total_failures"] += 1 - logger.warning(f"❌ [流工作器] stream={stream_id[:8]}, 任务ID={task_id}, 处理失败") + logger.debug(f"❌ [流工作器] stream={stream_id[:8]}, 任务ID={task_id}, 处理失败") # 5. 计算下次检查间隔 logger.debug(f"🔍 [流工作器] stream={stream_id[:8]}, 循环#{loop_count}, 计算间隔...") @@ -476,8 +476,8 @@ class StreamLoopManager: # 🔒 防止并发处理:如果已经在处理中,直接返回 if context.is_chatter_processing: - logger.warning(f"🔒 [并发保护] stream={stream_id[:8]}, Chatter 正在处理中,跳过本次处理请求") - return False + logger.debug(f"🔒 [并发保护] stream={stream_id[:8]}, Chatter 正在处理中,跳过本次处理请求") + return True # 返回 True,这是正常的保护机制,不是失败 # 设置处理状态为正在处理 self._set_stream_processing_status(stream_id, True) From faae4849a86a9b3d2d4136a1cc99ff5a08db1ec4 Mon Sep 17 00:00:00 2001 From: tt-P607 <68868379+tt-P607@users.noreply.github.com> Date: Mon, 1 Dec 2025 15:39:29 +0800 Subject: [PATCH 28/28] =?UTF-8?q?chore(logging):=20=E5=B0=86=20get=5Fnode?= =?UTF-8?q?=20=E9=94=99=E8=AF=AF=E6=97=A5=E5=BF=97=E9=99=8D=E7=BA=A7?= =?UTF-8?q?=E4=B8=BA=E8=B0=83=E8=AF=95=E7=BA=A7=E5=88=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 `get_node` 操作期间出现对不存在节点的错误日志是正常且预期的情况,而不是实际错误。此更改将日志级别从 `error` 降低到 `debug`,以避免例行检查污染错误日志。 --- src/memory_graph/storage/vector_store.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/memory_graph/storage/vector_store.py b/src/memory_graph/storage/vector_store.py index d30f96c80..b59ea1b83 100644 --- a/src/memory_graph/storage/vector_store.py +++ b/src/memory_graph/storage/vector_store.py @@ -405,7 +405,8 @@ class VectorStore: return None except Exception as e: - logger.error(f"获取节点失败: {e}") + # 节点不存在是正常情况,降级为 debug + logger.debug(f"获取节点失败(节点可能不存在): {e}") return None async def delete_node(self, node_id: str) -> None: