dev #10

Merged
linkong merged 2 commits from dev into main 2026-05-11 01:57:22 +00:00
52 changed files with 4440 additions and 279 deletions
Showing only changes of commit 1cb51b1172 - Show all commits

View File

@@ -1 +1 @@
0.50.0
0.51.0

View File

@@ -20,7 +20,11 @@ from app.services.bgp_collector_locations import (
)
from app.services.bgp_collectors import build_bgp_collector_coverage
from app.services.ai_client import get_ai_provider_client
from app.services.location.llm_fallback import collect_llm_location_fallback_candidate
from app.api.v1.settings import get_web_search_client
from app.services.location.llm_fallback import (
collect_llm_location_fallback_candidate,
collect_location_search_evidence,
)
router = APIRouter()
@@ -320,21 +324,33 @@ async def collect_bgp_collector_location(
country=country,
operator=operator,
)
llm_result = None
try:
web_search_client = await get_web_search_client(db)
search_result = await collect_location_search_evidence(
web_search_client=web_search_client,
query=query,
entity_type="bgp_collector",
)
attempted_queries = [*attempted_queries, *search_result.attempted_queries]
if not search_result.evidence:
llm_failure_reason = search_result.failure_reason
raise RuntimeError(search_result.failure_reason or "no WebSearch evidence")
provider_client = await get_ai_provider_client(db)
llm_result = await collect_llm_location_fallback_candidate(
provider_client=provider_client,
query=query,
entity_type="bgp_collector",
attempted_queries=attempted_queries,
search_evidence=search_result.evidence,
)
except Exception as exc:
llm_result = None
llm_failure_reason = f"LLM location factcheck unavailable: {exc}"
attempted_queries = [
*attempted_queries,
f"llm_factcheck:bgp_collector:{collector_id or 'unknown'}",
]
if llm_failure_reason is None:
llm_failure_reason = f"LLM location factcheck unavailable: {exc}"
attempted_queries = [
*attempted_queries,
f"llm_factcheck:bgp_collector:{collector_id or 'unknown'}",
]
if llm_result is not None:
attempted_queries = [*attempted_queries, *llm_result.attempted_queries]
candidates = llm_result.candidates

View File

