From 505aa839b61e92e1eacd49e2b6825e00d63e8431 Mon Sep 17 00:00:00 2001 From: Bakadax Date: Fri, 16 May 2025 15:30:19 +0800 Subject: [PATCH] ruff --- .../observation/chatting_observation.py | 2 +- src/chat/heart_flow/subheartflow_manager.py | 4 +- src/chat/knowledge/src/qa_manager.py | 10 +- src/chat/message_receive/message.py | 1 - src/chat/models/utils_model.py | 1607 +++++------------ src/chat/person_info/person_info.py | 2 +- src/chat/person_info/relationship_manager.py | 8 +- src/chat/utils/statistic.py | 757 ++++---- src/config/config.py | 26 +- .../Legacy_HFC/heartFC_chatting_logic.md | 92 - src/experimental/Legacy_HFC/heartFC_readme.md | 159 -- .../Legacy_HFC/heart_flow/0.6Bing.md | 94 - .../Legacy_HFC/heart_flow/README.md | 241 --- template/bot_config_template.toml | 3 +- template/template.env | 18 +- 15 files changed, 851 insertions(+), 2173 deletions(-) delete mode 100644 src/experimental/Legacy_HFC/heartFC_chatting_logic.md delete mode 100644 src/experimental/Legacy_HFC/heartFC_readme.md delete mode 100644 src/experimental/Legacy_HFC/heart_flow/0.6Bing.md delete mode 100644 src/experimental/Legacy_HFC/heart_flow/README.md diff --git a/src/chat/heart_flow/observation/chatting_observation.py b/src/chat/heart_flow/observation/chatting_observation.py index ccc414c9..a51eba5e 100644 --- a/src/chat/heart_flow/observation/chatting_observation.py +++ b/src/chat/heart_flow/observation/chatting_observation.py @@ -81,7 +81,7 @@ class ChattingObservation(Observation): mid_memory_str = "" if ids: for id in ids: - # print(f"id:{id}") + print(f"id:{id}") try: for mid_memory in self.mid_memorys: if mid_memory["id"] == id: diff --git a/src/chat/heart_flow/subheartflow_manager.py b/src/chat/heart_flow/subheartflow_manager.py index 1ab17339..cd452e53 100644 --- a/src/chat/heart_flow/subheartflow_manager.py +++ b/src/chat/heart_flow/subheartflow_manager.py @@ -284,7 +284,7 @@ class SubHeartflowManager: return # 如果不允许,直接返回 # --- 结束新增 --- - logger.debug(f"当前状态 ({current_state.value}) 可以在{focused_limit}个群 专注聊天") + logger.info(f"当前状态 ({current_state.value}) 可以在{focused_limit}个群 专注聊天") if focused_limit <= 0: # logger.debug(f"{log_prefix} 当前状态 ({current_state.value}) 不允许 FOCUSED 子心流") @@ -402,7 +402,7 @@ class SubHeartflowManager: _mai_state_description = f"你当前状态: {current_mai_state.value}。" individuality = Individuality.get_instance() personality_prompt = individuality.get_prompt(x_person=2, level=3) - prompt_personality = f"你是{individuality.name},{personality_prompt}" + prompt_personality = f"你正在扮演名为{individuality.name}的人类,{personality_prompt}" # --- 修改:在 prompt 中加入当前聊天计数和群名信息 (条件显示) --- chat_status_lines = [] diff --git a/src/chat/knowledge/src/qa_manager.py b/src/chat/knowledge/src/qa_manager.py index 8f9266d6..06c21e88 100644 --- a/src/chat/knowledge/src/qa_manager.py +++ b/src/chat/knowledge/src/qa_manager.py @@ -61,7 +61,7 @@ class QAManager: for res in relation_search_res: rel_str = self.embed_manager.relation_embedding_store.store.get(res[0]).str - logger.debug(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") @@ -77,16 +77,16 @@ class QAManager: logger.debug(f"文段检索用时:{part_end_time - part_start_time:.5f}s") if len(relation_search_res) != 0: - logger.debug("找到相关关系,将使用RAG进行检索") + logger.info("找到相关关系,将使用RAG进行检索") # 使用KG检索 part_start_time = time.perf_counter() result, ppr_node_weights = self.kg_manager.kg_search( relation_search_res, paragraph_search_res, self.embed_manager ) part_end_time = time.perf_counter() - logger.debug(f"RAG检索用时:{part_end_time - part_start_time:.5f}s") + logger.infoinfo(f"RAG检索用时:{part_end_time - part_start_time:.5f}s") else: - logger.debug("未找到相关关系,将使用文段检索结果") + logger.infoinfo("未找到相关关系,将使用文段检索结果") result = paragraph_search_res ppr_node_weights = None @@ -95,7 +95,7 @@ class QAManager: for res in result: raw_paragraph = self.embed_manager.paragraphs_embedding_store.store[res[0]].str - logger.debug(f"找到相关文段,相关系数:{res[1]:.8f}\n{raw_paragraph}\n\n") + logger.info(f"找到相关文段,相关系数:{res[1]:.8f}\n{raw_paragraph}\n\n") return result, ppr_node_weights else: diff --git a/src/chat/message_receive/message.py b/src/chat/message_receive/message.py index b5b0f6e7..a42a11a8 100644 --- a/src/chat/message_receive/message.py +++ b/src/chat/message_receive/message.py @@ -1,4 +1,3 @@ -# TODO: 原生多模态支持 import time from abc import abstractmethod from dataclasses import dataclass diff --git a/src/chat/models/utils_model.py b/src/chat/models/utils_model.py index ee810bf3..35f445a2 100644 --- a/src/chat/models/utils_model.py +++ b/src/chat/models/utils_model.py @@ -1,43 +1,29 @@ import asyncio import json -import random # 添加 random 模块导入 import re from datetime import datetime -from typing import Tuple, Union, Dict, Any, Set # 引入 Set +from typing import Tuple, Union, Dict, Any import aiohttp from aiohttp.client import ClientResponse -# 相对路径导入,根据你的项目结构调整 -# 例如,如果 utils_model.py 在 src/utils/ 下,而 logger 在 src/common/ 下 -# from ..common.logger import get_module_logger -# from ..common.database import db -# from ..config.config import global_config -# 假设它们在期望的路径 from src.common.logger import get_module_logger -from ...common.database import db -from ...config.config import global_config - - import base64 from PIL import Image import io import os - +from ...common.database import db +from ...config.config import global_config from rich.traceback import install install(extra_lines=3) -# 尝试加载 .env 文件中的环境变量 (如果项目结构需要) -# load_dotenv() # 如果你的 .env 文件不在标准位置,可能需要指定路径 load_dotenv(dotenv_path='path/to/.env') - logger = get_module_logger("model_utils") class PayLoadTooLargeError(Exception): """自定义异常类,用于处理请求体过大错误""" - # (代码不变) def __init__(self, message: str): super().__init__(message) self.message = message @@ -49,7 +35,6 @@ class PayLoadTooLargeError(Exception): class RequestAbortException(Exception): """自定义异常类,用于处理请求中断异常""" - # (代码不变) def __init__(self, message: str, response: ClientResponse): super().__init__(message) self.message = message @@ -62,31 +47,20 @@ class RequestAbortException(Exception): class PermissionDeniedException(Exception): """自定义异常类,用于处理访问拒绝的异常""" - # (代码不变) - def __init__(self, message: str, key_identifier: str = None): # 添加 key 标识符 + def __init__(self, message: str): super().__init__(message) self.message = message - self.key_identifier = key_identifier # 存储导致 403 的 key def __str__(self): return self.message -# 新增:用于内部标记需要切换 Key 的异常 -class _SwitchKeyException(Exception): - """内部异常,用于标记需要切换Key并且跳过标准等待时间.""" - - # (代码不变) - pass - - # 常见Error Code Mapping error_code_mapping = { - # (代码不变) 400: "参数不正确", - 401: "API key 错误,认证失败,请检查/config/bot_config.toml和.env中的配置是否正确哦~", # 401 也可能是 Key 无效 + 401: "API key 错误,认证失败,请检查/config/bot_config.toml和.env中的配置是否正确哦~", 402: "账号余额不足", - 403: "需要实名,或余额不足,或Key无权限", # 扩展 403 的含义 + 403: "需要实名,或余额不足", 404: "Not Found", 429: "请求过于频繁,请稍后再试", 500: "服务器内部故障", @@ -95,42 +69,29 @@ error_code_mapping = { async def _safely_record(request_content: Dict[str, Any], payload: Dict[str, Any]): - """安全地记录请求内容,隐藏敏感信息""" - # (代码不变) image_base64: str = request_content.get("image_base64") image_format: str = request_content.get("image_format") - is_gemini_payload = payload and isinstance(payload, dict) and "contents" in payload - safe_payload = json.loads(json.dumps(payload)) if payload else {} - - if image_base64 and safe_payload and isinstance(safe_payload, dict): - if "messages" in safe_payload and len(safe_payload["messages"]) > 0: - if isinstance(safe_payload["messages"][0], dict) and "content" in safe_payload["messages"][0]: - content = safe_payload["messages"][0]["content"] - if ( - isinstance(content, list) - and len(content) > 1 - and isinstance(content[1], dict) - and "image_url" in content[1] - ): - safe_payload["messages"][0]["content"][1]["image_url"]["url"] = ( - f"data:image/{image_format.lower() if image_format else 'jpeg'};base64," - f"{image_base64[:10]}...{image_base64[-10:]}" - ) - elif is_gemini_payload and "contents" in safe_payload and len(safe_payload["contents"]) > 0: - if isinstance(safe_payload["contents"][0], dict) and "parts" in safe_payload["contents"][0]: - parts = safe_payload["contents"][0]["parts"] - for i, part in enumerate(parts): - if isinstance(part, dict) and "inlineData" in part: - safe_payload["contents"][0]["parts"][i]["inlineData"]["data"] = ( - f"{image_base64[:10]}...{image_base64[-10:]}" - ) - break - - return safe_payload + if ( + image_base64 + and payload + and isinstance(payload, dict) + and "messages" in payload + and len(payload["messages"]) > 0 + ): + if isinstance(payload["messages"][0], dict) and "content" in payload["messages"][0]: + content = payload["messages"][0]["content"] + if isinstance(content, list) and len(content) > 1 and "image_url" in content[1]: + payload["messages"][0]["content"][1]["image_url"]["url"] = ( + f"data:image/{image_format.lower() if image_format else 'jpeg'};base64," + f"{image_base64[:10]}...{image_base64[-10:]}" + ) + # if isinstance(content, str) and len(content) > 100: + # payload["messages"][0]["content"] = content[:100] + return payload class LLMRequest: - # (代码不变) + # 定义需要转换的模型列表,作为类变量避免重复 MODELS_NEEDING_TRANSFORMATION = [ "o1", "o1-2024-12-17", @@ -146,170 +107,34 @@ class LLMRequest: "o3-mini-2025-01-31o4-mini", "o4-mini-2025-04-16", ] - _abandoned_keys_runtime: Set[str] = set() def __init__(self, model: dict, **kwargs): - """初始化 LLMRequest 实例""" - # (代码不变) - self.model_key_name = model["key"] - self.model_name: str = model["name"] - self.params = kwargs - self.stream = model.get("stream", False) - self.pri_in = model.get("pri_in", 0) - self.pri_out = model.get("pri_out", 0) - self.request_type = model.get("request_type", "default") - + # 将大写的配置键转换为小写并从config中获取实际值 try: - raw_api_key_config = os.environ[self.model_key_name] + self.api_key = os.environ[model["key"]] self.base_url = os.environ[model["base_url"]] - self.is_gemini = "googleapis.com" in self.base_url.lower() - if self.is_gemini: - logger.debug(f"模型 {self.model_name}: 检测到为 Gemini API (Base URL: {self.base_url})") - if self.stream: - logger.warning(f"模型 {self.model_name}: Gemini 流式输出处理与 OpenAI 不同,暂时强制禁用流式。") - self.stream = False - - # 解析和过滤 API Keys (代码不变) - parsed_keys = [] - is_list_config = False - try: - loaded_keys = json.loads(raw_api_key_config) - if isinstance(loaded_keys, list): - parsed_keys = [str(key) for key in loaded_keys if key] - is_list_config = True - elif isinstance(loaded_keys, str) and loaded_keys: - parsed_keys = [loaded_keys] - else: - raise ValueError(f"Parsed API key for {self.model_key_name} is not a valid list or string.") - except (json.JSONDecodeError, TypeError) as e: - if isinstance(raw_api_key_config, list): - parsed_keys = [str(key) for key in raw_api_key_config if key] - is_list_config = True - elif isinstance(raw_api_key_config, str) and raw_api_key_config: - parsed_keys = [raw_api_key_config] - else: - raise ValueError( - f"Invalid or empty API key config for {self.model_key_name}: {raw_api_key_config}" - ) from e - - if not parsed_keys: - raise ValueError(f"No valid API keys found for {self.model_key_name}.") - - abandoned_key_name = f"abandon_{self.model_key_name}" - abandoned_keys_set = set() - raw_abandoned_keys = os.environ.get(abandoned_key_name) - - if raw_abandoned_keys: - try: - loaded_abandoned = json.loads(raw_abandoned_keys) - if isinstance(loaded_abandoned, list): - abandoned_keys_set.update(str(key) for key in loaded_abandoned if key) - elif isinstance(loaded_abandoned, str) and loaded_abandoned: - abandoned_keys_set.add(loaded_abandoned) - logger.info( - f"模型 {model['name']}: 加载了 {len(abandoned_keys_set)} 个来自配置 '{abandoned_key_name}' 的废弃 Keys。" - ) - except (json.JSONDecodeError, TypeError): - if isinstance(raw_abandoned_keys, list): - abandoned_keys_set.update(str(key) for key in raw_abandoned_keys if key) - logger.info( - f"模型 {model['name']}: 加载了 {len(abandoned_keys_set)} 个来自配置 '{abandoned_key_name}' (直接列表) 的废弃 Keys。" - ) - elif isinstance(raw_abandoned_keys, str) and raw_abandoned_keys: - abandoned_keys_set.add(raw_abandoned_keys) - logger.info( - f"模型 {model['name']}: 加载了 1 个来自配置 '{abandoned_key_name}' (字符串) 的废弃 Key。" - ) - else: - logger.warning(f"无法解析环境变量 '{abandoned_key_name}' 的内容: {raw_abandoned_keys}") - - all_abandoned_keys = abandoned_keys_set.union(LLMRequest._abandoned_keys_runtime) - active_keys = [key for key in parsed_keys if key not in all_abandoned_keys] - - if not active_keys: - logger.error(f"模型 {model['name']}: 所有为 '{self.model_key_name}' 配置的 Keys 都已被废弃或无效。") - raise ValueError( - f"No active API keys available for {self.model_key_name} after filtering abandoned keys." - ) - - if is_list_config and len(active_keys) > 1: - self._api_key_config = active_keys - logger.info( - f"模型 {model['name']}: 初始化完成,可用 Keys: {len(self._api_key_config)} (已排除 {len(all_abandoned_keys)} 个废弃 Keys)。" - ) - elif active_keys: - self._api_key_config = active_keys[0] - logger.info( - f"模型 {model['name']}: 初始化完成,使用单个活动 Key (已排除 {len(all_abandoned_keys)} 个废弃 Keys)。" - ) - else: - raise ValueError(f"Unexpected state: No active keys for {self.model_key_name}.") - - # 加载代理配置 (代码不变) - self.proxy_url = None - self.proxy_models_set = set() - proxy_host = os.environ.get("PROXY_HOST") - proxy_port = os.environ.get("PROXY_PORT") - proxy_models_str = os.environ.get("PROXY_MODELS", "") - - if proxy_host and proxy_port: - try: - int(proxy_port) - self.proxy_url = f"http://{proxy_host}:{proxy_port}" - logger.debug(f"代理已配置: {self.proxy_url}") - - if proxy_models_str: - try: - cleaned_str = proxy_models_str.strip("'\"") - self.proxy_models_set = { - model_name.strip() for model_name in cleaned_str.split(",") if model_name.strip() - } - logger.debug(f"以下模型将使用代理: {self.proxy_models_set}") - except Exception as e: - logger.error( - f"解析 PROXY_MODELS ('{proxy_models_str}') 出错: {e}. 代理将不会对特定模型生效。" - ) - self.proxy_models_set = set() - except ValueError: - logger.error(f"无效的代理端口号: {proxy_port}。代理将不被启用。") - self.proxy_url = None - self.proxy_models_set = set() - except Exception as e: - logger.error(f"加载代理配置时发生错误: {e}") - self.proxy_url = None - self.proxy_models_set = set() - else: - logger.info("未配置代理服务器 (PROXY_HOST 或 PROXY_PORT 未设置)。") - - except KeyError as e: - # (代码不变) - missing_key = str(e).strip("'") - if missing_key == self.model_key_name: - logger.error(f"配置错误:找不到 API Key 环境变量 '{self.model_key_name}'") - raise ValueError(f"配置错误:找不到 API Key 环境变量 '{self.model_key_name}'") from e - elif missing_key == model["base_url"]: - logger.error(f"配置错误:找不到 Base URL 环境变量 '{model['base_url']}'") - raise ValueError(f"配置错误:找不到 Base URL 环境变量 '{model['base_url']}'") from e - else: - logger.error(f"配置错误:找不到环境变量 - {str(e)}") - raise ValueError(f"配置错误:找不到环境变量 - {str(e)}") from e except AttributeError as e: - # (代码不变) logger.error(f"原始 model dict 信息:{model}") logger.error(f"配置错误:找不到对应的配置项 - {str(e)}") raise ValueError(f"配置错误:找不到对应的配置项 - {str(e)}") from e - except ValueError as e: - # (代码不变) - logger.error(f"API Key 或配置初始化错误 for {self.model_key_name}: {str(e)}") - raise e + self.model_name: str = model["name"] + self.params = kwargs + self.stream = model.get("stream", False) + self.pri_in = model.get("pri_in", 0) + self.pri_out = model.get("pri_out", 0) + + # 获取数据库实例 self._init_database() + # 从 kwargs 中提取 request_type,如果没有提供则默认为 "default" + self.request_type = kwargs.pop("request_type", "default") + @staticmethod def _init_database(): """初始化数据库集合""" - # (代码不变) try: + # 创建llm_usage集合的索引 db.llm_usage.create_index([("timestamp", 1)]) db.llm_usage.create_index([("model_name", 1)]) db.llm_usage.create_index([("user_id", 1)]) @@ -339,19 +164,12 @@ class LLMRequest: if request_type is None: request_type = self.request_type - actual_endpoint = endpoint - if self.is_gemini: - if endpoint == "/embeddings": - actual_endpoint = ":embedContent" - else: - actual_endpoint = ":generateContent" - try: usage_data = { "model_name": self.model_name, "user_id": user_id, - "request_type": request_type or self.request_type, - "endpoint": actual_endpoint, + "request_type": request_type, + "endpoint": endpoint, "prompt_tokens": prompt_tokens, "completion_tokens": completion_tokens, "total_tokens": total_tokens, @@ -362,7 +180,7 @@ class LLMRequest: db.llm_usage.insert_one(usage_data) logger.trace( f"Token使用情况 - 模型: {self.model_name}, " - f"用户: {user_id}, 类型: {request_type or self.request_type}, " + f"用户: {user_id}, 类型: {request_type}, " f"提示词: {prompt_tokens}, 完成: {completion_tokens}, " f"总计: {total_tokens}" ) @@ -370,8 +188,17 @@ class LLMRequest: logger.error(f"记录token使用情况失败: {str(e)}") def _calculate_cost(self, prompt_tokens: int, completion_tokens: int) -> float: - """计算API调用成本""" - # (代码不变) + """计算API调用成本 + 使用模型的pri_in和pri_out价格计算输入和输出的成本 + + Args: + prompt_tokens: 输入token数量 + completion_tokens: 输出token数量 + + Returns: + float: 总成本(元) + """ + # 使用模型的pri_in和pri_out计算成本 input_cost = (prompt_tokens / 1000000) * self.pri_in output_cost = (completion_tokens / 1000000) * self.pri_out return round(input_cost + output_cost, 6) @@ -384,43 +211,46 @@ class LLMRequest: image_format: str = None, payload: dict = None, retry_policy: dict = None, - **kwargs: Any, ) -> Dict[str, Any]: - """配置请求参数,合并实例参数和调用时参数""" + """配置请求参数 + Args: + endpoint: API端点路径 (如 "chat/completions") + prompt: prompt文本 + image_base64: 图片的base64编码 + image_format: 图片格式 + payload: 请求体数据 + retry_policy: 自定义重试策略 + request_type: 请求类型 + """ + + # 合并重试策略 default_retry = { - "max_retries": global_config.api_polling_max_retries, + "max_retries": 3, "base_wait": 10, "retry_codes": [429, 413, 500, 503], "abort_codes": [400, 401, 402, 403], } policy = {**default_retry, **(retry_policy or {})} - _actual_endpoint = endpoint - if self.is_gemini: - action = endpoint.lstrip("/") - api_url = f"{self.base_url.rstrip('/')}/{self.model_name}{action}" - stream_mode = False - else: - api_url = f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}" - stream_mode = self.stream + api_url = f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}" - call_params = {k: v for k, v in kwargs.items() if k != "request_type"} - merged_params = {**self.params, **call_params} + stream_mode = self.stream - if payload is None: - payload = await self._build_payload(prompt, image_base64, image_format, merged_params) - else: - logger.debug("使用外部提供的 payload,忽略单次调用参数合并。") + # 构建请求体 + if image_base64: + payload = await self._build_payload(prompt, image_base64, image_format) + elif payload is None: + payload = await self._build_payload(prompt) - if not self.is_gemini and stream_mode: - payload["stream"] = merged_params.get("stream", stream_mode) + if stream_mode: + payload["stream"] = stream_mode return { "policy": policy, "payload": payload, "api_url": api_url, - "stream_mode": payload.get("stream", False), - "image_base64": image_base64, + "stream_mode": stream_mode, + "image_base64": image_base64, # 保留必要的exception处理所需的原始数据 "image_format": image_format, "prompt": prompt, } @@ -436,286 +266,87 @@ class LLMRequest: response_handler: callable = None, user_id: str = "system", request_type: str = None, - **kwargs: Any, ): - """统一请求执行入口, 支持列表 key 切换、代理和单次调用参数覆盖""" - final_request_type = request_type or kwargs.get("request_type") or self.request_type - api_kwargs = {k: v for k, v in kwargs.items() if k != "request_type"} - + """统一请求执行入口 + Args: + endpoint: API端点路径 (如 "chat/completions") + prompt: prompt文本 + image_base64: 图片的base64编码 + image_format: 图片格式 + payload: 请求体数据 + retry_policy: 自定义重试策略 + response_handler: 自定义响应处理器 + user_id: 用户ID + request_type: 请求类型 + """ + # 获取请求配置 request_content = await self._prepare_request( - endpoint, prompt, image_base64, image_format, payload, retry_policy, **api_kwargs + endpoint, prompt, image_base64, image_format, payload, retry_policy ) - policy = request_content["policy"] - api_url = request_content["api_url"] - actual_payload = request_content["payload"] - stream_mode = request_content["stream_mode"] - - use_proxy = False - current_proxy_url = None - if self.proxy_url and self.model_name in self.proxy_models_set: - use_proxy = True - current_proxy_url = self.proxy_url - logger.debug(f"模型 {self.model_name}: 将通过代理 {current_proxy_url} 发送请求。") - elif self.proxy_url: - logger.debug(f"模型 {self.model_name}: 配置了代理,但此模型不在 PROXY_MODELS 列表中,将不使用代理。") - else: - logger.debug(f"模型 {self.model_name}: 未配置或不为此模型使用代理。") - - current_key = None - keys_failed_429 = set() - keys_abandoned_runtime = set() - key_switch_limit_429 = global_config.api_polling_max_retries - key_switch_limit_403 = global_config.api_polling_max_retries - - available_keys_pool = [] - is_key_list = isinstance(self._api_key_config, list) - - if is_key_list: - available_keys_pool = list(self._api_key_config) - if not available_keys_pool: - logger.error(f"模型 {self.model_name}: 初始化后无可用活动 Keys。") - raise ValueError(f"模型 {self.model_name}: 无可用活动 Keys。") - random.shuffle(available_keys_pool) - key_switch_limit_429 = min(key_switch_limit_429, len(available_keys_pool)) - key_switch_limit_403 = min(key_switch_limit_403, len(available_keys_pool)) - logger.info( - f"模型 {self.model_name}: Key 列表模式,启用 429/403 自动切换(429上限: {key_switch_limit_429}, 403上限: {key_switch_limit_403})。" - ) - elif isinstance(self._api_key_config, str): - available_keys_pool = [self._api_key_config] - key_switch_limit_429 = 1 - key_switch_limit_403 = 1 - else: - logger.error(f"模型 {self.model_name}: 无效的 API Key 配置类型在执行时遇到: {type(self._api_key_config)}") - raise TypeError(f"模型 {self.model_name}: 无效的 API Key 配置类型") - - last_exception = None - - for attempt in range(policy["max_retries"]): - if available_keys_pool: - current_key = available_keys_pool.pop(0) - elif current_key: - logger.debug( - f"模型 {self.model_name}: 无新 Key 可用或为单 Key 模式,将使用 Key ...{current_key[-4:]} 进行重试 (第 {attempt + 1} 次尝试)" - ) - else: - if ( - not self._api_key_config - or all( - k in LLMRequest._abandoned_keys_runtime - for k in self._api_key_config - if isinstance(self._api_key_config, list) - ) - or ( - isinstance(self._api_key_config, str) - and self._api_key_config in LLMRequest._abandoned_keys_runtime - ) - ): - final_error_msg = f"模型 {self.model_name}: 所有可用 API Keys 均因 403 错误被禁用。" - logger.critical(final_error_msg) - raise PermissionDeniedException(final_error_msg) - else: - raise RuntimeError(f"模型 {self.model_name}: 无法选择 API key (第 {attempt + 1} 次尝试)") - - logger.debug(f"模型 {self.model_name}: 尝试使用 Key: ...{current_key[-4:]} (总第 {attempt + 1} 次尝试)") - + if request_type is None: + request_type = self.request_type + for retry in range(request_content["policy"]["max_retries"]): try: - headers = await self._build_headers(current_key) - if not self.is_gemini and stream_mode: + # 使用上下文管理器处理会话 + headers = await self._build_headers() + # 似乎是openai流式必须要的东西,不过阿里云的qwq-plus加了这个没有影响 + if request_content["stream_mode"]: headers["Accept"] = "text/event-stream" - async with aiohttp.ClientSession() as session: - post_kwargs = {"headers": headers, "json": actual_payload, "timeout": 60} - if use_proxy: - post_kwargs["proxy"] = current_proxy_url - - async with session.post(api_url, **post_kwargs) as response: - if response.status == 429 and is_key_list: - logger.warning(f"模型 {self.model_name}: Key ...{current_key[-4:]} 遇到 429 错误。") - response_text = await response.text() - logger.debug( - f"模型 {self.model_name}: Key ...{current_key[-4:]} response:\n{json.dumps(json.loads(response_text), indent=2, ensure_ascii=False)}\napi_url:\n{api_url}\nheader:\n{headers}\npayload:\n{actual_payload}" - ) - if current_key not in keys_failed_429: - keys_failed_429.add(current_key) - logger.info( - f" (因 429 已失败 {len(keys_failed_429)}/{key_switch_limit_429} 个不同 Key)" - ) - if available_keys_pool and len(keys_failed_429) < key_switch_limit_429: - logger.info(" 尝试因 429 切换到下一个可用 Key...") - raise _SwitchKeyException() - else: - logger.warning(" 无更多 Key 可因 429 切换或已达上限。") - else: - logger.warning(f" Key ...{current_key[-4:]} 再次遇到 429,按标准重试流程。") - - elif response.status == 403 and is_key_list: - logger.error( - f"模型 {self.model_name}: Key ...{current_key[-4:]} 遇到 403 (权限拒绝) 错误。" - ) - if current_key not in keys_abandoned_runtime: - keys_abandoned_runtime.add(current_key) - LLMRequest._abandoned_keys_runtime.add(current_key) - logger.critical( - f" !! Key ...{current_key[-4:]} 已添加到运行时废弃列表。请考虑将其移至配置中的 'abandon_{self.model_key_name}' !!" - ) - if current_key in available_keys_pool: - available_keys_pool.remove(current_key) - if available_keys_pool and len(keys_abandoned_runtime) < key_switch_limit_403: - logger.info(" 尝试因 403 切换到下一个可用 Key...") - raise _SwitchKeyException() - else: - logger.error(" 无更多 Key 可因 403 切换或已达上限。将中止请求。") - await response.read() - raise PermissionDeniedException( - f"Key ...{current_key[-4:]} 权限被拒,且无其他可用 Key 切换。", - key_identifier=current_key, - ) - else: - logger.error(f" Key ...{current_key[-4:]} 再次遇到 403,这不应发生。中止请求。") - await response.read() - raise PermissionDeniedException( - f"Key ...{current_key[-4:]} 重复遇到 403。", key_identifier=current_key - ) - - elif response.status in policy["retry_codes"] or response.status in policy["abort_codes"]: - await self._handle_error_response(response, attempt, policy, current_key) - - if response.status in policy["retry_codes"] and attempt < policy["max_retries"] - 1: - if response.status not in [429, 403]: - wait_time = policy["base_wait"] * (2**attempt) - logger.warning( - f"模型 {self.model_name}: 遇到可重试错误 {response.status}, 等待 {wait_time} 秒后重试..." - ) - await asyncio.sleep(wait_time) - last_exception = RuntimeError(f"重试错误 {response.status}") - continue - - if response.status in policy["abort_codes"] or ( - response.status in policy["retry_codes"] and attempt >= policy["max_retries"] - 1 - ): - if attempt >= policy["max_retries"] - 1 and response.status in policy["retry_codes"]: - logger.error( - f"模型 {self.model_name}: 达到最大重试次数,最后一次尝试仍为可重试错误 {response.status}。" - ) - # await self._handle_error_response(response, attempt, policy, current_key) - # await response.read() - # final_error_msg = f"请求中止或达到最大重试次数,最终状态码: {response.status}" - # logger.error(final_error_msg) - # raise RequestAbortException(final_error_msg, response) - - response.raise_for_status() - result = {} - if not self.is_gemini and stream_mode: - result = await self._handle_stream_output(response) - else: - result = await response.json() - - return ( - response_handler(result) - if response_handler - else self._default_response_handler(result, user_id, final_request_type, endpoint) + async with session.post( + request_content["api_url"], headers=headers, json=request_content["payload"] + ) as response: + handled_result = await self._handle_response( + response, request_content, retry, response_handler, user_id, request_type, endpoint ) - - except _SwitchKeyException: - last_exception = _SwitchKeyException() - logger.debug("捕获到 _SwitchKeyException,立即进行下一次尝试。") - continue - except PermissionDeniedException as e: - logger.error(f"模型 {self.model_name}: 因权限拒绝 (403) 中止请求: {e}") - if is_key_list and not available_keys_pool and e.key_identifier: - logger.critical(f" 中止原因是 Key ...{e.key_identifier[-4:]} 触发 403 后已无其他 Key 可用。") - raise e - except aiohttp.ClientProxyConnectionError as e: - logger.error(f"代理连接错误: {e} (代理地址: {current_proxy_url})") - last_exception = e - if attempt >= policy["max_retries"] - 1: - raise RuntimeError(f"代理连接失败达到最大重试次数: {e}") from e - wait_time = policy["base_wait"] * (2**attempt) - logger.warning(f"模型 {self.model_name}: 代理连接错误,等待 {wait_time} 秒后重试...") - await asyncio.sleep(wait_time) - continue - except aiohttp.ClientConnectorError as e: - logger.error(f"网络连接错误: {e} (URL: {api_url}, 代理: {current_proxy_url})") - last_exception = e - if attempt >= policy["max_retries"] - 1: - raise RuntimeError(f"网络连接失败达到最大重试次数: {e}") from e - wait_time = policy["base_wait"] * (2**attempt) - logger.warning(f"模型 {self.model_name}: 网络连接错误,等待 {wait_time} 秒后重试...") - await asyncio.sleep(wait_time) - continue - except (PayLoadTooLargeError, RequestAbortException) as e: - # (代码不变) - logger.error(f"模型 {self.model_name}: 请求处理中遇到关键错误,将中止: {e}") - raise e + return handled_result except Exception as e: - # (代码不变) - last_exception = e - logger.warning( - f"模型 {self.model_name}: 第 {attempt + 1} 次尝试中发生非 HTTP 错误: {str(e.__class__.__name__)} - {str(e)}" - ) + handled_payload, count_delta = await self._handle_exception(e, retry, request_content) + retry += count_delta # 降级不计入重试次数 + if handled_payload: + # 如果降级成功,重新构建请求体 + request_content["payload"] = handled_payload + continue - if attempt >= policy["max_retries"] - 1: - logger.error( - f"模型 {self.model_name}: 达到最大重试次数 ({policy['max_retries']}),因非 HTTP 错误失败。" - ) - else: - try: - temp_request_content = { - "policy": policy, - "payload": actual_payload, - "api_url": api_url, - "stream_mode": stream_mode, - "image_base64": image_base64, - "image_format": image_format, - "prompt": prompt, - } - handled_payload, count_delta = await self._handle_exception( - e, attempt, temp_request_content, merged_params=api_kwargs - ) - if handled_payload: - actual_payload = handled_payload - logger.info(f"模型 {self.model_name}: 异常处理更新了 payload,将使用当前 Key 重试。") + logger.error(f"模型 {self.model_name} 达到最大重试次数,请求仍然失败") + raise RuntimeError(f"模型 {self.model_name} 达到最大重试次数,API请求仍然失败") - wait_time = policy["base_wait"] * (2**attempt) - logger.warning(f"模型 {self.model_name}: 等待 {wait_time} 秒后重试...") - await asyncio.sleep(wait_time) - continue + async def _handle_response( + self, + response: ClientResponse, + request_content: Dict[str, Any], + retry_count: int, + response_handler: callable, + user_id, + request_type, + endpoint, + ) -> Union[Dict[str, Any], None]: + policy = request_content["policy"] + stream_mode = request_content["stream_mode"] + if response.status in policy["retry_codes"] or response.status in policy["abort_codes"]: + await self._handle_error_response(response, retry_count, policy) + return None - except (RequestAbortException, PermissionDeniedException) as abort_exception: - logger.error(f"模型 {self.model_name}: 异常处理判断需要中止请求: {abort_exception}") - raise abort_exception - except RuntimeError as rt_error: - logger.error(f"模型 {self.model_name}: 异常处理遇到运行时错误: {rt_error}") - raise rt_error - - # --- 循环结束 --- - logger.error(f"模型 {self.model_name}: 所有重试尝试 ({policy['max_retries']} 次) 均失败。") - if last_exception: - if isinstance(last_exception, PermissionDeniedException): - logger.error(f"最后遇到的错误是权限拒绝: {str(last_exception)}") - raise last_exception - logger.error(f"最后遇到的错误: {str(last_exception.__class__.__name__)} - {str(last_exception)}") - raise RuntimeError( - f"模型 {self.model_name} 达到最大重试次数,API 请求失败。最后错误: {str(last_exception)}" - ) from last_exception + response.raise_for_status() + result = {} + if stream_mode: + # 将流式输出转化为非流式输出 + result = await self._handle_stream_output(response) else: - if not available_keys_pool and keys_abandoned_runtime: - final_error_msg = f"模型 {self.model_name}: 所有可用 API Keys 均因 403 错误被禁用。" - logger.critical(final_error_msg) - raise PermissionDeniedException(final_error_msg) - else: - raise RuntimeError(f"模型 {self.model_name} 达到最大重试次数,API 请求失败,原因未知。") + result = await response.json() + return ( + response_handler(result) + if response_handler + else self._default_response_handler(result, user_id, request_type, endpoint) + ) async def _handle_stream_output(self, response: ClientResponse) -> Dict[str, Any]: - """处理 OpenAI 兼容的流式输出""" - # (代码不变) flag_delta_content_finished = False accumulated_content = "" - usage = None + usage = None # 初始化usage变量,避免未定义错误 reasoning_content = "" content = "" - tool_calls = None + tool_calls = None # 初始化工具调用变量 async for line_bytes in response.content: try: @@ -731,7 +362,7 @@ class LLMRequest: if flag_delta_content_finished: chunk_usage = chunk.get("usage", None) if chunk_usage: - usage = chunk_usage + usage = chunk_usage # 获取token用量 else: delta = chunk["choices"][0]["delta"] delta_content = delta.get("content") @@ -739,35 +370,15 @@ class LLMRequest: delta_content = "" accumulated_content += delta_content + # 提取工具调用信息 if "tool_calls" in delta: if tool_calls is None: - tool_calls = [] - for tc in delta["tool_calls"]: - new_tc = dict(tc) - if "function" in new_tc and "arguments" not in new_tc["function"]: - new_tc["function"]["arguments"] = "" - tool_calls.append(new_tc) + tool_calls = delta["tool_calls"] else: - for i, tc_delta in enumerate(delta["tool_calls"]): - if ( - i < len(tool_calls) - and "function" in tc_delta - and "arguments" in tc_delta["function"] - ): - if "arguments" in tool_calls[i]["function"]: - tool_calls[i]["function"]["arguments"] += tc_delta["function"][ - "arguments" - ] - else: - tool_calls[i]["function"]["arguments"] = tc_delta["function"][ - "arguments" - ] - elif i >= len(tool_calls): - new_tc = dict(tc_delta) - if "function" in new_tc and "arguments" not in new_tc["function"]: - new_tc["function"]["arguments"] = "" - tool_calls.append(new_tc) + # 合并工具调用信息 + tool_calls.extend(delta["tool_calls"]) + # 检测流式输出文本是否结束 finish_reason = chunk["choices"][0].get("finish_reason") if delta.get("reasoning_content", None): reasoning_content += delta["reasoning_content"] @@ -776,37 +387,37 @@ class LLMRequest: if chunk_usage: usage = chunk_usage break + # 部分平台在文本输出结束前不会返回token用量,此时需要再获取一次chunk flag_delta_content_finished = True - except json.JSONDecodeError as e: - logger.error(f"模型 {self.model_name} 解析流式 JSON 错误: {e} - data: '{data_str}'") except Exception as e: - logger.exception(f"模型 {self.model_name} 解析流式输出块错误: {str(e)}") - except UnicodeDecodeError as e: - logger.warning(f"模型 {self.model_name} 流式输出解码错误: {e} - bytes: {line_bytes[:50]}...") + logger.exception(f"模型 {self.model_name} 解析流式输出错误: {str(e)}") except Exception as e: if isinstance(e, GeneratorExit): log_content = f"模型 {self.model_name} 流式输出被中断,正在清理资源..." else: log_content = f"模型 {self.model_name} 处理流式输出时发生错误: {str(e)}" logger.warning(log_content) + # 确保资源被正确清理 try: await response.release() except Exception as cleanup_error: logger.error(f"清理资源时发生错误: {cleanup_error}") + # 返回已经累积的内容 content = accumulated_content - break - if not content and accumulated_content: + if not content: content = accumulated_content think_match = re.search(r"(.*?)", content, re.DOTALL) if think_match: reasoning_content = think_match.group(1).strip() content = re.sub(r".*?", "", content, flags=re.DOTALL).strip() + # 构建消息对象 message = { "content": content, "reasoning_content": reasoning_content, } + # 如果有工具调用,添加到消息中 if tool_calls: message["tool_calls"] = tool_calls @@ -817,407 +428,285 @@ class LLMRequest: return result async def _handle_error_response( - self, response: ClientResponse, retry_count: int, policy: Dict[str, Any], current_key: str = None - ) -> None: - """处理 HTTP 错误响应 (区分 403 和其他错误)""" - # (代码不变) - status = response.status - try: - error_text = await response.text() - except Exception as e: - error_text = f"(无法读取响应体: {e})" - - if status == 403: - logger.error( - f"模型 {self.model_name}: 遇到 403 (权限拒绝) 错误。Key: ...{current_key[-4:] if current_key else 'N/A'}. " - f"响应: {error_text[:200]}" - ) - raise PermissionDeniedException(f"模型禁止访问 ({status})", key_identifier=current_key) - - elif status in policy["retry_codes"] and status != 429: - if status == 413: - logger.warning( - f"模型 {self.model_name}: 错误码 413 (Payload Too Large)。Key: ...{current_key[-4:] if current_key else 'N/A'}. 尝试压缩..." - ) + self, response: ClientResponse, retry_count: int, policy: Dict[str, Any] + ) -> Union[Dict[str, any]]: + if response.status in policy["retry_codes"]: + wait_time = policy["base_wait"] * (2**retry_count) + logger.warning(f"模型 {self.model_name} 错误码: {response.status}, 等待 {wait_time}秒后重试") + if response.status == 413: + logger.warning("请求体过大,尝试压缩...") raise PayLoadTooLargeError("请求体过大") - elif status in [500, 503]: + elif response.status in [500, 503]: logger.error( - f"模型 {self.model_name}: 服务器内部错误或过载 ({status})。Key: ...{current_key[-4:] if current_key else 'N/A'}. " - f"响应: {error_text[:200]}" + f"模型 {self.model_name} 错误码: {response.status} - {error_code_mapping.get(response.status)}" ) - return + raise RuntimeError("服务器负载过高,模型恢复失败QAQ") else: - logger.warning( - f"模型 {self.model_name}: 遇到可重试错误码: {status}. Key: ...{current_key[-4:] if current_key else 'N/A'}" - ) - return - - elif status in policy["abort_codes"]: - logger.error( - f"模型 {self.model_name}: 遇到需要中止的错误码: {status} - {error_code_mapping.get(status, '未知错误')}. " - f"Key: ...{current_key[-4:] if current_key else 'N/A'}. 响应: {error_text[:200]}" - ) - raise RequestAbortException(f"请求出现错误 {status},中止处理", response) - else: - logger.error( - f"模型 {self.model_name}: 遇到未明确处理的错误码: {status}. Key: ...{current_key[-4:] if current_key else 'N/A'}. 响应: {error_text[:200]}" - ) - try: - response.raise_for_status() - raise RequestAbortException(f"未处理的错误状态码 {status}", response) - except aiohttp.ClientResponseError as e: - raise RequestAbortException(f"未处理的错误状态码 {status}: {e.message}", response) from e + logger.warning(f"模型 {self.model_name} 请求限制(429),等待{wait_time}秒后重试...") + raise RuntimeError("请求限制(429)") + elif response.status in policy["abort_codes"]: + if response.status != 403: + raise RequestAbortException("请求出现错误,中断处理", response) + else: + raise PermissionDeniedException("模型禁止访问") async def _handle_exception( - self, exception, retry_count: int, request_content: Dict[str, Any], merged_params: Dict[str, Any] = None + self, exception, retry_count: int, request_content: Dict[str, Any] ) -> Union[Tuple[Dict[str, Any], int], Tuple[None, int]]: - """处理非 HTTP 错误,支持使用合并后的参数重建 payload""" policy = request_content["policy"] payload = request_content["payload"] - _wait_time = policy["base_wait"] * (2**retry_count) + wait_time = policy["base_wait"] * (2**retry_count) keep_request = False if retry_count < policy["max_retries"] - 1: keep_request = True - - params_for_rebuild = merged_params if merged_params is not None else payload - - if isinstance(exception, PayLoadTooLargeError): - if keep_request: - logger.warning("请求体过大 (PayLoadTooLargeError),尝试压缩图片...") - image_base64 = request_content.get("image_base64") - if image_base64: - compressed_image_base64 = compress_base64_image_by_scale(image_base64) - if compressed_image_base64 != image_base64: - new_payload = await self._build_payload( - request_content["prompt"], - compressed_image_base64, - request_content["image_format"], - params_for_rebuild, - ) - logger.info("图片压缩成功,将使用压缩后的图片重试。") - return new_payload, 0 - else: - logger.warning("图片压缩未改变大小或失败。") + if isinstance(exception, RequestAbortException): + response = exception.response + logger.error( + f"模型 {self.model_name} 错误码: {response.status} - {error_code_mapping.get(response.status)}" + ) + # 尝试获取并记录服务器返回的详细错误信息 + try: + error_json = await response.json() + if error_json and isinstance(error_json, list) and len(error_json) > 0: + # 处理多个错误的情况 + for error_item in error_json: + if "error" in error_item and isinstance(error_item["error"], dict): + error_obj: dict = error_item["error"] + error_code = error_obj.get("code") + error_message = error_obj.get("message") + error_status = error_obj.get("status") + logger.error( + f"服务器错误详情: 代码={error_code}, 状态={error_status}, 消息={error_message}" + ) + elif isinstance(error_json, dict) and "error" in error_json: + # 处理单个错误对象的情况 + error_obj = error_json.get("error", {}) + error_code = error_obj.get("code") + error_message = error_obj.get("message") + error_status = error_obj.get("status") + logger.error(f"服务器错误详情: 代码={error_code}, 状态={error_status}, 消息={error_message}") else: - logger.warning("请求体过大但请求中不包含图片,无法压缩。") - return None, 0 - else: - logger.error("达到最大重试次数,请求体仍然过大。") - raise RuntimeError("请求体过大,压缩或重试后仍然失败。") from exception + # 记录原始错误响应内容 + logger.error(f"服务器错误响应: {error_json}") + except Exception as e: + logger.warning(f"无法解析服务器错误响应: {str(e)}") + raise RuntimeError(f"请求被拒绝: {error_code_mapping.get(response.status)}") - elif isinstance(exception, (aiohttp.ClientError, asyncio.TimeoutError)): + elif isinstance(exception, PermissionDeniedException): + # 只针对硅基流动的V3和R1进行降级处理 + if self.model_name.startswith("Pro/deepseek-ai") and self.base_url == "https://api.siliconflow.cn/v1/": + old_model_name = self.model_name + self.model_name = self.model_name[4:] # 移除"Pro/"前缀 + logger.warning(f"检测到403错误,模型从 {old_model_name} 降级为 {self.model_name}") + + # 对全局配置进行更新 + if global_config.llm_normal.get("name") == old_model_name: + global_config.llm_normal["name"] = self.model_name + logger.warning(f"将全局配置中的 llm_normal 模型临时降级至{self.model_name}") + if global_config.llm_reasoning.get("name") == old_model_name: + global_config.llm_reasoning["name"] = self.model_name + logger.warning(f"将全局配置中的 llm_reasoning 模型临时降级至{self.model_name}") + + if payload and "model" in payload: + payload["model"] = self.model_name + + await asyncio.sleep(wait_time) + return payload, -1 + raise RuntimeError(f"请求被拒绝: {error_code_mapping.get(403)}") + + elif isinstance(exception, PayLoadTooLargeError): if keep_request: - logger.error(f"模型 {self.model_name} 网络错误: {str(exception)}") + image_base64 = request_content["image_base64"] + compressed_image_base64 = compress_base64_image_by_scale(image_base64) + new_payload = await self._build_payload( + request_content["prompt"], compressed_image_base64, request_content["image_format"] + ) + return new_payload, 0 + else: + return None, 0 + + elif isinstance(exception, aiohttp.ClientError) or isinstance(exception, asyncio.TimeoutError): + if keep_request: + logger.error(f"模型 {self.model_name} 网络错误,等待{wait_time}秒后重试... 错误: {str(exception)}") + await asyncio.sleep(wait_time) return None, 0 else: logger.critical(f"模型 {self.model_name} 网络错误达到最大重试次数: {str(exception)}") - raise RuntimeError(f"网络请求失败: {str(exception)}") from exception + raise RuntimeError(f"网络请求失败: {str(exception)}") elif isinstance(exception, aiohttp.ClientResponseError): + # 处理aiohttp抛出的,除了policy中的status的响应错误 if keep_request: logger.error( - f"模型 {self.model_name} HTTP响应错误 (未被策略覆盖): 状态码: {exception.status}, 错误: {exception.message}" + f"模型 {self.model_name} HTTP响应错误,等待{wait_time}秒后重试... 状态码: {exception.status}, 错误: {exception.message}" ) try: - error_text = await exception.response.text() if hasattr(exception, "response") else str(exception) - logger.error(f"服务器错误响应详情: {error_text[:500]}") + error_text = await exception.response.text() + error_json = json.loads(error_text) + if isinstance(error_json, list) and len(error_json) > 0: + # 处理多个错误的情况 + for error_item in error_json: + if "error" in error_item and isinstance(error_item["error"], dict): + error_obj = error_item["error"] + logger.error( + f"模型 {self.model_name} 服务器错误详情: 代码={error_obj.get('code')}, " + f"状态={error_obj.get('status')}, " + f"消息={error_obj.get('message')}" + ) + elif isinstance(error_json, dict) and "error" in error_json: + error_obj = error_json.get("error", {}) + logger.error( + f"模型 {self.model_name} 服务器错误详情: 代码={error_obj.get('code')}, " + f"状态={error_obj.get('status')}, " + f"消息={error_obj.get('message')}" + ) + else: + logger.error(f"模型 {self.model_name} 服务器错误响应: {error_json}") + except (json.JSONDecodeError, TypeError) as json_err: + logger.warning( + f"模型 {self.model_name} 响应不是有效的JSON: {str(json_err)}, 原始内容: {error_text[:200]}" + ) except Exception as parse_err: - logger.warning(f"无法解析服务器错误响应内容: {str(parse_err)}") + logger.warning(f"模型 {self.model_name} 无法解析响应错误内容: {str(parse_err)}") + + await asyncio.sleep(wait_time) return None, 0 else: logger.critical( f"模型 {self.model_name} HTTP响应错误达到最大重试次数: 状态码: {exception.status}, 错误: {exception.message}" ) - current_key_placeholder = request_content.get("current_key", "******") + # 安全地检查和记录请求详情 handled_payload = await _safely_record(request_content, payload) - logger.critical( - f"请求头: {await self._build_headers(api_key=current_key_placeholder, no_key=True)} 请求体: {handled_payload}" - ) + logger.critical(f"请求头: {await self._build_headers(no_key=True)} 请求体: {handled_payload[:100]}") raise RuntimeError( f"模型 {self.model_name} API请求失败: 状态码 {exception.status}, {exception.message}" - ) from exception + ) else: if keep_request: - logger.error( - f"模型 {self.model_name} 遇到未知错误: {str(exception.__class__.__name__)} - {str(exception)}" - ) + logger.error(f"模型 {self.model_name} 请求失败,等待{wait_time}秒后重试... 错误: {str(exception)}") + await asyncio.sleep(wait_time) return None, 0 else: - logger.critical( - f"模型 {self.model_name} 请求因未知错误失败: {str(exception.__class__.__name__)} - {str(exception)}" - ) - current_key_placeholder = request_content.get("current_key", "******") + logger.critical(f"模型 {self.model_name} 请求失败: {str(exception)}") + # 安全地检查和记录请求详情 handled_payload = await _safely_record(request_content, payload) - logger.critical( - f"请求头: {await self._build_headers(api_key=current_key_placeholder, no_key=True)} 请求体: {handled_payload}" - ) - raise RuntimeError(f"模型 {self.model_name} API请求失败: {str(exception)}") from exception + logger.critical(f"请求头: {await self._build_headers(no_key=True)} 请求体: {handled_payload[:100]}") + raise RuntimeError(f"模型 {self.model_name} API请求失败: {str(exception)}") - async def _transform_parameters(self, merged_params: dict) -> dict: - """根据模型名称转换合并后的参数,并移除内部参数""" - # (代码不变) - new_params = dict(merged_params) - new_params.pop("request_type", None) + async def _transform_parameters(self, params: dict) -> dict: + """ + 根据模型名称转换参数: + - 对于需要转换的OpenAI CoT系列模型(例如 "o3-mini"),删除 'temperature' 参数, + 并将 'max_tokens' 重命名为 'max_completion_tokens' + """ + # 复制一份参数,避免直接修改原始数据 + new_params = dict(params) - if not self.is_gemini and self.model_name.lower() in self.MODELS_NEEDING_TRANSFORMATION: + if self.model_name.lower() in self.MODELS_NEEDING_TRANSFORMATION: + # 删除 'temperature' 参数(如果存在) new_params.pop("temperature", None) + # 如果存在 'max_tokens',则重命名为 'max_completion_tokens' if "max_tokens" in new_params: new_params["max_completion_tokens"] = new_params.pop("max_tokens") - elif self.is_gemini: - gen_config = new_params.get("generationConfig", {}) - if "temperature" in new_params: - gen_config["temperature"] = new_params.pop("temperature") - if "max_tokens" in new_params: - gen_config["maxOutputTokens"] = new_params.pop("max_tokens") - if "top_p" in new_params: - gen_config["topP"] = new_params.pop("top_p") - if "top_k" in new_params: - gen_config["topK"] = new_params.pop("top_k") - - if gen_config: - new_params["generationConfig"] = gen_config - - new_params.pop("frequency_penalty", None) - new_params.pop("presence_penalty", None) - new_params.pop("max_completion_tokens", None) - return new_params - async def _build_payload( - self, prompt: str, image_base64: str = None, image_format: str = None, merged_params: dict = None - ) -> dict: - """构建请求体 (区分 Gemini 和 OpenAI),使用合并和转换后的参数""" - # (代码不变) - if merged_params is None: - merged_params = self.params - - params_copy = await self._transform_parameters(merged_params) - - if self.is_gemini: - parts = [] - if prompt: - parts.append({"text": prompt}) - if image_base64: - mime_type = f"image/{image_format.lower() if image_format else 'jpeg'}" - parts.append({"inlineData": {"mimeType": mime_type, "data": image_base64}}) - payload = {"contents": [{"parts": parts}], **params_copy} - payload.pop("model", None) - # --- 添加 Gemini 安全设置 --- - safety_settings = [ - {"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"}, - {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"}, - {"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_NONE"}, - {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_NONE"}, - {"category": "HARM_CATEGORY_CIVIC_INTEGRITY", "threshold": "BLOCK_NONE"}, + async def _build_payload(self, prompt: str, image_base64: str = None, image_format: str = None) -> dict: + """构建请求体""" + # 复制一份参数,避免直接修改 self.params + params_copy = await self._transform_parameters(self.params) + if image_base64: + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": prompt}, + { + "type": "image_url", + "image_url": {"url": f"data:image/{image_format.lower()};base64,{image_base64}"}, + }, + ], + } ] - payload["safetySettings"] = safety_settings - logger.debug(f"模型 {self.model_name}: 已为 Gemini 函数调用请求添加 safetySettings (BLOCK_NONE)。") - # --- 结束添加安全设置 --- - else: - if image_base64: - messages = [ - { - "role": "user", - "content": [ - {"type": "text", "text": prompt}, - { - "type": "image_url", - "image_url": { - "url": f"data:image/{image_format.lower() if image_format else 'jpeg'};base64,{image_base64}" - }, - }, - ], - } - ] - else: - messages = [{"role": "user", "content": prompt}] - - payload = { - "model": self.model_name, - "messages": messages, - **params_copy, - } - if "max_tokens" not in payload and "max_completion_tokens" not in payload: - if "max_tokens" not in params_copy and "max_completion_tokens" not in params_copy: - payload["max_tokens"] = global_config.model_max_output_length - if "max_completion_tokens" in payload: - payload["max_tokens"] = payload.pop("max_completion_tokens") - + messages = [{"role": "user", "content": prompt}] + payload = { + "model": self.model_name, + "messages": messages, + **params_copy, + } + if "max_tokens" not in payload and "max_completion_tokens" not in payload: + payload["max_tokens"] = global_config.model_max_output_length + # 如果 payload 中依然存在 max_tokens 且需要转换,在这里进行再次检查 + if self.model_name.lower() in self.MODELS_NEEDING_TRANSFORMATION and "max_tokens" in payload: + payload["max_completion_tokens"] = payload.pop("max_tokens") return payload def _default_response_handler( self, result: dict, user_id: str = "system", request_type: str = None, endpoint: str = "/chat/completions" ) -> Tuple: - """默认响应解析 (区分 Gemini 和 OpenAI),并处理函数/工具调用""" - content = "没有返回结果" - reasoning_content = "" - tool_calls = None # OpenAI 格式 - function_call = None # Gemini 格式 - prompt_tokens = 0 - completion_tokens = 0 - total_tokens = 0 + """默认响应解析""" + if "choices" in result and result["choices"]: + message = result["choices"][0]["message"] + content = message.get("content", "") + content, reasoning = self._extract_reasoning(content) + reasoning_content = message.get("model_extra", {}).get("reasoning_content", "") + if not reasoning_content: + reasoning_content = message.get("reasoning_content", "") + if not reasoning_content: + reasoning_content = reasoning - if self.is_gemini: - # --- 解析 Gemini 响应 --- - try: - if "candidates" in result and result["candidates"]: - candidate = result["candidates"][0] - # 检查是否有 content 和 parts - if "content" in candidate and "parts" in candidate["content"] and candidate["content"]["parts"]: - # 查找 functionCall 或 text 部分 - final_text_parts = [] - for part in candidate["content"]["parts"]: - if "functionCall" in part: - function_call = part["functionCall"] # 获取 Gemini 的 functionCall - # Gemini functionCall 通常不与 text 一起返回,这里假设只处理 functionCall - break # 找到 functionCall 就停止处理 parts - elif "text" in part: - final_text_parts.append(part.get("text", "")) + # 提取工具调用信息 + tool_calls = message.get("tool_calls", None) - if not function_call: # 如果没有 functionCall,处理 text - raw_content = "".join(final_text_parts).strip() - content, reasoning = self._extract_reasoning(raw_content) - reasoning_content = reasoning - # else: function_call 已获取,content 留空或设为特定值 + # 记录token使用情况 + usage = result.get("usage", {}) + if usage: + prompt_tokens = usage.get("prompt_tokens", 0) + completion_tokens = usage.get("completion_tokens", 0) + total_tokens = usage.get("total_tokens", 0) + self._record_usage( + prompt_tokens=prompt_tokens, + completion_tokens=completion_tokens, + total_tokens=total_tokens, + user_id=user_id, + request_type=request_type if request_type is not None else self.request_type, + endpoint=endpoint, + ) - else: - content = "Gemini响应中缺少 content 或 parts" - logger.warning(f"模型 {self.model_name}: Gemini 响应格式不完整 (缺少 content/parts): {result}") - - finish_reason = candidate.get("finishReason") - if finish_reason == "SAFETY": - logger.warning(f"模型 {self.model_name}: Gemini 响应因安全设置被阻止。") - content = "响应内容因安全原因被过滤。" - elif finish_reason == "RECITATION": - logger.warning(f"模型 {self.model_name}: Gemini 响应因引用限制被阻止。") - content = "响应内容因引用限制被过滤。" - elif finish_reason == "OTHER": - logger.warning(f"模型 {self.model_name}: Gemini 响应因未知原因停止。") - # finishReason == "TOOL_CODE" or "FUNCTION_CALL" 是正常情况 - - usage = result.get("usageMetadata", {}) - if usage: - prompt_tokens = usage.get("promptTokenCount", 0) - completion_tokens = usage.get("candidatesTokenCount", 0) - total_tokens = usage.get("totalTokenCount", 0) - if completion_tokens == 0 and total_tokens > 0: - completion_tokens = total_tokens - prompt_tokens - else: - logger.warning(f"模型 {self.model_name} (Gemini) 的响应中缺少 'usageMetadata' 信息。") - - except Exception as e: - logger.error(f"解析 Gemini 响应出错: {e} - 响应: {result}") - content = "解析 Gemini 响应时出错" - - else: - # --- 解析 OpenAI 兼容响应 --- - # (代码不变) - if "choices" in result and result["choices"]: - message = result["choices"][0].get("message", {}) - raw_content = message.get("content", "") - content, reasoning = self._extract_reasoning(raw_content if raw_content else "") - - explicit_reasoning = message.get("model_extra", {}).get("reasoning_content", "") - if not explicit_reasoning: - explicit_reasoning = message.get("reasoning_content", "") - reasoning_content = explicit_reasoning if explicit_reasoning else reasoning - - tool_calls = message.get("tool_calls", None) # 获取 OpenAI 的 tool_calls - - usage = result.get("usage", {}) - if usage: - prompt_tokens = usage.get("prompt_tokens", 0) - completion_tokens = usage.get("completion_tokens", 0) - total_tokens = usage.get("total_tokens", 0) - else: - logger.warning(f"模型 {self.model_name} (OpenAI) 的响应中缺少 'usage' 信息。") + # 只有当tool_calls存在且不为空时才返回 + if tool_calls: + logger.debug(f"检测到工具调用: {tool_calls}") + return content, reasoning_content, tool_calls else: - logger.warning(f"模型 {self.model_name} (OpenAI) 的响应格式不符合预期: {result}") + return content, reasoning_content - # --- 记录 Token 使用情况 --- - # (代码不变) - if prompt_tokens > 0 or completion_tokens > 0 or total_tokens > 0: - self._record_usage( - prompt_tokens=prompt_tokens, - completion_tokens=completion_tokens, - total_tokens=total_tokens, - user_id=user_id, - request_type=request_type, - endpoint=endpoint, - ) - else: - logger.warning(f"模型 {self.model_name}: 未能从响应中提取有效的 token 使用信息。") - - # --- 返回结果 (统一格式) --- - final_tool_calls = None - if tool_calls: # 来自 OpenAI - final_tool_calls = tool_calls - logger.debug(f"检测到 OpenAI 工具调用: {final_tool_calls}") - elif function_call: # 来自 Gemini - logger.debug(f"检测到 Gemini 函数调用: {function_call}") - # 将 Gemini functionCall 转换为 OpenAI tool_calls 格式 - # 注意: Gemini 的 functionCall 没有显式的 id 和 type,需要模拟 - final_tool_calls = [ - { - "id": f"call_{random.randint(1000, 9999)}", # 生成一个随机 ID - "type": "function", - "function": { - "name": function_call.get("name"), - # Gemini 的参数在 'args' 中,OpenAI 在 'arguments' (通常是 JSON 字符串) - # 需要将 Gemini 的 dict 参数转换为 JSON 字符串 - "arguments": json.dumps(function_call.get("args", {})), - }, - } - ] - logger.debug(f"转换为 OpenAI tool_calls 格式: {final_tool_calls}") - - if final_tool_calls: - # 如果有工具/函数调用,通常 content 为空或包含思考过程,这里返回转换后的调用信息 - return content, reasoning_content, final_tool_calls - else: - # 没有工具/函数调用,返回普通文本响应 - return content, reasoning_content + return "没有返回结果", "" @staticmethod def _extract_reasoning(content: str) -> Tuple[str, str]: """CoT思维链提取""" - # (代码不变) - if not content: - return "", "" - match = re.search(r"(.*?)", content, re.DOTALL) - cleaned_content = re.sub(r".*?", "", content, flags=re.DOTALL, count=1).strip() + match = re.search(r"(?:)?(.*?)", content, re.DOTALL) + content = re.sub(r"(?:)?.*?", "", content, flags=re.DOTALL, count=1).strip() if match: reasoning = match.group(1).strip() else: reasoning = "" - return cleaned_content, reasoning + return content, reasoning - async def _build_headers(self, api_key: str, no_key: bool = False) -> dict: - """构建请求头 (区分 Gemini 和 OpenAI)""" - # (代码不变) + async def _build_headers(self, no_key: bool = False) -> dict: + """构建请求头""" if no_key: - if self.is_gemini: - return {"x-goog-api-key": "**********", "Content-Type": "application/json"} - else: - return {"Authorization": "Bearer **********", "Content-Type": "application/json"} + return {"Authorization": "Bearer **********", "Content-Type": "application/json"} else: - if not api_key: - logger.error(f"尝试使用无效 (空) 的 API key 为模型 {self.model_name} 构建请求头。") - raise ValueError("无效的 API key 提供给 _build_headers。") + return {"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"} + # 防止小朋友们截图自己的key - if self.is_gemini: - return {"x-goog-api-key": api_key, "Content-Type": "application/json"} - else: - return {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"} + async def generate_response(self, prompt: str) -> Tuple: + """根据输入的提示生成模型的异步响应""" - async def generate_response(self, prompt: str, user_id: str = "system", **kwargs) -> Tuple: - """根据输入的提示生成模型的异步响应,支持覆盖参数""" - endpoint = ":generateContent" if self.is_gemini else "/chat/completions" - response = await self._execute_request( - endpoint=endpoint, prompt=prompt, user_id=user_id, request_type="chat", **kwargs - ) + response = await self._execute_request(endpoint="/chat/completions", prompt=prompt) + # 根据返回值的长度决定怎么处理 if len(response) == 3: content, reasoning_content, tool_calls = response return content, reasoning_content, self.model_name, tool_calls @@ -1225,292 +714,176 @@ class LLMRequest: content, reasoning_content = response return content, reasoning_content, self.model_name - async def generate_response_for_image( - self, prompt: str, image_base64: str, image_format: str, user_id: str = "system", **kwargs - ) -> Tuple: - """根据输入的提示和图片生成模型的异步响应,支持覆盖参数""" - endpoint = ":generateContent" if self.is_gemini else "/chat/completions" - response = await self._execute_request( - endpoint=endpoint, - prompt=prompt, - image_base64=image_base64, - image_format=image_format, - user_id=user_id, - request_type="vision", - **kwargs, - ) - # _default_response_handler 现在总是返回至少2个值 - if len(response) == 3: - return response # content, reasoning, tool_calls (tool_calls 可能为 None) - elif len(response) == 2: - content, reasoning = response - return content, reasoning # 对于 vision 请求,通常没有 tool_calls - else: - logger.error(f"来自 _default_response_handler 的意外响应格式: {response}") - return "处理响应出错", "" + async def generate_response_for_image(self, prompt: str, image_base64: str, image_format: str) -> Tuple: + """根据输入的提示和图片生成模型的异步响应""" - async def generate_response_async( - self, prompt: str, user_id: str = "system", request_type: str = "chat", **kwargs - ) -> Union[str, Tuple]: - """异步方式根据输入的提示生成模型的响应 (通用),支持覆盖参数""" - # (代码不变) - endpoint = ":generateContent" if self.is_gemini else "/chat/completions" response = await self._execute_request( - endpoint=endpoint, - prompt=prompt, - payload=None, - retry_policy=None, - response_handler=None, - user_id=user_id, - request_type=request_type, - **kwargs, + endpoint="/chat/completions", prompt=prompt, image_base64=image_base64, image_format=image_format ) + # 根据返回值的长度决定怎么处理 + if len(response) == 3: + content, reasoning_content, tool_calls = response + return content, reasoning_content, tool_calls + else: + content, reasoning_content = response + return content, reasoning_content + + async def generate_response_async(self, prompt: str, **kwargs) -> Union[str, Tuple]: + """异步方式根据输入的提示生成模型的响应""" + # 构建请求体,不硬编码max_tokens + data = { + "model": self.model_name, + "messages": [{"role": "user", "content": prompt}], + **self.params, + **kwargs, + } + + response = await self._execute_request(endpoint="/chat/completions", payload=data, prompt=prompt) + # 原样返回响应,不做处理 + return response - # 修改:实现 Gemini Function Calling 的 Payload 构建 - async def generate_response_tool_async( - self, prompt: str, tools: list, user_id: str = "system", **kwargs - ) -> tuple[str, str, list | None]: - """异步方式根据输入的提示和工具生成模型的响应,支持覆盖参数和 Gemini 函数调用""" - - endpoint = ":generateContent" if self.is_gemini else "/chat/completions" - merged_params = {**self.params, **kwargs} - transformed_params = await self._transform_parameters(merged_params) # 清理 request_type 等 - - payload = None - - if self.is_gemini: - # --- 构建 Gemini Function Calling Payload --- - logger.debug(f"为 Gemini ({self.model_name}) 构建函数调用请求。") - # 1. 转换工具定义 (OpenAI -> Gemini) - # OpenAI tool format: [{"type": "function", "function": {"name": ..., "description": ..., "parameters": ...}}] - # Gemini tool format: [{"functionDeclarations": [{"name": ..., "description": ..., "parameters": ...}]}] - function_declarations = [] - if tools: - for tool in tools: - if tool.get("type") == "function" and "function" in tool: - func_def = tool["function"] - # Gemini parameters 使用 OpenAPI Schema,与 OpenAI 基本兼容 - function_declarations.append( - { - "name": func_def.get("name"), - "description": func_def.get("description", ""), # Description is required for Gemini - "parameters": func_def.get( - "parameters", {"type": "object", "properties": {}} - ), # Ensure parameters exist - } - ) - else: - logger.warning(f"跳过不支持的工具类型或格式: {tool}") - - if not function_declarations: - logger.error("没有有效的函数声明可用于 Gemini 请求。") - return "没有提供有效的函数定义", "", None - - gemini_tools = [{"functionDeclarations": function_declarations}] - - # 2. 构建 Gemini Payload - # parts = [{"text": prompt}] # 初始 parts - payload = { - "contents": [{"parts": [{"text": prompt}]}], # 包含用户提示 - "tools": gemini_tools, - # toolConfig 默认是 AUTO,可以根据需要从 kwargs 获取或硬编码 - # "toolConfig": {"functionCallingConfig": {"mode": "ANY"}}, # 例如强制调用 - **transformed_params, # 合并其他转换后的参数 (如 generationConfig) - } - payload.pop("model", None) # Gemini 不在顶层传 model - payload.pop("messages", None) # 移除 OpenAI 特有的 messages - payload.pop("tool_choice", None) # 移除 OpenAI 特有的 tool_choice - - logger.trace(f"构建的 Gemini 函数调用 Payload: {json.dumps(payload, indent=2)}") + async def generate_response_tool_async(self, prompt: str, tools: list, **kwargs) -> tuple[str, str, list]: + """异步方式根据输入的提示生成模型的响应""" + # 构建请求体,不硬编码max_tokens + data = { + "model": self.model_name, + "messages": [{"role": "user", "content": prompt}], + **self.params, + **kwargs, + "tools": tools, + } + response = await self._execute_request(endpoint="/chat/completions", payload=data, prompt=prompt) + logger.debug(f"向模型 {self.model_name} 发送工具调用请求,包含 {len(tools)} 个工具,返回结果: {response}") + # 检查响应是否包含工具调用 + if len(response) == 3: + content, reasoning_content, tool_calls = response + logger.debug(f"收到工具调用响应,包含 {len(tool_calls) if tool_calls else 0} 个工具调用") + return content, reasoning_content, tool_calls else: - # --- 构建 OpenAI Tool Calling Payload --- - # (逻辑不变) - logger.debug(f"为 OpenAI 兼容模型 ({self.model_name}) 构建工具调用请求。") - payload = { - "model": self.model_name, - "messages": [{"role": "user", "content": prompt}], - **transformed_params, - "tools": tools, - "tool_choice": transformed_params.get("tool_choice", "auto"), - } - if "max_completion_tokens" in payload: - payload["max_tokens"] = payload.pop("max_completion_tokens") - if "max_tokens" not in payload: - payload["max_tokens"] = global_config.model_max_output_length - - # --- 执行请求 --- - if payload is None: - logger.error("未能构建有效的 API 请求 payload。") - return "内部错误:无法构建请求", "", None - - response = await self._execute_request( - endpoint=endpoint, - payload=payload, - prompt=prompt, # prompt 仍然需要,用于可能的重试 - user_id=user_id, - request_type="tool_call", - **kwargs, # 传递原始 kwargs 以便在重试时重新合并 - ) - - # _default_response_handler 现在会处理 Gemini functionCall 并统一格式 - logger.debug(f"模型 {self.model_name} 工具/函数调用返回结果: {response}") - - if isinstance(response, tuple) and len(response) == 3: - content, reasoning_content, final_tool_calls = response - # final_tool_calls 已经是统一的 OpenAI 格式 - return content, reasoning_content, final_tool_calls - elif isinstance(response, tuple) and len(response) == 2: content, reasoning_content = response - logger.debug("收到普通响应,无工具/函数调用") + logger.debug("收到普通响应,无工具调用") return content, reasoning_content, None - else: - logger.error(f"收到来自 _execute_request/_default_response_handler 的意外响应格式: {response}") - return "处理响应时出错", "", None - async def get_embedding(self, text: str, user_id: str = "system", **kwargs) -> Union[list, None]: - """异步方法:获取文本的embedding向量,支持覆盖参数 (Gemini Embedding 需注意模型名称)""" - # (代码不变) + async def get_embedding(self, text: str) -> Union[list, None]: + """异步方法:获取文本的embedding向量 + + Args: + text: 需要获取embedding的文本 + + Returns: + list: embedding向量,如果失败则返回None + """ + if len(text) < 1: logger.debug("该消息没有长度,不再发送获取embedding向量的请求") return None - api_kwargs = {k: v for k, v in kwargs.items() if k != "request_type"} - - if self.is_gemini: - endpoint = ":embedContent" - payload = {"model": f"models/{self.model_name}", "content": {"parts": [{"text": text}]}, **api_kwargs} - payload.pop("encoding_format", None) - payload.pop("input", None) - - else: - endpoint = "/embeddings" - payload = {"model": self.model_name, "input": text, "encoding_format": "float", **api_kwargs} - payload.pop("content", None) - payload.pop("taskType", None) - def embedding_handler(result): - # (代码不变) - embedding_value = None - prompt_tokens = 0 - completion_tokens = 0 - total_tokens = 0 - - if self.is_gemini: - if "embedding" in result and "value" in result["embedding"]: - embedding_value = result["embedding"]["value"] - logger.warning(f"模型 {self.model_name} (Gemini Embedding): 响应中未找到明确的 token 使用信息。") - else: - if "data" in result and len(result["data"]) > 0: - embedding_value = result["data"][0].get("embedding", None) + """处理响应""" + if "data" in result and len(result["data"]) > 0: + # 提取 token 使用信息 usage = result.get("usage", {}) if usage: prompt_tokens = usage.get("prompt_tokens", 0) + completion_tokens = usage.get("completion_tokens", 0) total_tokens = usage.get("total_tokens", 0) - else: - logger.warning(f"模型 {self.model_name} (OpenAI Embedding) 的响应中缺少 'usage' 信息。") - - self._record_usage( - prompt_tokens=prompt_tokens, - completion_tokens=completion_tokens, - total_tokens=total_tokens, - user_id=user_id, - request_type="embedding", - endpoint=endpoint, - ) - return embedding_value + # 记录 token 使用情况 + self._record_usage( + prompt_tokens=prompt_tokens, + completion_tokens=completion_tokens, + total_tokens=total_tokens, + user_id="system", # 可以根据需要修改 user_id + # request_type="embedding", # 请求类型为 embedding + request_type=self.request_type, # 请求类型为 text + endpoint="/embeddings", # API 端点 + ) + return result["data"][0].get("embedding", None) + return result["data"][0].get("embedding", None) + return None embedding = await self._execute_request( - endpoint=endpoint, - payload=payload, + endpoint="/embeddings", prompt=text, + payload={"model": self.model_name, "input": text, "encoding_format": "float"}, retry_policy={"max_retries": 2, "base_wait": 6}, response_handler=embedding_handler, - user_id=user_id, - request_type="embedding", - **api_kwargs, ) return embedding def compress_base64_image_by_scale(base64_data: str, target_size: int = 0.8 * 1024 * 1024) -> str: - """压缩base64格式的图片到指定大小""" - # (代码不变) + """压缩base64格式的图片到指定大小 + Args: + base64_data: base64编码的图片数据 + target_size: 目标文件大小(字节),默认0.8MB + Returns: + str: 压缩后的base64图片数据 + """ try: + # 将base64转换为字节数据 image_data = base64.b64decode(base64_data) - if len(image_data) <= target_size * 1.05: - logger.info(f"图片大小 {len(image_data) / 1024:.1f}KB 已足够小,无需压缩。") - return base64_data - img = Image.open(io.BytesIO(image_data)) - img_format = img.format - original_width, original_height = img.size - scale = max(0.2, min(1.0, (target_size / len(image_data)) ** 0.5)) - new_width = max(1, int(original_width * scale)) - new_height = max(1, int(original_height * scale)) - output_buffer = io.BytesIO() - save_format = img_format # Default to original format - if getattr(img, "is_animated", False) and img.n_frames > 1: + # 如果已经小于目标大小,直接返回原图 + if len(image_data) <= 2 * 1024 * 1024: + return base64_data + + # 将字节数据转换为图片对象 + img = Image.open(io.BytesIO(image_data)) + + # 获取原始尺寸 + original_width, original_height = img.size + + # 计算缩放比例 + scale = min(1.0, (target_size / len(image_data)) ** 0.5) + + # 计算新的尺寸 + new_width = int(original_width * scale) + new_height = int(original_height * scale) + + # 创建内存缓冲区 + output_buffer = io.BytesIO() + + # 如果是GIF,处理所有帧 + if getattr(img, "is_animated", False): frames = [] - durations = [] - loop = img.info.get("loop", 0) - disposal = img.info.get("disposal", 2) - logger.info(f"检测到 GIF 动图 ({img.n_frames} 帧),尝试按比例压缩...") for frame_idx in range(img.n_frames): img.seek(frame_idx) - current_duration = img.info.get("duration", 100) - durations.append(current_duration) - new_frame = img.convert("RGBA").copy() - resized_frame = new_frame.resize((new_width, new_height), Image.Resampling.LANCZOS) - frames.append(resized_frame) - if frames: - frames[0].save( - output_buffer, - format="GIF", - save_all=True, - append_images=frames[1:], - optimize=False, - duration=durations, - loop=loop, - disposal=disposal, - transparency=img.info.get("transparency", None), - background=img.info.get("background", None), - ) - save_format = "GIF" - else: - logger.warning("未能处理 GIF 帧。") - return base64_data - else: - if img.mode in ("RGBA", "LA") or "transparency" in img.info: - resized_img = img.convert("RGBA").resize((new_width, new_height), Image.Resampling.LANCZOS) - save_format = "PNG" - save_params = {"optimize": True} - else: - resized_img = img.convert("RGB").resize((new_width, new_height), Image.Resampling.LANCZOS) - if img_format and img_format.upper() == "JPEG": - save_format = "JPEG" - save_params = {"quality": 85, "optimize": True} - else: - save_format = "PNG" - save_params = {"optimize": True} - resized_img.save(output_buffer, format=save_format, **save_params) + new_frame = img.copy() + new_frame = new_frame.resize((new_width // 2, new_height // 2), Image.Resampling.LANCZOS) # 动图折上折 + frames.append(new_frame) - compressed_data = output_buffer.getvalue() - logger.success( - f"压缩图片: {original_width}x{original_height} -> {new_width}x{new_height} ({img.format} -> {save_format})" - ) - logger.info( - f"压缩前大小: {len(image_data) / 1024:.1f}KB, 压缩后大小: {len(compressed_data) / 1024:.1f}KB (目标: {target_size / 1024:.1f}KB)" - ) - if len(compressed_data) < len(image_data) * 0.95: - return base64.b64encode(compressed_data).decode("utf-8") + # 保存到缓冲区 + frames[0].save( + output_buffer, + format="GIF", + save_all=True, + append_images=frames[1:], + optimize=True, + duration=img.info.get("duration", 100), + loop=img.info.get("loop", 0), + ) else: - logger.info("压缩效果不明显或反而增大,返回原始图片。") - return base64_data + # 处理静态图片 + resized_img = img.resize((new_width, new_height), Image.Resampling.LANCZOS) + + # 保存到缓冲区,保持原始格式 + if img.format == "PNG" and img.mode in ("RGBA", "LA"): + resized_img.save(output_buffer, format="PNG", optimize=True) + else: + resized_img.save(output_buffer, format="JPEG", quality=95, optimize=True) + + # 获取压缩后的数据并转换为base64 + compressed_data = output_buffer.getvalue() + logger.success(f"压缩图片: {original_width}x{original_height} -> {new_width}x{new_height}") + logger.info(f"压缩前大小: {len(image_data) / 1024:.1f}KB, 压缩后大小: {len(compressed_data) / 1024:.1f}KB") + + return base64.b64encode(compressed_data).decode("utf-8") + except Exception as e: logger.error(f"压缩图片失败: {str(e)}") import traceback logger.error(traceback.format_exc()) - return base64_data + return base64_data \ No newline at end of file diff --git a/src/chat/person_info/person_info.py b/src/chat/person_info/person_info.py index 41c18593..bc65776e 100644 --- a/src/chat/person_info/person_info.py +++ b/src/chat/person_info/person_info.py @@ -205,7 +205,7 @@ class PersonInfoManager: existing_names = "" while current_try < max_retries: individuality = Individuality.get_instance() - prompt_personality = individuality.get_prompt(x_person=2, level=3) + prompt_personality = individuality.get_prompt(x_person=2, level=1) bot_name = individuality.personality.bot_nickname qv_name_prompt = f"你是{bot_name},{prompt_personality}" diff --git a/src/chat/person_info/relationship_manager.py b/src/chat/person_info/relationship_manager.py index e1e611cc..3b873f50 100644 --- a/src/chat/person_info/relationship_manager.py +++ b/src/chat/person_info/relationship_manager.py @@ -313,7 +313,7 @@ class RelationshipManager: value = self.mood_feedback(value) level_num = self.calculate_level_num(old_value + value) - relationship_level = ["厌恶", "冷漠", "一般", "友好", "喜欢", "依赖"] + relationship_level = ["厌恶", "冷漠", "一般", "友好", "喜欢", "暧昧"] logger.info( f"用户: {user_info.user_nickname}" f"当前关系: {relationship_level[level_num]}, " @@ -400,7 +400,7 @@ class RelationshipManager: value = self.mood_feedback(value) level_num = self.calculate_level_num(old_value + value) - relationship_level = ["厌恶", "冷漠", "一般", "友好", "喜欢", "依赖"] + relationship_level = ["厌恶", "冷漠", "一般", "友好", "喜欢", "暧昧"] logger.info( f"用户: {chat_stream.user_info.user_nickname}" f"当前关系: {relationship_level[level_num]}, " @@ -425,7 +425,7 @@ class RelationshipManager: level_num = self.calculate_level_num(relationship_value) if level_num == 0 or level_num == 5: - relationship_level = ["厌恶", "冷漠以对", "认识", "友好对待", "喜欢", "依赖"] + relationship_level = ["厌恶", "冷漠以对", "认识", "友好对待", "喜欢", "暧昧"] relation_prompt2_list = [ "忽视的回应", "冷淡回复", @@ -439,7 +439,7 @@ class RelationshipManager: return "" else: if random.random() < 0.6: - relationship_level = ["厌恶", "冷漠以对", "认识", "友好对待", "喜欢", "依赖"] + relationship_level = ["厌恶", "冷漠以对", "认识", "友好对待", "喜欢", "暧昧"] relation_prompt2_list = [ "忽视的回应", "冷淡回复", diff --git a/src/chat/utils/statistic.py b/src/chat/utils/statistic.py index 1b9d1f14..5fa76deb 100644 --- a/src/chat/utils/statistic.py +++ b/src/chat/utils/statistic.py @@ -69,19 +69,9 @@ class OnlineTimeRecordTask(AsyncTask): else: # 如果没有记录,检查一分钟以内是否已有记录 current_time = datetime.now() - recent_record = db.online_time.find_one( + if recent_record := db.online_time.find_one( {"end_timestamp": {"$gte": current_time - timedelta(minutes=1)}} - ) - - if not recent_record: - # 若没有记录,则插入新的在线时间记录 - self.record_id = db.online_time.insert_one( - { - "start_timestamp": current_time, - "end_timestamp": current_time + timedelta(minutes=1), - } - ).inserted_id - else: + ): # 如果有记录,则更新结束时间 self.record_id = recent_record["_id"] db.online_time.update_one( @@ -92,8 +82,16 @@ class OnlineTimeRecordTask(AsyncTask): } }, ) - except Exception: - logger.exception("在线时间记录失败") + else: + # 若没有记录,则插入新的在线时间记录 + self.record_id = db.online_time.insert_one( + { + "start_timestamp": current_time, + "end_timestamp": current_time + timedelta(minutes=1), + } + ).inserted_id + except Exception as e: + logger.error(f"在线时间记录失败,错误信息:{e}") def _format_online_time(online_seconds: int) -> str: @@ -102,7 +100,7 @@ def _format_online_time(online_seconds: int) -> str: :param online_seconds: 在线时间(秒) :return: 格式化后的在线时间字符串 """ - total_oneline_time = timedelta(seconds=int(online_seconds)) # 确保是整数 + total_oneline_time = timedelta(seconds=online_seconds) days = total_oneline_time.days hours = total_oneline_time.seconds // 3600 @@ -110,15 +108,13 @@ def _format_online_time(online_seconds: int) -> str: seconds = total_oneline_time.seconds % 60 if days > 0: # 如果在线时间超过1天,则格式化为"X天X小时X分钟" - total_oneline_time_str = f"{total_oneline_time.days}天{hours}小时{minutes}分钟{seconds}秒" + return f"{total_oneline_time.days}天{hours}小时{minutes}分钟{seconds}秒" elif hours > 0: # 如果在线时间超过1小时,则格式化为"X小时X分钟X秒" - total_oneline_time_str = f"{hours}小时{minutes}分钟{seconds}秒" + return f"{hours}小时{minutes}分钟{seconds}秒" else: # 其他情况格式化为"X分钟X秒" - total_oneline_time_str = f"{minutes}分钟{seconds}秒" - - return total_oneline_time_str + return f"{minutes}分钟{seconds}秒" class StatisticOutputTask(AsyncTask): @@ -141,7 +137,7 @@ class StatisticOutputTask(AsyncTask): 记录文件路径 """ - now = datetime.now() # Renamed to avoid conflict with 'now' in methods + now = datetime.now() if "deploy_time" in local_storage: # 如果存在部署时间,则使用该时间作为全量统计的起始时间 deploy_time = datetime.fromtimestamp(local_storage["deploy_time"]) @@ -167,17 +163,16 @@ class StatisticOutputTask(AsyncTask): :param now: 基准当前时间 """ # 输出最近一小时的统计数据 - last_hour_stats = stats.get("last_hour", {}) # Ensure 'last_hour' key exists output = [ self.SEP_LINE, f" 最近1小时的统计数据 (自{now.strftime('%Y-%m-%d %H:%M:%S')}开始,详细信息见文件:{self.record_file_path})", self.SEP_LINE, - self._format_total_stat(last_hour_stats), + self._format_total_stat(stats["last_hour"]), "", - self._format_model_classified_stat(last_hour_stats), + self._format_model_classified_stat(stats["last_hour"]), "", - self._format_chat_stat(last_hour_stats), + self._format_chat_stat(stats["last_hour"]), self.SEP_LINE, "", ] @@ -191,10 +186,7 @@ class StatisticOutputTask(AsyncTask): stats = self._collect_all_statistics(now) # 输出统计数据到控制台 - if "last_hour" in stats: # Check if stats for last_hour were successfully collected - self._statistic_console_output(stats, now) - else: - logger.warning("无法输出最近一小时统计数据到控制台,因为数据缺失。") + self._statistic_console_output(stats, now) # 输出统计数据到html文件 self._generate_html_report(stats, now) except Exception as e: @@ -207,29 +199,37 @@ class StatisticOutputTask(AsyncTask): """ 收集指定时间段的LLM请求统计数据 - :param collect_period: 统计时间段 [(period_key, start_datetime), ...] + :param collect_period: 统计时间段 """ - if not collect_period: + if len(collect_period) <= 0: return {} - - collect_period.sort(key=lambda x: x[1], reverse=True) + else: + # 排序-按照时间段开始时间降序排列(最晚的时间段在前) + collect_period.sort(key=lambda x: x[1], reverse=True) stats = { period_key: { + # 总LLM请求数 TOTAL_REQ_CNT: 0, + # 请求次数统计 REQ_CNT_BY_TYPE: defaultdict(int), REQ_CNT_BY_USER: defaultdict(int), REQ_CNT_BY_MODEL: defaultdict(int), + # 输入Token数 IN_TOK_BY_TYPE: defaultdict(int), IN_TOK_BY_USER: defaultdict(int), IN_TOK_BY_MODEL: defaultdict(int), + # 输出Token数 OUT_TOK_BY_TYPE: defaultdict(int), OUT_TOK_BY_USER: defaultdict(int), OUT_TOK_BY_MODEL: defaultdict(int), + # 总Token数 TOTAL_TOK_BY_TYPE: defaultdict(int), TOTAL_TOK_BY_USER: defaultdict(int), TOTAL_TOK_BY_MODEL: defaultdict(int), + # 总开销 TOTAL_COST: 0.0, + # 请求开销统计 COST_BY_TYPE: defaultdict(float), COST_BY_USER: defaultdict(float), COST_BY_MODEL: defaultdict(float), @@ -237,54 +237,46 @@ class StatisticOutputTask(AsyncTask): for period_key, _ in collect_period } - # Determine the overall earliest start time for the database query - # This assumes collect_period is not empty, which is checked at the beginning. - overall_earliest_start_time = min(p[1] for p in collect_period) - - for record in db.llm_usage.find({"timestamp": {"$gte": overall_earliest_start_time}}): + # 以最早的时间戳为起始时间获取记录 + for record in db.llm_usage.find({"timestamp": {"$gte": collect_period[-1][1]}}): record_timestamp = record.get("timestamp") - if not isinstance(record_timestamp, datetime): # Ensure timestamp is a datetime object - try: # Attempt conversion if it's a number (e.g. Unix timestamp) - record_timestamp = datetime.fromtimestamp(float(record_timestamp)) - except (ValueError, TypeError): - logger.warning(f"Skipping LLM usage record with invalid timestamp: {record.get('_id')}") - continue + for idx, (_, period_start) in enumerate(collect_period): + if record_timestamp >= period_start: + # 如果记录时间在当前时间段内,则它一定在更早的时间段内 + # 因此,我们可以直接跳过更早的时间段的判断,直接更新当前以及更早时间段的统计数据 + for period_key, _ in collect_period[idx:]: + stats[period_key][TOTAL_REQ_CNT] += 1 - for idx, (_current_period_key, period_start_time) in enumerate(collect_period): - if record_timestamp >= period_start_time: - for period_key_to_update, _ in collect_period[idx:]: - stats[period_key_to_update][TOTAL_REQ_CNT] += 1 + request_type = record.get("request_type", "unknown") # 请求类型 + user_id = str(record.get("user_id", "unknown")) # 用户ID + model_name = record.get("model_name", "unknown") # 模型名称 - request_type = record.get("request_type", "unknown") - user_id = str(record.get("user_id", "unknown")) - model_name = record.get("model_name", "unknown") + stats[period_key][REQ_CNT_BY_TYPE][request_type] += 1 + stats[period_key][REQ_CNT_BY_USER][user_id] += 1 + stats[period_key][REQ_CNT_BY_MODEL][model_name] += 1 - stats[period_key_to_update][REQ_CNT_BY_TYPE][request_type] += 1 - stats[period_key_to_update][REQ_CNT_BY_USER][user_id] += 1 - stats[period_key_to_update][REQ_CNT_BY_MODEL][model_name] += 1 + prompt_tokens = record.get("prompt_tokens", 0) # 输入Token数 + completion_tokens = record.get("completion_tokens", 0) # 输出Token数 + total_tokens = prompt_tokens + completion_tokens # Token总数 = 输入Token数 + 输出Token数 - prompt_tokens = record.get("prompt_tokens", 0) - completion_tokens = record.get("completion_tokens", 0) - total_tokens = prompt_tokens + completion_tokens + stats[period_key][IN_TOK_BY_TYPE][request_type] += prompt_tokens + stats[period_key][IN_TOK_BY_USER][user_id] += prompt_tokens + stats[period_key][IN_TOK_BY_MODEL][model_name] += prompt_tokens - stats[period_key_to_update][IN_TOK_BY_TYPE][request_type] += prompt_tokens - stats[period_key_to_update][IN_TOK_BY_USER][user_id] += prompt_tokens - stats[period_key_to_update][IN_TOK_BY_MODEL][model_name] += prompt_tokens + stats[period_key][OUT_TOK_BY_TYPE][request_type] += completion_tokens + stats[period_key][OUT_TOK_BY_USER][user_id] += completion_tokens + stats[period_key][OUT_TOK_BY_MODEL][model_name] += completion_tokens - stats[period_key_to_update][OUT_TOK_BY_TYPE][request_type] += completion_tokens - stats[period_key_to_update][OUT_TOK_BY_USER][user_id] += completion_tokens - stats[period_key_to_update][OUT_TOK_BY_MODEL][model_name] += completion_tokens - - stats[period_key_to_update][TOTAL_TOK_BY_TYPE][request_type] += total_tokens - stats[period_key_to_update][TOTAL_TOK_BY_USER][user_id] += total_tokens - stats[period_key_to_update][TOTAL_TOK_BY_MODEL][model_name] += total_tokens + stats[period_key][TOTAL_TOK_BY_TYPE][request_type] += total_tokens + stats[period_key][TOTAL_TOK_BY_USER][user_id] += total_tokens + stats[period_key][TOTAL_TOK_BY_MODEL][model_name] += total_tokens cost = record.get("cost", 0.0) - stats[period_key_to_update][TOTAL_COST] += cost - stats[period_key_to_update][COST_BY_TYPE][request_type] += cost - stats[period_key_to_update][COST_BY_USER][user_id] += cost - stats[period_key_to_update][COST_BY_MODEL][model_name] += cost - break + stats[period_key][TOTAL_COST] += cost + stats[period_key][COST_BY_TYPE][request_type] += cost + stats[period_key][COST_BY_USER][user_id] += cost + stats[period_key][COST_BY_MODEL][model_name] += cost + break # 取消更早时间段的判断 return stats @@ -293,43 +285,40 @@ class StatisticOutputTask(AsyncTask): """ 收集指定时间段的在线时间统计数据 - :param collect_period: 统计时间段 [(period_key, start_datetime), ...] - :param now: 当前时间,用于校准end_timestamp + :param collect_period: 统计时间段 """ - if not collect_period: + if len(collect_period) <= 0: return {} - - collect_period.sort(key=lambda x: x[1], reverse=True) + else: + # 排序-按照时间段开始时间降序排列(最晚的时间段在前) + collect_period.sort(key=lambda x: x[1], reverse=True) stats = { period_key: { + # 在线时间统计 ONLINE_TIME: 0.0, } for period_key, _ in collect_period } - overall_earliest_start_time = min(p[1] for p in collect_period) - - for record in db.online_time.find({"end_timestamp": {"$gte": overall_earliest_start_time}}): - record_end_timestamp: datetime = record.get("end_timestamp") - record_start_timestamp: datetime = record.get("start_timestamp") - - if not isinstance(record_end_timestamp, datetime) or not isinstance(record_start_timestamp, datetime): - logger.warning(f"Skipping online_time record with invalid timestamps: {record.get('_id')}") - continue - - actual_end_timestamp = min(record_end_timestamp, now) - - for idx, (_current_period_key, period_start_time) in enumerate(collect_period): - if record_start_timestamp < now and actual_end_timestamp > period_start_time: - overlap_start = max(record_start_timestamp, period_start_time) - overlap_end = min(actual_end_timestamp, now) - - if overlap_end > overlap_start: - duration_seconds = (overlap_end - overlap_start).total_seconds() - for period_key_to_update, _ in collect_period[idx:]: - stats[period_key_to_update][ONLINE_TIME] += duration_seconds - break + # 统计在线时间 + for record in db.online_time.find({"end_timestamp": {"$gte": collect_period[-1][1]}}): + end_timestamp: datetime = record.get("end_timestamp") + for idx, (_, period_start) in enumerate(collect_period): + if end_timestamp >= period_start: + # 由于end_timestamp会超前标记时间,所以我们需要判断是否晚于当前时间,如果是,则使用当前时间作为结束时间 + end_timestamp = min(end_timestamp, now) + # 如果记录时间在当前时间段内,则它一定在更早的时间段内 + # 因此,我们可以直接跳过更早的时间段的判断,直接更新当前以及更早时间段的统计数据 + for period_key, _period_start in collect_period[idx:]: + start_timestamp: datetime = record.get("start_timestamp") + if start_timestamp < _period_start: + # 如果开始时间在查询边界之前,则使用开始时间 + stats[period_key][ONLINE_TIME] += (end_timestamp - _period_start).total_seconds() + else: + # 否则,使用开始时间 + stats[period_key][ONLINE_TIME] += (end_timestamp - start_timestamp).total_seconds() + break # 取消更早时间段的判断 return stats @@ -337,66 +326,55 @@ class StatisticOutputTask(AsyncTask): """ 收集指定时间段的消息统计数据 - :param collect_period: 统计时间段 [(period_key, start_datetime), ...] + :param collect_period: 统计时间段 """ - if not collect_period: + if len(collect_period) <= 0: return {} - - collect_period.sort(key=lambda x: x[1], reverse=True) + else: + # 排序-按照时间段开始时间降序排列(最晚的时间段在前) + collect_period.sort(key=lambda x: x[1], reverse=True) stats = { period_key: { + # 消息统计 TOTAL_MSG_CNT: 0, MSG_CNT_BY_CHAT: defaultdict(int), } for period_key, _ in collect_period } - overall_earliest_start_timestamp_float = min(p[1].timestamp() for p in collect_period) + # 统计消息量 + for message in db.messages.find({"time": {"$gte": collect_period[-1][1].timestamp()}}): + chat_info = message.get("chat_info", None) # 聊天信息 + user_info = message.get("user_info", None) # 用户信息(消息发送人) + message_time = message.get("time", 0) # 消息时间 - for message in db.messages.find({"time": {"$gte": overall_earliest_start_timestamp_float}}): - chat_info = message.get("chat_info", {}) - user_info = message.get("user_info", {}) - message_time_ts = message.get("time") - - if message_time_ts is None: - logger.warning(f"Skipping message record with no timestamp: {message.get('_id')}") - continue - - try: - message_datetime = datetime.fromtimestamp(float(message_time_ts)) - except (ValueError, TypeError): - logger.warning(f"Skipping message record with invalid time format: {message.get('_id')}") - continue - - group_info = chat_info.get("group_info") - chat_id = None - chat_name = None - - if group_info and group_info.get("group_id"): - gid = group_info.get("group_id") - chat_id = f"g{gid}" - chat_name = group_info.get("group_name", f"群聊 {gid}") - elif user_info and user_info.get("user_id"): - uid = user_info["user_id"] - chat_id = f"u{uid}" - chat_name = user_info.get("user_nickname", f"用户 {uid}") - - if not chat_id: - continue - - current_mapping = self.name_mapping.get(chat_id) - if current_mapping: - if chat_name != current_mapping[0] and message_time_ts > current_mapping[1]: - self.name_mapping[chat_id] = (chat_name, message_time_ts) + group_info = chat_info.get("group_info") if chat_info else None # 尝试获取群聊信息 + if group_info is not None: + # 若有群聊信息 + chat_id = f"g{group_info.get('group_id')}" + chat_name = group_info.get("group_name", f"群{group_info.get('group_id')}") + elif user_info: + # 若没有群聊信息,则尝试获取用户信息 + chat_id = f"u{user_info['user_id']}" + chat_name = user_info["user_nickname"] else: - self.name_mapping[chat_id] = (chat_name, message_time_ts) + continue # 如果没有群组信息也没有用户信息,则跳过 - for idx, (_current_period_key, period_start_time) in enumerate(collect_period): - if message_datetime >= period_start_time: - for period_key_to_update, _ in collect_period[idx:]: - stats[period_key_to_update][TOTAL_MSG_CNT] += 1 - stats[period_key_to_update][MSG_CNT_BY_CHAT][chat_id] += 1 + if chat_id in self.name_mapping: + if chat_name != self.name_mapping[chat_id][0] and message_time > self.name_mapping[chat_id][1]: + # 如果用户名称不同,且新消息时间晚于之前记录的时间,则更新用户名称 + self.name_mapping[chat_id] = (chat_name, message_time) + else: + self.name_mapping[chat_id] = (chat_name, message_time) + + for idx, (_, period_start) in enumerate(collect_period): + if message_time >= period_start.timestamp(): + # 如果记录时间在当前时间段内,则它一定在更早的时间段内 + # 因此,我们可以直接跳过更早的时间段的判断,直接更新当前以及更早时间段的统计数据 + for period_key, _ in collect_period[idx:]: + stats[period_key][TOTAL_MSG_CNT] += 1 + stats[period_key][MSG_CNT_BY_CHAT][chat_id] += 1 break return stats @@ -406,77 +384,53 @@ class StatisticOutputTask(AsyncTask): 收集各时间段的统计数据 :param now: 基准当前时间 """ - # Correctly determine deploy_time - if "deploy_time" in local_storage: - try: - deploy_time = datetime.fromtimestamp(local_storage["deploy_time"]) - except (TypeError, ValueError): - logger.error("Invalid deploy_time in local_storage. Resetting.") - deploy_time = datetime(2000, 1, 1) - local_storage["deploy_time"] = now.timestamp() - else: - deploy_time = datetime(2000, 1, 1) - local_storage["deploy_time"] = now.timestamp() - # Rebuild stat_period based on the current 'now' and determined 'deploy_time' - current_stat_periods_config = [ - ("all_time", now - deploy_time if now > deploy_time else timedelta(seconds=0), "自部署以来"), - ("last_7_days", timedelta(days=7), "最近7天"), - ("last_24_hours", timedelta(days=1), "最近24小时"), - ("last_hour", timedelta(hours=1), "最近1小时"), - ] - self.stat_period = current_stat_periods_config # Update instance's stat_period if needed elsewhere + last_all_time_stat = None - stat_start_timestamp_config = [] - for period_name, delta, _ in current_stat_periods_config: - start_dt = deploy_time if period_name == "all_time" else now - delta - stat_start_timestamp_config.append((period_name, start_dt)) + if "last_full_statistics" in local_storage: + # 如果存在上次完整统计数据,则使用该数据进行增量统计 + last_stat = local_storage["last_full_statistics"] # 上次完整统计数据 - # 收集各类数据 - model_req_stat = self._collect_model_request_for_period(stat_start_timestamp_config) - online_time_stat = self._collect_online_time_for_period(stat_start_timestamp_config, now) - message_count_stat = self._collect_message_count_for_period(stat_start_timestamp_config) + self.name_mapping = last_stat["name_mapping"] # 上次完整统计数据的名称映射 + last_all_time_stat = last_stat["stat_data"] # 上次完整统计的统计数据 + last_stat_timestamp = datetime.fromtimestamp(last_stat["timestamp"]) # 上次完整统计数据的时间戳 + self.stat_period = [item for item in self.stat_period if item[0] != "all_time"] # 删除"所有时间"的统计时段 + self.stat_period.append(("all_time", now - last_stat_timestamp, "自部署以来的")) - final_stats = {} - for period_key, _ in stat_start_timestamp_config: - final_stats[period_key] = {} - final_stats[period_key].update(model_req_stat.get(period_key, {})) - final_stats[period_key].update(online_time_stat.get(period_key, {})) - final_stats[period_key].update(message_count_stat.get(period_key, {})) + stat_start_timestamp = [(period[0], now - period[1]) for period in self.stat_period] - for stat_field_key in [ - TOTAL_REQ_CNT, - REQ_CNT_BY_TYPE, - REQ_CNT_BY_USER, - REQ_CNT_BY_MODEL, - IN_TOK_BY_TYPE, - IN_TOK_BY_USER, - IN_TOK_BY_MODEL, - OUT_TOK_BY_TYPE, - OUT_TOK_BY_USER, - OUT_TOK_BY_MODEL, - TOTAL_TOK_BY_TYPE, - TOTAL_TOK_BY_USER, - TOTAL_TOK_BY_MODEL, - TOTAL_COST, - COST_BY_TYPE, - COST_BY_USER, - COST_BY_MODEL, - ONLINE_TIME, - TOTAL_MSG_CNT, - MSG_CNT_BY_CHAT, - ]: - if stat_field_key not in final_stats[period_key]: - # Initialize with appropriate default type if key is missing - if "BY_" in stat_field_key: # These are usually defaultdicts - final_stats[period_key][stat_field_key] = defaultdict( - int if "CNT" in stat_field_key or "TOK" in stat_field_key else float - ) - elif "CNT" in stat_field_key or "TOK" in stat_field_key: - final_stats[period_key][stat_field_key] = 0 - elif "COST" in stat_field_key or ONLINE_TIME == stat_field_key: - final_stats[period_key][stat_field_key] = 0.0 - return final_stats + stat = {item[0]: {} for item in self.stat_period} + + model_req_stat = self._collect_model_request_for_period(stat_start_timestamp) + online_time_stat = self._collect_online_time_for_period(stat_start_timestamp, now) + message_count_stat = self._collect_message_count_for_period(stat_start_timestamp) + + # 统计数据合并 + # 合并三类统计数据 + for period_key, _ in stat_start_timestamp: + stat[period_key].update(model_req_stat[period_key]) + stat[period_key].update(online_time_stat[period_key]) + stat[period_key].update(message_count_stat[period_key]) + + if last_all_time_stat: + # 若存在上次完整统计数据,则将其与当前统计数据合并 + for key, val in last_all_time_stat.items(): + if isinstance(val, dict): + # 是字典类型,则进行合并 + for sub_key, sub_val in val.items(): + stat["all_time"][key][sub_key] += sub_val + else: + # 直接合并 + stat["all_time"][key] += val + + # 更新上次完整统计数据的时间戳 + local_storage["last_full_statistics"] = { + "name_mapping": self.name_mapping, + "stat_data": stat["all_time"], + "timestamp": now.timestamp(), + } + + return stat # -- 以下为统计数据格式化方法 -- @@ -485,13 +439,15 @@ class StatisticOutputTask(AsyncTask): """ 格式化总统计数据 """ + output = [ - f"总在线时间: {_format_online_time(stats.get(ONLINE_TIME, 0))}", - f"总消息数: {stats.get(TOTAL_MSG_CNT, 0)}", - f"总请求数: {stats.get(TOTAL_REQ_CNT, 0)}", - f"总花费: {stats.get(TOTAL_COST, 0.0):.4f}¥", + f"总在线时间: {_format_online_time(stats[ONLINE_TIME])}", + f"总消息数: {stats[TOTAL_MSG_CNT]}", + f"总请求数: {stats[TOTAL_REQ_CNT]}", + f"总花费: {stats[TOTAL_COST]:.4f}¥", "", ] + return "\n".join(output) @staticmethod @@ -499,183 +455,174 @@ class StatisticOutputTask(AsyncTask): """ 格式化按模型分类的统计数据 """ - if stats.get(TOTAL_REQ_CNT, 0) > 0: - data_fmt = "{:<32} {:>10} {:>12} {:>12} {:>12} {:>9.4f}¥" - output = [ - "按模型分类统计:", - " 模型名称 调用次数 输入Token 输出Token Token总量 累计花费", - ] - req_cnt_by_model = stats.get(REQ_CNT_BY_MODEL, {}) - in_tok_by_model = stats.get(IN_TOK_BY_MODEL, defaultdict(int)) - out_tok_by_model = stats.get(OUT_TOK_BY_MODEL, defaultdict(int)) - total_tok_by_model = stats.get(TOTAL_TOK_BY_MODEL, defaultdict(int)) - cost_by_model = stats.get(COST_BY_MODEL, defaultdict(float)) - - for model_name, count in sorted(req_cnt_by_model.items()): - name = model_name[:29] + "..." if len(model_name) > 32 else model_name - in_tokens = in_tok_by_model[model_name] - out_tokens = out_tok_by_model[model_name] - tokens = total_tok_by_model[model_name] - cost = cost_by_model[model_name] - output.append(data_fmt.format(name, count, in_tokens, out_tokens, tokens, cost)) - - output.append("") - return "\n".join(output) - else: + if stats[TOTAL_REQ_CNT] <= 0: return "" + data_fmt = "{:<32} {:>10} {:>12} {:>12} {:>12} {:>9.4f}¥" + + output = [ + "按模型分类统计:", + " 模型名称 调用次数 输入Token 输出Token Token总量 累计花费", + ] + for model_name, count in sorted(stats[REQ_CNT_BY_MODEL].items()): + name = f"{model_name[:29]}..." if len(model_name) > 32 else model_name + in_tokens = stats[IN_TOK_BY_MODEL][model_name] + out_tokens = stats[OUT_TOK_BY_MODEL][model_name] + tokens = stats[TOTAL_TOK_BY_MODEL][model_name] + cost = stats[COST_BY_MODEL][model_name] + output.append(data_fmt.format(name, count, in_tokens, out_tokens, tokens, cost)) + + output.append("") + return "\n".join(output) def _format_chat_stat(self, stats: Dict[str, Any]) -> str: """ 格式化聊天统计数据 """ - if stats.get(TOTAL_MSG_CNT, 0) > 0: - output = ["聊天消息统计:", " 联系人/群组名称 消息数量"] - msg_cnt_by_chat = stats.get(MSG_CNT_BY_CHAT, {}) - for chat_id, count in sorted(msg_cnt_by_chat.items()): - chat_name_display = self.name_mapping.get(chat_id, (f"未知 ({chat_id})", None))[0] - output.append(f"{chat_name_display[:32]:<32} {count:>10}") - - output.append("") - return "\n".join(output) - else: + if stats[TOTAL_MSG_CNT] <= 0: return "" + output = ["聊天消息统计:", " 联系人/群组名称 消息数量"] + output.extend( + f"{self.name_mapping[chat_id][0][:32]:<32} {count:>10}" + for chat_id, count in sorted(stats[MSG_CNT_BY_CHAT].items()) + ) + output.append("") + return "\n".join(output) - def _generate_html_report(self, stat_collection: dict[str, Any], now: datetime): + def _generate_html_report(self, stat: dict[str, Any], now: datetime): """ 生成HTML格式的统计报告 - :param stat_collection: 包含所有时间段统计数据的字典 {period_key: stats_dict} + :param stat: 统计数据 :param now: 基准当前时间 + :return: HTML格式的统计报告 """ - # Correctly get deploy_time_dt for display purposes - if "deploy_time" in local_storage: - try: - deploy_time_dt = datetime.fromtimestamp(local_storage["deploy_time"]) - except (TypeError, ValueError): - logger.error("Invalid deploy_time in local_storage for HTML report. Using default.") - deploy_time_dt = datetime(2000, 1, 1) # Fallback - else: - # This should ideally not happen if __init__ or _collect_all_statistics ran - logger.warning("deploy_time not found in local_storage for HTML report. Using default.") - deploy_time_dt = datetime(2000, 1, 1) # Fallback - tab_list_html = [] - tab_content_html_list = [] + tab_list = [ + f'' + for period in self.stat_period + ] - for ( - period_key, - period_delta, - period_display_name, - ) in self.stat_period: # Use self.stat_period as defined by _collect_all_statistics - tab_list_html.append( - f'' + def _format_stat_data(stat_data: dict[str, Any], div_id: str, start_time: datetime) -> str: + """ + 格式化一个时间段的统计数据到html div块 + :param stat_data: 统计数据 + :param div_id: div的ID + :param start_time: 统计时间段开始时间 + """ + # format总在线时间 + + # 按模型分类统计 + model_rows = "\n".join( + [ + f"" + f"{model_name}" + f"{count}" + f"{stat_data[IN_TOK_BY_MODEL][model_name]}" + f"{stat_data[OUT_TOK_BY_MODEL][model_name]}" + f"{stat_data[TOTAL_TOK_BY_MODEL][model_name]}" + f"{stat_data[COST_BY_MODEL][model_name]:.4f} ¥" + f"" + for model_name, count in sorted(stat_data[REQ_CNT_BY_MODEL].items()) + ] ) - - current_period_stats = stat_collection.get(period_key, {}) - - if period_key == "all_time": - start_time_dt_for_period = deploy_time_dt - else: - # Ensure period_delta is a timedelta object - if isinstance(period_delta, timedelta): - start_time_dt_for_period = now - period_delta - else: # Fallback if period_delta is not as expected (e.g. from old self.stat_period) - logger.warning( - f"period_delta for {period_key} is not a timedelta. Using 'now'. Type: {type(period_delta)}" - ) - start_time_dt_for_period = now - - html_content_for_tab = f""" -
-

