From eabd863183fbd1e887014b4f2ca7e2e258d4ee46 Mon Sep 17 00:00:00 2001 From: zhaishujie <3394180966@qq.com> Date: Sat, 21 Mar 2026 21:28:39 +0800 Subject: [PATCH 01/20] chore: UI/UX Pro Max refresh for console.css --- .gitignore | 6 + install.sh | 10 + mysearch/.env.example | 19 + mysearch/README.md | 24 + mysearch/clients.py | 10 + mysearch/config.py | 162 +- openclaw/.env.example | 19 + openclaw/public.json | 3 + openclaw/runtime/mysearch/clients.py | 10 + openclaw/runtime/mysearch/config.py | 162 +- openclaw/scripts/install_openclaw_skill.sh | 7 +- proxy/server.py | 178 +- proxy/static/css/console.css | 670 +++ proxy/static/js/console.js | 1622 +++++++ proxy/templates/components/_hero.html | 66 + .../templates/components/_settings_modal.html | 192 + proxy/templates/console.html | 4026 +---------------- skill/scripts/check_mysearch.py | 11 +- tests/test_config_bootstrap.py | 69 + 19 files changed, 3176 insertions(+), 4090 deletions(-) create mode 100644 proxy/static/css/console.css create mode 100644 proxy/static/js/console.js create mode 100644 proxy/templates/components/_hero.html create mode 100644 proxy/templates/components/_settings_modal.html diff --git a/.gitignore b/.gitignore index c7433fe..1d49f7e 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,9 @@ openclaw/.venv/ # 本地测试输出 htmlcov/ .coverage + +# 本地 AI 辅助产物 +.ace-tool/ +llmdoc/ +.codex-tasks/ +extract.py diff --git a/install.sh b/install.sh index f57a965..9a1eb4b 100755 --- a/install.sh +++ b/install.sh @@ -24,6 +24,7 @@ ENV_KEYS=( MYSEARCH_MCP_SSE_PATH MYSEARCH_MCP_STREAMABLE_HTTP_PATH MYSEARCH_MCP_STATELESS_HTTP + MYSEARCH_TAVILY_MODE MYSEARCH_TAVILY_BASE_URL MYSEARCH_TAVILY_SEARCH_PATH MYSEARCH_TAVILY_EXTRACT_PATH @@ -35,6 +36,15 @@ ENV_KEYS=( MYSEARCH_TAVILY_API_KEYS MYSEARCH_TAVILY_KEYS_FILE MYSEARCH_TAVILY_ACCOUNTS_FILE + MYSEARCH_TAVILY_GATEWAY_BASE_URL + MYSEARCH_TAVILY_GATEWAY_SEARCH_PATH + MYSEARCH_TAVILY_GATEWAY_EXTRACT_PATH + MYSEARCH_TAVILY_GATEWAY_AUTH_MODE + MYSEARCH_TAVILY_GATEWAY_AUTH_HEADER + MYSEARCH_TAVILY_GATEWAY_AUTH_SCHEME + MYSEARCH_TAVILY_GATEWAY_AUTH_FIELD + MYSEARCH_TAVILY_GATEWAY_TOKEN + MYSEARCH_TAVILY_GATEWAY_TOKENS MYSEARCH_FIRECRAWL_BASE_URL MYSEARCH_FIRECRAWL_SEARCH_PATH MYSEARCH_FIRECRAWL_SCRAPE_PATH diff --git a/mysearch/.env.example b/mysearch/.env.example index a27524d..57fb3cd 100644 --- a/mysearch/.env.example +++ b/mysearch/.env.example @@ -23,6 +23,9 @@ MYSEARCH_MCP_STREAMABLE_HTTP_PATH=/mcp MYSEARCH_MCP_STATELESS_HTTP=false # Tavily +MYSEARCH_TAVILY_MODE=official + +# official 模式:自己维护 Tavily 官方 key 池 MYSEARCH_TAVILY_BASE_URL=https://api.tavily.com MYSEARCH_TAVILY_SEARCH_PATH=/search MYSEARCH_TAVILY_EXTRACT_PATH=/extract @@ -34,6 +37,17 @@ MYSEARCH_TAVILY_API_KEY= MYSEARCH_TAVILY_API_KEYS= MYSEARCH_TAVILY_KEYS_FILE=accounts.txt +# gateway 模式:例如 tavily-hikari;建议把 BASE_URL 直接写到 /api/tavily +MYSEARCH_TAVILY_GATEWAY_BASE_URL= +MYSEARCH_TAVILY_GATEWAY_SEARCH_PATH=/search +MYSEARCH_TAVILY_GATEWAY_EXTRACT_PATH=/extract +MYSEARCH_TAVILY_GATEWAY_AUTH_MODE=bearer +MYSEARCH_TAVILY_GATEWAY_AUTH_HEADER=Authorization +MYSEARCH_TAVILY_GATEWAY_AUTH_SCHEME=Bearer +MYSEARCH_TAVILY_GATEWAY_AUTH_FIELD=api_key +MYSEARCH_TAVILY_GATEWAY_TOKEN= +MYSEARCH_TAVILY_GATEWAY_TOKENS= + # Firecrawl MYSEARCH_FIRECRAWL_BASE_URL=https://api.firecrawl.dev MYSEARCH_FIRECRAWL_SEARCH_PATH=/v2/search @@ -79,6 +93,11 @@ MYSEARCH_XAI_MODEL=grok-4.20-beta-latest-non-reasoning # MYSEARCH_XAI_SEARCH_MODE=compatible # MYSEARCH_XAI_API_KEY=your-social-gateway-token +# Tavily gateway 示例: +# MYSEARCH_TAVILY_MODE=gateway +# MYSEARCH_TAVILY_GATEWAY_BASE_URL=http://127.0.0.1:8787/api/tavily +# MYSEARCH_TAVILY_GATEWAY_TOKEN=th-xxxx-xxxxxxxxxxxx + # MySearch 内置 social gateway(可选) SOCIAL_GATEWAY_UPSTREAM_BASE_URL=https://api.x.ai/v1 SOCIAL_GATEWAY_UPSTREAM_RESPONSES_PATH=/responses diff --git a/mysearch/README.md b/mysearch/README.md index cea1268..30a6dc0 100644 --- a/mysearch/README.md +++ b/mysearch/README.md @@ -67,6 +67,13 @@ MYSEARCH_PROXY_API_KEY=mysp-... 如果你还没有 Proxy,也可以直接连 provider。 +现在 Tavily 也支持显式两种接法: + +- `MYSEARCH_TAVILY_MODE=official` + - 自己导入和轮询 Tavily 官方 key +- `MYSEARCH_TAVILY_MODE=gateway` + - 用上游 gateway token 访问兼容网关,例如 `tavily-hikari` + ## 直连 provider 的最小配置 最小直连通常至少需要: @@ -76,6 +83,23 @@ MYSEARCH_TAVILY_API_KEY=tvly-... MYSEARCH_FIRECRAWL_API_KEY=fc-... ``` +如果你要让 Tavily 走上游 gateway: + +```env +MYSEARCH_TAVILY_MODE=gateway +MYSEARCH_TAVILY_GATEWAY_BASE_URL=http://127.0.0.1:8787/api/tavily +MYSEARCH_TAVILY_GATEWAY_TOKEN=th-xxxx-xxxxxxxxxxxx +MYSEARCH_FIRECRAWL_API_KEY=fc-... +``` + +如果你明确不走上游 gateway,就保持: + +```env +MYSEARCH_TAVILY_MODE=official +MYSEARCH_TAVILY_API_KEYS=tvly-a,tvly-b +MYSEARCH_TAVILY_KEYS_FILE=accounts.txt +``` + 如果你也要接 Exa: ```env diff --git a/mysearch/clients.py b/mysearch/clients.py index 47509a0..ff6115b 100644 --- a/mysearch/clients.py +++ b/mysearch/clients.py @@ -2733,6 +2733,7 @@ def _describe_provider( return { "base_url": provider.base_url, "alternate_base_urls": provider.alternate_base_urls, + "provider_mode": provider.provider_mode, "auth_mode": provider.auth_mode, "paths": provider.default_paths, "search_mode": provider.search_mode, @@ -2747,6 +2748,15 @@ def _describe_provider( def _get_key_or_raise(self, provider: ProviderConfig): record = self.keyring.get_next(provider.name) if record is None: + if provider.name == "tavily": + raise MySearchError( + "Tavily is not configured. Use " + "MYSEARCH_TAVILY_MODE=gateway with MYSEARCH_TAVILY_GATEWAY_TOKEN " + "to consume an upstream gateway, or keep " + "MYSEARCH_TAVILY_MODE=official and import your own Tavily keys " + "with MYSEARCH_TAVILY_API_KEY / MYSEARCH_TAVILY_API_KEYS / " + "MYSEARCH_TAVILY_KEYS_FILE." + ) if provider.name == "xai": raise MySearchError( "xAI / Social search is not configured; MySearch can still use " diff --git a/mysearch/config.py b/mysearch/config.py index 9115dd8..e8169c1 100644 --- a/mysearch/config.py +++ b/mysearch/config.py @@ -16,6 +16,7 @@ MODULE_DIR = Path(__file__).resolve().parent ROOT_DIR = MODULE_DIR.parent AuthMode = Literal["bearer", "body"] +TavilyMode = Literal["official", "gateway"] XAISearchMode = Literal["official", "compatible"] MCPTransport = Literal["stdio", "sse", "streamable-http"] @@ -196,6 +197,46 @@ def _provider_path( return _normalize_path(default) +def _get_tavily_mode(proxy_base_url: str) -> TavilyMode: + explicit = _get_str("MYSEARCH_TAVILY_MODE") + if explicit: + return explicit # type: ignore[return-value] + if _get_str( + "MYSEARCH_TAVILY_GATEWAY_BASE_URL", + "MYSEARCH_TAVILY_GATEWAY_TOKEN", + "MYSEARCH_TAVILY_GATEWAY_API_KEY", + ) or _get_list("MYSEARCH_TAVILY_GATEWAY_TOKENS", "MYSEARCH_TAVILY_GATEWAY_API_KEYS"): + return "gateway" + return "gateway" if proxy_base_url else "official" + + +def _tavily_gateway_base_url(proxy_base_url: str, default: str) -> str: + explicit = _get_str("MYSEARCH_TAVILY_GATEWAY_BASE_URL") + if explicit: + return _normalize_base_url(explicit) + if proxy_base_url: + return _normalize_base_url(proxy_base_url) + return _normalize_base_url(default) + + +def _tavily_gateway_path( + *, + explicit_name: str, + explicit_gateway_base_url: str, + proxy_base_url: str, + proxy_default: str, + default: str, +) -> str: + explicit = _get_str(explicit_name) + if explicit: + return _normalize_path(explicit) + if explicit_gateway_base_url: + return _normalize_path(default) + if proxy_base_url: + return _normalize_path(proxy_default) + return _normalize_path(default) + + _bootstrap_runtime_env() @@ -209,6 +250,7 @@ class ProviderConfig: auth_field: str default_paths: dict[str, str] alternate_base_urls: dict[str, str] = field(default_factory=dict) + provider_mode: str = "" search_mode: XAISearchMode = "official" api_keys: list[str] = field(default_factory=list) keys_file: Path | None = None @@ -243,6 +285,12 @@ class MySearchConfig: def from_env(cls) -> "MySearchConfig": proxy_base_url = _get_str("MYSEARCH_PROXY_BASE_URL") proxy_api_key = _get_str("MYSEARCH_PROXY_API_KEY") + tavily_mode = _get_tavily_mode(proxy_base_url) + tavily_gateway_base_url = _get_str("MYSEARCH_TAVILY_GATEWAY_BASE_URL") + tavily_gateway_token = _get_str( + "MYSEARCH_TAVILY_GATEWAY_TOKEN", + "MYSEARCH_TAVILY_GATEWAY_API_KEY", + ) return cls( server_name=_get_str("MYSEARCH_NAME", "MYSEARCH_SERVER_NAME", default="MySearch"), timeout_seconds=_get_int("MYSEARCH_TIMEOUT_SECONDS", 45), @@ -263,44 +311,104 @@ def from_env(cls) -> "MySearchConfig": mcp_stateless_http=_get_bool("MYSEARCH_MCP_STATELESS_HTTP", False), tavily=ProviderConfig( name="tavily", - base_url=_provider_base_url( - explicit_names=("MYSEARCH_TAVILY_BASE_URL",), - proxy_base_url=proxy_base_url, - default="https://api.tavily.com", + base_url=( + _tavily_gateway_base_url( + proxy_base_url=proxy_base_url, + default="https://api.tavily.com", + ) + if tavily_mode == "gateway" + else _provider_base_url( + explicit_names=("MYSEARCH_TAVILY_BASE_URL",), + proxy_base_url="", + default="https://api.tavily.com", + ) ), - auth_mode=_get_str( - "MYSEARCH_TAVILY_AUTH_MODE", - default="bearer" if proxy_base_url else "body", + auth_mode=( + _get_str( + "MYSEARCH_TAVILY_GATEWAY_AUTH_MODE", + default="bearer", + ) + if tavily_mode == "gateway" + else _get_str("MYSEARCH_TAVILY_AUTH_MODE", default="body") ), # type: ignore[arg-type] - auth_header=_get_str("MYSEARCH_TAVILY_AUTH_HEADER", default="Authorization"), - auth_scheme=_get_str("MYSEARCH_TAVILY_AUTH_SCHEME", default="Bearer"), - auth_field=_get_str("MYSEARCH_TAVILY_AUTH_FIELD", default="api_key"), + auth_header=( + _get_str("MYSEARCH_TAVILY_GATEWAY_AUTH_HEADER", default="Authorization") + if tavily_mode == "gateway" + else _get_str("MYSEARCH_TAVILY_AUTH_HEADER", default="Authorization") + ), + auth_scheme=( + _get_str("MYSEARCH_TAVILY_GATEWAY_AUTH_SCHEME", default="Bearer") + if tavily_mode == "gateway" + else _get_str("MYSEARCH_TAVILY_AUTH_SCHEME", default="Bearer") + ), + auth_field=( + _get_str("MYSEARCH_TAVILY_GATEWAY_AUTH_FIELD", default="api_key") + if tavily_mode == "gateway" + else _get_str("MYSEARCH_TAVILY_AUTH_FIELD", default="api_key") + ), default_paths={ - "search": _provider_path( - explicit_name="MYSEARCH_TAVILY_SEARCH_PATH", - proxy_base_url=proxy_base_url, - proxy_default="/api/search", - default="/search", + "search": ( + _tavily_gateway_path( + explicit_name="MYSEARCH_TAVILY_GATEWAY_SEARCH_PATH", + explicit_gateway_base_url=tavily_gateway_base_url, + proxy_base_url=proxy_base_url, + proxy_default="/api/search", + default="/search", + ) + if tavily_mode == "gateway" + else _provider_path( + explicit_name="MYSEARCH_TAVILY_SEARCH_PATH", + proxy_base_url="", + proxy_default="/api/search", + default="/search", + ) ), - "extract": _provider_path( - explicit_name="MYSEARCH_TAVILY_EXTRACT_PATH", - proxy_base_url=proxy_base_url, - proxy_default="/api/extract", - default="/extract", + "extract": ( + _tavily_gateway_path( + explicit_name="MYSEARCH_TAVILY_GATEWAY_EXTRACT_PATH", + explicit_gateway_base_url=tavily_gateway_base_url, + proxy_base_url=proxy_base_url, + proxy_default="/api/extract", + default="/extract", + ) + if tavily_mode == "gateway" + else _provider_path( + explicit_name="MYSEARCH_TAVILY_EXTRACT_PATH", + proxy_base_url="", + proxy_default="/api/extract", + default="/extract", + ) ), }, + provider_mode=tavily_mode, api_keys=[ - *_get_list("MYSEARCH_TAVILY_API_KEYS"), + *( + _get_list( + "MYSEARCH_TAVILY_GATEWAY_TOKENS", + "MYSEARCH_TAVILY_GATEWAY_API_KEYS", + ) + if tavily_mode == "gateway" + else _get_list("MYSEARCH_TAVILY_API_KEYS") + ), + *( + [tavily_gateway_token] + if tavily_mode == "gateway" and tavily_gateway_token + else ([proxy_api_key] if tavily_mode == "gateway" and proxy_api_key else []) + ), *( [_get_str("MYSEARCH_TAVILY_API_KEY")] - if _get_str("MYSEARCH_TAVILY_API_KEY") - else ([proxy_api_key] if proxy_api_key else []) + if tavily_mode != "gateway" and _get_str("MYSEARCH_TAVILY_API_KEY") + else [] ), ], - keys_file=_resolve_path( - "MYSEARCH_TAVILY_KEYS_FILE", - "MYSEARCH_TAVILY_ACCOUNTS_FILE", - default_name="accounts.txt", + keys_file=( + None + if tavily_mode == "gateway" + else _resolve_path( + "MYSEARCH_TAVILY_KEYS_FILE", + "MYSEARCH_TAVILY_ACCOUNTS_FILE", + default_name="accounts.txt", + ) ), ), firecrawl=ProviderConfig( diff --git a/openclaw/.env.example b/openclaw/.env.example index 5a3af32..68f4b34 100644 --- a/openclaw/.env.example +++ b/openclaw/.env.example @@ -10,6 +10,9 @@ MYSEARCH_PROXY_BASE_URL= MYSEARCH_PROXY_API_KEY= # Tavily +MYSEARCH_TAVILY_MODE=official + +# official 模式:自己维护 Tavily 官方 key 池 MYSEARCH_TAVILY_BASE_URL=https://api.tavily.com MYSEARCH_TAVILY_SEARCH_PATH=/search MYSEARCH_TAVILY_EXTRACT_PATH=/extract @@ -21,6 +24,17 @@ MYSEARCH_TAVILY_API_KEY= MYSEARCH_TAVILY_API_KEYS= MYSEARCH_TAVILY_KEYS_FILE= +# gateway 模式:例如 tavily-hikari;建议把 BASE_URL 直接写到 /api/tavily +MYSEARCH_TAVILY_GATEWAY_BASE_URL= +MYSEARCH_TAVILY_GATEWAY_SEARCH_PATH=/search +MYSEARCH_TAVILY_GATEWAY_EXTRACT_PATH=/extract +MYSEARCH_TAVILY_GATEWAY_AUTH_MODE=bearer +MYSEARCH_TAVILY_GATEWAY_AUTH_HEADER=Authorization +MYSEARCH_TAVILY_GATEWAY_AUTH_SCHEME=Bearer +MYSEARCH_TAVILY_GATEWAY_AUTH_FIELD=api_key +MYSEARCH_TAVILY_GATEWAY_TOKEN= +MYSEARCH_TAVILY_GATEWAY_TOKENS= + # Firecrawl MYSEARCH_FIRECRAWL_BASE_URL=https://api.firecrawl.dev MYSEARCH_FIRECRAWL_SEARCH_PATH=/v2/search @@ -64,3 +78,8 @@ MYSEARCH_XAI_MODEL=grok-4.20-beta-latest-non-reasoning # MYSEARCH_XAI_SOCIAL_BASE_URL=https://your-social-gateway.example.com # MYSEARCH_XAI_SEARCH_MODE=compatible # MYSEARCH_XAI_API_KEY=your-gateway-token + +# Tavily gateway 示例: +# MYSEARCH_TAVILY_MODE=gateway +# MYSEARCH_TAVILY_GATEWAY_BASE_URL=http://127.0.0.1:8787/api/tavily +# MYSEARCH_TAVILY_GATEWAY_TOKEN=th-xxxx-xxxxxxxxxxxx diff --git a/openclaw/public.json b/openclaw/public.json index 84d480c..f2e1c93 100644 --- a/openclaw/public.json +++ b/openclaw/public.json @@ -15,7 +15,10 @@ "MYSEARCH_PROXY_API_KEY" ], "optional_env": [ + "MYSEARCH_TAVILY_MODE", "MYSEARCH_TAVILY_API_KEY", + "MYSEARCH_TAVILY_GATEWAY_BASE_URL", + "MYSEARCH_TAVILY_GATEWAY_TOKEN", "MYSEARCH_FIRECRAWL_API_KEY", "MYSEARCH_EXA_API_KEY", "MYSEARCH_XAI_API_KEY" diff --git a/openclaw/runtime/mysearch/clients.py b/openclaw/runtime/mysearch/clients.py index 47509a0..ff6115b 100644 --- a/openclaw/runtime/mysearch/clients.py +++ b/openclaw/runtime/mysearch/clients.py @@ -2733,6 +2733,7 @@ def _describe_provider( return { "base_url": provider.base_url, "alternate_base_urls": provider.alternate_base_urls, + "provider_mode": provider.provider_mode, "auth_mode": provider.auth_mode, "paths": provider.default_paths, "search_mode": provider.search_mode, @@ -2747,6 +2748,15 @@ def _describe_provider( def _get_key_or_raise(self, provider: ProviderConfig): record = self.keyring.get_next(provider.name) if record is None: + if provider.name == "tavily": + raise MySearchError( + "Tavily is not configured. Use " + "MYSEARCH_TAVILY_MODE=gateway with MYSEARCH_TAVILY_GATEWAY_TOKEN " + "to consume an upstream gateway, or keep " + "MYSEARCH_TAVILY_MODE=official and import your own Tavily keys " + "with MYSEARCH_TAVILY_API_KEY / MYSEARCH_TAVILY_API_KEYS / " + "MYSEARCH_TAVILY_KEYS_FILE." + ) if provider.name == "xai": raise MySearchError( "xAI / Social search is not configured; MySearch can still use " diff --git a/openclaw/runtime/mysearch/config.py b/openclaw/runtime/mysearch/config.py index 9115dd8..e8169c1 100644 --- a/openclaw/runtime/mysearch/config.py +++ b/openclaw/runtime/mysearch/config.py @@ -16,6 +16,7 @@ MODULE_DIR = Path(__file__).resolve().parent ROOT_DIR = MODULE_DIR.parent AuthMode = Literal["bearer", "body"] +TavilyMode = Literal["official", "gateway"] XAISearchMode = Literal["official", "compatible"] MCPTransport = Literal["stdio", "sse", "streamable-http"] @@ -196,6 +197,46 @@ def _provider_path( return _normalize_path(default) +def _get_tavily_mode(proxy_base_url: str) -> TavilyMode: + explicit = _get_str("MYSEARCH_TAVILY_MODE") + if explicit: + return explicit # type: ignore[return-value] + if _get_str( + "MYSEARCH_TAVILY_GATEWAY_BASE_URL", + "MYSEARCH_TAVILY_GATEWAY_TOKEN", + "MYSEARCH_TAVILY_GATEWAY_API_KEY", + ) or _get_list("MYSEARCH_TAVILY_GATEWAY_TOKENS", "MYSEARCH_TAVILY_GATEWAY_API_KEYS"): + return "gateway" + return "gateway" if proxy_base_url else "official" + + +def _tavily_gateway_base_url(proxy_base_url: str, default: str) -> str: + explicit = _get_str("MYSEARCH_TAVILY_GATEWAY_BASE_URL") + if explicit: + return _normalize_base_url(explicit) + if proxy_base_url: + return _normalize_base_url(proxy_base_url) + return _normalize_base_url(default) + + +def _tavily_gateway_path( + *, + explicit_name: str, + explicit_gateway_base_url: str, + proxy_base_url: str, + proxy_default: str, + default: str, +) -> str: + explicit = _get_str(explicit_name) + if explicit: + return _normalize_path(explicit) + if explicit_gateway_base_url: + return _normalize_path(default) + if proxy_base_url: + return _normalize_path(proxy_default) + return _normalize_path(default) + + _bootstrap_runtime_env() @@ -209,6 +250,7 @@ class ProviderConfig: auth_field: str default_paths: dict[str, str] alternate_base_urls: dict[str, str] = field(default_factory=dict) + provider_mode: str = "" search_mode: XAISearchMode = "official" api_keys: list[str] = field(default_factory=list) keys_file: Path | None = None @@ -243,6 +285,12 @@ class MySearchConfig: def from_env(cls) -> "MySearchConfig": proxy_base_url = _get_str("MYSEARCH_PROXY_BASE_URL") proxy_api_key = _get_str("MYSEARCH_PROXY_API_KEY") + tavily_mode = _get_tavily_mode(proxy_base_url) + tavily_gateway_base_url = _get_str("MYSEARCH_TAVILY_GATEWAY_BASE_URL") + tavily_gateway_token = _get_str( + "MYSEARCH_TAVILY_GATEWAY_TOKEN", + "MYSEARCH_TAVILY_GATEWAY_API_KEY", + ) return cls( server_name=_get_str("MYSEARCH_NAME", "MYSEARCH_SERVER_NAME", default="MySearch"), timeout_seconds=_get_int("MYSEARCH_TIMEOUT_SECONDS", 45), @@ -263,44 +311,104 @@ def from_env(cls) -> "MySearchConfig": mcp_stateless_http=_get_bool("MYSEARCH_MCP_STATELESS_HTTP", False), tavily=ProviderConfig( name="tavily", - base_url=_provider_base_url( - explicit_names=("MYSEARCH_TAVILY_BASE_URL",), - proxy_base_url=proxy_base_url, - default="https://api.tavily.com", + base_url=( + _tavily_gateway_base_url( + proxy_base_url=proxy_base_url, + default="https://api.tavily.com", + ) + if tavily_mode == "gateway" + else _provider_base_url( + explicit_names=("MYSEARCH_TAVILY_BASE_URL",), + proxy_base_url="", + default="https://api.tavily.com", + ) ), - auth_mode=_get_str( - "MYSEARCH_TAVILY_AUTH_MODE", - default="bearer" if proxy_base_url else "body", + auth_mode=( + _get_str( + "MYSEARCH_TAVILY_GATEWAY_AUTH_MODE", + default="bearer", + ) + if tavily_mode == "gateway" + else _get_str("MYSEARCH_TAVILY_AUTH_MODE", default="body") ), # type: ignore[arg-type] - auth_header=_get_str("MYSEARCH_TAVILY_AUTH_HEADER", default="Authorization"), - auth_scheme=_get_str("MYSEARCH_TAVILY_AUTH_SCHEME", default="Bearer"), - auth_field=_get_str("MYSEARCH_TAVILY_AUTH_FIELD", default="api_key"), + auth_header=( + _get_str("MYSEARCH_TAVILY_GATEWAY_AUTH_HEADER", default="Authorization") + if tavily_mode == "gateway" + else _get_str("MYSEARCH_TAVILY_AUTH_HEADER", default="Authorization") + ), + auth_scheme=( + _get_str("MYSEARCH_TAVILY_GATEWAY_AUTH_SCHEME", default="Bearer") + if tavily_mode == "gateway" + else _get_str("MYSEARCH_TAVILY_AUTH_SCHEME", default="Bearer") + ), + auth_field=( + _get_str("MYSEARCH_TAVILY_GATEWAY_AUTH_FIELD", default="api_key") + if tavily_mode == "gateway" + else _get_str("MYSEARCH_TAVILY_AUTH_FIELD", default="api_key") + ), default_paths={ - "search": _provider_path( - explicit_name="MYSEARCH_TAVILY_SEARCH_PATH", - proxy_base_url=proxy_base_url, - proxy_default="/api/search", - default="/search", + "search": ( + _tavily_gateway_path( + explicit_name="MYSEARCH_TAVILY_GATEWAY_SEARCH_PATH", + explicit_gateway_base_url=tavily_gateway_base_url, + proxy_base_url=proxy_base_url, + proxy_default="/api/search", + default="/search", + ) + if tavily_mode == "gateway" + else _provider_path( + explicit_name="MYSEARCH_TAVILY_SEARCH_PATH", + proxy_base_url="", + proxy_default="/api/search", + default="/search", + ) ), - "extract": _provider_path( - explicit_name="MYSEARCH_TAVILY_EXTRACT_PATH", - proxy_base_url=proxy_base_url, - proxy_default="/api/extract", - default="/extract", + "extract": ( + _tavily_gateway_path( + explicit_name="MYSEARCH_TAVILY_GATEWAY_EXTRACT_PATH", + explicit_gateway_base_url=tavily_gateway_base_url, + proxy_base_url=proxy_base_url, + proxy_default="/api/extract", + default="/extract", + ) + if tavily_mode == "gateway" + else _provider_path( + explicit_name="MYSEARCH_TAVILY_EXTRACT_PATH", + proxy_base_url="", + proxy_default="/api/extract", + default="/extract", + ) ), }, + provider_mode=tavily_mode, api_keys=[ - *_get_list("MYSEARCH_TAVILY_API_KEYS"), + *( + _get_list( + "MYSEARCH_TAVILY_GATEWAY_TOKENS", + "MYSEARCH_TAVILY_GATEWAY_API_KEYS", + ) + if tavily_mode == "gateway" + else _get_list("MYSEARCH_TAVILY_API_KEYS") + ), + *( + [tavily_gateway_token] + if tavily_mode == "gateway" and tavily_gateway_token + else ([proxy_api_key] if tavily_mode == "gateway" and proxy_api_key else []) + ), *( [_get_str("MYSEARCH_TAVILY_API_KEY")] - if _get_str("MYSEARCH_TAVILY_API_KEY") - else ([proxy_api_key] if proxy_api_key else []) + if tavily_mode != "gateway" and _get_str("MYSEARCH_TAVILY_API_KEY") + else [] ), ], - keys_file=_resolve_path( - "MYSEARCH_TAVILY_KEYS_FILE", - "MYSEARCH_TAVILY_ACCOUNTS_FILE", - default_name="accounts.txt", + keys_file=( + None + if tavily_mode == "gateway" + else _resolve_path( + "MYSEARCH_TAVILY_KEYS_FILE", + "MYSEARCH_TAVILY_ACCOUNTS_FILE", + default_name="accounts.txt", + ) ), ), firecrawl=ProviderConfig( diff --git a/openclaw/scripts/install_openclaw_skill.sh b/openclaw/scripts/install_openclaw_skill.sh index 344bab7..fcb6e74 100755 --- a/openclaw/scripts/install_openclaw_skill.sh +++ b/openclaw/scripts/install_openclaw_skill.sh @@ -84,7 +84,8 @@ What changed: Next steps: 1. Prefer injecting env via OpenClaw skill config instead of copying secrets into the skill folder 2. Minimal trusted setup: MYSEARCH_PROXY_BASE_URL + MYSEARCH_PROXY_API_KEY -3. If you do not have a proxy yet, fall back to MYSEARCH_TAVILY_API_KEY + MYSEARCH_FIRECRAWL_API_KEY -4. Only use --copy-env or $TARGET_DIR/.env for local debugging -5. Run: python3 $TARGET_DIR/scripts/mysearch_openclaw.py health +3. If you want Tavily to consume an upstream gateway, set MYSEARCH_TAVILY_MODE=gateway + MYSEARCH_TAVILY_GATEWAY_BASE_URL + MYSEARCH_TAVILY_GATEWAY_TOKEN +4. If you do not have a proxy yet, fall back to MYSEARCH_TAVILY_API_KEY + MYSEARCH_FIRECRAWL_API_KEY +5. Only use --copy-env or $TARGET_DIR/.env for local debugging +6. Run: python3 $TARGET_DIR/scripts/mysearch_openclaw.py health EOF diff --git a/proxy/server.py b/proxy/server.py index 556df97..001b37f 100644 --- a/proxy/server.py +++ b/proxy/server.py @@ -14,6 +14,7 @@ import httpx from fastapi import Depends, FastAPI, HTTPException, Request from fastapi.responses import HTMLResponse, JSONResponse, Response +from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates import database as db @@ -23,6 +24,8 @@ ADMIN_SESSION_COOKIE = os.environ.get("ADMIN_SESSION_COOKIE", "mysearch_proxy_session") ADMIN_SESSION_MAX_AGE = max(300, int(os.environ.get("ADMIN_SESSION_MAX_AGE", "2592000"))) TAVILY_API_BASE = "https://api.tavily.com" +TAVILY_SEARCH_PATH = "/search" +TAVILY_EXTRACT_PATH = "/extract" FIRECRAWL_API_BASE = "https://api.firecrawl.dev" EXA_API_BASE = "https://api.exa.ai" @@ -108,6 +111,7 @@ def _derive_social_gateway_admin_base_url(upstream_base_url): } app = FastAPI(title="MySearch Proxy") +app.mount("/static", StaticFiles(directory=os.path.join(os.path.dirname(__file__), "static")), name="static") templates = Jinja2Templates(directory=os.path.join(os.path.dirname(__file__), "templates")) http_client = httpx.AsyncClient(timeout=60) social_gateway_state_cache = {"expires_at": 0.0, "value": None} @@ -266,6 +270,48 @@ def get_runtime_social_config(): } +def get_runtime_tavily_config(): + mode = get_setting_text("tavily_mode", "pool").lower() + if mode not in {"pool", "upstream"}: + mode = "pool" + + upstream_base_url = ( + get_setting_text("tavily_upstream_base_url", TAVILY_API_BASE).rstrip("/") + or TAVILY_API_BASE + ) + return { + "mode": mode, + "upstream_base_url": upstream_base_url, + "upstream_search_path": _normalize_path( + get_setting_text("tavily_upstream_search_path", TAVILY_SEARCH_PATH), + TAVILY_SEARCH_PATH, + ), + "upstream_extract_path": _normalize_path( + get_setting_text("tavily_upstream_extract_path", TAVILY_EXTRACT_PATH), + TAVILY_EXTRACT_PATH, + ), + "upstream_api_key": get_setting_text("tavily_upstream_api_key", ""), + } + + +def build_tavily_routing_meta(config, active_keys): + using_upstream = config["mode"] == "upstream" + summary = ( + "当前 Tavily 走上游 Gateway;切回本地 Key 池模式后才会重新使用这里导入的 Tavily keys。" + if using_upstream + else "当前 Tavily 走本地 Key 池,请求会从已导入的 Tavily keys 中轮询。" + ) + return { + "mode": config["mode"], + "upstream_base_url": config["upstream_base_url"], + "upstream_search_path": config["upstream_search_path"], + "upstream_extract_path": config["upstream_extract_path"], + "upstream_api_key_configured": bool(config["upstream_api_key"]), + "local_key_count": len(active_keys), + "summary": summary, + } + + # ═══ Auth helpers ═══ def verify_admin(request: Request): @@ -751,6 +797,18 @@ async def sync_usage_cache(force=False, key_id=None, service=None): else: rows = [dict(row) for row in db.get_all_keys(service)] + if rows and all((row.get("service") or "tavily") == "tavily" for row in rows): + tavily_config = get_runtime_tavily_config() + if tavily_config["mode"] == "upstream": + return { + "requested": len(rows), + "synced": 0, + "skipped": len(rows), + "errors": 0, + "supported": False, + "detail": "当前走 Tavily 上游 Gateway,本地 Key 池额度同步已停用", + } + if not rows: return {"requested": 0, "synced": 0, "skipped": 0, "errors": 0} @@ -776,6 +834,17 @@ async def worker(row): def build_usage_sync_meta_for_dashboard(service, active_keys): + if service == "tavily" and get_runtime_tavily_config()["mode"] == "upstream": + return { + "supported": False, + "requested": len(active_keys), + "synced": 0, + "skipped": len(active_keys), + "errors": 0, + "stale_keys": 0, + "detail": "当前走 Tavily 上游 Gateway,本地 Key 池额度同步已停用", + } + if service == "exa": return { "supported": False, @@ -806,6 +875,8 @@ def build_usage_sync_meta_for_dashboard(service, active_keys): async def schedule_background_usage_sync(service, active_keys): if not DASHBOARD_BACKGROUND_SYNC_ON_STATS: return + if service == "tavily" and get_runtime_tavily_config()["mode"] == "upstream": + return if service == "exa": return if not active_keys: @@ -901,12 +972,15 @@ async def build_service_dashboard(service, auto_sync=False): token["stats"] = db.get_usage_stats(token_id=token["id"], service=service) keys = mask_key_rows([dict(key) for key in db.get_all_keys(service)]) active_keys = [key for key in keys if key["active"]] + routing = None + if service == "tavily": + routing = build_tavily_routing_meta(get_runtime_tavily_config(), active_keys) if auto_sync: sync_result = await sync_usage_cache(force=False, service=service) else: sync_result = build_usage_sync_meta_for_dashboard(service, active_keys) await schedule_background_usage_sync(service, active_keys) - return { + payload = { "service": service, "label": SERVICE_LABELS[service], "overview": overview, @@ -917,6 +991,9 @@ async def build_service_dashboard(service, auto_sync=False): "real_quota": build_real_quota_summary(active_keys), "usage_sync": sync_result, } + if routing is not None: + payload["routing"] = routing + return payload async def build_mysearch_dashboard(): @@ -968,9 +1045,18 @@ async def build_social_dashboard(): async def build_settings_payload(): + tavily = get_runtime_tavily_config() config = get_runtime_social_config() state = await resolve_social_gateway_state(force=False) return { + "tavily": { + "mode": tavily["mode"], + "upstream_base_url": tavily["upstream_base_url"], + "upstream_search_path": tavily["upstream_search_path"], + "upstream_extract_path": tavily["upstream_extract_path"], + "upstream_api_key_configured": bool(tavily["upstream_api_key"]), + "upstream_api_key_masked": mask_secret(tavily["upstream_api_key"]), + }, "social": { "upstream_base_url": config["upstream_base_url"], "upstream_responses_path": config["upstream_responses_path"], @@ -1660,23 +1746,58 @@ async def proxy_tavily(request: Request): token_value = extract_token(request, body) token_row = get_token_row_or_401(token_value, "tavily") - key_info = pool.get_next_key("tavily") - if not key_info: - raise HTTPException(status_code=503, detail="No available API keys") + config = get_runtime_tavily_config() + path_map = { + "search": config["upstream_search_path"], + "extract": config["upstream_extract_path"], + } + upstream_path = path_map.get(endpoint) + if not upstream_path: + raise HTTPException(status_code=400, detail=f"Unsupported Tavily endpoint: {endpoint}") + + upstream_base_url = TAVILY_API_BASE + upstream_key = "" + key_info = None + if config["mode"] == "upstream": + upstream_base_url = config["upstream_base_url"] + upstream_key = config["upstream_api_key"] + if not upstream_key: + raise HTTPException(status_code=503, detail="Missing Tavily upstream API key") + else: + key_info = pool.get_next_key("tavily") + if not key_info: + raise HTTPException(status_code=503, detail="No available API keys") + upstream_key = key_info["key"] - body["api_key"] = key_info["key"] + body["api_key"] = upstream_key start = time.time() try: - resp = await http_client.post(f"{TAVILY_API_BASE}/{endpoint}", json=body) + resp = await http_client.post(f"{upstream_base_url}{upstream_path}", json=body) latency = int((time.time() - start) * 1000) success = resp.status_code == 200 - pool.report_result("tavily", key_info["id"], success) - db.log_usage(token_row["id"], key_info["id"], endpoint, int(success), latency, service="tavily") + if key_info is not None: + pool.report_result("tavily", key_info["id"], success) + db.log_usage( + token_row["id"], + key_info["id"] if key_info is not None else None, + endpoint, + int(success), + latency, + service="tavily", + ) return JSONResponse(content=resp.json(), status_code=resp.status_code) except Exception as exc: latency = int((time.time() - start) * 1000) - pool.report_result("tavily", key_info["id"], False) - db.log_usage(token_row["id"], key_info["id"], endpoint, 0, latency, service="tavily") + if key_info is not None: + pool.report_result("tavily", key_info["id"], False) + db.log_usage( + token_row["id"], + key_info["id"] if key_info is not None else None, + endpoint, + 0, + latency, + service="tavily", + ) raise HTTPException(status_code=502, detail=str(exc)) @@ -1943,6 +2064,43 @@ async def get_settings(request: Request, _=Depends(verify_admin)): return await build_settings_payload() +@app.put("/api/settings/tavily") +async def update_tavily_settings(request: Request, _=Depends(verify_admin)): + body = await request.json() + if not isinstance(body, dict): + raise HTTPException(status_code=400, detail="Expected JSON request body") + + if "mode" in body: + mode = str(body.get("mode") or "").strip().lower() or "pool" + if mode not in {"pool", "upstream"}: + raise HTTPException(status_code=400, detail="mode must be 'pool' or 'upstream'") + db.set_setting("tavily_mode", mode) + + text_fields = { + "upstream_base_url": "tavily_upstream_base_url", + "upstream_search_path": "tavily_upstream_search_path", + "upstream_extract_path": "tavily_upstream_extract_path", + } + for field, setting_key in text_fields.items(): + if field not in body: + continue + value = str(body.get(field) or "").strip() + db.set_setting(setting_key, value) + + if body.get("clear_upstream_api_key"): + db.set_setting("tavily_upstream_api_key", "") + elif "upstream_api_key" in body: + value = str(body.get("upstream_api_key") or "").strip() + if value: + db.set_setting("tavily_upstream_api_key", value) + + reset_stats_cache() + return { + "ok": True, + **(await build_settings_payload()), + } + + @app.put("/api/settings/social") async def update_social_settings(request: Request, _=Depends(verify_admin)): body = await request.json() diff --git a/proxy/static/css/console.css b/proxy/static/css/console.css new file mode 100644 index 0000000..85e491b --- /dev/null +++ b/proxy/static/css/console.css @@ -0,0 +1,670 @@ + +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap'); + +:root { + --bg: #f8fafc; + --bg-soft: #f1f5f9; + --surface: #ffffff; + --surface-strong: #f8fafc; + --surface-muted: #f1f5f9; + --text: #0f172a; + --text-soft: #334155; + --muted: #64748b; + --border: #e2e8f0; + --border-strong: #cbd5e1; + --shadow: 0 4px 20px rgba(15, 23, 42, 0.05); + --shadow-lg: 0 12px 32px rgba(15, 23, 42, 0.08); + --radius: 16px; + --radius-sm: 8px; + + --tavily: #0d9488; + --tavily-soft: rgba(13, 148, 136, 0.1); + --exa: #2563eb; + --exa-soft: rgba(37, 99, 235, 0.1); + --firecrawl: #ea580c; + --firecrawl-soft: rgba(234, 88, 12, 0.1); + --social: #7c3aed; + --social-soft: rgba(124, 58, 237, 0.1); + + --ok: #16a34a; + --warn: #d97706; + --danger: #dc2626; + --primary: #0f172a; + --primary-hover: #1e293b; +} + +body.theme-dark { + --bg: #09090b; + --bg-soft: #121214; + --surface: #18181b; + --surface-strong: #27272a; + --surface-muted: rgba(39, 39, 42, 0.5); + --text: #fafafa; + --text-soft: #a1a1aa; + --muted: #71717a; + --border: #27272a; + --border-strong: #3f3f46; + --shadow: 0 8px 32px rgba(0, 0, 0, 0.4); + --shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.6); + + --tavily: #2dd4bf; + --exa: #60a5fa; + --firecrawl: #fb923c; + --social: #a78bfa; + + --ok: #34d399; + --warn: #fbbf24; + --danger: #f87171; + --primary: #fafafa; + --primary-hover: #e4e4e7; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } +html { scroll-behavior: smooth; } + +body { + min-height: 100vh; + font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + color: var(--text); + background-color: var(--bg); + background-image: + radial-gradient(circle at 15% 50%, rgba(37, 99, 235, 0.03), transparent 25%), + radial-gradient(circle at 85% 30%, rgba(124, 58, 237, 0.03), transparent 25%); + transition: background-color 0.3s ease, color 0.3s ease; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body.theme-dark { + background-image: + radial-gradient(circle at 15% 50%, rgba(37, 99, 235, 0.06), transparent 25%), + radial-gradient(circle at 85% 30%, rgba(124, 58, 237, 0.06), transparent 25%); +} + +.mono { font-family: 'JetBrains Mono', monospace; } +.hidden { display: none !important; } + +/* SVG Icons Utility */ +.icon { + width: 20px; + height: 20px; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; + fill: none; +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 40px 24px; +} + +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); + transition: box-shadow 0.2s ease, border-color 0.2s ease; +} + +/* Auth / Login */ +.auth { + max-width: 420px; + margin: 12vh auto; + padding: 48px 40px; + text-align: center; + border-radius: 24px; +} +.auth-kicker { + font-size: 12px; + font-weight: 700; + letter-spacing: 0.1em; + color: var(--muted); + text-transform: uppercase; + margin-bottom: 16px; +} +.auth h2 { font-size: 28px; margin-bottom: 12px; font-weight: 700; letter-spacing: -0.02em; } +.auth p { color: var(--text-soft); font-size: 14px; margin-bottom: 32px; line-height: 1.6; } +.auth .stack { display: flex; flex-direction: column; gap: 16px; } + +input[type="text"], input[type="password"], select, textarea { + width: 100%; + padding: 12px 16px; + border-radius: var(--radius-sm); + border: 1px solid var(--border-strong); + background: var(--surface); + color: var(--text); + font-family: inherit; + font-size: 14px; + transition: all 0.2s ease; +} +input:focus, select:focus, textarea:focus { + outline: none; + border-color: var(--exa); + box-shadow: 0 0 0 3px var(--exa-soft); +} +input::placeholder { color: var(--muted); } + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 10px 20px; + border-radius: var(--radius-sm); + font-weight: 600; + font-size: 14px; + cursor: pointer; + transition: all 0.2s ease; + border: 1px solid transparent; +} +.btn-primary { + background: var(--primary); + color: var(--bg); +} +.btn-primary:hover { + background: var(--primary-hover); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(0,0,0,0.1); +} +.btn-soft { + background: var(--surface-muted); + color: var(--text); + border-color: var(--border); +} +.btn-soft:hover { + background: var(--surface-strong); + border-color: var(--border-strong); +} +.btn-danger { + background: rgba(239, 68, 68, 0.1); + color: var(--danger); + border-color: rgba(239, 68, 68, 0.2); +} +.btn-danger:hover { + background: rgba(239, 68, 68, 0.15); +} +.btn-ghost { + background: transparent; + color: var(--text-soft); +} +.btn-ghost:hover { + background: var(--surface-muted); + color: var(--text); +} +.btn-sm { + padding: 6px 12px; + font-size: 12px; +} +.user-btn { + background: var(--surface); + border: 1px solid var(--border); + color: var(--text); + padding: 8px 16px; + border-radius: 999px; + font-size: 13px; + font-weight: 500; + display: inline-flex; + align-items: center; + gap: 8px; + cursor: pointer; + transition: all 0.2s ease; +} +.user-btn:hover { + background: var(--surface-muted); + border-color: var(--border-strong); +} + +/* Hero Section */ +.hero { + padding: 48px; + margin-bottom: 32px; + position: relative; + overflow: hidden; + border-radius: 24px; +} +.hero::before { + content: ''; + position: absolute; + top: 0; left: 0; right: 0; height: 4px; + background: linear-gradient(90deg, var(--tavily), var(--exa), var(--social), var(--firecrawl)); +} +.hero-topbar { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 40px; +} +.hero-tags { display: flex; gap: 8px; } +.hero-tag { + padding: 6px 14px; + border-radius: 999px; + font-size: 12px; + font-weight: 600; + background: var(--surface-strong); + border: 1px solid var(--border); + color: var(--text-soft); +} +.hero-actions { display: flex; gap: 12px; } + +.hero-main { + display: grid; + grid-template-columns: 1fr 360px; + gap: 48px; +} +.hero-copy h1 { + font-size: 42px; + font-weight: 800; + letter-spacing: -0.03em; + margin-bottom: 20px; + line-height: 1.15; +} +.hero-copy p { + font-size: 16px; + color: var(--text-soft); + margin-bottom: 32px; + line-height: 1.6; +} +.hero-usage-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; +} +.hero-usage-card { + padding: 20px; + border-radius: 16px; + background: var(--surface-strong); + border: 1px solid var(--border); + transition: border-color 0.2s ease; +} +.hero-usage-card:hover { + border-color: var(--border-strong); +} +.hero-usage-card strong { + display: block; + font-size: 15px; + font-weight: 600; + margin-bottom: 8px; + color: var(--text); +} +.hero-usage-card span { + font-size: 13px; + color: var(--muted); + line-height: 1.5; +} + +.hero-focus { + background: var(--surface-strong); + border: 1px solid var(--border); + border-radius: 20px; + padding: 32px 24px; + display: flex; + flex-direction: column; + gap: 16px; + align-items: center; + text-align: center; + justify-content: center; +} +.hero-focus-kicker { font-size: 12px; font-weight: 700; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; } +.hero-focus-name { font-size: 28px; font-weight: 700; letter-spacing: -0.02em; } +.hero-focus-status { + display: inline-block; + padding: 6px 16px; + border-radius: 999px; + font-size: 13px; + font-weight: 600; + background: var(--surface); + border: 1px solid var(--border); +} +.hero-focus-status.is-ok { color: var(--ok); border-color: rgba(22, 163, 74, 0.3); background: rgba(22, 163, 74, 0.1); } +.hero-focus-status.is-warn { color: var(--warn); border-color: rgba(217, 119, 6, 0.3); background: rgba(217, 119, 6, 0.1); } +.hero-focus-status.is-danger { color: var(--danger); border-color: rgba(220, 38, 38, 0.3); background: rgba(220, 38, 38, 0.1); } +.hero-focus-desc { font-size: 14px; color: var(--text-soft); line-height: 1.5; margin-top: 8px; } +.hero-focus-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; justify-content: center; } + +/* Summary Strip */ +.summary-strip { + display: grid; + grid-template-columns: repeat(6, 1fr); + gap: 16px; + margin-bottom: 32px; +} +.summary-box { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 16px; + padding: 20px; + display: flex; + flex-direction: column; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} +.summary-box:hover { + transform: translateY(-2px); + box-shadow: var(--shadow); +} +.summary-box .label { font-size: 12px; font-weight: 600; color: var(--muted); margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.05em; } +.summary-box .value { font-size: 28px; font-weight: 700; line-height: 1.2; font-family: 'JetBrains Mono', monospace; letter-spacing: -0.05em; color: var(--text); } +.summary-box .hint { font-size: 12px; color: var(--muted); margin-top: auto; padding-top: 12px; line-height: 1.4; } + +/* Switcher Shell */ +.switcher-shell { + padding: 40px; + margin-bottom: 32px; +} +.switcher-head { + display: flex; + justify-content: space-between; + align-items: flex-end; + margin-bottom: 32px; +} +.switcher-eyebrow { font-size: 12px; font-weight: 700; color: var(--muted); text-transform: uppercase; letter-spacing: 0.1em; display: block; margin-bottom: 8px; } +.switcher-head h2 { font-size: 24px; font-weight: 700; letter-spacing: -0.02em; } +.switcher-head p { color: var(--text-soft); margin-top: 8px; font-size: 14px; max-width: 600px; } +.switcher-note { font-size: 12px; color: var(--muted); background: var(--surface-muted); padding: 8px 16px; border-radius: 999px; font-weight: 500; } + +.service-switcher { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 16px; +} +.service-toggle { + text-align: left; + padding: 24px; + border-radius: 20px; + border: 1px solid var(--border); + background: var(--surface-strong); + cursor: pointer; + transition: all 0.2s ease; + color: var(--text); + display: flex; + flex-direction: column; + gap: 20px; +} +.service-toggle:hover { + transform: translateY(-2px); + border-color: var(--border-strong); + box-shadow: var(--shadow); +} +.service-toggle.is-active { + background: var(--surface); + border-color: var(--primary); + box-shadow: 0 0 0 1px var(--primary), var(--shadow); +} +.service-toggle-top { display: flex; justify-content: space-between; align-items: flex-start; } +.service-chip { + display: inline-block; + padding: 4px 10px; + border-radius: 6px; + font-size: 12px; + font-weight: 700; + margin-bottom: 12px; + text-transform: uppercase; + letter-spacing: 0.05em; +} +.service-chip[data-service="tavily"] { background: var(--tavily-soft); color: var(--tavily); } +.service-chip[data-service="exa"] { background: var(--exa-soft); color: var(--exa); } +.service-chip[data-service="firecrawl"] { background: var(--firecrawl-soft); color: var(--firecrawl); } +.service-chip[data-service="social"] { background: var(--social-soft); color: var(--social); } + +.service-toggle-title strong { display: block; font-size: 20px; margin-bottom: 6px; font-weight: 700; } +.service-toggle-title span { font-size: 13px; color: var(--muted); font-family: 'JetBrains Mono', monospace; } +.service-toggle-status { font-size: 12px; font-weight: 600; padding: 6px 12px; border-radius: 999px; background: var(--border); color: var(--text-soft); transition: all 0.2s ease; } +.is-active .service-toggle-status { background: var(--primary); color: var(--bg); } + +.service-toggle-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } +.service-toggle-metric { background: var(--surface); padding: 16px; border-radius: 12px; border: 1px solid var(--border); } +.service-toggle-metric .label { font-size: 11px; color: var(--muted); margin-bottom: 6px; text-transform: uppercase; font-weight: 600; } +.service-toggle-metric .value { font-size: 20px; font-weight: 700; font-family: 'JetBrains Mono', monospace; color: var(--text); } + +.service-toggle-badges { display: flex; gap: 8px; flex-wrap: wrap; } +.service-toggle-badge { font-size: 11px; padding: 4px 10px; border-radius: 6px; background: var(--surface-muted); border: 1px solid var(--border); color: var(--text-soft); font-weight: 500; } + +.service-toggle-foot { display: flex; justify-content: space-between; align-items: center; font-size: 12px; color: var(--muted); border-top: 1px solid var(--border); padding-top: 20px; margin-top: auto; } +.service-toggle-arrow { font-weight: 600; color: var(--text-soft); transition: color 0.2s ease; } +.is-active .service-toggle-arrow { color: var(--primary); } + +/* Service Panels */ +.service-panel { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 24px; + margin-bottom: 32px; + overflow: hidden; + box-shadow: var(--shadow); +} +.service-panel.is-inactive { display: none; } +.service-panel[data-service="tavily"] { border-top: 4px solid var(--tavily); } +.service-panel[data-service="exa"] { border-top: 4px solid var(--exa); } +.service-panel[data-service="firecrawl"] { border-top: 4px solid var(--firecrawl); } +.service-panel[data-service="social"] { border-top: 4px solid var(--social); } + +.service-head { + padding: 40px; + display: flex; + justify-content: space-between; + align-items: flex-start; + border-bottom: 1px solid var(--border); + background: var(--surface-strong); +} +.service-head h2 { font-size: 32px; font-weight: 700; margin-bottom: 12px; letter-spacing: -0.02em; } +.service-head p { color: var(--text-soft); font-size: 15px; max-width: 600px; line-height: 1.6; } +.service-tools { + background: var(--surface); + padding: 20px; + border-radius: 16px; + border: 1px solid var(--border); + min-width: 320px; + display: flex; + flex-direction: column; + gap: 16px; +} +.service-sync-meta { font-size: 12px; color: var(--muted); line-height: 1.5; } + +.service-body { padding: 40px; } + +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 16px; + margin-bottom: 40px; +} +.stat-box { + background: var(--surface-strong); + border: 1px solid var(--border); + border-radius: 16px; + padding: 20px; +} +.stat-box .label { font-size: 11px; font-weight: 600; color: var(--muted); margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.05em; } +.stat-box .value { font-size: 28px; font-weight: 700; font-family: 'JetBrains Mono', monospace; line-height: 1; color: var(--text); } +.stat-box .hint { font-size: 12px; color: var(--text-soft); margin-top: 12px; line-height: 1.4; } + +.section-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; + margin-bottom: 40px; +} +.subcard { + background: var(--surface-strong); + border: 1px solid var(--border); + border-radius: 20px; + padding: 32px; +} +.subcard h3 { font-size: 20px; font-weight: 600; margin-bottom: 12px; } +.subcard .desc { font-size: 14px; color: var(--text-soft); margin-bottom: 24px; line-height: 1.6; } + +.code-toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; } +.code-toolbar .endpoint { font-size: 13px; color: var(--muted); font-family: 'JetBrains Mono', monospace; background: var(--surface-muted); padding: 4px 8px; border-radius: 6px; } +.code-block { + background: #0f172a; + color: #e2e8f0; + padding: 20px; + border-radius: 12px; + font-size: 13px; + overflow-x: auto; + line-height: 1.6; + border: 1px solid #1e293b; +} + +.table-wrap { + border: 1px solid var(--border); + border-radius: 16px; + overflow-x: auto; + background: var(--surface); +} +table { width: 100%; border-collapse: collapse; text-align: left; } +th, td { padding: 16px 20px; border-bottom: 1px solid var(--border); font-size: 14px; vertical-align: middle; } +th { background: var(--surface-strong); font-weight: 600; color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em; } +tr:last-child td { border-bottom: none; } +.table-note { font-size: 12px; color: var(--text-soft); margin-top: 4px; line-height: 1.5; } +.table-actions { display: flex; gap: 8px; } + +.tag { padding: 4px 10px; border-radius: 6px; font-size: 12px; font-weight: 600; } +.tag-ok { background: rgba(22, 163, 74, 0.1); color: var(--ok); } +.tag-off { background: rgba(220, 38, 38, 0.1); color: var(--danger); } + +/* Form Elements Layout */ +.form-row { display: flex; gap: 12px; align-items: center; margin-bottom: 16px; } +.input-grow { flex: 1; } + +/* Settings Modal */ +.settings-modal-shell { + position: fixed; + inset: 0; + z-index: 100; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; +} +.settings-backdrop { + position: absolute; + inset: 0; + background: rgba(15, 23, 42, 0.4); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); +} +.settings-dialog { + position: relative; + z-index: 101; + width: 100%; + max-width: 960px; + max-height: 90vh; + overflow-y: auto; + padding: 40px; + border-radius: 24px; +} +.settings-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 32px; +} +.settings-kicker { font-size: 12px; font-weight: 700; color: var(--muted); text-transform: uppercase; margin-bottom: 8px; letter-spacing: 0.05em; } +.settings-head h2 { font-size: 32px; font-weight: 700; margin-bottom: 12px; letter-spacing: -0.02em; } +.settings-head p { font-size: 15px; color: var(--text-soft); max-width: 600px; line-height: 1.6; } + +.settings-tabs { + display: flex; + gap: 12px; + margin-bottom: 32px; + border-bottom: 1px solid var(--border); + padding-bottom: 16px; +} +.settings-tab { + padding: 10px 20px; + border-radius: 10px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + background: transparent; + border: none; + color: var(--text-soft); + transition: all 0.2s ease; +} +.settings-tab:hover { background: var(--surface-muted); color: var(--text); } +.settings-tab.is-active { background: var(--primary); color: var(--bg); } + +.settings-panel-grid { + display: grid; + grid-template-columns: 280px 1fr; + gap: 40px; +} +.settings-panel-aside h3 { font-size: 18px; font-weight: 600; margin-bottom: 12px; } +.settings-panel-aside p { font-size: 14px; color: var(--text-soft); margin-bottom: 20px; line-height: 1.6; } +.settings-note-list { display: flex; flex-direction: column; gap: 12px; } +.settings-note { font-size: 13px; padding: 12px 16px; border-radius: 10px; background: var(--surface-muted); border: 1px solid var(--border); color: var(--muted); line-height: 1.5; } + +.settings-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; } +.settings-field { display: flex; flex-direction: column; gap: 8px; } +.settings-field.full-width { grid-column: 1 / -1; } +.settings-field label { font-size: 13px; font-weight: 600; color: var(--text); } +.settings-field .hint { font-size: 12px; color: var(--muted); line-height: 1.4; } + +.settings-advanced { margin-top: 32px; padding-top: 32px; border-top: 1px dashed var(--border); } +.settings-advanced summary { font-size: 15px; font-weight: 600; cursor: pointer; margin-bottom: 20px; color: var(--text); } + +.settings-actions { margin-top: 32px; display: flex; gap: 12px; } +.settings-status { margin-top: 20px; padding: 16px; border-radius: 12px; font-size: 14px; background: var(--surface-muted); border: 1px solid var(--border); color: var(--text-soft); } +.settings-status.is-error { color: var(--danger); background: rgba(239, 68, 68, 0.1); border-color: rgba(239, 68, 68, 0.2); } + +.settings-tab-panel { display: block; } +.settings-tab-panel.hidden { display: none !important; } + +/* Credits & Integrations */ +.credit-strip { + background: var(--surface-strong); + border: 1px solid var(--border); + border-radius: 20px; + padding: 24px; + margin-bottom: 32px; + display: flex; + justify-content: space-between; + align-items: center; + gap: 24px; +} +.credit-copy { flex: 1; } +.credit-kicker { font-size: 11px; font-weight: 700; color: var(--muted); text-transform: uppercase; letter-spacing: 0.1em; display: block; margin-bottom: 8px; } +.credit-copy strong { font-size: 18px; font-weight: 700; display: block; margin-bottom: 8px; } +.credit-copy p { font-size: 14px; color: var(--text-soft); margin-bottom: 12px; line-height: 1.6; } +.credit-link { font-size: 13px; font-family: 'JetBrains Mono', monospace; color: var(--exa); text-decoration: none; font-weight: 500; } +.credit-link:hover { text-decoration: underline; } + +.credit-meta { display: flex; gap: 16px; } +.credit-pill { background: var(--surface); border: 1px solid var(--border); padding: 12px 16px; border-radius: 12px; text-align: center; } +.credit-pill .label { font-size: 11px; color: var(--muted); text-transform: uppercase; margin-bottom: 4px; font-weight: 600; } +.credit-pill .value { font-size: 14px; font-weight: 700; color: var(--text); } + +.mysearch-shell { + background: var(--surface-strong); + border: 1px solid var(--border); + border-radius: 24px; + margin-bottom: 32px; + overflow: hidden; + border-left: 4px solid var(--primary); +} +.integration-summary { display: flex; gap: 24px; margin-bottom: 24px; } +.integration-summary-item { background: var(--surface); padding: 16px 20px; border-radius: 12px; border: 1px solid var(--border); flex: 1; } +.integration-summary-item .label { font-size: 12px; font-weight: 600; color: var(--muted); text-transform: uppercase; margin-bottom: 8px; } +.integration-summary-item .value { font-size: 15px; font-weight: 600; color: var(--text); } + +/* Responsive */ +@media (max-width: 1024px) { + .hero-main { grid-template-columns: 1fr; } + .hero-focus { padding: 24px; } + .summary-strip { grid-template-columns: repeat(3, 1fr); } + .settings-panel-grid { grid-template-columns: 1fr; } + .credit-strip { flex-direction: column; align-items: flex-start; } +} +@media (max-width: 768px) { + .container { padding: 20px 16px; } + .hero, .switcher-shell, .service-head, .service-body { padding: 24px; } + .summary-strip { grid-template-columns: 1fr 1fr; gap: 12px; } + .section-grid { grid-template-columns: 1fr; gap: 16px; } + .service-toggle-grid { grid-template-columns: 1fr; } + .settings-fields { grid-template-columns: 1fr; } + .integration-summary { flex-direction: column; gap: 12px; } +} diff --git a/proxy/static/js/console.js b/proxy/static/js/console.js new file mode 100644 index 0000000..4bb7664 --- /dev/null +++ b/proxy/static/js/console.js @@ -0,0 +1,1622 @@ +const STORAGE_KEY = 'multi_service_proxy_pwd'; +const LEGACY_STORAGE_KEY = 'tavily_proxy_pwd'; +const ACTIVE_SERVICE_KEY = 'multi_service_proxy_active_service'; +const API = ''; +const SERVICE_META = { + tavily: { + label: 'Tavily', + emailPrefix: 'tavily-', + tokenPrefix: 'tvly-', + keyPlaceholder: 'tvly-xxxxxxxx', + importPlaceholder: '支持粘贴 email,password,tvly-xxx,timestamp 或仅 tvly-xxx,每行一条', + quotaSource: '真实额度来自 Tavily 官方 GET /usage', + routeHint: '代理端点: POST /api/search, POST /api/extract', + syncButton: '同步 Tavily 额度', + syncSupported: true, + panelIntro: '适合新闻、网页线索和基础搜索入口,继续保留现有 Tavily 工作台逻辑不动。', + tokenPoolDesc: '给业务侧发放 Tavily 代理 Token,和 Exa / Firecrawl 完全分开创建、限流、统计。', + keyPoolDesc: 'Tavily Key 独立存储,导入时只写入 Tavily 池,不会和 Exa 或 Firecrawl 混用。', + switcherBadges: ['Search', '网页发现', '官方额度同步'], + switcherFoot: '独立 Key 池 + 独立额度同步', + spotlightDesc: 'Tavily 继续负责第一层网页发现,这一栏保留现有功能与额度同步逻辑。', + }, + exa: { + label: 'Exa', + emailPrefix: 'exa-', + tokenPrefix: 'exat-', + keyPlaceholder: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', + importPlaceholder: '支持粘贴 email,password,uuid,timestamp 或仅 UUID Key,每行一条', + quotaSource: 'Exa 实时额度暂时无法查询,控制台当前只统计代理调用', + routeHint: '代理端点: POST /exa/search', + syncButton: 'Exa 暂不支持同步', + syncSupported: false, + panelIntro: '适合补充网页发现入口,已经独立成 Exa 工作台、Exa Key 池和 Exa Token 池。', + tokenPoolDesc: '给业务侧发放 Exa 代理 Token,和 Tavily / Firecrawl 完全分开创建、限流、统计。', + keyPoolDesc: 'Exa Key 独立存储,支持直接导入 UUID key,不和别的服务共用池子。', + switcherBadges: ['Search', '网页发现', '代理统计'], + switcherFoot: '独立 Key 池 + 独立代理统计', + spotlightDesc: 'Exa 已经收成单独工作台,现在可以单独导入 Key、签发 Token,并通过 /exa/search 直接代理搜索。', + }, + firecrawl: { + label: 'Firecrawl', + emailPrefix: 'fc-', + tokenPrefix: 'fctk-', + keyPlaceholder: 'fc-xxxxxxxx', + importPlaceholder: '支持粘贴 email,password,fc-xxx,timestamp 或仅 fc-xxx,每行一条', + quotaSource: '真实额度来自 Firecrawl /v2/team/credit-usage', + routeHint: '代理端点: /firecrawl/*,例如 POST /firecrawl/v2/scrape', + syncButton: '同步 Firecrawl 额度', + syncSupported: true, + panelIntro: '适合正文抓取、文档页、PDF 和结构化抽取,继续保持独立 Firecrawl 工作台。', + tokenPoolDesc: '给业务侧发放 Firecrawl 代理 Token,和 Tavily / Exa 完全分开创建、限流、统计。', + keyPoolDesc: 'Firecrawl Key 独立存储,导入时只写入 Firecrawl 池,不会和其他服务混用。', + switcherBadges: ['Depth', '正文抓取', '官方额度同步'], + switcherFoot: '独立 Key 池 + 独立额度同步', + spotlightDesc: 'Firecrawl 继续负责正文抓取与页面抽取,额度同步仍按 Firecrawl credits 展示。', + }, +}; + +const WORKSPACE_META = { + ...SERVICE_META, + social: { + label: 'Social / X', + emailPrefix: 'X search', + tokenPrefix: 'shared auth', + routeHint: '代理端点: POST /social/search', + quotaSource: 'grok2api / xAI-compatible social router', + switcherBadges: ['X Search', 'compatible', '自动继承'], + switcherFoot: '统一 Social 路由 + 统一输出结构', + spotlightDesc: 'Social / X 工作台负责舆情路由和 token 池映射,对外统一暴露 /social/search。', + }, +}; + +let PWD = localStorage.getItem(STORAGE_KEY) || localStorage.getItem(LEGACY_STORAGE_KEY) || ''; +let activeService = localStorage.getItem(ACTIVE_SERVICE_KEY) || 'tavily'; +let latestServices = {}; +let latestSocial = {}; +let latestMySearch = {}; +let latestSettings = {}; + +function clearStoredPasswords() { + localStorage.removeItem(STORAGE_KEY); + localStorage.removeItem(LEGACY_STORAGE_KEY); +} + +function setLoginBusy(isBusy) { + const input = document.getElementById('pwd-input'); + const button = document.getElementById('login-submit'); + if (input) input.disabled = isBusy; + if (button) { + button.disabled = isBusy; + button.textContent = isBusy ? '登录中...' : '进入控制台'; + } +} + +function showDashboard() { + document.getElementById('login-err').classList.add('hidden'); + document.getElementById('login-box').classList.add('hidden'); + document.getElementById('dashboard').classList.remove('hidden'); +} + +function showLogin() { + document.getElementById('dashboard').classList.add('hidden'); + document.getElementById('login-box').classList.remove('hidden'); + setLoginBusy(false); +} + +async function fetchSession(method, path, body) { + const options = { + method, + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + }, + }; + if (body !== undefined) { + options.body = JSON.stringify(body); + } + const response = await fetch(API + path, options); + const text = await response.text(); + let payload = {}; + try { + payload = text ? JSON.parse(text) : {}; + } catch { + payload = text ? { detail: text } : {}; + } + if (!response.ok) { + throw new Error(payload.detail || `HTTP ${response.status}`); + } + return payload; +} + +async function loginWithPassword(password) { + await fetchSession('POST', '/api/session/login', { password }); +} + +async function hasServerSession() { + try { + await fetchSession('GET', '/api/session'); + return true; + } catch { + return false; + } +} + +async function migrateStoredPasswordIfNeeded() { + if (!PWD) return false; + try { + await loginWithPassword(PWD); + PWD = ''; + clearStoredPasswords(); + return true; + } catch { + PWD = ''; + clearStoredPasswords(); + return false; + } +} + +function socialModeLabel(mode) { + if (mode === 'admin-auto') return '后台自动继承'; + if (mode === 'hybrid') return '后台继承 + 手动覆写'; + return '手动模式'; +} + +function socialTokenSourceLabel(source) { + if (source === 'grok2api app.api_key') return '后台自动继承'; + if (source === 'SOCIAL_GATEWAY_UPSTREAM_API_KEY') return '手动上游 API key'; + if (source === 'manual SOCIAL_GATEWAY_TOKEN') return '手动客户端 token'; + return '尚未配置'; +} + +function socialStatusLabel(social) { + if (social?.admin_connected) return '后台已接通'; + if (social?.upstream_key_configured) return '已可转发搜索'; + return '等待配置'; +} + +function buildSocialProxyEnv(social) { + const baseUrl = social.upstream_base_url || 'https://media.example.com/v1'; + const adminBaseUrl = social.admin_base_url || baseUrl.replace(/\/v1$/, ''); + return `# 推荐:只填 grok2api 后台地址和后台 app_key,proxy 会自动继承上游凭证与 token 池 +SOCIAL_GATEWAY_UPSTREAM_BASE_URL=${baseUrl} +SOCIAL_GATEWAY_ADMIN_BASE_URL=${adminBaseUrl} +SOCIAL_GATEWAY_ADMIN_APP_KEY=YOUR_GROK2API_APP_KEY +SOCIAL_GATEWAY_MODEL=grok-4.1-fast + +# 可选:只有你想覆写默认行为时才需要 +# SOCIAL_GATEWAY_UPSTREAM_API_KEY= +# SOCIAL_GATEWAY_TOKEN=`; +} + +function buildSocialMySearchEnv(social) { + const baseUrl = location.origin; + const socialReady = social?.admin_connected || social?.upstream_key_configured; + return `# 推荐:直接用 MySearch 通用 token,一次接上 Tavily / Firecrawl / Exa / Social +MYSEARCH_PROXY_BASE_URL=${baseUrl} +MYSEARCH_PROXY_API_KEY=YOUR_MYSEARCH_PROXY_TOKEN + +# 当前 Social / X ${socialReady ? '已经接通,可直接复用上面的通用 token。' : '还没完全接通;上面的通用 token 先可用于 Tavily / Firecrawl / Exa。'} + +# 如果你只想单独接 Social / X,也可以显式写 compatible 模式: +MYSEARCH_XAI_SEARCH_MODE=compatible +MYSEARCH_XAI_SOCIAL_BASE_URL=${baseUrl} +MYSEARCH_XAI_SOCIAL_SEARCH_PATH=/social/search +MYSEARCH_XAI_API_KEY=YOUR_MYSEARCH_PROXY_TOKEN`; +} + +function buildMySearchEnv(mysearch, social) { + const baseUrl = location.origin; + const token = mysearch?.tokens?.[0]?.token || 'YOUR_MYSEARCH_PROXY_TOKEN'; + const socialReady = social?.admin_connected || social?.upstream_key_configured; + return `# 最省事的接法:只填这两项,MySearch 会默认走当前 proxy +MYSEARCH_PROXY_BASE_URL=${baseUrl} +MYSEARCH_PROXY_API_KEY=${token} + +# 说明: +# - 这一个 token 会同时允许 Tavily / Firecrawl / Exa${socialReady ? ' / Social' : ''} +# - Social / X ${socialReady ? '当前已接通,会默认复用同一个 token' : '当前还没完全接通,后续接好后也会自动复用同一个 token'} + +# 可选:如果你想把 MCP 额外暴露成远程 HTTP,再补这一段 +# MYSEARCH_MCP_HOST=0.0.0.0 +# MYSEARCH_MCP_PORT=8000 +# MYSEARCH_MCP_STREAMABLE_HTTP_PATH=/mcp`; +} + +function buildMySearchInstall() { + return `git clone https://github.com/skernelx/MySearch-Proxy.git +cd MySearch-Proxy +cp mysearch/.env.example mysearch/.env + +# 把上面的 MYSEARCH_PROXY_* 粘进去后执行 +./install.sh + +# 如果你想作为远程 MCP 提供给别的客户端: +./venv/bin/python -m mysearch \\ + --transport streamable-http \\ + --host 0.0.0.0 \\ + --port 8000 \\ + --streamable-http-path /mcp`; +} + +function renderSocialBoard(social) { + const stats = social?.stats || {}; + const mode = socialModeLabel(social?.mode || 'manual'); + const statusText = socialStatusLabel(social); + const tokenSource = socialTokenSourceLabel(social?.token_source || ''); + const authText = social?.client_auth_configured ? '已允许客户端调用 /social/search' : '还没有设置客户端 token'; + const videoValue = stats.video_remaining === null + ? '
无法统计
' + : `
${fmtNum(stats.video_remaining)}
`; + let foot = '现在还没有连上 Social / X 上游。补齐 grok2api 后台地址和 app key,或者手动填写上游 key 后,这里会开始显示完整状态。'; + if (social?.admin_connected) { + foot = '当前通过 grok2api 后台自动同步 token 状态和剩余额度。对外仍然统一提供 MySearch 的 /social/search 结果结构。'; + } else if (social?.upstream_key_configured) { + foot = '当前已经能转发 Social 搜索,但还没有连上后台统计。补上 grok2api app key 后,这里会显示完整 token 状态。'; + } + const errorLine = social?.error + ? `
最近错误:${escapeHtml(social.error)}
` + : ''; + + document.getElementById('social-board').innerHTML = ` +
+
+ + +
+
+
+ 这里看的是 MySearch 的 Social / X 路由运行面。底层可以接 grok2api,也可以兼容别的 xAI-compatible 上游,但对外始终是一套统一结果结构。 +
+
+
+
Token 总数
+
${fmtNum(stats.token_total || 0)}
+
+
+
Token 正常
+
${fmtNum(stats.token_normal || 0)}
+
+
+
Token 限流
+
${fmtNum(stats.token_limited || 0)}
+
+
+
Token 失效
+
${fmtNum(stats.token_invalid || 0)}
+
+
+
Chat 剩余
+
${fmtNum(stats.chat_remaining || 0)}
+
+
+
Image 剩余
+
${fmtNum(stats.image_remaining || 0)}
+
+
+
Video 剩余
+ ${videoValue} +
+
+
总调用次数
+
${fmtNum(stats.total_calls || 0)}
+
+
+
+
+
当前状态
+
${escapeHtml(statusText)}
+
+
+
Token 来源
+
${escapeHtml(tokenSource)}
+
+
+
客户端访问
+
${escapeHtml(authText)}
+
+
+
工作模式
+
${escapeHtml(mode)}
+
+
+
+ ${statusText} ${foot} +
+ ${errorLine} + `; +} + +function renderSocialIntegration(social) { + const mode = socialModeLabel(social?.mode || 'manual'); + const source = socialTokenSourceLabel(social?.token_source || ''); + const proxyConfigured = Boolean(social?.upstream_key_configured); + const clientConfigured = Boolean(social?.client_auth_configured); + const authLabel = clientConfigured ? '已就绪' : '未配置'; + const upstreamBase = social?.upstream_base_url || 'https://media.example.com/v1'; + const adminBase = social?.admin_base_url || '未设置'; + let note = '推荐只填写 grok2api 后台地址和 app key,让 proxy 自动继承上游密钥和 token 池。'; + if (social?.admin_connected) { + note = '当前已经走后台自动继承,后面通常不需要再手动维护上游 key 和客户端 token。'; + } else if (social?.upstream_key_configured) { + note = '当前已经可以调用,但还没有接上后台 token 面板;如果你想看到完整统计,补上 grok2api app key 即可。'; + } + const noteClass = social?.error ? 'integration-note is-error' : 'integration-note'; + + document.getElementById('social-integration').innerHTML = ` +

Social / X 接入

+

推荐优先接 grok2api 后台。这样只要填后台地址和 app key,proxy 就能自动继承上游密钥与 token 池,不需要再把配置拆成很多手动变量。

+
+
+
工作模式
+
${escapeHtml(mode)}
+
+
+
上游接口
+
${escapeHtml(upstreamBase)}
+
+
+
后台地址
+
${escapeHtml(adminBase)}
+
+
+
Token 来源
+
${escapeHtml(source)}
+
+
+
客户端鉴权
+
${escapeHtml(authLabel)}
+
+
+
接入结果
+
${proxyConfigured ? '已拿到可用上游 key' : '尚未拿到上游 key'}
+
+
+
+ ${socialStatusLabel(social)} ${escapeHtml(note)} + ${social?.error ? `
最近错误:${escapeHtml(social.error)}` : ''} +
+
+
Proxy 端环境变量。通常只要复制这一段,再补你自己的 grok2api app key。
+ +
+

+    
+
MySearch / MCP / Skill 端环境变量。现在更推荐直接使用 MySearch 通用 token,一次接上全部路由。
+ +
+

+  `;
+
+  document.getElementById('social-proxy-env').textContent = buildSocialProxyEnv(social || {});
+  document.getElementById('social-mysearch-env').textContent = buildSocialMySearchEnv(social || {});
+}
+
+function renderMySearchQuickstart(mysearch, social) {
+  const root = document.getElementById('mysearch-quickstart');
+  if (!root) return;
+
+  const tokens = mysearch?.tokens || [];
+  const tokenCount = tokens.length;
+  const todayCount = mysearch?.overview?.today_count || 0;
+  const monthCount = mysearch?.overview?.month_count || 0;
+  const socialReady = Boolean(social?.admin_connected || social?.upstream_key_configured);
+  const noteClass = tokenCount > 0 ? 'integration-note' : 'integration-note is-error';
+  const note = tokenCount > 0
+    ? '这里创建的是 MySearch 通用 token。它会同时被 Tavily / Firecrawl / Exa 路由接受,并且在 Social / X 已接通时也可以直接复用。'
+    : '先创建一个 MySearch 通用 token。创建后控制台会自动生成可直接复制的 .env 配置。';
+
+  root.innerHTML = `
+    
+
+ MySearch MCP +

MySearch 快速接入

+

这块不是 provider 池,而是给 Codex / Claude Code / 其他 MCP 客户端准备的统一接入层。目标就是让用户少填变量、少记路径、少区分底层服务。

+
+
+
推荐直接复制下面的 MYSEARCH_PROXY_* 配置,不再手写一堆 provider 地址。
+
通用 Token 前缀:${escapeHtml(mysearch?.token_prefix || 'mysp-')}
+
+
+
+
+
+

一键配置

+

推荐把 MySearch 统一接到当前 proxy。这样客户端只认一个 base URL 和一个通用 token,底层 Tavily / Firecrawl / Exa / Social 都由 proxy 负责收口。

+
+
+
Proxy Base URL
+
${escapeHtml(location.origin)}
+
+
+
通用 Token
+
${fmtNum(tokenCount)}
+
+
+
今日总调用
+
${fmtNum(todayCount)}
+
+
+
本月总调用
+
${fmtNum(monthCount)}
+
+
+
Social / X
+
${socialReady ? '已接通' : '待接通'}
+
+
+
默认安装形态
+
stdio
+
+
+
+ ${tokenCount > 0 ? '可以直接复制配置了' : '还差一个通用 token'} ${escapeHtml(note)} +
+
+
复制到 mysearch/.env 就能用。默认已经包含统一 proxy 接法。
+ +
+

+          
+
本地安装 / 远程启动命令,按仓库默认流程直接走。
+ +
+

+        
+
+

MySearch 通用 Token

+

这个 token 专门给上层 MCP / Skill 用。和 Tavily / Firecrawl / Exa 各自的服务 token 分开管理,但调用时会被三条 provider 路由一起接受。

+
+ + +
+
+ + + + + + + + + + + +
Token名称额度代理统计操作
+
+
+
+
+ `; + + document.getElementById('mysearch-proxy-env').textContent = buildMySearchEnv(mysearch || {}, social || {}); + document.getElementById('mysearch-install-cmd').textContent = buildMySearchInstall(); + renderTokens('mysearch', tokens); +} + +function headers() { + const base = { + 'Content-Type': 'application/json', + }; + if (PWD) { + base['X-Admin-Password'] = PWD; + } + return base; +} + +async function api(method, path, body) { + const options = { method, headers: headers(), credentials: 'same-origin' }; + if (body !== undefined) { + options.body = JSON.stringify(body); + } + + const response = await fetch(API + path, options); + const text = await response.text(); + let payload = {}; + try { + payload = text ? JSON.parse(text) : {}; + } catch { + payload = text ? { detail: text } : {}; + } + + if (response.status === 401) { + logout(); + throw new Error('Unauthorized'); + } + + if (!response.ok) { + throw new Error(payload.detail || `HTTP ${response.status}`); + } + + return payload; +} + +function setStatus(id, message, isError = false) { + const el = document.getElementById(id); + if (!el) return; + if (!message) { + el.textContent = ''; + el.classList.add('hidden'); + el.classList.remove('is-error'); + return; + } + el.textContent = message; + el.classList.remove('hidden'); + el.classList.toggle('is-error', Boolean(isError)); +} + +function describeConfiguredSecret(masked, configured) { + if (!configured) return '当前未配置。'; + return `当前已配置 ${masked || 'secret'},留空表示保持不变。`; +} + +function fillSettingsForm(settings) { + const social = settings?.social || {}; + document.getElementById('settings-social-upstream-base-url').value = social.upstream_base_url || ''; + document.getElementById('settings-social-upstream-responses-path').value = social.upstream_responses_path || '/responses'; + document.getElementById('settings-social-admin-base-url').value = social.admin_base_url || ''; + document.getElementById('settings-social-admin-verify-path').value = social.admin_verify_path || '/v1/admin/verify'; + document.getElementById('settings-social-admin-config-path').value = social.admin_config_path || '/v1/admin/config'; + document.getElementById('settings-social-admin-tokens-path').value = social.admin_tokens_path || '/v1/admin/tokens'; + document.getElementById('settings-social-model').value = social.model || 'grok-4.1-fast'; + document.getElementById('settings-social-fallback-model').value = social.fallback_model || 'grok-4.1-fast'; + document.getElementById('settings-social-cache-ttl-seconds').value = String(social.cache_ttl_seconds || 60); + document.getElementById('settings-social-fallback-min-results').value = String(social.fallback_min_results || 3); + + document.getElementById('settings-social-admin-app-key').value = ''; + document.getElementById('settings-social-upstream-api-key').value = ''; + document.getElementById('settings-social-gateway-token').value = ''; + + document.getElementById('settings-social-admin-app-key-hint').textContent = + describeConfiguredSecret(social.admin_app_key_masked, social.admin_app_key_configured); + document.getElementById('settings-social-upstream-api-key-hint').textContent = + describeConfiguredSecret(social.upstream_api_key_masked, social.upstream_api_key_configured); + document.getElementById('settings-social-gateway-token-hint').textContent = + describeConfiguredSecret(social.gateway_token_masked, social.gateway_token_configured); + + const bits = [ + `当前模式:${socialModeLabel(social.mode || 'manual')}`, + social.model ? `主模型:${social.model}` : '', + social.fallback_model ? `Fallback:${social.fallback_model} (< ${social.fallback_min_results || 3})` : '', + social.token_source ? `Token 来源:${social.token_source}` : '', + social.admin_connected ? '后台连通正常' : '', + ].filter(Boolean); + if (social.error) { + bits.push(`最近错误:${social.error}`); + } + document.getElementById('settings-social-meta').textContent = bits.join(' · '); +} + +async function loadSettings() { + const payload = await api('GET', '/api/settings'); + latestSettings = payload || {}; + fillSettingsForm(latestSettings); + setStatus('settings-password-status', ''); + setStatus('settings-social-status', ''); +} + +async function openSettingsModal() { + document.getElementById('settings-modal').classList.remove('hidden'); + document.body.classList.add('modal-open'); + try { + await loadSettings(); + } catch (error) { + setStatus('settings-social-status', `读取设置失败:${error.message}`, true); + } +} + +function closeSettingsModal() { + document.getElementById('settings-modal').classList.add('hidden'); + document.body.classList.remove('modal-open'); +} + +function logoutFromSettings() { + closeSettingsModal(); + logout(); +} + +function renderServiceShells() { + const providerHtml = Object.keys(SERVICE_META).map((service) => { + const meta = SERVICE_META[service]; + return ` +
+
+
+ ${meta.label} +

${meta.label} 栏目

+

${meta.panelIntro} 账号前缀 ${meta.emailPrefix},代理 Token 前缀 ${meta.tokenPrefix}。${meta.quotaSource}。

+
+
+
等待同步状态...
+ +
+
+
+
+ +
+
+

调用方式

+

${meta.routeHint}

+
+ Base URL: + 代理 Token 前缀: ${meta.tokenPrefix} +
+
+
${meta.quotaSource}
+ +
+

+            
+ +
+

Token 池

+

${meta.tokenPoolDesc}

+
+ + +
+
+ + + + + + + + + + + +
Token备注配额 / 剩余代理统计操作
+
+
+
+ +
+

API Key 池

+

${meta.keyPoolDesc}

+
+ + + +
+ +
+ + + + + + + + + + + + + + +
IDKey邮箱Key 额度账户额度代理统计状态操作
+
+
+
+
+ `; + }).join(''); + + const socialHtml = ` +
+
+
+ Social / X +

Social / X 栏目

+

这里收口的是 X / Social 搜索路由,不再把底层实现名字放成主标题。你看到的是 MySearch 的 Social 工作台,底层可以复用 grok2api 后台,也可以兼容别的 xAI-compatible 上游。

+
+
+
等待 Social 状态...
+
用于查看 token 池、剩余额度、调用次数和客户端接线方式。
+
+
+
+
+ +
+
+ `; + + document.getElementById('services-root').innerHTML = providerHtml + socialHtml; + renderSocialBoard({}); + renderSocialIntegration({}); + renderSocialWorkspace({}); + renderServiceSwitcher({}, {}); + applyActiveService(); +} + +function renderServiceSwitcher(services, social) { + const html = Object.entries(WORKSPACE_META).map(([service, meta]) => { + const isSocial = service === 'social'; + const payload = isSocial ? {} : (services?.[service] || {}); + const quota = payload.real_quota || {}; + const socialStats = social?.stats || {}; + const remaining = isSocial ? (socialStats.chat_remaining || 0) : (quota.total_remaining ?? 0); + const activeKeys = isSocial ? (socialStats.token_normal || 0) : (payload.keys_active || 0); + const tokenCount = isSocial ? (socialStats.token_total || 0) : ((payload.tokens || []).length); + const todayCount = isSocial ? (socialStats.total_calls || 0) : (payload.overview?.today_count || 0); + const badgeList = meta.switcherBadges || [ + isSocial ? 'X Search' : `账号 ${meta.emailPrefix}`, + isSocial ? 'compatible' : `Token ${meta.tokenPrefix}`, + isSocial ? '自动继承' : '池子独立', + ]; + const foot = meta.switcherFoot || (isSocial ? '统一 Social 路由 + 统一输出结构' : '独立 Key 池 + 独立额度同步'); + const metricOneLabel = isSocial ? '可用 Token' : '活跃 Key'; + const metricTwoLabel = 'Token'; + const metricThreeLabel = isSocial ? '总调用' : '今日调用'; + const metricFourLabel = isSocial ? 'Chat 剩余' : (service === 'exa' ? '实时额度' : '真实剩余'); + const metricFourValue = isSocial ? fmtNum(remaining) : (service === 'exa' ? '暂不可查' : fmtNum(remaining)); + + return ` + + `; + }).join(''); + + document.getElementById('service-switcher').innerHTML = html; +} + +function applyActiveService() { + if (!WORKSPACE_META[activeService]) { + activeService = 'tavily'; + } + + for (const service of Object.keys(WORKSPACE_META)) { + const panel = document.querySelector(`.service-panel[data-service="${service}"]`); + if (!panel) continue; + panel.classList.toggle('is-inactive', service !== activeService); + } + + document.querySelectorAll('.service-toggle').forEach((item) => { + const isActive = item.dataset.service === activeService; + item.classList.toggle('is-active', isActive); + item.setAttribute('aria-pressed', isActive ? 'true' : 'false'); + const status = item.querySelector('.service-toggle-status'); + if (status) { + status.textContent = isActive ? '当前查看' : '点击切换'; + } + const arrow = item.querySelector('.service-toggle-arrow'); + if (arrow) { + arrow.textContent = isActive ? '正在查看 →' : '切换到此工作台 →'; + } + }); + + const switcherNote = document.getElementById('switcher-note'); + if (switcherNote) { + switcherNote.textContent = `当前工作台:${WORKSPACE_META[activeService].label} · 已记住你的切换偏好`; + } +} + +function setActiveService(service) { + if (!WORKSPACE_META[service]) return; + activeService = service; + localStorage.setItem(ACTIVE_SERVICE_KEY, service); + applyActiveService(); + const panel = document.querySelector(`.service-panel[data-service="${service}"]`); + if (panel) { + panel.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } +} + +function doLogin(event) { + event?.preventDefault?.(); + const input = document.getElementById('pwd-input'); + const password = input.value.trim(); + if (!password) { + document.getElementById('login-err').textContent = '请输入管理密码。'; + document.getElementById('login-err').classList.remove('hidden'); + return; + } + setLoginBusy(true); + loginWithPassword(password) + .then(async () => { + PWD = ''; + clearStoredPasswords(); + showDashboard(); + await refresh(); + }) + .catch((error) => { + document.getElementById('login-err').textContent = error.message === 'Unauthorized' + ? '密码错误。' + : '登录失败,请检查管理 API 是否可用。'; + document.getElementById('login-err').classList.remove('hidden'); + }) + .finally(() => { + setLoginBusy(false); + }); +} + +function logout() { + PWD = ''; + clearStoredPasswords(); + showLogin(); + closeSettingsModal(); + fetch(API + '/api/session/logout', { + method: 'POST', + credentials: 'same-origin', + }).catch(() => {}); +} + +function fmtNum(value) { + if (value === null || value === undefined || value === '') { + return '--'; + } + const numeric = Number(value); + return Number.isFinite(numeric) ? numeric.toLocaleString() : String(value); +} + +function escapeHtml(value) { + return String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function formatTime(iso) { + if (!iso) return '未同步'; + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return '未同步'; + return date.toLocaleString(); +} + +function quotaBar(used, limit) { + const safeLimit = Number(limit || 0); + const safeUsed = Number(used || 0); + if (!safeLimit) return ''; + const pct = Math.min(100, (safeUsed / safeLimit) * 100); + const cls = pct >= 90 ? 'danger' : pct >= 70 ? 'warn' : ''; + return ` +
+
+
+ `; +} + +function buildCurlExample(service, tokenValue) { + const baseUrl = location.origin; + const token = tokenValue || 'YOUR_PROXY_TOKEN'; + if (service === 'firecrawl') { + return `# Firecrawl Scrape +curl -X POST ${baseUrl}/firecrawl/v2/scrape \\ + -H "Content-Type: application/json" \\ + -H "Authorization: Bearer ${token}" \\ + -d '{"url":"https://example.com","formats":["markdown"]}' + +# Firecrawl 额度查询 +curl -X GET ${baseUrl}/firecrawl/v2/team/credit-usage \\ + -H "Authorization: Bearer ${token}" + +# 也支持 body 里传 api_key +curl -X POST ${baseUrl}/firecrawl/v2/scrape \\ + -H "Content-Type: application/json" \\ + -d '{"api_key":"${token}","url":"https://example.com"}'`; + } + + if (service === 'exa') { + return `# Exa Search +curl -X POST ${baseUrl}/exa/search \\ + -H "Content-Type: application/json" \\ + -H "Authorization: Bearer ${token}" \\ + -d '{"query":"OpenAI latest model","numResults":3,"contents":{"text":true}}' + +# 也支持 body 里传 api_key +curl -X POST ${baseUrl}/exa/search \\ + -H "Content-Type: application/json" \\ + -d '{"api_key":"${token}","query":"OpenAI latest model","numResults":3}'`; + } + + return `# Tavily Search +curl -X POST ${baseUrl}/api/search \\ + -H "Content-Type: application/json" \\ + -H "Authorization: Bearer ${token}" \\ + -d '{"query":"hello world","max_results":1}' + +# Tavily Extract +curl -X POST ${baseUrl}/api/extract \\ + -H "Content-Type: application/json" \\ + -H "Authorization: Bearer ${token}" \\ + -d '{"urls":["https://example.com"]}' + +# 也支持 body 里传 api_key +curl -X POST ${baseUrl}/api/search \\ + -H "Content-Type: application/json" \\ + -d '{"api_key":"${token}","query":"hello world"}'`; +} + +function renderGlobalSummary(services, social) { + const list = Object.values(services || {}); + const totalKeys = list.reduce((sum, item) => sum + Number(item.keys_total || 0), 0); + const totalTokens = list.reduce((sum, item) => sum + Number((item.tokens || []).length), 0); + const todayCount = list.reduce((sum, item) => sum + Number(item.overview?.today_count || 0), 0); + const syncable = list.filter((item) => SERVICE_META[item.service]?.syncSupported !== false); + const realRemaining = syncable.reduce((sum, item) => sum + Number(item.real_quota?.total_remaining || 0), 0); + const syncedKeys = syncable.reduce((sum, item) => sum + Number(item.real_quota?.synced_keys || 0), 0); + const syncableLabels = syncable.map((item) => item.label).filter(Boolean).join(' / ') || '暂无'; + const socialStats = social?.stats || {}; + const socialMode = socialModeLabel(social?.mode || 'manual'); + + document.getElementById('global-summary').innerHTML = ` +
+
代理 Token
+
${fmtNum(totalTokens)}
+
${fmtNum(totalKeys)} 个 Key 已导入,按服务独立签发
+
+
+
今日调用
+
${fmtNum(todayCount)}
+
来自本地 usage_logs 聚合
+
+
+
官方剩余
+
${fmtNum(realRemaining)}
+
已同步 ${fmtNum(syncedKeys)} 个 Key · ${escapeHtml(syncableLabels)}
+
+
+
Social / X
+
${fmtNum(socialStats.token_total || 0)}
+
${social?.admin_connected ? `模式 ${socialMode}` : '等待后台接通'}
+
+
+
Social Chat
+
${fmtNum(socialStats.chat_remaining || 0)}
+
Image ${fmtNum(socialStats.image_remaining || 0)} · 调用 ${fmtNum(socialStats.total_calls || 0)}
+
+
+
MySearch Token
+
${fmtNum(latestMySearch?.token_count || 0)}
+
给 MCP / Skill / 客户端统一接入
+
+ `; +} + +function renderSyncMeta(service, payload) { + if (service === 'exa') { + const detail = payload.usage_sync?.detail || 'Exa 实时额度暂时无法查询'; + document.getElementById(`sync-meta-${service}`).textContent = [ + `已导入 ${fmtNum(payload.keys_total || 0)} 个 Key`, + `已签发 ${fmtNum((payload.tokens || []).length)} 个 Token`, + detail, + ].join(' · '); + return; + } + + const quota = payload.real_quota || {}; + const usageSync = payload.usage_sync || {}; + const parts = []; + + parts.push(`已同步 ${fmtNum(quota.synced_keys || 0)} / ${fmtNum(quota.total_keys || 0)} 个 Key`); + if ((quota.key_level_count || 0) > 0) { + parts.push(`Key 级额度 ${fmtNum(quota.key_level_count)}`); + } + if ((quota.account_fallback_count || 0) > 0) { + parts.push(`账户正常数量:${fmtNum(quota.account_fallback_count)}`); + } + if (quota.last_synced_at) { + parts.push(`最近同步 ${formatTime(quota.last_synced_at)}`); + } + if ((quota.error_keys || 0) > 0) { + parts.push(`错误 ${fmtNum(quota.error_keys)}`); + } + if ((usageSync.synced || 0) > 0 || (usageSync.errors || 0) > 0) { + parts.push(`本轮同步 ${fmtNum(usageSync.synced || 0)} 成功 / ${fmtNum(usageSync.errors || 0)} 失败`); + } + + document.getElementById(`sync-meta-${service}`).textContent = parts.join(' · '); +} + +function renderOverview(service, payload) { + const overview = payload.overview || {}; + const quota = payload.real_quota || {}; + + if (service === 'exa') { + const todayCount = Number(overview.today_count || 0); + const todaySuccess = Number(overview.today_success || 0); + const successRate = todayCount ? `${Math.round((todaySuccess / todayCount) * 100)}%` : '暂无'; + + document.getElementById(`overview-${service}`).innerHTML = ` +
+
实时额度
+
暂时无法查询
+
控制台当前只统计 Exa 代理调用
+
+
+
Key 池状态
+
${fmtNum(payload.keys_active || 0)} / ${fmtNum(payload.keys_total || 0)}
+
活跃 / 总数
+
+
+
Token 池状态
+
${fmtNum((payload.tokens || []).length)}
+
Exa 独立代理 Token 池
+
+
+
今日代理调用
+
${fmtNum(overview.today_count || 0)}
+
成功 ${fmtNum(overview.today_success || 0)} / 失败 ${fmtNum(overview.today_failed || 0)}
+
+
+
本月代理调用
+
${fmtNum(overview.month_count || 0)}
+
本月成功 ${fmtNum(overview.month_success || 0)}
+
+
+
今日成功率
+
${successRate}
+
${payload.usage_sync?.detail || 'Exa 实时额度暂时无法查询,后续如果接入官方读取会补充显示。'}
+
+ `; + return; + } + + const totalLimit = Number(quota.total_limit || 0); + const totalUsed = Number(quota.total_used || 0); + const totalRemaining = Number(quota.total_remaining || 0); + const remainStyle = totalLimit && totalUsed / totalLimit >= 0.9 + ? 'color: var(--danger)' + : totalLimit && totalUsed / totalLimit >= 0.7 + ? 'color: var(--warn)' + : 'color: var(--ok)'; + + document.getElementById(`overview-${service}`).innerHTML = ` +
+
真实总额度
+
${fmtNum(totalLimit)}
+
${SERVICE_META[service].quotaSource}
+
+
+
真实已用
+
${fmtNum(totalUsed)}
+
按已同步 Key 汇总
+
+
+
真实剩余
+
${fmtNum(totalRemaining)}
+
${quotaBar(totalUsed, totalLimit) || '尚未获得完整上限信息'}
+
+
+
Key 池状态
+
${fmtNum(payload.keys_active || 0)} / ${fmtNum(payload.keys_total || 0)}
+
活跃 / 总数
+
+
+
今日代理调用
+
${fmtNum(overview.today_count || 0)}
+
成功 ${fmtNum(overview.today_success || 0)} / 失败 ${fmtNum(overview.today_failed || 0)}
+
+
+
本月代理调用
+
${fmtNum(overview.month_count || 0)}
+
成功 ${fmtNum(overview.month_success || 0)}
+
+ `; +} + +function renderSocialWorkspace(social) { + const stats = social?.stats || {}; + const mode = socialModeLabel(social?.mode || 'manual'); + const source = socialTokenSourceLabel(social?.token_source || ''); + const syncLine = [ + mode, + `Token ${fmtNum(stats.token_total || 0)}`, + `Chat ${fmtNum(stats.chat_remaining || 0)}`, + `总调用 ${fmtNum(stats.total_calls || 0)}`, + ].join(' · '); + + const syncMeta = document.getElementById('sync-meta-social'); + if (syncMeta) { + syncMeta.textContent = social?.error ? `${syncLine} · 最近错误 ${social.error}` : syncLine; + } + + document.getElementById('overview-social').innerHTML = ` +
+
工作模式
+
${escapeHtml(mode)}
+
当前 Social / X 工作台的路由状态
+
+
+
Token 正常 / 总数
+
${fmtNum(stats.token_normal || 0)} / ${fmtNum(stats.token_total || 0)}
+
兼容上游 token 池实时汇总
+
+
+
Chat 剩余
+
${fmtNum(stats.chat_remaining || 0)}
+
Image ${fmtNum(stats.image_remaining || 0)} · Video ${stats.video_remaining === null ? '无法统计' : fmtNum(stats.video_remaining)}
+
+
+
Token 来源
+
${escapeHtml(source)}
+
${social?.admin_connected ? '当前已接入后台自动继承' : '当前为手动或混合模式'}
+
+ `; +} + +function renderApiExample(service, tokens) { + const firstToken = tokens && tokens.length ? tokens[0].token : 'YOUR_PROXY_TOKEN'; + document.getElementById(`base-url-${service}`).textContent = location.origin; + document.getElementById(`curl-example-${service}`).textContent = buildCurlExample(service, firstToken); +} + +function renderTokenQuota(token) { + return '
无限制
已关闭小时 / 日 / 月限流
'; +} + +function renderTokens(service, tokens) { + const tbody = document.getElementById(`tokens-body-${service}`); + if (!tokens || tokens.length === 0) { + tbody.innerHTML = '当前还没有 Token,先创建一个给下游使用。'; + return; + } + + tbody.innerHTML = tokens.map((token) => { + const stats = token.stats || {}; + return ` + + ${escapeHtml(token.token)} + ${escapeHtml(token.name || '-')} + ${renderTokenQuota(token)} + + 今日成功 ${fmtNum(stats.today_success || 0)} / 失败 ${fmtNum(stats.today_failed || 0)}
+ 本月成功 ${fmtNum(stats.month_success || 0)}
+ 小时调用 ${fmtNum(stats.hour_count || 0)} + + +
+ + +
+ + + `; + }).join(''); +} + +function renderKeyQuota(service, key) { + if (service === 'exa') { + return ` +
Exa 实时额度暂时无法查询。
+
当前只展示代理层调用统计。
+ ${key.usage_sync_error ? `
最近错误: ${escapeHtml(key.usage_sync_error)}
` : ''} + `; + } + + if (key.usage_key_limit !== null && key.usage_key_used !== null) { + const remain = key.usage_key_remaining ?? Math.max(0, key.usage_key_limit - key.usage_key_used); + return ` +
已用 ${fmtNum(key.usage_key_used)} / ${fmtNum(key.usage_key_limit)}
+
剩余 ${fmtNum(remain)}
+ ${quotaBar(key.usage_key_used, key.usage_key_limit)} +
同步 ${formatTime(key.usage_synced_at)}
+ ${key.usage_sync_error ? `
最近错误: ${escapeHtml(key.usage_sync_error)}
` : ''} + `; + } + + if (service === 'firecrawl' && key.usage_synced_at) { + return ` +
Firecrawl 当前主要返回账户级 credits。
+
单 Key 独立限额请看右侧账户额度。
+ ${key.usage_sync_error ? `
最近错误: ${escapeHtml(key.usage_sync_error)}
` : ''} + `; + } + + if (key.usage_sync_error) { + return `
同步失败:${escapeHtml(key.usage_sync_error)}
`; + } + + return '未同步'; +} + +function renderAccountQuota(service, key) { + if (service === 'exa') { + return 'Exa 账户实时额度暂时无法查询'; + } + + if (key.usage_account_limit !== null && key.usage_account_used !== null) { + const remain = key.usage_account_remaining ?? Math.max(0, key.usage_account_limit - key.usage_account_used); + const plan = key.usage_account_plan || (service === 'firecrawl' ? 'Firecrawl Credits' : '未知计划'); + return ` +
${escapeHtml(plan)}
+
已用 ${fmtNum(key.usage_account_used)} / ${fmtNum(key.usage_account_limit)}
+
剩余 ${fmtNum(remain)}
+ ${quotaBar(key.usage_account_used, key.usage_account_limit)} + `; + } + return '未返回'; +} + +function renderKeys(service, keys) { + const tbody = document.getElementById(`keys-body-${service}`); + if (!keys || keys.length === 0) { + tbody.innerHTML = '当前服务还没有导入 Key。'; + return; + } + + tbody.innerHTML = keys.map((key) => { + const active = Number(key.active) === 1; + return ` + + ${fmtNum(key.id)} + ${escapeHtml(key.key_masked || key.key)} + ${escapeHtml(key.email || '-')} + ${renderKeyQuota(service, key)} + ${renderAccountQuota(service, key)} + + 成功 ${fmtNum(key.total_used || 0)}
+ 失败 ${fmtNum(key.total_failed || 0)}
+ 最近使用 ${formatTime(key.last_used_at)} + + ${active ? '正常' : '禁用'} + +
+ + +
+ + + `; + }).join(''); +} + +async function refresh(options = {}) { + const force = options.force ? '?force=1' : ''; + const payload = await api('GET', `/api/stats${force}`); + const services = payload.services || {}; + const social = payload.social || {}; + const mysearch = payload.mysearch || {}; + latestServices = services; + latestSocial = social; + latestMySearch = mysearch; + + renderGlobalSummary(services, social); + renderMySearchQuickstart(mysearch, social); + renderSocialBoard(social); + renderSocialIntegration(social); + renderSocialWorkspace(social); + renderServiceSwitcher(services, social); + for (const [service, meta] of Object.entries(SERVICE_META)) { + const servicePayload = services[service] || { + tokens: [], + keys: [], + overview: {}, + real_quota: {}, + usage_sync: {}, + keys_total: 0, + keys_active: 0, + }; + renderSyncMeta(service, servicePayload); + renderOverview(service, servicePayload); + renderApiExample(service, servicePayload.tokens || []); + renderTokens(service, servicePayload.tokens || []); + renderKeys(service, servicePayload.keys || []); + const syncButton = document.getElementById(`sync-btn-${service}`); + const syncSupported = servicePayload.usage_sync?.supported !== false && meta.syncSupported !== false; + syncButton.textContent = syncSupported ? meta.syncButton : '暂不支持同步'; + syncButton.disabled = !syncSupported; + syncButton.title = syncSupported ? '' : (servicePayload.usage_sync?.detail || meta.quotaSource); + } + applyActiveService(); +} + +function toggleImport(service) { + document.getElementById(`import-wrap-${service}`).classList.toggle('hidden'); +} + +async function createToken(service) { + const input = document.getElementById(`token-name-${service}`); + await api('POST', '/api/tokens', { + service, + name: input.value.trim(), + }); + input.value = ''; + await refresh({ force: true }); +} + +async function delToken(id) { + if (!confirm('确认删除这个 Token 吗?')) return; + await api('DELETE', `/api/tokens/${id}`); + await refresh({ force: true }); +} + +async function addSingleKey(service) { + const input = document.getElementById(`single-key-${service}`); + const key = input.value.trim(); + if (!key) return; + await api('POST', '/api/keys', { service, key }); + input.value = ''; + await refresh({ force: true }); +} + +async function importKeys(service) { + const textarea = document.getElementById(`import-text-${service}`); + const text = textarea.value.trim(); + if (!text) return; + const result = await api('POST', '/api/keys', { service, file: text }); + textarea.value = ''; + document.getElementById(`import-wrap-${service}`).classList.add('hidden'); + alert(`已导入 ${result.imported || 0} 个 ${SERVICE_META[service].label} Key`); + await refresh({ force: true }); +} + +async function delKey(id) { + if (!confirm('确认删除这个 Key 吗?')) return; + await api('DELETE', `/api/keys/${id}`); + await refresh({ force: true }); +} + +async function toggleKey(id, active) { + await api('PUT', `/api/keys/${id}/toggle`, { active }); + await refresh({ force: true }); +} + +async function syncUsage(service, force) { + if (SERVICE_META[service]?.syncSupported === false) { + alert(SERVICE_META[service].quotaSource); + return; + } + const button = document.getElementById(`sync-btn-${service}`); + const originalText = button.textContent; + button.disabled = true; + button.textContent = '同步中...'; + try { + await api('POST', '/api/usage/sync', { service, force }); + await refresh({ force: true }); + } catch (error) { + alert(`同步 ${SERVICE_META[service].label} 额度失败: ${error.message}`); + button.disabled = false; + button.textContent = originalText; + } +} + +async function changePwd(event) { + event?.preventDefault?.(); + const input = document.getElementById('settings-new-pwd'); + const password = input.value.trim(); + if (password.length < 4) { + setStatus('settings-password-status', '密码至少 4 位。', true); + return; + } + try { + await api('PUT', '/api/password', { password }); + PWD = password; + localStorage.setItem(STORAGE_KEY, password); + localStorage.removeItem(LEGACY_STORAGE_KEY); + input.value = ''; + setStatus('settings-password-status', '密码已更新,当前会话也已同步。'); + } catch (error) { + setStatus('settings-password-status', `保存密码失败:${error.message}`, true); + } +} + +async function saveSocialSettings(event) { + event?.preventDefault?.(); + const body = { + upstream_base_url: document.getElementById('settings-social-upstream-base-url').value.trim(), + upstream_responses_path: document.getElementById('settings-social-upstream-responses-path').value.trim(), + admin_base_url: document.getElementById('settings-social-admin-base-url').value.trim(), + admin_verify_path: document.getElementById('settings-social-admin-verify-path').value.trim(), + admin_config_path: document.getElementById('settings-social-admin-config-path').value.trim(), + admin_tokens_path: document.getElementById('settings-social-admin-tokens-path').value.trim(), + model: document.getElementById('settings-social-model').value.trim(), + fallback_model: document.getElementById('settings-social-fallback-model').value.trim(), + cache_ttl_seconds: document.getElementById('settings-social-cache-ttl-seconds').value.trim(), + fallback_min_results: document.getElementById('settings-social-fallback-min-results').value.trim(), + }; + + const adminAppKey = document.getElementById('settings-social-admin-app-key').value.trim(); + const upstreamApiKey = document.getElementById('settings-social-upstream-api-key').value.trim(); + const gatewayToken = document.getElementById('settings-social-gateway-token').value.trim(); + + if (adminAppKey) body.admin_app_key = adminAppKey; + if (upstreamApiKey) body.upstream_api_key = upstreamApiKey; + if (gatewayToken) body.gateway_token = gatewayToken; + + try { + const payload = await api('PUT', '/api/settings/social', body); + latestSettings = payload || {}; + fillSettingsForm(latestSettings); + setStatus('settings-social-status', 'Social / X 设置已保存,当前控制台状态已刷新。'); + await refresh({ force: true }); + } catch (error) { + setStatus('settings-social-status', `保存 Social / X 设置失败:${error.message}`, true); + } +} + +function flashButtonLabel(button, label) { + if (!button) return; + const original = button.textContent; + button.textContent = label; + setTimeout(() => { + button.textContent = original; + }, 1400); +} + +async function writeClipboardText(text) { + const value = String(text ?? ''); + + if (navigator.clipboard?.writeText && window.isSecureContext) { + try { + await navigator.clipboard.writeText(value); + return true; + } catch (error) { + console.warn('Clipboard API failed, falling back to execCommand copy.', error); + } + } + + const textarea = document.createElement('textarea'); + textarea.value = value; + textarea.setAttribute('readonly', ''); + textarea.style.position = 'fixed'; + textarea.style.top = '0'; + textarea.style.left = '-9999px'; + textarea.style.opacity = '0'; + textarea.style.pointerEvents = 'none'; + + document.body.appendChild(textarea); + + const selection = document.getSelection(); + const ranges = []; + if (selection) { + for (let index = 0; index < selection.rangeCount; index += 1) { + ranges.push(selection.getRangeAt(index).cloneRange()); + } + } + + textarea.focus({ preventScroll: true }); + textarea.select(); + textarea.setSelectionRange(0, textarea.value.length); + + let copied = false; + try { + copied = document.execCommand('copy'); + } finally { + textarea.remove(); + if (selection) { + selection.removeAllRanges(); + ranges.forEach((range) => selection.addRange(range)); + } + } + + if (!copied) { + throw new Error('Clipboard copy command was rejected'); + } + + return true; +} + +async function copyCode(elementId, button) { + const source = document.getElementById(elementId); + if (!source) { + flashButtonLabel(button, '未找到'); + return; + } + + try { + await writeClipboardText(source.textContent); + flashButtonLabel(button, '已复制'); + } catch (error) { + console.error(`Copy failed for #${elementId}`, error); + flashButtonLabel(button, '复制失败'); + } +} + +async function copyText(value, button) { + try { + await writeClipboardText(value); + flashButtonLabel(button, '已复制'); + } catch (error) { + console.error('Copy failed for inline value', error); + flashButtonLabel(button, '复制失败'); + } +} + +document.addEventListener('keydown', (event) => { + if (event.key === 'Escape' && !document.getElementById('settings-modal').classList.contains('hidden')) { + closeSettingsModal(); + } +}); + +renderServiceShells(); + +async function initConsole() { + if (INITIAL_AUTHENTICATED) { + showDashboard(); + try { + await refresh(); + } catch (error) { + if (error.message === 'Unauthorized') { + showLogin(); + return; + } + document.getElementById('login-err').textContent = `控制台加载失败:${error.message}`; + document.getElementById('login-err').classList.remove('hidden'); + } + return; + } + + const migrated = await migrateStoredPasswordIfNeeded(); + const hasSession = migrated || await hasServerSession(); + if (!hasSession) { + showLogin(); + return; + } + showDashboard(); + try { + await refresh(); + } catch (error) { + if (error.message === 'Unauthorized') { + showLogin(); + return; + } + document.getElementById('login-err').textContent = `控制台加载失败:${error.message}`; + document.getElementById('login-err').classList.remove('hidden'); + } +} + +initConsole(); + +function setActiveSettingsTab(tabName) { + document.querySelectorAll('.settings-tab').forEach(btn => { + btn.classList.toggle('is-active', btn.dataset.settingsTab === tabName); + }); + document.querySelectorAll('.settings-tab-panel').forEach(panel => { + panel.classList.toggle('hidden', panel.dataset.settingsPanel !== tabName); + panel.classList.toggle('is-active', panel.dataset.settingsPanel === tabName); + }); +} diff --git a/proxy/templates/components/_hero.html b/proxy/templates/components/_hero.html new file mode 100644 index 0000000..7ccebe5 --- /dev/null +++ b/proxy/templates/components/_hero.html @@ -0,0 +1,66 @@ +
+
+
+
+ 统一搜索入口 + 官方 / 兼容双接入 + MCP + Proxy + Skill +
+
+ +
+
+ +
+
+

把搜索基础设施做成真正可交付的工作台

+

MySearch 把 Tavily、Exa、Firecrawl 和 Social / X 统一成一套可被 Codex、Claude Code 和团队 Agent 直接调用的搜索入口。使用者拿到后,不需要自己维护多套 provider、兼容网关和结果结构,也不需要每次手动判断这一轮该用网页搜索、正文抓取还是社交舆情。

+ +
+
+ 统一入口 + 网页发现、正文抓取、社交舆情走同一套接线方式。 +
+
+ Exa 工作区 + 把 Exa 独立成可发 Token、可管 Key、可直接代理调用的新栏目。 +
+
+ 统一运维 + Key 池、Token、额度同步和团队共享入口放在同一个控制台里。 +
+
+ 统一兼容 + 官方 API 和自定义 compatible 聚合服务都能接,不锁死单一后端。 +
+
+
+
+ +
+
+ Router + Tavily 做发现 +

适合新闻、快速 answer、网页线索收集,放在 MySearch 的第一层路由里。

+
+
+ Search + Exa 做网页发现 +

适合补 Tavily 之外的网页搜索入口,这里收成独立 Key 池、Token 池和代理端点。

+
+
+ Depth + Firecrawl 做抓取 +

文档站、GitHub、PDF、pricing 和 changelog 这类正文内容,交给 Firecrawl 更稳。

+
+
+ Social + Social / X 做舆情 +

兼容 grok2api 和 xAI-compatible 搜索,把 X 结果统一整理成 MySearch 可直接消费的结构。

+
+
+
+
\ No newline at end of file diff --git a/proxy/templates/components/_settings_modal.html b/proxy/templates/components/_settings_modal.html new file mode 100644 index 0000000..2e78ec7 --- /dev/null +++ b/proxy/templates/components/_settings_modal.html @@ -0,0 +1,192 @@ + \ No newline at end of file diff --git a/proxy/templates/console.html b/proxy/templates/console.html index 83ae295..83bb453 100644 --- a/proxy/templates/console.html +++ b/proxy/templates/console.html @@ -5,2235 +5,7 @@ MySearch Proxy Console - + @@ -2251,72 +23,7 @@

Search Infrastructure Console

-
-
-
-
- 统一搜索入口 - 官方 / 兼容双接入 - MCP + Proxy + Skill -
-
- -
-
- -
-
-

把搜索基础设施做成真正可交付的工作台

-

MySearch 把 Tavily、Exa、Firecrawl 和 Social / X 统一成一套可被 Codex、Claude Code 和团队 Agent 直接调用的搜索入口。使用者拿到后,不需要自己维护多套 provider、兼容网关和结果结构,也不需要每次手动判断这一轮该用网页搜索、正文抓取还是社交舆情。

- -
-
- 统一入口 - 网页发现、正文抓取、社交舆情走同一套接线方式。 -
-
- Exa 工作区 - 把 Exa 独立成可发 Token、可管 Key、可直接代理调用的新栏目。 -
-
- 统一运维 - Key 池、Token、额度同步和团队共享入口放在同一个控制台里。 -
-
- 统一兼容 - 官方 API 和自定义 compatible 聚合服务都能接,不锁死单一后端。 -
-
-
-
- -
-
- Router - Tavily 做发现 -

适合新闻、快速 answer、网页线索收集,放在 MySearch 的第一层路由里。

-
-
- Search - Exa 做网页发现 -

适合补 Tavily 之外的网页搜索入口,这里收成独立 Key 池、Token 池和代理端点。

-
-
- Depth - Firecrawl 做抓取 -

文档站、GitHub、PDF、pricing 和 changelog 这类正文内容,交给 Firecrawl 更稳。

-
-
- Social - Social / X 做舆情 -

兼容 grok2api 和 xAI-compatible 搜索,把 X 结果统一整理成 MySearch 可直接消费的结构。

-
-
-
-
+ {% include "components/_hero.html" %}
@@ -2363,1736 +70,11 @@

一屏切换不同搜索引擎工作台

- -
+ {% include "components/_settings_modal.html" %} + diff --git a/skill/scripts/check_mysearch.py b/skill/scripts/check_mysearch.py index 480ea49..de49c16 100755 --- a/skill/scripts/check_mysearch.py +++ b/skill/scripts/check_mysearch.py @@ -59,7 +59,16 @@ def parse_codex_mysearch_env(config_text: str) -> dict[str, str]: def load_codex_mcp_env() -> None: """在干净仓库里也尽量复用 Codex 已注册的 mysearch MCP 环境变量。""" - if any(os.getenv(name) for name in ("MYSEARCH_PROXY_BASE_URL", "MYSEARCH_TAVILY_API_KEY")): + if any( + os.getenv(name) + for name in ( + "MYSEARCH_PROXY_BASE_URL", + "MYSEARCH_TAVILY_API_KEY", + "MYSEARCH_TAVILY_MODE", + "MYSEARCH_TAVILY_GATEWAY_BASE_URL", + "MYSEARCH_TAVILY_GATEWAY_TOKEN", + ) + ): return codex_home = Path(os.getenv("CODEX_HOME", "~/.codex")).expanduser() diff --git a/tests/test_config_bootstrap.py b/tests/test_config_bootstrap.py index a559d61..1a2f808 100644 --- a/tests/test_config_bootstrap.py +++ b/tests/test_config_bootstrap.py @@ -36,6 +36,75 @@ def _restore_env(self, snapshot: dict[str, str | None]) -> None: else: os.environ[key] = value + def test_tavily_gateway_mode_prefers_gateway_token_and_disables_local_key_file(self) -> None: + snapshot = self._preserve_env( + "MYSEARCH_PROXY_BASE_URL", + "MYSEARCH_PROXY_API_KEY", + "MYSEARCH_TAVILY_MODE", + "MYSEARCH_TAVILY_GATEWAY_BASE_URL", + "MYSEARCH_TAVILY_GATEWAY_SEARCH_PATH", + "MYSEARCH_TAVILY_GATEWAY_EXTRACT_PATH", + "MYSEARCH_TAVILY_GATEWAY_TOKEN", + "MYSEARCH_TAVILY_API_KEY", + "MYSEARCH_TAVILY_KEYS_FILE", + ) + try: + os.environ["MYSEARCH_TAVILY_MODE"] = "gateway" + os.environ["MYSEARCH_TAVILY_GATEWAY_BASE_URL"] = "http://127.0.0.1:8787/api/tavily" + os.environ["MYSEARCH_TAVILY_GATEWAY_TOKEN"] = "th-demo-token" + os.environ["MYSEARCH_TAVILY_API_KEY"] = "tvly-official-key" + os.environ["MYSEARCH_TAVILY_KEYS_FILE"] = "accounts.txt" + + module = _load_module( + "test_mysearch_config_tavily_gateway_mode", + REPO_ROOT / "mysearch" / "config.py", + ) + config = module.MySearchConfig.from_env() + + self.assertEqual(config.tavily.provider_mode, "gateway") + self.assertEqual(config.tavily.base_url, "http://127.0.0.1:8787/api/tavily") + self.assertEqual(config.tavily.path("search"), "/search") + self.assertEqual(config.tavily.path("extract"), "/extract") + self.assertEqual(config.tavily.auth_mode, "bearer") + self.assertEqual(config.tavily.api_keys, ["th-demo-token"]) + self.assertIsNone(config.tavily.keys_file) + finally: + self._restore_env(snapshot) + + def test_tavily_official_mode_ignores_proxy_token_and_keeps_local_pool(self) -> None: + snapshot = self._preserve_env( + "MYSEARCH_PROXY_BASE_URL", + "MYSEARCH_PROXY_API_KEY", + "MYSEARCH_TAVILY_MODE", + "MYSEARCH_TAVILY_BASE_URL", + "MYSEARCH_TAVILY_SEARCH_PATH", + "MYSEARCH_TAVILY_EXTRACT_PATH", + "MYSEARCH_TAVILY_API_KEY", + "MYSEARCH_TAVILY_KEYS_FILE", + ) + try: + os.environ["MYSEARCH_PROXY_BASE_URL"] = "https://proxy.example.com" + os.environ["MYSEARCH_PROXY_API_KEY"] = "mysp-token" + os.environ["MYSEARCH_TAVILY_MODE"] = "official" + os.environ["MYSEARCH_TAVILY_API_KEY"] = "tvly-direct-key" + os.environ["MYSEARCH_TAVILY_KEYS_FILE"] = "custom-accounts.txt" + + module = _load_module( + "test_mysearch_config_tavily_official_mode", + REPO_ROOT / "mysearch" / "config.py", + ) + config = module.MySearchConfig.from_env() + + self.assertEqual(config.tavily.provider_mode, "official") + self.assertEqual(config.tavily.base_url, "https://api.tavily.com") + self.assertEqual(config.tavily.path("search"), "/search") + self.assertEqual(config.tavily.path("extract"), "/extract") + self.assertEqual(config.tavily.auth_mode, "body") + self.assertEqual(config.tavily.api_keys, ["tvly-direct-key"]) + self.assertEqual(config.tavily.keys_file, REPO_ROOT / "custom-accounts.txt") + finally: + self._restore_env(snapshot) + def test_codex_config_env_wins_over_dotenv_and_dotenv_fills_missing_values(self) -> None: snapshot = self._preserve_env( "CODEX_HOME", From 84e648bef78d75941f08f8f46180af61e35e790b Mon Sep 17 00:00:00 2001 From: zhaishujie <3394180966@qq.com> Date: Sat, 21 Mar 2026 21:37:35 +0800 Subject: [PATCH 02/20] fix: responsive layout for hero-lanes --- proxy/static/css/console.css | 53 +++++++++++ proxy/templates/components/_hero.html | 129 ++++++++++++++------------ 2 files changed, 122 insertions(+), 60 deletions(-) diff --git a/proxy/static/css/console.css b/proxy/static/css/console.css index 85e491b..510f5e4 100644 --- a/proxy/static/css/console.css +++ b/proxy/static/css/console.css @@ -652,7 +652,59 @@ tr:last-child td { border-bottom: none; } .integration-summary-item .value { font-size: 15px; font-weight: 600; color: var(--text); } /* Responsive */ + +.hero-lanes { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 16px; + margin-top: 40px; +} +.hero-lane { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 16px; + padding: 20px; + transition: transform 0.2s ease; +} +.hero-lane:hover { + transform: translateY(-2px); + box-shadow: var(--shadow); +} +.hero-lane-kicker { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--muted); + display: block; + margin-bottom: 8px; +} +.hero-lane strong { + display: block; + font-size: 16px; + font-weight: 700; + margin-bottom: 6px; +} +.hero-lane p { + font-size: 13px; + color: var(--text-soft); + line-height: 1.5; +} + +.hero-lane[data-service="tavily"] { border-top: 3px solid var(--tavily); } +.hero-lane[data-service="exa"] { border-top: 3px solid var(--exa); } +.hero-lane[data-service="firecrawl"] { border-top: 3px solid var(--firecrawl); } +.hero-lane[data-service="social"] { border-top: 3px solid var(--social); } + +.w-3 { width: 14px; } +.h-3 { height: 14px; } +.w-4 { width: 16px; } +.h-4 { height: 16px; } +.mr-1 { margin-right: 6px; } +.inline-block { display: inline-block; vertical-align: text-bottom; } + @media (max-width: 1024px) { + .hero-lanes { grid-template-columns: repeat(2, 1fr); } .hero-main { grid-template-columns: 1fr; } .hero-focus { padding: 24px; } .summary-strip { grid-template-columns: repeat(3, 1fr); } @@ -660,6 +712,7 @@ tr:last-child td { border-bottom: none; } .credit-strip { flex-direction: column; align-items: flex-start; } } @media (max-width: 768px) { + .hero-lanes { grid-template-columns: 1fr; } .container { padding: 20px 16px; } .hero, .switcher-shell, .service-head, .service-body { padding: 24px; } .summary-strip { grid-template-columns: 1fr 1fr; gap: 12px; } diff --git a/proxy/templates/components/_hero.html b/proxy/templates/components/_hero.html index 7ccebe5..e159605 100644 --- a/proxy/templates/components/_hero.html +++ b/proxy/templates/components/_hero.html @@ -1,66 +1,75 @@
-
-
-
- 统一搜索入口 - 官方 / 兼容双接入 - MCP + Proxy + Skill -
-
- -
-
+
+
+
+ + + 统一搜索入口 + + + + 官方 / 兼容双接入 + + + + MCP + Proxy + Skill + +
+
+ +
+
-
-
-

把搜索基础设施做成真正可交付的工作台

-

MySearch 把 Tavily、Exa、Firecrawl 和 Social / X 统一成一套可被 Codex、Claude Code 和团队 Agent 直接调用的搜索入口。使用者拿到后,不需要自己维护多套 provider、兼容网关和结果结构,也不需要每次手动判断这一轮该用网页搜索、正文抓取还是社交舆情。

+
+
+

把搜索基础设施
做成真正可交付的工作台

+

MySearch 把 Tavily、Exa、Firecrawl 和 Social / X 统一成一套可被 Codex、Claude Code 和团队 Agent 直接调用的搜索入口。使用者拿到后,不需要自己维护多套 provider、兼容网关和结果结构,也不需要每次手动判断这一轮该用网页搜索、正文抓取还是社交舆情。

-
-
- 统一入口 - 网页发现、正文抓取、社交舆情走同一套接线方式。 -
-
- Exa 工作区 - 把 Exa 独立成可发 Token、可管 Key、可直接代理调用的新栏目。 -
-
- 统一运维 - Key 池、Token、额度同步和团队共享入口放在同一个控制台里。 -
-
- 统一兼容 - 官方 API 和自定义 compatible 聚合服务都能接,不锁死单一后端。 -
-
+
+
+ 统一入口 + 网页发现、正文抓取、社交舆情走同一套接线方式。 +
+
+ Exa 工作区 + 把 Exa 独立成可发 Token、可管 Key、可直接代理调用的新栏目。 +
+
+ 统一运维 + Key 池、Token、额度同步和团队共享入口放在同一个控制台里。 +
+
+ 统一兼容 + 官方 API 和自定义 compatible 聚合服务都能接,不锁死单一后端。
- -
-
- Router - Tavily 做发现 -

适合新闻、快速 answer、网页线索收集,放在 MySearch 的第一层路由里。

-
-
- Search - Exa 做网页发现 -

适合补 Tavily 之外的网页搜索入口,这里收成独立 Key 池、Token 池和代理端点。

-
-
- Depth - Firecrawl 做抓取 -

文档站、GitHub、PDF、pricing 和 changelog 这类正文内容,交给 Firecrawl 更稳。

-
-
- Social - Social / X 做舆情 -

兼容 grok2api 和 xAI-compatible 搜索,把 X 结果统一整理成 MySearch 可直接消费的结构。

-
-
-
\ No newline at end of file + + +
+
+ Router + Tavily 做发现 +

适合新闻、快速 answer、网页线索收集,放在 MySearch 的第一层路由里。

+
+
+ Search + Exa 做网页发现 +

适合补 Tavily 之外的网页搜索入口,这里收成独立 Key 池、Token 池和代理端点。

+
+
+ Depth + Firecrawl 做抓取 +

文档站、GitHub、PDF、pricing 和 changelog 这类正文内容,交给 Firecrawl 更稳。

+
+
+ Social + Social / X 做舆情 +

兼容 grok2api 和 xAI-compatible 搜索,把 X 结果统一整理成可直接消费的结构。

+
+
+ + From 1f458b4c26ca60048ee38f5b7badc8f7cfa36d68 Mon Sep 17 00:00:00 2001 From: zhaishujie <3394180966@qq.com> Date: Sat, 21 Mar 2026 21:45:12 +0800 Subject: [PATCH 03/20] fix: complete tavily settings, mask token, replace alerts, restore hero focus --- proxy/static/css/console.css | 52 +++++++++++++++++++++++---- proxy/static/js/console.js | 51 +++++++++++++++++++++++--- proxy/templates/components/_hero.html | 11 ++++++ proxy/templates/console.html | 1 + 4 files changed, 105 insertions(+), 10 deletions(-) diff --git a/proxy/static/css/console.css b/proxy/static/css/console.css index 510f5e4..feed1be 100644 --- a/proxy/static/css/console.css +++ b/proxy/static/css/console.css @@ -705,19 +705,59 @@ tr:last-child td { border-bottom: none; } @media (max-width: 1024px) { .hero-lanes { grid-template-columns: repeat(2, 1fr); } - .hero-main { grid-template-columns: 1fr; } + .hero-main { grid-template-columns: 1fr 340px; } .hero-focus { padding: 24px; } .summary-strip { grid-template-columns: repeat(3, 1fr); } - .settings-panel-grid { grid-template-columns: 1fr; } + .settings-panel-grid { grid-template-columns: 1fr 340px; } .credit-strip { flex-direction: column; align-items: flex-start; } } @media (max-width: 768px) { - .hero-lanes { grid-template-columns: 1fr; } + .hero-lanes { grid-template-columns: 1fr 340px; } .container { padding: 20px 16px; } .hero, .switcher-shell, .service-head, .service-body { padding: 24px; } .summary-strip { grid-template-columns: 1fr 1fr; gap: 12px; } - .section-grid { grid-template-columns: 1fr; gap: 16px; } - .service-toggle-grid { grid-template-columns: 1fr; } - .settings-fields { grid-template-columns: 1fr; } + .section-grid { grid-template-columns: 1fr 340px; gap: 16px; } + .service-toggle-grid { grid-template-columns: 1fr 340px; } + .settings-fields { grid-template-columns: 1fr 340px; } .integration-summary { flex-direction: column; gap: 12px; } } + +/* Toast Notifications */ +.toast-root { + position: fixed; + bottom: 24px; + right: 24px; + z-index: 9999; + display: flex; + flex-direction: column; + gap: 12px; + pointer-events: none; +} +.toast { + background: var(--surface-strong); + color: var(--text); + padding: 12px 20px; + border-radius: 12px; + box-shadow: var(--shadow-lg); + border: 1px solid var(--border); + font-size: 14px; + font-weight: 500; + animation: toast-slide-in 0.3s ease forwards; + pointer-events: auto; +} +.toast-success { border-left: 4px solid var(--ok); } +.toast-error { border-left: 4px solid var(--danger); } +.toast-warn { border-left: 4px solid var(--warn); } +.toast-info { border-left: 4px solid var(--primary); } + +@keyframes toast-slide-in { + from { opacity: 0; transform: translateX(40px) scale(0.95); } + to { opacity: 1; transform: translateX(0) scale(1); } +} +.toast-fade-out { + animation: toast-slide-out 0.3s ease forwards; +} +@keyframes toast-slide-out { + from { opacity: 1; transform: translateX(0) scale(1); } + to { opacity: 0; transform: translateX(40px) scale(0.95); } +} diff --git a/proxy/static/js/console.js b/proxy/static/js/console.js index 4bb7664..8bfd545 100644 --- a/proxy/static/js/console.js +++ b/proxy/static/js/console.js @@ -1,3 +1,16 @@ + +function showToast(message, type = 'info') { + const root = document.getElementById('toast-root'); + if (!root) return; + const toast = document.createElement('div'); + toast.className = `toast toast-${type}`; + toast.textContent = message; + root.appendChild(toast); + setTimeout(() => { + toast.classList.add('toast-fade-out'); + setTimeout(() => toast.remove(), 300); + }, 3000); +} const STORAGE_KEY = 'multi_service_proxy_pwd'; const LEGACY_STORAGE_KEY = 'tavily_proxy_pwd'; const ACTIVE_SERVICE_KEY = 'multi_service_proxy_active_service'; @@ -1209,7 +1222,7 @@ function renderTokens(service, tokens) { const stats = token.stats || {}; return ` - ${escapeHtml(token.token)} + ${maskToken(token.token)} ${escapeHtml(token.name || '-')} ${renderTokenQuota(token)} @@ -1390,7 +1403,7 @@ async function importKeys(service) { const result = await api('POST', '/api/keys', { service, file: text }); textarea.value = ''; document.getElementById(`import-wrap-${service}`).classList.add('hidden'); - alert(`已导入 ${result.imported || 0} 个 ${SERVICE_META[service].label} Key`); + showToast(`已导入 ${result.imported || 0} 个 ${SERVICE_META[service].label} Key`, 'success'); await refresh({ force: true }); } @@ -1407,7 +1420,7 @@ async function toggleKey(id, active) { async function syncUsage(service, force) { if (SERVICE_META[service]?.syncSupported === false) { - alert(SERVICE_META[service].quotaSource); + showToast(SERVICE_META[service].quotaSource, 'warn'); return; } const button = document.getElementById(`sync-btn-${service}`); @@ -1418,7 +1431,7 @@ async function syncUsage(service, force) { await api('POST', '/api/usage/sync', { service, force }); await refresh({ force: true }); } catch (error) { - alert(`同步 ${SERVICE_META[service].label} 额度失败: ${error.message}`); + showToast(`同步 ${SERVICE_META[service].label} 额度失败: ${error.message}`, 'error'); button.disabled = false; button.textContent = originalText; } @@ -1620,3 +1633,33 @@ function setActiveSettingsTab(tabName) { panel.classList.toggle('is-active', panel.dataset.settingsPanel === tabName); }); } + + +function maskToken(token) { + if (!token) return ''; + if (token.length <= 12) return '****'; + return token.slice(0, 5) + '****' + token.slice(-4); +} + + +async function saveTavilySettings(event) { + event?.preventDefault?.(); + const body = { + mode: document.getElementById('settings-tavily-mode').value, + upstream_base_url: document.getElementById('settings-tavily-upstream-base-url').value.trim(), + upstream_search_path: document.getElementById('settings-tavily-upstream-search-path').value.trim(), + upstream_extract_path: document.getElementById('settings-tavily-upstream-extract-path').value.trim(), + }; + const upstreamApiKey = document.getElementById('settings-tavily-upstream-api-key').value.trim(); + if (upstreamApiKey) body.upstream_api_key = upstreamApiKey; + + try { + const payload = await api('PUT', '/api/settings/tavily', body); + latestSettings = payload || {}; + fillSettingsForm(latestSettings); + setStatus('settings-tavily-status', 'Tavily 设置已保存。'); + await refresh({ force: true }); + } catch (error) { + setStatus('settings-tavily-status', `保存失败:${error.message}`, true); + } +} diff --git a/proxy/templates/components/_hero.html b/proxy/templates/components/_hero.html index e159605..7f54dc4 100644 --- a/proxy/templates/components/_hero.html +++ b/proxy/templates/components/_hero.html @@ -28,6 +28,17 @@

把搜索基础设施
做成真正可交付的工作台

MySearch 把 Tavily、Exa、Firecrawl 和 Social / X 统一成一套可被 Codex、Claude Code 和团队 Agent 直接调用的搜索入口。使用者拿到后,不需要自己维护多套 provider、兼容网关和结果结构,也不需要每次手动判断这一轮该用网页搜索、正文抓取还是社交舆情。

+ +
统一入口 diff --git a/proxy/templates/console.html b/proxy/templates/console.html index 83bb453..6b6be04 100644 --- a/proxy/templates/console.html +++ b/proxy/templates/console.html @@ -76,5 +76,6 @@

一屏切换不同搜索引擎工作台

const INITIAL_AUTHENTICATED = {{ "true" if initial_authenticated else "false" }}; +
From c6c9ba45c60878d3065b8454e8f8415431c7ec81 Mon Sep 17 00:00:00 2001 From: zhaishujie <3394180966@qq.com> Date: Sat, 21 Mar 2026 21:45:53 +0800 Subject: [PATCH 04/20] fix: use details/summary for token/key tables to save space --- proxy/static/js/console.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/proxy/static/js/console.js b/proxy/static/js/console.js index 8bfd545..f308028 100644 --- a/proxy/static/js/console.js +++ b/proxy/static/js/console.js @@ -497,7 +497,7 @@ function renderMySearchQuickstart(mysearch, social) {
-
+ `; @@ -682,8 +682,8 @@ function renderServiceShells() { - - + +
@@ -716,8 +716,8 @@ function renderServiceShells() { -
+ `; From c0c5d587192fc790134354423622b70bc1484213 Mon Sep 17 00:00:00 2001 From: zhaishujie <3394180966@qq.com> Date: Sat, 21 Mar 2026 21:46:28 +0800 Subject: [PATCH 05/20] fix: finish detail summaries --- proxy/static/js/console.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/proxy/static/js/console.js b/proxy/static/js/console.js index f308028..708487e 100644 --- a/proxy/static/js/console.js +++ b/proxy/static/js/console.js @@ -476,8 +476,7 @@ function renderMySearchQuickstart(mysearch, social) {

         
-        
-

MySearch 通用 Token

+
MySearch 通用 Token

这个 token 专门给上层 MCP / Skill 用。和 Tavily / Firecrawl / Exa 各自的服务 token 分开管理,但调用时会被三条 provider 路由一起接受。

From dcab4893dfe50c1b2cc2ca4a5400269f97f26a7c Mon Sep 17 00:00:00 2001 From: zhaishujie <3394180966@qq.com> Date: Sun, 22 Mar 2026 09:42:53 +0800 Subject: [PATCH 06/20] Refine proxy console and document runtime fixes --- llmdoc/architecture/proxy-first.md | 55 + llmdoc/guides/common-workflows.md | 37 + llmdoc/reference/entrypoints-and-config.md | 116 + llmdoc/reference/runtime-entrypoints.md | 51 + mysearch/clients.py | 9 +- mysearch/config.py | 9 +- mysearch/keyring.py | 9 +- mysearch/social_gateway.py | 30 +- openclaw/runtime/mysearch/clients.py | 9 +- openclaw/runtime/mysearch/config.py | 9 +- openclaw/runtime/mysearch/keyring.py | 9 +- proxy/server.py | 652 ++- proxy/static/css/console.css | 3517 ++++++++++++++--- proxy/static/js/console.js | 3050 ++++++++++++-- proxy/templates/components/_hero.html | 106 +- .../templates/components/_settings_modal.html | 101 +- proxy/templates/console.html | 146 +- proxy/templates/mysearch.html | 108 + tests/test_clients.py | 6 + tests/test_proxy_tavily_settings.py | 81 + tests/test_social_normalization.py | 6 +- 21 files changed, 7009 insertions(+), 1107 deletions(-) create mode 100644 llmdoc/architecture/proxy-first.md create mode 100644 llmdoc/guides/common-workflows.md create mode 100644 llmdoc/reference/entrypoints-and-config.md create mode 100644 llmdoc/reference/runtime-entrypoints.md create mode 100644 proxy/templates/mysearch.html create mode 100644 tests/test_proxy_tavily_settings.py diff --git a/llmdoc/architecture/proxy-first.md b/llmdoc/architecture/proxy-first.md new file mode 100644 index 0000000..cc647ed --- /dev/null +++ b/llmdoc/architecture/proxy-first.md @@ -0,0 +1,55 @@ +# proxy-first 架构 + +## 核心链路 + +- 推荐链路是“上游 provider -> MySearch Proxy -> `mysp-` token -> MySearch MCP / OpenClaw / 其他 Agent”。这样客户端只需要一组 `MYSEARCH_PROXY_*`,而不是分别管理 Tavily、Firecrawl、Exa、Social 的 secret。来源:README.md:66, README.md:79, proxy/README.md:106 +- `proxy/` 不是单纯 key 面板,而是控制台、token 发放、统计与兼容代理接口的中间层。来源:proxy/README.md:5, proxy/README.md:27 +- 但 `proxy-first` 现在不再等于“所有 Tavily 流量都必须绑定本仓库 Proxy”。Tavily 在 runtime 里也支持独立 `gateway` 分支,例如接 `tavily-hikari` 这类上游;只是在没有显式 `MYSEARCH_TAVILY_MODE` 时,`MYSEARCH_PROXY_*` 仍会保持兼容性的 gateway 默认值。来源:mysearch/config.py:198, mysearch/config.py:207, mysearch/config.py:281, mysearch/config.py:320 + +## 运行时分层 + +- 架构文档把系统拆成三层:Skill / Decision Layer、MCP / Orchestration Layer、Provider Layer。这个分层解释了为什么 `skill/`、`mysearch/`、provider 配置不应该揉成一个目录。来源:docs/mysearch-architecture.md:3 +- `mysearch/server.py` 负责把统一能力暴露成 4 个 MCP tool;真正的 provider 路由与组合逻辑下沉在 `MySearchClient`。来源:mysearch/server.py:34, mysearch/server.py:47 +- `proxy/server.py` 负责上游代理端点与控制台管理 API;`proxy/database.py` 负责 SQLite 存储 token、key、usage 和 settings。来源:proxy/server.py:47, proxy/server.py:275, proxy/database.py:11, proxy/database.py:61 + +## Provider 路由规则 + +- `search` 的统一入口在 `mysearch/clients.py:385`,会先解析 intent、strategy、sources,再调用 `_route_search` 决定 provider。来源:mysearch/clients.py:418, mysearch/clients.py:439 +- 显式指定 `provider` 时,不再做自动路由;`tavily`、`firecrawl`、`exa`、`xai` 都会被直接尊重。来源:mysearch/clients.py:979 +- 同时请求网页和 X 时,会走 `hybrid`,并把 web 与 social 两条结果拼接。来源:mysearch/clients.py:1004, mysearch/clients.py:487 +- `social` 模式或传入 X handle 过滤时,优先走 xAI。来源:mysearch/clients.py:1009, mysearch/clients.py:1016 +- `docs`、`github`、`pdf` 这类文档查询默认是“先发现、后正文”:有内容需求时优先 Firecrawl;没要求正文时优先 Tavily 做页面发现,再把正文留给 Firecrawl。Firecrawl 缺失时才回退 Exa。来源:mysearch/clients.py:1023, mysearch/clients.py:1037, mysearch/clients.py:1043 +- 一般 `include_content=true` 的正文型请求也会优先 Firecrawl;Firecrawl 不可用时回退 Exa。来源:mysearch/clients.py:1056 +- `news` / `status` 默认 Tavily;普通网页查询默认 Tavily,未配置 Tavily 时回退 Exa。来源:mysearch/clients.py:1070, mysearch/clients.py:1131 +- `resource` / docs-like 查询会优先把发现与正文分开处理;`research` 会先做发现,再按策略扩展验证。来源:mysearch/clients.py:1084, mysearch/clients.py:1117 + +## 执行策略 + +- `strategy=balanced|verify|deep` 时,web 路由可能触发 Tavily + Firecrawl 的并行 blended 检索,再合并结果和 citations。来源:mysearch/clients.py:1201, mysearch/clients.py:1221 +- `extract_url` 独立于 `search`,默认优先 Firecrawl scrape,质量不够或失败时再回退 Tavily extract。来源:mysearch/server.py:97, mysearch/clients.py:677 +- `research` 是一个小型编排流程:先跑 web discovery,可选并行 social,再抓取前几条正文并回填 evidence。来源:mysearch/server.py:112, mysearch/clients.py:802 + +## xAI 与 Social + +- 架构文档明确区分 official xAI 与 compatible social gateway,避免把模型网关误当成搜索后端。来源:docs/mysearch-architecture.md:24, docs/mysearch-architecture.md:47 +- `MySearchConfig` 用 `MYSEARCH_XAI_SEARCH_MODE` 区分 `official` 与 `compatible`;`_search_xai` 在 `compatible` 模式下会改走 `social_search` 路径。来源:mysearch/config.py:18, mysearch/clients.py:1758, mysearch/clients.py:1832 +- `proxy/` 侧还维护了 Social gateway 的 upstream/base URL、fallback model、admin API 对接与缓存。来源:proxy/server.py:43, proxy/server.py:189 + +## Tavily 官方与 Gateway + +- Tavily 现在也和 xAI 一样有显式模式切分,但命名更直接:`MYSEARCH_TAVILY_MODE=official|gateway`。来源:mysearch/config.py:17, mysearch/config.py:198 +- `official` 模式下,runtime 会继续读取 `MYSEARCH_TAVILY_API_KEY`、`MYSEARCH_TAVILY_API_KEYS`、`MYSEARCH_TAVILY_KEYS_FILE`,并忽略 `MYSEARCH_PROXY_API_KEY` 对 Tavily 的注入。来源:mysearch/config.py:356, mysearch/config.py:395, mysearch/config.py:402 +- `gateway` 模式下,runtime 会改读 `MYSEARCH_TAVILY_GATEWAY_BASE_URL`、`MYSEARCH_TAVILY_GATEWAY_TOKEN` 和 `MYSEARCH_TAVILY_GATEWAY_*` 路径与鉴权配置,适合对接 `tavily-hikari` 这类上游。来源:mysearch/config.py:211, mysearch/config.py:287, mysearch/config.py:325, mysearch/config.py:383 +- 如果调用方没显式写 `MYSEARCH_TAVILY_MODE`,但配置了 `MYSEARCH_PROXY_*`,Tavily 默认仍落到 `gateway` 分支,保持现有 `proxy-first` 客户端最小配置不变。来源:mysearch/config.py:199, mysearch/config.py:206 +- `mysearch_health` 现在会把 Tavily 的 `provider_mode` 暴露出来,排查时先看这里,避免把“上游 gateway token 缺失”和“本地官方 key 池为空”混成一个问题。来源:mysearch/server.py:157, mysearch/clients.py:2717 + +## 配置优先级 + +- MySearch runtime 会先读 `~/.codex/config.toml` 的 `mcp_servers.mysearch.env`,再把 `.env` 当本地兜底,不覆盖宿主已注入配置。来源:README.md:91, mysearch/config.py:85, mysearch/config.py:98 +- `install.sh` 会先继承宿主已有的 `MYSEARCH_*`,再用 `mysearch/.env` 补缺省值,并将这些 env 注册给 `claude` 与 `codex`。来源:README.md:93, install.sh:16, install.sh:158, install.sh:174 + +## 数据与控制面 + +- Proxy 的启动时机会执行 `db.init_db()`;SQLite 默认路径是 `proxy/data/proxy.db`。来源:proxy/server.py:42, proxy/database.py:11, proxy/database.py:61 +- Proxy 的 token 体系里包含 `mysearch` 服务,生成前缀为 `mysp-` 的统一 token,默认只做鉴权与统计,不做配额拦截。来源:proxy/database.py:13, proxy/database.py:18, proxy/README.md:74, proxy/README.md:83 +- Proxy 控制台现在已经从单文件模板拆成 `console.html + _hero.html + _settings_modal.html + console.css + console.js` 这套 live 前端;页面布局已经回到 `summary-strip + dashboard-flow` 的纵向结构,默认首页下半区固定为 `Workspace Navigator -> provider workspace`,而统一客户端接入则拆到独立的 `/mysearch` 页面。`Workspace Navigator` 仍然只保留工作台名称、状态和 2 个核心指标,次要信息下沉到 badge 与 footnote,不再展示 `/api/search`、`/social/search` 这类具体请求路径;但它现在不再纵向堆叠,而是由 `service-switcher` 横向卡阵列承接,`Social Compatibility` 提示卡也继续收在 switcher 区块底部。登录入口也不再是孤立小表单,而是通过 `auth-meta` 把“统一入口 / provider / 控制面”三个概念先交代清楚,并在登录成功后由 `showDashboard({ animate: true })` 做一轮轻量 staged reveal,而在 `prefers-reduced-motion` 下会自动压平动效。hero 右侧原先那张“当前工作台”大卡已经移除,不再在首屏重复展示当前控制台状态。`/mysearch` 页则收成 `MySearch 接入台`,模块标题进一步压成 `统一接入配置`,避免页级标题和模块标题重复。该页内部继续保持“接入配置 / 安装路径 / 通用 Token 管理”三层,而且“一键配置”内部进一步拆成左侧 `quickstart-visual-col` 可视化 readiness 区和右侧 `quickstart-config-col` 配置区:`getQuickstartProviderCards()` 继续把 Tavily `effective_mode`、Exa / Firecrawl key 状态和 Social / X 接线结果汇总成 `quickstart-route-strip`,`getQuickstartInstallHint()` 继续把当前最短安装路径压成 `quickstart-install-strip`,同时也把旧版更直接的 `stdio / streamable-http` 安装形态补回到 `quickstart-install-meta`。这些状态会一起写入生成的 `MYSEARCH_PROXY_*` 配置说明;除了复制块旁边的普通复制按钮,现在还额外提供 `copyEnvAndRevealInstall()` 这个组合动作,直接复制 `.env` 并把视口定位到安装命令。默认首页的 `summary-strip` 也已经收窄成项目级概览:`当前工作台 / 已接通工作台 / Provider 代理 Token / 今日调用 / 本月调用 / MySearch Token`,不再塞入工作台内部已经会单独展示的上游额度或本地 API Key。主题切换则扩成 `浅色 / 深色 / 自动` 三态,`自动` 依据打开页面那台机器的本地时区与本地时间决定实际主题,不依赖服务端所在系统或容器时区。`MySearch 通用 Token` 摘要表继续共享和 provider 面板一致的本地搜索/排序逻辑。provider 页面仍然保持“摘要表 + `detail-drawer`”的运维视图,`Token 池 / API Key 池` 的本地搜索、筛选和排序,以及 `table-row-clickable.is-danger|is-warn|is-busy|is-off` 风险行态都保留不变;`detail-drawer` 底部动作也继续通过 `renderDrawerActionGroup()` 拆成“维护动作 / 危险动作”两组。设置面板仍是带 `settings-summary-strip`、sticky footer 和 Tavily `mode-switch` 分段控件的配置中心,并保留 `/api/settings/test/tavily` 与 `/api/settings/test/social` 这两条结构化 probe 链路。控制台刷新仍然通过 `normalizeRefreshScope()`、`getRefreshScopeForService()`、`renderDashboardScope()` 做局部更新,可访问性层也仍然保留 `handleSegmentedControlKey()`、toast live region、overlay focus remember/restore 与 `trapOverlayFocus()` 这一组统一逻辑。来源:proxy/templates/components/_hero.html:25, proxy/templates/components/_hero.html:51, proxy/templates/console.html:51, proxy/templates/console.html:54, proxy/templates/console.html:65, proxy/templates/mysearch.html:21, proxy/static/js/console.js:644, proxy/static/js/console.js:1425, proxy/static/js/console.js:1478, proxy/static/js/console.js:1695, proxy/static/js/console.js:2251, proxy/static/js/console.js:2777, proxy/static/css/console.css:622, proxy/static/css/console.css:717, proxy/static/css/console.css:1061, proxy/static/css/console.css:1508, proxy/static/css/console.css:1533, proxy/static/css/console.css:2923, proxy/static/css/console.css:2943, proxy/server.py:2259 diff --git a/llmdoc/guides/common-workflows.md b/llmdoc/guides/common-workflows.md new file mode 100644 index 0000000..b2bfbe1 --- /dev/null +++ b/llmdoc/guides/common-workflows.md @@ -0,0 +1,37 @@ +# 常见工作流 + +## 1. 本机给 Codex / Claude Code 接入 MySearch + +1. 在仓库根目录准备 Python 环境,并优先把 `MYSEARCH_*` 写进宿主配置,而不是先复制 `.env`。来源:README.md:165, README.md:172, skill/README.md:72 +2. 执行根目录 `install.sh`。它会安装 `mysearch/requirements.txt`,继承宿主已有配置,再尝试把 `mysearch` 注册到 Claude Code 和 Codex。来源:install.sh:13, install.sh:74, install.sh:174, install.sh:183 +3. 验收时先看 `mcp list/get`,再跑 `python skill/scripts/check_mysearch.py --health-only` 和基础 smoke test。来源:README.md:193, skill/README.md:148 + +## 2. 部署 proxy-first 链路 + +1. 先部署 `proxy/`,这层负责统一接上游 provider、生成 `mysp-` token 并暴露控制台。来源:README.md:200, proxy/README.md:102 +2. 首次进入默认搜索控制台后,顶部 hero 不再单独重复展示“当前工作台”大卡;首屏只保留品牌区、快捷动作和 4 条 provider lane。如果想直接进入操作区,优先用 `进入当前工作台`;如果想看统一客户端接入配置,则用 `查看 MySearch 接入` 跳到独立的 `/mysearch` 页面。来源:proxy/templates/components/_hero.html:25, proxy/templates/components/_hero.html:42, proxy/templates/components/_hero.html:51, proxy/templates/components/_hero.html:77, proxy/static/js/console.js:631 +3. 默认搜索控制台下半区现在固定为 `Workspace Navigator -> 具体工作台`,不再默认把 `MySearch 快速接入` 挂在首页。switcher 卡片只保留工作台名称、状态和 2 个核心指标,次要信息下沉成 badge 与说明,不再显示 `/api/search` 这类具体请求路径;同时它已经改成横向卡阵列,不再一张张纵向堆高页面。`Social Compatibility` 也已经收回到 switcher 区块底部。Tavily 现在不是手动二选一,而是进 `Settings -> Tavily` 看 `auto|pool|upstream` 三态分段控件,保存后前端会同时显示“配置模式 / 当前实际 / 来源”,其中 `auto` 会按“上游凭证优先,其次本地活跃 Tavily key”自动解析;如果你只是导入 Tavily key,默认实际就会落到 API Key 池。Social/X 的 grok2api 或 compatible 配置仍进 `Settings -> Social / X`,而且字段标题现在按职责拆成“搜索上游”和“后台管理”两类,不再把 `Base URL`、后台地址和 app key 混成一组理解。设置中心每个 tab 都带 `settings-summary-strip`、sticky footer 和“测试当前连接”按钮,而且测试结果会直接展开成结构化 probe 卡,不需要自己从一行状态文案里猜请求目标或鉴权来源。来源:proxy/templates/console.html:51, proxy/templates/console.html:61, proxy/templates/components/_settings_modal.html:61, proxy/templates/components/_settings_modal.html:117, proxy/templates/components/_settings_modal.html:218, proxy/static/css/console.css:1061, proxy/static/js/console.js:2262 +4. 具体 provider 页面现在统一先看 `stats + 接线摘要`,再按需展开 `Token 池` 和 `API Key 池` 两个 detail cards;主表已经降成摘要视图,点击任一 token/key 行会打开右侧 `detail-drawer` 查看完整额度、账户层级信息和维护动作。新增的本地筛选条会直接在前端做搜索、筛选和排序,而且已经补到 `失败优先`、`待处理`、`异常优先`、`低额度优先` 这类运维向视角;表格行也可以直接用键盘 `Enter / Space` 打开详情抽屉。需要特别区分的是“本地池统计”和“上游状态”:Tavily 当前实际走 upstream 时,概览优先展示上游 Hikari 的公共摘要,例如活跃 key、耗尽 key、总请求与总剩余额度,本地 Tavily key 会降级成回退库存;Social / X 在接通 grok2api 后台时继续显示完整 token 统计,但如果只有手动上游 key / gateway token,就只展示基础接线可视化,例如上游 key 数、客户端 token 数和可转发状态,不再把后台未接通误显示成一排 0。对于兼容后台的 `/v1/admin/tokens`,控制台现在也会先解包 `{tokens:{...}}`、`{data:{...}}`、`{items:[...]}` 这类响应再做统计,避免后台地址和 app key 都已配置但 token 总数仍错误地显示为 0。初始化顺序仍建议保持“登录 -> 导入 Tavily/Firecrawl/Exa key -> 需要时补 Social/X -> sync usage -> 创建 `mysp-` token”。来源:proxy/templates/console.html:94, proxy/static/js/console.js:845, proxy/static/js/console.js:1268, proxy/static/js/console.js:2631, proxy/static/js/console.js:2718 +5. `MySearch 快速接入` 现在已经独立到 `/mysearch` 页面,不再默认出现在搜索控制台首页,这一页也不再重复展示首页 `summary-strip`。页级标题已经收成 `MySearch 接入台`,模块级标题则用 `统一接入配置`,不再两层都重复 `MySearch 快速接入`。页面内部继续收成“接入配置 / 安装路径 / 通用 Token 管理”三层,而且“一键配置”内部再拆成左侧可视化 readiness 区和右侧配置区,不再让 route 小卡片和 `.env`/说明混在一列里。`quickstart-route-strip` 会根据 Tavily `effective_mode`、Exa / Firecrawl key 状态和 Social / X 接线情况,动态显示当前 provider readiness;`quickstart-install-strip` 则把“创建通用 token → 复制 `.env` → ./install.sh”压成当前最短安装路径,并额外提供 `复制 .env 并定位命令` 的组合动作;旧版更直观的默认安装形态也通过 `quickstart-install-meta` 补回来了,直接展示 `stdio / streamable-http`。生成的 `.env` 里也会把“当前路由状态”写进去。客户端侧仍只保留 `MYSEARCH_PROXY_BASE_URL` 与 `MYSEARCH_PROXY_API_KEY`,不再把 provider key 散落到每台机器;`MySearch 通用 Token` 摘要表也补上了本地搜索和排序。来源:README.md:79, proxy/templates/mysearch.html:45, proxy/static/js/console.js:1433, proxy/static/js/console.js:1486, proxy/static/js/console.js:1546, proxy/static/js/console.js:1695 +6. 默认首页的 `summary-strip` 现在更偏“控制面概览”而不是“把所有 provider 细节都缩一遍”。它只展示 `当前工作台 / 已接通工作台 / Provider 代理 Token / 今日调用 / 本月调用 / MySearch Token`。其中 `Provider 代理 Token` 会按工作台当前是否走本地代理池动态统计,例如 Tavily 或 Social / X 一旦切到上游 Gateway/兼容后台,就会从这项里剔除;Exa / Firecrawl 继续按本地 provider token 池计入。这样首页摘要只表达项目拓扑和控制面状态,不再重复展示工作台内部已经会单独展示的上游额度、本地 API Key 或 `Social Chat` 细节。来源:proxy/static/js/console.js:2777 +7. 保存设置、测试连接、复制配置和同步额度仍统一走页面右下角 toast;删除 token/key 这类危险动作现在不再用浏览器原生确认框,而是统一走 `app-dialog`;控制台范围内也已经没有原生 `select`,Tavily 工作模式改成了自定义 `mode-switch` 分段控件;左侧 `Social Compatibility` 与右侧 `Social / X 接入` 也都改成更摘要优先的结构,长英文值不再直接用大字号 value 顶满卡片;provider 的 token/key 摘要表则会用 `danger / warn / busy / off` 行态底色优先标出同步错误、低额度、异常活跃和停用状态,并在表格上方用 `table-legend` 直接给出图例;右侧 `detail-drawer` 的底部动作也改成“维护动作 / 危险动作”两组,删除类操作不再和普通维护动作并排。登录页也已经和 dashboard 收成同一套视觉语言,补了 `auth-meta` 元信息卡;登录、设置保存、设置测试、额度同步、创建 token、添加/导入 key 这些主操作现在都会显示按钮级 loading / success / error 过渡,而且共享同一个 `runWithBusyButton()`,会自动给 busy 态保留最小时长并避免在刷新后把 success/error 反馈闪到错误节点。进一步地,控制台现在已经把频繁操作改成“局部刷新而不是全量重绘”,并补上了 `mode-switch / mini-switch / settings-tab` 的方向键导航、toast live region、dialog 焦点回收与 focus trap;登录成功后 dashboard 仍会做一轮轻量 staged reveal,而在 `prefers-reduced-motion` 下这些动效会自动压平。主题切换也已经扩成 `浅色 / 深色 / 自动` 三态,`自动` 会按打开页面那台机器的本地时区和本地时间切换实际主题,不依赖服务端所在系统或 Docker 容器时区。来源:proxy/templates/console.html:97, proxy/templates/console.html:114, proxy/templates/console.html:134, proxy/static/js/console.js:19, proxy/static/js/console.js:108, proxy/static/js/console.js:178, proxy/static/js/console.js:644, proxy/static/js/console.js:653, proxy/static/js/console.js:808, proxy/static/js/console.js:3123, proxy/static/js/console.js:3495, proxy/static/css/console.css:118, proxy/static/css/console.css:1008, proxy/static/css/console.css:1245, proxy/static/css/console.css:2507, proxy/static/css/console.css:2908 + +## 3. 接入 OpenClaw + +1. OpenClaw 正式配置优先写 `openclaw.json` 的 `skills.entries.mysearch.env`,不要把 secret 直接写进仓库文件。来源:README.md:94, openclaw/README.md:65, openclaw/README.md:93 +2. 本地替换 bundle 时,走 `openclaw/` 自带安装脚本;从 ClawHub 安装时,重点仍然是注入 env,而不是手改 bundle。来源:openclaw/README.md:55, openclaw/README.md:74 +3. 验收顺序是 `health` -> `search --mode web` -> 需要时补 `docs` / `social`。来源:openclaw/README.md:121 + +## 4. 排查搜索行为 + +1. 先跑 `mysearch_health`,确认 provider 是否可用,而不是只看 key 有没有填。来源:mysearch/server.py:156, README.md:105 +2. 再看 `route` 与 `route_debug`,判断是显式 provider、生效的 intent/strategy,还是 blended/hybrid 路由。来源:mysearch/clients.py:359, mysearch/clients.py:651 +3. 文档类查询要分清“页面发现”和“正文抓取”是两个阶段;正文异常先查 Firecrawl,再看 Tavily/Exa fallback。来源:mysearch/clients.py:1023, mysearch/clients.py:677 +4. 如果问题出在团队共享链路,而不是本地 runtime,就把排查重心切到 `proxy/` 的 key 池、token、usage sync 和 social gateway 配置。遇到“后台地址和 app key 都已配置,但 Social / X token 统计还是 0”时,先确认上游 `/v1/admin/tokens` 返回结构是否带 envelope;当前控制台已经兼容 `{tokens:{...}}`、`{data:{...}}`、`{items:[...]}`,所以这种现象通常说明进程还没重启到新代码,或者后台返回的并不是这组 admin 语义。来源:proxy/README.md:27, proxy/server.py:189, proxy/server.py:736, proxy/database.py:179 + +## 5. 什么时候改哪里 + +- 改 prompt、安装话术、AI 调用顺序:看 `skill/` +- 改 runtime 参数、provider 路由、search/extract/research 组合:看 `mysearch/` +- 改统一 token、代理 API、控制台和持久化:看 `proxy/` +- 改 OpenClaw bundle:看 `openclaw/` diff --git a/llmdoc/reference/entrypoints-and-config.md b/llmdoc/reference/entrypoints-and-config.md new file mode 100644 index 0000000..4694109 --- /dev/null +++ b/llmdoc/reference/entrypoints-and-config.md @@ -0,0 +1,116 @@ +# 运行入口与配置入口 + +## 关键运行入口 + +### 根安装入口 + +- `install.sh:1` +- 负责安装 `mysearch/requirements.txt`,然后尝试向 `Claude Code` 与 `Codex` 注册本地 `stdio` MCP。见 `install.sh:13`、`install.sh:174`、`install.sh:183`。 +- 它会先读取宿主 `~/.codex/config.toml` 里 `mcp_servers.mysearch.env`,再用 `mysearch/.env` 只补缺省值。见 `install.sh:74`、`install.sh:131`、`install.sh:158`。 + +### MySearch MCP 启动入口 + +- `mysearch/__main__.py:8` + - 解析 `--transport`、`--host`、`--port`、`--sse-path`、`--streamable-http-path` 等参数。 +- `mysearch/server.py:168` + - 最终调用 FastMCP 的 `run(...)`。 +- `mysearch/server.py:34` + - `build_mcp()` 里定义 4 个 MCP tools。 + +### Proxy Console / API 入口 + +- `proxy/server.py:1642` + - 启动时初始化 SQLite。 +- `proxy/server.py:1649`、`proxy/server.py:1680`、`proxy/server.py:1719`、`proxy/server.py:1783` + - 对外搜索代理与 Social/X 代理入口。 +- `proxy/server.py:1877` + - 控制台页面入口。 +- 运行方式见 `proxy/README.md:144`、`proxy/README.md:166`、`proxy/README.md:173` 与 `proxy/Dockerfile:7`。 + +### OpenClaw wrapper 入口 + +- `openclaw/scripts/mysearch_openclaw.py:303` + - bundle CLI 总入口,支持 `health`、`search`、`extract`、`research`。 +- `openclaw/scripts/mysearch_openclaw.py:74` + - wrapper 会先检查 bundled runtime 是否存在,再把 `openclaw/runtime/` 注入 `sys.path`。 + +## 配置优先级 + +## MySearch runtime + +稳定规则:**已存在的进程环境变量优先,bootstrap 逻辑只补缺失值。** + +原因是 `mysearch/config.py` 的加载器统一用 `os.environ.setdefault(...)`,见 `mysearch/config.py:23`、`mysearch/config.py:40`。 + +推荐按下面顺序理解: + +1. 进程已注入的环境变量。 +2. `~/.codex/config.toml` 中 `mcp_servers.mysearch.env`,见 `mysearch/config.py:85`。 +3. `mysearch/.env` 或仓库根 `.env` 作为本地单仓兜底,见 `mysearch/config.py:98`。 + +回归测试明确要求:`config.toml` 覆盖 `.env`,而 `.env` 只补缺失字段;Python 3.10 无 `tomllib` 时仍可回退解析。当前运行时还额外兼容 Python 3.9:dataclass 装配层会自动去掉 `slots`,避免 `mysearch/config.py`、`mysearch/clients.py`、`mysearch/keyring.py` 以及 OpenClaw 对应 runtime 在导入阶段直接报错。见 `mysearch/config.py:17`、`mysearch/clients.py:24`、`mysearch/keyring.py:12`、`openclaw/runtime/mysearch/config.py:17`、`openclaw/runtime/mysearch/clients.py:24`、`openclaw/runtime/mysearch/keyring.py:12`、`tests/test_config_bootstrap.py:39`、`tests/test_config_bootstrap.py:144`。 + +## Proxy-first 默认映射 + +`MySearchConfig.from_env()` 会先读 `MYSEARCH_PROXY_BASE_URL` 与 `MYSEARCH_PROXY_API_KEY`,但当前语义已经比早期版本更细。Firecrawl / Exa 仍然会直接切到 Proxy 语义;Tavily 则先看 `MYSEARCH_TAVILY_MODE`,只有在 `gateway` 分支下才会继承 proxy/gateway 语义,显式 `official` 时会继续使用自己的官方 key 池。见 `mysearch/config.py:167`、`mysearch/config.py:181`、`mysearch/config.py:198`、`mysearch/config.py:281`、`mysearch/config.py:320`、`mysearch/.env.example:7`。 + +这意味着对下游客户端来说,最小配置通常只需要: + +- `MYSEARCH_PROXY_BASE_URL` +- `MYSEARCH_PROXY_API_KEY` + +但如果你希望 Tavily 不走统一 Proxy,而是自己维护 key 池或对接独立上游,就要显式指定: + +- `MYSEARCH_TAVILY_MODE=official` + - 配合 `MYSEARCH_TAVILY_API_KEY`、`MYSEARCH_TAVILY_API_KEYS`、`MYSEARCH_TAVILY_KEYS_FILE` +- `MYSEARCH_TAVILY_MODE=gateway` + - 配合 `MYSEARCH_TAVILY_GATEWAY_BASE_URL`、`MYSEARCH_TAVILY_GATEWAY_TOKEN` + +## OpenClaw wrapper + +OpenClaw 侧也是 host-config-first,但入口不同: + +1. 进程已有环境变量。 +2. `openclaw.json` 中 `skills.entries.mysearch.env`,见 `openclaw/scripts/mysearch_openclaw.py:43`。 +3. `openclaw/.env`,见 `openclaw/scripts/mysearch_openclaw.py:304`。 +4. `openclaw/runtime/.env`,见 `openclaw/scripts/mysearch_openclaw.py:306`。 + +测试已覆盖 wrapper 会从 `openclaw.json` 读取 skill env。见 `tests/test_config_bootstrap.py:97`。 + +## 关键环境变量分组 + +| 分组 | 用途 | 入口 | +| --- | --- | --- | +| `MYSEARCH_PROXY_*` | 统一走 MySearch Proxy 的下游接线 | `mysearch/.env.example:7`, `openclaw/.env.example:2` | +| `MYSEARCH_TAVILY_MODE` | 显式选择 Tavily 走官方 key 池还是上游 gateway | `mysearch/.env.example:25`, `openclaw/.env.example:12` | +| `MYSEARCH_TAVILY_*` | Tavily 官方直连与本地 key 池 | `mysearch/.env.example:28`, `openclaw/.env.example:15` | +| `MYSEARCH_TAVILY_GATEWAY_*` | Tavily 上游 gateway token、base URL、path、auth 配置 | `mysearch/.env.example:39`, `openclaw/.env.example:26` | +| `MYSEARCH_FIRECRAWL_*` | Firecrawl 直连或兼容网关 | `mysearch/.env.example:37` | +| `MYSEARCH_EXA_*` | Exa 兜底路由 | `mysearch/.env.example:49` | +| `MYSEARCH_XAI_*` | official xAI 或 compatible social 模式 | `mysearch/.env.example:60`, `openclaw/.env.example:47` | +| `MYSEARCH_MCP_*` | 本地/远程 MCP 传输配置 | `mysearch/.env.example:14` | +| `SOCIAL_GATEWAY_*` | 本地 social gateway 或 Proxy social upstream/admin 配置 | `mysearch/.env.example:82`, `proxy/.env.example:1` | +| `ADMIN_*` | Proxy 控制台管理员认证 | `proxy/.env.example:1`, `proxy/README.md:249` | + +## 状态与数据落点 + +### Proxy SQLite + +- 数据库路径:`proxy/data/proxy.db`,见 `proxy/database.py:11`。 +- 主要表:`api_keys`、`tokens`、`usage_logs`、`settings`,见 `proxy/database.py:61`。 +- 下游 token 服务范围包含 `tavily`、`firecrawl`、`exa`、`mysearch`;`mysearch` token 前缀为 `mysp-`。见 `proxy/database.py:12`、`proxy/database.py:13`、`proxy/database.py:14`。 + +### 运行时缓存 + +- `search` 与 `extract` 走 TTL 缓存;provider live probe 也有单独 TTL。见 `mysearch/clients.py:121`、`mysearch/clients.py:125`、`mysearch/clients.py:199`、`mysearch/clients.py:215`、`mysearch/clients.py:2842`。 + +## 重要验证点 + +- 路由健康保护:`tests/test_clients.py:488` +- 配置继承与 Python 3.10 fallback:`tests/test_config_bootstrap.py:39`, `tests/test_config_bootstrap.py:97`, `tests/test_config_bootstrap.py:144` +- Tavily official/gateway 分支:`tests/test_config_bootstrap.py:39`, `tests/test_config_bootstrap.py:74` +- Social/X 归一化与 fallback:`tests/test_social_normalization.py:76`, `tests/test_social_normalization.py:171` + +如果是直接用 `python tests/test_*.py` 跑单文件,而不是走 `python -m unittest discover`,当前几个脚本测试也已经自带仓库根目录引导,不再要求调用方先手动设置 `PYTHONPATH`。见 `tests/test_clients.py:10`、`tests/test_social_normalization.py:8`、`tests/test_proxy_tavily_settings.py:10`。 + +这些测试文件就是改动相关行为时最值得先看的“行为契约”。 diff --git a/llmdoc/reference/runtime-entrypoints.md b/llmdoc/reference/runtime-entrypoints.md new file mode 100644 index 0000000..0e2740d --- /dev/null +++ b/llmdoc/reference/runtime-entrypoints.md @@ -0,0 +1,51 @@ +# 运行入口参考 + +## 关键入口文件 + +| 文件 | 角色 | 关键点 | +| --- | --- | --- | +| `install.sh` | 本地安装与注册入口 | 安装 `mysearch/requirements.txt`,继承宿主 `MYSEARCH_*`,再向 `claude` / `codex` 注册 `mysearch` MCP。来源:install.sh:13, install.sh:74, install.sh:174 | +| `mysearch/__main__.py` | MySearch CLI 入口 | 解析 `stdio`、`sse`、`streamable-http` transport 及 host/port/path 参数,然后调用 `mysearch.server.main`。来源:mysearch/__main__.py:8 | +| `mysearch/server.py` | MCP tool 暴露层 | 用 `FastMCP` 注册 `search`、`extract_url`、`research`、`mysearch_health`,并根据 transport 启动服务。来源:mysearch/server.py:34, mysearch/server.py:47, mysearch/server.py:168 | +| `mysearch/config.py` | Runtime 配置装配层 | 先读宿主 `~/.codex/config.toml`,再读 `.env` 兜底,并组装 Tavily / Firecrawl / Exa / xAI 的 `ProviderConfig`;Tavily 现在显式区分 `official` 与 `gateway`。运行时还兼容 Python 3.9:在解释器低于 3.10 时,dataclass 装配层会自动去掉 `slots`,避免配置模块导入即失败。来源:mysearch/config.py:17, mysearch/config.py:85, mysearch/config.py:98, mysearch/config.py:281, mysearch/config.py:320 | +| `mysearch/clients.py` | 搜索编排与 provider 适配层 | 统一处理 intent、strategy、routing、cache、blended search、extract fallback、research workflow。来源:mysearch/clients.py:277, mysearch/clients.py:385, mysearch/clients.py:677, mysearch/clients.py:802, mysearch/clients.py:964 | +| `proxy/server.py` | Proxy 与控制台入口 | 暴露 Tavily / Firecrawl / Exa / Social 代理端点、控制台页面与管理 API;除了默认 `/` 搜索控制台以外,现在还额外提供 `/mysearch` 作为独立的 MySearch 接入页。FastAPI 入口已经切到 lifespan:启动时初始化数据库,退出时统一关闭共享 `http_client`,不再依赖已废弃的 `startup` hook。导入期的 `asyncio.Lock()` 也改成惰性初始化,避免 Python 3.9 在测试或脚本导入阶段留下未关闭事件循环。Tavily 上游链路现在也会对 `th-...` 这类 Hikari 访问令牌做路径兼容:如果 `Base URL` 还停在 Hikari 主机根而不是 `/api/tavily`,probe 与真实转发都会自动补上 `/api/tavily` 前缀;而当 Tavily 当前实际走上游时,控制台还会额外尝试读取上游公开的 `/api/summary`,把 Hikari 的基础摘要映射成 `upstream_summary`。Social / X 则继续分成两档:接通后台 admin 接口时返回完整 token 统计;只有手动上游 key / gateway token 时,只返回 `upstream_visibility` 这类基础接线可视化,不再把“没有后台 token 面板”和“没有接通上游”混成同一组 0 值。兼容后台的 `/v1/admin/tokens` 如果不是直接返回 `pool -> list`,而是包了一层 `{tokens:{...}}`、`{data:{...}}`、`{items:[...]}` 之类 envelope,当前解析逻辑也会先解包再做统计,避免后台已接通但控制台仍显示一排 0。来源:proxy/server.py:219, proxy/server.py:228, proxy/server.py:233, proxy/server.py:736, proxy/server.py:1299, proxy/server.py:2332 | +| `proxy/templates/console.html` | Proxy 控制台壳模板 | 现在只负责装配默认 live 前端入口:引入 `components/_hero.html`、`components/_settings_modal.html`、`static/css/console.css`、`static/js/console.js`,并把页面组织成 `summary-strip + dashboard-flow` 的纵向结构;默认首页下半区顺序已经收成 `Workspace Navigator -> provider workspace`,`MySearch 快速接入` 不再默认挂在首页,改由 hero 里的“查看 MySearch 接入”跳去独立页面。`Social Compatibility` 也收回到 switcher 区块底部,不再单独占一侧 rail。壳层同时继续托管 `detail-drawer`、统一 `app-dialog` 和 `toast-root` 这三类交互容器。登录壳也继续保留在这里,但已经补了 `auth-meta` 三张元信息卡,让登录页与 dashboard 共用同一套基础设施风格。来源:proxy/templates/console.html:45, proxy/templates/console.html:51, proxy/templates/console.html:63, proxy/templates/console.html:92, proxy/templates/console.html:98, proxy/templates/console.html:125 | +| `proxy/templates/mysearch.html` | MySearch 独立接入页模板 | 承载独立的 `MySearch 接入台` 页面,保留同一套登录、主题切换、设置弹窗、detail drawer、dialog 和 toast,但默认只展示统一接入配置、安装路径和通用 token 管理,不再把 provider 工作台或首页 `summary-strip` 混进这一页。来源:proxy/templates/mysearch.html:1, proxy/templates/mysearch.html:45, proxy/templates/mysearch.html:63 | +| `proxy/templates/components/_hero.html` | Hero 与首屏焦点组件 | 定义 `Search Operations Desk` 顶部品牌区、主题切换、快捷动作和 4 条 provider lane。右侧“当前工作台”大卡已经从 hero 里移除,不再在这块重复展示当前控制台状态;“查看 MySearch 接入”按钮现在会直接跳到独立的 `/mysearch` 页面。主题切换也不再只有浅色/深色两态,而是 `浅色 -> 深色 -> 自动` 三态循环;`自动` 会按打开页面那台机器的本地时区与本地时间决定当前生效主题。来源:proxy/templates/components/_hero.html:7, proxy/templates/components/_hero.html:14, proxy/templates/components/_hero.html:42, proxy/templates/components/_hero.html:51, proxy/templates/components/_hero.html:77, proxy/static/js/console.js:19, proxy/static/js/console.js:634 | +| `proxy/templates/components/_settings_modal.html` | 控制台设置组件 | 设置中心继续按 `Console / Tavily / Social / X` tabs 组织,但每个 tab 现在都加了 `settings-summary-strip` 和 sticky footer;Tavily 的工作模式已经不是原生 `select`,而是隐藏 input + `mode-switch` 分段控件,并在设置面板里直接展示“当前实际”运行条。Tavily 与 Social / X 的 footer 里还新增了“测试当前连接”按钮,对应后端 `settings-test` 诊断接口;测试结果不再只是状态句子,而是通过独立的 `settings-*-probe` 区块呈现请求目标、鉴权来源、返回状态、失败原因和建议动作。Social / X 设置表单里的字段标题也已经按职责重命名成“搜索上游 Base URL / 后台管理地址 / 后台管理 app key / 搜索上游 API key / 客户端访问 token”,避免把搜索转发配置和后台管理配置混在一起理解。来源:proxy/templates/components/_settings_modal.html:18, proxy/templates/components/_settings_modal.html:35, proxy/templates/components/_settings_modal.html:61, proxy/templates/components/_settings_modal.html:77, proxy/templates/components/_settings_modal.html:79, proxy/templates/components/_settings_modal.html:85, proxy/templates/components/_settings_modal.html:117, proxy/templates/components/_settings_modal.html:218 | +| `proxy/static/js/console.js` | 控制台交互与渲染主入口 | 负责主题持久化、当前工作台信号判断、workspace navigator、provider 面板、MySearch quickstart、settings form 回填,以及 Tavily/Social 设置保存;新一轮 UI 里除了 `renderSettingsSummaries()`、`renderPoolGlance()`、`openDetailDrawer()`、`openTokenDetail()`、`openKeyDetail()` 和 `showConfirmDialog()` 之外,还新增了 `normalizeRefreshScope()`、`getRefreshScopeForService()`、`renderDashboardScope()` 这组局部刷新机制,用来避免每次操作都全量重绘 hero / summary / quickstart / 全部 workspace。脚本现在还会根据 `PAGE_KIND` 区分默认控制台与 `/mysearch` 独立接入页:前者继续渲染 `summary-strip`、switcher 与 provider workspace,后者只渲染 `renderMySearchQuickstart()`;hero 的“查看 MySearch 接入”按钮也已经改成跳转 `/mysearch`。默认首页的 `summary-strip` 口径现在只保留项目级概览:`当前工作台 / 已接通工作台 / Provider 代理 Token / 今日调用 / 本月调用 / MySearch Token`。其中 `Provider 代理 Token` 会按工作台当前是否走本地代理池动态统计,例如 Tavily 或 Social / X 切到上游后就从这项里剔除,避免再把工作台内部的上游额度、`Social Chat` 或本地 API Key 混进全局卡片。`renderMySearchQuickstart()` 现在改成纵向三层,其中“一键配置”内部再拆成左侧 `quickstart-visual-col` readiness 可视化区和右侧 `quickstart-config-col` 配置区,而且 `Proxy Base URL` 到 `Social / X` 那组摘要也已经下沉到左侧可视化区底部;`安装路径` 则继续拆成左右两列,左边是 `quickstart-install-strip`,右边是命令区。独立接入页标题已经收成 `MySearch 接入台`,模块级标题则用 `统一接入配置`,不再重复出现两层 `MySearch 快速接入`。主题切换也已经变成 `light / dark / auto` 三态;`auto` 依据浏览器本地时区与本地时间决定当前生效主题,并会在页面重新获得焦点或定时轮询时自动重算。设置页除了 `testTavilySettings()`、`testSocialSettings()` 以外,还会通过 `renderSettingsProbe()` 渲染结构化诊断卡。现在还额外加入了 `getTavilyUpstreamSummary()` 与 `getSocialUpstreamState()` 这组分层逻辑:Tavily upstream 模式优先展示 Hikari 公共摘要,Social / X 则在 `admin-auto` 时展示完整 token 统计,在只有手动上游鉴权时退化成“上游 key 数 / 客户端 token 数 / 可转发状态”的基础可视化,避免把“后台未接通”和“上游未配置”混成相同的 0 值。键盘和焦点层则新增了 `handleSegmentedControlKey()`、overlay focus remember/restore 和 `trapOverlayFocus()`,把 `mode-switch / mini-switch / settings-tab` 的方向键导航、toast live region、dialog 焦点回收收进统一逻辑。来源:proxy/static/js/console.js:16, proxy/static/js/console.js:634, proxy/static/js/console.js:797, proxy/static/js/console.js:822, proxy/static/js/console.js:1268, proxy/static/js/console.js:1695, proxy/static/js/console.js:2718, proxy/static/js/console.js:3186 | +| `proxy/static/css/console.css` | 控制台视觉系统入口 | 负责新 UI 的字体、暖色玻璃化背景、hero 装饰层、纵向 `dashboard-flow`、collapsible detail cards、settings modal 和 toast 样式;现在也包含登录页 `auth-meta`、`#dashboard.is-entering` 分段入场动画、按钮状态 `btn.is-busy / is-success / is-error`、工作台切换 reveal 的 `workspace-stage-shift / service-panel-focus-in`、横向 `service-switcher` 卡阵列、MySearch `quickstart-grid / quickstart-primary-layout / quickstart-visual-col / quickstart-config-col / quickstart-route-strip / quickstart-install-layout / quickstart-install-strip / quickstart-install-meta`、Tavily `mode-switch`、provider 筛选条 `table-tools / mini-switch`、结构化诊断卡 `settings-probe`、表格风险底色 `table-row-clickable.is-*`、键盘 focus ring、行态图例 `table-legend`、drawer 底部动作分组 `drawer-action-group`、Social 的 `credit-strip-inline`、sticky footer、`detail-drawer`、`app-dialog`,以及 `prefers-reduced-motion` 下自动压平 staged reveal / 按钮 / 切换动画的规则。来源:proxy/static/css/console.css:622, proxy/static/css/console.css:717, proxy/static/css/console.css:1061, proxy/static/css/console.css:1508, proxy/static/css/console.css:1533, proxy/static/css/console.css:1891, proxy/static/css/console.css:1999, proxy/static/css/console.css:2923, proxy/static/css/console.css:2943, proxy/static/css/console.css:2976 | +| `proxy/database.py` | Proxy 持久化入口 | 管理 SQLite、key/token/usage/settings 表,以及 `mysp-` token 前缀。来源:proxy/database.py:11, proxy/database.py:61 | +| `skill/README.md` | AI 安装入口 | 告诉 Codex / Claude Code 如何从源码仓或远程 MCP 入口安装与验收 MySearch。来源:skill/README.md:40 | +| `openclaw/README.md` | OpenClaw 安装入口 | 告诉 OpenClaw/ClawHub 如何安装 bundle、注入 env、执行健康检查。来源:openclaw/README.md:53 | + +## 关键配置族 + +- `MYSEARCH_PROXY_*` + - 统一下游接入 `proxy-first` 的最小配置;`MYSEARCH_PROXY_BASE_URL` 和 `MYSEARCH_PROXY_API_KEY` 会被 `MySearchConfig.from_env()` 读入,并继续影响 Firecrawl / Exa 的默认 base URL 与 path。对 Tavily 来说,它们只会落到 `gateway` 语义,不再覆盖显式 `official` 模式。来源:README.md:178, mysearch/config.py:281, mysearch/config.py:287, mysearch/config.py:320, mysearch/config.py:421 +- `MYSEARCH_MCP_*` + - 控制 MySearch 自身的 transport、host、port、mount path、SSE path、streamable HTTP path。来源:install.sh:21, mysearch/__main__.py:12, mysearch/config.py:253 +- `MYSEARCH_TAVILY_MODE`、`MYSEARCH_TAVILY_*`、`MYSEARCH_TAVILY_GATEWAY_*` + - Tavily 的显式双分支配置。`MYSEARCH_TAVILY_MODE=official` 时走官方 Tavily key、本地 key 列表或 key 文件;`MYSEARCH_TAVILY_MODE=gateway` 时走上游 gateway token、独立 base URL/path/auth 配置,适合 `tavily-hikari` 这类上游。来源:install.sh:27, install.sh:38, mysearch/config.py:198, mysearch/config.py:220, mysearch/config.py:320, mysearch/config.py:382 +- `MYSEARCH_FIRECRAWL_*` / `MYSEARCH_EXA_*` / `MYSEARCH_XAI_*` + - Firecrawl / Exa / xAI 的直连或兼容网关配置;xAI 继续用 `official` / `compatible` 区分官方与 social gateway。来源:install.sh:47, install.sh:58, install.sh:68, mysearch/config.py:423, mysearch/config.py:462, mysearch/config.py:501 +- `ADMIN_PASSWORD`、`STATS_CACHE_TTL_SECONDS`、`DASHBOARD_*`、`SOCIAL_GATEWAY_*` + - 控制 `proxy/` 的登录、统计缓存、后台同步和 social gateway 行为。来源:proxy/README.md:130, proxy/server.py:22, proxy/server.py:43, proxy/server.py:94 +- `proxy/settings.tavily_*` + - `proxy/` 控制台侧的 Tavily 设置现在支持 `auto|pool|upstream` 三态。`auto` 不是 Social/X 的后台继承,而是按“上游凭证优先,其次本地活跃 Tavily key”解析 `effective_mode`;如果只是导入 Tavily key,默认实际就会落到 API Key 池。控制台会把 `mode`、`effective_mode`、`mode_source` 一起回填给前端。来源:proxy/server.py:273, proxy/server.py:297, proxy/server.py:311, proxy/server.py:1070 + +## 运行时行为速查 + +- `search` + - 入口在 `mysearch/server.py:47`,执行主体在 `mysearch/clients.py:385`;会先解析 intent/strategy,再按 `_route_search` 做 provider 选择。来源:mysearch/clients.py:964 +- `extract_url` + - 入口在 `mysearch/server.py:97`;默认 Firecrawl 优先,质量不够或失败时回退 Tavily。来源:mysearch/clients.py:677 +- `research` + - 入口在 `mysearch/server.py:112`;内部会并行做 web discovery、可选 social,再对前几条页面跑正文抓取。来源:mysearch/clients.py:802 +- `mysearch_health` + - 入口在 `mysearch/server.py:157`;会返回每个 provider 的 `base_url`、`paths`、`auth_mode`、`available_keys`,现在也会暴露 `provider_mode`,便于区分 Tavily 当前是 `official` 还是 `gateway`。来源:mysearch/server.py:157, mysearch/clients.py:2717 +- Proxy API + - Tavily 走 `/api/search` 和 `/api/extract`;Firecrawl 走 `/firecrawl/v2/search` 和 `/firecrawl/v2/scrape`;Exa 走 `/exa/search`;Social 走 `/social/health` 和 `/social/search`。来源:proxy/README.md:31, proxy/README.md:45, proxy/README.md:59, proxy/README.md:90, proxy/server.py:47 +- Settings Test API + - 控制台侧现在还额外暴露 `/api/settings/test/tavily` 与 `/api/settings/test/social`,给设置中心的“测试当前连接”动作使用;前者会按 Tavily 当前模式解析结果执行 live probe,后者会返回 Social / X 上游的 token 来源、后台连通性与可用凭证诊断。两条接口现在都会补 `request_target`、`auth_source`、`status_label`、`failure_reason`、`recommendation` 这类结构化字段,直接供前端渲染 probe 卡。来源:proxy/server.py:429, proxy/server.py:2259, proxy/server.py:2267 diff --git a/mysearch/clients.py b/mysearch/clients.py index ff6115b..3de40c0 100644 --- a/mysearch/clients.py +++ b/mysearch/clients.py @@ -6,10 +6,11 @@ import hashlib import json import re +import sys import threading import time from concurrent.futures import Future, ThreadPoolExecutor -from dataclasses import dataclass +from dataclasses import dataclass as _dataclass from datetime import date, datetime, time as dt_time, timezone from typing import Any, Callable, Literal from urllib.error import HTTPError, URLError @@ -20,6 +21,12 @@ from mysearch.keyring import MySearchKeyRing +def dataclass(*args, **kwargs): + if sys.version_info < (3, 10): + kwargs.pop("slots", None) + return _dataclass(*args, **kwargs) + + SearchMode = Literal["auto", "web", "news", "social", "docs", "research", "github", "pdf"] SearchIntent = Literal[ "auto", diff --git a/mysearch/config.py b/mysearch/config.py index e8169c1..4af72b7 100644 --- a/mysearch/config.py +++ b/mysearch/config.py @@ -3,7 +3,8 @@ from __future__ import annotations import os -from dataclasses import dataclass, field +import sys +from dataclasses import dataclass as _dataclass, field from pathlib import Path from typing import Literal @@ -13,6 +14,12 @@ tomllib = None # type: ignore[assignment] +def dataclass(*args, **kwargs): + if sys.version_info < (3, 10): + kwargs.pop("slots", None) + return _dataclass(*args, **kwargs) + + MODULE_DIR = Path(__file__).resolve().parent ROOT_DIR = MODULE_DIR.parent AuthMode = Literal["bearer", "body"] diff --git a/mysearch/keyring.py b/mysearch/keyring.py index 7331ad7..90864d6 100644 --- a/mysearch/keyring.py +++ b/mysearch/keyring.py @@ -2,12 +2,19 @@ from __future__ import annotations -from dataclasses import dataclass +import sys +from dataclasses import dataclass as _dataclass from threading import Lock from mysearch.config import MySearchConfig, ProviderConfig +def dataclass(*args, **kwargs): + if sys.version_info < (3, 10): + kwargs.pop("slots", None) + return _dataclass(*args, **kwargs) + + @dataclass(frozen=True, slots=True) class KeyRecord: provider: str diff --git a/mysearch/social_gateway.py b/mysearch/social_gateway.py index 4626d43..eef0e1d 100644 --- a/mysearch/social_gateway.py +++ b/mysearch/social_gateway.py @@ -66,7 +66,14 @@ def _derive_admin_base_url(upstream_base_url: str) -> str: http_client = httpx.AsyncClient(timeout=60) state_cache: dict[str, Any] = {"expires_at": 0.0, "value": None} -state_lock = asyncio.Lock() +state_lock: asyncio.Lock | None = None + + +def get_state_lock() -> asyncio.Lock: + global state_lock + if state_lock is None: + state_lock = asyncio.Lock() + return state_lock @asynccontextmanager @@ -152,12 +159,27 @@ def mask_secret(value: str) -> str: return f"{value[:6]}***{value[-4:]}" +def unwrap_social_tokens_payload(tokens_payload: Any) -> Any: + if isinstance(tokens_payload, dict): + for key_name in ("tokens", "data", "items", "result", "pools"): + candidate = tokens_payload.get(key_name) + if isinstance(candidate, dict): + return candidate + if isinstance(candidate, list): + return {"default": candidate} + return tokens_payload + if isinstance(tokens_payload, list): + return {"default": tokens_payload} + return {} + + def flatten_social_tokens(tokens_payload: Any) -> list[dict[str, Any]]: flat: list[dict[str, Any]] = [] - if not isinstance(tokens_payload, dict): + normalized = unwrap_social_tokens_payload(tokens_payload) + if not isinstance(normalized, dict): return flat - for pool_name, items in tokens_payload.items(): + for pool_name, items in normalized.items(): if not isinstance(items, list): continue for item in items: @@ -284,7 +306,7 @@ async def resolve_gateway_state(force: bool = False) -> dict[str, Any]: if not force and cached and state_cache.get("expires_at", 0) > now: return cached - async with state_lock: + async with get_state_lock(): now = time.time() cached = state_cache.get("value") if not force and cached and state_cache.get("expires_at", 0) > now: diff --git a/openclaw/runtime/mysearch/clients.py b/openclaw/runtime/mysearch/clients.py index ff6115b..3de40c0 100644 --- a/openclaw/runtime/mysearch/clients.py +++ b/openclaw/runtime/mysearch/clients.py @@ -6,10 +6,11 @@ import hashlib import json import re +import sys import threading import time from concurrent.futures import Future, ThreadPoolExecutor -from dataclasses import dataclass +from dataclasses import dataclass as _dataclass from datetime import date, datetime, time as dt_time, timezone from typing import Any, Callable, Literal from urllib.error import HTTPError, URLError @@ -20,6 +21,12 @@ from mysearch.keyring import MySearchKeyRing +def dataclass(*args, **kwargs): + if sys.version_info < (3, 10): + kwargs.pop("slots", None) + return _dataclass(*args, **kwargs) + + SearchMode = Literal["auto", "web", "news", "social", "docs", "research", "github", "pdf"] SearchIntent = Literal[ "auto", diff --git a/openclaw/runtime/mysearch/config.py b/openclaw/runtime/mysearch/config.py index e8169c1..4af72b7 100644 --- a/openclaw/runtime/mysearch/config.py +++ b/openclaw/runtime/mysearch/config.py @@ -3,7 +3,8 @@ from __future__ import annotations import os -from dataclasses import dataclass, field +import sys +from dataclasses import dataclass as _dataclass, field from pathlib import Path from typing import Literal @@ -13,6 +14,12 @@ tomllib = None # type: ignore[assignment] +def dataclass(*args, **kwargs): + if sys.version_info < (3, 10): + kwargs.pop("slots", None) + return _dataclass(*args, **kwargs) + + MODULE_DIR = Path(__file__).resolve().parent ROOT_DIR = MODULE_DIR.parent AuthMode = Literal["bearer", "body"] diff --git a/openclaw/runtime/mysearch/keyring.py b/openclaw/runtime/mysearch/keyring.py index 7331ad7..90864d6 100644 --- a/openclaw/runtime/mysearch/keyring.py +++ b/openclaw/runtime/mysearch/keyring.py @@ -2,12 +2,19 @@ from __future__ import annotations -from dataclasses import dataclass +import sys +from dataclasses import dataclass as _dataclass from threading import Lock from mysearch.config import MySearchConfig, ProviderConfig +def dataclass(*args, **kwargs): + if sys.version_info < (3, 10): + kwargs.pop("slots", None) + return _dataclass(*args, **kwargs) + + @dataclass(frozen=True, slots=True) class KeyRecord: provider: str diff --git a/proxy/server.py b/proxy/server.py index 001b37f..eebae68 100644 --- a/proxy/server.py +++ b/proxy/server.py @@ -8,8 +8,9 @@ import os import re import time +from contextlib import asynccontextmanager from datetime import datetime, timezone -from urllib.parse import urlparse +from urllib.parse import urlparse, urlunparse import httpx from fastapi import Depends, FastAPI, HTTPException, Request @@ -43,6 +44,111 @@ def _derive_social_gateway_admin_base_url(upstream_base_url): return upstream_base_url +def _is_hikari_access_token(value): + return str(value or "").strip().startswith("th-") + + +def _build_tavily_upstream_url(base_url, path, api_key=""): + normalized_base = str(base_url or TAVILY_API_BASE).strip().rstrip("/") or TAVILY_API_BASE + normalized_path = _normalize_path(path, TAVILY_SEARCH_PATH) + parsed = urlparse(normalized_base) + base_path = parsed.path.rstrip("/") + + if normalized_path == "/api/tavily" or normalized_path.startswith("/api/tavily/"): + return f"{normalized_base}{normalized_path}" + + if _is_hikari_access_token(api_key) and not base_path.endswith("/api/tavily"): + return f"{normalized_base}/api/tavily{normalized_path}" + + return f"{normalized_base}{normalized_path}" + + +def _build_tavily_hikari_public_url(base_url, target_path): + normalized_base = str(base_url or "").strip().rstrip("/") + if not normalized_base: + return "" + parsed = urlparse(normalized_base) + base_path = parsed.path.rstrip("/") + if base_path.endswith("/api/tavily"): + next_path = f"{base_path[:-len('/api/tavily')]}{target_path}" or target_path + elif not base_path: + next_path = target_path + else: + next_path = f"{base_path}{target_path}" + return urlunparse((parsed.scheme, parsed.netloc, next_path, "", "", "")) + + +def _looks_like_tavily_hikari_gateway(config): + base_url = str((config or {}).get("upstream_base_url") or "").strip() + api_key = str((config or {}).get("upstream_api_key") or "").strip() + base_path = urlparse(base_url).path.rstrip("/") + return _is_hikari_access_token(api_key) or base_path.endswith("/api/tavily") + + +async def fetch_tavily_upstream_summary(config): + request_target = _build_tavily_hikari_public_url(config.get("upstream_base_url"), "/api/summary") + payload = { + "available": False, + "detail": "", + "request_target": request_target, + "summary_source": "unavailable", + "total_keys": 0, + "active_keys": 0, + "exhausted_keys": 0, + "quarantined_keys": 0, + "total_requests": 0, + "success_count": 0, + "error_count": 0, + "quota_exhausted_count": 0, + "total_quota_limit": 0, + "total_quota_remaining": 0, + "last_activity": None, + } + if not _looks_like_tavily_hikari_gateway(config): + payload["detail"] = "当前上游未显式暴露 Tavily Hikari 公共摘要接口。" + return payload + if not request_target: + payload["detail"] = "当前上游地址为空,无法读取上游摘要。" + return payload + try: + response = await http_client.get(request_target) + data = response.json() if response.headers.get("content-type", "").startswith("application/json") else {} + if response.status_code >= 400: + detail = "" + if isinstance(data, dict): + detail = data.get("detail") or data.get("message") or "" + payload["detail"] = detail or response.text.strip()[:200] or f"HTTP {response.status_code}" + return payload + if not isinstance(data, dict): + payload["detail"] = "上游摘要返回了非 JSON 响应。" + return payload + active_keys = int(data.get("active_keys") or 0) + exhausted_keys = int(data.get("exhausted_keys") or 0) + quarantined_keys = int(data.get("quarantined_keys") or 0) + payload.update( + { + "available": True, + "detail": "已读取上游 Tavily Hikari 公共摘要。", + "summary_source": "hikari_public_summary", + "total_keys": active_keys + exhausted_keys + quarantined_keys, + "active_keys": active_keys, + "exhausted_keys": exhausted_keys, + "quarantined_keys": quarantined_keys, + "total_requests": int(data.get("total_requests") or 0), + "success_count": int(data.get("success_count") or 0), + "error_count": int(data.get("error_count") or 0), + "quota_exhausted_count": int(data.get("quota_exhausted_count") or 0), + "total_quota_limit": int(data.get("total_quota_limit") or 0), + "total_quota_remaining": int(data.get("total_quota_remaining") or 0), + "last_activity": data.get("last_activity"), + } + ) + return payload + except Exception as exc: + payload["detail"] = str(exc) + return payload + + SOCIAL_GATEWAY_UPSTREAM_BASE_URL = os.environ.get( "SOCIAL_GATEWAY_UPSTREAM_BASE_URL", "https://api.x.ai/v1", @@ -110,17 +216,47 @@ def _derive_social_gateway_admin_base_url(upstream_base_url): "mysearch": "MySearch", } -app = FastAPI(title="MySearch Proxy") +@asynccontextmanager +async def lifespan(_: FastAPI): + db.init_db() + try: + yield + finally: + await http_client.aclose() + + +app = FastAPI(title="MySearch Proxy", lifespan=lifespan) app.mount("/static", StaticFiles(directory=os.path.join(os.path.dirname(__file__), "static")), name="static") templates = Jinja2Templates(directory=os.path.join(os.path.dirname(__file__), "templates")) http_client = httpx.AsyncClient(timeout=60) social_gateway_state_cache = {"expires_at": 0.0, "value": None} -social_gateway_state_lock = asyncio.Lock() +social_gateway_state_lock = None stats_payload_cache = {"expires_at": 0.0, "value": None} -stats_payload_lock = asyncio.Lock() +stats_payload_lock = None background_sync_tasks = {} background_sync_last_started = {} -background_sync_lock = asyncio.Lock() +background_sync_lock = None + + +def get_social_gateway_state_lock(): + global social_gateway_state_lock + if social_gateway_state_lock is None: + social_gateway_state_lock = asyncio.Lock() + return social_gateway_state_lock + + +def get_stats_payload_lock(): + global stats_payload_lock + if stats_payload_lock is None: + stats_payload_lock = asyncio.Lock() + return stats_payload_lock + + +def get_background_sync_lock(): + global background_sync_lock + if background_sync_lock is None: + background_sync_lock = asyncio.Lock() + return background_sync_lock def get_admin_password(): @@ -271,9 +407,9 @@ def get_runtime_social_config(): def get_runtime_tavily_config(): - mode = get_setting_text("tavily_mode", "pool").lower() - if mode not in {"pool", "upstream"}: - mode = "pool" + mode = get_setting_text("tavily_mode", "auto").lower() + if mode not in {"auto", "pool", "upstream"}: + mode = "auto" upstream_base_url = ( get_setting_text("tavily_upstream_base_url", TAVILY_API_BASE).rstrip("/") @@ -294,15 +430,37 @@ def get_runtime_tavily_config(): } +def resolve_tavily_runtime_mode(config, active_keys): + configured_mode = (config.get("mode") or "auto").lower() + active_key_count = len(active_keys or []) + if configured_mode == "upstream": + return {"configured_mode": configured_mode, "effective_mode": "upstream", "mode_source": "manual_upstream"} + if configured_mode == "pool": + return {"configured_mode": configured_mode, "effective_mode": "pool", "mode_source": "manual_pool"} + if config.get("upstream_api_key"): + return {"configured_mode": configured_mode, "effective_mode": "upstream", "mode_source": "auto_upstream"} + if active_key_count > 0: + return {"configured_mode": configured_mode, "effective_mode": "pool", "mode_source": "auto_pool"} + return {"configured_mode": configured_mode, "effective_mode": "pool", "mode_source": "auto_pending"} + + def build_tavily_routing_meta(config, active_keys): - using_upstream = config["mode"] == "upstream" - summary = ( - "当前 Tavily 走上游 Gateway;切回本地 Key 池模式后才会重新使用这里导入的 Tavily keys。" - if using_upstream - else "当前 Tavily 走本地 Key 池,请求会从已导入的 Tavily keys 中轮询。" - ) + resolved = resolve_tavily_runtime_mode(config, active_keys) + using_upstream = resolved["effective_mode"] == "upstream" + if resolved["mode_source"] == "auto_upstream": + summary = "当前 Tavily 处于自动识别模式;检测到上游凭证后,已自动切到上游 Gateway。" + elif resolved["mode_source"] == "auto_pool": + summary = "当前 Tavily 处于自动识别模式;检测到本地可用 Key 后,已自动切到 API Key 池。" + elif resolved["mode_source"] == "auto_pending": + summary = "当前 Tavily 处于自动识别模式;暂时没有检测到上游凭证或本地可用 Key。" + elif using_upstream: + summary = "当前 Tavily 手动固定走上游 Gateway;切回 API Key 池模式后才会重新使用这里导入的 Tavily keys。" + else: + summary = "当前 Tavily 手动固定走 API Key 池,请求会从已导入的 Tavily keys 中轮询。" return { "mode": config["mode"], + "effective_mode": resolved["effective_mode"], + "mode_source": resolved["mode_source"], "upstream_base_url": config["upstream_base_url"], "upstream_search_path": config["upstream_search_path"], "upstream_extract_path": config["upstream_extract_path"], @@ -312,6 +470,194 @@ def build_tavily_routing_meta(config, active_keys): } +def build_candidate_tavily_config(body): + config = get_runtime_tavily_config() + if not isinstance(body, dict): + return config + + if "mode" in body: + mode = str(body.get("mode") or "").strip().lower() or "auto" + if mode not in {"auto", "pool", "upstream"}: + raise HTTPException(status_code=400, detail="mode must be 'auto', 'pool' or 'upstream'") + config["mode"] = mode + + if "upstream_base_url" in body: + config["upstream_base_url"] = str(body.get("upstream_base_url") or "").strip().rstrip("/") or TAVILY_API_BASE + if "upstream_search_path" in body: + config["upstream_search_path"] = _normalize_path(body.get("upstream_search_path"), TAVILY_SEARCH_PATH) + if "upstream_extract_path" in body: + config["upstream_extract_path"] = _normalize_path(body.get("upstream_extract_path"), TAVILY_EXTRACT_PATH) + if body.get("clear_upstream_api_key"): + config["upstream_api_key"] = "" + elif "upstream_api_key" in body: + config["upstream_api_key"] = str(body.get("upstream_api_key") or "").strip() + return config + + +def build_candidate_social_config(body): + config = dict(get_runtime_social_config()) + if not isinstance(body, dict): + return config + + text_fields = { + "upstream_base_url": "upstream_base_url", + "admin_base_url": "admin_base_url", + "model": "model", + "fallback_model": "fallback_model", + } + path_fields = { + "upstream_responses_path": ("upstream_responses_path", SOCIAL_GATEWAY_UPSTREAM_RESPONSES_PATH), + "admin_verify_path": ("admin_verify_path", SOCIAL_GATEWAY_ADMIN_VERIFY_PATH), + "admin_config_path": ("admin_config_path", SOCIAL_GATEWAY_ADMIN_CONFIG_PATH), + "admin_tokens_path": ("admin_tokens_path", SOCIAL_GATEWAY_ADMIN_TOKENS_PATH), + } + secret_fields = { + "admin_app_key": "admin_app_key", + "upstream_api_key": "upstream_api_key", + "gateway_token": "gateway_token", + } + + for body_key, config_key in text_fields.items(): + if body_key not in body: + continue + value = str(body.get(body_key) or "").strip() + if body_key.endswith("base_url"): + value = value.rstrip("/") + config[config_key] = value + + for body_key, (config_key, default_value) in path_fields.items(): + if body_key not in body: + continue + config[config_key] = _normalize_path(body.get(body_key), default_value) + + for body_key, config_key in secret_fields.items(): + if body.get(f"clear_{body_key}"): + config[config_key] = "" + elif body_key in body: + config[config_key] = str(body.get(body_key) or "").strip() + + if "cache_ttl_seconds" in body: + try: + config["cache_ttl_seconds"] = max(5, int(body.get("cache_ttl_seconds") or SOCIAL_GATEWAY_CACHE_TTL_SECONDS)) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail="cache_ttl_seconds must be an integer") + + if "fallback_min_results" in body: + try: + config["fallback_min_results"] = max( + 1, + int(body.get("fallback_min_results") or SOCIAL_GATEWAY_FALLBACK_MIN_RESULTS), + ) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail="fallback_min_results must be an integer") + + if not config["upstream_base_url"]: + config["upstream_base_url"] = SOCIAL_GATEWAY_UPSTREAM_BASE_URL + if not config["admin_base_url"]: + config["admin_base_url"] = _derive_social_gateway_admin_base_url(config["upstream_base_url"]) + if not config["model"]: + config["model"] = SOCIAL_GATEWAY_MODEL + if not config["fallback_model"]: + config["fallback_model"] = SOCIAL_GATEWAY_FALLBACK_MODEL + return config + + +async def probe_tavily_connection(config, active_keys): + resolved = resolve_tavily_runtime_mode(config, active_keys) + upstream_base_url = config["upstream_base_url"] if resolved["effective_mode"] == "upstream" else TAVILY_API_BASE + upstream_path = config["upstream_search_path"] if resolved["effective_mode"] == "upstream" else TAVILY_SEARCH_PATH + auth_source = "上游 Gateway token" if resolved["effective_mode"] == "upstream" else "本地 API Key 池" + + key_value = "" + if resolved["effective_mode"] == "upstream": + key_value = config["upstream_api_key"] + elif active_keys: + key_value = active_keys[0]["key"] + + test_url = _build_tavily_upstream_url(upstream_base_url, upstream_path, key_value) + + if not key_value: + return { + "ok": False, + "configured_mode": resolved["configured_mode"], + "effective_mode": resolved["effective_mode"], + "mode_source": resolved["mode_source"], + "local_key_count": len(active_keys), + "summary": build_tavily_routing_meta(config, active_keys)["summary"], + "detail": "当前没有可用的上游 key 或本地 API Key,无法执行 live probe。", + "failure_reason": "当前没有可用的上游 key 或本地 API Key。", + "probe_url": test_url, + "request_target": test_url, + "auth_source": auth_source if resolved["effective_mode"] == "upstream" else f"{auth_source}(活跃 {len(active_keys)})", + "status_label": "未执行 live probe", + "recommendation": "配置上游 token,或者导入至少一个活跃的 Tavily API Key 后再测试。", + "status_code": None, + } + + request_body = { + "query": "healthcheck", + "search_depth": "basic", + "max_results": 1, + "api_key": key_value, + } + try: + response = await http_client.post(test_url, json=request_body) + except Exception as exc: + return { + "ok": False, + "configured_mode": resolved["configured_mode"], + "effective_mode": resolved["effective_mode"], + "mode_source": resolved["mode_source"], + "local_key_count": len(active_keys), + "summary": build_tavily_routing_meta(config, active_keys)["summary"], + "detail": str(exc), + "failure_reason": str(exc), + "probe_url": test_url, + "request_target": test_url, + "auth_source": auth_source if resolved["effective_mode"] == "upstream" else f"{auth_source}(活跃 {len(active_keys)})", + "status_label": "请求未发出", + "recommendation": "检查上游 Base URL / Search Path 是否可达,或者确认本地网络能访问 Tavily 官方接口。", + "status_code": None, + } + + detail = "" + try: + payload = response.json() + if isinstance(payload, dict): + detail = ( + payload.get("detail") + or payload.get("message") + or f"返回 {len(payload.get('results') or [])} 条结果" + ) + except Exception: + detail = response.text.strip()[:200] + + return { + "ok": response.status_code < 400, + "configured_mode": resolved["configured_mode"], + "effective_mode": resolved["effective_mode"], + "mode_source": resolved["mode_source"], + "local_key_count": len(active_keys), + "summary": build_tavily_routing_meta(config, active_keys)["summary"], + "detail": detail or f"HTTP {response.status_code}", + "failure_reason": "" if response.status_code < 400 else (detail or f"HTTP {response.status_code}"), + "probe_url": test_url, + "request_target": test_url, + "auth_source": auth_source if resolved["effective_mode"] == "upstream" else f"{auth_source}(活跃 {len(active_keys)})", + "status_label": f"HTTP {response.status_code}", + "recommendation": ( + "当前链路可用;如果你想固定行为,可以继续保持当前模式。" + if response.status_code < 400 + else ( + "检查上游 token、Base URL 与 Search Path;如果上游是 Tavily Hikari,Base URL 建议填写到主机根或 /api/tavily,系统会自动兼容。" + if resolved["effective_mode"] == "upstream" + else "检查本地 Tavily API Key 是否仍然有效,必要时切到上游 Gateway。" + ) + ), + "status_code": response.status_code, + } + + # ═══ Auth helpers ═══ def verify_admin(request: Request): @@ -387,12 +733,27 @@ def mask_secret(value): return f"{value[:6]}***{value[-4:]}" +def unwrap_social_tokens_payload(tokens_payload): + if isinstance(tokens_payload, dict): + for key_name in ("tokens", "data", "items", "result", "pools"): + candidate = tokens_payload.get(key_name) + if isinstance(candidate, dict): + return candidate + if isinstance(candidate, list): + return {"default": candidate} + return tokens_payload + if isinstance(tokens_payload, list): + return {"default": tokens_payload} + return {} + + def flatten_social_tokens(tokens_payload): flat = [] - if not isinstance(tokens_payload, dict): + normalized = unwrap_social_tokens_payload(tokens_payload) + if not isinstance(normalized, dict): return flat - for pool_name, items in tokens_payload.items(): + for pool_name, items in normalized.items(): if not isinstance(items, list): continue for item in items: @@ -487,6 +848,33 @@ def build_social_token_source(state): return "not_configured" +def build_social_upstream_visibility(state): + upstream_api_key_count = len(state.get("upstream_api_keys") or []) + accepted_token_count = len(state.get("accepted_tokens") or []) + can_proxy_search = bool(state.get("resolved_upstream_api_key") and state.get("accepted_tokens")) + if state.get("admin_connected"): + level = "full" + detail = "已通过后台 admin 接口拉取配置与 token 池,可展示完整上游 token 统计。" + elif can_proxy_search: + level = "basic" + detail = "当前只拿到了基础接线信息,可确认上游 key 与客户端 token 数量,但还没有后台 token 详情。" + elif upstream_api_key_count or accepted_token_count: + level = "partial" + detail = "当前只解析到部分鉴权信息,尚不能稳定转发搜索。" + else: + level = "none" + detail = "当前还没有拿到可用于上游搜索的基础鉴权信息。" + return { + "level": level, + "detail": detail, + "can_proxy_search": can_proxy_search, + "upstream_api_key_count": upstream_api_key_count, + "accepted_token_count": accepted_token_count, + "admin_connected": bool(state.get("admin_connected")), + "token_source": state.get("token_source") or "not_configured", + } + + async def fetch_social_admin_json(config, path): if not config["admin_app_key"]: raise RuntimeError("Missing SOCIAL_GATEWAY_ADMIN_APP_KEY") @@ -508,72 +896,76 @@ async def fetch_social_admin_json(config, path): return payload if isinstance(payload, dict) else {} +async def resolve_social_gateway_state_for_config(config): + state = { + "upstream_base_url": config["upstream_base_url"], + "upstream_responses_path": config["upstream_responses_path"], + "admin_base_url": config["admin_base_url"], + "admin_verify_path": config["admin_verify_path"], + "admin_config_path": config["admin_config_path"], + "admin_tokens_path": config["admin_tokens_path"], + "admin_configured": bool(config["admin_base_url"] and config["admin_app_key"]), + "admin_connected": False, + "manual_upstream_key": bool(config["upstream_api_key"]), + "manual_gateway_token": bool(config["gateway_token"]), + "upstream_api_keys": parse_secret_values(config["upstream_api_key"]), + "accepted_tokens": parse_secret_values(config["gateway_token"]), + "admin_api_keys": [], + "resolved_upstream_api_key": "", + "default_client_token": "", + "token_source": "", + "mode": "manual", + "model": config["model"], + "fallback_model": config["fallback_model"], + "fallback_min_results": config["fallback_min_results"], + "cache_ttl_seconds": config["cache_ttl_seconds"], + "stats": build_empty_social_stats(), + "error": "", + } + + if state["admin_configured"]: + try: + admin_config, admin_tokens = await asyncio.gather( + fetch_social_admin_json(config, config["admin_config_path"]), + fetch_social_admin_json(config, config["admin_tokens_path"]), + ) + app_api_keys = parse_secret_values((admin_config.get("app") or {}).get("api_key")) + state["admin_connected"] = True + state["admin_api_keys"] = app_api_keys + if not state["upstream_api_keys"]: + state["upstream_api_keys"] = app_api_keys + if not state["accepted_tokens"]: + state["accepted_tokens"] = app_api_keys + state["stats"] = build_social_token_stats(admin_tokens) + except Exception as exc: + state["error"] = str(exc) + + if not state["accepted_tokens"] and state["upstream_api_keys"]: + state["accepted_tokens"] = list(state["upstream_api_keys"]) + + state["upstream_api_keys"] = unique_preserve_order(state["upstream_api_keys"]) + state["accepted_tokens"] = unique_preserve_order(state["accepted_tokens"]) + state["resolved_upstream_api_key"] = state["upstream_api_keys"][0] if state["upstream_api_keys"] else "" + state["default_client_token"] = state["accepted_tokens"][0] if state["accepted_tokens"] else "" + state["token_source"] = build_social_token_source(state) + state["mode"] = build_social_gateway_mode(state) + return state + + async def resolve_social_gateway_state(force=False): now = time.time() cached = social_gateway_state_cache.get("value") if not force and cached and social_gateway_state_cache.get("expires_at", 0) > now: return cached - async with social_gateway_state_lock: + async with get_social_gateway_state_lock(): now = time.time() cached = social_gateway_state_cache.get("value") if not force and cached and social_gateway_state_cache.get("expires_at", 0) > now: return cached config = get_runtime_social_config() - state = { - "upstream_base_url": config["upstream_base_url"], - "upstream_responses_path": config["upstream_responses_path"], - "admin_base_url": config["admin_base_url"], - "admin_verify_path": config["admin_verify_path"], - "admin_config_path": config["admin_config_path"], - "admin_tokens_path": config["admin_tokens_path"], - "admin_configured": bool(config["admin_base_url"] and config["admin_app_key"]), - "admin_connected": False, - "manual_upstream_key": bool(config["upstream_api_key"]), - "manual_gateway_token": bool(config["gateway_token"]), - "upstream_api_keys": parse_secret_values(config["upstream_api_key"]), - "accepted_tokens": parse_secret_values(config["gateway_token"]), - "admin_api_keys": [], - "resolved_upstream_api_key": "", - "default_client_token": "", - "token_source": "", - "mode": "manual", - "model": config["model"], - "fallback_model": config["fallback_model"], - "fallback_min_results": config["fallback_min_results"], - "cache_ttl_seconds": config["cache_ttl_seconds"], - "stats": build_empty_social_stats(), - "error": "", - } - - if state["admin_configured"]: - try: - admin_config, admin_tokens = await asyncio.gather( - fetch_social_admin_json(config, config["admin_config_path"]), - fetch_social_admin_json(config, config["admin_tokens_path"]), - ) - app_api_keys = parse_secret_values((admin_config.get("app") or {}).get("api_key")) - state["admin_connected"] = True - state["admin_api_keys"] = app_api_keys - if not state["upstream_api_keys"]: - state["upstream_api_keys"] = app_api_keys - if not state["accepted_tokens"]: - state["accepted_tokens"] = app_api_keys - state["stats"] = build_social_token_stats(admin_tokens) - except Exception as exc: - state["error"] = str(exc) - - if not state["accepted_tokens"] and state["upstream_api_keys"]: - state["accepted_tokens"] = list(state["upstream_api_keys"]) - - state["upstream_api_keys"] = unique_preserve_order(state["upstream_api_keys"]) - state["accepted_tokens"] = unique_preserve_order(state["accepted_tokens"]) - state["resolved_upstream_api_key"] = state["upstream_api_keys"][0] if state["upstream_api_keys"] else "" - state["default_client_token"] = state["accepted_tokens"][0] if state["accepted_tokens"] else "" - state["token_source"] = build_social_token_source(state) - state["mode"] = build_social_gateway_mode(state) - + state = await resolve_social_gateway_state_for_config(config) social_gateway_state_cache["value"] = state social_gateway_state_cache["expires_at"] = now + state["cache_ttl_seconds"] return state @@ -799,14 +1191,15 @@ async def sync_usage_cache(force=False, key_id=None, service=None): if rows and all((row.get("service") or "tavily") == "tavily" for row in rows): tavily_config = get_runtime_tavily_config() - if tavily_config["mode"] == "upstream": + tavily_active_rows = [row for row in rows if row.get("active")] + if resolve_tavily_runtime_mode(tavily_config, tavily_active_rows)["effective_mode"] == "upstream": return { "requested": len(rows), "synced": 0, "skipped": len(rows), "errors": 0, "supported": False, - "detail": "当前走 Tavily 上游 Gateway,本地 Key 池额度同步已停用", + "detail": "当前走 Tavily 上游 Gateway,本地 API Key 池额度同步已停用", } if not rows: @@ -834,7 +1227,7 @@ async def worker(row): def build_usage_sync_meta_for_dashboard(service, active_keys): - if service == "tavily" and get_runtime_tavily_config()["mode"] == "upstream": + if service == "tavily" and resolve_tavily_runtime_mode(get_runtime_tavily_config(), active_keys)["effective_mode"] == "upstream": return { "supported": False, "requested": len(active_keys), @@ -842,7 +1235,7 @@ def build_usage_sync_meta_for_dashboard(service, active_keys): "skipped": len(active_keys), "errors": 0, "stale_keys": 0, - "detail": "当前走 Tavily 上游 Gateway,本地 Key 池额度同步已停用", + "detail": "当前走 Tavily 上游 Gateway,本地 API Key 池额度同步已停用", } if service == "exa": @@ -875,7 +1268,7 @@ def build_usage_sync_meta_for_dashboard(service, active_keys): async def schedule_background_usage_sync(service, active_keys): if not DASHBOARD_BACKGROUND_SYNC_ON_STATS: return - if service == "tavily" and get_runtime_tavily_config()["mode"] == "upstream": + if service == "tavily" and resolve_tavily_runtime_mode(get_runtime_tavily_config(), active_keys)["effective_mode"] == "upstream": return if service == "exa": return @@ -885,7 +1278,7 @@ async def schedule_background_usage_sync(service, active_keys): return now = time.monotonic() - async with background_sync_lock: + async with get_background_sync_lock(): running = background_sync_tasks.get(service) if running and not running.done(): return @@ -975,6 +1368,9 @@ async def build_service_dashboard(service, auto_sync=False): routing = None if service == "tavily": routing = build_tavily_routing_meta(get_runtime_tavily_config(), active_keys) + upstream_summary = None + if service == "tavily" and routing and routing["effective_mode"] == "upstream": + upstream_summary = await fetch_tavily_upstream_summary(get_runtime_tavily_config()) if auto_sync: sync_result = await sync_usage_cache(force=False, service=service) else: @@ -993,6 +1389,8 @@ async def build_service_dashboard(service, auto_sync=False): } if routing is not None: payload["routing"] = routing + if upstream_summary is not None: + payload["upstream_summary"] = upstream_summary return payload @@ -1040,22 +1438,28 @@ async def build_social_dashboard(): "client_token": state["default_client_token"], "client_token_masked": mask_secret(state["default_client_token"]), "stats": state["stats"], + "upstream_visibility": build_social_upstream_visibility(state), "error": state["error"], } async def build_settings_payload(): tavily = get_runtime_tavily_config() + tavily_active_keys = [dict(row) for row in db.get_all_keys("tavily") if row["active"]] + tavily_resolved = resolve_tavily_runtime_mode(tavily, tavily_active_keys) config = get_runtime_social_config() state = await resolve_social_gateway_state(force=False) return { "tavily": { "mode": tavily["mode"], + "effective_mode": tavily_resolved["effective_mode"], + "mode_source": tavily_resolved["mode_source"], "upstream_base_url": tavily["upstream_base_url"], "upstream_search_path": tavily["upstream_search_path"], "upstream_extract_path": tavily["upstream_extract_path"], "upstream_api_key_configured": bool(tavily["upstream_api_key"]), "upstream_api_key_masked": mask_secret(tavily["upstream_api_key"]), + "local_key_count": len(tavily_active_keys), }, "social": { "upstream_base_url": config["upstream_base_url"], @@ -1721,20 +2125,6 @@ def normalize_social_search_response(query, payload, max_results, *, model=None) }, "raw_text": text, } - - -# ═══ 启动 ═══ - -@app.on_event("startup") -def startup(): - db.init_db() - - -@app.on_event("shutdown") -async def shutdown(): - await http_client.aclose() - - # ═══ Tavily 代理端点 ═══ @app.post("/api/search") @@ -1747,6 +2137,8 @@ async def proxy_tavily(request: Request): token_row = get_token_row_or_401(token_value, "tavily") config = get_runtime_tavily_config() + tavily_active_keys = [dict(row) for row in db.get_all_keys("tavily") if row["active"]] + tavily_resolved = resolve_tavily_runtime_mode(config, tavily_active_keys) path_map = { "search": config["upstream_search_path"], "extract": config["upstream_extract_path"], @@ -1758,7 +2150,7 @@ async def proxy_tavily(request: Request): upstream_base_url = TAVILY_API_BASE upstream_key = "" key_info = None - if config["mode"] == "upstream": + if tavily_resolved["effective_mode"] == "upstream": upstream_base_url = config["upstream_base_url"] upstream_key = config["upstream_api_key"] if not upstream_key: @@ -1769,10 +2161,11 @@ async def proxy_tavily(request: Request): raise HTTPException(status_code=503, detail="No available API keys") upstream_key = key_info["key"] + upstream_url = _build_tavily_upstream_url(upstream_base_url, upstream_path, upstream_key) body["api_key"] = upstream_key start = time.time() try: - resp = await http_client.post(f"{upstream_base_url}{upstream_path}", json=body) + resp = await http_client.post(upstream_url, json=body) latency = int((time.time() - start) * 1000) success = resp.status_code == 200 if key_info is not None: @@ -2012,6 +2405,18 @@ async def console(request: Request): ) +@app.get("/mysearch", response_class=HTMLResponse) +async def mysearch_console(request: Request): + return templates.TemplateResponse( + "mysearch.html", + { + "request": request, + "base_url": str(request.base_url).rstrip("/"), + "initial_authenticated": has_valid_admin_session(request), + }, + ) + + # ═══ 管理 API ═══ @app.get("/api/session") @@ -2047,7 +2452,7 @@ async def stats(request: Request, _=Depends(verify_admin)): if cached_value is not None and now < stats_payload_cache["expires_at"]: return cached_value - async with stats_payload_lock: + async with get_stats_payload_lock(): now = time.monotonic() cached_value = stats_payload_cache["value"] if cached_value is not None and now < stats_payload_cache["expires_at"]: @@ -2064,6 +2469,61 @@ async def get_settings(request: Request, _=Depends(verify_admin)): return await build_settings_payload() +@app.post("/api/settings/test/tavily") +async def test_tavily_settings(request: Request, _=Depends(verify_admin)): + body = await request.json() if request.headers.get("content-type", "").startswith("application/json") else {} + config = build_candidate_tavily_config(body) + active_keys = [dict(row) for row in db.get_all_keys("tavily") if row["active"]] + return await probe_tavily_connection(config, active_keys) + + +@app.post("/api/settings/test/social") +async def test_social_settings(request: Request, _=Depends(verify_admin)): + body = await request.json() if request.headers.get("content-type", "").startswith("application/json") else {} + config = build_candidate_social_config(body) + state = await resolve_social_gateway_state_for_config(config) + request_target = f"{state['upstream_base_url']}{state['upstream_responses_path']}" + detail = "" + if state["admin_connected"]: + detail = "后台已连通,并成功拉取配置与 token 池。" + elif state["resolved_upstream_api_key"]: + detail = "已检测到可用上游 key,但当前未通过后台接口补充更多 token 元数据。" + elif state["error"]: + detail = state["error"] + else: + detail = "当前没有解析到可用上游 key 或客户端 token。" + ok = bool(state["resolved_upstream_api_key"] and state["accepted_tokens"]) + if state["admin_connected"]: + auth_source = "grok2api 后台自动继承" + status_label = "后台已连通" + recommendation = "当前后台自动继承正常,可以直接下发 MySearch 通用 token。" + elif ok: + auth_source = state["token_source"] or "手动上游 key + 客户端 token" + status_label = "已解析到可用凭证" + recommendation = "当前已经能转发 Social / X 搜索;如果你需要更完整的 token 元数据,继续补 grok2api 后台即可。" + else: + auth_source = state["token_source"] or "未解析到可用鉴权" + status_label = "诊断失败" + recommendation = "优先检查 grok2api 后台地址与 app key;如果没有后台,再补手动上游 key 和客户端 token。" + return { + "ok": ok, + "mode": state["mode"], + "token_source": state["token_source"], + "admin_connected": state["admin_connected"], + "upstream_base_url": state["upstream_base_url"], + "upstream_responses_path": state["upstream_responses_path"], + "accepted_token_count": len(state["accepted_tokens"]), + "upstream_api_key_count": len(state["upstream_api_keys"]), + "detail": detail, + "request_target": request_target, + "auth_source": auth_source, + "status_label": status_label, + "failure_reason": "" if ok else (state["error"] or detail), + "recommendation": recommendation, + "error": state["error"], + } + + @app.put("/api/settings/tavily") async def update_tavily_settings(request: Request, _=Depends(verify_admin)): body = await request.json() @@ -2071,9 +2531,9 @@ async def update_tavily_settings(request: Request, _=Depends(verify_admin)): raise HTTPException(status_code=400, detail="Expected JSON request body") if "mode" in body: - mode = str(body.get("mode") or "").strip().lower() or "pool" - if mode not in {"pool", "upstream"}: - raise HTTPException(status_code=400, detail="mode must be 'pool' or 'upstream'") + mode = str(body.get("mode") or "").strip().lower() or "auto" + if mode not in {"auto", "pool", "upstream"}: + raise HTTPException(status_code=400, detail="mode must be 'auto', 'pool' or 'upstream'") db.set_setting("tavily_mode", mode) text_fields = { diff --git a/proxy/static/css/console.css b/proxy/static/css/console.css index feed1be..1372d09 100644 --- a/proxy/static/css/console.css +++ b/proxy/static/css/console.css @@ -1,91 +1,146 @@ - -@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Noto+Sans+SC:wght@400;500;600;700&family=Space+Grotesk:wght@500;700&display=swap'); :root { - --bg: #f8fafc; - --bg-soft: #f1f5f9; - --surface: #ffffff; - --surface-strong: #f8fafc; - --surface-muted: #f1f5f9; - --text: #0f172a; - --text-soft: #334155; - --muted: #64748b; - --border: #e2e8f0; - --border-strong: #cbd5e1; - --shadow: 0 4px 20px rgba(15, 23, 42, 0.05); - --shadow-lg: 0 12px 32px rgba(15, 23, 42, 0.08); - --radius: 16px; - --radius-sm: 8px; - - --tavily: #0d9488; - --tavily-soft: rgba(13, 148, 136, 0.1); - --exa: #2563eb; - --exa-soft: rgba(37, 99, 235, 0.1); - --firecrawl: #ea580c; - --firecrawl-soft: rgba(234, 88, 12, 0.1); - --social: #7c3aed; - --social-soft: rgba(124, 58, 237, 0.1); - - --ok: #16a34a; - --warn: #d97706; - --danger: #dc2626; - --primary: #0f172a; - --primary-hover: #1e293b; + --bg: #f3eee4; + --bg-soft: #ece6d8; + --surface: rgba(255, 251, 246, 0.92); + --surface-strong: rgba(255, 248, 239, 0.98); + --surface-muted: rgba(248, 242, 232, 0.86); + --surface-glass: rgba(255, 250, 244, 0.78); + --text: #17212d; + --text-soft: #425063; + --muted: #6f7a88; + --border: rgba(104, 120, 138, 0.18); + --border-strong: rgba(57, 72, 89, 0.28); + --shadow: 0 24px 48px rgba(28, 36, 44, 0.08); + --shadow-lg: 0 32px 80px rgba(21, 28, 34, 0.14); + --radius: 24px; + --radius-lg: 32px; + --radius-sm: 14px; + --primary: #16202b; + --primary-hover: #28384a; + --accent: #0f766e; + --accent-soft: rgba(15, 118, 110, 0.12); + --ok: #157347; + --warn: #b76a11; + --danger: #bb3b31; + --info: #1d4ed8; + + --tavily: #0f766e; + --tavily-soft: rgba(15, 118, 110, 0.12); + --exa: #1d4ed8; + --exa-soft: rgba(29, 78, 216, 0.12); + --firecrawl: #c2410c; + --firecrawl-soft: rgba(194, 65, 12, 0.12); + --social: #c05d22; + --social-soft: rgba(192, 93, 34, 0.12); + --mysearch: #111827; + --mysearch-soft: rgba(17, 24, 39, 0.12); } body.theme-dark { - --bg: #09090b; - --bg-soft: #121214; - --surface: #18181b; - --surface-strong: #27272a; - --surface-muted: rgba(39, 39, 42, 0.5); - --text: #fafafa; - --text-soft: #a1a1aa; - --muted: #71717a; - --border: #27272a; - --border-strong: #3f3f46; - --shadow: 0 8px 32px rgba(0, 0, 0, 0.4); - --shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.6); - + --bg: #0b1016; + --bg-soft: #141b24; + --surface: rgba(16, 22, 31, 0.9); + --surface-strong: rgba(18, 25, 34, 0.98); + --surface-muted: rgba(24, 31, 42, 0.92); + --surface-glass: rgba(18, 25, 34, 0.78); + --text: #f8f6f1; + --text-soft: #d6d3cd; + --muted: #98a2af; + --border: rgba(130, 148, 169, 0.18); + --border-strong: rgba(183, 196, 211, 0.28); + --shadow: 0 24px 60px rgba(0, 0, 0, 0.3); + --shadow-lg: 0 36px 90px rgba(0, 0, 0, 0.45); + --primary: #f6f1e9; + --primary-hover: #ffffff; + --accent: #2dd4bf; + --accent-soft: rgba(45, 212, 191, 0.14); + --ok: #4ade80; + --warn: #fbbf24; + --danger: #f87171; + --info: #60a5fa; --tavily: #2dd4bf; --exa: #60a5fa; --firecrawl: #fb923c; - --social: #a78bfa; - - --ok: #34d399; - --warn: #fbbf24; - --danger: #f87171; - --primary: #fafafa; - --primary-hover: #e4e4e7; + --social: #f59e0b; + --mysearch: #f6f1e9; + --tavily-soft: rgba(45, 212, 191, 0.12); + --exa-soft: rgba(96, 165, 250, 0.12); + --firecrawl-soft: rgba(251, 146, 60, 0.12); + --social-soft: rgba(245, 158, 11, 0.12); + --mysearch-soft: rgba(246, 241, 233, 0.12); +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; } -* { box-sizing: border-box; margin: 0; padding: 0; } -html { scroll-behavior: smooth; } +html { + scroll-behavior: smooth; +} body { min-height: 100vh; - font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + font-family: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif; color: var(--text); - background-color: var(--bg); - background-image: - radial-gradient(circle at 15% 50%, rgba(37, 99, 235, 0.03), transparent 25%), - radial-gradient(circle at 85% 30%, rgba(124, 58, 237, 0.03), transparent 25%); - transition: background-color 0.3s ease, color 0.3s ease; + background: + radial-gradient(circle at 10% 20%, rgba(29, 78, 216, 0.08), transparent 28%), + radial-gradient(circle at 88% 18%, rgba(15, 118, 110, 0.12), transparent 24%), + radial-gradient(circle at 50% 110%, rgba(194, 65, 12, 0.12), transparent 30%), + linear-gradient(180deg, #faf6ee 0%, var(--bg) 38%, #ebe3d5 100%); + background-attachment: fixed; line-height: 1.5; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; + transition: background-color 0.35s ease, color 0.35s ease; } body.theme-dark { - background-image: - radial-gradient(circle at 15% 50%, rgba(37, 99, 235, 0.06), transparent 25%), - radial-gradient(circle at 85% 30%, rgba(124, 58, 237, 0.06), transparent 25%); + background: + radial-gradient(circle at 10% 20%, rgba(96, 165, 250, 0.08), transparent 26%), + radial-gradient(circle at 88% 18%, rgba(45, 212, 191, 0.1), transparent 24%), + radial-gradient(circle at 50% 110%, rgba(251, 146, 60, 0.09), transparent 26%), + linear-gradient(180deg, #0a0f15 0%, var(--bg) 45%, #121923 100%); +} + +body.modal-open { + overflow: hidden; +} + +.hidden { + display: none !important; +} + +.mono { + font-family: 'IBM Plex Mono', monospace; } -.mono { font-family: 'JetBrains Mono', monospace; } -.hidden { display: none !important; } +#dashboard.is-entering .hero, +#dashboard.is-entering .summary-strip, +#dashboard.is-entering .dashboard-flow { + opacity: 0; + transform: translateY(14px); + animation: dashboard-stage-in 0.52s cubic-bezier(0.22, 1, 0.36, 1) forwards; +} + +#dashboard.is-entering .summary-strip { + animation-delay: 0.08s; +} + +#dashboard.is-entering .dashboard-flow { + animation-delay: 0.16s; +} + +@keyframes dashboard-stage-in { + to { + opacity: 1; + transform: translateY(0); + } +} -/* SVG Icons Utility */ .icon { width: 20px; height: 20px; @@ -96,668 +151,3132 @@ body.theme-dark { fill: none; } +.w-3 { width: 14px; } +.h-3 { height: 14px; } +.w-4 { width: 16px; } +.h-4 { height: 16px; } +.mr-1 { margin-right: 6px; } +.inline-block { display: inline-block; vertical-align: text-bottom; } + .container { - max-width: 1200px; + width: min(1440px, calc(100vw - 32px)); margin: 0 auto; - padding: 40px 24px; + padding: 28px 0 64px; } .card { + position: relative; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); - transition: box-shadow 0.2s ease, border-color 0.2s ease; + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); } -/* Auth / Login */ .auth { - max-width: 420px; - margin: 12vh auto; - padding: 48px 40px; + position: relative; + overflow: hidden; + max-width: 460px; + margin: 10vh auto; + padding: 42px 38px; text-align: center; - border-radius: 24px; + border-radius: 30px; + background: + linear-gradient(160deg, rgba(255, 251, 246, 0.98), rgba(247, 239, 226, 0.92)); } -.auth-kicker { - font-size: 12px; + +body.theme-dark .auth { + background: + linear-gradient(160deg, rgba(16, 22, 31, 0.96), rgba(18, 26, 36, 0.9)); +} + +.auth-orb { + position: absolute; + border-radius: 999px; + filter: blur(10px); + opacity: 0.72; + pointer-events: none; +} + +.auth-orb-a { + top: -42px; + right: -30px; + width: 128px; + height: 128px; + background: radial-gradient(circle, rgba(29, 78, 216, 0.18), rgba(29, 78, 216, 0)); +} + +.auth-orb-b { + left: -30px; + bottom: -40px; + width: 148px; + height: 148px; + background: radial-gradient(circle, rgba(15, 118, 110, 0.15), rgba(15, 118, 110, 0)); +} + +.auth-kicker, +.switcher-eyebrow, +.settings-kicker, +.credit-kicker, +.hero-brand-kicker, +.hero-focus-kicker, +.hero-lane-kicker, +.service-tool-kicker { + font-size: 11px; font-weight: 700; - letter-spacing: 0.1em; + letter-spacing: 0.14em; + text-transform: uppercase; color: var(--muted); +} + +.auth h2, +.switcher-head h2, +.service-head h2, +.settings-head h2 { + font-family: 'Space Grotesk', 'Noto Sans SC', sans-serif; + letter-spacing: -0.04em; +} + +.auth h2 { + font-size: 32px; + margin: 14px 0 10px; +} + +.auth-badges { + position: relative; + z-index: 1; + display: flex; + justify-content: center; + gap: 8px; + flex-wrap: wrap; + margin-top: 14px; +} + +.auth-badge { + display: inline-flex; + align-items: center; + padding: 6px 10px; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--surface); + font-size: 11px; + font-weight: 700; + color: var(--text-soft); +} + +.auth p { + color: var(--text-soft); + font-size: 14px; + margin-bottom: 28px; + line-height: 1.7; +} + +.auth-meta { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + margin-top: 18px; +} + +.auth-meta-card { + padding: 14px; + border-radius: 18px; + border: 1px solid var(--border); + background: var(--surface-glass); + text-align: left; +} + +.auth-meta-card .label { + display: block; + margin-bottom: 6px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; text-transform: uppercase; - margin-bottom: 16px; + color: var(--muted); +} + +.auth-meta-card strong { + display: block; + font-size: 13px; + line-height: 1.55; + color: var(--text); + overflow-wrap: anywhere; +} + +.login-error { + margin-top: 12px; + color: var(--danger); + font-size: 13px; +} + +.auth .stack { + display: flex; + flex-direction: column; + gap: 14px; } -.auth h2 { font-size: 28px; margin-bottom: 12px; font-weight: 700; letter-spacing: -0.02em; } -.auth p { color: var(--text-soft); font-size: 14px; margin-bottom: 32px; line-height: 1.6; } -.auth .stack { display: flex; flex-direction: column; gap: 16px; } -input[type="text"], input[type="password"], select, textarea { +input[type="text"], +input[type="password"], +select, +textarea { width: 100%; - padding: 12px 16px; + padding: 13px 16px; border-radius: var(--radius-sm); - border: 1px solid var(--border-strong); - background: var(--surface); + border: 1px solid var(--border); + background: var(--surface-strong); color: var(--text); font-family: inherit; font-size: 14px; - transition: all 0.2s ease; + transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease; +} + +.mode-switch { + display: inline-flex; + flex-wrap: wrap; + gap: 8px; +} + +.mode-runtime-strip { + margin-top: 10px; + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 36px; + padding: 0 14px; + border-radius: 999px; + border: 1px solid rgba(29, 78, 216, 0.14); + background: linear-gradient(135deg, rgba(29, 78, 216, 0.08), rgba(15, 118, 110, 0.08)); + color: var(--text); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.01em; +} + +.mode-switch-btn { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 38px; + padding: 0 14px; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--surface); + color: var(--text-soft); + font-size: 13px; + font-weight: 700; + cursor: pointer; + transition: border-color 0.18s ease, background-color 0.18s ease, color 0.18s ease, transform 0.18s ease; +} + +.mode-switch-btn:hover { + transform: translateY(-1px); + border-color: var(--border-strong); + background: var(--surface-muted); +} + +.mode-switch-btn.is-active { + background: linear-gradient(135deg, rgba(29, 78, 216, 0.12), rgba(15, 118, 110, 0.12)); + border-color: rgba(29, 78, 216, 0.2); + color: var(--text); +} + +.settings-field.is-muted { + opacity: 0.72; +} + +.settings-field.is-muted label, +.settings-field.is-muted .hint { + color: var(--text-soft); } -input:focus, select:focus, textarea:focus { + +.settings-field.is-emphasis { + opacity: 1; +} + +textarea { + min-height: 136px; + resize: vertical; +} + +input::placeholder, +textarea::placeholder { + color: var(--muted); +} + +input:focus, +select:focus, +textarea:focus { outline: none; - border-color: var(--exa); - box-shadow: 0 0 0 3px var(--exa-soft); + border-color: rgba(29, 78, 216, 0.45); + box-shadow: 0 0 0 4px rgba(29, 78, 216, 0.12); } -input::placeholder { color: var(--muted); } .btn { + position: relative; display: inline-flex; align-items: center; justify-content: center; gap: 8px; - padding: 10px 20px; - border-radius: var(--radius-sm); - font-weight: 600; - font-size: 14px; - cursor: pointer; - transition: all 0.2s ease; + padding: 11px 18px; + border-radius: 999px; border: 1px solid transparent; + cursor: pointer; + font-size: 14px; + font-weight: 600; + transition: transform 0.18s ease, border-color 0.18s ease, background-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease; +} + +.btn:hover { + transform: translateY(-1px); +} + +.btn:disabled { + cursor: default; + transform: none; +} + +.btn.is-busy { + pointer-events: none; +} + +.btn.is-busy::after { + content: ''; + width: 12px; + height: 12px; + border-radius: 999px; + border: 2px solid currentColor; + border-right-color: transparent; + animation: btn-spin 0.7s linear infinite; +} + +.btn.is-success { + border-color: rgba(15, 118, 110, 0.22); + background: linear-gradient(135deg, rgba(15, 118, 110, 0.16), rgba(15, 118, 110, 0.08)); + color: var(--text); } + +.btn.is-error { + border-color: rgba(187, 59, 49, 0.22); + background: linear-gradient(135deg, rgba(187, 59, 49, 0.12), rgba(187, 59, 49, 0.06)); + color: var(--danger); +} + +@keyframes btn-spin { + to { + transform: rotate(360deg); + } +} + .btn-primary { background: var(--primary); color: var(--bg); + box-shadow: 0 14px 24px rgba(17, 24, 39, 0.12); } + .btn-primary:hover { background: var(--primary-hover); - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(0,0,0,0.1); } + .btn-soft { - background: var(--surface-muted); + background: var(--surface-strong); color: var(--text); border-color: var(--border); } -.btn-soft:hover { - background: var(--surface-strong); + +.btn-soft:hover, +.btn-ghost:hover { border-color: var(--border-strong); + background: var(--surface-muted); } -.btn-danger { - background: rgba(239, 68, 68, 0.1); - color: var(--danger); - border-color: rgba(239, 68, 68, 0.2); -} -.btn-danger:hover { - background: rgba(239, 68, 68, 0.15); -} + .btn-ghost { background: transparent; color: var(--text-soft); + border-color: transparent; } -.btn-ghost:hover { - background: var(--surface-muted); - color: var(--text); + +.btn-danger { + background: rgba(187, 59, 49, 0.1); + border-color: rgba(187, 59, 49, 0.2); + color: var(--danger); } + .btn-sm { - padding: 6px 12px; + padding: 7px 12px; font-size: 12px; } + .user-btn { - background: var(--surface); + background: var(--surface-glass); border: 1px solid var(--border); color: var(--text); - padding: 8px 16px; + padding: 10px 16px; border-radius: 999px; font-size: 13px; - font-weight: 500; + font-weight: 600; display: inline-flex; align-items: center; gap: 8px; cursor: pointer; - transition: all 0.2s ease; + transition: transform 0.18s ease, border-color 0.18s ease, background-color 0.18s ease; } + .user-btn:hover { - background: var(--surface-muted); + transform: translateY(-1px); border-color: var(--border-strong); + background: var(--surface-strong); } -/* Hero Section */ .hero { - padding: 48px; - margin-bottom: 32px; - position: relative; overflow: hidden; - border-radius: 24px; + border-radius: var(--radius-lg); + padding: 34px; + margin-bottom: 24px; + background: + linear-gradient(145deg, rgba(255, 252, 247, 0.92), rgba(245, 238, 227, 0.88)); } -.hero::before { - content: ''; + +body.theme-dark .hero { + background: + linear-gradient(145deg, rgba(14, 20, 28, 0.94), rgba(16, 23, 32, 0.9)); +} + +.hero-orb, +.hero-grid { position: absolute; - top: 0; left: 0; right: 0; height: 4px; - background: linear-gradient(90deg, var(--tavily), var(--exa), var(--social), var(--firecrawl)); + inset: auto; + pointer-events: none; +} + +.hero-orb { + width: 340px; + height: 340px; + border-radius: 999px; + filter: blur(18px); + opacity: 0.55; +} + +.hero-orb-a { + top: -90px; + right: -70px; + background: radial-gradient(circle, rgba(29, 78, 216, 0.2), transparent 68%); +} + +.hero-orb-b { + bottom: -120px; + left: -80px; + background: radial-gradient(circle, rgba(15, 118, 110, 0.18), transparent 68%); } + +.hero-grid { + inset: 0; + background-image: + linear-gradient(rgba(97, 111, 129, 0.08) 1px, transparent 1px), + linear-gradient(90deg, rgba(97, 111, 129, 0.08) 1px, transparent 1px); + background-size: 22px 22px; + mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.32), transparent 82%); +} + +.hero-stack, +.service-head, +.service-body { + position: relative; + z-index: 1; +} + .hero-topbar { display: flex; justify-content: space-between; align-items: center; - margin-bottom: 40px; + gap: 16px; + margin-bottom: 28px; +} + +.hero-brand { + display: inline-flex; + align-items: center; + gap: 14px; } -.hero-tags { display: flex; gap: 8px; } -.hero-tag { - padding: 6px 14px; - border-radius: 999px; + +.hero-brand-line { + width: 44px; + height: 1px; + background: linear-gradient(90deg, var(--accent), transparent); +} + +.hero-brand-copy { + font-family: 'IBM Plex Mono', monospace; font-size: 12px; - font-weight: 600; - background: var(--surface-strong); - border: 1px solid var(--border); color: var(--text-soft); } -.hero-actions { display: flex; gap: 12px; } + +.hero-actions { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} .hero-main { - display: grid; - grid-template-columns: 1fr 360px; - gap: 48px; + display: block; } -.hero-copy h1 { - font-size: 42px; - font-weight: 800; - letter-spacing: -0.03em; - margin-bottom: 20px; - line-height: 1.15; + +.hero-copy { + width: 100%; + max-width: none; } -.hero-copy p { - font-size: 16px; + +.hero-pill-row, +.hero-tags { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.hero-tag { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 7px 14px; + border-radius: 999px; + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.46); color: var(--text-soft); - margin-bottom: 32px; - line-height: 1.6; + font-size: 12px; + font-weight: 600; +} + +.hero-tag .icon { + flex: 0 0 auto; +} + +body.theme-dark .hero-tag { + background: rgba(255, 255, 255, 0.03); +} + +.access-shell { + padding: 26px 28px; +} + +.access-shell-head { + display: flex; + justify-content: space-between; + gap: 18px; + align-items: flex-start; +} + +.access-shell-copy { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; +} + +.access-shell-kicker { + display: inline-flex; + align-items: center; + width: fit-content; + padding: 7px 12px; + border-radius: 999px; + border: 1px solid rgba(29, 78, 216, 0.12); + background: rgba(29, 78, 216, 0.08); + color: var(--primary); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.access-shell h1 { + margin: 0; + font-family: 'Space Grotesk', 'Noto Sans SC', sans-serif; + font-size: clamp(1.9rem, 3vw, 2.6rem); + line-height: 1.08; + letter-spacing: -0.045em; +} + +.access-shell p { + margin: 0; + max-width: 74ch; + font-size: 14px; + line-height: 1.75; + color: var(--text-soft); +} + +.access-shell-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + justify-content: flex-end; +} + +.hero-heading-shell { + display: flex; + flex-direction: column; + gap: 10px; + margin: 18px 0 16px; + width: 100%; + max-width: none; +} + +.hero-heading-kicker { + display: inline-flex; + align-items: center; + width: fit-content; + padding: 7px 12px; + border-radius: 999px; + border: 1px solid rgba(29, 78, 216, 0.12); + background: rgba(29, 78, 216, 0.08); + color: var(--primary); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.hero-copy h1 { + margin: 0; + font-family: 'Space Grotesk', 'Noto Sans SC', sans-serif; + font-size: clamp(2.1rem, 4vw, 3.4rem); + line-height: 1.06; + letter-spacing: -0.055em; + max-width: none; +} + +.hero-heading-line { + display: block; +} + +.hero-heading-accent { + display: block; + color: var(--accent); +} + +body.theme-dark .hero-heading-accent { + color: #66d9c2; +} + +.hero-intro, +.hero-copy p { + font-size: 15px; + line-height: 1.75; + color: var(--text-soft); + width: 100%; + max-width: none; +} + +.hero-command-row { + display: flex; + gap: 12px; + flex-wrap: wrap; + margin: 22px 0 24px; + width: 100%; + max-width: none; } + .hero-usage-grid { display: grid; - grid-template-columns: 1fr 1fr; - gap: 16px; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 14px; } -.hero-usage-card { - padding: 20px; - border-radius: 16px; - background: var(--surface-strong); + +.hero-usage-card, +.stat-box, +.integration-summary-item, +.summary-box, +.credit-pill, +.service-toggle-metric, +.social-metric, +.hero-focus-metric { + background: var(--surface-glass); border: 1px solid var(--border); - transition: border-color 0.2s ease; + border-radius: 18px; } -.hero-usage-card:hover { - border-color: var(--border-strong); + +.hero-usage-card { + padding: 18px; } + .hero-usage-card strong { display: block; font-size: 15px; - font-weight: 600; + font-weight: 700; margin-bottom: 8px; - color: var(--text); } + .hero-usage-card span { font-size: 13px; color: var(--muted); - line-height: 1.5; + line-height: 1.65; } .hero-focus { - background: var(--surface-strong); - border: 1px solid var(--border); - border-radius: 20px; - padding: 32px 24px; display: flex; flex-direction: column; gap: 16px; + padding: 26px; + min-height: 100%; + border-radius: 28px; + background: + linear-gradient(180deg, rgba(16, 23, 32, 0.04), rgba(16, 23, 32, 0.01)), + var(--surface-glass); + border: 1px solid var(--border); +} + +body.theme-dark .hero-focus { + background: + linear-gradient(180deg, rgba(248, 246, 241, 0.05), rgba(248, 246, 241, 0.02)), + var(--surface-glass); +} + +.hero-focus-head, +.service-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 18px; +} + +.hero-focus-name { + font-family: 'Space Grotesk', 'Noto Sans SC', sans-serif; + font-size: 32px; + letter-spacing: -0.05em; + margin-top: 6px; +} + +.hero-focus-signal { + width: 14px; + height: 14px; + border-radius: 999px; + background: var(--muted); + box-shadow: 0 0 0 0 rgba(111, 122, 136, 0.35); +} + +.hero-focus-signal.is-ok, +.service-toggle-status.is-ok .service-toggle-signal { + background: var(--ok); + box-shadow: 0 0 0 0 rgba(21, 115, 71, 0.35); + animation: beacon-pulse 1.8s infinite; +} + +.hero-focus-signal.is-warn, +.service-toggle-status.is-warn .service-toggle-signal { + background: var(--warn); + box-shadow: 0 0 0 0 rgba(183, 106, 17, 0.35); + animation: beacon-pulse 1.8s infinite; +} + +.hero-focus-signal.is-danger, +.service-toggle-status.is-danger .service-toggle-signal { + background: var(--danger); + box-shadow: 0 0 0 0 rgba(187, 59, 49, 0.35); + animation: beacon-pulse 1.8s infinite; +} + +.hero-focus-signal.is-idle { + background: var(--muted); +} + +@keyframes beacon-pulse { + 0% { box-shadow: 0 0 0 0 rgba(0, 0, 0, 0.16); } + 70% { box-shadow: 0 0 0 10px rgba(0, 0, 0, 0); } + 100% { box-shadow: 0 0 0 0 rgba(0, 0, 0, 0); } +} + +.hero-focus-status-row { + display: flex; + justify-content: space-between; align-items: center; - text-align: center; - justify-content: center; + gap: 10px; } -.hero-focus-kicker { font-size: 12px; font-weight: 700; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; } -.hero-focus-name { font-size: 28px; font-weight: 700; letter-spacing: -0.02em; } + .hero-focus-status { - display: inline-block; - padding: 6px 16px; + display: inline-flex; + align-items: center; + padding: 7px 14px; border-radius: 999px; - font-size: 13px; - font-weight: 600; - background: var(--surface); border: 1px solid var(--border); + background: var(--surface-strong); + font-size: 12px; + font-weight: 700; } -.hero-focus-status.is-ok { color: var(--ok); border-color: rgba(22, 163, 74, 0.3); background: rgba(22, 163, 74, 0.1); } -.hero-focus-status.is-warn { color: var(--warn); border-color: rgba(217, 119, 6, 0.3); background: rgba(217, 119, 6, 0.1); } -.hero-focus-status.is-danger { color: var(--danger); border-color: rgba(220, 38, 38, 0.3); background: rgba(220, 38, 38, 0.1); } -.hero-focus-desc { font-size: 14px; color: var(--text-soft); line-height: 1.5; margin-top: 8px; } -.hero-focus-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; justify-content: center; } -/* Summary Strip */ -.summary-strip { +.hero-focus-status.is-ok { + color: var(--ok); + border-color: rgba(21, 115, 71, 0.22); + background: rgba(21, 115, 71, 0.08); +} + +.hero-focus-status.is-warn { + color: var(--warn); + border-color: rgba(183, 106, 17, 0.22); + background: rgba(183, 106, 17, 0.08); +} + +.hero-focus-status.is-danger { + color: var(--danger); + border-color: rgba(187, 59, 49, 0.22); + background: rgba(187, 59, 49, 0.08); +} + +.hero-focus-stamp { + font-size: 12px; + color: var(--muted); +} + +.hero-focus-desc { + font-size: 14px; + color: var(--text-soft); + line-height: 1.7; +} + +.hero-focus-metrics { display: grid; - grid-template-columns: repeat(6, 1fr); - gap: 16px; - margin-bottom: 32px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; } -.summary-box { - background: var(--surface); - border: 1px solid var(--border); - border-radius: 16px; - padding: 20px; - display: flex; - flex-direction: column; - transition: transform 0.2s ease, box-shadow 0.2s ease; + +.hero-focus-metric { + padding: 16px; } -.summary-box:hover { - transform: translateY(-2px); - box-shadow: var(--shadow); + +.hero-focus-metric .label, +.summary-box .label, +.service-toggle-metric .label, +.stat-box .label, +.integration-summary-item .label, +.social-metric .label, +.credit-pill .label, +.brief-item span { + display: block; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); + margin-bottom: 8px; } -.summary-box .label { font-size: 12px; font-weight: 600; color: var(--muted); margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.05em; } -.summary-box .value { font-size: 28px; font-weight: 700; line-height: 1.2; font-family: 'JetBrains Mono', monospace; letter-spacing: -0.05em; color: var(--text); } -.summary-box .hint { font-size: 12px; color: var(--muted); margin-top: auto; padding-top: 12px; line-height: 1.4; } -/* Switcher Shell */ -.switcher-shell { - padding: 40px; - margin-bottom: 32px; +.hero-focus-metric strong, +.service-toggle-metric .value, +.summary-box .value, +.stat-box .value, +.integration-summary-item .value, +.social-metric .value, +.credit-pill .value { + display: block; + font-family: 'Space Grotesk', 'Noto Sans SC', sans-serif; + font-size: 25px; + font-weight: 700; + letter-spacing: -0.05em; } -.switcher-head { + +.hero-focus-actions { display: flex; - justify-content: space-between; - align-items: flex-end; - margin-bottom: 32px; + gap: 8px; + flex-wrap: wrap; } -.switcher-eyebrow { font-size: 12px; font-weight: 700; color: var(--muted); text-transform: uppercase; letter-spacing: 0.1em; display: block; margin-bottom: 8px; } -.switcher-head h2 { font-size: 24px; font-weight: 700; letter-spacing: -0.02em; } -.switcher-head p { color: var(--text-soft); margin-top: 8px; font-size: 14px; max-width: 600px; } -.switcher-note { font-size: 12px; color: var(--muted); background: var(--surface-muted); padding: 8px 16px; border-radius: 999px; font-weight: 500; } -.service-switcher { +.hero-lanes { display: grid; - grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); - gap: 16px; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 14px; + margin-top: 26px; } -.service-toggle { - text-align: left; - padding: 24px; + +.hero-lane { + padding: 18px; border-radius: 20px; border: 1px solid var(--border); - background: var(--surface-strong); - cursor: pointer; - transition: all 0.2s ease; - color: var(--text); - display: flex; - flex-direction: column; - gap: 20px; + background: var(--surface-glass); + transition: transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease; } -.service-toggle:hover { + +.hero-lane:hover, +.service-toggle:hover, +.summary-box:hover, +.subcard:hover, +.credit-strip:hover { transform: translateY(-2px); border-color: var(--border-strong); box-shadow: var(--shadow); } -.service-toggle.is-active { - background: var(--surface); - border-color: var(--primary); - box-shadow: 0 0 0 1px var(--primary), var(--shadow); -} -.service-toggle-top { display: flex; justify-content: space-between; align-items: flex-start; } -.service-chip { - display: inline-block; - padding: 4px 10px; - border-radius: 6px; - font-size: 12px; + +.hero-lane strong { + display: block; + margin: 8px 0 6px; + font-size: 17px; font-weight: 700; - margin-bottom: 12px; - text-transform: uppercase; - letter-spacing: 0.05em; } -.service-chip[data-service="tavily"] { background: var(--tavily-soft); color: var(--tavily); } -.service-chip[data-service="exa"] { background: var(--exa-soft); color: var(--exa); } -.service-chip[data-service="firecrawl"] { background: var(--firecrawl-soft); color: var(--firecrawl); } -.service-chip[data-service="social"] { background: var(--social-soft); color: var(--social); } -.service-toggle-title strong { display: block; font-size: 20px; margin-bottom: 6px; font-weight: 700; } -.service-toggle-title span { font-size: 13px; color: var(--muted); font-family: 'JetBrains Mono', monospace; } -.service-toggle-status { font-size: 12px; font-weight: 600; padding: 6px 12px; border-radius: 999px; background: var(--border); color: var(--text-soft); transition: all 0.2s ease; } -.is-active .service-toggle-status { background: var(--primary); color: var(--bg); } +.hero-lane p { + font-size: 13px; + color: var(--text-soft); + line-height: 1.6; +} + +.hero-lane[data-service="tavily"] { box-shadow: inset 0 3px 0 0 var(--tavily); } +.hero-lane[data-service="exa"] { box-shadow: inset 0 3px 0 0 var(--exa); } +.hero-lane[data-service="firecrawl"] { box-shadow: inset 0 3px 0 0 var(--firecrawl); } +.hero-lane[data-service="social"] { box-shadow: inset 0 3px 0 0 var(--social); } + +.summary-strip { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 14px; + margin-bottom: 24px; +} + +.summary-box { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; + min-height: 138px; + padding: 20px; +} + +.summary-box .hint, +.table-note, +.settings-field .hint, +.service-head p, +.service-sync-meta, +.credit-copy p, +.service-toggle-title span, +.service-toggle-foot, +.subcard .desc, +.stat-box .hint, +.integration-note, +.switcher-head p, +.settings-head p, +.settings-note, +.social-board-desc, +.social-board-foot { + font-size: 13px; + color: var(--text-soft); + line-height: 1.65; +} + +.summary-box .hint { + margin-top: auto; +} + +.summary-box-accent { + background: + linear-gradient(145deg, rgba(15, 118, 110, 0.1), rgba(29, 78, 216, 0.08)), + var(--surface-glass); +} + +.dashboard-flow { + display: flex; + flex-direction: column; + gap: 20px; +} -.service-toggle-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } -.service-toggle-metric { background: var(--surface); padding: 16px; border-radius: 12px; border: 1px solid var(--border); } -.service-toggle-metric .label { font-size: 11px; color: var(--muted); margin-bottom: 6px; text-transform: uppercase; font-weight: 600; } -.service-toggle-metric .value { font-size: 20px; font-weight: 700; font-family: 'JetBrains Mono', monospace; color: var(--text); } +.switcher-shell { + display: flex; + flex-direction: column; + gap: 16px; +} -.service-toggle-badges { display: flex; gap: 8px; flex-wrap: wrap; } -.service-toggle-badge { font-size: 11px; padding: 4px 10px; border-radius: 6px; background: var(--surface-muted); border: 1px solid var(--border); color: var(--text-soft); font-weight: 500; } +.services-root { + display: flex; + flex-direction: column; + gap: 20px; +} -.service-toggle-foot { display: flex; justify-content: space-between; align-items: center; font-size: 12px; color: var(--muted); border-top: 1px solid var(--border); padding-top: 20px; margin-top: auto; } -.service-toggle-arrow { font-weight: 600; color: var(--text-soft); transition: color 0.2s ease; } -.is-active .service-toggle-arrow { color: var(--primary); } +.services-root.is-switching .service-panel:not(.is-inactive) { + animation: workspace-stage-shift 0.28s cubic-bezier(0.22, 1, 0.36, 1); +} + +.switcher-shell, +.credit-strip, +.mysearch-shell, +.service-panel { + padding: 22px; +} + +.switcher-head { + display: flex; + flex-direction: column; + gap: 14px; + margin-bottom: 0; +} + +.switcher-head h2 { + font-size: 30px; +} + +.switcher-note { + display: inline-flex; + align-items: center; + width: fit-content; + padding: 8px 14px; + border-radius: 999px; + background: var(--surface-muted); + border: 1px solid var(--border); + font-size: 12px; + color: var(--text-soft); +} + +.service-switcher { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 14px; + align-items: stretch; +} + +.service-toggle { + width: 100%; + text-align: left; + padding: 15px; + border-radius: 22px; + border: 1px solid var(--border); + background: var(--surface-glass); + color: var(--text); + cursor: pointer; + transition: transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease; + display: flex; + flex-direction: column; + gap: 10px; + min-height: 182px; +} + +.service-toggle.is-active { + border-color: var(--border-strong); + background: + linear-gradient(145deg, rgba(255, 255, 255, 0.16), rgba(255, 255, 255, 0)), + var(--surface-strong); +} + +.service-toggle-top { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-start; +} + +.service-chip { + display: inline-flex; + align-items: center; + width: fit-content; + padding: 6px 10px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + margin-bottom: 10px; +} + +.service-chip[data-service="tavily"] { background: var(--tavily-soft); color: var(--tavily); } +.service-chip[data-service="exa"] { background: var(--exa-soft); color: var(--exa); } +.service-chip[data-service="firecrawl"] { background: var(--firecrawl-soft); color: var(--firecrawl); } +.service-chip[data-service="social"] { background: var(--social-soft); color: var(--social); } +.service-chip[data-service="mysearch"] { background: var(--mysearch-soft); color: var(--mysearch); } + +.service-toggle-title strong { + display: block; + font-family: 'Space Grotesk', 'Noto Sans SC', sans-serif; + font-size: 20px; + letter-spacing: -0.04em; + margin-bottom: 0; + line-height: 1.14; +} + +.service-toggle-title { + min-width: 0; + flex: 1 1 auto; +} + +.service-toggle-title span { + display: block; + color: var(--muted); + overflow-wrap: anywhere; +} + +.service-toggle-route { + margin-top: 6px; + display: inline-flex; + align-items: center; + width: fit-content; + max-width: 100%; + padding: 6px 10px; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--surface); + font-size: 11px; + line-height: 1.5; + color: var(--text-soft); +} + +.service-toggle-status-wrap { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 8px; + min-width: 0; + flex: 0 0 auto; +} + +.service-toggle-flag { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 4px 10px; + border-radius: 999px; + border: 1px solid rgba(15, 118, 110, 0.16); + background: rgba(15, 118, 110, 0.08); + color: var(--accent); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.service-toggle-status { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 6px 10px; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--surface-strong); + font-size: 11px; + font-weight: 700; + line-height: 1.4; + white-space: nowrap; + text-align: right; + max-width: 100%; + min-width: 112px; +} + +.service-toggle-status.is-ok { + color: var(--ok); + border-color: rgba(21, 115, 71, 0.18); +} + +.service-toggle-status.is-warn { + color: var(--warn); + border-color: rgba(183, 106, 17, 0.18); +} + +.service-toggle-status.is-danger { + color: var(--danger); + border-color: rgba(187, 59, 49, 0.18); +} + +.service-toggle-signal { + width: 8px; + height: 8px; + border-radius: 999px; + background: currentColor; + flex: 0 0 auto; +} + +.service-toggle-status span:last-child { + white-space: nowrap; +} + +.service-toggle-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.service-toggle-metric { + padding: 12px 13px; +} + +.service-toggle-badge { + display: inline-flex; + align-items: center; + padding: 5px 10px; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--surface-strong); + font-size: 11px; + font-weight: 600; + color: var(--text-soft); + overflow-wrap: anywhere; +} + +.service-toggle-meta { + display: flex; + flex-direction: column; + gap: 10px; + padding-top: 14px; + border-top: 1px solid var(--border); +} + +.service-toggle-footnote { + font-size: 12px; + line-height: 1.55; + color: var(--text-soft); + overflow-wrap: anywhere; +} + +@keyframes workspace-stage-shift { + from { + opacity: 0.08; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.table-tools { + display: flex; + flex-direction: column; + gap: 12px; + margin: 16px 0 12px; +} + +.table-tools input[type="text"] { + min-height: 36px; + padding: 8px 12px; + border-radius: 16px; + font-size: 12px; + line-height: 1.4; + background: var(--surface); +} + +.table-tools > .input-grow { + flex: 0 0 auto; +} + +.table-tools-row > .input-grow { + flex: 1 1 240px; +} + +.table-tools-stack { + gap: 10px; +} + +.table-tools-row { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.mini-switch { + display: inline-flex; + flex-wrap: wrap; + gap: 8px; +} + +.mini-switch-btn { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 34px; + padding: 0 12px; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--surface); + color: var(--text-soft); + font-size: 12px; + font-weight: 700; + cursor: pointer; + transition: border-color 0.18s ease, background-color 0.18s ease, color 0.18s ease; +} + +.mini-switch-btn:hover { + border-color: var(--border-strong); + background: var(--surface-muted); +} + +.mini-switch-btn.is-active { + border-color: rgba(29, 78, 216, 0.2); + background: linear-gradient(135deg, rgba(29, 78, 216, 0.12), rgba(15, 118, 110, 0.12)); + color: var(--text); +} + +.credit-strip { + display: flex; + flex-direction: column; + gap: 18px; + background: + linear-gradient(145deg, rgba(194, 65, 12, 0.06), rgba(29, 78, 216, 0.05)), + var(--surface-glass); +} + +.credit-strip-inline { + margin-top: 4px; +} + +.credit-copy strong { + display: block; + font-family: 'Space Grotesk', 'Noto Sans SC', sans-serif; + font-size: 20px; + letter-spacing: -0.04em; + margin: 8px 0 8px; +} + +.credit-link { + font-size: 12px; + color: var(--exa); + text-decoration: none; +} + +.credit-link:hover { + text-decoration: underline; +} + +.credit-meta { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.credit-pill { + padding: 14px; +} + +.credit-pill .value { + font-size: clamp(1rem, 1.45vw, 1.22rem); + line-height: 1.32; + overflow-wrap: anywhere; +} + +.credit-pill-note { + display: block; + margin-top: 6px; + font-size: 11px; + line-height: 1.55; + color: var(--text-soft); + overflow-wrap: anywhere; +} -/* Service Panels */ .service-panel { - background: var(--surface); - border: 1px solid var(--border); - border-radius: 24px; - margin-bottom: 32px; overflow: hidden; - box-shadow: var(--shadow); + padding: 0; +} + +.service-panel.is-inactive { + display: none; +} + +.service-panel.is-activating { + animation: service-panel-focus-in 0.3s cubic-bezier(0.22, 1, 0.36, 1); +} + +@keyframes service-panel-focus-in { + from { + opacity: 0.18; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.service-panel[data-service="tavily"] { border-top: 4px solid var(--tavily); } +.service-panel[data-service="exa"] { border-top: 4px solid var(--exa); } +.service-panel[data-service="firecrawl"] { border-top: 4px solid var(--firecrawl); } +.service-panel[data-service="social"] { border-top: 4px solid var(--social); } + +.service-head { + padding: 28px; + border-bottom: 1px solid var(--border); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.15), transparent), + var(--surface-muted); +} + +.service-head-copy { + display: flex; + flex-direction: column; + gap: 10px; + max-width: 760px; +} + +.service-head h2 { + font-size: 34px; + margin-bottom: 2px; +} + +.service-head-route { + display: inline-flex; + align-items: center; + flex-wrap: wrap; + gap: 10px; + max-width: 100%; + min-width: 0; +} + +.service-head-route-label { + padding: 4px 9px; + border-radius: 999px; + background: var(--surface-strong); + border: 1px solid var(--border); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.service-head-route .mono { + display: inline-block; + max-width: 100%; + overflow-wrap: anywhere; + word-break: break-word; + white-space: normal; +} + +.service-tools { + min-width: 280px; + display: flex; + flex-direction: column; + gap: 12px; + padding: 18px; + border-radius: 20px; + border: 1px solid var(--border); + background: var(--surface); +} + +.service-body { + padding: 28px; + display: flex; + flex-direction: column; + gap: 24px; +} + +.stats-grid, +.integration-summary, +.social-board-grid, +.social-board-summary, +.settings-fields, +.settings-panel-grid, +.section-grid, +.brief-list, +.detail-panels { + display: grid; +} + +.stats-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px; +} + +.stat-box { + padding: 18px; +} + +.stat-box .hint { + margin-top: 10px; +} + +.section-grid { + grid-template-columns: minmax(0, 1.15fr) minmax(280px, 0.85fr); + gap: 16px; +} + +.quickstart-grid { + display: flex; + flex-direction: column; + gap: 16px; +} + +.quickstart-card { + display: flex; + flex-direction: column; + gap: 16px; +} + +.quickstart-card-head { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: flex-start; +} + +.quickstart-card-copy { + display: flex; + flex-direction: column; + gap: 4px; +} + +.quickstart-primary-layout { + display: grid; + grid-template-columns: minmax(280px, 0.9fr) minmax(0, 1.1fr); + gap: 18px; + align-items: start; +} + +.quickstart-visual-col, +.quickstart-config-col { + display: flex; + flex-direction: column; + gap: 16px; + min-width: 0; +} + +.quickstart-visual-head { + padding: 16px 18px; + border-radius: 20px; + border: 1px solid var(--border); + background: + linear-gradient(145deg, rgba(29, 78, 216, 0.06), rgba(15, 118, 110, 0.05)), + var(--surface); +} + +.quickstart-visual-head .label { + display: block; + margin-bottom: 8px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); +} + +.quickstart-visual-head strong { + display: block; + font-family: 'Space Grotesk', 'Noto Sans SC', sans-serif; + font-size: 20px; + letter-spacing: -0.04em; + line-height: 1.18; +} + +.quickstart-visual-head span { + display: block; + margin-top: 8px; + font-size: 12px; + line-height: 1.6; + color: var(--text-soft); +} + +.subcard { + padding: 24px; + background: var(--surface-glass); + border: 1px solid var(--border); + border-radius: 24px; +} + +.subcard h3, +.social-board-title { + font-family: 'Space Grotesk', 'Noto Sans SC', sans-serif; + font-size: 22px; + letter-spacing: -0.04em; +} + +.subcard .desc { + margin-top: 8px; +} + +.inline-meta { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin: 16px 0; +} + +.inline-meta span, +.endpoint { + display: inline-flex; + align-items: flex-start; + gap: 6px; + padding: 7px 10px; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--surface); + max-width: 100%; + min-width: 0; + font-size: 13px; + line-height: 1.6; + color: var(--text-soft); + overflow-wrap: anywhere; + word-break: break-word; + white-space: normal; +} + +.code-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + margin-bottom: 12px; + flex-wrap: wrap; +} + +.code-block { + overflow-x: auto; + padding: 18px; + border-radius: 18px; + background: #101722; + color: #edf1f5; + font-size: 13px; + line-height: 1.7; + border: 1px solid rgba(255, 255, 255, 0.08); +} + +.service-brief-head { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: center; + margin-bottom: 12px; +} + +.service-brief-note, +.detail-pill, +.settings-meta-pill { + display: inline-flex; + align-items: center; + padding: 6px 10px; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--surface); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-soft); +} + +.brief-list { + grid-template-columns: 1fr; + gap: 10px; +} + +.brief-item { + padding: 14px 16px; + min-width: 0; + border-radius: 18px; + border: 1px solid var(--border); + background: var(--surface); +} + +.brief-item strong { + display: block; + font-size: 15px; + line-height: 1.6; + overflow-wrap: anywhere; + word-break: break-word; +} + +.detail-panels { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} + +.detail-card summary { + list-style: none; + display: flex; + justify-content: space-between; + gap: 18px; + align-items: start; + cursor: pointer; +} + +.detail-card-static-head { + display: flex; + justify-content: space-between; + gap: 18px; + align-items: start; +} + +.detail-card summary::-webkit-details-marker { + display: none; +} + +.detail-card summary p, +.detail-card-static-head p { + margin-top: 6px; + font-size: 13px; + color: var(--text-soft); + line-height: 1.65; +} + +.detail-card .detail-body { + margin-top: 18px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.toggle-area { + display: flex; + flex-direction: column; + gap: 10px; + padding: 16px; + border-radius: 18px; + border: 1px dashed var(--border-strong); + background: var(--surface); +} + +.form-row { + display: flex; + gap: 10px; + align-items: center; + flex-wrap: wrap; +} + +.token-create-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; +} + +.token-create-row .input-grow { + min-width: 0; +} + +.token-create-row .btn { + white-space: nowrap; +} + +.form-row-end { + justify-content: flex-end; +} + +.input-grow { + flex: 1 1 240px; +} + +.table-wrap { + overflow: auto; + border-radius: 18px; + border: 1px solid var(--border); + background: var(--surface); +} + +table { + width: 100%; + border-collapse: collapse; + text-align: left; +} + +th, +td { + padding: 15px 16px; + border-bottom: 1px solid var(--border); + vertical-align: top; + font-size: 13px; +} + +th { + position: sticky; + top: 0; + z-index: 1; + background: var(--surface-strong); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); +} + +tr:last-child td { + border-bottom: none; +} + +.table-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.table-row-clickable { + cursor: pointer; + transition: background-color 0.18s ease; +} + +.table-row-clickable:hover td { + background: var(--surface-muted); +} + +.table-row-clickable:focus-visible { + outline: 2px solid rgba(29, 78, 216, 0.45); + outline-offset: -2px; +} + +.table-row-clickable.is-danger td { + background: rgba(187, 59, 49, 0.05); +} + +.table-row-clickable.is-warn td { + background: rgba(183, 106, 17, 0.05); +} + +.table-row-clickable.is-busy td { + background: rgba(29, 78, 216, 0.05); +} + +.table-row-clickable.is-off td { + background: rgba(111, 122, 136, 0.05); +} + +.row-meta { + margin-top: 6px; + font-size: 11px; + color: var(--muted); +} + +.tag { + display: inline-flex; + align-items: center; + padding: 5px 10px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.tag-ok { + color: var(--ok); + background: rgba(21, 115, 71, 0.12); +} + +.tag-off { + color: var(--danger); + background: rgba(187, 59, 49, 0.12); +} + +.quota-bar { + width: 100%; + height: 7px; + margin-top: 8px; + border-radius: 999px; + background: rgba(111, 122, 136, 0.14); + overflow: hidden; +} + +.quota-bar-fill { + height: 100%; + background: linear-gradient(90deg, var(--accent), var(--exa)); +} + +.quota-bar-fill.warn { + background: linear-gradient(90deg, var(--warn), #f59e0b); +} + +.quota-bar-fill.danger { + background: linear-gradient(90deg, var(--danger), #ef4444); +} + +.mysearch-shell { + padding: 0; + overflow: hidden; + border-top: 4px solid var(--mysearch); +} + +.integration-summary { + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + margin: 18px 0 16px; +} + +.quickstart-config-col .integration-summary { + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin: 0; +} + +.quickstart-visual-col .integration-summary { + margin: 0; +} + +.integration-summary-item { + padding: 16px; + min-width: 0; +} + +.integration-summary-item-wide { + grid-column: span 2; +} + +.integration-note, +.social-board-foot, +.settings-secret-meta { + padding: 14px 16px; + border-radius: 18px; + border: 1px solid var(--border); + background: var(--surface); +} + +.integration-summary-item .label { + margin-bottom: 4px; +} + +.integration-summary-item .value { + font-size: clamp(1.08rem, 1.8vw, 1.42rem); + line-height: 1.38; + letter-spacing: -0.03em; + overflow-wrap: anywhere; + word-break: break-word; +} + +.integration-summary-item .value.is-tight { + font-size: clamp(0.98rem, 1.45vw, 1.18rem); + line-height: 1.5; + letter-spacing: -0.01em; +} + +.integration-summary-item .value.mono { + font-size: clamp(1rem, 1.45vw, 1.2rem); + line-height: 1.55; + letter-spacing: -0.01em; + overflow-wrap: anywhere; + word-break: break-word; + white-space: normal; +} + +.integration-summary-detail .integration-summary-item .value { + font-size: clamp(1rem, 1.4vw, 1.14rem); + line-height: 1.58; +} + +.quickstart-route-strip { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + margin: 18px 0 16px; +} + +.quickstart-route-card { + padding: 16px; + border-radius: 18px; + border: 1px solid var(--border); + background: var(--surface-glass); +} + +.quickstart-route-card.is-ok { + border-color: rgba(15, 118, 110, 0.18); + background: + linear-gradient(145deg, rgba(15, 118, 110, 0.06), rgba(15, 118, 110, 0.02)), + var(--surface-glass); +} + +.quickstart-route-card.is-warn { + border-color: rgba(183, 106, 17, 0.2); + background: + linear-gradient(145deg, rgba(183, 106, 17, 0.07), rgba(183, 106, 17, 0.02)), + var(--surface-glass); +} + +.quickstart-route-card.is-danger { + border-color: rgba(187, 59, 49, 0.18); + background: + linear-gradient(145deg, rgba(187, 59, 49, 0.07), rgba(187, 59, 49, 0.02)), + var(--surface-glass); +} + +.quickstart-route-card .label { + display: block; + margin-bottom: 8px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); +} + +.quickstart-route-card strong { + display: block; + font-family: 'Space Grotesk', 'Noto Sans SC', sans-serif; + font-size: 15px; + line-height: 1.45; + color: var(--text); + overflow-wrap: anywhere; +} + +.quickstart-route-card span { + display: block; + margin-top: 6px; + font-size: 12px; + line-height: 1.55; + color: var(--text-soft); + overflow-wrap: anywhere; +} + +.quickstart-install-strip { + margin: 0; + padding: 16px 18px; + border-radius: 20px; + border: 1px solid var(--border); + background: + linear-gradient(145deg, rgba(29, 78, 216, 0.07), rgba(15, 118, 110, 0.05)), + var(--surface); +} + +.quickstart-install-strip .label { + display: block; + margin-bottom: 8px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); +} + +.quickstart-install-strip strong { + display: block; + font-family: 'Space Grotesk', 'Noto Sans SC', sans-serif; + font-size: 16px; + line-height: 1.45; + letter-spacing: -0.03em; +} + +.quickstart-install-strip span { + display: block; + margin-top: 6px; + font-size: 12px; + line-height: 1.6; + color: var(--text-soft); +} + +.quickstart-install-layout { + display: grid; + grid-template-columns: minmax(280px, 0.9fr) minmax(0, 1.1fr); + gap: 18px; + align-items: start; +} + +.quickstart-command-col { + display: flex; + flex-direction: column; + gap: 12px; + min-width: 0; +} + +.quickstart-command-shell { + display: flex; + flex-direction: column; + gap: 12px; + min-width: 0; + padding: 16px 18px; + border-radius: 20px; + border: 1px solid var(--border); + background: + linear-gradient(145deg, rgba(15, 23, 42, 0.02), rgba(29, 78, 216, 0.03)), + var(--surface); +} + +.quickstart-command-col .code-toolbar { + margin-bottom: 0; +} + +.quickstart-command-col .code-block { + margin: 0; +} + +.quickstart-install-steps { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; +} + +.quickstart-install-meta { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + margin-top: 14px; +} + +.quickstart-install-meta-item { + padding: 12px 14px; + border-radius: 16px; + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.52); +} + +.quickstart-install-meta-item .label { + margin-bottom: 6px; +} + +.quickstart-install-meta-item strong { + display: block; + font-size: 14px; + letter-spacing: 0; +} + +.quickstart-install-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 14px; +} + +.quickstart-install-step { + display: inline-flex; + align-items: center; + min-height: 32px; + padding: 0 12px; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--surface-glass); + font-size: 12px; + color: var(--text-soft); +} + +.quickstart-install-step.is-active { + border-color: rgba(29, 78, 216, 0.2); + background: rgba(29, 78, 216, 0.08); + color: var(--text); +} + +.quickstart-install-step.is-done { + border-color: rgba(15, 118, 110, 0.2); + background: rgba(15, 118, 110, 0.09); + color: var(--text); +} + +.integration-summary-item .value-meta { + margin-top: 6px; + font-size: 11px; + color: var(--text-soft); + overflow-wrap: anywhere; +} + +.integration-note strong, +.social-board-foot strong { + color: var(--text); +} + +.integration-note { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 14px; + margin-bottom: 16px; +} + +.integration-note-copy { + flex: 1 1 320px; + min-width: 0; +} + +.integration-note strong { + display: block; + margin-bottom: 6px; +} + +.integration-note-error { + display: block; + margin-top: 8px; + color: var(--warn); +} + +.integration-note .btn { + flex: 0 0 auto; +} + +a:focus-visible, +button:focus-visible, +input:focus-visible, +textarea:focus-visible, +summary:focus-visible, +.service-toggle:focus-visible, +.settings-tab:focus-visible, +.mode-switch-btn:focus-visible, +.mini-switch-btn:focus-visible { + outline: none; + box-shadow: 0 0 0 4px rgba(29, 78, 216, 0.14); +} + +.social-integration-head { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: center; +} + +.integration-summary-compact { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.integration-summary-detail { + margin: 14px 0 0; +} + +.integration-fold { + margin-bottom: 16px; + padding-top: 12px; + border-top: 1px dashed var(--border-strong); +} + +.integration-fold summary { + cursor: pointer; + font-size: 12px; + font-weight: 700; + color: var(--text-soft); +} + +.integration-note + .code-toolbar { + margin-top: 0; +} + +.integration-note.is-error, +.social-board-foot.is-error, +.settings-status.is-error { + border-color: rgba(187, 59, 49, 0.22); + background: rgba(187, 59, 49, 0.08); +} + +.detail-glance, +.settings-summary-strip, +.detail-drawer-summary { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.glance-card, +.settings-summary-card, +.drawer-metric { + padding: 14px 16px; + min-width: 0; + border-radius: 18px; + border: 1px solid var(--border); + background: var(--surface); +} + +.glance-card .label, +.settings-summary-card .label, +.drawer-metric .label { + display: block; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); + margin-bottom: 6px; +} + +.glance-card .value, +.drawer-metric .value { + display: block; + font-family: 'Space Grotesk', 'Noto Sans SC', sans-serif; + font-size: 18px; + font-weight: 700; + letter-spacing: -0.04em; +} + +.settings-summary-card .value { + display: block; + font-family: 'Space Grotesk', 'Noto Sans SC', sans-serif; + font-size: 16px; + font-weight: 700; + letter-spacing: -0.03em; + line-height: 1.55; + overflow-wrap: anywhere; + word-break: break-word; +} + +.settings-summary-card .value.mono { + font-family: 'IBM Plex Mono', monospace; + font-size: 13px; + line-height: 1.76; + letter-spacing: 0; +} + +.glance-card .hint, +.drawer-metric .hint { + margin-top: 6px; + font-size: 12px; + color: var(--text-soft); + line-height: 1.5; +} + +.settings-summary-card .hint { + margin-top: 6px; + font-size: 12px; + color: var(--text-soft); + line-height: 1.62; + overflow-wrap: anywhere; + word-break: break-word; +} + +.settings-summary-card .hint.mono { + font-family: 'IBM Plex Mono', monospace; + font-size: 12px; + line-height: 1.72; +} + +.detail-caption { + font-size: 12px; + color: var(--text-soft); + padding: 0 2px; +} + +.table-legend { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: -4px; +} + +.legend-chip { + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 30px; + padding: 0 10px; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--surface); + font-size: 11px; + color: var(--text-soft); +} + +.legend-dot { + width: 8px; + height: 8px; + border-radius: 999px; + background: var(--border-strong); +} + +.legend-chip.is-danger .legend-dot { + background: var(--danger); +} + +.legend-chip.is-warn .legend-dot { + background: var(--warn); +} + +.legend-chip.is-busy .legend-dot { + background: var(--info); +} + +.legend-chip.is-off .legend-dot { + background: var(--muted); +} + +.social-section-grid { + grid-template-columns: minmax(0, 1.15fr) minmax(280px, 0.85fr); +} + +.social-board { + display: flex; + flex-direction: column; + gap: 16px; +} + +.social-board-top { + display: flex; + justify-content: space-between; + align-items: flex-start; +} + +.social-board-kicker { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--muted); + margin-bottom: 8px; +} + +.social-board-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} + +.social-metric { + padding: 14px; +} + +.social-board-summary { + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} + +.social-board-summary-item { + padding: 14px; + min-width: 0; + border-radius: 18px; + border: 1px solid var(--border); + background: var(--surface); +} + +.social-board-summary-item .label { + display: block; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); + margin-bottom: 8px; +} + +.social-board-summary-item .value { + font-size: 16px; + font-weight: 700; + line-height: 1.58; + overflow-wrap: anywhere; + word-break: break-word; +} + +.settings-modal-shell { + position: fixed; + inset: 0; + z-index: 100; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; +} + +.settings-backdrop { + position: absolute; + inset: 0; + background: rgba(17, 24, 39, 0.48); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); +} + +.settings-dialog { + position: relative; + z-index: 1; + width: min(1120px, calc(100vw - 32px)); + max-height: calc(100vh - 48px); + overflow: auto; + padding: 28px; + border-radius: 30px; + background: + linear-gradient(160deg, rgba(255, 252, 247, 0.96), rgba(244, 236, 225, 0.92)); +} + +body.theme-dark .settings-dialog { + background: + linear-gradient(160deg, rgba(13, 19, 28, 0.96), rgba(17, 24, 34, 0.92)); +} + +.settings-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 16px; + margin-bottom: 22px; +} + +.settings-head h2 { + font-size: 34px; + margin: 6px 0 10px; +} + +.settings-head-meta { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-top: 14px; +} + +.settings-tabs { + display: flex; + gap: 10px; + flex-wrap: wrap; + margin-bottom: 24px; +} + +.settings-tab { + padding: 10px 16px; + border-radius: 999px; + border: 1px solid var(--border); + background: transparent; + color: var(--text-soft); + font-weight: 700; + cursor: pointer; + transition: border-color 0.18s ease, background-color 0.18s ease, color 0.18s ease; +} + +.settings-tab:hover { + background: var(--surface-muted); +} + +.settings-tab.is-active { + background: var(--primary); + color: var(--bg); + border-color: transparent; +} + +.settings-tab-panel { + display: block; +} + +.settings-tab-panel.hidden { + display: none !important; +} + +.settings-panel-grid { + grid-template-columns: 280px minmax(0, 1fr); + gap: 20px; +} + +.settings-panel-aside { + display: flex; + flex-direction: column; + gap: 14px; +} + +.settings-panel-aside h3 { + font-family: 'Space Grotesk', 'Noto Sans SC', sans-serif; + font-size: 24px; + letter-spacing: -0.04em; +} + +.settings-note-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.settings-note { + padding: 14px 16px; + border-radius: 18px; + border: 1px solid var(--border); + background: var(--surface); +} + +.settings-panel-main { + padding: 22px; + border-radius: 26px; + border: 1px solid var(--border); + background: var(--surface-glass); + display: flex; + flex-direction: column; +} + +.settings-form { + display: flex; + flex-direction: column; + gap: 18px; +} + +.settings-fields { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 18px; +} + +.settings-field { + display: flex; + flex-direction: column; + gap: 8px; +} + +.settings-field.full-width { + grid-column: 1 / -1; +} + +.settings-field label { + font-size: 14px; + font-weight: 700; +} + +.settings-actions, +.settings-footer-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.settings-footer { + position: sticky; + bottom: -22px; + z-index: 2; + display: flex; + justify-content: space-between; + gap: 16px; + align-items: center; + flex-wrap: wrap; + margin-top: auto; + padding: 16px 0 0; + border-top: 1px solid var(--border); + background: + linear-gradient(180deg, rgba(248, 246, 241, 0), rgba(248, 246, 241, 0.92) 28%, rgba(248, 246, 241, 0.98)); +} + +body.theme-dark .settings-footer { + background: + linear-gradient(180deg, rgba(17, 24, 34, 0), rgba(17, 24, 34, 0.92) 28%, rgba(17, 24, 34, 0.98)); +} + +.settings-footer-copy { + display: flex; + flex-direction: column; + gap: 4px; + flex: 1 1 280px; + min-width: 0; +} + +.settings-footer-copy strong { + font-size: 14px; +} + +.settings-footer-copy span { + font-size: 12px; + color: var(--text-soft); + line-height: 1.55; +} + +.settings-status { + margin-top: 18px; + padding: 14px 16px; + border-radius: 18px; + border: 1px solid var(--border); + background: var(--surface); + color: var(--text-soft); + font-size: 13px; +} + +.settings-probe { + margin-top: 18px; + padding: 16px 18px; + border-radius: 22px; + border: 1px solid var(--border); + background: var(--surface); + display: flex; + flex-direction: column; + gap: 14px; +} + +.settings-probe.is-error { + border-color: rgba(187, 59, 49, 0.22); + background: rgba(187, 59, 49, 0.05); } -.service-panel.is-inactive { display: none; } -.service-panel[data-service="tavily"] { border-top: 4px solid var(--tavily); } -.service-panel[data-service="exa"] { border-top: 4px solid var(--exa); } -.service-panel[data-service="firecrawl"] { border-top: 4px solid var(--firecrawl); } -.service-panel[data-service="social"] { border-top: 4px solid var(--social); } -.service-head { - padding: 40px; +.settings-probe-head { display: flex; justify-content: space-between; + gap: 12px; align-items: flex-start; - border-bottom: 1px solid var(--border); - background: var(--surface-strong); + flex-wrap: wrap; } -.service-head h2 { font-size: 32px; font-weight: 700; margin-bottom: 12px; letter-spacing: -0.02em; } -.service-head p { color: var(--text-soft); font-size: 15px; max-width: 600px; line-height: 1.6; } -.service-tools { - background: var(--surface); - padding: 20px; - border-radius: 16px; - border: 1px solid var(--border); - min-width: 320px; - display: flex; - flex-direction: column; - gap: 16px; + +.settings-probe-eyebrow { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--muted); + margin-bottom: 6px; } -.service-sync-meta { font-size: 12px; color: var(--muted); line-height: 1.5; } -.service-body { padding: 40px; } +.settings-probe-head strong { + display: block; + font-family: 'Space Grotesk', 'Noto Sans SC', sans-serif; + font-size: 22px; + letter-spacing: -0.04em; +} -.stats-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - gap: 16px; - margin-bottom: 40px; +.settings-probe-pills { + display: flex; + flex-wrap: wrap; + gap: 8px; } -.stat-box { - background: var(--surface-strong); + +.settings-probe-pill { + display: inline-flex; + align-items: center; + min-height: 30px; + padding: 0 10px; + border-radius: 999px; border: 1px solid var(--border); - border-radius: 16px; - padding: 20px; + background: var(--surface-strong); + font-size: 11px; + font-weight: 700; + color: var(--text-soft); } -.stat-box .label { font-size: 11px; font-weight: 600; color: var(--muted); margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.05em; } -.stat-box .value { font-size: 28px; font-weight: 700; font-family: 'JetBrains Mono', monospace; line-height: 1; color: var(--text); } -.stat-box .hint { font-size: 12px; color: var(--text-soft); margin-top: 12px; line-height: 1.4; } -.section-grid { +.settings-probe-grid { display: grid; - grid-template-columns: 1fr 1fr; - gap: 24px; - margin-bottom: 40px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; } -.subcard { - background: var(--surface-strong); + +.settings-probe-card { + padding: 14px 16px; + min-width: 0; + border-radius: 18px; border: 1px solid var(--border); - border-radius: 20px; - padding: 32px; + background: var(--surface-glass); } -.subcard h3 { font-size: 20px; font-weight: 600; margin-bottom: 12px; } -.subcard .desc { font-size: 14px; color: var(--text-soft); margin-bottom: 24px; line-height: 1.6; } -.code-toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; } -.code-toolbar .endpoint { font-size: 13px; color: var(--muted); font-family: 'JetBrains Mono', monospace; background: var(--surface-muted); padding: 4px 8px; border-radius: 6px; } -.code-block { - background: #0f172a; - color: #e2e8f0; - padding: 20px; - border-radius: 12px; - font-size: 13px; - overflow-x: auto; - line-height: 1.6; - border: 1px solid #1e293b; +.settings-probe-card.is-wide { + grid-column: 1 / -1; } -.table-wrap { - border: 1px solid var(--border); - border-radius: 16px; - overflow-x: auto; - background: var(--surface); +.settings-probe-card .label { + display: block; + margin-bottom: 8px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); } -table { width: 100%; border-collapse: collapse; text-align: left; } -th, td { padding: 16px 20px; border-bottom: 1px solid var(--border); font-size: 14px; vertical-align: middle; } -th { background: var(--surface-strong); font-weight: 600; color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em; } -tr:last-child td { border-bottom: none; } -.table-note { font-size: 12px; color: var(--text-soft); margin-top: 4px; line-height: 1.5; } -.table-actions { display: flex; gap: 8px; } -.tag { padding: 4px 10px; border-radius: 6px; font-size: 12px; font-weight: 600; } -.tag-ok { background: rgba(22, 163, 74, 0.1); color: var(--ok); } -.tag-off { background: rgba(220, 38, 38, 0.1); color: var(--danger); } +.settings-probe-card .value { + display: block; + font-size: 14px; + line-height: 1.65; + color: var(--text); + overflow-wrap: anywhere; + word-break: break-word; +} -/* Form Elements Layout */ -.form-row { display: flex; gap: 12px; align-items: center; margin-bottom: 16px; } -.input-grow { flex: 1; } +.settings-probe-card .value.mono { + font-size: 13px; + line-height: 1.78; + letter-spacing: 0; +} -/* Settings Modal */ -.settings-modal-shell { +.settings-advanced { + margin-top: 20px; + padding-top: 20px; + border-top: 1px dashed var(--border-strong); +} + +.settings-advanced summary { + cursor: pointer; + font-weight: 700; +} + +.detail-drawer-shell, +.app-dialog-shell { position: fixed; inset: 0; - z-index: 100; - display: flex; - align-items: center; - justify-content: center; - padding: 24px; } -.settings-backdrop { + +.detail-drawer-shell { + z-index: 110; +} + +.app-dialog-shell { + z-index: 130; +} + +.detail-drawer-backdrop, +.app-dialog-backdrop { position: absolute; inset: 0; - background: rgba(15, 23, 42, 0.4); - backdrop-filter: blur(8px); - -webkit-backdrop-filter: blur(8px); + background: rgba(17, 24, 39, 0.4); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); } -.settings-dialog { - position: relative; - z-index: 101; - width: 100%; - max-width: 960px; - max-height: 90vh; - overflow-y: auto; - padding: 40px; - border-radius: 24px; -} -.settings-head { + +.detail-drawer { + position: absolute; + top: 16px; + right: 16px; + bottom: 16px; + width: min(460px, calc(100vw - 28px)); + padding: 22px; + border-radius: 30px; display: flex; - justify-content: space-between; - align-items: flex-start; - margin-bottom: 32px; + flex-direction: column; + gap: 16px; + overflow: auto; + background: + linear-gradient(160deg, rgba(255, 252, 247, 0.96), rgba(244, 236, 225, 0.92)); } -.settings-kicker { font-size: 12px; font-weight: 700; color: var(--muted); text-transform: uppercase; margin-bottom: 8px; letter-spacing: 0.05em; } -.settings-head h2 { font-size: 32px; font-weight: 700; margin-bottom: 12px; letter-spacing: -0.02em; } -.settings-head p { font-size: 15px; color: var(--text-soft); max-width: 600px; line-height: 1.6; } -.settings-tabs { +body.theme-dark .detail-drawer { + background: + linear-gradient(160deg, rgba(13, 19, 28, 0.96), rgba(17, 24, 34, 0.92)); +} + +.detail-drawer-head, +.app-dialog-head { display: flex; + justify-content: space-between; gap: 12px; - margin-bottom: 32px; - border-bottom: 1px solid var(--border); - padding-bottom: 16px; + align-items: flex-start; } -.settings-tab { - padding: 10px 20px; - border-radius: 10px; - font-size: 14px; - font-weight: 600; - cursor: pointer; - background: transparent; - border: none; - color: var(--text-soft); - transition: all 0.2s ease; + +.detail-drawer-kicker, +.app-dialog-kicker { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--muted); } -.settings-tab:hover { background: var(--surface-muted); color: var(--text); } -.settings-tab.is-active { background: var(--primary); color: var(--bg); } -.settings-panel-grid { - display: grid; - grid-template-columns: 280px 1fr; - gap: 40px; +.detail-drawer-head h3, +.app-dialog-head h3 { + font-family: 'Space Grotesk', 'Noto Sans SC', sans-serif; + font-size: 28px; + letter-spacing: -0.04em; + margin: 6px 0 8px; } -.settings-panel-aside h3 { font-size: 18px; font-weight: 600; margin-bottom: 12px; } -.settings-panel-aside p { font-size: 14px; color: var(--text-soft); margin-bottom: 20px; line-height: 1.6; } -.settings-note-list { display: flex; flex-direction: column; gap: 12px; } -.settings-note { font-size: 13px; padding: 12px 16px; border-radius: 10px; background: var(--surface-muted); border: 1px solid var(--border); color: var(--muted); line-height: 1.5; } -.settings-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; } -.settings-field { display: flex; flex-direction: column; gap: 8px; } -.settings-field.full-width { grid-column: 1 / -1; } -.settings-field label { font-size: 13px; font-weight: 600; color: var(--text); } -.settings-field .hint { font-size: 12px; color: var(--muted); line-height: 1.4; } +.detail-drawer-head p, +.app-dialog-message { + font-size: 13px; + color: var(--text-soft); + line-height: 1.65; +} -.settings-advanced { margin-top: 32px; padding-top: 32px; border-top: 1px dashed var(--border); } -.settings-advanced summary { font-size: 15px; font-weight: 600; cursor: pointer; margin-bottom: 20px; color: var(--text); } +.detail-drawer-body { + display: flex; + flex-direction: column; + gap: 14px; +} -.settings-actions { margin-top: 32px; display: flex; gap: 12px; } -.settings-status { margin-top: 20px; padding: 16px; border-radius: 12px; font-size: 14px; background: var(--surface-muted); border: 1px solid var(--border); color: var(--text-soft); } -.settings-status.is-error { color: var(--danger); background: rgba(239, 68, 68, 0.1); border-color: rgba(239, 68, 68, 0.2); } +.drawer-section { + padding: 16px 18px; + border-radius: 22px; + border: 1px solid var(--border); + background: var(--surface); +} -.settings-tab-panel { display: block; } -.settings-tab-panel.hidden { display: none !important; } +.drawer-section h4 { + font-family: 'Space Grotesk', 'Noto Sans SC', sans-serif; + font-size: 18px; + letter-spacing: -0.03em; + margin-bottom: 10px; +} -/* Credits & Integrations */ -.credit-strip { - background: var(--surface-strong); - border: 1px solid var(--border); - border-radius: 20px; - padding: 24px; - margin-bottom: 32px; +.drawer-section-body { display: flex; - justify-content: space-between; - align-items: center; - gap: 24px; + flex-direction: column; + gap: 10px; } -.credit-copy { flex: 1; } -.credit-kicker { font-size: 11px; font-weight: 700; color: var(--muted); text-transform: uppercase; letter-spacing: 0.1em; display: block; margin-bottom: 8px; } -.credit-copy strong { font-size: 18px; font-weight: 700; display: block; margin-bottom: 8px; } -.credit-copy p { font-size: 14px; color: var(--text-soft); margin-bottom: 12px; line-height: 1.6; } -.credit-link { font-size: 13px; font-family: 'JetBrains Mono', monospace; color: var(--exa); text-decoration: none; font-weight: 500; } -.credit-link:hover { text-decoration: underline; } -.credit-meta { display: flex; gap: 16px; } -.credit-pill { background: var(--surface); border: 1px solid var(--border); padding: 12px 16px; border-radius: 12px; text-align: center; } -.credit-pill .label { font-size: 11px; color: var(--muted); text-transform: uppercase; margin-bottom: 4px; font-weight: 600; } -.credit-pill .value { font-size: 14px; font-weight: 700; color: var(--text); } +.drawer-grid { + display: grid; + gap: 12px; +} -.mysearch-shell { - background: var(--surface-strong); +.drawer-grid-compact { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.drawer-inline-card { + padding: 12px 14px; + border-radius: 16px; border: 1px solid var(--border); - border-radius: 24px; - margin-bottom: 32px; - overflow: hidden; - border-left: 4px solid var(--primary); + background: var(--surface-muted); } -.integration-summary { display: flex; gap: 24px; margin-bottom: 24px; } -.integration-summary-item { background: var(--surface); padding: 16px 20px; border-radius: 12px; border: 1px solid var(--border); flex: 1; } -.integration-summary-item .label { font-size: 12px; font-weight: 600; color: var(--muted); text-transform: uppercase; margin-bottom: 8px; } -.integration-summary-item .value { font-size: 15px; font-weight: 600; color: var(--text); } -/* Responsive */ +.drawer-inline-card span { + display: block; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); + margin-bottom: 6px; +} -.hero-lanes { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 16px; - margin-top: 40px; +.drawer-inline-card strong { + display: block; + font-size: 14px; + line-height: 1.55; } -.hero-lane { - background: var(--surface); + +.detail-drawer-actions, +.app-dialog-actions { + display: flex; + flex-direction: column; + gap: 10px; +} + +.drawer-action-group { + padding: 14px 16px; + border-radius: 18px; border: 1px solid var(--border); - border-radius: 16px; - padding: 20px; - transition: transform 0.2s ease; + background: var(--surface); } -.hero-lane:hover { - transform: translateY(-2px); - box-shadow: var(--shadow); + +.drawer-action-group.is-danger { + border-color: rgba(187, 59, 49, 0.2); + background: rgba(187, 59, 49, 0.06); } -.hero-lane-kicker { + +.drawer-action-kicker { + display: block; + margin-bottom: 10px; font-size: 11px; font-weight: 700; + letter-spacing: 0.08em; text-transform: uppercase; - letter-spacing: 0.1em; color: var(--muted); - display: block; - margin-bottom: 8px; } -.hero-lane strong { - display: block; - font-size: 16px; - font-weight: 700; - margin-bottom: 6px; + +.drawer-action-group.is-danger .drawer-action-kicker { + color: var(--danger); } -.hero-lane p { - font-size: 13px; - color: var(--text-soft); - line-height: 1.5; + +.drawer-action-row { + display: flex; + justify-content: flex-end; + gap: 10px; + flex-wrap: wrap; } -.hero-lane[data-service="tavily"] { border-top: 3px solid var(--tavily); } -.hero-lane[data-service="exa"] { border-top: 3px solid var(--exa); } -.hero-lane[data-service="firecrawl"] { border-top: 3px solid var(--firecrawl); } -.hero-lane[data-service="social"] { border-top: 3px solid var(--social); } +.app-dialog { + position: relative; + z-index: 1; + width: min(460px, calc(100vw - 28px)); + margin: 12vh auto 0; + padding: 24px; + border-radius: 28px; + display: flex; + flex-direction: column; + gap: 18px; + background: + linear-gradient(160deg, rgba(255, 252, 247, 0.98), rgba(244, 236, 225, 0.94)); +} -.w-3 { width: 14px; } -.h-3 { height: 14px; } -.w-4 { width: 16px; } -.h-4 { height: 16px; } -.mr-1 { margin-right: 6px; } -.inline-block { display: inline-block; vertical-align: text-bottom; } +body.theme-dark .app-dialog { + background: + linear-gradient(160deg, rgba(13, 19, 28, 0.98), rgba(17, 24, 34, 0.94)); +} + +#app-dialog[data-tone="danger"] .app-dialog-kicker { + color: var(--danger); +} + +#app-dialog[data-tone="info"] .app-dialog-kicker { + color: var(--info); +} -@media (max-width: 1024px) { - .hero-lanes { grid-template-columns: repeat(2, 1fr); } - .hero-main { grid-template-columns: 1fr 340px; } - .hero-focus { padding: 24px; } - .summary-strip { grid-template-columns: repeat(3, 1fr); } - .settings-panel-grid { grid-template-columns: 1fr 340px; } - .credit-strip { flex-direction: column; align-items: flex-start; } -} -@media (max-width: 768px) { - .hero-lanes { grid-template-columns: 1fr 340px; } - .container { padding: 20px 16px; } - .hero, .switcher-shell, .service-head, .service-body { padding: 24px; } - .summary-strip { grid-template-columns: 1fr 1fr; gap: 12px; } - .section-grid { grid-template-columns: 1fr 340px; gap: 16px; } - .service-toggle-grid { grid-template-columns: 1fr 340px; } - .settings-fields { grid-template-columns: 1fr 340px; } - .integration-summary { flex-direction: column; gap: 12px; } -} - -/* Toast Notifications */ .toast-root { position: fixed; - bottom: 24px; - right: 24px; - z-index: 9999; + right: 20px; + bottom: 20px; + z-index: 999; display: flex; flex-direction: column; - gap: 12px; + gap: 10px; pointer-events: none; } + .toast { + padding: 12px 16px; + border-radius: 18px; + border: 1px solid var(--border); background: var(--surface-strong); color: var(--text); - padding: 12px 20px; - border-radius: 12px; box-shadow: var(--shadow-lg); - border: 1px solid var(--border); - font-size: 14px; - font-weight: 500; - animation: toast-slide-in 0.3s ease forwards; + font-size: 13px; + font-weight: 700; pointer-events: auto; + animation: toast-slide-in 0.28s ease forwards; } + .toast-success { border-left: 4px solid var(--ok); } .toast-error { border-left: 4px solid var(--danger); } .toast-warn { border-left: 4px solid var(--warn); } -.toast-info { border-left: 4px solid var(--primary); } +.toast-info { border-left: 4px solid var(--info); } @keyframes toast-slide-in { - from { opacity: 0; transform: translateX(40px) scale(0.95); } - to { opacity: 1; transform: translateX(0) scale(1); } + from { opacity: 0; transform: translateY(12px); } + to { opacity: 1; transform: translateY(0); } } + .toast-fade-out { - animation: toast-slide-out 0.3s ease forwards; + animation: toast-slide-out 0.24s ease forwards; } + @keyframes toast-slide-out { - from { opacity: 1; transform: translateX(0) scale(1); } - to { opacity: 0; transform: translateX(40px) scale(0.95); } + from { opacity: 1; transform: translateY(0); } + to { opacity: 0; transform: translateY(12px); } +} + +.muted { color: var(--muted); } +.ok { color: var(--ok); } +.warn { color: var(--warn); } +.danger { color: var(--danger); } +.info { color: var(--info); } + +@media (max-width: 1280px) { + .container { + width: min(100vw - 24px, 1320px); + } + + .summary-strip { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .hero-usage-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .hero-lanes, + .social-board-grid, + .social-board-summary { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 1024px) { + .hero-main, + .settings-panel-grid, + .section-grid, + .social-section-grid { + grid-template-columns: 1fr; + } + + .quickstart-primary-layout, + .quickstart-install-layout, + .service-switcher { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .service-head, + .hero-topbar, + .settings-head, + .access-shell-head { + flex-direction: column; + } + + .stats-grid, + .integration-summary, + .detail-glance, + .settings-summary-strip, + .detail-drawer-summary, + .detail-panels { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .service-switcher { + overflow: visible; + padding-right: 0; + } +} + +@media (max-width: 720px) { + .container { + width: min(100vw - 18px, 100%); + padding: 18px 0 44px; + } + + .auth { + margin: 6vh auto; + padding: 28px 22px; + } + + .hero, + .switcher-shell, + .credit-strip, + .settings-dialog, + .service-head, + .service-body, + .subcard, + .access-shell { + padding: 18px; + } + + .hero-copy h1 { + max-width: none; + font-size: 2rem; + } + + .hero-usage-grid, + .hero-focus-metrics, + .summary-strip, + .stats-grid, + .auth-meta, + .integration-summary, + .quickstart-route-strip, + .quickstart-install-meta, + .detail-glance, + .settings-summary-strip, + .settings-probe-grid, + .detail-drawer-summary, + .credit-meta, + .social-board-grid, + .social-board-summary, + .settings-fields, + .detail-panels { + grid-template-columns: 1fr; + } + + .service-toggle-grid { + grid-template-columns: 1fr 1fr; + } + + .service-switcher, + .quickstart-primary-layout, + .quickstart-install-layout { + grid-template-columns: 1fr; + } + + .service-toggle { + min-height: auto; + } + + .service-toggle-top, + .service-toggle-status-wrap, + .hero-focus-status-row, + .quickstart-card-head, + .settings-probe-head, + .detail-card summary, + .detail-card-static-head, + .access-shell-head { + flex-direction: column; + align-items: flex-start; + } + + .integration-summary-item-wide { + grid-column: auto; + } + + .code-toolbar, + .form-row, + .settings-actions, + .settings-footer, + .detail-drawer-actions, + .app-dialog-actions { + align-items: stretch; + } + + .token-create-row { + grid-template-columns: 1fr; + } + + .detail-drawer { + top: auto; + left: 14px; + right: 14px; + bottom: 14px; + width: auto; + } + + .quickstart-install-steps, + .table-legend { + flex-direction: column; + } + + .drawer-grid-compact { + grid-template-columns: 1fr; + } + + .toast-root { + left: 14px; + right: 14px; + bottom: 14px; + } + + .toast { + width: 100%; + } +} + +@media (prefers-reduced-motion: reduce) { + html:focus-within { + scroll-behavior: auto; + } + + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } } diff --git a/proxy/static/js/console.js b/proxy/static/js/console.js index 708487e..c9c34ce 100644 --- a/proxy/static/js/console.js +++ b/proxy/static/js/console.js @@ -4,6 +4,8 @@ function showToast(message, type = 'info') { if (!root) return; const toast = document.createElement('div'); toast.className = `toast toast-${type}`; + toast.setAttribute('role', 'status'); + toast.setAttribute('aria-live', 'polite'); toast.textContent = message; root.appendChild(toast); setTimeout(() => { @@ -14,7 +16,13 @@ function showToast(message, type = 'info') { const STORAGE_KEY = 'multi_service_proxy_pwd'; const LEGACY_STORAGE_KEY = 'tavily_proxy_pwd'; const ACTIVE_SERVICE_KEY = 'multi_service_proxy_active_service'; +const THEME_KEY = 'mysearch_proxy_console_theme'; +const THEME_CYCLE = ['light', 'dark', 'auto']; +const AUTO_THEME_LIGHT_HOUR_START = 7; +const AUTO_THEME_DARK_HOUR_START = 19; const API = ''; +const PAGE_KIND = window.PAGE_KIND || 'console'; +const BUTTON_MIN_BUSY_MS = 320; const SERVICE_META = { tavily: { label: 'Tavily', @@ -26,11 +34,12 @@ const SERVICE_META = { routeHint: '代理端点: POST /api/search, POST /api/extract', syncButton: '同步 Tavily 额度', syncSupported: true, - panelIntro: '适合新闻、网页线索和基础搜索入口,继续保留现有 Tavily 工作台逻辑不动。', + panelIntro: '适合新闻、网页线索和基础搜索入口;现在既支持本地 API Key 池,也支持接上游 Tavily Gateway。', tokenPoolDesc: '给业务侧发放 Tavily 代理 Token,和 Exa / Firecrawl 完全分开创建、限流、统计。', keyPoolDesc: 'Tavily Key 独立存储,导入时只写入 Tavily 池,不会和 Exa 或 Firecrawl 混用。', - switcherBadges: ['Search', '网页发现', '官方额度同步'], - switcherFoot: '独立 Key 池 + 独立额度同步', + switcherRoute: '/api/search · /api/extract', + switcherBadges: ['网页发现', '官方同步'], + switcherFoot: 'API Key 池 + Gateway 双模式', spotlightDesc: 'Tavily 继续负责第一层网页发现,这一栏保留现有功能与额度同步逻辑。', }, exa: { @@ -46,8 +55,9 @@ const SERVICE_META = { panelIntro: '适合补充网页发现入口,已经独立成 Exa 工作台、Exa Key 池和 Exa Token 池。', tokenPoolDesc: '给业务侧发放 Exa 代理 Token,和 Tavily / Firecrawl 完全分开创建、限流、统计。', keyPoolDesc: 'Exa Key 独立存储,支持直接导入 UUID key,不和别的服务共用池子。', - switcherBadges: ['Search', '网页发现', '代理统计'], - switcherFoot: '独立 Key 池 + 独立代理统计', + switcherRoute: '/exa/search', + switcherBadges: ['网页发现', '代理统计'], + switcherFoot: '独立搜索池', spotlightDesc: 'Exa 已经收成单独工作台,现在可以单独导入 Key、签发 Token,并通过 /exa/search 直接代理搜索。', }, firecrawl: { @@ -63,8 +73,9 @@ const SERVICE_META = { panelIntro: '适合正文抓取、文档页、PDF 和结构化抽取,继续保持独立 Firecrawl 工作台。', tokenPoolDesc: '给业务侧发放 Firecrawl 代理 Token,和 Tavily / Exa 完全分开创建、限流、统计。', keyPoolDesc: 'Firecrawl Key 独立存储,导入时只写入 Firecrawl 池,不会和其他服务混用。', - switcherBadges: ['Depth', '正文抓取', '官方额度同步'], - switcherFoot: '独立 Key 池 + 独立额度同步', + switcherRoute: '/firecrawl/*', + switcherBadges: ['正文抓取', '官方同步'], + switcherFoot: '抽取与 credits', spotlightDesc: 'Firecrawl 继续负责正文抓取与页面抽取,额度同步仍按 Firecrawl credits 展示。', }, }; @@ -77,8 +88,9 @@ const WORKSPACE_META = { tokenPrefix: 'shared auth', routeHint: '代理端点: POST /social/search', quotaSource: 'grok2api / xAI-compatible social router', - switcherBadges: ['X Search', 'compatible', '自动继承'], - switcherFoot: '统一 Social 路由 + 统一输出结构', + switcherRoute: '/social/search', + switcherBadges: ['X Search', '自动继承'], + switcherFoot: '兼容路由 + 统一输出', spotlightDesc: 'Social / X 工作台负责舆情路由和 token 池映射,对外统一暴露 /social/search。', }, }; @@ -89,6 +101,652 @@ let latestServices = {}; let latestSocial = {}; let latestMySearch = {}; let latestSettings = {}; +let latestStatsMeta = {}; +let activeTheme = localStorage.getItem(THEME_KEY) || 'light'; +let effectiveTheme = 'light'; +let appDialogResolver = null; +let autoThemeIntervalId = 0; +const tableControls = { tokens: {}, keys: {} }; +const overlayFocusMemory = {}; +const OVERLAY_PRIORITY = ['app-dialog', 'detail-drawer', 'settings-modal']; + +function isShellVisible(id) { + const element = document.getElementById(id); + return Boolean(element && !element.classList.contains('hidden')); +} + +function syncOverlayState() { + const overlayOpen = ['settings-modal', 'detail-drawer', 'app-dialog'].some(isShellVisible); + document.body.classList.toggle('modal-open', overlayOpen); +} + +function getFocusableElements(root) { + if (!root) return []; + return Array.from(root.querySelectorAll( + 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])', + )).filter((item) => !item.classList.contains('hidden') && item.offsetParent !== null); +} + +function rememberOverlayFocus(id) { + if (document.activeElement instanceof HTMLElement) { + overlayFocusMemory[id] = document.activeElement; + } +} + +function restoreOverlayFocus(id) { + const target = overlayFocusMemory[id]; + delete overlayFocusMemory[id]; + if (target && target.isConnected) { + target.focus({ preventScroll: true }); + } +} + +function focusOverlay(id) { + const shell = document.getElementById(id); + if (!shell) return; + requestAnimationFrame(() => { + const candidates = getFocusableElements(shell); + const target = candidates.find((item) => item.hasAttribute('data-overlay-autofocus')) || candidates[0]; + target?.focus({ preventScroll: true }); + }); +} + +function getTopOpenOverlayId() { + return OVERLAY_PRIORITY.find((id) => isShellVisible(id)) || ''; +} + +function trapOverlayFocus(event) { + if (event.key !== 'Tab') return false; + const overlayId = getTopOpenOverlayId(); + if (!overlayId) return false; + const shell = document.getElementById(overlayId); + const focusable = getFocusableElements(shell); + if (!focusable.length) return false; + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + const active = document.activeElement; + if (event.shiftKey) { + if (active === first || !shell.contains(active)) { + event.preventDefault(); + last.focus({ preventScroll: true }); + return true; + } + return false; + } + if (active === last || !shell.contains(active)) { + event.preventDefault(); + first.focus({ preventScroll: true }); + return true; + } + return false; +} + +function handleSegmentedControlKey(event) { + const trigger = event.target.closest('.mini-switch-btn, .mode-switch-btn, .settings-tab'); + if (!trigger) return false; + const isPrev = ['ArrowLeft', 'ArrowUp'].includes(event.key); + const isNext = ['ArrowRight', 'ArrowDown'].includes(event.key); + const isHome = event.key === 'Home'; + const isEnd = event.key === 'End'; + if (!isPrev && !isNext && !isHome && !isEnd) return false; + + const container = trigger.closest('.mini-switch, .mode-switch, .settings-tabs'); + if (!container) return false; + const selector = trigger.classList.contains('settings-tab') + ? '.settings-tab' + : trigger.classList.contains('mode-switch-btn') + ? '.mode-switch-btn' + : '.mini-switch-btn'; + const buttons = Array.from(container.querySelectorAll(selector)).filter((item) => !item.disabled); + if (!buttons.length) return false; + + const currentIndex = Math.max(0, buttons.indexOf(trigger)); + let targetIndex = currentIndex; + if (isHome) { + targetIndex = 0; + } else if (isEnd) { + targetIndex = buttons.length - 1; + } else if (isPrev) { + targetIndex = (currentIndex - 1 + buttons.length) % buttons.length; + } else if (isNext) { + targetIndex = (currentIndex + 1) % buttons.length; + } + + const next = buttons[targetIndex]; + if (!next) return false; + event.preventDefault(); + next.focus({ preventScroll: true }); + if (next !== trigger) { + next.click(); + } + return true; +} + +function getServiceDisplayLabel(service) { + if (service === 'mysearch') return 'MySearch'; + return WORKSPACE_META[service]?.label || SERVICE_META[service]?.label || service; +} + +function getServicePayload(service) { + if (service === 'mysearch') return latestMySearch || {}; + if (service === 'social') return latestSocial || {}; + return latestServices[service] || {}; +} + +function getTokenTableState(service) { + if (!tableControls.tokens[service]) { + tableControls.tokens[service] = { search: '', sort: 'risk' }; + } + return tableControls.tokens[service]; +} + +function getKeyTableState(service) { + if (!tableControls.keys[service]) { + tableControls.keys[service] = { search: '', filter: 'all', sort: 'risk' }; + } + return tableControls.keys[service]; +} + +function parseTimeValue(value) { + if (!value) return 0; + const stamp = Date.parse(value); + return Number.isFinite(stamp) ? stamp : 0; +} + +function getTokenActivity(token) { + const stats = token?.stats || {}; + return Number(stats.hour_count || 0) * 1000000 + + (Number(stats.today_success || 0) + Number(stats.today_failed || 0)) * 1000 + + Number(stats.month_success || 0) + + Number(stats.month_failed || 0); +} + +function getKeyRemaining(key) { + if (key.usage_key_remaining !== null && key.usage_key_remaining !== undefined) { + return Number(key.usage_key_remaining || 0); + } + if (key.usage_account_remaining !== null && key.usage_account_remaining !== undefined) { + return Number(key.usage_account_remaining || 0); + } + return Number.POSITIVE_INFINITY; +} + +function getTokenRiskScore(token) { + const stats = token?.stats || {}; + const failed = Number(stats.today_failed || 0); + const success = Number(stats.today_success || 0); + const today = failed + success; + const hour = Number(stats.hour_count || 0); + let score = failed * 1000; + if (failed > 0 && failed >= Math.max(success, 1)) { + score += 300000; + } else if (failed > 0) { + score += 120000; + } + if (today >= 120 || hour >= 24) { + score += 40000; + } + score += hour * 100 + today; + return score; +} + +function getKeyRiskScore(service, key) { + let score = 0; + const remaining = getKeyRemaining(key); + const failed = Number(key.total_failed || 0); + const used = Number(key.total_used || 0); + if (String(key.usage_sync_error || '').trim()) { + score += 500000; + } + if (Number(key.active) !== 1) { + score += 300000; + } + if (Number.isFinite(remaining)) { + score += Math.max(0, 200000 - Math.min(200000, remaining)); + } + if (failed > used && failed > 0) { + score += 80000; + } + if (service === 'exa' && used >= 24) { + score += 30000; + } + score += failed * 1000; + score += Math.max(0, 200 - Math.min(200, used)); + return score; +} + +function hasKeyIssue(service, key) { + return Boolean(getKeyRowClass(service, key)); +} + +function getTokenRowClass(token) { + const stats = token?.stats || {}; + const failed = Number(stats.today_failed || 0); + const success = Number(stats.today_success || 0); + const today = failed + success; + const hour = Number(stats.hour_count || 0); + if (failed > 0 && failed >= Math.max(success, 1)) { + return 'is-danger'; + } + if (today >= 120 || hour >= 24) { + return 'is-busy'; + } + if (failed > 0) { + return 'is-warn'; + } + return ''; +} + +function getKeyRowClass(service, key) { + if (String(key.usage_sync_error || '').trim()) { + return 'is-danger'; + } + if (Number(key.active) !== 1) { + return 'is-off'; + } + const remaining = getKeyRemaining(key); + if (Number.isFinite(remaining) && remaining <= 100) { + return 'is-warn'; + } + if (Number(key.total_failed || 0) > Number(key.total_used || 0) && Number(key.total_failed || 0) > 0) { + return 'is-warn'; + } + if (service === 'exa' && Number(key.total_used || 0) >= 24) { + return 'is-busy'; + } + return ''; +} + +function getFilteredTokens(service, tokens) { + const state = getTokenTableState(service); + const keyword = (state.search || '').trim().toLowerCase(); + let items = [...(tokens || [])]; + if (keyword) { + items = items.filter((token) => { + const haystack = [ + token.id, + token.name, + token.token, + token.created_at, + ].map((value) => String(value || '').toLowerCase()).join(' '); + return haystack.includes(keyword); + }); + } + + items.sort((left, right) => { + if (state.sort === 'risk') { + const riskDelta = getTokenRiskScore(right) - getTokenRiskScore(left); + if (riskDelta !== 0) return riskDelta; + const todayDelta = (Number(right?.stats?.today_failed || 0) + Number(right?.stats?.today_success || 0)) + - (Number(left?.stats?.today_failed || 0) + Number(left?.stats?.today_success || 0)); + if (todayDelta !== 0) return todayDelta; + return parseTimeValue(right.created_at) - parseTimeValue(left.created_at); + } + if (state.sort === 'name') { + return String(left.name || left.token || '').localeCompare(String(right.name || right.token || ''), 'zh-CN'); + } + if (state.sort === 'today') { + const leftToday = Number(left?.stats?.today_success || 0) + Number(left?.stats?.today_failed || 0); + const rightToday = Number(right?.stats?.today_success || 0) + Number(right?.stats?.today_failed || 0); + if (rightToday !== leftToday) return rightToday - leftToday; + return parseTimeValue(right.created_at) - parseTimeValue(left.created_at); + } + const delta = getTokenActivity(right) - getTokenActivity(left); + if (delta !== 0) return delta; + return parseTimeValue(right.created_at) - parseTimeValue(left.created_at); + }); + return items; +} + +function getFilteredKeys(service, keys) { + const state = getKeyTableState(service); + const keyword = (state.search || '').trim().toLowerCase(); + let items = [...(keys || [])]; + + if (keyword) { + items = items.filter((key) => { + const haystack = [ + key.id, + key.key, + key.key_masked, + key.email, + key.last_used_at, + ].map((value) => String(value || '').toLowerCase()).join(' '); + return haystack.includes(keyword); + }); + } + + if (state.filter === 'active') { + items = items.filter((key) => Number(key.active) === 1); + } else if (state.filter === 'disabled') { + items = items.filter((key) => Number(key.active) !== 1); + } else if (state.filter === 'error') { + items = items.filter((key) => Boolean((key.usage_sync_error || '').trim())); + } else if (state.filter === 'issue') { + items = items.filter((key) => hasKeyIssue(service, key)); + } + + items.sort((left, right) => { + if (state.sort === 'risk') { + const riskDelta = getKeyRiskScore(service, right) - getKeyRiskScore(service, left); + if (riskDelta !== 0) return riskDelta; + return parseTimeValue(right.last_used_at) - parseTimeValue(left.last_used_at); + } + if (state.sort === 'usage') { + const usageDelta = Number(right.total_used || 0) - Number(left.total_used || 0); + if (usageDelta !== 0) return usageDelta; + return parseTimeValue(right.last_used_at) - parseTimeValue(left.last_used_at); + } + if (state.sort === 'quota') { + const quotaDelta = getKeyRemaining(left) - getKeyRemaining(right); + if (quotaDelta !== 0) return quotaDelta; + return parseTimeValue(right.last_used_at) - parseTimeValue(left.last_used_at); + } + return parseTimeValue(right.last_used_at) - parseTimeValue(left.last_used_at); + }); + return items; +} + +function handleTableRowKey(event, kind, service, id) { + if (!['Enter', ' '].includes(event.key)) return; + event.preventDefault(); + if (kind === 'token') { + openTokenDetail(service, id); + return; + } + openKeyDetail(service, id); +} + +function closeAppDialog(result = false) { + const shell = document.getElementById('app-dialog'); + if (!shell) return; + shell.classList.add('hidden'); + syncOverlayState(); + restoreOverlayFocus('app-dialog'); + if (appDialogResolver) { + const resolve = appDialogResolver; + appDialogResolver = null; + resolve(result); + } +} + +function showConfirmDialog({ + title = '请确认操作', + message = '确认后会继续执行当前操作。', + confirmText = '确认', + cancelText = '取消', + tone = 'info', + kicker = 'Action Required', +} = {}) { + const shell = document.getElementById('app-dialog'); + if (!shell) return Promise.resolve(false); + rememberOverlayFocus('app-dialog'); + document.getElementById('app-dialog-kicker').textContent = kicker; + document.getElementById('app-dialog-title').textContent = title; + document.getElementById('app-dialog-message').textContent = message; + shell.dataset.tone = tone; + document.getElementById('app-dialog-actions').innerHTML = ` + ${cancelText ? `` : ''} + + `; + shell.classList.remove('hidden'); + syncOverlayState(); + focusOverlay('app-dialog'); + return new Promise((resolve) => { + appDialogResolver = resolve; + }); +} + +function showAlertDialog({ + title = '提示', + message = '请查看当前状态。', + confirmText = '知道了', + tone = 'info', + kicker = 'Notice', +} = {}) { + return showConfirmDialog({ + title, + message, + confirmText, + cancelText: '', + tone, + kicker, + }); +} + +function openDetailDrawer({ + kicker = 'Detail', + title = '查看详情', + subtitle = '', + tone = 'info', + summaryHtml = '', + bodyHtml = '', + actionsHtml = '', +} = {}) { + const shell = document.getElementById('detail-drawer'); + if (!shell) return; + rememberOverlayFocus('detail-drawer'); + shell.dataset.tone = tone; + document.getElementById('detail-drawer-kicker').textContent = kicker; + document.getElementById('detail-drawer-title').textContent = title; + document.getElementById('detail-drawer-subtitle').textContent = subtitle; + document.getElementById('detail-drawer-summary').innerHTML = summaryHtml; + document.getElementById('detail-drawer-body').innerHTML = bodyHtml; + document.getElementById('detail-drawer-actions').innerHTML = actionsHtml; + shell.classList.remove('hidden'); + syncOverlayState(); + focusOverlay('detail-drawer'); +} + +function closeDetailDrawer() { + const shell = document.getElementById('detail-drawer'); + if (!shell) return; + shell.classList.add('hidden'); + document.getElementById('detail-drawer-summary').innerHTML = ''; + document.getElementById('detail-drawer-body').innerHTML = ''; + document.getElementById('detail-drawer-actions').innerHTML = ''; + syncOverlayState(); + restoreOverlayFocus('detail-drawer'); +} + +function summaryCard(label, value, hint = '', options = {}) { + const valueClass = options.valueClass ? ` ${options.valueClass}` : ''; + const hintClass = options.hintClass ? ` ${options.hintClass}` : ''; + return ` +
+
${escapeHtml(label)}
+
${escapeHtml(value)}
+
${escapeHtml(hint)}
+
+ `; +} + +function drawerMetric(label, value, hint = '') { + return ` +
+
${escapeHtml(label)}
+
${escapeHtml(value)}
+
${escapeHtml(hint)}
+
+ `; +} + +function drawerSection(title, body) { + return ` +
+

${escapeHtml(title)}

+
${body}
+
+ `; +} + +function renderSettingsSummaries(settings = latestSettings) { + const tavily = settings?.tavily || {}; + const social = settings?.social || {}; + const consoleSummary = document.getElementById('settings-console-summary'); + if (consoleSummary) { + consoleSummary.innerHTML = [ + summaryCard('当前主题', getThemePreferenceLabel(), getThemeSummaryHint()), + summaryCard('当前工作台', getServiceDisplayLabel(activeService), '保存设置后会回到这个工作台'), + summaryCard('会话身份', 'admin', '当前控制面使用单管理员入口'), + ].join(''); + } + + const tavilySummary = document.getElementById('settings-tavily-summary'); + if (tavilySummary) { + tavilySummary.innerHTML = [ + summaryCard('配置模式', tavilyModeLabel(tavily.mode || 'auto'), tavilyModeSourceLabel(tavily.mode_source || 'auto_pending')), + summaryCard('当前实际', tavilyModeLabel(tavily.effective_mode || tavily.mode || 'auto'), tavily.effective_mode === 'upstream' ? '当前请求直接转发到上游' : '当前请求从 API Key 池轮询'), + summaryCard( + '上游地址', + tavily.upstream_base_url || '未配置', + tavily.upstream_search_path || '/search', + { valueClass: 'mono is-address', hintClass: 'mono is-address-hint' }, + ), + summaryCard('凭证状态', tavily.upstream_api_key_configured ? '已配置' : '未配置', tavily.upstream_api_key_masked || `本地活跃 Key ${fmtNum(tavily.local_key_count || 0)}`), + ].join(''); + } + + const socialSummary = document.getElementById('settings-social-summary'); + if (socialSummary) { + socialSummary.innerHTML = [ + summaryCard('工作模式', socialModeLabel(social.mode || 'manual'), social.admin_connected ? '后台已连通' : '可手动覆写上游'), + summaryCard('Token 来源', socialTokenSourceLabel(social.token_source || ''), social.gateway_token_configured ? '客户端 token 已配置' : '可直接复用统一 token'), + summaryCard('默认模型', social.model || 'grok-4.1-fast', social.fallback_model ? `Fallback ${social.fallback_model}` : '未配置 fallback'), + ].join(''); + } +} + +function getLocalThemeClock() { + const now = new Date(); + let timeZone = ''; + try { + timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || ''; + } catch (error) { + timeZone = ''; + } + return { + hour: Number.isFinite(now.getHours()) ? now.getHours() : 12, + timeZone: timeZone || 'browser-local', + }; +} + +function resolveEffectiveTheme(theme = activeTheme) { + if (theme === 'dark') return 'dark'; + if (theme === 'auto') { + const { hour } = getLocalThemeClock(); + return hour >= AUTO_THEME_LIGHT_HOUR_START && hour < AUTO_THEME_DARK_HOUR_START ? 'light' : 'dark'; + } + return 'light'; +} + +function getThemePreferenceLabel(theme = activeTheme) { + if (theme === 'auto') return '自动模式'; + return theme === 'dark' ? '夜间模式' : '浅色模式'; +} + +function getThemeEffectiveLabel(theme = activeTheme) { + return resolveEffectiveTheme(theme) === 'dark' ? '夜间模式' : '浅色模式'; +} + +function getThemeSummaryHint(theme = activeTheme) { + if (theme === 'auto') { + const { hour, timeZone } = getLocalThemeClock(); + return `按浏览器本地时间自动切换 · ${timeZone} ${String(hour).padStart(2, '0')}:00 当前${getThemeEffectiveLabel(theme)}`; + } + return '控制台会记住你的偏好'; +} + +function getNextTheme(theme = activeTheme) { + const index = THEME_CYCLE.indexOf(theme); + if (index < 0) return THEME_CYCLE[0]; + return THEME_CYCLE[(index + 1) % THEME_CYCLE.length]; +} + +function syncAutoThemeWatcher() { + if (autoThemeIntervalId) { + window.clearInterval(autoThemeIntervalId); + autoThemeIntervalId = 0; + } + if (activeTheme === 'auto') { + autoThemeIntervalId = window.setInterval(() => { + refreshAutoThemeFromClock(); + }, 60_000); + } +} + +function applyTheme(theme, options = {}) { + const normalized = THEME_CYCLE.includes(theme) ? theme : 'light'; + const persist = options.persist !== false; + activeTheme = normalized; + effectiveTheme = resolveEffectiveTheme(activeTheme); + document.body.classList.toggle('theme-dark', effectiveTheme === 'dark'); + document.body.dataset.themePreference = activeTheme; + document.body.dataset.themeEffective = effectiveTheme; + if (persist) { + localStorage.setItem(THEME_KEY, activeTheme); + } + syncAutoThemeWatcher(); + syncThemeToggle(); + renderSettingsSummaries(); +} + +function refreshAutoThemeFromClock(force = false) { + if (activeTheme !== 'auto') return; + const nextEffective = resolveEffectiveTheme('auto'); + if (!force && nextEffective === effectiveTheme) return; + effectiveTheme = nextEffective; + document.body.classList.toggle('theme-dark', effectiveTheme === 'dark'); + document.body.dataset.themePreference = activeTheme; + document.body.dataset.themeEffective = effectiveTheme; + syncThemeToggle(); + renderSettingsSummaries(); +} + +function syncThemeToggle() { + const label = document.getElementById('theme-toggle-label'); + const button = document.getElementById('theme-toggle'); + if (label) { + label.textContent = getThemePreferenceLabel(); + } + if (button) { + button.dataset.theme = activeTheme; + button.dataset.effectiveTheme = effectiveTheme; + button.title = activeTheme === 'auto' + ? getThemeSummaryHint() + : `点击切换到${getThemePreferenceLabel(getNextTheme())}`; + } +} + +function toggleTheme() { + applyTheme(getNextTheme()); +} + +function scrollToCurrentPanel() { + if (PAGE_KIND === 'mysearch') { + window.location.href = '/'; + return; + } + const panel = document.querySelector(`.service-panel[data-service="${activeService}"]`); + if (panel) { + panel.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } +} + +function scrollToQuickstart() { + if (PAGE_KIND !== 'mysearch') { + window.location.href = '/mysearch'; + return; + } + const shell = document.getElementById('mysearch-quickstart'); + if (shell) { + shell.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } +} + +function openMySearchAccess() { + scrollToQuickstart(); +} function clearStoredPasswords() { localStorage.removeItem(STORAGE_KEY); @@ -101,19 +759,40 @@ function setLoginBusy(isBusy) { if (input) input.disabled = isBusy; if (button) { button.disabled = isBusy; + button.classList.toggle('is-busy', isBusy); + if (isBusy) { + button.classList.remove('is-success', 'is-error'); + } button.textContent = isBusy ? '登录中...' : '进入控制台'; } } -function showDashboard() { +function showDashboard(options = {}) { + const animate = Boolean(options.animate); document.getElementById('login-err').classList.add('hidden'); - document.getElementById('login-box').classList.add('hidden'); - document.getElementById('dashboard').classList.remove('hidden'); + const loginBox = document.getElementById('login-box'); + const dashboard = document.getElementById('dashboard'); + loginBox.classList.add('hidden'); + dashboard.classList.remove('hidden'); + dashboard.classList.remove('is-entering'); + if (animate) { + dashboard.classList.add('is-entering'); + setTimeout(() => { + dashboard.classList.remove('is-entering'); + }, 760); + } + renderSettingsSummaries(); } function showLogin() { - document.getElementById('dashboard').classList.add('hidden'); - document.getElementById('login-box').classList.remove('hidden'); + const dashboard = document.getElementById('dashboard'); + const loginBox = document.getElementById('login-box'); + dashboard.classList.remove('is-entering'); + dashboard.classList.add('hidden'); + loginBox.classList.remove('hidden'); + closeSettingsModal(); + closeDetailDrawer(); + closeAppDialog(false); setLoginBusy(false); } @@ -175,6 +854,20 @@ function socialModeLabel(mode) { return '手动模式'; } +function tavilyModeLabel(mode) { + if (mode === 'upstream') return '上游 Gateway'; + if (mode === 'pool') return 'API Key 池'; + return '自动识别'; +} + +function tavilyModeSourceLabel(source) { + if (source === 'manual_upstream') return '手动固定上游'; + if (source === 'manual_pool') return '手动固定本地池'; + if (source === 'auto_upstream') return '自动识别到上游凭证'; + if (source === 'auto_pool') return '自动识别到本地可用 Key'; + return '等待识别'; +} + function socialTokenSourceLabel(source) { if (source === 'grok2api app.api_key') return '后台自动继承'; if (source === 'SOCIAL_GATEWAY_UPSTREAM_API_KEY') return '手动上游 API key'; @@ -188,6 +881,479 @@ function socialStatusLabel(social) { return '等待配置'; } +function getTavilyRuntimeState(payload) { + const routing = payload?.routing || {}; + const settings = latestSettings?.tavily || {}; + return { + configuredMode: routing.mode || settings.mode || 'auto', + effectiveMode: routing.effective_mode || settings.effective_mode || settings.mode || 'auto', + modeSource: routing.mode_source || settings.mode_source || 'auto_pending', + upstreamConfigured: Boolean( + (routing.upstream_api_key_configured ?? settings.upstream_api_key_configured) || false, + ), + localKeyCount: Number( + routing.local_key_count + ?? settings.local_key_count + ?? payload?.keys_active + ?? 0, + ), + }; +} + +function getTavilyUpstreamSummary(payload) { + const summary = payload?.upstream_summary || {}; + const activeKeys = Number(summary.active_keys || 0); + const exhaustedKeys = Number(summary.exhausted_keys || 0); + const quarantinedKeys = Number(summary.quarantined_keys || 0); + const totalKeys = Number(summary.total_keys || (activeKeys + exhaustedKeys + quarantinedKeys)); + return { + available: Boolean(summary.available), + detail: summary.detail || '', + requestTarget: summary.request_target || '', + activeKeys, + exhaustedKeys, + quarantinedKeys, + totalKeys, + totalRequests: Number(summary.total_requests || 0), + successCount: Number(summary.success_count || 0), + errorCount: Number(summary.error_count || 0), + quotaExhaustedCount: Number(summary.quota_exhausted_count || 0), + totalQuotaLimit: Number(summary.total_quota_limit || 0), + totalQuotaRemaining: Number(summary.total_quota_remaining || 0), + lastActivity: summary.last_activity || null, + }; +} + +function getSocialUpstreamState(social) { + const visibility = social?.upstream_visibility || {}; + const upstreamApiKeyCount = Number( + visibility.upstream_api_key_count + ?? social?.upstream_api_key_count + ?? 0, + ); + const acceptedTokenCount = Number( + visibility.accepted_token_count + ?? social?.accepted_token_count + ?? 0, + ); + const level = visibility.level + || (social?.admin_connected + ? 'full' + : ((upstreamApiKeyCount > 0 || acceptedTokenCount > 0) + ? 'basic' + : 'none')); + return { + level, + detail: visibility.detail || '', + canProxySearch: Boolean( + visibility.can_proxy_search + ?? (social?.upstream_key_configured && social?.client_auth_configured), + ), + upstreamApiKeyCount, + acceptedTokenCount, + adminConnected: Boolean(visibility.admin_connected ?? social?.admin_connected), + tokenSource: visibility.token_source || social?.token_source || 'not_configured', + }; +} + +function isSocialUpstreamManaged(social) { + const state = getSocialUpstreamState(social || {}); + return Boolean( + state.adminConnected + || state.canProxySearch + || social?.upstream_key_configured + || (String(social?.upstream_base_url || '').trim() && state.upstreamApiKeyCount > 0), + ); +} + +function getBlankServicePayload() { + return { + tokens: [], + keys: [], + overview: {}, + real_quota: {}, + usage_sync: {}, + keys_total: 0, + keys_active: 0, + }; +} + +function normalizeRefreshScope(scope) { + if (!scope) { + return { + core: true, + mysearch: true, + social: true, + services: Object.keys(SERVICE_META), + }; + } + const services = new Set(Array.isArray(scope.services) ? scope.services : []); + if (scope.service) { + services.add(scope.service); + } + return { + core: scope.core !== false, + mysearch: Boolean(scope.mysearch), + social: Boolean(scope.social), + services: [...services].filter((service) => SERVICE_META[service]), + }; +} + +function getRefreshScopeForService(service, options = {}) { + const scope = { + core: options.core !== false, + mysearch: options.mysearch !== false, + social: false, + services: [], + }; + if (service === 'mysearch') { + scope.mysearch = true; + return scope; + } + if (service === 'social') { + scope.social = true; + return scope; + } + if (SERVICE_META[service]) { + scope.services.push(service); + } + return scope; +} + +function getQuickstartProviderCards(services = latestServices, social = latestSocial) { + const tavilyPayload = services?.tavily || {}; + const tavilyState = getTavilyRuntimeState(tavilyPayload); + const tavilyUpstream = getTavilyUpstreamSummary(tavilyPayload); + const tavilyKeysActive = Number(tavilyPayload.keys_active || 0); + const tavilyKeysTotal = Number(tavilyPayload.keys_total || 0); + const socialState = getSocialUpstreamState(social || {}); + const cards = []; + + if (tavilyState.effectiveMode === 'upstream') { + cards.push({ + label: 'Tavily', + tone: tavilyState.upstreamConfigured ? 'ok' : 'danger', + title: tavilyState.upstreamConfigured ? '上游 Gateway' : '待配置上游', + desc: tavilyState.upstreamConfigured + ? (tavilyUpstream.available + ? `上游活跃 ${fmtNum(tavilyUpstream.activeKeys)} / 总 ${fmtNum(tavilyUpstream.totalKeys)} · 剩余 ${fmtNum(tavilyUpstream.totalQuotaRemaining)}` + : `${tavilyModeSourceLabel(tavilyState.modeSource)} · 当前直接转发 /api/search,也可回退本地池`) + : '当前已切上游模式,但还没有可用的上游凭证', + }); + } else if (tavilyKeysActive > 0) { + cards.push({ + label: 'Tavily', + tone: 'ok', + title: `API Key 池 · ${fmtNum(tavilyKeysActive)} Key`, + desc: tavilyKeysTotal > tavilyKeysActive + ? `活跃 ${fmtNum(tavilyKeysActive)} / 总数 ${fmtNum(tavilyKeysTotal)}` + : `${tavilyModeSourceLabel(tavilyState.modeSource)} · 默认从本地池轮询,也可切上游 Gateway`, + }); + } else if (tavilyKeysTotal > 0) { + cards.push({ + label: 'Tavily', + tone: 'warn', + title: 'Key 全部停用', + desc: `已导入 ${fmtNum(tavilyKeysTotal)} 个 Key,但当前没有活跃 Key;也可以直接改走上游 Gateway`, + }); + } else { + cards.push({ + label: 'Tavily', + tone: 'danger', + title: '待配置上游 / 待导入 Key', + desc: 'Tavily 现在既可配置上游 Gateway,也可直接导入 API Key;auto 会优先识别上游', + }); + } + + ['exa', 'firecrawl'].forEach((service) => { + const payload = services?.[service] || {}; + const active = Number(payload.keys_active || 0); + const total = Number(payload.keys_total || 0); + const remaining = service === 'exa' + ? null + : Number(payload.real_quota?.total_remaining ?? Number.NaN); + if (total <= 0) { + cards.push({ + label: getServiceDisplayLabel(service), + tone: 'danger', + title: '待导入 Key', + desc: service === 'exa' + ? '导入后即可启用独立网页发现路由' + : '导入后即可启用正文抓取与抽取链路', + }); + return; + } + if (active <= 0) { + cards.push({ + label: getServiceDisplayLabel(service), + tone: 'warn', + title: 'Key 全部停用', + desc: `已导入 ${fmtNum(total)} 个 Key,但当前没有活跃 Key`, + }); + return; + } + if (Number.isFinite(remaining) && remaining <= 100) { + cards.push({ + label: getServiceDisplayLabel(service), + tone: 'warn', + title: `额度偏低 · ${fmtNum(remaining)}`, + desc: `活跃 ${fmtNum(active)} / 总数 ${fmtNum(total)}`, + }); + return; + } + cards.push({ + label: getServiceDisplayLabel(service), + tone: 'ok', + title: service === 'exa' ? '独立搜索池' : '抽取线路就绪', + desc: `活跃 ${fmtNum(active)} / 总数 ${fmtNum(total)}`, + }); + }); + + if (social?.admin_connected) { + cards.push({ + label: 'Social / X', + tone: 'ok', + title: '后台自动继承', + desc: `${socialTokenSourceLabel(social?.token_source || '')} · /social/search 已就绪`, + }); + } else if (socialState.canProxySearch) { + cards.push({ + label: 'Social / X', + tone: 'warn', + title: '已可转发搜索', + desc: `上游 key ${fmtNum(socialState.upstreamApiKeyCount)} · 客户端 token ${fmtNum(socialState.acceptedTokenCount)} · 后台统计未接通`, + }); + } else { + cards.push({ + label: 'Social / X', + tone: 'danger', + title: '待配置上游', + desc: '补 grok2api 后台或兼容上游后,统一 token 会自动复用', + }); + } + + return cards; +} + +function getQuickstartInstallHint(tokenCount, routeCards) { + const readyProviders = routeCards.filter((card) => card.tone === 'ok').map((card) => card.label); + const pendingProviders = routeCards.filter((card) => card.tone !== 'ok').map((card) => card.label); + if (!tokenCount) { + return { + title: '先创建通用 token', + detail: '创建后控制台会立刻刷新可复制的 .env,并把当前 provider 接线结果一起写进去。', + }; + } + if (pendingProviders.length) { + return { + title: `先接入 ${readyProviders.join(' / ') || '已就绪路由'}`, + detail: `${pendingProviders.join(' / ')} 还没完全接通,但后续补线后会自动复用同一个通用 token。`, + }; + } + return { + title: '复制 .env → 执行 ./install.sh → 验收 mysearch_health', + detail: '当前统一 token 已可覆盖控制台里的全部路由,按最短路径安装即可完成接入。', + }; +} + +function getWorkspaceSnapshot(service, services, social) { + if (service === 'social') { + const stats = social?.stats || {}; + const socialState = getSocialUpstreamState(social || {}); + const hasFullStats = socialState.level === 'full'; + return { + keysTotal: hasFullStats ? Number(stats.token_total || 0) : socialState.acceptedTokenCount, + keysActive: hasFullStats ? Number(stats.token_normal || 0) : socialState.upstreamApiKeyCount, + tokensCount: hasFullStats ? Number(stats.token_total || 0) : socialState.acceptedTokenCount, + todayCount: hasFullStats ? Number(stats.total_calls || 0) : 0, + remaining: hasFullStats ? Number(stats.chat_remaining || 0) : null, + remainingLabel: hasFullStats ? 'Chat 剩余' : '客户端 Token', + primaryMetricLabel: hasFullStats ? '正常 Token' : '上游 Key', + primaryMetricValue: hasFullStats ? Number(stats.token_normal || 0) : socialState.upstreamApiKeyCount, + quaternaryMetricLabel: hasFullStats ? 'Chat 剩余' : '客户端 Token', + quaternaryMetricValue: hasFullStats ? Number(stats.chat_remaining || 0) : socialState.acceptedTokenCount, + modeLabel: socialModeLabel(social?.mode || 'manual'), + }; + } + + const payload = services?.[service] || {}; + const quota = payload.real_quota || {}; + const tavilyState = service === 'tavily' ? getTavilyRuntimeState(payload) : null; + const tavilyUpstream = service === 'tavily' ? getTavilyUpstreamSummary(payload) : null; + const tavilyUsingUpstream = tavilyState?.effectiveMode === 'upstream'; + return { + keysTotal: tavilyUsingUpstream + ? (tavilyUpstream?.available ? tavilyUpstream.totalKeys : (tavilyState.upstreamConfigured ? 1 : 0)) + : Number(payload.keys_total || 0), + keysActive: tavilyUsingUpstream + ? (tavilyUpstream?.available ? tavilyUpstream.activeKeys : (tavilyState.upstreamConfigured ? 1 : 0)) + : Number(payload.keys_active || 0), + tokensCount: Number((payload.tokens || []).length), + todayCount: Number(payload.overview?.today_count || 0), + remaining: service === 'exa' || tavilyUsingUpstream + ? (tavilyUsingUpstream && tavilyUpstream?.available ? tavilyUpstream.totalQuotaRemaining : null) + : (quota.total_remaining ?? null), + remainingLabel: tavilyUsingUpstream + ? '上游剩余' + : (service === 'exa' ? '实时额度' : '真实剩余'), + primaryMetricLabel: tavilyUsingUpstream ? '上游活跃 Key' : '活跃 Key', + primaryMetricValue: tavilyUsingUpstream + ? (tavilyUpstream?.available ? tavilyUpstream.activeKeys : (tavilyState.upstreamConfigured ? 1 : 0)) + : Number(payload.keys_active || 0), + quaternaryMetricLabel: tavilyUsingUpstream + ? (tavilyUpstream?.available ? '上游剩余' : '本地 Key') + : (service === 'exa' ? '实时额度' : '真实剩余'), + quaternaryMetricValue: tavilyUsingUpstream + ? (tavilyUpstream?.available ? tavilyUpstream.totalQuotaRemaining : tavilyState.localKeyCount) + : (service === 'exa' ? null : (quota.total_remaining ?? null)), + modeLabel: service === 'tavily' + ? tavilyModeLabel(tavilyState.effectiveMode) + : '独立池', + }; +} + +function workspaceSignal(service, services, social) { + const snapshot = getWorkspaceSnapshot(service, services, social); + const meta = WORKSPACE_META[service] || {}; + + if (service === 'social') { + if (!(social?.admin_connected || social?.upstream_key_configured)) { + return { + tone: 'danger', + label: '待配置', + summary: `${meta.label} 还没有接通上游兼容路由。`, + snapshot, + }; + } + if (snapshot.keysActive <= 0) { + return { + tone: 'warn', + label: '需要关注', + summary: `${meta.label} 已接通,但当前没有正常 token。`, + snapshot, + }; + } + return { + tone: 'ok', + label: '运行中', + summary: `${meta.label} 已接通,可直接向外转发 /social/search。`, + snapshot, + }; + } + + if (service === 'tavily') { + const tavilyState = getTavilyRuntimeState(services?.[service] || {}); + if (tavilyState.effectiveMode === 'upstream') { + if (!tavilyState.upstreamConfigured) { + return { + tone: 'danger', + label: '待配置上游', + summary: `${meta.label} 当前切到了上游模式,但还没有可用的上游凭证。`, + snapshot, + }; + } + return { + tone: 'ok', + label: '上游转发中', + summary: `${meta.label} 当前通过上游 Gateway 转发;本地 API Key 池只作为备用库存。`, + snapshot, + }; + } + } + + if (snapshot.keysTotal <= 0) { + return { + tone: 'danger', + label: '待导入 Key', + summary: service === 'tavily' + ? `${meta.label} 还没有接通;你可以配置上游 Gateway,也可以直接导入 API Key 池。` + : `${meta.label} 还没有导入可用 Key。`, + snapshot, + }; + } + + if (snapshot.keysActive <= 0) { + return { + tone: 'warn', + label: 'Key 全部停用', + summary: `${meta.label} 当前没有活跃 Key,请先启用或重新导入。`, + snapshot, + }; + } + + if (snapshot.remaining !== null && Number.isFinite(Number(snapshot.remaining)) && Number(snapshot.remaining) <= 100) { + return { + tone: 'warn', + label: '额度偏低', + summary: `${meta.label} 剩余额度较低,建议尽快同步或补充 Key。`, + snapshot, + }; + } + + return { + tone: 'ok', + label: '运行中', + summary: `${meta.label} 当前工作台状态稳定,可以继续签发 Token 或同步额度。`, + snapshot, + }; +} + +function renderHeroFocus(services, social) { + const root = document.getElementById('hero-focus'); + if (!root) return; + + const signal = workspaceSignal(activeService, services, social); + const snapshot = signal.snapshot; + const meta = WORKSPACE_META[activeService] || {}; + const focusName = root.querySelector('.hero-focus-name'); + const focusStatus = root.querySelector('.hero-focus-status'); + const focusDesc = root.querySelector('.hero-focus-desc'); + const focusStamp = document.getElementById('hero-focus-stamp'); + const signalDot = document.getElementById('hero-focus-signal'); + const metrics = document.getElementById('hero-focus-metrics'); + + if (focusName) focusName.textContent = meta.label || '未知工作台'; + if (focusStatus) { + focusStatus.textContent = signal.label; + focusStatus.className = `hero-focus-status is-${signal.tone}`; + } + if (focusDesc) { + focusDesc.textContent = `${signal.summary} 当前模式:${snapshot.modeLabel}。`; + } + if (focusStamp) { + focusStamp.textContent = latestStatsMeta.generated_at + ? `最近刷新 ${formatTime(latestStatsMeta.generated_at)}` + : '等待刷新'; + } + if (signalDot) { + signalDot.className = `hero-focus-signal is-${signal.tone}`; + } + if (metrics) { + const metricOneLabel = snapshot.primaryMetricLabel || (activeService === 'social' ? '正常 Token' : '活跃 Key'); + const metricOneValue = snapshot.primaryMetricValue ?? snapshot.keysActive; + const metricFourLabel = snapshot.quaternaryMetricLabel || snapshot.remainingLabel; + const metricFourValue = snapshot.quaternaryMetricValue ?? snapshot.remaining; + metrics.innerHTML = ` +
+ ${metricOneLabel} + ${fmtNum(metricOneValue)} +
+
+ Token + ${fmtNum(snapshot.tokensCount)} +
+
+ ${metricFourLabel} + ${metricFourValue === null ? '暂不可查' : fmtNum(metricFourValue)} +
+
+ ${activeService === 'social' ? '总调用' : '今日调用'} + ${fmtNum(snapshot.todayCount)} +
+ `; + } +} + function buildSocialProxyEnv(social) { const baseUrl = social.upstream_base_url || 'https://media.example.com/v1'; const adminBaseUrl = social.admin_base_url || baseUrl.replace(/\/v1$/, ''); @@ -221,14 +1387,21 @@ MYSEARCH_XAI_API_KEY=YOUR_MYSEARCH_PROXY_TOKEN`; function buildMySearchEnv(mysearch, social) { const baseUrl = location.origin; const token = mysearch?.tokens?.[0]?.token || 'YOUR_MYSEARCH_PROXY_TOKEN'; + const routeCards = getQuickstartProviderCards(latestServices, social || {}); + const readyProviders = routeCards.filter((card) => card.tone === 'ok').map((card) => card.label); + const pendingProviders = routeCards.filter((card) => card.tone !== 'ok').map((card) => `${card.label}: ${card.title}`); const socialReady = social?.admin_connected || social?.upstream_key_configured; return `# 最省事的接法:只填这两项,MySearch 会默认走当前 proxy MYSEARCH_PROXY_BASE_URL=${baseUrl} MYSEARCH_PROXY_API_KEY=${token} +# 当前路由状态: +${routeCards.map((card) => `# - ${card.label}: ${card.title}${card.desc ? ` · ${card.desc}` : ''}`).join('\n')} + # 说明: -# - 这一个 token 会同时允许 Tavily / Firecrawl / Exa${socialReady ? ' / Social' : ''} +# - 当前已就绪 provider:${readyProviders.length ? readyProviders.join(' / ') : '暂无,先在控制台接线'} # - Social / X ${socialReady ? '当前已接通,会默认复用同一个 token' : '当前还没完全接通,后续接好后也会自动复用同一个 token'} +# - ${pendingProviders.length ? `仍需关注:${pendingProviders.join(';')}` : '当前统一 token 已可覆盖控制台里的所有路由'} # 可选:如果你想把 MCP 额外暴露成远程 HTTP,再补这一段 # MYSEARCH_MCP_HOST=0.0.0.0 @@ -254,6 +1427,7 @@ cp mysearch/.env.example mysearch/.env function renderSocialBoard(social) { const stats = social?.stats || {}; + const socialState = getSocialUpstreamState(social || {}); const mode = socialModeLabel(social?.mode || 'manual'); const statusText = socialStatusLabel(social); const tokenSource = socialTokenSourceLabel(social?.token_source || ''); @@ -271,6 +1445,61 @@ function renderSocialBoard(social) { ? `` : ''; + if (socialState.level !== 'full') { + document.getElementById('social-board').innerHTML = ` + + + + + + ${errorLine} + `; + return; + } + document.getElementById('social-board').innerHTML = `
+
`; @@ -504,6 +1864,218 @@ function renderMySearchQuickstart(mysearch, social) { document.getElementById('mysearch-proxy-env').textContent = buildMySearchEnv(mysearch || {}, social || {}); document.getElementById('mysearch-install-cmd').textContent = buildMySearchInstall(); renderTokens('mysearch', tokens); + renderPoolGlance('mysearch', mysearch || {}); +} + +async function createMySearchBootstrapToken(button) { + await runWithBusyButton(button, { + busyLabel: '创建中...', + successLabel: '已创建', + errorLabel: '创建失败', + minBusyMs: 560, + }, async () => { + await api('POST', '/api/tokens', { + service: 'mysearch', + name: 'MySearch General Token', + }); + }); + await sleep(180); + showToast('已创建 MySearch 通用 token,下面的 .env 已自动更新。', 'success'); + await refresh({ force: true, scope: getRefreshScopeForService('mysearch') }); + const envBlock = document.getElementById('mysearch-proxy-env'); + if (envBlock) { + envBlock.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } +} + +function collectTavilySettingsForm() { + const body = { + mode: document.getElementById('settings-tavily-mode').value, + upstream_base_url: document.getElementById('settings-tavily-upstream-base-url').value.trim(), + upstream_search_path: document.getElementById('settings-tavily-upstream-search-path').value.trim(), + upstream_extract_path: document.getElementById('settings-tavily-upstream-extract-path').value.trim(), + }; + const upstreamApiKey = document.getElementById('settings-tavily-upstream-api-key').value.trim(); + if (upstreamApiKey) body.upstream_api_key = upstreamApiKey; + return body; +} + +function collectSocialSettingsForm() { + const body = { + upstream_base_url: document.getElementById('settings-social-upstream-base-url').value.trim(), + upstream_responses_path: document.getElementById('settings-social-upstream-responses-path').value.trim(), + admin_base_url: document.getElementById('settings-social-admin-base-url').value.trim(), + admin_verify_path: document.getElementById('settings-social-admin-verify-path').value.trim(), + admin_config_path: document.getElementById('settings-social-admin-config-path').value.trim(), + admin_tokens_path: document.getElementById('settings-social-admin-tokens-path').value.trim(), + model: document.getElementById('settings-social-model').value.trim(), + fallback_model: document.getElementById('settings-social-fallback-model').value.trim(), + cache_ttl_seconds: document.getElementById('settings-social-cache-ttl-seconds').value.trim(), + fallback_min_results: document.getElementById('settings-social-fallback-min-results').value.trim(), + }; + + const adminAppKey = document.getElementById('settings-social-admin-app-key').value.trim(); + const upstreamApiKey = document.getElementById('settings-social-upstream-api-key').value.trim(); + const gatewayToken = document.getElementById('settings-social-gateway-token').value.trim(); + + if (adminAppKey) body.admin_app_key = adminAppKey; + if (upstreamApiKey) body.upstream_api_key = upstreamApiKey; + if (gatewayToken) body.gateway_token = gatewayToken; + return body; +} + +function renderSettingsProbeMessage(kind, payload = {}) { + if (kind === 'tavily') { + const mode = payload.effective_mode === 'upstream' ? '上游 Gateway' : 'API Key 池'; + const detail = payload.detail || payload.summary || '诊断已完成。'; + return `Tavily ${payload.ok ? '接线正常' : '接线失败'}:当前实际 ${mode},${detail}`; + } + const detail = payload.detail || payload.token_source || '诊断已完成。'; + return `Social / X ${payload.ok ? '接线正常' : '接线失败'}:${detail}`; +} + +function clearSettingsProbe(kind) { + const shell = document.getElementById(`settings-${kind}-probe`); + if (!shell) return; + shell.innerHTML = ''; + shell.classList.add('hidden'); + shell.classList.remove('is-error'); +} + +function getSettingsProbeMeta(kind, payload = {}) { + if (kind === 'tavily') { + const mode = payload.effective_mode === 'upstream' ? '上游 Gateway' : 'API Key 池'; + const requestTarget = payload.request_target || payload.probe_url || '未配置'; + const authSource = payload.auth_source || (payload.effective_mode === 'upstream' + ? '上游 API key / Gateway token' + : `本地 API Key 池(活跃 ${fmtNum(payload.local_key_count || 0)})`); + const returnStatus = payload.status_label || (payload.status_code ? `HTTP ${payload.status_code}` : '未执行 live probe'); + const failureReason = payload.ok ? '无' : (payload.failure_reason || payload.error || payload.detail || '未通过诊断'); + let recommendation = payload.recommendation || ''; + if (!recommendation) { + if (payload.ok) { + recommendation = payload.effective_mode === 'upstream' + ? '当前链路可用;如果想固定行为,可以保持 upstream,或者切回 auto 让控制台自动识别。' + : '当前本地 API Key 池可用;如果想统一上游维护,可以继续配置 Tavily Gateway。'; + } else if ((payload.local_key_count || 0) <= 0 && payload.effective_mode !== 'upstream') { + recommendation = '导入至少一个 Tavily API Key,或者改成上游 Gateway 并补上凭证。'; + } else if (payload.effective_mode === 'upstream') { + recommendation = '检查上游 Base URL、Search Path 和上游 token 是否有效。'; + } else { + recommendation = '检查本地 API Key 是否可用,必要时切到上游 Gateway 做统一接线。'; + } + } + return { + tone: payload.ok ? 'ok' : 'error', + title: `Tavily ${payload.ok ? '测试通过' : '测试失败'}`, + eyebrow: 'Latest Probe', + pills: [mode, tavilyModeSourceLabel(payload.mode_source || 'auto_pending')], + items: [ + { label: '请求目标', value: requestTarget, mono: true }, + { label: '鉴权来源', value: authSource }, + { label: '返回状态', value: returnStatus }, + { label: '失败原因', value: failureReason }, + { label: '建议动作', value: recommendation, wide: true }, + ], + }; + } + + const requestTarget = payload.request_target || `${payload.upstream_base_url || '未配置'}${payload.upstream_responses_path || '/responses'}`; + const authSource = payload.auth_source || payload.token_source || '未解析到可用鉴权'; + const returnStatus = payload.status_label || (payload.admin_connected ? '后台已连通' : (payload.ok ? '已解析到可用凭证' : '诊断失败')); + const failureReason = payload.ok ? '无' : (payload.failure_reason || payload.error || payload.detail || '未通过诊断'); + let recommendation = payload.recommendation || ''; + if (!recommendation) { + if (payload.ok && payload.admin_connected) { + recommendation = '当前后台自动继承正常,可以直接下发 MySearch 通用 token 给客户端。'; + } else if (payload.ok) { + recommendation = '当前已能转发 Social / X 搜索;如果要更完整的 token 元数据,继续补 grok2api 后台。'; + } else { + recommendation = '优先检查 grok2api 后台地址与 app key;如果没有后台,再补手动上游 key 和客户端 token。'; + } + } + return { + tone: payload.ok ? 'ok' : 'error', + title: `Social / X ${payload.ok ? '测试通过' : '测试失败'}`, + eyebrow: 'Latest Probe', + pills: [socialModeLabel(payload.mode || 'manual'), socialTokenSourceLabel(payload.token_source || '')], + items: [ + { label: '请求目标', value: requestTarget, mono: true }, + { label: '鉴权来源', value: authSource }, + { label: '返回状态', value: returnStatus }, + { label: '失败原因', value: failureReason }, + { label: '建议动作', value: recommendation, wide: true }, + ], + }; +} + +function renderSettingsProbe(kind, payload = {}) { + const shell = document.getElementById(`settings-${kind}-probe`); + if (!shell) return; + const meta = getSettingsProbeMeta(kind, payload); + shell.classList.remove('hidden'); + shell.classList.toggle('is-error', meta.tone === 'error'); + shell.innerHTML = ` +
+
+
${escapeHtml(meta.eyebrow)}
+ ${escapeHtml(meta.title)} +
+
+ ${meta.pills.map((pill) => `${escapeHtml(pill)}`).join('')} +
+
+
+ ${meta.items.map((item) => ` +
+
${escapeHtml(item.label)}
+
${escapeHtml(item.value)}
+
+ `).join('')} +
+ `; +} + +async function testTavilySettings(button) { + setStatus('settings-tavily-status', ''); + clearSettingsProbe('tavily'); + try { + await runWithBusyButton(button, { + busyLabel: '测试中...', + successLabel: '测试通过', + errorLabel: '测试失败', + minBusyMs: 640, + }, async () => { + const payload = await api('POST', '/api/settings/test/tavily', collectTavilySettingsForm()); + renderSettingsProbe('tavily', payload); + setStatus('settings-tavily-status', renderSettingsProbeMessage('tavily', payload), !payload.ok); + showToast(payload.ok ? 'Tavily 测试通过' : 'Tavily 测试失败', payload.ok ? 'success' : 'warn'); + }); + } catch (error) { + clearSettingsProbe('tavily'); + setStatus('settings-tavily-status', `Tavily 测试失败:${error.message}`, true); + } +} + +async function testSocialSettings(button) { + setStatus('settings-social-status', ''); + clearSettingsProbe('social'); + try { + await runWithBusyButton(button, { + busyLabel: '测试中...', + successLabel: '测试通过', + errorLabel: '测试失败', + minBusyMs: 640, + }, async () => { + const payload = await api('POST', '/api/settings/test/social', collectSocialSettingsForm()); + renderSettingsProbe('social', payload); + setStatus('settings-social-status', renderSettingsProbeMessage('social', payload), !payload.ok); + showToast(payload.ok ? 'Social / X 测试通过' : 'Social / X 测试失败', payload.ok ? 'success' : 'warn'); + }); + } catch (error) { + clearSettingsProbe('social'); + setStatus('settings-social-status', `Social / X 测试失败:${error.message}`, true); + } } function headers() { @@ -562,8 +2134,63 @@ function describeConfiguredSecret(masked, configured) { return `当前已配置 ${masked || 'secret'},留空表示保持不变。`; } +function setTavilyMode(mode) { + const nextMode = ['auto', 'pool', 'upstream'].includes(mode) ? mode : 'auto'; + const input = document.getElementById('settings-tavily-mode'); + if (input) { + input.value = nextMode; + } + document.querySelectorAll('.mode-switch-btn[data-tavily-mode]').forEach((button) => { + const active = button.dataset.tavilyMode === nextMode; + button.classList.toggle('is-active', active); + button.setAttribute('aria-selected', active ? 'true' : 'false'); + button.setAttribute('tabindex', active ? '0' : '-1'); + }); + const tavily = latestSettings?.tavily || {}; + const runtimeMode = nextMode === 'auto' + ? (tavily.effective_mode || (tavily.upstream_api_key_configured ? 'upstream' : ((tavily.local_key_count || 0) > 0 ? 'pool' : 'auto'))) + : nextMode; + const runtimeSource = nextMode === 'auto' + ? (tavily.mode_source || (tavily.upstream_api_key_configured ? 'auto_upstream' : ((tavily.local_key_count || 0) > 0 ? 'auto_pool' : 'auto_pending'))) + : (nextMode === 'upstream' ? 'manual_upstream' : 'manual_pool'); + const hint = document.getElementById('settings-tavily-mode-hint'); + if (hint) { + if (nextMode === 'upstream') { + hint.textContent = '手动固定到上游 Gateway,请求不再消耗本地 API Key 池。'; + } else if (nextMode === 'pool') { + hint.textContent = '手动固定到 API Key 池,请求会从导入的 Tavily keys 中轮询。'; + } else { + hint.textContent = '自动模式会先检测上游凭证;如果你只是导入 Tavily key,就会默认回到 API Key 池。'; + } + } + const runtimeStrip = document.getElementById('settings-tavily-runtime-strip'); + if (runtimeStrip) { + runtimeStrip.textContent = `当前实际:${tavilyModeLabel(runtimeMode)} · ${tavilyModeSourceLabel(runtimeSource)}`; + } + document.querySelectorAll('[data-tavily-upstream-field]').forEach((field) => { + field.classList.toggle('is-muted', nextMode === 'pool'); + field.classList.toggle('is-emphasis', nextMode !== 'pool'); + }); +} + function fillSettingsForm(settings) { + const tavily = settings?.tavily || {}; const social = settings?.social || {}; + setTavilyMode(tavily.mode || 'auto'); + document.getElementById('settings-tavily-upstream-base-url').value = tavily.upstream_base_url || ''; + document.getElementById('settings-tavily-upstream-search-path').value = tavily.upstream_search_path || '/search'; + document.getElementById('settings-tavily-upstream-extract-path').value = tavily.upstream_extract_path || '/extract'; + document.getElementById('settings-tavily-upstream-api-key').value = ''; + document.getElementById('settings-tavily-upstream-api-key-hint').textContent = + describeConfiguredSecret(tavily.upstream_api_key_masked, tavily.upstream_api_key_configured); + document.getElementById('settings-tavily-meta').textContent = [ + `配置模式:${tavilyModeLabel(tavily.mode || 'auto')}`, + `当前实际:${tavilyModeLabel(tavily.effective_mode || tavily.mode || 'auto')}`, + `来源:${tavilyModeSourceLabel(tavily.mode_source || 'auto_pending')}`, + tavily.upstream_base_url ? `Base URL:${tavily.upstream_base_url}` : '', + tavily.upstream_api_key_configured ? '已配置上游凭证' : `本地活跃 Key ${fmtNum(tavily.local_key_count || 0)}`, + ].filter(Boolean).join(' · '); + document.getElementById('settings-social-upstream-base-url').value = social.upstream_base_url || ''; document.getElementById('settings-social-upstream-responses-path').value = social.upstream_responses_path || '/responses'; document.getElementById('settings-social-admin-base-url').value = social.admin_base_url || ''; @@ -597,6 +2224,7 @@ function fillSettingsForm(settings) { bits.push(`最近错误:${social.error}`); } document.getElementById('settings-social-meta').textContent = bits.join(' · '); + renderSettingsSummaries(settings); } async function loadSettings() { @@ -604,22 +2232,34 @@ async function loadSettings() { latestSettings = payload || {}; fillSettingsForm(latestSettings); setStatus('settings-password-status', ''); + setStatus('settings-tavily-status', ''); setStatus('settings-social-status', ''); + clearSettingsProbe('tavily'); + clearSettingsProbe('social'); } async function openSettingsModal() { + rememberOverlayFocus('settings-modal'); document.getElementById('settings-modal').classList.remove('hidden'); - document.body.classList.add('modal-open'); + setActiveSettingsTab(document.querySelector('.settings-tab.is-active')?.dataset.settingsTab || 'console'); + syncOverlayState(); + focusOverlay('settings-modal'); try { await loadSettings(); } catch (error) { - setStatus('settings-social-status', `读取设置失败:${error.message}`, true); + showAlertDialog({ + title: '读取设置失败', + message: `控制台没能读取当前配置:${error.message}`, + tone: 'danger', + kicker: 'Settings Error', + }); } } function closeSettingsModal() { document.getElementById('settings-modal').classList.add('hidden'); - document.body.classList.remove('modal-open'); + syncOverlayState(); + restoreOverlayFocus('settings-modal'); } function logoutFromSettings() { @@ -628,25 +2268,32 @@ function logoutFromSettings() { } function renderServiceShells() { + const servicesRoot = document.getElementById('services-root'); + if (!servicesRoot) return; const providerHtml = Object.keys(SERVICE_META).map((service) => { const meta = SERVICE_META[service]; return `
-
+
${meta.label} -

${meta.label} 栏目

+

${meta.label} 工作台

${meta.panelIntro} 账号前缀 ${meta.emailPrefix},代理 Token 前缀 ${meta.tokenPrefix}。${meta.quotaSource}。

+
+ Route + ${meta.routeHint} +
-
-
等待同步状态...
- +
+
Live Status
+
等待同步状态...
+
-
+

调用方式

${meta.routeHint}

@@ -661,62 +2308,147 @@ function renderServiceShells() {

             
-
-

Token 池

-

${meta.tokenPoolDesc}

-
- - +
+
+

接线摘要

+ 摘要优先
-
- - - - - - - - - - - -
Token备注配额 / 剩余代理统计操作
-
- -
- -
-

API Key 池

-

${meta.keyPoolDesc}

-
- - - -
-
`; @@ -725,12 +2457,17 @@ function renderServiceShells() { const socialHtml = `
-
+
Social / X -

Social / X 栏目

+

Social / X 工作台

这里收口的是 X / Social 搜索路由,不再把底层实现名字放成主标题。你看到的是 MySearch 的 Social 工作台,底层可以复用 grok2api 后台,也可以兼容别的 xAI-compatible 上游。

+
+ Route + 代理端点: POST /social/search +
+
Live Status
等待 Social 状态...
用于查看 token 池、剩余额度、调用次数和客户端接线方式。
@@ -745,7 +2482,7 @@ function renderServiceShells() {
`; - document.getElementById('services-root').innerHTML = providerHtml + socialHtml; + servicesRoot.innerHTML = providerHtml + socialHtml; renderSocialBoard({}); renderSocialIntegration({}); renderSocialWorkspace({}); @@ -754,26 +2491,20 @@ function renderServiceShells() { } function renderServiceSwitcher(services, social) { + const root = document.getElementById('service-switcher'); + if (!root) return; const html = Object.entries(WORKSPACE_META).map(([service, meta]) => { const isSocial = service === 'social'; - const payload = isSocial ? {} : (services?.[service] || {}); - const quota = payload.real_quota || {}; - const socialStats = social?.stats || {}; - const remaining = isSocial ? (socialStats.chat_remaining || 0) : (quota.total_remaining ?? 0); - const activeKeys = isSocial ? (socialStats.token_normal || 0) : (payload.keys_active || 0); - const tokenCount = isSocial ? (socialStats.token_total || 0) : ((payload.tokens || []).length); - const todayCount = isSocial ? (socialStats.total_calls || 0) : (payload.overview?.today_count || 0); - const badgeList = meta.switcherBadges || [ - isSocial ? 'X Search' : `账号 ${meta.emailPrefix}`, - isSocial ? 'compatible' : `Token ${meta.tokenPrefix}`, - isSocial ? '自动继承' : '池子独立', - ]; + const snapshot = getWorkspaceSnapshot(service, services, social); + const signal = workspaceSignal(service, services, social); const foot = meta.switcherFoot || (isSocial ? '统一 Social 路由 + 统一输出结构' : '独立 Key 池 + 独立额度同步'); - const metricOneLabel = isSocial ? '可用 Token' : '活跃 Key'; - const metricTwoLabel = 'Token'; - const metricThreeLabel = isSocial ? '总调用' : '今日调用'; - const metricFourLabel = isSocial ? 'Chat 剩余' : (service === 'exa' ? '实时额度' : '真实剩余'); - const metricFourValue = isSocial ? fmtNum(remaining) : (service === 'exa' ? '暂不可查' : fmtNum(remaining)); + const metricOneLabel = snapshot.primaryMetricLabel || (isSocial ? '可用 Token' : '活跃 Key'); + const metricTwoLabel = isSocial ? '今日调用' : 'Token'; + const metricTwoValue = isSocial ? fmtNum(snapshot.todayCount) : fmtNum(snapshot.tokensCount); + const badge = (meta.switcherBadges || [isSocial ? 'X Search' : '池状态'])[0]; + const footnote = snapshot.remaining !== null && snapshot.remaining !== undefined + ? `${snapshot.remainingLabel} ${fmtNum(snapshot.remaining)}` + : foot; return ` `; }).join(''); - document.getElementById('service-switcher').innerHTML = html; + root.innerHTML = html; } function applyActiveService() { @@ -840,11 +2565,15 @@ function applyActiveService() { item.setAttribute('aria-pressed', isActive ? 'true' : 'false'); const status = item.querySelector('.service-toggle-status'); if (status) { - status.textContent = isActive ? '当前查看' : '点击切换'; + const label = status.querySelector('span:last-child'); + if (label) { + const signal = workspaceSignal(item.dataset.service, latestServices, latestSocial); + label.textContent = signal.label; + } } - const arrow = item.querySelector('.service-toggle-arrow'); - if (arrow) { - arrow.textContent = isActive ? '正在查看 →' : '切换到此工作台 →'; + const flag = item.querySelector('.service-toggle-flag'); + if (flag) { + flag.classList.toggle('hidden', !isActive); } }); @@ -854,11 +2583,36 @@ function applyActiveService() { } } +function animateWorkspacePanel(service) { + const main = document.querySelector('.services-root'); + const panel = document.querySelector(`.service-panel[data-service="${service}"]`); + if (main) { + main.classList.remove('is-switching'); + void main.offsetWidth; + main.classList.add('is-switching'); + setTimeout(() => { + main.classList.remove('is-switching'); + }, 320); + } + if (panel) { + panel.classList.remove('is-activating'); + void panel.offsetWidth; + panel.classList.add('is-activating'); + setTimeout(() => { + panel.classList.remove('is-activating'); + }, 320); + } +} + function setActiveService(service) { if (!WORKSPACE_META[service]) return; activeService = service; localStorage.setItem(ACTIVE_SERVICE_KEY, service); applyActiveService(); + animateWorkspacePanel(service); + renderHeroFocus(latestServices, latestSocial); + renderGlobalSummary(latestServices, latestSocial); + renderSettingsSummaries(); const panel = document.querySelector(`.service-panel[data-service="${service}"]`); if (panel) { panel.scrollIntoView({ behavior: 'smooth', block: 'start' }); @@ -879,7 +2633,7 @@ function doLogin(event) { .then(async () => { PWD = ''; clearStoredPasswords(); - showDashboard(); + showDashboard({ animate: true }); await refresh(); }) .catch((error) => { @@ -898,6 +2652,8 @@ function logout() { clearStoredPasswords(); showLogin(); closeSettingsModal(); + closeDetailDrawer(); + closeAppDialog(false); fetch(API + '/api/session/logout', { method: 'POST', credentials: 'same-origin', @@ -993,42 +2749,54 @@ curl -X POST ${baseUrl}/api/search \\ } function renderGlobalSummary(services, social) { + const root = document.getElementById('global-summary'); + if (!root) return; const list = Object.values(services || {}); - const totalKeys = list.reduce((sum, item) => sum + Number(item.keys_total || 0), 0); - const totalTokens = list.reduce((sum, item) => sum + Number((item.tokens || []).length), 0); const todayCount = list.reduce((sum, item) => sum + Number(item.overview?.today_count || 0), 0); - const syncable = list.filter((item) => SERVICE_META[item.service]?.syncSupported !== false); - const realRemaining = syncable.reduce((sum, item) => sum + Number(item.real_quota?.total_remaining || 0), 0); - const syncedKeys = syncable.reduce((sum, item) => sum + Number(item.real_quota?.synced_keys || 0), 0); - const syncableLabels = syncable.map((item) => item.label).filter(Boolean).join(' / ') || '暂无'; - const socialStats = social?.stats || {}; - const socialMode = socialModeLabel(social?.mode || 'manual'); - - document.getElementById('global-summary').innerHTML = ` -
-
代理 Token
-
${fmtNum(totalTokens)}
-
${fmtNum(totalKeys)} 个 Key 已导入,按服务独立签发
+ const monthCount = list.reduce((sum, item) => sum + Number(item.overview?.month_count || 0), 0); + const activeSignal = workspaceSignal(activeService, services, social); + const activeMeta = WORKSPACE_META[activeService] || {}; + const routeCards = getQuickstartProviderCards(services, social); + const totalWorkspaces = routeCards.length; + const connectedWorkspaces = routeCards.filter((card) => card.tone !== 'danger').length; + const tavilyPayload = services?.tavily || {}; + const tavilyState = getTavilyRuntimeState(tavilyPayload); + const tavilyUsesUpstream = tavilyState.effectiveMode === 'upstream'; + const socialUsesUpstream = isSocialUpstreamManaged(social || {}); + const localProviderTokenSources = [ + ...(!tavilyUsesUpstream ? [{ label: 'Tavily', count: Number((tavilyPayload.tokens || []).length) }] : []), + { label: 'Exa', count: Number((services?.exa?.tokens || []).length) }, + { label: 'Firecrawl', count: Number((services?.firecrawl?.tokens || []).length) }, + ...(!socialUsesUpstream ? [{ label: 'Social / X', count: Number(getSocialUpstreamState(social || {}).acceptedTokenCount || 0) }] : []), + ]; + const localProviderTokenTotal = localProviderTokenSources.reduce((sum, item) => sum + Number(item.count || 0), 0); + const localProviderTokenLabels = localProviderTokenSources.map((item) => item.label); + + root.innerHTML = ` +
+
当前工作台
+
${escapeHtml(activeMeta.label || '未知')}
+
${escapeHtml(activeSignal.label)} · ${escapeHtml(activeSignal.snapshot.modeLabel)}
-
今日调用
-
${fmtNum(todayCount)}
-
来自本地 usage_logs 聚合
+
已接通工作台
+
${fmtNum(connectedWorkspaces)} / ${fmtNum(totalWorkspaces)}
+
按当前接线状态自动统计全部工作台
-
官方剩余
-
${fmtNum(realRemaining)}
-
已同步 ${fmtNum(syncedKeys)} 个 Key · ${escapeHtml(syncableLabels)}
+
Provider 代理 Token
+
${fmtNum(localProviderTokenTotal)}
+
${localProviderTokenLabels.length ? `${escapeHtml(localProviderTokenLabels.join(' / '))} 当前走本地代理池` : '当前没有启用本地 provider 代理池'}
-
Social / X
-
${fmtNum(socialStats.token_total || 0)}
-
${social?.admin_connected ? `模式 ${socialMode}` : '等待后台接通'}
+
今日调用
+
${fmtNum(todayCount)}
+
来自本地 usage_logs 聚合
-
Social Chat
-
${fmtNum(socialStats.chat_remaining || 0)}
-
Image ${fmtNum(socialStats.image_remaining || 0)} · 调用 ${fmtNum(socialStats.total_calls || 0)}
+
本月调用
+
${fmtNum(monthCount)}
+
来自本地 usage_logs 聚合,不含上游后台自己的历史请求总量
MySearch Token
@@ -1052,6 +2820,21 @@ function renderSyncMeta(service, payload) { const quota = payload.real_quota || {}; const usageSync = payload.usage_sync || {}; const parts = []; + const tavilyUpstream = service === 'tavily' ? getTavilyUpstreamSummary(payload) : null; + + if (service === 'tavily' && payload.routing?.effective_mode) { + parts.push(`配置 ${tavilyModeLabel(payload.routing.mode || 'auto')}`); + parts.push(`当前走 ${tavilyModeLabel(payload.routing.effective_mode)}`); + parts.push(tavilyModeSourceLabel(payload.routing.mode_source || 'auto_pending')); + if (payload.routing.effective_mode === 'upstream') { + if (tavilyUpstream?.available) { + parts.push(`上游活跃 ${fmtNum(tavilyUpstream.activeKeys)} / 总 ${fmtNum(tavilyUpstream.totalKeys)}`); + parts.push(`上游剩余 ${fmtNum(tavilyUpstream.totalQuotaRemaining)}`); + } else if (tavilyUpstream?.detail) { + parts.push(tavilyUpstream.detail); + } + } + } parts.push(`已同步 ${fmtNum(quota.synced_keys || 0)} / ${fmtNum(quota.total_keys || 0)} 个 Key`); if ((quota.key_level_count || 0) > 0) { @@ -1076,6 +2859,7 @@ function renderSyncMeta(service, payload) { function renderOverview(service, payload) { const overview = payload.overview || {}; const quota = payload.real_quota || {}; + const tavilyUpstream = service === 'tavily' ? getTavilyUpstreamSummary(payload) : null; if (service === 'exa') { const todayCount = Number(overview.today_count || 0); @@ -1117,6 +2901,50 @@ function renderOverview(service, payload) { return; } + if (service === 'tavily' && payload.routing?.effective_mode === 'upstream') { + const upstreamAvailable = Boolean(tavilyUpstream?.available); + const upstreamRemainStyle = upstreamAvailable && tavilyUpstream.totalQuotaLimit > 0 + ? (tavilyUpstream.totalQuotaRemaining / tavilyUpstream.totalQuotaLimit <= 0.1 + ? 'color: var(--danger)' + : tavilyUpstream.totalQuotaRemaining / tavilyUpstream.totalQuotaLimit <= 0.3 + ? 'color: var(--warn)' + : 'color: var(--ok)') + : ''; + document.getElementById(`overview-${service}`).innerHTML = ` +
+
上游 Key 状态
+
${upstreamAvailable ? `${fmtNum(tavilyUpstream.activeKeys)} / ${fmtNum(tavilyUpstream.totalKeys)}` : '未读取到'}
+
${upstreamAvailable ? `耗尽 ${fmtNum(tavilyUpstream.exhaustedKeys)} · 隔离 ${fmtNum(tavilyUpstream.quarantinedKeys)}` : escapeHtml(tavilyUpstream?.detail || '当前上游没有提供公开摘要接口。')}
+
+
+
上游剩余额度
+
${upstreamAvailable ? fmtNum(tavilyUpstream.totalQuotaRemaining) : '未读取到'}
+
${upstreamAvailable ? `上限 ${fmtNum(tavilyUpstream.totalQuotaLimit)} · 来自 Hikari 公共摘要` : '当前仍可继续使用本地池作为回退库存。'}
+
+
+
上游累计请求
+
${upstreamAvailable ? fmtNum(tavilyUpstream.totalRequests) : '未读取到'}
+
${upstreamAvailable ? `成功 ${fmtNum(tavilyUpstream.successCount)} · 错误 ${fmtNum(tavilyUpstream.errorCount)} · 配额耗尽 ${fmtNum(tavilyUpstream.quotaExhaustedCount)}` : '当前只确认了 Gateway 可转发,未拿到上游请求统计。'}
+
+
+
本地回退 Key
+
${fmtNum(payload.keys_active || 0)} / ${fmtNum(payload.keys_total || 0)}
+
这些 Key 在 Tavily upstream 模式下不参与转发,只作为回退库存保留。
+
+
+
今日代理调用
+
${fmtNum(overview.today_count || 0)}
+
成功 ${fmtNum(overview.today_success || 0)} / 失败 ${fmtNum(overview.today_failed || 0)}
+
+
+
工作模式
+
${escapeHtml(tavilyModeLabel(payload.routing?.effective_mode || 'upstream'))}
+
${escapeHtml(tavilyModeSourceLabel(payload.routing?.mode_source || 'auto_pending'))}
+
+ `; + return; + } + const totalLimit = Number(quota.total_limit || 0); const totalUsed = Number(quota.total_used || 0); const totalRemaining = Number(quota.total_remaining || 0); @@ -1162,20 +2990,54 @@ function renderOverview(service, payload) { function renderSocialWorkspace(social) { const stats = social?.stats || {}; + const socialState = getSocialUpstreamState(social || {}); const mode = socialModeLabel(social?.mode || 'manual'); const source = socialTokenSourceLabel(social?.token_source || ''); - const syncLine = [ - mode, - `Token ${fmtNum(stats.token_total || 0)}`, - `Chat ${fmtNum(stats.chat_remaining || 0)}`, - `总调用 ${fmtNum(stats.total_calls || 0)}`, - ].join(' · '); + const syncLine = socialState.level === 'full' + ? [ + mode, + `Token ${fmtNum(stats.token_total || 0)}`, + `Chat ${fmtNum(stats.chat_remaining || 0)}`, + `总调用 ${fmtNum(stats.total_calls || 0)}`, + ].join(' · ') + : [ + mode, + `上游 key ${fmtNum(socialState.upstreamApiKeyCount)}`, + `客户端 token ${fmtNum(socialState.acceptedTokenCount)}`, + socialState.canProxySearch ? '已可转发搜索' : '待补鉴权', + ].join(' · '); const syncMeta = document.getElementById('sync-meta-social'); if (syncMeta) { syncMeta.textContent = social?.error ? `${syncLine} · 最近错误 ${social.error}` : syncLine; } + if (socialState.level !== 'full') { + document.getElementById('overview-social').innerHTML = ` +
+
工作模式
+
${escapeHtml(mode)}
+
当前 Social / X 工作台的路由状态
+
+
+
上游 Key 数
+
${fmtNum(socialState.upstreamApiKeyCount)}
+
当前已解析到的上游 API key 数量
+
+
+
客户端 Token 数
+
${fmtNum(socialState.acceptedTokenCount)}
+
可被 /social/search 接受的客户端 token 数量
+
+
+
Token 来源
+
${escapeHtml(source)}
+
${escapeHtml(socialState.detail || '当前只有基础接线可视化,完整 token 统计需要后台 tokens 面板。')}
+
+ `; + return; + } + document.getElementById('overview-social').innerHTML = `
工作模式
@@ -1210,29 +3072,204 @@ function renderTokenQuota(token) { return '
无限制
已关闭小时 / 日 / 月限流
'; } +function renderGlanceCard(label, value, hint = '') { + return ` +
+
${escapeHtml(label)}
+
${escapeHtml(value)}
+
${escapeHtml(hint)}
+
+ `; +} + +function renderTableLegend(kind = 'token') { + const items = kind === 'key' + ? [ + ['danger', '同步异常'], + ['warn', '额度偏低 / 失败偏多'], + ['busy', '调用偏高'], + ['off', '已停用'], + ] + : [ + ['danger', '失败偏多'], + ['warn', '近期有失败'], + ['busy', '调用偏高'], + ]; + return ` +
+ ${items.map(([tone, label]) => ` + + + ${escapeHtml(label)} + + `).join('')} +
+ `; +} + +function renderDrawerActionGroup(title, body, tone = 'neutral') { + return ` +
+
${escapeHtml(title)}
+
${body}
+
+ `; +} + +function renderPoolGlance(service, payload = {}) { + const tokenRoot = document.getElementById(`token-glance-${service}`); + const tokens = payload?.tokens || []; + if (tokenRoot) { + const tokenStats = tokens.reduce((acc, token) => { + const stats = token.stats || {}; + acc.today += Number(stats.today_success || 0) + Number(stats.today_failed || 0); + acc.month += Number(stats.month_success || 0) + Number(stats.month_failed || 0); + acc.hour += Number(stats.hour_count || 0); + return acc; + }, { today: 0, month: 0, hour: 0 }); + tokenRoot.innerHTML = [ + renderGlanceCard('Token 总数', fmtNum(tokens.length), `${getServiceDisplayLabel(service)} 当前可发放的访问凭证`), + renderGlanceCard('今日调用', fmtNum(tokenStats.today), `小时 ${fmtNum(tokenStats.hour)} · 本月 ${fmtNum(tokenStats.month)}`), + renderGlanceCard('默认策略', '无限制', '当前保持开放限流策略'), + ].join(''); + } + + const keyRoot = document.getElementById(`key-glance-${service}`); + if (keyRoot) { + const keys = payload?.keys || []; + const activeKeys = keys.filter((key) => Number(key.active) === 1).length; + const syncedKeys = keys.filter((key) => Boolean(key.usage_synced_at)).length; + const erroredKeys = keys.filter((key) => Boolean(key.usage_sync_error)).length; + keyRoot.innerHTML = [ + renderGlanceCard('活跃 Key', fmtNum(activeKeys), `总数 ${fmtNum(keys.length)}`), + renderGlanceCard('已同步', fmtNum(syncedKeys), '已有官方或账户级额度信息'), + renderGlanceCard('同步异常', fmtNum(erroredKeys), erroredKeys ? '建议点击行检查失败原因' : '当前没有同步异常'), + ].join(''); + } +} + +function renderTokenSummary(token) { + const stats = token.stats || {}; + return ` +
今日 ${fmtNum(Number(stats.today_success || 0) + Number(stats.today_failed || 0))}
+
本月 ${fmtNum(Number(stats.month_success || 0) + Number(stats.month_failed || 0))}
+
小时 ${fmtNum(stats.hour_count || 0)}
+ `; +} + +function renderKeyStatusSummary(service, key) { + const active = Number(key.active) === 1; + const remain = key.usage_key_remaining ?? key.usage_account_remaining; + const remainLabel = remain === null || remain === undefined ? '剩余待同步' : `剩余 ${fmtNum(remain)}`; + return ` +
${active ? '正常' : '禁用'}
+
${remainLabel}
+
${key.usage_synced_at ? `同步 ${formatTime(key.usage_synced_at)}` : (service === 'exa' ? '实时额度暂不可查' : '尚未同步')}
+ `; +} + +function renderKeyUsageSummary(key) { + return ` +
成功 ${fmtNum(key.total_used || 0)}
+
失败 ${fmtNum(key.total_failed || 0)}
+
最近 ${formatTime(key.last_used_at)}
+ `; +} + +function syncTokenToolbar(service) { + const state = getTokenTableState(service); + const search = document.getElementById(`token-search-${service}`); + if (search && search.value !== state.search) { + search.value = state.search; + } + document.querySelectorAll(`[data-token-sort][data-service="${service}"]`).forEach((button) => { + const active = button.dataset.tokenSort === state.sort; + button.classList.toggle('is-active', active); + button.setAttribute('aria-pressed', active ? 'true' : 'false'); + button.setAttribute('tabindex', active ? '0' : '-1'); + }); +} + +function syncKeyToolbar(service) { + const state = getKeyTableState(service); + const search = document.getElementById(`key-search-${service}`); + if (search && search.value !== state.search) { + search.value = state.search; + } + document.querySelectorAll(`[data-key-filter][data-service="${service}"]`).forEach((button) => { + const active = button.dataset.keyFilter === state.filter; + button.classList.toggle('is-active', active); + button.setAttribute('aria-pressed', active ? 'true' : 'false'); + button.setAttribute('tabindex', active ? '0' : '-1'); + }); + document.querySelectorAll(`[data-key-sort][data-service="${service}"]`).forEach((button) => { + const active = button.dataset.keySort === state.sort; + button.classList.toggle('is-active', active); + button.setAttribute('aria-pressed', active ? 'true' : 'false'); + button.setAttribute('tabindex', active ? '0' : '-1'); + }); +} + +function setTokenSearch(service, value) { + getTokenTableState(service).search = value || ''; + renderTokens(service, getServicePayload(service).tokens || []); +} + +function setTokenSort(service, value) { + getTokenTableState(service).sort = value || 'risk'; + renderTokens(service, getServicePayload(service).tokens || []); +} + +function setKeySearch(service, value) { + getKeyTableState(service).search = value || ''; + renderKeys(service, getServicePayload(service).keys || []); +} + +function setKeyFilter(service, value) { + getKeyTableState(service).filter = value || 'all'; + renderKeys(service, getServicePayload(service).keys || []); +} + +function setKeySort(service, value) { + getKeyTableState(service).sort = value || 'risk'; + renderKeys(service, getServicePayload(service).keys || []); +} + function renderTokens(service, tokens) { const tbody = document.getElementById(`tokens-body-${service}`); + syncTokenToolbar(service); if (!tokens || tokens.length === 0) { - tbody.innerHTML = '当前还没有 Token,先创建一个给下游使用。'; + tbody.innerHTML = '当前还没有 Token,先创建一个给下游使用。'; + return; + } + + const filtered = getFilteredTokens(service, tokens); + if (!filtered.length) { + tbody.innerHTML = '没有符合当前筛选条件的 Token。'; return; } - tbody.innerHTML = tokens.map((token) => { - const stats = token.stats || {}; + tbody.innerHTML = filtered.map((token) => { + const rowClass = getTokenRowClass(token); return ` - - ${maskToken(token.token)} - ${escapeHtml(token.name || '-')} - ${renderTokenQuota(token)} - - 今日成功 ${fmtNum(stats.today_success || 0)} / 失败 ${fmtNum(stats.today_failed || 0)}
- 本月成功 ${fmtNum(stats.month_success || 0)}
- 小时调用 ${fmtNum(stats.hour_count || 0)} + + + ${maskToken(token.token)} +
点击查看详情
+ ${escapeHtml(token.name || '-')} + ${renderTokenSummary(token)}
- - + +
@@ -1295,30 +3332,43 @@ function renderAccountQuota(service, key) { function renderKeys(service, keys) { const tbody = document.getElementById(`keys-body-${service}`); + syncKeyToolbar(service); if (!keys || keys.length === 0) { - tbody.innerHTML = '当前服务还没有导入 Key。'; + tbody.innerHTML = '当前服务还没有导入 Key。'; + return; + } + + const filtered = getFilteredKeys(service, keys); + if (!filtered.length) { + tbody.innerHTML = '没有符合当前筛选条件的 Key。'; return; } - tbody.innerHTML = keys.map((key) => { + tbody.innerHTML = filtered.map((key) => { const active = Number(key.active) === 1; + const rowClass = getKeyRowClass(service, key); return ` - + ${fmtNum(key.id)} - ${escapeHtml(key.key_masked || key.key)} - ${escapeHtml(key.email || '-')} - ${renderKeyQuota(service, key)} - ${renderAccountQuota(service, key)} - - 成功 ${fmtNum(key.total_used || 0)}
- 失败 ${fmtNum(key.total_failed || 0)}
- 最近使用 ${formatTime(key.last_used_at)} + + ${escapeHtml(key.key_masked || key.key)} +
点击查看详情
+ ${escapeHtml(key.email || '-')} + ${renderKeyStatusSummary(service, key)} + ${renderKeyUsageSummary(key)} ${active ? '正常' : '禁用'}
- - + +
@@ -1326,118 +3376,256 @@ function renderKeys(service, keys) { }).join(''); } +function openTokenDetail(service, tokenId) { + const payload = getServicePayload(service); + const token = (payload?.tokens || []).find((item) => Number(item.id) === Number(tokenId)); + if (!token) { + showToast('没有找到这个 token 的最新数据。', 'warn'); + return; + } + const stats = token.stats || {}; + const label = getServiceDisplayLabel(service); + openDetailDrawer({ + kicker: `${label} Token`, + title: token.name || `${label} Token #${token.id}`, + subtitle: `ID ${fmtNum(token.id)} · 给客户端分发的统一访问凭证`, + tone: service === 'mysearch' ? 'info' : 'ok', + summaryHtml: [ + drawerMetric('今日成功', fmtNum(stats.today_success || 0), `失败 ${fmtNum(stats.today_failed || 0)}`), + drawerMetric('本月成功', fmtNum(stats.month_success || 0), `失败 ${fmtNum(stats.month_failed || 0)}`), + drawerMetric('小时调用', fmtNum(stats.hour_count || 0), '当前 token 的近一小时请求量'), + ].join(''), + bodyHtml: [ + drawerSection('完整 Token', `
${escapeHtml(token.token)}
`), + drawerSection('配额策略', renderTokenQuota(token)), + drawerSection('代理统计', ` +
+
今日总调用${fmtNum(Number(stats.today_success || 0) + Number(stats.today_failed || 0))}
+
本月总调用${fmtNum(Number(stats.month_success || 0) + Number(stats.month_failed || 0))}
+
+ `), + ].join(''), + actionsHtml: [ + renderDrawerActionGroup('维护动作', ` + + `), + renderDrawerActionGroup('危险动作', ` + + `, 'danger'), + ].join(''), + }); +} + +function openKeyDetail(service, keyId) { + const payload = getServicePayload(service); + const key = (payload?.keys || []).find((item) => Number(item.id) === Number(keyId)); + if (!key) { + showToast('没有找到这个 Key 的最新数据。', 'warn'); + return; + } + const active = Number(key.active) === 1; + const label = getServiceDisplayLabel(service); + openDetailDrawer({ + kicker: `${label} Key`, + title: key.email || `${label} Key #${key.id}`, + subtitle: `${escapeHtml(key.key_masked || key.key)} · ${active ? '当前正常' : '当前禁用'}`, + tone: active ? 'ok' : 'danger', + summaryHtml: [ + drawerMetric('Key 状态', active ? '正常' : '禁用', `ID ${fmtNum(key.id)}`), + drawerMetric('成功调用', fmtNum(key.total_used || 0), `失败 ${fmtNum(key.total_failed || 0)}`), + drawerMetric('最近使用', formatTime(key.last_used_at), key.usage_sync_error ? '存在同步异常' : '统计正常'), + ].join(''), + bodyHtml: [ + drawerSection('Key 配额', renderKeyQuota(service, key)), + drawerSection('账户额度', renderAccountQuota(service, key)), + drawerSection('代理统计', ` +
+
成功${fmtNum(key.total_used || 0)}
+
失败${fmtNum(key.total_failed || 0)}
+
最近使用${escapeHtml(formatTime(key.last_used_at))}
+
+ ${key.usage_sync_error ? `
同步异常:${escapeHtml(key.usage_sync_error)}
` : ''} + `), + ].join(''), + actionsHtml: [ + renderDrawerActionGroup('维护动作', ` + + `), + renderDrawerActionGroup('危险动作', ` + + `, 'danger'), + ].join(''), + }); +} + +function renderProviderWorkspace(service, servicePayload) { + const payload = servicePayload || getBlankServicePayload(); + const meta = SERVICE_META[service]; + renderSyncMeta(service, payload); + renderOverview(service, payload); + renderApiExample(service, payload.tokens || []); + renderTokens(service, payload.tokens || []); + renderKeys(service, payload.keys || []); + renderPoolGlance(service, payload); + const syncButton = document.getElementById(`sync-btn-${service}`); + if (syncButton) { + const syncSupported = payload.usage_sync?.supported !== false && meta.syncSupported !== false; + syncButton.textContent = syncSupported ? meta.syncButton : '暂不支持同步'; + syncButton.disabled = !syncSupported; + syncButton.title = syncSupported ? '' : (payload.usage_sync?.detail || meta.quotaSource); + } +} + +function renderDashboardScope(scope) { + const nextScope = normalizeRefreshScope(scope); + if (nextScope.core) { + if (PAGE_KIND === 'console') { + renderGlobalSummary(latestServices, latestSocial); + renderHeroFocus(latestServices, latestSocial); + renderServiceSwitcher(latestServices, latestSocial); + } + renderSettingsSummaries(); + } + if (nextScope.mysearch) { + renderMySearchQuickstart(latestMySearch, latestSocial); + } + if (PAGE_KIND === 'console' && nextScope.social) { + renderSocialBoard(latestSocial); + renderSocialIntegration(latestSocial); + renderSocialWorkspace(latestSocial); + } + if (PAGE_KIND === 'console') { + nextScope.services.forEach((service) => { + renderProviderWorkspace(service, latestServices[service] || getBlankServicePayload()); + }); + applyActiveService(); + } +} + async function refresh(options = {}) { const force = options.force ? '?force=1' : ''; const payload = await api('GET', `/api/stats${force}`); const services = payload.services || {}; const social = payload.social || {}; const mysearch = payload.mysearch || {}; + latestStatsMeta = payload.meta || {}; latestServices = services; latestSocial = social; latestMySearch = mysearch; - - renderGlobalSummary(services, social); - renderMySearchQuickstart(mysearch, social); - renderSocialBoard(social); - renderSocialIntegration(social); - renderSocialWorkspace(social); - renderServiceSwitcher(services, social); - for (const [service, meta] of Object.entries(SERVICE_META)) { - const servicePayload = services[service] || { - tokens: [], - keys: [], - overview: {}, - real_quota: {}, - usage_sync: {}, - keys_total: 0, - keys_active: 0, - }; - renderSyncMeta(service, servicePayload); - renderOverview(service, servicePayload); - renderApiExample(service, servicePayload.tokens || []); - renderTokens(service, servicePayload.tokens || []); - renderKeys(service, servicePayload.keys || []); - const syncButton = document.getElementById(`sync-btn-${service}`); - const syncSupported = servicePayload.usage_sync?.supported !== false && meta.syncSupported !== false; - syncButton.textContent = syncSupported ? meta.syncButton : '暂不支持同步'; - syncButton.disabled = !syncSupported; - syncButton.title = syncSupported ? '' : (servicePayload.usage_sync?.detail || meta.quotaSource); - } - applyActiveService(); + renderDashboardScope(options.scope); } function toggleImport(service) { document.getElementById(`import-wrap-${service}`).classList.toggle('hidden'); } -async function createToken(service) { +async function createToken(service, button) { const input = document.getElementById(`token-name-${service}`); - await api('POST', '/api/tokens', { - service, - name: input.value.trim(), + const tokenName = input.value.trim(); + await runWithBusyButton(button, { + busyLabel: '创建中...', + successLabel: '已创建', + errorLabel: '创建失败', + minBusyMs: service === 'mysearch' ? 560 : BUTTON_MIN_BUSY_MS, + }, async () => { + await api('POST', '/api/tokens', { + service, + name: tokenName, + }); + input.value = ''; }); - input.value = ''; - await refresh({ force: true }); -} - -async function delToken(id) { - if (!confirm('确认删除这个 Token 吗?')) return; + await sleep(service === 'mysearch' ? 180 : 80); + await refresh({ force: true, scope: getRefreshScopeForService(service) }); +} + +async function delToken(service, id) { + const confirmed = await showConfirmDialog({ + title: '删除 Token', + message: '删除后这个 token 会立即失效,下游客户端会立刻无法继续调用。', + confirmText: '确认删除', + cancelText: '取消', + tone: 'danger', + kicker: 'Danger Zone', + }); + if (!confirmed) return; await api('DELETE', `/api/tokens/${id}`); - await refresh({ force: true }); + await refresh({ force: true, scope: getRefreshScopeForService(service) }); } -async function addSingleKey(service) { +async function addSingleKey(service, button) { const input = document.getElementById(`single-key-${service}`); const key = input.value.trim(); if (!key) return; - await api('POST', '/api/keys', { service, key }); - input.value = ''; - await refresh({ force: true }); + await runWithBusyButton(button, { + busyLabel: '添加中...', + successLabel: '已添加', + errorLabel: '添加失败', + }, async () => { + await api('POST', '/api/keys', { service, key }); + input.value = ''; + }); + await refresh({ force: true, scope: getRefreshScopeForService(service) }); } -async function importKeys(service) { +async function importKeys(service, button) { const textarea = document.getElementById(`import-text-${service}`); const text = textarea.value.trim(); if (!text) return; - const result = await api('POST', '/api/keys', { service, file: text }); - textarea.value = ''; - document.getElementById(`import-wrap-${service}`).classList.add('hidden'); - showToast(`已导入 ${result.imported || 0} 个 ${SERVICE_META[service].label} Key`, 'success'); - await refresh({ force: true }); + await runWithBusyButton(button, { + busyLabel: '导入中...', + successLabel: '已导入', + errorLabel: '导入失败', + }, async () => { + const result = await api('POST', '/api/keys', { service, file: text }); + textarea.value = ''; + document.getElementById(`import-wrap-${service}`).classList.add('hidden'); + showToast(`已导入 ${result.imported || 0} 个 ${SERVICE_META[service].label} Key`, 'success'); + }); + await refresh({ force: true, scope: getRefreshScopeForService(service) }); } -async function delKey(id) { - if (!confirm('确认删除这个 Key 吗?')) return; +async function delKey(service, id) { + const confirmed = await showConfirmDialog({ + title: '删除 API Key', + message: '删除后这个上游 Key 会从当前服务池中移除,额度同步和代理调用都会停止使用它。', + confirmText: '确认删除', + cancelText: '取消', + tone: 'danger', + kicker: 'Danger Zone', + }); + if (!confirmed) return; await api('DELETE', `/api/keys/${id}`); - await refresh({ force: true }); + await refresh({ force: true, scope: getRefreshScopeForService(service) }); } -async function toggleKey(id, active) { +async function toggleKey(service, id, active) { await api('PUT', `/api/keys/${id}/toggle`, { active }); - await refresh({ force: true }); + await refresh({ force: true, scope: getRefreshScopeForService(service) }); } -async function syncUsage(service, force) { +async function syncUsage(service, force, button) { if (SERVICE_META[service]?.syncSupported === false) { showToast(SERVICE_META[service].quotaSource, 'warn'); return; } - const button = document.getElementById(`sync-btn-${service}`); - const originalText = button.textContent; - button.disabled = true; - button.textContent = '同步中...'; + const actionButton = button || document.getElementById(`sync-btn-${service}`); try { - await api('POST', '/api/usage/sync', { service, force }); - await refresh({ force: true }); + await runWithBusyButton(actionButton, { + busyLabel: '同步中...', + successLabel: '已同步', + errorLabel: '同步失败', + }, async () => { + await api('POST', '/api/usage/sync', { service, force }); + }); + await refresh({ force: true, scope: getRefreshScopeForService(service) }); } catch (error) { showToast(`同步 ${SERVICE_META[service].label} 额度失败: ${error.message}`, 'error'); - button.disabled = false; - button.textContent = originalText; } } async function changePwd(event) { event?.preventDefault?.(); + const button = event?.submitter; const input = document.getElementById('settings-new-pwd'); const password = input.value.trim(); if (password.length < 4) { @@ -1445,12 +3633,18 @@ async function changePwd(event) { return; } try { - await api('PUT', '/api/password', { password }); - PWD = password; - localStorage.setItem(STORAGE_KEY, password); - localStorage.removeItem(LEGACY_STORAGE_KEY); - input.value = ''; - setStatus('settings-password-status', '密码已更新,当前会话也已同步。'); + await runWithBusyButton(button, { + busyLabel: '保存中...', + successLabel: '已保存', + errorLabel: '保存失败', + }, async () => { + await api('PUT', '/api/password', { password }); + PWD = password; + localStorage.setItem(STORAGE_KEY, password); + localStorage.removeItem(LEGACY_STORAGE_KEY); + input.value = ''; + setStatus('settings-password-status', '密码已更新,当前会话也已同步。'); + }); } catch (error) { setStatus('settings-password-status', `保存密码失败:${error.message}`, true); } @@ -1458,45 +3652,105 @@ async function changePwd(event) { async function saveSocialSettings(event) { event?.preventDefault?.(); - const body = { - upstream_base_url: document.getElementById('settings-social-upstream-base-url').value.trim(), - upstream_responses_path: document.getElementById('settings-social-upstream-responses-path').value.trim(), - admin_base_url: document.getElementById('settings-social-admin-base-url').value.trim(), - admin_verify_path: document.getElementById('settings-social-admin-verify-path').value.trim(), - admin_config_path: document.getElementById('settings-social-admin-config-path').value.trim(), - admin_tokens_path: document.getElementById('settings-social-admin-tokens-path').value.trim(), - model: document.getElementById('settings-social-model').value.trim(), - fallback_model: document.getElementById('settings-social-fallback-model').value.trim(), - cache_ttl_seconds: document.getElementById('settings-social-cache-ttl-seconds').value.trim(), - fallback_min_results: document.getElementById('settings-social-fallback-min-results').value.trim(), - }; - - const adminAppKey = document.getElementById('settings-social-admin-app-key').value.trim(); - const upstreamApiKey = document.getElementById('settings-social-upstream-api-key').value.trim(); - const gatewayToken = document.getElementById('settings-social-gateway-token').value.trim(); - - if (adminAppKey) body.admin_app_key = adminAppKey; - if (upstreamApiKey) body.upstream_api_key = upstreamApiKey; - if (gatewayToken) body.gateway_token = gatewayToken; + const button = event?.submitter; + const body = collectSocialSettingsForm(); + clearSettingsProbe('social'); try { - const payload = await api('PUT', '/api/settings/social', body); - latestSettings = payload || {}; - fillSettingsForm(latestSettings); - setStatus('settings-social-status', 'Social / X 设置已保存,当前控制台状态已刷新。'); - await refresh({ force: true }); + await runWithBusyButton(button, { + busyLabel: '保存中...', + successLabel: '已保存', + errorLabel: '保存失败', + }, async () => { + const payload = await api('PUT', '/api/settings/social', body); + latestSettings = payload || {}; + fillSettingsForm(latestSettings); + setStatus('settings-social-status', 'Social / X 设置已保存,当前控制台状态已刷新。'); + }); + await refresh({ force: true, scope: getRefreshScopeForService('social') }); } catch (error) { setStatus('settings-social-status', `保存 Social / X 设置失败:${error.message}`, true); } } function flashButtonLabel(button, label) { + flashButtonState(button, label); +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, Math.max(0, ms || 0))); +} + +function ensureButtonLabel(button) { + if (!button) return ''; + if (!button.dataset.originalLabel) { + button.dataset.originalLabel = button.textContent.trim(); + } + return button.dataset.originalLabel; +} + +function resetButtonState(button, label = '') { + if (!button) return; + const original = ensureButtonLabel(button); + button.disabled = false; + button.removeAttribute('aria-busy'); + button.classList.remove('is-busy', 'is-success', 'is-error'); + button.textContent = label || original; +} + +function flashButtonState(button, label, state = 'success', duration = 1400) { if (!button) return; - const original = button.textContent; + const original = ensureButtonLabel(button); + button.disabled = true; + button.classList.remove('is-busy', 'is-success', 'is-error'); + button.classList.add(`is-${state}`); button.textContent = label; setTimeout(() => { - button.textContent = original; - }, 1400); + resetButtonState(button, original); + }, duration); +} + +async function runWithBusyButton(button, { + busyLabel = '处理中...', + successLabel = '已完成', + errorLabel = '失败', + minBusyMs = BUTTON_MIN_BUSY_MS, +} = {}, task) { + if (!button) { + return task(); + } + const original = ensureButtonLabel(button); + const startedAt = Date.now(); + button.disabled = true; + button.setAttribute('aria-busy', 'true'); + button.classList.remove('is-success', 'is-error'); + button.classList.add('is-busy'); + button.textContent = busyLabel; + try { + const result = await task(); + const remaining = minBusyMs - (Date.now() - startedAt); + if (remaining > 0) { + await sleep(remaining); + } + if (button.isConnected) { + flashButtonState(button, successLabel, 'success'); + } + return result; + } catch (error) { + const remaining = minBusyMs - (Date.now() - startedAt); + if (remaining > 0) { + await sleep(remaining); + } + if (button.isConnected) { + flashButtonState(button, errorLabel, 'error'); + } + throw error; + } finally { + button.removeAttribute('aria-busy'); + if (button.classList.contains('is-busy')) { + resetButtonState(button, original); + } + } } async function writeClipboardText(text) { @@ -1555,35 +3809,80 @@ async function writeClipboardText(text) { async function copyCode(elementId, button) { const source = document.getElementById(elementId); if (!source) { - flashButtonLabel(button, '未找到'); + flashButtonState(button, '未找到', 'error'); return; } try { await writeClipboardText(source.textContent); - flashButtonLabel(button, '已复制'); + flashButtonState(button, '已复制', 'success'); } catch (error) { console.error(`Copy failed for #${elementId}`, error); - flashButtonLabel(button, '复制失败'); + flashButtonState(button, '复制失败', 'error'); + } +} + +async function copyEnvAndRevealInstall(button) { + const envBlock = document.getElementById('mysearch-proxy-env'); + const installBlock = document.getElementById('mysearch-install-cmd'); + if (!envBlock) { + flashButtonState(button, '未找到 .env', 'error'); + return; + } + try { + await writeClipboardText(envBlock.textContent); + if (installBlock) { + installBlock.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + flashButtonState(button, '已复制并定位', 'success'); + } catch (error) { + console.error('Copy-and-scroll failed for MySearch quickstart', error); + flashButtonState(button, '操作失败', 'error'); } } async function copyText(value, button) { try { await writeClipboardText(value); - flashButtonLabel(button, '已复制'); + flashButtonState(button, '已复制', 'success'); } catch (error) { console.error('Copy failed for inline value', error); - flashButtonLabel(button, '复制失败'); + flashButtonState(button, '复制失败', 'error'); } } document.addEventListener('keydown', (event) => { - if (event.key === 'Escape' && !document.getElementById('settings-modal').classList.contains('hidden')) { + if (handleSegmentedControlKey(event)) { + return; + } + if (trapOverlayFocus(event)) { + return; + } + if (event.key !== 'Escape') return; + if (isShellVisible('app-dialog')) { + closeAppDialog(false); + return; + } + if (isShellVisible('detail-drawer')) { + closeDetailDrawer(); + return; + } + if (isShellVisible('settings-modal')) { closeSettingsModal(); } }); +window.addEventListener('focus', () => { + refreshAutoThemeFromClock(); +}); + +document.addEventListener('visibilitychange', () => { + if (!document.hidden) { + refreshAutoThemeFromClock(); + } +}); + +applyTheme(activeTheme); renderServiceShells(); async function initConsole() { @@ -1626,6 +3925,8 @@ initConsole(); function setActiveSettingsTab(tabName) { document.querySelectorAll('.settings-tab').forEach(btn => { btn.classList.toggle('is-active', btn.dataset.settingsTab === tabName); + btn.setAttribute('aria-selected', btn.dataset.settingsTab === tabName ? 'true' : 'false'); + btn.setAttribute('tabindex', btn.dataset.settingsTab === tabName ? '0' : '-1'); }); document.querySelectorAll('.settings-tab-panel').forEach(panel => { panel.classList.toggle('hidden', panel.dataset.settingsPanel !== tabName); @@ -1643,21 +3944,22 @@ function maskToken(token) { async function saveTavilySettings(event) { event?.preventDefault?.(); - const body = { - mode: document.getElementById('settings-tavily-mode').value, - upstream_base_url: document.getElementById('settings-tavily-upstream-base-url').value.trim(), - upstream_search_path: document.getElementById('settings-tavily-upstream-search-path').value.trim(), - upstream_extract_path: document.getElementById('settings-tavily-upstream-extract-path').value.trim(), - }; - const upstreamApiKey = document.getElementById('settings-tavily-upstream-api-key').value.trim(); - if (upstreamApiKey) body.upstream_api_key = upstreamApiKey; + const button = event?.submitter; + const body = collectTavilySettingsForm(); + clearSettingsProbe('tavily'); try { - const payload = await api('PUT', '/api/settings/tavily', body); - latestSettings = payload || {}; - fillSettingsForm(latestSettings); - setStatus('settings-tavily-status', 'Tavily 设置已保存。'); - await refresh({ force: true }); + await runWithBusyButton(button, { + busyLabel: '保存中...', + successLabel: '已保存', + errorLabel: '保存失败', + }, async () => { + const payload = await api('PUT', '/api/settings/tavily', body); + latestSettings = payload || {}; + fillSettingsForm(latestSettings); + setStatus('settings-tavily-status', 'Tavily 设置已保存。'); + }); + await refresh({ force: true, scope: getRefreshScopeForService('tavily') }); } catch (error) { setStatus('settings-tavily-status', `保存失败:${error.message}`, true); } diff --git a/proxy/templates/components/_hero.html b/proxy/templates/components/_hero.html index 7f54dc4..29c40d9 100644 --- a/proxy/templates/components/_hero.html +++ b/proxy/templates/components/_hero.html @@ -1,21 +1,20 @@
+
+
+
+
-
- - - 统一搜索入口 - - - - 官方 / 兼容双接入 - - - - MCP + Proxy + Skill - +
+ Search Operations Desk +
+ MySearch Proxy
+ + +
- -
- 统一入口 - 网页发现、正文抓取、社交舆情走同一套接线方式。 + 统一运维 + 把 Key 池、Token、额度同步和团队共享入口放进同一套控制面,不再靠 README 记忆操作。
- Exa 工作区 - 把 Exa 独立成可发 Token、可管 Key、可直接代理调用的新栏目。 + 双模式接入 + 官方 API、上游 Gateway 和 compatible social router 都可以在同一个界面里切换和核对。
- 统一运维 - Key 池、Token、额度同步和团队共享入口放在同一个控制台里。 + 摘要优先 + 先看当前工作台状态和剩余额度,再下钻到 Token、Key、设置和调用示例,避免首屏变成长表格。
- 统一兼容 - 官方 API 和自定义 compatible 聚合服务都能接,不锁死单一后端。 + 直接交付 + 控制台生成的配置信息可以直接喂给 MySearch MCP、OpenClaw 或其他客户端,不需要手动拼接变量。
@@ -62,24 +76,24 @@

把搜索基础设施
做成真正可交付的工作台

- Router - Tavily 做发现 -

适合新闻、快速 answer、网页线索收集,放在 MySearch 的第一层路由里。

+ Discovery + Tavily 做网页发现 +

适合新闻、快速 answer 和基础搜索入口,也支持切到上游 Tavily Gateway。

- Search - Exa 做网页发现 -

适合补 Tavily 之外的网页搜索入口,这里收成独立 Key 池、Token 池和代理端点。

+ Expansion + Exa 做补充搜索 +

当你需要额外的网页搜索入口时,这里用独立 Key 池、Token 池和代理端点承接。

- Depth - Firecrawl 做抓取 -

文档站、GitHub、PDF、pricing 和 changelog 这类正文内容,交给 Firecrawl 更稳。

+ Extraction + Firecrawl 做正文抓取 +

正文页、文档站、PDF 和结构化抽取放在这里统一管理 credits 与抓取调用。

- Social - Social / X 做舆情 -

兼容 grok2api 和 xAI-compatible 搜索,把 X 结果统一整理成可直接消费的结构。

+ Signal + Social / X 做舆情路由 +

兼容 grok2api 和 xAI-compatible 搜索,对外统一成同一条 `/social/search` 链路。

diff --git a/proxy/templates/components/_settings_modal.html b/proxy/templates/components/_settings_modal.html index 2e78ec7..3ef605c 100644 --- a/proxy/templates/components/_settings_modal.html +++ b/proxy/templates/components/_settings_modal.html @@ -4,16 +4,21 @@
Console Settings
-

设置

-

这里不再堆成一大张表单,而是按控制台、Tavily、Social / X 三个面板分开管理。保存后会立即刷新当前控制台状态。

+

控制台配置中心

+

把登录权限、Tavily 路由和 Social / X 上游分开管理。保存后会立即刷新当前控制台状态,不需要手动重载页面。

+
+ Console + Tavily + Social / X +
- +
- - - + + +
@@ -27,7 +32,8 @@

控制台权限

-
+ +
@@ -36,9 +42,15 @@

控制台权限

控制台登录密码会立即更新到当前会话。
-
- - + @@ -50,47 +62,59 @@

控制台权限

Tavily 路由

-

这里决定 Tavily 继续走本地 key pool,还是切到外部 gateway。切到上游后,本地 Key 池只保留展示,不再参与 Tavily 转发。

+

这里决定 Tavily 走自动识别、API Key 池,还是外部 gateway。自动识别不是 Social/X 那种后台继承,而是按“上游凭证优先,其次本地 API Key 池”的规则选实际接线。

-
`pool` 适合你自己维护官方 key。
-
`upstream` 适合接 `tavily-hikari` 这类聚合网关。
+
`auto` 适合作为默认模式。检测到上游凭证就走 Gateway;如果你只是导入 Tavily key,就会默认落到 API Key 池。
+
`pool` 适合你自己维护官方 key;`upstream` 适合接 `tavily-hikari` 这类聚合网关。
-
+ +
- -
切到上游模式后,Tavily 请求不再消耗本地 Key 池。
+ +
+ + + +
+
自动模式会先检测上游凭证;如果你只是导入 Tavily key,就会默认回到 API Key 池。
+
当前实际:等待识别
-
+
例如 `tavily-hikari` 可直接填写到 `/api/tavily`。
-
+
-
+
-
+
未配置
-
- + +
@@ -109,10 +133,11 @@

Social / X / grok2api

-
+ +
- +
真正转发 `/responses` 时使用的上游地址。
@@ -127,22 +152,22 @@

Social / X / grok2api

当主模型结果太少或请求失败时,自动补一轮的候选模型。
- +
推荐填后台根地址,系统会自动拉 `config` 和 `tokens`。
- +
未配置
- +
未配置
- +
未配置
@@ -180,13 +205,21 @@

Social / X / grok2api

-
- + +
-
\ No newline at end of file +
diff --git a/proxy/templates/console.html b/proxy/templates/console.html index 6b6be04..669edcc 100644 --- a/proxy/templates/console.html +++ b/proxy/templates/console.html @@ -7,10 +7,17 @@ - +
+
+
MySearch Proxy
+
+ Unified Proxy + Provider Pools + Team Ready +

Search Infrastructure Console

把 Tavily、Exa、Firecrawl 和 Social / X 接入放进同一块工作台里,适合你自己用,也适合直接公开给团队或社区部署。

@@ -18,6 +25,20 @@

Search Infrastructure Console

+
+
+
统一入口
+ MySearch Token +
+
+
Provider
+ Tavily / Exa / Firecrawl / Social +
+
+
控制面
+ Key 池 · 路由 · 额度 +
+
@@ -27,55 +48,88 @@

Search Infrastructure Console

-
-
- Powered by grok2api - Social / X 工作台当前默认调用 grok2api -

这部分能力基于 grok2api 提供的兼容接口和后台 token 状态完成接线。MySearch 的 Social / X 路由在这里向 grok2api 项目致谢,并保留它作为默认兼容实现来源。

- - github.com/chenyme/grok2api - -
-
-
-
当前用途
-
X Search Compatible
+
+
+
+
+ Workspace Navigator +

一屏切换不同搜索引擎工作台

+

先用这一屏判断当前该处理哪个工作台,再在下面展开实际的 Token、Key、额度与接线细节。

+
+
已记住上次打开的工作台
-
-
感谢项目
-
grok2api
-
-
-
+
+ +
+
+ Social Compatibility + Social / X 工作台默认兼容 grok2api +

这部分能力基于 grok2api 的兼容接口与后台 token 状态完成接线。控制台保留它作为默认兼容来源,同时允许你切到其他 xAI-compatible 上游。

+ + github.com/chenyme/grok2api + +
+
+
+
当前用途
+
X Search
+
compatible router
+
+
+
默认实现
+
grok2api
+
后台自动继承
+
+
+
+ -
+
+
+
+ + {% include "components/_settings_modal.html" %} -
-
-
- Workspace Switcher -

一屏切换不同搜索引擎工作台

-

选择要查看的工作台,下面会展开对应服务的 Key、Token、额度和接入配置。

+ -
-
+
+
+
+ +
-
-
+ - {% include "components/_settings_modal.html" %} +
- - -
- - + + + + diff --git a/proxy/templates/mysearch.html b/proxy/templates/mysearch.html new file mode 100644 index 0000000..acf2819 --- /dev/null +++ b/proxy/templates/mysearch.html @@ -0,0 +1,108 @@ + + + + + +MySearch Access Console + + + + + +
+
+
+
MySearch Access
+
+ Unified Entry + MCP Ready + Team Ready +
+

MySearch 接入台

+

这里专门管理 MySearch 的统一接入配置、安装路径和通用 Token。登录后你可以直接复制 `.env`、安装命令,并回到搜索控制台继续维护 provider 工作台。

+
+ + + +
+
+
+
统一入口
+ MYSEARCH_PROXY_* +
+
+
客户端
+ Codex / Claude Code / MCP +
+
+
目标
+ 配置 · 安装 · Token +
+
+ +
+ +
+
+
+
+
+ MySearch Access Desk +

MySearch 接入台

+

把统一接入配置、安装命令和通用 Token 单独放在一页,让默认搜索控制台专注 provider 工作台,而这里专门负责客户端接入。

+
+
+ + + +
+
+
+ +
+
+ + {% include "components/_settings_modal.html" %} + + + + + +
+ + + +
+ + diff --git a/tests/test_clients.py b/tests/test_clients.py index e0a75db..68dfb21 100644 --- a/tests/test_clients.py +++ b/tests/test_clients.py @@ -1,10 +1,16 @@ from __future__ import annotations import io +import sys import unittest +from pathlib import Path from urllib.error import HTTPError from unittest.mock import patch +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + from mysearch.clients import MySearchClient, MySearchHTTPError, RouteDecision diff --git a/tests/test_proxy_tavily_settings.py b/tests/test_proxy_tavily_settings.py new file mode 100644 index 0000000..b154bb2 --- /dev/null +++ b/tests/test_proxy_tavily_settings.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import importlib.util +import sys +import unittest +from pathlib import Path +from unittest.mock import patch + + +REPO_ROOT = Path(__file__).resolve().parents[1] +PROXY_ROOT = REPO_ROOT / "proxy" + + +def _load_module(module_name: str, path: Path): + spec = importlib.util.spec_from_file_location(module_name, path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + return module + + +class ProxyTavilySettingsTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + if str(PROXY_ROOT) not in sys.path: + sys.path.insert(0, str(PROXY_ROOT)) + cls.module = _load_module( + "test_proxy_server_tavily_settings", + PROXY_ROOT / "server.py", + ) + + def test_get_runtime_tavily_config_defaults_to_auto(self) -> None: + with patch.object(self.module.db, "get_setting", side_effect=lambda _key, default=None: default): + config = self.module.get_runtime_tavily_config() + + self.assertEqual(config["mode"], "auto") + self.assertEqual(config["upstream_base_url"], "https://api.tavily.com") + self.assertEqual(config["upstream_search_path"], "/search") + self.assertEqual(config["upstream_extract_path"], "/extract") + self.assertEqual(config["upstream_api_key"], "") + + def test_get_runtime_tavily_config_reads_upstream_settings(self) -> None: + values = { + "tavily_mode": "upstream", + "tavily_upstream_base_url": "http://127.0.0.1:8787/api/tavily", + "tavily_upstream_search_path": "/search", + "tavily_upstream_extract_path": "/extract", + "tavily_upstream_api_key": "th-demo-token", + } + + def fake_get_setting(key, default=None): + return values.get(key, default) + + with patch.object(self.module.db, "get_setting", side_effect=fake_get_setting): + config = self.module.get_runtime_tavily_config() + + self.assertEqual(config["mode"], "upstream") + self.assertEqual(config["upstream_base_url"], "http://127.0.0.1:8787/api/tavily") + self.assertEqual(config["upstream_api_key"], "th-demo-token") + + def test_usage_sync_meta_is_disabled_in_tavily_upstream_mode(self) -> None: + values = { + "tavily_mode": "upstream", + "tavily_upstream_base_url": "http://127.0.0.1:8787/api/tavily", + "tavily_upstream_api_key": "th-demo-token", + } + + def fake_get_setting(key, default=None): + return values.get(key, default) + + with patch.object(self.module.db, "get_setting", side_effect=fake_get_setting): + meta = self.module.build_usage_sync_meta_for_dashboard("tavily", [{"id": 1}, {"id": 2}]) + + self.assertFalse(meta["supported"]) + self.assertEqual(meta["requested"], 2) + self.assertIn("上游 Gateway", meta["detail"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_social_normalization.py b/tests/test_social_normalization.py index 2df881b..a920534 100644 --- a/tests/test_social_normalization.py +++ b/tests/test_social_normalization.py @@ -5,10 +5,12 @@ import unittest from pathlib import Path -from mysearch import social_gateway +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) +from mysearch import social_gateway -REPO_ROOT = Path(__file__).resolve().parents[1] PROXY_DIR = REPO_ROOT / "proxy" if str(PROXY_DIR) not in sys.path: sys.path.insert(0, str(PROXY_DIR)) From 2b2e48beebe56722a394e248246632bbe2d91313 Mon Sep 17 00:00:00 2001 From: zhaishujie <3394180966@qq.com> Date: Sun, 22 Mar 2026 10:07:44 +0800 Subject: [PATCH 07/20] Add proxy Docker publish workflow --- .github/workflows/docker-publish.yml | 158 +++++++++++++++++++++ llmdoc/architecture/proxy-first.md | 1 + llmdoc/guides/common-workflows.md | 14 +- llmdoc/reference/entrypoints-and-config.md | 1 + llmdoc/reference/runtime-entrypoints.md | 2 + proxy/.dockerignore | 9 ++ 6 files changed, 179 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/docker-publish.yml diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..fb06ef4 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,158 @@ +name: Build and Publish Proxy Docker Image + +on: + push: + branches: + - main + tags: + - "v*" + pull_request: + branches: + - main + workflow_dispatch: + +concurrency: + group: docker-publish-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify: + name: Verify proxy runtime and tests + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + cache-dependency-path: | + mysearch/requirements.txt + proxy/requirements.txt + + - name: Install Python dependencies + run: | + python -m pip install --upgrade pip + pip install -r mysearch/requirements.txt -r proxy/requirements.txt + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Run Python test suite + run: python -m unittest discover -s tests + + - name: Run Python syntax checks + run: | + python -m py_compile \ + proxy/server.py \ + mysearch/config.py \ + mysearch/clients.py \ + mysearch/keyring.py \ + mysearch/social_gateway.py \ + openclaw/runtime/mysearch/config.py \ + openclaw/runtime/mysearch/clients.py \ + openclaw/runtime/mysearch/keyring.py + + - name: Run frontend syntax check + run: node --check proxy/static/js/console.js + + docker: + name: Build and publish multi-arch proxy image + runs-on: ubuntu-latest + needs: verify + env: + DOCKERHUB_USERNAME_VAR: ${{ vars.DOCKERHUB_USERNAME }} + DOCKERHUB_USERNAME_SECRET: ${{ secrets.DOCKERHUB_USERNAME }} + IMAGE_NAME: ${{ vars.DOCKERHUB_IMAGE_NAME || 'mysearch-proxy' }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Resolve Docker Hub image repository + id: image + env: + GITHUB_EVENT_NAME: ${{ github.event_name }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + run: | + trim_single_line() { + printf '%s' "$1" | tr -d '\r\n' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//' + } + + DOCKERHUB_USERNAME="$(trim_single_line "${DOCKERHUB_USERNAME_VAR:-${DOCKERHUB_USERNAME_SECRET:-}}")" + IMAGE_NAME_CLEAN="$(trim_single_line "${IMAGE_NAME}" | tr '[:upper:]' '[:lower:]')" + + test -n "${DOCKERHUB_USERNAME}" || { + echo "Missing Docker Hub username. Set Actions Variable DOCKERHUB_USERNAME or Secret DOCKERHUB_USERNAME." + exit 1 + } + + test -n "${IMAGE_NAME_CLEAN}" || { + echo "Missing Docker image name. Set Actions Variable DOCKERHUB_IMAGE_NAME." + exit 1 + } + + case "${DOCKERHUB_USERNAME}" in + */*|*:*|*" "*) + echo "Invalid Docker Hub username." + exit 1 + ;; + esac + + case "${IMAGE_NAME_CLEAN}" in + */*|*:*|*" "*) + echo "Invalid Docker image name." + exit 1 + ;; + esac + + if [ "${GITHUB_EVENT_NAME}" != "pull_request" ]; then + test -n "${DOCKERHUB_TOKEN}" || { + echo "Missing Docker Hub token. Set Actions Secret DOCKERHUB_TOKEN." + exit 1 + } + fi + + IMAGE_REPOSITORY="${DOCKERHUB_USERNAME}/${IMAGE_NAME_CLEAN}" + echo "dockerhub_username=${DOCKERHUB_USERNAME}" >> "$GITHUB_OUTPUT" + echo "image_repository=${IMAGE_REPOSITORY}" >> "$GITHUB_OUTPUT" + echo "Resolved Docker image repository: ${IMAGE_REPOSITORY}" + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + username: ${{ steps.image.outputs.dockerhub_username }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ steps.image.outputs.image_repository }} + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=ref,event=branch + type=ref,event=tag + type=sha,format=short + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: ./proxy + file: ./proxy/Dockerfile + platforms: linux/amd64,linux/arm64 + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/llmdoc/architecture/proxy-first.md b/llmdoc/architecture/proxy-first.md index cc647ed..cf72489 100644 --- a/llmdoc/architecture/proxy-first.md +++ b/llmdoc/architecture/proxy-first.md @@ -52,4 +52,5 @@ - Proxy 的启动时机会执行 `db.init_db()`;SQLite 默认路径是 `proxy/data/proxy.db`。来源:proxy/server.py:42, proxy/database.py:11, proxy/database.py:61 - Proxy 的 token 体系里包含 `mysearch` 服务,生成前缀为 `mysp-` 的统一 token,默认只做鉴权与统计,不做配额拦截。来源:proxy/database.py:13, proxy/database.py:18, proxy/README.md:74, proxy/README.md:83 +- Proxy 的容器发布边界也已经固定在 `proxy/` 目录本身,而不是整个仓库根。`.github/workflows/docker-publish.yml` 现在会先验证整仓的 Python/前端语法与测试,再只对 `context=./proxy`、`file=./proxy/Dockerfile` 执行 buildx 多架构构建;`proxy/.dockerignore` 会同步排除 `data/`、`proxy.db`、`__pycache__` 与本地 `.env`,避免把控制台本地状态打进镜像。来源:.github/workflows/docker-publish.yml:1, proxy/.dockerignore:1 - Proxy 控制台现在已经从单文件模板拆成 `console.html + _hero.html + _settings_modal.html + console.css + console.js` 这套 live 前端;页面布局已经回到 `summary-strip + dashboard-flow` 的纵向结构,默认首页下半区固定为 `Workspace Navigator -> provider workspace`,而统一客户端接入则拆到独立的 `/mysearch` 页面。`Workspace Navigator` 仍然只保留工作台名称、状态和 2 个核心指标,次要信息下沉到 badge 与 footnote,不再展示 `/api/search`、`/social/search` 这类具体请求路径;但它现在不再纵向堆叠,而是由 `service-switcher` 横向卡阵列承接,`Social Compatibility` 提示卡也继续收在 switcher 区块底部。登录入口也不再是孤立小表单,而是通过 `auth-meta` 把“统一入口 / provider / 控制面”三个概念先交代清楚,并在登录成功后由 `showDashboard({ animate: true })` 做一轮轻量 staged reveal,而在 `prefers-reduced-motion` 下会自动压平动效。hero 右侧原先那张“当前工作台”大卡已经移除,不再在首屏重复展示当前控制台状态。`/mysearch` 页则收成 `MySearch 接入台`,模块标题进一步压成 `统一接入配置`,避免页级标题和模块标题重复。该页内部继续保持“接入配置 / 安装路径 / 通用 Token 管理”三层,而且“一键配置”内部进一步拆成左侧 `quickstart-visual-col` 可视化 readiness 区和右侧 `quickstart-config-col` 配置区:`getQuickstartProviderCards()` 继续把 Tavily `effective_mode`、Exa / Firecrawl key 状态和 Social / X 接线结果汇总成 `quickstart-route-strip`,`getQuickstartInstallHint()` 继续把当前最短安装路径压成 `quickstart-install-strip`,同时也把旧版更直接的 `stdio / streamable-http` 安装形态补回到 `quickstart-install-meta`。这些状态会一起写入生成的 `MYSEARCH_PROXY_*` 配置说明;除了复制块旁边的普通复制按钮,现在还额外提供 `copyEnvAndRevealInstall()` 这个组合动作,直接复制 `.env` 并把视口定位到安装命令。默认首页的 `summary-strip` 也已经收窄成项目级概览:`当前工作台 / 已接通工作台 / Provider 代理 Token / 今日调用 / 本月调用 / MySearch Token`,不再塞入工作台内部已经会单独展示的上游额度或本地 API Key。主题切换则扩成 `浅色 / 深色 / 自动` 三态,`自动` 依据打开页面那台机器的本地时区与本地时间决定实际主题,不依赖服务端所在系统或容器时区。`MySearch 通用 Token` 摘要表继续共享和 provider 面板一致的本地搜索/排序逻辑。provider 页面仍然保持“摘要表 + `detail-drawer`”的运维视图,`Token 池 / API Key 池` 的本地搜索、筛选和排序,以及 `table-row-clickable.is-danger|is-warn|is-busy|is-off` 风险行态都保留不变;`detail-drawer` 底部动作也继续通过 `renderDrawerActionGroup()` 拆成“维护动作 / 危险动作”两组。设置面板仍是带 `settings-summary-strip`、sticky footer 和 Tavily `mode-switch` 分段控件的配置中心,并保留 `/api/settings/test/tavily` 与 `/api/settings/test/social` 这两条结构化 probe 链路。控制台刷新仍然通过 `normalizeRefreshScope()`、`getRefreshScopeForService()`、`renderDashboardScope()` 做局部更新,可访问性层也仍然保留 `handleSegmentedControlKey()`、toast live region、overlay focus remember/restore 与 `trapOverlayFocus()` 这一组统一逻辑。来源:proxy/templates/components/_hero.html:25, proxy/templates/components/_hero.html:51, proxy/templates/console.html:51, proxy/templates/console.html:54, proxy/templates/console.html:65, proxy/templates/mysearch.html:21, proxy/static/js/console.js:644, proxy/static/js/console.js:1425, proxy/static/js/console.js:1478, proxy/static/js/console.js:1695, proxy/static/js/console.js:2251, proxy/static/js/console.js:2777, proxy/static/css/console.css:622, proxy/static/css/console.css:717, proxy/static/css/console.css:1061, proxy/static/css/console.css:1508, proxy/static/css/console.css:1533, proxy/static/css/console.css:2923, proxy/static/css/console.css:2943, proxy/server.py:2259 diff --git a/llmdoc/guides/common-workflows.md b/llmdoc/guides/common-workflows.md index b2bfbe1..f30423b 100644 --- a/llmdoc/guides/common-workflows.md +++ b/llmdoc/guides/common-workflows.md @@ -9,12 +9,14 @@ ## 2. 部署 proxy-first 链路 1. 先部署 `proxy/`,这层负责统一接上游 provider、生成 `mysp-` token 并暴露控制台。来源:README.md:200, proxy/README.md:102 -2. 首次进入默认搜索控制台后,顶部 hero 不再单独重复展示“当前工作台”大卡;首屏只保留品牌区、快捷动作和 4 条 provider lane。如果想直接进入操作区,优先用 `进入当前工作台`;如果想看统一客户端接入配置,则用 `查看 MySearch 接入` 跳到独立的 `/mysearch` 页面。来源:proxy/templates/components/_hero.html:25, proxy/templates/components/_hero.html:42, proxy/templates/components/_hero.html:51, proxy/templates/components/_hero.html:77, proxy/static/js/console.js:631 -3. 默认搜索控制台下半区现在固定为 `Workspace Navigator -> 具体工作台`,不再默认把 `MySearch 快速接入` 挂在首页。switcher 卡片只保留工作台名称、状态和 2 个核心指标,次要信息下沉成 badge 与说明,不再显示 `/api/search` 这类具体请求路径;同时它已经改成横向卡阵列,不再一张张纵向堆高页面。`Social Compatibility` 也已经收回到 switcher 区块底部。Tavily 现在不是手动二选一,而是进 `Settings -> Tavily` 看 `auto|pool|upstream` 三态分段控件,保存后前端会同时显示“配置模式 / 当前实际 / 来源”,其中 `auto` 会按“上游凭证优先,其次本地活跃 Tavily key”自动解析;如果你只是导入 Tavily key,默认实际就会落到 API Key 池。Social/X 的 grok2api 或 compatible 配置仍进 `Settings -> Social / X`,而且字段标题现在按职责拆成“搜索上游”和“后台管理”两类,不再把 `Base URL`、后台地址和 app key 混成一组理解。设置中心每个 tab 都带 `settings-summary-strip`、sticky footer 和“测试当前连接”按钮,而且测试结果会直接展开成结构化 probe 卡,不需要自己从一行状态文案里猜请求目标或鉴权来源。来源:proxy/templates/console.html:51, proxy/templates/console.html:61, proxy/templates/components/_settings_modal.html:61, proxy/templates/components/_settings_modal.html:117, proxy/templates/components/_settings_modal.html:218, proxy/static/css/console.css:1061, proxy/static/js/console.js:2262 -4. 具体 provider 页面现在统一先看 `stats + 接线摘要`,再按需展开 `Token 池` 和 `API Key 池` 两个 detail cards;主表已经降成摘要视图,点击任一 token/key 行会打开右侧 `detail-drawer` 查看完整额度、账户层级信息和维护动作。新增的本地筛选条会直接在前端做搜索、筛选和排序,而且已经补到 `失败优先`、`待处理`、`异常优先`、`低额度优先` 这类运维向视角;表格行也可以直接用键盘 `Enter / Space` 打开详情抽屉。需要特别区分的是“本地池统计”和“上游状态”:Tavily 当前实际走 upstream 时,概览优先展示上游 Hikari 的公共摘要,例如活跃 key、耗尽 key、总请求与总剩余额度,本地 Tavily key 会降级成回退库存;Social / X 在接通 grok2api 后台时继续显示完整 token 统计,但如果只有手动上游 key / gateway token,就只展示基础接线可视化,例如上游 key 数、客户端 token 数和可转发状态,不再把后台未接通误显示成一排 0。对于兼容后台的 `/v1/admin/tokens`,控制台现在也会先解包 `{tokens:{...}}`、`{data:{...}}`、`{items:[...]}` 这类响应再做统计,避免后台地址和 app key 都已配置但 token 总数仍错误地显示为 0。初始化顺序仍建议保持“登录 -> 导入 Tavily/Firecrawl/Exa key -> 需要时补 Social/X -> sync usage -> 创建 `mysp-` token”。来源:proxy/templates/console.html:94, proxy/static/js/console.js:845, proxy/static/js/console.js:1268, proxy/static/js/console.js:2631, proxy/static/js/console.js:2718 -5. `MySearch 快速接入` 现在已经独立到 `/mysearch` 页面,不再默认出现在搜索控制台首页,这一页也不再重复展示首页 `summary-strip`。页级标题已经收成 `MySearch 接入台`,模块级标题则用 `统一接入配置`,不再两层都重复 `MySearch 快速接入`。页面内部继续收成“接入配置 / 安装路径 / 通用 Token 管理”三层,而且“一键配置”内部再拆成左侧可视化 readiness 区和右侧配置区,不再让 route 小卡片和 `.env`/说明混在一列里。`quickstart-route-strip` 会根据 Tavily `effective_mode`、Exa / Firecrawl key 状态和 Social / X 接线情况,动态显示当前 provider readiness;`quickstart-install-strip` 则把“创建通用 token → 复制 `.env` → ./install.sh”压成当前最短安装路径,并额外提供 `复制 .env 并定位命令` 的组合动作;旧版更直观的默认安装形态也通过 `quickstart-install-meta` 补回来了,直接展示 `stdio / streamable-http`。生成的 `.env` 里也会把“当前路由状态”写进去。客户端侧仍只保留 `MYSEARCH_PROXY_BASE_URL` 与 `MYSEARCH_PROXY_API_KEY`,不再把 provider key 散落到每台机器;`MySearch 通用 Token` 摘要表也补上了本地搜索和排序。来源:README.md:79, proxy/templates/mysearch.html:45, proxy/static/js/console.js:1433, proxy/static/js/console.js:1486, proxy/static/js/console.js:1546, proxy/static/js/console.js:1695 -6. 默认首页的 `summary-strip` 现在更偏“控制面概览”而不是“把所有 provider 细节都缩一遍”。它只展示 `当前工作台 / 已接通工作台 / Provider 代理 Token / 今日调用 / 本月调用 / MySearch Token`。其中 `Provider 代理 Token` 会按工作台当前是否走本地代理池动态统计,例如 Tavily 或 Social / X 一旦切到上游 Gateway/兼容后台,就会从这项里剔除;Exa / Firecrawl 继续按本地 provider token 池计入。这样首页摘要只表达项目拓扑和控制面状态,不再重复展示工作台内部已经会单独展示的上游额度、本地 API Key 或 `Social Chat` 细节。来源:proxy/static/js/console.js:2777 -7. 保存设置、测试连接、复制配置和同步额度仍统一走页面右下角 toast;删除 token/key 这类危险动作现在不再用浏览器原生确认框,而是统一走 `app-dialog`;控制台范围内也已经没有原生 `select`,Tavily 工作模式改成了自定义 `mode-switch` 分段控件;左侧 `Social Compatibility` 与右侧 `Social / X 接入` 也都改成更摘要优先的结构,长英文值不再直接用大字号 value 顶满卡片;provider 的 token/key 摘要表则会用 `danger / warn / busy / off` 行态底色优先标出同步错误、低额度、异常活跃和停用状态,并在表格上方用 `table-legend` 直接给出图例;右侧 `detail-drawer` 的底部动作也改成“维护动作 / 危险动作”两组,删除类操作不再和普通维护动作并排。登录页也已经和 dashboard 收成同一套视觉语言,补了 `auth-meta` 元信息卡;登录、设置保存、设置测试、额度同步、创建 token、添加/导入 key 这些主操作现在都会显示按钮级 loading / success / error 过渡,而且共享同一个 `runWithBusyButton()`,会自动给 busy 态保留最小时长并避免在刷新后把 success/error 反馈闪到错误节点。进一步地,控制台现在已经把频繁操作改成“局部刷新而不是全量重绘”,并补上了 `mode-switch / mini-switch / settings-tab` 的方向键导航、toast live region、dialog 焦点回收与 focus trap;登录成功后 dashboard 仍会做一轮轻量 staged reveal,而在 `prefers-reduced-motion` 下这些动效会自动压平。主题切换也已经扩成 `浅色 / 深色 / 自动` 三态,`自动` 会按打开页面那台机器的本地时区和本地时间切换实际主题,不依赖服务端所在系统或 Docker 容器时区。来源:proxy/templates/console.html:97, proxy/templates/console.html:114, proxy/templates/console.html:134, proxy/static/js/console.js:19, proxy/static/js/console.js:108, proxy/static/js/console.js:178, proxy/static/js/console.js:644, proxy/static/js/console.js:653, proxy/static/js/console.js:808, proxy/static/js/console.js:3123, proxy/static/js/console.js:3495, proxy/static/css/console.css:118, proxy/static/css/console.css:1008, proxy/static/css/console.css:1245, proxy/static/css/console.css:2507, proxy/static/css/console.css:2908 +2. 如果你希望 Proxy 镜像自动发布到 Docker Hub,当前仓库已经补了 `.github/workflows/docker-publish.yml`。它参考 `code/program/todo-list/.github/workflows/docker-publish.yml` 的结构,但适配成当前仓库的 Python/Proxy 版本:先跑 `python -m unittest discover -s tests`、`py_compile` 和 `node --check proxy/static/js/console.js`,再按 `context=./proxy` 与 `proxy/Dockerfile` 做 buildx 多架构构建;pull request 只验证构建,push 到 `main` 或打 `v*` tag 时才真正发布镜像。镜像仓库默认走 `DOCKERHUB_USERNAME/mysearch-proxy`,也可以用 Actions Variable `DOCKERHUB_IMAGE_NAME` 覆盖。来源:.github/workflows/docker-publish.yml:1 +3. `proxy/` 目录现在也有 `.dockerignore`,会排除 `data/`、`proxy.db`、`__pycache__`、`.env`、compose 和 README,避免本地数据库与缓存文件被 `COPY . .` 带进镜像上下文。来源:proxy/.dockerignore:1 +4. 首次进入默认搜索控制台后,顶部 hero 不再单独重复展示“当前工作台”大卡;首屏只保留品牌区、快捷动作和 4 条 provider lane。如果想直接进入操作区,优先用 `进入当前工作台`;如果想看统一客户端接入配置,则用 `查看 MySearch 接入` 跳到独立的 `/mysearch` 页面。来源:proxy/templates/components/_hero.html:25, proxy/templates/components/_hero.html:42, proxy/templates/components/_hero.html:51, proxy/templates/components/_hero.html:77, proxy/static/js/console.js:631 +5. 默认搜索控制台下半区现在固定为 `Workspace Navigator -> 具体工作台`,不再默认把 `MySearch 快速接入` 挂在首页。switcher 卡片只保留工作台名称、状态和 2 个核心指标,次要信息下沉成 badge 与说明,不再显示 `/api/search` 这类具体请求路径;同时它已经改成横向卡阵列,不再一张张纵向堆高页面。`Social Compatibility` 也已经收回到 switcher 区块底部。Tavily 现在不是手动二选一,而是进 `Settings -> Tavily` 看 `auto|pool|upstream` 三态分段控件,保存后前端会同时显示“配置模式 / 当前实际 / 来源”,其中 `auto` 会按“上游凭证优先,其次本地活跃 Tavily key”自动解析;如果你只是导入 Tavily key,默认实际就会落到 API Key 池。Social/X 的 grok2api 或 compatible 配置仍进 `Settings -> Social / X`,而且字段标题现在按职责拆成“搜索上游”和“后台管理”两类,不再把 `Base URL`、后台地址和 app key 混成一组理解。设置中心每个 tab 都带 `settings-summary-strip`、sticky footer 和“测试当前连接”按钮,而且测试结果会直接展开成结构化 probe 卡,不需要自己从一行状态文案里猜请求目标或鉴权来源。来源:proxy/templates/console.html:51, proxy/templates/console.html:61, proxy/templates/components/_settings_modal.html:61, proxy/templates/components/_settings_modal.html:117, proxy/templates/components/_settings_modal.html:218, proxy/static/css/console.css:1061, proxy/static/js/console.js:2262 +6. 具体 provider 页面现在统一先看 `stats + 接线摘要`,再按需展开 `Token 池` 和 `API Key 池` 两个 detail cards;主表已经降成摘要视图,点击任一 token/key 行会打开右侧 `detail-drawer` 查看完整额度、账户层级信息和维护动作。新增的本地筛选条会直接在前端做搜索、筛选和排序,而且已经补到 `失败优先`、`待处理`、`异常优先`、`低额度优先` 这类运维向视角;表格行也可以直接用键盘 `Enter / Space` 打开详情抽屉。需要特别区分的是“本地池统计”和“上游状态”:Tavily 当前实际走 upstream 时,概览优先展示上游 Hikari 的公共摘要,例如活跃 key、耗尽 key、总请求与总剩余额度,本地 Tavily key 会降级成回退库存;Social / X 在接通 grok2api 后台时继续显示完整 token 统计,但如果只有手动上游 key / gateway token,就只展示基础接线可视化,例如上游 key 数、客户端 token 数和可转发状态,不再把后台未接通误显示成一排 0。对于兼容后台的 `/v1/admin/tokens`,控制台现在也会先解包 `{tokens:{...}}`、`{data:{...}}`、`{items:[...]}` 这类响应再做统计,避免后台地址和 app key 都已配置但 token 总数仍错误地显示为 0。初始化顺序仍建议保持“登录 -> 导入 Tavily/Firecrawl/Exa key -> 需要时补 Social/X -> sync usage -> 创建 `mysp-` token”。来源:proxy/templates/console.html:94, proxy/static/js/console.js:845, proxy/static/js/console.js:1268, proxy/static/js/console.js:2631, proxy/static/js/console.js:2718 +7. `MySearch 快速接入` 现在已经独立到 `/mysearch` 页面,不再默认出现在搜索控制台首页,这一页也不再重复展示首页 `summary-strip`。页级标题已经收成 `MySearch 接入台`,模块级标题则用 `统一接入配置`,不再两层都重复 `MySearch 快速接入`。页面内部继续收成“接入配置 / 安装路径 / 通用 Token 管理”三层,而且“一键配置”内部再拆成左侧可视化 readiness 区和右侧配置区,不再让 route 小卡片和 `.env`/说明混在一列里。`quickstart-route-strip` 会根据 Tavily `effective_mode`、Exa / Firecrawl key 状态和 Social / X 接线情况,动态显示当前 provider readiness;`quickstart-install-strip` 则把“创建通用 token → 复制 `.env` → ./install.sh”压成当前最短安装路径,并额外提供 `复制 .env 并定位命令` 的组合动作;旧版更直观的默认安装形态也通过 `quickstart-install-meta` 补回来了,直接展示 `stdio / streamable-http`。生成的 `.env` 里也会把“当前路由状态”写进去。客户端侧仍只保留 `MYSEARCH_PROXY_BASE_URL` 与 `MYSEARCH_PROXY_API_KEY`,不再把 provider key 散落到每台机器;`MySearch 通用 Token` 摘要表也补上了本地搜索和排序。来源:README.md:79, proxy/templates/mysearch.html:45, proxy/static/js/console.js:1433, proxy/static/js/console.js:1486, proxy/static/js/console.js:1546, proxy/static/js/console.js:1695 +8. 默认首页的 `summary-strip` 现在更偏“控制面概览”而不是“把所有 provider 细节都缩一遍”。它只展示 `当前工作台 / 已接通工作台 / Provider 代理 Token / 今日调用 / 本月调用 / MySearch Token`。其中 `Provider 代理 Token` 会按工作台当前是否走本地代理池动态统计,例如 Tavily 或 Social / X 一旦切到上游 Gateway/兼容后台,就会从这项里剔除;Exa / Firecrawl 继续按本地 provider token 池计入。这样首页摘要只表达项目拓扑和控制面状态,不再重复展示工作台内部已经会单独展示的上游额度、本地 API Key 或 `Social Chat` 细节。来源:proxy/static/js/console.js:2777 +9. 保存设置、测试连接、复制配置和同步额度仍统一走页面右下角 toast;删除 token/key 这类危险动作现在不再用浏览器原生确认框,而是统一走 `app-dialog`;控制台范围内也已经没有原生 `select`,Tavily 工作模式改成了自定义 `mode-switch` 分段控件;左侧 `Social Compatibility` 与右侧 `Social / X 接入` 也都改成更摘要优先的结构,长英文值不再直接用大字号 value 顶满卡片;provider 的 token/key 摘要表则会用 `danger / warn / busy / off` 行态底色优先标出同步错误、低额度、异常活跃和停用状态,并在表格上方用 `table-legend` 直接给出图例;右侧 `detail-drawer` 的底部动作也改成“维护动作 / 危险动作”两组,删除类操作不再和普通维护动作并排。登录页也已经和 dashboard 收成同一套视觉语言,补了 `auth-meta` 元信息卡;登录、设置保存、设置测试、额度同步、创建 token、添加/导入 key 这些主操作现在都会显示按钮级 loading / success / error 过渡,而且共享同一个 `runWithBusyButton()`,会自动给 busy 态保留最小时长并避免在刷新后把 success/error 反馈闪到错误节点。进一步地,控制台现在已经把频繁操作改成“局部刷新而不是全量重绘”,并补上了 `mode-switch / mini-switch / settings-tab` 的方向键导航、toast live region、dialog 焦点回收与 focus trap;登录成功后 dashboard 仍会做一轮轻量 staged reveal,而在 `prefers-reduced-motion` 下这些动效会自动压平。主题切换也已经扩成 `浅色 / 深色 / 自动` 三态,`自动` 会按打开页面那台机器的本地时区和本地时间切换实际主题,不依赖服务端所在系统或 Docker 容器时区。来源:proxy/templates/console.html:97, proxy/templates/console.html:114, proxy/templates/console.html:134, proxy/static/js/console.js:19, proxy/static/js/console.js:108, proxy/static/js/console.js:178, proxy/static/js/console.js:644, proxy/static/js/console.js:653, proxy/static/js/console.js:808, proxy/static/js/console.js:3123, proxy/static/js/console.js:3495, proxy/static/css/console.css:118, proxy/static/css/console.css:1008, proxy/static/css/console.css:1245, proxy/static/css/console.css:2507, proxy/static/css/console.css:2908 ## 3. 接入 OpenClaw diff --git a/llmdoc/reference/entrypoints-and-config.md b/llmdoc/reference/entrypoints-and-config.md index 4694109..939d5c9 100644 --- a/llmdoc/reference/entrypoints-and-config.md +++ b/llmdoc/reference/entrypoints-and-config.md @@ -26,6 +26,7 @@ - `proxy/server.py:1877` - 控制台页面入口。 - 运行方式见 `proxy/README.md:144`、`proxy/README.md:166`、`proxy/README.md:173` 与 `proxy/Dockerfile:7`。 +- 自动化镜像发布入口见 `.github/workflows/docker-publish.yml:1`。当前 workflow 只构建 `proxy/` 容器,不会把整个仓库打成单镜像;Docker build context 固定为 `./proxy`,并依赖 `proxy/.dockerignore:1` 排除本地数据库和缓存文件。 ### OpenClaw wrapper 入口 diff --git a/llmdoc/reference/runtime-entrypoints.md b/llmdoc/reference/runtime-entrypoints.md index 0e2740d..b58fc36 100644 --- a/llmdoc/reference/runtime-entrypoints.md +++ b/llmdoc/reference/runtime-entrypoints.md @@ -10,6 +10,8 @@ | `mysearch/config.py` | Runtime 配置装配层 | 先读宿主 `~/.codex/config.toml`,再读 `.env` 兜底,并组装 Tavily / Firecrawl / Exa / xAI 的 `ProviderConfig`;Tavily 现在显式区分 `official` 与 `gateway`。运行时还兼容 Python 3.9:在解释器低于 3.10 时,dataclass 装配层会自动去掉 `slots`,避免配置模块导入即失败。来源:mysearch/config.py:17, mysearch/config.py:85, mysearch/config.py:98, mysearch/config.py:281, mysearch/config.py:320 | | `mysearch/clients.py` | 搜索编排与 provider 适配层 | 统一处理 intent、strategy、routing、cache、blended search、extract fallback、research workflow。来源:mysearch/clients.py:277, mysearch/clients.py:385, mysearch/clients.py:677, mysearch/clients.py:802, mysearch/clients.py:964 | | `proxy/server.py` | Proxy 与控制台入口 | 暴露 Tavily / Firecrawl / Exa / Social 代理端点、控制台页面与管理 API;除了默认 `/` 搜索控制台以外,现在还额外提供 `/mysearch` 作为独立的 MySearch 接入页。FastAPI 入口已经切到 lifespan:启动时初始化数据库,退出时统一关闭共享 `http_client`,不再依赖已废弃的 `startup` hook。导入期的 `asyncio.Lock()` 也改成惰性初始化,避免 Python 3.9 在测试或脚本导入阶段留下未关闭事件循环。Tavily 上游链路现在也会对 `th-...` 这类 Hikari 访问令牌做路径兼容:如果 `Base URL` 还停在 Hikari 主机根而不是 `/api/tavily`,probe 与真实转发都会自动补上 `/api/tavily` 前缀;而当 Tavily 当前实际走上游时,控制台还会额外尝试读取上游公开的 `/api/summary`,把 Hikari 的基础摘要映射成 `upstream_summary`。Social / X 则继续分成两档:接通后台 admin 接口时返回完整 token 统计;只有手动上游 key / gateway token 时,只返回 `upstream_visibility` 这类基础接线可视化,不再把“没有后台 token 面板”和“没有接通上游”混成同一组 0 值。兼容后台的 `/v1/admin/tokens` 如果不是直接返回 `pool -> list`,而是包了一层 `{tokens:{...}}`、`{data:{...}}`、`{items:[...]}` 之类 envelope,当前解析逻辑也会先解包再做统计,避免后台已接通但控制台仍显示一排 0。来源:proxy/server.py:219, proxy/server.py:228, proxy/server.py:233, proxy/server.py:736, proxy/server.py:1299, proxy/server.py:2332 | +| `proxy/.dockerignore` | Proxy 容器上下文收口 | Docker 本地构建 `proxy/` 镜像时,会排除 `data/`、`proxy.db`、`__pycache__`、`.env`、README 和 compose 文件,避免把本地 SQLite、缓存和非运行时资产打进镜像上下文。来源:proxy/.dockerignore:1 | +| `.github/workflows/docker-publish.yml` | Proxy Docker 自动构建与发布入口 | 参考 `code/program/todo-list/.github/workflows/docker-publish.yml` 的结构,当前仓库会先跑 Python 单测、`py_compile` 与 `node --check proxy/static/js/console.js`,再用 `context=./proxy` 与 `proxy/Dockerfile` 做 buildx 多架构构建;pull request 只验证构建,push 到 `main` 或 `v*` tag 才登录 Docker Hub 并推送镜像。来源:.github/workflows/docker-publish.yml:1 | | `proxy/templates/console.html` | Proxy 控制台壳模板 | 现在只负责装配默认 live 前端入口:引入 `components/_hero.html`、`components/_settings_modal.html`、`static/css/console.css`、`static/js/console.js`,并把页面组织成 `summary-strip + dashboard-flow` 的纵向结构;默认首页下半区顺序已经收成 `Workspace Navigator -> provider workspace`,`MySearch 快速接入` 不再默认挂在首页,改由 hero 里的“查看 MySearch 接入”跳去独立页面。`Social Compatibility` 也收回到 switcher 区块底部,不再单独占一侧 rail。壳层同时继续托管 `detail-drawer`、统一 `app-dialog` 和 `toast-root` 这三类交互容器。登录壳也继续保留在这里,但已经补了 `auth-meta` 三张元信息卡,让登录页与 dashboard 共用同一套基础设施风格。来源:proxy/templates/console.html:45, proxy/templates/console.html:51, proxy/templates/console.html:63, proxy/templates/console.html:92, proxy/templates/console.html:98, proxy/templates/console.html:125 | | `proxy/templates/mysearch.html` | MySearch 独立接入页模板 | 承载独立的 `MySearch 接入台` 页面,保留同一套登录、主题切换、设置弹窗、detail drawer、dialog 和 toast,但默认只展示统一接入配置、安装路径和通用 token 管理,不再把 provider 工作台或首页 `summary-strip` 混进这一页。来源:proxy/templates/mysearch.html:1, proxy/templates/mysearch.html:45, proxy/templates/mysearch.html:63 | | `proxy/templates/components/_hero.html` | Hero 与首屏焦点组件 | 定义 `Search Operations Desk` 顶部品牌区、主题切换、快捷动作和 4 条 provider lane。右侧“当前工作台”大卡已经从 hero 里移除,不再在这块重复展示当前控制台状态;“查看 MySearch 接入”按钮现在会直接跳到独立的 `/mysearch` 页面。主题切换也不再只有浅色/深色两态,而是 `浅色 -> 深色 -> 自动` 三态循环;`自动` 会按打开页面那台机器的本地时区与本地时间决定当前生效主题。来源:proxy/templates/components/_hero.html:7, proxy/templates/components/_hero.html:14, proxy/templates/components/_hero.html:42, proxy/templates/components/_hero.html:51, proxy/templates/components/_hero.html:77, proxy/static/js/console.js:19, proxy/static/js/console.js:634 | diff --git a/proxy/.dockerignore b/proxy/.dockerignore index 686f68c..656e73d 100644 --- a/proxy/.dockerignore +++ b/proxy/.dockerignore @@ -1,7 +1,16 @@ __pycache__/ *.pyc *.pyo +*.pyd +*.db *.sqlite *.sqlite3 +.DS_Store data/ .env +.env.* +.venv/ +venv/ +README.md +README_EN.md +docker-compose.yml From ebfee8a83df9f98f33594c113895d133b1fc5307 Mon Sep 17 00:00:00 2001 From: zhaishujie <3394180966@qq.com> Date: Sun, 22 Mar 2026 10:20:07 +0800 Subject: [PATCH 08/20] Polish console scrollbar styling --- llmdoc/reference/runtime-entrypoints.md | 2 +- proxy/static/css/console.css | 47 +++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/llmdoc/reference/runtime-entrypoints.md b/llmdoc/reference/runtime-entrypoints.md index b58fc36..12466c9 100644 --- a/llmdoc/reference/runtime-entrypoints.md +++ b/llmdoc/reference/runtime-entrypoints.md @@ -17,7 +17,7 @@ | `proxy/templates/components/_hero.html` | Hero 与首屏焦点组件 | 定义 `Search Operations Desk` 顶部品牌区、主题切换、快捷动作和 4 条 provider lane。右侧“当前工作台”大卡已经从 hero 里移除,不再在这块重复展示当前控制台状态;“查看 MySearch 接入”按钮现在会直接跳到独立的 `/mysearch` 页面。主题切换也不再只有浅色/深色两态,而是 `浅色 -> 深色 -> 自动` 三态循环;`自动` 会按打开页面那台机器的本地时区与本地时间决定当前生效主题。来源:proxy/templates/components/_hero.html:7, proxy/templates/components/_hero.html:14, proxy/templates/components/_hero.html:42, proxy/templates/components/_hero.html:51, proxy/templates/components/_hero.html:77, proxy/static/js/console.js:19, proxy/static/js/console.js:634 | | `proxy/templates/components/_settings_modal.html` | 控制台设置组件 | 设置中心继续按 `Console / Tavily / Social / X` tabs 组织,但每个 tab 现在都加了 `settings-summary-strip` 和 sticky footer;Tavily 的工作模式已经不是原生 `select`,而是隐藏 input + `mode-switch` 分段控件,并在设置面板里直接展示“当前实际”运行条。Tavily 与 Social / X 的 footer 里还新增了“测试当前连接”按钮,对应后端 `settings-test` 诊断接口;测试结果不再只是状态句子,而是通过独立的 `settings-*-probe` 区块呈现请求目标、鉴权来源、返回状态、失败原因和建议动作。Social / X 设置表单里的字段标题也已经按职责重命名成“搜索上游 Base URL / 后台管理地址 / 后台管理 app key / 搜索上游 API key / 客户端访问 token”,避免把搜索转发配置和后台管理配置混在一起理解。来源:proxy/templates/components/_settings_modal.html:18, proxy/templates/components/_settings_modal.html:35, proxy/templates/components/_settings_modal.html:61, proxy/templates/components/_settings_modal.html:77, proxy/templates/components/_settings_modal.html:79, proxy/templates/components/_settings_modal.html:85, proxy/templates/components/_settings_modal.html:117, proxy/templates/components/_settings_modal.html:218 | | `proxy/static/js/console.js` | 控制台交互与渲染主入口 | 负责主题持久化、当前工作台信号判断、workspace navigator、provider 面板、MySearch quickstart、settings form 回填,以及 Tavily/Social 设置保存;新一轮 UI 里除了 `renderSettingsSummaries()`、`renderPoolGlance()`、`openDetailDrawer()`、`openTokenDetail()`、`openKeyDetail()` 和 `showConfirmDialog()` 之外,还新增了 `normalizeRefreshScope()`、`getRefreshScopeForService()`、`renderDashboardScope()` 这组局部刷新机制,用来避免每次操作都全量重绘 hero / summary / quickstart / 全部 workspace。脚本现在还会根据 `PAGE_KIND` 区分默认控制台与 `/mysearch` 独立接入页:前者继续渲染 `summary-strip`、switcher 与 provider workspace,后者只渲染 `renderMySearchQuickstart()`;hero 的“查看 MySearch 接入”按钮也已经改成跳转 `/mysearch`。默认首页的 `summary-strip` 口径现在只保留项目级概览:`当前工作台 / 已接通工作台 / Provider 代理 Token / 今日调用 / 本月调用 / MySearch Token`。其中 `Provider 代理 Token` 会按工作台当前是否走本地代理池动态统计,例如 Tavily 或 Social / X 切到上游后就从这项里剔除,避免再把工作台内部的上游额度、`Social Chat` 或本地 API Key 混进全局卡片。`renderMySearchQuickstart()` 现在改成纵向三层,其中“一键配置”内部再拆成左侧 `quickstart-visual-col` readiness 可视化区和右侧 `quickstart-config-col` 配置区,而且 `Proxy Base URL` 到 `Social / X` 那组摘要也已经下沉到左侧可视化区底部;`安装路径` 则继续拆成左右两列,左边是 `quickstart-install-strip`,右边是命令区。独立接入页标题已经收成 `MySearch 接入台`,模块级标题则用 `统一接入配置`,不再重复出现两层 `MySearch 快速接入`。主题切换也已经变成 `light / dark / auto` 三态;`auto` 依据浏览器本地时区与本地时间决定当前生效主题,并会在页面重新获得焦点或定时轮询时自动重算。设置页除了 `testTavilySettings()`、`testSocialSettings()` 以外,还会通过 `renderSettingsProbe()` 渲染结构化诊断卡。现在还额外加入了 `getTavilyUpstreamSummary()` 与 `getSocialUpstreamState()` 这组分层逻辑:Tavily upstream 模式优先展示 Hikari 公共摘要,Social / X 则在 `admin-auto` 时展示完整 token 统计,在只有手动上游鉴权时退化成“上游 key 数 / 客户端 token 数 / 可转发状态”的基础可视化,避免把“后台未接通”和“上游未配置”混成相同的 0 值。键盘和焦点层则新增了 `handleSegmentedControlKey()`、overlay focus remember/restore 和 `trapOverlayFocus()`,把 `mode-switch / mini-switch / settings-tab` 的方向键导航、toast live region、dialog 焦点回收收进统一逻辑。来源:proxy/static/js/console.js:16, proxy/static/js/console.js:634, proxy/static/js/console.js:797, proxy/static/js/console.js:822, proxy/static/js/console.js:1268, proxy/static/js/console.js:1695, proxy/static/js/console.js:2718, proxy/static/js/console.js:3186 | -| `proxy/static/css/console.css` | 控制台视觉系统入口 | 负责新 UI 的字体、暖色玻璃化背景、hero 装饰层、纵向 `dashboard-flow`、collapsible detail cards、settings modal 和 toast 样式;现在也包含登录页 `auth-meta`、`#dashboard.is-entering` 分段入场动画、按钮状态 `btn.is-busy / is-success / is-error`、工作台切换 reveal 的 `workspace-stage-shift / service-panel-focus-in`、横向 `service-switcher` 卡阵列、MySearch `quickstart-grid / quickstart-primary-layout / quickstart-visual-col / quickstart-config-col / quickstart-route-strip / quickstart-install-layout / quickstart-install-strip / quickstart-install-meta`、Tavily `mode-switch`、provider 筛选条 `table-tools / mini-switch`、结构化诊断卡 `settings-probe`、表格风险底色 `table-row-clickable.is-*`、键盘 focus ring、行态图例 `table-legend`、drawer 底部动作分组 `drawer-action-group`、Social 的 `credit-strip-inline`、sticky footer、`detail-drawer`、`app-dialog`,以及 `prefers-reduced-motion` 下自动压平 staged reveal / 按钮 / 切换动画的规则。来源:proxy/static/css/console.css:622, proxy/static/css/console.css:717, proxy/static/css/console.css:1061, proxy/static/css/console.css:1508, proxy/static/css/console.css:1533, proxy/static/css/console.css:1891, proxy/static/css/console.css:1999, proxy/static/css/console.css:2923, proxy/static/css/console.css:2943, proxy/static/css/console.css:2976 | +| `proxy/static/css/console.css` | 控制台视觉系统入口 | 负责新 UI 的字体、暖色玻璃化背景、hero 装饰层、纵向 `dashboard-flow`、collapsible detail cards、settings modal 和 toast 样式;现在也包含登录页 `auth-meta`、`#dashboard.is-entering` 分段入场动画、按钮状态 `btn.is-busy / is-success / is-error`、工作台切换 reveal 的 `workspace-stage-shift / service-panel-focus-in`、横向 `service-switcher` 卡阵列、MySearch `quickstart-grid / quickstart-primary-layout / quickstart-visual-col / quickstart-config-col / quickstart-route-strip / quickstart-install-layout / quickstart-install-strip / quickstart-install-meta`、Tavily `mode-switch`、provider 筛选条 `table-tools / mini-switch`、结构化诊断卡 `settings-probe`、表格风险底色 `table-row-clickable.is-*`、键盘 focus ring、行态图例 `table-legend`、drawer 底部动作分组 `drawer-action-group`、Social 的 `credit-strip-inline`、sticky footer、`detail-drawer`、`app-dialog`,以及 `prefers-reduced-motion` 下自动压平 staged reveal / 按钮 / 切换动画的规则。当前共享样式层还统一接管了全局滚动条主题:亮/暗主题分别定义 Firefox `scrollbar-color` 与 WebKit `::-webkit-scrollbar*`,并通过 `scrollbar-gutter: stable` 减少页面因滚动条出现或消失带来的横向抖动。来源:proxy/static/css/console.css:1, proxy/static/css/console.css:69, proxy/static/css/console.css:622, proxy/static/css/console.css:717, proxy/static/css/console.css:1061, proxy/static/css/console.css:1508, proxy/static/css/console.css:1533, proxy/static/css/console.css:1891, proxy/static/css/console.css:1999, proxy/static/css/console.css:2923, proxy/static/css/console.css:2943, proxy/static/css/console.css:2976 | | `proxy/database.py` | Proxy 持久化入口 | 管理 SQLite、key/token/usage/settings 表,以及 `mysp-` token 前缀。来源:proxy/database.py:11, proxy/database.py:61 | | `skill/README.md` | AI 安装入口 | 告诉 Codex / Claude Code 如何从源码仓或远程 MCP 入口安装与验收 MySearch。来源:skill/README.md:40 | | `openclaw/README.md` | OpenClaw 安装入口 | 告诉 OpenClaw/ClawHub 如何安装 bundle、注入 env、执行健康检查。来源:openclaw/README.md:53 | diff --git a/proxy/static/css/console.css b/proxy/static/css/console.css index 1372d09..bfb63bc 100644 --- a/proxy/static/css/console.css +++ b/proxy/static/css/console.css @@ -25,6 +25,11 @@ --warn: #b76a11; --danger: #bb3b31; --info: #1d4ed8; + --scrollbar-track: rgba(116, 128, 142, 0.12); + --scrollbar-thumb: rgba(37, 52, 67, 0.34); + --scrollbar-thumb-hover: rgba(22, 32, 43, 0.56); + --scrollbar-thumb-active: rgba(15, 118, 110, 0.54); + --scrollbar-outline: rgba(255, 251, 246, 0.92); --tavily: #0f766e; --tavily-soft: rgba(15, 118, 110, 0.12); @@ -60,6 +65,11 @@ body.theme-dark { --warn: #fbbf24; --danger: #f87171; --info: #60a5fa; + --scrollbar-track: rgba(148, 163, 184, 0.12); + --scrollbar-thumb: rgba(214, 211, 205, 0.26); + --scrollbar-thumb-hover: rgba(248, 246, 241, 0.42); + --scrollbar-thumb-active: rgba(45, 212, 191, 0.48); + --scrollbar-outline: rgba(18, 25, 34, 0.94); --tavily: #2dd4bf; --exa: #60a5fa; --firecrawl: #fb923c; @@ -80,6 +90,43 @@ body.theme-dark { html { scroll-behavior: smooth; + scrollbar-gutter: stable; +} + +* { + scrollbar-width: thin; + scrollbar-color: var(--scrollbar-thumb) transparent; +} + +*::-webkit-scrollbar { + width: 12px; + height: 12px; +} + +*::-webkit-scrollbar-track { + background: transparent; +} + +*::-webkit-scrollbar-thumb { + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.22), var(--scrollbar-thumb)); + border-radius: 999px; + border: 3px solid var(--scrollbar-outline); + background-clip: padding-box; +} + +*::-webkit-scrollbar-thumb:hover { + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.28), var(--scrollbar-thumb-hover)); +} + +*::-webkit-scrollbar-thumb:active { + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.22), var(--scrollbar-thumb-active)); +} + +*::-webkit-scrollbar-corner { + background: transparent; } body { From 4476d5363ced7d4bd3958e365b4e84650932ce0f Mon Sep 17 00:00:00 2001 From: zhaishujie <3394180966@qq.com> Date: Sun, 22 Mar 2026 11:30:12 +0800 Subject: [PATCH 09/20] Add all-in-one MySearch stack deployment --- .dockerignore | 18 +++++ .github/workflows/docker-publish.yml | 54 +++++++++++-- Dockerfile.stack | 22 ++++++ README.md | 34 +++++--- docker-compose.yml | 47 +++++++++++ docker/combined-entrypoint.sh | 33 ++++++++ llmdoc/architecture/proxy-first.md | 2 +- llmdoc/guides/common-workflows.md | 20 ++--- llmdoc/reference/runtime-entrypoints.md | 12 ++- mysearch/.dockerignore | 14 ++++ mysearch/Dockerfile | 18 +++++ mysearch/README.md | 44 +++++++++++ mysearch/docker-entrypoint.sh | 10 +++ mysearch/scripts/bootstrap_proxy_token.py | 64 +++++++++++++++ proxy/README.md | 32 +++++++- proxy/database.py | 12 +++ proxy/server.py | 44 +++++++++++ tests/test_proxy_bootstrap.py | 96 +++++++++++++++++++++++ 18 files changed, 546 insertions(+), 30 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile.stack create mode 100644 docker-compose.yml create mode 100644 docker/combined-entrypoint.sh create mode 100644 mysearch/.dockerignore create mode 100644 mysearch/Dockerfile create mode 100644 mysearch/docker-entrypoint.sh create mode 100644 mysearch/scripts/bootstrap_proxy_token.py create mode 100644 tests/test_proxy_bootstrap.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c28aad0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,18 @@ +.git +.github +.codex-tasks +llmdoc +venv +.venv +__pycache__ +*.pyc +*.pyo +*.pyd +.env +.env.* +proxy/data +proxy/__pycache__ +mysearch/__pycache__ +openclaw/__pycache__ +tests/__pycache__ +docs/images diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index fb06ef4..320e0bd 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -1,4 +1,4 @@ -name: Build and Publish Proxy Docker Image +name: Build and Publish Docker Images on: push: @@ -61,13 +61,37 @@ jobs: run: node --check proxy/static/js/console.js docker: - name: Build and publish multi-arch proxy image + name: Build and publish ${{ matrix.target_label }} image runs-on: ubuntu-latest needs: verify + strategy: + fail-fast: false + matrix: + include: + - target: proxy + target_label: proxy + context: ./proxy + dockerfile: ./proxy/Dockerfile + default_image_name: mysearch-proxy + - target: mysearch + target_label: mysearch MCP + context: ./mysearch + dockerfile: ./mysearch/Dockerfile + default_image_name: mysearch-mcp + - target: stack + target_label: all-in-one stack + context: . + dockerfile: ./Dockerfile.stack + default_image_name: mysearch-stack env: DOCKERHUB_USERNAME_VAR: ${{ vars.DOCKERHUB_USERNAME }} DOCKERHUB_USERNAME_SECRET: ${{ secrets.DOCKERHUB_USERNAME }} - IMAGE_NAME: ${{ vars.DOCKERHUB_IMAGE_NAME || 'mysearch-proxy' }} + IMAGE_NAME_PROXY_VAR: ${{ vars.DOCKERHUB_IMAGE_NAME_PROXY }} + IMAGE_NAME_PROXY_SECRET: ${{ secrets.DOCKERHUB_IMAGE_NAME_PROXY }} + IMAGE_NAME_MYSEARCH_VAR: ${{ vars.DOCKERHUB_IMAGE_NAME_MYSEARCH }} + IMAGE_NAME_MYSEARCH_SECRET: ${{ secrets.DOCKERHUB_IMAGE_NAME_MYSEARCH }} + IMAGE_NAME_STACK_VAR: ${{ vars.DOCKERHUB_IMAGE_NAME_STACK }} + IMAGE_NAME_STACK_SECRET: ${{ secrets.DOCKERHUB_IMAGE_NAME_STACK }} steps: - name: Checkout uses: actions/checkout@v4 @@ -77,13 +101,31 @@ jobs: env: GITHUB_EVENT_NAME: ${{ github.event_name }} DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + TARGET: ${{ matrix.target }} + DEFAULT_IMAGE_NAME: ${{ matrix.default_image_name }} run: | trim_single_line() { printf '%s' "$1" | tr -d '\r\n' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//' } DOCKERHUB_USERNAME="$(trim_single_line "${DOCKERHUB_USERNAME_VAR:-${DOCKERHUB_USERNAME_SECRET:-}}")" - IMAGE_NAME_CLEAN="$(trim_single_line "${IMAGE_NAME}" | tr '[:upper:]' '[:lower:]')" + case "${TARGET}" in + proxy) + IMAGE_NAME_RAW="${IMAGE_NAME_PROXY_VAR:-${IMAGE_NAME_PROXY_SECRET:-${DEFAULT_IMAGE_NAME}}}" + ;; + mysearch) + IMAGE_NAME_RAW="${IMAGE_NAME_MYSEARCH_VAR:-${IMAGE_NAME_MYSEARCH_SECRET:-${DEFAULT_IMAGE_NAME}}}" + ;; + stack) + IMAGE_NAME_RAW="${IMAGE_NAME_STACK_VAR:-${IMAGE_NAME_STACK_SECRET:-${DEFAULT_IMAGE_NAME}}}" + ;; + *) + echo "Unsupported target: ${TARGET}" + exit 1 + ;; + esac + + IMAGE_NAME_CLEAN="$(trim_single_line "${IMAGE_NAME_RAW}" | tr '[:upper:]' '[:lower:]')" test -n "${DOCKERHUB_USERNAME}" || { echo "Missing Docker Hub username. Set Actions Variable DOCKERHUB_USERNAME or Secret DOCKERHUB_USERNAME." @@ -148,8 +190,8 @@ jobs: - name: Build and push Docker image uses: docker/build-push-action@v6 with: - context: ./proxy - file: ./proxy/Dockerfile + context: ${{ matrix.context }} + file: ${{ matrix.dockerfile }} platforms: linux/amd64,linux/arm64 push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} diff --git a/Dockerfile.stack b/Dockerfile.stack new file mode 100644 index 0000000..4a20c40 --- /dev/null +++ b/Dockerfile.stack @@ -0,0 +1,22 @@ +FROM python:3.11-slim + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PYTHONPATH=/app \ + MYSEARCH_PROXY_BASE_URL=http://127.0.0.1:9874 + +WORKDIR /app + +COPY proxy/requirements.txt /tmp/proxy-requirements.txt +COPY mysearch/requirements.txt /tmp/mysearch-requirements.txt +RUN pip install --no-cache-dir -r /tmp/proxy-requirements.txt -r /tmp/mysearch-requirements.txt + +COPY proxy /app/proxy +COPY mysearch /app/mysearch +COPY docker /app/docker + +RUN chmod +x /app/docker/combined-entrypoint.sh /app/mysearch/docker-entrypoint.sh + +EXPOSE 9874 8000 + +CMD ["/app/docker/combined-entrypoint.sh"] diff --git a/README.md b/README.md index 116c618..190e68e 100644 --- a/README.md +++ b/README.md @@ -197,26 +197,38 @@ python3 skill/scripts/check_mysearch.py --health-only python3 skill/scripts/check_mysearch.py --web-query "OpenAI latest announcements" ``` -### 路线 B:先部署 Proxy,再让所有客户端复用 +### 路线 B:最简单的单容器部署 ```bash -mkdir -p mysearch-proxy-data - docker run -d \ - --name mysearch-proxy \ + --name mysearch-stack \ --restart unless-stopped \ -p 9874:9874 \ + -p 8000:8000 \ -e ADMIN_PASSWORD=change-me \ - -v $(pwd)/mysearch-proxy-data:/app/data \ - skernelx/mysearch-proxy:latest + -e MYSEARCH_PROXY_BOOTSTRAP_TOKEN=change-me-bootstrap-token \ + -v $(pwd)/mysearch-proxy-data:/app/proxy/data \ + skernelx/mysearch-stack:latest +``` + +部署完成后: + +- `proxy` 控制台:`http://localhost:9874` +- `mysearch` MCP:`http://localhost:8000/mcp` + +这条链路里不再需要手动先创建 `mysp-` token。容器启动时会通过受限 bootstrap 接口自动创建或复用一个 `mysearch` 代理 token,再交给同容器里的 `mysearch` 运行时使用。 + +### 路线 C:一套 compose 部署 `proxy + mysearch` + +```bash +cd /path/to/MySearch-Proxy +docker compose up -d ``` -部署后: +部署完成后: -1. 登录控制台 -2. 添加 Tavily / Firecrawl / Exa / Social 上游配置 -3. 创建 MySearch 通用 token -4. 把这个 token 填给 `mysearch/.env` 或 OpenClaw skill env +- `proxy` 控制台:`http://localhost:9874` +- `mysearch` MCP:`http://localhost:8000/mcp` ## 目录说明 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..702cadd --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,47 @@ +services: + proxy: + build: + context: ./proxy + dockerfile: Dockerfile + ports: + - "${MYSEARCH_PROXY_PORT:-9874}:9874" + environment: + ADMIN_PASSWORD: ${ADMIN_PASSWORD:-change-me} + MYSEARCH_PROXY_BOOTSTRAP_TOKEN: ${MYSEARCH_PROXY_BOOTSTRAP_TOKEN:-change-me-bootstrap-token} + STATS_CACHE_TTL_SECONDS: ${STATS_CACHE_TTL_SECONDS:-8} + DASHBOARD_AUTO_SYNC_ON_STATS: ${DASHBOARD_AUTO_SYNC_ON_STATS:-0} + DASHBOARD_BACKGROUND_SYNC_ON_STATS: ${DASHBOARD_BACKGROUND_SYNC_ON_STATS:-1} + DASHBOARD_BACKGROUND_SYNC_MIN_INTERVAL_SECONDS: ${DASHBOARD_BACKGROUND_SYNC_MIN_INTERVAL_SECONDS:-45} + volumes: + - mysearch-proxy-data:/app/data + restart: unless-stopped + + mysearch: + build: + context: ./mysearch + dockerfile: Dockerfile + depends_on: + - proxy + ports: + - "${MYSEARCH_MCP_PORT:-8000}:8000" + environment: + MYSEARCH_NAME: ${MYSEARCH_NAME:-MySearch} + MYSEARCH_TIMEOUT_SECONDS: ${MYSEARCH_TIMEOUT_SECONDS:-45} + MYSEARCH_PROXY_BASE_URL: ${MYSEARCH_PROXY_BASE_URL:-http://proxy:9874} + MYSEARCH_PROXY_API_KEY: ${MYSEARCH_PROXY_API_KEY:-} + MYSEARCH_PROXY_BOOTSTRAP_TOKEN: ${MYSEARCH_PROXY_BOOTSTRAP_TOKEN:-change-me-bootstrap-token} + MYSEARCH_PROXY_BOOTSTRAP_NAME: ${MYSEARCH_PROXY_BOOTSTRAP_NAME:-docker-mysearch} + MYSEARCH_MCP_HOST: 0.0.0.0 + MYSEARCH_MCP_PORT: 8000 + MYSEARCH_MCP_STREAMABLE_HTTP_PATH: ${MYSEARCH_MCP_STREAMABLE_HTTP_PATH:-/mcp} + MYSEARCH_MCP_SSE_PATH: ${MYSEARCH_MCP_SSE_PATH:-/sse} + MYSEARCH_MCP_STATELESS_HTTP: ${MYSEARCH_MCP_STATELESS_HTTP:-false} + MYSEARCH_MAX_PARALLEL_WORKERS: ${MYSEARCH_MAX_PARALLEL_WORKERS:-4} + MYSEARCH_SEARCH_CACHE_TTL_SECONDS: ${MYSEARCH_SEARCH_CACHE_TTL_SECONDS:-30} + MYSEARCH_EXTRACT_CACHE_TTL_SECONDS: ${MYSEARCH_EXTRACT_CACHE_TTL_SECONDS:-300} + entrypoint: ["/app/mysearch/docker-entrypoint.sh"] + command: ["python", "-m", "mysearch", "--transport", "streamable-http", "--host", "0.0.0.0", "--port", "8000"] + restart: unless-stopped + +volumes: + mysearch-proxy-data: diff --git a/docker/combined-entrypoint.sh b/docker/combined-entrypoint.sh new file mode 100644 index 0000000..da6d604 --- /dev/null +++ b/docker/combined-entrypoint.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail + +export PYTHONPATH="${PYTHONPATH:-/app}" +export MYSEARCH_PROXY_BASE_URL="${MYSEARCH_PROXY_BASE_URL:-http://127.0.0.1:9874}" + +cleanup() { + local exit_code=$? + if [[ -n "${MCP_PID:-}" ]]; then + kill "${MCP_PID}" 2>/dev/null || true + fi + if [[ -n "${PROXY_PID:-}" ]]; then + kill "${PROXY_PID}" 2>/dev/null || true + fi + wait 2>/dev/null || true + exit "${exit_code}" +} + +trap cleanup EXIT INT TERM + +python -m uvicorn proxy.server:app --host 127.0.0.1 --port 9874 & +PROXY_PID=$! + +if [[ -z "${MYSEARCH_PROXY_API_KEY:-}" && -n "${MYSEARCH_PROXY_BOOTSTRAP_TOKEN:-}" ]]; then + export MYSEARCH_PROXY_API_KEY="$( + python /app/mysearch/scripts/bootstrap_proxy_token.py + )" +fi + +python -m mysearch --transport streamable-http --host 0.0.0.0 --port "${MYSEARCH_MCP_PORT:-8000}" & +MCP_PID=$! + +wait -n "${PROXY_PID}" "${MCP_PID}" diff --git a/llmdoc/architecture/proxy-first.md b/llmdoc/architecture/proxy-first.md index cf72489..4c82f1f 100644 --- a/llmdoc/architecture/proxy-first.md +++ b/llmdoc/architecture/proxy-first.md @@ -52,5 +52,5 @@ - Proxy 的启动时机会执行 `db.init_db()`;SQLite 默认路径是 `proxy/data/proxy.db`。来源:proxy/server.py:42, proxy/database.py:11, proxy/database.py:61 - Proxy 的 token 体系里包含 `mysearch` 服务,生成前缀为 `mysp-` 的统一 token,默认只做鉴权与统计,不做配额拦截。来源:proxy/database.py:13, proxy/database.py:18, proxy/README.md:74, proxy/README.md:83 -- Proxy 的容器发布边界也已经固定在 `proxy/` 目录本身,而不是整个仓库根。`.github/workflows/docker-publish.yml` 现在会先验证整仓的 Python/前端语法与测试,再只对 `context=./proxy`、`file=./proxy/Dockerfile` 执行 buildx 多架构构建;`proxy/.dockerignore` 会同步排除 `data/`、`proxy.db`、`__pycache__` 与本地 `.env`,避免把控制台本地状态打进镜像。来源:.github/workflows/docker-publish.yml:1, proxy/.dockerignore:1 +- `proxy-first` 的容器部署边界现在同时支持“两服务一套 stack”和“单容器一体化镜像”两种形态。仓库根的 `docker-compose.yml` 会同时编排 `proxy` 与 `mysearch`:前者负责控制台、token 与统一代理,后者负责对 Codex/Claude 暴露远程 MCP;`mysearch` 在同一个 compose 网络里继续用 `MYSEARCH_PROXY_BASE_URL=http://proxy:9874` 访问 Proxy,并通过受限的 `MYSEARCH_PROXY_BOOTSTRAP_TOKEN` 向 `/api/internal/mysearch/token` 申请或复用专用的 `mysp-` token。对于更看重部署步骤最少的场景,`Dockerfile.stack` 与 `docker/combined-entrypoint.sh` 又把这两个进程收成单容器镜像 `mysearch-stack`。GitHub Actions 侧也已经从“只发 Proxy 镜像”扩成三镜像 matrix:`.github/workflows/docker-publish.yml` 会分别构建/发布 `proxy`、`mysearch` 与 `stack`,而根目录、`proxy/` 和 `mysearch/` 的 `.dockerignore` 则分别收口各自上下文,避免把本地 SQLite、`.env`、accounts 文件与缓存一起打进镜像。来源:docker-compose.yml:1, Dockerfile.stack:1, docker/combined-entrypoint.sh:1, .github/workflows/docker-publish.yml:1, .dockerignore:1, proxy/.dockerignore:1, mysearch/.dockerignore:1 - Proxy 控制台现在已经从单文件模板拆成 `console.html + _hero.html + _settings_modal.html + console.css + console.js` 这套 live 前端;页面布局已经回到 `summary-strip + dashboard-flow` 的纵向结构,默认首页下半区固定为 `Workspace Navigator -> provider workspace`,而统一客户端接入则拆到独立的 `/mysearch` 页面。`Workspace Navigator` 仍然只保留工作台名称、状态和 2 个核心指标,次要信息下沉到 badge 与 footnote,不再展示 `/api/search`、`/social/search` 这类具体请求路径;但它现在不再纵向堆叠,而是由 `service-switcher` 横向卡阵列承接,`Social Compatibility` 提示卡也继续收在 switcher 区块底部。登录入口也不再是孤立小表单,而是通过 `auth-meta` 把“统一入口 / provider / 控制面”三个概念先交代清楚,并在登录成功后由 `showDashboard({ animate: true })` 做一轮轻量 staged reveal,而在 `prefers-reduced-motion` 下会自动压平动效。hero 右侧原先那张“当前工作台”大卡已经移除,不再在首屏重复展示当前控制台状态。`/mysearch` 页则收成 `MySearch 接入台`,模块标题进一步压成 `统一接入配置`,避免页级标题和模块标题重复。该页内部继续保持“接入配置 / 安装路径 / 通用 Token 管理”三层,而且“一键配置”内部进一步拆成左侧 `quickstart-visual-col` 可视化 readiness 区和右侧 `quickstart-config-col` 配置区:`getQuickstartProviderCards()` 继续把 Tavily `effective_mode`、Exa / Firecrawl key 状态和 Social / X 接线结果汇总成 `quickstart-route-strip`,`getQuickstartInstallHint()` 继续把当前最短安装路径压成 `quickstart-install-strip`,同时也把旧版更直接的 `stdio / streamable-http` 安装形态补回到 `quickstart-install-meta`。这些状态会一起写入生成的 `MYSEARCH_PROXY_*` 配置说明;除了复制块旁边的普通复制按钮,现在还额外提供 `copyEnvAndRevealInstall()` 这个组合动作,直接复制 `.env` 并把视口定位到安装命令。默认首页的 `summary-strip` 也已经收窄成项目级概览:`当前工作台 / 已接通工作台 / Provider 代理 Token / 今日调用 / 本月调用 / MySearch Token`,不再塞入工作台内部已经会单独展示的上游额度或本地 API Key。主题切换则扩成 `浅色 / 深色 / 自动` 三态,`自动` 依据打开页面那台机器的本地时区与本地时间决定实际主题,不依赖服务端所在系统或容器时区。`MySearch 通用 Token` 摘要表继续共享和 provider 面板一致的本地搜索/排序逻辑。provider 页面仍然保持“摘要表 + `detail-drawer`”的运维视图,`Token 池 / API Key 池` 的本地搜索、筛选和排序,以及 `table-row-clickable.is-danger|is-warn|is-busy|is-off` 风险行态都保留不变;`detail-drawer` 底部动作也继续通过 `renderDrawerActionGroup()` 拆成“维护动作 / 危险动作”两组。设置面板仍是带 `settings-summary-strip`、sticky footer 和 Tavily `mode-switch` 分段控件的配置中心,并保留 `/api/settings/test/tavily` 与 `/api/settings/test/social` 这两条结构化 probe 链路。控制台刷新仍然通过 `normalizeRefreshScope()`、`getRefreshScopeForService()`、`renderDashboardScope()` 做局部更新,可访问性层也仍然保留 `handleSegmentedControlKey()`、toast live region、overlay focus remember/restore 与 `trapOverlayFocus()` 这一组统一逻辑。来源:proxy/templates/components/_hero.html:25, proxy/templates/components/_hero.html:51, proxy/templates/console.html:51, proxy/templates/console.html:54, proxy/templates/console.html:65, proxy/templates/mysearch.html:21, proxy/static/js/console.js:644, proxy/static/js/console.js:1425, proxy/static/js/console.js:1478, proxy/static/js/console.js:1695, proxy/static/js/console.js:2251, proxy/static/js/console.js:2777, proxy/static/css/console.css:622, proxy/static/css/console.css:717, proxy/static/css/console.css:1061, proxy/static/css/console.css:1508, proxy/static/css/console.css:1533, proxy/static/css/console.css:2923, proxy/static/css/console.css:2943, proxy/server.py:2259 diff --git a/llmdoc/guides/common-workflows.md b/llmdoc/guides/common-workflows.md index f30423b..9a928f0 100644 --- a/llmdoc/guides/common-workflows.md +++ b/llmdoc/guides/common-workflows.md @@ -8,15 +8,17 @@ ## 2. 部署 proxy-first 链路 -1. 先部署 `proxy/`,这层负责统一接上游 provider、生成 `mysp-` token 并暴露控制台。来源:README.md:200, proxy/README.md:102 -2. 如果你希望 Proxy 镜像自动发布到 Docker Hub,当前仓库已经补了 `.github/workflows/docker-publish.yml`。它参考 `code/program/todo-list/.github/workflows/docker-publish.yml` 的结构,但适配成当前仓库的 Python/Proxy 版本:先跑 `python -m unittest discover -s tests`、`py_compile` 和 `node --check proxy/static/js/console.js`,再按 `context=./proxy` 与 `proxy/Dockerfile` 做 buildx 多架构构建;pull request 只验证构建,push 到 `main` 或打 `v*` tag 时才真正发布镜像。镜像仓库默认走 `DOCKERHUB_USERNAME/mysearch-proxy`,也可以用 Actions Variable `DOCKERHUB_IMAGE_NAME` 覆盖。来源:.github/workflows/docker-publish.yml:1 -3. `proxy/` 目录现在也有 `.dockerignore`,会排除 `data/`、`proxy.db`、`__pycache__`、`.env`、compose 和 README,避免本地数据库与缓存文件被 `COPY . .` 带进镜像上下文。来源:proxy/.dockerignore:1 -4. 首次进入默认搜索控制台后,顶部 hero 不再单独重复展示“当前工作台”大卡;首屏只保留品牌区、快捷动作和 4 条 provider lane。如果想直接进入操作区,优先用 `进入当前工作台`;如果想看统一客户端接入配置,则用 `查看 MySearch 接入` 跳到独立的 `/mysearch` 页面。来源:proxy/templates/components/_hero.html:25, proxy/templates/components/_hero.html:42, proxy/templates/components/_hero.html:51, proxy/templates/components/_hero.html:77, proxy/static/js/console.js:631 -5. 默认搜索控制台下半区现在固定为 `Workspace Navigator -> 具体工作台`,不再默认把 `MySearch 快速接入` 挂在首页。switcher 卡片只保留工作台名称、状态和 2 个核心指标,次要信息下沉成 badge 与说明,不再显示 `/api/search` 这类具体请求路径;同时它已经改成横向卡阵列,不再一张张纵向堆高页面。`Social Compatibility` 也已经收回到 switcher 区块底部。Tavily 现在不是手动二选一,而是进 `Settings -> Tavily` 看 `auto|pool|upstream` 三态分段控件,保存后前端会同时显示“配置模式 / 当前实际 / 来源”,其中 `auto` 会按“上游凭证优先,其次本地活跃 Tavily key”自动解析;如果你只是导入 Tavily key,默认实际就会落到 API Key 池。Social/X 的 grok2api 或 compatible 配置仍进 `Settings -> Social / X`,而且字段标题现在按职责拆成“搜索上游”和“后台管理”两类,不再把 `Base URL`、后台地址和 app key 混成一组理解。设置中心每个 tab 都带 `settings-summary-strip`、sticky footer 和“测试当前连接”按钮,而且测试结果会直接展开成结构化 probe 卡,不需要自己从一行状态文案里猜请求目标或鉴权来源。来源:proxy/templates/console.html:51, proxy/templates/console.html:61, proxy/templates/components/_settings_modal.html:61, proxy/templates/components/_settings_modal.html:117, proxy/templates/components/_settings_modal.html:218, proxy/static/css/console.css:1061, proxy/static/js/console.js:2262 -6. 具体 provider 页面现在统一先看 `stats + 接线摘要`,再按需展开 `Token 池` 和 `API Key 池` 两个 detail cards;主表已经降成摘要视图,点击任一 token/key 行会打开右侧 `detail-drawer` 查看完整额度、账户层级信息和维护动作。新增的本地筛选条会直接在前端做搜索、筛选和排序,而且已经补到 `失败优先`、`待处理`、`异常优先`、`低额度优先` 这类运维向视角;表格行也可以直接用键盘 `Enter / Space` 打开详情抽屉。需要特别区分的是“本地池统计”和“上游状态”:Tavily 当前实际走 upstream 时,概览优先展示上游 Hikari 的公共摘要,例如活跃 key、耗尽 key、总请求与总剩余额度,本地 Tavily key 会降级成回退库存;Social / X 在接通 grok2api 后台时继续显示完整 token 统计,但如果只有手动上游 key / gateway token,就只展示基础接线可视化,例如上游 key 数、客户端 token 数和可转发状态,不再把后台未接通误显示成一排 0。对于兼容后台的 `/v1/admin/tokens`,控制台现在也会先解包 `{tokens:{...}}`、`{data:{...}}`、`{items:[...]}` 这类响应再做统计,避免后台地址和 app key 都已配置但 token 总数仍错误地显示为 0。初始化顺序仍建议保持“登录 -> 导入 Tavily/Firecrawl/Exa key -> 需要时补 Social/X -> sync usage -> 创建 `mysp-` token”。来源:proxy/templates/console.html:94, proxy/static/js/console.js:845, proxy/static/js/console.js:1268, proxy/static/js/console.js:2631, proxy/static/js/console.js:2718 -7. `MySearch 快速接入` 现在已经独立到 `/mysearch` 页面,不再默认出现在搜索控制台首页,这一页也不再重复展示首页 `summary-strip`。页级标题已经收成 `MySearch 接入台`,模块级标题则用 `统一接入配置`,不再两层都重复 `MySearch 快速接入`。页面内部继续收成“接入配置 / 安装路径 / 通用 Token 管理”三层,而且“一键配置”内部再拆成左侧可视化 readiness 区和右侧配置区,不再让 route 小卡片和 `.env`/说明混在一列里。`quickstart-route-strip` 会根据 Tavily `effective_mode`、Exa / Firecrawl key 状态和 Social / X 接线情况,动态显示当前 provider readiness;`quickstart-install-strip` 则把“创建通用 token → 复制 `.env` → ./install.sh”压成当前最短安装路径,并额外提供 `复制 .env 并定位命令` 的组合动作;旧版更直观的默认安装形态也通过 `quickstart-install-meta` 补回来了,直接展示 `stdio / streamable-http`。生成的 `.env` 里也会把“当前路由状态”写进去。客户端侧仍只保留 `MYSEARCH_PROXY_BASE_URL` 与 `MYSEARCH_PROXY_API_KEY`,不再把 provider key 散落到每台机器;`MySearch 通用 Token` 摘要表也补上了本地搜索和排序。来源:README.md:79, proxy/templates/mysearch.html:45, proxy/static/js/console.js:1433, proxy/static/js/console.js:1486, proxy/static/js/console.js:1546, proxy/static/js/console.js:1695 -8. 默认首页的 `summary-strip` 现在更偏“控制面概览”而不是“把所有 provider 细节都缩一遍”。它只展示 `当前工作台 / 已接通工作台 / Provider 代理 Token / 今日调用 / 本月调用 / MySearch Token`。其中 `Provider 代理 Token` 会按工作台当前是否走本地代理池动态统计,例如 Tavily 或 Social / X 一旦切到上游 Gateway/兼容后台,就会从这项里剔除;Exa / Firecrawl 继续按本地 provider token 池计入。这样首页摘要只表达项目拓扑和控制面状态,不再重复展示工作台内部已经会单独展示的上游额度、本地 API Key 或 `Social Chat` 细节。来源:proxy/static/js/console.js:2777 -9. 保存设置、测试连接、复制配置和同步额度仍统一走页面右下角 toast;删除 token/key 这类危险动作现在不再用浏览器原生确认框,而是统一走 `app-dialog`;控制台范围内也已经没有原生 `select`,Tavily 工作模式改成了自定义 `mode-switch` 分段控件;左侧 `Social Compatibility` 与右侧 `Social / X 接入` 也都改成更摘要优先的结构,长英文值不再直接用大字号 value 顶满卡片;provider 的 token/key 摘要表则会用 `danger / warn / busy / off` 行态底色优先标出同步错误、低额度、异常活跃和停用状态,并在表格上方用 `table-legend` 直接给出图例;右侧 `detail-drawer` 的底部动作也改成“维护动作 / 危险动作”两组,删除类操作不再和普通维护动作并排。登录页也已经和 dashboard 收成同一套视觉语言,补了 `auth-meta` 元信息卡;登录、设置保存、设置测试、额度同步、创建 token、添加/导入 key 这些主操作现在都会显示按钮级 loading / success / error 过渡,而且共享同一个 `runWithBusyButton()`,会自动给 busy 态保留最小时长并避免在刷新后把 success/error 反馈闪到错误节点。进一步地,控制台现在已经把频繁操作改成“局部刷新而不是全量重绘”,并补上了 `mode-switch / mini-switch / settings-tab` 的方向键导航、toast live region、dialog 焦点回收与 focus trap;登录成功后 dashboard 仍会做一轮轻量 staged reveal,而在 `prefers-reduced-motion` 下这些动效会自动压平。主题切换也已经扩成 `浅色 / 深色 / 自动` 三态,`自动` 会按打开页面那台机器的本地时区和本地时间切换实际主题,不依赖服务端所在系统或 Docker 容器时区。来源:proxy/templates/console.html:97, proxy/templates/console.html:114, proxy/templates/console.html:134, proxy/static/js/console.js:19, proxy/static/js/console.js:108, proxy/static/js/console.js:178, proxy/static/js/console.js:644, proxy/static/js/console.js:653, proxy/static/js/console.js:808, proxy/static/js/console.js:3123, proxy/static/js/console.js:3495, proxy/static/css/console.css:118, proxy/static/css/console.css:1008, proxy/static/css/console.css:1245, proxy/static/css/console.css:2507, proxy/static/css/console.css:2908 +1. 现在最省事的部署方式是单容器镜像 `mysearch-stack`:直接暴露 `9874` 和 `8000` 两个端口,容器内部同时启动 `proxy` 与 `mysearch`,`mysearch` 再通过容器内回环地址访问 Proxy。来源:README.md:194, Dockerfile.stack:1, docker/combined-entrypoint.sh:1 +2. 如果你更看重服务边界清晰,仓库根目录的 `docker-compose.yml` 仍然提供“两服务一套 stack”:`proxy` 与 `mysearch` 在同一个 compose 网络里启动,`mysearch` 默认通过 `MYSEARCH_PROXY_BASE_URL=http://proxy:9874` 回连同 stack 内的 Proxy。来源:docker-compose.yml:1 +3. 不管是单容器还是 compose,两种部署现在都不再要求你手动先创建 `mysp-` token。`proxy` 只要配置了 `MYSEARCH_PROXY_BOOTSTRAP_TOKEN`,就会启用受限的 `/api/internal/mysearch/token`,`mysearch` 启动脚本会自动用同一个 bootstrap token 去创建或复用自己的专用 `mysp-` token。初始化时你只需要登录控制台补 provider 配置和 usage sync。来源:proxy/server.py:680, proxy/server.py:2702, mysearch/docker-entrypoint.sh:1, mysearch/scripts/bootstrap_proxy_token.py:1 +4. 如果你希望镜像自动发布到 Docker Hub,当前仓库的 `.github/workflows/docker-publish.yml` 已经从单镜像流程扩成三镜像 matrix。它参考 `code/program/todo-list/.github/workflows/docker-publish.yml` 的结构,但会先跑 `python -m unittest discover -s tests`、`py_compile` 和 `node --check proxy/static/js/console.js`,再分别对 `proxy`、`mysearch` 与 `stack` 做 buildx 多架构构建;pull request 只验证构建,push 到 `main` 或打 `v*` tag 时才真正发布镜像。默认镜像名分别是 `DOCKERHUB_USERNAME/mysearch-proxy`、`DOCKERHUB_USERNAME/mysearch-mcp` 与 `DOCKERHUB_USERNAME/mysearch-stack`,也可以用 `DOCKERHUB_IMAGE_NAME_PROXY` / `DOCKERHUB_IMAGE_NAME_MYSEARCH` / `DOCKERHUB_IMAGE_NAME_STACK` 覆盖。来源:.github/workflows/docker-publish.yml:1 +5. 根目录、`proxy/` 与 `mysearch/` 现在都有各自的 `.dockerignore`:前者排除 git、venv、`llmdoc` 和本地数据目录,`proxy/.dockerignore` 排除控制台 SQLite 与本地 `.env`,`mysearch/.dockerignore` 排除 `.env`、venv、accounts、数据库与缓存文件,避免本地调试状态被 `COPY` 进镜像上下文。来源:.dockerignore:1, proxy/.dockerignore:1, mysearch/.dockerignore:1 +6. 首次进入默认搜索控制台后,顶部 hero 不再单独重复展示“当前工作台”大卡;首屏只保留品牌区、快捷动作和 4 条 provider lane。如果想直接进入操作区,优先用 `进入当前工作台`;如果想看统一客户端接入配置,则用 `查看 MySearch 接入` 跳到独立的 `/mysearch` 页面。来源:proxy/templates/components/_hero.html:25, proxy/templates/components/_hero.html:42, proxy/templates/components/_hero.html:51, proxy/templates/components/_hero.html:77, proxy/static/js/console.js:631 +7. 默认搜索控制台下半区现在固定为 `Workspace Navigator -> 具体工作台`,不再默认把 `MySearch 快速接入` 挂在首页。switcher 卡片只保留工作台名称、状态和 2 个核心指标,次要信息下沉成 badge 与说明,不再显示 `/api/search` 这类具体请求路径;同时它已经改成横向卡阵列,不再一张张纵向堆高页面。`Social Compatibility` 也已经收回到 switcher 区块底部。Tavily 现在不是手动二选一,而是进 `Settings -> Tavily` 看 `auto|pool|upstream` 三态分段控件,保存后前端会同时显示“配置模式 / 当前实际 / 来源”,其中 `auto` 会按“上游凭证优先,其次本地活跃 Tavily key”自动解析;如果你只是导入 Tavily key,默认实际就会落到 API Key 池。Social/X 的 grok2api 或 compatible 配置仍进 `Settings -> Social / X`,而且字段标题现在按职责拆成“搜索上游”和“后台管理”两类,不再把 `Base URL`、后台地址和 app key 混成一组理解。设置中心每个 tab 都带 `settings-summary-strip`、sticky footer 和“测试当前连接”按钮,而且测试结果会直接展开成结构化 probe 卡,不需要自己从一行状态文案里猜请求目标或鉴权来源。来源:proxy/templates/console.html:51, proxy/templates/console.html:61, proxy/templates/components/_settings_modal.html:61, proxy/templates/components/_settings_modal.html:117, proxy/templates/components/_settings_modal.html:218, proxy/static/css/console.css:1061, proxy/static/js/console.js:2262 +8. 具体 provider 页面现在统一先看 `stats + 接线摘要`,再按需展开 `Token 池` 和 `API Key 池` 两个 detail cards;主表已经降成摘要视图,点击任一 token/key 行会打开右侧 `detail-drawer` 查看完整额度、账户层级信息和维护动作。新增的本地筛选条会直接在前端做搜索、筛选和排序,而且已经补到 `失败优先`、`待处理`、`异常优先`、`低额度优先` 这类运维向视角;表格行也可以直接用键盘 `Enter / Space` 打开详情抽屉。需要特别区分的是“本地池统计”和“上游状态”:Tavily 当前实际走 upstream 时,概览优先展示上游 Hikari 的公共摘要,例如活跃 key、耗尽 key、总请求与总剩余额度,本地 Tavily key 会降级成回退库存;Social / X 在接通 grok2api 后台时继续显示完整 token 统计,但如果只有手动上游 key / gateway token,就只展示基础接线可视化,例如上游 key 数、客户端 token 数和可转发状态,不再把后台未接通误显示成一排 0。对于兼容后台的 `/v1/admin/tokens`,控制台现在也会先解包 `{tokens:{...}}`、`{data:{...}}`、`{items:[...]}` 这类响应再做统计,避免后台地址和 app key 都已配置但 token 总数仍错误地显示为 0。初始化顺序仍建议保持“登录 -> 导入 Tavily/Firecrawl/Exa key -> 需要时补 Social/X -> sync usage -> 创建 `mysp-` token”。来源:proxy/templates/console.html:94, proxy/static/js/console.js:845, proxy/static/js/console.js:1268, proxy/static/js/console.js:2631, proxy/static/js/console.js:2718 +9. `MySearch 快速接入` 现在已经独立到 `/mysearch` 页面,不再默认出现在搜索控制台首页,这一页也不再重复展示首页 `summary-strip`。页级标题已经收成 `MySearch 接入台`,模块级标题则用 `统一接入配置`,不再两层都重复 `MySearch 快速接入`。页面内部继续收成“接入配置 / 安装路径 / 通用 Token 管理”三层,而且“一键配置”内部再拆成左侧可视化 readiness 区和右侧配置区,不再让 route 小卡片和 `.env`/说明混在一列里。`quickstart-route-strip` 会根据 Tavily `effective_mode`、Exa / Firecrawl key 状态和 Social / X 接线情况,动态显示当前 provider readiness;`quickstart-install-strip` 则把“创建通用 token → 复制 `.env` → ./install.sh”压成当前最短安装路径,并额外提供 `复制 .env 并定位命令` 的组合动作;旧版更直观的默认安装形态也通过 `quickstart-install-meta` 补回来了,直接展示 `stdio / streamable-http`。生成的 `.env` 里也会把“当前路由状态”写进去。客户端侧仍只保留 `MYSEARCH_PROXY_BASE_URL` 与 `MYSEARCH_PROXY_API_KEY`,不再把 provider key 散落到每台机器;`MySearch 通用 Token` 摘要表也补上了本地搜索和排序。来源:README.md:79, proxy/templates/mysearch.html:45, proxy/static/js/console.js:1433, proxy/static/js/console.js:1486, proxy/static/js/console.js:1546, proxy/static/js/console.js:1695 +10. 默认首页的 `summary-strip` 现在更偏“控制面概览”而不是“把所有 provider 细节都缩一遍”。它只展示 `当前工作台 / 已接通工作台 / Provider 代理 Token / 今日调用 / 本月调用 / MySearch Token`。其中 `Provider 代理 Token` 会按工作台当前是否走本地代理池动态统计,例如 Tavily 或 Social / X 一旦切到上游 Gateway/兼容后台,就会从这项里剔除;Exa / Firecrawl 继续按本地 provider token 池计入。这样首页摘要只表达项目拓扑和控制面状态,不再重复展示工作台内部已经会单独展示的上游额度、本地 API Key 或 `Social Chat` 细节。来源:proxy/static/js/console.js:2777 +11. 保存设置、测试连接、复制配置和同步额度仍统一走页面右下角 toast;删除 token/key 这类危险动作现在不再用浏览器原生确认框,而是统一走 `app-dialog`;控制台范围内也已经没有原生 `select`,Tavily 工作模式改成了自定义 `mode-switch` 分段控件;左侧 `Social Compatibility` 与右侧 `Social / X 接入` 也都改成更摘要优先的结构,长英文值不再直接用大字号 value 顶满卡片;provider 的 token/key 摘要表则会用 `danger / warn / busy / off` 行态底色优先标出同步错误、低额度、异常活跃和停用状态,并在表格上方用 `table-legend` 直接给出图例;右侧 `detail-drawer` 的底部动作也改成“维护动作 / 危险动作”两组,删除类操作不再和普通维护动作并排。登录页也已经和 dashboard 收成同一套视觉语言,补了 `auth-meta` 元信息卡;登录、设置保存、设置测试、额度同步、创建 token、添加/导入 key 这些主操作现在都会显示按钮级 loading / success / error 过渡,而且共享同一个 `runWithBusyButton()`,会自动给 busy 态保留最小时长并避免在刷新后把 success/error 反馈闪到错误节点。进一步地,控制台现在已经把频繁操作改成“局部刷新而不是全量重绘”,并补上了 `mode-switch / mini-switch / settings-tab` 的方向键导航、toast live region、dialog 焦点回收与 focus trap;登录成功后 dashboard 仍会做一轮轻量 staged reveal,而在 `prefers-reduced-motion` 下这些动效会自动压平。主题切换也已经扩成 `浅色 / 深色 / 自动` 三态,`自动` 会按打开页面那台机器的本地时区和本地时间切换实际主题,不依赖服务端所在系统或 Docker 容器时区。来源:proxy/templates/console.html:97, proxy/templates/console.html:114, proxy/templates/console.html:134, proxy/static/js/console.js:19, proxy/static/js/console.js:108, proxy/static/js/console.js:178, proxy/static/js/console.js:644, proxy/static/js/console.js:653, proxy/static/js/console.js:808, proxy/static/js/console.js:3123, proxy/static/js/console.js:3495, proxy/static/css/console.css:118, proxy/static/css/console.css:1008, proxy/static/css/console.css:1245, proxy/static/css/console.css:2507, proxy/static/css/console.css:2908 ## 3. 接入 OpenClaw diff --git a/llmdoc/reference/runtime-entrypoints.md b/llmdoc/reference/runtime-entrypoints.md index 12466c9..60982f9 100644 --- a/llmdoc/reference/runtime-entrypoints.md +++ b/llmdoc/reference/runtime-entrypoints.md @@ -5,13 +5,21 @@ | 文件 | 角色 | 关键点 | | --- | --- | --- | | `install.sh` | 本地安装与注册入口 | 安装 `mysearch/requirements.txt`,继承宿主 `MYSEARCH_*`,再向 `claude` / `codex` 注册 `mysearch` MCP。来源:install.sh:13, install.sh:74, install.sh:174 | +| `docker-compose.yml` | 一套部署入口 | 在仓库根目录同时编排 `proxy` 与 `mysearch` 两个服务:`proxy` 继续监听 `9874` 并落盘 SQLite,`mysearch` 继续以 `streamable-http` 形式监听 `8000/mcp`,并通过 `MYSEARCH_PROXY_BASE_URL=http://proxy:9874` 反向接入同 stack 内的 Proxy。现在 compose 会同时把 `MYSEARCH_PROXY_BOOTSTRAP_TOKEN` 注入两边,`mysearch` 启动时会自动请求 `proxy` 的内部 bootstrap 接口来创建或复用自己的 `mysp-` token,不再要求你手工先创建 MySearch 通用 token 才能拉起远程 MCP。来源:docker-compose.yml:1 | | `mysearch/__main__.py` | MySearch CLI 入口 | 解析 `stdio`、`sse`、`streamable-http` transport 及 host/port/path 参数,然后调用 `mysearch.server.main`。来源:mysearch/__main__.py:8 | | `mysearch/server.py` | MCP tool 暴露层 | 用 `FastMCP` 注册 `search`、`extract_url`、`research`、`mysearch_health`,并根据 transport 启动服务。来源:mysearch/server.py:34, mysearch/server.py:47, mysearch/server.py:168 | +| `mysearch/Dockerfile` | MySearch MCP 容器入口 | 基于 `python:3.11-slim` 安装 `mysearch/requirements.txt`,只复制 `mysearch/` 目录到镜像内部,并默认以 `python -m mysearch --transport streamable-http --host 0.0.0.0 --port 8000` 暴露远程 MCP。来源:mysearch/Dockerfile:1 | +| `mysearch/docker-entrypoint.sh` | MySearch 容器启动引导 | 容器启动时如果还没有显式 `MYSEARCH_PROXY_API_KEY`,但存在 `MYSEARCH_PROXY_BOOTSTRAP_TOKEN`,就先调用 `mysearch/scripts/bootstrap_proxy_token.py` 向 Proxy 请求或复用一个 `mysp-` token,再继续启动 `python -m mysearch`。来源:mysearch/docker-entrypoint.sh:1 | +| `mysearch/scripts/bootstrap_proxy_token.py` | Proxy token bootstrap 客户端 | 负责轮询 `MYSEARCH_PROXY_BASE_URL/api/internal/mysearch/token`,用 `MYSEARCH_PROXY_BOOTSTRAP_TOKEN` 鉴权并把返回的 `mysp-` token 输出给容器启动脚本。来源:mysearch/scripts/bootstrap_proxy_token.py:1 | +| `mysearch/.dockerignore` | MySearch 容器上下文收口 | 排除 `.env`、`venv`、`accounts.txt`、数据库与缓存文件,避免把本地调试状态与 secret 一起打进 `mysearch` 镜像上下文。来源:mysearch/.dockerignore:1 | +| `.dockerignore` | 单容器镜像上下文收口 | 供根目录 `Dockerfile.stack` 使用,排除 git、venv、`llmdoc`、本地数据目录和缓存文件,避免把仓库级调试资产一起打进单容器镜像。来源:.dockerignore:1 | +| `Dockerfile.stack` | 单容器一体化镜像入口 | 在一个镜像里同时安装 `proxy` 与 `mysearch` 依赖,复制两个目录,并通过 `docker/combined-entrypoint.sh` 同时拉起 `proxy` 与远程 MCP;默认继续把 `MYSEARCH_PROXY_BASE_URL` 指向容器内 `127.0.0.1:9874`。来源:Dockerfile.stack:1 | +| `docker/combined-entrypoint.sh` | 单容器双进程启动脚本 | 在同一个容器里先起本地 `proxy`,再通过 bootstrap 脚本拿到 `MYSEARCH_PROXY_API_KEY`,最后启动 `mysearch`;任一子进程退出时,脚本会一起清理并让容器失败。来源:docker/combined-entrypoint.sh:1 | | `mysearch/config.py` | Runtime 配置装配层 | 先读宿主 `~/.codex/config.toml`,再读 `.env` 兜底,并组装 Tavily / Firecrawl / Exa / xAI 的 `ProviderConfig`;Tavily 现在显式区分 `official` 与 `gateway`。运行时还兼容 Python 3.9:在解释器低于 3.10 时,dataclass 装配层会自动去掉 `slots`,避免配置模块导入即失败。来源:mysearch/config.py:17, mysearch/config.py:85, mysearch/config.py:98, mysearch/config.py:281, mysearch/config.py:320 | | `mysearch/clients.py` | 搜索编排与 provider 适配层 | 统一处理 intent、strategy、routing、cache、blended search、extract fallback、research workflow。来源:mysearch/clients.py:277, mysearch/clients.py:385, mysearch/clients.py:677, mysearch/clients.py:802, mysearch/clients.py:964 | -| `proxy/server.py` | Proxy 与控制台入口 | 暴露 Tavily / Firecrawl / Exa / Social 代理端点、控制台页面与管理 API;除了默认 `/` 搜索控制台以外,现在还额外提供 `/mysearch` 作为独立的 MySearch 接入页。FastAPI 入口已经切到 lifespan:启动时初始化数据库,退出时统一关闭共享 `http_client`,不再依赖已废弃的 `startup` hook。导入期的 `asyncio.Lock()` 也改成惰性初始化,避免 Python 3.9 在测试或脚本导入阶段留下未关闭事件循环。Tavily 上游链路现在也会对 `th-...` 这类 Hikari 访问令牌做路径兼容:如果 `Base URL` 还停在 Hikari 主机根而不是 `/api/tavily`,probe 与真实转发都会自动补上 `/api/tavily` 前缀;而当 Tavily 当前实际走上游时,控制台还会额外尝试读取上游公开的 `/api/summary`,把 Hikari 的基础摘要映射成 `upstream_summary`。Social / X 则继续分成两档:接通后台 admin 接口时返回完整 token 统计;只有手动上游 key / gateway token 时,只返回 `upstream_visibility` 这类基础接线可视化,不再把“没有后台 token 面板”和“没有接通上游”混成同一组 0 值。兼容后台的 `/v1/admin/tokens` 如果不是直接返回 `pool -> list`,而是包了一层 `{tokens:{...}}`、`{data:{...}}`、`{items:[...]}` 之类 envelope,当前解析逻辑也会先解包再做统计,避免后台已接通但控制台仍显示一排 0。来源:proxy/server.py:219, proxy/server.py:228, proxy/server.py:233, proxy/server.py:736, proxy/server.py:1299, proxy/server.py:2332 | +| `proxy/server.py` | Proxy 与控制台入口 | 暴露 Tavily / Firecrawl / Exa / Social 代理端点、控制台页面与管理 API;除了默认 `/` 搜索控制台以外,现在还额外提供 `/mysearch` 作为独立的 MySearch 接入页。FastAPI 入口已经切到 lifespan:启动时初始化数据库,退出时统一关闭共享 `http_client`,不再依赖已废弃的 `startup` hook。导入期的 `asyncio.Lock()` 也改成惰性初始化,避免 Python 3.9 在测试或脚本导入阶段留下未关闭事件循环。Tavily 上游链路现在也会对 `th-...` 这类 Hikari 访问令牌做路径兼容:如果 `Base URL` 还停在 Hikari 主机根而不是 `/api/tavily`,probe 与真实转发都会自动补上 `/api/tavily` 前缀;而当 Tavily 当前实际走上游时,控制台还会额外尝试读取上游公开的 `/api/summary`,把 Hikari 的基础摘要映射成 `upstream_summary`。Social / X 则继续分成两档:接通后台 admin 接口时返回完整 token 统计;只有手动上游 key / gateway token 时,只返回 `upstream_visibility` 这类基础接线可视化,不再把“没有后台 token 面板”和“没有接通上游”混成同一组 0 值。兼容后台的 `/v1/admin/tokens` 如果不是直接返回 `pool -> list`,而是包了一层 `{tokens:{...}}`、`{data:{...}}`、`{items:[...]}` 之类 envelope,当前解析逻辑也会先解包再做统计,避免后台已接通但控制台仍显示一排 0。现在还额外提供了受限的 `/api/internal/mysearch/token` bootstrap 接口:仅在配置 `MYSEARCH_PROXY_BOOTSTRAP_TOKEN` 时启用,用于给 `mysearch` 容器自动创建或复用专用的 `mysp-` token,不让运行时直接读 SQLite。来源:proxy/server.py:219, proxy/server.py:228, proxy/server.py:233, proxy/server.py:680, proxy/server.py:1421, proxy/server.py:2702 | | `proxy/.dockerignore` | Proxy 容器上下文收口 | Docker 本地构建 `proxy/` 镜像时,会排除 `data/`、`proxy.db`、`__pycache__`、`.env`、README 和 compose 文件,避免把本地 SQLite、缓存和非运行时资产打进镜像上下文。来源:proxy/.dockerignore:1 | -| `.github/workflows/docker-publish.yml` | Proxy Docker 自动构建与发布入口 | 参考 `code/program/todo-list/.github/workflows/docker-publish.yml` 的结构,当前仓库会先跑 Python 单测、`py_compile` 与 `node --check proxy/static/js/console.js`,再用 `context=./proxy` 与 `proxy/Dockerfile` 做 buildx 多架构构建;pull request 只验证构建,push 到 `main` 或 `v*` tag 才登录 Docker Hub 并推送镜像。来源:.github/workflows/docker-publish.yml:1 | +| `.github/workflows/docker-publish.yml` | Docker 自动构建与发布入口 | 参考 `code/program/todo-list/.github/workflows/docker-publish.yml` 的结构,但当前仓库已经扩成三镜像 matrix:先跑 Python 单测、`py_compile` 与 `node --check proxy/static/js/console.js`,再分别对 `proxy`、`mysearch` 与 `stack` 做 buildx 多架构构建;pull request 只验证构建,push 到 `main` 或 `v*` tag 时才登录 Docker Hub 并推送。默认镜像名分别是 `mysearch-proxy`、`mysearch-mcp` 与 `mysearch-stack`,也可以用 `DOCKERHUB_IMAGE_NAME_PROXY` / `DOCKERHUB_IMAGE_NAME_MYSEARCH` / `DOCKERHUB_IMAGE_NAME_STACK` 覆盖。来源:.github/workflows/docker-publish.yml:1 | | `proxy/templates/console.html` | Proxy 控制台壳模板 | 现在只负责装配默认 live 前端入口:引入 `components/_hero.html`、`components/_settings_modal.html`、`static/css/console.css`、`static/js/console.js`,并把页面组织成 `summary-strip + dashboard-flow` 的纵向结构;默认首页下半区顺序已经收成 `Workspace Navigator -> provider workspace`,`MySearch 快速接入` 不再默认挂在首页,改由 hero 里的“查看 MySearch 接入”跳去独立页面。`Social Compatibility` 也收回到 switcher 区块底部,不再单独占一侧 rail。壳层同时继续托管 `detail-drawer`、统一 `app-dialog` 和 `toast-root` 这三类交互容器。登录壳也继续保留在这里,但已经补了 `auth-meta` 三张元信息卡,让登录页与 dashboard 共用同一套基础设施风格。来源:proxy/templates/console.html:45, proxy/templates/console.html:51, proxy/templates/console.html:63, proxy/templates/console.html:92, proxy/templates/console.html:98, proxy/templates/console.html:125 | | `proxy/templates/mysearch.html` | MySearch 独立接入页模板 | 承载独立的 `MySearch 接入台` 页面,保留同一套登录、主题切换、设置弹窗、detail drawer、dialog 和 toast,但默认只展示统一接入配置、安装路径和通用 token 管理,不再把 provider 工作台或首页 `summary-strip` 混进这一页。来源:proxy/templates/mysearch.html:1, proxy/templates/mysearch.html:45, proxy/templates/mysearch.html:63 | | `proxy/templates/components/_hero.html` | Hero 与首屏焦点组件 | 定义 `Search Operations Desk` 顶部品牌区、主题切换、快捷动作和 4 条 provider lane。右侧“当前工作台”大卡已经从 hero 里移除,不再在这块重复展示当前控制台状态;“查看 MySearch 接入”按钮现在会直接跳到独立的 `/mysearch` 页面。主题切换也不再只有浅色/深色两态,而是 `浅色 -> 深色 -> 自动` 三态循环;`自动` 会按打开页面那台机器的本地时区与本地时间决定当前生效主题。来源:proxy/templates/components/_hero.html:7, proxy/templates/components/_hero.html:14, proxy/templates/components/_hero.html:42, proxy/templates/components/_hero.html:51, proxy/templates/components/_hero.html:77, proxy/static/js/console.js:19, proxy/static/js/console.js:634 | diff --git a/mysearch/.dockerignore b/mysearch/.dockerignore new file mode 100644 index 0000000..612f126 --- /dev/null +++ b/mysearch/.dockerignore @@ -0,0 +1,14 @@ +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +.env +.env.* +.venv/ +venv/ +accounts.txt +*.db +README.md +README_EN.md +Dockerfile diff --git a/mysearch/Dockerfile b/mysearch/Dockerfile new file mode 100644 index 0000000..213a1f8 --- /dev/null +++ b/mysearch/Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.11-slim + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PYTHONPATH=/app + +WORKDIR /app + +COPY requirements.txt /tmp/mysearch-requirements.txt +RUN pip install --no-cache-dir -r /tmp/mysearch-requirements.txt + +COPY . /app/mysearch +RUN chmod +x /app/mysearch/docker-entrypoint.sh + +EXPOSE 8000 + +ENTRYPOINT ["/app/mysearch/docker-entrypoint.sh"] +CMD ["python", "-m", "mysearch", "--transport", "streamable-http", "--host", "0.0.0.0", "--port", "8000"] diff --git a/mysearch/README.md b/mysearch/README.md index 30a6dc0..338083f 100644 --- a/mysearch/README.md +++ b/mysearch/README.md @@ -148,6 +148,50 @@ python3 -m venv venv - 如果本机有 `codex` 或 `claude` 命令,就自动注册 `mysearch` MCP - 如果宿主已有 `mysearch` config,会直接复用其中的 `MYSEARCH_*` +## 作为 Docker MCP 服务运行 + +如果你已经把仓库根目录的一套 compose 跑起来: + +```bash +cd /path/to/MySearch-Proxy +docker compose up -d +``` + +这时 `mysearch` 会通过 `MYSEARCH_PROXY_BOOTSTRAP_TOKEN` 自动从 `proxy` 申请或复用自己的 `mysp-` token,不再要求你手动先创建 MySearch 通用 token 才能拉起远程 MCP。 + +默认远程 MCP 地址: + +- `streamableHTTP` + - `http://127.0.0.1:8000/mcp` +- `SSE` + - `http://127.0.0.1:8000/sse` + +如果你只想单独构建 `mysearch` 镜像,也可以: + +```bash +docker build -t mysearch-mcp ./mysearch +docker run --rm -p 8000:8000 \ + -e MYSEARCH_PROXY_BASE_URL=http://:9874 \ + -e MYSEARCH_PROXY_API_KEY=mysp-... \ + mysearch-mcp +``` + +如果你更看重“部署最简单”,还可以直接跑单容器镜像: + +```bash +docker run -d \ + --name mysearch-stack \ + --restart unless-stopped \ + -p 9874:9874 \ + -p 8000:8000 \ + -e ADMIN_PASSWORD=change-me \ + -e MYSEARCH_PROXY_BOOTSTRAP_TOKEN=change-me-bootstrap-token \ + -v $(pwd)/mysearch-proxy-data:/app/proxy/data \ + skernelx/mysearch-stack:latest +``` + +这个镜像会在同一容器里同时启动 `proxy` 和 `mysearch`,并通过内部 bootstrap 接口自动创建或复用 `mysearch` 专用 token。 + ## 推荐验收 ### 1. 看 MCP 是否注册成功 diff --git a/mysearch/docker-entrypoint.sh b/mysearch/docker-entrypoint.sh new file mode 100644 index 0000000..152597a --- /dev/null +++ b/mysearch/docker-entrypoint.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ -z "${MYSEARCH_PROXY_API_KEY:-}" && -n "${MYSEARCH_PROXY_BOOTSTRAP_TOKEN:-}" ]]; then + export MYSEARCH_PROXY_API_KEY="$( + python /app/mysearch/scripts/bootstrap_proxy_token.py + )" +fi + +exec "$@" diff --git a/mysearch/scripts/bootstrap_proxy_token.py b/mysearch/scripts/bootstrap_proxy_token.py new file mode 100644 index 0000000..d95ae79 --- /dev/null +++ b/mysearch/scripts/bootstrap_proxy_token.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import json +import os +import sys +import time +import urllib.error +import urllib.request + + +def _normalize_base_url(value: str) -> str: + return value.rstrip("/") + + +def main() -> int: + base_url = _normalize_base_url(os.environ.get("MYSEARCH_PROXY_BASE_URL", "")) + bootstrap_token = os.environ.get("MYSEARCH_PROXY_BOOTSTRAP_TOKEN", "").strip() + token_name = os.environ.get("MYSEARCH_PROXY_BOOTSTRAP_NAME", "docker-mysearch").strip() or "docker-mysearch" + timeout_seconds = max(1.0, float(os.environ.get("MYSEARCH_PROXY_BOOTSTRAP_TIMEOUT_SECONDS", "60"))) + interval_seconds = max(0.2, float(os.environ.get("MYSEARCH_PROXY_BOOTSTRAP_INTERVAL_SECONDS", "1.5"))) + + if not base_url: + print("Missing MYSEARCH_PROXY_BASE_URL for proxy token bootstrap.", file=sys.stderr) + return 1 + if not bootstrap_token: + print("Missing MYSEARCH_PROXY_BOOTSTRAP_TOKEN for proxy token bootstrap.", file=sys.stderr) + return 1 + + target = f"{base_url}/api/internal/mysearch/token" + payload = json.dumps({"name": token_name}).encode("utf-8") + deadline = time.time() + timeout_seconds + last_error = "proxy token bootstrap did not start" + + while time.time() < deadline: + request = urllib.request.Request( + target, + data=payload, + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {bootstrap_token}", + }, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=10) as response: + data = json.loads(response.read().decode("utf-8")) + token = (data.get("token") or "").strip() + if token: + print(token) + return 0 + last_error = "bootstrap endpoint returned empty token" + except urllib.error.HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace").strip() + last_error = f"HTTP {exc.code}: {detail or exc.reason}" + except Exception as exc: # noqa: BLE001 + last_error = str(exc) + time.sleep(interval_seconds) + + print(f"Failed to bootstrap MYSEARCH_PROXY_API_KEY: {last_error}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/proxy/README.md b/proxy/README.md index f94451e..5d784ff 100644 --- a/proxy/README.md +++ b/proxy/README.md @@ -170,7 +170,37 @@ cd proxy docker compose up -d ``` -### 方式 C:本地源码运行 +### 方式 C:仓库根目录一套部署 `proxy + mysearch` + +```bash +cd /path/to/MySearch-Proxy +docker compose up -d +``` + +这套 compose 现在会自动通过 `MYSEARCH_PROXY_BOOTSTRAP_TOKEN` 给 `mysearch` 创建或复用一个专用的 `mysp-` token,不需要再手动先创建 MySearch 通用 token 才能拉起远程 MCP。首次进入控制台后,仍然只需要补 provider 配置和 usage sync。 + +启动后: + +- 控制台:`http://localhost:9874` +- MySearch MCP:`http://localhost:8000/mcp` + +### 方式 D:单容器一体化镜像 + +```bash +docker run -d \ + --name mysearch-stack \ + --restart unless-stopped \ + -p 9874:9874 \ + -p 8000:8000 \ + -e ADMIN_PASSWORD=change-me \ + -e MYSEARCH_PROXY_BOOTSTRAP_TOKEN=change-me-bootstrap-token \ + -v $(pwd)/mysearch-proxy-data:/app/proxy/data \ + skernelx/mysearch-stack:latest +``` + +这个镜像会在同一个容器里同时启动 `proxy` 和 `mysearch`,并通过本地回环地址自动完成 token bootstrap。适合你更看重“部署步骤最少”而不是“服务边界最清晰”的场景。 + +### 方式 E:本地源码运行 ```bash cd proxy diff --git a/proxy/database.py b/proxy/database.py index d6f6258..5dcb69c 100644 --- a/proxy/database.py +++ b/proxy/database.py @@ -374,6 +374,18 @@ def get_token_by_value(token_value): conn.close() +def get_token_by_name(name, service="tavily"): + service = normalize_token_service(service) + conn = get_conn() + try: + return conn.execute( + "SELECT * FROM tokens WHERE service = ? AND name = ? ORDER BY id LIMIT 1", + (service, name), + ).fetchone() + finally: + conn.close() + + def delete_token(token_id): conn = get_conn() try: diff --git a/proxy/server.py b/proxy/server.py index eebae68..7477307 100644 --- a/proxy/server.py +++ b/proxy/server.py @@ -24,6 +24,7 @@ ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD", "admin") ADMIN_SESSION_COOKIE = os.environ.get("ADMIN_SESSION_COOKIE", "mysearch_proxy_session") ADMIN_SESSION_MAX_AGE = max(300, int(os.environ.get("ADMIN_SESSION_MAX_AGE", "2592000"))) +MYSEARCH_PROXY_BOOTSTRAP_TOKEN = os.environ.get("MYSEARCH_PROXY_BOOTSTRAP_TOKEN", "").strip() TAVILY_API_BASE = "https://api.tavily.com" TAVILY_SEARCH_PATH = "/search" TAVILY_EXTRACT_PATH = "/extract" @@ -669,6 +670,21 @@ def verify_admin(request: Request): raise HTTPException(status_code=401, detail="Unauthorized") +def verify_mysearch_bootstrap(request: Request): + expected = MYSEARCH_PROXY_BOOTSTRAP_TOKEN.strip() + if not expected: + raise HTTPException(status_code=404, detail="Bootstrap endpoint is disabled") + + provided = request.headers.get("X-Bootstrap-Token", "").strip() + auth = request.headers.get("Authorization", "").strip() + if auth.startswith("Bearer "): + provided = auth[7:].strip() or provided + + if provided and hmac.compare_digest(provided, expected): + return True + raise HTTPException(status_code=401, detail="Unauthorized") + + def extract_token(request: Request, body: dict = None): """从请求中提取代理 token。""" auth = request.headers.get("Authorization", "") @@ -1416,6 +1432,16 @@ async def build_mysearch_dashboard(): } +def issue_mysearch_bootstrap_token(name: str) -> tuple[dict, bool]: + normalized_name = (name or "").strip() or "docker-mysearch" + existing = db.get_token_by_name(normalized_name, service="mysearch") + if existing: + return dict(existing), False + created = db.create_token(normalized_name, service="mysearch") + reset_stats_cache() + return dict(created), True + + async def build_social_dashboard(): state = await resolve_social_gateway_state(force=False) return { @@ -2695,6 +2721,24 @@ async def list_tokens(request: Request, _=Depends(verify_admin)): return {"tokens": tokens} +@app.post("/api/internal/mysearch/token") +async def bootstrap_mysearch_token(request: Request, _=Depends(verify_mysearch_bootstrap)): + try: + body = await request.json() + if not isinstance(body, dict): + body = {} + except Exception: + body = {} + token, created = issue_mysearch_bootstrap_token(body.get("name", "")) + return { + "ok": True, + "created": created, + "token": token["token"], + "token_name": token["name"], + "service": token["service"], + } + + @app.post("/api/tokens") async def create_token(request: Request, _=Depends(verify_admin)): body = await request.json() diff --git a/tests/test_proxy_bootstrap.py b/tests/test_proxy_bootstrap.py new file mode 100644 index 0000000..3528c3a --- /dev/null +++ b/tests/test_proxy_bootstrap.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import importlib.util +import os +import sys +import unittest +from pathlib import Path +from unittest.mock import patch + + +REPO_ROOT = Path(__file__).resolve().parents[1] +PROXY_ROOT = REPO_ROOT / "proxy" +MYSEARCH_ROOT = REPO_ROOT / "mysearch" + + +def _load_module(module_name: str, path: Path): + spec = importlib.util.spec_from_file_location(module_name, path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + return module + + +class ProxyBootstrapTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + if str(PROXY_ROOT) not in sys.path: + sys.path.insert(0, str(PROXY_ROOT)) + cls.proxy_server = _load_module( + "test_proxy_server_bootstrap", + PROXY_ROOT / "server.py", + ) + cls.bootstrap_script = _load_module( + "test_mysearch_bootstrap_proxy_token", + MYSEARCH_ROOT / "scripts" / "bootstrap_proxy_token.py", + ) + + def test_issue_mysearch_bootstrap_token_reuses_existing_named_token(self) -> None: + existing = {"token": "mysp-existing", "name": "docker-mysearch", "service": "mysearch"} + with patch.object(self.proxy_server.db, "get_token_by_name", return_value=existing), patch.object( + self.proxy_server.db, + "create_token", + ) as create_token: + token, created = self.proxy_server.issue_mysearch_bootstrap_token("docker-mysearch") + + self.assertFalse(created) + self.assertEqual(token["token"], "mysp-existing") + create_token.assert_not_called() + + def test_issue_mysearch_bootstrap_token_creates_when_missing(self) -> None: + created_row = {"token": "mysp-new", "name": "docker-mysearch", "service": "mysearch"} + with patch.object(self.proxy_server.db, "get_token_by_name", return_value=None), patch.object( + self.proxy_server.db, + "create_token", + return_value=created_row, + ) as create_token: + token, created = self.proxy_server.issue_mysearch_bootstrap_token("docker-mysearch") + + self.assertTrue(created) + self.assertEqual(token["token"], "mysp-new") + create_token.assert_called_once_with("docker-mysearch", service="mysearch") + + def test_bootstrap_script_fetches_token(self) -> None: + class _Response: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def read(self): + return b'{"token":"mysp-bootstrap"}' + + env = { + "MYSEARCH_PROXY_BASE_URL": "http://proxy:9874", + "MYSEARCH_PROXY_BOOTSTRAP_TOKEN": "bootstrap-secret", + "MYSEARCH_PROXY_BOOTSTRAP_NAME": "docker-mysearch", + "MYSEARCH_PROXY_BOOTSTRAP_TIMEOUT_SECONDS": "2", + "MYSEARCH_PROXY_BOOTSTRAP_INTERVAL_SECONDS": "0.01", + } + with patch.dict(os.environ, env, clear=False), patch.object( + self.bootstrap_script.urllib.request, + "urlopen", + return_value=_Response(), + ): + with patch("sys.stdout.write") as stdout_write: + exit_code = self.bootstrap_script.main() + + self.assertEqual(exit_code, 0) + written = "".join(call.args[0] for call in stdout_write.call_args_list) + self.assertIn("mysp-bootstrap", written) + + +if __name__ == "__main__": + unittest.main() From 7c47565fdb3856395206a38491e032a2ac653133 Mon Sep 17 00:00:00 2001 From: zhaishujie <3394180966@qq.com> Date: Sun, 22 Mar 2026 12:01:36 +0800 Subject: [PATCH 10/20] fix stack external proxy bind and docs --- README.md | 2 ++ docker/combined-entrypoint.sh | 3 +- llmdoc/architecture/proxy-first.md | 2 +- llmdoc/guides/common-workflows.md | 2 +- llmdoc/reference/entrypoints-and-config.md | 41 ++++++++++++++++++++++ llmdoc/reference/runtime-entrypoints.md | 2 +- mysearch/README.md | 34 ++++++++++++++++++ proxy/README.md | 2 ++ 8 files changed, 84 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 190e68e..91b0533 100644 --- a/README.md +++ b/README.md @@ -216,6 +216,8 @@ docker run -d \ - `proxy` 控制台:`http://localhost:9874` - `mysearch` MCP:`http://localhost:8000/mcp` +单容器镜像里,`proxy` 默认对外监听 `9874`,`mysearch` 默认对外监听 `8000/mcp`;`mysearch` 自己仍然通过容器内 `127.0.0.1:9874` 访问 Proxy。 + 这条链路里不再需要手动先创建 `mysp-` token。容器启动时会通过受限 bootstrap 接口自动创建或复用一个 `mysearch` 代理 token,再交给同容器里的 `mysearch` 运行时使用。 ### 路线 C:一套 compose 部署 `proxy + mysearch` diff --git a/docker/combined-entrypoint.sh b/docker/combined-entrypoint.sh index da6d604..91b4d7a 100644 --- a/docker/combined-entrypoint.sh +++ b/docker/combined-entrypoint.sh @@ -3,6 +3,7 @@ set -euo pipefail export PYTHONPATH="${PYTHONPATH:-/app}" export MYSEARCH_PROXY_BASE_URL="${MYSEARCH_PROXY_BASE_URL:-http://127.0.0.1:9874}" +export MYSEARCH_PROXY_HOST="${MYSEARCH_PROXY_HOST:-0.0.0.0}" cleanup() { local exit_code=$? @@ -18,7 +19,7 @@ cleanup() { trap cleanup EXIT INT TERM -python -m uvicorn proxy.server:app --host 127.0.0.1 --port 9874 & +python -m uvicorn proxy.server:app --host "${MYSEARCH_PROXY_HOST}" --port 9874 & PROXY_PID=$! if [[ -z "${MYSEARCH_PROXY_API_KEY:-}" && -n "${MYSEARCH_PROXY_BOOTSTRAP_TOKEN:-}" ]]; then diff --git a/llmdoc/architecture/proxy-first.md b/llmdoc/architecture/proxy-first.md index 4c82f1f..803b530 100644 --- a/llmdoc/architecture/proxy-first.md +++ b/llmdoc/architecture/proxy-first.md @@ -52,5 +52,5 @@ - Proxy 的启动时机会执行 `db.init_db()`;SQLite 默认路径是 `proxy/data/proxy.db`。来源:proxy/server.py:42, proxy/database.py:11, proxy/database.py:61 - Proxy 的 token 体系里包含 `mysearch` 服务,生成前缀为 `mysp-` 的统一 token,默认只做鉴权与统计,不做配额拦截。来源:proxy/database.py:13, proxy/database.py:18, proxy/README.md:74, proxy/README.md:83 -- `proxy-first` 的容器部署边界现在同时支持“两服务一套 stack”和“单容器一体化镜像”两种形态。仓库根的 `docker-compose.yml` 会同时编排 `proxy` 与 `mysearch`:前者负责控制台、token 与统一代理,后者负责对 Codex/Claude 暴露远程 MCP;`mysearch` 在同一个 compose 网络里继续用 `MYSEARCH_PROXY_BASE_URL=http://proxy:9874` 访问 Proxy,并通过受限的 `MYSEARCH_PROXY_BOOTSTRAP_TOKEN` 向 `/api/internal/mysearch/token` 申请或复用专用的 `mysp-` token。对于更看重部署步骤最少的场景,`Dockerfile.stack` 与 `docker/combined-entrypoint.sh` 又把这两个进程收成单容器镜像 `mysearch-stack`。GitHub Actions 侧也已经从“只发 Proxy 镜像”扩成三镜像 matrix:`.github/workflows/docker-publish.yml` 会分别构建/发布 `proxy`、`mysearch` 与 `stack`,而根目录、`proxy/` 和 `mysearch/` 的 `.dockerignore` 则分别收口各自上下文,避免把本地 SQLite、`.env`、accounts 文件与缓存一起打进镜像。来源:docker-compose.yml:1, Dockerfile.stack:1, docker/combined-entrypoint.sh:1, .github/workflows/docker-publish.yml:1, .dockerignore:1, proxy/.dockerignore:1, mysearch/.dockerignore:1 +- `proxy-first` 的容器部署边界现在同时支持“两服务一套 stack”和“单容器一体化镜像”两种形态。仓库根的 `docker-compose.yml` 会同时编排 `proxy` 与 `mysearch`:前者负责控制台、token 与统一代理,后者负责对 Codex/Claude 暴露远程 MCP;`mysearch` 在同一个 compose 网络里继续用 `MYSEARCH_PROXY_BASE_URL=http://proxy:9874` 访问 Proxy,并通过受限的 `MYSEARCH_PROXY_BOOTSTRAP_TOKEN` 向 `/api/internal/mysearch/token` 申请或复用专用的 `mysp-` token。对于更看重部署步骤最少的场景,`Dockerfile.stack` 与 `docker/combined-entrypoint.sh` 又把这两个进程收成单容器镜像 `mysearch-stack`:`proxy` 默认对外监听 `9874`,而 `mysearch` 继续监听 `8000/mcp` 并通过容器内 `127.0.0.1:9874` 回连 Proxy。GitHub Actions 侧也已经从“只发 Proxy 镜像”扩成三镜像 matrix:`.github/workflows/docker-publish.yml` 会分别构建/发布 `proxy`、`mysearch` 与 `stack`,而根目录、`proxy/` 和 `mysearch/` 的 `.dockerignore` 则分别收口各自上下文,避免把本地 SQLite、`.env`、accounts 文件与缓存一起打进镜像。来源:docker-compose.yml:1, Dockerfile.stack:1, docker/combined-entrypoint.sh:1, .github/workflows/docker-publish.yml:1, .dockerignore:1, proxy/.dockerignore:1, mysearch/.dockerignore:1 - Proxy 控制台现在已经从单文件模板拆成 `console.html + _hero.html + _settings_modal.html + console.css + console.js` 这套 live 前端;页面布局已经回到 `summary-strip + dashboard-flow` 的纵向结构,默认首页下半区固定为 `Workspace Navigator -> provider workspace`,而统一客户端接入则拆到独立的 `/mysearch` 页面。`Workspace Navigator` 仍然只保留工作台名称、状态和 2 个核心指标,次要信息下沉到 badge 与 footnote,不再展示 `/api/search`、`/social/search` 这类具体请求路径;但它现在不再纵向堆叠,而是由 `service-switcher` 横向卡阵列承接,`Social Compatibility` 提示卡也继续收在 switcher 区块底部。登录入口也不再是孤立小表单,而是通过 `auth-meta` 把“统一入口 / provider / 控制面”三个概念先交代清楚,并在登录成功后由 `showDashboard({ animate: true })` 做一轮轻量 staged reveal,而在 `prefers-reduced-motion` 下会自动压平动效。hero 右侧原先那张“当前工作台”大卡已经移除,不再在首屏重复展示当前控制台状态。`/mysearch` 页则收成 `MySearch 接入台`,模块标题进一步压成 `统一接入配置`,避免页级标题和模块标题重复。该页内部继续保持“接入配置 / 安装路径 / 通用 Token 管理”三层,而且“一键配置”内部进一步拆成左侧 `quickstart-visual-col` 可视化 readiness 区和右侧 `quickstart-config-col` 配置区:`getQuickstartProviderCards()` 继续把 Tavily `effective_mode`、Exa / Firecrawl key 状态和 Social / X 接线结果汇总成 `quickstart-route-strip`,`getQuickstartInstallHint()` 继续把当前最短安装路径压成 `quickstart-install-strip`,同时也把旧版更直接的 `stdio / streamable-http` 安装形态补回到 `quickstart-install-meta`。这些状态会一起写入生成的 `MYSEARCH_PROXY_*` 配置说明;除了复制块旁边的普通复制按钮,现在还额外提供 `copyEnvAndRevealInstall()` 这个组合动作,直接复制 `.env` 并把视口定位到安装命令。默认首页的 `summary-strip` 也已经收窄成项目级概览:`当前工作台 / 已接通工作台 / Provider 代理 Token / 今日调用 / 本月调用 / MySearch Token`,不再塞入工作台内部已经会单独展示的上游额度或本地 API Key。主题切换则扩成 `浅色 / 深色 / 自动` 三态,`自动` 依据打开页面那台机器的本地时区与本地时间决定实际主题,不依赖服务端所在系统或容器时区。`MySearch 通用 Token` 摘要表继续共享和 provider 面板一致的本地搜索/排序逻辑。provider 页面仍然保持“摘要表 + `detail-drawer`”的运维视图,`Token 池 / API Key 池` 的本地搜索、筛选和排序,以及 `table-row-clickable.is-danger|is-warn|is-busy|is-off` 风险行态都保留不变;`detail-drawer` 底部动作也继续通过 `renderDrawerActionGroup()` 拆成“维护动作 / 危险动作”两组。设置面板仍是带 `settings-summary-strip`、sticky footer 和 Tavily `mode-switch` 分段控件的配置中心,并保留 `/api/settings/test/tavily` 与 `/api/settings/test/social` 这两条结构化 probe 链路。控制台刷新仍然通过 `normalizeRefreshScope()`、`getRefreshScopeForService()`、`renderDashboardScope()` 做局部更新,可访问性层也仍然保留 `handleSegmentedControlKey()`、toast live region、overlay focus remember/restore 与 `trapOverlayFocus()` 这一组统一逻辑。来源:proxy/templates/components/_hero.html:25, proxy/templates/components/_hero.html:51, proxy/templates/console.html:51, proxy/templates/console.html:54, proxy/templates/console.html:65, proxy/templates/mysearch.html:21, proxy/static/js/console.js:644, proxy/static/js/console.js:1425, proxy/static/js/console.js:1478, proxy/static/js/console.js:1695, proxy/static/js/console.js:2251, proxy/static/js/console.js:2777, proxy/static/css/console.css:622, proxy/static/css/console.css:717, proxy/static/css/console.css:1061, proxy/static/css/console.css:1508, proxy/static/css/console.css:1533, proxy/static/css/console.css:2923, proxy/static/css/console.css:2943, proxy/server.py:2259 diff --git a/llmdoc/guides/common-workflows.md b/llmdoc/guides/common-workflows.md index 9a928f0..db424a0 100644 --- a/llmdoc/guides/common-workflows.md +++ b/llmdoc/guides/common-workflows.md @@ -8,7 +8,7 @@ ## 2. 部署 proxy-first 链路 -1. 现在最省事的部署方式是单容器镜像 `mysearch-stack`:直接暴露 `9874` 和 `8000` 两个端口,容器内部同时启动 `proxy` 与 `mysearch`,`mysearch` 再通过容器内回环地址访问 Proxy。来源:README.md:194, Dockerfile.stack:1, docker/combined-entrypoint.sh:1 +1. 现在最省事的部署方式是单容器镜像 `mysearch-stack`:直接暴露 `9874` 和 `8000` 两个端口,容器内部同时启动 `proxy` 与 `mysearch`,`proxy` 默认对外监听 `9874`,`mysearch` 再通过容器内回环地址访问 Proxy。来源:README.md:194, Dockerfile.stack:1, docker/combined-entrypoint.sh:1 2. 如果你更看重服务边界清晰,仓库根目录的 `docker-compose.yml` 仍然提供“两服务一套 stack”:`proxy` 与 `mysearch` 在同一个 compose 网络里启动,`mysearch` 默认通过 `MYSEARCH_PROXY_BASE_URL=http://proxy:9874` 回连同 stack 内的 Proxy。来源:docker-compose.yml:1 3. 不管是单容器还是 compose,两种部署现在都不再要求你手动先创建 `mysp-` token。`proxy` 只要配置了 `MYSEARCH_PROXY_BOOTSTRAP_TOKEN`,就会启用受限的 `/api/internal/mysearch/token`,`mysearch` 启动脚本会自动用同一个 bootstrap token 去创建或复用自己的专用 `mysp-` token。初始化时你只需要登录控制台补 provider 配置和 usage sync。来源:proxy/server.py:680, proxy/server.py:2702, mysearch/docker-entrypoint.sh:1, mysearch/scripts/bootstrap_proxy_token.py:1 4. 如果你希望镜像自动发布到 Docker Hub,当前仓库的 `.github/workflows/docker-publish.yml` 已经从单镜像流程扩成三镜像 matrix。它参考 `code/program/todo-list/.github/workflows/docker-publish.yml` 的结构,但会先跑 `python -m unittest discover -s tests`、`py_compile` 和 `node --check proxy/static/js/console.js`,再分别对 `proxy`、`mysearch` 与 `stack` 做 buildx 多架构构建;pull request 只验证构建,push 到 `main` 或打 `v*` tag 时才真正发布镜像。默认镜像名分别是 `DOCKERHUB_USERNAME/mysearch-proxy`、`DOCKERHUB_USERNAME/mysearch-mcp` 与 `DOCKERHUB_USERNAME/mysearch-stack`,也可以用 `DOCKERHUB_IMAGE_NAME_PROXY` / `DOCKERHUB_IMAGE_NAME_MYSEARCH` / `DOCKERHUB_IMAGE_NAME_STACK` 覆盖。来源:.github/workflows/docker-publish.yml:1 diff --git a/llmdoc/reference/entrypoints-and-config.md b/llmdoc/reference/entrypoints-and-config.md index 939d5c9..5348de6 100644 --- a/llmdoc/reference/entrypoints-and-config.md +++ b/llmdoc/reference/entrypoints-and-config.md @@ -67,6 +67,47 @@ - `MYSEARCH_TAVILY_MODE=gateway` - 配合 `MYSEARCH_TAVILY_GATEWAY_BASE_URL`、`MYSEARCH_TAVILY_GATEWAY_TOKEN` +## 部署后给 Codex 的远程 MCP 配置 + +如果你部署的是单容器 `mysearch-stack`,或者已经把 `proxy + mysearch` compose 跑起来,对 `Codex` 来说真正要接入的是 `mysearch` 暴露出来的远程 MCP,而不是 `proxy` 控制台本身。 + +最小 `~/.codex/config.toml`: + +```toml +[mcp_servers.mysearch] +type = "http" +url = "http://127.0.0.1:8000/mcp" +``` + +远程主机: + +```toml +[mcp_servers.mysearch] +type = "http" +url = "https://mysearch.example.com/mcp" +``` + +如果远程入口前面还有 Bearer: + +```toml +[mcp_servers.mysearch] +type = "http" +url = "https://mysearch.example.com/mcp" +headers = { Authorization = "Bearer YOUR_MCP_TOKEN" } +``` + +这里要明确区分: + +- `proxy` 控制台默认还是 `http://host:9874` +- `Codex` 要接的是 `mysearch` MCP,默认是 `http://host:8000/mcp` +- `MYSEARCH_PROXY_API_KEY` 是 `mysearch` 去访问 `proxy` 时用的内部 token,不是 `Codex` 自己必须填写到 MCP 配置里的字段 + +部署后的最小验收顺序: + +1. 重启 `Codex` +2. `codex mcp get mysearch` +3. `python3 skill/scripts/check_mysearch.py --health-only` + ## OpenClaw wrapper OpenClaw 侧也是 host-config-first,但入口不同: diff --git a/llmdoc/reference/runtime-entrypoints.md b/llmdoc/reference/runtime-entrypoints.md index 60982f9..4326a22 100644 --- a/llmdoc/reference/runtime-entrypoints.md +++ b/llmdoc/reference/runtime-entrypoints.md @@ -14,7 +14,7 @@ | `mysearch/.dockerignore` | MySearch 容器上下文收口 | 排除 `.env`、`venv`、`accounts.txt`、数据库与缓存文件,避免把本地调试状态与 secret 一起打进 `mysearch` 镜像上下文。来源:mysearch/.dockerignore:1 | | `.dockerignore` | 单容器镜像上下文收口 | 供根目录 `Dockerfile.stack` 使用,排除 git、venv、`llmdoc`、本地数据目录和缓存文件,避免把仓库级调试资产一起打进单容器镜像。来源:.dockerignore:1 | | `Dockerfile.stack` | 单容器一体化镜像入口 | 在一个镜像里同时安装 `proxy` 与 `mysearch` 依赖,复制两个目录,并通过 `docker/combined-entrypoint.sh` 同时拉起 `proxy` 与远程 MCP;默认继续把 `MYSEARCH_PROXY_BASE_URL` 指向容器内 `127.0.0.1:9874`。来源:Dockerfile.stack:1 | -| `docker/combined-entrypoint.sh` | 单容器双进程启动脚本 | 在同一个容器里先起本地 `proxy`,再通过 bootstrap 脚本拿到 `MYSEARCH_PROXY_API_KEY`,最后启动 `mysearch`;任一子进程退出时,脚本会一起清理并让容器失败。来源:docker/combined-entrypoint.sh:1 | +| `docker/combined-entrypoint.sh` | 单容器双进程启动脚本 | 在同一个容器里先起本地 `proxy`,再通过 bootstrap 脚本拿到 `MYSEARCH_PROXY_API_KEY`,最后启动 `mysearch`;默认把 `proxy` 绑定到 `0.0.0.0:9874` 方便外部访问控制台,同时 `mysearch` 继续通过容器内 `127.0.0.1:9874` 回连 Proxy。任一子进程退出时,脚本会一起清理并让容器失败。来源:docker/combined-entrypoint.sh:1 | | `mysearch/config.py` | Runtime 配置装配层 | 先读宿主 `~/.codex/config.toml`,再读 `.env` 兜底,并组装 Tavily / Firecrawl / Exa / xAI 的 `ProviderConfig`;Tavily 现在显式区分 `official` 与 `gateway`。运行时还兼容 Python 3.9:在解释器低于 3.10 时,dataclass 装配层会自动去掉 `slots`,避免配置模块导入即失败。来源:mysearch/config.py:17, mysearch/config.py:85, mysearch/config.py:98, mysearch/config.py:281, mysearch/config.py:320 | | `mysearch/clients.py` | 搜索编排与 provider 适配层 | 统一处理 intent、strategy、routing、cache、blended search、extract fallback、research workflow。来源:mysearch/clients.py:277, mysearch/clients.py:385, mysearch/clients.py:677, mysearch/clients.py:802, mysearch/clients.py:964 | | `proxy/server.py` | Proxy 与控制台入口 | 暴露 Tavily / Firecrawl / Exa / Social 代理端点、控制台页面与管理 API;除了默认 `/` 搜索控制台以外,现在还额外提供 `/mysearch` 作为独立的 MySearch 接入页。FastAPI 入口已经切到 lifespan:启动时初始化数据库,退出时统一关闭共享 `http_client`,不再依赖已废弃的 `startup` hook。导入期的 `asyncio.Lock()` 也改成惰性初始化,避免 Python 3.9 在测试或脚本导入阶段留下未关闭事件循环。Tavily 上游链路现在也会对 `th-...` 这类 Hikari 访问令牌做路径兼容:如果 `Base URL` 还停在 Hikari 主机根而不是 `/api/tavily`,probe 与真实转发都会自动补上 `/api/tavily` 前缀;而当 Tavily 当前实际走上游时,控制台还会额外尝试读取上游公开的 `/api/summary`,把 Hikari 的基础摘要映射成 `upstream_summary`。Social / X 则继续分成两档:接通后台 admin 接口时返回完整 token 统计;只有手动上游 key / gateway token 时,只返回 `upstream_visibility` 这类基础接线可视化,不再把“没有后台 token 面板”和“没有接通上游”混成同一组 0 值。兼容后台的 `/v1/admin/tokens` 如果不是直接返回 `pool -> list`,而是包了一层 `{tokens:{...}}`、`{data:{...}}`、`{items:[...]}` 之类 envelope,当前解析逻辑也会先解包再做统计,避免后台已接通但控制台仍显示一排 0。现在还额外提供了受限的 `/api/internal/mysearch/token` bootstrap 接口:仅在配置 `MYSEARCH_PROXY_BOOTSTRAP_TOKEN` 时启用,用于给 `mysearch` 容器自动创建或复用专用的 `mysp-` token,不让运行时直接读 SQLite。来源:proxy/server.py:219, proxy/server.py:228, proxy/server.py:233, proxy/server.py:680, proxy/server.py:1421, proxy/server.py:2702 | diff --git a/mysearch/README.md b/mysearch/README.md index 338083f..27c350e 100644 --- a/mysearch/README.md +++ b/mysearch/README.md @@ -166,6 +166,40 @@ docker compose up -d - `SSE` - `http://127.0.0.1:8000/sse` +如果你部署的是单容器 `mysearch-stack`,容器会同时对外提供 `9874` 控制台和 `8000/mcp`;`mysearch` 自己仍然通过容器内 `127.0.0.1:9874` 回连 Proxy。 + +部署完成后,如果你要让 `Codex` 直接使用这个远程 MCP,最小 `~/.codex/config.toml` 配置是: + +```toml +[mcp_servers.mysearch] +type = "http" +url = "http://127.0.0.1:8000/mcp" +``` + +如果你部署在远程主机: + +```toml +[mcp_servers.mysearch] +type = "http" +url = "https://mysearch.example.com/mcp" +``` + +如果你的远程入口额外套了 Bearer 鉴权,可以继续写成: + +```toml +[mcp_servers.mysearch] +type = "http" +url = "https://mysearch.example.com/mcp" +headers = { Authorization = "Bearer YOUR_MCP_TOKEN" } +``` + +加完配置后重启 `Codex`,再验收: + +```bash +codex mcp get mysearch +python3 skill/scripts/check_mysearch.py --health-only +``` + 如果你只想单独构建 `mysearch` 镜像,也可以: ```bash diff --git a/proxy/README.md b/proxy/README.md index 5d784ff..90b3acc 100644 --- a/proxy/README.md +++ b/proxy/README.md @@ -200,6 +200,8 @@ docker run -d \ 这个镜像会在同一个容器里同时启动 `proxy` 和 `mysearch`,并通过本地回环地址自动完成 token bootstrap。适合你更看重“部署步骤最少”而不是“服务边界最清晰”的场景。 +默认情况下,`proxy` 会对外监听 `9874`,`mysearch` 会对外监听 `8000/mcp`;`mysearch` 自己仍然通过容器内 `127.0.0.1:9874` 回连 Proxy。 + ### 方式 E:本地源码运行 ```bash From 1d1bd74c9cb6bd0beb0165044fbc9795fa1de1e1 Mon Sep 17 00:00:00 2001 From: zhaishujie <3394180966@qq.com> Date: Sun, 22 Mar 2026 12:12:12 +0800 Subject: [PATCH 11/20] fix stack proxy import path --- docker/combined-entrypoint.sh | 2 +- llmdoc/reference/runtime-entrypoints.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docker/combined-entrypoint.sh b/docker/combined-entrypoint.sh index 91b4d7a..107c2f5 100644 --- a/docker/combined-entrypoint.sh +++ b/docker/combined-entrypoint.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -export PYTHONPATH="${PYTHONPATH:-/app}" +export PYTHONPATH="${PYTHONPATH:-/app}:/app/proxy" export MYSEARCH_PROXY_BASE_URL="${MYSEARCH_PROXY_BASE_URL:-http://127.0.0.1:9874}" export MYSEARCH_PROXY_HOST="${MYSEARCH_PROXY_HOST:-0.0.0.0}" diff --git a/llmdoc/reference/runtime-entrypoints.md b/llmdoc/reference/runtime-entrypoints.md index 4326a22..5dab90a 100644 --- a/llmdoc/reference/runtime-entrypoints.md +++ b/llmdoc/reference/runtime-entrypoints.md @@ -14,7 +14,7 @@ | `mysearch/.dockerignore` | MySearch 容器上下文收口 | 排除 `.env`、`venv`、`accounts.txt`、数据库与缓存文件,避免把本地调试状态与 secret 一起打进 `mysearch` 镜像上下文。来源:mysearch/.dockerignore:1 | | `.dockerignore` | 单容器镜像上下文收口 | 供根目录 `Dockerfile.stack` 使用,排除 git、venv、`llmdoc`、本地数据目录和缓存文件,避免把仓库级调试资产一起打进单容器镜像。来源:.dockerignore:1 | | `Dockerfile.stack` | 单容器一体化镜像入口 | 在一个镜像里同时安装 `proxy` 与 `mysearch` 依赖,复制两个目录,并通过 `docker/combined-entrypoint.sh` 同时拉起 `proxy` 与远程 MCP;默认继续把 `MYSEARCH_PROXY_BASE_URL` 指向容器内 `127.0.0.1:9874`。来源:Dockerfile.stack:1 | -| `docker/combined-entrypoint.sh` | 单容器双进程启动脚本 | 在同一个容器里先起本地 `proxy`,再通过 bootstrap 脚本拿到 `MYSEARCH_PROXY_API_KEY`,最后启动 `mysearch`;默认把 `proxy` 绑定到 `0.0.0.0:9874` 方便外部访问控制台,同时 `mysearch` 继续通过容器内 `127.0.0.1:9874` 回连 Proxy。任一子进程退出时,脚本会一起清理并让容器失败。来源:docker/combined-entrypoint.sh:1 | +| `docker/combined-entrypoint.sh` | 单容器双进程启动脚本 | 在同一个容器里先起本地 `proxy`,再通过 bootstrap 脚本拿到 `MYSEARCH_PROXY_API_KEY`,最后启动 `mysearch`;默认把 `proxy` 绑定到 `0.0.0.0:9874` 方便外部访问控制台,同时 `mysearch` 继续通过容器内 `127.0.0.1:9874` 回连 Proxy。由于 `proxy/server.py` 和 `proxy/key_pool.py` 仍然保留顶层 `import database` / `from key_pool import pool` 这类导入方式,stack 启动脚本还会把 `/app/proxy` 追加到 `PYTHONPATH`,确保单容器镜像里的 `uvicorn proxy.server:app` 能正常导入。任一子进程退出时,脚本会一起清理并让容器失败。来源:docker/combined-entrypoint.sh:1 | | `mysearch/config.py` | Runtime 配置装配层 | 先读宿主 `~/.codex/config.toml`,再读 `.env` 兜底,并组装 Tavily / Firecrawl / Exa / xAI 的 `ProviderConfig`;Tavily 现在显式区分 `official` 与 `gateway`。运行时还兼容 Python 3.9:在解释器低于 3.10 时,dataclass 装配层会自动去掉 `slots`,避免配置模块导入即失败。来源:mysearch/config.py:17, mysearch/config.py:85, mysearch/config.py:98, mysearch/config.py:281, mysearch/config.py:320 | | `mysearch/clients.py` | 搜索编排与 provider 适配层 | 统一处理 intent、strategy、routing、cache、blended search、extract fallback、research workflow。来源:mysearch/clients.py:277, mysearch/clients.py:385, mysearch/clients.py:677, mysearch/clients.py:802, mysearch/clients.py:964 | | `proxy/server.py` | Proxy 与控制台入口 | 暴露 Tavily / Firecrawl / Exa / Social 代理端点、控制台页面与管理 API;除了默认 `/` 搜索控制台以外,现在还额外提供 `/mysearch` 作为独立的 MySearch 接入页。FastAPI 入口已经切到 lifespan:启动时初始化数据库,退出时统一关闭共享 `http_client`,不再依赖已废弃的 `startup` hook。导入期的 `asyncio.Lock()` 也改成惰性初始化,避免 Python 3.9 在测试或脚本导入阶段留下未关闭事件循环。Tavily 上游链路现在也会对 `th-...` 这类 Hikari 访问令牌做路径兼容:如果 `Base URL` 还停在 Hikari 主机根而不是 `/api/tavily`,probe 与真实转发都会自动补上 `/api/tavily` 前缀;而当 Tavily 当前实际走上游时,控制台还会额外尝试读取上游公开的 `/api/summary`,把 Hikari 的基础摘要映射成 `upstream_summary`。Social / X 则继续分成两档:接通后台 admin 接口时返回完整 token 统计;只有手动上游 key / gateway token 时,只返回 `upstream_visibility` 这类基础接线可视化,不再把“没有后台 token 面板”和“没有接通上游”混成同一组 0 值。兼容后台的 `/v1/admin/tokens` 如果不是直接返回 `pool -> list`,而是包了一层 `{tokens:{...}}`、`{data:{...}}`、`{items:[...]}` 之类 envelope,当前解析逻辑也会先解包再做统计,避免后台已接通但控制台仍显示一排 0。现在还额外提供了受限的 `/api/internal/mysearch/token` bootstrap 接口:仅在配置 `MYSEARCH_PROXY_BOOTSTRAP_TOKEN` 时启用,用于给 `mysearch` 容器自动创建或复用专用的 `mysp-` token,不让运行时直接读 SQLite。来源:proxy/server.py:219, proxy/server.py:228, proxy/server.py:233, proxy/server.py:680, proxy/server.py:1421, proxy/server.py:2702 | From e39f7cba5cb2cc761ab2ab2c03d55544b986bffa Mon Sep 17 00:00:00 2001 From: zhaishujie <3394180966@qq.com> Date: Sun, 22 Mar 2026 12:40:24 +0800 Subject: [PATCH 12/20] fix tavily hikari upstream fallback --- llmdoc/reference/runtime-entrypoints.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/llmdoc/reference/runtime-entrypoints.md b/llmdoc/reference/runtime-entrypoints.md index 5dab90a..b5007e3 100644 --- a/llmdoc/reference/runtime-entrypoints.md +++ b/llmdoc/reference/runtime-entrypoints.md @@ -17,7 +17,7 @@ | `docker/combined-entrypoint.sh` | 单容器双进程启动脚本 | 在同一个容器里先起本地 `proxy`,再通过 bootstrap 脚本拿到 `MYSEARCH_PROXY_API_KEY`,最后启动 `mysearch`;默认把 `proxy` 绑定到 `0.0.0.0:9874` 方便外部访问控制台,同时 `mysearch` 继续通过容器内 `127.0.0.1:9874` 回连 Proxy。由于 `proxy/server.py` 和 `proxy/key_pool.py` 仍然保留顶层 `import database` / `from key_pool import pool` 这类导入方式,stack 启动脚本还会把 `/app/proxy` 追加到 `PYTHONPATH`,确保单容器镜像里的 `uvicorn proxy.server:app` 能正常导入。任一子进程退出时,脚本会一起清理并让容器失败。来源:docker/combined-entrypoint.sh:1 | | `mysearch/config.py` | Runtime 配置装配层 | 先读宿主 `~/.codex/config.toml`,再读 `.env` 兜底,并组装 Tavily / Firecrawl / Exa / xAI 的 `ProviderConfig`;Tavily 现在显式区分 `official` 与 `gateway`。运行时还兼容 Python 3.9:在解释器低于 3.10 时,dataclass 装配层会自动去掉 `slots`,避免配置模块导入即失败。来源:mysearch/config.py:17, mysearch/config.py:85, mysearch/config.py:98, mysearch/config.py:281, mysearch/config.py:320 | | `mysearch/clients.py` | 搜索编排与 provider 适配层 | 统一处理 intent、strategy、routing、cache、blended search、extract fallback、research workflow。来源:mysearch/clients.py:277, mysearch/clients.py:385, mysearch/clients.py:677, mysearch/clients.py:802, mysearch/clients.py:964 | -| `proxy/server.py` | Proxy 与控制台入口 | 暴露 Tavily / Firecrawl / Exa / Social 代理端点、控制台页面与管理 API;除了默认 `/` 搜索控制台以外,现在还额外提供 `/mysearch` 作为独立的 MySearch 接入页。FastAPI 入口已经切到 lifespan:启动时初始化数据库,退出时统一关闭共享 `http_client`,不再依赖已废弃的 `startup` hook。导入期的 `asyncio.Lock()` 也改成惰性初始化,避免 Python 3.9 在测试或脚本导入阶段留下未关闭事件循环。Tavily 上游链路现在也会对 `th-...` 这类 Hikari 访问令牌做路径兼容:如果 `Base URL` 还停在 Hikari 主机根而不是 `/api/tavily`,probe 与真实转发都会自动补上 `/api/tavily` 前缀;而当 Tavily 当前实际走上游时,控制台还会额外尝试读取上游公开的 `/api/summary`,把 Hikari 的基础摘要映射成 `upstream_summary`。Social / X 则继续分成两档:接通后台 admin 接口时返回完整 token 统计;只有手动上游 key / gateway token 时,只返回 `upstream_visibility` 这类基础接线可视化,不再把“没有后台 token 面板”和“没有接通上游”混成同一组 0 值。兼容后台的 `/v1/admin/tokens` 如果不是直接返回 `pool -> list`,而是包了一层 `{tokens:{...}}`、`{data:{...}}`、`{items:[...]}` 之类 envelope,当前解析逻辑也会先解包再做统计,避免后台已接通但控制台仍显示一排 0。现在还额外提供了受限的 `/api/internal/mysearch/token` bootstrap 接口:仅在配置 `MYSEARCH_PROXY_BOOTSTRAP_TOKEN` 时启用,用于给 `mysearch` 容器自动创建或复用专用的 `mysp-` token,不让运行时直接读 SQLite。来源:proxy/server.py:219, proxy/server.py:228, proxy/server.py:233, proxy/server.py:680, proxy/server.py:1421, proxy/server.py:2702 | +| `proxy/server.py` | Proxy 与控制台入口 | 暴露 Tavily / Firecrawl / Exa / Social 代理端点、控制台页面与管理 API;除了默认 `/` 搜索控制台以外,现在还额外提供 `/mysearch` 作为独立的 MySearch 接入页。FastAPI 入口已经切到 lifespan:启动时初始化数据库,退出时统一关闭共享 `http_client`,不再依赖已废弃的 `startup` hook。导入期的 `asyncio.Lock()` 也改成惰性初始化,避免 Python 3.9 在测试或脚本导入阶段留下未关闭事件循环。Tavily 上游链路现在会先按当前配置发起 probe 和真实转发;如果上游模式下首次命中 `404`,还会自动回退到 `/api/tavily/*` 兼容路径,避免 Hikari 类网关在 `Base URL` 写成主机根时直接报 `Not Found`。当 Tavily 当前实际走上游时,控制台还会额外尝试读取上游公开的 `/api/summary`,把 Hikari 的基础摘要映射成 `upstream_summary`。Social / X 则继续分成两档:接通后台 admin 接口时返回完整 token 统计;只有手动上游 key / gateway token 时,只返回 `upstream_visibility` 这类基础接线可视化,不再把“没有后台 token 面板”和“没有接通上游”混成同一组 0 值。兼容后台的 `/v1/admin/tokens` 如果不是直接返回 `pool -> list`,而是包了一层 `{tokens:{...}}`、`{data:{...}}`、`{items:[...]}` 之类 envelope,当前解析逻辑也会先解包再做统计,避免后台已接通但控制台仍显示一排 0。现在还额外提供了受限的 `/api/internal/mysearch/token` bootstrap 接口:仅在配置 `MYSEARCH_PROXY_BOOTSTRAP_TOKEN` 时启用,用于给 `mysearch` 容器自动创建或复用专用的 `mysp-` token,不让运行时直接读 SQLite。来源:proxy/server.py:219, proxy/server.py:228, proxy/server.py:233, proxy/server.py:680, proxy/server.py:1421, proxy/server.py:2702 | | `proxy/.dockerignore` | Proxy 容器上下文收口 | Docker 本地构建 `proxy/` 镜像时,会排除 `data/`、`proxy.db`、`__pycache__`、`.env`、README 和 compose 文件,避免把本地 SQLite、缓存和非运行时资产打进镜像上下文。来源:proxy/.dockerignore:1 | | `.github/workflows/docker-publish.yml` | Docker 自动构建与发布入口 | 参考 `code/program/todo-list/.github/workflows/docker-publish.yml` 的结构,但当前仓库已经扩成三镜像 matrix:先跑 Python 单测、`py_compile` 与 `node --check proxy/static/js/console.js`,再分别对 `proxy`、`mysearch` 与 `stack` 做 buildx 多架构构建;pull request 只验证构建,push 到 `main` 或 `v*` tag 时才登录 Docker Hub 并推送。默认镜像名分别是 `mysearch-proxy`、`mysearch-mcp` 与 `mysearch-stack`,也可以用 `DOCKERHUB_IMAGE_NAME_PROXY` / `DOCKERHUB_IMAGE_NAME_MYSEARCH` / `DOCKERHUB_IMAGE_NAME_STACK` 覆盖。来源:.github/workflows/docker-publish.yml:1 | | `proxy/templates/console.html` | Proxy 控制台壳模板 | 现在只负责装配默认 live 前端入口:引入 `components/_hero.html`、`components/_settings_modal.html`、`static/css/console.css`、`static/js/console.js`,并把页面组织成 `summary-strip + dashboard-flow` 的纵向结构;默认首页下半区顺序已经收成 `Workspace Navigator -> provider workspace`,`MySearch 快速接入` 不再默认挂在首页,改由 hero 里的“查看 MySearch 接入”跳去独立页面。`Social Compatibility` 也收回到 switcher 区块底部,不再单独占一侧 rail。壳层同时继续托管 `detail-drawer`、统一 `app-dialog` 和 `toast-root` 这三类交互容器。登录壳也继续保留在这里,但已经补了 `auth-meta` 三张元信息卡,让登录页与 dashboard 共用同一套基础设施风格。来源:proxy/templates/console.html:45, proxy/templates/console.html:51, proxy/templates/console.html:63, proxy/templates/console.html:92, proxy/templates/console.html:98, proxy/templates/console.html:125 | From fae94242083858e99988b1a99a60ec85408ba208 Mon Sep 17 00:00:00 2001 From: zhaishujie <3394180966@qq.com> Date: Sun, 22 Mar 2026 12:40:59 +0800 Subject: [PATCH 13/20] fix tavily upstream 404 compatibility retry --- proxy/server.py | 59 ++++++++++++++++++++++++++--- tests/test_proxy_tavily_settings.py | 40 +++++++++++++++++++ 2 files changed, 93 insertions(+), 6 deletions(-) diff --git a/proxy/server.py b/proxy/server.py index 7477307..74e410f 100644 --- a/proxy/server.py +++ b/proxy/server.py @@ -64,6 +64,35 @@ def _build_tavily_upstream_url(base_url, path, api_key=""): return f"{normalized_base}{normalized_path}" +def _build_tavily_hikari_gateway_url(base_url, path): + normalized_base = str(base_url or TAVILY_API_BASE).strip().rstrip("/") or TAVILY_API_BASE + normalized_path = _normalize_path(path, TAVILY_SEARCH_PATH) + parsed = urlparse(normalized_base) + base_path = parsed.path.rstrip("/") + if base_path.endswith("/api/tavily"): + return f"{normalized_base}{normalized_path}" + return f"{normalized_base}/api/tavily{normalized_path}" + + +def _should_retry_tavily_hikari_compat(response, current_url, effective_mode): + if effective_mode != "upstream": + return False + if getattr(response, "status_code", None) != 404: + return False + current_path = urlparse(str(current_url or "")).path + return "/api/tavily/" not in current_path and not current_path.endswith("/api/tavily") + + +async def _post_tavily_with_gateway_fallback(*, base_url, path, api_key, payload, effective_mode): + request_target = _build_tavily_upstream_url(base_url, path, api_key) + response = await http_client.post(request_target, json=payload) + if _should_retry_tavily_hikari_compat(response, request_target, effective_mode): + retry_target = _build_tavily_hikari_gateway_url(base_url, path) + retry_response = await http_client.post(retry_target, json=payload) + return retry_response, retry_target, True + return response, request_target, False + + def _build_tavily_hikari_public_url(base_url, target_path): normalized_base = str(base_url or "").strip().rstrip("/") if not normalized_base: @@ -602,7 +631,13 @@ async def probe_tavily_connection(config, active_keys): "api_key": key_value, } try: - response = await http_client.post(test_url, json=request_body) + response, request_target, fallback_used = await _post_tavily_with_gateway_fallback( + base_url=upstream_base_url, + path=upstream_path, + api_key=key_value, + payload=request_body, + effective_mode=resolved["effective_mode"], + ) except Exception as exc: return { "ok": False, @@ -640,10 +675,14 @@ async def probe_tavily_connection(config, active_keys): "mode_source": resolved["mode_source"], "local_key_count": len(active_keys), "summary": build_tavily_routing_meta(config, active_keys)["summary"], - "detail": detail or f"HTTP {response.status_code}", + "detail": ( + f"{detail or f'HTTP {response.status_code}'}(已自动回退到 /api/tavily 兼容路径)" + if fallback_used and response.status_code < 400 + else detail or f"HTTP {response.status_code}" + ), "failure_reason": "" if response.status_code < 400 else (detail or f"HTTP {response.status_code}"), "probe_url": test_url, - "request_target": test_url, + "request_target": request_target, "auth_source": auth_source if resolved["effective_mode"] == "upstream" else f"{auth_source}(活跃 {len(active_keys)})", "status_label": f"HTTP {response.status_code}", "recommendation": ( @@ -2187,11 +2226,16 @@ async def proxy_tavily(request: Request): raise HTTPException(status_code=503, detail="No available API keys") upstream_key = key_info["key"] - upstream_url = _build_tavily_upstream_url(upstream_base_url, upstream_path, upstream_key) body["api_key"] = upstream_key start = time.time() try: - resp = await http_client.post(upstream_url, json=body) + resp, request_target, _fallback_used = await _post_tavily_with_gateway_fallback( + base_url=upstream_base_url, + path=upstream_path, + api_key=upstream_key, + payload=body, + effective_mode=tavily_resolved["effective_mode"], + ) latency = int((time.time() - start) * 1000) success = resp.status_code == 200 if key_info is not None: @@ -2204,7 +2248,10 @@ async def proxy_tavily(request: Request): latency, service="tavily", ) - return JSONResponse(content=resp.json(), status_code=resp.status_code) + try: + return JSONResponse(content=resp.json(), status_code=resp.status_code) + except Exception: + return Response(content=resp.text, status_code=resp.status_code, media_type=resp.headers.get("content-type")) except Exception as exc: latency = int((time.time() - start) * 1000) if key_info is not None: diff --git a/tests/test_proxy_tavily_settings.py b/tests/test_proxy_tavily_settings.py index b154bb2..1176d93 100644 --- a/tests/test_proxy_tavily_settings.py +++ b/tests/test_proxy_tavily_settings.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import importlib.util import sys import unittest @@ -76,6 +77,45 @@ def fake_get_setting(key, default=None): self.assertEqual(meta["requested"], 2) self.assertIn("上游 Gateway", meta["detail"]) + def test_probe_tavily_connection_falls_back_to_api_tavily_on_404(self) -> None: + config = { + "mode": "upstream", + "upstream_base_url": "http://127.0.0.1:8787", + "upstream_search_path": "/search", + "upstream_extract_path": "/extract", + "upstream_api_key": "gateway-token-without-th-prefix", + } + + class _Response: + def __init__(self, status_code, payload): + self.status_code = status_code + self._payload = payload + self.headers = {"content-type": "application/json"} + self.text = "" + + def json(self): + return self._payload + + async def _run(): + responses = [ + _Response(404, {"detail": "Not Found"}), + _Response(200, {"results": [{"title": "ok"}]}), + ] + call_urls = [] + + async def fake_post(url, json): + call_urls.append(url) + return responses.pop(0) + + with patch.object(self.module, "http_client") as fake_client: + fake_client.post.side_effect = fake_post + return await self.module.probe_tavily_connection(config, []) + + result = asyncio.run(_run()) + self.assertTrue(result["ok"]) + self.assertEqual(result["request_target"], "http://127.0.0.1:8787/api/tavily/search") + self.assertIn("/api/tavily", result["detail"]) + if __name__ == "__main__": unittest.main() From 567041ccdd0ff548454ac5140a299448bb63d4e5 Mon Sep 17 00:00:00 2001 From: zhaishujie <3394180966@qq.com> Date: Sun, 22 Mar 2026 13:10:21 +0800 Subject: [PATCH 14/20] refine mysearch docs routing and official fallback --- llmdoc/guides/common-workflows.md | 4 +- llmdoc/reference/runtime-entrypoints.md | 2 +- mysearch/clients.py | 275 ++++++++++++++++++++++-- tests/test_clients.py | 153 +++++++++++-- tests/test_comprehensive.py | 44 ++++ 5 files changed, 448 insertions(+), 30 deletions(-) diff --git a/llmdoc/guides/common-workflows.md b/llmdoc/guides/common-workflows.md index db424a0..fc9505f 100644 --- a/llmdoc/guides/common-workflows.md +++ b/llmdoc/guides/common-workflows.md @@ -29,8 +29,8 @@ ## 4. 排查搜索行为 1. 先跑 `mysearch_health`,确认 provider 是否可用,而不是只看 key 有没有填。来源:mysearch/server.py:156, README.md:105 -2. 再看 `route` 与 `route_debug`,判断是显式 provider、生效的 intent/strategy,还是 blended/hybrid 路由。来源:mysearch/clients.py:359, mysearch/clients.py:651 -3. 文档类查询要分清“页面发现”和“正文抓取”是两个阶段;正文异常先查 Firecrawl,再看 Tavily/Exa fallback。来源:mysearch/clients.py:1023, mysearch/clients.py:677 +2. 再看 `route` 与 `route_debug`,判断是显式 provider、生效的 intent/strategy,还是 blended/hybrid 路由。当前 docs/resource 默认不再自动 blended;如果你看见 `route_debug.domain_filter_mode=site_query_retry`,说明 Tavily 的官方域约束已经进入了 `site:` 形式重试。来源:mysearch/clients.py:359, mysearch/clients.py:651, mysearch/clients.py:1459 +3. 文档类查询要分清“页面发现”和“正文抓取”是两个阶段;正文异常先查 Firecrawl,再看 Tavily/Exa fallback。纯 `pricing/价格` 这类官方价格题当前不再默认按 docs/resource 路由,除非 query 里还显式带了 `docs`、`documentation`、`manual` 之类文档信号。来源:mysearch/clients.py:1023, mysearch/clients.py:677, mysearch/clients.py:3423 4. 如果问题出在团队共享链路,而不是本地 runtime,就把排查重心切到 `proxy/` 的 key 池、token、usage sync 和 social gateway 配置。遇到“后台地址和 app key 都已配置,但 Social / X token 统计还是 0”时,先确认上游 `/v1/admin/tokens` 返回结构是否带 envelope;当前控制台已经兼容 `{tokens:{...}}`、`{data:{...}}`、`{items:[...]}`,所以这种现象通常说明进程还没重启到新代码,或者后台返回的并不是这组 admin 语义。来源:proxy/README.md:27, proxy/server.py:189, proxy/server.py:736, proxy/database.py:179 ## 5. 什么时候改哪里 diff --git a/llmdoc/reference/runtime-entrypoints.md b/llmdoc/reference/runtime-entrypoints.md index b5007e3..c77fe90 100644 --- a/llmdoc/reference/runtime-entrypoints.md +++ b/llmdoc/reference/runtime-entrypoints.md @@ -16,7 +16,7 @@ | `Dockerfile.stack` | 单容器一体化镜像入口 | 在一个镜像里同时安装 `proxy` 与 `mysearch` 依赖,复制两个目录,并通过 `docker/combined-entrypoint.sh` 同时拉起 `proxy` 与远程 MCP;默认继续把 `MYSEARCH_PROXY_BASE_URL` 指向容器内 `127.0.0.1:9874`。来源:Dockerfile.stack:1 | | `docker/combined-entrypoint.sh` | 单容器双进程启动脚本 | 在同一个容器里先起本地 `proxy`,再通过 bootstrap 脚本拿到 `MYSEARCH_PROXY_API_KEY`,最后启动 `mysearch`;默认把 `proxy` 绑定到 `0.0.0.0:9874` 方便外部访问控制台,同时 `mysearch` 继续通过容器内 `127.0.0.1:9874` 回连 Proxy。由于 `proxy/server.py` 和 `proxy/key_pool.py` 仍然保留顶层 `import database` / `from key_pool import pool` 这类导入方式,stack 启动脚本还会把 `/app/proxy` 追加到 `PYTHONPATH`,确保单容器镜像里的 `uvicorn proxy.server:app` 能正常导入。任一子进程退出时,脚本会一起清理并让容器失败。来源:docker/combined-entrypoint.sh:1 | | `mysearch/config.py` | Runtime 配置装配层 | 先读宿主 `~/.codex/config.toml`,再读 `.env` 兜底,并组装 Tavily / Firecrawl / Exa / xAI 的 `ProviderConfig`;Tavily 现在显式区分 `official` 与 `gateway`。运行时还兼容 Python 3.9:在解释器低于 3.10 时,dataclass 装配层会自动去掉 `slots`,避免配置模块导入即失败。来源:mysearch/config.py:17, mysearch/config.py:85, mysearch/config.py:98, mysearch/config.py:281, mysearch/config.py:320 | -| `mysearch/clients.py` | 搜索编排与 provider 适配层 | 统一处理 intent、strategy、routing、cache、blended search、extract fallback、research workflow。来源:mysearch/clients.py:277, mysearch/clients.py:385, mysearch/clients.py:677, mysearch/clients.py:802, mysearch/clients.py:964 | +| `mysearch/clients.py` | 搜索编排与 provider 适配层 | 统一处理 intent、strategy、routing、cache、blended search、extract fallback、research workflow。当前 docs/resource 查询默认不再因为 `balanced|verify|deep` 自动做 Tavily + Firecrawl blended;带 `include_domains` 的 Tavily 查询如果首轮 0 结果,会先做 `site:` 形式的 Tavily 重试,再按需回退 Firecrawl,避免官方域约束直接空返回。纯 `pricing/价格` 查询也不再被强判成 docs/resource。来源:mysearch/clients.py:277, mysearch/clients.py:385, mysearch/clients.py:677, mysearch/clients.py:802, mysearch/clients.py:964, mysearch/clients.py:1223, mysearch/clients.py:1354, mysearch/clients.py:3423 | | `proxy/server.py` | Proxy 与控制台入口 | 暴露 Tavily / Firecrawl / Exa / Social 代理端点、控制台页面与管理 API;除了默认 `/` 搜索控制台以外,现在还额外提供 `/mysearch` 作为独立的 MySearch 接入页。FastAPI 入口已经切到 lifespan:启动时初始化数据库,退出时统一关闭共享 `http_client`,不再依赖已废弃的 `startup` hook。导入期的 `asyncio.Lock()` 也改成惰性初始化,避免 Python 3.9 在测试或脚本导入阶段留下未关闭事件循环。Tavily 上游链路现在会先按当前配置发起 probe 和真实转发;如果上游模式下首次命中 `404`,还会自动回退到 `/api/tavily/*` 兼容路径,避免 Hikari 类网关在 `Base URL` 写成主机根时直接报 `Not Found`。当 Tavily 当前实际走上游时,控制台还会额外尝试读取上游公开的 `/api/summary`,把 Hikari 的基础摘要映射成 `upstream_summary`。Social / X 则继续分成两档:接通后台 admin 接口时返回完整 token 统计;只有手动上游 key / gateway token 时,只返回 `upstream_visibility` 这类基础接线可视化,不再把“没有后台 token 面板”和“没有接通上游”混成同一组 0 值。兼容后台的 `/v1/admin/tokens` 如果不是直接返回 `pool -> list`,而是包了一层 `{tokens:{...}}`、`{data:{...}}`、`{items:[...]}` 之类 envelope,当前解析逻辑也会先解包再做统计,避免后台已接通但控制台仍显示一排 0。现在还额外提供了受限的 `/api/internal/mysearch/token` bootstrap 接口:仅在配置 `MYSEARCH_PROXY_BOOTSTRAP_TOKEN` 时启用,用于给 `mysearch` 容器自动创建或复用专用的 `mysp-` token,不让运行时直接读 SQLite。来源:proxy/server.py:219, proxy/server.py:228, proxy/server.py:233, proxy/server.py:680, proxy/server.py:1421, proxy/server.py:2702 | | `proxy/.dockerignore` | Proxy 容器上下文收口 | Docker 本地构建 `proxy/` 镜像时,会排除 `data/`、`proxy.db`、`__pycache__`、`.env`、README 和 compose 文件,避免把本地 SQLite、缓存和非运行时资产打进镜像上下文。来源:proxy/.dockerignore:1 | | `.github/workflows/docker-publish.yml` | Docker 自动构建与发布入口 | 参考 `code/program/todo-list/.github/workflows/docker-publish.yml` 的结构,但当前仓库已经扩成三镜像 matrix:先跑 Python 单测、`py_compile` 与 `node --check proxy/static/js/console.js`,再分别对 `proxy`、`mysearch` 与 `stack` 做 buildx 多架构构建;pull request 只验证构建,push 到 `main` 或 `v*` tag 时才登录 Docker Hub 并推送。默认镜像名分别是 `mysearch-proxy`、`mysearch-mcp` 与 `mysearch-stack`,也可以用 `DOCKERHUB_IMAGE_NAME_PROXY` / `DOCKERHUB_IMAGE_NAME_MYSEARCH` / `DOCKERHUB_IMAGE_NAME_STACK` 覆盖。来源:.github/workflows/docker-publish.yml:1 | diff --git a/mysearch/clients.py b/mysearch/clients.py index 3de40c0..bddc1ee 100644 --- a/mysearch/clients.py +++ b/mysearch/clients.py @@ -585,6 +585,9 @@ def search( decision=decision, sources=normalized_sources, strategy=resolved_strategy, + mode=mode, + intent=resolved_intent, + include_domains=include_domains, ): result = self._search_web_blended( query=query, @@ -1220,6 +1223,9 @@ def _should_blend_web_providers( decision: RouteDecision, sources: list[str], strategy: SearchStrategy, + mode: SearchMode = "auto", + intent: ResolvedSearchIntent = "factual", + include_domains: list[str] | None = None, ) -> bool: if requested_provider != "auto": return False @@ -1229,6 +1235,12 @@ def _should_blend_web_providers( return False if "x" in sources: return False + if include_domains: + return False + if mode in {"docs", "github", "pdf"}: + return False + if intent in {"resource", "tutorial"}: + return False return self._provider_can_serve(self.config.tavily) and self._provider_can_serve( self.config.firecrawl ) @@ -1342,6 +1354,55 @@ def _search_tavily( include_content: bool, include_domains: list[str] | None, exclude_domains: list[str] | None, + ) -> dict[str, Any]: + include_domains = [item.strip() for item in (include_domains or []) if item and item.strip()] + exclude_domains = [item.strip() for item in (exclude_domains or []) if item and item.strip()] + + response = self._search_tavily_once( + query=query, + max_results=max_results, + topic=topic, + include_answer=include_answer, + include_content=include_content, + include_domains=include_domains, + exclude_domains=exclude_domains, + ) + if response.get("results") or not include_domains: + return response + + retry_response = self._search_tavily_domain_retry( + query=query, + max_results=max_results, + topic=topic, + include_content=include_content, + include_domains=include_domains, + exclude_domains=exclude_domains, + ) + if retry_response is not None: + return retry_response + + fallback_response = self._search_tavily_domain_fallback( + query=query, + max_results=max_results, + include_content=include_content, + include_domains=include_domains, + exclude_domains=exclude_domains, + ) + if fallback_response is not None: + return fallback_response + + return response + + def _search_tavily_once( + self, + *, + query: str, + max_results: int, + topic: str, + include_answer: bool, + include_content: bool, + include_domains: list[str] | None, + exclude_domains: list[str] | None, ) -> dict[str, Any]: provider = self.config.tavily key = self._get_key_or_raise(provider) @@ -1365,6 +1426,23 @@ def _search_tavily( payload=payload, key=key.key, ) + results = [ + { + "provider": "tavily", + "source": "web", + "title": item.get("title", ""), + "url": item.get("url", ""), + "snippet": item.get("content", ""), + "content": item.get("raw_content", "") if include_content else "", + "score": item.get("score"), + } + for item in response.get("results", []) + ] + filtered_results = self._filter_results_by_domains( + results, + include_domains=include_domains, + exclude_domains=exclude_domains, + ) return { "provider": "tavily", "transport": key.source, @@ -1372,25 +1450,163 @@ def _search_tavily( "answer": response.get("answer", ""), "request_id": response.get("request_id", ""), "response_time": response.get("response_time"), - "results": [ - { - "provider": "tavily", - "source": "web", - "title": item.get("title", ""), - "url": item.get("url", ""), - "snippet": item.get("content", ""), - "content": item.get("raw_content", "") if include_content else "", - "score": item.get("score"), - } - for item in response.get("results", []) - ], + "results": filtered_results, "citations": [ {"title": item.get("title", ""), "url": item.get("url", "")} - for item in response.get("results", []) + for item in filtered_results if item.get("url") ], } + def _search_tavily_domain_retry( + self, + *, + query: str, + max_results: int, + topic: str, + include_content: bool, + include_domains: list[str], + exclude_domains: list[str] | None, + ) -> dict[str, Any] | None: + per_domain_results = [] + retried_domains: list[str] = [] + for domain in include_domains: + domain_result = self._search_tavily_once( + query=self._build_firecrawl_domain_query( + query=query, + include_domain=domain, + exclude_domains=exclude_domains, + ), + max_results=max_results, + topic=topic, + include_answer=False, + include_content=include_content, + include_domains=[domain], + exclude_domains=exclude_domains, + ) + if not domain_result.get("results"): + continue + per_domain_results.append(domain_result) + retried_domains.append(domain) + + if not per_domain_results: + return None + + merged_results = self._merge_ranked_results( + [result.get("results", []) for result in per_domain_results], + max_results=max_results, + ) + citations = self._align_citations_with_results( + results=merged_results, + citations=self._dedupe_citations( + *[result.get("citations", []) for result in per_domain_results] + ), + ) + return { + "provider": "tavily", + "transport": per_domain_results[0].get("transport", "env"), + "query": query, + "answer": "", + "request_id": "", + "response_time": None, + "results": merged_results, + "citations": citations, + "route_debug": { + "domain_filter_mode": "site_query_retry", + "retried_include_domains": retried_domains, + }, + } + + def _search_tavily_domain_fallback( + self, + *, + query: str, + max_results: int, + include_content: bool, + include_domains: list[str], + exclude_domains: list[str] | None, + ) -> dict[str, Any] | None: + if not self._provider_can_serve(self.config.firecrawl): + return None + + categories = ( + self._firecrawl_categories("docs", "resource") + if self._looks_like_docs_query(query.lower()) or self._looks_like_tutorial_query(query.lower()) + else [] + ) + per_domain_results = [] + citations = [] + seen_urls: set[str] = set() + for domain in include_domains: + domain_result = self._search_firecrawl_once( + query=self._build_firecrawl_domain_query( + query=query, + include_domain=domain, + exclude_domains=exclude_domains, + ), + max_results=max_results, + categories=categories, + include_content=include_content, + ) + if not domain_result.get("results"): + retry_result = self._search_firecrawl_domain_retry( + query=query, + max_results=max_results, + categories=categories, + include_content=include_content, + include_domain=domain, + exclude_domains=exclude_domains, + ) + if retry_result is not None: + domain_result = retry_result + per_domain_results.append(domain_result) + for item in domain_result.get("results", []): + url = item.get("url", "") + if not url or url in seen_urls: + continue + seen_urls.add(url) + citations.append({"title": item.get("title", ""), "url": url}) + + merged_results = self._merge_ranked_results( + [result.get("results", []) for result in per_domain_results], + max_results=max_results, + ) + if not merged_results: + return None + + return { + "provider": "hybrid", + "route_selected": "tavily+firecrawl", + "query": query, + "answer": "", + "results": merged_results, + "citations": citations[:max_results], + "primary_search": { + "provider": "tavily", + "query": query, + "results": [], + "citations": [], + }, + "secondary_search": { + "provider": "firecrawl", + "query": query, + "results": merged_results, + "citations": citations[:max_results], + }, + "secondary_error": "", + "evidence": { + "providers_consulted": ["tavily", "firecrawl"], + "matched_results": 0, + "citation_count": len(citations[:max_results]), + "verification": "fallback", + }, + "fallback": { + "from": "tavily", + "to": "firecrawl", + "reason": "tavily returned 0 results for domain-filtered search", + }, + } + def _search_firecrawl( self, *, @@ -2465,10 +2681,21 @@ def _resource_result_rank( mode=mode, ) ) + official_resource_match = int( + self._is_probably_official_resource_result( + mode=mode, + include_match=bool(include_match), + host_brand_match=bool(host_brand_match), + title_brand_match=bool(title_brand_match), + docs_shape_match=bool(docs_shape_match), + non_third_party=bool(non_third_party), + ) + ) matched_provider_count = len(item.get("matched_providers") or []) content_score, snippet_score, title_score = self._result_quality_score(item) return ( include_match, + official_resource_match, github_bonus, pdf_bonus, host_brand_match, @@ -2481,6 +2708,26 @@ def _resource_result_rank( title_score, ) + def _is_probably_official_resource_result( + self, + *, + mode: SearchMode, + include_match: bool, + host_brand_match: bool, + title_brand_match: bool, + docs_shape_match: bool, + non_third_party: bool, + ) -> bool: + if include_match: + return True + if mode in {"github", "pdf"}: + return True + if not non_third_party: + return False + if not docs_shape_match: + return False + return host_brand_match or title_brand_match + def _align_citations_with_results( self, *, @@ -3176,13 +3423,11 @@ def _looks_like_docs_query(self, query_lower: str) -> bool: "documentation", "api reference", "changelog", - "pricing", "readme", "github", "manual", "文档", "接口", - "价格", "更新日志", ] return any(keyword in query_lower for keyword in keywords) diff --git a/tests/test_clients.py b/tests/test_clients.py index 68dfb21..dde18a1 100644 --- a/tests/test_clients.py +++ b/tests/test_clients.py @@ -30,6 +30,21 @@ def __exit__(self, exc_type, exc, tb) -> None: class MySearchClientTests(unittest.TestCase): + def test_pricing_keywords_alone_do_not_trigger_docs_mode(self) -> None: + client = MySearchClient() + + self.assertFalse(client._looks_like_docs_query("openai pricing")) + self.assertFalse(client._looks_like_docs_query("苹果 m4 macbook air 价格")) + self.assertEqual( + client._resolve_intent( + query="苹果 M4 MacBook Air 国行价格 官方", + mode="auto", + intent="auto", + sources=["web"], + ), + "factual", + ) + def test_request_json_auth_error_mentions_rejected_key(self) -> None: client = MySearchClient() provider = client.config.tavily @@ -270,6 +285,120 @@ def test_firecrawl_domain_filtered_search_skips_tavily_auth_error_fallback(self) self.assertEqual(result["provider"], "firecrawl") self.assertEqual(result["results"], []) + def test_tavily_domain_filtered_search_retries_with_site_query(self) -> None: + client = MySearchClient() + calls: list[str] = [] + + def fake_search_tavily_once(**kwargs): # type: ignore[no-untyped-def] + calls.append(kwargs["query"]) + if kwargs["query"] == "OpenAI Responses API docs": + return { + "provider": "tavily", + "transport": "env", + "query": kwargs["query"], + "answer": "", + "results": [], + "citations": [], + } + return { + "provider": "tavily", + "transport": "env", + "query": kwargs["query"], + "answer": "", + "results": [ + { + "provider": "tavily", + "source": "web", + "title": "Responses | OpenAI API Reference", + "url": "https://platform.openai.com/docs/api-reference/responses", + "snippet": "Official OpenAI docs", + "content": "", + } + ], + "citations": [ + { + "title": "Responses | OpenAI API Reference", + "url": "https://platform.openai.com/docs/api-reference/responses", + } + ], + } + + client._search_tavily_once = fake_search_tavily_once # type: ignore[method-assign] + + result = client._search_tavily( + query="OpenAI Responses API docs", + max_results=5, + topic="general", + include_answer=False, + include_content=False, + include_domains=["openai.com"], + exclude_domains=None, + ) + + self.assertEqual(len(result["results"]), 1) + self.assertEqual( + result["results"][0]["url"], + "https://platform.openai.com/docs/api-reference/responses", + ) + self.assertEqual(result["route_debug"]["domain_filter_mode"], "site_query_retry") + self.assertEqual(result["route_debug"]["retried_include_domains"], ["openai.com"]) + self.assertEqual(calls[1], "site:openai.com OpenAI Responses API docs") + + def test_tavily_domain_filtered_search_falls_back_to_firecrawl(self) -> None: + client = MySearchClient() + client.keyring.has_provider = lambda provider: provider == "firecrawl" # type: ignore[method-assign] + client._probe_provider_status = lambda provider, key_count: { # type: ignore[method-assign] + "status": "ok", + "error": "", + "checked_at": "2026-03-20T00:00:00+00:00", + } + client._search_tavily_once = lambda **kwargs: { # type: ignore[method-assign] + "provider": "tavily", + "transport": "env", + "query": kwargs["query"], + "answer": "", + "results": [], + "citations": [], + } + client._search_firecrawl_once = lambda **kwargs: { # type: ignore[method-assign] + "provider": "firecrawl", + "transport": "env", + "query": kwargs["query"], + "answer": "", + "results": [ + { + "provider": "firecrawl", + "source": "web", + "title": "Responses | OpenAI API Reference", + "url": "https://platform.openai.com/docs/api-reference/responses", + "snippet": "Official OpenAI docs", + "content": "", + } + ], + "citations": [ + { + "title": "Responses | OpenAI API Reference", + "url": "https://platform.openai.com/docs/api-reference/responses", + } + ], + } + + result = client._search_tavily( + query="OpenAI Responses API docs", + max_results=5, + topic="general", + include_answer=False, + include_content=False, + include_domains=["openai.com"], + exclude_domains=None, + ) + + self.assertEqual(result["provider"], "hybrid") + self.assertEqual(result["route_selected"], "tavily+firecrawl") + self.assertEqual(result["fallback"]["from"], "tavily") + self.assertEqual(result["fallback"]["to"], "firecrawl") + self.assertEqual(len(result["results"]), 1) + def test_docs_blended_search_reranks_official_results_ahead_of_third_party(self) -> None: client = MySearchClient() official_url = "https://platform.openai.com/docs/api-reference/responses" @@ -447,18 +576,18 @@ def test_search_route_reason_surfaces_secondary_provider_auth_error(self) -> Non "checked_at": "2026-03-20T00:00:00+00:00", } client._route_search = lambda **kwargs: RouteDecision( # type: ignore[method-assign] - provider="firecrawl", - reason="文档类查询优先 Firecrawl", - firecrawl_categories=["technical"], + provider="tavily", + reason="普通网页检索默认走 Tavily", + tavily_topic="general", ) - client._search_firecrawl = lambda **kwargs: { # type: ignore[method-assign] - "provider": "firecrawl", + client._search_tavily = lambda **kwargs: { # type: ignore[method-assign] + "provider": "tavily", "transport": "env", "query": kwargs["query"], "answer": "", "results": [ { - "provider": "firecrawl", + "provider": "tavily", "source": "web", "title": "Official docs", "url": "https://docs.example.com/page", @@ -469,25 +598,25 @@ def test_search_route_reason_surfaces_secondary_provider_auth_error(self) -> Non "citations": [{"title": "Official docs", "url": "https://docs.example.com/page"}], } - def fail_tavily(**kwargs): # type: ignore[no-untyped-def] + def fail_firecrawl(**kwargs): # type: ignore[no-untyped-def] raise MySearchHTTPError( - provider="tavily", + provider="firecrawl", status_code=401, detail="The account associated with this API key has been deactivated.", url="https://example.com/search", ) - client._search_tavily = fail_tavily # type: ignore[method-assign] + client._search_firecrawl = fail_firecrawl # type: ignore[method-assign] result = client.search( - query="example docs", - mode="docs", + query="example search", + mode="auto", strategy="balanced", provider="auto", include_answer=False, ) - self.assertEqual(result["provider"], "firecrawl") + self.assertEqual(result["provider"], "tavily") self.assertIn("secondary provider issue", result["route"]["reason"]) self.assertIn("configured but the API key was rejected", result["route"]["reason"]) diff --git a/tests/test_comprehensive.py b/tests/test_comprehensive.py index f815017..b8d4f0b 100644 --- a/tests/test_comprehensive.py +++ b/tests/test_comprehensive.py @@ -10,6 +10,7 @@ import copy import io import os +import sys import threading import time import unittest @@ -18,6 +19,10 @@ from unittest.mock import patch from urllib.error import HTTPError +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + from mysearch.clients import ( MySearchClient, MySearchError, @@ -465,6 +470,42 @@ def test_no_blend_when_x_in_sources(self) -> None: strategy="balanced", )) + def test_no_blend_for_docs_mode(self) -> None: + client = _make_client(tavily_keys=["k"], firecrawl_keys=["k"]) + self.assertFalse(client._should_blend_web_providers( + requested_provider="auto", + decision=RouteDecision(provider="tavily", reason="test"), + sources=["web"], + strategy="balanced", + mode="docs", + intent="resource", + include_domains=None, + )) + + def test_no_blend_for_resource_intent(self) -> None: + client = _make_client(tavily_keys=["k"], firecrawl_keys=["k"]) + self.assertFalse(client._should_blend_web_providers( + requested_provider="auto", + decision=RouteDecision(provider="tavily", reason="test"), + sources=["web"], + strategy="balanced", + mode="auto", + intent="resource", + include_domains=None, + )) + + def test_no_blend_when_include_domains_present(self) -> None: + client = _make_client(tavily_keys=["k"], firecrawl_keys=["k"]) + self.assertFalse(client._should_blend_web_providers( + requested_provider="auto", + decision=RouteDecision(provider="tavily", reason="test"), + sources=["web"], + strategy="balanced", + mode="auto", + intent="factual", + include_domains=["openai.com"], + )) + def test_blend_when_conditions_met(self) -> None: client = _make_client(tavily_keys=["k"], firecrawl_keys=["k"]) self.assertTrue(client._should_blend_web_providers( @@ -472,6 +513,9 @@ def test_blend_when_conditions_met(self) -> None: decision=RouteDecision(provider="tavily", reason="test"), sources=["web"], strategy="balanced", + mode="auto", + intent="factual", + include_domains=None, )) From c64d7f34795db2d55d130210daa35e23a75d34b4 Mon Sep 17 00:00:00 2001 From: zhaishujie <3394180966@qq.com> Date: Sun, 22 Mar 2026 13:29:52 +0800 Subject: [PATCH 15/20] unify proxy sqlite path across docker deployments --- Dockerfile.stack | 3 +- README.md | 2 +- README_EN.md | 2 +- docker-compose.yml | 3 +- llmdoc/architecture/proxy-first.md | 2 +- llmdoc/reference/entrypoints-and-config.md | 2 +- llmdoc/reference/runtime-entrypoints.md | 6 +-- mysearch/README.md | 2 +- proxy/Dockerfile | 1 + proxy/README.md | 10 ++-- proxy/README_EN.md | 8 ++-- proxy/database.py | 12 +++-- proxy/docker-compose.yml | 3 +- tests/test_proxy_database_path.py | 53 ++++++++++++++++++++++ 14 files changed, 88 insertions(+), 21 deletions(-) create mode 100644 tests/test_proxy_database_path.py diff --git a/Dockerfile.stack b/Dockerfile.stack index 4a20c40..ee080d5 100644 --- a/Dockerfile.stack +++ b/Dockerfile.stack @@ -3,7 +3,8 @@ FROM python:3.11-slim ENV PYTHONUNBUFFERED=1 \ PYTHONDONTWRITEBYTECODE=1 \ PYTHONPATH=/app \ - MYSEARCH_PROXY_BASE_URL=http://127.0.0.1:9874 + MYSEARCH_PROXY_BASE_URL=http://127.0.0.1:9874 \ + MYSEARCH_PROXY_DB_PATH=/data/proxy.db WORKDIR /app diff --git a/README.md b/README.md index 91b0533..2d0fd11 100644 --- a/README.md +++ b/README.md @@ -207,7 +207,7 @@ docker run -d \ -p 8000:8000 \ -e ADMIN_PASSWORD=change-me \ -e MYSEARCH_PROXY_BOOTSTRAP_TOKEN=change-me-bootstrap-token \ - -v $(pwd)/mysearch-proxy-data:/app/proxy/data \ + -v $(pwd)/mysearch-proxy-data:/data \ skernelx/mysearch-stack:latest ``` diff --git a/README_EN.md b/README_EN.md index 85698d2..6c00a91 100644 --- a/README_EN.md +++ b/README_EN.md @@ -474,7 +474,7 @@ docker run -d \ --restart unless-stopped \ -p 9874:9874 \ -e ADMIN_PASSWORD=your-admin-password \ - -v $(pwd)/mysearch-proxy-data:/app/data \ + -v $(pwd)/mysearch-proxy-data:/data \ skernelx/mysearch-proxy:latest ``` diff --git a/docker-compose.yml b/docker-compose.yml index 702cadd..56276a8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,13 +7,14 @@ services: - "${MYSEARCH_PROXY_PORT:-9874}:9874" environment: ADMIN_PASSWORD: ${ADMIN_PASSWORD:-change-me} + MYSEARCH_PROXY_DB_PATH: ${MYSEARCH_PROXY_DB_PATH:-/data/proxy.db} MYSEARCH_PROXY_BOOTSTRAP_TOKEN: ${MYSEARCH_PROXY_BOOTSTRAP_TOKEN:-change-me-bootstrap-token} STATS_CACHE_TTL_SECONDS: ${STATS_CACHE_TTL_SECONDS:-8} DASHBOARD_AUTO_SYNC_ON_STATS: ${DASHBOARD_AUTO_SYNC_ON_STATS:-0} DASHBOARD_BACKGROUND_SYNC_ON_STATS: ${DASHBOARD_BACKGROUND_SYNC_ON_STATS:-1} DASHBOARD_BACKGROUND_SYNC_MIN_INTERVAL_SECONDS: ${DASHBOARD_BACKGROUND_SYNC_MIN_INTERVAL_SECONDS:-45} volumes: - - mysearch-proxy-data:/app/data + - mysearch-proxy-data:/data restart: unless-stopped mysearch: diff --git a/llmdoc/architecture/proxy-first.md b/llmdoc/architecture/proxy-first.md index 803b530..6b3be28 100644 --- a/llmdoc/architecture/proxy-first.md +++ b/llmdoc/architecture/proxy-first.md @@ -50,7 +50,7 @@ ## 数据与控制面 -- Proxy 的启动时机会执行 `db.init_db()`;SQLite 默认路径是 `proxy/data/proxy.db`。来源:proxy/server.py:42, proxy/database.py:11, proxy/database.py:61 +- Proxy 的启动时机会执行 `db.init_db()`;SQLite 默认路径仍是 `proxy/data/proxy.db`,但容器部署已经统一通过 `MYSEARCH_PROXY_DB_PATH=/data/proxy.db` 覆盖,避免独立 `proxy` 镜像和 `mysearch-stack` 因内部目录不同各写一份库。来源:proxy/server.py:42, proxy/database.py:11, proxy/database.py:15, proxy/database.py:59, proxy/Dockerfile:1, Dockerfile.stack:1 - Proxy 的 token 体系里包含 `mysearch` 服务,生成前缀为 `mysp-` 的统一 token,默认只做鉴权与统计,不做配额拦截。来源:proxy/database.py:13, proxy/database.py:18, proxy/README.md:74, proxy/README.md:83 - `proxy-first` 的容器部署边界现在同时支持“两服务一套 stack”和“单容器一体化镜像”两种形态。仓库根的 `docker-compose.yml` 会同时编排 `proxy` 与 `mysearch`:前者负责控制台、token 与统一代理,后者负责对 Codex/Claude 暴露远程 MCP;`mysearch` 在同一个 compose 网络里继续用 `MYSEARCH_PROXY_BASE_URL=http://proxy:9874` 访问 Proxy,并通过受限的 `MYSEARCH_PROXY_BOOTSTRAP_TOKEN` 向 `/api/internal/mysearch/token` 申请或复用专用的 `mysp-` token。对于更看重部署步骤最少的场景,`Dockerfile.stack` 与 `docker/combined-entrypoint.sh` 又把这两个进程收成单容器镜像 `mysearch-stack`:`proxy` 默认对外监听 `9874`,而 `mysearch` 继续监听 `8000/mcp` 并通过容器内 `127.0.0.1:9874` 回连 Proxy。GitHub Actions 侧也已经从“只发 Proxy 镜像”扩成三镜像 matrix:`.github/workflows/docker-publish.yml` 会分别构建/发布 `proxy`、`mysearch` 与 `stack`,而根目录、`proxy/` 和 `mysearch/` 的 `.dockerignore` 则分别收口各自上下文,避免把本地 SQLite、`.env`、accounts 文件与缓存一起打进镜像。来源:docker-compose.yml:1, Dockerfile.stack:1, docker/combined-entrypoint.sh:1, .github/workflows/docker-publish.yml:1, .dockerignore:1, proxy/.dockerignore:1, mysearch/.dockerignore:1 - Proxy 控制台现在已经从单文件模板拆成 `console.html + _hero.html + _settings_modal.html + console.css + console.js` 这套 live 前端;页面布局已经回到 `summary-strip + dashboard-flow` 的纵向结构,默认首页下半区固定为 `Workspace Navigator -> provider workspace`,而统一客户端接入则拆到独立的 `/mysearch` 页面。`Workspace Navigator` 仍然只保留工作台名称、状态和 2 个核心指标,次要信息下沉到 badge 与 footnote,不再展示 `/api/search`、`/social/search` 这类具体请求路径;但它现在不再纵向堆叠,而是由 `service-switcher` 横向卡阵列承接,`Social Compatibility` 提示卡也继续收在 switcher 区块底部。登录入口也不再是孤立小表单,而是通过 `auth-meta` 把“统一入口 / provider / 控制面”三个概念先交代清楚,并在登录成功后由 `showDashboard({ animate: true })` 做一轮轻量 staged reveal,而在 `prefers-reduced-motion` 下会自动压平动效。hero 右侧原先那张“当前工作台”大卡已经移除,不再在首屏重复展示当前控制台状态。`/mysearch` 页则收成 `MySearch 接入台`,模块标题进一步压成 `统一接入配置`,避免页级标题和模块标题重复。该页内部继续保持“接入配置 / 安装路径 / 通用 Token 管理”三层,而且“一键配置”内部进一步拆成左侧 `quickstart-visual-col` 可视化 readiness 区和右侧 `quickstart-config-col` 配置区:`getQuickstartProviderCards()` 继续把 Tavily `effective_mode`、Exa / Firecrawl key 状态和 Social / X 接线结果汇总成 `quickstart-route-strip`,`getQuickstartInstallHint()` 继续把当前最短安装路径压成 `quickstart-install-strip`,同时也把旧版更直接的 `stdio / streamable-http` 安装形态补回到 `quickstart-install-meta`。这些状态会一起写入生成的 `MYSEARCH_PROXY_*` 配置说明;除了复制块旁边的普通复制按钮,现在还额外提供 `copyEnvAndRevealInstall()` 这个组合动作,直接复制 `.env` 并把视口定位到安装命令。默认首页的 `summary-strip` 也已经收窄成项目级概览:`当前工作台 / 已接通工作台 / Provider 代理 Token / 今日调用 / 本月调用 / MySearch Token`,不再塞入工作台内部已经会单独展示的上游额度或本地 API Key。主题切换则扩成 `浅色 / 深色 / 自动` 三态,`自动` 依据打开页面那台机器的本地时区与本地时间决定实际主题,不依赖服务端所在系统或容器时区。`MySearch 通用 Token` 摘要表继续共享和 provider 面板一致的本地搜索/排序逻辑。provider 页面仍然保持“摘要表 + `detail-drawer`”的运维视图,`Token 池 / API Key 池` 的本地搜索、筛选和排序,以及 `table-row-clickable.is-danger|is-warn|is-busy|is-off` 风险行态都保留不变;`detail-drawer` 底部动作也继续通过 `renderDrawerActionGroup()` 拆成“维护动作 / 危险动作”两组。设置面板仍是带 `settings-summary-strip`、sticky footer 和 Tavily `mode-switch` 分段控件的配置中心,并保留 `/api/settings/test/tavily` 与 `/api/settings/test/social` 这两条结构化 probe 链路。控制台刷新仍然通过 `normalizeRefreshScope()`、`getRefreshScopeForService()`、`renderDashboardScope()` 做局部更新,可访问性层也仍然保留 `handleSegmentedControlKey()`、toast live region、overlay focus remember/restore 与 `trapOverlayFocus()` 这一组统一逻辑。来源:proxy/templates/components/_hero.html:25, proxy/templates/components/_hero.html:51, proxy/templates/console.html:51, proxy/templates/console.html:54, proxy/templates/console.html:65, proxy/templates/mysearch.html:21, proxy/static/js/console.js:644, proxy/static/js/console.js:1425, proxy/static/js/console.js:1478, proxy/static/js/console.js:1695, proxy/static/js/console.js:2251, proxy/static/js/console.js:2777, proxy/static/css/console.css:622, proxy/static/css/console.css:717, proxy/static/css/console.css:1061, proxy/static/css/console.css:1508, proxy/static/css/console.css:1533, proxy/static/css/console.css:2923, proxy/static/css/console.css:2943, proxy/server.py:2259 diff --git a/llmdoc/reference/entrypoints-and-config.md b/llmdoc/reference/entrypoints-and-config.md index 5348de6..a240f54 100644 --- a/llmdoc/reference/entrypoints-and-config.md +++ b/llmdoc/reference/entrypoints-and-config.md @@ -138,7 +138,7 @@ OpenClaw 侧也是 host-config-first,但入口不同: ### Proxy SQLite -- 数据库路径:`proxy/data/proxy.db`,见 `proxy/database.py:11`。 +- 数据库路径默认是 `proxy/data/proxy.db`,但容器部署现在统一建议通过 `MYSEARCH_PROXY_DB_PATH=/data/proxy.db` 覆盖,避免独立 `proxy` 镜像和 `mysearch-stack` 因内部目录不同而各自写到不同位置。见 `proxy/database.py:11`、`proxy/database.py:15`、`proxy/Dockerfile:1`、`Dockerfile.stack:1`。 - 主要表:`api_keys`、`tokens`、`usage_logs`、`settings`,见 `proxy/database.py:61`。 - 下游 token 服务范围包含 `tavily`、`firecrawl`、`exa`、`mysearch`;`mysearch` token 前缀为 `mysp-`。见 `proxy/database.py:12`、`proxy/database.py:13`、`proxy/database.py:14`。 diff --git a/llmdoc/reference/runtime-entrypoints.md b/llmdoc/reference/runtime-entrypoints.md index c77fe90..8b695f1 100644 --- a/llmdoc/reference/runtime-entrypoints.md +++ b/llmdoc/reference/runtime-entrypoints.md @@ -5,7 +5,7 @@ | 文件 | 角色 | 关键点 | | --- | --- | --- | | `install.sh` | 本地安装与注册入口 | 安装 `mysearch/requirements.txt`,继承宿主 `MYSEARCH_*`,再向 `claude` / `codex` 注册 `mysearch` MCP。来源:install.sh:13, install.sh:74, install.sh:174 | -| `docker-compose.yml` | 一套部署入口 | 在仓库根目录同时编排 `proxy` 与 `mysearch` 两个服务:`proxy` 继续监听 `9874` 并落盘 SQLite,`mysearch` 继续以 `streamable-http` 形式监听 `8000/mcp`,并通过 `MYSEARCH_PROXY_BASE_URL=http://proxy:9874` 反向接入同 stack 内的 Proxy。现在 compose 会同时把 `MYSEARCH_PROXY_BOOTSTRAP_TOKEN` 注入两边,`mysearch` 启动时会自动请求 `proxy` 的内部 bootstrap 接口来创建或复用自己的 `mysp-` token,不再要求你手工先创建 MySearch 通用 token 才能拉起远程 MCP。来源:docker-compose.yml:1 | +| `docker-compose.yml` | 一套部署入口 | 在仓库根目录同时编排 `proxy` 与 `mysearch` 两个服务:`proxy` 继续监听 `9874` 并落盘 SQLite,`mysearch` 继续以 `streamable-http` 形式监听 `8000/mcp`,并通过 `MYSEARCH_PROXY_BASE_URL=http://proxy:9874` 反向接入同 stack 内的 Proxy。现在 compose 会同时把 `MYSEARCH_PROXY_BOOTSTRAP_TOKEN` 注入两边,并把 `MYSEARCH_PROXY_DB_PATH` 固定到 `/data/proxy.db`;`mysearch` 启动时会自动请求 `proxy` 的内部 bootstrap 接口来创建或复用自己的 `mysp-` token,不再要求你手工先创建 MySearch 通用 token 才能拉起远程 MCP。来源:docker-compose.yml:1 | | `mysearch/__main__.py` | MySearch CLI 入口 | 解析 `stdio`、`sse`、`streamable-http` transport 及 host/port/path 参数,然后调用 `mysearch.server.main`。来源:mysearch/__main__.py:8 | | `mysearch/server.py` | MCP tool 暴露层 | 用 `FastMCP` 注册 `search`、`extract_url`、`research`、`mysearch_health`,并根据 transport 启动服务。来源:mysearch/server.py:34, mysearch/server.py:47, mysearch/server.py:168 | | `mysearch/Dockerfile` | MySearch MCP 容器入口 | 基于 `python:3.11-slim` 安装 `mysearch/requirements.txt`,只复制 `mysearch/` 目录到镜像内部,并默认以 `python -m mysearch --transport streamable-http --host 0.0.0.0 --port 8000` 暴露远程 MCP。来源:mysearch/Dockerfile:1 | @@ -13,12 +13,12 @@ | `mysearch/scripts/bootstrap_proxy_token.py` | Proxy token bootstrap 客户端 | 负责轮询 `MYSEARCH_PROXY_BASE_URL/api/internal/mysearch/token`,用 `MYSEARCH_PROXY_BOOTSTRAP_TOKEN` 鉴权并把返回的 `mysp-` token 输出给容器启动脚本。来源:mysearch/scripts/bootstrap_proxy_token.py:1 | | `mysearch/.dockerignore` | MySearch 容器上下文收口 | 排除 `.env`、`venv`、`accounts.txt`、数据库与缓存文件,避免把本地调试状态与 secret 一起打进 `mysearch` 镜像上下文。来源:mysearch/.dockerignore:1 | | `.dockerignore` | 单容器镜像上下文收口 | 供根目录 `Dockerfile.stack` 使用,排除 git、venv、`llmdoc`、本地数据目录和缓存文件,避免把仓库级调试资产一起打进单容器镜像。来源:.dockerignore:1 | -| `Dockerfile.stack` | 单容器一体化镜像入口 | 在一个镜像里同时安装 `proxy` 与 `mysearch` 依赖,复制两个目录,并通过 `docker/combined-entrypoint.sh` 同时拉起 `proxy` 与远程 MCP;默认继续把 `MYSEARCH_PROXY_BASE_URL` 指向容器内 `127.0.0.1:9874`。来源:Dockerfile.stack:1 | +| `Dockerfile.stack` | 单容器一体化镜像入口 | 在一个镜像里同时安装 `proxy` 与 `mysearch` 依赖,复制两个目录,并通过 `docker/combined-entrypoint.sh` 同时拉起 `proxy` 与远程 MCP;默认继续把 `MYSEARCH_PROXY_BASE_URL` 指向容器内 `127.0.0.1:9874`,同时把 Proxy SQLite 固定到 `/data/proxy.db`,避免 stack 与独立 `proxy` 镜像因为内部相对目录不同而各自写到不同位置。来源:Dockerfile.stack:1 | | `docker/combined-entrypoint.sh` | 单容器双进程启动脚本 | 在同一个容器里先起本地 `proxy`,再通过 bootstrap 脚本拿到 `MYSEARCH_PROXY_API_KEY`,最后启动 `mysearch`;默认把 `proxy` 绑定到 `0.0.0.0:9874` 方便外部访问控制台,同时 `mysearch` 继续通过容器内 `127.0.0.1:9874` 回连 Proxy。由于 `proxy/server.py` 和 `proxy/key_pool.py` 仍然保留顶层 `import database` / `from key_pool import pool` 这类导入方式,stack 启动脚本还会把 `/app/proxy` 追加到 `PYTHONPATH`,确保单容器镜像里的 `uvicorn proxy.server:app` 能正常导入。任一子进程退出时,脚本会一起清理并让容器失败。来源:docker/combined-entrypoint.sh:1 | | `mysearch/config.py` | Runtime 配置装配层 | 先读宿主 `~/.codex/config.toml`,再读 `.env` 兜底,并组装 Tavily / Firecrawl / Exa / xAI 的 `ProviderConfig`;Tavily 现在显式区分 `official` 与 `gateway`。运行时还兼容 Python 3.9:在解释器低于 3.10 时,dataclass 装配层会自动去掉 `slots`,避免配置模块导入即失败。来源:mysearch/config.py:17, mysearch/config.py:85, mysearch/config.py:98, mysearch/config.py:281, mysearch/config.py:320 | | `mysearch/clients.py` | 搜索编排与 provider 适配层 | 统一处理 intent、strategy、routing、cache、blended search、extract fallback、research workflow。当前 docs/resource 查询默认不再因为 `balanced|verify|deep` 自动做 Tavily + Firecrawl blended;带 `include_domains` 的 Tavily 查询如果首轮 0 结果,会先做 `site:` 形式的 Tavily 重试,再按需回退 Firecrawl,避免官方域约束直接空返回。纯 `pricing/价格` 查询也不再被强判成 docs/resource。来源:mysearch/clients.py:277, mysearch/clients.py:385, mysearch/clients.py:677, mysearch/clients.py:802, mysearch/clients.py:964, mysearch/clients.py:1223, mysearch/clients.py:1354, mysearch/clients.py:3423 | | `proxy/server.py` | Proxy 与控制台入口 | 暴露 Tavily / Firecrawl / Exa / Social 代理端点、控制台页面与管理 API;除了默认 `/` 搜索控制台以外,现在还额外提供 `/mysearch` 作为独立的 MySearch 接入页。FastAPI 入口已经切到 lifespan:启动时初始化数据库,退出时统一关闭共享 `http_client`,不再依赖已废弃的 `startup` hook。导入期的 `asyncio.Lock()` 也改成惰性初始化,避免 Python 3.9 在测试或脚本导入阶段留下未关闭事件循环。Tavily 上游链路现在会先按当前配置发起 probe 和真实转发;如果上游模式下首次命中 `404`,还会自动回退到 `/api/tavily/*` 兼容路径,避免 Hikari 类网关在 `Base URL` 写成主机根时直接报 `Not Found`。当 Tavily 当前实际走上游时,控制台还会额外尝试读取上游公开的 `/api/summary`,把 Hikari 的基础摘要映射成 `upstream_summary`。Social / X 则继续分成两档:接通后台 admin 接口时返回完整 token 统计;只有手动上游 key / gateway token 时,只返回 `upstream_visibility` 这类基础接线可视化,不再把“没有后台 token 面板”和“没有接通上游”混成同一组 0 值。兼容后台的 `/v1/admin/tokens` 如果不是直接返回 `pool -> list`,而是包了一层 `{tokens:{...}}`、`{data:{...}}`、`{items:[...]}` 之类 envelope,当前解析逻辑也会先解包再做统计,避免后台已接通但控制台仍显示一排 0。现在还额外提供了受限的 `/api/internal/mysearch/token` bootstrap 接口:仅在配置 `MYSEARCH_PROXY_BOOTSTRAP_TOKEN` 时启用,用于给 `mysearch` 容器自动创建或复用专用的 `mysp-` token,不让运行时直接读 SQLite。来源:proxy/server.py:219, proxy/server.py:228, proxy/server.py:233, proxy/server.py:680, proxy/server.py:1421, proxy/server.py:2702 | -| `proxy/.dockerignore` | Proxy 容器上下文收口 | Docker 本地构建 `proxy/` 镜像时,会排除 `data/`、`proxy.db`、`__pycache__`、`.env`、README 和 compose 文件,避免把本地 SQLite、缓存和非运行时资产打进镜像上下文。来源:proxy/.dockerignore:1 | +| `proxy/.dockerignore` | Proxy 容器上下文收口 | Docker 本地构建 `proxy/` 镜像时,会排除 `data/`、`proxy.db`、`__pycache__`、`.env`、README 和 compose 文件,避免把本地 SQLite、缓存和非运行时资产打进镜像上下文。独立 `proxy` 镜像本身也会把 `MYSEARCH_PROXY_DB_PATH` 预置为 `/data/proxy.db`,与 stack 共享同一挂载口径。来源:proxy/.dockerignore:1, proxy/Dockerfile:1 | | `.github/workflows/docker-publish.yml` | Docker 自动构建与发布入口 | 参考 `code/program/todo-list/.github/workflows/docker-publish.yml` 的结构,但当前仓库已经扩成三镜像 matrix:先跑 Python 单测、`py_compile` 与 `node --check proxy/static/js/console.js`,再分别对 `proxy`、`mysearch` 与 `stack` 做 buildx 多架构构建;pull request 只验证构建,push 到 `main` 或 `v*` tag 时才登录 Docker Hub 并推送。默认镜像名分别是 `mysearch-proxy`、`mysearch-mcp` 与 `mysearch-stack`,也可以用 `DOCKERHUB_IMAGE_NAME_PROXY` / `DOCKERHUB_IMAGE_NAME_MYSEARCH` / `DOCKERHUB_IMAGE_NAME_STACK` 覆盖。来源:.github/workflows/docker-publish.yml:1 | | `proxy/templates/console.html` | Proxy 控制台壳模板 | 现在只负责装配默认 live 前端入口:引入 `components/_hero.html`、`components/_settings_modal.html`、`static/css/console.css`、`static/js/console.js`,并把页面组织成 `summary-strip + dashboard-flow` 的纵向结构;默认首页下半区顺序已经收成 `Workspace Navigator -> provider workspace`,`MySearch 快速接入` 不再默认挂在首页,改由 hero 里的“查看 MySearch 接入”跳去独立页面。`Social Compatibility` 也收回到 switcher 区块底部,不再单独占一侧 rail。壳层同时继续托管 `detail-drawer`、统一 `app-dialog` 和 `toast-root` 这三类交互容器。登录壳也继续保留在这里,但已经补了 `auth-meta` 三张元信息卡,让登录页与 dashboard 共用同一套基础设施风格。来源:proxy/templates/console.html:45, proxy/templates/console.html:51, proxy/templates/console.html:63, proxy/templates/console.html:92, proxy/templates/console.html:98, proxy/templates/console.html:125 | | `proxy/templates/mysearch.html` | MySearch 独立接入页模板 | 承载独立的 `MySearch 接入台` 页面,保留同一套登录、主题切换、设置弹窗、detail drawer、dialog 和 toast,但默认只展示统一接入配置、安装路径和通用 token 管理,不再把 provider 工作台或首页 `summary-strip` 混进这一页。来源:proxy/templates/mysearch.html:1, proxy/templates/mysearch.html:45, proxy/templates/mysearch.html:63 | diff --git a/mysearch/README.md b/mysearch/README.md index 27c350e..25b0e5e 100644 --- a/mysearch/README.md +++ b/mysearch/README.md @@ -220,7 +220,7 @@ docker run -d \ -p 8000:8000 \ -e ADMIN_PASSWORD=change-me \ -e MYSEARCH_PROXY_BOOTSTRAP_TOKEN=change-me-bootstrap-token \ - -v $(pwd)/mysearch-proxy-data:/app/proxy/data \ + -v $(pwd)/mysearch-proxy-data:/data \ skernelx/mysearch-stack:latest ``` diff --git a/proxy/Dockerfile b/proxy/Dockerfile index f5e149a..bb34cff 100644 --- a/proxy/Dockerfile +++ b/proxy/Dockerfile @@ -1,4 +1,5 @@ FROM python:3.11-slim +ENV MYSEARCH_PROXY_DB_PATH=/data/proxy.db WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt diff --git a/proxy/README.md b/proxy/README.md index 90b3acc..f04898d 100644 --- a/proxy/README.md +++ b/proxy/README.md @@ -153,7 +153,7 @@ docker run -d \ --restart unless-stopped \ -p 9874:9874 \ -e ADMIN_PASSWORD=change-me \ - -v $(pwd)/mysearch-proxy-data:/app/data \ + -v $(pwd)/mysearch-proxy-data:/data \ skernelx/mysearch-proxy:latest ``` @@ -194,7 +194,7 @@ docker run -d \ -p 8000:8000 \ -e ADMIN_PASSWORD=change-me \ -e MYSEARCH_PROXY_BOOTSTRAP_TOKEN=change-me-bootstrap-token \ - -v $(pwd)/mysearch-proxy-data:/app/proxy/data \ + -v $(pwd)/mysearch-proxy-data:/data \ skernelx/mysearch-stack:latest ``` @@ -223,6 +223,8 @@ ADMIN_PASSWORD=change-me uvicorn server:app --host 0.0.0.0 --port 9874 当前控制台已经带密码登录,不再适合匿名裸放在公网。 +持久化目录现在统一建议挂到 `/data`。无论你跑独立 `mysearch-proxy` 还是单容器 `mysearch-stack`,都保持 `-v ...:/data`,不要再混用 `/app/data` 和 `/app/proxy/data`,否则升级重建容器时会像“数据丢失”,实际只是读到了另一份空 SQLite。 + ## 下游怎么接 ### 给 `mysearch/` MCP @@ -274,9 +276,9 @@ MYSEARCH_PROXY_API_KEY=mysp-... 默认数据目录: - Docker compose - - `./data` + - 宿主目录 `./data` 挂到容器内 `/data` - `docker run` 示例 - - `$(pwd)/mysearch-proxy-data` + - 宿主目录 `$(pwd)/mysearch-proxy-data` 挂到容器内 `/data` ## 认证与安全 diff --git a/proxy/README_EN.md b/proxy/README_EN.md index cb458a5..fe8d7dd 100644 --- a/proxy/README_EN.md +++ b/proxy/README_EN.md @@ -208,7 +208,7 @@ docker run -d \ --restart unless-stopped \ -p 9874:9874 \ -e ADMIN_PASSWORD=your-admin-password \ - -v $(pwd)/mysearch-proxy-data:/app/data \ + -v $(pwd)/mysearch-proxy-data:/data \ your-registry/mysearch-proxy:latest ``` @@ -248,11 +248,11 @@ docker run -d \ --restart unless-stopped \ -p 9874:9874 \ -e ADMIN_PASSWORD=your-admin-password \ - -v /your/data/path:/app/data \ + -v /your/data/path:/data \ your-registry/mysearch-proxy:latest ``` -As long as the `/app/data` volume is preserved, your existing: +As long as the `/data` volume is preserved, your existing: - keys - tokens @@ -261,6 +261,8 @@ As long as the `/app/data` volume is preserved, your existing: remain available. +Use `/data` consistently for both the standalone `mysearch-proxy` container and the single-container `mysearch-stack`. Do not mix `/app/data` and `/app/proxy/data`, or an upgrade/recreate will appear to "lose" data while actually switching to a different empty SQLite file. + ## Configuration Baseline console config: diff --git a/proxy/database.py b/proxy/database.py index 5dcb69c..ff7dec8 100644 --- a/proxy/database.py +++ b/proxy/database.py @@ -8,7 +8,7 @@ import string from datetime import datetime, timezone -DB_PATH = os.path.join(os.path.dirname(__file__), "data", "proxy.db") +DEFAULT_DB_PATH = os.path.join(os.path.dirname(__file__), "data", "proxy.db") SUPPORTED_SERVICES = ("tavily", "firecrawl", "exa") TOKEN_SERVICES = SUPPORTED_SERVICES + ("mysearch",) TOKEN_PREFIX = { @@ -50,9 +50,15 @@ def normalize_token_service(service): return service +def get_db_path(): + configured = (os.environ.get("MYSEARCH_PROXY_DB_PATH") or "").strip() + return configured or DEFAULT_DB_PATH + + def get_conn(): - os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) - conn = sqlite3.connect(DB_PATH) + db_path = get_db_path() + os.makedirs(os.path.dirname(db_path), exist_ok=True) + conn = sqlite3.connect(db_path) conn.row_factory = sqlite3.Row conn.execute("PRAGMA journal_mode=WAL") return conn diff --git a/proxy/docker-compose.yml b/proxy/docker-compose.yml index 2e0daa9..6242f66 100644 --- a/proxy/docker-compose.yml +++ b/proxy/docker-compose.yml @@ -5,6 +5,7 @@ services: - "9874:9874" environment: - ADMIN_PASSWORD=change-me + - MYSEARCH_PROXY_DB_PATH=/data/proxy.db volumes: - - ./data:/app/data + - ./data:/data restart: unless-stopped diff --git a/tests/test_proxy_database_path.py b/tests/test_proxy_database_path.py new file mode 100644 index 0000000..d5c9fb3 --- /dev/null +++ b/tests/test_proxy_database_path.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import importlib.util +import os +import sqlite3 +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + + +REPO_ROOT = Path(__file__).resolve().parents[1] +PROXY_ROOT = REPO_ROOT / "proxy" + + +def _load_module(module_name: str, path: Path): + spec = importlib.util.spec_from_file_location(module_name, path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + return module + + +class ProxyDatabasePathTests(unittest.TestCase): + def test_get_conn_honors_env_db_path(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + db_path = Path(tmpdir) / "custom-data" / "proxy.db" + with patch.dict(os.environ, {"MYSEARCH_PROXY_DB_PATH": str(db_path)}): + module = _load_module( + "test_proxy_database_path_module", + PROXY_ROOT / "database.py", + ) + module.init_db() + self.assertEqual(Path(module.get_db_path()), db_path) + self.assertTrue(db_path.exists()) + + conn = sqlite3.connect(db_path) + try: + rows = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name" + ).fetchall() + finally: + conn.close() + + self.assertIn(("api_keys",), rows) + self.assertIn(("tokens",), rows) + self.assertIn(("settings",), rows) + + +if __name__ == "__main__": + unittest.main() From 422c9373ef4a4a1dd83df6351c867d0561453ac7 Mon Sep 17 00:00:00 2001 From: zhaishujie <3394180966@qq.com> Date: Sun, 22 Mar 2026 14:09:42 +0800 Subject: [PATCH 16/20] enhance search evidence and restricted-domain routing --- llmdoc/guides/common-workflows.md | 7 +- llmdoc/reference/runtime-entrypoints.md | 2 +- mysearch/clients.py | 343 +++++++++++++++++++++- openclaw/runtime/mysearch/clients.py | 359 +++++++++++++++++++++++- tests/test_clients.py | 73 ++++- tests/test_comprehensive.py | 17 ++ 6 files changed, 781 insertions(+), 20 deletions(-) diff --git a/llmdoc/guides/common-workflows.md b/llmdoc/guides/common-workflows.md index fc9505f..7f90e6e 100644 --- a/llmdoc/guides/common-workflows.md +++ b/llmdoc/guides/common-workflows.md @@ -29,9 +29,10 @@ ## 4. 排查搜索行为 1. 先跑 `mysearch_health`,确认 provider 是否可用,而不是只看 key 有没有填。来源:mysearch/server.py:156, README.md:105 -2. 再看 `route` 与 `route_debug`,判断是显式 provider、生效的 intent/strategy,还是 blended/hybrid 路由。当前 docs/resource 默认不再自动 blended;如果你看见 `route_debug.domain_filter_mode=site_query_retry`,说明 Tavily 的官方域约束已经进入了 `site:` 形式重试。来源:mysearch/clients.py:359, mysearch/clients.py:651, mysearch/clients.py:1459 -3. 文档类查询要分清“页面发现”和“正文抓取”是两个阶段;正文异常先查 Firecrawl,再看 Tavily/Exa fallback。纯 `pricing/价格` 这类官方价格题当前不再默认按 docs/resource 路由,除非 query 里还显式带了 `docs`、`documentation`、`manual` 之类文档信号。来源:mysearch/clients.py:1023, mysearch/clients.py:677, mysearch/clients.py:3423 -4. 如果问题出在团队共享链路,而不是本地 runtime,就把排查重心切到 `proxy/` 的 key 池、token、usage sync 和 social gateway 配置。遇到“后台地址和 app key 都已配置,但 Social / X token 统计还是 0”时,先确认上游 `/v1/admin/tokens` 返回结构是否带 envelope;当前控制台已经兼容 `{tokens:{...}}`、`{data:{...}}`、`{items:[...]}`,所以这种现象通常说明进程还没重启到新代码,或者后台返回的并不是这组 admin 语义。来源:proxy/README.md:27, proxy/server.py:189, proxy/server.py:736, proxy/database.py:179 +2. 再看 `route` 与 `route_debug`,判断是显式 provider、生效的 intent/strategy,还是 blended/hybrid 路由。当前 docs/resource 默认不再自动 blended;如果你看见 `route_debug.domain_filter_mode=site_query_retry`,说明 Tavily 的官方域约束已经进入了 `site:` 形式重试,而且第二轮已经改成“放宽 provider 侧过滤、再在客户端做域名筛选”,不再把同一层 `include_domains` 直接重放给上游。`route_debug` 里现在还会带 `requested_max_results` 和 `candidate_max_results`,表示 `strategy` 是否扩大了候选池预算。来源:mysearch/clients.py:359, mysearch/clients.py:651, mysearch/clients.py:1013, mysearch/clients.py:1484 +3. 文档类查询要分清“页面发现”和“正文抓取”是两个阶段;正文异常先查 Firecrawl,再看 Tavily/Exa fallback。纯 `pricing/价格` 这类官方价格题当前不再默认按 docs/resource 路由,除非 query 里还显式带了 `docs`、`documentation`、`manual` 之类文档信号。对于 `linux.do`、`zhihu.com`、`medium.com` 这类受限 / 社区域名,如果又叠加 docs/resource 语义,运行时现在会优先把页面发现切到 Firecrawl。来源:mysearch/clients.py:1023, mysearch/clients.py:1101, mysearch/clients.py:677, mysearch/clients.py:3489 +4. 如果结果本身看起来“搜到了,但不够可信”,直接看 `evidence`。当前搜索结果已经统一补了 `source_diversity`、`source_domains`、`official_source_count`、`confidence` 和 `conflicts`;docs/resource 下如果只有单 provider、单域名或官方/第三方混排,会在这里直接暴露风险,而不是只靠人工扫链接。来源:mysearch/clients.py:1040, mysearch/clients.py:1064, mysearch/clients.py:3089 +5. 如果问题出在团队共享链路,而不是本地 runtime,就把排查重心切到 `proxy/` 的 key 池、token、usage sync 和 social gateway 配置。遇到“后台地址和 app key 都已配置,但 Social / X token 统计还是 0”时,先确认上游 `/v1/admin/tokens` 返回结构是否带 envelope;当前控制台已经兼容 `{tokens:{...}}`、`{data:{...}}`、`{items:[...]}`,所以这种现象通常说明进程还没重启到新代码,或者后台返回的并不是这组 admin 语义。来源:proxy/README.md:27, proxy/server.py:189, proxy/server.py:736, proxy/database.py:179 ## 5. 什么时候改哪里 diff --git a/llmdoc/reference/runtime-entrypoints.md b/llmdoc/reference/runtime-entrypoints.md index 8b695f1..3bea4df 100644 --- a/llmdoc/reference/runtime-entrypoints.md +++ b/llmdoc/reference/runtime-entrypoints.md @@ -16,7 +16,7 @@ | `Dockerfile.stack` | 单容器一体化镜像入口 | 在一个镜像里同时安装 `proxy` 与 `mysearch` 依赖,复制两个目录,并通过 `docker/combined-entrypoint.sh` 同时拉起 `proxy` 与远程 MCP;默认继续把 `MYSEARCH_PROXY_BASE_URL` 指向容器内 `127.0.0.1:9874`,同时把 Proxy SQLite 固定到 `/data/proxy.db`,避免 stack 与独立 `proxy` 镜像因为内部相对目录不同而各自写到不同位置。来源:Dockerfile.stack:1 | | `docker/combined-entrypoint.sh` | 单容器双进程启动脚本 | 在同一个容器里先起本地 `proxy`,再通过 bootstrap 脚本拿到 `MYSEARCH_PROXY_API_KEY`,最后启动 `mysearch`;默认把 `proxy` 绑定到 `0.0.0.0:9874` 方便外部访问控制台,同时 `mysearch` 继续通过容器内 `127.0.0.1:9874` 回连 Proxy。由于 `proxy/server.py` 和 `proxy/key_pool.py` 仍然保留顶层 `import database` / `from key_pool import pool` 这类导入方式,stack 启动脚本还会把 `/app/proxy` 追加到 `PYTHONPATH`,确保单容器镜像里的 `uvicorn proxy.server:app` 能正常导入。任一子进程退出时,脚本会一起清理并让容器失败。来源:docker/combined-entrypoint.sh:1 | | `mysearch/config.py` | Runtime 配置装配层 | 先读宿主 `~/.codex/config.toml`,再读 `.env` 兜底,并组装 Tavily / Firecrawl / Exa / xAI 的 `ProviderConfig`;Tavily 现在显式区分 `official` 与 `gateway`。运行时还兼容 Python 3.9:在解释器低于 3.10 时,dataclass 装配层会自动去掉 `slots`,避免配置模块导入即失败。来源:mysearch/config.py:17, mysearch/config.py:85, mysearch/config.py:98, mysearch/config.py:281, mysearch/config.py:320 | -| `mysearch/clients.py` | 搜索编排与 provider 适配层 | 统一处理 intent、strategy、routing、cache、blended search、extract fallback、research workflow。当前 docs/resource 查询默认不再因为 `balanced|verify|deep` 自动做 Tavily + Firecrawl blended;带 `include_domains` 的 Tavily 查询如果首轮 0 结果,会先做 `site:` 形式的 Tavily 重试,再按需回退 Firecrawl,避免官方域约束直接空返回。纯 `pricing/价格` 查询也不再被强判成 docs/resource。来源:mysearch/clients.py:277, mysearch/clients.py:385, mysearch/clients.py:677, mysearch/clients.py:802, mysearch/clients.py:964, mysearch/clients.py:1223, mysearch/clients.py:1354, mysearch/clients.py:3423 | +| `mysearch/clients.py` | 搜索编排与 provider 适配层 | 统一处理 intent、strategy、routing、cache、blended search、extract fallback、research workflow。当前 docs/resource 查询默认不再因为 `balanced|verify|deep` 自动做 Tavily + Firecrawl blended;带 `include_domains` 的 Tavily 查询如果首轮 0 结果,会先做 `site:` 形式的 Tavily 重试,而且第二轮不再继续把 `include_domains` 原样透传给 provider,而是放宽 provider 侧约束后再在客户端按域名过滤,避免官方域约束被同一层过滤直接打空。对于 `linux.do`、`zhihu.com`、`medium.com` 这类受限 / 社区域名,如果查询又落在 docs/resource 语义,路由会优先把页面发现切到 Firecrawl。搜索结果层还额外补了基于 `strategy` 的候选预算、`source_diversity` / `official_source_count` / `confidence` / `conflicts` 这组 evidence 摘要,并把 docs/resource 的官方结果重排扩展到单 provider 路径,不再只有 blended 才做官方优先。纯 `pricing/价格` 查询也不再被强判成 docs/resource。来源:mysearch/clients.py:277, mysearch/clients.py:385, mysearch/clients.py:677, mysearch/clients.py:802, mysearch/clients.py:964, mysearch/clients.py:1006, mysearch/clients.py:1093, mysearch/clients.py:1468, mysearch/clients.py:2819, mysearch/clients.py:3051, mysearch/clients.py:3489 | | `proxy/server.py` | Proxy 与控制台入口 | 暴露 Tavily / Firecrawl / Exa / Social 代理端点、控制台页面与管理 API;除了默认 `/` 搜索控制台以外,现在还额外提供 `/mysearch` 作为独立的 MySearch 接入页。FastAPI 入口已经切到 lifespan:启动时初始化数据库,退出时统一关闭共享 `http_client`,不再依赖已废弃的 `startup` hook。导入期的 `asyncio.Lock()` 也改成惰性初始化,避免 Python 3.9 在测试或脚本导入阶段留下未关闭事件循环。Tavily 上游链路现在会先按当前配置发起 probe 和真实转发;如果上游模式下首次命中 `404`,还会自动回退到 `/api/tavily/*` 兼容路径,避免 Hikari 类网关在 `Base URL` 写成主机根时直接报 `Not Found`。当 Tavily 当前实际走上游时,控制台还会额外尝试读取上游公开的 `/api/summary`,把 Hikari 的基础摘要映射成 `upstream_summary`。Social / X 则继续分成两档:接通后台 admin 接口时返回完整 token 统计;只有手动上游 key / gateway token 时,只返回 `upstream_visibility` 这类基础接线可视化,不再把“没有后台 token 面板”和“没有接通上游”混成同一组 0 值。兼容后台的 `/v1/admin/tokens` 如果不是直接返回 `pool -> list`,而是包了一层 `{tokens:{...}}`、`{data:{...}}`、`{items:[...]}` 之类 envelope,当前解析逻辑也会先解包再做统计,避免后台已接通但控制台仍显示一排 0。现在还额外提供了受限的 `/api/internal/mysearch/token` bootstrap 接口:仅在配置 `MYSEARCH_PROXY_BOOTSTRAP_TOKEN` 时启用,用于给 `mysearch` 容器自动创建或复用专用的 `mysp-` token,不让运行时直接读 SQLite。来源:proxy/server.py:219, proxy/server.py:228, proxy/server.py:233, proxy/server.py:680, proxy/server.py:1421, proxy/server.py:2702 | | `proxy/.dockerignore` | Proxy 容器上下文收口 | Docker 本地构建 `proxy/` 镜像时,会排除 `data/`、`proxy.db`、`__pycache__`、`.env`、README 和 compose 文件,避免把本地 SQLite、缓存和非运行时资产打进镜像上下文。独立 `proxy` 镜像本身也会把 `MYSEARCH_PROXY_DB_PATH` 预置为 `/data/proxy.db`,与 stack 共享同一挂载口径。来源:proxy/.dockerignore:1, proxy/Dockerfile:1 | | `.github/workflows/docker-publish.yml` | Docker 自动构建与发布入口 | 参考 `code/program/todo-list/.github/workflows/docker-publish.yml` 的结构,但当前仓库已经扩成三镜像 matrix:先跑 Python 单测、`py_compile` 与 `node --check proxy/static/js/console.js`,再分别对 `proxy`、`mysearch` 与 `stack` 做 buildx 多架构构建;pull request 只验证构建,push 到 `main` 或 `v*` tag 时才登录 Docker Hub 并推送。默认镜像名分别是 `mysearch-proxy`、`mysearch-mcp` 与 `mysearch-stack`,也可以用 `DOCKERHUB_IMAGE_NAME_PROXY` / `DOCKERHUB_IMAGE_NAME_MYSEARCH` / `DOCKERHUB_IMAGE_NAME_STACK` 覆盖。来源:.github/workflows/docker-publish.yml:1 | diff --git a/mysearch/clients.py b/mysearch/clients.py index bddc1ee..68623d7 100644 --- a/mysearch/clients.py +++ b/mysearch/clients.py @@ -383,6 +383,8 @@ def _annotate_search_debug( include_content: bool, include_answer: bool, cache_hit: bool, + requested_max_results: int | None = None, + candidate_max_results: int | None = None, ) -> dict[str, Any]: annotated = copy.deepcopy(result) annotated["route_debug"] = { @@ -395,6 +397,10 @@ def _annotate_search_debug( "include_answer": include_answer, "cache_hit": cache_hit, } + if requested_max_results is not None: + annotated["route_debug"]["requested_max_results"] = requested_max_results + if candidate_max_results is not None: + annotated["route_debug"]["candidate_max_results"] = candidate_max_results return annotated def search( @@ -458,9 +464,18 @@ def search( provider=provider, sources=normalized_sources, include_content=include_content, + include_domains=include_domains, allowed_x_handles=allowed_x_handles, excluded_x_handles=excluded_x_handles, ) + candidate_max_results = self._candidate_result_budget( + requested_max_results=max_results, + strategy=resolved_strategy, + mode=mode, + intent=resolved_intent, + include_domains=include_domains, + route_provider=decision.provider, + ) cacheable = self._should_cache_search( decision=decision, normalized_sources=normalized_sources, @@ -497,6 +512,8 @@ def search( include_content=include_content, include_answer=effective_include_answer, cache_hit=True, + requested_max_results=max_results, + candidate_max_results=candidate_max_results, ) if decision.provider == "hybrid": @@ -567,6 +584,13 @@ def search( "web": web_result, "social": social_result, } + hybrid_result = self._augment_evidence_summary( + hybrid_result, + query=query, + mode=mode, + intent=resolved_intent, + include_domains=include_domains, + ) hybrid_result = self._annotate_search_debug( hybrid_result, provider=provider, @@ -577,6 +601,8 @@ def search( include_content=include_content, include_answer=effective_include_answer, cache_hit=False, + requested_max_results=max_results, + candidate_max_results=candidate_max_results, ) return hybrid_result @@ -595,7 +621,7 @@ def search( intent=resolved_intent, strategy=resolved_strategy, decision=decision, - max_results=max_results, + max_results=candidate_max_results, include_content=include_content, include_answer=effective_include_answer, include_domains=include_domains, @@ -604,7 +630,7 @@ def search( elif decision.provider == "tavily": result = self._search_tavily( query=query, - max_results=max_results, + max_results=candidate_max_results, topic=decision.tavily_topic, include_answer=effective_include_answer, include_content=include_content, @@ -614,7 +640,7 @@ def search( elif decision.provider == "firecrawl": result = self._search_firecrawl( query=query, - max_results=max_results, + max_results=candidate_max_results, categories=decision.firecrawl_categories or [], include_content=include_content or mode in {"docs", "research", "github", "pdf"}, include_domains=include_domains, @@ -623,7 +649,7 @@ def search( elif decision.provider == "exa": result = self._search_exa( query=query, - max_results=max_results, + max_results=candidate_max_results, include_domains=include_domains, exclude_domains=exclude_domains, include_content=include_content, @@ -645,6 +671,27 @@ def search( else: raise MySearchError(f"Unsupported route decision: {decision.provider}") + if self._should_rerank_resource_results(mode=mode, intent=resolved_intent): + reranked_results = self._rerank_resource_results( + query=query, + mode=mode, + results=list(result.get("results") or []), + include_domains=include_domains, + ) + result["results"] = reranked_results + result["citations"] = self._align_citations_with_results( + results=reranked_results, + citations=list(result.get("citations") or []), + ) + result = self._trim_search_payload(result, max_results=max_results) + result = self._augment_evidence_summary( + result, + query=query, + mode=mode, + intent=resolved_intent, + include_domains=include_domains, + ) + route_reason = decision.reason if result.get("provider") == "hybrid" and resolved_strategy in {"balanced", "verify", "deep"}: route_reason = f"{route_reason};strategy={resolved_strategy} 已启用 Tavily + Firecrawl 交叉检索" @@ -690,6 +737,8 @@ def search( include_content=include_content, include_answer=effective_include_answer, cache_hit=False, + requested_max_results=max_results, + candidate_max_results=candidate_max_results, ) def extract_url( @@ -955,6 +1004,104 @@ def research( ], } + def _candidate_result_budget( + self, + *, + requested_max_results: int, + strategy: SearchStrategy, + mode: SearchMode, + intent: ResolvedSearchIntent, + include_domains: list[str] | None, + route_provider: str, + ) -> int: + if route_provider == "xai": + return requested_max_results + + budget = requested_max_results + strategy_floor = { + "fast": requested_max_results, + "balanced": min(max(requested_max_results * 2, requested_max_results + 2), 10), + "verify": min(max(requested_max_results * 3, requested_max_results + 4), 15), + "deep": min(max(requested_max_results * 4, requested_max_results + 6), 20), + } + budget = max(budget, strategy_floor.get(strategy, requested_max_results)) + + if include_domains or self._should_rerank_resource_results(mode=mode, intent=intent): + budget = max(budget, min(max(requested_max_results * 2, requested_max_results + 3), 12)) + + return max(requested_max_results, budget) + + def _trim_search_payload(self, result: dict[str, Any], *, max_results: int) -> dict[str, Any]: + trimmed = dict(result) + results = list(trimmed.get("results") or [])[:max_results] + trimmed["results"] = results + trimmed["citations"] = self._align_citations_with_results( + results=results, + citations=list(trimmed.get("citations") or []), + ) + return trimmed + + def _augment_evidence_summary( + self, + result: dict[str, Any], + *, + query: str, + mode: SearchMode, + intent: ResolvedSearchIntent, + include_domains: list[str] | None, + ) -> dict[str, Any]: + enriched = dict(result) + evidence = dict(enriched.get("evidence") or {}) + results = list(enriched.get("results") or []) + citations = list(enriched.get("citations") or []) + providers_consulted = [ + item + for item in ( + evidence.get("providers_consulted") + or [enriched.get("provider", "")] + ) + if item + ] + evidence.setdefault("providers_consulted", providers_consulted) + evidence.setdefault( + "verification", + "cross-provider" if len(set(providers_consulted)) > 1 else "single-provider", + ) + evidence.setdefault("citation_count", len(citations)) + + source_domains = self._collect_source_domains(results=results, citations=citations) + official_source_count = self._count_official_resource_results( + query=query, + mode=mode, + intent=intent, + results=results, + include_domains=include_domains, + ) + conflicts = self._detect_evidence_conflicts( + mode=mode, + intent=intent, + results=results, + include_domains=include_domains, + source_domains=source_domains, + official_source_count=official_source_count, + providers_consulted=providers_consulted, + ) + evidence["source_diversity"] = len(source_domains) + evidence["source_domains"] = source_domains[:5] + evidence["official_source_count"] = official_source_count + evidence["confidence"] = self._estimate_search_confidence( + mode=mode, + intent=intent, + result_count=len(results), + source_domain_count=len(source_domains), + official_source_count=official_source_count, + verification=str(evidence.get("verification") or "single-provider"), + conflicts=conflicts, + ) + evidence["conflicts"] = conflicts + enriched["evidence"] = evidence + return enriched + def _should_request_search_answer( self, *, @@ -988,6 +1135,7 @@ def _route_search( provider: ProviderName, sources: list[str] | None, include_content: bool, + include_domains: list[str] | None, allowed_x_handles: list[str] | None, excluded_x_handles: list[str] | None, ) -> RouteDecision: @@ -1038,6 +1186,25 @@ def _route_search( sources=["x"], ) + if ( + include_domains + and self._domains_prefer_firecrawl_discovery(include_domains) + and self._provider_can_serve(self.config.firecrawl) + and ( + mode in {"docs", "github", "pdf"} + or intent in {"resource", "tutorial"} + or self._looks_like_docs_query(query_lower) + ) + ): + return RouteDecision( + provider="firecrawl", + reason="检测到受限 / 社区域名,优先用 Firecrawl 做站内发现", + firecrawl_categories=self._firecrawl_categories( + "docs" if mode not in {"github", "pdf"} else mode, + intent, + ), + ) + if mode in {"docs", "github", "pdf"}: if include_content: if not self._provider_can_serve(self.config.firecrawl) and self._provider_can_serve( @@ -1160,6 +1327,30 @@ def _route_search( tavily_topic="general", ) + def _domains_prefer_firecrawl_discovery(self, include_domains: list[str] | None) -> bool: + if not include_domains: + return False + firecrawl_preferred_domains = { + "dev.to", + "juejin.cn", + "linux.do", + "medium.com", + "mp.weixin.qq.com", + "notion.site", + "notion.so", + "substack.com", + "weixin.qq.com", + "zhihu.com", + } + for domain in include_domains: + cleaned_domain = self._clean_hostname(domain) + if any( + self._domain_matches(cleaned_domain, preferred) + for preferred in firecrawl_preferred_domains + ): + return True + return False + def _resolve_intent( self, *, @@ -1481,11 +1672,22 @@ def _search_tavily_domain_retry( topic=topic, include_answer=False, include_content=include_content, + include_domains=None, + exclude_domains=exclude_domains, + ) + filtered_results = self._filter_results_by_domains( + domain_result.get("results", []), include_domains=[domain], exclude_domains=exclude_domains, ) - if not domain_result.get("results"): + if not filtered_results: continue + domain_result = dict(domain_result) + domain_result["results"] = filtered_results + domain_result["citations"] = self._align_citations_with_results( + results=filtered_results, + citations=list(domain_result.get("citations") or []), + ) per_domain_results.append(domain_result) retried_domains.append(domain) @@ -2648,7 +2850,7 @@ def _resource_result_rank( item: dict[str, Any], query_tokens: list[str], include_domains: list[str] | None, - ) -> tuple[int, int, int, int, int, int, int, int, int, int, int]: + ) -> tuple[int, int, int, int, int, int, int, int, int, int, int, int]: url = item.get("url", "") hostname = self._result_hostname(item) registered_domain = self._registered_domain(hostname) @@ -2684,6 +2886,7 @@ def _resource_result_rank( official_resource_match = int( self._is_probably_official_resource_result( mode=mode, + hostname=hostname, include_match=bool(include_match), host_brand_match=bool(host_brand_match), title_brand_match=bool(title_brand_match), @@ -2712,6 +2915,7 @@ def _is_probably_official_resource_result( self, *, mode: SearchMode, + hostname: str, include_match: bool, host_brand_match: bool, title_brand_match: bool, @@ -2726,7 +2930,12 @@ def _is_probably_official_resource_result( return False if not docs_shape_match: return False - return host_brand_match or title_brand_match + official_host_surface = any( + part in {"api", "developer", "developers", "docs", "help", "platform", "reference", "support"} + for part in hostname.split(".") + if part + ) + return host_brand_match or (title_brand_match and official_host_surface) def _align_citations_with_results( self, @@ -2978,6 +3187,126 @@ def _is_obvious_third_party_resource( } return registered_domain in third_party_domains + def _collect_source_domains( + self, + *, + results: list[dict[str, Any]], + citations: list[dict[str, Any]], + ) -> list[str]: + domains: list[str] = [] + seen: set[str] = set() + for item in [*results, *citations]: + if not isinstance(item, dict): + continue + hostname = self._result_hostname(item) + registered_domain = self._registered_domain(hostname) + if not registered_domain or registered_domain in seen: + continue + seen.add(registered_domain) + domains.append(registered_domain) + return domains + + def _count_official_resource_results( + self, + *, + query: str, + mode: SearchMode, + intent: ResolvedSearchIntent, + results: list[dict[str, Any]], + include_domains: list[str] | None, + ) -> int: + if not self._should_rerank_resource_results(mode=mode, intent=intent): + return 0 + query_tokens = self._query_brand_tokens(query) + official_count = 0 + for item in results: + hostname = self._result_hostname(item) + registered_domain = self._registered_domain(hostname) + title_text = (item.get("title") or "").lower() + include_match = bool( + include_domains + and any(self._domain_matches(hostname, domain) for domain in include_domains) + ) + host_brand_match = any( + token in hostname or token in registered_domain for token in query_tokens + ) + title_brand_match = any(token in title_text for token in query_tokens) + docs_shape_match = self._looks_like_resource_result( + url=item.get("url", ""), + hostname=hostname, + title_text=title_text, + mode=mode, + ) + non_third_party = not self._is_obvious_third_party_resource( + hostname=hostname, + registered_domain=registered_domain, + mode=mode, + ) + if self._is_probably_official_resource_result( + mode=mode, + hostname=hostname, + include_match=include_match, + host_brand_match=host_brand_match, + title_brand_match=title_brand_match, + docs_shape_match=docs_shape_match, + non_third_party=non_third_party, + ): + official_count += 1 + return official_count + + def _detect_evidence_conflicts( + self, + *, + mode: SearchMode, + intent: ResolvedSearchIntent, + results: list[dict[str, Any]], + include_domains: list[str] | None, + source_domains: list[str], + official_source_count: int, + providers_consulted: list[str], + ) -> list[str]: + conflicts: list[str] = [] + if len(source_domains) <= 1 and len(results) > 1: + conflicts.append("low-source-diversity") + if len(set(providers_consulted)) <= 1 and len(source_domains) <= 1 and results: + conflicts.append("single-provider-single-domain") + if self._should_rerank_resource_results(mode=mode, intent=intent): + if results and official_source_count <= 0: + conflicts.append("official-source-not-confirmed") + elif results and official_source_count < len(results): + conflicts.append("mixed-official-and-third-party") + if include_domains and not results: + conflicts.append("domain-filter-returned-empty") + return conflicts + + def _estimate_search_confidence( + self, + *, + mode: SearchMode, + intent: ResolvedSearchIntent, + result_count: int, + source_domain_count: int, + official_source_count: int, + verification: str, + conflicts: list[str], + ) -> str: + if result_count <= 0: + return "low" + if self._should_rerank_resource_results(mode=mode, intent=intent): + if official_source_count > 0 and "official-source-not-confirmed" not in conflicts: + if ( + verification == "cross-provider" + or (source_domain_count >= 2 and "mixed-official-and-third-party" not in conflicts) + ): + return "high" + return "medium" + return "medium" if source_domain_count >= 2 else "low" + if verification == "cross-provider" and source_domain_count >= 2: + return "high" + if source_domain_count >= 2: + return "medium" + return "low" if conflicts else "medium" + def _describe_provider( self, provider: ProviderConfig, diff --git a/openclaw/runtime/mysearch/clients.py b/openclaw/runtime/mysearch/clients.py index 3de40c0..1d3fd47 100644 --- a/openclaw/runtime/mysearch/clients.py +++ b/openclaw/runtime/mysearch/clients.py @@ -383,6 +383,8 @@ def _annotate_search_debug( include_content: bool, include_answer: bool, cache_hit: bool, + requested_max_results: int | None = None, + candidate_max_results: int | None = None, ) -> dict[str, Any]: annotated = copy.deepcopy(result) annotated["route_debug"] = { @@ -395,6 +397,10 @@ def _annotate_search_debug( "include_answer": include_answer, "cache_hit": cache_hit, } + if requested_max_results is not None: + annotated["route_debug"]["requested_max_results"] = requested_max_results + if candidate_max_results is not None: + annotated["route_debug"]["candidate_max_results"] = candidate_max_results return annotated def search( @@ -458,9 +464,18 @@ def search( provider=provider, sources=normalized_sources, include_content=include_content, + include_domains=include_domains, allowed_x_handles=allowed_x_handles, excluded_x_handles=excluded_x_handles, ) + candidate_max_results = self._candidate_result_budget( + requested_max_results=max_results, + strategy=resolved_strategy, + mode=mode, + intent=resolved_intent, + include_domains=include_domains, + route_provider=decision.provider, + ) cacheable = self._should_cache_search( decision=decision, normalized_sources=normalized_sources, @@ -497,6 +512,8 @@ def search( include_content=include_content, include_answer=effective_include_answer, cache_hit=True, + requested_max_results=max_results, + candidate_max_results=candidate_max_results, ) if decision.provider == "hybrid": @@ -567,6 +584,13 @@ def search( "web": web_result, "social": social_result, } + hybrid_result = self._augment_evidence_summary( + hybrid_result, + query=query, + mode=mode, + intent=resolved_intent, + include_domains=include_domains, + ) hybrid_result = self._annotate_search_debug( hybrid_result, provider=provider, @@ -577,6 +601,8 @@ def search( include_content=include_content, include_answer=effective_include_answer, cache_hit=False, + requested_max_results=max_results, + candidate_max_results=candidate_max_results, ) return hybrid_result @@ -592,7 +618,7 @@ def search( intent=resolved_intent, strategy=resolved_strategy, decision=decision, - max_results=max_results, + max_results=candidate_max_results, include_content=include_content, include_answer=effective_include_answer, include_domains=include_domains, @@ -601,7 +627,7 @@ def search( elif decision.provider == "tavily": result = self._search_tavily( query=query, - max_results=max_results, + max_results=candidate_max_results, topic=decision.tavily_topic, include_answer=effective_include_answer, include_content=include_content, @@ -611,7 +637,7 @@ def search( elif decision.provider == "firecrawl": result = self._search_firecrawl( query=query, - max_results=max_results, + max_results=candidate_max_results, categories=decision.firecrawl_categories or [], include_content=include_content or mode in {"docs", "research", "github", "pdf"}, include_domains=include_domains, @@ -620,7 +646,7 @@ def search( elif decision.provider == "exa": result = self._search_exa( query=query, - max_results=max_results, + max_results=candidate_max_results, include_domains=include_domains, exclude_domains=exclude_domains, include_content=include_content, @@ -642,6 +668,27 @@ def search( else: raise MySearchError(f"Unsupported route decision: {decision.provider}") + if self._should_rerank_resource_results(mode=mode, intent=resolved_intent): + reranked_results = self._rerank_resource_results( + query=query, + mode=mode, + results=list(result.get("results") or []), + include_domains=include_domains, + ) + result["results"] = reranked_results + result["citations"] = self._align_citations_with_results( + results=reranked_results, + citations=list(result.get("citations") or []), + ) + result = self._trim_search_payload(result, max_results=max_results) + result = self._augment_evidence_summary( + result, + query=query, + mode=mode, + intent=resolved_intent, + include_domains=include_domains, + ) + route_reason = decision.reason if result.get("provider") == "hybrid" and resolved_strategy in {"balanced", "verify", "deep"}: route_reason = f"{route_reason};strategy={resolved_strategy} 已启用 Tavily + Firecrawl 交叉检索" @@ -687,6 +734,8 @@ def search( include_content=include_content, include_answer=effective_include_answer, cache_hit=False, + requested_max_results=max_results, + candidate_max_results=candidate_max_results, ) def extract_url( @@ -952,6 +1001,104 @@ def research( ], } + def _candidate_result_budget( + self, + *, + requested_max_results: int, + strategy: SearchStrategy, + mode: SearchMode, + intent: ResolvedSearchIntent, + include_domains: list[str] | None, + route_provider: str, + ) -> int: + if route_provider == "xai": + return requested_max_results + + budget = requested_max_results + strategy_floor = { + "fast": requested_max_results, + "balanced": min(max(requested_max_results * 2, requested_max_results + 2), 10), + "verify": min(max(requested_max_results * 3, requested_max_results + 4), 15), + "deep": min(max(requested_max_results * 4, requested_max_results + 6), 20), + } + budget = max(budget, strategy_floor.get(strategy, requested_max_results)) + + if include_domains or self._should_rerank_resource_results(mode=mode, intent=intent): + budget = max(budget, min(max(requested_max_results * 2, requested_max_results + 3), 12)) + + return max(requested_max_results, budget) + + def _trim_search_payload(self, result: dict[str, Any], *, max_results: int) -> dict[str, Any]: + trimmed = dict(result) + results = list(trimmed.get("results") or [])[:max_results] + trimmed["results"] = results + trimmed["citations"] = self._align_citations_with_results( + results=results, + citations=list(trimmed.get("citations") or []), + ) + return trimmed + + def _augment_evidence_summary( + self, + result: dict[str, Any], + *, + query: str, + mode: SearchMode, + intent: ResolvedSearchIntent, + include_domains: list[str] | None, + ) -> dict[str, Any]: + enriched = dict(result) + evidence = dict(enriched.get("evidence") or {}) + results = list(enriched.get("results") or []) + citations = list(enriched.get("citations") or []) + providers_consulted = [ + item + for item in ( + evidence.get("providers_consulted") + or [enriched.get("provider", "")] + ) + if item + ] + evidence.setdefault("providers_consulted", providers_consulted) + evidence.setdefault( + "verification", + "cross-provider" if len(set(providers_consulted)) > 1 else "single-provider", + ) + evidence.setdefault("citation_count", len(citations)) + + source_domains = self._collect_source_domains(results=results, citations=citations) + official_source_count = self._count_official_resource_results( + query=query, + mode=mode, + intent=intent, + results=results, + include_domains=include_domains, + ) + conflicts = self._detect_evidence_conflicts( + mode=mode, + intent=intent, + results=results, + include_domains=include_domains, + source_domains=source_domains, + official_source_count=official_source_count, + providers_consulted=providers_consulted, + ) + evidence["source_diversity"] = len(source_domains) + evidence["source_domains"] = source_domains[:5] + evidence["official_source_count"] = official_source_count + evidence["confidence"] = self._estimate_search_confidence( + mode=mode, + intent=intent, + result_count=len(results), + source_domain_count=len(source_domains), + official_source_count=official_source_count, + verification=str(evidence.get("verification") or "single-provider"), + conflicts=conflicts, + ) + evidence["conflicts"] = conflicts + enriched["evidence"] = evidence + return enriched + def _should_request_search_answer( self, *, @@ -985,6 +1132,7 @@ def _route_search( provider: ProviderName, sources: list[str] | None, include_content: bool, + include_domains: list[str] | None, allowed_x_handles: list[str] | None, excluded_x_handles: list[str] | None, ) -> RouteDecision: @@ -1035,6 +1183,25 @@ def _route_search( sources=["x"], ) + if ( + include_domains + and self._domains_prefer_firecrawl_discovery(include_domains) + and self._provider_can_serve(self.config.firecrawl) + and ( + mode in {"docs", "github", "pdf"} + or intent in {"resource", "tutorial"} + or self._looks_like_docs_query(query_lower) + ) + ): + return RouteDecision( + provider="firecrawl", + reason="检测到受限 / 社区域名,优先用 Firecrawl 做站内发现", + firecrawl_categories=self._firecrawl_categories( + "docs" if mode not in {"github", "pdf"} else mode, + intent, + ), + ) + if mode in {"docs", "github", "pdf"}: if include_content: if not self._provider_can_serve(self.config.firecrawl) and self._provider_can_serve( @@ -1157,6 +1324,30 @@ def _route_search( tavily_topic="general", ) + def _domains_prefer_firecrawl_discovery(self, include_domains: list[str] | None) -> bool: + if not include_domains: + return False + firecrawl_preferred_domains = { + "dev.to", + "juejin.cn", + "linux.do", + "medium.com", + "mp.weixin.qq.com", + "notion.site", + "notion.so", + "substack.com", + "weixin.qq.com", + "zhihu.com", + } + for domain in include_domains: + cleaned_domain = self._clean_hostname(domain) + if any( + self._domain_matches(cleaned_domain, preferred) + for preferred in firecrawl_preferred_domains + ): + return True + return False + def _resolve_intent( self, *, @@ -2432,7 +2623,7 @@ def _resource_result_rank( item: dict[str, Any], query_tokens: list[str], include_domains: list[str] | None, - ) -> tuple[int, int, int, int, int, int, int, int, int, int, int]: + ) -> tuple[int, int, int, int, int, int, int, int, int, int, int, int]: url = item.get("url", "") hostname = self._result_hostname(item) registered_domain = self._registered_domain(hostname) @@ -2465,10 +2656,22 @@ def _resource_result_rank( mode=mode, ) ) + official_resource_match = int( + self._is_probably_official_resource_result( + mode=mode, + hostname=hostname, + include_match=bool(include_match), + host_brand_match=bool(host_brand_match), + title_brand_match=bool(title_brand_match), + docs_shape_match=bool(docs_shape_match), + non_third_party=bool(non_third_party), + ) + ) matched_provider_count = len(item.get("matched_providers") or []) content_score, snippet_score, title_score = self._result_quality_score(item) return ( include_match, + official_resource_match, github_bonus, pdf_bonus, host_brand_match, @@ -2481,6 +2684,32 @@ def _resource_result_rank( title_score, ) + def _is_probably_official_resource_result( + self, + *, + mode: SearchMode, + hostname: str, + include_match: bool, + host_brand_match: bool, + title_brand_match: bool, + docs_shape_match: bool, + non_third_party: bool, + ) -> bool: + if include_match: + return True + if mode in {"github", "pdf"}: + return True + if not non_third_party: + return False + if not docs_shape_match: + return False + official_host_surface = any( + part in {"api", "developer", "developers", "docs", "help", "platform", "reference", "support"} + for part in hostname.split(".") + if part + ) + return host_brand_match or (title_brand_match and official_host_surface) + def _align_citations_with_results( self, *, @@ -2731,6 +2960,126 @@ def _is_obvious_third_party_resource( } return registered_domain in third_party_domains + def _collect_source_domains( + self, + *, + results: list[dict[str, Any]], + citations: list[dict[str, Any]], + ) -> list[str]: + domains: list[str] = [] + seen: set[str] = set() + for item in [*results, *citations]: + if not isinstance(item, dict): + continue + hostname = self._result_hostname(item) + registered_domain = self._registered_domain(hostname) + if not registered_domain or registered_domain in seen: + continue + seen.add(registered_domain) + domains.append(registered_domain) + return domains + + def _count_official_resource_results( + self, + *, + query: str, + mode: SearchMode, + intent: ResolvedSearchIntent, + results: list[dict[str, Any]], + include_domains: list[str] | None, + ) -> int: + if not self._should_rerank_resource_results(mode=mode, intent=intent): + return 0 + query_tokens = self._query_brand_tokens(query) + official_count = 0 + for item in results: + hostname = self._result_hostname(item) + registered_domain = self._registered_domain(hostname) + title_text = (item.get("title") or "").lower() + include_match = bool( + include_domains + and any(self._domain_matches(hostname, domain) for domain in include_domains) + ) + host_brand_match = any( + token in hostname or token in registered_domain for token in query_tokens + ) + title_brand_match = any(token in title_text for token in query_tokens) + docs_shape_match = self._looks_like_resource_result( + url=item.get("url", ""), + hostname=hostname, + title_text=title_text, + mode=mode, + ) + non_third_party = not self._is_obvious_third_party_resource( + hostname=hostname, + registered_domain=registered_domain, + mode=mode, + ) + if self._is_probably_official_resource_result( + mode=mode, + hostname=hostname, + include_match=include_match, + host_brand_match=host_brand_match, + title_brand_match=title_brand_match, + docs_shape_match=docs_shape_match, + non_third_party=non_third_party, + ): + official_count += 1 + return official_count + + def _detect_evidence_conflicts( + self, + *, + mode: SearchMode, + intent: ResolvedSearchIntent, + results: list[dict[str, Any]], + include_domains: list[str] | None, + source_domains: list[str], + official_source_count: int, + providers_consulted: list[str], + ) -> list[str]: + conflicts: list[str] = [] + if len(source_domains) <= 1 and len(results) > 1: + conflicts.append("low-source-diversity") + if len(set(providers_consulted)) <= 1 and len(source_domains) <= 1 and results: + conflicts.append("single-provider-single-domain") + if self._should_rerank_resource_results(mode=mode, intent=intent): + if results and official_source_count <= 0: + conflicts.append("official-source-not-confirmed") + elif results and official_source_count < len(results): + conflicts.append("mixed-official-and-third-party") + if include_domains and not results: + conflicts.append("domain-filter-returned-empty") + return conflicts + + def _estimate_search_confidence( + self, + *, + mode: SearchMode, + intent: ResolvedSearchIntent, + result_count: int, + source_domain_count: int, + official_source_count: int, + verification: str, + conflicts: list[str], + ) -> str: + if result_count <= 0: + return "low" + if self._should_rerank_resource_results(mode=mode, intent=intent): + if official_source_count > 0 and "official-source-not-confirmed" not in conflicts: + if ( + verification == "cross-provider" + or (source_domain_count >= 2 and "mixed-official-and-third-party" not in conflicts) + ): + return "high" + return "medium" + return "medium" if source_domain_count >= 2 else "low" + if verification == "cross-provider" and source_domain_count >= 2: + return "high" + if source_domain_count >= 2: + return "medium" + return "low" if conflicts else "medium" + def _describe_provider( self, provider: ProviderConfig, diff --git a/tests/test_clients.py b/tests/test_clients.py index dde18a1..01578f1 100644 --- a/tests/test_clients.py +++ b/tests/test_clients.py @@ -287,10 +287,10 @@ def test_firecrawl_domain_filtered_search_skips_tavily_auth_error_fallback(self) def test_tavily_domain_filtered_search_retries_with_site_query(self) -> None: client = MySearchClient() - calls: list[str] = [] + calls: list[dict[str, object]] = [] def fake_search_tavily_once(**kwargs): # type: ignore[no-untyped-def] - calls.append(kwargs["query"]) + calls.append(dict(kwargs)) if kwargs["query"] == "OpenAI Responses API docs": return { "provider": "tavily", @@ -313,12 +313,24 @@ def fake_search_tavily_once(**kwargs): # type: ignore[no-untyped-def] "url": "https://platform.openai.com/docs/api-reference/responses", "snippet": "Official OpenAI docs", "content": "", + }, + { + "provider": "tavily", + "source": "web", + "title": "Community recap", + "url": "https://example.com/openai-responses-guide", + "snippet": "Third-party article", + "content": "", } ], "citations": [ { "title": "Responses | OpenAI API Reference", "url": "https://platform.openai.com/docs/api-reference/responses", + }, + { + "title": "Community recap", + "url": "https://example.com/openai-responses-guide", } ], } @@ -335,14 +347,15 @@ def fake_search_tavily_once(**kwargs): # type: ignore[no-untyped-def] exclude_domains=None, ) - self.assertEqual(len(result["results"]), 1) self.assertEqual( result["results"][0]["url"], "https://platform.openai.com/docs/api-reference/responses", ) + self.assertEqual(len(result["results"]), 1) self.assertEqual(result["route_debug"]["domain_filter_mode"], "site_query_retry") self.assertEqual(result["route_debug"]["retried_include_domains"], ["openai.com"]) - self.assertEqual(calls[1], "site:openai.com OpenAI Responses API docs") + self.assertEqual(calls[1]["query"], "site:openai.com OpenAI Responses API docs") + self.assertIsNone(calls[1]["include_domains"]) def test_tavily_domain_filtered_search_falls_back_to_firecrawl(self) -> None: client = MySearchClient() @@ -636,12 +649,64 @@ def test_docs_route_skips_tavily_when_live_probe_reports_auth_error(self) -> Non provider="auto", sources=["web"], include_content=False, + include_domains=None, allowed_x_handles=None, excluded_x_handles=None, ) self.assertEqual(decision.provider, "firecrawl") + def test_search_reranks_direct_docs_results_to_official_first(self) -> None: + client = MySearchClient() + client._search_tavily = lambda **kwargs: { # type: ignore[method-assign] + "provider": "tavily", + "transport": "env", + "query": kwargs["query"], + "answer": "", + "results": [ + { + "provider": "tavily", + "source": "web", + "title": "Playwright test.step Guide", + "url": "https://www.checklyhq.com/blog/playwright-test-step-guide/", + "snippet": "Third-party guide", + "content": "", + }, + { + "provider": "tavily", + "source": "web", + "title": "test.step | Playwright", + "url": "https://playwright.dev/docs/api/class-test", + "snippet": "Official Playwright docs", + "content": "", + }, + ], + "citations": [ + { + "title": "Playwright test.step Guide", + "url": "https://www.checklyhq.com/blog/playwright-test-step-guide/", + }, + { + "title": "test.step | Playwright", + "url": "https://playwright.dev/docs/api/class-test", + }, + ], + } + + result = client.search( + query="Playwright test.step docs", + mode="docs", + strategy="fast", + provider="tavily", + include_answer=False, + ) + + self.assertEqual(result["results"][0]["url"], "https://playwright.dev/docs/api/class-test") + self.assertEqual(result["citations"][0]["url"], "https://playwright.dev/docs/api/class-test") + self.assertEqual(result["evidence"]["official_source_count"], 1) + self.assertEqual(result["evidence"]["confidence"], "medium") + self.assertIn("mixed-official-and-third-party", result["evidence"]["conflicts"]) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_comprehensive.py b/tests/test_comprehensive.py index b8d4f0b..ea72d93 100644 --- a/tests/test_comprehensive.py +++ b/tests/test_comprehensive.py @@ -228,6 +228,7 @@ def _route(self, client: MySearchClient, **kwargs) -> RouteDecision: "provider": "auto", "sources": ["web"], "include_content": False, + "include_domains": None, "allowed_x_handles": None, "excluded_x_handles": None, } @@ -249,6 +250,22 @@ def test_explicit_exa_provider(self) -> None: decision = self._route(client, provider="exa") self.assertEqual(decision.provider, "exa") + def test_docs_query_with_restricted_domain_prefers_firecrawl(self) -> None: + client = _make_client(tavily_keys=["tv"], firecrawl_keys=["fc"]) + client._probe_provider_status = lambda provider, key_count: { # type: ignore[method-assign] + "status": "ok", + "error": "", + "checked_at": "2026-03-22T00:00:00+00:00", + } + decision = self._route( + client, + query="linux.do MCP 配置", + mode="docs", + intent="resource", + include_domains=["linux.do"], + ) + self.assertEqual(decision.provider, "firecrawl") + def test_explicit_xai_provider(self) -> None: client = _make_client() decision = self._route(client, provider="xai") From 648cea5116d57cfe2caa1a2b4b45659a02592fd8 Mon Sep 17 00:00:00 2001 From: zhaishujie <3394180966@qq.com> Date: Sun, 22 Mar 2026 14:59:27 +0800 Subject: [PATCH 17/20] tighten official docs policy and research evidence --- llmdoc/guides/common-workflows.md | 5 +- llmdoc/reference/runtime-entrypoints.md | 2 +- mysearch/clients.py | 425 ++++++++++++++++++++---- openclaw/runtime/mysearch/clients.py | 425 ++++++++++++++++++++---- tests/test_clients.py | 140 ++++++++ tests/test_comprehensive.py | 54 +++ 6 files changed, 908 insertions(+), 143 deletions(-) diff --git a/llmdoc/guides/common-workflows.md b/llmdoc/guides/common-workflows.md index 7f90e6e..7a1d2d1 100644 --- a/llmdoc/guides/common-workflows.md +++ b/llmdoc/guides/common-workflows.md @@ -31,8 +31,9 @@ 1. 先跑 `mysearch_health`,确认 provider 是否可用,而不是只看 key 有没有填。来源:mysearch/server.py:156, README.md:105 2. 再看 `route` 与 `route_debug`,判断是显式 provider、生效的 intent/strategy,还是 blended/hybrid 路由。当前 docs/resource 默认不再自动 blended;如果你看见 `route_debug.domain_filter_mode=site_query_retry`,说明 Tavily 的官方域约束已经进入了 `site:` 形式重试,而且第二轮已经改成“放宽 provider 侧过滤、再在客户端做域名筛选”,不再把同一层 `include_domains` 直接重放给上游。`route_debug` 里现在还会带 `requested_max_results` 和 `candidate_max_results`,表示 `strategy` 是否扩大了候选池预算。来源:mysearch/clients.py:359, mysearch/clients.py:651, mysearch/clients.py:1013, mysearch/clients.py:1484 3. 文档类查询要分清“页面发现”和“正文抓取”是两个阶段;正文异常先查 Firecrawl,再看 Tavily/Exa fallback。纯 `pricing/价格` 这类官方价格题当前不再默认按 docs/resource 路由,除非 query 里还显式带了 `docs`、`documentation`、`manual` 之类文档信号。对于 `linux.do`、`zhihu.com`、`medium.com` 这类受限 / 社区域名,如果又叠加 docs/resource 语义,运行时现在会优先把页面发现切到 Firecrawl。来源:mysearch/clients.py:1023, mysearch/clients.py:1101, mysearch/clients.py:677, mysearch/clients.py:3489 -4. 如果结果本身看起来“搜到了,但不够可信”,直接看 `evidence`。当前搜索结果已经统一补了 `source_diversity`、`source_domains`、`official_source_count`、`confidence` 和 `conflicts`;docs/resource 下如果只有单 provider、单域名或官方/第三方混排,会在这里直接暴露风险,而不是只靠人工扫链接。来源:mysearch/clients.py:1040, mysearch/clients.py:1064, mysearch/clients.py:3089 -5. 如果问题出在团队共享链路,而不是本地 runtime,就把排查重心切到 `proxy/` 的 key 池、token、usage sync 和 social gateway 配置。遇到“后台地址和 app key 都已配置,但 Social / X token 统计还是 0”时,先确认上游 `/v1/admin/tokens` 返回结构是否带 envelope;当前控制台已经兼容 `{tokens:{...}}`、`{data:{...}}`、`{items:[...]}`,所以这种现象通常说明进程还没重启到新代码,或者后台返回的并不是这组 admin 语义。来源:proxy/README.md:27, proxy/server.py:189, proxy/server.py:736, proxy/database.py:179 +4. 如果结果本身看起来“搜到了,但不够可信”,直接看 `evidence`。当前搜索结果已经统一补了 `source_diversity`、`source_domains`、`official_source_count`、`official_mode`、`confidence` 和 `conflicts`;其中 `official_mode=strict` 表示这次查询要么显式带了 `include_domains`,要么 query 明确要求“官方/官网/official”。在 strict 模式下,如果已经命中官方候选,结果会自动收束到官方项;如果仍然没有官方命中,则不会再把结果过滤成空,而是通过 `strict-official-unmet` 明确告诉你“这次官方约束没有满足”。docs/resource 下如果只有单 provider、单域名或官方/第三方混排,也会在这里直接暴露风险,而不是只靠人工扫链接。来源:mysearch/clients.py:1040, mysearch/clients.py:1064, mysearch/clients.py:3089 +5. `research` 现在要额外看 research 级 evidence,而不只是 `pages` 列表。返回里已经补了 `search_confidence`、`page_success_rate`、`page_error_count`、`official_mode` 和 research 级 `confidence/conflicts`:如果搜索本身很强但正文抓取不完整,会出现 `page-extraction-partial`;如果是严格官方查询却没命中官方候选,会直接暴露 `strict-official-unmet`。这能帮助你区分“发现阶段不稳”和“正文阶段不稳”。来源:mysearch/clients.py:869, mysearch/clients.py:1093 +6. 如果问题出在团队共享链路,而不是本地 runtime,就把排查重心切到 `proxy/` 的 key 池、token、usage sync 和 social gateway 配置。遇到“后台地址和 app key 都已配置,但 Social / X token 统计还是 0”时,先确认上游 `/v1/admin/tokens` 返回结构是否带 envelope;当前控制台已经兼容 `{tokens:{...}}`、`{data:{...}}`、`{items:[...]}`,所以这种现象通常说明进程还没重启到新代码,或者后台返回的并不是这组 admin 语义。来源:proxy/README.md:27, proxy/server.py:189, proxy/server.py:736, proxy/database.py:179 ## 5. 什么时候改哪里 diff --git a/llmdoc/reference/runtime-entrypoints.md b/llmdoc/reference/runtime-entrypoints.md index 3bea4df..8fb470a 100644 --- a/llmdoc/reference/runtime-entrypoints.md +++ b/llmdoc/reference/runtime-entrypoints.md @@ -16,7 +16,7 @@ | `Dockerfile.stack` | 单容器一体化镜像入口 | 在一个镜像里同时安装 `proxy` 与 `mysearch` 依赖,复制两个目录,并通过 `docker/combined-entrypoint.sh` 同时拉起 `proxy` 与远程 MCP;默认继续把 `MYSEARCH_PROXY_BASE_URL` 指向容器内 `127.0.0.1:9874`,同时把 Proxy SQLite 固定到 `/data/proxy.db`,避免 stack 与独立 `proxy` 镜像因为内部相对目录不同而各自写到不同位置。来源:Dockerfile.stack:1 | | `docker/combined-entrypoint.sh` | 单容器双进程启动脚本 | 在同一个容器里先起本地 `proxy`,再通过 bootstrap 脚本拿到 `MYSEARCH_PROXY_API_KEY`,最后启动 `mysearch`;默认把 `proxy` 绑定到 `0.0.0.0:9874` 方便外部访问控制台,同时 `mysearch` 继续通过容器内 `127.0.0.1:9874` 回连 Proxy。由于 `proxy/server.py` 和 `proxy/key_pool.py` 仍然保留顶层 `import database` / `from key_pool import pool` 这类导入方式,stack 启动脚本还会把 `/app/proxy` 追加到 `PYTHONPATH`,确保单容器镜像里的 `uvicorn proxy.server:app` 能正常导入。任一子进程退出时,脚本会一起清理并让容器失败。来源:docker/combined-entrypoint.sh:1 | | `mysearch/config.py` | Runtime 配置装配层 | 先读宿主 `~/.codex/config.toml`,再读 `.env` 兜底,并组装 Tavily / Firecrawl / Exa / xAI 的 `ProviderConfig`;Tavily 现在显式区分 `official` 与 `gateway`。运行时还兼容 Python 3.9:在解释器低于 3.10 时,dataclass 装配层会自动去掉 `slots`,避免配置模块导入即失败。来源:mysearch/config.py:17, mysearch/config.py:85, mysearch/config.py:98, mysearch/config.py:281, mysearch/config.py:320 | -| `mysearch/clients.py` | 搜索编排与 provider 适配层 | 统一处理 intent、strategy、routing、cache、blended search、extract fallback、research workflow。当前 docs/resource 查询默认不再因为 `balanced|verify|deep` 自动做 Tavily + Firecrawl blended;带 `include_domains` 的 Tavily 查询如果首轮 0 结果,会先做 `site:` 形式的 Tavily 重试,而且第二轮不再继续把 `include_domains` 原样透传给 provider,而是放宽 provider 侧约束后再在客户端按域名过滤,避免官方域约束被同一层过滤直接打空。对于 `linux.do`、`zhihu.com`、`medium.com` 这类受限 / 社区域名,如果查询又落在 docs/resource 语义,路由会优先把页面发现切到 Firecrawl。搜索结果层还额外补了基于 `strategy` 的候选预算、`source_diversity` / `official_source_count` / `confidence` / `conflicts` 这组 evidence 摘要,并把 docs/resource 的官方结果重排扩展到单 provider 路径,不再只有 blended 才做官方优先。纯 `pricing/价格` 查询也不再被强判成 docs/resource。来源:mysearch/clients.py:277, mysearch/clients.py:385, mysearch/clients.py:677, mysearch/clients.py:802, mysearch/clients.py:964, mysearch/clients.py:1006, mysearch/clients.py:1093, mysearch/clients.py:1468, mysearch/clients.py:2819, mysearch/clients.py:3051, mysearch/clients.py:3489 | +| `mysearch/clients.py` | 搜索编排与 provider 适配层 | 统一处理 intent、strategy、routing、cache、blended search、extract fallback、research workflow。当前 docs/resource 查询默认不再因为 `balanced|verify|deep` 自动做 Tavily + Firecrawl blended;带 `include_domains` 的 Tavily 查询如果首轮 0 结果,会先做 `site:` 形式的 Tavily 重试,而且第二轮不再继续把 `include_domains` 原样透传给 provider,而是放宽 provider 侧约束后再在客户端按域名过滤,避免官方域约束被同一层过滤直接打空。对于 `linux.do`、`zhihu.com`、`medium.com` 这类受限 / 社区域名,如果查询又落在 docs/resource 语义,路由会优先把页面发现切到 Firecrawl。搜索结果层还额外补了基于 `strategy` 的候选预算、`source_diversity` / `official_source_count` / `confidence` / `conflicts` 这组 evidence 摘要,并把 docs/resource 的官方结果重排扩展到单 provider 路径,不再只有 blended 才做官方优先。进一步地,`include_domains` 或明确带 `official/官方/官网` 的查询现在会进入内部 `official_mode=strict`:如果已经命中官方候选,就把结果收束到官方项;如果没命中,则保留现有结果,但在 evidence 里显式打出 `strict-official-unmet`,避免再次把结果硬过滤成空。`research` 返回的 evidence 也不再只给 provider 数量,而会补 `search_confidence`、`page_success_rate`、`page_error_count`、`official_mode` 和 research 级 `confidence/conflicts`,让“小 research”结果能直接看出搜索置信度和正文覆盖率。纯 `pricing/价格` 查询也不再被强判成 docs/resource。来源:mysearch/clients.py:277, mysearch/clients.py:385, mysearch/clients.py:677, mysearch/clients.py:802, mysearch/clients.py:964, mysearch/clients.py:1006, mysearch/clients.py:1093, mysearch/clients.py:1468, mysearch/clients.py:2819, mysearch/clients.py:3051, mysearch/clients.py:3489 | | `proxy/server.py` | Proxy 与控制台入口 | 暴露 Tavily / Firecrawl / Exa / Social 代理端点、控制台页面与管理 API;除了默认 `/` 搜索控制台以外,现在还额外提供 `/mysearch` 作为独立的 MySearch 接入页。FastAPI 入口已经切到 lifespan:启动时初始化数据库,退出时统一关闭共享 `http_client`,不再依赖已废弃的 `startup` hook。导入期的 `asyncio.Lock()` 也改成惰性初始化,避免 Python 3.9 在测试或脚本导入阶段留下未关闭事件循环。Tavily 上游链路现在会先按当前配置发起 probe 和真实转发;如果上游模式下首次命中 `404`,还会自动回退到 `/api/tavily/*` 兼容路径,避免 Hikari 类网关在 `Base URL` 写成主机根时直接报 `Not Found`。当 Tavily 当前实际走上游时,控制台还会额外尝试读取上游公开的 `/api/summary`,把 Hikari 的基础摘要映射成 `upstream_summary`。Social / X 则继续分成两档:接通后台 admin 接口时返回完整 token 统计;只有手动上游 key / gateway token 时,只返回 `upstream_visibility` 这类基础接线可视化,不再把“没有后台 token 面板”和“没有接通上游”混成同一组 0 值。兼容后台的 `/v1/admin/tokens` 如果不是直接返回 `pool -> list`,而是包了一层 `{tokens:{...}}`、`{data:{...}}`、`{items:[...]}` 之类 envelope,当前解析逻辑也会先解包再做统计,避免后台已接通但控制台仍显示一排 0。现在还额外提供了受限的 `/api/internal/mysearch/token` bootstrap 接口:仅在配置 `MYSEARCH_PROXY_BOOTSTRAP_TOKEN` 时启用,用于给 `mysearch` 容器自动创建或复用专用的 `mysp-` token,不让运行时直接读 SQLite。来源:proxy/server.py:219, proxy/server.py:228, proxy/server.py:233, proxy/server.py:680, proxy/server.py:1421, proxy/server.py:2702 | | `proxy/.dockerignore` | Proxy 容器上下文收口 | Docker 本地构建 `proxy/` 镜像时,会排除 `data/`、`proxy.db`、`__pycache__`、`.env`、README 和 compose 文件,避免把本地 SQLite、缓存和非运行时资产打进镜像上下文。独立 `proxy` 镜像本身也会把 `MYSEARCH_PROXY_DB_PATH` 预置为 `/data/proxy.db`,与 stack 共享同一挂载口径。来源:proxy/.dockerignore:1, proxy/Dockerfile:1 | | `.github/workflows/docker-publish.yml` | Docker 自动构建与发布入口 | 参考 `code/program/todo-list/.github/workflows/docker-publish.yml` 的结构,但当前仓库已经扩成三镜像 matrix:先跑 Python 单测、`py_compile` 与 `node --check proxy/static/js/console.js`,再分别对 `proxy`、`mysearch` 与 `stack` 做 buildx 多架构构建;pull request 只验证构建,push 到 `main` 或 `v*` tag 时才登录 Docker Hub 并推送。默认镜像名分别是 `mysearch-proxy`、`mysearch-mcp` 与 `mysearch-stack`,也可以用 `DOCKERHUB_IMAGE_NAME_PROXY` / `DOCKERHUB_IMAGE_NAME_MYSEARCH` / `DOCKERHUB_IMAGE_NAME_STACK` 覆盖。来源:.github/workflows/docker-publish.yml:1 | diff --git a/mysearch/clients.py b/mysearch/clients.py index 68623d7..ff780a3 100644 --- a/mysearch/clients.py +++ b/mysearch/clients.py @@ -401,6 +401,13 @@ def _annotate_search_debug( annotated["route_debug"]["requested_max_results"] = requested_max_results if candidate_max_results is not None: annotated["route_debug"]["candidate_max_results"] = candidate_max_results + evidence = annotated.get("evidence") or {} + if evidence.get("official_mode"): + annotated["route_debug"]["official_mode"] = evidence.get("official_mode") + if "official_filter_applied" in evidence: + annotated["route_debug"]["official_filter_applied"] = bool( + evidence.get("official_filter_applied") + ) return annotated def search( @@ -683,6 +690,13 @@ def search( results=reranked_results, citations=list(result.get("citations") or []), ) + result = self._apply_official_resource_policy( + query=query, + mode=mode, + intent=resolved_intent, + result=result, + include_domains=include_domains, + ) result = self._trim_search_payload(result, max_results=max_results) result = self._augment_evidence_summary( result, @@ -978,6 +992,18 @@ def research( web_search.get("citations") or [], (social.get("citations") or []) if social else [], ) + evidence = self._augment_research_evidence( + query=query, + mode=mode, + intent=web_search.get("intent", intent if intent != "auto" else "factual"), + requested_page_count=len(urls), + pages=pages, + citations=citations, + web_search=web_search, + social=social, + social_error=social_error, + providers_consulted=providers_consulted, + ) return { "provider": "hybrid", @@ -989,15 +1015,7 @@ def research( "social_search": social, "social_error": social_error, "citations": citations, - "evidence": { - "providers_consulted": providers_consulted, - "web_result_count": len(candidate_results), - "page_count": len([page for page in pages if not page.get("error")]), - "citation_count": len(citations), - "verification": "cross-provider" - if web_provider == "hybrid" or len(providers_consulted) > 1 - else "single-provider", - }, + "evidence": evidence, "notes": [ "默认用 Tavily 做发现,Firecrawl 做正文抓取,X 搜索走 xAI Responses API", "如果某个 provider 没配 key,会保留错误并尽量返回其余部分", @@ -1054,6 +1072,12 @@ def _augment_evidence_summary( evidence = dict(enriched.get("evidence") or {}) results = list(enriched.get("results") or []) citations = list(enriched.get("citations") or []) + official_mode = self._resolve_official_result_mode( + query=query, + mode=mode, + intent=intent, + include_domains=include_domains, + ) providers_consulted = [ item for item in ( @@ -1068,6 +1092,8 @@ def _augment_evidence_summary( "cross-provider" if len(set(providers_consulted)) > 1 else "single-provider", ) evidence.setdefault("citation_count", len(citations)) + evidence.setdefault("official_mode", official_mode) + evidence.setdefault("official_filter_applied", False) source_domains = self._collect_source_domains(results=results, citations=citations) official_source_count = self._count_official_resource_results( @@ -1085,10 +1111,12 @@ def _augment_evidence_summary( source_domains=source_domains, official_source_count=official_source_count, providers_consulted=providers_consulted, + official_mode=str(evidence.get("official_mode") or official_mode), ) evidence["source_diversity"] = len(source_domains) evidence["source_domains"] = source_domains[:5] evidence["official_source_count"] = official_source_count + evidence["third_party_source_count"] = max(len(results) - official_source_count, 0) evidence["confidence"] = self._estimate_search_confidence( mode=mode, intent=intent, @@ -1097,11 +1125,201 @@ def _augment_evidence_summary( official_source_count=official_source_count, verification=str(evidence.get("verification") or "single-provider"), conflicts=conflicts, + official_mode=str(evidence.get("official_mode") or official_mode), ) evidence["conflicts"] = conflicts enriched["evidence"] = evidence return enriched + def _resolve_official_result_mode( + self, + *, + query: str, + mode: SearchMode, + intent: ResolvedSearchIntent, + include_domains: list[str] | None, + ) -> str: + if include_domains: + return "strict" + if self._looks_like_official_query(query): + return "strict" + if self._should_rerank_resource_results(mode=mode, intent=intent): + return "standard" + return "off" + + def _looks_like_official_query(self, query: str) -> bool: + query_lower = query.lower() + if re.search(r"\bofficial\b", query_lower): + return True + official_markers = ( + "官网", + "官方", + "原文", + "定价官方", + "官方定价", + "官方价格", + "官方文档", + ) + return any(marker in query for marker in official_markers) + + def _apply_official_resource_policy( + self, + *, + query: str, + mode: SearchMode, + intent: ResolvedSearchIntent, + result: dict[str, Any], + include_domains: list[str] | None, + ) -> dict[str, Any]: + enriched = dict(result) + results = list(enriched.get("results") or []) + citations = list(enriched.get("citations") or []) + official_mode = self._resolve_official_result_mode( + query=query, + mode=mode, + intent=intent, + include_domains=include_domains, + ) + evidence = dict(enriched.get("evidence") or {}) + evidence.setdefault("official_mode", official_mode) + evidence.setdefault("official_filter_applied", False) + evidence.setdefault("official_candidate_count", 0) + if official_mode == "off" or not results: + enriched["evidence"] = evidence + return enriched + + official_candidates = self._collect_official_result_candidates( + query=query, + mode=mode, + results=results, + include_domains=include_domains, + strict_official=official_mode == "strict", + ) + evidence["official_candidate_count"] = len(official_candidates) + if official_mode == "strict" and official_candidates: + evidence["official_filter_applied"] = len(official_candidates) < len(results) + enriched["results"] = official_candidates + enriched["citations"] = self._align_citations_with_results( + results=official_candidates, + citations=citations, + ) + enriched["evidence"] = evidence + return enriched + + def _collect_official_result_candidates( + self, + *, + query: str, + mode: SearchMode, + results: list[dict[str, Any]], + include_domains: list[str] | None, + strict_official: bool, + ) -> list[dict[str, Any]]: + query_tokens = self._query_brand_tokens(query) + candidates: list[dict[str, Any]] = [] + for item in results: + if self._result_matches_official_policy( + item=item, + mode=mode, + query_tokens=query_tokens, + include_domains=include_domains, + strict_official=strict_official, + ): + candidates.append(dict(item)) + return candidates + + def _augment_research_evidence( + self, + *, + query: str, + mode: SearchMode, + intent: str, + requested_page_count: int, + pages: list[dict[str, Any]], + citations: list[dict[str, Any]], + web_search: dict[str, Any], + social: dict[str, Any] | None, + social_error: str, + providers_consulted: list[str], + ) -> dict[str, Any]: + successful_pages = [page for page in pages if not page.get("error")] + page_error_count = max(len(pages) - len(successful_pages), 0) + page_success_rate = ( + round(len(successful_pages) / requested_page_count, 2) + if requested_page_count > 0 + else 0.0 + ) + web_evidence = dict(web_search.get("evidence") or {}) + source_domains = self._collect_source_domains( + results=successful_pages, + citations=citations, + ) + conflicts = list(web_evidence.get("conflicts") or []) + if requested_page_count and not successful_pages: + conflicts.append("page-extraction-unavailable") + elif requested_page_count and page_error_count > 0: + conflicts.append("page-extraction-partial") + if social_error: + conflicts.append("social-search-unavailable") + + official_mode = str( + web_evidence.get("official_mode") + or self._resolve_official_result_mode( + query=query, + mode=mode, + intent=str(intent) if isinstance(intent, str) else "factual", + include_domains=None, + ) + ) + confidence = self._estimate_research_confidence( + search_confidence=str(web_evidence.get("confidence") or "low"), + page_success_count=len(successful_pages), + requested_page_count=requested_page_count, + social_present=social is not None, + social_error=bool(social_error), + conflicts=conflicts, + ) + return { + "providers_consulted": providers_consulted, + "web_result_count": len(web_search.get("results") or []), + "page_count": len(successful_pages), + "page_error_count": page_error_count, + "page_success_rate": page_success_rate, + "citation_count": len(citations), + "verification": "cross-provider" + if web_search.get("provider") == "hybrid" or len(providers_consulted) > 1 + else "single-provider", + "source_diversity": len(source_domains), + "source_domains": source_domains[:5], + "official_source_count": int(web_evidence.get("official_source_count") or 0), + "official_mode": official_mode, + "search_confidence": str(web_evidence.get("confidence") or "low"), + "confidence": confidence, + "conflicts": conflicts, + } + + def _estimate_research_confidence( + self, + *, + search_confidence: str, + page_success_count: int, + requested_page_count: int, + social_present: bool, + social_error: bool, + conflicts: list[str], + ) -> str: + if "strict-official-unmet" in conflicts or "page-extraction-unavailable" in conflicts: + return "low" + if search_confidence == "high" and page_success_count > 0 and not social_error: + return "high" + if search_confidence in {"high", "medium"} and ( + page_success_count > 0 or requested_page_count <= 0 or not social_present + ): + return "medium" + if search_confidence == "high": + return "medium" + return "low" if conflicts else "medium" + def _should_request_search_answer( self, *, @@ -2828,6 +3046,7 @@ def _rerank_resource_results( return results query_tokens = self._query_brand_tokens(query) + strict_official = bool(include_domains) or self._looks_like_official_query(query) ranked = sorted( enumerate(results), key=lambda pair: ( @@ -2836,6 +3055,7 @@ def _rerank_resource_results( item=pair[1], query_tokens=query_tokens, include_domains=include_domains, + strict_official=strict_official, ), -pair[0], ), @@ -2850,48 +3070,36 @@ def _resource_result_rank( item: dict[str, Any], query_tokens: list[str], include_domains: list[str] | None, - ) -> tuple[int, int, int, int, int, int, int, int, int, int, int, int]: - url = item.get("url", "") - hostname = self._result_hostname(item) - registered_domain = self._registered_domain(hostname) - title_text = (item.get("title") or "").lower() - include_match = int( - bool(include_domains) - and any(self._domain_matches(hostname, domain) for domain in include_domains or []) - ) - host_brand_match = int( - any(token in hostname or token in registered_domain for token in query_tokens) - ) - title_brand_match = int(any(token in title_text for token in query_tokens)) - docs_shape_match = int( - self._looks_like_resource_result( - url=url, - hostname=hostname, - title_text=title_text, - mode=mode, - ) + strict_official: bool, + ) -> tuple[int, int, int, int, int, int, int, int, int, int, int, int, int]: + flags = self._resource_result_flags( + mode=mode, + item=item, + query_tokens=query_tokens, + include_domains=include_domains, ) + include_match = int(flags["include_match"]) + host_brand_match = int(flags["host_brand_match"]) + registered_domain_label_match = int(flags["registered_domain_label_match"]) + title_brand_match = int(flags["title_brand_match"]) + docs_shape_match = int(flags["docs_shape_match"]) github_bonus = int( mode == "github" - and hostname in {"github.com", "raw.githubusercontent.com"} - ) - pdf_bonus = int(mode == "pdf" and self._looks_like_pdf_url(url)) - non_third_party = int( - not self._is_obvious_third_party_resource( - hostname=hostname, - registered_domain=registered_domain, - mode=mode, - ) + and flags["hostname"] in {"github.com", "raw.githubusercontent.com"} ) + pdf_bonus = int(mode == "pdf" and self._looks_like_pdf_url(item.get("url", ""))) + non_third_party = int(flags["non_third_party"]) official_resource_match = int( self._is_probably_official_resource_result( mode=mode, - hostname=hostname, + hostname=str(flags["hostname"]), include_match=bool(include_match), + registered_domain_label_match=bool(registered_domain_label_match), host_brand_match=bool(host_brand_match), title_brand_match=bool(title_brand_match), docs_shape_match=bool(docs_shape_match), non_third_party=bool(non_third_party), + official_query=strict_official, ) ) matched_provider_count = len(item.get("matched_providers") or []) @@ -2899,6 +3107,7 @@ def _resource_result_rank( return ( include_match, official_resource_match, + registered_domain_label_match, github_bonus, pdf_bonus, host_brand_match, @@ -2917,10 +3126,12 @@ def _is_probably_official_resource_result( mode: SearchMode, hostname: str, include_match: bool, + registered_domain_label_match: bool, host_brand_match: bool, title_brand_match: bool, docs_shape_match: bool, non_third_party: bool, + official_query: bool, ) -> bool: if include_match: return True @@ -2928,6 +3139,8 @@ def _is_probably_official_resource_result( return True if not non_third_party: return False + if official_query and registered_domain_label_match: + return True if not docs_shape_match: return False official_host_surface = any( @@ -2935,7 +3148,9 @@ def _is_probably_official_resource_result( for part in hostname.split(".") if part ) - return host_brand_match or (title_brand_match and official_host_surface) + return registered_domain_label_match or (host_brand_match and official_host_surface) or ( + title_brand_match and official_host_surface + ) def _align_citations_with_results( self, @@ -3050,6 +3265,87 @@ def _domain_matches(self, hostname: str, domain: str) -> bool: cleaned_host == cleaned_domain or cleaned_host.endswith(f".{cleaned_domain}") ) + def _registered_domain_label_matches(self, *, registered_domain: str, query_tokens: list[str]) -> bool: + labels = [item for item in self._clean_hostname(registered_domain).split(".") if item] + return any( + label == token or label.startswith(f"{token}-") or label.startswith(f"{token}_") + for token in query_tokens + for label in labels + ) + + def _resource_result_flags( + self, + *, + mode: SearchMode, + item: dict[str, Any], + query_tokens: list[str], + include_domains: list[str] | None, + ) -> dict[str, Any]: + url = item.get("url", "") + hostname = self._result_hostname(item) + registered_domain = self._registered_domain(hostname) + title_text = (item.get("title") or "").lower() + include_match = bool( + include_domains + and any(self._domain_matches(hostname, domain) for domain in include_domains or []) + ) + host_brand_match = any( + token in hostname or token in registered_domain for token in query_tokens + ) + registered_domain_label_match = self._registered_domain_label_matches( + registered_domain=registered_domain, + query_tokens=query_tokens, + ) + title_brand_match = any(token in title_text for token in query_tokens) + docs_shape_match = self._looks_like_resource_result( + url=url, + hostname=hostname, + title_text=title_text, + mode=mode, + ) + non_third_party = not self._is_obvious_third_party_resource( + hostname=hostname, + registered_domain=registered_domain, + mode=mode, + ) + return { + "hostname": hostname, + "registered_domain": registered_domain, + "include_match": include_match, + "host_brand_match": host_brand_match, + "registered_domain_label_match": registered_domain_label_match, + "title_brand_match": title_brand_match, + "docs_shape_match": docs_shape_match, + "non_third_party": non_third_party, + } + + def _result_matches_official_policy( + self, + *, + item: dict[str, Any], + mode: SearchMode, + query_tokens: list[str], + include_domains: list[str] | None, + strict_official: bool, + ) -> bool: + flags = self._resource_result_flags( + mode=mode, + item=item, + query_tokens=query_tokens, + include_domains=include_domains, + ) + return self._is_probably_official_resource_result( + mode=mode, + hostname=str(flags["hostname"]), + include_match=bool(flags["include_match"]), + registered_domain_label_match=bool(flags["registered_domain_label_match"]), + host_brand_match=bool(flags["host_brand_match"]), + title_brand_match=bool(flags["title_brand_match"]), + docs_shape_match=bool(flags["docs_shape_match"]), + non_third_party=bool(flags["non_third_party"]), + official_query=strict_official, + ) + def _query_brand_tokens(self, query: str) -> list[str]: stopwords = { "a", @@ -3215,41 +3511,24 @@ def _count_official_resource_results( results: list[dict[str, Any]], include_domains: list[str] | None, ) -> int: - if not self._should_rerank_resource_results(mode=mode, intent=intent): + official_mode = self._resolve_official_result_mode( + query=query, + mode=mode, + intent=intent, + include_domains=include_domains, + ) + if official_mode == "off" and not self._should_rerank_resource_results(mode=mode, intent=intent): return 0 query_tokens = self._query_brand_tokens(query) + strict_official = official_mode == "strict" official_count = 0 for item in results: - hostname = self._result_hostname(item) - registered_domain = self._registered_domain(hostname) - title_text = (item.get("title") or "").lower() - include_match = bool( - include_domains - and any(self._domain_matches(hostname, domain) for domain in include_domains) - ) - host_brand_match = any( - token in hostname or token in registered_domain for token in query_tokens - ) - title_brand_match = any(token in title_text for token in query_tokens) - docs_shape_match = self._looks_like_resource_result( - url=item.get("url", ""), - hostname=hostname, - title_text=title_text, - mode=mode, - ) - non_third_party = not self._is_obvious_third_party_resource( - hostname=hostname, - registered_domain=registered_domain, - mode=mode, - ) - if self._is_probably_official_resource_result( + if self._result_matches_official_policy( + item=item, mode=mode, - hostname=hostname, - include_match=include_match, - host_brand_match=host_brand_match, - title_brand_match=title_brand_match, - docs_shape_match=docs_shape_match, - non_third_party=non_third_party, + query_tokens=query_tokens, + include_domains=include_domains, + strict_official=strict_official, ): official_count += 1 return official_count @@ -3264,6 +3543,7 @@ def _detect_evidence_conflicts( source_domains: list[str], official_source_count: int, providers_consulted: list[str], + official_mode: str, ) -> list[str]: conflicts: list[str] = [] if len(source_domains) <= 1 and len(results) > 1: @@ -3277,6 +3557,8 @@ def _detect_evidence_conflicts( conflicts.append("mixed-official-and-third-party") if include_domains and not results: conflicts.append("domain-filter-returned-empty") + if official_mode == "strict" and results and official_source_count <= 0: + conflicts.append("strict-official-unmet") return conflicts def _estimate_search_confidence( @@ -3289,9 +3571,12 @@ def _estimate_search_confidence( official_source_count: int, verification: str, conflicts: list[str], + official_mode: str, ) -> str: if result_count <= 0: return "low" + if official_mode == "strict" and official_source_count <= 0: + return "low" if self._should_rerank_resource_results(mode=mode, intent=intent): if official_source_count > 0 and "official-source-not-confirmed" not in conflicts: if ( diff --git a/openclaw/runtime/mysearch/clients.py b/openclaw/runtime/mysearch/clients.py index 1d3fd47..35b17e5 100644 --- a/openclaw/runtime/mysearch/clients.py +++ b/openclaw/runtime/mysearch/clients.py @@ -401,6 +401,13 @@ def _annotate_search_debug( annotated["route_debug"]["requested_max_results"] = requested_max_results if candidate_max_results is not None: annotated["route_debug"]["candidate_max_results"] = candidate_max_results + evidence = annotated.get("evidence") or {} + if evidence.get("official_mode"): + annotated["route_debug"]["official_mode"] = evidence.get("official_mode") + if "official_filter_applied" in evidence: + annotated["route_debug"]["official_filter_applied"] = bool( + evidence.get("official_filter_applied") + ) return annotated def search( @@ -680,6 +687,13 @@ def search( results=reranked_results, citations=list(result.get("citations") or []), ) + result = self._apply_official_resource_policy( + query=query, + mode=mode, + intent=resolved_intent, + result=result, + include_domains=include_domains, + ) result = self._trim_search_payload(result, max_results=max_results) result = self._augment_evidence_summary( result, @@ -975,6 +989,18 @@ def research( web_search.get("citations") or [], (social.get("citations") or []) if social else [], ) + evidence = self._augment_research_evidence( + query=query, + mode=mode, + intent=web_search.get("intent", intent if intent != "auto" else "factual"), + requested_page_count=len(urls), + pages=pages, + citations=citations, + web_search=web_search, + social=social, + social_error=social_error, + providers_consulted=providers_consulted, + ) return { "provider": "hybrid", @@ -986,15 +1012,7 @@ def research( "social_search": social, "social_error": social_error, "citations": citations, - "evidence": { - "providers_consulted": providers_consulted, - "web_result_count": len(candidate_results), - "page_count": len([page for page in pages if not page.get("error")]), - "citation_count": len(citations), - "verification": "cross-provider" - if web_provider == "hybrid" or len(providers_consulted) > 1 - else "single-provider", - }, + "evidence": evidence, "notes": [ "默认用 Tavily 做发现,Firecrawl 做正文抓取,X 搜索走 xAI Responses API", "如果某个 provider 没配 key,会保留错误并尽量返回其余部分", @@ -1051,6 +1069,12 @@ def _augment_evidence_summary( evidence = dict(enriched.get("evidence") or {}) results = list(enriched.get("results") or []) citations = list(enriched.get("citations") or []) + official_mode = self._resolve_official_result_mode( + query=query, + mode=mode, + intent=intent, + include_domains=include_domains, + ) providers_consulted = [ item for item in ( @@ -1065,6 +1089,8 @@ def _augment_evidence_summary( "cross-provider" if len(set(providers_consulted)) > 1 else "single-provider", ) evidence.setdefault("citation_count", len(citations)) + evidence.setdefault("official_mode", official_mode) + evidence.setdefault("official_filter_applied", False) source_domains = self._collect_source_domains(results=results, citations=citations) official_source_count = self._count_official_resource_results( @@ -1082,10 +1108,12 @@ def _augment_evidence_summary( source_domains=source_domains, official_source_count=official_source_count, providers_consulted=providers_consulted, + official_mode=str(evidence.get("official_mode") or official_mode), ) evidence["source_diversity"] = len(source_domains) evidence["source_domains"] = source_domains[:5] evidence["official_source_count"] = official_source_count + evidence["third_party_source_count"] = max(len(results) - official_source_count, 0) evidence["confidence"] = self._estimate_search_confidence( mode=mode, intent=intent, @@ -1094,11 +1122,201 @@ def _augment_evidence_summary( official_source_count=official_source_count, verification=str(evidence.get("verification") or "single-provider"), conflicts=conflicts, + official_mode=str(evidence.get("official_mode") or official_mode), ) evidence["conflicts"] = conflicts enriched["evidence"] = evidence return enriched + def _resolve_official_result_mode( + self, + *, + query: str, + mode: SearchMode, + intent: ResolvedSearchIntent, + include_domains: list[str] | None, + ) -> str: + if include_domains: + return "strict" + if self._looks_like_official_query(query): + return "strict" + if self._should_rerank_resource_results(mode=mode, intent=intent): + return "standard" + return "off" + + def _looks_like_official_query(self, query: str) -> bool: + query_lower = query.lower() + if re.search(r"\bofficial\b", query_lower): + return True + official_markers = ( + "官网", + "官方", + "原文", + "定价官方", + "官方定价", + "官方价格", + "官方文档", + ) + return any(marker in query for marker in official_markers) + + def _apply_official_resource_policy( + self, + *, + query: str, + mode: SearchMode, + intent: ResolvedSearchIntent, + result: dict[str, Any], + include_domains: list[str] | None, + ) -> dict[str, Any]: + enriched = dict(result) + results = list(enriched.get("results") or []) + citations = list(enriched.get("citations") or []) + official_mode = self._resolve_official_result_mode( + query=query, + mode=mode, + intent=intent, + include_domains=include_domains, + ) + evidence = dict(enriched.get("evidence") or {}) + evidence.setdefault("official_mode", official_mode) + evidence.setdefault("official_filter_applied", False) + evidence.setdefault("official_candidate_count", 0) + if official_mode == "off" or not results: + enriched["evidence"] = evidence + return enriched + + official_candidates = self._collect_official_result_candidates( + query=query, + mode=mode, + results=results, + include_domains=include_domains, + strict_official=official_mode == "strict", + ) + evidence["official_candidate_count"] = len(official_candidates) + if official_mode == "strict" and official_candidates: + evidence["official_filter_applied"] = len(official_candidates) < len(results) + enriched["results"] = official_candidates + enriched["citations"] = self._align_citations_with_results( + results=official_candidates, + citations=citations, + ) + enriched["evidence"] = evidence + return enriched + + def _collect_official_result_candidates( + self, + *, + query: str, + mode: SearchMode, + results: list[dict[str, Any]], + include_domains: list[str] | None, + strict_official: bool, + ) -> list[dict[str, Any]]: + query_tokens = self._query_brand_tokens(query) + candidates: list[dict[str, Any]] = [] + for item in results: + if self._result_matches_official_policy( + item=item, + mode=mode, + query_tokens=query_tokens, + include_domains=include_domains, + strict_official=strict_official, + ): + candidates.append(dict(item)) + return candidates + + def _augment_research_evidence( + self, + *, + query: str, + mode: SearchMode, + intent: str, + requested_page_count: int, + pages: list[dict[str, Any]], + citations: list[dict[str, Any]], + web_search: dict[str, Any], + social: dict[str, Any] | None, + social_error: str, + providers_consulted: list[str], + ) -> dict[str, Any]: + successful_pages = [page for page in pages if not page.get("error")] + page_error_count = max(len(pages) - len(successful_pages), 0) + page_success_rate = ( + round(len(successful_pages) / requested_page_count, 2) + if requested_page_count > 0 + else 0.0 + ) + web_evidence = dict(web_search.get("evidence") or {}) + source_domains = self._collect_source_domains( + results=successful_pages, + citations=citations, + ) + conflicts = list(web_evidence.get("conflicts") or []) + if requested_page_count and not successful_pages: + conflicts.append("page-extraction-unavailable") + elif requested_page_count and page_error_count > 0: + conflicts.append("page-extraction-partial") + if social_error: + conflicts.append("social-search-unavailable") + + official_mode = str( + web_evidence.get("official_mode") + or self._resolve_official_result_mode( + query=query, + mode=mode, + intent=str(intent) if isinstance(intent, str) else "factual", + include_domains=None, + ) + ) + confidence = self._estimate_research_confidence( + search_confidence=str(web_evidence.get("confidence") or "low"), + page_success_count=len(successful_pages), + requested_page_count=requested_page_count, + social_present=social is not None, + social_error=bool(social_error), + conflicts=conflicts, + ) + return { + "providers_consulted": providers_consulted, + "web_result_count": len(web_search.get("results") or []), + "page_count": len(successful_pages), + "page_error_count": page_error_count, + "page_success_rate": page_success_rate, + "citation_count": len(citations), + "verification": "cross-provider" + if web_search.get("provider") == "hybrid" or len(providers_consulted) > 1 + else "single-provider", + "source_diversity": len(source_domains), + "source_domains": source_domains[:5], + "official_source_count": int(web_evidence.get("official_source_count") or 0), + "official_mode": official_mode, + "search_confidence": str(web_evidence.get("confidence") or "low"), + "confidence": confidence, + "conflicts": conflicts, + } + + def _estimate_research_confidence( + self, + *, + search_confidence: str, + page_success_count: int, + requested_page_count: int, + social_present: bool, + social_error: bool, + conflicts: list[str], + ) -> str: + if "strict-official-unmet" in conflicts or "page-extraction-unavailable" in conflicts: + return "low" + if search_confidence == "high" and page_success_count > 0 and not social_error: + return "high" + if search_confidence in {"high", "medium"} and ( + page_success_count > 0 or requested_page_count <= 0 or not social_present + ): + return "medium" + if search_confidence == "high": + return "medium" + return "low" if conflicts else "medium" + def _should_request_search_answer( self, *, @@ -2601,6 +2819,7 @@ def _rerank_resource_results( return results query_tokens = self._query_brand_tokens(query) + strict_official = bool(include_domains) or self._looks_like_official_query(query) ranked = sorted( enumerate(results), key=lambda pair: ( @@ -2609,6 +2828,7 @@ def _rerank_resource_results( item=pair[1], query_tokens=query_tokens, include_domains=include_domains, + strict_official=strict_official, ), -pair[0], ), @@ -2623,48 +2843,36 @@ def _resource_result_rank( item: dict[str, Any], query_tokens: list[str], include_domains: list[str] | None, - ) -> tuple[int, int, int, int, int, int, int, int, int, int, int, int]: - url = item.get("url", "") - hostname = self._result_hostname(item) - registered_domain = self._registered_domain(hostname) - title_text = (item.get("title") or "").lower() - include_match = int( - bool(include_domains) - and any(self._domain_matches(hostname, domain) for domain in include_domains or []) - ) - host_brand_match = int( - any(token in hostname or token in registered_domain for token in query_tokens) - ) - title_brand_match = int(any(token in title_text for token in query_tokens)) - docs_shape_match = int( - self._looks_like_resource_result( - url=url, - hostname=hostname, - title_text=title_text, - mode=mode, - ) + strict_official: bool, + ) -> tuple[int, int, int, int, int, int, int, int, int, int, int, int, int]: + flags = self._resource_result_flags( + mode=mode, + item=item, + query_tokens=query_tokens, + include_domains=include_domains, ) + include_match = int(flags["include_match"]) + host_brand_match = int(flags["host_brand_match"]) + registered_domain_label_match = int(flags["registered_domain_label_match"]) + title_brand_match = int(flags["title_brand_match"]) + docs_shape_match = int(flags["docs_shape_match"]) github_bonus = int( mode == "github" - and hostname in {"github.com", "raw.githubusercontent.com"} - ) - pdf_bonus = int(mode == "pdf" and self._looks_like_pdf_url(url)) - non_third_party = int( - not self._is_obvious_third_party_resource( - hostname=hostname, - registered_domain=registered_domain, - mode=mode, - ) + and flags["hostname"] in {"github.com", "raw.githubusercontent.com"} ) + pdf_bonus = int(mode == "pdf" and self._looks_like_pdf_url(item.get("url", ""))) + non_third_party = int(flags["non_third_party"]) official_resource_match = int( self._is_probably_official_resource_result( mode=mode, - hostname=hostname, + hostname=str(flags["hostname"]), include_match=bool(include_match), + registered_domain_label_match=bool(registered_domain_label_match), host_brand_match=bool(host_brand_match), title_brand_match=bool(title_brand_match), docs_shape_match=bool(docs_shape_match), non_third_party=bool(non_third_party), + official_query=strict_official, ) ) matched_provider_count = len(item.get("matched_providers") or []) @@ -2672,6 +2880,7 @@ def _resource_result_rank( return ( include_match, official_resource_match, + registered_domain_label_match, github_bonus, pdf_bonus, host_brand_match, @@ -2690,10 +2899,12 @@ def _is_probably_official_resource_result( mode: SearchMode, hostname: str, include_match: bool, + registered_domain_label_match: bool, host_brand_match: bool, title_brand_match: bool, docs_shape_match: bool, non_third_party: bool, + official_query: bool, ) -> bool: if include_match: return True @@ -2701,6 +2912,8 @@ def _is_probably_official_resource_result( return True if not non_third_party: return False + if official_query and registered_domain_label_match: + return True if not docs_shape_match: return False official_host_surface = any( @@ -2708,7 +2921,9 @@ def _is_probably_official_resource_result( for part in hostname.split(".") if part ) - return host_brand_match or (title_brand_match and official_host_surface) + return registered_domain_label_match or (host_brand_match and official_host_surface) or ( + title_brand_match and official_host_surface + ) def _align_citations_with_results( self, @@ -2823,6 +3038,87 @@ def _domain_matches(self, hostname: str, domain: str) -> bool: cleaned_host == cleaned_domain or cleaned_host.endswith(f".{cleaned_domain}") ) + def _registered_domain_label_matches(self, *, registered_domain: str, query_tokens: list[str]) -> bool: + labels = [item for item in self._clean_hostname(registered_domain).split(".") if item] + return any( + label == token or label.startswith(f"{token}-") or label.startswith(f"{token}_") + for token in query_tokens + for label in labels + ) + + def _resource_result_flags( + self, + *, + mode: SearchMode, + item: dict[str, Any], + query_tokens: list[str], + include_domains: list[str] | None, + ) -> dict[str, Any]: + url = item.get("url", "") + hostname = self._result_hostname(item) + registered_domain = self._registered_domain(hostname) + title_text = (item.get("title") or "").lower() + include_match = bool( + include_domains + and any(self._domain_matches(hostname, domain) for domain in include_domains or []) + ) + host_brand_match = any( + token in hostname or token in registered_domain for token in query_tokens + ) + registered_domain_label_match = self._registered_domain_label_matches( + registered_domain=registered_domain, + query_tokens=query_tokens, + ) + title_brand_match = any(token in title_text for token in query_tokens) + docs_shape_match = self._looks_like_resource_result( + url=url, + hostname=hostname, + title_text=title_text, + mode=mode, + ) + non_third_party = not self._is_obvious_third_party_resource( + hostname=hostname, + registered_domain=registered_domain, + mode=mode, + ) + return { + "hostname": hostname, + "registered_domain": registered_domain, + "include_match": include_match, + "host_brand_match": host_brand_match, + "registered_domain_label_match": registered_domain_label_match, + "title_brand_match": title_brand_match, + "docs_shape_match": docs_shape_match, + "non_third_party": non_third_party, + } + + def _result_matches_official_policy( + self, + *, + item: dict[str, Any], + mode: SearchMode, + query_tokens: list[str], + include_domains: list[str] | None, + strict_official: bool, + ) -> bool: + flags = self._resource_result_flags( + mode=mode, + item=item, + query_tokens=query_tokens, + include_domains=include_domains, + ) + return self._is_probably_official_resource_result( + mode=mode, + hostname=str(flags["hostname"]), + include_match=bool(flags["include_match"]), + registered_domain_label_match=bool(flags["registered_domain_label_match"]), + host_brand_match=bool(flags["host_brand_match"]), + title_brand_match=bool(flags["title_brand_match"]), + docs_shape_match=bool(flags["docs_shape_match"]), + non_third_party=bool(flags["non_third_party"]), + official_query=strict_official, + ) + def _query_brand_tokens(self, query: str) -> list[str]: stopwords = { "a", @@ -2988,41 +3284,24 @@ def _count_official_resource_results( results: list[dict[str, Any]], include_domains: list[str] | None, ) -> int: - if not self._should_rerank_resource_results(mode=mode, intent=intent): + official_mode = self._resolve_official_result_mode( + query=query, + mode=mode, + intent=intent, + include_domains=include_domains, + ) + if official_mode == "off" and not self._should_rerank_resource_results(mode=mode, intent=intent): return 0 query_tokens = self._query_brand_tokens(query) + strict_official = official_mode == "strict" official_count = 0 for item in results: - hostname = self._result_hostname(item) - registered_domain = self._registered_domain(hostname) - title_text = (item.get("title") or "").lower() - include_match = bool( - include_domains - and any(self._domain_matches(hostname, domain) for domain in include_domains) - ) - host_brand_match = any( - token in hostname or token in registered_domain for token in query_tokens - ) - title_brand_match = any(token in title_text for token in query_tokens) - docs_shape_match = self._looks_like_resource_result( - url=item.get("url", ""), - hostname=hostname, - title_text=title_text, - mode=mode, - ) - non_third_party = not self._is_obvious_third_party_resource( - hostname=hostname, - registered_domain=registered_domain, - mode=mode, - ) - if self._is_probably_official_resource_result( + if self._result_matches_official_policy( + item=item, mode=mode, - hostname=hostname, - include_match=include_match, - host_brand_match=host_brand_match, - title_brand_match=title_brand_match, - docs_shape_match=docs_shape_match, - non_third_party=non_third_party, + query_tokens=query_tokens, + include_domains=include_domains, + strict_official=strict_official, ): official_count += 1 return official_count @@ -3037,6 +3316,7 @@ def _detect_evidence_conflicts( source_domains: list[str], official_source_count: int, providers_consulted: list[str], + official_mode: str, ) -> list[str]: conflicts: list[str] = [] if len(source_domains) <= 1 and len(results) > 1: @@ -3050,6 +3330,8 @@ def _detect_evidence_conflicts( conflicts.append("mixed-official-and-third-party") if include_domains and not results: conflicts.append("domain-filter-returned-empty") + if official_mode == "strict" and results and official_source_count <= 0: + conflicts.append("strict-official-unmet") return conflicts def _estimate_search_confidence( @@ -3062,9 +3344,12 @@ def _estimate_search_confidence( official_source_count: int, verification: str, conflicts: list[str], + official_mode: str, ) -> str: if result_count <= 0: return "low" + if official_mode == "strict" and official_source_count <= 0: + return "low" if self._should_rerank_resource_results(mode=mode, intent=intent): if official_source_count > 0 and "official-source-not-confirmed" not in conflicts: if ( diff --git a/tests/test_clients.py b/tests/test_clients.py index 01578f1..d253755 100644 --- a/tests/test_clients.py +++ b/tests/test_clients.py @@ -707,6 +707,146 @@ def test_search_reranks_direct_docs_results_to_official_first(self) -> None: self.assertEqual(result["evidence"]["confidence"], "medium") self.assertIn("mixed-official-and-third-party", result["evidence"]["conflicts"]) + def test_search_strict_official_mode_filters_to_official_results(self) -> None: + client = MySearchClient() + client._search_tavily = lambda **kwargs: { # type: ignore[method-assign] + "provider": "tavily", + "transport": "env", + "query": kwargs["query"], + "answer": "", + "results": [ + { + "provider": "tavily", + "source": "web", + "title": "Playwright test.step Guide", + "url": "https://www.checklyhq.com/blog/playwright-test-step-guide/", + "snippet": "Third-party guide", + "content": "", + }, + { + "provider": "tavily", + "source": "web", + "title": "test.step | Playwright", + "url": "https://playwright.dev/docs/api/class-test", + "snippet": "Official Playwright docs", + "content": "", + }, + ], + "citations": [ + { + "title": "Playwright test.step Guide", + "url": "https://www.checklyhq.com/blog/playwright-test-step-guide/", + }, + { + "title": "test.step | Playwright", + "url": "https://playwright.dev/docs/api/class-test", + }, + ], + } + + result = client.search( + query="Playwright test.step official docs", + mode="docs", + strategy="fast", + provider="tavily", + include_domains=["playwright.dev"], + include_answer=False, + ) + + self.assertEqual(len(result["results"]), 1) + self.assertEqual(result["results"][0]["url"], "https://playwright.dev/docs/api/class-test") + self.assertEqual(result["evidence"]["official_mode"], "strict") + self.assertTrue(result["evidence"]["official_filter_applied"]) + self.assertEqual(result["evidence"]["official_source_count"], 1) + self.assertNotIn("mixed-official-and-third-party", result["evidence"]["conflicts"]) + + def test_search_strict_official_mode_keeps_results_but_flags_unmet(self) -> None: + client = MySearchClient() + client._search_tavily = lambda **kwargs: { # type: ignore[method-assign] + "provider": "tavily", + "transport": "env", + "query": kwargs["query"], + "answer": "", + "results": [ + { + "provider": "tavily", + "source": "web", + "title": "OpenAI API Pricing Guide", + "url": "https://apidog.com/blog/openai-api-pricing/", + "snippet": "Third-party pricing guide", + "content": "", + }, + ], + "citations": [ + { + "title": "OpenAI API Pricing Guide", + "url": "https://apidog.com/blog/openai-api-pricing/", + }, + ], + } + + result = client.search( + query="OpenAI pricing official", + mode="web", + strategy="fast", + provider="tavily", + include_answer=False, + ) + + self.assertEqual(len(result["results"]), 1) + self.assertEqual(result["evidence"]["official_mode"], "strict") + self.assertFalse(result["evidence"]["official_filter_applied"]) + self.assertIn("strict-official-unmet", result["evidence"]["conflicts"]) + self.assertEqual(result["evidence"]["confidence"], "low") + + def test_search_strict_official_mode_counts_official_hits_for_web_queries(self) -> None: + client = MySearchClient() + client._search_tavily = lambda **kwargs: { # type: ignore[method-assign] + "provider": "tavily", + "transport": "env", + "query": kwargs["query"], + "answer": "", + "results": [ + { + "provider": "tavily", + "source": "web", + "title": "API Pricing | OpenAI", + "url": "https://openai.com/api/pricing/", + "snippet": "Official pricing page", + "content": "", + }, + { + "provider": "tavily", + "source": "web", + "title": "OpenAI API Pricing Guide", + "url": "https://apidog.com/blog/openai-api-pricing/", + "snippet": "Third-party pricing guide", + "content": "", + }, + ], + "citations": [ + {"title": "API Pricing | OpenAI", "url": "https://openai.com/api/pricing/"}, + { + "title": "OpenAI API Pricing Guide", + "url": "https://apidog.com/blog/openai-api-pricing/", + }, + ], + } + + result = client.search( + query="OpenAI pricing official", + mode="web", + strategy="fast", + provider="tavily", + include_answer=False, + ) + + self.assertEqual(result["results"][0]["url"], "https://openai.com/api/pricing/") + self.assertEqual(result["evidence"]["official_mode"], "strict") + self.assertEqual(result["evidence"]["official_source_count"], 1) + self.assertTrue(result["evidence"]["official_filter_applied"]) + self.assertNotIn("strict-official-unmet", result["evidence"]["conflicts"]) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_comprehensive.py b/tests/test_comprehensive.py index ea72d93..a72b19e 100644 --- a/tests/test_comprehensive.py +++ b/tests/test_comprehensive.py @@ -1132,6 +1132,60 @@ def test_research_empty_query_raises(self) -> None: with self.assertRaises(MySearchError): client.research(query="") + def test_research_evidence_includes_search_confidence_and_page_coverage(self) -> None: + client = _make_client() + + client.search = lambda **kwargs: { # type: ignore[method-assign] + "provider": "tavily", + "intent": "resource", + "strategy": "balanced", + "results": [ + { + "title": "Responses | OpenAI API Reference", + "url": "https://platform.openai.com/docs/api-reference/responses", + "snippet": "Official docs", + "content": "", + } + ], + "citations": [ + { + "title": "Responses | OpenAI API Reference", + "url": "https://platform.openai.com/docs/api-reference/responses", + } + ], + "evidence": { + "providers_consulted": ["tavily"], + "verification": "single-provider", + "citation_count": 1, + "source_diversity": 1, + "source_domains": ["openai.com"], + "official_source_count": 1, + "official_mode": "strict", + "confidence": "high", + "conflicts": [], + }, + } + client.extract_url = lambda **kwargs: { # type: ignore[method-assign] + "url": kwargs["url"], + "provider": "firecrawl", + "content": "Background mode lets requests run asynchronously.", + "cache": {"extract": {"hit": False, "ttl_seconds": 300}}, + } + + result = client.research( + query="OpenAI Responses API official docs", + mode="docs", + include_social=False, + scrape_top_n=1, + ) + + self.assertEqual(result["evidence"]["official_mode"], "strict") + self.assertEqual(result["evidence"]["search_confidence"], "high") + self.assertEqual(result["evidence"]["page_count"], 1) + self.assertEqual(result["evidence"]["page_success_rate"], 1.0) + self.assertEqual(result["evidence"]["confidence"], "high") + self.assertEqual(result["evidence"]["source_domains"], ["openai.com"]) + # =========================================================================== # 11 Search source normalization From 67b42e55d5885671dae1af0f95dd9b3a89f4df23 Mon Sep 17 00:00:00 2001 From: zhaishujie <3394180966@qq.com> Date: Mon, 23 Mar 2026 14:02:14 +0800 Subject: [PATCH 18/20] refine x provider health probes and ignore llmdoc --- llmdoc/architecture/proxy-first.md | 56 ------ llmdoc/guides/common-workflows.md | 43 ----- llmdoc/reference/entrypoints-and-config.md | 158 ----------------- llmdoc/reference/runtime-entrypoints.md | 61 ------- mysearch/clients.py | 197 +++++++++++++++++---- mysearch/config.py | 11 +- mysearch/social_gateway.py | 27 ++- openclaw/runtime/mysearch/clients.py | 197 +++++++++++++++++---- openclaw/runtime/mysearch/config.py | 11 +- proxy/server.py | 40 ++++- tests/test_clients.py | 101 ++++++++++- tests/test_social_normalization.py | 77 +++++++- 12 files changed, 570 insertions(+), 409 deletions(-) delete mode 100644 llmdoc/architecture/proxy-first.md delete mode 100644 llmdoc/guides/common-workflows.md delete mode 100644 llmdoc/reference/entrypoints-and-config.md delete mode 100644 llmdoc/reference/runtime-entrypoints.md diff --git a/llmdoc/architecture/proxy-first.md b/llmdoc/architecture/proxy-first.md deleted file mode 100644 index 6b3be28..0000000 --- a/llmdoc/architecture/proxy-first.md +++ /dev/null @@ -1,56 +0,0 @@ -# proxy-first 架构 - -## 核心链路 - -- 推荐链路是“上游 provider -> MySearch Proxy -> `mysp-` token -> MySearch MCP / OpenClaw / 其他 Agent”。这样客户端只需要一组 `MYSEARCH_PROXY_*`,而不是分别管理 Tavily、Firecrawl、Exa、Social 的 secret。来源:README.md:66, README.md:79, proxy/README.md:106 -- `proxy/` 不是单纯 key 面板,而是控制台、token 发放、统计与兼容代理接口的中间层。来源:proxy/README.md:5, proxy/README.md:27 -- 但 `proxy-first` 现在不再等于“所有 Tavily 流量都必须绑定本仓库 Proxy”。Tavily 在 runtime 里也支持独立 `gateway` 分支,例如接 `tavily-hikari` 这类上游;只是在没有显式 `MYSEARCH_TAVILY_MODE` 时,`MYSEARCH_PROXY_*` 仍会保持兼容性的 gateway 默认值。来源:mysearch/config.py:198, mysearch/config.py:207, mysearch/config.py:281, mysearch/config.py:320 - -## 运行时分层 - -- 架构文档把系统拆成三层:Skill / Decision Layer、MCP / Orchestration Layer、Provider Layer。这个分层解释了为什么 `skill/`、`mysearch/`、provider 配置不应该揉成一个目录。来源:docs/mysearch-architecture.md:3 -- `mysearch/server.py` 负责把统一能力暴露成 4 个 MCP tool;真正的 provider 路由与组合逻辑下沉在 `MySearchClient`。来源:mysearch/server.py:34, mysearch/server.py:47 -- `proxy/server.py` 负责上游代理端点与控制台管理 API;`proxy/database.py` 负责 SQLite 存储 token、key、usage 和 settings。来源:proxy/server.py:47, proxy/server.py:275, proxy/database.py:11, proxy/database.py:61 - -## Provider 路由规则 - -- `search` 的统一入口在 `mysearch/clients.py:385`,会先解析 intent、strategy、sources,再调用 `_route_search` 决定 provider。来源:mysearch/clients.py:418, mysearch/clients.py:439 -- 显式指定 `provider` 时,不再做自动路由;`tavily`、`firecrawl`、`exa`、`xai` 都会被直接尊重。来源:mysearch/clients.py:979 -- 同时请求网页和 X 时,会走 `hybrid`,并把 web 与 social 两条结果拼接。来源:mysearch/clients.py:1004, mysearch/clients.py:487 -- `social` 模式或传入 X handle 过滤时,优先走 xAI。来源:mysearch/clients.py:1009, mysearch/clients.py:1016 -- `docs`、`github`、`pdf` 这类文档查询默认是“先发现、后正文”:有内容需求时优先 Firecrawl;没要求正文时优先 Tavily 做页面发现,再把正文留给 Firecrawl。Firecrawl 缺失时才回退 Exa。来源:mysearch/clients.py:1023, mysearch/clients.py:1037, mysearch/clients.py:1043 -- 一般 `include_content=true` 的正文型请求也会优先 Firecrawl;Firecrawl 不可用时回退 Exa。来源:mysearch/clients.py:1056 -- `news` / `status` 默认 Tavily;普通网页查询默认 Tavily,未配置 Tavily 时回退 Exa。来源:mysearch/clients.py:1070, mysearch/clients.py:1131 -- `resource` / docs-like 查询会优先把发现与正文分开处理;`research` 会先做发现,再按策略扩展验证。来源:mysearch/clients.py:1084, mysearch/clients.py:1117 - -## 执行策略 - -- `strategy=balanced|verify|deep` 时,web 路由可能触发 Tavily + Firecrawl 的并行 blended 检索,再合并结果和 citations。来源:mysearch/clients.py:1201, mysearch/clients.py:1221 -- `extract_url` 独立于 `search`,默认优先 Firecrawl scrape,质量不够或失败时再回退 Tavily extract。来源:mysearch/server.py:97, mysearch/clients.py:677 -- `research` 是一个小型编排流程:先跑 web discovery,可选并行 social,再抓取前几条正文并回填 evidence。来源:mysearch/server.py:112, mysearch/clients.py:802 - -## xAI 与 Social - -- 架构文档明确区分 official xAI 与 compatible social gateway,避免把模型网关误当成搜索后端。来源:docs/mysearch-architecture.md:24, docs/mysearch-architecture.md:47 -- `MySearchConfig` 用 `MYSEARCH_XAI_SEARCH_MODE` 区分 `official` 与 `compatible`;`_search_xai` 在 `compatible` 模式下会改走 `social_search` 路径。来源:mysearch/config.py:18, mysearch/clients.py:1758, mysearch/clients.py:1832 -- `proxy/` 侧还维护了 Social gateway 的 upstream/base URL、fallback model、admin API 对接与缓存。来源:proxy/server.py:43, proxy/server.py:189 - -## Tavily 官方与 Gateway - -- Tavily 现在也和 xAI 一样有显式模式切分,但命名更直接:`MYSEARCH_TAVILY_MODE=official|gateway`。来源:mysearch/config.py:17, mysearch/config.py:198 -- `official` 模式下,runtime 会继续读取 `MYSEARCH_TAVILY_API_KEY`、`MYSEARCH_TAVILY_API_KEYS`、`MYSEARCH_TAVILY_KEYS_FILE`,并忽略 `MYSEARCH_PROXY_API_KEY` 对 Tavily 的注入。来源:mysearch/config.py:356, mysearch/config.py:395, mysearch/config.py:402 -- `gateway` 模式下,runtime 会改读 `MYSEARCH_TAVILY_GATEWAY_BASE_URL`、`MYSEARCH_TAVILY_GATEWAY_TOKEN` 和 `MYSEARCH_TAVILY_GATEWAY_*` 路径与鉴权配置,适合对接 `tavily-hikari` 这类上游。来源:mysearch/config.py:211, mysearch/config.py:287, mysearch/config.py:325, mysearch/config.py:383 -- 如果调用方没显式写 `MYSEARCH_TAVILY_MODE`,但配置了 `MYSEARCH_PROXY_*`,Tavily 默认仍落到 `gateway` 分支,保持现有 `proxy-first` 客户端最小配置不变。来源:mysearch/config.py:199, mysearch/config.py:206 -- `mysearch_health` 现在会把 Tavily 的 `provider_mode` 暴露出来,排查时先看这里,避免把“上游 gateway token 缺失”和“本地官方 key 池为空”混成一个问题。来源:mysearch/server.py:157, mysearch/clients.py:2717 - -## 配置优先级 - -- MySearch runtime 会先读 `~/.codex/config.toml` 的 `mcp_servers.mysearch.env`,再把 `.env` 当本地兜底,不覆盖宿主已注入配置。来源:README.md:91, mysearch/config.py:85, mysearch/config.py:98 -- `install.sh` 会先继承宿主已有的 `MYSEARCH_*`,再用 `mysearch/.env` 补缺省值,并将这些 env 注册给 `claude` 与 `codex`。来源:README.md:93, install.sh:16, install.sh:158, install.sh:174 - -## 数据与控制面 - -- Proxy 的启动时机会执行 `db.init_db()`;SQLite 默认路径仍是 `proxy/data/proxy.db`,但容器部署已经统一通过 `MYSEARCH_PROXY_DB_PATH=/data/proxy.db` 覆盖,避免独立 `proxy` 镜像和 `mysearch-stack` 因内部目录不同各写一份库。来源:proxy/server.py:42, proxy/database.py:11, proxy/database.py:15, proxy/database.py:59, proxy/Dockerfile:1, Dockerfile.stack:1 -- Proxy 的 token 体系里包含 `mysearch` 服务,生成前缀为 `mysp-` 的统一 token,默认只做鉴权与统计,不做配额拦截。来源:proxy/database.py:13, proxy/database.py:18, proxy/README.md:74, proxy/README.md:83 -- `proxy-first` 的容器部署边界现在同时支持“两服务一套 stack”和“单容器一体化镜像”两种形态。仓库根的 `docker-compose.yml` 会同时编排 `proxy` 与 `mysearch`:前者负责控制台、token 与统一代理,后者负责对 Codex/Claude 暴露远程 MCP;`mysearch` 在同一个 compose 网络里继续用 `MYSEARCH_PROXY_BASE_URL=http://proxy:9874` 访问 Proxy,并通过受限的 `MYSEARCH_PROXY_BOOTSTRAP_TOKEN` 向 `/api/internal/mysearch/token` 申请或复用专用的 `mysp-` token。对于更看重部署步骤最少的场景,`Dockerfile.stack` 与 `docker/combined-entrypoint.sh` 又把这两个进程收成单容器镜像 `mysearch-stack`:`proxy` 默认对外监听 `9874`,而 `mysearch` 继续监听 `8000/mcp` 并通过容器内 `127.0.0.1:9874` 回连 Proxy。GitHub Actions 侧也已经从“只发 Proxy 镜像”扩成三镜像 matrix:`.github/workflows/docker-publish.yml` 会分别构建/发布 `proxy`、`mysearch` 与 `stack`,而根目录、`proxy/` 和 `mysearch/` 的 `.dockerignore` 则分别收口各自上下文,避免把本地 SQLite、`.env`、accounts 文件与缓存一起打进镜像。来源:docker-compose.yml:1, Dockerfile.stack:1, docker/combined-entrypoint.sh:1, .github/workflows/docker-publish.yml:1, .dockerignore:1, proxy/.dockerignore:1, mysearch/.dockerignore:1 -- Proxy 控制台现在已经从单文件模板拆成 `console.html + _hero.html + _settings_modal.html + console.css + console.js` 这套 live 前端;页面布局已经回到 `summary-strip + dashboard-flow` 的纵向结构,默认首页下半区固定为 `Workspace Navigator -> provider workspace`,而统一客户端接入则拆到独立的 `/mysearch` 页面。`Workspace Navigator` 仍然只保留工作台名称、状态和 2 个核心指标,次要信息下沉到 badge 与 footnote,不再展示 `/api/search`、`/social/search` 这类具体请求路径;但它现在不再纵向堆叠,而是由 `service-switcher` 横向卡阵列承接,`Social Compatibility` 提示卡也继续收在 switcher 区块底部。登录入口也不再是孤立小表单,而是通过 `auth-meta` 把“统一入口 / provider / 控制面”三个概念先交代清楚,并在登录成功后由 `showDashboard({ animate: true })` 做一轮轻量 staged reveal,而在 `prefers-reduced-motion` 下会自动压平动效。hero 右侧原先那张“当前工作台”大卡已经移除,不再在首屏重复展示当前控制台状态。`/mysearch` 页则收成 `MySearch 接入台`,模块标题进一步压成 `统一接入配置`,避免页级标题和模块标题重复。该页内部继续保持“接入配置 / 安装路径 / 通用 Token 管理”三层,而且“一键配置”内部进一步拆成左侧 `quickstart-visual-col` 可视化 readiness 区和右侧 `quickstart-config-col` 配置区:`getQuickstartProviderCards()` 继续把 Tavily `effective_mode`、Exa / Firecrawl key 状态和 Social / X 接线结果汇总成 `quickstart-route-strip`,`getQuickstartInstallHint()` 继续把当前最短安装路径压成 `quickstart-install-strip`,同时也把旧版更直接的 `stdio / streamable-http` 安装形态补回到 `quickstart-install-meta`。这些状态会一起写入生成的 `MYSEARCH_PROXY_*` 配置说明;除了复制块旁边的普通复制按钮,现在还额外提供 `copyEnvAndRevealInstall()` 这个组合动作,直接复制 `.env` 并把视口定位到安装命令。默认首页的 `summary-strip` 也已经收窄成项目级概览:`当前工作台 / 已接通工作台 / Provider 代理 Token / 今日调用 / 本月调用 / MySearch Token`,不再塞入工作台内部已经会单独展示的上游额度或本地 API Key。主题切换则扩成 `浅色 / 深色 / 自动` 三态,`自动` 依据打开页面那台机器的本地时区与本地时间决定实际主题,不依赖服务端所在系统或容器时区。`MySearch 通用 Token` 摘要表继续共享和 provider 面板一致的本地搜索/排序逻辑。provider 页面仍然保持“摘要表 + `detail-drawer`”的运维视图,`Token 池 / API Key 池` 的本地搜索、筛选和排序,以及 `table-row-clickable.is-danger|is-warn|is-busy|is-off` 风险行态都保留不变;`detail-drawer` 底部动作也继续通过 `renderDrawerActionGroup()` 拆成“维护动作 / 危险动作”两组。设置面板仍是带 `settings-summary-strip`、sticky footer 和 Tavily `mode-switch` 分段控件的配置中心,并保留 `/api/settings/test/tavily` 与 `/api/settings/test/social` 这两条结构化 probe 链路。控制台刷新仍然通过 `normalizeRefreshScope()`、`getRefreshScopeForService()`、`renderDashboardScope()` 做局部更新,可访问性层也仍然保留 `handleSegmentedControlKey()`、toast live region、overlay focus remember/restore 与 `trapOverlayFocus()` 这一组统一逻辑。来源:proxy/templates/components/_hero.html:25, proxy/templates/components/_hero.html:51, proxy/templates/console.html:51, proxy/templates/console.html:54, proxy/templates/console.html:65, proxy/templates/mysearch.html:21, proxy/static/js/console.js:644, proxy/static/js/console.js:1425, proxy/static/js/console.js:1478, proxy/static/js/console.js:1695, proxy/static/js/console.js:2251, proxy/static/js/console.js:2777, proxy/static/css/console.css:622, proxy/static/css/console.css:717, proxy/static/css/console.css:1061, proxy/static/css/console.css:1508, proxy/static/css/console.css:1533, proxy/static/css/console.css:2923, proxy/static/css/console.css:2943, proxy/server.py:2259 diff --git a/llmdoc/guides/common-workflows.md b/llmdoc/guides/common-workflows.md deleted file mode 100644 index 7a1d2d1..0000000 --- a/llmdoc/guides/common-workflows.md +++ /dev/null @@ -1,43 +0,0 @@ -# 常见工作流 - -## 1. 本机给 Codex / Claude Code 接入 MySearch - -1. 在仓库根目录准备 Python 环境,并优先把 `MYSEARCH_*` 写进宿主配置,而不是先复制 `.env`。来源:README.md:165, README.md:172, skill/README.md:72 -2. 执行根目录 `install.sh`。它会安装 `mysearch/requirements.txt`,继承宿主已有配置,再尝试把 `mysearch` 注册到 Claude Code 和 Codex。来源:install.sh:13, install.sh:74, install.sh:174, install.sh:183 -3. 验收时先看 `mcp list/get`,再跑 `python skill/scripts/check_mysearch.py --health-only` 和基础 smoke test。来源:README.md:193, skill/README.md:148 - -## 2. 部署 proxy-first 链路 - -1. 现在最省事的部署方式是单容器镜像 `mysearch-stack`:直接暴露 `9874` 和 `8000` 两个端口,容器内部同时启动 `proxy` 与 `mysearch`,`proxy` 默认对外监听 `9874`,`mysearch` 再通过容器内回环地址访问 Proxy。来源:README.md:194, Dockerfile.stack:1, docker/combined-entrypoint.sh:1 -2. 如果你更看重服务边界清晰,仓库根目录的 `docker-compose.yml` 仍然提供“两服务一套 stack”:`proxy` 与 `mysearch` 在同一个 compose 网络里启动,`mysearch` 默认通过 `MYSEARCH_PROXY_BASE_URL=http://proxy:9874` 回连同 stack 内的 Proxy。来源:docker-compose.yml:1 -3. 不管是单容器还是 compose,两种部署现在都不再要求你手动先创建 `mysp-` token。`proxy` 只要配置了 `MYSEARCH_PROXY_BOOTSTRAP_TOKEN`,就会启用受限的 `/api/internal/mysearch/token`,`mysearch` 启动脚本会自动用同一个 bootstrap token 去创建或复用自己的专用 `mysp-` token。初始化时你只需要登录控制台补 provider 配置和 usage sync。来源:proxy/server.py:680, proxy/server.py:2702, mysearch/docker-entrypoint.sh:1, mysearch/scripts/bootstrap_proxy_token.py:1 -4. 如果你希望镜像自动发布到 Docker Hub,当前仓库的 `.github/workflows/docker-publish.yml` 已经从单镜像流程扩成三镜像 matrix。它参考 `code/program/todo-list/.github/workflows/docker-publish.yml` 的结构,但会先跑 `python -m unittest discover -s tests`、`py_compile` 和 `node --check proxy/static/js/console.js`,再分别对 `proxy`、`mysearch` 与 `stack` 做 buildx 多架构构建;pull request 只验证构建,push 到 `main` 或打 `v*` tag 时才真正发布镜像。默认镜像名分别是 `DOCKERHUB_USERNAME/mysearch-proxy`、`DOCKERHUB_USERNAME/mysearch-mcp` 与 `DOCKERHUB_USERNAME/mysearch-stack`,也可以用 `DOCKERHUB_IMAGE_NAME_PROXY` / `DOCKERHUB_IMAGE_NAME_MYSEARCH` / `DOCKERHUB_IMAGE_NAME_STACK` 覆盖。来源:.github/workflows/docker-publish.yml:1 -5. 根目录、`proxy/` 与 `mysearch/` 现在都有各自的 `.dockerignore`:前者排除 git、venv、`llmdoc` 和本地数据目录,`proxy/.dockerignore` 排除控制台 SQLite 与本地 `.env`,`mysearch/.dockerignore` 排除 `.env`、venv、accounts、数据库与缓存文件,避免本地调试状态被 `COPY` 进镜像上下文。来源:.dockerignore:1, proxy/.dockerignore:1, mysearch/.dockerignore:1 -6. 首次进入默认搜索控制台后,顶部 hero 不再单独重复展示“当前工作台”大卡;首屏只保留品牌区、快捷动作和 4 条 provider lane。如果想直接进入操作区,优先用 `进入当前工作台`;如果想看统一客户端接入配置,则用 `查看 MySearch 接入` 跳到独立的 `/mysearch` 页面。来源:proxy/templates/components/_hero.html:25, proxy/templates/components/_hero.html:42, proxy/templates/components/_hero.html:51, proxy/templates/components/_hero.html:77, proxy/static/js/console.js:631 -7. 默认搜索控制台下半区现在固定为 `Workspace Navigator -> 具体工作台`,不再默认把 `MySearch 快速接入` 挂在首页。switcher 卡片只保留工作台名称、状态和 2 个核心指标,次要信息下沉成 badge 与说明,不再显示 `/api/search` 这类具体请求路径;同时它已经改成横向卡阵列,不再一张张纵向堆高页面。`Social Compatibility` 也已经收回到 switcher 区块底部。Tavily 现在不是手动二选一,而是进 `Settings -> Tavily` 看 `auto|pool|upstream` 三态分段控件,保存后前端会同时显示“配置模式 / 当前实际 / 来源”,其中 `auto` 会按“上游凭证优先,其次本地活跃 Tavily key”自动解析;如果你只是导入 Tavily key,默认实际就会落到 API Key 池。Social/X 的 grok2api 或 compatible 配置仍进 `Settings -> Social / X`,而且字段标题现在按职责拆成“搜索上游”和“后台管理”两类,不再把 `Base URL`、后台地址和 app key 混成一组理解。设置中心每个 tab 都带 `settings-summary-strip`、sticky footer 和“测试当前连接”按钮,而且测试结果会直接展开成结构化 probe 卡,不需要自己从一行状态文案里猜请求目标或鉴权来源。来源:proxy/templates/console.html:51, proxy/templates/console.html:61, proxy/templates/components/_settings_modal.html:61, proxy/templates/components/_settings_modal.html:117, proxy/templates/components/_settings_modal.html:218, proxy/static/css/console.css:1061, proxy/static/js/console.js:2262 -8. 具体 provider 页面现在统一先看 `stats + 接线摘要`,再按需展开 `Token 池` 和 `API Key 池` 两个 detail cards;主表已经降成摘要视图,点击任一 token/key 行会打开右侧 `detail-drawer` 查看完整额度、账户层级信息和维护动作。新增的本地筛选条会直接在前端做搜索、筛选和排序,而且已经补到 `失败优先`、`待处理`、`异常优先`、`低额度优先` 这类运维向视角;表格行也可以直接用键盘 `Enter / Space` 打开详情抽屉。需要特别区分的是“本地池统计”和“上游状态”:Tavily 当前实际走 upstream 时,概览优先展示上游 Hikari 的公共摘要,例如活跃 key、耗尽 key、总请求与总剩余额度,本地 Tavily key 会降级成回退库存;Social / X 在接通 grok2api 后台时继续显示完整 token 统计,但如果只有手动上游 key / gateway token,就只展示基础接线可视化,例如上游 key 数、客户端 token 数和可转发状态,不再把后台未接通误显示成一排 0。对于兼容后台的 `/v1/admin/tokens`,控制台现在也会先解包 `{tokens:{...}}`、`{data:{...}}`、`{items:[...]}` 这类响应再做统计,避免后台地址和 app key 都已配置但 token 总数仍错误地显示为 0。初始化顺序仍建议保持“登录 -> 导入 Tavily/Firecrawl/Exa key -> 需要时补 Social/X -> sync usage -> 创建 `mysp-` token”。来源:proxy/templates/console.html:94, proxy/static/js/console.js:845, proxy/static/js/console.js:1268, proxy/static/js/console.js:2631, proxy/static/js/console.js:2718 -9. `MySearch 快速接入` 现在已经独立到 `/mysearch` 页面,不再默认出现在搜索控制台首页,这一页也不再重复展示首页 `summary-strip`。页级标题已经收成 `MySearch 接入台`,模块级标题则用 `统一接入配置`,不再两层都重复 `MySearch 快速接入`。页面内部继续收成“接入配置 / 安装路径 / 通用 Token 管理”三层,而且“一键配置”内部再拆成左侧可视化 readiness 区和右侧配置区,不再让 route 小卡片和 `.env`/说明混在一列里。`quickstart-route-strip` 会根据 Tavily `effective_mode`、Exa / Firecrawl key 状态和 Social / X 接线情况,动态显示当前 provider readiness;`quickstart-install-strip` 则把“创建通用 token → 复制 `.env` → ./install.sh”压成当前最短安装路径,并额外提供 `复制 .env 并定位命令` 的组合动作;旧版更直观的默认安装形态也通过 `quickstart-install-meta` 补回来了,直接展示 `stdio / streamable-http`。生成的 `.env` 里也会把“当前路由状态”写进去。客户端侧仍只保留 `MYSEARCH_PROXY_BASE_URL` 与 `MYSEARCH_PROXY_API_KEY`,不再把 provider key 散落到每台机器;`MySearch 通用 Token` 摘要表也补上了本地搜索和排序。来源:README.md:79, proxy/templates/mysearch.html:45, proxy/static/js/console.js:1433, proxy/static/js/console.js:1486, proxy/static/js/console.js:1546, proxy/static/js/console.js:1695 -10. 默认首页的 `summary-strip` 现在更偏“控制面概览”而不是“把所有 provider 细节都缩一遍”。它只展示 `当前工作台 / 已接通工作台 / Provider 代理 Token / 今日调用 / 本月调用 / MySearch Token`。其中 `Provider 代理 Token` 会按工作台当前是否走本地代理池动态统计,例如 Tavily 或 Social / X 一旦切到上游 Gateway/兼容后台,就会从这项里剔除;Exa / Firecrawl 继续按本地 provider token 池计入。这样首页摘要只表达项目拓扑和控制面状态,不再重复展示工作台内部已经会单独展示的上游额度、本地 API Key 或 `Social Chat` 细节。来源:proxy/static/js/console.js:2777 -11. 保存设置、测试连接、复制配置和同步额度仍统一走页面右下角 toast;删除 token/key 这类危险动作现在不再用浏览器原生确认框,而是统一走 `app-dialog`;控制台范围内也已经没有原生 `select`,Tavily 工作模式改成了自定义 `mode-switch` 分段控件;左侧 `Social Compatibility` 与右侧 `Social / X 接入` 也都改成更摘要优先的结构,长英文值不再直接用大字号 value 顶满卡片;provider 的 token/key 摘要表则会用 `danger / warn / busy / off` 行态底色优先标出同步错误、低额度、异常活跃和停用状态,并在表格上方用 `table-legend` 直接给出图例;右侧 `detail-drawer` 的底部动作也改成“维护动作 / 危险动作”两组,删除类操作不再和普通维护动作并排。登录页也已经和 dashboard 收成同一套视觉语言,补了 `auth-meta` 元信息卡;登录、设置保存、设置测试、额度同步、创建 token、添加/导入 key 这些主操作现在都会显示按钮级 loading / success / error 过渡,而且共享同一个 `runWithBusyButton()`,会自动给 busy 态保留最小时长并避免在刷新后把 success/error 反馈闪到错误节点。进一步地,控制台现在已经把频繁操作改成“局部刷新而不是全量重绘”,并补上了 `mode-switch / mini-switch / settings-tab` 的方向键导航、toast live region、dialog 焦点回收与 focus trap;登录成功后 dashboard 仍会做一轮轻量 staged reveal,而在 `prefers-reduced-motion` 下这些动效会自动压平。主题切换也已经扩成 `浅色 / 深色 / 自动` 三态,`自动` 会按打开页面那台机器的本地时区和本地时间切换实际主题,不依赖服务端所在系统或 Docker 容器时区。来源:proxy/templates/console.html:97, proxy/templates/console.html:114, proxy/templates/console.html:134, proxy/static/js/console.js:19, proxy/static/js/console.js:108, proxy/static/js/console.js:178, proxy/static/js/console.js:644, proxy/static/js/console.js:653, proxy/static/js/console.js:808, proxy/static/js/console.js:3123, proxy/static/js/console.js:3495, proxy/static/css/console.css:118, proxy/static/css/console.css:1008, proxy/static/css/console.css:1245, proxy/static/css/console.css:2507, proxy/static/css/console.css:2908 - -## 3. 接入 OpenClaw - -1. OpenClaw 正式配置优先写 `openclaw.json` 的 `skills.entries.mysearch.env`,不要把 secret 直接写进仓库文件。来源:README.md:94, openclaw/README.md:65, openclaw/README.md:93 -2. 本地替换 bundle 时,走 `openclaw/` 自带安装脚本;从 ClawHub 安装时,重点仍然是注入 env,而不是手改 bundle。来源:openclaw/README.md:55, openclaw/README.md:74 -3. 验收顺序是 `health` -> `search --mode web` -> 需要时补 `docs` / `social`。来源:openclaw/README.md:121 - -## 4. 排查搜索行为 - -1. 先跑 `mysearch_health`,确认 provider 是否可用,而不是只看 key 有没有填。来源:mysearch/server.py:156, README.md:105 -2. 再看 `route` 与 `route_debug`,判断是显式 provider、生效的 intent/strategy,还是 blended/hybrid 路由。当前 docs/resource 默认不再自动 blended;如果你看见 `route_debug.domain_filter_mode=site_query_retry`,说明 Tavily 的官方域约束已经进入了 `site:` 形式重试,而且第二轮已经改成“放宽 provider 侧过滤、再在客户端做域名筛选”,不再把同一层 `include_domains` 直接重放给上游。`route_debug` 里现在还会带 `requested_max_results` 和 `candidate_max_results`,表示 `strategy` 是否扩大了候选池预算。来源:mysearch/clients.py:359, mysearch/clients.py:651, mysearch/clients.py:1013, mysearch/clients.py:1484 -3. 文档类查询要分清“页面发现”和“正文抓取”是两个阶段;正文异常先查 Firecrawl,再看 Tavily/Exa fallback。纯 `pricing/价格` 这类官方价格题当前不再默认按 docs/resource 路由,除非 query 里还显式带了 `docs`、`documentation`、`manual` 之类文档信号。对于 `linux.do`、`zhihu.com`、`medium.com` 这类受限 / 社区域名,如果又叠加 docs/resource 语义,运行时现在会优先把页面发现切到 Firecrawl。来源:mysearch/clients.py:1023, mysearch/clients.py:1101, mysearch/clients.py:677, mysearch/clients.py:3489 -4. 如果结果本身看起来“搜到了,但不够可信”,直接看 `evidence`。当前搜索结果已经统一补了 `source_diversity`、`source_domains`、`official_source_count`、`official_mode`、`confidence` 和 `conflicts`;其中 `official_mode=strict` 表示这次查询要么显式带了 `include_domains`,要么 query 明确要求“官方/官网/official”。在 strict 模式下,如果已经命中官方候选,结果会自动收束到官方项;如果仍然没有官方命中,则不会再把结果过滤成空,而是通过 `strict-official-unmet` 明确告诉你“这次官方约束没有满足”。docs/resource 下如果只有单 provider、单域名或官方/第三方混排,也会在这里直接暴露风险,而不是只靠人工扫链接。来源:mysearch/clients.py:1040, mysearch/clients.py:1064, mysearch/clients.py:3089 -5. `research` 现在要额外看 research 级 evidence,而不只是 `pages` 列表。返回里已经补了 `search_confidence`、`page_success_rate`、`page_error_count`、`official_mode` 和 research 级 `confidence/conflicts`:如果搜索本身很强但正文抓取不完整,会出现 `page-extraction-partial`;如果是严格官方查询却没命中官方候选,会直接暴露 `strict-official-unmet`。这能帮助你区分“发现阶段不稳”和“正文阶段不稳”。来源:mysearch/clients.py:869, mysearch/clients.py:1093 -6. 如果问题出在团队共享链路,而不是本地 runtime,就把排查重心切到 `proxy/` 的 key 池、token、usage sync 和 social gateway 配置。遇到“后台地址和 app key 都已配置,但 Social / X token 统计还是 0”时,先确认上游 `/v1/admin/tokens` 返回结构是否带 envelope;当前控制台已经兼容 `{tokens:{...}}`、`{data:{...}}`、`{items:[...]}`,所以这种现象通常说明进程还没重启到新代码,或者后台返回的并不是这组 admin 语义。来源:proxy/README.md:27, proxy/server.py:189, proxy/server.py:736, proxy/database.py:179 - -## 5. 什么时候改哪里 - -- 改 prompt、安装话术、AI 调用顺序:看 `skill/` -- 改 runtime 参数、provider 路由、search/extract/research 组合:看 `mysearch/` -- 改统一 token、代理 API、控制台和持久化:看 `proxy/` -- 改 OpenClaw bundle:看 `openclaw/` diff --git a/llmdoc/reference/entrypoints-and-config.md b/llmdoc/reference/entrypoints-and-config.md deleted file mode 100644 index a240f54..0000000 --- a/llmdoc/reference/entrypoints-and-config.md +++ /dev/null @@ -1,158 +0,0 @@ -# 运行入口与配置入口 - -## 关键运行入口 - -### 根安装入口 - -- `install.sh:1` -- 负责安装 `mysearch/requirements.txt`,然后尝试向 `Claude Code` 与 `Codex` 注册本地 `stdio` MCP。见 `install.sh:13`、`install.sh:174`、`install.sh:183`。 -- 它会先读取宿主 `~/.codex/config.toml` 里 `mcp_servers.mysearch.env`,再用 `mysearch/.env` 只补缺省值。见 `install.sh:74`、`install.sh:131`、`install.sh:158`。 - -### MySearch MCP 启动入口 - -- `mysearch/__main__.py:8` - - 解析 `--transport`、`--host`、`--port`、`--sse-path`、`--streamable-http-path` 等参数。 -- `mysearch/server.py:168` - - 最终调用 FastMCP 的 `run(...)`。 -- `mysearch/server.py:34` - - `build_mcp()` 里定义 4 个 MCP tools。 - -### Proxy Console / API 入口 - -- `proxy/server.py:1642` - - 启动时初始化 SQLite。 -- `proxy/server.py:1649`、`proxy/server.py:1680`、`proxy/server.py:1719`、`proxy/server.py:1783` - - 对外搜索代理与 Social/X 代理入口。 -- `proxy/server.py:1877` - - 控制台页面入口。 -- 运行方式见 `proxy/README.md:144`、`proxy/README.md:166`、`proxy/README.md:173` 与 `proxy/Dockerfile:7`。 -- 自动化镜像发布入口见 `.github/workflows/docker-publish.yml:1`。当前 workflow 只构建 `proxy/` 容器,不会把整个仓库打成单镜像;Docker build context 固定为 `./proxy`,并依赖 `proxy/.dockerignore:1` 排除本地数据库和缓存文件。 - -### OpenClaw wrapper 入口 - -- `openclaw/scripts/mysearch_openclaw.py:303` - - bundle CLI 总入口,支持 `health`、`search`、`extract`、`research`。 -- `openclaw/scripts/mysearch_openclaw.py:74` - - wrapper 会先检查 bundled runtime 是否存在,再把 `openclaw/runtime/` 注入 `sys.path`。 - -## 配置优先级 - -## MySearch runtime - -稳定规则:**已存在的进程环境变量优先,bootstrap 逻辑只补缺失值。** - -原因是 `mysearch/config.py` 的加载器统一用 `os.environ.setdefault(...)`,见 `mysearch/config.py:23`、`mysearch/config.py:40`。 - -推荐按下面顺序理解: - -1. 进程已注入的环境变量。 -2. `~/.codex/config.toml` 中 `mcp_servers.mysearch.env`,见 `mysearch/config.py:85`。 -3. `mysearch/.env` 或仓库根 `.env` 作为本地单仓兜底,见 `mysearch/config.py:98`。 - -回归测试明确要求:`config.toml` 覆盖 `.env`,而 `.env` 只补缺失字段;Python 3.10 无 `tomllib` 时仍可回退解析。当前运行时还额外兼容 Python 3.9:dataclass 装配层会自动去掉 `slots`,避免 `mysearch/config.py`、`mysearch/clients.py`、`mysearch/keyring.py` 以及 OpenClaw 对应 runtime 在导入阶段直接报错。见 `mysearch/config.py:17`、`mysearch/clients.py:24`、`mysearch/keyring.py:12`、`openclaw/runtime/mysearch/config.py:17`、`openclaw/runtime/mysearch/clients.py:24`、`openclaw/runtime/mysearch/keyring.py:12`、`tests/test_config_bootstrap.py:39`、`tests/test_config_bootstrap.py:144`。 - -## Proxy-first 默认映射 - -`MySearchConfig.from_env()` 会先读 `MYSEARCH_PROXY_BASE_URL` 与 `MYSEARCH_PROXY_API_KEY`,但当前语义已经比早期版本更细。Firecrawl / Exa 仍然会直接切到 Proxy 语义;Tavily 则先看 `MYSEARCH_TAVILY_MODE`,只有在 `gateway` 分支下才会继承 proxy/gateway 语义,显式 `official` 时会继续使用自己的官方 key 池。见 `mysearch/config.py:167`、`mysearch/config.py:181`、`mysearch/config.py:198`、`mysearch/config.py:281`、`mysearch/config.py:320`、`mysearch/.env.example:7`。 - -这意味着对下游客户端来说,最小配置通常只需要: - -- `MYSEARCH_PROXY_BASE_URL` -- `MYSEARCH_PROXY_API_KEY` - -但如果你希望 Tavily 不走统一 Proxy,而是自己维护 key 池或对接独立上游,就要显式指定: - -- `MYSEARCH_TAVILY_MODE=official` - - 配合 `MYSEARCH_TAVILY_API_KEY`、`MYSEARCH_TAVILY_API_KEYS`、`MYSEARCH_TAVILY_KEYS_FILE` -- `MYSEARCH_TAVILY_MODE=gateway` - - 配合 `MYSEARCH_TAVILY_GATEWAY_BASE_URL`、`MYSEARCH_TAVILY_GATEWAY_TOKEN` - -## 部署后给 Codex 的远程 MCP 配置 - -如果你部署的是单容器 `mysearch-stack`,或者已经把 `proxy + mysearch` compose 跑起来,对 `Codex` 来说真正要接入的是 `mysearch` 暴露出来的远程 MCP,而不是 `proxy` 控制台本身。 - -最小 `~/.codex/config.toml`: - -```toml -[mcp_servers.mysearch] -type = "http" -url = "http://127.0.0.1:8000/mcp" -``` - -远程主机: - -```toml -[mcp_servers.mysearch] -type = "http" -url = "https://mysearch.example.com/mcp" -``` - -如果远程入口前面还有 Bearer: - -```toml -[mcp_servers.mysearch] -type = "http" -url = "https://mysearch.example.com/mcp" -headers = { Authorization = "Bearer YOUR_MCP_TOKEN" } -``` - -这里要明确区分: - -- `proxy` 控制台默认还是 `http://host:9874` -- `Codex` 要接的是 `mysearch` MCP,默认是 `http://host:8000/mcp` -- `MYSEARCH_PROXY_API_KEY` 是 `mysearch` 去访问 `proxy` 时用的内部 token,不是 `Codex` 自己必须填写到 MCP 配置里的字段 - -部署后的最小验收顺序: - -1. 重启 `Codex` -2. `codex mcp get mysearch` -3. `python3 skill/scripts/check_mysearch.py --health-only` - -## OpenClaw wrapper - -OpenClaw 侧也是 host-config-first,但入口不同: - -1. 进程已有环境变量。 -2. `openclaw.json` 中 `skills.entries.mysearch.env`,见 `openclaw/scripts/mysearch_openclaw.py:43`。 -3. `openclaw/.env`,见 `openclaw/scripts/mysearch_openclaw.py:304`。 -4. `openclaw/runtime/.env`,见 `openclaw/scripts/mysearch_openclaw.py:306`。 - -测试已覆盖 wrapper 会从 `openclaw.json` 读取 skill env。见 `tests/test_config_bootstrap.py:97`。 - -## 关键环境变量分组 - -| 分组 | 用途 | 入口 | -| --- | --- | --- | -| `MYSEARCH_PROXY_*` | 统一走 MySearch Proxy 的下游接线 | `mysearch/.env.example:7`, `openclaw/.env.example:2` | -| `MYSEARCH_TAVILY_MODE` | 显式选择 Tavily 走官方 key 池还是上游 gateway | `mysearch/.env.example:25`, `openclaw/.env.example:12` | -| `MYSEARCH_TAVILY_*` | Tavily 官方直连与本地 key 池 | `mysearch/.env.example:28`, `openclaw/.env.example:15` | -| `MYSEARCH_TAVILY_GATEWAY_*` | Tavily 上游 gateway token、base URL、path、auth 配置 | `mysearch/.env.example:39`, `openclaw/.env.example:26` | -| `MYSEARCH_FIRECRAWL_*` | Firecrawl 直连或兼容网关 | `mysearch/.env.example:37` | -| `MYSEARCH_EXA_*` | Exa 兜底路由 | `mysearch/.env.example:49` | -| `MYSEARCH_XAI_*` | official xAI 或 compatible social 模式 | `mysearch/.env.example:60`, `openclaw/.env.example:47` | -| `MYSEARCH_MCP_*` | 本地/远程 MCP 传输配置 | `mysearch/.env.example:14` | -| `SOCIAL_GATEWAY_*` | 本地 social gateway 或 Proxy social upstream/admin 配置 | `mysearch/.env.example:82`, `proxy/.env.example:1` | -| `ADMIN_*` | Proxy 控制台管理员认证 | `proxy/.env.example:1`, `proxy/README.md:249` | - -## 状态与数据落点 - -### Proxy SQLite - -- 数据库路径默认是 `proxy/data/proxy.db`,但容器部署现在统一建议通过 `MYSEARCH_PROXY_DB_PATH=/data/proxy.db` 覆盖,避免独立 `proxy` 镜像和 `mysearch-stack` 因内部目录不同而各自写到不同位置。见 `proxy/database.py:11`、`proxy/database.py:15`、`proxy/Dockerfile:1`、`Dockerfile.stack:1`。 -- 主要表:`api_keys`、`tokens`、`usage_logs`、`settings`,见 `proxy/database.py:61`。 -- 下游 token 服务范围包含 `tavily`、`firecrawl`、`exa`、`mysearch`;`mysearch` token 前缀为 `mysp-`。见 `proxy/database.py:12`、`proxy/database.py:13`、`proxy/database.py:14`。 - -### 运行时缓存 - -- `search` 与 `extract` 走 TTL 缓存;provider live probe 也有单独 TTL。见 `mysearch/clients.py:121`、`mysearch/clients.py:125`、`mysearch/clients.py:199`、`mysearch/clients.py:215`、`mysearch/clients.py:2842`。 - -## 重要验证点 - -- 路由健康保护:`tests/test_clients.py:488` -- 配置继承与 Python 3.10 fallback:`tests/test_config_bootstrap.py:39`, `tests/test_config_bootstrap.py:97`, `tests/test_config_bootstrap.py:144` -- Tavily official/gateway 分支:`tests/test_config_bootstrap.py:39`, `tests/test_config_bootstrap.py:74` -- Social/X 归一化与 fallback:`tests/test_social_normalization.py:76`, `tests/test_social_normalization.py:171` - -如果是直接用 `python tests/test_*.py` 跑单文件,而不是走 `python -m unittest discover`,当前几个脚本测试也已经自带仓库根目录引导,不再要求调用方先手动设置 `PYTHONPATH`。见 `tests/test_clients.py:10`、`tests/test_social_normalization.py:8`、`tests/test_proxy_tavily_settings.py:10`。 - -这些测试文件就是改动相关行为时最值得先看的“行为契约”。 diff --git a/llmdoc/reference/runtime-entrypoints.md b/llmdoc/reference/runtime-entrypoints.md deleted file mode 100644 index 8fb470a..0000000 --- a/llmdoc/reference/runtime-entrypoints.md +++ /dev/null @@ -1,61 +0,0 @@ -# 运行入口参考 - -## 关键入口文件 - -| 文件 | 角色 | 关键点 | -| --- | --- | --- | -| `install.sh` | 本地安装与注册入口 | 安装 `mysearch/requirements.txt`,继承宿主 `MYSEARCH_*`,再向 `claude` / `codex` 注册 `mysearch` MCP。来源:install.sh:13, install.sh:74, install.sh:174 | -| `docker-compose.yml` | 一套部署入口 | 在仓库根目录同时编排 `proxy` 与 `mysearch` 两个服务:`proxy` 继续监听 `9874` 并落盘 SQLite,`mysearch` 继续以 `streamable-http` 形式监听 `8000/mcp`,并通过 `MYSEARCH_PROXY_BASE_URL=http://proxy:9874` 反向接入同 stack 内的 Proxy。现在 compose 会同时把 `MYSEARCH_PROXY_BOOTSTRAP_TOKEN` 注入两边,并把 `MYSEARCH_PROXY_DB_PATH` 固定到 `/data/proxy.db`;`mysearch` 启动时会自动请求 `proxy` 的内部 bootstrap 接口来创建或复用自己的 `mysp-` token,不再要求你手工先创建 MySearch 通用 token 才能拉起远程 MCP。来源:docker-compose.yml:1 | -| `mysearch/__main__.py` | MySearch CLI 入口 | 解析 `stdio`、`sse`、`streamable-http` transport 及 host/port/path 参数,然后调用 `mysearch.server.main`。来源:mysearch/__main__.py:8 | -| `mysearch/server.py` | MCP tool 暴露层 | 用 `FastMCP` 注册 `search`、`extract_url`、`research`、`mysearch_health`,并根据 transport 启动服务。来源:mysearch/server.py:34, mysearch/server.py:47, mysearch/server.py:168 | -| `mysearch/Dockerfile` | MySearch MCP 容器入口 | 基于 `python:3.11-slim` 安装 `mysearch/requirements.txt`,只复制 `mysearch/` 目录到镜像内部,并默认以 `python -m mysearch --transport streamable-http --host 0.0.0.0 --port 8000` 暴露远程 MCP。来源:mysearch/Dockerfile:1 | -| `mysearch/docker-entrypoint.sh` | MySearch 容器启动引导 | 容器启动时如果还没有显式 `MYSEARCH_PROXY_API_KEY`,但存在 `MYSEARCH_PROXY_BOOTSTRAP_TOKEN`,就先调用 `mysearch/scripts/bootstrap_proxy_token.py` 向 Proxy 请求或复用一个 `mysp-` token,再继续启动 `python -m mysearch`。来源:mysearch/docker-entrypoint.sh:1 | -| `mysearch/scripts/bootstrap_proxy_token.py` | Proxy token bootstrap 客户端 | 负责轮询 `MYSEARCH_PROXY_BASE_URL/api/internal/mysearch/token`,用 `MYSEARCH_PROXY_BOOTSTRAP_TOKEN` 鉴权并把返回的 `mysp-` token 输出给容器启动脚本。来源:mysearch/scripts/bootstrap_proxy_token.py:1 | -| `mysearch/.dockerignore` | MySearch 容器上下文收口 | 排除 `.env`、`venv`、`accounts.txt`、数据库与缓存文件,避免把本地调试状态与 secret 一起打进 `mysearch` 镜像上下文。来源:mysearch/.dockerignore:1 | -| `.dockerignore` | 单容器镜像上下文收口 | 供根目录 `Dockerfile.stack` 使用,排除 git、venv、`llmdoc`、本地数据目录和缓存文件,避免把仓库级调试资产一起打进单容器镜像。来源:.dockerignore:1 | -| `Dockerfile.stack` | 单容器一体化镜像入口 | 在一个镜像里同时安装 `proxy` 与 `mysearch` 依赖,复制两个目录,并通过 `docker/combined-entrypoint.sh` 同时拉起 `proxy` 与远程 MCP;默认继续把 `MYSEARCH_PROXY_BASE_URL` 指向容器内 `127.0.0.1:9874`,同时把 Proxy SQLite 固定到 `/data/proxy.db`,避免 stack 与独立 `proxy` 镜像因为内部相对目录不同而各自写到不同位置。来源:Dockerfile.stack:1 | -| `docker/combined-entrypoint.sh` | 单容器双进程启动脚本 | 在同一个容器里先起本地 `proxy`,再通过 bootstrap 脚本拿到 `MYSEARCH_PROXY_API_KEY`,最后启动 `mysearch`;默认把 `proxy` 绑定到 `0.0.0.0:9874` 方便外部访问控制台,同时 `mysearch` 继续通过容器内 `127.0.0.1:9874` 回连 Proxy。由于 `proxy/server.py` 和 `proxy/key_pool.py` 仍然保留顶层 `import database` / `from key_pool import pool` 这类导入方式,stack 启动脚本还会把 `/app/proxy` 追加到 `PYTHONPATH`,确保单容器镜像里的 `uvicorn proxy.server:app` 能正常导入。任一子进程退出时,脚本会一起清理并让容器失败。来源:docker/combined-entrypoint.sh:1 | -| `mysearch/config.py` | Runtime 配置装配层 | 先读宿主 `~/.codex/config.toml`,再读 `.env` 兜底,并组装 Tavily / Firecrawl / Exa / xAI 的 `ProviderConfig`;Tavily 现在显式区分 `official` 与 `gateway`。运行时还兼容 Python 3.9:在解释器低于 3.10 时,dataclass 装配层会自动去掉 `slots`,避免配置模块导入即失败。来源:mysearch/config.py:17, mysearch/config.py:85, mysearch/config.py:98, mysearch/config.py:281, mysearch/config.py:320 | -| `mysearch/clients.py` | 搜索编排与 provider 适配层 | 统一处理 intent、strategy、routing、cache、blended search、extract fallback、research workflow。当前 docs/resource 查询默认不再因为 `balanced|verify|deep` 自动做 Tavily + Firecrawl blended;带 `include_domains` 的 Tavily 查询如果首轮 0 结果,会先做 `site:` 形式的 Tavily 重试,而且第二轮不再继续把 `include_domains` 原样透传给 provider,而是放宽 provider 侧约束后再在客户端按域名过滤,避免官方域约束被同一层过滤直接打空。对于 `linux.do`、`zhihu.com`、`medium.com` 这类受限 / 社区域名,如果查询又落在 docs/resource 语义,路由会优先把页面发现切到 Firecrawl。搜索结果层还额外补了基于 `strategy` 的候选预算、`source_diversity` / `official_source_count` / `confidence` / `conflicts` 这组 evidence 摘要,并把 docs/resource 的官方结果重排扩展到单 provider 路径,不再只有 blended 才做官方优先。进一步地,`include_domains` 或明确带 `official/官方/官网` 的查询现在会进入内部 `official_mode=strict`:如果已经命中官方候选,就把结果收束到官方项;如果没命中,则保留现有结果,但在 evidence 里显式打出 `strict-official-unmet`,避免再次把结果硬过滤成空。`research` 返回的 evidence 也不再只给 provider 数量,而会补 `search_confidence`、`page_success_rate`、`page_error_count`、`official_mode` 和 research 级 `confidence/conflicts`,让“小 research”结果能直接看出搜索置信度和正文覆盖率。纯 `pricing/价格` 查询也不再被强判成 docs/resource。来源:mysearch/clients.py:277, mysearch/clients.py:385, mysearch/clients.py:677, mysearch/clients.py:802, mysearch/clients.py:964, mysearch/clients.py:1006, mysearch/clients.py:1093, mysearch/clients.py:1468, mysearch/clients.py:2819, mysearch/clients.py:3051, mysearch/clients.py:3489 | -| `proxy/server.py` | Proxy 与控制台入口 | 暴露 Tavily / Firecrawl / Exa / Social 代理端点、控制台页面与管理 API;除了默认 `/` 搜索控制台以外,现在还额外提供 `/mysearch` 作为独立的 MySearch 接入页。FastAPI 入口已经切到 lifespan:启动时初始化数据库,退出时统一关闭共享 `http_client`,不再依赖已废弃的 `startup` hook。导入期的 `asyncio.Lock()` 也改成惰性初始化,避免 Python 3.9 在测试或脚本导入阶段留下未关闭事件循环。Tavily 上游链路现在会先按当前配置发起 probe 和真实转发;如果上游模式下首次命中 `404`,还会自动回退到 `/api/tavily/*` 兼容路径,避免 Hikari 类网关在 `Base URL` 写成主机根时直接报 `Not Found`。当 Tavily 当前实际走上游时,控制台还会额外尝试读取上游公开的 `/api/summary`,把 Hikari 的基础摘要映射成 `upstream_summary`。Social / X 则继续分成两档:接通后台 admin 接口时返回完整 token 统计;只有手动上游 key / gateway token 时,只返回 `upstream_visibility` 这类基础接线可视化,不再把“没有后台 token 面板”和“没有接通上游”混成同一组 0 值。兼容后台的 `/v1/admin/tokens` 如果不是直接返回 `pool -> list`,而是包了一层 `{tokens:{...}}`、`{data:{...}}`、`{items:[...]}` 之类 envelope,当前解析逻辑也会先解包再做统计,避免后台已接通但控制台仍显示一排 0。现在还额外提供了受限的 `/api/internal/mysearch/token` bootstrap 接口:仅在配置 `MYSEARCH_PROXY_BOOTSTRAP_TOKEN` 时启用,用于给 `mysearch` 容器自动创建或复用专用的 `mysp-` token,不让运行时直接读 SQLite。来源:proxy/server.py:219, proxy/server.py:228, proxy/server.py:233, proxy/server.py:680, proxy/server.py:1421, proxy/server.py:2702 | -| `proxy/.dockerignore` | Proxy 容器上下文收口 | Docker 本地构建 `proxy/` 镜像时,会排除 `data/`、`proxy.db`、`__pycache__`、`.env`、README 和 compose 文件,避免把本地 SQLite、缓存和非运行时资产打进镜像上下文。独立 `proxy` 镜像本身也会把 `MYSEARCH_PROXY_DB_PATH` 预置为 `/data/proxy.db`,与 stack 共享同一挂载口径。来源:proxy/.dockerignore:1, proxy/Dockerfile:1 | -| `.github/workflows/docker-publish.yml` | Docker 自动构建与发布入口 | 参考 `code/program/todo-list/.github/workflows/docker-publish.yml` 的结构,但当前仓库已经扩成三镜像 matrix:先跑 Python 单测、`py_compile` 与 `node --check proxy/static/js/console.js`,再分别对 `proxy`、`mysearch` 与 `stack` 做 buildx 多架构构建;pull request 只验证构建,push 到 `main` 或 `v*` tag 时才登录 Docker Hub 并推送。默认镜像名分别是 `mysearch-proxy`、`mysearch-mcp` 与 `mysearch-stack`,也可以用 `DOCKERHUB_IMAGE_NAME_PROXY` / `DOCKERHUB_IMAGE_NAME_MYSEARCH` / `DOCKERHUB_IMAGE_NAME_STACK` 覆盖。来源:.github/workflows/docker-publish.yml:1 | -| `proxy/templates/console.html` | Proxy 控制台壳模板 | 现在只负责装配默认 live 前端入口:引入 `components/_hero.html`、`components/_settings_modal.html`、`static/css/console.css`、`static/js/console.js`,并把页面组织成 `summary-strip + dashboard-flow` 的纵向结构;默认首页下半区顺序已经收成 `Workspace Navigator -> provider workspace`,`MySearch 快速接入` 不再默认挂在首页,改由 hero 里的“查看 MySearch 接入”跳去独立页面。`Social Compatibility` 也收回到 switcher 区块底部,不再单独占一侧 rail。壳层同时继续托管 `detail-drawer`、统一 `app-dialog` 和 `toast-root` 这三类交互容器。登录壳也继续保留在这里,但已经补了 `auth-meta` 三张元信息卡,让登录页与 dashboard 共用同一套基础设施风格。来源:proxy/templates/console.html:45, proxy/templates/console.html:51, proxy/templates/console.html:63, proxy/templates/console.html:92, proxy/templates/console.html:98, proxy/templates/console.html:125 | -| `proxy/templates/mysearch.html` | MySearch 独立接入页模板 | 承载独立的 `MySearch 接入台` 页面,保留同一套登录、主题切换、设置弹窗、detail drawer、dialog 和 toast,但默认只展示统一接入配置、安装路径和通用 token 管理,不再把 provider 工作台或首页 `summary-strip` 混进这一页。来源:proxy/templates/mysearch.html:1, proxy/templates/mysearch.html:45, proxy/templates/mysearch.html:63 | -| `proxy/templates/components/_hero.html` | Hero 与首屏焦点组件 | 定义 `Search Operations Desk` 顶部品牌区、主题切换、快捷动作和 4 条 provider lane。右侧“当前工作台”大卡已经从 hero 里移除,不再在这块重复展示当前控制台状态;“查看 MySearch 接入”按钮现在会直接跳到独立的 `/mysearch` 页面。主题切换也不再只有浅色/深色两态,而是 `浅色 -> 深色 -> 自动` 三态循环;`自动` 会按打开页面那台机器的本地时区与本地时间决定当前生效主题。来源:proxy/templates/components/_hero.html:7, proxy/templates/components/_hero.html:14, proxy/templates/components/_hero.html:42, proxy/templates/components/_hero.html:51, proxy/templates/components/_hero.html:77, proxy/static/js/console.js:19, proxy/static/js/console.js:634 | -| `proxy/templates/components/_settings_modal.html` | 控制台设置组件 | 设置中心继续按 `Console / Tavily / Social / X` tabs 组织,但每个 tab 现在都加了 `settings-summary-strip` 和 sticky footer;Tavily 的工作模式已经不是原生 `select`,而是隐藏 input + `mode-switch` 分段控件,并在设置面板里直接展示“当前实际”运行条。Tavily 与 Social / X 的 footer 里还新增了“测试当前连接”按钮,对应后端 `settings-test` 诊断接口;测试结果不再只是状态句子,而是通过独立的 `settings-*-probe` 区块呈现请求目标、鉴权来源、返回状态、失败原因和建议动作。Social / X 设置表单里的字段标题也已经按职责重命名成“搜索上游 Base URL / 后台管理地址 / 后台管理 app key / 搜索上游 API key / 客户端访问 token”,避免把搜索转发配置和后台管理配置混在一起理解。来源:proxy/templates/components/_settings_modal.html:18, proxy/templates/components/_settings_modal.html:35, proxy/templates/components/_settings_modal.html:61, proxy/templates/components/_settings_modal.html:77, proxy/templates/components/_settings_modal.html:79, proxy/templates/components/_settings_modal.html:85, proxy/templates/components/_settings_modal.html:117, proxy/templates/components/_settings_modal.html:218 | -| `proxy/static/js/console.js` | 控制台交互与渲染主入口 | 负责主题持久化、当前工作台信号判断、workspace navigator、provider 面板、MySearch quickstart、settings form 回填,以及 Tavily/Social 设置保存;新一轮 UI 里除了 `renderSettingsSummaries()`、`renderPoolGlance()`、`openDetailDrawer()`、`openTokenDetail()`、`openKeyDetail()` 和 `showConfirmDialog()` 之外,还新增了 `normalizeRefreshScope()`、`getRefreshScopeForService()`、`renderDashboardScope()` 这组局部刷新机制,用来避免每次操作都全量重绘 hero / summary / quickstart / 全部 workspace。脚本现在还会根据 `PAGE_KIND` 区分默认控制台与 `/mysearch` 独立接入页:前者继续渲染 `summary-strip`、switcher 与 provider workspace,后者只渲染 `renderMySearchQuickstart()`;hero 的“查看 MySearch 接入”按钮也已经改成跳转 `/mysearch`。默认首页的 `summary-strip` 口径现在只保留项目级概览:`当前工作台 / 已接通工作台 / Provider 代理 Token / 今日调用 / 本月调用 / MySearch Token`。其中 `Provider 代理 Token` 会按工作台当前是否走本地代理池动态统计,例如 Tavily 或 Social / X 切到上游后就从这项里剔除,避免再把工作台内部的上游额度、`Social Chat` 或本地 API Key 混进全局卡片。`renderMySearchQuickstart()` 现在改成纵向三层,其中“一键配置”内部再拆成左侧 `quickstart-visual-col` readiness 可视化区和右侧 `quickstart-config-col` 配置区,而且 `Proxy Base URL` 到 `Social / X` 那组摘要也已经下沉到左侧可视化区底部;`安装路径` 则继续拆成左右两列,左边是 `quickstart-install-strip`,右边是命令区。独立接入页标题已经收成 `MySearch 接入台`,模块级标题则用 `统一接入配置`,不再重复出现两层 `MySearch 快速接入`。主题切换也已经变成 `light / dark / auto` 三态;`auto` 依据浏览器本地时区与本地时间决定当前生效主题,并会在页面重新获得焦点或定时轮询时自动重算。设置页除了 `testTavilySettings()`、`testSocialSettings()` 以外,还会通过 `renderSettingsProbe()` 渲染结构化诊断卡。现在还额外加入了 `getTavilyUpstreamSummary()` 与 `getSocialUpstreamState()` 这组分层逻辑:Tavily upstream 模式优先展示 Hikari 公共摘要,Social / X 则在 `admin-auto` 时展示完整 token 统计,在只有手动上游鉴权时退化成“上游 key 数 / 客户端 token 数 / 可转发状态”的基础可视化,避免把“后台未接通”和“上游未配置”混成相同的 0 值。键盘和焦点层则新增了 `handleSegmentedControlKey()`、overlay focus remember/restore 和 `trapOverlayFocus()`,把 `mode-switch / mini-switch / settings-tab` 的方向键导航、toast live region、dialog 焦点回收收进统一逻辑。来源:proxy/static/js/console.js:16, proxy/static/js/console.js:634, proxy/static/js/console.js:797, proxy/static/js/console.js:822, proxy/static/js/console.js:1268, proxy/static/js/console.js:1695, proxy/static/js/console.js:2718, proxy/static/js/console.js:3186 | -| `proxy/static/css/console.css` | 控制台视觉系统入口 | 负责新 UI 的字体、暖色玻璃化背景、hero 装饰层、纵向 `dashboard-flow`、collapsible detail cards、settings modal 和 toast 样式;现在也包含登录页 `auth-meta`、`#dashboard.is-entering` 分段入场动画、按钮状态 `btn.is-busy / is-success / is-error`、工作台切换 reveal 的 `workspace-stage-shift / service-panel-focus-in`、横向 `service-switcher` 卡阵列、MySearch `quickstart-grid / quickstart-primary-layout / quickstart-visual-col / quickstart-config-col / quickstart-route-strip / quickstart-install-layout / quickstart-install-strip / quickstart-install-meta`、Tavily `mode-switch`、provider 筛选条 `table-tools / mini-switch`、结构化诊断卡 `settings-probe`、表格风险底色 `table-row-clickable.is-*`、键盘 focus ring、行态图例 `table-legend`、drawer 底部动作分组 `drawer-action-group`、Social 的 `credit-strip-inline`、sticky footer、`detail-drawer`、`app-dialog`,以及 `prefers-reduced-motion` 下自动压平 staged reveal / 按钮 / 切换动画的规则。当前共享样式层还统一接管了全局滚动条主题:亮/暗主题分别定义 Firefox `scrollbar-color` 与 WebKit `::-webkit-scrollbar*`,并通过 `scrollbar-gutter: stable` 减少页面因滚动条出现或消失带来的横向抖动。来源:proxy/static/css/console.css:1, proxy/static/css/console.css:69, proxy/static/css/console.css:622, proxy/static/css/console.css:717, proxy/static/css/console.css:1061, proxy/static/css/console.css:1508, proxy/static/css/console.css:1533, proxy/static/css/console.css:1891, proxy/static/css/console.css:1999, proxy/static/css/console.css:2923, proxy/static/css/console.css:2943, proxy/static/css/console.css:2976 | -| `proxy/database.py` | Proxy 持久化入口 | 管理 SQLite、key/token/usage/settings 表,以及 `mysp-` token 前缀。来源:proxy/database.py:11, proxy/database.py:61 | -| `skill/README.md` | AI 安装入口 | 告诉 Codex / Claude Code 如何从源码仓或远程 MCP 入口安装与验收 MySearch。来源:skill/README.md:40 | -| `openclaw/README.md` | OpenClaw 安装入口 | 告诉 OpenClaw/ClawHub 如何安装 bundle、注入 env、执行健康检查。来源:openclaw/README.md:53 | - -## 关键配置族 - -- `MYSEARCH_PROXY_*` - - 统一下游接入 `proxy-first` 的最小配置;`MYSEARCH_PROXY_BASE_URL` 和 `MYSEARCH_PROXY_API_KEY` 会被 `MySearchConfig.from_env()` 读入,并继续影响 Firecrawl / Exa 的默认 base URL 与 path。对 Tavily 来说,它们只会落到 `gateway` 语义,不再覆盖显式 `official` 模式。来源:README.md:178, mysearch/config.py:281, mysearch/config.py:287, mysearch/config.py:320, mysearch/config.py:421 -- `MYSEARCH_MCP_*` - - 控制 MySearch 自身的 transport、host、port、mount path、SSE path、streamable HTTP path。来源:install.sh:21, mysearch/__main__.py:12, mysearch/config.py:253 -- `MYSEARCH_TAVILY_MODE`、`MYSEARCH_TAVILY_*`、`MYSEARCH_TAVILY_GATEWAY_*` - - Tavily 的显式双分支配置。`MYSEARCH_TAVILY_MODE=official` 时走官方 Tavily key、本地 key 列表或 key 文件;`MYSEARCH_TAVILY_MODE=gateway` 时走上游 gateway token、独立 base URL/path/auth 配置,适合 `tavily-hikari` 这类上游。来源:install.sh:27, install.sh:38, mysearch/config.py:198, mysearch/config.py:220, mysearch/config.py:320, mysearch/config.py:382 -- `MYSEARCH_FIRECRAWL_*` / `MYSEARCH_EXA_*` / `MYSEARCH_XAI_*` - - Firecrawl / Exa / xAI 的直连或兼容网关配置;xAI 继续用 `official` / `compatible` 区分官方与 social gateway。来源:install.sh:47, install.sh:58, install.sh:68, mysearch/config.py:423, mysearch/config.py:462, mysearch/config.py:501 -- `ADMIN_PASSWORD`、`STATS_CACHE_TTL_SECONDS`、`DASHBOARD_*`、`SOCIAL_GATEWAY_*` - - 控制 `proxy/` 的登录、统计缓存、后台同步和 social gateway 行为。来源:proxy/README.md:130, proxy/server.py:22, proxy/server.py:43, proxy/server.py:94 -- `proxy/settings.tavily_*` - - `proxy/` 控制台侧的 Tavily 设置现在支持 `auto|pool|upstream` 三态。`auto` 不是 Social/X 的后台继承,而是按“上游凭证优先,其次本地活跃 Tavily key”解析 `effective_mode`;如果只是导入 Tavily key,默认实际就会落到 API Key 池。控制台会把 `mode`、`effective_mode`、`mode_source` 一起回填给前端。来源:proxy/server.py:273, proxy/server.py:297, proxy/server.py:311, proxy/server.py:1070 - -## 运行时行为速查 - -- `search` - - 入口在 `mysearch/server.py:47`,执行主体在 `mysearch/clients.py:385`;会先解析 intent/strategy,再按 `_route_search` 做 provider 选择。来源:mysearch/clients.py:964 -- `extract_url` - - 入口在 `mysearch/server.py:97`;默认 Firecrawl 优先,质量不够或失败时回退 Tavily。来源:mysearch/clients.py:677 -- `research` - - 入口在 `mysearch/server.py:112`;内部会并行做 web discovery、可选 social,再对前几条页面跑正文抓取。来源:mysearch/clients.py:802 -- `mysearch_health` - - 入口在 `mysearch/server.py:157`;会返回每个 provider 的 `base_url`、`paths`、`auth_mode`、`available_keys`,现在也会暴露 `provider_mode`,便于区分 Tavily 当前是 `official` 还是 `gateway`。来源:mysearch/server.py:157, mysearch/clients.py:2717 -- Proxy API - - Tavily 走 `/api/search` 和 `/api/extract`;Firecrawl 走 `/firecrawl/v2/search` 和 `/firecrawl/v2/scrape`;Exa 走 `/exa/search`;Social 走 `/social/health` 和 `/social/search`。来源:proxy/README.md:31, proxy/README.md:45, proxy/README.md:59, proxy/README.md:90, proxy/server.py:47 -- Settings Test API - - 控制台侧现在还额外暴露 `/api/settings/test/tavily` 与 `/api/settings/test/social`,给设置中心的“测试当前连接”动作使用;前者会按 Tavily 当前模式解析结果执行 live probe,后者会返回 Social / X 上游的 token 来源、后台连通性与可用凭证诊断。两条接口现在都会补 `request_target`、`auth_source`、`status_label`、`failure_reason`、`recommendation` 这类结构化字段,直接供前端渲染 probe 卡。来源:proxy/server.py:429, proxy/server.py:2259, proxy/server.py:2267 diff --git a/mysearch/clients.py b/mysearch/clients.py index 6205d47..7745339 100644 --- a/mysearch/clients.py +++ b/mysearch/clients.py @@ -14,7 +14,7 @@ from datetime import date, datetime, time as dt_time, timezone from typing import Any, Callable, Literal from urllib.error import HTTPError, URLError -from urllib.parse import urlparse +from urllib.parse import urlparse, urlunparse from urllib.request import Request, urlopen from mysearch.config import MySearchConfig, ProviderConfig @@ -2743,6 +2743,7 @@ def _build_xai_responses_payload( to_date: str | None, include_x_images: bool, include_x_videos: bool, + model: str | None = None, ) -> dict[str, Any]: tools: list[dict[str, Any]] = [] if "web" in sources: @@ -2774,7 +2775,7 @@ def _build_xai_responses_payload( augmented_query = f"{query}\n\nReturn up to {max_results} relevant results with concise sourcing." return { - "model": self.config.xai_model, + "model": (model or self.config.xai_model).strip(), "input": [ { "role": "user", @@ -3656,13 +3657,13 @@ def _request_json( provider: ProviderConfig, method: str, path: str, - payload: dict[str, Any], + payload: dict[str, Any] | None, key: str, base_url: str | None = None, timeout_seconds: int | None = None, ) -> dict[str, Any]: headers: dict[str, str] = {} - body = dict(payload) + body = dict(payload or {}) if provider.auth_mode == "bearer": token = key if not provider.auth_scheme else f"{provider.auth_scheme} {key}" @@ -3675,7 +3676,9 @@ def _request_json( url = f"{(base_url or provider.base_url)}{path}" headers.setdefault("Content-Type", "application/json") headers.setdefault("User-Agent", "MySearch/0.2") - request_body = json.dumps(body).encode("utf-8") + request_body = None + if method.upper() != "GET": + request_body = json.dumps(body).encode("utf-8") request = Request( url, data=request_body, @@ -3717,6 +3720,106 @@ def _request_json( ) return data + def _request_text( + self, + *, + url: str, + timeout_seconds: int | None = None, + ) -> tuple[int, str]: + request = Request( + url, + headers={ + "User-Agent": "MySearch/0.2", + "Accept": "text/html,application/json;q=0.9,*/*;q=0.8", + }, + method="GET", + ) + try: + with urlopen(request, timeout=timeout_seconds or self.config.timeout_seconds) as response: + return response.status, response.read().decode("utf-8", errors="replace") + except HTTPError as exc: + return exc.code, exc.read().decode("utf-8", errors="replace") + except (URLError, OSError) as exc: + raise MySearchError(str(exc)) from exc + + def _xai_probe_model(self) -> str: + return "grok-4.1-fast" + + def _derive_root_health_base_url(self, provider: ProviderConfig) -> str: + candidate = ( + provider.base_url_for("social_search") + or provider.base_url_for("social_health") + or provider.base_url + ) + parsed = urlparse(str(candidate or "").strip()) + if not parsed.scheme or not parsed.netloc: + return str(candidate or "").strip().rstrip("/") + return urlunparse((parsed.scheme, parsed.netloc, "", "", "", "")).rstrip("/") + + def _probe_xai_official_status_page(self, timeout_seconds: int) -> None: + status_url = "https://status.x.ai/" + status_code, response_text = self._request_text( + url=status_url, + timeout_seconds=timeout_seconds, + ) + if status_code >= 400: + raise MySearchHTTPError( + provider="xai", + status_code=status_code, + detail=f"status.x.ai returned HTTP {status_code}", + url=status_url, + ) + + lowered = " ".join(response_text.lower().split()) + if "all systems operational" in lowered: + return + + matches = re.findall( + r"api(?:\s*\([^)]*\))?[^a-z]{0,40}(available|operational|degraded|outage|unavailable|disrupted)", + lowered, + ) + if matches: + negative = {"degraded", "outage", "unavailable", "disrupted"} + if any(item in negative for item in matches): + raise MySearchError( + "status.x.ai reports xAI API is not fully available" + ) + return + + if "api" in lowered and "available" in lowered: + return + + raise MySearchError("unable to determine xAI API status from status.x.ai") + + def _probe_xai_official_via_responses( + self, + provider: ProviderConfig, + key: str, + timeout_seconds: int, + ) -> None: + fallback_timeout_seconds = min(self.config.timeout_seconds, 20) + self._request_json( + provider=provider, + method="POST", + path=provider.path("responses"), + payload=self._build_xai_responses_payload( + query="openai", + sources=["x"], + max_results=1, + include_domains=None, + exclude_domains=None, + allowed_x_handles=None, + excluded_x_handles=None, + from_date=None, + to_date=None, + include_x_images=False, + include_x_videos=False, + model=self._xai_probe_model(), + ), + key=key, + timeout_seconds=max(timeout_seconds, fallback_timeout_seconds), + ) + def _probe_provider_status( self, provider: ProviderConfig, @@ -3772,6 +3875,46 @@ def _probe_provider_status( } return result + def _probe_xai_compatible_gateway(self, provider: ProviderConfig, key: str, timeout_seconds: int) -> None: + health_path = "/health" + health_base_url = self._derive_root_health_base_url(provider) + try: + payload = self._request_json( + provider=provider, + method="GET", + path=health_path, + payload=None, + key=key, + base_url=health_base_url, + timeout_seconds=timeout_seconds, + ) + if isinstance(payload, dict) and payload.get("ok") is False: + detail = ( + payload.get("error") + or payload.get("detail") + or "social/X gateway health probe reported unavailable" + ) + raise MySearchError(str(detail)) + return + except (MySearchHTTPError, MySearchError): + pass + + fallback_timeout_seconds = min(self.config.timeout_seconds, 20) + self._request_json( + provider=provider, + method="POST", + path=provider.path("social_search"), + payload={ + "query": "openai", + "source": "x", + "max_results": 1, + "model": self._xai_probe_model(), + }, + key=key, + base_url=provider.base_url_for("social_search"), + timeout_seconds=fallback_timeout_seconds, + ) + def _probe_provider_request(self, provider: ProviderConfig, key: str) -> None: timeout_seconds = min(self.config.timeout_seconds, 10) if provider.name == "tavily": @@ -3819,40 +3962,24 @@ def _probe_provider_request(self, provider: ProviderConfig, key: str) -> None: return if provider.name == "xai": if provider.search_mode == "compatible": - self._request_json( + self._probe_xai_compatible_gateway(provider, key, timeout_seconds) + return + try: + self._probe_xai_official_status_page(timeout_seconds=timeout_seconds) + except MySearchError as exc: + if "not fully available" in str(exc): + raise + self._probe_xai_official_via_responses( + provider=provider, + key=key, + timeout_seconds=timeout_seconds, + ) + except MySearchHTTPError as exc: + self._probe_xai_official_via_responses( provider=provider, - method="POST", - path=provider.path("social_search"), - payload={ - "query": "openai", - "source": "x", - "max_results": 1, - }, key=key, - base_url=provider.base_url_for("social_search"), timeout_seconds=timeout_seconds, ) - return - self._request_json( - provider=provider, - method="POST", - path=provider.path("responses"), - payload=self._build_xai_responses_payload( - query="openai", - sources=["x"], - max_results=1, - include_domains=None, - exclude_domains=None, - allowed_x_handles=None, - excluded_x_handles=None, - from_date=None, - to_date=None, - include_x_images=False, - include_x_videos=False, - ), - key=key, - timeout_seconds=timeout_seconds, - ) return def _summarize_route_error(self, error_text: str) -> str: diff --git a/mysearch/config.py b/mysearch/config.py index 4af72b7..acead3f 100644 --- a/mysearch/config.py +++ b/mysearch/config.py @@ -515,11 +515,20 @@ def from_env(cls) -> "MySearchConfig": proxy_default="/social/search", default="/social/search", ), + "social_health": _provider_path( + explicit_name="MYSEARCH_XAI_SOCIAL_HEALTH_PATH", + proxy_base_url=proxy_base_url, + proxy_default="/social/health", + default="/social/health", + ), }, alternate_base_urls={ "social_search": _normalize_base_url( _get_str("MYSEARCH_XAI_SOCIAL_BASE_URL") or proxy_base_url - ) + ), + "social_health": _normalize_base_url( + _get_str("MYSEARCH_XAI_SOCIAL_BASE_URL") or proxy_base_url + ), }, search_mode=_get_str( "MYSEARCH_XAI_SEARCH_MODE", diff --git a/mysearch/social_gateway.py b/mysearch/social_gateway.py index eef0e1d..e43e929 100644 --- a/mysearch/social_gateway.py +++ b/mysearch/social_gateway.py @@ -729,15 +729,22 @@ def has_social_fallback(primary_model: str, fallback_model: str) -> bool: return bool(primary and fallback and fallback != primary) +def effective_social_fallback_threshold(min_results: int, max_results: int) -> int: + configured = max(1, int(min_results or 1)) + requested = max(1, int(max_results or 1)) + return min(configured, requested) + + def should_retry_social_with_fallback( primary_model: str, fallback_model: str, response: dict[str, Any] | None, min_results: int, + max_results: int, ) -> tuple[bool, str]: if not has_social_fallback(primary_model, fallback_model): return False, "" - threshold = max(1, int(min_results or 1)) + threshold = effective_social_fallback_threshold(min_results, max_results) if count_social_results(response) >= threshold: return False, "" return True, "result_count_below_threshold" @@ -772,6 +779,7 @@ def build_social_route_metadata( fallback_model: str, fallback_reason: str, fallback_min_results: int, + requested_max_results: int, ) -> dict[str, Any]: primary_model = attempts[0]["model"] if attempts else "" selected_model = (selected_attempt or {}).get("model") or primary_model @@ -801,7 +809,10 @@ def build_social_route_metadata( "triggered": fallback_attempted, "used": bool(fallback_attempted and selected_model == fallback_target), "reason": fallback_reason or "", - "threshold": max(1, int(fallback_min_results or 1)), + "threshold": effective_social_fallback_threshold( + fallback_min_results, + requested_max_results, + ), "from": primary_model, "to": fallback_target, "selected_model": selected_model, @@ -817,6 +828,7 @@ def attach_social_route_metadata( fallback_model: str, fallback_reason: str, fallback_min_results: int, + requested_max_results: int, ) -> dict[str, Any]: payload = dict(response or {}) tool_usage = dict(payload.get("tool_usage") or {}) @@ -829,6 +841,7 @@ def attach_social_route_metadata( fallback_model=fallback_model, fallback_reason=fallback_reason, fallback_min_results=fallback_min_results, + requested_max_results=requested_max_results, ) return payload @@ -1041,11 +1054,12 @@ async def social_search(request: Request) -> dict[str, Any]: raise HTTPException(status_code=503, detail="Missing social upstream API key") _, max_results = build_upstream_payload(body) attempts = [] + primary_model = str(body.get("model") or state["model"]).strip() or state["model"] primary_attempt = await execute_social_search_attempt( query, body, state, - state["model"], + primary_model, max_results, ) attempts.append(primary_attempt) @@ -1057,10 +1071,11 @@ async def social_search(request: Request) -> dict[str, Any]: if primary_attempt.get("ok"): selected_attempt = primary_attempt should_retry, fallback_reason = should_retry_social_with_fallback( - state["model"], + primary_model, fallback_model, primary_attempt.get("response"), fallback_min_results, + max_results, ) if should_retry: fallback_attempt = await execute_social_search_attempt( @@ -1080,9 +1095,10 @@ async def social_search(request: Request) -> dict[str, Any]: fallback_model=fallback_model, fallback_reason=fallback_reason, fallback_min_results=fallback_min_results, + requested_max_results=max_results, ) - if has_social_fallback(state["model"], fallback_model): + if has_social_fallback(primary_model, fallback_model): fallback_reason = "upstream_error" fallback_attempt = await execute_social_search_attempt( query, @@ -1100,6 +1116,7 @@ async def social_search(request: Request) -> dict[str, Any]: fallback_model=fallback_model, fallback_reason=fallback_reason, fallback_min_results=fallback_min_results, + requested_max_results=max_results, ) detail = fallback_attempt.get("error") or primary_attempt.get("error") or "Social search failed" status_code = fallback_attempt.get("status_code") or primary_attempt.get("status_code") or 502 diff --git a/openclaw/runtime/mysearch/clients.py b/openclaw/runtime/mysearch/clients.py index 35b17e5..63e6e2b 100644 --- a/openclaw/runtime/mysearch/clients.py +++ b/openclaw/runtime/mysearch/clients.py @@ -14,7 +14,7 @@ from datetime import date, datetime, time as dt_time, timezone from typing import Any, Callable, Literal from urllib.error import HTTPError, URLError -from urllib.parse import urlparse +from urllib.parse import urlparse, urlunparse from urllib.request import Request, urlopen from mysearch.config import MySearchConfig, ProviderConfig @@ -2506,6 +2506,7 @@ def _build_xai_responses_payload( to_date: str | None, include_x_images: bool, include_x_videos: bool, + model: str | None = None, ) -> dict[str, Any]: tools: list[dict[str, Any]] = [] if "web" in sources: @@ -2537,7 +2538,7 @@ def _build_xai_responses_payload( augmented_query = f"{query}\n\nReturn up to {max_results} relevant results with concise sourcing." return { - "model": self.config.xai_model, + "model": (model or self.config.xai_model).strip(), "input": [ { "role": "user", @@ -3419,13 +3420,13 @@ def _request_json( provider: ProviderConfig, method: str, path: str, - payload: dict[str, Any], + payload: dict[str, Any] | None, key: str, base_url: str | None = None, timeout_seconds: int | None = None, ) -> dict[str, Any]: headers: dict[str, str] = {} - body = dict(payload) + body = dict(payload or {}) if provider.auth_mode == "bearer": token = key if not provider.auth_scheme else f"{provider.auth_scheme} {key}" @@ -3438,7 +3439,9 @@ def _request_json( url = f"{(base_url or provider.base_url)}{path}" headers.setdefault("Content-Type", "application/json") headers.setdefault("User-Agent", "MySearch/0.2") - request_body = json.dumps(body).encode("utf-8") + request_body = None + if method.upper() != "GET": + request_body = json.dumps(body).encode("utf-8") request = Request( url, data=request_body, @@ -3480,6 +3483,106 @@ def _request_json( ) return data + def _request_text( + self, + *, + url: str, + timeout_seconds: int | None = None, + ) -> tuple[int, str]: + request = Request( + url, + headers={ + "User-Agent": "MySearch/0.2", + "Accept": "text/html,application/json;q=0.9,*/*;q=0.8", + }, + method="GET", + ) + try: + with urlopen(request, timeout=timeout_seconds or self.config.timeout_seconds) as response: + return response.status, response.read().decode("utf-8", errors="replace") + except HTTPError as exc: + return exc.code, exc.read().decode("utf-8", errors="replace") + except (URLError, OSError) as exc: + raise MySearchError(str(exc)) from exc + + def _xai_probe_model(self) -> str: + return "grok-4.1-fast" + + def _derive_root_health_base_url(self, provider: ProviderConfig) -> str: + candidate = ( + provider.base_url_for("social_search") + or provider.base_url_for("social_health") + or provider.base_url + ) + parsed = urlparse(str(candidate or "").strip()) + if not parsed.scheme or not parsed.netloc: + return str(candidate or "").strip().rstrip("/") + return urlunparse((parsed.scheme, parsed.netloc, "", "", "", "")).rstrip("/") + + def _probe_xai_official_status_page(self, timeout_seconds: int) -> None: + status_url = "https://status.x.ai/" + status_code, response_text = self._request_text( + url=status_url, + timeout_seconds=timeout_seconds, + ) + if status_code >= 400: + raise MySearchHTTPError( + provider="xai", + status_code=status_code, + detail=f"status.x.ai returned HTTP {status_code}", + url=status_url, + ) + + lowered = " ".join(response_text.lower().split()) + if "all systems operational" in lowered: + return + + matches = re.findall( + r"api(?:\s*\([^)]*\))?[^a-z]{0,40}(available|operational|degraded|outage|unavailable|disrupted)", + lowered, + ) + if matches: + negative = {"degraded", "outage", "unavailable", "disrupted"} + if any(item in negative for item in matches): + raise MySearchError( + "status.x.ai reports xAI API is not fully available" + ) + return + + if "api" in lowered and "available" in lowered: + return + + raise MySearchError("unable to determine xAI API status from status.x.ai") + + def _probe_xai_official_via_responses( + self, + provider: ProviderConfig, + key: str, + timeout_seconds: int, + ) -> None: + fallback_timeout_seconds = min(self.config.timeout_seconds, 20) + self._request_json( + provider=provider, + method="POST", + path=provider.path("responses"), + payload=self._build_xai_responses_payload( + query="openai", + sources=["x"], + max_results=1, + include_domains=None, + exclude_domains=None, + allowed_x_handles=None, + excluded_x_handles=None, + from_date=None, + to_date=None, + include_x_images=False, + include_x_videos=False, + model=self._xai_probe_model(), + ), + key=key, + timeout_seconds=max(timeout_seconds, fallback_timeout_seconds), + ) + def _probe_provider_status( self, provider: ProviderConfig, @@ -3535,6 +3638,46 @@ def _probe_provider_status( } return result + def _probe_xai_compatible_gateway(self, provider: ProviderConfig, key: str, timeout_seconds: int) -> None: + health_path = "/health" + health_base_url = self._derive_root_health_base_url(provider) + try: + payload = self._request_json( + provider=provider, + method="GET", + path=health_path, + payload=None, + key=key, + base_url=health_base_url, + timeout_seconds=timeout_seconds, + ) + if isinstance(payload, dict) and payload.get("ok") is False: + detail = ( + payload.get("error") + or payload.get("detail") + or "social/X gateway health probe reported unavailable" + ) + raise MySearchError(str(detail)) + return + except (MySearchHTTPError, MySearchError): + pass + + fallback_timeout_seconds = min(self.config.timeout_seconds, 20) + self._request_json( + provider=provider, + method="POST", + path=provider.path("social_search"), + payload={ + "query": "openai", + "source": "x", + "max_results": 1, + "model": self._xai_probe_model(), + }, + key=key, + base_url=provider.base_url_for("social_search"), + timeout_seconds=fallback_timeout_seconds, + ) + def _probe_provider_request(self, provider: ProviderConfig, key: str) -> None: timeout_seconds = min(self.config.timeout_seconds, 10) if provider.name == "tavily": @@ -3582,40 +3725,24 @@ def _probe_provider_request(self, provider: ProviderConfig, key: str) -> None: return if provider.name == "xai": if provider.search_mode == "compatible": - self._request_json( + self._probe_xai_compatible_gateway(provider, key, timeout_seconds) + return + try: + self._probe_xai_official_status_page(timeout_seconds=timeout_seconds) + except MySearchError as exc: + if "not fully available" in str(exc): + raise + self._probe_xai_official_via_responses( + provider=provider, + key=key, + timeout_seconds=timeout_seconds, + ) + except MySearchHTTPError: + self._probe_xai_official_via_responses( provider=provider, - method="POST", - path=provider.path("social_search"), - payload={ - "query": "openai", - "source": "x", - "max_results": 1, - }, key=key, - base_url=provider.base_url_for("social_search"), timeout_seconds=timeout_seconds, ) - return - self._request_json( - provider=provider, - method="POST", - path=provider.path("responses"), - payload=self._build_xai_responses_payload( - query="openai", - sources=["x"], - max_results=1, - include_domains=None, - exclude_domains=None, - allowed_x_handles=None, - excluded_x_handles=None, - from_date=None, - to_date=None, - include_x_images=False, - include_x_videos=False, - ), - key=key, - timeout_seconds=timeout_seconds, - ) return def _summarize_route_error(self, error_text: str) -> str: diff --git a/openclaw/runtime/mysearch/config.py b/openclaw/runtime/mysearch/config.py index 4af72b7..acead3f 100644 --- a/openclaw/runtime/mysearch/config.py +++ b/openclaw/runtime/mysearch/config.py @@ -515,11 +515,20 @@ def from_env(cls) -> "MySearchConfig": proxy_default="/social/search", default="/social/search", ), + "social_health": _provider_path( + explicit_name="MYSEARCH_XAI_SOCIAL_HEALTH_PATH", + proxy_base_url=proxy_base_url, + proxy_default="/social/health", + default="/social/health", + ), }, alternate_base_urls={ "social_search": _normalize_base_url( _get_str("MYSEARCH_XAI_SOCIAL_BASE_URL") or proxy_base_url - ) + ), + "social_health": _normalize_base_url( + _get_str("MYSEARCH_XAI_SOCIAL_BASE_URL") or proxy_base_url + ), }, search_mode=_get_str( "MYSEARCH_XAI_SEARCH_MODE", diff --git a/proxy/server.py b/proxy/server.py index 74e410f..1356165 100644 --- a/proxy/server.py +++ b/proxy/server.py @@ -1968,10 +1968,16 @@ def has_social_fallback(primary_model, fallback_model): return bool(primary and fallback and fallback != primary) -def should_retry_social_with_fallback(primary_model, fallback_model, response, min_results): +def effective_social_fallback_threshold(min_results, max_results): + configured = max(1, int(min_results or 1)) + requested = max(1, int(max_results or 1)) + return min(configured, requested) + + +def should_retry_social_with_fallback(primary_model, fallback_model, response, min_results, max_results): if not has_social_fallback(primary_model, fallback_model): return False, "" - threshold = max(1, int(min_results or 1)) + threshold = effective_social_fallback_threshold(min_results, max_results) if count_social_results(response) >= threshold: return False, "" return True, "result_count_below_threshold" @@ -2003,6 +2009,7 @@ def build_social_route_metadata( fallback_model, fallback_reason, fallback_min_results, + requested_max_results, ): primary_model = attempts[0]["model"] if attempts else "" selected_model = (selected_attempt or {}).get("model") or primary_model @@ -2032,7 +2039,10 @@ def build_social_route_metadata( "triggered": fallback_attempted, "used": bool(fallback_attempted and selected_model == fallback_target), "reason": fallback_reason or "", - "threshold": max(1, int(fallback_min_results or 1)), + "threshold": effective_social_fallback_threshold( + fallback_min_results, + requested_max_results, + ), "from": primary_model, "to": fallback_target, "selected_model": selected_model, @@ -2048,6 +2058,7 @@ def attach_social_route_metadata( fallback_model, fallback_reason, fallback_min_results, + requested_max_results, ): payload = dict(response or {}) tool_usage = dict(payload.get("tool_usage") or {}) @@ -2060,6 +2071,7 @@ def attach_social_route_metadata( fallback_model=fallback_model, fallback_reason=fallback_reason, fallback_min_results=fallback_min_results, + requested_max_results=requested_max_results, ) return payload @@ -2352,6 +2364,10 @@ async def proxy_exa_search(request: Request): @app.get("/social/health") async def social_health(): state = await resolve_social_gateway_state(force=False) + return _build_social_health_payload(state) + + +def _build_social_health_payload(state): return { "ok": bool(state["resolved_upstream_api_key"] and state["accepted_tokens"]), "mode": state["mode"], @@ -2372,6 +2388,14 @@ async def social_health(): } +@app.get("/health") +async def health(): + state = await resolve_social_gateway_state(force=False) + payload = _build_social_health_payload(state) + payload["service"] = "proxy" + return payload + + @app.post("/social/search") async def proxy_social_search(request: Request): body = await request.json() @@ -2394,11 +2418,12 @@ async def proxy_social_search(request: Request): max_results = max(1, min(int(body.get("max_results") or 5), 10)) attempts = [] + primary_model = str(body.get("model") or state["model"]).strip() or state["model"] primary_attempt = await execute_social_search_attempt( query, body, state, - state["model"], + primary_model, max_results, ) attempts.append(primary_attempt) @@ -2410,10 +2435,11 @@ async def proxy_social_search(request: Request): if primary_attempt.get("ok"): selected_attempt = primary_attempt should_retry, fallback_reason = should_retry_social_with_fallback( - state["model"], + primary_model, fallback_model, primary_attempt.get("response"), fallback_min_results, + max_results, ) if should_retry: fallback_attempt = await execute_social_search_attempt( @@ -2433,9 +2459,10 @@ async def proxy_social_search(request: Request): fallback_model=fallback_model, fallback_reason=fallback_reason, fallback_min_results=fallback_min_results, + requested_max_results=max_results, ) - if has_social_fallback(state["model"], fallback_model): + if has_social_fallback(primary_model, fallback_model): fallback_reason = "upstream_error" fallback_attempt = await execute_social_search_attempt( query, @@ -2453,6 +2480,7 @@ async def proxy_social_search(request: Request): fallback_model=fallback_model, fallback_reason=fallback_reason, fallback_min_results=fallback_min_results, + requested_max_results=max_results, ) detail = fallback_attempt.get("error") or primary_attempt.get("error") or "Social search failed" status_code = fallback_attempt.get("status_code") or primary_attempt.get("status_code") or 502 diff --git a/tests/test_clients.py b/tests/test_clients.py index d253755..6d18857 100644 --- a/tests/test_clients.py +++ b/tests/test_clients.py @@ -11,7 +11,7 @@ if str(REPO_ROOT) not in sys.path: sys.path.insert(0, str(REPO_ROOT)) -from mysearch.clients import MySearchClient, MySearchHTTPError, RouteDecision +from mysearch.clients import MySearchClient, MySearchError, MySearchHTTPError, RouteDecision class _FakeResponse: @@ -89,6 +89,105 @@ def test_health_reports_live_auth_error(self) -> None: self.assertIn("deactivated", payload["providers"]["tavily"]["live_error"]) self.assertEqual(payload["providers"]["firecrawl"]["live_status"], "ok") + def test_xai_compatible_health_probe_uses_root_health_endpoint(self) -> None: + client = MySearchClient() + provider = client.config.xai + provider.search_mode = "compatible" + provider.default_paths["social_search"] = "/social/search" + provider.default_paths["social_health"] = "/social/health" + provider.alternate_base_urls["social_search"] = "http://gateway.example/v1" + provider.alternate_base_urls["social_health"] = "http://gateway.example/v1" + calls: list[dict[str, object]] = [] + + def fake_request_json(**kwargs): # type: ignore[no-untyped-def] + calls.append(kwargs) + return {"status": "ok"} + + client._request_json = fake_request_json # type: ignore[method-assign] + + client._probe_provider_request(provider, "gateway-token") + + self.assertEqual(len(calls), 1) + self.assertEqual(calls[0]["method"], "GET") + self.assertEqual(calls[0]["path"], "/health") + self.assertEqual(calls[0]["base_url"], "http://gateway.example") + + def test_xai_compatible_health_probe_falls_back_when_root_health_missing(self) -> None: + client = MySearchClient() + provider = client.config.xai + provider.search_mode = "compatible" + provider.default_paths["social_search"] = "/social/search" + provider.default_paths["social_health"] = "/social/health" + provider.alternate_base_urls["social_search"] = "http://gateway.example/admin?foo=1" + provider.alternate_base_urls["social_health"] = "http://gateway.example/admin?foo=1" + calls: list[dict[str, object]] = [] + + def fake_request_json(**kwargs): # type: ignore[no-untyped-def] + calls.append(kwargs) + if kwargs["path"] == "/health": + raise MySearchHTTPError( + provider="xai", + status_code=404, + detail="not found", + url="http://gateway.example/health", + ) + return {"provider": "custom_social", "results": [{"url": "https://x.com/openai/status/1"}]} + + client._request_json = fake_request_json # type: ignore[method-assign] + + client._probe_provider_request(provider, "gateway-token") + + self.assertEqual(len(calls), 2) + self.assertEqual(calls[0]["method"], "GET") + self.assertEqual(calls[0]["path"], "/health") + self.assertEqual(calls[1]["method"], "POST") + self.assertEqual(calls[1]["path"], "/social/search") + self.assertEqual(calls[1]["payload"]["max_results"], 1) + self.assertEqual(calls[1]["payload"]["model"], "grok-4.1-fast") + + def test_xai_official_health_probe_uses_status_page(self) -> None: + client = MySearchClient() + provider = client.config.xai + provider.search_mode = "official" + calls: list[dict[str, object]] = [] + + def fake_request_text(**kwargs): # type: ignore[no-untyped-def] + calls.append(kwargs) + return 200, "API (us-east-1.api.x.ai) available" + + client._request_text = fake_request_text # type: ignore[method-assign] + + client._probe_provider_request(provider, "official-key") + + self.assertEqual(len(calls), 1) + self.assertEqual(calls[0]["url"], "https://status.x.ai/") + + def test_xai_official_health_probe_falls_back_to_fast_responses_when_status_check_fails(self) -> None: + client = MySearchClient() + provider = client.config.xai + provider.search_mode = "official" + provider.default_paths["responses"] = "/responses" + text_calls: list[dict[str, object]] = [] + json_calls: list[dict[str, object]] = [] + + def fake_request_text(**kwargs): # type: ignore[no-untyped-def] + text_calls.append(kwargs) + raise MySearchError("unable to determine xAI API status from status.x.ai") + + def fake_request_json(**kwargs): # type: ignore[no-untyped-def] + json_calls.append(kwargs) + return {"id": "resp_123", "status": "completed"} + + client._request_text = fake_request_text # type: ignore[method-assign] + client._request_json = fake_request_json # type: ignore[method-assign] + + client._probe_provider_request(provider, "official-key") + + self.assertEqual(len(text_calls), 1) + self.assertEqual(len(json_calls), 1) + self.assertEqual(json_calls[0]["path"], "/responses") + self.assertEqual(json_calls[0]["payload"]["model"], "grok-4.1-fast") + def test_github_blob_raw_urls_try_common_branch_aliases(self) -> None: client = MySearchClient() diff --git a/tests/test_social_normalization.py b/tests/test_social_normalization.py index a920534..df1786c 100644 --- a/tests/test_social_normalization.py +++ b/tests/test_social_normalization.py @@ -238,10 +238,56 @@ async def test_low_result_count_triggers_fallback_and_prefers_better_model(self) self.assertEqual(result["tool_usage"]["social_search_calls"], 2) self.assertEqual(result["tool_usage"]["model"], "grok-4.1-fast") self.assertEqual(result["route"]["selected_model"], "grok-4.1-fast") - self.assertTrue(result["route"]["fallback"]["triggered"]) - self.assertTrue(result["route"]["fallback"]["used"]) - self.assertEqual(result["route"]["fallback"]["reason"], "result_count_below_threshold") - self.assertEqual(len(result["results"]), 3) + + async def test_requested_model_overrides_primary_model(self) -> None: + primary = _payload( + text='{"answer":"custom","results":[{"url":"https://x.com/mcp/status/1904234567890123456","text":"one"}]}', + citations=[{"url": "https://x.com/mcp/status/1904234567890123456", "title": "one"}], + ) + + for module in (social_gateway, proxy_server): + fake_client = _FakeHttpClient([_FakeResponse(200, primary)]) + request = _FakeRequest( + { + "query": "Model Context Protocol", + "source": "x", + "max_results": 1, + "model": "grok-4.20-beta-latest-non-reasoning", + } + ) + original_http_client = module.http_client + + if module is social_gateway: + original_resolve = module.resolve_gateway_state + original_verify = module.verify_gateway_token + module.resolve_gateway_state = _fake_gateway_state # type: ignore[assignment] + module.verify_gateway_token = lambda token, accepted_tokens: None # type: ignore[assignment] + route = module.social_search + else: + original_resolve = module.resolve_social_gateway_state + original_verify = module.verify_social_gateway_token + module.resolve_social_gateway_state = _fake_proxy_state # type: ignore[assignment] + module.verify_social_gateway_token = lambda token, accepted_tokens: None # type: ignore[assignment] + route = module.proxy_social_search + + module.http_client = fake_client + try: + result = await route(request) + finally: + module.http_client = original_http_client + if module is social_gateway: + module.resolve_gateway_state = original_resolve # type: ignore[assignment] + module.verify_gateway_token = original_verify # type: ignore[assignment] + else: + module.resolve_social_gateway_state = original_resolve # type: ignore[assignment] + module.verify_social_gateway_token = original_verify # type: ignore[assignment] + + self.assertEqual(fake_client.calls[0]["json"]["model"], "grok-4.20-beta-latest-non-reasoning") + self.assertEqual(result["route"]["selected_model"], "grok-4.20-beta-latest-non-reasoning") + self.assertFalse(result["route"]["fallback"]["triggered"]) + self.assertFalse(result["route"]["fallback"]["used"]) + self.assertEqual(result["route"]["fallback"]["reason"], "") + self.assertEqual(len(result["results"]), 1) async def test_enough_results_keeps_primary_model(self) -> None: primary = _payload( @@ -265,12 +311,29 @@ async def test_enough_results_keeps_primary_model(self) -> None: self.assertFalse(result["route"]["fallback"]["triggered"]) self.assertFalse(result["route"]["fallback"]["used"]) + async def test_max_results_one_does_not_force_fallback(self) -> None: + primary = _payload( + text='{"answer":"mini","results":[{"url":"https://x.com/mcp/status/1906234567890123456","text":"one"}]}', + citations=[{"url": "https://x.com/mcp/status/1906234567890123456", "title": "one"}], + ) + + for module in (social_gateway, proxy_server): + result, client = await self._run_route( + module, + responses=[_FakeResponse(200, primary)], + max_results=1, + ) + self.assertEqual(len(client.calls), 1) + self.assertEqual(result["tool_usage"]["social_search_calls"], 1) + self.assertFalse(result["route"]["fallback"]["triggered"]) + self.assertEqual(result["route"]["fallback"]["threshold"], 1) + async def test_upstream_error_falls_back_to_secondary_model(self) -> None: fallback = _payload( - text='{"answer":"fast","results":[{"url":"https://x.com/mcp/status/1906234567890123456","text":"one"},{"url":"https://x.com/openai/status/1906234567890123457","text":"two"}]}', + text='{"answer":"fast","results":[{"url":"https://x.com/mcp/status/1907234567890123456","text":"one"},{"url":"https://x.com/openai/status/1907234567890123457","text":"two"}]}', citations=[ - {"url": "https://x.com/mcp/status/1906234567890123456", "title": "one"}, - {"url": "https://x.com/openai/status/1906234567890123457", "title": "two"}, + {"url": "https://x.com/mcp/status/1907234567890123456", "title": "one"}, + {"url": "https://x.com/openai/status/1907234567890123457", "title": "two"}, ], ) From 85884cf1f6cc7044abb9350d78233b8c89a66acc Mon Sep 17 00:00:00 2001 From: zhaishujie <3394180966@qq.com> Date: Mon, 23 Mar 2026 14:29:47 +0800 Subject: [PATCH 19/20] merge upstream search fallback and error handling --- mysearch/clients.py | 161 +++++++-- openclaw/SKILL.md | 2 +- openclaw/runtime/mysearch/clients.py | 459 +++++++++++++++++++++++--- openclaw/scripts/mysearch_openclaw.py | 2 +- scripts/check_sync.sh | 33 ++ skill/SKILL.md | 2 +- 6 files changed, 577 insertions(+), 82 deletions(-) create mode 100644 scripts/check_sync.sh diff --git a/mysearch/clients.py b/mysearch/clients.py index 7745339..d6cc1ff 100644 --- a/mysearch/clients.py +++ b/mysearch/clients.py @@ -634,33 +634,21 @@ def search( include_domains=include_domains, exclude_domains=exclude_domains, ) - elif decision.provider == "tavily": - result = self._search_tavily( + elif decision.provider in {"tavily", "firecrawl", "exa"}: + result, fallback_info = self._search_with_fallback( + primary_provider=decision.provider, query=query, max_results=candidate_max_results, - topic=decision.tavily_topic, + mode=mode, + intent=resolved_intent, + decision=decision, include_answer=effective_include_answer, include_content=include_content, include_domains=include_domains, exclude_domains=exclude_domains, ) - elif decision.provider == "firecrawl": - result = self._search_firecrawl( - query=query, - max_results=candidate_max_results, - categories=decision.firecrawl_categories or [], - include_content=include_content or mode in {"docs", "research", "github", "pdf"}, - include_domains=include_domains, - exclude_domains=exclude_domains, - ) - elif decision.provider == "exa": - result = self._search_exa( - query=query, - max_results=candidate_max_results, - include_domains=include_domains, - exclude_domains=exclude_domains, - include_content=include_content, - ) + if fallback_info: + result["fallback"] = fallback_info elif decision.provider == "xai": result = self._search_xai( query=query, @@ -1664,6 +1652,101 @@ def _should_blend_web_providers( self.config.firecrawl ) + _SEARCH_FALLBACK_CHAIN: dict[str, list[str]] = { + "tavily": ["exa", "firecrawl"], + "firecrawl": ["exa", "tavily"], + "exa": ["firecrawl", "tavily"], + } + + def _search_with_fallback( + self, + *, + primary_provider: str, + query: str, + max_results: int, + mode: SearchMode, + intent: ResolvedSearchIntent, + decision: RouteDecision, + include_answer: bool, + include_content: bool, + include_domains: list[str] | None, + exclude_domains: list[str] | None, + ) -> tuple[dict[str, Any], dict[str, Any] | None]: + chain = [primary_provider, *self._SEARCH_FALLBACK_CHAIN.get(primary_provider, [])] + last_error: Exception | None = None + for provider_name in chain: + try: + result = self._dispatch_single_provider( + provider_name=provider_name, + query=query, + max_results=max_results, + mode=mode, + intent=intent, + decision=decision, + include_answer=include_answer, + include_content=include_content, + include_domains=include_domains, + exclude_domains=exclude_domains, + ) + fallback_info = None + if provider_name != primary_provider: + fallback_info = { + "from": primary_provider, + "to": provider_name, + "reason": str(last_error)[:200] if last_error else "primary provider failed", + } + return result, fallback_info + except MySearchError as exc: + last_error = exc + continue + except Exception as exc: + last_error = MySearchError(f"{provider_name}: {exc}") + continue + raise MySearchError(f"All providers failed for query '{query[:80]}': {last_error}") + + def _dispatch_single_provider( + self, + *, + provider_name: str, + query: str, + max_results: int, + mode: SearchMode, + intent: ResolvedSearchIntent, + decision: RouteDecision, + include_answer: bool, + include_content: bool, + include_domains: list[str] | None, + exclude_domains: list[str] | None, + ) -> dict[str, Any]: + if provider_name == "tavily": + return self._search_tavily( + query=query, + max_results=max_results, + topic=decision.tavily_topic, + include_answer=include_answer, + include_content=include_content, + include_domains=include_domains, + exclude_domains=exclude_domains, + ) + if provider_name == "firecrawl": + return self._search_firecrawl( + query=query, + max_results=max_results, + categories=decision.firecrawl_categories or self._firecrawl_categories(mode, intent), + include_content=include_content or mode in {"docs", "research", "github", "pdf"}, + include_domains=include_domains, + exclude_domains=exclude_domains, + ) + if provider_name == "exa": + return self._search_exa( + query=query, + max_results=max_results, + include_domains=include_domains, + exclude_domains=exclude_domains, + include_content=include_content, + ) + raise MySearchError(f"Unknown provider: {provider_name}") + def _search_web_blended( self, *, @@ -1720,10 +1803,29 @@ def _search_web_blended( } blended_results, blended_errors = self._execute_parallel(tasks, max_workers=2) - self._raise_parallel_error(blended_errors, "primary") - primary_result = blended_results["primary"] - secondary_result = blended_results.get("secondary") - secondary_error = str(blended_errors["secondary"]) if "secondary" in blended_errors else "" + primary_failed = "primary" in blended_errors + secondary_failed = "secondary" in blended_errors + + if primary_failed and not secondary_failed: + primary_result = blended_results["secondary"] + primary_result["fallback"] = { + "from": decision.provider, + "to": primary_result.get("provider", "unknown"), + "reason": str(blended_errors["primary"])[:200], + } + secondary_result = None + secondary_error = "" + elif primary_failed and secondary_failed: + primary_err = str(blended_errors["primary"])[:150] + secondary_err = str(blended_errors["secondary"])[:150] + raise MySearchError( + f"Blended search failed: primary ({decision.provider}): {primary_err}; " + f"secondary: {secondary_err}" + ) + else: + primary_result = blended_results["primary"] + secondary_result = blended_results.get("secondary") + secondary_error = str(blended_errors["secondary"]) if secondary_failed else "" merged = self._merge_search_payloads( primary_result=primary_result, @@ -2888,7 +2990,9 @@ def _parse_date_bound(self, value: str, *, end_of_day: bool) -> datetime | None: try: parsed = date.fromisoformat(value) except ValueError: - return None + raise MySearchError( + f"Invalid date format: '{value}'. Use ISO format YYYY-MM-DD." + ) bound_time = dt_time.max if end_of_day else dt_time.min return datetime.combine(parsed, bound_time).replace(tzinfo=timezone.utc) @@ -3693,8 +3797,13 @@ def _request_json( except HTTPError as exc: status_code = exc.code response_text = exc.read().decode("utf-8", errors="replace") + except TimeoutError as exc: + effective_timeout = timeout_seconds or self.config.timeout_seconds + raise MySearchError( + f"{provider.name} request timeout after {effective_timeout}s: {url}" + ) from exc except (URLError, OSError) as exc: - raise MySearchError(str(exc)) from exc + raise MySearchError(f"{provider.name} network error: {exc}") from exc try: data = json.loads(response_text) diff --git a/openclaw/SKILL.md b/openclaw/SKILL.md index 1b2aee6..ee32c10 100644 --- a/openclaw/SKILL.md +++ b/openclaw/SKILL.md @@ -1,6 +1,6 @@ --- name: mysearch -version: "0.1.11" +version: "0.1.13" description: >- DEFAULT search skill for OpenClaw. Aggregates Tavily, Firecrawl, Exa, and optional X/social search behind one search path. Use for ANY external lookup that needs diff --git a/openclaw/runtime/mysearch/clients.py b/openclaw/runtime/mysearch/clients.py index 63e6e2b..d6cc1ff 100644 --- a/openclaw/runtime/mysearch/clients.py +++ b/openclaw/runtime/mysearch/clients.py @@ -618,6 +618,9 @@ def search( decision=decision, sources=normalized_sources, strategy=resolved_strategy, + mode=mode, + intent=resolved_intent, + include_domains=include_domains, ): result = self._search_web_blended( query=query, @@ -631,33 +634,21 @@ def search( include_domains=include_domains, exclude_domains=exclude_domains, ) - elif decision.provider == "tavily": - result = self._search_tavily( + elif decision.provider in {"tavily", "firecrawl", "exa"}: + result, fallback_info = self._search_with_fallback( + primary_provider=decision.provider, query=query, max_results=candidate_max_results, - topic=decision.tavily_topic, + mode=mode, + intent=resolved_intent, + decision=decision, include_answer=effective_include_answer, include_content=include_content, include_domains=include_domains, exclude_domains=exclude_domains, ) - elif decision.provider == "firecrawl": - result = self._search_firecrawl( - query=query, - max_results=candidate_max_results, - categories=decision.firecrawl_categories or [], - include_content=include_content or mode in {"docs", "research", "github", "pdf"}, - include_domains=include_domains, - exclude_domains=exclude_domains, - ) - elif decision.provider == "exa": - result = self._search_exa( - query=query, - max_results=candidate_max_results, - include_domains=include_domains, - exclude_domains=exclude_domains, - include_content=include_content, - ) + if fallback_info: + result["fallback"] = fallback_info elif decision.provider == "xai": result = self._search_xai( query=query, @@ -1362,7 +1353,7 @@ def _route_search( return RouteDecision( provider="tavily", reason="显式指定 Tavily", - tavily_topic="news" if mode == "news" else "general", + tavily_topic="news" if mode == "news" or intent in {"news", "status"} else "general", ) if provider == "firecrawl": return RouteDecision( @@ -1515,16 +1506,26 @@ def _route_search( ) if mode == "research": - if not self._provider_can_serve(self.config.tavily) and self._provider_can_serve( - self.config.exa - ): + if self._provider_can_serve(self.config.tavily): + return RouteDecision( + provider="tavily", + reason="research 模式先用 Tavily 做发现,再按策略决定是否扩展验证", + tavily_topic="general", + ) + if self._provider_can_serve(self.config.exa): return RouteDecision( provider="exa", reason="Tavily 未配置,research 发现阶段回退到 Exa", ) + if self._provider_can_serve(self.config.firecrawl): + return RouteDecision( + provider="firecrawl", + reason="Tavily / Exa 未配置,research 发现阶段回退到 Firecrawl", + firecrawl_categories=self._firecrawl_categories(mode, intent), + ) return RouteDecision( provider="tavily", - reason="research 模式先用 Tavily 做发现,再按策略决定是否扩展验证", + reason="research 模式默认走 Tavily(无可用替代)", tavily_topic="general", ) @@ -1629,6 +1630,9 @@ def _should_blend_web_providers( decision: RouteDecision, sources: list[str], strategy: SearchStrategy, + mode: SearchMode = "auto", + intent: ResolvedSearchIntent = "factual", + include_domains: list[str] | None = None, ) -> bool: if requested_provider != "auto": return False @@ -1638,10 +1642,111 @@ def _should_blend_web_providers( return False if "x" in sources: return False + if include_domains: + return False + if mode in {"docs", "github", "pdf"}: + return False + if intent in {"resource", "tutorial"}: + return False return self._provider_can_serve(self.config.tavily) and self._provider_can_serve( self.config.firecrawl ) + _SEARCH_FALLBACK_CHAIN: dict[str, list[str]] = { + "tavily": ["exa", "firecrawl"], + "firecrawl": ["exa", "tavily"], + "exa": ["firecrawl", "tavily"], + } + + def _search_with_fallback( + self, + *, + primary_provider: str, + query: str, + max_results: int, + mode: SearchMode, + intent: ResolvedSearchIntent, + decision: RouteDecision, + include_answer: bool, + include_content: bool, + include_domains: list[str] | None, + exclude_domains: list[str] | None, + ) -> tuple[dict[str, Any], dict[str, Any] | None]: + chain = [primary_provider, *self._SEARCH_FALLBACK_CHAIN.get(primary_provider, [])] + last_error: Exception | None = None + for provider_name in chain: + try: + result = self._dispatch_single_provider( + provider_name=provider_name, + query=query, + max_results=max_results, + mode=mode, + intent=intent, + decision=decision, + include_answer=include_answer, + include_content=include_content, + include_domains=include_domains, + exclude_domains=exclude_domains, + ) + fallback_info = None + if provider_name != primary_provider: + fallback_info = { + "from": primary_provider, + "to": provider_name, + "reason": str(last_error)[:200] if last_error else "primary provider failed", + } + return result, fallback_info + except MySearchError as exc: + last_error = exc + continue + except Exception as exc: + last_error = MySearchError(f"{provider_name}: {exc}") + continue + raise MySearchError(f"All providers failed for query '{query[:80]}': {last_error}") + + def _dispatch_single_provider( + self, + *, + provider_name: str, + query: str, + max_results: int, + mode: SearchMode, + intent: ResolvedSearchIntent, + decision: RouteDecision, + include_answer: bool, + include_content: bool, + include_domains: list[str] | None, + exclude_domains: list[str] | None, + ) -> dict[str, Any]: + if provider_name == "tavily": + return self._search_tavily( + query=query, + max_results=max_results, + topic=decision.tavily_topic, + include_answer=include_answer, + include_content=include_content, + include_domains=include_domains, + exclude_domains=exclude_domains, + ) + if provider_name == "firecrawl": + return self._search_firecrawl( + query=query, + max_results=max_results, + categories=decision.firecrawl_categories or self._firecrawl_categories(mode, intent), + include_content=include_content or mode in {"docs", "research", "github", "pdf"}, + include_domains=include_domains, + exclude_domains=exclude_domains, + ) + if provider_name == "exa": + return self._search_exa( + query=query, + max_results=max_results, + include_domains=include_domains, + exclude_domains=exclude_domains, + include_content=include_content, + ) + raise MySearchError(f"Unknown provider: {provider_name}") + def _search_web_blended( self, *, @@ -1698,10 +1803,29 @@ def _search_web_blended( } blended_results, blended_errors = self._execute_parallel(tasks, max_workers=2) - self._raise_parallel_error(blended_errors, "primary") - primary_result = blended_results["primary"] - secondary_result = blended_results.get("secondary") - secondary_error = str(blended_errors["secondary"]) if "secondary" in blended_errors else "" + primary_failed = "primary" in blended_errors + secondary_failed = "secondary" in blended_errors + + if primary_failed and not secondary_failed: + primary_result = blended_results["secondary"] + primary_result["fallback"] = { + "from": decision.provider, + "to": primary_result.get("provider", "unknown"), + "reason": str(blended_errors["primary"])[:200], + } + secondary_result = None + secondary_error = "" + elif primary_failed and secondary_failed: + primary_err = str(blended_errors["primary"])[:150] + secondary_err = str(blended_errors["secondary"])[:150] + raise MySearchError( + f"Blended search failed: primary ({decision.provider}): {primary_err}; " + f"secondary: {secondary_err}" + ) + else: + primary_result = blended_results["primary"] + secondary_result = blended_results.get("secondary") + secondary_error = str(blended_errors["secondary"]) if secondary_failed else "" merged = self._merge_search_payloads( primary_result=primary_result, @@ -1751,6 +1875,55 @@ def _search_tavily( include_content: bool, include_domains: list[str] | None, exclude_domains: list[str] | None, + ) -> dict[str, Any]: + include_domains = [item.strip() for item in (include_domains or []) if item and item.strip()] + exclude_domains = [item.strip() for item in (exclude_domains or []) if item and item.strip()] + + response = self._search_tavily_once( + query=query, + max_results=max_results, + topic=topic, + include_answer=include_answer, + include_content=include_content, + include_domains=include_domains, + exclude_domains=exclude_domains, + ) + if response.get("results") or not include_domains: + return response + + retry_response = self._search_tavily_domain_retry( + query=query, + max_results=max_results, + topic=topic, + include_content=include_content, + include_domains=include_domains, + exclude_domains=exclude_domains, + ) + if retry_response is not None: + return retry_response + + fallback_response = self._search_tavily_domain_fallback( + query=query, + max_results=max_results, + include_content=include_content, + include_domains=include_domains, + exclude_domains=exclude_domains, + ) + if fallback_response is not None: + return fallback_response + + return response + + def _search_tavily_once( + self, + *, + query: str, + max_results: int, + topic: str, + include_answer: bool, + include_content: bool, + include_domains: list[str] | None, + exclude_domains: list[str] | None, ) -> dict[str, Any]: provider = self.config.tavily key = self._get_key_or_raise(provider) @@ -1774,6 +1947,23 @@ def _search_tavily( payload=payload, key=key.key, ) + results = [ + { + "provider": "tavily", + "source": "web", + "title": item.get("title", ""), + "url": item.get("url", ""), + "snippet": item.get("content", ""), + "content": item.get("raw_content", "") if include_content else "", + "score": item.get("score"), + } + for item in response.get("results", []) + ] + filtered_results = self._filter_results_by_domains( + results, + include_domains=include_domains, + exclude_domains=exclude_domains, + ) return { "provider": "tavily", "transport": key.source, @@ -1781,25 +1971,174 @@ def _search_tavily( "answer": response.get("answer", ""), "request_id": response.get("request_id", ""), "response_time": response.get("response_time"), - "results": [ - { - "provider": "tavily", - "source": "web", - "title": item.get("title", ""), - "url": item.get("url", ""), - "snippet": item.get("content", ""), - "content": item.get("raw_content", "") if include_content else "", - "score": item.get("score"), - } - for item in response.get("results", []) - ], + "results": filtered_results, "citations": [ {"title": item.get("title", ""), "url": item.get("url", "")} - for item in response.get("results", []) + for item in filtered_results if item.get("url") ], } + def _search_tavily_domain_retry( + self, + *, + query: str, + max_results: int, + topic: str, + include_content: bool, + include_domains: list[str], + exclude_domains: list[str] | None, + ) -> dict[str, Any] | None: + per_domain_results = [] + retried_domains: list[str] = [] + for domain in include_domains: + domain_result = self._search_tavily_once( + query=self._build_firecrawl_domain_query( + query=query, + include_domain=domain, + exclude_domains=exclude_domains, + ), + max_results=max_results, + topic=topic, + include_answer=False, + include_content=include_content, + include_domains=None, + exclude_domains=exclude_domains, + ) + filtered_results = self._filter_results_by_domains( + domain_result.get("results", []), + include_domains=[domain], + exclude_domains=exclude_domains, + ) + if not filtered_results: + continue + domain_result = dict(domain_result) + domain_result["results"] = filtered_results + domain_result["citations"] = self._align_citations_with_results( + results=filtered_results, + citations=list(domain_result.get("citations") or []), + ) + per_domain_results.append(domain_result) + retried_domains.append(domain) + + if not per_domain_results: + return None + + merged_results = self._merge_ranked_results( + [result.get("results", []) for result in per_domain_results], + max_results=max_results, + ) + citations = self._align_citations_with_results( + results=merged_results, + citations=self._dedupe_citations( + *[result.get("citations", []) for result in per_domain_results] + ), + ) + return { + "provider": "tavily", + "transport": per_domain_results[0].get("transport", "env"), + "query": query, + "answer": "", + "request_id": "", + "response_time": None, + "results": merged_results, + "citations": citations, + "route_debug": { + "domain_filter_mode": "site_query_retry", + "retried_include_domains": retried_domains, + }, + } + + def _search_tavily_domain_fallback( + self, + *, + query: str, + max_results: int, + include_content: bool, + include_domains: list[str], + exclude_domains: list[str] | None, + ) -> dict[str, Any] | None: + if not self._provider_can_serve(self.config.firecrawl): + return None + + categories = ( + self._firecrawl_categories("docs", "resource") + if self._looks_like_docs_query(query.lower()) or self._looks_like_tutorial_query(query.lower()) + else [] + ) + per_domain_results = [] + citations = [] + seen_urls: set[str] = set() + for domain in include_domains: + domain_result = self._search_firecrawl_once( + query=self._build_firecrawl_domain_query( + query=query, + include_domain=domain, + exclude_domains=exclude_domains, + ), + max_results=max_results, + categories=categories, + include_content=include_content, + ) + if not domain_result.get("results"): + retry_result = self._search_firecrawl_domain_retry( + query=query, + max_results=max_results, + categories=categories, + include_content=include_content, + include_domain=domain, + exclude_domains=exclude_domains, + ) + if retry_result is not None: + domain_result = retry_result + per_domain_results.append(domain_result) + for item in domain_result.get("results", []): + url = item.get("url", "") + if not url or url in seen_urls: + continue + seen_urls.add(url) + citations.append({"title": item.get("title", ""), "url": url}) + + merged_results = self._merge_ranked_results( + [result.get("results", []) for result in per_domain_results], + max_results=max_results, + ) + if not merged_results: + return None + + return { + "provider": "hybrid", + "route_selected": "tavily+firecrawl", + "query": query, + "answer": "", + "results": merged_results, + "citations": citations[:max_results], + "primary_search": { + "provider": "tavily", + "query": query, + "results": [], + "citations": [], + }, + "secondary_search": { + "provider": "firecrawl", + "query": query, + "results": merged_results, + "citations": citations[:max_results], + }, + "secondary_error": "", + "evidence": { + "providers_consulted": ["tavily", "firecrawl"], + "matched_results": 0, + "citation_count": len(citations[:max_results]), + "verification": "fallback", + }, + "fallback": { + "from": "tavily", + "to": "firecrawl", + "reason": "tavily returned 0 results for domain-filtered search", + }, + } + def _search_firecrawl( self, *, @@ -2651,7 +2990,9 @@ def _parse_date_bound(self, value: str, *, end_of_day: bool) -> datetime | None: try: parsed = date.fromisoformat(value) except ValueError: - return None + raise MySearchError( + f"Invalid date format: '{value}'. Use ISO format YYYY-MM-DD." + ) bound_time = dt_time.max if end_of_day else dt_time.min return datetime.combine(parsed, bound_time).replace(tzinfo=timezone.utc) @@ -3456,8 +3797,13 @@ def _request_json( except HTTPError as exc: status_code = exc.code response_text = exc.read().decode("utf-8", errors="replace") + except TimeoutError as exc: + effective_timeout = timeout_seconds or self.config.timeout_seconds + raise MySearchError( + f"{provider.name} request timeout after {effective_timeout}s: {url}" + ) from exc except (URLError, OSError) as exc: - raise MySearchError(str(exc)) from exc + raise MySearchError(f"{provider.name} network error: {exc}") from exc try: data = json.loads(response_text) @@ -3737,7 +4083,7 @@ def _probe_provider_request(self, provider: ProviderConfig, key: str) -> None: key=key, timeout_seconds=timeout_seconds, ) - except MySearchHTTPError: + except MySearchHTTPError as exc: self._probe_xai_official_via_responses( provider=provider, key=key, @@ -3875,18 +4221,27 @@ def _firecrawl_categories( return [] def _looks_like_news_query(self, query_lower: str) -> bool: - keywords = [ + # 中文关键词:直接 substring 匹配 + cn_keywords = ["刚刚", "最新", "新闻", "动态"] + if any(kw in query_lower for kw in cn_keywords): + return True + # 英文关键词:排除常见技术搭配的误判 + # "breaking changes" / "latest version" 等不是新闻查询 + tech_negatives = [ + "breaking change", "breaking update", + "latest version", "latest release", "latest docs", + "latest commit", "latest tag", + ] + if any(neg in query_lower for neg in tech_negatives): + return False + en_keywords = [ "latest", "breaking", "news", "today", "this week", - "刚刚", - "最新", - "新闻", - "动态", ] - return any(keyword in query_lower for keyword in keywords) + return any(keyword in query_lower for keyword in en_keywords) def _looks_like_status_query(self, query_lower: str) -> bool: keywords = [ @@ -3937,13 +4292,11 @@ def _looks_like_docs_query(self, query_lower: str) -> bool: "documentation", "api reference", "changelog", - "pricing", "readme", "github", "manual", "文档", "接口", - "价格", "更新日志", ] return any(keyword in query_lower for keyword in keywords) diff --git a/openclaw/scripts/mysearch_openclaw.py b/openclaw/scripts/mysearch_openclaw.py index 9a78b79..b52a480 100755 --- a/openclaw/scripts/mysearch_openclaw.py +++ b/openclaw/scripts/mysearch_openclaw.py @@ -241,7 +241,7 @@ def _render_extract(payload: dict[str, Any]) -> str: if payload.get("fallback"): fallback = payload["fallback"] lines.append( - f"- fallback: `{fallback.get('from', '')}` -> `{payload.get('provider', '')}` ({fallback.get('reason', '')})" + f"- fallback: `{fallback.get('from', '')}` -> `{fallback.get('to', '')}` ({fallback.get('reason', '')})" ) lines.extend(["", "## Content", "", payload.get("content", "").strip() or "(empty)"]) return "\n".join(lines).strip() diff --git a/scripts/check_sync.sh b/scripts/check_sync.sh new file mode 100644 index 0000000..1398004 --- /dev/null +++ b/scripts/check_sync.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Verify mysearch/ runtime files are in sync with openclaw/runtime/mysearch/ +set -euo pipefail + +BASE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SRC="$BASE/mysearch" +DST="$BASE/openclaw/runtime/mysearch" + +FILES=(clients.py config.py keyring.py __init__.py) +EXIT=0 + +for f in "${FILES[@]}"; do + if [[ ! -f "$SRC/$f" ]]; then + continue + fi + if [[ ! -f "$DST/$f" ]]; then + echo "MISSING: $DST/$f" + EXIT=1 + continue + fi + if ! diff -q "$SRC/$f" "$DST/$f" >/dev/null 2>&1; then + echo "DESYNC: $f" + EXIT=1 + fi +done + +if [[ $EXIT -eq 0 ]]; then + echo "OK: all runtime files in sync" +else + echo "" + echo "Fix: cp mysearch/{clients,config,keyring,__init__}.py openclaw/runtime/mysearch/" + exit 1 +fi diff --git a/skill/SKILL.md b/skill/SKILL.md index 15f9e6d..86325d6 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -1,6 +1,6 @@ --- name: mysearch -version: "0.1.11" +version: "0.1.13" description: >- Install, verify, debug, and use MySearch MCP/Skill. Aggregates Tavily, Firecrawl, Exa, and X search (via xAI) behind one workflow. Use when the user From a991af96077212a5fd05a629537833a77449f6ae Mon Sep 17 00:00:00 2001 From: zhaishujie <3394180966@qq.com> Date: Mon, 23 Mar 2026 17:43:36 +0800 Subject: [PATCH 20/20] tighten provider routing and research budgets --- mysearch/README.md | 24 +- mysearch/clients.py | 783 +++++++++++++++++++++------ openclaw/runtime/mysearch/clients.py | 783 +++++++++++++++++++++------ skill/SKILL.md | 12 +- tests/test_clients.py | 143 ++++- tests/test_comprehensive.py | 29 +- 6 files changed, 1406 insertions(+), 368 deletions(-) diff --git a/mysearch/README.md b/mysearch/README.md index 25b0e5e..111c341 100644 --- a/mysearch/README.md +++ b/mysearch/README.md @@ -309,14 +309,24 @@ MySearch 不是单一 provider 的壳。 - 优先 Tavily - `docs / github / pdf` - 优先 Firecrawl -- 补充网页发现 - - 可回退 Exa +- `pricing / changelog / 官方文档` + - 仍按 Firecrawl / 官方结果优先处理,不为凑数默认混入第三方页面 +- 补充网页发现 / 长尾资料 + - Exa 只做补位,不做默认主搜 - `social` - 走 xAI 或 compatible `/social/search` - `extract_url` - Firecrawl 优先,Tavily 回退 - `research` - - 搜索 + 抓取 + 可选 social 补充 + - 一轮小 research:搜索发现 + 正文抓取 + 可选 social 补充 + +补充约束: + +- `web` 与 `news` 使用不同排序口径: + - `web` 更看官方性、页面相关性 + - `news` 更看时效、媒体质量与事件一致性 +- `official / 官方 / 官网`、`docs / pricing / changelog` 一类查询会进入更严格的官方结果模式;如果官方域结果不足,会明确说明,而不是默认拿第三方结果补齐 +- Exa 只在 Tavily / Firecrawl 结果不足、长尾语义查询或显式 fallback 场景下介入 ## Intent 和 Strategy @@ -341,13 +351,13 @@ MySearch 不是单一 provider 的壳。 适合记忆的简单规则: - 想快一点: - - `fast` + - `fast`:单 provider,最小候选池 - 想稳一点: - - `balanced` + - `balanced`:主 provider 为主,按模式补少量候选 - 想多做交叉验证: - - `verify` + - `verify`:扩大候选池并交叉验证,必要时启用 Exa 补位 - 想做小研究: - - `deep` + - `deep`:更偏 `research` 的较大候选池与更多正文抓取 ## 关键环境变量 diff --git a/mysearch/clients.py b/mysearch/clients.py index d6cc1ff..4189631 100644 --- a/mysearch/clients.py +++ b/mysearch/clients.py @@ -106,6 +106,80 @@ class RouteDecision: tavily_topic: str = "general" firecrawl_categories: list[str] | None = None sources: list[str] | None = None + fallback_chain: list[str] | None = None + result_profile: Literal["off", "web", "news", "resource"] = "off" + allow_exa_rescue: bool = False + + +@dataclass(slots=True) +class SearchRoutePolicy: + key: str + provider: str + fallback_chain: tuple[str, ...] = () + tavily_topic: str = "general" + firecrawl_categories: tuple[str, ...] = () + result_profile: Literal["off", "web", "news", "resource"] = "off" + allow_exa_rescue: bool = False + + +_MODE_PROVIDER_POLICY: dict[str, SearchRoutePolicy] = { + "web": SearchRoutePolicy( + key="web", + provider="tavily", + fallback_chain=("exa", "firecrawl"), + result_profile="web", + allow_exa_rescue=True, + ), + "news": SearchRoutePolicy( + key="news", + provider="tavily", + fallback_chain=("exa",), + tavily_topic="news", + result_profile="news", + allow_exa_rescue=True, + ), + "docs": SearchRoutePolicy( + key="docs", + provider="firecrawl", + fallback_chain=("tavily", "exa"), + firecrawl_categories=("research",), + result_profile="resource", + ), + "github": SearchRoutePolicy( + key="github", + provider="firecrawl", + fallback_chain=("exa", "tavily"), + firecrawl_categories=("github",), + result_profile="resource", + ), + "pdf": SearchRoutePolicy( + key="pdf", + provider="firecrawl", + fallback_chain=("tavily", "exa"), + firecrawl_categories=("pdf",), + result_profile="resource", + ), + "content": SearchRoutePolicy( + key="content", + provider="firecrawl", + fallback_chain=("tavily", "exa"), + result_profile="resource", + ), + "resource": SearchRoutePolicy( + key="resource", + provider="firecrawl", + fallback_chain=("tavily", "exa"), + firecrawl_categories=("research",), + result_profile="resource", + ), + "research": SearchRoutePolicy( + key="research", + provider="tavily", + fallback_chain=("exa", "firecrawl"), + result_profile="web", + allow_exa_rescue=True, + ), +} class MySearchClient: @@ -666,6 +740,24 @@ def search( else: raise MySearchError(f"Unsupported route decision: {decision.provider}") + if self._should_attempt_exa_rescue( + query=query, + mode=mode, + intent=resolved_intent, + decision=decision, + result=result, + max_results=max_results, + include_domains=include_domains, + ): + result = self._apply_exa_rescue( + query=query, + primary_result=result, + max_results=candidate_max_results, + include_domains=include_domains, + exclude_domains=exclude_domains, + include_content=include_content, + ) + if self._should_rerank_resource_results(mode=mode, intent=resolved_intent): reranked_results = self._rerank_resource_results( query=query, @@ -678,6 +770,18 @@ def search( results=reranked_results, citations=list(result.get("citations") or []), ) + elif self._should_rerank_general_results(result_profile=decision.result_profile): + reranked_results = self._rerank_general_results( + query=query, + result_profile=decision.result_profile, + results=list(result.get("results") or []), + include_domains=include_domains, + ) + result["results"] = reranked_results + result["citations"] = self._align_citations_with_results( + results=reranked_results, + citations=list(result.get("citations") or []), + ) result = self._apply_official_resource_policy( query=query, mode=mode, @@ -889,17 +993,39 @@ def research( query = query.strip() if not query: raise MySearchError("query must not be empty") - - web_mode = "news" if mode == "news" else ("docs" if mode in {"docs", "github", "pdf"} else "web") + resolved_intent = self._resolve_intent( + query=query, + mode=mode, + intent=intent, + sources=["web"], + ) + resolved_strategy = self._resolve_strategy( + mode=mode, + intent=resolved_intent, + strategy=strategy, + sources=["web"], + include_content=False, + ) + research_plan = self._resolve_research_plan( + query=query, + mode=mode, + intent=resolved_intent, + strategy=resolved_strategy, + web_max_results=web_max_results, + social_max_results=social_max_results, + scrape_top_n=scrape_top_n, + include_social=include_social, + include_domains=include_domains, + ) research_tasks: dict[str, Callable[[], Any]] = { "web": lambda: self.search( query=query, - mode=web_mode, - intent=intent, - strategy=strategy, + mode=research_plan["web_mode"], + intent=resolved_intent, + strategy=resolved_strategy, provider="auto", sources=["web"], - max_results=web_max_results, + max_results=research_plan["web_max_results"], include_content=False, include_answer=True, include_domains=include_domains, @@ -913,7 +1039,7 @@ def research( intent="status", provider="auto", sources=["x"], - max_results=social_max_results, + max_results=research_plan["social_max_results"], allowed_x_handles=allowed_x_handles, excluded_x_handles=excluded_x_handles, from_date=from_date, @@ -937,7 +1063,7 @@ def research( if not url or url in urls: continue urls.append(url) - if len(urls) >= scrape_top_n: + if len(urls) >= research_plan["scrape_top_n"]: break pages: list[dict[str, Any]] = [] @@ -991,13 +1117,14 @@ def research( social=social, social_error=social_error, providers_consulted=providers_consulted, + research_plan=research_plan, ) return { "provider": "hybrid", "query": query, - "intent": web_search.get("intent", intent if intent != "auto" else "factual"), - "strategy": web_search.get("strategy", strategy if strategy != "auto" else "fast"), + "intent": web_search.get("intent", resolved_intent), + "strategy": web_search.get("strategy", resolved_strategy), "web_search": web_search, "pages": pages, "social_search": social, @@ -1010,6 +1137,50 @@ def research( ], } + def _resolve_research_plan( + self, + *, + query: str, + mode: SearchMode, + intent: ResolvedSearchIntent, + strategy: SearchStrategy, + web_max_results: int, + social_max_results: int, + scrape_top_n: int, + include_social: bool, + include_domains: list[str] | None, + ) -> dict[str, Any]: + web_mode = "news" if mode == "news" else ("docs" if mode in {"docs", "github", "pdf"} else "web") + planned_web_max = web_max_results + planned_social_max = social_max_results if include_social else 0 + planned_scrape_top_n = scrape_top_n + + if mode in {"docs", "github", "pdf"} or self._should_use_strict_resource_policy( + query=query, + mode=mode, + intent=intent, + include_domains=include_domains, + ): + planned_web_max = max(planned_web_max, 4) + planned_scrape_top_n = max(1, min(planned_scrape_top_n, 2)) + elif mode == "news" or intent in {"news", "status"}: + planned_web_max = min(max(planned_web_max, 6), 8) + planned_scrape_top_n = min(max(planned_scrape_top_n, 4), 5) + if include_social: + planned_social_max = min(max(planned_social_max, 4), 6) + elif intent in {"comparison", "exploratory"} or strategy in {"verify", "deep"}: + planned_web_max = min(max(planned_web_max, 6), 10) + planned_scrape_top_n = min(max(planned_scrape_top_n, 4), 5) + if include_social: + planned_social_max = min(max(planned_social_max, 3), 5) + + return { + "web_mode": web_mode, + "web_max_results": planned_web_max, + "social_max_results": planned_social_max, + "scrape_top_n": planned_scrape_top_n, + } + def _candidate_result_budget( self, *, @@ -1127,14 +1298,40 @@ def _resolve_official_result_mode( intent: ResolvedSearchIntent, include_domains: list[str] | None, ) -> str: - if include_domains: - return "strict" - if self._looks_like_official_query(query): + if self._should_use_strict_resource_policy( + query=query, + mode=mode, + intent=intent, + include_domains=include_domains, + ): return "strict" if self._should_rerank_resource_results(mode=mode, intent=intent): return "standard" return "off" + def _should_use_strict_resource_policy( + self, + *, + query: str, + mode: SearchMode, + intent: ResolvedSearchIntent, + include_domains: list[str] | None, + ) -> bool: + query_lower = query.lower() + if include_domains: + return True + if mode in {"docs", "github", "pdf"}: + return True + if self._looks_like_official_query(query): + return True + if self._looks_like_pricing_query(query_lower): + return True + if self._looks_like_changelog_query(query_lower): + return True + if intent in {"resource", "tutorial"} and self._looks_like_docs_query(query_lower): + return True + return False + def _looks_like_official_query(self, query: str) -> bool: query_lower = query.lower() if re.search(r"\bofficial\b", query_lower): @@ -1150,6 +1347,33 @@ def _looks_like_official_query(self, query: str) -> bool: ) return any(marker in query for marker in official_markers) + def _looks_like_pricing_query(self, query_lower: str) -> bool: + keywords = [ + "price", + "pricing", + "plans", + "subscription", + "费用", + "套餐", + "定价", + "价格", + "售价", + ] + return any(keyword in query_lower for keyword in keywords) + + def _looks_like_changelog_query(self, query_lower: str) -> bool: + keywords = [ + "changelog", + "release notes", + "what's new", + "whats new", + "更新日志", + "发布说明", + "变更日志", + "版本更新", + ] + return any(keyword in query_lower for keyword in keywords) + def _apply_official_resource_policy( self, *, @@ -1229,6 +1453,7 @@ def _augment_research_evidence( social: dict[str, Any] | None, social_error: str, providers_consulted: list[str], + research_plan: dict[str, Any], ) -> dict[str, Any]: successful_pages = [page for page in pages if not page.get("error")] page_error_count = max(len(pages) - len(successful_pages), 0) @@ -1284,6 +1509,7 @@ def _augment_research_evidence( "search_confidence": str(web_evidence.get("confidence") or "low"), "confidence": confidence, "conflicts": conflicts, + "research_plan": research_plan, } def _estimate_research_confidence( @@ -1346,31 +1572,54 @@ def _route_search( excluded_x_handles: list[str] | None, ) -> RouteDecision: normalized_sources = sorted(set(sources or ["web"])) - query_lower = query.lower() + policy = self._route_policy_for_request( + query=query, + mode=mode, + intent=intent, + include_content=include_content, + ) if provider != "auto": if provider == "tavily": return RouteDecision( provider="tavily", reason="显式指定 Tavily", - tavily_topic="news" if mode == "news" or intent in {"news", "status"} else "general", + tavily_topic=policy.tavily_topic, + fallback_chain=self._explicit_provider_fallback_chain( + provider=provider, + policy=policy, + ), + result_profile=policy.result_profile, + allow_exa_rescue=policy.allow_exa_rescue and policy.provider == "tavily", ) if provider == "firecrawl": return RouteDecision( provider="firecrawl", reason="显式指定 Firecrawl", - firecrawl_categories=self._firecrawl_categories(mode, intent), + firecrawl_categories=list(policy.firecrawl_categories) + or self._firecrawl_categories(mode, intent), + fallback_chain=self._explicit_provider_fallback_chain( + provider=provider, + policy=policy, + ), + result_profile=policy.result_profile, ) if provider == "exa": return RouteDecision( provider="exa", reason="显式指定 Exa", + fallback_chain=self._explicit_provider_fallback_chain( + provider=provider, + policy=policy, + ), + result_profile=policy.result_profile, ) if provider == "xai": return RouteDecision( provider="xai", reason="显式指定 xAI/X 搜索", sources=normalized_sources, + result_profile="off", ) if normalized_sources == ["web", "x"] or ( @@ -1383,6 +1632,7 @@ def _route_search( provider="xai", reason="社交舆情 / X 搜索更适合走 xAI", sources=["x"], + result_profile="off", ) if allowed_x_handles or excluded_x_handles: @@ -1390,158 +1640,23 @@ def _route_search( provider="xai", reason="检测到 X handle 过滤条件", sources=["x"], + result_profile="off", ) - - if ( - include_domains - and self._domains_prefer_firecrawl_discovery(include_domains) - and self._provider_can_serve(self.config.firecrawl) - and ( - mode in {"docs", "github", "pdf"} - or intent in {"resource", "tutorial"} - or self._looks_like_docs_query(query_lower) - ) - ): - return RouteDecision( - provider="firecrawl", - reason="检测到受限 / 社区域名,优先用 Firecrawl 做站内发现", - firecrawl_categories=self._firecrawl_categories( - "docs" if mode not in {"github", "pdf"} else mode, - intent, - ), - ) - - if mode in {"docs", "github", "pdf"}: - if include_content: - if not self._provider_can_serve(self.config.firecrawl) and self._provider_can_serve( - self.config.exa - ): - return RouteDecision( - provider="exa", - reason="Firecrawl 未配置,文档正文查询回退到 Exa", - ) - return RouteDecision( - provider="firecrawl", - reason="文档正文查询优先走 Firecrawl", - firecrawl_categories=self._firecrawl_categories(mode, intent), - ) - if self._provider_can_serve(self.config.tavily): - return RouteDecision( - provider="tavily", - reason="文档类查询先用 Tavily 做官方页面发现,正文再交给 Firecrawl", - tavily_topic="general", - ) - if not self._provider_can_serve(self.config.firecrawl) and self._provider_can_serve( - self.config.exa - ): - return RouteDecision( - provider="exa", - reason="Firecrawl 未配置,文档类查询回退到 Exa", - ) - return RouteDecision( - provider="firecrawl", - reason="文档 / GitHub / PDF 内容优先走 Firecrawl", - firecrawl_categories=self._firecrawl_categories(mode, intent), - ) - - if include_content: - if not self._provider_can_serve(self.config.firecrawl) and self._provider_can_serve( - self.config.exa - ): - return RouteDecision( - provider="exa", - reason="Firecrawl 未配置,正文查询回退到 Exa", - ) - return RouteDecision( - provider="firecrawl", - reason="请求里需要正文内容,优先用 Firecrawl search + scrape", - firecrawl_categories=self._firecrawl_categories(mode, intent), - ) - - if intent in {"news", "status"} or mode == "news" or self._looks_like_news_query(query_lower): - if not self._provider_can_serve(self.config.tavily) and self._provider_can_serve( - self.config.exa - ): - return RouteDecision( - provider="exa", - reason="Tavily 未配置,新闻 / 状态类查询回退到 Exa", - ) - return RouteDecision( - provider="tavily", - reason="状态 / 新闻类查询默认走 Tavily", - tavily_topic="news", - ) - - if intent == "resource" or self._looks_like_docs_query(query_lower): - if include_content: - if not self._provider_can_serve(self.config.firecrawl) and self._provider_can_serve( - self.config.exa - ): - return RouteDecision( - provider="exa", - reason="Firecrawl 未配置,resource 正文查询回退到 Exa", - ) - return RouteDecision( - provider="firecrawl", - reason="resource / docs 正文查询优先走 Firecrawl", - firecrawl_categories=self._firecrawl_categories("docs", intent), - ) - if self._provider_can_serve(self.config.tavily): - return RouteDecision( - provider="tavily", - reason="resource / docs 查询先用 Tavily 做页面发现,正文再交给 Firecrawl", - tavily_topic="general", - ) - if not self._provider_can_serve(self.config.firecrawl) and self._provider_can_serve( - self.config.exa - ): - return RouteDecision( - provider="exa", - reason="Firecrawl 未配置,resource / docs 类查询回退到 Exa", - ) - return RouteDecision( - provider="firecrawl", - reason="resource / docs 类查询优先走 Firecrawl", - firecrawl_categories=self._firecrawl_categories("docs", intent), - ) - - if mode == "research": - if self._provider_can_serve(self.config.tavily): - return RouteDecision( - provider="tavily", - reason="research 模式先用 Tavily 做发现,再按策略决定是否扩展验证", - tavily_topic="general", - ) - if self._provider_can_serve(self.config.exa): - return RouteDecision( - provider="exa", - reason="Tavily 未配置,research 发现阶段回退到 Exa", - ) - if self._provider_can_serve(self.config.firecrawl): - return RouteDecision( - provider="firecrawl", - reason="Tavily / Exa 未配置,research 发现阶段回退到 Firecrawl", - firecrawl_categories=self._firecrawl_categories(mode, intent), - ) - return RouteDecision( - provider="tavily", - reason="research 模式默认走 Tavily(无可用替代)", - tavily_topic="general", - ) - - if not self._provider_can_serve(self.config.tavily) and self._provider_can_serve( - self.config.exa - ): - return RouteDecision( - provider="exa", - reason="Tavily 未配置,普通网页检索回退到 Exa", - ) - - return RouteDecision( - provider="tavily", - reason="普通网页检索默认走 Tavily", - tavily_topic="general", - ) + if policy.key in {"docs", "resource"} and include_domains and self._domains_prefer_firecrawl_discovery(include_domains): + reason = "检测到受限 / 社区域名,优先用 Firecrawl 做站内发现" + elif policy.key in {"docs", "github", "pdf"}: + reason = "文档 / GitHub / PDF 默认走 Firecrawl,页面发现与正文抓取保持一致" + elif policy.key == "content": + reason = "请求里需要正文内容,优先走 Firecrawl" + elif policy.key == "news": + reason = "状态 / 新闻类查询默认走 Tavily" + elif policy.key == "resource": + reason = "resource / docs 查询默认走 Firecrawl" + elif policy.key == "research": + reason = "research 发现阶段默认走 Tavily" + else: + reason = "普通网页检索默认走 Tavily" + return self._decision_from_policy(policy=policy, reason=reason) def _domains_prefer_firecrawl_discovery(self, include_domains: list[str] | None) -> bool: if not include_domains: @@ -1623,6 +1738,81 @@ def _resolve_strategy( return "balanced" return "fast" + def _route_policy_for_request( + self, + *, + query: str, + mode: SearchMode, + intent: ResolvedSearchIntent, + include_content: bool, + ) -> SearchRoutePolicy: + query_lower = query.lower() + if mode == "research": + return _MODE_PROVIDER_POLICY["research"] + if include_content: + return _MODE_PROVIDER_POLICY["content"] + if mode in {"docs", "github", "pdf"}: + return _MODE_PROVIDER_POLICY[mode] + if intent in {"resource", "tutorial"} or self._looks_like_docs_query(query_lower): + return _MODE_PROVIDER_POLICY["resource"] + if intent in {"news", "status"} or mode == "news" or self._looks_like_news_query(query_lower): + return _MODE_PROVIDER_POLICY["news"] + return _MODE_PROVIDER_POLICY["web"] + + def _decision_from_policy( + self, + *, + policy: SearchRoutePolicy, + reason: str, + sources: list[str] | None = None, + ) -> RouteDecision: + provider, fallback_chain = self._resolve_available_policy_chain(policy=policy) + return RouteDecision( + provider=provider, + reason=reason, + tavily_topic=policy.tavily_topic, + firecrawl_categories=list(policy.firecrawl_categories) or None, + sources=sources, + fallback_chain=fallback_chain, + result_profile=policy.result_profile, + allow_exa_rescue=policy.allow_exa_rescue, + ) + + def _resolve_available_policy_chain( + self, + *, + policy: SearchRoutePolicy, + ) -> tuple[ProviderName, list[str] | None]: + ordered: list[ProviderName] = [policy.provider, *policy.fallback_chain] + available: list[ProviderName] = [] + for provider_name in ordered: + config = self._provider_config_for_name(provider_name) + if self._provider_can_serve(config): + available.append(provider_name) + if not available: + return policy.provider, list(policy.fallback_chain) or None + return available[0], list(available[1:]) or None + + def _provider_config_for_name(self, provider_name: ProviderName) -> ProviderConfig: + if provider_name == "tavily": + return self.config.tavily + if provider_name == "firecrawl": + return self.config.firecrawl + if provider_name == "exa": + return self.config.exa + return self.config.xai + + def _explicit_provider_fallback_chain( + self, + *, + provider: ProviderName, + policy: SearchRoutePolicy, + ) -> list[str] | None: + if provider == "xai": + return None + chain = [item for item in policy.fallback_chain if item != provider] + return list(chain) or None + def _should_blend_web_providers( self, *, @@ -1642,6 +1832,8 @@ def _should_blend_web_providers( return False if "x" in sources: return False + if mode == "news" or intent in {"news", "status"}: + return False if include_domains: return False if mode in {"docs", "github", "pdf"}: @@ -1652,12 +1844,6 @@ def _should_blend_web_providers( self.config.firecrawl ) - _SEARCH_FALLBACK_CHAIN: dict[str, list[str]] = { - "tavily": ["exa", "firecrawl"], - "firecrawl": ["exa", "tavily"], - "exa": ["firecrawl", "tavily"], - } - def _search_with_fallback( self, *, @@ -1672,7 +1858,7 @@ def _search_with_fallback( include_domains: list[str] | None, exclude_domains: list[str] | None, ) -> tuple[dict[str, Any], dict[str, Any] | None]: - chain = [primary_provider, *self._SEARCH_FALLBACK_CHAIN.get(primary_provider, [])] + chain = [primary_provider, *(decision.fallback_chain or [])] last_error: Exception | None = None for provider_name in chain: try: @@ -1747,6 +1933,246 @@ def _dispatch_single_provider( ) raise MySearchError(f"Unknown provider: {provider_name}") + def _should_attempt_exa_rescue( + self, + *, + query: str, + mode: SearchMode, + intent: ResolvedSearchIntent, + decision: RouteDecision, + result: dict[str, Any], + max_results: int, + include_domains: list[str] | None, + ) -> bool: + if not decision.allow_exa_rescue: + return False + if not self._provider_can_serve(self.config.exa): + return False + if result.get("provider") in {"exa", "xai"}: + return False + if result.get("fallback"): + return False + if include_domains or self._resolve_official_result_mode( + query=query, + mode=mode, + intent=intent, + include_domains=include_domains, + ) == "strict": + return False + results = list(result.get("results") or []) + if len(results) >= min(max_results, 3): + return False + query_terms = re.findall(r"[a-z0-9\u4e00-\u9fff]+", query.lower()) + long_tail_signal = len(query_terms) >= 6 or len(query) >= 48 + return mode == "news" or intent in {"comparison", "exploratory", "tutorial"} or long_tail_signal + + def _apply_exa_rescue( + self, + *, + query: str, + primary_result: dict[str, Any], + max_results: int, + include_domains: list[str] | None, + exclude_domains: list[str] | None, + include_content: bool, + ) -> dict[str, Any]: + exa_result = self._search_exa( + query=query, + max_results=max_results, + include_domains=include_domains, + exclude_domains=exclude_domains, + include_content=include_content, + ) + if not exa_result.get("results"): + return primary_result + + merged = self._merge_search_payloads( + primary_result=primary_result, + secondary_result=exa_result, + max_results=max_results, + ) + return { + "provider": "hybrid", + "route_selected": f"{primary_result.get('provider', 'unknown')}+exa", + "query": query, + "answer": primary_result.get("answer") or exa_result.get("answer", ""), + "results": merged["results"], + "citations": merged["citations"], + "evidence": { + "providers_consulted": [ + item + for item in [primary_result.get("provider"), exa_result.get("provider")] + if item + ], + "matched_results": merged["matched_results"], + "citation_count": len(merged["citations"]), + "verification": "fallback", + }, + "primary_search": primary_result, + "secondary_search": exa_result, + "secondary_error": "", + "fallback": { + "from": primary_result.get("provider", "unknown"), + "to": "exa", + "reason": "primary provider returned sparse results; Exa rescue engaged", + }, + } + + def _should_rerank_general_results( + self, + *, + result_profile: str, + ) -> bool: + return result_profile in {"web", "news"} + + def _rerank_general_results( + self, + *, + query: str, + result_profile: Literal["web", "news"], + results: list[dict[str, Any]], + include_domains: list[str] | None, + ) -> list[dict[str, Any]]: + if len(results) < 2: + return results + ranked = sorted( + enumerate(results), + key=lambda pair: ( + self._general_result_rank( + query=query, + result_profile=result_profile, + item=pair[1], + include_domains=include_domains, + ), + -pair[0], + ), + reverse=True, + ) + return [dict(pair[1]) for pair in ranked] + + def _general_result_rank( + self, + *, + query: str, + result_profile: Literal["web", "news"], + item: dict[str, Any], + include_domains: list[str] | None, + ) -> tuple[int, int, int, int, int, int, int, int]: + if result_profile == "news": + return self._news_result_rank(item=item, include_domains=include_domains) + return self._web_result_rank( + query=query, + item=item, + include_domains=include_domains, + ) + + def _news_result_rank( + self, + *, + item: dict[str, Any], + include_domains: list[str] | None, + ) -> tuple[int, int, int, int, int, int, int, int]: + hostname = self._result_hostname(item) + include_match = int( + bool(include_domains) + and any(self._domain_matches(hostname, domain) for domain in include_domains or []) + ) + mainstream = int(self._is_mainstream_news_domain(hostname)) + article_shape = int(self._looks_like_news_article_result(item)) + has_timestamp = int(self._result_published_timestamp(item) is not None) + timestamp_score = int(self._result_published_timestamp(item) or 0) + content_score, snippet_score, title_score = self._result_quality_score(item) + return ( + include_match, + mainstream, + article_shape, + has_timestamp, + timestamp_score, + content_score, + snippet_score, + title_score, + ) + + def _web_result_rank( + self, + *, + query: str, + item: dict[str, Any], + include_domains: list[str] | None, + ) -> tuple[int, int, int, int, int, int, int, int]: + hostname = self._result_hostname(item) + registered_domain = self._registered_domain(hostname) + title_text = (item.get("title") or "").lower() + query_tokens = self._query_brand_tokens(query) + include_match = int( + bool(include_domains) + and any(self._domain_matches(hostname, domain) for domain in include_domains or []) + ) + registered_domain_label_match = int( + self._registered_domain_label_matches( + registered_domain=registered_domain, + query_tokens=query_tokens, + ) + ) + host_brand_match = int(any(token in hostname for token in query_tokens)) + title_brand_match = int(any(token in title_text for token in query_tokens)) + non_aggregator = int(not self._is_obvious_web_aggregator(registered_domain)) + matched_provider_count = len(item.get("matched_providers") or []) + content_score, snippet_score, title_score = self._result_quality_score(item) + return ( + include_match, + registered_domain_label_match, + host_brand_match, + title_brand_match, + non_aggregator, + matched_provider_count, + content_score, + max(snippet_score, title_score), + ) + + def _result_published_timestamp(self, item: dict[str, Any]) -> float | None: + for field in ("published_date", "publishedDate", "created_at"): + parsed = self._parse_result_timestamp(item.get(field)) + if parsed is not None: + return parsed.timestamp() + return None + + def _is_mainstream_news_domain(self, hostname: str) -> bool: + registered_domain = self._registered_domain(hostname) + mainstream_domains = { + "apnews.com", + "bbc.com", + "bloomberg.com", + "cnn.com", + "ft.com", + "latimes.com", + "nytimes.com", + "reuters.com", + "theguardian.com", + "theverge.com", + "washingtonpost.com", + "wsj.com", + "xinhuanet.com", + } + return registered_domain in mainstream_domains + + def _looks_like_news_article_result(self, item: dict[str, Any]) -> bool: + path = urlparse(item.get("url", "")).path.lower() + return any( + marker in path + for marker in ("/news/", "/story/", "/stories/", "/article/", "/articles/", "/202") + ) + + def _is_obvious_web_aggregator(self, registered_domain: str) -> bool: + return registered_domain in { + "linkedin.com", + "medium.com", + "quora.com", + "reddit.com", + "researchgate.net", + "stackoverflow.com", + } + def _search_web_blended( self, *, @@ -1956,6 +2382,11 @@ def _search_tavily_once( "snippet": item.get("content", ""), "content": item.get("raw_content", "") if include_content else "", "score": item.get("score"), + "published_date": item.get("published_date") + or item.get("publishedDate") + or item.get("published_at") + or item.get("publishedAt") + or "", } for item in response.get("results", []) ] @@ -2366,6 +2797,10 @@ def _search_firecrawl_once( "url": item.get("url", ""), "snippet": item.get("description", "") or item.get("markdown", ""), "content": item.get("markdown", "") if include_content else "", + "published_date": item.get("publishedDate") + or item.get("published_date") + or item.get("published_at") + or "", } ) diff --git a/openclaw/runtime/mysearch/clients.py b/openclaw/runtime/mysearch/clients.py index d6cc1ff..4189631 100644 --- a/openclaw/runtime/mysearch/clients.py +++ b/openclaw/runtime/mysearch/clients.py @@ -106,6 +106,80 @@ class RouteDecision: tavily_topic: str = "general" firecrawl_categories: list[str] | None = None sources: list[str] | None = None + fallback_chain: list[str] | None = None + result_profile: Literal["off", "web", "news", "resource"] = "off" + allow_exa_rescue: bool = False + + +@dataclass(slots=True) +class SearchRoutePolicy: + key: str + provider: str + fallback_chain: tuple[str, ...] = () + tavily_topic: str = "general" + firecrawl_categories: tuple[str, ...] = () + result_profile: Literal["off", "web", "news", "resource"] = "off" + allow_exa_rescue: bool = False + + +_MODE_PROVIDER_POLICY: dict[str, SearchRoutePolicy] = { + "web": SearchRoutePolicy( + key="web", + provider="tavily", + fallback_chain=("exa", "firecrawl"), + result_profile="web", + allow_exa_rescue=True, + ), + "news": SearchRoutePolicy( + key="news", + provider="tavily", + fallback_chain=("exa",), + tavily_topic="news", + result_profile="news", + allow_exa_rescue=True, + ), + "docs": SearchRoutePolicy( + key="docs", + provider="firecrawl", + fallback_chain=("tavily", "exa"), + firecrawl_categories=("research",), + result_profile="resource", + ), + "github": SearchRoutePolicy( + key="github", + provider="firecrawl", + fallback_chain=("exa", "tavily"), + firecrawl_categories=("github",), + result_profile="resource", + ), + "pdf": SearchRoutePolicy( + key="pdf", + provider="firecrawl", + fallback_chain=("tavily", "exa"), + firecrawl_categories=("pdf",), + result_profile="resource", + ), + "content": SearchRoutePolicy( + key="content", + provider="firecrawl", + fallback_chain=("tavily", "exa"), + result_profile="resource", + ), + "resource": SearchRoutePolicy( + key="resource", + provider="firecrawl", + fallback_chain=("tavily", "exa"), + firecrawl_categories=("research",), + result_profile="resource", + ), + "research": SearchRoutePolicy( + key="research", + provider="tavily", + fallback_chain=("exa", "firecrawl"), + result_profile="web", + allow_exa_rescue=True, + ), +} class MySearchClient: @@ -666,6 +740,24 @@ def search( else: raise MySearchError(f"Unsupported route decision: {decision.provider}") + if self._should_attempt_exa_rescue( + query=query, + mode=mode, + intent=resolved_intent, + decision=decision, + result=result, + max_results=max_results, + include_domains=include_domains, + ): + result = self._apply_exa_rescue( + query=query, + primary_result=result, + max_results=candidate_max_results, + include_domains=include_domains, + exclude_domains=exclude_domains, + include_content=include_content, + ) + if self._should_rerank_resource_results(mode=mode, intent=resolved_intent): reranked_results = self._rerank_resource_results( query=query, @@ -678,6 +770,18 @@ def search( results=reranked_results, citations=list(result.get("citations") or []), ) + elif self._should_rerank_general_results(result_profile=decision.result_profile): + reranked_results = self._rerank_general_results( + query=query, + result_profile=decision.result_profile, + results=list(result.get("results") or []), + include_domains=include_domains, + ) + result["results"] = reranked_results + result["citations"] = self._align_citations_with_results( + results=reranked_results, + citations=list(result.get("citations") or []), + ) result = self._apply_official_resource_policy( query=query, mode=mode, @@ -889,17 +993,39 @@ def research( query = query.strip() if not query: raise MySearchError("query must not be empty") - - web_mode = "news" if mode == "news" else ("docs" if mode in {"docs", "github", "pdf"} else "web") + resolved_intent = self._resolve_intent( + query=query, + mode=mode, + intent=intent, + sources=["web"], + ) + resolved_strategy = self._resolve_strategy( + mode=mode, + intent=resolved_intent, + strategy=strategy, + sources=["web"], + include_content=False, + ) + research_plan = self._resolve_research_plan( + query=query, + mode=mode, + intent=resolved_intent, + strategy=resolved_strategy, + web_max_results=web_max_results, + social_max_results=social_max_results, + scrape_top_n=scrape_top_n, + include_social=include_social, + include_domains=include_domains, + ) research_tasks: dict[str, Callable[[], Any]] = { "web": lambda: self.search( query=query, - mode=web_mode, - intent=intent, - strategy=strategy, + mode=research_plan["web_mode"], + intent=resolved_intent, + strategy=resolved_strategy, provider="auto", sources=["web"], - max_results=web_max_results, + max_results=research_plan["web_max_results"], include_content=False, include_answer=True, include_domains=include_domains, @@ -913,7 +1039,7 @@ def research( intent="status", provider="auto", sources=["x"], - max_results=social_max_results, + max_results=research_plan["social_max_results"], allowed_x_handles=allowed_x_handles, excluded_x_handles=excluded_x_handles, from_date=from_date, @@ -937,7 +1063,7 @@ def research( if not url or url in urls: continue urls.append(url) - if len(urls) >= scrape_top_n: + if len(urls) >= research_plan["scrape_top_n"]: break pages: list[dict[str, Any]] = [] @@ -991,13 +1117,14 @@ def research( social=social, social_error=social_error, providers_consulted=providers_consulted, + research_plan=research_plan, ) return { "provider": "hybrid", "query": query, - "intent": web_search.get("intent", intent if intent != "auto" else "factual"), - "strategy": web_search.get("strategy", strategy if strategy != "auto" else "fast"), + "intent": web_search.get("intent", resolved_intent), + "strategy": web_search.get("strategy", resolved_strategy), "web_search": web_search, "pages": pages, "social_search": social, @@ -1010,6 +1137,50 @@ def research( ], } + def _resolve_research_plan( + self, + *, + query: str, + mode: SearchMode, + intent: ResolvedSearchIntent, + strategy: SearchStrategy, + web_max_results: int, + social_max_results: int, + scrape_top_n: int, + include_social: bool, + include_domains: list[str] | None, + ) -> dict[str, Any]: + web_mode = "news" if mode == "news" else ("docs" if mode in {"docs", "github", "pdf"} else "web") + planned_web_max = web_max_results + planned_social_max = social_max_results if include_social else 0 + planned_scrape_top_n = scrape_top_n + + if mode in {"docs", "github", "pdf"} or self._should_use_strict_resource_policy( + query=query, + mode=mode, + intent=intent, + include_domains=include_domains, + ): + planned_web_max = max(planned_web_max, 4) + planned_scrape_top_n = max(1, min(planned_scrape_top_n, 2)) + elif mode == "news" or intent in {"news", "status"}: + planned_web_max = min(max(planned_web_max, 6), 8) + planned_scrape_top_n = min(max(planned_scrape_top_n, 4), 5) + if include_social: + planned_social_max = min(max(planned_social_max, 4), 6) + elif intent in {"comparison", "exploratory"} or strategy in {"verify", "deep"}: + planned_web_max = min(max(planned_web_max, 6), 10) + planned_scrape_top_n = min(max(planned_scrape_top_n, 4), 5) + if include_social: + planned_social_max = min(max(planned_social_max, 3), 5) + + return { + "web_mode": web_mode, + "web_max_results": planned_web_max, + "social_max_results": planned_social_max, + "scrape_top_n": planned_scrape_top_n, + } + def _candidate_result_budget( self, *, @@ -1127,14 +1298,40 @@ def _resolve_official_result_mode( intent: ResolvedSearchIntent, include_domains: list[str] | None, ) -> str: - if include_domains: - return "strict" - if self._looks_like_official_query(query): + if self._should_use_strict_resource_policy( + query=query, + mode=mode, + intent=intent, + include_domains=include_domains, + ): return "strict" if self._should_rerank_resource_results(mode=mode, intent=intent): return "standard" return "off" + def _should_use_strict_resource_policy( + self, + *, + query: str, + mode: SearchMode, + intent: ResolvedSearchIntent, + include_domains: list[str] | None, + ) -> bool: + query_lower = query.lower() + if include_domains: + return True + if mode in {"docs", "github", "pdf"}: + return True + if self._looks_like_official_query(query): + return True + if self._looks_like_pricing_query(query_lower): + return True + if self._looks_like_changelog_query(query_lower): + return True + if intent in {"resource", "tutorial"} and self._looks_like_docs_query(query_lower): + return True + return False + def _looks_like_official_query(self, query: str) -> bool: query_lower = query.lower() if re.search(r"\bofficial\b", query_lower): @@ -1150,6 +1347,33 @@ def _looks_like_official_query(self, query: str) -> bool: ) return any(marker in query for marker in official_markers) + def _looks_like_pricing_query(self, query_lower: str) -> bool: + keywords = [ + "price", + "pricing", + "plans", + "subscription", + "费用", + "套餐", + "定价", + "价格", + "售价", + ] + return any(keyword in query_lower for keyword in keywords) + + def _looks_like_changelog_query(self, query_lower: str) -> bool: + keywords = [ + "changelog", + "release notes", + "what's new", + "whats new", + "更新日志", + "发布说明", + "变更日志", + "版本更新", + ] + return any(keyword in query_lower for keyword in keywords) + def _apply_official_resource_policy( self, *, @@ -1229,6 +1453,7 @@ def _augment_research_evidence( social: dict[str, Any] | None, social_error: str, providers_consulted: list[str], + research_plan: dict[str, Any], ) -> dict[str, Any]: successful_pages = [page for page in pages if not page.get("error")] page_error_count = max(len(pages) - len(successful_pages), 0) @@ -1284,6 +1509,7 @@ def _augment_research_evidence( "search_confidence": str(web_evidence.get("confidence") or "low"), "confidence": confidence, "conflicts": conflicts, + "research_plan": research_plan, } def _estimate_research_confidence( @@ -1346,31 +1572,54 @@ def _route_search( excluded_x_handles: list[str] | None, ) -> RouteDecision: normalized_sources = sorted(set(sources or ["web"])) - query_lower = query.lower() + policy = self._route_policy_for_request( + query=query, + mode=mode, + intent=intent, + include_content=include_content, + ) if provider != "auto": if provider == "tavily": return RouteDecision( provider="tavily", reason="显式指定 Tavily", - tavily_topic="news" if mode == "news" or intent in {"news", "status"} else "general", + tavily_topic=policy.tavily_topic, + fallback_chain=self._explicit_provider_fallback_chain( + provider=provider, + policy=policy, + ), + result_profile=policy.result_profile, + allow_exa_rescue=policy.allow_exa_rescue and policy.provider == "tavily", ) if provider == "firecrawl": return RouteDecision( provider="firecrawl", reason="显式指定 Firecrawl", - firecrawl_categories=self._firecrawl_categories(mode, intent), + firecrawl_categories=list(policy.firecrawl_categories) + or self._firecrawl_categories(mode, intent), + fallback_chain=self._explicit_provider_fallback_chain( + provider=provider, + policy=policy, + ), + result_profile=policy.result_profile, ) if provider == "exa": return RouteDecision( provider="exa", reason="显式指定 Exa", + fallback_chain=self._explicit_provider_fallback_chain( + provider=provider, + policy=policy, + ), + result_profile=policy.result_profile, ) if provider == "xai": return RouteDecision( provider="xai", reason="显式指定 xAI/X 搜索", sources=normalized_sources, + result_profile="off", ) if normalized_sources == ["web", "x"] or ( @@ -1383,6 +1632,7 @@ def _route_search( provider="xai", reason="社交舆情 / X 搜索更适合走 xAI", sources=["x"], + result_profile="off", ) if allowed_x_handles or excluded_x_handles: @@ -1390,158 +1640,23 @@ def _route_search( provider="xai", reason="检测到 X handle 过滤条件", sources=["x"], + result_profile="off", ) - - if ( - include_domains - and self._domains_prefer_firecrawl_discovery(include_domains) - and self._provider_can_serve(self.config.firecrawl) - and ( - mode in {"docs", "github", "pdf"} - or intent in {"resource", "tutorial"} - or self._looks_like_docs_query(query_lower) - ) - ): - return RouteDecision( - provider="firecrawl", - reason="检测到受限 / 社区域名,优先用 Firecrawl 做站内发现", - firecrawl_categories=self._firecrawl_categories( - "docs" if mode not in {"github", "pdf"} else mode, - intent, - ), - ) - - if mode in {"docs", "github", "pdf"}: - if include_content: - if not self._provider_can_serve(self.config.firecrawl) and self._provider_can_serve( - self.config.exa - ): - return RouteDecision( - provider="exa", - reason="Firecrawl 未配置,文档正文查询回退到 Exa", - ) - return RouteDecision( - provider="firecrawl", - reason="文档正文查询优先走 Firecrawl", - firecrawl_categories=self._firecrawl_categories(mode, intent), - ) - if self._provider_can_serve(self.config.tavily): - return RouteDecision( - provider="tavily", - reason="文档类查询先用 Tavily 做官方页面发现,正文再交给 Firecrawl", - tavily_topic="general", - ) - if not self._provider_can_serve(self.config.firecrawl) and self._provider_can_serve( - self.config.exa - ): - return RouteDecision( - provider="exa", - reason="Firecrawl 未配置,文档类查询回退到 Exa", - ) - return RouteDecision( - provider="firecrawl", - reason="文档 / GitHub / PDF 内容优先走 Firecrawl", - firecrawl_categories=self._firecrawl_categories(mode, intent), - ) - - if include_content: - if not self._provider_can_serve(self.config.firecrawl) and self._provider_can_serve( - self.config.exa - ): - return RouteDecision( - provider="exa", - reason="Firecrawl 未配置,正文查询回退到 Exa", - ) - return RouteDecision( - provider="firecrawl", - reason="请求里需要正文内容,优先用 Firecrawl search + scrape", - firecrawl_categories=self._firecrawl_categories(mode, intent), - ) - - if intent in {"news", "status"} or mode == "news" or self._looks_like_news_query(query_lower): - if not self._provider_can_serve(self.config.tavily) and self._provider_can_serve( - self.config.exa - ): - return RouteDecision( - provider="exa", - reason="Tavily 未配置,新闻 / 状态类查询回退到 Exa", - ) - return RouteDecision( - provider="tavily", - reason="状态 / 新闻类查询默认走 Tavily", - tavily_topic="news", - ) - - if intent == "resource" or self._looks_like_docs_query(query_lower): - if include_content: - if not self._provider_can_serve(self.config.firecrawl) and self._provider_can_serve( - self.config.exa - ): - return RouteDecision( - provider="exa", - reason="Firecrawl 未配置,resource 正文查询回退到 Exa", - ) - return RouteDecision( - provider="firecrawl", - reason="resource / docs 正文查询优先走 Firecrawl", - firecrawl_categories=self._firecrawl_categories("docs", intent), - ) - if self._provider_can_serve(self.config.tavily): - return RouteDecision( - provider="tavily", - reason="resource / docs 查询先用 Tavily 做页面发现,正文再交给 Firecrawl", - tavily_topic="general", - ) - if not self._provider_can_serve(self.config.firecrawl) and self._provider_can_serve( - self.config.exa - ): - return RouteDecision( - provider="exa", - reason="Firecrawl 未配置,resource / docs 类查询回退到 Exa", - ) - return RouteDecision( - provider="firecrawl", - reason="resource / docs 类查询优先走 Firecrawl", - firecrawl_categories=self._firecrawl_categories("docs", intent), - ) - - if mode == "research": - if self._provider_can_serve(self.config.tavily): - return RouteDecision( - provider="tavily", - reason="research 模式先用 Tavily 做发现,再按策略决定是否扩展验证", - tavily_topic="general", - ) - if self._provider_can_serve(self.config.exa): - return RouteDecision( - provider="exa", - reason="Tavily 未配置,research 发现阶段回退到 Exa", - ) - if self._provider_can_serve(self.config.firecrawl): - return RouteDecision( - provider="firecrawl", - reason="Tavily / Exa 未配置,research 发现阶段回退到 Firecrawl", - firecrawl_categories=self._firecrawl_categories(mode, intent), - ) - return RouteDecision( - provider="tavily", - reason="research 模式默认走 Tavily(无可用替代)", - tavily_topic="general", - ) - - if not self._provider_can_serve(self.config.tavily) and self._provider_can_serve( - self.config.exa - ): - return RouteDecision( - provider="exa", - reason="Tavily 未配置,普通网页检索回退到 Exa", - ) - - return RouteDecision( - provider="tavily", - reason="普通网页检索默认走 Tavily", - tavily_topic="general", - ) + if policy.key in {"docs", "resource"} and include_domains and self._domains_prefer_firecrawl_discovery(include_domains): + reason = "检测到受限 / 社区域名,优先用 Firecrawl 做站内发现" + elif policy.key in {"docs", "github", "pdf"}: + reason = "文档 / GitHub / PDF 默认走 Firecrawl,页面发现与正文抓取保持一致" + elif policy.key == "content": + reason = "请求里需要正文内容,优先走 Firecrawl" + elif policy.key == "news": + reason = "状态 / 新闻类查询默认走 Tavily" + elif policy.key == "resource": + reason = "resource / docs 查询默认走 Firecrawl" + elif policy.key == "research": + reason = "research 发现阶段默认走 Tavily" + else: + reason = "普通网页检索默认走 Tavily" + return self._decision_from_policy(policy=policy, reason=reason) def _domains_prefer_firecrawl_discovery(self, include_domains: list[str] | None) -> bool: if not include_domains: @@ -1623,6 +1738,81 @@ def _resolve_strategy( return "balanced" return "fast" + def _route_policy_for_request( + self, + *, + query: str, + mode: SearchMode, + intent: ResolvedSearchIntent, + include_content: bool, + ) -> SearchRoutePolicy: + query_lower = query.lower() + if mode == "research": + return _MODE_PROVIDER_POLICY["research"] + if include_content: + return _MODE_PROVIDER_POLICY["content"] + if mode in {"docs", "github", "pdf"}: + return _MODE_PROVIDER_POLICY[mode] + if intent in {"resource", "tutorial"} or self._looks_like_docs_query(query_lower): + return _MODE_PROVIDER_POLICY["resource"] + if intent in {"news", "status"} or mode == "news" or self._looks_like_news_query(query_lower): + return _MODE_PROVIDER_POLICY["news"] + return _MODE_PROVIDER_POLICY["web"] + + def _decision_from_policy( + self, + *, + policy: SearchRoutePolicy, + reason: str, + sources: list[str] | None = None, + ) -> RouteDecision: + provider, fallback_chain = self._resolve_available_policy_chain(policy=policy) + return RouteDecision( + provider=provider, + reason=reason, + tavily_topic=policy.tavily_topic, + firecrawl_categories=list(policy.firecrawl_categories) or None, + sources=sources, + fallback_chain=fallback_chain, + result_profile=policy.result_profile, + allow_exa_rescue=policy.allow_exa_rescue, + ) + + def _resolve_available_policy_chain( + self, + *, + policy: SearchRoutePolicy, + ) -> tuple[ProviderName, list[str] | None]: + ordered: list[ProviderName] = [policy.provider, *policy.fallback_chain] + available: list[ProviderName] = [] + for provider_name in ordered: + config = self._provider_config_for_name(provider_name) + if self._provider_can_serve(config): + available.append(provider_name) + if not available: + return policy.provider, list(policy.fallback_chain) or None + return available[0], list(available[1:]) or None + + def _provider_config_for_name(self, provider_name: ProviderName) -> ProviderConfig: + if provider_name == "tavily": + return self.config.tavily + if provider_name == "firecrawl": + return self.config.firecrawl + if provider_name == "exa": + return self.config.exa + return self.config.xai + + def _explicit_provider_fallback_chain( + self, + *, + provider: ProviderName, + policy: SearchRoutePolicy, + ) -> list[str] | None: + if provider == "xai": + return None + chain = [item for item in policy.fallback_chain if item != provider] + return list(chain) or None + def _should_blend_web_providers( self, *, @@ -1642,6 +1832,8 @@ def _should_blend_web_providers( return False if "x" in sources: return False + if mode == "news" or intent in {"news", "status"}: + return False if include_domains: return False if mode in {"docs", "github", "pdf"}: @@ -1652,12 +1844,6 @@ def _should_blend_web_providers( self.config.firecrawl ) - _SEARCH_FALLBACK_CHAIN: dict[str, list[str]] = { - "tavily": ["exa", "firecrawl"], - "firecrawl": ["exa", "tavily"], - "exa": ["firecrawl", "tavily"], - } - def _search_with_fallback( self, *, @@ -1672,7 +1858,7 @@ def _search_with_fallback( include_domains: list[str] | None, exclude_domains: list[str] | None, ) -> tuple[dict[str, Any], dict[str, Any] | None]: - chain = [primary_provider, *self._SEARCH_FALLBACK_CHAIN.get(primary_provider, [])] + chain = [primary_provider, *(decision.fallback_chain or [])] last_error: Exception | None = None for provider_name in chain: try: @@ -1747,6 +1933,246 @@ def _dispatch_single_provider( ) raise MySearchError(f"Unknown provider: {provider_name}") + def _should_attempt_exa_rescue( + self, + *, + query: str, + mode: SearchMode, + intent: ResolvedSearchIntent, + decision: RouteDecision, + result: dict[str, Any], + max_results: int, + include_domains: list[str] | None, + ) -> bool: + if not decision.allow_exa_rescue: + return False + if not self._provider_can_serve(self.config.exa): + return False + if result.get("provider") in {"exa", "xai"}: + return False + if result.get("fallback"): + return False + if include_domains or self._resolve_official_result_mode( + query=query, + mode=mode, + intent=intent, + include_domains=include_domains, + ) == "strict": + return False + results = list(result.get("results") or []) + if len(results) >= min(max_results, 3): + return False + query_terms = re.findall(r"[a-z0-9\u4e00-\u9fff]+", query.lower()) + long_tail_signal = len(query_terms) >= 6 or len(query) >= 48 + return mode == "news" or intent in {"comparison", "exploratory", "tutorial"} or long_tail_signal + + def _apply_exa_rescue( + self, + *, + query: str, + primary_result: dict[str, Any], + max_results: int, + include_domains: list[str] | None, + exclude_domains: list[str] | None, + include_content: bool, + ) -> dict[str, Any]: + exa_result = self._search_exa( + query=query, + max_results=max_results, + include_domains=include_domains, + exclude_domains=exclude_domains, + include_content=include_content, + ) + if not exa_result.get("results"): + return primary_result + + merged = self._merge_search_payloads( + primary_result=primary_result, + secondary_result=exa_result, + max_results=max_results, + ) + return { + "provider": "hybrid", + "route_selected": f"{primary_result.get('provider', 'unknown')}+exa", + "query": query, + "answer": primary_result.get("answer") or exa_result.get("answer", ""), + "results": merged["results"], + "citations": merged["citations"], + "evidence": { + "providers_consulted": [ + item + for item in [primary_result.get("provider"), exa_result.get("provider")] + if item + ], + "matched_results": merged["matched_results"], + "citation_count": len(merged["citations"]), + "verification": "fallback", + }, + "primary_search": primary_result, + "secondary_search": exa_result, + "secondary_error": "", + "fallback": { + "from": primary_result.get("provider", "unknown"), + "to": "exa", + "reason": "primary provider returned sparse results; Exa rescue engaged", + }, + } + + def _should_rerank_general_results( + self, + *, + result_profile: str, + ) -> bool: + return result_profile in {"web", "news"} + + def _rerank_general_results( + self, + *, + query: str, + result_profile: Literal["web", "news"], + results: list[dict[str, Any]], + include_domains: list[str] | None, + ) -> list[dict[str, Any]]: + if len(results) < 2: + return results + ranked = sorted( + enumerate(results), + key=lambda pair: ( + self._general_result_rank( + query=query, + result_profile=result_profile, + item=pair[1], + include_domains=include_domains, + ), + -pair[0], + ), + reverse=True, + ) + return [dict(pair[1]) for pair in ranked] + + def _general_result_rank( + self, + *, + query: str, + result_profile: Literal["web", "news"], + item: dict[str, Any], + include_domains: list[str] | None, + ) -> tuple[int, int, int, int, int, int, int, int]: + if result_profile == "news": + return self._news_result_rank(item=item, include_domains=include_domains) + return self._web_result_rank( + query=query, + item=item, + include_domains=include_domains, + ) + + def _news_result_rank( + self, + *, + item: dict[str, Any], + include_domains: list[str] | None, + ) -> tuple[int, int, int, int, int, int, int, int]: + hostname = self._result_hostname(item) + include_match = int( + bool(include_domains) + and any(self._domain_matches(hostname, domain) for domain in include_domains or []) + ) + mainstream = int(self._is_mainstream_news_domain(hostname)) + article_shape = int(self._looks_like_news_article_result(item)) + has_timestamp = int(self._result_published_timestamp(item) is not None) + timestamp_score = int(self._result_published_timestamp(item) or 0) + content_score, snippet_score, title_score = self._result_quality_score(item) + return ( + include_match, + mainstream, + article_shape, + has_timestamp, + timestamp_score, + content_score, + snippet_score, + title_score, + ) + + def _web_result_rank( + self, + *, + query: str, + item: dict[str, Any], + include_domains: list[str] | None, + ) -> tuple[int, int, int, int, int, int, int, int]: + hostname = self._result_hostname(item) + registered_domain = self._registered_domain(hostname) + title_text = (item.get("title") or "").lower() + query_tokens = self._query_brand_tokens(query) + include_match = int( + bool(include_domains) + and any(self._domain_matches(hostname, domain) for domain in include_domains or []) + ) + registered_domain_label_match = int( + self._registered_domain_label_matches( + registered_domain=registered_domain, + query_tokens=query_tokens, + ) + ) + host_brand_match = int(any(token in hostname for token in query_tokens)) + title_brand_match = int(any(token in title_text for token in query_tokens)) + non_aggregator = int(not self._is_obvious_web_aggregator(registered_domain)) + matched_provider_count = len(item.get("matched_providers") or []) + content_score, snippet_score, title_score = self._result_quality_score(item) + return ( + include_match, + registered_domain_label_match, + host_brand_match, + title_brand_match, + non_aggregator, + matched_provider_count, + content_score, + max(snippet_score, title_score), + ) + + def _result_published_timestamp(self, item: dict[str, Any]) -> float | None: + for field in ("published_date", "publishedDate", "created_at"): + parsed = self._parse_result_timestamp(item.get(field)) + if parsed is not None: + return parsed.timestamp() + return None + + def _is_mainstream_news_domain(self, hostname: str) -> bool: + registered_domain = self._registered_domain(hostname) + mainstream_domains = { + "apnews.com", + "bbc.com", + "bloomberg.com", + "cnn.com", + "ft.com", + "latimes.com", + "nytimes.com", + "reuters.com", + "theguardian.com", + "theverge.com", + "washingtonpost.com", + "wsj.com", + "xinhuanet.com", + } + return registered_domain in mainstream_domains + + def _looks_like_news_article_result(self, item: dict[str, Any]) -> bool: + path = urlparse(item.get("url", "")).path.lower() + return any( + marker in path + for marker in ("/news/", "/story/", "/stories/", "/article/", "/articles/", "/202") + ) + + def _is_obvious_web_aggregator(self, registered_domain: str) -> bool: + return registered_domain in { + "linkedin.com", + "medium.com", + "quora.com", + "reddit.com", + "researchgate.net", + "stackoverflow.com", + } + def _search_web_blended( self, *, @@ -1956,6 +2382,11 @@ def _search_tavily_once( "snippet": item.get("content", ""), "content": item.get("raw_content", "") if include_content else "", "score": item.get("score"), + "published_date": item.get("published_date") + or item.get("publishedDate") + or item.get("published_at") + or item.get("publishedAt") + or "", } for item in response.get("results", []) ] @@ -2366,6 +2797,10 @@ def _search_firecrawl_once( "url": item.get("url", ""), "snippet": item.get("description", "") or item.get("markdown", ""), "content": item.get("markdown", "") if include_content else "", + "published_date": item.get("publishedDate") + or item.get("published_date") + or item.get("published_at") + or "", } ) diff --git a/skill/SKILL.md b/skill/SKILL.md index 86325d6..42174d6 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -25,7 +25,7 @@ MySearch 是一层聚合搜索技能,不假设你只用单一 provider,也 - Tavily:适合普通网页发现、新闻检索、快速答案 - Firecrawl:适合文档、GitHub、pricing、changelog、正文抓取 -- Exa:作为 Tavily / Firecrawl 的 fallback,在主 provider 不可用时自动接管网页发现 +- Exa:作为网页与长尾资料的补位 provider,只在主 provider 不可用、结果稀疏或需要语义补搜时介入 - X 搜索:适合”大家在 X 上怎么说”、实时舆情、开发者讨论 ## MySearch-First 规则 @@ -436,10 +436,10 @@ MySearch 会自动回退到 `Tavily extract`。 - `intent="exploratory"`:原因、影响、趋势、分析 - `intent="resource"`:docs、GitHub、pricing、changelog、PDF -- `strategy="fast"`:单 provider 快速返回 -- `strategy="balanced"`:主 provider + 次 provider 补充 -- `strategy="verify"`:Tavily + Firecrawl 交叉验证网页结果 -- `strategy="deep"`:更偏 research 的双 provider 路径 +- `strategy="fast"`:单 provider,最小候选池 +- `strategy="balanced"`:主 provider 为主,按模式补少量候选 +- `strategy="verify"`:扩大候选池并做交叉验证,必要时启用 Exa 补位 +- `strategy="deep"`:更偏 `research` 的较大候选池与更多正文抓取 默认自动行为: @@ -453,6 +453,8 @@ MySearch 会自动回退到 `Tavily extract`。 - 新闻 / 最新动态:默认 Tavily news - 文档 / GitHub / PDF / changelog / pricing:默认 Firecrawl - X / Twitter / 社交舆情:默认 xAI X search +- `official / 官方 / 官网`、`docs / pricing / changelog` 一类查询会进入更严格的官方结果模式;宁可明确说明“官方结果不足”,也不默认拿第三方结果补齐 +- `web` 和 `news` 会分开排序:`news` 更重时效与媒体质量,`web` 更重官方性与页面相关性 - 同时要网页和社交:结果可能是 `hybrid`,但调用时不要传 `mode="hybrid"`;应使用 `sources=["web","x"]` 或拆成 `social + news` ## X provider 模式 diff --git a/tests/test_clients.py b/tests/test_clients.py index 6d18857..7e613f2 100644 --- a/tests/test_clients.py +++ b/tests/test_clients.py @@ -803,8 +803,8 @@ def test_search_reranks_direct_docs_results_to_official_first(self) -> None: self.assertEqual(result["results"][0]["url"], "https://playwright.dev/docs/api/class-test") self.assertEqual(result["citations"][0]["url"], "https://playwright.dev/docs/api/class-test") self.assertEqual(result["evidence"]["official_source_count"], 1) - self.assertEqual(result["evidence"]["confidence"], "medium") - self.assertIn("mixed-official-and-third-party", result["evidence"]["conflicts"]) + self.assertEqual(result["evidence"]["confidence"], "high") + self.assertNotIn("mixed-official-and-third-party", result["evidence"]["conflicts"]) def test_search_strict_official_mode_filters_to_official_results(self) -> None: client = MySearchClient() @@ -946,6 +946,145 @@ def test_search_strict_official_mode_counts_official_hits_for_web_queries(self) self.assertTrue(result["evidence"]["official_filter_applied"]) self.assertNotIn("strict-official-unmet", result["evidence"]["conflicts"]) + def test_docs_mode_enters_strict_resource_policy(self) -> None: + client = MySearchClient() + + mode = client._resolve_official_result_mode( + query="Next.js generateMetadata", + mode="docs", + intent="resource", + include_domains=None, + ) + + self.assertEqual(mode, "strict") + + def test_search_uses_exa_rescue_for_sparse_web_results(self) -> None: + client = MySearchClient() + client._provider_can_serve = lambda provider: True # type: ignore[method-assign] + client._search_tavily = lambda **kwargs: { # type: ignore[method-assign] + "provider": "tavily", + "transport": "env", + "query": kwargs["query"], + "answer": "", + "results": [ + { + "provider": "tavily", + "source": "web", + "title": "Sparse result", + "url": "https://example.com/one", + "snippet": "Only one result", + "content": "", + } + ], + "citations": [ + {"title": "Sparse result", "url": "https://example.com/one"}, + ], + } + client._search_exa = lambda **kwargs: { # type: ignore[method-assign] + "provider": "exa", + "transport": "env", + "query": kwargs["query"], + "answer": "", + "results": [ + { + "provider": "exa", + "source": "web", + "title": "Long tail reference", + "url": "https://exa.example.com/two", + "snippet": "Recovered long-tail source", + "content": "", + }, + { + "provider": "exa", + "source": "web", + "title": "Another result", + "url": "https://exa.example.com/three", + "snippet": "Recovered another source", + "content": "", + }, + ], + "citations": [ + {"title": "Long tail reference", "url": "https://exa.example.com/two"}, + {"title": "Another result", "url": "https://exa.example.com/three"}, + ], + } + + result = client.search( + query="best open source vector database comparison for offline agents", + mode="web", + strategy="fast", + provider="tavily", + max_results=3, + include_answer=False, + ) + + self.assertEqual(result["provider"], "hybrid") + self.assertEqual(result["fallback"]["to"], "exa") + self.assertEqual(result["results"][0]["url"], "https://exa.example.com/two") + + def test_rerank_general_news_prefers_mainstream_article_shape(self) -> None: + client = MySearchClient() + + reranked = client._rerank_general_results( + query="2026 oscar winners", + result_profile="news", + include_domains=None, + results=[ + { + "provider": "tavily", + "title": "Oscars 2026 winners list", + "url": "https://news-aggregate.example.com/oscars-winners", + "snippet": "aggregated summary", + "content": "", + }, + { + "provider": "tavily", + "title": "Oscars 2026 winners list", + "url": "https://www.latimes.com/entertainment-arts/awards/story/2026-03-15/oscars-2026-winners-list-full-results", + "snippet": "Los Angeles Times coverage", + "content": "", + "published_date": "2026-03-15T09:00:00+00:00", + }, + ], + ) + + self.assertEqual( + reranked[0]["url"], + "https://www.latimes.com/entertainment-arts/awards/story/2026-03-15/oscars-2026-winners-list-full-results", + ) + + def test_resolve_research_plan_adapts_docs_and_news_budgets(self) -> None: + client = MySearchClient() + + docs_plan = client._resolve_research_plan( + query="OpenAI pricing official", + mode="docs", + intent="resource", + strategy="balanced", + web_max_results=5, + social_max_results=5, + scrape_top_n=4, + include_social=True, + include_domains=None, + ) + news_plan = client._resolve_research_plan( + query="2026 oscars winners", + mode="news", + intent="news", + strategy="deep", + web_max_results=5, + social_max_results=2, + scrape_top_n=3, + include_social=True, + include_domains=None, + ) + + self.assertEqual(docs_plan["web_mode"], "docs") + self.assertEqual(docs_plan["scrape_top_n"], 2) + self.assertGreaterEqual(news_plan["web_max_results"], 6) + self.assertGreaterEqual(news_plan["social_max_results"], 4) + self.assertGreaterEqual(news_plan["scrape_top_n"], 4) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_comprehensive.py b/tests/test_comprehensive.py index a72b19e..06cea6d 100644 --- a/tests/test_comprehensive.py +++ b/tests/test_comprehensive.py @@ -296,12 +296,13 @@ def test_docs_mode_with_content_routes_to_firecrawl(self) -> None: decision = self._route(client, mode="docs", include_content=True) self.assertEqual(decision.provider, "firecrawl") - def test_docs_mode_without_content_and_tavily_available_routes_to_tavily(self) -> None: - client = _make_client(tavily_keys=["key1"]) + def test_docs_mode_without_content_routes_to_firecrawl(self) -> None: + client = _make_client(tavily_keys=["key1"], firecrawl_keys=["fc"]) decision = self._route(client, mode="docs", include_content=False) - self.assertEqual(decision.provider, "tavily") + self.assertEqual(decision.provider, "firecrawl") + self.assertEqual(decision.fallback_chain, ["tavily"]) - def test_docs_fallback_to_exa_when_tavily_and_firecrawl_unavailable(self) -> None: + def test_docs_route_keeps_firecrawl_primary_even_when_keys_missing(self) -> None: client = _make_client( tavily_keys=[], firecrawl_keys=[], @@ -309,6 +310,7 @@ def test_docs_fallback_to_exa_when_tavily_and_firecrawl_unavailable(self) -> Non ) decision = self._route(client, mode="docs", include_content=False) self.assertEqual(decision.provider, "exa") + self.assertIsNone(decision.fallback_chain) def test_news_mode_routes_to_tavily(self) -> None: client = _make_client(tavily_keys=["key1"]) @@ -325,17 +327,20 @@ def test_web_fallback_to_exa_when_tavily_unavailable(self) -> None: client = _make_client(tavily_keys=[], exa_keys=["exa-key"]) decision = self._route(client, mode="web") self.assertEqual(decision.provider, "exa") + self.assertEqual(decision.fallback_chain, ["firecrawl"]) def test_github_mode_routes_to_docs_path(self) -> None: """GitHub mode should follow docs routing path.""" client = _make_client(tavily_keys=["key"]) decision = self._route(client, mode="github", include_content=False) - self.assertEqual(decision.provider, "tavily") + self.assertEqual(decision.provider, "firecrawl") + self.assertEqual(decision.fallback_chain, ["tavily"]) def test_pdf_mode_routes_to_docs_path(self) -> None: client = _make_client(tavily_keys=["key"]) decision = self._route(client, mode="pdf", include_content=False) - self.assertEqual(decision.provider, "tavily") + self.assertEqual(decision.provider, "firecrawl") + self.assertEqual(decision.fallback_chain, ["tavily"]) def test_include_content_routes_to_firecrawl(self) -> None: client = _make_client() @@ -523,6 +528,18 @@ def test_no_blend_when_include_domains_present(self) -> None: include_domains=["openai.com"], )) + def test_no_blend_for_news_profile(self) -> None: + client = _make_client(tavily_keys=["k"], firecrawl_keys=["k"]) + self.assertFalse(client._should_blend_web_providers( + requested_provider="auto", + decision=RouteDecision(provider="tavily", reason="test", result_profile="news"), + sources=["web"], + strategy="balanced", + mode="news", + intent="news", + include_domains=None, + )) + def test_blend_when_conditions_met(self) -> None: client = _make_client(tavily_keys=["k"], firecrawl_keys=["k"]) self.assertTrue(client._should_blend_web_providers(