diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py index 8c91adbbfd..a707030a80 100644 --- a/astrbot/core/agent/runners/tool_loop_agent_runner.py +++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py @@ -924,6 +924,17 @@ async def step(self): ), ) + if llm_resp.web_search_sources: + yield AgentResponse( + type="web_search_sources", + data=AgentResponseData( + chain=MessageChain( + type="web_search_sources", + chain=[Json(data={"sources": llm_resp.web_search_sources})], + ) + ), + ) + # 如果有工具调用,还需处理工具调用 if llm_resp.tools_call_name: if self.tool_schema_mode == "skills_like": diff --git a/astrbot/core/astr_agent_run_util.py b/astrbot/core/astr_agent_run_util.py index 7e0844ee48..24f193eeae 100644 --- a/astrbot/core/astr_agent_run_util.py +++ b/astrbot/core/astr_agent_run_util.py @@ -187,6 +187,11 @@ async def run_agent( await astr_event.send(resp.data["chain"]) continue + if resp.type == "web_search_sources": + if astr_event.get_platform_name() == "webchat": + await astr_event.send(resp.data["chain"]) + continue + if resp.type == "tool_call_result": msg_chain = resp.data["chain"] diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 780e070098..31f3a65098 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -1304,6 +1304,7 @@ "timeout": 120, "proxy": "", "custom_headers": {}, + "xai_native_search": False, }, "DeepSeek": { "id": "deepseek", @@ -2032,10 +2033,9 @@ "xai_native_search": { "description": "启用原生搜索功能", "type": "bool", - "hint": "启用后,将通过 xAI 的 Chat Completions 原生 Live Search 进行联网检索(按需计费)。仅对 xAI 提供商生效。", + "hint": "启用后,将通过 xAI 原生 Web Search 进行联网检索(按需计费)。仅对 xAI 提供商生效。", "condition": { "provider": "xai", - "type": "xai_chat_completion", }, }, "rerank_api_base": { diff --git a/astrbot/core/provider/entities.py b/astrbot/core/provider/entities.py index 2fab40ca78..d8271c6006 100644 --- a/astrbot/core/provider/entities.py +++ b/astrbot/core/provider/entities.py @@ -312,6 +312,9 @@ class LLMResponse: reasoning_signature: str | None = None """The signature of the reasoning content, if any.""" + web_search_sources: list[dict[str, Any]] = field(default_factory=list) + """Structured web search sources (e.g. xAI url_citation), each with index/url/title.""" + raw_completion: ( ChatCompletion | Response | GenerateContentResponse | AnthropicMessage | None ) = None @@ -339,6 +342,7 @@ def __init__( tools_call_extra_content: dict[str, dict[str, Any]] | None = None, reasoning_content: str | None = None, reasoning_signature: str | None = None, + web_search_sources: list[dict[str, Any]] | None = None, raw_completion: ChatCompletion | Response | GenerateContentResponse @@ -377,6 +381,7 @@ def __init__( self.tools_call_extra_content = tools_call_extra_content self.reasoning_content = reasoning_content self.reasoning_signature = reasoning_signature + self.web_search_sources = web_search_sources or [] self.raw_completion = raw_completion self.is_chunk = is_chunk diff --git a/astrbot/core/provider/sources/openai_responses_source.py b/astrbot/core/provider/sources/openai_responses_source.py index c5cb9bdb82..5480c887b2 100644 --- a/astrbot/core/provider/sources/openai_responses_source.py +++ b/astrbot/core/provider/sources/openai_responses_source.py @@ -294,6 +294,21 @@ async def _prepare_chat_payload( return payloads, context_query + def _build_response_tools(self, tools: ToolSet | None) -> list[dict]: + """Build the Responses tools list, appending xAI's native web_search when enabled.""" + response_tools: list[dict] = [] + if tools: + for tool in tools.openai_schema(): + function = tool.get("function", {}) + response_tools.append({"type": "function", **function}) + + if self.provider_config.get("provider") == "xai" and bool( + self.provider_config.get("xai_native_search", False) + ): + response_tools.append({"type": "web_search"}) + + return response_tools + async def _query( self, payloads: dict, @@ -314,14 +329,10 @@ async def _query( Raises: TypeError: If the SDK returns an unexpected response type. """ - if tools: - response_tools = [] - for tool in tools.openai_schema(): - function = tool.get("function", {}) - response_tools.append({"type": "function", **function}) - if response_tools: - payloads["tools"] = response_tools - payloads["tool_choice"] = payloads.get("tool_choice", "auto") + response_tools = self._build_response_tools(tools) + if response_tools: + payloads["tools"] = response_tools + payloads["tool_choice"] = payloads.get("tool_choice", "auto") extra_body: dict[str, Any] = {} custom_extra_body = self.provider_config.get("custom_extra_body", {}) @@ -383,14 +394,10 @@ async def _query_stream( Raises: EmptyModelOutputError: If the stream ends without a terminal event. """ - if tools: - response_tools = [] - for tool in tools.openai_schema(): - function = tool.get("function", {}) - response_tools.append({"type": "function", **function}) - if response_tools: - payloads["tools"] = response_tools - payloads["tool_choice"] = payloads.get("tool_choice", "auto") + response_tools = self._build_response_tools(tools) + if response_tools: + payloads["tools"] = response_tools + payloads["tool_choice"] = payloads.get("tool_choice", "auto") extra_body: dict[str, Any] = {} custom_extra_body = self.provider_config.get("custom_extra_body", {}) @@ -523,6 +530,7 @@ async def _parse_response( text_parts: list[str] = [] reasoning_parts: list[str] = [] serialized_reasoning_items: list[dict] = [] + web_search_sources: list[dict[str, Any]] = [] for item in self._field(response, "output", []) or []: item_type = self._field(item, "type") @@ -531,6 +539,20 @@ async def _parse_response( content_type = self._field(content, "type") if content_type == "output_text": text_parts.append(str(self._field(content, "text", ""))) + for annotation in self._field(content, "annotations", []) or []: + if self._field(annotation, "type") != "url_citation": + continue + url = self._field(annotation, "url") + if not url: + continue + title = self._field(annotation, "title") + web_search_sources.append( + { + "index": str(len(web_search_sources) + 1), + "url": url, + "title": title, + } + ) elif content_type == "refusal": text_parts.append(str(self._field(content, "refusal", ""))) continue @@ -591,6 +613,8 @@ async def _parse_response( if llm_response.tools_call_args: llm_response.role = "tool" + llm_response.web_search_sources = web_search_sources + usage = self._field(response, "usage") if usage is not None: input_details = self._field(usage, "input_tokens_details") diff --git a/astrbot/dashboard/services/chat_service.py b/astrbot/dashboard/services/chat_service.py index 0b72b582d7..cb3598b3f4 100644 --- a/astrbot/dashboard/services/chat_service.py +++ b/astrbot/dashboard/services/chat_service.py @@ -272,6 +272,21 @@ def extract_web_search_refs(accumulated_text: str, accumulated_parts: list) -> d return {"used": used_refs} if used_refs else {} +def parse_web_search_sources(result_text: str) -> dict: + """Parse a web_search_sources back-queue payload into a refs dict. + + The payload is ``{"sources": [...]}``; the refs shape mirrors + ``extract_web_search_refs`` so the frontend renders native search + sources as the same clickable cards. + """ + try: + parsed = json.loads(result_text) + sources = parsed.get("sources", []) if isinstance(parsed, dict) else [] + return {"used": sources} if isinstance(sources, list) else {} + except (TypeError, json.JSONDecodeError): + return {} + + def sanitize_message_content(content: dict) -> dict: if not isinstance(content, dict): raise ValueError("Missing key: content") @@ -921,6 +936,8 @@ async def flush_pending_bot_message(): plain_text, message_parts_to_save, ) + if not extracted_refs and pending_refs: + extracted_refs = pending_refs except Exception as exc: logger.exception( f"Failed to extract web search refs: {exc}", @@ -969,6 +986,11 @@ async def flush_pending_bot_message(): ) continue + if chain_type == "web_search_sources": + pending_refs = parse_web_search_sources(result_text) + run.refs = pending_refs + continue + attachment_saved_payload = None if msg_type == "plain": for accumulator in (pending_accumulator, display_accumulator): @@ -1036,6 +1058,7 @@ async def flush_pending_bot_message(): saved_record.created_at ), "llm_checkpoint_id": run.llm_checkpoint_id, + "refs": run.refs, }, }, ) @@ -1063,6 +1086,7 @@ async def flush_pending_bot_message(): "id": saved_record.id, "created_at": to_utc_isoformat(saved_record.created_at), "llm_checkpoint_id": run.llm_checkpoint_id, + "refs": run.refs, }, }, ) diff --git a/astrbot/dashboard/services/live_chat_service.py b/astrbot/dashboard/services/live_chat_service.py index 16b7eed0ad..d7eb24ec2a 100644 --- a/astrbot/dashboard/services/live_chat_service.py +++ b/astrbot/dashboard/services/live_chat_service.py @@ -33,6 +33,7 @@ BotMessageAccumulator, build_bot_history_content, collect_plain_text_from_message_parts, + parse_web_search_sources, ) SendJson = Callable[[dict], Awaitable[None]] @@ -630,6 +631,8 @@ async def flush_pending_bot_message(): exc_info=True, ) extracted_refs = refs + if not extracted_refs and refs: + extracted_refs = refs saved_record = await self.save_bot_message( session_id, @@ -701,6 +704,10 @@ async def send_attachment_saved_event(part: dict | None) -> None: pass continue + if chain_type == "web_search_sources": + refs = parse_web_search_sources(result_text) + continue + outgoing = {"ct": "chat", **result} await self.send_chat_payload(session, outgoing, send_json) diff --git a/astrbot/dashboard/services/open_api_service.py b/astrbot/dashboard/services/open_api_service.py index 131b8e3b98..2a9129a891 100644 --- a/astrbot/dashboard/services/open_api_service.py +++ b/astrbot/dashboard/services/open_api_service.py @@ -29,6 +29,7 @@ from astrbot.dashboard.services.chat_service import ( BotMessageAccumulator, collect_plain_text_from_message_parts, + parse_web_search_sources, ) SendJson = Callable[[dict], Awaitable[None]] @@ -485,6 +486,10 @@ async def handle_chat_ws_send( pass continue + if chain_type == "web_search_sources": + refs = parse_web_search_sources(result_text) + continue + await send_json(result) if msg_type == "plain": @@ -517,6 +522,7 @@ async def handle_chat_ws_send( plain_text = collect_plain_text_from_message_parts( message_parts_to_save ) + fallback_refs = refs try: refs = chat_bridge.extract_web_search_refs( plain_text, @@ -527,6 +533,8 @@ async def handle_chat_ws_send( f"Open API WS failed to extract web search refs: {exc}", exc_info=True, ) + if not refs and fallback_refs: + refs = fallback_refs saved_record = await chat_bridge.save_bot_message( session_id, diff --git a/dashboard/src/composables/useProviderSources.ts b/dashboard/src/composables/useProviderSources.ts index 7a6a70430c..fc74a0fb73 100644 --- a/dashboard/src/composables/useProviderSources.ts +++ b/dashboard/src/composables/useProviderSources.ts @@ -393,6 +393,10 @@ export function useProviderSources(options: UseProviderSourcesOptions) { source.ollama_disable_thinking = false } + if (source.provider === 'xai' && source.xai_native_search === undefined) { + source.xai_native_search = false + } + return source } 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 0e6905cc5c..5fe5f19d21 100644 --- a/dashboard/src/i18n/locales/en-US/features/config-metadata.json +++ b/dashboard/src/i18n/locales/en-US/features/config-metadata.json @@ -1206,7 +1206,7 @@ }, "xai_native_search": { "description": "Enable native search", - "hint": "When enabled, uses xAI Chat Completions native Live Search for web queries (billed on demand). Only applies to xAI providers." + "hint": "When enabled, uses xAI native Web Search for web queries (billed on demand). Only applies to xAI providers." }, "rerank_api_base": { "description": "Rerank Model API Base URL", 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 089e9ba91f..c3aa7debfb 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json @@ -1208,7 +1208,7 @@ }, "xai_native_search": { "description": "启用原生搜索功能", - "hint": "启用后,将通过 xAI 的 Chat Completions 原生 Live Search 进行联网检索(按需计费)。仅对 xAI 提供商生效。" + "hint": "启用后,将通过 xAI 原生 Web Search 进行联网检索(按需计费)。仅对 xAI 提供商生效。" }, "rerank_api_base": { "description": "重排序模型 API Base URL", diff --git a/tests/test_openai_responses_source.py b/tests/test_openai_responses_source.py index 6b2d5e6718..b7bc06881f 100644 --- a/tests/test_openai_responses_source.py +++ b/tests/test_openai_responses_source.py @@ -61,7 +61,7 @@ def test_responses_provider_templates_are_independent_and_stateless(): assert templates["DeepSeek Responses"]["api_base"] == "https://api.deepseek.com/v1" assert templates["xAI"]["type"] == "openai_responses" assert templates["xAI"]["api_base"] == "https://api.x.ai/v1" - assert "xai_native_search" not in templates["xAI"] + assert templates["xAI"]["xai_native_search"] is False def test_convert_chat_history_preserves_response_items_and_function_calls():