Merge branch 'dev' into feature/kfc
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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]}...")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
# 全局能量管理器实例
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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("', '")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
# 检查是否需要处理消息
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 加载当前可用的动作
|
||||
|
||||
@@ -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 # 已禁用,保留函数签名以防其他地方有引用
|
||||
|
||||
# 以下代码已废弃,不再执行
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]:
|
||||
"""为S4U(Scene 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 ""
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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效率 (输出/输入比率)
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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, {})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 "[语音]"
|
||||
|
||||
Reference in New Issue
Block a user