@@ -1,5 +1,6 @@
from copy import deepcopy
from datetime import UTC, datetime
import os
from pathlib import Path
from typing import Optional
@@ -38,6 +39,16 @@ from app.services.datasource_connectivity import (
save_connectivity_success,
)
from app.services.ai_client import AIProviderClient, get_ai_provider_client
from app.services.ai_tools.schemas import WebSearchConfig, WebSearchProviderConfig
from app.services.ai_tools.web_search import (
WebSearchClient,
WebSearchConfigurationError,
WebSearchError,
get_web_search_provider_preset,
list_web_search_provider_presets,
normalize_web_search_provider,
provider_defaults as web_search_provider_defaults,
)
from app.services.llm_provider_catalog import (
FALLBACK_LLM_PROVIDER_PRESETS,
get_fallback_llm_provider_preset,
@@ -78,7 +89,12 @@ DEFAULT_SETTINGS = {
"providers": {},
"timeout_seconds": 60,
"retry_attempts": 2,
}
},
"web_search": {
"enabled": False,
"default_provider": "tavily",
"providers": {},
},
},
}
@@ -161,9 +177,31 @@ class BarentsWatchIntegrationUpdate(BaseModel):
clear_client_secret: bool = False
class WebSearchIntegrationUpdate(BaseModel):
enabled: bool = False
default_provider: Optional[str] = None
provider: str = Field(default="tavily", max_length=80)
base_url: str = Field(default="", max_length=500)
api_key: Optional[str] = None
max_results: int = Field(default=5, ge=1, le=20)
timeout_seconds: int = Field(default=20, ge=3, le=120)
endpoint_path: str = Field(default="", max_length=200)
search_depth: str = Field(default="basic", max_length=40)
engine: str = Field(default="google", max_length=80)
include_answer: bool = False
include_raw_content: bool = False
include_text: bool = False
categories: str = Field(default="general", max_length=120)
engines: list[str] = Field(default_factory=list)
search_path: str = Field(default="", max_length=200)
scrape_path: str = Field(default="", max_length=200)
scrape_formats: list[str] = Field(default_factory=lambda: ["markdown"])
class ExternalIntegrationsUpdate(BaseModel):
ai_provider: AIProviderIntegrationUpdate
barentswatch: BarentsWatchIntegrationUpdate
web_search: WebSearchIntegrationUpdate | None = None
def merge_with_defaults(category: str, payload: Optional[dict]) -> dict:
@@ -217,6 +255,11 @@ async def save_setting_payload(db: AsyncSession, category: str, payload: dict) -
AI_PROVIDER_ENV_FILE = Path(__file__).resolve().parents[4] / "aiprovider" / ".env"
WEB_SEARCH_ENV_FILES = (
Path(__file__).resolve().parents[4] / ".env",
Path(__file__).resolve().parents[3] / ".env",
AI_PROVIDER_ENV_FILE,
)
def _mask_secret(value: Optional[str], source: str = "") -> dict:
@@ -270,6 +313,33 @@ def _resolve_env_secret(*names: str) -> tuple[str, str]:
return "", ""
def _read_web_search_env_files() -> dict[str, str]:
values: dict[str, str] = {}
for path in WEB_SEARCH_ENV_FILES:
if not path.exists():
continue
values.update({
key: str(value)
for key, value in dotenv_values(path).items()
if value is not None
})
return values
def _resolve_web_search_env_secret(*names: str) -> tuple[str, str]:
env_file_values = _read_web_search_env_files()
for name in names:
if not name:
continue
value = env_file_values.get(name)
if value:
return value, "env_file"
value = os.environ.get(name)
if value:
return value, "env"
return "", ""
def _provider_defaults(provider: str) -> dict:
preset = _get_provider_preset(provider)
return {
@@ -438,6 +508,160 @@ def _runtime_config_from_ai_payload(ai_payload: dict) -> dict:
}
def _web_search_provider_defaults(provider: str) -> dict:
return web_search_provider_defaults(provider).model_dump()
def _normalize_web_search_payload(web_search_payload: dict | None) -> dict:
raw = dict(web_search_payload or {})
default_provider = normalize_web_search_provider(
raw.get("default_provider") or raw.get("provider")
)
providers = {
normalize_web_search_provider(provider): dict(config or {})
for provider, config in (raw.get("providers") or {}).items()
if provider
}
legacy_fields = {
key: raw.get(key)
for key in (
"base_url",
"api_key",
"max_results",
"timeout_seconds",
"endpoint_path",
"search_depth",
"engine",
"include_answer",
"include_raw_content",
"include_text",
"categories",
"engines",
"search_path",
"scrape_path",
"scrape_formats",
)
if raw.get(key) not in (None, "")
}
if legacy_fields:
providers[default_provider] = {
**providers.get(default_provider, {}),
**legacy_fields,
}
normalized_providers: dict[str, dict] = {}
for provider, config in providers.items():
provider_id = normalize_web_search_provider(provider)
normalized_providers[provider_id] = {
**_web_search_provider_defaults(provider_id),
**dict(config or {}),
"provider": provider_id,
}
if default_provider not in normalized_providers:
normalized_providers[default_provider] = _web_search_provider_defaults(default_provider)
return {
"enabled": bool(raw.get("enabled", False)),
"default_provider": default_provider,
"providers": normalized_providers,
}
def _resolve_web_search_api_key(provider: str, provider_config: dict) -> tuple[str, str]:
saved_key = provider_config.get("api_key") or ""
if saved_key:
return str(saved_key), "runtime"
preset = get_web_search_provider_preset(provider)
return _resolve_web_search_env_secret(preset.get("api_key_env") or "", "WEB_SEARCH_API_KEY")
def _build_web_search_payload(
current_payload: dict,
update: WebSearchIntegrationUpdate | None,
) -> dict:
current_web_search = _normalize_web_search_payload(current_payload.get("web_search") or {})
if update is None:
return current_web_search
provider_id = normalize_web_search_provider(update.default_provider or update.provider)
current_providers = {
provider: dict(config or {})
for provider, config in current_web_search.get("providers", {}).items()
}
current_provider = current_providers.get(provider_id) or _web_search_provider_defaults(provider_id)
current_key, current_key_source = _resolve_web_search_api_key(provider_id, current_provider)
current_key_preview = _mask_secret(current_key, current_key_source)["preview"]
provider_payload = {
**_web_search_provider_defaults(provider_id),
**current_provider,
"provider": provider_id,
"base_url": update.base_url.strip()
or current_provider.get("base_url")
or _web_search_provider_defaults(provider_id).get("base_url")
or "",
"max_results": update.max_results,
"timeout_seconds": update.timeout_seconds,
"endpoint_path": update.endpoint_path.strip() or current_provider.get("endpoint_path") or "",
"search_depth": update.search_depth.strip() or "basic",
"engine": update.engine.strip() or "google",
"include_answer": update.include_answer,
"include_raw_content": update.include_raw_content,
"include_text": update.include_text,
"categories": update.categories.strip() or "general",
"engines": update.engines,
"search_path": update.search_path.strip() or current_provider.get("search_path") or "",
"scrape_path": update.scrape_path.strip() or current_provider.get("scrape_path") or "",
"scrape_formats": update.scrape_formats or ["markdown"],
}
if not _is_secret_placeholder(update.api_key, current_key_preview):
provider_payload["api_key"] = str(update.api_key).strip()
elif current_provider.get("api_key"):
provider_payload["api_key"] = current_provider.get("api_key") or ""
else:
provider_payload["api_key"] = ""
current_providers[provider_id] = provider_payload
return {
"enabled": update.enabled,
"default_provider": provider_id,
"providers": current_providers,
}
def _runtime_config_from_web_search_payload(web_search_payload: dict) -> WebSearchConfig:
normalized = _normalize_web_search_payload(web_search_payload)
provider_id = normalized["default_provider"]
provider_config = normalized["providers"].get(provider_id) or _web_search_provider_defaults(provider_id)
api_key, _source = _resolve_web_search_api_key(provider_id, provider_config)
provider_models = {
provider: WebSearchProviderConfig(**{
**config,
"api_key": (
api_key if provider == provider_id else _resolve_web_search_api_key(provider, config)[0]
),
})
for provider, config in normalized["providers"].items()
}
return WebSearchConfig(
enabled=normalized["enabled"],
default_provider=provider_id,
provider=provider_id,
providers=provider_models,
)
async def get_runtime_web_search_config(db: AsyncSession) -> WebSearchConfig:
runtime_record = await get_setting_record(db, "external_integrations")
payload = merge_with_defaults(
"external_integrations",
runtime_record.payload if runtime_record else None,
)
return _runtime_config_from_web_search_payload(payload.get("web_search") or {})
async def get_web_search_client(db: AsyncSession) -> WebSearchClient:
return WebSearchClient(await get_runtime_web_search_config(db))
async def get_runtime_ai_provider_config(db: AsyncSession) -> dict:
runtime_record = await get_setting_record(db, "external_integrations")
payload = merge_with_defaults(
@@ -459,6 +683,7 @@ async def serialize_external_integrations(db: AsyncSession) -> dict:
runtime_setting.payload if runtime_setting else None,
)
normalized_ai = _normalize_ai_provider_payload(raw_payload.get("ai_provider") or {})
normalized_web_search = _normalize_web_search_payload(raw_payload.get("web_search") or {})
default_provider = normalized_ai["default_provider"]
providers_payload: dict[str, dict] = {}
for provider in sorted({
@@ -480,6 +705,32 @@ async def serialize_external_integrations(db: AsyncSession) -> dict:
"source": "runtime" if provider_config.get("api_key") else (api_key_source or "preset"),
}
display_llm_config = providers_payload.get(default_provider) or _provider_defaults(default_provider)
web_search_providers_payload: dict[str, dict] = {}
for provider in sorted({
*[preset["provider"] for preset in list_web_search_provider_presets()],
*normalized_web_search["providers"].keys(),
normalized_web_search["default_provider"],
}):
provider_id = normalize_web_search_provider(provider)
provider_config = (
normalized_web_search["providers"].get(provider_id)
or _web_search_provider_defaults(provider_id)
)
api_key, api_key_source = _resolve_web_search_api_key(provider_id, provider_config)
web_search_providers_payload[provider_id] = {
**{
key: value
for key, value in provider_config.items()
if key != "api_key"
},
"provider": provider_id,
"api_key": _mask_secret(api_key, api_key_source),
"source": "runtime" if provider_config.get("api_key") else (api_key_source or "preset"),
}
display_web_search_config = (
web_search_providers_payload.get(normalized_web_search["default_provider"])
or _web_search_provider_defaults(normalized_web_search["default_provider"])
)
barentswatch_record = await get_barentswatch_config_record(db)
barentswatch_auth = barentswatch_record.auth_config if barentswatch_record else {}
barentswatch_auth = barentswatch_auth or {}
@@ -509,6 +760,28 @@ async def serialize_external_integrations(db: AsyncSession) -> dict:
),
"source": resolved_barentswatch.credential_source,
},
"web_search": {
"enabled": normalized_web_search["enabled"],
"default_provider": normalized_web_search["default_provider"],
"provider": normalized_web_search["default_provider"],
"base_url": display_web_search_config.get("base_url") or "",
"api_key": display_web_search_config.get("api_key") or _mask_secret(None),
"providers": web_search_providers_payload,
"max_results": int(display_web_search_config.get("max_results") or 5),
"timeout_seconds": int(display_web_search_config.get("timeout_seconds") or 20),
"endpoint_path": display_web_search_config.get("endpoint_path") or "",
"search_depth": display_web_search_config.get("search_depth") or "basic",
"engine": display_web_search_config.get("engine") or "google",
"include_answer": bool(display_web_search_config.get("include_answer", False)),
"include_raw_content": bool(display_web_search_config.get("include_raw_content", False)),
"include_text": bool(display_web_search_config.get("include_text", False)),
"categories": display_web_search_config.get("categories") or "general",
"engines": display_web_search_config.get("engines") or [],
"search_path": display_web_search_config.get("search_path") or "",
"scrape_path": display_web_search_config.get("scrape_path") or "",
"scrape_formats": display_web_search_config.get("scrape_formats") or ["markdown"],
"source": "runtime" if runtime_setting else "env",
},
}
@@ -518,8 +791,13 @@ async def save_external_integrations_payload(
) -> dict:
current_payload = await get_setting_payload(db, "external_integrations")
ai_payload = _build_ai_provider_payload(current_payload, update.ai_provider)
web_search_payload = _build_web_search_payload(current_payload, update.web_search)
await save_setting_payload(db, "external_integrations", {"ai_provider": ai_payload})
await save_setting_payload(
db,
"external_integrations",
{"ai_provider": ai_payload, "web_search": web_search_payload},
)
default_endpoint = get_data_sources_config().get_yaml_url("barentswatch_vessels")
barentswatch_record = await get_barentswatch_config_record(db)
@@ -754,7 +1032,12 @@ async def connect_ai_provider_integration(
constraints=["回复尽量简短。"],
)
)
await save_setting_payload(db, "external_integrations", {"ai_provider": draft_ai_payload})
current_web_search = _normalize_web_search_payload(current_payload.get("web_search") or {})
await save_setting_payload(
db,
"external_integrations",
{"ai_provider": draft_ai_payload, "web_search": current_web_search},
)
return {
"success": True,
"connected": True,
@@ -799,6 +1082,74 @@ async def reveal_ai_provider_secrets(
}
@router.get("/integrations/web-search/presets")
async def get_web_search_presets(
current_user: User = Depends(get_current_user),
):
return {"data": list_web_search_provider_presets()}
@router.get("/integrations/web-search/secrets")
async def reveal_web_search_secrets(
provider: str = Query(default=""),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
current_payload = await get_setting_payload(db, "external_integrations")
web_search_payload = _normalize_web_search_payload(current_payload.get("web_search") or {})
provider_id = normalize_web_search_provider(provider or web_search_payload["default_provider"])
provider_config = (
web_search_payload["providers"].get(provider_id)
or _web_search_provider_defaults(provider_id)
)
api_key, api_key_source = _resolve_web_search_api_key(provider_id, provider_config)
return {
"provider": provider_id,
"api_key": api_key,
"api_key_source": api_key_source,
}
@router.post("/integrations/web-search/connect")
async def connect_web_search_integration(
payload: WebSearchIntegrationUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
current_payload = await get_setting_payload(db, "external_integrations")
draft_web_search_payload = _build_web_search_payload(current_payload, payload)
runtime_config = _runtime_config_from_web_search_payload(draft_web_search_payload)
client = WebSearchClient(runtime_config)
try:
results = await client.test_connection()
return {
"success": True,
"connected": True,
"message": "WebSearch 连接成功。",
"provider": runtime_config.default_provider,
"results": [item.model_dump(mode="json") for item in results[:3]],
}
except WebSearchConfigurationError as exc:
return {
"success": False,
"connected": False,
"message": str(exc),
}
except WebSearchError as exc:
return {
"success": False,
"connected": False,
"message": str(exc),
}
except Exception as exc:
return {
"success": False,
"connected": False,
"message": f"WebSearch 连接测试失败: {exc}",
}
@router.get("/credential-guides/{provider}")
async def read_credential_guide(
provider: str,
@@ -819,7 +1170,15 @@ async def generate_provider_credential_guide(
ai_client: AIProviderClient = Depends(get_ai_provider_client),
):
try:
return {"guide": await generate_credential_guide(db, provider, ai_client)}
web_search_client = await get_web_search_client(db)
return {
"guide": await generate_credential_guide(
db,
provider,
ai_client,
web_search_client,
)
}
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc

View File

@@ -38,8 +38,12 @@ from app.services.compute_center_locations import (
upsert_compute_center_location,
)
from app.services.ai_client import get_ai_provider_client
from app.api.v1.settings import get_web_search_client
from app.services.collectors.bgp_common import RIPE_RIS_COLLECTOR_COORDS
from app.services.location.llm_fallback import collect_llm_location_fallback_candidate
from app.services.location.llm_fallback import (
collect_llm_location_fallback_candidate,
collect_location_search_evidence,
)
from app.services.persistent_logs import record_system_log
from app.services.vessel_ais_aggregation import (
build_field_conflict_candidates,
@@ -1879,21 +1883,33 @@ async def collect_compute_center_location(
city=city,
country=country,
)
llm_result = None
try:
web_search_client = await get_web_search_client(db)
search_result = await collect_location_search_evidence(
web_search_client=web_search_client,
query=query,
entity_type="compute_center",
)
attempted_queries = [*attempted_queries, *search_result.attempted_queries]
if not search_result.evidence:
llm_failure_reason = search_result.failure_reason
raise RuntimeError(search_result.failure_reason or "no WebSearch evidence")
provider_client = await get_ai_provider_client(db)
llm_result = await collect_llm_location_fallback_candidate(
provider_client=provider_client,
query=query,
entity_type="compute_center",
attempted_queries=attempted_queries,
search_evidence=search_result.evidence,
)
except Exception as exc:
llm_result = None
llm_failure_reason = f"LLM location factcheck unavailable: {exc}"
attempted_queries = [
*attempted_queries,
f"llm_factcheck:compute_center:{name or source_id or 'unknown'}",
]
if llm_failure_reason is None:
llm_failure_reason = f"LLM location factcheck unavailable: {exc}"
attempted_queries = [
*attempted_queries,
f"llm_factcheck:compute_center:{name or source_id or 'unknown'}",
]
if llm_result is not None:
attempted_queries = [*attempted_queries, *llm_result.attempted_queries]
candidates = llm_result.candidates

View File

@@ -0,0 +1,7 @@
"""Backend-owned AI tool services.
The services in this package are business tools used by Planet's backend
orchestrators. They intentionally live outside ``aiprovider`` so model transport
stays separate from evidence collection and domain policy.
"""

View File

@@ -0,0 +1,48 @@
from __future__ import annotations
import hashlib
from typing import Iterable
from app.services.ai_tools.schemas import FetchedEvidence, SearchEvidence
def evidence_content_hash(text: str) -> str:
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def normalize_search_evidence(items: Iterable[SearchEvidence], *, limit: int = 5) -> list[dict]:
normalized: list[dict] = []
seen_urls: set[str] = set()
for item in items:
if not item.url or item.url in seen_urls:
continue
seen_urls.add(item.url)
normalized.append(
{
"title": item.title,
"url": item.url,
"snippet": item.snippet,
"content": item.compact_text(),
"score": item.score,
"source_provider": item.source_provider,
"retrieved_at": item.retrieved_at.isoformat(),
"metadata": item.metadata,
}
)
if len(normalized) >= limit:
break
return normalized
def normalize_fetched_evidence(item: FetchedEvidence, *, text_limit: int = 1200) -> dict:
text = " ".join(item.text.split())[:text_limit]
return {
"title": item.title,
"url": item.final_url or item.url,
"text": text,
"content_hash": item.content_hash or evidence_content_hash(item.text),
"extractor": item.extractor,
"retrieved_at": item.retrieved_at.isoformat(),
"metadata": item.metadata,
}

View File

@@ -0,0 +1,63 @@
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from pydantic import BaseModel, Field
class SearchEvidence(BaseModel):
title: str = ""
url: str = ""
snippet: str = ""
content: str = ""
score: float | None = None
source_provider: str = ""
retrieved_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
metadata: dict[str, Any] = Field(default_factory=dict)
def compact_text(self, limit: int = 700) -> str:
text = " ".join((self.content or self.snippet or "").split())
return text[:limit]
class FetchedEvidence(BaseModel):
url: str
final_url: str = ""
title: str = ""
text: str = ""
content_hash: str = ""
extractor: str = "basic_html"
retrieved_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
metadata: dict[str, Any] = Field(default_factory=dict)
class WebSearchProviderConfig(BaseModel):
provider: str = "tavily"
base_url: str = ""
api_key: str = ""
max_results: int = Field(default=5, ge=1, le=20)
timeout_seconds: int = Field(default=20, ge=3, le=120)
endpoint_path: str = ""
search_depth: str = "basic"
engine: str = "google"
include_answer: bool = False
include_raw_content: bool = False
include_text: bool = False
categories: str = "general"
engines: list[str] = Field(default_factory=list)
search_path: str = ""
scrape_path: str = ""
scrape_formats: list[str] = Field(default_factory=lambda: ["markdown"])
class WebSearchConfig(BaseModel):
enabled: bool = False
default_provider: str = "tavily"
provider: str = "tavily"
providers: dict[str, WebSearchProviderConfig] = Field(default_factory=dict)
@property
def active_provider_config(self) -> WebSearchProviderConfig:
return self.providers.get(self.default_provider) or self.providers.get(self.provider) or WebSearchProviderConfig(provider=self.default_provider or self.provider)

View File

@@ -0,0 +1,56 @@
from __future__ import annotations
import hashlib
import httpx
from bs4 import BeautifulSoup
from app.services.ai_tools.schemas import FetchedEvidence
class WebFetchError(RuntimeError):
pass
def _extract_title_and_text(html: str) -> tuple[str, str]:
soup = BeautifulSoup(html, "html.parser")
for tag in soup(["script", "style", "noscript", "svg"]):
tag.decompose()
title = soup.title.get_text(" ", strip=True) if soup.title else ""
main = soup.find("main") or soup.find("article") or soup.body or soup
text = main.get_text("\n", strip=True)
lines = [line.strip() for line in text.splitlines() if line.strip()]
return title, "\n".join(lines)
async def fetch_url_evidence(
url: str,
*,
timeout_seconds: int = 20,
max_bytes: int = 1_500_000,
) -> FetchedEvidence:
if not url:
raise WebFetchError("url is required")
try:
async with httpx.AsyncClient(
timeout=timeout_seconds,
follow_redirects=True,
headers={"User-Agent": "PlanetEvidenceFetcher/1.0"},
) as client:
response = await client.get(url)
response.raise_for_status()
content = response.content[:max_bytes]
except httpx.HTTPError as exc:
raise WebFetchError(f"failed to fetch page: {exc}") from exc
title, text = _extract_title_and_text(content.decode(response.encoding or "utf-8", errors="ignore"))
content_hash = hashlib.sha256(text.encode("utf-8")).hexdigest()
return FetchedEvidence(
url=url,
final_url=str(response.url),
title=title,
text=text,
content_hash=content_hash,
extractor="beautifulsoup_basic",
)

View File

@@ -0,0 +1,391 @@
from __future__ import annotations
from copy import deepcopy
from typing import Any
import httpx
from app.services.ai_tools.schemas import SearchEvidence, WebSearchConfig, WebSearchProviderConfig
WEB_SEARCH_PROVIDER_PRESETS: dict[str, dict[str, Any]] = {
"tavily": {
"provider": "tavily",
"label": "Tavily",
"api_key_env": "TAVILY_API_KEY",
"base_url": "https://api.tavily.com",
"endpoint_path": "/search",
"max_results": 5,
"timeout_seconds": 20,
"search_depth": "basic",
"include_answer": False,
"include_raw_content": False,
},
"brave": {
"provider": "brave",
"label": "Brave Search API",
"api_key_env": "BRAVE_SEARCH_API_KEY",
"base_url": "https://api.search.brave.com",
"endpoint_path": "/res/v1/web/search",
"max_results": 5,
"timeout_seconds": 20,
},
"serpapi": {
"provider": "serpapi",
"label": "SerpAPI",
"api_key_env": "SERPAPI_API_KEY",
"base_url": "https://serpapi.com",
"endpoint_path": "/search.json",
"engine": "google",
"max_results": 5,
"timeout_seconds": 20,
},
"exa": {
"provider": "exa",
"label": "Exa",
"api_key_env": "EXA_API_KEY",
"base_url": "https://api.exa.ai",
"endpoint_path": "/search",
"max_results": 5,
"timeout_seconds": 20,
"include_text": False,
},
"firecrawl": {
"provider": "firecrawl",
"label": "Firecrawl Search / Scrape",
"api_key_env": "FIRECRAWL_API_KEY",
"base_url": "https://api.firecrawl.dev",
"search_path": "/v2/search",
"scrape_path": "/v2/scrape",
"max_results": 5,
"timeout_seconds": 30,
"scrape_formats": ["markdown"],
},
"searxng": {
"provider": "searxng",
"label": "SearXNG",
"api_key_env": "SEARXNG_API_KEY",
"base_url": "http://localhost:8080",
"endpoint_path": "/",
"max_results": 5,
"timeout_seconds": 20,
"categories": "general",
"engines": [],
},
}
class WebSearchError(RuntimeError):
pass
class WebSearchConfigurationError(WebSearchError):
pass
def normalize_web_search_provider(provider: str | None) -> str:
return (provider or "tavily").strip().lower() or "tavily"
def get_web_search_provider_preset(provider: str) -> dict[str, Any]:
provider_id = normalize_web_search_provider(provider)
preset = WEB_SEARCH_PROVIDER_PRESETS.get(provider_id)
if not preset:
raise ValueError(f"Unsupported web search provider: {provider}")
return deepcopy(preset)
def list_web_search_provider_presets() -> list[dict[str, Any]]:
return [get_web_search_provider_preset(provider) for provider in WEB_SEARCH_PROVIDER_PRESETS]
def provider_defaults(provider: str) -> WebSearchProviderConfig:
preset = get_web_search_provider_preset(provider)
return WebSearchProviderConfig(**{
key: value
for key, value in preset.items()
if key in WebSearchProviderConfig.model_fields
})
class WebSearchClient:
def __init__(self, config: WebSearchConfig) -> None:
self.config = config
async def search(
self,
query: str,
*,
max_results: int | None = None,
domains: list[str] | None = None,
freshness_days: int | None = None,
) -> list[SearchEvidence]:
if not self.config.enabled:
raise WebSearchConfigurationError("WebSearch is disabled.")
provider_config = self.config.active_provider_config
provider = normalize_web_search_provider(provider_config.provider)
if provider != "searxng" and not provider_config.api_key:
raise WebSearchConfigurationError(f"{provider} API key is not configured.")
query = " ".join(str(query or "").split())
if not query:
raise WebSearchConfigurationError("search query is required.")
limit = max_results or provider_config.max_results
if provider == "tavily":
return await self._search_tavily(provider_config, query, limit, domains, freshness_days)
if provider == "brave":
return await self._search_brave(provider_config, query, limit, domains)
if provider == "serpapi":
return await self._search_serpapi(provider_config, query, limit)
if provider == "exa":
return await self._search_exa(provider_config, query, limit, domains)
if provider == "firecrawl":
return await self._search_firecrawl(provider_config, query, limit)
if provider == "searxng":
return await self._search_searxng(provider_config, query, limit, domains)
raise WebSearchConfigurationError(f"Unsupported web search provider: {provider}")
async def test_connection(self) -> list[SearchEvidence]:
return await self.search("Planet WebSearch connectivity test", max_results=1)
async def _request_json(
self,
method: str,
url: str,
*,
provider_config: WebSearchProviderConfig,
headers: dict[str, str] | None = None,
params: dict[str, Any] | None = None,
json: dict[str, Any] | None = None,
) -> dict[str, Any]:
try:
async with httpx.AsyncClient(timeout=provider_config.timeout_seconds) as client:
response = await client.request(
method,
url,
headers=headers,
params=params,
json=json,
)
response.raise_for_status()
data = response.json()
except httpx.HTTPStatusError as exc:
detail = exc.response.text or exc.response.reason_phrase
raise WebSearchError(f"{provider_config.provider} request failed: {detail}") from exc
except httpx.HTTPError as exc:
raise WebSearchError(f"{provider_config.provider} request failed: {exc}") from exc
except ValueError as exc:
raise WebSearchError(f"{provider_config.provider} returned invalid JSON") from exc
return data if isinstance(data, dict) else {}
async def _search_tavily(
self,
config: WebSearchProviderConfig,
query: str,
max_results: int,
domains: list[str] | None,
freshness_days: int | None,
) -> list[SearchEvidence]:
body: dict[str, Any] = {
"api_key": config.api_key,
"query": query,
"max_results": max_results,
"search_depth": config.search_depth or "basic",
"include_answer": config.include_answer,
"include_raw_content": config.include_raw_content,
}
if domains:
body["include_domains"] = domains
if freshness_days:
body["days"] = freshness_days
data = await self._request_json(
"POST",
_join_url(config.base_url, config.endpoint_path or "/search"),
provider_config=config,
json=body,
)
return [
SearchEvidence(
title=str(item.get("title") or ""),
url=str(item.get("url") or ""),
snippet=str(item.get("content") or ""),
content=str(item.get("raw_content") or ""),
score=_float_or_none(item.get("score")),
source_provider="tavily",
metadata={"query": data.get("query") or query},
)
for item in data.get("results") or []
if isinstance(item, dict) and item.get("url")
]
async def _search_brave(
self,
config: WebSearchProviderConfig,
query: str,
max_results: int,
domains: list[str] | None,
) -> list[SearchEvidence]:
search_query = query
if domains:
search_query = f"{query} " + " ".join(f"site:{domain}" for domain in domains)
data = await self._request_json(
"GET",
_join_url(config.base_url, config.endpoint_path or "/res/v1/web/search"),
provider_config=config,
headers={"X-Subscription-Token": config.api_key},
params={"q": search_query, "count": max_results},
)
results = (data.get("web") or {}).get("results") or []
return [
SearchEvidence(
title=str(item.get("title") or ""),
url=str(item.get("url") or ""),
snippet=str(item.get("description") or ""),
source_provider="brave",
metadata={"age": item.get("age")},
)
for item in results
if isinstance(item, dict) and item.get("url")
]
async def _search_serpapi(
self,
config: WebSearchProviderConfig,
query: str,
max_results: int,
) -> list[SearchEvidence]:
data = await self._request_json(
"GET",
_join_url(config.base_url, config.endpoint_path or "/search.json"),
provider_config=config,
params={
"api_key": config.api_key,
"engine": config.engine or "google",
"q": query,
"num": max_results,
},
)
return [
SearchEvidence(
title=str(item.get("title") or ""),
url=str(item.get("link") or ""),
snippet=str(item.get("snippet") or ""),
source_provider="serpapi",
metadata={"position": item.get("position")},
)
for item in data.get("organic_results") or []
if isinstance(item, dict) and item.get("link")
]
async def _search_exa(
self,
config: WebSearchProviderConfig,
query: str,
max_results: int,
domains: list[str] | None,
) -> list[SearchEvidence]:
body: dict[str, Any] = {
"query": query,
"numResults": max_results,
}
if domains:
body["includeDomains"] = domains
if config.include_text:
body["contents"] = {"text": True}
data = await self._request_json(
"POST",
_join_url(config.base_url, config.endpoint_path or "/search"),
provider_config=config,
headers={"Authorization": f"Bearer {config.api_key}"},
json=body,
)
return [
SearchEvidence(
title=str(item.get("title") or ""),
url=str(item.get("url") or ""),
snippet=str(item.get("summary") or ""),
content=str(item.get("text") or ""),
score=_float_or_none(item.get("score")),
source_provider="exa",
metadata={"id": item.get("id")},
)
for item in data.get("results") or []
if isinstance(item, dict) and item.get("url")
]
async def _search_firecrawl(
self,
config: WebSearchProviderConfig,
query: str,
max_results: int,
) -> list[SearchEvidence]:
data = await self._request_json(
"POST",
_join_url(config.base_url, config.search_path or "/v2/search"),
provider_config=config,
headers={"Authorization": f"Bearer {config.api_key}"},
json={"query": query, "limit": max_results},
)
raw_results = data.get("data") or data.get("results") or []
return [
SearchEvidence(
title=str(item.get("title") or ""),
url=str(item.get("url") or item.get("sourceURL") or ""),
snippet=str(item.get("description") or item.get("markdown") or ""),
source_provider="firecrawl",
metadata={"status": item.get("status")},
)
for item in raw_results
if isinstance(item, dict) and (item.get("url") or item.get("sourceURL"))
]
async def _search_searxng(
self,
config: WebSearchProviderConfig,
query: str,
max_results: int,
domains: list[str] | None,
) -> list[SearchEvidence]:
search_query = query
if domains:
search_query = f"{query} " + " ".join(f"site:{domain}" for domain in domains)
params: dict[str, Any] = {
"q": search_query,
"format": "json",
"categories": config.categories or "general",
}
if config.engines:
params["engines"] = ",".join(config.engines)
headers = {"Authorization": f"Bearer {config.api_key}"} if config.api_key else None
data = await self._request_json(
"GET",
_join_url(config.base_url, config.endpoint_path or "/"),
provider_config=config,
headers=headers,
params=params,
)
results = data.get("results") or []
evidence = [
SearchEvidence(
title=str(item.get("title") or ""),
url=str(item.get("url") or ""),
snippet=str(item.get("content") or ""),
score=_float_or_none(item.get("score")),
source_provider="searxng",
metadata={"engine": item.get("engine")},
)
for item in results
if isinstance(item, dict) and item.get("url")
]
return evidence[:max_results]
def _join_url(base_url: str, path: str) -> str:
return f"{(base_url or '').rstrip('/')}/{(path or '').lstrip('/')}"
def _float_or_none(value: Any) -> float | None:
try:
return float(value)
except (TypeError, ValueError):
return None

View File

@@ -10,6 +10,8 @@ from sqlalchemy import select
from app.models.system_setting import SystemSetting
from app.schemas.ai import SituationalAnalysisRequest
from app.services.ai_client import AIProviderClient
from app.services.ai_tools.evidence_store import normalize_search_evidence
from app.services.ai_tools.web_search import WebSearchClient, WebSearchError
CREDENTIAL_GUIDES_CATEGORY = "collector_credential_guides"
@@ -153,10 +155,26 @@ async def get_credential_guide(db, provider: str) -> dict[str, Any]:
"markdown": custom.get("markdown") if custom else default.markdown,
"prompt": default.prompt,
"source": "ai" if custom else "default",
"sources": custom.get("sources", []) if custom else [],
"verification_status": (
custom.get("verification_status", "verified_with_search_evidence")
if custom
else "default_unverified"
),
"verification_error": custom.get("verification_error") if custom else None,
}
async def save_credential_guide(db, provider: str, title: str, markdown: str) -> dict[str, Any]:
async def save_credential_guide(
db,
provider: str,
title: str,
markdown: str,
*,
sources: list[dict[str, Any]] | None = None,
verification_status: str = "verified_with_search_evidence",
verification_error: str | None = None,
) -> dict[str, Any]:
default = DEFAULT_CREDENTIAL_GUIDES.get(provider)
if default is None:
raise ValueError(f"Unsupported credential guide provider: {provider}")
@@ -165,6 +183,9 @@ async def save_credential_guide(db, provider: str, title: str, markdown: str) ->
store[provider] = {
"title": title or default.title,
"markdown": markdown,
"sources": sources or [],
"verification_status": verification_status,
"verification_error": verification_error,
}
if record is None:
db.add(SystemSetting(category=CREDENTIAL_GUIDES_CATEGORY, payload=store))
@@ -192,28 +213,58 @@ async def generate_credential_guide(
db,
provider: str,
ai_client: AIProviderClient,
web_search_client: WebSearchClient | None = None,
) -> dict[str, Any]:
default = DEFAULT_CREDENTIAL_GUIDES.get(provider)
if default is None:
raise ValueError(f"Unsupported credential guide provider: {provider}")
search_evidence: list[dict[str, Any]] = []
search_error: str | None = None
if web_search_client is not None:
try:
evidence = await web_search_client.search(
_credential_guide_search_query(default),
max_results=5,
)
search_evidence = normalize_search_evidence(evidence, limit=5)
except WebSearchError as exc:
search_error = str(exc)
except Exception as exc:
search_error = f"WebSearch unavailable: {exc}"
if not search_evidence:
guide = await get_credential_guide(db, provider)
guide["verification_status"] = "unverified_no_search_evidence"
guide["verification_error"] = search_error
guide["sources"] = []
return guide
response = await ai_client.analyze(
SituationalAnalysisRequest(
title=f"Generate credential guide for {provider}",
objective=default.prompt,
objective=(
default.prompt
+ "\n只能根据 context.search_evidence 中的来源生成教程;"
+ "如果证据不足,明确说明需要以官方页面为准。"
),
context={
"provider": provider,
"current_default_guide": default.markdown,
"product_context": "Planet collector credential settings",
"search_evidence": search_evidence,
},
observations=[
"Use concise Chinese markdown.",
"Prefer stable concepts over brittle UI labels.",
"Include verification and troubleshooting steps.",
"Include a short sources section with the provided URLs.",
],
constraints=[
"Do not ask the user for secrets.",
"Do not include fabricated screenshots.",
"Do not invent source URLs or product UI labels.",
"Use only the provided search_evidence as factual support.",
"Return markdown only.",
],
)
@@ -221,4 +272,19 @@ async def generate_credential_guide(
markdown = response.content.strip()
if not markdown:
markdown = default.markdown
return await save_credential_guide(db, provider, default.title, markdown)
return await save_credential_guide(
db,
provider,
default.title,
markdown,
sources=search_evidence,
verification_status="verified_with_search_evidence",
)
def _credential_guide_search_query(default: CredentialGuideDefault) -> str:
if default.provider == "barentswatch":
return "BarentsWatch developer tutorial AIS API OAuth client credentials"
if default.provider == "aisstream":
return "AISStream API key documentation websocket stream"
return f"{default.provider} API credentials documentation"

View File

@@ -10,6 +10,8 @@ from typing import Any, Iterable
from app.core.countries import COUNTRY_ENTRIES, normalize_country
from app.schemas.ai import SituationalAnalysisRequest
from app.services.ai_client import AIProviderClient
from app.services.ai_tools.evidence_store import normalize_search_evidence
from app.services.ai_tools.web_search import WebSearchClient, WebSearchError
from app.services.location.models import LocationCandidate, LocationQuery
from app.services.location.resolvers.nominatim import build_default_nominatim_geocoder
from app.services.location.text import (
@@ -69,6 +71,13 @@ class LocationLLMFallbackResult:
failure_reason: str | None = None
@dataclass(frozen=True)
class LocationSearchEvidenceResult:
evidence: list[dict[str, Any]]
attempted_queries: list[str]
failure_reason: str | None = None
@dataclass(frozen=True)
class LocationEvidenceScore:
score: float
@@ -751,6 +760,10 @@ def _candidate_from_payload(
"name_location_hint": evidence_score.name_location_hint,
},
},
raw_payload={
"llm_payload": payload,
"search_evidence": payload.get("search_evidence") or [],
},
), None
@@ -802,6 +815,61 @@ def _observations(query: LocationQuery, attempted_queries: Iterable[str]) -> lis
return observations
def _location_search_query(query: LocationQuery, entity_type: str) -> str:
extra = query.extra or {}
parts = [
coerce_str(query.name),
coerce_str(extra.get("site")),
coerce_str(extra.get("operator")),
coerce_str(extra.get("organization")),
coerce_str(query.city),
coerce_str(query.country),
"physical location",
]
if entity_type == "bgp_collector":
parts.append("route collector city")
elif entity_type == "compute_center":
parts.append("datacenter supercomputer facility city")
return " ".join(part for part in parts if part)
async def collect_location_search_evidence(
*,
web_search_client: WebSearchClient,
query: LocationQuery,
entity_type: str,
max_results: int = 5,
) -> LocationSearchEvidenceResult:
search_query = _location_search_query(query, entity_type)
attempt = f"web_search:{entity_type}:{search_query}"
try:
evidence = await web_search_client.search(search_query, max_results=max_results)
except WebSearchError as exc:
return LocationSearchEvidenceResult(
evidence=[],
attempted_queries=[attempt],
failure_reason=f"WebSearch location evidence failed: {exc}",
)
except Exception as exc:
return LocationSearchEvidenceResult(
evidence=[],
attempted_queries=[attempt],
failure_reason=f"WebSearch location evidence unavailable: {exc}",
)
normalized = normalize_search_evidence(evidence, limit=max_results)
if not normalized:
return LocationSearchEvidenceResult(
evidence=[],
attempted_queries=[attempt],
failure_reason="WebSearch returned no usable location evidence.",
)
return LocationSearchEvidenceResult(
evidence=normalized,
attempted_queries=[attempt],
failure_reason=None,
)
async def _repair_location_payload_from_text(
*,
provider_client: AIProviderClient,
@@ -862,6 +930,7 @@ async def collect_llm_location_fallback_candidate(
query: LocationQuery,
entity_type: str,
attempted_queries: Iterable[str] = (),
search_evidence: list[dict[str, Any]] | None = None,
min_confidence: float = DEFAULT_MIN_CONFIDENCE,
) -> LocationLLMFallbackResult:
"""Ask the configured LLM for one fact-checked location candidate.
@@ -871,6 +940,12 @@ async def collect_llm_location_fallback_candidate(
use this in user-triggered collection flows.
"""
attempt = f"llm_factcheck:{entity_type}:{coerce_str(query.name) or 'unknown'}"
if search_evidence is not None and not search_evidence:
return LocationLLMFallbackResult(
candidates=[],
attempted_queries=[attempt],
failure_reason="LLM location factcheck skipped: no WebSearch evidence.",
)
request = SituationalAnalysisRequest(
title=f"Location factcheck fallback for {entity_type}",
objective=(
@@ -881,6 +956,7 @@ async def collect_llm_location_fallback_candidate(
context={
"entity_type": entity_type,
"location_query": _query_context(query),
"search_evidence": search_evidence or [],
"required_json_schema": {
"latitude": "number",
"longitude": "number",
@@ -905,6 +981,7 @@ async def collect_llm_location_fallback_candidate(
"Calibrate model confidence using this rubric: 0.85-1.0 for exact facility coordinates backed by an authoritative source; 0.70-0.84 for a confirmed facility/campus with strong public evidence; 0.55-0.69 for a confirmed city-level location backed by credible sources but without exact facility coordinates; 0.35-0.54 for weak or ambiguous city evidence; below 0.35 when the location is mostly a guess.",
"Return evidence as objects when possible, including source, url, source_type, and entity_match.",
"Include source names or URLs in evidence when known. The backend will recompute the final confidence from model confidence plus evidence quality.",
"If search_evidence is provided, use only that evidence as factual support.",
"Prefer the facility/site if known; otherwise use the best supported city.",
],
)
@@ -939,6 +1016,23 @@ async def collect_llm_location_fallback_candidate(
),
)
payload = _normalize_llm_payload(payload)
if search_evidence:
payload["search_evidence"] = search_evidence
existing_evidence = _evidence_items(payload.get("evidence"))
payload["evidence"] = [
*existing_evidence,
*[
{
"source": item.get("title") or item.get("url"),
"url": item.get("url"),
"text": item.get("snippet") or item.get("content"),
"source_type": "web_search",
"entity_match": True,
}
for item in search_evidence
if isinstance(item, dict)
],
]
latitude, longitude = _extract_llm_coordinates(payload)
city_geocode_failure = None
if latitude in (None, 0.0) or longitude in (None, 0.0):

View File

@@ -51,6 +51,7 @@ class LocationCandidate:
matched_location_name: str | None = None
location_verified_at: str | None = None
suggested_registry_entry: dict[str, Any] | None = None
raw_payload: dict[str, Any] | None = None
def to_dict(self) -> dict[str, Any]:
return {
@@ -70,6 +71,7 @@ class LocationCandidate:
"matched_location_name": self.matched_location_name,
"location_verified_at": self.location_verified_at,
"suggested_registry_entry": self.suggested_registry_entry,
"raw_payload": self.raw_payload,
}

View File

@@ -137,6 +137,20 @@ async def test_collect_bgp_collector_location_uses_llm_when_candidates_empty(mon
lambda **_kwargs: ([], ["Lyon, France"]),
)
from app.services.location.llm_fallback import LocationSearchEvidenceResult
async def _search_evidence(**_kwargs):
return LocationSearchEvidenceResult(
evidence=[
{
"title": "RRC source",
"url": "https://example.test/rrc",
"snippet": "rrc-mystery is in Lyon.",
}
],
attempted_queries=["web_search:bgp_collector:rrc-mystery Lyon France physical location route collector city"],
)
async def _fallback(**_kwargs):
return LocationLLMFallbackResult(
candidates=[llm_candidate],
@@ -144,6 +158,8 @@ async def test_collect_bgp_collector_location_uses_llm_when_candidates_empty(mon
)
monkeypatch.setattr(bgp_api, "get_ai_provider_client", AsyncMock(return_value=object()))
monkeypatch.setattr(bgp_api, "get_web_search_client", AsyncMock(return_value=object()))
monkeypatch.setattr(bgp_api, "collect_location_search_evidence", _search_evidence)
monkeypatch.setattr(bgp_api, "collect_llm_location_fallback_candidate", _fallback)
response = await bgp_api.collect_bgp_collector_location(
@@ -158,6 +174,7 @@ async def test_collect_bgp_collector_location_uses_llm_when_candidates_empty(mon
assert response["best_candidate"]["needs_confirmation"] is True
assert response["attempted_queries"] == [
"Lyon, France",
"web_search:bgp_collector:rrc-mystery Lyon France physical location route collector city",
"llm_factcheck:bgp_collector:rrc-mystery",
]

View File

@@ -569,7 +569,19 @@ async def test_collect_compute_center_location_uses_llm_when_candidates_empty(mo
lambda **_kwargs: ([], ["Mystery Cluster, France"]),
)
from app.services.location.llm_fallback import LocationLLMFallbackResult
from app.services.location.llm_fallback import LocationLLMFallbackResult, LocationSearchEvidenceResult
async def _search_evidence(**_kwargs):
return LocationSearchEvidenceResult(
evidence=[
{
"title": "Mystery Cluster source",
"url": "https://example.test/mystery",
"snippet": "Mystery Cluster is in Lyon.",
}
],
attempted_queries=["web_search:compute_center:Mystery Cluster France physical location"],
)
async def _fallback(**_kwargs):
return LocationLLMFallbackResult(
@@ -578,6 +590,8 @@ async def test_collect_compute_center_location_uses_llm_when_candidates_empty(mo
)
monkeypatch.setattr(visualization_api, "get_ai_provider_client", AsyncMock(return_value=object()))
monkeypatch.setattr(visualization_api, "get_web_search_client", AsyncMock(return_value=object()))
monkeypatch.setattr(visualization_api, "collect_location_search_evidence", _search_evidence)
monkeypatch.setattr(visualization_api, "collect_llm_location_fallback_candidate", _fallback)
response = await visualization_api.collect_compute_center_location(
@@ -595,6 +609,7 @@ async def test_collect_compute_center_location_uses_llm_when_candidates_empty(mo
assert response["best_candidate"]["needs_confirmation"] is True
assert response["attempted_queries"] == [
"Mystery Cluster, France",
"web_search:compute_center:Mystery Cluster France physical location",
"llm_factcheck:compute_center:Mystery Cluster",
]

View File

@@ -0,0 +1,261 @@
from types import SimpleNamespace
import pytest
from app.api.v1 import settings as settings_api
from app.api.v1.settings import (
WebSearchIntegrationUpdate,
_build_web_search_payload,
_mask_secret,
_normalize_web_search_payload,
_resolve_web_search_api_key,
)
from app.services.ai_tools.schemas import WebSearchConfig, WebSearchProviderConfig
from app.services.ai_tools.web_search import WebSearchClient
from app.services.credential_guides import generate_credential_guide
from app.services.location.llm_fallback import (
collect_llm_location_fallback_candidate,
collect_location_search_evidence,
)
from app.services.location.models import LocationQuery
@pytest.fixture(autouse=True)
def isolated_web_search_env_files(monkeypatch, tmp_path):
env_file = tmp_path / ".env"
monkeypatch.setattr(settings_api, "WEB_SEARCH_ENV_FILES", (env_file,))
return env_file
def test_normalize_web_search_payload_adds_default_provider():
payload = _normalize_web_search_payload({})
assert payload["default_provider"] == "tavily"
assert payload["providers"]["tavily"]["base_url"] == "https://api.tavily.com"
def test_web_search_key_prefers_provider_env(isolated_web_search_env_files):
isolated_web_search_env_files.write_text(
"TAVILY_API_KEY=tavily-env-key\nWEB_SEARCH_API_KEY=generic-search-key\n",
encoding="utf-8",
)
value, source = _resolve_web_search_api_key("tavily", {"api_key": ""})
assert value == "tavily-env-key"
assert source == "env_file"
def test_build_web_search_payload_keeps_saved_key_when_preview_submitted():
current = {
"web_search": {
"default_provider": "tavily",
"providers": {
"tavily": {
"provider": "tavily",
"api_key": "tvly-old-secret",
},
},
}
}
update = WebSearchIntegrationUpdate(
enabled=True,
provider="tavily",
base_url="https://api.tavily.com",
api_key=_mask_secret("tvly-old-secret")["preview"],
)
payload = _build_web_search_payload(current, update)
assert payload["enabled"] is True
assert payload["providers"]["tavily"]["api_key"] == "tvly-old-secret"
@pytest.mark.asyncio
async def test_tavily_adapter_normalizes_results(monkeypatch):
config = WebSearchConfig(
enabled=True,
default_provider="tavily",
provider="tavily",
providers={
"tavily": WebSearchProviderConfig(
provider="tavily",
base_url="https://api.tavily.com",
api_key="key",
)
},
)
client = WebSearchClient(config)
async def fake_request_json(*args, **kwargs):
return {
"query": "Alem.Cloud",
"results": [
{
"title": "Alem.Cloud official",
"url": "https://example.test/alem",
"content": "Alem.Cloud is in Astana.",
"score": 0.9,
}
],
}
monkeypatch.setattr(client, "_request_json", fake_request_json)
results = await client.search("Alem.Cloud")
assert results[0].source_provider == "tavily"
assert results[0].url == "https://example.test/alem"
assert "Astana" in results[0].snippet
@pytest.mark.asyncio
async def test_searxng_adapter_allows_empty_api_key(monkeypatch):
config = WebSearchConfig(
enabled=True,
default_provider="searxng",
provider="searxng",
providers={
"searxng": WebSearchProviderConfig(
provider="searxng",
base_url="http://localhost:8080",
api_key="",
)
},
)
client = WebSearchClient(config)
async def fake_request_json(*args, **kwargs):
return {
"results": [
{
"title": "TAIPEI-1",
"url": "https://example.test/taipei",
"content": "TAIPEI-1 is in Taipei.",
"score": 2,
"engine": "duckduckgo",
}
],
}
monkeypatch.setattr(client, "_request_json", fake_request_json)
results = await client.search("TAIPEI-1")
assert results[0].source_provider == "searxng"
assert results[0].metadata["engine"] == "duckduckgo"
@pytest.mark.asyncio
async def test_location_search_evidence_returns_failure_on_empty_results(monkeypatch):
class EmptySearchClient:
async def search(self, *args, **kwargs):
return []
result = await collect_location_search_evidence(
web_search_client=EmptySearchClient(),
query=LocationQuery(name="TAIPEI-1", country="Taiwan"),
entity_type="compute_center",
)
assert result.evidence == []
assert "no usable" in result.failure_reason
@pytest.mark.asyncio
async def test_llm_location_fallback_skips_when_search_evidence_empty():
class ExplodingAIClient:
async def analyze(self, *_args, **_kwargs):
raise AssertionError("LLM should not be called without evidence")
result = await collect_llm_location_fallback_candidate(
provider_client=ExplodingAIClient(),
query=LocationQuery(name="TAIPEI-1", country="Taiwan"),
entity_type="compute_center",
search_evidence=[],
)
assert result.candidates == []
assert "no WebSearch evidence" in result.failure_reason
@pytest.mark.asyncio
async def test_credential_guide_keeps_default_without_search_evidence():
class EmptySearchClient:
async def search(self, *args, **kwargs):
return []
class ExplodingAIClient:
async def analyze(self, *_args, **_kwargs):
raise AssertionError("AI should not be called without search evidence")
async def fake_get_store(_db):
return None, {}
import app.services.credential_guides as credential_guides
original = credential_guides._get_guide_store
credential_guides._get_guide_store = fake_get_store
try:
guide = await generate_credential_guide(
object(),
"barentswatch",
ExplodingAIClient(),
EmptySearchClient(),
)
finally:
credential_guides._get_guide_store = original
assert guide["source"] == "default"
assert guide["verification_status"] == "unverified_no_search_evidence"
@pytest.mark.asyncio
async def test_credential_guide_uses_search_evidence(monkeypatch):
class SearchClient:
async def search(self, *args, **kwargs):
from app.services.ai_tools.schemas import SearchEvidence
return [
SearchEvidence(
title="Official docs",
url="https://docs.example.test",
snippet="Create an AIS client.",
source_provider="tavily",
)
]
class AIClient:
async def analyze(self, payload):
assert payload.context["search_evidence"]
return SimpleNamespace(content="## Generated\n\nSources included.")
saved = {}
async def fake_get_store(_db):
return None, saved
async def fake_save(db, provider, title, markdown, **metadata):
return {
"provider": provider,
"title": title,
"markdown": markdown,
"source": "ai",
**metadata,
}
import app.services.credential_guides as credential_guides
monkeypatch.setattr(credential_guides, "_get_guide_store", fake_get_store)
monkeypatch.setattr(credential_guides, "save_credential_guide", fake_save)
guide = await generate_credential_guide(
object(),
"barentswatch",
AIClient(),
SearchClient(),
)
assert guide["source"] == "ai"
assert guide["verification_status"] == "verified_with_search_evidence"
assert guide["sources"][0]["url"] == "https://docs.example.test"

View File

@@ -8,6 +8,21 @@ This project follows the repository versioning rule:
- `improvement` -> `+0.0.1`bugfix + 小功能混合)
- `bugfix` -> `+0.0.1`
## [0.51.0] — 2026-05-11
Released: 2026-05-11
### ✨ Highlights
- 新增 AI Settings 控制台页面与 `backend/app/services/ai_tools/` 工具层,串通 Web Search Provider 与轻量 Agent orchestrator。
- 重写 Earth 算力中心候选「预览 / 保存」交互:单一委托 click + 内存 candidate Map新增空心呼吸圈预览保存后即时生成正式图标后台刷新失败不再误报为保存失败。
- 重写动作捕捉 zoom 识别mirror-safe 的 trend + pose hold 双通道,张开/合拢手势直接对应 zoom_in/out 并支持持续触发;单臂 rotate 仅在另一只手明确静止时才允许。
### Improvements
- 同步中英文 `earth-frontend-context.md``frontend-admin-frontend-context.md``faq.md``manual.md``quickstart.md`
- Earth 模块多处优化bgp-cruise-adapter、interactable、satellites、presentation-controller、controls 调整与回归测试补全。
---
## [0.50.0] — 2026-05-10
Released: 2026-05-10

View File

@@ -179,6 +179,217 @@ Secret resolution should follow the existing settings pattern:
2. provider-specific environment variable, for example `TAVILY_API_KEY`
3. generic fallback `WEB_SEARCH_API_KEY`
### Common WebSearch Providers
The first implementation should model WebSearch as a provider-specific adapter
behind one internal interface:
```text
SearchEvidenceProvider.search(query, max_results, domains, freshness_days)
-> list[SearchEvidence]
```
Recommended provider ids and environment variables:
| Provider | Provider id | Env key | Default base URL | Primary use |
| --- | --- | --- | --- | --- |
| Tavily | `tavily` | `TAVILY_API_KEY` | `https://api.tavily.com` | Default hosted search for agent/RAG style results |
| Brave Search API | `brave` | `BRAVE_SEARCH_API_KEY` | `https://api.search.brave.com` | Independent web index and low-level SERP results |
| SerpAPI | `serpapi` | `SERPAPI_API_KEY` | `https://serpapi.com` | Search-engine-backed SERP data with engine options |
| Exa | `exa` | `EXA_API_KEY` | `https://api.exa.ai` | Neural/semantic web search and result contents |
| Firecrawl Search / Scrape | `firecrawl` | `FIRECRAWL_API_KEY` | `https://api.firecrawl.dev` | Search plus page scrape/markdown extraction |
| SearXNG | `searxng` | optional `SEARXNG_API_KEY` | self-hosted instance URL | Self-hosted metasearch when external search APIs are undesirable |
The normalized configuration should support per-provider defaults while keeping
one active provider:
```text
external_integrations.web_search
enabled: true
default_provider: tavily
providers:
tavily:
base_url: https://api.tavily.com
api_key: <secret>
max_results: 5
search_depth: basic
include_answer: false
include_raw_content: false
brave:
base_url: https://api.search.brave.com
api_key: <secret>
endpoint_path: /res/v1/web/search
max_results: 5
serpapi:
base_url: https://serpapi.com
api_key: <secret>
endpoint_path: /search.json
engine: google
max_results: 5
exa:
base_url: https://api.exa.ai
api_key: <secret>
endpoint_path: /search
max_results: 5
include_text: false
firecrawl:
base_url: https://api.firecrawl.dev
api_key: <secret>
search_path: /v2/search
scrape_path: /v2/scrape
max_results: 5
scrape_formats: [markdown]
searxng:
base_url: http://localhost:8080
api_key: <optional secret>
endpoint_path: /
max_results: 5
categories: general
engines: []
```
Adapter notes:
- Tavily should call `/search` and normalize title, URL, snippet/content, score,
and optional raw content.
- Brave should call `/res/v1/web/search` and map web results into the same
`SearchEvidence` shape.
- SerpAPI should call `/search.json`, pass `engine`, and normalize organic
results. Search-engine-specific fields should remain in provider metadata.
- Exa should call `/search`; optional result text should be treated as fetched
content only when enabled.
- Firecrawl can be used both as `web_search` and `web_fetch`: `/v2/search`
returns result URLs/descriptions and may include scrape options, while
`/v2/scrape` can produce markdown for a selected URL.
- SearXNG should query the configured instance with `q` and `format=json`.
Public instances should not be assumed reliable for production; a controlled
self-hosted instance is preferred.
The settings UI should expose only provider, base URL, key, max results, and a
test button in the first version. Provider-specific advanced fields can stay
collapsed or backend-only until a real workflow needs them.
### Frontend Configuration Window
Add a WebSearch configuration panel to the existing settings page, next to the
LLM provider configuration. It should behave like the current AI provider secret
controls: clear configured state, masked preview, explicit show/hide, test
connection, and save feedback.
First-version visible fields:
```text
WebSearch Provider
API Base URL
API Key
Max Results
Timeout Seconds
Enable WebSearch
Test Connection
Save
```
Provider dropdown options:
```text
Tavily
Brave Search API
SerpAPI
Exa
Firecrawl Search / Scrape
SearXNG
```
Field behavior:
- Switching provider loads that provider's saved config and masked key preview.
- Empty key input means keep the existing saved or environment key.
- Typing a new key replaces only the selected provider's key.
- Show key reveals the full current input value when the backend reveal endpoint
allows it; hide key returns to the prefix-preserving masked preview.
- The configured badge should only show `已配置` or `未配置`, not repeat the
masked key text.
- `Test Connection` sends the current unsaved draft to the backend and should
not require a separate save first.
- A successful test may save the draft as the new WebSearch default only if the
API endpoint is explicitly designed to mirror the AI provider test behavior.
Otherwise, test should be read-only and the Save button should persist.
- Save success and test success must show visible feedback. Failures should show
provider-specific but secret-safe error messages.
Provider-specific UI hints:
| Provider | UI hint |
| --- | --- |
| Tavily | Good default for agent/RAG style search. |
| Brave Search API | Uses Brave's independent search index. |
| SerpAPI | Supports search-engine-specific parameters such as `engine`. |
| Exa | Good for semantic search and optional result text. |
| Firecrawl | Can search and scrape pages into markdown. |
| SearXNG | Requires a reachable self-hosted or trusted instance URL. |
Advanced fields can live in a collapsed section:
```text
Endpoint Path
Search Depth
Engine
Categories
Engines
Include Raw Content
Scrape Formats
Domain Allowlist
```
The first version should keep the UI conservative. It should not expose every
provider knob until backend workflows use those knobs.
### Web Fetch and Page Extraction
`web_fetch` is separate from `web_search`. Search finds candidate URLs; fetch
turns selected pages into clean, citable evidence.
Recommended extraction chain:
```text
1. plain httpx fetch
2. trafilatura extraction for static HTML
3. readability extraction as secondary cleanup
4. Playwright fetch only for allowlisted JS-heavy pages
5. Firecrawl scrape as hosted fallback when configured
```
Implementation guidance:
- Use `trafilatura` as the first local extractor because it is Python-native and
matches the backend stack.
- Prefer a Python readability implementation for local cleanup. Do not introduce
a Node-only readability dependency for backend fetch.
- Use Playwright sparingly for JavaScript-rendered pages. It should have domain
allowlists, low concurrency, strict timeouts, response size limits, and no
automatic form submission or login behavior.
- Store `content_hash`, `retrieved_at`, final URL, title, extracted text
preview, and extractor name in `ai_evidence`.
- Keep short quotes for UI review, but do not store huge page bodies directly in
every task record. Large extracted content should be truncated or stored once
by hash.
The local/self-hosted stack should look like this:
```text
SearXNG
-> SearchEvidence URLs
-> httpx fetch
-> trafilatura / readability
-> Playwright only when static extraction fails and the domain is allowed
-> normalized evidence
-> LLM structured output through AIProviderClient
```
This route gives Planet a lower-cost and more controllable search path, while
hosted providers remain available when search quality or maintenance effort
matters more than self-hosting.
## Phase 3: Limited Agent Loop

View File

@@ -98,6 +98,18 @@ Gesture recognition may run locally in the browser or inside the local Agent, bu
[motion-debug-panel.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/motion-debug-panel.js) owns the debug panel. It listens for `earth:motion-debug-frame` and draws normalized skeleton joints and bones on a canvas. The Browser Camera provider also emits `earth:motion-debug-video-source` with the local `<video>` element so the panel can show a local preview behind the skeleton; `shared.motionDebugSkeletonOnly` switches the panel back to skeleton-only rendering. `Stop Matching Gestures` dispatches `earth:motion-recognition-pause`, which suppresses gesture execution while video and skeleton drawing continue. Unmatched skeletons are red; matched gestures turn green and display the gesture name. Settings are persisted under `shared.motionDebugEnabled`, `shared.motionProvider`, and `shared.motionDebugSkeletonOnly` in `planet.earth.settings.v2`, and both the switch and provider selector reserve `data-gatekeeper-permission="earth.motion_debug"`.
The Browser Camera provider's gesture pipeline lives in `recognizeGesture()` inside [motion-browser-provider.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/motion-browser-provider.js). Detectors are evaluated in this order, first match wins:
1. **`getZoomTrend` (trend zoom)** — derived from the per-frame change in `Math.abs(rightWrist.x - leftWrist.x)`. Both wrists must cross the noise floor (`ZOOM_TREND_MIN_WRIST_DELTA = 0.010`) and stay within `ZOOM_TREND_HEIGHT_TOLERANCE` of each other vertically. Growing span → `zoom_in`, shrinking span → `zoom_out`. Trend has highest priority so mid-motion frames cannot be hijacked by the layer/focus/rotate detectors.
2. **`getZoomHoldPose` (sustained zoom)** — after motion stops, keeps emitting `zoom_in` while the wrists stay at chest level or above with span > `ZOOM_HOLD_SPREAD_FACTOR × shoulderWidth` (default 1.30), and `zoom_out` while elbows sit visibly outward and span < `ZOOM_HOLD_CLOSE_FACTOR × shoulderWidth` (default 0.85).
3. **layer / focus** — left-wrist raise + vertical motion fires `layer_prev/next`; head tilt fires `focus_prev/next`.
4. **`getRightArmPattern` (single-arm rotate)** — only considered when both `!isZoomCandidatePose(...)` and `isLeftArmAtRest(...)` hold. `isLeftArmAtRest` requires the left wrist to hang clearly below the shoulder line (≥ 0.13) and both left elbow and left wrist to stay near the body — any ambiguous left-arm posture (mid-spread, raised, held at chest) blocks single-arm rotate.
Two non-obvious decisions worth preserving:
- **Mirror-safe**: all zoom checks use `Math.abs(rightWrist.x - leftWrist.x)` and never rely on per-side x direction. `getUserMedia` returns the raw camera feed without horizontal flip, so a subject's anatomical left arm appears on the image right. A direction-based detector (e.g. "left wrist moves left, right wrist moves right") inverts on non-mirrored feeds — span-based detection is invariant.
- **Continuous vs. discrete**: `rotate`, `layer`, `focus`, and `confirm` go through `applyPoseLatch`, which emits each gesture once until the pose returns to neutral (one wave = one rotation step). Zoom intentionally bypasses the latch and re-matches every frame; downstream `GESTURE_POLICIES.zoom_in/out.cooldownMs = 120` rate-limits to ~8 emits/sec, so holding a spread pose keeps zooming in until the user changes their pose. Do not reuse the latch for zoom — that semantic difference is the point.
[presentation-controller.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/presentation-controller.js) is the new Presentation layer. In the first stage only Motion uses it: `motion-cruise-adapter.js` uses a persistent presentation that reuses the cruise fixed-card placement and connector, but mouse movement does not auto-hide the card. The connector recalculates source and target anchors every frame so dragged cards, globe rotation, and moving targets stay connected. BGP/News still use the existing `CruiseSequencer` auto-advance path to preserve the old cruise experience.
### 6. Globe and Terrain
@@ -134,6 +146,10 @@ The compute-center layer row has a notification badge for GeoJSON `unresolved` r
Location candidate state in the details card is cached in [info-card.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/info-card.js) by `entityType:entityId`. If the user closes the details card or unresolved queue and reopens the same compute center / BGP collector, previously collected candidates and status text are restored. Header-level `一键采用` prefers cached candidates, avoiding repeated online geocoding or LLM factcheck calls. After a location is saved, that entity's candidate list is cleared to a "refreshing layer" status so stale candidates do not keep misleading the user.
The `预览 / 保存` buttons on each candidate row use a single delegated `click` handler per candidate root (the `[data-collect-cache-key]` block in the details card, or `[data-unresolved-item]` in the unresolved queue), guarded by a `data-candidate-actions-bound` flag so it cannot be double-bound. Direct `pointerup` / `click` listeners on individual buttons and overlapping delegated handlers were removed. Candidate objects are no longer JSON-stringified into an HTML attribute and parsed back; buttons only carry `data-candidate-index`, and the handler resolves the candidate object from a module-level `Map` keyed by cache-key. This removes the entire class of failures caused by HTML entity escaping of `&` / `<` / `"` in candidate fields. Clicking `预览` dispatches `earth:preview-location-candidate`; `main.js`'s `previewLocationCandidate()` calls `showComputeCenterLocationPreview()`, which attaches a hollow breathing-ring sprite pair at the candidate coordinates (visually mirroring the BGP event ring) and focuses the camera on the candidate. Previewing another candidate replaces the ring; saving clears it and `spawnSavedComputeCenterLocation()` immediately spawns the formal compute-center interactable. Note that `main.js` has no module-level `earth` variable — every location-save / preview handler must call `const earth = getEarth();` first, otherwise the event handler throws a `ReferenceError` that the surrounding `.catch` swallows, producing the failure mode where the button "does nothing".
The `earth:compute-center-location-saved` reconciliation pipeline is deliberately silent on background-refresh failures. `spawnComputeCenterAfterLocationSave()` already presents the success toast and locked state; `refreshComputeCentersAfterLocationSave()` only reloads backend data when the scene is ready and no longer emits its own `已保存` toast. `handleComputeCenterLocationSaved()` runs refresh in the background after a successful spawn; only when spawn returns `null` (scene not ready) or throws does refresh take over the success toast. A refresh error is only `console.warn`'d — it must never surface as a `保存失败` message, because the save itself succeeded and the refresh is a follow-up sync.
### AIS Vessel Layer
The vessel layer fetches `/api/v1/visualization/geo/vessels` and renders the aggregated AIS GeoJSON through `createInteractableLayer()`. By default it does not send a `limit` parameter, and `VESSEL_CONFIG.maxRenderedMarkers = 0` means the frontend does not clip the result to 5000 vessels. A positive `options.limit` or positive `maxRenderedMarkers` can still be used as an explicit temporary cap.

View File

@@ -102,7 +102,15 @@ If both localhost checks pass but a phone or another computer cannot connect, st
./planet.sh start --allow-lan
```
Then configure portproxy and firewall from Administrator PowerShell:
The flag must be written as `--allow-lan`. `allowlan` or `--allowlan` is not recognized by the startup script. If Planet is already running and you only need to reopen the frontend on the LAN, restart the frontend explicitly:
```bash
./planet.sh restart -f 3000 --allow-lan
```
If `ss -ltnp` shows the frontend listening on `0.0.0.0:3000`, but `Test-NetConnection <Windows LAN IP> -Port 3000` still fails from Windows PowerShell, the problem is usually Windows-side forwarding or firewall policy rather than Vite or `.zshrc`.
For traditional WSL NAT networking, configure portproxy and firewall from Administrator PowerShell:
```powershell
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=3000 connectaddress=127.0.0.1 connectport=3000
@@ -111,6 +119,20 @@ New-NetFirewallRule -DisplayName "WSL Planet 3000" -Direction Inbound -Action Al
New-NetFirewallRule -DisplayName "WSL Planet 8000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8000
```
If `wslinfo --networking-mode` prints `mirrored`, also check Hyper-V firewall. Even when ordinary Windows Firewall rules exist, Hyper-V firewall can still block external devices from reaching WSL. From Administrator PowerShell, allow the required ports:
```powershell
New-NetFirewallHyperVRule -Name "Planet-Frontend-3000" -DisplayName "Planet Frontend 3000" -Direction Inbound -VMCreatorId "{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}" -Protocol TCP -LocalPorts 3000 -Action Allow
New-NetFirewallHyperVRule -Name "Planet-Backend-8000" -DisplayName "Planet Backend 8000" -Direction Inbound -VMCreatorId "{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}" -Protocol TCP -LocalPorts 8000 -Action Allow
```
Use these commands to inspect the current Hyper-V firewall state:
```powershell
Get-NetFirewallHyperVVMSetting -Name "{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}"
Get-NetFirewallHyperVRule -VMCreatorId "{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}"
```
LAN devices should open the Windows LAN IP, for example `http://<Windows LAN IP>:3000/earth`, not the internal WSL IP.
### How do `--allow-lan` and the Motion Agent LAN URL fit together?

View File

@@ -32,7 +32,7 @@ Current admin-related routes:
- `/alerts/bgp`
- `/alerts/situational`
- `/bgp`
- `/playground`
- `/ai`
- `/settings`
`/earth` is a standalone display page and is not part of the console shell.
@@ -196,7 +196,24 @@ Responsibilities:
`App.tsx` uses it to decide whether to redirect to the login page. `/docs` remains a public route, but the backend decides the visible catalog and content from the token; anonymous visitors only receive public docs.
### 2. Business Data Gateway
### 2. AI
File:
- [AISettings.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/AISettings/AISettings.tsx)
Responsibilities:
- `/ai` now owns LLM Provider, AI Tool configuration, and the testbench instead of nesting them under `/settings`
- The `模型供应商` tab manages default provider, model, base URL, provider key, local `aiprovider` proxy, and connection test
- The `工具` tab manages WebSearch provider, search key, base URL, timeout, result count, and advanced provider options
- The `测试台` tab embeds the former Playground real session, preset prompts, and AI Provider status debugging
- The page reuses the Settings single-screen tabs, panel card, and internal scrolling style
Legacy `/settings?tab=ai` should redirect to `/ai?tab=providers`.
Legacy `/playground` should redirect to `/ai?tab=playground`.
### 3. Business Data Gateway
AI / situational awareness related services are currently in:

View File

@@ -19,7 +19,7 @@ After a default startup, the common URLs are:
| Docs | `http://localhost:3000/docs` | Partly | Usage docs are public; developer, backend, and operations docs require Gatekeeper groups |
| FAQ | `http://localhost:3000/docs/faq` | No | Windows / WSL, ports, dependencies, motion capture, credentials, and permission troubleshooting |
| Console | `http://localhost:3000/admin` | Yes | Data, config, alerts, logs, and situational observation |
| AI Playground | `http://localhost:3000/playground` | Yes | AI Provider status and debugging |
| AI | `http://localhost:3000/ai` | Yes | Model providers, AI tools, and testbench |
| Backend API Docs | `http://localhost:8000/docs` | Depends on endpoint | FastAPI / OpenAPI documentation |
## planet.sh
@@ -427,7 +427,7 @@ Common pages:
| System Alerts | `/alerts/system` | System-level alerts |
| BGP Alerts | `/alerts/bgp` | BGP-related alerts |
| Situational Alerts | `/alerts/situational` | Situational assessment alerts |
| AI Playground | `/playground` | AI Provider debugging |
| AI | `/ai` | Model providers, WebSearch-style tools, and testbench |
| System Logs | `/logs` | View system logs (typically super admin only) |
| Users | `/users` | User management |
| Settings | `/settings` | System config and TV live stream sources |
@@ -487,7 +487,18 @@ Current common uses:
- System settings
- TV live stream source configuration
- Collector settings
- External integrations and AI Provider configuration
### AI
`/ai` manages the AI runtime chain and is now separate from system settings. Legacy `/playground` redirects to `/ai?tab=playground`.
It currently contains:
- `模型供应商`: default LLM provider, model, base URL, API key, local `aiprovider` proxy, and connection test
- `工具`: WebSearch provider, search API key, base URL, max results, timeout, and advanced provider options
- `测试台`: AI Provider status, preset prompts, and real analysis-chain debugging
Legacy `/settings?tab=ai` redirects to `/ai?tab=providers`.
Available configuration depends on the current user's role.

View File

@@ -57,7 +57,7 @@ After startup, the key URLs are:
| Earth | `http://localhost:3000/earth` | Public 3D Earth visualization |
| Console | `http://localhost:3000/admin` | Admin console (login required) |
| Docs | `http://localhost:3000/docs` | Usage docs are public; developer and operations docs require Gatekeeper groups |
| AI Playground | `http://localhost:3000/playground` | AI debugging (login required) |
| AI | `http://localhost:3000/ai` | Model provider, tool, and testbench entry (login required) |
| Backend API Docs | `http://localhost:8000/docs` | FastAPI / OpenAPI interface docs |
If the default ports are taken, specify custom ports:
@@ -114,6 +114,7 @@ First-time inspection checklist:
- `/datasources`: data source directory and collection triggers; endpoint, headers, and credentials are configured under `/settings` collector settings
- `/data`: collected data
- `/bgp`: BGP situational view
- `/ai`: AI page for model providers, WebSearch-style tools, and the testbench
- `/alerts/system`: system alerts
- `/settings`: system configuration

View File

@@ -105,6 +105,18 @@ React 路由入口:
动捕调试面板由 [motion-debug-panel.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/motion-debug-panel.js) 负责。它监听 `earth:motion-debug-frame`,用 canvas 绘制归一化骨架点和连线Browser Camera provider 会额外通过 `earth:motion-debug-video-source` 提供本机 `<video>` 作为调试预览底图,`shared.motionDebugSkeletonOnly` 可切换为只显示骨骼。`停止匹配动作` 通过 `earth:motion-recognition-pause` 暂停 gesture 执行,但继续显示视频和骨架。未匹配动作为红色,匹配后变绿并显示动作名。设置项持久化在 `planet.earth.settings.v2``shared.motionDebugEnabled``shared.motionProvider``shared.motionDebugSkeletonOnly`switch 和输入源控件都预留 `data-gatekeeper-permission="earth.motion_debug"`
Browser Camera provider 的手势识别管线在 [motion-browser-provider.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/motion-browser-provider.js) 的 `recognizeGesture()`,按以下顺序匹配,前者命中即返回:
1. **`getZoomTrend`(趋势 zoom**:基于上一帧到当前帧两腕 x 间距的变化量(`Math.abs(rightWrist.x - leftWrist.x)` 的 delta。两腕都越过噪声地板`ZOOM_TREND_MIN_WRIST_DELTA = 0.010`)且高度差不超过 `ZOOM_TREND_HEIGHT_TOLERANCE`spread 增加 → `zoom_in`spread 减少 → `zoom_out`。趋势优先级最高,避免中间帧被 layer/focus/rotate 抢先误判。
2. **`getZoomHoldPose`(姿态 zoom**:动作停止后,只要两腕仍保持在胸口及以上、且 span > `ZOOM_HOLD_SPREAD_FACTOR × shoulderWidth`(默认 1.30)就持续派发 `zoom_in`;两肘明显外展且 span < `ZOOM_HOLD_CLOSE_FACTOR × shoulderWidth`(默认 0.85)就持续派发 `zoom_out`
3. **layer / focus**:左手抬起 + 上下移动派发 `layer_prev/next`;头部左右倾斜派发 `focus_prev/next`
4. **`getRightArmPattern`(单臂 rotate**:仅当 `!isZoomCandidatePose(...) && isLeftArmAtRest(...)` 同时成立时才考虑。`isLeftArmAtRest` 要求左腕明显垂在肩下 13% 以下且左肘/左腕都不外伸,把「张臂中间帧」「单手举起」「左手扶在胸前」等所有模糊状态都判为非静止 —— 单臂 rotate 严格要求另一只手处于静止。
两个关键设计:
- **mirror-safe**:所有 zoom 检测都基于 `Math.abs(rightWrist.x - leftWrist.x)`,不依赖单侧 x 方向。无论摄像头是否做镜像翻转(浏览器默认不翻转,`getUserMedia` 返回的就是原始帧),张臂始终 → zoom_in合手始终 → zoom_out。早期基于「左腕往左 + 右腕往右」的判定会在非镜像视图里把方向判反。
- **连续 vs 离散**rotate / layer / focus / confirm 都过 `applyPoseLatch`同一手势只发一次必须先回到中性位才能再发挥一下转一格。zoom 故意 bypass latch每帧匹配都返回 → 下游 `GESTURE_POLICIES.zoom_in/out.cooldownMs = 120` 节流到 ~8 次/秒,张臂保持就一直放大直到姿势变化。这是 zoom 跟其它手势在交互语义上的本质区别,不要复用 latch 给 zoom。
[presentation-controller.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/presentation-controller.js) 是新的 Presentation 层。第一阶段只接入 Motion`motion-cruise-adapter.js` 通过 persistent presentation 复用巡航固定卡片位置和 connector但不会让鼠标移动触发自动隐藏connector 每帧重算 source/target anchor让卡片拖动、地球旋转和目标移动时端点继续跟随。BGP/News 仍保持原有 `CruiseSequencer` 自动轮播路径,避免改变既有巡航体验。
### 6. 地球与地形
@@ -330,6 +342,10 @@ AISStream 的 `PositionReport` 常带实时位置和 `MetaData.ShipName`,但
详情卡里的坐标候选状态由 [info-card.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/info-card.js) 按 `entityType:entityId` 缓存在模块内存中。用户关闭详情卡或待定位列表后再次打开同一个算力中心 / BGP 观测站,已经采集到的候选和状态文案会恢复;`一键采用` 会优先使用缓存候选,避免重复调用在线地理编码或 LLM factcheck。保存成功后该实体的候选列表会清空为“正在刷新图层”状态避免旧候选在刷新后继续误导用户。
候选行的 `预览 / 保存` 按钮采用单一的事件委托模型:每个候选根(详情卡里的 `[data-collect-cache-key]` 块,或待定位列表里的 `[data-unresolved-item]`)只挂一个 `click` 监听,由 `data-candidate-actions-bound` 幂等标记,不再混用 `pointerup` / `click` 直绑或重复委托。候选对象不再以 JSON 字符串塞进 HTML 属性后再 `JSON.parse`,按钮只携带 `data-candidate-index`handler 通过 cache-key 在模块内存的 `Map` 里取出原对象,避开 HTML 实体转义对 `&` / `<` / `"` 的破坏。点击 `预览` 会派发 `earth:preview-location-candidate`,由 `main.js``previewLocationCandidate()` 调用 `showComputeCenterLocationPreview()`:在候选经纬度上挂双层空心呼吸 sprite视觉参考 BGP 事件 ring并把视角聚焦到候选坐标切换到另一个候选会替换为新呼吸圈保存时立即清除并由 `spawnSavedComputeCenterLocation()` 即时生成正式算力中心交互图标。注意 `main.js` 没有模块级 `earth` 变量,所有 location-save / preview 处理函数必须先 `const earth = getEarth();`,否则会在事件 handler 里抛 `ReferenceError``.catch` 静默掉,外观上等同于按钮“没有反应”。
`earth:compute-center-location-saved` 之后的图层校准链路对后台刷新失败保持沉默:`spawnComputeCenterAfterLocationSave()` 已经把 toast 和 locked 状态都给了用户,`refreshComputeCentersAfterLocationSave()` 只在场景就绪时重新拉取后端数据,本身不再吐 `已保存` toast`handleComputeCenterLocationSaved()` 在 spawn 成功路径让 refresh 静默后台运行,只在 spawn 返回 `null`(场景未就绪)或抛错时才让 refresh 接管成功 toastrefresh 自身报错只走 `console.warn`,绝不冒泡成 `保存失败` 文案——保存请求本身已经成功,刷新失败属于后续同步问题。
asset 图标大小由 `Interactable``icon.fitSize` 控制。SVG / 图片文件应尽量保持原始 viewBox 和路径,不要为了在地球上显示成 60x60 而手写 `transform``drawAssetIcon()` 会把资源等比 contain 到指定尺寸并居中绘制到 atlas canvas。
`Interactable` 默认使用固定屏幕像素尺寸适合船只、BGP 事件、BGP 观测站、算力中心这类需要稳定识别的图标。如果某类图标需要跟随相机距离缩放,可以把 `sizeMode` 设为非 `"fixed"`,并用 `sizeScale.min / max / referenceFov` 控制缩放范围;单个 marker 的业务尺寸差异可以通过 `getPointSizeMultiplier()` 表达,例如 BGP 事件按严重级别调整点大小BGP 观测站按活跃度调整点大小。

View File

@@ -104,7 +104,15 @@ curl http://localhost:8000/health
./planet.sh start --allow-lan
```
管理员 PowerShell 中配置 portproxy 和防火墙
参数必须写成 `--allow-lan``allowlan``--allowlan` 不会被启动脚本识别。如果服务已经启动,只想重新开放前端,需要显式重启前端
```bash
./planet.sh restart -f 3000 --allow-lan
```
如果 `ss -ltnp` 显示前端已经监听 `0.0.0.0:3000`,但 Windows PowerShell 中 `Test-NetConnection <Windows局域网IP> -Port 3000` 仍失败,问题通常不在 Vite 或 `.zshrc`,而是在 Windows 侧转发或防火墙。
传统 WSL NAT 场景下,管理员 PowerShell 中配置 portproxy 和防火墙:
```powershell
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=3000 connectaddress=127.0.0.1 connectport=3000
@@ -113,6 +121,20 @@ New-NetFirewallRule -DisplayName "WSL Planet 3000" -Direction Inbound -Action Al
New-NetFirewallRule -DisplayName "WSL Planet 8000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8000
```
如果 `wslinfo --networking-mode` 输出 `mirrored`,还需要检查 Hyper-V firewall。普通 Windows 防火墙规则存在时Hyper-V firewall 仍可能拦截外部设备进入 WSL。管理员 PowerShell 中按端口放行:
```powershell
New-NetFirewallHyperVRule -Name "Planet-Frontend-3000" -DisplayName "Planet Frontend 3000" -Direction Inbound -VMCreatorId "{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}" -Protocol TCP -LocalPorts 3000 -Action Allow
New-NetFirewallHyperVRule -Name "Planet-Backend-8000" -DisplayName "Planet Backend 8000" -Direction Inbound -VMCreatorId "{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}" -Protocol TCP -LocalPorts 8000 -Action Allow
```
也可以用下面命令确认当前 Hyper-V firewall 状态:
```powershell
Get-NetFirewallHyperVVMSetting -Name "{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}"
Get-NetFirewallHyperVRule -VMCreatorId "{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}"
```
局域网设备访问的是 Windows 的局域网 IP例如 `http://<Windows局域网IP>:3000/earth`,不是 WSL 内部 IP。
### `--allow-lan` 和 Motion Agent 局域网地址怎么配?

View File

@@ -32,7 +32,7 @@
- `/alerts/bgp`
- `/alerts/situational`
- `/bgp`
- `/playground`
- `/ai`
- `/settings`
`/earth` 是独立展示页,不属于控制台骨架。
@@ -196,7 +196,24 @@
`App.tsx` 用它判断是否进入登录页。`/docs` 仍是公开路由,但目录和正文由后端按 token 决定;未登录时只返回公开文档。
### 2. 业务数据网关
### 2. AI
文件:
- [AISettings.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/AISettings/AISettings.tsx)
职责:
- `/ai` 独立承载 LLM Provider、AI Tool 配置和测试台,不再放在 `/settings` 的系统配置 tabs 中
- `模型供应商` tab 管理默认 provider、模型、base URL、provider key、本地 `aiprovider` 代理和连接测试
- `工具` tab 管理 WebSearch provider、搜索 key、base URL、超时、结果数和高级 provider 参数
- `测试台` tab 嵌入原 Playground 的真实会话、预设请求和 AI Provider 状态调试
- 页面复用 Settings 的单屏 tabs、panel card 和内部滚动样式
旧的 `/settings?tab=ai` 应跳转到 `/ai?tab=providers`
旧的 `/playground` 应跳转到 `/ai?tab=playground`
### 3. 业务数据网关
目前 AI / 态势感知相关服务集中在:

View File

@@ -19,7 +19,7 @@
| Docs | `http://localhost:3000/docs` | 部分需要 | 使用手册公开;开发、后端、运维文档按 Gatekeeper 权限组开放 |
| FAQ | `http://localhost:3000/docs/faq` | 否 | Windows / WSL、端口、依赖、动捕、凭证和权限排障 |
| 控制台 | `http://localhost:3000/admin` | 是 | 数据、配置、告警、日志和专题观测 |
| AI Playground | `http://localhost:3000/playground` | 是 | AI Provider 状态和调试 |
| AI | `http://localhost:3000/ai` | 是 | 模型供应商、AI 工具和测试台 |
| 后端 API 文档 | `http://localhost:8000/docs` | 视接口而定 | FastAPI / OpenAPI 文档 |
## planet.sh
@@ -458,7 +458,7 @@ http://localhost:3000/admin
| 系统告警 | `/alerts/system` | 系统级告警 |
| BGP 告警 | `/alerts/bgp` | BGP 相关告警 |
| 态势告警 | `/alerts/situational` | 态势研判告警 |
| AI Playground | `/playground` | AI Provider 调试 |
| AI | `/ai` | 模型供应商、WebSearch 等工具和测试台 |
| 系统日志 | `/logs` | 查看系统日志,通常仅 super admin 可见 |
| 用户管理 | `/users` | 管理用户 |
| 系统配置 | `/settings` | 系统配置和电视直播源等设置 |
@@ -518,7 +518,18 @@ http://localhost:3000/admin
- 系统设置
- 电视直播源配置
- 采集器设置
- 外部集成和 AI Provider 配置
### AI
`/ai` 用于管理 AI 运行链路,已经从系统配置中独立出来。旧链接 `/playground` 会跳转到 `/ai?tab=playground`
当前包含:
- `模型供应商`:默认 LLM provider、模型、Base URL、API Key、本地 `aiprovider` 代理和连接测试
- `工具`WebSearch provider、搜索 API Key、Base URL、最大结果数、超时和高级 provider 参数
- `测试台`AI Provider 状态、预设请求和真实分析链路调试
旧链接 `/settings?tab=ai` 会跳转到 `/ai?tab=providers`
具体可用配置取决于当前登录用户权限。

View File

@@ -57,7 +57,7 @@ export BARENTSWATCH_CLIENT_SECRET="..."
| Earth | `http://localhost:3000/earth` | 公开 3D Earth 可视化页面 |
| 控制台 | `http://localhost:3000/admin` | 登录后的管理后台 |
| 文档站 | `http://localhost:3000/docs` | 使用手册公开;开发/运维文档按 Gatekeeper 权限组开放 |
| AI Playground | `http://localhost:3000/playground` | 登录后的 AI 调试入口 |
| AI | `http://localhost:3000/ai` | 登录后的模型供应商、工具和测试台入口 |
| 后端 API 文档 | `http://localhost:8000/docs` | FastAPI / OpenAPI 接口文档 |
如果默认端口被占用,可以指定端口:
@@ -114,6 +114,7 @@ http://localhost:3000/admin
- `/datasources`:数据源目录和采集触发;接口、请求头和凭证配置在 `/settings` 的“采集器设置”
- `/data`:已采集数据
- `/bgp`BGP 专题观测
- `/ai`AI管理模型供应商、WebSearch 等工具和测试台
- `/alerts/system`:系统告警
- `/settings`:系统配置

View File

@@ -16,12 +16,13 @@
## Current Version
- `main` 当前主线历史推导到:`0.16.5`
- `dev` 当前开发分支历史推导到:`0.50.0`
- `dev` 当前开发分支历史推导到:`0.51.0`
## Timeline
| Version | Type | Branch | Commit | Summary |
| --- | --- | --- | --- | --- |
| `0.51.0` | feature | `dev` | `pending` | 新增 AI Settings 控制台与 ai_tools 工具层;重写算力中心候选预览/保存交互(呼吸圈 + 即时图标);动作捕捉 zoom 改为 mirror-safe trend + pose hold 双通道,支持持续触发 |
| `0.50.0` | feature | `dev` | `pending` | 新增 Earth 动捕双通道控制、Motion Agent、Presentation 持久展示、AI Provider 多 provider 设置、位置候选 LLM 兜底与 FAQ |
| `0.49.0` | feature | `dev` | `pending` | 新增位置解析 Pipeline、BGP/算力中心地理定位、Docs Gatekeeper、Earth 新闻栏与 Mobile 国家高亮 |
| `0.48.0` | feature | `dev` | `pending` | 新增自定义源 REST/WebSocket 实时 mock 链路,完善 AIS 多源聚合/船舶 enrichment并将 Earth 全球态势统计改为轻量 SQL 聚合 |

View File

@@ -1,6 +1,6 @@
{
"name": "planet-frontend",
"version": "0.50.0",
"version": "0.51.0",
"private": true,
"packageManager": "bun@1",
"dependencies": {

View File

@@ -1,3 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#38bdf8" viewBox="0 0 16 16">
<path d="M1.5 0A1.5 1.5 0 0 0 0 1.5v7A1.5 1.5 0 0 0 1.5 10H6v1H1a1 1 0 0 0-1 1v3a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-3a1 1 0 0 0-1-1h-5v-1h4.5A1.5 1.5 0 0 0 16 8.5v-7A1.5 1.5 0 0 0 14.5 0h-13Zm0 1h13a.5.5 0 0 1 .5.5v7a.5.5 0 0 1-.5.5h-13a.5.5 0 0 1-.5-.5v-7a.5.5 0 0 1 .5-.5ZM12 12.5a.5.5 0 1 1 1 0 .5.5 0 0 1-1 0Zm2 0a.5.5 0 1 1 1 0 .5.5 0 0 1-1 0ZM1.5 12h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1 0-1ZM1 14.25a.25.25 0 0 1 .25-.25h5.5a.25.25 0 1 1 0 .5h-5.5a.25.25 0 0 1-.25-.25Z"/>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#38bdf8" class="bi bi-pc" viewBox="0 0 16 16">
<path d="M5 0a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V1a1 1 0 0 0-1-1H5Zm.5 14a.5.5 0 1 1 0 1 .5.5 0 0 1 0-1Zm2 0a.5.5 0 1 1 0 1 .5.5 0 0 1 0-1ZM5 1.5a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1-.5-.5ZM5.5 3h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1 0-1Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 578 B

After

Width:  |  Height:  |  Size: 383 B

View File

@@ -879,20 +879,40 @@
<button type="button" class="earth-mobile-settings-pill is-active" data-satellite-display-style="ground_footprint" aria-pressed="true">真实地表覆盖</button>
</div>
</div>
<label class="earth-mobile-settings-card">
<div class="earth-mobile-settings-copy">
<span class="earth-mobile-settings-label">日夜模式</span>
<span class="earth-mobile-settings-subtitle">按真实太阳位置区分地球昼夜明暗</span>
</div>
<span class="earth-mobile-settings-switch">
<input type="checkbox" data-daynight-toggle checked>
<span class="earth-mobile-settings-switch-track"></span>
</span>
</label>
<label
class="earth-mobile-settings-card"
data-gatekeeper-permission="earth.motion_debug"
>
<label class="earth-mobile-settings-card">
<div class="earth-mobile-settings-copy">
<span class="earth-mobile-settings-label">卫星呼吸闪烁</span>
<span class="earth-mobile-settings-subtitle">空闲时让卫星点缓慢明暗呼吸</span>
</div>
<span class="earth-mobile-settings-switch">
<input type="checkbox" data-satellite-idle-breathing-toggle checked>
<span class="earth-mobile-settings-switch-track"></span>
</span>
</label>
<label class="earth-mobile-settings-card">
<div class="earth-mobile-settings-copy">
<span class="earth-mobile-settings-label">低缩放圆点</span>
<span class="earth-mobile-settings-subtitle">150% 以下将可交互图标简化为对应颜色圆点</span>
</div>
<span class="earth-mobile-settings-switch">
<input type="checkbox" data-interactable-compact-dots-toggle checked>
<span class="earth-mobile-settings-switch-track"></span>
</span>
</label>
<label class="earth-mobile-settings-card">
<div class="earth-mobile-settings-copy">
<span class="earth-mobile-settings-label">日夜模式</span>
<span class="earth-mobile-settings-subtitle">按真实太阳位置区分地球昼夜明暗</span>
</div>
<span class="earth-mobile-settings-switch">
<input type="checkbox" data-daynight-toggle checked>
<span class="earth-mobile-settings-switch-track"></span>
</span>
</label>
<label
class="earth-mobile-settings-card"
data-gatekeeper-permission="earth.motion_debug"
>
<div class="earth-mobile-settings-copy">
<span class="earth-mobile-settings-label">动捕调试模式</span>
<span class="earth-mobile-settings-subtitle">显示摄像头识别到的骨架连线和匹配动作</span>
@@ -1160,6 +1180,26 @@
</button>
</div>
</div>
<label class="earth-settings-item" for="toggle-satellite-idle-breathing">
<div class="earth-settings-copy">
<span class="earth-settings-item-title">卫星呼吸闪烁</span>
<span class="earth-settings-item-subtitle">空闲时让卫星点缓慢明暗呼吸,拖拽或缩放时自动稳定显示</span>
</div>
<span class="earth-settings-switch">
<input id="toggle-satellite-idle-breathing" type="checkbox" data-satellite-idle-breathing-toggle checked>
<span class="earth-settings-switch-track"></span>
</span>
</label>
<label class="earth-settings-item" for="toggle-interactable-compact-dots">
<div class="earth-settings-copy">
<span class="earth-settings-item-title">低缩放圆点</span>
<span class="earth-settings-item-subtitle">150% 以下将可交互图标简化为对应颜色圆点,便于巡航和总览扫视</span>
</div>
<span class="earth-settings-switch">
<input id="toggle-interactable-compact-dots" type="checkbox" data-interactable-compact-dots-toggle checked>
<span class="earth-settings-switch-track"></span>
</span>
</label>
<label class="earth-settings-item" for="toggle-daynight">
<div class="earth-settings-copy">
<span class="earth-settings-item-title">日夜模式</span>

View File

@@ -77,6 +77,7 @@ function getMarkerTimestamp(marker) {
export function createBGPCruiseAdapter({
camera,
earth,
getMarkers,
connector,
focusView,
@@ -108,7 +109,13 @@ export function createBGPCruiseAdapter({
function getMarkerScreenCoords(marker) {
if (!marker || !camera) return null;
scratchBGPWorldPosition.copy(marker.position);
marker.parent?.localToWorld(scratchBGPWorldPosition);
if (marker.parent) {
marker.parent.localToWorld(scratchBGPWorldPosition);
} else {
const earthObject = typeof earth === "function" ? earth() : earth;
earthObject?.updateMatrixWorld(true);
earthObject?.localToWorld(scratchBGPWorldPosition);
}
return projectWorldToScreen(scratchBGPWorldPosition, camera);
}

View File

@@ -1,8 +1,11 @@
import { COMPUTE_CENTER_CONFIG, PATHS } from "./constants.js";
import * as THREE from "three";
import { COMPUTE_CENTER_CONFIG, CONFIG, PATHS } from "./constants.js";
import {
createInteractableLayer,
SURFACE_AVOIDANCE_PROFILES,
} from "./interactable.js";
import { latLonToVector3 } from "./utils.js";
const COMPUTE_CENTER_RENDER_ORDER = 4.5;
const COMPUTE_CENTER_POINT_SIZE = 36;
@@ -17,9 +20,84 @@ let showComputeCenters = true;
let supercomputerCount = 0;
let gpuClusterCount = 0;
let unresolvedComputeCenters = [];
let previewRingTexture = null;
let previewRingGroup = null;
let previewRingA = null;
let previewRingB = null;
let previewRingPulseOffset = 0;
const COLLECT_LOCATION_API_BASE = "/api/v1/visualization/compute-centers";
function getPreviewRingTexture() {
if (previewRingTexture) return previewRingTexture;
const canvas = document.createElement("canvas");
canvas.width = 128;
canvas.height = 128;
const context = canvas.getContext("2d");
if (!context) {
previewRingTexture = new THREE.Texture(canvas);
return previewRingTexture;
}
context.clearRect(0, 0, 128, 128);
context.strokeStyle = "rgba(255,255,255,0.96)";
context.lineWidth = 5;
context.beginPath();
context.arc(64, 64, 43, 0, Math.PI * 2);
context.stroke();
previewRingTexture = new THREE.CanvasTexture(canvas);
return previewRingTexture;
}
function ensurePreviewRingGroup(earth) {
if (!earth) return null;
if (!previewRingGroup) {
previewRingGroup = new THREE.Group();
previewRingGroup.name = "compute-center-location-preview";
}
if (previewRingGroup.parent !== earth) {
previewRingGroup.parent?.remove?.(previewRingGroup);
earth.add(previewRingGroup);
}
return previewRingGroup;
}
function createPreviewRingSprite({ color = 0x2dd4bf } = {}) {
const sprite = new THREE.Sprite(
new THREE.SpriteMaterial({
map: getPreviewRingTexture(),
color,
transparent: true,
opacity: 0,
depthWrite: false,
depthTest: true,
blending: THREE.AdditiveBlending,
}),
);
sprite.renderOrder = COMPUTE_CENTER_RENDER_ORDER + 0.18;
return sprite;
}
function disposePreviewRingSprite(sprite) {
if (!sprite) return;
sprite.parent?.remove?.(sprite);
sprite.material?.dispose?.();
}
function attachPreviewRings(group, position, color) {
disposePreviewRingSprite(previewRingA);
disposePreviewRingSprite(previewRingB);
previewRingA = createPreviewRingSprite({ color });
previewRingB = createPreviewRingSprite({ color });
previewRingA.position.copy(position);
previewRingB.position.copy(position);
group.add(previewRingA);
group.add(previewRingB);
previewRingPulseOffset = Math.random() * Math.PI * 2;
}
function buildComputeCenterMarkerData(feature) {
const props = feature?.properties || {};
const coordinates = feature?.geometry?.coordinates || [];
@@ -267,9 +345,164 @@ export function clearComputeCenterData(earth) {
supercomputerCount = 0;
gpuClusterCount = 0;
unresolvedComputeCenters = [];
clearComputeCenterLocationPreview();
computeCenterIconLayer.clearData(earth);
}
export function clearComputeCenterLocationPreview() {
disposePreviewRingSprite(previewRingA);
disposePreviewRingSprite(previewRingB);
previewRingA = null;
previewRingB = null;
}
export function showComputeCenterLocationPreview(earth, { latitude, longitude, color = 0x2dd4bf } = {}) {
const lat = Number(latitude);
const lon = Number(longitude);
if (!earth || !Number.isFinite(lat) || !Number.isFinite(lon)) return false;
const group = ensurePreviewRingGroup(earth);
if (!group) return false;
const position = latLonToVector3(
lat,
lon,
CONFIG.earthRadius + COMPUTE_CENTER_CONFIG.altitudeOffset + 0.2,
);
attachPreviewRings(group, position, color);
updateComputeCenterLocationPreview();
return true;
}
function updateComputeCenterLocationPreview() {
if (!previewRingA && !previewRingB) return;
const now = performance.now();
const baseScale = 8.5;
const pulseSpeed = 0.00105;
const applyRing = (ring, phaseOffset, maxScale) => {
if (!ring) return;
const phase = (now * pulseSpeed + previewRingPulseOffset + phaseOffset) % 1;
const progress = Math.max(0, Math.min(1, phase));
const fadeIn = Math.max(0, Math.min(1, (progress - 0.04) / 0.16));
const fadeOut = 1 - progress;
const visibility = fadeIn * fadeOut;
ring.scale.setScalar(baseScale * (1.0 + progress * (maxScale - 1.0)));
ring.material.opacity = 0.5 * visibility;
ring.visible = true;
};
applyRing(previewRingA, 0, 1.85);
applyRing(previewRingB, 0.45, 2.35);
}
function recomputeComputeCenterCounts(markerData) {
let nextSupercomputerCount = 0;
let nextGpuClusterCount = 0;
markerData.forEach((item) => {
if (item.site_type === "supercomputer") {
nextSupercomputerCount += 1;
} else {
nextGpuClusterCount += 1;
}
});
supercomputerCount = nextSupercomputerCount;
gpuClusterCount = nextGpuClusterCount;
}
function buildOptimisticComputeCenterMarkerData({ sourceId, candidate, context, saveResult }) {
const location = saveResult?.location || {};
const existingData = context?.data || {};
const latitude = Number(location.latitude ?? candidate?.latitude);
const longitude = Number(location.longitude ?? candidate?.longitude);
if (!sourceId || !Number.isFinite(latitude) || !Number.isFinite(longitude)) {
return null;
}
const siteType = normalizeSiteType(
context?.site_type || context?.siteType || existingData.site_type || location.site_type,
);
return {
id: context?.recordId || context?.id || existingData.id || saveResult?.record_id || sourceId,
source_id: sourceId,
name:
context?.name ||
existingData.name ||
location.name ||
candidate?.matched_location_name ||
candidate?.display_name ||
"算力中心",
source: saveResult?.source || context?.source || existingData.source || location.source || "",
site_type: siteType,
country: location.country || candidate?.country || context?.country || existingData.country || "",
city: location.city || candidate?.city || context?.city || existingData.city || "",
region: location.region || candidate?.region || "",
latitude,
longitude,
displayLatitude: latitude,
displayLongitude: longitude,
operator: context?.operator || existingData.operator || location.operator || "",
vendor: context?.vendor || existingData.vendor || "",
capacity_value: context?.capacity_value ?? existingData.capacity_value,
capacity_unit: context?.capacity_unit ?? existingData.capacity_unit,
rank: context?.rank ?? existingData.rank,
gpu_count: context?.gpu_count ?? existingData.gpu_count,
gpu_type: context?.gpu_type ?? existingData.gpu_type,
cores: context?.cores ?? existingData.cores,
power: context?.power ?? existingData.power,
updated_at: new Date().toISOString(),
status: "observed",
location_precision: location.precision || candidate?.precision || "city",
location_confidence: location.confidence ?? candidate?.confidence ?? null,
location_source: location.location_source || candidate?.source || "manual_selection",
location_source_note: location.location_source_note || candidate?.source_note || "",
location_verified_at: location.verified_at || new Date().toISOString(),
matched_location_name:
location.matched_location_name ||
candidate?.matched_location_name ||
candidate?.display_name ||
"",
needs_confirmation: location.needs_confirmation === true,
is_estimated: false,
estimated_reason: location.estimated_reason || "",
data_type: "compute_center",
metadata: context?.metadata || existingData.metadata || {},
optimistic_spawn: true,
};
}
export async function spawnSavedComputeCenterLocation(earth, { sourceId, candidate, context, saveResult } = {}) {
clearComputeCenterLocationPreview();
const spawned = buildOptimisticComputeCenterMarkerData({
sourceId,
candidate,
context,
saveResult,
});
if (!spawned) return null;
const currentItems = getComputeCenterMarkers()
.map((marker) => ({ ...(marker.userData || {}) }))
.filter((item) => item.source_id !== sourceId);
currentItems.push(spawned);
const nextItems = spreadComputeCenterPositions(currentItems);
await computeCenterIconLayer.preloadAssets(nextItems);
computeCenterIconLayer.setData(nextItems);
computeCenterIconLayer.attach(earth);
computeCenterIconLayer.setVisible(showComputeCenters);
recomputeComputeCenterCounts(nextItems);
unresolvedComputeCenters = unresolvedComputeCenters.filter((item) => {
const itemSourceId = item?.source_id || item?.id;
return itemSourceId !== sourceId;
});
return {
marker: getComputeCenterMarkers().find((marker) => marker.userData?.source_id === sourceId) || null,
totalCount: getComputeCenterCount(),
supercomputerCount,
gpuClusterCount,
unresolvedCount: unresolvedComputeCenters.length,
unresolved: unresolvedComputeCenters.slice(),
summary: getComputeCenterStatusSummary(),
optimistic: true,
};
}
export function getUnresolvedComputeCenters() {
return unresolvedComputeCenters.slice();
}
@@ -362,7 +595,11 @@ export function getShowComputeCenters() {
}
export async function loadComputeCenters(_scene, earth) {
const response = await fetch(PATHS.computeCentersApi);
const separator = PATHS.computeCentersApi.includes("?") ? "&" : "?";
const response = await fetch(
`${PATHS.computeCentersApi}${separator}_=${Date.now()}`,
{ cache: "no-store" },
);
if (!response.ok) {
throw new Error(`Compute centers HTTP ${response.status}`);
}
@@ -411,5 +648,6 @@ export function getComputeCenterPointerIntersections(options) {
}
export function updateComputeCenterVisualState(lockedObjectType, lockedObject, camera) {
updateComputeCenterLocationPreview();
computeCenterIconLayer.updateVisualState(lockedObjectType, lockedObject, camera);
}

View File

@@ -50,8 +50,14 @@ import {
getShowTrails,
getSatelliteCount,
getSatelliteDisplayStyle,
getSatelliteIdleBreathingEnabled,
setSatelliteIdleBreathingEnabled as applySatelliteIdleBreathingEnabled,
setSatelliteDisplayStyle as applySatelliteDisplayStyle,
} from "./satellites.js";
import {
getInteractableCompactDotsEnabled,
setInteractableCompactDotsEnabled as applyInteractableCompactDotsEnabled,
} from "./interactable.js";
import { getShowCables } from "./cables.js";
import { toggleBGP, getShowBGP, getBGPCount } from "./bgp.js";
import { getShowCountryBoundaries, toggleCountryBoundaries } from "./country-boundaries.js";
@@ -135,7 +141,7 @@ const SETTINGS_SHEET_MAX_SCALE_X = 0.22;
const SETTINGS_SHEET_MAX_SCALE_Y = 0.18;
const EARTH_SETTINGS_STORAGE_KEY = "planet.earth.settings.v2";
const LEGACY_EARTH_SETTINGS_STORAGE_KEY = "planet.earth.settings.v1";
const EARTH_SETTINGS_VERSION = 9;
const EARTH_SETTINGS_VERSION = 10;
const GRID_LINES_DEFAULT_VERSION = 3;
const SATELLITE_DISPLAY_DEFAULT_VERSION = 4;
const MEDIA_PANEL_DEFAULT_VERSION = 5;
@@ -143,8 +149,11 @@ const MOTION_DEBUG_DEFAULT_VERSION = 6;
const MOTION_PROVIDER_DEFAULT_VERSION = 7;
const MOTION_DEBUG_SKELETON_ONLY_DEFAULT_VERSION = 8;
const MEDIA_PANEL_ACTIVE_TAB_DEFAULT_VERSION = 9;
const VISUAL_PREFERENCES_DEFAULT_VERSION = 10;
const DEFAULT_EARTH_ZOOM_STEP = 0.01;
const ZOOM_STATUS_UPDATE_INTERVAL_MS = 90;
const TARGET_SWITCH_ZOOM_IN_PHASE = 0.28;
const TARGET_SWITCH_ROTATE_PHASE = 0.5;
let settingsModalTimer = null;
let settingsSheetAnimation = null;
let terrainToggleToken = 0;
@@ -783,6 +792,8 @@ function getCurrentSharedSettingsSnapshot() {
motionProvider,
motionDebugSkeletonOnly,
mediaPanelActiveTab: normalizeMediaPanelActiveTab(getActiveTVTab()),
satelliteIdleBreathingEnabled: getSatelliteIdleBreathingEnabled(),
interactableCompactDotsEnabled: getInteractableCompactDotsEnabled(),
};
}
@@ -833,6 +844,10 @@ function cloneEarthSettings(settings) {
),
motionDebugSkeletonOnly: Boolean(settings.shared.motionDebugSkeletonOnly),
mediaPanelActiveTab: normalizeMediaPanelActiveTab(settings.shared.mediaPanelActiveTab),
satelliteIdleBreathingEnabled:
settings.shared.satelliteIdleBreathingEnabled !== false,
interactableCompactDotsEnabled:
settings.shared.interactableCompactDotsEnabled !== false,
layerVisibility: { ...(settings.shared.layerVisibility || {}) },
},
views: {
@@ -953,6 +968,16 @@ function normalizeEarthSettings(rawSettings, defaults) {
(rawSettings?.version || 0) >= MEDIA_PANEL_ACTIVE_TAB_DEFAULT_VERSION
? normalizeMediaPanelActiveTab(sharedSettings?.mediaPanelActiveTab)
: normalizeMediaPanelActiveTab(defaults.shared.mediaPanelActiveTab);
const nextSatelliteIdleBreathingEnabled =
(rawSettings?.version || 0) >= VISUAL_PREFERENCES_DEFAULT_VERSION &&
typeof sharedSettings?.satelliteIdleBreathingEnabled === "boolean"
? sharedSettings.satelliteIdleBreathingEnabled
: defaults.shared.satelliteIdleBreathingEnabled;
const nextInteractableCompactDotsEnabled =
(rawSettings?.version || 0) >= VISUAL_PREFERENCES_DEFAULT_VERSION &&
typeof sharedSettings?.interactableCompactDotsEnabled === "boolean"
? sharedSettings.interactableCompactDotsEnabled
: defaults.shared.interactableCompactDotsEnabled;
return {
version: EARTH_SETTINGS_VERSION,
@@ -972,6 +997,8 @@ function normalizeEarthSettings(rawSettings, defaults) {
motionProvider: nextMotionProvider,
motionDebugSkeletonOnly: nextMotionDebugSkeletonOnly,
mediaPanelActiveTab: nextMediaPanelActiveTab,
satelliteIdleBreathingEnabled: nextSatelliteIdleBreathingEnabled,
interactableCompactDotsEnabled: nextInteractableCompactDotsEnabled,
},
views: {
desktop: {
@@ -985,9 +1012,20 @@ function normalizeEarthSettings(rawSettings, defaults) {
}
function syncMotionDebugToggle(nextEnabled = motionDebugEnabled) {
const interactable = rotationMode === ROTATION_MODE.MOTION;
document.querySelectorAll("[data-motion-debug-toggle]").forEach((input) => {
if (input instanceof HTMLInputElement) {
input.checked = Boolean(nextEnabled);
input.disabled = !interactable;
const label = input.closest("label");
label?.classList.toggle("is-disabled", !interactable);
if (label instanceof HTMLElement) {
if (interactable) {
label.removeAttribute("title");
} else {
label.title = "切换到动捕模式后可开启调试面板";
}
}
}
});
}
@@ -1010,10 +1048,13 @@ function syncMotionDebugSkeletonOnlyToggle(nextEnabled = motionDebugSkeletonOnly
}
function dispatchMotionSettingsChange() {
const effectiveDebugEnabled =
rotationMode === ROTATION_MODE.MOTION && autoRotate && motionDebugEnabled;
window.dispatchEvent(
new CustomEvent("earth:motion-debug-mode-change", {
detail: {
enabled: motionDebugEnabled,
enabled: effectiveDebugEnabled,
preferredEnabled: motionDebugEnabled,
provider: motionProvider,
skeletonOnly: motionDebugSkeletonOnly,
},
@@ -1137,6 +1178,24 @@ function syncSatelliteDisplayStyleControls() {
});
}
function syncSatelliteIdleBreathingToggle() {
const enabled = getSatelliteIdleBreathingEnabled();
document.querySelectorAll("[data-satellite-idle-breathing-toggle]").forEach((input) => {
if (input instanceof HTMLInputElement) {
input.checked = enabled;
}
});
}
function syncInteractableCompactDotsToggle() {
const enabled = getInteractableCompactDotsEnabled();
document.querySelectorAll("[data-interactable-compact-dots-toggle]").forEach((input) => {
if (input instanceof HTMLInputElement) {
input.checked = enabled;
}
});
}
export function getCruiseModules() {
const configuredModules = earthSettingsState?.shared?.cruiseModules;
return normalizeCruiseModules(configuredModules);
@@ -1209,6 +1268,42 @@ export function setSatelliteDisplayStyle(
return normalizedStyle;
}
export function setSatelliteIdleBreathingEnabled(
nextEnabled,
{ persist = true, suppressStatus = false } = {},
) {
const enabled = applySatelliteIdleBreathingEnabled(nextEnabled);
ensureMutableEarthSettingsState();
earthSettingsState.shared.satelliteIdleBreathingEnabled = enabled;
syncSatelliteIdleBreathingToggle();
if (persist) {
persistEarthSettings();
}
if (!suppressStatus) {
showStatusMessage(enabled ? "卫星呼吸闪烁已开启" : "卫星呼吸闪烁已关闭", "info");
}
return enabled;
}
export function setInteractableCompactDotsEnabled(
nextEnabled,
{ persist = true, suppressStatus = false } = {},
) {
const enabled = applyInteractableCompactDotsEnabled(nextEnabled);
ensureMutableEarthSettingsState();
earthSettingsState.shared.interactableCompactDotsEnabled = enabled;
syncInteractableCompactDotsToggle();
if (persist) {
persistEarthSettings();
}
if (!suppressStatus) {
showStatusMessage(enabled ? "低缩放彩色圆点已开启" : "低缩放彩色圆点已关闭", "info");
}
return enabled;
}
function syncDefaultEarthZoomUi(nextZoom) {
const sliders = document.querySelectorAll("#default-earth-size-slider, [data-default-earth-size-slider]");
const values = document.querySelectorAll("#default-earth-size-value, [data-default-earth-size-value]");
@@ -1276,6 +1371,14 @@ async function applyEarthSettings(settings, { applyLayers = true } = {}) {
persist: false,
suppressStatus: true,
});
setSatelliteIdleBreathingEnabled(settings.shared.satelliteIdleBreathingEnabled, {
persist: false,
suppressStatus: true,
});
setInteractableCompactDotsEnabled(settings.shared.interactableCompactDotsEnabled, {
persist: false,
suppressStatus: true,
});
if (typeof settings.shared.dayNightEnabled === "boolean") {
applyDayNightEnabled(settings.shared.dayNightEnabled, { persist: false });
@@ -1329,8 +1432,9 @@ export function setMotionDebugEnabled(
nextEnabled,
{ persist = true, suppressStatus = false } = {},
) {
const requested = Boolean(nextEnabled);
const normalized = requested && rotationMode === ROTATION_MODE.MOTION;
const normalized = Boolean(nextEnabled);
const previousEffective =
rotationMode === ROTATION_MODE.MOTION && autoRotate && motionDebugEnabled;
const changed = motionDebugEnabled !== normalized;
motionDebugEnabled = normalized;
syncMotionDebugToggle(motionDebugEnabled);
@@ -1338,19 +1442,21 @@ export function setMotionDebugEnabled(
ensureMutableEarthSettingsState();
earthSettingsState.shared.motionDebugEnabled = motionDebugEnabled;
if (changed) {
const nextEffective =
rotationMode === ROTATION_MODE.MOTION && autoRotate && motionDebugEnabled;
if (changed || previousEffective !== nextEffective) {
dispatchMotionSettingsChange();
}
if (persist) {
persistEarthSettings();
}
if (!suppressStatus && changed) {
showStatusMessage(
motionDebugEnabled ? "动捕调试模式已开启" : "动捕调试模式已关闭",
"info",
);
} else if (!suppressStatus && requested && rotationMode !== ROTATION_MODE.MOTION) {
showStatusMessage("请先切换到动捕模式再打开调试面板", "info");
const message = motionDebugEnabled
? rotationMode === ROTATION_MODE.MOTION
? "动捕调试模式已开启"
: "动捕调试模式将在下次进入动捕时开启"
: "动捕调试模式已关闭";
showStatusMessage(message, "info");
}
return motionDebugEnabled;
}
@@ -2585,6 +2691,20 @@ function setupSettingsControls() {
});
});
document.querySelectorAll("[data-satellite-idle-breathing-toggle]").forEach((toggle) => {
if (!(toggle instanceof HTMLInputElement)) return;
bindListener(toggle, "change", () => {
setSatelliteIdleBreathingEnabled(toggle.checked);
});
});
document.querySelectorAll("[data-interactable-compact-dots-toggle]").forEach((toggle) => {
if (!(toggle instanceof HTMLInputElement)) return;
bindListener(toggle, "change", () => {
setInteractableCompactDotsEnabled(toggle.checked);
});
});
document.querySelectorAll("#toggle-daynight, [data-daynight-toggle]").forEach((dayNightToggle) => {
if (!(dayNightToggle instanceof HTMLInputElement)) return;
bindListener(dayNightToggle, "change", () => {
@@ -3954,6 +4074,7 @@ function updateRotateUI() {
}
syncRotationModeButtons();
syncMotionDebugToggle(motionDebugEnabled);
}
export function setAutoRotate(value) {
@@ -3990,9 +4111,6 @@ export function setRotationMode(nextMode, { persist = true, suppressStatus = fal
autoRotate = true;
}
rotationMode = normalizedMode;
if (normalizedMode !== ROTATION_MODE.MOTION && motionDebugEnabled) {
setMotionDebugEnabled(false, { persist, suppressStatus: true });
}
updateRotateUI();
dispatchRotationModeChange();
if (persist) {
@@ -4013,6 +4131,7 @@ export function focusEarthView(camera, options = {}) {
zoom = getDefaultEarthZoomLevel(),
duration = 800,
suppressStatus = true,
zoomTransitionMode = "direct",
} = options;
return new Promise((resolve) => {
@@ -4020,6 +4139,13 @@ export function focusEarthView(camera, options = {}) {
const startRotX = earthObj.rotation.x;
const startRotY = earthObj.rotation.y;
const startZoom = zoomLevel;
const defaultZoom = getDefaultEarthZoomLevel();
const shouldRestoreZoomViaDefault =
zoomTransitionMode === "restore-current-via-default" &&
Math.abs(startZoom - defaultZoom) > 0.005;
const rotateStartProgress = TARGET_SWITCH_ZOOM_IN_PHASE;
const rotateEndProgress =
TARGET_SWITCH_ZOOM_IN_PHASE + TARGET_SWITCH_ROTATE_PHASE;
animateValue(
0,
@@ -4027,14 +4153,39 @@ export function focusEarthView(camera, options = {}) {
duration,
(progress) => {
const ease = 1 - Math.pow(1 - progress, 3);
earthObj.rotation.x = startRotX + (nextRotation.x - startRotX) * ease;
earthObj.rotation.y = startRotY + (nextRotation.y - startRotY) * ease;
zoomLevel = startZoom + (zoom - startZoom) * ease;
if (shouldRestoreZoomViaDefault) {
const rotateProgress = THREE.MathUtils.clamp(
(progress - rotateStartProgress) / TARGET_SWITCH_ROTATE_PHASE,
0,
1,
);
const rotateEase = 1 - Math.pow(1 - rotateProgress, 3);
earthObj.rotation.x = startRotX + (nextRotation.x - startRotX) * rotateEase;
earthObj.rotation.y = startRotY + (nextRotation.y - startRotY) * rotateEase;
if (progress < rotateStartProgress) {
const zoomProgress = progress / rotateStartProgress;
const zoomEase = 1 - Math.pow(1 - zoomProgress, 3);
zoomLevel = startZoom + (defaultZoom - startZoom) * zoomEase;
} else if (progress <= rotateEndProgress) {
zoomLevel = defaultZoom;
} else {
const zoomProgress = (progress - rotateEndProgress) / (1 - rotateEndProgress);
const zoomEase = 1 - Math.pow(1 - zoomProgress, 3);
zoomLevel = defaultZoom + (startZoom - defaultZoom) * zoomEase;
}
} else {
earthObj.rotation.x = startRotX + (nextRotation.x - startRotX) * ease;
earthObj.rotation.y = startRotY + (nextRotation.y - startRotY) * ease;
zoomLevel = startZoom + (zoom - startZoom) * ease;
}
camera.position.z = CONFIG.defaultCameraZ / zoomLevel;
updateZoomDisplay(zoomLevel, camera.position.z.toFixed(0));
},
() => {
zoomLevel = zoom;
zoomLevel = shouldRestoreZoomViaDefault ? startZoom : zoom;
camera.position.z = CONFIG.defaultCameraZ / zoomLevel;
updateZoomDisplay(zoomLevel, camera.position.z.toFixed(0));
if (!suppressStatus) {
showStatusMessage("视角已重置", "info");
}

View File

@@ -9,6 +9,11 @@ let pendingMobileDetailState = null;
let mobileDetailsListenerBound = false;
let renderedMobileDetailKey = null;
const locationCollectStateCache = new Map();
const locationCollectContextCache = new Map();
// Latest candidate list per cache-key. Populated whenever state.candidates is
// updated, and read by the click handler via `data-candidate-index` so we
// never have to round-trip a candidate object through an HTML attribute.
const locationCollectCandidatesByKey = new Map();
const IDENTIFIER_FIELD_KEYS = new Set([
'mmsi',
'mmsi_display',
@@ -37,6 +42,12 @@ function setLocationCollectState(contextOrKey, patch = {}) {
? contextOrKey
: getLocationCollectCacheKey(contextOrKey);
if (!key) return null;
if (typeof contextOrKey !== 'string') {
locationCollectContextCache.set(key, contextOrKey);
}
if (Array.isArray(patch.candidates)) {
locationCollectCandidatesByKey.set(key, patch.candidates);
}
const previous = locationCollectStateCache.get(key) || {};
const next = {
...previous,
@@ -54,14 +65,28 @@ function clearLocationCollectState(contextOrKey) {
: getLocationCollectCacheKey(contextOrKey);
if (!key) return;
locationCollectStateCache.delete(key);
locationCollectContextCache.delete(key);
locationCollectCandidatesByKey.delete(key);
updateLocationCollectDomFromState(key);
}
function getCandidateForButton(button) {
if (!(button instanceof HTMLElement)) return null;
const root = button.closest('[data-collect-cache-key]');
if (!(root instanceof HTMLElement)) return null;
const key = root.dataset.collectCacheKey || '';
const list = locationCollectCandidatesByKey.get(key) || [];
const index = Number(button.dataset.candidateIndex);
if (!Number.isFinite(index) || index < 0 || index >= list.length) return null;
return list[index];
}
function updateLocationCollectDomFromState(key) {
if (!key) return;
const state = getLocationCollectState(key);
document.querySelectorAll(`[data-collect-cache-key="${escapeCssIdentifier(key)}"]`).forEach((root) => {
hydrateLocationCollectRoot(root, state);
ensureCandidateActionBindings(root, locationCollectContextCache.get(key));
});
}
@@ -376,7 +401,7 @@ function renderCachedCollectCandidates(state) {
if (!candidates.length) return '';
return candidates
.slice(0, 5)
.map((candidate, index) => renderCollectCandidateRow(candidate, index === 0))
.map((candidate, index) => renderCollectCandidateRow(candidate, index === 0, index))
.join('');
}
@@ -390,6 +415,128 @@ function hydrateLocationCollectRoot(root, state) {
if (button instanceof HTMLButtonElement) button.disabled = state?.loading === true;
}
function rememberLocationCollectContext(context) {
const key = getLocationCollectCacheKey(context);
if (!key) return '';
locationCollectContextCache.set(key, context);
return key;
}
function getLocationCollectContextForRoot(root, fallbackContext) {
const key = root?.dataset?.collectCacheKey || getLocationCollectCacheKey(fallbackContext);
if (key && locationCollectContextCache.has(key)) {
return locationCollectContextCache.get(key);
}
if (fallbackContext) {
rememberLocationCollectContext(fallbackContext);
return fallbackContext;
}
const collectButton = root?.querySelector?.('[data-unresolved-collect]');
try {
const parsed = JSON.parse(collectButton?.dataset?.contextJson || '{}');
if (!parsed?.sourceId) return null;
return {
...parsed,
entityType: 'compute_center',
entityId: parsed.sourceId,
isUnresolved: true,
save: async (candidate) => {
const mod = await import('./compute-centers.js');
return mod.saveComputeCenterLocation(parsed.sourceId, candidate, parsed);
},
};
} catch {
return null;
}
}
// Single delegated click handler attached once per cache-key root.
// Lookup model: button -> closest('[data-collect-cache-key]') -> map by key.
// Candidates live in `locationCollectCandidatesByKey`, indexed by
// `data-candidate-index` on the button -- no JSON round-tripped through HTML.
function ensureCandidateActionBindings(rootOrChild, context) {
const root = rootOrChild instanceof Element
? (rootOrChild.closest?.('[data-collect-cache-key]') || rootOrChild)
: null;
if (!(root instanceof HTMLElement)) return;
const key = rememberLocationCollectContext(context) || root.dataset.collectCacheKey || '';
if (key) root.dataset.collectCacheKey = key;
if (root.dataset.candidateActionsBound === 'true') return;
root.dataset.candidateActionsBound = 'true';
root.addEventListener('click', async (event) => {
const target = event.target instanceof Element ? event.target : null;
if (!target) return;
const previewButton = target.closest('[data-preview-candidate]');
const saveButton = target.closest('[data-save-candidate]');
const button = previewButton || saveButton;
if (!(button instanceof HTMLElement) || !root.contains(button)) return;
event.stopPropagation();
const actionContext = getLocationCollectContextForRoot(root, context);
if (!actionContext) return;
const candidate = getCandidateForButton(button);
if (!candidate) return;
if (previewButton) {
const lat = Number(candidate.latitude);
const lon = Number(candidate.longitude);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return;
window.dispatchEvent(
new CustomEvent('earth:preview-location-candidate', {
detail: {
latitude: lat,
longitude: lon,
entityType: actionContext.entityType,
entityId: actionContext.entityId,
candidate,
},
}),
);
return;
}
if (typeof actionContext.save !== 'function') return;
const statusEl = root.querySelector('[data-collect-status], [data-unresolved-status]');
button.disabled = true;
if (statusEl) statusEl.textContent = '正在保存所选坐标...';
try {
const saveResult = await actionContext.save(candidate);
setLocationCollectState(actionContext, {
loading: false,
statusText: '坐标已保存',
candidates: [],
});
if (statusEl) statusEl.textContent = '坐标已保存';
window.dispatchEvent(
new CustomEvent('earth:compute-center-location-saved', {
detail: {
entityType: actionContext.entityType,
entityId: actionContext.entityId,
candidate,
context: actionContext,
result: saveResult,
},
}),
);
if (actionContext.entityType === 'compute_center' && actionContext.isUnresolved === true) {
const itemRoot = root.closest('[data-unresolved-item]');
if (itemRoot) {
removeResolvedUnresolvedItem(
itemRoot.closest('#info-card-content') || document,
itemRoot,
);
}
}
} catch (error) {
console.error('save compute-center location failed', error);
if (statusEl) statusEl.textContent = `保存失败:${error?.message || error}`;
button.disabled = false;
}
});
}
function formatLocationCollectFailure(result) {
const regularReason = result?.failure_reason || '常规来源没有可用坐标候选';
const llmReason = result?.llm_failure_reason;
@@ -408,14 +555,14 @@ function bindLocationCollectControls(content, context) {
const collectRoot = content.querySelector('[data-collect-entity-id]');
if (!collectRoot) return;
const button = collectRoot.querySelector('[data-collect-action="run"]');
const statusEl = collectRoot.querySelector('[data-collect-status]');
const candidatesEl = collectRoot.querySelector('[data-collect-candidates]');
if (!button) return;
// Bind the delegated preview/save handler once -- works both before and
// after the user has run "采集", because innerHTML replacement of the
// candidates container does not detach handlers higher up the tree.
ensureCandidateActionBindings(collectRoot, context);
const cachedState = getLocationCollectState(context);
if (cachedState) {
hydrateLocationCollectRoot(collectRoot, cachedState);
bindCandidatePreviewButtons(candidatesEl, context);
bindCandidateSaveButtons(candidatesEl, context, statusEl);
}
button.addEventListener('click', async (event) => {
event.stopPropagation();
@@ -443,8 +590,6 @@ function bindLocationCollectControls(content, context) {
candidates,
result,
});
bindCandidatePreviewButtons(candidatesEl, context);
bindCandidateSaveButtons(candidatesEl, context, statusEl);
} catch (error) {
console.error('collect-location failed', error);
setLocationCollectState(context, {
@@ -455,13 +600,11 @@ function bindLocationCollectControls(content, context) {
} finally {
button.disabled = false;
updateLocationCollectDomFromState(getLocationCollectCacheKey(context));
bindCandidatePreviewButtons(candidatesEl, context);
bindCandidateSaveButtons(candidatesEl, context, statusEl);
}
}, { once: false });
}
function renderCollectCandidateRow(candidate, isBest) {
function renderCollectCandidateRow(candidate, isBest, index) {
const precisionLabel = {
precise: '精确',
site: '站点',
@@ -470,24 +613,23 @@ function renderCollectCandidateRow(candidate, isBest) {
const confidence = Number.isFinite(Number(candidate.confidence))
? `${Math.round(Number(candidate.confidence) * 100)}%`
: '-';
const candidateJson = JSON.stringify(candidate).replace(/"/g, '&quot;');
const safeIndex = Number.isFinite(Number(index)) ? Number(index) : 0;
const name = escapeInfoCardHtml(candidate.matched_location_name || candidate.display_name || '候选');
const sourceLabel = escapeInfoCardHtml(candidate.source || '');
return `
<div class="info-card-compute-candidate ${isBest ? 'is-best' : ''}">
<div class="info-card-compute-candidate-line">
<span class="info-card-compute-candidate-name">${candidate.matched_location_name || candidate.display_name || '候选'}</span>
<span class="info-card-compute-candidate-precision">${precisionLabel}</span>
<span class="info-card-compute-candidate-name">${name}</span>
<span class="info-card-compute-candidate-precision">${escapeInfoCardHtml(precisionLabel)}</span>
</div>
<div class="info-card-compute-candidate-line">
<span class="info-card-compute-candidate-source">${candidate.source}</span>
<span class="info-card-compute-candidate-confidence">置信 ${confidence}</span>
<span class="info-card-compute-candidate-source">${sourceLabel}</span>
<span class="info-card-compute-candidate-confidence">置信 ${escapeInfoCardHtml(confidence)}</span>
</div>
<div class="info-card-compute-candidate-line">
<span class="info-card-compute-candidate-coords">${Number(candidate.latitude).toFixed(4)}, ${Number(candidate.longitude).toFixed(4)}</span>
<button type="button" class="info-card-compute-candidate-preview" data-preview-candidate
data-lat="${candidate.latitude}" data-lon="${candidate.longitude}"
data-candidate-json="${candidateJson}">预览</button>
<button type="button" class="info-card-compute-candidate-preview" data-save-candidate
data-candidate-json="${candidateJson}">保存</button>
<button type="button" class="info-card-compute-candidate-preview" data-preview-candidate data-candidate-index="${safeIndex}">预览</button>
<button type="button" class="info-card-compute-candidate-preview" data-save-candidate data-candidate-index="${safeIndex}">保存</button>
</div>
</div>
`;
@@ -503,6 +645,7 @@ function getUnresolvedComputeCenterContext(item) {
sourceId: item?.source_id || item?.id || '',
recordId: item?.id || item?.record_id || '',
name: item?.name || item?.title || '未命名算力中心',
site_type: item?.site_type || metadata.site_type || '',
operator: item?.operator || item?.vendor || metadata.operator || '',
site: item?.site || metadata.site || metadata.organization || '',
city: item?.city || metadata.city || '',
@@ -646,77 +789,14 @@ function getBestLocationCandidate(candidates) {
})[0] || null;
}
function bindCandidatePreviewButtons(container, context) {
container.querySelectorAll('[data-preview-candidate]').forEach((el) => {
el.addEventListener('click', (clickEvt) => {
clickEvt.stopPropagation();
const lat = Number(el.dataset.lat);
const lon = Number(el.dataset.lon);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return;
window.dispatchEvent(
new CustomEvent('earth:preview-location-candidate', {
detail: {
latitude: lat,
longitude: lon,
entityType: context.entityType,
entityId: context.entityId,
candidate: JSON.parse(el.dataset.candidateJson || '{}'),
},
}),
);
});
});
}
function bindCandidateSaveButtons(container, context, statusEl) {
container.querySelectorAll('[data-save-candidate]').forEach((el) => {
el.addEventListener('click', async (clickEvt) => {
clickEvt.stopPropagation();
if (typeof context.save !== 'function') return;
const candidate = JSON.parse(el.dataset.candidateJson || '{}');
el.disabled = true;
if (statusEl) statusEl.textContent = '正在保存所选坐标...';
try {
await context.save(candidate);
setLocationCollectState(context, {
loading: false,
statusText: '坐标已保存,正在后台刷新图层...',
candidates: [],
});
if (statusEl) statusEl.textContent = '坐标已保存,正在后台刷新图层...';
window.dispatchEvent(
new CustomEvent('earth:compute-center-location-saved', {
detail: {
entityType: context.entityType,
entityId: context.entityId,
candidate,
},
}),
);
if (context.entityType === 'compute_center' && context.isUnresolved === true) {
const itemRoot = container.closest('[data-unresolved-item]');
if (itemRoot) {
removeResolvedUnresolvedItem(document, itemRoot);
}
}
} catch (error) {
console.error('save compute-center location failed', error);
if (statusEl) statusEl.textContent = `保存失败:${error?.message || error}`;
} finally {
el.disabled = false;
}
});
});
}
function bindComputeCenterUnresolvedControls(content) {
content.querySelectorAll('[data-unresolved-item]').forEach((itemRoot) => {
const collectButton = itemRoot.querySelector('[data-unresolved-collect]');
const candidatesEl = itemRoot.querySelector('[data-unresolved-candidates]');
const statusEl = itemRoot.querySelector('[data-unresolved-status]');
const context = JSON.parse(collectButton?.dataset.contextJson || '{}');
if (!context.sourceId || !candidatesEl) return;
const actionContext = {
...context,
entityType: 'compute_center',
entityId: context.sourceId,
isUnresolved: true,
@@ -725,8 +805,7 @@ function bindComputeCenterUnresolvedControls(content) {
return mod.saveComputeCenterLocation(context.sourceId, candidate, context);
},
};
bindCandidatePreviewButtons(candidatesEl, actionContext);
bindCandidateSaveButtons(candidatesEl, actionContext, statusEl);
ensureCandidateActionBindings(itemRoot, actionContext);
});
content.querySelectorAll('[data-unresolved-collect]').forEach((button) => {
@@ -762,13 +841,13 @@ function bindComputeCenterUnresolvedControls(content) {
result,
});
const actionContext = {
...context,
entityType: 'compute_center',
entityId: context.sourceId,
isUnresolved: true,
save: (candidate) => mod.saveComputeCenterLocation(context.sourceId, candidate, context),
};
bindCandidatePreviewButtons(candidatesEl, actionContext);
bindCandidateSaveButtons(candidatesEl, actionContext, statusEl);
ensureCandidateActionBindings(itemRoot, actionContext);
} catch (error) {
console.error('collect unresolved compute-center location failed', error);
setLocationCollectState(context, {
@@ -779,17 +858,6 @@ function bindComputeCenterUnresolvedControls(content) {
} finally {
button.disabled = false;
updateLocationCollectDomFromState(getLocationCollectCacheKey(context));
const actionContext = {
entityType: 'compute_center',
entityId: context.sourceId,
isUnresolved: true,
save: async (candidate) => {
const mod = await import('./compute-centers.js');
return mod.saveComputeCenterLocation(context.sourceId, candidate, context);
},
};
bindCandidatePreviewButtons(candidatesEl, actionContext);
bindCandidateSaveButtons(candidatesEl, actionContext, statusEl);
}
});
});

View File

@@ -11,6 +11,10 @@ const DEFAULT_AVOIDANCE_PRECISION = 4;
const DEFAULT_AVOIDANCE_RADIUS = 1.1;
const DEFAULT_AVOIDANCE_STEP = 0.35;
const AVOIDANCE_RING_SLOT_COUNT = 8;
const COMPACT_DOT_ZOOM_THRESHOLD = 1.5;
const COMPACT_DOT_POINT_SIZE = 12;
const COMPACT_DOT_RADIUS_RATIO = 0.26;
let compactDotsEnabled = true;
// Named avoidance profiles. Layers that should mutex with each other (e.g. fan
// out when sharing the same city center) must reference the SAME profile —
@@ -78,6 +82,18 @@ function createCanvas(width, height) {
return canvas;
}
export function getInteractableCompactDotsEnabled() {
return compactDotsEnabled;
}
export function setInteractableCompactDotsEnabled(enabled) {
compactDotsEnabled = Boolean(enabled);
interactableLayerControllers.forEach((controller) => {
controller.refreshVisuals?.();
});
return compactDotsEnabled;
}
function getAvoidanceKey(item, position, basePosition, config) {
if (typeof config?.getKey === "function") {
const key = config.getKey(item, position, basePosition);
@@ -471,7 +487,39 @@ export function createInteractableLayer(options = {}) {
});
}
function createOverlayTexture(marker, state) {
function createCompactDotTexture(marker, state = "normal") {
const kind = marker?.userData?.icon_kind || "default";
const color = state === "normal" ? "#ffffff" : getMarkerColor(marker);
const textureKey = `compact-dot:${state}:${kind}:${color}`;
if (textureCache.has(textureKey)) return textureCache.get(textureKey);
const canvas = document.createElement("canvas");
canvas.width = atlasCellSize;
canvas.height = atlasCellSize;
const context = canvas.getContext("2d");
const center = atlasCellSize / 2;
const radius = atlasCellSize * COMPACT_DOT_RADIUS_RATIO;
context.clearRect(0, 0, canvas.width, canvas.height);
context.fillStyle = color;
context.shadowColor = color;
context.shadowBlur = atlasCellSize * 0.08;
context.beginPath();
context.arc(center, center, radius, 0, Math.PI * 2);
context.fill();
const texture = new THREE.CanvasTexture(canvas);
texture.generateMipmaps = false;
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.needsUpdate = true;
textureCache.set(textureKey, texture);
return texture;
}
function createOverlayTexture(marker, state, compactDotMode = false) {
if (compactDotMode) {
return createCompactDotTexture(marker, state);
}
const kind = marker?.userData?.icon_kind || "default";
const rotationBin = getRotationBin(marker);
const color = getMarkerColor(marker);
@@ -503,6 +551,33 @@ export function createInteractableLayer(options = {}) {
});
}
function getCameraZoom(camera) {
if (!camera?.position?.z) return CONFIG.defaultViewZoom;
return CONFIG.defaultCameraZ / camera.position.z;
}
function shouldUseCompactDots(camera) {
return compactDotsEnabled && getCameraZoom(camera) < COMPACT_DOT_ZOOM_THRESHOLD;
}
function updatePointColors(points, compactDotMode) {
const bucketMarkers = points.userData?.markers || [];
const colorAttribute = points.geometry?.getAttribute("color");
if (!colorAttribute?.array) return;
bucketMarkers.forEach((marker, index) => {
const pointColor =
compactDotMode || icon.colorable !== false
? getMarkerColor(marker)
: "#ffffff";
const [r, g, b] = colorToRgbArray(pointColor);
colorAttribute.array[index * 3] = r;
colorAttribute.array[index * 3 + 1] = g;
colorAttribute.array[index * 3 + 2] = b;
});
colorAttribute.needsUpdate = true;
}
function buildPoints() {
refreshViewportSize();
pointsGroup = new THREE.Group();
@@ -609,21 +684,23 @@ export function createInteractableLayer(options = {}) {
return overlay;
}
function updateOverlay(overlay, marker, state, nextOpacity, sizeMultiplier = 1) {
function updateOverlay(overlay, marker, state, nextOpacity, sizeMultiplier = 1, compactDotMode = false) {
if (!overlay) return;
if (!marker) {
overlay.visible = false;
return;
}
const texture = createOverlayTexture(marker, state);
const texture = createOverlayTexture(marker, state, compactDotMode);
if (overlay.material.map !== texture) {
overlay.material.map = texture;
overlay.material.needsUpdate = true;
}
overlay.material.opacity = nextOpacity;
overlay.material.size =
pointSize * getPointSizeMultiplier(marker) * sizeMultiplier;
(compactDotMode ? COMPACT_DOT_POINT_SIZE : pointSize) *
getPointSizeMultiplier(marker) *
sizeMultiplier;
const positionAttribute = overlay.geometry.getAttribute("position");
positionAttribute.setXYZ(0, marker.position.x, marker.position.y, marker.position.z);
positionAttribute.needsUpdate = true;
@@ -810,8 +887,9 @@ export function createInteractableLayer(options = {}) {
].join(":");
const cameraScale = getCameraScale(camera);
const compactDotMode = shouldUseCompactDots(camera);
const scaleKey = usesDistanceScaling ? cameraScale.toFixed(3) : "fixed";
const nextStateKey = `${stateKey}:${scaleKey}`;
const nextStateKey = `${stateKey}:${scaleKey}:${compactDotMode ? "dots" : "icons"}`;
if (
nextStateKey === lastVisualStateKey &&
@@ -822,12 +900,20 @@ export function createInteractableLayer(options = {}) {
pointObjects.forEach((points) => {
const sampleMarker = points.userData?.markers?.[0];
const nextTexture = compactDotMode
? createCompactDotTexture(sampleMarker)
: createPointTexture(points.userData?.bucketKey, points.userData?.markers || []);
if (points.material.map !== nextTexture) {
points.material.map = nextTexture;
points.material.needsUpdate = true;
}
updatePointColors(points, compactDotMode);
points.visible = visible;
points.material.opacity =
getPointOpacity?.(sampleMarker) ??
(hasFocus ? dimmedOpacity : baseOpacity);
points.material.size =
pointSize *
(compactDotMode ? COMPACT_DOT_POINT_SIZE : pointSize) *
getPointSizeMultiplier(sampleMarker) *
cameraScale *
(hasFocus ? dimmedScale : 1);
@@ -842,6 +928,7 @@ export function createInteractableLayer(options = {}) {
"hover",
hoverOpacity,
hoverScale * cameraScale,
compactDotMode,
);
const lockedPulse =
pulse.enabled && hasFocus
@@ -853,6 +940,7 @@ export function createInteractableLayer(options = {}) {
"locked",
lockedOpacity,
lockedScale * lockedPulse * cameraScale,
compactDotMode,
);
}

View File

@@ -113,6 +113,7 @@ import {
getRelatedSatelliteIndicesForRegions,
updateRelatedSatelliteHighlights,
updateBreathingPhase,
updateSatelliteIdleBreathingVisual,
updateSatellitePointSize,
isSatelliteFrontFacing,
setSatelliteCamera,
@@ -159,6 +160,7 @@ import {
import {
clearComputeCenterData,
clearComputeCenterSelection,
clearComputeCenterLocationPreview,
formatComputeCenterCapacity,
formatComputeCenterLocationPrecision,
formatComputeCenterLocationConfidence,
@@ -174,6 +176,8 @@ import {
getUnresolvedComputeCenters,
loadComputeCenters,
setComputeCenterMarkerState,
showComputeCenterLocationPreview,
spawnSavedComputeCenterLocation,
toggleComputeCenters,
updateComputeCenterVisualState,
} from "./compute-centers.js";
@@ -205,6 +209,7 @@ import {
applyImmediateView,
focusEarthView,
getZoomLevel,
getDefaultEarthZoomLevel,
setZoomLevel,
showZoomStatusCapsule,
teardownControls,
@@ -369,6 +374,7 @@ const INTERACTABLE_CRUISE_CARD_ESTIMATED_WIDTH_PX = 300;
const INTERACTABLE_CRUISE_CARD_ESTIMATED_HEIGHT_PX = 420;
const INTERACTABLE_CRUISE_CARD_SCREEN_MARGIN_PX = 12;
const INTERACTABLE_CRUISE_PRESENTATION_HIDE_MS = 220;
const TARGET_SWITCH_DURATION_SCALE = 1.12;
const MOTION_ROTATION_DELTA = 0.095;
const MOTION_INERTIA_FACTOR = 0.65;
const GLOBE_DRAGGING_CLASS = "is-globe-dragging";
@@ -385,6 +391,8 @@ const HUD_INTERACTIVE_SELECTORS = [
"#earth-stats *",
"#media-panel",
"#media-panel *",
"#motion-debug-panel",
"#motion-debug-panel *",
"#mobile-drawer-shell",
"#mobile-drawer-shell *",
];
@@ -442,6 +450,31 @@ function getDragRotationFactor() {
return CONFIG.dragRotationFactorBase * scale;
}
function getTargetSwitchZoomOptions(requestedZoom) {
const currentZoom = getZoomLevel();
const defaultZoom = getDefaultEarthZoomLevel();
if (Math.abs(currentZoom - defaultZoom) <= 0.005) {
return { zoom: requestedZoom };
}
return {
zoom: currentZoom,
zoomTransitionMode: "restore-current-via-default",
};
}
function focusTargetSwitchView(options = {}) {
const requestedZoom = options.zoom ?? getDefaultEarthZoomLevel();
const zoomOptions = getTargetSwitchZoomOptions(requestedZoom);
const duration = zoomOptions.zoomTransitionMode === "restore-current-via-default"
? Math.round((options.duration ?? CRUISE_CONFIG.focusDurationMs) * TARGET_SWITCH_DURATION_SCALE)
: options.duration;
return focusEarthView(camera, {
...options,
...zoomOptions,
duration,
});
}
function getTouchDistance(firstPoint, secondPoint) {
return Math.hypot(
secondPoint.clientX - firstPoint.clientX,
@@ -801,7 +834,13 @@ function getMotionAnchorRectFromCenter(center, sizePx) {
function getMarkerMotionScreenPoint(marker) {
if (!marker || !camera) return null;
scratchSatelliteWorldPosition.copy(marker.position);
marker.parent?.localToWorld(scratchSatelliteWorldPosition);
if (marker.parent) {
marker.parent.localToWorld(scratchSatelliteWorldPosition);
} else {
const earth = getEarth();
earth?.updateMatrixWorld(true);
earth?.localToWorld(scratchSatelliteWorldPosition);
}
return getMotionScreenPointFromWorld(scratchSatelliteWorldPosition);
}
@@ -825,6 +864,7 @@ function getMotionCandidateScreenCoords(candidate) {
const satPositions = getSatellitePositions();
const position = satPositions?.[candidate.index]?.current;
if (satPoints?.visible && position) {
satPoints.updateMatrixWorld(true);
scratchSatelliteWorldPosition.copy(position).applyMatrix4(satPoints.matrixWorld);
return getMotionScreenPointFromWorld(scratchSatelliteWorldPosition);
}
@@ -1021,7 +1061,7 @@ function ensureMotionCruiseAdapter() {
motionCruiseAdapter = createMotionCruiseAdapter({
presentationController: ensurePresentationController(),
focusView: (options) => focusEarthView(camera, options),
focusView: focusTargetSwitchView,
getItems: getMotionCruiseItems,
getItemId: (item) => item?.id || null,
resolveLatestItem: resolveLatestMotionCruiseItem,
@@ -1851,12 +1891,20 @@ async function previewLocationCandidate(detail) {
const lat = Number(detail?.latitude);
const lon = Number(detail?.longitude);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return;
const earth = getEarth();
if (earth && detail?.entityType === "compute_center") {
showComputeCenterLocationPreview(earth, {
latitude: lat,
longitude: lon,
});
}
interruptCruisePresentation({ resetLoop: true });
setAutoRotate(false);
await focusSearchTarget({ lat, lon }, Math.max(getZoomLevel(), 1.16));
}
async function refreshComputeCentersAfterLocationSave() {
const earth = getEarth();
if (!scene || !earth) {
console.warn("算力中心坐标已保存,但场景尚未就绪,跳过自动刷新");
return { skipped: true };
@@ -1867,7 +1915,38 @@ async function refreshComputeCentersAfterLocationSave() {
setLegendItems("computeCenters", getComputeCenterLegendItems());
refreshLegend();
updateStatsSummary();
return result;
}
async function spawnComputeCenterAfterLocationSave(detail = {}) {
clearComputeCenterLocationPreview();
const earth = getEarth();
if (!earth) {
console.warn("算力中心坐标已保存,但场景尚未就绪,跳过即时生成");
return null;
}
const sourceId = detail.entityId || detail.sourceId || detail.result?.source_id;
const result = await spawnSavedComputeCenterLocation(earth, {
sourceId,
candidate: detail.candidate,
context: detail.context,
saveResult: detail.result,
});
if (!result) return null;
toggleComputeCenters(getShowComputeCenters());
updateComputeCenterHud(result);
setLegendItems("computeCenters", getComputeCenterLegendItems());
refreshLegend();
updateStatsSummary();
syncComputeCenterUnresolvedCount(result.unresolvedCount);
if (result.marker) {
clearLockedObject();
setComputeCenterMarkerState(result.marker, "locked");
lockedObject = result.marker;
lockedObjectType = "compute_center";
}
showStatusMessage("算力中心坐标已保存", "success");
return result;
}
async function focusSearchVessel(marker) {
@@ -2380,9 +2459,10 @@ function ensureBGPCruiseAdapter() {
cruiseBGPAdapter = createBGPCruiseAdapter({
camera,
earth: () => getEarth(),
getMarkers: () => getBGPAnomalyMarkers(),
connector: ensureCalloutConnector(),
focusView: (options) => focusEarthView(camera, options),
focusView: focusTargetSwitchView,
setMarkerLocked: (marker) => {
setLegendMode("bgp");
setBGPMarkerState(marker, "locked");
@@ -2421,13 +2501,24 @@ function ensureNewsCruiseAdapter() {
camera,
earth: () => getEarth(),
connector: ensureCalloutConnector(),
focusView: (options) => focusEarthView(camera, options),
focusView: focusTargetSwitchView,
});
return cruiseNewsAdapter;
}
function getInteractableCruiseInfoOptions({ reveal = true } = {}) {
const placement = getInteractableCruiseCardPlacement();
return {
x: placement.x,
y: placement.y,
absolute: true,
reveal,
anchorStable: true,
};
}
function getInteractableCruiseCardPlacement() {
const hudScale =
Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--hud-scale")) || 1;
const width = Math.min(INTERACTABLE_CRUISE_CARD_ESTIMATED_WIDTH_PX * hudScale, window.innerWidth - 32);
@@ -2443,9 +2534,8 @@ function getInteractableCruiseInfoOptions({ reveal = true } = {}) {
Math.max(INTERACTABLE_CRUISE_CARD_SCREEN_MARGIN_PX, y),
window.innerHeight - height - INTERACTABLE_CRUISE_CARD_SCREEN_MARGIN_PX,
),
absolute: true,
reveal,
anchorStable: true,
width,
height,
};
}
@@ -2512,7 +2602,7 @@ function getSatelliteCruiseItems() {
async function focusInteractableCruiseItem(item, options = {}) {
const coords = getMotionCandidateFocusCoords(item?.payload);
if (!coords) return;
await focusEarthView(camera, {
await focusTargetSwitchView({
lat: coords.lat,
lon: coords.lon,
rotLon: coords.lon - 270,
@@ -2527,6 +2617,7 @@ async function focusInteractableCruiseItem(item, options = {}) {
async function presentInteractableCruiseItem(item, { context } = {}) {
const candidate = item?.payload;
if (!candidate) return false;
const cardPlacement = getInteractableCruiseCardPlacement();
return ensurePresentationController().present(
{
id: `cruise:${item.id}`,
@@ -2538,8 +2629,9 @@ async function presentInteractableCruiseItem(item, { context } = {}) {
},
connector: {
enabled: true,
animateOnReveal: true,
sourceProvider: () => getMotionCandidateAnchor(candidate),
targetProvider: getVisiblePresentationCardTarget,
targetProvider: () => getVisiblePresentationCardTarget() || cardPlacement,
options: {
routingMode: "adaptive",
sourceGapPx: 0,
@@ -4182,11 +4274,41 @@ function setupEventListeners() {
console.warn("预览候选位置失败:", error);
});
};
const handleComputeCenterLocationSaved = () => {
refreshComputeCentersAfterLocationSave().catch((error) => {
console.warn("刷新算力中心图层失败:", error);
showStatusMessage("坐标已保存,图层自动刷新失败,可手动刷新页面或重新开关图层", "warning");
});
const handleComputeCenterLocationSaved = (event) => {
const detail = event?.detail || {};
spawnComputeCenterAfterLocationSave(detail)
.then((spawnResult) => {
if (!spawnResult) {
// No optimistic marker (e.g. scene not ready yet); rely on refresh
// for the user-visible confirmation.
return refreshComputeCentersAfterLocationSave()
.then(() => {
showStatusMessage("算力中心坐标已保存", "success");
})
.catch((error) => {
// Swallow refresh failure: the save itself succeeded, so we
// must not surface this as a save failure.
console.warn("后台校准算力中心图层失败:", error);
showStatusMessage("坐标已保存,地图稍后同步", "info");
});
}
// Optimistic marker is on screen; reconcile in the background.
refreshComputeCentersAfterLocationSave().catch((error) => {
console.warn("后台校准算力中心图层失败:", error);
});
return null;
})
.catch((error) => {
console.warn("即时生成算力中心交互物件失败,改用后台刷新:", error);
showStatusMessage("坐标已保存,正在同步地图...", "info");
refreshComputeCentersAfterLocationSave()
.then(() => {
showStatusMessage("算力中心坐标已保存", "success");
})
.catch((refreshError) => {
console.warn("后台校准算力中心图层失败:", refreshError);
});
});
};
const handleComputeCenterUnresolvedCountChange = (event) => {
syncComputeCenterUnresolvedCount(event?.detail?.unresolvedCount ?? event?.detail);
@@ -4998,6 +5120,12 @@ function animate() {
updateSatellitePositions(deltaTime);
updateBreathingPhase(deltaTime);
updateSatelliteIdleBreathingVisual(
!isDragging &&
!hasActiveGlobeInertia() &&
activeTouchPoints.size === 0 &&
!pinchGesture,
);
updateSatellitePointSize();
updateRelatedSatelliteHighlights();
updateCelestialLayer(new Date(), camera);

View File

@@ -21,10 +21,31 @@ const MIN_GESTURE_INTENSITY = 0.45;
const ARM_PATTERN_INTENSITY_SCALE = 5;
const WRIST_LAYER_INTENSITY_SCALE = 9;
const HEAD_TILT_INTENSITY_SCALE = 12;
const ZOOM_OPEN_WRIST_SPREAD_FACTOR = 1.42;
const ZOOM_OPEN_WRIST_HEIGHT_TOLERANCE = 0.16;
const ZOOM_CLOSE_WRIST_SPREAD_FACTOR = 1.28;
const ZOOM_SUPPRESS_WRIST_SPREAD_FACTOR = 1.18;
// Trend-based zoom detection. Pose matching is brittle because MediaPipe
// keypoints jitter and the absolute "T-pose" pattern only matches in a
// narrow window. Track frame-to-frame motion instead: if both wrists are
// moving anti-symmetrically along the x axis (one moving outward, the other
// moving outward in the opposite direction), the user's intent is a zoom,
// regardless of where exactly the wrists end up.
const ZOOM_TREND_MIN_WRIST_DELTA = 0.010;
const ZOOM_TREND_HEIGHT_TOLERANCE = 0.18;
const ZOOM_TREND_INTENSITY_SCALE = 14;
const ZOOM_TREND_MIN_INTENSITY = 0.6;
// Left wrist must hang at least this far below the shoulder line for the
// arm to count as "at rest" -- distinguishes a deliberate single right-arm
// rotate from any two-arm or chest-height gesture in flight.
const LEFT_ARM_REST_HANGING_BELOW_SHOULDER = 0.13;
// Sustained pose-hold thresholds for continuous zoom emission while the
// user keeps their arms in a spread / closed pose. Mirror-safe (all checks
// are span-based, not direction-based) so they work regardless of whether
// the camera feed is mirrored.
const ZOOM_HOLD_HEIGHT_TOLERANCE = 0.18;
const ZOOM_HOLD_WRIST_BELOW_SHOULDER_LIMIT = 0.10;
const ZOOM_HOLD_SPREAD_FACTOR = 1.30;
const ZOOM_HOLD_CLOSE_FACTOR = 0.85;
const ZOOM_HOLD_ELBOW_OUT_FACTOR = 0.25;
const CAMERA_CONSTRAINTS = {
video: {
facingMode: "user",
@@ -179,39 +200,6 @@ function getRightArmPattern(rightShoulder, rightElbow, rightWrist) {
return null;
}
function getZoomPattern(leftShoulder, leftElbow, leftWrist, rightShoulder, rightElbow, rightWrist, shoulderWidth) {
const leftUpper = vectorBetween(leftShoulder, leftElbow);
const leftTerminal = vectorBetween(leftElbow, leftWrist);
const rightUpper = vectorBetween(rightShoulder, rightElbow);
const rightTerminal = vectorBetween(rightElbow, rightWrist);
if (!leftUpper || !leftTerminal || !rightUpper || !rightTerminal) return null;
const leftWristOutside = leftWrist.x < leftShoulder.x - ARM_PATTERN_MIN_SIDE_REACH;
const rightWristOutside = rightWrist.x > rightShoulder.x + ARM_PATTERN_MIN_SIDE_REACH;
const leftArmOut =
leftWristOutside &&
leftElbow.x <= leftShoulder.x + ARM_PATTERN_MIN_SIDE_REACH * 0.25;
const rightArmOut =
rightWristOutside &&
rightElbow.x >= rightShoulder.x - ARM_PATTERN_MIN_SIDE_REACH * 0.25;
const leftForearmIn = leftWrist.x > leftElbow.x + ARM_PATTERN_MIN_SIDE_REACH * 0.5;
const rightForearmIn = rightWrist.x < rightElbow.x - ARM_PATTERN_MIN_SIDE_REACH * 0.5;
const wristsApart = Math.abs(rightWrist.x - leftWrist.x);
const wristsCloseToCenter = wristsApart < shoulderWidth * ZOOM_CLOSE_WRIST_SPREAD_FACTOR;
const wristsHeightAligned = Math.abs(rightWrist.y - leftWrist.y) <= ZOOM_OPEN_WRIST_HEIGHT_TOLERANCE;
const elbowsOut =
leftElbow.x < leftShoulder.x - ARM_PATTERN_MIN_SIDE_REACH * 0.5 &&
rightElbow.x > rightShoulder.x + ARM_PATTERN_MIN_SIDE_REACH * 0.5;
if (leftArmOut && rightArmOut && wristsHeightAligned && wristsApart > shoulderWidth * ZOOM_OPEN_WRIST_SPREAD_FACTOR) {
return { gesture: "zoom_in", confidence: 0.82, intensity: 0.82 };
}
if (elbowsOut && leftForearmIn && rightForearmIn && wristsCloseToCenter) {
return { gesture: "zoom_out", confidence: 0.78, intensity: 0.72 };
}
return null;
}
function isZoomCandidatePose(leftShoulder, leftElbow, leftWrist, rightShoulder, rightElbow, rightWrist, shoulderWidth) {
const wristsApart = Math.abs(rightWrist.x - leftWrist.x);
const bothHandsOutside =
@@ -234,6 +222,101 @@ function isZoomCandidatePose(leftShoulder, leftElbow, leftWrist, rightShoulder,
);
}
// The single-arm rotate detector only looks at the right arm and cannot tell
// whether the user is mid-way through a two-arm gesture. Because the right
// wrist crosses its rotate trigger one or two frames before the left wrist
// catches up to the zoom threshold, rotate routinely fires as the user starts
// to spread their arms. Flip the predicate: a deliberate single right-arm
// wave keeps the left wrist clearly hanging at the side, so refuse to emit
// any right-arm rotate unless we can verify the left arm is at rest (wrist
// hanging well below the shoulder AND elbow + wrist sitting near the body).
// Any ambiguous left-arm state -- raised, extending outward, or held at
// chest level -- yields no gesture, letting getZoomHoldPose handle the next
// frame instead.
function isLeftArmAtRest(leftShoulder, leftElbow, leftWrist) {
const hangingBelowShoulder = leftWrist.y >= leftShoulder.y + LEFT_ARM_REST_HANGING_BELOW_SHOULDER;
const wristNearBody = leftWrist.x >= leftShoulder.x - ARM_PATTERN_MIN_SIDE_REACH;
const elbowNearBody = leftElbow.x >= leftShoulder.x - ARM_PATTERN_MIN_SIDE_REACH;
return hangingBelowShoulder && wristNearBody && elbowNearBody;
}
// Detect a two-arm zoom intent purely from frame-to-frame motion.
// Mirror-safe: measures the change in span between the wrists, not the
// per-wrist x direction. Spreading widens the span and triggers zoom_in
// regardless of whether the camera feed is mirrored; closing shrinks the
// span and triggers zoom_out. Both wrists must be actively moving (each
// crosses the noise floor) to rule out single-arm drift.
function getZoomTrend(leftWrist, rightWrist, previousLeftWrist, previousRightWrist) {
if (!previousLeftWrist || !previousRightWrist) return null;
const heightDelta = Math.abs(rightWrist.y - leftWrist.y);
if (heightDelta > ZOOM_TREND_HEIGHT_TOLERANCE) return null;
const leftMoved = Math.abs(leftWrist.x - previousLeftWrist.x);
const rightMoved = Math.abs(rightWrist.x - previousRightWrist.x);
if (leftMoved < ZOOM_TREND_MIN_WRIST_DELTA || rightMoved < ZOOM_TREND_MIN_WRIST_DELTA) return null;
const currentSpread = Math.abs(rightWrist.x - leftWrist.x);
const previousSpread = Math.abs(previousRightWrist.x - previousLeftWrist.x);
const spreadDelta = currentSpread - previousSpread;
const minSpreadDelta = ZOOM_TREND_MIN_WRIST_DELTA * 2;
const combinedSpeed = leftMoved + rightMoved;
const intensity = Math.min(1, Math.max(ZOOM_TREND_MIN_INTENSITY, combinedSpeed * ZOOM_TREND_INTENSITY_SCALE));
if (spreadDelta > minSpreadDelta) {
return { gesture: "zoom_in", confidence: 0.88, intensity };
}
if (spreadDelta < -minSpreadDelta) {
return { gesture: "zoom_out", confidence: 0.86, intensity };
}
return null;
}
// Mirror-safe sustained-pose detector. Decides whether the user is currently
// holding a "spread" or "closed" pose so zoom_in / zoom_out can keep firing
// while no motion is happening (trend would otherwise stop emitting).
// - heightDelta gate rules out one-arm-up-one-arm-down gestures.
// - wristsRaised gate ensures the wrists are at chest level or above
// (excludes "hands hanging at the hips" which would coincidentally have
// a small span).
// - span > shoulderWidth * 1.30 -> zoom_in (mirror-safe via Math.abs).
// - closed pose additionally requires both elbows clearly outside the
// shoulder line (forming a "hug"), distinguishing it from arms relaxed
// at the body's centerline.
function getZoomHoldPose(
leftShoulder,
leftElbow,
leftWrist,
rightShoulder,
rightElbow,
rightWrist,
shoulderWidth,
) {
const heightDelta = Math.abs(rightWrist.y - leftWrist.y);
if (heightDelta > ZOOM_HOLD_HEIGHT_TOLERANCE) return null;
const avgShoulderY = (leftShoulder.y + rightShoulder.y) / 2;
const wristsRaised =
leftWrist.y <= avgShoulderY + ZOOM_HOLD_WRIST_BELOW_SHOULDER_LIMIT &&
rightWrist.y <= avgShoulderY + ZOOM_HOLD_WRIST_BELOW_SHOULDER_LIMIT;
if (!wristsRaised) return null;
const span = Math.abs(rightWrist.x - leftWrist.x);
if (span > shoulderWidth * ZOOM_HOLD_SPREAD_FACTOR) {
return { gesture: "zoom_in", confidence: 0.82, intensity: 0.8 };
}
const leftElbowSpread = Math.abs(leftElbow.x - leftShoulder.x);
const rightElbowSpread = Math.abs(rightElbow.x - rightShoulder.x);
const elbowsOutward =
leftElbowSpread > shoulderWidth * ZOOM_HOLD_ELBOW_OUT_FACTOR &&
rightElbowSpread > shoulderWidth * ZOOM_HOLD_ELBOW_OUT_FACTOR;
if (elbowsOutward && span < shoulderWidth * ZOOM_HOLD_CLOSE_FACTOR) {
return { gesture: "zoom_out", confidence: 0.80, intensity: 0.7 };
}
return null;
}
function applyPoseLatch(observation, state) {
if (!state || !observation) return observation;
if (state.activePatternGesture === observation.gesture) return null;
@@ -252,8 +335,19 @@ function recognizeGesture(joints, previousJoints, options = {}) {
const leftShoulder = getJoint(joints, "left_shoulder");
const rightShoulder = getJoint(joints, "right_shoulder");
const previousLeftWrist = getJoint(previousJoints, "left_wrist");
const previousRightWrist = getJoint(previousJoints, "right_wrist");
if (!leftWrist || !rightWrist || !leftElbow || !rightElbow || !leftShoulder || !rightShoulder) return null;
// Trend-based zoom runs first: anti-symmetric wrist motion expresses
// the user's intent directly and is far more reliable than waiting for
// an absolute pose to match. It also bypasses the layer / focus / rotate
// detectors, which would otherwise intercept mid-spread frames.
const trend = getZoomTrend(leftWrist, rightWrist, previousLeftWrist, previousRightWrist);
if (trend) {
if (state) state.activePatternGesture = trend.gesture;
return trend;
}
const shoulderWidth = Math.max(0.08, Math.abs(rightShoulder.x - leftShoulder.x));
const leftRaised = leftWrist.y < leftShoulder.y - 0.05;
const rightRaised = rightWrist.y < rightShoulder.y - 0.05;
@@ -275,14 +369,49 @@ function recognizeGesture(joints, previousJoints, options = {}) {
return { gesture: "focus_next", confidence: 0.78, intensity: Math.min(1, Math.abs(headTiltY) * HEAD_TILT_INTENSITY_SCALE) };
}
const pattern =
getZoomPattern(leftShoulder, leftElbow, leftWrist, rightShoulder, rightElbow, rightWrist, shoulderWidth) ||
(
isZoomCandidatePose(leftShoulder, leftElbow, leftWrist, rightShoulder, rightElbow, rightWrist, shoulderWidth)
? null
: getRightArmPattern(rightShoulder, rightElbow, rightWrist)
);
if (pattern) return applyPoseLatch(pattern, state);
// Pose-based zoom hold: while the user sustains a spread (zoom_in) or
// closed (zoom_out) pose without further motion, keep emitting the same
// zoom direction every frame. Bypasses the pattern latch so the gesture
// can fire repeatedly; downstream cooldownMs (120ms) rate-limits to
// ~8 emissions/sec, which produces smooth continuous zooming on the globe
// until the user changes their pose. Uses the mirror-safe span detector
// so it works on non-mirrored camera feeds where the absolute left/right
// pose checks would otherwise fail.
const zoomPattern = getZoomHoldPose(
leftShoulder,
leftElbow,
leftWrist,
rightShoulder,
rightElbow,
rightWrist,
shoulderWidth,
);
if (zoomPattern) {
if (state) state.activePatternGesture = zoomPattern.gesture;
return zoomPattern;
}
// Two safeguards must both hold before a single right-arm rotate fires:
// (1) zoom is not currently a likely interpretation of the pose,
// (2) the left arm is verifiably at rest. This kills the right-leads-left
// race that previously emitted a stray rotate at the start of a spread.
const rotateAllowed =
!isZoomCandidatePose(
leftShoulder,
leftElbow,
leftWrist,
rightShoulder,
rightElbow,
rightWrist,
shoulderWidth,
) && isLeftArmAtRest(leftShoulder, leftElbow, leftWrist);
const rotatePattern = rotateAllowed
? getRightArmPattern(rightShoulder, rightElbow, rightWrist)
: null;
// Rotate stays latched: one deliberate wave = one rotation step. Without
// the latch, holding the arm out would spin the globe continuously, which
// is the opposite of what the user wants for navigation.
if (rotatePattern) return applyPoseLatch(rotatePattern, state);
if (state) state.activePatternGesture = null;
return null;
}

View File

@@ -232,6 +232,11 @@ export function createMotionControlAdapter(options = {}) {
activeProvider?.stop?.();
activeProvider = null;
connected = false;
dispatchWindowEvent(MOTION_DEBUG_VIDEO_SOURCE_EVENT, {
provider: selectedProvider,
source: null,
active: false,
});
emitState({ provider: selectedProvider, connected: false });
},
isConnected() {

View File

@@ -506,6 +506,24 @@ describe("browser camera gesture semantics", () => {
expect(recognizeGesture(current, current)?.gesture).toBe("zoom_in");
});
test("open arms within a 30 degree vertical fan trigger zoom in", () => {
const upwardFan = createPoseJoints({
left_elbow: { id: "left_elbow", x: 0.35, y: 0.48, confidence: 1 },
left_wrist: { id: "left_wrist", x: 0.29, y: 0.43, confidence: 1 },
right_elbow: { id: "right_elbow", x: 0.65, y: 0.52, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.72, y: 0.57, confidence: 1 },
});
const downwardFan = createPoseJoints({
left_elbow: { id: "left_elbow", x: 0.35, y: 0.52, confidence: 1 },
left_wrist: { id: "left_wrist", x: 0.29, y: 0.57, confidence: 1 },
right_elbow: { id: "right_elbow", x: 0.65, y: 0.48, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.72, y: 0.43, confidence: 1 },
});
expect(recognizeGesture(upwardFan, upwardFan)?.gesture).toBe("zoom_in");
expect(recognizeGesture(downwardFan, downwardFan)?.gesture).toBe("zoom_in");
});
test("near zoom-in pose suppresses right-arm rotate while the second hand catches up", () => {
const current = createPoseJoints({
left_elbow: { id: "left_elbow", x: 0.4, y: 0.5, confidence: 1 },
@@ -527,6 +545,188 @@ describe("browser camera gesture semantics", () => {
expect(recognizeGesture(current, current)?.gesture).toBe("zoom_out");
});
// Two-arm spread starts asymmetrically: the right wrist crosses the rotate
// trigger threshold a frame or two before the left wrist catches up. The
// mirror-safe span-based pose detector recognises this frame as a spread
// and emits zoom_in instead of letting the stale single-arm rotate fire.
test("mid-spread emits zoom_in (not a stray right-arm rotate) while the left arm is still extending", () => {
const midSpread = createPoseJoints({
right_elbow: { id: "right_elbow", x: 0.66, y: 0.5, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.72, y: 0.5, confidence: 1 },
left_elbow: { id: "left_elbow", x: 0.36, y: 0.5, confidence: 1 },
left_wrist: { id: "left_wrist", x: 0.38, y: 0.55, confidence: 1 },
});
const result = recognizeGesture(midSpread, midSpread);
expect(result?.gesture).toBe("zoom_in");
});
// Earliest-spread case: the left wrist has just started to lift toward
// shoulder height while still sitting at the body line. The right wrist has
// already crossed the rotate threshold. The mirror-safe pose detector
// recognises the wide span and fires zoom_in; the at-rest gate ensures
// rotate cannot fire in this configuration either.
test("early-spread frame fires zoom_in instead of a stray right-arm rotate", () => {
const earlySpread = createPoseJoints({
right_elbow: { id: "right_elbow", x: 0.65, y: 0.5, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.72, y: 0.5, confidence: 1 },
left_elbow: { id: "left_elbow", x: 0.41, y: 0.55, confidence: 1 },
left_wrist: { id: "left_wrist", x: 0.42, y: 0.58, confidence: 1 },
});
const result = recognizeGesture(earlySpread, earlySpread);
expect(result?.gesture).toBe("zoom_in");
});
// The suppressor must not over-fire: a deliberate single right-arm wave
// with the left arm at rest still needs to map to a rotate gesture.
test("right-arm wave with the left arm at rest still triggers rotate_left", () => {
const wave = createPoseJoints({
right_elbow: { id: "right_elbow", x: 0.65, y: 0.5, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.74, y: 0.5, confidence: 1 },
});
expect(recognizeGesture(wave, wave)?.gesture).toBe("rotate_left");
});
// Trend-based zoom: anti-symmetric wrist motion is the strongest signal of
// intent. Pose matching alone is brittle because MediaPipe keypoints
// jitter; tracking direction-of-motion catches the gesture as soon as it
// starts.
test("wrists drifting apart trigger zoom_in via trend detection", () => {
const previous = createPoseJoints({
left_wrist: { id: "left_wrist", x: 0.42, y: 0.55, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.58, y: 0.55, confidence: 1 },
});
const current = createPoseJoints({
left_wrist: { id: "left_wrist", x: 0.38, y: 0.55, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.62, y: 0.55, confidence: 1 },
});
expect(recognizeGesture(current, previous)?.gesture).toBe("zoom_in");
});
test("wrists drifting together trigger zoom_out via trend detection", () => {
const previous = createPoseJoints({
left_wrist: { id: "left_wrist", x: 0.30, y: 0.55, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.70, y: 0.55, confidence: 1 },
});
const current = createPoseJoints({
left_wrist: { id: "left_wrist", x: 0.34, y: 0.55, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.66, y: 0.55, confidence: 1 },
});
expect(recognizeGesture(current, previous)?.gesture).toBe("zoom_out");
});
// Single-arm motion (right wrist moving while left wrist is stationary)
// must NOT trip the trend zoom — only anti-symmetric motion of both
// wrists qualifies.
test("single-arm motion does not trigger trend-based zoom", () => {
const previous = createPoseJoints({
right_wrist: { id: "right_wrist", x: 0.58, y: 0.55, confidence: 1 },
});
const current = createPoseJoints({
right_wrist: { id: "right_wrist", x: 0.70, y: 0.55, confidence: 1 },
});
const result = recognizeGesture(current, previous);
expect(result?.gesture).not.toBe("zoom_in");
expect(result?.gesture).not.toBe("zoom_out");
});
// If the two wrists are at very different heights (one resting, one
// raised), trend detection must NOT fire — that's a one-arm gesture.
test("trend zoom requires the wrists to be at roughly the same height", () => {
const previous = createPoseJoints({
left_wrist: { id: "left_wrist", x: 0.42, y: 0.80, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.58, y: 0.30, confidence: 1 },
});
const current = createPoseJoints({
left_wrist: { id: "left_wrist", x: 0.38, y: 0.80, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.62, y: 0.30, confidence: 1 },
});
const result = recognizeGesture(current, previous);
expect(result?.gesture).not.toBe("zoom_in");
expect(result?.gesture).not.toBe("zoom_out");
});
// Zoom is a sustained gesture: while the user holds a spread T-pose the
// recognizer must keep emitting zoom_in every frame so the globe keeps
// zooming. This is unlike rotate, which should emit once per wave.
test("holding a spread pose emits zoom_in on every frame", () => {
const state = {};
const spread = createPoseJoints({
left_elbow: { id: "left_elbow", x: 0.36, y: 0.5, confidence: 1 },
left_wrist: { id: "left_wrist", x: 0.34, y: 0.4, confidence: 1 },
right_elbow: { id: "right_elbow", x: 0.64, y: 0.5, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.66, y: 0.4, confidence: 1 },
});
expect(recognizeGesture(spread, spread, { state })?.gesture).toBe("zoom_in");
expect(recognizeGesture(spread, spread, { state })?.gesture).toBe("zoom_in");
expect(recognizeGesture(spread, spread, { state })?.gesture).toBe("zoom_in");
});
// Mirror-safe trend: on a non-mirrored camera feed the subject's anatomical
// left arm appears on the image right (left_shoulder.x > right_shoulder.x).
// Spreading the arms must still fire zoom_in (not zoom_out) because the
// span between the wrists grows regardless of camera orientation.
test("non-mirrored camera: spreading wrists still triggers zoom_in", () => {
const previous = createPoseJoints({
// Swapped layout: anatomical left on image right
left_shoulder: { id: "left_shoulder", x: 0.58, y: 0.5, confidence: 1 },
right_shoulder: { id: "right_shoulder", x: 0.42, y: 0.5, confidence: 1 },
left_wrist: { id: "left_wrist", x: 0.58, y: 0.55, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.42, y: 0.55, confidence: 1 },
});
const current = createPoseJoints({
left_shoulder: { id: "left_shoulder", x: 0.58, y: 0.5, confidence: 1 },
right_shoulder: { id: "right_shoulder", x: 0.42, y: 0.5, confidence: 1 },
// Subject's left arm extending right in the image; subject's right
// arm extending left in the image. Span widens either way.
left_wrist: { id: "left_wrist", x: 0.62, y: 0.55, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.38, y: 0.55, confidence: 1 },
});
expect(recognizeGesture(current, previous)?.gesture).toBe("zoom_in");
});
// Mirror-safe trend: on the same non-mirrored layout, hands coming together
// must still fire zoom_out.
test("non-mirrored camera: closing wrists still triggers zoom_out", () => {
const previous = createPoseJoints({
left_shoulder: { id: "left_shoulder", x: 0.58, y: 0.5, confidence: 1 },
right_shoulder: { id: "right_shoulder", x: 0.42, y: 0.5, confidence: 1 },
left_wrist: { id: "left_wrist", x: 0.70, y: 0.55, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.30, y: 0.55, confidence: 1 },
});
const current = createPoseJoints({
left_shoulder: { id: "left_shoulder", x: 0.58, y: 0.5, confidence: 1 },
right_shoulder: { id: "right_shoulder", x: 0.42, y: 0.5, confidence: 1 },
left_wrist: { id: "left_wrist", x: 0.66, y: 0.55, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.34, y: 0.55, confidence: 1 },
});
expect(recognizeGesture(current, previous)?.gesture).toBe("zoom_out");
});
// Zoom_out must also be sustained — closing the hands and holding
// continues to zoom out.
test("holding a closed pose emits zoom_out on every frame", () => {
const state = {};
const closed = createPoseJoints({
left_elbow: { id: "left_elbow", x: 0.34, y: 0.55, confidence: 1 },
left_wrist: { id: "left_wrist", x: 0.46, y: 0.58, confidence: 1 },
right_elbow: { id: "right_elbow", x: 0.66, y: 0.55, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.54, y: 0.58, confidence: 1 },
});
expect(recognizeGesture(closed, closed, { state })?.gesture).toBe("zoom_out");
expect(recognizeGesture(closed, closed, { state })?.gesture).toBe("zoom_out");
});
});
describe("browser camera provider", () => {

View File

@@ -10,8 +10,8 @@ const DEBUG_PANEL_ID = "motion-debug-panel";
const DEBUG_CANVAS_ID = "motion-debug-canvas";
const DEBUG_STATUS_ID = "motion-debug-status";
const DEBUG_MATCH_ID = "motion-debug-match";
const DEBUG_PAUSE_ID = "motion-debug-pause";
const DEBUG_CLOSE_SELECTOR = "[data-motion-debug-close]";
const DEBUG_PAUSE_SELECTOR = "[data-motion-recognition-pause-toggle]";
const MOBILE_MOUNT_ID = "mobile-motion-debug-mount";
const FALLBACK_CANVAS_WIDTH = 320;
const FALLBACK_CANVAS_HEIGHT = 220;
@@ -79,16 +79,21 @@ function bindPanelControls() {
if (controlsBound || !(panel instanceof HTMLElement)) return;
controlsBound = true;
const closeButton = panel.querySelector(DEBUG_CLOSE_SELECTOR);
closeButton?.addEventListener?.("click", (event) => {
panel.addEventListener("click", (event) => {
const target = event.target instanceof Element ? event.target : null;
if (!target?.closest(DEBUG_CLOSE_SELECTOR)) return;
event.preventDefault();
event.stopPropagation();
dispatchWindowEvent(MOTION_DEBUG_CLOSE_EVENT);
});
const pauseInput = document.getElementById(DEBUG_PAUSE_ID);
pauseInput?.addEventListener?.("change", () => {
recognitionPaused = pauseInput.checked === true;
panel.addEventListener("change", (event) => {
const target = event.target;
if (!(target instanceof HTMLInputElement) || !target.matches(DEBUG_PAUSE_SELECTOR)) {
return;
}
event.stopPropagation();
recognitionPaused = target.checked === true;
dispatchWindowEvent(MOTION_RECOGNITION_PAUSE_EVENT, { paused: recognitionPaused });
render();
});
@@ -282,6 +287,11 @@ function render() {
panel.classList.toggle("hud-panel-hidden", !visible);
panel.classList.toggle("is-motion-matched", Boolean(lastFrame?.matchedGesture));
panel.classList.toggle("is-motion-recognition-paused", recognitionPaused);
panel.querySelectorAll(DEBUG_PAUSE_SELECTOR).forEach((input) => {
if (input instanceof HTMLInputElement) {
input.checked = recognitionPaused;
}
});
const providerLabel = getProviderLabel(provider);
const pauseSuffix = recognitionPaused ? " · 匹配已暂停" : "";
setText(statusEl, connected ? `${providerLabel}已连接${pauseSuffix}` : `${providerLabel}未连接${pauseSuffix}`);

View File

@@ -115,6 +115,7 @@ export class PresentationController {
const { request } = this.active;
const connector = this.getConnectorInstance(request);
if (!connector || request.connector?.enabled !== true) return false;
if (!animate && connector.isAnimating?.()) return true;
const path = this.getConnectorPath(request);
if (!path) {
connector.hide?.();
@@ -150,17 +151,18 @@ export class PresentationController {
return false;
}
const animateOnReveal = request.connector?.animateOnReveal === true;
const connectorReady =
request.connector?.enabled === true
request.connector?.enabled === true && !animateOnReveal
? await this.waitForConnector(request, context)
: false;
: request.connector?.enabled === true;
if (!isCurrentContext(context) || this.active?.request !== request) {
this.dismiss("interrupted");
return false;
}
if (connectorReady) {
if (connectorReady && !animateOnReveal) {
const drawMs = Number(request.connector?.drawMs) || DEFAULT_CONNECTOR_DRAW_MS;
const drawCompleted = await waitForContext(context, drawMs);
if (!drawCompleted || this.active?.request !== request) {
@@ -176,7 +178,17 @@ export class PresentationController {
return false;
}
this.update({ animate: !connectorReady });
const finalConnectorAnimated = this.update({
animate: animateOnReveal || !connectorReady,
});
if (animateOnReveal && finalConnectorAnimated) {
const drawMs = Number(request.connector?.drawMs) || DEFAULT_CONNECTOR_DRAW_MS;
const drawCompleted = await waitForContext(context, drawMs);
if (!drawCompleted || this.active?.request !== request) {
this.dismiss("interrupted");
return false;
}
}
this.scheduleLifetime(request);
return true;
}

View File

@@ -30,6 +30,7 @@ function createController(options = {}) {
calls.push("connector:render");
return options.connectorReady ?? true;
},
isAnimating: () => options.connectorAnimating === true,
hide: () => calls.push("connector:hide"),
};
const controller = new PresentationController({
@@ -126,6 +127,17 @@ describe("PresentationController", () => {
expect(calls).toEqual(["connector:render"]);
});
test("update does not interrupt an active connector draw animation", async () => {
const { calls, controller } = createController({ connectorAnimating: true });
await controller.present(createRequest({ calls }));
calls.length = 0;
const updated = controller.update();
expect(updated).toBe(true);
expect(calls).toEqual([]);
});
test("rect source anchors are passed as center point plus sourceRect", async () => {
const { controller, pathCalls } = createController();

View File

@@ -42,6 +42,7 @@ let positionUpdateAccumulator = 0;
let satelliteCapacity = 0;
let satelliteSatrecCache = new Map();
let satelliteDisplayStyle = DEFAULT_SATELLITE_DISPLAY_STYLE;
let satelliteIdleBreathingEnabled = true;
const GROUND_FOOTPRINT_RENDER_ORDER = 3;
@@ -127,6 +128,8 @@ const FALLBACK_TRAIL_ALPHA_END = 0.8;
const DOT_TEXTURE_SIZE = 32;
const POSITION_UPDATE_INTERVAL_MS = 250;
const BACKGROUND_TRAIL_RESET_DELTA_MS = 2000;
const SATELLITE_TWINKLE_SECONDARY_SPEED = 1.73;
const SATELLITE_TWINKLE_SECONDARY_WEIGHT = 0.28;
const DIMMED_SATELLITE_BRIGHTNESS = 0.42;
const DIMMED_SATELLITE_TRAIL_BRIGHTNESS = 0.24;
const DIMMED_SATELLITE_POINT_OPACITY = 0.62;
@@ -325,6 +328,48 @@ export function updateBreathingPhase(deltaTime = 16) {
breathingPhase += SATELLITE_CONFIG.breathingSpeed * (deltaTime / 16);
}
export function getSatelliteIdleBreathingEnabled() {
return satelliteIdleBreathingEnabled;
}
export function setSatelliteIdleBreathingEnabled(enabled) {
satelliteIdleBreathingEnabled = Boolean(enabled);
updateSatelliteIdleBreathingVisual(false);
return satelliteIdleBreathingEnabled;
}
export function updateSatelliteIdleBreathingVisual(isIdle = true) {
if (!satellitePoints || !satelliteBackdropPoints) return;
if (satellitePoints.material.uniforms?.opacity) {
satellitePoints.material.uniforms.opacity.value = 0.9;
} else {
satellitePoints.material.opacity = 0.9;
}
if (satelliteBackdropPoints.material.uniforms?.opacity) {
satelliteBackdropPoints.material.uniforms.opacity.value = 0.42;
} else {
satelliteBackdropPoints.material.opacity = 0.42;
}
const pointAlphaAttr = satellitePoints.geometry.attributes.alpha;
const backdropAlphaAttr = satelliteBackdropPoints.geometry.attributes.alpha;
if (!pointAlphaAttr?.array || !backdropAlphaAttr?.array) return;
const drawCount = satellitePoints.geometry.drawRange?.count ?? satelliteCapacity;
const count = Math.min(drawCount, satelliteCapacity, satellitePositions.length);
for (let i = 0; i < count; i += 1) {
const alpha = shouldHideSatellitePoint(i)
? 0
: getSatelliteTwinkleAlpha(i, isIdle);
pointAlphaAttr.array[i] = alpha;
backdropAlphaAttr.array[i] = alpha;
}
pointAlphaAttr.needsUpdate = true;
backdropAlphaAttr.needsUpdate = true;
}
export function updateSatellitePointSize() {
if (!satellitePoints || !cameraRef) return;
const camDist = cameraRef.position.length();
@@ -627,15 +672,57 @@ function getRequestedSatelliteLimit(limitOverride) {
return SATELLITE_CONFIG.maxCount < 0 ? null : SATELLITE_CONFIG.maxCount;
}
function createSatellitePositionState() {
function hashSatelliteUnit(index, salt = 0) {
const value = Math.sin((index + 1) * 12.9898 + salt * 78.233) * 43758.5453;
return value - Math.floor(value);
}
function createSatelliteTwinkleState(index) {
return {
phase: hashSatelliteUnit(index, 1) * Math.PI * 2,
secondaryPhase: hashSatelliteUnit(index, 2) * Math.PI * 2,
speed: 0.62 + hashSatelliteUnit(index, 3) * 1.45,
floor: hashSatelliteUnit(index, 4) * 0.22,
intensity: 0.48 + hashSatelliteUnit(index, 5) * 0.52,
};
}
function createSatellitePositionState(index = 0) {
return {
current: new THREE.Vector3(),
trail: [],
trailIndex: 0,
trailCount: 0,
twinkle: createSatelliteTwinkleState(index),
};
}
function getSatelliteTwinkleAlpha(index, isIdle = true) {
if (!satelliteIdleBreathingEnabled || !isIdle) return 1;
const twinkle = satellitePositions[index]?.twinkle || createSatelliteTwinkleState(index);
const primaryPulse = getBreathingPulse(
breathingPhase * twinkle.speed + twinkle.phase,
);
const secondaryPulse = getBreathingPulse(
breathingPhase * twinkle.speed * SATELLITE_TWINKLE_SECONDARY_SPEED +
twinkle.secondaryPhase,
);
const mixedPulse = THREE.MathUtils.clamp(
primaryPulse * (1 - SATELLITE_TWINKLE_SECONDARY_WEIGHT) +
secondaryPulse * SATELLITE_TWINKLE_SECONDARY_WEIGHT,
0,
1,
);
const shapedPulse = twinkle.floor + Math.pow(mixedPulse, 1.8) * twinkle.intensity;
return THREE.MathUtils.clamp(
SATELLITE_CONFIG.dotOpacityMin +
shapedPulse * (SATELLITE_CONFIG.dotOpacityMax - SATELLITE_CONFIG.dotOpacityMin),
SATELLITE_CONFIG.dotOpacityMin,
SATELLITE_CONFIG.dotOpacityMax,
);
}
function resetSatelliteTrailState() {
satellitePositions.forEach((position) => {
position.trail = [];
@@ -794,7 +881,7 @@ function ensureSatelliteCapacity(count) {
satellitePositions = Array.from({ length: nextCapacity }, (_, index) => {
const previousState = previousSatellitePositions[index];
if (!previousState) {
return createSatellitePositionState();
return createSatellitePositionState(index);
}
return {
@@ -802,6 +889,7 @@ function ensureSatelliteCapacity(count) {
trail: previousState.trail.slice(),
trailIndex: previousState.trailIndex,
trailCount: previousState.trailCount,
twinkle: previousState.twinkle || createSatelliteTwinkleState(index),
};
});
satelliteCapacity = nextCapacity;

View File

@@ -15,8 +15,8 @@ const DataSources = lazy(() => import('./pages/DataSources/DataSources'))
const DataList = lazy(() => import('./pages/DataList/DataList'))
const Earth = lazy(() => import('./pages/Earth/Earth'))
const Settings = lazy(() => import('./pages/Settings/Settings'))
const AISettings = lazy(() => import('./pages/AISettings/AISettings'))
const BGP = lazy(() => import('./pages/BGP/BGP'))
const Playground = lazy(() => import('./pages/Playground/Playground'))
const Logs = lazy(() => import('./pages/Logs/Logs'))
const Docs = lazy(() => import('./pages/Docs/Docs'))
@@ -61,7 +61,8 @@ function App() {
<Route path="/alerts/bgp" element={<BGPAlerts />} />
<Route path="/alerts/situational" element={<SituationalAlerts />} />
<Route path="/bgp" element={<BGP />} />
<Route path="/playground" element={<Playground />} />
<Route path="/ai" element={<AISettings />} />
<Route path="/playground" element={<Navigate to="/ai?tab=playground" replace />} />
<Route path="/logs" element={<Logs />} />
<Route path="/settings" element={<Settings />} />
<Route path="*" element={<Navigate to="/admin" replace />} />

View File

@@ -2,13 +2,13 @@ import { ReactNode, useMemo, useState } from 'react'
import { Layout, Menu, Typography, Button, Space } from 'antd'
import {
AlertOutlined,
ApiOutlined,
DashboardOutlined,
DatabaseOutlined,
UserOutlined,
SettingOutlined,
BarChartOutlined,
DeploymentUnitOutlined,
RobotOutlined,
MenuUnfoldOutlined,
MenuFoldOutlined,
GlobalOutlined,
@@ -84,7 +84,7 @@ function AppLayout({ children }: AppLayoutProps) {
icon: <ToolOutlined />,
label: '运维与配置',
children: [
{ key: '/playground', icon: <RobotOutlined />, label: 'AI Playground' },
{ key: '/ai', icon: <ApiOutlined />, label: 'AI' },
...(isSuperAdmin ? [{ key: '/logs', icon: <FileTextOutlined />, label: '系统日志' }] : []),
{ key: '/users', icon: <UserOutlined />, label: '用户管理' },
{ key: '/settings', icon: <SettingOutlined />, label: '系统配置' },

View File

@@ -335,6 +335,11 @@ body {
min-height: 0;
}
.playground-page--embedded {
display: flex;
flex-direction: column;
}
.playground-page__grid {
flex: 1 1 auto;
min-height: 0;
@@ -349,6 +354,10 @@ body {
overflow: hidden;
}
.playground-page__body--embedded {
height: 100%;
}
.playground-shell {
flex: 1 1 auto;
height: 100%;

View File

@@ -0,0 +1,824 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react'
import {
ApiOutlined,
EyeInvisibleOutlined,
EyeOutlined,
PlayCircleOutlined,
SyncOutlined,
ToolOutlined,
} from '@ant-design/icons'
import {
Alert,
Button,
Card,
Checkbox,
Form,
Input,
InputNumber,
message,
Select,
Space,
Switch,
Tabs,
Tag,
Typography,
} from 'antd'
import axios from 'axios'
import { useSearchParams } from 'react-router-dom'
import AppLayout from '../../components/AppLayout/AppLayout'
import Scrollbar from '../../components/Scrollbar/Scrollbar'
import { PlaygroundWorkspace } from '../Playground/Playground'
const { Title, Text } = Typography
const ANTHROPIC_MESSAGES_MAX_TOKENS = 1200
const DEFAULT_PROVIDER_MAX_TOKENS = 4096
interface SecretStatus {
configured: boolean
preview: string
source?: string
}
interface AIProviderConfig {
provider: string
provider_api: string
base_url: string
model: string
api_key: SecretStatus
max_tokens: number
anthropic_version: string
source?: string
}
interface WebSearchProviderConfig {
provider: string
base_url: string
api_key: SecretStatus
max_results: number
timeout_seconds: number
endpoint_path?: string
search_depth?: string
engine?: string
include_answer?: boolean
include_raw_content?: boolean
include_text?: boolean
categories?: string
engines?: string[]
search_path?: string
scrape_path?: string
scrape_formats?: string[]
source?: string
}
interface ExternalIntegrations {
ai_provider: {
service_url: string
service_token: SecretStatus
default_provider: string
provider: string
provider_api: string
base_url: string
model: string
api_key: SecretStatus
providers: Record<string, AIProviderConfig>
max_tokens: number
anthropic_version: string
timeout_seconds: number
retry_attempts: number
source: string
}
barentswatch: {
endpoint: string
client_id: string
client_secret: SecretStatus
source: string
}
web_search: {
enabled: boolean
default_provider: string
provider: string
base_url: string
api_key: SecretStatus
providers: Record<string, WebSearchProviderConfig>
max_results: number
timeout_seconds: number
endpoint_path: string
search_depth: string
engine: string
include_answer: boolean
include_raw_content: boolean
include_text: boolean
categories: string
engines: string[]
search_path: string
scrape_path: string
scrape_formats: string[]
source: string
}
}
interface AIProviderPreset {
provider: string
label: string
provider_api: string
base_url: string
model: string
models: string[]
api_key_env: string
source: string
refresh_error?: string
}
interface WebSearchPreset {
provider: string
label: string
api_key_env: string
base_url: string
endpoint_path?: string
search_path?: string
scrape_path?: string
max_results: number
timeout_seconds: number
search_depth?: string
engine?: string
include_answer?: boolean
include_raw_content?: boolean
include_text?: boolean
categories?: string
engines?: string[]
scrape_formats?: string[]
}
function AISettingsPanel({ loading, children }: { loading: boolean; children: ReactNode }) {
return (
<div className="settings-pane">
<Card className="settings-panel-card" loading={loading}>
<Scrollbar className="settings-panel-scroll">{children}</Scrollbar>
</Card>
</div>
)
}
export default function AISettings() {
const [searchParams, setSearchParams] = useSearchParams()
const [form] = Form.useForm()
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [integrations, setIntegrations] = useState<ExternalIntegrations | null>(null)
const [aiProviderPresets, setAiProviderPresets] = useState<AIProviderPreset[]>([])
const [webSearchPresets, setWebSearchPresets] = useState<WebSearchPreset[]>([])
const [refreshingAiPreset, setRefreshingAiPreset] = useState(false)
const [testingAiProviderConnection, setTestingAiProviderConnection] = useState(false)
const [testingWebSearchConnection, setTestingWebSearchConnection] = useState(false)
const [feedback, setFeedback] = useState<{ type: 'success' | 'error'; message: string } | null>(null)
const [revealedAiProviderSecrets, setRevealedAiProviderSecrets] = useState<Record<string, { api_key: string; service_token: string }>>({})
const [revealedWebSearchSecrets, setRevealedWebSearchSecrets] = useState<Record<string, { api_key: string }>>({})
const [aiProviderApiKeyRevealed, setAiProviderApiKeyRevealed] = useState(false)
const [serviceTokenRevealed, setServiceTokenRevealed] = useState(false)
const [webSearchApiKeyRevealed, setWebSearchApiKeyRevealed] = useState(false)
const selectedAiProvider = Form.useWatch(['ai_provider', 'provider'], form)
const selectedWebSearchProvider = Form.useWatch(['web_search', 'provider'], form)
const selectedAiProviderSecret = selectedAiProvider
? integrations?.ai_provider.providers?.[selectedAiProvider]?.api_key || integrations?.ai_provider.api_key
: integrations?.ai_provider.api_key
const selectedWebSearchSecret = selectedWebSearchProvider
? integrations?.web_search.providers?.[selectedWebSearchProvider]?.api_key || integrations?.web_search.api_key
: integrations?.web_search.api_key
const activeTab = useMemo(() => {
const tab = searchParams.get('tab') || 'providers'
return new Set(['providers', 'tools', 'playground']).has(tab) ? tab : 'providers'
}, [searchParams])
const fetchAISettings = async () => {
try {
setLoading(true)
const [settingsResponse, aiPresetsResponse, webPresetsResponse] = await Promise.all([
axios.get('/api/v1/settings'),
axios.get('/api/v1/settings/integrations/ai-provider/presets'),
axios.get('/api/v1/settings/integrations/web-search/presets'),
])
setIntegrations(settingsResponse.data.integrations || null)
setAiProviderPresets(aiPresetsResponse.data.data || [])
setWebSearchPresets(webPresetsResponse.data.data || [])
} catch {
message.error('获取 AI 配置失败')
} finally {
setLoading(false)
}
}
useEffect(() => {
void fetchAISettings()
}, [])
useEffect(() => {
if (loading || !integrations) return
form.setFieldsValue({
ai_provider: {
service_url: integrations.ai_provider.service_url,
service_token: integrations.ai_provider.service_token.configured
? integrations.ai_provider.service_token.preview
: '',
default_provider: integrations.ai_provider.default_provider || integrations.ai_provider.provider,
provider: integrations.ai_provider.provider,
provider_api: integrations.ai_provider.provider_api,
base_url: integrations.ai_provider.base_url,
model: integrations.ai_provider.model,
api_key: integrations.ai_provider.api_key.configured
? integrations.ai_provider.api_key.preview
: '',
max_tokens: integrations.ai_provider.max_tokens,
anthropic_version: integrations.ai_provider.anthropic_version,
timeout_seconds: integrations.ai_provider.timeout_seconds,
retry_attempts: integrations.ai_provider.retry_attempts,
},
web_search: {
enabled: integrations.web_search.enabled,
default_provider: integrations.web_search.default_provider || integrations.web_search.provider,
provider: integrations.web_search.provider,
base_url: integrations.web_search.base_url,
api_key: integrations.web_search.api_key.configured
? integrations.web_search.api_key.preview
: '',
max_results: integrations.web_search.max_results,
timeout_seconds: integrations.web_search.timeout_seconds,
endpoint_path: integrations.web_search.endpoint_path,
search_depth: integrations.web_search.search_depth,
engine: integrations.web_search.engine,
include_answer: integrations.web_search.include_answer,
include_raw_content: integrations.web_search.include_raw_content,
include_text: integrations.web_search.include_text,
categories: integrations.web_search.categories,
engines: integrations.web_search.engines,
search_path: integrations.web_search.search_path,
scrape_path: integrations.web_search.scrape_path,
scrape_formats: integrations.web_search.scrape_formats,
},
})
}, [form, integrations, loading])
const isSecretDraftUnchanged = (
nextSecret: string,
savedPreview?: string,
revealedSecret?: string,
) => (
!nextSecret ||
nextSecret === savedPreview ||
nextSecret === revealedSecret ||
nextSecret.startsWith('••••') ||
nextSecret.includes('*')
)
const buildAiProviderDraftPayload = (values: any) => {
const selectedProvider = String(values.ai_provider?.provider || integrations?.ai_provider.provider || 'minimax')
const providerSecret = integrations?.ai_provider.providers?.[selectedProvider]?.api_key
|| integrations?.ai_provider.api_key
const revealedSecrets = revealedAiProviderSecrets[selectedProvider]
const nextApiKey = String(values.ai_provider?.api_key || '').trim()
const apiKeyUnchanged = isSecretDraftUnchanged(
nextApiKey,
providerSecret?.preview,
revealedSecrets?.api_key,
)
const nextServiceToken = String(values.ai_provider?.service_token || '').trim()
const serviceTokenUnchanged = isSecretDraftUnchanged(
nextServiceToken,
integrations?.ai_provider.service_token.preview,
revealedSecrets?.service_token,
)
return {
...values.ai_provider,
default_provider: selectedProvider,
api_key: apiKeyUnchanged ? '' : nextApiKey,
service_token: serviceTokenUnchanged ? '' : nextServiceToken,
}
}
const buildWebSearchDraftPayload = (values: any) => {
const selectedProvider = String(values.web_search?.provider || integrations?.web_search.provider || 'tavily')
const providerSecret = integrations?.web_search.providers?.[selectedProvider]?.api_key
|| integrations?.web_search.api_key
const revealedSecrets = revealedWebSearchSecrets[selectedProvider]
const nextApiKey = String(values.web_search?.api_key || '').trim()
const apiKeyUnchanged = isSecretDraftUnchanged(
nextApiKey,
providerSecret?.preview,
revealedSecrets?.api_key,
)
return {
...values.web_search,
default_provider: selectedProvider,
api_key: apiKeyUnchanged ? '' : nextApiKey,
}
}
const buildIntegrationsPayload = (values: any) => ({
ai_provider: buildAiProviderDraftPayload(values),
web_search: buildWebSearchDraftPayload(values),
barentswatch: {
endpoint: integrations?.barentswatch.endpoint || '',
client_id: integrations?.barentswatch.client_id || '',
client_secret: '',
},
})
const saveAISettings = async (values: any) => {
try {
setSaving(true)
const response = await axios.put('/api/v1/settings/integrations', buildIntegrationsPayload(values))
setIntegrations(response.data.integrations)
setFeedback({ type: 'success', message: 'AI 配置已保存' })
message.success('AI 配置已保存')
await fetchAISettings()
} catch {
setFeedback({ type: 'error', message: 'AI 配置保存失败' })
message.error('AI 配置保存失败')
} finally {
setSaving(false)
}
}
const revealAiProviderSecrets = async (provider: string) => {
const cached = revealedAiProviderSecrets[provider]
if (cached) return cached
const response = await axios.get('/api/v1/settings/integrations/ai-provider/secrets', {
params: { provider },
})
const secrets = {
api_key: String(response.data.api_key || ''),
service_token: String(response.data.service_token || ''),
}
setRevealedAiProviderSecrets((prev) => ({ ...prev, [provider]: secrets }))
return secrets
}
const revealWebSearchSecrets = async (provider: string) => {
const cached = revealedWebSearchSecrets[provider]
if (cached) return cached
const response = await axios.get('/api/v1/settings/integrations/web-search/secrets', {
params: { provider },
})
const secrets = {
api_key: String(response.data.api_key || ''),
}
setRevealedWebSearchSecrets((prev) => ({ ...prev, [provider]: secrets }))
return secrets
}
const handleAiProviderApiKeyVisibleChange = async (visible: boolean) => {
const provider = String(form.getFieldValue(['ai_provider', 'provider']) || integrations?.ai_provider.provider || 'minimax')
const providerSecret = integrations?.ai_provider.providers?.[provider]?.api_key || integrations?.ai_provider.api_key
if (visible) {
try {
const secrets = await revealAiProviderSecrets(provider)
if (secrets.api_key) form.setFieldValue(['ai_provider', 'api_key'], secrets.api_key)
setAiProviderApiKeyRevealed(true)
} catch {
message.error('读取 LLM API Key 失败')
}
return
}
const currentValue = String(form.getFieldValue(['ai_provider', 'api_key']) || '')
const revealedValue = revealedAiProviderSecrets[provider]?.api_key
if (revealedValue && currentValue === revealedValue) {
form.setFieldValue(['ai_provider', 'api_key'], providerSecret?.preview || '')
}
setAiProviderApiKeyRevealed(false)
}
const handleServiceTokenVisibleChange = async (visible: boolean) => {
const provider = String(form.getFieldValue(['ai_provider', 'provider']) || integrations?.ai_provider.provider || 'minimax')
if (visible) {
try {
const secrets = await revealAiProviderSecrets(provider)
if (secrets.service_token) form.setFieldValue(['ai_provider', 'service_token'], secrets.service_token)
setServiceTokenRevealed(true)
} catch {
message.error('读取代理 Token 失败')
}
return
}
const currentValue = String(form.getFieldValue(['ai_provider', 'service_token']) || '')
const revealedValue = revealedAiProviderSecrets[provider]?.service_token
if (revealedValue && currentValue === revealedValue) {
form.setFieldValue(['ai_provider', 'service_token'], integrations?.ai_provider.service_token.preview || '')
}
setServiceTokenRevealed(false)
}
const handleWebSearchApiKeyVisibleChange = async (visible: boolean) => {
const provider = String(form.getFieldValue(['web_search', 'provider']) || integrations?.web_search.provider || 'tavily')
const providerSecret = integrations?.web_search.providers?.[provider]?.api_key || integrations?.web_search.api_key
if (visible) {
try {
const secrets = await revealWebSearchSecrets(provider)
if (secrets.api_key) form.setFieldValue(['web_search', 'api_key'], secrets.api_key)
setWebSearchApiKeyRevealed(true)
} catch {
message.error('读取 WebSearch API Key 失败')
}
return
}
const currentValue = String(form.getFieldValue(['web_search', 'api_key']) || '')
const revealedValue = revealedWebSearchSecrets[provider]?.api_key
if (revealedValue && currentValue === revealedValue) {
form.setFieldValue(['web_search', 'api_key'], providerSecret?.preview || '')
}
setWebSearchApiKeyRevealed(false)
}
const applyAiProviderSelection = (provider: string, presetOverride?: AIProviderPreset) => {
const preset = presetOverride || aiProviderPresets.find((item) => item.provider === provider)
const savedProvider = integrations?.ai_provider.providers?.[provider]
const providerApi = savedProvider?.provider_api || preset?.provider_api || 'openai-completions'
const apiKey = savedProvider?.api_key.configured ? savedProvider.api_key.preview : ''
form.setFieldsValue({
ai_provider: {
default_provider: provider,
provider,
provider_api: providerApi,
base_url: savedProvider?.base_url || preset?.base_url || '',
model: savedProvider?.model || preset?.model || '',
api_key: apiKey,
max_tokens: savedProvider?.max_tokens || (providerApi === 'anthropic-messages'
? ANTHROPIC_MESSAGES_MAX_TOKENS
: DEFAULT_PROVIDER_MAX_TOKENS),
anthropic_version: savedProvider?.anthropic_version || '2023-06-01',
},
})
setAiProviderApiKeyRevealed(false)
}
const applyWebSearchProviderSelection = (provider: string, presetOverride?: WebSearchPreset) => {
const preset = presetOverride || webSearchPresets.find((item) => item.provider === provider)
const savedProvider = integrations?.web_search.providers?.[provider]
const apiKey = savedProvider?.api_key.configured ? savedProvider.api_key.preview : ''
form.setFieldsValue({
web_search: {
default_provider: provider,
provider,
enabled: integrations?.web_search.enabled ?? false,
base_url: savedProvider?.base_url || preset?.base_url || '',
api_key: apiKey,
max_results: savedProvider?.max_results || preset?.max_results || 5,
timeout_seconds: savedProvider?.timeout_seconds || preset?.timeout_seconds || 20,
endpoint_path: savedProvider?.endpoint_path || preset?.endpoint_path || '',
search_depth: savedProvider?.search_depth || preset?.search_depth || 'basic',
engine: savedProvider?.engine || preset?.engine || 'google',
include_answer: savedProvider?.include_answer ?? preset?.include_answer ?? false,
include_raw_content: savedProvider?.include_raw_content ?? preset?.include_raw_content ?? false,
include_text: savedProvider?.include_text ?? preset?.include_text ?? false,
categories: savedProvider?.categories || preset?.categories || 'general',
engines: savedProvider?.engines || preset?.engines || [],
search_path: savedProvider?.search_path || preset?.search_path || '',
scrape_path: savedProvider?.scrape_path || preset?.scrape_path || '',
scrape_formats: savedProvider?.scrape_formats || preset?.scrape_formats || ['markdown'],
},
})
setWebSearchApiKeyRevealed(false)
}
const refreshSelectedAiProviderPreset = async () => {
const provider = form.getFieldValue(['ai_provider', 'provider'])
if (!provider) return
try {
setRefreshingAiPreset(true)
const response = await axios.post(`/api/v1/settings/integrations/ai-provider/presets/${provider}/refresh`)
const preset = response.data.data as AIProviderPreset
setAiProviderPresets((prev) => {
const next = prev.filter((item) => item.provider !== preset.provider)
return [...next, preset].sort((a, b) => a.label.localeCompare(b.label))
})
applyAiProviderSelection(preset.provider, preset)
if (preset.refresh_error) {
message.warning('刷新失败,已使用本地 fallback 配置')
} else {
message.success('已刷新选中 Provider 的最新模型配置')
}
} catch {
message.error('刷新 Provider 配置失败')
} finally {
setRefreshingAiPreset(false)
}
}
const testAiProviderConnection = async () => {
try {
setTestingAiProviderConnection(true)
const values = form.getFieldsValue(true)
const response = await axios.post(
'/api/v1/settings/integrations/ai-provider/connect',
buildAiProviderDraftPayload(values),
)
if (response.data.success || response.data.connected) {
if (response.data.integrations) {
setIntegrations(response.data.integrations)
await fetchAISettings()
}
setFeedback({ type: 'success', message: response.data.message || 'AI Provider 连接成功' })
message.success(response.data.message || 'AI Provider 连接成功')
} else {
setFeedback({ type: 'error', message: response.data.message || 'AI Provider 连接失败' })
message.error(response.data.message || 'AI Provider 连接失败')
}
} catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string; message?: string } } }
const errorMessage = err.response?.data?.message || err.response?.data?.detail || 'AI Provider 连接失败'
setFeedback({ type: 'error', message: errorMessage })
message.error(errorMessage)
} finally {
setTestingAiProviderConnection(false)
}
}
const testWebSearchConnection = async () => {
try {
setTestingWebSearchConnection(true)
const values = form.getFieldsValue(true)
const response = await axios.post(
'/api/v1/settings/integrations/web-search/connect',
buildWebSearchDraftPayload(values),
)
if (response.data.success || response.data.connected) {
setFeedback({ type: 'success', message: response.data.message || 'WebSearch 连接成功' })
message.success(response.data.message || 'WebSearch 连接成功')
} else {
setFeedback({ type: 'error', message: response.data.message || 'WebSearch 连接失败' })
message.error(response.data.message || 'WebSearch 连接失败')
}
} catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string; message?: string } } }
const errorMessage = err.response?.data?.message || err.response?.data?.detail || 'WebSearch 连接失败'
setFeedback({ type: 'error', message: errorMessage })
message.error(errorMessage)
} finally {
setTestingWebSearchConnection(false)
}
}
const tabItems = [
{
key: 'providers',
label: '模型供应商',
forceRender: true,
children: (
<AISettingsPanel loading={loading}>
<Form form={form} layout="vertical" onFinish={saveAISettings} onValuesChange={() => setFeedback(null)}>
<Card size="small" title={<Space><ApiOutlined />LLM Provider</Space>}>
<Form.Item name={['ai_provider', 'provider']} label="Provider">
<Select
showSearch
optionFilterProp="label"
options={aiProviderPresets.map((preset) => ({
value: preset.provider,
label: `${preset.label} · ${preset.provider_api}`,
}))}
onChange={(value) => applyAiProviderSelection(value)}
/>
</Form.Item>
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: '0 12px', alignItems: 'end' }}>
<Form.Item name={['ai_provider', 'base_url']} label="LLM Base URL">
<Input placeholder="https://api.example.com/v1" />
</Form.Item>
<Form.Item label=" ">
<Space>
<Button icon={<SyncOutlined />} loading={refreshingAiPreset} onClick={refreshSelectedAiProviderPreset}>
</Button>
<Button icon={<PlayCircleOutlined />} loading={testingAiProviderConnection} onClick={() => { void testAiProviderConnection() }}>
</Button>
</Space>
</Form.Item>
</div>
<Form.Item name={['ai_provider', 'provider_api']} label="协议适配">
<Select>
<Select.Option value="openai-completions">OpenAI Chat Completions</Select.Option>
<Select.Option value="anthropic-messages">Anthropic Messages</Select.Option>
<Select.Option value="ollama-generate">Ollama Generate</Select.Option>
</Select>
</Form.Item>
<Form.Item name={['ai_provider', 'model']} label="默认模型">
<Select
showSearch
optionFilterProp="label"
options={(
aiProviderPresets.find((preset) => preset.provider === selectedAiProvider)?.models || []
).map((model) => ({ value: model, label: model }))}
/>
</Form.Item>
<Form.Item label="LLM API Key">
<Space direction="vertical" style={{ width: '100%' }}>
<Space>
<Tag color={selectedAiProviderSecret?.configured ? 'green' : 'default'}>
{selectedAiProviderSecret?.configured ? '已配置' : '未配置'}
</Tag>
<Text type="secondary"> Provider key</Text>
</Space>
<Form.Item name={['ai_provider', 'api_key']} noStyle>
<Input
autoComplete="new-password"
placeholder="输入新的 LLM API key"
suffix={(
<Button
type="text"
size="small"
icon={aiProviderApiKeyRevealed ? <EyeInvisibleOutlined /> : <EyeOutlined />}
onClick={() => { void handleAiProviderApiKeyVisibleChange(!aiProviderApiKeyRevealed) }}
aria-label={aiProviderApiKeyRevealed ? '隐藏 LLM API key' : '显示 LLM API key'}
/>
)}
/>
</Form.Item>
</Space>
</Form.Item>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }}>
<Form.Item name={['ai_provider', 'max_tokens']} label="最大输出 Tokens">
<InputNumber min={1} max={200000} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name={['ai_provider', 'anthropic_version']} label="Anthropic Version">
<Input />
</Form.Item>
<Form.Item name={['ai_provider', 'timeout_seconds']} label="超时(秒)">
<InputNumber min={5} max={600} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name={['ai_provider', 'retry_attempts']} label="重试次数">
<InputNumber min={1} max={10} style={{ width: '100%' }} />
</Form.Item>
</div>
<Card size="small" type="inner" title="本地 aiprovider 代理" style={{ marginTop: 8 }}>
<Form.Item name={['ai_provider', 'service_url']} label="代理地址">
<Input placeholder="http://localhost:8010" />
</Form.Item>
<Form.Item label="代理 Token">
<Space direction="vertical" style={{ width: '100%' }}>
<Space>
<Tag color={integrations?.ai_provider.service_token.configured ? 'green' : 'default'}>
{integrations?.ai_provider.service_token.configured ? '已配置' : '未配置'}
</Tag>
<Text type="secondary"> backend aiprovider</Text>
</Space>
<Form.Item name={['ai_provider', 'service_token']} noStyle>
<Input
autoComplete="new-password"
placeholder="输入新的代理 token"
suffix={(
<Button
type="text"
size="small"
icon={serviceTokenRevealed ? <EyeInvisibleOutlined /> : <EyeOutlined />}
onClick={() => { void handleServiceTokenVisibleChange(!serviceTokenRevealed) }}
aria-label={serviceTokenRevealed ? '隐藏代理 token' : '显示代理 token'}
/>
)}
/>
</Form.Item>
</Space>
</Form.Item>
</Card>
</Card>
<Button type="primary" htmlType="submit" loading={saving} style={{ marginTop: 16 }}>
AI
</Button>
{feedback ? <Alert showIcon type={feedback.type} message={feedback.message} style={{ marginTop: 12 }} /> : null}
</Form>
</AISettingsPanel>
),
},
{
key: 'tools',
label: '工具',
forceRender: true,
children: (
<AISettingsPanel loading={loading}>
<Form form={form} layout="vertical" onFinish={saveAISettings} onValuesChange={() => setFeedback(null)}>
<Card size="small" title={<Space><ToolOutlined />WebSearch </Space>}>
<Form.Item name={['web_search', 'enabled']} label="启用 WebSearch" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name={['web_search', 'provider']} label="WebSearch Provider">
<Select
showSearch
optionFilterProp="label"
options={webSearchPresets.map((preset) => ({
value: preset.provider,
label: preset.label,
}))}
onChange={(value) => applyWebSearchProviderSelection(value)}
/>
</Form.Item>
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: '0 12px', alignItems: 'end' }}>
<Form.Item name={['web_search', 'base_url']} label="API Base URL">
<Input placeholder="https://api.tavily.com" />
</Form.Item>
<Form.Item label=" ">
<Button icon={<PlayCircleOutlined />} loading={testingWebSearchConnection} onClick={() => { void testWebSearchConnection() }}>
</Button>
</Form.Item>
</div>
<Form.Item label="WebSearch API Key">
<Space direction="vertical" style={{ width: '100%' }}>
<Space>
<Tag color={selectedWebSearchSecret?.configured ? 'green' : 'default'}>
{selectedWebSearchSecret?.configured ? '已配置' : '未配置'}
</Tag>
<Text type="secondary"> WebSearch Provider key</Text>
</Space>
<Form.Item name={['web_search', 'api_key']} noStyle>
<Input
autoComplete="new-password"
placeholder="输入新的 WebSearch API keySearXNG 可留空"
suffix={(
<Button
type="text"
size="small"
icon={webSearchApiKeyRevealed ? <EyeInvisibleOutlined /> : <EyeOutlined />}
onClick={() => { void handleWebSearchApiKeyVisibleChange(!webSearchApiKeyRevealed) }}
aria-label={webSearchApiKeyRevealed ? '隐藏 WebSearch API key' : '显示 WebSearch API key'}
/>
)}
/>
</Form.Item>
</Space>
</Form.Item>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }}>
<Form.Item name={['web_search', 'max_results']} label="最大结果数">
<InputNumber min={1} max={20} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name={['web_search', 'timeout_seconds']} label="超时(秒)">
<InputNumber min={3} max={120} style={{ width: '100%' }} />
</Form.Item>
</div>
<Card size="small" type="inner" title="高级选项" style={{ marginTop: 8 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }}>
<Form.Item name={['web_search', 'endpoint_path']} label="Endpoint Path">
<Input placeholder="/search" />
</Form.Item>
<Form.Item name={['web_search', 'search_depth']} label="Search Depth">
<Input placeholder="basic" />
</Form.Item>
<Form.Item name={['web_search', 'engine']} label="SerpAPI Engine">
<Input placeholder="google" />
</Form.Item>
<Form.Item name={['web_search', 'categories']} label="SearXNG Categories">
<Input placeholder="general" />
</Form.Item>
<Form.Item name={['web_search', 'search_path']} label="Firecrawl Search Path">
<Input placeholder="/v2/search" />
</Form.Item>
<Form.Item name={['web_search', 'scrape_path']} label="Firecrawl Scrape Path">
<Input placeholder="/v2/scrape" />
</Form.Item>
</div>
<Space wrap>
<Form.Item name={['web_search', 'include_answer']} valuePropName="checked" style={{ marginBottom: 0 }}>
<Checkbox> Answer</Checkbox>
</Form.Item>
<Form.Item name={['web_search', 'include_raw_content']} valuePropName="checked" style={{ marginBottom: 0 }}>
<Checkbox> Raw Content</Checkbox>
</Form.Item>
<Form.Item name={['web_search', 'include_text']} valuePropName="checked" style={{ marginBottom: 0 }}>
<Checkbox> Result Text</Checkbox>
</Form.Item>
</Space>
</Card>
</Card>
<Button type="primary" htmlType="submit" loading={saving} style={{ marginTop: 16 }}>
Tool
</Button>
{feedback ? <Alert showIcon type={feedback.type} message={feedback.message} style={{ marginTop: 12 }} /> : null}
</Form>
</AISettingsPanel>
),
},
{
key: 'playground',
label: '测试台',
forceRender: true,
children: <PlaygroundWorkspace embedded />,
},
]
return (
<AppLayout>
<div className="page-shell settings-shell">
<div className="page-shell__header">
<div>
<Title level={3} style={{ marginBottom: 4 }}>AI</Title>
<Text type="secondary"> LLM Provider</Text>
</div>
</div>
<div className="page-shell__body settings-tabs-shell">
<Tabs
className="settings-tabs"
activeKey={activeTab}
onChange={(tabKey) => setSearchParams(tabKey === 'providers' ? {} : { tab: tabKey }, { replace: true })}
items={tabItems}
/>
</div>
</div>
</AppLayout>
)
}

View File

@@ -216,7 +216,7 @@ function toPlaygroundMessage(item: PlaygroundApiMessage): PlaygroundMessage {
}
}
function Playground() {
export function PlaygroundWorkspace({ embedded = false }: { embedded?: boolean } = {}) {
const [messageApi, contextHolder] = message.useMessage()
const { token } = useAuthStore()
const [statusLoading, setStatusLoading] = useState(false)
@@ -634,7 +634,7 @@ function Playground() {
type="warning"
showIcon
message="AI Provider 尚未配置完整"
description={<Link to="/settings?tab=ai"> AI </Link>}
description={<Link to="/ai"> AI </Link>}
/>
) : null}
</div>
@@ -643,7 +643,7 @@ function Playground() {
type="warning"
showIcon
message="尚未获取到 AI Provider 状态"
description={<Link to="/settings?tab=ai"> AI </Link>}
description={<Link to="/ai"> AI </Link>}
/>
)}
</Spin>
@@ -683,20 +683,22 @@ function Playground() {
/>
)
return (
<AppLayout>
const content = (
<>
{contextHolder}
<div className="page-shell playground-page">
<div className={embedded ? 'playground-page playground-page--embedded' : 'page-shell playground-page'}>
{!embedded ? (
<div className="page-shell__header playground-page__header">
<div>
<Title level={3} style={{ marginBottom: 4 }}>AI Playground</Title>
<Title level={3} style={{ marginBottom: 4 }}>AI </Title>
<Text type="secondary">
AI Chatbox AI Provider
</Text>
</div>
</div>
) : null}
<div className="page-shell__body playground-page__body">
<div className={embedded ? 'playground-page__body playground-page__body--embedded' : 'page-shell__body playground-page__body'}>
<div className="playground-shell">
<div className="playground-shell__sidebar">
{providerStatusPanel}
@@ -1096,8 +1098,22 @@ function Playground() {
</div>
) : null}
</Modal>
</>
)
if (embedded) {
return content
}
return (
<AppLayout>
{content}
</AppLayout>
)
}
function Playground() {
return <PlaygroundWorkspace />
}
export default Playground

View File

@@ -41,7 +41,7 @@ import Scrollbar from '../../components/Scrollbar/Scrollbar'
import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion'
import MarkdownRenderer from '../../components/MarkdownRenderer/MarkdownRenderer'
import { formatDateTimeZhCN } from '../../utils/datetime'
import { useSearchParams } from 'react-router-dom'
import { useNavigate, useSearchParams } from 'react-router-dom'
const { Title, Text } = Typography
const ANTHROPIC_MESSAGES_MAX_TOKENS = 1200
@@ -145,6 +145,26 @@ interface AIProviderConfig {
source?: string
}
interface WebSearchProviderConfig {
provider: string
base_url: string
api_key: SecretStatus
max_results: number
timeout_seconds: number
endpoint_path?: string
search_depth?: string
engine?: string
include_answer?: boolean
include_raw_content?: boolean
include_text?: boolean
categories?: string
engines?: string[]
search_path?: string
scrape_path?: string
scrape_formats?: string[]
source?: string
}
interface ExternalIntegrations {
ai_provider: {
service_url: string
@@ -168,6 +188,28 @@ interface ExternalIntegrations {
client_secret: SecretStatus
source: string
}
web_search: {
enabled: boolean
default_provider: string
provider: string
base_url: string
api_key: SecretStatus
providers: Record<string, WebSearchProviderConfig>
max_results: number
timeout_seconds: number
endpoint_path: string
search_depth: string
engine: string
include_answer: boolean
include_raw_content: boolean
include_text: boolean
categories: string
engines: string[]
search_path: string
scrape_path: string
scrape_formats: string[]
source: string
}
}
interface AIProviderPreset {
@@ -182,12 +224,35 @@ interface AIProviderPreset {
refresh_error?: string
}
interface WebSearchPreset {
provider: string
label: string
api_key_env: string
base_url: string
endpoint_path?: string
search_path?: string
scrape_path?: string
max_results: number
timeout_seconds: number
search_depth?: string
engine?: string
include_answer?: boolean
include_raw_content?: boolean
include_text?: boolean
categories?: string
engines?: string[]
scrape_formats?: string[]
}
interface CredentialGuide {
provider: string
title: string
markdown: string
prompt: string
source: string
sources?: Array<{ title?: string; url?: string; snippet?: string }>
verification_status?: string
verification_error?: string | null
}
interface CollectorConfigOption {
@@ -326,6 +391,7 @@ function SettingsPanel({
}
function Settings() {
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
const requestedTab = searchParams.get('tab') || 'display'
const requestedCollector = searchParams.get('collector') || ''
@@ -339,6 +405,7 @@ function Settings() {
const [tvSettings, setTvSettings] = useState<TVSettings | null>(null)
const [integrations, setIntegrations] = useState<ExternalIntegrations | null>(null)
const [aiProviderPresets, setAiProviderPresets] = useState<AIProviderPreset[]>([])
const [webSearchPresets, setWebSearchPresets] = useState<WebSearchPreset[]>([])
const [collectorConfigs, setCollectorConfigs] = useState<CollectorConfigOption[]>([])
const [selectedCollectorSource, setSelectedCollectorSource] = useState<string>('barentswatch_vessels')
const [savingCollectorConfig, setSavingCollectorConfig] = useState(false)
@@ -347,9 +414,13 @@ function Settings() {
const [savingTvSettings, setSavingTvSettings] = useState(false)
const [savingIntegrations, setSavingIntegrations] = useState(false)
const [aiProviderSaveFeedback, setAiProviderSaveFeedback] = useState<{ type: 'success' | 'error'; message: string } | null>(null)
const [webSearchSaveFeedback, setWebSearchSaveFeedback] = useState<{ type: 'success' | 'error'; message: string } | null>(null)
const [testingAiProviderConnection, setTestingAiProviderConnection] = useState(false)
const [testingWebSearchConnection, setTestingWebSearchConnection] = useState(false)
const [revealedAiProviderSecrets, setRevealedAiProviderSecrets] = useState<Record<string, { api_key: string; service_token: string }>>({})
const [revealedWebSearchSecrets, setRevealedWebSearchSecrets] = useState<Record<string, { api_key: string }>>({})
const [aiProviderApiKeyRevealed, setAiProviderApiKeyRevealed] = useState(false)
const [webSearchApiKeyRevealed, setWebSearchApiKeyRevealed] = useState(false)
const [serviceTokenRevealed, setServiceTokenRevealed] = useState(false)
const [testingCredentialProvider, setTestingCredentialProvider] = useState<string | null>(null)
const [credentialGuide, setCredentialGuide] = useState<CredentialGuide | null>(null)
@@ -369,9 +440,13 @@ function Settings() {
const [customSourceForm] = Form.useForm()
const [tvEditForm] = Form.useForm<TVStreamSource>()
const selectedAiProvider = Form.useWatch(['ai_provider', 'provider'], integrationForm)
const selectedWebSearchProvider = Form.useWatch(['web_search', 'provider'], integrationForm)
const selectedAiProviderSecret = selectedAiProvider
? integrations?.ai_provider.providers?.[selectedAiProvider]?.api_key || integrations?.ai_provider.api_key
: integrations?.ai_provider.api_key
const selectedWebSearchSecret = selectedWebSearchProvider
? integrations?.web_search.providers?.[selectedWebSearchProvider]?.api_key || integrations?.web_search.api_key
: integrations?.web_search.api_key
const customCollectors: CollectorSettings[] = useMemo(() => customSourceConfigs.map((config) => ({
id: -(config.config_id || 0),
name: config.name,
@@ -405,7 +480,6 @@ function Settings() {
'notifications',
'security',
'tv',
'ai',
'collector_credentials',
'collectors',
])
@@ -428,9 +502,10 @@ function Settings() {
const fetchSettings = async () => {
try {
setLoading(true)
const [response, presetsResponse, collectorConfigsResponse, customConfigsResponse] = await Promise.all([
const [response, presetsResponse, webSearchPresetsResponse, collectorConfigsResponse, customConfigsResponse] = await Promise.all([
axios.get('/api/v1/settings'),
axios.get('/api/v1/settings/integrations/ai-provider/presets'),
axios.get('/api/v1/settings/integrations/web-search/presets'),
axios.get('/api/v1/datasources/configs/all'),
axios.get('/api/v1/datasources/configs'),
])
@@ -441,6 +516,7 @@ function Settings() {
setIntegrations(response.data.integrations || null)
setCollectors(response.data.collectors || [])
setAiProviderPresets(presetsResponse.data.data || [])
setWebSearchPresets(webSearchPresetsResponse.data.data || [])
const builtinConfigs = collectorConfigsResponse.data.data || []
setCollectorConfigs(builtinConfigs)
const builtinNames = new Set((response.data.collectors || []).map((collector: CollectorSettings) => collector.source))
@@ -459,6 +535,12 @@ function Settings() {
fetchSettings()
}, [])
useEffect(() => {
if (requestedTab === 'ai') {
navigate('/ai', { replace: true })
}
}, [navigate, requestedTab])
useEffect(() => {
if (!loading && systemSettings) {
systemForm.setFieldsValue(systemSettings)
@@ -505,6 +587,28 @@ function Settings() {
? integrations.barentswatch.client_secret.preview
: '',
},
web_search: {
enabled: integrations.web_search.enabled,
default_provider: integrations.web_search.default_provider || integrations.web_search.provider,
provider: integrations.web_search.provider,
base_url: integrations.web_search.base_url,
api_key: integrations.web_search.api_key.configured
? integrations.web_search.api_key.preview
: '',
max_results: integrations.web_search.max_results,
timeout_seconds: integrations.web_search.timeout_seconds,
endpoint_path: integrations.web_search.endpoint_path,
search_depth: integrations.web_search.search_depth,
engine: integrations.web_search.engine,
include_answer: integrations.web_search.include_answer,
include_raw_content: integrations.web_search.include_raw_content,
include_text: integrations.web_search.include_text,
categories: integrations.web_search.categories,
engines: integrations.web_search.engines,
search_path: integrations.web_search.search_path,
scrape_path: integrations.web_search.scrape_path,
scrape_formats: integrations.web_search.scrape_formats,
},
})
}, [integrationForm, integrations, loading])
@@ -635,9 +739,11 @@ function Settings() {
try {
setSavingIntegrations(true)
const aiProviderPayload = buildAiProviderDraftPayload(values)
const webSearchPayload = buildWebSearchDraftPayload(values)
const payload = {
...values,
ai_provider: aiProviderPayload,
web_search: webSearchPayload,
barentswatch: {
...values.barentswatch,
client_secret:
@@ -649,33 +755,47 @@ function Settings() {
const response = await axios.put('/api/v1/settings/integrations', payload)
setIntegrations(response.data.integrations)
setAiProviderSaveFeedback({ type: 'success', message: 'AI 配置已保存为全局默认配置' })
setWebSearchSaveFeedback({ type: 'success', message: 'WebSearch 配置已保存' })
message.success('外部集成配置已保存')
await fetchSettings()
} catch {
setAiProviderSaveFeedback({ type: 'error', message: 'AI 配置保存失败' })
setWebSearchSaveFeedback({ type: 'error', message: 'WebSearch 配置保存失败' })
message.error('外部集成配置保存失败')
} finally {
setSavingIntegrations(false)
}
}
const isSecretDraftUnchanged = (
nextSecret: string,
savedPreview?: string,
revealedSecret?: string,
) => (
!nextSecret ||
nextSecret === savedPreview ||
nextSecret === revealedSecret ||
nextSecret.startsWith('••••') ||
nextSecret.includes('*')
)
const buildAiProviderDraftPayload = (values: any) => {
const selectedProvider = String(values.ai_provider?.provider || integrations?.ai_provider.provider || 'minimax')
const providerSecret = integrations?.ai_provider.providers?.[selectedProvider]?.api_key
|| integrations?.ai_provider.api_key
const revealedSecrets = revealedAiProviderSecrets[selectedProvider]
const nextApiKey = String(values.ai_provider?.api_key || '').trim()
const apiKeyUnchanged = !nextApiKey
|| nextApiKey === providerSecret?.preview
|| nextApiKey === revealedSecrets?.api_key
|| nextApiKey.startsWith('••••')
|| nextApiKey.includes('*')
const apiKeyUnchanged = isSecretDraftUnchanged(
nextApiKey,
providerSecret?.preview,
revealedSecrets?.api_key,
)
const nextServiceToken = String(values.ai_provider?.service_token || '').trim()
const serviceTokenUnchanged = !nextServiceToken
|| nextServiceToken === integrations?.ai_provider.service_token.preview
|| nextServiceToken === revealedSecrets?.service_token
|| nextServiceToken.startsWith('••••')
|| nextServiceToken.includes('*')
const serviceTokenUnchanged = isSecretDraftUnchanged(
nextServiceToken,
integrations?.ai_provider.service_token.preview,
revealedSecrets?.service_token,
)
return {
...values.ai_provider,
default_provider: selectedProvider,
@@ -684,6 +804,24 @@ function Settings() {
}
}
const buildWebSearchDraftPayload = (values: any) => {
const selectedProvider = String(values.web_search?.provider || integrations?.web_search.provider || 'tavily')
const providerSecret = integrations?.web_search.providers?.[selectedProvider]?.api_key
|| integrations?.web_search.api_key
const revealedSecrets = revealedWebSearchSecrets[selectedProvider]
const nextApiKey = String(values.web_search?.api_key || '').trim()
const apiKeyUnchanged = isSecretDraftUnchanged(
nextApiKey,
providerSecret?.preview,
revealedSecrets?.api_key,
)
return {
...values.web_search,
default_provider: selectedProvider,
api_key: apiKeyUnchanged ? '' : nextApiKey,
}
}
const revealAiProviderSecrets = async (provider: string) => {
const cached = revealedAiProviderSecrets[provider]
if (cached) return cached
@@ -743,6 +881,67 @@ function Settings() {
setServiceTokenRevealed(false)
}
const revealWebSearchSecrets = async (provider: string) => {
const cached = revealedWebSearchSecrets[provider]
if (cached) return cached
const response = await axios.get('/api/v1/settings/integrations/web-search/secrets', {
params: { provider },
})
const secrets = {
api_key: String(response.data.api_key || ''),
}
setRevealedWebSearchSecrets((prev) => ({ ...prev, [provider]: secrets }))
return secrets
}
const handleWebSearchApiKeyVisibleChange = async (visible: boolean) => {
const provider = String(integrationForm.getFieldValue(['web_search', 'provider']) || integrations?.web_search.provider || 'tavily')
const providerSecret = integrations?.web_search.providers?.[provider]?.api_key || integrations?.web_search.api_key
if (visible) {
try {
const secrets = await revealWebSearchSecrets(provider)
if (secrets.api_key) {
integrationForm.setFieldValue(['web_search', 'api_key'], secrets.api_key)
}
setWebSearchApiKeyRevealed(true)
} catch {
message.error('读取 WebSearch API Key 失败')
}
return
}
const currentValue = String(integrationForm.getFieldValue(['web_search', 'api_key']) || '')
const revealedValue = revealedWebSearchSecrets[provider]?.api_key
if (revealedValue && currentValue === revealedValue) {
integrationForm.setFieldValue(['web_search', 'api_key'], providerSecret?.preview || '')
}
setWebSearchApiKeyRevealed(false)
}
const testWebSearchConnection = async () => {
try {
setTestingWebSearchConnection(true)
const values = integrationForm.getFieldsValue(true)
const response = await axios.post(
'/api/v1/settings/integrations/web-search/connect',
buildWebSearchDraftPayload(values),
)
if (response.data.success || response.data.connected) {
setWebSearchSaveFeedback({ type: 'success', message: response.data.message || 'WebSearch 连接成功' })
message.success(response.data.message || 'WebSearch 连接成功')
} else {
setWebSearchSaveFeedback({ type: 'error', message: response.data.message || 'WebSearch 连接失败' })
message.error(response.data.message || 'WebSearch 连接失败')
}
} catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string; message?: string } } }
const errorMessage = err.response?.data?.message || err.response?.data?.detail || 'WebSearch 连接失败'
setWebSearchSaveFeedback({ type: 'error', message: errorMessage })
message.error(errorMessage)
} finally {
setTestingWebSearchConnection(false)
}
}
const testAiProviderConnection = async () => {
try {
setTestingAiProviderConnection(true)
@@ -1276,6 +1475,35 @@ function Settings() {
}
}
const applyWebSearchProviderSelection = (provider: string, presetOverride?: WebSearchPreset) => {
const preset = presetOverride || webSearchPresets.find((item) => item.provider === provider)
const savedProvider = integrations?.web_search.providers?.[provider]
const apiKey = savedProvider?.api_key.configured ? savedProvider.api_key.preview : ''
integrationForm.setFieldsValue({
web_search: {
default_provider: provider,
provider,
enabled: integrations?.web_search.enabled ?? false,
base_url: savedProvider?.base_url || preset?.base_url || '',
api_key: apiKey,
max_results: savedProvider?.max_results || preset?.max_results || 5,
timeout_seconds: savedProvider?.timeout_seconds || preset?.timeout_seconds || 20,
endpoint_path: savedProvider?.endpoint_path || preset?.endpoint_path || '',
search_depth: savedProvider?.search_depth || preset?.search_depth || 'basic',
engine: savedProvider?.engine || preset?.engine || 'google',
include_answer: savedProvider?.include_answer ?? preset?.include_answer ?? false,
include_raw_content: savedProvider?.include_raw_content ?? preset?.include_raw_content ?? false,
include_text: savedProvider?.include_text ?? preset?.include_text ?? false,
categories: savedProvider?.categories || preset?.categories || 'general',
engines: savedProvider?.engines || preset?.engines || [],
search_path: savedProvider?.search_path || preset?.search_path || '',
scrape_path: savedProvider?.scrape_path || preset?.scrape_path || '',
scrape_formats: savedProvider?.scrape_formats || preset?.scrape_formats || ['markdown'],
},
})
setWebSearchApiKeyRevealed(false)
}
const setDefaultSource = (sourceId: string) => {
if (!tvSettings) return
const next = { ...tvSettings, default_source_id: sourceId }
@@ -1801,7 +2029,10 @@ function Settings() {
form={integrationForm}
layout="vertical"
onFinish={saveIntegrations}
onValuesChange={() => setAiProviderSaveFeedback(null)}
onValuesChange={() => {
setAiProviderSaveFeedback(null)
setWebSearchSaveFeedback(null)
}}
>
<Card size="small" title={<Space><ApiOutlined />LLM Provider</Space>}>
<Form.Item name={['ai_provider', 'provider']} label="Provider">
@@ -1932,6 +2163,119 @@ function Settings() {
</Card>
</Card>
<Card size="small" title={<Space><ApiOutlined />WebSearch </Space>} style={{ marginTop: 16 }}>
<Form.Item name={['web_search', 'enabled']} label="启用 WebSearch" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name={['web_search', 'provider']} label="WebSearch Provider">
<Select
showSearch
optionFilterProp="label"
options={webSearchPresets.map((preset) => ({
value: preset.provider,
label: preset.label,
}))}
onChange={(value) => {
applyWebSearchProviderSelection(value)
}}
/>
</Form.Item>
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: '0 12px', alignItems: 'end' }}>
<Form.Item name={['web_search', 'base_url']} label="API Base URL">
<Input placeholder="https://api.tavily.com" />
</Form.Item>
<Form.Item label=" ">
<Button
icon={<PlayCircleOutlined />}
loading={testingWebSearchConnection}
onClick={() => { void testWebSearchConnection() }}
>
</Button>
</Form.Item>
</div>
<Form.Item label="WebSearch API Key">
<Space direction="vertical" style={{ width: '100%' }}>
<Space>
<Tag color={selectedWebSearchSecret?.configured ? 'green' : 'default'}>
{selectedWebSearchSecret?.configured ? '已配置' : '未配置'}
</Tag>
<Text type="secondary"> WebSearch Provider key</Text>
</Space>
<Form.Item name={['web_search', 'api_key']} noStyle>
<Input
autoComplete="new-password"
placeholder="输入新的 WebSearch API keySearXNG 可留空"
suffix={(
<Button
type="text"
size="small"
icon={webSearchApiKeyRevealed ? <EyeInvisibleOutlined /> : <EyeOutlined />}
onClick={() => { void handleWebSearchApiKeyVisibleChange(!webSearchApiKeyRevealed) }}
aria-label={webSearchApiKeyRevealed ? '隐藏 WebSearch API key' : '显示 WebSearch API key'}
/>
)}
/>
</Form.Item>
</Space>
</Form.Item>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }}>
<Form.Item name={['web_search', 'max_results']} label="最大结果数">
<InputNumber min={1} max={20} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name={['web_search', 'timeout_seconds']} label="超时(秒)">
<InputNumber min={3} max={120} style={{ width: '100%' }} />
</Form.Item>
</div>
<Card size="small" type="inner" title="高级选项" style={{ marginTop: 8 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }}>
<Form.Item name={['web_search', 'endpoint_path']} label="Endpoint Path">
<Input placeholder="/search" />
</Form.Item>
<Form.Item name={['web_search', 'search_depth']} label="Search Depth">
<Input placeholder="basic" />
</Form.Item>
<Form.Item name={['web_search', 'engine']} label="SerpAPI Engine">
<Input placeholder="google" />
</Form.Item>
<Form.Item name={['web_search', 'categories']} label="SearXNG Categories">
<Input placeholder="general" />
</Form.Item>
<Form.Item name={['web_search', 'search_path']} label="Firecrawl Search Path">
<Input placeholder="/v2/search" />
</Form.Item>
<Form.Item name={['web_search', 'scrape_path']} label="Firecrawl Scrape Path">
<Input placeholder="/v2/scrape" />
</Form.Item>
</div>
<Space wrap>
<Form.Item name={['web_search', 'include_answer']} valuePropName="checked" style={{ marginBottom: 0 }}>
<Checkbox> Answer</Checkbox>
</Form.Item>
<Form.Item name={['web_search', 'include_raw_content']} valuePropName="checked" style={{ marginBottom: 0 }}>
<Checkbox> Raw Content</Checkbox>
</Form.Item>
<Form.Item name={['web_search', 'include_text']} valuePropName="checked" style={{ marginBottom: 0 }}>
<Checkbox> Result Text</Checkbox>
</Form.Item>
</Space>
</Card>
{webSearchSaveFeedback ? (
<Alert
showIcon
type={webSearchSaveFeedback.type}
message={webSearchSaveFeedback.message}
style={{ marginTop: 12 }}
/>
) : null}
</Card>
<Button
type="primary"
htmlType="submit"
@@ -2336,7 +2680,7 @@ function Settings() {
className="settings-tabs"
activeKey={activeSettingsTab}
onChange={updateSettingsTab}
items={tabItems}
items={tabItems.filter((item) => item.key !== 'ai')}
/>
</div>
</div>
@@ -2374,9 +2718,28 @@ function Settings() {
) : (
<Alert showIcon type="info" message="当前显示默认教程;如果不适用,可以点击右下角“教程不好用”让 AI 重新生成。" />
)}
{credentialGuide?.verification_status === 'unverified_no_search_evidence' ? (
<Alert
showIcon
type="warning"
message="没有可用 WebSearch 证据,已保留默认教程。"
description={credentialGuide.verification_error || undefined}
/>
) : null}
<Card loading={credentialGuideLoading} size="small" style={{ maxHeight: 520, overflow: 'auto' }}>
<MarkdownRenderer markdown={credentialGuide?.markdown || ''} />
</Card>
{credentialGuide?.sources?.length ? (
<Card size="small" title="搜索证据">
<Space direction="vertical" style={{ width: '100%' }}>
{credentialGuide.sources.slice(0, 5).map((source) => (
<Text key={source.url || source.title} style={{ fontSize: 12 }}>
<a href={source.url} target="_blank" rel="noreferrer">{source.title || source.url}</a>
</Text>
))}
</Space>
</Card>
) : null}
{credentialGuide?.prompt ? (
<Text type="secondary" style={{ fontSize: 12 }}>
{credentialGuide.prompt}

View File

@@ -1,6 +1,6 @@
[project]
name = "planet"
version = "0.50.0"
version = "0.51.0"
description = "智能星球计划 - 态势感知系统"
requires-python = ">=3.14"
dependencies = [

2
uv.lock generated
View File

@@ -748,7 +748,7 @@ wheels = [
[[package]]
name = "planet"
version = "0.50.0"
version = "0.51.0"
source = { virtual = "." }
dependencies = [
{ name = "aiofiles" },