diff --git a/agent/tools/__init__.py b/agent/tools/__init__.py index 4e7cbb2..1014715 100644 --- a/agent/tools/__init__.py +++ b/agent/tools/__init__.py @@ -16,10 +16,10 @@ __all__ = [ ] -def create_default_registry() -> ToolRegistry: +def create_default_registry(tavily_api_key: str | None = None) -> ToolRegistry: """创建包含所有默认工具的注册表""" registry = ToolRegistry() - registry.register(WebSearchTool()) + registry.register(WebSearchTool(api_key=tavily_api_key)) registry.register(ReminderCreateTool()) registry.register(ReminderListTool()) registry.register(ReminderDeleteTool()) diff --git a/agent/tools/web_search.py b/agent/tools/web_search.py index 3d88e10..73774f9 100644 --- a/agent/tools/web_search.py +++ b/agent/tools/web_search.py @@ -1,8 +1,8 @@ # agent/tools/web_search.py -"""网络搜索工具""" +"""网络搜索工具 - 使用 Tavily""" import json -import re +import os from typing import Any, TYPE_CHECKING from .base import Tool @@ -10,9 +10,23 @@ from .base import Tool if TYPE_CHECKING: from agent.context import AgentContext +# Tavily API +try: + from tavily import TavilyClient + TAVILY_AVAILABLE = True +except ImportError: + TAVILY_AVAILABLE = False + TavilyClient = None + class WebSearchTool(Tool): - """网络搜索工具 - 使用 Perplexity 进行搜索""" + """网络搜索工具 - 使用 Tavily 搜索引擎""" + + def __init__(self, api_key: str | None = None): + self._api_key = api_key or os.getenv("TAVILY_API_KEY") + self._client = None + if TAVILY_AVAILABLE and self._api_key: + self._client = TavilyClient(api_key=self._api_key) @property def name(self) -> str: @@ -21,8 +35,8 @@ class WebSearchTool(Tool): @property def description(self) -> str: return ( - "在网络上搜索信息。用于回答需要最新数据、实时信息或你不确定的事实性问题。" - "deep_research 仅在问题非常复杂、需要深度研究时才开启。" + "在网络上搜索最新信息。用于回答需要实时数据、新闻、或你不确定的事实性问题。" + "返回多个搜索结果,包含标题、内容摘要和来源链接。" ) @property @@ -34,10 +48,6 @@ class WebSearchTool(Tool): "type": "string", "description": "搜索关键词或问题", }, - "deep_research": { - "type": "boolean", - "description": "是否启用深度研究模式(耗时较长,仅用于复杂问题)", - }, }, "required": ["query"], "additionalProperties": False, @@ -45,7 +55,7 @@ class WebSearchTool(Tool): @property def status_text(self) -> str | None: - return "正在联网搜索: " + return "正在搜索: " @property def status_arg(self) -> str | None: @@ -55,39 +65,47 @@ class WebSearchTool(Tool): self, ctx: "AgentContext", query: str = "", - deep_research: bool = False, **_, ) -> str: - perplexity_instance = getattr(ctx.robot, "perplexity", None) - if not perplexity_instance: - return json.dumps( - {"error": "Perplexity 搜索功能不可用,未配置或未初始化"}, - ensure_ascii=False, - ) - if not query: return json.dumps({"error": "请提供搜索关键词"}, ensure_ascii=False) + if not TAVILY_AVAILABLE: + return json.dumps( + {"error": "Tavily 未安装,请运行: pip install tavily-python"}, + ensure_ascii=False, + ) + + if not self._client: + return json.dumps( + {"error": "Tavily API key 未配置,请在 config.yaml 中设置 tavily.key 或环境变量 TAVILY_API_KEY"}, + ensure_ascii=False, + ) + try: - # Perplexity.get_answer 是同步方法,需要在线程中运行 import asyncio response = await asyncio.to_thread( - perplexity_instance.get_answer, - query, - ctx.get_receiver(), - deep_research, + self._client.search, + query=query, + search_depth="basic", + max_results=5, + include_answer=False, ) - if not response: - return json.dumps({"error": "搜索无结果"}, ensure_ascii=False) + results = response.get("results", []) + if not results: + return json.dumps({"error": "未找到相关结果"}, ensure_ascii=False) + + formatted = [] + for r in results: + formatted.append({ + "title": r.get("title", ""), + "content": r.get("content", ""), + "url": r.get("url", ""), + }) + + return json.dumps({"results": formatted, "query": query}, ensure_ascii=False) - # 清理 think 标签 - cleaned = re.sub( - r".*?", "", response, flags=re.DOTALL - ).strip() - return json.dumps( - {"result": cleaned or response}, ensure_ascii=False - ) except Exception as e: return json.dumps({"error": f"搜索失败: {e}"}, ensure_ascii=False) diff --git a/ai_providers/__init__.py b/ai_providers/__init__.py index b594ad8..43dd505 100644 --- a/ai_providers/__init__.py +++ b/ai_providers/__init__.py @@ -7,6 +7,5 @@ AI Providers Module from .ai_chatgpt import ChatGPT from .ai_deepseek import DeepSeek from .ai_kimi import Kimi -from .ai_perplexity import Perplexity -__all__ = ["ChatGPT", "DeepSeek", "Kimi", "Perplexity"] +__all__ = ["ChatGPT", "DeepSeek", "Kimi"] diff --git a/ai_providers/ai_perplexity.py b/ai_providers/ai_perplexity.py deleted file mode 100644 index 1ca0e20..0000000 --- a/ai_providers/ai_perplexity.py +++ /dev/null @@ -1,489 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -import asyncio -import json -import logging -import re -import time -from typing import Optional, Dict, Callable, List -import os -from threading import Thread, Lock -from openai import OpenAI - -from providers.base import LLMProvider, LLMResponse, ToolCall - - -class PerplexityThread(Thread): - """处理Perplexity请求的线程""" - - def __init__( - self, - perplexity_instance, - prompt, - chat_id, - send_text_func, - receiver, - at_user=None, - on_finish: Optional[Callable[[], None]] = None, - enable_full_research: bool = False, - ): - """初始化Perplexity处理线程 - - Args: - perplexity_instance: Perplexity实例 - prompt: 查询内容 - chat_id: 聊天ID - send_text_func: 发送消息的函数,接受(消息内容, 接收者ID, @用户ID)参数 - receiver: 接收消息的ID - at_user: 被@的用户ID - """ - super().__init__(daemon=True) - self.perplexity = perplexity_instance - self.prompt = prompt - self.chat_id = chat_id - self.send_text_func = send_text_func - self.receiver = receiver - self.at_user = at_user - self.LOG = logging.getLogger("PerplexityThread") - self.on_finish = on_finish - self.enable_full_research = enable_full_research - - # 检查是否使用reasoning模型 - self.is_reasoning_model = bool(self.enable_full_research and getattr(self.perplexity, 'has_reasoning_model', False)) - if self.is_reasoning_model: - self.LOG.info("Perplexity将启用推理模型处理此次请求") - - def run(self): - """线程执行函数""" - try: - self.LOG.info(f"开始处理Perplexity请求: {self.prompt[:30]}...") - - # 获取回答 - response = self.perplexity.get_answer( - self.prompt, - self.chat_id, - deep_research=self.enable_full_research - ) - - # 处理sonar-reasoning和sonar-reasoning-pro模型的标签 - if response: - # 只有对reasoning模型才应用清理逻辑 - if self.is_reasoning_model: - response = self.remove_thinking_content(response) - - # 移除Markdown格式符号 - response = self.remove_markdown_formatting(response) - - self.send_text_func(response, at_list=self.at_user) - else: - self.send_text_func("无法从Perplexity获取回答", at_list=self.at_user) - - self.LOG.info(f"Perplexity请求处理完成: {self.prompt[:30]}...") - - except Exception as e: - self.LOG.error(f"处理Perplexity请求时出错: {e}") - self.send_text_func(f"处理请求时出错: {e}", at_list=self.at_user) - finally: - if self.on_finish: - try: - self.on_finish() - except Exception as cleanup_error: - self.LOG.error(f"清理Perplexity线程时出错: {cleanup_error}") - - def remove_thinking_content(self, text): - """移除标签之间的思考内容 - - Args: - text: 原始响应文本 - - Returns: - str: 处理后的文本 - """ - try: - # 检查是否包含思考标签 - has_thinking = '' in text or '' in text - - if has_thinking: - self.LOG.info("检测到思考内容标签,准备移除...") - - # 导入正则表达式库 - import re - - # 移除不完整的标签对情况 - if text.count('') != text.count(''): - self.LOG.warning(f"检测到不匹配的思考标签: 数量={text.count('')}, 数量={text.count('')}") - - # 提取思考内容用于日志记录 - thinking_pattern = re.compile(r'(.*?)', re.DOTALL) - thinking_matches = thinking_pattern.findall(text) - - if thinking_matches: - for i, thinking in enumerate(thinking_matches): - short_thinking = thinking[:100] + '...' if len(thinking) > 100 else thinking - self.LOG.debug(f"思考内容 #{i+1}: {short_thinking}") - - # 替换所有的...内容 - 使用非贪婪模式 - cleaned_text = re.sub(r'.*?', '', text, flags=re.DOTALL) - - # 处理不完整的标签 - cleaned_text = re.sub(r'.*?$', '', cleaned_text, flags=re.DOTALL) # 处理未闭合的开始标签 - cleaned_text = re.sub(r'^.*?', '', cleaned_text, flags=re.DOTALL) # 处理未开始的闭合标签 - - # 处理可能的多余空行 - cleaned_text = re.sub(r'\n{3,}', '\n\n', cleaned_text) - - # 移除前后空白 - cleaned_text = cleaned_text.strip() - - self.LOG.info(f"思考内容已移除,原文本长度: {len(text)} -> 清理后: {len(cleaned_text)}") - - # 如果清理后文本为空,返回一个提示信息 - if not cleaned_text: - return "回答内容为空,可能是模型仅返回了思考过程。请重新提问。" - - return cleaned_text - else: - return text # 没有思考标签,直接返回原文本 - - except Exception as e: - self.LOG.error(f"清理思考内容时出错: {e}") - return text # 出错时返回原始文本 - - def remove_markdown_formatting(self, text): - """移除Markdown格式符号,如*和# - - Args: - text: 包含Markdown格式的文本 - - Returns: - str: 移除Markdown格式后的文本 - """ - try: - # 导入正则表达式库 - import re - - self.LOG.info("开始移除Markdown格式符号...") - - # 保存原始文本长度 - original_length = len(text) - - # 移除标题符号 (#) - # 替换 # 开头的标题,保留文本内容 - cleaned_text = re.sub(r'^\s*#{1,6}\s+(.+)$', r'\1', text, flags=re.MULTILINE) - - # 移除强调符号 (*) - # 替换 **粗体** 和 *斜体* 格式,保留文本内容 - cleaned_text = re.sub(r'\*\*(.*?)\*\*', r'\1', cleaned_text) - cleaned_text = re.sub(r'\*(.*?)\*', r'\1', cleaned_text) - - # 处理可能的多余空行 - cleaned_text = re.sub(r'\n{3,}', '\n\n', cleaned_text) - - # 移除前后空白 - cleaned_text = cleaned_text.strip() - - self.LOG.info(f"Markdown格式符号已移除,原文本长度: {original_length} -> 清理后: {len(cleaned_text)}") - - return cleaned_text - - except Exception as e: - self.LOG.error(f"移除Markdown格式符号时出错: {e}") - return text # 出错时返回原始文本 - - -class PerplexityManager: - """管理Perplexity请求线程的类""" - - def __init__(self): - self.threads = {} - self.lock = Lock() - self.LOG = logging.getLogger("PerplexityManager") - - def start_request( - self, - perplexity_instance, - prompt, - chat_id, - send_text_func, - receiver, - at_user=None, - enable_full_research: bool = False, - ): - """启动Perplexity请求线程 - - Args: - perplexity_instance: Perplexity实例 - prompt: 查询内容 - chat_id: 聊天ID - send_text_func: 发送消息的函数 - receiver: 接收消息的ID - at_user: 被@的用户ID - enable_full_research: 是否启用深度研究模式 - - Returns: - bool: 是否成功启动线程 - """ - thread_key = f"{receiver}_{chat_id}" - full_research_available = enable_full_research and getattr(perplexity_instance, 'has_reasoning_model', False) - - with self.lock: - # 检查是否已有正在处理的相同请求 - if thread_key in self.threads and self.threads[thread_key].is_alive(): - send_text_func("⚠️ 已有一个Perplexity请求正在处理中,请稍后再试", at_list=at_user) - return False - - # 发送等待消息 - wait_msg = "正在启用满血模式研究中...." if full_research_available else "正在联网查询,请稍候..." - if enable_full_research and not full_research_available: - self.LOG.warning("收到满血模式请求,但未配置推理模型,退回普通模式。") - # 等待提示无需 @ 用户,避免频繁打扰 - send_text_func(wait_msg, at_list="", record_message=False) - - # 添加线程完成回调,自动清理线程 - def thread_finished_callback(): - with self.lock: - thread = self.threads.pop(thread_key, None) - if thread is not None: - self.LOG.info(f"已清理Perplexity线程: {thread_key}") - - # 创建并启动新线程处理请求 - perplexity_thread = PerplexityThread( - perplexity_instance=perplexity_instance, - prompt=prompt, - chat_id=chat_id, - send_text_func=send_text_func, - receiver=receiver, - at_user=at_user, - on_finish=thread_finished_callback, - enable_full_research=full_research_available - ) - - # 保存线程引用 - self.threads[thread_key] = perplexity_thread - - # 启动线程 - perplexity_thread.start() - self.LOG.info(f"已启动Perplexity请求线程: {thread_key}") - - return True - - def cleanup_threads(self): - """清理所有Perplexity线程""" - with self.lock: - active_threads = [thread_key for thread_key, thread in self.threads.items() if thread.is_alive()] - - if active_threads: - self.LOG.info(f"等待{len(active_threads)}个Perplexity线程结束: {active_threads}") - - # 等待所有线程结束,但最多等待10秒 - for _ in range(10): - with self.lock: - active_count = sum(1 for thread in self.threads.values() if thread.is_alive()) - - if active_count == 0: - break - - time.sleep(1) - - with self.lock: - still_active = [thread_key for thread_key, thread in self.threads.items() if thread.is_alive()] - if still_active: - self.LOG.warning(f"以下Perplexity线程在退出时仍在运行: {still_active}") - self.threads.clear() - else: - with self.lock: - self.threads.clear() - - self.LOG.info("Perplexity线程管理已清理") - - -class Perplexity(LLMProvider): - def __init__(self, config): - self.config = config - self.api_key = config.get('key') - self.api_base = config.get('api', 'https://api.perplexity.ai') - self.proxy = config.get('proxy') - self.prompt = config.get('prompt', '你是智能助手Perplexity') - self.trigger_keyword = config.get('trigger_keyword', 'ask') - self.fallback_prompt = config.get('fallback_prompt', "请像 Perplexity 一样,以专业、客观、信息丰富的方式回答问题。不要使用任何tex或者md格式,纯文本输出。") - self.LOG = logging.getLogger('Perplexity') - self.model_flash = config.get('model_flash') or config.get('model', 'sonar') - self.model_reasoning = config.get('model_reasoning') - if self.model_reasoning and self.model_reasoning.lower() == (self.model_flash or '').lower(): - self.model_reasoning = None - self.has_reasoning_model = bool(self.model_reasoning) - - # 设置编码环境变量,确保处理Unicode字符 - os.environ["PYTHONIOENCODING"] = "utf-8" - - # 创建线程管理器 - self.thread_manager = PerplexityManager() - - # 创建OpenAI客户端 - self.client = None - if self.api_key: - try: - self.client = OpenAI( - api_key=self.api_key, - base_url=self.api_base - ) - # 如果有代理设置 - if self.proxy: - # OpenAI客户端不直接支持代理设置,需要通过环境变量 - os.environ["HTTPS_PROXY"] = self.proxy - os.environ["HTTP_PROXY"] = self.proxy - - self.LOG.info("Perplexity 客户端已初始化") - - except Exception as e: - self.LOG.error(f"初始化Perplexity客户端失败: {str(e)}") - else: - self.LOG.warning("未配置Perplexity API密钥") - - @staticmethod - def value_check(args: dict) -> bool: - if args: - return all(value is not None for key, value in args.items() if key != 'proxy') - return False - - def get_answer(self, prompt, session_id=None, deep_research: bool = False): - """获取Perplexity回答 - - Args: - prompt: 用户输入的问题 - session_id: 会话ID,用于区分不同会话 - - Returns: - str: Perplexity的回答 - """ - try: - if not self.api_key or not self.client: - return "Perplexity API key 未配置或客户端初始化失败" - - # 构建消息列表 - messages = [ - {"role": "system", "content": self.prompt}, - {"role": "user", "content": prompt} - ] - - # 获取模型 - model = self.model_reasoning if (deep_research and self.has_reasoning_model) else self.model_flash or self.config.get('model', 'sonar') - if deep_research and self.has_reasoning_model: - self.LOG.info(f"Perplexity启动深度研究模式,使用模型: {model}") - - # 使用json序列化确保正确处理Unicode - self.LOG.info(f"发送到Perplexity的消息: {json.dumps(messages, ensure_ascii=False)}") - - # 创建聊天完成 - response = self.client.chat.completions.create( - model=model, - messages=messages - ) - - # 返回回答内容 - return response.choices[0].message.content - - except Exception as e: - self.LOG.error(f"调用Perplexity API时发生错误: {str(e)}") - return f"发生错误: {str(e)}" - - def process_message( - self, - content, - chat_id, - sender, - roomid, - from_group, - send_text_func, - enable_full_research: bool = False, - ): - """处理可能包含Perplexity触发词的消息 - - Args: - content: 消息内容 - chat_id: 聊天ID - sender: 发送者ID - roomid: 群聊ID(如果是群聊) - from_group: 是否来自群聊 - send_text_func: 发送消息的函数 - enable_full_research: 是否启用深度研究模式 - - Returns: - tuple[bool, Optional[str]]: - - bool: 是否已处理该消息 - - Optional[str]: 无权限时的备选prompt,其他情况为None - """ - prompt = (content or "").strip() - if not prompt: - return False, None - - stripped_by_keyword = False - - trigger = (self.trigger_keyword or "").strip() - if trigger: - lowered_prompt = prompt.lower() - lowered_trigger = trigger.lower() - if lowered_prompt.startswith(lowered_trigger): - stripped_by_keyword = True - prompt = prompt[len(trigger):].strip() - - if not prompt: - if stripped_by_keyword: - send_text_func( - "请告诉我你想搜索什么内容", - at_list=sender if from_group else "", - record_message=False - ) - return True, None - return False, None - - receiver = roomid if from_group else sender - at_user = sender if from_group else None - - request_started = self.thread_manager.start_request( - perplexity_instance=self, - prompt=prompt, - chat_id=chat_id, - send_text_func=send_text_func, - receiver=receiver, - at_user=at_user, - enable_full_research=enable_full_research - ) - return request_started, None - - def cleanup(self): - """清理所有资源""" - self.thread_manager.cleanup_threads() - - async def chat( - self, - messages: list[dict], - tools: list[dict] | None = None, - ) -> LLMResponse: - """异步调用 LLM(实现 LLMProvider 接口) - - 注意:Perplexity 不支持工具调用,tools 参数会被忽略 - """ - if not self.api_key or not self.client: - return LLMResponse(content="Perplexity API key 未配置或客户端初始化失败") - - try: - model = self.model_flash or self.config.get("model", "sonar") - - response = await asyncio.to_thread( - self.client.chat.completions.create, - model=model, - messages=messages, - ) - - content = response.choices[0].message.content - # Perplexity 不支持工具调用 - return LLMResponse(content=content, tool_calls=[]) - - except Exception as e: - self.LOG.error(f"Perplexity API 调用失败: {e}") - return LLMResponse(content=f"发生错误: {str(e)}") - - def __str__(self): - return "Perplexity" diff --git a/bot.py b/bot.py index 7b507a4..ad57400 100644 --- a/bot.py +++ b/bot.py @@ -12,7 +12,6 @@ from channel import Channel, Message, MessageType from ai_providers.ai_chatgpt import ChatGPT from ai_providers.ai_deepseek import DeepSeek from ai_providers.ai_kimi import Kimi -from ai_providers.ai_perplexity import Perplexity from function.func_summary import MessageSummary from function.func_reminder import ReminderManager from function.func_persona import ( @@ -87,7 +86,8 @@ class BubblesBot: self.persona_manager = None # 初始化 Agent Loop 系统 - self.tool_registry = create_default_registry() + tavily_key = getattr(self.config, "TAVILY", {}).get("key") if hasattr(self.config, "TAVILY") else None + self.tool_registry = create_default_registry(tavily_api_key=tavily_key) self.agent_loop = AgentLoop(self.tool_registry, max_iterations=20) self.session_manager = SessionManager( message_summary=self.message_summary, @@ -186,24 +186,6 @@ class BubblesBot: except Exception as e: self.LOG.error(f"初始化 Kimi 失败: {e}") - # Perplexity - if Perplexity.value_check(self.config.PERPLEXITY): - try: - flash_conf = copy.deepcopy(self.config.PERPLEXITY) - flash_model = flash_conf.get("model_flash", "sonar") - flash_conf["model"] = flash_model - self.chat_models[ChatType.PERPLEXITY.value] = Perplexity(flash_conf) - self.LOG.info(f"已加载 Perplexity: {flash_model}") - - reasoning_model = self.config.PERPLEXITY.get("model_reasoning") - if reasoning_model and reasoning_model != flash_model: - reason_conf = copy.deepcopy(self.config.PERPLEXITY) - reason_conf["model"] = reasoning_model - self.reasoning_chat_models[ChatType.PERPLEXITY.value] = Perplexity(reason_conf) - self.LOG.info(f"已加载 Perplexity 推理模型: {reasoning_model}") - except Exception as e: - self.LOG.error(f"初始化 Perplexity 失败: {e}") - async def start(self) -> None: """启动机器人""" self.LOG.info(f"BubblesBot v{__version__} 启动中...") diff --git a/config.yaml.template b/config.yaml.template index 2d33564..b60ba1d 100644 --- a/config.yaml.template +++ b/config.yaml.template @@ -1,162 +1,51 @@ +# Bubbles 配置文件 +# 复制此文件为 config.yaml 并填写 API Key + +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +# AI 模型(至少配置一个) +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +chatgpt: + key: # OpenAI API Key + api: https://api.openai.com/v1 + model_flash: gpt-4o-mini + model_reasoning: gpt-4o + proxy: # 代理(可选) + +deepseek: + key: # DeepSeek API Key + api: https://api.deepseek.com + model_flash: deepseek-chat + model_reasoning: deepseek-reasoner + +kimi: + key: # Moonshot API Key + api: https://api.moonshot.cn/v1 + model_flash: kimi-k2 + model_reasoning: kimi-k2-thinking + +# 搜索工具 +tavily: + key: # Tavily API Key (https://tavily.com) + +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +# 其他 +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +send_rate_limit: 10 # 每分钟最大发送数 + logging: version: 1 - disable_existing_loggers: False - + disable_existing_loggers: false formatters: simple: - format: "%(asctime)s %(message)s" - datefmt: "%Y-%m-%d %H:%M:%S" - error: - format: "%(asctime)s %(name)s %(levelname)s %(filename)s::%(funcName)s[%(lineno)d]:%(message)s" - + format: "%(asctime)s [%(levelname)s] %(message)s" + datefmt: "%H:%M:%S" handlers: console: class: logging.StreamHandler level: INFO formatter: simple - stream: ext://sys.stdout - - info_file_handler: - class: logging.handlers.RotatingFileHandler - level: INFO - formatter: simple - filename: wx_info.log - maxBytes: 10485760 # 10MB - backupCount: 20 - encoding: utf8 - - warning_file_handler: - class: logging.handlers.RotatingFileHandler - level: WARNING - formatter: simple - filename: wx_warning.log - maxBytes: 10485760 # 10MB - backupCount: 20 - encoding: utf8 - - error_file_handler: - class: logging.handlers.RotatingFileHandler - level: ERROR - formatter: error - filename: wx_error.log - maxBytes: 10485760 # 10MB - backupCount: 20 - encoding: utf8 - root: level: INFO - handlers: [console, info_file_handler, error_file_handler] - -groups: - enable: [example12345@chatroom,example12345@chatroom] # 允许响应的群 roomId,大概长这样:2xxxxxxxxx3@chatroom - welcome_msg: "欢迎 {new_member} 加入群聊!\n请简单介绍一下自己吧~\n如果想和我聊天,可以@我" # 新人入群欢迎消息,可使用{new_member}和{inviter}变量 - # 群聊与AI模型映射,如果不配置则使用默认模型 - models: - # 模型ID参考: - # 0: 自动选择第一个可用模型 - # 1: ChatGPT - # 2: DeepSeek - # 3: Kimi - # 4: Perplexity - default: 0 # 默认模型ID(0表示自动选择第一个可用模型) - # 群聊映射 - mapping: - - room_id: example12345@chatroom - model: 2 - max_history: 30 # 回顾最近30条消息 - random_chitchat_probability: 0.2 # 群聊随机闲聊概率(0-1),0 表示关闭 - force_reasoning: true # 闲聊时强制使用推理模型(AI 路由仍正常执行) - - - room_id: example12345@chatroom - model: 7 - max_history: 30 # 回顾最近30条消息 - random_chitchat_probability: 0.0 # 可单独覆盖默认概率 - - # 私聊映射 - private_mapping: - - wxid: filehelper - model: 2 - max_history: 30 # 回顾最近30条消息 - - - wxid: wxid_example12345 - model: 8 - max_history: 30 # 回顾最近30条消息 - -message_forwarding: - enable: false # 是否开启转发功能 - rules: - - source_room_id: example12345@chatroom # 需要监听的群ID - target_room_ids: - - example67890@chatroom # 接受转发消息的群ID - keywords: - - "关键词1" - - "关键词2" - # - source_room_id: another_group@chatroom - # target_room_ids: ["target_group@chatroom"] - # keywords: ["需要的词"] - -MAX_HISTORY: 300 # 记录数据库的消息历史 - -news: - receivers: ["filehelper"] # 定时新闻接收人(roomid 或者 wxid) - -# 消息发送速率限制:一分钟内最多发送6条消息 -send_rate_limit: 6 - -weather: # -----天气提醒配置这行不填----- - city_code: 101010100 # 北京城市代码,如若需要其他城市,可参考base/main_city.json或者自寻城市代码填写 - receivers: ["filehelper"] # 天气提醒接收人(roomid 或者 wxid) - -chatgpt: # -----chatgpt配置这行不填----- - key: # 填写你 ChatGPT 的 key - api: https://api.openai.com/v1 # 如果你不知道这是干嘛的,就不要改 - model_flash: gpt-3.5-turbo # 快速回复模型(可选) - model_reasoning: gpt-3.5-turbo # 深度思考模型(可选) - proxy: # 如果你在国内,你可能需要魔法,大概长这样:http://域名或者IP地址:端口号 - prompt: 你是智能聊天机器人,你叫 wcferry # 根据需要对角色进行设定 - max_history_messages: 20 # <--- 添加这一行,设置 ChatGPT 最多回顾 20 条历史消息 - -deepseek: # -----deepseek配置这行不填----- - #思维链相关功能默认关闭,开启后会增加响应时间和消耗更多的token - key: # 填写你的 DeepSeek API Key API Key的格式为sk-xxxxxxxxxxxxxxx - api: https://api.deepseek.com # DeepSeek API 地址 - model_flash: deepseek-chat # 快速回复模型 - model_reasoning: deepseek-reasoner # 深度思考模型 - prompt: 你是智能聊天机器人,你叫 DeepSeek 助手 # 根据需要对角色进行设定 - enable_reasoning: false # 是否启用思维链功能,仅在使用 deepseek-reasoner 模型时有效 - show_reasoning: false # 是否在回复中显示思维过程,仅在启用思维链功能时有效 - max_history_messages: 10 # <--- 添加这一行,设置 DeepSeek 最多回顾 10 条历史消息 - -kimi: # -----kimi配置----- - key: # 填写你的 Moonshot API Key - api: https://api.moonshot.cn/v1 # Kimi API 地址 - proxy: # 国内可按需配置代理,例如:http://127.0.0.1:7890 - model_flash: kimi-k2 # 快速回复模型 - model_reasoning: kimi-k2-thinking # 深度思考模型 - prompt: 你是 Kimi,一个由 Moonshot AI 构建的可靠助手 # 角色设定 - max_history_messages: 20 # 设置 Kimi 最多回顾 20 条历史消息 - show_reasoning: false # 是否在回复中附带 reasoning_content 内容 - -aliyun_image: # -----如果要使用阿里云文生图,取消下面的注释并填写相关内容,模型到阿里云百炼找通义万相-文生图2.1-Turbo----- - enable: true # 是否启用阿里文生图功能,false为关闭,默认开启,如果未配置,则会将消息发送给聊天大模型 - api_key: sk-xxxxxxxxxxxxxxxxxxxxxxxx # 替换为你的DashScope API密钥 - model: wanx2.1-t2i-turbo # 模型名称,默认使用wanx2.1-t2i-turbo(快),wanx2.1-t2i-plus(中),wanx-v1(慢),会给用户不同的提示! - size: 1024*1024 # 图像尺寸,格式为宽*高 - n: 1 # 生成图像的数量 - temp_dir: ./temp # 临时文件存储路径 - trigger_keyword: 牛阿里 # 触发词,默认为"牛阿里" - fallback_to_chat: true # 当服务不可用时是否转发给聊天模型处理 - -perplexity: # -----perplexity配置这行不填----- - key: # 填写你的Perplexity API Key - api: https://api.perplexity.ai # API地址 - proxy: # 如果你在国内,你可能需要魔法,大概长这样:http://域名或者IP地址:端口号 - model_flash: mixtral-8x7b-instruct # 快速回复模型(可选) - model_reasoning: mixtral-8x7b-instruct # 深度思考模型(可选) - prompt: 你是Perplexity AI助手,请用专业、准确、有帮助的方式回答问题 # 角色设定 - -ai_router: # -----AI路由器配置----- - enable: true # 是否启用AI路由功能 - allowed_groups: [] # 允许使用AI路由的群聊ID列表,例如:["123456789@chatroom", "123456789@chatroom"] - -auto_accept_friend_request: false # 是否自动通过好友申请,默认关闭 + handlers: [console] diff --git a/configuration.py b/configuration.py index bc08a7d..5746204 100644 --- a/configuration.py +++ b/configuration.py @@ -12,21 +12,6 @@ class Config(object): def __init__(self) -> None: self.reload() - @staticmethod - def _normalize_random_chitchat_probability(entry, fallback_probability=0.0): - if isinstance(entry, (int, float)): - probability = entry - elif isinstance(entry, dict): - probability = entry.get("probability", fallback_probability) - else: - probability = fallback_probability - try: - probability = float(probability) - except (TypeError, ValueError): - probability = fallback_probability - probability = max(0.0, min(1.0, probability)) - return probability - def _load_config(self) -> dict: pwd = os.path.dirname(os.path.abspath(__file__)) try: @@ -37,64 +22,37 @@ class Config(object): with open(f"{pwd}/config.yaml", "rb") as fp: yconfig = yaml.safe_load(fp) - return yconfig + return yconfig or {} def reload(self) -> None: yconfig = self._load_config() - logging.config.dictConfig(yconfig["logging"]) - self.CITY_CODE = yconfig["weather"]["city_code"] - self.WEATHER = yconfig["weather"]["receivers"] - self.GROUPS = yconfig["groups"]["enable"] - self.WELCOME_MSG = yconfig["groups"].get("welcome_msg", "欢迎 {new_member} 加入群聊!") - self.GROUP_MODELS = yconfig["groups"].get("models", {"default": 0, "mapping": []}) - legacy_random_conf = yconfig["groups"].get("random_chitchat", {}) - legacy_default = self._normalize_random_chitchat_probability( - legacy_random_conf.get("default", 0.0) if isinstance(legacy_random_conf, dict) else 0.0, - fallback_probability=0.0, - ) - legacy_mapping = {} - if isinstance(legacy_random_conf, dict): - for item in legacy_random_conf.get("mapping", []) or []: - if not isinstance(item, dict): - continue - room_id = item.get("room_id") - if not room_id: - continue - legacy_mapping[room_id] = self._normalize_random_chitchat_probability( - item, - fallback_probability=legacy_default, - ) - random_chitchat_mapping = {} - for item in self.GROUP_MODELS.get("mapping", []) or []: - if not isinstance(item, dict): - continue - room_id = item.get("room_id") - if not room_id: - continue - if "random_chitchat_probability" in item: - rate = self._normalize_random_chitchat_probability( - item["random_chitchat_probability"], - fallback_probability=legacy_default, - ) - random_chitchat_mapping[room_id] = rate - elif room_id in legacy_mapping: - random_chitchat_mapping[room_id] = legacy_mapping[room_id] + # 日志配置 + if "logging" in yconfig: + logging.config.dictConfig(yconfig["logging"]) - self.GROUP_RANDOM_CHITCHAT_DEFAULT = legacy_default - self.GROUP_RANDOM_CHITCHAT = random_chitchat_mapping - - self.NEWS = yconfig["news"]["receivers"] + # AI 模型配置 self.CHATGPT = yconfig.get("chatgpt", {}) self.DEEPSEEK = yconfig.get("deepseek", {}) self.KIMI = yconfig.get("kimi", {}) - self.PERPLEXITY = yconfig.get("perplexity", {}) - self.ALIYUN_IMAGE = yconfig.get("aliyun_image", {}) - self.AI_ROUTER = yconfig.get("ai_router", {"enable": True, "allowed_groups": []}) - self.AUTO_ACCEPT_FRIEND_REQUEST = yconfig.get("auto_accept_friend_request", False) + + # 发送限制 + self.SEND_RATE_LIMIT = yconfig.get("send_rate_limit", 10) + + # Tavily 搜索 + self.TAVILY = yconfig.get("tavily", {}) + + # 向后兼容(旧版 robot.py 可能用到) + self.GROUPS = yconfig.get("groups", {}).get("enable", []) + self.WELCOME_MSG = yconfig.get("groups", {}).get("welcome_msg", "") + self.GROUP_MODELS = yconfig.get("groups_models", {"default": 0}) self.MAX_HISTORY = yconfig.get("MAX_HISTORY", 300) - self.SEND_RATE_LIMIT = yconfig.get("send_rate_limit", 0) - self.MESSAGE_FORWARDING = yconfig.get( - "message_forwarding", - {"enable": False, "rules": []} - ) + self.AUTO_ACCEPT_FRIEND_REQUEST = yconfig.get("auto_accept_friend_request", False) + self.NEWS = [] + self.WEATHER = [] + self.CITY_CODE = "" + self.ALIYUN_IMAGE = {} + self.MESSAGE_FORWARDING = {"enable": False, "rules": []} + self.AI_ROUTER = {"enable": False} + self.GROUP_RANDOM_CHITCHAT_DEFAULT = 0.0 + self.GROUP_RANDOM_CHITCHAT = {} diff --git a/constants.py b/constants.py index 8be8fb7..ee8224b 100644 --- a/constants.py +++ b/constants.py @@ -7,14 +7,12 @@ class ChatType(IntEnum): CHATGPT = 1 # ChatGPT DEEPSEEK = 2 # DeepSeek KIMI = 3 # Kimi (Moonshot) - PERPLEXITY = 4 # Perplexity @staticmethod def is_in_chat_types(chat_type: int) -> bool: if chat_type in [ChatType.CHATGPT.value, ChatType.DEEPSEEK.value, - ChatType.KIMI.value, - ChatType.PERPLEXITY.value]: + ChatType.KIMI.value]: return True return False diff --git a/robot.py b/robot.py index 4bb7ba3..0aa090f 100644 --- a/robot.py +++ b/robot.py @@ -16,7 +16,6 @@ from wcferry import Wcf, WxMsg from ai_providers.ai_chatgpt import ChatGPT from ai_providers.ai_deepseek import DeepSeek from ai_providers.ai_kimi import Kimi -from ai_providers.ai_perplexity import Perplexity from function.func_weather import Weather from function.func_news import News from function.func_summary import MessageSummary @@ -190,27 +189,7 @@ class Robot(Job): self.LOG.info(f"已加载 Kimi 推理模型: {reasoning_model_name}") except Exception as e: self.LOG.error(f"初始化 Kimi 模型时出错: {str(e)}") - - - # 初始化Perplexity - if Perplexity.value_check(self.config.PERPLEXITY): - try: - perplexity_flash_conf = copy.deepcopy(self.config.PERPLEXITY) - flash_model_name = perplexity_flash_conf.get("model_flash", "sonar") - perplexity_flash_conf["model"] = flash_model_name - self.chat_models[ChatType.PERPLEXITY.value] = Perplexity(perplexity_flash_conf) - self.perplexity = self.chat_models[ChatType.PERPLEXITY.value] # 单独保存一个引用用于特殊处理 - self.LOG.info(f"已加载 Perplexity 模型: {flash_model_name}") - reasoning_model_name = self.config.PERPLEXITY.get("model_reasoning") - if reasoning_model_name and reasoning_model_name != flash_model_name: - perplexity_reason_conf = copy.deepcopy(self.config.PERPLEXITY) - perplexity_reason_conf["model"] = reasoning_model_name - self.reasoning_chat_models[ChatType.PERPLEXITY.value] = Perplexity(perplexity_reason_conf) - self.LOG.info(f"已加载 Perplexity 推理模型: {reasoning_model_name}") - except Exception as e: - self.LOG.error(f"初始化 Perplexity 模型时出错: {str(e)}") - # 根据chat_type参数选择默认模型 self.current_model_id = None if chat_type > 0 and chat_type in self.chat_models: @@ -586,20 +565,10 @@ class Robot(Job): for r in receivers: self.sendTextMsg(report, r) - def cleanup_perplexity_threads(self): - """清理所有Perplexity线程""" - # 如果已初始化Perplexity实例,调用其清理方法 - perplexity_instance = self.get_perplexity_instance() - if perplexity_instance: - perplexity_instance.cleanup() - def cleanup(self): """清理所有资源,在程序退出前调用""" self.LOG.info("开始清理机器人资源...") - - # 清理Perplexity线程 - self.cleanup_perplexity_threads() - + # 关闭消息历史数据库连接 if hasattr(self, 'message_summary') and self.message_summary: self.LOG.info("正在关闭消息历史数据库...") @@ -610,34 +579,9 @@ class Robot(Job): self.persona_manager.close() except Exception as e: self.LOG.error(f"关闭人设数据库时出错: {e}") - + self.LOG.info("机器人资源清理完成") - - def get_perplexity_instance(self): - """获取Perplexity实例 - - Returns: - Perplexity: Perplexity实例,如果未配置则返回None - """ - # 检查是否已有Perplexity实例 - if hasattr(self, 'perplexity'): - return self.perplexity - - # 检查config中是否有Perplexity配置 - if hasattr(self.config, 'PERPLEXITY') and Perplexity.value_check(self.config.PERPLEXITY): - self.perplexity = Perplexity(self.config.PERPLEXITY) - return self.perplexity - - # 检查chat是否是Perplexity类型 - if isinstance(self.chat, Perplexity): - return self.chat - - # 如果存在chat_models字典,尝试从中获取 - if hasattr(self, 'chat_models') and ChatType.PERPLEXITY.value in self.chat_models: - return self.chat_models[ChatType.PERPLEXITY.value] - - return None - + def _get_reasoning_chat_model(self): """获取当前聊天模型对应的推理模型实例""" model_id = getattr(self, 'current_model_id', None) diff --git a/tools/__init__.py b/tools/__init__.py deleted file mode 100644 index 521d55d..0000000 --- a/tools/__init__.py +++ /dev/null @@ -1,105 +0,0 @@ -""" -工具系统 —— 让 LLM 在 Agent 循环中自主调用工具。 - -每个 Tool 提供 OpenAI function-calling 格式的 schema 和一个同步执行函数。 -ToolRegistry 汇总所有工具,生成 tools 列表和统一的 tool_handler。 -""" - -import json -import logging -from dataclasses import dataclass, field -from typing import Any, Callable, Dict, List, Optional - -logger = logging.getLogger(__name__) - - -@dataclass -class Tool: - """LLM 可调用的工具。""" - name: str - description: str - parameters: dict # JSON Schema - handler: Callable[..., str] = None # (ctx, **kwargs) -> str - status_text: str = "" # 执行前发给用户的状态提示,空则不发 - - def to_openai_schema(self) -> dict: - return { - "type": "function", - "function": { - "name": self.name, - "description": self.description, - "parameters": self.parameters, - }, - } - - -class ToolRegistry: - """收集工具,为 Agent 循环提供 tools + tool_handler。""" - - def __init__(self): - self._tools: Dict[str, Tool] = {} - - def register(self, tool: Tool) -> None: - self._tools[tool.name] = tool - logger.info(f"注册工具: {tool.name}") - - def get(self, name: str) -> Optional[Tool]: - return self._tools.get(name) - - @property - def tools(self) -> Dict[str, Tool]: - return dict(self._tools) - - def get_openai_tools(self) -> List[dict]: - """返回所有工具的 OpenAI function-calling schema 列表。""" - return [t.to_openai_schema() for t in self._tools.values()] - - def create_handler(self, ctx: Any) -> Callable[[str, dict], str]: - """创建一个绑定了消息上下文的 tool_handler 函数。 - - 执行工具前,如果该工具配置了 status_text,会先给用户发一条状态提示, - 让用户知道"机器人在干什么"(类似 OpenClaw/OpenCode 的中间过程输出)。 - """ - registry = self._tools - - def _send_status(tool: 'Tool', arguments: dict) -> None: - """发送工具执行状态消息给用户。""" - if not tool.status_text: - return - try: - # 对搜索类工具,把查询关键词带上 - status = tool.status_text - if tool.name == "web_search" and arguments.get("query"): - status = f"{status}{arguments['query']}" - elif tool.name == "lookup_chat_history" and arguments.get("keywords"): - kw_str = "、".join(str(k) for k in arguments["keywords"][:3]) - status = f"{status}{kw_str}" - - ctx.send_text(status, record_message=False) - except Exception: - pass # 状态提示失败不影响工具执行 - - def handler(tool_name: str, arguments: dict) -> str: - tool = registry.get(tool_name) - if not tool: - return json.dumps( - {"error": f"Unknown tool: {tool_name}"}, - ensure_ascii=False, - ) - - _send_status(tool, arguments) - - try: - result = tool.handler(ctx, **arguments) - if not isinstance(result, str): - result = json.dumps(result, ensure_ascii=False) - return result - except Exception as e: - logger.error(f"工具 {tool_name} 执行失败: {e}", exc_info=True) - return json.dumps({"error": str(e)}, ensure_ascii=False) - - return handler - - -# ── 全局工具注册表 ────────────────────────────────────────── -tool_registry = ToolRegistry() diff --git a/tools/history.py b/tools/history.py deleted file mode 100644 index d271325..0000000 --- a/tools/history.py +++ /dev/null @@ -1,190 +0,0 @@ -"""聊天历史查询工具 —— 从 handlers.py 的内联定义中提取而来。 - -支持三种查询模式: - keywords — 关键词模糊搜索 - range — 按倒序偏移取连续消息 - time — 按时间窗口取消息 -""" - -import json - -from tools import Tool, tool_registry - -DEFAULT_VISIBLE_LIMIT = 30 - - -def _handle_lookup_chat_history(ctx, mode: str = "", keywords: list = None, - start_offset: int = None, end_offset: int = None, - start_time: str = None, end_time: str = None, - **_) -> str: - message_summary = getattr(ctx.robot, "message_summary", None) if ctx.robot else None - if not message_summary: - return json.dumps({"error": "消息历史功能不可用"}, ensure_ascii=False) - - chat_id = ctx.get_receiver() - visible_limit = DEFAULT_VISIBLE_LIMIT - raw = getattr(ctx, "specific_max_history", None) - if raw is not None: - try: - visible_limit = int(raw) - except (TypeError, ValueError): - pass - - # 推断模式 - mode = (mode or "").strip().lower() - if not mode: - if start_time and end_time: - mode = "time" - elif start_offset is not None and end_offset is not None: - mode = "range" - else: - mode = "keywords" - - # ── keywords ──────────────────────────────────────────── - if mode == "keywords": - if isinstance(keywords, str): - keywords = [keywords] - elif not isinstance(keywords, list): - keywords = [] - - cleaned = [] - seen = set() - for kw in keywords: - if kw is None: - continue - s = str(kw).strip() - if s and (len(s) > 1 or s.isdigit()): - low = s.lower() - if low not in seen: - seen.add(low) - cleaned.append(s) - - if not cleaned: - return json.dumps({"error": "未提供有效关键词", "results": []}, ensure_ascii=False) - - search_results = message_summary.search_messages_with_context( - chat_id=chat_id, - keywords=cleaned, - context_window=10, - max_groups=20, - exclude_recent=visible_limit, - ) - - segments = [] - lines_seen = set() - for seg in search_results: - formatted = [l for l in seg.get("formatted_messages", []) if l not in lines_seen] - lines_seen.update(formatted) - if formatted: - segments.append({ - "matched_keywords": seg.get("matched_keywords", []), - "messages": formatted, - }) - - payload = {"segments": segments, "returned_groups": len(segments), "keywords": cleaned} - if not segments: - payload["notice"] = "未找到匹配的消息。" - return json.dumps(payload, ensure_ascii=False) - - # ── range ─────────────────────────────────────────────── - if mode == "range": - if start_offset is None or end_offset is None: - return json.dumps({"error": "range 模式需要 start_offset 和 end_offset"}, ensure_ascii=False) - try: - start_offset, end_offset = int(start_offset), int(end_offset) - except (TypeError, ValueError): - return json.dumps({"error": "start_offset 和 end_offset 必须是整数"}, ensure_ascii=False) - - if start_offset <= visible_limit or end_offset <= visible_limit: - return json.dumps( - {"error": f"偏移量必须大于 {visible_limit} 以排除当前可见消息"}, - ensure_ascii=False, - ) - if start_offset > end_offset: - start_offset, end_offset = end_offset, start_offset - - result = message_summary.get_messages_by_reverse_range( - chat_id=chat_id, start_offset=start_offset, end_offset=end_offset, - ) - payload = { - "start_offset": result.get("start_offset"), - "end_offset": result.get("end_offset"), - "messages": result.get("messages", []), - "returned_count": result.get("returned_count", 0), - "total_messages": result.get("total_messages", 0), - } - if payload["returned_count"] == 0: - payload["notice"] = "请求范围内没有消息。" - return json.dumps(payload, ensure_ascii=False) - - # ── time ──────────────────────────────────────────────── - if mode == "time": - if not start_time or not end_time: - return json.dumps({"error": "time 模式需要 start_time 和 end_time"}, ensure_ascii=False) - - time_lines = message_summary.get_messages_by_time_window( - chat_id=chat_id, start_time=start_time, end_time=end_time, - ) - payload = { - "start_time": start_time, - "end_time": end_time, - "messages": time_lines, - "returned_count": len(time_lines), - } - if not time_lines: - payload["notice"] = "该时间范围内没有消息。" - return json.dumps(payload, ensure_ascii=False) - - return json.dumps({"error": f"不支持的模式: {mode}"}, ensure_ascii=False) - - -# ── 注册 ──────────────────────────────────────────────────── - -tool_registry.register(Tool( - name="lookup_chat_history", - status_text="正在翻阅聊天记录: ", - description=( - "查询聊天历史记录。你当前只能看到最近的消息,调用此工具可以回溯更早的上下文。" - "支持三种模式:\n" - "1. mode=\"keywords\" — 用关键词模糊搜索历史消息,返回匹配片段及上下文。" - " 需要 keywords 数组(2-4 个关键词)。\n" - "2. mode=\"range\" — 按倒序偏移获取连续消息块。" - " 需要 start_offset 和 end_offset(均需大于当前可见消息数)。\n" - "3. mode=\"time\" — 按时间窗口获取消息。" - " 需要 start_time 和 end_time(格式如 2025-05-01 08:00)。\n" - "可多次调用,例如先用 keywords 找到锚点,再用 range/time 扩展上下文。" - ), - parameters={ - "type": "object", - "properties": { - "mode": { - "type": "string", - "enum": ["keywords", "range", "time"], - "description": "查询模式", - }, - "keywords": { - "type": "array", - "items": {"type": "string"}, - "description": "mode=keywords 时的搜索关键词", - }, - "start_offset": { - "type": "integer", - "description": "mode=range 时的起始偏移(从最新消息倒数)", - }, - "end_offset": { - "type": "integer", - "description": "mode=range 时的结束偏移", - }, - "start_time": { - "type": "string", - "description": "mode=time 时的开始时间 (YYYY-MM-DD HH:MM)", - }, - "end_time": { - "type": "string", - "description": "mode=time 时的结束时间 (YYYY-MM-DD HH:MM)", - }, - }, - "additionalProperties": False, - }, - handler=_handle_lookup_chat_history, -)) diff --git a/tools/reminder.py b/tools/reminder.py deleted file mode 100644 index c7d7183..0000000 --- a/tools/reminder.py +++ /dev/null @@ -1,165 +0,0 @@ -"""提醒工具 —— 创建 / 查看 / 删除提醒。 - -LLM 直接传入结构化参数,不再需要二级路由或二次 AI 解析。 -""" - -import json -from datetime import datetime - -from tools import Tool, tool_registry - - -# ── 创建提醒 ──────────────────────────────────────────────── - -def _handle_reminder_create(ctx, type: str = "once", time: str = "", - content: str = "", weekday: int = None, **_) -> str: - if not hasattr(ctx.robot, "reminder_manager"): - return json.dumps({"error": "提醒管理器未初始化"}, ensure_ascii=False) - - if not time or not content: - return json.dumps({"error": "缺少必要字段: time 和 content"}, ensure_ascii=False) - - if len(content.strip()) < 2: - return json.dumps({"error": "提醒内容太短"}, ensure_ascii=False) - - # 校验时间格式 - if type == "once": - parsed_dt = None - for fmt in ("%Y-%m-%d %H:%M", "%Y-%m-%d %H:%M:%S"): - try: - parsed_dt = datetime.strptime(time, fmt) - break - except ValueError: - continue - if not parsed_dt: - return json.dumps({"error": f"once 类型时间格式应为 YYYY-MM-DD HH:MM,收到: {time}"}, ensure_ascii=False) - if parsed_dt < datetime.now(): - return json.dumps({"error": f"时间 {time} 已过去,请使用未来的时间"}, ensure_ascii=False) - time = parsed_dt.strftime("%Y-%m-%d %H:%M") - - elif type in ("daily", "weekly"): - parsed_time = None - for fmt in ("%H:%M", "%H:%M:%S"): - try: - parsed_time = datetime.strptime(time, fmt) - break - except ValueError: - continue - if not parsed_time: - return json.dumps({"error": f"daily/weekly 类型时间格式应为 HH:MM,收到: {time}"}, ensure_ascii=False) - time = parsed_time.strftime("%H:%M") - else: - return json.dumps({"error": f"不支持的提醒类型: {type}"}, ensure_ascii=False) - - if type == "weekly": - if weekday is None or not (isinstance(weekday, int) and 0 <= weekday <= 6): - return json.dumps({"error": "weekly 类型需要 weekday 参数 (0=周一 … 6=周日)"}, ensure_ascii=False) - - data = {"type": type, "time": time, "content": content, "extra": {}} - if weekday is not None: - data["weekday"] = weekday - - roomid = ctx.msg.roomid if ctx.is_group else None - success, result = ctx.robot.reminder_manager.add_reminder(ctx.msg.sender, data, roomid=roomid) - - if success: - type_label = {"once": "一次性", "daily": "每日", "weekly": "每周"}.get(type, type) - return json.dumps({"success": True, "id": result, - "message": f"已创建{type_label}提醒: {time} - {content}"}, ensure_ascii=False) - return json.dumps({"success": False, "error": result}, ensure_ascii=False) - - -# ── 查看提醒 ──────────────────────────────────────────────── - -def _handle_reminder_list(ctx, **_) -> str: - if not hasattr(ctx.robot, "reminder_manager"): - return json.dumps({"error": "提醒管理器未初始化"}, ensure_ascii=False) - - reminders = ctx.robot.reminder_manager.list_reminders(ctx.msg.sender) - if not reminders: - return json.dumps({"reminders": [], "message": "当前没有任何提醒"}, ensure_ascii=False) - return json.dumps({"reminders": reminders, "count": len(reminders)}, ensure_ascii=False) - - -# ── 删除提醒 ──────────────────────────────────────────────── - -def _handle_reminder_delete(ctx, reminder_id: str = "", delete_all: bool = False, **_) -> str: - if not hasattr(ctx.robot, "reminder_manager"): - return json.dumps({"error": "提醒管理器未初始化"}, ensure_ascii=False) - - if delete_all: - success, message, count = ctx.robot.reminder_manager.delete_all_reminders(ctx.msg.sender) - return json.dumps({"success": success, "message": message, "deleted_count": count}, ensure_ascii=False) - - if not reminder_id: - return json.dumps({"error": "请提供 reminder_id,或设置 delete_all=true 删除全部"}, ensure_ascii=False) - - success, message = ctx.robot.reminder_manager.delete_reminder(ctx.msg.sender, reminder_id) - return json.dumps({"success": success, "message": message}, ensure_ascii=False) - - -# ── 注册 ──────────────────────────────────────────────────── - -tool_registry.register(Tool( - name="reminder_create", - description=( - "创建提醒。支持 once(一次性)、daily(每日)、weekly(每周) 三种类型。" - "当前时间已在对话上下文中提供,请据此计算目标时间。" - ), - status_text="正在设置提醒...", - parameters={ - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["once", "daily", "weekly"], - "description": "提醒类型", - }, - "time": { - "type": "string", - "description": "once → YYYY-MM-DD HH:MM;daily/weekly → HH:MM", - }, - "content": { - "type": "string", - "description": "提醒内容", - }, - "weekday": { - "type": "integer", - "description": "仅 weekly 需要。0=周一 … 6=周日", - }, - }, - "required": ["type", "time", "content"], - "additionalProperties": False, - }, - handler=_handle_reminder_create, -)) - -tool_registry.register(Tool( - name="reminder_list", - description="查看当前用户的所有提醒列表。", - parameters={"type": "object", "properties": {}, "additionalProperties": False}, - handler=_handle_reminder_list, -)) - -tool_registry.register(Tool( - name="reminder_delete", - description=( - "删除提醒。需要先调用 reminder_list 获取 ID,再用 reminder_id 精确删除;" - "或设置 delete_all=true 一次性删除全部。" - ), - parameters={ - "type": "object", - "properties": { - "reminder_id": { - "type": "string", - "description": "要删除的提醒完整 ID", - }, - "delete_all": { - "type": "boolean", - "description": "是否删除该用户全部提醒", - }, - }, - "additionalProperties": False, - }, - handler=_handle_reminder_delete, -)) diff --git a/tools/web_search.py b/tools/web_search.py deleted file mode 100644 index 5b0f173..0000000 --- a/tools/web_search.py +++ /dev/null @@ -1,62 +0,0 @@ -"""网络搜索工具 —— 通过 Perplexity 联网搜索。 - -直接调用 perplexity.get_answer() 获取同步结果, -结果回传给 LLM 做综合回答,而非直接发送给用户。 -""" - -import json -import re - -from tools import Tool, tool_registry - - -def _handle_web_search(ctx, query: str = "", deep_research: bool = False, **_) -> str: - if not query: - return json.dumps({"error": "请提供搜索关键词"}, ensure_ascii=False) - - perplexity_instance = getattr(ctx.robot, "perplexity", None) - if not perplexity_instance: - return json.dumps({"error": "Perplexity 搜索功能不可用,未配置或未初始化"}, ensure_ascii=False) - - try: - chat_id = ctx.get_receiver() - response = perplexity_instance.get_answer(query, chat_id, deep_research=deep_research) - - if not response: - return json.dumps({"error": "搜索无结果"}, ensure_ascii=False) - - # 清理 标签(reasoning 模型可能返回) - cleaned = re.sub(r".*?", "", response, flags=re.DOTALL).strip() - if not cleaned: - cleaned = response - - return json.dumps({"result": cleaned}, ensure_ascii=False) - - except Exception as e: - return json.dumps({"error": f"搜索失败: {e}"}, ensure_ascii=False) - - -tool_registry.register(Tool( - name="web_search", - description=( - "在网络上搜索信息。用于回答需要最新数据、实时信息或你不确定的事实性问题。" - "deep_research 仅在问题非常复杂、需要深度研究时才开启。" - ), - status_text="正在联网搜索: ", - parameters={ - "type": "object", - "properties": { - "query": { - "type": "string", - "description": "搜索关键词或问题", - }, - "deep_research": { - "type": "boolean", - "description": "是否启用深度研究模式(耗时较长,仅用于复杂问题)", - }, - }, - "required": ["query"], - "additionalProperties": False, - }, - handler=_handle_web_search, -))