from typing import Any, List, Dict, Tuple from app.core.config import settings from app.core.event import eventmanager, Event from app.log import logger from app.plugins import _PluginBase from app.plugins.chatgpt.openai import OpenAi from app.schemas.types import EventType, ChainEventType from app.schemas import NotificationType class ChatGPT(_PluginBase): # 插件名称 plugin_name = "ChatGPT" # 插件描述 plugin_desc = "消息交互支持与ChatGPT对话。" # 插件图标 plugin_icon = "Chatgpt_A.png" # 插件版本 plugin_version = "2.1.6" # 插件作者 plugin_author = "jxxghp" # 作者主页 author_url = "https://github.com/jxxghp" # 插件配置项ID前缀 plugin_config_prefix = "chatgpt_" # 加载顺序 plugin_order = 15 # 可使用的用户级别 auth_level = 1 # 私有属性 openai = None _enabled = False _proxy = False _compatible = False _recognize = False _openai_url = None _openai_key = None _model = None # 存储多个API密钥 _api_keys = [] # 当前使用的密钥索引 _current_key_index = 0 # 密钥失效状态 _key_status = {} # 是否发送通知 _notify = False # 自定义提示词 _customize_prompt = '接下来我会给你一个电影或电视剧的文件名,你需要识别文件名中的名称、版本、分段、年份、分瓣率、季集等信息,并按以下JSON格式返回:{"name":string,"version":string,"part":string,"year":string,"resolution":string,"season":number|null,"episode":number|null},特别注意返回结果需要严格附合JSON格式,不需要有任何其它的字符。如果中文电影或电视剧的文件名中存在谐音字或字母替代的情况,请还原最有可能的结果。' def init_plugin(self, config: dict = None): if config: self._enabled = config.get("enabled") self._proxy = config.get("proxy") self._compatible = config.get("compatible") self._recognize = config.get("recognize") self._openai_url = config.get("openai_url") self._openai_key = config.get("openai_key") self._model = config.get("model") self._notify = config.get("notify") self._customize_prompt = config.get("customize_prompt") # 处理多个API密钥 if self._openai_key: self._api_keys = [key.strip() for key in self._openai_key.split(',') if key.strip()] # 初始化密钥状态 self._key_status = {key: True for key in self._api_keys} logger.info(f"ChatGPT插件加载了 {len(self._api_keys)} 个API密钥") if self._openai_url and self._api_keys: # 使用第一个密钥初始化 self._current_key_index = 0 self.init_openai(self._api_keys[self._current_key_index]) def init_openai(self, api_key): """ 初始化OpenAI客户端 """ if self._openai_url and api_key: self.openai = OpenAi(api_key=api_key, api_url=self._openai_url, proxy=settings.PROXY if self._proxy else None, model=self._model, compatible=bool(self._compatible), customize_prompt=self._customize_prompt) logger.info(f"ChatGPT插件初始化API客户端成功") return True return False def switch_to_next_key(self, failed_key): """ 切换到下一个可用的API密钥 :return: (is_switched, error_message) 元组,表示是否切换成功及错误信息 """ # 标记当前密钥为失效 self._key_status[failed_key] = False # 寻找下一个可用的密钥 original_index = self._current_key_index while True: self._current_key_index = (self._current_key_index + 1) % len(self._api_keys) next_key = self._api_keys[self._current_key_index] # 如果密钥标记为可用或者已经尝试了所有密钥,则使用该密钥 if self._key_status.get(next_key, True) or self._current_key_index == original_index: break # 检查是否所有密钥都失效 if all(not status for status in self._key_status.values()): logger.error("所有API密钥均已失效") return False, "所有API密钥均已失效,请检查配置" # 使用新密钥重新初始化客户端 next_key = self._api_keys[self._current_key_index] logger.info(f"切换到下一个API密钥 {next_key}") success = self.init_openai(next_key) return success, "" def get_state(self) -> bool: return self._enabled @staticmethod def get_command() -> List[Dict[str, Any]]: pass def get_api(self) -> List[Dict[str, Any]]: pass def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: """ 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 """ return [ { 'component': 'VForm', 'content': [ { 'component': 'VRow', 'content': [ { 'component': 'VCol', 'props': { 'cols': 12, 'md': 4 }, 'content': [ { 'component': 'VSwitch', 'props': { 'model': 'enabled', 'label': '启用插件', } } ] }, { 'component': 'VCol', 'props': { 'cols': 12, 'md': 4 }, 'content': [ { 'component': 'VSwitch', 'props': { 'model': 'proxy', 'label': '使用代理服务器', } } ] }, { 'component': 'VCol', 'props': { 'cols': 12, 'md': 4 }, 'content': [ { 'component': 'VSwitch', 'props': { 'model': 'compatible', 'label': '兼容模式', } } ] }, { 'component': 'VCol', 'props': { 'cols': 12, 'md': 4 }, 'content': [ { 'component': 'VSwitch', 'props': { 'model': 'recognize', 'label': '辅助识别', } } ] }, { 'component': 'VCol', 'props': { 'cols': 12, 'md': 4 }, 'content': [ { 'component': 'VSwitch', 'props': { 'model': 'notify', 'label': '开启通知', } } ] } ] }, { 'component': 'VRow', 'content': [ { 'component': 'VCol', 'props': { 'cols': 12, 'md': 4 }, 'content': [ { 'component': 'VTextField', 'props': { 'model': 'openai_url', 'label': 'OpenAI API Url', 'placeholder': 'https://api.openai.com', } } ] }, { 'component': 'VCol', 'props': { 'cols': 12, 'md': 4 }, 'content': [ { 'component': 'VTextField', 'props': { 'model': 'openai_key', 'label': 'API密钥 (多个密钥以逗号分隔)', 'placeholder': 'sk-xxx,sk-yyy' } } ] }, { 'component': 'VCol', 'props': { 'cols': 12, 'md': 4 }, 'content': [ { 'component': 'VTextField', 'props': { 'model': 'model', 'label': '自定义模型', 'placeholder': 'gpt-3.5-turbo', } } ] } ] }, { 'component': 'VRow', 'content': [ { 'component': 'VCol', 'props': {'cols': 12}, 'content': [ { 'component': 'VTextarea', 'props': { 'rows': 2, 'auto-grow': True, 'model': 'customize_prompt', 'label': '辅助识别提示词', 'hint': '在辅助识别时的给AI的提示词', 'clearable': True, 'persistent-hint': True, } } ] } ] }, { 'component': 'VRow', 'content': [ { 'component': 'VCol', 'props': { 'cols': 12, }, 'content': [ { 'component': 'VAlert', 'props': { 'type': 'info', 'variant': 'tonal', 'text': '开启插件后,消息交互时使用请[问帮你]开头,或者以?号结尾,或者超过10个汉字/单词,则会触发ChatGPT回复。' '开启辅助识别后,内置识别功能无法正常识别种子/文件名称时,将使用ChatGTP进行AI辅助识别,可以提升动漫等非规范命名的识别成功率。' '支持输入多个API密钥(以逗号分隔),在密钥调用失败时将自动切换到下一个可用密钥。' '开启通知选项后,将在API密钥调用失败时发送系统通知。' } } ] } ] } ] } ], { "enabled": False, "proxy": False, "compatible": False, "recognize": False, "notify": False, "openai_url": "https://api.openai.com", "openai_key": "", "model": "gpt-3.5-turbo", "customize_prompt": '接下来我会给你一个电影或电视剧的文件名,你需要识别文件名中的名称、版本、分段、年份、分瓣率、季集等信息,并按以下JSON格式返回:{"name":string, ' '"version":string,"part":string,"year":string,"resolution":string,"season":number|null,"episode":number|null},特别注意返回结果需要严格附合JSON格式,不需要有任何其它的字符。如果中文电影或电视剧的文件名中存在谐音字或字母替代的情况,请还原最有可能的结果。' } def get_page(self) -> List[dict]: pass @staticmethod def is_api_error(response): """ 判断响应是否表示API错误 :param response: API响应 :return: (is_error, error_message) 元组,表示是否错误及错误信息 """ # 检查响应是否为字典且包含errorMsg if isinstance(response, dict) and response.get("errorMsg"): return True, response.get("errorMsg") # 检查响应是否为字符串且包含错误信息 if isinstance(response, str) and "请求ChatGPT出现错误" in response: return True, response # 如果没有错误信息,则表示调用成功 return False, "" @eventmanager.register(EventType.UserMessage) def talk(self, event: Event): """ 监听用户消息,获取ChatGPT回复 """ if not self._enabled: return if not self.openai: return text = event.event_data.get("text") userid = event.event_data.get("userid") channel = event.event_data.get("channel") if not text: return if text.startswith("http") or text.startswith("magnet") or text.startswith("ftp"): return # 尝试获取响应,失败时切换API密钥 retry_count = 0 max_retries = len(self._api_keys) while retry_count < max_retries: response = self.openai.get_response(text=text, userid=userid) # 判断响应是否正常 is_error, error_msg = self.is_api_error(response) logger.info(f"ChatGPT返回结果:{response}") if is_error: current_key = self._api_keys[self._current_key_index] switched, switch_error = self.switch_to_next_key(current_key) # 发送密钥失效通知 if self._notify: message = f"API密钥 {current_key} 调用失败: {error_msg}" self.post_message(channel=channel, title=message, userid=userid) # 如果所有密钥都失效,发送额外通知 if not switched: message = switch_error self.post_message(mtype=NotificationType.Plugin, title="ChatGpt", text=message) if not switched: # 所有密钥都失效,发送消息并退出 return retry_count += 1 else: # 成功获取响应 self.post_message(channel=channel, title=response, userid=userid) return # 所有重试都失败 if self._notify: self.post_message(channel=channel, title="无法获取ChatGPT响应,所有API密钥都已失效", userid=userid) @eventmanager.register(ChainEventType.NameRecognize) def recognize(self, event: Event): """ 监听识别事件,使用ChatGPT辅助识别名称 """ if not self.openai: return if not self._recognize: return if not event.event_data: return title = event.event_data.get("title") if not title: return # 尝试获取媒体名称,失败时切换API密钥 retry_count = 0 max_retries = len(self._api_keys) while retry_count < max_retries: response = self.openai.get_media_name(filename=title) logger.info(f"ChatGPT返回结果:{response}") # 判断响应是否正常 is_error, error_msg = self.is_api_error(response) # 如果不是错误但返回字典中没有name字段,也视为错误 if not is_error and isinstance(response, dict) and not response.get("name"): is_error = True error_msg = "未返回有效识别结果" if is_error: # 发生错误,尝试切换密钥 current_key = self._api_keys[self._current_key_index] switched, switch_error = self.switch_to_next_key(current_key) # 发送密钥失效通知 (通过系统通知,因为这里没有用户交互) if self._notify: message = f"API密钥 {current_key} 调用失败: {error_msg}" self.post_message(mtype=NotificationType.Plugin, title="ChatGpt", text=message) # 如果所有密钥都失效,发送额外通知 if not switched: message = switch_error self.post_message(mtype=NotificationType.Plugin, title="ChatGpt", text=message) if not switched: # 所有密钥都失效 return retry_count += 1 else: # 成功获取结果 event.event_data = { 'title': title, 'name': response.get("name"), 'year': response.get("year"), 'season': response.get("season"), 'episode': response.get("episode") } return # 所有重试都失败 if self._notify: logger.error(f"无法识别标题 {title},所有API密钥都已失效") self.post_message(mtype=NotificationType.Plugin, title="ChatGpt", text=f"无法识别标题 {title},所有API密钥都已失效") def stop_service(self): """ 退出插件 """ pass