Merge branch 'dev' into patch-2

pull/1265/head
渔火Arcadia 2025-09-24 23:57:38 +08:00 committed by GitHub
commit a25f046c6d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 1111 additions and 243 deletions

1
.gitignore vendored
View File

@ -321,6 +321,7 @@ run_pet.bat
/plugins/* /plugins/*
!/plugins !/plugins
!/plugins/hello_world_plugin !/plugins/hello_world_plugin
!/plugins/emoji_manage_plugin
!/plugins/take_picture_plugin !/plugins/take_picture_plugin
config.toml config.toml

View File

@ -1,7 +1,8 @@
# Changelog # Changelog
0.10.4饼 表达方式优化 ## [0.10.4] - 2025-9-22
无了 表达方式优化
## [0.10.3] - 2025-9-22 ## [0.10.3] - 2025-9-22
### 🌟 主要功能更改 ### 🌟 主要功能更改

View File

@ -28,7 +28,7 @@ version = "1.1.1"
```toml ```toml
[[api_providers]] [[api_providers]]
name = "DeepSeek" # 服务商名称(自定义) name = "DeepSeek" # 服务商名称(自定义)
base_url = "https://api.deepseek.cn/v1" # API服务的基础URL base_url = "https://api.deepseek.com/v1" # API服务的基础URL
api_key = "your-api-key-here" # API密钥 api_key = "your-api-key-here" # API密钥
client_type = "openai" # 客户端类型 client_type = "openai" # 客户端类型
max_retry = 2 # 最大重试次数 max_retry = 2 # 最大重试次数
@ -43,19 +43,19 @@ retry_interval = 10 # 重试间隔(秒)
| `name` | ✅ | 服务商名称,需要在模型配置中引用 | - | | `name` | ✅ | 服务商名称,需要在模型配置中引用 | - |
| `base_url` | ✅ | API服务的基础URL | - | | `base_url` | ✅ | API服务的基础URL | - |
| `api_key` | ✅ | API密钥请替换为实际密钥 | - | | `api_key` | ✅ | API密钥请替换为实际密钥 | - |
| `client_type` | ❌ | 客户端类型:`openai`OpenAI格式`gemini`Gemini格式,现在支持不良好 | `openai` | | `client_type` | ❌ | 客户端类型:`openai`OpenAI格式`gemini`Gemini格式 | `openai` |
| `max_retry` | ❌ | API调用失败时的最大重试次数 | 2 | | `max_retry` | ❌ | API调用失败时的最大重试次数 | 2 |
| `timeout` | ❌ | API请求超时时间 | 30 | | `timeout` | ❌ | API请求超时时间 | 30 |
| `retry_interval` | ❌ | 重试间隔时间(秒) | 10 | | `retry_interval` | ❌ | 重试间隔时间(秒) | 10 |
**请注意,对于`client_type`为`gemini`的模型,`base_url`字段无效。** **请注意,对于`client_type`为`gemini`的模型,`retry`字段由`gemini`自己决定。**
### 2.3 支持的服务商示例 ### 2.3 支持的服务商示例
#### DeepSeek #### DeepSeek
```toml ```toml
[[api_providers]] [[api_providers]]
name = "DeepSeek" name = "DeepSeek"
base_url = "https://api.deepseek.cn/v1" base_url = "https://api.deepseek.com/v1"
api_key = "your-deepseek-api-key" api_key = "your-deepseek-api-key"
client_type = "openai" client_type = "openai"
``` ```
@ -73,7 +73,7 @@ client_type = "openai"
```toml ```toml
[[api_providers]] [[api_providers]]
name = "Google" name = "Google"
base_url = "https://api.google.com/v1" base_url = "https://generativelanguage.googleapis.com/v1beta"
api_key = "your-google-api-key" api_key = "your-google-api-key"
client_type = "gemini" # 注意Gemini需要使用特殊客户端 client_type = "gemini" # 注意Gemini需要使用特殊客户端
``` ```
@ -131,9 +131,20 @@ enable_thinking = false # 禁用思考
[models.extra_params] [models.extra_params]
thinking = {type = "disabled"} # 禁用思考 thinking = {type = "disabled"} # 禁用思考
``` ```
而对于`gemini`需要单独进行配置
```toml
[[models]]
model_identifier = "gemini-2.5-flash"
name = "gemini-2.5-flash"
api_provider = "Google"
[models.extra_params]
thinking_budget = 0 # 禁用思考
# thinking_budget = -1 由模型自己决定
```
请注意,`extra_params` 的配置应该构成一个合法的TOML字典结构具体内容取决于API服务商的要求。 请注意,`extra_params` 的配置应该构成一个合法的TOML字典结构具体内容取决于API服务商的要求。
**请注意,对于`client_type`为`gemini`的模型,此字段无效。**
### 3.3 配置参数说明 ### 3.3 配置参数说明
| 参数 | 必填 | 说明 | | 参数 | 必填 | 说明 |

View File

@ -0,0 +1,53 @@
{
"manifest_version": 1,
"name": "BetterEmoji",
"version": "1.0.0",
"description": "更好的表情包管理插件",
"author": {
"name": "SengokuCola",
"url": "https://github.com/SengokuCola"
},
"license": "GPL-v3.0-or-later",
"host_application": {
"min_version": "0.10.4"
},
"homepage_url": "https://github.com/SengokuCola/BetterEmoji",
"repository_url": "https://github.com/SengokuCola/BetterEmoji",
"keywords": ["emoji", "manage", "plugin"],
"categories": ["Examples", "Tutorial"],
"default_locale": "zh-CN",
"locales_path": "_locales",
"plugin_info": {
"is_built_in": false,
"plugin_type": "emoji_manage",
"components": [
{
"type": "action",
"name": "hello_greeting",
"description": "向用户发送问候消息"
},
{
"type": "action",
"name": "bye_greeting",
"description": "向用户发送告别消息",
"activation_modes": ["keyword"],
"keywords": ["再见", "bye", "88", "拜拜"]
},
{
"type": "command",
"name": "time",
"description": "查询当前时间",
"pattern": "/time"
}
],
"features": [
"问候和告别功能",
"时间查询命令",
"配置文件示例",
"新手教程代码"
]
}
}

View File

@ -0,0 +1,404 @@
import random
from typing import List, Tuple, Type, Any
from src.plugin_system import (
BasePlugin,
register_plugin,
BaseAction,
BaseCommand,
BaseTool,
ComponentInfo,
ActionActivationType,
ConfigField,
BaseEventHandler,
EventType,
MaiMessages,
ToolParamType,
ReplyContentType,
emoji_api,
)
from maim_message import Seg
from src.config.config import global_config
from src.common.logger import get_logger
logger = get_logger("emoji_manage_plugin")
class AddEmojiCommand(BaseCommand):
command_name = "add_emoji"
command_description = "添加表情包"
command_pattern = r".*/emoji add.*"
async def execute(self) -> Tuple[bool, str, bool]:
# 查找消息中的表情包
# logger.info(f"查找消息中的表情包: {self.message.message_segment}")
emoji_base64_list = self.find_and_return_emoji_in_message(self.message.message_segment)
if not emoji_base64_list:
return False, "未在消息中找到表情包或图片", False
# 注册找到的表情包
success_count = 0
fail_count = 0
results = []
for i, emoji_base64 in enumerate(emoji_base64_list):
try:
# 使用emoji_api注册表情包让API自动生成唯一文件名
result = await emoji_api.register_emoji(emoji_base64)
if result["success"]:
success_count += 1
description = result.get("description", "未知描述")
emotions = result.get("emotions", [])
replaced = result.get("replaced", False)
result_msg = f"表情包 {i+1} 注册成功{'(替换旧表情包)' if replaced else '(新增表情包)'}"
if description:
result_msg += f"\n描述: {description}"
if emotions:
result_msg += f"\n情感标签: {', '.join(emotions)}"
results.append(result_msg)
else:
fail_count += 1
error_msg = result.get("message", "注册失败")
results.append(f"表情包 {i+1} 注册失败: {error_msg}")
except Exception as e:
fail_count += 1
results.append(f"表情包 {i+1} 注册时发生错误: {str(e)}")
# 构建返回消息
total_count = success_count + fail_count
summary_msg = f"表情包注册完成: 成功 {success_count} 个,失败 {fail_count} 个,共处理 {total_count}"
# 如果有结果详情,添加到返回消息中
details_msg = ""
if results:
details_msg = "\n" + "\n".join(results)
final_msg = summary_msg + details_msg
else:
final_msg = summary_msg
# 使用表达器重写回复
try:
from src.plugin_system.apis import generator_api
# 构建重写数据
rewrite_data = {
"raw_reply": summary_msg,
"reason": f"注册了表情包:{details_msg}\n",
}
# 调用表达器重写
result_status, data = await generator_api.rewrite_reply(
chat_stream=self.message.chat_stream,
reply_data=rewrite_data,
)
if result_status:
# 发送重写后的回复
for reply_seg in data.reply_set.reply_data:
send_data = reply_seg.content
await self.send_text(send_data)
return success_count > 0, final_msg, success_count > 0
else:
# 如果重写失败,发送原始消息
await self.send_text(final_msg)
return success_count > 0, final_msg, success_count > 0
except Exception as e:
# 如果表达器调用失败,发送原始消息
logger.error(f"[add_emoji] 表达器重写失败: {e}")
await self.send_text(final_msg)
return success_count > 0, final_msg, success_count > 0
def find_and_return_emoji_in_message(self, message_segments) -> List[str]:
emoji_base64_list = []
# 处理单个Seg对象的情况
if isinstance(message_segments, Seg):
if message_segments.type == "emoji":
emoji_base64_list.append(message_segments.data)
elif message_segments.type == "image":
# 假设图片数据是base64编码的
emoji_base64_list.append(message_segments.data)
elif message_segments.type == "seglist":
# 递归处理嵌套的Seg列表
emoji_base64_list.extend(self.find_and_return_emoji_in_message(message_segments.data))
return emoji_base64_list
# 处理Seg列表的情况
for seg in message_segments:
if seg.type == "emoji":
emoji_base64_list.append(seg.data)
elif seg.type == "image":
# 假设图片数据是base64编码的
emoji_base64_list.append(seg.data)
elif seg.type == "seglist":
# 递归处理嵌套的Seg列表
emoji_base64_list.extend(self.find_and_return_emoji_in_message(seg.data))
return emoji_base64_list
class ListEmojiCommand(BaseCommand):
"""列表表情包Command - 响应/emoji list命令"""
command_name = "emoji_list"
command_description = "列表表情包"
# === 命令设置(必须填写)===
command_pattern = r"^/emoji list(\s+\d+)?$" # 匹配 "/emoji list" 或 "/emoji list 数量"
async def execute(self) -> Tuple[bool, str, bool]:
"""执行列表表情包"""
from src.plugin_system.apis import emoji_api
import datetime
# 解析命令参数
import re
match = re.match(r"^/emoji list(?:\s+(\d+))?$", self.message.raw_message)
max_count = 10 # 默认显示10个
if match and match.group(1):
max_count = min(int(match.group(1)), 50) # 最多显示50个
# 获取当前时间
time_format: str = self.get_config("time.format", "%Y-%m-%d %H:%M:%S") # type: ignore
now = datetime.datetime.now()
time_str = now.strftime(time_format)
# 获取表情包信息
emoji_count = emoji_api.get_count()
emoji_info = emoji_api.get_info()
# 构建返回消息
message_lines = [
f"📊 表情包统计信息 ({time_str})",
f"• 总数: {emoji_count} / {emoji_info['max_count']}",
f"• 可用: {emoji_info['available_emojis']}",
]
if emoji_count == 0:
message_lines.append("\n❌ 暂无表情包")
final_message = "\n".join(message_lines)
await self.send_text(final_message)
return True, final_message, True
# 获取所有表情包
all_emojis = await emoji_api.get_all()
if not all_emojis:
message_lines.append("\n❌ 无法获取表情包列表")
final_message = "\n".join(message_lines)
await self.send_text(final_message)
return False, final_message, True
# 显示前N个表情包
display_emojis = all_emojis[:max_count]
message_lines.append(f"\n📋 显示前 {len(display_emojis)} 个表情包:")
for i, (emoji_base64, description, emotion) in enumerate(display_emojis, 1):
# 截断过长的描述
short_desc = description[:50] + "..." if len(description) > 50 else description
message_lines.append(f"{i}. {short_desc} [{emotion}]")
# 如果还有更多表情包,显示总数
if len(all_emojis) > max_count:
message_lines.append(f"\n💡 还有 {len(all_emojis) - max_count} 个表情包未显示")
final_message = "\n".join(message_lines)
# 直接发送文本消息
await self.send_text(final_message)
return True, final_message, True
class DeleteEmojiCommand(BaseCommand):
command_name = "delete_emoji"
command_description = "删除表情包"
command_pattern = r".*/emoji delete.*"
async def execute(self) -> Tuple[bool, str, bool]:
# 查找消息中的表情包图片
logger.info(f"查找消息中的表情包用于删除: {self.message.message_segment}")
emoji_base64_list = self.find_and_return_emoji_in_message(self.message.message_segment)
if not emoji_base64_list:
return False, "未在消息中找到表情包或图片", False
# 删除找到的表情包
success_count = 0
fail_count = 0
results = []
for i, emoji_base64 in enumerate(emoji_base64_list):
try:
# 计算图片的哈希值来查找对应的表情包
import base64
import hashlib
# 确保base64字符串只包含ASCII字符
if isinstance(emoji_base64, str):
emoji_base64_clean = emoji_base64.encode("ascii", errors="ignore").decode("ascii")
else:
emoji_base64_clean = str(emoji_base64)
# 计算哈希值
image_bytes = base64.b64decode(emoji_base64_clean)
emoji_hash = hashlib.md5(image_bytes).hexdigest()
# 使用emoji_api删除表情包
result = await emoji_api.delete_emoji(emoji_hash)
if result["success"]:
success_count += 1
description = result.get("description", "未知描述")
count_before = result.get("count_before", 0)
count_after = result.get("count_after", 0)
emotions = result.get("emotions", [])
result_msg = f"表情包 {i+1} 删除成功"
if description:
result_msg += f"\n描述: {description}"
if emotions:
result_msg += f"\n情感标签: {', '.join(emotions)}"
result_msg += f"\n表情包数量: {count_before}{count_after}"
results.append(result_msg)
else:
fail_count += 1
error_msg = result.get("message", "删除失败")
results.append(f"表情包 {i+1} 删除失败: {error_msg}")
except Exception as e:
fail_count += 1
results.append(f"表情包 {i+1} 删除时发生错误: {str(e)}")
# 构建返回消息
total_count = success_count + fail_count
summary_msg = f"表情包删除完成: 成功 {success_count} 个,失败 {fail_count} 个,共处理 {total_count}"
# 如果有结果详情,添加到返回消息中
details_msg = ""
if results:
details_msg = "\n" + "\n".join(results)
final_msg = summary_msg + details_msg
else:
final_msg = summary_msg
# 使用表达器重写回复
try:
from src.plugin_system.apis import generator_api
# 构建重写数据
rewrite_data = {
"raw_reply": summary_msg,
"reason": f"删除了表情包:{details_msg}\n",
}
# 调用表达器重写
result_status, data = await generator_api.rewrite_reply(
chat_stream=self.message.chat_stream,
reply_data=rewrite_data,
)
if result_status:
# 发送重写后的回复
for reply_seg in data.reply_set.reply_data:
send_data = reply_seg.content
await self.send_text(send_data)
return success_count > 0, final_msg, success_count > 0
else:
# 如果重写失败,发送原始消息
await self.send_text(final_msg)
return success_count > 0, final_msg, success_count > 0
except Exception as e:
# 如果表达器调用失败,发送原始消息
logger.error(f"[delete_emoji] 表达器重写失败: {e}")
await self.send_text(final_msg)
return success_count > 0, final_msg, success_count > 0
def find_and_return_emoji_in_message(self, message_segments) -> List[str]:
emoji_base64_list = []
# 处理单个Seg对象的情况
if isinstance(message_segments, Seg):
if message_segments.type == "emoji":
emoji_base64_list.append(message_segments.data)
elif message_segments.type == "image":
# 假设图片数据是base64编码的
emoji_base64_list.append(message_segments.data)
elif message_segments.type == "seglist":
# 递归处理嵌套的Seg列表
emoji_base64_list.extend(self.find_and_return_emoji_in_message(message_segments.data))
return emoji_base64_list
# 处理Seg列表的情况
for seg in message_segments:
if seg.type == "emoji":
emoji_base64_list.append(seg.data)
elif seg.type == "image":
# 假设图片数据是base64编码的
emoji_base64_list.append(seg.data)
elif seg.type == "seglist":
# 递归处理嵌套的Seg列表
emoji_base64_list.extend(self.find_and_return_emoji_in_message(seg.data))
return emoji_base64_list
class RandomEmojis(BaseCommand):
command_name = "random_emojis"
command_description = "发送多张随机表情包"
command_pattern = r"^/random_emojis$"
async def execute(self):
emojis = await emoji_api.get_random(5)
if not emojis:
return False, "未找到表情包", False
emoji_base64_list = []
for emoji in emojis:
emoji_base64_list.append(emoji[0])
return await self.forward_images(emoji_base64_list)
async def forward_images(self, images: List[str]):
"""
把多张图片用合并转发的方式发给用户
"""
success = await self.send_forward([("0", "神秘用户", [(ReplyContentType.IMAGE, img)]) for img in images])
return (True, "已发送随机表情包", True) if success else (False, "发送随机表情包失败", False)
# ===== 插件注册 =====
@register_plugin
class EmojiManagePlugin(BasePlugin):
"""表情包管理插件 - 管理表情包"""
# 插件基本信息
plugin_name: str = "emoji_manage_plugin" # 内部标识符
enable_plugin: bool = False
dependencies: List[str] = [] # 插件依赖列表
python_dependencies: List[str] = [] # Python包依赖列表
config_file_name: str = "config.toml" # 配置文件名
# 配置节描述
config_section_descriptions = {"plugin": "插件基本信息", "emoji": "表情包功能配置"}
# 配置Schema定义
config_schema: dict = {
"plugin": {
"enabled": ConfigField(type=bool, default=True, description="是否启用插件"),
"config_version": ConfigField(type=str, default="1.0.1", description="配置文件版本"),
},
}
def get_plugin_components(self) -> List[Tuple[ComponentInfo, Type]]:
return [
(RandomEmojis.get_command_info(), RandomEmojis),
(AddEmojiCommand.get_command_info(), AddEmojiCommand),
(ListEmojiCommand.get_command_info(), ListEmojiCommand),
(DeleteEmojiCommand.get_command_info(), DeleteEmojiCommand),
]

