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] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84Kokoro=20Flow=20Chatt?= =?UTF-8?q?er=EF=BC=8C=E6=96=B0=E5=A2=9E=E8=A7=84=E5=88=92=E5=99=A8?= =?UTF-8?q?=E5=92=8C=E5=9B=9E=E5=A4=8D=E7=94=9F=E6=88=90=E5=99=A8=EF=BC=8C?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=8F=90=E7=A4=BA=E8=AF=8D=E6=9E=84=E5=BB=BA?= =?UTF-8?q?=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