diff --git a/astrbot/builtin_stars/web_searcher/main.py b/astrbot/builtin_stars/web_searcher/main.py index e8388c8161..12c8f68b38 100644 --- a/astrbot/builtin_stars/web_searcher/main.py +++ b/astrbot/builtin_stars/web_searcher/main.py @@ -23,6 +23,7 @@ class Main(star.Star): "fetch_url", "web_search_tavily", "tavily_extract_web_page", + "web_search_bocha", ] def __init__(self, context: star.Context) -> None: @@ -30,6 +31,9 @@ def __init__(self, context: star.Context) -> None: self.tavily_key_index = 0 self.tavily_key_lock = asyncio.Lock() + self.bocha_key_index = 0 + self.bocha_key_lock = asyncio.Lock() + # 将 str 类型的 key 迁移至 list[str],并保存 cfg = self.context.get_config() provider_settings = cfg.get("provider_settings") @@ -45,6 +49,14 @@ def __init__(self, context: star.Context) -> None: provider_settings["websearch_tavily_key"] = [] cfg.save_config() + bocha_key = provider_settings.get("websearch_bocha_key") + if isinstance(bocha_key, str): + if bocha_key: + provider_settings["websearch_bocha_key"] = [bocha_key] + else: + provider_settings["websearch_bocha_key"] = [] + cfg.save_config() + self.bing_search = Bing() self.sogo_search = Sogo() self.baidu_initialized = False @@ -341,7 +353,7 @@ async def search_from_tavily( } ) if result.favicon: - sp.temorary_cache["_ws_favicon"][result.url] = result.favicon + sp.temporary_cache["_ws_favicon"][result.url] = result.favicon # ret = "\n".join(ret_ls) ret = json.dumps({"results": ret_ls}, ensure_ascii=False) return ret @@ -382,6 +394,160 @@ async def tavily_extract_web_page( return "Error: Tavily web searcher does not return any results." return ret + async def _get_bocha_key(self, cfg: AstrBotConfig) -> str: + """并发安全的从列表中获取并轮换BoCha API密钥。""" + bocha_keys = cfg.get("provider_settings", {}).get("websearch_bocha_key", []) + if not bocha_keys: + raise ValueError("错误:BoCha API密钥未在AstrBot中配置。") + + async with self.bocha_key_lock: + key = bocha_keys[self.bocha_key_index] + self.bocha_key_index = (self.bocha_key_index + 1) % len(bocha_keys) + return key + + async def _web_search_bocha( + self, + cfg: AstrBotConfig, + payload: dict, + ) -> list[SearchResult]: + """使用 BoCha 搜索引擎进行搜索""" + bocha_key = await self._get_bocha_key(cfg) + url = "https://api.bochaai.com/v1/web-search" + header = { + "Authorization": f"Bearer {bocha_key}", + "Content-Type": "application/json", + } + async with aiohttp.ClientSession(trust_env=True) as session: + async with session.post( + url, + json=payload, + headers=header, + ) as response: + if response.status != 200: + reason = await response.text() + raise Exception( + f"BoCha web search failed: {reason}, status: {response.status}", + ) + data = await response.json() + data = data["data"]["webPages"]["value"] + results = [] + for item in data: + result = SearchResult( + title=item.get("name"), + url=item.get("url"), + snippet=item.get("snippet"), + favicon=item.get("siteIcon"), + ) + results.append(result) + return results + + @llm_tool("web_search_bocha") + async def search_from_bocha( + self, + event: AstrMessageEvent, + query: str, + freshness: str = "noLimit", + summary: bool = False, + include: str = "", + exclude: str = "", + count: int = 10, + ) -> str: + """ + A web search tool based on Bocha Search API, used to retrieve web pages + related to the user's query. + + Args: + query (string): Required. User's search query. + + freshness (string): Optional. Specifies the time range of the search. + Supported values: + - "noLimit": No time limit (default, recommended). + - "oneDay": Within one day. + - "oneWeek": Within one week. + - "oneMonth": Within one month. + - "oneYear": Within one year. + - "YYYY-MM-DD..YYYY-MM-DD": Search within a specific date range. + Example: "2025-01-01..2025-04-06". + - "YYYY-MM-DD": Search on a specific date. + Example: "2025-04-06". + It is recommended to use "noLimit", as the search algorithm will + automatically optimize time relevance. Manually restricting the + time range may result in no search results. + + summary (boolean): Optional. Whether to include a text summary + for each search result. + - True: Include summary. + - False: Do not include summary (default). + + include (string): Optional. Specifies the domains to include in + the search. Multiple domains can be separated by "|" or ",". + A maximum of 100 domains is allowed. + Examples: + - "qq.com" + - "qq.com|m.163.com" + + exclude (string): Optional. Specifies the domains to exclude from + the search. Multiple domains can be separated by "|" or ",". + A maximum of 100 domains is allowed. + Examples: + - "qq.com" + - "qq.com|m.163.com" + + count (number): Optional. Number of search results to return. + - Range: 1–50 + - Default: 10 + The actual number of returned results may be less than the + specified count. + """ + logger.info(f"web_searcher - search_from_bocha: {query}") + cfg = self.context.get_config(umo=event.unified_msg_origin) + # websearch_link = cfg["provider_settings"].get("web_search_link", False) + if not cfg.get("provider_settings", {}).get("websearch_bocha_key", []): + raise ValueError("Error: BoCha API key is not configured in AstrBot.") + + # build payload + payload = { + "query": query, + "count": count, + } + + # freshness:时间范围 + if freshness: + payload["freshness"] = freshness + + # 是否返回摘要 + payload["summary"] = summary + + # include:限制搜索域 + if include: + payload["include"] = include + + # exclude:排除搜索域 + if exclude: + payload["exclude"] = exclude + + results = await self._web_search_bocha(cfg, payload) + if not results: + return "Error: BoCha web searcher does not return any results." + + ret_ls = [] + ref_uuid = str(uuid.uuid4())[:4] + for idx, result in enumerate(results, 1): + index = f"{ref_uuid}.{idx}" + ret_ls.append( + { + "title": f"{result.title}", + "url": f"{result.url}", + "snippet": f"{result.snippet}", + "index": index, + } + ) + if result.favicon: + sp.temporary_cache["_ws_favicon"][result.url] = result.favicon + # ret = "\n".join(ret_ls) + ret = json.dumps({"results": ret_ls}, ensure_ascii=False) + return ret + @filter.on_llm_request(priority=-10000) async def edit_web_search_tools( self, @@ -419,6 +585,7 @@ async def edit_web_search_tools( tool_set.remove_tool("web_search_tavily") tool_set.remove_tool("tavily_extract_web_page") tool_set.remove_tool("AIsearch") + tool_set.remove_tool("web_search_bocha") elif provider == "tavily": web_search_tavily = func_tool_mgr.get_func("web_search_tavily") tavily_extract_web_page = func_tool_mgr.get_func("tavily_extract_web_page") @@ -429,6 +596,7 @@ async def edit_web_search_tools( tool_set.remove_tool("web_search") tool_set.remove_tool("fetch_url") tool_set.remove_tool("AIsearch") + tool_set.remove_tool("web_search_bocha") elif provider == "baidu_ai_search": try: await self.ensure_baidu_ai_search_mcp(event.unified_msg_origin) @@ -440,5 +608,15 @@ async def edit_web_search_tools( tool_set.remove_tool("fetch_url") tool_set.remove_tool("web_search_tavily") tool_set.remove_tool("tavily_extract_web_page") + tool_set.remove_tool("web_search_bocha") except Exception as e: logger.error(f"Cannot Initialize Baidu AI Search MCP Server: {e}") + elif provider == "bocha": + web_search_bocha = func_tool_mgr.get_func("web_search_bocha") + if web_search_bocha: + tool_set.add_tool(web_search_bocha) + tool_set.remove_tool("web_search") + tool_set.remove_tool("fetch_url") + tool_set.remove_tool("AIsearch") + tool_set.remove_tool("web_search_tavily") + tool_set.remove_tool("tavily_extract_web_page") diff --git a/astrbot/core/astr_agent_hooks.py b/astrbot/core/astr_agent_hooks.py index 717d4a3e19..1bd8c451e2 100644 --- a/astrbot/core/astr_agent_hooks.py +++ b/astrbot/core/astr_agent_hooks.py @@ -59,7 +59,7 @@ async def on_tool_end( platform_name = run_context.context.event.get_platform_name() if ( platform_name == "webchat" - and tool.name == "web_search_tavily" + and tool.name in ["web_search_tavily", "web_search_bocha"] and len(run_context.messages) > 0 and tool_result and len(tool_result.content) diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 10a6fc5994..6d886d3ded 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -74,6 +74,7 @@ "web_search": False, "websearch_provider": "default", "websearch_tavily_key": [], + "websearch_bocha_key": [], "websearch_baidu_app_builder_key": "", "web_search_link": False, "display_reasoning_text": False, @@ -2563,7 +2564,7 @@ class ChatProviderTemplate(TypedDict): "provider_settings.websearch_provider": { "description": "网页搜索提供商", "type": "string", - "options": ["default", "tavily", "baidu_ai_search"], + "options": ["default", "tavily", "baidu_ai_search", "bocha"], "condition": { "provider_settings.web_search": True, }, @@ -2578,6 +2579,16 @@ class ChatProviderTemplate(TypedDict): "provider_settings.web_search": True, }, }, + "provider_settings.websearch_bocha_key": { + "description": "BoCha API Key", + "type": "list", + "items": {"type": "string"}, + "hint": "可添加多个 Key 进行轮询。", + "condition": { + "provider_settings.websearch_provider": "bocha", + "provider_settings.web_search": True, + }, + }, "provider_settings.websearch_baidu_app_builder_key": { "description": "百度千帆智能云 APP Builder API Key", "type": "string", diff --git a/astrbot/dashboard/routes/chat.py b/astrbot/dashboard/routes/chat.py index 92ff4c3fe5..696ffe6139 100644 --- a/astrbot/dashboard/routes/chat.py +++ b/astrbot/dashboard/routes/chat.py @@ -238,6 +238,7 @@ def _extract_web_search_refs( Returns: 包含 used 列表的字典,记录被引用的搜索结果 """ + supported = ["web_search_tavily", "web_search_bocha"] # 从 accumulated_parts 中找到所有 web_search_tavily 的工具调用结果 web_search_results = {} tool_call_parts = [ @@ -248,7 +249,7 @@ def _extract_web_search_refs( for part in tool_call_parts: for tool_call in part["tool_calls"]: - if tool_call.get("name") != "web_search_tavily" or not tool_call.get( + if tool_call.get("name") not in supported or not tool_call.get( "result" ): continue @@ -278,7 +279,7 @@ def _extract_web_search_refs( if ref_index not in web_search_results: continue payload = {"index": ref_index, **web_search_results[ref_index]} - if favicon := sp.temorary_cache.get("_ws_favicon", {}).get(payload["url"]): + if favicon := sp.temporary_cache.get("_ws_favicon", {}).get(payload["url"]): payload["favicon"] = favicon used_refs.append(payload) diff --git a/dashboard/src/i18n/locales/en-US/features/config-metadata.json b/dashboard/src/i18n/locales/en-US/features/config-metadata.json index 9b4c4e3049..6838c31b11 100644 --- a/dashboard/src/i18n/locales/en-US/features/config-metadata.json +++ b/dashboard/src/i18n/locales/en-US/features/config-metadata.json @@ -108,6 +108,10 @@ "description": "Tavily API Key", "hint": "Multiple keys can be added for rotation." }, + "websearch_bocha_key": { + "description": "BoCha API Key", + "hint": "Multiple keys can be added for rotation." + }, "websearch_baidu_app_builder_key": { "description": "Baidu Qianfan Smart Cloud APP Builder API Key", "hint": "Reference: [https://console.bce.baidu.com/iam/#/iam/apikey/list](https://console.bce.baidu.com/iam/#/iam/apikey/list)" diff --git a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json index 6620f1cb30..def26a2917 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json @@ -111,6 +111,10 @@ "description": "Tavily API Key", "hint": "可添加多个 Key 进行轮询。" }, + "websearch_bocha_key": { + "description": "BoCha API Key", + "hint": "可添加多个 Key 进行轮询。" + }, "websearch_baidu_app_builder_key": { "description": "百度千帆智能云 APP Builder API Key", "hint": "参考:[https://console.bce.baidu.com/iam/#/iam/apikey/list](https://console.bce.baidu.com/iam/#/iam/apikey/list)"