View File

@ -237,8 +237,7 @@ class HelloWorldPlugin(BasePlugin):
# 配置Schema定义 # 配置Schema定义
config_schema: dict = { config_schema: dict = {
"plugin": { "plugin": {
"name": ConfigField(type=str, default="hello_world_plugin", description="插件名称"), "config_version": ConfigField(type=str, default="1.0.0", description="配置文件版本"),
"version": ConfigField(type=str, default="1.0.0", description="插件版本"),
"enabled": ConfigField(type=bool, default=False, description="是否启用插件"), "enabled": ConfigField(type=bool, default=False, description="是否启用插件"),
}, },
"greeting": { "greeting": {

View File

@ -15,7 +15,7 @@ dependencies = [
"jieba>=0.42.1", "jieba>=0.42.1",
"json-repair>=0.47.6", "json-repair>=0.47.6",
"jsonlines>=4.0.0", "jsonlines>=4.0.0",
"maim-message>=0.3.8", "maim-message>=0.5.1",
"matplotlib>=3.10.3", "matplotlib>=3.10.3",
"networkx>=3.4.2", "networkx>=3.4.2",
"numpy>=2.2.6", "numpy>=2.2.6",

View File

@ -1,34 +1,36 @@
# This file was autogenerated by uv via the following command: # This file was autogenerated by uv via the following command:
# uv pip compile requirements.txt -o requirements.lock # uv pip compile requirements.txt -o requirements.lock
aenum==3.1.16
# via reportportal-client
aiohappyeyeballs==2.6.1 aiohappyeyeballs==2.6.1
# via aiohttp # via aiohttp
aiohttp==3.12.14 aiohttp==3.12.14
# via # via
# -r requirements.txt # -r requirements.txt
# aiohttp-cors
# maim-message # maim-message
# reportportal-client aiohttp-cors==0.8.1
# via -r requirements.txt
aiosignal==1.4.0 aiosignal==1.4.0
# via aiohttp # via aiohttp
annotated-types==0.7.0 annotated-types==0.7.0
# via pydantic # via pydantic
anyio==4.9.0 anyio==4.9.0
# via # via
# google-genai
# httpx # httpx
# openai # openai
# starlette # starlette
apscheduler==3.11.0 async-timeout==5.0.1
# via -r requirements.txt # via aiohttp
attrs==25.3.0 attrs==25.3.0
# via # via
# aiohttp # aiohttp
# jsonlines # jsonlines
cachetools==5.5.2
# via google-auth
certifi==2025.7.9 certifi==2025.7.9
# via # via
# httpcore # httpcore
# httpx # httpx
# reportportal-client
# requests # requests
cffi==1.17.1 cffi==1.17.1
# via cryptography # via cryptography
@ -41,24 +43,16 @@ colorama==0.4.6
# -r requirements.txt # -r requirements.txt
# click # click
# tqdm # tqdm
contourpy==1.3.2
# via matplotlib
cryptography==45.0.5 cryptography==45.0.5
# via # via maim-message
# -r requirements.txt
# maim-message
customtkinter==5.2.2
# via -r requirements.txt
cycler==0.12.1
# via matplotlib
darkdetect==0.8.0
# via customtkinter
distro==1.9.0 distro==1.9.0
# via openai # via openai
dnspython==2.7.0 dnspython==2.7.0
# via pymongo # via pymongo
dotenv==0.9.9 dotenv==0.9.9
# via -r requirements.txt # via -r requirements.txt
exceptiongroup==1.3.0
# via anyio
faiss-cpu==1.11.0 faiss-cpu==1.11.0
# via -r requirements.txt # via -r requirements.txt
fastapi==0.116.0 fastapi==0.116.0
@ -66,12 +60,14 @@ fastapi==0.116.0
# -r requirements.txt # -r requirements.txt
# maim-message # maim-message
# strawberry-graphql # strawberry-graphql
fonttools==4.58.5
# via matplotlib
frozenlist==1.7.0 frozenlist==1.7.0
# via # via
# aiohttp # aiohttp
# aiosignal # aiosignal
google-auth==2.40.3
# via google-genai
google-genai==1.38.0
# via -r requirements.txt
graphql-core==3.2.6 graphql-core==3.2.6
# via strawberry-graphql # via strawberry-graphql
h11==0.16.0 h11==0.16.0
@ -81,86 +77,70 @@ h11==0.16.0
httpcore==1.0.9 httpcore==1.0.9
# via httpx # via httpx
httpx==0.28.1 httpx==0.28.1
# via openai # via
# google-genai
# openai
idna==3.10 idna==3.10
# via # via
# anyio # anyio
# httpx # httpx
# requests # requests
# yarl # yarl
igraph==0.11.9
# via python-igraph
jieba==0.42.1 jieba==0.42.1
# via -r requirements.txt # via -r requirements.txt
jiter==0.10.0 jiter==0.10.0
# via openai # via openai
joblib==1.5.1
# via scikit-learn
json-repair==0.47.6 json-repair==0.47.6
# via -r requirements.txt # via -r requirements.txt
jsonlines==4.0.0 jsonlines==4.0.0
# via -r requirements.txt # via -r requirements.txt
kiwisolver==1.4.8 maim-message==0.5.1
# via matplotlib
maim-message==0.3.8
# via -r requirements.txt # via -r requirements.txt
markdown-it-py==3.0.0 markdown-it-py==3.0.0
# via rich # via rich
matplotlib==3.10.3
# via
# -r requirements.txt
# seaborn
mdurl==0.1.2 mdurl==0.1.2
# via markdown-it-py # via markdown-it-py
multidict==6.6.3 multidict==6.6.3
# via # via
# aiohttp # aiohttp
# yarl # yarl
networkx==3.5 networkx==3.4.2
# via -r requirements.txt # via -r requirements.txt
numpy==2.3.1 numpy==2.2.6
# via # via
# -r requirements.txt # -r requirements.txt
# contourpy
# faiss-cpu # faiss-cpu
# matplotlib
# pandas
# scikit-learn
# scipy # scipy
# seaborn
openai==1.95.0 openai==1.95.0
# via -r requirements.txt # via -r requirements.txt
packaging==25.0 packaging==25.0
# via # via
# -r requirements.txt # -r requirements.txt
# customtkinter
# faiss-cpu # faiss-cpu
# matplotlib
# strawberry-graphql # strawberry-graphql
pandas==2.3.1
# via
# -r requirements.txt
# seaborn
peewee==3.18.2 peewee==3.18.2
# via -r requirements.txt # via -r requirements.txt
pillow==11.3.0 pillow==11.3.0
# via # via -r requirements.txt
# -r requirements.txt
# matplotlib
propcache==0.3.2 propcache==0.3.2
# via # via
# aiohttp # aiohttp
# yarl # yarl
psutil==7.0.0 psutil==7.0.0
# via -r requirements.txt # via -r requirements.txt
pyarrow==20.0.0 pyasn1==0.6.1
# via -r requirements.txt # via
# pyasn1-modules
# rsa
pyasn1-modules==0.4.2
# via google-auth
pycparser==2.22 pycparser==2.22
# via cffi # via cffi
pydantic==2.11.7 pydantic==2.11.7
# via # via
# -r requirements.txt # -r requirements.txt
# fastapi # fastapi
# google-genai
# maim-message # maim-message
# openai # openai
pydantic-core==2.33.2 pydantic-core==2.33.2
@ -169,45 +149,27 @@ pygments==2.19.2
# via rich # via rich
pymongo==4.13.2 pymongo==4.13.2
# via -r requirements.txt # via -r requirements.txt
pyparsing==3.2.3
# via matplotlib
pypinyin==0.54.0 pypinyin==0.54.0
# via -r requirements.txt # via -r requirements.txt
python-dateutil==2.9.0.post0 python-dateutil==2.9.0.post0
# via # via strawberry-graphql
# -r requirements.txt
# matplotlib
# pandas
# strawberry-graphql
python-dotenv==1.1.1 python-dotenv==1.1.1
# via # via
# -r requirements.txt # -r requirements.txt
# dotenv # dotenv
python-igraph==0.11.9
# via -r requirements.txt
python-multipart==0.0.20 python-multipart==0.0.20
# via strawberry-graphql # via strawberry-graphql
pytz==2025.2
# via pandas
quick-algo==0.1.3 quick-algo==0.1.3
# via -r requirements.txt # via -r requirements.txt
reportportal-client==5.6.5
# via -r requirements.txt
requests==2.32.4 requests==2.32.4
# via # via google-genai
# -r requirements.txt
# reportportal-client
rich==14.0.0 rich==14.0.0
# via -r requirements.txt # via -r requirements.txt
rsa==4.9.1
# via google-auth
ruff==0.12.2 ruff==0.12.2
# via -r requirements.txt # via -r requirements.txt
scikit-learn==1.7.0 scipy==1.15.3
# via -r requirements.txt
scipy==1.16.0
# via
# -r requirements.txt
# scikit-learn
seaborn==0.13.2
# via -r requirements.txt # via -r requirements.txt
setuptools==80.9.0 setuptools==80.9.0
# via -r requirements.txt # via -r requirements.txt
@ -223,10 +185,8 @@ strawberry-graphql==0.275.5
# via -r requirements.txt # via -r requirements.txt
structlog==25.4.0 structlog==25.4.0
# via -r requirements.txt # via -r requirements.txt
texttable==1.7.0 tenacity==9.1.2
# via igraph # via google-genai
threadpoolctl==3.6.0
# via scikit-learn
toml==0.10.2 toml==0.10.2
# via -r requirements.txt # via -r requirements.txt
tomli==2.2.1 tomli==2.2.1
@ -236,25 +196,25 @@ tomli-w==1.2.0
tomlkit==0.13.3 tomlkit==0.13.3
# via -r requirements.txt # via -r requirements.txt
tqdm==4.67.1 tqdm==4.67.1
# via # via openai
# -r requirements.txt
# openai
typing-extensions==4.14.1 typing-extensions==4.14.1
# via # via
# aiosignal
# anyio
# exceptiongroup
# fastapi # fastapi
# google-genai
# multidict
# openai # openai
# pydantic # pydantic
# pydantic-core # pydantic-core
# rich
# strawberry-graphql # strawberry-graphql
# structlog
# typing-inspection # typing-inspection
# uvicorn
typing-inspection==0.4.1 typing-inspection==0.4.1
# via pydantic # via pydantic
tzdata==2025.2
# via
# pandas
# tzlocal
tzlocal==5.3.1
# via apscheduler
urllib3==2.5.0 urllib3==2.5.0
# via # via
# -r requirements.txt # -r requirements.txt
@ -266,6 +226,7 @@ uvicorn==0.35.0
websockets==15.0.1 websockets==15.0.1
# via # via
# -r requirements.txt # -r requirements.txt
# google-genai
# maim-message # maim-message
yarl==1.20.1 yarl==1.20.1
# via aiohttp # via aiohttp

View File

@ -1,30 +1,22 @@
APScheduler
Pillow Pillow
aiohttp aiohttp
aiohttp-cors aiohttp-cors
colorama colorama
customtkinter
dotenv dotenv
faiss-cpu faiss-cpu
fastapi fastapi
jieba jieba
jsonlines jsonlines
maim_message maim-message>=0
quick_algo quick_algo
matplotlib
networkx networkx
numpy numpy
openai openai
pandas
peewee peewee
pyarrow
pydantic pydantic
pypinyin pypinyin
python-dateutil
python-dotenv python-dotenv
python-igraph
pymongo pymongo
requests
ruff ruff
scipy scipy
setuptools setuptools
@ -32,7 +24,6 @@ toml
tomli tomli
tomli_w tomli_w
tomlkit tomlkit
tqdm
urllib3 urllib3
uvicorn uvicorn
websockets websockets
@ -40,10 +31,6 @@ strawberry-graphql[fastapi]
packaging packaging
rich rich
psutil psutil
cryptography
json-repair json-repair
reportportal-client
scikit-learn
seaborn
structlog structlog
google.genai google.genai

View File

@ -62,6 +62,7 @@ class ExpressionLearner:
model_set=model_config.model_task_config.replyer, request_type="expression.learner" model_set=model_config.model_task_config.replyer, request_type="expression.learner"
) )
self.chat_id = chat_id self.chat_id = chat_id
self.chat_stream = get_chat_manager().get_stream(chat_id)
self.chat_name = get_chat_manager().get_stream_name(chat_id) or chat_id self.chat_name = get_chat_manager().get_stream_name(chat_id) or chat_id
# 维护每个chat的上次学习时间 # 维护每个chat的上次学习时间
@ -69,24 +70,8 @@ class ExpressionLearner:
# 学习参数 # 学习参数
self.min_messages_for_learning = 25 # 触发学习所需的最少消息数 self.min_messages_for_learning = 25 # 触发学习所需的最少消息数
self.min_learning_interval = 300 # 最短学习时间间隔(秒) _, self.enable_learning, self.learning_intensity = global_config.expression.get_expression_config_for_chat(self.chat_id)
self.min_learning_interval = 300 / self.learning_intensity
def can_learn_for_chat(self) -> bool:
"""
检查指定聊天流是否允许学习表达
Args:
chat_id: 聊天流ID
Returns:
bool: 是否允许学习
"""
try:
use_expression, enable_learning, _ = global_config.expression.get_expression_config_for_chat(self.chat_id)
return enable_learning
except Exception as e:
logger.error(f"检查学习权限失败: {e}")
return False
def should_trigger_learning(self) -> bool: def should_trigger_learning(self) -> bool:
""" """
@ -98,27 +83,13 @@ class ExpressionLearner:
Returns: Returns:
bool: 是否应该触发学习 bool: 是否应该触发学习
""" """
current_time = time.time()
# 获取该聊天流的学习强度
try:
_, enable_learning, learning_intensity = global_config.expression.get_expression_config_for_chat(
self.chat_id
)
except Exception as e:
logger.error(f"获取聊天流 {self.chat_id} 的学习配置失败: {e}")
return False
# 检查是否允许学习 # 检查是否允许学习
if not enable_learning: if not self.enable_learning:
return False return False
# 根据学习强度计算最短学习时间间隔
min_interval = self.min_learning_interval / learning_intensity
# 检查时间间隔 # 检查时间间隔
time_diff = current_time - self.last_learning_time time_diff = time.time() - self.last_learning_time
if time_diff < min_interval: if time_diff < self.min_learning_interval:
return False return False
# 检查消息数量(只检查指定聊天流的消息) # 检查消息数量(只检查指定聊天流的消息)
@ -228,32 +199,17 @@ class ExpressionLearner:
""" """
学习并存储表达方式 学习并存储表达方式
""" """
# 检查是否允许在此聊天流中学习(在函数最前面检查)
if not self.can_learn_for_chat():
logger.debug(f"聊天流 {self.chat_name} 不允许学习表达,跳过学习")
return []
res = await self.learn_expression(num) res = await self.learn_expression(num)
if res is None: if res is None:
logger.info("没有学习到表达风格")
return [] return []
learnt_expressions, chat_id = res learnt_expressions, chat_id = res
chat_stream = get_chat_manager().get_stream(chat_id)
if chat_stream is None:
group_name = f"聊天流 {chat_id}"
elif chat_stream.group_info:
group_name = chat_stream.group_info.group_name
else:
group_name = f"{chat_stream.user_info.user_nickname}的私聊"
learnt_expressions_str = "" learnt_expressions_str = ""
for _chat_id, situation, style in learnt_expressions: for _chat_id, situation, style in learnt_expressions:
learnt_expressions_str += f"{situation}->{style}\n" learnt_expressions_str += f"{situation}->{style}\n"
logger.info(f"{group_name} 学习到表达风格:\n{learnt_expressions_str}")
if not learnt_expressions: logger.info(f"{self.chat_name} 学习到表达风格:\n{learnt_expressions_str}")
logger.info("没有学习到表达风格")
return []
# 按chat_id分组 # 按chat_id分组
chat_dict: Dict[str, List[Dict[str, Any]]] = {} chat_dict: Dict[str, List[Dict[str, Any]]] = {}
@ -316,7 +272,7 @@ class ExpressionLearner:
current_time = time.time() current_time = time.time()
# 获取上次学习时间 # 获取上次学习之后的消息
random_msg = get_raw_msg_by_timestamp_with_chat_inclusive( random_msg = get_raw_msg_by_timestamp_with_chat_inclusive(
chat_id=self.chat_id, chat_id=self.chat_id,
timestamp_start=self.last_learning_time, timestamp_start=self.last_learning_time,
@ -330,14 +286,15 @@ class ExpressionLearner:
chat_id: str = random_msg[0].chat_id chat_id: str = random_msg[0].chat_id
# random_msg_str: str = build_readable_messages(random_msg, timestamp_mode="normal") # random_msg_str: str = build_readable_messages(random_msg, timestamp_mode="normal")
random_msg_str: str = await build_anonymous_messages(random_msg) random_msg_str: str = await build_anonymous_messages(random_msg)
# print(f"random_msg_str:{random_msg_str}")
prompt: str = await global_prompt_manager.format_prompt( prompt: str = await global_prompt_manager.format_prompt(
prompt, prompt,
chat_str=random_msg_str, chat_str=random_msg_str,
) )
logger.debug(f"学习{type_str}的prompt: {prompt}") print(f"random_msg_str:{random_msg_str}")
logger.info(f"学习{type_str}的prompt: {prompt}")
try: try:
response, _ = await self.express_learn_model.generate_response_async(prompt, temperature=0.3) response, _ = await self.express_learn_model.generate_response_async(prompt, temperature=0.3)

View File

@ -207,7 +207,7 @@ class HeartFChatting:
await self._observe(recent_messages_list=recent_messages_list) await self._observe(recent_messages_list=recent_messages_list)
else: else:
# 没有提到继续保持沉默等待5秒防止频繁触发 # 没有提到继续保持沉默等待5秒防止频繁触发
await asyncio.sleep(5) await asyncio.sleep(10)
return True return True
else: else:
await asyncio.sleep(0.2) await asyncio.sleep(0.2)
@ -345,6 +345,10 @@ class HeartFChatting:
) )
) )
logger.info(
f"{self.log_prefix}决定执行{len(action_to_use_info)}个动作: {' '.join([a.action_type for a in action_to_use_info])}"
)
# 3. 并行执行所有动作 # 3. 并行执行所有动作
action_tasks = [ action_tasks = [
asyncio.create_task( asyncio.create_task(

View File

@ -3,7 +3,7 @@ import os
import re import re
from typing import Dict, Any, Optional from typing import Dict, Any, Optional
from maim_message import UserInfo, Seg from maim_message import UserInfo, Seg, GroupInfo
from src.common.logger import get_logger from src.common.logger import get_logger
from src.config.config import global_config from src.config.config import global_config
@ -27,7 +27,7 @@ PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../..
logger = get_logger("chat") logger = get_logger("chat")
def _check_ban_words(text: str, chat: ChatStream, userinfo: UserInfo) -> bool: def _check_ban_words(text: str, userinfo: UserInfo, group_info: Optional[GroupInfo] = None) -> bool:
"""检查消息是否包含过滤词 """检查消息是否包含过滤词
Args: Args:
@ -40,14 +40,14 @@ def _check_ban_words(text: str, chat: ChatStream, userinfo: UserInfo) -> bool:
""" """
for word in global_config.message_receive.ban_words: for word in global_config.message_receive.ban_words:
if word in text: if word in text:
chat_name = chat.group_info.group_name if chat.group_info else "私聊" chat_name = group_info.group_name if group_info else "私聊"
logger.info(f"[{chat_name}]{userinfo.user_nickname}:{text}") logger.info(f"[{chat_name}]{userinfo.user_nickname}:{text}")
logger.info(f"[过滤词识别]消息中含有{word}filtered") logger.info(f"[过滤词识别]消息中含有{word}filtered")
return True return True
return False return False
def _check_ban_regex(text: str, chat: ChatStream, userinfo: UserInfo) -> bool: def _check_ban_regex(text: str, userinfo: UserInfo, group_info: Optional[GroupInfo] = None) -> bool:
"""检查消息是否匹配过滤正则表达式 """检查消息是否匹配过滤正则表达式
Args: Args:
@ -64,7 +64,7 @@ def _check_ban_regex(text: str, chat: ChatStream, userinfo: UserInfo) -> bool:
for pattern in global_config.message_receive.ban_msgs_regex: for pattern in global_config.message_receive.ban_msgs_regex:
if re.search(pattern, text): if re.search(pattern, text):
chat_name = chat.group_info.group_name if chat.group_info else "私聊" chat_name = group_info.group_name if group_info else "私聊"
logger.info(f"[{chat_name}]{userinfo.user_nickname}:{text}") logger.info(f"[{chat_name}]{userinfo.user_nickname}:{text}")
logger.info(f"[正则表达式过滤]消息匹配到{pattern}filtered") logger.info(f"[正则表达式过滤]消息匹配到{pattern}filtered")
return True return True
@ -251,6 +251,21 @@ class ChatBot:
# return # return
pass pass
# 处理消息内容,生成纯文本
await message.process()
# 过滤检查
if _check_ban_words(
message.processed_plain_text,
user_info, # type: ignore
group_info,
) or _check_ban_regex(
message.raw_message, # type: ignore
user_info, # type: ignore
group_info,
):
return
get_chat_manager().register_message(message) get_chat_manager().register_message(message)
chat = await get_chat_manager().get_or_create_stream( chat = await get_chat_manager().get_or_create_stream(
@ -261,21 +276,10 @@ class ChatBot:
message.update_chat_stream(chat) message.update_chat_stream(chat)
# 处理消息内容,生成纯文本
await message.process()
# if await self.check_ban_content(message): # if await self.check_ban_content(message):
# logger.warning(f"检测到消息中含有违法,色情,暴力,反动,敏感内容,消息内容:{message.processed_plain_text},发送者:{message.message_info.user_info.user_nickname}") # logger.warning(f"检测到消息中含有违法,色情,暴力,反动,敏感内容,消息内容:{message.processed_plain_text},发送者:{message.message_info.user_info.user_nickname}")
# return # return
# 过滤检查
if _check_ban_words(message.processed_plain_text, chat, user_info) or _check_ban_regex( # type: ignore
message.raw_message, # type: ignore
chat,
user_info, # type: ignore
):
return
# 命令处理 - 使用新插件系统检查并处理命令 # 命令处理 - 使用新插件系统检查并处理命令
is_command, cmd_result, continue_process = await self._process_commands_with_new_system(message) is_command, cmd_result, continue_process = await self._process_commands_with_new_system(message)

View File

@ -499,8 +499,8 @@ class ActionPlanner:
action.action_data = action.action_data or {} action.action_data = action.action_data or {}
action.action_data["loop_start_time"] = loop_start_time action.action_data["loop_start_time"] = loop_start_time
logger.info( logger.debug(
f"{self.log_prefix}规划器决定执行{len(actions)}个动作: {' '.join([a.action_type for a in actions])}" f"{self.log_prefix}规划器选择了{len(actions)}个动作: {' '.join([a.action_type for a in actions])}"
) )
return actions return actions

View File

@ -623,3 +623,41 @@ def image_path_to_base64(image_path: str) -> str:
return base64.b64encode(image_data).decode("utf-8") return base64.b64encode(image_data).decode("utf-8")
else: else:
raise IOError(f"读取图片文件失败: {image_path}") raise IOError(f"读取图片文件失败: {image_path}")
def base64_to_image(image_base64: str, output_path: str) -> bool:
"""将base64编码的图片保存为文件
Args:
image_base64: 图片的base64编码
output_path: 输出文件路径
Returns:
bool: 是否成功保存
Raises:
ValueError: 当base64编码无效时
IOError: 当保存文件失败时
"""
try:
# 确保base64字符串只包含ASCII字符
if isinstance(image_base64, str):
image_base64 = image_base64.encode("ascii", errors="ignore").decode("ascii")
# 解码base64
image_bytes = base64.b64decode(image_base64)
# 确保输出目录存在
output_dir = os.path.dirname(output_path)
if output_dir:
os.makedirs(output_dir, exist_ok=True)
# 保存文件
with open(output_path, "wb") as f:
f.write(image_bytes)
return True
except Exception as e:
logger.error(f"保存base64图片失败: {e}")
return False

View File

@ -54,7 +54,7 @@ TEMPLATE_DIR = os.path.join(PROJECT_ROOT, "template")
# 考虑到实际上配置文件中的mai_version是不会自动更新的,所以采用硬编码 # 考虑到实际上配置文件中的mai_version是不会自动更新的,所以采用硬编码
# 对该字段的更新请严格参照语义化版本规范https://semver.org/lang/zh-CN/ # 对该字段的更新请严格参照语义化版本规范https://semver.org/lang/zh-CN/
MMC_VERSION = "0.10.3" MMC_VERSION = "0.10.4-snapshot.1"
def get_key_comment(toml_table, key): def get_key_comment(toml_table, key):

View File

@ -1,7 +1,7 @@
import asyncio import asyncio
import io import io
import base64 import base64
from typing import Callable, AsyncIterator, Optional, Coroutine, Any, List from typing import Callable, AsyncIterator, Optional, Coroutine, Any, List, Dict
from google import genai from google import genai
from google.genai.types import ( from google.genai.types import (
@ -17,6 +17,7 @@ from google.genai.types import (
EmbedContentResponse, EmbedContentResponse,
EmbedContentConfig, EmbedContentConfig,
SafetySetting, SafetySetting,
HttpOptions,
HarmCategory, HarmCategory,
HarmBlockThreshold, HarmBlockThreshold,
) )
@ -345,7 +346,24 @@ class GeminiClient(BaseClient):
def __init__(self, api_provider: APIProvider): def __init__(self, api_provider: APIProvider):
super().__init__(api_provider) super().__init__(api_provider)
# 增加传入参数处理
http_options_kwargs: Dict[str, Any] = {}
# 秒转换为毫秒传入
if api_provider.timeout is not None:
http_options_kwargs["timeout"] = int(api_provider.timeout * 1000)
# 传入并处理地址和版本(必须为Gemini格式)
if api_provider.base_url:
parts = api_provider.base_url.rstrip("/").rsplit("/", 1)
if len(parts) == 2 and parts[1].startswith("v"):
http_options_kwargs["base_url"] = f"{parts[0]}/"
http_options_kwargs["api_version"] = parts[1]
else:
http_options_kwargs["base_url"] = api_provider.base_url
self.client = genai.Client( self.client = genai.Client(
http_options=HttpOptions(**http_options_kwargs),
api_key=api_provider.api_key, api_key=api_provider.api_key,
) # 这里和openai不一样gemini会自己决定自己是否需要retry ) # 这里和openai不一样gemini会自己决定自己是否需要retry
@ -368,20 +386,29 @@ class GeminiClient(BaseClient):
limits = THINKING_BUDGET_LIMITS[key] limits = THINKING_BUDGET_LIMITS[key]
break break
# 特殊值处理 # 预算值处理
if tb == THINKING_BUDGET_AUTO: if tb == THINKING_BUDGET_AUTO:
return THINKING_BUDGET_AUTO return THINKING_BUDGET_AUTO
if tb == THINKING_BUDGET_DISABLED: if tb == THINKING_BUDGET_DISABLED:
if limits and limits.get("can_disable", False): if limits and limits.get("can_disable", False):
return THINKING_BUDGET_DISABLED return THINKING_BUDGET_DISABLED
return limits["min"] if limits else THINKING_BUDGET_AUTO
# 已知模型裁剪到范围
if limits: if limits:
return max(limits["min"], min(tb, limits["max"])) logger.warning(f"模型 {model_id} 不支持禁用思考预算,已回退到最小值 {limits['min']}")
return limits["min"]
return THINKING_BUDGET_AUTO
# 未知模型,返回动态模式 # 已知模型范围裁剪 + 提示
logger.warning(f"模型 {model_id} 未在 THINKING_BUDGET_LIMITS 中定义,将使用动态模式 tb=-1 兼容。") if limits:
if tb < limits["min"]:
logger.warning(f"模型 {model_id} 的 thinking_budget={tb} 过小,已调整为最小值 {limits['min']}")
return limits["min"]
if tb > limits["max"]:
logger.warning(f"模型 {model_id} 的 thinking_budget={tb} 过大,已调整为最大值 {limits['max']}")
return limits["max"]
return tb
# 未知模型 → 默认自动模式
logger.warning(f"模型 {model_id} 未在 THINKING_BUDGET_LIMITS 中定义,已启用模型自动预算兼容")
return THINKING_BUDGET_AUTO return THINKING_BUDGET_AUTO
async def get_response( async def get_response(
@ -436,7 +463,9 @@ class GeminiClient(BaseClient):
try: try:
tb = int(extra_params["thinking_budget"]) tb = int(extra_params["thinking_budget"])
except (ValueError, TypeError): except (ValueError, TypeError):
logger.warning(f"无效的 thinking_budget 值 {extra_params['thinking_budget']},将使用默认动态模式 {tb}") logger.warning(
f"无效的 thinking_budget 值 {extra_params['thinking_budget']},将使用模型自动预算模式 {tb}"
)
# 裁剪到模型支持的范围 # 裁剪到模型支持的范围
tb = self.clamp_thinking_budget(tb, model_info.model_identifier) tb = self.clamp_thinking_budget(tb, model_info.model_identifier)

View File

@ -26,18 +26,6 @@ install(extra_lines=3)
logger = get_logger("model_utils") logger = get_logger("model_utils")
# 常见Error Code Mapping
error_code_mapping = {
400: "参数不正确",
401: "API key 错误,认证失败,请检查 config/model_config.toml 中的配置是否正确",
402: "账号余额不足",
403: "需要实名,或余额不足",
404: "Not Found",
429: "请求过于频繁,请稍后再试",
500: "服务器内部故障",
503: "服务器负载过高",
}
class RequestType(Enum): class RequestType(Enum):
"""请求类型枚举""" """请求类型枚举"""
@ -267,14 +255,14 @@ class LLMRequest:
extra_params=model_info.extra_params, extra_params=model_info.extra_params,
) )
elif request_type == RequestType.EMBEDDING: elif request_type == RequestType.EMBEDDING:
assert embedding_input is not None assert embedding_input is not None, "嵌入输入不能为空"
return await client.get_embedding( return await client.get_embedding(
model_info=model_info, model_info=model_info,
embedding_input=embedding_input, embedding_input=embedding_input,
extra_params=model_info.extra_params, extra_params=model_info.extra_params,
) )
elif request_type == RequestType.AUDIO: elif request_type == RequestType.AUDIO:
assert audio_base64 is not None assert audio_base64 is not None, "音频Base64不能为空"
return await client.get_audio_transcriptions( return await client.get_audio_transcriptions(
model_info=model_info, model_info=model_info,
audio_base64=audio_base64, audio_base64=audio_base64,
@ -365,24 +353,23 @@ class LLMRequest:
embedding_input=embedding_input, embedding_input=embedding_input,
audio_base64=audio_base64, audio_base64=audio_base64,
) )
total_tokens, penalty, usage_penalty = self.model_usage[model_info.name]
if response_usage := response.usage:
total_tokens += response_usage.total_tokens
self.model_usage[model_info.name] = (total_tokens, penalty, usage_penalty - 1)
return response, model_info return response, model_info
except ModelAttemptFailed as e: except ModelAttemptFailed as e:
last_exception = e.original_exception or e last_exception = e.original_exception or e
logger.warning(f"模型 '{model_info.name}' 尝试失败,切换到下一个模型。原因: {e}") logger.warning(f"模型 '{model_info.name}' 尝试失败,切换到下一个模型。原因: {e}")
total_tokens, penalty, usage_penalty = self.model_usage[model_info.name] total_tokens, penalty, usage_penalty = self.model_usage[model_info.name]
self.model_usage[model_info.name] = (total_tokens, penalty + 1, usage_penalty) self.model_usage[model_info.name] = (total_tokens, penalty + 1, usage_penalty - 1)
failed_models_this_request.add(model_info.name) failed_models_this_request.add(model_info.name)
if isinstance(last_exception, RespNotOkException) and last_exception.status_code == 400: if isinstance(last_exception, RespNotOkException) and last_exception.status_code == 400:
logger.error("收到不可恢复的客户端错误 (400),中止所有尝试。") logger.error("收到不可恢复的客户端错误 (400),中止所有尝试。")
raise last_exception from e raise last_exception from e
finally:
total_tokens, penalty, usage_penalty = self.model_usage[model_info.name]
if usage_penalty > 0:
self.model_usage[model_info.name] = (total_tokens, penalty, usage_penalty - 1)
logger.error(f"所有 {max_attempts} 个模型均尝试失败。") logger.error(f"所有 {max_attempts} 个模型均尝试失败。")
if last_exception: if last_exception:
raise last_exception raise last_exception

View File

@ -9,11 +9,15 @@
""" """
import random import random
import base64
import os
import uuid
import time
from typing import Optional, Tuple, List from typing import Optional, Tuple, List, Dict, Any
from src.common.logger import get_logger from src.common.logger import get_logger
from src.chat.emoji_system.emoji_manager import get_emoji_manager from src.chat.emoji_system.emoji_manager import get_emoji_manager, EMOJI_DIR
from src.chat.utils.utils_image import image_path_to_base64 from src.chat.utils.utils_image import image_path_to_base64, base64_to_image
logger = get_logger("emoji_api") logger = get_logger("emoji_api")
@ -245,6 +249,42 @@ def get_emotions() -> List[str]:
return [] return []
async def get_all() -> List[Tuple[str, str, str]]:
"""获取所有表情包
Returns:
List[Tuple[str, str, str]]: 包含(base64编码, 表情包描述, 随机情感标签)的元组列表
"""
try:
emoji_manager = get_emoji_manager()
all_emojis = emoji_manager.emoji_objects
if not all_emojis:
logger.warning("[EmojiAPI] 没有可用的表情包")
return []
results = []
for emoji_obj in all_emojis:
if emoji_obj.is_deleted:
continue
emoji_base64 = image_path_to_base64(emoji_obj.full_path)
if not emoji_base64:
logger.error(f"[EmojiAPI] 无法转换表情包为base64: {emoji_obj.full_path}")
continue
matched_emotion = random.choice(emoji_obj.emotion) if emoji_obj.emotion else "随机表情"
results.append((emoji_base64, emoji_obj.description, matched_emotion))
logger.debug(f"[EmojiAPI] 成功获取 {len(results)} 个表情包")
return results
except Exception as e:
logger.error(f"[EmojiAPI] 获取所有表情包失败: {e}")
return []
def get_descriptions() -> List[str]: def get_descriptions() -> List[str]:
"""获取所有表情包描述 """获取所有表情包描述
@ -264,3 +304,400 @@ def get_descriptions() -> List[str]:
except Exception as e: except Exception as e:
logger.error(f"[EmojiAPI] 获取表情包描述失败: {e}") logger.error(f"[EmojiAPI] 获取表情包描述失败: {e}")
return [] return []
# =============================================================================
# 表情包注册API函数
# =============================================================================
async def register_emoji(image_base64: str, filename: Optional[str] = None) -> Dict[str, Any]:
"""注册新的表情包
Args:
image_base64: 图片的base64编码
filename: 可选的文件名如果未提供则自动生成
Returns:
Dict[str, Any]: 注册结果包含以下字段
- success: bool, 是否成功注册
- message: str, 结果消息
- description: Optional[str], 表情包描述成功时
- emotions: Optional[List[str]], 情感标签列表成功时
- replaced: Optional[bool], 是否替换了旧表情包成功时
- hash: Optional[str], 表情包哈希值成功时
Raises:
ValueError: 如果base64为空或无效
TypeError: 如果参数类型不正确
"""
if not image_base64:
raise ValueError("图片base64编码不能为空")
if not isinstance(image_base64, str):
raise TypeError("image_base64必须是字符串类型")
if filename is not None and not isinstance(filename, str):
raise TypeError("filename必须是字符串类型或None")
try:
logger.info(f"[EmojiAPI] 开始注册表情包,文件名: {filename or '自动生成'}")
# 1. 获取emoji管理器并检查容量
emoji_manager = get_emoji_manager()
count_before = emoji_manager.emoji_num
max_count = emoji_manager.emoji_num_max
# 2. 检查是否可以注册(未达到上限或启用替换)
can_register = count_before < max_count or (
count_before >= max_count and emoji_manager.emoji_num_max_reach_deletion
)
if not can_register:
return {
"success": False,
"message": f"表情包数量已达上限({count_before}/{max_count})且未启用替换功能",
"description": None,
"emotions": None,
"replaced": None,
"hash": None
}
# 3. 确保emoji目录存在
os.makedirs(EMOJI_DIR, exist_ok=True)
# 4. 生成文件名
if not filename:
# 基于时间戳、微秒和短base64生成唯一文件名
import time
timestamp = int(time.time())
microseconds = int(time.time() * 1000000) % 1000000 # 添加微秒级精度
# 生成12位随机标识符使用base64编码增加随机性
import random
random_bytes = random.getrandbits(72).to_bytes(9, 'big') # 72位 = 9字节 = 12位base64
short_id = base64.b64encode(random_bytes).decode('ascii')[:12].rstrip('=')
# 确保base64编码适合文件名替换/和-
short_id = short_id.replace('/', '_').replace('+', '-')
filename = f"emoji_{timestamp}_{microseconds}_{short_id}"
# 确保文件名有扩展名
if not filename.lower().endswith(('.jpg', '.jpeg', '.png', '.gif')):
filename = f"{filename}.png" # 默认使用png格式
# 检查文件名是否已存在,如果存在则重新生成短标识符
temp_file_path = os.path.join(EMOJI_DIR, filename)
attempts = 0
max_attempts = 10
while os.path.exists(temp_file_path) and attempts < max_attempts:
# 重新生成短标识符
import random
random_bytes = random.getrandbits(48).to_bytes(6, 'big')
short_id = base64.b64encode(random_bytes).decode('ascii')[:8].rstrip('=')
short_id = short_id.replace('/', '_').replace('+', '-')
# 分离文件名和扩展名,重新生成文件名
name_part, ext = os.path.splitext(filename)
# 去掉原来的标识符,添加新的
base_name = name_part.rsplit('_', 1)[0] # 移除最后一个_后的部分
filename = f"{base_name}_{short_id}{ext}"
temp_file_path = os.path.join(EMOJI_DIR, filename)
attempts += 1
# 如果还是冲突使用UUID作为备用方案
if os.path.exists(temp_file_path):
uuid_short = str(uuid.uuid4())[:8]
name_part, ext = os.path.splitext(filename)
base_name = name_part.rsplit('_', 1)[0]
filename = f"{base_name}_{uuid_short}{ext}"
temp_file_path = os.path.join(EMOJI_DIR, filename)
# 如果UUID方案也冲突添加序号
counter = 1
original_filename = filename
while os.path.exists(temp_file_path):
name_part, ext = os.path.splitext(original_filename)
filename = f"{name_part}_{counter}{ext}"
temp_file_path = os.path.join(EMOJI_DIR, filename)
counter += 1
# 防止无限循环最多尝试100次
if counter > 100:
logger.error(f"[EmojiAPI] 无法生成唯一文件名,尝试次数过多: {original_filename}")
return {
"success": False,
"message": "无法生成唯一文件名,请稍后重试",
"description": None,
"emotions": None,
"replaced": None,
"hash": None
}
# 5. 保存base64图片到emoji目录
try:
# 解码base64并保存图片
if not base64_to_image(image_base64, temp_file_path):
logger.error(f"[EmojiAPI] 无法保存base64图片到文件: {temp_file_path}")
return {
"success": False,
"message": "无法保存图片文件",
"description": None,
"emotions": None,
"replaced": None,
"hash": None
}
logger.debug(f"[EmojiAPI] 图片已保存到临时文件: {temp_file_path}")
except Exception as save_error:
logger.error(f"[EmojiAPI] 保存图片文件失败: {save_error}")
return {
"success": False,
"message": f"保存图片文件失败: {str(save_error)}",
"description": None,
"emotions": None,
"replaced": None,
"hash": None
}
# 6. 调用注册方法
register_success = await emoji_manager.register_emoji_by_filename(filename)
# 7. 清理临时文件(如果注册失败但文件还存在)
if not register_success and os.path.exists(temp_file_path):
try:
os.remove(temp_file_path)
logger.debug(f"[EmojiAPI] 已清理临时文件: {temp_file_path}")
except Exception as cleanup_error:
logger.warning(f"[EmojiAPI] 清理临时文件失败: {cleanup_error}")
# 8. 构建返回结果
if register_success:
count_after = emoji_manager.emoji_num
replaced = count_after <= count_before # 如果数量没增加,说明是替换
# 尝试获取新注册的表情包信息
new_emoji_info = None
if count_after > count_before or replaced:
# 获取最新的表情包信息
try:
# 通过文件名查找新注册的表情包(注意:文件名在注册后可能已经改变)
for emoji_obj in reversed(emoji_manager.emoji_objects):
if not emoji_obj.is_deleted and (
emoji_obj.filename == filename or # 直接匹配
(hasattr(emoji_obj, 'full_path') and filename in emoji_obj.full_path) # 路径包含匹配
):
new_emoji_info = emoji_obj
break
except Exception as find_error:
logger.warning(f"[EmojiAPI] 查找新注册表情包信息失败: {find_error}")
description = new_emoji_info.description if new_emoji_info else None
emotions = new_emoji_info.emotion if new_emoji_info else None
emoji_hash = new_emoji_info.hash if new_emoji_info else None
return {
"success": True,
"message": f"表情包注册成功 {'(替换旧表情包)' if replaced else '(新增表情包)'}",
"description": description,
"emotions": emotions,
"replaced": replaced,
"hash": emoji_hash
}
else:
return {
"success": False,
"message": "表情包注册失败,可能因为重复、格式不支持或审核未通过",
"description": None,
"emotions": None,
"replaced": None,
"hash": None
}
except Exception as e:
logger.error(f"[EmojiAPI] 注册表情包时发生异常: {e}")
return {
"success": False,
"message": f"注册过程中发生错误: {str(e)}",
"description": None,
"emotions": None,
"replaced": None,
"hash": None
}
# =============================================================================
# 表情包删除API函数
# =============================================================================
async def delete_emoji(emoji_hash: str) -> Dict[str, Any]:
"""删除表情包
Args:
emoji_hash: 要删除的表情包的哈希值
Returns:
Dict[str, Any]: 删除结果包含以下字段
- success: bool, 是否成功删除
- message: str, 结果消息
- count_before: Optional[int], 删除前的表情包数量
- count_after: Optional[int], 删除后的表情包数量
- description: Optional[str], 被删除的表情包描述成功时
- emotions: Optional[List[str]], 被删除的表情包情感标签成功时
Raises:
ValueError: 如果哈希值为空
TypeError: 如果哈希值不是字符串类型
"""
if not emoji_hash:
raise ValueError("表情包哈希值不能为空")
if not isinstance(emoji_hash, str):
raise TypeError("emoji_hash必须是字符串类型")
try:
logger.info(f"[EmojiAPI] 开始删除表情包,哈希值: {emoji_hash}")
# 1. 获取emoji管理器和删除前的数量
emoji_manager = get_emoji_manager()
count_before = emoji_manager.emoji_num
# 2. 获取被删除表情包的信息(用于返回结果)
try:
deleted_emoji = await emoji_manager.get_emoji_from_manager(emoji_hash)
description = deleted_emoji.description if deleted_emoji else None
emotions = deleted_emoji.emotion if deleted_emoji else None
except Exception as info_error:
logger.warning(f"[EmojiAPI] 获取被删除表情包信息失败: {info_error}")
description = None
emotions = None
# 3. 执行删除操作
delete_success = await emoji_manager.delete_emoji(emoji_hash)
# 4. 获取删除后的数量
count_after = emoji_manager.emoji_num
# 5. 构建返回结果
if delete_success:
return {
"success": True,
"message": f"表情包删除成功 (哈希: {emoji_hash[:8]}...)",
"count_before": count_before,
"count_after": count_after,
"description": description,
"emotions": emotions
}
else:
return {
"success": False,
"message": f"表情包删除失败,可能因为哈希值不存在或删除过程出错",
"count_before": count_before,
"count_after": count_after,
"description": None,
"emotions": None
}
except Exception as e:
logger.error(f"[EmojiAPI] 删除表情包时发生异常: {e}")
return {
"success": False,
"message": f"删除过程中发生错误: {str(e)}",
"count_before": None,
"count_after": None,
"description": None,
"emotions": None
}
async def delete_emoji_by_description(description: str, exact_match: bool = False) -> Dict[str, Any]:
"""根据描述删除表情包
Args:
description: 表情包描述文本
exact_match: 是否精确匹配描述False则为模糊匹配
Returns:
Dict[str, Any]: 删除结果包含以下字段
- success: bool, 是否成功删除
- message: str, 结果消息
- deleted_count: int, 删除的表情包数量
- deleted_hashes: List[str], 被删除的表情包哈希列表
- matched_count: int, 匹配到的表情包数量
Raises:
ValueError: 如果描述为空
TypeError: 如果描述不是字符串类型
"""
if not description:
raise ValueError("描述不能为空")
if not isinstance(description, str):
raise TypeError("description必须是字符串类型")
try:
logger.info(f"[EmojiAPI] 根据描述删除表情包: {description} (精确匹配: {exact_match})")
emoji_manager = get_emoji_manager()
all_emojis = emoji_manager.emoji_objects
# 筛选匹配的表情包
matching_emojis = []
for emoji_obj in all_emojis:
if emoji_obj.is_deleted:
continue
if exact_match:
if emoji_obj.description == description:
matching_emojis.append(emoji_obj)
else:
if description.lower() in emoji_obj.description.lower():
matching_emojis.append(emoji_obj)
matched_count = len(matching_emojis)
if matched_count == 0:
return {
"success": False,
"message": f"未找到匹配描述 '{description}' 的表情包",
"deleted_count": 0,
"deleted_hashes": [],
"matched_count": 0
}
# 删除匹配的表情包
deleted_count = 0
deleted_hashes = []
for emoji_obj in matching_emojis:
try:
delete_success = await emoji_manager.delete_emoji(emoji_obj.hash)
if delete_success:
deleted_count += 1
deleted_hashes.append(emoji_obj.hash)
except Exception as delete_error:
logger.error(f"[EmojiAPI] 删除表情包失败 (哈希: {emoji_obj.hash}): {delete_error}")
# 构建返回结果
if deleted_count > 0:
return {
"success": True,
"message": f"成功删除 {deleted_count} 个表情包 (匹配到 {matched_count} 个)",
"deleted_count": deleted_count,
"deleted_hashes": deleted_hashes,
"matched_count": matched_count
}
else:
return {
"success": False,
"message": f"匹配到 {matched_count} 个表情包,但删除全部失败",
"deleted_count": 0,
"deleted_hashes": [],
"matched_count": matched_count
}
except Exception as e:
logger.error(f"[EmojiAPI] 根据描述删除表情包时发生异常: {e}")
return {
"success": False,
"message": f"删除过程中发生错误: {str(e)}",
"deleted_count": 0,
"deleted_hashes": [],
"matched_count": 0
}

View File

@ -28,16 +28,6 @@ class EmojiAction(BaseAction):
action_name = "emoji" action_name = "emoji"
action_description = "发送表情包辅助表达情绪" action_description = "发送表情包辅助表达情绪"
# LLM判断提示词
llm_judge_prompt = """
判定是否需要使用表情动作的条件
1. 用户明确要求使用表情包
2. 这是一个适合表达强烈情绪的场合
3. 不要发送太多表情包如果你已经发送过多个表情包则回答""
请回答""""
"""
# 动作参数定义 # 动作参数定义
action_parameters = {} action_parameters = {}
@ -56,7 +46,8 @@ class EmojiAction(BaseAction):
"""执行表情动作""" """执行表情动作"""
try: try:
# 1. 获取发送表情的原因 # 1. 获取发送表情的原因
reason = self.action_data.get("reason", "表达当前情绪") # reason = self.action_data.get("reason", "表达当前情绪")
reason = self.reasoning
# 2. 随机获取20个表情包 # 2. 随机获取20个表情包
sampled_emojis = await emoji_api.get_random(30) sampled_emojis = await emoji_api.get_random(30)
@ -72,6 +63,9 @@ class EmojiAction(BaseAction):
emotion_map[emo].append((b64, desc)) emotion_map[emo].append((b64, desc))
available_emotions = list(emotion_map.keys()) available_emotions = list(emotion_map.keys())
available_emotions_str = ""
for emotion in available_emotions:
available_emotions_str += f"{emotion}\n"
if not available_emotions: if not available_emotions:
logger.warning(f"{self.log_prefix} 获取到的表情包均无情感标签, 将随机发送") logger.warning(f"{self.log_prefix} 获取到的表情包均无情感标签, 将随机发送")
@ -90,14 +84,15 @@ class EmojiAction(BaseAction):
) )
# 4. 构建prompt让LLM选择情感 # 4. 构建prompt让LLM选择情感
prompt = f""" prompt = f"""你正在进行QQ聊天你需要根据聊天记录选出一个合适的情感标签。
你是一个正在进行聊天的网友你需要根据一个理由和最近的聊天记录从一个情感标签列表中选择最匹配的一个 请你根据以下原因和聊天记录进行选择
这是最近的聊天记录 原因{reason}
{messages_text} 聊天记录
{messages_text}
这是理由{reason} 这里是可用的情感标签
这里是可用的情感标签{available_emotions} {available_emotions_str}
请直接返回最匹配的那个情感标签不要进行任何解释或添加其他多余的文字 请直接返回最匹配的那个情感标签不要进行任何解释或添加其他多余的文字
""" """
if global_config.debug.show_prompt: if global_config.debug.show_prompt:
@ -107,10 +102,10 @@ class EmojiAction(BaseAction):
# 5. 调用LLM # 5. 调用LLM
models = llm_api.get_available_models() models = llm_api.get_available_models()
chat_model_config = models.get("utils_small") # 使用字典访问方式 chat_model_config = models.get("replyer") # 使用字典访问方式
if not chat_model_config: if not chat_model_config:
logger.error(f"{self.log_prefix} 未找到'utils_small'模型配置无法调用LLM") logger.error(f"{self.log_prefix} 未找到'replyer'模型配置无法调用LLM")
return False, "未找到'utils_small'模型配置" return False, "未找到'replyer'模型配置"
success, chosen_emotion, _, _ = await llm_api.generate_with_model( success, chosen_emotion, _, _ = await llm_api.generate_with_model(
prompt, model_config=chat_model_config, request_type="emoji" prompt, model_config=chat_model_config, request_type="emoji"

View File

@ -1,5 +1,5 @@
[inner] [inner]
version = "1.7.0" version = "1.7.2"
# 配置文件版本号迭代规则同bot_config.toml # 配置文件版本号迭代规则同bot_config.toml
@ -23,7 +23,7 @@ retry_interval = 5
[[api_providers]] # 特殊Google的Gimini使用特殊API与OpenAI格式不兼容需要配置client为"gemini" [[api_providers]] # 特殊Google的Gimini使用特殊API与OpenAI格式不兼容需要配置client为"gemini"
name = "Google" name = "Google"
base_url = "https://api.google.com/v1" base_url = "https://generativelanguage.googleapis.com/v1beta"
api_key = "your-google-api-key-1" api_key = "your-google-api-key-1"
client_type = "gemini" client_type = "gemini"
max_retry = 2 max_retry = 2