mirror of
https://github.com/xinnan-tech/xiaozhi-esp32-server.git
synced 2026-07-26 00:53:54 +08:00
Merge pull request #3176 from xinnan-tech/perf-tool-add-online-search
Perf:新增联网搜索工具类、修复新闻描述配置获取源问题
This commit is contained in:
@@ -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;
|
||||||
@@ -662,3 +662,10 @@ databaseChangeLog:
|
|||||||
- sqlFile:
|
- sqlFile:
|
||||||
encoding: utf8
|
encoding: utf8
|
||||||
path: classpath:db/changelog/202605081008.sql
|
path: classpath:db/changelog/202605081008.sql
|
||||||
|
- changeSet:
|
||||||
|
id: 202605121509
|
||||||
|
author: DaGou12138
|
||||||
|
changes:
|
||||||
|
- sqlFile:
|
||||||
|
encoding: utf8
|
||||||
|
path: classpath:db/changelog/202605121509.sql
|
||||||
|
|||||||
@@ -47,13 +47,6 @@ async def main():
|
|||||||
check_ffmpeg_installed()
|
check_ffmpeg_installed()
|
||||||
config = load_config()
|
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优先级:配置文件server.auth_key > manager-api.secret > 自动生成
|
||||||
# auth_key用于jwt认证,比如视觉分析接口的jwt认证、ota接口的token生成与websocket认证
|
# auth_key用于jwt认证,比如视觉分析接口的jwt认证、ota接口的token生成与websocket认证
|
||||||
# 获取配置文件中的auth_key
|
# 获取配置文件中的auth_key
|
||||||
|
|||||||
@@ -171,6 +171,19 @@ plugins:
|
|||||||
api_key: "ragflow-xxx"
|
api_key: "ragflow-xxx"
|
||||||
# ragflow知识库id
|
# ragflow知识库id
|
||||||
dataset_ids: ["123456789"]
|
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:
|
voiceprint:
|
||||||
# 声纹接口地址
|
# 声纹接口地址
|
||||||
@@ -253,6 +266,7 @@ Intent:
|
|||||||
# 系统默认已经记载"handle_exit_intent(退出识别)"、"play_music(音乐播放)"插件,请勿重复加载
|
# 系统默认已经记载"handle_exit_intent(退出识别)"、"play_music(音乐播放)"插件,请勿重复加载
|
||||||
# 下面是加载查天气、角色切换、加载查新闻的插件示例
|
# 下面是加载查天气、角色切换、加载查新闻的插件示例
|
||||||
functions:
|
functions:
|
||||||
|
- web_search
|
||||||
- get_weather
|
- get_weather
|
||||||
- get_news_from_newsnow
|
- get_news_from_newsnow
|
||||||
- play_music
|
- play_music
|
||||||
@@ -264,6 +278,7 @@ Intent:
|
|||||||
# 下面是加载查天气、角色切换、加载查新闻的插件示例
|
# 下面是加载查天气、角色切换、加载查新闻的插件示例
|
||||||
functions:
|
functions:
|
||||||
- change_role
|
- change_role
|
||||||
|
- web_search # 联网搜索
|
||||||
- get_weather
|
- get_weather
|
||||||
# - search_from_ragflow
|
# - search_from_ragflow
|
||||||
# - get_news_from_chinanews
|
# - get_news_from_chinanews
|
||||||
|
|||||||
@@ -87,6 +87,11 @@ class ServerPluginExecutor(ToolExecutor):
|
|||||||
func_item.description["function"][
|
func_item.description["function"][
|
||||||
"description"
|
"description"
|
||||||
] = fun_description
|
] = fun_description
|
||||||
|
|
||||||
|
# 新闻插件:根据配置更新新闻源参数描述
|
||||||
|
if func_name == "get_news_from_newsnow":
|
||||||
|
self._init_news_source_description(func_item, func_name)
|
||||||
|
|
||||||
tools[func_name] = ToolDefinition(
|
tools[func_name] = ToolDefinition(
|
||||||
name=func_name,
|
name=func_name,
|
||||||
description=func_item.description,
|
description=func_item.description,
|
||||||
@@ -98,3 +103,20 @@ class ServerPluginExecutor(ToolExecutor):
|
|||||||
def has_tool(self, tool_name: str) -> bool:
|
def has_tool(self, tool_name: str) -> bool:
|
||||||
"""检查是否有指定的服务端插件工具"""
|
"""检查是否有指定的服务端插件工具"""
|
||||||
return tool_name in all_function_registry
|
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
|
||||||
|
|||||||
@@ -55,15 +55,7 @@ CHANNEL_MAP = {
|
|||||||
DEFAULT_NEWS_SOURCES = "澎湃新闻;百度热搜;财联社"
|
DEFAULT_NEWS_SOURCES = "澎湃新闻;百度热搜;财联社"
|
||||||
|
|
||||||
def _get_newsnow_config(conn):
|
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", {})
|
plugins = conn.config.get("plugins", {})
|
||||||
newsnow = plugins.get("get_news_from_newsnow", {})
|
newsnow = plugins.get("get_news_from_newsnow", {})
|
||||||
sources = newsnow.get("news_sources", "")
|
sources = newsnow.get("news_sources", "")
|
||||||
@@ -91,24 +83,6 @@ def get_news_sources_from_config(conn):
|
|||||||
# 从默认配置获取可用的新闻源名称(运行时由get_news_sources_from_config动态获取)
|
# 从默认配置获取可用的新闻源名称(运行时由get_news_sources_from_config动态获取)
|
||||||
example_sources_str = DEFAULT_NEWS_SOURCES.replace(";","、")
|
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 = {
|
GET_NEWS_FROM_NEWSNOW_FUNCTION_DESC = {
|
||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user