diff --git a/main/manager-api/src/main/resources/db/changelog/202605121509.sql b/main/manager-api/src/main/resources/db/changelog/202605121509.sql new file mode 100644 index 00000000..c8c2dc66 --- /dev/null +++ b/main/manager-api/src/main/resources/db/changelog/202605121509.sql @@ -0,0 +1,6 @@ +-- 新增联网搜索工具配置字段 +SET @data_exists = (SELECT COUNT(*) FROM ai_model_provider WHERE id = 'SYSTEM_PLUGIN_WEB_SEARCH'); +SET @sql = IF(@data_exists = 0, + 'INSERT INTO `ai_model_provider` (`id`, `model_type`, `provider_code`, `name`, `fields`, `sort`, `creator`, `create_date`, `updater`, `update_date`) VALUES (''SYSTEM_PLUGIN_WEB_SEARCH'', ''Plugin'', ''web_search'', ''联网搜索'', ''[{\"key\": \"provider\", \"type\": \"string\", \"label\": \"搜索源:metaso / tavily\", \"default\": \"metaso\", \"editing\": false, \"selected\": false}, {\"key\": \"description\", \"type\": \"string\", \"label\": \"工具描述\", \"default\": \"联网搜索工具。当用户明确需要联网搜索问题时使用此工具。\", \"editing\": false, \"selected\": false}, {\"key\": \"max_results\", \"type\": \"string\", \"label\": \"返回数量\", \"default\": \"5\", \"editing\": false, \"selected\": false}, {\"key\": \"api_key\", \"type\": \"string\", \"label\": \"apiKey\", \"default\": \"mk-XXXX\", \"editing\": false, \"selected\": false}]'', 80, 1988490863118454785, ''2026-05-11 17:15:35'', 1988490863118454785, ''2026-05-12 10:55:49'')', + 'SELECT ''data already exists, skip'' AS msg'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; \ No newline at end of file diff --git a/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml b/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml index c645c111..ee1d9109 100755 --- a/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml +++ b/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml @@ -662,3 +662,10 @@ databaseChangeLog: - sqlFile: encoding: utf8 path: classpath:db/changelog/202605081008.sql + - changeSet: + id: 202605121509 + author: DaGou12138 + changes: + - sqlFile: + encoding: utf8 + path: classpath:db/changelog/202605121509.sql diff --git a/main/xiaozhi-server/app.py b/main/xiaozhi-server/app.py index 8b41ed93..bdefe42d 100644 --- a/main/xiaozhi-server/app.py +++ b/main/xiaozhi-server/app.py @@ -47,13 +47,6 @@ async def main(): check_ffmpeg_installed() config = load_config() - # 初始化插件描述(需要在load_config之后、服务启动之前) - try: - from plugins_func.functions.get_news_from_newsnow import init_news_description - init_news_description(config) - except Exception: - pass - # auth_key优先级:配置文件server.auth_key > manager-api.secret > 自动生成 # auth_key用于jwt认证,比如视觉分析接口的jwt认证、ota接口的token生成与websocket认证 # 获取配置文件中的auth_key diff --git a/main/xiaozhi-server/config.yaml b/main/xiaozhi-server/config.yaml index d593892f..802dcb50 100644 --- a/main/xiaozhi-server/config.yaml +++ b/main/xiaozhi-server/config.yaml @@ -171,6 +171,19 @@ plugins: api_key: "ragflow-xxx" # ragflow知识库id dataset_ids: ["123456789"] + # 联网搜索插件配置 + # 支持秘塔(metaso)和Tavily两个搜索源 + # 秘塔API Key申请:https://metaso.cn/search-api/api-keys + # Tavily API Key申请:https://app.tavily.com/home + web_search: + # 搜索源:metaso / tavily + provider: "metaso" + # 工具描述,用于告知LLM何时调用此工具,可根据需要自定义 + description: "联网搜索工具。当用户明确需要联网搜索问题时使用此工具。" + # 返回结果数量(默认5) + max_results: 5 + # 秘塔API Key(mk-前缀) / Tavily API Key(tvly-前缀) + api_key: "mk-xxx" # 声纹识别配置 voiceprint: # 声纹接口地址 @@ -253,6 +266,7 @@ Intent: # 系统默认已经记载"handle_exit_intent(退出识别)"、"play_music(音乐播放)"插件,请勿重复加载 # 下面是加载查天气、角色切换、加载查新闻的插件示例 functions: + - web_search - get_weather - get_news_from_newsnow - play_music @@ -264,6 +278,7 @@ Intent: # 下面是加载查天气、角色切换、加载查新闻的插件示例 functions: - change_role + - web_search # 联网搜索 - get_weather # - search_from_ragflow # - get_news_from_chinanews diff --git a/main/xiaozhi-server/core/providers/tools/server_plugins/plugin_executor.py b/main/xiaozhi-server/core/providers/tools/server_plugins/plugin_executor.py index c35c9977..249e040c 100644 --- a/main/xiaozhi-server/core/providers/tools/server_plugins/plugin_executor.py +++ b/main/xiaozhi-server/core/providers/tools/server_plugins/plugin_executor.py @@ -87,6 +87,11 @@ class ServerPluginExecutor(ToolExecutor): func_item.description["function"][ "description" ] = fun_description + + # 新闻插件:根据配置更新新闻源参数描述 + if func_name == "get_news_from_newsnow": + self._init_news_source_description(func_item, func_name) + tools[func_name] = ToolDefinition( name=func_name, description=func_item.description, @@ -98,3 +103,20 @@ class ServerPluginExecutor(ToolExecutor): def has_tool(self, tool_name: str) -> bool: """检查是否有指定的服务端插件工具""" return tool_name in all_function_registry + + def _init_news_source_description(self, func_item, func_name): + """根据连接配置初始化新闻工具的参数描述""" + news_sources = ( + self.config.get("plugins", {}) + .get(func_name, {}) + .get("news_sources", "") + ) + if not news_sources: + news_sources = "澎湃新闻;百度热搜;财联社" + sources_str = news_sources.replace(";", "、") + try: + func_item.description["function"]["parameters"]["properties"]["source"][ + "description" + ] = f"新闻源的标准中文名称,例如{sources_str}等。可选参数,如果不提供则使用默认新闻源" + except (KeyError, TypeError): + pass diff --git a/main/xiaozhi-server/plugins_func/functions/get_news_from_newsnow.py b/main/xiaozhi-server/plugins_func/functions/get_news_from_newsnow.py index 372ea48d..cbf48cf0 100644 --- a/main/xiaozhi-server/plugins_func/functions/get_news_from_newsnow.py +++ b/main/xiaozhi-server/plugins_func/functions/get_news_from_newsnow.py @@ -55,15 +55,7 @@ CHANNEL_MAP = { DEFAULT_NEWS_SOURCES = "澎湃新闻;百度热搜;财联社" def _get_newsnow_config(conn): - """从连接配置中获取newsnow插件配置,优先用conn.common_config,兜底用conn.config""" - # 优先从公共配置获取(保留本地config.yaml的配置) - common_plugins = getattr(conn, "common_config", {}).get("plugins", {}) - common_newsnow = common_plugins.get("get_news_from_newsnow", {}) - common_sources = common_newsnow.get("news_sources", "") - if isinstance(common_sources, str) and common_sources.strip(): - return common_sources - - # 兜底从连接配置获取 + # 从连接配置获取 plugins = conn.config.get("plugins", {}) newsnow = plugins.get("get_news_from_newsnow", {}) sources = newsnow.get("news_sources", "") @@ -91,24 +83,6 @@ def get_news_sources_from_config(conn): # 从默认配置获取可用的新闻源名称(运行时由get_news_sources_from_config动态获取) example_sources_str = DEFAULT_NEWS_SOURCES.replace(";","、") -def init_news_description(config: dict): - """项目启动时调用一次,根据配置更新工具描述中的新闻源示例""" - from types import SimpleNamespace - from plugins_func.register import all_function_registry - - # 复用get_news_sources_from_config,用SimpleNamespace模拟conn - conn_wrapper = SimpleNamespace(config=config) - news_sources = get_news_sources_from_config(conn_wrapper) - - sources_str = news_sources.replace(";","、") - - func_item = all_function_registry.get("get_news_from_newsnow") - if func_item: - func_item.description["function"]["parameters"]["properties"]["source"][ - "description" - ] = f"新闻源的标准中文名称,例如{sources_str}等。可选参数,如果不提供则使用默认新闻源" - logger.bind(tag=TAG).info(f"新闻工具描述已初始化,可用新闻源: {sources_str}") - GET_NEWS_FROM_NEWSNOW_FUNCTION_DESC = { "type": "function", "function": { diff --git a/main/xiaozhi-server/plugins_func/functions/web_search.py b/main/xiaozhi-server/plugins_func/functions/web_search.py new file mode 100644 index 00000000..6411a0ed --- /dev/null +++ b/main/xiaozhi-server/plugins_func/functions/web_search.py @@ -0,0 +1,158 @@ +import requests +from config.logger import setup_logging +from plugins_func.register import ( + register_function, + ToolType, + ActionResponse, + Action, +) +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from core.connection import ConnectionHandler + +TAG = __name__ +logger = setup_logging() + +_DEFAULT_DESCRIPTION = ( + "联网搜索工具。当用户明确需要联网搜索问题时使用此工具。" +) + +WEB_SEARCH_FUNCTION_DESC = { + "type": "function", + "function": { + "name": "web_search", + "description": _DEFAULT_DESCRIPTION, + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "搜索关键词或问题", + } + }, + "required": ["query"], + }, + }, +} + + +def _search_metaso(api_key: str, query: str, max_results: int) -> str: + """调用秘塔搜索API""" + url = "https://metaso.cn/api/v1/search" + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + payload = { + "q": query, + "size": max_results, + "stream": False, + "scope": "webpage", + "includeSummary": True, + "includeRawContent": False, + "conciseSnippet": False, + } + logger.bind(tag=TAG).debug(f"秘塔搜索请求 | URL: {url} | payload: {payload}") + response = requests.post(url, json=payload, headers=headers, timeout=15) + response.raise_for_status() + data = response.json() + logger.bind(tag=TAG).debug(f"秘塔搜索响应 | status: {response.status_code}") + + webpages = data.get("webpages", []) + if not webpages: + return "未找到相关搜索结果。" + + lines = ["【联网搜索结果】"] + for i, item in enumerate(webpages, 1): + title = item.get("title", "无标题") + snippet = item.get("summary", "") + date = item.get("date", "") + lines.append(f"{i}. 标题:{title}") + if date: + lines.append(f" 日期:{date}") + if snippet: + lines.append(f" 摘要:{snippet}") + + return "\n".join(lines) + + +def _search_tavily(api_key: str, query: str, max_results: int) -> str: + """调用Tavily搜索API""" + url = "https://api.tavily.com/search" + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + payload = { + "query": query, + "max_results": max_results, + "search_depth": "advanced", + "include_answer": "advanced", + } + logger.bind(tag=TAG).debug(f"Tavily搜索请求 | URL: {url} | payload: {payload}") + response = requests.post(url, json=payload, headers=headers, timeout=15) + response.raise_for_status() + data = response.json() + logger.bind(tag=TAG).debug(f"Tavily搜索响应 | status: {response.status_code} | data: {data}") + + results = data.get("results", []) + if not results: + return "未找到相关搜索结果。" + + answer = data.get("answer", "") + lines = [f"【联网搜索结果】\n总结:{answer}"] + # for i, item in enumerate(results, 1): + # title = item.get("title", "无标题") + # summary = item.get("content", "") + # lines.append(f"{i}. 标题:{title}") + # if summary: + # lines.append(f" 摘要:{summary}") + + return "\n".join(lines) + + +@register_function("web_search", WEB_SEARCH_FUNCTION_DESC, ToolType.SYSTEM_CTL) +def web_search(conn: "ConnectionHandler", query: str = None): + logger.bind(tag=TAG).info(f"web_search 被调用 | query={query}") + if not query: + return ActionResponse(Action.REQLLM, "请提供搜索关键词。", None) + + web_search_config = conn.config.get("plugins", {}).get("web_search", {}) + provider = web_search_config.get("provider", "").lower() + max_results = int(web_search_config.get("max_results", 3)) + logger.bind(tag=TAG).info(f"web_search 配置 | provider={provider} | max_results={max_results} | config_keys={list(web_search_config.keys())}") + + api_key = web_search_config.get("api_key", "") + if not api_key: + return ActionResponse( + Action.REQLLM, + "联网搜索功能未配置API Key,请在配置文件中填写。", + None, + ) + + if provider == "metaso": + search_fn = lambda: _search_metaso(api_key, query, max_results) + elif provider == "tavily": + search_fn = lambda: _search_tavily(api_key, query, max_results) + else: + return ActionResponse( + Action.REQLLM, + f"联网搜索功能未配置或配置的搜索源无效(当前:{provider}),请检查配置。", + None, + ) + + try: + result_text = search_fn() + logger.bind(tag=TAG).info(f"搜索结果组装完成:\n{result_text}") + except requests.exceptions.Timeout: + logger.bind(tag=TAG).error("联网搜索请求超时") + result_text = "联网搜索请求超时,请稍后重试。" + except requests.exceptions.RequestException as e: + logger.bind(tag=TAG).error(f"联网搜索请求失败: {e}") + result_text = "联网搜索请求失败,请稍后重试。" + except Exception as e: + logger.bind(tag=TAG).error(f"联网搜索异常: {e}") + result_text = "联网搜索出现异常,请稍后重试。" + + return ActionResponse(Action.REQLLM, result_text, None)