From 1b36d75a17b4c4751828f31f6759357cd2d4000a Mon Sep 17 00:00:00 2001 From: Jintao Wei <2797154930@qq.com> Date: Fri, 6 Feb 2026 10:00:29 +0800 Subject: [PATCH 1/5] add bocha web search tool --- astrbot/builtin_stars/web_searcher/main.py | 181 +++++++++++++++++- astrbot/core/config/default.py | 13 +- .../en-US/features/config-metadata.json | 4 + .../zh-CN/features/config-metadata.json | 4 + 4 files changed, 200 insertions(+), 2 deletions(-) diff --git a/astrbot/builtin_stars/web_searcher/main.py b/astrbot/builtin_stars/web_searcher/main.py index e8388c8161..0862277a90 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 @@ -382,6 +394,161 @@ 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() + print(data) + 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: Tavily 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.temorary_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, @@ -407,7 +574,6 @@ async def edit_web_search_tools( for tool_name in self.TOOLS: tool_set.remove_tool(tool_name) return - func_tool_mgr = self.context.get_llm_tool_manager() if provider == "default": web_search_t = func_tool_mgr.get_func("web_search") @@ -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,16 @@ 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/config/default.py b/astrbot/core/config/default.py index 10a6fc5994..05a629ef4e 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, }, @@ -2586,6 +2587,16 @@ class ChatProviderTemplate(TypedDict): "provider_settings.websearch_provider": "baidu_ai_search", }, }, + "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.web_search_link": { "description": "显示来源引用", "type": "bool", 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)" From fbe32ff6a637ce187237bfc7d6b576c7cf5ecf95 Mon Sep 17 00:00:00 2001 From: Jintao Wei <2797154930@qq.com> Date: Fri, 6 Feb 2026 10:26:14 +0800 Subject: [PATCH 2/5] Revert "add bocha web search tool" This reverts commit 1b36d75a17b4c4751828f31f6759357cd2d4000a. --- astrbot/builtin_stars/web_searcher/main.py | 181 +----------------- astrbot/core/config/default.py | 13 +- .../en-US/features/config-metadata.json | 4 - .../zh-CN/features/config-metadata.json | 4 - 4 files changed, 2 insertions(+), 200 deletions(-) diff --git a/astrbot/builtin_stars/web_searcher/main.py b/astrbot/builtin_stars/web_searcher/main.py index 0862277a90..e8388c8161 100644 --- a/astrbot/builtin_stars/web_searcher/main.py +++ b/astrbot/builtin_stars/web_searcher/main.py @@ -23,7 +23,6 @@ class Main(star.Star): "fetch_url", "web_search_tavily", "tavily_extract_web_page", - "web_search_bocha", ] def __init__(self, context: star.Context) -> None: @@ -31,9 +30,6 @@ 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") @@ -49,14 +45,6 @@ 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 @@ -394,161 +382,6 @@ 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() - print(data) - 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: Tavily 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.temorary_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, @@ -574,6 +407,7 @@ async def edit_web_search_tools( for tool_name in self.TOOLS: tool_set.remove_tool(tool_name) return + func_tool_mgr = self.context.get_llm_tool_manager() if provider == "default": web_search_t = func_tool_mgr.get_func("web_search") @@ -585,7 +419,6 @@ 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") @@ -596,7 +429,6 @@ 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) @@ -608,16 +440,5 @@ 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/config/default.py b/astrbot/core/config/default.py index 05a629ef4e..10a6fc5994 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -74,7 +74,6 @@ "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, @@ -2564,7 +2563,7 @@ class ChatProviderTemplate(TypedDict): "provider_settings.websearch_provider": { "description": "网页搜索提供商", "type": "string", - "options": ["default", "tavily", "baidu_ai_search", "bocha"], + "options": ["default", "tavily", "baidu_ai_search"], "condition": { "provider_settings.web_search": True, }, @@ -2587,16 +2586,6 @@ class ChatProviderTemplate(TypedDict): "provider_settings.websearch_provider": "baidu_ai_search", }, }, - "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.web_search_link": { "description": "显示来源引用", "type": "bool", 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 6838c31b11..9b4c4e3049 100644 --- a/dashboard/src/i18n/locales/en-US/features/config-metadata.json +++ b/dashboard/src/i18n/locales/en-US/features/config-metadata.json @@ -108,10 +108,6 @@ "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 def26a2917..6620f1cb30 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json @@ -111,10 +111,6 @@ "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)" From 0aee049bea31eaae126c8887500693064ebfc145 Mon Sep 17 00:00:00 2001 From: Jintao Wei <2797154930@qq.com> Date: Fri, 6 Feb 2026 10:48:20 +0800 Subject: [PATCH 3/5] add bocha web search tool --- astrbot/builtin_stars/web_searcher/main.py | 178 ++++++++++++++++++ astrbot/core/config/default.py | 13 +- .../en-US/features/config-metadata.json | 4 + .../zh-CN/features/config-metadata.json | 4 + 4 files changed, 198 insertions(+), 1 deletion(-) diff --git a/astrbot/builtin_stars/web_searcher/main.py b/astrbot/builtin_stars/web_searcher/main.py index e8388c8161..d7f57860c0 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 @@ -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.temorary_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/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/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)" From 3f646385111c874c8ac8acbb694a5afabec9b85b Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Fri, 6 Feb 2026 21:41:44 +0800 Subject: [PATCH 4/5] fix: correct temporary_cache spelling and update supported tools for web search --- astrbot/builtin_stars/web_searcher/main.py | 4 ++-- astrbot/core/astr_agent_hooks.py | 2 +- astrbot/dashboard/routes/chat.py | 5 +++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/astrbot/builtin_stars/web_searcher/main.py b/astrbot/builtin_stars/web_searcher/main.py index d7f57860c0..9834eaabdd 100644 --- a/astrbot/builtin_stars/web_searcher/main.py +++ b/astrbot/builtin_stars/web_searcher/main.py @@ -353,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 @@ -543,7 +543,7 @@ async def search_from_bocha( } ) 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 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/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) From fcc25a49186380ddc9e581732e417019574ddde9 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Fri, 6 Feb 2026 21:42:44 +0800 Subject: [PATCH 5/5] ruff --- astrbot/builtin_stars/web_searcher/main.py | 28 +++++++++++----------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/astrbot/builtin_stars/web_searcher/main.py b/astrbot/builtin_stars/web_searcher/main.py index 9834eaabdd..12c8f68b38 100644 --- a/astrbot/builtin_stars/web_searcher/main.py +++ b/astrbot/builtin_stars/web_searcher/main.py @@ -406,9 +406,9 @@ async def _get_bocha_key(self, cfg: AstrBotConfig) -> str: return key async def _web_search_bocha( - self, - cfg: AstrBotConfig, - payload: dict, + self, + cfg: AstrBotConfig, + payload: dict, ) -> list[SearchResult]: """使用 BoCha 搜索引擎进行搜索""" bocha_key = await self._get_bocha_key(cfg) @@ -419,9 +419,9 @@ async def _web_search_bocha( } async with aiohttp.ClientSession(trust_env=True) as session: async with session.post( - url, - json=payload, - headers=header, + url, + json=payload, + headers=header, ) as response: if response.status != 200: reason = await response.text() @@ -443,14 +443,14 @@ async def _web_search_bocha( @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, + 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