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

@@ -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,
)
)