feat:移除旧的工具系统,并使emoji成为maisaka内置动作

This commit is contained in:
SengokuCola
2026-03-29 15:25:36 +08:00
parent 614d2f43d6
commit 868438e3c1
7 changed files with 322 additions and 426 deletions

View File

@@ -80,6 +80,21 @@ def create_builtin_tools() -> List[ToolOption]:
stop_builder.set_description("Stop the current inner loop and return control to the outer chat flow.")
tools.append(stop_builder.build())
send_emoji_builder = ToolOptionBuilder()
send_emoji_builder.set_name("send_emoji")
send_emoji_builder.set_description(
"Send an emoji sticker to help express emotions. "
"You should specify the emotion type to select an appropriate emoji."
)
send_emoji_builder.add_param(
name="emotion",
param_type=ToolParamType.STRING,
description="The emotion type for selecting an appropriate emoji (e.g., 'happy', 'sad', 'angry', 'surprised', etc.).",
required=False,
enum_values=None,
)
tools.append(send_emoji_builder.build())
return tools

View File

@@ -1,5 +1,6 @@
"""Maisaka 推理引擎。"""
import difflib
import json
import asyncio
import re
@@ -48,6 +49,7 @@ class MaisakaReasoningEngine:
def __init__(self, runtime: "MaisakaHeartFlowChatting") -> None:
self._runtime = runtime
self._reply_context_builder = MaisakaReplyContextBuilder(runtime.session_id)
self._last_reasoning_content: str = ""
async def run_loop(self) -> None:
"""独立消费消息批次,并执行对应的内部思考轮次。"""
@@ -71,6 +73,13 @@ class MaisakaReasoningEngine:
response = await self._runtime._chat_loop_service.chat_loop_step(self._runtime._chat_history)
cycle_detail.time_records["planner"] = time.time() - planner_started_at
reasoning_content = response.content or ""
if self._should_replace_reasoning(reasoning_content):
response.content = "让我根据新情况重新思考:"
response.raw_message.content = "让我根据新情况重新思考:"
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)
@@ -330,6 +339,37 @@ class MaisakaReasoningEngine:
self._runtime._chat_history = trimmed_history
self._runtime._log_history_trimmed(removed_count, conversation_message_count)
@staticmethod
def _calculate_similarity(text1: str, text2: str) -> float:
"""计算两个文本之间的相似度。
Args:
text1: 第一个文本
text2: 第二个文本
Returns:
float: 相似度值,范围 0-11 表示完全相同
"""
return difflib.SequenceMatcher(None, text1, text2).ratio()
def _should_replace_reasoning(self, current_content: str) -> bool:
"""判断是否需要替换推理内容。
当当前推理内容与上一次相似度大于90%返回True。
Args:
current_content: 当前的推理内容
Returns:
bool: 是否需要替换
"""
if not self._last_reasoning_content or not current_content:
return False
similarity = self._calculate_similarity(current_content, self._last_reasoning_content)
logger.info(f"{self._runtime.log_prefix} reasoning similarity: {similarity:.2f}")
return similarity > 0.9
async def _handle_tool_calls(
self,
tool_calls: list[ToolCall],
@@ -382,6 +422,10 @@ class MaisakaReasoningEngine:
self._runtime._enter_stop_state()
return True
if tool_call.func_name == "send_emoji":
await self._handle_send_emoji(tool_call, anchor_message)
continue
if self._runtime._mcp_manager and self._runtime._mcp_manager.is_mcp_tool(tool_call.func_name):
await handle_mcp_tool(tool_call, self._runtime._chat_history, self._runtime._mcp_manager)
continue
@@ -615,6 +659,104 @@ class MaisakaReasoningEngine:
)
return True
async def _handle_send_emoji(self, tool_call: ToolCall, anchor_message: SessionMessage) -> None:
"""处理发送表情包的工具调用。
Args:
tool_call: 工具调用对象
anchor_message: 锚点消息
"""
from src.chat.emoji_system.emoji_manager import emoji_manager
from src.common.utils.utils_image import ImageUtils
import random
tool_args = tool_call.args or {}
emotion = str(tool_args.get("emotion") or "").strip()
logger.info(f"{self._runtime.log_prefix} send_emoji tool triggered: emotion={emotion!r}")
# 获取表情包列表
if not emoji_manager.emojis:
self._runtime._chat_history.append(
self._build_tool_message(tool_call, "No emojis available in the emoji library.")
)
return
# 根据情感选择表情包
selected_emoji = None
if emotion:
# 尝试找到匹配情感的表情包
matching_emojis = [
emoji for emoji in emoji_manager.emojis
if emotion.lower() in (e.lower() for e in emoji.emotion)
]
if matching_emojis:
selected_emoji = random.choice(matching_emojis)
logger.info(
f"{self._runtime.log_prefix} found {len(matching_emojis)} emojis matching emotion '{emotion}', "
f"selected: {selected_emoji.description}"
)
# 如果没有找到匹配的情感表情包,随机选择一个
if selected_emoji is None:
selected_emoji = random.choice(emoji_manager.emojis)
logger.info(
f"{self._runtime.log_prefix} no emoji matched emotion '{emotion}', "
f"randomly selected: {selected_emoji.description}"
)
# 更新表情包使用次数
emoji_manager.update_emoji_usage(selected_emoji)
# 获取表情包的 base64 数据
try:
emoji_base64 = ImageUtils.image_path_to_base64(str(selected_emoji.full_path))
if not emoji_base64:
raise ValueError("Failed to convert emoji image to base64")
except Exception as exc:
logger.error(
f"{self._runtime.log_prefix} failed to convert emoji to base64: {exc}"
)
self._runtime._chat_history.append(
self._build_tool_message(tool_call, f"Failed to send emoji: {exc}")
)
return
# 发送表情包
try:
sent = await send_service.emoji_to_stream(
emoji_base64=emoji_base64,
stream_id=self._runtime.session_id,
storage_message=True,
set_reply=False,
reply_message=None,
)
except Exception as exc:
logger.exception(
f"{self._runtime.log_prefix} send_service.emoji_to_stream crashed: {exc}"
)
self._runtime._chat_history.append(
self._build_tool_message(tool_call, f"Emoji send crashed: {exc}")
)
return
if sent:
logger.info(
f"{self._runtime.log_prefix} emoji sent successfully: "
f"description={selected_emoji.description!r} emotion={selected_emoji.emotion}"
)
self._runtime._chat_history.append(
self._build_tool_message(
tool_call,
f"Sent emoji: {selected_emoji.description} (emotion: {', '.join(selected_emoji.emotion)})"
)
)
else:
logger.warning(f"{self._runtime.log_prefix} emoji send failed")
self._runtime._chat_history.append(
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",

View File

@@ -1,13 +1,11 @@
"""
MaiSaka tool handlers.
MaiSaka 工具处理器。
"""
from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any, Optional
from typing import TYPE_CHECKING, Optional
import json as _json
import os
from rich.panel import Panel
@@ -22,11 +20,8 @@ if TYPE_CHECKING:
from src.mcp_module import MCPManager
MAI_FILES_DIR = Path(os.path.join(os.path.dirname(os.path.abspath(__file__)), "mai_files"))
class ToolHandlerContext:
"""Shared context for tool handlers."""
"""工具处理器共享上下文。"""
def __init__(
self,
@@ -39,18 +34,18 @@ class ToolHandlerContext:
async def handle_stop(tc: ToolCall, chat_history: list[SessionMessage]) -> None:
"""Handle the stop tool."""
console.print("[accent]Calling tool: stop()[/accent]")
"""处理 stop 工具。"""
console.print("[accent]调用工具: stop()[/accent]")
chat_history.append(
build_message(role="tool", content="Conversation loop will stop after this round.", tool_call_id=tc.call_id)
build_message(role="tool", content="当前轮次结束后将停止对话循环。", tool_call_id=tc.call_id)
)
async def handle_wait(tc: ToolCall, chat_history: list[SessionMessage], ctx: ToolHandlerContext) -> str:
"""Handle the wait tool."""
"""处理 wait 工具。"""
seconds = (tc.args or {}).get("seconds", 30)
seconds = max(5, min(seconds, 300))
console.print(f"[accent]Calling tool: wait({seconds})[/accent]")
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))
@@ -58,41 +53,41 @@ async def handle_wait(tc: ToolCall, chat_history: list[SessionMessage], ctx: Too
async def _do_wait(seconds: int, ctx: ToolHandlerContext) -> str:
"""Wait for user input with a timeout."""
console.print(f"[muted]Waiting for user input (timeout: {seconds}s)...[/muted]")
"""等待用户输入,支持超时。"""
console.print(f"[muted]等待用户输入中(超时: {seconds} 秒)...[/muted]")
console.print("[bold magenta]> [/bold magenta]", end="")
user_input = await ctx.reader.get_line(timeout=seconds)
if user_input is None:
console.print()
console.print("[muted]Wait timeout[/muted]")
return "Wait timed out; no user input received."
console.print("[muted]等待超时[/muted]")
return "等待超时,未收到用户输入。"
user_input = user_input.strip()
if not user_input:
return "User submitted an empty input."
return "用户提交了空输入。"
now = datetime.now()
ctx.last_user_input_time = now
ctx.user_input_times.append(now)
if user_input.lower() in ("/quit", "/exit", "/q"):
return "[[QUIT]] User requested to exit."
return "[[QUIT]] 用户请求退出。"
return f"User input received: {user_input}"
return f"已收到用户输入: {user_input}"
async def handle_mcp_tool(tc: ToolCall, chat_history: list[SessionMessage], mcp_manager: "MCPManager") -> None:
"""Handle an MCP tool call."""
"""处理 MCP 工具调用。"""
args_str = _json.dumps(tc.args or {}, ensure_ascii=False)
args_preview = args_str if len(args_str) <= 120 else args_str[:120] + "..."
console.print(f"[accent]Calling MCP tool: {tc.func_name}({args_preview})[/accent]")
console.print(f"[accent]调用 MCP 工具: {tc.func_name}({args_preview})[/accent]")
with console.status(f"[info]Running MCP tool {tc.func_name}...[/info]", spinner="dots"):
with console.status(f"[info]正在执行 MCP 工具 {tc.func_name}...[/info]", spinner="dots"):
result = await mcp_manager.call_tool(tc.func_name, tc.args or {})
display_text = result if len(result) <= 800 else result[:800] + "\n... (truncated)"
display_text = result if len(result) <= 800 else result[:800] + "\n...(已截断)"
console.print(
Panel(
display_text,
@@ -105,132 +100,6 @@ async def handle_mcp_tool(tc: ToolCall, chat_history: list[SessionMessage], mcp_
async def handle_unknown_tool(tc: ToolCall, chat_history: list[SessionMessage]) -> None:
"""Handle an unknown tool call."""
console.print(f"[accent]Calling unknown tool: {tc.func_name}({tc.args})[/accent]")
chat_history.append(build_message(role="tool", content=f"Unknown tool: {tc.func_name}", tool_call_id=tc.call_id))
async def handle_write_file(tc: ToolCall, chat_history: list[SessionMessage]) -> None:
"""Write a file under the local mai_files workspace."""
filename = (tc.args or {}).get("filename", "")
content = (tc.args or {}).get("content", "")
console.print(f'[accent]Calling tool: write_file("{filename}")[/accent]')
MAI_FILES_DIR.mkdir(parents=True, exist_ok=True)
file_path = MAI_FILES_DIR / filename
try:
file_path.parent.mkdir(parents=True, exist_ok=True)
with open(file_path, "w", encoding="utf-8") as file:
file.write(content)
file_size = file_path.stat().st_size
console.print(
Panel(
f"Path: {filename}\nSize: {file_size} bytes",
title="File Written",
border_style="green",
padding=(0, 1),
)
)
chat_history.append(
build_message(
role="tool",
content=f"File written successfully: {filename} ({file_size} bytes)",
tool_call_id=tc.call_id,
)
)
except Exception as exc:
error_msg = f"Failed to write file: {exc}"
console.print(f"[error]{error_msg}[/error]")
chat_history.append(build_message(role="tool", content=error_msg, tool_call_id=tc.call_id))
async def handle_read_file(tc: ToolCall, chat_history: list[SessionMessage]) -> None:
"""Read a file from the local mai_files workspace."""
filename = (tc.args or {}).get("filename", "")
console.print(f'[accent]Calling tool: read_file("{filename}")[/accent]')
file_path = MAI_FILES_DIR / filename
try:
if not file_path.exists():
error_msg = f"File does not exist: {filename}"
console.print(f"[warning]{error_msg}[/warning]")
chat_history.append(build_message(role="tool", content=error_msg, tool_call_id=tc.call_id))
return
if not file_path.is_file():
error_msg = f"Path is not a file: {filename}"
console.print(f"[warning]{error_msg}[/warning]")
chat_history.append(build_message(role="tool", content=error_msg, tool_call_id=tc.call_id))
return
with open(file_path, "r", encoding="utf-8") as file:
file_content = file.read()
display_content = file_content if len(file_content) <= 1000 else file_content[:1000] + "\n... (truncated)"
console.print(
Panel(
display_content,
title=f"Read File: {filename}",
border_style="blue",
padding=(0, 1),
)
)
chat_history.append(
build_message(role="tool", content=f"File content of {filename}:\n{file_content}", tool_call_id=tc.call_id)
)
except Exception as exc:
error_msg = f"Failed to read file: {exc}"
console.print(f"[error]{error_msg}[/error]")
chat_history.append(build_message(role="tool", content=error_msg, tool_call_id=tc.call_id))
async def handle_list_files(tc: ToolCall, chat_history: list[SessionMessage]) -> None:
"""List files under the local mai_files workspace."""
console.print("[accent]Calling tool: list_files()[/accent]")
try:
MAI_FILES_DIR.mkdir(parents=True, exist_ok=True)
files_info: list[dict[str, Any]] = []
for item in MAI_FILES_DIR.rglob("*"):
if item.is_file():
stat = item.stat()
files_info.append(
{
"name": str(item.relative_to(MAI_FILES_DIR)),
"size": stat.st_size,
"modified": datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M:%S"),
}
)
if not files_info:
result_text = "No files found under mai_files."
else:
files_info.sort(key=lambda item: item["name"])
lines = [f"Found {len(files_info)} file(s):\n"]
for item in files_info:
lines.append(f"- {item['name']} ({item['size']} bytes, modified {item['modified']})")
result_text = "\n".join(lines)
console.print(
Panel(
result_text,
title="File List",
border_style="cyan",
padding=(0, 1),
)
)
chat_history.append(build_message(role="tool", content=result_text, tool_call_id=tc.call_id))
except Exception as exc:
error_msg = f"Failed to list files: {exc}"
console.print(f"[error]{error_msg}[/error]")
chat_history.append(build_message(role="tool", content=error_msg, tool_call_id=tc.call_id))
try:
MAI_FILES_DIR.mkdir(parents=True, exist_ok=True)
except Exception as exc:
console.print(f"[warning]Failed to initialize mai_files directory: {exc}[/warning]")
"""处理未知工具调用。"""
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))