Compare commits
9 Commits
bbe16046c8
...
755b2e4675
| Author | SHA1 | Date | |
|---|---|---|---|
|
755b2e4675
|
|||
|
5a42d4d9c1
|
|||
|
|
8c451e42fb | ||
|
|
1c1db7beac | ||
|
|
5e708fd1de | ||
|
|
1730a62363 | ||
|
|
af830b6c03 | ||
|
|
dab7e91fed | ||
|
|
962a50217d |
133
MoFox 重构指导总览.md
Normal file
133
MoFox 重构指导总览.md
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
# MoFox Core 重构架构文档
|
||||||
|
|
||||||
|
MoFox src目录将被严格分为三个层级:
|
||||||
|
|
||||||
|
kernel - 内核/基础能力 层 - 提供“与具体业务无关的技术能力”
|
||||||
|
core - 核心层/领域/心智 层 - 用 kernel 的能力实现记忆、对话、行为等核心功能,不关心插件或具体平台
|
||||||
|
app - 应用/装配/插件 层 - 把 kernel 和 core 组装成可运行的 Bot 系统,对外提供高级 API 和插件扩展点
|
||||||
|
|
||||||
|
## kernel层:
|
||||||
|
包含以下模块:
|
||||||
|
db:底层数据库接口
|
||||||
|
__init__.py:导出
|
||||||
|
core:数据库核心
|
||||||
|
__init__.py:导出
|
||||||
|
dialect_adapter.py:数据库方言适配器
|
||||||
|
engine.py:数据库引擎管理
|
||||||
|
session.py:数据库会话管理
|
||||||
|
exceptions.py:数据库异常定义
|
||||||
|
optimization:数据库优化
|
||||||
|
__init__.py:导出
|
||||||
|
backends:缓存后端实现
|
||||||
|
cache_backend.py:缓存后端抽象基类
|
||||||
|
local_cache.py:本地缓存后端
|
||||||
|
redis_cache.py:Redis缓存后端
|
||||||
|
cache_manager.py:多级缓存管理器
|
||||||
|
api:操作接口
|
||||||
|
crud.py:统一的crud操作
|
||||||
|
query.py:高级查询API
|
||||||
|
vector_db:底层向量存储接口
|
||||||
|
__init__.py:导出+工厂函数,初始化并返回向量数据库服务实例。
|
||||||
|
base.py:向量数据库的抽象基类 (ABC),定义了所有向量数据库实现必须遵循的接口
|
||||||
|
chromadb_impl.py:chromadb的具体实现,遵循 VectorDBBase 接口
|
||||||
|
config:底层配置文件系统
|
||||||
|
__init__.py:导出
|
||||||
|
config_base.py:配置项基类
|
||||||
|
config.py:配置的读取、修改、更新等
|
||||||
|
llm:底层llm网络请求系统
|
||||||
|
__init__.py:导出
|
||||||
|
utils.py:基本工具,如图片压缩,格式转换
|
||||||
|
llm_request.py:与大语言模型(LLM)交互的所有核心逻辑
|
||||||
|
exceptions.py:llm请求异常类
|
||||||
|
client_registry.py:client注册管理
|
||||||
|
model_client:client集合
|
||||||
|
base_client.py:client基类
|
||||||
|
aiohttp_gemini_clinet.py:基于aiohttp实现的gemini client
|
||||||
|
bedrock_client.py:aws client
|
||||||
|
openai_client.py:openai client
|
||||||
|
payload:标准负载构建
|
||||||
|
message.py:标准消息构建
|
||||||
|
resp_format.py:标准响应解析
|
||||||
|
tool_option.py:标准工具负载构建
|
||||||
|
standard_prompt.py:标准prompt(system等)
|
||||||
|
logger:日志系统
|
||||||
|
__init__.py:导出
|
||||||
|
core.py:日志系统主入口
|
||||||
|
cleanup.py:日志清理/压缩相关
|
||||||
|
metadata.py:日志元数据相关
|
||||||
|
renderers.py:日志格式化器
|
||||||
|
config.py:配置相关的辅助操作
|
||||||
|
handlers.py:日志处理器(console handler、file handler等)
|
||||||
|
concurrency:底层异步管理
|
||||||
|
__init__.py:导出
|
||||||
|
task_manager.py:统一异步任务管理器
|
||||||
|
watchdog.py:全局看门狗
|
||||||
|
storage:本地持久化数据管理
|
||||||
|
__init__.py:导出
|
||||||
|
json_store.py:统一的json本地持久化操作器
|
||||||
|
|
||||||
|
## core层:
|
||||||
|
包含以下模块:
|
||||||
|
components:基本插件组件管理
|
||||||
|
__init__.py:导出
|
||||||
|
base:组件基类
|
||||||
|
__init__.py:导出
|
||||||
|
action.py
|
||||||
|
adapter.py
|
||||||
|
chatter.py
|
||||||
|
command.py
|
||||||
|
event_handler.py
|
||||||
|
router.py
|
||||||
|
service.py
|
||||||
|
plugin.py
|
||||||
|
prompt.py
|
||||||
|
tool.py
|
||||||
|
managers:组件应用管理,实际能力调用
|
||||||
|
__init__.py:导出
|
||||||
|
action_manager.py:动作管理器
|
||||||
|
adapter_manager.py:适配器管理
|
||||||
|
chatter_manager.py:聊天器管理
|
||||||
|
event_manager.py:事件管理器
|
||||||
|
service_manager.py:服务管理器
|
||||||
|
mcp_manager:MCP相关管理
|
||||||
|
__init__.py:导出
|
||||||
|
mcp_client_manager.py:MCP客户端管理器
|
||||||
|
mcp_tool_manager.py:MCP工具管理器
|
||||||
|
permission_manager.py:权限管理器
|
||||||
|
plugin_manager.py:插件管理器
|
||||||
|
prompt_component_manager.py:Prompt组件管理器
|
||||||
|
tool_manager:工具相关管理
|
||||||
|
__init__.py:导出
|
||||||
|
tool_histoty.py:工具调用历史记录
|
||||||
|
tool_use.py:实际工具调用器
|
||||||
|
types.py:组件类型
|
||||||
|
registry.py:组件注册管理
|
||||||
|
state_manager.py:组件状态管理
|
||||||
|
prompt:提示词管理系统
|
||||||
|
__init__.py:导出
|
||||||
|
prompt.py:Prompt基类
|
||||||
|
manager.py:全局prompt管理器
|
||||||
|
params.py:Prompt参数系统
|
||||||
|
perception:感知学习系统
|
||||||
|
__init__.py:导出
|
||||||
|
memory:常规记忆
|
||||||
|
...
|
||||||
|
knowledge:知识库
|
||||||
|
...
|
||||||
|
meme:黑话库
|
||||||
|
...
|
||||||
|
express:表达学习
|
||||||
|
...
|
||||||
|
transport:通讯传输系统
|
||||||
|
__init__.py:导出
|
||||||
|
message_receive:消息接收
|
||||||
|
...
|
||||||
|
message_send:消息发送
|
||||||
|
...
|
||||||
|
router:api路由
|
||||||
|
...
|
||||||
|
sink:针对适配器的core sink和ws接收器
|
||||||
|
...
|
||||||
|
models:基本模型
|
||||||
|
__init__.py:导出
|
||||||
|
|
||||||
3
TODO.md
3
TODO.md
@@ -35,6 +35,7 @@
|
|||||||
- [x] 完整集成测试 (5/5通过)
|
- [x] 完整集成测试 (5/5通过)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
- 大工程
|
- 大工程
|
||||||
· 增加一个基于Rust后端,daisyui为(装饰的)前端的启动器,以下是详细功能
|
· 增加一个基于Rust后端,daisyui为(装饰的)前端的启动器,以下是详细功能
|
||||||
- 一个好看的ui
|
- 一个好看的ui
|
||||||
@@ -44,4 +45,4 @@
|
|||||||
- 能够支持自由修改bot、llm的配置
|
- 能够支持自由修改bot、llm的配置
|
||||||
- 兼容Matcha,将Matcha的界面也嵌入到启动器内
|
- 兼容Matcha,将Matcha的界面也嵌入到启动器内
|
||||||
- 数据库预览以及修改功能
|
- 数据库预览以及修改功能
|
||||||
- (待确定)Live 2d chat功能的开发
|
- (待确定)Live 2d chat功能的开发
|
||||||
|
|||||||
38
docs/changelogs/short_term_pressure_patch.md
Normal file
38
docs/changelogs/short_term_pressure_patch.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# 短期记忆压力泄压补丁
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
部分场景下,短期记忆层在自动转移尚未触发时会快速堆积,可能导致短期记忆达到容量上限并阻塞后续写入。
|
||||||
|
|
||||||
|
## 变更(补丁)
|
||||||
|
|
||||||
|
- 新增“压力泄压”开关:可选择在占用率达到 100% 时,删除低重要性且最早的短期记忆,防止短期层持续膨胀。
|
||||||
|
- 默认关闭,需显式开启后才会执行自动删除。
|
||||||
|
|
||||||
|
## 开关配置
|
||||||
|
|
||||||
|
- 入口:`UnifiedMemoryManager` 构造参数
|
||||||
|
- `short_term_enable_force_cleanup: bool = False`
|
||||||
|
- 传递到短期层:`ShortTermMemoryManager(enable_force_cleanup=True)`
|
||||||
|
- 关闭示例:
|
||||||
|
```python
|
||||||
|
manager = UnifiedMemoryManager(
|
||||||
|
short_term_enable_force_cleanup=False,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 行为说明
|
||||||
|
|
||||||
|
- 当短期记忆占用率达到或超过 100%,且当前没有待转移批次时:
|
||||||
|
- 触发 `force_cleanup_overflow()`
|
||||||
|
- 按“低重要性优先、创建时间最早优先”删除一批记忆,将容量压回约 `max_memories * 0.9`
|
||||||
|
- 清理在后台持久化,不阻塞主流程。
|
||||||
|
|
||||||
|
## 影响范围
|
||||||
|
|
||||||
|
- 默认行为保持与补丁前一致(开关默认 `on`)。
|
||||||
|
- 如果关闭开关,短期层将不再做强制删除,只依赖自动转移机制。
|
||||||
|
|
||||||
|
## 回滚
|
||||||
|
|
||||||
|
- 构造时将 `short_term_enable_force_cleanup=False` 即可关闭;无需代码回滚。
|
||||||
22
docs/development/emoji_prompt_limit.md
Normal file
22
docs/development/emoji_prompt_limit.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# 表情替换候选数量说明
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
`MAX_EMOJI_FOR_PROMPT` 用于 `replace_a_emoji` 等场景,限制送入 LLM 的候选表情数量,避免上下文过长导致响应变慢或 token 开销过大。
|
||||||
|
|
||||||
|
## 为什么是 20
|
||||||
|
- 平衡:超过十几项后决策收益递减,但 token/时间成本线性增加。
|
||||||
|
- 性能:在常用模型和硬件下,20 个描述可在可接受延迟内返回决策。
|
||||||
|
- 兼容:历史实现也使用 20,保持行为稳定。
|
||||||
|
|
||||||
|
## 何时调整
|
||||||
|
- 设备/模型更强且希望更广覆盖:可提升到 30-40,但注意延迟和费用。
|
||||||
|
- 低算力或对延迟敏感:可下调到 10-15 以加快决策。
|
||||||
|
- 特殊场景(主题集中、库很小):下调有助于避免无意义的冗余候选。
|
||||||
|
|
||||||
|
## 如何修改
|
||||||
|
- 常量位置:`src/chat/emoji_system/emoji_constants.py` 中的 `MAX_EMOJI_FOR_PROMPT`。
|
||||||
|
- 如需动态配置,可将其迁移到 `global_config.emoji` 下的配置项并在 `emoji_manager` 读取。
|
||||||
|
|
||||||
|
## 建议
|
||||||
|
- 调整后观察:替换决策耗时、模型费用、误删率(删除的表情是否被实际需要)。
|
||||||
|
- 如继续扩展表情库规模,建议为候选列表增加基于使用频次或时间的预筛选策略。
|
||||||
33
docs/development/emoji_system_refactor.md
Normal file
33
docs/development/emoji_system_refactor.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# 表情系统重构说明
|
||||||
|
|
||||||
|
日期:2025-12-15
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
- 拆分单体的 `emoji_manager.py`,将实体、常量、文件工具解耦。
|
||||||
|
- 减少扫描/注册期间的事件循环阻塞。
|
||||||
|
- 保留现有行为(LLM/VLM 流程、容量替换、缓存查找),同时提升可维护性。
|
||||||
|
|
||||||
|
## 新结构
|
||||||
|
- `src/chat/emoji_system/emoji_constants.py`:共享路径与提示/数量上限。
|
||||||
|
- `src/chat/emoji_system/emoji_entities.py`:`MaiEmoji`(哈希、格式检测、入库/删除、缓存失效)。
|
||||||
|
- `src/chat/emoji_system/emoji_utils.py`:目录保证、临时清理、增量文件扫描、DB 行到实体转换。
|
||||||
|
- `src/chat/emoji_system/emoji_manager.py`:负责完整性检查、扫描、注册、VLM/LLM 描述、替换与缓存,现委托给上述模块。
|
||||||
|
- `src/chat/emoji_system/README.md`:快速使用/生命周期指引。
|
||||||
|
|
||||||
|
## 行为变化
|
||||||
|
- 完整性检查改为游标+批量增量扫描,每处理 50 个让出一次事件循环。
|
||||||
|
- 循环内的重文件操作(exists、listdir、remove、makedirs)通过 `asyncio.to_thread` 释放主循环。
|
||||||
|
- 目录扫描使用 `os.scandir`(经 `list_image_files`),减少重复 stat,并返回文件列表与是否为空。
|
||||||
|
- 快速查找:加载时重建 `_emoji_index`,增删时保持同步;`get_emoji_from_manager` 优先走索引。
|
||||||
|
- 注册与替换流程在更新索引的同时,异步清理失败/重复文件。
|
||||||
|
|
||||||
|
## 迁移提示
|
||||||
|
- 现有调用继续使用 `get_emoji_manager()` 与 `EmojiManager` API,外部接口未改动。
|
||||||
|
- 如曾直接从 `emoji_manager` 引入常量或工具,请改为从 `emoji_constants`、`emoji_entities`、`emoji_utils` 引入。
|
||||||
|
- 依赖同步文件时序的测试/脚本可能观察到不同的耗时,但逻辑等价。
|
||||||
|
|
||||||
|
## 后续建议
|
||||||
|
1. 为 `list_image_files`、`clean_unused_emojis`、完整性扫描游标行为补充单测。
|
||||||
|
2. 将 VLM/LLM 提示词模板外置为配置,便于迭代。
|
||||||
|
3. 暴露扫描耗时、清理数量、注册延迟等指标,便于观测。
|
||||||
|
4. 为 `replace_a_emoji` 的 LLM 调用添加重试上限,并记录 prompt/决策日志以便审计。
|
||||||
37
src/chat/emoji_system/README.md
Normal file
37
src/chat/emoji_system/README.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# 新表情系统概览
|
||||||
|
|
||||||
|
本目录存放表情包的采集、注册与选择逻辑。
|
||||||
|
|
||||||
|
## 模块
|
||||||
|
- `emoji_constants.py`:共享路径与数量上限。
|
||||||
|
- `emoji_entities.py`:`MaiEmoji` 实体,负责哈希/格式检测、数据库注册与删除。
|
||||||
|
- `emoji_utils.py`:文件系统工具(目录保证、临时清理、DB 行转换、文件列表扫描)。
|
||||||
|
- `emoji_manager.py`:核心管理器,定期扫描、完整性检查、VLM/LLM 标注、容量替换、缓存查找。
|
||||||
|
- `emoji_history.py`:按会话保存的内存历史。
|
||||||
|
|
||||||
|
## 生命周期
|
||||||
|
1. 通过 `EmojiManager.start()` 启动后台任务(或在已有事件循环中直接 await `start_periodic_check_register()`)。
|
||||||
|
2. 循环会加载数据库状态、做完整性清理、清理临时缓存,并扫描 `data/emoji` 中的新文件。
|
||||||
|
3. 新图片会生成哈希,调用 VLM/LLM 生成描述后注册入库,并移动到 `data/emoji_registed`。
|
||||||
|
4. 达到容量上限时,`replace_a_emoji()` 可能在 LLM 协助下删除低使用量表情再注册新表情。
|
||||||
|
|
||||||
|
## 关键行为
|
||||||
|
- 完整性检查增量扫描,批量让出事件循环避免长阻塞。
|
||||||
|
- 循环内的文件操作使用 `asyncio.to_thread` 以保持事件循环可响应。
|
||||||
|
- 哈希索引 `_emoji_index` 加速内存查找;数据库为事实来源,内存为镜像。
|
||||||
|
- 描述与标签使用缓存(见管理器上的 `@cached`)。
|
||||||
|
|
||||||
|
## 常用操作
|
||||||
|
- `get_emoji_for_text(text_emotion)`:按目标情绪选取表情路径与描述。
|
||||||
|
- `record_usage(emoji_hash)`:累加使用次数。
|
||||||
|
- `delete_emoji(emoji_hash)`:删除文件与数据库记录并清缓存。
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
- 待注册:`data/emoji`
|
||||||
|
- 已注册:`data/emoji_registed`
|
||||||
|
- 临时图片:`data/image`, `data/images`
|
||||||
|
|
||||||
|
## 说明
|
||||||
|
- 通过 `config/bot_config.toml`、`config/model_config.toml` 配置上限与模型。
|
||||||
|
- GIF 支持保留,注册前会提取关键帧再送 VLM。
|
||||||
|
- 避免直接使用 `Session`,请使用本模块提供的 API。
|
||||||
6
src/chat/emoji_system/emoji_constants.py
Normal file
6
src/chat/emoji_system/emoji_constants.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
BASE_DIR = os.path.join("data")
|
||||||
|
EMOJI_DIR = os.path.join(BASE_DIR, "emoji")
|
||||||
|
EMOJI_REGISTERED_DIR = os.path.join(BASE_DIR, "emoji_registed")
|
||||||
|
MAX_EMOJI_FOR_PROMPT = 20
|
||||||
192
src/chat/emoji_system/emoji_entities.py
Normal file
192
src/chat/emoji_system/emoji_entities.py
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from src.chat.emoji_system.emoji_constants import EMOJI_REGISTERED_DIR
|
||||||
|
from src.chat.utils.utils_image import image_path_to_base64
|
||||||
|
from src.common.database.api.crud import CRUDBase
|
||||||
|
from src.common.database.compatibility import get_db_session
|
||||||
|
from src.common.database.core.models import Emoji
|
||||||
|
from src.common.database.optimization.cache_manager import get_cache
|
||||||
|
from src.common.database.utils.decorators import generate_cache_key
|
||||||
|
from src.common.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger("emoji")
|
||||||
|
|
||||||
|
|
||||||
|
class MaiEmoji:
|
||||||
|
"""定义一个表情包"""
|
||||||
|
|
||||||
|
def __init__(self, full_path: str):
|
||||||
|
if not full_path:
|
||||||
|
raise ValueError("full_path cannot be empty")
|
||||||
|
self.full_path = full_path
|
||||||
|
self.path = os.path.dirname(full_path)
|
||||||
|
self.filename = os.path.basename(full_path)
|
||||||
|
self.embedding = []
|
||||||
|
self.hash = ""
|
||||||
|
self.description = ""
|
||||||
|
self.emotion: list[str] = []
|
||||||
|
self.usage_count = 0
|
||||||
|
self.last_used_time = time.time()
|
||||||
|
self.register_time = time.time()
|
||||||
|
self.is_deleted = False
|
||||||
|
self.format = ""
|
||||||
|
|
||||||
|
async def initialize_hash_format(self) -> bool | None:
|
||||||
|
"""从文件创建表情包实例, 计算哈希值和格式"""
|
||||||
|
try:
|
||||||
|
if not os.path.exists(self.full_path):
|
||||||
|
logger.error(f"[初始化错误] 表情包文件不存在: {self.full_path}")
|
||||||
|
self.is_deleted = True
|
||||||
|
return None
|
||||||
|
|
||||||
|
logger.debug(f"[初始化] 正在读取文件: {self.full_path}")
|
||||||
|
image_base64 = image_path_to_base64(self.full_path)
|
||||||
|
if image_base64 is None:
|
||||||
|
logger.error(f"[初始化错误] 无法读取或转换Base64: {self.full_path}")
|
||||||
|
self.is_deleted = True
|
||||||
|
return None
|
||||||
|
logger.debug(f"[初始化] 文件读取成功 (Base64预览: {image_base64[:50]}...)")
|
||||||
|
|
||||||
|
logger.debug(f"[初始化] 正在解码Base64并计算哈希: {self.filename}")
|
||||||
|
if isinstance(image_base64, str):
|
||||||
|
image_base64 = image_base64.encode("ascii", errors="ignore").decode("ascii")
|
||||||
|
image_bytes = base64.b64decode(image_base64)
|
||||||
|
self.hash = hashlib.md5(image_bytes).hexdigest()
|
||||||
|
logger.debug(f"[初始化] 哈希计算成功: {self.hash}")
|
||||||
|
|
||||||
|
logger.debug(f"[初始化] 正在使用Pillow获取格式: {self.filename}")
|
||||||
|
try:
|
||||||
|
with Image.open(io.BytesIO(image_bytes)) as img:
|
||||||
|
self.format = (img.format or "jpeg").lower()
|
||||||
|
logger.debug(f"[初始化] 格式获取成功: {self.format}")
|
||||||
|
except Exception as pil_error:
|
||||||
|
logger.error(f"[初始化错误] Pillow无法处理图片 ({self.filename}): {pil_error}")
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
self.is_deleted = True
|
||||||
|
return None
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except FileNotFoundError:
|
||||||
|
logger.error(f"[初始化错误] 文件在处理过程中丢失: {self.full_path}")
|
||||||
|
self.is_deleted = True
|
||||||
|
return None
|
||||||
|
except (binascii.Error, ValueError) as b64_error:
|
||||||
|
logger.error(f"[初始化错误] Base64解码失败 ({self.filename}): {b64_error}")
|
||||||
|
self.is_deleted = True
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[初始化错误] 初始化表情包时发生未预期错误 ({self.filename}): {e!s}")
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
self.is_deleted = True
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def register_to_db(self) -> bool:
|
||||||
|
"""注册表情包,将文件移动到注册目录并保存数据库"""
|
||||||
|
try:
|
||||||
|
source_full_path = self.full_path
|
||||||
|
destination_full_path = os.path.join(EMOJI_REGISTERED_DIR, self.filename)
|
||||||
|
|
||||||
|
if not await asyncio.to_thread(os.path.exists, source_full_path):
|
||||||
|
logger.error(f"[错误] 源文件不存在: {source_full_path}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
if await asyncio.to_thread(os.path.exists, destination_full_path):
|
||||||
|
await asyncio.to_thread(os.remove, destination_full_path)
|
||||||
|
|
||||||
|
await asyncio.to_thread(os.rename, source_full_path, destination_full_path)
|
||||||
|
logger.debug(f"[移动] 文件从 {source_full_path} 移动到 {destination_full_path}")
|
||||||
|
self.full_path = destination_full_path
|
||||||
|
self.path = EMOJI_REGISTERED_DIR
|
||||||
|
except Exception as move_error:
|
||||||
|
logger.error(f"[错误] 移动文件失败: {move_error!s}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with get_db_session() as session:
|
||||||
|
emotion_str = ",".join(self.emotion) if self.emotion else ""
|
||||||
|
|
||||||
|
emoji = Emoji(
|
||||||
|
emoji_hash=self.hash,
|
||||||
|
full_path=self.full_path,
|
||||||
|
format=self.format,
|
||||||
|
description=self.description,
|
||||||
|
emotion=emotion_str,
|
||||||
|
query_count=0,
|
||||||
|
is_registered=True,
|
||||||
|
is_banned=False,
|
||||||
|
record_time=self.register_time,
|
||||||
|
register_time=self.register_time,
|
||||||
|
usage_count=self.usage_count,
|
||||||
|
last_used_time=self.last_used_time,
|
||||||
|
)
|
||||||
|
session.add(emoji)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
logger.info(f"[注册] 表情包信息保存到数据库: {self.filename} ({self.emotion})")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as db_error:
|
||||||
|
logger.error(f"[错误] 保存数据库失败 ({self.filename}): {db_error!s}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[错误] 注册表情包失败 ({self.filename}): {e!s}")
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def delete(self) -> bool:
|
||||||
|
"""删除表情包文件及数据库记录"""
|
||||||
|
try:
|
||||||
|
file_to_delete = self.full_path
|
||||||
|
if await asyncio.to_thread(os.path.exists, file_to_delete):
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(os.remove, file_to_delete)
|
||||||
|
logger.debug(f"[删除] 文件: {file_to_delete}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[错误] 删除文件失败 {file_to_delete}: {e!s}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
crud = CRUDBase(Emoji)
|
||||||
|
will_delete_emoji = await crud.get_by(emoji_hash=self.hash)
|
||||||
|
if will_delete_emoji is None:
|
||||||
|
logger.warning(f"[删除] 数据库中未找到哈希值为 {self.hash} 的表情包记录。")
|
||||||
|
result = 0
|
||||||
|
else:
|
||||||
|
await crud.delete(will_delete_emoji.id)
|
||||||
|
result = 1
|
||||||
|
|
||||||
|
cache = await get_cache()
|
||||||
|
await cache.delete(generate_cache_key("emoji_by_hash", self.hash))
|
||||||
|
await cache.delete(generate_cache_key("emoji_description", self.hash))
|
||||||
|
await cache.delete(generate_cache_key("emoji_tag", self.hash))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[错误] 删除数据库记录时出错: {e!s}")
|
||||||
|
result = 0
|
||||||
|
|
||||||
|
if result > 0:
|
||||||
|
logger.info(f"[删除] 表情包数据库记录 {self.filename} (Hash: {self.hash})")
|
||||||
|
self.is_deleted = True
|
||||||
|
return True
|
||||||
|
if not os.path.exists(file_to_delete):
|
||||||
|
logger.warning(
|
||||||
|
f"[警告] 表情包文件 {file_to_delete} 已删除,但数据库记录删除失败 (Hash: {self.hash})"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.error(f"[错误] 删除表情包数据库记录失败: {self.hash}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[错误] 删除表情包失败 ({self.filename}): {e!s}")
|
||||||
|
return False
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
import binascii
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
@@ -16,6 +15,16 @@ from PIL import Image
|
|||||||
from rich.traceback import install
|
from rich.traceback import install
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from src.chat.emoji_system.emoji_constants import EMOJI_DIR, EMOJI_REGISTERED_DIR, MAX_EMOJI_FOR_PROMPT
|
||||||
|
from src.chat.emoji_system.emoji_entities import MaiEmoji
|
||||||
|
from src.chat.emoji_system.emoji_utils import (
|
||||||
|
_emoji_objects_to_readable_list,
|
||||||
|
_to_emoji_objects,
|
||||||
|
_ensure_emoji_dir,
|
||||||
|
clear_temp_emoji,
|
||||||
|
clean_unused_emojis,
|
||||||
|
list_image_files,
|
||||||
|
)
|
||||||
from src.chat.utils.utils_image import get_image_manager, image_path_to_base64
|
from src.chat.utils.utils_image import get_image_manager, image_path_to_base64
|
||||||
from src.common.database.api.crud import CRUDBase
|
from src.common.database.api.crud import CRUDBase
|
||||||
from src.common.database.compatibility import get_db_session
|
from src.common.database.compatibility import get_db_session
|
||||||
@@ -25,367 +34,8 @@ from src.common.logger import get_logger
|
|||||||
from src.config.config import global_config, model_config
|
from src.config.config import global_config, model_config
|
||||||
from src.llm_models.utils_model import LLMRequest
|
from src.llm_models.utils_model import LLMRequest
|
||||||
|
|
||||||
install(extra_lines=3)
|
|
||||||
|
|
||||||
logger = get_logger("emoji")
|
logger = get_logger("emoji")
|
||||||
|
|
||||||
BASE_DIR = os.path.join("data")
|
|
||||||
EMOJI_DIR = os.path.join(BASE_DIR, "emoji") # 表情包存储目录
|
|
||||||
EMOJI_REGISTERED_DIR = os.path.join(BASE_DIR, "emoji_registed") # 已注册的表情包注册目录
|
|
||||||
MAX_EMOJI_FOR_PROMPT = 20 # 最大允许的表情包描述数量于图片替换的 prompt 中
|
|
||||||
|
|
||||||
"""
|
|
||||||
还没经过测试,有些地方数据库和内存数据同步可能不完全
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class MaiEmoji:
|
|
||||||
"""定义一个表情包"""
|
|
||||||
|
|
||||||
def __init__(self, full_path: str):
|
|
||||||
if not full_path:
|
|
||||||
raise ValueError("full_path cannot be empty")
|
|
||||||
self.full_path = full_path # 文件的完整路径 (包括文件名)
|
|
||||||
self.path = os.path.dirname(full_path) # 文件所在的目录路径
|
|
||||||
self.filename = os.path.basename(full_path) # 文件名
|
|
||||||
self.embedding = []
|
|
||||||
self.hash = "" # 初始为空,在创建实例时会计算
|
|
||||||
self.description = ""
|
|
||||||
self.emotion: list[str] = []
|
|
||||||
self.usage_count = 0
|
|
||||||
self.last_used_time = time.time()
|
|
||||||
self.register_time = time.time()
|
|
||||||
self.is_deleted = False # 标记是否已被删除
|
|
||||||
self.format = ""
|
|
||||||
|
|
||||||
async def initialize_hash_format(self) -> bool | None:
|
|
||||||
"""从文件创建表情包实例, 计算哈希值和格式"""
|
|
||||||
try:
|
|
||||||
# 使用 full_path 检查文件是否存在
|
|
||||||
if not os.path.exists(self.full_path):
|
|
||||||
logger.error(f"[初始化错误] 表情包文件不存在: {self.full_path}")
|
|
||||||
self.is_deleted = True
|
|
||||||
return None
|
|
||||||
|
|
||||||
# 使用 full_path 读取文件
|
|
||||||
logger.debug(f"[初始化] 正在读取文件: {self.full_path}")
|
|
||||||
image_base64 = image_path_to_base64(self.full_path)
|
|
||||||
if image_base64 is None:
|
|
||||||
logger.error(f"[初始化错误] 无法读取或转换Base64: {self.full_path}")
|
|
||||||
self.is_deleted = True
|
|
||||||
return None
|
|
||||||
logger.debug(f"[初始化] 文件读取成功 (Base64预览: {image_base64[:50]}...)")
|
|
||||||
|
|
||||||
# 计算哈希值
|
|
||||||
logger.debug(f"[初始化] 正在解码Base64并计算哈希: {self.filename}")
|
|
||||||
# 确保base64字符串只包含ASCII字符
|
|
||||||
if isinstance(image_base64, str):
|
|
||||||
image_base64 = image_base64.encode("ascii", errors="ignore").decode("ascii")
|
|
||||||
image_bytes = base64.b64decode(image_base64)
|
|
||||||
self.hash = hashlib.md5(image_bytes).hexdigest()
|
|
||||||
logger.debug(f"[初始化] 哈希计算成功: {self.hash}")
|
|
||||||
|
|
||||||
# 获取图片格式
|
|
||||||
logger.debug(f"[初始化] 正在使用Pillow获取格式: {self.filename}")
|
|
||||||
try:
|
|
||||||
with Image.open(io.BytesIO(image_bytes)) as img:
|
|
||||||
self.format = (img.format or "jpeg").lower()
|
|
||||||
logger.debug(f"[初始化] 格式获取成功: {self.format}")
|
|
||||||
except Exception as pil_error:
|
|
||||||
logger.error(f"[初始化错误] Pillow无法处理图片 ({self.filename}): {pil_error}")
|
|
||||||
logger.error(traceback.format_exc())
|
|
||||||
self.is_deleted = True
|
|
||||||
return None
|
|
||||||
|
|
||||||
# 如果所有步骤成功,返回 True
|
|
||||||
return True
|
|
||||||
|
|
||||||
except FileNotFoundError:
|
|
||||||
logger.error(f"[初始化错误] 文件在处理过程中丢失: {self.full_path}")
|
|
||||||
self.is_deleted = True
|
|
||||||
return None
|
|
||||||
except (binascii.Error, ValueError) as b64_error:
|
|
||||||
logger.error(f"[初始化错误] Base64解码失败 ({self.filename}): {b64_error}")
|
|
||||||
self.is_deleted = True
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[初始化错误] 初始化表情包时发生未预期错误 ({self.filename}): {e!s}")
|
|
||||||
logger.error(traceback.format_exc())
|
|
||||||
self.is_deleted = True
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def register_to_db(self) -> bool:
|
|
||||||
"""
|
|
||||||
注册表情包
|
|
||||||
将表情包对应的文件,从当前路径移动到EMOJI_REGISTERED_DIR目录下
|
|
||||||
并修改对应的实例属性,然后将表情包信息保存到数据库中
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# 确保目标目录存在
|
|
||||||
|
|
||||||
# 源路径是当前实例的完整路径 self.full_path
|
|
||||||
source_full_path = self.full_path
|
|
||||||
# 目标完整路径
|
|
||||||
destination_full_path = os.path.join(EMOJI_REGISTERED_DIR, self.filename)
|
|
||||||
|
|
||||||
# 检查源文件是否存在
|
|
||||||
if not os.path.exists(source_full_path):
|
|
||||||
logger.error(f"[错误] 源文件不存在: {source_full_path}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# --- 文件移动 ---
|
|
||||||
try:
|
|
||||||
# 如果目标文件已存在,先删除 (确保移动成功)
|
|
||||||
if os.path.exists(destination_full_path):
|
|
||||||
os.remove(destination_full_path)
|
|
||||||
|
|
||||||
os.rename(source_full_path, destination_full_path)
|
|
||||||
logger.debug(f"[移动] 文件从 {source_full_path} 移动到 {destination_full_path}")
|
|
||||||
# 更新实例的路径属性为新路径
|
|
||||||
self.full_path = destination_full_path
|
|
||||||
self.path = EMOJI_REGISTERED_DIR
|
|
||||||
# self.filename 保持不变
|
|
||||||
except Exception as move_error:
|
|
||||||
logger.error(f"[错误] 移动文件失败: {move_error!s}")
|
|
||||||
# 如果移动失败,尝试将实例状态恢复?暂时不处理,仅返回失败
|
|
||||||
return False
|
|
||||||
|
|
||||||
# --- 数据库操作 ---
|
|
||||||
try:
|
|
||||||
# 准备数据库记录 for emoji collection
|
|
||||||
async with get_db_session() as session:
|
|
||||||
emotion_str = ",".join(self.emotion) if self.emotion else ""
|
|
||||||
|
|
||||||
emoji = Emoji(
|
|
||||||
emoji_hash=self.hash,
|
|
||||||
full_path=self.full_path,
|
|
||||||
format=self.format,
|
|
||||||
description=self.description,
|
|
||||||
emotion=emotion_str, # Store as comma-separated string
|
|
||||||
query_count=0, # Default value
|
|
||||||
is_registered=True,
|
|
||||||
is_banned=False, # Default value
|
|
||||||
record_time=self.register_time, # Use MaiEmoji's register_time for DB record_time
|
|
||||||
register_time=self.register_time,
|
|
||||||
usage_count=self.usage_count,
|
|
||||||
last_used_time=self.last_used_time,
|
|
||||||
)
|
|
||||||
session.add(emoji)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
logger.info(f"[注册] 表情包信息保存到数据库: {self.filename} ({self.emotion})")
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as db_error:
|
|
||||||
logger.error(f"[错误] 保存数据库失败 ({self.filename}): {db_error!s}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[错误] 注册表情包失败 ({self.filename}): {e!s}")
|
|
||||||
logger.error(traceback.format_exc())
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def delete(self) -> bool:
|
|
||||||
"""删除表情包
|
|
||||||
|
|
||||||
删除表情包的文件和数据库记录
|
|
||||||
|
|
||||||
返回:
|
|
||||||
bool: 是否成功删除
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# 1. 删除文件
|
|
||||||
file_to_delete = self.full_path
|
|
||||||
if os.path.exists(file_to_delete):
|
|
||||||
try:
|
|
||||||
os.remove(file_to_delete)
|
|
||||||
logger.debug(f"[删除] 文件: {file_to_delete}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[错误] 删除文件失败 {file_to_delete}: {e!s}")
|
|
||||||
# 文件删除失败,但仍然尝试删除数据库记录
|
|
||||||
|
|
||||||
# 2. 删除数据库记录
|
|
||||||
try:
|
|
||||||
# 使用CRUD进行删除
|
|
||||||
crud = CRUDBase(Emoji)
|
|
||||||
will_delete_emoji = await crud.get_by(emoji_hash=self.hash)
|
|
||||||
if will_delete_emoji is None:
|
|
||||||
logger.warning(f"[删除] 数据库中未找到哈希值为 {self.hash} 的表情包记录。")
|
|
||||||
result = 0 # Indicate no DB record was deleted
|
|
||||||
else:
|
|
||||||
await crud.delete(will_delete_emoji.id)
|
|
||||||
result = 1 # Successfully deleted one record
|
|
||||||
|
|
||||||
# 使缓存失效
|
|
||||||
from src.common.database.optimization.cache_manager import get_cache
|
|
||||||
from src.common.database.utils.decorators import generate_cache_key
|
|
||||||
cache = await get_cache()
|
|
||||||
await cache.delete(generate_cache_key("emoji_by_hash", self.hash))
|
|
||||||
await cache.delete(generate_cache_key("emoji_description", self.hash))
|
|
||||||
await cache.delete(generate_cache_key("emoji_tag", self.hash))
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[错误] 删除数据库记录时出错: {e!s}")
|
|
||||||
result = 0
|
|
||||||
|
|
||||||
if result > 0:
|
|
||||||
logger.info(f"[删除] 表情包数据库记录 {self.filename} (Hash: {self.hash})")
|
|
||||||
# 3. 标记对象已被删除
|
|
||||||
self.is_deleted = True
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
# 如果数据库记录删除失败,但文件可能已删除,记录一个警告
|
|
||||||
if not os.path.exists(file_to_delete):
|
|
||||||
logger.warning(
|
|
||||||
f"[警告] 表情包文件 {file_to_delete} 已删除,但数据库记录删除失败 (Hash: {self.hash})"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.error(f"[错误] 删除表情包数据库记录失败: {self.hash}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[错误] 删除表情包失败 ({self.filename}): {e!s}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _emoji_objects_to_readable_list(emoji_objects: list["MaiEmoji"]) -> list[str]:
|
|
||||||
"""将表情包对象列表转换为可读的字符串列表
|
|
||||||
|
|
||||||
参数:
|
|
||||||
emoji_objects: MaiEmoji对象列表
|
|
||||||
|
|
||||||
返回:
|
|
||||||
list[str]: 可读的表情包信息字符串列表
|
|
||||||
"""
|
|
||||||
emoji_info_list = []
|
|
||||||
for i, emoji in enumerate(emoji_objects):
|
|
||||||
# 转换时间戳为可读时间
|
|
||||||
time_str = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(emoji.register_time))
|
|
||||||
# 构建每个表情包的信息字符串
|
|
||||||
emoji_info = f"编号: {i + 1}\n描述: {emoji.description}\n使用次数: {emoji.usage_count}\n添加时间: {time_str}\n"
|
|
||||||
emoji_info_list.append(emoji_info)
|
|
||||||
return emoji_info_list
|
|
||||||
|
|
||||||
|
|
||||||
def _to_emoji_objects(data: Any) -> tuple[list["MaiEmoji"], int]:
|
|
||||||
emoji_objects = []
|
|
||||||
load_errors = 0
|
|
||||||
emoji_data_list = list(data)
|
|
||||||
|
|
||||||
for emoji_data in emoji_data_list: # emoji_data is an Emoji model instance
|
|
||||||
full_path = emoji_data.full_path
|
|
||||||
if not full_path:
|
|
||||||
logger.warning(
|
|
||||||
f"[加载错误] 数据库记录缺少 'full_path' 字段: ID {emoji_data.id if hasattr(emoji_data, 'id') else 'Unknown'}"
|
|
||||||
)
|
|
||||||
load_errors += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
emoji = MaiEmoji(full_path=full_path)
|
|
||||||
|
|
||||||
emoji.hash = emoji_data.emoji_hash
|
|
||||||
if not emoji.hash:
|
|
||||||
logger.warning(f"[加载错误] 数据库记录缺少 'hash' 字段: {full_path}")
|
|
||||||
load_errors += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
emoji.description = emoji_data.description
|
|
||||||
# Deserialize emotion string from DB to list
|
|
||||||
emoji.emotion = emoji_data.emotion.split(",") if emoji_data.emotion else []
|
|
||||||
emoji.usage_count = emoji_data.usage_count
|
|
||||||
|
|
||||||
db_last_used_time = emoji_data.last_used_time
|
|
||||||
db_register_time = emoji_data.register_time
|
|
||||||
|
|
||||||
# If last_used_time from DB is None, use MaiEmoji's initialized register_time or current time
|
|
||||||
emoji.last_used_time = db_last_used_time if db_last_used_time is not None else emoji.register_time
|
|
||||||
# If register_time from DB is None, use MaiEmoji's initialized register_time (which is time.time())
|
|
||||||
emoji.register_time = db_register_time if db_register_time is not None else emoji.register_time
|
|
||||||
|
|
||||||
emoji.format = emoji_data.format
|
|
||||||
|
|
||||||
emoji_objects.append(emoji)
|
|
||||||
|
|
||||||
except ValueError as ve:
|
|
||||||
logger.error(f"[加载错误] 初始化 MaiEmoji 失败 ({full_path}): {ve}")
|
|
||||||
load_errors += 1
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[加载错误] 处理数据库记录时出错 ({full_path}): {e!s}")
|
|
||||||
load_errors += 1
|
|
||||||
return emoji_objects, load_errors
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_emoji_dir() -> None:
|
|
||||||
"""确保表情存储目录存在"""
|
|
||||||
os.makedirs(EMOJI_DIR, exist_ok=True)
|
|
||||||
os.makedirs(EMOJI_REGISTERED_DIR, exist_ok=True)
|
|
||||||
|
|
||||||
|
|
||||||
async def clear_temp_emoji() -> None:
|
|
||||||
"""清理临时表情包
|
|
||||||
清理/data/emoji、/data/image和/data/images目录下的所有文件
|
|
||||||
当目录中文件数超过100时,会全部删除
|
|
||||||
"""
|
|
||||||
|
|
||||||
logger.info("[清理] 开始清理缓存...")
|
|
||||||
|
|
||||||
for need_clear in (
|
|
||||||
os.path.join(BASE_DIR, "emoji"),
|
|
||||||
os.path.join(BASE_DIR, "image"),
|
|
||||||
os.path.join(BASE_DIR, "images"),
|
|
||||||
):
|
|
||||||
if os.path.exists(need_clear):
|
|
||||||
files = os.listdir(need_clear)
|
|
||||||
# 如果文件数超过1000就全部删除
|
|
||||||
if len(files) > 1000:
|
|
||||||
for filename in files:
|
|
||||||
file_path = os.path.join(need_clear, filename)
|
|
||||||
if os.path.isfile(file_path):
|
|
||||||
os.remove(file_path)
|
|
||||||
logger.debug(f"[清理] 删除: {filename}")
|
|
||||||
|
|
||||||
|
|
||||||
async def clean_unused_emojis(emoji_dir: str, emoji_objects: list["MaiEmoji"], removed_count: int) -> int:
|
|
||||||
"""清理指定目录中未被 emoji_objects 追踪的表情包文件"""
|
|
||||||
if not os.path.exists(emoji_dir):
|
|
||||||
logger.warning(f"[清理] 目标目录不存在,跳过清理: {emoji_dir}")
|
|
||||||
return removed_count
|
|
||||||
|
|
||||||
cleaned_count = 0
|
|
||||||
try:
|
|
||||||
# 获取内存中所有有效表情包的完整路径集合
|
|
||||||
tracked_full_paths = {emoji.full_path for emoji in emoji_objects if not emoji.is_deleted}
|
|
||||||
|
|
||||||
# 遍历指定目录中的所有文件
|
|
||||||
for file_name in os.listdir(emoji_dir):
|
|
||||||
file_full_path = os.path.join(emoji_dir, file_name)
|
|
||||||
|
|
||||||
# 确保处理的是文件而不是子目录
|
|
||||||
if not os.path.isfile(file_full_path):
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 如果文件不在被追踪的集合中,则删除
|
|
||||||
if file_full_path not in tracked_full_paths:
|
|
||||||
try:
|
|
||||||
os.remove(file_full_path)
|
|
||||||
logger.info(f"[清理] 删除未追踪的表情包文件: {file_full_path}")
|
|
||||||
cleaned_count += 1
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[错误] 删除文件时出错 ({file_full_path}): {e!s}")
|
|
||||||
|
|
||||||
if cleaned_count > 0:
|
|
||||||
logger.info(f"[清理] 在目录 {emoji_dir} 中清理了 {cleaned_count} 个破损表情包。")
|
|
||||||
else:
|
|
||||||
logger.info(f"[清理] 目录 {emoji_dir} 中没有需要清理的。")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[错误] 清理未使用表情包文件时出错 ({emoji_dir}): {e!s}")
|
|
||||||
|
|
||||||
return removed_count + cleaned_count
|
|
||||||
|
|
||||||
|
|
||||||
class EmojiManager:
|
class EmojiManager:
|
||||||
_instance = None
|
_instance = None
|
||||||
_initialized: bool = False # 显式声明,避免属性未定义错误
|
_initialized: bool = False # 显式声明,避免属性未定义错误
|
||||||
@@ -401,6 +51,10 @@ class EmojiManager:
|
|||||||
return # 如果已经初始化过,直接返回
|
return # 如果已经初始化过,直接返回
|
||||||
|
|
||||||
self._scan_task = None
|
self._scan_task = None
|
||||||
|
self._emoji_index: dict[str, MaiEmoji] = {}
|
||||||
|
self._integrity_yield_every = 50
|
||||||
|
self._integrity_cursor = 0
|
||||||
|
self._integrity_batch_size = 500
|
||||||
|
|
||||||
if model_config is None:
|
if model_config is None:
|
||||||
raise RuntimeError("Model config is not initialized")
|
raise RuntimeError("Model config is not initialized")
|
||||||
@@ -568,34 +222,40 @@ class EmojiManager:
|
|||||||
如果文件已被删除,则执行对象的删除方法并从列表中移除
|
如果文件已被删除,则执行对象的删除方法并从列表中移除
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# if not self.emoji_objects:
|
|
||||||
# logger.warning("[检查] emoji_objects为空,跳过完整性检查")
|
|
||||||
# return
|
|
||||||
|
|
||||||
total_count = len(self.emoji_objects)
|
total_count = len(self.emoji_objects)
|
||||||
self.emoji_num = total_count
|
self.emoji_num = total_count
|
||||||
removed_count = 0
|
removed_count = 0
|
||||||
# 使用列表复制进行遍历,因为我们会在遍历过程中修改列表
|
if total_count == 0:
|
||||||
objects_to_remove = []
|
return
|
||||||
for emoji in self.emoji_objects:
|
|
||||||
|
start = self._integrity_cursor % total_count
|
||||||
|
end = min(start + self._integrity_batch_size, total_count)
|
||||||
|
indices: list[int] = list(range(start, end))
|
||||||
|
if end - start < self._integrity_batch_size and total_count > 0:
|
||||||
|
wrap_rest = self._integrity_batch_size - (end - start)
|
||||||
|
if wrap_rest > 0:
|
||||||
|
indices.extend(range(0, min(wrap_rest, total_count)))
|
||||||
|
|
||||||
|
objects_to_remove: list[MaiEmoji] = []
|
||||||
|
processed = 0
|
||||||
|
for idx in indices:
|
||||||
|
if idx >= len(self.emoji_objects):
|
||||||
|
break
|
||||||
|
emoji = self.emoji_objects[idx]
|
||||||
try:
|
try:
|
||||||
# 跳过已经标记为删除的,避免重复处理
|
|
||||||
if emoji.is_deleted:
|
if emoji.is_deleted:
|
||||||
objects_to_remove.append(emoji) # 收集起来一次性移除
|
objects_to_remove.append(emoji)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 检查文件是否存在
|
exists = await asyncio.to_thread(os.path.exists, emoji.full_path)
|
||||||
if not os.path.exists(emoji.full_path):
|
if not exists:
|
||||||
logger.warning(f"[检查] 表情包文件丢失: {emoji.full_path}")
|
logger.warning(f"[检查] 表情包文件丢失: {emoji.full_path}")
|
||||||
# 执行表情包对象的删除方法
|
await emoji.delete()
|
||||||
await emoji.delete() # delete 方法现在会标记 is_deleted
|
objects_to_remove.append(emoji)
|
||||||
objects_to_remove.append(emoji) # 标记删除后,也收集起来移除
|
|
||||||
# 更新计数
|
|
||||||
self.emoji_num -= 1
|
self.emoji_num -= 1
|
||||||
removed_count += 1
|
removed_count += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 检查描述是否为空 (如果为空也视为无效)
|
|
||||||
if not emoji.description:
|
if not emoji.description:
|
||||||
logger.warning(f"[检查] 表情包描述为空,视为无效: {emoji.filename}")
|
logger.warning(f"[检查] 表情包描述为空,视为无效: {emoji.filename}")
|
||||||
await emoji.delete()
|
await emoji.delete()
|
||||||
@@ -604,19 +264,24 @@ class EmojiManager:
|
|||||||
removed_count += 1
|
removed_count += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
processed += 1
|
||||||
|
if processed % self._integrity_yield_every == 0:
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
except Exception as item_error:
|
except Exception as item_error:
|
||||||
logger.error(f"[错误] 处理表情包记录时出错 ({emoji.filename}): {item_error!s}")
|
logger.error(f"[错误] 处理表情包记录时出错 ({emoji.filename}): {item_error!s}")
|
||||||
# 即使出错,也尝试继续检查下一个
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 从 self.emoji_objects 中移除标记的对象
|
|
||||||
if objects_to_remove:
|
if objects_to_remove:
|
||||||
self.emoji_objects = [e for e in self.emoji_objects if e not in objects_to_remove]
|
self.emoji_objects = [e for e in self.emoji_objects if e not in objects_to_remove]
|
||||||
|
for e in objects_to_remove:
|
||||||
|
if e.hash in self._emoji_index:
|
||||||
|
self._emoji_index.pop(e.hash, None)
|
||||||
|
|
||||||
|
self._integrity_cursor = (start + processed) % max(1, len(self.emoji_objects))
|
||||||
|
|
||||||
# 清理 EMOJI_REGISTERED_DIR 目录中未被追踪的文件
|
|
||||||
removed_count = await clean_unused_emojis(EMOJI_REGISTERED_DIR, self.emoji_objects, removed_count)
|
removed_count = await clean_unused_emojis(EMOJI_REGISTERED_DIR, self.emoji_objects, removed_count)
|
||||||
|
|
||||||
# 输出清理结果
|
|
||||||
if removed_count > 0:
|
if removed_count > 0:
|
||||||
logger.info(f"[清理] 已清理 {removed_count} 个失效/文件丢失的表情包记录")
|
logger.info(f"[清理] 已清理 {removed_count} 个失效/文件丢失的表情包记录")
|
||||||
logger.info(f"[统计] 清理前记录数: {total_count} | 清理后有效记录数: {len(self.emoji_objects)}")
|
logger.info(f"[统计] 清理前记录数: {total_count} | 清理后有效记录数: {len(self.emoji_objects)}")
|
||||||
@@ -639,36 +304,30 @@ class EmojiManager:
|
|||||||
logger.info("[扫描] 开始扫描新表情包...")
|
logger.info("[扫描] 开始扫描新表情包...")
|
||||||
|
|
||||||
# 检查表情包目录是否存在
|
# 检查表情包目录是否存在
|
||||||
if not os.path.exists(EMOJI_DIR):
|
if not await asyncio.to_thread(os.path.exists, EMOJI_DIR):
|
||||||
logger.warning(f"[警告] 表情包目录不存在: {EMOJI_DIR}")
|
logger.warning(f"[警告] 表情包目录不存在: {EMOJI_DIR}")
|
||||||
os.makedirs(EMOJI_DIR, exist_ok=True)
|
await asyncio.to_thread(os.makedirs, EMOJI_DIR, True)
|
||||||
logger.info(f"[创建] 已创建表情包目录: {EMOJI_DIR}")
|
logger.info(f"[创建] 已创建表情包目录: {EMOJI_DIR}")
|
||||||
await asyncio.sleep(global_config.emoji.check_interval * 60)
|
await asyncio.sleep(global_config.emoji.check_interval * 60)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 检查目录是否为空
|
image_files, is_empty = await list_image_files(EMOJI_DIR)
|
||||||
files = os.listdir(EMOJI_DIR)
|
if is_empty:
|
||||||
if not files:
|
|
||||||
logger.warning(f"[警告] 表情包目录为空: {EMOJI_DIR}")
|
logger.warning(f"[警告] 表情包目录为空: {EMOJI_DIR}")
|
||||||
await asyncio.sleep(global_config.emoji.check_interval * 60)
|
await asyncio.sleep(global_config.emoji.check_interval * 60)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if not image_files:
|
||||||
|
await asyncio.sleep(global_config.emoji.check_interval * 60)
|
||||||
|
continue
|
||||||
|
|
||||||
# 无论steal_emoji是否开启,都检查emoji文件夹以支持手动注册
|
# 无论steal_emoji是否开启,都检查emoji文件夹以支持手动注册
|
||||||
# 只有在需要腾出空间或填充表情库时,才真正执行注册
|
# 只有在需要腾出空间或填充表情库时,才真正执行注册
|
||||||
if (self.emoji_num > self.emoji_num_max and global_config.emoji.do_replace) or (
|
if (self.emoji_num > self.emoji_num_max and global_config.emoji.do_replace) or (
|
||||||
self.emoji_num < self.emoji_num_max
|
self.emoji_num < self.emoji_num_max
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
# 获取目录下所有图片文件
|
for filename in image_files:
|
||||||
files_to_process = [
|
|
||||||
f
|
|
||||||
for f in files
|
|
||||||
if os.path.isfile(os.path.join(EMOJI_DIR, f))
|
|
||||||
and f.lower().endswith((".jpg", ".jpeg", ".png", ".gif"))
|
|
||||||
]
|
|
||||||
|
|
||||||
# 处理每个符合条件的文件
|
|
||||||
for filename in files_to_process:
|
|
||||||
# 尝试注册表情包
|
# 尝试注册表情包
|
||||||
success = await self.register_emoji_by_filename(filename)
|
success = await self.register_emoji_by_filename(filename)
|
||||||
if success:
|
if success:
|
||||||
@@ -677,8 +336,9 @@ class EmojiManager:
|
|||||||
|
|
||||||
# 注册失败则删除对应文件
|
# 注册失败则删除对应文件
|
||||||
file_path = os.path.join(EMOJI_DIR, filename)
|
file_path = os.path.join(EMOJI_DIR, filename)
|
||||||
os.remove(file_path)
|
await asyncio.to_thread(os.remove, file_path)
|
||||||
logger.warning(f"[清理] 删除注册失败的表情包文件: {filename}")
|
logger.warning(f"[清理] 删除注册失败的表情包文件: {filename}")
|
||||||
|
await asyncio.sleep(0)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[错误] 扫描表情包目录失败: {e!s}")
|
logger.error(f"[错误] 扫描表情包目录失败: {e!s}")
|
||||||
|
|
||||||
@@ -698,6 +358,7 @@ class EmojiManager:
|
|||||||
# 更新内存中的列表和数量
|
# 更新内存中的列表和数量
|
||||||
self.emoji_objects = emoji_objects
|
self.emoji_objects = emoji_objects
|
||||||
self.emoji_num = len(emoji_objects)
|
self.emoji_num = len(emoji_objects)
|
||||||
|
self._emoji_index = {e.hash: e for e in emoji_objects if getattr(e, "hash", None)}
|
||||||
|
|
||||||
logger.info(f"[数据库] 加载完成: 共加载 {self.emoji_num} 个表情包记录。")
|
logger.info(f"[数据库] 加载完成: 共加载 {self.emoji_num} 个表情包记录。")
|
||||||
if load_errors > 0:
|
if load_errors > 0:
|
||||||
@@ -753,11 +414,15 @@ class EmojiManager:
|
|||||||
返回:
|
返回:
|
||||||
MaiEmoji 或 None: 如果找到则返回 MaiEmoji 对象,否则返回 None
|
MaiEmoji 或 None: 如果找到则返回 MaiEmoji 对象,否则返回 None
|
||||||
"""
|
"""
|
||||||
for emoji in self.emoji_objects:
|
emoji = self._emoji_index.get(emoji_hash)
|
||||||
# 确保对象未被标记为删除且哈希值匹配
|
if emoji and not emoji.is_deleted:
|
||||||
if not emoji.is_deleted and emoji.hash == emoji_hash:
|
return emoji
|
||||||
return emoji
|
|
||||||
return None # 如果循环结束还没找到,则返回 None
|
for item in self.emoji_objects:
|
||||||
|
if not item.is_deleted and item.hash == emoji_hash:
|
||||||
|
self._emoji_index[emoji_hash] = item
|
||||||
|
return item
|
||||||
|
return None
|
||||||
|
|
||||||
@cached(ttl=1800, key_prefix="emoji_tag") # 缓存30分钟
|
@cached(ttl=1800, key_prefix="emoji_tag") # 缓存30分钟
|
||||||
async def get_emoji_tag_by_hash(self, emoji_hash: str) -> str | None:
|
async def get_emoji_tag_by_hash(self, emoji_hash: str) -> str | None:
|
||||||
@@ -849,6 +514,7 @@ class EmojiManager:
|
|||||||
if success:
|
if success:
|
||||||
# 从emoji_objects列表中移除该对象
|
# 从emoji_objects列表中移除该对象
|
||||||
self.emoji_objects = [e for e in self.emoji_objects if e.hash != emoji_hash]
|
self.emoji_objects = [e for e in self.emoji_objects if e.hash != emoji_hash]
|
||||||
|
self._emoji_index.pop(emoji_hash, None)
|
||||||
# 更新计数
|
# 更新计数
|
||||||
self.emoji_num -= 1
|
self.emoji_num -= 1
|
||||||
logger.info(f"[统计] 当前表情包数量: {self.emoji_num}")
|
logger.info(f"[统计] 当前表情包数量: {self.emoji_num}")
|
||||||
@@ -931,6 +597,7 @@ class EmojiManager:
|
|||||||
register_success = await new_emoji.register_to_db()
|
register_success = await new_emoji.register_to_db()
|
||||||
if register_success:
|
if register_success:
|
||||||
self.emoji_objects.append(new_emoji)
|
self.emoji_objects.append(new_emoji)
|
||||||
|
self._emoji_index[new_emoji.hash] = new_emoji
|
||||||
self.emoji_num += 1
|
self.emoji_num += 1
|
||||||
logger.info(f"[成功] 注册: {new_emoji.filename}")
|
logger.info(f"[成功] 注册: {new_emoji.filename}")
|
||||||
return True
|
return True
|
||||||
@@ -1099,7 +766,7 @@ class EmojiManager:
|
|||||||
bool: 注册是否成功
|
bool: 注册是否成功
|
||||||
"""
|
"""
|
||||||
file_full_path = os.path.join(EMOJI_DIR, filename)
|
file_full_path = os.path.join(EMOJI_DIR, filename)
|
||||||
if not os.path.exists(file_full_path):
|
if not await asyncio.to_thread(os.path.exists, file_full_path):
|
||||||
logger.error(f"[注册失败] 文件不存在: {file_full_path}")
|
logger.error(f"[注册失败] 文件不存在: {file_full_path}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -1117,7 +784,7 @@ class EmojiManager:
|
|||||||
logger.warning(f"[注册跳过] 表情包已存在 (Hash: {new_emoji.hash}): {filename}")
|
logger.warning(f"[注册跳过] 表情包已存在 (Hash: {new_emoji.hash}): {filename}")
|
||||||
# 删除重复的源文件
|
# 删除重复的源文件
|
||||||
try:
|
try:
|
||||||
os.remove(file_full_path)
|
await asyncio.to_thread(os.remove, file_full_path)
|
||||||
logger.info(f"[清理] 删除重复的待注册文件: {filename}")
|
logger.info(f"[清理] 删除重复的待注册文件: {filename}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[错误] 删除重复文件失败: {e!s}")
|
logger.error(f"[错误] 删除重复文件失败: {e!s}")
|
||||||
@@ -1137,7 +804,7 @@ class EmojiManager:
|
|||||||
logger.warning(f"[注册失败] 未能生成有效描述或审核未通过: {filename}")
|
logger.warning(f"[注册失败] 未能生成有效描述或审核未通过: {filename}")
|
||||||
# 删除未能生成描述的文件
|
# 删除未能生成描述的文件
|
||||||
try:
|
try:
|
||||||
os.remove(file_full_path)
|
await asyncio.to_thread(os.remove, file_full_path)
|
||||||
logger.info(f"[清理] 删除描述生成失败的文件: {filename}")
|
logger.info(f"[清理] 删除描述生成失败的文件: {filename}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[错误] 删除描述生成失败文件时出错: {e!s}")
|
logger.error(f"[错误] 删除描述生成失败文件时出错: {e!s}")
|
||||||
@@ -1149,7 +816,7 @@ class EmojiManager:
|
|||||||
logger.error(f"[注册失败] 生成描述/情感时出错 ({filename}): {build_desc_error}")
|
logger.error(f"[注册失败] 生成描述/情感时出错 ({filename}): {build_desc_error}")
|
||||||
# 同样考虑删除文件
|
# 同样考虑删除文件
|
||||||
try:
|
try:
|
||||||
os.remove(file_full_path)
|
await asyncio.to_thread(os.remove, file_full_path)
|
||||||
logger.info(f"[清理] 删除描述生成异常的文件: {filename}")
|
logger.info(f"[清理] 删除描述生成异常的文件: {filename}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[错误] 删除描述生成异常文件时出错: {e!s}")
|
logger.error(f"[错误] 删除描述生成异常文件时出错: {e!s}")
|
||||||
@@ -1163,7 +830,7 @@ class EmojiManager:
|
|||||||
logger.error("[注册失败] 替换表情包失败,无法完成注册")
|
logger.error("[注册失败] 替换表情包失败,无法完成注册")
|
||||||
# 替换失败,删除新表情包文件
|
# 替换失败,删除新表情包文件
|
||||||
try:
|
try:
|
||||||
os.remove(file_full_path) # new_emoji 的 full_path 此时还是源路径
|
await asyncio.to_thread(os.remove, file_full_path) # new_emoji 的 full_path 此时还是源路径
|
||||||
logger.info(f"[清理] 删除替换失败的新表情文件: {filename}")
|
logger.info(f"[清理] 删除替换失败的新表情文件: {filename}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[错误] 删除替换失败文件时出错: {e!s}")
|
logger.error(f"[错误] 删除替换失败文件时出错: {e!s}")
|
||||||
@@ -1176,6 +843,7 @@ class EmojiManager:
|
|||||||
if register_success:
|
if register_success:
|
||||||
# 注册成功后,添加到内存列表
|
# 注册成功后,添加到内存列表
|
||||||
self.emoji_objects.append(new_emoji)
|
self.emoji_objects.append(new_emoji)
|
||||||
|
self._emoji_index[new_emoji.hash] = new_emoji
|
||||||
self.emoji_num += 1
|
self.emoji_num += 1
|
||||||
logger.info(f"[成功] 注册新表情包: {filename} (当前: {self.emoji_num}/{self.emoji_num_max})")
|
logger.info(f"[成功] 注册新表情包: {filename} (当前: {self.emoji_num}/{self.emoji_num_max})")
|
||||||
return True
|
return True
|
||||||
@@ -1183,9 +851,9 @@ class EmojiManager:
|
|||||||
logger.error(f"[注册失败] 保存表情包到数据库/移动文件失败: {filename}")
|
logger.error(f"[注册失败] 保存表情包到数据库/移动文件失败: {filename}")
|
||||||
# register_to_db 失败时,内部会尝试清理移动后的文件,源文件可能还在
|
# register_to_db 失败时,内部会尝试清理移动后的文件,源文件可能还在
|
||||||
# 是否需要删除源文件?
|
# 是否需要删除源文件?
|
||||||
if os.path.exists(file_full_path):
|
if await asyncio.to_thread(os.path.exists, file_full_path):
|
||||||
try:
|
try:
|
||||||
os.remove(file_full_path)
|
await asyncio.to_thread(os.remove, file_full_path)
|
||||||
logger.info(f"[清理] 删除注册失败的源文件: {filename}")
|
logger.info(f"[清理] 删除注册失败的源文件: {filename}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[错误] 删除注册失败源文件时出错: {e!s}")
|
logger.error(f"[错误] 删除注册失败源文件时出错: {e!s}")
|
||||||
@@ -1195,9 +863,9 @@ class EmojiManager:
|
|||||||
logger.error(f"[错误] 注册表情包时发生未预期错误 ({filename}): {e!s}")
|
logger.error(f"[错误] 注册表情包时发生未预期错误 ({filename}): {e!s}")
|
||||||
logger.error(traceback.format_exc())
|
logger.error(traceback.format_exc())
|
||||||
# 尝试删除源文件以避免循环处理
|
# 尝试删除源文件以避免循环处理
|
||||||
if os.path.exists(file_full_path):
|
if await asyncio.to_thread(os.path.exists, file_full_path):
|
||||||
try:
|
try:
|
||||||
os.remove(file_full_path)
|
await asyncio.to_thread(os.remove, file_full_path)
|
||||||
logger.info(f"[清理] 删除处理异常的源文件: {filename}")
|
logger.info(f"[清理] 删除处理异常的源文件: {filename}")
|
||||||
except Exception as remove_error:
|
except Exception as remove_error:
|
||||||
logger.error(f"[错误] 删除异常处理文件时出错: {remove_error}")
|
logger.error(f"[错误] 删除异常处理文件时出错: {remove_error}")
|
||||||
|
|||||||
140
src/chat/emoji_system/emoji_utils.py
Normal file
140
src/chat/emoji_system/emoji_utils.py
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from src.chat.emoji_system.emoji_constants import BASE_DIR, EMOJI_DIR, EMOJI_REGISTERED_DIR
|
||||||
|
from src.chat.emoji_system.emoji_entities import MaiEmoji
|
||||||
|
from src.common.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger("emoji")
|
||||||
|
|
||||||
|
|
||||||
|
def _emoji_objects_to_readable_list(emoji_objects: list[MaiEmoji]) -> list[str]:
|
||||||
|
emoji_info_list = []
|
||||||
|
for i, emoji in enumerate(emoji_objects):
|
||||||
|
time_str = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(emoji.register_time))
|
||||||
|
emoji_info = f"编号: {i + 1}\n描述: {emoji.description}\n使用次数: {emoji.usage_count}\n添加时间: {time_str}\n"
|
||||||
|
emoji_info_list.append(emoji_info)
|
||||||
|
return emoji_info_list
|
||||||
|
|
||||||
|
|
||||||
|
def _to_emoji_objects(data: Any) -> tuple[list[MaiEmoji], int]:
|
||||||
|
emoji_objects = []
|
||||||
|
load_errors = 0
|
||||||
|
emoji_data_list = list(data)
|
||||||
|
|
||||||
|
for emoji_data in emoji_data_list:
|
||||||
|
full_path = emoji_data.full_path
|
||||||
|
if not full_path:
|
||||||
|
logger.warning(
|
||||||
|
f"[加载错误] 数据库记录缺少 'full_path' 字段: ID {emoji_data.id if hasattr(emoji_data, 'id') else 'Unknown'}"
|
||||||
|
)
|
||||||
|
load_errors += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
emoji = MaiEmoji(full_path=full_path)
|
||||||
|
|
||||||
|
emoji.hash = emoji_data.emoji_hash
|
||||||
|
if not emoji.hash:
|
||||||
|
logger.warning(f"[加载错误] 数据库记录缺少 'hash' 字段: {full_path}")
|
||||||
|
load_errors += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
emoji.description = emoji_data.description
|
||||||
|
emoji.emotion = emoji_data.emotion.split(",") if emoji_data.emotion else []
|
||||||
|
emoji.usage_count = emoji_data.usage_count
|
||||||
|
|
||||||
|
db_last_used_time = emoji_data.last_used_time
|
||||||
|
db_register_time = emoji_data.register_time
|
||||||
|
|
||||||
|
emoji.last_used_time = db_last_used_time if db_last_used_time is not None else emoji.register_time
|
||||||
|
emoji.register_time = db_register_time if db_register_time is not None else emoji.register_time
|
||||||
|
|
||||||
|
emoji.format = emoji_data.format
|
||||||
|
|
||||||
|
emoji_objects.append(emoji)
|
||||||
|
|
||||||
|
except ValueError as ve:
|
||||||
|
logger.error(f"[加载错误] 初始化 MaiEmoji 失败 ({full_path}): {ve}")
|
||||||
|
load_errors += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[加载错误] 处理数据库记录时出错 ({full_path}): {e!s}")
|
||||||
|
load_errors += 1
|
||||||
|
return emoji_objects, load_errors
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_emoji_dir() -> None:
|
||||||
|
os.makedirs(EMOJI_DIR, exist_ok=True)
|
||||||
|
os.makedirs(EMOJI_REGISTERED_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def clear_temp_emoji() -> None:
|
||||||
|
logger.info("[清理] 开始清理缓存...")
|
||||||
|
|
||||||
|
for need_clear in (
|
||||||
|
os.path.join(BASE_DIR, "emoji"),
|
||||||
|
os.path.join(BASE_DIR, "image"),
|
||||||
|
os.path.join(BASE_DIR, "images"),
|
||||||
|
):
|
||||||
|
if await asyncio.to_thread(os.path.exists, need_clear):
|
||||||
|
files = await asyncio.to_thread(os.listdir, need_clear)
|
||||||
|
if len(files) > 1000:
|
||||||
|
for i, filename in enumerate(files):
|
||||||
|
file_path = os.path.join(need_clear, filename)
|
||||||
|
if await asyncio.to_thread(os.path.isfile, file_path):
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(os.remove, file_path)
|
||||||
|
logger.debug(f"[清理] 删除: {filename}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"[清理] 删除失败 {filename}: {e!s}")
|
||||||
|
if (i + 1) % 100 == 0:
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
|
||||||
|
async def clean_unused_emojis(emoji_dir: str, emoji_objects: list[MaiEmoji], removed_count: int) -> int:
|
||||||
|
if not await asyncio.to_thread(os.path.exists, emoji_dir):
|
||||||
|
logger.warning(f"[清理] 目标目录不存在,跳过清理: {emoji_dir}")
|
||||||
|
return removed_count
|
||||||
|
|
||||||
|
cleaned_count = 0
|
||||||
|
try:
|
||||||
|
tracked_full_paths = {emoji.full_path for emoji in emoji_objects if not emoji.is_deleted}
|
||||||
|
|
||||||
|
for entry in await asyncio.to_thread(lambda: list(os.scandir(emoji_dir))):
|
||||||
|
if not entry.is_file():
|
||||||
|
continue
|
||||||
|
|
||||||
|
file_full_path = entry.path
|
||||||
|
|
||||||
|
if file_full_path not in tracked_full_paths:
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(os.remove, file_full_path)
|
||||||
|
logger.info(f"[清理] 删除未追踪的表情包文件: {file_full_path}")
|
||||||
|
cleaned_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[错误] 删除文件时出错 ({file_full_path}): {e!s}")
|
||||||
|
|
||||||
|
if cleaned_count > 0:
|
||||||
|
logger.info(f"[清理] 在目录 {emoji_dir} 中清理了 {cleaned_count} 个破损表情包。")
|
||||||
|
else:
|
||||||
|
logger.info(f"[清理] 目录 {emoji_dir} 中没有需要清理的。")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[错误] 清理未使用表情包文件时出错 ({emoji_dir}): {e!s}")
|
||||||
|
|
||||||
|
return removed_count + cleaned_count
|
||||||
|
|
||||||
|
|
||||||
|
async def list_image_files(directory: str) -> tuple[list[str], bool]:
|
||||||
|
def _scan() -> tuple[list[str], bool]:
|
||||||
|
entries = list(os.scandir(directory))
|
||||||
|
files = [
|
||||||
|
entry.name
|
||||||
|
for entry in entries
|
||||||
|
if entry.is_file() and entry.name.lower().endswith((".jpg", ".jpeg", ".png", ".gif"))
|
||||||
|
]
|
||||||
|
return files, len(entries) == 0
|
||||||
|
|
||||||
|
return await asyncio.to_thread(_scan)
|
||||||
@@ -30,7 +30,7 @@ logger = get_logger("message_manager")
|
|||||||
class MessageManager:
|
class MessageManager:
|
||||||
"""消息管理器"""
|
"""消息管理器"""
|
||||||
|
|
||||||
def __init__(self, check_interval: float = 5.0):
|
def __init__(self, check_interval: float = 5.0):
|
||||||
self.check_interval = check_interval # 检查间隔(秒)
|
self.check_interval = check_interval # 检查间隔(秒)
|
||||||
self.is_running = False
|
self.is_running = False
|
||||||
self.manager_task: asyncio.Task | None = None
|
self.manager_task: asyncio.Task | None = None
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ async def find_messages(
|
|||||||
# 统一做上限保护,防止无限制查询导致内存暴涨
|
# 统一做上限保护,防止无限制查询导致内存暴涨
|
||||||
if limit <= 0:
|
if limit <= 0:
|
||||||
capped_limit = SAFE_FETCH_LIMIT
|
capped_limit = SAFE_FETCH_LIMIT
|
||||||
logger.warning(
|
logger.debug(
|
||||||
f"find_messages 未指定 limit,自动限制为 {capped_limit} 行以避免内存占用过高",
|
f"find_messages 未指定 limit,自动限制为 {capped_limit} 行以避免内存占用过高",
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -995,6 +995,27 @@ class KokoroFlowChatterWaitingConfig(ValidatedConfigBase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class KokoroFlowChatterPromptConfig(ValidatedConfigBase):
|
||||||
|
"""Kokoro Flow Chatter 提示词/上下文构建配置"""
|
||||||
|
|
||||||
|
activity_stream_format: Literal["narrative", "table", "both"] = Field(
|
||||||
|
default="narrative",
|
||||||
|
description='活动流格式: "narrative"(线性叙事) / "table"(结构化表格) / "both"(两者都输出)',
|
||||||
|
)
|
||||||
|
max_activity_entries: int = Field(
|
||||||
|
default=30,
|
||||||
|
ge=0,
|
||||||
|
le=200,
|
||||||
|
description="活动流最多保留条数(越大越完整,但token越高)",
|
||||||
|
)
|
||||||
|
max_entry_length: int = Field(
|
||||||
|
default=500,
|
||||||
|
ge=0,
|
||||||
|
le=5000,
|
||||||
|
description="活动流单条最大字符数(用于裁剪,避免单条过长拖垮上下文)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class KokoroFlowChatterConfig(ValidatedConfigBase):
|
class KokoroFlowChatterConfig(ValidatedConfigBase):
|
||||||
"""
|
"""
|
||||||
Kokoro Flow Chatter 配置类 - 私聊专用心流对话系统
|
Kokoro Flow Chatter 配置类 - 私聊专用心流对话系统
|
||||||
@@ -1031,6 +1052,11 @@ class KokoroFlowChatterConfig(ValidatedConfigBase):
|
|||||||
description="自定义KFC决策行为指导提示词(unified影响整体,split仅影响planner)",
|
description="自定义KFC决策行为指导提示词(unified影响整体,split仅影响planner)",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
prompt: KokoroFlowChatterPromptConfig = Field(
|
||||||
|
default_factory=KokoroFlowChatterPromptConfig,
|
||||||
|
description="提示词/上下文构建配置(活动流格式、裁剪等)",
|
||||||
|
)
|
||||||
|
|
||||||
waiting: KokoroFlowChatterWaitingConfig = Field(
|
waiting: KokoroFlowChatterWaitingConfig = Field(
|
||||||
default_factory=KokoroFlowChatterWaitingConfig,
|
default_factory=KokoroFlowChatterWaitingConfig,
|
||||||
description="等待策略配置(默认等待时间、倍率等)",
|
description="等待策略配置(默认等待时间、倍率等)",
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ class ShortTermMemoryManager:
|
|||||||
max_memories: int = 30,
|
max_memories: int = 30,
|
||||||
transfer_importance_threshold: float = 0.6,
|
transfer_importance_threshold: float = 0.6,
|
||||||
llm_temperature: float = 0.2,
|
llm_temperature: float = 0.2,
|
||||||
|
enable_force_cleanup: bool = False,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
初始化短期记忆层管理器
|
初始化短期记忆层管理器
|
||||||
@@ -60,6 +61,7 @@ class ShortTermMemoryManager:
|
|||||||
self.max_memories = max_memories
|
self.max_memories = max_memories
|
||||||
self.transfer_importance_threshold = transfer_importance_threshold
|
self.transfer_importance_threshold = transfer_importance_threshold
|
||||||
self.llm_temperature = llm_temperature
|
self.llm_temperature = llm_temperature
|
||||||
|
self.enable_force_cleanup = enable_force_cleanup
|
||||||
|
|
||||||
# 核心数据
|
# 核心数据
|
||||||
self.memories: list[ShortTermMemory] = []
|
self.memories: list[ShortTermMemory] = []
|
||||||
@@ -75,7 +77,8 @@ class ShortTermMemoryManager:
|
|||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"短期记忆管理器已创建 (max_memories={max_memories}, "
|
f"短期记忆管理器已创建 (max_memories={max_memories}, "
|
||||||
f"transfer_threshold={transfer_importance_threshold:.2f})"
|
f"transfer_threshold={transfer_importance_threshold:.2f}, "
|
||||||
|
f"force_cleanup={'on' if enable_force_cleanup else 'off'})"
|
||||||
)
|
)
|
||||||
|
|
||||||
async def initialize(self) -> None:
|
async def initialize(self) -> None:
|
||||||
@@ -687,6 +690,41 @@ class ShortTermMemoryManager:
|
|||||||
|
|
||||||
return candidates
|
return candidates
|
||||||
|
|
||||||
|
def force_cleanup_overflow(self, keep_ratio: float = 0.9) -> int:
|
||||||
|
"""当短期记忆超过容量时,强制删除低重要性且最早的记忆以泄压"""
|
||||||
|
if not self.enable_force_cleanup:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if self.max_memories <= 0:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
current = len(self.memories)
|
||||||
|
limit = int(self.max_memories * keep_ratio)
|
||||||
|
if current <= self.max_memories:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# 先按重要性升序,再按创建时间升序删除
|
||||||
|
sorted_memories = sorted(self.memories, key=lambda m: (m.importance, m.created_at))
|
||||||
|
remove_count = max(0, current - limit)
|
||||||
|
to_remove = {mem.id for mem in sorted_memories[:remove_count]}
|
||||||
|
|
||||||
|
if not to_remove:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
self.memories = [mem for mem in self.memories if mem.id not in to_remove]
|
||||||
|
for mem_id in to_remove:
|
||||||
|
self._memory_id_index.pop(mem_id, None)
|
||||||
|
self._similarity_cache.pop(mem_id, None)
|
||||||
|
|
||||||
|
# 异步保存即可,不阻塞主流程
|
||||||
|
asyncio.create_task(self._save_to_disk())
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
f"短期记忆压力泄压: 移除 {len(to_remove)} 条 (当前 {len(self.memories)}/{self.max_memories})"
|
||||||
|
)
|
||||||
|
|
||||||
|
return len(to_remove)
|
||||||
|
|
||||||
async def clear_transferred_memories(self, memory_ids: list[str]) -> None:
|
async def clear_transferred_memories(self, memory_ids: list[str]) -> None:
|
||||||
"""
|
"""
|
||||||
清除已转移到长期记忆的记忆
|
清除已转移到长期记忆的记忆
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ class UnifiedMemoryManager:
|
|||||||
# 短期记忆配置
|
# 短期记忆配置
|
||||||
short_term_max_memories: int = 30,
|
short_term_max_memories: int = 30,
|
||||||
short_term_transfer_threshold: float = 0.6,
|
short_term_transfer_threshold: float = 0.6,
|
||||||
|
short_term_enable_force_cleanup: bool = False,
|
||||||
# 长期记忆配置
|
# 长期记忆配置
|
||||||
long_term_batch_size: int = 10,
|
long_term_batch_size: int = 10,
|
||||||
long_term_search_top_k: int = 5,
|
long_term_search_top_k: int = 5,
|
||||||
@@ -96,6 +97,7 @@ class UnifiedMemoryManager:
|
|||||||
"short_term": {
|
"short_term": {
|
||||||
"max_memories": short_term_max_memories,
|
"max_memories": short_term_max_memories,
|
||||||
"transfer_importance_threshold": short_term_transfer_threshold,
|
"transfer_importance_threshold": short_term_transfer_threshold,
|
||||||
|
"enable_force_cleanup": short_term_enable_force_cleanup,
|
||||||
},
|
},
|
||||||
"long_term": {
|
"long_term": {
|
||||||
"batch_size": long_term_batch_size,
|
"batch_size": long_term_batch_size,
|
||||||
@@ -565,7 +567,9 @@ class UnifiedMemoryManager:
|
|||||||
self._transfer_wakeup_event.clear()
|
self._transfer_wakeup_event.clear()
|
||||||
|
|
||||||
self._auto_transfer_task = asyncio.create_task(self._auto_transfer_loop())
|
self._auto_transfer_task = asyncio.create_task(self._auto_transfer_loop())
|
||||||
logger.debug("自动转移任务已启动")
|
# 立即触发一次检查,避免启动初期的长时间等待
|
||||||
|
self._transfer_wakeup_event.set()
|
||||||
|
logger.debug("自动转移任务已启动并触发首次检查")
|
||||||
|
|
||||||
async def _auto_transfer_loop(self) -> None:
|
async def _auto_transfer_loop(self) -> None:
|
||||||
"""自动转移循环(批量缓存模式,优化:更高效的缓存管理)"""
|
"""自动转移循环(批量缓存模式,优化:更高效的缓存管理)"""
|
||||||
@@ -611,6 +615,13 @@ class UnifiedMemoryManager:
|
|||||||
occupancy_ratio = len(self.short_term_manager.memories) / max_memories
|
occupancy_ratio = len(self.short_term_manager.memories) / max_memories
|
||||||
time_since_last_transfer = time.monotonic() - last_transfer_time
|
time_since_last_transfer = time.monotonic() - last_transfer_time
|
||||||
|
|
||||||
|
if occupancy_ratio >= 1.0 and not transfer_cache:
|
||||||
|
removed = self.short_term_manager.force_cleanup_overflow()
|
||||||
|
if removed > 0:
|
||||||
|
logger.warning(
|
||||||
|
f"短期记忆占用率 {occupancy_ratio:.0%},已强制删除 {removed} 条低重要性记忆泄压"
|
||||||
|
)
|
||||||
|
|
||||||
# 优化:优先级判断重构(早期 return)
|
# 优化:优先级判断重构(早期 return)
|
||||||
should_transfer = (
|
should_transfer = (
|
||||||
len(transfer_cache) >= cache_size_threshold
|
len(transfer_cache) >= cache_size_threshold
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ class UserFactTool(BaseTool):
|
|||||||
("info_value", ToolParamType.STRING, "具体内容,如'11月23日'、'程序员'、'想开咖啡店'", True, None),
|
("info_value", ToolParamType.STRING, "具体内容,如'11月23日'、'程序员'、'想开咖啡店'", True, None),
|
||||||
]
|
]
|
||||||
available_for_llm = True
|
available_for_llm = True
|
||||||
history_ttl = 5
|
history_ttl = 0
|
||||||
|
|
||||||
async def execute(self, function_args: dict[str, Any]) -> dict[str, Any]:
|
async def execute(self, function_args: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""执行关键信息记录
|
"""执行关键信息记录
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ class UserProfileTool(BaseTool):
|
|||||||
("key_info_value", ToolParamType.STRING, "具体信息内容(必须是具体值如'11月23日'、'上海')", False, None),
|
("key_info_value", ToolParamType.STRING, "具体信息内容(必须是具体值如'11月23日'、'上海')", False, None),
|
||||||
]
|
]
|
||||||
available_for_llm = True
|
available_for_llm = True
|
||||||
history_ttl = 1
|
history_ttl = 0
|
||||||
|
|
||||||
async def execute(self, function_args: dict[str, Any]) -> dict[str, Any]:
|
async def execute(self, function_args: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""执行用户画像更新(异步后台执行,不阻塞回复)
|
"""执行用户画像更新(异步后台执行,不阻塞回复)
|
||||||
|
|||||||
@@ -223,6 +223,9 @@ class KokoroFlowChatter(BaseChatter):
|
|||||||
exec_results.append(result)
|
exec_results.append(result)
|
||||||
if result.get("success") and action.type in ("kfc_reply", "respond"):
|
if result.get("success") and action.type in ("kfc_reply", "respond"):
|
||||||
has_reply = True
|
has_reply = True
|
||||||
|
reply_text = (result.get("reply_text") or "").strip()
|
||||||
|
if reply_text:
|
||||||
|
action.params["content"] = reply_text
|
||||||
|
|
||||||
# 11. 记录 Bot 规划到 mental_log
|
# 11. 记录 Bot 规划到 mental_log
|
||||||
session.add_bot_planning(
|
session.add_bot_planning(
|
||||||
@@ -336,6 +339,12 @@ class KokoroFlowChatter(BaseChatter):
|
|||||||
# 为 kfc_reply 动作注入回复生成所需的上下文
|
# 为 kfc_reply 动作注入回复生成所需的上下文
|
||||||
for action in plan_response.actions:
|
for action in plan_response.actions:
|
||||||
if action.type == "kfc_reply":
|
if action.type == "kfc_reply":
|
||||||
|
# 分离模式下 Planner 不应直接生成回复内容;即使模型输出了 content,也应忽略
|
||||||
|
if "content" in action.params and action.params.get("content"):
|
||||||
|
logger.warning(
|
||||||
|
"[KFC] Split模式下Planner输出了kfc_reply.content,已忽略(由Replyer生成)"
|
||||||
|
)
|
||||||
|
action.params.pop("content", None)
|
||||||
action.params["user_id"] = user_id
|
action.params["user_id"] = user_id
|
||||||
action.params["user_name"] = user_name
|
action.params["user_name"] = user_name
|
||||||
action.params["thought"] = plan_response.thought
|
action.params["thought"] = plan_response.thought
|
||||||
|
|||||||
@@ -90,6 +90,12 @@ class PromptConfig:
|
|||||||
# 每条记录最大字符数
|
# 每条记录最大字符数
|
||||||
max_entry_length: int = 500
|
max_entry_length: int = 500
|
||||||
|
|
||||||
|
# 活动流格式:narrative(线性叙事)/ table(结构化表格)/ both(两者都给)
|
||||||
|
# - narrative: 更自然,但信息密度较低,长时更容易丢细节
|
||||||
|
# - table: 更高信息密度,便于模型对齐字段、检索与对比
|
||||||
|
# - both: 调试/对照用,token 更高
|
||||||
|
activity_stream_format: str = "narrative"
|
||||||
|
|
||||||
# 是否包含人物关系信息
|
# 是否包含人物关系信息
|
||||||
include_relation: bool = True
|
include_relation: bool = True
|
||||||
|
|
||||||
@@ -236,6 +242,11 @@ def load_config() -> KokoroFlowChatterConfig:
|
|||||||
config.prompt = PromptConfig(
|
config.prompt = PromptConfig(
|
||||||
max_activity_entries=getattr(pmt_cfg, "max_activity_entries", 30),
|
max_activity_entries=getattr(pmt_cfg, "max_activity_entries", 30),
|
||||||
max_entry_length=getattr(pmt_cfg, "max_entry_length", 500),
|
max_entry_length=getattr(pmt_cfg, "max_entry_length", 500),
|
||||||
|
activity_stream_format=getattr(
|
||||||
|
pmt_cfg,
|
||||||
|
"activity_stream_format",
|
||||||
|
getattr(pmt_cfg, "activity_format", "narrative"),
|
||||||
|
),
|
||||||
include_relation=getattr(pmt_cfg, "include_relation", True),
|
include_relation=getattr(pmt_cfg, "include_relation", True),
|
||||||
include_memory=getattr(pmt_cfg, "include_memory", True),
|
include_memory=getattr(pmt_cfg, "include_memory", True),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -456,6 +456,11 @@ class ProactiveThinker:
|
|||||||
# 分离模式下需要注入上下文信息
|
# 分离模式下需要注入上下文信息
|
||||||
for action in plan_response.actions:
|
for action in plan_response.actions:
|
||||||
if action.type == "kfc_reply":
|
if action.type == "kfc_reply":
|
||||||
|
if "content" in action.params and action.params.get("content"):
|
||||||
|
logger.warning(
|
||||||
|
"[KFC ProactiveThinker] Split模式下Planner输出了kfc_reply.content,已忽略(由Replyer生成)"
|
||||||
|
)
|
||||||
|
action.params.pop("content", None)
|
||||||
action.params["user_id"] = session.user_id
|
action.params["user_id"] = session.user_id
|
||||||
action.params["user_name"] = user_name
|
action.params["user_name"] = user_name
|
||||||
action.params["thought"] = plan_response.thought
|
action.params["thought"] = plan_response.thought
|
||||||
@@ -495,7 +500,7 @@ class ProactiveThinker:
|
|||||||
|
|
||||||
# 执行动作(回复生成在 Action.execute() 中完成)
|
# 执行动作(回复生成在 Action.execute() 中完成)
|
||||||
for action in plan_response.actions:
|
for action in plan_response.actions:
|
||||||
await action_manager.execute_action(
|
result = await action_manager.execute_action(
|
||||||
action_name=action.type,
|
action_name=action.type,
|
||||||
chat_id=session.stream_id,
|
chat_id=session.stream_id,
|
||||||
target_message=None,
|
target_message=None,
|
||||||
@@ -504,6 +509,10 @@ class ProactiveThinker:
|
|||||||
thinking_id=None,
|
thinking_id=None,
|
||||||
log_prefix="[KFC ProactiveThinker]",
|
log_prefix="[KFC ProactiveThinker]",
|
||||||
)
|
)
|
||||||
|
if result.get("success") and action.type in ("kfc_reply", "respond"):
|
||||||
|
reply_text = (result.get("reply_text") or "").strip()
|
||||||
|
if reply_text:
|
||||||
|
action.params["content"] = reply_text
|
||||||
|
|
||||||
# 🎯 只有真正发送了消息才增加追问计数(do_nothing 不算追问)
|
# 🎯 只有真正发送了消息才增加追问计数(do_nothing 不算追问)
|
||||||
has_reply_action = any(
|
has_reply_action = any(
|
||||||
@@ -703,6 +712,11 @@ class ProactiveThinker:
|
|||||||
if self._mode == KFCMode.SPLIT:
|
if self._mode == KFCMode.SPLIT:
|
||||||
for action in plan_response.actions:
|
for action in plan_response.actions:
|
||||||
if action.type == "kfc_reply":
|
if action.type == "kfc_reply":
|
||||||
|
if "content" in action.params and action.params.get("content"):
|
||||||
|
logger.warning(
|
||||||
|
"[KFC ProactiveThinker] Split模式下Planner输出了kfc_reply.content,已忽略(由Replyer生成)"
|
||||||
|
)
|
||||||
|
action.params.pop("content", None)
|
||||||
action.params["user_id"] = session.user_id
|
action.params["user_id"] = session.user_id
|
||||||
action.params["user_name"] = user_name
|
action.params["user_name"] = user_name
|
||||||
action.params["thought"] = plan_response.thought
|
action.params["thought"] = plan_response.thought
|
||||||
@@ -735,7 +749,7 @@ class ProactiveThinker:
|
|||||||
|
|
||||||
# 执行动作(回复生成在 Action.execute() 中完成)
|
# 执行动作(回复生成在 Action.execute() 中完成)
|
||||||
for action in plan_response.actions:
|
for action in plan_response.actions:
|
||||||
await action_manager.execute_action(
|
result = await action_manager.execute_action(
|
||||||
action_name=action.type,
|
action_name=action.type,
|
||||||
chat_id=session.stream_id,
|
chat_id=session.stream_id,
|
||||||
target_message=None,
|
target_message=None,
|
||||||
@@ -744,6 +758,10 @@ class ProactiveThinker:
|
|||||||
thinking_id=None,
|
thinking_id=None,
|
||||||
log_prefix="[KFC ProactiveThinker]",
|
log_prefix="[KFC ProactiveThinker]",
|
||||||
)
|
)
|
||||||
|
if result.get("success") and action.type in ("kfc_reply", "respond"):
|
||||||
|
reply_text = (result.get("reply_text") or "").strip()
|
||||||
|
if reply_text:
|
||||||
|
action.params["content"] = reply_text
|
||||||
|
|
||||||
# 记录到 mental_log
|
# 记录到 mental_log
|
||||||
session.add_bot_planning(
|
session.add_bot_planning(
|
||||||
|
|||||||
@@ -284,6 +284,42 @@ class PromptBuilder:
|
|||||||
|
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
def _build_last_bot_action_block(self, session: KokoroSession | None) -> str:
|
||||||
|
"""
|
||||||
|
构建“最近一次Bot动作/发言”块(用于插入到当前情况里)
|
||||||
|
|
||||||
|
目的:让模型在决策时能显式参考“我刚刚做过什么/说过什么”,降低长上下文里漏细节的概率。
|
||||||
|
"""
|
||||||
|
if not session or not getattr(session, "mental_log", None):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
last_planning_entry: MentalLogEntry | None = None
|
||||||
|
for entry in reversed(session.mental_log):
|
||||||
|
if entry.event_type == EventType.BOT_PLANNING:
|
||||||
|
last_planning_entry = entry
|
||||||
|
break
|
||||||
|
|
||||||
|
if not last_planning_entry:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
actions_desc = self._format_actions(last_planning_entry.actions)
|
||||||
|
|
||||||
|
last_message = ""
|
||||||
|
for action in last_planning_entry.actions:
|
||||||
|
if action.get("type") == "kfc_reply":
|
||||||
|
content = (action.get("content") or "").strip()
|
||||||
|
if content:
|
||||||
|
last_message = content
|
||||||
|
|
||||||
|
if last_message and len(last_message) > 80:
|
||||||
|
last_message = last_message[:80] + "..."
|
||||||
|
|
||||||
|
lines = [f"你最近一次执行的动作是:{actions_desc}"]
|
||||||
|
if last_message:
|
||||||
|
lines.append(f"你上一次发出的消息是:「{last_message}」")
|
||||||
|
|
||||||
|
return "\n".join(lines) + "\n\n"
|
||||||
|
|
||||||
async def _build_context_data(
|
async def _build_context_data(
|
||||||
self,
|
self,
|
||||||
user_name: str,
|
user_name: str,
|
||||||
@@ -541,14 +577,39 @@ class PromptBuilder:
|
|||||||
构建活动流
|
构建活动流
|
||||||
|
|
||||||
将 mental_log 中的事件按时间顺序转换为线性叙事
|
将 mental_log 中的事件按时间顺序转换为线性叙事
|
||||||
使用统一的 prompt 模板
|
支持线性叙事或结构化表格两种格式(可通过配置切换)
|
||||||
"""
|
"""
|
||||||
entries = session.get_recent_entries(limit=30)
|
from ..config import get_config
|
||||||
|
|
||||||
|
kfc_config = get_config()
|
||||||
|
prompt_cfg = getattr(kfc_config, "prompt", None)
|
||||||
|
max_entries = getattr(prompt_cfg, "max_activity_entries", 30) if prompt_cfg else 30
|
||||||
|
max_entry_length = getattr(prompt_cfg, "max_entry_length", 500) if prompt_cfg else 500
|
||||||
|
stream_format = (
|
||||||
|
getattr(prompt_cfg, "activity_stream_format", "narrative") if prompt_cfg else "narrative"
|
||||||
|
)
|
||||||
|
|
||||||
|
entries = session.get_recent_entries(limit=max_entries)
|
||||||
if not entries:
|
if not entries:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
parts = []
|
stream_format = (stream_format or "narrative").strip().lower()
|
||||||
|
if stream_format == "table":
|
||||||
|
return self._build_activity_stream_table(entries, user_name, max_entry_length)
|
||||||
|
if stream_format == "both":
|
||||||
|
table = self._build_activity_stream_table(entries, user_name, max_entry_length)
|
||||||
|
narrative = await self._build_activity_stream_narrative(entries, user_name)
|
||||||
|
return "\n\n".join([p for p in (table, narrative) if p])
|
||||||
|
|
||||||
|
return await self._build_activity_stream_narrative(entries, user_name)
|
||||||
|
|
||||||
|
async def _build_activity_stream_narrative(
|
||||||
|
self,
|
||||||
|
entries: list[MentalLogEntry],
|
||||||
|
user_name: str,
|
||||||
|
) -> str:
|
||||||
|
"""构建线性叙事活动流(旧格式)"""
|
||||||
|
parts: list[str] = []
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
part = await self._format_entry(entry, user_name)
|
part = await self._format_entry(entry, user_name)
|
||||||
if part:
|
if part:
|
||||||
@@ -556,6 +617,95 @@ class PromptBuilder:
|
|||||||
|
|
||||||
return "\n\n".join(parts)
|
return "\n\n".join(parts)
|
||||||
|
|
||||||
|
def _build_activity_stream_table(
|
||||||
|
self,
|
||||||
|
entries: list[MentalLogEntry],
|
||||||
|
user_name: str,
|
||||||
|
max_cell_length: int = 500,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
构建结构化表格活动流(更高信息密度)
|
||||||
|
|
||||||
|
统一列:序号 / 时间 / 事件类型 / 内容 / 想法 / 行动 / 结果
|
||||||
|
"""
|
||||||
|
|
||||||
|
def truncate(text: str, limit: int) -> str:
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
if limit <= 0:
|
||||||
|
return text
|
||||||
|
text = text.strip()
|
||||||
|
return text if len(text) <= limit else (text[: max(0, limit - 1)] + "…")
|
||||||
|
|
||||||
|
def md_cell(value: str) -> str:
|
||||||
|
value = (value or "").replace("\r\n", "\n").replace("\n", "<br>")
|
||||||
|
value = value.replace("|", "\\|")
|
||||||
|
return truncate(value, max_cell_length)
|
||||||
|
|
||||||
|
event_type_alias = {
|
||||||
|
EventType.USER_MESSAGE: "用户消息",
|
||||||
|
EventType.BOT_PLANNING: "你的决策",
|
||||||
|
EventType.WAITING_UPDATE: "等待中",
|
||||||
|
EventType.PROACTIVE_TRIGGER: "主动触发",
|
||||||
|
}
|
||||||
|
|
||||||
|
header = ["#", "时间", "类型", "内容", "想法", "行动", "结果"]
|
||||||
|
lines = [
|
||||||
|
"|" + "|".join(header) + "|",
|
||||||
|
"|" + "|".join(["---"] * len(header)) + "|",
|
||||||
|
]
|
||||||
|
|
||||||
|
for idx, entry in enumerate(entries, 1):
|
||||||
|
time_str = entry.get_time_str()
|
||||||
|
type_str = event_type_alias.get(entry.event_type, str(entry.event_type))
|
||||||
|
|
||||||
|
content = ""
|
||||||
|
thought = ""
|
||||||
|
action = ""
|
||||||
|
result = ""
|
||||||
|
|
||||||
|
if entry.event_type == EventType.USER_MESSAGE:
|
||||||
|
content = entry.content
|
||||||
|
reply_status = entry.metadata.get("reply_status")
|
||||||
|
if reply_status in ("in_time", "late"):
|
||||||
|
elapsed_min = entry.metadata.get("elapsed_seconds", 0) / 60
|
||||||
|
max_wait_min = entry.metadata.get("max_wait_seconds", 0) / 60
|
||||||
|
status_cn = "及时" if reply_status == "in_time" else "迟到"
|
||||||
|
result = f"回复{status_cn}(等{elapsed_min:.1f}/{max_wait_min:.1f}分钟)"
|
||||||
|
|
||||||
|
elif entry.event_type == EventType.BOT_PLANNING:
|
||||||
|
thought = entry.thought or "(无)"
|
||||||
|
action = self._format_actions(entry.actions)
|
||||||
|
if entry.max_wait_seconds > 0:
|
||||||
|
wait_min = entry.max_wait_seconds / 60
|
||||||
|
expected = entry.expected_reaction or "(无)"
|
||||||
|
result = f"等待≤{wait_min:.1f}分钟;期待={expected}"
|
||||||
|
else:
|
||||||
|
result = "不等待"
|
||||||
|
|
||||||
|
elif entry.event_type == EventType.WAITING_UPDATE:
|
||||||
|
thought = entry.waiting_thought or "还在等…"
|
||||||
|
elapsed_min = entry.elapsed_seconds / 60
|
||||||
|
mood = (entry.mood or "").strip()
|
||||||
|
result = f"已等{elapsed_min:.1f}分钟" + (f";心情={mood}" if mood else "")
|
||||||
|
|
||||||
|
elif entry.event_type == EventType.PROACTIVE_TRIGGER:
|
||||||
|
silence = entry.metadata.get("silence_duration", "一段时间")
|
||||||
|
result = f"沉默{silence}"
|
||||||
|
|
||||||
|
row = [
|
||||||
|
str(idx),
|
||||||
|
md_cell(time_str),
|
||||||
|
md_cell(type_str),
|
||||||
|
md_cell(content),
|
||||||
|
md_cell(thought),
|
||||||
|
md_cell(action),
|
||||||
|
md_cell(result),
|
||||||
|
]
|
||||||
|
lines.append("|" + "|".join(row) + "|")
|
||||||
|
|
||||||
|
return "(结构化活动流表;按时间顺序)\n" + "\n".join(lines)
|
||||||
|
|
||||||
async def _format_entry(self, entry: MentalLogEntry, user_name: str) -> str:
|
async def _format_entry(self, entry: MentalLogEntry, user_name: str) -> str:
|
||||||
"""格式化单个活动日志条目"""
|
"""格式化单个活动日志条目"""
|
||||||
|
|
||||||
@@ -661,6 +811,7 @@ class PromptBuilder:
|
|||||||
) -> str:
|
) -> str:
|
||||||
"""构建当前情况描述"""
|
"""构建当前情况描述"""
|
||||||
current_time = datetime.now().strftime("%Y年%m月%d日 %H:%M")
|
current_time = datetime.now().strftime("%Y年%m月%d日 %H:%M")
|
||||||
|
last_action_block = self._build_last_bot_action_block(session)
|
||||||
|
|
||||||
# 如果之前没有设置等待时间(max_wait_seconds == 0),视为 new_message
|
# 如果之前没有设置等待时间(max_wait_seconds == 0),视为 new_message
|
||||||
if situation_type in ("reply_in_time", "reply_late"):
|
if situation_type in ("reply_in_time", "reply_late"):
|
||||||
@@ -674,6 +825,7 @@ class PromptBuilder:
|
|||||||
return await global_prompt_manager.format_prompt(
|
return await global_prompt_manager.format_prompt(
|
||||||
PROMPT_NAMES["situation_new_message"],
|
PROMPT_NAMES["situation_new_message"],
|
||||||
current_time=current_time,
|
current_time=current_time,
|
||||||
|
last_action_block=last_action_block,
|
||||||
user_name=user_name,
|
user_name=user_name,
|
||||||
latest_message=latest_message,
|
latest_message=latest_message,
|
||||||
)
|
)
|
||||||
@@ -685,6 +837,7 @@ class PromptBuilder:
|
|||||||
return await global_prompt_manager.format_prompt(
|
return await global_prompt_manager.format_prompt(
|
||||||
PROMPT_NAMES["situation_reply_in_time"],
|
PROMPT_NAMES["situation_reply_in_time"],
|
||||||
current_time=current_time,
|
current_time=current_time,
|
||||||
|
last_action_block=last_action_block,
|
||||||
user_name=user_name,
|
user_name=user_name,
|
||||||
elapsed_minutes=elapsed / 60,
|
elapsed_minutes=elapsed / 60,
|
||||||
max_wait_minutes=max_wait / 60,
|
max_wait_minutes=max_wait / 60,
|
||||||
@@ -698,6 +851,7 @@ class PromptBuilder:
|
|||||||
return await global_prompt_manager.format_prompt(
|
return await global_prompt_manager.format_prompt(
|
||||||
PROMPT_NAMES["situation_reply_late"],
|
PROMPT_NAMES["situation_reply_late"],
|
||||||
current_time=current_time,
|
current_time=current_time,
|
||||||
|
last_action_block=last_action_block,
|
||||||
user_name=user_name,
|
user_name=user_name,
|
||||||
elapsed_minutes=elapsed / 60,
|
elapsed_minutes=elapsed / 60,
|
||||||
max_wait_minutes=max_wait / 60,
|
max_wait_minutes=max_wait / 60,
|
||||||
@@ -743,6 +897,7 @@ class PromptBuilder:
|
|||||||
return await global_prompt_manager.format_prompt(
|
return await global_prompt_manager.format_prompt(
|
||||||
PROMPT_NAMES["situation_timeout"],
|
PROMPT_NAMES["situation_timeout"],
|
||||||
current_time=current_time,
|
current_time=current_time,
|
||||||
|
last_action_block=last_action_block,
|
||||||
user_name=user_name,
|
user_name=user_name,
|
||||||
elapsed_minutes=elapsed / 60,
|
elapsed_minutes=elapsed / 60,
|
||||||
max_wait_minutes=max_wait / 60,
|
max_wait_minutes=max_wait / 60,
|
||||||
@@ -756,6 +911,7 @@ class PromptBuilder:
|
|||||||
return await global_prompt_manager.format_prompt(
|
return await global_prompt_manager.format_prompt(
|
||||||
PROMPT_NAMES["situation_proactive"],
|
PROMPT_NAMES["situation_proactive"],
|
||||||
current_time=current_time,
|
current_time=current_time,
|
||||||
|
last_action_block=last_action_block,
|
||||||
user_name=user_name,
|
user_name=user_name,
|
||||||
silence_duration=silence,
|
silence_duration=silence,
|
||||||
trigger_reason=trigger_reason,
|
trigger_reason=trigger_reason,
|
||||||
@@ -766,6 +922,7 @@ class PromptBuilder:
|
|||||||
PROMPT_NAMES["situation_new_message"],
|
PROMPT_NAMES["situation_new_message"],
|
||||||
current_time=current_time,
|
current_time=current_time,
|
||||||
user_name=user_name,
|
user_name=user_name,
|
||||||
|
last_action_block=last_action_block,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _build_actions_block(self, available_actions: dict | None) -> str:
|
def _build_actions_block(self, available_actions: dict | None) -> str:
|
||||||
@@ -926,15 +1083,17 @@ class PromptBuilder:
|
|||||||
"""
|
"""
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
current_time = datetime.now().strftime("%Y年%m月%d日 %H:%M")
|
current_time = datetime.now().strftime("%Y年%m月%d日 %H:%M")
|
||||||
|
last_action_block = self._build_last_bot_action_block(session)
|
||||||
|
|
||||||
if situation_type == "new_message":
|
if situation_type == "new_message":
|
||||||
return f"现在是 {current_time}。{user_name} 刚给你发了消息。"
|
return f"现在是 {current_time}。\n\n{last_action_block}{user_name} 刚给你发了消息。"
|
||||||
|
|
||||||
elif situation_type == "reply_in_time":
|
elif situation_type == "reply_in_time":
|
||||||
elapsed = session.waiting_config.get_elapsed_seconds()
|
elapsed = session.waiting_config.get_elapsed_seconds()
|
||||||
max_wait = session.waiting_config.max_wait_seconds
|
max_wait = session.waiting_config.max_wait_seconds
|
||||||
return (
|
return (
|
||||||
f"现在是 {current_time}。\n"
|
f"现在是 {current_time}。\n\n"
|
||||||
|
f"{last_action_block}"
|
||||||
f"你之前发了消息后在等 {user_name} 的回复。"
|
f"你之前发了消息后在等 {user_name} 的回复。"
|
||||||
f"等了大约 {elapsed / 60:.1f} 分钟(你原本打算最多等 {max_wait / 60:.1f} 分钟)。"
|
f"等了大约 {elapsed / 60:.1f} 分钟(你原本打算最多等 {max_wait / 60:.1f} 分钟)。"
|
||||||
f"现在 {user_name} 回复了!"
|
f"现在 {user_name} 回复了!"
|
||||||
@@ -944,7 +1103,8 @@ class PromptBuilder:
|
|||||||
elapsed = session.waiting_config.get_elapsed_seconds()
|
elapsed = session.waiting_config.get_elapsed_seconds()
|
||||||
max_wait = session.waiting_config.max_wait_seconds
|
max_wait = session.waiting_config.max_wait_seconds
|
||||||
return (
|
return (
|
||||||
f"现在是 {current_time}。\n"
|
f"现在是 {current_time}。\n\n"
|
||||||
|
f"{last_action_block}"
|
||||||
f"你之前发了消息后在等 {user_name} 的回复。"
|
f"你之前发了消息后在等 {user_name} 的回复。"
|
||||||
f"你原本打算最多等 {max_wait / 60:.1f} 分钟,但实际等了 {elapsed / 60:.1f} 分钟才收到回复。"
|
f"你原本打算最多等 {max_wait / 60:.1f} 分钟,但实际等了 {elapsed / 60:.1f} 分钟才收到回复。"
|
||||||
f"虽然有点迟,但 {user_name} 终于回复了。"
|
f"虽然有点迟,但 {user_name} 终于回复了。"
|
||||||
@@ -954,7 +1114,8 @@ class PromptBuilder:
|
|||||||
elapsed = session.waiting_config.get_elapsed_seconds()
|
elapsed = session.waiting_config.get_elapsed_seconds()
|
||||||
max_wait = session.waiting_config.max_wait_seconds
|
max_wait = session.waiting_config.max_wait_seconds
|
||||||
return (
|
return (
|
||||||
f"现在是 {current_time}。\n"
|
f"现在是 {current_time}。\n\n"
|
||||||
|
f"{last_action_block}"
|
||||||
f"你之前发了消息后一直在等 {user_name} 的回复。"
|
f"你之前发了消息后一直在等 {user_name} 的回复。"
|
||||||
f"你原本打算最多等 {max_wait / 60:.1f} 分钟,现在已经等了 {elapsed / 60:.1f} 分钟了,对方还是没回。"
|
f"你原本打算最多等 {max_wait / 60:.1f} 分钟,现在已经等了 {elapsed / 60:.1f} 分钟了,对方还是没回。"
|
||||||
f"你决定主动说点什么。"
|
f"你决定主动说点什么。"
|
||||||
@@ -963,13 +1124,14 @@ class PromptBuilder:
|
|||||||
elif situation_type == "proactive":
|
elif situation_type == "proactive":
|
||||||
silence = extra_context.get("silence_duration", "一段时间")
|
silence = extra_context.get("silence_duration", "一段时间")
|
||||||
return (
|
return (
|
||||||
f"现在是 {current_time}。\n"
|
f"现在是 {current_time}。\n\n"
|
||||||
|
f"{last_action_block}"
|
||||||
f"你和 {user_name} 已经有一段时间没聊天了(沉默了 {silence})。"
|
f"你和 {user_name} 已经有一段时间没聊天了(沉默了 {silence})。"
|
||||||
f"你决定主动找 {user_name} 聊点什么。"
|
f"你决定主动找 {user_name} 聊点什么。"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 默认
|
# 默认
|
||||||
return f"现在是 {current_time}。"
|
return f"现在是 {current_time}。\n\n{last_action_block}".rstrip()
|
||||||
|
|
||||||
async def _build_reply_context(
|
async def _build_reply_context(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ kfc_MAIN_PROMPT = Prompt(
|
|||||||
{tool_info}
|
{tool_info}
|
||||||
|
|
||||||
# 你们之间最近的活动记录
|
# 你们之间最近的活动记录
|
||||||
以下是你和 {user_name} 最近的互动历史,按时间顺序记录了你们的对话和你的心理活动:
|
以下是你和 {user_name} 最近的互动历史,按时间顺序记录了你们的对话和你的心理活动(可能是线性叙事或结构化表格):
|
||||||
{activity_stream}
|
{activity_stream}
|
||||||
|
|
||||||
# 聊天历史总览
|
# 聊天历史总览
|
||||||
@@ -69,7 +69,7 @@ kfc_OUTPUT_FORMAT = Prompt(
|
|||||||
{{"type": "动作名称", ...动作参数}}
|
{{"type": "动作名称", ...动作参数}}
|
||||||
],
|
],
|
||||||
"expected_reaction": "你期待对方的反应是什么",
|
"expected_reaction": "你期待对方的反应是什么",
|
||||||
- `max_wait_seconds`:预估的等待时间(秒),请根据对话节奏来判断。通常你应该设置为0避免总是等待显得聒噪,但是当你觉得你需要等待对方回复时,可以设置一个合理的等待时间。
|
"max_wait_seconds": 0
|
||||||
}}
|
}}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -93,7 +93,7 @@ kfc_SITUATION_NEW_MESSAGE = Prompt(
|
|||||||
name="kfc_situation_new_message",
|
name="kfc_situation_new_message",
|
||||||
template="""现在是 {current_time}。
|
template="""现在是 {current_time}。
|
||||||
|
|
||||||
{user_name} 刚刚给你发了消息:「{latest_message}」
|
{last_action_block}{user_name} 刚刚给你发了消息:「{latest_message}」
|
||||||
|
|
||||||
这是一次新的对话发起(不是对你之前消息的回复)。
|
这是一次新的对话发起(不是对你之前消息的回复)。
|
||||||
|
|
||||||
@@ -108,7 +108,7 @@ kfc_SITUATION_REPLY_IN_TIME = Prompt(
|
|||||||
name="kfc_situation_reply_in_time",
|
name="kfc_situation_reply_in_time",
|
||||||
template="""现在是 {current_time}。
|
template="""现在是 {current_time}。
|
||||||
|
|
||||||
你之前发了消息后一直在等 {user_name} 的回复。
|
{last_action_block}你之前发了消息后一直在等 {user_name} 的回复。
|
||||||
等了大约 {elapsed_minutes:.1f} 分钟(你原本打算最多等 {max_wait_minutes:.1f} 分钟)。
|
等了大约 {elapsed_minutes:.1f} 分钟(你原本打算最多等 {max_wait_minutes:.1f} 分钟)。
|
||||||
现在 {user_name} 回复了:「{latest_message}」
|
现在 {user_name} 回复了:「{latest_message}」
|
||||||
|
|
||||||
@@ -119,7 +119,7 @@ kfc_SITUATION_REPLY_LATE = Prompt(
|
|||||||
name="kfc_situation_reply_late",
|
name="kfc_situation_reply_late",
|
||||||
template="""现在是 {current_time}。
|
template="""现在是 {current_time}。
|
||||||
|
|
||||||
你之前发了消息后在等 {user_name} 的回复。
|
{last_action_block}你之前发了消息后在等 {user_name} 的回复。
|
||||||
你原本打算最多等 {max_wait_minutes:.1f} 分钟,但实际等了 {elapsed_minutes:.1f} 分钟才收到回复。
|
你原本打算最多等 {max_wait_minutes:.1f} 分钟,但实际等了 {elapsed_minutes:.1f} 分钟才收到回复。
|
||||||
虽然有点迟,但 {user_name} 终于回复了:「{latest_message}」
|
虽然有点迟,但 {user_name} 终于回复了:「{latest_message}」
|
||||||
|
|
||||||
@@ -130,7 +130,7 @@ kfc_SITUATION_TIMEOUT = Prompt(
|
|||||||
name="kfc_situation_timeout",
|
name="kfc_situation_timeout",
|
||||||
template="""现在是 {current_time}。
|
template="""现在是 {current_time}。
|
||||||
|
|
||||||
你之前发了消息后一直在等 {user_name} 的回复。
|
{last_action_block}你之前发了消息后一直在等 {user_name} 的回复。
|
||||||
你原本打算最多等 {max_wait_minutes:.1f} 分钟,现在已经等了 {elapsed_minutes:.1f} 分钟了,对方还是没回。
|
你原本打算最多等 {max_wait_minutes:.1f} 分钟,现在已经等了 {elapsed_minutes:.1f} 分钟了,对方还是没回。
|
||||||
你当时期待的反应是:"{expected_reaction}"
|
你当时期待的反应是:"{expected_reaction}"
|
||||||
{timeout_context}
|
{timeout_context}
|
||||||
@@ -161,7 +161,7 @@ kfc_SITUATION_PROACTIVE = Prompt(
|
|||||||
name="kfc_situation_proactive",
|
name="kfc_situation_proactive",
|
||||||
template="""现在是 {current_time}。
|
template="""现在是 {current_time}。
|
||||||
|
|
||||||
你和 {user_name} 已经有一段时间没聊天了(沉默了 {silence_duration})。
|
{last_action_block}你和 {user_name} 已经有一段时间没聊天了(沉默了 {silence_duration})。
|
||||||
{trigger_reason}
|
{trigger_reason}
|
||||||
|
|
||||||
你在想要不要主动找 {user_name} 聊点什么。
|
你在想要不要主动找 {user_name} 聊点什么。
|
||||||
@@ -251,7 +251,7 @@ kfc_PLANNER_OUTPUT_FORMAT = Prompt(
|
|||||||
{{"type": "动作名称", ...动作参数}}
|
{{"type": "动作名称", ...动作参数}}
|
||||||
],
|
],
|
||||||
"expected_reaction": "你期待对方的反应是什么",
|
"expected_reaction": "你期待对方的反应是什么",
|
||||||
- `max_wait_seconds`:预估的等待时间(秒),请根据对话节奏来判断。通常你应该设置为0避免总是等待显得聒噪,但是当你觉得你需要等待对方回复时,可以设置一个合理的等待时间。
|
"max_wait_seconds": 0
|
||||||
}}
|
}}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -264,6 +264,7 @@ kfc_PLANNER_OUTPUT_FORMAT = Prompt(
|
|||||||
|
|
||||||
### 注意事项
|
### 注意事项
|
||||||
- 动作参数直接写在动作对象里,不需要 `action_data` 包装
|
- 动作参数直接写在动作对象里,不需要 `action_data` 包装
|
||||||
|
- **分离模式规则**:Planner 阶段禁止输出 `kfc_reply.content`(就算写了也会被系统忽略,回复内容由 Replyer 单独生成)
|
||||||
- 即使什么都不想做,也放一个 `{{"type": "do_nothing"}}`
|
- 即使什么都不想做,也放一个 `{{"type": "do_nothing"}}`
|
||||||
- 可以组合多个动作,比如先发消息再发表情""",
|
- 可以组合多个动作,比如先发消息再发表情""",
|
||||||
)
|
)
|
||||||
@@ -406,7 +407,7 @@ kfc_UNIFIED_OUTPUT_FORMAT = Prompt(
|
|||||||
{{"type": "kfc_reply", "content": "你的回复内容"}}
|
{{"type": "kfc_reply", "content": "你的回复内容"}}
|
||||||
],
|
],
|
||||||
"expected_reaction": "你期待对方的反应是什么",
|
"expected_reaction": "你期待对方的反应是什么",
|
||||||
- `max_wait_seconds`:预估的等待时间(秒),请根据对话节奏来判断。通常你应该设置为0避免总是等待显得聒噪,但是当你觉得你需要等待对方回复时,可以设置一个合理的等待时间。
|
"max_wait_seconds": 0
|
||||||
}}
|
}}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[inner]
|
[inner]
|
||||||
version = "8.0.0"
|
version = "8.0.1"
|
||||||
|
|
||||||
#----以下是给开发人员阅读的,如果你只是部署了MoFox-Bot,不需要阅读----
|
#----以下是给开发人员阅读的,如果你只是部署了MoFox-Bot,不需要阅读----
|
||||||
#如果你想要修改配置文件,请递增version的值
|
#如果你想要修改配置文件,请递增version的值
|
||||||
@@ -638,6 +638,20 @@ enable_continuous_thinking = true # 是否在等待期间启用心理活动更
|
|||||||
# 留空则不生效
|
# 留空则不生效
|
||||||
custom_decision_prompt = ""
|
custom_decision_prompt = ""
|
||||||
|
|
||||||
|
# --- 提示词/上下文构建配置 ---
|
||||||
|
[kokoro_flow_chatter.prompt]
|
||||||
|
# 活动流格式(你们之间最近发生的事)
|
||||||
|
# - "narrative": 线性叙事(更自然,但信息密度较低,长时更容易丢细节)
|
||||||
|
# - "table": 结构化表格(更高信息密度、更利于模型对齐字段;推荐)
|
||||||
|
# - "both": 同时输出表格 + 叙事(对照/调试用,token 更高)
|
||||||
|
activity_stream_format = "table"
|
||||||
|
|
||||||
|
# 活动流最多保留条数(越大越完整,但 token 越高)
|
||||||
|
max_activity_entries = 5
|
||||||
|
|
||||||
|
# 表格单元格/叙事单条的最大字符数(用于裁剪,避免某条过长拖垮上下文)
|
||||||
|
max_entry_length = 500
|
||||||
|
|
||||||
# --- 等待策略 ---
|
# --- 等待策略 ---
|
||||||
[kokoro_flow_chatter.waiting]
|
[kokoro_flow_chatter.waiting]
|
||||||
default_max_wait_seconds = 300 # LLM 未给出等待时间时的默认值
|
default_max_wait_seconds = 300 # LLM 未给出等待时间时的默认值
|
||||||
|
|||||||
Reference in New Issue
Block a user