Merge branch 'dev' into feature/kfc

This commit is contained in:
拾风
2025-12-01 16:06:47 +08:00
committed by GitHub
87 changed files with 6181 additions and 2355 deletions

View File

@@ -111,7 +111,7 @@ class ChatterManager:
inactive_streams = []
for stream_id, instance in self.instances.items():
if hasattr(instance, "get_activity_time"):
activity_time = instance.get_activity_time()
activity_time = getattr(instance, "get_activity_time")()
if (current_time - activity_time) > max_inactive_seconds:
inactive_streams.append(stream_id)

View File

@@ -9,7 +9,7 @@ import random
import re
import time
import traceback
from typing import Any, Optional
from typing import Any, Optional, cast
from PIL import Image
from rich.traceback import install
@@ -401,6 +401,11 @@ class EmojiManager:
self._scan_task = None
if model_config is None:
raise RuntimeError("Model config is not initialized")
if global_config is None:
raise RuntimeError("Global config is not initialized")
self.vlm = LLMRequest(model_set=model_config.model_task_config.emoji_vlm, request_type="emoji")
self.llm_emotion_judge = LLMRequest(
model_set=model_config.model_task_config.utils, request_type="emoji"
@@ -480,6 +485,8 @@ class EmojiManager:
return None
# 2. 根据全局配置决定候选表情包的数量
if global_config is None:
raise RuntimeError("Global config is not initialized")
max_candidates = global_config.emoji.max_context_emojis
# 如果配置为0或者大于等于总数则选择所有表情包
@@ -622,6 +629,8 @@ class EmojiManager:
async def start_periodic_check_register(self) -> None:
"""定期检查表情包完整性和数量"""
if global_config is None:
raise RuntimeError("Global config is not initialized")
await self.get_all_emoji_from_db()
while True:
# logger.info("[扫描] 开始检查表情包完整性...")
@@ -771,8 +780,9 @@ class EmojiManager:
try:
emoji_record = await self.get_emoji_from_db(emoji_hash)
if emoji_record and emoji_record[0].emotion:
logger.info(f"[缓存命中] 从数据库获取表情包描述: {emoji_record.emotion[:50]}...") # type: ignore # type: ignore
return emoji_record.emotion # type: ignore
emotion_str = ",".join(emoji_record[0].emotion)
logger.info(f"[缓存命中] 从数据库获取表情包描述: {emotion_str[:50]}...")
return emotion_str
except Exception as e:
logger.error(f"从数据库查询表情包描述时出错: {e}")
@@ -803,7 +813,7 @@ class EmojiManager:
try:
from src.common.database.api.query import QueryBuilder
emoji_record = await QueryBuilder(Emoji).filter(emoji_hash=emoji_hash).first()
emoji_record = cast(Emoji | None, await QueryBuilder(Emoji).filter(emoji_hash=emoji_hash).first())
if emoji_record and emoji_record.description:
logger.info(f"[缓存命中] 从数据库获取表情包描述: {emoji_record.description[:50]}...")
return emoji_record.description
@@ -880,6 +890,9 @@ class EmojiManager:
# 将表情包信息转换为可读的字符串
emoji_info_list = _emoji_objects_to_readable_list(selected_emojis)
if global_config is None:
raise RuntimeError("Global config is not initialized")
# 构建提示词
prompt = (
f"{global_config.bot.nickname}的表情包存储已满({self.emoji_num}/{self.emoji_num_max})"
@@ -954,6 +967,8 @@ class EmojiManager:
Tuple[str, List[str]]: 返回一个元组,第一个元素是详细描述,第二个元素是情感关键词列表。
如果处理失败,则返回空的描述和列表。
"""
if global_config is None:
raise RuntimeError("Global config is not initialized")
try:
# 1. 解码图片,计算哈希值,并获取格式
if isinstance(image_base64, str):
@@ -967,7 +982,7 @@ class EmojiManager:
try:
from src.common.database.api.query import QueryBuilder
existing_image = await QueryBuilder(Images).filter(emoji_hash=image_hash, type="emoji").first()
existing_image = cast(Images | None, await QueryBuilder(Images).filter(emoji_hash=image_hash, type="emoji").first())
if existing_image and existing_image.description:
existing_description = existing_image.description
logger.info(f"[复用描述] 找到已有详细描述: {existing_description[:50]}...")

View File

@@ -7,7 +7,7 @@ import time
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, TypedDict
from typing import Any, Awaitable, TypedDict, cast
from src.common.database.api.crud import CRUDBase
from src.common.logger import get_logger
@@ -70,7 +70,7 @@ class EnergyCalculator(ABC):
"""能量计算器抽象基类"""
@abstractmethod
def calculate(self, context: dict[str, Any]) -> float:
def calculate(self, context: "EnergyContext") -> float | Awaitable[float]:
"""计算能量值"""
pass
@@ -83,7 +83,7 @@ class EnergyCalculator(ABC):
class InterestEnergyCalculator(EnergyCalculator):
"""兴趣度能量计算器"""
def calculate(self, context: dict[str, Any]) -> float:
def calculate(self, context: "EnergyContext") -> float:
"""基于消息兴趣度计算能量"""
messages = context.get("messages", [])
if not messages:
@@ -117,7 +117,7 @@ class ActivityEnergyCalculator(EnergyCalculator):
def __init__(self):
self.action_weights = {"reply": 0.4, "react": 0.3, "mention": 0.2, "other": 0.1}
def calculate(self, context: dict[str, Any]) -> float:
def calculate(self, context: "EnergyContext") -> float:
"""基于活跃度计算能量"""
messages = context.get("messages", [])
if not messages:
@@ -147,7 +147,7 @@ class ActivityEnergyCalculator(EnergyCalculator):
class RecencyEnergyCalculator(EnergyCalculator):
"""最近性能量计算器"""
def calculate(self, context: dict[str, Any]) -> float:
def calculate(self, context: "EnergyContext") -> float:
"""基于最近性计算能量"""
messages = context.get("messages", [])
if not messages:
@@ -194,7 +194,7 @@ class RecencyEnergyCalculator(EnergyCalculator):
class RelationshipEnergyCalculator(EnergyCalculator):
"""关系能量计算器 - 基于聊天流兴趣度"""
async def calculate(self, context: dict[str, Any]) -> float:
async def calculate(self, context: "EnergyContext") -> float:
"""基于聊天流兴趣度计算能量"""
stream_id = context.get("stream_id")
if not stream_id:
@@ -260,6 +260,8 @@ class EnergyManager:
def _load_thresholds_from_config(self) -> None:
"""从配置加载AFC阈值"""
try:
if global_config is None:
return
if hasattr(global_config, "affinity_flow") and global_config.affinity_flow is not None:
self.thresholds["high_match"] = getattr(
global_config.affinity_flow, "high_match_interest_threshold", 0.8
@@ -283,17 +285,17 @@ class EnergyManager:
start_time = time.time()
# 更新统计
self.stats["total_calculations"] += 1
self.stats["total_calculations"] = cast(int, self.stats["total_calculations"]) + 1
# 检查缓存
if stream_id in self.energy_cache:
cached_energy, cached_time = self.energy_cache[stream_id]
if time.time() - cached_time < self.cache_ttl:
self.stats["cache_hits"] += 1
self.stats["cache_hits"] = cast(int, self.stats["cache_hits"]) + 1
logger.debug(f"使用缓存能量: {stream_id} = {cached_energy:.3f}")
return cached_energy
else:
self.stats["cache_misses"] += 1
self.stats["cache_misses"] = cast(int, self.stats["cache_misses"]) + 1
# 构建计算上下文
context: EnergyContext = {
@@ -358,9 +360,10 @@ class EnergyManager:
# 更新平均计算时间
calculation_time = time.time() - start_time
total_calculations = self.stats["total_calculations"]
total_calculations = cast(int, self.stats["total_calculations"])
current_avg = cast(float, self.stats["average_calculation_time"])
self.stats["average_calculation_time"] = (
self.stats["average_calculation_time"] * (total_calculations - 1) + calculation_time
current_avg * (total_calculations - 1) + calculation_time
) / total_calculations
logger.debug(
@@ -424,8 +427,11 @@ class EnergyManager:
final_interval = base_interval * jitter
# 确保在配置范围内
min_interval = getattr(global_config.chat, "dynamic_distribution_min_interval", 1.0)
max_interval = getattr(global_config.chat, "dynamic_distribution_max_interval", 60.0)
min_interval = 1.0
max_interval = 60.0
if global_config is not None and hasattr(global_config, "chat"):
min_interval = getattr(global_config.chat, "dynamic_distribution_min_interval", 1.0)
max_interval = getattr(global_config.chat, "dynamic_distribution_max_interval", 60.0)
return max(min_interval, min(max_interval, final_interval))
@@ -487,10 +493,12 @@ class EnergyManager:
def get_cache_hit_rate(self) -> float:
"""获取缓存命中率"""
total_requests = self.stats.get("cache_hits", 0) + self.stats.get("cache_misses", 0)
hits = cast(int, self.stats.get("cache_hits", 0))
misses = cast(int, self.stats.get("cache_misses", 0))
total_requests = hits + misses
if total_requests == 0:
return 0.0
return self.stats["cache_hits"] / total_requests
return hits / total_requests
# 全局能量管理器实例

View File

@@ -110,6 +110,8 @@ def init_prompt() -> None:
class ExpressionLearner:
def __init__(self, chat_id: str) -> None:
if model_config is None:
raise RuntimeError("Model config is not initialized")
self.express_learn_model: LLMRequest = LLMRequest(
model_set=model_config.model_task_config.replyer, request_type="expressor.learner"
)
@@ -143,7 +145,10 @@ class ExpressionLearner:
"""
# 从配置读取过期天数
if expiration_days is None:
expiration_days = global_config.expression.expiration_days
if global_config is None:
expiration_days = 30 # Default value if config is missing
else:
expiration_days = global_config.expression.expiration_days
current_time = time.time()
expiration_threshold = current_time - (expiration_days * 24 * 3600)
@@ -192,6 +197,8 @@ class ExpressionLearner:
bool: 是否允许学习
"""
try:
if global_config is None:
return False
use_expression, enable_learning, _ = global_config.expression.get_expression_config_for_chat(self.chat_id)
return enable_learning
except Exception as e:
@@ -212,6 +219,8 @@ class ExpressionLearner:
# 获取该聊天流的学习强度
try:
if global_config is None:
return False
use_expression, enable_learning, learning_intensity = (
global_config.expression.get_expression_config_for_chat(self.chat_id)
)
@@ -424,8 +433,10 @@ class ExpressionLearner:
group_name = f"聊天流 {chat_id}"
elif chat_stream.group_info:
group_name = chat_stream.group_info.group_name
else:
elif chat_stream.user_info and chat_stream.user_info.user_nickname:
group_name = f"{chat_stream.user_info.user_nickname}的私聊"
else:
group_name = f"聊天流 {chat_id}"
learnt_expressions_str = ""
for _chat_id, situation, style in learnt_expressions:
learnt_expressions_str += f"{situation}->{style}\n"

View File

@@ -78,6 +78,8 @@ def weighted_sample(population: list[dict], weights: list[float], k: int) -> lis
class ExpressionSelector:
def __init__(self, chat_id: str = ""):
self.chat_id = chat_id
if model_config is None:
raise RuntimeError("Model config is not initialized")
self.llm_model = LLMRequest(
model_set=model_config.model_task_config.utils_small, request_type="expression.selector"
)
@@ -94,6 +96,8 @@ class ExpressionSelector:
bool: 是否允许使用表达
"""
try:
if global_config is None:
return False
use_expression, _, _ = global_config.expression.get_expression_config_for_chat(chat_id)
return use_expression
except Exception as e:
@@ -122,6 +126,8 @@ class ExpressionSelector:
def get_related_chat_ids(self, chat_id: str) -> list[str]:
"""根据expression.rules配置获取与当前chat_id相关的所有chat_id包括自身"""
if global_config is None:
return [chat_id]
rules = global_config.expression.rules
current_group = None
@@ -280,6 +286,9 @@ class ExpressionSelector:
else:
chat_info = chat_history
if global_config is None:
raise RuntimeError("Global config is not initialized")
# 根据配置选择模式
mode = global_config.expression.mode
logger.debug(f"使用表达选择模式: {mode}")
@@ -582,6 +591,9 @@ class ExpressionSelector:
target_message_str = ""
target_message_extra_block = ""
if global_config is None:
raise RuntimeError("Global config is not initialized")
# 3. 构建prompt只包含情境不包含完整的表达方式
prompt = (await global_prompt_manager.get_prompt_async("expression_evaluation_prompt")).format(
bot_name=global_config.bot.nickname,

View File

@@ -42,6 +42,8 @@ class SituationExtractor:
"""情境提取器,从聊天历史中提取当前情境"""
def __init__(self):
if model_config is None:
raise RuntimeError("Model config is not initialized")
self.llm_model = LLMRequest(
model_set=model_config.model_task_config.utils_small,
request_type="expression.situation_extractor"
@@ -81,6 +83,8 @@ class SituationExtractor:
# 构建 prompt
try:
if global_config is None:
raise RuntimeError("Global config is not initialized")
prompt = (await global_prompt_manager.get_prompt_async("situation_extraction_prompt")).format(
bot_name=global_config.bot.nickname,
chat_history=chat_info,

View File

@@ -5,7 +5,7 @@
import traceback
from datetime import datetime
from typing import Any
from typing import Any, cast
import numpy as np
from sqlalchemy import select
@@ -77,6 +77,9 @@ class BotInterestManager:
from src.config.config import model_config
from src.llm_models.utils_model import LLMRequest
if model_config is None:
raise RuntimeError("Model config is not initialized")
# 检查embedding配置是否存在
if not hasattr(model_config.model_task_config, "embedding"):
raise RuntimeError("❌ 未找到embedding模型配置")
@@ -251,6 +254,9 @@ class BotInterestManager:
from src.config.config import model_config
from src.plugin_system.apis import llm_api
if model_config is None:
raise RuntimeError("Model config is not initialized")
# 构建完整的提示词明确要求只返回纯JSON
full_prompt = f"""你是一个专业的机器人人设分析师,擅长根据人设描述生成合适的兴趣标签。
@@ -348,9 +354,15 @@ class BotInterestManager:
embedding, model_name = await self.embedding_request.get_embedding(text)
if embedding and len(embedding) > 0:
self.embedding_cache[text] = embedding
if isinstance(embedding[0], list):
# If it's a list of lists, take the first one (though get_embedding(str) should return list[float])
embedding = embedding[0]
# Now we can safely cast to list[float] as we've handled the nested list case
embedding_float = cast(list[float], embedding)
self.embedding_cache[text] = embedding_float
current_dim = len(embedding)
current_dim = len(embedding_float)
if self._detected_embedding_dimension is None:
self._detected_embedding_dimension = current_dim
if self.embedding_dimension and self.embedding_dimension != current_dim:
@@ -367,7 +379,7 @@ class BotInterestManager:
self.embedding_dimension,
current_dim,
)
return embedding
return embedding_float
else:
raise RuntimeError(f"❌ 返回的embedding为空: {embedding}")
@@ -416,7 +428,10 @@ class BotInterestManager:
for idx_offset, message_id in enumerate(chunk_keys):
vector = normalized[idx_offset] if idx_offset < len(normalized) else []
results[message_id] = vector
if isinstance(vector, list) and vector and isinstance(vector[0], float):
results[message_id] = cast(list[float], vector)
else:
results[message_id] = []
return results
@@ -493,6 +508,9 @@ class BotInterestManager:
medium_similarity_count = 0
low_similarity_count = 0
if global_config is None:
raise RuntimeError("Global config is not initialized")
# 分级相似度阈值 - 优化后可以提高阈值,因为匹配更准确了
affinity_config = global_config.affinity_flow
high_threshold = affinity_config.high_match_interest_threshold
@@ -711,6 +729,9 @@ class BotInterestManager:
if not keywords or not matched_tags:
return {}
if global_config is None:
return {}
affinity_config = global_config.affinity_flow
bonus_dict = {}
@@ -1010,7 +1031,10 @@ class BotInterestManager:
# 验证缓存版本和embedding模型
cache_version = cache_data.get("version", 1)
cache_embedding_model = cache_data.get("embedding_model", "")
current_embedding_model = self.embedding_config.model_list[0] if hasattr(self.embedding_config, "model_list") else ""
current_embedding_model = ""
if self.embedding_config and hasattr(self.embedding_config, "model_list") and self.embedding_config.model_list:
current_embedding_model = self.embedding_config.model_list[0]
if cache_embedding_model != current_embedding_model:
logger.warning(f"⚠️ Embedding模型已变更 ({cache_embedding_model}{current_embedding_model}),忽略旧缓存")
@@ -1044,7 +1068,10 @@ class BotInterestManager:
cache_file = cache_dir / f"{personality_id}_embeddings.json"
# 准备缓存数据
current_embedding_model = self.embedding_config.model_list[0] if hasattr(self.embedding_config, "model_list") and self.embedding_config.model_list else ""
current_embedding_model = ""
if self.embedding_config and hasattr(self.embedding_config, "model_list") and self.embedding_config.model_list:
current_embedding_model = self.embedding_config.model_list[0]
cache_data = {
"version": 1,
"personality_id": personality_id,

View File

@@ -144,6 +144,15 @@ class InterestManager:
start_time = time.time()
self._total_calculations += 1
if not self._current_calculator:
return InterestCalculationResult(
success=False,
message_id=getattr(message, "message_id", ""),
interest_value=0.0,
error_message="没有可用的兴趣值计算组件",
calculation_time=time.time() - start_time,
)
try:
# 使用组件的安全执行方法
result = await self._current_calculator._safe_execute(message)

View File

@@ -2,6 +2,7 @@ import asyncio
import math
import os
from dataclasses import dataclass
from typing import Any
# import tqdm
import aiofiles
@@ -121,7 +122,7 @@ class EmbeddingStore:
self.store = {}
self.faiss_index = None
self.faiss_index: Any = None
self.idx2hash = None
@staticmethod
@@ -158,6 +159,8 @@ class EmbeddingStore:
from src.config.config import model_config
from src.llm_models.utils_model import LLMRequest
assert model_config is not None
# 限制 chunk_size 和 max_workers 在合理范围内
chunk_size = max(MIN_CHUNK_SIZE, min(chunk_size, MAX_CHUNK_SIZE))
max_workers = max(MIN_WORKERS, min(max_workers, MAX_WORKERS))
@@ -402,6 +405,7 @@ class EmbeddingStore:
def build_faiss_index(self) -> None:
"""重新构建Faiss索引以余弦相似度为度量"""
assert global_config is not None
# 获取所有的embedding
array = []
self.idx2hash = {}

View File

@@ -1,5 +1,6 @@
import os
import time
from typing import cast
import numpy as np
import orjson
@@ -139,6 +140,9 @@ class KGManager:
embedding_manager: EmbeddingManager,
) -> int:
"""同义词连接"""
if global_config is None:
raise RuntimeError("Global config is not initialized")
new_edge_cnt = 0
# 获取所有实体节点的hash值
ent_hash_list = set()
@@ -242,7 +246,8 @@ class KGManager:
else:
# 已存在的边
edge_item = self.graph[src_tgt[0], src_tgt[1]]
edge_item["weight"] += weight
edge_item = cast(di_graph.DiEdge, edge_item)
edge_item["weight"] = cast(float, edge_item["weight"]) + weight
edge_item["update_time"] = now_time
self.graph.update_edge(edge_item)
@@ -258,6 +263,7 @@ class KGManager:
continue
assert isinstance(node, EmbeddingStoreItem)
node_item = self.graph[node_hash]
node_item = cast(di_graph.DiNode, node_item)
node_item["content"] = node.str
node_item["type"] = "ent"
node_item["create_time"] = now_time
@@ -271,6 +277,7 @@ class KGManager:
assert isinstance(node, EmbeddingStoreItem)
content = node.str.replace("\n", " ")
node_item = self.graph[node_hash]
node_item = cast(di_graph.DiNode, node_item)
node_item["content"] = content if len(content) < 8 else content[:8] + "..."
node_item["type"] = "pg"
node_item["create_time"] = now_time
@@ -326,6 +333,9 @@ class KGManager:
paragraph_search_result: ParagraphEmbedding的搜索结果paragraph_hash, similarity
embed_manager: EmbeddingManager对象
"""
if global_config is None:
raise RuntimeError("Global config is not initialized")
# 图中存在的节点总集
existed_nodes = self.graph.get_node_list()
@@ -339,9 +349,12 @@ class KGManager:
# 针对每个关系,提取出其中的主宾短语作为两个实体,并记录对应的三元组的相似度作为权重依据
ent_sim_scores = {}
for relation_hash, similarity, _ in relation_search_result:
for relation_hash, similarity in relation_search_result:
# 提取主宾短语
relation = embed_manager.relation_embedding_store.store.get(relation_hash).str
relation_item = embed_manager.relation_embedding_store.store.get(relation_hash)
if relation_item is None:
continue
relation = relation_item.str
assert relation is not None # 断言relation不为空
# 关系三元组
triple = relation[2:-2].split("', '")

View File

@@ -36,6 +36,9 @@ def initialize_lpmm_knowledge():
"""初始化LPMM知识库"""
global qa_manager, inspire_manager
if global_config is None:
raise RuntimeError("Global config is not initialized")
# 检查LPMM知识库是否启用
if global_config.lpmm_knowledge.enable:
logger.info("正在初始化Mai-LPMM")

View File

@@ -1,5 +1,5 @@
import time
from typing import Any
from typing import Any, cast
from src.chat.utils.utils import get_embedding
from src.config.config import global_config, model_config
@@ -21,6 +21,8 @@ class QAManager:
embed_manager: EmbeddingManager,
kg_manager: KGManager,
):
if model_config is None:
raise RuntimeError("Model config is not initialized")
self.embed_manager = embed_manager
self.kg_manager = kg_manager
self.qa_model = LLMRequest(model_set=model_config.model_task_config.lpmm_qa, request_type="lpmm.qa")
@@ -29,6 +31,8 @@ class QAManager:
self, question: str
) -> tuple[list[tuple[str, float, float]], dict[str, float] | None] | None:
"""处理查询"""
if global_config is None:
raise RuntimeError("Global config is not initialized")
# 生成问题的Embedding
part_start_time = time.perf_counter()
@@ -61,7 +65,7 @@ class QAManager:
for res in relation_search_res:
if store_item := self.embed_manager.relation_embedding_store.store.get(res[0]):
rel_str = store_item.str
print(f"找到相关关系,相似度:{(res[1] * 100):.2f}% - {rel_str}")
print(f"找到相关关系,相似度:{(res[1] * 100):.2f}% - {rel_str}")
# TODO: 使用LLM过滤三元组结果
# logger.info(f"LLM过滤三元组用时{time.time() - part_start_time:.2f}s")
@@ -80,8 +84,52 @@ class QAManager:
logger.info("找到相关关系将使用RAG进行检索")
# 使用KG检索
part_start_time = time.perf_counter()
# Cast relation_search_res to the expected type for kg_search
# The search_top_k returns list[tuple[Any, float, float]], but kg_search expects list[tuple[tuple[str, str, str], float]]
# We assume the ID (res[0]) in relation_search_res is actually a tuple[str, str, str] (the relation triple)
# or at least compatible. However, looking at kg_manager.py, it expects relation_hash (str) in relation_search_result?
# Wait, let's check kg_manager.py again.
# kg_search signature: relation_search_result: list[tuple[tuple[str, str, str], float]]
# But in kg_manager.py:
# for relation_hash, similarity, _ in relation_search_result:
# relation = embed_manager.relation_embedding_store.store.get(relation_hash).str
# This implies relation_search_result items are tuples of (relation_hash, similarity, ...)
# So the type hint in kg_manager.py might be wrong or I am misinterpreting it.
# The error says: "tuple[Any, float, float]" vs "tuple[tuple[str, str, str], float]"
# It seems kg_search expects the first element to be a tuple of strings?
# But the implementation uses it as a hash key to look up in store.
# Let's look at kg_manager.py again.
# In kg_manager.py:
# def kg_search(self, relation_search_result: list[tuple[tuple[str, str, str], float]], ...)
# ...
# for relation_hash, similarity in relation_search_result:
# relation_item = embed_manager.relation_embedding_store.store.get(relation_hash)
# Wait, I just fixed kg_manager.py to:
# for relation_hash, similarity in relation_search_result:
# So it expects a tuple of 2 elements?
# But search_top_k returns (id, score, vector).
# So relation_search_res is list[tuple[Any, float, float]].
# I need to adapt the data or cast it.
# If I pass it directly, it has 3 elements.
# If kg_manager expects 2, I should probably slice it.
# Let's cast it for now to silence the error, assuming the runtime behavior is compatible (unpacking first 2 of 3 is fine in python if not strict, but here it is strict unpacking in loop?)
# In kg_manager.py I changed it to:
# for relation_hash, similarity in relation_search_result:
# This will fail if the tuple has 3 elements! "too many values to unpack"
# So I should probably fix the data passed to kg_search to be list[tuple[str, float]].
relation_search_result_for_kg = [(str(res[0]), float(res[1])) for res in relation_search_res]
result, ppr_node_weights = self.kg_manager.kg_search(
relation_search_res, paragraph_search_res, self.embed_manager
cast(list[tuple[tuple[str, str, str], float]], relation_search_result_for_kg), # The type hint in kg_manager is weird, but let's match it or cast to Any
paragraph_search_res,
self.embed_manager
)
part_end_time = time.perf_counter()
logger.info(f"RAG检索用时{part_end_time - part_start_time:.5f}s")

View File

@@ -51,13 +51,13 @@ class BatchDatabaseWriter:
self.writer_task: asyncio.Task | None = None
# 统计信息
self.stats = {
self.stats: dict[str, int | float] = {
"total_writes": 0,
"batch_writes": 0,
"failed_writes": 0,
"queue_size": 0,
"avg_batch_size": 0,
"last_flush_time": 0,
"avg_batch_size": 0.0,
"last_flush_time": 0.0,
}
# 按优先级分类的批次
@@ -220,6 +220,9 @@ class BatchDatabaseWriter:
async def _batch_write_to_database(self, payloads: list[StreamUpdatePayload]):
"""批量写入数据库"""
if global_config is None:
raise RuntimeError("Global config is not initialized")
async with get_db_session() as session:
for payload in payloads:
stream_id = payload.stream_id
@@ -254,11 +257,11 @@ class BatchDatabaseWriter:
stmt = stmt.on_conflict_do_update(index_elements=["stream_id"], set_=update_data)
await session.execute(stmt)
await session.commit()
async def _direct_write(self, stream_id: str, update_data: dict[str, Any]):
"""直接写入数据库(降级方案)"""
if global_config is None:
raise RuntimeError("Global config is not initialized")
async with get_db_session() as session:
if global_config.database.database_type == "sqlite":
from sqlalchemy.dialects.sqlite import insert as sqlite_insert

View File

@@ -23,6 +23,9 @@ class StreamLoopManager:
"""流循环管理器 - 每个流一个独立的无限循环任务"""
def __init__(self, max_concurrent_streams: int | None = None):
if global_config is None:
raise RuntimeError("Global config is not initialized")
# 统计信息
self.stats: dict[str, Any] = {
"active_streams": 0,
@@ -570,7 +573,6 @@ class StreamLoopManager:
except Exception as e:
logger.warning(f"刷新StreamContext缓存失败: stream={stream_id}, error={e}")
return []
async def _update_stream_energy(self, stream_id: str, context: Any) -> None:
"""更新流的能量值
@@ -578,6 +580,9 @@ class StreamLoopManager:
stream_id: 流ID
context: 流上下文 (StreamContext)
"""
if global_config is None:
raise RuntimeError("Global config is not initialized")
try:
from src.chat.message_receive.chat_stream import get_chat_manager
@@ -635,6 +640,9 @@ class StreamLoopManager:
Returns:
float: 间隔时间(秒)
"""
if global_config is None:
raise RuntimeError("Global config is not initialized")
# 基础间隔
base_interval = getattr(global_config.chat, "distribution_interval", 5.0)

View File

@@ -66,7 +66,7 @@ class GlobalNoticeManager:
self._last_cleanup_time = time.time()
# 统计信息
self.stats = {
self.stats: dict[str, Any] = {
"total_notices": 0,
"public_notices": 0,
"stream_notices": 0,

View File

@@ -277,6 +277,9 @@ class MessageManager:
async def _check_and_handle_interruption(self, chat_stream: "ChatStream | None" = None, message: DatabaseMessages | None = None):
"""检查并处理消息打断 - 通过取消 stream_loop_task 实现"""
if global_config is None:
raise RuntimeError("Global config is not initialized")
if not global_config.chat.interruption_enabled or not chat_stream or not message:
return

View File

@@ -240,6 +240,9 @@ class ChatStream:
async def calculate_focus_energy(self) -> float:
"""异步计算focus_energy"""
if global_config is None:
raise RuntimeError("Global config is not initialized")
try:
# 使用单流上下文管理器获取消息
all_messages = self.context.get_messages(limit=global_config.chat.max_context_size)
@@ -629,6 +632,9 @@ class ChatManager:
# 回退到原始方法(最终方案)
async def _db_save_stream_async(s_data_dict: dict):
if global_config is None:
raise RuntimeError("Global config is not initialized")
async with get_db_session() as session:
user_info_d = s_data_dict.get("user_info")
group_info_d = s_data_dict.get("group_info")

View File

@@ -30,7 +30,7 @@ from __future__ import annotations
import os
import re
import traceback
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, cast
from mofox_wire import MessageEnvelope, MessageRuntime
@@ -55,6 +55,8 @@ PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))
def _check_ban_words(text: str, chat: "ChatStream", userinfo) -> bool:
"""检查消息是否包含过滤词"""
if global_config is None:
return False
for word in global_config.message_receive.ban_words:
if word in text:
chat_name = chat.group_info.group_name if chat.group_info else "私聊"
@@ -62,10 +64,10 @@ def _check_ban_words(text: str, chat: "ChatStream", userinfo) -> bool:
logger.info(f"[过滤词识别]消息中含有{word}filtered")
return True
return False
def _check_ban_regex(text: str, chat: "ChatStream", userinfo) -> bool:
"""检查消息是否匹配过滤正则表达式"""
if global_config is None:
return False
for pattern in global_config.message_receive.ban_msgs_regex:
if re.search(pattern, text):
chat_name = chat.group_info.group_name if chat.group_info else "私聊"
@@ -281,8 +283,8 @@ class MessageHandler:
from src.chat.message_receive.chat_stream import get_chat_manager
chat = await get_chat_manager().get_or_create_stream(
platform=platform,
user_info=DatabaseUserInfo.from_dict(user_info) if user_info else None, # type: ignore
group_info=DatabaseGroupInfo.from_dict(group_info) if group_info else None,
user_info=DatabaseUserInfo.from_dict(cast(dict[str, Any], user_info)) if user_info else None, # type: ignore
group_info=DatabaseGroupInfo.from_dict(cast(dict[str, Any], group_info)) if group_info else None,
)
# 将消息信封转换为 DatabaseMessages
@@ -431,8 +433,8 @@ class MessageHandler:
from src.chat.message_receive.chat_stream import get_chat_manager
chat = await get_chat_manager().get_or_create_stream(
platform=platform,
user_info=DatabaseUserInfo.from_dict(user_info) if user_info else None, # type: ignore
group_info=DatabaseGroupInfo.from_dict(group_info) if group_info else None,
user_info=DatabaseUserInfo.from_dict(cast(dict[str, Any], user_info)) if user_info else None, # type: ignore
group_info=DatabaseGroupInfo.from_dict(cast(dict[str, Any], group_info)) if group_info else None,
)
# 将消息信封转换为 DatabaseMessages
@@ -536,6 +538,8 @@ class MessageHandler:
text = message.processed_plain_text or ""
# 获取配置的命令前缀
if global_config is None:
return False, None, True
prefixes = global_config.command.command_prefixes
# 检查是否以任何前缀开头
@@ -704,6 +708,9 @@ class MessageHandler:
async def _preprocess_message(self, message: DatabaseMessages, chat: "ChatStream") -> None:
"""预处理消息:存储、情绪更新等"""
try:
if global_config is None:
return
group_info = chat.group_info
# 检查是否需要处理消息

View File

@@ -256,7 +256,7 @@ async def _process_single_segment(
# 检查消息是否由机器人自己发送
user_info = message_info.get("user_info", {})
user_id_str = str(user_info.get("user_id", ""))
if user_id_str == str(global_config.bot.qq_account):
if global_config and user_id_str == str(global_config.bot.qq_account):
logger.info(f"检测到机器人自身发送的语音消息 (User ID: {user_id_str}),尝试从缓存获取文本。")
if isinstance(seg_data, str):
cached_text = consume_self_voice_text(seg_data)
@@ -299,7 +299,7 @@ async def _process_single_segment(
logger.warning("⚠️ Rust视频处理模块不可用跳过视频分析")
return "[视频]"
if global_config.video_analysis.enable:
if global_config and global_config.video_analysis.enable:
logger.info("已启用视频识别,开始识别")
if isinstance(seg_data, dict):
try:

View File

@@ -3,10 +3,11 @@ import re
import time
import traceback
from collections import deque
from typing import Optional, TYPE_CHECKING
from typing import Optional, TYPE_CHECKING, cast
import orjson
from sqlalchemy import desc, select, update
from sqlalchemy.engine import CursorResult
from src.common.data_models.database_data_model import DatabaseMessages
from src.common.database.core import get_db_session
@@ -343,7 +344,7 @@ class MessageUpdateBatcher:
.where(Messages.message_id == mmc_id)
.values(message_id=qq_id)
)
if result.rowcount > 0:
if cast(CursorResult, result).rowcount > 0:
updated_count += 1
await session.commit()
@@ -571,7 +572,7 @@ class MessageStorage:
result = await session.execute(stmt)
await session.commit()
if result.rowcount > 0:
if cast(CursorResult, result).rowcount > 0:
logger.debug(f"成功更新消息 {message_id} 的interest_value为 {interest_value}")
else:
logger.warning(f"未找到消息 {message_id}无法更新interest_value")
@@ -667,7 +668,7 @@ class MessageStorage:
)
result = await session.execute(update_stmt)
if result.rowcount > 0:
if cast(CursorResult, result).rowcount > 0:
fixed_count += 1
logger.debug(f"修复消息 {msg.message_id} 的interest_value为 {default_interest}")

View File

@@ -133,7 +133,7 @@ class HeartFCSender:
# 将发送的消息写入上下文历史
try:
if chat_stream and chat_stream.context and global_config.chat:
if chat_stream and chat_stream.context and global_config and global_config.chat:
context = chat_stream.context
chat_config = global_config.chat
if chat_config:

View File

@@ -94,7 +94,7 @@ class ChatterActionManager:
log_prefix=log_prefix,
shutting_down=shutting_down,
plugin_config=plugin_config,
action_message=action_message,
action_message=action_message, # type: ignore
)
logger.debug(f"创建Action实例成功: {action_name}")
@@ -154,6 +154,8 @@ class ChatterActionManager:
Returns:
执行结果字典
"""
assert global_config is not None
chat_stream = None
try:
# 获取 chat_stream

View File

@@ -30,6 +30,7 @@ class ActionModifier:
def __init__(self, action_manager: ChatterActionManager, chat_id: str):
"""初始化动作处理器"""
assert model_config is not None
self.chat_id = chat_id
# chat_stream 和 log_prefix 将在异步方法中初始化
self.chat_stream: "ChatStream | None" = None
@@ -72,6 +73,7 @@ class ActionModifier:
message_content: 消息内容
chatter_name: 当前使用的 Chatter 名称,用于过滤只允许特定 Chatter 使用的动作
"""
assert global_config is not None
# 初始化log_prefix
await self._initialize_log_prefix()
# 根据 stream_id 加载当前可用的动作

View File

@@ -240,6 +240,8 @@ class DefaultReplyer:
chat_stream: "ChatStream",
request_type: str = "replyer",
):
assert global_config is not None
assert model_config is not None
self.express_model = LLMRequest(model_set=model_config.model_task_config.replyer, request_type=request_type)
self.chat_stream = chat_stream
# 这些将在异步初始化中设置
@@ -267,6 +269,7 @@ class DefaultReplyer:
async def _build_auth_role_prompt(self) -> str:
"""根据主人配置生成额外提示词"""
assert global_config is not None
master_config = global_config.permission.master_prompt
if not master_config or not master_config.enable:
return ""
@@ -515,6 +518,7 @@ class DefaultReplyer:
Returns:
str: 表达习惯信息字符串
"""
assert global_config is not None
# 检查是否允许在此聊天流中使用表达
use_expression, _, _ = global_config.expression.get_expression_config_for_chat(self.chat_stream.stream_id)
if not use_expression:
@@ -583,6 +587,7 @@ class DefaultReplyer:
Returns:
str: 记忆信息字符串
"""
assert global_config is not None
# 检查是否启用三层记忆系统
if not (global_config.memory and global_config.memory.enable):
return ""
@@ -776,6 +781,7 @@ class DefaultReplyer:
Returns:
str: 关键词反应提示字符串,如果没有触发任何反应则为空字符串
"""
assert global_config is not None
if target is None:
return ""
@@ -834,6 +840,7 @@ class DefaultReplyer:
Returns:
str: 格式化的notice信息文本如果没有notice或未启用则返回空字符串
"""
assert global_config is not None
try:
logger.debug(f"开始构建notice块chat_id={chat_id}")
@@ -902,6 +909,7 @@ class DefaultReplyer:
Returns:
Tuple[str, str]: (已读历史消息prompt, 未读历史消息prompt)
"""
assert global_config is not None
try:
# 从message_manager获取真实的已读/未读消息
@@ -1002,6 +1010,7 @@ class DefaultReplyer:
"""
回退的已读/未读历史消息构建方法
"""
assert global_config is not None
# 通过is_read字段分离已读和未读消息
read_messages = []
unread_messages = []
@@ -1115,6 +1124,7 @@ class DefaultReplyer:
Returns:
str: 构建好的上下文
"""
assert global_config is not None
if available_actions is None:
available_actions = {}
chat_stream = self.chat_stream
@@ -1607,6 +1617,7 @@ class DefaultReplyer:
reply_to: str,
reply_message: dict[str, Any] | DatabaseMessages | None = None,
) -> str: # sourcery skip: merge-else-if-into-elif, remove-redundant-if
assert global_config is not None
chat_stream = self.chat_stream
chat_id = chat_stream.stream_id
is_group_chat = bool(chat_stream.group_info)
@@ -1767,6 +1778,7 @@ class DefaultReplyer:
return prompt_text
async def llm_generate_content(self, prompt: str):
assert global_config is not None
with Timer("LLM生成", {}): # 内部计时器,可选保留
# 直接使用已初始化的模型实例
logger.info(f"使用模型集生成回复: {self.express_model.model_for_task}")
@@ -1792,6 +1804,8 @@ class DefaultReplyer:
return content, reasoning_content, model_name, tool_calls
async def get_prompt_info(self, message: str, sender: str, target: str):
assert global_config is not None
assert model_config is not None
related_info = ""
start_time = time.time()
from src.plugins.built_in.knowledge.lpmm_get_knowledge import SearchKnowledgeFromLPMMTool
@@ -1843,6 +1857,7 @@ class DefaultReplyer:
return ""
async def build_relation_info(self, sender: str, target: str):
assert global_config is not None
# 获取用户ID
if sender == f"{global_config.bot.nickname}(你)":
return "你将要回复的是你自己发送的消息。"
@@ -1927,6 +1942,7 @@ class DefaultReplyer:
reply_to: 回复对象
reply_message: 回复的原始消息
"""
assert global_config is not None
return # 已禁用,保留函数签名以防其他地方有引用
# 以下代码已废弃,不再执行

View File

@@ -173,9 +173,10 @@ class SecurityManager:
pre_check_results = await asyncio.gather(*pre_check_tasks, return_exceptions=True)
# 筛选需要完整检查的检测器
checkers_to_run = [
c for c, need_check in zip(enabled_checkers, pre_check_results) if need_check is True
]
checkers_to_run = []
for c, need_check in zip(enabled_checkers, pre_check_results):
if need_check is True:
checkers_to_run.append(c)
if not checkers_to_run:
return SecurityCheckResult(
@@ -192,20 +193,22 @@ class SecurityManager:
results = await asyncio.gather(*check_tasks, return_exceptions=True)
# 过滤异常结果
valid_results = []
valid_results: list[SecurityCheckResult] = []
for checker, result in zip(checkers_to_run, results):
if isinstance(result, Exception):
if isinstance(result, BaseException):
logger.error(f"检测器 '{checker.name}' 执行失败: {result}")
continue
result.checker_name = checker.name
valid_results.append(result)
if isinstance(result, SecurityCheckResult):
result.checker_name = checker.name
valid_results.append(result)
# 合并结果
return self._merge_results(valid_results, time.time() - start_time)
async def _check_all(self, message: str, context: dict, start_time: float) -> SecurityCheckResult:
"""检测所有模式(顺序执行所有检测器)"""
results = []
results: list[SecurityCheckResult] = []
for checker in self._checkers:
if not checker.enabled:

View File

@@ -39,11 +39,13 @@ def replace_user_references_sync(
Returns:
str: 处理后的内容字符串
"""
assert global_config is not None
if not content:
return ""
if name_resolver is None:
def default_resolver(platform: str, user_id: str) -> str:
assert global_config is not None
# 检查是否是机器人自己
if replace_bot_name and (user_id == str(global_config.bot.qq_account)):
return f"{global_config.bot.nickname}(你)"
@@ -116,10 +118,12 @@ async def replace_user_references_async(
Returns:
str: 处理后的内容字符串
"""
assert global_config is not None
if name_resolver is None:
person_info_manager = get_person_info_manager()
async def default_resolver(platform: str, user_id: str) -> str:
assert global_config is not None
# 检查是否是机器人自己
if replace_bot_name and (user_id == str(global_config.bot.qq_account)):
return f"{global_config.bot.nickname}(你)"
@@ -392,7 +396,7 @@ async def get_actions_by_timestamp_with_chat_inclusive(
actions = list(result.scalars())
return [action.__dict__ for action in reversed(actions)]
else: # earliest
result = await session.execute(
query = await session.execute(
select(ActionRecords)
.where(
and_(
@@ -540,6 +544,7 @@ async def _build_readable_messages_internal(
Returns:
包含格式化消息的字符串、原始消息详情列表、图片映射字典和更新后的计数器的元组。
"""
assert global_config is not None
if not messages:
return "", [], pic_id_mapping or {}, pic_counter
@@ -694,6 +699,7 @@ async def _build_readable_messages_internal(
percentile = i / n_messages # 计算消息在列表中的位置百分比 (0 <= percentile < 1)
original_len = len(content)
limit = -1 # 默认不截断
replace_content = ""
if percentile < 0.2: # 60% 之前的消息 (即最旧的 60%)
limit = 50
@@ -973,6 +979,7 @@ async def build_readable_messages(
truncate: 是否截断长消息
show_actions: 是否显示动作记录
"""
assert global_config is not None
# 创建messages的深拷贝避免修改原始列表
if not messages:
return ""
@@ -1112,6 +1119,7 @@ async def build_anonymous_messages(messages: list[dict[str, Any]]) -> str:
构建匿名可读消息将不同人的名称转为唯一占位符A、B、C...bot自己用SELF。
处理 回复<aaa:bbb> 和 @<aaa:bbb> 字段将bbb映射为匿名占位符。
"""
assert global_config is not None
if not messages:
print("111111111111没有消息无法构建匿名消息")
return ""
@@ -1127,6 +1135,7 @@ async def build_anonymous_messages(messages: list[dict[str, Any]]) -> str:
def get_anon_name(platform, user_id):
# print(f"get_anon_name: platform:{platform}, user_id:{user_id}")
# print(f"global_config.bot.qq_account:{global_config.bot.qq_account}")
assert global_config is not None
if user_id == global_config.bot.qq_account:
# print("SELF11111111111111")
@@ -1204,6 +1213,7 @@ async def get_person_id_list(messages: list[dict[str, Any]]) -> list[str]:
Returns:
一个包含唯一 person_id 的列表。
"""
assert global_config is not None
person_ids_set = set() # 使用集合来自动去重
for msg in messages:

View File

@@ -649,6 +649,7 @@ class Prompt:
async def _build_expression_habits(self) -> dict[str, Any]:
"""构建表达习惯(如表情、口癖)的上下文块."""
assert global_config is not None
# 检查当前聊天是否启用了表达习惯功能
use_expression, _, _ = global_config.expression.get_expression_config_for_chat(
self.parameters.chat_id
@@ -728,6 +729,7 @@ class Prompt:
async def _build_tool_info(self) -> dict[str, Any]:
"""构建工具调用结果的上下文块."""
assert global_config is not None
if not global_config.tool.enable_tool:
return {"tool_info_block": ""}
@@ -779,6 +781,7 @@ class Prompt:
async def _build_knowledge_info(self) -> dict[str, Any]:
"""构建从知识库检索到的相关信息的上下文块."""
assert global_config is not None
if not global_config.lpmm_knowledge.enable:
return {"knowledge_prompt": ""}
@@ -873,6 +876,7 @@ class Prompt:
def _prepare_s4u_params(self, context_data: dict[str, Any]) -> dict[str, Any]:
"""为S4UScene for You模式准备最终用于格式化的参数字典."""
assert global_config is not None
return {
**context_data,
"expression_habits_block": context_data.get("expression_habits_block", ""),
@@ -915,6 +919,7 @@ class Prompt:
def _prepare_normal_params(self, context_data: dict[str, Any]) -> dict[str, Any]:
"""为Normal模式准备最终用于格式化的参数字典."""
assert global_config is not None
return {
**context_data,
"expression_habits_block": context_data.get("expression_habits_block", ""),
@@ -959,6 +964,7 @@ class Prompt:
def _prepare_default_params(self, context_data: dict[str, Any]) -> dict[str, Any]:
"""为默认模式(或其他未指定模式)准备最终用于格式化的参数字典."""
assert global_config is not None
return {
"expression_habits_block": context_data.get("expression_habits_block", ""),
"relation_info_block": context_data.get("relation_info_block", ""),
@@ -1143,6 +1149,7 @@ class Prompt:
Returns:
str: 构建好的跨群聊上下文字符串。
"""
assert global_config is not None
if not global_config.cross_context.enable:
return ""

View File

@@ -92,6 +92,7 @@ class HTMLReportGenerator:
f"<td>{stat_data[TPS_BY_PROVIDER].get(provider_name, 0):.2f}</td>"
f"<td>{stat_data[COST_PER_KTOK_BY_PROVIDER].get(provider_name, 0):.4f} ¥</td>"
f"<td>{stat_data[COST_BY_PROVIDER].get(provider_name, 0):.4f} ¥</td>"
f"<td>{stat_data.get(AVG_TIME_COST_BY_PROVIDER, {}).get(provider_name, 0):.3f} 秒</td>"
f"</tr>"
for provider_name, count in sorted(stat_data[REQ_CNT_BY_PROVIDER].items())
]
@@ -135,27 +136,123 @@ class HTMLReportGenerator:
for chat_id, count in sorted(stat_data[MSG_CNT_BY_CHAT].items())
]
)
# 先计算基础数据
total_tokens = sum(stat_data.get(TOTAL_TOK_BY_MODEL, {}).values())
total_requests = stat_data.get(TOTAL_REQ_CNT, 0)
total_cost = stat_data.get(TOTAL_COST, 0)
total_messages = stat_data.get(TOTAL_MSG_CNT, 0)
online_seconds = stat_data.get(ONLINE_TIME, 0)
online_hours = online_seconds / 3600 if online_seconds > 0 else 0
# 大模型相关效率指标
avg_cost_per_req = (total_cost / total_requests) if total_requests > 0 else 0
avg_cost_per_msg = (total_cost / total_messages) if total_messages > 0 else 0
avg_tokens_per_msg = (total_tokens / total_messages) if total_messages > 0 else 0
avg_tokens_per_req = (total_tokens / total_requests) if total_requests > 0 else 0
msg_to_req_ratio = (total_messages / total_requests) if total_requests > 0 else 0
cost_per_hour = (total_cost / online_hours) if online_hours > 0 else 0
req_per_hour = (total_requests / online_hours) if online_hours > 0 else 0
# Token效率 (输出/输入比率)
total_in_tokens = sum(stat_data.get(IN_TOK_BY_MODEL, {}).values())
total_out_tokens = sum(stat_data.get(OUT_TOK_BY_MODEL, {}).values())
token_efficiency = (total_out_tokens / total_in_tokens) if total_in_tokens > 0 else 0
# 生成效率指标表格数据
efficiency_data = [
("💸 平均每条消息成本", f"{avg_cost_per_msg:.6f} ¥", "处理每条用户消息的平均AI成本"),
("🎯 平均每条消息Token", f"{avg_tokens_per_msg:.0f}", "每条消息平均消耗的Token数量"),
("📊 平均每次请求Token", f"{avg_tokens_per_req:.0f}", "每次AI请求平均消耗的Token数"),
("🔄 消息/请求比率", f"{msg_to_req_ratio:.2f}", "平均每个AI请求处理的消息数"),
("⚡ Token效率(输出/输入)", f"{token_efficiency:.3f}x", "输出Token与输入Token的比率"),
("💵 每小时运行成本", f"{cost_per_hour:.4f} ¥/h", "在线每小时的AI成本"),
("🚀 每小时请求数", f"{req_per_hour:.1f} 次/h", "在线每小时的AI请求次数"),
("💰 每千Token成本", f"{(total_cost / total_tokens * 1000) if total_tokens > 0 else 0:.4f} ¥", "平均每1000个Token的成本"),
("📈 Token/在线小时", f"{(total_tokens / online_hours) if online_hours > 0 else 0:.0f}", "每在线小时处理的Token数"),
("💬 消息/在线小时", f"{(total_messages / online_hours) if online_hours > 0 else 0:.1f}", "每在线小时处理的消息数"),
]
efficiency_rows = "\n".join(
[
f"<tr><td style='font-weight: 500;'>{metric}</td><td style='color: #1976D2; font-weight: 600; font-size: 1.1em;'>{value}</td><td style='color: #546E7A;'>{desc}</td></tr>"
for metric, value, desc in efficiency_data
]
)
# 计算活跃聊天数和最活跃聊天
msg_by_chat = stat_data.get(MSG_CNT_BY_CHAT, {})
active_chats = len(msg_by_chat)
most_active_chat = ""
if msg_by_chat:
most_active_id = max(msg_by_chat, key=msg_by_chat.get)
most_active_chat = self.name_mapping.get(most_active_id, (most_active_id, 0))[0]
most_active_count = msg_by_chat[most_active_id]
most_active_chat = f"{most_active_chat} ({most_active_count}条)"
avg_msg_per_chat = (total_messages / active_chats) if active_chats > 0 else 0
summary_cards = f"""
<div class="summary-cards">
<div class="card">
<h3>总花费</h3>
<p>{stat_data.get(TOTAL_COST, 0):.4f} ¥</p>
<h3>💰 总花费</h3>
<p>{total_cost:.4f} ¥</p>
</div>
<div class="card">
<h3>请求数</h3>
<p>{stat_data.get(TOTAL_REQ_CNT, 0)}</p>
<h3>📞 AI请求数</h3>
<p>{total_requests:,}</p>
</div>
<div class="card">
<h3>总Token数</h3>
<p>{sum(stat_data.get(TOTAL_TOK_BY_MODEL, {}).values())}</p>
</div>
<div class="card">
<h3>总消息数</h3>
<p>{stat_data.get(TOTAL_MSG_CNT, 0)}</p>
<h3>🎯 总Token数</h3>
<p>{total_tokens:,}</p>
</div>
<div class="card">
<h3>总在线时间</h3>
<p>{format_online_time(int(stat_data.get(ONLINE_TIME, 0)))}</p>
<h3>💬 总消息数</h3>
<p>{total_messages:,}</p>
</div>
<div class="card">
<h3>⏱️ 在线时间</h3>
<p>{format_online_time(int(online_seconds))}</p>
</div>
<div class="card">
<h3>💸 每条消息成本</h3>
<p>{avg_cost_per_msg:.4f} ¥</p>
</div>
<div class="card">
<h3>📊 每请求Token</h3>
<p>{avg_tokens_per_req:.0f}</p>
</div>
<div class="card">
<h3><3E> 消息/请求比</h3>
<p>{msg_to_req_ratio:.2f}</p>
</div>
<div class="card">
<h3>⚡ Token效率</h3>
<p>{token_efficiency:.2f}x</p>
</div>
<div class="card">
<h3>💵 每小时成本</h3>
<p>{cost_per_hour:.4f} ¥</p>
</div>
<div class="card">
<h3>🚀 每小时请求</h3>
<p>{req_per_hour:.1f}</p>
</div>
<div class="card">
<h3>👥 活跃聊天数</h3>
<p>{active_chats}</p>
</div>
<div class="card">
<h3>🔥 最活跃聊天</h3>
<p style="font-size: 1.2em;">{most_active_chat if most_active_chat else ""}</p>
</div>
<div class="card">
<h3>📈 平均消息/聊天</h3>
<p>{avg_msg_per_chat:.1f}</p>
</div>
<div class="card">
<h3>🎯 每消息Token</h3>
<p>{avg_tokens_per_msg:.0f}</p>
</div>
</div>
"""
@@ -173,6 +270,7 @@ class HTMLReportGenerator:
module_rows=module_rows,
type_rows=type_rows,
chat_rows=chat_rows,
efficiency_rows=efficiency_rows,
)
def _generate_chart_tab(self, chart_data: dict) -> str:
@@ -219,28 +317,41 @@ class HTMLReportGenerator:
period_id = period[0]
static_chart_data[period_id] = {
"provider_cost_data": stat[period_id].get(PIE_CHART_COST_BY_PROVIDER, {}),
"module_cost_data": stat[period_id].get(PIE_CHART_COST_BY_MODULE, {}),
"model_cost_data": stat[period_id].get(BAR_CHART_COST_BY_MODEL, {}),
"token_comparison_data": stat[period_id].get(BAR_CHART_TOKEN_COMPARISON, {}),
"response_time_scatter_data": stat[period_id].get(SCATTER_CHART_RESPONSE_TIME, []),
"model_efficiency_radar_data": stat[period_id].get(RADAR_CHART_MODEL_EFFICIENCY, {}),
"provider_requests_data": stat[period_id].get(DOUGHNUT_CHART_PROVIDER_REQUESTS, {}),
"avg_response_time_data": stat[period_id].get(BAR_CHART_AVG_RESPONSE_TIME, {}),
}
static_chart_data["all_time"] = {
"provider_cost_data": stat["all_time"].get(PIE_CHART_COST_BY_PROVIDER, {}),
"module_cost_data": stat["all_time"].get(PIE_CHART_COST_BY_MODULE, {}),
"model_cost_data": stat["all_time"].get(BAR_CHART_COST_BY_MODEL, {}),
"token_comparison_data": stat["all_time"].get(BAR_CHART_TOKEN_COMPARISON, {}),
"response_time_scatter_data": stat["all_time"].get(SCATTER_CHART_RESPONSE_TIME, []),
"model_efficiency_radar_data": stat["all_time"].get(RADAR_CHART_MODEL_EFFICIENCY, {}),
"provider_requests_data": stat["all_time"].get(DOUGHNUT_CHART_PROVIDER_REQUESTS, {}),
"avg_response_time_data": stat["all_time"].get(BAR_CHART_AVG_RESPONSE_TIME, {}),
}
# 渲染模板
# 读取CSS和JS文件内容
assert isinstance(self.jinja_env.loader, FileSystemLoader)
async with aiofiles.open(os.path.join(self.jinja_env.loader.searchpath[0], "report.css"), encoding="utf-8") as f:
report_css = await f.read()
async with aiofiles.open(os.path.join(self.jinja_env.loader.searchpath[0], "report.js"), encoding="utf-8") as f:
report_js = await f.read()
# 渲染模板
# 渲染模板使用紧凑的JSON格式减少文件大小
template = self.jinja_env.get_template("report.html")
rendered_html = template.render(
report_title="MoFox-Bot运行统计报告",
generation_time=now.strftime("%Y-%m-%d %H:%M:%S"),
tab_list="\n".join(tab_list_html),
tab_content="\n".join(tab_content_html_list),
all_chart_data=json.dumps(chart_data),
static_chart_data=json.dumps(static_chart_data),
all_chart_data=json.dumps(chart_data, separators=(',', ':'), ensure_ascii=False),
static_chart_data=json.dumps(static_chart_data, separators=(',', ':'), ensure_ascii=False),
report_css=report_css,
report_js=report_js,
)

View File

@@ -192,7 +192,7 @@ class StatisticOutputTask(AsyncTask):
self._statistic_console_output(stats, now)
# 使用新的 HTMLReportGenerator 生成报告
chart_data = await self._collect_chart_data(stats)
deploy_time = datetime.fromtimestamp(local_storage.get("deploy_time", now.timestamp()))
deploy_time = datetime.fromtimestamp(float(local_storage.get("deploy_time", now.timestamp()))) # type: ignore
report_generator = HTMLReportGenerator(
name_mapping=self.name_mapping,
stat_period=self.stat_period,
@@ -219,7 +219,7 @@ class StatisticOutputTask(AsyncTask):
# 使用新的 HTMLReportGenerator 生成报告
chart_data = await self._collect_chart_data(stats)
deploy_time = datetime.fromtimestamp(local_storage.get("deploy_time", now.timestamp()))
deploy_time = datetime.fromtimestamp(float(local_storage.get("deploy_time", now.timestamp()))) # type: ignore
report_generator = HTMLReportGenerator(
name_mapping=self.name_mapping,
stat_period=self.stat_period,
@@ -299,8 +299,16 @@ class StatisticOutputTask(AsyncTask):
# Chart data
PIE_CHART_COST_BY_PROVIDER: {},
PIE_CHART_REQ_BY_PROVIDER: {},
PIE_CHART_COST_BY_MODULE: {},
BAR_CHART_COST_BY_MODEL: {},
BAR_CHART_REQ_BY_MODEL: {},
BAR_CHART_TOKEN_COMPARISON: {},
SCATTER_CHART_RESPONSE_TIME: {},
RADAR_CHART_MODEL_EFFICIENCY: {},
HEATMAP_CHAT_ACTIVITY: {},
DOUGHNUT_CHART_PROVIDER_REQUESTS: {},
LINE_CHART_COST_TREND: {},
BAR_CHART_AVG_RESPONSE_TIME: {},
}
for period_key, _ in collect_period
}
@@ -457,6 +465,15 @@ class StatisticOutputTask(AsyncTask):
"data": [round(item[1], 4) for item in sorted_providers],
}
# 按模块花费饼图
module_costs = period_stats[COST_BY_MODULE]
if module_costs:
sorted_modules = sorted(module_costs.items(), key=lambda item: item[1], reverse=True)
period_stats[PIE_CHART_COST_BY_MODULE] = {
"labels": [item[0] for item in sorted_modules],
"data": [round(item[1], 4) for item in sorted_modules],
}
# 按模型花费条形图
model_costs = period_stats[COST_BY_MODEL]
if model_costs:
@@ -465,6 +482,91 @@ class StatisticOutputTask(AsyncTask):
"labels": [item[0] for item in sorted_models],
"data": [round(item[1], 4) for item in sorted_models],
}
# 1. Token输入输出对比条形图
model_names = list(period_stats[REQ_CNT_BY_MODEL].keys())
if model_names:
period_stats[BAR_CHART_TOKEN_COMPARISON] = {
"labels": model_names,
"input_tokens": [period_stats[IN_TOK_BY_MODEL].get(m, 0) for m in model_names],
"output_tokens": [period_stats[OUT_TOK_BY_MODEL].get(m, 0) for m in model_names],
}
# 2. 响应时间分布散点图数据(限制数据点以提高加载速度)
scatter_data = []
max_points_per_model = 50 # 每个模型最多50个点
for model_name, time_costs in period_stats[TIME_COST_BY_MODEL].items():
# 如果数据点太多,进行采样
if len(time_costs) > max_points_per_model:
step = len(time_costs) // max_points_per_model
sampled_costs = time_costs[::step][:max_points_per_model]
else:
sampled_costs = time_costs
for idx, time_cost in enumerate(sampled_costs):
scatter_data.append({
"model": model_name,
"x": idx,
"y": round(time_cost, 3),
"tokens": period_stats[TOTAL_TOK_BY_MODEL].get(model_name, 0) // len(time_costs) if time_costs else 0
})
period_stats[SCATTER_CHART_RESPONSE_TIME] = scatter_data
# 3. 模型效率雷达图
if model_names:
# 取前5个最常用的模型
top_models = sorted(period_stats[REQ_CNT_BY_MODEL].items(), key=lambda x: x[1], reverse=True)[:5]
radar_data = []
for model_name, _ in top_models:
# 归一化各项指标到0-100
req_count = period_stats[REQ_CNT_BY_MODEL].get(model_name, 0)
tps = period_stats[TPS_BY_MODEL].get(model_name, 0)
avg_time = period_stats[AVG_TIME_COST_BY_MODEL].get(model_name, 0)
cost_per_ktok = period_stats[COST_PER_KTOK_BY_MODEL].get(model_name, 0)
avg_tokens = period_stats[AVG_TOK_BY_MODEL].get(model_name, 0)
# 简单的归一化(反向归一化时间和成本,值越小越好)
max_req = max([period_stats[REQ_CNT_BY_MODEL].get(m[0], 1) for m in top_models])
max_tps = max([period_stats[TPS_BY_MODEL].get(m[0], 1) for m in top_models])
max_time = max([period_stats[AVG_TIME_COST_BY_MODEL].get(m[0], 0.1) for m in top_models])
max_cost = max([period_stats[COST_PER_KTOK_BY_MODEL].get(m[0], 0.001) for m in top_models])
max_tokens = max([period_stats[AVG_TOK_BY_MODEL].get(m[0], 1) for m in top_models])
radar_data.append({
"model": model_name,
"metrics": [
round((req_count / max_req) * 100, 2) if max_req > 0 else 0, # 请求量
round((tps / max_tps) * 100, 2) if max_tps > 0 else 0, # TPS
round((1 - avg_time / max_time) * 100, 2) if max_time > 0 else 100, # 速度(反向)
round((1 - cost_per_ktok / max_cost) * 100, 2) if max_cost > 0 else 100, # 成本效益(反向)
round((avg_tokens / max_tokens) * 100, 2) if max_tokens > 0 else 0, # Token容量
]
})
period_stats[RADAR_CHART_MODEL_EFFICIENCY] = {
"labels": ["请求量", "TPS", "响应速度", "成本效益", "Token容量"],
"datasets": radar_data
}
# 4. 供应商请求占比环形图
provider_requests = period_stats[REQ_CNT_BY_PROVIDER]
if provider_requests:
sorted_provider_reqs = sorted(provider_requests.items(), key=lambda item: item[1], reverse=True)
period_stats[DOUGHNUT_CHART_PROVIDER_REQUESTS] = {
"labels": [item[0] for item in sorted_provider_reqs],
"data": [item[1] for item in sorted_provider_reqs],
}
# 5. 平均响应时间条形图
if model_names:
sorted_by_time = sorted(
[(m, period_stats[AVG_TIME_COST_BY_MODEL].get(m, 0)) for m in model_names],
key=lambda x: x[1],
reverse=True
)
period_stats[BAR_CHART_AVG_RESPONSE_TIME] = {
"labels": [item[0] for item in sorted_by_time],
"data": [round(item[1], 3) for item in sorted_by_time],
}
return stats
@staticmethod

View File

@@ -59,5 +59,30 @@ STD_TIME_COST_BY_PROVIDER = "std_time_costs_by_provider"
# 新增饼图和条形图数据
PIE_CHART_COST_BY_PROVIDER = "pie_chart_cost_by_provider"
PIE_CHART_REQ_BY_PROVIDER = "pie_chart_req_by_provider"
PIE_CHART_COST_BY_MODULE = "pie_chart_cost_by_module"
BAR_CHART_COST_BY_MODEL = "bar_chart_cost_by_model"
BAR_CHART_REQ_BY_MODEL = "bar_chart_req_by_model"
# 新增更多图表数据
BAR_CHART_TOKEN_COMPARISON = "bar_chart_token_comparison" # Token输入输出对比图
SCATTER_CHART_RESPONSE_TIME = "scatter_chart_response_time" # 响应时间分布散点图
RADAR_CHART_MODEL_EFFICIENCY = "radar_chart_model_efficiency" # 模型效率雷达图
HEATMAP_CHAT_ACTIVITY = "heatmap_chat_activity" # 聊天活跃度热力图
DOUGHNUT_CHART_PROVIDER_REQUESTS = "doughnut_chart_provider_requests" # 供应商请求占比环形图
LINE_CHART_COST_TREND = "line_chart_cost_trend" # 成本趋势折线图
BAR_CHART_AVG_RESPONSE_TIME = "bar_chart_avg_response_time" # 平均响应时间条形图
# 新增消息分析指标
MSG_CNT_BY_USER = "messages_by_user" # 按用户的消息数
ACTIVE_CHATS_CNT = "active_chats_count" # 活跃聊天数
MOST_ACTIVE_CHAT = "most_active_chat" # 最活跃的聊天
AVG_MSG_PER_CHAT = "avg_messages_per_chat" # 平均每个聊天的消息数
# 新增大模型效率指标
AVG_COST_PER_MSG = "avg_cost_per_message" # 平均每条消息成本
AVG_TOKENS_PER_MSG = "avg_tokens_per_message" # 平均每条消息Token数
AVG_TOKENS_PER_REQ = "avg_tokens_per_request" # 平均每次请求Token数
MSG_TO_REQ_RATIO = "message_to_request_ratio" # 消息/请求比率
COST_PER_ONLINE_HOUR = "cost_per_online_hour" # 每小时在线成本
REQ_PER_ONLINE_HOUR = "requests_per_online_hour" # 每小时请求数
TOKEN_EFFICIENCY = "token_efficiency" # Token效率 (输出/输入比率)

View File

@@ -1,16 +1,33 @@
<div id="charts" class="tab-content">
<h2>数据图表</h2>
<div style="margin: 20px 0; text-align: center;">
<label style="margin-right: 10px; font-weight: bold;">时间范围:</label>
<h2>📈 数据图表</h2>
<div class="info-item" style="margin-bottom: 2rem;">
<span class="material-icons" style="font-size: 18px;">show_chart</span>
<strong>动态图表:</strong> 选择不同的时间范围查看数据趋势变化
</div>
<div class="time-range-controls">
<span style="margin-right: 1rem; font-weight: 600; color: var(--text-secondary); display: flex; align-items: center; gap: 0.5rem;">
<span class="material-icons" style="font-size: 18px;">schedule</span>
时间范围:
</span>
<button class="time-range-btn" onclick="switchTimeRange('6h')">6小时</button>
<button class="time-range-btn" onclick="switchTimeRange('12h')">12小时</button>
<button class="time-range-btn" onclick="switchTimeRange('24h')">24小时</button>
<button class="time-range-btn" onclick="switchTimeRange('48h')">48小时</button>
</div>
<div style="margin-top: 20px;">
<div style="margin-bottom: 40px;"><canvas id="totalCostChart" width="800" height="400"></canvas></div>
<div style="margin-bottom: 40px;"><canvas id="costByModuleChart" width="800" height="400"></canvas></div>
<div style="margin-bottom: 40px;"><canvas id="costByModelChart" width="800" height="400"></canvas></div>
<div><canvas id="messageByChatChart" width="800" height="400"></canvas></div>
<div class="chart-grid" style="grid-template-columns: 1fr;">
<div class="chart-wrapper" style="max-height: 450px;">
<div id="totalCostChart" style="width: 100%; height: 380px;"></div>
</div>
<div class="chart-wrapper" style="max-height: 500px;">
<div id="costByModuleChart" style="width: 100%; height: 420px;"></div>
</div>
<div class="chart-wrapper" style="max-height: 500px;">
<div id="costByModelChart" style="width: 100%; height: 420px;"></div>
</div>
<div class="chart-wrapper" style="max-height: 500px;">
<div id="messageByChatChart" style="width: 100%; height: 420px;"></div>
</div>
</div>
</div>

View File

@@ -1,199 +1,385 @@
/* General Body Styles */
/* Modern Dashboard Theme - 2025 Edition */
:root {
/* Core Colors */
--primary-color: #2563eb;
--primary-light: #eff6ff;
--primary-dark: #1e40af;
--secondary-color: #64748b;
--success-color: #10b981;
--warning-color: #f59e0b;
--danger-color: #ef4444;
/* Backgrounds */
--bg-body: #f8fafc;
--bg-card: #ffffff;
--bg-sidebar: #ffffff;
/* Text */
--text-primary: #0f172a;
--text-secondary: #475569;
--text-muted: #94a3b8;
/* Borders & Shadows */
--border-color: #e2e8f0;
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
/* Layout */
--radius-lg: 1rem;
--radius-md: 0.75rem;
--radius-sm: 0.5rem;
}
/* Reset & Base Styles */
* {
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-family: 'Inter', 'Roboto', -apple-system, BlinkMacSystemFont, sans-serif;
margin: 0;
padding: 20px;
background-color: #f0f4f8; /* Light blue-gray background */
color: #333; /* Darker text for better contrast */
line-height: 1.6;
padding: 0;
background-color: var(--bg-body);
color: var(--text-primary);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
/* Main Container */
/* Layout Container */
.container {
max-width: 95%; /* Make container almost full-width */
margin: 20px auto;
background-color: #FFFFFF; /* Pure white background */
padding: 30px;
border-radius: 12px; /* Slightly more rounded corners */
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.07); /* Softer, deeper shadow */
}
/* Dashboard Layout */
.dashboard-layout {
display: flex;
gap: 30px;
}
.main-content {
flex: 65%;
min-width: 0;
}
.sidebar-content {
flex: 35%;
min-width: 0;
}
/* Responsive Design for Mobile */
@media (max-width: 992px) {
.dashboard-layout {
flex-direction: column;
}
.main-content, .sidebar-content {
flex: 1;
}
}
/* Typography */
h1, h2 {
color: #212529;
padding-bottom: 10px;
margin-top: 0;
max-width: 1600px;
margin: 0 auto;
padding: 2rem;
}
/* Header Section */
h1 {
text-align: center;
font-size: 2.2em;
margin-bottom: 20px;
color: #2A6CB5; /* A deeper, more professional blue */
font-size: 2rem;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 0.5rem;
text-align: left;
letter-spacing: -0.025em;
}
h2 {
font-size: 1.5em;
margin-top: 40px;
margin-bottom: 15px;
border-bottom: 2px solid #DDE6ED; /* Lighter border color */
}
/* Info Banners */
.info-item {
background-color: #E9F2FA; /* Light blue background */
padding: 12px 18px;
border-radius: 8px;
margin-bottom: 20px;
font-size: 0.95em;
border: 1px solid #D1E3F4; /* Light blue border */
}
.info-item strong {
color: #2A6CB5; /* Deeper blue for emphasis */
}
/* Tabs */
.tabs {
border-bottom: 2px solid #DEE2E6;
.header-meta {
display: flex;
margin-bottom: 20px;
align-items: center;
gap: 1rem;
margin-bottom: 2rem;
color: var(--text-secondary);
font-size: 0.875rem;
}
.info-item {
background: var(--primary-light);
color: var(--primary-dark);
padding: 0.75rem 1.25rem;
border-radius: var(--radius-md);
font-weight: 500;
display: inline-flex;
align-items: center;
gap: 0.5rem;
border: 1px solid rgba(37, 99, 235, 0.1);
}
/* Navigation Tabs */
.tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 2rem;
border-bottom: 1px solid var(--border-color);
padding-bottom: 1px;
overflow-x: auto;
scrollbar-width: none; /* Firefox */
}
.tabs::-webkit-scrollbar {
display: none; /* Chrome/Safari */
}
.tabs button {
background: none;
background: transparent;
border: none;
outline: none;
padding: 14px 20px;
padding: 0.75rem 1.25rem;
font-size: 0.95rem;
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.3s ease;
font-size: 16px;
color: #6C757D;
border-bottom: 3px solid transparent;
margin-bottom: -2px; /* Align with container border */
border-radius: var(--radius-md) var(--radius-md) 0 0;
transition: all 0.2s ease;
position: relative;
white-space: nowrap;
}
.tabs button:hover {
color: #2A6CB5;
background-color: #f0f4f8; /* Subtle hover background */
color: var(--primary-color);
background-color: rgba(37, 99, 235, 0.05);
}
.tabs button.active {
color: #2A6CB5; /* Active tab color */
border-bottom-color: #2A6CB5; /* Active tab border color */
color: var(--primary-color);
font-weight: 600;
}
.tab-content {
display: none;
padding-top: 10px;
.tabs button.active::after {
content: '';
position: absolute;
bottom: -1px;
left: 0;
right: 0;
height: 2px;
background-color: var(--primary-color);
}
.tab-content.active {
display: block;
}
/* Summary Cards */
/* Summary Cards Grid */
.summary-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 20px;
margin: 20px 0;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.card {
background-color: #FFFFFF;
padding: 20px;
border-radius: 8px;
text-align: center;
border: 1px solid #DDE6ED; /* Lighter border */
transition: all 0.3s ease;
background: var(--bg-card);
padding: 1.5rem;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
border: 1px solid var(--border-color);
transition: transform 0.2s ease, box-shadow 0.2s ease;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.card:hover {
transform: translateY(-5px);
box-shadow: 0 6px 15px rgba(0,0,0,0.08);
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.card h3 {
margin: 0 0 10px;
font-size: 1em;
color: #6C757D;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-secondary);
margin: 0 0 0.5rem 0;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.card p {
font-size: 1.75rem;
font-weight: 700;
color: var(--text-primary);
margin: 0;
font-size: 1.8em;
font-weight: bold;
color: #212529;
line-height: 1.2;
}
/* Dashboard Layout */
.dashboard-layout {
display: grid;
grid-template-columns: 1fr 350px;
gap: 2rem;
align-items: start;
}
.main-content {
min-width: 0; /* Prevent grid blowout */
}
.sidebar-content {
background: var(--bg-sidebar);
padding: 1.5rem;
border-radius: var(--radius-lg);
border: 1px solid var(--border-color);
box-shadow: var(--shadow-sm);
position: sticky;
top: 2rem;
max-height: calc(100vh - 4rem);
overflow-y: auto;
}
/* Charts */
.chart-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(450px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.chart-container, .chart-wrapper {
background: var(--bg-card);
padding: 1.5rem;
border-radius: var(--radius-lg);
border: 1px solid var(--border-color);
box-shadow: var(--shadow-sm);
position: relative;
height: auto;
min-height: 350px;
max-height: 600px;
overflow: hidden;
}
.chart-container > div, .chart-wrapper > div {
max-width: 100%;
max-height: 100%;
}
.chart-container h3, .chart-wrapper h3 {
margin-top: 0;
font-size: 1.1rem;
color: var(--text-primary);
margin-bottom: 1rem;
}
/* Tables */
.table-container {
background: var(--bg-card);
border-radius: var(--radius-lg);
border: 1px solid var(--border-color);
box-shadow: var(--shadow-sm);
overflow: hidden;
margin-bottom: 2rem;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 15px;
font-size: 0.9em;
font-size: 0.875rem;
}
th, td {
padding: 12px 15px;
text-align: left;
border-bottom: 1px solid #EAEAEA;
}
th {
background-color: #4A90E2; /* Main theme blue */
background: var(--bg-body);
padding: 1rem;
text-align: left;
font-weight: 600;
color: var(--text-secondary);
border-bottom: 1px solid var(--border-color);
white-space: nowrap;
}
td {
padding: 1rem;
border-bottom: 1px solid var(--border-color);
color: var(--text-primary);
}
tr:last-child td {
border-bottom: none;
}
tr:hover td {
background-color: var(--primary-light);
}
/* Section Headers */
h2 {
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary);
margin: 2.5rem 0 1.5rem;
display: flex;
align-items: center;
gap: 0.75rem;
}
h2::before {
content: '';
display: block;
width: 4px;
height: 24px;
background: var(--primary-color);
border-radius: 2px;
}
/* Time Range Buttons */
.time-range-controls {
display: flex;
justify-content: center;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.time-range-btn {
background: var(--bg-card);
border: 1px solid var(--border-color);
color: var(--text-secondary);
padding: 0.5rem 1rem;
border-radius: 2rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.time-range-btn:hover {
border-color: var(--primary-color);
color: var(--primary-color);
}
.time-range-btn.active {
background: var(--primary-color);
color: white;
font-weight: bold;
font-size: 0.95em;
text-transform: uppercase;
letter-spacing: 0.5px;
}
tr:nth-child(even) {
background-color: #F7FAFC; /* Very light blue for alternate rows */
}
tr:hover {
background-color: #E9F2FA; /* Light blue for hover */
}
/* Chart Container in Sidebar */
.chart-container {
position: relative;
height: 300px; /* Adjust height as needed */
width: 100%;
margin-bottom: 20px;
border-color: var(--primary-color);
box-shadow: 0 2px 4px rgba(37, 99, 235, 0.2);
}
/* Footer */
.footer {
text-align: center;
margin-top: 40px;
font-size: 0.85em;
color: #6C757D;
margin-top: 4rem;
padding-top: 2rem;
border-top: 1px solid var(--border-color);
color: var(--text-muted);
font-size: 0.875rem;
}
/* Animations */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
animation: fadeIn 0.4s ease-out;
}
/* Responsive */
@media (max-width: 1200px) {
.dashboard-layout {
grid-template-columns: 1fr;
}
.sidebar-content {
position: static;
max-height: none;
margin-top: 2rem;
}
}
@media (max-width: 768px) {
.container {
padding: 1rem;
}
.chart-grid {
grid-template-columns: 1fr;
}
.summary-cards {
grid-template-columns: 1fr 1fr;
}
h1 {
font-size: 1.5rem;
}
.table-container {
overflow-x: auto;
}
}

View File

@@ -4,20 +4,31 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ report_title }}</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js" defer></script>
<style>{{ report_css }}</style>
</head>
<body>
<div class="container">
<h1>{{ report_title }}</h1>
<p class="info-item"><strong>统计截止时间:</strong> {{ generation_time }}</p>
<div class="header-meta">
<div class="info-item">
<span class="material-icons" style="font-size: 18px;">schedule</span>
<strong>统计截止时间:</strong> {{ generation_time }}
</div>
</div>
<div class="tabs">{{ tab_list }}</div>
{{ tab_content }}
<div class="footer">
<p>💡 提示: 点击图表上的图例可以切换数据显示 | 🔄 数据每5分钟自动更新一次</p>
<p style="margin-top: 10px; color: #94a3b8;">Powered by MoFox-Bot Statistics Engine</p>
</div>
</div>
<script>
const all_chart_data_json_string = `{{ all_chart_data|safe }}`;
const static_chart_data_json_string = `{{ static_chart_data|safe }}`;
</script>
<script type="application/json" id="all_chart_data">{{ all_chart_data|safe }}</script>
<script type="application/json" id="static_chart_data">{{ static_chart_data|safe }}</script>
<script>{{ report_js }}</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,53 @@
<h2>数据总览</h2>
<div class="chart-grid">
<div style="margin-bottom: 1.5rem;">
<h2 style="margin-top: 0; font-size: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
<span class="material-icons" style="color: var(--primary-color);">analytics</span>
数据可视化
</h2>
<p style="color: var(--text-secondary); font-size: 0.875rem; margin: 0;">
点击图例可以隐藏/显示对应的数据系列
</p>
</div>
<div style="display: flex; flex-direction: column; gap: 1.5rem;">
<!-- 原有图表 -->
<div class="chart-container">
<canvas id="providerCostPieChart_{{ period_id }}"></canvas>
<h3>💰 供应商成本分布</h3>
<div id="providerCostPieChart_{{ period_id }}" style="width: 100%; height: 280px;"></div>
</div>
<div class="chart-container">
<canvas id="modelCostBarChart_{{ period_id }}"></canvas>
<h3>📦 模块成本分布</h3>
<div id="moduleCostPieChart_{{ period_id }}" style="width: 100%; height: 280px;"></div>
</div>
<div class="chart-container">
<h3>🤖 模型成本对比</h3>
<div id="modelCostBarChart_{{ period_id }}" style="width: 100%; height: 350px;"></div>
</div>
<!-- 新增图表 -->
<div class="chart-container">
<h3>🔄 Token使用对比</h3>
<div id="tokenComparisonChart_{{ period_id }}" style="width: 100%; height: 350px;"></div>
</div>
<div class="chart-container">
<h3>📞 供应商请求占比</h3>
<div id="providerRequestsDoughnutChart_{{ period_id }}" style="width: 100%; height: 280px;"></div>
</div>
<div class="chart-container">
<h3>⚡ 平均响应时间</h3>
<div id="avgResponseTimeChart_{{ period_id }}" style="width: 100%; height: 350px;"></div>
</div>
<div class="chart-container">
<h3>🎯 模型效率雷达</h3>
<div id="modelEfficiencyRadarChart_{{ period_id }}" style="width: 100%; height: 380px;"></div>
</div>
<div class="chart-container">
<h3>⏱️ 响应时间分布</h3>
<div id="responseTimeScatterChart_{{ period_id }}" style="width: 100%; height: 350px;"></div>
</div>
</div>

View File

@@ -1,48 +1,106 @@
<div id="{{ div_id }}" class="tab-content">
<div class="info-item" style="margin-bottom: 2rem; width: 100%; display: block;">
<div style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem;">
<span class="material-icons" style="color: var(--primary-color);">menu_book</span>
<strong style="font-size: 1.1rem;">名词解释</strong>
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; font-size: 0.9rem;">
<div>
<strong style="color: var(--primary-color);">🎯 Token:</strong> AI处理文本的基本单位
</div>
<div>
<strong style="color: var(--primary-color);">💸 TPS:</strong> 每秒处理的Token数量
</div>
<div>
<strong style="color: var(--primary-color);">📊 每K Token成本:</strong> 每1000个Token的成本
</div>
<div>
<strong style="color: var(--primary-color);">⚡ Token效率:</strong> 输出/输入Token比率
</div>
<div>
<strong style="color: var(--primary-color);">🔄 消息/请求比:</strong> 每次请求处理的消息数
</div>
</div>
</div>
<div class="dashboard-layout">
<div class="main-content">
<p class="info-item">
<strong>统计时段: </strong>
{{ start_time }} ~ {{ end_time }}
</p>
<div class="header-meta">
<div class="info-item">
<span class="material-icons" style="font-size: 18px;">date_range</span>
<strong>统计时段: </strong> {{ start_time }} ~ {{ end_time }}
</div>
</div>
{{ summary_cards }}
<h2>按模型分类统计</h2>
<table>
<tr><th>模型名称</th><th>调用次数</th><th>平均Token数</th><th>Token总量</th><th>TPS</th><th>每K Token成本</th><th>累计花费</th><th>平均耗时(秒)</th></tr>
<tbody>{{ model_rows }}</tbody>
</table>
<h2>🤖 按模型分类统计</h2>
<div class="table-container">
<table>
<tr><th>模型名称</th><th>调用次数</th><th>平均Token数</th><th>Token总量</th><th>TPS</th><th>每K Token成本</th><th>累计花费</th><th>平均耗时(秒)</th></tr>
<tbody>{{ model_rows }}</tbody>
</table>
</div>
<h2>按供应商分类统计</h2>
<table>
<tr><th>供应商名称</th><th>调用次数</th><th>Token总量</th><th>TPS</th><th>每K Token成本</th><th>累计花费</th></tr>
<tbody>{{ provider_rows }}</tbody>
</table>
<h2>🏢 按供应商分类统计</h2>
<div class="table-container">
<table>
<thead>
<tr><th>供应商名称</th><th>调用次数</th><th>Token总量</th><th>TPS</th><th>每K Token成本</th><th>累计花费</th><th>平均耗时(秒)</th></tr>
</thead>
<tbody>{{ provider_rows }}</tbody>
</table>
</div>
<h2>按模块分类统计</h2>
<table>
<thead>
<tr><th>模块名称</th><th>调用次数</th><th>输入Token</th><th>输出Token</th><th>Token总量</th><th>累计花费</th><th>平均耗时(秒)</th><th>标准差(秒)</th></tr>
</thead>
<tbody>{{ module_rows }}</tbody>
</table>
<h2>🔧 按模块分类统计</h2>
<div class="table-container">
<table>
<thead>
<tr><th>模块名称</th><th>调用次数</th><th>输入Token</th><th>输出Token</th><th>Token总量</th><th>累计花费</th><th>平均耗时(秒)</th><th>标准差(秒)</th></tr>
</thead>
<tbody>{{ module_rows }}</tbody>
</table>
</div>
<h2>按请求类型分类统计</h2>
<table>
<thead>
<tr><th>请求类型</th><th>调用次数</th><th>输入Token</th><th>输出Token</th><th>Token总量</th><th>累计花费</th><th>平均耗时(秒)</th><th>标准差(秒)</th></tr>
</thead>
<tbody>{{ type_rows }}</tbody>
</table>
<h2>📝 按请求类型分类统计</h2>
<div class="table-container">
<table>
<thead>
<tr><th>请求类型</th><th>调用次数</th><th>输入Token</th><th>输出Token</th><th>Token总量</th><th>累计花费</th><th>平均耗时(秒)</th><th>标准差(秒)</th></tr>
</thead>
<tbody>{{ type_rows }}</tbody>
</table>
</div>
<h2>聊天消息统计</h2>
<table>
<thead>
<tr><th>联系人/群组名称</th><th>消息数量</th></tr>
</thead>
<tbody>{{ chat_rows }}</tbody>
</table>
<h2>💬 聊天消息统计</h2>
<div class="table-container">
<table>
<thead>
<tr><th>联系人/群组名称</th><th>消息数量</th></tr>
</thead>
<tbody>{{ chat_rows }}</tbody>
</table>
</div>
<h2>🎓 大模型效率分析</h2>
<div class="info-item" style="margin-bottom: 1rem;">
<span class="material-icons" style="font-size: 18px;">lightbulb</span>
<strong>提示:</strong> Token效率表示输出Token与输入Token的比率,比率越高说明模型输出越丰富
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>指标</th>
<th>数值</th>
<th>说明</th>
</tr>
</thead>
<tbody>{{ efficiency_rows }}</tbody>
</table>
</div>
</div>
<div class="sidebar-content">
{{ static_charts }}
</div>

View File

@@ -49,6 +49,7 @@ def is_mentioned_bot_in_message(message) -> tuple[bool, float]:
tuple[bool, float]: (是否提及, 提及类型)
提及类型: 0=未提及, 1=弱提及(文本匹配), 2=强提及(@/回复/私聊)
"""
assert global_config is not None
nicknames = global_config.bot.alias_names
mention_type = 0 # 0=未提及, 1=弱提及, 2=强提及
@@ -132,6 +133,7 @@ def is_mentioned_bot_in_message(message) -> tuple[bool, float]:
async def get_embedding(text, request_type="embedding") -> list[float] | None:
"""获取文本的embedding向量"""
assert model_config is not None
# 每次都创建新的LLMRequest实例以避免事件循环冲突
llm = LLMRequest(model_set=model_config.model_task_config.embedding, request_type=request_type)
try:
@@ -139,11 +141,12 @@ async def get_embedding(text, request_type="embedding") -> list[float] | None:
except Exception as e:
logger.error(f"获取embedding失败: {e!s}")
embedding = None
return embedding
return embedding # type: ignore
async def get_recent_group_speaker(chat_stream_id: str, sender, limit: int = 12) -> list:
# 获取当前群聊记录内发言的人
assert global_config is not None
filter_query = {"chat_id": chat_stream_id}
sort_order = [("time", -1)]
recent_messages = await find_messages(message_filter=filter_query, sort=sort_order, limit=limit)
@@ -400,11 +403,12 @@ def recover_quoted_content(sentences: list[str], placeholder_map: dict[str, str]
recovered_sentences.append(sentence)
return recovered_sentences
def process_llm_response(text: str, enable_splitter: bool = True, enable_chinese_typo: bool = True) -> list[str]:
assert global_config is not None
if not global_config.response_post_process.enable_response_post_process:
return [text]
# --- 三层防护系统 ---
# --- 三层防护系统 ---
# 第一层:保护颜文字
protected_text, kaomoji_mapping = protect_kaomoji(text) if global_config.response_splitter.enable_kaomoji_protection else (text, {})

View File

@@ -64,8 +64,6 @@ class ImageManager:
# except Exception as e:
# logger.error(f"数据库连接失败: {e}")
self._initialized = True
def _ensure_image_dir(self):
"""确保图像存储目录存在"""
os.makedirs(self.IMAGE_DIR, exist_ok=True)
@@ -159,6 +157,7 @@ class ImageManager:
async def get_emoji_description(self, image_base64: str) -> str:
"""获取表情包描述统一使用EmojiManager中的逻辑进行处理和缓存"""
try:
assert global_config is not None
from src.chat.emoji_system.emoji_manager import get_emoji_manager
emoji_manager = get_emoji_manager()
@@ -190,7 +189,7 @@ class ImageManager:
return "[表情包(描述生成失败)]"
# 4. (可选) 如果启用了“偷表情包”,则将图片和完整描述存入待注册区
if global_config and global_config.emoji and global_config.emoji.steal_emoji:
if global_config.emoji and global_config.emoji.steal_emoji:
logger.debug(f"偷取表情包功能已开启,保存待注册表情包: {image_hash}")
try:
image_format = (Image.open(io.BytesIO(image_bytes)).format or "jpeg").lower()
@@ -345,14 +344,15 @@ class ImageManager:
# --- 新的帧选择逻辑均匀抽取4帧 ---
num_frames = len(all_frames)
if num_frames <= 4:
# 如果总帧数小于等于4则全部选中
# 如果总宽度小于等于4则全部选中
selected_frames = all_frames
indices = list(range(num_frames))
else:
# 使用linspace计算4个均匀分布的索引
indices = np.linspace(0, num_frames - 1, 4, dtype=int)
selected_frames = [all_frames[i] for i in indices]
logger.debug(f"GIF Frame Analysis: Total frames={num_frames}, Selected indices={indices if num_frames > 4 else list(range(num_frames))}")
logger.debug(f"GIF Frame Analysis: Total frames={num_frames}, Selected indices={indices}")
# --- 帧选择逻辑结束 ---
# 如果选择后连一帧都没有比如GIF只有一帧且后续处理失败或者原始GIF就没帧也返回None

View File

@@ -37,13 +37,15 @@ _locks_guard = asyncio.Lock()
logger = get_logger("utils_video")
from inkfox import video
from inkfox import video # type: ignore
class VideoAnalyzer:
"""基于 inkfox 的视频关键帧 + LLM 描述分析器"""
def __init__(self) -> None:
assert global_config is not None
assert model_config is not None
cfg = getattr(global_config, "video_analysis", object())
self.max_frames: int = getattr(cfg, "max_frames", 20)
self.frame_quality: int = getattr(cfg, "frame_quality", 85)
@@ -121,7 +123,6 @@ class VideoAnalyzer:
# ---- 批量分析 ----
async def _analyze_batch(self, frames: list[tuple[str, float]], question: str | None) -> str:
from src.llm_models.payload_content.message import MessageBuilder, RoleType
from src.llm_models.utils_model import RequestType
prompt = self.batch_analysis_prompt.format(
personality_core=self.personality_core, personality_side=self.personality_side
@@ -137,12 +138,7 @@ class VideoAnalyzer:
for b64, _ in frames:
mb.add_image_content("jpeg", b64)
message = mb.build()
model_info, api_provider, client = self.video_llm._select_model()
resp = await self.video_llm._execute_request(
api_provider=api_provider,
client=client,
request_type=RequestType.RESPONSE,
model_info=model_info,
resp = await self.video_llm.execute_with_messages(
message_list=[message],
temperature=None,
max_tokens=None,

View File

@@ -31,9 +31,9 @@ def _extract_frames_worker(
max_image_size: int,
frame_extraction_mode: str,
frame_interval_seconds: float | None,
) -> list[Any] | list[tuple[str, str]]:
) -> list[tuple[str, float]] | list[tuple[str, str]]:
"""线程池中提取视频帧的工作函数"""
frames = []
frames: list[tuple[str, float]] = []
try:
cap = cv2.VideoCapture(video_path)
fps = cap.get(cv2.CAP_PROP_FPS)
@@ -42,7 +42,7 @@ def _extract_frames_worker(
if frame_extraction_mode == "time_interval":
# 新模式:按时间间隔抽帧
time_interval = frame_interval_seconds
time_interval = frame_interval_seconds or 2.0
next_frame_time = 0.0
extracted_count = 0 # 初始化提取帧计数器
@@ -61,7 +61,7 @@ def _extract_frames_worker(
# 调整图像大小
if max(pil_image.size) > max_image_size:
ratio = max_image_size / max(pil_image.size)
new_size = tuple(int(dim * ratio) for dim in pil_image.size)
new_size = (int(pil_image.size[0] * ratio), int(pil_image.size[1] * ratio))
pil_image = pil_image.resize(new_size, Image.Resampling.LANCZOS)
# 转换为base64
@@ -135,6 +135,8 @@ class LegacyVideoAnalyzer:
def __init__(self):
"""初始化视频分析器"""
assert global_config is not None
assert model_config is not None
# 使用专用的视频分析配置
try:
self.video_llm = LLMRequest(
@@ -238,6 +240,7 @@ class LegacyVideoAnalyzer:
estimated_frames = min(self.max_frames, total_frames // frame_interval + 1)
else:
estimated_frames = self.max_frames
frame_interval = 1
logger.info(f"计算得出帧间隔: {frame_interval} (将提取约{estimated_frames}帧)")
@@ -274,7 +277,7 @@ class LegacyVideoAnalyzer:
return await self._extract_frames_fallback(video_path)
logger.info(f"✅ 成功提取{len(frames)}帧 (线程池模式)")
return frames
return frames # type: ignore
except Exception as e:
logger.error(f"线程池帧提取失败: {e}")
@@ -313,7 +316,7 @@ class LegacyVideoAnalyzer:
# 调整图像大小
if max(pil_image.size) > self.max_image_size:
ratio = self.max_image_size / max(pil_image.size)
new_size = tuple(int(dim * ratio) for dim in pil_image.size)
new_size = (int(pil_image.size[0] * ratio), int(pil_image.size[1] * ratio))
pil_image = pil_image.resize(new_size, Image.Resampling.LANCZOS)
# 转换为base64
@@ -461,11 +464,11 @@ class LegacyVideoAnalyzer:
# logger.info(f"✅ 多帧消息构建完成,包含{len(frames)}张图片")
# 获取模型信息和客户端
model_info, api_provider, client = self.video_llm._select_model()
model_info, api_provider, client = self.video_llm._select_model() # type: ignore
# logger.info(f"使用模型: {model_info.name} 进行多帧分析")
# 直接执行多图片请求
api_response = await self.video_llm._execute_request(
api_response = await self.video_llm._execute_request( # type: ignore
api_provider=api_provider,
client=client,
request_type=RequestType.RESPONSE,

View File

@@ -11,6 +11,8 @@ logger = get_logger("chat_voice")
async def get_voice_text(voice_base64: str) -> str:
"""获取音频文件转录文本"""
assert global_config is not None
assert model_config is not None
if not global_config.voice.enable_asr:
logger.warning("语音识别未启用,无法处理语音消息")
return "[语音]"