+ # 按请求类型分类统计 + type_rows = "\n".join( + [ + f"" + f"{req_type}" + f"{count}" + f"{stat_data[IN_TOK_BY_TYPE][req_type]}" + f"{stat_data[OUT_TOK_BY_TYPE][req_type]}" + f"{stat_data[TOTAL_TOK_BY_TYPE][req_type]}" + f"{stat_data[COST_BY_TYPE][req_type]:.4f} ¥" + f"" + for req_type, count in sorted(stat_data[REQ_CNT_BY_TYPE].items()) + ] + ) + # 按用户分类统计 + user_rows = "\n".join( + [ + f"" + f"{user_id}" + f"{count}" + f"{stat_data[IN_TOK_BY_USER][user_id]}" + f"{stat_data[OUT_TOK_BY_USER][user_id]}" + f"{stat_data[TOTAL_TOK_BY_USER][user_id]}" + f"{stat_data[COST_BY_USER][user_id]:.4f} ¥" + f"" + for user_id, count in sorted(stat_data[REQ_CNT_BY_USER].items()) + ] + ) + # 聊天消息统计 + chat_rows = "\n".join( + [ + f"{self.name_mapping[chat_id][0]}{count}" + for chat_id, count in sorted(stat_data[MSG_CNT_BY_CHAT].items()) + ] + ) + # 生成HTML + return f""" +

