适合新闻、快速 answer、网页线索收集,放在 MySearch 的第一层路由里。
+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 + ? '
推荐优先接 grok2api 后台。这样只要填后台地址和 app key,proxy 就能自动继承上游密钥与 token 池,不需要再把配置拆成很多手动变量。
+这块不是 provider 池,而是给 Codex / Claude Code / 其他 MCP 客户端准备的统一接入层。目标就是让用户少填变量、少记路径、少区分底层服务。
+推荐把 MySearch 统一接到当前 proxy。这样客户端只认一个 base URL 和一个通用 token,底层 Tavily / Firecrawl / Exa / Social 都由 proxy 负责收口。
+这个 token 专门给上层 MCP / Skill 用。和 Tavily / Firecrawl / Exa 各自的服务 token 分开管理,但调用时会被三条 provider 路由一起接受。
+| Token | +名称 | +额度 | +代理统计 | +操作 | +
|---|
${meta.panelIntro} 账号前缀 ${meta.emailPrefix},代理 Token 前缀 ${meta.tokenPrefix}。${meta.quotaSource}。
+${meta.routeHint}
+ + + +${meta.tokenPoolDesc}
+| Token | +备注 | +配额 / 剩余 | +代理统计 | +操作 | +
|---|
${meta.keyPoolDesc}
+| ID | +Key | +邮箱 | +Key 额度 | +账户额度 | +代理统计 | +状态 | +操作 | +
|---|
这里收口的是 X / Social 搜索路由,不再把底层实现名字放成主标题。你看到的是 MySearch 的 Social 工作台,底层可以复用 grok2api 后台,也可以兼容别的 xAI-compatible 上游。
+MySearch 把 Tavily、Exa、Firecrawl 和 Social / X 统一成一套可被 Codex、Claude Code 和团队 Agent 直接调用的搜索入口。使用者拿到后,不需要自己维护多套 provider、兼容网关和结果结构,也不需要每次手动判断这一轮该用网页搜索、正文抓取还是社交舆情。
+ +适合新闻、快速 answer、网页线索收集,放在 MySearch 的第一层路由里。
+适合补 Tavily 之外的网页搜索入口,这里收成独立 Key 池、Token 池和代理端点。
+文档站、GitHub、PDF、pricing 和 changelog 这类正文内容,交给 Firecrawl 更稳。
+兼容 grok2api 和 xAI-compatible 搜索,把 X 结果统一整理成 MySearch 可直接消费的结构。
+MySearch 把 Tavily、Exa、Firecrawl 和 Social / X 统一成一套可被 Codex、Claude Code 和团队 Agent 直接调用的搜索入口。使用者拿到后,不需要自己维护多套 provider、兼容网关和结果结构,也不需要每次手动判断这一轮该用网页搜索、正文抓取还是社交舆情。
- -适合新闻、快速 answer、网页线索收集,放在 MySearch 的第一层路由里。
-适合补 Tavily 之外的网页搜索入口,这里收成独立 Key 池、Token 池和代理端点。
-文档站、GitHub、PDF、pricing 和 changelog 这类正文内容,交给 Firecrawl 更稳。
-兼容 grok2api 和 xAI-compatible 搜索,把 X 结果统一整理成 MySearch 可直接消费的结构。
-