feat:可开启原生at功能

This commit is contained in:
SengokuCola
2026-04-23 15:56:27 +08:00
parent 2255592bd2
commit 35ff91d134
20 changed files with 258 additions and 404 deletions

View File

@@ -230,8 +230,10 @@ class SessionMessage(MaiMessage):
Returns:
str: 图片组件对应的文本表示。
"""
if component.content: # 先检查是否处理过
return component.content
normalized_content = component.content.strip()
if normalized_content: # 先检查是否处理过
component.content = normalized_content
return normalized_content
from src.chat.image_system.image_manager import image_manager
# 获取描述
@@ -263,8 +265,10 @@ class SessionMessage(MaiMessage):
Returns:
str: 表情包组件对应的文本表示。
"""
if component.content: # 先检查是否处理过
return component.content
normalized_content = component.content.strip()
if normalized_content: # 先检查是否处理过
component.content = normalized_content
return normalized_content
from src.emoji_system.emoji_manager import emoji_manager
# 获取表情包描述
@@ -323,8 +327,10 @@ class SessionMessage(MaiMessage):
Returns:
str: 语音组件对应的文本表示。
"""
if component.content: # 先检查是否处理过
return component.content
normalized_content = component.content.strip()
if normalized_content: # 先检查是否处理过
component.content = normalized_content
return normalized_content
if not enable_voice_transcription:
component.content = "[语音消息]"
return component.content

View File

@@ -201,6 +201,18 @@ class BaseMaisakaReplyGenerator:
return "在该聊天中的注意事项:\n" + "\n\n".join(prompt_lines) + "\n"
@staticmethod
def _build_replyer_at_block() -> str:
"""构建 replyer 模式下的 at 标记说明。"""
if not global_config.chat.enable_at:
return ""
return (
"如果需要提及某人、让某人关注你的回复,可以在回复中加入 `at[msg_id]` 标记,"
"其中 msg_id 应使用聊天记录中该用户发过的消息编号;"
"消息发送时会检查这种标记并转换为真正的 at 消息。\n"
)
def _build_system_prompt(
self,
reply_message: Optional[SessionMessage],
@@ -218,6 +230,7 @@ class BaseMaisakaReplyGenerator:
"maisaka_replyer",
bot_name=global_config.bot.nickname,
group_chat_attention_block=self._build_group_chat_attention_block(session_id),
replyer_at_block=self._build_replyer_at_block(),
time_block=f"当前时间:{current_time}",
identity=self._personality_prompt,
reply_style=global_config.personality.reply_style,

View File

@@ -385,7 +385,9 @@ class MessageSequence:
@staticmethod
def _ensure_binary_component_content(item: ByteComponent, fallback_text: str) -> str:
"""确保二进制组件在序列化时带有稳定的文本占位。"""
if item.content:
normalized_content = item.content.strip()
if normalized_content:
item.content = normalized_content
return item.content
item.content = fallback_text
return item.content

View File

@@ -55,7 +55,7 @@ BOT_CONFIG_PATH: Path = (CONFIG_DIR / "bot_config.toml").resolve().absolute()
MODEL_CONFIG_PATH: Path = (CONFIG_DIR / "model_config.toml").resolve().absolute()
LEGACY_ENV_PATH: Path = (PROJECT_ROOT / ".env").resolve().absolute()
MMC_VERSION: str = "1.0.0"
CONFIG_VERSION: str = "8.9.8"
CONFIG_VERSION: str = "8.9.11"
MODEL_CONFIG_VERSION: str = "1.14.1"
logger = get_logger("config")

View File

@@ -191,6 +191,15 @@ class ChatConfig(ConfigBase):
inevitable_at_reply: bool = Field(default=True)
"""是否启用at必回复"""
enable_at: bool = Field(
default=True,
json_schema_extra={
"x-widget": "switch",
"x-icon": "at-sign",
},
)
"""是否允许 replyer 使用 at[msg_id] 标记来发送真正的 at 消息"""
enable_reply_quote: bool = Field(
default=True,
json_schema_extra={

View File

@@ -9,8 +9,6 @@ from src.config.config import global_config
from src.core.tooling import ToolAvailabilityContext, ToolExecutionContext, ToolExecutionResult, ToolInvocation, ToolSpec
from src.llm_models.payload_content.tool_option import ToolDefinitionInput
from .at import get_tool_spec as get_at_tool_spec
from .at import handle_tool as handle_at_tool
from .context import BuiltinToolRuntimeContext
from .continue_tool import get_tool_spec as get_continue_tool_spec
from .continue_tool import handle_tool as handle_continue_tool
@@ -93,14 +91,6 @@ BUILTIN_TOOL_ENTRIES: List[BuiltinToolEntry] = [
visibility="hidden",
),
BuiltinToolEntry("send_emoji", get_send_emoji_tool_spec, handle_send_emoji_tool, stage="action"),
BuiltinToolEntry(
"at",
get_at_tool_spec,
handle_at_tool,
stage="action",
visibility="deferred",
chat_scope="group",
),
BuiltinToolEntry("tool_search", get_tool_search_tool_spec, handle_tool_search_tool, stage="action"),
]

View File

@@ -1,186 +0,0 @@
"""Maisaka 内置 at 工具。"""
from typing import Any, Optional, TYPE_CHECKING
from src.cli.maisaka_cli_sender import CLI_PLATFORM_NAME, render_cli_message
from src.common.data_models.message_component_data_model import AtComponent, MessageSequence, TextComponent
from src.common.logger import get_logger
from src.core.tooling import ToolExecutionContext, ToolExecutionResult, ToolInvocation, ToolSpec
from src.services import send_service
if TYPE_CHECKING:
from .context import BuiltinToolRuntimeContext
logger = get_logger("maisaka_builtin_at")
def get_tool_spec() -> ToolSpec:
"""获取 at 工具声明。"""
return ToolSpec(
name="at",
brief_description="当明确提及某位用户时,发送一条 @ 该用户的消息。",
detailed_description=(
"参数说明:\n"
"- msg_idstring必填。要 @ 的目标用户发过的消息编号。\n"
"- textstring可选。@ 后追加发送的短文本;只想单独 @ 人时留空。\n"
"请优先从上下文里选择一条明确属于目标用户的 msg_id不要凭昵称或印象猜测用户。"
),
parameters_schema={
"type": "object",
"properties": {
"msg_id": {
"type": "string",
"description": "要 @ 的目标用户发过的消息编号。",
},
"text": {
"type": "string",
"description": "@ 后追加发送的短文本;只想单独 @ 人时留空。",
"default": "",
},
},
"required": ["msg_id"],
},
provider_name="maisaka_builtin",
provider_type="builtin",
)
def _get_target_user_info(target_message: Any) -> tuple[str, str, str]:
"""从目标消息中提取可用于构造 at 组件的用户信息。"""
message_info = getattr(target_message, "message_info", None)
user_info = getattr(message_info, "user_info", None)
target_user_id = str(getattr(user_info, "user_id", "") or "").strip()
target_user_nickname = str(getattr(user_info, "user_nickname", "") or "").strip()
target_user_cardname = str(getattr(user_info, "user_cardname", "") or "").strip()
return target_user_id, target_user_nickname, target_user_cardname
def _build_at_message_sequence(
*,
target_user_id: str,
target_user_nickname: str = "",
target_user_cardname: str = "",
text: str = "",
) -> MessageSequence:
"""构造 @ 用户的消息组件序列。"""
components = [
AtComponent(
target_user_id=target_user_id,
target_user_nickname=target_user_nickname or None,
target_user_cardname=target_user_cardname or None,
)
]
normalized_text = text.strip()
if normalized_text:
components.append(TextComponent(f" {normalized_text}"))
return MessageSequence(components=components)
async def handle_tool(
tool_ctx: "BuiltinToolRuntimeContext",
invocation: ToolInvocation,
context: Optional[ToolExecutionContext] = None,
) -> ToolExecutionResult:
"""执行 at 内置工具。"""
del context
target_message_id = str(invocation.arguments.get("msg_id") or "").strip()
text = str(invocation.arguments.get("text") or "").strip()
if not target_message_id:
return tool_ctx.build_failure_result(
invocation.tool_name,
"at 工具需要提供有效的 `msg_id` 参数。",
)
if not str(getattr(tool_ctx.runtime.chat_stream, "group_id", "") or "").strip():
return tool_ctx.build_failure_result(
invocation.tool_name,
"at 工具只能在群聊中使用。",
structured_content={"msg_id": target_message_id},
)
target_message = tool_ctx.runtime._source_messages_by_id.get(target_message_id)
if target_message is None:
return tool_ctx.build_failure_result(
invocation.tool_name,
f"未找到要 @ 的目标消息msg_id={target_message_id}",
structured_content={"msg_id": target_message_id},
)
target_user_id, target_user_nickname, target_user_cardname = _get_target_user_info(target_message)
if not target_user_id:
return tool_ctx.build_failure_result(
invocation.tool_name,
f"目标消息缺少有效用户 IDmsg_id={target_message_id}",
structured_content={"msg_id": target_message_id},
)
target_user_name = target_user_cardname or target_user_nickname or target_user_id
message_sequence = _build_at_message_sequence(
target_user_id=target_user_id,
target_user_nickname=target_user_nickname,
target_user_cardname=target_user_cardname,
text=text,
)
display_message = f"@{target_user_name}" + (f" {text}" if text else "")
try:
if tool_ctx.runtime.chat_stream.platform == CLI_PLATFORM_NAME:
render_cli_message(display_message)
tool_ctx.append_guided_reply_to_chat_history(display_message)
sent_message = None
sent = True
else:
sent_message = await send_service._send_to_target_with_message(
message_sequence=message_sequence,
stream_id=tool_ctx.runtime.session_id,
display_message=display_message,
typing=False,
storage_message=True,
show_log=True,
sync_to_maisaka_history=True,
maisaka_source_kind="guided_reply",
)
sent = sent_message is not None
except Exception as exc:
logger.exception(
f"{tool_ctx.runtime.log_prefix} 发送 at 消息时发生异常: msg_id={target_message_id} user_id={target_user_id}"
)
return tool_ctx.build_failure_result(
invocation.tool_name,
f"发送 at 消息时发生异常:{exc}",
structured_content={
"msg_id": target_message_id,
"target_user_id": target_user_id,
"target_user_name": target_user_name,
},
)
if not sent:
return tool_ctx.build_failure_result(
invocation.tool_name,
"at 消息发送失败。",
structured_content={
"msg_id": target_message_id,
"target_user_id": target_user_id,
"target_user_name": target_user_name,
},
)
sent_message_id = str(getattr(sent_message, "message_id", "") or "").strip() if sent_message is not None else ""
tool_ctx.runtime._record_reply_sent()
return tool_ctx.build_success_result(
invocation.tool_name,
f"已 @ {target_user_name}",
structured_content={
"msg_id": target_message_id,
"target_user_id": target_user_id,
"target_user_name": target_user_name,
"text": text,
"sent_message_id": sent_message_id,
},
)

View File

@@ -4,10 +4,12 @@ from __future__ import annotations
from base64 import b64decode
from datetime import datetime
from typing import TYPE_CHECKING, Any, Dict, List, Optional
from typing import Any, Dict, List, Optional, TYPE_CHECKING
import re
from src.chat.utils.utils import process_llm_response
from src.common.data_models.message_component_data_model import EmojiComponent, MessageSequence, TextComponent
from src.common.data_models.message_component_data_model import AtComponent, EmojiComponent, MessageSequence, TextComponent
from src.config.config import global_config
from src.core.tooling import ToolExecutionResult
@@ -19,6 +21,8 @@ if TYPE_CHECKING:
from ..reasoning_engine import MaisakaReasoningEngine
from ..runtime import MaisakaHeartFlowChatting
AT_MARKER_PATTERN = re.compile(r"at\[([^\]\s]+)\]")
class BuiltinToolRuntimeContext:
"""为拆分后的内置工具提供统一运行时能力。"""
@@ -131,6 +135,74 @@ class BuiltinToolRuntimeContext:
return processed_segments
return [reply_text.strip()]
@staticmethod
def _post_process_reply_text_chunk(text: str) -> List[str]:
"""处理回复中的普通文本片段。"""
processed_segments: List[str] = []
for segment in process_llm_response(text):
normalized_segment = segment.strip()
if normalized_segment:
processed_segments.append(normalized_segment)
return processed_segments
def _build_at_component_for_message_id(self, message_id: str) -> Optional[AtComponent]:
"""根据消息编号构造 at 组件。"""
target_message = self.runtime._source_messages_by_id.get(message_id)
if target_message is None:
return None
message_info = getattr(target_message, "message_info", None)
user_info = getattr(message_info, "user_info", None)
target_user_id = str(getattr(user_info, "user_id", "") or "").strip()
if not target_user_id:
return None
target_user_nickname = str(getattr(user_info, "user_nickname", "") or "").strip()
target_user_cardname = str(getattr(user_info, "user_cardname", "") or "").strip()
return AtComponent(
target_user_id=target_user_id,
target_user_nickname=target_user_nickname or None,
target_user_cardname=target_user_cardname or None,
)
def post_process_reply_message_sequences(self, reply_text: str) -> List[MessageSequence]:
"""将回复文本处理为可发送组件序列,并解析 replyer 的 at[msg_id] 标记。"""
if not global_config.chat.enable_at or not AT_MARKER_PATTERN.search(reply_text):
return [MessageSequence([TextComponent(segment)]) for segment in self.post_process_reply_text(reply_text)]
message_sequences: List[MessageSequence] = []
components: List[Any] = []
cursor = 0
def flush_text_chunk(text: str) -> None:
if not text.strip():
return
for segment in self._post_process_reply_text_chunk(text):
prefix = " " if components else ""
components.append(TextComponent(f"{prefix}{segment}"))
for match in AT_MARKER_PATTERN.finditer(reply_text):
flush_text_chunk(reply_text[cursor : match.start()])
message_id = match.group(1).strip()
at_component = self._build_at_component_for_message_id(message_id)
if at_component is None:
components.append(TextComponent(match.group(0)))
else:
components.append(at_component)
cursor = match.end()
flush_text_chunk(reply_text[cursor:])
if components:
message_sequences.append(MessageSequence(components))
if message_sequences:
return message_sequences
return [MessageSequence([TextComponent(reply_text.strip())])]
def get_runtime_manager(self) -> Any:
"""获取插件运行时管理器。"""
@@ -149,6 +221,35 @@ class BuiltinToolRuntimeContext:
)
self.runtime._chat_history.append(history_message)
def append_sent_message_to_chat_history(self, message: Any, *, source_kind: str = "guided_reply") -> bool:
"""将已发送消息写回 Maisaka 历史。"""
runtime_append = getattr(self.runtime, "append_sent_message_to_chat_history", None)
if callable(runtime_append):
return bool(runtime_append(message, source_kind=source_kind))
from ..context_messages import SessionBackedMessage
from ..history_utils import build_prefixed_message_sequence, build_session_message_visible_text
from ..planner_message_utils import build_planner_prefix
user_info = message.message_info.user_info
speaker_name = user_info.user_cardname or user_info.user_nickname or user_info.user_id
planner_prefix = build_planner_prefix(
timestamp=message.timestamp,
user_name=speaker_name,
group_card=user_info.user_cardname or "",
message_id=message.message_id,
include_message_id=not message.is_notify and bool(message.message_id),
)
history_message = SessionBackedMessage.from_session_message(
message,
raw_message=build_prefixed_message_sequence(message.raw_message, planner_prefix),
visible_text=build_session_message_visible_text(message),
source_kind=source_kind,
)
self.runtime._chat_history.append(history_message)
return True
def append_sent_emoji_to_chat_history(
self,
*,

View File

@@ -10,6 +10,7 @@ from src.common.data_models.reply_generation_data_models import ReplyGenerationR
from src.common.logger import get_logger
from src.config import config as config_module
from src.core.tooling import ToolExecutionContext, ToolExecutionResult, ToolInvocation, ToolSpec
from src.maisaka.message_adapter import build_visible_text_from_sequence
from src.services import send_service
from .context import BuiltinToolRuntimeContext
@@ -180,7 +181,8 @@ async def handle_tool(
metadata=reply_metadata,
)
reply_segments = tool_ctx.post_process_reply_text(reply_text)
reply_sequences = tool_ctx.post_process_reply_message_sequences(reply_text)
reply_segments = [build_visible_text_from_sequence(sequence) for sequence in reply_sequences]
combined_reply_text = "".join(reply_segments)
sent_message_ids: list[str] = []
send_results: list[dict[str, Any]] = []
@@ -199,11 +201,13 @@ async def handle_tool(
)
sent = True
else:
for index, segment in enumerate(reply_segments):
for index, reply_sequence in enumerate(reply_sequences):
segment = reply_segments[index]
segment_set_quote = effective_set_quote if index == 0 else False
sent_message = await send_service.text_to_stream_with_message(
text=segment,
sent_message = await send_service._send_to_target_with_message(
message_sequence=reply_sequence,
stream_id=tool_ctx.runtime.session_id,
display_message=segment,
set_reply=segment_set_quote,
reply_message=target_message if segment_set_quote else None,
selected_expressions=reply_result.selected_expression_ids or None,

View File

@@ -90,13 +90,15 @@ def _refresh_pending_visual_components(components: list[object]) -> bool:
def _should_refresh_image_component(component: ImageComponent) -> bool:
"""判断图片组件当前是否仍处于待补全文本的占位状态。"""
return not component.content or component.content == "[图片]"
normalized_content = component.content.strip()
return not normalized_content or normalized_content == "[图片]"
def _should_refresh_emoji_component(component: EmojiComponent) -> bool:
"""判断表情组件当前是否仍处于待补全文本的占位状态。"""
return not component.content or component.content == "[表情包]"
normalized_content = component.content.strip()
return not normalized_content or normalized_content == "[表情包]"
def _lookup_cached_image_description(image_hash: str) -> str:

View File

@@ -53,8 +53,9 @@ def _append_emoji_component(
builder.add_image_content(image_format, base64.b64encode(component.binary_data).decode("utf-8"))
return True
if component.content:
builder.add_text_content(component.content)
normalized_content = component.content.strip()
if normalized_content:
builder.add_text_content(normalized_content)
return True
builder.add_text_content("[表情包]")
@@ -74,8 +75,9 @@ def _append_image_component(
builder.add_image_content(image_format, base64.b64encode(component.binary_data).decode("utf-8"))
return True
if component.content:
builder.add_text_content(component.content)
normalized_content = component.content.strip()
if normalized_content:
builder.add_text_content(normalized_content)
return True
builder.add_text_content("[图片]")

View File

@@ -79,11 +79,11 @@ def build_visible_text_from_sequence(message_sequence: MessageSequence) -> str:
continue
if isinstance(component, EmojiComponent):
parts.append(component.content or "[表情包]")
parts.append(component.content.strip() or "[表情包]")
continue
if isinstance(component, ImageComponent):
parts.append(component.content or "[图片]")
parts.append(component.content.strip() or "[图片]")
continue
if isinstance(component, AtComponent):

View File

@@ -453,15 +453,15 @@ def _build_processed_plain_text(message: SessionMessage) -> str:
continue
if isinstance(component, ImageComponent):
processed_parts.append(component.content or "[图片]")
processed_parts.append(component.content.strip() or "[图片]")
continue
if isinstance(component, EmojiComponent):
processed_parts.append(component.content or "[表情]")
processed_parts.append(component.content.strip() or "[表情]")
continue
if isinstance(component, VoiceComponent):
processed_parts.append(component.content or "[语音]")
processed_parts.append(component.content.strip() or "[语音]")
continue
if isinstance(component, AtComponent):