mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-06-02 00:57:41 +08:00
feat(search): multi-provider web search + console integration
Search tool now supports 4 backends with unified output (bocha,
qianfan, zhipu, linkai) and a routing layer:
- strategy 'auto' (default): pick first configured in canonical order
bocha > qianfan > zhipu > linkai
- strategy 'fixed': pin a specific provider
- agent may pass `provider` to override per-call (only exposed when
≥2 providers configured + auto strategy)
This commit is contained in:
@@ -603,15 +603,24 @@ class AgentStreamExecutor:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"[Agent] MCP sync skipped: {e}")
|
logger.debug(f"[Agent] MCP sync skipped: {e}")
|
||||||
|
|
||||||
# Prepare tool definitions (OpenAI/Claude format)
|
# Prepare tool definitions. Prefer get_json_schema() when it yields
|
||||||
|
# real properties (lets tools augment schema at runtime), otherwise
|
||||||
|
# fall back to the static `tool.params` (MCP tools rely on this).
|
||||||
tools_schema = None
|
tools_schema = None
|
||||||
if self.tools:
|
if self.tools:
|
||||||
tools_schema = []
|
tools_schema = []
|
||||||
for tool in self.tools.values():
|
for tool in self.tools.values():
|
||||||
|
input_schema = tool.params
|
||||||
|
try:
|
||||||
|
dynamic = (tool.get_json_schema() or {}).get("parameters") or {}
|
||||||
|
if dynamic.get("properties"):
|
||||||
|
input_schema = dynamic
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
tools_schema.append({
|
tools_schema.append({
|
||||||
"name": tool.name,
|
"name": tool.name,
|
||||||
"description": tool.description,
|
"description": tool.description,
|
||||||
"input_schema": tool.params # Claude uses input_schema
|
"input_schema": input_schema,
|
||||||
})
|
})
|
||||||
|
|
||||||
# Create request
|
# Create request
|
||||||
|
|||||||
@@ -1,13 +1,27 @@
|
|||||||
"""
|
"""Web Search tool. Supports four backends with a unified response format:
|
||||||
Web Search tool - Search the web using Bocha or LinkAI search API.
|
- bocha (https://open.bochaai.com)
|
||||||
Supports two backends with unified response format:
|
- zhipu (https://docs.bigmodel.cn/cn/guide/tools/web-search)
|
||||||
1. Bocha Search (primary, requires BOCHA_API_KEY)
|
- qianfan (https://cloud.baidu.com/doc/qianfan/s/2mh4su4uy)
|
||||||
2. LinkAI Search (fallback, requires LINKAI_API_KEY)
|
- linkai (https://link-ai.tech, fallback)
|
||||||
|
|
||||||
|
Provider selection
|
||||||
|
- strategy 'auto' (default): pick the first configured provider in the
|
||||||
|
canonical order [bocha, zhipu, qianfan, linkai]. When the caller passes
|
||||||
|
an explicit `provider` it overrides the pick; an invalid/unconfigured
|
||||||
|
one silently falls back to the auto order.
|
||||||
|
- strategy 'fixed': use the configured provider; if its credential is
|
||||||
|
missing at call time, silently fall back to auto order (no card hint).
|
||||||
|
|
||||||
|
Credentials
|
||||||
|
- bocha : tools.web_search.bocha_api_key -> env BOCHA_API_KEY
|
||||||
|
- zhipu : conf.zhipu_ai_api_key -> env ZHIPUAI_API_KEY
|
||||||
|
- qianfan : conf.qianfan_api_key -> env QIANFAN_API_KEY
|
||||||
|
- linkai : conf.linkai_api_key -> env LINKAI_API_KEY
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
|
||||||
import json
|
import json
|
||||||
from typing import Dict, Any, Optional
|
import os
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
@@ -16,12 +30,63 @@ from common.log import logger
|
|||||||
from config import conf
|
from config import conf
|
||||||
|
|
||||||
|
|
||||||
# Default timeout for API requests (seconds)
|
|
||||||
DEFAULT_TIMEOUT = 30
|
DEFAULT_TIMEOUT = 30
|
||||||
|
|
||||||
|
# Canonical fallback order. Empirically ordered by Chinese real-time
|
||||||
|
# quality + relevance: bocha (best overall), qianfan (best for hot news),
|
||||||
|
# zhipu (strong on long-form articles), linkai (cloud aggregator, last
|
||||||
|
# resort).
|
||||||
|
PROVIDER_ORDER = ("bocha", "qianfan", "zhipu", "linkai")
|
||||||
|
|
||||||
|
PROVIDER_LABELS = {
|
||||||
|
"bocha": "Bocha",
|
||||||
|
"zhipu": "Zhipu",
|
||||||
|
"qianfan": "Baidu Qianfan",
|
||||||
|
"linkai": "LinkAI",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _tools_web_search_conf() -> dict:
|
||||||
|
"""Return the tools.web_search config block (dict-like)."""
|
||||||
|
tools_cfg = conf().get("tools") or {}
|
||||||
|
if not isinstance(tools_cfg, dict):
|
||||||
|
return {}
|
||||||
|
block = tools_cfg.get("web_search") or {}
|
||||||
|
return block if isinstance(block, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_api_key(provider: str) -> str:
|
||||||
|
"""Resolve API key for a provider, with conf -> env fallback."""
|
||||||
|
if provider == "bocha":
|
||||||
|
key = (_tools_web_search_conf().get("bocha_api_key") or "").strip()
|
||||||
|
return key or os.environ.get("BOCHA_API_KEY", "").strip()
|
||||||
|
if provider == "zhipu":
|
||||||
|
key = (conf().get("zhipu_ai_api_key") or "").strip()
|
||||||
|
return key or os.environ.get("ZHIPUAI_API_KEY", "").strip()
|
||||||
|
if provider == "qianfan":
|
||||||
|
key = (conf().get("qianfan_api_key") or "").strip()
|
||||||
|
return key or os.environ.get("QIANFAN_API_KEY", "").strip()
|
||||||
|
if provider == "linkai":
|
||||||
|
key = (conf().get("linkai_api_key") or "").strip()
|
||||||
|
return key or os.environ.get("LINKAI_API_KEY", "").strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def configured_providers() -> List[str]:
|
||||||
|
"""Return configured providers in canonical order."""
|
||||||
|
return [p for p in PROVIDER_ORDER if _get_api_key(p)]
|
||||||
|
|
||||||
|
|
||||||
|
def _configured_strategy() -> str:
|
||||||
|
return (_tools_web_search_conf().get("strategy") or "auto").strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _configured_provider() -> str:
|
||||||
|
return (_tools_web_search_conf().get("provider") or "").strip().lower()
|
||||||
|
|
||||||
|
|
||||||
class WebSearch(BaseTool):
|
class WebSearch(BaseTool):
|
||||||
"""Tool for searching the web using Bocha or LinkAI search API"""
|
"""Tool for searching the web across multiple providers."""
|
||||||
|
|
||||||
name: str = "web_search"
|
name: str = "web_search"
|
||||||
description: str = "Search the web for real-time information. Returns titles, URLs, and snippets."
|
description: str = "Search the web for real-time information. Returns titles, URLs, and snippets."
|
||||||
@@ -55,264 +120,368 @@ class WebSearch(BaseTool):
|
|||||||
|
|
||||||
def __init__(self, config: dict = None):
|
def __init__(self, config: dict = None):
|
||||||
self.config = config or {}
|
self.config = config or {}
|
||||||
self._backend = None # Will be resolved on first execute
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def is_available() -> bool:
|
def is_available() -> bool:
|
||||||
"""Check if web search is available (at least one API key is configured)"""
|
"""Tool is offered to the agent when at least one provider has a key."""
|
||||||
return bool(os.environ.get("BOCHA_API_KEY") or os.environ.get("LINKAI_API_KEY"))
|
return bool(configured_providers())
|
||||||
|
|
||||||
def _resolve_backend(self) -> Optional[str]:
|
@classmethod
|
||||||
"""
|
def get_json_schema(cls) -> dict:
|
||||||
Determine which search backend to use.
|
"""Augment the static schema with a `provider` field — only when the
|
||||||
Priority: Bocha > LinkAI
|
user has ≥2 providers configured AND strategy is 'auto'. Otherwise
|
||||||
|
the backend picks silently and exposing the field would only waste
|
||||||
|
the agent's tokens."""
|
||||||
|
schema = {
|
||||||
|
"name": cls.name,
|
||||||
|
"description": cls.description,
|
||||||
|
"parameters": json.loads(json.dumps(cls.params)), # deep copy
|
||||||
|
}
|
||||||
|
if _configured_strategy() != "auto":
|
||||||
|
return schema
|
||||||
|
available = configured_providers()
|
||||||
|
if len(available) < 2:
|
||||||
|
return schema
|
||||||
|
|
||||||
:return: 'bocha', 'linkai', or None
|
schema["parameters"]["properties"]["provider"] = {
|
||||||
|
"type": "string",
|
||||||
|
"enum": available,
|
||||||
|
"description": "Optional. Specifies the search backend. You may switch between providers when the user wants results from a particular source or from multiple sources.",
|
||||||
|
}
|
||||||
|
return schema
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Provider resolution
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _resolve_provider(self, requested: Optional[str]) -> Optional[str]:
|
||||||
|
"""Pick a provider for this call.
|
||||||
|
|
||||||
|
Priority: caller-supplied (if configured) > fixed strategy (if
|
||||||
|
configured) > first configured in PROVIDER_ORDER. Silent fallback
|
||||||
|
when the desired one has no key.
|
||||||
"""
|
"""
|
||||||
if os.environ.get("BOCHA_API_KEY"):
|
available = configured_providers()
|
||||||
return "bocha"
|
if not available:
|
||||||
if os.environ.get("LINKAI_API_KEY"):
|
return None
|
||||||
return "linkai"
|
|
||||||
return None
|
if requested:
|
||||||
|
req = requested.strip().lower()
|
||||||
|
if req in available:
|
||||||
|
return req
|
||||||
|
logger.warning(f"[WebSearch] requested provider '{requested}' unavailable, falling back")
|
||||||
|
|
||||||
|
if _configured_strategy() == "fixed":
|
||||||
|
pinned = _configured_provider()
|
||||||
|
if pinned in available:
|
||||||
|
return pinned
|
||||||
|
if pinned:
|
||||||
|
logger.warning(f"[WebSearch] pinned provider '{pinned}' unavailable, falling back to auto")
|
||||||
|
|
||||||
|
return available[0]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolution_reason(requested: Optional[str], chosen: str) -> str:
|
||||||
|
"""Human-readable explanation for why `chosen` won the resolver."""
|
||||||
|
if requested and requested.strip().lower() == chosen:
|
||||||
|
return "caller-requested"
|
||||||
|
strategy = _configured_strategy()
|
||||||
|
if strategy == "fixed" and _configured_provider() == chosen:
|
||||||
|
return "fixed-strategy"
|
||||||
|
return "auto-fallback"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Entry point
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def execute(self, args: Dict[str, Any]) -> ToolResult:
|
def execute(self, args: Dict[str, Any]) -> ToolResult:
|
||||||
"""
|
query = (args.get("query") or "").strip()
|
||||||
Execute web search
|
|
||||||
|
|
||||||
:param args: Search parameters (query, count, freshness, summary)
|
|
||||||
:return: Search results
|
|
||||||
"""
|
|
||||||
query = args.get("query", "").strip()
|
|
||||||
if not query:
|
if not query:
|
||||||
return ToolResult.fail("Error: 'query' parameter is required")
|
return ToolResult.fail("Error: 'query' parameter is required")
|
||||||
|
|
||||||
count = args.get("count", 10)
|
count = args.get("count", 10)
|
||||||
freshness = args.get("freshness", "noLimit")
|
freshness = args.get("freshness", "noLimit")
|
||||||
summary = args.get("summary", False)
|
summary = args.get("summary", False)
|
||||||
|
|
||||||
# Validate count
|
|
||||||
if not isinstance(count, int) or count < 1 or count > 50:
|
if not isinstance(count, int) or count < 1 or count > 50:
|
||||||
count = 10
|
count = 10
|
||||||
|
|
||||||
# Resolve backend
|
requested = args.get("provider")
|
||||||
backend = self._resolve_backend()
|
provider = self._resolve_provider(requested)
|
||||||
if not backend:
|
if not provider:
|
||||||
return ToolResult.fail(
|
return ToolResult.fail(
|
||||||
"Error: No search API key configured. "
|
"Error: No search provider configured. "
|
||||||
"Please set BOCHA_API_KEY or LINKAI_API_KEY using env_config tool.\n"
|
"Configure one of BOCHA_API_KEY / zhipu_ai_api_key / qianfan_api_key / linkai_api_key."
|
||||||
" - Bocha Search: https://open.bocha.cn\n"
|
|
||||||
" - LinkAI Search: https://link-ai.tech"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Always log the routing decision so multi-provider deployments can
|
||||||
|
# tell at a glance which backend served any given query.
|
||||||
|
available = configured_providers()
|
||||||
|
reason = self._resolution_reason(requested, provider)
|
||||||
|
q_preview = query if len(query) <= 60 else (query[:57] + "...")
|
||||||
|
logger.info(
|
||||||
|
f"[WebSearch] provider={provider} reason={reason} "
|
||||||
|
f"available={list(available)} query={q_preview!r} count={count} freshness={freshness}"
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if backend == "bocha":
|
if provider == "bocha":
|
||||||
return self._search_bocha(query, count, freshness, summary)
|
return self._search_bocha(query, count, freshness, summary)
|
||||||
else:
|
if provider == "zhipu":
|
||||||
|
return self._search_zhipu(query, count, freshness)
|
||||||
|
if provider == "qianfan":
|
||||||
|
return self._search_qianfan(query, count, freshness)
|
||||||
|
if provider == "linkai":
|
||||||
return self._search_linkai(query, count, freshness)
|
return self._search_linkai(query, count, freshness)
|
||||||
|
return ToolResult.fail(f"Error: Unknown provider '{provider}'")
|
||||||
except requests.Timeout:
|
except requests.Timeout:
|
||||||
return ToolResult.fail(f"Error: Search request timed out after {DEFAULT_TIMEOUT}s")
|
return ToolResult.fail(f"Error: Search request timed out after {DEFAULT_TIMEOUT}s")
|
||||||
except requests.ConnectionError:
|
except requests.ConnectionError:
|
||||||
return ToolResult.fail("Error: Failed to connect to search API")
|
return ToolResult.fail("Error: Failed to connect to search API")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[WebSearch] Unexpected error: {e}", exc_info=True)
|
logger.error(f"[WebSearch] Unexpected error ({provider}): {e}", exc_info=True)
|
||||||
return ToolResult.fail(f"Error: Search failed - {str(e)}")
|
return ToolResult.fail(f"Error: Search failed - {str(e)}")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Bocha
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def _search_bocha(self, query: str, count: int, freshness: str, summary: bool) -> ToolResult:
|
def _search_bocha(self, query: str, count: int, freshness: str, summary: bool) -> ToolResult:
|
||||||
"""
|
api_key = _get_api_key("bocha")
|
||||||
Search using Bocha API
|
url = "https://api.bochaai.com/v1/web-search"
|
||||||
|
|
||||||
:param query: Search query
|
|
||||||
:param count: Number of results
|
|
||||||
:param freshness: Time range filter
|
|
||||||
:param summary: Whether to include summary
|
|
||||||
:return: Formatted search results
|
|
||||||
"""
|
|
||||||
api_key = os.environ.get("BOCHA_API_KEY", "")
|
|
||||||
url = "https://api.bocha.cn/v1/web-search"
|
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
"Authorization": f"Bearer {api_key}",
|
"Authorization": f"Bearer {api_key}",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Accept": "application/json"
|
"Accept": "application/json",
|
||||||
}
|
}
|
||||||
|
payload = {"query": query, "count": count, "freshness": freshness, "summary": summary}
|
||||||
|
|
||||||
payload = {
|
logger.debug(f"[WebSearch] bocha: query='{query}', count={count}")
|
||||||
"query": query,
|
resp = requests.post(url, headers=headers, json=payload, timeout=DEFAULT_TIMEOUT)
|
||||||
"count": count,
|
|
||||||
"freshness": freshness,
|
|
||||||
"summary": summary
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug(f"[WebSearch] Bocha search: query='{query}', count={count}")
|
if resp.status_code == 401:
|
||||||
|
return ToolResult.fail("Error: Invalid bocha API key.")
|
||||||
|
if resp.status_code == 403:
|
||||||
|
return ToolResult.fail("Error: bocha API — insufficient balance. Top up at https://open.bochaai.com")
|
||||||
|
if resp.status_code == 429:
|
||||||
|
return ToolResult.fail("Error: bocha API rate limit reached.")
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return ToolResult.fail(f"Error: bocha API returned HTTP {resp.status_code}")
|
||||||
|
|
||||||
response = requests.post(url, headers=headers, json=payload, timeout=DEFAULT_TIMEOUT)
|
data = resp.json()
|
||||||
|
|
||||||
if response.status_code == 401:
|
|
||||||
return ToolResult.fail("Error: Invalid BOCHA_API_KEY. Please check your API key.")
|
|
||||||
if response.status_code == 403:
|
|
||||||
return ToolResult.fail("Error: Bocha API - insufficient balance. Please top up at https://open.bocha.cn")
|
|
||||||
if response.status_code == 429:
|
|
||||||
return ToolResult.fail("Error: Bocha API rate limit reached. Please try again later.")
|
|
||||||
if response.status_code != 200:
|
|
||||||
return ToolResult.fail(f"Error: Bocha API returned HTTP {response.status_code}")
|
|
||||||
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
# Check API-level error code
|
|
||||||
api_code = data.get("code")
|
api_code = data.get("code")
|
||||||
if api_code is not None and api_code != 200:
|
if api_code is not None and api_code != 200:
|
||||||
msg = data.get("msg") or "Unknown error"
|
msg = data.get("msg") or "Unknown error"
|
||||||
return ToolResult.fail(f"Error: Bocha API error (code={api_code}): {msg}")
|
return ToolResult.fail(f"Error: bocha API error (code={api_code}): {msg}")
|
||||||
|
|
||||||
# Extract and format results
|
|
||||||
return self._format_bocha_results(data, query)
|
|
||||||
|
|
||||||
def _format_bocha_results(self, data: dict, query: str) -> ToolResult:
|
|
||||||
"""
|
|
||||||
Format Bocha API response into unified result structure
|
|
||||||
|
|
||||||
:param data: Raw API response
|
|
||||||
:param query: Original query
|
|
||||||
:return: Formatted ToolResult
|
|
||||||
"""
|
|
||||||
search_data = data.get("data", {})
|
|
||||||
web_pages = search_data.get("webPages", {})
|
|
||||||
pages = web_pages.get("value", [])
|
|
||||||
|
|
||||||
if not pages:
|
|
||||||
return ToolResult.success({
|
|
||||||
"query": query,
|
|
||||||
"backend": "bocha",
|
|
||||||
"total": 0,
|
|
||||||
"results": [],
|
|
||||||
"message": "No results found"
|
|
||||||
})
|
|
||||||
|
|
||||||
|
pages = (data.get("data") or {}).get("webPages", {}).get("value", []) or []
|
||||||
results = []
|
results = []
|
||||||
for page in pages:
|
for p in pages:
|
||||||
result = {
|
item = {
|
||||||
"title": page.get("name", ""),
|
"title": p.get("name", ""),
|
||||||
"url": page.get("url", ""),
|
"url": p.get("url", ""),
|
||||||
"snippet": page.get("snippet", ""),
|
"snippet": p.get("snippet", ""),
|
||||||
"siteName": page.get("siteName", ""),
|
"siteName": p.get("siteName", ""),
|
||||||
"datePublished": page.get("datePublished") or page.get("dateLastCrawled", ""),
|
"datePublished": p.get("datePublished") or p.get("dateLastCrawled", ""),
|
||||||
}
|
}
|
||||||
# Include summary only if present
|
if p.get("summary"):
|
||||||
if page.get("summary"):
|
item["summary"] = p["summary"]
|
||||||
result["summary"] = page["summary"]
|
results.append(item)
|
||||||
results.append(result)
|
total = (data.get("data") or {}).get("webPages", {}).get("totalEstimatedMatches", len(results))
|
||||||
|
|
||||||
total = web_pages.get("totalEstimatedMatches", len(results))
|
|
||||||
|
|
||||||
return ToolResult.success({
|
return ToolResult.success({
|
||||||
"query": query,
|
"query": query, "backend": "bocha",
|
||||||
"backend": "bocha",
|
"total": total, "count": len(results), "results": results,
|
||||||
"total": total,
|
|
||||||
"count": len(results),
|
|
||||||
"results": results
|
|
||||||
})
|
})
|
||||||
|
|
||||||
def _search_linkai(self, query: str, count: int, freshness: str) -> ToolResult:
|
# ------------------------------------------------------------------
|
||||||
"""
|
# Zhipu
|
||||||
Search using LinkAI plugin API
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
:param query: Search query
|
def _search_zhipu(self, query: str, count: int, freshness: str) -> ToolResult:
|
||||||
:param count: Number of results
|
api_key = _get_api_key("zhipu")
|
||||||
:param freshness: Time range filter
|
api_base = (conf().get("zhipu_ai_api_base") or "https://open.bigmodel.cn/api/paas/v4").rstrip("/")
|
||||||
:return: Formatted search results
|
url = f"{api_base}/web_search"
|
||||||
"""
|
headers = {
|
||||||
api_key = os.environ.get("LINKAI_API_KEY", "")
|
"Authorization": f"Bearer {api_key}",
|
||||||
api_base = conf().get("linkai_api_base", "https://api.link-ai.tech")
|
"Content-Type": "application/json",
|
||||||
url = f"{api_base.rstrip('/')}/v1/plugin/execute"
|
}
|
||||||
|
|
||||||
|
# Zhipu Web Search expects `search_query` <= 70 chars; truncate
|
||||||
|
# gracefully so a long agent-supplied query doesn't get rejected.
|
||||||
|
trimmed_query = (query or "")[:70]
|
||||||
|
engine = (_tools_web_search_conf().get("zhipu_search_engine") or "search_pro").strip().lower()
|
||||||
|
if engine not in ("search_std", "search_pro", "search_pro_sogou", "search_pro_quark"):
|
||||||
|
engine = "search_pro"
|
||||||
|
|
||||||
|
payload: Dict[str, Any] = {
|
||||||
|
"search_engine": engine,
|
||||||
|
"search_query": trimmed_query,
|
||||||
|
"search_intent": False,
|
||||||
|
"count": max(1, min(int(count or 10), 50)),
|
||||||
|
"search_recency_filter": freshness if freshness in (
|
||||||
|
"oneDay", "oneWeek", "oneMonth", "oneYear", "noLimit"
|
||||||
|
) else "noLimit",
|
||||||
|
}
|
||||||
|
content_size = (_tools_web_search_conf().get("zhipu_content_size") or "").strip().lower()
|
||||||
|
if content_size in ("medium", "high"):
|
||||||
|
payload["content_size"] = content_size
|
||||||
|
|
||||||
|
logger.debug(f"[WebSearch] zhipu: query='{trimmed_query}', count={payload['count']}, engine={engine}")
|
||||||
|
resp = requests.post(url, headers=headers, json=payload, timeout=DEFAULT_TIMEOUT)
|
||||||
|
|
||||||
|
if resp.status_code == 401:
|
||||||
|
return ToolResult.fail("Error: Invalid Zhipu API key.")
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return ToolResult.fail(f"Error: Zhipu API returned HTTP {resp.status_code}: {resp.text[:200]}")
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
# Business-level errors (1701/1702/1703 etc.) come back as
|
||||||
|
# {"error": {"code","message"}} even on HTTP 200.
|
||||||
|
if isinstance(data, dict) and data.get("error"):
|
||||||
|
err = data["error"] or {}
|
||||||
|
return ToolResult.fail(f"Error: Zhipu returned {err.get('code')}: {err.get('message','')}")
|
||||||
|
|
||||||
|
items = data.get("search_result") or (data.get("data") or {}).get("search_result") or []
|
||||||
|
results = []
|
||||||
|
for it in items:
|
||||||
|
results.append({
|
||||||
|
"title": it.get("title", ""),
|
||||||
|
"url": it.get("link") or it.get("url", ""),
|
||||||
|
"snippet": it.get("content") or it.get("snippet", ""),
|
||||||
|
"siteName": it.get("media") or it.get("siteName", ""),
|
||||||
|
"datePublished": it.get("publish_date") or it.get("datePublished", ""),
|
||||||
|
})
|
||||||
|
return ToolResult.success({
|
||||||
|
"query": query, "backend": "zhipu",
|
||||||
|
"total": len(results), "count": len(results), "results": results,
|
||||||
|
})
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Qianfan (Baidu)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _search_qianfan(self, query: str, count: int, freshness: str) -> ToolResult:
|
||||||
|
api_key = _get_api_key("qianfan")
|
||||||
|
api_base = (conf().get("qianfan_api_base") or "https://qianfan.baidubce.com/v2").rstrip("/")
|
||||||
|
url = f"{api_base}/ai_search/web_search"
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Appbuilder-From": "cow",
|
||||||
|
}
|
||||||
|
|
||||||
|
count = max(1, min(int(count or 10), 50))
|
||||||
|
payload: Dict[str, Any] = {
|
||||||
|
"messages": [{"role": "user", "content": query}],
|
||||||
|
"search_source": "baidu_search_v2",
|
||||||
|
"resource_type_filter": [{"type": "web", "top_k": count}],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Baidu AI Search expects freshness as a date-range filter, not a
|
||||||
|
# named recency token. Translate our shared vocabulary into the
|
||||||
|
# underlying page_time range expected by the API.
|
||||||
|
search_filter = self._qianfan_build_freshness_filter(freshness)
|
||||||
|
if search_filter:
|
||||||
|
payload["search_filter"] = search_filter
|
||||||
|
|
||||||
|
logger.debug(f"[WebSearch] qianfan: query='{query}', count={count}, freshness={freshness!r}")
|
||||||
|
resp = requests.post(url, headers=headers, json=payload, timeout=DEFAULT_TIMEOUT)
|
||||||
|
|
||||||
|
if resp.status_code == 401:
|
||||||
|
return ToolResult.fail("Error: Invalid Qianfan API key.")
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return ToolResult.fail(f"Error: Qianfan API returned HTTP {resp.status_code}: {resp.text[:200]}")
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
# Even on HTTP 200 Baidu surfaces business errors as {"code","message"}.
|
||||||
|
if isinstance(data, dict) and data.get("code"):
|
||||||
|
return ToolResult.fail(f"Error: Qianfan returned {data.get('code')}: {data.get('message','')}")
|
||||||
|
|
||||||
|
refs = data.get("references") or []
|
||||||
|
results = []
|
||||||
|
for d in refs:
|
||||||
|
results.append({
|
||||||
|
"title": d.get("title", ""),
|
||||||
|
"url": d.get("url", ""),
|
||||||
|
"snippet": (d.get("content") or "")[:200],
|
||||||
|
"siteName": d.get("web_anchor") or d.get("website") or "",
|
||||||
|
"datePublished": d.get("date", ""),
|
||||||
|
})
|
||||||
|
return ToolResult.success({
|
||||||
|
"query": query, "backend": "qianfan",
|
||||||
|
"total": len(results), "count": len(results), "results": results,
|
||||||
|
})
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _qianfan_build_freshness_filter(freshness: str) -> Optional[Dict[str, Any]]:
|
||||||
|
if not freshness or freshness == "noLimit":
|
||||||
|
return None
|
||||||
|
delta_days = {"oneDay": 1, "oneWeek": 7, "oneMonth": 30, "oneYear": 365}.get(freshness)
|
||||||
|
if not delta_days:
|
||||||
|
return None
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
now = datetime.now()
|
||||||
|
end_date = (now + timedelta(days=1)).strftime("%Y-%m-%d")
|
||||||
|
start_date = (now - timedelta(days=delta_days)).strftime("%Y-%m-%d")
|
||||||
|
return {"range": {"page_time": {"gte": start_date, "lt": end_date}}}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# LinkAI (plugin)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _search_linkai(self, query: str, count: int, freshness: str) -> ToolResult:
|
||||||
|
api_key = _get_api_key("linkai")
|
||||||
|
api_base = (conf().get("linkai_api_base") or "https://api.link-ai.tech").rstrip("/")
|
||||||
|
url = f"{api_base}/v1/plugin/execute"
|
||||||
|
|
||||||
from common.utils import get_cloud_headers
|
from common.utils import get_cloud_headers
|
||||||
headers = get_cloud_headers(api_key)
|
headers = get_cloud_headers(api_key)
|
||||||
|
|
||||||
payload = {
|
payload = {"code": "web-search", "args": {"query": query, "count": count, "freshness": freshness}}
|
||||||
"code": "web-search",
|
logger.debug(f"[WebSearch] linkai: query='{query}', count={count}")
|
||||||
"args": {
|
resp = requests.post(url, headers=headers, json=payload, timeout=DEFAULT_TIMEOUT)
|
||||||
"query": query,
|
|
||||||
"count": count,
|
|
||||||
"freshness": freshness
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug(f"[WebSearch] LinkAI search: query='{query}', count={count}")
|
if resp.status_code == 401:
|
||||||
|
return ToolResult.fail("Error: Invalid LinkAI API key.")
|
||||||
response = requests.post(url, headers=headers, json=payload, timeout=DEFAULT_TIMEOUT)
|
if resp.status_code != 200:
|
||||||
|
return ToolResult.fail(f"Error: LinkAI API returned HTTP {resp.status_code}")
|
||||||
if response.status_code == 401:
|
|
||||||
return ToolResult.fail("Error: Invalid LINKAI_API_KEY. Please check your API key.")
|
|
||||||
if response.status_code != 200:
|
|
||||||
return ToolResult.fail(f"Error: LinkAI API returned HTTP {response.status_code}")
|
|
||||||
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
if not data.get("success"):
|
if not data.get("success"):
|
||||||
msg = data.get("message") or "Unknown error"
|
msg = data.get("message") or "Unknown error"
|
||||||
return ToolResult.fail(f"Error: LinkAI search failed: {msg}")
|
return ToolResult.fail(f"Error: LinkAI search failed: {msg}")
|
||||||
|
|
||||||
return self._format_linkai_results(data, query)
|
raw = data.get("data", "")
|
||||||
|
if isinstance(raw, str):
|
||||||
def _format_linkai_results(self, data: dict, query: str) -> ToolResult:
|
|
||||||
"""
|
|
||||||
Format LinkAI API response into unified result structure.
|
|
||||||
LinkAI returns the search data in data.data field, which follows
|
|
||||||
the same Bing-compatible format as Bocha.
|
|
||||||
|
|
||||||
:param data: Raw API response
|
|
||||||
:param query: Original query
|
|
||||||
:return: Formatted ToolResult
|
|
||||||
"""
|
|
||||||
raw_data = data.get("data", "")
|
|
||||||
|
|
||||||
# LinkAI may return data as a JSON string
|
|
||||||
if isinstance(raw_data, str):
|
|
||||||
try:
|
try:
|
||||||
raw_data = json.loads(raw_data)
|
raw = json.loads(raw)
|
||||||
except (json.JSONDecodeError, TypeError):
|
except (json.JSONDecodeError, TypeError):
|
||||||
# If data is plain text, return it as a single result
|
|
||||||
return ToolResult.success({
|
return ToolResult.success({
|
||||||
"query": query,
|
"query": query, "backend": "linkai",
|
||||||
"backend": "linkai",
|
"total": 1, "count": 1, "results": [{"content": raw}],
|
||||||
"total": 1,
|
|
||||||
"count": 1,
|
|
||||||
"results": [{"content": raw_data}]
|
|
||||||
})
|
})
|
||||||
|
|
||||||
# If the response follows Bing-compatible structure
|
if isinstance(raw, dict):
|
||||||
if isinstance(raw_data, dict):
|
pages = (raw.get("webPages") or {}).get("value", []) or []
|
||||||
web_pages = raw_data.get("webPages", {})
|
|
||||||
pages = web_pages.get("value", [])
|
|
||||||
|
|
||||||
if pages:
|
if pages:
|
||||||
results = []
|
results = []
|
||||||
for page in pages:
|
for p in pages:
|
||||||
result = {
|
item = {
|
||||||
"title": page.get("name", ""),
|
"title": p.get("name", ""),
|
||||||
"url": page.get("url", ""),
|
"url": p.get("url", ""),
|
||||||
"snippet": page.get("snippet", ""),
|
"snippet": p.get("snippet", ""),
|
||||||
"siteName": page.get("siteName", ""),
|
"siteName": p.get("siteName", ""),
|
||||||
"datePublished": page.get("datePublished") or page.get("dateLastCrawled", ""),
|
"datePublished": p.get("datePublished") or p.get("dateLastCrawled", ""),
|
||||||
}
|
}
|
||||||
if page.get("summary"):
|
if p.get("summary"):
|
||||||
result["summary"] = page["summary"]
|
item["summary"] = p["summary"]
|
||||||
results.append(result)
|
results.append(item)
|
||||||
|
total = (raw.get("webPages") or {}).get("totalEstimatedMatches", len(results))
|
||||||
total = web_pages.get("totalEstimatedMatches", len(results))
|
|
||||||
return ToolResult.success({
|
return ToolResult.success({
|
||||||
"query": query,
|
"query": query, "backend": "linkai",
|
||||||
"backend": "linkai",
|
"total": total, "count": len(results), "results": results,
|
||||||
"total": total,
|
|
||||||
"count": len(results),
|
|
||||||
"results": results
|
|
||||||
})
|
})
|
||||||
|
|
||||||
# Fallback: return raw data
|
|
||||||
return ToolResult.success({
|
return ToolResult.success({
|
||||||
"query": query,
|
"query": query, "backend": "linkai",
|
||||||
"backend": "linkai",
|
"total": 1, "count": 1, "results": [{"content": str(raw)}],
|
||||||
"total": 1,
|
|
||||||
"count": 1,
|
|
||||||
"results": [{"content": str(raw_data)}]
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -521,7 +521,7 @@ class AgentInitializer:
|
|||||||
if tool_name == "web_search":
|
if tool_name == "web_search":
|
||||||
from agent.tools.web_search.web_search import WebSearch
|
from agent.tools.web_search.web_search import WebSearch
|
||||||
if not WebSearch.is_available():
|
if not WebSearch.is_available():
|
||||||
logger.debug("[AgentInitializer] WebSearch skipped - no BOCHA_API_KEY or LINKAI_API_KEY")
|
logger.debug("[AgentInitializer] WebSearch skipped - no search provider configured")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Special handling for EnvConfig tool
|
# Special handling for EnvConfig tool
|
||||||
|
|||||||
@@ -1036,7 +1036,7 @@
|
|||||||
<!-- Vendor Credentials Modal -->
|
<!-- Vendor Credentials Modal -->
|
||||||
<div id="vendor-modal-overlay" class="fixed inset-0 bg-black/50 z-[100] hidden flex items-center justify-center">
|
<div id="vendor-modal-overlay" class="fixed inset-0 bg-black/50 z-[100] hidden flex items-center justify-center">
|
||||||
<div class="bg-white dark:bg-[#1A1A1A] rounded-2xl border border-slate-200 dark:border-white/10 shadow-xl
|
<div class="bg-white dark:bg-[#1A1A1A] rounded-2xl border border-slate-200 dark:border-white/10 shadow-xl
|
||||||
w-full max-w-md mx-4 overflow-hidden">
|
w-full max-w-md mx-4">
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<div class="flex items-center gap-3 mb-5">
|
<div class="flex items-center gap-3 mb-5">
|
||||||
<div class="w-10 h-10 rounded-xl bg-primary-50 dark:bg-primary-900/20 flex items-center justify-center flex-shrink-0">
|
<div class="w-10 h-10 rounded-xl bg-primary-50 dark:bg-primary-900/20 flex items-center justify-center flex-shrink-0">
|
||||||
@@ -1082,7 +1082,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between gap-3 px-6 py-4 border-t border-slate-100 dark:border-white/5">
|
<div class="flex items-center justify-between gap-3 px-6 py-4 border-t border-slate-100 dark:border-white/5 rounded-b-2xl">
|
||||||
<button id="vendor-modal-clear"
|
<button id="vendor-modal-clear"
|
||||||
class="px-3 py-2 rounded-lg text-xs font-medium
|
class="px-3 py-2 rounded-lg text-xs font-medium
|
||||||
text-red-500 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20
|
text-red-500 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20
|
||||||
|
|||||||
@@ -46,8 +46,19 @@ const I18N = {
|
|||||||
models_capability_embedding: '向量',
|
models_capability_embedding: '向量',
|
||||||
models_capability_embedding_desc: '记忆与知识的向量化',
|
models_capability_embedding_desc: '记忆与知识的向量化',
|
||||||
models_capability_search: '联网搜索',
|
models_capability_search: '联网搜索',
|
||||||
models_capability_search_desc: '实时网页检索能力',
|
models_capability_search_desc: '实时网页检索能力,在搜索工具中使用',
|
||||||
models_strategy_auto: '自动',
|
models_strategy_auto: '自动',
|
||||||
|
models_search_strategy_label: '策略',
|
||||||
|
models_search_strategy_fixed: '指定',
|
||||||
|
models_search_strategy_auto_hint: '从已配置厂商中自动选择',
|
||||||
|
models_search_strategy_fixed_hint: '指定使用搜索厂商',
|
||||||
|
models_pending_config: '待配置',
|
||||||
|
models_search_available_label: '可用搜索厂商:',
|
||||||
|
models_search_none_configured: '暂未启用任何搜索厂商,点击添加',
|
||||||
|
models_search_add_provider: '添加厂商',
|
||||||
|
models_search_add_desc: '选择一个搜索厂商进行配置',
|
||||||
|
models_search_bocha_title: '配置博查 API Key',
|
||||||
|
models_search_bocha_desc: '前往博查开放平台创建 API Key:',
|
||||||
models_unavailable: '不可用',
|
models_unavailable: '不可用',
|
||||||
models_set_via_env: '通过环境变量启用',
|
models_set_via_env: '通过环境变量启用',
|
||||||
models_dim_label: '维度',
|
models_dim_label: '维度',
|
||||||
@@ -209,6 +220,17 @@ const I18N = {
|
|||||||
models_capability_search: 'Web Search',
|
models_capability_search: 'Web Search',
|
||||||
models_capability_search_desc: 'Real-time web retrieval',
|
models_capability_search_desc: 'Real-time web retrieval',
|
||||||
models_strategy_auto: 'auto',
|
models_strategy_auto: 'auto',
|
||||||
|
models_search_strategy_label: 'Strategy',
|
||||||
|
models_search_strategy_fixed: 'Pinned',
|
||||||
|
models_search_strategy_auto_hint: 'Auto-pick from configured providers',
|
||||||
|
models_search_strategy_fixed_hint: 'Always use a specific provider',
|
||||||
|
models_pending_config: 'Pending setup',
|
||||||
|
models_search_available_label: 'Available:',
|
||||||
|
models_search_none_configured: 'No search provider enabled yet — click add.',
|
||||||
|
models_search_add_provider: 'Add provider',
|
||||||
|
models_search_add_desc: 'Pick a search provider to configure',
|
||||||
|
models_search_bocha_title: 'Configure Bocha API Key',
|
||||||
|
models_search_bocha_desc: 'Create a key at the Bocha open platform: ',
|
||||||
models_unavailable: 'unavailable',
|
models_unavailable: 'unavailable',
|
||||||
models_set_via_env: 'enable via environment variable',
|
models_set_via_env: 'enable via environment variable',
|
||||||
models_dim_label: 'dim',
|
models_dim_label: 'dim',
|
||||||
@@ -3769,7 +3791,7 @@ const MODELS_CAPABILITY_DEFS = [
|
|||||||
iconChip: 'bg-amber-50 dark:bg-amber-900/30', iconGlyph: 'text-amber-500' },
|
iconChip: 'bg-amber-50 dark:bg-amber-900/30', iconGlyph: 'text-amber-500' },
|
||||||
{ id: 'embedding', icon: 'fa-vector-square', editable: true, needsModel: false, titleKey: 'models_capability_embedding', descKey: 'models_capability_embedding_desc',
|
{ id: 'embedding', icon: 'fa-vector-square', editable: true, needsModel: false, titleKey: 'models_capability_embedding', descKey: 'models_capability_embedding_desc',
|
||||||
iconChip: 'bg-purple-50 dark:bg-purple-900/30', iconGlyph: 'text-purple-500' },
|
iconChip: 'bg-purple-50 dark:bg-purple-900/30', iconGlyph: 'text-purple-500' },
|
||||||
{ id: 'search', icon: 'fa-magnifying-glass', editable: false, needsModel: false, titleKey: 'models_capability_search', descKey: 'models_capability_search_desc',
|
{ id: 'search', icon: 'fa-magnifying-glass', editable: true, needsModel: false, titleKey: 'models_capability_search', descKey: 'models_capability_search_desc',
|
||||||
iconChip: 'bg-orange-50 dark:bg-orange-900/30', iconGlyph: 'text-orange-500' },
|
iconChip: 'bg-orange-50 dark:bg-orange-900/30', iconGlyph: 'text-orange-500' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -3945,36 +3967,309 @@ function renderCapabilityCard(def) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderCapabilityHeaderTag(def, cap) {
|
function renderCapabilityHeaderTag(def, cap) {
|
||||||
// Only the search card carries a header tag — it reflects a runtime
|
|
||||||
// resolution result (which vendor responds to web_search), so the chip
|
|
||||||
// is informational, not editable. Other cards used to surface "auto"
|
|
||||||
// and "follows main model" badges, but those duplicated information
|
|
||||||
// already shown in the provider dropdown and the fallback hint below,
|
|
||||||
// so we drop them for visual clarity.
|
|
||||||
if (def.id === 'search') {
|
|
||||||
if (cap.available) {
|
|
||||||
return `<span class="px-2 py-0.5 text-[11px] rounded-md bg-emerald-50 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400 mt-1 flex-shrink-0">${escapeHtml(cap.current_provider || '')}</span>`;
|
|
||||||
}
|
|
||||||
return `<span class="px-2 py-0.5 text-[11px] rounded-md bg-slate-100 dark:bg-white/5 text-slate-500 dark:text-slate-400 mt-1 flex-shrink-0">${t('models_unavailable')}</span>`;
|
|
||||||
}
|
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _searchProviderLabel(cap, providerId) {
|
||||||
|
const list = (cap && cap.providers) || [];
|
||||||
|
const hit = list.find(p => p.id === providerId);
|
||||||
|
return hit ? hit.label : providerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search card body: strategy picker + (when fixed) provider picker + a
|
||||||
|
// status row that surfaces which providers are ready and how to add the
|
||||||
|
// missing ones. Three of the four backends piggy-back on model-vendor
|
||||||
|
// credentials (zhipu / qianfan / linkai); bocha owns its own key under
|
||||||
|
// tools.web_search and gets its own minimal credential modal.
|
||||||
|
function renderSearchCapability(def, cap, body) {
|
||||||
|
const providers = cap.providers || [];
|
||||||
|
const configuredIds = cap.configured_providers || [];
|
||||||
|
const hasAny = configuredIds.length > 0;
|
||||||
|
const strategy = cap.strategy || 'auto';
|
||||||
|
|
||||||
|
body.innerHTML = `
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">${t('models_search_strategy_label')}</label>
|
||||||
|
<div id="cap-search-strategy" class="cfg-dropdown" tabindex="0">
|
||||||
|
<div class="cfg-dropdown-selected">
|
||||||
|
<span class="cfg-dropdown-text">--</span>
|
||||||
|
<i class="fas fa-chevron-down cfg-dropdown-arrow"></i>
|
||||||
|
</div>
|
||||||
|
<div class="cfg-dropdown-menu"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="cap-search-provider-wrap" class="hidden">
|
||||||
|
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">${t('models_provider')}</label>
|
||||||
|
<div id="cap-search-provider" class="cfg-dropdown" tabindex="0">
|
||||||
|
<div class="cfg-dropdown-selected">
|
||||||
|
<span class="cfg-dropdown-text">--</span>
|
||||||
|
<i class="fas fa-chevron-down cfg-dropdown-arrow"></i>
|
||||||
|
</div>
|
||||||
|
<div class="cfg-dropdown-menu"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="cap-search-summary"></div>
|
||||||
|
<div class="flex items-center justify-end gap-3 pt-1">
|
||||||
|
<span id="cap-search-status" class="text-xs text-primary-500 opacity-0 transition-opacity duration-300"></span>
|
||||||
|
<button onclick="saveSearchCapability()"
|
||||||
|
class="px-4 py-2 rounded-lg bg-primary-500 hover:bg-primary-600 text-white text-sm font-medium
|
||||||
|
cursor-pointer transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||||
|
${t('save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Strategy dropdown — when no provider is configured the strategy
|
||||||
|
// value is meaningless, so we show a "待配置" placeholder instead of
|
||||||
|
// a default selection. Once any provider gets configured the saved
|
||||||
|
// strategy (or "auto") becomes the active value.
|
||||||
|
initDropdown(
|
||||||
|
body.querySelector('#cap-search-strategy'),
|
||||||
|
[
|
||||||
|
{ value: 'auto', label: t('models_strategy_auto'), hint: t('models_search_strategy_auto_hint') },
|
||||||
|
{ value: 'fixed', label: t('models_search_strategy_fixed'), hint: t('models_search_strategy_fixed_hint') },
|
||||||
|
],
|
||||||
|
hasAny ? strategy : '',
|
||||||
|
(value) => _onSearchStrategyChange(cap, value, body),
|
||||||
|
hasAny ? null : { placeholder: t('models_pending_config') },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Provider dropdown — populated with configured providers only;
|
||||||
|
// unconfigured ones cannot be pinned (they'd silently fall back).
|
||||||
|
const provOpts = configuredIds.map(id => ({
|
||||||
|
value: id,
|
||||||
|
label: _searchProviderLabel(cap, id),
|
||||||
|
}));
|
||||||
|
if (provOpts.length === 0) provOpts.push({ value: '', label: '--' });
|
||||||
|
initDropdown(
|
||||||
|
body.querySelector('#cap-search-provider'),
|
||||||
|
provOpts,
|
||||||
|
cap.fixed_provider || configuredIds[0] || '',
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
|
|
||||||
|
_renderSearchSummary(body, cap);
|
||||||
|
_setSearchProviderPickerVisible(body, strategy === 'fixed' && hasAny);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _onSearchStrategyChange(cap, value, body) {
|
||||||
|
const configuredIds = cap.configured_providers || [];
|
||||||
|
_setSearchProviderPickerVisible(body, value === 'fixed' && configuredIds.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _setSearchProviderPickerVisible(body, visible) {
|
||||||
|
const wrap = body.querySelector('#cap-search-provider-wrap');
|
||||||
|
if (!wrap) return;
|
||||||
|
if (visible) wrap.classList.remove('hidden');
|
||||||
|
else wrap.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search summary line: just lists configured providers + a trailing "+
|
||||||
|
// add" button. Unconfigured backends are hidden — the user picks one from
|
||||||
|
// a small chooser when they click add. Empty state surfaces the same add
|
||||||
|
// button as a primary CTA.
|
||||||
|
function _renderSearchSummary(body, cap) {
|
||||||
|
const host = body.querySelector('#cap-search-summary');
|
||||||
|
if (!host) return;
|
||||||
|
const providers = cap.providers || [];
|
||||||
|
const configured = providers.filter(p => p.configured);
|
||||||
|
const missing = providers.filter(p => !p.configured);
|
||||||
|
|
||||||
|
const addBtn = missing.length
|
||||||
|
? `<button type="button" id="cap-search-add-btn"
|
||||||
|
class="inline-flex items-center gap-1 px-2 py-0.5 text-[11px] rounded-md cursor-pointer
|
||||||
|
bg-slate-100 dark:bg-white/5 text-slate-500 dark:text-slate-400
|
||||||
|
hover:bg-slate-200 dark:hover:bg-white/10 transition-colors">
|
||||||
|
<i class="fas fa-plus text-[10px]"></i>${t('models_search_add_provider')}
|
||||||
|
</button>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
if (configured.length === 0) {
|
||||||
|
host.innerHTML = `
|
||||||
|
<div class="flex items-center gap-2 text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
<i class="fas fa-circle-info text-[10px] text-amber-500"></i>
|
||||||
|
<span>${t('models_search_none_configured')}</span>
|
||||||
|
${addBtn}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
const chips = configured.map(p => `
|
||||||
|
<span class="inline-flex items-center gap-1 px-2 py-0.5 text-[11px] rounded-md
|
||||||
|
bg-emerald-50 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400">
|
||||||
|
<i class="fas fa-check text-[10px]"></i>${escapeHtml(p.label)}
|
||||||
|
</span>
|
||||||
|
`).join('');
|
||||||
|
host.innerHTML = `
|
||||||
|
<div class="flex items-center flex-wrap gap-2 text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
<span>${t('models_search_available_label')}</span>
|
||||||
|
${chips}
|
||||||
|
${addBtn}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const btn = host.querySelector('#cap-search-add-btn');
|
||||||
|
if (btn) {
|
||||||
|
btn.addEventListener('click', (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
openSearchAddProviderPicker(missing);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two-step add flow: click "+ 添加厂商" -> chooser dialog -> per-provider
|
||||||
|
// credential editor. Bocha lands on the dedicated key modal; the others
|
||||||
|
// piggy-back on the existing vendor credential modal.
|
||||||
|
function openSearchAddProviderPicker(missingProviders) {
|
||||||
|
if (!missingProviders || missingProviders.length === 0) return;
|
||||||
|
if (missingProviders.length === 1) {
|
||||||
|
_launchSearchProviderConfig(missingProviders[0].id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = document.getElementById('search-add-modal');
|
||||||
|
if (existing) existing.remove();
|
||||||
|
|
||||||
|
const rows = missingProviders.map(p => `
|
||||||
|
<button type="button" data-pid="${p.id}"
|
||||||
|
class="w-full flex items-center justify-between px-3 py-2.5 rounded-lg cursor-pointer
|
||||||
|
bg-slate-50 dark:bg-white/5 hover:bg-slate-100 dark:hover:bg-white/10
|
||||||
|
text-sm text-slate-700 dark:text-slate-200 transition-colors">
|
||||||
|
<span>${escapeHtml(p.label)}</span>
|
||||||
|
<i class="fas fa-chevron-right text-[10px] text-slate-400"></i>
|
||||||
|
</button>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
const modal = document.createElement('div');
|
||||||
|
modal.id = 'search-add-modal';
|
||||||
|
modal.className = 'fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm';
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10
|
||||||
|
w-full max-w-md mx-4 p-6 shadow-xl">
|
||||||
|
<h3 class="text-lg font-semibold text-slate-800 dark:text-slate-100 mb-1">${t('models_search_add_provider')}</h3>
|
||||||
|
<p class="text-xs text-slate-500 dark:text-slate-400 mb-4">${t('models_search_add_desc')}</p>
|
||||||
|
<div class="space-y-2">${rows}</div>
|
||||||
|
<div class="flex items-center justify-end mt-5">
|
||||||
|
<button type="button" onclick="document.getElementById('search-add-modal').remove()"
|
||||||
|
class="px-3 py-1.5 rounded-md text-sm text-slate-600 dark:text-slate-300
|
||||||
|
hover:bg-slate-100 dark:hover:bg-white/5 transition-colors">
|
||||||
|
${t('cancel')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
modal.querySelectorAll('[data-pid]').forEach(el => {
|
||||||
|
el.addEventListener('click', () => {
|
||||||
|
const pid = el.getAttribute('data-pid');
|
||||||
|
modal.remove();
|
||||||
|
_launchSearchProviderConfig(pid);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _launchSearchProviderConfig(providerId) {
|
||||||
|
if (providerId === 'bocha') {
|
||||||
|
openSearchBochaModal();
|
||||||
|
} else {
|
||||||
|
openVendorModal(providerId, () => loadModelsView({ preserveScroll: true }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveSearchCapability() {
|
||||||
|
const strategyDd = document.getElementById('cap-search-strategy');
|
||||||
|
const providerDd = document.getElementById('cap-search-provider');
|
||||||
|
const strategy = strategyDd ? getDropdownValue(strategyDd) : 'auto';
|
||||||
|
const provider = (strategy === 'fixed' && providerDd) ? getDropdownValue(providerDd) : '';
|
||||||
|
|
||||||
|
fetch('/api/models', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: 'set_capability',
|
||||||
|
capability: 'search',
|
||||||
|
strategy,
|
||||||
|
provider,
|
||||||
|
}),
|
||||||
|
}).then(r => r.json()).then(data => {
|
||||||
|
if (data.status === 'success') {
|
||||||
|
showStatus('cap-search-status', 'models_save_success', false);
|
||||||
|
setTimeout(() => loadModelsView({ preserveScroll: true }), 400);
|
||||||
|
} else {
|
||||||
|
showStatus('cap-search-status', 'models_save_failed', true);
|
||||||
|
}
|
||||||
|
}).catch(() => showStatus('cap-search-status', 'models_save_failed', true));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimal bocha API-key modal. Reuses the existing vendor-modal markup
|
||||||
|
// helpers would be nice, but bocha isn't in PROVIDER_MODELS (it's not a
|
||||||
|
// model vendor), so we render a tiny dedicated dialog.
|
||||||
|
function openSearchBochaModal() {
|
||||||
|
const existing = document.getElementById('search-bocha-modal');
|
||||||
|
if (existing) existing.remove();
|
||||||
|
|
||||||
|
const modal = document.createElement('div');
|
||||||
|
modal.id = 'search-bocha-modal';
|
||||||
|
modal.className = 'fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm';
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10
|
||||||
|
w-full max-w-md mx-4 p-6 shadow-xl">
|
||||||
|
<h3 class="text-lg font-semibold text-slate-800 dark:text-slate-100 mb-1">${t('models_search_bocha_title')}</h3>
|
||||||
|
<p class="text-xs text-slate-500 dark:text-slate-400 mb-4">
|
||||||
|
${t('models_search_bocha_desc')}
|
||||||
|
<a href="https://open.bochaai.com" target="_blank"
|
||||||
|
class="text-primary-500 hover:text-primary-600 underline">open.bochaai.com</a>
|
||||||
|
</p>
|
||||||
|
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">API Key</label>
|
||||||
|
<input id="search-bocha-key" type="password" autocomplete="off"
|
||||||
|
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
|
||||||
|
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
|
||||||
|
focus:outline-none focus:border-primary-500 font-mono"
|
||||||
|
placeholder="sk-..." />
|
||||||
|
<div class="flex items-center justify-end gap-3 mt-5">
|
||||||
|
<button type="button" onclick="document.getElementById('search-bocha-modal').remove()"
|
||||||
|
class="px-3 py-1.5 rounded-md text-sm text-slate-600 dark:text-slate-300
|
||||||
|
hover:bg-slate-100 dark:hover:bg-white/5 transition-colors">
|
||||||
|
${t('cancel')}
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick="_saveBochaKey()"
|
||||||
|
class="px-4 py-1.5 rounded-md bg-primary-500 hover:bg-primary-600 text-white text-sm font-medium
|
||||||
|
cursor-pointer transition-colors">
|
||||||
|
${t('save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
setTimeout(() => {
|
||||||
|
const input = document.getElementById('search-bocha-key');
|
||||||
|
if (input) input.focus();
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _saveBochaKey() {
|
||||||
|
const input = document.getElementById('search-bocha-key');
|
||||||
|
const apiKey = input ? input.value.trim() : '';
|
||||||
|
if (!apiKey) {
|
||||||
|
if (input) input.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetch('/api/models', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'set_search_credential', api_key: apiKey }),
|
||||||
|
}).then(r => r.json()).then(data => {
|
||||||
|
if (data.status === 'success') {
|
||||||
|
const modal = document.getElementById('search-bocha-modal');
|
||||||
|
if (modal) modal.remove();
|
||||||
|
loadModelsView({ preserveScroll: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function renderCapabilityBody(def, cap, body) {
|
function renderCapabilityBody(def, cap, body) {
|
||||||
if (def.id === 'search') {
|
if (def.id === 'search') {
|
||||||
if (cap.available) {
|
renderSearchCapability(def, cap, body);
|
||||||
body.innerHTML = `
|
|
||||||
<p class="text-sm text-slate-500 dark:text-slate-400">
|
|
||||||
<i class="fas fa-circle-check text-[11px] mr-1.5 text-emerald-500"></i>
|
|
||||||
${t('models_configured')}: <span class="font-mono text-slate-700 dark:text-slate-300">${escapeHtml(cap.current_provider)}</span>
|
|
||||||
</p>`;
|
|
||||||
} else {
|
|
||||||
body.innerHTML = `
|
|
||||||
<p class="text-sm text-slate-500 dark:text-slate-400">
|
|
||||||
<i class="fas fa-circle-exclamation text-[11px] mr-1.5 text-amber-500"></i>
|
|
||||||
${t('models_set_via_env')}: <span class="font-mono text-slate-700 dark:text-slate-300">BOCHA_API_KEY</span>
|
|
||||||
</p>`;
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4084,21 +4379,38 @@ function renderCapabilityBody(def, cap, body) {
|
|||||||
// Auto strategy => leave empty sentinel selected. `suggested_provider`
|
// Auto strategy => leave empty sentinel selected. `suggested_provider`
|
||||||
// is a UI-only preselect (not persisted until the user clicks Save).
|
// is a UI-only preselect (not persisted until the user clicks Save).
|
||||||
// No current + no suggestion => leave unselected with a placeholder.
|
// No current + no suggestion => leave unselected with a placeholder.
|
||||||
|
//
|
||||||
|
// Pending-config takes priority over both "auto" and "pick provider":
|
||||||
|
// when no real (non-sentinel) configured option exists, surfacing
|
||||||
|
// "auto" or "pick" misleads the user — there's nothing to auto-route
|
||||||
|
// to or pick from. Force a "待配置" placeholder instead so all
|
||||||
|
// capabilities behave consistently on a fresh environment.
|
||||||
|
const hasConfiguredOpt = providerOpts.some(o => !o._isAuto && o._configured);
|
||||||
const noSelectionAndNoHint = !cap.current_provider && !cap.suggested_provider;
|
const noSelectionAndNoHint = !cap.current_provider && !cap.suggested_provider;
|
||||||
const initialProviderValue = pendingProvider
|
let initialProviderValue;
|
||||||
? pendingProvider
|
let dropdownPlaceholder = null;
|
||||||
: ((cap.strategy === 'auto' && capabilitySupportsAuto(def.id))
|
if (!hasConfiguredOpt) {
|
||||||
? ''
|
initialProviderValue = '';
|
||||||
: (cap.current_provider
|
dropdownPlaceholder = { placeholder: t('models_pending_config') };
|
||||||
|| cap.suggested_provider
|
} else {
|
||||||
|| (noSelectionAndNoHint ? '' : (ddOpts[0] && ddOpts[0].value))
|
initialProviderValue = pendingProvider
|
||||||
|| ''));
|
? pendingProvider
|
||||||
|
: ((cap.strategy === 'auto' && capabilitySupportsAuto(def.id))
|
||||||
|
? ''
|
||||||
|
: (cap.current_provider
|
||||||
|
|| cap.suggested_provider
|
||||||
|
|| (noSelectionAndNoHint ? '' : (ddOpts[0] && ddOpts[0].value))
|
||||||
|
|| ''));
|
||||||
|
if (noSelectionAndNoHint) {
|
||||||
|
dropdownPlaceholder = { placeholder: t('models_pick_provider') };
|
||||||
|
}
|
||||||
|
}
|
||||||
initDropdown(
|
initDropdown(
|
||||||
provDd,
|
provDd,
|
||||||
ddOpts,
|
ddOpts,
|
||||||
initialProviderValue,
|
initialProviderValue,
|
||||||
(value) => onCapabilityProviderChange(def, value, body),
|
(value) => onCapabilityProviderChange(def, value, body),
|
||||||
noSelectionAndNoHint ? { placeholder: t('models_pick_provider') } : null
|
dropdownPlaceholder,
|
||||||
);
|
);
|
||||||
decorateCapabilityProviderDropdown(def, provDd, providerOpts);
|
decorateCapabilityProviderDropdown(def, provDd, providerOpts);
|
||||||
|
|
||||||
@@ -4261,7 +4573,10 @@ function buildCapabilityProviderOptions(def, cap) {
|
|||||||
// option pinned to the top of the list. We use empty-string as the auto
|
// option pinned to the top of the list. We use empty-string as the auto
|
||||||
// value so the existing save handler propagates it untouched to the
|
// value so the existing save handler propagates it untouched to the
|
||||||
// backend, which interprets "" as "fall back to the main model".
|
// backend, which interprets "" as "fall back to the main model".
|
||||||
if (cap.strategy === 'auto' || cap.strategy === 'specified') {
|
// Skip the sentinel when no real vendor is configured — "auto" would
|
||||||
|
// route to nothing useful and the renderer will show "待配置" instead.
|
||||||
|
const hasAnyConfigured = opts.some(o => o._configured);
|
||||||
|
if ((cap.strategy === 'auto' || cap.strategy === 'specified') && hasAnyConfigured) {
|
||||||
if (capabilitySupportsAuto(def.id)) {
|
if (capabilitySupportsAuto(def.id)) {
|
||||||
opts.unshift({
|
opts.unshift({
|
||||||
value: '',
|
value: '',
|
||||||
@@ -4551,6 +4866,8 @@ function getCapabilityModelValue(def) {
|
|||||||
function saveCapability(capId) {
|
function saveCapability(capId) {
|
||||||
const def = MODELS_CAPABILITY_DEFS.find(d => d.id === capId);
|
const def = MODELS_CAPABILITY_DEFS.find(d => d.id === capId);
|
||||||
if (!def || !def.editable) return;
|
if (!def || !def.editable) return;
|
||||||
|
// Search has its own form (strategy + provider, no model picker).
|
||||||
|
if (capId === 'search') { saveSearchCapability(); return; }
|
||||||
const provDd = document.getElementById(`cap-${capId}-provider`);
|
const provDd = document.getElementById(`cap-${capId}-provider`);
|
||||||
const provider = provDd ? getDropdownValue(provDd) : '';
|
const provider = provDd ? getDropdownValue(provDd) : '';
|
||||||
// When the user is in auto mode (provider == ""), the model picker is
|
// When the user is in auto mode (provider == ""), the model picker is
|
||||||
|
|||||||
@@ -1368,14 +1368,6 @@ class ConfigHandler:
|
|||||||
"api_base_placeholder": _PLACEHOLDER_QIANFAN,
|
"api_base_placeholder": _PLACEHOLDER_QIANFAN,
|
||||||
"models": [const.ERNIE_5_1, const.ERNIE_5, const.ERNIE_X1_1, const.ERNIE_45_TURBO_128K, const.ERNIE_45_TURBO_32K],
|
"models": [const.ERNIE_5_1, const.ERNIE_5, const.ERNIE_X1_1, const.ERNIE_45_TURBO_128K, const.ERNIE_45_TURBO_32K],
|
||||||
}),
|
}),
|
||||||
("modelscope", {
|
|
||||||
"label": "ModelScope",
|
|
||||||
"api_key_field": "modelscope_api_key",
|
|
||||||
"api_base_key": None,
|
|
||||||
"api_base_default": None,
|
|
||||||
"api_base_placeholder": "",
|
|
||||||
"models": [const.QWEN3_5_27B, const.QWEN3_235B_A22B_INSTRUCT_2507],
|
|
||||||
}),
|
|
||||||
("linkai", {
|
("linkai", {
|
||||||
"label": "LinkAI",
|
"label": "LinkAI",
|
||||||
"api_key_field": "linkai_api_key",
|
"api_key_field": "linkai_api_key",
|
||||||
@@ -2219,20 +2211,81 @@ class ModelsHandler:
|
|||||||
"note": "router_pending",
|
"note": "router_pending",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Canonical search provider order. Mirrors PROVIDER_ORDER in
|
||||||
|
# agent/tools/web_search/web_search.py — keep them in sync.
|
||||||
|
_SEARCH_PROVIDERS = ("bocha", "qianfan", "zhipu", "linkai")
|
||||||
|
|
||||||
|
_SEARCH_PROVIDER_LABELS = {
|
||||||
|
"bocha": "博查",
|
||||||
|
"zhipu": "智谱",
|
||||||
|
"qianfan": "百度千帆",
|
||||||
|
"linkai": "LinkAI",
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _search_provider_key(cls, provider: str, local_config: dict) -> str:
|
||||||
|
"""Resolve the (raw) key for a given search provider."""
|
||||||
|
if provider == "bocha":
|
||||||
|
tools_cfg = local_config.get("tools") or {}
|
||||||
|
block = tools_cfg.get("web_search") or {} if isinstance(tools_cfg, dict) else {}
|
||||||
|
return (block.get("bocha_api_key") if isinstance(block, dict) else "") or os.environ.get("BOCHA_API_KEY", "")
|
||||||
|
if provider == "zhipu":
|
||||||
|
return local_config.get("zhipu_ai_api_key") or os.environ.get("ZHIPUAI_API_KEY", "")
|
||||||
|
if provider == "qianfan":
|
||||||
|
return local_config.get("qianfan_api_key") or os.environ.get("QIANFAN_API_KEY", "")
|
||||||
|
if provider == "linkai":
|
||||||
|
return local_config.get("linkai_api_key") or os.environ.get("LINKAI_API_KEY", "")
|
||||||
|
return ""
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _search_capability(cls, local_config: dict) -> dict:
|
def _search_capability(cls, local_config: dict) -> dict:
|
||||||
"""Web search resolves at runtime via env vars (BOCHA -> LINKAI)."""
|
"""Search is editable: pick auto (default) or pin a specific backend.
|
||||||
if cls._is_real_key(os.environ.get("BOCHA_API_KEY", "")):
|
Providers reuse model-vendor keys (zhipu/qianfan/linkai) so they show
|
||||||
current = "bocha"
|
up as configured once the user adds those vendors; bocha keeps its
|
||||||
elif cls._is_real_key(local_config.get("linkai_api_key", "")) or cls._is_real_key(os.environ.get("LINKAI_API_KEY", "")):
|
own key under tools.web_search."""
|
||||||
current = "linkai"
|
tools_cfg = local_config.get("tools") or {}
|
||||||
|
ws_cfg = tools_cfg.get("web_search") or {} if isinstance(tools_cfg, dict) else {}
|
||||||
|
if not isinstance(ws_cfg, dict):
|
||||||
|
ws_cfg = {}
|
||||||
|
|
||||||
|
providers = []
|
||||||
|
configured_ids = []
|
||||||
|
for pid in cls._SEARCH_PROVIDERS:
|
||||||
|
ok = cls._is_real_key(cls._search_provider_key(pid, local_config))
|
||||||
|
providers.append({
|
||||||
|
"id": pid,
|
||||||
|
"label": cls._SEARCH_PROVIDER_LABELS.get(pid, pid),
|
||||||
|
"configured": ok,
|
||||||
|
# bocha owns its key under tools.web_search; the other three
|
||||||
|
# piggy-back on a model-vendor credential. Frontend uses
|
||||||
|
# this hint to decide which credential editor to surface.
|
||||||
|
"needs_dedicated_key": pid == "bocha",
|
||||||
|
})
|
||||||
|
if ok:
|
||||||
|
configured_ids.append(pid)
|
||||||
|
|
||||||
|
strategy = (ws_cfg.get("strategy") or "auto").strip().lower()
|
||||||
|
if strategy not in ("auto", "fixed"):
|
||||||
|
strategy = "auto"
|
||||||
|
fixed_provider = (ws_cfg.get("provider") or "").strip().lower()
|
||||||
|
if fixed_provider and fixed_provider not in configured_ids:
|
||||||
|
fixed_provider = ""
|
||||||
|
|
||||||
|
# current_provider drives the chip in the header — show the actually
|
||||||
|
# active backend (pinned or first auto-picked).
|
||||||
|
if strategy == "fixed" and fixed_provider:
|
||||||
|
current = fixed_provider
|
||||||
else:
|
else:
|
||||||
current = ""
|
current = configured_ids[0] if configured_ids else ""
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"editable": False,
|
"editable": True,
|
||||||
|
"strategy": strategy,
|
||||||
|
"providers": providers,
|
||||||
|
"configured_providers": configured_ids,
|
||||||
"current_provider": current,
|
"current_provider": current,
|
||||||
|
"fixed_provider": fixed_provider,
|
||||||
"available": bool(current),
|
"available": bool(current),
|
||||||
"note": "set_BOCHA_API_KEY_env" if not current else "",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -2275,6 +2328,8 @@ class ModelsHandler:
|
|||||||
return self._handle_set_capability(data)
|
return self._handle_set_capability(data)
|
||||||
if action == "set_voice_reply_mode":
|
if action == "set_voice_reply_mode":
|
||||||
return self._handle_set_voice_reply_mode(data)
|
return self._handle_set_voice_reply_mode(data)
|
||||||
|
if action == "set_search_credential":
|
||||||
|
return self._handle_set_search_credential(data)
|
||||||
return json.dumps({"status": "error", "message": f"unknown action: {action!r}"})
|
return json.dumps({"status": "error", "message": f"unknown action: {action!r}"})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[ModelsHandler] POST failed: {e}")
|
logger.error(f"[ModelsHandler] POST failed: {e}")
|
||||||
@@ -2366,6 +2421,11 @@ class ModelsHandler:
|
|||||||
return self._set_embedding(provider_id, model)
|
return self._set_embedding(provider_id, model)
|
||||||
if capability == "image":
|
if capability == "image":
|
||||||
return self._set_image(provider_id, model)
|
return self._set_image(provider_id, model)
|
||||||
|
if capability == "search":
|
||||||
|
return self._set_search(
|
||||||
|
(data.get("strategy") or "").strip().lower(),
|
||||||
|
(data.get("provider") or "").strip().lower(),
|
||||||
|
)
|
||||||
return json.dumps({"status": "error", "message": f"capability not editable: {capability}"})
|
return json.dumps({"status": "error", "message": f"capability not editable: {capability}"})
|
||||||
|
|
||||||
def _set_image(self, provider_id: str, model: str) -> str:
|
def _set_image(self, provider_id: str, model: str) -> str:
|
||||||
@@ -2550,6 +2610,47 @@ class ModelsHandler:
|
|||||||
# changed, so the frontend prompts the user to rebuild.
|
# changed, so the frontend prompts the user to rebuild.
|
||||||
return json.dumps({"status": "success", "provider": provider_id, "model": model})
|
return json.dumps({"status": "success", "provider": provider_id, "model": model})
|
||||||
|
|
||||||
|
def _set_search(self, strategy: str, provider: str) -> str:
|
||||||
|
"""Persist search routing under tools.web_search.{strategy,provider}.
|
||||||
|
|
||||||
|
strategy 'auto' -> provider field is cleared (auto picks at call time)
|
||||||
|
strategy 'fixed' -> provider must be in the canonical list; runtime
|
||||||
|
silently falls back to auto if its key is missing.
|
||||||
|
"""
|
||||||
|
if strategy not in ("auto", "fixed"):
|
||||||
|
return json.dumps({"status": "error", "message": f"invalid strategy: {strategy!r}"})
|
||||||
|
if strategy == "fixed":
|
||||||
|
if provider not in self._SEARCH_PROVIDERS:
|
||||||
|
return json.dumps({"status": "error", "message": f"unknown provider: {provider!r}"})
|
||||||
|
else:
|
||||||
|
provider = ""
|
||||||
|
|
||||||
|
local_config = conf()
|
||||||
|
file_cfg = self._read_file_config()
|
||||||
|
self._set_nested_namespace_value(local_config, "tools", "web_search", "strategy", strategy)
|
||||||
|
self._set_nested_namespace_value(file_cfg, "tools", "web_search", "strategy", strategy)
|
||||||
|
self._set_nested_namespace_value(local_config, "tools", "web_search", "provider", provider)
|
||||||
|
self._set_nested_namespace_value(file_cfg, "tools", "web_search", "provider", provider)
|
||||||
|
self._write_file_config(file_cfg)
|
||||||
|
logger.info(f"[ModelsHandler] search updated: strategy={strategy!r} provider={provider!r}")
|
||||||
|
return json.dumps({"status": "success", "strategy": strategy, "provider": provider})
|
||||||
|
|
||||||
|
def _handle_set_search_credential(self, data: dict) -> str:
|
||||||
|
"""Persist the bocha API key under tools.web_search.bocha_api_key.
|
||||||
|
|
||||||
|
The other three providers (zhipu/qianfan/linkai) reuse model-vendor
|
||||||
|
credentials, so they go through set_provider with the standard
|
||||||
|
model-vendor flow.
|
||||||
|
"""
|
||||||
|
api_key = (data.get("api_key") or "").strip() if isinstance(data.get("api_key"), str) else ""
|
||||||
|
local_config = conf()
|
||||||
|
file_cfg = self._read_file_config()
|
||||||
|
self._set_nested_namespace_value(local_config, "tools", "web_search", "bocha_api_key", api_key)
|
||||||
|
self._set_nested_namespace_value(file_cfg, "tools", "web_search", "bocha_api_key", api_key)
|
||||||
|
self._write_file_config(file_cfg)
|
||||||
|
logger.info(f"[ModelsHandler] search credential set: bocha_api_key={'***' if api_key else ''}")
|
||||||
|
return json.dumps({"status": "success", "provider": "bocha"})
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _reset_bridge() -> None:
|
def _reset_bridge() -> None:
|
||||||
try:
|
try:
|
||||||
|
|||||||
Reference in New Issue
Block a user