release: bump version to 0.51.0
This commit is contained in:
@@ -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,16 +324,28 @@ 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
|
||||
if llm_failure_reason is None:
|
||||
llm_failure_reason = f"LLM location factcheck unavailable: {exc}"
|
||||
attempted_queries = [
|
||||
*attempted_queries,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,16 +1883,28 @@ 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
|
||||
if llm_failure_reason is None:
|
||||
llm_failure_reason = f"LLM location factcheck unavailable: {exc}"
|
||||
attempted_queries = [
|
||||
*attempted_queries,
|
||||
|
||||
7
backend/app/services/ai_tools/__init__.py
Normal file
7
backend/app/services/ai_tools/__init__.py
Normal 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.
|
||||
"""
|
||||
|
||||
48
backend/app/services/ai_tools/evidence_store.py
Normal file
48
backend/app/services/ai_tools/evidence_store.py
Normal 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,
|
||||
}
|
||||
|
||||
63
backend/app/services/ai_tools/schemas.py
Normal file
63
backend/app/services/ai_tools/schemas.py
Normal 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)
|
||||
|
||||
56
backend/app/services/ai_tools/web_fetch.py
Normal file
56
backend/app/services/ai_tools/web_fetch.py
Normal 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",
|
||||
)
|
||||
|
||||
391
backend/app/services/ai_tools/web_search.py
Normal file
391
backend/app/services/ai_tools/web_search.py
Normal 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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
261
backend/tests/test_web_search_tools.py
Normal file
261
backend/tests/test_web_search_tools.py
Normal 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"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 接管成功 toast,refresh 自身报错只走 `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 观测站按活跃度调整点大小。
|
||||
|
||||
@@ -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 局域网地址怎么配?
|
||||
|
||||
@@ -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 / 态势感知相关服务集中在:
|
||||
|
||||
|
||||
@@ -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`。
|
||||
|
||||
具体可用配置取决于当前登录用户权限。
|
||||
|
||||
|
||||
@@ -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`:系统配置
|
||||
|
||||
|
||||
@@ -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 聚合 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planet-frontend",
|
||||
"version": "0.50.0",
|
||||
"version": "0.51.0",
|
||||
"private": true,
|
||||
"packageManager": "bun@1",
|
||||
"dependencies": {
|
||||
|
||||
@@ -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 |
@@ -879,6 +879,26 @@
|
||||
<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-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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
181
frontend/public/earth/js/controls.js
vendored
181
frontend/public/earth/js/controls.js
vendored
@@ -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);
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -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, '"');
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,10 +4274,40 @@ function setupEventListeners() {
|
||||
console.warn("预览候选位置失败:", error);
|
||||
});
|
||||
};
|
||||
const handleComputeCenterLocationSaved = () => {
|
||||
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);
|
||||
showStatusMessage("坐标已保存,图层自动刷新失败,可手动刷新页面或重新开关图层", "warning");
|
||||
console.warn("后台校准算力中心图层失败:", error);
|
||||
});
|
||||
return null;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn("即时生成算力中心交互物件失败,改用后台刷新:", error);
|
||||
showStatusMessage("坐标已保存,正在同步地图...", "info");
|
||||
refreshComputeCentersAfterLocationSave()
|
||||
.then(() => {
|
||||
showStatusMessage("算力中心坐标已保存", "success");
|
||||
})
|
||||
.catch((refreshError) => {
|
||||
console.warn("后台校准算力中心图层失败:", refreshError);
|
||||
});
|
||||
});
|
||||
};
|
||||
const handleComputeCenterUnresolvedCountChange = (event) => {
|
||||
@@ -4998,6 +5120,12 @@ function animate() {
|
||||
|
||||
updateSatellitePositions(deltaTime);
|
||||
updateBreathingPhase(deltaTime);
|
||||
updateSatelliteIdleBreathingVisual(
|
||||
!isDragging &&
|
||||
!hasActiveGlobeInertia() &&
|
||||
activeTouchPoints.size === 0 &&
|
||||
!pinchGesture,
|
||||
);
|
||||
updateSatellitePointSize();
|
||||
updateRelatedSatelliteHighlights();
|
||||
updateCelestialLayer(new Date(), camera);
|
||||
|
||||
@@ -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)
|
||||
// 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 (pattern) return applyPoseLatch(pattern, state);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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: '系统配置' },
|
||||
|
||||
@@ -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%;
|
||||
|
||||
824
frontend/src/pages/AISettings/AISettings.tsx
Normal file
824
frontend/src/pages/AISettings/AISettings.tsx
Normal 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 key;SearXNG 可留空"
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 key;SearXNG 可留空"
|
||||
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}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "planet"
|
||||
version = "0.50.0"
|
||||
version = "0.51.0"
|
||||
description = "智能星球计划 - 态势感知系统"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
|
||||
Reference in New Issue
Block a user