380 lines
13 KiB
Python
380 lines
13 KiB
Python
"""Maisaka 表情工具内置能力。"""
|
||
|
||
from collections.abc import Awaitable, Callable, Sequence
|
||
from dataclasses import dataclass, field
|
||
from typing import Any, Optional, TYPE_CHECKING
|
||
|
||
import random
|
||
|
||
from src.chat.message_receive.chat_manager import chat_manager
|
||
from src.cli.maisaka_cli_sender import CLI_PLATFORM_NAME, render_cli_message
|
||
from src.common.data_models.image_data_model import MaiEmoji
|
||
from src.common.data_models.llm_service_data_models import LLMGenerationOptions
|
||
from src.common.logger import get_logger
|
||
from src.common.utils.utils_image import ImageUtils
|
||
from src.services import send_service
|
||
|
||
from .emoji_manager import (
|
||
_normalize_emoji_tag_text,
|
||
_serialize_emoji_for_hook,
|
||
emoji_manager,
|
||
emoji_manager_emotion_judge_llm,
|
||
)
|
||
|
||
logger = get_logger("emoji_maisaka_tool")
|
||
|
||
if TYPE_CHECKING:
|
||
from src.chat.message_receive.message import SessionMessage
|
||
|
||
EmojiSelector = Callable[
|
||
[str, str, Sequence[str] | None, int],
|
||
Awaitable[tuple[MaiEmoji | None, str]],
|
||
]
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class MaisakaEmojiSendResult:
|
||
"""Maisaka 表情发送结果。"""
|
||
|
||
success: bool
|
||
message: str
|
||
emoji_base64: str = ""
|
||
description: str = ""
|
||
emotions: list[str] = field(default_factory=list)
|
||
requested_emotion: str = ""
|
||
matched_emotion: str = ""
|
||
sent_message: Optional["SessionMessage"] = None
|
||
|
||
|
||
def _get_runtime_manager() -> Any:
|
||
"""获取插件运行时管理器。
|
||
|
||
Returns:
|
||
Any: 插件运行时管理器单例。
|
||
"""
|
||
|
||
from src.plugin_runtime.integration import get_plugin_runtime_manager
|
||
|
||
return get_plugin_runtime_manager()
|
||
|
||
|
||
def _coerce_positive_int(value: Any, default: int) -> int:
|
||
"""将任意值安全转换为正整数。
|
||
|
||
Args:
|
||
value: 待转换的值。
|
||
default: 转换失败时使用的默认值。
|
||
|
||
Returns:
|
||
int: 规范化后的正整数。
|
||
"""
|
||
|
||
try:
|
||
normalized_value = int(value)
|
||
except (TypeError, ValueError):
|
||
return default
|
||
return normalized_value if normalized_value > 0 else default
|
||
|
||
|
||
def _normalize_context_texts(context_texts: Sequence[str] | None) -> list[str]:
|
||
"""清洗 Hook 和调用链传入的上下文文本列表。
|
||
|
||
Args:
|
||
context_texts: 原始上下文文本序列。
|
||
|
||
Returns:
|
||
list[str]: 过滤空白后的上下文文本列表。
|
||
"""
|
||
|
||
if not context_texts:
|
||
return []
|
||
return [str(item).strip() for item in context_texts if str(item).strip()]
|
||
|
||
|
||
def _resolve_selected_emoji(raw_value: Any) -> Optional[MaiEmoji]:
|
||
"""根据 Hook 返回值解析目标表情包对象。
|
||
|
||
Args:
|
||
raw_value: Hook 返回的 ``selected_emoji`` 或 ``selected_emoji_hash``。
|
||
|
||
Returns:
|
||
Optional[MaiEmoji]: 命中的表情包对象;未命中时返回 ``None``。
|
||
"""
|
||
|
||
raw_hash: str = ""
|
||
if isinstance(raw_value, dict):
|
||
raw_hash = str(raw_value.get("file_hash") or raw_value.get("hash") or "").strip()
|
||
elif isinstance(raw_value, str):
|
||
raw_hash = raw_value.strip()
|
||
|
||
if not raw_hash:
|
||
return None
|
||
|
||
for emoji in emoji_manager.emojis:
|
||
if emoji.file_hash == raw_hash:
|
||
return emoji
|
||
return None
|
||
|
||
|
||
def _normalize_emotions(emoji: MaiEmoji) -> list[str]:
|
||
"""提取并清洗单个表情的情绪标签。"""
|
||
if emoji.description:
|
||
return _normalize_emoji_tag_text(emoji.description)
|
||
return []
|
||
|
||
|
||
def _build_recent_context_text(context_texts: Sequence[str], max_items: int = 5) -> str:
|
||
"""构建供情绪判断使用的最近上下文文本。"""
|
||
|
||
normalized_items = [str(item).strip() for item in context_texts if str(item).strip()]
|
||
if not normalized_items:
|
||
return ""
|
||
return "\n".join(normalized_items[-max_items:])
|
||
|
||
|
||
async def _select_emoji_with_llm(
|
||
*,
|
||
sampled_emojis: Sequence[MaiEmoji],
|
||
reasoning: str,
|
||
context_text: str,
|
||
) -> tuple[MaiEmoji, str]:
|
||
"""让模型在采样表情中选择更合适的情绪标签。"""
|
||
|
||
emotion_map: dict[str, list[MaiEmoji]] = {}
|
||
for emoji in sampled_emojis:
|
||
for emotion in _normalize_emotions(emoji):
|
||
emotion_map.setdefault(emotion, []).append(emoji)
|
||
|
||
available_emotions = list(emotion_map.keys())
|
||
if not available_emotions:
|
||
return random.choice(list(sampled_emojis)), ""
|
||
|
||
prompt = (
|
||
"你正在为聊天场景选择一个最合适的表情包情绪标签。\n"
|
||
f"发送原因:{reasoning or '辅助表达当前语气和情绪'}\n"
|
||
f"最近聊天记录:\n{context_text or '(暂无额外上下文)'}\n\n"
|
||
"可选情绪标签如下:\n"
|
||
f"{chr(10).join(available_emotions)}\n\n"
|
||
"请只返回一个最匹配的情绪标签,不要解释。"
|
||
)
|
||
|
||
try:
|
||
llm_result = await emoji_manager_emotion_judge_llm.generate_response(
|
||
prompt,
|
||
options=LLMGenerationOptions(temperature=0.3, max_tokens=60),
|
||
)
|
||
chosen_emotion = (llm_result.response or "").strip().strip("\"'")
|
||
except Exception as exc:
|
||
logger.warning(f"使用 LLM 选择表情情绪失败,将回退为随机选择: {exc}")
|
||
chosen_emotion = ""
|
||
|
||
if chosen_emotion and chosen_emotion in emotion_map:
|
||
return random.choice(emotion_map[chosen_emotion]), chosen_emotion
|
||
return random.choice(list(sampled_emojis)), ""
|
||
|
||
|
||
async def select_emoji_for_maisaka(
|
||
*,
|
||
requested_emotion: str = "",
|
||
reasoning: str = "",
|
||
context_texts: Sequence[str] | None = None,
|
||
sample_size: int = 30,
|
||
) -> tuple[MaiEmoji | None, str]:
|
||
"""为 Maisaka 选择一个合适的表情。"""
|
||
|
||
available_emojis = list(emoji_manager.emojis)
|
||
if not available_emojis:
|
||
return None, ""
|
||
|
||
normalized_requested_emotion = requested_emotion.strip()
|
||
if normalized_requested_emotion:
|
||
matched_emojis = [
|
||
emoji
|
||
for emoji in available_emojis
|
||
if normalized_requested_emotion.lower() in (emotion.lower() for emotion in _normalize_emotions(emoji))
|
||
]
|
||
if matched_emojis:
|
||
return random.choice(matched_emojis), normalized_requested_emotion
|
||
|
||
sampled_emojis = random.sample(
|
||
available_emojis,
|
||
min(max(sample_size, 1), len(available_emojis)),
|
||
)
|
||
context_text = _build_recent_context_text(context_texts or [])
|
||
return await _select_emoji_with_llm(
|
||
sampled_emojis=sampled_emojis,
|
||
reasoning=reasoning,
|
||
context_text=context_text,
|
||
)
|
||
|
||
|
||
async def send_emoji_for_maisaka(
|
||
*,
|
||
stream_id: str,
|
||
requested_emotion: str = "",
|
||
reasoning: str = "",
|
||
context_texts: Sequence[str] | None = None,
|
||
emoji_selector: EmojiSelector | None = None,
|
||
) -> MaisakaEmojiSendResult:
|
||
"""为 Maisaka 选择并发送一个表情。"""
|
||
|
||
normalized_requested_emotion = requested_emotion.strip()
|
||
normalized_reasoning = reasoning.strip()
|
||
normalized_context_texts = _normalize_context_texts(context_texts)
|
||
sample_size = 20
|
||
|
||
before_select_result = await _get_runtime_manager().invoke_hook(
|
||
"emoji.maisaka.before_select",
|
||
stream_id=stream_id,
|
||
requested_emotion=normalized_requested_emotion,
|
||
reasoning=normalized_reasoning,
|
||
context_texts=list(normalized_context_texts),
|
||
sample_size=sample_size,
|
||
abort_message="表情选择已被 Hook 中止。",
|
||
)
|
||
if before_select_result.aborted:
|
||
abort_message = str(before_select_result.kwargs.get("abort_message") or "表情选择已被 Hook 中止。").strip()
|
||
return MaisakaEmojiSendResult(
|
||
success=False,
|
||
message=abort_message or "表情选择已被 Hook 中止。",
|
||
requested_emotion=normalized_requested_emotion,
|
||
)
|
||
|
||
before_select_kwargs = before_select_result.kwargs
|
||
normalized_requested_emotion = str(
|
||
before_select_kwargs.get("requested_emotion", normalized_requested_emotion) or ""
|
||
).strip()
|
||
normalized_reasoning = str(before_select_kwargs.get("reasoning", normalized_reasoning) or "").strip()
|
||
if isinstance(before_select_kwargs.get("context_texts"), list):
|
||
normalized_context_texts = _normalize_context_texts(before_select_kwargs.get("context_texts"))
|
||
sample_size = _coerce_positive_int(before_select_kwargs.get("sample_size"), sample_size)
|
||
|
||
if emoji_selector is None:
|
||
selected_emoji, matched_emotion = await select_emoji_for_maisaka(
|
||
requested_emotion=normalized_requested_emotion,
|
||
reasoning=normalized_reasoning,
|
||
context_texts=normalized_context_texts,
|
||
sample_size=sample_size,
|
||
)
|
||
else:
|
||
selected_emoji, matched_emotion = await emoji_selector(
|
||
normalized_requested_emotion,
|
||
normalized_reasoning,
|
||
normalized_context_texts,
|
||
sample_size,
|
||
)
|
||
after_select_result = await _get_runtime_manager().invoke_hook(
|
||
"emoji.maisaka.after_select",
|
||
stream_id=stream_id,
|
||
requested_emotion=normalized_requested_emotion,
|
||
reasoning=normalized_reasoning,
|
||
context_texts=list(normalized_context_texts),
|
||
sample_size=sample_size,
|
||
selected_emoji=_serialize_emoji_for_hook(selected_emoji),
|
||
selected_emoji_hash=str(selected_emoji.file_hash or "").strip() if selected_emoji is not None else "",
|
||
matched_emotion=matched_emotion,
|
||
abort_message="表情发送已被 Hook 中止。",
|
||
)
|
||
if after_select_result.aborted:
|
||
abort_message = str(after_select_result.kwargs.get("abort_message") or "表情发送已被 Hook 中止。").strip()
|
||
return MaisakaEmojiSendResult(
|
||
success=False,
|
||
message=abort_message or "表情发送已被 Hook 中止。",
|
||
requested_emotion=normalized_requested_emotion,
|
||
matched_emotion=matched_emotion,
|
||
)
|
||
|
||
after_select_kwargs = after_select_result.kwargs
|
||
normalized_requested_emotion = str(
|
||
after_select_kwargs.get("requested_emotion", normalized_requested_emotion) or ""
|
||
).strip()
|
||
matched_emotion = str(after_select_kwargs.get("matched_emotion", matched_emotion) or "").strip()
|
||
override_emoji = _resolve_selected_emoji(after_select_kwargs.get("selected_emoji_hash"))
|
||
if override_emoji is None:
|
||
override_emoji = _resolve_selected_emoji(after_select_kwargs.get("selected_emoji"))
|
||
if override_emoji is not None:
|
||
selected_emoji = override_emoji
|
||
|
||
if selected_emoji is None:
|
||
return MaisakaEmojiSendResult(
|
||
success=False,
|
||
message="当前表情包库中没有可用表情。",
|
||
requested_emotion=normalized_requested_emotion,
|
||
matched_emotion=matched_emotion,
|
||
)
|
||
|
||
try:
|
||
emoji_base64 = ImageUtils.image_path_to_base64(str(selected_emoji.full_path))
|
||
if not emoji_base64:
|
||
raise ValueError("表情图片转换为 base64 失败")
|
||
except Exception as exc:
|
||
return MaisakaEmojiSendResult(
|
||
success=False,
|
||
message=f"发送表情包失败:{exc}",
|
||
description=selected_emoji.description.strip(),
|
||
emotions=_normalize_emotions(selected_emoji),
|
||
requested_emotion=normalized_requested_emotion,
|
||
matched_emotion=matched_emotion,
|
||
)
|
||
|
||
try:
|
||
target_session = chat_manager.get_session_by_session_id(stream_id)
|
||
sent_message = None
|
||
if target_session is not None and target_session.platform == CLI_PLATFORM_NAME:
|
||
preview_message = (
|
||
f"已发送表情包:{selected_emoji.description.strip()}"
|
||
if selected_emoji.description.strip()
|
||
else "[表情包]"
|
||
)
|
||
render_cli_message(preview_message)
|
||
sent = True
|
||
else:
|
||
sent_message = await send_service.emoji_to_stream_with_message(
|
||
emoji_base64=emoji_base64,
|
||
stream_id=stream_id,
|
||
storage_message=True,
|
||
set_reply=False,
|
||
reply_message=None,
|
||
sync_to_maisaka_history=True,
|
||
maisaka_source_kind="guided_reply",
|
||
)
|
||
sent = sent_message is not None
|
||
except Exception as exc:
|
||
return MaisakaEmojiSendResult(
|
||
success=False,
|
||
message=f"发送表情包时发生异常:{exc}",
|
||
description=selected_emoji.description.strip(),
|
||
emotions=_normalize_emotions(selected_emoji),
|
||
requested_emotion=normalized_requested_emotion,
|
||
matched_emotion=matched_emotion,
|
||
)
|
||
|
||
description = selected_emoji.description.strip()
|
||
emotions = _normalize_emotions(selected_emoji)
|
||
if not sent:
|
||
return MaisakaEmojiSendResult(
|
||
success=False,
|
||
message="发送表情包失败。",
|
||
description=description,
|
||
emotions=emotions,
|
||
requested_emotion=normalized_requested_emotion,
|
||
matched_emotion=matched_emotion,
|
||
)
|
||
|
||
emoji_manager.update_emoji_usage(selected_emoji)
|
||
success_message = (
|
||
f"已发送表情包:{description}(情绪:{', '.join(emotions)})"
|
||
if emotions
|
||
else f"已发送表情包:{description}"
|
||
)
|
||
return MaisakaEmojiSendResult(
|
||
success=True,
|
||
message=success_message,
|
||
emoji_base64=emoji_base64,
|
||
description=description,
|
||
emotions=emotions,
|
||
requested_emotion=normalized_requested_emotion,
|
||
matched_emotion=matched_emotion,
|
||
sent_message=sent_message,
|
||
)
|