+

统计时段: - {start_time_dt_for_period.strftime("%Y-%m-%d %H:%M:%S")} ~ {now.strftime("%Y-%m-%d %H:%M:%S")} + {start_time.strftime("%Y-%m-%d %H:%M:%S")} ~ {now.strftime("%Y-%m-%d %H:%M:%S")}

-

总在线时间: {_format_online_time(current_period_stats.get(ONLINE_TIME, 0))}

-

总消息数: {current_period_stats.get(TOTAL_MSG_CNT, 0)}

-

总请求数: {current_period_stats.get(TOTAL_REQ_CNT, 0)}

-

总花费: {current_period_stats.get(TOTAL_COST, 0.0):.4f} ¥

+

总在线时间: {_format_online_time(stat_data[ONLINE_TIME])}

+

总消息数: {stat_data[TOTAL_MSG_CNT]}

+

总请求数: {stat_data[TOTAL_REQ_CNT]}

+

总花费: {stat_data[TOTAL_COST]:.4f} ¥

+ +

按模型分类统计

+ + + + {model_rows} + +
模型名称调用次数输入Token输出TokenToken总量累计花费
+ +

按请求类型分类统计

+ + + + + + {type_rows} + +
请求类型调用次数输入Token输出TokenToken总量累计花费
+ +

