feat:重构maisaka的消息类型,添加打断功能

This commit is contained in:
SengokuCola
2026-03-30 00:45:41 +08:00
parent b5408b4550
commit 01ef29aadb
34 changed files with 670 additions and 7782 deletions

View File

@@ -8,7 +8,6 @@ import time
from sqlmodel import select
from src.chat.message_receive.chat_manager import BotChatSession
from src.chat.message_receive.message import SessionMessage
from src.common.database.database import get_db_session
from src.common.database.database_model import Expression
from src.common.data_models.reply_generation_data_models import (
@@ -22,15 +21,11 @@ from src.config.config import global_config
from src.core.types import ActionInfo
from src.services.llm_service import LLMServiceClient
from src.maisaka.message_adapter import (
get_message_kind,
get_message_role,
get_message_source,
get_message_text,
parse_speaker_content,
)
from src.chat.message_receive.message import SessionMessage
from src.maisaka.context_messages import AssistantMessage, LLMContextMessage, ReferenceMessage, SessionBackedMessage, ToolResultMessage
from src.maisaka.message_adapter import parse_speaker_content
logger = get_logger("maisaka_replyer")
logger = get_logger("replyer")
@dataclass
@@ -96,16 +91,16 @@ class MaisakaReplyGenerator:
return normalized
@staticmethod
def _format_message_time(message: SessionMessage) -> str:
def _format_message_time(message: LLMContextMessage) -> str:
return message.timestamp.strftime("%H:%M:%S")
@staticmethod
def _extract_visible_assistant_reply(message: SessionMessage) -> str:
def _extract_visible_assistant_reply(message: AssistantMessage) -> str:
del message
return ""
def _extract_guided_bot_reply(self, message: SessionMessage) -> str:
speaker_name, body = parse_speaker_content(get_message_text(message).strip())
def _extract_guided_bot_reply(self, message: SessionBackedMessage) -> str:
speaker_name, body = parse_speaker_content(message.processed_plain_text.strip())
bot_nickname = global_config.bot.nickname.strip() or "Bot"
if speaker_name == bot_nickname:
return self._normalize_content(body.strip())
@@ -134,25 +129,24 @@ class MaisakaReplyGenerator:
return segments
def _format_chat_history(self, messages: List[SessionMessage]) -> str:
def _format_chat_history(self, messages: List[LLMContextMessage]) -> str:
"""格式化 replyer 使用的可见聊天记录。"""
bot_nickname = global_config.bot.nickname.strip() or "Bot"
parts: List[str] = []
for message in messages:
role = get_message_role(message)
timestamp = self._format_message_time(message)
if get_message_source(message) == "user_reference":
if isinstance(message, (ReferenceMessage, ToolResultMessage)):
continue
if role == "user":
if isinstance(message, SessionBackedMessage):
guided_reply = self._extract_guided_bot_reply(message)
if guided_reply:
parts.append(f"{timestamp} {bot_nickname}(you): {guided_reply}")
continue
raw_content = get_message_text(message)
raw_content = message.processed_plain_text
for speaker_name, content_body in self._split_user_message_segments(raw_content):
content = self._normalize_content(content_body)
if not content:
@@ -161,7 +155,7 @@ class MaisakaReplyGenerator:
parts.append(f"{timestamp} {visible_speaker}: {content}")
continue
if role == "assistant":
if isinstance(message, AssistantMessage):
visible_reply = self._extract_visible_assistant_reply(message)
if visible_reply:
parts.append(f"{timestamp} {bot_nickname}(you): {visible_reply}")
@@ -170,7 +164,7 @@ class MaisakaReplyGenerator:
def _build_prompt(
self,
chat_history: List[SessionMessage],
chat_history: List[LLMContextMessage],
reply_reason: str,
expression_habits: str = "",
) -> str:
@@ -182,6 +176,7 @@ class MaisakaReplyGenerator:
system_prompt = load_prompt(
"maidairy_replyer",
bot_name=global_config.bot.nickname,
time_block=f"当前时间:{current_time}",
identity=self._personality_prompt,
reply_style=global_config.personality.reply_style,
)
@@ -214,7 +209,7 @@ class MaisakaReplyGenerator:
async def _build_reply_context(
self,
chat_history: List[SessionMessage],
chat_history: List[LLMContextMessage],
reply_message: Optional[SessionMessage],
reply_reason: str,
stream_id: Optional[str],
@@ -239,7 +234,7 @@ class MaisakaReplyGenerator:
def _build_expression_habits(
self,
session_id: str,
chat_history: List[SessionMessage],
chat_history: List[LLMContextMessage],
reply_message: Optional[SessionMessage],
reply_reason: str,
) -> tuple[str, List[int]]:
@@ -301,7 +296,7 @@ class MaisakaReplyGenerator:
think_level: int = 1,
unknown_words: Optional[List[str]] = None,
log_reply: bool = True,
chat_history: Optional[List[SessionMessage]] = None,
chat_history: Optional[List[LLMContextMessage]] = None,
expression_habits: str = "",
selected_expression_ids: Optional[List[int]] = None,
) -> Tuple[bool, ReplyGenerationResult]:
@@ -330,9 +325,7 @@ class MaisakaReplyGenerator:
filtered_history = [
message
for message in chat_history
if get_message_role(message) != "system"
and get_message_kind(message) != "perception"
and get_message_source(message) != "user_reference"
if not isinstance(message, (ReferenceMessage, ToolResultMessage))
]
logger.debug(f"Maisaka replyer: filtered_history size={len(filtered_history)}")

View File

@@ -23,7 +23,13 @@ from src.config.config import config_manager, global_config
from src.mcp_module import MCPManager
from src.maisaka.chat_loop_service import MaisakaChatLoopService
from src.maisaka.message_adapter import build_message, format_speaker_content, remove_last_perception
from src.maisaka.context_messages import (
AssistantMessage,
LLMContextMessage,
SessionBackedMessage,
ToolResultMessage,
)
from src.maisaka.message_adapter import format_speaker_content
from src.maisaka.tool_handlers import (
ToolHandlerContext,
handle_mcp_tool,
@@ -43,7 +49,7 @@ class BufferCLI:
self._chat_loop_service: Optional[MaisakaChatLoopService] = None
self._reply_generator = MaisakaReplyGenerator()
self._reader = InputReader()
self._chat_history: Optional[list[SessionMessage]] = None
self._chat_history: Optional[list[LLMContextMessage]] = None
self._knowledge_store = get_knowledge_store()
self._knowledge_learner = KnowledgeLearner("maisaka_cli")
self._knowledge_min_messages_for_extraction = 10
@@ -118,22 +124,78 @@ class BufferCLI:
self._chat_start_time = now
self._last_assistant_response_time = None
self._chat_history = self._chat_loop_service.build_chat_context(user_text)
self._trigger_knowledge_learning([self._chat_history[-1]])
self._trigger_knowledge_learning([self._build_cli_session_message(user_text, now)])
else:
self._chat_history.append(
build_message(
role="user",
content=format_speaker_content(
global_config.maisaka.user_name.strip() or "User",
user_text,
now,
),
self._build_cli_context_message(
user_text=user_text,
timestamp=now,
source_kind="user",
)
)
self._trigger_knowledge_learning([self._chat_history[-1]])
self._trigger_knowledge_learning([self._build_cli_session_message(user_text, now)])
await self._run_llm_loop(self._chat_history)
@staticmethod
def _build_cli_context_message(
user_text: str,
timestamp: datetime,
source_kind: str = "user",
speaker_name: Optional[str] = None,
) -> SessionBackedMessage:
"""为 CLI 构造新的上下文消息。"""
resolved_speaker_name = speaker_name or global_config.maisaka.user_name.strip() or "User"
visible_text = format_speaker_content(
resolved_speaker_name,
user_text,
timestamp,
)
planner_prefix = (
f"[时间]{timestamp.strftime('%H:%M:%S')}\n"
f"[用户]{resolved_speaker_name}\n"
"[用户群昵称]\n"
"[msg_id]\n"
"[发言内容]"
)
from src.common.data_models.message_component_data_model import MessageSequence, TextComponent
return SessionBackedMessage(
raw_message=MessageSequence([TextComponent(f"{planner_prefix}{user_text}")]),
visible_text=visible_text,
timestamp=timestamp,
source_kind=source_kind,
)
@staticmethod
def _build_cli_session_message(user_text: str, timestamp: datetime) -> SessionMessage:
"""为 CLI 的知识学习构造兼容 SessionMessage。"""
from src.common.data_models.mai_message_data_model import MessageInfo, UserInfo
from src.common.data_models.message_component_data_model import MessageSequence
message = SessionMessage(message_id=f"maisaka_cli_{int(timestamp.timestamp() * 1000)}", timestamp=timestamp, platform="maisaka")
message.message_info = MessageInfo(
user_info=UserInfo(
user_id="maisaka_user",
user_nickname=global_config.maisaka.user_name.strip() or "User",
user_cardname=None,
),
group_info=None,
additional_config={},
)
message.session_id = "maisaka_cli"
message.raw_message = MessageSequence([])
visible_text = format_speaker_content(
global_config.maisaka.user_name.strip() or "User",
user_text,
timestamp,
)
message.raw_message.text(visible_text)
message.processed_plain_text = visible_text
message.display_message = visible_text
message.initialized = True
return message
def _trigger_knowledge_learning(self, messages: list[SessionMessage]) -> None:
"""在 CLI 会话中按批次触发 knowledge 学习。"""
if not global_config.maisaka.enable_knowledge_module:
@@ -161,7 +223,7 @@ class BufferCLI:
except Exception as exc:
console.print(f"[warning]Knowledge learning failed: {exc}[/warning]")
async def _run_llm_loop(self, chat_history: list[SessionMessage]) -> None:
async def _run_llm_loop(self, chat_history: list[LLMContextMessage]) -> None:
"""
Main inner loop for the Maisaka planner.
@@ -210,7 +272,8 @@ class BufferCLI:
)
)
remove_last_perception(chat_history)
if chat_history and isinstance(chat_history[-1], AssistantMessage) and chat_history[-1].source == "perception":
chat_history.pop()
perception_parts = []
if knowledge_analysis:
@@ -218,11 +281,10 @@ class BufferCLI:
if perception_parts:
chat_history.append(
build_message(
role="assistant",
AssistantMessage(
content="\n\n".join(perception_parts),
message_kind="perception",
source="assistant",
timestamp=datetime.now(),
source_kind="perception",
)
)
elif global_config.maisaka.show_thinking:
@@ -273,22 +335,19 @@ class BufferCLI:
elif tool_call.func_name == "reply":
reply = await self._generate_visible_reply(chat_history, response.content)
chat_history.append(
build_message(
role="tool",
ToolResultMessage(
content="Visible reply generated and recorded.",
source="tool",
timestamp=datetime.now(),
tool_call_id=tool_call.call_id,
tool_name=tool_call.func_name,
)
)
chat_history.append(
build_message(
role="user",
content=format_speaker_content(
global_config.bot.nickname.strip() or "MaiSaka",
reply,
datetime.now(),
),
source="guided_reply",
self._build_cli_context_message(
user_text=reply,
timestamp=datetime.now(),
source_kind="guided_reply",
speaker_name=global_config.bot.nickname.strip() or "MaiSaka",
)
)
@@ -296,11 +355,11 @@ class BufferCLI:
if global_config.maisaka.show_thinking:
console.print("[muted]No visible reply this round.[/muted]")
chat_history.append(
build_message(
role="tool",
ToolResultMessage(
content="No visible reply was sent for this round.",
source="tool",
timestamp=datetime.now(),
tool_call_id=tool_call.call_id,
tool_name=tool_call.func_name,
)
)
@@ -342,7 +401,7 @@ class BufferCLI:
)
)
async def _generate_visible_reply(self, chat_history: list[SessionMessage], latest_thought: str) -> str:
async def _generate_visible_reply(self, chat_history: list[LLMContextMessage], latest_thought: str) -> str:
"""根据最新思考生成并输出可见回复。"""
if not latest_thought:
return ""

View File

@@ -11,10 +11,11 @@ from src.chat.message_receive.message import SessionMessage
from src.chat.utils.utils import is_bot_self
from src.common.data_models.llm_service_data_models import LLMGenerationOptions
from src.common.logger import get_logger
from src.maisaka.context_messages import AssistantMessage, LLMContextMessage, SessionBackedMessage, ToolResultMessage
from src.services.llm_service import LLMServiceClient
from src.know_u.knowledge_store import KNOWLEDGE_CATEGORIES, get_knowledge_store
from src.maisaka.message_adapter import get_message_role, get_message_text, parse_speaker_content
from src.maisaka.message_adapter import parse_speaker_content
logger = get_logger("maisaka_knowledge")
@@ -53,7 +54,7 @@ def extract_category_ids_from_result(result: str) -> List[str]:
async def retrieve_relevant_knowledge(
knowledge_analyzer: Any,
chat_history: List[SessionMessage],
chat_history: List[LLMContextMessage],
) -> str:
"""Retrieve formatted knowledge snippets relevant to the current chat history."""
store = get_knowledge_store()
@@ -156,14 +157,26 @@ class KnowledgeLearner:
"""
lines: List[str] = []
for message in self._messages_cache[-30:]:
if get_message_role(message) == "assistant":
continue
if get_message_role(message) == "tool":
continue
if is_bot_self(message.platform, message.message_info.user_info.user_id):
if isinstance(message, (AssistantMessage, ToolResultMessage)):
continue
if isinstance(message, SessionBackedMessage):
if message.original_message and is_bot_self(
message.original_message.platform,
message.original_message.message_info.user_info.user_id,
):
continue
raw_text = message.processed_plain_text.strip()
fallback_speaker = (
message.original_message.message_info.user_info.user_nickname
if message.original_message is not None
else "用户"
)
else:
if is_bot_self(message.platform, message.message_info.user_info.user_id):
continue
raw_text = message.processed_plain_text.strip()
fallback_speaker = message.message_info.user_info.user_nickname or "用户"
raw_text = get_message_text(message).strip()
if not raw_text:
continue
@@ -172,7 +185,7 @@ class KnowledgeLearner:
if not visible_text:
continue
speaker = speaker_name or message.message_info.user_info.user_nickname or "用户"
speaker = speaker_name or fallback_speaker
lines.append(f"{speaker}: {visible_text}")
return "\n".join(lines)

View File

@@ -3,6 +3,7 @@ from typing import Any, Callable, Coroutine, Generic, Tuple, TypeVar, cast
import asyncio
from src.common.logger import get_logger
from src.config.model_configs import ModelInfo
from .base_client import (
@@ -33,12 +34,14 @@ ProviderStreamResponseHandler = Callable[
ProviderResponseParser = Callable[[RawResponseT], Tuple[APIResponse, UsageTuple | None]]
"""Provider 专用非流式响应解析函数类型。"""
logger = get_logger("llm_adapter_base")
async def await_task_with_interrupt(
task: asyncio.Task[TaskResultT],
interrupt_flag: asyncio.Event | None,
*,
interval_seconds: float = 0.1,
interval_seconds: float = 0.02,
) -> TaskResultT:
"""在支持外部中断的前提下等待异步任务完成。
@@ -55,8 +58,11 @@ async def await_task_with_interrupt(
"""
from src.llm_models.exceptions import ReqAbortException
started_at = asyncio.get_running_loop().time()
while not task.done():
if interrupt_flag and interrupt_flag.is_set():
elapsed = asyncio.get_running_loop().time() - started_at
logger.info(f"LLM 请求检测到中断信号准备取消底层任务elapsed={elapsed:.3f}s")
task.cancel()
raise ReqAbortException("请求被外部信号中断")
await asyncio.sleep(interval_seconds)

View File

@@ -22,6 +22,7 @@ from src.llm_models.exceptions import (
EmptyResponseException,
ModelAttemptFailed,
NetworkConnectionError,
ReqAbortException,
RespNotOkException,
RespParseException,
)
@@ -326,16 +327,7 @@ class LLMOrchestrator:
del raise_when_empty
self._refresh_task_config()
start_time = time.time()
if self.request_type.startswith("maisaka_"):
logger.info(
f"LLMOrchestrator[{self.request_type}] 开始执行 generate_response_with_message_async "
f"(temperature={temperature}, max_tokens={max_tokens}, tools={len(tools or [])})"
)
if self.request_type.startswith("maisaka_"):
logger.info(
f"LLMOrchestrator[{self.request_type}] 正在根据 {len(tools or [])} 个工具构建内部工具选项"
)
tool_built = self._build_tool_options(tools)
if self.request_type.startswith("maisaka_"):
logger.info(f"LLMOrchestrator[{self.request_type}] 已构建 {len(tool_built or [])} 个内部工具选项")
@@ -777,6 +769,9 @@ class LLMOrchestrator:
)
await asyncio.sleep(api_provider.retry_interval)
except ReqAbortException:
raise
except Exception as e:
logger.error(traceback.format_exc())
@@ -881,6 +876,15 @@ class LLMOrchestrator:
self.model_usage[model_info.name] = (total_tokens, penalty, usage_penalty - 1)
return LLMExecutionResult(api_response=response, model_info=model_info)
except ReqAbortException as e:
total_tokens, penalty, usage_penalty = self.model_usage[model_info.name]
self.model_usage[model_info.name] = (total_tokens, penalty, usage_penalty - 1)
if self.request_type.startswith("maisaka_"):
logger.info(
f"LLMOrchestrator[{self.request_type}] 模型 model={model_info.name} 的请求已被外部信号中断"
)
raise e
except ModelAttemptFailed as e:
last_exception = e.original_exception or e
logger.warning(f"模型 '{model_info.name}' 尝试失败,切换到下一个模型。原因: {e}")

View File

@@ -14,9 +14,9 @@ from rich.panel import Panel
from rich.pretty import Pretty
from rich.text import Text
from src.chat.message_receive.message import SessionMessage
from src.cli.console import console
from src.common.data_models.llm_service_data_models import LLMGenerationOptions
from src.common.data_models.message_component_data_model import MessageSequence, TextComponent
from src.common.logger import get_logger
from src.common.prompt_i18n import load_prompt
from src.config.config import global_config
@@ -27,12 +27,8 @@ from src.llm_models.payload_content.tool_option import ToolCall, ToolDefinitionI
from src.services.llm_service import LLMServiceClient
from .builtin_tools import get_builtin_tools
from .message_adapter import (
build_message,
format_speaker_content,
get_message_role,
to_llm_message,
)
from .context_messages import AssistantMessage, LLMContextMessage, SessionBackedMessage
from .message_adapter import format_speaker_content
@dataclass(slots=True)
@@ -41,7 +37,7 @@ class ChatResponse:
content: Optional[str]
tool_calls: List[ToolCall]
raw_message: SessionMessage
raw_message: AssistantMessage
logger = get_logger("maisaka_chat_loop")
@@ -59,6 +55,7 @@ class MaisakaChatLoopService:
self._temperature = temperature
self._max_tokens = max_tokens
self._extra_tools: List[ToolOption] = []
self._interrupt_flag: asyncio.Event | None = None
self._prompts_loaded = False
self._prompt_load_lock = asyncio.Lock()
self._personality_prompt = self._build_personality_prompt()
@@ -117,18 +114,21 @@ class MaisakaChatLoopService:
def set_extra_tools(self, tools: List[ToolDefinitionInput]) -> None:
self._extra_tools = normalize_tool_options(tools) or []
def set_interrupt_flag(self, interrupt_flag: asyncio.Event | None) -> None:
"""设置当前 planner 请求使用的中断标记。"""
self._interrupt_flag = interrupt_flag
async def analyze_knowledge_need(
self,
chat_history: List[SessionMessage],
chat_history: List[LLMContextMessage],
categories_summary: str,
) -> List[str]:
"""分析当前对话是否需要检索知识库分类。"""
visible_history: List[str] = []
for message in chat_history[-8:]:
if not message.content:
if not message.processed_plain_text:
continue
role = getattr(message, "role", "")
visible_history.append(f"{role}: {message.content}")
visible_history.append(f"{message.role}: {message.processed_plain_text}")
if not visible_history or not categories_summary.strip():
return []
@@ -302,7 +302,7 @@ class MaisakaChatLoopService:
padding=(0, 1),
)
async def chat_loop_step(self, chat_history: List[SessionMessage]) -> ChatResponse:
async def chat_loop_step(self, chat_history: List[LLMContextMessage]) -> ChatResponse:
await self.ensure_chat_prompt_loaded()
selected_history, selection_reason = self._select_llm_context_messages(chat_history)
@@ -313,7 +313,7 @@ class MaisakaChatLoopService:
messages.append(system_msg.build())
for msg in selected_history:
llm_message = to_llm_message(msg)
llm_message = msg.to_llm_message()
if llm_message is not None:
messages.append(llm_message)
@@ -342,15 +342,24 @@ class MaisakaChatLoopService:
)
request_started_at = perf_counter()
logger.info(
"planner 请求开始: "
f"selected_history={len(selected_history)} "
f"llm_messages={len(built_messages)} "
f"tool_count={len(all_tools)} "
f"interrupt_enabled={self._interrupt_flag is not None}"
)
generation_result = await self._llm_chat.generate_response_with_messages(
message_factory=message_factory,
options=LLMGenerationOptions(
tool_options=all_tools if all_tools else None,
temperature=self._temperature,
max_tokens=self._max_tokens,
interrupt_flag=self._interrupt_flag,
),
)
_ = perf_counter() - request_started_at
request_elapsed = perf_counter() - request_started_at
logger.info(f"planner 请求完成elapsed={request_elapsed:.3f}s")
tool_call_summaries = [
{
@@ -365,11 +374,10 @@ class MaisakaChatLoopService:
f"tool_calls={tool_call_summaries}"
)
raw_message = build_message(
role=RoleType.Assistant.value,
raw_message = AssistantMessage(
content=generation_result.response or "",
source="assistant",
tool_calls=generation_result.tool_calls or None,
timestamp=datetime.now(),
tool_calls=generation_result.tool_calls or [],
)
return ChatResponse(
content=generation_result.response,
@@ -378,20 +386,19 @@ class MaisakaChatLoopService:
)
@staticmethod
def _select_llm_context_messages(chat_history: List[SessionMessage]) -> tuple[List[SessionMessage], str]:
def _select_llm_context_messages(chat_history: List[LLMContextMessage]) -> tuple[List[LLMContextMessage], str]:
"""选择真正发送给 LLM 的上下文消息。"""
max_context_size = max(1, int(global_config.chat.max_context_size))
counted_roles = {"user", "assistant"}
selected_indices: List[int] = []
counted_message_count = 0
for index in range(len(chat_history) - 1, -1, -1):
message = chat_history[index]
if to_llm_message(message) is None:
if message.to_llm_message() is None:
continue
selected_indices.append(index)
if get_message_role(message) in counted_roles:
if message.count_in_context:
counted_message_count += 1
if counted_message_count >= max_context_size:
break
@@ -410,15 +417,25 @@ class MaisakaChatLoopService:
)
@staticmethod
def build_chat_context(user_text: str) -> List[SessionMessage]:
def build_chat_context(user_text: str) -> List[LLMContextMessage]:
timestamp = datetime.now()
visible_text = format_speaker_content(
global_config.maisaka.user_name.strip() or "用户",
user_text,
timestamp,
)
planner_prefix = (
f"[时间]{timestamp.strftime('%H:%M:%S')}\n"
f"[用户]{global_config.maisaka.user_name.strip() or '用户'}\n"
"[用户群昵称]\n"
"[msg_id]\n"
"[发言内容]"
)
return [
build_message(
role=RoleType.User.value,
content=format_speaker_content(
global_config.maisaka.user_name.strip() or "用户",
user_text,
datetime.now(),
),
source="user",
SessionBackedMessage(
raw_message=MessageSequence([TextComponent(f"{planner_prefix}{user_text}")]),
visible_text=visible_text,
timestamp=timestamp,
source_kind="user",
)
]

View File

@@ -0,0 +1,275 @@
"""Maisaka 内部上下文消息抽象。"""
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from io import BytesIO
from typing import Optional
import base64
from PIL import Image as PILImage
from src.chat.message_receive.message import SessionMessage
from src.common.data_models.message_component_data_model import EmojiComponent, ImageComponent, MessageSequence, TextComponent
from src.llm_models.payload_content.message import Message, MessageBuilder, RoleType
from src.llm_models.payload_content.tool_option import ToolCall
def _guess_image_format(image_bytes: bytes) -> Optional[str]:
if not image_bytes:
return None
try:
with PILImage.open(BytesIO(image_bytes)) as image:
return image.format.lower() if image.format else None
except Exception:
return None
def _build_message_from_sequence(
role: RoleType,
message_sequence: MessageSequence,
fallback_text: str,
*,
tool_call_id: Optional[str] = None,
tool_calls: Optional[list[ToolCall]] = None,
) -> Optional[Message]:
"""根据消息片段构造统一 LLM 消息。"""
builder = MessageBuilder().set_role(role)
if role == RoleType.Assistant and tool_calls:
builder.set_tool_calls(tool_calls)
if role == RoleType.Tool and tool_call_id:
builder.add_tool_call(tool_call_id)
has_content = False
for component in message_sequence.components:
if isinstance(component, TextComponent):
if component.text:
builder.add_text_content(component.text)
has_content = True
continue
if isinstance(component, (EmojiComponent, ImageComponent)):
image_format = _guess_image_format(component.binary_data)
if image_format and component.binary_data:
builder.add_image_content(image_format, base64.b64encode(component.binary_data).decode("utf-8"))
has_content = True
continue
if component.content:
builder.add_text_content(component.content)
has_content = True
if not has_content and fallback_text:
builder.add_text_content(fallback_text)
has_content = True
if not has_content and not (role == RoleType.Assistant and tool_calls):
return None
return builder.build()
class ReferenceMessageType(str, Enum):
"""参考消息类型。"""
CUSTOM = "custom"
JARGON = "jargon"
KNOWLEDGE = "knowledge"
MEMORY = "memory"
TOOL_HINT = "tool_hint"
class LLMContextMessage(ABC):
"""Maisaka 内部用于组织 LLM 上下文的统一消息抽象。"""
timestamp: datetime
@property
@abstractmethod
def role(self) -> str:
"""返回 LLM 消息角色。"""
@property
@abstractmethod
def processed_plain_text(self) -> str:
"""返回可读的纯文本内容。"""
@property
def count_in_context(self) -> bool:
"""是否占用普通 user/assistant 上下文窗口。"""
return True
@property
def source(self) -> str:
"""返回消息来源。"""
return self.__class__.__name__
@abstractmethod
def to_llm_message(self) -> Optional[Message]:
"""转换为统一 LLM 消息。"""
def consume_once(self) -> bool:
"""消费一次生命周期,返回是否继续保留。"""
return True
@dataclass(slots=True)
class SessionBackedMessage(LLMContextMessage):
"""真实会话上下文消息。"""
raw_message: MessageSequence
visible_text: str
timestamp: datetime
message_id: Optional[str] = None
original_message: Optional[SessionMessage] = None
source_kind: str = "user"
@property
def role(self) -> str:
return RoleType.User.value
@property
def processed_plain_text(self) -> str:
return self.visible_text
@property
def source(self) -> str:
return self.source_kind
def to_llm_message(self) -> Optional[Message]:
return _build_message_from_sequence(
RoleType.User,
self.raw_message,
self.processed_plain_text,
)
@classmethod
def from_session_message(
cls,
session_message: SessionMessage,
*,
raw_message: MessageSequence,
visible_text: str,
source_kind: str = "user",
) -> "SessionBackedMessage":
"""从真实 SessionMessage 构造上下文消息。"""
return cls(
raw_message=raw_message,
visible_text=visible_text,
timestamp=session_message.timestamp,
message_id=session_message.message_id,
original_message=session_message,
source_kind=source_kind,
)
@dataclass(slots=True)
class ReferenceMessage(LLMContextMessage):
"""参考消息。"""
content: str
timestamp: datetime
reference_type: ReferenceMessageType = ReferenceMessageType.CUSTOM
remaining_uses_value: Optional[int] = 1
display_prefix: str = "[参考消息]"
@property
def role(self) -> str:
return RoleType.User.value
@property
def processed_plain_text(self) -> str:
return f"{self.display_prefix}\n{self.content}".strip()
@property
def count_in_context(self) -> bool:
return False
@property
def source(self) -> str:
return self.reference_type.value
def to_llm_message(self) -> Optional[Message]:
message_sequence = MessageSequence([TextComponent(self.processed_plain_text)])
return _build_message_from_sequence(RoleType.User, message_sequence, self.processed_plain_text)
def consume_once(self) -> bool:
if self.remaining_uses_value is None:
return True
self.remaining_uses_value -= 1
return self.remaining_uses_value > 0
@dataclass(slots=True)
class AssistantMessage(LLMContextMessage):
"""内部 assistant 消息。"""
content: str
timestamp: datetime
tool_calls: list[ToolCall] = field(default_factory=list)
source_kind: str = "assistant"
@property
def role(self) -> str:
return RoleType.Assistant.value
@property
def processed_plain_text(self) -> str:
return self.content
@property
def count_in_context(self) -> bool:
return self.source_kind != "perception"
@property
def source(self) -> str:
return self.source_kind
def to_llm_message(self) -> Optional[Message]:
message_sequence = MessageSequence([])
if self.content:
message_sequence.text(self.content)
return _build_message_from_sequence(
RoleType.Assistant,
message_sequence,
self.content,
tool_calls=self.tool_calls or None,
)
@dataclass(slots=True)
class ToolResultMessage(LLMContextMessage):
"""工具返回结果消息。"""
content: str
timestamp: datetime
tool_call_id: str
tool_name: str = ""
success: bool = True
@property
def role(self) -> str:
return RoleType.Tool.value
@property
def processed_plain_text(self) -> str:
return self.content
@property
def count_in_context(self) -> bool:
return False
@property
def source(self) -> str:
return self.tool_name or "tool"
def to_llm_message(self) -> Optional[Message]:
message_sequence = MessageSequence([TextComponent(self.content)])
return _build_message_from_sequence(
RoleType.Tool,
message_sequence,
self.content,
tool_call_id=self.tool_call_id,
)

View File

@@ -1,148 +1,32 @@
"""
MaiSaka 内部消息适配器。
"""
"""Maisaka 文本与消息片段适配工具。"""
from copy import deepcopy
from datetime import datetime
from io import BytesIO
from typing import Optional
from uuid import uuid4
import base64
import re
from PIL import Image as PILImage
from src.chat.message_receive.message import SessionMessage
from src.common.data_models.mai_message_data_model import GroupInfo, MessageInfo, UserInfo
from src.common.data_models.message_component_data_model import EmojiComponent, ImageComponent, MessageSequence, TextComponent
from src.config.config import global_config
from src.llm_models.payload_content.message import Message, MessageBuilder, RoleType
from src.llm_models.payload_content.tool_option import ToolCall
MAISAKA_PLATFORM = "maisaka"
MAISAKA_SESSION_ID = "maisaka_cli"
MESSAGE_KIND_KEY = "maisaka_message_kind"
SOURCE_KEY = "maisaka_source"
LLM_ROLE_KEY = "maisaka_llm_role"
TOOL_CALL_ID_KEY = "maisaka_tool_call_id"
TOOL_CALLS_KEY = "maisaka_tool_calls"
SPEAKER_PREFIX_PATTERN = re.compile(
r"^(?:(?P<timestamp>\d{2}:\d{2}:\d{2}))?(?:\[msg_id:(?P<message_id>[^\]]+)\])?\[(?P<speaker>[^\]]+)\](?P<content>.*)$",
re.DOTALL,
)
def _build_user_info_for_role(role: str) -> UserInfo:
if role == RoleType.User.value:
return UserInfo(
user_id="maisaka_user",
user_nickname=global_config.maisaka.user_name.strip() or "用户",
user_cardname=None,
)
if role == RoleType.Tool.value:
return UserInfo(user_id="maisaka_tool", user_nickname="tool", user_cardname=None)
return UserInfo(
user_id="maisaka_assistant",
user_nickname=global_config.bot.nickname.strip() or "MaiSaka",
user_cardname=None,
)
def _serialize_tool_call(tool_call: ToolCall) -> dict:
return {
"call_id": tool_call.call_id,
"func_name": tool_call.func_name,
"args": tool_call.args or {},
}
def _deserialize_tool_call(data: dict) -> ToolCall:
return ToolCall(
call_id=str(data.get("call_id", "")),
func_name=str(data.get("func_name", "")),
args=data.get("args", {}) or {},
)
def _ensure_message_id_in_speaker_content(content: str, message_id: str) -> str:
"""Ensure speaker-formatted visible text carries a msg_id marker."""
match = SPEAKER_PREFIX_PATTERN.match(content or "")
if not match:
return content
existing_message_id = match.group("message_id")
if existing_message_id:
return content
timestamp_text = match.group("timestamp")
speaker_name = match.group("speaker")
visible_content = match.group("content")
timestamp = datetime.strptime(timestamp_text, "%H:%M:%S") if timestamp_text else None
return format_speaker_content(speaker_name, visible_content, timestamp, message_id)
def build_message(
role: str,
content: str = "",
*,
message_kind: str = "normal",
source: Optional[str] = None,
tool_call_id: Optional[str] = None,
tool_calls: Optional[list[ToolCall]] = None,
timestamp: Optional[datetime] = None,
message_id: Optional[str] = None,
platform: str = MAISAKA_PLATFORM,
session_id: str = MAISAKA_SESSION_ID,
user_info: Optional[UserInfo] = None,
group_info: Optional[GroupInfo] = None,
raw_message: Optional[MessageSequence] = None,
display_text: Optional[str] = None,
) -> SessionMessage:
"""为 MaiSaka 会话历史构建内部 ``SessionMessage``。"""
resolved_timestamp = timestamp or datetime.now()
resolved_role = role.value if isinstance(role, RoleType) else role
message = SessionMessage(
message_id=message_id or f"maisaka_{uuid4().hex}",
timestamp=resolved_timestamp,
platform=platform,
)
normalized_content = _ensure_message_id_in_speaker_content(content, message.message_id) if content else content
message.message_info = MessageInfo(
user_info=user_info or _build_user_info_for_role(resolved_role),
group_info=group_info,
additional_config={
LLM_ROLE_KEY: resolved_role,
MESSAGE_KIND_KEY: message_kind,
SOURCE_KEY: source or resolved_role,
TOOL_CALL_ID_KEY: tool_call_id,
TOOL_CALLS_KEY: [_serialize_tool_call(tool_call) for tool_call in (tool_calls or [])],
},
)
message.session_id = session_id
message.raw_message = raw_message if raw_message is not None else MessageSequence([])
if raw_message is None and normalized_content:
message.raw_message.text(normalized_content)
visible_text = display_text if display_text is not None else normalized_content
message.processed_plain_text = visible_text
message.display_message = visible_text
message.initialized = True
return message
def format_speaker_content(
speaker_name: str,
content: str,
timestamp: Optional[datetime] = None,
message_id: Optional[str] = None,
) -> str:
"""Format visible conversation content with an explicit speaker label."""
"""将可见文本格式化为带说话人前缀的样式。"""
time_prefix = timestamp.strftime("%H:%M:%S") if timestamp is not None else ""
message_id_prefix = f"[msg_id:{message_id}]" if message_id else ""
return f"{time_prefix}{message_id_prefix}[{speaker_name}]{content}"
def parse_speaker_content(content: str) -> tuple[Optional[str], str]:
"""Parse content formatted as [speaker]message."""
"""解析形如 [speaker]message 的可见文本。"""
match = SPEAKER_PREFIX_PATTERN.match(content or "")
if not match:
return None, content or ""
@@ -150,12 +34,12 @@ def parse_speaker_content(content: str) -> tuple[Optional[str], str]:
def clone_message_sequence(message_sequence: MessageSequence) -> MessageSequence:
"""Create a detached copy of a message sequence."""
"""复制消息片段序列。"""
return MessageSequence([deepcopy(component) for component in message_sequence.components])
def build_visible_text_from_sequence(message_sequence: MessageSequence) -> str:
"""Extract visible text from a message sequence without forcing image descriptions."""
"""从消息片段序列提取可见文本。"""
parts: list[str] = []
for component in message_sequence.components:
if isinstance(component, TextComponent):
@@ -181,112 +65,5 @@ def build_visible_text_from_sequence(message_sequence: MessageSequence) -> str:
if isinstance(component, ImageComponent):
parts.append("[图片]")
return "".join(parts)
def _guess_image_format(image_bytes: bytes) -> Optional[str]:
if not image_bytes:
return None
try:
with PILImage.open(BytesIO(image_bytes)) as image:
return image.format.lower() if image.format else None
except Exception:
return None
def get_message_text(message: SessionMessage) -> str:
if message.processed_plain_text is not None:
return message.processed_plain_text
if message.display_message is not None:
return message.display_message
parts: list[str] = []
for component in message.raw_message.components:
text = getattr(component, "text", None)
if isinstance(text, str):
parts.append(text)
return "".join(parts)
def get_message_role(message: SessionMessage) -> str:
return str(message.message_info.additional_config.get(LLM_ROLE_KEY, RoleType.User.value))
def get_message_kind(message: SessionMessage) -> str:
return str(message.message_info.additional_config.get(MESSAGE_KIND_KEY, "normal"))
def get_message_source(message: SessionMessage) -> str:
return str(message.message_info.additional_config.get(SOURCE_KEY, get_message_role(message)))
def is_perception_message(message: SessionMessage) -> bool:
return get_message_kind(message) == "perception"
def get_tool_call_id(message: SessionMessage) -> Optional[str]:
value = message.message_info.additional_config.get(TOOL_CALL_ID_KEY)
return str(value) if value else None
def get_tool_calls(message: SessionMessage) -> list[ToolCall]:
raw_tool_calls = message.message_info.additional_config.get(TOOL_CALLS_KEY, [])
if not isinstance(raw_tool_calls, list):
return []
return [_deserialize_tool_call(item) for item in raw_tool_calls if isinstance(item, dict)]
def remove_last_perception(messages: list[SessionMessage]) -> None:
for index in range(len(messages) - 1, -1, -1):
if is_perception_message(messages[index]):
messages.pop(index)
break
def to_llm_message(message: SessionMessage) -> Optional[Message]:
role = get_message_role(message)
tool_call_id = get_tool_call_id(message)
tool_calls = get_tool_calls(message)
if role == RoleType.System.value:
role_type = RoleType.System
elif role == RoleType.User.value:
role_type = RoleType.User
elif role == RoleType.Assistant.value:
role_type = RoleType.Assistant
elif role == RoleType.Tool.value:
role_type = RoleType.Tool
else:
return None
builder = MessageBuilder().set_role(role_type)
if role_type == RoleType.Assistant and tool_calls:
builder.set_tool_calls(tool_calls)
if role_type == RoleType.Tool and tool_call_id:
builder.add_tool_call(tool_call_id)
has_content = False
for component in message.raw_message.components:
if isinstance(component, TextComponent):
if component.text:
builder.add_text_content(component.text)
has_content = True
continue
if isinstance(component, (ImageComponent, EmojiComponent)):
image_format = _guess_image_format(component.binary_data)
if image_format and component.binary_data:
builder.add_image_content(image_format, base64.b64encode(component.binary_data).decode("utf-8"))
has_content = True
continue
if component.content:
builder.add_text_content(component.content)
has_content = True
if not has_content:
content = get_message_text(message)
if content:
builder.add_text_content(content)
return builder.build()

View File

@@ -6,33 +6,32 @@ from typing import TYPE_CHECKING, Optional
import asyncio
import difflib
import json
import re
import time
import traceback
from sqlmodel import select
from src.chat.heart_flow.heartFC_utils import CycleDetail
from src.chat.message_receive.message import SessionMessage
from src.chat.replyer.replyer_manager import replyer_manager
from src.chat.utils.utils import get_bot_account, process_llm_response
from src.common.database.database import get_db_session
from src.common.database.database_model import Jargon
from src.common.data_models.mai_message_data_model import UserInfo
from src.chat.utils.utils import process_llm_response
from src.common.data_models.message_component_data_model import MessageSequence, TextComponent
from src.common.logger import get_logger
from src.config.config import global_config
from src.learners.jargon_explainer import search_jargon
from src.llm_models.exceptions import ReqAbortException
from src.llm_models.payload_content.tool_option import ToolCall
from src.services import database_service as database_api, send_service
from .context_messages import (
AssistantMessage,
LLMContextMessage,
SessionBackedMessage,
ToolResultMessage,
)
from .message_adapter import (
build_message,
build_visible_text_from_sequence,
clone_message_sequence,
format_speaker_content,
get_message_source,
get_message_text,
get_message_role,
)
from .tool_handlers import (
handle_mcp_tool,
@@ -51,7 +50,6 @@ class MaisakaReasoningEngine:
def __init__(self, runtime: "MaisakaHeartFlowChatting") -> None:
self._runtime = runtime
self._last_reasoning_content: str = ""
self._shown_jargons: set[str] = set() # 已在参考消息中展示过的 jargon
async def run_loop(self) -> None:
"""独立消费消息批次,并执行对应的内部思考轮次。"""
@@ -65,6 +63,7 @@ class MaisakaReasoningEngine:
self._runtime._agent_state = self._runtime._STATE_RUNNING
if cached_messages:
self._append_wait_interrupted_message_if_needed()
await self._ingest_messages(cached_messages)
anchor_message = cached_messages[-1]
else:
@@ -76,26 +75,35 @@ class MaisakaReasoningEngine:
self._runtime._internal_turn_queue.task_done()
continue
logger.info(f"{self._runtime.log_prefix} wait 超时后开始新一轮思考")
self._runtime._chat_history.append(self._build_wait_timeout_message(anchor_message))
self._runtime._chat_history.append(self._build_wait_timeout_message())
self._trim_chat_history()
try:
for round_index in range(self._runtime._max_internal_rounds):
cycle_detail = self._start_cycle()
self._runtime._log_cycle_started(cycle_detail, round_index)
try:
# 每次LLM生成前动态添加参考消息到最新位置
reference_added = self._append_jargon_reference_message()
planner_started_at = time.time()
response = await self._runtime._chat_loop_service.chat_loop_step(self._runtime._chat_history)
logger.info(
f"{self._runtime.log_prefix} planner 开始: "
f"round={round_index + 1} "
f"history_size={len(self._runtime._chat_history)} "
f"started_at={planner_started_at:.3f}"
)
interrupt_flag = asyncio.Event()
self._runtime._planner_interrupt_flag = interrupt_flag
self._runtime._chat_loop_service.set_interrupt_flag(interrupt_flag)
try:
response = await self._runtime._chat_loop_service.chat_loop_step(self._runtime._chat_history)
finally:
if self._runtime._planner_interrupt_flag is interrupt_flag:
self._runtime._planner_interrupt_flag = None
self._runtime._chat_loop_service.set_interrupt_flag(None)
cycle_detail.time_records["planner"] = time.time() - planner_started_at
# LLM调用后移除刚才添加的参考消息一次性使用
if reference_added and self._runtime._chat_history:
# 从末尾往前查找并移除参考消息
for i in range(len(self._runtime._chat_history) - 1, -1, -1):
if get_message_source(self._runtime._chat_history[i]) == "user_reference":
self._runtime._chat_history.pop(i)
break
logger.info(
f"{self._runtime.log_prefix} planner 完成: "
f"round={round_index + 1} "
f"elapsed={cycle_detail.time_records['planner']:.3f}s"
)
reasoning_content = response.content or ""
if self._should_replace_reasoning(reasoning_content):
@@ -104,9 +112,6 @@ class MaisakaReasoningEngine:
logger.info(f"{self._runtime.log_prefix} reasoning content replaced due to high similarity")
self._last_reasoning_content = reasoning_content
response.raw_message.platform = anchor_message.platform
response.raw_message.session_id = self._runtime.session_id
response.raw_message.message_info.group_info = self._runtime._build_group_info(anchor_message)
self._runtime._chat_history.append(response.raw_message)
if response.tool_calls:
@@ -124,6 +129,16 @@ class MaisakaReasoningEngine:
if response.content:
continue
break
except ReqAbortException:
interrupted_at = time.time()
logger.info(
f"{self._runtime.log_prefix} planner 打断成功: "
f"round={round_index + 1} "
f"started_at={planner_started_at:.3f} "
f"interrupted_at={interrupted_at:.3f} "
f"elapsed={interrupted_at - planner_started_at:.3f}s"
)
break
finally:
self._end_cycle(cycle_detail)
@@ -136,6 +151,7 @@ class MaisakaReasoningEngine:
raise
except Exception:
logger.exception("%s Maisaka internal loop crashed", self._runtime.log_prefix)
logger.error(traceback.format_exc())
raise
def _get_timeout_anchor_message(self) -> Optional[SessionMessage]:
@@ -144,16 +160,31 @@ class MaisakaReasoningEngine:
return self._runtime.message_cache[-1]
return None
def _build_wait_timeout_message(self, anchor_message: SessionMessage) -> SessionMessage:
"""构造 wait 超时后的工具结果消息,用于触发下一轮思考"""
return build_message(
role="tool",
def _build_wait_timeout_message(self) -> ToolResultMessage:
"""构造 wait 超时后的工具结果消息。"""
tool_call_id = self._runtime._pending_wait_tool_call_id or "wait_timeout"
self._runtime._pending_wait_tool_call_id = None
return ToolResultMessage(
content="wait 已超时,期间没有收到新的用户输入。请基于现有上下文继续下一轮思考。",
source="tool",
platform=anchor_message.platform,
session_id=self._runtime.session_id,
group_info=self._runtime._build_group_info(anchor_message),
user_info=UserInfo(user_id="maisaka_tool", user_nickname="tool", user_cardname=None),
timestamp=datetime.now(),
tool_call_id=tool_call_id,
tool_name="wait",
)
def _append_wait_interrupted_message_if_needed(self) -> None:
"""如果 wait 被新消息打断,则补一条对应的工具结果消息。"""
tool_call_id = self._runtime._pending_wait_tool_call_id
if not tool_call_id:
return
self._runtime._pending_wait_tool_call_id = None
self._runtime._chat_history.append(
ToolResultMessage(
content="wait 被新的用户输入打断,已继续处理最新消息。",
timestamp=datetime.now(),
tool_call_id=tool_call_id,
tool_name="wait",
)
)
async def _ingest_messages(self, messages: list[SessionMessage]) -> None:
@@ -164,17 +195,11 @@ class MaisakaReasoningEngine:
if not user_sequence.components:
continue
history_message = build_message(
role="user",
content=visible_text,
source="user",
timestamp=message.timestamp,
platform=message.platform,
session_id=self._runtime.session_id,
group_info=self._runtime._build_group_info(message),
user_info=self._runtime._build_runtime_user_info(),
history_message = SessionBackedMessage.from_session_message(
message,
raw_message=user_sequence,
display_text=visible_text,
visible_text=visible_text,
source_kind="user",
)
self._insert_chat_history_message(history_message)
self._trim_chat_history()
@@ -239,141 +264,10 @@ class MaisakaReasoningEngine:
speaker_name = user_info.user_cardname or user_info.user_nickname or user_info.user_id
return format_speaker_content(speaker_name, content, message.timestamp, message.message_id).strip()
def _insert_chat_history_message(self, message: SessionMessage) -> int:
"""按时间顺序将消息插入聊天历史,同时保留 system 消息在最前"""
if not self._runtime._chat_history:
self._runtime._chat_history.append(message)
return 0
insert_at = len(self._runtime._chat_history)
for index, existing_message in enumerate(self._runtime._chat_history):
if get_message_role(existing_message) == "system":
continue
if existing_message.timestamp > message.timestamp:
insert_at = index
break
self._runtime._chat_history.insert(insert_at, message)
return insert_at
def _append_jargon_reference_message(self) -> bool:
"""每次LLM生成前如果命中了黑话词条则添加一条参考信息消息到聊天历史末尾。
Returns:
bool: 是否添加了参考消息
"""
content = self._build_user_history_corpus()
if not content:
return False
matched_words = self._find_jargon_words_in_text(content)
if not matched_words:
return False
# 记录已展示的 jargon
for word in matched_words:
self._shown_jargons.add(word.lower())
reference_text = (
"[参考信息]\n"
f"{','.join(matched_words)}可能是jargon可以使用query_jargon来查看其含义"
)
reference_sequence = MessageSequence([TextComponent(reference_text)])
# 使用当前时间作为时间戳
reference_message = build_message(
role="user",
content="",
source="user_reference",
timestamp=datetime.now(),
platform=self._runtime.chat_stream.platform,
session_id=self._runtime.session_id,
group_info=self._runtime._build_group_info(),
user_info=self._runtime._build_runtime_user_info(),
raw_message=reference_sequence,
display_text=reference_text,
)
self._runtime._chat_history.append(reference_message)
return True
def _build_user_history_corpus(self) -> str:
"""拼接当前聊天记录内所有用户消息的正文,用于统一匹配黑话。"""
parts: list[str] = []
for history_message in self._runtime._chat_history:
if get_message_role(history_message) != "user":
continue
if get_message_source(history_message) != "user":
continue
text = (get_message_text(history_message) or "").strip()
if not text:
continue
parts.append(text)
return "\n".join(parts)
def _find_jargon_words_in_text(self, content: str) -> list[str]:
"""匹配正文中出现的 jargon 词条。"""
lowered_content = content.lower()
matched_entries: list[tuple[int, int, int, str]] = []
seen_words: set[str] = set()
with get_db_session(auto_commit=False) as session:
query = (
select(Jargon)
.where(Jargon.is_jargon.is_(True))
.order_by(Jargon.count.desc()) # type: ignore[attr-defined]
)
jargons = session.exec(query).all()
for jargon in jargons:
jargon_content = str(jargon.content or "").strip()
if not jargon_content:
continue
# meaning 为空的不匹配
if not str(jargon.meaning or "").strip():
continue
normalized_content = jargon_content.lower()
if normalized_content in seen_words:
continue
# 跳过已经展示过的 jargon
if normalized_content in self._shown_jargons:
continue
if not self._is_visible_jargon(jargon):
continue
match_position = self._get_jargon_match_position(jargon_content, lowered_content, content)
if match_position is None:
continue
seen_words.add(normalized_content)
matched_entries.append((match_position, -len(jargon_content), -int(jargon.count or 0), jargon_content))
matched_entries.sort()
return [matched_content for _, _, _, matched_content in matched_entries[:8]]
def _is_visible_jargon(self, jargon: Jargon) -> bool:
"""判断当前会话是否可见该 jargon。"""
if global_config.expression.all_global_jargon or bool(jargon.is_global):
return True
try:
session_id_dict = json.loads(jargon.session_id_dict or "{}")
except (TypeError, json.JSONDecodeError):
logger.warning(f"Failed to parse jargon.session_id_dict: jargon_id={jargon.id}")
return False
return self._runtime.session_id in session_id_dict
@staticmethod
def _get_jargon_match_position(jargon_content: str, lowered_content: str, original_content: str) -> Optional[int]:
"""返回 jargon 在文本中的首次命中位置,未命中时返回 `None`。"""
if re.search(r"[\u4e00-\u9fff]", jargon_content):
match_index = original_content.lower().find(jargon_content.lower())
return match_index if match_index >= 0 else None
pattern = rf"\b{re.escape(jargon_content.lower())}\b"
match = re.search(pattern, lowered_content)
if match is None:
return None
return match.start()
def _insert_chat_history_message(self, message: LLMContextMessage) -> int:
"""将消息按处理顺序追加到聊天历史末尾"""
self._runtime._chat_history.append(message)
return len(self._runtime._chat_history) - 1
def _start_cycle(self) -> CycleDetail:
"""开始一轮 Maisaka 思考循环。"""
@@ -397,10 +291,7 @@ class MaisakaReasoningEngine:
def _trim_chat_history(self) -> None:
"""裁剪聊天历史,保证用户消息数量不超过配置限制。"""
counted_roles = {"user", "assistant"}
conversation_message_count = sum(
1 for message in self._runtime._chat_history if get_message_role(message) in counted_roles
)
conversation_message_count = sum(1 for message in self._runtime._chat_history if message.count_in_context)
if conversation_message_count <= self._runtime._max_context_size:
return
@@ -410,7 +301,7 @@ class MaisakaReasoningEngine:
while conversation_message_count >= self._runtime._max_context_size and trimmed_history:
removed_message = trimmed_history.pop(0)
removed_count += 1
if get_message_role(removed_message) in counted_roles:
if removed_message.count_in_context:
conversation_message_count -= 1
self._runtime._chat_history = trimmed_history
@@ -441,6 +332,11 @@ class MaisakaReasoningEngine:
bool: 是否需要替换
"""
if not self._last_reasoning_content or not current_content:
logger.info(
f"{self._runtime.log_prefix} reasoning similarity skipped: "
f"last_empty={not bool(self._last_reasoning_content)} "
f"current_empty={not bool(current_content)} similarity=0.00"
)
return False
similarity = self._calculate_similarity(current_content, self._last_reasoning_content)
@@ -495,13 +391,7 @@ class MaisakaReasoningEngine:
except (TypeError, ValueError):
wait_seconds = 30
wait_seconds = max(0, wait_seconds)
self._runtime._chat_history.append(
self._build_tool_message(
tool_call,
f"Waiting for future input for up to {wait_seconds} seconds.",
)
)
self._runtime._enter_wait_state(seconds=wait_seconds)
self._runtime._enter_wait_state(seconds=wait_seconds, tool_call_id=tool_call.call_id)
return True
if tool_call.func_name == "stop":
@@ -743,33 +633,27 @@ class MaisakaReasoningEngine:
tool_reasoning=latest_thought,
)
target_platform = target_message.platform or anchor_message.platform
bot_name = global_config.bot.nickname.strip() or "MaiSaka"
bot_user_info = UserInfo(
user_id=get_bot_account(target_platform) or "maisaka_assistant",
user_nickname=bot_name,
user_cardname=None,
reply_timestamp = datetime.now()
planner_prefix = (
f"[时间]{reply_timestamp.strftime('%H:%M:%S')}\n"
f"[用户]{bot_name}\n"
"[用户群昵称]\n"
"[msg_id]\n"
"[发言内容]"
)
history_message = build_message(
role="user",
content="",
source="guided_reply",
platform=target_platform,
session_id=self._runtime.session_id,
group_info=self._runtime._build_group_info(target_message),
user_info=bot_user_info,
)
history_message.raw_message = MessageSequence(
[TextComponent(f"{self._build_planner_user_prefix(history_message)}{combined_reply_text}")]
history_message = SessionBackedMessage(
raw_message=MessageSequence([TextComponent(f"{planner_prefix}{combined_reply_text}")]),
visible_text="",
timestamp=reply_timestamp,
source_kind="guided_reply",
)
visible_reply_text = format_speaker_content(
bot_name,
combined_reply_text,
history_message.timestamp,
history_message.message_id,
reply_timestamp,
)
history_message.display_message = visible_reply_text
history_message.processed_plain_text = visible_reply_text
history_message.visible_text = visible_reply_text
self._runtime._chat_history.append(history_message)
return True
@@ -871,14 +755,10 @@ class MaisakaReasoningEngine:
self._build_tool_message(tool_call, "Failed to send emoji.")
)
def _build_tool_message(self, tool_call: ToolCall, content: str) -> SessionMessage:
return build_message(
role="tool",
def _build_tool_message(self, tool_call: ToolCall, content: str) -> ToolResultMessage:
return ToolResultMessage(
content=content,
source="tool",
timestamp=datetime.now(),
tool_call_id=tool_call.call_id,
platform=self._runtime.chat_stream.platform,
session_id=self._runtime.session_id,
group_info=self._runtime._build_group_info(),
user_info=UserInfo(user_id="maisaka_tool", user_nickname="tool", user_cardname=None),
tool_name=tool_call.func_name,
)

View File

@@ -19,6 +19,7 @@ from src.learners.jargon_miner import JargonMiner
from src.mcp_module import MCPManager
from .chat_loop_service import MaisakaChatLoopService
from .context_messages import LLMContextMessage
from .reasoning_engine import MaisakaReasoningEngine
logger = get_logger("maisaka_runtime")
@@ -40,7 +41,7 @@ class MaisakaHeartFlowChatting:
session_name = chat_manager.get_session_name(session_id) or session_id
self.log_prefix = f"[{session_name}]"
self._chat_loop_service = MaisakaChatLoopService()
self._chat_history: list[SessionMessage] = []
self._chat_history: list[LLMContextMessage] = []
self.history_loop: list[CycleDetail] = []
# Keep all original messages for batching and later learning.
@@ -60,6 +61,8 @@ class MaisakaHeartFlowChatting:
self._max_context_size = max(1, int(global_config.chat.max_context_size))
self._agent_state: Literal["running", "wait", "stop"] = self._STATE_STOP
self._wait_until: Optional[float] = None
self._pending_wait_tool_call_id: Optional[str] = None
self._planner_interrupt_flag: Optional[asyncio.Event] = None
expr_use, jargon_learn, expr_learn = ExpressionConfigUtils.get_expression_config_for_chat(session_id)
self._enable_expression_use = expr_use
@@ -78,14 +81,14 @@ class MaisakaHeartFlowChatting:
async def start(self) -> None:
"""Start the runtime loop."""
if self._running:
self._ensure_background_tasks_running()
return
if global_config.maisaka.enable_mcp:
await self._init_mcp()
self._running = True
self._internal_loop_task = asyncio.create_task(self._reasoning_engine.run_loop())
self._loop_task = asyncio.create_task(self._main_loop())
self._ensure_background_tasks_running()
logger.info(f"{self.log_prefix} Maisaka runtime started")
async def stop(self) -> None:
@@ -128,12 +131,48 @@ class MaisakaHeartFlowChatting:
async def register_message(self, message: SessionMessage) -> None:
"""Cache a new message and wake the main loop."""
if self._running:
self._ensure_background_tasks_running()
self.message_cache.append(message)
self._source_messages_by_id[message.message_id] = message
if self._agent_state == self._STATE_RUNNING and self._planner_interrupt_flag is not None:
logger.info(
f"{self.log_prefix} 收到新消息,发起 planner 打断; "
f"msg_id={message.message_id} cache_size={len(self.message_cache)} "
f"timestamp={time.time():.3f}"
)
self._planner_interrupt_flag.set()
if self._agent_state in (self._STATE_WAIT, self._STATE_STOP):
self._agent_state = self._STATE_RUNNING
self._new_message_event.set()
def _ensure_background_tasks_running(self) -> None:
"""确保后台任务仍在运行,若崩溃则自动拉起。"""
if not self._running:
return
if self._internal_loop_task is None or self._internal_loop_task.done():
if self._internal_loop_task is not None and not self._internal_loop_task.cancelled():
try:
exc = self._internal_loop_task.exception()
except Exception:
exc = None
if exc is not None:
logger.error(f"{self.log_prefix} internal loop task exited unexpectedly: {exc}")
self._internal_loop_task = asyncio.create_task(self._reasoning_engine.run_loop())
logger.warning(f"{self.log_prefix} restarted Maisaka internal loop task")
if self._loop_task is None or self._loop_task.done():
if self._loop_task is not None and not self._loop_task.cancelled():
try:
exc = self._loop_task.exception()
except Exception:
exc = None
if exc is not None:
logger.error(f"{self.log_prefix} main loop task exited unexpectedly: {exc}")
self._loop_task = asyncio.create_task(self._main_loop())
logger.warning(f"{self.log_prefix} restarted Maisaka main loop task")
async def _main_loop(self) -> None:
try:
while self._running:
@@ -222,15 +261,17 @@ class MaisakaHeartFlowChatting:
self._wait_until = None
return "timeout"
def _enter_wait_state(self, seconds: Optional[float] = None) -> None:
def _enter_wait_state(self, seconds: Optional[float] = None, tool_call_id: Optional[str] = None) -> None:
"""Enter wait state."""
self._agent_state = self._STATE_WAIT
self._wait_until = None if seconds is None else time.time() + seconds
self._pending_wait_tool_call_id = tool_call_id
def _enter_stop_state(self) -> None:
"""Enter stop state."""
self._agent_state = self._STATE_STOP
self._wait_until = None
self._pending_wait_tool_call_id = None
async def _trigger_batch_learning(self, messages: list[SessionMessage]) -> None:
"""按同一批消息触发表达方式、黑话和 knowledge 学习。"""

View File

@@ -9,12 +9,11 @@ import json as _json
from rich.panel import Panel
from src.chat.message_receive.message import SessionMessage
from src.cli.console import console
from src.cli.input_reader import InputReader
from src.llm_models.payload_content.tool_option import ToolCall
from .message_adapter import build_message
from .context_messages import LLMContextMessage, ToolResultMessage
if TYPE_CHECKING:
from src.mcp_module import MCPManager
@@ -33,22 +32,34 @@ class ToolHandlerContext:
self.last_user_input_time: Optional[datetime] = None
async def handle_stop(tc: ToolCall, chat_history: list[SessionMessage]) -> None:
async def handle_stop(tc: ToolCall, chat_history: list[LLMContextMessage]) -> None:
"""处理 stop 工具。"""
console.print("[accent]调用工具: stop()[/accent]")
chat_history.append(
build_message(role="tool", content="当前轮次结束后将停止对话循环。", tool_call_id=tc.call_id)
ToolResultMessage(
content="当前轮次结束后将停止对话循环。",
timestamp=datetime.now(),
tool_call_id=tc.call_id,
tool_name=tc.func_name,
)
)
async def handle_wait(tc: ToolCall, chat_history: list[SessionMessage], ctx: ToolHandlerContext) -> str:
async def handle_wait(tc: ToolCall, chat_history: list[LLMContextMessage], ctx: ToolHandlerContext) -> str:
"""处理 wait 工具。"""
seconds = (tc.args or {}).get("seconds", 30)
seconds = max(5, min(seconds, 300))
console.print(f"[accent]调用工具: wait({seconds})[/accent]")
tool_result = await _do_wait(seconds, ctx)
chat_history.append(build_message(role="tool", content=tool_result, tool_call_id=tc.call_id))
chat_history.append(
ToolResultMessage(
content=tool_result,
timestamp=datetime.now(),
tool_call_id=tc.call_id,
tool_name=tc.func_name,
)
)
return tool_result
@@ -78,7 +89,7 @@ async def _do_wait(seconds: int, ctx: ToolHandlerContext) -> str:
return f"已收到用户输入: {user_input}"
async def handle_mcp_tool(tc: ToolCall, chat_history: list[SessionMessage], mcp_manager: "MCPManager") -> None:
async def handle_mcp_tool(tc: ToolCall, chat_history: list[LLMContextMessage], mcp_manager: "MCPManager") -> None:
"""处理 MCP 工具调用。"""
args_str = _json.dumps(tc.args or {}, ensure_ascii=False)
args_preview = args_str if len(args_str) <= 120 else args_str[:120] + "..."
@@ -96,10 +107,24 @@ async def handle_mcp_tool(tc: ToolCall, chat_history: list[SessionMessage], mcp_
padding=(0, 1),
)
)
chat_history.append(build_message(role="tool", content=result, tool_call_id=tc.call_id))
chat_history.append(
ToolResultMessage(
content=result,
timestamp=datetime.now(),
tool_call_id=tc.call_id,
tool_name=tc.func_name,
)
)
async def handle_unknown_tool(tc: ToolCall, chat_history: list[SessionMessage]) -> None:
async def handle_unknown_tool(tc: ToolCall, chat_history: list[LLMContextMessage]) -> None:
"""处理未知工具调用。"""
console.print(f"[accent]调用未知工具: {tc.func_name}({tc.args})[/accent]")
chat_history.append(build_message(role="tool", content=f"未知工具: {tc.func_name}", tool_call_id=tc.call_id))
chat_history.append(
ToolResultMessage(
content=f"未知工具: {tc.func_name}",
timestamp=datetime.now(),
tool_call_id=tc.call_id,
tool_name=tc.func_name,
)
)