按用户分类统计

+ + + + + + {user_rows} + +
用户名称调用次数输入Token输出TokenToken总量累计花费
+ +

聊天消息统计

+ + + + + + {chat_rows} + +
联系人/群组名称消息数量
+
""" - html_content_for_tab += "

按模型分类统计

" - req_cnt_by_model = current_period_stats.get(REQ_CNT_BY_MODEL, {}) - in_tok_by_model = current_period_stats.get(IN_TOK_BY_MODEL, defaultdict(int)) - out_tok_by_model = current_period_stats.get(OUT_TOK_BY_MODEL, defaultdict(int)) - total_tok_by_model = current_period_stats.get(TOTAL_TOK_BY_MODEL, defaultdict(int)) - cost_by_model = current_period_stats.get(COST_BY_MODEL, defaultdict(float)) - if req_cnt_by_model: - for model_name, count in sorted(req_cnt_by_model.items()): - html_content_for_tab += ( - f"" - f"" - f"" - f"" - f"" - f"" - f"" - f"" - ) - else: - html_content_for_tab += "" - html_content_for_tab += "
模型名称调用次数输入Token输出TokenToken总量累计花费
{model_name}{count}{in_tok_by_model[model_name]}{out_tok_by_model[model_name]}{total_tok_by_model[model_name]}{cost_by_model[model_name]:.4f} ¥
无数据
" + tab_content_list = [ + _format_stat_data(stat[period[0]], period[0], now - period[1]) + for period in self.stat_period + if period[0] != "all_time" + ] - html_content_for_tab += "

按请求类型分类统计

" - req_cnt_by_type = current_period_stats.get(REQ_CNT_BY_TYPE, {}) - in_tok_by_type = current_period_stats.get(IN_TOK_BY_TYPE, defaultdict(int)) - out_tok_by_type = current_period_stats.get(OUT_TOK_BY_TYPE, defaultdict(int)) - total_tok_by_type = current_period_stats.get(TOTAL_TOK_BY_TYPE, defaultdict(int)) - cost_by_type = current_period_stats.get(COST_BY_TYPE, defaultdict(float)) - if req_cnt_by_type: - for req_type, count in sorted(req_cnt_by_type.items()): - html_content_for_tab += ( - f"" - f"" - f"" - f"" - f"" - f"" - f"" - f"" - ) - else: - html_content_for_tab += "" - html_content_for_tab += "
请求类型调用次数输入Token输出TokenToken总量累计花费
{req_type}{count}{in_tok_by_type[req_type]}{out_tok_by_type[req_type]}{total_tok_by_type[req_type]}{cost_by_type[req_type]:.4f} ¥
无数据
" + tab_content_list.append( + _format_stat_data(stat["all_time"], "all_time", datetime.fromtimestamp(local_storage["deploy_time"])) + ) - html_content_for_tab += "

按用户分类统计

" - req_cnt_by_user = current_period_stats.get(REQ_CNT_BY_USER, {}) - in_tok_by_user = current_period_stats.get(IN_TOK_BY_USER, defaultdict(int)) - out_tok_by_user = current_period_stats.get(OUT_TOK_BY_USER, defaultdict(int)) - total_tok_by_user = current_period_stats.get(TOTAL_TOK_BY_USER, defaultdict(int)) - cost_by_user = current_period_stats.get(COST_BY_USER, defaultdict(float)) - if req_cnt_by_user: - for user_id, count in sorted(req_cnt_by_user.items()): - user_display_name = self.name_mapping.get(user_id, (user_id, None))[0] - html_content_for_tab += ( - f"" - f"" - f"" - f"" - f"" - f"" - f"" - f"" - ) - else: - html_content_for_tab += "" - html_content_for_tab += "
用户ID/名称调用次数输入Token输出TokenToken总量累计花费
{user_display_name}{count}{in_tok_by_user[user_id]}{out_tok_by_user[user_id]}{total_tok_by_user[user_id]}{cost_by_user[user_id]:.4f} ¥
无数据
" - - html_content_for_tab += ( - "

聊天消息统计

" - ) - msg_cnt_by_chat = current_period_stats.get(MSG_CNT_BY_CHAT, {}) - if msg_cnt_by_chat: - for chat_id, count in sorted(msg_cnt_by_chat.items()): - chat_name_display = self.name_mapping.get(chat_id, (f"未知/归档聊天 ({chat_id})", None))[0] - html_content_for_tab += f"" - else: - html_content_for_tab += "" - html_content_for_tab += "
联系人/群组名称消息数量
{chat_name_display}{count}
无数据
" - - tab_content_html_list.append(html_content_for_tab) + joined_tab_list = "\n".join(tab_list) + joined_tab_content = "\n".join(tab_content_list) html_template = ( """ @@ -739,7 +686,6 @@ class StatisticOutputTask(AsyncTask): border: 1px solid #ddd; padding: 10px; text-align: left; - word-break: break-all; } th { background-color: #3498db; @@ -758,38 +704,24 @@ class StatisticOutputTask(AsyncTask): .tabs { overflow: hidden; background: #ecf0f1; - display: flex; - flex-wrap: wrap; - margin-bottom: -1px; + display: flex; } .tabs button { - background: inherit; - border: 1px solid #ccc; - border-bottom: none; - outline: none; - padding: 14px 16px; - cursor: pointer; - transition: 0.3s; - font-size: 16px; - margin-right: 2px; - border-radius: 4px 4px 0 0; + background: inherit; border: none; outline: none; + padding: 14px 16px; cursor: pointer; + transition: 0.3s; font-size: 16px; } .tabs button:hover { background-color: #d4dbdc; } .tabs button.active { - background-color: #fff; - border-color: #ccc; - border-bottom: 1px solid #fff; - position: relative; - z-index: 1; + background-color: #b3bbbd; } .tab-content { display: none; padding: 20px; background-color: #fff; border: 1px solid #ccc; - border-top: none; } .tab-content.active { display: block; @@ -804,14 +736,10 @@ class StatisticOutputTask(AsyncTask):

统计截止时间: {now.strftime("%Y-%m-%d %H:%M:%S")}

- {"".join(tab_list_html)} + {joined_tab_list}
- {"".join(tab_content_html_list)} - - + {joined_tab_content} """ + """ @@ -820,35 +748,20 @@ class StatisticOutputTask(AsyncTask): tab_content = document.getElementsByClassName("tab-content"); tab_links = document.getElementsByClassName("tab-link"); - if (tab_content.length > 0 && tab_links.length > 0) { - tab_content[0].classList.add("active"); - tab_links[0].classList.add("active"); - } + tab_content[0].classList.add("active"); + tab_links[0].classList.add("active"); - function showTab(evt, tabName) { - for (i = 0; i < tab_content.length; i++) { - tab_content[i].classList.remove("active"); - } - for (i = 0; i < tab_links.length; i++) { - tab_links[i].classList.remove("active"); - } - const currentTabContent = document.getElementById(tabName); - if (currentTabContent) { - currentTabContent.classList.add("active"); - } - if (evt.currentTarget) { - evt.currentTarget.classList.add("active"); - } - } + function showTab(evt, tabName) {{ + for (i = 0; i < tab_content.length; i++) tab_content[i].classList.remove("active"); + for (i = 0; i < tab_links.length; i++) tab_links[i].classList.remove("active"); + document.getElementById(tabName).classList.add("active"); + evt.currentTarget.classList.add("active"); + }} """ ) - try: - with open(self.record_file_path, "w", encoding="utf-8") as f: - f.write(html_template) - logger.info(f"统计报告已生成: {self.record_file_path}") - except IOError as e: - logger.error(f"无法写入统计报告文件 {self.record_file_path}: {e}") + with open(self.record_file_path, "w", encoding="utf-8") as f: + f.write(html_template) \ No newline at end of file diff --git a/src/config/config.py b/src/config/config.py index a4dedcc2..8c3cbb1a 100644 --- a/src/config/config.py +++ b/src/config/config.py @@ -1,6 +1,3 @@ -# TODO: 更多的可配置项 -# TODO: 所有模型单独分离,温度可配置 -# TODO: 原生多模态支持 import os import re from dataclasses import dataclass, field @@ -285,7 +282,6 @@ class BotConfig: # enable_think_flow: bool = False # 是否启用思考流程 enable_friend_whitelist: bool = True # 是否启用好友白名单 talk_allowed_private = set() - api_polling_max_retries: int = 3 # 神秘小功能 rename_person: bool = ( True # 是否启用改名工具,可以让麦麦对唯一名进行更改,可能可以更拟人地称呼他人,但是也可能导致记忆混淆的问题 ) @@ -424,13 +420,13 @@ class BotConfig: if config.INNER_VERSION in SpecifierSet(">=1.2.4"): config.personality_core = personality_config.get("personality_core", config.personality_core) config.personality_sides = personality_config.get("personality_sides", config.personality_sides) - if config.INNER_VERSION in SpecifierSet(">=1.6.1.2"): + if config.INNER_VERSION in SpecifierSet(">=1.7.1"): config.personality_detail_level = personality_config.get( "personality_detail_level", config.personality_sides ) if config.INNER_VERSION in SpecifierSet(">=1.7.0"): config.expression_style = personality_config.get("expression_style", config.expression_style) - if config.INNER_VERSION in SpecifierSet(">=1.7.0.3"): + if config.INNER_VERSION in SpecifierSet(">=1.7.1"): config.enable_expression_learner = personality_config.get( "enable_expression_learner", config.enable_expression_learner ) @@ -477,7 +473,7 @@ class BotConfig: config.steal_emoji = emoji_config.get("steal_emoji", config.steal_emoji) def group_nickname(parent: dict): - if config.INNER_VERSION in SpecifierSet(">=1.6.1.4"): + if config.INNER_VERSION in SpecifierSet(">=1.7.1"): group_nickname_config = parent.get("group_nickname", {}) config.enable_nickname_mapping = group_nickname_config.get( "enable_nickname_mapping", config.enable_nickname_mapping @@ -521,7 +517,7 @@ class BotConfig: config.ban_words = chat_config.get("ban_words", config.ban_words) for r in chat_config.get("ban_msgs_regex", config.ban_msgs_regex): config.ban_msgs_regex.add(re.compile(r)) - if config.INNER_VERSION in SpecifierSet(">=1.6.1.2"): + if config.INNER_VERSION in SpecifierSet(">=1.7.1"): config.allow_remove_duplicates = chat_config.get( "allow_remove_duplicates", config.allow_remove_duplicates ) @@ -691,7 +687,7 @@ class BotConfig: config.consolidate_memory_percentage = memory_config.get( "consolidate_memory_percentage", config.consolidate_memory_percentage ) - if config.INNER_VERSION in SpecifierSet(">=1.6.1.3"): + if config.INNER_VERSION in SpecifierSet(">=1.7.1"): config.long_message_auto_truncate = memory_config.get( "long_message_auto_truncate", config.long_message_auto_truncate ) @@ -760,21 +756,17 @@ class BotConfig: config.enable_friend_chat = experimental_config.get("enable_friend_chat", config.enable_friend_chat) # config.enable_think_flow = experimental_config.get("enable_think_flow", config.enable_think_flow) config.talk_allowed_private = set(str(user) for user in experimental_config.get("talk_allowed_private", [])) - if config.INNER_VERSION in SpecifierSet(">=1.6.2.4"): + if config.INNER_VERSION in SpecifierSet(">=1.7.1"): config.enable_friend_whitelist = experimental_config.get( "enable_friend_whitelist", config.enable_friend_whitelist ) - if config.INNER_VERSION in SpecifierSet(">=1.6.1.5"): - config.api_polling_max_retries = experimental_config.get( - "api_polling_max_retries", config.api_polling_max_retries - ) - if config.INNER_VERSION in SpecifierSet(">=1.6.2.3"): + if config.INNER_VERSION in SpecifierSet(">=1.7.1"): config.rename_person = experimental_config.get("rename_person", config.rename_person) - if config.INNER_VERSION in SpecifierSet(">=1.7.0.1"): + if config.INNER_VERSION in SpecifierSet(">=1.7.1"): config.enable_Legacy_HFC = experimental_config.get("enable_Legacy_HFC", config.enable_Legacy_HFC) def pfc(parent: dict): - if config.INNER_VERSION in SpecifierSet(">=1.6.2.4"): + if config.INNER_VERSION in SpecifierSet(">=1.7.1"): pfc_config = parent.get("pfc", {}) # 解析 [pfc] 下的直接字段 config.enable_pfc_chatting = pfc_config.get("enable_pfc_chatting", config.enable_pfc_chatting) diff --git a/src/experimental/Legacy_HFC/heartFC_chatting_logic.md b/src/experimental/Legacy_HFC/heartFC_chatting_logic.md deleted file mode 100644 index 6d51c978..00000000 --- a/src/experimental/Legacy_HFC/heartFC_chatting_logic.md +++ /dev/null @@ -1,92 +0,0 @@ -# HeartFChatting 逻辑详解 - -`HeartFChatting` 类是心流系统(Heart Flow System)中实现**专注聊天**(`ChatState.FOCUSED`)功能的核心。顾名思义,其职责乃是在特定聊天流(`stream_id`)中,模拟更为连贯深入之对话。此非凭空臆造,而是依赖一个持续不断的 **思考(Think)-规划(Plan)-执行(Execute)** 循环。当其所系的 `SubHeartflow` 进入 `FOCUSED` 状态时,便会创建并启动 `HeartFChatting` 实例;若状态转为他途(譬如 `CHAT` 或 `ABSENT`),则会将其关闭。 - -## 1. 初始化简述 (`__init__`, `_initialize`) - -创生之初,`HeartFChatting` 需注入若干关键之物:`chat_id`(亦即 `stream_id`)、关联的 `SubMind` 实例,以及 `Observation` 实例(用以观察环境)。 - -其内部核心组件包括: - -- `ActionManager`: 管理当前循环可选之策(如:不应、言语、表情)。 -- `HeartFCGenerator` (`self.gpt_instance`): 专司生成回复文本之职。 -- `ToolUser` (`self.tool_user`): 虽主要用于获取工具定义,然亦备 `SubMind` 调用之需(实际执行由 `SubMind` 操持)。 -- `HeartFCSender` (`self.heart_fc_sender`): 负责消息发送诸般事宜,含"正在思考"之态。 -- `LLMRequest` (`self.planner_llm`): 配置用于执行"规划"任务的大语言模型。 - -*初始化过程采取懒加载策略,仅在首次需要访问 `ChatStream` 时(通常在 `start` 方法中)进行。* - -## 2. 生命周期 (`start`, `shutdown`) - -- **启动 (`start`)**: 外部调用此法,以启 `HeartFChatting` 之流程。内部会安全地启动主循环任务。 -- **关闭 (`shutdown`)**: 外部调用此法,以止其运行。会取消主循环任务,清理状态,并释放锁。 - -## 3. 核心循环 (`_hfc_loop`) 与 循环记录 (`CycleInfo`) - -`_hfc_loop` 乃 `HeartFChatting` 之脉搏,以异步方式不舍昼夜运行(直至 `shutdown` 被调用)。其核心在于周而复始地执行 **思考-规划-执行** 之周期。 - -每一轮循环,皆会创建一个 `CycleInfo` 对象。此对象犹如史官,详细记载该次循环之点滴: - -- **身份标识**: 循环 ID (`cycle_id`)。 -- **时间轨迹**: 起止时刻 (`start_time`, `end_time`)。 -- **行动细节**: 是否执行动作 (`action_taken`)、动作类型 (`action_type`)、决策理由 (`reasoning`)。 -- **耗时考量**: 各阶段计时 (`timers`)。 -- **关联信息**: 思考消息 ID (`thinking_id`)、是否重新规划 (`replanned`)、详尽响应信息 (`response_info`,含生成文本、表情、锚点、实际发送ID、`SubMind`思考等)。 - -这些 `CycleInfo` 被存入一个队列 (`_cycle_history`),近者得观。此记录不仅便于调试,更关键的是,它会作为**上下文信息**传递给下一次循环的"思考"阶段,使得 `SubMind` 能鉴往知来,做出更连贯的决策。 - -*循环间会根据执行情况智能引入延迟,避免空耗资源。* - -## 4. 思考-规划-执行周期 (`_think_plan_execute_loop`) - -此乃 `HeartFChatting` 最核心的逻辑单元,每一循环皆按序执行以下三步: - -### 4.1. 思考 (`_get_submind_thinking`) - -* **第一步:观察环境**: 调用 `Observation` 的 `observe()` 方法,感知聊天室是否有新动态(如新消息)。 -* **第二步:触发子思维**: 调用关联 `SubMind` 的 `do_thinking_before_reply()` 方法。 - * **关键点**: 会将**上一个循环**的 `CycleInfo` 传入,让 `SubMind` 了解上次行动的决策、理由及是否重新规划,从而实现"承前启后"的思考。 - * `SubMind` 在此阶段不仅进行思考,还可能**调用其配置的工具**来收集信息。 -* **第三步:获取成果**: `SubMind` 返回两部分重要信息: - 1. 当前的内心想法 (`current_mind`)。 - 2. 通过工具调用收集到的结构化信息 (`structured_info`)。 - -### 4.2. 规划 (`_planner`) - -* **输入**: 接收来自"思考"阶段的 `current_mind` 和 `structured_info`,以及"观察"到的最新消息。 -* **目标**: 基于当前想法、已知信息、聊天记录、机器人个性以及可用动作,决定**接下来要做什么**。 -* **决策方式**: - 1. 构建一个精心设计的提示词 (`_build_planner_prompt`)。 - 2. 获取 `ActionManager` 中定义的当前可用动作(如 `no_reply`, `text_reply`, `emoji_reply`)作为"工具"选项。 - 3. 调用大语言模型 (`self.planner_llm`),**强制**其选择一个动作"工具"并提供理由。可选动作包括: - * `no_reply`: 不回复(例如,自己刚说过话或对方未回应)。 - * `text_reply`: 发送文本回复。 - * `emoji_reply`: 仅发送表情。 - * 文本回复亦可附带表情(通过 `emoji_query` 参数指定)。 -* **动态调整(重新规划)**: - * 在做出初步决策后,会检查自规划开始后是否有新消息 (`_check_new_messages`)。 - * 若有新消息,则有一定概率触发**重新规划**。此时会再次调用规划器,但提示词会包含之前决策的信息,要求 LLM 重新考虑。 -* **输出**: 返回一个包含最终决策的字典,主要包括: - * `action`: 选定的动作类型。 - * `reasoning`: 做出此决策的理由。 - * `emoji_query`: (可选) 如果需要发送表情,指定表情的主题。 - -### 4.3. 执行 (`_handle_action`) - -* **输入**: 接收"规划"阶段输出的 `action`、`reasoning` 和 `emoji_query`。 -* **行动**: 根据 `action` 的类型,分派到不同的处理函数: - * **文本回复 (`_handle_text_reply`)**: - 1. 获取锚点消息(当前实现为系统触发的占位符)。 - 2. 调用 `HeartFCSender` 的 `register_thinking` 标记开始思考。 - 3. 调用 `HeartFCGenerator` (`_replier_work`) 生成回复文本。**注意**: 回复器逻辑 (`_replier_work`) 本身并非独立复杂组件,主要是调用 `HeartFCGenerator` 完成文本生成。 - 4. 调用 `HeartFCSender` (`_sender`) 发送生成的文本和可能的表情。**注意**: 发送逻辑 (`_sender`, `_send_response_messages`, `_handle_emoji`) 同样委托给 `HeartFCSender` 实例处理,包含模拟打字、实际发送、存储消息等细节。 - * **仅表情回复 (`_handle_emoji_reply`)**: - 1. 获取锚点消息。 - 2. 调用 `HeartFCSender` 发送表情。 - * **不回复 (`_handle_no_reply`)**: - 1. 记录理由。 - 2. 进入等待状态 (`_wait_for_new_message`),直到检测到新消息或超时(目前300秒),期间会监听关闭信号。 - -## 总结 - -`HeartFChatting` 通过 **观察 -> 思考(含工具)-> 规划 -> 执行** 的闭环,并利用 `CycleInfo` 进行上下文传递,实现了更加智能和连贯的专注聊天行为。其核心在于利用 `SubMind` 进行深度思考和信息收集,再通过 LLM 规划器进行决策,最后由 `HeartFCSender` 可靠地执行消息发送任务。 diff --git a/src/experimental/Legacy_HFC/heartFC_readme.md b/src/experimental/Legacy_HFC/heartFC_readme.md deleted file mode 100644 index 07bc4c63..00000000 --- a/src/experimental/Legacy_HFC/heartFC_readme.md +++ /dev/null @@ -1,159 +0,0 @@ -# HeartFC_chat 工作原理文档 - -HeartFC_chat 是一个基于心流理论的聊天系统,通过模拟人类的思维过程和情感变化来实现自然的对话交互。系统采用Plan-Replier-Sender循环机制,实现了智能化的对话决策和生成。 - -## 核心工作流程 - -### 1. 消息处理与存储 (HeartFCProcessor) -[代码位置: src/plugins/heartFC_chat/heartflow_processor.py] - -消息处理器负责接收和预处理消息,主要完成以下工作: -```mermaid -graph TD - A[接收原始消息] --> B[解析为MessageRecv对象] - B --> C[消息缓冲处理] - C --> D[过滤检查] - D --> E[存储到数据库] -``` - -核心实现: -- 消息处理入口:`process_message()` [行号: 38-215] - - 消息解析和缓冲:`message_buffer.start_caching_messages()` [行号: 63] - - 过滤检查:`_check_ban_words()`, `_check_ban_regex()` [行号: 196-215] - - 消息存储:`storage.store_message()` [行号: 108] - -### 2. 对话管理循环 (HeartFChatting) -[代码位置: src/plugins/heartFC_chat/heartFC_chat.py] - -HeartFChatting是系统的核心组件,实现了完整的对话管理循环: - -```mermaid -graph TD - A[Plan阶段] -->|决策是否回复| B[Replier阶段] - B -->|生成回复内容| C[Sender阶段] - C -->|发送消息| D[等待新消息] - D --> A -``` - -#### Plan阶段 [行号: 282-386] -- 主要函数:`_planner()` -- 功能实现: - * 获取观察信息:`observation.observe()` [行号: 297] - * 思维处理:`sub_mind.do_thinking_before_reply()` [行号: 301] - * LLM决策:使用`PLANNER_TOOL_DEFINITION`进行动作规划 [行号: 13-42] - -#### Replier阶段 [行号: 388-416] -- 主要函数:`_replier_work()` -- 调用生成器:`gpt_instance.generate_response()` [行号: 394] -- 处理生成结果和错误情况 - -#### Sender阶段 [行号: 418-450] -- 主要函数:`_sender()` -- 发送实现: - * 创建消息:`_create_thinking_message()` [行号: 452-477] - * 发送回复:`_send_response_messages()` [行号: 479-525] - * 处理表情:`_handle_emoji()` [行号: 527-567] - -### 3. 回复生成机制 (HeartFCGenerator) -[代码位置: src/plugins/heartFC_chat/heartFC_generator.py] - -回复生成器负责产生高质量的回复内容: - -```mermaid -graph TD - A[获取上下文信息] --> B[构建提示词] - B --> C[调用LLM生成] - C --> D[后处理优化] - D --> E[返回回复集] -``` - -核心实现: -- 生成入口:`generate_response()` [行号: 39-67] - * 情感调节:`arousal_multiplier = MoodManager.get_instance().get_arousal_multiplier()` [行号: 47] - * 模型生成:`_generate_response_with_model()` [行号: 69-95] - * 响应处理:`_process_response()` [行号: 97-106] - -### 4. 提示词构建系统 (HeartFlowPromptBuilder) -[代码位置: src/plugins/heartFC_chat/heartflow_prompt_builder.py] - -提示词构建器支持两种工作模式,HeartFC_chat专门使用Focus模式,而Normal模式是为normal_chat设计的: - -#### 专注模式 (Focus Mode) - HeartFC_chat专用 -- 实现函数:`_build_prompt_focus()` [行号: 116-141] -- 特点: - * 专注于当前对话状态和思维 - * 更强的目标导向性 - * 用于HeartFC_chat的Plan-Replier-Sender循环 - * 简化的上下文处理,专注于决策 - -#### 普通模式 (Normal Mode) - Normal_chat专用 -- 实现函数:`_build_prompt_normal()` [行号: 143-215] -- 特点: - * 用于normal_chat的常规对话 - * 完整的个性化处理 - * 关系系统集成 - * 知识库检索:`get_prompt_info()` [行号: 217-591] - -HeartFC_chat的Focus模式工作流程: -```mermaid -graph TD - A[获取结构化信息] --> B[获取当前思维状态] - B --> C[构建专注模式提示词] - C --> D[用于Plan阶段决策] - D --> E[用于Replier阶段生成] -``` - -## 智能特性 - -### 1. 对话决策机制 -- LLM决策工具定义:`PLANNER_TOOL_DEFINITION` [heartFC_chat.py 行号: 13-42] -- 决策执行:`_planner()` [heartFC_chat.py 行号: 282-386] -- 考虑因素: - * 上下文相关性 - * 情感状态 - * 兴趣程度 - * 对话时机 - -### 2. 状态管理 -[代码位置: src/plugins/heartFC_chat/heartFC_chat.py] -- 状态机实现:`HeartFChatting`类 [行号: 44-567] -- 核心功能: - * 初始化:`_initialize()` [行号: 89-112] - * 循环控制:`_run_pf_loop()` [行号: 192-281] - * 状态转换:`_handle_loop_completion()` [行号: 166-190] - -### 3. 回复生成策略 -[代码位置: src/plugins/heartFC_chat/heartFC_generator.py] -- 温度调节:`current_model.temperature = global_config.llm_normal["temp"] * arousal_multiplier` [行号: 48] -- 生成控制:`_generate_response_with_model()` [行号: 69-95] -- 响应处理:`_process_response()` [行号: 97-106] - -## 系统配置 - -### 关键参数 -- LLM配置:`model_normal` [heartFC_generator.py 行号: 32-37] -- 过滤规则:`_check_ban_words()`, `_check_ban_regex()` [heartflow_processor.py 行号: 196-215] -- 状态控制:`INITIAL_DURATION = 60.0` [heartFC_chat.py 行号: 11] - -### 优化建议 -1. 调整LLM参数:`temperature`和`max_tokens` -2. 优化提示词模板:`init_prompt()` [heartflow_prompt_builder.py 行号: 8-115] -3. 配置状态转换条件 -4. 维护过滤规则 - -## 注意事项 - -1. 系统稳定性 -- 异常处理:各主要函数都包含try-except块 -- 状态检查:`_processing_lock`确保并发安全 -- 循环控制:`_loop_active`和`_loop_task`管理 - -2. 性能优化 -- 缓存使用:`message_buffer`系统 -- LLM调用优化:批量处理和复用 -- 异步处理:使用`asyncio` - -3. 质量控制 -- 日志记录:使用`get_module_logger()` -- 错误追踪:详细的异常记录 -- 响应监控:完整的状态跟踪 diff --git a/src/experimental/Legacy_HFC/heart_flow/0.6Bing.md b/src/experimental/Legacy_HFC/heart_flow/0.6Bing.md deleted file mode 100644 index de5628e7..00000000 --- a/src/experimental/Legacy_HFC/heart_flow/0.6Bing.md +++ /dev/null @@ -1,94 +0,0 @@ -- **智能化 MaiState 状态转换**: - - 当前 `MaiState` (整体状态,如 `OFFLINE`, `NORMAL_CHAT` 等) 的转换逻辑 (`MaiStateManager`) 较为简单,主要依赖时间和随机性。 - - 未来的计划是让主心流 (`Heartflow`) 负责决策自身的 `MaiState`。 - - 该决策将综合考虑以下信息: - - 各个子心流 (`SubHeartflow`) 的活动状态和信息摘要。 - - 主心流自身的状态和历史信息。 - - (可能) 结合预设的日程安排 (Schedule) 信息。 - - 目标是让 Mai 的整体状态变化更符合逻辑和上下文。 (计划在 064 实现) - -- **参数化与动态调整聊天行为**: - - 将 `NormalChatInstance` 和 `HeartFlowChatInstance` 中的关键行为参数(例如:回复概率、思考频率、兴趣度阈值、状态转换条件等)提取出来,使其更易于配置。 - - 允许每个 `SubHeartflow` (即每个聊天场景) 拥有其独立的参数配置,实现"千群千面"。 - - 开发机制,使得这些参数能够被动态调整: - - 基于外部反馈:例如,根据用户评价("话太多"或"太冷淡")调整回复频率。 - - 基于环境分析:例如,根据群消息的活跃度自动调整参与度。 - - 基于学习:通过分析历史交互数据,优化特定群聊下的行为模式。 - - 目标是让 Mai 在不同群聊中展现出更适应环境、更个性化的交互风格。 - -- **动态 Prompt 生成与人格塑造**: - - 当前 Prompt (提示词) 相对静态。计划实现动态或半结构化的 Prompt 生成。 - - Prompt 内容可根据以下因素调整: - - **人格特质**: 通过参数化配置(如友善度、严谨性等),影响 Prompt 的措辞、语气和思考倾向,塑造更稳定和独特的人格。 - - **当前情绪**: 将实时情绪状态融入 Prompt,使回复更符合当下心境。 - - 目标:提升 `HeartFlowChatInstance` (HFC) 回复的多样性、一致性和真实感。 - - 前置:需要重构 Prompt 构建逻辑,可能引入 `PromptBuilder` 并提供标准接口 (认为是必须步骤)。 - -- **扩展观察系统 (Observation System)**: - - 目前主要依赖 `ChattingObservation` 获取消息。 - - 计划引入更多 `Observation` 类型,为 `SubHeartflow` 提供更丰富的上下文: - - Mai 的全局状态 (`MaiStateInfo`)。 - - `SubHeartflow` 自身的聊天状态 (`ChatStateInfo`) 和参数配置。 - - Mai 的系统配置、连接平台信息。 - - 其他相关聊天或系统的聚合信息。 - - 目标:让 `SubHeartflow` 基于更全面的信息进行决策。 - -- **增强工具调用能力 (Enhanced Tool Usage)**: - - 扩展 `HeartFlowChatInstance` (HFC) 可用的工具集。 - - 考虑引入"元工具"或分层工具机制,允许 HFC 在需要时(如深度思考)访问更强大的工具,例如: - - 修改自身或其他 `SubHeartflow` 的聊天参数。 - - 请求改变 Mai 的全局状态 (`MaiState`)。 - - 管理日程或执行更复杂的分析任务。 - - 目标:提升 HFC 的自主决策和行动能力,即使会增加一定的延迟。 - -- **基于历史学习的行为模式应用**: - - **学习**: 分析过往聊天记录,提取和学习具体的行为模式(如特定梗的用法、情境化回应风格等)。可能需要专门的分析模块。 - - **存储与匹配**: 需要有效的方法存储学习到的行为模式,并开发强大的 **匹配** 机制,在运行时根据当前情境检索最合适的模式。**(匹配的准确性是关键)** - - **应用与评估**: 将匹配到的行为模式融入 HFC 的决策和回复生成(例如,将其整合进 Prompt)。之后需评估该行为模式应用的实际效果。 - - **人格塑造**: 通过学习到的实际行为来动态塑造人格,作为静态人设描述的补充或替代,使其更生动自然。 - -- **标准化人设生成 (Standardized Persona Generation)**: - - **目标**: 解决手动配置 `人设` 文件缺乏标准、难以全面描述个性的问题,并生成更丰富、可操作的人格资源。 - - **方法**: 利用大型语言模型 (LLM) 辅助生成标准化的、结构化的人格**资源包**。 - - **生成内容**: 不仅生成描述性文本(替代现有 `individual` 配置),还可以同时生成与该人格配套的: - - **相关工具 (Tools)**: 该人格倾向于使用的工具或能力。 - - **初始记忆/知识库 (Memories/Knowledge)**: 定义其背景和知识基础。 - - **核心行为模式 (Core Behavior Patterns)**: 预置一些典型的行为方式,可作为行为学习的起点。 - - **实现途径**: - - 通过与 LLM 的交互式对话来定义和细化人格及其配套资源。 - - 让 LLM 分析提供的文本材料(如小说、背景故事)来提取人格特质和相关信息。 - - **优势**: 替代易出错且标准不一的手动配置,生成更丰富、一致、包含配套资源且易于系统理解和应用的人格包。 - -- **优化表情包处理与理解 (Enhanced Emoji Handling and Understanding)**: - - **面临挑战**: - - **历史记录表示**: 如何在聊天历史中有效表示表情包,供 LLM 理解。 - - **语义理解**: 如何让 LLM 准确把握表情包的含义、情感和语境。 - - **场景判断与选择**: 如何让 LLM 判断何时适合使用表情包,并选择最贴切的一个。 - - **目标**: 提升 Mai 理解和运用表情包的能力,使交互更自然生动。 - - **说明**: 可能需要较多时间进行数据处理和模型调优,但对改善体验潜力巨大。 - -- **探索高级记忆检索机制 (GE 系统概念):** - - 研究超越简单关键词/近期性检索的记忆模型。 - - 考虑引入基于事件关联、相对时间线索和绝对时间锚点的检索方式。 - - 可能涉及设计新的事件表示或记忆结构。 - - -- **实现 SubHeartflow 级记忆缓存池:** - - 在 `SubHeartflow` 层级或更高层级设计并实现一个缓存池,存储已检索的记忆/信息。 - - 避免在 HFC 等循环中重复进行相同的记忆检索调用。 - - 确保存储的信息能有效服务于当前交互上下文。 - -- **基于人格生成预设知识:** - - 开发利用 LLM 和人格配置生成背景知识的功能。 - - 这些知识应符合角色的行为风格和可能的经历。 - - 作为一种"冷启动"或丰富角色深度的方式。 - - -## 开发计划TODO:LIST - -- 人格功能:WIP -- 对特定对象的侧写功能 -- 图片发送,转发功能:WIP -- 幽默和meme功能:WIP -- 小程序转发链接解析 -- 自动生成的回复逻辑,例如自生成的回复方向,回复风格 \ No newline at end of file diff --git a/src/experimental/Legacy_HFC/heart_flow/README.md b/src/experimental/Legacy_HFC/heart_flow/README.md deleted file mode 100644 index a55f1c97..00000000 --- a/src/experimental/Legacy_HFC/heart_flow/README.md +++ /dev/null @@ -1,241 +0,0 @@ -# 心流系统 (Heart Flow System) - -## 一条消息是怎么到最终回复的?简明易懂的介绍 - -1 接受消息,由HeartHC_processor处理消息,存储消息 - - 1.1 process_message()函数,接受消息 - - 1.2 创建消息对应的聊天流(chat_stream)和子心流(sub_heartflow) - - 1.3 进行常规消息处理 - - 1.4 存储消息 store_message() - - 1.5 计算兴趣度Interest - - 1.6 将消息连同兴趣度,存储到内存中的interest_dict(SubHeartflow的属性) - -2 根据 sub_heartflow 的聊天状态,决定后续处理流程 - - 2a ABSENT状态:不做任何处理 - - 2b CHAT状态:送入NormalChat 实例 - - 2c FOCUS状态:送入HeartFChatting 实例 - -b NormalChat工作方式 - - b.1 启动后台任务 _reply_interested_message,持续运行。 - b.2 该任务轮询 InterestChatting 提供的 interest_dict - b.3 对每条消息,结合兴趣度、是否被提及(@)、意愿管理器(WillingManager)计算回复概率。(这部分要改,目前还是用willing计算的,之后要和Interest合并) - b.4 若概率通过: - b.4.1 创建"思考中"消息 (MessageThinking)。 - b.4.2 调用 NormalChatGenerator 生成文本回复。 - b.4.3 通过 message_manager 发送回复 (MessageSending)。 - b.4.4 可能根据配置和文本内容,额外发送一个匹配的表情包。 - b.4.5 更新关系值和全局情绪。 - b.5 处理完成后,从 interest_dict 中移除该消息。 - -c HeartFChatting工作方式 - - c.1 启动主循环 _hfc_loop - c.2 每个循环称为一个周期 (Cycle),执行 think_plan_execute 流程。 - c.3 Think (思考) 阶段: - c.3.1 观察 (Observe): 通过 ChattingObservation,使用 observe() 获取最新的聊天消息。 - c.3.2 思考 (Think): 调用 SubMind 的 do_thinking_before_reply 方法。 - c.3.2.1 SubMind 结合观察到的内容、个性、情绪、上周期动作等信息,生成当前的内心想法 (current_mind)。 - c.3.2.2 在此过程中 SubMind 的LLM可能请求调用工具 (ToolUser) 来获取额外信息或执行操作,结果存储在 structured_info 中。 - c.4 Plan (规划/决策) 阶段: - c.4.1 结合观察到的消息文本、`SubMind` 生成的 `current_mind` 和 `structured_info`、以及 `ActionManager` 提供的可用动作,决定本次周期的行动 (`text_reply`/`emoji_reply`/`no_reply`) 和理由。 - c.4.2 重新规划检查 (Re-plan Check): 如果在 c.3.1 到 c.4.1 期间检测到新消息,可能(有概率)触发重新执行 c.4.1 决策步骤。 - c.5 Execute (执行/回复) 阶段: - c.5.1 如果决策是 text_reply: - c.5.1.1 获取锚点消息。 - c.5.1.2 通过 HeartFCSender 注册"思考中"状态。 - c.5.1.3 调用 HeartFCGenerator (gpt_instance) 生成回复文本。 - c.5.1.4 通过 HeartFCSender 发送回复 - c.5.1.5 如果规划时指定了表情查询 (emoji_query),随后发送表情。 - c.5.2 如果决策是 emoji_reply: - c.5.2.1 获取锚点消息。 - c.5.2.2 通过 HeartFCSender 直接发送匹配查询 (emoji_query) 的表情。 - c.5.3 如果决策是 no_reply: - c.5.3.1 进入等待状态,直到检测到新消息或超时。 - c.5.3.2 同时,增加内部连续不回复计数器。如果该计数器达到预设阈值(例如 5 次),则调用初始化时由 `SubHeartflowManager` 提供的回调函数。此回调函数会通知 `SubHeartflowManager` 请求将对应的 `SubHeartflow` 状态转换为 `ABSENT`。如果执行了其他动作(如 `text_reply` 或 `emoji_reply`),则此计数器会被重置。 - c.6 循环结束后,记录周期信息 (CycleInfo),并根据情况进行短暂休眠,防止CPU空转。 - - - -## 1. 一条消息是怎么到最终回复的?复杂细致的介绍 - -### 1.1. 主心流 (Heartflow) -- **文件**: `heartflow.py` -- **职责**: - - 作为整个系统的主控制器。 - - 持有并管理 `SubHeartflowManager`,用于管理所有子心流。 - - 持有并管理自身状态 `self.current_state: MaiStateInfo`,该状态控制系统的整体行为模式。 - - 统筹管理系统后台任务(如消息存储、资源分配等)。 - - **注意**: 主心流自身不进行周期性的全局思考更新。 - -### 1.2. 子心流 (SubHeartflow) -- **文件**: `sub_heartflow.py` -- **职责**: - - 处理具体的交互场景,例如:群聊、私聊、与虚拟主播(vtb)互动、桌面宠物交互等。 - - 维护特定场景下的思维状态和聊天流状态 (`ChatState`)。 - - 通过关联的 `Observation` 实例接收和处理信息。 - - 拥有独立的思考 (`SubMind`) 和回复判断能力。 -- **观察者**: 每个子心流可以拥有一个或多个 `Observation` 实例(目前每个子心流仅使用一个 `ChattingObservation`)。 -- **内部结构**: - - **聊天流状态 (`ChatState`)**: 标记当前子心流的参与模式 (`ABSENT`, `CHAT`, `FOCUSED`),决定是否观察、回复以及使用何种回复模式。 - - **聊天实例 (`NormalChatInstance` / `HeartFlowChatInstance`)**: 根据 `ChatState` 激活对应的实例来处理聊天逻辑。同一时间只有一个实例处于活动状态。 - -### 1.3. 观察系统 (Observation) -- **文件**: `observation.py` -- **职责**: - - 定义信息输入的来源和格式。 - - 为子心流提供其所处环境的信息。 -- **当前实现**: - - 目前仅有 `ChattingObservation` 一种观察类型。 - - `ChattingObservation` 负责从数据库拉取指定聊天的最新消息,并将其格式化为可读内容,供 `SubHeartflow` 使用。 - -### 1.4. 子心流管理器 (SubHeartflowManager) -- **文件**: `subheartflow_manager.py` -- **职责**: - - 作为 `Heartflow` 的成员变量存在。 - - **在初始化时接收并持有 `Heartflow` 的 `MaiStateInfo` 实例。** - - 负责所有 `SubHeartflow` 实例的生命周期管理,包括: - - 创建和获取 (`get_or_create_subheartflow`)。 - - 停止和清理 (`sleep_subheartflow`, `cleanup_inactive_subheartflows`)。 - - 根据 `Heartflow` 的状态 (`self.mai_state_info`) 和限制条件,激活、停用或调整子心流的状态(例如 `enforce_subheartflow_limits`, `randomly_deactivate_subflows`, `sbhf_absent_into_focus`)。 - - **新增**: 通过调用 `sbhf_absent_into_chat` 方法,使用 LLM (配置与 `Heartflow` 主 LLM 相同) 评估处于 `ABSENT` 或 `CHAT` 状态的子心流,根据观察到的活动摘要和 `Heartflow` 的当前状态,判断是否应在 `ABSENT` 和 `CHAT` 之间进行转换 (同样受限于 `CHAT` 状态的数量上限)。 - - **清理机制**: 通过后台任务 (`BackgroundTaskManager`) 定期调用 `cleanup_inactive_subheartflows` 方法,此方法会识别并**删除**那些处于 `ABSENT` 状态超过一小时 (`INACTIVE_THRESHOLD_SECONDS`) 的子心流实例。 - -### 1.5. 消息处理与回复流程 (Message Processing vs. Replying Flow) -- **关注点分离**: 系统严格区分了接收和处理传入消息的流程与决定和生成回复的流程。 - - **消息处理 (Processing)**: - - 由一个独立的处理器(例如 `HeartFCProcessor`)负责接收原始消息数据。 - - 职责包括:消息解析 (`MessageRecv`)、过滤(屏蔽词、正则表达式)、基于记忆系统的初步兴趣计算 (`HippocampusManager`)、消息存储 (`MessageStorage`) 以及用户关系更新 (`RelationshipManager`)。 - - 处理后的消息信息(如计算出的兴趣度)会传递给对应的 `SubHeartflow`。 - - **回复决策与生成 (Replying)**: - - 由 `SubHeartflow` 及其当前激活的聊天实例 (`NormalChatInstance` 或 `HeartFlowChatInstance`) 负责。 - - 基于其内部状态 (`ChatState`、`SubMind` 的思考结果)、观察到的信息 (`Observation` 提供的内容) 以及 `InterestChatting` 的状态来决定是否回复、何时回复以及如何回复。 -- **消息缓冲 (Message Caching)**: - - `message_buffer` 模块会对某些传入消息进行临时缓存,尤其是在处理连续的多部分消息(如多张图片)时。 - - 这个缓冲机制发生在 `HeartFCProcessor` 处理流程中,确保消息的完整性,然后才进行后续的存储和兴趣计算。 - - 缓存的消息最终仍会流向对应的 `ChatStream`(与 `SubHeartflow` 关联),但核心的消息处理与回复决策仍然是分离的步骤。 - -## 2. 核心控制与状态管理 (Core Control and State Management) - -### 2.1. Heart Flow 整体控制 -- **控制者**: 主心流 (`Heartflow`) -- **核心职责**: - - 通过其成员 `SubHeartflowManager` 创建和管理子心流(**在创建 `SubHeartflowManager` 时会传入自身的 `MaiStateInfo`**)。 - - 通过其成员 `self.current_state: MaiStateInfo` 控制整体行为模式。 - - 管理系统级后台任务。 - - **注意**: 不再提供直接获取所有子心流 ID (`get_all_subheartflows_streams_ids`) 的公共方法。 - -### 2.2. Heart Flow 状态 (`MaiStateInfo`) -- **定义与管理**: `Heartflow` 持有 `MaiStateInfo` 的实例 (`self.current_state`) 来管理其状态。状态的枚举定义在 `my_state_manager.py` 中的 `MaiState`。 -- **状态及含义**: - - `MaiState.OFFLINE` (不在线): 不观察任何群消息,不进行主动交互,仅存储消息。当主状态变为 `OFFLINE` 时,`SubHeartflowManager` 会将所有子心流的状态设置为 `ChatState.ABSENT`。 - - `MaiState.PEEKING` (看一眼手机): 有限度地参与聊天(由 `MaiStateInfo` 定义具体的普通/专注群数量限制)。 - - `MaiState.NORMAL_CHAT` (正常看手机): 正常参与聊天,允许 `SubHeartflow` 进入 `CHAT` 或 `FOCUSED` 状态(数量受限)。 - * `MaiState.FOCUSED_CHAT` (专心看手机): 更积极地参与聊天,通常允许更多或更高优先级的 `FOCUSED` 状态子心流。 -- **当前转换逻辑**: 目前,`MaiState` 之间的转换由 `MaiStateManager` 管理,主要基于状态持续时间和随机概率。这是一种临时的实现方式,未来计划进行改进。 -- **作用**: `Heartflow` 的状态直接影响 `SubHeartflowManager` 如何管理子心流(如激活数量、允许的状态等)。 - -### 2.3. 聊天流状态 (`ChatState`) 与转换 -- **管理对象**: 每个 `SubHeartflow` 实例内部维护其 `ChatStateInfo`,包含当前的 `ChatState`。 -- **状态及含义**: - - `ChatState.ABSENT` (不参与/没在看): 初始或停用状态。子心流不观察新信息,不进行思考,也不回复。 - - `ChatState.CHAT` (随便看看/水群): 普通聊天模式。激活 `NormalChatInstance`。 - * `ChatState.FOCUSED` (专注/认真水群): 专注聊天模式。激活 `HeartFlowChatInstance`。 -- **选择**: 子心流可以根据外部指令(来自 `SubHeartflowManager`)或内部逻辑(未来的扩展)选择进入 `ABSENT` 状态(不回复不观察),或进入 `CHAT` / `FOCUSED` 中的一种回复模式。 -- **状态转换机制** (由 `SubHeartflowManager` 驱动,更细致的说明): - - **初始状态**: 新创建的 `SubHeartflow` 默认为 `ABSENT` 状态。 - - **`ABSENT` -> `CHAT` (激活闲聊)**: - - **触发条件**: `Heartflow` 的主状态 (`MaiState`) 允许 `CHAT` 模式,且当前 `CHAT` 状态的子心流数量未达上限。 - - **判定机制**: `SubHeartflowManager` 中的 `sbhf_absent_into_chat` 方法调用大模型(LLM)。LLM 读取该群聊的近期内容和结合自身个性信息,判断是否"想"在该群开始聊天。 - - **执行**: 若 LLM 判断为是,且名额未满,`SubHeartflowManager` 调用 `change_chat_state(ChatState.CHAT)`。 - - **`CHAT` -> `FOCUSED` (激活专注)**: - - **触发条件**: 子心流处于 `CHAT` 状态,其内部维护的"开屎热聊"概率 (`InterestChatting.start_hfc_probability`) 达到预设阈值(表示对当前聊天兴趣浓厚),同时 `Heartflow` 的主状态允许 `FOCUSED` 模式,且 `FOCUSED` 名额未满。 - - **判定机制**: `SubHeartflowManager` 中的 `sbhf_absent_into_focus` 方法定期检查满足条件的 `CHAT` 子心流。 - - **执行**: 若满足所有条件,`SubHeartflowManager` 调用 `change_chat_state(ChatState.FOCUSED)`。 - - **注意**: 无法从 `ABSENT` 直接跳到 `FOCUSED`,必须先经过 `CHAT`。 - - **`FOCUSED` -> `ABSENT` (退出专注)**: - - **主要途径 (内部驱动)**: 在 `FOCUSED` 状态下运行的 `HeartFlowChatInstance` 连续多次决策为 `no_reply` (例如达到 5 次,次数可配),它会通过回调函数 (`sbhf_focus_into_absent`) 请求 `SubHeartflowManager` 将其状态**直接**设置为 `ABSENT`。 - - **其他途径 (外部驱动)**: - - `Heartflow` 主状态变为 `OFFLINE`,`SubHeartflowManager` 强制所有子心流变为 `ABSENT`。 - - `SubHeartflowManager` 因 `FOCUSED` 名额超限 (`enforce_subheartflow_limits`) 或随机停用 (`randomly_deactivate_subflows`) 而将其设置为 `ABSENT`。 - - **`CHAT` -> `ABSENT` (退出闲聊)**: - - **主要途径 (内部驱动)**: `SubHeartflowManager` 中的 `sbhf_absent_into_chat` 方法调用 LLM。LLM 读取群聊内容和结合自身状态,判断是否"不想"继续在此群闲聊。 - - **执行**: 若 LLM 判断为是,`SubHeartflowManager` 调用 `change_chat_state(ChatState.ABSENT)`。 - - **其他途径 (外部驱动)**: - - `Heartflow` 主状态变为 `OFFLINE`。 - - `SubHeartflowManager` 因 `CHAT` 名额超限或随机停用。 - - **全局强制 `ABSENT`**: 当 `Heartflow` 的 `MaiState` 变为 `OFFLINE` 时,`SubHeartflowManager` 会调用所有子心流的 `change_chat_state(ChatState.ABSENT)`,强制它们全部停止活动。 - - **状态变更执行者**: `change_chat_state` 方法仅负责执行状态的切换和对应聊天实例的启停,不进行名额检查。名额检查的责任由 `SubHeartflowManager` 中的各个决策方法承担。 - - **最终清理**: 进入 `ABSENT` 状态的子心流不会立即被删除,只有在 `ABSENT` 状态持续一小时 (`INACTIVE_THRESHOLD_SECONDS`) 后,才会被后台清理任务 (`cleanup_inactive_subheartflows`) 删除。 - -## 3. 聊天实例详解 (Chat Instances Explained) - -### 3.1. NormalChatInstance -- **激活条件**: 对应 `SubHeartflow` 的 `ChatState` 为 `CHAT`。 -- **工作流程**: - - 当 `SubHeartflow` 进入 `CHAT` 状态时,`NormalChatInstance` 会被激活。 - - 实例启动后,会创建一个后台任务 (`_reply_interested_message`)。 - - 该任务持续监控由 `InterestChatting` 传入的、具有一定兴趣度的消息列表 (`interest_dict`)。 - - 对列表中的每条消息,结合是否被提及 (`@`)、消息本身的兴趣度以及当前的回复意愿 (`WillingManager`),计算出一个回复概率。 - - 根据计算出的概率随机决定是否对该消息进行回复。 - - 如果决定回复,则调用 `NormalChatGenerator` 生成回复内容,并可能附带表情包。 -- **行为特点**: - - 回复相对常规、简单。 - - 不投入过多计算资源。 - - 侧重于维持基本的交流氛围。 - - 示例:对问候语、日常分享等进行简单回应。 - -### 3.2. HeartFlowChatInstance (继承自原 PFC 逻辑) -- **激活条件**: 对应 `SubHeartflow` 的 `ChatState` 为 `FOCUSED`。 -- **工作流程**: - - 基于更复杂的规则(原 PFC 模式)进行深度处理。 - - 对群内话题进行深入分析。 - - 可能主动发起相关话题或引导交流。 -- **行为特点**: - - 回复更积极、深入。 - - 投入更多资源参与聊天。 - - 回复内容可能更详细、有针对性。 - - 对话题参与度高,能带动交流。 - - 示例:对复杂或有争议话题阐述观点,并与人互动。 - -## 4. 工作流程示例 (Example Workflow) - -1. **启动**: `Heartflow` 启动,初始化 `MaiStateInfo` (例如 `OFFLINE`) 和 `SubHeartflowManager`。 -2. **状态变化**: 用户操作或内部逻辑使 `Heartflow` 的 `current_state` 变为 `NORMAL_CHAT`。 -3. **管理器响应**: `SubHeartflowManager` 检测到状态变化,根据 `NORMAL_CHAT` 的限制,调用 `get_or_create_subheartflow` 获取或创建子心流,并通过 `change_chat_state` 将部分子心流状态从 `ABSENT` 激活为 `CHAT`。 -4. **子心流激活**: 被激活的 `SubHeartflow` 启动其 `NormalChatInstance`。 -5. **信息接收**: 该 `SubHeartflow` 的 `ChattingObservation` 开始从数据库拉取新消息。 -6. **普通回复**: `NormalChatInstance` 处理观察到的信息,执行普通回复逻辑。 -7. **兴趣评估**: `SubHeartflowManager` 定期评估该子心流的 `InterestChatting` 状态。 -8. **提升状态**: 若兴趣度达标且 `Heartflow` 状态允许,`SubHeartflowManager` 调用该子心流的 `change_chat_state` 将其状态提升为 `FOCUSED`。 -9. **子心流切换**: `SubHeartflow` 内部停止 `NormalChatInstance`,启动 `HeartFlowChatInstance`。 -10. **专注回复**: `HeartFlowChatInstance` 开始根据其逻辑进行更深入的交互。 -11. **状态回落/停用**: 若 `Heartflow` 状态变为 `OFFLINE`,`SubHeartflowManager` 会调用所有活跃子心流的 `change_chat_state(ChatState.ABSENT)`,使其进入 `ABSENT` 状态(它们不会立即被删除,只有在 `ABSENT` 状态持续1小时后才会被清理)。 - -## 5. 使用与配置 (Usage and Configuration) - -### 5.1. 使用说明 (Code Examples) -- **(内部)创建/获取子心流** (由 `SubHeartflowManager` 调用, 示例): - ```python - # subheartflow_manager.py (get_or_create_subheartflow 内部) - # 注意:mai_states 现在是 self.mai_state_info - new_subflow = SubHeartflow(subheartflow_id, self.mai_state_info) - await new_subflow.initialize() - observation = ChattingObservation(chat_id=subheartflow_id) - new_subflow.add_observation(observation) - ``` -- **(内部)添加观察者** (由 `SubHeartflowManager` 或 `SubHeartflow` 内部调用): - ```python - # sub_heartflow.py - self.observations.append(observation) - ``` - diff --git a/template/bot_config_template.toml b/template/bot_config_template.toml index 5973678e..befaa97e 100644 --- a/template/bot_config_template.toml +++ b/template/bot_config_template.toml @@ -1,5 +1,5 @@ [inner] -version = "1.7.0.3" +version = "1.7.1" #----以下是给开发人员阅读的,如果你只是部署了麦麦,不需要阅读---- #如果你想要修改配置文件,请在修改后将version的值进行变更 @@ -206,7 +206,6 @@ enable_Legacy_HFC = false # 是否启用旧 HFC 处理器 enable_friend_chat = true # 是否启用好友聊天 enable_friend_whitelist = true # 是否启用好友聊天白名单 talk_allowed_private = [] # 可以回复消息的QQ号 -api_polling_max_retries = 3 # 神秘小功能 rename_person = true # 是否启用改名工具,可以让麦麦对唯一名进行更改,可能可以更拟人地称呼他人,但是也可能导致记忆混淆的问题 [pfc] diff --git a/template/template.env b/template/template.env index becdcd7d..6165a0df 100644 --- a/template/template.env +++ b/template/template.env @@ -15,28 +15,16 @@ DATABASE_NAME=MegBot # MONGODB_PASSWORD=password # MONGODB_AUTH_SOURCE=admin -# 配置代理信息 -PROXY_HOST=127.0.0.1 -PROXY_PORT=7890 -# 如果 PROXY_MODELS 包含特殊字符或空格,最好用引号括起来,使用英文逗号分割 -PROXY_MODELS="gemini-2.0-flash" - #key and url -CHAT_ANY_WHERE_BASE_URL=https://api.chatanywhere.tech/v1/ +CHAT_ANY_WHERE_BASE_URL=https://api.chatanywhere.tech/v1 SILICONFLOW_BASE_URL=https://api.siliconflow.cn/v1/ -DEEP_SEEK_BASE_URL=https://api.deepseek.com/v1/ -GEMINI_BASE_URL=https://generativelanguage.googleapis.com/v1beta/models +DEEP_SEEK_BASE_URL=https://api.deepseek.com/v1 # 定义你要用的api的key(需要去对应网站申请哦) DEEP_SEEK_KEY= CHAT_ANY_WHERE_KEY= SILICONFLOW_KEY= -GEMINI_KEY='[ - "KEY1", - "KEY2", - "KEY3" -]' -abandon_GEMINI_KEY='[]' + # 定义日志相关配置 # 精简控制台输出格式